Инкремент в PHP

Возьмите переменную и увеличьте её на 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 становится bab становится ac (перенос не нужен), az становится ba (z становится aaстановится 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, которое не существует. В результате оба значения преобразуются в longbool(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).