《Effective C#》筆記(1) - 程式設計習慣

語言: CN / TW / HK

1.優先使用隱式型別的區域性變數

推薦優先使用隱式型別的區域性變數,即用var來宣告,因為這可以令人把注意力放在最為重要的部分,也就是變數的語義上面,而不用分心去考慮其型別.

有時隱式型別比自己指定型別表現更好

用var來宣告的變數不是動態變數,隱式型別的區域性變數的型別推斷也不等於動態型別檢查。只是編譯器會根據賦值符號右側的表示式來推斷變數的型別。var的意義在於不用專門指定變數的型別,而是交給編譯器來判斷,所以區域性變數的型別推斷機制並不影響C#的靜態型別檢查。 有時隱式型別會有比專門指定型別更好的表現,比如下面這段指定變數q為IEnumerable<string>的程式碼便存在嚴重的效能問題。

public IEnumerable<string> FindCustomerStartWith(string start)
{
    IEnumerable<string> q =
    from c in db.Customers
    select c.ContactName;
    var q2 = q.Select(a => a.StartsWith(start));
    return q2;
}

第一行查詢語句會把每一個人的姓名都從資料庫裡取出來,由於它要查詢資料庫,因此其返回值實際上是IQueryable<string>型別,但是開發者卻把儲存該返回值的變數q宣告成了IEnumerable<string>型別。由於IQueryable<T>繼承自IEnumerable<T>,因此編譯器並不會報錯,但是這樣做將導致後續的程式碼無法使用由IQueryable所提供的特性。接下來的那行查詢語句,就受到了這樣的影響,它本來可以使用Queryable.Where去查詢,但是卻用了Enumerable.Where。這會導致程式把從資料庫中獲取到的客戶姓名全都拿到本地,然後才能執行第二條查詢語句。

而只需要改用var來宣告變數,就可以避免這個問題:

public IEnumerable<string> FindCustomerStartWith(string start)
{
    var q =
    from c in db.Customers
    select c.ContactName;
    var q2 = q.Select(a => a.StartsWith(start));
    return q2;
}

因為q變成了IQueryable<string>型別,系統會首先把第二條篩選語句第一條查詢語句相結合,建立一棵更為完備的表示式樹,然後只有在呼叫方真正去使用查詢結果裡面的內容時,這棵樹所表示的查詢操作才會得到執行。

隱式型別可能帶來的問題

雖然推薦大多數時候使用var,但也不能盲目地使用var來宣告一切區域性變數。有時隱式型別可能帶來一些隱祕的問題。因為如果用var來宣告,則編譯器會自行推斷其型別,而其他開發者卻看不到編譯器所推斷出的型別。因此,他們所認定的型別可能與編譯器推斷出的型別不符。這會令程式碼在維護過程中遭到錯誤地修改,併產生一些本來可以避免的bug。 典型的如值型別,在計算過程中可能會觸發各種形式的轉換。有些轉換是寬化轉換(widening conversion),這種轉換肯定是安全的,例如從float到double就是如此,但還有一些轉換是窄化轉換(narrowing conversion),這種轉換會令精確度下降,例如從long到int的轉換就會產生這個問題。如果明確地寫出數值變數所應具備的型別,那麼就可以更好地加以控制,而且編譯器也會把有可能把因轉換而丟失精度的地方給指出來。 比如下面這段程式碼:

var f = GetMagicNumber();
var total = 100 * f / 6;
Console.WriteLine($"Type: {total.GetType().Name}, Value: {total}");

下面這5種輸出結果分別對應5個GetMagicNumber版本,每個版本的返回值型別都不一樣:

Type: Double, Value: 1666.6666666666667
Type: Single, Value: 1666.6666
Type: Decimal, Value: 1666.6666666666666666666666667
Type: Int32, Value: 1666
Type: Int32, Value: 1666

total變數在這5種情況下會表現出5種不同的型別,這是因為該變數的型別由變數f來確定,而變數f的型別又是編譯器根據GetMagicNumber()的返回值型別推斷出來的。計算total值的時候,會用到一些常數,由於這些常數是以字面量的形式寫出的,因此,編譯器會將其轉換成和f一致的型別,並按照那種型別的規則加以計算。於是,不同的型別就會產生不同的結果。

總結

如果發現編譯器自動選擇的型別有可能令人誤解程式碼的含義,使人無法立刻看出這個區域性變數的準確型別,那麼就應該把型別明確指出來,而不要採用var來宣告。反之,在其它的場景,都應該優先用var來宣告區域性變數。用隱式型別的區域性變數來表示數值的時候要多加小心,因為可能會發生很多隱式轉換,這不僅容易令閱讀程式碼的人產生誤解,而且其中某些轉換還會令精確度下降。

2.考慮用readonly代替const

C#的常量有兩種:

  • 編譯期(compile-time)常量,關鍵字const
  • 執行期(runtime)常量,關鍵字readonly

兩者的區別主要有:

  • readonly和const常量都可以在class、struct的範圍內宣告;此外const常量還可以在方法裡面宣告,readonly則不可以
  • const常量的取值會嵌入目的碼,必須在宣告時賦值; readonly常量可以在宣告時賦值,也可以在建構函式賦值
  • const常量只能用數字、字串或null來初始化;readonly常量的型別則不受限制
  • readonly可以用來宣告例項級別的常量,以便給同一個類的每個例項設定不同的常量值,而編譯期的常量則是靜態常量。

可見readonly比const更加靈活。此外,const在編譯時解析值的特性還會對影響程式的維護工作。 比如在程式集A中有這樣的程式碼:

public class ValueInfo{
    public static readonly int Start = 5;
    public const int End = 10;
}

然後程式集B引用了程式集A中的這兩個常量:

for(var i = valueInfo.Start; i < valueInfo.End; i++)
    Console.Writeline(i);

則輸出結果為:

5
6
7
8
9

隨後修改了程式集A:

public class ValueInfo{
    public static readonly int Start = 105;
    public const int End = 110;
}

此後如果只發布程式集A,而不去構建程式集B,是不會下面這樣得到期望的結果的:

105
106
...
109

因為在程式集B中,valueInfo.End的值仍然是上一次編譯是的10,要想讓修改生效,需要重新編譯程式集B。

總結

推薦優先使用readonly,因為它比const更靈活,但const也不是一無是處,首先它的效能更好,此外有時使用const僅僅是為了消除魔數增加可讀性,這種情況使用const也未嘗不可,另外還有些確實需要在編譯器把常量值固定下來的需求,那麼也是必須使用const。

3.優先考慮is和as運算子,儘量少用強制型別轉換

在C#中實現型別轉換可以使用as運算子,或者使用強制型別轉換(cast)來繞過編譯器的型別檢查。 使用as運算子的寫法:

private static void As()
{
    // object a = null; 
    object a = new TypeB();
    var b = a as TypeA;
    if (b != null)
    {
        Console.WriteLine("convert succeed");
    }
    else
    {
        Console.WriteLine("convert failed");
    }
}

使用cast的寫法:

private static void Cast()
{
    //object a = null;
    object a= new TypeB();
    try
    {
      var b = (TypeA) a;
      if (b != null)
      {
        Console.WriteLine("convert succeed");
      }
      else
      {
        Console.WriteLine("convert failed");
      }
    }
    catch (InvalidCastException e)
    {
      Console.WriteLine("convert failed");
    }
}

TypeA與TypeB沒有任何聯絡,因此兩種寫法的轉換都會失敗,但兩者的區別在於:

  • 在將TypeB轉換為TypeA時,as寫法的結果為null,但cast寫法會報InvalidCastException異常
  • 在將object a = null轉換為TypeA時,兩者的結果都是null

所以a s寫法在兩種情況下的結果都是null,但cast寫法需要判斷null並catch InvalidCastException異常才能涵蓋兩種情況。可見as寫法相比cast寫法省了try/catch結構,程式的開銷與程式碼量都比較低。除了判斷轉換結果是否為null,也可以先用Is來判斷轉換能否成功。

as與cast最大的區別在於它們如何對待由使用者所定義的轉換邏輯:

  • as與is運算子只會判斷待轉換的那個物件在執行期是何種型別,並據此做出相應的處理,除了必要的裝箱與取消裝箱操作,它們不會執行其他操作。如果待轉換的物件既不屬於目標型別,也不屬於由目標型別所派生出來的型別,那麼as操作就會失敗。
  • cast操作則有可能使用某些型別轉換邏輯來實現型別轉換,這不僅包含由使用者所定義的型別轉換邏輯,而且還包括內建的數值型別之間的轉換。例如可能發生從long至short的轉換,這種轉換可能導致資訊丟失。

如果在TypeB類中定義如下運算子:

public class TypeB
{
  private TypeA _typeA =new TypeA();
  public static implicit operator TypeA(TypeB typeB)
  {
    return typeB._typeA;
  }
}

那麼前面的cast方式的程式碼應該就會把由使用者所定義的轉換邏輯也考慮進去,但執行後發現轉換仍然失敗,這是為什麼呢? 這是因為雖然cast方式會考慮自定義轉換邏輯,但它針對的是源物件的編譯期型別,而不是實際型別。具體到本例來說,由於待轉換的物件其編譯期的型別是object,因此,編譯器會把它當成object看待,而不考慮其在執行期的型別。 如果改成在cast前先轉換為TypeB,則轉換會成功:

...
object a= new TypeB();
try
{
  var a1 = a as TypeB;
  var b = (TypeA) a1;
  if (b != null)
...

但不推薦這種彆扭的寫法,應該優先考慮採用as運算子來實現型別轉換,因為這樣做要比盲目地進行型別轉換更加安全,而且在執行的時候也更有效率。

不能使用as的情況

類似下面這樣的程式碼,將object轉換為值型別,是無法通過語法檢查的,因為值型別無法表示null:

object a = null;
var b = a as int;

為此只需將轉換目標修改為可空值型別就可以了:

object a = null;
var b = a as int?;

總結

使用面嚮物件語言來程式設計序的時候,應該儘量避免型別轉換操作,但總有一些場合是必須轉換型別的。此時應該採用as及is運算子來更為清晰地表達程式碼的意圖。

4.用內插字串取代string.Format()

string.Format()可以用來設定字串的格式,但C#6.0之後提供了內插字串(Interpolated String)特性,更推薦使用後者。

內插字串的好處

  • 使程式碼更容易閱讀、維護
  • 編譯器也可以用它實現出更為完備的靜態型別檢查機制,從而降低程式出錯的概率
  • 內插字串還提供了更加豐富的語法

string.Format()可能造成的問題

  • 如果格式字串後面的引數個數與待替換的序號數量是否相等,編譯器是不會發現這個問題的
  • 如果格式字串中的序號與params陣列中的位置沒有相對應,這個錯誤可能很難被發現

內插字串的用法

  • 不能使用if/else或while等控制流語句,如果必須使用,可以把這些邏輯寫成方法,然後在內插字串呼叫該方法
  • 內插字串會在必要的時候將變數轉換為string,比如$"the value of PI is {Math.PI}" ,會將double轉換為string,由於double是值型別,必須先通過裝箱操作轉為object,如果這段程式碼頻繁執行,就會嚴重影響效能。 這可以通過強制呼叫Math.PI.ToString()來避免。
  • 字串內插機制支援很多種語法,只要是有效的C#表示式,都可以出現在字串裡面,比如三元表示式、null條件運算子、null傳播運算子、LINQ查詢,還可以在內插字串裡面繼續編寫內插字串。

內插字串是一種語法糖

內插字串實際上是一種語法糖,生成的是FormattableString,將接收內插字串的變數指定為FormattableString可以看到其Format屬性的值,通過GetArguments可以看到對應的引數:

 FormattableString a1 = $"the value of PI is {Math.PI}, E is {Math.E}";
 Console.WriteLine("Format: " + a1.Format);
 Console.WriteLine("Arguments: ");
 foreach (var arg in a1.GetArguments())
 {
   Console.WriteLine($"\t{arg}");
 }

執行結果為:

Format: the value of PI is {0}, E is {1}
Arguments: 
        3.141592653589793
        2.718281828459045

只是在實際使用時系統會自動將其解讀為string結果。

7.用委託表示回撥

回撥是一種由被呼叫端向呼叫端提供非同步反饋的機制,它可能會涉及多執行緒(multithreading),也有可能只是給同步更新提供入口。 C#用委託來表示回撥。通過委託,可以定義型別安全的回撥。型別安全程式碼指訪問被授權可以訪問的記憶體位置,型別安全直觀來說意味著編譯器將在編譯時驗證型別,如果嘗試將錯誤的型別分配給變數,則丟擲錯誤。

最常用到委託的地方是事件處理,此外,還可用於多種場合,比如想採用比介面更為鬆散的方式在類之間溝通時,就應該考慮委託。這種機制可以在執行的時候配置回撥目標,並且能夠通知給多個客戶端。

委託是一種物件,其中含有指向方法的引用,這個方法既可以是靜態方法,又可以是例項方法。

C#提供了一種簡便的寫法,可以直接用lambda表示式來表示委託。此外,還可以用Predicate<T>、Action<>及Func<>表示很多常見的委託形式,LINQ就是用這些機制構建起來的。predicate(謂詞)是用來判斷某條件是否成立的布林(Boolean)函式,而Func<>則會根據一系列的引數求出某個結果。其實Func<T,bool>與Predicate<T>是同一個意思,只不過編譯器會把兩者分開對待而已,也就是說,即便兩個委託是用同一套引數及返回型別來定義的,也依然要按照兩個來算,編譯器不允許在它們之間相互轉換。

由於歷史原因,所有的委託都是多播委託(multicast delegate),也就是會把新增到委託中的所有目標函式(target function)都視為一個整體去執行。 這就需要注意下面兩個問題:

  • 程式在執行這些目標函式的過程中可能發生異常;但多播委託在執行的時候,會依次呼叫這些目標函式,且不捕獲異常。因此,只要其中一個目標丟擲異常,呼叫鏈就會中斷,從而導致其餘的那些目標函式都得不到呼叫。

  • 程式會把最後執行的那個目標函式所返回的結果當成整個委託的結果。

對於這兩個問題,必要的時候可以通過委託的GetInvocationList方法獲取目標函式列表,然後手動遍歷來處理異常和返回值。

8.用null條件運算子呼叫事件處理程式

關於事件處理程式,有很多陷阱要注意,比如,如果沒有處理程式與這個事件相關聯,那會出現什麼情況?如果有多個執行緒都要檢測並呼叫事件處理程式,而這些執行緒之間相互爭奪,那又會出現什麼情況?

觸發事件的基本寫法可以是這樣:

public class EventSource
{
  public event Action<int> Update;
  public void RaiseUpdate()
  {
    Update(2);
  }
}

但如果沒有為Update註冊事件處理程式,這種寫法就會報NullReferenceException,為此可以改進為觸發前先檢查事件處理程式是否存在:

public void RaiseUpdate()
{
  if(Update!=null)
    Update(2);  
}

這種寫法基本上可以應對各種狀況,但還是有個隱藏的bug。因為當程式中的執行緒執行完那行if語句並發現Updated不等於null之後,可能會有另一個執行緒打斷該執行緒,並將唯一的那個事件處理程式解除訂閱,這樣等早前的執行緒繼續執行Updated(2)語句時,事件處理程式就變成了null,仍然會引發NullReferenceException。 為了預防這種情況出現,可以將程式碼繼續改進為:

public void RaiseUpdate()
{
  var handler = Update;
  if(handler!=null)
    handler(2);  
}

這種寫法是執行緒安全的,因為將handler賦值為Update會執行淺拷貝,也就是建立新的引用,將handler指向原來Update的事件處理程式。這樣即使另外一個執行緒把Update事件清空,handler中還是儲存著事件處理程式的引用,並不會受到影響。

這種寫法雖然沒什麼問題,但看起來冗長而費解。使用c#6.0引入的null條件運算子可以改用更為清晰的寫法:

public void RaiseUpdate()
{
  Update?.Invoke(2);
}

這段程式碼採用null條件運算子(?.)首先判斷其左側的內容,如果不是null,那就執行右側的內容,反之則跳過該語句。從語義上來看,這與前面的if結構類似,但區別在於條件運算子左側的內容只會被計算一次。

9. 儘量避免裝箱與拆箱操作

值型別是盛放資料的容器,它們不應該設計成多型型別,但另一方面,.NET又必須設計System.Object這樣一種引用型別,並將其放在整個物件體系的根部,使得所有型別都成為由Object所派生出的多型型別。這兩專案標是有所衝突的。 為了解決該衝突,.NET引入了裝箱與拆箱的機制。裝箱的過程是把值型別放在非型別化的引用物件中,使得那些需要使用引用型別的地方也能夠使用值型別。拆箱則是把已經裝箱的那個值拷貝一份出來。 如果要在只接受System.Object型別或介面型別的地方使用值型別,那就必然涉及裝箱及取消裝箱。 但這兩項操作都很影響效能,有的時候還需要為物件建立臨時的拷貝,而且容易給程式引入難於查詢的bug。 因此,應該儘量避免裝箱與取消裝箱這兩種操作。 就連下面這條簡單內插字串寫法都會用到裝箱:

var firstNumber = 1;
var a = $"the first number is: {firstNumber}";

因為系統在解讀內插字串時,需要建立由System.Object所構成的陣列,以便將呼叫方所要輸出的值放在這個數組裡面,並交給由編譯器所生成的方法去解讀。但firstNumber變數卻是值型別,要想把它當成System.Object來用,就必須裝箱。 此外,該方法的程式碼還需要呼叫ToString(),而這實際上相當於在箱子所封裝的原值上面呼叫,也就是說,相當於生成了這樣的程式碼:

var firstNumber = 1;
object o = firstNumber;
var str = firstNumber.ToString();

要避開這一點,需要提前把這些值手工地轉換成string:

var a = $"the first number is: {firstNumber.ToString()}";

總之,要避免裝箱與拆箱操作,就應注意那些會把值型別轉換成System.Object型別的地方,例如把值型別的值放入集合、用值型別的值做引數來呼叫引數型別為System.Object的方法以及將這些值轉為System.Object等。

10.只有在應對新版基類與現有子類之間的衝突時才應該使用new修飾符

new修飾符可以重新定義從基類繼承下來的非虛成員,但要慎用這個特性,因為重新定義非虛方法可能會使程式表現出令人困惑的行為。 假設MyOtherClass繼承自MyClass,那麼初看起來下面這兩種寫法的效果應該是相同的:

object c = new MyOtherClass();
var c1 =c as MyClass;
c1.MagicMethod();

var c2 =c as MyOtherClass;
c2.MagicMethod();

但如果使用了new修飾符就不會相同了:

public class MyClass
{
  public void MagicMethod()
  {
    Console.WriteLine("MyClass");
  }
}

public class MyOtherClass : MyClass
{
  public new void MagicMethod()
  {
    Console.WriteLine("MyOtherClass");
  }
}

c2.MagicMethod()的結果是"MyOtherClass", new修飾符並不會把本來是非虛的方法轉變成虛方法,而是會在類的名稱空間裡面另外新增一個方法。非虛的方法是靜態繫結的,所以凡是引用MyClass.MagicMethod()的地方到了執行的時候執行的都是MyClass類裡面的那個MagicMethod,即便派生類裡面還有其他版本的同名方法也不予考慮。 反之,虛方法則是動態繫結的,要到執行的時候才會根據物件的實際型別來決定應該呼叫哪個版本。

不推薦new修飾符重新定義非虛的方法,但這並非是在鼓勵把基類的每個方法都設定成虛方法。程式庫的設計者如果把某個函式設定成虛擬函式,那相當於在制定契約,也就是要告訴使用者:該類的派生類可能會以其他的方式來實現這個虛擬函式。虛擬函式應該用來描述那些子類與基類可能有所區別的行為。如果直接把類中的所有函式全都設定成虛擬函式,那麼就等於在說這個類的每一種行為都有可能為子類所修改。這表現出類的設計者根本就沒有仔細去考慮其中到底有哪些行為才是真正可能會由子類來修改的。

本書的作者認為唯一一種可能使用new修飾符的情況是:新版的基類裡面添加了一個方法,而那個方法與你的子類中已有的方法重名了。作者提到的原因是:在這種情況下,你所寫的程式碼裡面可能已經有很多地方都用到了子類裡面的這個方法,而且其他程式集或許也用到了這個方法,因此,想要給子類的方法改名可能比較麻煩。但是現在的IDE可以方便地重新命名,並不會麻煩,所以new修飾符基本失去了使用場景,事實上,在平時也確實鮮有需要用到這個修飾符的情況。

參考書籍

《Effective C#:改善C#程式碼的50個有效方法(原書第3版)》 比爾·瓦格納

分享到: