224 заметки с тегом

php

Позднее Ctrl + ↑

Переезд на PHP7

В почте попросили описать переезд на ПХП7, с какими сложностями встретились, как они решались. Опишу, пока не забыл — я этим занимался почти в одиночку, так как задача не была приоритетной. Я считал, что мы сможем получить хороший прирост производительности, поэтому сам её и делал в свободное время.

Если кому интересно, загруженность серверов, где крутится ПХП, действительно упала, примерно в 1,5—1,7 раза.

Вообще говоря, у ПХП есть довольно детальные описания того, что может пойти не так при смене версий, достаточно заглянуть в раздел «Изменения, ломающие обратную совместимость» нужной версии. Там можно сразу прикинуть что придётся поменять в коде, если вы хорошо его знаете.

Ниже мой собственный опыт.

Сначала я составил список всех модулей ПХП, которые у нас используются — натурально выписал из зависимостей пакета нашего продукта в табличку в местной «вики». На тот момент не все модули поддерживали «семёрку», поэтому к табличке я иногда возвращался и вносил изменения, если они случались.

Когда в табличке без поддержки остался единственный модуль ctwig от шаблонизатора «Твиг», решил, что без него мы можем обойтись — к этому времени в интернете появились замеры скорости и «Твиг» без модуля под «семёркой» демонстрировал похожие результаты с «пятёркой» и модулем.

Дальше я проверил не используются ли у нас где-то новые зарезервированные слова. Так как их использование вызовает ошибку синтаксиса, то случаи использования легко ловятся скриптом наподобие этого:

find . -type f \( -name '*.php' -o -name '*.inc' \) -exec php -l {} \; |
fgrep -v 'No syntax errors'

У нас, к слову, было несколько таких мест — например, был объявлен класс с разными полезными штуками для обработки строк, который назывался String. Так больше нельзя — зарезервированное слово.

Посложнее с обработкой исключений — класс Exception перестал быть базовым и если требуется ловить все исключения, то в «семёрке» лучше всего перехватывать всё, что реализует интерфейс Throwable.

У нас необработанные кодом исключения ловятся и пишутся в логи, для этого в нескольких местах кода есть перехват всего подряд. Выглядело это примерно так:

try {
    // … какой-то код
} catch (Exception $e) {
   DI::log->get('exception')->error($e);
   throw $e;
}

Из-за пространства имён такие места простым поиском найти непросто, но специализированные редакторы неплохо справляются.

Несложными регулярками поискал различные непрямые выражения, там теперь тоже есть разница, нашёл всего несколько штук и поправил.

Потом я как-то походя заметил «нотисы», связанные с модулем Memcached и оказалось, что этого модуля сменилось АПИ, а в документации об этом не слова, пришлось писать прокси-класс. Сейчас документация уже обновлена, прокси мы убрали и просто переписали затронутые места.

Далее в дело вступили тестеры и программисты — в основном были исправления, связанные с тем, что в «семёрки» некоторые вещи устарели, ничего сверх этого я не припомню.

В целом мне кажется, переход прошёл безболезненнее, чем можно было бы ожидать — всё-таки изменения сделаны значительные. Правда и обновились мы очень поздно — отчасти потому, что я внимательно читал списки изменений к каждой версии, следил когда мажорные проблемы сменятся минорными, отчасти от того, что требовалась ещё и поддержка со стороны всех используемых нами модулей.

Две раздражающие частности в PHP

В ПХП немало глобальных проблем, которые многих раздражают, об этом написано много статей. Но есть две частные проблемы, два примера неинтуитивного поведения, которые рождают досадные баги на ровном месте. Они раздражают лично меня.

На текущем месте я работаю более шести лет и прежде чем у нас появилось ревью кода до пуша, в общую ветку регулярно попадали результаты недопонимания работы двух функций, которые порой рождали весьма причудливые баги.

Первый относится к функции exec. Свой второй параметр она получает по ссылке и выводит туда массив строк, которые выдаёт запускаемая команда. Беда возникает в следующем коде:

exec('1st command', $out, $ret);
// … проверяем $ret, делаем что-то полезное с $out
exec('2nd command', $out, $ret);
// … проверяем $ret, делаем что-то полезное с $out

Дело в том, что вторая команда exec не очистит второй параметр и добавит в него вывод второй команды. Таким образом вызов двух команд склеится, а это не то, что мы обычно ожидаем.

Такое поведение описано в руководстве и возможно это полезно для цикла с накоплением, но в общем случае контринтуитивно.

Второй раздражающей частностью является результат работы array_unique. Опять же, особенность описана в руководстве, но про неё нередко забывают: эта функция сохраняет ключи массива. Вот как это выглядит на практике:

var_dump(array_unique([1,1,2]));
/* array(2) {
  [0]=>
  int(1)
  [2]=>
  int(2)
} */

В случае ассоциативного массива претензий нет, это необходимо, возможно есть случаи, когда это полезно и для обычного массива, но чаще всего пропуски в ключах мешают в самых неожиданных местах.

Самый замысловатый баг, который я помню был таким. Результирующий массив превращался в джейсон-строку и передавался микросервису на Гоу. Чаще всего значения были уникальными и всё работало как ожидается.

Но однажды звёзды сошлись так, что уникальность нарушилась и в результате работы array_unique в массиве появились пропуски. В обычный массив такое в джейсоне превратить нельзя, поэтому ПХП сделал из него объект с числовыми ключами. Гоу такое не простил — у него статическая типизация и в него нельзя пихать всякое.

Микросервис выдал ошибку в лог, данные не принял. У тестеров возникла очень странная ошибка, которая повторялась в каких-то очень редких условиях. Хорошо, что логи были.

Увы, но документацию к языкам сейчас никто подробно не читает, а если читает, то такие особенности не помнит — такова данность и ничего не изменить. Хотелось бы чтобы в языках было меньше такого поведения, либо чтобы специализированные редакторы как-то обращали внимание программиста на такие мелочи.

Правда выигрывает

Отличную игру подсмотрел у Сэма — в ней одиннадцать уровней, в каждом на экране функция, которая должна вернуть «true», аргумент функции вы пишете сами, причём его длина должна быть минимальной. На каждом уровне указан абсолютный минимум.

Мне пока не удалось победить уровнь №6 (30 символов против 22) и 10 (29 против 27), не хватило свободного времени на работе, чтобы додумать, попробую в выходные.

Решил все. №10 — утром, №6 — уже к полуночи. С номером шесть возился очень долго — пришлось через рефлексию отобрать все функции, которые не имеют обязательных параметров, просмотреть большинство и отбросить кучу интересных, но тупиковых идей.

Особенность PHP 7.2 (и 7.1)

Потихоньку смотрю как наш продукт запускается на ПХП версии 7.2 — у нас сейчас используется 7.0, но очень хочется двинуться дальше. В хитросплетениях кода нашёл очень странный баг интерпретатора, который был разбросан по разным строчкам кода, а в сконцентрированном виде он выглядит так:

$arr = [[1]];
array_walk($arr, function(){});
array_map('array_shift', $arr);
var_dump($arr);

В ПХП 7.0 массив выведется в неизменном виде, а в версии 7.2 (и 7.1, как оказалось) единица исчезнет. Очевидно, что array_walk создаёт какие-то ссылки внутри массива, из-за чего array_shift начинает получать внутренний массив по ссылке и сдвигать. Но никаким другим способом (например, прямым созданием массива со ссылками) мне такое поведение получить не удаётся.

DevelNext

Понадобилось тут по работе быстро накидать графическую утилиту под Виндоуз, пока шли совещания погуглил инструменты и случайно наткнулся на «ДевелНекст» — развитие «ДевелСтудио», о которой я писал восемь лет назад.

Внутри у неё свой диалект ПХП (JPHP), написанный на Джаве. Отличия от обычного интерпретатора, в основном, в стандартных функциях — они присутствуют не все, но чаще всего есть какие-то аналоги. Актуальная на текущий момент версия поддерживает синтаксис ПХП 7.1.

С документацией на сайте что-то странное — часть глав ведут на несуществующие страницы, впрочем, как я успел заметить, большая часть классов списана с Джавы, так что когда нужно было, я гуглил примеры и просто переписывал их на JPHP.

Первую версию программы я накидал очень быстро, а потом встрял — мне нужно было по мере выполнения программы показывать индикацию, а она прорисовывалась только после завершения всей работы.

Потерял много времени, пытаясь найти пропавшую с сайта документацию — я догадывался, что всю работу надо выполнять в отдельном треде, чтобы не мешать интерфейсу прорисоваться, но не знал как это сделать. Пока случайно не попал в подраздел «Разного» — оказалось там притаилось решение моей проблемы.

Причём как оказалось, работать надо не просто в отдельном треде, так ещё и изменения интерфейса делать разрешается толко через специальную обёртку — функции uiLater и uiLaterAndWait. Пример из документации:

$this->label->text = 'Поток выполняется...';

$thread = new Thread(function () {
    sleep(3); // ждем 3 сек.

    uiLater(function() {
        $this->label->text = 'Поток выполнен.';
    });
});

$thread->start();

На выходе получается обычный запускаемый файл (в моём случае — 3,3 мегабайта, немного по нынешним временам). Внутри — судя по всему, интерпретатор Джавы и скомпилированная в байт-код программа.

К сожалению, сразу нашёлся баг — если на компьютере пользователя установлена Джава младше версии 1.8, программа запускаться отказывается. Автор «ДевелНекста» вызвался помочь исследовать эту проблему, надеюсь скоро исправит.

Разобрался: ларчик просто открывался! Я так был уверен, что должен получиться всего один файл, что не понял, что папку jre (где и лежит Джава) надо тоже копировать. Это уже менее интересно, конечно.

Дизассемблируй это

Дум (71.16КиБ)
«ДУМ», скомпилированный с использованием одних только команд MOV

В моей жизни последовательно сменяли друг друга три ассемблера. Первый, для компьютера «Радио-86РК», я выучил по комментариям к ассемблерному коду в журнале «Радио» — другой литературы не было, а до моего знакомства с интернетом оставалось несколько лет. Потом были последовательно ассемблеры для «Спектрума» и интеловских процессоров.

В те времена я думал, что всю жизнь буду программировать на ассемблере, на деле же последнюю программу на нём написал в начале 2000-х. Сейчас на чистом ассемблере мало кто пишет, а тем временем в этом мире иногда происходят интересные вещи.

В ассемблере есть такая команда — MOV (в некоторых ассемблерах — LD), записывает содержимое одного аргумента в другой. Сейчас набор комманд разросся, аргументом может быть почти что угодно — регистр, ячейка в памяти, сумма некоторого числа, одного регистра и другого, умноженного на число, но по сути это всегда присваивание.

И вот оказалось, что эта команда — полная по Тьюрингу. Звучит невероятно, но это так. Некие ребята заморочились и сделали компилятор, который компилирует любую программу на Си в последовательность команд MOV. Причём им даже ДУМ удалось скомпилировать, правда один кадр рисуется семь часов. Кстати, такая программа неуязвима для горюшка века — Мелтдауна и Спектра.

Есть небольшая (на 156 страниц и 90% воды) презентация, достаточно популярно объясняющая как этого удалось достичь, но для её чтения надо знать ассемблер, поэтому я позволю себе раскрыть детали трансляции двух инструкций, чтобы пояснить принцип для тех, кто ассемблера не знает или ленится причитать.

Например, сравнение двух чисел делается при помощи следующего псеводокода:

mov [X], 0
mov [Y], 1
mov R, [X]

У нас есть два числа в аргументах «X» и «Y», результат сравнения которых попадает в «R» — там будет ноль, если числа не равны и единица в противном случае. Как же это работает?

Первой командой ноль записывается в ячейку по адресу «X». Это ассемблер, у нас тут всё — число, остальное — человеческие интерператации, поэтому записанное в «X» мы используем как адрес. Второй командой единица записывается в ячейку по адресу «Y». Третьей командой мы читаем значение по адресу «X» и если значения в «X» и «Y» совпадают, то ноль перетрётся единицей (и она попадёт в R), если нет, то в ячейке по адресу «X» ноль останется (который попадёт в R).

Несложно. Но из кода выше непонятно как получаются другие необходимые инструкции. Увы, но какого-то единого принципа для всего нет, авторам для каждого набора приходилось придумывать что-то новое. Думаю интересно будет взглянуть на реализацию чего-нибудь ещё.

Возьмём, например, логическое «ИЛИ» («OR»), тут чуточку сложнее:

OR_ADDRS: dd OR_0, OR_1
OR_0: dd 0, 1
OR_1: dd 1, 1
; …
mov eax, X
mov edx, [OR_ADDRS + eax]
mov eax, Y
mov eax, [eax + edx]
mov R, eax

Во всех логических операциях ребята используют записанные заранее неизменяемые массивы значений.

Что тут происходит? В регистр (переменную, с которыми работает процессор) «eax» записывается значение «X» (возможные входные значения у нас тут — ноль или единица, численное представление булевых значений).

Далее в регистр «edx» записывается число из адреса, который является суммой адреса массива OR_ADDRS и содержимого регистра eax. Таким образом в eax попадёт OR_0 или OR_1, в зависимости от того былы записаны в eax ноль или единица. Эти значения — тоже числа и являются адресами двух других массивов из двух элементов.

Далее в eax мы записываем аргумент Y, его значение складывается с адресом полученным на предыдущем шаге и из получившегося адреса мы читаем записанное там значение. В переводе на ПХП получается следующее:

function mov_or(int $X, int $Y): int
{
    define('OR_0', [0, 1]);
    define('OR_1', [1, 1]);

    define('OR_ADDRS', [OR_0, OR_1]);

    $R = OR_ADDRS[$X][$Y];

    return $R;
}

Кстати, интересно, что у знаменитого дисассемблера «ИДА» от полученной таким образом программы крепко уносит крышу — при попытке отладки диссасемблер не видит никаких ветвлений и падает на анализе кода. Получился бы неплохой метод защиты от анализа, если бы не производительность.

Memcached и persistent connections

В работе с модулем «Мемкешд» есть целая куча подводных камней, которые иногда всплывают в какой-то незначительной строчке документации, иногда — в комментариях к ней, а иногда выясняются в процессе экспериментов.

Упомянутый модуль имеет малозаметную возможность, которая помогает экономии ресурсов в проектах с большой нагрузкой — если в конструктор передать строку, то она станет идентификатором для так называемого «персистент коннекта» — соединения, которое открывается процессом интерпретатора ПХП и живёт между запуском обрабатываемым этим процессом программ.

Оказалось у этого функционала есть особенность:

socket(PF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 4

connect(4, {sa_family=AF_INET, sin_port=htons(11211), sin_addr=inet_addr("127.0.0.1")}, 16) = -1

poll([{fd=4, events=POLLOUT}], 1, 4000) = 1 ([{fd=4, revents=POLLOUT}])
getsockopt(4, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
sendto(4, "version\r\n", 9, MSG_NOSIGNAL, NULL, 0) = 9
recvfrom(4, "VERSION 1.5.1\r\n", 8196, MSG_NOSIGNAL, NULL, NULL) = 15
sendto(4, "quit\r\n", 6, MSG_NOSIGNAL, NULL, 0) = 6
shutdown(4, SHUT_WR)                    = 0
shutdown(4, SHUT_RD)                    = -1 ENOTCONN (Transport endpoint is not connected)
close(4)                                = 0

socket(PF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 4
setsockopt(4, SOL_SOCKET, SO_LINGER, {onoff=1, linger=0}, 8) = 0

connect(4, {sa_family=AF_INET, sin_port=htons(11211), sin_addr=inet_addr("127.0.0.1")}, 16) = -1

poll([{fd=4, events=POLLIN|POLLOUT}], 1, 4000) = 1 ([{fd=4, revents=POLLOUT}])
getsockopt(4, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
sendto(4, "version\r\n", 9, MSG_NOSIGNAL, NULL, 0) = 9
recvfrom(4, "VERSION 1.5.1\r\n", 8196, MSG_NOSIGNAL, NULL, NULL) = 15

В логе выше видно, что процесс постоянно устанавливает новые соединения с сервером мемкешда, несмотря на то, что в коде (поверьте) используется один и тот же постоянный идентификатор.

Мы долго были в недоумении, но в конечно счёте выяснилось, что такое поведение воспроизводится, если после создания объекта выставлять любые опции, влияющие на сокет. В этом случае модуль переоткрывает соединение с сервером, причём неважно открывался сокет с теми же настройками или нет.

Решение — любым доступным способ проверять получили ли мы «свежий» объект или имеем дело с уже открытым соединением и выставлять опции только в первом случае:

$mc = new Memcached('persistent');

if (!$mc->getServerList()) {
    $mc->setOptions([
        Memcached::OPT_NO_BLOCK => true,
    ]);

    $mc->addServer('127.0.0.1', 11211);
}

Тогда соединение благополучно переиспользуется.

ПХП и строгая типизация

В ПХП много странностей, ещё одна дала о себе знать в неожиданном месте. Сначала немного теории.

Во многих языках дозволяется определять функции, методы или их аналоги с необязательными параметрами, их при вызове можно не указывать способом, предусмотренным языком. По моим ощущениям наиболее распространён способ с присвоением таким параметрам значений по-умолчанию, которые они получают, если другие значения не были указаны при вызове.

ПХП использует эту схему везде, кроме расширений и встроенных функций. Понятие «необязательный параметр» там есть, но обрабатывается иначе — у параметра указывается тип (например «строка»), необязательность и «нулабельность» (можно ли в этом параметре принимать null в качестве значения).

Последнее очень полезно для числовых и булевых типов — если «нулабельность» не указана, то null будет преобразован по правилам языка в значение указанного типа.

У многих функций ПХП в документации указаны значения, которые будут подставлены, если параметр не указан. В тех случаях, когда такое значение не указано, можно было попытаться подставить null, многие расширения это проглатывают.

Например, у нас преспокойно работал примерно такой код:

public function put(Serialized $object, $eventName, $extraEventData, $uniqueId = null)
    {
        return DI::gearman_client()->doBackground(
            $this->queueName,
            igbinary_serialize(
                [
                    'object' => $object,
                    'event_data' => $extraEventData,
                    'event_name' => $eventName,
                ]
            ),
            $uniqueId
        );
    }

Всё работало корректно, пока не пришёл ПХП7 и мы не стали потихоньку переползать на строгую типизацию. Вечером я закоммитил изменения в этом файле, которые позволили включить строгую типизацию, а за завтраком поймал в логах странную ошибку, которая сообщала мне, что в метод doBackground время от времени получает в качестве последнего параметра null, а так нельзя.

Сначала я недоумевал, а потом догадался, что случилось — у doBackground последний, необязательный парамер имеет тип «строка» и он не «нулабельный». То есть в строгой типизации я его должен либо не передавать вовсе, либо передавать туда исключительно строку. А null, который передавался туда до перехода на строгую типизацию более не подходит, ибо он не строка.

Пришлось переписать более уродливо:

public function put(Serialized $object, string $eventName, $extraEventData, string $uniqueId = null)
    {
        $args = [
            $this->queueName,
            igbinary_serialize(
                [
                    'object' => $object,
                    'event_data' => $extraEventData,
                    'event_name' => $eventName,
                ]
            ),
        ];

        if ($uniqueId !== null) {
            $args[] = $uniqueId;
        }

        return DI::gearman_client()->doBackground(...$args);
    }

Странно то, что у необязательного параметра нет никакого значения по-умолчанию, которое можно было бы указать. В принципе, даже если бы оно было, это тоже не очень удобно.

Не смог найти, но я помню, что было чьё-то предложение расширить синтаксис ПХП — разрешить при вызове функции или метода использовать ключевое слово «default» для указания, что в данном месте нужно использовать значение по-умолчанию. Мне кажется тут бы оно пригодилось.

PostgreSQL и PHP — слон слону не товарищ

Продолжаю серию удивительных открытий в мире перехода на «Постгрес». В документации к функции pg_execute есть малозаметное примечание к последнему параметру — в нём передаются значения для запроса:

Warning Elements are converted to strings by calling this function.

Думаю мало кто обращает на него внимания, собственно, я тоже не обращал. Прежде чем двинуться дальше, разберёмся — что же здесь написано?

Перевод такой: все значения, которые передаются, приводятся к строкам. Код, который это выполняется выглядит так (взял из ПХП 7.2):

if (num_params > 0) {
        int i = 0;
        params = (char **)safe_emalloc(sizeof(char *), num_params, 0);

        ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(pv_param_arr), tmp) {
                ZVAL_DEREF(tmp);
                if (Z_TYPE_P(tmp) == IS_NULL) {
                        params[i] = NULL;
                } else {
                        zval tmp_val;

                        ZVAL_COPY(&tmp_val, tmp);
                        convert_to_cstring(&tmp_val);
                        params[i] = estrndup(Z_STRVAL(tmp_val), Z_STRLEN(tmp_val));
                        zval_ptr_dtor(&tmp_val);
                }
                i++;
        } ZEND_HASH_FOREACH_END();
}

pgsql_result = PQexecParams(pgsql, query, num_params,
                                NULL, (const char * const *)params, NULL, NULL, 0);

Вышеупомянутое примечание есть только у этой функции, но на деле в любом месте, где привязываются значения, всё выглядит примерно так же (это касается и модуля ПДО).

Думаю, это связано с типизацией «Постгреса». Взять к примеру числа — два числовых типа ПХП нельзя адекватно преобразовать в россыпь типов «Постгреса», а если привести к неверному типу будут проблемы — в этой СУБД есть понятие перерузки функций, то есть функция выбирается не только по имени, но и по числу и типам параметров.

Поэтому и выбраны строки — они приведутся к нужному числовому типу сами собой, со строками это работает. К сожалению в этом преобразовании кроются и проблемы.

Ещё когда мы работали только с «Ораклом», заметили, что если вместо чисел привязывать строки, то иногда планы выполнения запросов меняются в худшую сторону. Лёгкость обращения с типами в ПХП иногда к этому приводит — переменная, используемая для хранения числа, имеет строковый тип.

К счастью, в оракловом модуле это легко решается — при привязке надо всего лишь указать, что тут мы имеем ввиду число, сами собой совершатся нужные преобразования и «Оракл» так устроен, что никаких проблем это не породит.

В «Постгресе» проблемы будут, я их описал выше, так ещё и способа хорошего нет — все функции любого модуля для работы с этой базой, доступные в ПХП любой версии, преобразуют все числа в строки. К несчастью, в «Постгресе» проблема изменения планов тоже имеет место — недавно наткнулись на запрос, который через ПХП выполняется почти полторы секунды, а через консольный клиент — меньше миллисекунды.

Мой братишка придумал оригинальное решение — определять позиции на которых мы привязываем числа и автоматически внутри нашего фреймворка в этом месте запроса указывать тип bigint явным образом. То есть добавлять после плейсхолдера параметра конструкцию «::bigint».

Пришлось изменить несколько наших хранимых процедур, но в целом всё плошло довольно гладко.

🐘 Кое-что новое об анонимных функциях PHP

Век живи, век учись. Даже чтение документации не избавляет от дыр в её знании, стоит пропустить одно-два предложения и не узнаешь о какой-то конструкции. Совершенно случайно заметил в документации любопытное сочетание ключевых слов. Внимание на пример ниже:

class Tester
{
    public function returnFunc()
    {
        return function() {
            return "Function call.\n";
        };
    }

    public function returnStaticFunc()
    {
        return static function() {
            return "Static function call.\n";
        };
    }

    public function __destruct()
    {
        echo "Tester died.\n";
    }
}

$holder = (new Tester)->returnFunc();
gc_collect_cycles();
echo $holder(); // «Function call.»
unset($holder);

$holderStatic = (new Tester)->returnStaticFunc();
gc_collect_cycles(); // дважды «Tester died»
echo $holderStatic(); // «Static function call.»

Обратите внимание на последовательность «return static». Дело в том, что анонимные функции в ПХП, если они создаются в методе, неявно захватывают его контекст, то есть переменную $this. В этом нет никакой беды, если функция короткоживующая.

В моём примере другая ситуация — объект возвращает анонимную функцию, но сам он уже не нужен. Логично было бы предположить, что его при следующей сборке прибъёт сборщик мусора (который я принудительно вызываю функцией gc_collect_cycles), но нет. Ссылка на него содержится в анонимной функции, порождённой методом returnFunc, ПХП интерпретатор и понятия не имеет нужен ли будет внутри контекст объекта, поэтому он его захватывает внутри на всякий случай.

Во втором подпримере конструкция «static function» говорит интерпретатору, что в ссылке на объект мы не нуждаемся, потому вызов gc_collect_cycles убивает два объекта — как предыдущего подпримера (так как переменная $holder уничтожена), так и из текущего, так как такая анонимная функция не содержит ссылку на объект.

2016   php   weddev
Ранее Ctrl + ↓