空指针与空对象模式


27

归因:这源于一个相关的P.SE问题

我的背景是C / C ++,但是我在Java中工作了很多,目前正在编写C#。由于我的C语言背景,检查传递的指针和返回的指针是二手的,但是我承认这偏颇了我的观点。

最近,我提到了空对象模式,其中的想法是始终返回对象。正常情况下返回预期的填充对象,错误情况下返回空对象而不是空指针。前提是调用函数将始终具有某种要访问的对象,因此避免了空访问内存冲突。

  • 那么,与使用Null Object Pattern相比,Null Check的利弊是什么?

我可以看到带有NOP的更干净的调用代码,但是我还可以看到它将在何处创建隐藏的失败,否则这些失败将不会引发。我宁愿让我的应用程序在开发过程中严重失败(又称异常),而不愿让无声的错误逃脱。

  • 空对象模式不能像不执行空检查一样具有类似的问题吗?

我使用过的许多对象都拥有自己的对象或容器。似乎我必须有一个特殊情况,才能保证所有主要对象的容器都有自己的空对象。多层嵌套似乎会使这种情况变得难看。


不是“错误情况”,而是“在所有情况下都是有效对象”。

@ThorbjørnRavnAndersen-很好,我编辑了问题以反映这一点。如果我仍然错过了NOP的前提,请告诉我。

6
简短的答案是“空对象模式”是错误的称呼。更准确地说,它称为“空对象反模式”。通常,使用“错误对象”更好-一个实现类的整个接口的有效对象,但是对它的任何使用都会导致响亮的突然死亡。
杰里·科芬

1
@JerryCoffin该情况应通过异常指示。这个想法是,您永远无法获得null,因此不需要检查null。

1
我不喜欢这种“模式”。但是,它可能植根于过度约束的关系数据库,在这些数据库中,当所有外键完全不为空时,在最终更新之前,在可编辑数据暂存期间更容易在外键中引用特殊的“空行”。

Answers:


24

因为发生了灾难性的故障,您不会在返回null(或Null Object)的地方使用Null Object Pattern。在那些地方,我将继续返回null。在某些情况下,如果没有恢复,那么您也可能会崩溃,因为至少崩溃转储将准确指出问题发生的位置。在这种情况下,当您添加自己的错误处理时,您仍然会终止该过程(再次,我说过没有恢复的情况),但是您的错误处理将掩盖崩溃转储将提供的非常重要的信息。

空对象模式更适用于在找不到对象的情况下可以采取默认行为的地方。例如,考虑以下内容:

User* pUser = GetUser( "Bob" );

if( pUser )
{
    pUser->SetAddress( "123 Fake St." );
}

如果您使用NOP,则应编写:

GetUser( "Bob" )->SetAddress( "123 Fake St." );

请注意,此代码的行为是“如果Bob存在,我想更新他的地址”。显然,如果您的应用程序要求Bob出现,那么您不想静默地成功。但是在某些情况下,这种行为是适当的。在这种情况下,NOP不会产生更简洁明了的代码吗?

在您真的不能没有Bob的地方,我会让GetUser()抛出一个应用程序异常(即非访问冲突或类似问题),该异常将在更高级别处理并报告一般操作失败。在这种情况下,不需要NOP,也不需要显式检查NULL。IMO,那些检查NULL的方法,只会使代码更大,并且会失去可读性。对于某些接口,检查NULL仍然是正确的设计选择,但不如某些人想的那么多。


4
同意使用空对象模式的用例。但是,为什么在遇到灾难性故障时返回null?为什么不抛出异常?
Apoorv Khurasia 2012年

2
@MonsterTruck:将其反转。在编写代码时,请确保验证从外部组件收到的大部分输入。但是,在内部类之间,如果我编写的代码使得一个类的函数永远不会返回NULL,那么在调用方,我不会仅为了“更加安全”而添加null检查。该值可能为NULL的唯一方法是,如果我的应用程序中发生了灾难性的逻辑错误。在那种情况下,我希望我的程序完全在那个位置崩溃,因为这样我就得到了一个不错的崩溃转储文件,并反转堆栈和对象状态以识别逻辑错误。
DXM 2012年

2
@MonsterTruck:通过“反向表示”,我的意思是,当您识别出灾难性错误时不要显式返回NULL,但是希望NULL仅由于某些无法预料的灾难性错误而发生。
DXM 2012年

现在,我了解了灾难性错误的含义-导致对象分配本身失败的原因。对?编辑:@DXM不能做,因为您的昵称只有3个字符。
Apoorv Khurasia 2012年

@MonsterTruck:关于“ @”的事情很奇怪。我知道其他人以前做过。我想知道他们是否最近对网站进行了调整。不必分配。如果我写了一个类,而API的目的是总是返回一个有效的对象,那么我就不会让调用者进行null检查。但是灾难性错误可能类似于程序员错误(导致返回NULL的拼写错误),或者某些竞争条件在其时间之前擦除了对象,或者某些堆栈损坏。本质上,任何导致NULL的意外代码路径都将返回。当发生这种情况时,您想要...
DXM 2012年

7

那么,与使用Null Object Pattern相比,Null Check的利弊是什么?

优点

  • 空检查更好,因为它可以解决更多情况。并非所有对象都具有正常的默认行为或无操作行为。
  • 空检查更为可靠。甚至具有默认默认值的对象也用于那些默认默认值无效的地方。如果代码将要失败,它应该在根本原因附近失败。如果代码将失败,则显然应该失败。

缺点

  • Sane的默认设置通常会产生更清晰的代码。
  • 如果Sane的默认设置成功进入野外,通常可以减少灾难性错误。

根据我的经验,最后一个“优点”是何时应采用每种方法的主要区别。“失败会带来噪音吗?”。在某些情况下,您希望失败是艰难而直接的。如果某种情况永远都不会发生,那将会发生。如果找不到重要资源...等等。在某些情况下,您可以使用默认值,因为这并不是真正的错误:从字典中获取值,但是例如缺少键。

像其他任何设计决策一样,取决于您的需求,也存在着弊端。


1
在“优点”部分:同意第一点。第二点是设计人员的错,因为即使在不应该使用null的地方也可以使用null。不会说任何关于模式的不好之处,只是反映了错误使用模式的开发人员。
Apoorv Khurasia 2012年

什么是更严重的失败,即在收款人没有收到钱且在明确检查之前不知道的情况下,无法进行汇款或无法(因此不再拥有)汇款?
user470365 2012年

缺点:Sane的默认设置可用于构建新对象。如果返回非空对象,则在创建另一个对象时可以修改其某些属性。如果返回空对象,则可以使用它创建一个新对象。在两种情况下,相同的代码均有效。无需单独的代码即可进行复制-修改和新建。这是哲学的一部分,即每一行代码都是错误的另一个地方。减少行数可减少错误。
shawnhcorey 2015年

@shawnhcorey-什么?
Telastyn 2015年

空对象应该像真实对象一样可用。例如,考虑IEEE的NaN(不是数字)。如果方程式出错,则将其返回。它可以用于进一步的计算,因为对其进行的任何操作都将返回NaN。由于您不必在每次算术运算后都检查NaN,因此可以简化代码。
shawnhcorey 2015年

6

我不是客观信息的好来源,但是主观上:

  • 我不想我的系统永远死掉;对我来说,这是系统设计不良或不完整的迹象;完整的系统应该处理所有可能的情况,并且永远不要进入意外状态;为了实现这一点,我需要明确地建模所有流程和情况;使用null并不是很明确,而是将许多不同的情况归为一类。我更喜欢NonExistingUser对象而不是null,我更喜欢NaN而不是null,...这样的方法使我可以根据需要尽可能多地详细说明这些情况及其处理方式,而null则只剩下null选项。在像Java这样的环境中,任何对象都可以为null,那么为什么您还要在通用案例池中也隐藏您的特定案例?
  • 空实现的行为与对象的行为大不相同,并且大多粘在所有对象的集合上(为什么?为什么?为什么?);在我看来,这种巨大的差异似乎是由于设计了null来表示错误,在这种情况下,除非明确处理,否则系统最有可能死掉(我很惊讶,很多人实际上更喜欢他们的系统死于上述职位)。对我来说,这是一种有缺陷的方法,它的根本原因是-它允许系统默认情况下死掉,除非您明确地进行了处理,这不是很安全吗?但是在任何情况下,都无法编写以相同方式处理null和object值的代码-只有很少的基本内置运算符(赋值,等于,参数等)可以工作,所有其他代码都将失败,而您需要两条完全不同的路径;
  • 使用Null Object可以更好地缩放-您可以根据需要在其中添加尽可能多的信息和结构;NonExistingUser是一个很好的例子-它可以包含您尝试接收的用户的电子邮件,并建议根据该信息创建新用户,而对于null解决方案,我需要考虑如何保留人们尝试访问的电子邮件接近空结果处理;

在Java或C#之类的环境中,它不会给您带来显式性的主要好处-解决方案的安全性是完整的,因为您仍然可以在系统中的任何对象位置接收null,并且Java无法实现(Bean的自定义注释除外) ,等等)来保护您,除非是明确的ifs。但是,在没有无效实现的系统中,以这种方式(以对象代表特殊情况)来解决问题,则可以带来所有的好处。因此,尝试阅读除主流语言以外的内容,您会发现自己正在改变编码方式。

通常,Null / Error对象/返回类型被认为是非常可靠的错误处理策略,并且在数学上非常合理,而Java或C的异常处理策略仅比“ die ASAP”策略高出一个步骤-因此,基本上将所有负担都留给了开发人员或维护人员上系统的不稳定和不可预测性。

还有一些monad可以被认为优于该策略(一开始理解起来也很复杂),但是更多的是关于您需要为类型的外部方面建模而Null Object为内部方面建模的情况。因此,NonExistingUser可能更好地建模为monad mayn_existing。

最后,我认为Null Object模式与静默处理错误情况之间没有任何联系。这应该是另一种方式-它应该迫使您明确地对每个错误情况进行建模,并相应地进行处理。当然,现在您可能会决定变得草率(无论出于何种原因),而跳过显式处理错误案例的步骤,而是以一般方式进行处理-那是您的明确选择。


9
当发生意外情况时,我希望我的应用程序失败的原因是-意外情况发生。这是怎么发生的?为什么?我得到了故障转储,可以调查并解决潜在的危险问题,而不是将其隐藏在代码中。在C#中,我当然可以捕获所有意外的异常以尝试恢复应用程序,但是即使如此,我仍要记录问题发生的位置,并找出是否需要单独处理(即使“不要”)。记录此错误,它实际上是预期的并且可以恢复”。
a安

3

我认为有趣的是,根据您的语言是否为静态类型,空对象的生存能力似乎有些不同。Ruby是一种实现Null(或Nil用语言的术语)的对象的语言。

语言不会返回指向未初始化内存的指针,而是会返回object Nil。语言是动态键入的,这有助于实现这一点。不能保证函数总是返回intNil如果需要这样做,它可以返回。用静态类型的语言很难做到这一点,因为您自然希望null可以应用于可以通过引用调用的任何对象。您必须开始实现可能为null的任何对象的null版本(或采用Scala / Haskell路线并具有某种“也许”包装器)。

MrLister提出了这样一个事实:在C ++中,初次创建时不能保证它具有初始化的引用/指针。通过使用专用的空对象而不是原始的空指针,您可以获得一些不错的附加功能。Ruby Nil包含一个to_s(toString)函数,该函数导致空白文本。如果您不介意在字符串上返回空值,并且希望跳过报告而不会崩溃,那就太好了。Open Classs还允许您在需要时手动覆盖输出Nil

当然,所有这些都可以与一粒盐一起服用。我相信在动态语言中,正确使用null对象会带来极大的便利。不幸的是,要充分利用它,可能需要您对其基本功能进行编辑,这会使您的程序难以为那些习惯于使用其更标准表单的人们所理解和维护。


1

对于其他观点,请看一下Objective-C,其中的值nil类代替了没有Null对象的工作,就像一个对象。无论方法的返回值类型如何,发送到nil对象的任何消息均无效,并且返回值0 / 0.0 / NO(false)/ NULL / nil。这在MacOS X和iOS编程中被用作非常普遍的模式,通常将设计方法,以便使nil对象返回0是合理的结果。

例如,如果要检查一个字符串是否包含一个或多个字符,可以编写

aString.length > 0

如果aString == nil,则可以得到合理的结果,因为不存在的字符串不包含任何字符。人们倾向于花一些时间来适应它,但是可能要花一些时间来习惯于检查其他语言中的所有零值。

(还有一个NSNull类的单例对象,其行为完全不同,在实践中很少使用)。


Objective-C使Null Object / Null Pointer事情变得非常模糊。nil#define'd'设置为NULL并表现为空对象。这是运行时功能,而在C#/ Java中,空指针会发出错误。
Maxthon Chan

0

如果可以避免,请勿使用任何一种。使用工厂函数返回选项类型,元组或专用求和类型。换句话说,用不同于保证默认值的类型表示可能默认值。

首先,让我们列出所需的内容,然后考虑如何用几种语言(C ++,OCaml,Python)表达它

  1. 在编译时捕获默认对象的不必要使用。
  2. 在读取代码时,可以很明显地看出给定值是否可能是默认值。
  3. 选择每种类型的明智的默认值(如果有)。绝对不要在每个呼叫站点都选择一个可能不同的默认值
  4. 使静态分析工具或人类易于grep寻找潜在的错误。
  5. 对于某些应用程序,如果意外给定默认值,则程序应正常运行。对于其他应用程序,如果给出默认值,则程序应立即停止运行,最好是提供信息性的方式。

我认为Null对象模式和null指针之间的关系来自(5)。但是,如果我们能够尽早发现错误,那么(5)就没有意义了。

让我们按语言来考虑这种语言:

C ++

在我看来,C ++类通常应该是默认可构造的,因为它简化了与库的交互,并使在容器中使用该类更加容易。由于您无需考虑要调用哪个超类构造函数,因此它还简化了继承。

但是,这意味着您无法确定类型的值是否MyClass处于“默认状态”。除了bool nonempty在运行时放入一个或类似字段以显示默认值外,您所能做的最好的事情就是以强迫用户对其进行检查的方式生成的新实例MyClass

我建议使用工厂函数返回一个std::optional<MyClass>std::pair<bool, MyClass>或R值参照std::unique_ptr<MyClass>如果可能的话。

如果要让工厂函数返回某种不同于默认构造的“占位符”状态MyClass,请使用std::pair并确保将其记录为文档。

如果factory函数具有唯一的名称,则很容易grep使用该名称并查找草率。但是,grep对于程序员本应该使用工厂功能但没有使用工厂功能的情况来说,这很难。

OCaml

如果使用OCaml之类的语言,则可以只使用一种option类型(在OCaml标准库中)或一种either类型(不在标准库中,但可以轻松滚动自己的类型)。或一种defaultable类型(我是用术语组成的defaultable)。

type 'a option =
    | None
    | Some of 'a

type ('a, 'b) either =
    | Left of 'a
    | Right of 'b

type 'a defaultable =
    | Default of 'a
    | Real of 'a

defaultable上面显示的A 比一对更好,因为用户必须进行模式匹配以提取,'a并且不能简单地忽略该对的第一个元素。

defaultable可以在std::variant具有两个相同类型实例的C ++中使用与上面所示类型等效的类型,但是std::variant如果两个类型相同,则部分API无法使用。std::variant由于它的类型构造函数是未命名的,所以这也是一个奇怪的用法。

蟒蛇

无论如何,您都没有针对Python进行任何编译时检查。但是,由于是动态类型的,因此通常在任何情况下都不需要某种类型的占位符实例来放置编译器。

当您被迫创建默认实例时,我建议您只抛出一个异常。

如果那是不可接受的,我建议创建一个从工厂函数DefaultedMyClass继承MyClass并从工厂函数返回的。如果需要的话,这可以为您提供默认情况下“残存”功能方面的灵活性。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.