《Effective C#》筆記(1) - 編程習慣

1.優先使用隱式類型的局部變量

推薦優先使用隱式類型的局部變量,即用var來聲明,由於這能夠使人把注意力放在最爲重要的部分,也就是變量的語義上面,而不用分心去考慮其類型.數據庫

有時隱式類型比本身指定類型表現更好

用var來聲明的變量不是動態變量,隱式類型的局部變量的類型推斷也不等於動態類型檢查。只是編譯器會根據賦值符號右側的表達式來推斷變量的類型。var的意義在於不用專門指定變量的類型,而是交給編譯器來判斷,因此局部變量的類型推斷機制並不影響C#的靜態類型檢查。
有時隱式類型會有比專門指定類型更好的表現,好比下面這段指定變量q爲IEnumerable 的代碼便存在嚴重的性能問題。 編程

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 類型,可是開發者卻把保存該返回值的變量q聲明成了IEnumerable 類型。因爲IQueryable 繼承自IEnumerable ,所以編譯器並不會報錯,可是這樣作將致使後續的代碼沒法使用由IQueryable所提供的特性。接下來的那行查詢語句,就受到了這樣的影響,它原本可使用Queryable.Where去查詢,可是卻用了Enumerable.Where。這會致使程序把從數據庫中獲取到的客戶姓名全都拿到本地,而後才能執行第二條查詢語句。 c#

而只須要改用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 類型,系統會首先把第二條篩選語句第一條查詢語句相結合,建立一棵更爲完備的表達式樹,而後只有在調用方真正去使用查詢結果裏面的內容時,這棵樹所表示的查詢操做纔會獲得執行。 安全

隱式類型可能帶來的問題

雖然推薦大多數時候使用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來聲明局部變量。用隱式類型的局部變量來表示數值的時候要多加當心,由於可能會發生不少隱式轉換,這不只容易令閱讀代碼的人產生誤解,並且其中某些轉換還會令精確度降低。ide

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 、Action<>及Func<>表示不少常見的委託形式,LINQ就是用這些機制構建起來的。predicate(謂詞)是用來判斷某條件是否成立的布爾(Boolean)函數,而Func<>則會根據一系列的參數求出某個結果。其實Func<T,bool>與Predicate 是同一個意思,只不過編譯器會把二者分開對待而已,也就是說,即使兩個委託是用同一套參數及返回類型來定義的,也依然要按照兩個來算,編譯器不容許在它們之間相互轉換。

因爲歷史緣由,全部的委託都是多播委託(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版)》 比爾·瓦格納

相關文章
相關標籤/搜索