Chapitre 622 minIntermédiaire
Collections, génériques et LINQ : List, Dictionary
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- choisir entre
List<T>,Dictionary<TKey, TValue>etHashSet<T>; - écrire une méthode ou une classe générique ;
- filtrer, trier, regrouper et calculer avec LINQ ;
- éviter les pièges de
foreach, deFirstet de l'exécution différée.
4 exercices corrigés
Une collection est un objet qui contient d'autres objets : la liste des tickets, l'annuaire des postes par numéro d'inventaire. En C#, les collections courantes, List<T>, Dictionary<TKey, TValue> et HashSet<T>, sont génériques : le T entre chevrons est le type des éléments. LINQ (Language Integrated Query) permet ensuite de filtrer, trier, regrouper et calculer sur ces collections en une ligne. Ce chapitre présente les trois collections, la généricité et les opérateurs LINQ que tu utiliseras tout le temps.
Les exemples portent sur cette liste de tickets, dont l'énumération Priorite vaut Basse, Normale, Haute ou Critique, et Statut vaut Ouvert, EnCours ou Resolu :
enum Priorite { Basse, Normale, Haute, Critique }
enum Statut { Ouvert, EnCours, Resolu }
List<Ticket> tickets =
[
new Ticket(1, "Écran noir", Priorite.Haute, Statut.Resolu, "Comptabilité", 45),
new Ticket(2, "Imprimante bloquée", Priorite.Normale, Statut.Resolu, "Comptabilité", 20),
new Ticket(3, "Mot de passe expiré", Priorite.Basse, Statut.Resolu, "Commercial", 10),
new Ticket(4, "Serveur de fichiers inaccessible", Priorite.Critique, Statut.EnCours, "Logistique", 90),
new Ticket(5, "Odoo lent", Priorite.Normale, Statut.EnCours, "Commercial", 60),
new Ticket(6, "Nouveau poste à préparer", Priorite.Normale, Statut.Ouvert, "Juridique", 0),
new Ticket(7, "Wi-Fi instable en salle de réunion", Priorite.Haute, Statut.Ouvert, "Direction", 0),
];
La classe Ticket a un constructeur et des propriétés en lecture seule Numero, Titre, Priorite, Statut, Service et DureeMin. Une énumération (enum) est un type qui n'admet qu'une liste fermée de valeurs nommées : Priorite.Haute ne peut pas être mal orthographiée, contrairement à la chaîne "haute". Ses valeurs sont numérotées dans l'ordre (0, 1, 2, 3), ce qui permet de les comparer : Priorite.Haute > Priorite.Normale est vrai.
Tableau ou liste ?
Un tableau (Ticket[]) a une taille fixée à sa création. Une liste (List<Ticket>) grandit et rétrécit à la demande. En POO, tu manipuleras presque toujours des listes ; les tableaux servent pour des données de taille connue (les jours de la semaine, le résultat de Split).
Ticket[] | List<Ticket> | |
|---|---|---|
| Taille | fixe | variable |
| Nombre d'éléments | .Length | .Count |
| Ajouter | impossible | .Add(t) |
| Accès par position | t[0] | t[0] |
| Création | new Ticket[10] ou [t1, t2] | new List<Ticket>() ou [t1, t2] |
La liste List : ajouter, chercher, supprimer
Console.WriteLine($"Nombre : {tickets.Count}");
Console.WriteLine($"Premier : {tickets[0].Titre}");
Ticket? t5 = tickets.Find(t => t.Numero == 5);
Console.WriteLine($"Trouvé : {t5?.Titre}");
tickets.Add(new Ticket(8, "Souris HS", Priorite.Basse, Statut.Ouvert, "Logistique", 0));
tickets.RemoveAll(t => t.Numero == 8);
Console.WriteLine($"Après ajout puis suppression : {tickets.Count}");
Nombre : 7
Premier : Écran noir
Trouvé : Odoo lent
Après ajout puis suppression : 7
| Méthode ou propriété | Rôle |
|---|---|
Add(x), AddRange(autres), Insert(i, x) | ajouter à la fin, ajouter plusieurs, insérer à une position |
Remove(x), RemoveAt(i), RemoveAll(condition), Clear() | retirer un élément, une position, tous ceux qui vérifient la condition, tout |
Contains(x), IndexOf(x) | présence, position (ou −1) |
Find(condition), FindAll(condition), Exists(condition) | premier élément qui vérifie la condition (ou null), tous, au moins un |
Sort(), Reverse() | trier sur place (le type doit être comparable, chapitre 9), inverser |
Count, liste[i] | nombre d'éléments, accès par position (à partir de 0) |
La condition passée à Find ou RemoveAll est une expression lambda : t => t.Numero == 5 se lit « pour un ticket t, renvoie vrai si son numéro vaut 5 ». C'est une petite fonction anonyme, écrite comme une fonction fléchée en JavaScript.
Contains et Remove comparent les éléments avec Equals, c'est-à-dire, pour une classe ordinaire, par référence : tickets.Contains(new Ticket(1, …)) renvoie false même si un ticket numéro 1 existe. Le chapitre 11 montre comment changer ce comportement.
Parcourir sans modifier
foreach est la façon normale de parcourir une collection. Mais on ne peut pas modifier la liste pendant qu'on la parcourt avec foreach :
foreach (Ticket t in tickets)
{
if (t.Statut == Statut.Resolu)
{
tickets.Remove(t); // InvalidOperationException
}
}
Unhandled exception. System.InvalidOperationException: Collection was modified; enumeration operation may not execute.
Pour supprimer selon une condition, utilise RemoveAll(t => t.Statut == Statut.Resolu), qui renvoie le nombre d'éléments supprimés (3 ici). Si tu dois vraiment boucler, parcours la liste à l'envers avec for (int i = tickets.Count - 1; i >= 0; i--), ou construis une nouvelle liste.
Le dictionnaire Dictionary : retrouver par clé
Un dictionnaire associe des clés uniques à des valeurs, et retrouve une valeur par sa clé presque instantanément, quelle que soit la taille. C'est l'équivalent d'un objet JavaScript utilisé comme table, ou d'un tableau associatif PHP.
Dictionary<string, string> utilisateurs = new Dictionary<string, string>
{
["PC-0001"] = "Hélène Garnier",
["PC-0002"] = "Karim Morel",
};
utilisateurs["PC-0003"] = "Julie Lefèvre";
utilisateurs["PC-0002"] = "Sofiane Benali"; // remplace la valeur
Console.WriteLine($"PC-0002 → {utilisateurs["PC-0002"]}");
if (utilisateurs.TryGetValue("PC-0009", out string? nom))
{
Console.WriteLine(nom);
}
else
{
Console.WriteLine("PC-0009 : poste inconnu");
}
foreach (KeyValuePair<string, string> paire in utilisateurs)
{
Console.WriteLine($"{paire.Key} : {paire.Value}");
}
PC-0002 → Sofiane Benali
PC-0009 : poste inconnu
PC-0001 : Hélène Garnier
PC-0002 : Sofiane Benali
PC-0003 : Julie Lefèvre
| Opération | Effet |
|---|---|
d[cle] = valeur | ajoute, ou remplace si la clé existe |
d.Add(cle, valeur) | ajoute ; lève ArgumentException si la clé existe déjà |
d[cle] en lecture | renvoie la valeur ; lève KeyNotFoundException si la clé n'existe pas |
d.TryGetValue(cle, out var v) | renvoie true et la valeur, ou false sans planter |
d.GetValueOrDefault(cle) | la valeur, ou la valeur par défaut du type (0, null) |
d.ContainsKey(cle), d.Remove(cle) | tester, supprimer |
d.Keys, d.Values, d.Count | les clés, les valeurs, le nombre de paires |
LINQ construit un dictionnaire à partir d'une liste en une ligne : Dictionary<int, Ticket> index = tickets.ToDictionary(t => t.Numero); puis index[4].Titre donne « Serveur de fichiers inaccessible ».
L'ensemble HashSet : des éléments uniques
Un ensemble (HashSet<T>) ne contient jamais deux fois le même élément. Add renvoie false si l'élément est déjà présent. Il sert à dédoublonner et à tester l'appartenance très vite.
HashSet<string> services = new HashSet<string>();
foreach (Ticket t in tickets)
{
services.Add(t.Service);
}
Console.WriteLine($"{services.Count} services distincts"); // 5 services distincts
Console.WriteLine(services.Add("Commercial")); // False : déjà présent
Quelle collection choisir ?
| Besoin | Collection |
|---|---|
| Une suite d'éléments dans un ordre, avec doublons possibles | List<T> |
| Retrouver un élément par une clé unique (numéro, code, email) | Dictionary<TKey, TValue> |
| Des éléments sans doublon, tester la présence | HashSet<T> |
| Traiter dans l'ordre d'arrivée (file d'attente des tickets) | Queue<T> : Enqueue, Dequeue |
| Traiter le dernier arrivé d'abord (annuler la dernière action) | Stack<T> : Push, Pop |
| Exposer une collection sans permettre de la modifier | IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T> |
La généricité : du code pour tous les types
Sans généricité, il faudrait une classe ListeDeTickets, une classe ListeDePostes, une classe ListeDeLogiciels… toutes identiques à part le type. Ou une liste d'object, qui accepterait n'importe quoi et obligerait à convertir chaque élément lu. La généricité résout le problème : on écrit le code une fois avec un paramètre de type T, et on précise le type réel à l'utilisation. List<Ticket> n'accepte que des tickets, et le compilateur le vérifie.
Tu peux écrire tes propres méthodes et classes génériques :
static T PlusGrand<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
Console.WriteLine(PlusGrand(3, 8)); // 8
Console.WriteLine(PlusGrand("abricot", "banane")); // banane
Console.WriteLine(PlusGrand(new DateOnly(2026, 1, 5), new DateOnly(2025, 12, 31))); // 05/01/2026
La contrainte where T : IComparable<T> exige que T sache se comparer, sinon a.CompareTo(b) n'existerait pas. Le compilateur déduit T des arguments : pas besoin d'écrire PlusGrand<int>(3, 8).
Une classe générique fonctionne de la même façon. Ce petit dépôt range des objets par identifiant, quel que soit leur type, pourvu qu'ils aient un Id :
interface IIdentifiable
{
int Id { get; }
}
class Depot<T> where T : IIdentifiable
{
private readonly Dictionary<int, T> elements = new Dictionary<int, T>();
public int Nombre => elements.Count;
public void Ajouter(T element) => elements[element.Id] = element;
public T? TrouverParId(int id) => elements.TryGetValue(id, out T? e) ? e : default;
}
Depot<Ticket> depot = new Depot<Ticket>(); // Ticket implémente IIdentifiable
depot.Ajouter(tickets[0]);
depot.Ajouter(tickets[3]);
Console.WriteLine(depot.TrouverParId(4)?.Titre); // Serveur de fichiers inaccessible
default est la valeur par défaut de T : null pour une classe, 0 pour un int. L'interface IIdentifiable est expliquée au chapitre 9 ; ce dépôt annonce le patron Repository du chapitre 13.
| Contrainte | Signification |
|---|---|
where T : class | T est un type référence |
where T : struct | T est un type valeur |
where T : new() | T a un constructeur sans paramètre |
where T : Materiel | T est Materiel ou une classe dérivée |
where T : IComparable<T> | T implémente l'interface |
LINQ : interroger une collection
LINQ ajoute à toutes les collections des méthodes d'interrogation qui ressemblent à SQL. Chacune prend une lambda et renvoie une nouvelle séquence, ce qui permet de les enchaîner.
Console.WriteLine($"Non résolus : {tickets.Count(t => t.Statut != Statut.Resolu)}");
var urgents = tickets
.Where(t => t.Priorite >= Priorite.Haute && t.Statut != Statut.Resolu)
.OrderByDescending(t => t.Priorite)
.Select(t => $"#{t.Numero} {t.Titre} ({t.Priorite})");
foreach (string u in urgents)
{
Console.WriteLine(u);
}
Console.WriteLine($"Temps total passé : {tickets.Sum(t => t.DureeMin)} min");
Console.WriteLine($"Durée moyenne des résolus : {tickets.Where(t => t.Statut == Statut.Resolu).Average(t => t.DureeMin):0.0} min");
Console.WriteLine($"Un ticket critique ? {tickets.Any(t => t.Priorite == Priorite.Critique)}");
Non résolus : 4
#4 Serveur de fichiers inaccessible (Critique)
#7 Wi-Fi instable en salle de réunion (Haute)
Temps total passé : 225 min
Durée moyenne des résolus : 25,0 min
Un ticket critique ? True
Sans LINQ, compter les tickets non résolus demande une variable, une boucle et une condition. Avec LINQ, l'intention se lit directement.
| LINQ | Rôle | Équivalent SQL |
|---|---|---|
Where(t => …) | filtrer | WHERE |
Select(t => …) | transformer chaque élément | la liste du SELECT |
OrderBy, OrderByDescending, ThenBy | trier | ORDER BY … ASC/DESC |
Count(), Sum(), Average(), Min(), Max() | agréger | COUNT, SUM, AVG, MIN, MAX |
GroupBy(t => …) | regrouper | GROUP BY |
Distinct() | dédoublonner | DISTINCT |
Any(…), All(…) | au moins un, tous | EXISTS, NOT EXISTS (… NOT …) |
First(), FirstOrDefault(), Single() | un seul élément | LIMIT 1 |
Take(n), Skip(n) | les n premiers, sauter n | LIMIT, OFFSET |
MaxBy(t => …), MinBy(…) | l'élément qui a la plus grande ou la plus petite valeur | ORDER BY … LIMIT 1 |
ToList(), ToDictionary(), ToHashSet() | matérialiser le résultat |
Le parallèle avec SQL n'est pas un hasard : la même logique de requête s'applique, sur une base avec le cours SQL, sur une collection en mémoire avec LINQ. Il existe aussi une syntaxe de requête (from t in tickets where … select t), moins utilisée : ce cours emploie la syntaxe par méthodes, la plus courante dans le code professionnel.
Regrouper avec GroupBy
GroupBy répartit les éléments en groupes qui partagent une clé. Chaque groupe a une propriété Key et se parcourt comme une collection.
var parService = tickets
.GroupBy(t => t.Service)
.Select(g => new { Service = g.Key, Nombre = g.Count(), Minutes = g.Sum(t => t.DureeMin) })
.OrderByDescending(x => x.Nombre)
.ThenBy(x => x.Service);
foreach (var ligne in parService)
{
Console.WriteLine($"{ligne.Service,-14} {ligne.Nombre} ticket(s) {ligne.Minutes,4} min");
}
Commercial 2 ticket(s) 70 min
Comptabilité 2 ticket(s) 65 min
Direction 1 ticket(s) 0 min
Juridique 1 ticket(s) 0 min
Logistique 1 ticket(s) 90 min
new { Service = …, Nombre = … } crée un type anonyme : un petit objet en lecture seule, sans classe déclarée, pratique pour un résultat intermédiaire. Comme son type n'a pas de nom, la variable se déclare avec var.
First, Single, FirstOrDefault : choisir sans planter
| Méthode | Aucun élément | Plusieurs éléments |
|---|---|---|
First(condition) | lève InvalidOperationException | renvoie le premier |
FirstOrDefault(condition) | renvoie null (ou 0) | renvoie le premier |
Single(condition) | lève une exception | lève une exception |
SingleOrDefault(condition) | renvoie null | lève une exception |
Ticket? aucun = tickets.FirstOrDefault(t => t.Service == "RH");
Console.WriteLine($"Premier des RH : {aucun?.Numero.ToString() ?? "aucun"}"); // Premier des RH : aucun
Utilise FirstOrDefault quand l'absence est normale (chercher un ticket par son numéro saisi), First quand elle serait un bug, et Single quand l'unicité est une règle (un seul employé par email).
L'exécution différée
Une requête LINQ n'est pas exécutée quand on l'écrit, mais quand on la parcourt. Elle reflète donc l'état de la collection au moment du parcours.
List<int> nombres = [1, 2, 3];
IEnumerable<int> pairs = nombres.Where(n => n % 2 == 0);
nombres.Add(4);
Console.WriteLine(string.Join(", ", pairs)); // 2, 4 : le 4 ajouté après est vu
List<int> figes = nombres.Where(n => n % 2 == 0).ToList();
nombres.Add(6);
Console.WriteLine($"{string.Join(", ", pairs)} | {string.Join(", ", figes)}"); // 2, 4, 6 | 2, 4
ToList() exécute la requête immédiatement et fige le résultat. Appelle-le quand tu dois parcourir le résultat plusieurs fois ou le conserver : sinon, la requête est recalculée à chaque parcours.
Boucle ou LINQ ?
LINQ rend la plupart des recherches plus courtes et plus lisibles. Mais une boucle reste préférable quand le traitement a des effets (afficher, modifier des objets, s'arrêter à la première erreur) ou quand la logique devient difficile à lire en une chaîne. À l'examen sur papier, et quand le sujet est en Java ou en PHP, une boucle foreach claire est toujours acceptée : sache écrire les deux.
Exercices
Exercice 1 sur 4 Requêtes LINQ Réussi À revoir
Sur la liste tickets du chapitre, écris la requête LINQ et donne son résultat.
- Les titres des tickets ouverts, du plus grand numéro au plus petit.
- Le nombre de tickets du service Commercial.
- Le titre du ticket qui a demandé le plus de temps.
- Le nombre de tickets par priorité, de la plus basse à la plus haute.
- Les services qui ont au moins un ticket non résolu, sans doublon, par ordre alphabétique.
- La durée moyenne des tickets sur lesquels du temps a été passé.
Voir la réponseMasquer la réponse de l'exercice 1
tickets.Where(t => t.Statut == Statut.Ouvert).OrderByDescending(t => t.Numero).Select(t => t.Titre)donne « Wi-Fi instable en salle de réunion », puis « Nouveau poste à préparer ».tickets.Count(t => t.Service == "Commercial")donne 2.tickets.MaxBy(t => t.DureeMin)?.Titre, outickets.OrderByDescending(t => t.DureeMin).First().Titre, donne « Serveur de fichiers inaccessible » (90 min).tickets.GroupBy(t => t.Priorite).OrderBy(g => g.Key)puisg.Count()pour chaque groupe : Basse 1, Normale 3, Haute 2, Critique 1.tickets.Where(t => t.Statut != Statut.Resolu).Select(t => t.Service).Distinct().OrderBy(s => s)donne Commercial, Direction, Juridique, Logistique.tickets.Where(t => t.DureeMin > 0).Average(t => t.DureeMin)donne 45 : (45 + 20 + 10 + 90 + 60) / 5 = 225 / 5.
Tu avais trouvé ?
Exercice 2 sur 4 Compter avec un dictionnaire Réussi À revoir
Sans LINQ, écris une boucle qui remplit un Dictionary<string, int> donnant le nombre de tickets de chaque service. Que vaut l'entrée « Comptabilité » ?
Voir la réponseMasquer la réponse de l'exercice 2
Dictionary<string, int> parService = new Dictionary<string, int>();
foreach (Ticket t in tickets)
{
if (parService.ContainsKey(t.Service))
{
parService[t.Service]++;
}
else
{
parService[t.Service] = 1;
}
}
parService["Comptabilité"] vaut 2. Une écriture plus courte remplace le if par parService[t.Service] = parService.GetValueOrDefault(t.Service) + 1; : pour une clé absente, GetValueOrDefault renvoie 0. Avec LINQ : tickets.GroupBy(t => t.Service).ToDictionary(g => g.Key, g => g.Count()).
Tu avais trouvé ?
Exercice 3 sur 4 Écrire une méthode générique Réussi À revoir
Écris la méthode générique Derniers<T>(List<T> liste, int n) qui renvoie une nouvelle liste contenant les n derniers éléments, ou toute la liste si elle en contient moins de n. Que renvoient Derniers([3, 8, 5], 2) et Derniers(["a", "b", "c"], 5) ?
Voir la réponseMasquer la réponse de l'exercice 3
static List<T> Derniers<T>(List<T> liste, int n)
{
List<T> resultat = new List<T>();
int debut = Math.Max(0, liste.Count - n);
for (int i = debut; i < liste.Count; i++)
{
resultat.Add(liste[i]);
}
return resultat;
}
Derniers([3, 8, 5], 2) renvoie [8, 5] ; Derniers(["a", "b", "c"], 5) renvoie ["a", "b", "c"] grâce au Math.Max(0, …), qui évite un indice négatif. Aucune contrainte n'est nécessaire : la méthode ne fait que copier des éléments, quel que soit leur type. En LINQ : liste.TakeLast(n).ToList().
Tu avais trouvé ?
Exercice 4 sur 4 Supprimer pendant un parcours Réussi À revoir
Ce code doit retirer de la liste les tickets résolus. Il plante. Explique pourquoi et propose deux corrections.
foreach (Ticket t in tickets)
{
if (t.Statut == Statut.Resolu)
{
tickets.Remove(t);
}
}
Voir la réponseMasquer la réponse de l'exercice 4
foreach parcourt la liste avec un énumérateur, qui détecte toute modification de la liste pendant le parcours et lève InvalidOperationException (« Collection was modified »). Deux corrections :
// 1. La méthode prévue pour ça
int nb = tickets.RemoveAll(t => t.Statut == Statut.Resolu); // nb vaut 3
// 2. Une boucle for à l'envers : supprimer ne décale que les éléments déjà vus
for (int i = tickets.Count - 1; i >= 0; i--)
{
if (tickets[i].Statut == Statut.Resolu)
{
tickets.RemoveAt(i);
}
}
Une boucle for à l'endroit serait fausse sans planter : après une suppression, l'élément suivant prend l'indice courant et i++ le saute.
Tu avais trouvé ?