Web

Démystifions les iterators avec des chats

by Stéphane Meaudre 1 avril 2021

Les itérateurs, introduits dans la version 5 de PHP, peuvent paraître obscurs et incompréhensibles à première vue. Néanmoins, ils sont très utiles et sont plus propres d’utilisation lorsque nous avons besoin de parcourir des collections d’objets. Beaucoup de développeurs utilisent array() et les fonctions associées lorsqu’ils manipulent plusieurs objets : ce n’est pas une erreur mais d’un point de vue sémantique ce n’est pas correct. Le problème des array est qu’il n’y a généralement pas de contexte et ne contiennent que des données « basiques », simples et conditionnées dans un format non pratique. Ainsi, pour des objets, il est mieux d’utiliser les itérateurs. C’est ce que nous allons voir par la suite et pour comprendre sans se prendre la tête, nous allons utiliser des chats.

Les exemples de code de cet article sont écrits en PHP 7.4.

Qu’est ce qu’un itérateur ?

Avant de rentrer dans le vif du sujet, prenons un peu de temps pour expliquer ce qu’est un itérateur. Un itérateur est un modèle de conception permettant de parcourir tous les éléments contenus dans un autre objet, généralement un conteneur de données (liste, arbre…). PHP permet la création et l’utilisation d’itérateurs personnalisés selon deux implémentations :

  • Ceux implémentant l’interface \Iterator et fournissant des Iterators prêt à l’emploi (comme les Iterators SPL dont certains sont décrits par la suite)
  • Ceux implémentant l’interface \IteratorAggregate permettant de créer un Iterator personnalisé

L’interface \Iterator est utilisée pour les itérateurs ou objets externes pouvant être itérés eux mêmes. Cette interface contient 5 méthodes :

  • current() : retourne l’objet courant
  • key() : retourne la clé de l’élément courant
  • next() : retourne l’élément suivant
  • rewind() : replace l’iterator sur le premier élément
  • valid() : vérifie si la position est valide

L’interface \IteratorAggregate est utilisée pour créer des itérateurs externes.

Nos deux interfaces étendent une interface parente \Traversable. Cette dernière, qui est une interface abstraite, va permettre de dire si une classe peut être parcourue en utilisant foreach() et ne peut pas être implémentée seule.

Pour illustrer les différents itérateurs et exemples suivants, partons du principe que nous avons une entité (un objet avec uniquement des propriétés) Cat. Cet objet va avoir en propriétés : un nom, un âge, une couleur, un poids.

$lazer = new Cat();
$lazer->setName("Lazer")->setColor('grey')->setAge(9)->setWeight(9);
 
$bazoo = new Cat();
$bazoo->setName("Bazoo")->setColor('ginger')->setAge(8)->setWeight(5);
 
$nibbler = new Cat();
$nibbler->setName("Nibbler")->setColor('black')->setAge(4)->setWeight(5);

$kata = new Cat();
$kata->setName("Kata")->setColor('multicolor')->setAge(1)->setWeight(3);

Les itérateurs SPL

La classe ArrayObject

ArrayObject est la classe de base qui va nous servir à manipuler nos objets avec les Iterators et de le faire fonctionner comme un ArrayIterator par défaut. Elle permet de faire fonctionner nos objets comme un array. La méthode getInnerIterator() retournera le type d’Iterator utilisé.

ArrayIterator

Cet itérateur va nous permettre de manipuler les valeurs et les clés lors de l’itération de nos objets. Il propose une multitude de méthodes : liste. Dans l’exemple ci-dessous, nous utilisons la méthode append() pour ajouter un objet. La méthode count() retourne le nombre d’éléments dans notre itérateur.

$myCats = new \ArrayIterator();
$myCats = $myCats->append($lazer);
$myCats = $myCats->append($bazoo);
$myCats = $myCats->append($nibbler);
$myCats = $myCats->append($kata);
 
echo $myCats->count();
while($myCats->valid()) {
  $cat = $myCats->current(); // Retourne l’itération courante
  echo $cat->getName();
  $myCats->next(); // Passe à l’itération suivante
}
 
/*
* Outputs:
* 4
* Lazer
* Bazoo
* Nibbler
* Kata
*/

DirectoryIterator

Cet itérateur est très intéressant à utiliser si on doit parcourir, lire et manipuler des fichiers d’un répertoire spécifique. Son approche peut être même préférable à l’utilisation classique des fonctions scandir() ou fopen().

$containerDirectory = '/path/to/my/directory/';
if (is_dir($containerDirectory)) {
    foreach (new \DirectoryIterator($containerDirectory) as $fileInfo) {
        // Permet d'exclure "." et ".."
        if ($fileInfo->isDot()) {
            continue;
        }
 
        // On vérifie que l'itération est un fichier et on le supprime
        if($fileInfo->isFile() && !unlink($fileInfo->getRealPath())) {
            throw new \RuntimeException(sprintf('Cant delete file "%s"', $fileInfo->getRealPath()));
        }
    }
    if(!rmdir($containerDirectory)) {
        throw new \RuntimeException(sprintf('Cant delete directory "%s"', $containerDirectory));
    }
}

Il peut également retourner les permissions d’un fichier, indiquer la date de la dernière modification d’un fichier etc.

FilesystemIterator

Cet itérateur est une extension du DirectoryIterator :

FilesystemIterator extends DirectoryIterator implements SeekableIterator 
{
}

Sa différence avec le DirectoryIterator est principalement sur le fonctionnement lors de chaque itération. FilesystemIterator (et RecursiveDirectoryIterator d’autre part) va renvoyer un nouvel objet SplFileInfo différent à chaque itération. Tandis que lorsqu’on utilise DirectoryIterator, à chaque itération chaque «valeur» renvoyée est le même objet DirectoryIterator.

FilterIterator

Cet itérateur fonctionne un peu de la même façon que la fonction PHP array_filter(). Pour l’utiliser il faut créer une classe qui va étendre FilterIterator. La classe est construite sur le schéma suivant :

  • Un constructeur acceptant deux arguments : notre itérateur et la variable de filtre.
  • Une méthode accept() qui va vérifier si l’élément courant valide le filtre.

Reprenons notre ArrayIterator contenant nos 3 chats. Nous souhaitons filtrer pour ne récupérer que les chats dont l’âge est supérieur à 5 ans.

class AgeFilter extends \FilterIterator
{
  private $filter;

  public function __construct(Iterator $iterator, $filter)
  {
 parent::__construct($iterator);
 $this->filter = $filter;
  }

  public function accept(): bool
  {
    return true === ($this->filter < $this->current()->getAge());
  }
}

// On veut filtrer les chats ayant plus de 5 ans
$oldCats = new AgeFilter($myCats, 5);
foreach ($oldCats as $oldCat) {
  echo $oldCat->getName();
} 
/*
* Outputs:
* Lazer
* Bazoo
*/

LimitIterator

Cet itérateur permet d’itérer sur une partie limitée de notre Iterator. Il prend en premier argument l’itérateur à limiter, en second argument l’offset et en troisième argument optionnel la limite.

$listCats = new LimitIterator($myCats, 2);
foreach ($listCats as $cat) {
  echo $cat->getName();
}
/**
* Output
* Nibbler
* Kata
*/
$listCats = new LimitIterator($myCats, 2, 1);
foreach ($listCats as $cat) {
  echo $cat->getName();
}
/**
* Output
* Nibbler
*/

Tous les itérateurs SPL :

Créer son propre itérateur

Il est possible de créer son propre itérateur lorsque nous avons un besoin spécifique non possible avec les itérateurs SPL. Notre itérateur héritant de \Iterator, on lui ajoute donc les méthodes obligatoires.

class CatIterator implements \Iterator
{
    private $listCats = [];

    public function __construct(array $listCats)
    {
     $this->listCats = $listCats;
    }

    public function current()
    {
        // TODO: Implement current() method.
    }

    public function next()
    {
        // TODO: Implement next() method.
    }

    public function key()
    {
        // TODO: Implement key() method.
    }

    public function valid()
    {
        // TODO: Implement valid() method.
   }

    public function rewind()
    {
        // TODO: Implement rewind() method.
    }
}

Au développeur d’ajouter et modifier sa classe selon son besoin. Gardons cette classe de côté pour plus tard lorsque nous aborderons les Collections.

L’interface \IteratorAggregate

ArrayCollection, une Collection fourni par Doctrine

Cet itérateur est particulier car il est fourni par Doctrine pour manipuler des entités récupérés via les associations @OneToMany ou @ManyToMany. Il retournera une collection d’entités. Reprenons notre entité Cat et imaginons là comme une entité Doctrine et ajoutons-y une entité Kitten via une propriété $kitten.

L’entité Kitten :

/**
 * @ORM\Table(name="kittens")
 */
class Kitten 
{
 /**
  * @var integer
  *
  * @ORM\Id()
  * @ORM\Column(name="id", type="integer", nullable=false)
  * @ORM\GeneratedValue(strategy="AUTO")
  */
    private int $id;

 /*
 * @ORM\ManyToOne(targetEntity="Cat", inversedBy="kitten", cascade={"persist"})
 * @ORM\JoinColumn(name="id_cat", referencedColumnName="id")
 */
    private Cat $cat;

 /**
  * @var string
  * @ORM\Column(name="name", type="string", length=500, nullable=false)
  */
    private string $name;

    // assessors...
}

Et la modification de notre entité Cat en lui ajoutant la propriété $kitten :

/**
 * @ORM\Table(name="cats")
 */
class Cat 
{
    /**
    * @ORM\OneToMany(targetEntity="Kitten", mappedBy="cat", nullable="true")
    */
    private ?Kitten $kitten;

    public function __construct()
    {
        $this->kitten = new ArrayCollection();
    }

    /**
    * @return Collection|null
    */
    public function getKitten(): ?Collection
    {
        return $this->kitten;
    }

    /**
    * @param Collection $kitten
    * @return Kitten
    */
    public function setKitten(?Collection $kitten): self
    {
        $this->kitten = $kitten;
        return $this;
    }

    /**
    * @param Kitten $historic
    * @return $this
    */
    public function addKitten(?Kitten $kitten): self
    {
        if (!$this->kitten->contains($kitten)) {
            $this->kitten->add($kitten);
        }
        return $this;
    }

    /**
    * @param Kitten $kitten
    * @return $this
    */
    public function removeKitten(?Kitten $kitten): self
    {
        if ($this->kitten->contains($kitten)) {
            $this->kitten->remove($kitten);
        }
        return $this;
    }
}

Pour manipuler notre entité Cat en lui ajoutant des Kitten :

// Get instance of Cat
$kata = $entityManager->getRepository(Cat::class)->find(['name' => 'kata']);

// Create instance of Kitten
$littleCato = new Kitten();
$littleCato->setName('Littlecato');

// Add Kitten to Cat
$kata->addKitten($littleCato);

// Persist in DB
$entityManager->persist($kata);
$entityManager->flush();

Construisons notre propre Collection

En programmation objet, une Collection est un type gérant une liste d’objets de la même classe. Le type Collection n’existe pas par défaut en PHP, mais il est possible d’y remédier en créant une classe qui implémente l’interface \IteratorAggregate. Notre classe doit contenir les méthodes suivante :

  • getIterator() ou on instancie l’itérateur que l’on souhaite utiliser ;
  • un constructeur pour y passer nos données simples
finalclass CatsCollectionimplements\IteratorAggregate
{
 private array $cats;

  public function __construct(array $cats)
  {
     $this->cats = $cats;
  }

  public function getIterator(): \ArrayIterator
  {
    return new \ArrayIterator($this->listCats);
  }
}

$catsCollection = new \CatsCollection(['lazer', 'bazoo', 'nibbler', 'kata']);
foreach ($catsCollection as $catName) { echo $catName; }

/*
*Outputs:
*
*lazer
*bazoo
*nibbler

*kata
*/

Pour gérer une collection d’objets Cat, nous allons modifier notre CatsCollection en y ajoutant une méthode addCat() prenant en argument un objet Cat.

use Traversable;

class CatsCollection implements \IteratorAggregate
{
    private array $cats;

    /**
     * @return ArrayIterator
     */
    public function getIterator(): \ArrayIterator
    {
        return new \ArrayIterator($this->cats);
    }

    public function addCat(Cat $cat): void
    {
        $this->cats[] = $cat;
    }
}

$catsCollection = new CatsCollection();
$catsCollection->addCat($lazer);
$catsCollection->addCat($bazoo);
$catsCollection->addCat($nibbler);

Pour compléter notre collection, ajoutons une méthode getCats() qui retournera notre résultat sous la forme d’un array en fonction de la valeur de $position. Tandis que $catsCollection->getIterator() retournera un ArrayIterator et pourra être parcouru comme tel.

Modifions un peu maintenant notre collection pour n’y avoir que des objets Cat dedans. Pour cela, nous allons utiliser notre itérateur personnalisé CatIterator. Nous modifions l’instance de l’itérateur dans la méthode getIterator() de la collection pour le remplacer par :

class CatsCollection implements \IteratorAggregate
{
    private array $cats;

    /**
     * @return CatIterator
     */
    public function getIterator(): CatIterator
    {
        return new CatIterator($this);
    }

    public function addCat(Cat $cat): void
    {
        $this->cats[] = $cat;
    }

    public function getCat(int $position): ?Cat
    {
        return $this->cats[$position] ?? null;
    }

    public function count(): int
    {
        return count($this->cats);
    }
}

Modifions notre classe CatIterator en conséquence :

class CatIterator implements \Iterator
{
    private int $position = 0;
    private CatsCollection $catsCollection;
 
    public function __construct(CatsCollection $catsCollection)
    {
        $this->catsCollection = $catsCollection;
    }

    public function current(): Cat
    {
        return $this->catsCollection->getCat($this->position);
    }

    public function next(): void
    {
        $this->position++;
    }

    public function key(): int
    {
        return $this->position;
    }

    public function valid(): bool
    {
        return !is_null($this->catsCollection->getCat($this->position));
    }

    public function rewind(): void
    {
        $this->position = 0;
    }
}

Nous pouvons maintenant ajouter nos Cat dans la collection :

$catsCollection = new CatsCollection();
$catsCollection->addCat($lazer);
$catsCollection->addCat($bazoo);
$catsCollection->addCat($nibbler);

foreach ($catCollection as $cat) {
   var_dump($cat->getName());
}

/**
* Output:
* string(5) "Lazer"
* string(5) "Bazoo"
* string(7) "Nibbler"
*/ 

[lien vers la documentation]

Les Generator et yield

Un Generator est un procédé se comportant comme un Iterator et utilisant le mot-clé yield. Son principal intérêt est dans la consommation mémoire qui peut devenir excessive lors qu’on parcours via foreach() un array contenant énormément de données. La documentation de PHP le résume ainsi : les générateurs fournissent une façon simple de mettre en place des itérateurs sans le coût ni la complexité du développement d’une classe qui implémente l’interface Iterator.

Un générateur vous permet d’écrire du code qui utilise foreach pour parcourir un jeu de données, sans avoir à construire un tableau en mémoire pouvant conduire à dépasser la limite de la mémoire ou nécessiter un temps important pour sa génération. Au lieu de cela, vous pouvez écrire une fonction générateur, qui est identique à une fonction normale, mis à part le fait qu’au lieu de retourner une seule fois, un générateur peut utiliser yield autant de fois que nécessaire, afin de fournir les valeurs à parcourir.

Exemple classique de l’utilisation d’un yield avec une closure (ou fonction anonyme) :

$listCats = function () {
    foreach (['lazer', 'bazoo', 'nibbler', 'kata'] as $catName) {
        yield $catName;    
    }
}
foreach($listCats() as $cat) {
    echo $cat;
}

Pourquoi utiliser un yield au lieu de parcourir simplement le tableau ?

Au vue de l’exemple ci-dessus, pas d’intérêt effectivement. Mais leur utilisation est principalement pour des questions de performances et d’optimisation de l’utilisation de la mémoire. Imaginons une base de données contenant plus de 100 000 chats que l’on doit convertir en entité. À un moment, la mémoire assignée à PHP va miauler très fort… C’est donc là que le yield est intéressant à utiliser.

La différence entre un array et un yield est dans le fonctionnement :

  • La boucle foreach opère sur une copie des valeurs du tableau spécifié et non sur les valeurs elles-mêmes. Ensuite foreach affecte le pointeur interne du tableau sur l’élément suivant ;
  • avec un yield chaque résultat est généré à chaque itération.

PHP 7 permet d’appeler une fonction \Generator depuis une autre classe. Notre code peut donc ressembler comme ceci :

  • Un repository qui retourne la liste de nos entitées Cat
use Doctrine\ORM\EntityRepository;

class CatRepository extends EntityRepository
{
    public function getCats(): \Generator
    {
        $results = $this->find();
        foreach ($result as $cat) {
            yield $cat;
        }
    }
}
  • Dans une autre classe (controller, manager, service…)
$listCats = function () use($catRepository) {
    yield from $catRepository->getCats();
}

foreach ($listCats() as $cat) {
    echo $cat->getName();
}

Autre exemple d’utilisation du yield dans Api Platform dans le cas du développement d’une custom collection data provider. Plutôt que retourner l’entité en tant que telle, le yield renvoie les données.

Compléments

PHP fournit 3 fonctions :

  • iterator_apply : appelle une fonction sur tous les éléments d’un itérateur ;
  • iterator_to_array : copie les éléments de l’itérateur dans un array() ;
  • iterator_count : retourne le nombre d’élément dans l’itérateur (attention, l’utilisation de cette fonction peut changer la position de l’élément courant).

Conclusion

Les itérateurs et générateurs devraient vous paraître beaucoup plus clair après lecture. Leur utilisation va permettre d’avoir un code plus rigoureux et plus flexible sur la manipulation des listes d’objets mais également balayer de mauvaises pratiques d’utilisation des array.

Un remerciement pour Lazer, Bazoo, Nibbler et Kata qui ont accepté de participer à cet article, ainsi qu’à Jean-Baptiste et Lucie pour la relecture.

Stéphane Meaudre

Stéphane Meaudre

Développeur PHP à Kaliop depuis 2014 et passionné d'astronomie, j'aime réfléchir à l'architecture et à la conception de mon code avant tout. C'est pour ces raisons que j'apprécie fortement de travailler avec Symfony et API Platform.

Commentaires

Ajouter un commentaire

Votre commentaire sera modéré par nos administrateurs

Vous avez un projet ? Nos équipes répondent à vos questions

Contactez-nous