C# · Le langage

Collections et LINQ

IEnumerable contre IQueryable, exécution différée, opérateurs.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 17 min

Choisir une collection, c'est choisir ce qu'une opération coûte ; choisir le type d'un paramètre, c'est choisir ce que la méthode promet. LINQ ajoute une troisième question, celle du moment : une requête ne s'exécute pas là où elle s'écrit, mais là où on la parcourt, et parfois ailleurs qu'en mémoire. Ce cours sert quand une méthode relit trois fois un fichier, quand un filtre voit une donnée ajoutée après qu'il a été écrit, ou quand une requête en base rapatrie une table entière.

Les interfaces de collection, et ce que chacune promet

Les interfaces génériques forment deux familles parallèles au-dessus d'IEnumerable<T>. D'un côté, la lecture : IReadOnlyCollection<T> ajoute Count, IReadOnlyList<T> l'accès par index. De l'autre, l'écriture : ICollection<T> ajoute Count, Contains, Add, Remove et Clear, IList<T> l'insertion et l'accès par index.

InterfacePrometNe promet pas
IEnumerable<T>un parcours vers l'avantun nombre, un second parcours identique, un coût
IReadOnlyCollection<T>Count, en plusl'accès par index
IReadOnlyList<T>l'indexeur en lecturel'immuabilité
ICollection<T>Count, Contains, Add, Remove, Clearque l'écriture réussisse (IsReadOnly)
IList<T>l'indexeur en lecture et écriture, Insert, RemoveAtd'être un IReadOnlyList<T>

La dernière ligne surprend : en .NET 10, IList<T> n'hérite toujours pas d'IReadOnlyList<T>. Les interfaces en lecture seule sont arrivées avec .NET Framework 4.5, bien après les interfaces modifiables, et n'ont pas été insérées dans leur hiérarchie. List<T> et les tableaux implémentent les deux familles, mais une variable typée IList<T> ne se passe pas là où l'on attend un IReadOnlyList<T>.

La règle de choix suit les guides de conception de .NET : un paramètre prend le type le moins spécialisé qui suffit, parce qu'il élargit les appelants possibles et documente ce que la méthode fait de la collection. Un type de retour peut être plus riche, pour donner à l'appelant ce dont il a besoin, mais il engage l'implémentation : dans une API publique, les mêmes guides écartent List<T> et recommandent ReadOnlyCollection<T> pour une collection en lecture seule, afin de pouvoir en changer l'implémentation plus tard.

using System;
using System.Collections.Generic;

static class Promesses
{
    // Un seul passage, vers l'avant : tout ce qui s'enumere convient, y compris
    // un resultat de LINQ ou une methode yield.
    public static decimal Total(IEnumerable<decimal> montants)
    {
        var total = 0m;
        foreach (var montant in montants)
        {
            total += montant;
        }

        return total;
    }

    // La mediane exige Count et l'acces par index : IReadOnlyList<T> promet
    // exactement cela, et rien de plus.
    public static decimal Mediane(IReadOnlyList<decimal> tries)
    {
        if (tries.Count == 0)
        {
            throw new ArgumentException("Serie vide.", nameof(tries));
        }

        var milieu = tries.Count / 2;
        return tries.Count % 2 == 1 ? tries[milieu] : (tries[milieu - 1] + tries[milieu]) / 2;
    }

    // ICollection<T> ajoute Add, Remove et Clear : la signature annonce une ecriture.
    public static void AjouterFraisDePort(ICollection<decimal> lignes) => lignes.Add(5m);

    public static void Executer()
    {
        decimal[] tableau = [12m, 30m, 45m];
        var liste = new List<decimal> { 12m, 30m, 50m, 60m };

        Console.WriteLine(Total(tableau));   // 87
        Console.WriteLine(Mediane(tableau)); // 30
        Console.WriteLine(Mediane(liste));   // 40

        AjouterFraisDePort(liste);
        Console.WriteLine(liste.Count);      // 5

        // Un tableau implemente ICollection<T> : l'appel compile, puis echoue.
        try
        {
            AjouterFraisDePort(tableau);
        }
        catch (NotSupportedException ex)
        {
            // Les messages d'exception du runtime .NET 10 sont en anglais.
            Console.WriteLine(ex.Message);   // Collection was of a fixed size.
        }

        // IList<T> n'herite pas d'IReadOnlyList<T> : les deux familles sont
        // paralleles, et List<T> implemente les deux.
        IList<decimal> ecrivable = liste;
        // Mediane(ecrivable); // CS1503 : conversion impossible de
        //                     // IList<decimal> en IReadOnlyList<decimal>
        Console.WriteLine(ecrivable.Count);  // 5
    }
}

Le tableau passé à AjouterFraisDePort montre le prix d'une interface trop large : ICollection<T> ne garantit pas que Add fonctionne, et IsReadOnly est le seul avertissement. De l'autre côté, un IReadOnlyList<T> ne garantit pas que rien ne change : c'est une vue sur la même liste, et l'appelant peut la reconvertir.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;

sealed class Panier
{
    private readonly List<string> _articles = [];

    // Le type de retour promet la lecture, pas l'immuabilite : c'est la meme
    // liste, vue a travers une interface plus etroite.
    public IReadOnlyList<string> Articles => _articles;

    // Une enveloppe ne se laisse pas reconvertir en List<string>.
    public ReadOnlyCollection<string> ArticlesProteges => _articles.AsReadOnly();

    public void Ajouter(string article) => _articles.Add(article);
}

static class LectureSeule
{
    public static void Executer()
    {
        var panier = new Panier();
        panier.Ajouter("stylo");

        var vue = panier.Articles;
        panier.Ajouter("cahier");
        Console.WriteLine(vue.Count); // 2 : la vue suit la liste, ce n'est pas une copie

        // Rien n'empeche un appelant de reconvertir la vue vers son type reel.
        ((List<string>)panier.Articles).Clear();
        Console.WriteLine(panier.Articles.Count); // 0

        // L'enveloppe implemente IList<string>, mais refuse toute ecriture.
        try
        {
            ((IList<string>)panier.ArticlesProteges).Add("gomme");
        }
        catch (NotSupportedException ex)
        {
            Console.WriteLine(ex.Message); // Collection is read-only.
        }
    }
}

AsReadOnly ferme la porte de la conversion, pas celle du propriétaire, qui continue d'écrire dans la liste sous l'enveloppe. Une collection réellement immuable se trouve dans System.Collections.Immutable : ImmutableArray<T> ou ImmutableList<T>, où chaque modification rend une nouvelle instance.

List, Dictionary, HashSet : ce que coûte chaque opération

OpérationList<T>Dictionary<TKey, TValue>HashSet<T>
AjoutO(1) amorti, en finO(1) en moyenneO(1) en moyenne
Recherche d'un élément (Contains, ContainsKey)O(n), parcours linéaireO(1) en moyenne, par la cléO(1) en moyenne
Recherche d'une valeur hors clé (ContainsValue)sans objetO(n), parcours linéairesans objet
Accès par positionO(1)aucunaucun
Insertion ou suppression en têteO(n), tout se décalesans objetsans objet
Ordre d'énumérationcelui des positionsnon défininon défini

« En moyenne » suppose des empreintes bien réparties : si beaucoup d'éléments partagent la même, la recherche dans un ensemble ou un dictionnaire dégénère en parcours, O(n) dans le pire cas. List<T> est un tableau qu'elle remplace par un plus grand quand il déborde. « Amorti » veut dire que la plupart des ajouts ne coûtent qu'une écriture, et que quelques-uns recopient tout. Comme la capacité double à chaque fois, les recopies d'une liste de n éléments totalisent moins de 2n écritures : un coût constant par ajout, en moyenne sur la suite. Quand la taille finale est connue, la passer au constructeur supprime ces recopies.

using System;
using System.Collections.Generic;

static class Capacite
{
    public static void Executer()
    {
        var lignes = new List<int>();
        var capacites = new List<int> { lignes.Capacity };

        for (var i = 0; i < 20; i++)
        {
            lignes.Add(i);
            if (lignes.Capacity != capacites[^1])
            {
                capacites.Add(lignes.Capacity);
            }
        }

        // Un premier ajout alloue 4 cases ; ensuite, le tableau interne double a
        // chaque debordement, et tout y est recopie.
        Console.WriteLine(string.Join(" -> ", capacites)); // 0 -> 4 -> 8 -> 16 -> 32

        // Quand la taille est connue, la reserver evite toutes ces recopies.
        var reservee = new List<int>(20);
        for (var i = 0; i < 20; i++)
        {
            reservee.Add(i);
        }

        Console.WriteLine(reservee.Capacity); // 20
    }
}

Le piège le plus courant n'est pas la croissance, c'est la recherche. Un Contains sur une liste, dans une boucle ou un Where, multiplie les deux tailles : la méthode paraît linéaire et ne l'est pas. Ce code compare dix mille lignes à mille références, et compte les appels à Equals :

using System;
using System.Collections.Generic;
using System.Linq;

// Une reference d'article qui compte ses comparaisons.
readonly struct Sku : IEquatable<Sku>
{
    public static int Comparaisons;

    public Sku(int valeur) => Valeur = valeur;

    public int Valeur { get; }

    public bool Equals(Sku autre)
    {
        Comparaisons++;
        return Valeur == autre.Valeur;
    }

    public override bool Equals(object? obj) => obj is Sku autre && Equals(autre);

    public override int GetHashCode() => Valeur;
}

static class Retraits
{
    public static void Executer()
    {
        // 1 000 references retirees de la vente, 10 000 lignes a controler.
        var retirees = Enumerable.Range(0, 1_000).Select(i => new Sku(i * 10)).ToList();
        var lignes = Enumerable.Range(0, 10_000).Select(i => new Sku(i)).ToList();

        // List<T>.Contains parcourt la liste du debut : jusqu'a 1 000
        // comparaisons pour chaque ligne, et 1 000 pleines pour chaque absente.
        var bloquees = lignes.Count(ligne => retirees.Contains(ligne));

        Console.WriteLine(bloquees);         // 1000
        Console.WriteLine(Sku.Comparaisons); // 9500500
    }
}

Les mêmes données, avec les références retirées dans un ensemble :

using System;
using System.Collections.Generic;
using System.Linq;

// Une reference d'article qui compte ses comparaisons.
readonly struct Sku : IEquatable<Sku>
{
    public static int Comparaisons;

    public Sku(int valeur) => Valeur = valeur;

    public int Valeur { get; }

    public bool Equals(Sku autre)
    {
        Comparaisons++;
        return Valeur == autre.Valeur;
    }

    public override bool Equals(object? obj) => obj is Sku autre && Equals(autre);

    public override int GetHashCode() => Valeur;
}

static class Retraits
{
    public static void Executer()
    {
        // Meme donnees, mais les references retirees vont dans un HashSet<T>.
        var retirees = Enumerable.Range(0, 1_000).Select(i => new Sku(i * 10)).ToHashSet();
        var lignes = Enumerable.Range(0, 10_000).Select(i => new Sku(i)).ToList();

        // GetHashCode designe le seau ; Equals ne departage que les elements
        // de meme empreinte. Ici, il n'est appele que pour les vraies correspondances.
        var bloquees = lignes.Count(ligne => retirees.Contains(ligne));

        Console.WriteLine(bloquees);         // 1000
        Console.WriteLine(Sku.Comparaisons); // 1000
    }
}

Le gain repose entièrement sur GetHashCode : deux valeurs égales doivent rendre la même empreinte, et une empreinte qui change pendant que l'objet est dans l'ensemble le rend introuvable. Le cours Bases du langage montre ce que Equals et GetHashCode se doivent l'un à l'autre, et ce qu'un record en synthétise. Dictionary<TKey, TValue> repose sur le même mécanisme pour ses clés ; son indexeur en lecture lève sur une clé absente, là où TryGetValue rend false.

using System;
using System.Collections.Generic;

static class Totaux
{
    public static void Executer()
    {
        (string Client, decimal Montant)[] commandes =
            [("Durand", 120m), ("Martin", 80m), ("Durand", 30m)];

        var totaux = new Dictionary<string, decimal>();

        foreach (var (client, montant) in commandes)
        {
            // TryGetValue lit sans lever : cumul vaut 0 quand la cle est absente.
            // L'indexeur en ecriture ajoute ou remplace.
            totaux.TryGetValue(client, out var cumul);
            totaux[client] = cumul + montant;
        }

        Console.WriteLine(totaux["Durand"]); // 150

        // L'indexeur en lecture, lui, ne rend pas de valeur par defaut.
        try
        {
            _ = totaux["Petit"];
        }
        catch (KeyNotFoundException ex)
        {
            Console.WriteLine(ex.Message); // The given key 'Petit' was not present in the dictionary.
        }

        // Add leve sur une cle deja presente ; TryAdd rend false et ne touche a rien.
        Console.WriteLine(totaux.TryAdd("Martin", 0m)); // False
        Console.WriteLine(totaux["Martin"]);            // 80
    }
}

L'exécution différée

Where, Select et la plupart des opérateurs qui rendent une séquence ne parcourent rien : ils rendent un objet qui sait comment la produire. Le parcours a lieu au foreach, ou à l'appel d'un opérateur qui doit consommer la séquence pour rendre une valeur : ToList, Count, First, Sum. La requête voit donc l'état de la source au moment du parcours, et les variables capturées avec leur valeur de ce moment-là.

using System;
using System.Collections.Generic;
using System.Linq;

static class Differe
{
    public static void Executer()
    {
        var montants = new List<int> { 120, 40, 300 };
        var seuil = 100;

        // Rien n'est parcouru ici : la requete est une recette, qui capture la
        // liste et la variable seuil, pas leurs valeurs du moment.
        var grosses = montants.Where(m => m > seuil);

        montants.Add(500);
        seuil = 200;

        // Le parcours a lieu maintenant, avec l'etat de maintenant.
        Console.WriteLine(string.Join(", ", grosses)); // 300, 500

        // Une exception levee par le lambda attend, elle aussi, l'enumeration.
        var ratios = montants.Select(m => 1_000 / (m - 40));
        Console.WriteLine("requete construite");

        try
        {
            Console.WriteLine(ratios.Sum());
        }
        catch (DivideByZeroException)
        {
            Console.WriteLine("division par zero, levee par Sum");
        }
    }
}

Différé ne veut pas dire que tout se lit élément par élément. Where et Select laissent passer chaque élément dès qu'il arrive ; OrderBy, GroupBy ou Reverse ne peuvent rien rendre avant d'avoir tout lu, et mettent la source entière en mémoire dès le premier élément demandé.

using System;
using System.Collections.Generic;
using System.Linq;

static class Flux
{
    static IEnumerable<int> Lire()
    {
        foreach (var n in new[] { 3, 1, 2 })
        {
            Console.WriteLine($"lu {n}");
            yield return n;
        }
    }

    public static void Executer()
    {
        // Where et Select passent les elements un par un : lecture et
        // traitement s'entrelacent.
        foreach (var n in Lire().Where(n => n > 1).Select(n => n * 10))
        {
            Console.WriteLine($"recu {n}");
        }
        // lu 3, recu 30, lu 1, lu 2, recu 20

        // OrderBy ne peut rien rendre avant d'avoir tout vu : il lit la source
        // entiere au premier element demande.
        foreach (var n in Lire().OrderBy(n => n))
        {
            Console.WriteLine($"recu {n}");
        }
        // lu 3, lu 1, lu 2, recu 1, recu 2, recu 3
    }
}

L'énumération multiple

Une requête différée ne garde pas son résultat : chaque parcours la rejoue depuis la source. Sur une liste en mémoire, c'est du temps perdu ; sur un fichier, un appel réseau ou une requête en base, chaque opérateur relance la lecture. Ce code vérifie qu'il y a des relances, les compte, puis les envoie, et lit sa source trois fois :

using System;
using System.Collections.Generic;
using System.Linq;

sealed record Commande(string Reference, int JoursDeRetard);

static class Relances
{
    static int _enumerations;
    static int _lignesLues;

    // Tient la place d'une lecture couteuse : un fichier, un appel HTTP, une requete.
    static IEnumerable<Commande> Charger()
    {
        _enumerations++;
        for (var i = 1; i <= 1_000; i++)
        {
            _lignesLues++;
            yield return new Commande($"C-{i:0000}", i % 7);
        }
    }

    public static void Executer()
    {
        var enRetard = Charger().Where(c => c.JoursDeRetard > 3);
        var envoyees = 0;

        // Trois operateurs, trois enumerations : chacun relance Charger() depuis le debut.
        if (enRetard.Any())
        {
            Console.WriteLine($"{enRetard.Count()} relances a envoyer"); // 429 relances a envoyer
            foreach (var commande in enRetard)
            {
                envoyees++;
            }
        }

        Console.WriteLine(envoyees);      // 429
        Console.WriteLine(_enumerations); // 3
        Console.WriteLine(_lignesLues);   // 2004 : 4 pour Any, 1 000 pour Count, 1 000 pour foreach
    }
}

La correction consiste à matérialiser une fois, là où le résultat va servir plusieurs fois, et à travailler ensuite sur la liste :

using System;
using System.Collections.Generic;
using System.Linq;

sealed record Commande(string Reference, int JoursDeRetard);

static class Relances
{
    static int _enumerations;
    static int _lignesLues;

    // Tient la place d'une lecture couteuse : un fichier, un appel HTTP, une requete.
    static IEnumerable<Commande> Charger()
    {
        _enumerations++;
        for (var i = 1; i <= 1_000; i++)
        {
            _lignesLues++;
            yield return new Commande($"C-{i:0000}", i % 7);
        }
    }

    public static void Executer()
    {
        // ToList enumere une fois et garde le resultat. La suite travaille sur
        // une liste : Count est une propriete, et foreach ne relit rien.
        var enRetard = Charger().Where(c => c.JoursDeRetard > 3).ToList();
        var envoyees = 0;

        if (enRetard.Count > 0)
        {
            Console.WriteLine($"{enRetard.Count} relances a envoyer"); // 429 relances a envoyer
            foreach (var commande in enRetard)
            {
                envoyees++;
            }
        }

        Console.WriteLine(envoyees);      // 429
        Console.WriteLine(_enumerations); // 1
        Console.WriteLine(_lignesLues);   // 1000
    }
}

Matérialiser coûte la mémoire du résultat, et ne se justifie donc que devant plusieurs parcours. Une méthode qui reçoit un IEnumerable<T> et doit le parcourir deux fois a le même problème, sans savoir ce que coûte sa source : elle matérialise elle-même, ou demande un IReadOnlyCollection<T>. La règle d'analyse CA1851 signale ces énumérations multiples ; elle existe depuis .NET 7, mais n'est pas activée par défaut en .NET 10.

Les opérateurs essentiels

Chaque opérateur est une méthode d'extension sur IEnumerable<T>, et la plupart prennent un délégué — Count(), Distinct() ou ToList() s'en passent. Le cours Generics, delegates et events traite de Func et des lambdas. La syntaxe de requête, from … where … select, est traduite par le compilateur en ces mêmes appels.

using System;
using System.Collections.Generic;
using System.Linq;

sealed record Client(int Id, string Nom);

sealed record Ligne(string Sku, int Quantite);

sealed record Commande(int Numero, int ClientId, List<Ligne> Lignes);

static class Operateurs
{
    public static void Executer()
    {
        Client[] clients = [new(1, "Durand"), new(2, "Martin"), new(3, "Petit")];
        Commande[] commandes =
        [
            new(101, 1, [new("STYLO", 10), new("CAHIER", 2)]),
            new(102, 2, [new("STYLO", 1)]),
            new(103, 1, [new("GOMME", 4)]),
        ];

        // Where filtre, Select projette : une commande entre, un numero sort.
        var multiples = commandes.Where(c => c.Lignes.Count > 1).Select(c => c.Numero);
        Console.WriteLine(string.Join(", ", multiples)); // 101

        // SelectMany aplatit : chaque commande rend ses lignes, et le resultat
        // est une seule sequence de lignes, pas une sequence de listes.
        var stylos = commandes
            .SelectMany(c => c.Lignes)
            .Where(l => l.Sku == "STYLO")
            .Sum(l => l.Quantite);
        Console.WriteLine(stylos); // 11

        // GroupBy rend des IGrouping<TCle, TElement> : une cle, et ses elements.
        foreach (var groupe in commandes.GroupBy(c => c.ClientId))
        {
            Console.WriteLine($"client {groupe.Key} : {groupe.Count()} commande(s)");
        }
        // client 1 : 2 commande(s)
        // client 2 : 1 commande(s)

        // Join est une jointure interne : Petit, qui n'a rien commande, n'apparait pas.
        var avecNom = commandes.Join(
            clients,
            commande => commande.ClientId,
            client => client.Id,
            (commande, client) => $"{commande.Numero} {client.Nom}");
        Console.WriteLine(string.Join(", ", avecNom)); // 101 Durand, 102 Martin, 103 Durand
    }
}

GroupBy est un opérateur qui lit toute sa source avant de rendre le premier groupe. Join lit la seconde séquence entière dans une table indexée par le hachage de la clé, puis y cherche chaque élément de la première : son coût est celui d'un HashSet, pas d'une double boucle, et Equals n'est appelé que pour les clés de même empreinte. Les réductions rendent une valeur et déclenchent le parcours.

using System;
using System.Linq;

static class Reductions
{
    public static void Executer()
    {
        int[] montants = [120, 40, 300];
        int[] aucun = [];

        // Aggregate replie la sequence dans un accumulateur : ici le minimum et
        // le maximum en un seul passage, ce que Min et Max feraient en deux.
        var bornes = montants.Aggregate(
            (Min: int.MaxValue, Max: int.MinValue),
            (acc, m) => (Math.Min(acc.Min, m), Math.Max(acc.Max, m)));
        Console.WriteLine(bornes); // (40, 300)

        // Sans graine, le premier element sert de depart : une sequence vide leve.
        try
        {
            aucun.Aggregate((a, b) => a + b);
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine(ex.Message); // Sequence contains no elements
        }

        // Any s'arrete au premier element qui repond, All au premier qui ne repond pas.
        Console.WriteLine(montants.Any(m => m > 200)); // True
        Console.WriteLine(montants.All(m => m > 50));  // False

        // Sur une sequence vide, All est vrai : il n'existe aucun contre-exemple.
        Console.WriteLine(aucun.All(m => m > 1_000_000)); // True
    }
}

First et Single ne disent pas la même chose. First prend le premier et s'arrête ; Single affirme une unicité, et la vérifie en lisant jusqu'au bout, ou jusqu'à la deuxième correspondance. Choisir Single quand une donnée doit être unique, c'est transformer une incohérence en exception au lieu d'en prendre silencieusement une moitié.

using System;
using System.Linq;

static class Recherche
{
    public static void Executer()
    {
        string[] references = ["C-001", "C-002", "D-001"];

        // First rend le premier qui repond et ne lit pas la suite.
        Console.WriteLine(references.First(r => r.StartsWith('C'))); // C-001

        // Single affirme qu'il y en a exactement un : il lit jusqu'au bout, ou
        // jusqu'a la deuxieme correspondance, et leve s'il en trouve zero ou deux.
        Console.WriteLine(references.Single(r => r.StartsWith('D'))); // D-001

        try
        {
            references.Single(r => r.StartsWith('C'));
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine(ex.Message); // Sequence contains more than one matching element
        }

        // Les variantes OrDefault tolerent l'absence, et seulement elle :
        // SingleOrDefault leve toujours sur deux correspondances.
        Console.WriteLine(references.FirstOrDefault(r => r.StartsWith('Z')) is null);  // True
        Console.WriteLine(references.SingleOrDefault(r => r.StartsWith('Z')) is null); // True
    }
}

Les variantes OrDefault rendent default(T) quand rien ne répond. Pour un type référence, c'est null, qui ne se confond avec une donnée que si la séquence contient des null. Pour un type valeur, c'est 0, false ou un tuple à zéro, qui sont aussi des valeurs légitimes :

using System;
using System.Linq;

static class Stock
{
    public static void Executer()
    {
        (string Sku, int Quantite)[] stock = [("STYLO", 0), ("CAHIER", 12)];

        // Pour un type valeur, « rien trouve » rend default : ici (null, 0).
        var stylo = stock.FirstOrDefault(s => s.Sku == "STYLO").Quantite;
        var gomme = stock.FirstOrDefault(s => s.Sku == "GOMME").Quantite;

        // En rupture ou jamais reference : impossible de les distinguer.
        Console.WriteLine($"{stylo} / {gomme}"); // 0 / 0
    }
}

Projeter vers un type nullable avant de chercher rend à l'absence sa propre valeur. La surcharge de .NET 6 qui prend une valeur par défaut explicite ne vaut que si cette valeur ne peut pas être une donnée réelle.

using System;
using System.Linq;

static class Stock
{
    public static void Executer()
    {
        (string Sku, int Quantite)[] stock = [("STYLO", 0), ("CAHIER", 12)];

        // Projeter vers int? avant FirstOrDefault : l'absence devient null,
        // distincte d'une quantite nulle.
        int? Quantite(string sku) =>
            stock.Where(s => s.Sku == sku).Select(s => (int?)s.Quantite).FirstOrDefault();

        Console.WriteLine(Quantite("STYLO"));         // 0
        Console.WriteLine(Quantite("GOMME") is null); // True
    }
}

IEnumerable contre IQueryable : délégué ou arbre d'expression

Enumerable.Where prend un Func<T, bool> : du code compilé, qui ne peut qu'être exécuté. Queryable.Where prend un Expression<Func<T, bool>> : le compilateur, au lieu de produire du code, produit une description du lambda, qu'un programme peut lire. C'est ce qui permet à un fournisseur de traduire la condition dans une autre langue, SQL par exemple, et de l'exécuter ailleurs.

using System;
using System.Linq.Expressions;

sealed record Commande(string Client, decimal Montant);

static class Arbre
{
    public static void Executer()
    {
        var seuil = 100m;

        // Le meme lambda, deux types cibles. Un delegue : du code compile, opaque.
        Func<Commande, bool> filtre = c => c.Montant > seuil;

        // Un arbre d'expression : une donnee, qu'on peut lire noeud par noeud.
        Expression<Func<Commande, bool>> arbre = c => c.Montant > seuil;

        // <>c__DisplayClass0_0 est un nom de classe genere par le compilateur
        // (C# 14, SDK 10.0.300) : il change avec la version et le code environnant.
        Console.WriteLine(arbre);
        // c => (c.Montant > value(Arbre+<>c__DisplayClass0_0).seuil)

        var comparaison = (BinaryExpression)arbre.Body;
        Console.WriteLine(comparaison.NodeType); // GreaterThan
        Console.WriteLine(comparaison.Left);     // c.Montant
        Console.WriteLine(comparaison.Right);    // value(Arbre+<>c__DisplayClass0_0).seuil
        // La variable capturee est un champ de la classe de fermeture : un
        // fournisseur y lit la valeur au moment ou il traduit.

        // Un arbre se compile a la demande en delegue.
        var compile = arbre.Compile();
        var commande = new Commande("Durand", 250m);
        Console.WriteLine($"{filtre(commande)} {compile(commande)}"); // True True

        // Expression<Func<Commande, bool>> bloc = c => { return c.Montant > 0; };
        // CS0834 : un lambda a corps d'instructions ne se convertit pas en arbre.
    }
}

Un IQueryable<T> porte un tel arbre et un fournisseur, IQueryProvider. Chaque opérateur de Queryable ajoute un nœud à l'arbre sans rien exécuter ; l'énumération remet l'arbre entier au fournisseur. Pour l'observer sans base de données, AsQueryable enveloppe une liste dans un fournisseur qui exécute l'arbre en mémoire.

using System;
using System.Collections.Generic;
using System.Linq;

sealed record Commande(string Client, decimal Montant);

static class EnMemoire
{
    public static void Executer()
    {
        var commandes = new List<Commande> { new("Durand", 250m), new("Martin", 80m) };

        // AsQueryable enveloppe la liste dans un IQueryable<T> dont le fournisseur,
        // EnumerableQuery, sait executer un arbre en memoire.
        var requete = commandes.AsQueryable()
            .Where(c => c.Montant > 100m)
            .Select(c => c.Client);

        // Queryable.Where n'a rien filtre : il a ajoute un noeud a l'arbre. Le nom
        // List`1[Commande] suppose que Commande est dans l'espace de noms global.
        Console.WriteLine(requete.Expression);
        // System.Collections.Generic.List`1[Commande].Where(c => (c.Montant > 100)).Select(c => c.Client)

        Console.WriteLine(requete.Provider.GetType().Name); // EnumerableQuery`1

        // L'enumeration remet l'arbre au fournisseur, qui le compile puis l'execute.
        Console.WriteLine(string.Join(", ", requete)); // Durand
    }
}

Deux conséquences découlent de ce mécanisme. La première : un fournisseur qui traduit ne connaît que ce qu'il sait traduire. En mémoire, tout passe, puisque l'arbre est compilé. EF Core, depuis sa version 3.0, lève une exception à l'exécution quand une partie de la requête, hors de la projection finale, n'a pas d'équivalent SQL — en EF Core 10, son message commence par « The LINQ expression … could not be translated ». Le compilateur ne voit rien : la requête compile, puis échoue.

La seconde : le choix entre Enumerable.Where et Queryable.Where se fait à la compilation, d'après le type statique. Une méthode qui rend un IEnumerable<T> fait basculer tout ce qui suit en mémoire, sans erreur et avec un résultat juste. Seul l'arbre reçu par le fournisseur, privé du filtre, le trahit :

using System;
using System.Collections.Generic;
using System.Linq;

sealed record Commande(string Client, decimal Montant);

sealed class Depot(IQueryable<Commande> source)
{
    // Le type de retour efface IQueryable : pour l'appelant, c'est une sequence.
    public IEnumerable<Commande> Toutes() => source;
}

static class Appelant
{
    public static void Executer()
    {
        var commandes = new List<Commande> { new("Durand", 250m), new("Martin", 80m) };
        var depot = new Depot(commandes.AsQueryable());

        // Where est choisi a la compilation, sur le type statique : c'est
        // Enumerable.Where, avec un delegue. Le filtre n'entre jamais dans l'arbre.
        var grosses = depot.Toutes().Where(c => c.Montant > 100m);

        Console.WriteLine(grosses is IQueryable<Commande>);                 // False
        Console.WriteLine(string.Join(", ", grosses.Select(c => c.Client))); // Durand

        // Le resultat est juste. Mais voici l'arbre que le fournisseur recoit a
        // l'enumeration : celui de source, sans condition. Devant une base, il
        // aurait tout lu. (List`1[Commande] suppose l'espace de noms global.)
        Console.WriteLine(((IQueryable<Commande>)depot.Toutes()).Expression);
        // System.Collections.Generic.List`1[Commande]
    }
}

La correction garde la composition du côté de IQueryable<T> jusqu'à ce que la condition soit dans l'arbre, et n'exécute qu'ensuite. La requête composable reste privée ; l'appelant reçoit une ReadOnlyCollection<T> déjà exécutée :

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Linq.Expressions;

sealed record Commande(string Client, decimal Montant);

sealed class Depot(IQueryable<Commande> source)
{
    // Le critere entre dans la methode, qui compose sur IQueryable<T> avant
    // d'executer : Queryable.Where l'ajoute a l'arbre. Privee : IQueryable ne
    // sort pas du depot.
    private IQueryable<Commande> RequeteAuDessusDe(decimal seuil) =>
        source.Where(c => c.Montant > seuil);

    // La frontiere execute et rend une collection en lecture seule : l'appelant
    // ne compose plus rien, et ne peut pas modifier le resultat.
    public ReadOnlyCollection<Commande> AuDessusDe(decimal seuil) =>
        RequeteAuDessusDe(seuil).ToList().AsReadOnly();

    // Pour la demonstration seulement : l'arbre, pas une requete composable.
    public Expression ArbreAuDessusDe(decimal seuil) => RequeteAuDessusDe(seuil).Expression;
}

static class Appelant
{
    public static void Executer()
    {
        var commandes = new List<Commande> { new("Durand", 250m), new("Martin", 80m) };
        var depot = new Depot(commandes.AsQueryable());

        // Voici l'arbre que le fournisseur recoit : le filtre y figure.
        // <>c__DisplayClass2_0 est un nom genere par le compilateur (C# 14, SDK
        // 10.0.300), qui change avec la version et le code environnant ;
        // List`1[Commande] suppose l'espace de noms global.
        Console.WriteLine(depot.ArbreAuDessusDe(100m));
        // System.Collections.Generic.List`1[Commande].Where(c => (c.Montant > value(Depot+<>c__DisplayClass2_0).seuil))

        Console.WriteLine(depot.AuDessusDe(100m).Count); // 1
    }
}

Exposer directement IQueryable<T> aux appelants est l'autre voie, et elle a un prix : ils composent alors des requêtes que seul le fournisseur sait juger, ce que le cours DDD tactique écarte pour un repository. AsEnumerable, à l'inverse, est la bascule volontaire vers la mémoire, pour la partie qu'on sait ne pas pouvoir traduire. EF Core, fournisseur qui traduit ces arbres en SQL, a son propre cours, « EF Core ».

Les nouveautés récentes, de .NET 6 à .NET 10

.NET 6 a ajouté Chunk, MinBy et MaxBy, les variantes DistinctBy, ExceptBy, IntersectBy et UnionBy, Take sur un intervalle et TryGetNonEnumeratedCount, qui rend le nombre d'éléments seulement s'il est connu sans parcours. .NET 7 a ajouté Order et OrderDescending, qui trient les éléments eux-mêmes sans sélecteur de clé. .NET 9 a ajouté CountBy et AggregateBy, qui agrègent par clé sans allouer les groupes intermédiaires de GroupBy, et Index, qui remplace le compteur tenu à la main. .NET 10 a ajouté LeftJoin et RightJoin, jusque-là écrites avec GroupJoin, SelectMany et DefaultIfEmpty.

using System;
using System.Linq;

sealed record Commande(string Client, string Ville, decimal Montant);

static class Nouveautes
{
    public static void Executer()
    {
        Commande[] commandes =
        [
            new("Durand", "Lyon", 120m),
            new("Martin", "Nantes", 80m),
            new("Durand", "Lyon", 30m),
        ];

        // .NET 9 - CountBy compte par cle, sans construire de groupes.
        foreach (var (ville, nombre) in commandes.CountBy(c => c.Ville))
        {
            Console.WriteLine($"{ville} : {nombre}");
        }
        // Lyon : 2
        // Nantes : 1

        // .NET 9 - AggregateBy replie par cle, a partir d'une graine.
        foreach (var (client, total) in commandes.AggregateBy(c => c.Client, 0m, (cumul, c) => cumul + c.Montant))
        {
            Console.WriteLine($"{client} : {total}");
        }
        // Durand : 150
        // Martin : 80

        // .NET 9 - Index accompagne chaque element de son rang.
        foreach (var (rang, commande) in commandes.Index())
        {
            Console.WriteLine($"{rang + 1}. {commande.Client}");
        }
        // 1. Durand
        // 2. Martin
        // 3. Durand

        // .NET 10 - LeftJoin, jointure externe gauche : l'element de droite
        // est null quand il n'a pas de correspondant.
        string[] clients = ["Martin", "Petit"];
        var bilan = clients.LeftJoin(
            commandes,
            nom => nom,
            commande => commande.Client,
            (nom, commande) => commande is null ? $"{nom} : aucune" : $"{nom} : {commande.Montant}");
        Console.WriteLine(string.Join(", ", bilan)); // Martin : 80, Petit : aucune
    }
}

LeftJoin et RightJoin existent sur Enumerable comme sur Queryable, mais la syntaxe de requête n'a pas de mot-clé pour elles : elles s'écrivent en appels de méthode. .NET 10 apporte aussi Shuffle, qui mélange avec un générateur non cryptographique, Sequence et InfiniteSequence. Là où Range(start, count) compte des int de un en un, Sequence(start, endInclusive, step) va jusqu'à une borne incluse, avec un pas, pour tout type INumber<T> ; InfiniteSequence(start, step) ne s'arrête jamais et n'exige que IAdditionOperators<T, T, T>. S'y ajoute la classe AsyncEnumerable : les opérateurs de LINQ sur IAsyncEnumerable<T>, désormais fournis par la plateforme. Elle remplace le paquet communautaire System.Linq.Async, dont la référence, gardée telle quelle dans un projet .NET 10, peut rendre des appels ambigus à la compilation.

Ce cours vous a servi ? Offrir un café Signaler une erreur