【《Effective C#》提煉總結】提高Unity中C#代碼質量的22條準則


本文由@淺墨_毛星雲 出品,轉載請註明出處。  
文章鏈接: http://blog.csdn.net/poem_qianmo/article/details/53869998
作者:毛星雲(淺墨)    微博:http://weibo.com/u/1723155442



引言


我們知道,在C++領域,作爲進階閱讀材料,必看的書是《Effective C++》。 而《Effective C#》之於C# ,是類似《Effective C++》之於C++一樣的存在。

這篇文章,將《Effective C# Second Edition》一書中適用於Unity遊戲引擎裏使用C#的經驗之談進行了提煉,總結成爲22條準則,供各位快速地掌握這本書的知識梗概,在Unity中寫出更高質量的C#代碼。

 

《Effective C# Second Edition》一書原本有50條原則,但這50條原則是針對C#語言本身以及.Net來寫的,我在閱讀過程中,發現是有些原則並不適用於Unity中mono版本的C#的使用。於是,在進行讀書筆記總結的時候,將不適用的原則略去,同時將適用的原則進行提煉,總結出22(後來發現第22條也是.NET的特性,Unity版本的mono並沒有實現,所以嚴格意義上來說是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種不同的函數來判斷兩個對象是否“相等”。
    • public static bool ReferenceEquals (object left, object right);判斷兩個不同變量的對象標識(object identity)是否相等。無論比較的是引用類型還是值類型,該方法判斷的依據都是對象標識,而不是對象內容。
    • public static bool Equals (object left, object right); 用於判斷兩個變量的運行時類型是否相等。
    • public virtual bool Equals(object right); 用於重載
    • public static bool operator ==(MyClass left, MyClass right); 用於重載
  • 不應該覆寫Object.referenceEquals()靜態方法和Object.Equals()靜態方法,因爲它們已經完美的完成了所需要完成的工作,提供了正確的判斷,並且該判斷與運行時的具體類型無關。對於值類型,我們應該總是覆寫Object.Equals()實例方法和operatior==( ),以便爲其提供效率更高的等同性判斷。對於引用類型,僅當你認爲相等的含義並非是對象標識相等時,才需要覆寫Object.Equals( )實例方法。在覆寫Equals( )時也要實現IEquatable<T>。
  • PS: 此原則對應於《EffectiveC# Second Edition》中原則6。




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


  • GetHashCode( )方法在使用時會有不少坑,要謹慎使用。GetHashCode()函數僅會在一個地方用到,即爲基於散列(hash)的集合定義鍵的散列值時,此類集合包括HashSet<T>和Dictionary<K,V>容器等。對引用類型來講,索然可以正常工作,但是效率很低。對值類型來講,基類中的實現有時甚至不正確。而且,編寫的自己GetHashCode( )也不可能既有效率又正確。
  • 在.NET中,每個對象都有一個散列碼,其值由System.Object.GetHashCode()決定。
  • 實現自己的GetHashCode( )時,要遵循上述三條原則:
    • 如果兩個對象相等(由operation==定義),那麼他們必鬚生成相同的散列碼。否則,這樣的散列碼將無法用來查找容器中的對象。
    • 對於任何一個對象A,A.GetHashCode()必須保持不變。
    • 對於所有的輸入,散列函數應該在所有整數中按隨機分別生成散列碼。這樣散列容器才能得到足夠的效率提升。
  • PS: 此原則對應於《EffectiveC# Second Edition》中原則7。

 


 

 

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


  • 將C#代碼翻譯成可執行的機器碼需要兩個步驟。C#編譯器將生成IL,並放在程序集中。隨後,JIT將根據需要逐一爲方法(或是一組方法,如果涉及內聯)生成機器碼。短小的方法讓JIT編譯器能夠更好地平攤編譯的代價。短小的方法也更適合內聯。
  • 除了短小之外,簡化控制流程也很重要。控制分支越少,JIT編譯器也會越容易地找到最適合放在寄存器中的變量。
  • 所以,短小方法的優勢,並不僅體現在代碼的可讀性上,還關係到程序運行時的效率。
  •  此原則對應於《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)的區別:
    • 接口是一種契約式的設計方式,一個實現某個接口的類型,必須實現接口中約定的方法。抽象基類則爲一組相關的類型提供了一個共同的抽象。也就是說抽象基類描述了對象是什麼,而接口描述了對象將如何表現其行爲。
    • 接口不能包含實現,也不能包含任何具體的數據成員。而抽象基類可以爲派生類提供一些具體的實現。
    • 基類描述並實現了一組相關類型間共用的行爲。接口則定義了一組具有原子性的功能,供其他不相關的具體類型來實現。
  • 理解好兩者之間的差別,我們便可以創造更富表現力、更能應對變化的設計。使用類層次來定義相關的類型。用接口暴露功能,並讓不同的類型實現這些接口。
  • PS: 此原則對應於《EffectiveC# Second Edition》中原則22。

 



原則17:理解接口方法和虛方法的區別


  • 第一眼看來,實現接口和覆寫虛方法似乎沒有什麼區別,實際上,實現接口和覆寫虛方法之間的差別很大。
    • 接口中聲明的成員方法默認情況下並非虛方法,所以,派生類不能覆寫基類中實現的非虛接口成員。若要覆寫的話,將接口方法聲明爲virtual即可。
    • 基類可以爲接口中的方法提供默認的實現,隨後,派生類也可以聲明其實現了該接口,並從基類中繼承該實現。
    • 實現接口擁有的選擇要比創建和覆寫虛方法多。我們可以爲類層次創建密封(sealed)的實現,虛實現或者抽象的契約。還可以創建密封的實現,並在實現接口的方法中提供虛方法進行調用。
  • PS: 此原則對應於《EffectiveC# Second Edition》中原則23。

 



原則18:用委託實現回調


  • 在C#中,回調是用委託來實現的,主要要點如下:
    • 委託爲我們提供了類型安全的回調定義。雖然大多數常見的委託應用都和事件有關,但這並不是C#委託應用的全部場合。當類之間有通信的需要,並且我們期望一種比接口所提供的更爲鬆散的耦合機制時,委託便是最佳的選擇。
    • 委託允許我們在運行時配置目標並通知多個客戶對象。委託對象中包含一個方法的應用,該方法可以是靜態方法,也可以是實例方法。也就是說,使用委託,我們可以和一個或多個在運行時聯繫起來的客戶對象進行通信。
    • 由於回調和委託在C#中非常常用,以至於C#特地以lambda表達式的形式爲其提供了精簡語法。
    • 由於一些歷史原因,.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。

 

 


原則22:儘量減少在公有API中使用動態對象



(這條準則在Unity中請忽略,因爲Unity版本的mono並沒有實現.NET 4.0中的dynamic language runtime

  • 動態對象有一些“強制性”,即所有打交道的對象都變成動態的。若是某個操作的某個參數是動態的,那麼其結果也會是動態的。若是每個方法返回了一個動態對象,那麼所有使用過該對象的地方也變成了動態對象。
  • 所以,若你要在程序中使用動態特性,請儘量不要在公有接口中使用,這樣可以將動態類型現在在一個單獨的對象或類型中。
  • 應該將動態對象限制在最需要的地方,然後立即將動態對象轉換爲靜態類型。當你的代碼依賴於其他環境中創建的動態類型時,可以用專門的靜態類型封裝這些動態對象,並提供靜態的公有接口。
  • PS: 此原則對應於《Effective C# Second Edition》中原則44。

 

The end.

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章