如何擴充 LINQ
所有 LINQ 型方法都遵循兩個類似的模式之一。 它們採用可列舉的序列。 它們會傳回不同的序列或單一值。 圖形的一致性可讓您藉由撰寫具有類似圖形的方法來擴充 LINQ。 事實上,自 LINQ 首次推出以來,.NET 程式庫在許多 .NET 版本中都獲得了新的方法。 在本文中,您會藉由撰寫遵循相同模式的自有方法,來查看擴充 LINQ 的範例。
新增 LINQ 查詢的自訂方法
您可以藉由將擴充方法新增至 IEnumerable<T> 介面,來擴充您可以用於 LINQ 查詢的方法集合。 例如,除了標準平均值或最大運算,您可以建立自訂的彙總方法來計算一系列值的單一值。 您也可以建立一個方法,用為自訂篩選器或一系列值的特定資料轉換,並傳回新的序列。 這類方法的範例包括 Distinct、Skip 和 Reverse。
當您延伸 IEnumerable<T> 介面時,可將自訂方法套用至任何可列舉的集合。 如需詳細資訊,請參閱擴充方法。
彙總方法會計算一組值的單一值。 LINQ 提供數種彙總方法,包括 Average、Min 和 Max。 您可將擴充方法新增至 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();
您現在可以呼叫 integer
和 double
類型的 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,如果您使用 Aggregate
或 Group 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 | ! |
會依此順序建立下列群組:
- We、think、that
- Linq
- 是
- really
- cool、!
解決方案會實作為安全執行緒擴充方法,並以資料流方式傳回其結果。 它會在來源序列中移動時產生其群組。 不像 group
或 orderby
運算子,它可以在讀取所有序列之前,開始將群組傳回給呼叫端。 下例範例會顯示擴充方法及使用它的用戶端程式碼:
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 迴圈。 在該迴圈中,程式碼會執行下列動作:
- 取得目前區塊的索引鍵,並將它指派給
key
變數。 來源列舉程式會取用來源序列,直到它找到其索引鍵不相符的元素為止。 - 建立新的區塊 (群組) 物件,並將其儲存在
current
變數中。 它有一個 GroupItem,這是目前來源元素的複本。 - 傳回該區塊。 區塊是
IGrouping<TKey,TSource>
,這是ChunkBy
方法的傳回值。 該區塊在其來源序列中只有第一個元素。 只有在用戶端程式碼 foreach 超過此區塊時,才會傳回其餘元素。 如需詳細資訊,請參閱Chunk.GetEnumerator
。 - 查看是否:
- 該區塊已經複製了其所有來源元素,或者
- 列舉程式已到達來源序列的結尾。
- 當呼叫端列舉所有區塊項目時,
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
方法。 此方法會在用戶端要求之前保留一個元素。 它只會在用戶端要求清單中到前一個最後的元素之後,才會新增區塊的下一個元素。