LINQ (Language Integrated Query) 的延遲執行機制 (Deferred Execution) 是其一大特色,讓查詢表達式在實際迭代前都不會執行。然而,如果不加注意,可能會因誤用而引發難以察覺的效能問題。本文將探討 LINQ 的延遲執行機制及其潛在的誤用風險,並提供實際程式碼範例說明。
介紹延遲執行 (Deferred Execution)
LINQ 的延遲執行意指查詢表達式不會立即執行,只有在真正需要取出資料時才進行計算。
快速判斷是否會真正取出資料
許多人知道延遲執行的概念,也能列出一些延遲執行的運算子 (Deferred Execution Operators) 和會立即執行的運算子 (Immediate Execution Operators) 。但很少人能說明「如何分辨延遲執行運算子與立即執行運算子」。以下提供一些快速分辨方式,讓我們在看到陌生的 LINQ 方法時,能大概猜出其執行方式。
延遲執行運算子 (Deferred Execution Operators)
幾乎所有的延遲執行運算子都會回傳 IEnumerable<T>
或 IOrderedEnumerable<TElement>
,基本概念就是「Enumerable」,包含 foreach
搭配 yield
並回傳 IEnumerable<T>
的方法。
之所以說「幾乎」,是因為我們可以透過擴充方法實作回傳這些型別的 LINQ 方法,但如果內部實作觸及立即執行運算,那麼單從方法簽章判斷可能會誤判。
但擴充會回傳IEnumerable<T>
或IOrderedEnumerable<TElement>
的 LINQ 方法卻觸及立即執行運算,我認為這是設計上的錯誤。
立即執行運算子 (Immediate Execution Operators)
與延遲執行運算子不同,立即執行運算子的判定相對容易。只要是回傳具體的值或集合等非「Enumerable」型別的方法都是立即執行運算子,包括透過 foreach
直接取得運算結果的方式。
小結
上述兩種 LINQ 運算子的分類只是初步判定。實際上,還有串流 (Streaming) 或非串流 (Nonstreaming) 兩種方式,區分內部是可能提前返回還是必須走訪所有元素,詳見 Classification of standard query operators by manner of execution。
誤用情境
用於展示誤用情境,所有範例都會使用下面的類別與資料示範。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29public class Person
{
public string Name { get; set; }
public int Age { get; set; }
}
var source = new List<Person>
{
new Person { Name = "Alice", Age = 25 },
new Person { Name = "Bob", Age = 32 },
new Person { Name = "Charlie", Age = 45 },
new Person { Name = "Diana", Age = 38 },
new Person { Name = "Eve", Age = 29 },
new Person { Name = "Frank", Age = 40 },
new Person { Name = "Grace", Age = 54 },
new Person { Name = "Henry", Age = 3 },
new Person { Name = "Ivy", Age = 30 },
new Person { Name = "Jack", Age = 27 },
new Person { Name = "Karen", Age = 35 },
new Person { Name = "Leo", Age = 48 },
new Person { Name = "Mia", Age = 22 },
new Person { Name = "Nina", Age = 37 },
new Person { Name = "Oscar", Age = 55 },
new Person { Name = "Paul", Age = 15 },
new Person { Name = "Quincy", Age = 42 },
new Person { Name = "Rita", Age = 36 },
new Person { Name = "Steve", Age = 25 },
new Person { Name = "Tina", Age = 41 }
};
重複運算 - LINQ
以下面範例為例:1
2
3
4var enmerable = source.Where(r => r.Age >= 18) ;
var oldestAdult = enmerable.MaxBy(r => r.Age) ;
var youngestAdult = enmerable.MinBy(r => r.Age) ;
由於 Where()
是延遲執行運算子,因此在執行 MaxBy()
和 MinBy()
時,source
會被篩選兩次,造成不必要的運算浪費。改進方式如下:
1 | var adults = source.Where(r => r.Age >= 18) .ToList() ; |
透過先使用 ToList()
方法將符合條件的成人資料篩選出來,後續的 MaxBy()
和 MinBy()
就不會重複篩選。
重複運算 - foreach
以下面範例為例:1
2
3
4
5
6
7
8
9
10var enmerable = source.Where(r => r.Age >= 18) ;
foreach (var item in enmerable)
{
// Do something.
}
foreach (var item in enmerable)
{
// Do something else.
}
類似於上一個情境,不同之處在於這裡由 foreach
觸發。修正方式同樣是提前呼叫立即執行運算,避免後續的兩個 foreach
造成重複的Where()
運算。
重複運算 - 子查詢
子查詢是容易被忽略的情境,以下範例:1
2
3
4
5
6
7var enumerable = source.Where(r => r.Age >= 18) ;
var targetAges = new List<int> { 40, 54, 23 };
var result = targetAges
.Where(age =>
enumerable.Any(s => s.Age == age)
&& enumerable.Any(s => (s.Age / 10) > 3) )
.ToList() ;
由於兩次立即執行運算 Any()
被包含在子查詢中,容易忽略重複運算。解決方式如下:
1 | var list = source.Where(r => r.Age >= 18) .ToList() ; |
重複運算 - 不洽當的客製方法
參考以下客製方法:1
2
3
4
5
6
7
8
9
10
11
12public static IEnumerable<T> Duplicate<T>(this IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
foreach (var item in source)
{
yield return item;
}
}
呼叫端如下:1
2
3
4var enmerable = src
.Where(r => r.Age >= 18)
.Duplicate()
.ToList() ;
問題在於 Duplicate()
方法中包含了兩個 foreach
。即使搭配了 yield return
使其為延遲執行運算,但當呼叫端執行時,Where()
方法會在 Duplicate()
中執行兩次。
以這個範例來說,根本原因是 Duplicate()
方法的設計錯誤,應該改寫這個方法來解決問題,但如果無法修改 Duplicate()
方法,臨時解決方式如下:
1 | var enmerable = src |
小結
其實誤用情境主要是重複迭代。但由於 LINQ 方法的組合與類似子查詢的語法結構,很多時候我們未察覺其中的重複迭代,進而無意間犯錯。
結論
理解 LINQ 的延遲執行機制有助於我們撰寫更高效的程式碼。透過注意可能的重複迭代情境,並適時使用立即執行運算子,可以避免不必要的效能損耗。
參考
Classification of standard query operators by manner of execution
ChatGPT