Использовать двойные кавычки или нет
Все уже сказано, но ещё не всеми— Карл Валентин
Именно в этом духе я пишу статью на ту же тему, что и статья Никиты Попов написанная 12 лет назад (если вы читали его статью, то дальше можете не читать).
Из-за чего весь шум
PHP выполняет интерполяцию строк, при которой ищет использование переменных в строке и заменяет их значением используемой переменной:
$juice = "apple";
echo "They drank some $juice juice.";
// будет выведено: They drank some apple juice.
Эта возможность ограничена строками в двойных кавычках и heredoc
. Использование одинарных кавычек (или nowdoc
) приведёт к другому результату:
$juice = "apple";
echo 'They drank some $juice juice.';
// будет выведено: They drank some $juice juice.
Посмотрите на это: PHP не будет искать переменные в строке с одинарными кавычками. Так что можно просто начать использовать одинарные кавычки везде. Поэтому люди начали предлагать изменения, подобные этим…
- $juice = "apple";
+ $juice = 'apple';
…потому что это будет быстрее и сэкономит кучу процессорных циклов при каждом выполнении кода, потому что PHP не ищет переменные в строках с одинарными кавычками (которых в примере всё равно нет), и все довольны, дело закрыто.
Дело закрыто
Очевидно, что есть разница в использовании одинарных и двойных кавычек, но для того, чтобы понять, что происходит, необходимо копнуть немного глубже.
Несмотря на то, что PHP является интерпретируемым языком, он использует этап компиляции, на котором определённые части играют друг с другом, чтобы получить то, что виртуальная машина может выполнить, а именно — опкоды. Так как же мы переходим от исходного кода PHP к опкодам?
Лексер
Лексер сканирует файл исходного кода и разбивает его на токены. Простой пример того, что это значит, можно найти в документации к функции token_get_all()
. Исходный код PHP, состоящий всего лишь из <?php echo "";
, превращается в эти токены:
T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ("")
Мы можем увидеть это в действии и поиграть с ним в сниппете на 3v4l.org.
Парсер
Парсер принимает эти токены и генерирует из них абстрактное синтаксическое дерево. AST представление, приведённого выше примера, выглядит следующим образом, если представить его в виде JSON:
{
"data": [
{
"nodeType": "Stmt_Echo",
"attributes": {
"startLine": 1,
"startTokenPos": 1,
"startFilePos": 6,
"endLine": 1,
"endTokenPos": 4,
"endFilePos": 13
},
"exprs": [
{
"nodeType": "Scalar_String",
"attributes": {
"startLine": 1,
"startTokenPos": 3,
"startFilePos": 11,
"endLine": 1,
"endTokenPos": 3,
"endFilePos": 12,
"kind": 2,
"rawValue": "\"\""
},
"value": ""
}
]
}
]
}
Если хотите поиграть с этим и посмотреть, как выглядит AST для другого кода, я нашёл https://phpast.com/ от Ryan Chandler и https://php-ast-viewer.com/, показывающие AST данного фрагмента PHP-кода.
Компилятор
Компилятор берет AST и создаёт опкоды. Опкоды — это то, что выполняет виртуальная машина, а также то, что будет храниться в OPcache, если он настроен и включён (что настоятельно рекомендую).
Для просмотра опкодов есть несколько вариантов (может быть, и больше, но я знаю только эти три):
- используйте расширение vulcan logic dumper. Оно также реализовано в 3v4l.org
- используйте
phpdbg -p script.php
для дампа опкодов - или используйте INI-настройку
opcache.opt_debug_level
для OPcache, чтобы заставить его выводить опкоды.- значение
0x20000
выводит опкоды после оптимизации - значение
0x10000
выводит опкоды до оптимизации
- значение
echo '<?php echo "";' > foo.php
php -dopcache.opt_debug_level=0x10000 foo.php
_main:
...
0000 ECHO string("")
0001 RETURN int(1)
Гипотеза
Возвращаясь к первоначальной идее об экономии процессорных циклов при использовании одинарных кавычек по сравнению с двойными, я думаю, что все согласны с тем, что это было бы верно только в том случае, если бы PHP оценивал эти строки в период выполнения для каждого запроса.
Что происходит в период выполнения
Итак, давайте посмотрим, какие опкоды создаёт PHP для двух разных версий.
Двойные кавычки:
<?php echo "apple";
0000 ECHO string("apple")
0001 RETURN int(1)
в сравнении с одинарными кавычками:
<?php echo 'apple';
0000 ECHO string("apple")
0001 RETURN int(1)
Эй, подождите, произошло что-то странное. Они выглядят идентично! Куда делась моя микрооптимизация?
Ну, может быть, реализация обработчика опкода ECHO парсит переданную строку, хотя нет никакого маркера или чего-то ещё, что говорит ему сделать это… хм 🤔.
Давайте попробуем другой подход и посмотрим, что делает лексер в этих двух случаях:
Двойные кавычки:
T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ("")
по сравнению с одинарными кавычками:
T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ('')
Токены по-прежнему различают двойные и одинарные кавычки, но проверка AST даст идентичный результат для обоих случаев — единственное различие заключается в rawValue
в атрибутах узла Scalar_String
, который по-прежнему содержит одинарные/двойные кавычки, но value
использует двойные кавычки в обоих случаях.
Новая гипотеза
Может ли быть так, что интерполяция строк на самом деле выполняется в период компиляции?
Давайте проверим это на более изощрённом
примере:
<?php
$juice="apple";
echo "juice: $juice";
Токены для этого файла:
T_OPEN_TAG (<?php)
T_VARIABLE ($juice)
T_CONSTANT_ENCAPSED_STRING ("apple")
T_WHITESPACE ()
T_ECHO (echo)
T_WHITESPACE ( )
T_ENCAPSED_AND_WHITESPACE (juice: )
T_VARIABLE ($juice)
Посмотрите на два последних токена! Интерполяция строк обрабатывается в лексере и, как таковая, является результатом периода компиляции и не имеет ничего общего с периодом выполнения.
Для полноты картины посмотрим на генерируемые при этом опкоды (после оптимизации, используя 0x20000
):
0000 ASSIGN CV0($juice) string("apple")
0001 T2 = FAST_CONCAT string("juice: ") CV0($juice)
0002 ECHO T2
0003 RETURN int(1)
Это другой опкод, отличающийся от того, который мы получили в простом примере <?php echo "";
, но это нормально, потому что мы здесь делаем совсем другое.
Конкатенация или интерполяция
Давайте рассмотрим эти три варианта:
<?php
$juice = "apple";
echo "juice: $juice $juice";
echo "juice: ", $juice, " ", $juice;
echo "juice: ".$juice." ".$juice;
- первая версия использует интерполяцию строк
- вторая использует разделение запятыми (которое, AFAIK, работает только с
echo
, но не с присвоением переменных или чем-то ещё) - и третья версия использует конкатенацию строк
Первый опкод присваивает переменной $juice
строку "apple"
:
0000 ASSIGN CV0($juice) string("apple")
В первом варианте (интерполяция строк) в качестве базовой структуры данных используется структура данных Строп, оптимизированная, чтобы создавать как можно меньше копий строк.
0001 T2 = ROPE_INIT 4 string("juice: ")
0002 T2 = ROPE_ADD 1 T2 CV0($juice)
0003 T2 = ROPE_ADD 2 T2 string(" ")
0004 T1 = ROPE_END 3 T2 CV0($juice)
0005 ECHO T1
Вторая версия наиболее эффективна с точки зрения экономии памяти, поскольку не создаёт промежуточное представление строки. Вместо этого она выполняет несколько вызовов ECHO
, что является блокирующим вызовом с точки зрения ввода-вывода, поэтому в зависимости от сценария использования это может быть недостатком.
0006 ECHO string("juice: ")
0007 ECHO CV0($juice)
0008 ECHO string(" ")
0009 ECHO CV0($juice)
Третья версия использует CONCAT
/FAST_CONCAT
для создания промежуточного представления строки и поэтому может создавать больше копий и/или использовать больше памяти, чем версия со Стропой
.
0010 T1 = CONCAT string("juice: ") CV0($juice)
0011 T2 = FAST_CONCAT T1 string(" ")
0012 T1 = CONCAT T2 CV0($juice)
0013 ECHO T1
Итак... что же здесь правильнее всего использовать и почему именно строковую интерполяцию?
Строковая интерполяция использует либо FAST_CONCAT
в случае echo "juice: $juice"
; либо высокооптимизированные опкоды ROPE_*
в случае echo "juice: $juice $juice"
; но самое главное — она чётко передаёт намерение, и ничего из этого не было проблемой ни в одном из PHP-приложений, с которыми я до сих пор работал, так что всё это не имеет значения.
TLDR
Интерполяция строк — это дело периода компиляции. Конечно, без OPcache лексеру придётся проверять переменные, используемые в строках с двойными кавычками, при каждом запросе, даже если их нет, тратя на это процессорные циклы, но, честно говоря: проблема не в строках с двойными кавычками, а в неиспользовании OPcache!
Однако есть одна оговорка: PHP до 4 (и, кажется, даже включая 5.0 и, возможно, 5.1, я не знаю) выполнял интерполяцию строк в период выполнения, так что использование этих версий… хм. Думаю, если кто-то действительно всё ещё использует PHP 5, то здесь всё так же, как и выше: проблема не в строках с двойными кавычками, а в использовании устаревшей версии PHP.
Заключительный совет
Обновите PHP до последней версии, включите OPcache и живите долго и счастливо!
Что насчёт sprintf()
На самом деле я хотел сказать, что всё это не является проблемой производительности, если вы используете интерполяцию строк, одинарные кавычки и конкатенацию или что-то ещё. Но кто-то заговорил о sprintf()
и о том, как это влияет на производительность. Поэтому для полноты картины давайте рассмотрим sprintf()
:
<?php
$juice = "apple";
echo sprintf("juice: %s %s", $juice, $juice);
компилируется в следующий опкод:
0000 ASSIGN CV0($juice) string("apple")
0001 INIT_FCALL 3 128 string("sprintf")
0002 SEND_VAL string("juice: %s %s") 1
0003 SEND_VAR CV0($juice) 2
0004 SEND_VAR CV0($juice) 3
0005 V1 = DO_ICALL
0006 ECHO V1
Быстрый бенчмарк показывает, что вариант sprintf()
занимает от 14 до 21 раза больше времени, чем вариант с интерполяцией строк на моей локальной машине.
И тут возникает загвоздка: это справедливо только для PHP 8.3, PHP 8.4 поставляется с другой оптимизацией периода компиляции, рассматривающая вызовы sprintf()
, содержащие только %s
и %d
, как если бы вы использовали интерполяцию строк:
0000 ASSIGN CV0($juice) string("apple")
0001 T2 = ROPE_INIT 4 string("juice: ")
0002 T2 = ROPE_ADD 1 T2 CV0($juice)
0003 T2 = ROPE_ADD 2 T2 string(" ")
0004 T1 = ROPE_END 3 T2 CV0($juice)
0005 ECHO T1
Поэтому последний совет остаётся в силе: обновите PHP до последней версии (ну, может быть, подождите с обновлением до выхода PHP 8.4).