检查空参数的最佳方法(守卫条款)

58
例如,通常情况下我们不希望构造函数中的参数为空,所以很正常会看到类似以下的写法:
if (someArg == null)
{
    throw new ArgumentNullException(nameof(someArg));
}

if (otherArg == null)
{
    throw new ArgumentNullException(nameof(otherArg));
}

这会使代码有点混乱。

有没有比这更好的方法来检查参数列表中的参数?

比如说,“检查所有参数,如果其中任何一个为 null,则抛出 ArgumentNullException,并提供那些为空的参数。”

顺便说一下,关于重复问题的声明,这不是关于使用属性或内置功能标记参数,而是所谓的 Guard Clauses,以确保对象接收到初始化的依赖项。


1
也许将它们放在一个对象数组中,然后使用foreach循环迭代它们?你需要这样的东西吗? - JoJo
我们通常在方法的开头检查参数,就像你的代码片段一样。不仅要检查 null,还要检查其他业务逻辑行为。只要参数不太多,我认为这样做没有问题。至少你可以轻松地阅读方法的要求。 - Andre
1
@JoJo 那是一个糟糕的想法。你不想让一个旨在接受特定数量和类型的对象作为参数的方法,仅仅为了轻松检查它们是否为空而接受未知数量和类型的对象。你正在通过创造更大的问题来解决一个问题。 - Daniel Mann
@DanielMann,你还能做些什么来缩短它?我只是举了一个例子。 - JoJo
12个回答

72

使用更新的 C# 语言版本,您可以在不需要额外库或方法调用的情况下编写此代码:

_ = someArg ?? throw new ArgumentNullException(nameof(someArg));
_ = otherArg ?? throw new ArgumentNullException(nameof(otherArg));

从.NET6开始,您还可以这样编写:

ArgumentNullException.ThrowIfNull(someArg);

.NET7开始,您可以处理空字符串和null检查:

ArgumentException.ThrowIfNullOrEmpty(someStringArg);

12
如果您还没有遇到discards,并且像我一样对下划线感到疑惑。该功能是C# 7.0的一个新特性,它允许我们在不使用变量的情况下忽略方法返回的某些值或属性。 - Daniel
2
在即将到来的.NET6中,有一种新的更短的方式:
ArgumentNullException.ThrowIfNull(someArg);
(https://dev59.com/0V4b5IYBdhLWcg3waA1P#69836300)
- user2315856

34
public static class Ensure
{
    /// <summary>
    /// Ensures that the specified argument is not null.
    /// </summary>
    /// <param name="argumentName">Name of the argument.</param>
    /// <param name="argument">The argument.</param>
    [DebuggerStepThrough]
    [ContractAnnotation("halt <= argument:null")]        
    public static void ArgumentNotNull(object argument, [InvokerParameterName] string argumentName)
    {
        if (argument == null)
        {
            throw new ArgumentNullException(argumentName);
        }
    }
}

使用方法:

// C# < 6
public Constructor([NotNull] object foo)
{
    Ensure.ArgumentNotNull(foo, "foo");
    ...
}

// C# >= 6
public Constructor([NotNull] object bar)
{
    Ensure.ArgumentNotNull(bar, nameof(bar));
    ...
}

DebuggerStepThroughAttribute很方便,这样在调试时发生异常(或者在异常发生之后我附加调试器时),我就不会进入ArgumentNotNull方法内部,而是进入实际发生空引用的调用方法。

我正在使用ReSharper Contract Annotations

  • ContractAnnotationAttribute确保我不会拼错参数("foo")并在我重命名foo符号时自动重命名它。
  • NotNullAttribute帮助ReSharper进行代码分析。因此,如果我执行new Constructor(null),ReSharper将向我发出警告,指出这将导致异常。
  • 如果您不喜欢直接注释代码,您也可以通过外部XML文件执行相同的操作,您可以将其与库一起部署,并且用户可以选择在他们的ReSharper中引用它。

你知道如何将这段代码重写为Code Contract,而不是使用ResharperAnnotation吗?这是否可行?或者我理解有误。提前感谢您的任何帮助。 - Alexcei Shmakov
代码合约基本上已经“死亡”(https://github.com/dotnet/corefx/issues/24681#issuecomment-337765314)。我不再使用它们了。如果您正在使用.NET Core或计划切换到它,我建议使用新的可空引用类型(https://learn.microsoft.com/en-us/dotnet/csharp/nullable-references)功能。 - bitbonk
在LinksPlatform上,我们有Platform.Exceptions库,其中包含单行检查器方法的类似实现。如果您需要额外的检查,您可以使用扩展方法扩展我们的实现,或者要求我们将这些检查添加到我们的库中。 - Konard

16

如果您的构造函数中有太多参数,最好对它们进行修改,但那是另一回事。

为了减少样板验证代码,许多人编写像这样的 Guard 实用程序类:

public static class Guard
{
    public static void ThrowIfNull(object argumentValue, string argumentName)
    {
        if (argumentValue == null)
        {
            throw new ArgumentNullException(argumentName);
        }
    }

    // other validation methods
}

你可以在那个守卫类中添加其他必要的验证方法。

因此,只需一行代码即可验证一个参数:

    private static void Foo(object obj)
    {
        Guard.ThrowIfNull(obj, "obj");
    }

15
只是一个小提示,你也可以使用 nameof 运算符而不是硬编码参数的名称,例如 Guard.ThrowIfNull(obj, nameof(obj)) - Daniel Smith
@DanielSmith,你觉得把 nameof(obj) 移到 ThrowIfNull 方法内部并且只有一个参数会有什么问题吗? - aufty
1
@aufty 因为 nameof(obj) 只会返回 "obj"。Daniel 的意思是使用 nameof(howeverYourParameterIsNamed)。在 ThrowIfNull 中,你必须写 nameof(argumentValue)(或者你选择的参数名称),这样你就总是会得到 NullReferenceException: argumentValue was null... 这并不是很有启发性。 - Vector Sigma

7

空引用是你必须防范的一种问题。但它们并不是唯一的问题。问题更广泛,核心是:该方法接受某个类型的实例,但不能处理所有的实例。

换言之,方法的领域比其处理的值集更大。为了确定实际参数不落入无法处理的方法领域的“灰色区域”,使用保护条款。

现在我们有了空引用作为通常不可接受的值之外的值。另一方面,经常发生一些非空元素也是不可接受的(例如空字符串)。

在这种情况下,可能会发现方法签名过于宽泛,从而表明存在设计问题。这可能导致重新设计,例如定义子类型(通常是派生接口),限制方法领域并使一些保护条款消失。您可以在本文中找到示例:为什么我们需要保护条款?


我同意你在这里所说的,但我不知道如何将其应用于所述问题,以便在编译时禁止空值,因为C#中的引用类型始终可为空。 - Tim Abell
1
@TimAbell OP并没有要求这个;不管怎样,C# 8现在引入了可空引用类型,这样你就可以强制编译器报告所有可疑的引用访问,这些引用可能为空。另一种可能是依赖于可选对象,但这在C#中并不被原生支持。 - Zoran Horvat

7

6

Ardalis有一个非常优秀的GuardClauses库。

使用Guard.Against.Null(message, nameof(message));很方便。


5

!! 操作符(实际上不是 C# 的一部分,但很有趣的故事)

微软尝试在 C# 10 中引入一个被称为 参数 null 检查 或者叫做 叹号操作符 的新语言特性,后来又在 C# 11 中引入了这个特性,但最终决定不发布它

这将会是最简洁的方法(远远胜过其他方式):只需要在要检查为空的参数后面加上两个感叹号 !!

之前:

public void test(string someArg){
    if (someArg == null)
    {
        throw new ArgumentNullException(nameof(someArg));
    }
    // other code
}

有了这个新操作符:

public void test(string someArg!!){
    // other code
}

调用test(null)会导致ArgumentNullException,告诉您someArgnull

微软在2021年初提到了它,但它没有成为C# 10的一部分:Microsoft Developer YouTube频道上介绍新功能的视频

该功能于2022年2月实现于C#11中,请参见GitHub

后来因为有很多批评意见,该功能被删除,请看Microsoft开发博客,批评者认为感叹号操作符非常喧闹

C#语言的开发人员认为,虽然这样的功能很受欢迎,但其受欢迎程度不如以往,并且这个功能可能会在以后的C#版本中推出



似乎带有 !! 的这个特性没有被包含在 C# 10 中。 - Aleksei Mialkin
他们的论点是,这很喧闹。我把这个加到了答案里。 - ndsvw

5
在C# 8.0及更高版本中,有新的帮助功能可用。 C# 8.0引入了非可空参考类型(在文档中有点令人困惑地称为“可空参考类型”)。 在C# 8.0之前,所有引用类型都可以设置为null。 但是现在,通过使用C# 8.0和“可空性”项目设置,我们可以说引用类型默认为非可空的,然后根据需要逐个案例使变量和参数可空。
因此,当前我们认识到这样的代码:
```csharp string myString = null; ``` 应该被重写为:
```csharp string? myString = null; ```
这告诉编译器,“myString”是一个可空的引用类型。 如果将它设置为null,也就不会报错了。
public void MyFunction(int thisCannotBeNull, int? thisCouldBeNull)
{
    // no need for checking my thisCannotBeNull parameter for null here
}

如果您在C# v8.0+项目中设置了<Nullable>enable</Nullable>,您也可以进行以下操作:
public void MyFunction(MyClass thisCannotBeNull, MyClass? thisCouldBeNull)
{
    // static code analysis at compile time checks to see if thisCannotBeNull could be null
}

使用静态代码分析,可以在编译时进行空指针检查。如果您编写的代码可能导致出现空指针,您将收到编译器警告(如果需要,可以将其升级为错误)。因此,在很多情况下(但不是全部情况),您需要在运行时检查空参数的情况可以基于您的代码进行编译时检查。


3
你可以尝试使用我的Heleonix.Guard库,它提供了守护功能。你可以像下面这样编写守护条款:
// C# 7.2+: Non-Trailing named arguments
Throw.ArgumentNullException(when: param.IsNull(), nameof(param));

// OR

// Prior to C# 7.2: You can use a helper method 'When'
Throw.ArgumentNullException(When(param.IsNull()), nameof(param));

// OR
Throw.ArgumentNullException(param == null, nameof(param));

// OR
Throw.ArgumentNullException(When (param == null), nameof(param));

它提供抛出许多现有异常的功能,您可以为自定义异常编写自定义扩展方法。此外,该库还引用了“Heleonix.Extensions”库中的预测性扩展,如IsNullIsNullOrEmptyOrWhitespaceIsLessThan等等,以检查您的参数或变量是否符合所需值。与某些具有流畅接口的警卫库不同,这些扩展程序不会生成中间对象,并且由于实现非常简单,它们具有良好的性能。

我想问一下,所有的参数都会被计算,即使条件为假,这种说法是正确的吗?对于问题中的简单情况来说,这并不是一个问题,但是当用户在其他上下文中使用该库时,他们需要注意这一点。 - Toby Speight
每个参数都应该有单独的检查。例如,在问题的情况下,代码如下:`Throw.ArgumentNullException(when: someArg.IsNull(), nameof(someArg)); Throw.ArgumentNullException(when: otherArg.IsNull(), nameof(otherArg));`如果第一个表达式 someArg.IsNull() 的评估结果为 true,那么就会抛出异常,因此第二个表达式 otherArg.IsNull() 就不会被评估等。 - Hennadii
我的意思是nameof(someArg)总是被评估,即使someArg.IsNull()返回false,不像if/then结构。在这种情况下,这可能是可以的,但如果我们有一些具有副作用(包括时间效应)的东西,而不是一个简单的nameof(),那么这可能是不可取的。 - Toby Speight
没错。这就是为什么抛出异常应该像一个简单的单行表达式一样。如果有一些多行逻辑,特别是带有副作用的逻辑,最好使用常规的 if 语句,因为那个逻辑会更易读(对我来说,我会将其视为您算法的一部分)。 - Hennadii

2

我发现最简单的方法受到Dapper使用匿名类型的启发。 我编写了一个Guard类,使用匿名类型获取属性名称。 守卫本身如下所示

    public class Guard
    {
        public static void ThrowIfNull(object param) 
        {
            var props = param.GetType().GetProperties();
            foreach (var prop in props) 
            {
                var name = prop.Name;
                var val = prop.GetValue(param, null);

                _ = val ?? throw new ArgumentNullException(name);
            }
        }
    }

你可以这样使用它。
...
        public void Method(string someValue, string otherValue)
        {
            Guard.ThrowIfNull(new { someValue, otherValue }); 
        }
...

当抛出ArgumentNullException时,会使用反射来确定属性的名称,然后在异常中显示该名称。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接