如何擴充 LINQ

所有 LINQ 型方法都遵循兩個類似的模式之一。 它們採用可列舉的序列。 它們會傳回不同的序列或單一值。 圖形的一致性可讓您藉由撰寫具有類似圖形的方法來擴充 LINQ。 事實上,自 LINQ 首次推出以來,.NET 程式庫在許多 .NET 版本中都獲得了新的方法。 在本文中,您會藉由撰寫遵循相同模式的自有方法,來查看擴充 LINQ 的範例。

新增 LINQ 查詢的自訂方法

您可以藉由將擴充方法新增至 IEnumerable<T> 介面,來擴充您可以用於 LINQ 查詢的方法集合。 例如,除了標準平均值或最大運算,您可以建立自訂的彙總方法來計算一系列值的單一值。 您也可以建立一個方法,用為自訂篩選器或一系列值的特定資料轉換,並傳回新的序列。 這類方法的範例包括 DistinctSkipReverse

當您延伸 IEnumerable<T> 介面時,可將自訂方法套用至任何可列舉的集合。 如需詳細資訊,請參閱擴充方法

彙總方法會計算一組值的單一值。 LINQ 提供數種彙總方法,包括 AverageMinMax。 您可將擴充方法新增至 IEnumerable<T> 介面,建立自己的彙總方法。

下列程式碼範例示範如何建立呼叫 Median 的擴充方法,來計算一系列類型為 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];
        }
    }
}

您可以使用從 IEnumerable<T> 介面呼叫其他彙總方法同樣的方式,為任何可列舉集合呼叫此擴充方法。

下列程式碼範例示範如何使用 Median 方法處理 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

您可以多載彙總方法,讓它接受各種類型的序列。 標準方法是為每種類型建立多載。 另一種方法是建立採用泛型型別的多載,並使用委派將它轉換成特定的類型。 您也可以結合這兩種方法。

您可以為想要支援的每種類型建立特定的多載。 下列程式碼範例示範適合 int 類型之 Median 方法的多載。

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

您現在可以呼叫 integerdouble 類型的 Median 多載,如下列程式碼所示︰

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

您也可以建立接受物件泛型序列的多載。 這個多載會接受委派作為參數,並使用它將泛型型別物件的序列轉換成特定的類型。

下列程式碼顯示 Median 方法的多載,接受 Func<T,TResult> 委派為參數。 這個委派會接受泛型型別 T 的物件,並傳回 double 類型的物件。

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

您現在可以針對一系列的類型物件呼叫 Median 方法。 如果類型沒有自己的方法多載,您就必須傳遞委派參數。 在 C# 中,您可以針對此目的使用 Lambda 運算式。 另僅限 Visual Basic,如果您使用 AggregateGroup By 子句而不是方法呼叫,您可以傳遞此子句範圍內的任何值或運算式。

下列程式碼範例示範如何呼叫 Median 方法,處理整數陣列及字串陣列。 針對字串,會計算陣列字串長度的中間值。 此範例會示範如何將 Func<T,TResult> 委派參數傳遞至每個案例的 Median 方法。

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

您可以使用傳回值序列的自訂查詢方法來擴充 IEnumerable<T> 介面。 在此情況下,方法必須傳回型別 IEnumerable<T> 的集合。 此等方法可用來將篩選條件或資料轉換套用至一系列的值。

下例示範如何建立名為 AlternateElements 的擴充方法,傳回集合中的每隔個項目,從第一個項目開始。

// 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++;
    }
}

您可為任何可列舉集合呼叫此擴充方法,就和您從 IEnumerable<T> 介面呼叫其他方法一樣,如下列程式碼所示:

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

以相鄰索引鍵將結果分組

下例示範如何將項目分組到代表相鄰索引鍵子序列的區塊。 例如,假設您有以下機碼值組的序列:

Key
A
A think
A that
B Linq
C
A really
B cool
B !

會依此順序建立下列群組:

  1. We、think、that
  2. Linq
  3. really
  4. cool、!

解決方案會實作為安全執行緒擴充方法,並以資料流方式傳回其結果。 它會在來源序列中移動時產生其群組。 不像 grouporderby 運算子,它可以在讀取所有序列之前,開始將群組傳回給呼叫端。 下例範例會顯示擴充方法及使用它的用戶端程式碼:

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

ChunkExtensions 類別

在所呈現的 ChunkExtensions 類別實作的程式碼中,ChunkBy 方法中的 while(true) 迴圈會逐一查看來源序列並建立每個區塊 (Chunk) 的複本。 在每次傳遞中,列舉程式都會前進到來源序列中的下一個「區塊」的第一個元素,由 Chunk 物件表示。 此迴圈對應於執行查詢的外部 foreach 迴圈。 在該迴圈中,程式碼會執行下列動作:

  1. 取得目前區塊的索引鍵,並將它指派給 key 變數。 來源列舉程式會取用來源序列,直到它找到其索引鍵不相符的元素為止。
  2. 建立新的區塊 (群組) 物件,並將其儲存在 current 變數中。 它有一個 GroupItem,這是目前來源元素的複本。
  3. 傳回該區塊。 區塊是 IGrouping<TKey,TSource>,這是 ChunkBy 方法的傳回值。 該區塊在其來源序列中只有第一個元素。 只有在用戶端程式碼 foreach 超過此區塊時,才會傳回其餘元素。 如需詳細資訊,請參閱 Chunk.GetEnumerator
  4. 查看是否:
    • 該區塊已經複製了其所有來源元素,或者
    • 列舉程式已到達來源序列的結尾。
  5. 當呼叫端列舉所有區塊項目時,Chunk.GetEnumerator 方法已複製所有區塊項目。 如果 Chunk.GetEnumerator 迴圈未列舉該區塊中的所有元素,請立即執行,以避免損毀列舉程式,因為用戶端可能正在個別執行緒上呼叫它。

Chunk 類別

Chunk 類別是一組連續的一或多個具有相同索引鍵的來源元素。 「區塊」有一個索引鍵和一個 ChunkItem 物件清單 (它們是來源序列中元素的複本):

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();
}

每個 ChunkItem (由 ChunkItem 類別表示) 都有此清單中下一個 ChunkItem 的參考。 該清單由其 head (儲存屬於該區塊的第一個來源元素的內容) 和其 tail (它是清單的結尾) 組成。 每次新增 ChunkItem 時都會重新定位結尾。 如果下一個元素的索引鍵與目前區塊的索引鍵不相符,或者來源中沒有其他的元素,則連結清單的結尾會在 CopyNextChunkElement 方法中設定為 null

CopyNextChunkElement 類別的 Chunk 方法會將一個 ChunkItem 新增到目前的項目群組中。 它會嘗試在來源序列上推進列舉程式。 如果 MoveNext() 方法傳回 false,則表示反覆運算位於結尾,且 isLastSourceElement 設定為 true

到達最後一個區塊的末尾後會呼叫 CopyAllChunkElements 方法。 它會檢查來源序列中是否有其他的元素。 如果有,並且此區塊的列舉程式已用盡,則會傳回 true。 在此方法中,當檢查私人 DoneCopyingChunk 欄位是否為 true 時,如果 isLastSourceElement 為 false,則會向外部列舉程式發出訊號以繼續進行列舉。

內部 foreach 迴圈會叫用 Chunk 類別的 GetEnumerator 方法。 此方法會在用戶端要求之前保留一個元素。 它只會在用戶端要求清單中到前一個最後的元素之後,才會新增區塊的下一個元素。