Après plusieurs interventions pour auditer la qualité du code chez différents clients, je me suis rendu compte (et vraiment surpris) que beaucoup de développeurs ne connaissaient pas toutes les collections disponibles du .NET Framework. Certains se limitent aux listes et aux tableaux, et d’autres ne savent réellement pas où et quand utiliser une Collection<T>, une List<T> ou un simple tableau…
Cet article à pour but de présenter très rapidement les différentes collections qui existent dans le .NET Framework et dans quelles cas les utiliser. Cet article explique aussi les nouvelles interfaces qui sont apparues dans le .NET Framework 4.5 afin de palier un problème de conception initial du .NET Framework. Cette article ne parlera pas des collections qui ne sont pas typées et que je considère moi-même obsolète dans le .NET Framework ainsi que les collections spécifiques à WPF/Silverlight.
Qu’est ce qu’une collection ?
Une collection est tout simplement un ensemble d’objets de même nature qui sont regroupés et pouvant être organisé et récupérés de différentes manières. Les collections offrent des services permettant de récupérer, ajouter, modifier et supprimer des éléments,
Les interfaces du .NET Framework (jusqu’à la version 4.0).
Dans le .NET Framework il existe 5 interfaces qui représente des collections :
- IEnumerable<T> : Représente une collection que l’on peut itérer élément par élément. Cette interface contient une méthode GetEnumerator() qui crée une implémentation de IEnumerator<T> représentant « un curseur » permettant de réaliser l’itération. L’interface IEnumerator<T> contient une méthode Reset() permettant de repositionner l’itérateur (le curseur) à sa position du début. L’interface IEnumerable<T> dans le .NET Framework est très importante car c’est le point d’entrée des méthodes d’extensions Linq.
- ICollection<T> : Représente un ensemble d’objets. Cette interface offre des services permettant d’ajouter de supprimer un élément dans la collection et de savoir si un objet se trouve dans la collection. Cette interface hérite de IEnumerable<T> ce qui signifie que l’on peut itérer élément par élément les objets contenu dans la collection. L’interface ICollection<T> peut-être vue comme un sac de billes : Il n’y a pas de notion d’ordre ni de notion d’index !
- IList<T> : Représente un ensemble indexable d’objets. C’est à dire qu’il possible d’accéder à un élément à partir de son index en commençant par 0. Etant donné que la notion d’index existe dans cette interface, il est possible d’insérer ou de supprimer un élément à une certaine position. L’interface IList<T> hérite de ICollection<T> ce qui signifie qu’elle hérite de tous les services contenu dans ICollection<T> et IEnumerable<T>.
- ISet<T> : Représente un ensemble (au sens mathématique) d’objets. Cette interface hérite de ICollection<T> et contient des services sur des opérations ensembliste (union, intersection, …).
- IDictionary<TKey, TValue> : Représente une collection de valeurs de type TValue identifié par par une clé de type TKey.
Les différentes classes du .NET Framework :
- Les tableaux : C’est la collection de base qui permet de représenter un ensemble d’objets. Un tableau est de taille fixe (et donc non redimensionnable). Les tableaux implémentent partiellement l’interface IList<T> ce qui nous verrons juste après nous posera problème.
- List<T> : Comme pour un tableau, elle représente une liste d’objets non limité en taille. La classe List<T> offre aussi plein d’autres services permettant de rechercher un élément par dichotomie, de supprimer un ensemble d’éléments,… Bien évidemment une List<T> implémente en totalité l’interface IList<T>.
- Collection<T> : Représente et expose une collection d’éléments. Cette classe peut-être vue comme un tableau dynamique (on n’est pas limité au niveau taille du tableau), mais ne contient pas des services évoluées comme les List<T>. Cette classe est souvent utilisé pour exposer des collections.
- ReadOnlyCollection<T> : Représente et expose une collection d’éléments en lecture seule. Contrairement aux idées reçus cette classe ne contient pas les éléments en lecture seule mais se base sur une IList<T> existante et expose les éléments de cette dernière en lecture seule (c’est une classe qui agit comme un décorateur au sens Design Pattern).
- HashSet<T> : Représente un ensemble d’éléments au sens mathématique qui implémente l’interface ISet<T>. Cette collection ne gère pas les éléments doublons (un élément existant n’est donc pas ajouté en double dans la collection).
- Dictionary<TKey, TValue>, SortedDictionary<TKey, TValue> et SortedList<TKey, TValue> : Ces 3 classes offrent 3 implémentations différentes de IDictionary<TKey, TValue>. Ce sont donc des collections permettant d’indexer des valeurs par rapport à une clé de type TKey. Je ne rentrerai pas en détail sur les différences entre ces 3 implémentations car cela sort du cadre de cet article, mais je vous invite à consulter la documentation MSDN de Microsoft à ce sujet.
Les collections précédentes ne sont pas thread-safe, ce qui veut dire que si 2 threads tentent de modifier et de lire la collection en même temps cela peut engendrer des problèmes au niveau de la structure interne de la collection. Depuis la version 4.0 du .NET Framework il existe les collections suivantes dans le namespace System.Collections.Concurrent :
- ConcurrentDictionary<TKey, TValue> : Une autre implémentation de IDictionary<TKey, TValue> permettant de gérer des accès simultanées en lecture/écriture par 2 threads.
- ConcurrentQueue<T> : Une implémentation d’une FIFO permettant de gérer des accès simultanées en lecture/écriture par 2 threads.
- ConcurrentStack<T> : Une implémentation d’une LIFO permettant de gérer des accès simultanées en lecture/écriture par 2 threads.
Les collections « d’exécution » et les collections « pour exposition ».
Pour déterminer quelle collection utiliser il est très important dans un premier de temps de faire la différence entre les collections dites d’exécution et les collections dites pour exposition.
Une collection d’exécution est une collection que l’on va utiliser en interne dans notre code (dans un champ privé ou dans une méthode) afin de pouvoir retrouver le plus rapidement possible un élément qui se trouve dans la collection. Ces collections peuvent être vues comme des « outils » techniques. Par exemple, la collection de type List<T> est un collection d’exécution car elle offre des services avancées pour pouvoir rechercher par dichotomie un élément dans une liste triée (méthode BinarySearch()). On va trouver aussi dans la List<T> des méthodes permettant d’ajouter ou d’enlever massivement des éléments qui s’y trouve. Il en est de même pour toutes les implémentations de IDictionary<TKey, TValue> qui représentent des collections permettant d’indexer et de retrouver très rapidement des éléments.
Les collections se trouvant dans System.Collections.Concurrent sont aussi des classes d’exécution, se sont des utilitaires pour faciliter la vie des développeur lors des accès concurrents pour modifier ou récupérer des éléments de la collection. Ces collections doivent donc être utilisées en interne dans le code et non être exposées de manière publique !
Une collection pour exposition est une collection que l’on expose en publique dans une classe pour montrer un ensemble d’objets. Par exemple une classe Groupe qui contient une collection d’Enfant. Ce genre de collection expose que peu de service, le plus souvent la possibilité de consulter, ajouter et supprimer les éléments. Les collections Collection<T> et ReadOnlyCollection<T> sont des bons exemples de collection d’exposition.
Alors maintenant, qu’est ce que cela veut dire exactement ? Et bien voilà le sujet qui fâche (j’attends plein de menace de mort dans les commentaires), on ne devrait pas exposer des List<T> et des implémentations de IDictionary<TKey, TValue> en publique ! Aussi bien au niveau des propriétés que en paramètre ou en retour des méthodes ! Rappelez vous que les objets List<T> et IDictionary<TKey, TValue> ne sont pas là pour exposer des éléments, mais ce sont des utilitaires permettant de manipuler très simplement et rapidement des éléments dans une collection. Ces classes là doivent être utilisée en interne dans votre code (dans des méthodes, des champs ou en paramètres dans des méthodes privées d’une classe).
Le défaut de conception du .NET Framework v4.0 avec les IList<T> et ICollection<T>
Les tableaux et les List<T> implémentent tous les deux l’interface IList<T>. Imaginons que nous disposons d’une méthode GetFactures() qui retourne une IList<T>, nous pouvons par polymorphisme retourner soit un tableau soit une List<T>.
public class FactureServices { public IList<Facture> GetFactures() { return new List<Facture>() { new Facture() }; } }
Maintenant imaginons que nous avons le code suivant qui appelle cette méthode, récupère la IList<T> et ajoute un élément dans la liste.
public void RécupérerEtAjouterUneFacture() { IList<Facture> factures; FactureServices services; services = new FactureServices(); factures = services.GetFactures(); factures.Add(new Facture()); }
Cela fonctionnera sans problème… Maintenant imaginons que la méthode GetFactures() retourne un tableau :
public class FactureServices { public IList<Facture> GetFactures() { return new Facture[] { new Facture() }; } }
Cela n’impact en aucun cas notre code existant (pour rappel les tableaux implémentent l’interface IList<T> !). A la compilation nous auront aucun problème, mais à l’exécution nous aurons l’erreur suivante :
Il en est de même avec la classe ReadOnlyCollection<T> qui implémente partiellement l’interface IList<T>.
Pour moi c’est un défaut de conception du .NET Framework (plus précisement l’interface IList<T> ne respecte pas le principe de Liskov des principes S.O.L.I.D.), nous verrons plus-tard que Microsoft à résolu le problème en introduisant de nouvelles interfaces dans le .NET Framework 4.5.
L’interface IEnumerable<T> très abstraite… Mais aussi très dangereuse !
Beaucoup de développeurs exposent dans des propriétés ou dans le retour des méthodes l’interface IEnumerable<T>. D’un point de vue orienté objet c’est très bien, car on est très abstrait, mais d’un point de vue technique un IEnumerable<T> peut cacher le déclenchement de requêtes SQL ! (lorsque l’on utilise Linq To Entities)…
L’exemple suivant illuste ce problème :
public void AfficherNuméroFactures(IEnumerable<Facture> factures) { int i = 1; foreach (Facture facture in factures) { Console.WriteLine("Facture n° {0} ({0}/{1})", facture.Numéro, i, factures.Count()); i++; } }
Dans cet exemple, on peut remarquer que l’on exécute le parcourt de la séquence spécifié dans le paramètre « factures » et pour chaque itération on appelle la méthode Count() sur cette séquence. Si la séquence est une collection d’objets en mémoire cela ne pose aucun problème au niveau performance, en revanche si cela représente une requête de type Linq To Entities, cela va engendrer une requête pour parcourir les factures et une requête dans chaque itération pour compter le nombre de factures. Bien évidemment, on aurait pu exécuter la méthode Count() afin de calculer le nombre de factures une seule fois et stocker le résultat dans une variable, cela aurait fait exécuté que 2 requêtes SQL, mais il faut partir du principe que l’on ne connait pas à l’avance réellement l’objet de type IEnumerable<T> qui sera passé en paramètre…
Les IEnumerable<T> peuvent donc cacher des déclenchements de requêtes (SQL, sur des objets, sur des documents XML,…) à chaque parcourt… Il est donc fortement déconseillé d’exposer publiquement cette interface en retour d’une méthode ou d’une propriété ainsi que en paramètre à des méthodes.
Quelle collection faut-il utiliser donc dans le retour des méthodes publiques ?
La réponse est directe : un tableau ! Certainement pas une List<T> ou un IDictionary<TKey, TValue> comme on l’a vue dans la section précédente. On ne retourne pas une Collection<T> car les collections représentent une collection d’objets et surtout on ne retourne pas une interface IEnumerable<T>, ICollection<T> ou IList<T> comme je l’ai justifié précédemment.
Certains développeurs retournent dans des méthodes de composants métiers des collections de type ObservableCollection<T> sous prétexte que cette collection sera utilisée par une application WPF. La réponse que je donne à ces développeurs est « pas forcement ! ». En effet, déjà un composant métier peut-être utilisé dans d’autres type d’applications (ASP .NET, Services WCF,…), et aussi il n’est pas sûr que au moment de la récupération des données dans l’application WPF que la collection ObservableCollection<T> sera directement bindé à l’IHM (il sera peut-être nécessaire de retravailler la collection pour présenter les données autrement !).
NB: Dans certains cas, il est intéressant de retourner un IEnumerable<T> lorsque l’on fait des méthodes utilitaires de base avec le mot de clé yield return, c’est le seul cas où l’on doit s’autoriser à utiliser IEnumerable<T>.
Quelle collection faut-il utiliser dans les propriétés ?
Pour exposer une collection dans une propriété, il faut utiliser les collections Collection<T> ou ReadOnlyCollection<T> et surtout pas des collections d’exécution ou des interfaces du .NET Framework.
Quelle collection faut-il utiliser en paramètre d’une méthode ?
Cela dépend si on veut ajouter des éléments dans cette collection ou alors juste les consulter. Il n’y a pas de collection dans le .NET Framework que l’on peut utiliser systématiquement pour les paramètres d’une méthode. Pour illustrer ce problème voilà différentes propositions de collections candidates pour être utilisée en paramètre d’une méthode ainsi que leurs inconvénients.
- L’interface IList<T> ou ICollection<T> : On utilisera ces interfaces pour les paramètres de méthodes dans le cas où l’on souhaite passer une collection dont on veut ajouter ou supprimer des éléments… Ces interfaces posent problème dans le cas où on passe réellement un tableau. En effet, rappelez-vous que les tableaux ne supportent pas les méthodes Add() et Remove() de la classe ICollection<T>. Ce qui impose certaines restrictions au niveau du paramètre (il sera peut-être nécessaire d’imposer une pré-condition…).
L’exemple suivant illustre ce qu’il faudrait écrire en utilisant Code Contracts pour être sûre que l’objet de type IList<T> passé en paramètre n’est pas un tableau :
public void GénérerFactures(IList<Facture> factures) { Contract.Requires<ArgumentNullException>(factures != null); Contract.Requires<ArgumentException>(Contract.ForAll(factures, f => f != null)); Contract.Requires<ArgumentException>(factures is Array == false); factures.Add(new Facture()); }
- L’interface IEnumerable<T> : Comme nous l’avons vu précédemment, cette interface peut cacher des requêtes SQL… De plus certains développeurs n’hésites pas à faire appel aux méthodes Last(), First(), Count() de Linq plusieurs fois et sans modération… Cela peut donc engendrer, par exemple, plusieurs requêtes SQL sans le savoir….
- Les tableaux : C’est la collection la plus adéquate pour passer des paramètres à une méthode. L’inconvénient étant la nécessité à l’appelant de créer un tableau ce qui peut engendrer des problèmes de performances au niveau CPU et mémoire si le nombre d’éléments devient trop important.
Exposer une collection de paire (clé/valeur)… Pensez objet non d’un chien !!!
Lorsque je dis aux développeurs de ne pas exposer des propriétés qui contiennent des IDictionary<TKey, TValue> (ou des implémentations), j’obtiens à chaque fois comme réponse : « Alors comment je fais pour exposer une collection de paire client/facture ? ». Et bien la réponse est dans la question :
- « Une collection de… » => Donc une Collection<T>
- « …de paire client/facture » => Une classe qui représente une paire de Client/Facture.
C’est simple non ? Voilà un diagramme de classe qui résume ce qu’il faudrait faire dans les bonnes pratiques :
Le code s’en trouve beaucoup plus lisible et on parle alors d’une collection de « ClientFacture » et non une collection de paire de clé/valeur dont la clé est le client et la facture la valeur. Au passage, le fait d’utiliser ce genre d’implémentation permet de faire évoluer la classe « ClientFacture » en rajoutant des méthodes ou d’autres propriétés, sans casser l’existant ! Dans le cas d’un IDictionary<TKey, TValue> imaginez que vous devez introduire dans la paire Client/Facture la notion de « réglement »…
Implémenter sa collection custom…
Souvent les développeurs préfèrent exposer dans des propriétés, des collections de type List<T> afin d’avoir des services avancés qui n’existent pas dans la classe Collection<T>. Tout d’abord, la classe Collection<T> implémente l’interface IEnumerable<T> ce qui signifie qu’il est possible d’utiliser toutes les méthodes utilitaires de Linq permettant la rechercher, les tris,…. Ensuite, si on souhaite implémenter sa propre collection, il n’y a rien de plus facile, il faut juste hériter de la classe Collection<T> et rajouter les services nécessaires… Il est aussi possible de surcharger certaines méthodes protected de la classe Collection<T> afin de customiser son comportement.
Voici un exemple d’implémentation d’une collection de factures qui contient une méthode de recherche avancée GetFacturesNonValidées() et test si on n’essaye pas d’ajouter des éléments null dans la collection :
public class FactureCollection : Collection<Facture> { public Facture[] GetFacturesNonValidées() { return this.Items.Where(f => f.EstValidé == false).ToArray(); } protected override void InsertItem(int index, Facture item) { if (item == null) { throw new ArgumentNullException("item"); } base.InsertItem(index, item); } protected override void SetItem(int index, Facture item) { if (item == null) { throw new ArgumentNullException("item"); } base.SetItem(index, item); } }
Quoi de neuf dans le .NET Framework 4.5 ?
Dans le .NET Framework 4.5, Microsoft a introduit 2 interfaces qui implémente l’interface IEnumerable<T> :
- IReadOnlyCollection<T> représente un ensemble d’éléments que l’on peut parcourir via l’implémentation IEnumerable<T> et l’on peut aussi obtenir le nombre d’éléments dans cette collection sans être obligé de les compter (en appelant la méthode Count() de Linq par exemple).
- IReadOnlyList<T> : Hérite de IReadOnlyCollection<T> et permet de récupérer un élément à une position particulière.
Ces 2 interfaces sont implémentées par les tableaux, les List<T>, les Collection<T> et les ReadOnlyCollection<T>.
L’avantage de ces 2 interfaces est de pouvoir passer en paramètre à une méthode, un tableau ou une List<T> dont on veut connaitre directement le nombre d’éléments qui y sont contenu sans utiliser la méthode Count() de Linq. Etant donné que toutes les méthodes contenues dans les interfaces IReadOnlyCollection<T> et IReadOnlyList<T> sont implémentées par les List<T> et les tableaux, il est maintenant possible et voir conseillé de les utiliser en paramètre ou en retour de méthode (au lieu d’un tableau).
Conclusion
Pour résumer, on peut utiliser n’importe quelle collections en interne dans son code mais en revanche pour le choix des collections à exposer et à utiliser en publique, je conseille d’utiliser les collections suivantes :
Avec le .NET Framework 4.0 :
Retour d’une méthode | Tableau |
Retour d’une propriété | Collection<T> ou ReadOnlyCollection<T> |
Paramètre d’une méthode afin de consulter les éléments | Tableau |
Paramètre d’une méthode afin de consulter et modifier les éléments | ICollection<T> ou IList<T> avec utilisation d’un contrat. |
Avec le .NET Framework 4.5
Retour d’une méthode | IReadOnlyList<T> ou IReadOnlyCollection<T> |
Retour d’une propriété | Collection<T> ou ReadOnlyCollection<T> |
Paramètre d’une méthode afin de consulter les éléments | IReadOnlyList<T> ou IReadOnlyCollection<T> |
Paramètre d’une méthode afin de consulter et modifier les éléments | ICollection<T> ou IList<T> avec utilisation d’un contrat. |
Dans tous les cas il ne faut pas exposer publiquement des IDictionary<TKey, TValue> ou alors des List<T>.
Au passage, j’attends énormément de commentaires et menace de mort de certains… Si vous n’êtes pas du même avis que moi concernant l’utilisation des collections avec le .NET Framework, regardez les API du .NET Framework (toute technologie confondue) et vous verrez que Microsoft n’expose pas par exemple des collections avec des List<T> ou des IDictionary<TKey, TValue>. Vous pouvez aussi consulter le Guidelines officiel de Microsoft sur les collections.