第 51 講:類型良構(gòu)規(guī)范(一):類型和成員的實(shí)現(xiàn)規(guī)范
之前我們講完了基本的數(shù)據(jù)類型的類別(類、結(jié)構(gòu)、接口、委托、枚舉),C# 目前只有這些數(shù)據(jù)類型類別。到以后學(xué)到新的語法的時候,C# 會更新拓展語法,以后就還會有更多的數(shù)據(jù)類型的類別,比如 C# 9 的記錄類(Record Class)類型和 C# 10 的記錄結(jié)構(gòu)(Record Struct)類型。目前我們先考慮簡單的、學(xué)過的數(shù)據(jù)類型類別,針對它們給大家講解一下,如何實(shí)現(xiàn)一個數(shù)據(jù)類型,才是一個良好的、正確的、干凈的實(shí)現(xiàn)模式、方式。
本節(jié)內(nèi)容從類型的角度出發(fā),給大家講解一些危險的實(shí)現(xiàn)模式,以及不正確的實(shí)現(xiàn)模式,好讓大家明白面向?qū)ο蟮降讓τ?C# 有什么樣的地位、作用都有哪些?!皩︻愋陀辛己玫膶?shí)現(xiàn)”在英語里稱為 well-formed implementation,這個詞組不是很好翻譯。參照《C# 本質(zhì)論》一書的介紹,這個詞組將被翻譯成“良構(gòu)的實(shí)現(xiàn)”,因此,本節(jié)內(nèi)容使用良構(gòu)(Well-formed)作為內(nèi)容給大家介紹,其中“良構(gòu)”我們將其當(dāng)作術(shù)語來理解,后面就不再說明這一點(diǎn)了。
Part 1 類型和命名空間的原則
1-1 類聲明上盡量追加 sealed
修飾符
在類的聲明上,我們會看到類的修飾符關(guān)鍵字的一些基本標(biāo)記。不過大多數(shù)人都會在自己寫程序的過程簡單地寫成 public class
或者干脆就直接寫 class
了,比如這樣:
這種實(shí)現(xiàn)不能說危險,但相當(dāng)不建議這么使用。這是因?yàn)槌绦騿T可能會逮著一些完全沒有意義的類型往下派生別的類型。這是非常奇怪的。
舉個例子。像是上面這個叫做 Program
類型的東西,它的出現(xiàn)僅僅是為了提供 Main
方法的存儲,它根本不拿來干別的事情。如果你對這個 Program
類型派生,顯然就沒有任何意義。所以,我們盡量建議你在上面類型上加上 sealed
關(guān)鍵字來表示類型不再派生出別的子類型,防止別人誤用和亂用。
當(dāng)然,如果這個類型要拿去派生的話,你肯定就得去掉 sealed
了。這不是顯然的嘛。而且,其它類別的數(shù)據(jù)類型也不支持派生對象,那肯定你就不能使用 : 類型
的語法了,自然就不可能有繼承關(guān)系,也自然就不存在 sealed
一說。但請注意,接口專門用于派生給類和接口,所以接口是不能有 sealed
修飾符的,因?yàn)榻涌诙济芊饬耍鸵馕吨鼰o法派生別的類型出來;但接口的作用就是約束實(shí)現(xiàn)派生類型用的,顯然就矛盾了,這樣做沒有意義。
1-2 類型名一定不要和庫提供的類型名沖突
還記得命名空間這么一回事嗎?這個東西我們很早就說過了,但是在前面內(nèi)容講解的時候,完全沒用上這些東西,因?yàn)槲艺f過命名空間不是讓人背的,這些是用的時候去查資料去搜,用多了就記住了。而且我們之前的內(nèi)容基本上只用到了 System
這一個命名空間里的類型,所以基本上也就只需要追加一句 using System;
即可。
可是問題就在于,我們時刻在不經(jīng)意之間就會用上 System
命名空間里的類型??蓡栴}就在于,如果你把你自己定義的類名取名成和這些類型名一樣的類型。這樣雖然語法上是同意你這么做的,但是這么做會使得命名取名很混亂。就比如說吧,我自己寫了一個 String
類型的東西。但是 System
命名空間里包含了 String
類型(基本類型,雖然平時也經(jīng)常用小寫 string
關(guān)鍵字表達(dá)這個類型,但是這個類型是客觀存在的),這樣名字就沖突了。如果你要引用和區(qū)別開兩個類型,你還得自己手動加上命名空間,比如
系統(tǒng)類型:
System.String
;自己寫的類型:
TestProject.String
。
顯然這么做就沒有必要。實(shí)際上 C# 為了規(guī)避命名空間里的類型重名,是有很多手段的,比如“寫全命名空間”;甚至還有 extern alias
指令還可以指定同命名空間但引用的 dll
文件不一樣的同名類型。當(dāng)然這些是我們沒有必要接觸到的東西,這些自己查資料就可以自己學(xué)的,沒有必要刻意去學(xué)它們,它們也不是特別重要。我在這里說這么多只是想告訴你 C# 很多語法是為了避免你“無法做到這一點(diǎn)”才產(chǎn)生的語法,你可能平時完全用不上它們,但在一些極端情況下,它們就會起到非常棒的作用。
但是,重名會引用全名導(dǎo)致書寫代碼極其不方便不說,還影響可讀性,所以不建議這么做。要么,你的類型換一個名字,要么,嵌套到別的類型下,總之避免這個類型可以直接調(diào)用和引用。
1-3 盡量使用最簡類型名
命名空間有這么一個用法:如果你不想寫 using
指令的時候,類型名是可以把它的命名空間寫在前面,然后跟上 .
表示出來的。舉個例子,我們經(jīng)常用 Console
類型輸出內(nèi)容。
using System;
using
指令了。不過這樣的話,類型前面要跟上命名空間,這樣會長一點(diǎn)。但是,我們?yōu)榱俗非蠛喕a、增加可讀性的約定,因此我們并不建議時而使用全名寫法,時而又使用沒有前面命名空間的寫法。這樣代碼風(fēng)格會很亂。因此我們建議統(tǒng)一導(dǎo)入命名空間(即使用 using
1-4 盡量不使用嵌套類型
說起來,上面這個部分說了,嵌套類型就得提一下了。很多小伙伴很喜歡嵌套類型,它可以允許我們在某一個數(shù)據(jù)類型的里面再創(chuàng)建一個嵌套的數(shù)據(jù)類型??蓡栴}在于,這樣的嵌套會使得你外部引用這個類型的寫法會很麻煩:先是外部類型名稱,然后才是內(nèi)部的這個類型名。
比如在 System
命名空間下有一個 Environment.SpecialFolder
枚舉類型。它是一個嵌套數(shù)據(jù)類型,你需要先寫它的所在類型名 Environment
,然后才是嵌套的枚舉類型名 SpecialFolder
。這種實(shí)現(xiàn)機(jī)制實(shí)際上是不推崇的,因?yàn)槟惚仨氁@么寫代碼:
就會出現(xiàn) Environment.SpecialFolder
這樣的類型名稱引用的寫法。為了規(guī)避這一點(diǎn),我們建議把嵌套類型從所在類型里拿出來。但是,庫內(nèi)的代碼是我們無法動的,你就只能老老實(shí)實(shí)使用 Environment.SpecialFolder
當(dāng)然,不建議使用嵌套類型不等于一定就不能用。在部分情況下,嵌套類型是一種方便的數(shù)據(jù)實(shí)現(xiàn)機(jī)制,它可以實(shí)現(xiàn)基本和 C++ 里 friend
關(guān)鍵字差不多的效果。如果 C# 沒有嵌套類型機(jī)制的話,很多功能都很難書寫起來,或者訪問修飾符級別會提升導(dǎo)致程序員誤用。
1-5 不要嵌套命名空間
C# 允許我們嵌套命名空間,比如這樣:
這樣的寫法是沒有問題的,它和單層的、小數(shù)點(diǎn)引用的命名空間寫法完全等價:
正是因?yàn)橐粚拥慕Y(jié)構(gòu),代碼看起來很清爽,所以我們不建議嵌套命名空間。
1-6 不要使用和系統(tǒng)命名空間沖突的 System
重名命名空間
既然類型名稱不建議和庫里的類型重名,那么命名空間自然也是一樣。顯然你不能定義叫 System
的重名的命名空間作為你自己的項(xiàng)目的命名空間,除非你里面書寫的全部代碼都能完美避開 System
里包含的庫里自帶的數(shù)據(jù)類型重名的問題。
1-7 接口里盡量不要帶有 object
里的那些方法
思考一個問題。object
里是不是包含 Equals
、GetHashCode
和 ToString
三個可重寫的 virtual
方法?這些方法可以提供給底層重寫以改變實(shí)現(xiàn)機(jī)制。如果 object
的這些方法被設(shè)計(jì)成 abstract
而不是 virtual
方法的話,每次你新建一個類型,都得去實(shí)現(xiàn)這三個方法。有些時候我們完全沒有必要實(shí)現(xiàn),就可以不管它們,所以大可不必搞成 abstract
。
那么到頭來,如果我們試著把接口里帶的成員也帶上 object
里的這些方法會如何呢?比如這樣:
結(jié)果是,沒有意義。你仔細(xì)思考一下。object
里是自帶這些方法的實(shí)現(xiàn)的,這就意味著,不管你實(shí)不實(shí)現(xiàn)接口,接口里的這個方法都是客觀上“已經(jīng)被實(shí)現(xiàn)了”的狀態(tài)。那么,接口就沒有起到接口的約束效果和作用。
正是因?yàn)槿绱?,我們不建議讓用戶將 object
里的那套虛方法帶入到接口里作為接口約束的成員的一部分。這是不合適的。
Part 2 構(gòu)造器
類型和命名空間的基本用法就介紹完了。下面我們來說一下構(gòu)造器的實(shí)現(xiàn)和使用規(guī)范。
2-1 為不需要初始化的非抽象類定義一個 private
無參構(gòu)造器
倘若我們有一個數(shù)據(jù)類型,它可以實(shí)例化的話,我們就要思考會不會被誤用。這是編程的開發(fā)人員必須考慮的問題,正是因?yàn)槿绱顺绦騿T的思維才大多是發(fā)散類型的。
假設(shè)這個數(shù)據(jù)類型不是 abstract
修飾的類型的話,那么它可以實(shí)例化。但問題就在于,這個類型如果里面全是靜態(tài)的方法,會怎么樣呢?實(shí)際上,有一個類型 System.Math
,它里面的成員就全部都是 static
修飾過的方法。這個類型專門用于提供一些數(shù)學(xué)上的處理過程,比如求正弦值之類的。
像是 Math
這樣的數(shù)據(jù)類型,我們稱為工具類(Tool Class)或者幫助類(Helper Class)。工具類一般不用來提供實(shí)例化的行為,因?yàn)樗鼈兝锩娴臇|西往往是“即取即用”的。比如求正弦值這個方法 Math.Sin
。我們總不能非得去實(shí)例化一個 Math
類型,然后才開始使用 instance.Sin
這樣的寫法吧,顯然就不夠方便。既然 C# 里有靜態(tài)方法,為什么我們要實(shí)例化一個 Math
這樣的東西出來呢?所以,里面提供的這些“工具操作”都是不必實(shí)例化就可以直接用的方便的方法集。
正是有這樣的數(shù)據(jù)類型,才能造就我們接下來要講的內(nèi)容。對于這樣的類型,我們作為程序員給別人使用 Math
類型之前就會考慮到對方可能會實(shí)例化的誤用行為。那么,我們就需要在這個類型里面,加上一個防止外部訪問的無參構(gòu)造器,修飾符用 private
,比如這樣:
Math
實(shí)例化的操作。因?yàn)轭愋陀?sealed
修飾后方法是密封狀態(tài)的,即無法繼續(xù)派生。但問題是,這樣的機(jī)制完全無法阻止實(shí)例化。要知道 C# 有一個默認(rèn)的行為是:如果一個類類型里沒有任何的構(gòu)造器,系統(tǒng)會自動生成一個 public
的無參構(gòu)造器。正是因?yàn)檫@個默認(rèn)機(jī)制的存在,如果我們不對這個類定義一個私有的無參構(gòu)造器的話,外部就會因?yàn)樽詣由闪?public
的無參構(gòu)造器而導(dǎo)致誤用和實(shí)例化 Math
類型。
加上無參構(gòu)造器并追加 private
修飾符后,這樣就阻止了編譯器自動生成無參構(gòu)造器的行為,而且恰好構(gòu)造器是私有的,外部也無法訪問到,因此整個類型就不會有實(shí)例化的誤用行為了。
順帶一提。構(gòu)造器是類型里的成員類別之一。正因?yàn)槿绱耍蓄悇e的成員默認(rèn)自帶的訪問修飾符都是
private
。所以其實(shí)……這個private Math { }
的private
也可以不寫:
這樣也可以 OK 的。我們只是廣泛建議各位寫代碼的時候,追加訪問修飾符(即使是默認(rèn)的級別也寫上)。
另外,你可能會說,我干脆把類型定義成抽象的不就行了?抽象類又不影響這些靜態(tài)方法的直接調(diào)用。加一個
abstract
修飾符不比加private
你可以這么參考著用,但沒有說你必須要這么用。畢竟,抽象類確實(shí)有一個作用是防止實(shí)例化,但它還有一個真正意義上的作用,是給派生出來的子類型提供公用的數(shù)據(jù)的提取。
2-2 為抽象類提供子類實(shí)例化的基本構(gòu)造器實(shí)現(xiàn)
實(shí)際上,“抽象類里的構(gòu)造器”一點(diǎn)在之前的內(nèi)容里完全沒有提到。這是因?yàn)樗臋C(jī)制有點(diǎn)別扭。因?yàn)槌橄箢惒荒軐?shí)例化,但抽象類的構(gòu)造器又是編譯器允許定義的,因此會讓初學(xué)者一臉懵逼。這一點(diǎn)我就放在這里來介紹了。
首先,抽象類是用來給子類繼承用的、數(shù)據(jù)提取的、僅用于提供數(shù)據(jù)的數(shù)據(jù)類型。這種數(shù)據(jù)類型的作用是給子類型提供使用和繼承的服務(wù)。就好像為什么有 object
一樣,因?yàn)橛行r候我們無法做到明確的類型的使用,或是反復(fù)重復(fù)相同的代碼,但就只是換一下數(shù)據(jù)的數(shù)據(jù)類型這樣的“重復(fù)操作”。有了 object
,我們可以把所有的數(shù)據(jù)類型直接轉(zhuǎn)換回 object
(也就是多態(tài)),這樣可以讓這個方法通用和普遍化,代碼就不必寫多份邏輯都是一毛一樣的過程了。
抽象類就是這么一種存在。為了給子類型提供繼承的服務(wù),我們提取出一樣的成員,這樣子類型就通過繼承語法完全拷貝這些成員,而不必自己再手寫一遍。
抽象類型是無法實(shí)例化的,這我們已經(jīng)說過很多次了。這也是抽象類本身的機(jī)制??墒牵橄箢惪梢园瑯?gòu)造器,這你知道嗎?
請注意里面的構(gòu)造器。因?yàn)轭愋蜔o法實(shí)例化,那么構(gòu)造器顯然就不可能通過語法 new Person(...)
這是因?yàn)闃?gòu)造器是給子類派生服務(wù)的。構(gòu)造器有兩大作用:
提供實(shí)例化語法(非
abstract
修飾的類,以及非接口的其它數(shù)據(jù)類型類別);提供給子類的
: base(參數(shù))
的構(gòu)造器調(diào)用鏈的語法使用。
第二點(diǎn)是最容易忽略的。因?yàn)槌鯇W(xué)很容易忘掉有這么一個機(jī)制。實(shí)際上,子類型在調(diào)用構(gòu)造器的時候,可以直接使用 : this(參數(shù))
調(diào)用同類型的別的構(gòu)造器,以及使用 : base(參數(shù))
調(diào)用基類的構(gòu)造器。當(dāng)然了,只要這個構(gòu)造器可以被子類型訪問到。如果你寫 private
,由于訪問修飾級別的問題,子類型仍無法看到這個構(gòu)造器。
那么,如果我們派生出 Teacher
類型的話,原本寫多行的語句就只需要寫一個 : base(參數(shù))
就可以完成賦值了:
這就是為什么這個機(jī)制得以存在的原因——方便。
2-3 為子類型提供調(diào)用的基類型構(gòu)造器使用 protected
修飾符
既然抽象類的構(gòu)造器沒辦法實(shí)例化,那么它只可能用于子類型的調(diào)用。那么問題來了。既然只能給子類型調(diào)用,那么給構(gòu)造器設(shè)置 public
或者 internal
訪問修飾符,和直接設(shè)置 protected
有區(qū)別嗎?
實(shí)際上是沒有的。因?yàn)榧词鼓阍O(shè)置了 public
和 internal
,對于別的類型,這個構(gòu)造器也完全沒有任何用途,畢竟你調(diào)用不了。它的唯一用途是為了子類型使用 : base(參數(shù))
語法,所以“給子類調(diào)用”這一點(diǎn)就夠了。因此,使用 protected
修飾符是最合適、最恰當(dāng)?shù)脑L問修飾符標(biāo)記。
你懂我意思吧。
同樣地,就算不是抽象類,那么這個構(gòu)造器既然只用來子類型的派生調(diào)用的話,那么構(gòu)造器還是 protected
比較好。
Part 3 字段
3-1 盡量讓字段只讀
這一點(diǎn)我就不多說明了。為了盡量細(xì)化規(guī)則規(guī)范,我們必然需要遵守這一點(diǎn)。只要字段后續(xù)不讓改的話,我們盡量就用上 readonly
修飾符,這樣可以保證字段的數(shù)值發(fā)生變動和更改。
除非,這個字段的配套屬性有 set
方法(賦值器),在實(shí)例化后仍可改變它的數(shù)值。
3-2 盡量保證結(jié)構(gòu)不可變
這一點(diǎn)我們之前說過了。還是那句話,“Mutable struct
s are evil”。之所以放在這里,是因?yàn)榻Y(jié)構(gòu)不可變的一個必要條件是字段(數(shù)據(jù)成員)全都是 readonly
修飾過的。
3-3 建議只讀量使用 const
和 static readonly
修飾
和 3-1 提及的效果類似。如果整個數(shù)據(jù)類型里需要用到一些數(shù)據(jù)完全相同的、以后不會發(fā)生變動的數(shù)據(jù)的話,就盡量用 static readonly
或 const
修飾。static
表示對象不用實(shí)例化就可以使用;readonly
則是保證數(shù)據(jù)不可變。那么組合起來就是不用實(shí)例化的不變數(shù)據(jù)。如果你不加 static
的話,每次都得等到實(shí)例化了之后這個數(shù)據(jù)才能使用,顯然這樣是不正確的實(shí)現(xiàn),也是麻煩的實(shí)現(xiàn)。所以能 static readonly
就不要只 readonly
。
另外,如果這個數(shù)據(jù)的數(shù)據(jù)類型是內(nèi)置的、非 object
的數(shù)據(jù)類型的話,請使用 const
修飾。const
和 static readonly
的區(qū)別是,const
數(shù)據(jù)編譯器期間就可以解析和分析出結(jié)果,但 static readonly
const
有一個特別棒的效果,就是編譯期替換。如果你用到了非常常用的、但數(shù)據(jù)可通用的地方的話,你肯定會優(yōu)先考慮把這個數(shù)據(jù)提到字段級別存儲。如果要想把數(shù)據(jù)類型推廣,只需要變動這個字段的數(shù)值,所有引用了這個字段的地方都可以發(fā)生自動的變動,這不比你手寫字面量要香?所以,const
比 static readonly
又要“優(yōu)越”一點(diǎn)。但是,const
能修飾的數(shù)據(jù)類型只有除了 object
以外的所有內(nèi)置類型(整數(shù)啊、浮點(diǎn)數(shù)啊、字符和字符串啊、布爾類型和枚舉類型之類的)而已。所以條件相當(dāng)苛刻。
說這么多是讓你知道,優(yōu)先考慮 const
修飾只讀數(shù)據(jù),如果無法 const
修飾的話,就用 static readonly
;實(shí)在是不能的話,最后再考慮 readonly
,是這么一個順序。當(dāng)然,如果數(shù)據(jù)可變,就不屬于本部分說的范疇了。
是的,沒錯。枚舉類型的所有字段也都是常量。這是因?yàn)樗鼈兪且蕴卣髦祦碜鳛橛?jì)算和使用的,只是編譯之前可以通過枚舉類型的機(jī)制“用取了名的信息來表達(dá)一些有意義的數(shù)據(jù)”。但是它的特征值都是以整數(shù)類型表達(dá)的,因此它們肯定也都是常量。所以如果是枚舉類型的對象作為字段出現(xiàn),也請使用
const
修飾,比如const Gender DefaultGender = Gender.Male;
。
3-4 靜態(tài)非只讀字段不要使用 public
修飾
這一點(diǎn)我不必多說了吧。雖然靜態(tài)類型的字段跟實(shí)例沒有關(guān)系了,但是它跟類本身的處理機(jī)制有關(guān)。這樣的字段一般只用于處理數(shù)據(jù),很少有別的用途。正是因?yàn)槿绱?,用于?nèi)部處理就不要公開暴露它。因此,不要出現(xiàn) public
修飾的靜態(tài)非只讀字段。
Part 4 屬性
屬性也是重要的數(shù)據(jù)處理成員。它是方法的替代,也是封裝字段的好伙伴。不過它的用法也很多,所以也有規(guī)范。
4-1 盡量不要在結(jié)構(gòu)里出現(xiàn) set
方法(賦值器)
這顯然是一句廢話。之前就說過不要使用可變結(jié)構(gòu),所以也就順帶約定了不要給屬性添加后續(xù)更改字段的賦值器了。
4-2 盡量用只讀屬性表示一些高階的數(shù)據(jù)成員
思考一個問題。假設(shè)我有一個類型表達(dá)了一個學(xué)生的學(xué)習(xí)成績(假設(shè)存儲的是“語文”、“數(shù)學(xué)”和“外語”三個科目的成績)。如果我想快速得到這個人學(xué)習(xí)平均成績的話,顯然我們可以使用方法 GetAverageScore
來計(jì)算。不過,我們有一種更優(yōu)的實(shí)現(xiàn)模式,就是使用只讀屬性這個特性。
我們經(jīng)常使用屬性,為其添加 get
和 set
方法(取值器和賦值器)來為底層字段賦值。殊不知實(shí)際上這個屬性本身的機(jī)制也不一定非要和單一的字段本身進(jìn)行綁定。屬性是可以單獨(dú)使用的,它可以用來計(jì)算一些數(shù)據(jù)得到結(jié)果,比如這里的平均數(shù)。平均數(shù)是三個科目的成績的總和再除以 3 得到的結(jié)果。這個公式也不復(fù)雜,也不必特意去用方法表示。而從面向?qū)ο髮用鎭碚f,“平均成績”本身也可以作為一個人的基本數(shù)據(jù)來表達(dá),因此它完全可以用一個屬性來表示。
可是,它這樣的話是沒有 set
方法(賦值器)的作用的,因?yàn)槠骄蛛y道你還想從外界賦值嗎?既然有了三個科目的成績了,那么就可以直接計(jì)算得到,何必我們單獨(dú)給個賦值器往里面賦值呢?而且就算有了賦值器,也沒有多大的意義,因?yàn)檫@個屬性本身也不是跟一個單一的字段綁定起來使用的,那么我們加賦值器又“賦”往何方呢?因此,這樣的屬性是不需要賦值器的。
之前我們接觸過一些賦值器只有 get
方法(取值器)而沒有 set
方法(賦值器)的情況,以表達(dá)一些只讀字段的讀取。這樣的屬性稱為只讀屬性(Read-only Property)。只要是只讀屬性,那么就沒有取值器。沒有取值器就意味著你的代碼會長這樣:
或者用底層的字段來取值:
很顯然這樣的實(shí)現(xiàn)是有道理的。我們堅(jiān)信屬性比方法成員更優(yōu)秀,所以能用屬性表達(dá)的話,就不要用方法。在以后的 C# 的新語法特性里,屬性也會被廣泛使用到。所以何樂而不為呢?
4-3 盡量不要使用只寫屬性
在 C# 里實(shí)際上也存在只寫屬性(Write-only Property),即只有 set
方法(賦值器)但沒有 get
方法(取值器)的屬性。但實(shí)際上只寫屬性用的機(jī)會并不多,而且“為什么我偏要往里面寫東西,卻又不需要讀取它呢?”,是吧,你肯定會有這種困惑,所以只寫屬性用得并不多。只寫屬性一般只會出現(xiàn)在 C# 使用的奇技淫巧里,正常我們是用不到它們的。
只寫屬性在初學(xué)的時候很容易用不好,雖然它在語法是沒有防止的,它也可以實(shí)現(xiàn)一些奇技淫巧,但是在正常實(shí)現(xiàn)和設(shè)計(jì)數(shù)據(jù)類型的時候,只寫屬性一般是沒有意義的,所以不要寫出只寫屬性。
非常不建議這么做。它的存在的唯一使用場合是在不需要通過屬性取值的同時,用來觸發(fā)事件?!笆褂脤傩杂|發(fā)事件”將在稍后提到。
4-4 盡量不要把底層集合類型的字段用屬性暴露出來
這個標(biāo)題是什么意思呢?后續(xù)的內(nèi)容我們會接觸到一個概念,叫做集合(Collection)。集合指的是“從邏輯上表達(dá)一系列數(shù)據(jù)的數(shù)據(jù)類型”,比如我們目前已經(jīng)學(xué)到的數(shù)組。數(shù)組就是一個集合。
這個標(biāo)題的意思是,請不要把數(shù)組這樣的類型用屬性的取值器返回出來。這個原因其實(shí)很簡單:因?yàn)楹苡锌赡苣愕倪@個集合類型屬性是配套了一個底層的字段的。這個字段也就是集合類型的。比如說我有一個存儲語數(shù)外三科成績的三元數(shù)組 int[] _scores
作為底層的字段。如果我們寫了這樣的代碼:
這樣就完完全全把底層的字段給返回了。還記得之前說數(shù)組的概念的知識點(diǎn)嗎?數(shù)組是引用傳遞,這也就意味著不論你是通過賦值給別的同類型數(shù)組對象,還是直接更改這個數(shù)組對象的屬性,都會改掉底層的這個字段里的信息,因?yàn)樗且脗鬟f。
比如這樣的代碼:
因?yàn)槭且脗鬟f,因此你如果對賦值后的 scores
變量更改數(shù)據(jù),也會改掉原來 student
變量里的底層字段里的第 2 個元素數(shù)值。這一點(diǎn)相當(dāng)隱蔽,但也請你注意,不要這么使用。這么做很危險。這就是為什么標(biāo)題不讓你將數(shù)組這樣的集合成員直接通過賦值器返回的原因。
比如這樣。
4-5 不要使用 public
類型的字段
看起來好像這個跟字段有關(guān),應(yīng)該放在“字段”一節(jié)里,但實(shí)際上這個內(nèi)容是和屬性息息相關(guān)的。字段的封裝靠屬性來實(shí)現(xiàn),正是因?yàn)槿绱耍覀儽仨毷褂脤傩詠肀磉_(dá)。
字段有一個好處是存儲數(shù)據(jù)信息,但如果你給出 public
類型的字段的話,用戶就可能通過神操作改變你這個字段的結(jié)果。如果數(shù)據(jù)不合法,更改期間因?yàn)槭侵苯油ㄟ^賦值表達(dá)式來賦值的,所以不會有任何判定,因此可能導(dǎo)致后期程序出現(xiàn)問題。
為了避免這一點(diǎn),我們必須使用屬性封裝來解決。將字段用 private
修飾,而配套的屬性才用 public
,并在 set
方法(賦值器)里書寫賦值和數(shù)據(jù)校驗(yàn)的操作,這樣可以達(dá)到嚴(yán)格賦值并防止濫用訪問的問題。所以,盡量不要公開任何字段,就算是這個字段好像怎么改也沒事。寫屬性是一種習(xí)慣,養(yǎng)成好習(xí)慣人人有責(zé)。
4-6 一般不建議使用靜態(tài)屬性,除非有特殊情況
C# 甚至允許你對屬性使用 static
修飾符來表示一個屬性是靜態(tài)的。但是一般來說,屬性是用來表達(dá)底層字段的封裝,但底層的字段要么本來就是靜態(tài)只讀的,所以靜態(tài)屬性完全沒有存在的意義。當(dāng)然,它在一些極端情況下是有用的,但正常情況是遇不上這樣的情況。所以我們不建議使用靜態(tài)屬性。
Part 5 索引器
5-1 索引器的參數(shù)盡量不要使用復(fù)雜的數(shù)據(jù)類型
索引器的參數(shù)起到索引的功能。正是因?yàn)槿绱?,這個成員才能叫做索引器。索引器的參數(shù)應(yīng)該盡量簡單才能具有更好更強(qiáng)的可讀性。假設(shè)我傳入了一個 Student
類型的成員進(jìn)去當(dāng)參數(shù),那么索引器又起到什么作用了呢?索引器肯定得有一個索引器的樣子,別的數(shù)據(jù)類型顯然就會降低可讀性。
這里的“復(fù)雜的數(shù)據(jù)類型”的“復(fù)雜”有一點(diǎn)靠語感才能理解。因?yàn)槲覀儾⒉荒苡靡粋€嚴(yán)格的定義來說明什么數(shù)據(jù)類型叫做“復(fù)雜”。一般內(nèi)置數(shù)據(jù)類型(比如字符、整數(shù)等等)都屬于“不復(fù)雜”的類型,但字符串雖然復(fù)雜,但因?yàn)樗S茫砸膊环Q為“復(fù)雜”的數(shù)據(jù)類型。這個靠語感來理解。
5-2 索引器也不建議返回數(shù)組類型的成員
這個我就不多解釋了。這樣容易破壞封裝,和返回集合類型的屬性一樣的道理。
5-3 不建議使用只寫索引器
這一點(diǎn)和只寫屬性完全一樣的道理,因此我們也不建議使用只寫索引器。
Part 6 事件
6-1 事件底層的字段一定做 null
檢查
觸發(fā)語句一般需要如下兩個步驟完成:
判斷委托字段是不是為
null
;當(dāng)不為
null
的時候才能使用.Invoke
以觸發(fā)事件。
注意,事件本身是不具備觸發(fā)事件的行為的。這一點(diǎn)相當(dāng)繞。事件成員僅僅起到的作用是封裝委托字段,但自身是邏輯上的事件,而并非語法上的事件。也就是說,這個成員只是一個封裝機(jī)制,而因?yàn)樗荏w現(xiàn)出的效果是事件應(yīng)該有的效果,因此只能稱為是邏輯上的事件。事件成員本身只能使用
+=
和-=
運(yùn)算符來控制事件的增刪回調(diào)函數(shù)的過程,本身不能.Invoke
來觸發(fā),因此事件成員本身是不能觸發(fā)事件的。觸發(fā)事件還得靠的是底層字段。
因此,綜合上面的寫法,觸發(fā)事件的標(biāo)準(zhǔn)寫法是這樣的:
如果對象為 null
就在觸發(fā)的話,就會必然引發(fā) NullReferenceException
異常。要知道,任何時候聲明的字段在初始情況都是 null
null
。正是因?yàn)槿绱宋覀儾判枰z測它是不是 null
。
6-2 底層的委托字段僅建議使用 protected
和 private
修飾
委托字段僅用來觸發(fā)事件,而增加和刪除回調(diào)函數(shù)的過程已經(jīng)在封裝的事件成員里完成了,因此它只有觸發(fā)行為。但是,按照一般的執(zhí)行過程,正常的事件觸發(fā)要求是這個類型內(nèi)部觸發(fā)。顯然,你從一個完全跟這個類型無關(guān)的外部觸發(fā)內(nèi)部的事件,就顯得相當(dāng)奇怪。不僅奇怪,而且也破壞了封裝。因?yàn)榧热荒愣寄軓耐獠坑|發(fā),就一定要讓委托字段公開化。哪怕是 internal
,外部也可以訪問了。既然字段都能直接訪問了,那么何必還封裝一個事件調(diào)用呢?事件的作用只有增刪回調(diào)函數(shù),這點(diǎn)操作委托字段就能做了,對吧。
所以,這樣是相當(dāng)奇怪的。所以相當(dāng)不建議暴露底層的委托字段。和普通的字段還不一樣,普通的字段你有可能需要使用 internal
修飾,但觸發(fā)事件這樣敏感的行為,還是不要從外部訪問,哪怕 internal
都不行。因此,唯二建議的訪問修飾符是 private
或者 protected
。protected internal
也都不建議。
6-3 一般不使用靜態(tài)事件
沒想到吧。C# 的靈活程度竟然出乎意料。事件也是可以使用靜態(tài)的,即靜態(tài)事件。既然普通的事件都對應(yīng)了一個實(shí)例的委托類型字段,那么靜態(tài)事件自然就對應(yīng)了靜態(tài)的字段,比如
那么,靜態(tài)事件用在哪里呢?回憶一下 Console
這個類型。Console
類型里的所有方法都是跟著類名,然后小數(shù)點(diǎn)后才是方法名稱。那么顯然它們都是一些靜態(tài)方法。這也就意味著這個 Console
類型代表的并不是一個單一的實(shí)例,而是對于整個系統(tǒng)里的命令提示符這樣的東西而通用的一種表現(xiàn)機(jī)制。對于這樣的類型,使用靜態(tài)事件就比較合適。比如說我清空了命令符的內(nèi)容,由于這個類型存儲的都是一些靜態(tài)的處理過程,所以它壓根沒有綁定一個單一的實(shí)例對象,因此使用普通的事件是無法完成工作的,畢竟普通的事件必須綁定一個實(shí)例才能得以執(zhí)行。那么,要想奏效的話,為了傳遞一些處理事件的數(shù)據(jù)信息,我們只能使用靜態(tài)事件來處理它們。僅此一種情況,才會用到靜態(tài)事件。在一般情況下,我們都是用不到的,所以一般不會建議你使用靜態(tài)事件。
另外,靜態(tài)事件有一個相當(dāng)麻煩的隱蔽 bug,就是內(nèi)存泄漏的問題。如果一個對象已經(jīng)被銷毀(內(nèi)存被消除了),但靜態(tài)事件綁定的是靜態(tài)的字段,由于靜態(tài)數(shù)據(jù)存儲的機(jī)制關(guān)系,它們不會消失。此時就可以導(dǎo)致內(nèi)存泄漏的問題。
這一點(diǎn)我們現(xiàn)在還解決不了。這一點(diǎn)需要在后面介紹了
IDisposable
接口的使用和手動進(jìn)行內(nèi)存釋放里才能講到。
6-4 建議為事件封裝起來的委托字段名添加 EventHandler
后綴
為了區(qū)分字段和別的字段用途不一樣,委托類型的字段請使用“EventHandler”作為字段名的后綴。比如說 _testEventHandler
之類的。
順帶一提,因?yàn)樽侄斡蒙狭恕癊ventHandler”后綴了,那么原始的委托類型本身名字也加上“EventHandler”吧,這樣是為了和別的固有的委托類型作區(qū)分,表示這個委托類型是和事件綁定使用的。
6-5 為事件的觸發(fā)的回調(diào)函數(shù)的參數(shù)追加事件處理信息數(shù)據(jù)
當(dāng)數(shù)據(jù)在交互的時候,我們肯定期望事件觸發(fā)的同時帶點(diǎn)什么數(shù)據(jù)參與執(zhí)行,這樣我們就可以獲取此時的信息,也便于我們調(diào)試代碼知道里面的東西到底都是什么。我還是使用之前講解的增加元素就觸發(fā)事件的那個數(shù)據(jù)類型來給大家說明這一點(diǎn)。
我們暫時刪去一些對本節(jié)內(nèi)容講解沒有意義的代碼。
首先我們可以看到,此時 Checker
這個委托類型是無參無返回值的數(shù)據(jù)類型。沒有參數(shù)也沒有返回值的數(shù)據(jù)類型對于我們幫助并不大,它能觸發(fā)事件是真的,但是我們在調(diào)試的時候,假設(shè)出 bug 了,我們也不知道此時的數(shù)據(jù)信息,怎么辦呢?這個時候我們會考慮添加量參數(shù),一個參數(shù)表示觸發(fā)事件的對象到底是誰,另外一個參數(shù)則表示觸發(fā)事件的時候,一些基本的數(shù)據(jù)的信息都是什么值。
顯然,Checker
這個委托類型的觸發(fā)是和 _count
字段(或者 Count
屬性)綁定起來的。那么,我們有必要改良一下 Checker
委托類型,并為其增加兩個參數(shù):
sender
和 e
。其中的 sender
表示是誰(是什么實(shí)例)觸發(fā)了此事件;另外一個參數(shù) e
則是一個存儲基本數(shù)據(jù)信息的事件數(shù)據(jù)類型的對象。此時我們還不知道 CheckingEventArgs
是的,其實(shí)也不復(fù)雜,短短 10 行代碼,其中還有三個空行,外帶一個 using
指令和一對大括號占的兩行。真正有用的只有四行代碼,對吧。
這個 CheckingEventArgs
類型從 EventArgs
類型派生。這里的 EventArgs
是存儲在 System
命名空間里的一個類,它是一個可實(shí)例化的非抽象類類型。另外,里面只包含了一個 int
類型的所謂的 _currentCountValue
字段。這個字段其實(shí)就對應(yīng)了我們剛才那個特別長的 List
類型里的 _count
的數(shù)值。
因?yàn)槲蓄愋臀覀兏牧藢懛ǎ哉{(diào)用觸發(fā)事件的地方也得更改調(diào)用了,也就是 AddElement
方法:
this
。這個用法其實(shí)很奇妙,它不算奇技淫巧,經(jīng)常使用到,但我們?nèi)腴T的時候,確實(shí)很少接觸到。傳入 this
可以看到,前面的 Equals
是實(shí)例方法,后面的 Equals
則是靜態(tài)方法。要比較兩個對象內(nèi)部的數(shù)值是不是一樣的話,我們肯定會優(yōu)先思考到一點(diǎn):既然有靜態(tài)的方法,那么實(shí)例版本的方法干脆直接調(diào)用這個靜態(tài)方法就好了啊,為什么又要單獨(dú)寫一遍實(shí)現(xiàn)呢?
但是問題來了,實(shí)現(xiàn)不好寫,對吧。因?yàn)楝F(xiàn)在是直接從實(shí)例轉(zhuǎn)靜態(tài)了,發(fā)現(xiàn)語法好像不夠用。實(shí)際上,這里的 this
是可以當(dāng)實(shí)例直接用的,因此 this
可以堂而皇之傳入方法里當(dāng)參數(shù)。這就是 this
直接用的其中一種場合。
回到剛才的內(nèi)容。第一個參數(shù)表示的是我什么對象觸發(fā)事件,那么自然是實(shí)例成員,觸發(fā)事件也就是實(shí)例它自己觸發(fā)的,所以傳入 this
就是最合適不過的參數(shù);而第二個參數(shù)則是傳入一個 CheckingEventArgs
的對象。顯然我們只傳入了 _count
就可以,因?yàn)槲覀円仓恍枰@一個信息。
那么,實(shí)例化這個類型后追加 Checking
事件成員的回調(diào)函數(shù),怎么寫呢?
大概是這么一種感覺。稍微注意一下這里的 !(sender is List)
的取反 is
運(yùn)算。這個語法稍微有點(diǎn)別扭,因?yàn)橹皼]用過,但是這么做是合適的。因?yàn)檫@個方法是和事件的回調(diào)函數(shù),那么就不應(yīng)該拿來給別的地方用。正是因?yàn)槿绱耍晕覀儽仨氁?yàn)證這里的 sender
參數(shù)是否正常。如果它自己都不是 List
類型了,那么這調(diào)用起來不是很滑稽么?所以,我們一定要注意這一點(diǎn)。這里的 sender
說實(shí)話也不怎么常用,要么調(diào)試 bug 期間很常用,要么判別數(shù)據(jù)類型很常用。很少直接提取里面的數(shù)據(jù)的。因?yàn)樗锩娴臄?shù)據(jù)其實(shí)很多時候都從外界訪問不了(字段都是 private
修飾過了)。而真要看里面的數(shù)據(jù),我們?yōu)槭裁床粡倪@里所謂的 e
參數(shù)里傳進(jìn)去呢?
順帶一提,參數(shù)名
sender
和e
也是一種書寫習(xí)慣。一般事件綁定的回調(diào)函數(shù)都是帶有這樣類型的兩個參數(shù)。要表示觸發(fā)事件的對象,我們都一般取名叫sender
而不是別的;而觸發(fā)事件的事件數(shù)據(jù)信息,我們都用的是e
。至于為什么是e
,因?yàn)?EventArgs
的首字母是e
。這是一種取名習(xí)慣。你可以不遵守,但還是建議你這么使用,因?yàn)檫@是一種規(guī)范。
Part 7 方法
7-1 不要給處理機(jī)制過于復(fù)雜的操作用屬性代替方法
如果一個方法的代碼比較多的時候,即使它確實(shí)可以看成一個類型的屬性,也不建議寫成屬性。因?yàn)樗膱?zhí)行會消耗一定時間,但屬性的執(zhí)行一般不會很復(fù)雜,都是即存即取的過程。比如一個二叉樹的所有子節(jié)點(diǎn),用一個集合返回出來。假設(shè)它寫成方法,或者寫成屬性有如下兩種模式:
寫成前者的話,你可能會在屬性里寫上很復(fù)雜的迭代代碼,比如遞歸之類的東西。但是這樣復(fù)雜的執(zhí)行會影響屬性取值的性能,這樣是比較棘手的(不論是調(diào)試代碼還是執(zhí)行代碼),數(shù)據(jù)一直出不來,就會卡在屬性執(zhí)行計(jì)算的過程上,這是非常難受的事情。因此,規(guī)范下屬性是一般不執(zhí)行笨重的操作的。
7-2 ToString
方法一般情況下一定要重寫
ToString
方法是一個特別特殊的方法。它是 object
這個最終基類型里就自帶的方法。但是,如果你不重寫的話,這個方法最終會輸出的字符串其實(shí)是這個數(shù)據(jù)類型的字符串寫法。比如說 object
類型是來自 System
命名空間下的 Object
數(shù)據(jù)類型,因此最終這個類型會顯示出 System.Object
這樣的東西。因此,假設(shè)你的寫法是這樣的:
"System.Object"
ToString
給重寫了。
比如這樣。string.Join
方法將一個集合類型(這里是數(shù)組)直接當(dāng)成參數(shù)傳入進(jìn)去,它表達(dá)的是每一個元素全部輸出,并且分隔符用的是第一個參數(shù)給的這個字符串。比如 _elements
里是 1、2、3 的話,那么 string.Join(", ", _elements)
的結(jié)果就是 "1, 2, 3, 0, 0, 0, 0, 0, 0, 0"
,因?yàn)楹竺鏀?shù)據(jù)沒用上,初始化的時候數(shù)組所有元素被初始化為 0,所以剩下的數(shù)據(jù)都是 0,而前面三個數(shù)據(jù)是改動過的,所以是 1、2、3 分別輸出。
顯然,ToString
7-3 ToString
方法重載的一些小細(xì)節(jié)
重寫 ToString
也是有講究的。
首先,ToString
輸出字符串的執(zhí)行操作代碼不宜過長。太長了就和屬性太笨重是一樣的效果。ToString
方法會起到呈現(xiàn)數(shù)據(jù)的效果,所以如果代碼過長會導(dǎo)致輸出顯示效率特別低。
其次是不要亂返回一些東西。重寫 ToString
的目的就是改掉原本的邏輯。要是你重寫了之后結(jié)果還是用的 base.ToString()
或者是 return null
這樣的東西的話,顯然是任何幫助都沒有。
接著,不要在 ToString
的里面引發(fā)任何異常。因?yàn)?ToString
是用來顯示字符串信息的,這也觸發(fā)異常會導(dǎo)致一系列的副作用,比如代碼調(diào)試無法繼續(xù)進(jìn)行等等。
然后,也不要讓 ToString
里的代碼更改和修改對象本身的字段存儲的信息。因?yàn)樽址@示和呈現(xiàn)功能只是取值和顯示的操作,從 ToString
里更改對象的數(shù)據(jù)成員的話,就可能使得每次字符串顯示的結(jié)果都不一樣,導(dǎo)致副作用的出現(xiàn)。
最后,ToString
重寫的時候,一定要記住,每一個實(shí)例都或多或少有不同之處。為了調(diào)試代碼方便,我們強(qiáng)烈建議盡量盡可能在最短的代碼里呈現(xiàn)和顯示出每一個實(shí)例的這些不同的信息。比如說一個集合,要是長度 100 的話,沒有必要整個 100 個元素都顯示出來,因?yàn)樘L了;但是要體現(xiàn)出對象的區(qū)別的話,我們可以嘗試著輸出這個集合類型的 Count
屬性(或者底層的 _count
字段)的數(shù)值。比如說
比如這樣。
string.Format
方法用來排版字符串,起到和Console.WriteLine
方法相似的效果。只是Console.WriteLine
是顯示字符串結(jié)果,但string.Format
是為了排版字符串,把后面的參數(shù)信息挨個替換到占位符上,最終將字符串結(jié)果返回出來,而不是直接呈現(xiàn)到控制臺。
所以,ToString
方法重寫也不是隨便搞一下就完事的。
7-4 建議重寫 GetHashCode
方法
實(shí)際上,GetHashCode
方法也是建議重寫掉的方法,因?yàn)檫@個方法的底層有些時候會導(dǎo)致性能損失。比如說值類型,值類型的 GetHashCode
的原理是,通過高階的代碼處理機(jī)制獲取到整個類型里的所有數(shù)據(jù)成員,然后挨個去取里面的數(shù)據(jù)的哈希碼,然后異或起來。但是問題就在于,“通過代碼來獲取代碼的信息”,就這一點(diǎn)來說就不容易;其次是獲取的每個成員也不一定自帶 GetHashCode
方法的重載。比如說數(shù)組。數(shù)組是包含方形數(shù)組和鋸齒數(shù)組兩種的,雖然都從 Array
類型派生,但是因?yàn)闄C(jī)制不同,所以 GetHashCode
處理肯定也沒辦法統(tǒng)一化。因此,有些數(shù)據(jù)類型是不包含重寫過的 GetHashCode
方法的。因此,最終計(jì)算出來的哈希碼壓根就不能當(dāng)成判別數(shù)據(jù)相等性的工具。
因此,我們強(qiáng)烈建議自己手寫處理機(jī)制,然后覆蓋掉基類型的 GetHashCode
方法。
再次強(qiáng)調(diào)說明一下哈希碼的具體效果和作用。哈希碼(Hash Code)也稱散列碼,指的是一個
int
類型的整數(shù),用來區(qū)別區(qū)分同類型的對象是不是包含相同的數(shù)據(jù)。由于哈希碼本身只能是int
的整數(shù),所以包含的可能情況是有限的。但是對象的靈活程度很高,這就使得很多時候哈希碼并不能完整映射到每一處不同的地方。但是哈希碼為了計(jì)算快捷和方便,很少出現(xiàn)哈希碼完全相同但對象包含不同數(shù)據(jù)的情況(因?yàn)檫@一點(diǎn)是通過復(fù)雜的公式計(jì)算到的,越復(fù)雜越好),所以這樣的行為特別少見。這樣的行為稱為哈希碼碰撞(Hash Code Collision)。哈希碼碰撞是暫時無法解決的問題,你也只能通過別的公式來避免這一點(diǎn)。哈希碼有時候比Equals
方法要好,是因?yàn)樗墓綇?fù)雜程度有些時候比Equals
的代碼執(zhí)行起來要簡單一些,比如比較集合相等性。集合的元素如果要判斷是不是完全一樣的話,GetHashCode
里可能就是循環(huán)起來,挨個元素異或;但是Equals
可能會對兩個集合對位進(jìn)行比較。如果集合的元素發(fā)生變動,可能位置不一定能對應(yīng)上的話,可能還要考慮兩次循環(huán),因此復(fù)雜度比GetHashCode
就要高一些。所以,哈希碼有些時候是比Equals
更優(yōu)越的判斷數(shù)據(jù)的模式。
7-5 建議 GetHashCode
代碼的公式越簡單越好
為了計(jì)算快速和性能損失最小化,我們需要保證公式計(jì)算可以盡量保證數(shù)據(jù)唯一性的基礎(chǔ)上,代碼也要最簡單才行。比如計(jì)算一個數(shù)組的哈希碼,那么我們可能會這么寫代碼:
我們故意取了一個復(fù)雜的數(shù)值 271828183 作為初始情況。因?yàn)楹竺娲鎯Φ臄?shù)據(jù)可能會很復(fù)雜也可能很簡單。所以處理最開始要保證數(shù)據(jù)防止對方去猜公式來反推數(shù)據(jù)。接著,公式直接通過循環(huán)遍歷整個數(shù)組序列,然后去挨個得到結(jié)果,并使用 ^=
運(yùn)算符把數(shù)據(jù)記錄到 result
里。之所以使用 ^=
是因?yàn)檫@個運(yùn)算符的處理不容易看懂,但電腦操作很快。你使用 +
、-
這樣的運(yùn)算符的話,對象唯一性的辨識度很低(很容易出現(xiàn)哈希碼碰撞)。因此使用 ^
是最好的選擇。
順帶一說,和 ToString
一樣,它也是特別的方法,因此不要在里面拋異常。
7-6 要在重寫了 GetHashCode
方法后重寫 Equals
方法,反之亦然
這一點(diǎn)其實(shí)不必多說。因?yàn)橐袆e對象相等性的話,兩種手段其實(shí)缺一不可。少一種看起來好像沒有啥大問題,但代碼執(zhí)行起來一來是性能損失嚴(yán)重,二來是有些機(jī)制可能就無法使用了。在以后我們可能會接觸到泛型,泛型集合里有一個叫做字典的類型,它需要大量使用對象的哈希碼計(jì)算結(jié)果和對象判別相等性的地方。一旦數(shù)據(jù)少一個實(shí)現(xiàn),就會啟用基類型(比如 object
這些類型)里的 GetHashCode
方法。這些方法的默認(rèn)執(zhí)行實(shí)際上是沒啥特別大用處的結(jié)果,所以一定不要這樣。
順帶一提。C# 里的 object
類里有一個 Equals
實(shí)例方法,它的長相大概是這樣的:
ReferenceEquals
引用判斷方法,所以一定要在需要對象判斷相等性的時候重寫掉 Equals
方法。
7-7 不要讓值類型對象調(diào)用 ReferenceEquals
方法
我們之前說過,ReferenceEquals
方法是判斷引用是否一致的。如果是值類型的話,因?yàn)橹殿愋捅旧韨魅脒M(jìn)去會因?yàn)閰?shù)是 object
類型而裝箱,因此引用會發(fā)生變動。就算兩個完全一樣的對象,裝箱后的地址也不同,所以值類型調(diào)用 ReferenceEquals
方法的返回值總是 false
。一定不要讓值類型去碰這個方法。
7-8 在比較相等性之前,先判斷對象是否為 null
(如果是引用類型的話)
別忘了這一點(diǎn)。如果沒有判斷 null
,可能會導(dǎo)致程序出現(xiàn)崩潰,產(chǎn)生 NullReferenceException
異常。當(dāng)然了,值類型又不存在 null
一說,這個另說;但是一定要注意的是默認(rèn)的 Equals
方法的參數(shù)是 object
,值類型需要拆箱不說,而且 object
是引用類型,所以可能傳入一個 null
參與比較,因此這個參數(shù)仍需要判斷是不是 null
。
7-9 請?jiān)谛枰獙ο笈袛嘞嗟刃缘臅r候,順帶實(shí)現(xiàn) IEquatable
接口
IEquatable
接口可能之前沒說過。這個接口是約束對象是否相等的。如果沒有這個顯式在繼承列表里寫出這個接口其實(shí)問題不大,但是有些刁鉆的時候它沒有就會導(dǎo)致無法繼續(xù)寫代碼,以至于更改數(shù)據(jù)結(jié)構(gòu)本身。
IEquatable
接口里只有一個 Equals
方法需要實(shí)現(xiàn),而且是跟 object.Equals
這個實(shí)例虛方法的簽名一模一樣。一旦你重寫了之后,這個接口請一定要追加到繼承列表里,表示這個接口是順帶也實(shí)現(xiàn)了。
7-10 除了實(shí)現(xiàn) IEquatable
接口外,還建議提供一個具體類型的 Equals
類型比較方法
這句話什么意思呢?就是假設(shè)我實(shí)現(xiàn)了 List
類型的、object
自帶的 Equals
方法,那么我們順帶還要實(shí)現(xiàn)一個重載方法,長這樣:
是的,參數(shù)改一下就行(記得還要去掉 override
關(guān)鍵字,因?yàn)榉椒ú皇侵剌d出來的)。這個方法的好處是,為了避免潛在的裝箱拆箱操作。如果有值類型的話,重寫的 Equals
方法的參數(shù)是 object
類型,會導(dǎo)致裝箱。
Part 8 運(yùn)算符重載
8-1 非比較運(yùn)算符重載的時候,應(yīng)總是返回新的對象
這是什么意思呢?假設(shè)你實(shí)現(xiàn)了一個 Int128
的數(shù)據(jù)類型,這個數(shù)據(jù)類型可以表示 128 個比特的超大整數(shù)類型(比 64 比特的 long
類型表示的數(shù)據(jù)范圍還要多),但問題在于這個類型要實(shí)現(xiàn)很多的運(yùn)算符重載。
假設(shè)我要對這個類型的對象進(jìn)行實(shí)現(xiàn)單目運(yùn)算符的 ~
(位取反運(yùn)算),我們的實(shí)現(xiàn)代碼建議是這樣的:
而不是
這是因?yàn)?,如?Int128
這個類型是個引用類型的話,就很有可能因?yàn)檫@個執(zhí)行行為而更改掉 current
~
運(yùn)算符操作一番還返回它自己又有什么用呢?這不就是算起了副作用了嘛。所以,我們不建議在重載運(yùn)算符期間更改參數(shù)本身的數(shù)據(jù)內(nèi)容,而總是返回新的對象。
8-2 盡量用 ReferenceEquals
方法代替調(diào)用 object
自帶的 ==
和 !=
運(yùn)算符
因?yàn)樽约褐剌d的運(yùn)算符可能不會考慮到 null
的處理導(dǎo)致異常拋出,所以建議使用 ReferenceEquals
方法來規(guī)避 NullReferenceEquals
異常。
8-3 在重載了 Equals
方法后,順帶重載 ==
和 !=
運(yùn)算符
這個就不多說了。顯然 ==
和 !=
寫起來就是比 Equals
要方便還要好看一點(diǎn)點(diǎn)。重載的運(yùn)算符的代碼,這樣就可以了:
第一個 ==
運(yùn)算符返回 left.Equals(right)
結(jié)果。而 !=
的話,直接調(diào)用 ==
運(yùn)算符,然后取反就可以了。
8-4 一般不重載 true
和 false
運(yùn)算符,除非這個對象類型本身就是一種邏輯類型
true
和 false
運(yùn)算符在之前介紹過,它需要依賴 true
、false
、&
和 |
四個運(yùn)算符,才能表達(dá)出 &&
和 ||
的重載效果。換言之,因?yàn)?&&
和 ||
不可直接重載的關(guān)系,所以我們只能轉(zhuǎn)去重載 true
和 false
運(yùn)算符來達(dá)到 &&
和 ||
重載掉后的效果。但這個運(yùn)算符從理解上就不容易,所以一般不是邏輯類型的數(shù)據(jù)非常不建議去重載。
8-5 盡量不要在運(yùn)算符重載期間拋異常
這個和屬性的機(jī)制差不多。運(yùn)算符是一種快捷書寫的方式。如果代碼過長,執(zhí)行起來慢就不多說了;而且拋異常在運(yùn)算符里會有很奇怪的副作用,因此完全不建議這樣做。
Part 9 類型轉(zhuǎn)換
9-1 將模糊類型轉(zhuǎn)具體類型的時候應(yīng)使用顯式轉(zhuǎn)換關(guān)鍵字 explicit
假設(shè)我們實(shí)現(xiàn)了自定義的 Int128
類型,顯然我們可以期望它可以和 long
這樣的數(shù)據(jù)類型進(jìn)行轉(zhuǎn)換。問題在于,Int128
類型從邏輯上表達(dá)的數(shù)據(jù)更多,往 long
上面轉(zhuǎn)的話,數(shù)據(jù)可能會因?yàn)槌?long
的范圍而產(chǎn)生異常。因此,我們建議對這樣的類型轉(zhuǎn)換使用 explicit
而不是 implicit
關(guān)鍵字:
9-2 將具體類型轉(zhuǎn)模糊類型的時候應(yīng)使用隱式轉(zhuǎn)換關(guān)鍵字 implicit
和 9-1 內(nèi)容同理。如果反過來的話,顯然任何時候轉(zhuǎn)換都是成功的,所以這么轉(zhuǎn)換的時候沒有必要隨時隨地都加上強(qiáng)制轉(zhuǎn)換,沒有必要。所以這樣的時候使用 implicit
就比較合適。
Part 10 總結(jié)
本節(jié)將前文建議和不建議的內(nèi)容作出一個總結(jié)。
命名規(guī)范
下面用一個表格列舉這些信息。
類型名:
和事件綁定使用的委托類型:帕斯卡+EventHandler 后綴(
PascalCaseEventHandler
);接口類型:
I
字母+帕斯卡(IPascalCase
其它:帕斯卡(
PascalCase
);字段:
static readonly
修飾:帕斯卡(PascalCase
);public
修飾:帕斯卡(PascalCase
);委托字段:下劃線+駝峰+EventHandler 后綴(
_camelCaseEventHandler
);其它:下劃線+駝峰(
_camelCase
);屬性:帕斯卡(
PascalCase
),一般和配套字段同名;事件:帕斯卡(
PascalCase
),一般使用動詞或動詞詞組的過去分詞和現(xiàn)在分詞形式;方法:帕斯卡(
PascalCase
);嵌套類型名:帕斯卡(
PascalCase
)。
使用規(guī)范
建議在類的聲明里添加
sealed
關(guān)鍵字密封這個類以防止別的程序員亂用,除非這個類要往下派生別的類型;不建議自定義類型的名字和庫提供的某個(某些)類型重名;
建議使用不帶命名空間前綴的類型名稱;
不建議使用嵌套類型;
不建議嵌套命名空間;
不建議使用
System
命名空間作為你自己程序的命名空間使用;建議給類型和內(nèi)部的成員都寫上訪問修飾符,即使級別是默認(rèn)的級別;
建議抽象類里提供給子類型專門實(shí)例化用的構(gòu)造器;
建議給用于子類型調(diào)用的基類型構(gòu)造器使用
protected
訪問修飾符;建議給字段標(biāo)記
readonly
修飾符,除非字段的值可變;建議所有的自定義結(jié)構(gòu)不可變;
建議只讀量一定用
const
和static readonly
修飾(且如果能用const
就用const
);不建議使用
public
修飾靜態(tài)非只讀字段;不建議給結(jié)構(gòu)的屬性成員加上
set
方法(賦值器);建議使用屬性表達(dá)一些無法通過字段表達(dá)的、需要公式計(jì)算的高階數(shù)據(jù)信息;
不建議使用只寫屬性和只寫索引器;
不建議通過屬性或索引器暴露出底層集合類型的字段;
建議使用屬性封裝字段的方式暴露字段,而不是直接使用
public
類型的字段;不建議使用靜態(tài)屬性,除非有特殊情況;
不建議對索引器參數(shù)使用復(fù)雜的數(shù)據(jù)類型;
建議事件觸發(fā)的時候,優(yōu)先驗(yàn)證底層的委托字段是不是
null
;建議事件底層的委托字段的訪問修飾符只能是
private
或protected
;不建議使用靜態(tài)事件,除非事件觸發(fā)起來跟任何實(shí)例都沒有關(guān)系;
不建議給屬性使用復(fù)雜的操作和執(zhí)行行為,請改成方法執(zhí)行;
建議重寫
ToString
和GetHashCode
方法;建議重寫的
ToString
方法的邏輯代碼不要太長;建議
ToString
返回有意義的數(shù)據(jù),而不是null
等無意義的東西;不建議在
ToString
里引發(fā)異常;不建議在
ToString
里更改變動對象的數(shù)據(jù)信息;建議對
ToString
輸出顯示能體現(xiàn)對象獨(dú)一無二特性的信息;建議對
GetHashCode
方法重寫體現(xiàn)出對象的唯一性;建議對
GetHashCode
的公式實(shí)現(xiàn)盡量簡單;建議對
GetHashCode
的公式實(shí)現(xiàn)稍微“坑”一點(diǎn),以便讓對方無法反推猜到原始數(shù)據(jù);不建議在
GetHashCode
里引發(fā)異常;建議在重寫了
GetHashCode
方法后也重寫掉Equals
方法;建議在重寫了
Equals
方法后也重寫掉GetHashCode
方法;不建議對值類型調(diào)用
ReferenceEquals
靜態(tài)方法;強(qiáng)烈建議在
Equals
重寫期間判斷對象是不是為null
(如果對象是引用類型的話);建議重寫了
Equals
方法后,順帶在類型聲明后的繼承列表里追加IEquatable
接口;建議重寫了
Equals
方法后還要添加一個具體類型作為參數(shù)的Equals
方法的重載版本;建議重載運(yùn)算符的代碼不要修改變動傳入的參數(shù)里的數(shù)據(jù)信息,并總是返回新的對象;
建議用
ReferenceEquals
?方法代替==
和!=
的運(yùn)算符使用;建議重寫了
GetHashCode
和Equals
方法后,順帶也重載掉==
和!=
運(yùn)算符;不建議對任何非真假邏輯類型重載
true
和false
運(yùn)算符;不建議在運(yùn)算符重載的代碼里拋異常;
不建議運(yùn)算符重載的代碼過多;
建議對表示數(shù)據(jù)更多的數(shù)據(jù)類型往表示數(shù)據(jù)范圍更少的數(shù)據(jù)類型方向轉(zhuǎn)換的轉(zhuǎn)換運(yùn)算符用
explicit
關(guān)鍵字;建議對表示數(shù)據(jù)更少的數(shù)據(jù)類型往表示數(shù)據(jù)范圍更多的數(shù)據(jù)類型方向轉(zhuǎn)換的轉(zhuǎn)換運(yùn)算符用
implicit
關(guān)鍵字。