C# : LINQ
Where, Select, OrderBy, GroupBy, exécution différée - les requêtes intégrées au langage C#.
C# : LINQ
LINQ (Language Integrated Query) permet d'écrire des requêtes directement en C# pour filtrer, trier, transformer et regrouper des données. Ça fonctionne sur n'importe quelle collection : listes, tableaux, résultats de base de données, XML, JSON...
C'est l'une des fonctionnalités les plus puissantes de C#. Une fois que tu l'as adopté, tu ne peux plus t'en passer.
# Deux syntaxes
LINQ existe sous deux formes. Elles font exactement la même chose :
Syntaxe méthode (la plus courante)
List<int> nombres = new List<int> { 1, 5, 3, 8, 2, 9, 4, 7, 6 };
List<int> pairs = nombres
.Where(n => n % 2 == 0)
.OrderBy(n => n)
.ToList();
// pairs = { 2, 4, 6, 8 }List<int> nombres = new List<int> { 1, 5, 3, 8, 2, 9, 4, 7, 6 }; List<int> pairs = nombres .Where(n => n % 2 == 0) .OrderBy(n => n) .ToList();// pairs = { 2, 4, 6, 8 }Syntaxe query (ressemble au SQL)
List<int> pairs = (
from n in nombres
where n % 2 == 0
orderby n
select n
).ToList();List<int> pairs = ( from n in nombres where n % 2 == 0 orderby n select n).ToList();Les deux produisent le même résultat. La syntaxe méthode est plus utilisée dans la pratique parce qu'elle est plus flexible et se chaîne mieux.
# Les opérateurs essentiels
Where - filtrer
Garde uniquement les éléments qui respectent une condition :
List<string> villes = new List<string> { "Paris", "Lyon", "Marseille", "Lille", "Bordeaux" };
List<string> grandesVilles = villes
.Where(v => v.Length > 4)
.ToList();
// { "Paris", "Marseille", "Lille", "Bordeaux" }List<string> villes = new List<string> { "Paris", "Lyon", "Marseille", "Lille", "Bordeaux" }; List<string> grandesVilles = villes .Where(v => v.Length > 4) .ToList();// { "Paris", "Marseille", "Lille", "Bordeaux" }Select - transformer
Projette chaque élément dans une nouvelle forme :
List<string> majuscules = villes
.Select(v => v.ToUpper())
.ToList();
// { "PARIS", "LYON", "MARSEILLE", "LILLE", "BORDEAUX" }List<string> majuscules = villes .Select(v => v.ToUpper()) .ToList();// { "PARIS", "LYON", "MARSEILLE", "LILLE", "BORDEAUX" }On peut aussi projeter vers un type anonyme ou un autre objet :
var infos = villes
.Select(v => new { Nom = v, Longueur = v.Length })
.ToList();
// { { Nom = "Paris", Longueur = 5 }, { Nom = "Lyon", Longueur = 4 }, ... }var infos = villes .Select(v => new { Nom = v, Longueur = v.Length }) .ToList();// { { Nom = "Paris", Longueur = 5 }, { Nom = "Lyon", Longueur = 4 }, ... }OrderBy / OrderByDescending - trier
List<string> triees = villes
.OrderBy(v => v) // alphabétique
.ToList();
List<string> inversees = villes
.OrderByDescending(v => v.Length) // par longueur décroissante
.ToList();List<string> triees = villes .OrderBy(v => v) // alphabétique .ToList(); List<string> inversees = villes .OrderByDescending(v => v.Length) // par longueur décroissante .ToList();Pour un tri secondaire, utilise ThenBy :
List<Personne> triees = personnes
.OrderBy(p => p.Nom)
.ThenBy(p => p.Prenom)
.ToList();List<Personne> triees = personnes .OrderBy(p => p.Nom) .ThenBy(p => p.Prenom) .ToList();First / FirstOrDefault - récupérer un élément
string premiere = villes.First(); // "Paris"
string premierL = villes.First(v => v.StartsWith("L")); // "Lyon"
// FirstOrDefault retourne null (ou la valeur par défaut) si rien ne matche
string? aucune = villes.FirstOrDefault(v => v.StartsWith("Z")); // nullstring premiere = villes.First(); // "Paris"string premierL = villes.First(v => v.StartsWith("L")); // "Lyon" // FirstOrDefault retourne null (ou la valeur par défaut) si rien ne matchestring? aucune = villes.FirstOrDefault(v => v.StartsWith("Z")); // null First() lève une InvalidOperationException si la collection est vide ou si aucun élément ne matche. Utilise FirstOrDefault() quand le résultat peut ne pas exister.
Any / All - vérifier une condition
bool auMoinsUn = villes.Any(v => v.StartsWith("P")); // true
bool tousLongs = villes.All(v => v.Length > 3); // true
bool estVide = villes.Any(); // true (la liste n'est pas vide)bool auMoinsUn = villes.Any(v => v.StartsWith("P")); // truebool tousLongs = villes.All(v => v.Length > 3); // truebool estVide = villes.Any(); // true (la liste n'est pas vide)Count - compter
int total = villes.Count(); // 5
int commencantParL = villes.Count(v => v.StartsWith("L")); // 2int total = villes.Count(); // 5int commencantParL = villes.Count(v => v.StartsWith("L")); // 2# Opérateurs avancés
GroupBy - regrouper
List<Personne> personnes = GetPersonnes();
var parVille = personnes
.GroupBy(p => p.Ville)
.ToList();
foreach (var groupe in parVille)
{
Console.WriteLine($"{groupe.Key} : {groupe.Count()} personnes");
foreach (Personne p in groupe)
{
Console.WriteLine($" - {p.Nom}");
}
}List<Personne> personnes = GetPersonnes(); var parVille = personnes .GroupBy(p => p.Ville) .ToList(); foreach (var groupe in parVille){ Console.WriteLine($"{groupe.Key} : {groupe.Count()} personnes"); foreach (Personne p in groupe) { Console.WriteLine($" - {p.Nom}"); }}GroupBy retourne des IGrouping<TKey, TElement> - chaque groupe a une Key (la valeur de regroupement) et contient les éléments du groupe.
SelectMany - aplatir
Quand chaque élément contient une collection, SelectMany les aplatit en une seule :
List<Classe> classes = GetClasses();
// Chaque classe a une liste d'élèves
List<Eleve> tousLesEleves = classes
.SelectMany(c => c.Eleves)
.ToList();List<Classe> classes = GetClasses(); // Chaque classe a une liste d'élèvesList<Eleve> tousLesEleves = classes .SelectMany(c => c.Eleves) .ToList();Sans SelectMany, tu aurais une List<List<Eleve>>. Avec, tu as directement une List<Eleve>.
Distinct - dédupliquer
List<string> villes = new List<string> { "Paris", "Lyon", "Paris", "Marseille", "Lyon" };
List<string> uniques = villes
.Distinct()
.ToList();
// { "Paris", "Lyon", "Marseille" }List<string> villes = new List<string> { "Paris", "Lyon", "Paris", "Marseille", "Lyon" }; List<string> uniques = villes .Distinct() .ToList();// { "Paris", "Lyon", "Marseille" }Take / Skip - paginer
List<int> page = nombres
.Skip(10) // saute les 10 premiers
.Take(5) // prend les 5 suivants
.ToList();List<int> page = nombres .Skip(10) // saute les 10 premiers .Take(5) // prend les 5 suivants .ToList();Aggregate - réduire
Applique une fonction d'accumulation sur toute la collection :
List<int> nombres = new List<int> { 1, 2, 3, 4, 5 };
int somme = nombres.Aggregate((acc, n) => acc + n); // 15
string concat = villes.Aggregate((acc, v) => $"{acc}, {v}"); // "Paris, Lyon, Marseille, ..."List<int> nombres = new List<int> { 1, 2, 3, 4, 5 }; int somme = nombres.Aggregate((acc, n) => acc + n); // 15string concat = villes.Aggregate((acc, v) => $"{acc}, {v}"); // "Paris, Lyon, Marseille, ..." Pour la somme et la moyenne, utilise directement nombres.Sum() et nombres.Average() - pas besoin d'Aggregate.
# Exécution différée (deferred execution)
C'est le concept le plus important de LINQ et celui qui piège le plus de développeurs.
Le piège
List<int> nombres = new List<int> { 1, 2, 3, 4, 5 };
IEnumerable<int> pairs = nombres.Where(n => n % 2 == 0);
// Ici, RIEN ne s'est exécuté. pairs est juste une "requête en attente".
nombres.Add(6);
List<int> resultat = pairs.ToList();
// Maintenant la requête s'exécute - et 6 est inclus !
// resultat = { 2, 4, 6 }List<int> nombres = new List<int> { 1, 2, 3, 4, 5 }; IEnumerable<int> pairs = nombres.Where(n => n % 2 == 0);// Ici, RIEN ne s'est exécuté. pairs est juste une "requête en attente". nombres.Add(6); List<int> resultat = pairs.ToList();// Maintenant la requête s'exécute - et 6 est inclus !// resultat = { 2, 4, 6 }Where, Select, OrderBy, GroupBy ne s'exécutent pas immédiatement. Ils retournent un IEnumerable<T> qui représente la requête. La requête ne s'exécute que quand on consomme le résultat (ToList(), ToArray(), foreach, First(), Count(), etc.).
Pourquoi c'est comme ça ?
- Performance - si tu chaînes 5 opérations, LINQ ne parcourt la collection qu'une seule fois au moment de la consommation, pas 5 fois
- Composabilité - tu peux construire une requête par morceaux avant de l'exécuter
Quand matérialiser ?
Appelle ToList() ou ToArray() quand :
- Tu veux "figer" le résultat à un instant donné
- Tu vas parcourir le résultat plusieurs fois (sinon la requête se ré-exécute à chaque fois)
- Tu veux passer le résultat à du code qui attend une
List<T>
// MAL - la requête se ré-exécute 3 fois
IEnumerable<int> pairs = nombres.Where(n => n % 2 == 0);
Console.WriteLine(pairs.Count()); // exécution 1
Console.WriteLine(pairs.First()); // exécution 2
Console.WriteLine(pairs.Last()); // exécution 3
// BIEN - matérialisé une seule fois
List<int> pairs = nombres.Where(n => n % 2 == 0).ToList();
Console.WriteLine(pairs.Count); // lecture de la liste
Console.WriteLine(pairs.First()); // lecture de la liste
Console.WriteLine(pairs.Last()); // lecture de la liste// MAL - la requête se ré-exécute 3 foisIEnumerable<int> pairs = nombres.Where(n => n % 2 == 0);Console.WriteLine(pairs.Count()); // exécution 1Console.WriteLine(pairs.First()); // exécution 2Console.WriteLine(pairs.Last()); // exécution 3 // BIEN - matérialisé une seule foisList<int> pairs = nombres.Where(n => n % 2 == 0).ToList();Console.WriteLine(pairs.Count); // lecture de la listeConsole.WriteLine(pairs.First()); // lecture de la listeConsole.WriteLine(pairs.Last()); // lecture de la liste# Chaîner les opérations
La vraie puissance de LINQ, c'est le chaînage. Un exemple concret :
List<Commande> commandes = GetCommandes();
var rapport = commandes
.Where(c => c.Date.Year == 2025) // commandes de 2025
.Where(c => c.Montant > 100) // montant > 100
.GroupBy(c => c.Client.Nom) // grouper par client
.Select(g => new // projeter
{
Client = g.Key,
NbCommandes = g.Count(),
Total = g.Sum(c => c.Montant)
})
.OrderByDescending(x => x.Total) // trier par total
.Take(10) // top 10
.ToList();
foreach (var ligne in rapport)
{
Console.WriteLine($"{ligne.Client} : {ligne.NbCommandes} commandes, {ligne.Total} euros");
}List<Commande> commandes = GetCommandes(); var rapport = commandes .Where(c => c.Date.Year == 2025) // commandes de 2025 .Where(c => c.Montant > 100) // montant > 100 .GroupBy(c => c.Client.Nom) // grouper par client .Select(g => new // projeter { Client = g.Key, NbCommandes = g.Count(), Total = g.Sum(c => c.Montant) }) .OrderByDescending(x => x.Total) // trier par total .Take(10) // top 10 .ToList(); foreach (var ligne in rapport){ Console.WriteLine($"{ligne.Client} : {ligne.NbCommandes} commandes, {ligne.Total} euros");}En 10 lignes, tu as filtré, groupé, projeté, trié et paginé. L'équivalent en boucles for ferait 40+ lignes.
# Pièges courants
Modification pendant l'itération
// PLANTE - InvalidOperationException
foreach (string ville in villes.Where(v => v.StartsWith("L")))
{
villes.Remove(ville); // on modifie la collection pendant qu'on l'itère
}
// BIEN - matérialiser d'abord
List<string> aSupprimer = villes.Where(v => v.StartsWith("L")).ToList();
foreach (string ville in aSupprimer)
{
villes.Remove(ville);
}// PLANTE - InvalidOperationExceptionforeach (string ville in villes.Where(v => v.StartsWith("L"))){ villes.Remove(ville); // on modifie la collection pendant qu'on l'itère} // BIEN - matérialiser d'abordList<string> aSupprimer = villes.Where(v => v.StartsWith("L")).ToList();foreach (string ville in aSupprimer){ villes.Remove(ville);}Null dans la collection
List<string?> noms = new List<string?> { "Alice", null, "Bob" };
// PLANTE - NullReferenceException sur le null
List<string> majuscules = noms.Select(n => n.ToUpper()).ToList();
// BIEN - filtrer les null d'abord
List<string> majuscules = noms
.Where(n => n != null)
.Select(n => n!.ToUpper())
.ToList();List<string?> noms = new List<string?> { "Alice", null, "Bob" }; // PLANTE - NullReferenceException sur le nullList<string> majuscules = noms.Select(n => n.ToUpper()).ToList(); // BIEN - filtrer les null d'abordList<string> majuscules = noms .Where(n => n != null) .Select(n => n!.ToUpper()) .ToList();# La suite
Découvre aussi les autres piliers de la série :
- Les exceptions - try/catch, exceptions custom, bonnes pratiques
- La mémoire - stack vs heap, value types vs reference types
- Les objets - classes, héritage, polymorphisme