第 71 講:C# 2 之泛型(五):泛型方法
為了增強泛型功能,C# 甚至允許我們對方法使用泛型。換句話說,我可以在一些本來不是泛型類型的方法上添加泛型參數(shù),以使得方法在使用的時候更加靈活。
順帶一提。在 C# 面向對象里規(guī)定的所有成員類別里,只有方法可以是泛型的。字段、屬性、索引器和事件自身只是存取數(shù)值或委托實例的一個變量信息,因此它不可能綁上泛型的概念(要綁也只能和類型的泛型參數(shù)綁上,而自身不會帶泛型),而運算符重載和類型轉換器自身也根本不能帶上泛型,否則它們就不叫運算符和類型轉換器了。想象一下你有一個比較運算符,然后帶一個泛型參數(shù),這是拿來干嘛呢?
Part 1 基本用途
考慮一種情況。我想要只是為了對一個 T
類型的數(shù)組里獲取最大的那個數(shù)值,那肯定是表達成 T[]
。但問題在于,它我們無法寫成一個獨立的數(shù)據(jù)類型,然后在類型里加上最大數(shù)值方法。
于是我們會考慮把它放在一個獨立的工具類里,然后這個類型也不讓實例化,也不允許派生。同時我們創(chuàng)建一個 static
成員以避免去實例化后才可調用它。
可問題是,這么做又有什么用呢?我這可是泛型數(shù)據(jù)類型的數(shù)組啊。這就得用到泛型方法了。
我們看到一個新的語法。我們直接在方法名后加上了泛型部分 <T>
,并在方法聲明頭部的末尾加上了泛型約束 where T : struct, IComparable<T>
。使用 struct
約束是為了避免引用類型會有 null
值參與其中導致 null
調用 CompareTo
方法產(chǎn)生 NullReferenceException
異常。
這個 Max<>
方法帶有一個泛型參數(shù),它不寫在類型上,而是寫在方法上。這個方法我們稱為泛型方法(Generic Method)。C# 對泛型方法沒有任何限制,換句話說,不管你是實例方法還是靜態(tài)方法,不管你是否包含一些 ref
、out
參數(shù),不管你訪問修飾符是不是 public
,均可使用泛型方法機制。
當然,這個例子有點奇怪。如果為了避免出現(xiàn) NullReferenceException
異常,又想使用引用類型作為使用實例的話,也可以考慮改變參數(shù)表列。比如這樣:
這個寫法有兩個要注意的地方。第一是 params
參數(shù),第二是 ReferenceEquals
對泛型數(shù)據(jù)類型的使用。params
參數(shù)允許數(shù)組參數(shù)在寫代碼的時候改成變長參數(shù)的形式,因此第一個參數(shù)沒有包含在 params
參數(shù)里是因為我們至少得保證序列里有一個元素傳入;而 ReferenceEquals
方法用于驗證對象是不是指向 null
。我們著重說明后者。
因為是泛型參數(shù) T
,因此我們無從得知它是不是值類型。而驗證它是不是 null
就顯得更加重要。要想知道它是不是 null
,我們只能驗證對象的地址??蓡栴}在于,對象如果是值類型的話,調用 ReferenceEquals
方法將導致值類型對象必然裝箱,而且地址也會隨之變化,導致驗證的失敗(返回 false
)。這也是期望的現(xiàn)象,因為值類型從來就不可能為 null
,而裝箱后的地址怎么也不可能等于 null
,對吧。所以必然會導致和 null
比較的結果不同。而我們也期望值類型在參與比較的時候,結果不同。因此,這么做是沒有邏輯問題的。
如果你很不喜歡這么書寫代碼來判別
null
的話,即使你使用result == null
以及element == null
的語法以替換ReferenceEquals
方法調用,但在泛型類型里,由于你尚未知道它的實際類型就必須參與比較,因此等號運算符會被定位到object
里默認自帶的==
操作上去。而object
里的==
就是簡單調用了一下ReferenceEquals
而已,因此它倆寫法最終的效果是一致的。
Part 2 泛型方法的重載和類型推斷
C# 提供了一種方便的機制,對泛型方法可以進行類型推斷(Type Inference)??紤]我們使用剛才的代碼來完成取最大值的任務,按正常的語法應該是這樣的:
按照模板去套,可以發(fā)現(xiàn)我們指定了泛型參數(shù)(按照基本的類的聲明和使用語法就能猜到我們應該把泛型部分 <int>
寫在方法名之后,因為聲明也是放在這里的。而由于我們指定了泛型參數(shù)的實際類型,因此我們的 T
就會自動替換為 int
參與計算,并最終得到 int
類型的結果。所以整個調用和賦值過程都是正確的。
不過,編譯器允許你省略 <int>
部分不寫,即寫成這樣:
是的,直接省略掉,連尖括號都不要。這個稱為泛型方法的類型推斷。
如果有多個泛型參數(shù)的話,要想使用泛型方法的類型推斷,必須全部都能成功推斷才可以省略,否則必須全部寫上。舉個例子:
F(10, "Hello")
,那么很自然可以發(fā)現(xiàn),10 是 int
字面量,而 "Hello"
是 string
字面量,因此泛型參數(shù) T
和 U
分別為 int
和 string
。但假設我指定了其中一個泛型參數(shù),比如 F<double, _>(10, "Hello")
,系統(tǒng)仍然需要讓你把第二個泛型參數(shù)給寫上,因為第一個泛型參數(shù)是你自己給的,只有當泛型參數(shù)全部都能進行類型推斷的時候,才可以省略。
好了,講完了。
咳咳咳,好像啥也沒說清楚。先來說說這種現(xiàn)象為什么能夠被允許,以及編譯器如何判斷泛型參數(shù)的實際類型。
2-1 方法的可重載性規(guī)則
我們知道,C# 的方法有著神奇的規(guī)則:它允許重載方法。重載意味著我們給出不同類型的參數(shù)表列,即使方法名完全相同(大小寫字母都一致),C# 也允許我們?nèi)绱寺暶鞫也划a(chǎn)生編譯器錯誤。不過,我們之前出現(xiàn)過相當多的影響重載性質的語法,下面我們來對這些語法進行梳理。梳理這些語法的目的是,為了介紹這里的泛型參數(shù)的分析模式,并最后告知你如何正確得到泛型參數(shù)的實際類型。
接下來的內(nèi)容會非常麻煩,因為規(guī)則被劃分得非常細致,所以非常不容易記住。因為平時很少會出現(xiàn)這些下面介紹的極端情況,因此無需擔心是否已經(jīng)掌握它們。這些東西你可以留著遇到的時候發(fā)現(xiàn)編譯器行為不如你的預期的時候,再回頭來查看這里的這些條條款款,也是可以的。
2-1-1 初級難度:基本重載規(guī)則
如果我有如下一些方法:
void F(decimal a);
void F(string a);
void F(double a);
如果我使用 F(30D)
,那么請問我調用的是什么方法?顯然是第三個,對吧。因為常量 30D
的后綴暗示著這個常量是 double
類型的,因此數(shù)據(jù)一定會按照匹配的類型進行調用。
如果我使用 F(30F)
,那么請問我調用的是什么方法?答案是第三個。這個稍微難理解一點。decimal
類型有點特殊,它往浮點數(shù)據(jù)類型(float
、double
和 decimal
這三個)上轉換,或者浮點數(shù)據(jù)類型往 decimal
類型上轉換,都是強制轉換;換句話說,你不得不添加強制轉換運算符 (decimal)
才可以把一個數(shù)值字面量往 decimal
上轉換;不過 30F
是 float
類型的字面量,它和 double
之間是隱式轉換的,因此編譯器會優(yōu)先選擇正常轉換的這一方作為調用,因此選的是第三個方法(參數(shù) double
類型)來調用。
如果我使用 F(30)
,那么請問我調用的是什么方法?因為字面量 30
是 int
類型字面量,而 decimal
數(shù)據(jù)類型里規(guī)定,int
往 decimal
是隱式轉換,而 int
字面量往 double
也是隱式轉換的,因此兩種調用無法確定最終結果,因此編譯器會告知用戶編譯器無法找出合適的調用方法是哪一個。
2-1-2 中級難度:變長參數(shù)規(guī)則
如果我有如下的一些方法:
void F(int a);
void F(int a, params int[] arr);
void F(params int[] a);
void F(params object[] a);
如果我使用 F(30)
,那么請問我調用的是什么方法?按照默認匹配的規(guī)則,那肯定是第一個,對吧。雖然它從邏輯上也滿足第二個、第三個和第四個傳參的規(guī)則,但因為它只有一個參數(shù),而從類型和個數(shù)上最匹配的項目只有第一個,所以直接選的是第一個作為調用項。
如果我使用 F(30, 40)
,那么請問我調用的是什么方法?說不上來了吧。調用的是第二個。原因在于,我第一個參數(shù)是和第二個方法參數(shù)表列的第一個參數(shù)類型 int
是匹配的,因此編譯器將只從前兩個方法里選取調用最終項,而第三個即使我們知道 30, 40
也可以調用第三個方法,但由于按參數(shù)順序讀取和匹配的關系,第三個只能 say goodbye 了。接著,由于 30, 40
是兩個參數(shù),因此第一個調用項被 pass 掉了,因此只能選取第二個作為匹配項。
如果我使用 F(new int[] { 30, 40 })
,那么請問我調用的是什么方法?第三個。因為只有第三個和第四個調用項能從第一個參數(shù)就開始傳數(shù)組進去,而第三個參數(shù)的類型完全和傳參的 new int[] { 30, 40 }
匹配。之前說過,雖然有 params
修飾符修飾參數(shù),但我們?nèi)栽试S完整傳入數(shù)組類型的對象而不必非得寫作變長參數(shù)的傳參形式。
如果我使用 F(new double[] { 30, 40 })
,那么請問我調用的是什么方法?第四個。因為前三個數(shù)據(jù)類型全部都不匹配,而給定的數(shù)組的元素是 double
類型的,第三個調用項是 int[]
,即每一個元素必須是 int
類型。數(shù)組協(xié)變的規(guī)則還記得吧:允許按數(shù)組級別從 a 類型隱式轉換為 b 類型(只要元素類型 a 到 b 允許隱式轉換的話)。但 double
到 int
是顯式轉換(強制轉換),因此只能去找合適的轉換項,只有最后一個合適。所以是最后一項。一定注意,雖然數(shù)組初始化器里寫的是 30 和 40 兩個 int
字面量,但數(shù)組類型規(guī)定的是 double
類型,所以最終按這個 double
類型為準。
2-1-3 高級難度:泛型參數(shù)規(guī)則
如果我有如下的一些方法:
void F(int a, int b);
void F(int a, decimal b);
int F(int a, object b);
T F<T>(int a, T b);
T F<T>(T a, T b);
void F<T, U>(T a, U b);
如果我使用 F(30, 40)
,那么請問我調用的是什么方法?顯然第一個,對吧。因為第一個完全匹配兩個參數(shù)的數(shù)據(jù)類型。雖然它滿足帶泛型參數(shù)的后面三個方法的傳參規(guī)則(30 是 int
字面量,那么 T
替換成 int
也是正確的調用過程),但是最匹配的是第一個,所以調用的是第一個方法。
如果我使用 F<int>(30, 40)
,那么請問我調用的是什么方法?第四個。因為第四個方法的第一個參數(shù)和這里的 30 最能匹配。按我們剛才解釋到的規(guī)則來看,參數(shù)的數(shù)據(jù)類型是按順序挨個判斷的,因為第一個參數(shù)是 int
類型,最能找到的匹配項是前面四個項,而這個例子帶有泛型參數(shù),因此調用的是第四項。
如果我使用 F<int>(30D, 40)
,那么請問我調用的是什么方法?可以大概看一下,這個調用過程是有泛型參數(shù)的,因此只能從后面兩個選擇。但問題是,我指定的泛型參數(shù)的實際類型是 int
,但我卻傳入了第一個參數(shù)是 double
類型進去,因此這是不可以的。因此編譯器會提示你,無法從 double
轉為 int
(隱式轉換的嗅探規(guī)則)。
如果我使用 F<double>(30D, 40)
,請問我調用的是什么方法?由于此時我指定了泛型參數(shù)是 double
,因此它只會在第四個和第五個里選取,因為只有這兩個情況下泛型參數(shù)才是一個。而可以發(fā)現(xiàn),只有第五項滿足,第四項的第一個參數(shù)是 int
類型,因此最終調用的是第五項。
如果我使用 F(30, 40D)
,請問我調用的是什么方法?這個能滿足的只有第三項、第四項、第五項和最后一項。但實際上可以發(fā)現(xiàn),第三項的第二個參數(shù)是 object
類型的,傳入的參數(shù)是 double
類型的,必須得隱式轉換過去才行,還避免不了地產(chǎn)生裝箱行為;可我既然有重載方法,那么我們肯定可以去使用泛型來完成這個任務啊,畢竟泛型避免一些潛在的裝箱行為(比如這里要轉 object
就得裝箱)。所以它會優(yōu)先看帶有泛型參數(shù)的調用項有沒有滿足的??梢园l(fā)現(xiàn),第四個是最匹配的,因為第四個項目的第一個參數(shù)是 int
剛好匹配實際類型 30 的字面量對應類型 int
,因此第四項是這個題目的調用項,而編譯器會根據(jù)調用項自動得到泛型參數(shù) T
的實際類型應為 double
。
如果我使用 F(30D, 40)
,請問我調用的是什么方法?第六個。因為只有第五項和最后一項可以滿足和匹配。其中第五項滿足的條件必須是得把其中的 40 隱式轉換為 double
類型后,才可以;但我有雙泛型參數(shù)的第六項,為什么不使用不轉換類型的重載版本呢?所以我們會優(yōu)先選擇第六項。而此時的話,編譯器會自動類型推斷,因此 T
是 double
類型,而 U
則是 int
類型。除非我刪去其中第六項,這個時候它才會去匹配第五項,并使得 40 自動轉為 double
類型調用。這種情況的話,T
是 double
類型。
如果我使用 F(30D, 40D)
,請問我調用的是什么方法?由于第一個參數(shù)是 double
類型,而前四個方法全部的第一個參數(shù)只能傳入 int
,因此前四項都 pass 掉。接著,由于剩余兩項也都包含泛型參數(shù),而且第五項是同泛型參數(shù),而第六項是不同泛型參數(shù),按照兼容的基本規(guī)則來看,顯然它們都是滿足的,只是一個只需要 T
為 double
才行,而另外一個需要 T
和 U
均是 double
才行。按照基本的重載規(guī)則,泛型參數(shù)個數(shù)不一樣雖然可構成重載,但在類型推斷上如果不指定個數(shù)的話,編譯器怎么知道你調用的是哪一個呢?非得總是泛型參數(shù)少的這一方嗎?說不定吧。所以在這種調用下,兩種調用均是滿足且不分上下。因此,編譯器會給出警告,告知你編譯器無法確定你到底調用哪一個,除非你指定泛型參數(shù),比如調用寫成 F<double>(30D, 40D)
就可以確定只有一個泛型參數(shù)的版本第五項作為最終調用項;而從這個角度來說,我確定了泛型參數(shù)類型,因此可以改變參數(shù)的數(shù)據(jù)類型(字面量信息的后綴)。顯然第一個會影響編譯器執(zhí)行和分析代碼(選取哪一個調用),而第二個參數(shù)由于不影響編譯器分析代碼,因此字面量 40D
的 D
后綴可以省略,即 F<double>(30D, 40)
也是可以的。
2-1-4 骨灰級難度:泛型參數(shù) + 變長參數(shù)規(guī)則
如果我有如下的一些方法:
void F<T>(double first, params T[] arr);
void F<T>(params T[] arr);
void F<T>(T first, params T[] arr);
不多說了。上最難的推斷情況。如果我使用 F(30F, 40)
,請問我調用的是什么方法?混用規(guī)則確實在一些推導情況下分析起來尤為復雜,這里我給出一個編譯器匹配重載方法的順序規(guī)則:精確類型匹配 -> 泛型類型匹配 -> 變長參數(shù)匹配 -> 隱式轉換類型匹配。舉個例子,我找到精確的類型的話,那么就直接從精確類型這里調用就完事了;如果沒有的話,我就會去查看泛型版本的方法是否有合適的。如果有的話,先獲取精確類型匹配了的參數(shù)成員的這一項,然后看剩余的部分是不是匹配的。如果是就正常匹配,否則就會去試著去看別的泛型參數(shù);最后如果都沒有,就會使用模糊匹配,考慮使用隱式轉換去找。如果隱式轉換能夠匹配上的話,那么這個項就會作為調用項結果。在這里的話,我的第一個參數(shù)是 30F
,這意味著我的第一個參數(shù)必然是 float
類型。走了一圈發(fā)現(xiàn)第一項、第二項和第三項均可滿足。其中:
要匹配第一項,需要要求第一個參數(shù)進行從
float
到double
的隱式轉換,然后看到后面的 40 是int
,因此泛型參數(shù)在匹配此項的時候T
就是int
;要匹配第二項,我們必然可以立馬得到第一個參數(shù)是
float
類型,因此這里的T
將被替換為float
。在這種情況下,后面第二個參數(shù)int
類型將會產(chǎn)生隱式轉換。另外,由于該項是params T[]
的參數(shù),此時由于第三項的第一個參數(shù)是直接通過泛型類型匹配的規(guī)則,因此第二項還比第三項多了一個數(shù)組的匹配邏輯;要匹配第三項,我們第一個參數(shù)是
float
類型,因此類型推斷會使得T
會改成float
。與此同時,由于后面跟的是params T[]
,而T
已經(jīng)替換為float
,因此第二個參數(shù) 40 將會從類型int
隱式轉換為float
。
通過這三點可以發(fā)現(xiàn),第二項比第一項和第三項多一步匹配規(guī)則(數(shù)組匹配規(guī)則),但第一項和第三項的調用匹配規(guī)則是相同的,都是得做一次隱式轉換,因此無法繼續(xù)斷定匹配項。正因為這個原因,編譯器會給出錯誤信息,告知你編譯器無法確定最終調用項。
解決這個調用無法編譯器確定的辦法是,指定泛型參數(shù)。比如我添上泛型參數(shù):F<float>(30F, 40)
,這樣就可以明確了:因為我傳入了泛型參數(shù)是 float
類型,因此它自動會確定后面的傳參過程(明確了隱式轉換規(guī)則),所以只會在第二個參數(shù)上做一次轉換;而第一個調用此時就不止一個轉換了,因為第一個參數(shù)是 double
類型,而第二個參數(shù)是 float
類型,因此我第一個參數(shù)會做一次轉換,而第二個參數(shù)以數(shù)組形式傳參過去的時候,由于是 int
類型,因此仍需要再做一次轉換,因此這個時候第一項調用需要做更多次的隱式轉換才可以匹配上。因此,這個時候是第三項最匹配,因此是第三項。
2-2 類型推斷的基本規(guī)則
說了重載,我們就可以大大方方開始講類型推斷的規(guī)則了。泛型方法的類型推斷依賴于前面說的這些匹配規(guī)則。如果方法不帶重載項的話,那么很容易就可以確定調用的數(shù)據(jù)正確性;但是一旦包含重載的話,必須遵循上面的這些規(guī)則來挨個匹配。找到合適的匹配后,會自動按照匹配的規(guī)則來計算并得到對應的泛型參數(shù)的實際類型。在正確找到泛型參數(shù)的實際類型后,我們就可以省略泛型參數(shù)的實際類型書寫了,因為它們會被自動推斷出來。
但是,在某些時候,我們?nèi)耘f無法正確得到推斷結果,比如編譯器也無法區(qū)分和辨別匹配情況。這個時候我們不得不通過指定泛型參數(shù)來確定調用的重載項到底是哪一個。另外,即使我們知道有些時候帶有多泛型參數(shù)的重載方法只需指定其中若干泛型參數(shù)的實際類型即可完整表達出調用重載的項,但是按照基本的省略原則,必須是全部泛型參數(shù)均可完成推斷才可省略,因此這種情況下仍需要你全部泛型參數(shù)都指定。
2-3 泛型約束和名稱跟重載無關
C# 雖然允許我們指定不同類型的參數(shù)作為重載項,但泛型參數(shù)沒有那么“智能”。哪怕我們從泛型約束上能夠立馬確定兩個類型的實際情況一定類型不同,但仍然不構成重載。舉個例子。
這兩個方法均帶有一個泛型參數(shù),并沒有參數(shù),無返回值。但僅僅區(qū)別在于,一個的泛型約束是 where T : struct
,而另外一個則是 where T : class
。
它們構成重載嗎?不構成。換句話說就是 C# 編譯器仍不允許你創(chuàng)建這樣的兩個方法。原因是泛型約束僅僅是起到傳參的時候的驗證(比如泛型參數(shù)的實際類型滿足泛型約束條件的時候,才會匹配調用),而實際上兩個方法的泛型參數(shù)是一致的(包括無參無返回值也都是一致的)。C# 重載方法必須要求簽名一致,在 C# 2 誕生泛型規(guī)則后,方法除了基本的“參數(shù)類型和個數(shù)均一致”規(guī)則外,還多了一項:泛型參數(shù)的個數(shù)也不一致。注意,這里說的是泛型參數(shù)是個數(shù),而不是名稱。泛型參數(shù)的名稱就好比參數(shù)名一樣,你參數(shù)名不一樣,哪怕類型一致也不構成重載。因此這里的泛型參數(shù)名也是一個道理。而此時可以看到,這個規(guī)則里只是說了泛型參數(shù)的個數(shù),而并未提及任何和泛型約束有關的內(nèi)容。因此,泛型約束并不作為實際重載的規(guī)則其中一條,因此不要認為即使上面兩個類型一定不一致,就構成重載了。
不過,這樣的方法構成重載:
雖然也都帶有泛型參數(shù),也是相同個數(shù),但因為兩者的參數(shù)類型不一致(一個是泛型的 SequenceList<>
,而另外一個是泛型的 BinaryTree<>
),因此仍然屬于不同的數(shù)據(jù)類型。你沒見過數(shù)據(jù)類型不同但它們帶的實際參數(shù)是一致的就說是一樣的類型吧?
所以請區(qū)分和辨別清楚。
Part 3 泛型方法的轉型
和類型使用完全一樣,你完全可以使用 (T)
語法來對一個 object
類型的實例進行轉型,因為 T
一定比 object
要?。ó吘?object
都最終基類型了);即使最極端的情況也就是 T
就是 object
,但這種情況我寫一個 (T)
雖然是冗余的,但仍然不能算作是一個錯誤,因為這里是泛型數(shù)據(jù)的轉型,我也不知道這里的 T
實際上真的是一個 object
類型的實例。
而強制轉換為 T
類型的這一點在序列化里非常常見(雖然你不一定知道序列化是什么個流程):
formatter.Deserialize
方法返回 object
類型,我們就可以這么轉換。另外,這里不可使用 as T
語法。因為 T
是不知道什么數(shù)據(jù)類型的泛型參數(shù),as
運算符僅適用于引用類型的轉換,因此除非你追加 T
的泛型約束 where T : class