Ответ 1
foreach
поддерживает итерацию по трем различным типам значений:
- Массивы
- Нормальные объекты
Traversable
объекты
Далее я попытаюсь объяснить, как итерация работает в разных случаях. Безусловно, самый простой случай - это объекты Traversable
, так как для них foreach
является, по сути, только синтаксическим сахаром для кода следующего вида:
foreach ($it as $k => $v) { /* ... */ }
/* translates to: */
if ($it instanceof IteratorAggregate) {
$it = $it->getIterator();
}
for ($it->rewind(); $it->valid(); $it->next()) {
$v = $it->current();
$k = $it->key();
/* ... */
}
Для внутренних классов фактические вызовы методов исключаются с помощью внутреннего API, который, по сути, просто отражает интерфейс Iterator
на уровне C.
Итерация массивов и простых объектов значительно сложнее. Прежде всего, следует отметить, что в PHP "массивы" - это действительно упорядоченные словари, и они будут проходить в соответствии с этим порядком (который соответствует порядку вставки, если вы не использовали что-то вроде sort
). Это противоречит итерации по естественному порядку ключей (как часто работают списки на других языках) или вообще не имеет определенного порядка (как часто работают словари на других языках).
То же самое относится и к объектам, поскольку свойства объекта можно рассматривать как другой (упорядоченный) словарь, сопоставляющий имена свойств с их значениями, а также некоторую обработку видимости. В большинстве случаев свойства объекта не сохраняются таким неэффективным способом. Однако, если вы начнете перебирать объект, упакованное представление, которое обычно используется, будет преобразовано в реальный словарь. В этот момент итерация простых объектов становится очень похожа на итерацию массивов (именно поэтому я не обсуждаю здесь итерацию простых объектов).
Все идет нормально. Перебор словаря не может быть слишком сложным, верно? Проблемы начинаются, когда вы понимаете, что массив/объект может меняться во время итерации. Это может происходить несколькими способами:
- Если вы выполняете итерацию по ссылке, используя
foreach ($arr as &$v)
, то$arr
превращается в ссылку, и вы можете изменить ее во время итерации. - В PHP 5 применяется то же самое, даже если вы выполняете итерацию по значению, но массив был ссылкой заранее:
$ref =& $arr; foreach ($ref as $v)
- Объекты имеют обходную семантику передачи, что для большинства практических целей означает, что они ведут себя как ссылки. Таким образом, объекты всегда могут быть изменены во время итерации.
Проблема с разрешением модификаций во время итерации - это тот случай, когда элемент, на котором вы сейчас находитесь, удален. Скажем, вы используете указатель, чтобы отслеживать, какой элемент массива вы используете в данный момент. Если этот элемент теперь освобожден, у вас остается висячий указатель (обычно приводящий к segfault).
Существуют разные способы решения этой проблемы. PHP 5 и PHP 7 значительно различаются в этом отношении, и я опишу оба поведения в следующем. Суть в том, что подход PHP 5 был довольно глупым и приводил к всевозможным странным проблемам с крайними случаями, в то время как более сложный подход PHP 7 приводит к более предсказуемому и последовательному поведению.
В качестве последнего предварительного замечания следует отметить, что PHP использует подсчет ссылок и копирование при записи для управления памятью. Это означает, что если вы "копируете" значение, вы фактически просто используете старое значение и увеличиваете его счетчик ссылок (refcount). Только после того, как вы выполните какую-либо модификацию, будет сделана настоящая копия (дублирование). См. . Вам лгут для более подробного ознакомления с этой темой.
PHP 5
Внутренний указатель массива и HashPointer
Массивы в PHP 5 имеют один выделенный "внутренний указатель массива" (IAP), который должным образом поддерживает изменения: всякий раз, когда элемент удаляется, будет проверяться, указывает ли IAP на этот элемент. Если это так, вместо этого он продвигается к следующему элементу.
Хотя foreach
действительно использует IAP, есть дополнительное осложнение: существует только один IAP, но один массив может быть частью нескольких циклов foreach
:
// Using by-ref iteration here to make sure that it really
// the same array in both loops and not a copy
foreach ($arr as &$v1) {
foreach ($arr as &$v) {
// ...
}
}
Для поддержки двух одновременных циклов только с одним указателем внутреннего массива foreach
выполняет следующие махинации: перед выполнением тела цикла foreach
создаст резервную копию указателя на текущий элемент и его хэш в каждом foreach HashPointer
. После запуска тела цикла IAP будет возвращен к этому элементу, если он все еще существует. Однако, если элемент был удален, мы просто будем использовать там, где сейчас находится IAP. Эта схема в основном своего рода работает, но вы можете извлечь из нее много странного поведения, некоторые из которых я продемонстрирую ниже.
Дублирование массива
IAP - это видимая особенность массива (предоставляемая с помощью семейства функций current
), поскольку такие изменения в IAP считаются модификациями в семантике копирования при записи. К сожалению, это означает, что во многих случаях foreach
вынужден дублировать массив, по которому он повторяется. Точные условия:
- Массив не является ссылкой (is_ref = 0). Если это ссылка, то изменения в ней должны распространяться, поэтому ее не следует дублировать.
- Массив имеет refcount> 1. Если
refcount
равен 1, то массив не является общим, и мы можем изменить его напрямую.
Если массив не дублируется (is_ref = 0, refcount = 1), то будет увеличен только его refcount
(*). Кроме того, если foreach
по ссылке используется, массив (потенциально дублированный) будет превращен в ссылку.
Рассмотрим этот код в качестве примера, где происходит дублирование:
function iterate($arr) {
foreach ($arr as $v) {}
}
$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);
Здесь, $arr
будет продублировано, чтобы предотвратить утечку изменений IAP на $arr
в $outerArr
. С точки зрения условий выше, массив не является ссылкой (is_ref = 0) и используется в двух местах (refcount = 2). Это требование является неудачным и является артефактом неоптимальной реализации (здесь нет проблем с модификацией во время итерации, поэтому нам не нужно в первую очередь использовать IAP).
(*) Увеличение значения refcount
здесь звучит безобидно, но нарушает семантику копирования при записи (COW): это означает, что мы собираемся изменить IAP массива refcount = 2, в то время как COW требует, чтобы изменения могли выполняться только на refcount = 1 значения. Это нарушение приводит к изменению поведения, видимому пользователю (в то время как COW обычно прозрачна), потому что изменение IAP в итерированном массиве будет наблюдаться - но только до первой не-IAP модификации в массиве. Вместо этого тремя "действительными" вариантами было бы: а) всегда дублировать, б) не увеличивать refcount
и, таким образом, позволять произвольному изменению повторяющегося массива в цикле, или в) вообще не использовать IAP (решение PHP 7).
Порядок продвижения позиции
Есть одна последняя деталь реализации, о которой вы должны знать, чтобы правильно понять примеры кода ниже. "Нормальный" способ прохождения некоторой структуры данных будет выглядеть примерно так в псевдокоде:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
code();
move_forward(arr);
}
Однако foreach
, будучи довольно особенной снежинкой, предпочитает делать вещи немного по-другому:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
move_forward(arr);
code();
}
А именно, указатель массива уже перемещен вперед до запуска тела цикла. Это означает, что пока тело цикла работает с элементом $i
, IAP уже находится в элементе $i+1
. По этой причине примеры кода, показывающие изменение во время итерации, всегда unset
будут следующим элементом, а не текущим.
Примеры: ваши тесты
Три аспекта, описанные выше, должны дать вам в основном полное представление об особенностях реализации foreach
, и мы можем перейти к обсуждению некоторых примеров.
Поведение ваших тестовых примеров просто объяснить в этой точке:
В тестовых примерах 1 и 2
$array
начинается с refcount = 1, поэтому он не будет дублироватьсяforeach
: увеличивается толькоrefcount
. Когда тело цикла впоследствии модифицирует массив (который имеет refcount = 2 в этой точке), дублирование произойдет в этой точке. Foreach продолжит работу над неизмененной копией$array
.В тестовом примере 3 массив снова не дублируется, поэтому
foreach
будет изменять IAP переменной$array
. В конце итерации IAP равен NULL (что означает, что итерация выполнена), что указываетeach
, возвращаяfalse
.В тестовых примерах 4 и 5 функции
each
иreset
являются функциями ссылки.$array
имеетrefcount=2
, когда он передается им, поэтому он должен быть продублирован. Таким образом,foreach
снова будет работать с отдельным массивом.
Примеры: Эффекты current
в foreach
Хороший способ показать различные режимы дублирования - наблюдать за поведением функции current()
внутри цикла foreach
. Рассмотрим этот пример:
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 2 2 2 2 */
Здесь вы должны знать, что current()
является функцией by-ref (на самом деле: предпочитают-ref), даже если она не модифицирует массив. Это должно быть сделано для того, чтобы хорошо играть со всеми другими функциями, такими как next
, которые все by-ref. Передача по ссылке подразумевает, что массив должен быть разделен, и поэтому $array
и foreach-array
будут разными. Причина, по которой вы получаете 2
вместо 1
, также упоминалась выше: foreach
продвигает указатель массива до запуска пользовательского кода, а не после. Таким образом, несмотря на то, что код находится на первом элементе, foreach
уже продвинул указатель на второй элемент.
Теперь давайте попробуем небольшую модификацию:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Здесь у нас есть случай is_ref = 1, поэтому массив не копируется (как выше). Но теперь, когда это ссылка, массив больше не должен дублироваться при передаче функции by-ref current()
. Таким образом, current()
и foreach
работают с одним и тем же массивом. Тем не менее, вы все еще видите поведение "один за другим" из-за способа, которым foreach
продвигает указатель.
Вы получаете то же самое поведение, когда выполняете итерацию по-ref:
foreach ($array as &$val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Здесь важная часть заключается в том, что foreach сделает $array
is_ref = 1, когда он повторяется по ссылке, так что в основном у вас та же ситуация, что и выше.
Еще одно небольшое изменение, на этот раз мы назначим массив другой переменной:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 1 1 1 1 1 */
Здесь в [цикл TG467] повторный счет равен 2, когда цикл запускается, так что на этот раз нам действительно нужно выполнить дублирование заранее. Таким образом, $array
и массив, используемый foreach, будут полностью отделены от начала. Вот почему вы получаете положение IAP, где бы оно ни находилось до цикла (в данном случае это было в первой позиции).
Примеры: модификация во время итерации
Попытка учесть модификации во время итерации - вот откуда возникли все наши проблемы с foreach, так что это служит для рассмотрения некоторых примеров для этого случая.
Рассмотрим эти вложенные циклы в одном и том же массиве (где используется итерация by-ref, чтобы удостовериться, что она действительно одна и та же):
foreach ($array as &$v1) {
foreach ($array as &$v2) {
if ($v1 == 1 && $v2 == 1) {
unset($array[1]);
}
echo "($v1, $v2)\n";
}
}
// Output: (1, 1) (1, 3) (1, 4) (1, 5)
Здесь ожидаемая часть заключается в том, что (1, 2)
отсутствует в выходных данных, поскольку элемент 1
был удален. Вероятно, неожиданно то, что внешний цикл останавливается после первого элемента. Почему это?
Причиной этого является хак с вложенным циклом, описанный выше: перед выполнением тела цикла текущее положение IAP и хеш-код копируются в HashPointer
. После тела цикла оно будет восстановлено, но только если элемент все еще существует, в противном случае вместо него используется текущая позиция IAP (какой бы она ни была). В приведенном выше примере это именно тот случай: текущий элемент внешнего цикла был удален, поэтому он будет использовать IAP, который уже помечен как завершенный внутренним циклом!
Другое последствие механизма резервного копирования и восстановления HashPointer
заключается в том, что изменения в IAP через reset()
и т.д. Обычно не влияют на foreach
. Например, следующий код выполняется так, как если бы reset()
вообще не было:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
// output: 1, 2, 3, 4, 5
Причина в том, что, хотя reset()
временно изменяет IAP, он будет восстановлен в текущем элементе foreach после тела цикла. Чтобы заставить reset()
влиять на цикл, вам необходимо дополнительно удалить текущий элемент, чтобы сбой механизма резервного копирования/восстановления:
$array = [1, 2, 3, 4, 5];
$ref =& $array;
foreach ($array as $value) {
var_dump($value);
unset($array[1]);
reset($array);
}
// output: 1, 1, 3, 4, 5
Но эти примеры всё ещё нормальны. Самое интересное начинается, если вы помните, что восстановление HashPointer
использует указатель на элемент и его хеш, чтобы определить, существует ли он до сих пор. Но: у хэшей есть коллизии, и указатели можно использовать повторно! Это означает, что при тщательном выборе ключей массива мы можем заставить foreach
поверить, что удаленный элемент все еще существует, поэтому он сразу перейдет к нему. Пример:
$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
$ref =& $array;
foreach ($array as $value) {
unset($array['EzFY']);
$array['FYFY'] = 4;
reset($array);
var_dump($value);
}
// output: 1, 4
Здесь обычно следует ожидать выходной сигнал 1, 1, 3, 4
в соответствии с предыдущими правилами. В результате получается, что 'FYFY'
имеет тот же хэш, что и удаленный элемент 'EzFY'
, и распределитель случайно использует ту же ячейку памяти для хранения элемента. Таким образом, foreach заканчивает тем, что непосредственно переходит к вновь вставленному элементу, таким образом сокращая цикл.
Подстановка повторяющегося объекта во время цикла
Еще один странный случай, о котором я хотел бы упомянуть, это то, что PHP позволяет заменять повторяющуюся сущность во время цикла. Таким образом, вы можете начать перебирать один массив, а затем заменить его другим массивом на полпути. Или начните итерацию с массива, а затем замените его объектом:
$arr = [1, 2, 3, 4, 5];
$obj = (object) [6, 7, 8, 9, 10];
$ref =& $arr;
foreach ($ref as $val) {
echo "$val\n";
if ($val == 3) {
$ref = $obj;
}
}
/* Output: 1 2 3 6 7 8 9 10 */
Как вы можете видеть, в этом случае PHP просто начнет итерировать другую сущность с самого начала, как только произойдет замена.
PHP 7
Hashtable итераторы
Если вы все еще помните, основная проблема с итерацией массива заключалась в том, как обрабатывать удаление элементов в середине итерации. В PHP 5 для этой цели использовался один внутренний указатель массива (IAP), что было несколько неоптимальным, поскольку один указатель массива нужно было растянуть для поддержки нескольких одновременных циклов foreach и взаимодействия с reset()
и т.д. Поверх этого.
В PHP 7 используется другой подход, а именно, он поддерживает создание произвольного количества внешних безопасных хеш-таблиц итераторов. Эти итераторы должны быть зарегистрированы в массиве, с этого момента они имеют ту же семантику, что и IAP: если элемент массива удален, все итераторы хеш-таблицы, указывающие на этот элемент, будут перенесены на следующий элемент.
Это означает, что foreach
больше не будет использовать IAP вообще. Цикл foreach
не окажет абсолютно никакого влияния на результаты current()
и т.д., И на его собственное поведение никогда не будут влиять такие функции, как reset()
и т.д.
Дублирование массива
Другое важное изменение между PHP 5 и PHP 7 связано с дублированием массива. Теперь, когда IAP больше не используется, итерация массива по значению будет делать только приращение refcount
(вместо дублирования массива) во всех случаях. Если массив изменяется во время цикла foreach
, в этот момент произойдет дублирование (в соответствии с копированием при записи), и foreach
продолжит работу со старым массивом.
В большинстве случаев это изменение прозрачно и не имеет никакого другого эффекта, кроме лучшей производительности. Однако есть один случай, когда это приводит к другому поведению, а именно случай, когда массив был ссылкой заранее:
$array = [1, 2, 3, 4, 5];
$ref = &$array;
foreach ($array as $val) {
var_dump($val);
$array[2] = 0;
}
/* Old output: 1, 2, 0, 4, 5 */
/* New output: 1, 2, 3, 4, 5 */
Ранее итерация ссылочных массивов по значению была особым случаем. В этом случае дублирование не произошло, поэтому все модификации массива во время итерации будут отражены циклом. В PHP 7 этот особый случай пропал: итерация массива по значению будет всегда продолжать работать с исходными элементами, игнорируя любые модификации во время цикла.
Это, конечно, не относится к итерации по ссылкам. Если вы выполняете итерацию по ссылке, все изменения будут отражены в цикле. Интересно, что то же самое верно для итерации по значению простых объектов:
$obj = new stdClass;
$obj->foo = 1;
$obj->bar = 2;
foreach ($obj as $val) {
var_dump($val);
$obj->bar = 42;
}
/* Old and new output: 1, 42 */
Это отражает семантику объектов-дескрипторов (т.е. они ведут себя как ссылки, даже в контекстах по значению).
Примеры
Давайте рассмотрим несколько примеров, начиная с ваших тестовых примеров:
Контрольные примеры 1 и 2 сохраняют один и тот же вывод: итерация массива по значению всегда работает с исходными элементами. (В этом случае даже
refcounting
и поведение дублирования одинаково для PHP 5 и PHP 7).Изменения в тестовом примере 3:
Foreach
больше не использует IAP, поэтому циклeach()
не затрагивается. Он будет иметь одинаковый вывод до и после.Контрольные примеры 4 и 5 остаются прежними:
each()
иreset()
дублируют массив перед изменением IAP, в то время какforeach
все еще использует исходный массив. (Не то, чтобы изменение IAP имело значение, даже если массив был общим.)
Второй набор примеров был связан с поведением current()
в различных конфигурациях reference/refcounting
. Это больше не имеет смысла, так как current()
полностью не подвержен влиянию цикла, поэтому его возвращаемое значение всегда остается неизменным.
Тем не менее, мы получаем некоторые интересные изменения при рассмотрении изменений во время итерации. Я надеюсь, что вы найдете новое поведение разумнее. Первый пример:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$v1) {
foreach ($array as &$v2) {
if ($v1 == 1 && $v2 == 1) {
unset($array[1]);
}
echo "($v1, $v2)\n";
}
}
// Old output: (1, 1) (1, 3) (1, 4) (1, 5)
// New output: (1, 1) (1, 3) (1, 4) (1, 5)
// (3, 1) (3, 3) (3, 4) (3, 5)
// (4, 1) (4, 3) (4, 4) (4, 5)
// (5, 1) (5, 3) (5, 4) (5, 5)
Как видите, внешний цикл больше не прерывается после первой итерации. Причина в том, что оба цикла теперь имеют совершенно отдельные хеш-таблицы итераторов, и больше нет перекрестного загрязнения обоих циклов через общую IAP.
Еще один странный крайний случай, который сейчас исправлен, это странный эффект, который вы получаете, когда удаляете и добавляете элементы, которые имеют одинаковый хэш:
$array = ['EzEz' => 1, 'EzFY' => 2, 'FYEz' => 3];
foreach ($array as &$value) {
unset($array['EzFY']);
$array['FYFY'] = 4;
var_dump($value);
}
// Old output: 1, 4
// New output: 1, 3, 4
Ранее механизм восстановления HashPointer перешел прямо к новому элементу, потому что он "выглядел" так же, как и удаленный элемент (из-за коллизии хеша и указателя). Поскольку мы больше ни на что не полагаемся на хэш элемента, это больше не проблема.