Comment étendre LINQ

Toutes les méthodes LINQ suivent l’un des deux modèles similaires. Ils prennent une séquence énumérable. Ils retournent soit une séquence différente, soit une valeur unique. Cette cohérence vous permet d’étendre LINQ en écrivant des méthodes ayant une forme similaire. En fait, les bibliothèques .NET ont été enrichies de nouvelles méthodes dans de nombreuses versions .NET depuis la première apparition de LINQ. Vous trouverez dans cet article des exemples d’extension de LINQ en écrivant vos propres méthodes qui suivent le même modèle.

Ajouter des méthodes personnalisées pour les requêtes LINQ

Vous étendez l’ensemble de méthodes que vous utilisez pour les requêtes LINQ en ajoutant des méthodes d’extension à l’interface IEnumerable<T>. Par exemple, en plus des opérations moyennes ou maximales standard, vous créez une méthode d’agrégation personnalisée pour calculer une valeur unique à partir d’une séquence de valeurs. Vous pouvez également créer une méthode qui fonctionne comme un filtre personnalisé ou une transformation de données pour une séquence de valeurs, et qui retourne une nouvelle séquence. Distinct, Skip et Reverse en sont quelques exemples.

Quand vous étendez l’interface IEnumerable<T>, vous pouvez appliquer vos méthodes personnalisées à n’importe quelle collection énumérable. Pour plus d’informations, consultez Méthodes d’extension.

Une méthode d’agrégation calcule une valeur unique à partir d’un ensemble de valeurs. LINQ fournit plusieurs méthodes d’agrégation, notamment Average, Min et Max. Vous pouvez créer votre propre méthode d’agrégation en ajoutant une méthode d’extension à l’interface IEnumerable<T>.

L’exemple de code suivant montre comment créer une méthode d’extension appelée Median pour calculer une valeur médiane pour une séquence de nombres de type double.

public static class EnumerableExtension
{
    public static double Median(this IEnumerable<double>? source)
    {
        if (source is null || !source.Any())
        {
            throw new InvalidOperationException("Cannot compute median for a null or empty set.");
        }

        var sortedList =
            source.OrderBy(number => number).ToList();

        int itemIndex = sortedList.Count / 2;

        if (sortedList.Count % 2 == 0)
        {
            // Even number of items.
            return (sortedList[itemIndex] + sortedList[itemIndex - 1]) / 2;
        }
        else
        {
            // Odd number of items.
            return sortedList[itemIndex];
        }
    }
}

Vous appelez cette méthode d’extension pour toute collection énumérable de la même façon que vous appelez d’autres méthodes d’agrégation depuis l’interface IEnumerable<T>.

L’exemple de code suivant montre comment utiliser la méthode Median pour un tableau de type double.

double[] numbers = [1.9, 2, 8, 4, 5.7, 6, 7.2, 0];
var query = numbers.Median();

Console.WriteLine($"double: Median = {query}");
// This code produces the following output:
//     double: Median = 4.85

Vous pouvez surcharger votre méthode d’agrégation afin qu’elle accepte des séquences de différents types. L’approche standard consiste à créer une surcharge pour chaque type. Une autre approche consiste à créer une surcharge qui accepte un type générique et le convertit en un autre type à l’aide d’un délégué. Vous pouvez également combiner ces deux approches.

Vous pouvez créer une surcharge pour chacun des types que vous voulez prendre en charge. L’exemple de code suivant montre une surcharge de la méthode Median pour le type int.

// int overload
public static double Median(this IEnumerable<int> source) =>
    (from number in source select (double)number).Median();

Vous pouvez maintenant appeler les surcharges Median pour les types integer et double, comme indiqué dans le code suivant :

double[] numbers1 = [1.9, 2, 8, 4, 5.7, 6, 7.2, 0];
var query1 = numbers1.Median();

Console.WriteLine($"double: Median = {query1}");

int[] numbers2 = [1, 2, 3, 4, 5];
var query2 = numbers2.Median();

Console.WriteLine($"int: Median = {query2}");
// This code produces the following output:
//     double: Median = 4.85
//     int: Median = 3

Vous pouvez également créer une surcharge qui accepte une séquence générique d’objets. Cette surcharge prend un délégué comme paramètre et l’utilise pour convertir une séquence d’objets de type générique en un autre type d’objets.

Le code suivant montre une surcharge de la méthode Median qui prend le délégué Func<T,TResult> comme paramètre. Ce délégué prend un objet de type générique T et retourne un objet de type double.

// generic overload
public static double Median<T>(
    this IEnumerable<T> numbers, Func<T, double> selector) =>
    (from num in numbers select selector(num)).Median();

Vous pouvez maintenant appeler la méthode Median pour une séquence d’objets de tout type. Si le type n’a pas sa propre surcharge de méthode, vous devez passer un paramètre délégué. En C#, vous pouvez utiliser une expression lambda à cet effet. En outre, en Visual Basic uniquement, si vous utilisez la clause Aggregate ou Group By au lieu de l’appel de méthode, vous pouvez passer n’importe quelle valeur ou expression qui se trouve dans la portée de cette clause.

L’exemple de code suivant montre comment appeler la méthode Median pour un tableau d’entiers et un tableau de chaînes. Pour les chaînes, c’est la valeur médiane des longueurs de chaînes du tableau qui est calculée. L’exemple montre comment passer le paramètre de délégué Func<T,TResult> à la méthode Median pour chaque cas.

int[] numbers3 = [1, 2, 3, 4, 5];

/*
    You can use the num => num lambda expression as a parameter for the Median method
    so that the compiler will implicitly convert its value to double.
    If there is no implicit conversion, the compiler will display an error message.
*/
var query3 = numbers3.Median(num => num);

Console.WriteLine($"int: Median = {query3}");

string[] numbers4 = ["one", "two", "three", "four", "five"];

// With the generic overload, you can also use numeric properties of objects.
var query4 = numbers4.Median(str => str.Length);

Console.WriteLine($"string: Median = {query4}");
// This code produces the following output:
//     int: Median = 3
//     string: Median = 4

Vous pouvez étendre l’interface IEnumerable<T> avec une méthode de requête personnalisée qui retourne une séquence de valeurs. Dans ce cas, la méthode doit retourner une collection de type IEnumerable<T>. Ces méthodes peuvent être utilisées pour appliquer des filtres ou des transformations de données à une séquence de valeurs.

L’exemple suivant montre comment créer une méthode d’extension nommée AlternateElements qui retourne un élément sur deux d’une collection, en commençant par le premier élément.

// Extension method for the IEnumerable<T> interface.
// The method returns every other element of a sequence.
public static IEnumerable<T> AlternateElements<T>(this IEnumerable<T> source)
{
    int index = 0;
    foreach (T element in source)
    {
        if (index % 2 == 0)
        {
            yield return element;
        }

        index++;
    }
}

Vous pouvez appeler cette méthode d’extension pour n’importe quelle collection énumérable, de la même façon que vous appelez d’autres méthodes depuis l’interface IEnumerable<T>, comme le montre le code suivant :

string[] strings = ["a", "b", "c", "d", "e"];

var query5 = strings.AlternateElements();

foreach (var element in query5)
{
    Console.WriteLine(element);
}
// This code produces the following output:
//     a
//     c
//     e

Regrouper des résultats par clés contiguës

L’exemple suivant montre comment regrouper des éléments dans des blocs représentant des sous-séquences de clés contiguës. Par exemple, supposons que vous disposiez de la séquence de paires clé-valeur suivante :

Clé Valeur
Un Nous
Un pensons
A que
B Linq
C is
Un vraiment
B chouette
B !

Les groupes suivants seront créés dans cet ordre :

  1. Nous, pensons, que
  2. Linq
  3. is
  4. vraiment
  5. chouette, !

La solution est implémentée comme une méthode d’extension thread-safe qui retourne ses résultats en continu. Elle produit ses groupes au fur et à mesure qu’il passe par la séquence source. Contrairement aux opérateurs group ou orderby, elle peut commencer à retourner des groupes à l’appelant avant d’avoir lu toute la séquence. L’exemple suivant montre la méthode d’extension et le code client qui l’utilise :

public static class ChunkExtensions
{
    public static IEnumerable<IGrouping<TKey, TSource>> ChunkBy<TSource, TKey>(
            this IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector) =>
                source.ChunkBy(keySelector, EqualityComparer<TKey>.Default);

    public static IEnumerable<IGrouping<TKey, TSource>> ChunkBy<TSource, TKey>(
            this IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector,
            IEqualityComparer<TKey> comparer)
    {
        // Flag to signal end of source sequence.
        const bool noMoreSourceElements = true;

        // Auto-generated iterator for the source array.
        IEnumerator<TSource>? enumerator = source.GetEnumerator();

        // Move to the first element in the source sequence.
        if (!enumerator.MoveNext())
        {
            yield break;        // source collection is empty
        }

        while (true)
        {
            var key = keySelector(enumerator.Current);

            Chunk<TKey, TSource> current = new(key, enumerator, value => comparer.Equals(key, keySelector(value)));

            yield return current;

            if (current.CopyAllChunkElements() == noMoreSourceElements)
            {
                yield break;
            }
        }
    }
}
public static class GroupByContiguousKeys
{
    // The source sequence.
    static readonly KeyValuePair<string, string>[] list = [
        new("A", "We"),
        new("A", "think"),
        new("A", "that"),
        new("B", "LINQ"),
        new("C", "is"),
        new("A", "really"),
        new("B", "cool"),
        new("B", "!")
    ];

    // Query variable declared as class member to be available
    // on different threads.
    static readonly IEnumerable<IGrouping<string, KeyValuePair<string, string>>> query =
        list.ChunkBy(p => p.Key);

    public static void GroupByContiguousKeys1()
    {
        // ChunkBy returns IGrouping objects, therefore a nested
        // foreach loop is required to access the elements in each "chunk".
        foreach (var item in query)
        {
            Console.WriteLine($"Group key = {item.Key}");
            foreach (var inner in item)
            {
                Console.WriteLine($"\t{inner.Value}");
            }
        }
    }
}

Classe ChunkExtensions

Dans le code présenté de l’implémentation de classe ChunkExtensions, la boucle while(true) dans la méthode ChunkBy itère dans la séquence source et crée une copie de chaque bloc (Chunk). Sur chaque passe, l’itérateur passe au premier élément du « Chunk » suivant, représenté par un objet Chunk, dans la séquence source. Cette boucle correspond à la boucle foreach externe qui exécute la requête. Dans cette boucle, le code effectue les actions suivantes :

  1. Obtient la clé du Chunk en cours et l’affecte à la variable key. L’itérateur source consomme la séquence source jusqu’à ce qu’il trouve un élément dont la clé ne correspond pas.
  2. Créez un nouvel objet Chunk (groupe) et stockez-le dans la variable current. Il possède un GroupItem, qui est une copie de l’élément source actuel.
  3. Renvoyez ce bloc. Un bloc est un IGrouping<TKey,TSource>, qui est la valeur de retour de la méthode ChunkBy. Le Chunk contient uniquement le premier élément de sa séquence source. Les éléments restants sont retournés uniquement lorsque le code client effectue des recherches sur ce bloc. Pour plus d'informations, consultez Chunk.GetEnumerator.
  4. Vérifiez si :
    • Le bloc possède une copie de tous ses éléments sources, ou
    • L’itérateur a atteint la fin de la séquence source.
  5. Lorsque l’appelant a énuméré tous les éléments du bloc, la méthode Chunk.GetEnumerator a copié tous les éléments du bloc. Si la boucle Chunk.GetEnumerator n’a pas énuméré tous les éléments du bloc, il convient de le faire à ce stade afin d’éviter de corrompre l’itérateur pour les clients qui pourraient l’appeler sur un autre fil d’exécution.

Classe Chunk

La classe Chunk est un groupe contigu d’un ou plusieurs éléments sources qui ont la même clé. Un Bloc dispose d’une clé et d’une liste d’objets ChunkItem qui sont des copies des éléments de la séquence de la source :

class Chunk<TKey, TSource> : IGrouping<TKey, TSource>
{
    // INVARIANT: DoneCopyingChunk == true ||
    //   (predicate != null && predicate(enumerator.Current) && current.Value == enumerator.Current)

    // A Chunk has a linked list of ChunkItems, which represent the elements in the current chunk. Each ChunkItem
    // has a reference to the next ChunkItem in the list.
    class ChunkItem
    {
        public ChunkItem(TSource value) => Value = value;
        public readonly TSource Value;
        public ChunkItem? Next;
    }

    public TKey Key { get; }

    // Stores a reference to the enumerator for the source sequence
    private IEnumerator<TSource> enumerator;

    // A reference to the predicate that is used to compare keys.
    private Func<TSource, bool> predicate;

    // Stores the contents of the first source element that
    // belongs with this chunk.
    private readonly ChunkItem head;

    // End of the list. It is repositioned each time a new
    // ChunkItem is added.
    private ChunkItem? tail;

    // Flag to indicate the source iterator has reached the end of the source sequence.
    internal bool isLastSourceElement;

    // Private object for thread synchronization
    private readonly object m_Lock;

    // REQUIRES: enumerator != null && predicate != null
    public Chunk(TKey key, [DisallowNull] IEnumerator<TSource> enumerator, [DisallowNull] Func<TSource, bool> predicate)
    {
        Key = key;
        this.enumerator = enumerator;
        this.predicate = predicate;

        // A Chunk always contains at least one element.
        head = new ChunkItem(enumerator.Current);

        // The end and beginning are the same until the list contains > 1 elements.
        tail = head;

        m_Lock = new object();
    }

    // Indicates that all chunk elements have been copied to the list of ChunkItems.
    private bool DoneCopyingChunk => tail == null;

    // Adds one ChunkItem to the current group
    // REQUIRES: !DoneCopyingChunk && lock(this)
    private void CopyNextChunkElement()
    {
        // Try to advance the iterator on the source sequence.
        isLastSourceElement = !enumerator.MoveNext();

        // If we are (a) at the end of the source, or (b) at the end of the current chunk
        // then null out the enumerator and predicate for reuse with the next chunk.
        if (isLastSourceElement || !predicate(enumerator.Current))
        {
            enumerator = default!;
            predicate = default!;
        }
        else
        {
            tail!.Next = new ChunkItem(enumerator.Current);
        }

        // tail will be null if we are at the end of the chunk elements
        // This check is made in DoneCopyingChunk.
        tail = tail!.Next;
    }

    // Called after the end of the last chunk was reached.
    internal bool CopyAllChunkElements()
    {
        while (true)
        {
            lock (m_Lock)
            {
                if (DoneCopyingChunk)
                {
                    return isLastSourceElement;
                }
                else
                {
                    CopyNextChunkElement();
                }
            }
        }
    }

    // Stays just one step ahead of the client requests.
    public IEnumerator<TSource> GetEnumerator()
    {
        // Specify the initial element to enumerate.
        ChunkItem? current = head;

        // There should always be at least one ChunkItem in a Chunk.
        while (current != null)
        {
            // Yield the current item in the list.
            yield return current.Value;

            // Copy the next item from the source sequence,
            // if we are at the end of our local list.
            lock (m_Lock)
            {
                if (current == tail)
                {
                    CopyNextChunkElement();
                }
            }

            // Move to the next ChunkItem in the list.
            current = current.Next;
        }
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}

Chaque ChunkItem (représenté par la classe ChunkItem) a une référence au ChunkItem suivant dans la liste. La liste se compose de son head ;qui stocke le contenu du premier élément source appartenant à ce morceau, et de son tail ; qui est la fin de la liste. Il est repositionné chaque fois qu’un nouveau ChunkItem est ajouté. La fin de la liste liée est définie sur null dans la méthode CopyNextChunkElement si la clé de l’élément suivant ne correspond pas à la clé du bloc actuel, ou qu’il n’y a plus d’éléments dans la source.

La méthode CopyNextChunkElement de la classe Chunk ajoute un ChunkItem au groupe actuel d’éléments. Il tente d’avancer l’itérateur sur la séquence source. Si la méthode MoveNext() retourne false, l’itération est terminée et isLastSourceElement est définie sur true.

La méthode CopyAllChunkElements est appelée après la fin du dernier bloc. Elle vérifie s’il existe plus d’éléments dans la séquence source. Si des éléments sont présent, elle retourne true si l’énumérateur pour ce bloc a été épuisé. Dans cette méthode, lorsque le champ privé DoneCopyingChunk est vérifié pour true, si isLastSourceElement est false, il signale à l’itérateur externe de poursuivre l’itération.

La boucle foreach interne appelle la méthode GetEnumerator de la classe Chunk. Cette méthode conserve un élément d’avance sur les requêtes du client. Elle ajoute l’élément suivant du bloc uniquement après que le client a demandé le dernier élément de la liste.