66

当不需要检查空值时,有哪些指导方针

我最近一直在处理的许多继承代码都带有令人作呕的空检查。对琐碎函数进行空检查,对声明非空返回的 API 调用进行空检查等。在某些情况下,空检查是合理的,但在许多地方,空值并不是一个合理的期望。

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

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

在 C++ 中,由于取消引用空指针的不可预测的行为,我在某种程度上理解了这一点;在 Java、C#、Python 等中,null 取消引用的处理更加优雅。我是否刚刚看到了警惕的 null 检查的不良示例,或者这真的有什么问题吗?

这个问题旨在与语言无关,尽管我主要对 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
}

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

(显然,在您的公共 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;
}
4

18 回答 18

36

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

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

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

  3. 在处理来自其他人框架的返回数据时,我只在可能且有效地返回 null 时检查 null。如果他们的合同说它不返回空值,我不会进行检查。

于 2008-11-19T17:53:14.320 回答
19

首先请注意,这是合同检查的一种特殊情况:您正在编写的代码除了在运行时验证是否符合记录的合同之外什么都不做。失败意味着某处的某些代码有问题。

对于实现更普遍有用的概念的特殊情况,我总是有点怀疑。契约检查很有用,因为它在第一次跨越 API 边界时捕获编程错误。空值有什么特别之处,这意味着它们是您要检查的合同的唯一部分?仍然,

关于输入验证:

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

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

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

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

如果 NULL 不是有效输入,您可以考虑通过引用而不是指针获取参数,但许多编码风格不赞成非常量引用参数。如果调用者传递给你 *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++ 实现上编译它,那么他们基本上是靠自己的,原因比这更大,所以他们必须自己做。

于 2008-11-21T12:27:30.937 回答
10

如果您编写代码及其合约,您有责任按照合约条款使用它并确保合约正确。如果你说“返回一个非空”x,那么调用者不应该检查空值。如果该引用/指针发生空指针异常,则说明您的合同不正确。

只有在使用不受信任或没有适当合同的库时,空值检查才应该走极端。如果是你的开发团队的代码,强调合约不能被破坏,并且在出现错误时追查错误使用合约的人。

于 2008-11-19T17:48:59.070 回答
7

这部分取决于代码的使用方式——例如,如果它是仅在项目中可用的方法还是公共 API 中可用的方法。API 错误检查需要比断言更强大的东西。

因此,虽然这在一个受单元测试和类似东西支持的项目中很好:

internal void DoThis(Something thing)
{
    Debug.Assert(thing != null, "Arg [thing] cannot be null.");
    //...
}

在您无法控制谁调用它的方法中,这样的方法可能会更好:

public void DoThis(Something thing)
{
    if (thing == null)
    {
        throw new ArgumentException("Arg [thing] cannot be null.");
    }
    //...
}
于 2008-11-19T17:56:25.810 回答
7

这取决于实际情况。我的其余答案假设 C++。

  • 我从不测试 new 的返回值,因为我使用的所有实现都会在失败时抛出 bad_alloc。如果我在我正在处理的任何代码中看到新的返回 null 的旧测试,我会将其删除,并且不会费心用任何东西替换它。
  • 除非心胸狭窄的编码标准禁止它,否则我会断言记录在案的先决条件。违反已发布合同的损坏代码需要立即且显着地失败。
  • 如果 null 是由不是由于代码损坏引起的运行时故障引起的,我会抛出。fopen 失败和 malloc 失败(尽管我很少在 C++ 中使用它们)将属于这一类。
  • 我不会尝试从分配失败中恢复。Bad_alloc 被 main() 捕获。
  • 如果空测试是针对我班级的合作者的对象,我会重写代码以通过引用获取它。
  • 如果协作者真的不存在,我使用Null Object 设计模式来创建一个占位符,以明确定义的方式失败。
于 2008-11-19T20:10:08.770 回答
4

NULL 检查通常是邪恶的,因为它为代码可测试性添加了一个小的负面标记。到处都有 NULL 检查,你不能使用“pass null”技术,它会在单元测试时打击你。最好对方法进行单元测试而不是空检查。

查看 Misko Hevery 在http://www.youtube.com/watch?v=wEhu57pih5w&feature=channel上关于该问题和一般单元测试的体面介绍

于 2008-11-19T18:31:50.047 回答
3

旧版本的 Microsoft C++(可能还有其他版本)不会为通过 new 的分配失败抛出异常,而是返回 NULL。必须在符合标准的版本和旧版本中运行的代码将具有您在第一个示例中指出的冗余检查。

让所有失败的分配都遵循相同的代码路径会更干净:

if(obj==NULL)
    throw std::bad_alloc();
于 2008-11-19T20:23:59.303 回答
2

众所周知,有以过程为导向的人(专注于以正确的方式做事)和以结果为导向的人(得到正确的答案)。我们大多数人位于中间的某个地方。看起来您已经找到了面向过程的异常值。这些人会说“除非你完全理解事物,否则一切皆有可能;所以为任何事情做好准备。” 对他们来说,你所看到的都是正确的。对他们来说,如果你改变它,他们会担心,因为鸭子没有排好队。

在处理别人的代码时,我会尽量确保自己知道两件事。
1. 程序员的意图
2. 为什么他们以他们的方式编写代码

对于跟进 A 型程序员,也许这会有所帮助。

因此,“多少才够”最终成为一个社会问题,就像一个技术问题一样——没有商定的衡量方法。

(它也让我发疯。)

于 2008-11-19T17:50:34.370 回答
2

当您可以指定正在使用哪个编译器时,对于诸如“新”之类的系统功能,检查 null 是代码中的错误。这意味着您将复制错误处理代码。重复的代码通常是错误的来源,因为经常有一个被更改而另一个没有。如果你不能指定编译器或编译器版本,你应该更加防御。

至于内部功能,您应该指定合同并确保通过单元测试执行合同。前段时间我们的代码中有一个问题,我们要么抛出异常,要么在数据库中缺少对象的情况下返回 null。这只是让 api 的调用者感到困惑,所以我们通过并使其在整个代码库中保持一致,并删除了重复检查。

重要的是(恕我直言)是没有重复的错误逻辑,其中一个分支永远不会被调用。如果你永远不能调用代码,那么你就不能测试它,你永远不会知道它是否被破坏。

于 2008-11-19T17:55:53.767 回答
2

就我个人而言,我认为在绝大多数情况下,null 测试是不必要的。如果 new 失败或 malloc 失败,您会遇到更大的问题,并且在您没有编写内存检查器的情况下,恢复的机会几乎为零!此外,空测试在开发阶段隐藏了很多错误,因为“空”子句通常只是空的并且什么都不做。

于 2008-11-19T18:37:47.223 回答
2

我会说这有点取决于你的语言,但我将 Resharper 与 C# 一起使用,它基本上不会告诉我“这个引用可能是空的”,在这种情况下我添加一个检查,如果它告诉我“这个对于“if (null != oMyThing && ....)”,将永远为真”,然后我会听它而不测试 null。

于 2008-11-19T21:30:11.590 回答
2

是否检查 null 很大程度上取决于具体情况。

例如,在我们的商店中,我们检查我们在方法内为 null 创建的方法的参数。原因很简单,作为最初的程序员,我很清楚该方法应该做什么。即使文档和要求不完整或不太令人满意,我也能理解上下文。后来负责维护的程序员可能不理解上下文,并且可能错误地认为传递 null 是无害的。如果我知道 null 是有害的,并且我可以预料到有人可能会传递 null,我应该采取简单的步骤来确保该方法以一种优雅的方式做出反应。

public MyObject MyMethod(object foo)
{
  if (foo == null)
  {
    throw new ArgumentNullException("foo");
  }

  // do whatever if foo was non-null
}
于 2008-11-20T15:13:52.223 回答
2

我只在看到 NULL 时知道该怎么做时才检查 NULL。“知道该怎么做”在这里意味着“知道如何避免崩溃”或“知道除了崩溃的位置之外要告诉用户什么”。例如,如果 malloc() 返回 NULL,我通常别无选择,只能中止程序。另一方面,如果 fopen() 返回 NULL,我可以让用户知道无法打开的文件名,可能是 errno。如果 find() 返回 end(),我通常知道如何继续而不崩溃。

于 2008-11-21T19:54:29.177 回答
1

较低级别的代码应该从较高级别的代码中检查使用。通常这意味着检查参数,但也可能意味着检查来自上行调用的返回值。不需要检查 Upcall 参数。

目的是以直接和明显的方式发现错误,并用不撒谎的代码记录合同。

于 2008-11-19T18:05:45.650 回答
1

我不认为这是糟糕的代码。相当数量的 Windows/Linux API 调用在某种失败时返回 NULL。所以,当然,我以 API 指定的方式检查失败。通常我会以某种方式将控制流传递给错误模块,而不是复制错误处理代码。

于 2008-11-19T21:24:04.410 回答
1

如果我收到一个语言不保证不为 null 的指针,并且将以 null 会破坏我的方式取消引用它,或者将我的函数放在我说不会产生 NULL 的地方,我检查 NULL。

这不仅仅是关于 NULL,如果可能,函数应该检查前置条件和后置条件。

如果给我指针的函数的合同说它永远不会产生空值,这根本不重要。我们都会制造错误。有一个很好的规则,一个程序应该尽早失败并且经常失败,所以与其将错误传递给另一个模块并让它失败,我会失败。在测试时使事情变得更容易调试。此外,在关键系统中,更容易保持系统健全。

此外,如果异常从 main 中逃脱,堆栈可能不会被卷起,从而阻止析构函数运行(请参阅有关 terminate() 的 C++ 标准)。这可能很严重。因此,不检查 bad_alloc 可能比看起来更危险。

断言失败与运行时错误失败是完全不同的话题。

如果标准 new() 行为未更改为返回 NULL 而不是抛出,则在 new() 之后检查 NULL 似乎已过时。

还有一个问题,即使 malloc 返回了一个有效的指针,也不意味着你已经分配了内存并且可以使用它。不过那是另一回事了。

于 2008-11-20T14:40:35.470 回答
1

我的第一个问题是,它会导致代码中充斥着空检查等。它损害了可读性,我什至会说它损害了可维护性,因为如果你正在编写一段代码,其中某个引用确实不应该为空,那么很容易忘记空检查。而且您只知道在某些地方会缺少空检查。这实际上使调试比需要的更难。如果原始异常没有被捕获并替换为错误的返回值,那么我们将获得一个有价值的异常对象,其中包含信息丰富的堆栈跟踪。缺少的空检查会给您带来什么?一段代码中的 NullReferenceException 让你走:wtf?这个引用永远不应该为空!

因此,您需要开始弄清楚代码是如何被调用的,以及为什么引用可能为空。这可能会花费大量时间,并且确实会影响调试工作的效率。最终你会找出真正的问题,但很可能它被隐藏得很深,你花费了比你应该花更多的时间来寻找它。

到处都有空检查的另一个问题是,一些开发人员在收到 NullReferenceException 时并没有真正花时间正确考虑真正的问题。实际上,我已经看到不少开发人员只是在发生 NullReferenceException 的代码上方添加了一个空检查。太好了,异常不再发生!欢呼!我们现在可以回家了!嗯……“不,你不能,你应该用肘部抵住脸”怎么样?真正的错误可能不再导致异常,但现在您可能有缺失或错误的行为......而且没有异常!这更加痛苦,并且需要更多时间来调试。

于 2013-09-20T07:04:56.033 回答
0

起初,这似乎是一个奇怪的问题:null支票很棒,而且是一种有价值的工具。检查new退货null绝对是愚蠢的。我将忽略一个事实,即有些语言允许这样做。我敢肯定有正当的理由,但我真的不认为我可以处理生活在那个现实中:)所有的开玩笑,看起来你至少应该指定当没有足够的内存时new应该返回null.

无论如何,检查null适当的地方会导致代码更清晰。我什至会说永远不要为函数参数分配默认值是下一个合乎逻辑的步骤。更进一步,在适当的地方返回空数组等会导致代码更简洁。不必担心得到nulls 是很好的,除非它们在逻辑上有意义。最好避免将空值作为错误值。

使用断言是一个非常好的主意。特别是如果它让您可以选择在运行时关闭它们。另外,这是一种更明确的合同风格:)

于 2011-03-25T01:48:05.170 回答