Accueil › Les fondations › Chapitre 7

Collections, génériques et LINQ

Un programme passe l'essentiel de son temps à manipuler des groupes de choses. Ce chapitre explique comment les ranger (collections), comment écrire du code qui marche avec n'importe quoi (génériques), et comment les interroger en une ligne au lieu de vingt (LINQ).

Les génériques : <T> démystifié

L'image

Un moule à gâteau accepte n'importe quelle pâte. Mais une fois que tu y as versé du chocolat, il ne contient que du chocolat : impossible d'y glisser une part de tarte aux pommes. List<T>, c'est ce moule : générique à la fabrication, strictement typé à l'usage.

Avant les génériques (2002)
var liste = new ArrayList();
liste.Add(42);
liste.Add("oups");          // accepté !
int x = (int)liste[1];      // 💥 à l'EXÉCUTION

// + un boxing par entier ajouté
Avec les génériques
var liste = new List<int>();
liste.Add(42);
// liste.Add("oups");       // ❌ refusé à la COMPILATION
int x = liste[0];           // pas de cast, pas de boxing
Écrire son propre code générique
// Une méthode qui marche avec n'importe quel type
public static T Premier<T>(IEnumerable<T> source) => source.First();

// Une contrainte pour pouvoir FAIRE quelque chose avec T
public static T Max<T>(T a, T b) where T : IComparable<T>
    => a.CompareTo(b) >= 0 ? a : b;

// Une classe générique
public class Cache<TCle, TValeur> where TCle : notnull
{
    private readonly Dictionary<TCle, TValeur> _elements = [];
    public void Ajouter(TCle cle, TValeur v) => _elements[cle] = v;
    public bool TryGet(TCle cle, out TValeur? v) => _elements.TryGetValue(cle, out v);
}
ContrainteSignifie
where T : classtype référence uniquement
where T : structtype valeur uniquement
where T : notnullnon nullable
where T : IComparable<T>implémente cette interface — le cas le plus utile
where T : Entitehérite de cette classe
where T : new()possède un constructeur sans paramètre (permet new T())
where T : INumber<T>n'importe quel type numérique (C# 11+) : un seul code pour int, double, decimal

Choisir la bonne collection

BesoinTypeCoût d'accèsRemarque
Une liste ordonnée qui granditList<T>index : instantané
recherche : proportionnelle
Le choix par défaut dans 80 % des cas
Retrouver par cléDictionary<K,V>instantanéUn annuaire. Clés uniques, ordre non garanti
Savoir si un élément existe, sans doublonsHashSet<T>instantanéIdéal pour dédoublonner ou tester l'appartenance
Taille fixe connueT[]instantanéLéger, mais on ne peut pas ajouter
Premier entré, premier sortiQueue<T>File d'attente, traitement de messages
Dernier entré, premier sortiStack<T>Historique, annulation
Trié en permanence par cléSortedDictionary<K,V>logarithmiquePlus lent qu'un Dictionary ; ne l'utilise que si l'ordre compte
Exposer sans laisser modifierIReadOnlyList<T>Pour les propriétés publiques (ch. 4)
Vraiment immuableImmutableArray<T>instantanéChaque « modification » renvoie une nouvelle instance
Accès depuis plusieurs threadsConcurrentDictionary<K,V>instantanéDictionary n'est PAS thread-safe : corruption silencieuse
Producteur / consommateurChannel<T>La bonne réponse moderne pour une file entre tâches
IEnumerable<T> → je peux être parcouru (foreach, LINQ). C'est tout. ICollection<T> → + Count, Add, Remove, Contains IList<T> · List<T>, T[] → + accès par index, Insert, ordre garanti
Règle d'or : accepte le plus général en paramètre (IEnumerable<T>), renvoie le plus précis (List<T> ou IReadOnlyList<T>).

LINQ : interroger, pas boucler

L'image

LINQ est une chaîne de production. Les éléments avancent sur un tapis roulant et traversent des postes : un trieur (Where) écarte les rebuts, une presse (Select) transforme, un rangeur (OrderBy) met en ordre, un emballeur (ToList) met en carton. Tant que personne ne réclame le carton, la chaîne ne démarre même pas.

À la main
var resultat = new List<string>();
foreach (var c in clients)
{
    if (c.Actif && c.Ville == "Lyon")
        resultat.Add(c.Nom.ToUpper());
}
resultat.Sort();
En LINQ
var resultat = clients
    .Where(c => c.Actif && c.Ville == "Lyon")
    .Select(c => c.Nom.ToUpper())
    .Order()
    .ToList();

Se lit comme une phrase : « prends les clients actifs de Lyon, garde leurs noms en majuscules, trie, mets en liste ».

Les opérateurs à connaître

CatégorieOpérateurFait quoi
FiltrerWhere(p)Ne garde que les éléments qui satisfont la condition
Take(n) / Skip(n)Les n premiers / saute les n premiers (pagination)
Distinct() / DistinctBy(k)Supprime les doublons
TransformerSelect(f)Transforme chaque élément (projection)
SelectMany(f)Aplatit : liste de listes → liste unique
Cast<T>() / OfType<T>()Change de type / ne garde que ceux d'un type
OrdonnerOrderBy(k) / OrderByDescending(k)Trie selon une clé
ThenBy(k)Critère de tri secondaire
RegrouperGroupBy(k)Crée des paquets par clé
ToDictionary(k, v) / ToLookup(k)Indexe le résultat
Un seul élémentFirst() / FirstOrDefault()Le premier / le premier ou null
Single() / SingleOrDefault()Exige qu'il y en ait exactement un
ElementAt(i) / Last()Par position / le dernier
AgrégerCount() / Any() / All()Combien / au moins un / tous
Sum() / Average() / Min() / Max()Calculs classiques
MinBy(k) / MaxBy(k)L'élément ayant la plus petite/grande clé
Aggregate(f)Réduction personnalisée (rare, souvent illisible)
CombinerJoin / GroupJoinJointure entre deux séquences
LeftJoin / RightJoin .NET 10 · FullJoin .NET 11Jointures externes de première classe, enfin
MatérialiserToList() / ToArray()Exécute et met le résultat en mémoire
Chunk(n)Découpe en paquets de n (traitement par lots)
Regrouper, agréger, mettre en forme : l'exemple complet
record Vente(string Ville, string Produit, decimal Montant, DateOnly Date);

var rapport = ventes
    .Where(v => v.Date.Year == 2026)
    .GroupBy(v => v.Ville)
    .Select(g => new
    {
        Ville   = g.Key,
        Total   = g.Sum(v => v.Montant),
        Nombre  = g.Count(),
        Panier  = g.Average(v => v.Montant),
        Vedette = g.MaxBy(v => v.Montant)!.Produit
    })
    .OrderByDescending(x => x.Total)
    .Take(5)
    .ToList();

foreach (var l in rapport)
    Console.WriteLine($"{l.Ville,-12} {l.Total,10:C}  ({l.Nombre} ventes, panier {l.Panier:C})");

Exécution différée : le concept qui piège tout le monde

Une requête LINQ ne s'exécute pas quand tu l'écris, mais quand tu lis son résultat.

.Where(...) rien ne s'exécute .Select(...) rien ne s'exécute .OrderBy(...) rien ne s'exécute .ToList() 🚦 DÉPART tout s'exécute ici Déclencheurs : ToList · ToArray · Count · First · Single · Any · Sum · foreach · await foreach Non déclencheurs : Where · Select · OrderBy · Skip · Take · GroupBy · Distinct
Piège 1 : la requête s'exécute à chaque parcours
var chers = produits.Where(p => { Console.WriteLine("filtre !"); return p.Prix > 100; });

Console.WriteLine(chers.Count());   // "filtre !" × N
Console.WriteLine(chers.Count());   // "filtre !" × N  ← RE-exécuté
foreach (var p in chers) { }        // "filtre !" × N  ← ENCORE

// Solution : matérialiser une fois si tu réutilises
var chersListe = produits.Where(p => p.Prix > 100).ToList();

Sur une base de données, ce sont trois requêtes SQL au lieu d'une.

Piège 2 : la variable capturée change avant l'exécution
var seuil = 100;
var requete = produits.Where(p => p.Prix > seuil);
seuil = 1000;                       // ← modifié APRÈS
Console.WriteLine(requete.Count()); // utilise 1000, pas 100 !

La lambda capture la variable, pas sa valeur du moment. Même cause pour les fameuses boucles où toutes les tâches lancées utilisent la dernière valeur de l'indice.

Piège 3 : modifier la collection pendant qu'on la parcourt
foreach (var c in clients)
    if (c.Inactif) clients.Remove(c);       // 💥 InvalidOperationException

clients.RemoveAll(c => c.Inactif);          // ✅ la bonne méthode
// ou : foreach (var c in clients.ToList()) { ... }   // on parcourt une copie

IEnumerable vs IQueryable : où le code s'exécute-t-il ?

Question d'entretien classique, et vrai piège de performance en EF Core.

IEnumerable<T>IQueryable<T>
La lambda est…du code compilé, exécuté en mémoireun arbre d'expression analysable
Le filtrage a lieu…dans ton application (C#)dans la base de données (SQL)
Données transféréestout, puis on jetteseulement le résultat filtré
Sur quoi ?Listes, tableaux, fichiersEF Core, sources distantes
Catastrophe silencieuse
// AsEnumerable() casse la traduction SQL
var actifs = db.Clients
    .AsEnumerable()             // ← tout descend en mémoire
    .Where(c => c.Actif)        // filtré en C#
    .ToList();

// SQL : SELECT * FROM Clients
// → 2 millions de lignes transférées
//   pour en garder 12
Traduit en SQL
var actifs = db.Clients          // IQueryable
    .Where(c => c.Actif)
    .Select(c => new { c.Id, c.Nom })
    .ToList();

// SQL : SELECT Id, Nom FROM Clients
//       WHERE Actif = 1
// → 12 lignes, 2 colonnes
Bon réflexe

Sur une requête EF Core, garde le type IQueryable<T> aussi longtemps que possible et matérialise une seule fois, à la fin, avec ToListAsync(). Ne renvoie jamais un IQueryable hors de ta couche d'accès aux données : l'appelant ne sait pas que le DbContext peut être déjà fermé. Détails au chapitre 15.

Les erreurs LINQ classiques

À éviterPourquoiÀ la place
liste.Count() > 0Peut parcourir toute la séquenceliste.Any() — s'arrête au premier
liste.Count() sur une ListMéthode LINQ là où une propriété existeliste.Count (sans parenthèses)
.First() sans certitudeInvalidOperationException si vide.FirstOrDefault() + test de null
.Single() par habitudeLève une exception s'il y en a deux — parfois voulu !Single si l'unicité est une règle, First sinon
.Where(...).Count()Lisible mais verbeux.Count(p => ...)
.OrderBy(...) avant .Where(...)On trie des éléments qu'on va jeterFiltrer d'abord, trier ensuite
Effets de bord dans SelectNe s'exécute peut-être jamais (différé)foreach explicite pour agir
.ToList() au milieu d'une chaîneMatérialise inutilement, coupe la traduction SQLUne seule fois, à la fin

Créer sa propre séquence avec yield

// Produit les éléments UN PAR UN, à la demande : aucune liste construite en mémoire
static IEnumerable<string> LignesNonVides(string chemin)
{
    using var lecteur = new StreamReader(chemin);
    string? ligne;
    while ((ligne = lecteur.ReadLine()) is not null)
        if (ligne.Length > 0)
            yield return ligne;          // ← « voici le suivant, je me remets en pause »
}

// Fonctionne sur un fichier de 40 Go avec une empreinte mémoire constante
foreach (var l in LignesNonVides("gros.csv").Take(10))
    Console.WriteLine(l);
Sous le capot : la machine à états

Le compilateur transforme une méthode à yield en une classe implémentant IEnumerator<T>, avec un champ _state qui mémorise où l'exécution s'était arrêtée. Conséquence pratique : le corps de la méthode ne s'exécute pas du tout avant le premier MoveNext(). Une validation de paramètres placée dans un itérateur ne lèvera donc son exception qu'au premier parcours — d'où le motif « méthode publique qui valide, puis appelle une méthode privée itérateur ».

LINQ et performance

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. Tu dois vérifier 50 000 fois si un identifiant fait partie d'un ensemble de 100 000. Quelle collection ?

List.Contains parcourt : 50 000 × 100 000 comparaisons dans le pire cas. HashSet répond en temps constant : 50 000 opérations.

2. var r = liste.Where(x => x > 10); — combien de fois la condition a-t-elle été évaluée à cette ligne ?

Exécution différée. Le travail commence au foreach, ToList(), Count()

3. Dans une requête EF Core, quel est l'effet de .AsEnumerable() placé au début ?

On quitte IQueryable : plus rien n'est traduit en SQL. C'est l'une des causes les plus fréquentes de lenteur inexpliquée.

4. First() ou Single() pour chercher un client par son identifiant unique ?

Single exprime et vérifie une règle métier (« il n'y en a qu'un »). First accepte silencieusement les doublons. En base, Single coûte une ligne supplémentaire lue pour vérifier — un prix modeste pour détecter une anomalie.

5. where T : INumber<T> permet…

Rendu possible par les membres static abstract des interfaces (C# 11) : les opérateurs +, * et T.Zero deviennent utilisables sur un type générique.

Fiches de révision

Collection par défaut ?
List<T>. Par clé : Dictionary. Appartenance/unicité : HashSet.
Quoi accepter en paramètre ?
IEnumerable<T> (le plus général). Et renvoyer le plus précis.
Qu'est-ce qui déclenche une requête LINQ ?
ToList, ToArray, Count, First, Any, Sum, foreach.
IEnumerable vs IQueryable ?
Enumerable = filtré en mémoire. Queryable = traduit en SQL et filtré par la base.
Any() ou Count() > 0 ?
Toujours Any() : il s'arrête au premier élément trouvé.
À quoi sert yield return ?
Produire une séquence à la demande, sans jamais construire la liste complète en mémoire.
À retenir