Все знают, что  foreach  по ссылке оставляет ссылку в  $row . Но он также оставляет ссылку в каждом элементе массива.

 <?php

declare(strict_types=1);

$baseline = memory_get_usage();
$rows = range(0, 999_999);
printf("build            %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
foreach ($rows as $row) {
}
printf("read-only walk   %+d bytes\n", memory_get_usage() - $baseline);

$baseline = memory_get_usage();
foreach ($rows as &$row) {
}
unset($row);
printf("by-reference walk %+d bytes\n", memory_get_usage() - $baseline); 
 build            +16793680 bytes
read-only walk   +0 bytes
by-reference walk +32000032 bytes 

Оба тела циклов пусты. Одно из них ничего не стоит, а другое стоит примерно вдвое больше самого массива, причём навсегда —  unset($row) , который рекомендуют в каждой статье, был выполнен перед последним замером и ничего из этого не освободил.

Как собирались эти цифры

PHP 8.5.10, NTS, arm64, сборка Homebrew, ноутбук Apple M4 Pro с 24 ГБ ОЗУ и 12 логическими ядрами, под управлением macOS, без специальной изоляции нагрузки. OPcache и JIT отключены для всех показателей, а  memory_limit  увеличен, чтобы поместились случаи с миллионом элементов. Это та же машина и та же версия, что и в статье о том, что на самом деле хранит переменная, поэтому цифры здесь и там можно сравнивать напрямую.

Показатели памяти представляют собой точные единичные замеры, а не медианы: повторные запуски каждого скрипта давали байт-в-байт одинаковый результат, поэтому отклонений нет. Замеры времени — это медиана 15 чередующихся запусков с отброшенными тремя прогревочными прогонами, измеренная с помощью  hrtime()  внутри процесса; диапазон указан для каждого случая. Оценивайте время выполнения относительно друг друга, а не как абсолютные величины для вашего железа, по причинам, делающим абсолютные замеры непереносимыми.

Каждый слот становится ссылкой

Цикл  foreach  по ссылке должен передать телу цикла нечто, через что тело сможет выполнять запись. Он делает это, по очереди преобразуя каждый элемент в ссылку PHP — слот элемента перестаёт хранить значение и начинает хранить указатель на структуру  zend_reference , которая теперь и содержит это значение. Затем цикл связывает  $row  с этой структурой.

Чего цикл не делает, так это не преобразует элемент обратно при переходе к следующему. Массив сохраняет каждую созданную им ссылку:

 <?php

declare(strict_types=1);

$walked = [1, 2];
foreach ($walked as &$value) {
}
unset($value);
debug_zval_dump($walked);

$untouched = [1, 2];
debug_zval_dump($untouched); 
 array(2) packed refcount(2){
  [0]=>
  reference refcount(1) {
    int(1)
  }
  [1]=>
  reference refcount(1) {
    int(2)
  }
}
array(2) packed refcount(3){
  [0]=>
  int(1)
  [1]=>
  int(2)
} 

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

байты на элемент
 range(0, 999_999)  16,793,680 16.79
после  foreach ($rows as &$row)  48,793,712 48.79
разница 32,000,032 32.00

Это в 2,905 раза больше исходного массива. Структура не изменилась — упакованный формат (packed layout), хранящий значения и вычисляющий ключи по позиции, сохраняется после обхода, и  debug_zval_dump()  всё ещё выводит  packed  выше. Те же миллион целых чисел, переведённые в хэшированный формат, занимают 41 943 120 байт, так что обход по ссылке — более затратная из двух вещей, которые могут произойти с упакованным массивом.

Оборачивание идемпотентно. Второй  foreach  по ссылке по тому же массиву не выделил больше ни одного байта, потому что оборачивать было уже нечего.

Повреждение данных — меньшая из бед

Поведение, о котором все пишут, можно увидеть в данных:

 <?php

declare(strict_types=1);

$names = ['ada', 'grace', 'alan', 'edsger'];

foreach ($names as &$name) {
    $name = strtoupper($name);
}

foreach ($names as $name) {
}

print_r($names); 
 Array
(
    [0] => ADA
    [1] => GRACE
    [2] => ALAN
    [3] => ALAN
) 

 edsger  исчез. После первого цикла  $name  всё ещё связан со ссылкой, находящейся в последнем элементе, поэтому второй цикл не читает данные в свежую переменную — он по очереди присваивает каждое значение через эту ссылку в слот 3. Последняя запись перед завершением цикла — это предпоследнее значение, именно поэтому  ALAN  появляется дважды. Добавление  unset($name)  между циклами восстанавливает  EDSGER , так как разрывает связь, не затрагивая ссылку в самом массиве.

Это задокументированное поведение, а не баг, и менять его не планируется: RFC, предлагавший разворачивать ссылку после цикла, предназначался для PHP 8.2 и его статус — Withdrawn (отозван). Рекомендация вызывать  unset()  актуальна всегда.

Кроме того, это та часть проблемы, которая сразу даёт о себе знать. Неправильное значение в массиве всплывёт в тесте; 32 байта на элемент — нет.

Как вернуть массив в исходное состояние

Очевидный кандидат не работает.  array_values()  на массиве, ключи которого уже представляют собой последовательный список, возвращает тот же массив с увеличенным счётчиком ссылок, так что ссылки возвращаются вместе с ним:

Применено к обойдённому массиву Результат всё ещё содержит ссылки
 array_values($rows)  да
 array_merge([], $rows)  да
 sort($rows)  да
 array_slice($rows, 0)  нет
 array_map(fn, $rows)  нет
 array_filter($rows, fn)  нет
 unserialize(serialize($rows))  нет
 $copy = $rows; $copy[0] = 1;  нет

 array_slice($rows, 0)  — самый дешёвый из рабочих способов, он вернул 32 000 000 из 32 000 032 байт, задействованных при обходе. Оставшиеся 32 байта — это ещё одна ссылка, выделенная единоразово, а не для каждого элемента, и удерживаемая чем-то помимо массива; я не стал выслеживать её в исходном коде на C. Последняя строка таблицы — самая интересная: разделение обойдённого массива создаёт развёрнутую копию, так что механизм copy-on-write ничем из этого не ломается. Переменные всё так же разделяют данные до первой записи, функция, принимающая массив по значению, всё так же получает изолированную копию, и вся семантика из первой статьи серии остается в силе. Обход изменил только расход памяти и ничего больше.

И это стоит сказать прямо, потому что фраза «массив теперь полон ссылок» звучит так, будто это должно влиять на корректность работы, хотя это не так.

Где накладные расходы — лишь погрешность округления

Тридцать два байта на элемент — это фиксированная плата, поэтому всё зависит от стоимости самих элементов. Миллион обычных целых чисел стоят по 16,79 байта каждый, и эта плата увеличивает их расход почти в три раза. С пятьюдесятью тысячами ассоциативных строк из трёх полей ситуация иная:

 built                                     21,844,288 bytes   436.89 b/row
after foreach ($rows as &$r) { ... }       1,600,000 bytes    32.00 b/row
growth                                          7.32% 

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

С точки зрения времени выполнения аргументов против цикла тоже нет. Прибавление единицы к каждому элементу массива из 1 048 576 элементов тремя способами:

медиана диапазон
 foreach ($rows as &$row) { $row++; }  7.425 ms 7.116–7.504
 foreach ($rows as $i => $row) { $rows[$i] = $row + 1; }  7.816 ms 7.679–8.011
 array_map(static fn (int $x): int => $x + 1, $rows)  15.981 ms 15.627–16.406

Цикл по ссылке — самый быстрый из трёх. Плата за него взимается позже, при каждом последующем чтении массива: полный индексированный сканирующий обход показал 4,835 мс на «чистом» массиве против 5,079 мс на обойдённом, а чтение через  foreach  — 3,245 мс против 3,420 мс, то есть около 5% при каждом последующем проходе, причём диапазоны замера не перекрывались во втором случае.

Для чего на самом деле нужен амперсанд

Всё вышесказанное не является аргументом против ссылок в параметрах функций — это другой механизм с другой ценой. Передача массива из 1 048 576 элементов в функцию и запись одного элемента:

Вызов На один вызов
 readsOnly(array $rows)  11 ns
 readsOnlyByRef(array &$rows)  12 ns
 writesOneByRef(array &$rows)  14 ns
 writesOne(array $rows)  1,797.615 µs

Разница между двумя последними строками — в 128 401 раз, и причина в той самой вещи, с которой началась эта серия статей: параметр по значению разделяет массив с вызывающим кодом, поэтому первая же запись приводит к разделению всего массива. Амперсанд не делает сам вызов дешевле — он убирает второго владельца, так что разделять становится нечего. Кроме того, параметр по ссылке в этой сборке не оборачивал элементы вызывающей стороны; это делает только  foreach .

Мифы, утверждающие обратное, устарели. Совет никогда не использовать ссылки для ускорения передачи больших массивов находится в пользовательской заметке на странице документации по ссылкам, а соседняя заметка, объясняющая механизм, описывает PHP 4. Часто цитируемая демонстрация — это gist с замером неиспользуемого связывания  $ref = &$array , где 10 000 холостых вызовов занимают 0,5552 секунды против 0,0022 без него — разница примерно в 250 раз на PHP 5.5. Я запустил аналогичный тест на 8.5.10:

10 000 вызовов  noop(array $rows)  медиана относительно
без связывания ссылки 0.119 ms 1.000x
неиспользуемый  $ref = &$rows  0.114 ms 0.955x
второе имя  $b = $rows  0.119 ms 0.996x

Никакой разницы нет. Штраф по производительности был реальным на момент замеров и относился к представлению данных, которое PHP больше не использует: в PHP 5 флаг ссылки находился внутри zval, поэтому значение не могло одновременно разделяться между ссылочной и нессылочной переменной, и копирование происходило принудительно. В PHP 7 ссылку вынесли в отдельную структуру (ту самую 32-байтовую обёртку), и с тех пор передача по значению читает через неё. Проект, в котором до сих пор избегают ссылок по этой причине, опирается на решение, которое никто не перепроверял.

Что это меняет в коде, который вы пишете

Сделайте grep по  as &$  и посмотрите, что происходит с массивом дальше. Обход через  foreach  по ссылке для списка, который вы изменяете и выбрасываете, не стоит практически ничего. Но тот же цикл по массиву, переживающему запрос (прогретый при старте кэш, таблица поиска в воркере или всё, что хранит долгоживущий процесс), добавляет по 32 байта на элемент на этой сборке и удерживает их. Вызов  array_slice($rows, 0)  после цикла возвращает эту память.

Оставляйте  unset()  независимо от памяти. Это не просто аккуратность и дело совсем не в расходе ресурсов: это строка, которая определяет, будет ли следующий цикл по этой переменной читать массив или записывать в него, а RFC, который сделал бы это действие ненужным, был отозван.

Оценивайте необходимость амперсанда в параметре по тому, что делает функция, а не по размеру аргумента. Если функция записывает в массив, и вызывающему коду нужна эта запись, ссылка — правильный механизм для этого, который к тому же сэкономил здесь полное копирование (разделение). Если функция только читает, ссылка не даёт абсолютно ничего: 11 нс по значению против 12 нс по ссылке для массива из миллиона элементов. Размер аргумента никогда не был ключевым фактором — как было сказано в первом замере этой серии, счёт выставляется при записи, а амперсанд лишь определяет, кому он достанется.