65.9K
CodeProject 正在变化。 阅读更多。
Home

关于 NULL 的很多事

starIconstarIconstarIconstarIconstarIcon

5.00/5 (15投票s)

2015 年 1 月 18 日

CPOL

11分钟阅读

viewsIcon

24786

防止空引用异常的模式

引言

Null 值可能非常令人头疼。最令人头疼的莫过于带有空指针错误消息的 bug 报告,通常需要花费数小时来弄清楚以下两件基本事实,按时间顺序排列:

  1. 导致异常的指针/引用是什么?
  2. Null 值来自哪里?为什么会生成?

虽然我们希望保护我们的代码免受这种讨厌的错误的影响,但我们也希望避免用频繁的 Null 检查或过度的错误处理来污染代码。我们不想过于谨慎,为本应直观的逻辑添加不必要的冗长和噪音,从而使代码难以阅读。

有许多优雅的技术可以防止生成 Null 引用错误或防止它们在代码中不受控制地传播。所有这些技术都很有用,但没有一种技术能够真正做到如此全面和强大,以至于在所有情况下都能妥善解决该问题。由于 Null 值可能有不同的含义,因此本文旨在描述不同的情况,并建议在每种特定情况下哪种技术最有效。

买者自慎:本文侧重于编码模式;因此,我将不讨论与测试相关的主题。单元测试和探索性测试是发现 Null 引用错误的极其有效的方法。本文列出的技术绝不应被视为测试的替代品。

Null 的五种类型

为了防止 Null 引用异常,我们首先需要检查 Null 值在我们的应用程序中是从哪里来的。我们可以大致识别五种不同的 Null 类型,描述如下:

1. 表示失败的 Null 值

Null 值可用于表示操作失败,通常是因为我们检测到某些内容无效,并且它阻止了方法的继续执行。可以认为抛出异常是处理这些情况的更合适的方式,但异常在许多面向对象语言中是相当昂贵的对象,它们会破坏代码的正常流程,在某些情况下,如果可能的话,我们可能希望避免它们。如果我们不想抛出异常,那么我们可以返回一个 Null 值来让调用者知道出了问题。

2. 表示道路终点的 Null 值

在复合数据结构中,Null 值可以表示导航路径的结束。例如,链表的最后一个元素,或者可能具有指向缺失子节点的 Null 指针的树结构的叶子节点。当没有外键记录连接时,数据库实体也可能将导航属性设置为 Null(由 ORM 框架设置)。

3. 表示查找/搜索不成功的 Null 值

这是一个非常常见的情况,当我们从字典、缓存提供程序或集合中搜索元素时。我们获得一个 Null 值是因为我们找不到想要的东西。这种情况与第一种情况非常相似,只是有一个重要的区别,我们将在后面讨论,这使得它们可以分为单独的一组。

4. 表示未指定输入或缺失值的 Null 值

在使用 MVC 或 MVVM 等模式时,我们有或多或少映射到 UI 控件的视图模型。例如,我们可能有日期选择器映射到 DateTime 属性。如果在 UI 上没有选择日期,那么我们可能会在相关的实例字段中获得一个 Null 值。
Null 值经常来自数据库中的缺失值(数据库 Null),这些值会反映在数据实体的映射属性中。

5. 意外/偶然的 Null 值

这些 Null 值不携带任何特定含义,最终被认为是编程错误。有时,我们忘记初始化或设置新创建对象的引用变量或引用属性。还有些时候,我们无法初始化一个变量,因为要创建实例,我们需要在构造函数中传递一些尚不可用的参数。无论哪种情况,如果未初始化的引用在作用域内,有人可能会意外使用它并触发臭名昭著的异常。
意外的 Null 值可以作为方法/构造函数参数接收,这些参数按设计本应始终是有效的实例;Null 值也可以来自第三方方法调用、转换/强制转换、对应该存在的类型的反射、反序列化操作等。

防止 Null 引用异常的模式

在这里,我们将检查一些处理应用程序中 Null 值的著名技术。
为了举例,我有时会使用几行 C# 代码,我认为它们足够简单,任何使用过面向对象语言的开发人员都能轻松理解。

Null 对象模式

这种策略包括用具有默认行为的对象替换 Null 值。例如,我们可以有一个搜索方法,在集合中搜索具有特定 ID 的人员。

Person p = People.FindByUniqueId(34);

如果集合中不存在 ID 为 34 的人员,我们可以返回一个 Person 的默认实例(例如 `Person.Nobody`),它代表一个功能齐全的 `Person` 实例,但具有默认/空白的属性和行为。虽然理论上我们可以使用此技术来替换 Null 值,但其实际适用性有一些严重的限制:

  1. 只能在有默认状态/行为的地方使用
  2. 当 Null 有特定含义时,使用占位符可能会隐藏该含义(例如,失败)。在上面的例子中,Null 对象模式会隐藏搜索不成功的事实。因此,该模式主要通过提供默认初始化选项来有效处理类型 4 和 5 的 Null 值。
  3. 该模式的使用应该是显而易见的。在上面的例子中,方法返回一个 Null 对象并不明显,因此未阅读文档的开发人员仍会进行 Null 检查。

测试者-执行者模式

一个可能返回 Null 值的方法(执行者)可以逻辑上与另一个方法(测试者)配对,该方法将首先验证条件以避免 Null 值。典型的例子是字典的 `ContainsKey` / `GetValue` 组合。

If (peopleDictionary.ContainsKey(34)) {

     Person p = peopleDictionary.GetValue(34); //Always successful(?)
}

这种方法适用于类型 2 和 3 的 Null 值,但有两个主要缺陷:

  1. 由于测试者和执行者是两个独立的操作,因此很难使其成为线程安全的。
  2. 该技术缺乏内聚性:该模式不强制先进行测试再执行,而逻辑上这两个操作应该始终一起进行。

Try 方法

在其基本形式中,try 操作是一个返回布尔值的方法,并且还有一个输出实例,仅在该方法返回 true 时使用。如果应用于之前的示例:

Person p = Person.Nobody; //Avoid null initialization, assuming a default behavior makes sense

if (People.TryFindByUniqueId(34, out p) {

     //p is the person with Id 34
} else {

     //nobody has id 34, no need to check p
}

try 方法的名称和签名立即传达了操作可能不成功的意思,并且其使用非常直观。有几种 try 方法的变体可能非常有用:`TryOrDefault` 和 `TryOrRetrieve`。
`TryOrDefault` 只有一个额外的参数,允许用户明确指定默认值,而不是返回 false。

Person p = People.TryFindByUniqueIdOrDefault(34, Person.Nobody);

`TryOrRetrieve` 非常相似,但它不让用户指定默认值,而是允许用户指定一个返回该值的函数。这种模式通常用于缓存检索操作。

Person p = cache.TryFindOrLoad (34, ()=> {return db.GetPersonPrDefault(34,Person.Nobody)});

Try 方法非常适合防止类型 2 和 3 的 Null 值,但对于防止表示失败的 Null 值(类型 1)却常常不足。下一项技术将更好地解决这种情况。

结果对象模式

返回 Null 来表示失败不仅危险,而且有一个根本性的缺陷:它没有告诉我们失败的原因。当操作复杂时,可能有很多失败的原因。让我们考虑这个例子:

Person p = Person.FromJSON("{id: 1, name: 'John Doe', age: 46}");

如果方法返回 Null,可能是因为年龄无效(例如,负数),或者姓名为空,或者整个 JSON 字符串为 Null。为了让调用者了解失败的原因,我们可以设计一个更复杂对象:操作结果对象。

PersonFromJSONResult result = Person.FromJSON("{id: 1, name: '', age: 46}");

If (result.Success) {
     //use result.Person
} else {
      //Check result.FailureReason to know more
}

结果对象需要一些工作,并且非常适合替换类型 1 的 Null 值,用于复杂的如验证或业务规则引擎、服务器-客户端通信(例如 Web 服务结果)、非平凡的转换/解析等操作。

Maybe Monad

这项技术来自函数式编程,可以看作是 try 方法更广泛、更通用的替代方案。
Maybe Monad 是一个特殊的对象,它可以包含一个实例,也可以不包含值。在支持泛型的语言中(例如 Java、C#),它的样子是这样的:

Maybe<Person> maybePerson = people.FindByUniqueId(34);

要从 Maybe 对象中提取值,我们可以这样做:

Person p = maybePerson.ValueOrDefault(Person.Nobody);

我们还可以提供一个测试方法以方便使用:

If (maybePerson.HasValue) { //extract value from maybePerson }

这种方法的优点是我们可以提供一个通用的可重用 `Maybe` 对象实现,并在不实现 try 方法的情况下将其用于任何适用的地方。
这种方法可以有效地应用于类型 2、3、4 和 5 的 Null 值。
查看您语言的文档,看看它是否原生提供了 Maybe Monad 的实现。

安全导航运算符

许多现代面向对象语言提供安全导航运算符(又名 Null 传播运算符),以避免在导航实体属性时(Null 类型 4)发生 Null 引用异常。该运算符是语法糖,用于避免在评估如下表达式时出现难看的嵌套 Null 检查。

var customerZipCode = db.GetCustomerById(5).ContactInfo.Address.ZipCode;

在导航实体中的数据时,我们可能会遇到一个 Null 引用,该引用会引发异常。如果我们使用安全导航运算符,那么在任何时候遇到 Null 值,表达式的求值结果都将是 Null 值。

var customerZipCode = db.GetCustomerById(5)?.ContactInfo?.Address?.ZipCode;

显然,在使用此运算符时,我们必须最终预期 `customerZipCode` 可能为 Null,并且在使用其值之前仍然需要进行检查。另一个重要的警告是,在某些语言中,此运算符的工作方式可能有点棘手(请参阅参考文献)。

空集合

值得注意的是,在处理集合而不是单个实例时,一个合适的解决方案通常很简单,就是使用空集合来替换 Null 值。在适用时,空集合是类型 2、3、4 和 5 的 Null 值的绝佳替代品。因此,返回单个实例的方法有时可以“复数化”以改用集合。

int[] ids = new int[]{34, 3, 88};
Person[] persons = people.FindByUniqueIds(ids); //It returns an empty array if no id has been found

Null 守卫子句

在某些情况下,无法替换 Null 值。当 Null 违反设计契约(前置条件、后置条件和不变量)时,通常的方法是使用 Null 守卫子句来保护代码。本质上,Null 守卫子句就是“if”语句,当它们发现违规的 Null 引用时会抛出异常。应尽可能早地抛出异常,并且异常消息应精确地指示检测到的 Null 引用变量。
通过抛出另一个异常来防止 Null 引用异常可能听起来很奇怪。然而,如果我们考虑以下两点,它仍然完全有意义:

  1. Null 引用异常很难排查,而守卫子句异常可以及早、精确地检测并查明问题。
  2. Null 守卫子句用于无法替换 Null 值且按设计不可接受的情况。不抛出异常将不可避免地破坏功能,可能以不可预测的方式。
public  void ScorePeople(IScoringStrategy strategy) {

   //Null guard clause
   if (strategy==null)
     throw new ArgumentNullException("Cannot score people with a null strategy!")

   //Some scoring strategy here
}

静态分析器

有一些工具可以通过静态分析帮助我们检测代码中的潜在 Null 引用。
我个人只尝试过其中一个用于 .NET(Code Contracts),并且发现它非常有趣且潜力巨大。
静态代码分析器不属于本文的范围。如果您想了解更多信息,以下是一些用于识别不同语言中 Null 引用问题的免费静态分析工具列表:

语言

工具

.NET

Code Contract
http://research.microsoft.com/en-us/projects/contracts/

Java, Scala, Groovy

Find Bugs
http://findbugs.sourceforge.net/

C, C++

CppCheck

http://sourceforge.net/projects/cppcheck/

C, Objective C

Clang Static Analyzer
http://clang-analyzer.llvm.org/

Php

Phantm

https://github.com/colder/phantm

备忘单

不是很喜欢备忘单,但它们仍然可用于参考目的。

技术

对 Null 类型有效

注释

Null 对象模式

4, 5

仅当默认行为有意义时

测试者-执行者模式

2, 3

线程安全问题,缺乏内聚性

Try 方法

2, 3, 4

 

结果对象模式

1

当您需要调用结果的额外元数据时

Maybe Monad

2,3,4,5

 

安全导航运算符

2,4

适合导航属性

空集合

2,3,4,5

仅适用于集合

Null 守卫子句

5

当 Null 值无法替换时

静态分析器

5

识别需要守卫子句的地方

参考文献

© . All rights reserved.