避免空引用异常

26

显然,在代码中绝大多数错误是空引用异常。有没有一些通用的技巧可以避免遇到空引用异常?

除非我搞错了,我知道像 F# 这样的语言不可能存在 null 值。但这并不是问题,我想问的是如何在 C# 等语言中避免空引用错误。


2
答案是不要轻易使用 null。 不要将其作为“错误代码”强行塞入,不要像它是真实对象一样乱扔,事实上,除非你可以为在这种特定情况下使用它进行辩解,否则根本不要考虑写 x = null。哦,对了,如果你在调用你无法控制的代码,请查看文档并查看它是否也可能会返回 null。 如果能返回 null 就一定要检查它。 - Anon.
1
绝大多数代码错误是空引用异常。只是好奇,你从哪里得到这个统计数据的? - Ragepotato
1
作为一名在微软工作并从事F#开发的员工,我可以明确地说,“代码中绝大多数错误都是空引用异常”这种说法是不正确的。 - Brian
几乎所有的“NullReferenceException”异常情况都是相同的。请参考“.NET中的NullReferenceException是什么?”获取一些提示。 - John Saunders
Tony Hoare在1965年的ALGOL W中引入了Null引用,他说:“只是因为它很容易实现”。他谈到这个决定时认为这是“我价值十亿美元的错误”。http://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare - Luis Perez
显示剩余3条评论
18个回答

32
当用户看到空引用异常时,这表示开发人员在编写代码时存在缺陷导致错误。以下是一些防止这些错误的想法。
我向那些关心软件质量并使用.net编程平台的人强烈推荐安装和使用Microsoft代码合同 ( http://msdn.microsoft.com/en-us/devlabs/dd491992.aspx )。它包括运行时检查和静态验证的功能。将这些合同构建到您的代码中的基本功能已包含在.net框架4.0版本中。如果您对代码质量感兴趣,并且听起来您确实很感兴趣,您可能会非常喜欢使用Microsoft代码合同。
使用Microsoft代码合同,您可以通过添加先决条件来保护您的方法免受空值的影响,例如 "Contract.Requires(customer != null);"。像这样添加一个先决条件相当于许多其他人在他们的评论中推荐的做法。在代码合同出现之前,我会建议您做类似于这样的事情。
if (customer == null) {throw new ArgumentNullException("customer");}

现在我推荐
Contract.Requires(customer != null);

你可以启用运行时检查系统,尽早捕获这些缺陷,帮助你诊断和修正有问题的代码。但不要让我给你留下代码合同只是取代参数空异常的花哨方式的印象。它们比那更加强大。
使用Microsoft代码合同,您还可以运行静态检查器,并要求它调查可能存在空引用异常的代码位置。静态检查器需要更多的使用经验。我不建议初学者首先使用它。但请随意尝试并自行了解。
对于是否空引用错误是一个重要问题,在这个主题中已经有一些争论。以下是冗长的答案。对于不想深入了解的人,我会总结一下。
- Microsoft在Spec#和代码合同项目中的程序正确性领域的领先研究人员认为这是值得解决的问题。 - ISE的Bertrand Meyer博士和软件工程团队,开发和支持Eiffel编程语言,也认为这是值得解决的问题。 - 在我自己的商业经验中,我经常看到空引用错误,因此我希望在自己的产品和实践中解决这个问题。
多年来,Microsoft一直投资于旨在提高软件质量的研究。他们的努力之一是Spec#项目。在我看来,.net 4.0框架中最令人兴奋的发展之一,是引入了Microsoft代码合同,这是早期由Spec#研究团队完成的工作的延续。
关于您提到的“代码中绝大多数错误都是空引用异常”的评论,我认为是“绝大多数”这个限定词会引起一些争议。短语“绝大多数”表明,也许70-90%的故障具有空引用异常作为根本原因。对我来说,这似乎太高了。我更喜欢引用Microsoft Spec#的研究。在他们的文章“Spec#编程系统:概述”中,Mike Barnett、K. Rustan M. Leino和Wolfram Schulte写道:

1.0非空类型 现代程序中的许多错误表现为空引用错误,这表明提供区分可能评估为空值和确定不为空值的表达式的编程语言的重要性(有关实验证据,请参见[24、22])。事实上,我们希望消除所有空引用错误。

这是Microsoft内部熟悉此研究的人员的可靠来源。该文章可在Spec#网站上获取。

我复制了下面的引用22和24,并包含ISBN以方便您查看。

  • Manuel Fahndrich和K. Rustan M. Leino。在面向对象语言中声明和检查非空类型。在2003年面向对象编程、系统、语言和应用程序的ACM会议论文集(OOPSLA 2003)中,第38卷,第11号SIGPLAN通知中,页码为302-312。ACM,2003年11月。ISBN = {1-58113-712-5},

  • Cormac Flanagan,K. Rustan M. Leino,Mark Lillibridge,Greg Nelson,James B. Saxe和Raymie Stata。Java的扩展静态检查。在2002年ACM SIGPLAN编程语言设计和实现(PLDI)会议论文集中,第37卷,第5号SIGPLAN通知中,页码为234-245。ACM,2002年5月。

我已经审查了这些参考资料。第一个参考资料指出他们对自己的代码进行了一些实验,以检查可能存在的空引用缺陷。他们不仅发现了几个问题,而且在许多情况下,潜在的空引用问题指示了更广泛的设计问题。

第二个参考资料没有提供任何具体证据来支持空引用错误是问题的说法。但作者确实指出,在他们的经验中,这些空引用错误是软件缺陷的重要来源。然后,论文进一步解释了他们如何试图消除这些错误。

我还记得在ISE最近发布的Eiffel版本中看到了关于这个问题的公告。他们称之为“void safety”,就像Bertrand Meyer博士所启发或开发的许多东西一样,他们对问题及如何在其语言和工具中防止该问题有着简明扼要的描述。我建议你阅读他们的文章http://doc.eiffel.com/book/method/void-safety-background-definition-and-tools以了解更多信息。
如果您想了解更多关于Microsoft代码契约的内容,最近已经出现了大量文章。您还可以查看我的博客http: SLASH SLASH codecontracts.info,这主要是关于通过使用编程合同来进行软件质量谈话的。

Stephen的评论:由于缺乏异步支持而不使用它。http://blog.stephencleary.com/2011/01/simple-and-easy-code-contracts.html。引用:“真是太遗憾了...现在几乎被放弃了”。 - Serge Pavlov

23

除了上述的空对象、空集合,还有一些通用技术,即C++中的资源获取即初始化(Resource Acquisition is Initialization,RAII)和Eiffel语言中的契约式设计(Design By Contract)。它们归结为以下几点:

  1. 使用有效值初始化变量。
  2. 如果一个变量可以为null,则要么检查是否为null并将其视为特殊情况,要么期望出现null引用异常(然后处理该异常)。在开发版本中,断言可以用于测试契约违规情况。

我看过很多这样的代码:

if ((value != null) && (value.getProperty() != null) && ... ) doSomethingUseful();
很多时候这是完全不必要的,大部分测试可以通过更严格的初始化和更紧密的合同定义来减少。 如果您的代码库中存在此问题,则需要了解每种情况下null代表什么: 1. 如果null代表空集合,请使用空集合。 2. 如果null代表异常情况,请抛出异常。 3. 如果null代表意外未初始化的值,请明确初始化它。 4. 如果null代表合法值,请进行测试-或者更好地使用NullObject执行null操作。 实际上,在设计水平上达到这种清晰度的标准是不容易的,并需要努力和自律才能一致地应用于您的代码库中。

8

你不需要做任何特殊的操作来尝试在C#中“防止”NRE。大多数情况下,NRE只是某种逻辑错误。您可以通过检查参数并编写大量代码来隔离接口边界。

void Foo(Something x) {
    if (x==null)
        throw new ArgumentNullException("x");
    ...
}

在许多 .Net Framework 中,异常处理机制是随处可见的,因此当出现错误时,可以得到略微详细的诊断信息(堆栈跟踪更有价值), NullReferenceException (NRE)也提供这样的信息。但最终还是只能得到异常。
(顺便说一句:像 NullReferenceException 、 ArgumentNullException 、 ArgumentException 这样的异常通常不应该被程序捕获,而是意味着“代码开发人员存在错误,请修复”。这些我称之为“设计时”异常; 与之相对的是真正的“运行时”异常,它们是由于运行时环境(例如FileNotFound)导致的可能需要被程序捕获和处理的异常。)
但归根结底,你只需要编写正确的代码。
理想情况下,大多数 NRE 分类永远不会发生,因为对于许多类型/变量,“null”是一个毫无意义的值,并且理想情况下,静态类型系统将禁止对这些特定类型/变量使用“null”值。然后编译器将防止您引入此类意外错误(排除某些类别的错误是编译器和类型系统擅长的)。这就是某些语言和类型系统的优势所在。
但如果没有这些功能,那么您只需测试代码以确保您没有这种类型的错误的代码路径(或者可能使用一些外部工具来为您进行额外的分析)即可。

5
对于“设计时”异常加一分——这是一个有趣的区别。我曾在工作中与人争论过关于异常的问题,我说同样的话。那个人说“异常太慢了”,然后我说“但是如果我们编写得当,我们根本不会触发或处理任何这些异常!” - Mark Simpson

5

在这里使用空对象模式是关键。

确保在集合未被填充时要求它们为空,而不是 null。在不必要的情况下使用空集合而不是 null 集合会令人困惑。

最后,在构造对象时,尽可能让它们断言非 null 值。这样以后就不会怀疑值是否为 null,并且只需要在必要的情况下执行 null 检查。对于大多数字段和参数,我可以假定基于之前的断言值不为 null。


5

在引发异常之前,您可以轻松检查空引用,但通常这并不是真正的问题,因此您最终仍然会抛出异常,因为没有数据,代码实际上无法继续执行。

通常,主要问题不是您有一个空引用,而是您首先获得了一个空引用。如果引用不应为空,则在初始化引用时还没有正确的引用,就不应该通过该点。


4

我见过的最常见的空引用错误之一来自字符串。通常会进行如下检查:

if(stringValue == "") {}

但是,该字符串确实为空。应该是:
if(string.IsNullOrEmpty(stringValue){}

此外,在尝试访问对象的成员/方法之前,您可能过于谨慎并检查对象是否为空。

IsNullOrEmpty经常隐藏了变量从未被赋值的问题。 - Kevin Whitefoot
这是基于 null 只用于初始化的假设,但并非总是如此。 - Joseph Yaduvanshi

4

如果你的语言中有空值,那么这种情况是不可避免的。空引用错误来自应用程序逻辑错误 - 因此,除非你能避免所有这些错误,否则你必然会遇到一些问题。


3

2

避免NullReferenceExceptions的最简单方法之一是在类构造函数/方法/属性设置器中积极检查null引用并引起注意。

例如:

public MyClass
{
   private ISomeDependency m_dependencyThatWillBeUsedMuchLater 

   // passing a null ref here will cause 
   // an exception with a meaningful stack trace    
   public MyClass(ISomeDependency dependency)
   {
      if(dependency == null) throw new ArgumentNullException("dependency");

      m_dependencyThatWillBeUsedMuchLater = dependency;
   }

   // Used later by some other code, resulting in a NullRef
   public ISomeDependency Dep { get; private set; }
}

在上述代码中,如果传递了一个 null 引用,你会立即发现调用代码错误地使用了该类型。如果没有进行空引用检查,则错误可能以许多不同的方式被隐藏。
你会注意到,.NET 框架库几乎总是在您提供无效的 null 引用时立即报错。由于抛出的异常明确表示“你弄错了!”并告诉你原因,因此检测和纠正有缺陷的代码成为一项微不足道的任务。
我听到一些开发人员抱怨说这种做法过于冗长和冗余,因为只需要 NullReferenceException 就可以了,但实际上我发现这样做有很大的区别。特别是在调用堆栈深度较深和/或参数被存储且其使用被推迟到以后(也许在不同的线程上或以某种其他方式被隐藏)的情况下。
你更喜欢在进入方法时得到 ArgumentNullException 还是在其内部出现晦涩的错误呢?离错误源越远,跟踪它就越困难。

2
好的代码分析工具可以帮助解决这个问题。如果您使用的工具将null视为可能的代码路径,则良好的单元测试也可以帮助解决这个问题。尝试在构建设置中打开“将警告视为错误”的开关,看看能否将项目中的警告数量保持为0。您可能会发现这些警告会告诉您很多信息。
需要注意的一点是,抛出空引用异常可能是一件好事。为什么?因为这可能意味着本应执行的代码未执行。初始化默认值是一个好主意,但您应该小心不要隐藏问题。
List<Client> GetAllClients()
{
    List<Client> returnList = new List<Client>;
    /* insert code to go to data base and get some data reader named rdr */
   for (rdr.Read()
   {
      /* code to build Client objects and add to list */
   }

   return returnList;
}

好的,这看起来没问题,但根据您的业务规则,这可能是个问题。当然,您永远不会抛出空引用,但也许您的用户表格应该永远不为空?您想让您的应用程序在原地旋转,从用户那里产生支持电话,说“它只是一个空屏幕”,还是想引发一个异常,可能会被记录在某个地方并迅速引发警报?不要忘记验证您正在做的事情以及“处理”异常。这就是为什么有些人不愿意将null从我们的语言中删除的原因之一…尽管这可能会导致一些新的错误,但它使查找错误变得更容易。
记住:处理异常,而不是隐藏它们。

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