最美情侣中文字幕电影,在线麻豆精品传媒,在线网站高清黄,久久黄色视频

歡迎光臨散文網(wǎng) 會員登陸 & 注冊

第 51 講:類型良構(gòu)規(guī)范(一):類型和成員的實(shí)現(xiàn)規(guī)范

2021-07-27 07:21 作者:SunnieShine  | 我要投稿

之前我們講完了基本的數(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; 這么一句話。C# 允許我們這么寫:

這樣我們就不用寫 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、GetHashCodeToString 三個可重寫的 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 的無參構(gòu)造器香?這個取決于你自己的書寫代碼的風(fēng)格,其實(shí)用抽象類型也可以的,不過上面這樣的實(shí)現(xiàn)機(jī)制是一種固定的實(shí)現(xiàn)模式,所以你可以這么參考著用,但沒有說你必須要這么用。畢竟,抽象類確實(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(...) 的方式調(diào)用到它。那么,構(gòu)造器都無法調(diào)用,為何又要寫上這個構(gòu)造器呢?

這是因?yàn)闃?gòu)造器是給子類派生服務(wù)的。構(gòu)造器有兩大作用:

  1. 提供實(shí)例化語法(非 abstract 修飾的類,以及非接口的其它數(shù)據(jù)類型類別);

  2. 提供給子類的 : 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è)置了 publicinternal,對于別的類型,這個構(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 structs are evil”。之所以放在這里,是因?yàn)榻Y(jié)構(gòu)不可變的一個必要條件是字段(數(shù)據(jù)成員)全都是 readonly 修飾過的。

3-3 建議只讀量使用 conststatic readonly 修飾

和 3-1 提及的效果類似。如果整個數(shù)據(jù)類型里需要用到一些數(shù)據(jù)完全相同的、以后不會發(fā)生變動的數(shù)據(jù)的話,就盡量用 static readonlyconst 修飾。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 修飾。conststatic readonly 的區(qū)別是,const 數(shù)據(jù)編譯器期間就可以解析和分析出結(jié)果,但 static readonly 因?yàn)閺?fù)雜程度的關(guān)系,編譯期間是無法或者說不一定能確定數(shù)據(jù)的確切數(shù)值的。正是因?yàn)檫@個特性的關(guān)系,const 有一個特別棒的效果,就是編譯期替換。如果你用到了非常常用的、但數(shù)據(jù)可通用的地方的話,你肯定會優(yōu)先考慮把這個數(shù)據(jù)提到字段級別存儲。如果要想把數(shù)據(jù)類型推廣,只需要變動這個字段的數(shù)值,所有引用了這個字段的地方都可以發(fā)生自動的變動,這不比你手寫字面量要香?所以,conststatic 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)常使用屬性,為其添加 getset 方法(取值器和賦值器)來為底層字段賦值。殊不知實(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ù)組這樣的集合成員直接通過賦值器返回的原因。

當(dāng)然了,如果你這個返回的類型是通過計(jì)算得到的集合的話,那么返回它就沒有關(guān)系了。比如我有語數(shù)外三科成績的字段,但分別是分開存儲的,那么我通過數(shù)組來整合和返回它們就沒有關(guān)系了,因?yàn)榫退隳阍谥蟾牧怂鞘且驗(yàn)榻M合的關(guān)系,底層的三個數(shù)值又都是值類型的關(guān)系(復(fù)制副本),就完全不會產(chǎn)生任何問題。

比如這樣。

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(除非有后面直接賦值)。對于委托字段來說,我們目前也沒有辦法讓它在實(shí)例化的時候就不可能為 null。正是因?yàn)槿绱宋覀儾判枰z測它是不是 null。

6-2 底層的委托字段僅建議使用 protectedprivate 修飾

委托字段僅用來觸發(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)事件有什么用呢?試想一下靜態(tài)字段的用途。靜態(tài)字段是故意為了避免實(shí)例化才會用的一種數(shù)據(jù)存儲機(jī)制。如果一個對象要想操作,但又不想按實(shí)例化后才能操作的行為來獲取的話,那么我們就會使用靜態(tài)字段。這樣的字段往往存儲了一些“跟某個實(shí)例無關(guān)的數(shù)據(jù)”。

那么,靜態(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ù):

請注意這兩個參數(shù) sendere。其中的 sender 表示是誰(是什么實(shí)例)觸發(fā)了此事件;另外一個參數(shù) e 則是一個存儲基本數(shù)據(jù)信息的事件數(shù)據(jù)類型的對象。此時我們還不知道 CheckingEventArgs 這個類型是怎么實(shí)現(xiàn)的,對吧。那么我們來給大家看一下,這個類型究竟是怎么實(shí)現(xiàn)的。

是的,其實(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 方法:

是的,我們僅更改了第 6 行代碼。因?yàn)檎{(diào)用的委托字段此時發(fā)生了變更,所以參數(shù)也得配套加上。第一個參數(shù)我們傳入的是 this。這個用法其實(shí)很奇妙,它不算奇技淫巧,經(jīng)常使用到,但我們?nèi)腴T的時候,確實(shí)很少接觸到。傳入 this 是一種將這個實(shí)例對象本身當(dāng)參數(shù)傳入到方法里執(zhí)行的過程。有些時候是需要這么做的,比如下面這個時候:

可以看到,前面的 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ù)名 sendere 也是一種書寫習(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" 字符串。顯然,這一點(diǎn)對我們實(shí)際實(shí)現(xiàn)數(shù)據(jù)類型沒有幫助,因此我們總是建議大家在寫數(shù)據(jù)類型的時候順帶把 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 方法是非常有用的東西,它幫助我們計(jì)算和判別內(nèi)容到底是什么,因此強(qiáng)烈建議重寫掉它。

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í)例方法,它的長相大概是這樣的:

換而言之,這個方法本質(zhì)上還是在調(diào)用了 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 對象里面的數(shù)據(jù)信息。如果能改掉自身的數(shù)據(jù),那么我們用 ~ 運(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 一般不重載 truefalse 運(yùn)算符,除非這個對象類型本身就是一種邏輯類型

truefalse 運(yùn)算符在之前介紹過,它需要依賴 truefalse、&| 四個運(yùn)算符,才能表達(dá)出 &&|| 的重載效果。換言之,因?yàn)?&&|| 不可直接重載的關(guān)系,所以我們只能轉(zhuǎn)去重載 truefalse 運(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),一般使用單詞的 -able/-ible 形容詞形式,或者是一個名字、動詞的分詞形式表示出來的名詞;

    • 其它:帕斯卡(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)不可變;

  • 建議只讀量一定用 conststatic 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;

  • 建議事件底層的委托字段的訪問修飾符只能是 privateprotected;

  • 不建議使用靜態(tài)事件,除非事件觸發(fā)起來跟任何實(shí)例都沒有關(guān)系;

  • 不建議給屬性使用復(fù)雜的操作和執(zhí)行行為,請改成方法執(zhí)行;

  • 建議重寫 ToStringGetHashCode 方法;

  • 建議重寫的 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)算符使用;

  • 建議重寫了 GetHashCodeEquals 方法后,順帶也重載掉 ==!= 運(yùn)算符;

  • 不建議對任何非真假邏輯類型重載 truefalse 運(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)鍵字。



第 51 講:類型良構(gòu)規(guī)范(一):類型和成員的實(shí)現(xiàn)規(guī)范的評論 (共 條)

分享到微博請遵守國家法律
新化县| 兴国县| 肥乡县| 光泽县| 增城市| 衡阳县| 铁岭县| 大同市| 柏乡县| 丰顺县| 花莲市| 平江县| 苍溪县| 柳江县| 京山县| 乌拉特前旗| 桐城市| 红桥区| 河北区| 平度市| 鹤峰县| 通化县| 黄冈市| 望城县| 桃源县| 台江县| 县级市| 射洪县| 栖霞市| 酉阳| 九龙县| 炉霍县| 界首市| 斗六市| 时尚| 犍为县| 广水市| 广德县| 正镶白旗| 兴化市| 香港 |