foreach
unterstützt die Iteration über drei verschiedene Arten von Werten:
Im Folgenden werde ich versuchen, genau zu erklären, wie Iteration in verschiedenen Fällen funktioniert. Bei weitem der einfachste Fall sind Traversable
Objekte, da es sich bei diesen foreach
im Wesentlichen nur um Syntaxzucker für Code in dieser Richtung handelt:
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();
/* ... */
}
Bei internen Klassen werden tatsächliche Methodenaufrufe vermieden, indem eine interne API verwendet wird, die im Wesentlichen nur die Iterator
Schnittstelle auf C-Ebene widerspiegelt.
Die Iteration von Arrays und einfachen Objekten ist erheblich komplizierter. Zuallererst sollte beachtet werden, dass in PHP "Arrays" wirklich geordnete Wörterbücher sind und sie in dieser Reihenfolge durchlaufen werden (die der Einfügereihenfolge entspricht, solange Sie so etwas nicht verwendet haben sort
). Dies steht im Gegensatz zu einer Iteration durch die natürliche Reihenfolge der Schlüssel (wie Listen in anderen Sprachen häufig funktionieren) oder einer überhaupt nicht definierten Reihenfolge (wie Wörterbücher in anderen Sprachen häufig funktionieren).
Gleiches gilt auch für Objekte, da die Objekteigenschaften als ein anderes (geordnetes) Wörterbuch angesehen werden können, das Eigenschaftsnamen ihren Werten zuordnet, sowie eine gewisse Sichtbarkeitsbehandlung. In den meisten Fällen werden die Objekteigenschaften nicht auf diese eher ineffiziente Weise gespeichert. Wenn Sie jedoch über ein Objekt iterieren, wird die normalerweise verwendete gepackte Darstellung in ein echtes Wörterbuch konvertiert. An diesem Punkt wird die Iteration von einfachen Objekten der Iteration von Arrays sehr ähnlich (weshalb ich hier nicht viel über die Iteration von einfachen Objekten spreche).
So weit, ist es gut. Das Durchlaufen eines Wörterbuchs kann nicht allzu schwierig sein, oder? Die Probleme beginnen, wenn Sie feststellen, dass sich ein Array / Objekt während der Iteration ändern kann. Dies kann auf verschiedene Arten geschehen:
- Wenn Sie mit der Referenz iterieren mit
foreach ($arr as &$v)
dann $arr
wird in eine Referenz gedreht und man kann es während der Iteration ändern.
- In PHP 5 gilt das Gleiche auch, wenn Sie nach Wert iterieren, das Array jedoch zuvor eine Referenz war:
$ref =& $arr; foreach ($ref as $v)
- Objekte haben eine By-Handle-Passing-Semantik, was für die meisten praktischen Zwecke bedeutet, dass sie sich wie Referenzen verhalten. So können Objekte während der Iteration immer geändert werden.
Das Problem beim Zulassen von Änderungen während der Iteration besteht darin, dass das Element, auf dem Sie sich gerade befinden, entfernt wird. Angenommen, Sie verwenden einen Zeiger, um zu verfolgen, an welchem Array-Element Sie sich gerade befinden. Wenn dieses Element jetzt freigegeben wird, bleibt ein baumelnder Zeiger übrig (was normalerweise zu einem Segfault führt).
Es gibt verschiedene Möglichkeiten, dieses Problem zu lösen. PHP 5 und PHP 7 unterscheiden sich in dieser Hinsicht erheblich und ich werde beide Verhaltensweisen im Folgenden beschreiben. Die Zusammenfassung ist, dass der Ansatz von PHP 5 ziemlich dumm war und zu allen möglichen seltsamen Randfällen führte, während der komplexere Ansatz von PHP 7 zu einem vorhersehbareren und konsistenteren Verhalten führt.
Als letzte Vorbemerkung sollte angemerkt werden, dass PHP Referenzzählung und Copy-on-Write verwendet, um den Speicher zu verwalten. Das heißt, wenn Sie einen Wert "kopieren", verwenden Sie den alten Wert einfach wieder und erhöhen seinen Referenzzähler (Refcount). Erst wenn Sie eine Änderung vornehmen, wird eine echte Kopie (als "Duplizierung" bezeichnet) erstellt. Sehen Sie werden belogen für eine umfangreichere Einführung zu diesem Thema.
PHP 5
Interner Array-Zeiger und HashPointer
Arrays in PHP 5 verfügen über einen dedizierten "Internal Array Pointer" (IAP), der Änderungen ordnungsgemäß unterstützt: Wenn ein Element entfernt wird, wird überprüft, ob der IAP auf dieses Element verweist. Wenn dies der Fall ist, wird es stattdessen zum nächsten Element weitergeleitet.
Während foreach
der IAP verwendet wird, gibt es eine zusätzliche Komplikation: Es gibt nur einen IAP, aber ein Array kann Teil mehrerer foreach
Schleifen sein:
// 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) {
// ...
}
}
Um zwei gleichzeitige Schleifen mit nur einem internen Array-Zeiger zu unterstützen, führen Sie foreach
die folgenden Spielereien aus: Bevor der Schleifenkörper ausgeführt wird, foreach
wird ein Zeiger auf das aktuelle Element und seinen Hash in einem Per-Foreach gesichert HashPointer
. Nachdem der Schleifenkörper ausgeführt wurde, wird der IAP auf dieses Element zurückgesetzt, wenn es noch vorhanden ist. Wenn das Element jedoch entfernt wurde, verwenden wir es nur dort, wo sich der IAP gerade befindet. Dieses Schema funktioniert meistens irgendwie, aber es gibt eine Menge seltsames Verhalten, das man daraus machen kann, von denen ich einige unten demonstrieren werde.
Array-Duplizierung
Der IAP ist ein sichtbares Merkmal eines Arrays (das durch die current
Funktionsfamilie verfügbar gemacht wird ), da solche Änderungen am IAP als Änderungen unter der Semantik des Kopierens beim Schreiben gelten. Dies bedeutet leider, dass foreach
in vielen Fällen das Array, über das es iteriert, dupliziert werden muss. Die genauen Bedingungen sind:
- Das Array ist keine Referenz (is_ref = 0). Wenn es sich um eine Referenz ist, ändert sich dann auf sie sind angeblich zu verbreiten, so dass es nicht dupliziert werden soll.
- Das Array hat refcount> 1. Wenn
refcount
1 ist, wird das Array nicht freigegeben und wir können es direkt ändern.
Wenn das Array nicht dupliziert wird (is_ref = 0, refcount = 1), wird nur sein Array refcount
inkrementiert (*). Wenn foreach
eine Referenz verwendet wird, wird das (möglicherweise duplizierte) Array in eine Referenz umgewandelt.
Betrachten Sie diesen Code als Beispiel für eine Duplizierung:
function iterate($arr) {
foreach ($arr as $v) {}
}
$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);
Hier $arr
wird dupliziert zu verhindern , auf IAP ändert $arr
Auslaufen zu $outerArr
. In Bezug auf die obigen Bedingungen ist das Array keine Referenz (is_ref = 0) und wird an zwei Stellen verwendet (refcount = 2). Diese Anforderung ist unglücklich und ein Artefakt der suboptimalen Implementierung (es gibt hier keine Bedenken hinsichtlich einer Änderung während der Iteration, sodass wir den IAP nicht wirklich verwenden müssen).
(*) Das Inkrementieren des refcount
hier klingt harmlos, verstößt jedoch gegen die COW-Semantik (Copy-on-Write): Dies bedeutet, dass wir den IAP eines refcount = 2-Arrays ändern werden, während COW vorschreibt, dass Änderungen nur für refcount = durchgeführt werden können 1 Werte. Diese Verletzung führt zu einer vom Benutzer sichtbaren Verhaltensänderung (während eine COW normalerweise transparent ist), da die IAP-Änderung im iterierten Array beobachtet werden kann - jedoch nur bis zur ersten Nicht-IAP-Änderung im Array. Stattdessen wären die drei "gültigen" Optionen a) gewesen, um immer zu duplizieren, b) nicht zu erhöhen refcount
und somit zuzulassen, dass das iterierte Array in der Schleife willkürlich geändert wird, oder c) den IAP überhaupt nicht zu verwenden (das PHP 7 Lösung).
Positionsvorschubreihenfolge
Es gibt ein letztes Implementierungsdetail, das Sie beachten müssen, um die folgenden Codebeispiele richtig zu verstehen. Die "normale" Art, eine Datenstruktur zu durchlaufen, würde im Pseudocode ungefähr so aussehen:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
code();
move_forward(arr);
}
Da foreach
es sich jedoch um eine ganz besondere Schneeflocke handelt, geht man etwas anders vor:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
move_forward(arr);
code();
}
Der Array-Zeiger wird nämlich bereits vorwärts bewegt, bevor der Schleifenkörper ausgeführt wird. Dies bedeutet, dass sich $i
der IAP bereits am Element befindet , während der Schleifenkörper am Element arbeitet $i+1
. Dies ist der Grund, warum Codebeispiele, die Änderungen während der Iteration zeigen, immer unset
das nächste Element und nicht das aktuelle Element sind.
Beispiele: Ihre Testfälle
Die drei oben beschriebenen Aspekte sollen Ihnen einen weitgehend vollständigen Eindruck von den Besonderheiten der foreach
Implementierung vermitteln, und wir können einige Beispiele diskutieren.
Das Verhalten Ihrer Testfälle ist an dieser Stelle einfach zu erklären:
In den Testfällen 1 und 2 $array
beginnt mit refcount = 1, sodass es nicht dupliziert wird durch foreach
: Nur das refcount
wird inkrementiert. Wenn der Schleifenkörper anschließend das Array ändert (das an diesem Punkt refcount = 2 hat), tritt die Duplizierung an diesem Punkt auf. Foreach wird weiterhin an einer unveränderten Kopie von arbeiten $array
.
In Testfall 3 wird das Array erneut nicht dupliziert, foreach
wodurch der IAP der $array
Variablen geändert wird. Am Ende der Iteration ist der IAP NULL (was bedeutet, dass die Iteration durchgeführt wurde), was each
durch Zurückgeben angezeigt wird false
.
In Testfällen 4 und 5 , die beide each
und reset
sind Nebenreferenzfunktionen. Das $array
hat ein, refcount=2
wenn es an sie übergeben wird, also muss es dupliziert werden. Als solches foreach
wird wieder an einem separaten Array gearbeitet.
Beispiele: Auswirkungen von current
in foreach
Eine gute Möglichkeit, die verschiedenen Duplizierungsverhalten zu zeigen, besteht darin, das Verhalten der current()
Funktion innerhalb einer foreach
Schleife zu beobachten . Betrachten Sie dieses Beispiel:
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 2 2 2 2 */
Hier sollten Sie wissen, dass current()
es sich um eine By-Ref-Funktion handelt (eigentlich: Prefer-Ref), obwohl das Array dadurch nicht geändert wird. Es muss sein, um mit all den anderen Funktionen, wie sie next
alle by-ref sind, gut zu spielen. Das Übergeben von Referenzen impliziert, dass das Array getrennt werden muss und daher $array
und das foreach-array
wird anders sein. Der Grund, den Sie 2
anstelle von erhalten, 1
ist auch oben erwähnt: foreach
Erweitert den Array-Zeiger vor dem Ausführen des Benutzercodes, nicht danach. Obwohl sich der Code im ersten Element befindet, wurde foreach
der Zeiger bereits auf das zweite Element verschoben.
Versuchen wir nun eine kleine Modifikation:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Hier haben wir den Fall is_ref = 1, so dass das Array nicht kopiert wird (genau wie oben). Jetzt, da es sich um eine Referenz handelt, muss das Array nicht mehr dupliziert werden, wenn es an die by-ref- current()
Funktion übergeben wird. Also current()
und foreach
arbeite am selben Array. Sie sehen jedoch immer noch das Off-by-One-Verhalten, da foreach
der Zeiger vorwärts bewegt wird.
Sie erhalten das gleiche Verhalten, wenn Sie eine Iteration nach Referenz durchführen:
foreach ($array as &$val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Hier ist der wichtige Teil, dass foreach $array
ein is_ref = 1 macht, wenn es durch Referenz iteriert wird, so dass Sie im Grunde die gleiche Situation wie oben haben.
Eine weitere kleine Variation, dieses Mal weisen wir das Array einer anderen Variablen zu:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 1 1 1 1 1 */
Hier ist der Refcount von $array
2, wenn die Schleife gestartet wird, also müssen wir die Duplizierung einmal im Voraus durchführen. Somit ist $array
das von foreach verwendete Array von Anfang an vollständig getrennt. Aus diesem Grund erhalten Sie die Position des IAP an der Stelle, an der sie sich vor der Schleife befand (in diesem Fall an der ersten Position).
Beispiele: Änderung während der Iteration
Der Versuch, Änderungen während der Iteration zu berücksichtigen, ist der Ursprung all unserer foreach-Probleme. Daher werden einige Beispiele für diesen Fall betrachtet.
Betrachten Sie diese verschachtelten Schleifen über dasselbe Array (wobei die By-Ref-Iteration verwendet wird, um sicherzustellen, dass es wirklich dasselbe ist):
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)
Der erwartete Teil hier ist, dass (1, 2)
er in der Ausgabe fehlt, weil das Element 1
entfernt wurde. Was wahrscheinlich unerwartet ist, ist, dass die äußere Schleife nach dem ersten Element stoppt. Warum ist das so?
Der Grund dafür ist der oben beschriebene Nested-Loop-Hack: Bevor der Loop-Body ausgeführt wird, werden die aktuelle IAP-Position und der aktuelle Hash in a gesichert HashPointer
. Nach dem Schleifenkörper wird er wiederhergestellt, jedoch nur, wenn das Element noch vorhanden ist. Andernfalls wird stattdessen die aktuelle IAP-Position (wie auch immer) verwendet. Im obigen Beispiel ist dies genau der Fall: Das aktuelle Element der äußeren Schleife wurde entfernt, sodass der IAP verwendet wird, der bereits von der inneren Schleife als fertig markiert wurde!
Eine weitere Konsequenz des HashPointer
Backup + Restore-Mechanismus ist, dass Änderungen am IAP über reset()
usw. normalerweise keine Auswirkungen haben foreach
. Der folgende Code wird beispielsweise so ausgeführt, als ob der reset()
überhaupt nicht vorhanden wäre:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
// output: 1, 2, 3, 4, 5
Der Grund dafür ist, dass reset()
der IAP zwar vorübergehend geändert wird, jedoch nach dem Schleifenkörper im aktuellen foreach-Element wiederhergestellt wird. Um reset()
eine Auswirkung auf die Schleife zu erzwingen , müssen Sie zusätzlich das aktuelle Element entfernen, damit der Sicherungs- / Wiederherstellungsmechanismus fehlschlägt:
$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
Aber diese Beispiele sind immer noch vernünftig. Der wahre Spaß beginnt, wenn Sie sich daran erinnern, dass die HashPointer
Wiederherstellung einen Zeiger auf das Element und seinen Hash verwendet, um festzustellen, ob es noch vorhanden ist. Aber: Hashes haben Kollisionen und Zeiger können wiederverwendet werden! Dies bedeutet, dass wir bei sorgfältiger Auswahl der Array-Schlüssel davon ausgehen können, foreach
dass ein entferntes Element noch vorhanden ist, sodass es direkt dorthin springt. Ein Beispiel:
$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
Hier sollten wir normalerweise die Ausgabe 1, 1, 3, 4
gemäß den vorherigen Regeln erwarten . Was passiert, ist, dass 'FYFY'
es denselben Hash wie das entfernte Element 'EzFY'
hat und der Allokator zufällig denselben Speicherort zum Speichern des Elements wiederverwendet. Foreach springt also direkt zum neu eingefügten Element und verkürzt so die Schleife.
Ersetzen der iterierten Entität während der Schleife
Ein letzter seltsamer Fall, den ich erwähnen möchte, ist, dass Sie mit PHP die iterierte Entität während der Schleife ersetzen können. Sie können also mit der Iteration eines Arrays beginnen und es dann zur Hälfte durch ein anderes Array ersetzen. Oder beginnen Sie mit der Iteration eines Arrays und ersetzen Sie es durch ein Objekt:
$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 */
Wie Sie in diesem Fall sehen können, beginnt PHP von Anfang an mit der Iteration der anderen Entität, sobald die Ersetzung erfolgt ist.
PHP 7
Hashtable-Iteratoren
Wenn Sie sich noch erinnern, bestand das Hauptproblem bei der Array-Iteration darin, wie Elemente während der Iteration entfernt werden. PHP 5 verwendete zu diesem Zweck einen einzelnen internen Array-Zeiger (IAP), der etwas suboptimal war, da ein Array-Zeiger gestreckt werden musste, um mehrere gleichzeitige foreach-Schleifen und die Interaktion mit reset()
usw. darüber hinaus zu unterstützen.
PHP 7 verwendet einen anderen Ansatz, nämlich die Erstellung einer beliebigen Anzahl externer, sicherer Hashtabellen-Iteratoren. Diese Iteratoren müssen im Array registriert sein. Ab diesem Zeitpunkt haben sie dieselbe Semantik wie der IAP: Wenn ein Array-Element entfernt wird, werden alle Hashtabellen-Iteratoren, die auf dieses Element verweisen, zum nächsten Element weitergeleitet.
Dies bedeutet, dass foreach
der IAP überhaupt nicht mehr verwendet wird . Die foreach
Schleife hat absolut keinen Einfluss auf die Ergebnisse von current()
usw. und ihr eigenes Verhalten wird niemals durch Funktionen wie reset()
usw. beeinflusst.
Array-Duplizierung
Eine weitere wichtige Änderung zwischen PHP 5 und PHP 7 betrifft die Duplizierung von Arrays. Nachdem der IAP nicht mehr verwendet wird, führt die Array-Iteration nach Wert refcount
in allen Fällen nur noch ein Inkrement durch (anstatt das Array zu duplizieren). Wenn das Array während der foreach
Schleife geändert wird, tritt zu diesem Zeitpunkt eine Duplizierung auf (gemäß Copy-on-Write) und foreach
arbeitet weiter am alten Array.
In den meisten Fällen ist diese Änderung transparent und hat keinen anderen Effekt als eine bessere Leistung. Es gibt jedoch eine Gelegenheit, bei der es zu einem anderen Verhalten kommt, nämlich in dem Fall, in dem das Array zuvor eine Referenz war:
$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 */
Bisher war die Iteration von Referenz-Arrays nach Wert Sonderfälle. In diesem Fall ist keine Duplizierung aufgetreten, sodass alle Änderungen des Arrays während der Iteration von der Schleife wiedergegeben werden. In PHP 7 ist dieser Sonderfall weg: Eine Iteration eines Arrays nach Wert arbeitet immer an den ursprünglichen Elementen, ohne Änderungen während der Schleife zu berücksichtigen.
Dies gilt natürlich nicht für die Iteration nach Referenz. Wenn Sie per Referenz iterieren, werden alle Änderungen von der Schleife übernommen. Interessanterweise gilt das Gleiche für die By-Value-Iteration einfacher Objekte:
$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 */
Dies spiegelt die By-Handle-Semantik von Objekten wider (dh sie verhalten sich selbst in By-Value-Kontexten referenzartig).
Beispiele
Betrachten wir einige Beispiele, beginnend mit Ihren Testfällen:
Die Testfälle 1 und 2 behalten dieselbe Ausgabe bei: Die Array-Iteration nach Wert arbeitet immer an den ursprünglichen Elementen. (In diesem Fall ist das gerade refcounting
und doppelte Verhalten zwischen PHP 5 und PHP 7 genau gleich.)
Änderungen an Testfall 3: Foreach
Verwendet den IAP nicht mehr und each()
ist daher von der Schleife nicht betroffen. Es wird vorher und nachher die gleiche Ausgabe haben.
Testfälle 4 und 5 gleich bleiben: each()
und reset()
das Array duplizieren , bevor die IAP zu ändern, während foreach
immer noch das Original - Array verwendet. (Nicht, dass die IAP-Änderung von Bedeutung gewesen wäre, selbst wenn das Array gemeinsam genutzt worden wäre.)
Die zweite Reihe von Beispielen bezog sich auf das Verhalten current()
unter verschiedenen reference/refcounting
Konfigurationen. Dies ist nicht mehr sinnvoll, da current()
es von der Schleife völlig unberührt bleibt und der Rückgabewert immer gleich bleibt.
Wir erhalten jedoch einige interessante Änderungen, wenn wir Änderungen während der Iteration berücksichtigen. Ich hoffe, Sie finden das neue Verhalten vernünftiger. Das erste Beispiel:
$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)
Wie Sie sehen, wird die äußere Schleife nach der ersten Iteration nicht mehr abgebrochen. Der Grund dafür ist, dass beide Schleifen jetzt vollständig separate Hashtabellen-Iteratoren haben und keine Kreuzkontamination mehr beider Schleifen durch einen gemeinsam genutzten IAP mehr besteht.
Ein weiterer seltsamer Randfall, der jetzt behoben ist, ist der seltsame Effekt, den Sie erhalten, wenn Sie Elemente entfernen und hinzufügen, die zufällig denselben Hash haben:
$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
Zuvor sprang der HashPointer-Wiederherstellungsmechanismus direkt auf das neue Element, da es "aussah", als wäre es dasselbe wie das entfernte Element (aufgrund von kollidierendem Hash und Zeiger). Da wir uns für nichts mehr auf den Element-Hash verlassen, ist dies kein Problem mehr.