
Возьмите переменную и увеличьте её на 1. Звучит просто, верно? Ну… С точки зрения PHP-разработчика, наверное, да. Но так ли это на самом деле? Здесь могут возникнуть некоторые трудности. Существует несколько способов инкрементировать значения, они могут выглядеть равноценными, но под капотом PHP работают по-разному, что может привести к, так сказать, интересным результатам.
Рассмотрим три примера добавления единицы к переменной:
$a = 1;
$a++; # Операция унарного инкрементирования
var_dump($a);
$b = 1;
$b += 1; # Операция добавления присваивания
var_dump($b);
$c = 1;
$c = $c + 1; # Операция стандартного добавления
var_dump($c);
Код разный, но в каждом случае значение переменной увеличивается. А какой будет результат?
int(2)
int(2)
int(2)
Интуитивно все три способа выглядят равнозначно. То есть для инкрементирования можно использовать как
$a++
, так и $a += 1
. Но давайте рассмотрим другой пример:$a = "foo";
$a++;
var_dump($a);
$a = "foo";
$a += 1;
var_dump($a);
$a = "foo";
$a = $a + 1;
var_dump($a);
string(3) "fop"
int(1)
int(1)
Наверняка многие из вас не ожидали такого результата! Может быть, кто-то уже знал, что добавление к строковой переменной приводит к изменению набора символов, но два
int(1)
? Откуда они взялись? С точки зрения PHP-разработчика это выглядит очень несогласованно, и выходит, что наши три способа инкрементирования неравнозначны. Давайте посмотрим, что происходит в недрах PHP при выполнении кода.Байт-код
Во время запуска PHP-скрипта ваш код сначала компилируется в промежуточный формат — байт-код. Этот факт опровергает мнение, что PHP по-настоящему интерпретируемый язык, — интерпретируется байт-код, а не исходный код PHP.
Приведённый выше код преобразуется в такой байт-код:
compiled vars: !0 = $a, !1 = $b, !2 = $c
line #* E I O op fetch ext return operands
---------------------------------------------------------------------------
3 0 E > ASSIGN !0, 1
4 1 POST_INC ~1 !0
2 FREE ~1
5 3 SEND_VAR !0
4 DO_FCALL 1 'var_dump'
7 5 ASSIGN !1, 1
8 6 ASSIGN_ADD 0 !1, 1
9 7 SEND_VAR !1
8 DO_FCALL 1 'var_dump'
11 9 ASSIGN !2, 1
12 10 ADD ~7 !2, 1
11 ASSIGN !2, ~7
13 12 SEND_VAR !2
13 DO_FCALL 1 'var_dump'
14 > RETURN 1
Вы легко можете создать такие опкоды самостоятельно, воспользовавшись дебаггером VLD или онлайн-сервисом 3v4l.org. Не думайте о том, что это всё означает. Если избавиться от неинтересных вещей, то останутся только эти строки:
compiled vars: !0 = $a, !1 = $b, !2 = $c
line #* E I O op fetch ext return operands
---------------------------------------------------------------------------
4 1 POST_INC ~1 !0
2 FREE ~1
8 6 ASSIGN_ADD 0 !1, 1
12 10 ADD ~7 !2, 1
11 ASSIGN !2, ~7
Таким образом,
$a++
превращается в два опкода (POST_INC и FREE
), $a += 1
— в один (ASSIGN_ADD
) и $a = $a + 1
тоже в два. Обратите внимание, что во всех трёх случаях получились разные опкоды, что уже подразумевает разное исполнение PHP.Оператор унарного инкрементирования
Рассмотрим первый способ инкрементирования — унарный оператор (
$a++
). Этот PHP-код преобразуется в опкод POST_INC
. К слову, PRE_INC
получается из ++$a
, и вам нужно знать разницу между ними. Второй опкод — FREE
— очищает результат после POST_INC
, потому что мы не используем его возвращаемое значение: POST_INC
на месте изменяет актуальный операнд. В данном случае можно проигнорировать этот опкод.Причина различия в исполнении этих опкодов кроется в файле
zend_vm_def.h
, который вы можете найти в исходном С-коде PHP. Это большой заголовочный файл, наполненный макросами, поэтому его не так легко читать, даже если вы знаете С. При вызове опкода POST_INC
выполняется содержимое строки 971.Если коротко, то происходит вот что:
- Проверяется, принадлежит ли переменная (
$a
в PHP-коде, которая в байт-коде превращается в!0
) к типуlong
. По сути, система проверяет, содержит ли переменная число. Хотя PHP — язык с динамической типизацией, каждая переменная всё же принадлежит к какому-то «типу». Типы могут меняться, как мы увидим далее. Если наша переменная относится кlong
, то вызывается С-функцияfast_increment_function()
и происходит возврат к следующему опкоду. - Если переменная нечисловая, то выполняются базовые проверки на возможность инкрементирования. Например, этого нельзя сделать со строковыми смещениями (string offset)
$a = "foobar"
;$a[2]++
, мы получим ошибку. - Далее проверяется, является ли переменная несуществующим свойством объекта, имеющего волшебные PHP-методы
__get
и__set
. Если это так, то с помощью__get
извлекается правильное значение, вызываетсяfast_increment_function()
и значение сохраняется с помощью вызова метода__set
. Эти методы вызываются из С, а не из PHP. - Наконец, если переменная не является свойством, то просто вызывается
increment_function()
.
Как видите, процесс добавления числа зависит от типа переменной. Если это число, то наверняка всё сведётся к вызову
fast_increment_function
, а если это волшебное свойство, то к вызову increment_function()
. Ниже мы поговорим о работе этих функций.fast_increment_function()
Функция
fast_increment_function()
относится к zend-операторам, и её задачей является максимально быстрое инкрементирование конкретной переменной.Если переменная относится к типу
long
, то для её инкрементирования используется очень быстрый ассемблерный код. Если значение достигло максимального числа типа INT (LONG_MAX
), то переменная автоматически преобразуется в двойную (double
). Это самый быстрый способ увеличения числа, поскольку эта часть кода написана на ассемблере. Считается, что компилятор не может оптимизировать С-код лучше, чем ассемблер. Но способ работает только в том случае, если переменная относится к типу long
. Иначе будет выполнен редирект на функцию increment_function()
. Поскольку инкрементирование (и декрементирование) чаще всего выполняется в очень маленьких внутренних циклах (например, for
), то необходимо делать это как можно быстрее ради сохранения высокой производительности PHP. increment_function()
Если
fast_increment_function()
— быстрый способ инкрементировать число, то increment_function
— медленный (slow
) способ. Сценарий процесса тоже зависит от типа переменной.- Если переменная относится к типу
long
, то число просто увеличивается (и преобразуется вdouble
при достижении максимального значения, которое уже нельзя хранить вlong
). Чаще всего это уже будет сделано с помощьюfast_increment_function
, но может случиться так, что этой функции всё равно будет переданоlong
, так что и здесь необходима проверка. - Если переменная относится к типу
double
, то она просто увеличивается. - Если переменная относится к типу
NULL
, то всегда возвращаетсяlong 1
. - Если переменная относится к типу
string
, то применяется описанная выше магия. - Если переменная — объект и имеет функциональность оператора
internal
, то вызывается операторadd
для добавленияlong 1
. Обратите внимание, что это работает только для классовinternal
, которые вручную определяют эти функции оператора, вы не можете определять операторы объекта в PHP-коде пространства пользователя. Это реализует единственный класс в исходном PHP-коде —GMP
. Так что вы можете сделать$a = new gmp(1) + new gmp(3); // gmp(4)
. Такая возможность появилась начиная с PHP 5.6, но перегрузка оператора невозможна в PHP напрямую. - Если переменная относится к какому-то другому типу, то её нельзя инкрементировать и возвращается код сбоя.
Итак, система проверяет разные типы. Заметьте: здесь нет проверки, скажем, на булево значение, это говорит о том, что такой тип нельзя инкрементировать.
$a = false; $a++
не только не будет работать, но даже ошибку не вернёт. Переменная просто не изменится, а останется false
.Инкрементирование строк
А теперь самое забавное. Работа со строками всегда полна нюансов, но в данном случае происходит вот что.
Во-первых, проверяется, содержит ли строка число. Например, строковая 123 содержит число 123. Такое «строчное число» будет преобразовано в нормальное число типа
long (int(123)
). При конвертировании используется несколько уловок:- Удаляются пробелы.
- Поддерживаются шестнадцатеричные числа (
0x123
). - Не поддерживаются восьмеричные и двоичные числа (
0123
иb11
). - Поддерживается научное представление (
1E5
). - Поддерживаются
double
. - Не поддерживаются и не считаются числами части, стоящие в начале или в конце строковой (
135abc
илиab123
).
Если в результате получилось
long
или double
, то число просто увеличивается. Например, если мы возьмём строковое 123 и инкрементируем, то получим int(124)
. Обратите внимание, что тип переменной меняется со строковой на целочисленную!Если строковая не может быть преобразована в
long
или double
, то вызывается функция increment_string()
.increment_string()
PHP использует систему инкрементирования наподобие Perl. Если строковая пустая, то просто возвращается
string("1")
. В противном случае для инкрементирования строковой применяется система переноса (carry-system).Начинаем с конца переменной. Если символ от
a
до z
, то он инкрементируется (a
становится b
, и т. д.). Если символ z
, то меняется на a
и «переносится» на одну позицию перед текущей.То есть:
a
становится b
, ab
становится ac
(перенос не нужен), az
становится ba
(z
становится a
, a
становится b
, потому что мы переносим один символ).То же самое относится и к прописным символам от
A
до Z
, а также к цифрам от 0
до 9
. При инкрементировании 9
превращается в 0
и переносится на предыдущую позицию.Если мы достигли начала строковой переменной и нужно сделать перенос, то просто добавляется ещё один символ ПЕРЕД всей строковой. Тип тот же, что и у переносимого символа:
"z" => "aa"
"9" => "00"
"Zz" => "AAa"
"9z" => "10a"
Так что при инкрементировании строки невозможно изменить тип каждого символа. Если он был в нижнем регистре, то в нём и останется.
Но будьте осторожны, если станете инкрементировать «число в строке» несколько раз.
При инкрементировании
string("2D9")
получится string("2E0")
(string("2D9"
) не является числом, поэтому будет выполняться инкрементирование обычной строки). Но при инкрементировании string("2E0")
вы получите уже double(3)
, потому что 2E0
— научное представление 2
и она будет преобразована в double
, который затем может быть инкрементирован до 3. Так что будьте внимательны с циклами инкрементирования!Эта система инкрементирования строк также объясняет, почему мы можем инкрементировать “Z” до “AA”, но не можем декрементировать “AA” обратно до “Z”. Декрементируется только последний символ “A”, но что делать с первым? Его тоже надо декрементировать до “Z” с помощью (отрицательного) переноса? А что насчёт “0A”? Оно должно стать
Z
? И если да, то при новом инкрементировании мы получим уже AA
. Иными словами, мы не можем просто убрать символы во время декрементирования, как мы добавляем их при инкрементировании.Суммирующий оператор присваивания
Рассмотрим теперь второй пример из начала статьи — суммирующий оператор присваивания (
$a += 1
). Выглядит аналогично унарному оператору инкремента, но ведёт себя иначе с точки зрения генерируемых опкодов и фактического выполнения. Выражение полностью обрабатывается с помощью zend_binary_assign_op_helper, который после ряда проверок вызывает add_function с двумя операндами: $a
и нашим значением int(1)
.add_function()
Метод
add_function
работает по-разному в зависимости от типов переменных. По большей части он состоит из проверки типов операндов:- Если они оба относятся к
long
, то их значения просто увеличиваются (при переполнении преобразуются вdouble
). - Если один
long
, а второйdouble
, то оба преобразуются вdouble
и инкрементируются. - Если они оба относятся к
double
, то просто суммируются. - Если они оба являются массивами, то будут объединены на основе ключей:
$a = [ 'a', 'b' ] + [ 'c', 'd' ];
. Получится[ 'a', 'b']
, как если бы объединили второй массив, но у них оказались одинаковые ключи. Обратите внимание, что объединение происходит не по значениям, а по ключам. - Если операнды являются объектами, то проверяется, имеет ли первый из них внутреннюю функциональность оператора (как в случае с методом
increment_function()
). У вас не получится сделать так в PHP самостоятельно, это поддерживается только внутренними классами вроде GMP.
Если операнды относятся к каким-то другим типам (например,
string + long
), то с помощью метода zendi_convert_scalar_to_number
они оба будут преобразованы в скаляры. После преобразования снова будет применена функция add_function
, и в этот раз наверняка будет обнаружено соответствие одной из описанных пар.zendi_convert_scalar_to_number()
Преобразование скаляра в число зависит от типа скаляра. Обычно всё сводится к одному из следующих алгоритмов:
- Если скаляр — строка, то с помощью
is_numeric_string
проверяется, содержит ли она число. Если нет, то возвращаетсяint(0)
. - Если скаляр —
null
или булевоfalse
, то возвращаетсяint(0)
. - Если скаляр — булево
true
, то возвращаетсяint(1)
. - Если скаляр — ресурс (resource), то возвращается цифровое значение номера ресурса (resource number).
- Если скаляр — объект, то делается попытка преобразовать его в
long
(как и в случае с внутренними операторами, здесь может быть функциональность внутреннего преобразования (internal cast functionality), но она не всегда реализована и доступна только для основных классов, а не для PHP-классов в пространстве пользователя).
Оператор суммы
Это самый простой из всех трёх вариантов. При его выполнении вызывается функция
fast_add_function()
. Как и fast_increment_function()
, она напрямую использует ассемблерный код для увеличения чисел, если оба операнда относятся к long
или double
. Если это не так, то осуществляется редирект на функцию add_function()
, используемую выражением присваивания.Поскольку и оператор сложения, и суммирующий оператор присваивания используют одну и те же базовую функциональность, то
$a = $a + 1 и $a += 1
работают одинаково. Единственное различие заключается в том, что оператор сложения МОЖЕТ выполняться быстро, если оба операнда относятся к long
или double
. Так что если вы хотите сделать микрооптимизацию, то $a = $a + 1
будет работать быстрее, чем $a += 1
. Не только благодаря fast_add_function()
, но и потому, что нам не нужно обрабатывать дополнительный байт-код для сохранения результатов обратно в $a
.Заключение
Инкрементирование значения отличается от простого сложения:
add_function
преобразует типы в совместимые пары, а increment_function
этого не делает. Теперь мы можем объяснить полученные результаты:$a = false;
$a++;
var_dump($a); // bool(false)
$a = false;
$a += 1;
var_dump($a); // int(1)
$a = false;
$a = $a + 1;
var_dump($a); // int(1)
Поскольку
increment_function
не преобразует булево значение (это не число и не строка, которую можно преобразовать в число), то происходит тихий сбой и значение не инкрементируется. Поэтому осталось bool(false)
. В случае с add_function
делается попытка найти соответствие пары boolean
и long
, которое не существует. В результате оба значения преобразуются в long
: bool(false)
становится int(0)
, а int(1)
остаётся int(1)
. Теперь у нас есть пара long
& long
, поэтому add_function
просто суммирует их и получается int(1)
. (Вопрос: во что превратится булево true
+ int(1)
?)Также мы можем объяснить ещё одну странность:
$a = "foo";
$a++;
var_dump($a); // string("fop")
$a = "foo";
$a += 1;
var_dump($a); // int(1)
$a = "foo";
$a = $a + 1;
var_dump($a); // int(1)
Поскольку строку не получается преобразовать в число, то выполняется обычное инкрементирование строки. Выражение добавления преобразует строки в
long
после проверки на наличие чисел. Поскольку их нет, то выполняется конвертирование строки в int(0)
и к ней добавляется int(1)
.