--- tags: C#, 語言特性, 翻譯文章 --- <!-- Dark Theme for HackMD --> # 你究竟有多了解 C\# 原文:How Well Do You Know C# ? 文章出處:<http://www.dotnetcurry.com/csharp/1417/csharp-common-mistakes> 譯者 : Cymon Dez 翻譯時間 : 2018/08/10、2018/08/12(第一次修訂) 目錄 [TOC] ## 前言 儘管C#被認為是一種易於學習和理解的語言,但就算是具備多年經驗和良好語言知識地開發人員,在撰寫程式碼時,有時仍會出現令人訝異、非預期的結果。 本文將介紹幾個諸如此類的程式碼片段,並加以解釋令人訝異其行為的背後原因。 ## 空值 ( Null Value ) 各位應該很清楚,不當處理空值(null values),將會是很危險的事情。 反向引用(Dereferencing)空值變數,即變數被設為空值時呼叫其方法或屬性時,將導致**NullReferenceException**,如下程式碼範例所示: ```csharp object nullValue = null; bool areNullValuesEqual = nullValue.Equals(null); // NullReferenceException ``` 為了更安全,我們應該始終確保引用類型值在取消引用之前不為null。 如果不這樣做,可能會導致特定邊緣情況(specific edge case)下出現未處理的例外(exception)。 雖然這種錯誤偶爾會發生在每個人身上,但我們很難稱之為意外行為。 那以下程式碼怎麼樣? ```csharp string nullString = (string)null; bool isStringType = nullString is string; //false ``` 此例中,isStringType的值是多少? 顯性型別轉換為字串的變數值是否也會在執行時被視為字串? 正確答案是 **false**。 **null值在運行時沒有類型**。 在某種程度上,這也會影響反射(Reflection)。 當然,你不能在null值上呼叫GetType(),因為會拋出NullReferenceException: ```csharp object nullValue = null; Type nullType = nullValue.GetType(); // NullReferenceException ``` 接下來,來討論 **可為空值型別(Nullable value type)**: ```csharp int intValue = 5; Nullable<int> nullableIntValue = 5; bool areTypesEqual = intValue.GetType() == nullableIntValue.GetType(); //true ``` 是否可以使用反射機制來區分*可為空值*和*不可空值*類型? 答案也是 **flase**。 上面的程式碼中為兩個變數(intValue & nullableIntValue)將返回相同的類型:**System.Int32**。 但這並不意味著反射沒有 **Nullable\<T>** 的表示。 ```csharp Type intType = typeof(int); Type nullableIntType = typeof(Nullable<int>); bool areTypesEqual = intType == nullableIntType; ``` 此程式碼段中的類型不同。 正如預期的那樣,可為空值類型(Nullable\<int>)將用 **System.Nullable`1\[[System.Int32]]** 表示。 只有在檢查值時,可為空值的對像在反射中被視為與值類相同。 ## 處理多載方法的空值參數 ( Handling Null values in Overloaded methods ) 在繼續討論其他主題之前,讓我們仔細看看在調用具有相同數量參數但具有不同類型的多載方法時如何處理空值。 ```csharp private string OverloadedMethod(object arg) { return "object parameter"; } private string OverloadedMethod(string arg) { return "string parameter "; } ``` 如果我們用null調用方法會發生什麼? ```csharp var result = OverloadedMethod(null); // 會回傳 "string parameter " ``` 請問將調用哪個多載方法? 或者由於模糊的方法調用,程式碼無法編譯? 在這種情況下,程式碼將編譯,並且將調用帶有字符串參數的方法。 通常,程式碼將在一個參數類型轉換為另一個參數類型時編譯(即一個參數類型來自另一個參數類型的衍伸)。 將調用具有更具體參數類型的方法。 當兩種類型之間沒有強制轉換時,程式碼將無法編譯。 要強制調用特定的重載,可以將null值強制轉換為該參數類型:[^1] ```csharp var result = OverloadedMethod((object)null); // 會回傳 "object parameter " ``` [^1]: 此處原文範例的方法名稱似乎有誤 parameteredMethod 應該改成 OverloadedMethod ,才能呼應討論主題前後的範例。 ## 算術運算 ( Arithmetic Operations ) ### 位移操作 我們大多數人鮮少進行位移操作! 讓我們先刷新我們的記憶。 左移運算子( << )將二進製表示向左移動給定的位數: [^2] [^2]: 0b 表示法為C#7.0導入的二進位數值表示法,詳細請參閱[C# 7.0 新功能介紹 -改良的 Literal](https://blogs.msdn.microsoft.com/msdntaiwan/2017/04/10/c7-new-features/#H) ```csharp var shifted = 0b1 << 1; // = 0b10 ``` 類似地,右移運算子(>>)將二進製表示移到右側: ```csharp var shifted = 0b1 >> 1; // = 0b0 ``` 當bits到達末尾時,它們不會循環。 這就是為什麼第二個範例的結果為0。如果我們將位向左移動"足夠遠"(32位,因為整數(int)是32位數),也會發生同樣的情況: ```csharp var shifted = 0b1; for (int i = 0; i < 32; i++) { shifted = shifted << 1; } ``` 結果再次顯示為 0。 但是,位移運算子具有第二運算元。 ```txt 運算子 << : 第一運算元 << 第二運算元 ``` 我們似乎可以向左移動32位並獲得相同的結果,而不是向左移動1位32次。 ```csharp var shifted = 0b1 << 32; ``` 真的可以嗎?**當然不行!**! 此例的回傳結果將會是 1。 為什麼呢?因為這運算子的定義方式:[^3] 在該運算之前,第二運算元將通過模除運算(modulo operation)正規化(normalize)為第一運算元的位元長度,即通過計算將第二運算元除以第一運算元的位元長度的餘數。 [^3]: [<< 運算子 (C# 參考) MSDN](https://docs.microsoft.com/zh-tw/dotnet/csharp/language-reference/operators/left-shift-operator) ```txt 運算子 << : 第一運算元 << (第二運算元 % 第一運算元位原長度) ``` 我們剛看到的示例中的第一個運算元是32位元,因此:32%32 = 0。 結果我們的數字將向左移位0位。 這與將其向左移動1位32次不同。 ```csharp // 我們寫下的程式碼 var shifted = 0b1 << 32; // 將會先轉成如下結果 var shifted = 0b1 << ( 32 % 32 ); // 0b1 是 Int32 ,所以是模除32 // 結果將變成 var shifted = 0b1 << 0; // 等同於不進行位移 ``` ### 位元邏輯運算 讓我們討論一下 運算子 **& (and)** and **| (or)**。根據運算元的類型,它們代表兩種不同的操作: + 對於布林運算元,它們充當邏輯運算子,類似於 && 和 || ,但有一個區別:它們是急切的,即總是評估兩個運算元,即使在評估第一個運算後結果已經確定,有別於 && 和 || 的 短路運算子[^4]。 + 對於整數類型,它們充當邏輯位元運算子,通常與表示旗標(flag)的列舉類型(enum)一起使用[^5]。 [^4]: [短路運算子](https://zh.wikipedia.org/wiki/短路求值) [^5]: [列舉類型 \(C# 程式設計手冊)-作為位元旗標的列舉類型](https://docs.microsoft.com/zh-tw/dotnet/csharp/programming-guide/enumeration-types) ```csharp [Flags] private enum Colors { None = 0b0, Red = 0b1, Green = 0b10, Blue = 0b100 } ``` 此時,**|** 運算子 用於組合旗標(flag),**&** 運算子 用於檢查是否設置了旗標(flag): ```csharp Colors color = Colors.Red | Colors.Green; bool isRed = (color & Colors.Red) == Colors.Red; ``` 在上面的程式碼中,我將括號括在位元邏輯運算周圍,以使程式碼更清晰。 這個表達式中是否需要括號? 事實證明,是的。 與算術運算子不同,位元邏輯運算子的優先級[^6]低於相等運算子。 幸運的是,由於類型檢查,沒有括號的程式碼將無法編譯。 [^6]: [C# 運算子優先順序](https://msdn.microsoft.com/zh-tw/library/2bxt6kc4.aspx) 從 \.NET framework 4.0開始,有一個更好的替代方法 HasFlag[^7],可用於檢查旗標(flag),您應該始終使用它而不是 **&** 運算子: [^7]: [Enum.HasFlag方法](https://msdn.microsoft.com/zh-tw/library/system.enum.hasflag(v=vs.110).aspx) ```csharp bool isRed = color.HasFlag(Colors.Red); ``` ### 四捨五入? Math.Round() 我們將以Round運算作為結束算術運算主題。 它如何取捨兩個整數值之間的中點處的值,例如1.5? 進位與否? ```csharp var rounded = Math.Round(1.5); ``` 你可能猜到了, 結果將是2。但,這是一般規則嗎?不會,猜猜下面範例的結果: ```csharp var rounded = Math.Round(2.5); ``` 結果將再次為2。 默認情況下,中點值將四捨五入到最接近的**偶數值**[^8]。 您可以為Round方法提供第二個參數,以顯性請求此類行為: [^8]: 詳細資訊請參閱 [Math.Round 方法](https://msdn.microsoft.com/zh-tw/library/system.math.round(v=vs.110).aspx) ```csharp var rounded = Math.Round(2.5, MidpointRounding.ToEven); ``` Math.Round可以使用第二個參數[^9]的不同值更改行為: [^9]: 有關Math.Round的列舉參數,請參考[MidpointRounding 列舉](https://msdn.microsoft.com/zh-tw/library/system.midpointrounding(v=vs.110).aspx) ```csharp var rounded = Math.Round(2.5,MidpointRounding.AwayFromZero); ``` 使用此明確規則,正值現在將始終向上舍入。 舍入數字也可能受浮點數精度[^10]的影響。 ```csharp var value = 1.4f; var rounded = Math.Round(value + 0.1f); ``` 雖然,中點值應四捨五入到最接近的偶數,即2,在這種情況下結果將為1,因為對於單精浮點數,沒有0.1的精確表示,並且計算的數字實際上將小於1.5 因此四捨五入到1。 儘管在使用倍精度浮點數時這個特定問題並未表現出來,儘管不常見,但仍可能發生舍入誤差。 因此,當需要最大精度時,應始終使用decimal而不是float或double。 [^10]: 浮點數問題,請參閱[IEEE754](https://zh.wikipedia.org/wiki/IEEE_754) ## 類別初始化 ( Class Initialization ) 最佳實踐(Best practices)建議我們應盡可能避免類別建構式中的類別初始化以防止例外(exception)。 對靜態建構式而言,所有這一切都更為重要。 您可能知道,當我們嘗試在運行時實例化靜態建構式時,會在實例建構式之前調用靜態建構式。 這是實例化任何類別時的初始化順序: 1. 靜態欄位 (static feilds) : 僅限第一次呼叫該類別,靜態成員或第一個實例 2. 靜態建構式 (static constructor ):僅限第一次呼叫該類別,靜態成員(static member)或第一個實例 3. 實例欄位 (instance feilds)[^11]:每個實例 4. 實例建構式 (instance constructor)[^12]:每個實例 [^11]: 亦稱作 成員欄位(member field)、非靜態欄位(non-static field) [^12]: 即由new所呼叫的建構式 ### 靜態建構式 讓我們創建一個帶有靜態建構式的類,可以將其配置為擲出例外(throw exception): ```csharp public static class Config { public static bool ThrowException { get; set; } = true; } public class FailingClass { static FailingClass() { if (Config.ThrowException) { throw new InvalidOperationException(); } } } ``` 任何嘗試創建此類的實例都會導致例外(exception),這不足為奇: ```csharp var instance = new FailingClass(); ``` 但是,它不會是**InvalidOperationException**。 運行時將自動將其包裝到**TypeInitializationException**中。 如果要捕獲例外並從中恢復,這是一個需要注意的重要細節。 ```csharp try { var failedInstance = new FailingClass(); } catch (TypeInitializationException) { } Config.ThrowException = false; var instance = new FailingClass(); ``` 應用我們剛才學到的東西,上面的程式碼應該捕獲靜態建構式拋出的例外(exception),更改配置以避免在將來的調用中拋出例外(exception),最後成功創建類的實例,對吧? 不幸的是,**沒有** 。 類別的靜態建構式只會呼叫一次。 如果它拋出例外(exception),那麼只要您想要創建實例或以任何其他方式訪問該類,就會重新拋出此例外(exception)。 在重新啟動 **行程(process)**,或 **應用程式域(AppDomain)** 之前,該類別實際上無法使用。 沒錯,即使是靜態建構式拋出例外(exception)的機會極小,也是一個非常糟糕的主意。 ### 衍生類別中的初始化順序 對於衍生類別,初始化順序甚至更複雜。 在邊緣情況下,這可能會給您帶來麻煩。 這是一個人為的例子: ```csharp public class BaseClass { public BaseClass() { VirtualMethod(1); } public virtual int VirtualMethod(int dividend) { return dividend / 1; } } public class DerivedClass : BaseClass { int divisor; public DerivedClass() { divisor = 1; } public override int VirtualMethod(int dividend) { return base.VirtualMethod(dividend / divisor); } } ``` 當嘗試實例化DerivedClass會發生什麼?發現DerivedClass中的問題了嗎? ```csharp var instance = new DerivedClass(); ``` 將會擲出DivideByZeroException。為什麼? 嗯,原因是衍生類別的初始化順序: 1. 實例欄位(instance feilds)按從最後衍生類別到基底類別的順序初始化。 2. 按照從基類到最衍生類別的順序呼叫建構式。 由於在整個初始化過程中將類視為DerivedClass,因此在DerivedClass建構式有機會初始化除數欄位之前,我們對BaseClass建構式中的VirtualMethod的呼叫會呼叫方法的DerivedClass的實作。這意味著該值仍為0,這導致DivideByZeroException。 在我們的例子中,可以通過直接初始化除數欄位而不是在建構式中來解決問題。 但是,該示例展示了為什麼從建構式呼叫虛擬方法(virtual method)可能會很危險。當它們被呼叫時,它們所定義的類的建構式可能尚未被呼叫,因此它們可能會出現意外行為。 ## 多形 ( Polymorphism ) 多形:以不同類別(class)實作同一介面(interface)功能。 即便如此,我們通常希望單一實例(instance)始終使用相同的方法實現,無論它是哪種類型。 這使得可以將集合類型化為基底別類(base class),並在集合(collection)中的所有實例上調呼叫特定方法,從而為要呼叫的每個類型生成特定的實作功能。 話雖如此,你能想到一種方法,當我們在呼叫方法之前將實例 **向下轉型(downcast)**[^13],即打破多態行為時,可以呼叫另一種方法嗎? [^13]: [向下轉型](https://en.wikipedia.org/wiki/Downcasting) ```csharp var instance = new DerivedClass(); var result = instance.Method(); // -> 將呼叫 DerivedClass 的 Method result = ((BaseClass)instance).Method(); // -> 將呼叫 BaseClass 的 Method ``` 正確答案是,使用 new 關鍵詞來改寫[^14] ```csharp public class BaseClass { public virtual string Method() { return "Method in BaseClass "; } } public class DerivedClass : BaseClass { public new string Method() { return "Method in DerivedClass"; } } ``` [^14]: 有關override與new的差別,請參閱[了解使用 Override 和 New 關鍵字的時機](https://docs.microsoft.com/zh-tw/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords) 這會將DerivedClass.Method隱藏在其基底類別中,因此在將實例(instance)強制轉換為基底類別時會調用BaseClass.Method。 這適用於基底類別,它們可以有自己的方法實現。 你能想到一種方法來實現一個介面,它不能包含自己的方法實現嗎? ```csharp var instance = new DerivedClass(); var result = instance.Method(); // -> Method in DerivedClass result = ((IInterface)instance).Method(); // -> Method belonging to IInterface ``` 這是 **明確介面實作**[^15] [^15]: 亦稱 顯性介面實作,詳請參考[明確介面實作](https://docs.microsoft.com/zh-tw/dotnet/csharp/programming-guide/interfaces/explicit-interface-implementation) ```csharp public interface IInterface { string Method(); } public class DerivedClass : IInterface { public string Method() { return "Method in DerivedClass"; } // explicit interface implementation ,明確介面實作,又稱 顯示介面實作 string IInterface.Method() { return "Method belonging to IInterface"; } } ``` 這通常用於使用者從實作它的類別的隱藏介面方法,除非他們將實例強制轉換為該介面。 但是如果我們想要在單一個類別中有兩個不同的方法實作,它也可以運作。 但是,很難想出這樣做的好理由。 ## 迭代器 ( Iterators ) 迭代器是用於單步執行項目集合的構造,通常使用foreach。 它們由IEnumerable\<T>泛型類型表示。 雖然非常容易使用,但由於一些編譯器魔法(compiler magic)[^16],如果我們不能好好地理解內部運作原理,我們很快就會陷入錯誤使用的陷阱。 [^16]: 有關編譯器魔法的定義,可以參考StackOverflow的[回答](https://stackoverflow.com/questions/46106580/what-does-special-compiler-magic-mean) 讓我們看看這樣一個例子。 我們將呼叫一個方法,該方法從using區塊中返回一個IEnumerable: ```csharp private IEnumerable<int> GetEnumerable(StringBuilder log) { using (var context = new Context(log)) { return Enumerable.Range(1, 5); } } ``` Context類別實作了IDisposable介面。 它會向日誌寫入一條訊息,指示何時輸入和退出其範圍。 在現實世界的程式碼中,此內容(context))可以由資料庫連線替換。 在其中,將以串流(streaming)方式從返回的結果集中讀取行。 ```csharp public class Context : IDisposable { private readonly StringBuilder log; public Context(StringBuilder log) { this.log = log; this.log.AppendLine("Context created"); } public void Dispose() { this.log.AppendLine("Context disposed"); } } ``` 要使用GetEnumerable返回值,我們使用foreach迴圈尋覽: ```csharp var log = new StringBuilder(); foreach (var number in GetEnumerable(log)) { log.AppendLine($"{number}"); } ``` 程式碼執行後日誌的內容是什麼? 返回的值是否會在Context創建(create))和釋放(dispose)之間列出? 不,它們不會,看看結果: ```txt Context created Context disposed 1 2 3 4 5 ``` 這意味著在我們的真實世界的資料庫範例中,程式碼將會出錯 - 在從資料庫讀取值之前,就已經先關閉了連接(connetion)。 我們如何修復程式碼,以便只有在所有值都已經迭代完畢後才會釋放Context? 唯一的方法是迭代GetEnumerable方法中的集合: ```csharp private IEnumerable<int> GetEnumerable(StringBuilder log) { using (var context = new Context(log)) { foreach (var i in Enumerable.Range(1, 5)) { yield return i; } } } ``` 此時,我們現在遍歷返回的IEnumerable時,Context將僅按預期在最後釋放: ```txt Context created 1 2 3 4 5 Context disposed ``` 如果您不是很了解yield return,這邊稍微解釋一下: yield return是用於創建狀態機的語法糖,它允許使用它的方法中的程式碼以遞增方式執行,即一次傳回一個元素,如同IEnumerable的迭代過程[^17]。 [^17]: yield 有 yield break 與 yield return 兩種,為C#3.0時推出的語法 [yield\(C# 參考)](https://docs.microsoft.com/zh-tw/dotnet/csharp/language-reference/keywords/yield) 可以使用以下方法更好地解釋這一點: ```csharp private IEnumerable<int> GetCustomEnumerable(StringBuilder log) { log.AppendLine("before 1"); yield return 1; log.AppendLine("before 2"); yield return 2; log.AppendLine("before 3"); yield return 3; log.AppendLine("before 4"); yield return 4; log.AppendLine("before 5"); yield return 5; log.AppendLine("before end"); } ``` 要了解這段程式碼的行為,我們可以使用以下程式碼來迭代它: ```csharp var log = new StringBuilder(); log.AppendLine("before enumeration"); foreach (var number in GetCustomEnumerable(log)) { log.AppendLine($"{number}"); } log.AppendLine("after enumeration"); ``` 讓我們看一下程式碼執行後的日誌內容: ```shell before enumeration before 1 1 before 2 2 before 3 3 before 4 4 before 5 5 before end after enumeration ``` 我們可以看到,對於我們迭代的每個值,兩個yield return之間的程式碼被執行。 對於第一個值,這是從方法開頭到第一個yield return的程式碼。 對於第二個值,它是第一個和第二個yield return之間的程式碼。 依此類推,直到方法結束。 當foreach循環在循環的最後一次迭代之後檢查IEnumerable中的下一個值時,將調用最後一個yield return之後的程式碼。 值得注意的是,每次迭代IEnumerable時都會執行此程式碼: ```csharp var log = new StringBuilder(); var enumerable = GetCustomEnumerable(log); for (int i = 1; i <= 2; i++) { log.AppendLine($"enumeration #{i}"); foreach (var number in enumerable) { log.AppendLine($"{number}"); } } ``` 執行此程式碼後,日誌將包含以下內容: ```shell enumeration #1 before 1 1 before 2 2 before 3 3 before 4 4 before 5 5 before end enumeration #2 before 1 1 before 2 2 before 3 3 before 4 4 before 5 5 before end ``` 為了防止每次迭代IEnumerable時都來執行一次,可以將IEnumerable的結果存儲到本地*集合*[^18]\(如:List)中並將其從那裡讀取,如果我們預計將多次使用它,這會是一個很好的做法: [^18]: 此處的集合,指的是Collection,而非Set ```csharp var log = new StringBuilder(); var enumerable = GetCustomEnumerable(log).ToList(); for (int i = 1; i <= 2; i++) { log.AppendLine($"enumeration #{i}"); foreach (var number in enumerable) { log.AppendLine($"{number}"); } } ``` 現在,程式碼將只執行一次 - 在我們創建列表時,在迭代之前: ```shell before 1 before 2 before 3 before 4 before 5 before end enumeration #1 1 2 3 4 5 enumeration #2 1 2 3 4 5 ``` 當我們迭代的IEnumerable背後有緩慢的I/O操作時,這一點尤為重要。資料庫查詢也是一個典型的例子。 ## 結論 您對於此文章中的所有範例,其執行結果是否皆如您預期? 如果為否,您可能已經了解到,當您不完全確定特定功能的實現方式時,假設行為可能是危險的。 在一種語言中不可能知道並記下每一個特殊案例(single edge case),因此當您不確定您遇到的重要程式碼時,最好先查閱相關文件或自己先嘗試寫一段驗證程式。 更重要的是,這一切都是為了避免編寫可能會讓其他開發人員感到非預期結果的程式碼(或者您亦可能於往後遺忘其結果)。 嘗試以不同方式編寫它或傳遞該可選參數的默認值(如我們的Math.Round示例中)以使意圖更清晰。 如果上述事情窒礙難行,那麼,至少撰寫對應的測試案例以清楚交代該程式碼的預期行為! ## 附記 原文審查: + Yacoub Massad - 技術審查 [相關連結](http://www.dotnetcurry.com/author/yacoub-massad) 翻譯校對:
×
Sign in
Email
Password
Forgot password
or
By clicking below, you agree to our
terms of service
.
Sign in via Facebook
Sign in via Twitter
Sign in via GitHub
Sign in via Dropbox
Sign in with Wallet
Wallet (
)
Connect another wallet
New to HackMD?
Sign up