C# : Les interfaces de collection
IEnumerable, ICollection, IList, IDictionary, ISet et les variantes ReadOnly - la hiérarchie des interfaces génériques et laquelle utiliser.
C# : Les interfaces de collection
Chaque collection générique (List<T>, Dictionary<TKey, TValue>, HashSet<T>...) implémente une hiérarchie d'interfaces. Les connaître change ta façon d'écrire du code : tu déclares tes paramètres et tes retours avec l'interface la plus adaptée, pas avec le type concret, et ton code devient plus flexible et plus testable.
Voici la hiérarchie, du plus général (en haut) au plus spécifique :
IEnumerable<T> parcourable avec foreach
└─ ICollection<T> + Count, Add, Remove, Contains
├─ IList<T> + accès par index [i], Insert, RemoveAt
├─ ISet<T> + opérations ensemblistes (union, intersection)
└─ IDictionary<TKey, TValue> + clés, valeurs, TryGetValueIEnumerable<T> parcourable avec foreach└─ ICollection<T> + Count, Add, Remove, Contains ├─ IList<T> + accès par index [i], Insert, RemoveAt ├─ ISet<T> + opérations ensemblistes (union, intersection) └─ IDictionary<TKey, TValue> + clés, valeurs, TryGetValueChaque niveau ajoute des capacités au précédent. Plus tu descends, plus tu exiges de la collection.
# IEnumerable<T> - être parcouru
L'interface la plus fondamentale. Elle promet une seule chose : on peut me parcourir avec foreach. C'est la base de tout, y compris de LINQ (toutes ses méthodes s'appliquent à un IEnumerable<T>).
public interface IEnumerable<T>
{
IEnumerator<T> GetEnumerator();
}public interface IEnumerable<T>{ IEnumerator<T> GetEnumerator();}Un foreach n'est en réalité que du sucre syntaxique au-dessus de l'IEnumerator<T> (MoveNext + Current) :
// Ce que tu écris
foreach (int n in nombres)
{
Console.WriteLine(n);
}
// Ce que le compilateur génère (en simplifié)
IEnumerator<int> e = nombres.GetEnumerator();
while (e.MoveNext())
{
int n = e.Current;
Console.WriteLine(n);
}// Ce que tu écrisforeach (int n in nombres){ Console.WriteLine(n);} // Ce que le compilateur génère (en simplifié)IEnumerator<int> e = nombres.GetEnumerator();while (e.MoveNext()){ int n = e.Current; Console.WriteLine(n);}Le plus simple pour produire un IEnumerable<T>, c'est yield return : il construit l'énumération paresseusement, élément par élément, sans jamais tout garder en mémoire.
public IEnumerable<int> Pairs(int max)
{
for (int i = 0; i <= max; i += 2)
{
yield return i; // rend un élément puis reprend ici au tour suivant
}
}public IEnumerable<int> Pairs(int max){ for (int i = 0; i <= max; i += 2) { yield return i; // rend un élément puis reprend ici au tour suivant }} Un IEnumerable<T> peut être infini ou coûteux à parcourir. Il ne connaît pas forcément son nombre d'éléments : il n'a pas de Count, juste de quoi avancer. Le mécanisme complet (yield, exécution différée, pièges de la ré-exécution) est détaillé dans IEnumerable, yield et exécution différée.
# ICollection<T> - compter et modifier
Ajoute la notion de taille connue et de modification : on peut compter, ajouter, retirer, vider.
public interface ICollection<T> : IEnumerable<T>
{
int Count { get; }
bool IsReadOnly { get; }
void Add(T item);
bool Remove(T item);
void Clear();
bool Contains(T item);
void CopyTo(T[] array, int arrayIndex);
}public interface ICollection<T> : IEnumerable<T>{ int Count { get; } bool IsReadOnly { get; } void Add(T item); bool Remove(T item); void Clear(); bool Contains(T item); void CopyTo(T[] array, int arrayIndex);}Contrairement à IEnumerable<T>, ici Count est immédiat (pas besoin de tout parcourir).
void Vider(ICollection<string> collection)
{
Console.WriteLine($"{collection.Count} éléments avant nettoyage");
collection.Clear();
}
// Fonctionne avec une List, un HashSet, un tableau (en lecture seule)...void Vider(ICollection<string> collection){ Console.WriteLine($"{collection.Count} éléments avant nettoyage"); collection.Clear();}// Fonctionne avec une List, un HashSet, un tableau (en lecture seule)...# IList<T> - accès par index
Ajoute la position : accès par index, insertion et suppression à une position précise.
public interface IList<T> : ICollection<T>
{
T this[int index] { get; set; }
int IndexOf(T item);
void Insert(int index, T item);
void RemoveAt(int index);
}public interface IList<T> : ICollection<T>{ T this[int index] { get; set; } int IndexOf(T item); void Insert(int index, T item); void RemoveAt(int index);}void Permuter(IList<int> liste, int i, int j)
{
(liste[i], liste[j]) = (liste[j], liste[i]); // accès par index garanti
}void Permuter(IList<int> liste, int i, int j){ (liste[i], liste[j]) = (liste[j], liste[i]); // accès par index garanti}C'est l'interface qu'implémentent List<T> et les tableaux T[]. Tu l'exiges quand ton algorithme a besoin de l'index (tri, permutation, accès positionnel).
# IDictionary<TKey, TValue> - clé vers valeur
L'abstraction du dictionnaire : une association clé unique vers valeur.
public interface IDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>
{
TValue this[TKey key] { get; set; }
ICollection<TKey> Keys { get; }
ICollection<TValue> Values { get; }
bool ContainsKey(TKey key);
bool TryGetValue(TKey key, out TValue value);
void Add(TKey key, TValue value);
bool Remove(TKey key);
}public interface IDictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>{ TValue this[TKey key] { get; set; } ICollection<TKey> Keys { get; } ICollection<TValue> Values { get; } bool ContainsKey(TKey key); bool TryGetValue(TKey key, out TValue value); void Add(TKey key, TValue value); bool Remove(TKey key);}Remarque qu'un dictionnaire est une ICollection de KeyValuePair<TKey, TValue> : c'est pour ça qu'on peut le parcourir au foreach par paires.
int Total(IDictionary<string, int> stock)
{
int total = 0;
foreach (KeyValuePair<string, int> paire in stock)
{
total += paire.Value;
}
return total;
}int Total(IDictionary<string, int> stock){ int total = 0; foreach (KeyValuePair<string, int> paire in stock) { total += paire.Value; } return total;}# ISet<T> - un ensemble
L'interface des ensembles (HashSet<T>, SortedSet<T>). Elle garantit l'unicité et ajoute les opérations ensemblistes.
public interface ISet<T> : ICollection<T>
{
new bool Add(T item); // retourne false si l'élément existait déjà
void UnionWith(IEnumerable<T> other);
void IntersectWith(IEnumerable<T> other);
void ExceptWith(IEnumerable<T> other);
bool IsSubsetOf(IEnumerable<T> other);
bool IsSupersetOf(IEnumerable<T> other);
bool Overlaps(IEnumerable<T> other);
}public interface ISet<T> : ICollection<T>{ new bool Add(T item); // retourne false si l'élément existait déjà void UnionWith(IEnumerable<T> other); void IntersectWith(IEnumerable<T> other); void ExceptWith(IEnumerable<T> other); bool IsSubsetOf(IEnumerable<T> other); bool IsSupersetOf(IEnumerable<T> other); bool Overlaps(IEnumerable<T> other);}Détail utile : ici Add retourne un bool (false si le doublon a été ignoré), là où le Add d'ICollection retourne void.
# Les variantes en lecture seule
Pour chaque interface modifiable, il existe une version lecture seule. Elles servent à exposer une collection sans laisser l'appelant la modifier :
public interface IReadOnlyCollection<T> : IEnumerable<T>
{
int Count { get; }
}
public interface IReadOnlyList<T> : IReadOnlyCollection<T>
{
T this[int index] { get; } // get seulement, pas de set
}
// et IReadOnlyDictionary<TKey, TValue>public interface IReadOnlyCollection<T> : IEnumerable<T>{ int Count { get; }} public interface IReadOnlyList<T> : IReadOnlyCollection<T>{ T this[int index] { get; } // get seulement, pas de set}// et IReadOnlyDictionary<TKey, TValue>Cas typique : une classe qui garde une liste privée et l'expose sans permettre l'ajout ou la suppression de l'extérieur.
public class Panier
{
private readonly List<string> _articles = new();
// L'extérieur peut lire et compter, mais pas Add / Remove
public IReadOnlyList<string> Articles => _articles;
public void Ajouter(string article) => _articles.Add(article);
}public class Panier{ private readonly List<string> _articles = new(); // L'extérieur peut lire et compter, mais pas Add / Remove public IReadOnlyList<string> Articles => _articles; public void Ajouter(string article) => _articles.Add(article);} IReadOnlyList<T> est une vue en lecture seule, pas une copie immuable. Si tu renvoies directement la liste interne comme ci-dessus, le détenteur ne peut pas la modifier via l'interface, mais la classe, elle, la modifie toujours. Pour une vraie immuabilité, regarde ImmutableList<T> du namespace System.Collections.Immutable.
# IComparer<T> et IEqualityComparer<T>
Deux interfaces qui ne sont pas des collections, mais que les collections utilisent pour savoir comparer ou comparer l'égalité de leurs éléments.
IComparer<T>définit un ordre (Comparerenvoie négatif / zéro / positif). UnSortedDictionaryou unList.Sortpeut en prendre un.IEqualityComparer<T>définit l'égalité et le hash (Equals+GetHashCode). UnDictionaryou unHashSets'en sert pour ses clés.
// Trier des chaînes par longueur plutôt qu'alphabétiquement
public class ParLongueur : IComparer<string>
{
public int Compare(string? a, string? b) => (a?.Length ?? 0) - (b?.Length ?? 0);
}
List<string> mots = new List<string> { "banane", "kiwi", "pomme" };
mots.Sort(new ParLongueur()); // { "kiwi", "pomme", "banane" }// Trier des chaînes par longueur plutôt qu'alphabétiquementpublic class ParLongueur : IComparer<string>{ public int Compare(string? a, string? b) => (a?.Length ?? 0) - (b?.Length ?? 0);} List<string> mots = new List<string> { "banane", "kiwi", "pomme" };mots.Sort(new ParLongueur()); // { "kiwi", "pomme", "banane" }# Qui implémente quoi
| Type concret | Interfaces principales implémentées |
|---|---|
List<T> | IList<T>, IReadOnlyList<T>, ICollection<T>, IEnumerable<T> |
T[] (tableau) | IList<T>, IReadOnlyList<T>, IEnumerable<T> |
Dictionary<K,V> | IDictionary<K,V>, IReadOnlyDictionary<K,V>, IEnumerable<...> |
HashSet<T> | ISet<T>, IReadOnlyCollection<T>, IEnumerable<T> |
Queue<T> / Stack<T> | IReadOnlyCollection<T>, IEnumerable<T> |
LinkedList<T> | ICollection<T>, IReadOnlyCollection<T>, IEnumerable<T> |
Queue<T> et Stack<T> n'implémentent volontairement ni IList ni ICollection.Add : accéder à une pile par index ou y insérer au milieu n'aurait pas de sens. L'interface reflète le contrat de la structure.
# Laquelle choisir dans une signature
La règle est simple et s'applique partout :
- En paramètre, exige le minimum dont tu as besoin. Tu ne fais que parcourir ? Prends
IEnumerable<T>. Tu as besoin duCount?IReadOnlyCollection<T>. De l'index ?IReadOnlyList<T>. - En retour, expose le type le plus précis utile :
IReadOnlyList<T>plutôt queList<T>si l'appelant ne doit pas modifier.
// Trop restrictif : n'accepte que des List
double Moyenne(List<int> nombres) { ... }
// Idéal : accepte List, tableau, HashSet, résultat LINQ, yield...
double Moyenne(IEnumerable<int> nombres) { ... }// Trop restrictif : n'accepte que des Listdouble Moyenne(List<int> nombres) { ... } // Idéal : accepte List, tableau, HashSet, résultat LINQ, yield...double Moyenne(IEnumerable<int> nombres) { ... }Ça découple ton code de l'implémentation concrète : tu peux changer une List en HashSet en interne sans casser tes appelants.
# La suite
- IEnumerable, yield et exécution différée - le deep-dive sur le contrat de base
- Collections & génériques - le hub : big picture et table de décision
- Les collections en pratique - les classes concrètes qui implémentent ces interfaces
- LINQ - tout ce qu'on peut faire sur un
IEnumerable<T> - Les interfaces - le mécanisme du langage derrière ces contrats