Les générateurs en PHP

Dans mon précédent article, je vous parlais des itérateurs en PHP. Je concluais en expliquant que les itérateurs sont très pratiques à utiliser, mais franchement pénibles à développer ; et que c’est la raison pour laquelle ont été créés les générateurs, dont je vais maintenant vous parler.

Les générateurs sont apparus dans PHP avec la version 5.5, qui date de 2013 (pour la petite histoire, on peut remarquer qu’ils sont apparus en 2001 dans le langage Python, en 2005 dans le C#, en 2008 dans Ruby, en 2015 dans ECMAScript… mais qu’ils ne sont pas disponibles en C, C++, Java…).

Qu’est-ce qu’un générateur ?

Un générateur est une fonction qui va générer un itérateur. Mais cette génération se fait de manière complètement transparente, et c’est justement tout l’intérêt de la chose.
Pour cela, il suffit que la fonction comporte l’instruction yield. C’est sa présence qui va faire que la fonction va donner l’impression de retourner un itérateur. En fait, à chaque fois qu’on va tomber sur l’instruction yield, on fera avancer l’itérateur.

Voyons cela dans un exemple :

function monGenerateur() {
yield 10;
yield 20;
yield 30;
}

foreach (monGenerateur() as $value) {
print("$value\n");
}

Le résultat de cette exécution sera :

10
20
30

Qu’est-ce qu’il s’est passé ?
Dans le foreach, la fonction monGenerateur() est appelée. Ce qu’on récupère, ce n’est pas le retour de la fonction, mais un itérateur. Cet itérateur a été créé automatiquement par PHP, parce que la fonction contient le mot-clé yield.
À chaque fois que le code de la fonction s’exécute, et qu’il tombe sur un yield, la fonction s’interrompt. La valeur courante de l’itérateur est alors la valeur qui a été « yieldée » (si je puis dire).
C’est ainsi que dans l’exemple, la variable $value déclarée dans le foreach va prendre successivement les valeurs 10, puis 20, puis 30. Quand on revient dans la fonction après avoir yieldé la valeur 30, on atteint la fin de la fonction, donc l’itérateur se termine.

Il faut bien comprendre que le générateur se retrouve réellement à générer (d’où son nom) un itérateur. Donc tout ce qu’on a vu sur les itérateurs dans le précédent article est toujours valable. Par exemple, il est possible de manipuler l’itérateur de manière explicite (plutôt que d’utiliser un simple foreach) :

$iterator = monGenerateur();
while ($iterator->valid()) {
print($iterator->current());
iterator->next();
}

On est d’accord qu’il n’y a pas grand intérêt à écrire les choses de cette manière, mais ça fait comprendre plus facilement ce qu’il se passe derrière.

Paires clé/valeur

Un générateur peut très facilement yielder une clé associée à une valeur, en utilisant la double-flèche (=>).
Petit exemple :

function monGenerateur() {
yield 'Alice' => 29;
yield 'Bob' => 34;
yield 'Camille' => 23;
}
foreach (monGenerateur() as $name => $age) {
print("$name a $age ans\n");
}

Ce qui donnera comme résultat :

Alice a 29 ans
Bob a 34 ans
Camille a 23 ans

Un exemple concret

Dans le précédent article, j’ai montré le code servant à créer un itérateur dont le rôle est de lire le contenu du fichier ligne par ligne. L’objet faisait 29 lignes de code (hors commentaires donc).
Voyons son équivalent avec un générateur :

/**
* Générateur de lecture de fichier.
* @param string $path Chemin vers le fichier à lire.
*/
function monGenerateurFichier($path) {
// ouverture du fichier
$file = fopen($path, 'r');
// initialisation du compteur de lignes
$lineIndex = 0;
// on boucle tant qu'on n'a pas tout lu
while (!feof($file)) {
// on récupère une ligne du fichier
$str = trim(fgets($file));
// on incrémente le compteur de lignes
$lineIndex++;
// on yielde le numéro de ligne et le contenu associé
yield $lineIndex => $str;
}
// fermeture du fichier
fclose($file);
}

Ça tient en seulement 10 lignes de code (qui pourraient facilement être condensées en 8 lignes de code).

Récupérer la valeur de retour de la fonction

Depuis PHP 7.0, il est possible d’aller plus loin avec les générateurs, notamment en récupérant une valeur retournée par la fonction à la fin de son exécution.
Souvenez-vous, la syntaxe de base fait que lorsqu’on appelle un générateur, on récupère l’itérateur généré, et non pas la valeur de retour.

Prenons un exemple : imaginons que l’on veuille étendre le générateur qui lit le contenu d’un fichier ligne par ligne ; on veut en plus qu’il compte le nombre de lignes vides dans le fichier. Comment récupérer ce nombre ?

Pour cela, on va utiliser la méthode getReturn() sur l’itérateur généré :

function monGenerateurFichier2($path) {
$file = fopen($path, 'r');
$lineIndex = 0;
// on ajoute un compteur de lignes vides
$nbrEmptyLines = 0;
while (!feof($file)) {
$str = trim(fgets($file));
$lineIndex++;
// si la ligne est vide, on incrémente le compteur
if (empty($str))
$nbrEmptyLines++;
yield $lineIndex => $str;
}
fclose($file);
// fin de la fonction, on retourne le nombre de lignes vides
return ($nbrEmptyLines);
}

$iterator = monGenerateurFichier2('/chemin/vers/fichier.txt');
foreach ($iterator as $index => $line) {
// on traite le contenu du fichier
}
// on récupère le nombre de lignes vides
$nbr = $iterator->getReturn();

Communication bidirectionnelle

Jusqu’ici, on n’a vu que des exemples où les informations étaient envoyées par le générateur vers l’itérateur. Mais il est aussi possible de communiquer dans l’autre sens. Cette fois on utilisera la méthode send() de l’itérateur pour renvoyer une donnée, qui sera accessible dans le générateur en récupérant la « valeur de retour » du yield.

Faisons encore évoluer le générateur qui lit les fichiers ligne à ligne. On va faire en sorte qu’il soit possible de le faire s’arrêter, si on lui renvoie le booléen false :

function monGenerateurFichier2($path) {
$file = fopen($path, 'r');
while (!feof($file)) {
$str = trim(fgets($file));
// on yielde la ligne, et on récupère le retour
$res = yield $str;
// si le retour vaut false, on arrête
if ($res === false)
break;
}
fclose($file);
}

$iterator = monGenerateurFichier2('/chemin/vers/fichier.txt');
foreach ($iterator as $index => $line) {
print("$line\n");
if ($line == 'STOP')
$iterator->send(false);
}

Ici, on affichera le contenu du fichier, récupéré ligne par ligne, jusqu’à ce qu’on trouve une ligne dont le contenu est « STOP » (ou jusqu’à la fin du fichier).

Délégation de générateurs

Il est possible de créer un générateur qui déléguera ses itérations à d’autres itérateurs (que ceux-ci soient créés directement ou via des générateurs). Pour cela, on utilise l’instruction yield from.

Voici un exemple simple :

function monGenerateurSimple() {
yield 10;
yield 20;
}
function monGenerateurListe() {
yield from [98, 99, 100];
}
function monGenerateur() {
yield from monGenerateurSimple();
yield from monGenerateurListe();
}
foreach (monGenerateur() as $val) {
print("$val\n");
}

Le résultat sera :

10
20
98
99
100

Vous pouvez voir que le générateur monGenerateur() délègue ses itérations aux générateurs monGenerateurSimple() et monGénérateurListe(). Mais aussi que monGenerateurListe() délègue ses itérations directement à un tableau PHP, qui agit comme un itérateur.

Pour prendre un autre exemple, on va utiliser l’itérateur SplFileObject que nous avions vu dans le précédent article :

function monGénérateur($path) {
yield from new SplFileObject($path);
yield from new SplFileObject("$path.save");
}

Ce générateur va lire le contenu du fichier dont le chemin lui aura été fourni en paramètre, mais aussi le contenu du fichier qui a le même nom avec le suffixe « .save ».

Conclusion

Les itérateurs (et les générateurs qui servent à les créer facilement) sont comme beaucoup d’autres techniques de développement : on n’en a pas souvent besoin, mais dans certaines situations ils peuvent être très efficaces. Il ne faut pas en abuser, mais il faut se sentir suffisamment à l’aise pour être capable de les utiliser.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Notifiez-moi des commentaires à venir via email. Vous pouvez aussi vous abonner sans commenter.