LINQ之路 6:延遲執行(Deferred Execution)
LINQ中大部分查詢運算符都有一個非常重要的特性:延遲執行。這意味著,他們不是在查詢創建的時候執行,而是在遍歷的時候執行(換句話說,當enumerator的MoveNext方法被調用時)。讓我們考慮下面這個query:
static void TestDeferredExecution()
{
var numbers = new List<int>();
numbers.Add(1);
IEnumerable<int> query = numbers.Select(n => n * 10); // Build query
numbers.Add(2); // Add an extra element after the query
foreach (int n in query)
Console.Write(n + "|"); // 10|20|
}
可以看出,我們在查詢創建之后添加的number也包含在查詢結果中了,這是因為直到foreach語句對query進行遍歷時,LINQ查詢才會執行,這時,數據源numbers已經包含了我們后來添加的元素2,LINQ的這種特性就是延遲執行。除了下面兩種查詢運算符,所有其他的運算符都是延遲執行的:
- 返回單個元素或者標量值的查詢運算符,如First、Count等。
- 下面這些轉換運算符:ToArray、ToList、ToDictionary、ToLookup。
上面兩種運算符會被立即執行,因為他們的返回值類型沒有提供延遲執行的機制,比如下面的查詢會被立即執行。
int matches = numbers.Where(n => (n % 2) == 0).Count(); // 1
對于LINQ來說,延遲執行時非常重要的,因為它把查詢的創建與查詢的執行解耦了,這讓我們可以向創建SQL查詢那樣,分成多個步驟來創建我們的LINQ查詢。
重復執行
延遲執行帶來的一個影響是,當我們重復遍歷查詢結果時,查詢會被重復執行:
static void TestReevaluation()
{
var numbers = new List<int>() { 1, 2 };
IEnumerable<int> query = numbers.Select(n => n * 10); // Build query
foreach (int n in query) Console.Write(n + "|"); // 10|20|
numbers.Clear();
foreach (int n in query) Console.Write(n + "|"); // <nothing>
}
有時候,重復執行對我們說可不是一個優點,理由如下:
- 當我們需要在某一個給定的點保存查詢的結果時。
- 有些查詢比較耗時,比如在對一個非常大的sequence進行查詢或者從遠程數據庫獲取數據時,為了性能考量,我們并不希望一個查詢會被反復執行。
這個時候,我們就可以利用之前介紹的轉換運算符,比如ToArray、ToList來避開重復執行,ToArray把查詢結果保存至一個Array,而ToList把結果保存至泛型List<>:
static void TestDefeatReevaluation()
{
var numbers = new List<int>() { 1, 2 };
List<int> timesTen = numbers
.Select(n => n * 10)
.ToList(); // Executes immediately into a List<int>
numbers.Clear();
Console.Write(timesTen.Count); // Still 2
}
變量捕獲
延遲執行還有一個不好的副作用。如果查詢的lambda表達式引用了程序的局部變量時,查詢會在執行時對變量進行捕獲。這意味著,如果在查詢定義之后改變了該變量的值,那么查詢結果也會隨之改變。
static void TestCapturedVariable()
{
int[] numbers = { 1, 2 };
int factor = 10;
IEnumerable<int> query = numbers.Select(n => n * factor);
factor = 20;
foreach (int n in query)
Console.Write(n + "|"); // 20|40|
}
這個特性在我們通過foreach循環創建查詢時會變成一個真正的陷阱。假如我們想要去掉一個字符串里的所有元音字母,我們可能會寫出如下的query:
IEnumerable<char> query = "How are you, friend.";
query = query.Where(c => c != 'a');
query = query.Where(c => c != 'e');
query = query.Where(c => c != 'i');
query = query.Where(c => c != 'o');
query = query.Where(c => c != 'u');
foreach (char c in query) Console.Write(c); //Hw r y, frnd.
盡管程序結果正確,但我們都能看出,如此寫出來的程序不夠優雅。所以我們會自然而然的想到使用foreach循環來重構上面這段程序:
IEnumerable<char> query = "How are you, friend.";
foreach(char vowel in "aeiou")
query = query.Where(c => c != vowel);
foreach (char c in query) Console.Write(c); //How are yo, friend.
結果中只有字母u被過濾了,咋一看,有沒有吃一驚呢!但只要仔細一想就能知道原因:因為vowel定義在循環之外,所以每個lambda表達式都捕獲了同一變量。當我們的query執行時,vowel的值是什么呢?不正是被過濾的字母u嘛。要解決這個問題,我們只需把循環變量賦值給一個內部變量即可,如下面的temp變量作用域只是當前的lambda表達式。
IEnumerable<char> query = "How are you, friend.";
foreach (char vowel in "aeiou")
{
char temp = vowel;
query = query.Where(c => c != temp);
}
foreach (char c in query) Console.Write(c); //Hw r y, frnd.
延遲執行的實現原理
查詢運算符通過返回裝飾者sequence(decorator sequence)來支持延遲執行。
和傳統的集合類型如array,linked list不同,一個裝飾者sequence并沒有自己用來存放元素的底層結構,而是包裝了我們在運行時提供的另外一個sequence。此后當我們從裝飾者sequence中請求數據時,它就會轉而從包裝的sequence中請求數據。
比如調用Where會創建一個裝飾者sequence,其中保存了輸入sequence的引用、lambda表達式還有其他提供的參數。下面的查詢對應的裝飾者sequence如圖所示:
IEnumerable<int> lessThanTen = new int[] { 5, 12, 3 }.Where(n => n < 10);

當我們遍歷lessThanTen時,實際上我們是在通過Where裝飾者從Array中查找數據。
而查詢運算符鏈接創建了一個多層的裝飾者,每個查詢運算符都會實例化一個裝飾者來包裝前一個sequence,比如下面的query和對應的多層裝飾者sequence:
IEnumerable<int> query = new int[] { 5, 12, 3 }
.Where(n => n < 10)
.OrderBy(n => n)
.Select(n => n * 10);

在我們遍歷query時,我們其實是在通過一個裝飾者鏈來查詢最初的array。
需要注意的是,如果在上面的查詢后面加上一個轉換運算符如ToList,那么query會被立即執行,這樣,單個list就會取代上面的整個對象模型。

浙公網安備 33010602011771號