没有NULL,我们该怎么办?

23

我曾经读到,使用可空类型是一种绝对的恶习。我相信这是由创造它们的人(在Ada语言中?)所写的一篇文章。我相信这篇文章就是

无论如何,那么如果像C#这样的语言默认使用非空类型,你将如何替换一些常见的C#或Ruby等任何其他常见语言中的惯用法,其中null是可接受的值?


3
你对原始数据类型方面有困扰,是吗?比如说有这样一个声明:double x; 你如何判断 x 是否被初始化了?请翻译以上内容。 - KLee1
据我所知,这并不特定于C#。double在大多数语言中都是非空类型。你需要以相同的方式处理对象不为null以及决定double是否已初始化。 - KLee1
6
Tony Hoare:空引用,亿万美元的错误Tony Hoare在2009年的演讲中提到,他发明了null引用,但这被证明是一种“亿万美元的错误”。他解释说,null引用使得程序员必须时刻谨防代码崩溃,并且在代码中处理空值的逻辑会使代码变得更加复杂。他呼吁编程语言设计者避免这种错误,并寻找替代方案。此外,关于这个话题还有一些采访和类似的内容可供参考。 - sth
4
也许使用 intdouble 更好作为例子。通常的 IEEE 浮点数实现中,double 提供了 NaN 值,它们往往可以作为空值的合理替代。 - bcat
NaN 不是 null ... 它意味着你有一个值,但在当前上下文中没有意义;null 意味着你真的什么都没有。 - robert
显示剩余4条评论
11个回答

27

与其斩钉截铁地宣称可为空类型是邪恶的,我认为:大多数语言将可空性附加到整个类型,而这两个概念实际上应该是正交的

例如,所有非原始Java类型(以及所有C#引用类型)都可为空。为什么?我们可以来回讨论,但最终我打赌答案归结为“这很容易”。Java语言本身并没有要求广泛使用可空性。C++引用提供了一个很好的例子,展示了如何在编译器级别消除nulls。当然,C++具有更多丑陋的语法,而Java明确试图遏制这些语法,因此一些好的特性和坏的特性一起被砍掉。

C# 2.0中的可空值类型迈出了正确的一步——将nullability与不相关类型语义或更糟糕的CLR实现细节分离开来——但它仍然缺少一种处理引用类型相反的方式。(代码契约很好,但它们没有嵌入到我们正在讨论的类型系统中。)

许多功能性或其他晦涩的语言从一开始就理解了这些概念……但如果它们得到广泛使用,我们就不会进行这种讨论了……

回答您的问题:在现代语言中,全面禁止nulls与所谓的“十亿美元错误”一样愚蠢。有些有效的编程结构需要nulls:可选参数、任何默认/回退计算,在其中合并运算符会导致简洁的代码,与关系数据库的交互等等。强迫自己使用哨兵值、NaN等将是比疾病更严重的“治疗方法”。

话虽如此,我暂时同意引用中表达的观点,只要我能够详细阐述我的经验:

  1. 大多数人认为需要nulls的情况要少得多
  • 一旦在库或代码路径中引入了null,要摆脱它们比添加它们困难得多。(所以不要让初级程序员凭空添加它们!)
  • 可空错误随着变量寿命的增加而扩大。
  • #3的推论:尽早崩溃。

  • 3
    在那些没有很好定义可选参数的语言中,你可能需要使用NULL来表示(也可能不需要,这取决于你想要多有创意)。但一般情况下,实现可选参数并不需要使用NULL。 - slebetman
    “True”。仅仅因为null可以用来实现可选参数,并不意味着它们是最佳的方式——特别是当我们开始谈论一些可能有自己定制语法糖的特定语言时。 - Richard Berg
    1
    我了解你在C#中的Nullable值类型的问题。Nullable使用起来有点笨拙,但这只是一个语法问题。(我宁愿仅使用“var”本身而不是“var.Value”来访问“var”的值。)创建一个相反的东西——默认情况下是引用/可空类型,但可以选择转换为值/非可空类型——肯定需要更多的泛型类和语法糖。 - Brian S
    有两种方法可以使类型成为非空: (1) 允许特定的固定默认值; (2) 要求创建包含该类型字段的对象或数组槽位必须导致调用一个类型特定的构造函数,在容器对象在任何地方公开之前必须完成。 第一种选择在某些情况下可能很有用,但在许多情况下,最明智的默认值是陷阱表示。 第二个选择有时可能有用,但在许多情况下会减慢常见情况的速度(在读取之前,数组中的每个槽位都将被重写)。 - supercat

    24

    我们将使用选项类型来表示那些允许为空值的(非常少见的)情况,并且由于任何对象引用都保证指向适当类型的有效实例,因此我们将减少很多难以捉摸的错误。


    2
    不是这样的。只是默认情况下不允许空值,而你需要在必要时明确表示空值是可以接受的。 - Richard Wolf
    6
    选项类型是一种可空类型。然而,具有不同类型的语言通常具有类型为非空的属性。某些类型(如浮点数或列表)可能在其域中内置了 null 类型。选项类型使我们能够在需要时将 nullability 引入其他类型。问题并不在于 nullability 的存在;而在于它的无处不在。如果您构建的语言是非空的,则选项类型可以让您在适当的情况下重新引入 nullability。 - Michael Ekstrand
    5
    一个选项类型和可空类型的不同之处在于编译器不允许你在先检查是否为 null 之前使用值。在 F# 中,它看起来像这样:match opt with | Some(value) -> do_something_with value | None -> oops_its_null。如果您尝试执行 do_something_with opt,则会收到编译时类型错误。 - Jason Orendorff
    4
    这是正确的答案。这也反映了 Stack Overflow 投票人群并没有给它更多的赞,这很令人遗憾。 - Jason Orendorff
    3
    @Gabe:可选类型和可空类型之间存在一些差异。其中一个是您可以拥有一个可选的可选项(即一种类型,其值为 None、Some(None) 和 Some(x)),这在具有参数化多态性的语言中特别重要。另一个区别是对于程序员来说,需要学习或实现的概念少了一个:可选项只是许多数据结构(0或1个x),例如列表(任意数量的按顺序排列的x)、数组(某个固定数量的x)、对(一个x和一个y)等。 - Gilles 'SO- stop being evil'
    显示剩余3条评论

    7

    Haskell是一种强大的语言,没有空值的概念。基本上,每个变量都必须初始化为非空值。如果你想表示一个“可选”的变量(变量可能有一个值,但也可能没有),你可以使用特殊的“Maybe”类型。

    在Haskell中实现这个系统比在C#中容易,因为在Haskell中数据是不可变的,所以在稍后填充空引用没有意义。然而,在C#中,链表中的最后一个链接可能具有指向下一个链接的空指针,在列表扩展时会被填充。我不知道一个没有空类型的过程化语言会是什么样子。

    此外,需要注意的是,许多人似乎建议用类型特定的逻辑“无值”值(999-999-9999,“NULL”等)替换空值。这些值并没有真正解决任何问题,因为人们对空值的问题是它们是一个特殊情况,但人们忘记编写特殊情况的代码。使用类型特定的逻辑无值值,人们仍然会忘记编写特殊情况的代码,但他们避免了捕捉此错误的错误,这是一件坏事。


    4

    是的,刚刚发现通过(非常小的)[non-nullable]标签。 - Earlz

    4
    您可以采用一个简单的规则:所有变量都会被初始化(默认情况下,可以覆盖)为不可变值,由变量的类定义。对于标量,这通常是某种形式的零。对于引用,每个类将定义其“null”值,引用将被初始化为指向此值的指针。
    这实际上是NullObject模式的一种语言级实现: http://en.wikipedia.org/wiki/Null_Object_pattern 因此,它并没有真正消除空对象,只是使它们不再是必须作为特殊情况处理的特例。

    7
    不,它们仍然是特殊情况,必须作为这样处理。否则你会遇到更难以调试的错误,因为它们被静默忽略而不是立即引发异常。 - Gabe
    如果(foo == 0)并不比If(foo == null)更优雅。在许多情况下,它比一开始允许空值更有问题。我知道你只是回答问题,而不是为Hoare的立场辩护,但我忍不住要评论一下... - Richard Berg
    @Gabe,如果空值的含义不仅仅是“什么也不做”,那么就需要对空值进行测试。我认为这些测试的位置应该来自于测试和设计,但我从未使用过这种工作方式的编程语言(嘿,也许有原因!),所以我不知道它在实践中能否正常工作。 - ergosys
    1
    我理解这种模式。如果你的语言特别不具表现力,我同意这有时是唯一的方法。但我认为这会更糟。至少,null引用将会早期崩溃并提供堆栈跟踪。而一个不正确的MyClass.Null可能会无限期地未被检测到。它们可能数量上较少,但调试听起来更加隐匿。 - Richard Berg
    null是一个哨兵值时,编译器可以告诉我每次需要检查它或插入自己的检查并抛出异常。使用空对象模式,我只需要猜测在哪里需要放置检查,而历史表明程序员不擅长放置此类检查。 - Gabe
    显示剩余4条评论

    3

    Null不是问题,问题在于语言允许你编写读取可能为空的值的代码。

    如果语言要求任何指针访问都必须首先进行检查或转换为非空类型,那么99%与Null相关的错误将会消失。例如,在C++中。

    void fun(foo *f)
    {
        f->x;                  // error: possibly null
        if (f)              
        {
            f->x;              // ok
            foo &r = *f;       // ok, convert to non-nullable type
            if (...) f = bar;  // possibly null again
            f->x;              // error
            r.x;               // ok
        }
    }
    

    不幸的是,这无法应用于大多数语言,因为它会破坏很多代码,但对于新语言来说是相当合理的。


    2
    Tcl是一种语言,不仅没有null的概念,而且null本身的概念与该语言的核心不一致。在Tcl中,我们说:“一切都是字符串”。它真正意味着的是Tcl具有严格的值语义(默认为字符串)。
    那么Tcl程序员用什么来表示“无数据”?大多数情况下是使用空字符串。在某些情况下,如果空字符串可以表示数据,则通常是以下之一:
    1. 无论如何都使用空字符串——大多数情况下对最终用户没有影响。
    2. 使用您知道在数据流中不存在的值——例如字符串“_NULL_”或数字9999999或我的最爱NUL字节“\0”。
    3. 使用包装在值周围的数据结构——最简单的是列表(其他语言称为数组)。一个元素的列表表示该值存在,零个元素表示null。
    4. 测试变量的存在性-[info exists variable_name]。
    有趣的是,Tcl并不是唯一具有严格值语义的语言。C也具有严格值语义,但默认语义是整数,而不是字符串。
    哦,几乎忘记了另外一个:
    一些库使用第二种方法的变体,允许用户指定“无数据”的占位符。基本上,它允许您指定默认值(如果您不指定,则默认值通常为一个空字符串)。

    是的,在Tcl中,您基本上有某些类似于null的东西,但最终会变得更糟。使用神奇值比使用nulls要糟糕得多。 - devoured elysium

    1

    我们会创建各种奇怪的结构来传达对象“无效”或“不存在”的消息,正如其他答案中所看到的。这是null可以很好地传达的信息。

    • Null Object模式有其缺点,我在这里解释了
    • 特定于域的nulls。这会强制你检查魔数,这是不好的
    • 集合包装器,其中空集合表示“没有值”。Nullable包装器会更好,但这与检查null或使用Null Object模式没有太大区别。

    个人而言,我会编写一些C#预处理器,使我可以使用null。然后这将映射到一些dynamic对象,每当在其上调用方法时都会抛出NullReferenceException

    回到1965年,空引用可能看起来像是一个错误。但现在,有各种代码分析工具警告我们空引用,我们不必太担心。从编程角度来看,null是一个非常有价值的关键字。


    参数化选项类型是特定于域的,易于编译器和读者进行检查。除了低级实现之外,空值没有合法的用途。 - bltxd

    1

    实际上,在任何允许指针或对象引用的强大编程语言中,都会出现代码能够访问未经任何初始化代码运行的指针的情况。可能可以保证这些指针将被初始化为某些静态值,但这似乎并不是非常有用的。如果机器有一种通用的方法来捕获对未初始化变量(无论是指针还是其他东西)的访问,那比特殊处理空指针更好,否则我看到的最大的与空值相关的错误发生在允许使用空指针进行算术运算的实现中。将5添加到(char*)0不应该产生一个字符指针以寻址5;它应该触发一个错误(如果创建指向绝对地址的指针是适当的,应该有其他手段来完成它)。


    1

    如果没有NULL,我们该怎么办?发明它!:-) 如果您正在寻找一种表示实际上不是指针的内部指针值,那么即使不是火箭科学家也可以使用0。


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