C# – Indexeurs (propriétés indexées)

Si vous ne connaissez pas les propriétés en C#, mieux vaut lire Proprietes-Methodes.html avant d'aborder le présent article.

Il est difficile de réaliser une encapsulation correcte en C# étant donné que tous les objets y sont manipulés de manière indirecte, à travers des références. Ce problème est exacerbé dans le cas d'objets référant à des collections comme des tableaux ou des List<T>, comme le montre l'exemple suivant, où quelqu'un pense (naïvement) avoir construit une classe CodeSecret cachant, dans un int[], un code secret difficile à découvrir :

using System;
namespace z
{
   class CodeSecret
   {
      public const int MIN = 1,
                       MAX = 100;
      private int [] Valeurs{ get; }
      public int NbEssais { get; private set; }
      public CodeSecret(int [] valeurs)
      {
         Random r = new Random();
         Valeurs = valeurs;
         for(int i = 0; i < Valeurs.Length; ++i)
         {
            Valeurs[i] = r.Next(MIN, MAX + 1);
         }
         NbEssais = 0;
      }
      public bool Essayer(int [] tentative)
      {
         ++NbEssais;
         if (tentative.Length != Valeurs.Length)
            return false;
         for(int i = 0; i != Valeurs.Length; ++i)
            if(tentative[i] != Valeurs[i])
               return false;
         return true;
      }
   }
   class Program
   {
      static void Main()
      {
         const int TAILLE_TEST = 5;
         int [] valeurs = new int[TAILLE_TEST];
         for(int i = 0; i != valeurs.Length; ++i)
            valeurs[i] = i + 1;
         CodeSecret codeSecret = new CodeSecret(valeurs);
         //
         // ICI : peut-on briser le code secret en insérant une ou deux lignes?
         //
         int [] essai = new int[valeurs.Length];
         do
         {
            Console.WriteLine($"Entrez {essai.Length} entiers, un par ligne");
            for(int i = 0; i != essai.Length; ++i)
               essai[i] = int.Parse(Console.ReadLine());
         }
         while(!codeSecret.Essayer(essai));
         if (codeSecret.NbEssais == 1)
            Console.WriteLine("Stupéfiant, vous l'avez trouvé du premier coup!");
         else
            Console.WriteLine($"Bravo, vous l'avez trouvé en {codeSecret.NbEssais} coups");
      }
   }
}

Ici, le fait que valeurs dans Main et Valeurs dans codeSecret réfèrent tous deux au même objet a pour conséquence qu'il suffise d'ajouter, à la place du commentaire débutant par ICI dans Main, quelque chose comme :

for(int i = 0; i != valeurs.Length; ++i)
   valeurs[i] = 3;

... pour tricher et mettre dans le tableau référé par codeSecret.Valeurs des valeurs qui ne sont pas secrètes du tout.

Il est difficile de se prémunir correctement contre de telles situations. Au minimum, il faut procéder à quelques ajustements :

Il faut tout d'abord faire en sorte que l'objet (l'instance de CodeSecret) ne tienne pas de référence sur un tableau provenant de l'extérieur, mais crée plutôt son propre tableau interne dans lequel elle copiera les valeurs du tableau original. Ceci évite le partage du tableau auquel les deux réfèreraient  alors, et règle une partie du problème.

De toute manière, il n'était pas utile de passer le tableau valeurs en paramètre à CodeSecret, car les valeurs de ce tableau n'étaient jamais utilisées; seule la taille de ce tableau était pertinente au problème. Nous avons donc ici deux améliorations pour le prix d'une seule

using System;
namespace z
{
   class CodeSecret
   {
      public const int MIN = 1,
                       MAX = 100;
      private int [] Valeurs{ get; }
      public int NbEssais { get; private set; }
      // public CodeSecret(int [] valeurs)
      // {
      //    Random r = new Random();
      //    Valeurs = valeurs;
      //    for(int i = 0; i < Valeurs.Length; ++i)
      //    {
      //       Valeurs[i] = r.Next(MIN, MAX + 1);
      //    }
      //    NbEssais = 0;
      // }
      public CodeSecret(int nbValeurs)
      {
         Random r = new Random();
         Valeurs = new int[nbValeurs];
         for(int i = 0; i < Valeurs.Length; ++i)
         {
            Valeurs[i] = r.Next(MIN, MAX + 1);
         }
         NbEssais = 0;
      }
      // etc.

Accès par voie de méthode aux éléments d'une collection encapsulée

Notez que cette section vise des étudiant(e)s en début de formation, et escamote des tas de techniques pertinentes tout en exposant sous forme de méthodes des services qu'il vaudrait mieux implémenter sous d'autres formes. J'implore votre tolérance.

Quittons le cas du CodeSecret et imaginons une classe TableauEntiers faite maison et offrant quelques services au code client : initialiser les éléments du tableau à une valeur autre que zéro, par exemple; trier les éléments du tableau en ordre croissant ou en ordre décroissant de valeur; inverser l'ordre des éléments, etc.

Une implémentation simpliste pourrait être la suivante :

Présumons tout d'abord une classe static nommée Algos logeant des algorithmes d'ordre général, et une méthode de classe Permuter capable de permuter deux entiers.

Ceci nous sera utile pour inverser l'ordre des éléments du tableau; de plus, il n'y a pas de raison pertinente pour en faire un service de TableauEntiers, son rôle étant bien plus général.

using System;
static class Algos
{
   public static void Permuter(ref int a, ref int b)
   {
      int temp = a;
      a = b;
      b = temp;
   }
}

La classe TableauEntiers en soi n'est pas particulièrement difficile à rédiger :

  • La propriété Valeurs réfère au tableau d'entiers sous-jacent, et est strictement privée (une instance de TableauEntiers n'expose cette référence au code client d'aucune manière)
  • Les services de tri sont implémentés à l'aide d'Array.Sort, en utilisant un comparateur lorsque cela s'avère pertinent
  • L'algorithme d'inversion de l'ordre des éléments est fait en permutant les éléments aux indices opposés dans le tableau sous-jacent (premier avec dernier, deuxième avec avant-dernier, etc.) en prenant soin d'éviter que les indices ne se croisent au centre

Un problème demeure entier cependant : comment devrions-nous permettre l'accès aux éléments d'une instance de TableauEntiers? Après tout, pour le moment, nous avons un tableau partiellement immuable, donc non-modifiable une fois construit (outre pour ce qui a trait à l'ordre de ses éléments).

Il serait embêtant, avec la classe TableauEntiers que nous avons présentement, d'écrire quelque chose d'aussi simple qu'une fonction capable d'afficher les éléments du tableau à la console.

class TableauEntiers
{
   private int [] Valeurs { get; }
   public int Taille { get => Valeurs.Length; }
   private static int[] CréerTableau(int taille, int valeurInitiale)
   {
      int [] tab = new int[taille];
      for (int i = 0; i != tab.Length; ++i)
         tab[i] = valeurInitiale;
      return tab;
   }
   public TableauEntiers(int taille, int valeurInitiale)
   {
      Valeurs = CréerTableau(taille, valeurInitiale);
   }
   public TableauEntiers(int taille)
   {
      Valeurs = CréerTableau(taille, 0);
   }
   public void TrierCroissant()
   {
      Array.Sort(Valeurs);
   }
   private static int CritèreDécroissant(int a, int b) => b - a;
   public void TrierDécroissant()
   {
      Array.Sort(Valeurs, CritèreDécroissant);
   }
   public void InverserÉléments()
   {
      for (int i = 0; i < Taille / 2; ++i)
         Algos.Permuter(ref Valeurs[i], ref Valeurs[Taille - i - 1]);
   }
   // ...

Si nous souhaitons permettre l'accès en lecture et en écriture à un élément, une manière de procéder est d'exposer une paire de méthodes en ce sens.

À droite, vous trouverez donc une méthode GetValeur, retournant une copie de la valeur à un indice donné, de même qu'une méthode SetValeur, insérant une valeur spécifiée par la fonction appelante à un indice donné dans le tableau.

Si nous souhaitions ne permettre que la consultation, sans permettre la modification, nous pourrions qualifier SetValeur de private... ou simplement ne pas l'implémenter.

   // ...
   public int GetValeur(int indice) => Valeurs[indice];
   public void SetValeur(int indice, int valeur)
   {
      Valeurs[indice] = valeur;
   }
}

Équipé de cette paire de méthodes, nous sommes en mesure d'accéder aux éléments d'un TableauEntiers en lecture, comme le fait Afficher(TableauEntiers) à droite, de même qu'en écriture, comme le démontre le programme principal (Main).

Clairement, cette approche fonctionne. Dans certains langages, c'est d'ailleurs essentiellement la meilleure solution disponible.

class Program
{
   static void Afficher(TableauEntiers tab)
   {
      for (int i = 0; i != tab.Taille; ++i)
         Console.Write($"{tab.GetValeur(i)} ");
      Console.WriteLine();
   }
   static void Main()
   {
      TableauEntiers tab = new TableauEntiers(10, -1);
      Afficher(tab);
      Random r = new Random();
      for (int i = 0; i != tab.Taille; ++i)
         tab.SetValeur(i, r.Next(0, 100));
      Afficher(tab);
      tab.InverserÉléments();
      Afficher(tab);
      tab.TrierCroissant();
      Afficher(tab);
      tab.TrierDécroissant();
      Afficher(tab);
   }
}

On peut toutefois se questionner sur l'élégance ce cette solution. Après tout, comparez le code permettant d'accéder à l'élément situé à l'indice 3 dans un int[] :

int [] tab = new int[10];
// ...
tab[3] = -1; // écriture
// ...
int n = tab[3]; // lecture
// ...

... avec celui requis pour accéder à l'élément situé à l'indice 3 dans un TableauEntiers :

TableauEntiers tab = new TableauEntiers(10);
// ...
tab.SetValeur(3, -1); // écriture
// ...
int n = tab.GetValeur(3); // lecture
// ...

Nous conviendrons que ce n'est pas tout à fait homogène

Indexeurs (propriétés indexées)

En pratique, pour en arriver à une solution plus homogène que celle proposée ci-dessus avec les méthodes GetValeur et SetValeur, ce que nous aimerions faire est exposer un opérateur [] pour TableauEntiers, un peu comme on surcharge d'autres opérateurs. Cela ne fonctionnera toutefois pas; C# n'offre pas un traitement aussi complet à la surcharge d'opérateurs que ne le font d'autres langages comme C++ par exemple.

Heureusement, C# offre une syntaxe spéciale pour l'utilisation de [] sur un objet avec les indexeurs, ou propriétés indexées. Cette syntaxe peut surprendre au premier abord, car elle « détonne » un peu dans le décor, mais on finit par s'y habituer

Un indexeur en C# est une notation hybride entre celle de la propriété (il y a une partie get et une partie set, respectivement pour les accès en lecture et en écriture, incluant le paramètre « magique » value dans le cas du set) et de la méthode, au sens où elle s'applique à l'objet référé (this) plutôt que de se présenter comme un état de cet objet. Notez aussi que les paramètres (ici, l'indice) d'un indexeur sont placés entre crochets plutôt qu'entre parenthèses.

Exprimé plus simplement, là où la propriété Valeurs d'un TableauEntiers s'accéderait comme suit (pour qui y a accès) :

static void AfficherPremierÉlément(TableauEntiers tab)
{
   Console.Write(tab.Valeurs[0]);
}

... l'indexeur d'un TableauEntiers s'utilise quant à lui comme suit :

static void AfficherPremierÉlément(TableauEntiers tab)
{
   Console.Write(tab[0]);
}
class TableauEntiers
{
   private int [] Valeurs { get; }
   public int Taille { get => Valeurs.Length; }
   // ...
   public int GetValeur(int indice) => Valeurs[indice];
   public void SetValeur(int indice, int valeur)
   {
      Valeurs[indice] = valeur;
   }
   public int this[int indice]
   {
      get => Valeurs[indice];
      set
      {
         Valeurs[indice] = value;
      }
   }
}

Le code client proposé plus haut, mais adapté pour profiter de l'indexeur, devient alors :

// ...
class Program
{
   static void Afficher(TableauEntiers tab)
   {
      for (int i = 0; i != tab.Taille; ++i)
         Console.Write($"{tab[i]} "); // <-- ICI
      Console.WriteLine();
   }
   static void Main()
   {
      TableauEntiers tab = new TableauEntiers(10, -1);
      Afficher(tab);
      Random r = new Random();
      for (int i = 0; i != tab.Taille; ++i)
         tab[i] = r.Next(0, 100); // <-- ICI
      Afficher(tab);
      tab.InverserÉléments();
      Afficher(tab);
      tab.TrierCroissant();
      Afficher(tab);
      tab.TrierDécroissant();
      Afficher(tab);
   }
}

Indexeurs à plusieurs indices

Un indexeur en C# a toujours au moins un indice, mais il est possible d'écrire des indexeurs à plusieurs indices. Par exemple, toute instance de la classe Matrice3x3 ci-dessous est construite à partir d'un double[,] de taille , et est immuable (donc non-modifiable une fois construite). Il est toutefois possible d'accéder aux éléments en utilisant la notation usuelle avec un tableau à deux dimensions (ici, ligne puis colonne) :

using System;
class Matrice3x3
{
   private double [,] Valeurs { get; }
   public Matrice3x3(double [,] src)
   {
      if (src.GetLength(0) != 3 || src.GetLength(1) != 3)
         throw new ArgumentException();
      Valeurs = new double[3, 3]
      {
         {  src[0,0], src[0,1], src[0,2] },
         {  src[1,0], src[1,1], src[1,2] },
         {  src[2,0], src[2,1], src[2,2] }
      };
   }
   public double this[int ligne, int colonne]
   {
      get => Valeurs[ligne, colonne];
      private set
      {
         Valeurs[ligne, colonne] = value;
      }
   }
   // ...
}

Indexeurs avec indices non-entiers

Les indexeurs permettent aussi de proposer des métaphores d'accès autres que celle, positionnelle, d'un indice entier. Imaginons par exemple un Registre de taille fixée à la construction, contenant des paires {nom,valeur} où le nom est une string et la valeur est un Jour.

Pour les besoins de l'exemple, donc, les valeurs du Registre seront des instances de Jour, un type énuméré. Nous aurions pu utiliser des types à la fois plus simples (des int, par exemple) ou plus complexes (des classes), mais je souhaitais un exemple de petite taille pour aller à l'essentiel.

using System;
enum Jour { Dimanche, Lundi, Mardi, Mercredi, Jeudi, Vendredi, Samedi }

Un Registre, comme annoncé plus haut, contiendra un tableau Éléments d'instances du type Entrée, où chaque Entrée fera correspondre à un nom (une string) une valeur (un Jour).

La capacité de tableau Éléments sera fixée à la construction, mais le nombre d'éléments réellement insérés (NbÉléments) démarrera à zéro et croîtra à chaque ajout au registre. Tenter d'insérer trop d'éléments mènera à une levée d'exception.

Pour fins de simplicité, j'ai inséré les éléments dans Éléments en ordre d'insertion, et j'ai fait une fouille linéaire dans l'indexeur pour retrouver la valeur associée à un certain nom. Notez que nous aurions pu être plus sophistiqués, et trier les éléments en ordre croissant de nom par exemple pour permettre une fouille dichotomique dans l'indexeur.

Notre indexeur dans ce cas-ci prend en paramètre une string (pas un int), et fouille dans Éléments pour trouver la valeur (le Jour) correspondant à cette string. Si aucune Entrée dans Éléments ne correspond à ce qui est demandé, une exception est levée.

class RegistrePleinException : Exception { }
class Registre
{
   private class Entrée
   {
      public string Nom { get; }
      public Jour Valeur { get; }
      public Entrée(string nom, Jour valeur)
      {
         Nom = nom;
         Valeur = valeur;
      }
   }
   private Entrée[] Éléments { get; }
   private int NbÉléments { get; set; }
   public Registre(int taille)
   {
      Éléments = new Entrée[taille];
      NbÉléments = 0;
   }
   public void Ajouter(string nom, Jour valeur)
   {
      if (NbÉléments == Éléments.Length)
         throw new RegistrePleinException();
      Éléments[NbÉléments] = new Entrée(nom, valeur);
      ++NbÉléments;
   }
   public Jour this[string nom]
   {
      get
      {
         foreach (Entrée e in Éléments)
            if (e.Nom == nom)
               return e.Valeur;
         throw new ArgumentException();
      }
   }
}

Pour illustrer l'utilisation (et l'utilité) de ce petit type, un programme est proposé à droite. Ce programme insère des corresponsances français / anglais pour les jours de la semaine, puis affiche ces correspondances en ordre croissant des noms anglais de ces jours.

À l'affichage, nous obtiendrons :

Friday correspond à Vendredi
Monday correspond à Lundi
Saturday correspond à Samedi
Sunday correspond à Dimanche
Thursday correspond à Jeudi
Tuesday correspond à Mardi
Wednesday correspond à Mercredi
class Program
{
   static void Main()
   {
      string[] names = new[] // l'ordre des valeurs est important ici
      {
         "Sunday",
         "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
         "Saturday"
      };
      Registre days = new Registre(names.Length);
      for (Jour j = Jour.Dimanche; j <= Jour.Samedi; ++j)
         days.Ajouter(names[(int)j], j);
      Array.Sort(names); // 
      // Traduire de l'anglais au français, en ordre croissant de nom
      // de jour en anglais
      foreach (string name in names)
         Console.WriteLine($"{name} correspond à {days[name]}");
   }
}

Lectures complémentaires

Quelques liens pour en savoir plus.


Valid XHTML 1.0 Transitional

CSS Valide !