foreach
prend en charge l'itération sur trois types de valeurs différents:
Dans ce qui suit, je vais essayer d'expliquer précisément comment fonctionne l'itération dans différents cas. Le cas de loin le plus simple est celui des Traversable
objets, car il ne foreach
s'agit pour l'essentiel que de sucre de syntaxe pour le code suivant:
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();
/* ... */
}
Pour les classes internes, les appels de méthode réels sont évités en utilisant une API interne qui reflète essentiellement l' Iterator
interface au niveau C.
L'itération de tableaux et d'objets simples est beaucoup plus compliquée. Tout d'abord, il convient de noter qu'en PHP, les "tableaux" sont vraiment des dictionnaires ordonnés et ils seront parcourus selon cet ordre (qui correspond à l'ordre d'insertion tant que vous n'avez pas utilisé quelque chose comme sort
). Cela s'oppose à l'itération par l'ordre naturel des clés (comment fonctionnent souvent les listes dans d'autres langues) ou à aucun ordre défini (comment fonctionnent souvent les dictionnaires dans d'autres langues).
La même chose s'applique également aux objets, car les propriétés d'objet peuvent être vues comme un autre dictionnaire (ordonné) mappant les noms de propriété à leurs valeurs, plus une certaine gestion de la visibilité. Dans la majorité des cas, les propriétés des objets ne sont pas réellement stockées de cette manière plutôt inefficace. Cependant, si vous commencez à itérer sur un objet, la représentation compressée normalement utilisée sera convertie en un véritable dictionnaire. À ce stade, l'itération d'objets simples devient très similaire à l'itération de tableaux (c'est pourquoi je ne parle pas beaucoup de l'itération d'objets simples ici).
Jusqu'ici tout va bien. Itérer sur un dictionnaire ne peut pas être trop difficile, non? Les problèmes commencent lorsque vous réalisez qu'un tableau / objet peut changer pendant l'itération. Cela peut se produire de plusieurs manières:
- Si vous itérez par référence en utilisant
foreach ($arr as &$v)
puis $arr
est transformé en référence et vous pouvez le changer pendant l'itération.
- En PHP 5, la même chose s'applique même si vous itérez par valeur, mais le tableau était une référence au préalable:
$ref =& $arr; foreach ($ref as $v)
- Les objets ont un by-handle passant la sémantique, ce qui signifie pour la plupart des cas pratiques qu'ils se comportent comme des références. Ainsi, les objets peuvent toujours être modifiés pendant l'itération.
Le problème avec l'autorisation des modifications pendant l'itération est le cas où l'élément sur lequel vous êtes actuellement est supprimé. Supposons que vous utilisez un pointeur pour savoir à quel élément du tableau vous vous trouvez actuellement. Si cet élément est maintenant libéré, vous vous retrouvez avec un pointeur suspendu (ce qui entraîne généralement une erreur de segmentation).
Il existe différentes façons de résoudre ce problème. PHP 5 et PHP 7 diffèrent considérablement à cet égard et je décrirai les deux comportements ci-dessous. Le résumé est que l'approche de PHP 5 était plutôt stupide et conduisait à toutes sortes de problèmes de bord étranges, tandis que l'approche plus impliquée de PHP 7 se traduisait par un comportement plus prévisible et cohérent.
En dernier lieu, il convient de noter que PHP utilise le comptage des références et la copie sur écriture pour gérer la mémoire. Cela signifie que si vous "copiez" une valeur, vous ne faites que réutiliser l'ancienne valeur et incrémenter son compte de référence (refcount). Une fois que vous avez effectué une sorte de modification, une copie réelle (appelée "duplication") sera effectuée. Voir On vous ment pour une introduction plus complète sur ce sujet.
PHP 5
Pointeur de tableau interne et HashPointer
Les tableaux en PHP 5 ont un "pointeur de tableau interne" (IAP) dédié, qui prend correctement en charge les modifications: chaque fois qu'un élément est supprimé, il y aura une vérification si l'IAP pointe vers cet élément. Si c'est le cas, il est avancé à l'élément suivant à la place.
Bien foreach
qu'il utilise l'IAP, il existe une complication supplémentaire: il n'y a qu'un seul IAP, mais un tableau peut faire partie de plusieurs foreach
boucles:
// 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) {
// ...
}
}
Pour prendre en charge deux boucles simultanées avec un seul pointeur de tableau interne, foreach
effectuez les manœuvres suivantes: Avant l'exécution du corps de la boucle, foreach
sauvegardera un pointeur sur l'élément actuel et son hachage dans un per-foreach HashPointer
. Après l'exécution du corps de la boucle, l'IAP sera redéfini sur cet élément s'il existe toujours. Si toutefois l'élément a été supprimé, nous n'utiliserons que l'endroit où se trouve actuellement l'IAP. Ce schéma fonctionne principalement en quelque sorte, mais il y a beaucoup de comportements étranges que vous pouvez en tirer, dont certains que je vais démontrer ci-dessous.
Duplication de baies
L'IAP est une caractéristique visible d'un tableau (exposée à travers la current
famille de fonctions), car de telles modifications du compte IAP comptent comme des modifications dans la sémantique de copie sur écriture. Cela signifie malheureusement que, foreach
dans de nombreux cas, il est obligé de dupliquer le tableau sur lequel il est en cours d'itération. Les conditions précises sont:
- Le tableau n'est pas une référence (is_ref = 0). S'il s'agit d'une référence, les modifications qui y sont apportées sont censées se propager et ne doivent donc pas être dupliquées.
- Le tableau a refcount> 1. Si
refcount
vaut 1, alors le tableau n'est pas partagé et nous sommes libres de le modifier directement.
Si le tableau n'est pas dupliqué (is_ref = 0, refcount = 1), alors seul son refcount
sera incrémenté (*). De plus, si foreach
par référence est utilisé, le tableau (potentiellement dupliqué) sera transformé en référence.
Considérez ce code comme un exemple de duplication:
function iterate($arr) {
foreach ($arr as $v) {}
}
$outerArr = [0, 1, 2, 3, 4];
iterate($outerArr);
Ici, $arr
sera dupliqué pour éviter les $arr
fuites des modifications IAP $outerArr
. En termes des conditions ci-dessus, le tableau n'est pas une référence (is_ref = 0) et est utilisé à deux endroits (refcount = 2). Cette exigence est regrettable et un artefact de l'implémentation sous-optimale (il n'y a pas de souci de modification pendant l'itération ici, donc nous n'avons pas vraiment besoin d'utiliser l'IAP en premier lieu).
(*) L'incrémentation refcount
ici semble inoffensive, mais viole la sémantique de copie sur écriture (COW): cela signifie que nous allons modifier l'IAP d'un tableau refcount = 2, tandis que COW stipule que les modifications ne peuvent être effectuées que sur refcount = 1 valeurs. Cette violation entraîne un changement de comportement visible par l'utilisateur (alors qu'un COW est normalement transparent) car le changement IAP sur le tableau itéré sera observable - mais uniquement jusqu'à la première modification non IAP sur le tableau. Au lieu de cela, les trois options "valides" auraient été a) de toujours dupliquer, b) ne pas incrémenter le refcount
et ainsi permettre au tableau itéré d'être arbitrairement modifié dans la boucle ou c) ne pas utiliser du tout l'IAP (le PHP 7 solution).
Ordre d'avancement de poste
Il y a un dernier détail d'implémentation que vous devez connaître pour bien comprendre les exemples de code ci-dessous. La manière "normale" de parcourir une certaine structure de données ressemblerait à ceci dans le pseudocode:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
code();
move_forward(arr);
}
Cependant foreach
, étant un flocon de neige plutôt spécial, choisit de faire les choses légèrement différemment:
reset(arr);
while (get_current_data(arr, &data) == SUCCESS) {
move_forward(arr);
code();
}
A savoir, le pointeur de tableau est déjà avancé avant l'exécution du corps de boucle. Cela signifie que pendant que le corps de la boucle travaille sur l'élément $i
, l'IAP est déjà sur l'élément $i+1
. C'est la raison pour laquelle les échantillons de code montrant une modification pendant l'itération seront toujours unset
l' élément suivant , plutôt que l'élément actuel.
Exemples: vos cas de test
Les trois aspects décrits ci-dessus devraient vous donner une impression presque complète des particularités de l' foreach
implémentation et nous pouvons passer à quelques exemples.
Le comportement de vos cas de test est simple à expliquer à ce stade:
Dans les cas de test 1 et 2 $array
commence par refcount = 1, il ne sera donc pas dupliqué par foreach
: Seul le refcount
est incrémenté. Lorsque le corps de boucle modifie par la suite le tableau (qui a refcount = 2 à ce point), la duplication se produit à ce point. Foreach continuera de travailler sur une copie non modifiée de $array
.
Dans le cas de test 3, une fois de plus, le tableau n'est pas dupliqué, ce foreach
qui modifiera donc l'IAP de la $array
variable. À la fin de l'itération, l'IAP est NULL (ce qui signifie que l'itération a été effectuée), ce qui each
indique en retournant false
.
Dans les cas de test 4 et 5, each
il reset
s'agit de fonctions de référence. Le $array
a un refcount=2
quand il leur est transmis, il doit donc être dupliqué. En tant que tel, foreach
il travaillera à nouveau sur un tableau séparé.
Exemples: effets de current
in foreach
Un bon moyen de montrer les différents comportements de duplication est d'observer le comportement de la current()
fonction à l'intérieur d'une foreach
boucle. Considérez cet exemple:
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 2 2 2 2 */
Ici, vous devez savoir qu'il current()
s'agit d'une fonction by-ref (en fait: prefer-ref), même si elle ne modifie pas le tableau. Cela doit être pour jouer bien avec toutes les autres fonctions comme celles next
qui sont toutes by-ref. Le passage par référence implique que le tableau doit être séparé et donc $array
et le foreach-array
sera différent. La raison pour laquelle vous obtenez à la 2
place de 1
est également mentionnée ci-dessus: foreach
avance le pointeur de tableau avant d' exécuter le code utilisateur, pas après. Ainsi, même si le code est au premier élément, foreach
le pointeur a déjà été avancé au second.
Essayons maintenant une petite modification:
$ref = &$array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Ici, nous avons le cas is_ref = 1, donc le tableau n'est pas copié (comme ci-dessus). Mais maintenant qu'il s'agit d'une référence, le tableau n'a plus à être dupliqué lors du passage à la fonction by-ref current()
. Ainsi current()
et foreach
travaillez sur le même tableau. Cependant, vous voyez toujours le comportement off-by-one, en raison de la façon dont foreach
le pointeur avance.
Vous obtenez le même comportement lors de l'itération par référence:
foreach ($array as &$val) {
var_dump(current($array));
}
/* Output: 2 3 4 5 false */
Ici, la partie importante est que foreach fera $array
un is_ref = 1 lorsqu'il est itéré par référence, donc fondamentalement, vous avez la même situation que ci-dessus.
Une autre petite variation, cette fois nous allons assigner le tableau à une autre variable:
$foo = $array;
foreach ($array as $val) {
var_dump(current($array));
}
/* Output: 1 1 1 1 1 */
Ici, le décompte du $array
est de 2 lorsque la boucle est lancée, donc pour une fois, nous devons faire la duplication à l'avance. Ainsi $array
, le tableau utilisé par foreach sera complètement séparé dès le départ. C'est pourquoi vous obtenez la position de l'IAP où qu'il se trouve avant la boucle (dans ce cas, c'était à la première position).
Exemples: modification pendant l'itération
Essayer de prendre en compte les modifications pendant l'itération est à l'origine de tous nos problèmes foreach, donc cela sert à considérer quelques exemples pour ce cas.
Considérez ces boucles imbriquées sur le même tableau (où l'itération by-ref est utilisée pour s'assurer qu'elle est vraiment la même):
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 partie attendue ici est celle qui (1, 2)
manque dans la sortie car l'élément a 1
été supprimé. Ce qui est probablement inattendu, c'est que la boucle externe s'arrête après le premier élément. Pourquoi donc?
La raison derrière cela est le hack de boucle imbriquée décrit ci-dessus: Avant que le corps de la boucle ne s'exécute, la position et le hachage IAP actuels sont sauvegardés dans a HashPointer
. Après le corps de la boucle, il sera restauré, mais uniquement si l'élément existe toujours, sinon la position IAP actuelle (quelle qu'elle soit) est utilisée à la place. Dans l'exemple ci-dessus, c'est exactement le cas: L'élément actuel de la boucle externe a été supprimé, il utilisera donc l'IAP, qui a déjà été marqué comme terminé par la boucle interne!
Une autre conséquence du HashPointer
mécanisme de sauvegarde + restauration est que les modifications apportées à l'IAP via reset()
etc. n'ont généralement pas d'impact foreach
. Par exemple, le code suivant s'exécute comme s'il reset()
n'était pas présent du tout:
$array = [1, 2, 3, 4, 5];
foreach ($array as &$value) {
var_dump($value);
reset($array);
}
// output: 1, 2, 3, 4, 5
La raison en est que, bien qu'il reset()
modifie temporairement l'IAP, il sera restauré dans l'élément foreach actuel après le corps de la boucle. Pour forcer reset()
à faire un effet sur la boucle, vous devez en outre supprimer l'élément actuel, afin que le mécanisme de sauvegarde / restauration échoue:
$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
Mais, ces exemples sont toujours sains d'esprit. Le vrai plaisir commence si vous vous souvenez que la HashPointer
restauration utilise un pointeur sur l'élément et son hachage pour déterminer s'il existe toujours. Mais: les hachages ont des collisions et les pointeurs peuvent être réutilisés! Cela signifie qu'avec un choix judicieux de clés de tableau, nous pouvons faire foreach
croire qu'un élément qui a été supprimé existe toujours, il y sautera donc directement. Un exemple:
$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
Ici, nous devrions normalement attendre la sortie 1, 1, 3, 4
selon les règles précédentes. Ce qui se passe, c'est qu'il 'FYFY'
a le même hachage que l'élément supprimé 'EzFY'
, et l'allocateur arrive à réutiliser le même emplacement de mémoire pour stocker l'élément. Ainsi, foreach finit par sauter directement à l'élément nouvellement inséré, raccourcissant ainsi la boucle.
Substitution de l'entité itérée pendant la boucle
Un dernier cas étrange que je voudrais mentionner, c'est que PHP vous permet de remplacer l'entité itérée pendant la boucle. Vous pouvez donc commencer l'itération sur un tableau, puis le remplacer par un autre tableau à mi-chemin. Ou commencez à itérer sur un tableau, puis remplacez-le par un objet:
$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 */
Comme vous pouvez le voir dans ce cas, PHP commencera juste à itérer l'autre entité depuis le début une fois la substitution effectuée.
PHP 7
Itérateurs de table de hachage
Si vous vous souvenez encore, le principal problème avec l'itération de tableau était de savoir comment gérer la suppression des éléments à mi-itération. PHP 5 a utilisé un seul pointeur de tableau interne (IAP) à cet effet, qui était quelque peu sous-optimal, car un pointeur de tableau devait être étiré pour prendre en charge plusieurs boucles foreach simultanées et l' interaction avec reset()
etc. en plus de cela.
PHP 7 utilise une approche différente, à savoir qu'il prend en charge la création d'une quantité arbitraire d'itérateurs de table de hachage externes et sûrs. Ces itérateurs doivent être enregistrés dans le tableau, à partir de ce moment, ils ont la même sémantique que l'IAP: si un élément du tableau est supprimé, tous les itérateurs de table de hachage pointant vers cet élément seront avancés vers l'élément suivant.
Cela signifie que foreach
n'utilisera plus du tout l'IAP . La foreach
boucle n'aura absolument aucun effet sur les résultats de current()
etc. et son propre comportement ne sera jamais influencé par des fonctions comme reset()
etc.
Duplication de baies
Un autre changement important entre PHP 5 et PHP 7 concerne la duplication de tableaux. Maintenant que l'IAP n'est plus utilisé, l'itération de tableau par valeur ne fera qu'un refcount
incrément (au lieu de dupliquer le tableau) dans tous les cas. Si le tableau est modifié pendant la foreach
boucle, à ce stade, une duplication se produira (selon la copie sur écriture) et foreach
continuera de fonctionner sur l'ancien tableau.
Dans la plupart des cas, ce changement est transparent et n'a d'autre effet que de meilleures performances. Cependant, il y a une occasion où il en résulte un comportement différent, à savoir le cas où le tableau était une référence au préalable:
$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 */
Auparavant, l'itération par valeur des tableaux de référence était des cas spéciaux. Dans ce cas, aucune duplication ne s'est produite, donc toutes les modifications du tableau pendant l'itération seraient reflétées par la boucle. En PHP 7, ce cas particulier a disparu: une itération par valeur d'un tableau continuera toujours à travailler sur les éléments d'origine, sans tenir compte des modifications pendant la boucle.
Ceci, bien sûr, ne s'applique pas à l'itération par référence. Si vous parcourez par référence toutes les modifications seront reflétées par la boucle. Fait intéressant, il en va de même pour l'itération par valeur des objets simples:
$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 */
Cela reflète la sémantique du by-handle des objets (c'est-à-dire qu'ils se comportent comme des références même dans des contextes de by-value).
Exemples
Prenons quelques exemples, en commençant par vos cas de test:
Les cas de test 1 et 2 conservent la même sortie: l'itération de tableau par valeur continue de fonctionner sur les éléments d'origine. (Dans ce cas, le refcounting
comportement pair et de duplication est exactement le même entre PHP 5 et PHP 7).
Le scénario de test 3 change: Foreach
n'utilise plus l'IAP, il each()
n'est donc pas affecté par la boucle. Il aura la même sortie avant et après.
Les cas de test 4 et 5 restent les mêmes: each()
et reset()
dupliqueront la baie avant de modifier l'IAP, tout foreach
en utilisant toujours la baie d'origine. (Ce n'est pas que le changement IAP aurait eu de l'importance, même si le tableau était partagé.)
Le deuxième ensemble d'exemples était lié au comportement de current()
sous différentes reference/refcounting
configurations. Cela n'a plus de sens, car il current()
n'est pas affecté par la boucle, donc sa valeur de retour reste toujours la même.
Cependant, nous obtenons des changements intéressants lors de l'examen des modifications lors de l'itération. J'espère que vous trouverez le nouveau comportement plus sain. Le premier exemple:
$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)
Comme vous pouvez le voir, la boucle externe n'interrompt plus après la première itération. La raison en est que les deux boucles ont maintenant des itérateurs de table de hachage entièrement séparés, et il n'y a plus de contamination croisée des deux boucles via un IAP partagé.
Un autre cas de bord étrange qui est résolu maintenant, est l'effet étrange que vous obtenez lorsque vous supprimez et ajoutez des éléments qui se trouvent avoir le même hachage:
$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
Auparavant, le mécanisme de restauration HashPointer sautait directement vers le nouvel élément car il "ressemblait" à la même chose que l'élément supprimé (en raison de la collision du hachage et du pointeur). Comme nous ne comptons plus sur l'élément de hachage pour rien, ce n'est plus un problème.