第 77 講:C# 2 之委托逆變參數(shù)和協(xié)變返回
我們經(jīng)常使用委托來(lái)“代理”一個(gè)函數(shù)。我們使用 new 委托(方法名)
的方式來(lái)告訴運(yùn)行時(shí),我這個(gè)方法我將在以后會(huì)用到,并且執(zhí)行,但不是現(xiàn)在??赡承r(shí)候,問(wèn)題總是離譜的。
C# 2 對(duì)委托類型有三個(gè)擴(kuò)展語(yǔ)法規(guī)則,其中兩個(gè)“方法組轉(zhuǎn)換”和“匿名函數(shù)”已經(jīng)說(shuō)過(guò)了,下面要說(shuō)的是第三種語(yǔ)法規(guī)則,它包含下面兩種情況:
協(xié)變返回(Covariance on Return)
逆變參數(shù)(Contravariance on Parameter)
它們均針對(duì)的是委托類型賦值方的方法組。
本節(jié)內(nèi)容微微有點(diǎn)難,如果看不懂也無(wú)妨。不過(guò)我在網(wǎng)上搜了很多資料,貌似對(duì)這個(gè)特性的介紹并不多。所以我這里盡量對(duì)這個(gè)語(yǔ)法特性的機(jī)制說(shuō)得清楚一些。
Part 1 參數(shù)的逆變性
1-1 理解參數(shù)的逆變性
考慮一種情況。假設(shè)我有一個(gè) MammalHandler
類型的委托,是這樣的:
那么,這個(gè)委托就可以接受一個(gè)參數(shù)類型是 Mammal
類型的實(shí)例,并不返回任何數(shù)值的方法。這個(gè) mammal 意思是“哺乳動(dòng)物”。我們假設(shè)這個(gè)委托類型表達(dá)一個(gè)意思是,處理一個(gè)哺乳動(dòng)物的基本數(shù)據(jù)信息。
現(xiàn)在,我有如下的三種數(shù)據(jù)類型:Animal
、Mammal
和 Cat
,分別表示和對(duì)應(yīng)“動(dòng)物”、“哺乳動(dòng)物”和“貓咪”。
那么請(qǐng)看下面的代碼:
Mammal
參數(shù)類型都對(duì)不上了,而且 Animal
派生了 Mammal
類型出來(lái)。那么,這樣的寫法是正確的嗎?你肯定會(huì)說(shuō)是錯(cuò)誤的,因?yàn)轭愋屯耆筒患嫒萋?。我怎么可能?huì)傳入一個(gè)比它“大”的類型呢?再怎么說(shuō)也應(yīng)該是傳入比它“小”的兼容類型吧。可事實(shí)是,從 C# 2 開(kāi)始,OnAnimalHandling
可以得到允許,而 OnCatHandling
反而不允許。這不是挺奇怪嗎?為啥會(huì)有這樣的現(xiàn)象?
想想看,我要求的是在委托類型里接受和兼容的數(shù)據(jù)類型是 Mammal
,而我添加到回調(diào)函數(shù)列表的這個(gè) OnCatHandling
卻僅允許接受一個(gè) Cat
類型的實(shí)例。你覺(jué)得,這科學(xué)嗎?顯然不科學(xué)。我們兼容和允許接受的類型是是一個(gè)哺乳動(dòng)物,這意味著什么長(zhǎng)頸鹿啊、什么豬豬啊、什么狗狗之類的哺乳動(dòng)物也都應(yīng)該從哺乳動(dòng)物類型 Mammal
這里派生下來(lái),這是良好的設(shè)計(jì)。而正是因?yàn)檫@種設(shè)計(jì)原則,所以我完全可能在調(diào)用 handler
委托實(shí)例的時(shí)候傳入一個(gè)不是 Cat
類型的實(shí)例進(jìn)去。如果我們?cè)试S OnCatHandling
這樣的方法成立的話:
那么假設(shè)前文的 handler += OnCatHandling
成立的話,就會(huì)出現(xiàn)完全不正常、不穩(wěn)定、不安全的類型錯(cuò)誤:你傳入了 Giraffe
類型實(shí)例完全跟 Cat
就不相關(guān),這肯定是不安全的類型轉(zhuǎn)換模式和處理機(jī)制。因此,在 C# 這個(gè)編程語(yǔ)言的語(yǔ)法設(shè)計(jì)里,并未允許 Cat
類型作為傳參的方法可以賦值給委托實(shí)例,因?yàn)轭愋筒⒉患嫒荨?/span>
從這種視角來(lái)看,由于 Cat
是 Mammal
的其中一種派生類型,那么按照這種語(yǔ)言設(shè)計(jì)規(guī)則來(lái)看,這種不允許的情況應(yīng)當(dāng)推廣到任何派生類型上去。因此,C# 規(guī)定,委托實(shí)例的回調(diào)函數(shù)的參數(shù)必須是委托類型簽名的參數(shù)的相同類型,或是它的基類型。而在早期 C# 的原生語(yǔ)法里,“或是它的基類型”這一部分內(nèi)容是不允許的。C# 2 里則允許的是這一部分情況。
好了。可能你還是覺(jué)得一頭霧水,那么我們?cè)賮?lái)說(shuō)一下,允許這種機(jī)制到底是為了干嘛。
1-2 逆變參數(shù)的真正用途
下面我用窗體程序的相關(guān)知識(shí)點(diǎn)給大家介紹一下為啥這個(gè)機(jī)制得已存在并且好用的原因。如果你沒(méi)有接觸過(guò)窗體程序的話,那么你可以先跳過(guò),等以后你學(xué)了一些相關(guān)的處理機(jī)制后,我們?cè)倩仡^看也是可以的。
在窗體程序之中,我們經(jīng)常會(huì)遇到處理按下按鍵、鼠標(biāo)點(diǎn)擊等等和鍵鼠這些硬件交互操作的過(guò)程,它們?cè)?C# 里往往都用事件來(lái)表示。要知道,事件就是委托字段的封裝,因此我們這里就當(dāng)成委托實(shí)例的交互來(lái)理解就行。
考慮一種情況,我要想讓鍵盤按鍵和鼠標(biāo)點(diǎn)擊都執(zhí)行同一個(gè)方法操作,這可能嗎?顯然不可能。因?yàn)槭髽?biāo)點(diǎn)擊后,肯定交互數(shù)據(jù)是跟鼠標(biāo)有關(guān)的;而在鍵盤操作后,肯定交互數(shù)據(jù)是跟鍵盤有關(guān)的。假設(shè)我們給鼠標(biāo)點(diǎn)擊期間的交互數(shù)據(jù)封裝為 MouseEventArgs
類型來(lái)包裹這些信息,而給鍵盤操作期間的交互數(shù)據(jù)封裝成 KeyboardEventArgs
類型來(lái)包裹這些信息,而它們均從 EventArgs
類型派生:
那么,我封裝了兩個(gè)委托類型來(lái)分別表示和處理鼠標(biāo)操作和鍵盤操作的過(guò)程:
MouseClick
來(lái)表達(dá)我現(xiàn)在鼠標(biāo)按下后要執(zhí)行什么方法;而 KeyboardPress
現(xiàn)在,有了 C# 2 提供的這個(gè)機(jī)制,我們就可以“一勞永逸”了:
我們?nèi)屖录幚淼臅r(shí)候,都只回調(diào) OnEventOccurred
這個(gè)方法。而 OnEventOccurred
方法此時(shí)允許的參數(shù)類型只需要是委托實(shí)例對(duì)應(yīng)參數(shù)類型的基類型即可,而恰好我們直接寫上 EventArgs
就行;所以這么做相當(dāng)方便。
1-3 為什么叫逆變參數(shù)?
可問(wèn)題來(lái)了。為啥這種機(jī)制叫逆變呢?這參數(shù)的類型兼容被“放大”了,這難道不是協(xié)變嗎?呃……這個(gè)命名其實(shí)是看的你實(shí)際傳入的參數(shù)類型和實(shí)際類型的對(duì)比關(guān)系。你想想看,我允許 Mammal
為類型參數(shù)傳入,而你傳入的確實(shí)是 Animal
類型,甚至比 Mammal
類型還要“大”。但是,你在調(diào)用的時(shí)候,這個(gè) Animal
會(huì)被縮小范圍到 Mammal
類型去調(diào)用和使用,你真正在開(kāi)始 Invoke
委托實(shí)例的時(shí)候,你只能傳入 Mammal
類型的實(shí)例進(jìn)去,而并不能傳入 Animal
類型的實(shí)例進(jìn)去。所以,真正意義上來(lái)看,是允許了基類型參數(shù)賦值到了派生類型參數(shù)的委托類型上去。這就是一個(gè)反向的類型兼容的機(jī)制,所以才叫它逆變參數(shù)。
1-4 不只是派生類,還可以是派生的接口
如題,這種機(jī)制除了可以允許派生類型的逆變參數(shù)以外,你也可以用到接口上。換句話說(shuō),假如 Animal
從 IAnimal
接口派生(即實(shí)現(xiàn)了 IAnimal
接口),你甚至可以允許把一個(gè)傳入 IAnimal
接口的方法給傳入賦值到 MammalHandler
委托實(shí)例上去:
Part 2 返回值的協(xié)變性
2-1 理解返回值的協(xié)變性
我們還是使用前面給出的那些數(shù)據(jù)類型來(lái)舉例。不過(guò)這次我們?yōu)榱苏f(shuō)明返回值是協(xié)變的,我們需要重新創(chuàng)建一個(gè)新的委托類型。
假設(shè)我們有一個(gè)委托類型 MammalCreator
,返回一個(gè) Mamal
類型,表達(dá)的意思是“創(chuàng)造一個(gè)哺乳動(dòng)物”,它的類型聲明是這樣的:
Mammal
是的,這樣的寫法確實(shí)沒(méi)有問(wèn)題,因?yàn)椴粠?shù),返回值的類型也是合適的?,F(xiàn)在我這么去改造一下方法:
其實(shí)也沒(méi)改造很多地方,也就第 3 行的返回值類型從 Mammal
改成了更為具體的 Cat
類型。這是允許的嗎?是的,這是允許的;但反過(guò)來(lái)你改成 Animal
這些就不行了。這是為什么呢?
這個(gè)比前文的參數(shù)逆變性要好說(shuō)一些。仔細(xì)思考一下,我給的回調(diào)函數(shù),它的返回值類型如果比委托類型原本給的返回值類型的范圍還要大的話,會(huì)有什么樣的結(jié)果?是不是就不安全了啊?我明明出的是 Mammal
類型,可你居然給我出了一個(gè) Animal
類型出來(lái)。委托可都沒(méi)允許我們這么返回,你居然這么大張旗鼓地返回一個(gè)更大的類型,那自然 C# 肯定不讓你這么做。沒(méi)明白?那我換個(gè)說(shuō)法。委托給的是 Mammal
類型作為返回值,可你返回了一個(gè)超出 Mammal
類型的實(shí)例(比如 Animal
這樣的類型),自然是不允許的。因?yàn)槟阒粦{借這個(gè)回調(diào)函數(shù)自身來(lái)看,由于你的返回值是 Animal
類型,而委托類型必須要求你返回 Mammal
類型,而你又根本無(wú)法保證和約束你這個(gè)方法一定返回的是 Mammal
的實(shí)例,所以編譯器自然不讓你這么干。
那么,推廣到比較廣泛的層面來(lái)說(shuō),委托實(shí)例的回調(diào)函數(shù)的返回值必須是委托類型簽名的返回值的相同類型,或是它的派生類型。
2-2 協(xié)變返回的用途
這有啥好說(shuō)的,都可以認(rèn)定為具體類型了,那么自然我們更喜歡和更習(xí)慣去處理具體類型。而范圍更“大”的數(shù)據(jù)類型我們則根本不能確認(rèn)它的類型,進(jìn)而不容易去處理一些事情。于是乎,有了這種機(jī)制,我們就可以讓回調(diào)函數(shù)是具體的類型,這樣更容易處理一些。
2-3 為什么叫協(xié)變返回?
emmm,雖然光看這段結(jié)論確實(shí)也是有點(diǎn)分不清,不過(guò)我們還是可以使用前面完全一樣的思路去理解。
協(xié)變返回之所以是協(xié)變性,是因?yàn)檫@種機(jī)制允許我們的回調(diào)函數(shù)的返回值類型是一個(gè)派生類型,而它相對(duì)于委托類型自身帶有的返回值類型來(lái)說(shuō),是協(xié)變的,畢竟我們定義的回調(diào)函數(shù)的返回值更“小”,而委托簽名的返回值類型則更“大”一些。
2-4 當(dāng)然,它也可以用于接口
是的,它也可以用在接口類型上。比如:
如果委托類型的簽名的返回值是 IAnimal
接口類型的話,那么你給的回調(diào)函數(shù)的返回值就可以是所有實(shí)現(xiàn)了 IAnimal
接口的類型。
Part 3 匿名函數(shù)的參數(shù)和返回值的可變性不在考慮范疇
很遺憾的是,我們前文舉例說(shuō)明的時(shí)候,都沒(méi)有使用匿名函數(shù)機(jī)制,是因?yàn)槲彝擞脝???shí)際上并不是。匿名函數(shù)的參數(shù)和返回值的可變性并未考慮在內(nèi)。換句話說(shuō),即使我們知道前文的這樣的代碼是成立的:
但,我們更換為匿名函數(shù)語(yǔ)法,卻并不行:
是的,你打開(kāi) Visual Studio,編寫代碼后,編譯器并不予以通過(guò),并且會(huì)告訴你,類型不兼容的錯(cuò)誤信息。因此,這種委托可變性的機(jī)制僅用于原生語(yǔ)法。