如果函数必须在执行预期的行为之前执行null检查,这是不好的设计吗?


67

所以我不知道这是好是坏的代码设计,所以我想我最好问一下。

我经常创建用于处理涉及类的数据的方法,并且经常对方法进行大量检查,以确保事先没有空引用或其他错误。

一个非常基本的例子:

// fields and properties
private Entity _someEntity;
public Entity SomeEntity => _someEntity;

public void AssignEntity(Entity entity){
    _someEntity = entity;
}
public void SetName(string name)
{
    if (_someEntity == null) return; //check to avoid null ref
    _someEntity.Name = name;
    label.SetText(_someEntity.Name);
}

因此,您可以看到im每次都检查null。但是该方法应该没有此检查吗?

例如,外部代码应事先清除数据,这样方法就不需要进行如下验证:

 if(entity != null) // this makes the null checks redundant in the methods
 {
     Manager.AssignEntity(entity);
     Manager.SetName("Test");
 }

总之,方法应该是“数据验证”,然后对数据进行处理,还是应该在调用方法之前对其进行保证,如果在调用方法之前未能验证,则应该抛出错误(或捕获错误)。错误)?


7
当有人无法执行空检查而SetName仍然引发异常时,会发生什么?
罗伯特·哈维

5
如果我实际上希望 someEntity为Null,会发生什么?您确定所需的内容,someEntity可以为Null,也可以不为null,然后相应地编写代码。如果您不希望它为Null,则可以用assert代替检查。
gnasher729

5
您可以观看Gary Bernhardt的Boundaries谈论的内容,即在外壳中清理数据(即检查是否为空等)与拥有不必关心这些内容的内部核心系统之间进行明确的区分-只需进行操作即可工作。
mgarciaisaia

1
用C#8,你将有可能永远不需要担心与正确的设置空引用异常开启:blogs.msdn.microsoft.com/dotnet/2017/11/15/...
JᴀʏMᴇᴇ

3
这是C#语言团队想要引入可为空的引用类型(和非为可为空的引用类型)的原因之一-> github.com/dotnet/csharplang/issues/36
Mafii,

Answers:


168

基本示例的问题不是null检查,而是无声的fail

空指针/引用错误通常是程序员错误。程序员错误通常最好通过立即大声地失败来解决。在这种情况下,您可以采用三种一般方法来解决此问题:

  1. 不要打扰检查,只需让运行时抛出nullpointer异常即可。
  2. 检查并抛出比基本NPE消息更好的消息。

第三种解决方案是更多的工作,但功能更强大:

  1. 构造您的类,以使几乎不可能_someEntity永远处于无效状态。做到这一点的一种方法是摆脱AssignEntity它,并要求它作为实例化的参数。其他依赖注入技术也可​​能有用。

在某些情况下,在执行任何工作之前检查所有函数参数的有效性是有意义的,而在其他情况下,告诉调用者负责确保其输入有效,而不检查是有意义的。您所涉足的范围取决于您的问题领域。第三种选择具有显着的好处,即可以在一定程度上使编译器强制调用者正确执行所有操作。

如果第三个选项不是一个选项,那么在我看来,只要它不会默默地失败,它就没有关系。如果不检查null会导致程序立即爆炸,那么很好,但是如果它悄悄地破坏数据以至于造成麻烦,那么最好在那儿进行检查和处理。


10
@aroth:任何事物的任何设计(包括软件)都将受到限制。设计的真正目的是选择这些约束的去向,这不是我的责任,也不应该是我接受任何输入并期望对此做一些有用的事情。我确实知道null是错误的还是无效的,因为在我的假设库中,我定义了数据类型,并且定义了什么是有效的,什么不是。您称其为“强制性假设”,但您猜猜是什么呢?这就是现有的每个软件库所要做的。在某些情况下,null是有效值,但并非总是如此。
whatsisname

4
您的代码被破坏,因为它无法null正确处理” –这是对正确处理方法的误解。对于大多数Java程序员而言,这意味着对任何无效输入都抛出异常。使用时@ParametersAreNonnullByDefault,也意味着每一个null,除非将参数标记为@Nullable。做其他任何事情都意味着要延长路径,直到它最终吹到其他地方为止,从而也延长了浪费在调试上的时间。好吧,通过忽略问题,您可以实现它永远不会崩溃,但是可以保证错误行为。
maaartinus

4
@aroth亲爱的勋爵,我不愿意使用您编写的任何代码。爆炸比做一些无意义的事情要好得多,这是无效输入的唯一其他选择。当方法无法猜测我的意图时,异常迫使我(调用方)决定在这些情况下应该采取的正确措施。被检查的异常和不必要的扩展Exception是完全无关的辩论。(我都认为这很麻烦。)对于这个特定的示例,null如果类的目的是在对象上调用方法,那么显然在这里没有意义。
jpmc26

3
“您的代码不应将假设强加给调用者的世界” –无法将假设强加给调用者是不可能的。这就是为什么我们需要使这些假设尽可能明确。
Diogo Kollross

3
@aroth 您的代码已损坏,因为当它应传递具有Name作为属性的内容时,将我的代码传递给null。除了爆炸以外的任何行为都是动态键入恕我直言的某种后门怪异世界版本。
mbrig

56

由于_someEntity可以在任何阶段进行修改,因此在每次SetName调用时对其进行测试都是有意义的。毕竟,自上次调用该方法以来,它可能已经发生了变化。但是请注意,其中的代码SetName不是线程安全的,因此您可以在一个线程中执行该检查,然后将其_someEntity设置为null另一线程,然后该代码NullReferenceException仍然会阻塞。

因此,解决此问题的一种方法是提高防御能力,并执行以下任一操作:

  1. _someEntity通过该方法制作本地副本并引用该副本,
  2. 使用锁_someEntity来确保方法执行时它不会改变,
  3. 使用try/catchNullReferenceException对方法中发生的任何操作执行与您在初始null检查中执行的操作相同的操作(在这种情况下为nop操作)。

但是,您真正应该做的是停下来问自己一个问题:您实际上是否需要允许_someEntity被覆盖?为什么不通过构造函数设置一次。这样,您只需要对null进行一次检查即可:

private readonly Entity _someEntity;

public Constructor(Entity someEntity)
{
    if (someEntity == null) throw new ArgumentNullException(nameof(someEntity));
    _someEntity = someEntity;
}

public void SetName(string name)
{
    _someEntity.Name = name;
    label.SetText(_someEntity.Name);
}

如果可能的话,这是我建议在这种情况下采取的方法。如果在选择如何处理可能的null时不注意线程安全性。

值得一提的是,我觉得您的代码很奇怪,可以改进。所有的:

private Entity _someEntity;
public Entity SomeEntity => _someEntity;

public void AssignEntity(Entity entity){
    _someEntity = entity;
}

可以替换为:

public Entity SomeEntity { get; set; }

它具有相同的功能,但可以通过属性设置器(而不是使用其他名称的方法)设置字段。


5
为什么这会回答问题?这个问题解决了空检查,这几乎是一个代码审查。
IEatBagels '18年

17
@TopinFrassi它说明了当前的null检查方法不是线程安全的。然后,它涉及其他防御性编程技术来解决此问题。然后,它总结了使用不变性的建议,以避免首先需要进行防御性编程。另外,它还提供有关如何更好地构造代码的建议。
David Arno

23

但是该方法应该没有此检查吗?

这是您的选择。

通过创建一种public方法,您可以为公众提供调用它的机会。这总是伴随着关于如何调用该方法以及执行该操作的隐含约定。该合同可能(也可能不)包括“ 是的,如果您将其null作为参数值传递,它将在您的面前爆炸。 ” 该合同的其他部分可能是“ 哦,顺便说一句:每当两个线程同时执行此方法时,一只小猫就死了 ”。

您要设计哪种类型的合同由您决定。重要的是

  • 创建一个有用且一致的API

  • 很好地记录下来,尤其是对于那些极端情况。

您的方法被调用的可能情况是什么?


好吧,当存在并发访问时,线程安全是例外,而不是规则。因此,已记录使用隐藏/全局状态,并且无论如何并发都是安全的。
Deduplicator

14

其他答案很好。我想通过对可访问性修饰符发表一些评论来扩展它们。首先,如果null永远无效,该怎么办:

  • 公共方法和受保护方法是您不控制调用者的方法。当传递null时,它们应该抛出。这样一来,您就可以训练您的呼叫者从不向您传递null,因为他们希望他们的程序不会崩溃。

  • 内部私有方法是那些你控制调用者。他们应该在传递null时断言;它们可能稍后会崩溃,但是至少您首先获得了断言,并且有机会闯入调试器。同样,您想训练呼叫者正确呼叫您的方法,以使其在不受到呼叫时受到伤害。

现在,如果传入null 可能有效,该怎么办?我将使用null对象模式避免这种情况。也就是说:制作该类型的特殊实例,并要求在需要无效对象的任何时候使用它。现在您又回到了对null的每次使用都进行抛出/声明的令人愉快的世界。

例如,您不想处于这种情况:

class Person { ... }
...
class ExpenseReport { 
  public void SubmitReport(Person approver) { 
    if (approver == null) ... do something ...

并在呼叫站点上:

// I guess this works but I don't know what it means
expenses.SubmitReport(null); 

因为(1)您正在培训呼叫者说null是一个好值,并且(2)尚不清楚该值的语义是什么。而是这样做:

class ExpenseReport { 
  public static readonly Person ApproverNotRequired = new Person();
  public static readonly Person ApproverUnknown = new Person();
  ...
  public void SubmitReport(Person approver) { 
    if (approver == null) throw ...
    if (approver == ApproverNotRequired) ... do something ...
    if (approver == ApproverUnknown) ... do something ...

现在,呼叫站点如下所示:

expenses.SubmitReport(ExpenseReport.ApproverNotRequired);

现在,代码的读者知道了它的含义。


更好的是,if对于空对象没有s 链,而是使用多态性使它们正确运行。
康拉德·鲁道夫

@KonradRudolph:确实,这通常是个好技术。我喜欢将其用于数据结构,在其中创建一个EmptyQueue在出队时抛出的类型,依此类推。
埃里克·利珀特

@EricLippert-原谅我,因为我还在这里学习,但是假设Person该类是不可变的,并且它不包含零参数构造函数,您将如何构造您的 ApproverNotRequiredor ApproverUnknown实例?换句话说,您将通过构造函数哪些值?

2
我喜欢在SE中碰到您的答案/评论,他们总是很有见地。我一看到对内部/私有方法的断言,便向下滚动,期望它是Eric Lippert的答案,这并不令人失望:)
bob esponja

4
你们对我给的具体例子有过多的思考。旨在快速了解空对象模式的想法。它并不是要成为纸张和工资自动化系统中良好设计的示例。在真实的系统中,使“审批者”具有“人”类型可能是一个坏主意,因为它与业务流程不匹配;可能没有批准者,您可能需要多个批准者,依此类推。正如我在回答该领域的问题时经常提到的那样,我们真正想要的是一个策略对象。不要挂在这个例子上。
埃里克·利珀特

8

其他答案指出,可以清理您的代码,而无需在有代码的地方进行空检查,但是,对于可以考虑以下示例代码的有用的空检查的一般性回答,请执行以下操作:

public static class Foo {
    public static void Frob(Bar a, Bar b) {
        if (a == null) throw new ArgumentNullException(nameof(a));
        if (b == null) throw new ArgumentNullException(nameof(b));

        a.Something = b.Something;
    }
}

这为您提供了维护方面的优势。如果您的代码中发生过任何缺陷,即某些东西将空对象传递给方法,则您将从方法中获得有关哪个参数为空的有用信息。如果不进行检查,您将在行上获得NullReferenceException:

a.Something = b.Something;

并且(假设Something属性是值类型)a或b都可以为null,并且您将无法从堆栈跟踪中知道哪个。通过检查,您将确切知道哪个对象为空,这在尝试取消缺陷时非常有用。

我会注意到您原始代码中的模式:

if (x == null) return;

可以在某些情况下使用它,它(可能会更频繁地)为您提供一种很好的掩盖代码库中潜在问题的方法。


4

完全没有,但这要视情况而定。

如果您要对此进行反应,则仅执行空引用检查。

如果执行空引用检查抛出已检查的异常,则将迫使该方法的用户对此作出反应并允许他进行恢复。该程序仍按预期运行。

如果不执行空引用检查(或引发未检查的异常),则可能会引发unchecked NullReferenceException,这通常不是由方法的用户处理的,甚至可能会关闭应用程序。这意味着该功能显然被完全误解了,因此应用程序出现了故障。


4

@null答案的扩展名,但是根据我的经验,无论如何都执行null检查。

好的防御性编程是一种态度,您应该假设整个程序的其余部分都在试图使其崩溃,因此您应单独编码当前例程。当您编写不允许发生故障的关键任务代码时,这几乎是唯一的选择。

现在,您如何实际处理一个null值,这在很大程度上取决于您的用例。

如果null是一个可接受的值,例如MSVC例程_splitpath()的第二个到第五个参数中的任何一个,则静默处理它是正确的行为。

如果null在调用有问题的例程时永远不会发生(即在OP的情况下,API合同要求AssignEntity()已被有效调用),Entity则将引发异常,否则,将导致您在处理过程中产生大量噪音。

不必担心空检查的成本,如果确实发生空检查,它们将非常便宜。对于现代编译器,如果编译器确定不会发生静态代码分析,则静态代码分析可以并且将完全淘汰它们。


或完全避免使用它(48分29秒:“切勿在程序附近使用或允许空指针”)。
彼得·莫滕森
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.