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.

8 commentaires pour “Les générateurs en PHP

  1. Merci pour ce commentaire très constructif.
    Surtout que j’ai bien mis en introduction les dates d’apparition des générateurs dans les différents langages (et… Python et Ruby ont respectivement 26 et 33 ans de retard sur le CLU).

    Les langages s’inspirent tous les uns des autres, c’est normal. Globalement, Python et Ruby n’ont pas inventé grand-chose qui n’existait nulle part ailleurs (pas plus que le PHP, on est d’accord).

    D’autres ont déjà participé à ce petit jeu débile il y a plusieurs années maintenant : https://www.geek-directeur-technique.com/2012/08/02/les-joies-de-twitter
    Bref, si tu veux continuer comme ça, va sur un autre blog stp.

  2. Bonjour Amaury,

    Dans le cadre d’un entrainement sur un puzzle Codingame ( light bulbs), j’avais écris la version itérative ET récursive en Javascript, chacune avec un parcours basique ET un générateur.

    J’avais constaté des temps d’éxécutions supérieurs sur les générateurs. D’un facteur x4 au moins il me semble par rapport aux versions basiques. Ce problème semble venir d’un problème d’optimisation du moteur V8 d’après les discussions des forums. Je n’écarte pas non plus une utilisation inappropriée des générateurs de mon côté (est ce vraiment utile pour des opérations CPU-bound ?).

    J’aurai voulu avoir votre avis sur la question. Avez-vous connu le même problème sur la plateforme PHP (ou autres) à des degrés aussi marqués ?

    Merci

  3. @Christian : Je ne suis pas très étonné. Les générateurs impliquent des traitements et des allocations supplémentaires, suivant ce que tu fais ça peut générer beaucoup d’overhead.

    Personnellement, je ne crois pas les avoir utilisés pour des opérations gourmandes en calcul. Le cas d’usage classique, c’est plutôt les trucs qui risquent de consommer beaucoup de mémoire. Ou encore les bouts de codes qui deviennent d’un coup bien plus lisibles (mais sans enjeu de performance).

  4. Bonjour Amaury,
    tout d’abord, je vous kiffe 🙂 .
    C’est rare de trouver des exemples concrets utiles ( en tout cas je ne tombe pas dessus ! ) sur des cours, et en plus, vous réutilisez le même, en le faisant évoluer. Plus que de savoir COMMENT ça marche, ça permet de savoir A QUOI ça sert.
    Concrètement, les avantages des générateurs :
    – consomment moins de mémoire qu’un array massif.
    – rendent le code plus clair ( et aujourd’hui, si je ne m’abuse, on peut même indiquer que la fonction retourne un iterable, ce qui permet de savoir directement à quoi on a affaire.
    Les inconvénients :
    – apparemment ça apporte plus de charge qu’un array classique, c’est ça ?

  5. @Vivien Goncalves : Merci ! 🙂

    C’est exactement ça : quand on veut écrire un code efficace qui ne va pas consommer beaucoup de mémoire, les générateurs permettent d’écrire du code lisible. Par contre, cela se fait au prix d’un surcoût mineur en terme de performances et de consommation mémoire (pour être précis, les générateurs consommes un poil plus de mémoire qu’en écrivant l’itérateur soi-même, mais c’est normalement un goutte d’eau par rapport à ce qu’on économise grâce à l’itérateur).

Laisser un commentaire

Votre adresse e-mail 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.