Accueil > Articles > KeyNotFoundException et la collection Dictionary

KeyNotFoundException et la collection Dictionary

La classe Dictionary (version générique de la HashTable) est sans doute l’une des collections du .NET Framework les plus utilisées, après la List<>. Elle permet de retrouver très rapidement un élément par l’intermédiaire de sa clé. C’est une classe très performante qui souffre cependant d’un petit défaut à mon avis : sa gestion d’erreur en cas d’absence d’une clé. Nous allons donc essayer d’analyser plusieurs solutions pour résoudre ce problème.

Commençons par un petit exemple : on va créer un dictionnaire très simple pour stocker des données provenant d’un fichier XML. On va donc prendre une clé de type String pour stocker le nom du champ, et un élément de type String aussi (type par défaut récupéré d’un XML) pour y stocker la valeur correspondante. On assurera éventuellement la conversion de données (numérique, date, etc.) si besoin par la suite.

Dictionary<String, String> data = new Dictionary<String, String>();

data.Add("Reference", "1542SD3F");
data.Add("Date", "2009-08-18");
data.Add("Quantite", "2");
data.Add("Prix", "15.2");

 

Pour accéder aux données on écrira par exemple :
String reference = data["Reference"];

Cependant, si jamais on écrit :
String reference = data["Nom"];

On obtient une exception de type KeyNotFoundException, car la clé « Nom » n’existe pas dans notre dictionnaire. Le message complet de l’exception est le suivant : "The given key was not present in the dictionary" ("La clé donnée était absente du dictionnaire." en français). Cela ne pose pas de problème particulier avec l’exemple ci-dessus, étant donné que l’on sait immédiatement quelle est la clé en question. Cependant si l’on a plusieurs lignes similaires à la suite, ça peut vite devenir gênant :

try
{
    string reference = data["Reference"];
    string nom = data["Nom"];
    string date = data["Date"];
    string quantite = data["Quantite"];
    string prix = data["Prix"];
}
catch (KeyNotFoundException ex)
{
    Console.WriteLine(ex.Message);
}

 

La seule chose que l’on obtiendra dans le message d’erreur, c’est « The given key was not present in the dictionary », ce qui n’aide pas beaucoup pour identifier la clé qui pose problème. Pour remédier à cela, on peut limiter le nombre d’accès au dictionnaire à un ou deux appels dans un même bloc try..catch et faire un message d’erreur personnalisé, mais ça devient vite indigeste et difficile à maintenir. On va donc essayer de trouver une solution plus élégante à ce problème.

 

 

La classe KeyNotFoundException

 

Ce n’est pas forcément la première chose à laquelle on pense, mais l’exception KeyNotFoundException dérivant de la classe de base System.Exception, on peut supposer qu’elle dispose de ses propres membres permettant d’obtenir plus d’informations sur l’erreur. Il n’y a malheureusement rien de spécial, mis à part la propriété « Data » de type IDictionnary présent dans la classe de base (c.f. la liste des membres sur MSDN). Celle-ci servant normalement à stocker des paires de clé/valeurs fournissant des informations supplémentaires sur l’exception. On aurait alors un code similaire à celui-ci pour le traitement de l’exception :

catch(KeyNotFoundException ex)
{
    foreach (KeyValuePair<string, string> item in ex.Data)
        Console.WriteLine("Key not found (Key = {0} ; value = {1})",
            item.Key, item.Value);
}

 

Cependant cela ne fonctionne pas. La liste « Data » reste désespérément vide lorsque l’exception est déclenchée. L’explication se trouve dans la classe Dictionary<>, dans le code appelé au moment ou l’on tente de récupérer la valeur. Pour bien comprendre ce problème, il faut savoir que l’accès à un élément d’une collection en utilisant la syntaxe myDictionary[myKey], avec la clé ou l’index entre crochets, fait appel à un membre bien spécifique de la classe : l’indexeur. Celui-ci est très similaire à une propriété et possède une déclaration du type public TValue this[TKey key] { get; set; }

Avec Reflector, on peut facilement observer le code de l’indexeur du Dictionary<> :

// Dans System.Collections.Generic.Dictionary<TKey,TValue>
public TValue this[TKey key]
{
    get
    {
        int index = this.FindEntry(key);
        if (index >= 0)
        {
            return this.entries[index].value;
        }
        ThrowHelper.ThrowKeyNotFoundException();
        return default(TValue);
    }
    set
    {
        this.Insert(key, value, false);
    }
}

// Dans System.ThrowHelper
internal static void ThrowKeyNotFoundException()
{
    throw new KeyNotFoundException();
}

On voit bien, en suivant le code de l’accesseur get, que l’exception KeyNotFoundException est dans tous les cas créée et initialisée sans paramètres. Sa propriété « Data » sera donc toujours vide.

En prenant un peu de recul, de toute façon cette propriété n’est vraiment pas idéale : son type de données (IDictionary) parait inadapté là où on aurait simplement besoin d’un objet unique de type TKey retournant la clé ayant provoquée l’exception. Le fait de renvoyer une collection oblige à coder un foreach, et à gérer les éventuelles exceptions liées à ce type d’objet.

 

Etude des différentes solutions

 

1ère solution : Garder une trace de la dernière clé utilisée

Sans doute la solution la plus facile, mais la moins élégante aussi. Elle consiste tout simplement à utiliser une variable de même type que la clé afin de mémoriser celle-ci. Il suffit ensuite de faire appel à cette variable pour récupérer les informations utiles lors du traitement de l’exception :

string currentKey = String.Empty;

try
{
    currentKey = "Reference";
    string reference = data[currentKey];
    currentKey = "Nom";
    string nom = data[currentKey];
}
catch (KeyNotFoundException ex)
{
    Console.WriteLine(String.Format("Error with key {0} : {1}",
                                    currentKey, ex.Message));
}

 

Visuellement ce n’est pas très agréable à lire car le nombre de ligne de code est doublé. Il est facile aussi d’oublier de modifier correctement chaque ligne lors de l’écriture, le copier-coller étant souvent utilisé dans ce genre de cas.

 

2ème solution : Utiliser la fonction ContainsKey

Au lieu d’essayer de mettre au point une solution une fois que l’erreur a été déclenchée, on peut aussi envisager le problème sous un autre angle et tenter d’empêcher la levée de l’exception KeyNotFoundException qui peut survenir lors de l’utilisation de l’indexeur. Pour cela nous avons deux méthodes à notre disposition : ContainsKey et TryGetValue. Elles retournent toutes les deux un booléen pour indiquer si la clé existe dans la collection ou non. La seule différence est que la seconde méthode renvoie aussi la valeur correspondante si elle existe. Voici quelques exemples d’écriture :

string reference = String.Empty;

// ContainsKey #1
if (data.ContainsKey("Reference"))
    reference = data["Reference"];

// ContainsKey #2
reference = data.ContainsKey("Reference") ? data["Reference"] : String.Empty;

// TryGetValue
data.TryGetValue("Reference", out reference);

 

L’indexeur effectue déjà en interne un test sur la clé (la méthode FindEntry), ce qui lui permet de savoir s’il doit lever ou non l’exception KeyNotFoundException. Utiliser ContainsKey puis l’indexeur revient à effectuer deux fois ce test, ce qui n’est pas très optimisé. Pour des raisons de performances, il vaut donc mieux privilégier la méthode TryGetValue afin de n’avoir plus qu’un seul test effectué. De plus, cela évite de répéter inutilement la clé dans le code.

 

3ème solution : Encapsuler l’accès à la collection dans une fonction

Si l’on souhaite encadrer chaque appel à la collection avec un bloc try..catch sans allonger considérablement le code, une solution consiste à créer notre propre fonction d’accès à la collection :

private string GetData(string key)
{
    string result = String.Empty;

    try
    {
        data.TryGetValue(key, out result);
    }
    catch (Exception ex)
    {
        throw new Exception(String.Format("Error with key {0} : {1}",
                                            key, ex.Message));
    }

    return result;
}

L’utilisation de l’indexeur sera ensuite remplacée de cette façon :

try
{
    string reference = GetData("Reference");
    string nom = GetData("Nom");
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

 

Cette solution présente un autre avantage en offrant la possibilité d’écrire des surcharges pour sécuriser les conversions de données. C’est particulièrement utile dans le cas d’un dictionnaire de type string/string, les chaînes de caractères étant souvent utilisées comme type par défaut pour stocker n’importe quel type de donnée en provenance, par exemple, d’un fichier XML (date, nombre entier ou décimal, etc.).

 

4ème solution : Masquer l’indexeur

Il reste une dernière solution : modifier ce qui nous pose problème ici, à savoir l’indexeur de la classe générique Dictionary. Nous avons déjà vu un peu plus haut son code, qui est relativement simple. Le but est donc de remplacer l’accesseur get de celui-ci pour renvoyer une exception contenant la valeur de la clé, si celle-ci est absente de la collection. Pour cela nous n’avons pas vraiment le choix, il va falloir dériver la classe générique Dictionary et substituer son indexeur, ce qui nous donne le code ci-dessous :

private class MyDictionary<TKey, TValue> : Dictionary<TKey, TValue>
{
    public override TValue this[TKey key]
    {
        get
        {
            if (base.ContainsKey(key))
                return base[key];
            else
                throw new KeyNotFoundException(String.Format(
                    "Unable to find the key '{0}'", key.ToString() ));
        }
        set
        {
            base[key] = value;
        }
    }
}

 

Malheureusement ce code ne peut pas être compilé, tout simplement parce que l’indexeur de la classe générique Dictionary n’est pas substituable. Hormis la création d’une nouvelle collection à partir des classes de base (pénibles souvenirs du Framework 1.1 …), la seule solution qu’il nous reste est donc de masquer cet indexeur. Avant d’aller plus loin, je vous invite à lire si besoin l’article précédent sur le masquage en C# pour mieux comprendre ce qui va suivre.

On modifie donc la déclaration de notre indexeur de cette façon :

public override new TValue this[TKey key]

Puis on lance un petit test :

try
{
    MyDictionary<String, String> data = new MyDictionary<String, String>();
    string reference = data["Reference"];
}
catch (KeyNotFoundException ex)
{
    Console.WriteLine(ex.Message);
}

Résultat :

Unable to find the key 'Reference'

 

Cette fois cela fonctionne. En reprenant les premiers exemples, il suffit juste de déclarer notre variable data de type MyDictionary<String, String> et de laisser les appels à l’indexeur dans un unique bloc try..catch. Cette fois, l’exception KeyNotFoundException contiendra un message personnalisé avec le nom de la clé absente.

Cependant, comme souvent avec le masquage, il est difficile d’éviter ses effets secondaires indésirables. Par exemple, si l’on décide de caster notre variable data de type MyDictionary en Dictionary avant d’appeler l’indexeur, l’exception renvoyée en cas d’erreur correspondra à celle de la classe de base Dictionary (sans précision de la clé) et non à notre nouveau message :

try
{
    MyDictionary<String, String> data = new MyDictionary<String, String>();
    Dictionary<String, String> data2 = data;
    string reference = data2["Reference"];
}
catch (KeyNotFoundException ex)
{
    Console.WriteLine(ex.Message);
}

Résultat :

The given key was not present in the dictionary

 

Retour à la case départ. Heureusement, si l’on regarde un peu plus en détails la classe Dictionary, on se rend compte qu’elle implémente plusieurs interfaces, dont IDictionary<TKey, TValue>. Celle-ci contient le prototype de l’indexeur qui nous intéresse : TValue this[TKey key] { get; set; }. On peut donc dans notre exemple caster notre objet MyDictionary en IDictionary et continuer d’utiliser l’indexeur. Mais cela aboutira au même résultat qu’un cast en Dictionary. Pour que notre masquage fonctionne à nouveau, il faut aussi implémenter cette interface dans notre classe dérivée :

private class MyDictionary<TKey, TValue> : IDictionary<TKey, TValue>

 

Exposer dans une classe une collection par l’intermédiaire de l’interface IDictionary n’est pas vraiment quelque chose de courant. Cependant les quelques méthodes présentes dans cette interface sont largement suffisantes pour la plupart des cas d’utilisation de la collection. Cela permet de s’assurer aussi qu’il n’y aura pas de problème de suppression du masque si jamais notre collection est castée, tout en offrant la possibilité de ne pas rendre publique la classe dérivée (qui sert uniquement à remplacer l’indexeur de base) :

private MyDictionary<String, String> data;
public IDictionary<String, String> Data
{
    get { return data as IDictionary<String, String>; }
}

 

Conclusion

 

Beaucoup de solutions sont donc possibles ici. Chacune d’elles a ses avantages et ses inconvénients suivant la situation. Il est quelque peu surprenant que Microsoft n’ai pas pensé à préciser la clé utilisée en cas d’erreur dans l’indexeur. Sans doute parce que cette clé peut être de n’importe quel type, mais dans ce cas un simple key.ToString() aurait suffit à répondre à la majorité des cas. Peut-être pour éviter des problèmes de sécurité ?

N’oublions pas toutefois que ce type de collection a subit des évolutions importantes à chaque nouvelle version du .NET Framework : D’abord présentée sous la forme de la classe Hashtable, puis sous le générique Dictionary afin d’offrir un dictionnaire fortement typé sans avoir besoin de dériver la classe de base, pour finir par l’ajout de la collection HashSet du Framework 3.5 qui a permis de combiner la simplicité d’une liste avec l’efficacité d’un dictionnaire, ou en d’autres termes, de créer un Dictionary avec uniquement un objet clé et non une paire clé/valeur. Cette collection sera donc peut-être encore améliorée dans les prochaines versions.

  1. Pas encore de commentaire
  1. Pas encore de trackbacks