多少空值检查就足够了?

2022-08-31 14:47:11

不需要检查空值时,有哪些准则?

我最近一直在处理的很多继承代码都有空检查和恶心。对琐碎函数进行空检查,对状态为非空返回的 API 调用进行空检查,等等。在某些情况下,空检查是合理的,但在许多情况下,空值不是合理的期望。

我听说过很多论点,从“你不能信任其他代码”到“总是防御性地编程”,再到“直到语言保证我有一个非空值,我总是会检查。我当然在某种程度上同意其中的许多原则,但我发现过度的空检查会导致其他通常违反这些原则的问题。顽强的空检查真的值得吗?

我经常观察到具有过多空检查的代码实际上质量较差,而不是质量更高。大部分代码似乎都集中在空检查上,以至于开发人员忽略了其他重要的品质,例如可读性、正确性或异常处理。特别是,我看到很多代码忽略了std::bad_alloc异常,但对.new

在C++中,我在某种程度上理解这一点,这是由于取消引用空指针的不可预测的行为;null 取消引用在 Java、C#、Python 等中处理得更优雅。我刚刚看到警惕的空检查的糟糕例子,还是真的有一些东西?

这个问题旨在与语言无关,尽管我主要对C++,Java和C#感兴趣。


我看到的一些似乎过多的空检查示例包括以下内容:


此示例似乎考虑了非标准编译器,因为C++规范说失败的新编译器会引发异常。除非您明确支持不合规的编译器,否则这有意义吗?这在 Java 或 C# 等托管语言(甚至C++/CLR)中是否有任何意义?

try {
   MyObject* obj = new MyObject(); 
   if(obj!=NULL) {
      //do something
   } else {
      //??? most code I see has log-it and move on
      //or it repeats what's in the exception handler
   }
} catch(std::bad_alloc) {
   //Do something? normally--this code is wrong as it allocates
   //more memory and will likely fail, such as writing to a log file.
}

另一个例子是在处理内部代码时。特别是,如果是一个小团队可以定义自己的开发实践,这似乎没有必要。在某些项目或遗留代码上,信任文档可能不合理...但是对于您或您的团队控制的新代码,这真的有必要吗?

如果一个你可以看到并且可以更新(或者可以对负责的开发人员大喊大叫)的方法有一个合约,是否仍然需要检查空值?

//X is non-negative.
//Returns an object or throws exception.
MyObject* create(int x) {
   if(x<0) throw;
   return new MyObject();
}

try {
   MyObject* x = create(unknownVar);
   if(x!=null) {
      //is this null check really necessary?
   }
} catch {
   //do something
}

在开发私有函数或其他内部函数时,当合约仅调用非 null 值时,是否真的有必要显式处理 null?为什么空检查比断言更可取?

(显然,在您的公共API上,空检查至关重要,因为对用户不正确使用API大喊大叫被认为是不礼貌的)

//Internal use only--non-public, not part of public API
//input must be non-null.
//returns non-negative value, or -1 if failed
int ParseType(String input) {
   if(input==null) return -1;
   //do something magic
   return value;
}

与:

//Internal use only--non-public, not part of public API
//input must be non-null.
//returns non-negative value
int ParseType(String input) {
   assert(input!=null : "Input must be non-null.");
   //do something magic
   return value;
}

答案 1

有一件事要记住,你今天编写的代码虽然可能是一个小团队,你可以有很好的文档,但会变成其他人必须维护的遗留代码。我使用以下规则:

  1. 如果我正在编写一个将向其他人公开的公共API,那么我将对所有引用参数进行空检查。

  2. 如果我正在为我的应用程序编写内部组件,那么当我需要执行一些特殊操作(当 null 存在时)或当我想非常清楚地说明时,我会编写 null 检查。否则,我不介意获得空引用异常,因为这也相当清楚发生了什么。

  3. 当使用来自其他人的框架的返回数据时,我只在返回null是可能和有效的时才检查null。如果他们的合约说它不返回空值,我不会做检查。


答案 2

首先要注意的是,这是契约检查的一个特例:你正在编写的代码,除了在运行时验证文档化的协定是否得到满足之外,什么都不做。失败意味着某个地方的某些代码有故障。

我总是对实现一个更普遍有用的概念的特殊情况有点怀疑。协定检查非常有用,因为它会在编程错误首次跨越 API 边界时捕获这些错误。nulls有什么特别之处,这意味着它们是您要检查的合同中唯一的部分?还

关于输入验证的主题:

null在Java中是特殊的:许多Java API都是这样编写的,null是唯一可能传递到给定方法调用中的无效值。在这种情况下,空检查“完全验证”输入,因此支持合约检查的完整参数适用。

另一方面,在C++中,NULL 只是指针参数可以采用的近 2^32 个(在较新的体系结构上为 2^64)无效值之一,因为几乎所有地址都不是正确类型的对象。您无法“完全验证”您的输入,除非您在某处有一个包含该类型所有对象的列表。

那么问题就变成了,NULL是否是一个足够常见的无效输入,可以获得没有得到的特殊处理?(foo *)(-1)

与Java不同,字段不会自动初始化为NULL,因此垃圾未初始化的值与NULL一样合理。但有时C++对象具有显式NULL初始化的指针成员,这意味着“我还没有一个”。如果您的调用方这样做,则存在一类重要的编程错误,可以通过NULL检查进行诊断。对于他们来说,异常可能比他们没有源代码的库中的页面错误更容易调试。因此,如果您不介意代码膨胀,它可能会有所帮助。但你应该想到的是你的调用方,而不是你自己 - 这不是防御性编码,因为它只“防御”NULL,而不是(foo *)(-1)。

如果 NULL 不是有效的输入,您可以考虑通过引用而不是指针来获取参数,但是许多编码样式都不赞成非 const 引用参数。如果调用方通过你*fooptr,其中fooptr是NULL,那么它无论如何都没有任何好处。你试图做的是将更多的文档压缩到函数签名中,希望你的调用者更有可能认为“嗯,fooptr在这里可能是空的?”当他们必须显式取消引用它时,而不是仅仅将其作为指针传递给你。它只能走这么远,但就目前而言,它可能会有所帮助。

我不了解C#,但我明白它就像Java一样,因为引用保证具有有效的值(至少在安全代码中),但与Java不同,并非所有类型都有NULL值。所以我猜空检查很少值得:如果你在安全的代码中,那么不要使用可空的类型,除非null是一个有效的输入,如果你在不安全的代码中,那么同样的推理适用于C++。

关于输出验证:

类似的问题出现了:在Java中,您可以通过知道其类型来“完全验证”输出,并且该值不是空的。在C++中,你不能用NULL检查来“完全验证”输出 - 据你所知,该函数返回了一个指向其自身堆栈上对象的指针,该对象刚刚被解卷。但是,如果由于被调用方代码的作者通常使用的构造,NULL 是常见的无效返回,则检查它会有所帮助。

在所有情况下:

尽可能使用断言而不是“真实代码”来检查合约 - 一旦你的应用工作正常,你可能不希望每个被调用方检查其所有输入的代码膨胀,以及每个调用方检查其返回值。

在编写可移植到非标准C++实现的代码的情况下,那么我可能会有这样的函数,而不是问题中检查null并捕获异常的代码:

template<typename T>
static inline void nullcheck(T *ptr) { 
    #if PLATFORM_TRAITS_NEW_RETURNS_NULL
        if (ptr == NULL) throw std::bad_alloc();
    #endif
}

然后,作为移植到新系统时要执行的操作列表之一,您可以正确定义PLATFORM_TRAITS_NEW_RETURNS_NULL(也许还有其他一些PLATFORM_TRAITS)。显然,您可以编写一个标头,为您所知道的所有编译器执行此操作。如果有人把你的代码编译到一个你一无所知的非标准C++实现上,他们基本上只能靠自己,原因比这更大,所以他们必须自己做。


推荐