• 20 heures
  • Moyenne

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 10/03/2017

Rendez une classe énumérable

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Vous vous rappelez notre TP sur les types génériques ? Nous avions créé une liste chaînée de toute beauté. Sauf qu'il manquait vraiment une fonctionnalité intéressante dans cette classe : le fait de pouvoir la parcourir avec un foreach .

C'est ce que nous allons voir maintenant, comment faire en sorte qu'une classe soit énumérable, au même titre qu'une liste ou qu'un tableau. Vous l'avez deviné, notre classe doit implémenter l'interface IEnumerable  et notamment sa version générique IEnumerable<T> .

Mais avant ça, je vous dois quelques précisons sur l'implémentation explicite d'interface.

Implémenter une interface explicitement

J’ai choisi délibérément de ne pas parler de ça dans le chapitre des interfaces car l'implémentation d'interface explicite est un cas relativement rare mais qui se produit justement quand on implémente l’interface IEnumerable<T> . 

En effet, l'interface IEnumerable<T>  générique hérite de la version non générique d'IEnumerable . Typiquement, le code du framework .NET ressemble (presque) à :

public interface IEnumerable<T> : IEnumerable
{
}

L’interface IEnumerable , non générique, expose une propriété Current . De même, l’interface IEnumerable<T> , générique, qui hérite de IEnumerable , expose également une propriété Current .

Il y a donc une ambiguïté car les deux propriétés portent le même nom, mais ne renvoient pas la même chose. Ce qui est contraire aux règles que nous avons déjà vues. Pour faire la différence, il suffira de préfixer la propriété par le nom de l’interface et de ne pas mettre le mot-clé public .

L’implémentation explicite a également un intérêt dans le code suivant :

public interface ICarnivore
{
    void Manger();
}

public interface IFrugivore
{
    void Manger();
}

public class Homme : ICarnivore, IFrugivore
{
    public void Manger()
    {
        Console.WriteLine("Je mange");
    }
}

class Program
{
    static void Main(string[] args)
    {
        Homme homme = new Homme();
        homme.Manger();
        ((ICarnivore)homme).Manger();
        ((IFrugivore)homme).Manger();
    }
}

Ici, ce code compile car la classe Homme implémente la méthode Manger  qui est commune aux deux interfaces. Par contre, il n’est pas possible de faire la distinction entre le fait de manger en tant qu’homme, en tant que ICarnivore  ou en tant que IFrugivore .

Ce code affichera :

Je mange
Je mange
Je mange

Si c’est le comportement attendu, tant mieux. Si ce n’est pas le cas, il va falloir implémenter au moins une des interfaces de manière explicite :

public class Homme : ICarnivore, IFrugivore
{
    public void Manger()
    {
        Console.WriteLine("Je mange");
    }

    void IFrugivore.Manger()
    {
        Console.WriteLine("Je mange en tant que IFrugivore");
    }

    void ICarnivore.Manger()
    {
        Console.WriteLine("Je mange en tant que ICarnivore");
    }
}

Avec ce code, notre exemple affichera :

Je mange
Je mange en tant que ICarnivore
Je mange en tant que IFrugivore

 Si vous vous rappelez, nous avions vu au moment du chapitre sur les interfaces que Visual Studio Express nous proposait de nous aider dans l’implémentation de l’interface. Par le bouton droit, vous aviez également accès à sous menu « implémenter l’interface explicitement  ». Vous pouvez vous en servir dans ce cas précis.

Je m’arrête là sur l’implémentation d’interface explicite, même s’il y aurait d’autres points à voir. Globalement dans la vraie vie, ils ne vous serviront jamais.

Implémenter IEnumerable 

Revenons à notre interface IEnumerable  ... c'est grâce à elle que nous allons pouvoir rendre notre classe énumérable. Plus précisément, nous allons implémenter sa version générique.

Vous allez voir que c'est une tâche assez fastidieuse, mais en fait c'est très facile et surtout, c'est quasiment tout le temps la même chose. Donc, à partir du moment où on l'a fait une fois, vous arriverez à le refaire à l'infini et au delà.

Comme dit plus haut, le fait d’implémenter cette interface va vous forcer à implémenter deux méthodes GetEnumerator() , la version normale et la version explicite. Sachez dès à présent que les deux méthodes feront exactement la même chose, en l'occurrence elles renvoient un Enumerator personnalisé.

l va donc falloir créer cet Enumerator  qui va s’occuper de la mécanique permettant de naviguer dans notre liste. Il s’agit d’une nouvelle classe qui va devoir implémenter l’interface IEnumerator<T>, c’est-à-dire :

public class ListeChaineeEnumerator<T> : IEnumerator<T>
{
}

Cette interface permet d’indiquer que notre enumérateur va respecter le contrat lui permettant de fonctionner avec un foreach .

Avec cette interface, vous allez devoir implémenter :

  • La propriété Current .

  • La propriété explicite Current  (qui sera la même chose que la précédente).

  • La méthode MoveNext  qui permet de passer à l’élément suivant.

  • La méthode Reset , qui permet de revenir au début de la liste.

  • Et la méthode Dispose .

La méthode Dispose  est en fait héritée de l’interface IDisposable dont hérite l’interface IEnumerator<T> . C’est une interface particulière qui offre l’opportunité de faire tout ce qu’il faut pour nettoyer la classe, c’est-à-dire libérer les variables qui en auraient besoin. En l’occurrence, ici nous n’aurons rien à faire mais il faut quand même que la méthode soit présente. Elle sera donc vide.

Pour implémenter les autres méthodes, il faut que l’énumérateur connaisse la liste qu’il doit énumérer. Il faudra donc que la classe ListeChaineeEnumerator  prenne en paramètre de son constructeur la liste à énumérer. Dans ce constructeur, on initialise la variable membre indice qui contient l’indice courant.

La propriété Current  renverra l’élément à l’indice courant.

La méthode MoveNext  passe à l’élément suivant et renvoie faux s’il n’y a plus d’éléments, vrai sinon.

Enfin la méthode Reset  repasse l’indice à sa valeur initiale.
À noter que la valeur initiale de l’indice est -1, car la boucle foreach  commence par appeler la méthode MoveNext  qui commence par aller à l’élément suivant, c’est-à-dire à l’élément 0.

Voilà pour la théorie. Un peu de code maintenant ! Tout d’abord, la liste chaînée doit implémenter IEnumerable<T> , ce qui donne :

public class ListeChainee<T> : IEnumerable<T>
{
    […Code identique au TP précédent…]

    public IEnumerator<T> GetEnumerator()
    {
        return new ListeChaineeEnumerator<T>(this);
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return new ListeChaineeEnumerator<T>(this);
    }
}

Maintenant, il faut donc créer un nouvel énumérateur personnalisé en lui passant notre liste chainée en paramètres. Cet énumérateur doit implémenter l’interface IEnumerator , ce qui donne :

public class ListeChaineeEnumerator<T> : IEnumerator<T>
{
}

Comme prévu, il faut donc un constructeur qui prend en paramètre la liste chainée :

public class ListeChaineeEnumerator<T> : IEnumerator<T>
{
    private int indice;
    private ListeChainee<T> listeChainee;

    public ListeChaineeEnumerator(ListeChainee<T> liste)
    {
        indice = -1;
        listeChainee = liste;
    }

    public void Dispose()
    {
    }
}

Cette liste sera enregistrée dans une variable membre de la classe. Tant que nous y sommes, nous ajoutons un indice privé que nous initialisons à -1, comme déjà expliqué.

Notez également que la méthode Dispose()  est vide. Reste à implémenter le reste des méthodes :

public class ListeChaineeEnumerator<T> : IEnumerator<T>
{
    private int indice;
    private ListeChainee<T> listeChainee;

    public ListeChaineeEnumerator(ListeChainee<T> liste)
    {
        indice = -1;
        listeChainee = liste;
    }

    public void Dispose()
    {
    }

    public bool MoveNext()
    {
        indice++;
        Chainage<T> element = listeChainee.ObtenirElement(indice);
        return element != null;
    }

    public T Current
    {
        get 
        {
            Chainage<T> element = listeChainee.ObtenirElement(indice);
            if (element == null)
                return default(T);
            return element.Valeur; 
        }
    }

    object IEnumerator.Current
    {
        get { return Current; }
    }

    public void Reset()
    {
        indice = -1;
    }
}

Commençons par la méthode MoveNext() . Elle passe à l’indice suivant et renvoie faux ou vrai en fonction de si on arrive au bout de la liste ou pas. N’oubliez pas que c’est la première méthode qui sera appelée dans le foreach , donc pour passer à l’élément suivant, on incrémente l’indice pour le positionner à l’élément 0. C’est pour cela que l’indice a été initialisé à -1. On utilise ensuite la méthode existante de la liste pour obtenir l’élément à un indice afin de savoir si notre liste peut continuer à s’énumérer.

La propriété Current  renvoie l’élément à l’indice courant, pour cela on utilise l’indice pour accéder à l’élément courant, en utilisant les méthodes de la liste. L’autre propriété Current  fait la même chose, il suffit d’appeler la première propriété Current .

Enfin, la méthode Reset  permet de réinitialiser l’énumérateur en retournant à l’indice initial.

Finalement, ce n’est pas si compliqué que ça. Mais il faut avouer que la première fois, c’est un peu déroutant. ;) Il y a déjà l'implémentation de l'interface, plus la nouvelle classe pour gérer l'énumérateur.  En fait, ce n’est pas obligatoire d'avoir cette classe. On peut très bien faire en sorte que notre classe gère la liste chainée et son énumérateur. Il suffit de faire en sorte que la liste chainée implémente également IEnumerator<T>  et de gérer la logique à l’intérieur de la classe.

Par contre, ce n'est pas recommandé. D'une manière générale il est bien qu'une classe n'ait à s'occuper que d'une seule chose. On appelle cela le principe de responsabilité unique (en anglais SRP : Single Responsibility Principle). Plus une classe fait de choses et plus une modification impacte les autres choses. Ici, il est judicieux de garder le découplage des deux classes.

Il y a quand même une chose que l'on peut améliorer dans ce code. En effet, cette liste n’est pas extrêmement optimisée car lorsque nous obtenons un élément, nous re-parcourons toute la liste depuis le début, notamment dans le cas de la gestion de l’énumérateur. Il pourrait être judicieux qu’à chaque foreach, nous ne parcourions pas tous les éléments et qu'on évite d'appeler continuellement la méthode ObtenirElement() .

Cela pourrait se faire en éliminant l’indice et en utilisant une variable de type Chainage<T> , par exemple :

public class ListeChaineeEnumerator<T> : IEnumerator<T>
{
    private Chainage<T> courant;
    private ListeChainee<T> listeChainee;
    
    public ListeChaineeEnumerator(ListeChainee<T> liste)
    {
        courant = null;
        listeChainee = liste;
    }

    public void Dispose()
    {
    }

    public bool MoveNext()
    {
        if (courant == null)
            courant = listeChainee.Premier;
        else
            courant = courant.Suivant;

        return courant != null;
    }

    public T Current
    {
        get
        {
            if (courant == null)
                return default(T);
            return courant.Valeur;
        }
    }

    object IEnumerator.Current
    {
        get { return Current; }
    }

    public void Reset()
    {
        courant = null;
    }
}

Ici, c’est la variable courant  qui nous permet d’itérer au fur et à mesure de la liste chainée. C’est le même principe que dans la méthode ObtenirElement , sauf qu’on ne re-parcoure pas toute la liste à chaque fois. Dans cet exemple, l'optimisation est négligeable. Elle peut s’avérer intéressante si notre liste grossit énormément. Dans tous les cas, ça ne fait pas de mal d’aller plus vite.11

Le mot-clé yield

Avant d'attaquer ce chapitre, je vous fais une promesse : celle de simplifier encore notre classe de gestion de liste chaînée. Youpi. :p

En effet, implémenter un itérateur est une tâche un peu lourde qui nécessite pas mal de code. Nous allons découvrir un nouveau mot-clé qui va nous rendre bien des services : le mot-clé yield .

Pour la démonstration, omettons un instant que la classe String soit énumérable et créons pour l’exemple une classe qui permette d’énumérer une chaîne de caractères. Nous aurions pu faire quelque chose comme ça :

public class ChaineEnumerable : IEnumerable<char>
{
    private string chaine;
    public ChaineEnumerable(string valeur)
    {
        chaine = valeur;
    }

    public IEnumerator<char> GetEnumerator()
    {
        return new ChaineEnumerateur(chaine);
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return new ChaineEnumerateur(chaine);
    }
}

public class ChaineEnumerateur : IEnumerator<char>
{
    private string chaine;
    private int indice;

    public ChaineEnumerateur(string valeur)
    {
        indice = -1;
        chaine = valeur;
    }

    public char Current
    {
        get { return chaine[indice]; }
    }

    public void Dispose()
    {
    }

    object IEnumerator.Current
    {
        get { return Current; }
    }

    public bool MoveNext()
    {
        indice++;
        return indice < chaine.Length;
    }

    public void Reset()
    {
        indice = -1;
    }
}

Vous êtes maintenant super à l'aise avec ça... Nous avons une classe ChaineEnumerable qui encapsule une chaîne de caractères de type string. Et une classe ChaineEnumerateur qui permet de parcourir une chaîne et de renvoyer à chaque itération un caractère, représenté par le type char (qui est un alias de la structure System.Char).

Rien de bien sorcier, mais un code plutôt lourd.

Regardons à présent le code suivant :

public class ChaineEnumerable : IEnumerable<char>
{
    private string chaine;
    public ChaineEnumerable(string valeur)
    {
        chaine = valeur;
    }

    public IEnumerator<char> GetEnumerator()
    {
        for (int i = 0; i < chaine.Length; i++)
        {
            yield return chaine[i];
        }
    }

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

Il fait la même chose, mais d’une manière bien simplifiée. Nous remarquons l’apparition du mot-clé yield. Il permet de créer facilement des énumérateurs. Il permet, combiné au mot-clé return, de renvoyer un élément d’une collection et de passer à l’élément suivant.
Il peut être combiné au mot-clé break également pour permettre d’arrêter l’énumération. Ce qui veut dire que nous aurions pu écrire la méthode GetEnumerator() également de cette façon :

public IEnumerator<char> GetEnumerator()
{
    int i = 0;
    while (true)
    {
        if (i == chaine.Length)
            yield break;
        yield return chaine[i];
        i++;
    }
}

Ce qui est plus laid, je le conçois :p , mais qui permet d’illustrer le mot-clé yield break.

Le mot-clé yield permet également de renvoyer un IEnumerable (ou sa version générique), ainsi un peu bêtement, on pourrait faire :

static void Main(string[] args)
{
    foreach (string prenom in ObtenirListeDePrenoms())
    {
        Console.WriteLine(prenom);
    }
}

public static IEnumerable<string> ObtenirListeDePrenoms()
{
    yield return "Nicolas";
    yield return "Jérémie";
    yield return "Delphine";
}

Cet exemple va surtout me permettre d’illustrer ce qu’il se passe exactement grâce au mode debug.
Mettez un point d’arrêt au début du programme et lancez-le en mode debug :

Image utilisateur

Appuyez sur F11 plusieurs fois pour rentrer dans la méthode ObtenirListeDePrenoms, nous voyons l’indicateur se déplacer sur les différentes parties de l’instruction foreach. Puis nous arrivons sur la première instruction yield return :

Image utilisateur

Appuyons à nouveau sur F11 et nous pouvons constater que nous sortons de la méthode et que nous rentrons à nouveau dans le corps de la boucle foreach qui va nous afficher le premier prénom. Continuons les F11 jusqu’à repasser sur le mot-clé in et rentrer à nouveau dans la méthode ObtenirListeDePrenoms() et nous pouvons constater que nous retournons directement au deuxième mot-clé yield :

Image utilisateur

Et ainsi de suite jusqu’à ce qu’il n’y ait plus rien dans la méthode ObtenirListeDePrenoms() et que du coup le foreach se termine.
À la première lecture, ce n’est pas vraiment ce comportement que nous aurions imaginé…

Voilà pour le principe du yield return.
À noter qu’un yield break nous aurait fait sortir directement de la boucle foreach.

Le mot-clé yield participe à ce qu’on appelle l’exécution différée. On évalue la méthode ObtenirListeDePrenoms() qu’au moment où on parcoure le résultat avec la boucle foreach, ce qui pourrait paraître surprenant.
Par exemple, si nous faisons :

public static IEnumerable<string> ObtenirListeDePrenoms()
{
    yield return "Nicolas";
    yield return "Jérémie";
    yield return "Delphine";
}

static void Main(string[] args)
{
    IEnumerable<string> prenoms = ObtenirListeDePrenoms();
    Console.WriteLine("On fait des choses ...");
    foreach (string prenom in prenoms)
    {
        Console.WriteLine(prenom);
    }
}

Et que nous mettons un point d’arrêt sur le Console.WriteLine et un autre point d’arrêt dans la méthode ObtenirListeDePrenoms(), alors étrangement, on va s’arrêter dans un premier temps sur le point d’arrêt positionné sur la ligne où il y a le Console.WriteLine et ensuite nous passerons dans le point d’arrêt positionné dans la méthode ObtenirListeDePrenoms().
En effet, on ne passera dans la méthode que lorsque nous itérerons explicitement sur les prénoms grâce au foreach.
Nous reviendrons sur cette exécution différée dans un prochain cours C’est le mot-clé yield qui fait ça.

Bon, et cette promesse alors ?

Nous y voilà, regardez maintenant notre nouvelle classe :

public class ListeChainee<T> : IEnumerable<T>
{
    public Chainage<T> Premier { get; private set; }

    public Chainage<T> Dernier
    {
        get
        {
            if (Premier == null)
                return null;
            Chainage<T> dernier = Premier;
            while (dernier.Suivant != null)
            {
                dernier = dernier.Suivant;
            }
            return dernier;
        }
    }

    public void Ajouter(T element)
    {
        if (Premier == null)
        {
            Premier = new Chainage<T> { Valeur = element };
        }
        else
        {
            Chainage<T> dernier = Dernier;
            dernier.Suivant = new Chainage<T> { Valeur = element, Precedent = dernier };
        }
    }

    public Chainage<T> ObtenirElement(int indice)
    {
        Chainage<T> temp = Premier;
        for (int i = 1; i <= indice; i++)
        {
            if (temp == null)
                return null;
            temp = temp.Suivant;
        }
        return temp;
    }

    public void Inserer(T element, int indice)
    {
        if (indice == 0)
        {
            if (Premier == null)
                Premier = new Chainage<T> { Valeur = element };
            else
            {
                Chainage<T> temp = Premier;
                Premier = new Chainage<T> { Suivant = temp, Valeur = element };
                temp.Precedent = Premier;
            }
        }
        else
        {
            Chainage<T> elementAIndice = ObtenirElement(indice);
            if (elementAIndice == null)
                Ajouter(element);
            else
            {
                Chainage<T> precedent = elementAIndice.Precedent;
                Chainage<T> temp = precedent.Suivant;
                precedent.Suivant = new Chainage<T> { Valeur = element, Precedent = precedent, Suivant = temp };
                temp.Precedent = precedent.Suivant;
            }
        }
    }

    public IEnumerator<T> GetEnumerator()
    {
        Chainage<T> courant = Premier;
        while (courant != null)
        {
            yield return courant.Valeur;
            courant = courant.Suivant;
        }
    }

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

Plus besoin de ListeChaineeEnumerator ...  Elle est pas belle la vie avec le yield ? :)

En résumé

  • Pour rendre une classe énumérable, il faut implémenter l'interface IEnumerable<T>.

  • Le mot clé yield permet de créer facilement des énumérateurs.

  • Il est possible d'implémenter une interface explicitement.

Exemple de certificat de réussite
Exemple de certificat de réussite