foreach
поддерживает итерацию по трем различным типам значений:
Далее я попытаюсь объяснить, как итерация работает в разных случаях. Безусловно, самый простой случай - это 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, есть дополнительное осложнение: существует только один IAP, но один массив может быть частью нескольких foreach
циклов:
// Using by-ref iteration here to make sure that it's really
// the same array in both loops and not a copy
foreach ($arr as &$v1) {
foreach ($arr as &$v) {
// ...
}
}
Для поддержки двух одновременных циклов только с одним внутренним указателем массива foreach
выполняются следующие махинации: перед выполнением тела цикла foreach
создаст резервную копию указателя на текущий элемент и его хэш в per-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
будет продублировано, чтобы предотвратить $arr
утечку изменений IAP $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
реализации, и мы можем перейти к обсуждению некоторых примеров.
Поведение ваших тестовых примеров просто объяснить в этой точке:
В тестовых $array
примерах 1 и 2 начинается с 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 (на самом деле :fer-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, поэтому массив не копируется (как и выше). Но теперь, когда это ссылка, массив больше не должен дублироваться при передаче в current()
функцию by-ref . Таким образом current()
и foreach
работают на одном массиве. Тем не менее, вы по-прежнему видите поведение «один за другим» из-за того, как foreach
продвигается указатель.
Вы получаете то же поведение при выполнении итерации по-реф:
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 */
Здесь повторный счет $array
равен 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 и хэш копируются в a 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
больше не будет использовать ИПД на всех . 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 перешел прямо к новому элементу, потому что он «выглядел» так, как будто он был удален (из-за столкновения хеша и указателя). Поскольку мы больше ни на что не полагаемся на хеш элемента, это больше не проблема.