点击上方蓝字关注“汪宇杰博客”
文: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 对本文进行了编辑审查。