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