第 96 講:C# 3 之?dāng)U展方法
前文我們介紹了第一個 C# 3 對方法語法的拓展功能,下面來看看第二個:擴(kuò)展方法(Extension Method)。
Part 1 引例
考慮一種情況。我現(xiàn)在有一個 int
的數(shù)據(jù),我想獲取這個 int
的所有比特位是 1 的位置,比如 13 的二進(jìn)制是 1101,我想通過某個方法得到 0、2、3 構(gòu)成的數(shù)組,表示的是 13 的二進(jìn)制表達(dá) 1101B 的第 0、2、3 位上都是 1。
其實實現(xiàn)起來并不難。
從代碼看出,其實邏輯也不復(fù)雜。不過這里用到三個處理技巧:BitOperations.PopCount
方法、>>= 1
操作和 & 1
操作。這也不必多說了,>>= 1
和 /= 2
是一個意思;& 1
和 % 2
是一個意思;而 BitOperations.PopCount
方法是獲取一個整數(shù)有多少個比特位是 1。
那么,我們調(diào)用這個方法就先得比較輕松了:
我們保證了方法處理過程是嚴(yán)謹(jǐn)?shù)?,因此我們可以留給別的人使用,因此我們可以通過這樣的機(jī)制來調(diào)用這個方法。可問題在于,這里我們的“類型”我們并不關(guān)心是什么。我們要想包裝這個方法,我們必須使用類型來包裝起來。C# 要求所有的方法必須都分門別類放在相同或不同的數(shù)據(jù)類型里,因此,我們必須給這個方法規(guī)劃一個類型而存儲起來。
假定這個類型叫 Int32Extensions
,專門放的是關(guān)于 int
數(shù)據(jù)類型的一些拓展操作的方法:
這么做也是有意義的,不過我們總不能用一次就寫一遍 Int32Extensions
吧,這太不友好了。于是,擴(kuò)展方法就誕生了。
Part 2 語法
擴(kuò)展方法需要我們做兩處修改和變動。第一處是方法本身。我們給參數(shù) digit
修飾 this
關(guān)鍵字。是的,this
關(guān)鍵字在這里當(dāng)成修飾符來用:
GetAllSets
就當(dāng)成是 int
是的,a.GetAllSets()
。我們試著將 a
這個變量當(dāng)作是 int
類型的實例,而 GetAllSets
雖然是靜態(tài)方法,但我們也可以通過此語法改成類似實例方法的調(diào)用方式,而由于 a
按照實例方法的寫法而被提前了,因此小括號里原本傳入 a
現(xiàn)在就會變?yōu)榭盏男±ㄌ?。這就是擴(kuò)展方法:帶有 this
關(guān)鍵字修飾在第一個參數(shù)上的靜態(tài)方法。
Part 3 使用約定和規(guī)范
既然用法我們知道了,那么肯定得有一些內(nèi)容需要我們注意。
3-1 this
修飾符只能在位于靜態(tài)類的靜態(tài)方法上
我們觀察擴(kuò)展方法的語法,可以得知,實際上擴(kuò)展方法就是標(biāo)記上了其中一個參數(shù),然后將其當(dāng)成實例的調(diào)用規(guī)則。這樣的方法可以從上面的引例看出,它一般都用作類型的擴(kuò)展。也就是說,有一些 .NET 庫就提供了的封裝完好的數(shù)據(jù)類型,我們無法直接在封裝好了的這些數(shù)據(jù)類型上加上東西,那么我們只有自己寫地方裝上它們,然后達(dá)到擴(kuò)展的目的。
但早期的 C# 語法里只能讓我們使用所謂的“工具類型”的概念去存儲它們,但現(xiàn)在我們有了這樣的語法規(guī)則,因此我們可以這樣去更加流暢地寫出代碼來。那么,既然是一個工具類型里提供的方法,那么我們之前就說過,工具類型是不求任何別的人去實例化的,因此這樣的類型我們往往都定義為靜態(tài)類型,而在這個靜態(tài)類型里,我們定義的方法也肯定就只能是靜態(tài)方法了,因為靜態(tài)類里不能聲明任何實例成員。
正是因為大家寫代碼得到的這些經(jīng)驗總結(jié)和約定俗成的內(nèi)容,因此 C# 3 的擴(kuò)展方法有這兩條限制:
擴(kuò)展方法只能放在靜態(tài)類里(因為靜態(tài)類才是最適合作為工具類型的類型);
擴(kuò)展方法只能是靜態(tài)的(因為靜態(tài)類要求存儲的成員也都是靜態(tài)的)。
3-2 this
修飾符只能修飾在方法的第一個參數(shù)上
this
修飾符作為擴(kuò)展方法使用的特殊修飾符類型,它的目的是用來擴(kuò)展一個數(shù)據(jù)類型,使得調(diào)用的時候顯得更為靈活和美觀,畢竟少了一個無意義的靜態(tài)類型名稱來說,代碼看起來確實要好看一些,也要簡短一些。
而 this
修飾符正是考慮到用來擴(kuò)展類型的目的,它只能用在第一個參數(shù)上。假設(shè)有兩個參數(shù),比如這樣的東西:
this
此時我們需要傳參的是兩個參數(shù)而不是方法要求的三個。傳入的兩個參數(shù)分別對應(yīng)的是這個 Slice
方法的 start
和 length
,而第一個參數(shù) s
,已經(jīng)被我們這樣的語法改寫而前置當(dāng)作實例去了。這種現(xiàn)象也叫擴(kuò)展方法的實例前置(Prepositional Instance)。
如果允許 this
修飾符放在別處的話,那么就亂套了:
就拿這個來舉例,假設(shè)我放在最后去了,但因為擴(kuò)展方法語法的嚴(yán)謹(jǐn)性和編譯器的方便處理,我們放在第一個顯然就會更好一些,因為每一個擴(kuò)展方法總是在第一個是 this
參數(shù),這就非常好用了,也方便編譯器讀懂我們使用的代碼和語法規(guī)則。而寫在末尾的話,每一個方法有或多或少有不同個數(shù)的參數(shù),這樣就不方便編譯器處理和閱讀我們的代碼。
3-3 this
修飾符在同一個方法里只能用一次
顯然,this
修飾符的出現(xiàn)對編譯器那是有特殊的用途。試想一下,如果下面的代碼是可以的的話,那么這樣的代碼是啥意思:
這肯定是不可能讀得懂的代碼。這要是翻譯為實例寫法的話,那到底是 a
是實例調(diào)用還是 b
是實例調(diào)用呢?這不就說不清楚了。
C# 3 的擴(kuò)展方法要求,this
修飾符專門用作實例調(diào)用的寫法,所以它只能在一個方法里出現(xiàn)一次,多了就不行了。
3-4 擴(kuò)展方法可以是任何訪問修飾級別
擴(kuò)展方法的用途和作用的是代換工具類型,改用實例的方式來優(yōu)化調(diào)用過程,進(jìn)而讓代碼更好看。那么,訪問修飾符對于這個特性來說,就沒有任何影響了。哪怕它是私有的,但是只要你在類型里使用,不也不影響?所以,擴(kuò)展方法可以用任何的訪問修飾符,只要你愿意。
還有一個原因是,擴(kuò)展方法只是多了一個參數(shù)用了 this
修飾符,但它的基本語法都和普通的方法是一樣的,所以沒有意義也沒有必要對擴(kuò)展方法的訪問修飾符單純做一次限制。
3-5 除了指針,任何數(shù)據(jù)類型都可以用來當(dāng)作擴(kuò)展方法的擴(kuò)展類型
C# 的擴(kuò)展方法基本上能將所有的類型都進(jìn)行擴(kuò)展,所以是一個非常強(qiáng)大的語言特性。但問題是,唯一一種類型,擴(kuò)展方法是不能用的;換句話說,就是這個類型是不能使用 this
修飾符的。這就是指針類型了。
比如這樣的代碼就不合理。有人會問,為什么指針不行呢?下面我從兩個角度給大家說明一下為什么。
第一,指針沒有單獨的類型聲明。在 C# 的世界里,所有的數(shù)據(jù)類型都需要我們自己獨立給出類型,存儲到一個文件或多個文件里去。但是,指針是沒有自己的類型聲明的。這說起來不好理解。我這么舉個例吧。int?
對應(yīng) Nullable<T>
類型,而 int
對應(yīng) Int32
類型,int[]
則對應(yīng) Array
類型。所有 C# 里的類型,不論它寫成啥樣,最終我們都能找到一個合理的基本類型來表達(dá)和表示它,但指針類型不行。C# 的指針類型是一種“奇怪”的類型,它甚至不走 object
的派生體系,因此你無法這么寫代碼:
void*
和 object
是兩種不同的類型,object
以及子類型是一個派生類型體系,而 void*
則不是派生體系的一員。所以,你擴(kuò)展方法實現(xiàn)了這個 void*
這種指針類型的擴(kuò)展,有什么意義呢?它擴(kuò)展了什么?
第二,指針具有 .
和 ->
運(yùn)算,允許指針使用擴(kuò)展方法將導(dǎo)致兩個運(yùn)算符的推算不再嚴(yán)謹(jǐn)??紤]下面的代碼。
倘若有這樣的方法,我們在使用的時候有如下的寫法:
而我們知道,指針類型變量在 C# 里只能使用指針成員訪問運(yùn)算符 ->
。因此,這破壞了 C# 對這個行為的基本規(guī)則和約定。
所以,不論如何,C# 沒有允許擴(kuò)展方法使用指針類型當(dāng)擴(kuò)展。
Part 4 泛型擴(kuò)展方法
是的,擴(kuò)展方法甚至支持泛型??紤]下面的代碼:
那么,這樣的代碼如果允許 C# 使用,那么咋用?
答案很簡單。T
是泛型類型,這意味著任何一種數(shù)據(jù)類型(這里指針仍然除外,因為指針類型不在泛型處理的考慮范疇里)都支持和兼容這個方法的使用和調(diào)用。因此,不論你傳入一個什么類型,都可以執(zhí)行這里給出的代碼邏輯。哪怕 T
僅僅是一個值類型。值類型雖然沒有 null
一說,但 T
是泛型的,所以它并不知道來者何人,而且再加上但凡它有數(shù)值,即使它是值類型,那么被裝箱后也會有對應(yīng)的裝箱實例的地址,因此怎么說也不可能為 null
。因此 ReferenceEquals(instance, null)
是合理的代碼。
用法很簡單。但凡在這里給一個實例,只要不是指針,就能使用上這個擴(kuò)展方法:
唯一需要注意的是,帶泛型約束的時候。
其實,問題也不大。調(diào)用的時候,只需要看看你給的實例是不是滿足泛型約束即可。如果不滿足,那么就不能作為擴(kuò)展方法來用,僅此而已。
Part 5 重載方法的調(diào)用優(yōu)先級規(guī)則推廣
由于這個語法和普通的實例方法的調(diào)用語法是完全相同的,所以它牽扯到了語義處理的機(jī)制,而不單單只是語法上的新規(guī)則。正是因為它和實例方法的調(diào)用規(guī)則寫法完全一致,因此方法的重載在 C# 3 擴(kuò)展方法誕生后有所推廣。
什么意思呢?你想想看,我在一個類型 T
里有一個 F
方法,而我也對 T
類型寫了一個擴(kuò)展方法,名字也叫 F
。那么這樣兩個方法由于不在一起,但也構(gòu)成重載。這就有一個比較奇特的現(xiàn)象:我完全可以實現(xiàn)一個擴(kuò)展方法,讓它的調(diào)用寫法和我直接調(diào)用實例方法的寫法是完全一樣的。比如 new T().F()
可以指代 T
類型的實例方法 F
(無參的),也可以指代給 T
類型寫了一個擴(kuò)展方法,比如它是 TExtensions
類型下的 F
方法,那么完全可以對應(yīng)上去的是 TExtensions.F(this T instance);
這樣靜態(tài)方法的簽名。由于方法存放的地方不一樣,所以它們不會沖突;而正是因為這樣的特殊性,因此擴(kuò)展方法和普通實例方法就會交織在一起構(gòu)成新的重載規(guī)則。對于這種重載規(guī)則,我們應(yīng)該如何去處理和理解呢?
你始終記住,擴(kuò)展方法是最接近兼容類型的實例方法。意思是說,除了 T
類型自帶的實例方法,跟 T
類型相關(guān)(就是寫成 this T
的意思)的這些擴(kuò)展方法也會被視為這個 T
類型的真正存在的實例方法,只是唯一一個區(qū)別是,優(yōu)先先看 T
類型自身的方法。如果這個類型沒有這個方法,則會去匹配擴(kuò)展方法。
5-1 擴(kuò)展方法和實例方法在一起時候的重載
下面我們來看一個例子。
在 E
類型里包含兩個擴(kuò)展方法 F
,都是針對于 object
的擴(kuò)展。不過傳參有所不同,一個是 int
,而另外一個是 string
。而 A
、B
是三個不同的數(shù)據(jù)類型,其中 A
類型里沒有實例方法,B
和 C
類型則有實例方法,也都叫 F
。不過 B
傳參是 int
類型,C
類型的這個方法傳參則是 object
類型。
接著 X
類型里則是執(zhí)行和調(diào)用這些方法。試問一下,這些方法分別都對應(yīng)什么方法?(答案已經(jīng)寫在上面了,我希望你先自己思考了然后看答案。)
下面我們針對上面給的答案解釋一下原因。
a.F(1)
:這個調(diào)用下,a
是A
類型的實例,但問題是A
類型沒有自己的實例方法,于是就只能去看擴(kuò)展方法。擴(kuò)展方法里有一個object
類型的擴(kuò)展方法,這意味著所有object
類型的實例都可以使用此擴(kuò)展方法。但問題是它是A
類型的實例,那么能不能用呢?當(dāng)然可以啦。因為A
是自己定義的類型,它是class
關(guān)鍵字定義的,因此屬于類,那么類的最終基類型就是object
。因此,只要屬于這個object
類型派生鏈條上的所有類型(包括它自己)都是可以用這種擴(kuò)展方法的。接著,可以發(fā)現(xiàn)E
類型給了兩個擴(kuò)展方法,都是F
,參數(shù)類型換了一下。而 1 是int
類型的字面量,顯然第一個方法就可以是完美匹配的。因此,a.F(1)
對應(yīng)調(diào)用的方法是E
類型里的第一個F
擴(kuò)展方法(即代碼第 3 行的這個方法);a.F("hello")
:顯然這就沒辦法在A
類型里去找到匹配了。因為A
類型里只有一個F
的重載,而這個重載只能去看擴(kuò)展方法里有沒有了。顯然,擴(kuò)展方法里是有的,這個F(this object, string)
的擴(kuò)展方法就非常對味。所以,這個方法調(diào)用的是E
這個靜態(tài)類里的F
擴(kuò)展方法(代碼里的第二個,即代碼第 4 行這個方法);b.F(1)
:B
類型里有自帶的實例,它調(diào)用的F
應(yīng)該是哪一個呢?答案看的是“就近原則”。因為B
類型自身就有一個F
方法,傳參是完全匹配的(參數(shù) 1 恰好是int
類型的字面量,而B
類型的F
方法也確實是傳參int
類型,因此外部存在的所有擴(kuò)展方法都是無效的,因為沒有機(jī)會匹配上它們;b.F("hello")
:不多說,因為B
類型沒有string
類型的重載,所以只得去看擴(kuò)展方法。擴(kuò)展方法里有兼容,所以它就調(diào)用這個;c.F(1)
:這個稍微麻煩一些。顯然擴(kuò)展方法里有一個完全兼容的方法重載版本,它可以要求傳入int
當(dāng)參數(shù),而我們這里頁恰好只需要一個int
當(dāng)參數(shù)??蓡栴}是,C
類型自己有一個F
實例方法,而且要求傳入的參數(shù),類型兼容的范圍太廣了——它能到object
。那么c.F(1)
調(diào)用誰呢?當(dāng)然就是這個類型內(nèi)部的這個方法了,因為它最近嘛。雖然有一個擴(kuò)展方法,參數(shù)是完美兼容的,但是傳入的this
參數(shù)是object
類型,這里做了一次隱式轉(zhuǎn)換相當(dāng)于繞了一步;而它又在別的類型里放著,所以要想發(fā)現(xiàn)它又需要繞一步,所以要想調(diào)用到c.F(1)
,需要繞兩步;但C
類型的這個F
方法,只需要轉(zhuǎn)換參數(shù)類型做一次隱式轉(zhuǎn)換即可,所以只繞一步。所以,c.F(1)
調(diào)用的是這個類型自己帶的這個方法;c.F("hello")
:也不必多說,都兼容完了,所以不管傳入啥,都只看C
類型自己,不看外面的擴(kuò)展方法。
5-2 命名空間距離的概念
上面我們說了一下類型自身的方法和擴(kuò)展方法的重載,下面我們來說一下,命名空間不同導(dǎo)致的不同擴(kuò)展方法之間的重載。
不同擴(kuò)展方法的重載,看的是,調(diào)用的地方距離哪一個方法更近。換句話說,每一個方法的調(diào)用都會層層使用到命名空間名稱,然后才是類型,最后是這個方法。假如引用擴(kuò)展方法 1 需要是 C.F
,而引用擴(kuò)展方法 2 則需要是 N1.D.F
,那么顯然 C
和 N1.D
就有不同:C
類型下直接就能被看到,但 N1.D
還需要進(jìn)入 N1
命名空間下,然后才能在 D
類型里發(fā)現(xiàn)它。因此相當(dāng)于多繞了一步。所以,不同地方的擴(kuò)展方法重載起來的話,看的是相對于調(diào)用方的“距離”。下面我們來舉個例子。
C
和 N1.D
類型都帶有 int
實例的重載,下面給出了 1.F()
、2.G()
和 3.H()
三個調(diào)用,請問它們分別調(diào)用的都是哪一個方法?
1.F()
:由于我們使用了using N1
指令,因此我們在Test
類型里調(diào)用F
方法應(yīng)該可以看到三個重載版本:C.F
、D.F
和E.F
。不過,因為E.F
距離Main
方法調(diào)用最近,因此這里我們優(yōu)先考慮的是E.F
方法(即代碼的第 23 行),C.F
和D.F
不論如何都是多了一層命名空間的間接引用;2.G()
:我們使用的using N1
指令使得我們可以看到N1
命名空間下的D.G
方法,因此我們這次可以看到兩處的重載:C.G
和D.G
。由于我們要發(fā)現(xiàn)C.G
需要走出N2
命名空間,但D.G
是我們通過using
指令已經(jīng)導(dǎo)入的內(nèi)容,所以它會被優(yōu)先發(fā)現(xiàn)到。你可以類比理解為一個“蟲洞”。從Main
(假設(shè)看成“地球”)往D.G
前進(jìn),你只需要穿越蟲洞(蟲洞幾乎不消耗能力)就可以到達(dá);但你要去看C.G
,你需要走出地球所在的太陽系,然后去別的星系才能看到。所以using
指令導(dǎo)入了的會被優(yōu)先選擇和讀取到,所以這個地方應(yīng)該調(diào)用的是D.G
方法(即代碼的第 12 行代碼);3.H()
:這個不多說,因為只有一個擴(kuò)展方法,它沒有重載版本,因此直接調(diào)用即可,所以3.H()
調(diào)用的是C.H
方法(即代碼的第 5 行)。
5-3 同距離或同轉(zhuǎn)換步驟數(shù)的重載咋辦?
再極端一些,如果你遇到了同樣距離訪問的擴(kuò)展方法的話,那么編譯器肯定是區(qū)分不了調(diào)用誰的。此時,編譯器會直接生成編譯器錯誤告訴你不要這么去使用。
同理,如果轉(zhuǎn)換次數(shù)也是一樣的的話,編譯器也會直接告訴你,它也不知道調(diào)用哪一個,于是編譯也不通過。因此一定要規(guī)避這樣的現(xiàn)象(雖然這樣的極端情況也基本上遇不到)。
Part 6 總結(jié)
總的來說,擴(kuò)展方法是一個相當(dāng)有趣的語言特性,雖然它在重載的優(yōu)先級調(diào)用和匹配上理解起來比較難,但也不是不能理解,而且這種情況也不常遇到。盡量規(guī)避出現(xiàn)這樣的問題就可以了。
下面我們將要介紹的是 C# 3 里最后一個新語言特性,也是目前接觸到的最復(fù)雜的語言語法體系:集成語言查詢(Language Integrated Query,簡稱 LINQ),它可能會有 10+ 講解的篇目介紹這個體系,請做好心理準(zhǔn)備。