第 82 講:C# 2 之迭代器語(yǔ)句(一):yield 語(yǔ)句
今天我們來(lái)學(xué)習(xí)一個(gè)新的語(yǔ)法:yield
語(yǔ)句。這個(gè)語(yǔ)法稍微比較復(fù)雜,它也成為了以后學(xué)習(xí)很關(guān)鍵的語(yǔ)法 async
/await
的奠基石,所以我們將這個(gè)語(yǔ)法分成兩講來(lái)說。
Part 1 模式的概念
今天我們?yōu)榱私榻B新語(yǔ)法之前,我們先來(lái)說一個(gè)概念:模式(Pattern)。
在當(dāng)前 C# 的環(huán)境,模式一共是有兩層含義:
表示一種實(shí)現(xiàn)模型的若干代碼組成的一段代碼,它表示一種固定含義的代碼,可用來(lái)當(dāng)成模板套用到任何地方上去;
表示一種判斷對(duì)象存儲(chǔ)的數(shù)據(jù)類型和數(shù)值信息的語(yǔ)法。
這兩種東西在 C# 里都叫模式,也都使用的是 pattern 這個(gè)單詞,不過它們使用的場(chǎng)合和環(huán)境不一樣,這里我們所說的是第一種;而從 C# 7 開始,我們才會(huì)介紹第二種模式的概念。
我們今天要說的模式,指的是一段固定邏輯的代碼,它的實(shí)現(xiàn)方式、手段可以變化,但做到的是同一項(xiàng)任務(wù),而且這種代碼可以作為模板代碼,套用到任何你想用到的地方上去。
舉個(gè)例子,我們要想迭代一個(gè)集合(就是使用 foreach
語(yǔ)句來(lái)完成遍歷集合的每一個(gè)元素的過程),我們有兩種手段:
將數(shù)組這種本身就是集合的類型封裝到整個(gè)類型里當(dāng)字段,然后迭代器直接用
GetEnumerator
方法來(lái)獲取即可;自己實(shí)現(xiàn)獨(dú)立的迭代器類型,通過鴨子類型的機(jī)制,滿足迭代器類型即可,無(wú)需實(shí)現(xiàn)
IEnumerable
或IEnumerable<T>
接口。
為了我們介紹后面的內(nèi)容,我們重新復(fù)習(xí)一下,我們之前對(duì)第二種實(shí)現(xiàn)的整體過程。
1-1 引例
假設(shè)我們自己實(shí)現(xiàn)了 List<T>
類型,迭代 T
類型的每個(gè)成員。
如果我們沒有自己的迭代器的話,由于 Array
類型的復(fù)雜性和處理機(jī)制的問題,_array
類型雖然是 T[]
的,我們也都知道可以直接 foreach
語(yǔ)句迭代出 T
類型的實(shí)例,可迭代器里我們并不能這么去書寫代碼,而單純使用 _array.GetEnumerator()
方法返回的是 IEnumerator
非泛型類型的實(shí)例,它自帶的 Current
類型成員是 object
類型而不是 T
的,因此無(wú)論如何都沒辦法精確到 T
上去,要么無(wú)法實(shí)現(xiàn),要么必須裝拆箱。所以,我們要想避免裝拆箱操作,不得不使用自己定義的迭代器類型。
寫法其實(shí)也不難,我們將數(shù)組的迭代語(yǔ)句看成是 while
循環(huán)執(zhí)行語(yǔ)句,那么 foreach
就可以自己等價(jià)換成 while
循環(huán):
可以看到,代碼變化不大。于是我們只需要將數(shù)組迭代的邏輯拆解為反復(fù)的移動(dòng)迭代指針的指向,并反復(fù)獲取當(dāng)前指向元素的過程就可以了。于是,我們可以這么實(shí)現(xiàn)代碼來(lái)完成這樣的操作:
List<T>
類型的 GetEnumerator
1-2 迭代器模式
可以看到,我們?yōu)榱藘?yōu)化性能,不得不創(chuàng)建一個(gè)帶有 MoveNext
的方法和一個(gè) Current
屬性的新類型(這個(gè) Enumerator
類型),然后讓用來(lái)迭代的類型(這個(gè)自己實(shí)現(xiàn)的 List<T>
)直接實(shí)現(xiàn) GetEnumerator
方法,返回值直接用 Enumerator
類型來(lái)代替原本的接口類型。
這些實(shí)現(xiàn)是必要的,但也都是固定代碼。即使你實(shí)在是記不住它們,你也大概有印象,實(shí)現(xiàn)它們需要分三步:
打開冰箱門
把大象放進(jìn)去
關(guān)上冰箱門
呃……不是這個(gè)。是這樣的三步:
實(shí)現(xiàn)
Enumerator
類型,傳入迭代的對(duì)象_array
用作構(gòu)造器參數(shù);實(shí)現(xiàn)
Enumerator
類型的Current
屬性(返回當(dāng)前結(jié)果即可)和MoveNext
方法(必須返回bool
類型);在用于實(shí)現(xiàn)迭代的類型里,給出
GetEnumerator
方法,用Enumerator
類型作為返回值。
這個(gè)過程是固定的,實(shí)現(xiàn)的代碼也都是固定的,唯一的區(qū)別是,你自己實(shí)現(xiàn)的時(shí)候可能就變量名換一下,或者是風(fēng)格稍微換一下,先寫屬性 Current
后寫 MoveNext
方法,還是先寫 MoveNext
方法后寫 Current
屬性的問題。但整個(gè)過程完全都是類似的,我們就把這樣的固定模板代碼稱為一種模式。而我們上面介紹的模式,目的是優(yōu)化性能減少甚至根治裝拆箱的方式,允許對(duì)象使用 foreach
循環(huán)迭代自身,即用于迭代器的模式,因此我們就稱為迭代器模式(Iterator Pattern)。
C# 里有眾多的模式等著我們?nèi)グl(fā)現(xiàn),迭代器模式是其中的一種。而有人將其總結(jié)起來(lái)然后整理成了一本書介紹,所以 C# 的寫法和用法都相當(dāng)靈活。如果你想要學(xué)習(xí)模式的實(shí)現(xiàn)的話,有單獨(dú)的一個(gè)介紹分支,叫設(shè)計(jì)模式,它會(huì)告訴你的。不過本教程只考慮語(yǔ)法,因此設(shè)計(jì)模式并不是側(cè)重點(diǎn),所以教程只簡(jiǎn)單提及這些概念;我以后會(huì)考慮出的。確實(shí)想學(xué),我可以給你一個(gè)參考鏈接,請(qǐng)參考介紹的內(nèi)容。
Part 2 yield
語(yǔ)句
說完了迭代器模式的完整語(yǔ)法,我們可以看到,這種語(yǔ)法下,我們?nèi)耘f需要自己定義和設(shè)計(jì)迭代的內(nèi)容以及過程,而且就只是因?yàn)閮?yōu)化性能的原因創(chuàng)建了許多面向?qū)ο蟮挠纺[代碼,實(shí)際上起作用的也就 MoveNext
和 Current
這幾行的代碼而已。因此,C# 2 提供了另外一種處理辦法:yield
語(yǔ)句。這個(gè)所謂的 yield
是一個(gè)新的 C# 關(guān)鍵字,繼 C# 2 開始,我們已經(jīng)見到了不少的上下文關(guān)鍵字了,比如前面說到的 global
、alias
關(guān)鍵字等等。yield
是為了簡(jiǎn)化迭代器的實(shí)現(xiàn)語(yǔ)法才產(chǎn)生的。
我們先從簡(jiǎn)單的開始說。找兩個(gè)例子來(lái)給大家介紹一下,yield
的用法。
2-1 有限序列的迭代
假設(shè),我要想迭代一個(gè)可以自己手動(dòng)列舉的集合的話,代碼可以寫得相當(dāng)簡(jiǎn)單。考慮一種情況,我想要迭代一下科目的所有名字,大家其實(shí)都知道,科目也就語(yǔ)數(shù)外政史地理化生這些,先暫時(shí)不考慮體育這些課程的話,那么九個(gè)科目是完全可以手動(dòng)枚舉和列舉出來(lái)的。那么,我們可以這么寫代碼:
請(qǐng)注意一下返回值類型。我們使用 IEnumerable<string>
來(lái)表示一個(gè) string
列表,可用來(lái)使用 foreach
循環(huán)來(lái)迭代序列。里面的代碼則是更為暴力:列舉每一個(gè)名字,然后直接使用了一個(gè)新的語(yǔ)句類型:yield return
語(yǔ)句來(lái)表示。
是的,yield return
語(yǔ)句改變了 return
關(guān)鍵字原本的語(yǔ)義:返回?cái)?shù)值。大家都知道,由于 return
表示返回結(jié)果并退出方法,所以沒有人會(huì) return
疊起來(lái)書寫的。而這次,yield return
貌似并不是返回結(jié)果然后退出,反而是挨個(gè)將這些字符串信息“返回”出去。是的,仍然是一個(gè)返回的概念,但此時(shí)我們不會(huì)再退出方法了。
最后,我們?cè)诘?12 行上加了 yield break;
這樣的語(yǔ)句。顯然,它不是 break
的基本用法:跳出循環(huán),而是跳出整個(gè)迭代器。是的,這才是真正的、退出方法的語(yǔ)句。因此,這里理解起來(lái)有些繞:
yield break
語(yǔ)句:迭代結(jié)束,退出迭代過程,返回整個(gè)序列;yield return
語(yǔ)句:將當(dāng)前結(jié)果拿到,作為迭代序列里的其中一員。
反而是這里的 yield break
是返回了,所以這里需要你額外注意這種語(yǔ)法的格式和用法。
那么,這樣的方法,它的用法呢?返回值不是 IEnumerable<string>
類型的對(duì)象么,那么我們直接當(dāng)成結(jié)果,foreach
它就可以了:
Classes()
方法是返回一個(gè)序列,一個(gè)可以 foreach
的序列,每一個(gè)都是 string
類型的元素的序列,因此在迭代的時(shí)候,我們使用 foreach (string @class in Classes())
的語(yǔ)法就可以搞定,和一般的返回 IEnumerable
以及 IEnumerable<T>
接口的方法用法是完全沒有區(qū)別的。唯一要注意的是,class
是類型的關(guān)鍵字,因此需要你使用 @class
加一個(gè)原義標(biāo)記 @
表示我們用的是標(biāo)識(shí)符 class
,而不是表示關(guān)鍵字 class
。
最后稍微說一下。前面介紹的
yield break
語(yǔ)句是可以不寫的,因?yàn)樗『煤竺鏇]有內(nèi)容了。因?yàn)槟銓懗鰜?lái)和不寫出來(lái),后面都因?yàn)闆]有任何的執(zhí)行代碼而直接被中斷掉,所以寫不寫都無(wú)所謂。就和寫在最后一句的return
語(yǔ)句一樣,寫不寫都沒有什么區(qū)別。另外,yield return
和yield break
都是yield
開頭的語(yǔ)句格式,因此我們將它們統(tǒng)稱為yield
語(yǔ)句(Yield Statement)。那么為什么
yield
關(guān)鍵字要用 yield 這個(gè)詞語(yǔ)呢?Yield 是什么意思?“產(chǎn)生”、“提供”的意思。當(dāng)然 yield 還有別的意思,比如“屈服”、“讓步”等等,不過這里我們用的是產(chǎn)生的這個(gè)釋義。是的,yield return 連起來(lái)就是“產(chǎn)生返回結(jié)果”的意思,而 yield break 連起來(lái)則是“產(chǎn)生中斷”的意思,它剛好契合我們這里處理機(jī)制的邏輯和語(yǔ)義,所以 yield 是再合適不過的單詞。注意,可能你學(xué)過 C# 高級(jí)編程,你可能聽說過
Task.Yield
方法表示讓出主使權(quán)和執(zhí)行權(quán),不過這里的 yield 單詞的意思是“屈服”、“讓步”這一套的意思,和這里迭代器模式里用到的“產(chǎn)生”、“提供”沒有一丁點(diǎn)的關(guān)系。
2-2 循環(huán)迭代
前面講了一下如何簡(jiǎn)單迭代一個(gè)我們完全可以列舉的情況,下面我們來(lái)說一下,如何迭代一個(gè)給定集合。
我們?nèi)允褂们懊孀铋_始的 Enumerator
實(shí)現(xiàn)的那個(gè)例子(_array
迭代的那個(gè))來(lái)講解。這次我們不再自己創(chuàng)建 Enumerator
類型,而是考慮使用 yield
語(yǔ)句的形式。
這個(gè)語(yǔ)句好用就好用在這里。我們這次改成使用 T
類型作為接收結(jié)果的 IEnumerator<T>
類型,作為返回值類型:
請(qǐng)看看這個(gè)例子。這個(gè)例子牛逼就牛逼在,它解決了我們前面說的“無(wú)法實(shí)現(xiàn)”的問題。前文我們說過,_array
類型雖然是 T
類型的元素的數(shù)組,也確實(shí)可以用 T
類型為迭代變量的類型使用在 foreach
循環(huán)里面,可問題就在于它的返回值是 IEnumerable
非泛型的接口類型,因此即使你迭代出來(lái),每一個(gè)元素也都是 object
類型的。因此,我們可以這么去轉(zhuǎn)換一下語(yǔ)義。
我們將返回值類型仍使用原來(lái)本應(yīng)該用到的 IEnumerator<>
類型來(lái)表達(dá),不過這次我們用上泛型接口版本來(lái)代替掉原本的非泛型版本。而我們?cè)谑褂?foreach
迭代集合里的 _array
時(shí),這次我們可以使用 T
類型了,于是通過每次循環(huán)得到的 element
,作為結(jié)果來(lái) yield return
到返回序列里去。
最后,循環(huán)執(zhí)行完成后,yield break
終止迭代過程(當(dāng)然,這個(gè) yield break
后面也沒東西了,也可以省略不寫)。

我們對(duì)比兩個(gè)例子,前面是原來(lái)的寫法,后面是現(xiàn)在的寫法,是不是簡(jiǎn)略了不少東西。這就是 yield
語(yǔ)句的魅力。
Part 3 注意事項(xiàng)
是的,yield
語(yǔ)句有非常不錯(cuò)的語(yǔ)法簡(jiǎn)化機(jī)制,因此給我們書寫代碼的時(shí)候帶來(lái)了非常優(yōu)雅的書寫代碼的方式。但是,它也不是隨時(shí)隨地都能用。下面我們來(lái)說一些需要注意的地方。
3-1 返回值只能是 IEnumerable
和 IEnumerator
接口及泛型版本
想必這個(gè)說起來(lái)很容易理解。IEnumerable
和它的泛型版本 IEnumerable<>
的目的是,直接返回一個(gè)可以 foreach
的對(duì)象;而 IEnumerator
和泛型版本 IEnumerator<>
的目的是,直接返回一個(gè)允許當(dāng)前類型的對(duì)象可以 foreach
的迭代器輔助對(duì)象。不過,yield
語(yǔ)句的機(jī)制的特殊性,C# 允許我們?cè)谶@兩種情況下,都可以使用 yield
語(yǔ)句來(lái)完成執(zhí)行任務(wù)。但是,別的類型就不行了。哪怕這個(gè)類型滿足那套鴨子類型的規(guī)則,或者別的什么理由,C# 都是不允許的,這是因?yàn)樗牡讓訉?shí)現(xiàn)機(jī)制導(dǎo)致的。底層的代碼我們將在下一講的內(nèi)容給大家介紹。
3-2 不能在帶有 yield
語(yǔ)句的方法體里用 return
語(yǔ)句
yield
語(yǔ)句帶有固定的執(zhí)行語(yǔ)義,如果一個(gè)方法里帶有了這樣的語(yǔ)句,它就相當(dāng)于是改寫了方法體的執(zhí)行邏輯和規(guī)則。如果你混用 yield
語(yǔ)句和 return
語(yǔ)句的話,就會(huì)改變這個(gè)執(zhí)行規(guī)則:yield
語(yǔ)句表示帶出一個(gè)新的迭代元素和退出迭代過程;而 return
語(yǔ)句直接返回一個(gè) IEnumerable
或 IEnumerator
接口的實(shí)例。
你想想看,我直接在方法體里寫了 yield
語(yǔ)句,原本方法體里應(yīng)該用 return
來(lái)返回一個(gè)實(shí)現(xiàn) IEnumerator
或 IEnumerable
接口類型的實(shí)例,可現(xiàn)在我 yield return
可以按元素為單位帶出每一個(gè)迭代的東西,它們顯然不屬于同一種思維方式。
所以,yield
語(yǔ)句使用就直接改變了迭代的邏輯和方案,這意味著你用 yield return
或 yield break
語(yǔ)句就不能用 return
語(yǔ)句;你用了 return
語(yǔ)句也就不能用 yield return
或 yield break
語(yǔ)句:它們的流程控制是不同的。
3-3 不能往用了 yield
的方法參數(shù)上用 ref
和 out
修飾符
想想看,下面的這個(gè)方法有啥意義?
這個(gè) yield return 1
是我象征性返回一個(gè)元素的代碼,實(shí)際上可以不帶只有一個(gè)元素的集合。可參數(shù)這次用了 ref
修飾。這有意義嗎?
我們可以制作一個(gè)方法,傳入一個(gè)變量,表示我從 1 開始迭代到多少:
所以這個(gè)參數(shù) s
是有意義的??扇绻覀魅肓?ref int
而不是 int
,意義就完全不一樣了:ref int
參數(shù)的 ref
修飾符意味著這個(gè)數(shù)據(jù)是可以通過方法體里的修改,直接影響到方法的調(diào)用方的。
你看看,要真的是在帶 yield
語(yǔ)句的方法上,參數(shù)用了 ref
,你就完全沒辦法掌控這個(gè) ref
參數(shù)的具體變化的情況了。因?yàn)?yield
的迭代會(huì)被底層看待是一個(gè)象征性的元素返回過程,并整體整理為一個(gè)集合,提供 foreach
操作。在此期間,我們使用 ref
修飾的參數(shù)如果出現(xiàn)在代碼里,產(chǎn)生了變更,那么變量的數(shù)值到底是每一個(gè)迭代改一下,還是底層機(jī)制自己控制改變情況呢?這個(gè)我們根本說不清楚。
同理,out
也是一樣。out
參數(shù)意味著方法體內(nèi)賦值傳出方法給調(diào)用方使用的過程,即從內(nèi)往外的單向賦值過程。可問題就在于,ref
雙向的都不允許,out
難道就會(huì)允許了?它們都是會(huì)同時(shí)影響調(diào)用方和方法本身里的代碼執(zhí)行的修飾符,我們必須限制 ref
后也要限制 out
才行,不然傳出的參數(shù)是多少也都是不穩(wěn)定的。
可能你還是對(duì) yield
不熟悉,下面我們會(huì)通過另外一講的方式,給大家詳盡闡述一下 yield
的底層原理,即編譯器到底是怎么看待和理解 yield
這個(gè)連方法體的流程控制的思維都改變了的關(guān)鍵字用法。