第 93 講:C# 3 之匿名類型
今天我們來說一個看起來相當簡單簡陋的語法特性:匿名類型(Anonymous Type)。這個語法特性簡單到你聽我說一下你自己就會用了。
Part 1 語法
我們定義下面這樣的東西叫匿名類型:
這樣看起來不好理解,我們看下用法:
是的,就是一個 new
關鍵字后直接跟上一個對象初始化器的語法。然后整個 new { ... }
的語法里,new
后沒有了真正的數(shù)據(jù)類型和構造器的參數(shù)信息,然后直接將這個表達式給賦值到了一個新變量上去。
語法其實很簡單,這三個變量 obj1
、obj2
和 obj3
我們都成為匿名類型的變量。下面我們來說說細節(jié)和這個語法到底是啥意思。
Part 2 實現(xiàn)細節(jié)
有人會說,這定義了有啥用?單純這么三個變量定義了之后,確實沒有任何用,因為這樣的語句,猜都猜得到就相當于有了屬性賦值的這么一個過程,而這些賦值過程會自動被編譯器改寫代碼,變?yōu)閷嶋H的數(shù)據(jù)類型。
是的,實際上確實如此,而且和我們的想法沒有任何的偏差。編譯器會直接將這些匿名類型的實例化過程(就是 new { ... }
的這個表達式)給改寫成一個實際類型的實例化操作,而里面包含和書寫的屬性名稱,會被編譯器自動改寫為實際的屬性。
我們拿 obj1
的賦值來舉例。這個語句會被翻譯為這樣:
說說細節(jié)吧。
2-1 類型的修飾符
這個類型是編譯器通過匿名類型實例化語句進行實例化后的實體類型。這個類型因為是自動生成的,因此不便于其它時候使用。所以,可以從這樣的實現(xiàn)機制和模式看出,匿名類型是一種“用了就丟”的數(shù)據(jù)類型。
正是因為這樣的形式,所以這個類型并不重要,編譯器生成的代碼就沒必要讓用戶還能交互起來,因此這個數(shù)據(jù)類型被標記為 internal
,表示無法被外界訪問。顯然,一個類型既然不是嵌套類型,就肯定不可能是 private
、protected
甚至 protected internal
的。所以,這種類型最低只能修飾為 internal
了,這也是一種讓別人盡量少知道和了解的一種設計模式原則:最小知道原則(Law of Demeter,簡稱 LoD,也叫迪米特原則,Least Knowledge Principle,簡稱 LKP)。當然了,這些內容不太重要,就不在這里提了。
接著,類型肯定也不允許派生,因此 sealed
關鍵字密封一波避免誤用產生奇怪的派生內容。
最后,類型是用 class
的,因為大家都知道,struct
和 class
在 C# 里都可以表示類型,而且基本可以實現(xiàn)近乎一致的代碼內容,但 struct
仍然有很多限制,它的出現(xiàn)也是為了底層優(yōu)化和性能提升而從 C/C++ 那邊抄過來并保留下來的,所以 struct
的使用難度和靈活運用的水平要比 class
高一些,因此大家都建議在 C# 里盡量使用 class
。所以這個匿名類型也不例外。
所以,這個類型被修飾為 internal sealed class
。
2-2 類型頭上的特性
可以注意到,這個類型頭上有兩個特性:[ComplierGenerated]
和 [DebuggerDisplay]
。第一個都不必多說了,老熟人了。下面我們詳細說第二個特性標記。
[DebuggerDisplay]
特性標記,對應的特性原名稱是 DebuggerDisplayAttribute
(這不廢話嗎),表示這個類型在調試階段,調試工具和模塊對這個類型怎么處理和顯示。這個特性實例化需要一個構造器參數(shù),是在調試工具里,如何呈現(xiàn)這個數(shù)據(jù)類型的文本表示內容,里面寫字符串信息,字符串里可以帶有大括號,類似于占位符一樣的概念。
仔細觀察這個字符串:@"\{ Name = {Name}, Age = {Age} }"
,開頭的 \{
要看成一組,它表示轉義。因為大括號在輸出的時候有占位符的概念,會被誤認為是去匹配閉大括號 }
,因此這個地方用一次轉義來避免它去匹配大括號、當作普通的大括號字符 {
。接著里面寫了 Name = {Name}, Age = {Age}
的內容。因為字符串是原封不動的內容,因此只有這里的占位符會被替代為具體屬性數(shù)值的結果。換句話說,比如 Name = "Sunnie", Age = 25
是我們最開始對 obj1
的賦值,那么這里就好比是顯示這樣的內容,把 "Sunnie"
字符串替換到 {Name}
占位符上去,而把 25 作為結果替換到 {Age}
占位符上去。最后有一個 }
,因為前面最開始轉義了開頭的開大括號 {
,所以這里大括號并不成對:整個序列里開大括號有兩個匹配上了占位符語法,而閉大括號也有兩個匹配上了占位符的語法,因此多出來的這個閉大括號沒有匹配,所以它只能被視為普通字符。
不要鉆牛角尖。請?zhí)貏e注意,這個占位符語法(即把屬性名稱嵌套放在字符串里可以當占位符用的語法)只是這個構造器參數(shù)里允許的書寫規(guī)則。換句話說,這個語法是不能用于
string.Format
、Console.Write
之類的方法里當占位符使用的。那些方法里的模式字符串和這里[DebuggerDisplay]
的構造器參數(shù)不是一個實現(xiàn)體系,只是用到了類似的處理機制和算法罷了,它們并不統(tǒng)一。所以不要想著是完全一樣的內容而去把這里的字符串生搬硬套放到那些方法里當模式字符串。
接著是 Type
這個命名參數(shù)。這個命名參數(shù)表示我在調試工具里,對這個類型的名稱怎么顯示。有些時候我們完全不必非得把這個名稱完整地顯示出來。比如這里我們命名為 AnonymousType_obj1<T1, T2>
,而很顯然下劃線后的 obj1
就沒必要顯示出來,因為是哪個變量的實例化過程我們并不關心,我們只關心里面的存儲數(shù)值;另外,泛型參數(shù)也不必顯示。
哦對,順帶一說。這個類型會被編譯器翻譯為一個泛型類型,而不是直接抄
string
和int
兩個實際數(shù)據(jù)類型上去。這樣是考慮到代碼的可復用性。如果我又來一個新的變量賦值,屬性名也都是完全一致的的話,就不必單獨再一次生成一個新的類型出來了,這里只需要通過泛型強大的類型替代機制,就可以實現(xiàn)兩個變量使用同一個具體類型的效果。
所以總的來說,這個特性標記其實就是控制一下調試工具和模塊到底怎么顯示,如何顯示,以及顯示什么的規(guī)則。
2-3 兩個字段
這兩個字段厲害了,它們的類型竟然是泛型參數(shù)的類型 T1
和 T2
。泛型參數(shù)類型前面有說過,是為了可以復用,避免創(chuàng)建特別多的匿名類型對應的實體類型。
其它的,也沒啥可說的,字段封裝肯定得定義為 private
修飾的;另外字段只在匿名類型的實例化的時候,才會調用構造器,而期間并不允許修改,因此匿名類型是不讓修改里面的數(shù)值的。
實際上,你也沒辦法修改。你已經定義了類型后,就無法通過 C# 的語法來改變了:
2-4 兩個屬性
屬性就不多說了。屬性是字段的封裝,因為字段是只讀的,所以屬性也只用于取值,所以它沒有 setter。
2-5 構造器
構造器也不必多說。用來實例化的。
2-6 Equals
方法和 EqualityComparer<>
類型
下面我們來聊聊 Equals
方法和 GetHashCode
兩個方法里面的 EqualityComparer<>
泛型類型。
這個泛型數(shù)據(jù)類型是之前沒說過的,它表示“路由”(Route)到具體類型的 Equals
和 GetHashCode
方法的特殊類型。這個方法有一個靜態(tài)屬性 Default
,用來獲取 EqualityComparer<T>
類型的這個 T
的相等性比較和哈希碼求值操作的過程。
不論你的代碼有多復雜,只要你重寫了 Equals(object)
方法和 GetHashCode()
方法,EqualityComparer<T>.Default
都能找到它們;如果你沒有重寫,那么它就會按照它的基類型的實現(xiàn)規(guī)則(也就是比如說 T
是引用類型,就看 T
的基類型有沒有重寫 Equals
和 GetHashCode
方法;如果沒有就繼續(xù)往上找基類型)去查找對應的實現(xiàn)。
這就很方便了。如果我們以前來寫代碼的話,我們還很難通過反射去調取一個對象是否真的包含自己的 Equals
和 GetHashCode
方法。就 Equals
方法來說,很多人會愿意去重載 ==
和 !=
運算符,但問題是有些時候也不一定有重載,于是運算符有些時候并不奏效。舉個例子,我們要比較兩個學生的數(shù)據(jù)是否一致,我們的辦法是:
==
運算符,因此比較起來很方便;可總有一些時候我們不一定能知道對象的類型是否有 ==
運算符;沒有我們還得去看有沒有自己已經重寫了 Equals
方法。很顯然,這樣的處理很復雜,反射機制是可以幫助我們做到這些內容的,但確實復雜了一些。這個 EqualityComparer<>
雖然說代碼復雜了,但是這個 Default
靜態(tài)屬性相當好用,一勞永逸。
我們回到原始的代碼里。匿名類型生成的 Equals
方法和這個地方我們自己實現(xiàn)的寫法有異曲同工之妙。只不過我們這里用的是 as
運算符代替了 if (!(other is T)) return;
的判斷。我們說過,as
運算符會同時判斷和獲取結果。如果類型不匹配,會返回 null
;否則會返回對象轉換后的結果。
注意這個 Defualt
靜態(tài)屬性,我們后面又多了一個 Equals
。這是 Default
類型里自帶的一個成員。它可以用來獲取計算得到兩個對象按照這個 EqualityComparer<T>
的 T
類型下的比較過程,來看是否兩個對象一致。
可以發(fā)現(xiàn),編譯器生成代碼里會大量用到這個 Default
靜態(tài)屬性。因為編譯器的代碼生成是想做到一勞永逸,而這個類型的 Default
屬性剛好可以一勞永逸(或者說,這個 Default
屬性就是為了編譯器生成代碼一勞永逸而發(fā)明設計出來的),所以編譯器會大量用到它。
2-7 GetHashCode
方法
方便就方便在,EqualityComparer<T>
類型的 Default
靜態(tài)屬性不僅提供了 Equals
計算兩個對象在當前 T
類型下的相等性比較操作,還提供了一個 GetHashCode
方法。這個方法傳入一個參數(shù),表示計算這個實例在這個 T
類型里給出的 GetHashCode
方法的計算過程運算的結果。
可以看到匿名類型生成的代碼,多了幾個亂七八糟的數(shù)據(jù)。它們的存在也就是為了混亂數(shù)據(jù)數(shù)值,使得哈希碼的結果更加“凌亂”,避免用戶從哈希碼反推對象。
2-8 ToString
方法
這個不必多說了,它實現(xiàn)的代碼就是在拼湊字符串,使得這個匿名類型輸出的結果更加好看一些。如果沒有重寫的話,我們都知道,ToString
會自動調用的是基類型 object
的 ToString
方法。它的結果是產生一個字符串,但這個字符串是這個類型的 BCL 全名。顯然這個名稱就沒有任何意義,因為它沒有體現(xiàn)和表示出對象包含的各個數(shù)據(jù)信息的結果。所以 ToString
在匿名類型里,也被編譯器重寫了。
2-9 [DebuggerHidden]
特性標記
這個特性有必要簡單說說。它標記一個成員,表示這個成員在調試期間不被調試器發(fā)現(xiàn)。啥意思呢?就是說,按照基本的實現(xiàn)規(guī)則來看,默認情況是,調試器會在調試數(shù)據(jù)欄里給出所有這個類型的成員信息,并給出對應的數(shù)值(即使是非 public
修飾的)。但是,這個標記可以允許調試器在調試數(shù)據(jù)欄里不顯示它。
Part 3 嵌套匿名類型
匿名類型是允許嵌套的,你可以這么寫代碼:
這里的 a
是匿名類型的變量,而它的 A
屬性,就是一個嵌套的匿名類型。
Part 4 匿名類型的對象只建議當成臨時變量使用
匿名類型提供了一種非常方便的思路來創(chuàng)建一個類型。但問題是,這樣的類型名稱我們都沒有辦法得到,我們到現(xiàn)在都只是在寫實例化一個匿名類型的實例的時候,用 var
關鍵字來代替。真實的類型名稱是編譯器給出的。因此我們也無從知道。
有人說前面介紹文字里給的那個 AnonymousType_obj1<T1, T2>
不是嗎?實際上,并不是。這個名字也是我為了幫助大家理解把編譯器的生成代碼魔改了過后,簡化后的樣子。實際上,你看到的編譯器生成的原始代碼真就跟天書一般,標識符甚至都不遵守規(guī)則,類型名里還可以帶有各種各樣的符號,什么豎線啊、什么尖括號啊之類的。這些東西都是我們沒有講到的內容,因為我們也不關心編譯器為什么非得這么生成代碼,所以我們沒有刻意去提及這一塊的內容。只是說,我們用到了類型名的時候,把它們都寫成我們看得懂的寫法,比較好理解。因此這個 var
并非直接代替的這里的 AnonymousType_obj1<T1, T2>
這個東西。
而從這個角度來說,你就不可能把它拿來當參數(shù)用于方法上。因為你連類型名稱都不知道,怎么可能寫得出來合適的代碼?而別的地方更不可能了:屬性?字段?它們都是要在名字前面帶上類型名稱的;運算符?索引器?這些更不可能了。所以,一頓排除法過后,我們發(fā)現(xiàn),也就只有臨時變量可以用一下匿名類型的機制。
Part 5 匿名類型實例當方法參數(shù)的小技巧
那么,到底可不可以使用匿名類型當方法參數(shù)呢?我們這里有一個小技巧可以做到。匿名類型是沒有任何的顯式基類型的,因此我們沒辦法知道它的具體類型。要想知道具體類型,只能借助泛型機制。
我們給需要使用匿名類型當參數(shù)的方法定義為泛型方法,帶有一個泛型參數(shù) T
表示匿名類型本身。接著,我們把匿名類型的對象用 T
表示并傳入。
即這么寫。
可以看到,這樣的代碼,我們可以調用,比如寫成 F(new { A = 1, B = 2 })
之類的語法,C# 是允許的。不過,方法內部怎么調取數(shù)值呢?這就只能用反射了。
我們稍加改動。假設返回值表示這個匿名類型的兩個屬性的數(shù)值之和,那么我們可以像上面這樣的方式來寫代碼。我們使用 typeof
表達式獲取類型 T
的類型信息,并通過這個類型信息實例,得到 A
和 B
屬性的數(shù)值(通過反射機制)。aProperty
和 bProperty
是 PropertyInfo
類型的實例,它并不是原值。這個類型表示和封裝了一組跟當前屬性 A
綁定起來的存取數(shù)值的操作。比如這里把 aProperty
當成實例,調用 GetValue
方法,就是在取值。注意這里要傳入一個參數(shù)。因為原始的 A
和 B
屬性都是實例屬性,沒有修飾 static
,所以它在概念上是綁定了一個具體的實例的,因此我們要取值必須要綁定上對應的實例對象。換句話說,我們大概要想等價 i.A
的寫法,肯定得有 i
,然后才能有 A
的數(shù)值。這個 GetValue
方法傳入的參數(shù),就是這里我們說的 i
。
最后,我們直接加起來即可。注意類型是 int?
而不是 int
,因此加法運算會先確保都不為 null
才會相加。但凡兩個 int?
實例里有一方是 null
,結果都為 null
,這個是 C# 2 的可空值類型里介紹過的一個現(xiàn)象。
調用這個 F
方法的機制也很簡單:
是的,我們巧妙利用上泛型方法的類型推斷機制,避免了我們明確給出匿名類型的全名這個我們無法做到的問題。這就是我們怎么完成匿名類型實例傳參的一個巧妙的辦法。
Part 6 “用了就丟”用在哪里?
匿名類型提供了一種“即用即丟”的思路,可以讓我們更加方便快捷地做到,可問題是,這個使用場合太窄了,臨時變量可用的話,我們很少會接觸到這樣的匿名類型的機制。那么它用在哪里呢?
先別急,之后我們會介紹一個 C# 3 的新語法:查詢表達式(Query Expression)和 LINQ。這是一個超大的語法體系,在那個時候,我們會廣泛用到匿名類型機制。