译 | 你到底有多精通 C# ?
點擊上方藍字關注“汪宇杰博客”
文:Damir Arh
譯:Edi Wang
即使是具有良好 C# 技能的開發人員有時候也會編寫可能會出現意外行為的代碼。本文介紹了屬于該類別的幾個 C# 代碼片段,并解釋了令人驚訝的行為背后的原因。
Null 值
我們都知道,如果處理不當,空值(null)可能是危險的。
使用一個空值對象(例如,在一個null對象上調用方法,或訪問它的一個屬性)會導致?NullReferenceException ,例如:
object nullValue = null;
bool areNullValuesEqual = nullValue.Equals(null);
為了安全起見,我們在使用引用類型之前需要確保它們不為 null 。如果不這樣做,可能會導致特定邊緣情況下的未處理異常。雖然這樣的錯誤偶爾會發生在每個人身上,但我們幾乎不能稱之為意外行為。
但是,下面的代碼呢?
string nullString = (string)null;
bool isStringType = nullString is string;
isStringType 的值是什么?顯式申明為字符串的變量是否也會在運行時作為字符串類型?
正確的答案是:否
null 值在運行時是沒有類型的
從某種程度上說,這也會影響反射。當然,您不能在空值上調用 GetType(),因為會引發空引用異常:
object nullValue = null;
Type nullType = nullValue.GetType();
接下來,我們看看可空的值類型
int intValue = 5;
Nullable<int> nullableIntValue = 5;
bool areTypesEqual = intValue.GetType() == nullableIntValue.GetType();
是否可以使用反射來區分可空值類型和不可空值類型?
答案是:不可以
上述代碼中的兩個變量返回相同的類型: System.Int32。不過,這并不意味著反射對 Nullable<T> 沒有表示。
Type intType = typeof(int);
Type nullableIntType = typeof(Nullable<int>);
bool areTypesEqual = intType == nullableIntType;
此代碼段中的類型是不同的。如預期的那樣,可空類型將用 System.Nullable'1[[System.Int32] 表示。只有在檢查值時,才會將值視為反射中的不可空值。
重載方法中的 null 值
在轉到其他話題之前,讓我們仔細了解在調用參數數量相同但類型不同的重載方法時如何處理空值。
private string OverloadedMethod(object arg)
{
????return "object parameter";
}
?
private string OverloadedMethod(string arg)
{
????return "string parameter ";
}
如果我們使用空(null)值調用這個方法,會發生什么情況?
var result = OverloadedMethod(null);
將調用哪個重載?還是代碼會因為方法調用不明確而無法編譯?
在這種情況下,代碼可以編譯,并調用具有字符串參數的方法。
通常,當一個參數類型可以轉換成一個參數類型 (即一個參數類型從另一個參數類型派生) 時,代碼可以編譯。將調用具有更具體參數類型的方法。
當這兩種類型之間不可以轉換時,代碼將不會編譯。
若要強制調用特定重載, 可以將空值強制轉換為該參數類型:
var result = parameteredMethod((object)null);
算術運算
我們大多數人并不經常使用位移位操作。
讓我們先刷新一下記憶。左移運算符 (<<) 將二進制表示向左移動給定數量的位置:
var shifted = 0b1 << 1; // = 0b10
同樣, 右移位運算符 (>>) 將二進制表示形式向右移動:
var shifted = 0b1 >> 1; // = 0b0
當這些位(bit)到達終點時,它們不會換行(wrap)。這就是為什么第二個表達式的結果是0。如果我們將位移動到足夠遠的左側 (32位, 因為整數是32位數字),也會發生同樣的情況:
var shifted = 0b1;
for (int i = 0; i < 32; i++)
{
????shifted = shifted << 1;
}
結果將再次為0。
但是, 位移位運算符具有第二個操作數。我們可以向左移動 32位,而不是向左移動1位32次,并獲得相同的結果。
var shifted = 0b1 << 32;
是這樣嗎?這是錯的!
此表達式的結果將是1。為什么?
因為這就是運算符的定義方式。在應用操作之前,第二個操作數將使用模數操作將被歸一操作的位長度規范化,即通過計算第二個操作數除以第一個操作數的位長度的剩余部分。
我們剛才看到的示例中的第一個操作數是32位數字,因此:32 % 32 = 0。我們的數字將向左移動0位。這和把它移1位32次是不一樣的。
讓我們繼續操作 & (和) | (或)。根據操作數的類型,它們表示兩種不同的操作:
對于布爾操作數,它們充當邏輯運算符,類似于 && 和 ||,有一個區別:它們是饑餓的(eager),即始終計算兩個操作數,即使在評估第一個操作數后就可以確定結果。
對于整數類型,它們充當邏輯按位運算符,通常用于表示 Flag 的枚舉類型。
[Flags]
private enum Colors
{
? ?None = 0b0,
? ?Red = 0b1,
? ?Green = 0b10,
? ?Blue = 0b100
}
| 運算符用于組合標志(Flag),& 運算符用于檢查是否設置了標志:
Colors color = Colors.Red | Colors.Green;
bool isRed = (color & Colors.Red) == Colors.Red;
在上面的代碼中,我在按位邏輯操作前后加上括號,以使代碼更加清晰。此表達式中是否需要括號?
事實證明,是的。
與算術運算符不同,按位邏輯運算符的優先級低于相等運算符。幸運的是,由于類型檢查,沒有括號的代碼將無法編譯。
從 .NET Framework 4.0 起,有一個更好的替代方法可用于檢查標志,您應該始終使用它,而不是 & 運算符:
bool isRed = color.HasFlag(Colors.Red);
Math.Round()
我們以Round為例繼續聊算術運算操作。它如何在兩個整數值 (例如 1.5) 之間的中點舍入值?向上還是向下?
var rounded = Math.Round(1.5);
如果你預測是2,你是對的。結果將是2。這是一般規則嗎?
var rounded = Math.Round(2.5);
不。結果將再次為2。默認情況下,中點值將Round到最接近的偶數值。您可以為方法提供第二個參數,以顯式請求此類行為:
var rounded = Math.Round(2.5, MidpointRounding.ToEven);
可以使用第二個參數的不同值更改行為:
var rounded = Math.Round(2.5, MidpointRounding.AwayFromZero);
有了這個明確的規則,正值現在總是向上舍入。
舍入數字也會受到浮點數精度的影響。
var value = 1.4f;
var rounded = Math.Round(value + 0.1f);
雖然中點值應舍入到最接近的偶數,即 2,但在這種情況下,結果將是 1,因為對于單精度浮點數,0.1 沒有精確的表示形式,計算的數字實際上將小于 1.5 并因此Round到1。
盡管在使用雙精度浮點數時沒有出現此特定問題,但舍入錯誤仍可能發生,盡管頻率較低。因此,在要求最大精度時,應始終使用小數而不是浮動或雙精度。
類初始化
最佳實踐建議盡可能避免類構造函數中的類初始化,以防止異常。
所有這些對于靜態構造函數來說都更加重要。
您可能知道,當我們嘗試在運行時實例化靜態構造函數時,它在實例構造函數之前調用。
這是實例化任何類時的初始化順序:
靜態字段 (僅限第一次類訪問: 靜態成員或第一個實例)
靜態構造函數 (僅限第一次類訪問: 靜態成員或第一個實例)
實例字段 (每個實例)
實例構造函數 (每個實例)
讓我們創建一個具有靜態構造函數的類,可以將其配置為引發異常:
public static class Config
{
? ?public static bool ThrowException { get; set; } = true;
}
public class FailingClass
{
? ?static FailingClass()
? ?{
? ? ? ?if (Config.ThrowException)
? ? ? ?{
? ? ? ? ? ?throw new InvalidOperationException();
? ? ? ?}
? ?}
}
創建此類實例的任何嘗試都會導致異常,這不應該讓人感到意外:
var instance = new FailingClass();
但是,它不會是?InvalidOperationException?。運行時將自動將其包裝到?TypeInitializationException?中。如果要捕獲異常并從中恢復,這是需要注意的重要詳細信息。
try
{
? ?var failedInstance = new FailingClass();
}
catch (TypeInitializationException) { }
Config.ThrowException = false;
var instance = new FailingClass();
應用我們所學到的知識,上面的代碼應該捕獲靜態構造函數引發的異常,更改配置以避免在以后的調用中引發異常,最后成功地創建類的實例,對嗎?
不幸的是,不對。
類的靜態構造函數只調用一次。如果它引發異常,則每當您要創建實例或以任何其他方式訪問類時,都將重新引發此異常。
在重新啟動進程 (或應用程序域) 之前,該類實際上無法使用。是的,即使靜態構造函數引發異常的可能性很小,也是一個非常糟糕的想法。
派生類中的初始化順序
對于派生類,初始化順序更加復雜。在邊緣情況下,這可能會給你帶來麻煩。是時候做一個人為的例子了:
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);
? ?}
}
你能在衍生類中發現一個問題嗎?當我嘗試實例化它時, 會發生什么?
var instance = new DerivedClass();
將引發一個 DivideByZeroException?。為什么?
原因是派生類的初始化順序:
首先,實例字段按從派生最遠的到基類的順序進行初始化。
其次,構造函數按從基類到派生最遠的類的順序調用。
由于在整個初始化過程中,該類被視為 DerivedClass,我們在 BaseClass 構造函數中調用 VirtualMethod 這個方法的實現其實是 DerivedClass 里的實現,這時候DerivedClass 的構造函數還沒機會初始化 divisor 字段。這意味著該值仍然為 0,這導致了DivideByZeroException。
在我們的示例中,可以通過直接初始化除數字段而不是在構造函數中來解決此問題。
然而,該示例說明了為什么從構造函數調用虛擬方法可能很危險。當調用它們時,它們在中定義的類的構造函數可能尚未調用,因此它們可能會出現意外行為。
多態性
多態性是不同類以不同的方式實現相同接口的能力。
不過,我們通常期望單個實例始終使用相同的方法實現,無論它是由哪個類型強制轉換的。這樣就可以將集合作為基類,并在集合中的所有實例上調用特定方法,從而為要調用的每個類型實現特定的方法。
話雖如此,但當我們在調用該方法之前向下轉換實例時,你能想出一種方法來調用不同的方法嗎?(即打破多態行為)
var instance = new DerivedClass();
var result = instance.Method(); // -> Method in DerivedClass
result = ((BaseClass)instance).Method(); // -> Method in BaseClass
正確的答案是: 通過使用 new 修飾符。
public class BaseClass
{
????public virtual string Method()
????{
????????return "Method in BaseClass ";
????}
}
?
public class DerivedClass : BaseClass
{
????public new string Method()
????{
????????return "Method in DerivedClass";
????}
}
這將從其基類中隱藏 DerivedClass.Method,因此在將實例轉換為基類時調用 BaseClass.Method。
這適用于基類,基類可以有自己的方法實現。對于不能包含自己的方法實現的接口,你能想出一個實現相同目標的方法嗎?
var instance = new DerivedClass();
var result = instance.Method(); // -> Method in DerivedClass
result = ((IInterface)instance).Method(); // -> Method belonging to IInterface
它是顯式接口實現
public interface IInterface
{
????string Method();
}
public class DerivedClass : IInterface
{
????public string Method()
????{
????????return "Method in DerivedClass";
????}
?
????string IInterface.Method()
????{
????????return "Method belonging to IInterface";
????}
}
它通常用于向實現它的類的使用者隱藏接口方法,除非他們將實例轉換到該接口。但是,如果我們希望在單個類中具有兩個不同的方法實現,它的效果也一樣好。不過,很難想出做這件事的好理由。
迭代器
迭代器是用于單步執行構造集合的結構,通常使用 foreach 語句。它們由 IEnumerable<T> 類型表示。
雖然它們很容易使用,但由于一些編譯器的魔力,如果我們不能很好地理解內部工作原理,我們很快就會陷入不正確用法的陷阱。
讓我們看一下這樣的例子。我們將調用一個方法,該方法從 using 內部返回一個 IEnumerable:
private IEnumerable<int> GetEnumerable(StringBuilder log)
{
????using (var context = new Context(log))
????{
????????return Enumerable.Range(1, 5);
????}
}
當然,Context 類型實現了?IDisposable。它將向日志寫入一條消息, 以指示何時輸入和退出其作用域。在實際代碼中, 此上下文可以被數據庫連接所取代。在它里面, 將以流式的方式從返回的結果集中讀取行。
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 循環:
var log = new StringBuilder();
foreach (var number in GetEnumerable(log))
{
????log.AppendLine($"{number}");
}
代碼執行后,日志的內容將是什么?返回的值是否會在上下文創建和處置之間列出?
不,他們不會:
Context created
Context disposed
1
2
3
4
5
這意味著,在我們的實際數據庫示例中,代碼將失敗--在從數據庫中讀取值之前,連接將被關閉。
我們如何修復代碼,以便只有在所有值都已迭代后才會釋放上下文?
執行此操作的唯一方法是循環訪問已在 GetEnumerable 方法中的集合:
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 created
1
2
3
4
5
Context disposed
如果您不熟悉 yield return 語句,它是用于創建狀態機的語法糖,允許以增量方式執行使用它的方法中的代碼,因為生成的 IEnumerable 正在被迭代。
這可以用下面的方法更好地解釋:
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");
}
若要查看這段代碼的行為,我們可以使用以下代碼對其進行循環訪問:
var log = new StringBuilder();
log.AppendLine("before enumeration");
foreach (var number in GetCustomEnumerable(log))
{
????log.AppendLine($"{number}");
}
log.AppendLine("after enumeration");
讓我們看看代碼執行后的日志內容:
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 迭代時,都會執行此代碼:
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}");
????}
}
執行此代碼后,日志將具有以下內容:
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 的結果存儲到本地集合 (例如, list) 中,如果我們計劃多次使用它,則從那里讀取它:
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}");
????}
}
現在,代碼將只執行一次--在我們創建列表時,然后再對其進行迭代:
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 操作時,這一點尤其重要。數據庫訪問也是一個典型的例子。
結論
您是否正確地預測了文章中所有示例的行為?
如果沒有,您可能已經了解到,當您不能完全確定特定功能是如何實現的時,采取行為可能是危險的。不可能知道并記住一種語言中的每一個邊緣案例,因此,當您對遇到的一段重要代碼不確定時,最好檢查文檔或自己先嘗試一下。
更重要的是,這其中的任何一項都是為了避免編寫可能會讓其他開發人員感到驚訝的代碼 (或者在經過一定時間后甚至可能是您)。嘗試以不同的方式編寫它或傳遞該可選參數的默認值 (如我們的 Math.Round 中的示例),以使意圖更清晰。
如果這行不通,就寫測試方法。他們將清楚地記錄預期的行為!
你能正確地預測哪些?在評論中讓我們知道吧。
Yacoub Masd 對該文章進行了技術審查。
Suprotim Agarwal 對本文進行了編輯審查。
總結
以上是生活随笔為你收集整理的译 | 你到底有多精通 C# ?的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: iNeuOS云操作系统,.NET Cor
- 下一篇: [开源] FreeSql AOP 功能模