第 101 講:C# 3 之查詢表達(dá)式(五):group 和 by 關(guān)鍵字
今天我們來(lái)繼續(xù)查詢表達(dá)式。今天我們講解的從句是 group-by
從句??梢钥吹?,這個(gè)從句的 group-by
寫法是分開(kāi)的,這里就和 orderby
從句不太一樣了。至于這么取名的原因我們之后再說(shuō),我們先說(shuō)一下這個(gè)從句的基本用法。
Part 1 引例:按性別分組
考慮一種情況。假設(shè)我要把整個(gè)序列按照男女分成兩組的話,應(yīng)該怎么做呢?
C# 提供了一個(gè)新的從句:group-by
從句,我們需要這么寫代碼:
是的,看起來(lái)未免有點(diǎn)太簡(jiǎn)單了。我們引入的 group-by
從句寫法是這樣的:
group
和 by
關(guān)鍵字的中間插入一個(gè)表達(dá)式,表示我要將整個(gè)表達(dá)式按什么結(jié)果作為分組的基本信息,而 by
后面,則是分組的依據(jù)。這個(gè) group
和 by
中間的表達(dá)式,理解起來(lái)有些類似 select
從句后面跟的那個(gè)表達(dá)式。它可以是原始的迭代變量本身,也可以是一個(gè)其它的表達(dá)式,它寫出來(lái)是作為映射的。這里的也是一樣,我們要想將學(xué)生按男女分組,需要達(dá)到的條件有兩點(diǎn):
分組依據(jù)是學(xué)生的性別(即
Gender
屬性);分組后,我們最后得到的每一個(gè)元素仍然得是學(xué)生類型的實(shí)例,而不是單單取它的名字什么的。
因?yàn)槲覀円_(dá)到這樣兩點(diǎn)的條件,因此這里我們寫的是 group student by student.Gender
。
可以看到,此時(shí) group-by
從句的特殊之處就出來(lái)了:它是繼 select
從句之后第二個(gè)可以放在結(jié)尾的從句類型。實(shí)際上,和 select
一樣,它也只能放在末尾;如果要放在中間,也得和 select
從句一樣,末尾加一個(gè) into
從句部分來(lái)接續(xù)。不過(guò)這個(gè)就扯遠(yuǎn)了,我們一會(huì)兒再說(shuō)。先來(lái)說(shuō)表達(dá)式的結(jié)果。
Part 2 分組查詢表達(dá)式結(jié)果的使用
篩選出來(lái)之后是什么樣的結(jié)果呢?使用怎么使用呢?下面我們就來(lái)說(shuō)一下如何去使用這個(gè)表達(dá)式的結(jié)果。
2-1 IGrouping<TKey, TElement>
接口類型
先來(lái)說(shuō)一個(gè)全新的接口類型:IGrouping<TKey, TElement>
。這個(gè)接口是不是很像是 Dictionary<TKey, TValue>
?是的,這個(gè)接口算是 LINQ 機(jī)制里的字典類型里對(duì)一個(gè)鍵值對(duì)的抽象。換句話說(shuō),這個(gè)接口類型差不多在理解上就是“一個(gè)鍵值對(duì)類型”。這個(gè) IGrouping<TKey, TElement>
接口類型好比是你實(shí)例化字典期間,初始化傳入進(jìn)去的鍵值對(duì)。只不過(guò)這個(gè)接口類型是“一個(gè)鍵對(duì)應(yīng)一組值”,而字典的鍵值對(duì)則是“一個(gè)鍵對(duì)應(yīng)一個(gè)值”。所以嚴(yán)謹(jǐn)一點(diǎn)的話呢,IGrouping<TKey, TElement>
和 Dictionary<TKey, IReadOnlyCollection<TElement>>
的其中一個(gè)鍵值對(duì)的意思差不多。
好像有點(diǎn)繞。我們舉個(gè)例子吧。使用一下上面查詢表達(dá)式得到的結(jié)果。
2-2 使用 IGrouping<,>
接口和 Key
屬性
我們寫兩層 foreach
循環(huán)。外層 foreach
循環(huán)用來(lái)遍歷所有的分組。這每一個(gè)分組都對(duì)應(yīng)了一種情況,也就是前面我們要求的按性別分組的機(jī)制。比如例子要求按性別分組,那么整個(gè)查詢表達(dá)式的結(jié)果就是由兩個(gè)分組構(gòu)成的集合;而這兩個(gè)分組的區(qū)別就是性別不一樣。同一個(gè)分組里的所有元素,性別都一樣,但兩個(gè)分組是按性別分的,所以所有男生在一組,所有女生在一組。
接著,我們使用 IGrouping<,>
接口里面自帶的 Key
屬性,獲取分組依據(jù)。這個(gè)分組依據(jù)指的就是性別(即 Gender
屬性的數(shù)值)。因?yàn)椴僮魇前茨信M(jìn)行分組,所以 Key
屬性就對(duì)應(yīng)男或者女,具體是哪一個(gè),就看這個(gè)分組是男生組還是女生組。
最后,我們?cè)诶锩嬖賹懸粋€(gè) foreach
循環(huán),表示迭代和遍歷整個(gè)這一組里的所有元素。迭代方法就是把這個(gè) IGrouping<,>
類型的實(shí)例給拿出來(lái),然后直接 foreach
它即可。
因此,整個(gè)上述的描述可以表達(dá)成下面這樣的代碼。
我們來(lái)看一下運(yùn)行效果:

可以看到,Female
是一組,Male
則是另外一組。每一組的元素在輸出顯示的時(shí)候,我都在模式字符串上加上了四個(gè)空格,用來(lái)表示類似縮進(jìn)的概念。這樣看到的結(jié)果就可以更容易區(qū)分 Female 的元素有哪些,而 Male 的元素有哪些。
這就是分組的用法。通過(guò)分組查詢表達(dá)式,我們可以得到的結(jié)果是一個(gè)若干結(jié)果構(gòu)成的集合,這若干結(jié)果是按照指定的東西分組之后的結(jié)果;而每一個(gè)結(jié)果,都代表了一組元素,它們的分組依據(jù)是相同的。因此,從這個(gè)漢字表達(dá)可以推導(dǎo)出,group-by
從句在執(zhí)行后,表達(dá)式反饋的結(jié)果類型應(yīng)該是 IEnumerable<IGrouping<Gender, Student>>
類型的。是的,確實(shí)有些長(zhǎng)了。不過(guò)我們可以拆解開(kāi)去理解它:

如果這么去看的話,應(yīng)該就不難理解了;而 IEnumerable<>
類型本身就可以 foreach
,而 IGrouping<,>
也是一個(gè)“一對(duì)多”的鍵值對(duì),它的 foreach
就等于是在遍歷里面的每一個(gè)值。所以這就是為什么,這個(gè)分組查詢表達(dá)式的結(jié)果需要用兩層 foreach
來(lái)搞定。
Part 3 可分組的對(duì)象類型
既然都說(shuō)了分組了,自然我們就要說(shuō)說(shuō),什么樣的對(duì)象可以使用 group-by
從句。
group-by
從句使用 group
和 by
兩部分構(gòu)成,group
后會(huì)跟一個(gè)變量或者表達(dá)式,而 by
后則也會(huì)跟一個(gè)變量或表達(dá)式。其中起到分組效果的是 by
這部分而不是 group
。實(shí)際上 group
這部分的表達(dá)式只是表示分組后,將什么結(jié)果作為反饋,因此阿貓阿狗都可以;而 by
后必須要是可以進(jìn)行分組的表達(dá)式。
那么,啥樣的東西可以分組呢?有相等性比較功能的對(duì)象類型唄。顯然,我隨便使用一個(gè)啥都沒(méi)有的實(shí)例去 group-by
就肯定不行:
C
類型,啥代碼都不實(shí)現(xiàn)。然后我們?cè)囍鴮?duì) C
注意 by
后直接寫的是 c
。那么這樣的代碼可行嗎?顯然不行。c
啥都沒(méi)實(shí)現(xiàn),怎么可能可以成功分組呢?要想正確分組,只要你得比較判斷這個(gè)對(duì)象是不是這一組的數(shù)據(jù)。那么,比較自然指的就是相等性的比較了。難不成你分個(gè)組還要大小比較一下?肯定不是嘛。
如果你實(shí)在理解不了,你可以想一下,假設(shè)你是圖書館的管理員,作為管理員你需要分門別類地放置書籍。你的做法肯定是拿起一本書,然后看一下書本自己的分類,然后去拿這個(gè)去和書架上印的分組名進(jìn)行比對(duì)。比對(duì)發(fā)現(xiàn)是一樣的,我們才會(huì)放進(jìn)去,對(duì)吧。那么這個(gè)比對(duì)過(guò)程用編程的視角來(lái)說(shuō),就是字符串的逐個(gè)字符比較。而且這個(gè)“比對(duì)一樣”指的就是字符串相等。
因此,上面這樣的 group c by c
的寫法就是不合適的。要想允許一個(gè)對(duì)象(或者這個(gè)類型的表達(dá)式)能夠參與分組操作(即寫在 group-by
從句的 by
后面),至少要求對(duì)象的數(shù)據(jù)類型可以進(jìn)行相等性比較。嚴(yán)謹(jǐn)一點(diǎn)的話呢,就是必須至少得重寫掉 object
派生下來(lái)的這個(gè) Equals
方法,或者是實(shí)現(xiàn) IEquatable<>
接口。當(dāng)然,我們推薦實(shí)現(xiàn) IEquatable<>
這個(gè)接口來(lái)達(dá)到相等性比較的功能,這樣的話性能會(huì)稍微好一些,比起重寫 Equals
方法來(lái)說(shuō)。因?yàn)?Equals
方法的參數(shù)是 object
類型的,而不是具體的數(shù)據(jù)類型。
Part 4 group-by-into
從句
如果單純只是看分組的話,其實(shí)理解起來(lái)難度還行。如果要分組之后繼續(xù)使用結(jié)果的話,就不太容易去理解了。
4-1 引例
考慮一個(gè)情況。我在構(gòu)造器里傳入了四個(gè)參數(shù),如果第二個(gè)參數(shù)代表的是學(xué)號(hào)的話,并且我們把學(xué)號(hào)的前四位當(dāng)成入學(xué)年份來(lái)看,那么如果我們想要按這些學(xué)生的入學(xué)年份來(lái)進(jìn)行分組的話,我們可以這么做:
是的,student.Id.Substring(0, 4)
可以得到每一位學(xué)生的學(xué)號(hào)(假設(shè)用 Id
屬性來(lái)表示的話),然后取出整個(gè)字符串的前四個(gè)字符。
運(yùn)行結(jié)果也是合理的:

唯一的一組是,我們只是分組,所以并不能保證分組后的序列是真正按照年份進(jìn)行升序或降序排序的。那么我們就需要對(duì)結(jié)果進(jìn)行排序。怎么做呢?
我們接續(xù) group-by
從句,在后面加上 into
,并給分組結(jié)果取名叫 currentGroup
;接著,我們?cè)诤竺媸褂?orderby
從句對(duì)齊進(jìn)行升序排序;最后別忘了 select
從句(因?yàn)?orderby
是不能作結(jié)尾的)。所以代碼是這樣的:
稍微啰嗦一點(diǎn)說(shuō)一下代碼。這里我們插入了兩個(gè)
let
從句,是因?yàn)槲覀円茨攴萆蚺判?,但?wèn)題是字符串的大小比較我們比較生疏。字符串是可以進(jìn)行大小比較的,看的是字典序。不過(guò)我們要按照年份排序,更為嚴(yán)謹(jǐn)正確的處理過(guò)程是,將字符串表達(dá)的整數(shù)數(shù)值給解析為真正的整數(shù),然后進(jìn)行大小比較。
into
從句后跟的currentGroup
是什么類型的呢?沒(méi)錯(cuò),和select-into
從句類似,select-into
從句的into
后跟的變量是select
從句給的當(dāng)前表達(dá)式的運(yùn)算結(jié)果。正因?yàn)槿绱耍?/span>group-by-into
的into
后也是當(dāng)前group-by
操作得到的當(dāng)前結(jié)果。所以,它是什么類型的呢?沒(méi)錯(cuò),IGrouping<string, Student>
類型的!第一個(gè)泛型參數(shù)的實(shí)際類型string
對(duì)應(yīng)分組依據(jù)是入學(xué)年份(的字符串表達(dá),因?yàn)槭?Substring
處理后的結(jié)果類型),而第二個(gè)泛型參數(shù)的實(shí)際類型Student
就是每一個(gè)當(dāng)前入學(xué)年份的學(xué)生的實(shí)例。因?yàn)樗?IGrouping<string, Student>
,因此必然會(huì)有一個(gè)Key
屬性,用來(lái)表示當(dāng)前分組下的入學(xué)年份是什么值。我們要進(jìn)行排序,因此要把這個(gè)值取出,并使用兩個(gè)let
從句將其解析為int
類型,并參與orderby
操作,進(jìn)行排序。最后,我們要把結(jié)果原封不動(dòng)返回,所以最后的select
從句里寫的還是currentGroup
而不是別的。如果這里寫的是別的的話,那么排序后就不再會(huì)把迭代結(jié)果給正確反饋出去了,那么foreach
循環(huán)就不能得到正確的分組結(jié)果了。另外,
ascending
也是可以去掉的。
我們?cè)俅芜\(yùn)行程序,發(fā)現(xiàn)年份已經(jīng)成功分組后進(jìn)行升序排列結(jié)果:

4-2 對(duì) currentGroup
內(nèi)的元素進(jìn)行操作
前面的代碼我們已經(jīng)足夠我們?nèi)粘5氖褂昧?。不過(guò)很顯然,我們還能繼續(xù)排序。比如說(shuō)我要將每一個(gè)分組里的元素成員按姓名的字典序進(jìn)行升序排序,比如看前面的圖片可以發(fā)現(xiàn),2021 年入學(xué)的三位學(xué)生,藤宮香織以 S 字母開(kāi)頭,字典序比杰瑞的 J 和康娜醬的 K 都靠后,卻排在了前面。如果非要摳細(xì)節(jié)的話,我們確實(shí)可以去按照每一組的學(xué)生的名字來(lái)繼續(xù)排序一下。不過(guò),這怎么搞呢?
我們觀察一下原始代碼。顯然 select
從句后的表達(dá)式是 IGrouping<,>
類型的,因?yàn)檫@個(gè)變量就是原來(lái) group-by-into
后的那個(gè)變量。但是,因?yàn)?IGrouping<,>
接口是無(wú)法改變的,它只能通過(guò) group-by
從句執(zhí)行后反饋出來(lái),因此我們無(wú)法自己去使用這個(gè)接口來(lái)完成;否則你就得自己創(chuàng)建一個(gè)集合類型,去實(shí)現(xiàn)這個(gè)接口。顯然,太復(fù)雜了。
這里我們就需要一個(gè)小技巧了。我們?cè)囍ジ淖?select
從句的結(jié)果,使用一次嵌套查詢。因?yàn)?IGrouping<,>
接口允許我們迭代,因此我們可以繼續(xù)使用 foreach
循環(huán)來(lái)完成這項(xiàng)任務(wù)。
我相信你肯定看不習(xí)慣這個(gè)寫法,我們居然在 select
從句上使用了嵌套查詢。這是可以被接受的,而且 C# 確實(shí)允許我們這么做。我們?cè)?select
從句后跟了一個(gè)查詢表達(dá)式,將這個(gè)查詢表達(dá)式看成整體的話,就不難理解了:我想要迭代得到排序后的序列,所以我們不得不對(duì) IGrouping<,>
的可迭代結(jié)果進(jìn)行排序。
注意,排序后的結(jié)果是什么類型的呢?IOrderedEnumerable<>
類型的。這個(gè)接口和 IEnumerable<>
差別不大,所以你當(dāng)成 IEnumerable<>
的用法來(lái)用就行。不過(guò),這樣迭代后,有一個(gè)信息的損失——整個(gè)嵌套查詢返回的結(jié)果是一個(gè)可迭代的實(shí)例,但原來(lái)這里返回的是 IGrouping<,>
類型的實(shí)例,這個(gè)實(shí)例除了可以迭代以外,還有一個(gè) Key
屬性,用來(lái)取整個(gè)分組的分組依據(jù)的數(shù)值。我們改成嵌套查詢后就沒(méi)這個(gè)信息了。這咋辦呢?
沒(méi)關(guān)系,改成匿名類型實(shí)例返回就行了:
Elements
屬性的值。因此,我們只需要改一下內(nèi)層 foreach
studentsGroup.Elements

可以發(fā)現(xiàn),名字也排序了。這便是嵌套查詢位于 select
從句的特殊技巧。
當(dāng)然,你如果確實(shí)看不習(xí)慣的話,可以使用 let
從句為這個(gè)嵌套查詢結(jié)果單獨(dú)創(chuàng)建出變量來(lái),然后試著將其帶入結(jié)果返回:
4-3 group-by-into
從句的拆解
之前說(shuō)過(guò) select-into
從句,我們說(shuō)明了一下它的形成緣由是為了內(nèi)聯(lián)查詢表達(dá)式,把查詢表達(dá)式給放在一起,into
從句則提供了接續(xù)的機(jī)會(huì)。那么對(duì)于 group-by-into
呢?我們能否做到這項(xiàng)任務(wù)呢?
答案是可以的。實(shí)際上,group-by-into
從句,以 into
作為分界線,我們可以拆解成兩部分:前面的內(nèi)容部分是一個(gè)查詢表達(dá)式,表示一個(gè)結(jié)果,而 into
后的則是另一個(gè)查詢表達(dá)式。比如前面的代碼:
into
into
從句后的代碼里作為迭代的開(kāi)始,而 from
后跟的是 into
從句的變量名,而 from-in
從句的 in
后的部分,則是這個(gè)前面分離出來(lái)的臨時(shí)變量 studentsGroupedByYearTemp
是的,這就是拆解的結(jié)果。當(dāng)然,拆解的方式是唯一的,因此要想合并起來(lái),只需要將剛才的拆解過(guò)程反向處理即可。來(lái)看一下完整的拆解結(jié)果吧:
好耶!
Part 5 為什么 group-by
要分開(kāi)寫,而 orderby
則是一起的?
那么,我來(lái)回答一下,為什么 orderby
是合在一起的關(guān)鍵字,但 group-by
是拆開(kāi)的兩個(gè)關(guān)鍵字。這個(gè)其實(shí)不太好說(shuō)明白。編譯器在分析代碼的時(shí)候,是為了盡量簡(jiǎn)單的方式來(lái)解決問(wèn)題的。C# 的編譯器的代碼分析能力有點(diǎn)恐怖了(恐怖到啥地步呢?恐怖到通過(guò)了圖靈測(cè)試)。
但是,編譯器會(huì)從優(yōu)解決翻譯和改寫代碼。group-by
從句執(zhí)行的操作顯然需要拆開(kāi),因?yàn)橛脩粲袝r(shí)候不一定非得去迭代返回整個(gè)對(duì)象本身,有些時(shí)候我只需要對(duì)象的取一部分信息即可???orderby
只是對(duì)于對(duì)象參與排序,而排序后你要干嘛都行,但都不屬于排序應(yīng)該做的事情。所以,從人為的邏輯上來(lái)說(shuō),orderby
只處理排序,跟你取里面啥東西沒(méi)有關(guān)系;但分組的話你可以設(shè)置取什么內(nèi)容和信息。
其次,從編譯器層面來(lái)說(shuō),orderby
的實(shí)現(xiàn)機(jī)制其實(shí)不難,你只需要實(shí)現(xiàn)一個(gè)排序就行了。可問(wèn)題就在于,orderby
是可以疊加排序依據(jù)的,也就是說(shuō),你可以 orderby s.A, s.B, s.C
甚至更多用逗號(hào)分開(kāi),但 group-by
做不到;相反,orderby
正是因?yàn)榭紤]到語(yǔ)法的復(fù)雜性,因此編譯器并沒(méi)有允許我們直接 orderby
的 order 和 by 兩個(gè)詞語(yǔ)中間插入東西。
詳情的話,請(qǐng)看一下霍姚遠(yuǎn)同學(xué)在 GitHub 上。
https://github.com/dotnet/csharplang/issues/3609#issuecomment-649205148
可能問(wèn)題看起來(lái)有點(diǎn)超綱,不過(guò)看下他的回答就可以了。
Part 6 總結(jié)
我們今天講解了如何使用 group-by
從句,以及它的執(zhí)行結(jié)果類型 IEnumerable<IGrouping<,>>
。整個(gè)接口是允許迭代的,而每一個(gè)元素又是一個(gè)分了組之后的元素序列。里面的這個(gè) IGrouping<,>
接口可以理解為一對(duì)多的鍵值對(duì),在迭代里面的元素之外,還包含 Key
屬性,用來(lái)獲取這個(gè)分組下的分組依據(jù)的數(shù)值是什么。比如學(xué)生按班級(jí)分組的話,那么一年一班是一組,一年二班是一組,一年三班是一組,等等。那么這個(gè) Key
屬性就對(duì)應(yīng)一年一班、一年二班和一年三班等等。
接著,我們還說(shuō)了 group-by-into
從句,into
可以用來(lái)接續(xù),使得 group-by
的代碼可以延續(xù)繼續(xù)使用。接著我們還說(shuō)了怎么去處理和修改 into
后的這個(gè)變量里面包含的元素,比如變更序列的順序(用 orderby
排序序列后,直接改掉 select
從句的表達(dá)式,改用嵌套查詢表達(dá)式的模式來(lái)完成這個(gè)任務(wù))。
至此,group-by
就有了一個(gè)比較深入的認(rèn)知。下一節(jié)我們將講的是最后一個(gè)從句類型:join
從句。