C# – Bases de programmation générique

C# supporte partiellement la programmation générique, au sens de programmation où les types ou les algorithmes sont exprimés sur la base d'un ou plusieurs types. Ce texte se veut une introduction aux bases de la programmation générique avec C#.

Vous aimerez peut-être aussi ce texte sur les expressions λ avec C#.

Bases de programmation générique

Soit les trois méthodes suivantes :

// ...
static void Afficher(int val)
{
   Console.WriteLine($"Valeur : {val}");
}
static void Afficher(float val)
{
   Console.WriteLine($"Valeur : {val}");
}
static void Afficher(string val)
{
   Console.WriteLine($"Valeur : {val}");
}
// ...

Vous remarquerez sans peine qu'elles ont toutes la même forme : même nom, même arité (même nombre de paramètres), même syntaxe à l'appel... Seuls des détails sémantiques diffèrent de l'une à l'autre : elles sont « pareilles » au type près!

Le polymorphisme est une approche permettant de généraliser de telles fonctionnalités. Dans l'exemple ci-dessous, toute classe implémentant l'interface IAffichable peut être affichée à travers la méthode Afficher(), bien que le code de la méthode Afficher() de chacun de ces IAffichable doive être rédigé explicitement :

// ...
interface Affichable
{
   void Afficher();
}
// ...
static void Afficher(Affichable a)
{
   a.Afficher();
}
// ...

Cependant, cette approche a ses défauts. En particulier, elle est intrusive : il faut implémenter une interface ou dériver d'une classe pour en profiter, ce qui impose des choix au code qui souhaite être utilisé par la méthode de classe Afficher(). Parfois, on souhaitera en arriver à une solution moins fortement couplée, plus flexible.

La généricité permet d'exprimer des méthodes et des classes identiques sur le plan algorithmique, mais différant sur la base des types. Par exemple, une liste de quelque chose plutôt qu'une liste d'entiers, ou une méthode capable d'afficher quelque chose plutôt que spécifiquement une chaîne de caractères. Par convention, le type de ce quelque chose sera souvent indiqué par la lettre T (pour type) ou U (celle qui suit T dans l'alphabet), mais ce n'est qu'une convention :

// ...
static void Afficher<T>(T val)
{
   Console.WriteLine($"Valeur : {val}");
}
// ...

Avec cette version de la méthode Afficher(), le code client déterminera les versions de Afficher() qui seront générées sur la base des types utilisés en pratique. Le compilateur générera une méthode distincte par type (ou combinaison de types) utilisé(s). Pour Afficher<T>(T), qui se limite à écrire l'instance de T à la console une fois celle-ci transformée en chaîne de caractères, le code fonctionnera essentiellement à tout coup car tous les types .NET se convertissent en string.

Note importante : la généricité avec C# est incomplète, et se limite aux « types références » (aux types dérivant directement ou indirectement de la classe Object). C# valide statiquement ce qui est applicable à un certain type T dans une méthode générique; ce qui fait que Afficher<T>() fonctionnera pour tous les types est que, dans ce langage :

En pratique, écrire du code générique sur la base de règles plus contraignantes que celles applicables à la classe Object demande un peu plus d'efforts.

Quelques exemples :

Afficher(3); // T est int
Afficher(3.5); // T est double
Afficher("J'aime mon prof"); // T est string

La généricité s'applique autant aux types qu'aux méthodes. Ainsi, il est possible de programmer une classe ListeEntiers capable d'organiser une liste chaînée de noeuds contenant des int, mais il est mieux encore (car plus polyvalent) de programmer une classe Liste<T> capable d'organiser une liste chaînée de noeuds contenant des T. Ceci permet d'entreposer des éléments de type T, dicté par le code client; ce faisant, nous écrivons une seule classe générique et le compilateur en génère tous les cas particuliers pour nous, en fonction des besoins.

Comparez les deux versions, ci-dessous :

Notez que les classes ListeEntiers et Liste<T> ci-dessous ne sont pas conformes aux interfaces IEnumerable<int> ou IEnumerable<T>... mais elles en sont tout près.

Pour des exemples conformes à ces interfaces, voir ceci opur ListeEntiers et ceci pour Liste<T>.

Classe ListeEntiers RemarquesClasse Liste<T>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ExerciceListeEntiers
{
   class ListeEntiers
   {

La seule différence entre les deux classes jusqu'ici est dans le nom (plus spécifique à gauche, plus générique à droite) et dans le marqueur (à droite) du fait que la classe Liste est générique sur la base d'un type qui y sera nommé T.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ExerciceListeGénérique
{
   class Liste<T>
   {
      class Noeud
      {
         public int Valeur { get; set; }
         public Noeud Successeur { get; set; } = null;
         public Noeud(int valeur)
         {
            Valeur = valeur;
         }
      }

Pour la représentation d'un noeud sous forme de classe interne et privée, la nuance entre les deux implémentations et que celle de gauche contient des int alors que cette de droite contient des T.

      class Noeud
      {
         public T Valeur { get; set; }
         public Noeud Successeur { get; set; } = null;
         public Noeud(T valeur)
         {
            Valeur = valeur;
         }
      }
      private Noeud Tête { get; set; } = null;
      private Noeud Dernier { get; set; } = null;
      public bool Vide => Tête == null;
      public ListeEntiers()
      {
      }

Pour tout ce qui tient aux noeuds plutôt qu'aux valeurs entreposées dans les noeuds, le code est identique de part et d'autre.

      private Noeud Tête { get; set; } = null;
      private Noeud Dernier { get; set; } = null;
      public bool Vide => Tête == null;
      public Liste()
      {
      }
      public void Ajouter(int valeur)
      {
         Noeud p = new Noeud(valeur);
         if (Vide)
         {
            Tête = p;
            Dernier = p;
         }
         else
         {
            Dernier.Successeur = p;
            Dernier = p;
         }
      }

L'ajout d'une valeur est identique de part et d'autre si on fait exception du type de la valeur ajoutée.

      public void Ajouter(T valeur)
      {
         Noeud p = new Noeud(valeur);
         if (Vide)
         {
            Tête = p;
            Dernier = p;
         }
         else
         {
            Dernier.Successeur = p;
            Dernier = p;
         }
      }
      public class Énumérateur
      {
         private Noeud Courant { get; set; } = null;
         private ListeEntiers Source
         {
            get;
            set;
         }
         public Énumérateur(ListeEntiers src)
         {
            Source = src;
         }
         private bool HasNext
            => Source != null &&
                  !Source.Vide &&
                  (Courant == null || Courant.Successeur != null);
         public bool MoveNext()
         {
            if (!HasNext)
               return false;
            if (Courant == null)
               Courant = Source.Tête;
            else
               Courant = Courant.Successeur;
            return true;
         }
         public int Valeur => Courant.Valeur;
      }
      public Énumérateur GetÉnumérateur()
      {
         return new Énumérateur(this);
      }
   }
}

Les énumérateurs sont eux aussi identiques au type de liste et de valeur près.

      public class Énumérateur
      {
         private Noeud Courant { get; set; } = null;
         private Liste<T> Source
         {
            get;
            set;
         }
         public Énumérateur(Liste<T> src)
         {
            Source = src;
         }
         private bool HasNext
            => Source != null &&
                  !Source.Vide &&
                  (Courant == null || Courant.Successeur != null);
         public bool MoveNext()
         {
            if (!HasNext)
               return false;
            if (Courant == null)
               Courant = Source.Tête;
            else
               Courant = Courant.Successeur;
            return true;
         }
         public T Valeur => Courant.Valeur;
      }
      public Énumérateur GetÉnumérateur()
      {
         return new Énumérateur(this);
      }
   }
}

Considérant que l'effort d'écriture est à peu près le même d'un côté comme de l'autre, pourquoi se priverait-on d'écrire des types et des fonctions génériques, résolvant d'un seul trait plusieurs problèmes plutôt qu'un seul? Cela dit, la situation n'est pas toujours aussi simple ou évidente que ce que nous avons présenté jusqu'ici...

Voilà pour un (très) bref survol du sujet.

Lectures complémentaires

Quelques liens pour en savoir plus.

L'implémentation des méthodes anonymes en C#, texte portant plus sur les délégués que sur les λ mais les implémentations sont connexes, par Raymond Chen en 2006 :

Pour la perspective du langage C++ sur la généricité, voir :

Texte de 2015 par Eric Lippert sur l'interaction entre le transtypage et le code générique avec C# : http://ericlippert.com/2015/10/14/casts-and-type-parameters-do-not-mix/


Valid XHTML 1.0 Transitional

CSS Valide !