《深入理解C#》整理7-查詢表達式和LINQ to Objects

一、LINQ介紹

1、LINQ中的基礎概念

1.1、序列

序列通過IEnumerable和IEnumerable接口進行封裝,它就像數據項的傳送帶——你每次只能獲取它們一個,直到你不再想獲取數據,或者序列中沒有數據了。序列和其他集合數據結構(比如列表和數組)之間最大的區別就是,當你從序列讀取數據的時候,通常不知道還有多少數據項等待讀取,或者不能訪問任意的數據項——只能是當前的這個。

序列是LINQ的基礎。在你看到一個查詢表達式的時候,應該要想到它所涉及的序列:一開始總是存在至少一個序列,且通常在中間過程會轉換爲其他序列,也可能和更多的序列連接在一起。舉個例子:結果不是字符串"Holly"和"Jon"——而是IEnumerable

image-20201027195353254

1.2、延遲執行與流處理

查詢表達式被創建的時候,不會處理任何數據,也不會訪問原始的人員列表。而是在內存中生成了這個查詢的表現形式。其判斷及轉換都是通過委託實例來表示的。只有在訪問結果IEnumerable的第一個元素的時候,整個車輪纔開始向前滾動。

LINQ的這個特點稱爲延遲執行。在最終結果的第一個元素被訪問的時候,Select轉換纔會爲它的第一個元素調用Where轉換。而Where轉換會訪問列表中的第一個元素,檢查這個謂詞是否匹配(在上個例子中,是匹配的),並把這個元素返回給Select。最後,依次提取出名稱作爲結果返回。如下圖:當我們使用foreach循環語句打印結果中的各個元素時,示例查詢表達式在運轉中的前幾個階段。

image-20201027200021029

在之前的章節中,我們已經看到過這樣的一個例子:Enumerable. Reverse方法需要提取所有可用的數據,以便把最後一個原始元素作爲結果序列的第一個元素返回。這使得Reverse成爲一個緩衝操作——它在效率上(甚至可行性上)對整個運算都有巨大的影響。如果你無法承受一次性把所有數據都讀到內存中的開銷,就不能使用緩衝操作。正如同流處理依賴於你所執行的操作,有些轉換一調用就會發生,而不會延遲執行。這稱爲立即執行。一般來說,返回另外一個序列的操作(通常是IEnumerable或IQueryable)使用延遲執行,而返回單一值的運算使用立即執行。

1.3、標準查詢操作符

LINQ的標準查詢操作符(如Select、Where方法)是一個轉換的集合,具有明確的含義。要跨多個數據源提供一致的查詢框架,這是極其重要的。

2、定義示例數據模型

下面定義一個數據模型供後續舉例使用:NotificationSubscription的作用是,每次相關項目的缺陷被創建或改變後,都發一封電子郵件到特定地址。

image-20201027201535680

二、簡單的開始:選擇元素

1、以數據源作爲開始,以選擇作爲結束

在C# 3中每個查詢表達式都以同樣的方式開始——聲明一個數據序列的數據源:from element in source,element只是一個標識符,它前面可以放置一個類型名稱。source是一個普通的表達式。在第一個子句出現之後,許多不同的事情會發生,不過遲早都會以一個select子句或group子句來結束。這裏我們使用select子句,它的語法也是很簡單的:select expression,select子句被稱爲投影。結合起來可以得到:from user in SampleData.AllUser select user

2、編譯器轉譯是查詢表達式基礎的轉譯

編譯器把查詢表達式轉譯爲普通的C#代碼,這是支持C# 3查詢表達式的基礎。它是以一種機v械的方式來進行轉換的,不會去理解代碼、應用類型引用、檢查方法調用的有效性或執行編譯器要執行的任何正常工作。編譯器會將上述的代碼轉換爲:SampleData.AllUsers.Select(user=>user)

C# 3編譯器進一步正確地編譯查詢表達式之前,就把它轉譯爲了這樣的代碼。它只是對代碼進行轉譯,並讓下一個編譯階段來處理查找適當方法的工作——不管這個方法是直接包含的成員,還是擴展方法。參數是一個適當的委託類型或者一個和類型T對應的Expression。重要之處在於,Lambda表達式能被轉換爲委託實例和表達式樹。轉譯執行之後,不管Lambda表達式中的普通變量(比如方法內部的局部變量)在哪出現,它都會以我們在第5章看到的方式轉換成捕獲變量。

實際上幾乎所有LINQ提供器都把數據顯示爲IEnumerable或IQueryable。轉譯不依賴於任何特定類型而僅僅依賴於方法名稱和參數,這是一種鴨子類型(Duck Typing)的編譯時形式。這和集合初始化程序使用了同樣的方式:使用普通的重載決策來查找公共方法調用Add,而不是使有包含特定簽名的Add方法的接口。查詢表達式進一步利用了這種思想——轉譯發生在編譯過程初期,以便讓編譯器來挑選實例方法或者擴展方法。

3、範圍變量和重要的投影

之前例子from user in SampleData.AllUser select user中的user爲聲明的範圍變量,select user爲投影表達式。範圍變量不像其他種類的變量。在某些方面它根本就不是變量。它們只能用於查詢表達式中,實際代表了從一個表達式傳遞給另外一個表達式的上下文信息。它們表示了特定序列中的一個元素,而且它們被用於編譯器轉譯中,以便把其他表達式輕易地轉譯爲Lambda表達式SampleData.AllUsers.Select(user=>user) ,表達式的左邊——提供參數名稱的部分來自於範圍變量的聲明,而右邊來自於select子句。

我們將user改爲user.Name作爲投影,即可看到結果變爲字符串序列,而不是User對象了。編譯器准許這樣做,是由於從Enumerable所選擇的Select擴展方法實際上具有如下簽名:static IEnumerable<TResult> Select<TSource,TResult>(this IEnumerable<TSource> source,Func<TSource,TResult> selector)。在將Lambda表達式轉換爲Func<TSource, TResult>的時候,類型推斷髮揮了作用。它首先根據SampleData.AllUsers的類型推斷出TSource爲User,這樣就知道了Lambda表達式的參數類型,並因此將user.Name作爲返回string類型的屬性訪問表達式,也就可以推斷出TResult爲string。這就是Lambda表達式允許使用隱式類型參數的原因,也就是會存在如此複雜的類型推斷規則的原因:這些都是LINQ引擎的“齒輪”和“活塞”。

4、Cast、OfType和顯式類型的範圍變量

大多數時候,範圍變量都可以是隱式類型,另外我們可以使用所需類型全部指定了的泛型集合。但如果我們想對一個ArrayList或一個object[]執行查詢,就只有依賴兩個標準查詢操作符來解決這個問題:Cast和OfType,其中Cast是查詢表達式語法直接支持的。

Cast通過把每個元素都轉換爲目標類型(遇到不是正確類型的任何元素的時候,就會出錯)來處理,而OfType首先進行一個測試,以跳過任何具有錯誤類型的元素。而在你引入了具有顯式類型的範圍變量後,編譯器就調用Cast來保證在查詢表達式的剩餘部分中使用的序列具有合適的類型。相關示例如下:

image-20201027205639723

其中顯式指定後轉譯後的代碼會變爲list.Cast().Select(entry=>entry.Substring(0,3)),沒有這個類型轉換,我們根本就不能調用Select,因爲該擴展方法只用於IEnumerable而不能用於IEnumerable、

三、對序列進行過濾和排序

1、使用where子句進行過濾

Where子句的格式爲where 過濾表達式。編譯器把這個子句轉譯爲帶有Lambda表達式的Where方法調用,它使用合適的範圍變量作爲這個Lambda表達式的參數,而以過濾表達式作爲主體。過濾表達式當作進入數據流的每個元素的謂詞,只有返回true的元素才能出現在結果序列中。使用多個where子句,會導致多個鏈接在一起的Where調用——只有滿足所有謂詞的元素才能進入結果序列。

2、退化的查詢表達式

到目前爲止,我們所有轉換後的查詢表達式都包含了對Select的調用。如果我們的select子句什麼都不做,只是返回同給定的序列相同的序列,編譯器會刪除所有對Select的調用。當然,前提是在查詢表達式中還有其他操作可執行時才這麼做。如:select defect in SampleData.AllDefects select defect,這就是所謂的退化查詢表達式。編譯器會故意生成一個對Select方法的調用,即使它什麼都沒有做。

3、使用orderby子句進行排序

orderby子句的一般語法基本上是上下文關鍵字orderby,後面跟一個或多個排序規則。一個排序規則就是一個表達式(可以使用範圍變量),後面可以緊跟ascending或descending關鍵字,它的意思顯而易見(默認規則是升序。)對於主排序規則的轉譯就是調用OrderBy或OrderByDescending,而其他子排序規則通過調用ThenBy或ThenByDescending來進行轉換。

OrderBy和ThenBy的不同之處非常簡單:OrderBy假設它對排序規則起決定作用,而ThenBy可理解爲對之前的一個或多個排序規則起輔助作用。要特別注意,儘管你能使用多個orderby子句,但每個都會以它自己的OrderBy或OrderByDescending子句作爲開始,這意味着最後一個纔會真正“獲勝”。也許會存在某些原因要包括多個orderby子句,不過這是很少見的。一般都應該使用單個子句來包含多個排序規則。

四、let子句和透明標識符

1、用let來進行中間計算

let子句只不過引入了一個新的範圍變量,它的值是基於其他範圍變量。語法是極其簡單的:let 標識符=表達式。當我們需要使用兩個範圍變量,但Lambda表達式只會給Select傳遞一個參數!這就該透明標識符出場了。它對一個表達式進行求值,並引入一個新的範圍變量。示例如下:

image-20201027212129819

2、透明標識符

上個例子的最後,我們在最後的投影中使用了兩個範圍變量,不過Select方法只對單個序列起作用。如何把範圍變量合併在一起?答案是,創建一個匿名類型來包含兩個變量,不過需要進行一個巧妙的轉換,以便看起來就像在select和orderby子句中實際應用了兩個參數。過程如下:

image-20201027212423842

let子句爲了實現目標,再一次調用了Select,併爲結果序列創建匿名類型,最終創建了一個新的範圍變量。對於原始的查詢表達式直接引用user或length的地方,如果引用發生在let子句之後,就用z.user或z.length來代替。這裏z這個名稱是隨機選擇的——一切都被編譯器隱藏起來。

嚴格地說,如何將不同的範圍變量組合到一起,並使透明標識符得以工作,取決於C#編譯器的實現。微軟的官方實現使用了匿名類型,規範中也展示了這種轉換。即使其他的編譯器使用了不同的方式,也不應該影響結果。

五、連接

1、使用join子句的內連接

內連接涉及兩個序列。一個鍵選擇器表達式應用於第1個序列的每個元素,另外一個鍵選擇器應用於第2個序列的每個元素。連接的結果是一個包含所有配對元素的序列,配對的規則是第1個元素的鍵與第2個元素的鍵相同。語法如下:join right-range-variable in right-sequence on left-key-selector equals right-key-selector。這裏使用equals而不是符號作爲上下文關鍵字,這樣更容易把左鍵選擇器和右鍵選擇器區分開。編譯器使用這個上下文關鍵字將鍵選擇器劃分爲不同的Lambda表達式。

在LINQ to Objects的實現中,使用左邊序列中第1個元素的所有成對數據先被返回(按右邊序列的順序),接着返回使用左邊序列中第2個元素的所有成對數據,依次類推。右邊序列被緩衝處理,不過左邊序列仍然進行流處理——所以,如果你打算把一個巨大的序列連接到一個極小的序列上,應儘可能把小序列作爲右邊序列。這種操作仍然是延遲的:在訪問第1個數據對時,它纔會開始執行,然後再從某個序列中讀取數據。這時,它會讀取整個右邊序列,來建立一個從鍵到生成這些鍵的值的映射。之後,它就不需要再次讀取右邊的序列了,這時你可以迭代左邊的序列,生成適當的數據對。在左邊的鍵選擇器中,只能訪問左邊序列的範圍變量;在右邊的鍵選擇器中,只能訪問右邊序列的範圍變量。如果你顛倒了左右兩邊的序列,那麼你必須也顛倒左右兩邊的鍵選擇器。幸好,編譯器知道這樣的常見錯誤,並建議你按恰當的步驟進行處理。示例如下:

image-20201027222143824

我們通常需要對序列進行過濾,而在連接前進行過濾比在連接後過濾效率要高得多。此外嵌套查詢表達式非常有用,不過也會降低可讀性:通常應該尋找一種替代方式,或者將右邊序列賦給一個變量,來讓代碼更加清晰。

內聯會被編譯器轉譯爲對Join方法的調用,leftSequence.Join(rightSequence,leftKeySelector,rightKeySelector,resultSelector),用於LINQ to Objects的重載簽名如下static IEnumerable<TResult> Join<TOuter,Tinner,Tkey,TResult>{this IEnumerbale<TOuter> outer,IEnumerable<TInner> inner,Func<TOuter,Tkey> outerKeySelector,Func<TInner,TKey> innerKeySelector,Func<TOuter,TInner,TResult> resultSelector}(通常來說內連接對應右邊,外連接對應左邊)

2、使用join...into子句進行分組連接

分組連接(group join)結果中的每個元素由左邊序列的某個元素和右邊序列的所有匹配元素的序列組成。後者用一個新的範圍變量表示,該變量由join子句中into後面的標識符指定。示例如下:

image-20201027222333932

內連接和分組連接之間的一個重要差異是,對於分組連接來說,在左邊序列和結果序列之間是一對一的對應關係,即使左邊序列中的某些元素在右邊序列中沒有任何匹配的元素也無所謂。在左邊元素不匹配任何右邊元素的時候,嵌入序列就是空的。與內連接一樣,分組連接要對右邊序列進行緩衝,而對左邊序列進行流處理

編譯器將分組連接轉譯爲簡單地調用GroupJoin方法,就像Join一樣。Enumerable.GroupJoin的簽名如下:static IEnumerable<TResult> GroupJoin<YOuter,TInner,TKey,TResult>(this IEnumerable<TOuter> outer,IEnumerable<TInner> inner,Func<TOuter,TKey> outKeySelector,Func<TInner,TKey> innerKeySelector,Func<TOuter,IEnumerable<TInner>,TResult> resultSelector)這個方法簽名和內連接的方法簽名非常相似,只不過resultSelector參數必須要用於處理右邊元素的序列,不能處理單一的元素。同內連接一致,如果分組連接後面緊跟着select子句,那麼投影就用作GroupJoin調用的結果選擇器,否則,就引入一個透明標識符。

3、使用多個from子句進行交叉連接和合並序列

交叉連接不在序列之間執行任何匹配操作:結果包含了所有可能的元素對。它們可以簡單地使用兩個(或多個)from子句來實現。每個額外的from子句都通過透明標識符添加了自己的範圍變量。

大多數時候,它就像指定了多表查詢的笛卡兒積。然而它有更強大的地方:在任意特定時刻使用的右邊序列依賴於左邊序列的“當前”值。也就是說,左邊序列中的每個元素都用來生成右邊的一個序列,然後左邊這個元素與右邊新生成序列的每個元素都組成一對。這並不是通常意義上的交叉連接,而是將多個序列高效地合併(flat)成一個序列。示例如下:

image-20201027222516849

編譯器用來生成這個序列所調用的方法是SelectMany。它使用單個的輸入序列(以我們的說法就是左邊序列),一個從左邊序列任意元素上生成另外一個序列的委託,以及一個生成結果元素(其包含了每個序列中的元素)的委託。下面是這個方法的簽名,再次寫成Enumerable. SelectMany的實例方法:static IEnumerable<TResult> SelectMany<TSource,TCollection,TResult>(this IEnumerbale<TSource>,Func<TSource,IEnumerable<TCollection>> collectionSelector,Func<TSource,TCollection,TResult> resultSelector)

和其他連接一樣,如果查詢表達式中連接操作後面緊跟的是select子句,那麼投影就作爲最後的實參;否則,引入一個透明標識符,從而使左右序列的範圍變量在後續查詢中都能被訪問。SelectMany的一個有意思的特性是,執行完全是流式的——一次只需處理每個序列的一個元素,因爲它爲左邊序列的每個不同元素使用最新生成的右邊序列。

SelectMany的合併行爲是非常有用的,你可能需要處理大量日誌文件,每次處理一行:

var query=from file in Dictionary.GetFiles(logDictionary,"*.log")
    	  from line in ReadLines(file)
          let entry=new LogEntry(line)
    	  where entry.Type==EntryType.Error
          select entry

六、分組和延續

1、使用group...by子句進行分組

要在查詢表達式中對序列進行分組,只需要使用group...by子句,語法如下:group projection by grouping。該子句與select子句一樣,出現在查詢表達式的末尾。projection表達式和select子句使用的投影是同樣的類型。只不過生成的結果稍有不同。

grouping表達式通過其鍵來決定序列如何分組。整個結果是一個序列,序列中的每個元素本身就是投影后元素的序列,還具有一個Key屬性,即用於分組的鍵;這樣的組合是封裝在IGrouping<TKey,TElement>接口中的,它擴展了IEnumerable 。同樣,如果你想根據多個值來進行分組,可以使用一個匿名類型作爲鍵。

編譯器總是對分組子句使用GroupBy的方法調用。當分組子句中的投影很簡單的時候——換句話說,在原始序列中的每個數據項都直接映射到子序列中的同一個對象的時候,編譯器將使用簡單的重載版本(只以分組表達式爲參數),它知道如何把每個元素映射到鍵上。示例如下:

image-20201028193342626

2、查詢延續

查詢延續提供了一種方式,把一個查詢表達式的結果用作另外一個查詢表達式的初始序列。它可以應用於group...by和select子句上,語法對於兩者是一樣的——你只需使用上下文關鍵字into,併爲新的範圍變量提供一個名稱就可以了。範圍變量接着能用在查詢表達式的下一部分。示例如下:

image-20201028194217240

注意:用於分組連接的join ... into子句不能形成一個延續的結構。主要的區別在於,在分組連接中,你仍然可以使用所有的早期範圍變量。而對比本節的查詢不難發現,延續會清除之前的範圍變量,只有在延續中聲明的範圍變量才能在供後續使用。

七、在查詢表達式和點標記之間作出選擇

查詢表達式在編譯之前,先被轉譯成普通的C#。用普通的C#調用LINQ查詢操作符來代替查詢表達式,這種做法並沒有官方名稱,很多開發者稱其爲點標記(dot notation)。每個查詢表達式都可以寫成點標記的形式,反之則不成立:很多LINQ操作符在C#中不存在等價的查詢表達式。

1、需要使用點標記的操作

最明顯的必須使用點標記的情形是調用Reverse、ToDictionary這類沒有相應的查詢表達式語法的方法。然而即使查詢表達式支持你要使用的查詢操作符,也很有可能無法使用你想使用的特定重載。

2、使用點標記可能會更簡單的查詢表達式

使用點標記可以比查詢表達式會更加清晰,但在實際情況下,應當先判斷所做的是什麼樣的查詢,並判斷哪一種方法更具可讀性。

3、選擇查詢表達式

在執行某些操作時(特別是連接操作),如果查詢表達式使用了透明標識符,這時點標記的可讀性就沒那麼高了。一個簡單的let子句可能就足以讓你選擇查詢表達式,而不是引入一個新的匿名類型只是爲了在查詢中擴充上下文。

查詢表達式佔優勢的另一種情況是,需要多個Lambda表達式,或多個方法調用。這同樣也包括連接在內,你需要爲每個連接方指定鍵選擇器,以及最終的結果選擇器。示例如下:

image-20201028200605640

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章