Aller au contenu

Design Pattern Iterator

Parcourir une collection sans exposer sa structure interne

Introduction

Le design pattern Iterator est un pattern de conception comportemental dont l'objectif est de parcourir les éléments d'une collection sans exposer la manière dont celle-ci stocke et organise ses données/objets. Lorsqu'un objet contient plusieurs éléments, le code client peut avoir besoin de les parcourir les uns après les autres. Une première approche consiste à exposer directement la collection interne, par exemple une liste ou un tableau. Cette solution crée cependant un couplage entre le code client et la structure utilisée pour stocker les données.

Le design pattern Iterator consiste à confier le parcours des éléments à un objet dédié : l'itérateur. Le code client peut ainsi parcourir les éléments sans connaître la structure interne de la collection. En C#, ce pattern occupe une place particulière puisque les interfaces IEnumerable<T> et IEnumerator<T> reposent directement sur ce dernier.

Problématique

Nous développons une application permettant de gérer une playlist de musiques. Une musique est représentée par la classe suivante :

public class Song
{
    public string Title { get; }

    public string Artist { get; }

    public Song(string title, string artist)
    {
        Title = title;
        Artist = artist;
    }
}

La classe Playlist contient les différentes musiques :

public class Playlist
{
    private readonly List<Song> _songs = [];

    public void Add(Song song)
    {
        _songs.Add(song);
    }

    public List<Song> GetSongs()
    {
        return _songs;
    }
}

Le code client peut alors parcourir directement la liste :

var playlist = new Playlist();

playlist.Add(new Song("Song 1", "Artist 1"));
playlist.Add(new Song("Song 2", "Artist 2"));
playlist.Add(new Song("Song 3", "Artist 3"));

foreach (var song in playlist.GetSongs())
    Console.WriteLine($"{song.Title} - {song.Artist}");

Cette implémentation fonctionne, mais la classe Playlist expose sa collection interne contenant les musiques, stockées dans une collection de type List<Song>. Si nous décidons ultérieurement de remplacer le type List<Song> en tableau de musiques (Song[]), cette modification pourrait avoir un impact sur le code. L'objectif est donc de permettre le parcours des musiques des playlists, sans exposer la structure dans laquelle les musiques sont stockées.

De plus, le code client peut modifier la collection des musiques, par exemple en ajoutant ou en supprimant des éléments sans passer par les méthodes de la classe Playlist.

Fonctionnement

Le design pattern Iterator permet de séparer deux responsabilités distinctes :

  • L'objet contenant les éléments et qui est responsable de leur stockage.
  • L'itérateur responsable de leur parcours.

Un itérateur conserve une référence vers la collection qu'il parcourt ainsi qu'une position permettant d'identifier l'élément courant. Nous pouvons commencer par définir une interface représentant notre itérateur :

public interface IIterator<T>
{
    bool HasNext();

    T Next();
}

Puis implémenter un itérateur dédié au parcours des musiques stockées dans les playlists :

public class PlaylistIterator : IIterator<Song>
{
    private readonly IReadOnlyList<Song> _songs;
    private int _position;

    public PlaylistIterator(IReadOnlyList<Song> songs)
    {
        _songs = songs;
        _position = 0;
    }

    public bool HasNext()
    {
        return _position < _songs.Count;
    }

    public Song Next()
    {
        if (!HasNext())
            throw new InvalidOperationException("Aucun autre morceau n'est disponible.");

        return _songs[_position++];
    }
}

La classe Playlist peut ensuite créer l'itérateur chargé de parcourir ses éléments, via la méthode CreateIterator :

public class Playlist
{
    private readonly List<Song> _songs = [];

    public void Add(Song song)
    {
        _songs.Add(song);
    }

    public IIterator<Song> CreateIterator()
    {
        return new PlaylistIterator(_songs);
    }
}

Le code client n'a désormais plus besoin d'accéder directement à la collection :

var playlist = new Playlist();

playlist.Add(new Song("Song 1", "Artist 1"));
playlist.Add(new Song("Song 2", "Artist 2"));
playlist.Add(new Song("Song 3", "Artist 3"));

var iterator = playlist.CreateIterator();

while (iterator.HasNext())
{
    var song = iterator.Next();

    Console.WriteLine($"{song.Title} - {song.Artist}");
}

Le code client peut demander un itérateur sur la playlist, puis récupérer successivement ses éléments. Le code client ne connaît ni le type de la collection interne, ni la manière dont le parcours est implémenté.

Proposer plusieurs modes de parcours

L'un des intérêts du pattern Iterator est de pouvoir proposer plusieurs manières de parcourir une même collection. Par exemple, nous pourrions créer un itérateur parcourant les musiques dans l'ordre inverse de l'ordre dans lequel ils sont stockés :

public class ReversePlaylistIterator : IIterator<Song>
{
    private readonly IReadOnlyList<Song> _songs;
    private int _position;

    public ReversePlaylistIterator(IReadOnlyList<Song> songs)
    {
        _songs = songs;
        _position = songs.Count - 1;
    }

    public bool HasNext()
    {
        return _position >= 0;
    }

    public Song Next()
    {
        if (!HasNext())
            throw new InvalidOperationException(
                "Aucun autre morceau n'est disponible.");

        return _songs[_position--];
    }
}

La classe Playlist peut alors proposer différents modes de parcours :

public IIterator<Song> CreateIterator()
{
    return new PlaylistIterator(_songs);
}

public IIterator<Song> CreateReverseIterator()
{
    return new ReversePlaylistIterator(_songs);
}

Le design pattern Iterator en .NET

En pratique, il n'est pas nécessaire de créer soi-même une abstraction telle que IIterator<T> en C#. .NET fournit déjà les interfaces nécessaires à l'implémentation du pattern Iterator. Les deux interfaces principales sont IEnumerable<T> et IEnumerator<T>.

Contrairement à notre interface IIterator<T>, qui utilise les méthodes HasNext() et Next(), IEnumerator<T> sépare le parcours et l’accès à l’élément courant. Sa méthode MoveNext() fait avancer l’itérateur, et sa propriété Current retourne l’élément sur lequel il est positionné.

L'interface IEnumerable<T> représente un objet dont les éléments peuvent être parcourus. Sa méthode GetEnumerator() retourne un IEnumerator<T> chargé d'effectuer ce parcours. Nous pouvons donc rendre notre classe Playlist directement énumérable :

public class Playlist : IEnumerable<Song>
{
    private readonly List<Song> _songs = [];

    public void Add(Song song)
    {
        _songs.Add(song);
    }

    public IEnumerator<Song> GetEnumerator()
    {
        return _songs.GetEnumerator();
    }

    System.Collections.IEnumerator
        System.Collections.IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

Le code client peut parcourir la collection de musiques avec une structure itérative foreach :

var playlist = new Playlist();

playlist.Add(new Song("Song 1", "Artist 1"));
playlist.Add(new Song("Song 2", "Artist 2"));
playlist.Add(new Song("Song 3", "Artist 3"));

foreach (var song in playlist)
    Console.WriteLine($"{song.Title} - {song.Artist}");

Le code devient plus naturel pour un développeur C# tout en conservant l'idée fondamentale du pattern : le code client parcourt les éléments sans avoir besoin de connaître leur représentation interne.

Simplifier la création d’un itérateur avec yield return

Le langage C# permet également de simplifier considérablement la création d'un itérateur grâce aux mots-clés yield return. Voici un exemple :

using System.Collections;

public class Playlist : IEnumerable<Song>
{
    private readonly List<Song> _songs = [];

    public void Add(Song song)
    {
        _songs.Add(song);
    }

    public IEnumerator<Song> GetEnumerator()
    {
        foreach (var song in _songs)
            yield return song;
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

Ainsi, il devient également très simple de définir un parcours particulier. Par exemple, pour ne retourner que les musiques d'un artiste donné :

public IEnumerable<Song> GetSongsByArtist(string artist)
{
    foreach (var song in _songs)
    {
        if (song.Artist == artist)
            yield return song;
    }
}

Lorsqu’une méthode contient une instruction yield return, le compilateur génère automatiquement une machine d’état implémentant le comportement de l’itérateur. Celle-ci mémorise la position du parcours entre deux demandes d’éléments. Le corps de la méthode est alors exécuté progressivement, à mesure que le code client parcourt la séquence.