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)
- Об'єкти мають побічну передачу семантики, що для більшості практичних цілей означає, що вони поводяться як посилання. Тому об'єкти завжди можна змінювати під час ітерації.
Проблема з дозволом модифікацій під час ітерації полягає в тому випадку, коли елемент, на якому ви зараз перебуваєте, видалений. Скажімо, ви використовуєте вказівник для відстеження того, в якому елементі масиву ви зараз перебуваєте. Якщо цей елемент тепер звільнений, вам залишається звисаючий покажчик (зазвичай це призводить до сегментації).
Існують різні способи вирішення цього питання. 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
буде продубльовано, щоб запобігти $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
знову буде працювати над окремим масивом.
Приклади: Ефекти роботи current
foreach
Хороший спосіб показати різні способи дублювання - це спостерігати за поведінкою 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 зробить $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 ($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 перейшов праворуч до нового елемента, оскільки він "виглядав" так, як він є таким же, як і видалений елемент (за рахунок стикання хеша та покажчика). Оскільки ми більше не покладаємось на хеш-елемент елементів, це вже не є проблемою.