Як насправді працює PHP 'foreach'?


2018

Дозвольте мені зафіксувати це, сказавши, що я знаю, що foreachтаке, як і як ним користуватися. Це питання стосується того, як він працює під капотом, і я не хочу відповіді за принципом "це, як ви цикли масив foreach".


Я довго вважав, що foreachпрацював із самим масивом. Тоді я знайшов багато посилань на те, що він працює з копією масиву, і з тих пір я вважав, що це кінець історії. Але я нещодавно вступив у дискусію з цього питання, і після невеликого експерименту виявив, що це насправді не так на 100%.

Дозвольте мені показати, що я маю на увазі. Для наступних тестових випадків ми будемо працювати з наступним масивом:

$array = array(1, 2, 3, 4, 5);

Тестовий випадок 1 :

foreach ($array as $item) {
  echo "$item\n";
  $array[] = $item;
}
print_r($array);

/* Output in loop:    1 2 3 4 5
   $array after loop: 1 2 3 4 5 1 2 3 4 5 */

Це чітко показує, що ми не працюємо безпосередньо з вихідним масивом - інакше цикл триватиме назавжди, оскільки ми постійно натискаємо на масиви під час циклу. Але просто щоб бути впевненим, це так:

Тест 2 :

foreach ($array as $key => $item) {
  $array[$key + 1] = $item + 2;
  echo "$item\n";
}

print_r($array);

/* Output in loop:    1 2 3 4 5
   $array after loop: 1 3 4 5 6 7 */

Це підтверджує наш вихідний висновок, ми працюємо з копією вихідного масиву під час циклу, інакше ми побачили б змінені значення під час циклу. Але ...

Якщо ми подивимось в посібнику , то знайдемо таке твердження:

Коли foreach вперше починає виконувати, внутрішній покажчик масиву автоматично скидається на перший елемент масиву.

Правильно ... це, здається, підказує, що foreachпокладається на покажчик масиву вихідного масиву. Але ми лише довели, що не працюємо з вихідним масивом , правда? Ну не зовсім.

Тест 3 :

// Move the array pointer on one to make sure it doesn't affect the loop
var_dump(each($array));

foreach ($array as $item) {
  echo "$item\n";
}

var_dump(each($array));

/* Output
  array(4) {
    [1]=>
    int(1)
    ["value"]=>
    int(1)
    [0]=>
    int(0)
    ["key"]=>
    int(0)
  }
  1
  2
  3
  4
  5
  bool(false)
*/

Отже, незважаючи на те, що ми не працюємо безпосередньо з вихідним масивом, ми працюємо безпосередньо з вказівником масиву джерела - це свідчить про те, що вказівник знаходиться в кінці масиву в кінці циклу. За винятком того, що це не може бути правдою - якби воно було, то тестовий випадок 1 зациклювався б назавжди.

У посібнику PHP також зазначено:

Оскільки foreach покладається на внутрішній вказівник масиву, зміна його в циклі може призвести до несподіваної поведінки.

Ну, давайте дізнаємося, що це за "несподівана поведінка" (технічно будь-яка поведінка несподівана, оскільки я вже не знаю, чого очікувати).

Тестовий випадок 4 :

foreach ($array as $key => $item) {
  echo "$item\n";
  each($array);
}

/* Output: 1 2 3 4 5 */

Тестовий випадок 5 :

foreach ($array as $key => $item) {
  echo "$item\n";
  reset($array);
}

/* Output: 1 2 3 4 5 */

... нічого такого несподіваного там, насправді, здається, це підтримує теорію "копії джерела".


Питання

Що тут відбувається? Мій C-fu недостатньо хороший, щоб я міг зробити належний висновок, просто переглянувши вихідний код PHP, я би вдячний, якби хтось міг перекласти його англійською мовою для мене.

Мені здається, він foreachпрацює з копією масиву, але встановлює покажчик масиву вихідного масиву на кінець масиву після циклу.

  • Це правильно і вся історія?
  • Якщо ні, то що це насправді?
  • Чи є ситуація , коли з допомогою функцій , які коригують покажчик масиву ( each(), і reset()ін.) Під час foreachмогли вплинути на результат циклу?

5
@DaveRandom Існує тег php- Internals, з яким, ймовірно, слід поступити, але я залишу це вам вирішити, який із інших 5 тегів замінити.
Майкл Берковський

5
виглядає як COW, без ручки для видалення
zb '

149
Спочатку я подумав, що «червня», ще одне питання для новачків. Прочитайте документи… хм, чітко невизначена поведінка «. Тоді я читаю повне запитання, і мушу сказати: мені це подобається. Ви доклали до цього чимало зусиль і написали всі тести. пс. чи тестові вітрини 4 і 5 однакові?
knittl

21
Просто думка про те, чому має сенс торкатися покажчика масиву: PHP потрібно скинути та перемістити внутрішній покажчик масиву вихідного масиву разом з копією, оскільки користувач може попросити посилання на поточне значення ( foreach ($array as &$value)) - PHP повинен знати поточну позицію в оригінальному масиві, навіть якщо він насправді повторюється над копією.
Ніко

4
@Sean: IMHO, документація PHP дійсно погано описує нюанси основних мовних особливостей. Але це, мабуть, тому, що так багато спеціальних випадків випадає на мову ...
Олівер Чарльзворт,

Відповіді:


1660

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)
  • Об'єкти мають побічну передачу семантики, що для більшості практичних цілей означає, що вони поводяться як посилання. Тому об'єкти завжди можна змінювати під час ітерації.

Проблема з дозволом модифікацій під час ітерації полягає в тому випадку, коли елемент, на якому ви зараз перебуваєте, видалений. Скажімо, ви використовуєте вказівник для відстеження того, в якому елементі масиву ви зараз перебуваєте. Якщо цей елемент тепер звільнений, вам залишається звисаючий покажчик (зазвичай це призводить до сегментації).

Існують різні способи вирішення цього питання. 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в багатьох випадках змушений дублювати масив, над яким він повторюється. Точні умови:

  1. Масив не є посиланням (is_ref = 0). Якщо це посилання, то зміни в ньому повинні поширюватися, тому його не слід дублювати.
  2. Масив має 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впровадження, і ми можемо продовжити обговорення деяких прикладів.

На даний момент поведінку ваших тестових випадків просто пояснити:

  • У тестових випадках 1 і 2 $arrayпочинаються з refcount = 1, тому він не буде дублюватися на foreach: refcountзбільшується лише the . Коли тіло циклу згодом модифікує масив (який має рефракцію = 2 у цій точці), дублювання відбудеться в цій точці. Foreach продовжить роботу над немодифікованою копією $array.

  • У тестовому випадку 3 знову масив не дублюється, таким чином, foreachбуде модифікована IAP $arrayзмінної. В кінці ітерації IAP є NULL (тобто ітерація виконана), що eachвказує на повернення false.

  • У тестових прикладах 4 і 5 , як eachі resetє по посиланню функції. Це $arrayє, refcount=2коли він передається їм, тому його треба дублювати. Таким чином, foreachзнову буде працювати над окремим масивом.

Приклади: Ефекти роботи currentforeach

Хороший спосіб показати різні способи дублювання - це спостерігати за поведінкою current()функції всередині foreachциклу. Розглянемо цей приклад:

foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 2 2 2 2 2 */

Тут ви повинні знати, що current()це функція by-ref (насправді: предпочитаю-ref), навіть якщо вона не модифікує масив. Це повинно бути для того, щоб грати добре з усіма іншими функціями, на зразок 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 зробить $arrayis_ref = 1, коли він повторюється за допомогою посилання, тому в основному у вас така ж ситуація, як вище.

Ще одна невелика варіація, на цей раз ми призначимо масив іншій змінній:

$foo = $array;
foreach ($array as $val) {
    var_dump(current($array));
}
/* Output: 1 1 1 1 1 */

Тут коефіцієнт знижки $arrayстановить 2, коли цикл запускається, тож колись насправді треба робити дублювання вперед. Таким чином, $arrayмасив, який використовується foreach, буде повністю відокремлений від самого початку. Ось чому ви отримуєте позицію IAP там, де вона була до циклу (у цьому випадку вона була на першій позиції).

Приклади: Модифікація під час ітерації

Спроба врахувати зміни під час ітерації - це те, звідки виникли всі наші проблеми з передбаченням, тому це служить для розгляду деяких прикладів для цього випадку.

Розглянемо ці вкладені петлі над тим же масивом (де використовується повторна ітерація, щоб переконатися, що вона справді однакова):

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

Ітератори хешбелу

Якщо ви все ще пам’ятаєте, головна проблема ітерації масиву полягала в тому, як обробляти видалення елементів середньої ітерації. 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 перейшов праворуч до нового елемента, оскільки він "виглядав" так, як він є таким же, як і видалений елемент (за рахунок стикання хеша та покажчика). Оскільки ми більше не покладаємось на хеш-елемент елементів, це вже не є проблемою.


4
@Baba Це так. Передати його у функцію - це те саме, що робити $foo = $arrayперед циклом;)
NikiC


1
Незначна корекція: те, що ви називаєте Bucket, - це не те, що зазвичай називається Bucket у хеш-таблиці. Зазвичай Bucket - це набір записів з однаковим розміром хеш-%. Ви, здається, використовуєте його для того, що зазвичай називають записом. Список пов’язаних зображень не у відрах, а у записах.
unbeli

12
@unbeli Я використовую термінологію, яку внутрішньо використовує PHP. В Buckets є частиною двусвязного списку хеша зіткнень , а також частини двусвязного списку для замовлення;)
NikiC

4
Великий привид. Я думаю, ти мав на увазі, iterate($outerArr);а не iterate($arr);десь.
niahoo

116

У прикладі 3 ви не змінюєте масив. У всіх інших прикладах ви змінюєте або вміст, або внутрішній вказівник масиву. Це важливо, коли мова йде про масиви PHP через семантику оператора присвоєння.

Оператор присвоєння масивів у PHP працює більше, як ледачий клон. Призначення однієї змінної іншій, що містить масив, буде клонувати масив, на відміну від більшості мов. Однак фактичне клонування не буде здійснено, якщо воно не знадобиться. Це означає, що клон відбудеться лише тоді, коли будь-яка із змінних буде змінена (копіювати при записі).

Ось приклад:

$a = array(1,2,3);
$b = $a;  // This is lazy cloning of $a. For the time
          // being $a and $b point to the same internal
          // data structure.

$a[] = 3; // Here $a changes, which triggers the actual
          // cloning. From now on, $a and $b are two
          // different data structures. The same would
          // happen if there were a change in $b.

Повернувшись до ваших тестових випадків, ви можете легко уявити, що foreachстворюється якийсь ітератор із посиланням на масив. Ця посилання працює точно так само, як і мінлива $bв моєму прикладі. Однак ітератор разом із посиланням живуть лише під час циклу, і тоді вони обидва відкидаються. Тепер ви можете бачити, що у всіх випадках, крім 3, масив змінюється під час циклу, поки ця додаткова посилання є активною. Це запускає клон, і це пояснює, що тут відбувається!

Ось чудова стаття щодо ще одного побічного ефекту такої поведінки копіювання при записі: Термінальний оператор PHP: Швидкий чи ні?


здається, що ви праві, я зробив приклад, який демонструє, що: codepad.org/OCjtvu8r одна відмінність від вашого прикладу - вона не копіюється, якщо ви змінюєте значення, лише якщо змінюєте ключі.
zb '

Це дійсно пояснює всі показані вище поведінки, і це можна добре проілюструвати, зателефонувавши each()в кінці першого тестового випадку, де ми бачимо, що вказівник масиву вихідного масиву вказує на другий елемент, оскільки масив був змінений під час перша ітерація. Це також, здається, демонструє, що foreachрухається вказівник масиву перед виконанням блоку коду циклу, якого я не очікував - я би подумав, що це зробить це наприкінці. Велике спасибі, це мені це чудово очищає.
DaveRandom

49

Деякі моменти, які слід зазначити при роботі з foreach():

а) foreachпрацює над шуканою копією оригінального масиву. Це означає, що foreach()буде зберігатися розділене зберігання даних до тих пір, поки а prospected copyне буде створено анотацію Примітки / коментарі користувачів .

б) Що запускає шукану копію ? Передбачена копія створюється на основі політики copy-on-write, тобто кожного разу, коли масив, переданий масиву foreach(), змінюється, створюється клон вихідного масиву.

в) Оригінальний масив і foreach()ітератор матимуть DISTINCT SENTINEL VARIABLES, тобто один для вихідного масиву, а інший для foreach; дивіться тестовий код нижче. SPL , ітератори та ітератор масиву .

Питання про переповнення стека Як переконатися, що значення скидається у циклі "foreach" у PHP? вирішує справи (3,4,5) вашого запитання.

У наступному прикладі показано , що кожен () і скидання () не впливає на SENTINELзмінні (for example, the current index variable)цей foreach()ітератор.

$array = array(1, 2, 3, 4, 5);

list($key2, $val2) = each($array);
echo "each() Original (outside): $key2 => $val2<br/>";

foreach($array as $key => $val){
    echo "foreach: $key => $val<br/>";

    list($key2,$val2) = each($array);
    echo "each() Original(inside): $key2 => $val2<br/>";

    echo "--------Iteration--------<br/>";
    if ($key == 3){
        echo "Resetting original array pointer<br/>";
        reset($array);
    }
}

list($key2, $val2) = each($array);
echo "each() Original (outside): $key2 => $val2<br/>";

Вихід:

each() Original (outside): 0 => 1
foreach: 0 => 1
each() Original(inside): 1 => 2
--------Iteration--------
foreach: 1 => 2
each() Original(inside): 2 => 3
--------Iteration--------
foreach: 2 => 3
each() Original(inside): 3 => 4
--------Iteration--------
foreach: 3 => 4
each() Original(inside): 4 => 5
--------Iteration--------
Resetting original array pointer
foreach: 4 => 5
each() Original(inside): 0=>1
--------Iteration--------
each() Original (outside): 1 => 2

2
Ваша відповідь не зовсім правильна. foreachоперує потенційною копією масиву, але фактичну копію не робить, якщо вона не потрібна.
linepogl

Ви хочете продемонструвати, як і коли створюється ця потенційна копія за допомогою коду? Мій код демонструє, що foreachкопіює масив у 100% часу. Я нетерплячий знати. Дякую за коментарі
sakhunzai

Копіювання масиву коштує багато. Спробуйте порахувати час, необхідний для ітерації масиву з 100000 елементами, використовуючи або forабо foreach. Ви не побачите жодної суттєвої різниці між ними, оскільки фактична копія не відбувається.
linepogl

Тоді я б припустив, що існує SHARED data storageзарезервований до або copy-on-write, але (з мого фрагмента коду) очевидно, що завжди буде ДВОЙ набір SENTINEL variablesодного для original arrayіншого, а іншого для foreach. Завдяки цьому є сенс
sakhunzai

1
так, це "перспективна" копія, тобто "потенційна" копія. Вона не захищена, як ви запропонували
sakhunzai

33

Примітка для PHP 7

Щоб оновити цю відповідь, оскільки вона набула деякої популярності: Ця відповідь більше не застосовується з PHP 7. Як пояснено в " Зворотні несумісні зміни ", у PHP 7 foreach працює над копією масиву, тому будь-які зміни в самому масиві не відображаються на циклі foreach. Детальніше за посиланням.

Пояснення (цитата з php.net ):

Перший цикл форми над масивом, заданим array_expression. На кожній ітерації значення поточного елемента присвоюється значенню $, а внутрішній покажчик масиву розширюється на один (тому на наступній ітерації ви будете дивитись на наступний елемент).

Отже, у вашому першому прикладі у масиві є лише один елемент, а коли переміщується покажчик, наступного елемента не існує, тому після додавання нового елемента кінець foreach, оскільки він вже "вирішив", що це він як останній елемент.

У вашому другому прикладі ви починаєте з двох елементів, і цикл foreach знаходиться не на останньому елементі, тому він оцінює масив на наступній ітерації і, таким чином, усвідомлює, що в масиві є новий елемент.

Я вважаю, що все це є наслідком на кожній ітераційній частині пояснення в документації, що, ймовірно, означає, що foreachвиконується вся логіка, перш ніж вона викликає код у {}.

Тестовий випадок

Якщо ви запускаєте це:

<?
    $array = Array(
        'foo' => 1,
        'bar' => 2
    );
    foreach($array as $k=>&$v) {
        $array['baz']=3;
        echo $v." ";
    }
    print_r($array);
?>

Ви отримаєте цей вихід:

1 2 3 Array
(
    [foo] => 1
    [bar] => 2
    [baz] => 3
)

Що означає, що він прийняв модифікацію і пройшов через неї, тому що вона була модифікована "в часі". Але якщо ви це зробите:

<?
    $array = Array(
        'foo' => 1,
        'bar' => 2
    );
    foreach($array as $k=>&$v) {
        if ($k=='bar') {
            $array['baz']=3;
        }
        echo $v." ";
    }
    print_r($array);
?>

Ти отримаєш:

1 2 Array
(
    [foo] => 1
    [bar] => 2
    [baz] => 3
)

Це означає, що масив був модифікований, але оскільки ми його змінили, коли foreachвже був на останньому елементі масиву, він "вирішив" більше не циклікувати, і хоча ми додали новий елемент, ми додали його "занадто пізно", і це не був пропущений наскрізь.

Детальне пояснення можна прочитати у розділі Як насправді працює PHP? що пояснює внутрішню основу такої поведінки.


7
Ну ви прочитали решту відповідей? Має досконалий сенс, що foreach вирішує, чи буде він інший цикл, перш ніж він навіть запустить код у ньому.
dkasipovic

2
Ні, масив модифікований, але "занадто пізно", оскільки foreach вже "думає", що він знаходиться на останньому елементі (який він знаходиться на початку ітерації) і більше не буде циклічно. Якщо у другому прикладі, він не знаходиться на останньому елементі на початку ітерації та оцінюється знову на початку наступної ітерації. Я намагаюся підготувати тестовий випадок.
dkasipovic

1
@AlmaDo Подивіться на lxr.php.net/xref/PHP_TRUNK/Zend/zend_vm_def.h#4509 Він завжди встановлюється на наступний покажчик, коли він ітераціює. Отже, коли вона досягне останньої ітерації, вона буде позначена як закінчена (через покажчик NULL). Коли ви додасте ключ в останній ітерації, foreach не помітить його.
bwoebi

1
@DKasipovic ні. Тут немає повного і чіткого пояснення (принаймні поки що - можливо, я помиляюся)
Alma Do

4
Насправді здається, що у @AlmaDo є недолік у розумінні власної логіки… Ваша відповідь - це добре.
bwoebi

15

Відповідно до документації, наданої в керівництві PHP.

На кожній ітерації значення поточного елемента присвоюється $ v, а внутрішній
покажчик масиву розширюється на один (так що на наступній ітерації ви будете дивитись на наступний елемент).

Отже, як у першому прикладі:

$array = ['foo'=>1];
foreach($array as $k=>&$v)
{
   $array['bar']=2;
   echo($v);
}

$arrayмають лише один елемент, так що відповідно до виконання foreach, 1 присвоюється, $vі він не має жодного іншого елемента для переміщення вказівника

Але у вашому другому прикладі:

$array = ['foo'=>1, 'bar'=>2];
foreach($array as $k=>&$v)
{
   $array['baz']=3;
   echo($v);
}

$arrayмають два елементи, тож тепер $ array оцінює нульові індекси та переміщує покажчик на один. Для першої ітерації циклу, доданого $array['baz']=3;як пропуск за посиланням.


13

Велике запитання, адже багато розробників, навіть досвідчені, збентежені тим, як PHP обробляє масиви в циклах foreach. У стандартному циклі foreach PHP робить копію масиву, який використовується в циклі. Копія відкидається відразу після закінчення циклу. Це прозоро в роботі простої петлі переднього плану. Наприклад:

$set = array("apple", "banana", "coconut");
foreach ( $set AS $item ) {
    echo "{$item}\n";
}

Цей результат:

apple
banana
coconut

Таким чином, копія створюється, але розробник не помічає, оскільки початковий масив не посилається в циклі або після завершення циклу. Однак, намагаючись змінити елементи в циклі, ви виявите, що вони закінчуються:

$set = array("apple", "banana", "coconut");
foreach ( $set AS $item ) {
    $item = strrev ($item);
}

print_r($set);

Цей результат:

Array
(
    [0] => apple
    [1] => banana
    [2] => coconut
)

Будь-які зміни оригіналу не можуть бути поміченими, насправді немає змін у оригіналі, навіть якщо ви чітко присвоїли значення елементу $. Це відбувається тому, що ви працюєте над $ item, як це відображається в копії $ set, над якою працює. Ви можете змінити це, перехопивши $ елемент за посиланням, наприклад:

$set = array("apple", "banana", "coconut");
foreach ( $set AS &$item ) {
    $item = strrev($item);
}
print_r($set);

Цей результат:

Array
(
    [0] => elppa
    [1] => ananab
    [2] => tunococ
)

Тож очевидно і помітно, що коли $ елемент керується посиланням, зміни, внесені до $ item, вносяться до членів початкового набору $. Використання $ item за посиланням також не дозволяє PHP створювати копію масиву. Щоб перевірити це, спочатку ми покажемо швидкий сценарій, що демонструє копію:

$set = array("apple", "banana", "coconut");
foreach ( $set AS $item ) {
    $set[] = ucfirst($item);
}
print_r($set);

Цей результат:

Array
(
    [0] => apple
    [1] => banana
    [2] => coconut
    [3] => Apple
    [4] => Banana
    [5] => Coconut
)

Як показано в прикладі, PHP скопіював $ set і використав його для циклу, але коли $ set використовувався всередині циклу, PHP додав змінні до вихідного масиву, а не скопійованого масиву. В основному, PHP використовує лише скопійований масив для виконання циклу та присвоєння елемента $. Через це цикл вище виконується лише 3 рази, і кожного разу він додає інше значення до кінця початкового набору $, залишаючи початковий набір $ з 6 елементами, але ніколи не вступаючи в нескінченний цикл.

Однак що робити, якщо ми використовували $ item за посиланням, як я вже згадував раніше? До вищевказаного тесту додано один символ:

$set = array("apple", "banana", "coconut");
foreach ( $set AS &$item ) {
    $set[] = ucfirst($item);
}
print_r($set);

Результати в нескінченному циклі. Зауважте, що насправді це нескінченний цикл, вам доведеться або вбити сценарій самостійно, або дочекатися, коли в вашій ОС закінчиться пам'ять. Я додав наступний рядок до свого сценарію, щоб PHP дуже швидко втратив пам’ять, я пропоную зробити те саме, якщо ви збираєтеся виконувати ці нескінченні тести циклу:

ini_set("memory_limit","1M");

Отже, в цьому попередньому прикладі з нескінченним циклом ми бачимо причину, чому PHP був написаний для створення копії масиву для циклу. Коли копія створюється і використовується лише структурою самої конструкції циклу, масив залишається статичним протягом усього виконання циклу, тому ви ніколи не зіткнетеся з проблемами.


7

Цикл передбачення PHP можна використовувати з Indexed arrays, Associative arraysі Object public variables.

У циклі foreach перше, що робить php, це те, що він створює копію масиву, який слід повторити. PHP потім повторює цей новий copyмасив, а не оригінальний. Це показано на прикладі нижче:

<?php
$numbers = [1,2,3,4,5,6,7,8,9]; # initial values for our array
echo '<pre>', print_r($numbers, true), '</pre>', '<hr />';
foreach($numbers as $index => $number){
    $numbers[$index] = $number + 1; # this is making changes to the origial array
    echo 'Inside of the array = ', $index, ': ', $number, '<br />'; # showing data from the copied array
}
echo '<hr />', '<pre>', print_r($numbers, true), '</pre>'; # shows the original values (also includes the newly added values).

Крім цього, php дозволяє також використовувати iterated values as a reference to the original array value. Це показано нижче:

<?php
$numbers = [1,2,3,4,5,6,7,8,9];
echo '<pre>', print_r($numbers, true), '</pre>';
foreach($numbers as $index => &$number){
    ++$number; # we are incrementing the original value
    echo 'Inside of the array = ', $index, ': ', $number, '<br />'; # this is showing the original value
}
echo '<hr />';
echo '<pre>', print_r($numbers, true), '</pre>'; # we are again showing the original value

Примітка. Це не дозволяє original array indexesвикористовувати як references.

Джерело: http://dwellupper.io/post/47/understanding-php-foreach-loop-with-examples


1
Object public variablesнеправильно або в кращому випадку вводить в оману. Ви не можете використовувати об'єкт у масиві без правильного інтерфейсу (наприклад, Traversible), а коли ви працюєте, foreach((array)$obj ...ви насправді працюєте з простим масивом, а не з об'єктом.
Крістіан
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.