空对象 vs 空值对象

17

[这是一个最佳实践:函数应该返回null还是一个空对象的结果?,但我试图非常通用。]

在我看过的许多传统(嗯...生产)C ++代码中,有一种倾向于编写很多NULL(或类似的)检查来测试指针。其中许多是在发布周期的最后阶段添加的,当添加NULL 检查提供了快速修复由指针解引用引起的崩溃时--并且没有足够的时间进行调查。

为了解决这个问题,我开始编写使用(const) 引用参数而不是更常见的传递指针的技术的代码。没有指针,也就没有必要检查NULL(忽略实际上有一个空引用的极端情况)。

在 C# 中,同样存在与 C++ 相同的“问题”:希望检查每个未知引用是否为nullArgumentNullException),并通过添加null检查来快速修复NullReferenceException

对我来说,避免这种情况的一种方法是使用空对象(String.Empty, EventArgs.Empty)。另一种方法是抛出异常而不是返回null

我刚开始学习 F#,但似乎在那个环境中很少有null对象。所以也许你真的没有许多null引用浮动?

我在这里走错方向了吗?


2
你一直在看糟糕的 C++ 代码。C++ 程序应该使用异常来避免无效对象,引用来避免指针等。任何接受指针而非 const 引用(或按值传递)的代码都是糟糕的代码。 - GManNickG
2
F#中的空对象较少,因为它倾向于使用选项。请参见http://srtsolutions.com/blogs/chrismarinos/archive/2009/09/10/option-types-vs-nullable-types.aspx进行比较。 - Tony Lee
2
Toney Hoare称空引用为他的十亿美元错误:http://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare - Tony Lee
3
10年前的C++程序员没有开发出好的C++程序。 老旧不等于好。 - GManNickG
6
我根本不同意在C++中使用异常会使它更好的观点。指针与引用、异常与返回值都是重要的东西,但并不是好代码或坏代码的指标。重要的是结构、一致性和良好的惯例。使用某种语言特性与否只是方便之处。说10年前的C++代码因为这个原因就不能是好代码,不仅幼稚无知,而且是一种侮辱。 - Chris Walton
显示剩余2条评论
11个回答

33

仅仅为了避免NullReferenceException而传递非空参数,相当于用一个更加微妙、难以调试的问题去换取一个简单明了、易于解决的问题(“它失败是因为它为空”)(“堆栈中的几个调用未能按照预期运行是因为远在很久以前它得到了一些没有有意义信息但不是空的对象”)。

NullReferenceException是一个好东西!它失败得又快又响,而且通常很容易确定和修复。它是我最喜欢的异常,因为当我看到它时,我的任务只需要大约2分钟的时间。相比之下,令人困惑的QA或客户报告试图描述必须要重现并追溯源头的奇怪行为。真恶心。

这完全归结于您作为方法或代码的角色可以合理地推断出调用您的代码。如果您被给予了一个null引用,并且您可以合理地推断出调用方可能的意思是什么(例如,可能是一个空集合?),那么您肯定应该处理null。但是,如果您无法合理地推断出如何处理null或者调用方通过null想表达什么意思(例如,调用代码要求您打开一个文件并将位置设为null),那么您应该抛出一个ArgumentNullException

在每个“网关”点(代码中的功能逻辑边界)保持正确的编码规范——NullReferenceExceptions应该会更加罕见。


5
仅仅为了修复潜在错误而将null替换为默认对象是不好的。然而,往往情况下,返回null具有意义,但是null本身无法表达该含义。当需要含义时,使用空或默认对象是一个不错的解决方案。硬错误虽好,但它是一个错误,应该被视为这样。在代码中处理null并不是错误,而是意味着你的设计是错误的(或者至少可以改进),因为null显然变得有意义了。 - Abel
如果你被交给一个 null 对象,建议重新表达以清楚区分空引用和空对象。 - itowlson
2
我会为您查找参考资料,但是由于600个字符的限制,我必须简要概括。在我看来,null的含义是数据的缺失,这本身就是一种含义。然而,在代码中,特别是对于初学者,你会发现null意味着“访客用户”或null意味着“本地主机”,这被测试和if语句随处可见。这就是我所说的“一种含义”,而这不应该是null所代表的。null是“没有数据”,“错误”,“没有实例”。这是一种含义,但是不同的含义。请注意,这与Win32 API或C++中的NULL不同,后者通常具有明确的含义,这让很多人感到恼火。 - Abel
@Abel 我完全同意 - null 不应该作为某些真实值的占位符,这仅仅是默认值。 - Rex M
@Dan 仅仅为了消除 null 而消除 null,并防止人们进行太多的 null 检查,这不是一个好的目标。 - Rex M
显示剩余3条评论

8

我倾向于怀疑有大量NULL的代码,并尽可能使用异常、空集合、Java Optionals等进行重构。

Martin Fowler在Refactoring(第260页)中提出的“引入Null Object”模式也可能会有所帮助。Null Object对真实对象的所有方法做出响应,但以“正确”的方式执行。因此,不要总是检查Order是否为NULL,而是确保Order在这些情况下具有NullDiscountPolicy。这简化了控制逻辑。


5

我支持使用null。不过,我的想法是“失败快速”。

String.IsNullOrEmpty(...)也非常有用,因为它捕捉到了null或空字符串的情况。你可以为你传递的所有类编写类似的函数。


2
你必须使用C++编程才能享受到引用的乐趣,我认为你肯定会改变主意的。 - Khaled Alshaya
我有一些C++的经验,从来没有遇到过这种问题。我记得每当我想要跳过一些代码时,会用'if(MyRef)'来包装我的MyRef使用,以便简单地检查引用是否为空,这对我来说不是什么大问题。或者我在这里漏掉了什么? - Chris
只是为了澄清,当我们谈论“引用”时,我们正在谈论C++指针吗? - Chris
我们可能在谈论C++引用,而不是指针。而且C++引用永远不会为空。 - sth

3
如果你编写的代码返回null作为错误条件,那么不要这样做:通常,你应该抛出异常 - 这样更难被忽视。
如果你在使用可能返回null的代码时,大多数情况下这些是愚蠢的异常:也许在调用者处进行一些Debug.Assert检查来在开发期间对输出进行感性检查。在生产中,你不应该真正需要庞大数量的null检查,但如果某个第三方库无法预测地返回大量null,那么当然可以进行检查。
在4.0版本中,你可能需要查看代码契约;这使你能够更好地控制说“此参数不应传递为null”,“此函数永远不会返回null”等,并且系统会在静态分析(即构建时)期间验证这些声明。

当所有返回值都是有效的,并且您需要指示无效响应并且不是异常情况时,返回null似乎是合理的选择(与返回某些结构体对象相比,该对象具有一个字段来决定其他字段是否具有意义,或修改参数甚至全局状态)。 - Alpedar

2
关于 null 的问题是它本身没有实际意义,仅仅是对象的缺失。
因此,如果你真的需要一个空字符串、集合或其他东西,应该返回对应的对象而不是 null。如果所使用的编程语言允许,一定要这样做。
当你需要返回的值无法用静态类型来指定时,有很多选择。返回 null 是其中一个答案,但是并没有实际意义,可能存在一些风险。抛出异常可能更符合你的需求。你可以通过特殊情况扩展类型(通常使用多态,即特殊情况模式(Null 对象模式的一个特例))。你也可以将返回值封装在一个具有更多含义的类型中,或者传入回调对象。通常会有很多选择。

+1 for "might" and "may". 但是在上下文中,null可能具有意义。 - NVRAM
嗯,你可以赋予null意义。你甚至可以在注释中对它进行一定程度的文档化。但是,这里并没有固有的含义。实际上,实现代码可能会将其解释为与客户端代码编写者所认为的意思正好相反的含义。 - Tom Hawtin - tackline
但这是任何其他程序员代码反应的值的问题。-2是什么意思? - Alpedar

1

如果你想在一个“无null”环境中进行编程,那么请考虑更频繁地使用扩展方法,它们不会受到NullReferenceExceptions的影响,并且至少“假装”null已经不存在了:

public static GetExtension(this string s)
{
    return (new FileInfo(s ?? "")).Extension;
}

可以被称为:

// this code will never throw, not even when somePath becomes null
string somePath = GetDataFromElseWhereCanBeNull();
textBoxExtension.Text = somePath.GetExtension(); 

我知道,这只是方便之举,许多人正确地认为它违反了面向对象的原则(尽管面向对象的“创始人”Bertrand Meyer认为null是邪恶的,并完全禁止在他的面向对象设计中使用,这适用于Eiffel语言,但这是另一回事)。编辑:丹提到比尔·瓦格纳(More Effective C#)认为这是一种不良实践,他是正确的。你曾经考虑过使用IsNull扩展方法吗?;-)

为了使您的代码更易读,另一个提示可能会有所帮助:在对象为空时更经常地使用空合并运算符来指定默认值:

// load settings
WriteSettings(currentUser.Settings ?? new Settings());

// example of some readonly property
public string DisplayName
{
    get 
    {
         return (currentUser ?? User.Guest).DisplayName
    }
}

这些代码并没有消除对null的偶尔检查(而??仅仅是一个隐藏的if-分支)。我尽可能在我的代码中少用null,因为我认为这会使代码更可读。当我的代码因为null而凌乱不堪时,我知道设计中有问题,需要重构。我建议任何人都可以这样做,但我知道在这个问题上意见各不相同。

(更新)与异常比较

到目前为止讨论中未提到的是与异常处理的相似之处。当您发现自己在考虑某事时总是无处不在地忽略null,它基本上就相当于写下:

try 
{
    //...code here...
}
catch (Exception) {}

这会导致移除任何异常的痕迹,只是在代码的后面引发不相关的异常。虽然我认为避免使用null是好的,正如在本主题中之前提到的那样,对于异常情况来说,使用null是好的。只是不要将它们隐藏在null-ignore块中,这最终会产生与catch-all-exceptions块相同的效果。


我有同样的书,也知道他的观点。总的来说,我同意那个观点。"如果你是认真的"开头部分是带有讽刺意味的,使用这种方法,除了极少数例外情况,不应成为常规做法。关于用户的例子显然是简化的,也许选择不当,但我希望说明如何使用 ?? 使代码更清晰,当您必须在空值时切换到默认对象时。 - Abel
更新了示例,也许这些更能说明??运算符的使用。 - Abel

1
我尽量避免在方法中返回null。通常有两种情况 - 当null结果是合法的,以及永远不应该发生的情况。
在第一种情况下,当没有结果是合法的时候,有几种解决方案可用于避免与它们相关的null结果和null检查:Null对象模式特殊情况模式可以返回什么都不做或在特定情况下执行某些特定操作的替代对象。
如果可以合法地返回空对象,但在Null Object或Special Case方面仍然没有合适的替代品,则我通常使用Option函数类型 - 然后可以在没有合法结果时返回一个空选项。然后由客户端决定如何处理空选项。

最后,如果从方法返回任何对象都是不合法的,因为如果缺少某些东西,该方法无法产生其结果,则我选择抛出异常并停止进一步执行。


1

我认为这要看情况而定。对于返回单个对象的方法,我通常会返回null。对于返回集合的方法,我通常会返回一个空的集合(非null)。不过这些更像是指导方针而非严格规则。


我同意,但是当创建/填充集合失败时返回null,我不想抛出异常。 - NVRAM

1
对于异常主角来说,它们通常源于事务性编程和强制异常安全保证或盲目的准则。在任何相当复杂的情况下,即异步工作流程、I/O 和特别是网络代码中,它们都不合适。你可以看到为什么在 C++ 中有 Google 风格的文档以及所有好的异步代码“不强制执行它”(也可以考虑你最喜欢的托管池)。
其中还有更多内容,虽然它看起来像一种简化,但它确实是那么简单。首先,在不为重度异常使用而设计的代码中,您会得到很多异常......无论如何,从世界顶级库设计师那里了解这方面的知识,boost 通常是一个好去处(只是不要将其与 boost 中喜欢异常的其他阵营混淆,因为他们必须编写音乐软件 :-))。
在您的情况下,这不是 Fowler 的专长,由于可用的转换机制(也许但并非总是通过dominance) C++ 才可能实现高效的“空对象”成语。另一方面,在您的 null type 中,您可以抛出异常并在保留干净的调用站点和代码结构的同时执行所需的操作。
在C#中,您的选择可以是一个类型的单个实例,该实例可以是良好的或畸形的;因此,它能够抛出异常或者简单地运行。因此,它可能会违反其他合同(取决于您认为哪种代码质量更好)。
最终,它确实可以清理调用站点,但不要忘记您将面临许多库的冲突(特别是来自容器/字典的返回值,末尾迭代器令人想起,以及任何其他与外部世界进行“接口”编码)。此外,空值检查是极其优化的机器代码片段,这是需要记住的,但我同意,任何一天都会使用野指针而不了解constness、引用等会导致不同类型的可变性、别名和性能问题。
此外,没有任何银弹,在受控空间中崩溃空引用或使用空引用,或抛出而不处理异常是相同的问题,尽管托管和异常世界会尝试向您销售。任何良好的环境都可以保护您免受这些问题(你甚至可以在任何操作系统上安装任何过滤器,你认为虚拟机还能做什么),而且有很多其他攻击向量,这个已经被过度强调了。再次介绍谷歌的 x86 验证,他们自己的方式更快、更好地进行“IL”、“动态”友好的代码等。
在这方面要凭直觉,权衡利弊并本地化影响...将来您的编译器将优化所有这些检查,比任何运行时或编译时人类方法更有效率(但对于跨模块交互来说不那么容易)。

1

空对象比空值对象好在哪里?你只是改变了症状的名称。问题在于,函数的合同定义过于宽松,“这个函数可能返回一些有用的东西,也可能返回一个虚拟值”(虚拟值可能是null、一个“空对象”或者一个像-1这样的魔法常量)。但无论如何表达这个虚拟值,调用者在使用返回值之前仍然必须检查它。

如果你想清理你的代码,解决方案应该是缩小函数的范围,使其首先不返回虚拟值。

如果你有一个可能返回值,也可能不返回值的函数,那么指针是一种常见(也是有效的)表达方式。但通常情况下,你的代码可以重构,以消除这种不确定性。如果你能保证函数返回有意义的东西,那么调用者就可以依赖它返回有意义的东西,然后他们就不必检查返回值了。


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