Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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> et HashSet<T> ;
  • écrire une méthode ou une classe générique ;
  • filtrer, trier, regrouper et calculer avec LINQ ;
  • éviter les pièges de foreach, de First et 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 :

csharp
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>
Taillefixevariable
Nombre d'éléments.Length.Count
Ajouterimpossible.Add(t)
Accès par positiont[0]t[0]
Créationnew Ticket[10] ou [t1, t2]new List<Ticket>() ou [t1, t2]

La liste List : ajouter, chercher, supprimer

csharp
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}");
text
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 :

csharp
foreach (Ticket t in tickets)
{
    if (t.Statut == Statut.Resolu)
    {
        tickets.Remove(t);   // InvalidOperationException
    }
}
text
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.

csharp
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}");
}
text
PC-0002 → Sofiane Benali
PC-0009 : poste inconnu
PC-0001 : Hélène Garnier
PC-0002 : Sofiane Benali
PC-0003 : Julie Lefèvre
OpérationEffet
d[cle] = valeurajoute, ou remplace si la clé existe
d.Add(cle, valeur)ajoute ; lève ArgumentException si la clé existe déjà
d[cle] en lecturerenvoie 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.Countles 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.

csharp
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 ?

BesoinCollection
Une suite d'éléments dans un ordre, avec doublons possiblesList<T>
Retrouver un élément par une clé unique (numéro, code, email)Dictionary<TKey, TValue>
Des éléments sans doublon, tester la présenceHashSet<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 modifierIReadOnlyList<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 :

csharp
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 :

csharp
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;
}
csharp
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.

ContrainteSignification
where T : classT est un type référence
where T : structT est un type valeur
where T : new()T a un constructeur sans paramètre
where T : MaterielT 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.

csharp
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)}");
text
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.

LINQRôleÉquivalent SQL
Where(t => …)filtrerWHERE
Select(t => …)transformer chaque élémentla liste du SELECT
OrderBy, OrderByDescending, ThenBytrierORDER BY … ASC/DESC
Count(), Sum(), Average(), Min(), Max()agrégerCOUNT, SUM, AVG, MIN, MAX
GroupBy(t => …)regrouperGROUP BY
Distinct()dédoublonnerDISTINCT
Any(…), All(…)au moins un, tousEXISTS, NOT EXISTS (… NOT …)
First(), FirstOrDefault(), Single()un seul élémentLIMIT 1
Take(n), Skip(n)les n premiers, sauter nLIMIT, OFFSET
MaxBy(t => …), MinBy(…)l'élément qui a la plus grande ou la plus petite valeurORDER 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.

csharp
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");
}
text
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éthodeAucun élémentPlusieurs éléments
First(condition)lève InvalidOperationExceptionrenvoie le premier
FirstOrDefault(condition)renvoie null (ou 0)renvoie le premier
Single(condition)lève une exceptionlève une exception
SingleOrDefault(condition)renvoie nulllève une exception
csharp
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.

csharp
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.

  1. Les titres des tickets ouverts, du plus grand numéro au plus petit.
  2. Le nombre de tickets du service Commercial.
  3. Le titre du ticket qui a demandé le plus de temps.
  4. Le nombre de tickets par priorité, de la plus basse à la plus haute.
  5. Les services qui ont au moins un ticket non résolu, sans doublon, par ordre alphabétique.
  6. La durée moyenne des tickets sur lesquels du temps a été passé.
Voir la réponseMasquer la réponse de l'exercice 1
  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 ».
  2. tickets.Count(t => t.Service == "Commercial") donne 2.
  3. tickets.MaxBy(t => t.DureeMin)?.Titre, ou tickets.OrderByDescending(t => t.DureeMin).First().Titre, donne « Serveur de fichiers inaccessible » (90 min).
  4. tickets.GroupBy(t => t.Priorite).OrderBy(g => g.Key) puis g.Count() pour chaque groupe : Basse 1, Normale 3, Haute 2, Critique 1.
  5. tickets.Where(t => t.Statut != Statut.Resolu).Select(t => t.Service).Distinct().OrderBy(s => s) donne Commercial, Direction, Juridique, Logistique.
  6. 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
csharp
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
csharp
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.

csharp
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 :

csharp
// 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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Héritage en C# : classe mère et classe filleRevoir le sommaire du cours