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
而只需要改用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來宣告區域變數,用隱式型別的區域變數來表示數值的時候要多加小心,因為可能會發生很多隱式轉換,這不僅容易令閱讀代碼的人產生誤解,而且其中某些轉換還會令精確度下降,
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
由于歷史原因,所有的委托都是多播委托(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版)》 比爾·瓦格納
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/254233.html
標籤:C#
上一篇:比EntityFramework簡單很多的SOD框架動態創建表的方法
下一篇:淺析 record 使用場景
