En route vers PHP 5.5 : Generators

5 novembre 2012php, php-5.5
 Cet article a été rédigé il y a plusieurs années et peut ne plus être tout à fait à jour…

La version actuelle de la branche master de PHP (et donc, probablement, PHP 5.5) implémente le concept de Generator.

Pour faire simple, disons que les Generators sont une manière simple d’implémenter des itérateurs, sans à avoir à écrire beaucoup de code décoratif ; autrement dit, les generators ne viennent pas révolutionner notre langage, mais, comme beaucoup d’autres nouveautés de PHP 5.5, visent à nous faciliter le travail et à rendre notre code plus concis.

Exemple : Chargement en mémoire vs Iterator vs Generator

La meilleure façon d’aborder le concept de Generators est sans aucun doute à travers un exemple.

Considérant la situation suivante :

Nous souhaitons afficher une liste de nombres, multiples d’une valeur donnée, en commençant à 0, et en allant jusqu’à une valeur maximale de notre choix.
Bien sûr, nous voulons séparer la logique de génération de cette liste de nombres, de la logique d’affichage.

Fonction chargeant “tout” en mémoire et retournant un tableau

La première solution qui vient à l’esprit, la plus naive si je puis me permettre, serait de créer une fonction, qui :

  • prenne en paramètre la valeur dont nous souhaitons obtenir les multiples et la valeur maximale à atteindre,
  • et retourne un tableau contenant tous les nombres multiples de la première valeur, entre 0 et la seconde valeur.

Typiquement, cette fonction pourrait être écrite comme ceci :

function multiplesDe($diviseur, $max) {
    $result = array();
    for ($num = 0 ; $num <= $max ; $num += $diviseur) {
        $result[] = $num;
    }
    return $result;
}

Et nous l’utiliserions ainsi, en bouclant sur le tableau qu’elle retourne, pour afficher les valeurs qui nous intéressent :

$listeMultiples = multiplesDe(3, 15);
foreach ($listeMultiples as $i => $multiple) {
    echo "$i => $multiple\n";
}

Le résultat obtenu en sortie sera le suivant :

0 => 0
1 => 3
2 => 6
3 => 9
4 => 12
5 => 15

Cette première implémentation est simple à écrire, et ne demande que peu de code ; mais elle souffre d’un inconvénient majeur : l’ensemble des résultats sont chargés en mémoire par la fonction – ce qui ne serait pas adapté si notre liste de résultats était d’une taille beaucoup plus importante.

Avec un itérateur

Pour répondre à cette problèmatique en évitant de tout charger en mémoire tout en conservant la séparation entre la logique de calculs et la logique d’affichage, PHP propose depuis sa version 5.0 d’utiliser des itérateurs.

Ré-écrivons donc notre fonction, sous la forme d’une classe implémentant l’interface Iterator :

class MultiplesIterator implements Iterator {
    protected $diviseur, $max;
    protected $current, $indiceCurrent;
    public function __construct($diviseur, $max) {
        $this->diviseur = $diviseur;
        $this->max = $max;
    }
    public function rewind() {
        $this->current = 0;
        $this->indiceCurrent = 0;
    }
    public function valid() {
        return $this->current <= $this->max;
    }
    public function current() {
        return $this->current;
    }
    public function key() {
        return $this->indiceCurrent;
    }
    public function next() {
        $this->indiceCurrent += 1;
        $this->current += $this->diviseur;
    }
}

Et l’utilisation de cette classe devient la suivante ; assez peu différente de la boucle de parcours que nous avions mis en place un peu plus haut :

$iterator = new MultiplesIterator(3, 15);
foreach ($iterator as $i => $multiple) {
    echo "$i => $multiple\n";
}

Cette proposition répond à nos deux besoins :

  • séparation calculs / affichage,
  • sans consommation excessive de mémoire dûe à un stockage de l’ensemble des résultats dans une liste.

Mais passer par un itérateur demande d’écrire une quantité de code que j’ai presque envie de qualifier d’incroyable, dans le cadre d’un exemple qui était censé être aussi simple… 25 lignes de code pour générer une liste de nombres multiples d’une valeur donnée ?

Et avec un Generator

A présent, ré-écrivons une nouvelle fois notre portion de code, en utilisant un Generator.

En quelques mots, un Generator est une fonction au sein de laquelle est utilisé le nouveau mot-clef yield. Lors que celui-ci est atteint, la valeur qu’il spécifie est renvoyée à l’appelant, et l’exécution de la fonction est mise en pause jusqu’à ce que l’appelant demande à ce qu’elle soit continuée – ce qui sera le cas jusqu’à l’instruction yield suivante, et ainsi de suite.

Dans notre cas, nous pouvons repartir de notre première implémentation ; mais, au lieu de stocker les nombres générés dans un tableau retourné à la fin de la fonction, il devient possible d’utiliser yield pour qu’ils soient immédiatement re-passés à l’appelant :

function multiplesDe($diviseur, $max) {
    for ($i = 0, $num = 0 ; $num <= $max ; $i += 1, $num += $diviseur) {
        yield $i => $num;
    }
}

L’utilisation de cette fonction, de ce Generator, est identique à celle que nous avions écrit initialement :

$listeMultiples = multiplesDe(3, 15);
foreach ($listeMultiples as $i => $multiple) {
    echo "$i => $multiple\n";
}

En somme, en utilisant un Generator, nous combinons le meilleur des deux solutions vues précédemment :

  • Les logiques de calcul et d’affichage sont séparées,
  • Nous ne consommons pas de mémoire inutilement en stockant tous les résultats dans une liste, puisque chaque valeur est yieldée au moment où elle est générée,
  • Et cette implémentation à base de Generator est courte : seulement 5 lignes de code (contrairement à la solution basée sur un itérateur, qui en demandait 25)

Un Generator est un Iterator

Le parcours à base de foreach() que nous venons d’utiliser fonctionne car, en interne, un Generator est un itérateur.

Parcourir un generator ?

Prenons comme exemple une fonction Generator très simple, qui yielde successivement deux valeurs :

function monGenerator() {
    echo "debut monGenerator\n";
    yield "Premier appel";
    yield "Second appel";
    echo "fin monGenerator\n";
}

Obtenons un generator depuis cette fonction, et, par curiosité, essayons de l’afficher :

$generator = monGenerator();
var_dump($generator);

La sortie obtenue sera la suivante :

object(Generator)#1 (0) {
}

Autrement dit, un generator est un objet, instance de la classe Generator.


A titre de vérification, essayons l'opérateur `instanceof` :
var_dump($generator instanceof Generator);
var_dump($generator instanceof Iterator);

La sortie obtenue sera la suivante :

bool(true)
bool(true)

Nous confirmons ainsi qu’un générateur est une instance de la classe Generator ; et nous prouvons ce que je disais plus haut, à savoir qu’un generator est un itérateur, puisqu’il implémente l’interface Iterator.


Puisqu'un generator est un itérateur, il peut être parcouru *manuellement*, sans forcément être utilisé avec `foreach()` ; cela permet d'ailleurs de mieux voir ce qu'il se passe ;-)

Par exemple, si nous continuons notre exemple, en exécutant la portion de code suivante (sur chaque ligne de code, j’ai indiqué en commentaire quelle sortie elle entrainait) :

$valeur = $generator->current();    // debut monGenerator
echo " >> $valeur\n";               //  >> Premier appel

$generator->next();
$valeur = $generator->current();
echo " >> $valeur\n";               //  >> Second appel

$generator->next();                 // fin monGenerator
$valeur = $generator->current();
echo " >> $valeur\n";               //  >>

La sortie globale obtenue sera la suivante:

debut monGenerator
 >> Premier appel
 >> Second appel
fin monGenerator
 >>

Mais quelques points sont à noter :

  • Lorsque nous avons appelé la fonction monGenerator(), aucune sortie n’a été générée ; le echo de sa première ligne n’a pas été exécuté, puisque nous n’avons pas obtenu la sortie "debut monGenerator" à ce moment là.
  • Par contre, nous avons obtenu cette sortie "debut monGenerator" au moment où nous avons réellement commencé à utiliser notre générateur : lors du premier appel à current()
  • next() permet d’avancer d’instruction yield en instruction yield, alors que current() permet d’obtenir la valeur yieldée.
  • Une fois la dernière instruction yield atteinte, appeler à nouveau next() permet de poursuivre l’exécution de la fonction jusqu’à sa fin.

Nous obtenons la preuve qu’une fonction contenant l’instruction yield – un Generator donc – ne se comporte pas vraiment comme une fonction normale.

Les méthodes d’un generator

Un generator est un itérateur ; il a donc les méthodes de l’interface Iterator :

  • rewind() : voir un peu plus bas : ne peut être utilisé que si le generator est à son premier yield ou avant,
  • valid() : retourne true tant que le generator n’est pas fermé,
  • current() : retourne la valeur yieldée ; ou null si aucune valeur n’a été yieldée ou que le generator est déjà fermé,
  • key() : retourne la clef yieldée ; ou si aucune clef n’a été spécifiée lors du yield, une clef numérique auto-incrémentée ; ou null si le générateur est fermé,
  • next() : reprend l’exécution du générateur, s’il n’est pas encore fermé.

Voici un exemple d’utilisation de la plupart de ces méthodes (à côté de chaque ligne de code figure la sortie qu’elle provoque) :

function monGenerator() {
    echo "debut monGenerator\n";
    yield "Premier appel";
    yield "Second appel";
    echo "fin monGenerator\n";
}

$generator = monGenerator();        // pas de sortie

$key = $generator->key();           // debut monGenerator
echo "key = $key\n";                // key = 0

$valeur = $generator->current();    // pas de sortie
echo "valeur = $valeur\n";          // valeur = Premier appel

var_dump($generator->valid());      // bool(true)

$generator->next();                 // pas de sortie ; avance d'un élément

$key = $generator->key();           // pas de sortie
echo "key = $key\n";                // key = 1

$valeur = $generator->current();    // pas de sortie
echo "valeur = $valeur\n";          // valeur = Second appel

var_dump($generator->valid());      // bool(true)

$generator->next();                 // fin monGenerator

var_dump($generator->valid());      // bool(false)

Et voici la sortie qui est obtenue au final :

debut monGenerator
key = 0
valeur = Premier appel
bool(true)
key = 1
valeur = Second appel
bool(true)
fin monGenerator
bool(false)

Attention avec rewind() : dans l’idée, appeler la fonction rewind() revient à effectuer un retour au contexte d’exécution de l’appel initial du generator, ce qui peut mener à des comportements inattendus.

Pour éviter cela, rewind() ne peut être appelée que s’il revenait à ne rien faire ; autrement dit, que si le generator est actuellement avant son premier yield, ou à son premier yield.
Appeler rewind() dans une autre situation entrainera une exception.

Par exemple, si nous considérons la portion de code suivante :

$generator = monGenerator();        // pas de sortie

// Generator pas après le 1er yield => peut être rewindé
$generator->rewind();               // debut monGenerator
echo $generator->current() . "\n";  // Premier appel

// Generator toujours pas après le 1er yield => peut être rewindé
$generator->rewind();               // pas de sortie
echo $generator->current() . "\n";  // Premier appel
$generator->next();

// Generator après le 1er yield (car next() appelée) => ne peut pas être rewindé
$generator->rewind();               // Uncaught exception 'Exception' with message 'Cannot rewind a generator that was already run'

Nous obtiendrons la sortie suivante :

debut monGenerator
Premier appel
Premier appel

Fatal error:  Uncaught exception 'Exception' with message 'Cannot rewind a generator that was already run'
    in /.../php-5.5/tests/generators/generators-006.php:24

Ici :

  • Le premier appel à rewind() a fonctionné : le générateur n’avait pas encore atteint son premier yield,
  • Le second appel à rewind() est possible : le generator était sur son premier yield – et pas après celui-ci,
  • Par contre, le troisième appel à rewind() entraine une exception : le generator était après son premier yield.

Syntaxe de yield

Comme je l’ai laissé entendre plus haut, la version courante de la branche master de PHP (et donc, probablement, PHP 5.5) introduit un nouveau mot-clef : yield.

Nouveau mot-clef yield

Ce nouveau mot-clef supporte trois syntaxes :

  • yield $key => $value : Yielde la valeur $value avec la clef $key,
  • yield $value : Yielde la valeur $value avec une clef numérique auto-incrémentée,
  • et yield seul : Yielde la valeur null, avec un clef numérique auto-incrémentée.

Par exemple, nous pouvons utiliser la portion de code suivante, qui utilise chacune des trois syntaxes deux fois de suite :

function monGenerator() {
    // Valeur 'null' avec clef numérique en auto-increment
    yield;
    yield;

    // En ne spécifiant qu'une valeur ; clef numérique en auto-increment
    yield "première valeur seule";
    yield "seconde valeur seule";

    // En spécifiant clef et valeur
    yield "première clef" => "première valeur ayant une clef";
    yield "seconde clef" => "seconde valeur ayant une clef";
}

$generator = monGenerator();
foreach ($generator as $clef => $valeur) {
    echo "$clef => $valeur\n";
}

Exécuter cette portion de code nous donnerait la sortie suivante :

0 =>
1 =>
2 => première valeur seule
3 => seconde valeur seule
première clef => première valeur ayant une clef
seconde clef => seconde valeur ayant une clef

On retrouve les trois cas présentés plus haut ; avec une clef numérique auto-incrémentée pour les deux syntaxes où nous ne spécifions pas nous-même cette clef.

Yielding de clefs

Pour ceux qui auraient déjà utilisé des generators avec d’autres langages que PHP (Python, Javascript, C#, …), vous aurez peut-être remarqué que la syntaxe proposée par PHP permet de yielder des clefs en plus des valeurs.

Ceci n’est pas supporté par les autres langages, car ils ne supportent pas de clef dans les itérateurs.

Mais, en PHP, le yielding de clefs :

  • Colle au fonctionnement des itérateurs, et de leurs méthodes key() et current(),
  • Et, même si c’est plutôt lié, s’adapte parfaitement à la syntaxe foreach clef => valeur qui est très utilisée.

Dans les cas où une clef n’est pas yieldée explicitement, le fonctionnement sera le même que pour les clefs de tableaux : un numérique auto-incrémenté est produit, commençant à 0 et s’incrémentant systématiquement de 1. Dans le cas où une clef numérique plus grande que la valeur d’auto-incrément courante est yieldée, celle-ci sera utilisée comme point de départ pour les futurs clefs générées de manière automatiques ; les clefs yieldée explicitement tout en étant d’autres types n’influeront pas sur ce mécanisme.

Une seule différence avec les clefs de tableaux, tout de même : lorsqu’une clef numérique mais qui n’est pas un integer est levée, elle ne sera avec les generators pas automatiquement transtypée en entier – alors qu’avec les tableaux, une clef numérique l’est (la clef de tableau '10' est automatiquement convertie en 10).

Yielding par référence

Les yieldings que nous avons vus jusqu’à présent étaient tous effectués par valeur ; mais il est possible de yielder par référence, pour donner à l’appelant accès à la donnée yieldée.

En m’inspirant assez fortement de l’exemple donné dans la RFC, considérons la portion de code suivante : nous avons une classe avec une donnée, et un generator qui yielde par référence (notez les & devant le nom de la fonction, et dans la boucle foreach) :

class TestGeneratorByRef {
    protected $valeurs;
    public function __construct(array $valeurs) {
        $this->valeurs = $valeurs;
    }
    public function getValeurs() {
        return $this->valeurs;
    }

    public function & getGenerator() {
        foreach ($this->valeurs as & $valeur) {
            yield $valeur;
        }
        unset($valeur);
    }
}

Instancions cette classe, et à titre de vérification, affichons les valeurs qui y sont stockées :

$obj = new TestGeneratorByRef(range(0, 3));
var_dump($obj->getValeurs());

Nous obtiendrons, comme nous pouvions nous y attendre, la sortie suivante (le tableau de valeurs stocké par l’objet a été initialisé) :

array(4) {
  [0]=>
  int(0)
  [1]=>
  int(1)
  [2]=>
  int(2)
  [3]=>
  int(3)
}

Maintenant, utilisons le generator retourné par référence par la méthode getGenerator(), pour parcourir (par référence, encore) la liste de valeurs, en effectuant une opération simple sur chacune d’enrte elles :

foreach ($obj->getGenerator() as & $valeur) {
    $valeur *= 10;
}
unset($valeur);
var_dump($obj->getValeurs());

La sortie obtenue, cette fois-ci, sera la suivante :

array(4) {
  [0]=>
  int(0)
  [1]=>
  int(10)
  [2]=>
  int(20)
  [3]=>
  int(30)
}

Autrement dit, travailler avec un generator par référence permet d’accéder en écriture aux données sur lesquelles nous itérons – ce qui n’est pas possible avec un itérateur normal.

Renvoyer une valeur au Generator

Le mot-clef yield correspond en fait à une expression, dont la valeur peut correspondre à celle envoyée au générateur à l’aide de sa méthode send() – oui, le code appelant, utilisant le generator et travaillant avec les données yieldées par celui-ci, peut renvoyer des données au générateur depuis lequel il obtient ses données.

La syntaxe à utiliser correspond à une simple affectation, sans oublier les parenthèses autour de l’instruction yield et de son couple clef-valeur :

$donnees = (yield $clef => $valeur);
$donnees = (yield $valeur);
$donnees = (yield);
$donnees = yield;       // Parenthèses non requises, ici

A titre d’exemple fonctionnel, considérons la portion de code que je reproduis ci-dessous :

function monGenerator() {
    $donnee = (yield "première valeur");
    var_dump($donnee);

    $donnee = (yield "seconde valeur");
    var_dump($donnee);
}

$generator = monGenerator();

$valeur = $generator->current();
echo "$valeur\n";                           // première valeur
$generator->send("retour 1er yield");       // string(16) "retour 1er yield"

// Pas d'appel à next()

$valeur = $generator->current();
echo "$valeur\n";                           // seconde valeur
$generator->send("retour 2nd yield");       // string(16) "retour 2nd yield"

Le generator utilisé ici yielde successivement deux valeurs ; et affiche à l’aide de var_dump() chacune des données retournées par les expressions de yielding.
Depuis le code appelant, nous utilisons la méthode send() du générateur pour lui spécifier la valeur que l’expression yield devra retourner, et poursuivre l’exécution de celui-ci.

Exécuter cette portion de code ménerait à la sortie suivante :

première valeur
string(16) "retour 1er yield"
seconde valeur
string(16) "retour 2nd yield"

Cet exemple nous montre que les generators n’ont pas un fonctionnement à sens unique (génération de données) : ils peuvent aussi, d’une certaine manière, adapter leur comportement en fonction de celui du code appelant.

Quelques points supplémentaires

Cet article est a priori déjà le plus long de cette série “En route vers PHP 5.5”, mais voici encore quelques points, peut-être moins importants, dont je voulais parler.

Fermeture d’un generator

Un generator est fermé dans trois cas :

  • L’instruction return est appelée depuis le generator,
  • Une exception est levée depuis le generator, et n’est pas catchée au sein de celui-ci,
  • Ou toutes les références à l’objet generator sont perdues / supprimées.

Lorsque le generator est fermé :

  • Son contexte d’exécution est libéré,
  • valid() retourne false,
  • Et key() et current() retournent null.

Quelques cas d’erreurs

Sans faire le tour de tous les “cas d’erreur” possibles, en voici quelques uns qui sont potentiellement intéressants :

Tout d’abord, notons qu’il n’est pas permis d’utiliser une instruction return avec une valeur dans une fonction generator.
Par exemple, la portion de code suivante :

<?php
function monGenerator() {
    yield "Premier appel";
    yield "Second appel";
    return "valeur de retour";
}

Entrainerait une sortie de ce type, en Fatal Error :

Fatal error: Generators cannot return values using "return"
    in /.../php-5.5/tests/generators/generators-error-001.php on line 5

Je disais un peu plus haut qu'un generator est un objet, instance de la classe `Generator`.
Suivant la même logique que la classe `Closure`, cette classe `Generator` ne peut pas être instanciée depuis du code utilisateur *(ça casserait un peu la magie...)*.

Typiquement, une portion de code comme celle que je reproduis ci-dessous n’est pas autorisée :

<?php
$obj = new Generator();

Tenter d’exécuter ceci mène à une autre erreur fatale :

Catchable fatal error: The "Generator" class is reserved for internal use and cannot be manually instantiated
    in /.../php-5.5/tests/generators/generators-error-002.php on line 2

Et purement pour la plaisir, quelques autres points méritant attention, qui entrainent soit une erreur, soit une levée d'exception :
  • Yielding d’une clef qui ne soit pas une clef de tableau valide,
  • Utilisation de l’instruction yield en dehors d’une fonction / méthode,
  • Tentative de parcours par référence d’un generator non-ref,

Pour une liste plus complète, mais pas forcément exhaustive pour autant, je vous invite à lire cette section de la RFC.

Voir aussi

Enfin, comme d’habitude, je termine cet article en vous pointant vers quelques pages qui peuvent être intéressantes :

Et aussi, même s’il s’agit ici de Python (qui supporte les generators depuis bien plus longtemps que PHP, qui ne les inclut pas encore dans une version stable), voici deux autres lectures qui pourraient vous intéresser :