foreach
supporta l'iterazione su tre diversi tipi di valori:
Di seguito, cercherò di spiegare con precisione come funziona l'iterazione in diversi casi. Di gran lunga il caso più semplice sono gli Traversable
oggetti, in quanto per questi foreach
è essenzialmente solo zucchero di sintassi per il codice lungo queste linee:
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();
/* ... */
}
Per le classi interne, le chiamate ai metodi effettivi vengono evitate utilizzando un'API interna che essenzialmente rispecchia semplicemente l' Iterator
interfaccia a livello C.
L'iterazione di matrici e oggetti semplici è significativamente più complicata. Prima di tutto, si dovrebbe notare che in PHP gli "array" sono dizionari realmente ordinati e saranno attraversati secondo questo ordine (che corrisponde all'ordine di inserimento fintanto che non hai usato qualcosa di simile sort
). Ciò si oppone all'iterazione dell'ordine naturale delle chiavi (come funzionano spesso gli elenchi in altre lingue) o non ha alcun ordine definito (come spesso funzionano i dizionari in altre lingue).
Lo stesso vale anche per gli oggetti, in quanto le proprietà dell'oggetto possono essere viste come un altro dizionario (ordinato) che associa i nomi delle proprietà ai loro valori, oltre a una gestione della visibilità. Nella maggior parte dei casi, le proprietà dell'oggetto non vengono effettivamente archiviate in questo modo piuttosto inefficiente. Tuttavia, se si inizia a iterare su un oggetto, la rappresentazione compressa normalmente utilizzata verrà convertita in un dizionario reale. A quel punto, l'iterazione di oggetti semplici diventa molto simile all'iterazione di array (motivo per cui non sto discutendo molto dell'iterazione di oggetti semplici qui).
Fin qui tutto bene. Scorrere su un dizionario non può essere troppo difficile, giusto? I problemi iniziano quando ti rendi conto che un array / oggetto può cambiare durante l'iterazione. Esistono diversi modi in cui ciò può accadere:
- Se si scorre con riferimento utilizzando
foreach ($arr as &$v)
poi $arr
viene trasformato in un punto di riferimento e si può cambiare durante l'iterazione.
- In PHP 5 lo stesso vale anche se si esegue l'iterazione per valore, ma l'array era un riferimento in precedenza:
$ref =& $arr; foreach ($ref as $v)
- Gli oggetti hanno una semantica di passaggio by-handle, che per scopi pratici significa che si comportano come riferimenti. Quindi gli oggetti possono sempre essere cambiati durante l'iterazione.
Il problema con consentire le modifiche durante l'iterazione è il caso in cui l'elemento in cui ci si trova attualmente viene rimosso. Supponi di utilizzare un puntatore per tenere traccia di quale elemento dell'array ti trovi attualmente. Se questo elemento viene ora liberato, ti rimane un puntatore penzolante (che di solito si traduce in un segfault).
Esistono diversi modi per risolvere questo problema. PHP 5 e PHP 7 differiscono significativamente in questo senso e descriverò entrambi i comportamenti di seguito. Il riassunto è che l'approccio di PHP 5 era piuttosto stupido e portava a tutti i tipi di strani casi limite, mentre l'approccio più coinvolto di PHP 7 porta a comportamenti più prevedibili e coerenti.
Come ultimo preliminare, va notato che PHP utilizza il conteggio dei riferimenti e il copy-on-write per gestire la memoria. Ciò significa che se "copi" un valore, in realtà riutilizzi il vecchio valore e ne aumenti il conteggio di riferimento (refcount). Solo una volta effettuata una qualche modifica, verrà eseguita una copia reale (chiamata "duplicazione"). Vedi Ti viene mentito per un'introduzione più ampia su questo argomento.
PHP 5
Puntatore di array interno e HashPointer
Gli array in PHP 5 hanno un "puntatore a array interno" (IAP) dedicato, che supporta correttamente le modifiche: ogni volta che un elemento viene rimosso, verrà verificato se lo IAP punta a questo elemento. In tal caso, viene invece spostato all'elemento successivo.
Mentre foreach
fa uso di IAP, c'è un'ulteriore complicazione: esiste solo un IAP, ma un array può far parte di più foreach
loop:
// 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) {
// ...
}
}
Per supportare due loop simultanei con un solo puntatore ad array interno, foreach
esegui i seguenti shenanigans: Prima che il corpo del loop venga eseguito, foreach
eseguirà il backup di un puntatore sull'elemento corrente e sul suo hash in una for-foreach HashPointer
. Dopo l'esecuzione del corpo del loop, l'IAP verrà ripristinato su questo elemento se esiste ancora. Se tuttavia l'elemento è stato rimosso, utilizzeremo semplicemente ovunque si trovi lo IAP. Questo schema è per lo più una specie di lavoro, ma ci sono molti comportamenti strani che puoi ottenere, alcuni dei quali mostrerò di seguito.
Duplicazione di array
L'IAP è una caratteristica visibile di un array (esposto attraverso la current
famiglia di funzioni), in quanto tali modifiche al conteggio degli IAP sono modificazioni nella semantica della copia su scrittura. Questo, sfortunatamente, significa che foreach
in molti casi è costretto a duplicare l'array su cui sta ripetendo. Le condizioni precise sono:
- L'array non è un riferimento (is_ref = 0). Se è un riferimento, si suppone che le modifiche si propaghino, quindi non dovrebbe essere duplicato.
- L'array ha refcount> 1. Se
refcount
è 1, l'array non è condiviso e siamo liberi di modificarlo direttamente.
Se l'array non è duplicato (is_ref = 0, refcount = 1), refcount
verrà incrementato solo il suo (*). Inoltre, se foreach
per riferimento viene utilizzato, l'array (potenzialmente duplicato) verrà trasformato in un riferimento.
Considera questo codice come un esempio in cui si verifica la duplicazione:
function iterate($arr) {
foreach ($arr as $v) {}
}
$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);
Qui, $arr
saranno duplicati per evitare che le modifiche IAP su $arr
perdano $outerArr
. In termini delle condizioni precedenti, l'array non è un riferimento (is_ref = 0) e viene utilizzato in due posizioni (refcount = 2). Questo requisito è sfortunato e un artefatto dell'implementazione non ottimale (non vi è alcun problema di modifica durante l'iterazione qui, quindi non abbiamo davvero bisogno di usare lo IAP in primo luogo).
(*) L'aumento del refcount
qui sembra innocuo, ma viola la semantica della copia su scrittura (COW): ciò significa che modificheremo lo IAP di un array refcount = 2, mentre COW impone che le modifiche possano essere eseguite solo su refcount = 1 valori Questa violazione comporta una modifica del comportamento visibile dall'utente (mentre una COW è normalmente trasparente) perché la modifica IAP sull'array iterato sarà osservabile, ma solo fino alla prima modifica non IAP sull'array. Invece, le tre opzioni "valide" sarebbero state a) duplicare sempre, b) non incrementare refcount
e quindi consentire l'array iterato di essere arbitrariamente modificato nel ciclo ec) non usare affatto lo IAP (il PHP 7 soluzione).
Ordine avanzamento posizione
C'è un ultimo dettaglio di implementazione di cui devi essere consapevole per comprendere correttamente i seguenti esempi di codice. Il modo "normale" di eseguire il looping di alcune strutture di dati sarebbe simile a questo nello pseudocodice:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
code();
move_forward(arr);
}
Tuttavia foreach
, essendo un fiocco di neve piuttosto speciale, sceglie di fare le cose in modo leggermente diverso:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
move_forward(arr);
code();
}
Vale a dire, il puntatore dell'array è già spostato in avanti prima dell'esecuzione del corpo del loop. Ciò significa che mentre il corpo del loop sta lavorando su element $i
, lo IAP è già su element $i+1
. Questo è il motivo per cui gli esempi di codice che mostrano modifiche durante l'iterazione saranno sempre unset
l' elemento successivo , anziché quello corrente.
Esempi: i tuoi casi di test
I tre aspetti sopra descritti dovrebbero fornire un'impressione per lo più completa delle idiosincrasie foreach
dell'implementazione e possiamo passare a discutere alcuni esempi.
Il comportamento dei casi di test è semplice da spiegare a questo punto:
Nei casi di test 1 e 2 $array
inizia con refcount = 1, quindi non sarà duplicato da foreach
: refcount
Viene incrementato solo il valore . Quando il corpo del loop successivamente modifica l'array (che ha refcount = 2 in quel punto), la duplicazione avverrà in quel punto. Foreach continuerà a lavorare su una copia non modificata di $array
.
Nel caso di test 3, ancora una volta l'array non viene duplicato, pertanto foreach
verrà modificato lo IAP della $array
variabile. Alla fine dell'iterazione, lo IAP è NULL (che significa che l'iterazione è stata eseguita), che each
indica restituendo false
.
Nei casi di test 4 e 5 entrambi each
e reset
sono funzioni di riferimento. L' $array
ha una refcount=2
quando è passata a loro, in modo che deve essere duplicato. Come tale foreach
funzionerà di nuovo su un array separato.
Esempi: effetti di current
in foreach
Un buon modo per mostrare i vari comportamenti di duplicazione è osservare il comportamento della current()
funzione all'interno di un foreach
loop. Considera questo esempio:
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 2 2 2 2 */
Qui dovresti sapere che current()
è una funzione by-ref (in realtà: prefer-ref), anche se non modifica l'array. Deve essere per giocare bene con tutte le altre funzioni come quelle next
che sono tutte by-ref. Il passaggio per riferimento implica che l'array deve essere separato e quindi $array
e foreach-array
sarà diverso. Il motivo che si ottiene al 2
posto di 1
è anche menzionato sopra: fa foreach
avanzare il puntatore dell'array prima di eseguire il codice utente, non dopo. Quindi, anche se il codice è al primo elemento, è foreach
già avanzato il puntatore al secondo.
Ora proviamo una piccola modifica:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Qui abbiamo il caso is_ref = 1, quindi l'array non viene copiato (proprio come sopra). Ma ora che è un riferimento, l'array non deve più essere duplicato quando si passa alla current()
funzione by-ref . Quindi current()
e foreach
lavora sullo stesso array. Si vede comunque il comportamento off-by-one, a causa del modo in cui foreach
avanza il puntatore.
Si ottiene lo stesso comportamento quando si esegue l'iterazione per ref:
foreach ($array as &$val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Qui la parte importante è che foreach farà $array
un is_ref = 1 quando viene iterato per riferimento, quindi sostanzialmente hai la stessa situazione di cui sopra.
Un'altra piccola variazione, questa volta assegneremo l'array a un'altra variabile:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 1 1 1 1 1 */
Qui il refcount di $array
è 2 all'avvio del loop, quindi per una volta dobbiamo effettivamente fare la duplicazione in anticipo. Pertanto, $array
l'array utilizzato da foreach sarà completamente separato dall'inizio. Ecco perché ottieni la posizione dell'IAP dovunque fosse prima del loop (in questo caso era nella prima posizione).
Esempi: modifica durante l'iterazione
Cercare di rendere conto delle modifiche durante l'iterazione è dove sono nati tutti i nostri problemi di foreach, quindi serve prendere in considerazione alcuni esempi per questo caso.
Considera questi loop nidificati sullo stesso array (dove viene usata l'iterazione by-ref per assicurarsi che sia davvero lo stesso):
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)
La parte prevista qui è che (1, 2)
manca dall'output perché l'elemento è 1
stato rimosso. Ciò che probabilmente è inaspettato è che il ciclo esterno si arresta dopo il primo elemento. Perché?
Il motivo alla base di questo è l'hack del ciclo nidificato sopra descritto: prima che il corpo del loop venga eseguito, viene eseguito il backup della posizione IAP corrente e dell'hash in a HashPointer
. Dopo il corpo del loop verrà ripristinato, ma solo se l'elemento esiste ancora, altrimenti verrà utilizzata la posizione IAP corrente (qualunque essa sia). Nell'esempio sopra questo è esattamente il caso: l'elemento corrente del loop esterno è stato rimosso, quindi utilizzerà lo IAP, che è già stato contrassegnato come completato dal loop interno!
Un'altra conseguenza del HashPointer
meccanismo di backup + ripristino è che le modifiche allo IAP attraverso reset()
ecc. Di solito non incidono foreach
. Ad esempio, il codice seguente viene eseguito come se reset()
non fossero presenti:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
// output: 1, 2, 3, 4, 5
Il motivo è che, mentre reset()
modifica temporaneamente lo IAP, verrà ripristinato sull'elemento foreach corrente dopo il corpo del loop. Per forzare reset()
l'effetto sul loop, è necessario rimuovere ulteriormente l'elemento corrente, in modo che il meccanismo di backup / ripristino non riesca:
$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
Ma quegli esempi sono ancora sani di mente. Il vero divertimento inizia se ricordi che il HashPointer
ripristino utilizza un puntatore all'elemento e al suo hash per determinare se esiste ancora. Ma: gli hash hanno delle collisioni e i puntatori possono essere riutilizzati! Ciò significa che, con un'attenta scelta delle chiavi dell'array, possiamo far foreach
credere che esiste ancora un elemento che è stato rimosso, quindi passerà direttamente ad esso. Un esempio:
$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
Qui dovremmo normalmente aspettarci l'output 1, 1, 3, 4
secondo le regole precedenti. Ciò che accade è che 'FYFY'
ha lo stesso hash dell'elemento rimosso 'EzFY'
e l'allocatore riesce a riutilizzare la stessa posizione di memoria per memorizzare l'elemento. Quindi foreach finisce direttamente saltando sull'elemento appena inserito, interrompendo così il ciclo.
Sostituendo l'entità iterata durante il ciclo
Un ultimo caso strano che vorrei menzionare, è che PHP ti consente di sostituire l'entità iterata durante il ciclo. Quindi puoi iniziare iterando su un array e poi sostituirlo con un altro array a metà. Oppure avvia iterando su un array e poi sostituiscilo con un oggetto:
$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 */
Come puoi vedere in questo caso, PHP inizierà a iterare l'altra entità dall'inizio una volta avvenuta la sostituzione.
PHP 7
Iteratori hashtable
Se ricordi ancora, il problema principale con l'iterazione dell'array era come gestire la rimozione degli elementi durante l'iterazione. PHP 5 ha utilizzato un singolo puntatore a matrice interna (IAP) per questo scopo, che era in qualche modo non ottimale, poiché un puntatore a matrice doveva essere allungato per supportare più cicli foreach simultanei e interazione con reset()
ecc.
PHP 7 utilizza un approccio diverso, ovvero supporta la creazione di una quantità arbitraria di iteratori hashtable esterni e sicuri. Questi iteratori devono essere registrati nell'array, da quel momento in poi hanno la stessa semantica dell'IAP: se un elemento dell'array viene rimosso, tutti gli iteratori hashtable che puntano a quell'elemento verranno portati all'elemento successivo.
Ciò significa che foreach
non sarà più utilizzare il PAI affatto . Il foreach
loop non avrà assolutamente alcun effetto sui risultati di current()
ecc. E il suo comportamento non sarà mai influenzato da funzioni come reset()
ecc.
Duplicazione di array
Un altro cambiamento importante tra PHP 5 e PHP 7 riguarda la duplicazione di array. Ora che lo IAP non viene più utilizzato, l'iterazione di array in base al valore farà solo un refcount
incremento (anziché duplicare l'array) in tutti i casi. Se l'array viene modificato durante il foreach
ciclo, a quel punto si verificherà una duplicazione (in base alla copia su scrittura) e foreach
continuerà a funzionare sull'array precedente.
Nella maggior parte dei casi, questa modifica è trasparente e non ha altri effetti che prestazioni migliori. Tuttavia, vi è un'occasione in cui risulta un comportamento diverso, vale a dire il caso in cui l'array era un riferimento in anticipo:
$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 */
In passato, l'iterazione in valore per array di riferimenti era casi speciali. In questo caso, non si è verificata alcuna duplicazione, quindi tutte le modifiche dell'array durante l'iterazione sarebbero riflesse dal loop. In PHP 7 questo caso speciale è sparito: un'iterazione per valore di un array continuerà sempre a funzionare sugli elementi originali, ignorando qualsiasi modifica durante il ciclo.
Questo, ovviamente, non si applica all'iterazione per riferimento. Se si itera per riferimento, tutte le modifiche verranno riflesse dal loop. È interessante notare che lo stesso vale per l'iterazione per valore di oggetti semplici:
$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 */
Ciò riflette la semantica by-handle degli oggetti (cioè si comportano come riferimenti anche in contesti per valore).
Esempi
Consideriamo alcuni esempi, a partire dai casi di test:
I casi di test 1 e 2 mantengono lo stesso output: l'iterazione dell'array per valore continua a funzionare sempre sugli elementi originali. (In questo caso, il refcounting
comportamento pari e duplicato è esattamente lo stesso tra PHP 5 e PHP 7).
Test case 3 cambia: Foreach
non utilizza più lo IAP, quindi each()
non è influenzato dal loop. Avrà lo stesso output prima e dopo.
I casi di test 4 e 5 rimangono gli stessi: each()
e reset()
duplicheranno l'array prima di modificare lo IAP, pur foreach
usando l'array originale. (Non che la modifica IAP avrebbe avuto importanza, anche se l'array fosse condiviso.)
La seconda serie di esempi era correlata al comportamento di current()
diverse reference/refcounting
configurazioni. Questo non ha più senso, poiché non current()
è completamente influenzato dal loop, quindi il suo valore restituito rimane sempre lo stesso.
Tuttavia, otteniamo alcune modifiche interessanti quando si considerano le modifiche durante l'iterazione. Spero che troverai il nuovo comportamento più sano. Il primo esempio:
$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)
Come puoi vedere, il ciclo esterno non si interrompe più dopo la prima iterazione. Il motivo è che entrambi i loop ora hanno iteratori hashtable completamente separati e non esiste più alcuna contaminazione incrociata di entrambi i loop attraverso un IAP condiviso.
Un altro strano caso limite che è stato risolto ora, è lo strano effetto che ottieni quando rimuovi e aggiungi elementi che hanno lo stesso hash:
$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
In precedenza il meccanismo di ripristino di HashPointer passava direttamente al nuovo elemento perché "sembrava" uguale all'elemento rimosso (a causa di hash e puntatore in conflitto). Dato che non facciamo più affidamento sull'hash dell'elemento per nulla, questo non è più un problema.