天天看點

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

Effective系列叢書 點選檢視第二章

More Effective C#:改善C#代碼的50個有效方法

(原書第2版)

More Effective C#:50 Specific Ways to Improve Your C#, Second Edition

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

[美] 比爾·瓦格納(Bill Wagner) 著

愛飛翔 譯

第1章

處理各種類型的資料

C#語言原本是設計給面向對象的開發者使用的,這種開發方式會把資料與功能合起來處理。在C#逐漸成熟的過程中,它又添加了一些新的程式設計範式,以便支援其他一些常用的開發方式。其中有一種開發方式強調把資料存儲方法與資料操作方法分開,這種方式随着分布式系統而興起,此類系統中的應用程式分成多個小的服務,每個服務隻實作一項功能,或者隻實作一組互相聯系的功能。如果要把資料的存儲與操作分開,那麼開發者就得有一些新的程式設計技術可供使用,正是這些需求促使C#語言添加了與之相應的一些特性。

本章會介紹怎樣把資料本身與操縱或處理該資料的方法分開。此處所說的資料不一定都是對象,也有可能是函數或被動的資料容器。

第1條:使用屬性而不是可直接通路的資料成員

屬性一直是C#語言的特色,目前的屬性機制比C#剛引入它的時候更為完備,這使得開發者能夠通過屬性實作很多功能,例如,可以給getter與setter設定不同的通路權限。與直接通過資料成員來程式設計的方式相比,自動屬性可以省去大量的程式設計工作,而且開發者可以通過該機制輕松地定義出隻讀的屬性。此外還可以結合以表達式為主體的(expression-bodied)寫法将代碼變得更緊湊。有了這些機制,就不應該繼續在類型中建立公有(public)字段,也不應該繼續手工編寫get與set方法。屬性既可以令調用者通過公有接口通路相關的資料成員,又可以確定這些成員得到面向對象式的封裝。在C#語言中,屬性這種元素可以像資料成員一樣被通路,但它們其實是通過方法來實作的。

類型中的某些成員很适合用資料來表示,如顧客的名字、點的(x, y)坐标以及上一年的收入等。

如果用屬性來實作這些成員,那麼在調用你所建立的接口時,就可以像使用方法那樣,通過這些屬性直接通路資料字段。這些屬性就像公有字段一樣,可以輕松地通路。而另一方面,開發這些屬性的人則可以在相關的方法中定義外界通路該屬性時所産生的效果。

.NET Framework 會做出這樣一種預設:它認為開發者都是通過屬性來表達公有資料成員的。這可以通過其中與資料綁定(data binding)有關的類而得到印證,因為這些類是通過屬性而非公有資料字段來提供支援的。WPF(Windows Presentation Foundation)、Windows Forms 以及 Web Forms 在把對象中的資料與使用者界面中的控件綁定時,都以相關的屬性為依據。資料綁定機制會通過反射在類型中尋找與名稱相符的屬性:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這段代碼會把textBoxCity控件的Text屬性與address對象的City屬性綁定。假如你在address對象中用的是名為City的公有資料字段,而不是屬性,那麼這段代碼将無法正常運作,因為Framework Class Library的設計者本來就沒打算支援這種寫法。直接使用公有資料成員是一種糟糕的程式設計方式,Framework Class Library 不為這種方式提供支援。這也是促使開發者改用屬性來程式設計的原因之一。

資料綁定機制隻針對那些其元素需要顯示在使用者界面(UI)中的類,然而,屬性的适用範圍卻不僅僅局限于此。在其他的類與結構中,也應該多使用屬性,這樣可以讓你在發現新的需求時,更為友善地修改代碼。比方說,如果你現在決定Customer類型中的 name(名字)資料不應出現空白值,那麼隻需修改Name屬性的代碼即可:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

假如當初沒有通過公有屬性來實作Name,而是采用了公有資料成員,那麼現在就必須在代碼庫裡找到設定過該成員的每行代碼,并逐個修改,這會浪費很多時間。

由于屬性是通過方法實作的,是以,開發者很容易就能給它添加多線程支援。例如可以像下面這樣實作get與set通路器,使外界對Name資料的通路得以同步(本書第39條會詳細講解這個問題):

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

C#方法所具備的一些特性同樣可以展現在屬性身上,其中很明顯的一條就是屬性也可以聲明為virtual:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

請注意,剛才那幾個例子在涉及屬性的地方用的都是隐式寫法。還有一種常見的寫法,是通過屬性來包裝某個backing store(後援字段)。采用隐式寫法時,開發者不用自己在屬性的 getter 與 setter 中編寫驗證邏輯。也就是說,我們在用屬性來表示比較簡單的字段時,無須通過大量的模闆代碼來建構這個屬性,編譯器會為我們自動建立私有字段(該字段通常稱為後援字段,并實作get與set這兩個通路器所需的簡單邏輯。

屬性也可以是抽象的,進而成為接口定義的一部分,這種屬性寫起來與隐式屬性相似。下面這段代碼,就示範了怎樣在泛型接口中定義屬性。雖然與隐式屬性的寫法相似,但這種屬性沒有對應的實作物。定義該屬性的接口隻是要求實作本接口的類型都必須滿足接口所訂立的契約,也就是必須正确地提供Name及Value這兩個屬性。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

在 C# 語言中,屬性是功能完備的“一等公民”,它可以視為對方法所做的擴充,用以通路或修改内部資料。凡是能在成員方法上執行的操作都可以在屬性上執行。此外,由于屬性不能傳遞給方法中用ref或out關鍵字所修飾的參數,是以與直接使用字段相比,它可以幫我們避開與此有關的一些嚴重問題。

對于類型中的屬性來說,它的通路器分成 getter(擷取器)與 setter(設定器)這兩個單獨的方法,這使我們能夠對二者施加不同的修飾符,以便分别控制外界對該屬性的擷取權與設定權。由于這兩種權限可以分開調整,是以我們能夠通過屬性更為靈活地封裝資料元素:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

屬性不隻适用于簡單的資料字段。如果某個類型要在其接口中釋出能夠用索引來通路的内容,那麼就可以建立索引器,這相當于帶有參數的屬性,或者說參數化的屬性。下面這種寫法很有用,用它建立出的屬性能夠傳回序列中的某個元素:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

與隻代表單個元素的屬性相似,索引器在 C# 語言中也受到很多支援。由于它們是根據你所編寫的方法來實作的,是以可以在索引器的邏輯代碼中進行相關的驗證或計算。索引器可以是virtual(虛拟)的,也可以是abstract(抽象)的,可以聲明在接口中,也可以設為隻讀或可讀可寫。若參數是整數的一維索引器,則可以參與資料綁定;若參數不是整數的一維索引器,則可以用來定義映射關系:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

和C#中的數組類似,索引器也可以是多元的,而且對于每個次元使用的索引,其類型可以互不相同:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

注意,索引器一律要用 this 關鍵字來聲明。由于 C# 不允許給索引器起名字,是以同一個類型中的索引器必須在參數清單上有所差別,否則就會産生歧義。對于屬性所具備的功能,索引器幾乎都有,如索引器可以聲明成 virtual 或 abstract,也可以為 setter 與 getter 指定不同的通路權限。然而有一個地方例外,那就是索引器必須明确地實作出來,而不能像屬性那樣可以由系統預設實作。

屬性是個相當好的機制,而且它在目前的 C# 語言中所受的支援比在舊版 C# 語言中更多。盡管如此,有些人還是想先建立普通的資料成員,然後在确實有必要的情況下再将其替換成屬性,以便利用屬性所具備的優勢。這種想法聽上去很有道理,但實際上并不合适。例如,我們考慮下面這個類的定義代碼:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這個類描述的是 Customer(客戶),其中有個名為 Name 的資料成員,用來表示客戶的名稱。可以用大家很熟悉的寫法來擷取或設定這個成員:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

上面這兩種用法都很直覺。有人可能就覺得,将來如果要把 Name 從資料成員換為屬性,那麼程式中的其他代碼是不需要改動的。這種說法在某種程度上也對,因為從文法上來看,屬性确實可以像資料成員那樣來通路。然而,從 MSIL(MicroSoft Intermediate Language)的角度來看卻不是這樣,因為通路屬性時所用的指令與通路資料成員時所用的指令是有差別的。

盡管屬性與資料成員在源代碼層面可以通用,但在二進制層面卻無法相容。這顯然意味着:如果把公有的資料成員改成對應的公有屬性,那麼原來使用公有資料成員的代碼就必須重新編譯。C# 把二進制程式集視為語言中的“一等公民”,因為 C# 的一項目标就是讓開發者能夠隻針對其中某個程式集來釋出更新,而無須更新整個應用程式。如果把資料成員改成屬性,那麼就破壞了這種二進制層面的相容機制,使得自己很難單獨更新某個程式集。

看到了通路屬性時所用的 MSIL 指令後,你可能會問:是以屬性的形式來通路資料比較快,還是以資料成員的形式來通路比較快?其實,前者的效率雖然不會超過後者,但也未必落後于它。因為JIT編譯器可能會對某些方法調用進行内聯,這也包括屬性通路器。如果 JIT 編譯器對屬性通路器做了内聯處理,那麼它的效率就會與資料成員相同。即便沒有内聯,兩者在函數調用效率上的差别也可以忽略不計。隻有在極個别的情況下,這種差别才會比較明顯。

盡管屬性需要由相關的方法來實作,但從主調方的角度來看,屬性在代碼中的用法其實與資料是一樣的,是以,使用屬性的人總是會認為自己能夠像使用資料成員那樣來使用它們,或者說,他們會認為通路屬性跟通路資料成員沒什麼差別,因為這兩種寫法看起來是一樣的。了解到這一點之後,你就應該清楚自己所寫的屬性通路器需要遵循使用者對屬性的使用習慣,其中,get 通路器不應産生較為明顯的副作用;反之,set 通路器則應該明确地修改狀态,使得使用者能夠看到這種變化。

除了要在寫法與效果方面貼近資料字段,屬性通路器在性能方面也應該給使用者類似的感覺。為了使屬性的通路速度能夠與資料字段一樣,你隻應該在通路器中執行較為簡單的資料通路操作,而不應該執行特别影響性能的操作;此外,也不應該執行非常耗時的運算或是跨應用程式的調用(如執行資料庫查詢操作)。總之,凡是讓使用者感到它與普通資料成員通路起來不太一樣的操作都不要在屬性的通路器中執行。

如果要在類型的公有或受保護(protected)接口中釋出資料,那麼應該以屬性的形式來釋出,對于序列或字典來說,應以索引器的形式釋出。至于類型中的資料成員,則應一律設為私有(private)。做到了這一點,你的類型就能夠參與資料綁定,而且以後也可以友善地修改相關方法的實作邏輯。在日常工作中,用屬性的形式來封裝變量頂多會占用你一到兩分鐘的時間,反之,如果你一開始沒有使用屬性,後來卻想要改用屬性來設計,那麼就得用好幾個小時去修正。現在多花一點時間,将來能省很多工夫。

第2條:盡量采用隐式屬性來表示可變的資料

C# 為屬性提供了很多支援,允許通過屬性清晰地表達出自己的設計思路,而且目前的 C# 語言還允許我們很友善地修改這些屬性。如果你一開始就能采用屬性來編寫代碼,那麼以後便可以從容地應對各種變化。

在向類中添加可供通路的資料時,要實作的屬性通路器通常很簡單,隻是對相應的資料字段做一層包裝而已。在這種情況下,其實可以采用隐式寫法來建立屬性,進而令代碼變得更加簡潔:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

編譯器會生成一個名字來表示與該屬性相對應的後援字段。可以用屬性的 setter 修改這個後援字段的值。由于該字段是編譯器生成的,是以,即便在自己所寫的類中,也得通過屬性通路器進行操作,而不是直接修改字段本身。這種差別其實并不會造成太大影響,因為編譯器所生成的屬性通路器中隻包含一條簡單的指派語句,是以,很有可能得到内聯,這樣一來,通過屬性通路器來操縱資料就和直接操縱後援字段差不多了。從程式運作時的行為來看,通路隐式屬性與通路後援字段是一樣的,就算從性能角度觀察,也是如此。

隐式屬性也可以像顯式實作的屬性那樣對通路器施加修飾符。例如,可以像下面這樣縮小 set 通路器的使用範圍:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

隐式屬性是通過後援字段來實作的,它與在早前版本的 C# 代碼中手工建立出來的屬性效果相同,好處在于寫起來更加友善,而且類的代碼也變得更加清晰。聲明隐式屬性可以準确呈現出設計者所要表達的意思,而不像手工編寫屬性時那樣要添加很多其他代碼,那些代碼可能會掩蓋真實的設計意圖。

由于編譯器為隐式屬性所生成的代碼與開發者顯式編寫出來的屬性實作代碼相同,是以,也可以用隐式屬性來定義或覆寫 virtual 屬性,或實作接口所定義的屬性。

對于編譯器所生成的後援字段,派生類是無法通路的,但派生類在覆寫基類的 virtual 屬性時,可以像覆寫其他 virtual 方法那樣調用基類的同名方法。如下面這段代碼就用到了基類的 getter 與 setter:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

使用隐式屬性還有兩個好處。第一,如果以後要自己實作這個屬性,以便驗證資料或執行其他處理,那麼這種修改不會破壞二進制層面的相容性。第二,資料的驗證邏輯隻需要寫在一個地方就可以了。

使用舊版的 C# 程式設計時,很多開發者都在自己的類中直接通路後援字段,這樣做會讓源檔案中出現大量的驗證代碼與錯誤檢測代碼。現在,我們不應該再這麼寫了。由于通路屬性的後援字段相當于調用對應的屬性通路器(這個通路器可能是私有的),是以,隻需要把隐式屬性改成顯式實作的屬性,并将驗證邏輯放到自己新寫的屬性通路器中就可以了:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

使用隐式屬性,可以在一處建立所有的驗證。如果繼續使用通路器而不是直接通路後援字段,那麼所有的驗證隻需要一次就夠了。

隐式屬性有一項重要的限制,就是無法在經過Serializable修飾的類型中使用。因為持久化檔案的存儲格式會用到編譯器為後援字段所生成的字段名,而這種自動生成的字段名卻不一定每次都相同。如果修改了包含該字段的類,那麼編譯器為這個字段所生成的名字就有可能發生變化。

盡管隐式屬性有上述兩個方面的問題需要注意,但總體來說,它還是具備很多優點的,例如,可以節省開發者的時間,可以産生更容易讀懂的代碼,還可以讓開發者在有需要的時候把與該屬性有關的修改及驗證邏輯都放在一個地方來處理。借助隐式屬性,可以寫出更為清晰的代碼,并有助于我們更好地維護這些代碼。

第3條:盡量把值類型設計成不可變的類型

不可變的類型是個很容易了解的概念,這種類型的對象一旦建立出來,就始終保持不變。把建構該對象所用的參數驗證好之後,可以確定這個對象以後将一直處于有效的狀态中。由于它的内部狀态無法改變,是以不可能陷入無效的狀态中。這種對象建立出來之後,狀态保持不變,于是無須再編寫錯誤檢測代碼來阻止使用者将其切換到某種無效的狀态上。此外,不可變的類型本身就是線程安全的(或者說本身就具備線程安全性),因為多個線程在通路同一份内容時,看到的總是同樣的結果,你用不着擔心它們會看到彼此不同的值。在設計其他對象的時候,可以從對象中把這些類型的值釋出給調用方,而無須擔心後者會修改它們的内部狀态。

不可變的類型很适合用在基于哈希的集合中。例如Object.GetHashCode()方法所傳回的值必須是個執行個體不變式(也叫作對象不變式,參見第 10 條),而不可變的類型本身就能保證這一點。

實際工作中,很難把每一種類型都設計成不可變的類型,是以筆者的建議是,盡量把原子類型與值類型設計成不可變的類型。其他的類型應該拆分成小的結構,使每個結構都能夠相當自然地同某個單一實體對應起來。例如 Address(位址)類型就可以算作單一實體,因為它雖然可以細分為很多小的字段,但隻要其中一個字段發生變化,其他字段就很有可能也需要同步修改。反之,Customer(客戶)類型則不是原子類型,因為它是由很多份資訊組成的,這些資訊能夠各自獨立地發生變化。例如,客戶在修改電話号碼的時候不一定同時要修改住址,而在修改住址的時候,也不一定要同時修改電話号碼。同理,他在修改姓名的時候,依然可以沿用原來的位址與電話号碼。這種對象雖然不是原子對象,但可以拆分成許多個不可變的值,或者說,它可以由許多個不可變的值通過組合來建構,例如可以拆分成位址、姓名以及一份聯系方式清單,該清單中的每個條目都是由電話号碼及類型所形成的值對。這些不可變的值可以通過原子類型來展現,這種類型就屬于剛才說的單一實體:如果某個對象是原子類型的對象,那麼不能單獨修改其中的某一部分内容,而是要把整套内容全都替換掉。下面舉例說明單獨修改其中的某一個字段所引發的問題。

假設我們還是像往常那樣,把 Address 類實作成可變類型:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

上面這段代碼在修改 a1 對象的内部狀态時,有可能破壞該對象所要求的不變關系,因為設定完 City(城市)屬性後,a1 對象會(暫時)處于無效的狀态—此時的 ZipCode(郵編)與 State(州)無法與 City 相比對。這種寫法雖然看上去沒有太大的問題,但要放在多線程的環境中執行就有可能引發混亂,因為系統可能在目前線程剛修改完 City 屬性但還沒來得及修改 ZipCode 與 State 時進行上下文切換,進而導緻切換到的線程在擷取 a1 對象的内容時,看到彼此不協調的 3 個屬性。

就算不在多線程環境中執行,這種修改對象内部狀态的寫法也會導緻錯誤。例如開發者在修改完 City 屬性後,确實想到了自己應該同步修改 ZipCode 屬性,然而他卻給 ZipCode 設定了無效的值,于是程式就會在執行 setter 時抛出異常,進而令 a1 對象陷入無效的狀态中。要想解決這個問題,必須在對象内部添加大量的驗證代碼,以確定構成該結構體的屬性能夠互相協調。這些驗證代碼會令項目膨脹,進而變得更加複雜。為了確定程式在抛出異常時也能夠處于有效的狀态中,必須在修改字段之前先給這些字段做一份拷貝,以防修改到一半的時候突然發生異常。此外,為了使程式支援多線程,還必須在每個屬性通路器上進行大量的線程同步檢查,set 與 get 通路器都要這樣處理。總之,工作量特别大,并且還會随着新功能的增多而不斷增多。

Address 這樣的對象如果要設計成struct(結構體),那麼最好是設計成不可變的 struct。首先,把所有的執行個體字段都改成外界隻能讀取而無法寫入的字段。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

現在,從公有接口的角度來看,Address 已經是不可變的類型了。為了使調用便于使用這個類型,必須提供适當的構造函數,以便能把 Address 結構體中的各項内容全都設定好。具體到本例來說,隻需要提供一個構造函數,用來對 Address 中的每個字段進行初始化。不需要實作拷貝構造函數,因為指派運算符已經夠用了。要注意:預設的構造函數依然能夠通路。在由那個函數所生成的位址中,每一個字元串型的字段都是 null,ZipCode 字段的值是0。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

改為不可變的類型之後,調用方需要用另一種寫法來修改位址對象的狀态。具體到本例來說,就是要初始化一個新的 Address 對象,并将其賦給原來的變量,而不能直接修改原執行個體:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

a1隻可能有兩種狀态:要麼是本來的取值,也就是 City 屬性為 Anytown 時的狀态;要麼是更新之後的取值,也就是 City 屬性為 Ann Arbor 時的狀态。由于它的屬性在設定完後便無法修改,是以不會像上一個例子那樣,其中有些屬性已經修改,另一些屬性卻尚未同步更新,而暫時陷入無效的狀态。它隻會在執行構造函數的那一小段時間内出現這種不協調的現象,然而這種現象在構造函數之外是看不出來的。隻要新的Address對象構造完成,它的各項屬性值就會固定下來,始終不發生變化。這種寫法還能保證程式狀态不會在抛出異常時陷入混亂,因為 a1 要麼是原來的位址,要麼就是新的位址。即便在構造新位址的過程中發生異常,程式的狀态也依然穩固,因為此時的a1仍指向原來的舊位址。

建立不可變的類型時,要注意代碼中是否存在漏洞導緻客戶代碼可以改變該對象的内部狀态。值類型由于沒有派生類,是以無須防範通過派生類來修改基類内容的做法。但是,如果不可變類型中的某個字段引用了某個可變類型的對象,那麼就要多加小心了。在給這樣的不可變類型編寫構造函數時,應該給可變類型的參數做一份拷貝。下面通過幾段範例代碼來說明這個問題。為了便于讨論,這些代碼都假設 Phone 是值類型,而且是不可變的值類型。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

數組(array)類是個引用類型。在本例中,PhoneList 結構體中的 phones 數組與該結構體外的 phones 數組其實指向同一塊存儲空間。是以,我們可以通過後者來修改數組的内容。如果想預防這個問題,那就需要把該數組在結構體中拷貝一份。還有一種辦法是采用 System.Collections.Immutable 命名空間中的 ImmutableArray 類來取代 Array,該類與 Array 的功能相似,但它是不可變的。直接使用可變的集合有可能出現剛才說的這種問題,此外,假如 Phone 是個可變的引用類型,那麼依舊會産生類似的問題。就本例來說,通過 readonly 來修飾 phones 數組隻能保證數組本身不變,無法保證其中的元素不被替換。要想保證這一點,可以改用 ImmutableList 集合類型來實作 phones 字段:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

不可變的類型應該怎樣初始化,這取決于它本身是否較為複雜。有下面 3 種辦法可供考慮。第一種辦法是像 Address 結構體那樣定義一個構造函數,使客戶代碼可以通過這個構造函數來初始化對象。提供一系列合适的構造函數給外界使用,這是最為簡單的做法。

第二種辦法是建立工廠方法,讓外界通過該方法來對結構體做初始化。這種辦法适合建立常用的值。例如 .NET Framework 中的 Color 類型就是用這種辦法來初始化系統顔色的。該類型中有兩個靜态方法,分别叫作 Color.FromKnownColor() 與 Color.FromName(),它們可以根據某個已知的顔色或顔色名稱來确定與這種系統顔色相對應的 Color 值,并傳回該值的一份拷貝。

第三種辦法是建立一個與不可變類型相配套的可變類,允許外界通過多個步驟來建構這個可變類的對象,進而将其轉化為不可變類的對象。.NET 的 String 類就搭配有這樣一個名為 System.Text.StringBuilder 的可變類,可以先多次操作該類的對象,以建構自己想要的字元串,等這些操作全都執行好之後,就可以把該字元串從 StringBuilder 對象中擷取出來。

不可變類型的編寫和維護比較容易,是以不要盲目地給類型中的每個屬性都建立 get 通路器與 set 通路器。如果你的類型隻用來儲存資料,那就應該考慮将其實作成不可變的原子值類型。用這些類型充當實體可以更加順利地建構出更為複雜的結構。

第4條:注意值類型與引用類型之間的差別

某個類型應該設計成值類型,還是設計成引用類型?是應該設計成結構體,還是應該設計成類?這些都是我們在編寫C#代碼時經常要考慮的問題。C#不像C++那樣把所有的類型都預設視為值類型,同時允許開發者建立指向這些對象的引用,它也不像 Java 那樣把所有的類型都視為引用類型(除非你是 C++ 或 Java 語言設計者)。對于 C# 來說,必須在建立類型時決定該類型的所有執行個體應該表現出什麼樣的行為。這是個很重要的決定。一旦做出,就得在後續的程式設計工作中遵守,因為以後如果要改動,可能導緻許多代碼都出現微妙的問題。剛開始建立類型時,隻是在struct與class這兩個關鍵字中挑選一個,并用它來定義該類型,然而稍後如果要修改這個類型,那麼所有用到該類型的客戶代碼恐怕就全都要做出相應的更新了。

究竟應該定義成值類型,還是應該定義成引用類型,這沒有固定的答案,而是要根據該類型的用法來判斷。值類型不是多态的,是以,更适合用來存放應用程式所要操縱的資料,而引用類型則可以多态,是以,應該用來定義應用程式的行為。建立新類型的時候,首先要考慮該類型的職責,然後根據職責來決定它是值類型還是引用類型。如果用來儲存資料,那就定義成結構體;如果用來展示行為,那就定義成類。

.NET 與 C# 之是以要強調值類型與引用類型之間的差別,是因為C++ 與 Java 代碼經常會在這裡出現問題。比方說,在 C++ 代碼中,所有的參數與傳回值都是按值傳遞的。這樣做固然很有效率,但可能會導緻局部拷貝,這種現象有時也叫作對象切割。如果在本來應該使用基類對象的地方用了派生類的對象,那麼系統隻會把該對象中與基類相對應的那一部分拷貝過去,這就意味着,對象中與派生類有關的資訊全都丢失了。就算在這樣的對象上調用虛函數,系統也會把該調用發送到基類的版本上。

Java 為了應對這個問題,把值類型從語言中幾乎給抹掉了。它規定,由使用者所定義的類型都是引用類型。所有參數與傳回值都按引用傳遞。這麼做的好處是程式表現得更加協調,但缺點則是降低了性能,因為實際上,并非所有類型都必須多态,而且有些類型根本就不需要多态。Java必須在堆上配置設定對象執行個體,而且最後還要對這些執行個體進行垃圾回收。此外,通路對象中的任何一個成員時,都必須對 this 進行解引用,這本身也要花時間。在 Java 中,所有的變量都是引用類型。

C#與這兩種語言不同,你需要通過struct或class關鍵字來區分自己新建立的對象是值類型還是引用類型。較小的或者說輕量級的對象應該設計成值類型,而彼此之間形成一套體系的對象則應該以引用類型來表示。本節将通過這兩種類型的用法來幫助你了解值類型與引用類型之間的差別。

首先,考慮下面這個類型。我們想在某個方法中把該類型的對象當成傳回值使用:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果MyData是值類型,那麼系統會把Foo()方法所傳回的内容複制到v所在的存儲區域中。反之,如果MyData是引用類型,那麼上述代碼會把内部變量myData引用的MyData對象通過Foo()方法的傳回值公布給外界,進而破壞封裝。于是,客戶代碼可以繞過你所設計的 API,直接修改myData的内容(詳情參見第17條)。

現在考慮另一種寫法:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果采用這種寫法,那麼系統會把myData複制一份存放到v中。由于MyData是引用類型,是以這将導緻堆上出現兩個對象,一個是本來的MyData對象,另一個是從該對象中複制出來的MyData對象。這樣寫确實不會暴露内部資料,但必須在堆上多建立一個對象,總之,這是一種效率比較低的寫法。

通過公有方法導出的資料以及充當屬性的資料都應該設計成值類型。這當然不是說所有的公有成員都必須傳回值類型而不應該傳回引用類型,這隻是說,如果要傳回的對象是用來存放數值的,那麼應該把它設計成值類型。例如在早前的代碼中,MyData 類型就是這樣一個用來存放數值的類型,是以,應該設計成值類型。

下面這段代碼示範了另外一種情況:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

myType變量在這裡充當的是Foo3()方法的傳回值,然而此處提供這個變量并不是為了讓人去通路其中的數值,而是為了通過該對象調用IMyInterface接口中所定義的DoWork()方法。

這段代碼展現了值類型與引用類型之間的重要差別。前者是為了存儲數值,而後者則用來定義行為。以類的形式來建立引用類型可以讓我們通過各種機制定義出很多複雜的行為。例如可以實作繼承,或是友善地管理這些對象的變化情況。把某個類型的對象當成接口類型來傳回并不意味着一定會引發裝箱與取消裝箱等操作。與引用類型相比,值類型的運作機制比較簡單,你可以通過這種類型來建立公有API,以確定某種不變關系,但若想通過它們表達較為複雜的行為則比較困難。這些較為複雜的行為最好是通過引用類型來模組化。

現在,我們進一步觀察這些類型在記憶體中的儲存方式,以及由這些方式所引發的性能問題。考慮下面這個類:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這種寫法建立了多少個對象?每個對象又是多大?這要依照具體情況來定。如果 MyType是值類型,那麼就隻需要做一次記憶體配置設定。配置設定的記憶體空間相當于MyType大小的兩倍。如果MyType是引用類型,那麼需要做 3 次記憶體配置設定,其中一次針對 C 類型的對象,另外兩次分别針對該對象中的兩個MyType對象。在采用32位指針的情況下,第一次配置設定的記憶體空間是8個位元組,這是因為需要給C對象中的兩個MyType各設立一個指針,而每個指針要占據4個位元組。記憶體配置設定的次數之是以有差別,是因為值類型的對象會内聯在包含它們的對象中(或者說,随着包含它們的對象一起配置設定),而引用類型則不會。如果某個變量表示的是引用類型的對象,那麼必須為該引用配置設定空間。

為了更加清楚地了解這種差別,我們考慮下面這種寫法:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果MyType是值類型,那麼隻需要配置設定一次記憶體,而且配置設定的記憶體空間是單個 MyType對象的100倍。如果MyType是引用類型,那麼也隻配置設定一次記憶體,但是,在這種情況下,數組裡的每一個元素都是 null。等到需要給這些元素做初始化的時候,就得再執行 100 次記憶體配置設定,是以,實際上需要配置設定 101 次記憶體,這樣做花的時間比隻配置設定 1 次要多。像這樣頻繁地給引用類型的對象配置設定記憶體空間會導緻堆記憶體變得支離破碎,進而降低程式的性能。如果隻是為了儲存數值,那麼就應該建立值類型,這樣可以減少記憶體的配置設定次數。不過,在值類型與引用類型之間選擇時,首先還是要根據類型的用法來判斷,至于記憶體配置設定次數也是一項可供考慮的因素,但與用法相比,它并不是最為重要的因素。

一旦把某個類型實作成了值類型或引用類型,以後就很難改變了,因為那樣做可能需要調整大量的代碼。比方說,我們把Employee設計成了值類型:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這個類型很簡單,隻有一個方法,該方法用來支付薪酬。這種寫法起初并沒有問題,但是過了一段時間,公司的員工變多了,于是,你想把這些人分開對待,例如銷售人員可以擷取提成,管理人員可以得到獎金。為此,需要把Employee類型從結構體改為類:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

修改之後,原來使用這個類型的代碼可能就會出問題,因為按值傳遞變成了按引用傳遞,早前按值傳遞的參數現在也要按引用來傳遞了。比方說,下面這段代碼的功能在修改之後就與早前有很大差別:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

本來是打算給 CEO 發一次獎金,但修改之後,這段代碼會把獎金永久地加到 CEO 的工資上,讓他每次都能多領 10 000 元。之是以出現這種效果,是因為修改之前,這段代碼隻在拷貝出來的值上進行操作,而修改之後,則是在引用上進行操作,是以,實際上修改的是原對象本身。編譯器當然會忠實地按照修改後的含義來做,CEO 可能也樂意看到這種效果,但掌管财務的 CFO顯然不會同意,他肯定要彙報這個 bug。通過本例我們可以看到,值類型不能随意改成引用類型,因為這可能導緻程式的行為也發生變化。

上面例子所示範的問題其實是由于Employee類型的用法而導緻的。它名義上是個值類型,但實際上并沒有遵守值類型的設計規範,因為除了存放資料元素,它還擔負了一些職責,具體來說,就是擔負了給雇員支付薪酬的職責。這些職責應該由類來實作才對,而不應該放在值類型中。類可以通過多态機制實作各種常用的功能,反之,結構體隻應該用來存放數值,而不應該用來實作功能。

.NET文檔建議根據類型的大小(size)來決定它是應該設計成值類型,還是應該設計成引用類型。但實際上,根據用法(use)來判斷或許更加合适。簡單的結構體與資料載體很适合設計成值類型。從記憶體管理的角度來看,這種類型的效率要比引用類型高,因為它不會導緻堆中出現過多的碎片,也不會産生過多的垃圾,此外,它使用起來要比引用類型更為直接。最重要的一點在于,從方法或屬性中傳回值類型的對象時,調用方所收到的其實隻是該對象的一份副本,這樣你就不用擔心類型内部的某些可變結構體會通過引用暴露給外界,進而令程式狀态出現反常的變化。然而,使用值類型也是有缺點的,因為有許多特性都無法利用。如常見的面向對象技術就有很多無法用在這些類型上。比如,你無法通過值類型建構對象體系,因為所有的值類型都會自動設為sealed類型(密封類型),進而無法為其他類型所繼承。值類型雖然能實作接口,但會引發裝箱操作,令程式的性能變低。這個問題請參見《Effective C#》(第 3 版)第 9 條。總之,應該把值類型當成存儲容器來用,而不要将其視為面向對象意義上的對象。

程式設計工作中需要建立的引用類型肯定比值類型要多。但如果對下面這 6 個問題都給出肯定的回答,那就應該考慮建立值類型。可以把這些問題放在剛才那個例子中思考一遍,看看它們能夠怎樣指導你在引用類型與值類型之間做出抉擇:

1.這個類型是否主要用來存放資料?

2.這個類型能否做成不可變的類型?

3.這個類型是否比較小?

4.能否完全通過通路其資料成員的屬性把這個類型的公有接口定義出來?

5.能否确定該類型将來不會有子類?

6.能否确定該類型将來不需要多态?

底層的資料對象最好是用值類型來表示,而應用程式的行為則适合放在引用類型中。在适當的地方使用值類型,可以讓你從類對象中安全地導出資料副本。此外,還可以提高記憶體的使用效率,因為這些類型的值是基于棧來存放的,而且可以内聯到其他的值類型中。在适當的地方使用引用類型,可以讓你利用标準的面向對象技術來編寫應用程式的邏輯代碼。如果你還不确定某個類型将來會怎麼用,那就優先考慮将其設為引用類型。

第5條:確定 0 可以當成值類型的有效狀态使用

.NET 系統的初始化機制預設會把所有的對象都設定成 0。你無法強迫其他開發者必須用 0 以外的值來初始化值類型的某個執行個體。如果他是按照預設方式來建立執行個體的,那麼系統自然會把該執行個體初始化為 0。是以,你所建立的值類型必須能夠應對初始值為 0 的情況。

enum(枚舉)類型尤其需要注意。如果某個類型無法将 0 當作有效的枚舉值來看待,那就不應該把類型設計成enum。所有的enum都繼承自System.ValueType,其中的枚舉值(也叫枚舉數)是從 0 開始算的。不過,你也可以手工指定每個枚舉值所對應的整數:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

sphere與anotherSphere變量的值都是 0,這并不是有效的枚舉值。如果早前編寫的一些代碼都認為Planet類型的變量總是會取某個有效的枚舉值,那麼那些代碼在遇到這兩個變量的時候就無法正常運作了。是以,你自己定義的enum類型必須能夠把0當成有效的枚舉值來用。如果你的enum是用位模式來表示各種特性的啟用情況,那就将0值視為任何特性都沒有啟用的狀态。

就本例來說,可以要求使用者必須把Planet類型的枚舉變量初始化成某個有效的枚舉值:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

但是,如果其他類型需要使用你所定義的枚舉類型來表示其中的資料,那麼使用那個類型的人就很難滿足你的要求了。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

比方說,他們可能隻是簡單地建立一個ObservationData對象,而沒有把其中的whichPlanet字段設定成有效的枚舉值:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

對于這個建立的ObservationData對象,其 magnitude(星等)字段為0,這當然是個合理的取值,然而值同樣為0的whichPlanet字段卻沒有合理的解釋,因為0對Planet(行星)枚舉來說是個無效的值。為了解決這個問題,應該規定一種與預設值 0 相對應的枚舉值,但對于本例來說,我們似乎看不出有哪個行星适合設定成預設的行星。在這種情況下,可以用0來表示enum暫時還不具備的具體取值,稍後需要加以更新:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這樣修改之後,sphere變量所對應的枚舉值就是None了,它用來表示該變量還沒有真正設定成某個具體的行星。這也會影響到包含Planet枚舉的ObservationData結構體,使得建立的ObservationData對象能夠處于合理的初始狀态。此時,這份觀測資料的星等是0,其觀測目标是None(表示還沒有加以設定)。你可以明确地提供構造函數,讓使用者通過該函數來給所有的字段指定初始值:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

但是,使用者依然可以通過系統預設提供的無參構造函數來建立結構體,這樣還是會将每個字段都設定成預設值。你無法阻止使用者這麼寫。

意識到這一點之後,我們就會發現剛才那段代碼仍然有問題:如果使用者在建立結構體之後一直都不給它的ObservationData字段指定具體的行星,那麼該字段始終是None,而針對 None 的觀測資料是沒有意義的。為了防止程式中出現這樣的情況,我們可以考慮把ObservationData從結構體改成類,使得使用者無法通過不帶參數的構造函數來建立對象。但即便這樣,你也隻能照顧到ObservationData這一個類型,而無法阻止開發者使用Planet枚舉去實作其他類型中的字段。假如他們還是把類型設計成結構體,而不是設計成類,那麼使用者依然可以通過無參數的構造函數加以建構。枚舉隻不過是在整數外面稍微封裝了一層而已,如果想要表達的抽象概念無法用某套整數常量來展現,那就要考慮采用其他語言特性來實作了。

在讨論其他數值類型之前,再講幾條與enum有關的特殊規則。如果用Flags特性修飾 enum,那麼要記得給0這個标志值賦予對應的含義。比方說,在下面這個表示樣式的Styles枚舉類型中,0的意思是沒有運用任何樣式(None):

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

很多開發者喜歡用按位AND(與)運算符來判斷枚舉變量是否設定了某個标志(或者說是否啟用了某個選項),然而,對于值為0的标志來說,這樣判斷是無效的。例如,下面這種寫法可以判斷出flag變量是否運用了由Styles.Flat枚舉值所表示的樣式,但是,若想判斷該變量所運用的樣式是不是None(或者說,是不是根本就沒有運用任何樣式),則不能這麼寫。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果你也像本例這樣采用Flags特性來修飾自己所定義的枚舉類型,那麼應該在其中設計一個與0相對應的枚舉值,用來表示任何标志都沒有設定(或任何選項都沒有開啟)。

如果值類型中包含引用,那麼在做初始化的時候也有可能出現問題。例如,我們經常會看到下面這種包含string引用的結構體:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這樣制作出來的MyMessage,其msg字段是null。你沒有辦法強迫使用者在構造 MyMessage的時候必須把msg設定成null以外的引用,然而我們可以利用屬性機制把這個問題局限在LogMessage結構體之内,不讓它影響到外界。比方說,可以建立Message屬性,将msg字段的值釋出給用戶端使用。有了這個屬性,就可以在 get 通路器中添加邏輯,以便在msg是null的情況下傳回空的字元串:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

你在自己的類中也應該使用這個屬性,這樣做可以確定檢測msg引用是不是null的邏輯出現在同一個地方,也就是出現在該屬性的 get 通路器中。而且,對于本例來說,如果你是從自己的程式集中擷取Message屬性的,那麼包含檢測邏輯的get通路器應該會得到内聯。這種寫法既能保證效率,又可以降低風險。

系統會把值類型的所有執行個體都初始化為0,而且你無法禁止使用者建立這種内容全都是 0 的值類型執行個體。是以,應該讓程式在遇到這種情況時能夠進入某個較為合理的狀态中。有一種特殊情況尤其要注意:如果用枚舉類型的變量來表示某組标志或選項的使用情況,那麼應該将值為0的枚舉值與未設定任何标志或未開啟任何選項的狀态關聯起來。

第6條:確定屬性能夠像資料那樣運用

屬性是個“雙面人”。對于外界來說,它與被動的資料元素很像,但對于包含該屬性的類來說,則必須通過方法加以實作。如果不能正确認識這種一體兩面的特征,那麼就有可能建立出令使用者感到困惑的屬性。使用者通常認為,從外界通路某個屬性時,其效果應該與通路相應的資料成員類似,如果建立出來的屬性做不到這一點,那麼他們就有可能誤用你所提供的類型。屬性本來應該給人這樣一種感覺:調用屬性方法與直接通路資料成員有着相同的效果。

如果編寫客戶代碼的開發者能夠像平常那樣使用你的屬性,那就說明該屬性正确地表示了它所要封裝的資料成員。首先,這要求程式在不受其他語句幹擾的情況下前後兩次通路該屬性都能夠得到相同的結果:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

在多線程環境中,其他線程可能會在目前線程執行完第一條語句後把控制權搶走,等到本線程拿回控制權并執行第二條語句時,someObject.ImportantProperty屬性的值可能已經發生了變化。但是,如果程式沒有受到這種幹擾,那麼反複通路該屬性應該得到相同的值。

此外,開發者在使用你所提供的類型時,會認為這個類型的屬性通路器與其他類型一樣,不會做太多的工作。這就是說,你所編寫的 getter 通路器不應該執行太費時間的操作,而 setter 通路器雖然可以進行一些驗證,但是調用起來也不應該太慢。

開發者為什麼會對你的類做出這樣的假設呢?這是因為,他們想把類中的屬性當成資料來用,而且想在頻繁執行的循環中多次通路這些屬性。其實 .NET 的集合類也是如此。用for循環列舉數組中的元素時,有可能每次都會擷取數組的Length屬性:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

數組越長,通路Length屬性的次數就越多。假如每通路一次Length,系統都要把數組中的元素個數重新計算一遍,然後才能給出數組的長度,那麼整個循環的執行時間就會與數組長度的平方成正比。這樣一來,就沒有人會在循環中調用Length屬性了。

讓自己的類符合其他開發者的預期其實并不困難。首先,要盡量使用隐式屬性。這些屬性隻是在編譯器所生成的後援字段外面稍微封裝了一層。通路這樣的屬性與直接通路資料字段差不多。由于這種屬性的通路器實作起來比較簡單,是以經常會得到内聯。隻要能堅持用隐式屬性設計自己的類,那麼編寫客戶代碼的人就可以順暢地使用類中的屬性。

如果你的屬性還帶有隐式屬性無法實作的行為,那麼就必須自己來編寫這些屬性了。然而,這種情況也是很容易應對的。可以把驗證邏輯放在自己編寫的 setter 中,這樣也能做出符合使用者期望的設計。例如,我們早前在給LastName屬性編寫 setter 時就是這麼做的:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這樣的驗證代碼并沒有破壞屬性應滿足的基本要求,因為它隻是用來確定對象中的資料有效,而且執行起來也相當快。

有些屬性的getter可能要先做運算,然後才能傳回屬性值。比方說,下面這個Point類的Distance屬性用來表示該點與原點之間的距離。它的 getter 必須先算出這個距離,然後才能将其傳回給調用方:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

計算坐标點與原點之間的距離是很快就能完成的操作,是以,像剛才那樣實作Distance屬性通常并不會引發性能問題。假如Distance确實成了性能瓶頸,那可以考慮把計算好的距離值緩存起來,這樣就不用每次都去計算了。但是,如果計算距離所用的某個分量(或者說某個因子)發生變化,那麼緩存就會失效;于是下次執行屬性的getter時,就必須重新計算緩存。(另一種辦法是把Point類設計成不可變的類型,這樣就不用擔心其中的分量會發生變化了。)

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果屬性的getter特别耗時,那麼可能要重新設計公有接口。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

其他開發者在使用這個類時,不會料到通路ObjectName屬性竟然要在本機與遠端資料庫之間往返,也不會想到通路過程中還有可能發生異常。為了不使他們感到意外,應該修改公有API。具體怎樣修改,要看每個類型的實際用法。就本例來說,可以考慮把遠端資料庫中的值緩存到本地。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

上面這種實作方式也可以改用.NET Framework的Lazy類來完成。也就是說,我們還可以這樣寫:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果開發者隻是偶爾才會用到ObjectName屬性,那麼上面的寫法就比較合适,因為當使用者還沒有要求擷取該屬性時,程式不需要提前把它算出來。但是,這種寫法在第一次查詢該屬性的時候會多花一些時間。如果開發者要頻繁使用ObjectName屬性,而且這個屬性能夠有效地予以緩存,那麼可以改用另一種寫法,也就是在構造函數中提前擷取該屬性的值,等到使用者查詢這個屬性的時候,直接把早前擷取的值傳回給他。當然,這樣做的前提是 ObjectName屬性确實能夠正确地納入緩存。假如程式中的其他代碼或是系統中的其他線程要修改遠端資料庫中的對象名稱,那麼這種寫法就會失效。

從遠端資料庫中擷取資料并将其寫回遠端資料庫其實是相當常見的功能,而且使用者完全有理由去調用這些功能。可以把這樣的操作放在專門的方法中執行,并且給這些方法起個合适的名字,以免令使用者感到意外。例如,我們可以用下面這種辦法來編寫MyType類:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

與getter類似,setter也有可能令使用者感到意外,或者說,使用者可能沒有想到你會在 setter中執行某些任務。現在舉個例子。如果ObjectName是可讀可寫的屬性,而你要在它的setter中把這個值寫回遠端資料庫,那麼使用者使用起來可能就會覺得有些奇怪:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

由 setter 來通路遠端資料庫會讓使用者在幾個方面都感到奇怪。首先,他們不會料到這樣一個簡單的 setter居然會對遠端資料庫做調用,而且要花費這樣長的時間。其次,他們不會料到在執行 setter 的過程中,還有可能發生各種各樣的錯誤。

除了剛才講到的問題之外,還有一個問題要注意:調試器為了顯示屬性的值,可能會自動調用相應的 getter。是以,如果getter抛出異常、耗時過多或是修改了對象的内部狀态,那麼調試工作就會變得更加複雜。

開發者對屬性提出的要求與方法不同,他們希望屬性能夠迅速執行完畢,進而可以友善地觀察或修改對象的狀态。他們認為屬性在行為與性能這兩個方面都應該與資料字段類似。如果你要建立的屬性無法滿足這些要求,那就應該考慮修改公有接口,把不适合由屬性執行的操作放到方法中去執行,進而将屬性恢複到它們應有的面貌,也就是隻充當通路并修改對象狀态的管道。

第7條:用元組來限制類型的作用範圍

C# 給開發者提供了很多種方式使其能夠建立自定義的類型,以表示程式中的對象與資料結構。開發者可以自己來定義類、結構體、元組類型以及匿名類型。其中,類與結構體的功能較為豐富,可以用來實作各種設計方案,但是,許多開發者過于盲目地使用這兩種類型,而沒有考慮到除此之外是否還能采用其他辦法。類與結構體雖然很強大,但卻要求開發者必須編寫許多例行的代碼,這對于簡單的設計方案來說有些不太值得,因為我們完全可以改用匿名類型或元組類型來實作這些方案。為此,大家應該了解這些類型的寫法以及各寫法之間的差別,而且還要知道它們與類和結構體之間的差異。

匿名類型是由編譯器所生成的不可變的引用類型。為了更好地了解其工作原理,我們現在根據它的定義來逐漸進行講解。要建立匿名類型,應該聲明新的變量,并把相應的字段定義在一對花括号中。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這行代碼給編譯器傳達了很多資訊。首先,編譯器知道要建立一個内部的密封類。其次,它知道這是個不可變的類型,而且其中含有兩個隻讀屬性,這兩個屬性分别用來封裝與 X 及 Y 相對應的兩個後援字段。

于是,剛才那行代碼的效果就相當于下面這段代碼:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

上面這段代碼不需要手寫,編譯器會自動為你生成。這樣做有很多好處。首先,最根本的一點就是:編譯器生成比你寫代碼更快。很多人都能迅速敲出 new 表達式,用以生成某個類型的對象,但在編寫這個類型的定義時可就沒那麼快了。其次,定義這樣的類型屬于重複性很強的工作,如果我們自己來做,可能會偶爾打錯字。本例中的代碼比較簡單,不太會出現這種錯誤,但這樣的情況畢竟是有可能發生的。反之,交給編譯器來做,則不會出現這種隻有人類才會犯的錯誤。最後,這樣做可以降低我們需要維護的代碼量,不然的話,其他開發者就要專門檢視這段代碼、了解它的意思、确定它的功能,并找到程式中有哪些地方用到了該代碼。由于這段代碼現在是由編譯器自動生成的,是以開發者需要查閱并加以了解的代碼就可以少一些。

使用匿名類有個很大的缺點,就是你不知道類型究竟叫什麼名字,于是無法将方法的參數或傳回值明确地設定成這種類型。盡管如此,我們還是可以運用一些技巧,把屬于某個匿名類型的單個對象或一系列對象傳給某方法,或在該方法中傳回這樣的對象。下面就來編寫這樣的方法或表達式,以便對定義在某個方法中的匿名類型加以運用。在這種情況下,需要在建立匿名類的外圍方法中,通過 lambda 表達式或匿名 delegate來表達要對這種對象所做的處理邏輯。由于泛型方法允許調用者傳入函數,是以可以把自己想要實作的方法設計成泛型方法,并像剛才說的那樣,将自己對匿名類型的用法寫在匿名方法中,進而可以把這個匿名方法當成函數(或者說,以 Func 的形式)傳進來。例如,下面的Transform方法就是這樣一個泛型方法,它會處理由匿名類型的對象所表示的坐标點,并将該點的 X 與 Y 坐标分别變成原來的 2 倍:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

可以把匿名類型的對象傳給Transform方法:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這個例子中的算法比較簡單,如果算法較為複雜,那麼 lambda 表達式可能也會變得複雜起來,而且有可能要多次調用不同的泛型方法。但是,建立這樣的算法其實并不難,因為隻需要對剛才那個例子加以擴充即可,而無須重新設計。由此可見,匿名類型很适合用來儲存計算過程中的臨時結果。匿名類型隻在定義它的外圍方法中起作用。你可以把算法在第一階段所得到的結果儲存在某個匿名類型的對象中,然後把該對象傳給算法的第二階段。可以在定義匿名類型的方法中編寫 lambda 表達式并調用相關的泛型方法,以處理這種類型的對象,并對其做出必要的變換。

另外要說的是,用匿名類型的對象來儲存算法的中間結果不會“污染”到應用程式的命名空間。由于這些簡單的類型是編譯器自動建立的,是以,開發者在了解應用程式的工作原理時,不用特意去檢視這些類型的實作代碼。匿名類型隻在聲明它的方法中起作用,開發者一看到這樣的類型,就會明白該類型隻是專門針對那個方法而寫的。

早前筆者隻是粗略地說,編譯器會在你需要匿名類型的時候,自動通過一段與手寫方式等效的代碼把該類型定義出來。其實,編譯器還添加了一些特性,這些特性是無法通過手寫而實作的。匿名類型也屬于不可變的類型,然而它支援對象初始化語句。如果不可變的類型是你自己建立的,那就必須手工編寫相應的構造函數,使得客戶代碼能夠通過該函數給每個字段或屬性賦予初始值。在這種情況下,由于這些類型沒有給其中的屬性安排可以從外界調用的 setter,是以,它們不支援對象初始化語句。與之相對,如果不可變的類型是編譯器自動生成的,那麼可以(而且必須)通過對象初始化語句來建構匿名類型的執行個體。編譯器會建立一個公有(public)構造函數,用來給每個屬性設定初始值,并且會讓代碼中本來應該調用setter的地方轉而調用這個構造函數。

比方說,如果你在代碼中是這樣寫的:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

那麼,編譯器就會把它換成

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果你想建立支援對象初始化語句的不可變類型,那麼隻能把它定義成匿名類型。手工編寫的不可變類型無法利用剛才說的自動轉換機制。

最後要說的是,匿名類型在運作期的開銷可能沒有想象中那樣大。有人可能認為,隻要寫出一個匿名類型,編譯器就必然要重新給出一份定義,實際上,它并沒有這麼機械。如果後來寫的匿名類型與早前寫過的相同,那麼編譯器就會複用它早前所生成的定義。

這裡必須準确地說明:出現在不同地點的匿名類型必須滿足什麼樣的條件才能算作“同一個匿名類型”。首先,這些類型必須聲明在同一個程式集中。

其次,兩個匿名類型的各個屬性在名稱與類型上必須互相比對,而且屬性之間的順序也必須相同。比方說,下面這兩種寫法會産生兩個不同的匿名類型:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果兩個匿名類型中的各屬性其名稱與類型都相同,但順序不同,那麼編譯器會将這兩者當成兩個不同的匿名類型。是以,在用某個匿名類型的多個對象來表達同一個概念時,應該按照同一套順序來書寫這些對象中的各個屬性,隻有這樣,編譯器才知道這些對象所屬的匿名類型是同一個匿名類型。

結束匿名類型之前,還有一種特殊情況要講。同一個匿名類型的兩個對象是否相等是根據其中的值來決定的,這意味着,可以把這樣的對象當作複合鍵使用。比方說,如果要根據給客戶提供服務的銷售人員以及客戶所在地的郵編對客戶分組,那麼可以像下面這樣進行查詢:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這樣做會産生一個字典(dictionary)。字典中每個元素的鍵都是複合鍵,其中包含 SalesRep(銷售代表)與ZipCode(郵編)兩個字段。字典中每個元素的值都是一個清單,用以表示由這位銷售人員負責且其位址位于同一個郵編之下的客戶。

元組與匿名類型在某種程度上較為相似,它們都屬于輕量級的類型,而且都是在建立執行個體的時候當場予以定義的。可是,它們又不完全相同,因為元組是帶有公有字段的值類型,并且是可變的類型,而匿名類型則是不可變的類型。編譯器在實際定義元組的時候,會參照泛型的ValueTuple來定義,并且會參照你所寫的名字來生成相應的字段。

此外,編譯器在建立元組類型時所用的辦法與建立匿名類型時所用的辦法是不同的。它建立的元組類型是個封閉的泛型類型,該類型源自ValueTuple的某種泛型形式。(ValueTuple之是以有很多種形式,是為了應對字段數量不同的元組。)

下面這種寫法可以建立元組執行個體:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這行代碼會告訴編譯器你要建立含有兩個整型字段的元組。編譯器會把你給這兩個字段起的語義名稱(也就是X與Y)記下來。這意味着,可以通過這些名字來通路元組中的相應字段:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

System.ValueTuple泛型結構體中,有一些方法用來判斷元組是否相等,還有一些方法用來進行比較,此外,它的ToString()方法能夠列印出元組中每個字段的值。在剛才執行個體化的ValueTuple中,X字段與Y字段的真實名稱其實是Item1與Item2,假如當時還寫了其他字段,那麼那些字段也會按照 Item 加編号的格式來命名。

C#的類型相容機制是根據類型的名稱而建構的,這意味着,它通常會按照類型的字面寫法來判斷兩個類型之間是否互相相容;然而,在判斷兩個元組是否屬于同一類型時,它依據的卻是其結構。也就是說,即便兩個元組的字段名稱不同(無論是你自己起的名字,還是編譯器生成的名字),隻要它們的模樣相同,那麼就屬于同一種元組。例如,凡是像aPoint這樣包含兩個整數字段的元組都算作同一種元組,它們都是從System.ValueTuple執行個體化而來的。

字段的語義名稱是在初始化的時候設定的。你可以在聲明元組變量時直接指明,也可以把初始化語句右側的元組所用的字段命名方式套用在左側的元組上。如果左右兩側使用了不同的字段命名方式,那麼以左側為準。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

元組類型與匿名類型都是輕量級的類型,都是在執行個體化該類型對象的語句中定義的。如果你隻想簡單地儲存資料,而不想定義任何行為,那麼就可以考慮使用這兩種類型。

在匿名類型與元組之間選擇時,必須先從這兩者的差別入手。元組之間是否相同是依照其結構來判斷的,是以,元組很适合用來聲明方法的傳回值及參數。匿名類型是不可變的類型,它适合充當集合的複合鍵。元組類型屬于值類型,能夠發揮出值類型的各種優勢,而匿名類型則是引用類型,是以具備引用類型的各項特性。你可以分别嘗試這兩種類型,看看其中哪一種更符合目前的需求。回顧一下剛才的例子,你就會發現,給這兩種類型建立執行個體時,所用的寫法其實差不多。

匿名類型與元組并沒有看上去那樣奇怪,隻要合理地使用它們,就不用擔心代碼會變得難懂。如果想記錄算法的中間結果,而且這種結果很适合用不可變的類型來表示,那就應該使用匿名類型,反之,若要記錄具體的而且有可能發生變化的值,則可以考慮使用元組。

第8條:在匿名類型中定義局部函數

從名稱的角度觀察元組與匿名類型,我們會發現:C# 語言不依照字面名稱(或者說名義上的稱呼)來判斷兩種元組是否相同,而匿名類型雖然有名稱,但開發者不知道這些名稱具體應該怎麼寫(參見第 7 條)。要想用元組對象或匿名類型的對象來充當方法參數、方法傳回值或屬性,就必須學會某些技巧,而且還得知道通過這些技巧來使用這兩種對象時會受到哪些限制。

首先說元組。如果方法需要傳回元組類型,那麼把元組的樣子描述出來就可以了:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

你不用給元組中的字段起名,但是,應該把這些字段的含義告訴調用者。

明白了各字段的含義之後,調用者就可以把上述方法的傳回值賦給自己所聲明的元組變量,也可以将其拆分到多個不同的變量中(這叫作對該元組做析構):

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

把元組當成方法的傳回值來用是比較容易的,然而若想用匿名類型的對象來充當傳回值則較為困難,因為這種類型雖然有名字,但你沒辦法在源代碼裡輸入名字。不過,可以建立泛型方法,并通過該方法的類型參數來指代這個匿名類型。

比方說,下面這個泛型方法可以把集合中與某個值相符的對象作為序列傳回給調用方。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

可以像下面這樣,用剛才編寫的FindValue()方法來處理匿名類型的對象:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

其實FindValue()方法并不關心匿名類型具體叫什麼,它隻是将其當作一個可以用泛型來表示的類型。

像FindValue()這樣簡單的函數當然隻能實作這種比較直白的功能。如果要通路的是匿名類型對象中的某個屬性,那就需要借助高階函數才行。高階函數是以其他函數為參數或傳回其他函數的函數。由于它們能夠把另一個函數當成參數來用,是以可以考慮将涉及匿名類型的邏輯實作成匿名函數,并将該匿名函數當作參數傳給高階函數。如果高階函數本身支援泛型,那麼可以依次使用各種匿名函數來實作較為豐富的功能。比方說,可以像下面這樣執行稍微複雜一些的查詢操作:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

該操作以TakeWhile()方法收尾,從這個方法的簽名可以看出,它正是支援泛型的高階函數:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

注意,該函數的傳回值類型是IEnumerable,而且其中一個參數的類型也是IEnumerable。早前所做的查詢操作用到了包含 X 及 Y 屬性的匿名類型,而這個匿名類型正可以用TSource來表示。該函數的另一個參數是Func類型,這種類型的對象本身也是一個函數,此函數接受TSource類型的對象(也就是查詢操作所涉及的匿名類型的對象)作為參數。

通過這項技巧,我們可以建立出龐大的程式庫,并編寫相當多的代碼來操作匿名類型的對象。剛才編寫 lambda 查詢表達式的時候,用到了像TakeWhile()那樣能夠處理匿名類型的泛型方法。由于表達式與匿名類型聲明在同一個作用範圍内,是以,它完全知道這個匿名類中都有哪些屬性。編譯器會建立 private 嵌套類,以便能将匿名類型的執行個體傳給其他方法。

下面這段代碼建立了一種匿名類型,并把該類型的對象依次交給多個泛型方法來處理:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這段代碼其實并沒有太過神奇的地方:編譯器隻是生成了相應的 delegate,并調用它們。編譯器針對每個查詢方法生成與之對應的另一個方法,後者接受匿名類型的對象作為參數。然後,編譯器針對它所生成的方法分别建立 delegate,并把 delegate 當作參數傳給相關的查詢方法。

過一陣子,程式或許就會逐漸膨脹起來,因為你有可能在其中寫了很多算法,而這些算法有可能包含重複的代碼。為此,我們應該想辦法來整理這些算法,使得程式在功能逐漸增加的過程中依然能夠保持簡潔,并形成清晰且易于擴充的子產品。

其中一種辦法是把某些邏輯移動到簡單的通用方法中,進而令各種算法都可以調用這個通用方法。例如,我們可以建立下面這個通用的泛型方法,讓它接受 lambda 表達式,這樣一來,就可以重構早前的算法,把其中對匿名類型的對象所做的各種處理都轉換成相應的 lambda 表達式,并将這些表達式分别傳給泛型方法。

這樣寫實際上隻是相當于通過泛型方法來做簡單的變換,也就是把一種匿名類型的對象變換成另一種匿名類型的對象,甚至僅僅是用修改後的數值來建立同一種匿名類型的對象。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

這種技巧的關鍵之處在于,将算法中與匿名類型的具體細節關系不大的地方給提取出來。所有的匿名類型都重寫了Equals()方法,以實作基于數值(而非基于身份或引用)的比較邏輯。如果某段代碼隻是把匿名類型的對象當成普通的System.Object來看待,并假設對象中有一些由匿名類型定義的公有成員,那麼這樣的代碼就可以像剛才那樣提取成相應的 lambda 表達式。重構後的代碼與早前并沒有太大差別,它僅僅是把我們想要對匿名類型的對象所做的處理寫在了lambda表達式中,并把lambda表達式傳給了泛型的通用方法,令通用方法去操作匿名類型的對象,而不像早前那樣直接以查詢語句的形式來操作。

在原方法中,我們已經把對匿名類型的對象所做的操作邏輯抽象到通用的 Map 函數中,然而,該方法中可能還有其他一些代碼也會用在許多不同的地方,于是,我們還應該把那些代碼提取到相應的泛型函數中,使得原方法與項目中的其他代碼都可以調用該函數。

在這樣做的時候必須把握尺度,不能濫用這項技巧。如果某個類型對于許多算法來說都相當重要,那麼這個類型就不應該表示成匿名類型。要是發現自己頻繁使用同一個類型的對象,并且總是在這種對象上做各種各樣的處理,那麼恐怕應該把對象的類型從匿名類型改成帶有名稱的普通類型。每個人可能都會針對匿名類型給出各自的建議,但有一條建議或許大多數人都會贊同,那就是:如果使用某個匿名類型的主要算法超過三個,那麼最好把該類型改成非匿名的普通類型。如果必須編寫很長、很複雜的 lambda 表達式才能夠繼續使用某個匿名類型的對象,那就意味着需要把該類型轉換成普通的類型。

匿名類型是輕量級的類型,能夠包含可讀可寫的屬性,這些屬性通常用來表示簡單的數值。許多算法都能用簡單的匿名類型來編寫,你可以借助 lambda 表達式及泛型方法等機制,在算法中更加友善地操作匿名類型的對象。此外,你平常可能會用 private 嵌套類來限制類型的作用範圍,而匿名類型在這一點上也是相似的,它也隻會在某個方法中起作用。結合泛型與高階函數來使用匿名類型的對象可以在代碼中建構出更為清晰的子產品。

第9條:了解相等的不同概念及它們之間的關系

在建立類型的時候,可能會同時定義一套規則來判斷與該類型有關的兩個對象是否相等(無論這個類型是類還是結構體,可能都會涉及這個問題)。C# 提供了 4 種不同的函數,用來決定兩個對象是否“相等”:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

C#語言允許你為上面的4個方法建立自己的版本。當然,你可以這麼做,并不意味着你應該這麼做。例如,對于前面那兩個 static 函數,就不應該重新去定義。而對于第 3 個函數,也就是名為Equals()的執行個體方法,則通常可以根據自己的類型所具備的語義來重新定義。在極個别情況下,可能要重寫第 4 個函數,也就是operator==(),這通常是為了能夠更快地比較值類型的對象。此外還要注意,這 4 個函數是互相關聯的,如果修改了其中的一個,那麼有可能會影響另一個函數的行為。判斷對象是否相等竟然要牽涉 4 個函數,這聽上去有些複雜。不過别擔心,這個過程可以整理得簡單一些。

其實,除了這 4 個方法,還有其他一些機制,也會用來判斷對象是否相等。例如,凡是重寫了Equals()的類型都應該同時實作IEquatable接口。如果某個類型是從值的意義上(而不是從引用或身份的意義上)來判斷對象是否相等的,那麼該類型還應該實作 IStructuralEquatable接口。這樣算起來,總共可以從6個角度判斷對象是否相等。

與 C# 語言中的其他一些複雜機制類似,之是以要從不同的角度來考慮兩個對象是否相等,是因為 C# 既允許建立值類型,又允許建立引用類型。兩個引用類型的對象是否相等,要看它們引用的是不是同一個執行個體,也就是要根據對象身份(或者說對象辨別)來判斷。與之相對,兩個值類型的對象是否相等,要看它們是否屬于同一種類型,以及是否具有相同的内容。值類型與引用類型之間的差異導緻我們需要用不同的方法來判斷兩個對象是否相等。

為了把判斷機制講清楚,我們首先來看那兩個不應該重寫的 static 函數。第一個是 ReferenceEquals()函數。如果兩個引用指向同一個對象,那麼該函數傳回 true(真),此時我們說這兩個引用所指的對象在身份上是相同的(或者說,這兩個引用具備相同的對象辨別)。無論是面對引用類型的對象還是面對值類型的對象,這個函數都是根據對象的身份來判斷的,而不考慮對象的内容。這意味着,如果把同一個值類型的兩個不同對象交給它去比較,那麼即便這兩個對象的内容一樣,也依然會得出 false(假)。有時候,你以為自己是在比較兩個不僅内容相同而且身份也相同的對象,但實際上,比較的還是兩個身份不相同的對象,因為你可能忽略了裝箱問題(内容相同、身份也相同的兩個對象裝箱之後變成了兩個身份不同的對象)。該問題參見《Effective C#》(第3版)第9條。

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

決不應該重新定義Object.ReferenceEquals(),因為這個函數完全能夠把它應該做的事情做好,也就是按照對象的身份來比較兩個引用是否相等。

還有一個函數也不應該重新定義,那就是早前提到的第2個靜态方法:Object.Equals()。如果你不清楚兩個引用的運作期類型,那麼可以用這個方法來判斷它們是否相等。C# 語言的其他所有類型都是從System.Object繼承而來的,是以無論要比較的是兩個什麼樣的變量,都可以将它們視為System.Object執行個體。問題在于,值類型的執行個體與引用類型的執行個體要根據不同的标準來進行判斷,那麼在不清楚這兩個引用所指向的執行個體究竟是值類型還是引用類型的情況下,Object.Equals()是怎樣判斷它們是否相等的呢?答案很簡單:該方法把任務委托給其中一個類型來處理。下面我們用手工編寫的C#代碼來模拟Object.Equals()靜态方法所用的判斷邏輯:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

有個新方法出現在了這段代碼中,它就是名為Equals()的執行個體方法。該方法會在稍後進行詳細講解,但是現在,我們先把static(靜态)版本的Equals()讨論完。這裡的重點是:靜态版本的Equals()方法會在left參數上調用執行個體版本的Equals()方法,以判斷兩個對象是否相等。

與ReferenceEquals()靜态方法類似,Object.Equals()靜态方法同樣不需要被重寫或重新予以定義,因為它本身就能夠很好地完成自己應該實作的功能,也就是在不清楚運作期類型的情況下判斷兩個對象是否相同。由于static Equals()方法把判斷任務委派給了left參數的Equals()執行個體方法, 是以,它的判斷結果取決于left參數所指向的對象是什麼類型,以及那種類型會采用什麼樣的規則來進行判斷。

知道了為什麼不需要重寫static ReferenceEquals()及static Equals()方法之後,我們就該講一講可以由開發者來重寫的那幾個方法了。然而,在開始讨論重寫之前,首先必須從數學角度看看等同關系具備哪些性質。你所定義并實作出來的Equals()方法也應該具備這些性質,這樣才能與其他開發者的想法相符,而不至于令他們産生誤解。重寫Equals()方法的時候,應該編寫相應的單元測試,以確定自己所提供的實作邏輯确實滿足這些要求。這意味着,你的Equals()方法必須滿足等同關系的 3 項數學性質:自反性、對稱性、可傳遞性。自反性意味着任何對象都與它自身相等,或者說,無論對象a是什麼類型,a==a都必定成立。對稱性意味着比較的順序不影響比較的結果,或者說,如果a==b成立,那麼b==a也成立,如果a==b不成立,那麼b==a也不成立。傳遞性意味着如果a==b與b==c同時成立,那麼a==c也一定成立。

現在就來講解執行個體版本的Object.Equals()方法,并談一談在什麼情況下應該重寫這個方法,以及該方法具體應該怎樣重寫。如果該方法的預設行為不适用于你所建立的類型,那麼就可以為該類型建立自己的Equals()方法。在預設情況下,Object.Equals()方法會依照對象的身份(或者說對象的辨別)來判斷兩個引用所指向的對象是否相等,這種判斷方式與Object.ReferenceEquals()所采用的方式一樣。

但要注意:值類型的預設行為不是這樣。用struct關鍵字建立的所有值類型都繼承自System.ValueType,而System.ValueType重寫了Object.Equals()方法。由ValueType所實作的Equals()方法在比較兩個值類型的引用是否指向同一個對象時,看的是這兩個引用所指向的對象是不是同一個類型以及它們的内容是不是也相同。問題在于,ValueType所給出的實作方式的效率并不是很高。由于它是所有值類型的基類,是以它必須按照最通用的方式來實作,以便應對各種各樣的值類型,為此,它不能去具體地判斷某個對象的運作期類型,而是在這個類型的每一個成員字段上分别進行比較。在C#語言中,這需要通過反射來實作。反射有很多缺點,在追求性能的場合尤其不适合用反射,而判斷兩個對象是否相等恰恰就是這樣一種需要在程式中頻繁執行的操作,是以,我們應該尋找比反射更快的辦法。而且對于絕大多數的值類型來說,我們确實能夠找到更快的實作方式來重寫 ValueType所提供的Equals()方法。是以,就值類型而言,有這樣一條簡單的原則:建立值類型的時候,總是應該針對這個類型重寫ValueType.Equals()方法。

對于引用類型來說,隻有當你需要修改該類型所定義的語義時,才應該重寫執行個體版本的 Equals()方法。.NET Framework Class Library中的很多類采用值語義而不采用引用語義來判斷是否相等。例如,判斷兩個string(字元串)對象是否相等,看的是它們有沒有包含一樣的内容;判斷兩個DataRowView對象是否相等,看的是它們有沒有指向一樣的 DataRow。總之,如果你的類型需要采用值語義而不是引用語義(或者說,需要按照對象内容而不是對象身份來進行比較),那麼就應該針對這個類型重寫執行個體版本的Object.Equals()方法。

明白了何時應該重寫Object.Equals()方法之後,現在來看看具體怎樣實作它。處理值類型的等同關系時,需要考慮與裝箱機制有關的一些問題,對于這些問題請參見《Effective C#》(第 3 版)第9條。處理引用類型的時候,應該確定自己所實作的執行個體方法不要與預定義的行為有明顯的差别,否則,使用你這個類的人就會覺得奇怪。此外,重寫 Equals()的時候,還應該讓該類型實作IEquatable接口。(稍後會講到這個問題。)

下面是重寫System.Object.Equals的标準流程,其中考慮到了該類型所要實作的IEquatable接口:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

Equals()方法決不應該抛出異常,即便抛出了,也沒有太大意義,因為調用者在這裡所關心的僅僅是兩個引用所指向的對象是否相等,而不關心其他方面的錯誤。如果你認為某些情況屬于錯誤(例如引用是 null,或者參數的類型不對),那就傳回false。

現在我們仔細看看這個方法的每個步驟,以便了解它為什麼做這些檢測,另外,還要談一下它為什麼把某些檢測給省略掉了。首先,判斷右側對象是否為 null。至于左側的this引用,在C#中絕對不可能是null,因為試圖在null引用上調用執行個體方法一定會導緻 CLR(Common Language Runtime,公共語言運作時)抛出異常。這樣的判斷方式其實并不具備對稱性,因為在a不是null但b是null的情況下調用a.Equals(b),會得到false的結果,反過來調用b.Equals(a)的時候,按道理也應該得出false,但實際上,卻會引發 NullReferenceException異常。

接下來,需要判斷兩個引用是否指向同一個對象,也就是按照對象的身份進行判斷。這是個效率很高的做法,因為如果連身份都相同的話,那麼内容必然也相同,是以不需要再判斷具體的内容。如果身份不同,那麼開始按照内容執行正式的判斷。有個很關鍵的地方值得注意:這段代碼沒有認定this(本對象)一定是Foo類型,而是在它上面調用了this.getType(),并把擷取到的類型與right(右側對象)的類型相比較。這樣做有兩個原因。第一,調用該方法的執行個體this可能不一定屬于Foo類型本身,而是屬于它的某個子類型。第二,此處必須判斷right參數所指向的對象其類型是否與this完全一緻,而不能隻是簡單地通過as運算符試着将其轉換成Foo類型,因為right與this一樣,未必一定是Foo類型本身,它同樣有可能是Foo的某個子類型。假如你以為,隻要能保證 right 可以順利地從 object 轉成 Foo 就夠了(或者說,隻要 as 運算符的轉換結果不是 null 就行),那麼寫出來的代碼可能會出現一些微妙的 bug。下面用一套簡單的繼承體系來示範這些 bug:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

按道理來說,Comparison 1(第一次比較)的結果應該與Comparison 2(第二次比較)的一樣,如果前者列印Equals(相等),那麼後者也應該列印Equals;如果前者列印Not Equal(不相等),那麼後者也應該列印Not Equal。但實際上,這兩次比較有可能得出互相沖突的結果,因為早前的代碼中有一些地方寫得并不正确。就本例而言,第二次比較是決不會得出Equals這一結果的,因為derivedObject是D類型(派生類型)的對象,而 baseObject則是B類型(基礎類型)的對象。由于B類型無法自動轉換成D類型,是以,系統會把derivedObject.Equals(baseObject)解析到B類型所實作的Equals(B other)方法中。與之相反,第一次比較卻有可能得出Equals這一結果,因為它是在基類對象baseObject上,以派生類的對象derivedObject為參數來調用Equals()方法的。系統可以把derivedObject參數從D類型自動轉換成B類型,是以,它會把baseObject.Equals(derivedObject)也解析到B類型所實作的Equals(B other)方法上。于是,按照那個方法所采用的判斷邏輯,隻要由other參數所表示的derivedObject對象中與B類型有關的内容和由this所表示的baseObject對象中的對應内容相等,程式就會判定這兩個對象相等,而不會考慮derivedObject中是否還有其他一些baseObject所不具備的内容。由此可見,系統在繼承體系中所做的自動轉換會破壞Equals方法本來應該具備的對稱性質。

下面這樣寫會令系統把derivedObject從D類型自動轉換成B類型:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果baseObject.Equals()方法僅僅根據與B類型有關的字段是否分别相等來判斷兩個對象是否相同,那麼它就會判定baseObject與derivedObject相同。反過來說,如果把derivedObject寫在前面,那麼系統無法将參數baseObject從B類型自動轉換成 D 類型:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

調用derivedObject.Equals(baseObject)總會得出false。由此可見,如果沒有通過GetType()來比較兩個參數的實際類型是否完全相同,而是隻根據能否對right參數順利執行as操作來判斷它是否與本對象同屬一個類型,那麼就會遇到剛才示範的情況,使得比較的結果會因兩個對象的先後順序而有所差别。

前面的例子還展現出另一個重要的問題,就是如果你的類型重寫了Equals(object),那麼應該實作IEquatable接口。該接口包含名為Equals(T other)的方法。實作這個接口,意味着向使用這個類型的開發者傳達了一條資訊,告訴他們自己所寫的類型能夠以類型安全的方式來判斷兩個對象是否相等。如果你認定隻有當右側對象與左側對象是同一種類型時Equals()才有可能傳回true,那麼采用了這種寫法之後,編譯器就能幫你把左右兩側對象不是同一類型的情況給攔截下來。

重寫Equals(object)方法的時候,要記住一條原則:隻有在基類型的Equals (object)不是由System.Object或System.ValueType所提供的情況下,才需要調用基類型的版本。剛才的例子就示範了這種情況。對于D類型的基類B來說,它的Equals(object)并不是由System.Object或System.ValueType所提供的,而是由編寫B類的開發者自己定制的,是以,D類型的Equals(object)方法應該通過base.Equals(right)來調用基類版本的Equals(object)方法。與之相反,B類型的 Equals(object)方法則不需要再通過base.Equals(right)來調用其基類型的版本了,因為假如那樣做的話,調用的就是System.Object所實作的Equals(object)方法,該方法是根據兩個對象的身份而不是内容來判斷它們是否相等。這種判斷方式并不是編寫 B 類型的開發者想要實作的效果,如果他真的要實作這種效果,就沒有必要專門在B類型中重寫Equals(object)方法了,而是可以直接沿用其基類型System.Object的版本。

總之,在建立值類型的時候,總是應該考慮重寫 Equals() 方法,而在建立引用類型的時候,如果你認為這種類型不應該像System.Object預設的那樣根據引用來判斷兩個對象是否相等,而是需要根據内容來進行判斷,那麼也應該考慮重寫 Equals() 方法。重寫該方法的時候,請參考早前那個Foo類型的例子來編寫判斷邏輯。重寫Equals()方法意味着需要同時重寫GetHashCode()方法,這将在第10條中詳述。

現在還剩下==運算符需要考慮。這個運算符是否應該重寫是比較容易判斷的:如果建立的是值類型,那麼可能應該重新定義operator ==(),因為你需要像重寫執行個體版本的 Equals()方法那樣,通過重載該運算符來實作高效的比較邏輯。預設版本的 == 運算符會通過反射來比較兩個值類型的對象是否相等,這麼做的效率當然比你自己實作出來的要低。然而你自己在實作這個運算符的時候,應該遵照《Effective C#》(第 3 版)第 9 條所說的建議,不要在對比兩個值類型的對象時觸發裝箱操作。

請注意,這并不意味着隻要重寫了執行個體版本的Equals()方法,就必須重新定義operator==()。這隻是說,當你建立的類型是值類型的時候,應該考慮重新定義operator==()。反之,如果建立的是引用類型,那麼幾乎不需要重寫這個操作符。.NET Framework中的類都認為==運算符在string以外的引用類型上應該按照引用(或者說身份)來比較兩個對象是否相等。

最後要說IStructuralEquality接口,System.Array與Tuple<>泛型類都實作了這個接口。它使得這些類型本身可以從數值的角度,根據其中的各個元素來判斷該類型的兩個對象是否相等,而不要求那些對象所在的類型必須實作基于數值的判定邏輯。你自己在建立新類型的時候,很少需要實作這個接口,除非你建立的類型确實是個相當輕量的類型。實作這個接口,意味着該類型的對象需要作為字段或元素融入某個較大的對象中,而那種對象所在的類型需要按照數值來判斷兩個對象是否相同。

C#提供了很多手段來判斷兩個對象是否相等,然而你在編寫自己的類型時,通常隻需要考慮其中的兩個手段,并實作與之對應的接口。靜态版本的Object.ReferenceEquals()與Object.Equals()方法決不需要重新予以定義,因為無論待比較的兩個對象在運作期是什麼類型,這兩個方法都能正确地進行比較。你要考慮的是針對自己所建立的值類型來重寫執行個體版本的Equals()方法,并重載==運算符,以求提升比較的效率。如果你建立的某個引用類型需要按照内容而非身份來判斷兩個對象是否相等,那麼應該針對該類型重寫執行個體版本的Equals()方法。重寫Equals()方法的時候,還應該考慮實作IEquatable接口。筆者把如何判斷兩個對象是否相等總結成了剛才的幾條建議,這個問題現在看起來是不是變得簡單一些了呢?

第10條:留意GetHashCode()方法的使用陷阱

這本書中的其他一些條目談的是應該怎樣去編寫某個方法,或者應該在什麼樣的情況下編寫某個方法,隻有這一條專門用來談為什麼不應該編寫某個方法。這個方法指的就是 GetHashCode()。它隻有一個用途,就是在基于哈希的集合中定義鍵的哈希值(也叫哈希碼)。這種集合通常指的是HashSet或Dictionary這樣的容器。在這種場合,你确實有理由針對鍵所在的類型來重寫這個方法,因為那個類型的基類所提供的版本可能會有一些問題。對于引用類型來說,那個版本的效率不是很高,對于值類型來說,那個版本可能根本就不正确。然而更嚴重的問題是:你或許無法寫出既高效又正确的 GetHashCode()方法來。沒有哪個函數能像GetHashCode()這樣引發如此多的争論,并給開發者帶來如此多的困擾。現在就來談談怎樣避免這些困擾。

如果你建立的類型根本就不會在容器中當作鍵(key)來使用,那就不用擔心 GetHashCode()方法該怎麼定義了。視窗控件、網頁控件或資料庫連接配接就屬于這種類型,因為你不太會把這些對象當成集合中的鍵來用。在這些情況下,不要自己去定義 GetHashCode()。如果你建立的類型是引用類型,那麼它預設采用的GetHashCode()方法是可以正常運作的,隻不過效率比較低罷了。如果你建立的類型是值類型,那麼應該盡量将其設計為不可變的類型(具體做法參見第 3 條。在這種情況下,預設的GetHashCode()方法也可以正常運作,然而效率同樣很低)。總之,無論你建立的是引用類型還是值類型,在絕大多數情況下,最好不要去動GetHashCode()方法。

如果你建立的類型确實要充當哈希鍵,那麼才需要考慮怎樣實作GetHashCode()方法,此時需要看看下面講的内容。每個對象都可以産生哈希碼,這是個整數值。基于哈希的容器在其内部會根據每個元素的哈希碼來做出優化,以便更快地進行搜尋。這些容器要把存儲空間劃分成多個桶,進而能夠把每個元素都放到對應的桶中。其哈希碼符合某項條件的那批元素會放在與該條件相對應的桶中。在儲存元素時,容器要計算該元素的鍵所具備的哈希值,以決定它究竟應該放在哪個桶中。而擷取元素時,容器也要根據鍵的哈希值來确定自己究竟應該在哪個桶中尋找這個元素。哈希機制就是為了提升搜尋效率而設計的。在理想的情況下,容器的每一個桶中都應該隻包含少數幾個元素。

.NET中的每個對象都有哈希碼,其哈希碼由System.Object.GetHashCode()決定。如果要重寫該方法,那麼必須遵守下面3條規則:

1.如果(執行個體版本的Equals()方法認定的)兩個對象相等,那麼這兩個對象的哈希碼也必須相同,否則,容器無法通過正确的哈希碼來尋找相應的元素。

2.對于任何一個對象A來說,GetHashCode()必須在執行個體層面上滿足這樣一種不變條件(或者說,在執行個體層面上具備這樣一種固定的性質)—在A的生命期内,無論執行個體 A 在執行完GetHashCode()方法之後還執行過其他哪些方法,當它再度執行GetHashCode()時,必定傳回與當初相同的值。這條性質用來確定容器總是能把A放在正确的桶中。

3.對于常見的輸入值來說,哈希函數應該把這些值均勻地映射到各個整數上,而不應該使自己所輸出的哈希碼僅僅集中在幾個整數上。如果每一個整數都能有相似的機率來充當對象的哈希碼,那麼基于哈希的容器就能夠較為高效地運作。簡單來說,就是要保證自己所實作的GetHashCode()能夠讓元素均勻地儲存在容器的每一個桶中。此外,每個桶的元素個數也不宜太多。

要想寫出正确而高效的哈希函數,就必須透徹地了解調用該函數的對象所屬的真實類型,這樣才能確定該函數遵守剛才所說的最後那條規則。System.Object和System.ValueType所定義的版本顯然無法确定這個函數究竟在什麼類型的對象上調用。由于它不知道具體的類型,是以隻能按照最通用的辦法來計算。Object.GetHashCode()方法是根據System.Object類的内部字段來生成哈希值的。

現在,我們先看看系統預設提供的Object.GetHashCode()是否符合上面的3條規則。如果兩個對象相等,那麼Object.GetHashCode()會傳回相同的哈希值,因為系統是按照對象的身份來判斷兩個對象是否相等的。如果兩個對象相等,那麼說明它們具備相同的身份辨別,而Object.GetHashCode()同樣是根據對象内部代表身份辨別的字段來産生哈希碼的,是以,它會為這兩個對象輸出相同的哈希碼。由此可見,系統預設提供的 GetHashCode()方法确實符合第1條規則。但是,如果你決定重寫Equals()方法,那麼必須同時重寫GetHashCode()方法,以確定第 1 條規則能夠繼續得到遵守(詳情參見第 9 條)。

接下來看第2條規則。由于對象建立出來之後系統為它生成的哈希碼總是固定不變的,是以,預設版本的Object.GetHashCode()也符合第2條規則。

最後看第3條規則,也就是看Object.GetHashCode()是否能夠根據有可能出現的輸入值把哈希碼均勻地分布到各個整數上。Object.GetHashCode()并不了解調用該方法的對象究竟屬于Object類本身,還是屬于某個具體的子類,但該方法确實會在它所能做到的範圍内盡量保證生成的哈希碼是均勻分布的。是以,我們可以認為,它很好地遵守了第3條規則。

在開始講解如何重寫GetHashCode之前,我們還要看看System.ValueType所實作的GetHashCode()方法是否符合剛才那3條規則。System.ValueType重寫了GetHashCode()方法,以便給所有的值類型都提供一份預設的實作。它所傳回的哈希碼,就是類型中第一個字段的哈希碼。例如:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

MyStruct對象所傳回的哈希碼就是msg字段的哈希碼。這意味着隻要msg不是null,下面這段代碼便總是傳回true:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

第1條規則要求兩個(由執行個體版的Equals()方法判定為)相等的對象必須傳回相同的哈希碼。在大多數情況下,值類型的對象都遵守這條規則,但有人可能會故意違反或是在無意之間違背這條規則。(此外,在編寫引用類型的對象時,常常出現兩個對象經由Equals()判定為相等但哈希碼卻不相等的情況。)在本例中,Equals()方法會對比兩個結構體(struct)的首個字段,當然也會對比其他的字段,而GetHashCode()則隻關注首個字段。在這種實作方式下,它是符合第 1 條規則的。如果你自己重寫了Equals()方法,那麼隻要該方法在判斷兩個對象是否相等時顧及了結構體的首個字段,那麼GetHashCode()方法就依然有效。反之,如果你編寫的結構體類型在判斷兩個對象是否相等時根本就不參考第一個字段,那麼由System.ValueType預設提供的GetHashCode()方法就會違背上述第1條規則。

第2條規則要求哈希碼必須在執行個體層面上保持不變。隻有當struct的首個字段是不可變的字段時,GetHashCode()才能滿足這條規則。如果首個字段的值是可以修改的,那麼修改了之後,GetHashCode()所傳回的哈希碼也會發生相應的變化,進而違背了第2條規則。是以,隻要你建立的struct類型允許該類型的對象在其生命期中修改首個字段的值,那麼預設的GetHashCode()方法就會在該值發生變化後失效。值類型之是以應該設計成不可變的類型,這也是其中一項理由(參見第3條)。

第3條規則是否得到滿足,要看首個字段是什麼類型,以及該字段在這種結構體的各個對象中是怎樣取值的。如果這個字段所在的類型其GetHashCode()方法能夠産生分布較為均勻的哈希碼,而且該結構體的各個對象能夠在首個字段的取值上互相錯開,那麼整個struct的GetHashCode()方法就能夠生成排列較為均勻的哈希碼。反之,如果有許多對象都在第一個字段上具備相同的值,那麼GetHashCode()方法就不能很好地滿足第3條規則了。比方說,稍微修改一下早前的struct:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

如果有多個MyStruct對象都把epoch字段設定成目前這一天(這裡假設隻精确到天,而不包含更為具體的時刻),那麼這些對象的哈希碼就都一樣。這種取值情況會令 GetHashCode()無法産生均勻分布的哈希碼。

現在把系統預設提供的哈希函數總結一下。Object.GetHashCode()方法能夠為引用類型的對象生成正确的哈希碼,然而它所生成的哈希碼未必是均勻分布的。(如果你在自己的類型中重寫了執行個體版本的Object.Equals()方法,那麼該方法有可能失效。)對于值類型來說,隻有當結構體的第一個字段是隻讀字段時,ValueType.GetHashCode()才能夠正常地運作。如果這種struct的各個對象能夠在該字段的取值上互相錯開,那麼ValueType.GetHashCode()方法所給出的哈希碼用起來就比較高效。

如果你想産生更好的哈希碼,那麼需要對類型做出一些限制。最好是能建立不可變的值類型,因為這種類型的GetHashCode()方法寫起來要比不受限制的類型簡單得多。現在我們就來看看怎樣實作GetHashCode()才能確定早前那3條規則都得到遵守。

首先,第1條規則要求,如果Equals()方法認定兩個對象相等,那麼GetHashCode()方法為這兩個對象所生成的哈希碼也必須相等。這意味着,凡是在生成哈希碼的過程中用到的屬性或資料都需要在判斷兩個對象是否相等的時候予以考慮,隻有這樣,才能確定這條規則得到遵守。反之,在判斷相等的時候所用到的屬性也應該在計算哈希碼的過程中予以考慮。有些GetHashCode()方法雖然遵守第1條規則,但卻沒有做到剛才說的那一點,例如System.ValueType所提供的GetHashCode()方法在計算哈希碼的時候,就不一定會把判斷兩個對象是否相等時所用到的屬性考慮進去。于是,這樣計算出來的哈希碼無法很好地遵守第 3 條規則。總之,判斷兩個對象是否相等與計算對象的哈希碼這兩種操作,都應該依據同一套元素來執行。

第2條規則要求GetHashCode()方法的傳回值在執行個體層面上必須固定不變。假如你定義了下面這個Customer引用類型:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

然後,又執行了下面這段代碼:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

那麼以後就無法在 hash map 中找到 c1 這個對象了。剛開始把 c1 添加到 map 的時候,它的哈希碼是根據内容為Acme Products的字元串而生成的,可是後來又把客戶(Customer)的名字(Name)改成了Acme Software,于是該對象現在的哈希碼也随之改變,因為 GetHashCode() 方法會根據新的名字(也就是Acme Software)來計算哈希碼。剛開始儲存 c1 的時候,hash map 會依照從字元串Acme Products計算出來的哈希碼來決定這個c1應該儲存到哪個桶中,但是,當它的名字變成Acme Software之後,hash map 可能就會誤以為它儲存在另外一個桶中。由于對象的哈希碼沒有在該對象的生命期内保持不變(或者說不具備對象層面上的不變性質),是以hash map 以後無法正确地尋找這個對象。如果對象在存儲到容器之後其哈希碼發生變化,那麼容器就有可能找不到這個對象。盡管對象此時依然位于容器中,但容器會誤以為它存放在另一個桶中。

這個問題隻有當Customer是引用類型時才有可能出現,如果它是值類型,那麼就不會出現這個問題,但是,程式的行為依然不太正常,因為它會表現出别的問題。Customer若是值類型,那麼儲存到 hash map 中的就是 c1 的一份副本,剛才寫的最後一行代碼雖然修改了 c1 對象的 Name,但卻影響不到那份副本的 Name,是以hash map 中儲存的Customer對象根本不會得到修改。由于裝箱與解除裝箱等機制也會涉及副本問題,是以值類型的對象在添加到集合中後,其成員是不太可能發生變化的。

要想遵守第 2 條規則,你隻能根據對象中某個(或某些)不會發生變化的屬性來計算該對象的哈希碼。例如System.Object就是用對象辨別符來計算哈希碼的,因為同一個對象在其生命期内辨別符始終不變。在計算哈希碼的時候,System.ValueType會假設該類型的首個字段不會發生變化,如果你無法将自己所寫的值類型設計成不可變的值類型,那麼恐怕也找不出比這更好的辦法。如果某個值類型的對象打算在 hash 容器中當作 key(鍵)來用,那麼該類型必須是不可變的類型。如果某個類型做不到這一點,但你卻把該類型的對象當成 key 來用,那麼使用這個類型的人就有可能會破壞這個容器,令它無法正常運作。

為了解決這個問題,我們可以修改Customer類的代碼,令它的Name屬性不可變:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章
帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

修改後的Customer類可以提供名為ChangeName()的方法,該方法通過構造函數與對象初始化語句來建構新的Customer對象,并把本對象的revenue值賦給新對象的對應屬性。這樣修改之後,開發者就不能再像早前那樣直接修改Name屬性了,而是要通過ChangeName()來建立新的對象,該方法會把新對象的Name屬性設定成開發者通過參數所傳入的那個名字:

帶你讀《More Effective C#:改善C#代碼的50個有效方法》之一:處理各種類型的資料第1章

使用修改後的Customer類時,開發者為了修改客戶名稱,必須先把該客戶從 myDictionary中移走,然後通過ChangeName()方法建立出具備正确名稱的另一個 Customer對象,以代表改名之後的客戶。接下來,還要把新的Customer對象重新放回 myDictionary中。與早前的寫法相比,這樣寫雖然較為麻煩,但是能夠得出正确的結果,而不像原來那樣會讓程式出現問題。早前的寫法有可能導緻開發者寫出錯誤的代碼。如果你能像本例這樣把計算哈希碼時所用到的屬性設計成不可變的屬性,那麼就能夠確定程式表現出正确的行為,因為使用該類型的人現在已經無法修改計算哈希碼時需要用到的屬性了。現在這種寫法要求類型的設計者與使用者都必須編寫更多的代碼才行,然而,這卻是很有必要的,因為隻有這樣做,才能使程式正常運作。總之,如果在計算哈希碼的過程中需要用到某個資料成員,那麼就必須把該成員設為不可變的成員。

第 3 條規則要求,GetHashCode()應該把有可能出現的各種輸入值都均勻地映射到可以充當哈希碼的整數上。至于如何滿足這項要求,則要看你所建立的類型具體是怎麼使用的。假如真的有一個奇妙的公式能适用于所有的類型,那麼System.Object早就拿它來計算哈希碼了,這樣的話,現在的這一條目(即本書的第10條)也就不用寫了。有一種常用的雜湊演算法,是在類型中的每個字段上分别調用其GetHashCode()方法,并把傳回的哈希碼進行異或(XOR)運算,這樣就得到了對象本身的哈希碼(注意:可變的字段不參與計算)。隻有當該類型的各字段之間互相獨立時,這樣的算法才能見效,否則,這種算法所生成的哈希碼還是有可能集中在某幾個值或某幾個範圍内的值上,進而無法實作均勻分布。這樣的話,容器隻會把元素集中儲存在少數幾個桶中,使得這些桶過于擁擠。

.NET 架構中有兩個例子,可以用來示範怎樣才算較好地實作了第 3 條規則。第一個例子是int,這個類型的GetHashCode()方法會直接把int所表示的整數值當成哈希碼傳回,這相當于根本就沒有做随機處理,于是,取值相近的一組源資料其哈希碼也必然會聚集到某個較小的範圍中,而無法實作均勻分布。第二個例子是DateTime,它的GetHashCode()方法把内部64比特的Ticks屬性分成高 32 位與低 32 位兩個部分,并對二者做 XOR 運算,這樣得到的哈希碼不會過于聚集。在給自己的類型編寫GetHashCode()方法時,如果能利用DateTime所實作的版本,而不是直接根據年、月、日等字段計算,那麼獲得的結果可能會好一些。比方說,如果要編寫某個類型來表示學生的資訊,那麼就要考慮到許多學生都是在同一年出生的,是以,根據年份算出的哈希值就有可能分布得過于密集。總之,要想建構出合适的GetHashCode()方法,就必須先清楚地知道自己所寫的類型在各字段的取值上有什麼樣的特點或規律。

GetHashCode()方法對開發者提出了3個很具體的要求,也就是要求相等的對象必須具備相等的哈希碼,而且要求同一個對象在其生命期内必須傳回同一個哈希碼。此外,為了使基于哈希的容器能夠高效地運作,它還要求哈希碼必須均勻分布,而不能過于密集。隻有當你實作的類型是不可變的類型時,才有可能寫出滿足這3個要求的GetHashCode()方法。如果這個類型是可變的,那你恐怕就得依賴系統所提供的預設版本了,在這種情況下,你必須了解這麼做可能帶來哪些問題。