Dot.Blog

C#, XAML, WinUI, WPF, Android, MAUI, IoT, IA, ChatGPT, Prompt Engineering

Utiliser des clés composées dans les dictionnaires

Les dictionnaires en C# sont très pratiques mais comment gérer des clés composées ?

De temps en temps j'aime revisiter les grands classiques de C#, ceux qu'on a oubliés, ceux qu'on a zappés ou qu'on n'a jamais connus parce qu'il y a tellement eu d'évolutions de la syntaxe et des features du langage qu'on peut être passé à côté de quelque chose. S'il n'est pas question de repasser toutes les spécifications du langage et du framework .NET (l'ancien comme Core), certains aspects par leur côté pratique méritent quelques mots. C'est le cas des clés composées dans les Dictionnaires.

Les dictionnaires sont des listes spécialisées permettant de relier deux objets, le premier étant considéré comme la clé, le second comme la valeur. Une fois clé et valeur associées au sein d'une entrée du dictionnaire ce dernier est capable de retourner rapidement la valeur de toute clé. Les dictionnaires peuvent être utilisés en de nombreuses circonstances, comme la conception de caches de données par exemple.

Un dictionnaire se créée à partir de la classe générique Dictionnary<Key,Value>. Cette classe provient du namespace System.Collection. Comme on le remarque si la clé peut être de tout type elle reste monolithique, pas de clés composées donc, et encore moins de classes telles Dictionnary<Key1,Key2,Value> ou Dictionnary<Key1,Key2,Key3,Value> etc...

Or, il est assez fréquent qu'une clé soit composée (multi-part key ou composed key). Comment utiliser les dictionnaires génériques dans un tel cas ?

La réponse est simple : ne confondons pas une seule clé et un seul objet objet clé ! En effet, si le dictionnaire n'accepte qu'un seul objet pour la partie clé, rien n'interdit que cet objet soit aussi complexe qu'on le désire... Il peut donc s'agir d'instances d'une classe créée pour l'occasion, classe capable de maintenir une clé composée.

Vous allez me dire que ce n'est pas bien compliqué, et vous n'aurez qu'à moitié raison...

Créer une classe qui contient 2 propriétés n'est effectivement pas vraiment ardu. Prenons un exemple simple d'un dictionnaire associant des ressources à des utilisateurs. Imaginons que l'utilisateur soit repéré grâce à deux informations, son nom et une clé numérique (le hash simple d'un password par ex) et imaginons, pour simplifier encore plus, que la ressource associée soit une simple chaîne de caractères.

La classe qui jouera le rôle de clé du dictionnaire peut ainsi s'écrire en une poignée de lignes :

   1:  public class LaClé
   2:  {
   3:    public string Name { get; set; }
   4:    public int PassKey {get; set; }
   5:  }

Oui, c'est vraiment simple. Mais il y a un hic !

En effet, cette classe ne gère pas l'égalité, elle n'est pas "comparable". De base, écrite comme ci-dessus, elle ne peut pas servir de clé à un dictionnaire...

Pour être utilisable dans un tel contexte il faut ajouter du code afin de gérer la comparaison entre deux instances. Il existe plusieurs façons de faire, l'une de celle que je préfère est l'implémentation de l'interface générique IEquatable<T>. On pourrait par exemple choisir une autre voie en implémentant dans la classe clé une autre classe implémentant IEqualityComparer<T>. Toutefois dans un tel cas il faudrait préciser au dictionnaire lors de sa création qu'il lui faut utiliser ce comparateur-là bien précis, cela est très pratique si on veut changer de comparateur à la volée, mais c'est un cas assez rare. En revanche si demain l'implémentation changeait dans notre logiciel et qu'une autre structure soit substituée au dictionnaire il y aurait de gros risque que l'application ne marche plus : les objets clés ne seraient toujours pas comparables deux à deux "automatiquement".

L'implémentation d'une classe utilisant IEqualityComparer<T> est ainsi une solution partielle en ce sens qu'elle réclame une action volontaire pour être active. De plus cette solution se limite aux cas où un comparateur de valeur peut être indiqué.

C'est pour cela que je vous conseille fortement d'implémenter directement dans la classe "clé" l'interface IEquatable<T>. Quelles que soient les utilisations de la classe dans votre application l'égalité fonctionnera toujours sans avoir à vous soucier de quoi que ce soit, et encore moins, et surtout, des éventuelles évolutions du code.

Le code de notre classe "clé" se transforme ainsi en quelque chose d'un peu plus volumineux mais de totalement fonctionnel :

   1:  public class ComposedKey : IEquatable<ComposedKey>
   2:          {
   3:              private string name;
   4:              public string Name
   5:              {
   6:                  get { return name; }
   7:                  set { name = value; }
   8:              }
   9:   
  10:              private int passKey;
  11:              public int PassKey
  12:              {
  13:                  get { return passKey; }
  14:                  set { passKey = value; }
  15:              }
  16:   
  17:              public ComposedKey(string name, int passKey)
  18:              {
  19:                  this.name = name;
  20:                  this.passKey = passKey;
  21:              }
  22:   
  23:              public override string ToString()
  24:              {
  25:                  return name + " " + passKey;
  26:              }
  27:   
  28:              public bool Equals(ComposedKey obj)
  29:              {
  30:                  if (ReferenceEquals(null, obj)) return false;
  31:                  if (ReferenceEquals(this, obj)) return true;
  32:                  return Equals(obj.name, name) && obj.passKey == passKey;
  33:              }
  34:   
  35:              public override bool Equals(object obj)
  36:              {
  37:                  if (ReferenceEquals(null, obj)) return false;
  38:                  if (ReferenceEquals(this, obj)) return true;
  39:                  if (obj.GetType() != typeof (ComposedKey)) return false;
  40:                  return Equals((ComposedKey) obj);
  41:              }
  42:   
  43:              public override int GetHashCode()
  44:              {
  45:                  unchecked
  46:                  {
  47:                      return ((name != null ? name.GetHashCode() : 0)*397) ^ passKey;
  48:                  }
  49:              }
  50:   
  51:              public static bool operator ==(ComposedKey left, ComposedKey right)
  52:              {
  53:                  return Equals(left, right);
  54:              }
  55:   
  56:              public static bool operator !=(ComposedKey left, ComposedKey right)
  57:              {
  58:                  return !Equals(left, right);
  59:              }  60:          }

Désormais il devient possible d'utiliser des instances de la classe ComposedKey comme clé d'un dictionnaire générique.

Dans un premier temps testons le comportement de l'égalité :

   1:  // Test of IEquatable in ComposedKey
   2:  var k1 = new ComposedKey("Olivier", 589);
   3:  var k2 = new ComposedKey("Bill", 9744);
   4:  var k3 = new ComposedKey("Olivier", 589);
   5:   
   6:  Console.WriteLine("{0} =? {1} : {2}",k1,k2,(k1==k2));
   7:  Console.WriteLine("{0} =? {1} : {2}",k1,k3,(k1==k3));
   8:  Console.WriteLine("{0} =? {1} : {2}",k2,k1,(k2==k1));
   9:  Console.WriteLine("{0} =? {1} : {2}",k2,k2,(k2==k2));
  10:  Console.WriteLine("{0} =? {1} : {2}",k2,k3,(k2==k3));

Ce code produira le résultat suivant à la console :

Olivier 589 =? Bill 9744 : False
Olivier 589 =? Olivier 589 : True
Bill 9744 =? Olivier 589 : False
Bill 9744 =? Bill 9744 : True
Bill 9744 =? Olivier 589 : False

Ces résultats sont conformes à notre attente. Nous pouvons dès lors utiliser la classe au sein d'un dictionnaire comme le montre le code suivant :

   1:  // Build a dictionnary using the composed key
   2:  var dict = new Dictionary<ComposedKey, string>()
   3:                 {
   4:                     {new ComposedKey("Olivier",145), "resource A"},
   5:                     {new ComposedKey("Yoda", 854), "resource B"},
   6:                     {new ComposedKey("Valérie", 9845), "resource C"},
   7:                     {new ComposedKey("Obiwan", 326), "resource D"},
   8:                 };
   9:   
  10:  // Find associated resources by key
  11:   
  12:  var fk1 = new ComposedKey("Yoda", 854);
  13:  var s = dict.ContainsKey(fk1) ? dict[fk1] : "No Resource Found";
  14:  // must return 'resource B'
  15:  Console.WriteLine("Key '{0}' is associated with resource '{1}'",fk1,s);
  16:   
  17:  var fk2 = new ComposedKey("Yoda", 999);
  18:  var s2 = dict.ContainsKey(fk2) ? dict[fk2] : "No Resource Found";
  19:  // must return 'No Resource Found'
  20:  Console.WriteLine("Key '{0}' is associated with resource '{1}'", fk2, s2);

Code qui produira la sortie suivante :

Key 'Yoda 854' is associated with resource 'resource B'
Key 'Yoda 999' is associated with resource 'No Resource Found'

Et voilà ...

Rien de tout cela n'est compliqué mais comme on peut le voir il y a toujours une distance de la coupe aux lèvres, et couvrir cette distance c'est justement tout le savoir-faire du développeur !


Stay Tuned !

Faites des heureux, partagez l'article !
blog comments powered by Disqus