foreach
obsługuje iterację trzech różnych rodzajów wartości:
Poniżej postaram się dokładnie wyjaśnić, w jaki sposób iteracja działa w różnych przypadkach. Zdecydowanie najprostszym przypadkiem są Traversable
obiekty, ponieważ dla nich foreach
jest to zasadniczo tylko cukier składniowy dla kodu wzdłuż tych linii:
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();
/* ... */
}
W przypadku klas wewnętrznych można uniknąć rzeczywistych wywołań metod, używając wewnętrznego interfejsu API, który zasadniczo odzwierciedla Iterator
interfejs na poziomie C.
Iteracja tablic i prostych obiektów jest znacznie bardziej skomplikowana. Przede wszystkim należy zauważyć, że w PHP „tablice” są naprawdę uporządkowanymi słownikami i będą one przechodzić zgodnie z tą kolejnością (która odpowiada kolejności wstawiania, o ile nie użyłeś czegoś takiego sort
). Przeciwstawia się to powtarzaniu przez naturalną kolejność klawiszy (jak często działają listy w innych językach) lub brak zdefiniowanej kolejności (jak często działają słowniki w innych językach).
To samo dotyczy również obiektów, ponieważ właściwości obiektu można postrzegać jako kolejną (uporządkowaną) nazwę właściwości mapowania słownika na ich wartości, a także pewną obsługę widoczności. W większości przypadków właściwości obiektu nie są faktycznie przechowywane w ten raczej nieefektywny sposób. Jeśli jednak zaczniesz iterować nad obiektem, normalnie używana spakowana reprezentacja zostanie przekonwertowana na prawdziwy słownik. W tym momencie iteracja prostych obiektów staje się bardzo podobna do iteracji tablic (dlatego nie omawiam tutaj dużo iteracji prostych obiektów).
Na razie w porządku. Iterowanie po słowniku nie może być zbyt trudne, prawda? Problemy zaczynają się, gdy zdajesz sobie sprawę, że tablica / obiekt może się zmieniać podczas iteracji. Może się to zdarzyć na wiele sposobów:
- Jeśli wykonujesz iterację przez referencję,
foreach ($arr as &$v)
wówczas $arr
zostanie zmieniona w referencję i możesz ją zmienić podczas iteracji.
- W PHP 5 to samo dotyczy nawet iteracji według wartości, ale tablica była wcześniej referencją:
$ref =& $arr; foreach ($ref as $v)
- Obiekty mają semantykę przekazywania uchwytów, co w większości praktycznych celów oznacza, że zachowują się jak odniesienia. Dlatego obiekty można zawsze zmieniać podczas iteracji.
Problem z zezwoleniem na modyfikacje podczas iteracji polega na tym, że element, na którym jesteś aktualnie, jest usuwany. Załóżmy, że używasz wskaźnika, aby śledzić, w którym elemencie tablicy aktualnie się znajdujesz. Jeśli ten element zostanie teraz uwolniony, pozostanie z wiszącym wskaźnikiem (zwykle skutkującym awarią).
Istnieją różne sposoby rozwiązania tego problemu. PHP 5 i PHP 7 różnią się znacznie pod tym względem i opiszę oba zachowania poniżej. Podsumowując, podejście PHP 5 było raczej głupie i prowadziło do różnego rodzaju dziwnych problemów na krawędziach, podczas gdy podejście bardziej zaangażowane w PHP 7 daje bardziej przewidywalne i spójne zachowanie.
Na koniec należy zauważyć, że PHP wykorzystuje liczenie referencji i kopiowanie przy zapisie do zarządzania pamięcią. Oznacza to, że jeśli „skopiujesz” wartość, w rzeczywistości po prostu ponownie użyjesz starej wartości i zwiększysz jej liczbę referencyjną (przeliczanie). Dopiero po wykonaniu jakiejś modyfikacji zostanie wykonana prawdziwa kopia (zwana „duplikacją”). Zobacz, w jaki sposób jesteś okłamywany, aby uzyskać bardziej obszerne wprowadzenie na ten temat.
PHP 5
Wskaźnik tablicy wewnętrznej i HashPointer
Tablice w PHP 5 mają jeden dedykowany „wewnętrzny wskaźnik tablicy” (IAP), który poprawnie obsługuje modyfikacje: Ilekroć element zostanie usunięty, będzie sprawdzane, czy IAP wskazuje na ten element. Jeśli tak, to zamiast tego przechodzi do następnego elementu.
Chociaż foreach
korzysta z IAP, istnieje dodatkowa komplikacja: istnieje tylko jeden IAP, ale jedna tablica może być częścią wielu foreach
pętli:
// 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) {
// ...
}
}
Aby obsłużyć dwie równoczesne pętle za pomocą tylko jednego wewnętrznego wskaźnika tablicy, foreach
wykonaj następujące shenanigany: Przed wykonaniem korpusu pętli foreach
utworzy kopię zapasową wskaźnika do bieżącego elementu i jego skrótu w foreach HashPointer
. Po uruchomieniu treści pętli IAP zostanie przywrócony do tego elementu, jeśli nadal istnieje. Jeśli jednak element zostanie usunięty, użyjemy go wszędzie tam, gdzie jest obecnie IAP. Ten schemat działa w pewnym sensie, ale można się z niego wydostać wiele dziwnych zachowań, niektóre z nich przedstawię poniżej.
Powielanie tablic
IAP jest widoczną cechą tablicy (widoczną przez current
rodzinę funkcji), ponieważ takie zmiany w IAP liczą się jako modyfikacje w ramach semantyki kopiowania przy zapisie. To niestety oznacza, że foreach
w wielu przypadkach jest zmuszony do zduplikowania tablicy, nad którą się iteruje. Dokładne warunki to:
- Tablica nie jest odwołaniem (is_ref = 0). Jeśli jest to odniesienie, a następnie zmienia się na to są niby rozprzestrzeniać, więc nie powinny być powielane.
- Tablica ma przelicznik> 1. Jeśli
refcount
wynosi 1, to tablica nie jest współdzielona i możemy ją modyfikować bezpośrednio.
Jeśli tablica nie jest zduplikowana (is_ref = 0, refcount = 1), wówczas tylko jej wartość refcount
zostanie zwiększona (*). Dodatkowo, jeśli foreach
zostanie użyte odniesienie, tablica (potencjalnie zduplikowana) zostanie przekształcona w odwołanie.
Rozważ ten kod jako przykład, w którym występuje duplikacja:
function iterate($arr) {
foreach ($arr as $v) {}
}
$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);
Tutaj $arr
będą powielane, aby zapobiec IAP zmienia on $arr
z wyciekającego $outerArr
. Pod względem powyższych warunków tablica nie jest odwołaniem (is_ref = 0) i jest używana w dwóch miejscach (refcount = 2). To wymaganie jest niefortunne i jest artefaktem nieoptymalnej implementacji (tutaj nie ma obaw o modyfikację podczas iteracji, więc tak naprawdę nie musimy używać IAP).
(*) Zwiększanie wartości refcount
tutaj brzmi nieszkodliwie, ale narusza semantykę kopiowania przy zapisie (COW): Oznacza to, że będziemy modyfikować IAP tablicy refcount = 2, podczas gdy COW dyktuje, że modyfikacje mogą być wykonywane tylko przy refcount = 1 wartości. Naruszenie to powoduje zmianę zachowania widoczną dla użytkownika (podczas gdy COW jest zwykle przezroczysty), ponieważ zmiana IAP w tablicy iterowanej będzie obserwowalna - ale tylko do pierwszej modyfikacji innej niż IAP w tablicy. Zamiast tego trzy „prawidłowe” opcje byłyby a) zawsze powielane, b) nie zwiększały, refcount
a tym samym pozwalały na dowolną modyfikację tablicy iterowanej w pętli lub c) w ogóle nie korzystały z IAP (PHP 7 rozwiązanie).
Pozycja awansu pozycji
Jest jeszcze jeden szczegół implementacji, o którym musisz wiedzieć, aby poprawnie zrozumieć poniższe przykłady kodu. „Normalny” sposób zapętlania struktury danych wyglądałby w pseudokodzie mniej więcej tak:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
code();
move_forward(arr);
}
Jednak foreach
będąc specjalnym płatkiem śniegu, robi rzeczy nieco inaczej:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
move_forward(arr);
code();
}
Mianowicie wskaźnik tablicy jest już przesuwany do przodu przed uruchomieniem treści pętli. Oznacza to, że gdy ciało pętli działa na elemencie $i
, IAP jest już na elemencie $i+1
. To jest powód, dlaczego próbki kodu pokazano modyfikację podczas iteracji będzie zawsze obok elementu, niż ten obecny.unset
Przykłady: Twoje przypadki testowe
Trzy aspekty opisane powyżej powinny dać ci w większości pełne wrażenie osobliwości foreach
implementacji i możemy przejść do omówienia niektórych przykładów.
Zachowanie przypadków testowych jest w tym miejscu łatwe do wyjaśnienia:
W przypadkach testowych 1 i 2 $array
zaczyna się od refcount = 1, więc nie będzie duplikowane przez foreach
: Tylko wartość refcount
jest zwiększana. Kiedy ciało pętli następnie zmodyfikuje tablicę (która ma w tym momencie refcount = 2), w tym momencie nastąpi duplikacja. Foreach będzie kontynuował pracę nad niezmodyfikowaną kopią $array
.
W przypadku testowym 3 tablica po raz kolejny nie jest duplikowana, a zatem foreach
będzie modyfikować IAP $array
zmiennej. Pod koniec iteracji IAP ma wartość NULL (co oznacza, że wykonano iterację), co each
oznacza powrót false
.
W przypadku testów 4 i 5, jak each
i reset
są funkcjami przez odwołanie. $array
Ma refcount=2
, gdy jest przekazywana do nich, więc to musi być powielane. W związku z tym foreach
ponownie będzie pracował na osobnej tablicy.
Przykłady: Skutki current
w foreach
Dobrym sposobem na pokazanie różnych zachowań związanych z powielaniem jest obserwowanie zachowania current()
funkcji wewnątrz foreach
pętli. Rozważ ten przykład:
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 2 2 2 2 */
Tutaj powinieneś wiedzieć, że current()
jest to funkcja by-ref (właściwie: prefer-ref), nawet jeśli nie modyfikuje tablicy. Musi być po to, aby dobrze grać ze wszystkimi innymi funkcjami, takimi jak te, next
które są by-ref. Przekazywanie przez referencje oznacza, że tablica musi być oddzielona, a zatem $array
i foreach-array
będzie inna. Powód, dla którego dostajesz 2
zamiast, 1
jest również wspomniany powyżej: foreach
przesuwa wskaźnik tablicy przed uruchomieniem kodu użytkownika, a nie po nim. Więc nawet jeśli kod znajduje się na pierwszym elemencie, foreach
wskaźnik przesunął się już do drugiego.
Teraz spróbujmy małej modyfikacji:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Tutaj mamy przypadek is_ref = 1, więc tablica nie jest kopiowana (tak jak powyżej). Ale teraz, gdy jest to odwołanie, tablica nie musi już być duplikowana podczas przekazywania do funkcji by-ref current()
. W ten sposób current()
i foreach
pracy w tym samym układzie. Nadal jednak widzisz zachowanie indywidualne, ze względu na sposób foreach
przesuwania wskaźnika.
Takie samo zachowanie uzyskuje się podczas iteracji według odwołania:
foreach ($array as &$val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Ważną częścią jest to, że foreach wykona $array
is_ref = 1, gdy jest iterowane przez odniesienie, więc w zasadzie masz taką samą sytuację jak powyżej.
Kolejna mała odmiana, tym razem przypiszemy tablicę do innej zmiennej:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 1 1 1 1 1 */
Tutaj liczba zwrotna $array
wynosi 2, gdy pętla jest uruchamiana, więc tym razem musimy faktycznie wykonać kopię z góry. Zatem $array
tablica używana przez foreach będzie całkowicie oddzielna od samego początku. Dlatego uzyskujesz pozycję IAP, gdziekolwiek była przed pętlą (w tym przypadku była na pierwszej pozycji).
Przykłady: Modyfikacja podczas iteracji
Próba uwzględnienia modyfikacji podczas iteracji jest źródłem wszystkich naszych problemów z wyprzedzeniem, dlatego warto rozważyć kilka przykładów tego przypadku.
Rozważ te zagnieżdżone pętle w tej samej tablicy (gdzie iteracja by-ref jest używana, aby upewnić się, że naprawdę jest taka sama):
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)
Oczekiwano tu części, której (1, 2)
brakuje w danych wyjściowych, ponieważ element 1
został usunięty. Prawdopodobnie nieoczekiwane jest to, że zewnętrzna pętla zatrzymuje się po pierwszym elemencie. Dlaczego?
Powodem tego jest włamanie do pętli zagnieżdżonej opisane powyżej: Przed uruchomieniem treści pętli bieżąca pozycja IAP i skrót są zapisywane w pliku HashPointer
. Po korpusie pętli zostanie przywrócony, ale tylko jeśli element nadal istnieje, w przeciwnym razie zostanie użyta bieżąca pozycja IAP (cokolwiek by to nie było). W powyższym przykładzie jest to dokładnie taki przypadek: bieżący element zewnętrznej pętli został usunięty, więc użyje IAP, który został już oznaczony jako zakończony przez wewnętrzną pętlę!
Inną konsekwencją HashPointer
mechanizmu tworzenia kopii zapasowych i przywracania jest to, że zmiany w IAP za pośrednictwem reset()
itp. Zwykle nie mają wpływu foreach
. Na przykład następujący kod jest wykonywany tak, jakby w reset()
ogóle nie był obecny:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
// output: 1, 2, 3, 4, 5
Powodem jest to, że chociaż reset()
tymczasowo modyfikuje IAP, zostanie on przywrócony do bieżącego elementu foreach po treści pętli. Aby wymusić reset()
efekt na pętli, musisz dodatkowo usunąć bieżący element, aby mechanizm tworzenia kopii zapasowych / przywracania nie działał:
$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
Ale te przykłady są nadal rozsądne. Prawdziwa zabawa zaczyna się, jeśli pamiętasz, że HashPointer
przywracanie używa wskaźnika do elementu i jego skrótu, aby ustalić, czy nadal istnieje. Ale: Hashe mają kolizje, a wskaźniki mogą być ponownie użyte! Oznacza to, że przy starannym doborze kluczy tablicowych możemy sprawić foreach
, że usunięty element nadal istnieje, więc przeskoczy bezpośrednio do niego. Przykład:
$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
Tutaj normalnie powinniśmy oczekiwać wyników 1, 1, 3, 4
zgodnie z poprzednimi zasadami. To, co się dzieje, 'FYFY'
ma taki sam skrót jak usunięty element 'EzFY'
, a alokator ponownie wykorzystuje tę samą lokalizację pamięci do przechowywania elementu. Foreach kończy się więc skokiem bezpośrednio do nowo wstawionego elementu, tym samym skracając pętlę.
Zastępowanie iterowanej jednostki podczas pętli
Ostatni nieparzysty przypadek, o którym chciałbym wspomnieć, to to, że PHP pozwala na zastąpienie iterowanej jednostki podczas pętli. Możesz więc rozpocząć iterację na jednej tablicy, a następnie zastąpić ją inną w połowie. Lub rozpocznij iterację tablicy, a następnie zamień ją na obiekt:
$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 */
Jak widać w tym przypadku, PHP po prostu zacznie iterację od drugiej jednostki, gdy nastąpi podstawienie.
PHP 7
Iteratory haszujące
Jeśli nadal pamiętasz, głównym problemem związanym z iteracją tablicy było to, jak radzić sobie z usuwaniem elementów w trakcie iteracji. W tym celu PHP 5 użył pojedynczego wewnętrznego wskaźnika tablicy (IAP), który był nieco nieoptymalny, ponieważ jeden wskaźnik tablicy musiał zostać rozciągnięty, aby obsługiwać wiele jednoczesnych pętli foreach i interakcji z reset()
itp.
PHP 7 stosuje inne podejście, mianowicie obsługuje tworzenie dowolnej liczby zewnętrznych, bezpiecznych iteratorów z mieszaniem. Te iteratory muszą być zarejestrowane w tablicy, od tego momentu mają taką samą semantykę jak IAP: Jeśli element tablicy zostanie usunięty, wszystkie iteratory hashtable wskazujące na ten element zostaną przeniesione do następnego elementu.
Oznacza to, że foreach
nie będą używać IAP w ogóle . foreach
Pętla będzie absolutnie żadnego wpływu na wynikach current()
itd, a jego własne zachowanie nigdy nie będzie pod wpływem funkcji, takich jak reset()
etc.
Powielanie tablic
Kolejna ważna zmiana między PHP 5 a PHP 7 dotyczy powielania tablic. Teraz, gdy IAP nie jest już używany, iteracja tablic według wartości spowoduje tylko refcount
zwiększenie (zamiast duplikowania tablicy) we wszystkich przypadkach. Jeśli tablica zostanie zmodyfikowana podczas foreach
pętli, w tym momencie nastąpi duplikacja (zgodnie z kopiowaniem przy zapisie) i foreach
nadal będzie działać na starej tablicy.
W większości przypadków zmiana ta jest przejrzysta i nie ma innego efektu niż lepsza wydajność. Istnieje jednak jeden przypadek, w którym powoduje to inne zachowanie, a mianowicie przypadek, w którym tablica była wcześniej odniesieniem:
$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 */
Poprzednio iteracja według wartości tablic referencyjnych była przypadkiem szczególnym. W takim przypadku nie wystąpiło powielanie, więc wszystkie modyfikacje tablicy podczas iteracji zostaną odzwierciedlone przez pętlę. W PHP 7 tego szczególnego przypadku nie ma: iteracja według wartości tablicy zawsze będzie działać na oryginalnych elementach, pomijając wszelkie modyfikacje podczas pętli.
To oczywiście nie dotyczy iteracji przez odniesienie. W przypadku iteracji według odwołania wszystkie modyfikacje zostaną odzwierciedlone przez pętlę. Co ciekawe, to samo dotyczy iteracji według wartości zwykłych obiektów:
$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 */
Odzwierciedla to semantykę uchwytów obiektów (tzn. Zachowują się one jak odniesienia, nawet w kontekstach wartości).
Przykłady
Rozważmy kilka przykładów, zaczynając od przypadków testowych:
Przypadki testowe 1 i 2 zachowują ten sam wynik: iteracja tablic według wartości zawsze działa na oryginalne elementy. (W tym przypadku refcounting
zachowanie parzystości i duplikacji jest dokładnie takie samo między PHP 5 i PHP 7).
Zmiany w przypadku testowym 3: Foreach
nie używa już IAP, więc each()
pętla nie ma na niego wpływu. Będzie miał tę samą moc przed i po.
Przypadki testowe 4 i 5 pozostają takie same: each()
i reset()
powielą tablicę przed zmianą IAP, foreach
nadal wykorzystując tablicę oryginalną. (Nie żeby zmiana IAP miała znaczenie, nawet gdyby tablica była współdzielona).
Drugi zestaw przykładów związany był z zachowaniem się current()
w różnych reference/refcounting
konfiguracjach. Nie ma to już sensu, ponieważ current()
pętla całkowicie na nią nie ma wpływu, więc jego wartość zwrotna zawsze pozostaje taka sama.
Dostajemy jednak kilka interesujących zmian przy rozważaniu modyfikacji podczas iteracji. Mam nadzieję, że odkryjesz, że nowe zachowanie jest zdrowsze. Pierwszy przykład:
$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)
Jak widać, zewnętrzna pętla nie jest już przerywana po pierwszej iteracji. Powodem jest to, że obie pętle mają teraz całkowicie oddzielne iteratory z mieszaniem, i nie ma już zanieczyszczenia krzyżowego obu pętli poprzez wspólny IAP.
Innym dziwnym przypadkiem krawędzi, który został teraz naprawiony, jest dziwny efekt, który uzyskujesz, gdy usuwasz i dodajesz elementy, które mają taki sam skrót:
$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
Wcześniej mechanizm przywracania HashPointer przeskakiwał w prawo do nowego elementu, ponieważ „wyglądał” tak, jakby był taki sam jak usunięty element (z powodu kolizji skrótu i wskaźnika). Ponieważ do niczego już nie polegamy na haszu elementu, nie stanowi to już problemu.