入参空值校验的原因与方法

入参校验空值是一个非常基础的事儿。很多人看到这个问题,第一反应就是:这事儿也要讨论?大家不都这么写吗?

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 的名称而已,这样的问题还是需要捕获并且及时修正;如果捕获后只在非空的时候处理业务逻辑而不上抛,则会隐藏空值传入带来的潜在问题。

对于这个问题,个人觉得入参空值校验还是有必要的,基于以下两点理由:

  • 通过入参空值校验,NullReferenceExceptionArgumentNullException 虽然看起来只是名字不一样,但是更加细分了 Exception 的责任方。ArgumentNullException 明确是调用者的问题,而 NullReferenceException 则可以明确是被调用者的问题。在后期 Debug 的时候会比较清晰。就像是 HTTP Status Code 中的 40x 和 50x 状态码一样,Bad Request 和 Server Error 是两种概念。
  • 有一些代码逻辑并不是 stateless 的,在这样的方法中如果中途出现异常会导致脏数据的出现,且没有数据库的那种回滚机制,脏数据无法处理。比如以下代码:
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));
}

如果有更好的最佳实践,欢迎评论区指点迷津。感恩。


参考资料