关于 NULL 的很多事





5.00/5 (15投票s)
防止空引用异常的模式
引言
Null 值可能非常令人头疼。最令人头疼的莫过于带有空指针错误消息的 bug 报告,通常需要花费数小时来弄清楚以下两件基本事实,按时间顺序排列:
- 导致异常的指针/引用是什么?
- 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 值,但其实际适用性有一些严重的限制:
- 只能在有默认状态/行为的地方使用
- 当 Null 有特定含义时,使用占位符可能会隐藏该含义(例如,失败)。在上面的例子中,Null 对象模式会隐藏搜索不成功的事实。因此,该模式主要通过提供默认初始化选项来有效处理类型 4 和 5 的 Null 值。
- 该模式的使用应该是显而易见的。在上面的例子中,方法返回一个 Null 对象并不明显,因此未阅读文档的开发人员仍会进行 Null 检查。
测试者-执行者模式
一个可能返回 Null 值的方法(执行者)可以逻辑上与另一个方法(测试者)配对,该方法将首先验证条件以避免 Null 值。典型的例子是字典的 `ContainsKey` / `GetValue` 组合。
If (peopleDictionary.ContainsKey(34)) {
Person p = peopleDictionary.GetValue(34); //Always successful(?)
}
这种方法适用于类型 2 和 3 的 Null 值,但有两个主要缺陷:
- 由于测试者和执行者是两个独立的操作,因此很难使其成为线程安全的。
- 该技术缺乏内聚性:该模式不强制先进行测试再执行,而逻辑上这两个操作应该始终一起进行。
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
这种方法可以有效地应用于类型 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 引用异常可能听起来很奇怪。然而,如果我们考虑以下两点,它仍然完全有意义:
- Null 引用异常很难排查,而守卫子句异常可以及早、精确地检测并查明问题。
- 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 |
Java, Scala, Groovy |
Find Bugs |
C, C++ |
CppCheck |
C, Objective C |
|
Php |
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 |
识别需要守卫子句的地方 |
参考文献
- Null 对象模式
http://en.wikipedia.org/wiki/Null_Object_pattern - Tester Doer Pattern
http://msdn.microsoft.com/en-us/library/ms229009%28v=vs.110%29.aspx - Tester Doer and Try-X Pattern
http://marchoeijmans.blogspot.com/2012/12/test-doer-and-try-parse-pattern.html - 结果对象模式
http://c2.com/cgi/wiki?ResultObjectPattern - Maybe Monad
http://en.wikipedia.org/wiki/Monad_%28functional_programming%29 - C# 6.0 – Null Propagation Operator
http://davefancher.com/2014/08/14/c-6-0-null-propagation-operator/ - Groovy's Safe Navigation Operator Not as Safe as I Thought https://www.altamiracorp.com/blog/employee-posts/groovys-safe-navigation-operat
- Guard Clauses
http://c2.com/cgi/wiki?GuardClause - Design By Contract
http://en.wikipedia.org/wiki/Design_by_contract - Contracts for Java (cofoja)
https://github.com/nhatminhle/cofoja - 代码契约
http://msdn.microsoft.com/en-us/library/dd264808%28v=vs.110%29.aspx