Инкремент в PHP

Original author: Joshua Thijssen
  • Translation


Возьмите переменную и увеличьте её на 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).
Mail.ru Group
Building the Internet

Comments 27

    +24
    Слабая динамическая типизация и ее глюки особенности.
      +3
      Разработчик не на PHP и его предрассудки особенности.
        +4
        Ну положим идею о таком инкременте не php-шники придумали.
        PHP follows Perl's convention when dealing with arithmetic operations on character variables and not C's. For example, in PHP and Perl $a = 'Z'; $a++; turns $a into 'AA', while in C a = 'Z'; a++; turns a into '[' (ASCII value of 'Z' is 90, ASCII value of '[' is 91).


        А самой статье сильно не хватает добавки про декремент.
        Т.к. даже те кто читал мануал и понимают что 'AA'++ даст 'AB' и 'Z'++ даст 'AA', не всегда понимают что 'AB'-- даст 'AB' и 'AA'-- даст 'AA';
          +9
          Ну так я говорю о языках со слабой динамической типизацией вообще, а не по php конкретно.
          Совершенно бессмысленно это все. Задач когда нужен инкремент строки настолько исчезающе мало, что я даже придумать не могу. А где нужен — пусть лучше будет соответствующая функция, эта задача идеологически ближе к разному кодированию/декодированию (base64, хеши и т.п.).
            +1
            Ну так я говорю о языках со слабой динамической типизацией вообще, а не по php конкретно.
            Промахнулись комментом, отвечали iGusev-у:)

            Совершенно бессмысленно это все. Задач когда нужен инкремент строки настолько исчезающе мало, что я даже придумать не могу.
            На стэковерфлоу достаточно вопросов как сделать из AAZ ABA или как пробежаться по буквам, но откровенно говоря от нас смысл тоже ускользает.
            Вероятно это некий атавизм, наиболее логичное поведение (как по нам) так это в С, в perl уже странновато, но в php слабая типизация превратила это в что-то дико странное и слабо предсказуемое.
        +6
        Ну вот, еще одна статья, ругающая php. на этот раз за то, что он позволяет инкрементить строки.

        Да, в ваш пример, как всегда это бывает, не хватало использования 8-, 16-ричной системы, А-ля прибавим к строке «Abba» +1

        Сорри за критику, но будущим комментариям, которые очень скоро будут говорить — «фуууу, php говно. Хорошо что я на нем никогда не пишу.» хочу сказать, что примеры весьма экзотические и редко встречаются в проектах. А если и встречаются, то отлавливаются весьма не плохо, т.к. когда после ++ ожидаешь получить число, а в дебаге выходит строка, то уже ясно где искать.
          –32
          от этого пхп говном быть не перестает, сложно представить что в век новых стандартов с++, производительности js и языкового стиля swift еще есть люди которые прыгают в яму с асфальтом к этому динозавру :)
            +20
            А ты смешной :)
              +2
              Простите, вы бы не могли про каждый язык программирования что-то в таком ключе написать? Правда, я бы такой пост (а там будет не один экран текста) с удовольствием почитал. Да и вы немного кругозор расширите )
                +2
                Php развивается в последнее время огромными шагами, не нужно так разработчиков php троллить.
              +8
              >многие… не ожидали такого результата

              Это да, многие ПХП-писатели не читают учебников…
                +1
                «чукча не читатель, чукча писатель»
                  –3
                  Обычно когда нужно что то быстро разработать проект, на документацию PHP не хватает времени. Поэтому возможно получаются проекты из категории «АД»
                  +7
                  А может просто документацию читать надо?
                  Когда идёт сложение строки с числом, строка приводится к числу.
                  Для конкатенации в php используется точка
                    0
                    Даже правильнее будет сказать, что при совершении любых математических операций со стройкой, она будет приведена к числу.
                    +2
                    Не понял, зачем лезть в байткод, если информация по приведению типов есть в официальной документации?
                    Например «неожиданный» результат вначале с двумя int(1) прекрасно объясняются в справке по строкам:
                    secure.php.net/manual/ru/language.types.string.php#language.types.string.conversion
                      +8
                      Я думаю, смысл статьи не в том, чтобы показать «смотрите, как в PHP странно работает инкремент», а в том, чтобы объяснить, почему он так работает.
                        +7
                        А нет ли тут подмены причины и следствия?
                        Скорее байткод так работает, чтобы обеспечивать текущее поведение, а не текущее поведение такое, потому что байткод так работает.
                        Текущее поведение сложилось задолго до того, как появился современный байткод (а то и до появления байткода вообще), и в будущем байткод может изменится, а вот поведение будет поддерживаться соответствующем документации.
                        Поэтому я не вижу смысла рассматривать байткод, лучше просто почитать документацию.
                        Наверное я скучный.
                      +10
                      Я бы на месте автора скорее удивлялся string(3) «fop», чем двум единичкам, но без чтения базовой документации можно удивляться в приципе чему угодно.
                        +6

                        Кстати, стоит помнить, что при инкременте строки не всегда просто инкрементируется последний символ.


                        php > $a = "zzz";
                        php > $a++;
                        php > var_dump($a);
                        string(4) "aaaa"

                        Если строку можно преобразовать в число, то перед инкрементом это происходит:


                        php > $a = "999";
                        php > $a++;
                        php > var_dump($a);
                        int(1000)

                        php > $a = "9.99";
                        php > $a++;
                        php > var_dump($a);
                        double(10.99)

                        Но не всегда:


                        php > $a = "111a";
                        php > $a++;
                        php > var_dump($a);
                        string(4) "111b"

                        php > $a = "111zzz";
                        php > $a++;
                        php > var_dump($a);
                        string(6) "112aaa"

                        Насчет разных версий PHP


                        До PHP 7


                        $a = "0xff";
                        $a++;
                        var_dump($a);
                        int(256)

                        В PHP 7


                        php > $a = "0xff";
                        php > $a++;
                        php > var_dump($a);
                        string(4) "0xfg"

                        NULL


                        php > $a = null;
                        php > $a++;
                        php > var_dump($a);
                        int(1)

                        Но при этом:


                        php > $a = false;
                        php > $a++;
                        php > var_dump($a);
                        bool(false)

                        php > $a = true;
                        php > $a++;
                        php > var_dump($a);
                        bool(true)

                        В следующих случаях инкремент тоже не имеет эффекта:


                        php > $a = [1];
                        php > $a++;
                        php > var_dump($a);
                        array(1) {
                          [0] =>
                          int(1)
                        }

                        php > $a = (object)['attr' => 1];
                        php > $a++;
                        php > var_dump($a);
                        class stdClass#1 (1) {
                          public $attr =>
                          int(1)
                        }

                        php > $a = function($b) { return $b; };
                        php > var_dump($a);
                        class Closure#2 (1) {
                          public $parameter =>
                          array(1) {
                            '$b' =>
                            string(10) "<required>"
                          }
                        }
                        php > $a++;
                        php > var_dump($a);
                        class Closure#2 (1) {
                          public $parameter =>
                          array(1) {
                            '$b' =>
                            string(10) "<required>"
                          }
                        }
                          +3
                          Это прямо краткое содержание статьи в примерах)
                            +1

                            Как раз хотел написать, что давайте будем инкрементить все подряд — массивы, функции и т.д. Спасибо )

                              0
                              Вот так вот, захочешь подбиралку паролей на php написать (Господи прости!) с помощью инкремента строки, так и тут в какой то момент она ВНЕЗАПНО превратится в число! :)
                              +1
                              В презентации Дмитрия Стогова (https://github.com/dstogov)
                              Есть упоминание, что в 7.1 они оптимизируют инкременты.

                              <?php
                                      function sum() {
                                          $sum = 0;
                                          for ($i = 0; $i < 100; $i++) {
                                              $sum = $sum + $i;
                                          }
                                          return $sum;
                                      }
                              


                              Как я понял, то код выше будет использовать (где то 34 слайд)
                              ASSIGN_ADD $sum, $i
                              


                              вместо 7.0
                              ADD $sum, $i -> T2
                              ASSIGN $sum, T2
                              


                              Однако могу ошибаться.
                                0
                                ИМХО, весь вышеприведённый под должен компилироваться во что- то типа
                                ASSIGN $sum, 4950
                                RETRUN $sum
                                

                                Вот это я понимаю будет оптимизация))
                                –1
                                никогда не понимал, зачем так извращаться над кодом в РНР? Или над объектами в JS (статья про [1,2,3]+1)? Зачем писать заведомо бред, и инкрементить string, если ты не идиот?
                                  0

                                  А что плохого в том, чтобы рассказать о таких вот особенностях языка?
                                  Статья не призывает инкрементировать строки, или к интам плюсовать объекты, потому что так можно, статья рассказывает об особенностях работы ++, +=, +1.

                                Only users with full accounts can post comments. Log in, please.