第 38 講:面向?qū)ο缶幊蹋ㄊ簩?duì)象的多態(tài)
前面,我們介紹了相當(dāng)多的繼承有關(guān)的有趣語法,比如繼承語法,比如抽象類(abstract class
)和密封類(sealed class
),有抽象成員、重寫成員(override
修飾的成員)和密封的重寫成員(sealed override
修飾的成員)等等。
下面我們繼續(xù)介紹面向?qū)ο蟮睦^承機(jī)制的一種特殊現(xiàn)象:多態(tài)。多態(tài)這個(gè)詞語是直接翻譯的 Polymorphism 這個(gè)單詞,因?yàn)檫@個(gè)詞語不屬于基本單詞,所以很少普通人知道這個(gè)詞語。對(duì)于我們初學(xué)面向?qū)ο蟮呐笥褌兌?,也不是很好理解。多態(tài)我們理解成“多種狀態(tài)”。
Part 1 基類型的實(shí)例
我們還是使用前面的 Shape
的例子。Shape
類型因?yàn)楸欢x為抽象類,因此無法使用實(shí)例化的 new
語句??墒?,我們這么寫代碼:
請(qǐng)看左右兩側(cè)的東西。左邊的 s
變量用的是 Shape
類型,而右側(cè)的實(shí)例化卻是用的 Circle
類型的構(gòu)造器。這個(gè)語法是正確的。為什么呢?Circle
表示“一個(gè)圓”,而 Shape
表示“一個(gè)形狀”,而圓就是一種形狀,所以為什么不行呢?
當(dāng)然,這是從邏輯上說的,所以道理應(yīng)該都明白。現(xiàn)在我們從代碼上這么考慮一下。
假設(shè),我們把 Shape
看成一個(gè)箱子。這個(gè)箱子里包含了你想要的東西(成員);當(dāng)你從 Shape
類型派生后,Circle
類型又是一個(gè)新的箱子。不過這個(gè)新的箱子顯然要和原來的 Shape
是一樣的,因?yàn)榛镜某蓡T都是一致的。不過,因?yàn)槟銊?chuàng)建的 Circle
類型是你可以人為控制的,因?yàn)樗梢詭в幸恍┬碌摹⒉皇?Shape
里的東西,于是你可以給箱子改裝一下。
現(xiàn)在,我們要把這個(gè)新的箱子丟給 s
變量(賦值給左邊)。s
是 Shape
類型的,因此它只需要檢測你給的這個(gè)箱子到底是不是包含 Shape
這些應(yīng)有的東西。顯然,繼承下來就一定包含它們,因此箱子你怎么改裝,肯定這些成員是不會(huì)丟失的,那么,自然就允許和通過檢測了,賦值是成功的。
Circle
類型的,也可以是 Shape
類型的,所以它這不就是兩個(gè)“狀態(tài)”了嘛。
那么這種賦值現(xiàn)象有什么用呢?用來把作變量類型的合取。假設(shè)我們有一個(gè)輸出一個(gè)形狀的方法:
所以,我們只需要把參數(shù)改成 Shape
類型的:
可以看到,只需要這么改變一下之后,我們什么圖形都可以調(diào)用這個(gè)方法了,只要這個(gè)變量是 Shape
類型的:
Shape
類型的,那么賦值是成功的,傳參的時(shí)候,s
、t
和 u
也都是 Shape
類型的,也符合參數(shù)的類型規(guī)則,因此一個(gè)方法支持多個(gè)執(zhí)行,當(dāng)然就是沒問題的了。
再說了,這么用,在 PrintArea
方法里,我們也只是用了 Area
屬性。顯然 Shape
自帶 Area
屬性,s
、t
和 u
肯定也都因?yàn)槔^承機(jī)制而自動(dòng)配了 Area
屬性的,所以憑什么我們又不讓它參與運(yùn)行呢?
這就是多態(tài)的好處。多態(tài)可以一勞永逸。當(dāng)然了,你也可以這么寫:
這么寫也可以。
這樣也可以。
Part 2 類型匹配
既然有多態(tài),那么如果我們類型是基類型的,那么我們咋知道這個(gè)類型具體是什么類型呢?這個(gè)時(shí)候我們就需要用到兩個(gè)運(yùn)算符了:is
和 as
運(yùn)算符。
2-1 is
運(yùn)算符
is
運(yùn)算符用來檢測類型是否正確。is
的左邊寫變量,右邊寫類型名稱。整個(gè)表達(dá)式結(jié)果是一個(gè) bool
類型的數(shù)值,表示是不是變量就是這個(gè)類型的。
大概這么用。
2-2 as
運(yùn)算符
as
運(yùn)算符可以轉(zhuǎn)換數(shù)據(jù)的類型,它等價(jià)于 obj is T ? (T)obj : null
這個(gè)表達(dá)式。其中的 T
就是寫在 is
后面的這個(gè)類型名稱。
比如這樣用。
2-3 強(qiáng)制轉(zhuǎn)換運(yùn)算符
前面我們用到了一個(gè)新鮮的類型轉(zhuǎn)換。如果一個(gè) Shape
類型原本就是從 Rectangle
這邊變過來的類型的話,我們可以使用 (Rectangle)obj
的語法。這是面向?qū)ο蟮睦^承里的一大特性。
如果從子類型轉(zhuǎn)基類型的話,因?yàn)槭且欢梢赞D(zhuǎn)換成功的,因此是隱式轉(zhuǎn)換的(即前面 Part 1 講的東西);
如果從基類型轉(zhuǎn)子類型的話,因?yàn)轭愋筒灰欢ǔ晒D(zhuǎn)換,因此是使用強(qiáng)制轉(zhuǎn)換的。
這就是繼承的轉(zhuǎn)換機(jī)制。當(dāng)然了,任何類型的變量,o is object
都應(yīng)該是為 true
的,因?yàn)樗蓄愋投紡?object
類型派生。
Part 3 object
類型:所有引用類型的根
下面我們來說一個(gè)新鮮玩意兒:object
類型。和 string
類型一樣,object
類型也是分關(guān)鍵字寫法和 BCL 名稱寫法兩種的。關(guān)鍵字 object
對(duì)應(yīng)了它的 BCL 名稱 Object
。
object
類型是所有類型的基類型。換句話說,如果你不寫繼承機(jī)制的語句:: 類名
,那么這個(gè)類型自動(dòng)從 object
類型派生;如果這個(gè)類型寫了繼承語句 : 類型
的話,那么這個(gè)類型就會(huì)從這個(gè)寫的這個(gè)類型派生;而寫出來的這個(gè)基類型,如果沒有繼承語句,那么它也是自動(dòng)從 object
派生的。
另外,就算是之前接觸的那些值類型,int
、double
這些,它們是從一個(gè)叫做 ValueType
的類型派生下來的;但 ValueType
是從 object
派生的。因此,不論你發(fā)現(xiàn)到什么東西,都是從 object
派生下來的。
說這個(gè)有什么用呢?下面我們來說一些 object
類型的成員。
3-1 Equals
方法
object
類型里有一個(gè)叫做 Equals
的方法,這個(gè)方法和之前我們提到過的 ReferenceEquals
方法一點(diǎn)區(qū)別都沒有:
可能你會(huì)問我,既然是一樣的,為什么要定義倆寫法不同的方法呢?因?yàn)?Equals
是被 virtual
修飾過的實(shí)例方法,而 ReferenceEquals
是靜態(tài)方法。
既然被 virtual
修飾過的,那么就意味著這方法可以重寫。因?yàn)樗窍到y(tǒng)自動(dòng)繼承的,因此這個(gè)方法不管你寫不寫繼承語句,都是自動(dòng)可以使用的方法。不過,如果你要比較內(nèi)部的數(shù)據(jù)的話,你可以重寫 Equals
方法。
假設(shè)我們還是用 Shape
類來舉例。
現(xiàn)在,我們把 Shape
類改裝成這樣。請(qǐng)注意第 5 行代碼,我們使用了一種新的語法 abstract override
組合關(guān)鍵字。這里,我們用到的是 object
類里自帶的 Equals
方法。那么,為什么我們可以這么組合呢?因?yàn)槲覀冞@里的 abstract
和 override
都有作用:abstract
是說這個(gè)方法是抽象的,那么在派生類里就必須給我實(shí)現(xiàn)這個(gè)方法;override
關(guān)鍵字則是表示這個(gè)方法是從基類型 object
里直接拿下來的。
接著,我們在繼承 Shape
類的時(shí)候,就需要同時(shí)實(shí)現(xiàn) Area
屬性和 Equals
方法了。
Rectangle
類型舉例。我們使用之前學(xué)的知識(shí)點(diǎn)來完善例子。再等下次,我們?nèi)绻褂玫?Equals
方法后,方法就會(huì)自動(dòng)定位到這里 Rectangle
里的 Equals
而不是 object
的 Equals
了。這樣,就和 ReferenceEquals
不再一致了。
3-2 GetHashCode
方法
要想明白這個(gè)方法為什么得以存在,地位還那么高(放在了 object
里),就得先知道一個(gè)概念:哈希碼(Hash Code)。
哈希碼,在 C# 里用一個(gè) int
類型的數(shù)值表示。任何世間萬物都通過一個(gè)公式(不論是系統(tǒng)自帶的,還是你自己寫的)來計(jì)算得到一個(gè)哈希碼。這個(gè)哈希碼用于直接區(qū)分對(duì)象是不是一致。換句話說,如果兩個(gè)對(duì)象的哈希碼一致,我們大概率認(rèn)為這兩個(gè)對(duì)象包含相同的數(shù)值;反之,如果哈希碼不同,那么我們大概率認(rèn)為這兩個(gè)對(duì)象可能有個(gè)別數(shù)據(jù)成員的數(shù)值不同,甚至是完全不同。
為什么說是“大概率”,而不是“一定相同”或者“一定不相同”呢?世間萬物都用公式計(jì)算的話,顯然是不合適的;另一方面,公式也不能夠完全區(qū)分兩個(gè)對(duì)象是不是相同。舉個(gè)例子,我有一個(gè)超長字符串(100 個(gè)字符的那種)和另外一個(gè)超長字符串(也是 100 個(gè)字符)。我如果要比較兩個(gè)字符串是否一致,顯然就是逐字符比較。遇到不同的字符就說明兩個(gè)字符串不同。
但是,如果通過哈希碼計(jì)算的話,就有一點(diǎn)問題。首先,在 C# 里的一個(gè)字符可以表示非常多的情況(大概??種不同的字符);那么 100 個(gè)字符就有?
種情況。很顯然這個(gè)數(shù)已經(jīng)是天文數(shù)字了。要想每一種情況都得配好一個(gè)哈希碼來的話,這肯定是不可能的事情,畢竟
GetHashCode
的默認(rèn)返回值是 int
的,這個(gè)你是改不了的。
所以,我們只能盡量做到“哈希碼數(shù)值不同能夠表達(dá)的對(duì)象不同”,而永遠(yuǎn)不可能找到一種辦法可以唯一表示任何一個(gè)字符串的通用計(jì)算哈希碼的公式。當(dāng)然,別的數(shù)據(jù)類型也是一樣,因?yàn)楫吘勾笮《疾灰粯勇铩?/span>
至于 C# 的
char
(字符類型)為什么有?種情況,這一點(diǎn)你可能需要參考一下 UTF-16 編碼,這里我們就不展開說明了。
這個(gè)方法,Visual Studio 會(huì)提示你在重寫了 Equals
方法的時(shí)候重寫它;或者是如果你不重寫 GetHashCode
方法的時(shí)候,Visual Studio 會(huì)告訴你“Equals
方法需要重寫”。雖然不能絕對(duì)保證數(shù)據(jù)不同,但 GetHashCode
確實(shí)可以用來比較數(shù)據(jù)。因?yàn)樵谝恍﹫龊舷?,哈希碼計(jì)算結(jié)果一定可以唯一表示一個(gè)數(shù)據(jù),且不同的數(shù)據(jù)產(chǎn)生的哈希碼一定不一樣。比如說我有一個(gè)叫做 Cell
的類,它包含兩個(gè)字段 Row
和 Column
。Cell
類型的對(duì)象表達(dá)的是一個(gè)格子的第幾行第幾列。那么假設(shè)整個(gè)網(wǎng)格最多只能 10 行 10 列的話,我們的哈希碼計(jì)算公式就可以這么寫:
是的,通過這個(gè)公式,我們就可以得到這個(gè)格子的哈希碼,而且因?yàn)槲覀兗僭O(shè)的網(wǎng)格最多只能 10 行 10 列,所以我們無法超過這個(gè)規(guī)格的話,用 Row * 10
那么,如果我們要比較兩個(gè) Cell
類型的對(duì)象是不是一樣,現(xiàn)在就有兩種比較辦法:
第一種就是純粹比較兩個(gè)對(duì)象的 Row
和 Column
數(shù)值是不是都一樣。而第二種判別方式就比較簡單了:因?yàn)楣4a能夠唯一確定數(shù)據(jù),所以我們直接通過哈希碼就可以比較兩個(gè)對(duì)象是不是一致。
而且可以看到哈希碼計(jì)算公式相當(dāng)簡單,因此我們直接上手寫逐數(shù)據(jù)成員比較的話,就顯得代碼很臃腫。畢竟,有簡單的比較辦法我們肯定不會(huì)用復(fù)雜的,因?yàn)閮蓚€(gè)比較辦法都能得到一致的、正確的結(jié)論。這就是哈希碼的存在的意義。
3-3 ToString
方法
很明顯,從這個(gè)名字上就可以看出這個(gè)玩意兒用來干嘛了。ToString
方法用來把對(duì)象用字符串形式表達(dá)呈現(xiàn)出來。因?yàn)橐敵鲲@示一個(gè)對(duì)象的信息,我們就不得不擁有一個(gè)機(jī)制,來把對(duì)象呈現(xiàn)出來。那么,只要我們重寫了 ToString
方法的話,就可以直接這么寫代碼:
就非常方便了。
一般通常,我們實(shí)現(xiàn) ToString
的辦法都是,把需要呈現(xiàn)的數(shù)據(jù)成員給提出來,然后用字符串拼接的方式把它們拼接起來,最后輸出。比如,假設(shè)我們要顯示一個(gè)形狀,那么代碼可能是這樣的:
首先,這是在 Shape
里的代碼。我們追加一個(gè)抽象屬性 ShapeKindName
用來顯示輸出這個(gè)形狀到底是什么。在從 Shape
類派生后,我們就不得不重寫掉這個(gè)屬性,比如重寫的數(shù)值可以是 "Rectangle"
,那么就寫成 public override string ShapeKindName { get { return "rectangle"; } }
。
寫好這個(gè)屬性后,我們就可以在 Shape
類里的 ToString
方法里直接使用 ShapeKindName
和 Area
來顯示具體的數(shù)值信息。這里用到了 string.Format
這個(gè)靜態(tài)方法,雖然沒有講過,但是可以告訴你的是,這個(gè)方法和 Console.WriteLine
的傳參方式是完全一樣的,所以不必考慮和擔(dān)心參數(shù)列表到底如何書寫的問題,照搬過來就可以了。只是,string.Format
方法返回的是一個(gè)字符串,而 Console.WriteLine
方法是直接把字符串結(jié)果顯示出來了,它倆在呈現(xiàn)機(jī)制上有所不同。
稍微提一下的是,這里的 ToString
被我用 sealed
標(biāo)記了,這表示我在添加別的類的繼承的時(shí)候,就不許再次重寫 ToString
了,你只能用這個(gè)方法,而不能改內(nèi)部的執(zhí)行邏輯。
3-4 ReferenceEquals
靜態(tài)方法
是的,你的猜想一點(diǎn)都沒有錯(cuò)。之前我們提到的 ReferenceEquals
方法其實(shí)就是來自于 object
類里,只是有所不同的地方是,這個(gè)方法一般都要寫成 object.ReferenceEquals
,因?yàn)樗?object
類里;但是實(shí)際上我們都沒有寫它,這是因?yàn)?C# 知道這個(gè)方法是 object
里的,所以不用寫。
3-5 ==
和 !=
運(yùn)算符為什么要重載
實(shí)際上,object
就自帶了 ==
和 !=
這兩個(gè)運(yùn)算符。正是因?yàn)樗亲詭У?,所以我們不重寫的話,C# 就會(huì)自動(dòng)定位到 object
的 ==
和 !=
。而大家都知道的是,==
和 !=
實(shí)際上就是簡單調(diào)用了一下 ReferenceEquals
(這一點(diǎn)之前有說過哦),所以我們要重載運(yùn)算符來避免 C# 定位到這里,只要我們重寫了 Equals
方法,或者 GetHashCode
方法。
另外,順帶一提。我們之前就說過寫代碼要養(yǎng)成好習(xí)慣,如果是引用類型傳入的話,就一定有可能為 null
,因此,只要遇到引用類型就一定要先判斷這個(gè)對(duì)象是不是為 null
數(shù)值。判斷方法就是調(diào)用 ReferenceEquals
方法了。
Part 4 重寫(override
)、重載和覆蓋(new
)的區(qū)別
很高興我們能說到這里。這三個(gè)詞語其實(shí)區(qū)別不大,所以經(jīng)常容易分不清楚。下面我們來說一下這三個(gè)詞語的區(qū)別。
重寫(Override):基于基類型提供的抽象成員(
abstract
修飾的)或虛成員(virtual
修飾的),重新修改執(zhí)行邏輯的過程;重載(Overload):重載有兩層含義:運(yùn)算符重載和方法重載。方法重載是參數(shù)不同構(gòu)成不同重載,所以跟這里關(guān)系不大;而運(yùn)算符重載是避免運(yùn)算符本身在調(diào)用的時(shí)候還定位到基類型(比如指的是
object
)的運(yùn)算符去。因?yàn)檫\(yùn)算符重載本身是靜態(tài)的行為,所以根本談不上用override
、virtual
、abstract
或者sealed
這類只用來修飾實(shí)例成員的修飾符;覆蓋(Overwrite):覆蓋和重寫的區(qū)別就是是否阻斷了繼承鏈。如果是重寫,那么就是基類型直接拿下來的;而覆蓋則是直接把基類型的成員隱藏掉,而以后所有的繼承都從這里覆蓋掉的地方開始往下算,而基類型的就不再能夠可以訪問了。
如果我使用了繼承關(guān)系的語法來的話,比如這樣的代碼:
在同一個(gè)項(xiàng)目下,A
和 B
因?yàn)闆]有訪問修飾符修飾,因此默認(rèn)的修飾符應(yīng)該是 internal
。而 internal
只在項(xiàng)目里可以隨便使用。如果我試著改變 A
和 B
的訪問修飾符的話,那么一共就有四種情況:
public class A
和public class B
;public class A
和internal class B
;internal class A
和public class B
;internal class A
和internal class B
。
那么,這些寫法都是正確的嗎?從語法上它們都應(yīng)該是對(duì)的,但實(shí)際上在使用的時(shí)候,我們來看一下 B : A
的繼承關(guān)系約束下,B
就必須和 A
是一樣級(jí)別的訪問修飾符,或者比 A
要小。
按道理來說,B
是 A
的派生類型,這就是在說,我可以使用多態(tài)機(jī)制來書寫這樣的代碼:
兩個(gè)代碼在語法上都是可以的。可問題就在于,我兩種寫法都正確的話,就意味著我必須 A
和 B
得是同一個(gè)級(jí)別,或者 B
比 A
的級(jí)別要小,才可以這樣。如果 B
比 A
訪問級(jí)別還要大的話,那么唯一的一組情況就只可能是 public class B
(子類型)和 internal class A
(父類型)了。從邏輯上來看,我能夠?qū)嵗粋€(gè) B
類型的對(duì)象并暴露寫在代碼里,可我如果多態(tài)使用 A
類型來接收的話,而我此時(shí) B
繼承關(guān)系上又保證了它是從基類型 A
這里拿下來的,但卻又不能使用多態(tài)給 new B()
賦值給 A
類型,因?yàn)?A
我又“看不見”。這不就是矛盾了嗎?
如果你沒有明白這段話的話,我換一個(gè)說法。面向?qū)ο笠馕吨粋€(gè)類型必須得要么走 object
這個(gè)默認(rèn)類型派生,要么就必須給出一個(gè)自定義的引用類型,讓該類型走這個(gè)我自定義的類型派生。那么我自己的訪問級(jí)別能夠被當(dāng)前環(huán)境(或者叫范圍吧)下看得到,那么基類型就必須得也能夠看得到才行,否則我走哪里派生的我不清楚的話,別人說不定還以為我是走 object
派生的,畢竟我現(xiàn)在連一個(gè)基類型都看不到了嘛。這就破壞了面向?qū)ο蟮睦^承機(jī)制。所以,當(dāng)前類型在當(dāng)前范圍下能看得到,那么它的基類型也必須能看得到。因此,我無關(guān)我當(dāng)前類型什么訪問修飾符,但它的基類型的訪問修飾符的級(jí)別至少都得和當(dāng)前類型的訪問級(jí)別得是一樣的,或者說基類型比當(dāng)前類型的訪問修飾級(jí)別還要大。所以 B
(子類型)是 public
但 A
(父類型)是 internal
的這組情況是不可能在 C# 里存在的;而其它三種情況均是可以的。
這個(gè)是類的繼承關(guān)系下的訪問修飾級(jí)別的問題。那么,如果是嵌套類型呢?這個(gè)時(shí)候,類型可以嵌套的話,里面的這個(gè)類型就可以使用 private
或者 protected
,甚至是 protected internal
來修飾了。這個(gè)情況更為復(fù)雜,這怎么理解呢?
倘若我有一個(gè)這樣的情況:
現(xiàn)在 Nested
類型從 B
類型派生,而 Nested
和 B
是不同的類型。那么這個(gè)時(shí)候,組合情況就非常多了。按照我們剛才的說法,“當(dāng)前類型在當(dāng)前范圍下能看得到,那么它的基類型也得能看得到”,因此至少 B
的訪問級(jí)別不能比 Nested
的低。
比如說,如果 Nested
是 private
修飾的,那么 B
就可以什么都行,因?yàn)槔^承關(guān)系下,訪問修飾級(jí)別是可以相同的,而最低情況下就只有 private
,而它也可以,所以,此時(shí) B
是什么修飾符都行;那如果是 protected
呢?internal
呢?
我們這里來看一個(gè)表格。

這個(gè)表格其實(shí)不用去死記硬背,因?yàn)楫吘共皇巧险n,也不是考試。但是有了這個(gè)表格,比較熟悉了的話,寫代碼會(huì)輕松一些;然后,這樣的情況平時(shí)用得也不多,所以大概了解一下即可。特別注意的是,即使派生類型和基類型都是相同的 protected internal
修飾符,這樣的組合也不允許,原因是 protected internal
組合修飾符比較特殊,因?yàn)?protected internal
是 protected
和 internal
兩種級(jí)別都混合在一起的情況,那么我就說不清楚這個(gè)類型到底是走了一個(gè) internal
還是 protected
修飾的類型派生下來的,所以是不允許的。