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é
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.
var liste = new ArrayList();
liste.Add(42);
liste.Add("oups"); // accepté !
int x = (int)liste[1]; // 💥 à l'EXÉCUTION
// + un boxing par entier ajoutévar liste = new List<int>();
liste.Add(42);
// liste.Add("oups"); // ❌ refusé à la COMPILATION
int x = liste[0]; // pas de cast, pas de boxing// 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);
}
| Contrainte | Signifie |
|---|---|
where T : class | type référence uniquement |
where T : struct | type valeur uniquement |
where T : notnull | non nullable |
where T : IComparable<T> | implémente cette interface — le cas le plus utile |
where T : Entite | hé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
| Besoin | Type | Coût d'accès | Remarque |
|---|---|---|---|
| Une liste ordonnée qui grandit | List<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 doublons | HashSet<T> | instantané | Idéal pour dédoublonner ou tester l'appartenance |
| Taille fixe connue | T[] | instantané | Léger, mais on ne peut pas ajouter |
| Premier entré, premier sorti | Queue<T> | — | File d'attente, traitement de messages |
| Dernier entré, premier sorti | Stack<T> | — | Historique, annulation |
| Trié en permanence par clé | SortedDictionary<K,V> | logarithmique | Plus lent qu'un Dictionary ; ne l'utilise que si l'ordre compte |
| Exposer sans laisser modifier | IReadOnlyList<T> | — | Pour les propriétés publiques (ch. 4) |
| Vraiment immuable | ImmutableArray<T> | instantané | Chaque « modification » renvoie une nouvelle instance |
| Accès depuis plusieurs threads | ConcurrentDictionary<K,V> | instantané | Dictionary n'est PAS thread-safe : corruption silencieuse |
| Producteur / consommateur | Channel<T> | — | La bonne réponse moderne pour une file entre tâches |
IEnumerable<T>),
renvoie le plus précis (List<T> ou IReadOnlyList<T>).LINQ : interroger, pas boucler
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.
var resultat = new List<string>();
foreach (var c in clients)
{
if (c.Actif && c.Ville == "Lyon")
resultat.Add(c.Nom.ToUpper());
}
resultat.Sort();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égorie | Opérateur | Fait quoi |
|---|---|---|
| Filtrer | Where(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 | |
| Transformer | Select(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 | |
| Ordonner | OrderBy(k) / OrderByDescending(k) | Trie selon une clé |
ThenBy(k) | Critère de tri secondaire | |
| Regrouper | GroupBy(k) | Crée des paquets par clé |
ToDictionary(k, v) / ToLookup(k) | Indexe le résultat | |
| Un seul élément | First() / 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éger | Count() / 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) | |
| Combiner | Join / GroupJoin | Jointure entre deux séquences |
LeftJoin / RightJoin .NET 10 · FullJoin .NET 11 | Jointures externes de première classe, enfin | |
| Matérialiser | ToList() / ToArray() | Exécute et met le résultat en mémoire |
Chunk(n) | Découpe en paquets de n (traitement par lots) |
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.
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.
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.
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 copieIEnumerable 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émoire | un arbre d'expression analysable |
| Le filtrage a lieu… | dans ton application (C#) | dans la base de données (SQL) |
| Données transférées | tout, puis on jette | seulement le résultat filtré |
| Sur quoi ? | Listes, tableaux, fichiers | EF Core, sources distantes |
// 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 12var 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 colonnesSur 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
| À éviter | Pourquoi | À la place |
|---|---|---|
liste.Count() > 0 | Peut parcourir toute la séquence | liste.Any() — s'arrête au premier |
liste.Count() sur une List | Méthode LINQ là où une propriété existe | liste.Count (sans parenthèses) |
.First() sans certitude | InvalidOperationException si vide | .FirstOrDefault() + test de null |
.Single() par habitude | Lè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 jeter | Filtrer d'abord, trier ensuite |
Effets de bord dans Select | Ne s'exécute peut-être jamais (différé) | foreach explicite pour agir |
.ToList() au milieu d'une chaîne | Matérialise inutilement, coupe la traduction SQL | Une 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);
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 ».
- Chaque opérateur alloue un objet énumérateur et une closure. Sur un chemin appelé un million de fois par
seconde, une boucle
forgagne. Partout ailleurs, la lisibilité gagne. Mesure d'abord (ch. 19). OrderBymatérialise nécessairement toute la séquence (impossible de trier en flux).Count()exploiteICollection.Countquand il peut ;Any()est toujours en temps constant.AsParallel()(PLINQ) parallélise sur plusieurs cœurs : gain réel seulement si le travail par élément est substantiel et sans effets de bord.System.Linq.AsyncEnumerable(intégré depuis .NET 10) apporte les mêmes opérateurs surIAsyncEnumerable<T>:await source.Where(...).ToListAsync().
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 ?
foreach,
ToList(), Count()…3. Dans une requête EF Core, quel est l'effet de .AsEnumerable() placé au début ?
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…
static abstract des interfaces
(C# 11) : les opérateurs +, * et T.Zero deviennent utilisables sur un
type générique.Fiches de révision
List<T>. Par clé : Dictionary. Appartenance/unicité : HashSet.IEnumerable<T> (le plus général). Et renvoyer le plus précis.ToList, ToArray, Count, First, Any, Sum, foreach.Any() ou Count() > 0 ?Any() : il s'arrête au premier élément trouvé.yield return ?- Les génériques donnent la sécurité de type sans boxing :
List<T>, jamaisArrayList. - List par défaut, Dictionary pour l'accès par clé, HashSet pour l'appartenance.
- LINQ est différé : il ne s'exécute qu'à la lecture, et à chaque lecture.
- Filtre avant de trier ; matérialise une seule fois, à la fin.
- IQueryable = la base travaille. IEnumerable = ta mémoire travaille.