Jak faktycznie działa foreach PHP?


2018

Pozwólcie, że poprzedzę to stwierdzeniem, że wiem, co to foreachjest, robi i jak z niego korzystać. To pytanie dotyczy tego, jak działa pod maską, i nie chcę żadnych odpowiedzi w stylu „w ten sposób zapętlasz tablicę za pomocą foreach”.


Przez długi czas zakładałem, że foreachdziała z samą tablicą. Potem znalazłem wiele odniesień do faktu, że działa on z kopią tablicy i od tego czasu założyłem, że to koniec historii. Ale ostatnio wdałem się w dyskusję na ten temat i po niewielkich eksperymentach okazało się, że nie jest to w 100% prawda.

Pokażę, co mam na myśli. W przypadku następujących przypadków testowych będziemy pracować z następującą tablicą:

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

Przypadek testowy 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 */

To wyraźnie pokazuje, że nie pracujemy bezpośrednio z tablicą źródłową - w przeciwnym razie pętla będzie trwać wiecznie, ponieważ w trakcie pętli ciągle popychamy elementy do tablicy. Ale dla pewności tak jest:

Przypadek testowy 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 */

To potwierdza nasze początkowe wnioski, pracujemy z kopią tablicy źródłowej podczas pętli, w przeciwnym razie widzielibyśmy zmodyfikowane wartości podczas pętli. Ale...

Jeśli zajrzymy do instrukcji , znajdziemy następujące stwierdzenie:

Kiedy foreach zaczyna działać, wewnętrzny wskaźnik tablicy jest automatycznie resetowany do pierwszego elementu tablicy.

Racja ... wydaje się to sugerować, że foreachzależy od wskaźnika tablicy tablicy źródłowej. Ale właśnie udowodniliśmy, że nie pracujemy z tablicą źródłową , prawda? Cóż, nie do końca.

Przypadek testowy 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)
*/

Tak więc pomimo faktu, że nie pracujemy bezpośrednio z tablicą źródłową, pracujemy bezpośrednio ze wskaźnikiem tablicy źródłowej - pokazuje to fakt, że wskaźnik znajduje się na końcu tablicy na końcu pętli. Tyle że to nie może być prawda - gdyby tak było, to przypadek testowy 1 zapętlałby się na zawsze.

Podręcznik PHP stwierdza również:

Ponieważ foreach opiera się na wskaźniku tablicy wewnętrznej, zmiana go w pętli może prowadzić do nieoczekiwanego zachowania.

Cóż, dowiedzmy się, czym jest to „nieoczekiwane zachowanie” (technicznie każde zachowanie jest nieoczekiwane, ponieważ nie wiem już, czego się spodziewać).

Przypadek testowy 4 :

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

/* Output: 1 2 3 4 5 */

Przypadek testowy 5 :

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

/* Output: 1 2 3 4 5 */

... nic tam nieoczekiwanego, w rzeczywistości wydaje się potwierdzać teorię „kopii źródła”.


Pytanie

Co tu się dzieje? Moje C-fu nie jest wystarczająco dobre, abym mógł wyciągnąć właściwy wniosek po prostu patrząc na kod źródłowy PHP, byłbym wdzięczny, gdyby ktoś mógł przetłumaczyć go na angielski.

Wydaje mi się, że foreachdziała z kopią tablicy, ale ustawia wskaźnik tablicy tablicy źródłowej na końcu tablicy po pętli.

  • Czy to prawda i cała historia?
  • Jeśli nie, co tak naprawdę robi?
  • Czy jest jakaś sytuacja, w której użycie funkcji, które dostosowują wskaźnik tablicy ( each(), reset()i in.) Podczas a, foreachmoże wpłynąć na wynik pętli?

5
@DaveRandom Istnieje znacznik php-internals, który prawdopodobnie powinien iść, ale zostawię to tobie, aby zdecydować, który z pozostałych 5 tagów zastąpić.
Michael Berkowski,

5
wygląda jak COW, bez uchwytu usuwania
zb '

149
Na początku pomyślałem »rany, kolejne pytanie dla początkujących. Przeczytaj dokumentację… hm, wyraźnie nieokreślone zachowanie «. Potem czytam pełne pytanie i muszę powiedzieć: podoba mi się. Włożyłeś w to sporo wysiłku i napisałeś wszystkie przypadki testowe. ps. czy testy 4 i 5 są takie same?
knittl,

21
Wystarczy pomyśleć o tym, dlaczego ma sens dotykanie wskaźnika tablicy: PHP musi zresetować i przesunąć wewnętrzny wskaźnik tablicy oryginalnej tablicy wraz z kopią, ponieważ użytkownik może poprosić o odwołanie do bieżącej wartości ( foreach ($array as &$value)) - PHP musi znać aktualną pozycję w oryginalnej tablicy, nawet jeśli faktycznie iteruje po kopii.
Niko,

4
@Sean: IMHO, dokumentacja PHP jest naprawdę kiepska w opisywaniu niuansów funkcji języka podstawowego. Być może dlatego, że tak wiele specjalnych przypadków ad-hoc upieczonych jest w tym języku ...
Oliver Charlesworth

Odpowiedzi:


1660

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ą Traversableobiekty, ponieważ dla nich foreachjest 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 Iteratorinterfejs 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 $arrzostanie 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ż foreachkorzysta z IAP, istnieje dodatkowa komplikacja: istnieje tylko jeden IAP, ale jedna tablica może być częścią wielu foreachpę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, foreachwykonaj następujące shenanigany: Przed wykonaniem korpusu pętli foreachutworzy 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 currentrodzinę funkcji), ponieważ takie zmiany w IAP liczą się jako modyfikacje w ramach semantyki kopiowania przy zapisie. To niestety oznacza, że foreachw wielu przypadkach jest zmuszony do zduplikowania tablicy, nad którą się iteruje. Dokładne warunki to:

  1. 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.
  2. Tablica ma przelicznik> 1. Jeśli refcountwynosi 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ść refcountzostanie zwiększona (*). Dodatkowo, jeśli foreachzostanie 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 $arrbędą powielane, aby zapobiec IAP zmienia on $arrz 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 refcounttutaj 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, refcounta 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 foreachbę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 foreachimplementacji 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 $arrayzaczyna się od refcount = 1, więc nie będzie duplikowane przez foreach: Tylko wartość refcountjest 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 foreachbędzie modyfikować IAP $arrayzmiennej. Pod koniec iteracji IAP ma wartość NULL (co oznacza, że ​​wykonano iterację), co eachoznacza powrót false.

  • W przypadku testów 4 i 5, jak eachi resetsą funkcjami przez odwołanie. $arrayMa refcount=2, gdy jest przekazywana do nich, więc to musi być powielane. W związku z tym foreachponownie będzie pracował na osobnej tablicy.

Przykłady: Skutki currentw foreach

Dobrym sposobem na pokazanie różnych zachowań związanych z powielaniem jest obserwowanie zachowania current()funkcji wewnątrz foreachpę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, nextktóre są by-ref. Przekazywanie przez referencje oznacza, że ​​tablica musi być oddzielona, ​​a zatem $arrayi foreach-arraybędzie inna. Powód, dla którego dostajesz 2zamiast, 1jest również wspomniany powyżej: foreachprzesuwa wskaźnik tablicy przed uruchomieniem kodu użytkownika, a nie po nim. Więc nawet jeśli kod znajduje się na pierwszym elemencie, foreachwskaź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 foreachpracy w tym samym układzie. Nadal jednak widzisz zachowanie indywidualne, ze względu na sposób foreachprzesuwania 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 $arrayis_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 $arraywynosi 2, gdy pętla jest uruchamiana, więc tym razem musimy faktycznie wykonać kopię z góry. Zatem $arraytablica 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 1został 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ą HashPointermechanizmu 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 HashPointerprzywracanie 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, 4zgodnie 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 foreachnie będą używać IAP w ogóle . foreachPę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 refcountzwiększenie (zamiast duplikowania tablicy) we wszystkich przypadkach. Jeśli tablica zostanie zmodyfikowana podczas foreachpętli, w tym momencie nastąpi duplikacja (zgodnie z kopiowaniem przy zapisie) i foreachnadal 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 refcountingzachowanie parzystości i duplikacji jest dokładnie takie samo między PHP 5 i PHP 7).

  • Zmiany w przypadku testowym 3: Foreachnie 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, foreachnadal 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/refcountingkonfiguracjach. 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.


4
@Baba Tak. Przekazanie go do funkcji jest takie samo, jak wykonanie $foo = $arrayprzed pętlą;)
NikiC

32
Dla tych z Was, którzy nie wiedzą, co to jest Zval, proszę zajrzeć na blog
shu zOMG chen

1
Drobna korekta: to, co nazywasz Bucket, nie jest tym, co zwykle nazywa się Bucket w tablicy mieszającej. Wiadro jest zwykle zbiorem wpisów o tym samym rozmiarze skrótu%. Wydaje się, że używasz go do tego, co zwykle nazywa się wpisem. Lista połączona nie znajduje się w segmentach, ale we wpisach.
unbeli

12
@unbeli Używam terminologii używanej wewnętrznie przez PHP. Gdy Buckets jest częścią podwójnie połączonej listy kolizji kratki, a także część podwójnie połączonej listy do celu;)
Nikić

4
Świetna odpowiedź. Myślę, że miałeś na myśli, iterate($outerArr);a nie iterate($arr);gdzieś.
niahoo,

116

W przykładzie 3 nie modyfikujesz tablicy. We wszystkich innych przykładach modyfikujesz zawartość lub wewnętrzny wskaźnik tablicy. Jest to ważne, jeśli chodzi o tablice PHP ze względu na semantykę operatora przypisania.

Operator przypisania tablic w PHP działa bardziej jak leniwy klon. Przypisanie jednej zmiennej do drugiej zawierającej tablicę spowoduje klonowanie tablicy, w przeciwieństwie do większości języków. Jednak faktyczne klonowanie nie zostanie wykonane, chyba że będzie potrzebne. Oznacza to, że klon nastąpi tylko wtedy, gdy jedna ze zmiennych zostanie zmodyfikowana (kopiowanie przy zapisie).

Oto przykład:

$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.

Wracając do przypadków testowych, możesz łatwo wyobrazić sobie, że foreachtworzy pewnego rodzaju iterator z odniesieniem do tablicy. To odwołanie działa dokładnie tak jak zmienna $bw moim przykładzie. Jednak iterator wraz z referencją są aktywne tylko podczas pętli, a następnie oba są odrzucane. Teraz widać, że we wszystkich przypadkach oprócz 3 tablica jest modyfikowana podczas pętli, podczas gdy to dodatkowe odniesienie jest aktywne. To uruchamia klon, a to wyjaśnia, co się tutaj dzieje!

Oto doskonały artykuł na temat innego efektu ubocznego tego zachowania podczas kopiowania: Operator trójskładnikowy PHP: szybki czy nie?


wydaje się, że masz rację, podałem przykład, który pokazuje, że: codepad.org/OCjtvu8r jedna różnica w stosunku do twojego przykładu - nie kopiuje się, jeśli zmienisz wartość, tylko jeśli zmienisz klucze.
zb '

To rzeczywiście tłumaczy wszystkie powyższe zachowania pokazane powyżej i można to dobrze zilustrować, wywołując each()na końcu pierwszego przypadku testowego, gdzie widzimy, że wskaźnik tablicy oryginalnej tablicy wskazuje na drugi element, ponieważ tablica została zmodyfikowana podczas pierwsza iteracja. Wydaje się to również pokazywać, że foreachprzesuwa wskaźnik tablicy przed wykonaniem bloku kodu pętli, czego się nie spodziewałem - pomyślałem, że zrobi to na końcu. Wielkie dzięki, to mi ładnie to wyjaśnia.
DaveRandom

49

Kilka punktów, na które należy zwrócić uwagę podczas pracy z foreach():

a) foreachdziała na potencjalnej kopii oryginalnej tablicy. Oznacza to foreach(), że będzie mieć UDOSTĘPNIONE przechowywanie danych do momentu, aż nie prospected copyzostanie utworzone żadne powiadomienie / uwagi użytkownika .

b) Co uruchamia potencjalną kopię ? Spodziewana kopia jest tworzona na podstawie zasady copy-on-write, tzn. Za każdym razem, gdy foreach()zmienia się przekazywana tablica, tworzony jest klon oryginalnej tablicy.

c) Oryginalna tablica i foreach()iterator będą miały DISTINCT SENTINEL VARIABLESjedną dla oryginalnej tablicy i drugą dla foreach; patrz kod testu poniżej. SPL , Iteratory i Array Iterator .

Pytanie o przepełnieniu stosu Jak upewnić się, że wartość jest resetowana w pętli foreach w PHP? odnosi się do przypadków (3,4,5) twojego pytania.

Poniższy przykład pokazuje, że każdy () i reset () nie ma wpływu SENTINELzmiennych (for example, the current index variable)na foreach()iteracyjnej.

$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/>";

Wynik:

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
Twoja odpowiedź nie jest całkiem poprawna. foreachdziała na potencjalnej kopii tablicy, ale nie tworzy rzeczywistej kopii, chyba że jest potrzebna.
linepogl

czy chcesz pokazać, jak i kiedy ta potencjalna kopia jest tworzona za pomocą kodu? Mój kod pokazuje, że foreachkopiuje tablicę w 100% przypadków. Chcę wiedzieć. Dziękuję za komentarze
sakhunzai

Kopiowanie tablicy kosztuje dużo. Spróbuj licząc czas potrzebny do iteracji tablica z wykorzystaniem elementów 100000 albo foralbo foreach. Nie zobaczysz żadnej znaczącej różnicy między nimi dwoma, ponieważ faktyczna kopia nie ma miejsca.
linepogl

W takim razie zakładam, że jest SHARED data storagezarezerwowane, dopóki nie copy-on-write, ale (z mojego fragmentu kodu) jest oczywiste, że zawsze będzie DWA zestaw SENTINEL variablesjednego dla original arraydrugiego i drugiego dla foreach. Dzięki, że to ma sens
sakhunzai,

1
tak, jest to „potencjalna” kopia, tj. „potencjalna” kopia. Nie jest chroniona, jak sugerowałeś
sakhunzai

33

UWAGA DLA PHP 7

Aby zaktualizować tę odpowiedź, ponieważ zyskała ona popularność: Ta odpowiedź nie dotyczy już PHP 7. Jak wyjaśniono w „ Wstecznych niezgodnych zmianach ”, w PHP 7 foreach działa na kopię tablicy, więc wszelkie zmiany na samej tablicy nie są odzwierciedlone w pętli foreach. Więcej szczegółów pod linkiem.

Objaśnienie (cytat z php.net ):

Pierwsza forma zapętla się nad tablicą podaną przez wyrażenie_wyrażeniowe. Przy każdej iteracji wartość bieżącego elementu jest przypisywana do wartości $, a wewnętrzny wskaźnik tablicy jest przesuwany o jeden (tak więc przy następnej iteracji będziesz patrzył na następny element).

Tak więc w pierwszym przykładzie masz tylko jeden element w tablicy, a kiedy wskaźnik zostanie przesunięty, następny element nie istnieje, więc po dodaniu nowego elementu foreach kończy się, ponieważ już „zdecydował”, że jest to ostatni element.

W drugim przykładzie zaczynasz od dwóch elementów, a pętla foreach nie znajduje się na ostatnim elemencie, więc ocenia tablicę podczas następnej iteracji i w ten sposób zdaje sobie sprawę, że w tablicy jest nowy element.

Uważam, że jest to konsekwencja części wyjaśnienia w każdej iteracji w dokumentacji, co prawdopodobnie oznacza, że foreachrobi całą logikę przed wywołaniem kodu {}.

Przypadek testowy

Jeśli uruchomisz to:

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

Otrzymasz ten wynik:

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

Co oznacza, że ​​zaakceptował modyfikację i przeszedł przez nią, ponieważ została zmodyfikowana „na czas”. Ale jeśli to zrobisz:

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

Dostaniesz:

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

Co oznacza, że ​​tablica została zmodyfikowana, ale ponieważ zmodyfikowaliśmy ją, gdy foreachznajdował się już przy ostatnim elemencie tablicy, „postanowił”, że nie będzie już zapętlać, i mimo że dodaliśmy nowy element, dodaliśmy go „za późno” i to nie został zapętlony.

Szczegółowe wyjaśnienie można znaleźć na stronie Jak faktycznie działa „foreach” PHP? co wyjaśnia elementy wewnętrzne tego zachowania.


7
Dobrze przeczytałeś resztę odpowiedzi? Ma sens, że foreach decyduje, czy zapętli się jeszcze raz, zanim uruchomi w nim kod.
dkasipovic

2
Nie, tablica jest modyfikowana, ale „za późno”, ponieważ foreach już „myśli”, że znajduje się na ostatnim elemencie (którym jest na początku iteracji) i nie będzie się zapętlać. Gdzie w drugim przykładzie nie jest na ostatnim elemencie na początku iteracji i ocenia ponownie na początku następnej iteracji. Próbuję przygotować przypadek testowy.
dkasipovic

1
@AlmaDo Spójrz na lxr.php.net/xref/PHP_TRUNK/Zend/zend_vm_def.h#4509 Podczas iteracji zawsze jest ustawiony na następny wskaźnik. Kiedy więc dojdzie do ostatniej iteracji, zostanie oznaczony jako zakończony (za pomocą wskaźnika NULL). Kiedy następnie dodasz klucz w ostatniej iteracji, foreach go nie zauważy.
bwoebi

1
@DKasipovic no. Nie ma tam pełnego i jasnego wyjaśnienia (przynajmniej na razie - może się mylę)
Alma Do

4
Właściwie wydaje się, że @AlmaDo ma wadę w zrozumieniu własnej logiki… Twoja odpowiedź jest w porządku.
bwoebi

15

Zgodnie z dokumentacją dostarczoną w podręczniku PHP.

Przy każdej iteracji wartość bieżącego elementu jest przypisywana do $ v, a wewnętrzny
wskaźnik tablicy jest przesuwany o jeden (tak więc przy następnej iteracji będziesz patrzył na następny element).

Tak jak w twoim pierwszym przykładzie:

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

$arraymają tylko jeden element, więc zgodnie z wykonaniem foreach, 1 przypisaj do $vi nie ma on żadnego innego elementu do przesunięcia wskaźnika

Ale w twoim drugim przykładzie:

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

$arraymają dwa elementy, więc teraz $ tablica ocenia wskaźniki zerowe i przesuwa wskaźnik o jeden. Do pierwszej iteracji pętli dodawanej $array['baz']=3;jako referencja.


13

Świetne pytanie, ponieważ wielu programistów, nawet doświadczonych, jest zdezorientowanych sposobem, w jaki PHP obsługuje tablice w pętlach foreach. W standardowej pętli foreach PHP tworzy kopię tablicy używanej w pętli. Kopia jest odrzucana natychmiast po zakończeniu pętli. Jest to przejrzyste w działaniu prostej pętli foreach. Na przykład:

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

To daje:

apple
banana
coconut

Tak więc kopia jest tworzona, ale programista nie zauważa, ponieważ do oryginalnej tablicy nie ma odniesienia w pętli ani po jej zakończeniu. Jednak podczas próby modyfikacji elementów w pętli okazuje się, że nie są one modyfikowane po zakończeniu:

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

print_r($set);

To daje:

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

Wszelkie zmiany w stosunku do oryginału nie mogą być powiadomieniami, w rzeczywistości nie ma żadnych zmian w stosunku do oryginału, nawet jeśli wyraźnie przypisałeś wartość do przedmiotu $. Wynika to z faktu, że operujesz na $ item, ponieważ pojawia się on w kopii przetwarzanego zestawu $ set. Możesz to zmienić, chwytając $ item przez odniesienie, w ten sposób:

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

To daje:

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

Jest więc oczywiste i możliwe do zaobserwowania, że ​​gdy $ item działa na podstawie odwołania, zmiany wprowadzone w $ item są dokonywane w elementach oryginalnego zestawu $. Użycie $ item przez odniesienie również zapobiega tworzeniu kopii tablicy przez PHP. Aby to przetestować, najpierw pokażemy szybki skrypt demonstrujący kopię:

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

To daje:

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

Jak pokazano w przykładzie, PHP skopiował $ set i użył go do zapętlenia, ale kiedy $ set został użyty w pętli, PHP dodał zmienne do oryginalnej tablicy, a nie do skopiowanej tablicy. Zasadniczo PHP używa tylko skopiowanej tablicy do wykonania pętli i przypisania $ item. Z tego powodu powyższa pętla wykonuje się tylko 3 razy i za każdym razem dodaje kolejną wartość na końcu oryginalnego zestawu $, pozostawiając oryginalny zestaw $ z 6 elementami, ale nigdy nie wchodzi w nieskończoną pętlę.

Co jednak, jeśli użyliśmy $ item przez odniesienie, jak wspomniałem wcześniej? Pojedynczy znak dodany do powyższego testu:

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

Wynikiem jest nieskończona pętla. Zauważ, że tak naprawdę jest to nieskończona pętla, musisz albo zabić skrypt samodzielnie, albo poczekać, aż zabraknie pamięci w twoim systemie operacyjnym. Dodałem następujący wiersz do mojego skryptu, aby PHP szybko zabrakło pamięci, sugeruję zrobić to samo, jeśli zamierzasz uruchomić te testy nieskończonej pętli:

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

Tak więc w poprzednim przykładzie z nieskończoną pętlą widzimy powód, dla którego napisano PHP, aby utworzyć kopię tablicy do zapętlenia. Kiedy kopia jest tworzona i używana tylko przez samą strukturę konstrukcji pętli, tablica pozostaje statyczna przez cały czas wykonywania pętli, więc nigdy nie napotkasz problemów.


7

Pętla foreach PHP może być używana z Indexed arrays, Associative arraysi Object public variables.

W pętli foreach pierwszą rzeczą, którą robi php, jest to, że tworzy kopię tablicy, która ma być iterowana. PHP następnie iteruje tę nową copytablicę zamiast oryginalnej. Pokazano to w poniższym przykładzie:

<?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).

Poza tym php pozwala również na użycie iterated values as a reference to the original array value. Pokazano to poniżej:

<?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

Uwaga: Nie original array indexesmożna go używać jako references.

Źródło: http://dwellupper.io/post/47/understanding-php-foreach-loop-with-examples


1
Object public variablesjest zły lub w najlepszym przypadku wprowadzający w błąd. Nie możesz użyć obiektu w tablicy bez odpowiedniego interfejsu (np. Traversible), a kiedy to robisz foreach((array)$obj ..., w rzeczywistości pracujesz z prostą tablicą, a nie obiektem.
Christian
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.