C#學習筆記(八)—–LINQ查詢之延遲執行

LINQ查詢之延遲執行

在LINQ中,另一個很重要的特性就是延遲執行,也可以說是延遲加載。它是指查詢操作並不是在查詢運算符定義的時候執行,真正使用集合中的數據時才執行,例如遍歷數據集合時調用MoveNext方法會觸發查詢操作,下面是一個簡單的示例:

var numbers = new List<int>();
numbers.Add (1);
IEnumerable<int> query = numbers.Select (n => n * 10); // Build query
numbers.Add (2); // Sneak in an extra element
foreach (int n in query)
Console.Write (n + "|"); // 10|20|

在上面的示例中,在創建查詢語句後又向集合中添加新的元素,這個新的元素最終也出現在查詢結果中,這就是因爲欻性能語句實在遇到foreach之後才真正執行的,而foreach在numbers.Add(2)之後,所以輸出集合中包含了這個元素。這是所謂的推遲或延遲執行,他與委託實現的效果相同:

Action a = () => Console.WriteLine ("Foo");
// 上面這句實際上並不會在控制檯上面輸出任何東西,現在,我們再寫下面一句:
a(); // 延遲執行的道理在這裏!

這種特性就是所謂的延遲加載,絕大部分標準的LINQ查詢運算符都具有延遲加載這種特性,當然也有例外,以下是幾個例外的運算符:
①那些返回單個元素或者返回一個數值的運算符,如First或者Count。
②轉換運算符:ToArray、ToList、ToDictionary、ToLookUp等等ToXXX方法。
以上這些運算符都會觸發LINQ語句立即執行,因爲他們的返回值類型不支持延遲加載。例如Count運算符,它返回一個簡單的整型數據,而一個整型數據不會用到遍歷操作,因此Count運算符會被立即執行,如下面這個實例所示:

int matches = numbers.Where (n => n < 2).Count(); // 1

在LINQ中,延遲加載特性具有很重要的意義,這種設計將查詢的創建和查詢的執行進行了解耦,這使得我們可以將查詢分爲多個步驟來創建,有利於查詢表達式的書寫,而且在執行的時候按照一個完整的結構去查詢,減少了對集合的查詢次數,這種特性在對數據庫的查詢中尤爲重要。需要注意的是,子查詢中表達式有額外的延遲加載限制,無論是聚合運算符還是轉換運算符,如果出現在子查詢中,他們都會被強制的進行延遲加載。後面將會討論他們。

重複執行

延遲執行導致的後果是,當兩次同時遍歷一個集合時,查詢被重複執行。也就是當多次查詢同一個數據集時,它後一次遍歷不會使用前一次遍歷的結果,而是再次到數據源中進行一次新的查詢。如下面這個示例:

var numbers = new List<int>() { 1, 2 };
IEnumerable<int> query = numbers.Select (n => n * 10);
foreach (int n in query) Console.Write (n + "|"); // 10|20|
numbers.Clear();
foreach (int n in query) Console.Write (n + "|"); // <nothing>

這種重複查詢是LINQ的缺點,他帶來以下兩個方面的影響:
①無法緩存在某一時刻的狀態,以供後面的代碼使用。
②對於一些遠程數據源,例如遠端的數據庫,這種重複執行會大大降低執行效率。
這種問題是可以解決的。一般使用轉換運算符來繞過這種重複執行,如ToArray或者ToList等ToXXX。ToArray是把結果集中的元素拷貝到一個新的數組中;ToList也一樣的功能。這兩種方式都可以保存查詢結果,下次需要使用結果集時,直接使用這兩種結合中保存的數據即可,下面是一段示例:

var numbers = new List<int>() { 1, 2 };
List<int> timesTen = numbers
.Select (n => n * 10)
.ToList(); // 立即執行,將結果存放於List<int>中
numbers.Clear();
Console.WriteLine (timesTen.Count); // 仍然是2,沒有被清除

捕獲的變量

還是閉包的問題。
延遲加載還會帶來另一個問題,如果在Lambda表達式中使用了本地變量,那麼Lambda表達式使用的是這個本地變量的引用,而不是他拷貝。這就意味着,如果在其他地方改變了本地變量的值,Lambda表達式中的結果也會發生改變。如下面的示例所示:

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循環裏面,那麼在這之前對factor做的任何改變都會影響到lambda獲取的局部變量的值,因爲①lambda會自動更新值②lambda總是在真正MoveNext的時候才獲取值。
儘管很多人知道這個問題,但是還是會犯錯,特別是在foreach循環中更容易出錯。如下面的實例,去掉一個字符串中的元音字符。首先是下面這種寫法,看起來效率不高,但可以得到正確的結果:

IEnumerable<char> query = "Not what you might expect";
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); // Nt wht y mght xpct

如果使用foreach語句來重構這段代碼,會使整個查詢更簡潔高效,現在讓我們來看看重構後會發生什麼事情,代碼如下:

IEnumerable<char> query = "Not what you might expect";
string vowels = "aeiou";
for (int i = 0; i < vowels.Length; i++)
query = query.Where (c => c != vowels[i]);
foreach (char c in query) Console.Write (c);

列舉查詢時會拋出IndexOutOfRangeException異常。前面我們也討論過,這是因爲編譯器將for循環中的迭代變量作用域定義爲與循環外部聲明的一樣了(意即捕獲了局部變量形成閉包)。因此,每一個閉包都會捕獲到相同的變量(i),查詢在執行實際列舉步驟時,他的值是5.爲了解決這個問題,必須將循環變量賦值給一個臨時變量:

for (int i = 0; i < vowels.Length; i++)
{
char vowel = vowels[i];
query = query.Where (c => c != vowel);
}

這就迫使在每一次循環迭代中獲得一個新的變量值。
**提示:**C#5.0中可以將它變成foreach循環:

foreach (char vowel in vowels)
query = query.Where (c => c != vowel);

這個方法在C#5.0可以生效,但是在之前的版本同樣會失敗,原因在前面的內容中講過。

延遲加載的工作原理

LINQ查詢操作符通過返回裝飾器序列提供延遲執行。與傳統的集合類不同,例如數組或鏈表(他們都代表了真正的內存。你讓一個返回IEnumerable的東西怎麼去代表一個真正的內存?),裝飾器序列(一般而言)沒有自己的支持結構來存儲元素,相反,它封裝了在運行時提供的另一個序列,它將保持一個永久的依賴項。當您從裝飾器請求數據時,它輪流請求來自包裝輸入序列的數據。
調用Where的時候僅僅是構造了一個裝飾器序列,在Where內部所做的操作都非常簡單,根據lambda表達式中指定的查詢條件,對輸入集合重新進行了篩選,保留那些符合條件的元素的指針引用,當外部遍歷Where的返回值時,Where會到它所關聯的集合中進行真正的查詢,然後返回查詢結果。

IEnumerable<int> lessThanTen = new int[] { 5, 12, 3 }.Where (n => n < 10);

這裏寫圖片描述
當執行lessThanThen操作時,實際上,就是使用where運算符對封裝序列進行篩選。
當需要使用一些具有特殊功能的運算符時,我們可以自己動手使用C#中的迭代器輕鬆的實現。例如下面這個實例所示,實現一個自定義的Select運算符:

public static IEnumerable<TResult> Select<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
foreach (TSource element in source)
yield return selector (element);
}

這個方法通過使用yield return而變成一個C#的迭代器,以下是一種快捷方式:

public static IEnumerable<TResult> Select<TSource,TResult>
(this IEnumerable<TSource> source, Func<TSource,TResult> selector)
{
return new SelectSequence (source, selector);
}

SelectSequence是一個(編譯器編寫的)類,它的枚舉器封裝迭代器方法中的邏輯。
因此,當您調用諸如Select或Where之類的操作符時,您所做的只是實例化一個可以裝飾輸入序列的可枚舉類。

連續使用封裝集合

如果使用運算符流語法對集合進行查詢,會創建多個層次的封裝集合。一下面的這個查詢爲例:

IEnumerable<int> query = new int[] { 5, 12, 3 }.Where (n => n < 10)
.OrderBy (n => n)
.Select (n => n * 10);

下圖演示了執行這個語句時對集合進行處理的過程,這是一個完整的對象處理模型,需要注意的是,這個對象模型一定是在查詢語句被真正執行之前就創建好的:
這裏寫圖片描述
在使用LINQ語句的返回集合時,實際是在原始的輸入集合中進行查詢,只不過在進入原始集合之前,會經過上面這些裝飾器的處理,在不同的層次的裝飾器中,系統會對查詢中相應的修改,這使得LINQ語句使用的各種查詢條件會被反映到最終的查詢結果中。如果在LINQ查詢語句的最後加上ToList方法,會強制LINQ語句立即執行,查詢結果會被保存到一個List類型的集合中。
下圖使用UML的方式重新演示了裝飾器之間的層次結構。從中可以看出,Select子句的裝飾器指向orderby的裝飾器,後者又指向了where的裝飾器,而where最終指向一個實際的真實存在於內存中的數組。LINQ查詢的延遲加載有這樣的一個功能,不論查詢語句是連續書寫的(像上面這樣select、orderby、where)還是分多個步驟完成的,在執行前,都會被組合成一個完整的對象模型,而寫兩種書寫方式所產生的對象模型是一樣的,例如下面這種書寫方式並不會導致查詢被多次執行:

IEnumerable<int>
source = new int[] { 5, 12, 3 },
filtered = source .Where (n => n < 10),
sorted = filtered .OrderBy (n => n),
query = sorted .Select (n => n * 10);

這裏寫圖片描述

查詢語句的執行方式

下面這幾行代碼遍歷了前面得到的query集合:

foreach (int n in query) Console.WriteLine (n);
30
50

這個foreach遍歷了query集合,並輸出了集合中的元素。分析代碼可知,foreach會調用select裝飾器(整個lINQ語句最外部的運算符)中的GetEnumeator方法,由這個方法拉開整個查詢的帷幕。結果是一個枚舉器的鏈表,它在結構上反映了裝飾序列的鏈。下圖展示了隨着枚舉的展開,執行的流程。
這裏寫圖片描述
在本章的第一部分中,我們將查詢描述爲傳送帶的生產線。
擴展這個類比,我們可以說LINQ查詢是一個懶惰的生產線,傳送帶只根據需要滾動元素。
構造一個查詢就是構造一個生產線,所有的東西都在一個地方,但是沒有任何滾動。
然後,當使用者請求一個元素(在查詢中枚舉)時,最右邊的傳送帶會激活;這反過來會觸發其他的,當需要輸入序列元素時。
LINQ遵循的是一個需求驅動的拉模型,而不是一個供給驅動的驅動模型。
這是很重要的,因爲我們將會看到後期,允許LINQ擴展到查詢SQL數據庫。

發佈了47 篇原創文章 · 獲贊 8 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章