入参校验空值是一个非常基础的事儿。很多人看到这个问题,第一反应就是:这事儿也要讨论?大家不都这么写吗?
public void DoSomething(Foo foo)
{
if (foo == null)
{
throw new ArgumentNullException(nameof(foo));
}
}
很多 coding style 建议这样做,很多开源项目也是这样写的,甚至 VS 还提供了一键校验入参的快捷按钮可以自动生成一堆 if params == null throw new Exception
的代码。但是为什么呢?不校验入参会导致什么问题,校验入参又带来了什么好处?请看下面这个例子:
public void PrintCar(Car car)
{
if (car == null)
{
throw new ArgumentNullException(nameof(foo));
}
Console.WriteLine(car.Name);
}
在这个例子里,如果不校验入参,Console.WriteLine(car.Name);
会抛出 NullReferenceException
,如果校验入参,则会抛出 ArgumentNullException
。有人说,这解决了空指针异常的问题。其实仔细琢磨琢磨,并没有。你消灭了一个异常,又抛出了另一个异常,对于调用者来说还是要处理异常。虽然 NullReferenceException
数量看起来减少了,但是换来的是 ArgumentNullException
,同样是需要调用者来捕获并处理。本质上,异常数量并没有发生改变,只是名字变了而已。不喊伏地魔的名字并不能消灭伏地魔。
在上面的例子中,函数捕获到了可能发生的异常,却没有能力处理异常,只能继续往上抛异常,这种情形是否还有入参空值校验的必要?对于这个问题,不同人有不同看法。
比如 Why I Never Null-Check Parameters 这篇文章的作者就提出了反对的看法。他认为,空指针异常是软件中客观存在的问题,无法通过捕获的方式妥善处理并解决,如果捕获后直接上抛,只是换了一个 Exception 的名称而已,这样的问题还是需要捕获并且及时修正;如果捕获后只在非空的时候处理业务逻辑而不上抛,则会隐藏空值传入带来的潜在问题。
对于这个问题,个人觉得入参空值校验还是有必要的,基于以下两点理由:
NullReferenceException
和 ArgumentNullException
虽然看起来只是名字不一样,但是更加细分了 Exception 的责任方。ArgumentNullException
明确是调用者的问题,而 NullReferenceException
则可以明确是被调用者的问题。在后期 Debug 的时候会比较清晰。就像是 HTTP Status Code 中的 40x 和 50x 状态码一样,Bad Request 和 Server Error 是两种概念。public void CarCounter(Car car)
{
this.CarCount += 1;
this.CarNames.Add(car.Name);
}
方法的第二行发现了空指针异常,而在异常发生之前已经执行了一些状态变更的代码。在这种场景下,就会产生错误的脏数据。这个例子比较简单,我们可能做一个类似 try catch count-- 的逻辑就可以实现一个类似于会话回滚的机制。但是大部分场景是比较复杂的,而且对于发送通知这种无法回滚的逻辑而言,这会是巨大的灾难。
入参的空值校验,其实是为了确保方法本身的强异常安全(strong exception safety),即:运行可以是失败,但失败的运行保证不会有负效应,因此所有涉及的数据都保持代码运行前的初始值。
综上所述,在很多方法中,入参的空值校验是非常有必要的,可以更加细化 Exception 的分类,明确异常产生的责任方,且可以有效的避免脏数据情况的产生。这种防御式编程的思想,可以有效的提高我们的软件质量。
虽然入参的校验是有必要的,但是这并不代表我们应该在所有方法里都做入参空值校验。
对于 public 方法,我们不知道外部会传给我们什么样的参数,在进行业务逻辑之前先校验一下入参是否是空值,可以尽早规避程序中会遇到的空指针异常。
对于 private 方法,调用者就是我们自己,完全知道会传入什么值,空值校验的工作可以在入口处提前处理妥当,在这种情况下方法内的空值检测就没有什么太大的必要。
如果你是一名 C# 程序员,建议开启 CA1062 Warning。这样的话,我们就不用纠结什么时候该校验什么时候不该校验了,只需要把关注点放在『什么时候该 public 什么时候该 private』即可。
道理大家都知道,但是要真的在代码里写一堆 if == null throw new Exception
的冗余代码,还是一件非常恶心的事情。这种冗余代码会降低代码的可读性,无形中增加项目的复杂度。
在《C# Futures: Simplified Parameter Null Validation》中,作者畅谈了 C# 中入参空值处理的几种方案,比如 C# Proposal #2145 中的 Bang Operator: void Insert(string s!) {}
,比如新增一个 Attribute:void Insert([NotNull] string value)
,比如通过 Compiler Flag
来让编译器干这个事情。
在目前的几种方案中,Code Contract 的 attribute 语法最为优雅:
public static void CheckNotNull([ValidatedNotNullAttribute] this object value)
{
}
然而 Code Contract 本身已经处于一个 不再维护 的状态。
相比之下,封装一个 Helper 的方案最为稳健:
internal static class ThrowIf
{
public static class Argument
{
public static void IsNull(object argument, string argumentName)
{
if (argument == null)
{
throw new ArgumentNullException(argumentName);
}
}
}
}
public void DoSomething(Foo foo, Bar bar)
{
ThrowIf.Argument.IsNull(foo, "foo");
ThrowIf.Argument.IsNull(bar, "bar");
}
随着 C#7 引入了新的运算符 null-coalescing operator,我们也可以把以前的四行代码用 ??
放在一行里实现:
public void DoSomething(Foo foo, Bar bar)
{
_ = foo ?? throw new ArgumentNullException(nameof(foo));
_ = bar ?? throw new ArgumentNullException(nameof(bar));
}
如果有更好的最佳实践,欢迎评论区指点迷津。感恩。
参考资料