提高 Unity 中 C# 程式碼質量的 21 條準則

騰訊WeTest發表於2017-03-28

本文將《Effective C# Second Edition》一書中適用於 Unity 遊戲引擎裡使用 C# 的經驗之談進行了提煉,總結成為21條(一開始總結的是22條,後來發現第22條也是.NET的特性,Unity版本的mono並沒有實現,所以嚴格意義上來說是21條)準則,供各位快速地掌握這本書的知識梗概,在 Unity 中寫出更高質量的 C# 程式碼。

《Effective C# Second Edition》一書原本有50條原則,但這50條原則是針對C#語言本身以及.NET來寫的,我在閱讀過程中,發現是有些原則並不適用於Unity中mono版本的C#的使用。於是,在進行讀書筆記總結的時候,將不適用的原則略去,同時將適用的原則進行提煉,總結出21條,構成本文的內容。

需要注意,因為是挑出了書中適用的準則,導致準則序號有些跳躍,為了閱讀方便,本文對這些序號進行了重新排列。重排後,標題中與書中序號不一樣的準則,都在該原則總結的末尾註明瞭對應的原書序號。

同樣地,作為總結式文章,每一條的內容都高度概括,也許理解坡度比較陡,若有讀到不太理解的地方,建議大家去閱讀原書,英文版和中文版均可,看看原書中提供的各種程式碼與示例,這樣掌握起來就會事半功倍。

本文內容思維導圖式總結

以下是本文內容,提高Unity中C#程式碼質量的22條準則的總結式思維導圖:

原則1   儘可能地使用屬性而不是可直接訪問的資料成員

       

● 屬性(property)一直是C#語言中比較有特點的存在。屬性允許將資料成員作為共有介面的一部分暴露出去,同時仍舊提供物件導向環境下所需的封裝。屬性這個語言元素可以讓你像訪問資料成員一樣使用,但其底層依舊是使用方法實現的。

● 使用屬性,可以非常輕鬆的在get和set程式碼段中加入檢查機制。

 

需要注意,正因為屬性是用方法實現的,所以它擁有方法所擁有的一切語言特性:

1)屬性增加多執行緒的支援是非常方便的。你可以加強 get 和 set 訪問器(accessors)的實現來提供資料訪問的同步。

2)屬性可以被定義為virtual。

3)可以把屬性擴充套件為abstract。

4)可以使用泛型版本的屬性型別。

5)屬性也可以定義為介面。

6)因為實現實現訪問的方法get與set是獨立的兩個方法,在C# 2.0之後,你可以給它們定義不同的訪問許可權,來更好的控制類成員的可見性。

7)而為了和多維陣列保持一致,我們可以建立多維索引器,在不同的維度上使用相同或不同型別。

無論何時,需要在型別的公有或保護介面中暴露資料,都應該使用屬性。如果可以也應該使用索引器來暴露序列或字典。現在多投入一點時間使用屬性,換來的是今後維護時的更加遊刃有餘。

原則2   偏向於使用執行時常量而不是編譯時常量

       

對於常量,C#裡有兩個不同的版本:執行時常量(readonly)和編譯時常量(const)。

應該儘量使用執行時常量,而不是編譯器常量。雖然編譯器常量略快,但並沒有執行時常量那麼靈活。應僅僅在那些效能異常敏感,且常量的值在各個版本之間絕對不會變化時,再使用編譯時常量。

編譯時常量與執行時常量不同之處表現在於他們的訪問方式不同,因為Readonly值是執行時解析的:

● 編譯時常量(const)的值會被目的碼中的值直接取代。

● 執行時常量(readonly)的值是在執行時進行求值。● 引用執行時生成的IL將引用到readonly變數,而不是變數的值。

這個差別就帶來了如下規則:

● 編譯時常量(const)僅能用於數值和字串。

● 執行時常量(readonly)可以為任意型別。執行時常量必須在建構函式或初始化器中初始化,因為在建構函式執行後不能再被修改。你可以讓某個readonly值為一個DataTime結構,而不能指定某個const為DataTIme。

● 可以用readonly值儲存例項常量,為類的每個例項存放不同的值。而編譯時常量就是靜態的常量。

● 有時候你需要讓某個值在編譯時才確定,就最好是使用執行時常量(readonly)。

● 標記版本號的值就應該使用執行時常量,因為它的值會隨著每個不同版本的釋出而改變。

● const優於readonly的地方僅僅是效能,使用已知的常量值要比訪問readonly值略高一點,不過這其中的效率提升,可以說是微乎其微的。

綜上,在編譯器必須得到確定數值時,一定要使用const。例如特性(attribute)的引數和列舉的定義,還有那些在各個版本釋出之間不會變化的值。除此之外的所有情況,都應儘量選擇更加靈活的readonly常量。

原則3   推薦使用is 或as操作符而不是強制型別轉換

            

● C#中,is和as操作符的用法概括如下:

is : 檢查一個物件是否相容於其他指定的型別,並返回一個Bool值,永遠不會丟擲異常。

as:作用與強制型別轉換是一樣,但是永遠不會丟擲異常,即如果轉換不成功,會返回null。

● 儘可能的使用as操作符,因為相對於強制型別轉換來說,as更加安全,也更加高效。

● as在轉換失敗時會返回null,在轉換物件是null時也會返回null,所以使用as進行轉換時,只需檢查返回的引用是否為null即可。

● as和is操作符都不會執行任何使用者自定義的轉換,它們僅當執行時型別符合目標型別時才能轉換成功,也不會在轉換時建立新的物件。

● as運算子對值型別是無效,此時可以使用is,配合強制型別轉換進行轉換。

● 僅當不能使用as進行轉換時,才應該使用is操作符。否則is就是多餘的。

原則4   推薦使用條件屬性而不是#if條件編譯

            

● 由於#if/#endif很容易被濫用,使得編寫的程式碼難於理解且更難於除錯。C#為此提供了一條件特性(Conditional attribute)。使用條件特性可以將函式拆分出來,讓其只有在定義了某些環境變數或設定了某個值之後才能編譯併成為類的一部分。Conditional特性最常用的地方就是將一段程式碼變成除錯語句。

● Conditional特性只可應用在整個方法上,另外,任何一個使用Conditional特性的方法都只能返回void型別。不能再方法內的程式碼塊上應用Conditional特性。也不可以在有返回值的方法上應用Conditional特性。但應用了Conditional特性的方法可以接受任意數目的引用型別引數。

● 使用Conditional特性生成的IL要比使用#if/#Eendif時更有效率。同時,將其限制在函式層面上可以更加清晰地將條件性的程式碼分離出來,以便進一步保證程式碼的良好結構。

原則5   理解幾個等同性判斷之間的關係

● C#中可以建立兩種型別:值型別和引用型別。如果兩個引用型別的變數指向的是同一個物件,它們將被認為是“引用相等”。如果兩個值型別的變數型別相同,而且包含同樣的內容,它們被認為是“值相等”。這也是等同性判斷需要如此多方法的原因。

● 當我們建立自己的型別時(無論是類還是struct),應為型別定義“等同性”的含義。C#提供了4種不同的函式來判斷兩個物件是否“相等”。

1)public static bool ReferenceEquals (object left, object right);判斷兩個不同變數的物件標識(object identity)是否相等。無論比較的是引用型別還是值型別,該方法判斷的依據都是物件標識,而不是物件內容。

2)public static bool Equals (object left, object right); 用於判斷兩個變數的執行時型別是否相等。

3)public virtual bool Equals(object right); 用於過載

4)public static bool operator ==(MyClass left, MyClass right); 用於過載

● 不應該覆寫Object.referenceEquals()靜態方法和Object.Equals()靜態方法,因為它們已經完美的完成了所需要完成的工作,提供了正確的判斷,並且該判斷與執行時的具體型別無關。對於值型別,我們應該總是覆寫Object.Equals()例項方法和operatior==( ),以便為其提供效率更高的等同性判斷。對於引用型別,僅當你認為相等的含義並非是物件標識相等時,才需要覆寫Object.Equals( )例項方法。在覆寫Equals( )時也要實現IEquatable。

PS: 此原則對應於《EffectiveC# Second Edition》中原則6。

原則6   瞭解GetHashCode( )的一些坑

● GetHashCode( )方法在使用時會有不少坑,要謹慎使用。GetHashCode()函式僅會在一個地方用到,即為基於雜湊(hash)的集合定義鍵的雜湊值時,此類集合包括HashSet和Dictionary<K,V>容器等。對引用型別來講,索然可以正常工作,但是效率很低。對值型別來講,基類中的實現有時甚至不正確。而且,編寫的自己GetHashCode( )也不可能既有效率又正確。

● 在.NET中,每個物件都有一個雜湊碼,其值由System.Object.GetHashCode()決定。

● 實現自己的GetHashCode( )時,要遵循上述三條原則:

1)如果兩個物件相等(由operation==定義),那麼他們必須生成相同的雜湊碼。否則,這樣的雜湊碼將無法用來查詢容器中的物件。

2)對於任何一個物件A,A.GetHashCode()必須保持不變。

3)對於所有的輸入,雜湊函式應該在所有整數中按隨機分別生成雜湊碼。這樣雜湊容器才能得到足夠的效率提升。

PS: 此原則對應於《EffectiveC# Second Edition》中原則7。

原則7   理解短小方法的優勢

將C#程式碼翻譯成可執行的機器碼需要兩個步驟。

C#編譯器將生成IL,並放在程式集中。隨後,JIT將根據需要逐一為方法(或是一組方法,如果涉及內聯)生成機器碼。短小的方法讓JIT編譯器能夠更好地平攤編譯的代價。短小的方法也更適合內聯。

除了短小之外,簡化控制流程也很重要。控制分支越少,JIT編譯器也會越容易地找到最適合放在暫存器中的變數。

所以,短小方法的優勢,並不僅體現在程式碼的可讀性上,還關係到程式執行時的效率。

PS:此原則對應於《EffectiveC# Second Edition》中原則11。

原則8  選擇變數初始化而不是賦值語句

成員初始化器是保證型別中成員均被初始化的最簡單的方法——無論呼叫的是哪一個建構函式。初始化器將在所有建構函式執行之前執行。使用這種語法也就保證了你不會再新增的新的建構函式時遺漏掉重要的初始化程式碼。

綜上,若是所有的建構函式都要將某個成員變數初始化成同一個值,那麼應該使用初始化器。

PS: 此原則對應於《Effective C# Second Edition》中原則12。

原則9   正確地初始化靜態成員變數

● C#提供了有靜態初始化器和靜態建構函式來專門用於靜態成員變數的初始化。

● 靜態建構函式是一個特殊的函式,將在其他所有方法執行之前以及變數或屬性被第一次訪問之前執行。可以用這個函式來初始化靜態變數,實現單例模式或執行類可用之前必須進行的任何操作。

● 和例項初始化一樣,也可以使用初始化器語法來替代靜態的建構函式。若只是需要為某個靜態成員分配空間,那麼不妨使用初始化器的語法。而若是要更復雜一些的邏輯來初始化靜態成員變數,那麼可以使用靜態建構函式。

● 使用靜態建構函式而不是靜態初始化器最常見的理由就是處理異常。在使用靜態初始化器時,我們無法自己捕獲異常。而在靜態建構函式中卻可以做到。

PS: 此原則對應於《Effective C# Second Edition》中原則13。

原則10   使用建構函式鏈(減少重複的初始化邏輯)

      

● 編寫建構函式很多時候是個重複性的勞動,如果你發現多個建構函式包含相同的邏輯,可以將這個邏輯提取到一個通用的建構函式中。這樣既可以避免程式碼重複,也可以利用建構函式初始化器來生成更高效的目的碼。

● C#編譯器將把建構函式初始化器看做是一種特殊的語法,並移除掉重複的變數初始化器以及重複的基類建構函式呼叫。這樣使得最終的物件可以執行最少的程式碼來保證初始化的正確性。

● 建構函式初始化器允許一個建構函式去呼叫另一個建構函式。而C# 4.0新增了對預設引數的支援,這個功能也可以用來減少建構函式中的重複程式碼。你可以將某個類的所有建構函式統一成一個,併為所有的可選引數指定預設值。其他的幾個建構函式呼叫某個建構函式,並提供不同的引數即可。

PS: 此原則對應於《EffectiveC# Second Edition》中原則14。

原則11   實現標準的銷燬模式

● GC可以高效地管理應用程式使用的記憶體。不過建立和銷燬堆上的物件仍舊需要時間。若是在某個方法中建立了太多的引用物件,將會對程式的效能產生嚴重的影響。

這裡有一些規則,可以幫你儘量降低GC的工作量:

1)若某個引用型別(值型別無所謂)的區域性變數用於被頻繁呼叫的例程中,那麼應該將其提升為成員變數。

2)為常用的型別例項提供靜態物件。

3)建立不可變型別的最終值。比如string類的+=操作符會建立一個新的字串物件並返回,多次使用會產生大量垃圾,不推薦使用。對於簡單的字串操作,推薦使用string.Format。對於複雜的字串操作,推薦使用StringBuilder類。

PS: 此原則對應於《EffectiveC# Second Edition》中原則16。

原則12  區分值型別和引用型別

● C#中,class對應引用型別,struct對應值型別。

● C#不是C++,不能將所有型別定義成值型別並在需要時對其建立引用。C#也不是Java,不像Java中那樣所有的東西都是引用型別。你必須在建立時就決定型別的表現行為,這相當重要,因為稍後的更改可能帶來很多災難性的問題。

● 值型別無法實現多型,因此其最佳用途就是存放資料。引用型別支援多型,因此用來定義應用程式的行為。

●  一般情況下,我們習慣用class,隨意建立的大都是引用型別,若下面幾點都肯定,那麼應該建立struct值型別:

1)該型別主要職責在於資料儲存嗎?

2)該型別的公有介面都是由訪問其資料成員的屬性定義的嗎?

3)你確定該型別絕不會有派生型別嗎?

4)你確定該型別永遠都不需要多型支援嗎?

● 用值型別表示底層儲存資料的型別,用引用型別來封裝程式的行為。這樣,你可以保證類暴露出的資料能以複製的形式安全提供,也能得到基於棧儲存和使用內聯方式儲存帶來的記憶體效能提升,更可以使用標準的物件導向技術來表達應用程式的邏輯。而倘若你對型別未來的用圖不確定,那麼應該選擇引用型別。

PS: 此原則對應於《Effective C# Second Edition》中原則18。

原則13  保證0為值型別的有效狀態

在建立自定義列舉值時,請確保0是一個有效的選項。若你定義的是標誌(flag),那麼可以將0定義為沒有選中任何狀態的標誌(比如None)。即作為標記使用的列舉值(即新增了Flags特性)應該總是將None設定為0。

PS: 此原則對應於《Effective C# Second Edition》中原則19。

原則14

保證值型別的常量性和原子性

常量性的型別使得我們的程式碼更加易於維護。不要盲目地為型別中的每一個屬性都建立get和set訪問器。對於那些目的是儲存資料的型別,應該儘可能地保證其常量性和原子性。

PS: 此原則對應於《Effective C# Second Edition》中原則20。

原則15   限制型別的可見性

在保證型別可以完成其工作的前提下。你應該儘可能地給型別分配最小的可見性。也就是,僅僅暴露那些需要暴露的。儘量使用較低可見性的類來實現公有介面。可見性越低,能訪問你功能的程式碼越少,以後可能出現的修改也就越少。

PS: 此原則對應於《Effective C# Second Edition》中原則21。

原則16  通過定義並實現介面替代繼承

● 理解抽象基類(abstract class)和介面(interface)的區別:

1)介面是一種契約式的設計方式,一個實現某個介面的型別,必須實現介面中約定的方法。抽象基類則為一組相關的型別提供了一個共同的抽象。也就是說抽象基類描述了物件是什麼,而介面描述了物件將如何表現其行為。

2)介面不能包含實現,也不能包含任何具體的資料成員。而抽象基類可以為派生類提供一些具體的實現。

3)基類描述並實現了一組相關型別間共用的行為。介面則定義了一組具有原子性的功能,供其他不相關的具體型別來實現。

● 理解好兩者之間的差別,我們便可以創造更富表現力、更能應對變化的設計。使用類層次來定義相關的型別。用介面暴露功能,並讓不同的型別實現這些介面。

PS: 此原則對應於《EffectiveC# Second Edition》中原則22。

原則17  理解介面方法和虛方法的區別

第一眼看來,實現介面和覆寫虛方法似乎沒有什麼區別,實際上,實現介面和覆寫虛方法之間的差別很大。

1)介面中宣告的成員方法預設情況下並非虛方法,所以,派生類不能覆寫基類中實現的非虛介面成員。若要覆寫的話,將介面方法宣告為virtual即可。

2)基類可以為介面中的方法提供預設的實現,隨後,派生類也可以宣告其實現了該介面,並從基類中繼承該實現。

3)實現介面擁有的選擇要比建立和覆寫虛方法多。我們可以為類層次建立密封(sealed)的實現,虛實現或者抽象的契約。還可以建立密封的實現,並在實現介面的方法中提供虛方法進行呼叫。

PS: 此原則對應於《EffectiveC# Second Edition》中原則23。

原則18   用委託實現回撥

在C#中,回撥是用委託來實現的,主要要點如下:

1)委託為我們提供了型別安全的回撥定義。雖然大多數常見的委託應用都和事件有關,但這並不是C#委託應用的全部場合。當類之間有通訊的需要,並且我們期望一種比介面所提供的更為鬆散的耦合機制時,委託便是最佳的選擇。

2)委託允許我們在執行時配置目標並通知多個客戶物件。委託物件中包含一個方法的應用,該方法可以是靜態方法,也可以是例項方法。也就是說,使用委託,我們可以和一個或多個在執行時聯絡起來的客戶物件進行通訊。

3)由於回撥和委託在C#中非常常用,以至於C#特地以lambda表示式的形式為其提供了精簡語法。

4)由於一些歷史原因,.NET中的委託都是多播委託(multicast delegate)。多播委託呼叫過程中,每個目標會被依次呼叫。委託物件本身不會捕捉任何異常。因此,任何目標丟擲的異常都會結束委託鏈的呼叫。

PS: 此原則對應於《EffectiveC# Second Edition》中原則24。

原則19  用事件模式實現通知

● 事件提供了一種標準的機制來通知監聽者,而C#中的事件其實就是觀察者模式的一個語法上的快捷實現。

● 事件是一種內建的委託,用來為事件處理函式提供型別安全的方法簽名。任意數量的客戶物件都可以將自己的處理函式註冊到事件上,然後處理這些事件,這些客戶物件無需在編譯器就給出,事件也不必非要有訂閱者才能正常工作。

● 在C#中使用事件可以降低傳送者和可能的通知接受者之間的耦合,傳送者可以完全獨立於接受者進行開發。

PS: 此原則對應於《EffectiveC# Second Edition》中原則25。

原則20   避免返回對內部類物件的引用

● 若將引用型別通過公有介面暴露給外界,那麼物件的使用者即可繞過我們定義的方法和屬性來更改物件的內部結構,這會導致常見的錯誤。

● 共有四種不同的策略可以防止型別內部的資料結構遭到有意或無意的修改:

1)值型別。當客戶程式碼通過屬性來訪問值型別成員時,實際返回的是值型別的物件副本。

2)常量型別。如System.String。

3)定義介面。將客戶對內部資料成員的訪問限制在一部分功能中。

4)包裝器(wrapper)。提供一個包裝器,僅暴露該包裝器,從而限制對其中物件的訪問。

PS: 此原則對應於《Effective C# Second Edition》中原則26。

原則21   僅用new修飾符處理基類更新

● 使用new操作符修飾類成員可以重新定義繼承自基類的非虛成員。

● new修飾符只是用來解決升級基類所造成的基類方法和派生類方法衝突的問題。

● new操作符必須小心使用。若隨心所欲的濫用,會造成物件呼叫方法的二義性。

PS: 此原則對應於《Effective C# Second Edition》中原則33

相關文章