Les dictionnaires en C# sont très pratiques mais comment gérer des clés composées ?
De temps à autre, j'apprécie de redécouvrir les fondamentaux de C#, ceux qui ont été oubliés, négligés ou jamais appris, car les nombreuses évolutions de la syntaxe et des fonctionnalités du langage peuvent nous faire manquer des éléments. Bien qu'il ne soit pas nécessaire de revoir toutes les spécifications du langage et du framework .NET (tant l'ancien que le Core), il est utile de s'attarder sur certains aspects pratiques, comme l'utilisation des clés composées dans les dictionnaires.
Les dictionnaires sont des structures de données spécialisées qui associent deux éléments : une clé et une valeur correspondante. Une fois une paire clé-valeur ajoutée au dictionnaire, celui-ci peut retrouver efficacement la valeur associée à n'importe quelle clé. Ils sont utiles dans diverses situations, telles que la création de systèmes de mise en cache de données.
Un dictionnaire se créé à 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 : il ne faut pas confondre une unique clé avec un objet clé unique ! En effet, bien que le dictionnaire n'accepte qu'un seul objet comme clé, il est possible que cet objet soit aussi complexe que nécessaire. Ainsi, il peut s'agir d'instances d'une classe spécialement conçue pour cela, capable de gérer une clé composite.
Vous allez me dire que ce n'est pas bien compliqué, et vous n'aurez qu'à moitié raison...
Créer une classe avec deux propriétés n'est pas particulièrement difficile. Considérons, par exemple, un dictionnaire qui associe des utilisateurs à des ressources. Supposons que l'utilisateur soit identifié par deux éléments : son nom et une clé numérique (par exemple, le hash d'un mot de passe) et, pour simplifier, que la ressource correspondante soit simplement une 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 !