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, но один массив может быть частью нескольких циклов 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
создает резервную копию указателя на текущий элемент и его хэш в каждом цикле каждого цикла. 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()
- это функция по ссылке (на самом деле: предпочитать ссылку), даже если она не изменяет массив. Это должно быть для того, чтобы хорошо работать со всеми другими функциями, такими как next
, которые все являются ссылочными. Передача по ссылке подразумевает, что массив должен быть разделен, и поэтому $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
перемещает указатель.
Вы получаете то же поведение при выполнении итерации по ссылке:
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, поэтому мы рассмотрим несколько примеров для этого случая.
Рассмотрим эти вложенные циклы по одному и тому же массиву (где итерация по ссылке используется, чтобы убедиться, что это действительно один и тот же):
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
Итераторы хеш-таблицы
Если вы все еще помните, основная проблема с итерацией массива заключалась в том, как обрабатывать удаление элементов в середине итерации. В 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 переходил прямо к новому элементу, потому что он «выглядел» так, как будто он был таким же, как удаленный элемент (из-за столкновения хеша и указателя). Поскольку мы больше ни в чем не полагаемся на хэш элемента, это больше не проблема.
@DaveRandom Там должен быть тег php-internals, но я оставлю его вам решать, какой из 5 других тегов заменить.
попробуйте также
unset($array[$key + 1]);
выглядит как КОРОВА, без дескриптора удаления
Сначала я подумал: «Господи, еще один вопрос для новичков». Прочтите документацию… хм, явно неопределенное поведение «. Затем я прочитал вопрос полностью и должен сказать: мне он нравится. Вы приложили немало усилий и написали все тестовые примеры. пс. тестовые случаи 4 и 5 одинаковы?
@knittl Разница между 4 + 5 составляет
each()
противreset()
- попытка заставить его пропустить элемент или начать с начала, соответственно. Поведение, похоже, одинаково для обоих, однако указатель исходного массива игнорируется.@eicto По-прежнему не влияет на выполнение цикла - codepad.org/y4hPzEw6. что вы имеете в виду под
looks like COW...
- не могли бы вы уточнить?@eicto О, я понимаю, что вы имеете в виду, спасибо за разъяснения. Я думаю, что это, вероятно, разумное предложение, хотя я все еще не уверен, как это повлияет на указатель исходного массива. Я не думал, что вы имели в виду, что он выглядел как черно-белый рогатый скот, но это тоже хорошо ;-)
Просто подумайте о том, почему имеет смысл касаться указателя массива: PHP необходимо сбросить и переместить указатель внутреннего массива исходного массива вместе с копией, потому что пользователь может запросить ссылку на текущее значение (
foreach ($array as &$value)
) - PHP должен знать текущую позицию в исходном массиве, даже если он фактически выполняет итерацию по копии.этот выглядит более запутанным codepad.org/RKFka0td
Кстати, это не
C++
-fu, вам нужноC
-fu. :)На самом деле, @OliCharlesworth, я бы сказал, что PHP имеет лучшую документацию по любому языку программирования в мире. Есть множество причин ненавидеть PHP, но его документация к ним не относится.
@Sean: IMHO, документация по PHP действительно плохо описывает нюансы основных языковых функций. Но это, возможно, потому, что в язык встроено так много специальных случаев ...
Вы выполняете итерацию по массиву с refcount = 1 по ссылке, поэтому сразу становится ясно, что а) копия не будет сделана и б) массив будет сделан ссылкой (удаление ссылки приведет к тому, что изменения не будут видны внутри цикла ).
@monocell да, я еще раз перечитал. Но дело в том, что мне не так ясно, что одна и та же механика порядка оценки кода (и перемещения указателя) работает по-разному.
@AlmaDo: этот вопрос обсуждается на Meta: meta.stackexchange.com/questions/229549/…
(технически любое поведение является неожиданным, так как я больше не знаю, чего ожидать) - великолепно