yokila
yokila
Published on 2024-05-11 / 36 Visits
0
0

计算机科学上最糟糕的错误(译文)

原文链接:THE WORST MISTAKE OF COMPUTER SCIENCE

译者注:原文评论区有很多人对文章内容进行讨论,这里没有一并翻译过来,但是大家有空的话建议一并去看看。

译者注:水平有限,翻译不好的地方请指出。

译文


比 Windows 反斜杠更丑陋,比===更奇怪,比 PHP 更常见,比 CORS 更不幸,比 Java 泛型更令人失望,比 XMLHttpRequest 更不一致,比 C 预处理器更混乱,比 MongoDB 更薄弱(flakier),比 UTF-16 更令人遗憾,计算机科学中最严重的错误是在 1965 年引入的。

20240507-simpson-doh.jpg

我将其称之为我的价值十亿美元的错误......当时,我正在设计面向对象语言中第一个引用综合类型系统。我的目标是确保所有引用的使用都应该是绝对安全的,并由编译器自动执行检查。但是我无法抗拒引入空引用的诱惑,仅仅是因为它是如此容易实现。这导致了无数的错误、漏洞和系统崩溃,在过去四十年中可能造成了十亿美元的痛苦和损失。
– Tony Hoare,ALGOL W 的发明者。

为了纪念 Hoare爵士的 null 50 周年,本文解释了 null 是什么,为什么它如此糟糕,以及如何避免它。

NULL 有什么问题?

简短的回答:NULL 是一个不是值的值。这就是问题所在。

它已经在有史以来最流行的语言中溃烂,现在有许多名称:NULL、nil、null、None、Nothing、Nil、nullptr。每种语言都有自己的细微差别。

NULL 引起的一些问题仅适用于特定语言,而另一些则是普遍存在的;其中一些只是单个问题的不同方面。

NULL......

  1. 颠覆了类型

  2. 是草率的

  3. 是一种特殊情况

  4. 产生了糟糕的 API

  5. 加剧了糟糕的语言决策

  6. 难以调试

  7. 是不可组合的

1. NULL 颠覆了类型

静态类型语言在不实际执行的情况下检查程序中类型的使用,从而为程序行为提供确切的保证。

例如,在 Java 中,如果我编写x.toUppercase(),编译器将检查x的类型。如果x已知是一个String,则类型检查成功;如果x已知是一个Socket,则类型检查失败。

静态类型检查是编写大型复杂软件的有力辅助工具。但对于 Java 来说,这些出色的编译时检查存在一个致命的缺陷:任何引用都可以为 null,并且在 null 上调用方法会产生NullPointerException。因此

  • toUppercase()可以在任意String上被安全地调用...除非String是 null。

  • read()可以在任意InputStream上被调用...除非InputStream为 null。

  • toString()可以在任意Object上被调用...除非Object为 null。

Java 并不是唯一的罪魁祸首;许多其他类型的系统都有相同的缺陷,当然包括 AGOL W。

在这些语言中,NULL 高于类型检查。它悄无声息地从它们身边溜走,等待运行时,最终在一连串的错误中爆发出来。NULL 是无,同时又是一切。

2. NULL是草率的

很多时候,拥有 null 是没有意义的。不幸的是,如果语言允许任何内容为 null,那么,任何内容都可以为 null。

Java 程序员有患上腕管综合症的风险来自于编写以下代码

if (str == null || str.equals("")) {
}

这是一个常见的写法,C# 添加了String.IsNullOrEmpty

if (string.IsNullOrEmpty(str)) {
}

可恶。

每次你编写将 null 字符串(null string) 和 空内容字符串(empty string)混为一谈的代码时,Guava 团队都会哭泣。
—— 谷歌Guava

说得好。但是,当你的类型系统(例如 Java 或 C#)允许 NULL 无处不在时,你就无法可靠地排除 NULL 的可能性,而且几乎不可避免地会混淆到某个地方。

null 无处不在的可能性带来了这样一个问题,以至于 Java 8 添加了@NonNull注释以尝试追溯修复其类型系统中的这个缺陷。

3. NULL 是一种特殊情况

鉴于 NULL 的作用是作为一个不是值的值,因此 NULL 自然会成为各种特殊处理形式的主题。

指针

例如,考虑以下 C++ 代码:

char c = 'A';
char *myChar = &c;
std::cout << *myChar << std::endl;

myChar 是一个char *,这意味着它是指向char的指针(即内存地址)。编译器对此进行验证。因此,以下内容无效:

char *myChar = 123; // compile error
std::cout << *myChar << std::endl;

由于 123 不能保证是char的地址,因此编译失败。但是,如果我们将数字更改为 0(在 C++ 中为 NULL),编译器会编译通过:

char *myChar = 0;
std::cout << *myChar << std::endl; // runtime error

与 123 一样,NULL 实际上不是char的地址。然而,这一次编译器允许它,因为 0 (NULL) 是一个特例。

字符串

另一种特殊情况发生在 C 的 null 结尾字符串上。这与其他示例略有不同,因为没有指针或引用。但是,不是值的值的概念仍然存在,以不是字符的字符的形式存在。

一个 C 字符串是一个字节序列,其末尾由 NUL(0)字节标识。

 76 117  99 105 100  32  83 111 102 116 119  97 114 101  0
 L   u   c   i   d       S   o   f   t   w   a   r   e  NUL

因此,C 字符串的每个字符都可以是可能的 256 字节中的任何一个,但 0(NUL 字符)除外。这不仅使字符串长度成为线性操作;更糟糕的是,这意味着 C 字符串不能用于 ASCII 或扩展 ASCII。相反,它们只能用于不寻常的 ASCIIZ。

单单是 NUL 字符的异常已经导致了无数错误:API 怪异、安全漏洞和缓冲区溢出。

NULL 是最严重的 CS 错误;更具体地说,以 NUL 结尾的字符串是最昂贵的单字节错误

4. NULL 产生了糟糕的 API

在下一个示例中,我们将前往动态类型语言的领域,在那里 NULL 将再次被证明是一个糟糕的错误。

键值存储

假设我们创建了一个充当键值存储的 Ruby 类。这可能是缓存、键值数据库的接口等。我们将使通用 API 变得简单:

class Store
    ##
    # associate key with value
    # 
    def set(key, value)
        ...
    end

    ##
    # get value associated with key, or return nil if there is no such key
    #
    def get(key)
        ...
    end
end

我们可以想象许多语言(Python、JavaScript、Java、C# 等)的类似写法。

现在,假设我们的程序有一种缓慢或资源密集型的方式来查找某人的电话号码(也许是通过访问 Web 服务)。

为了提高性能,我们将使用本地Store作为缓存,将一个人的姓名映射到他的电话号码。

store = Store.new()
store.set('Bob', '801-555-5555')
store.get('Bob') # returns '801-555-5555', which is Bob’s number
store.get('Alice') # returns nil, since it does not have Alice

但是,有些人没有电话号码(即他们的电话号码为 nil)。我们仍将缓存该信息,因此以后不必重新填充它。

store = Store.new()
store.set('Ted', nil) # Ted has no phone number
store.get('Ted') # returns nil, since Ted does not have a phone number

但现在我们结果的意义是模棱两可的!这可能意味着:

  1. 此人在缓存中不存在 (Alice)

  2. 此人存在于缓存中,但是没有电话号码 (Tom)

一种情况需要昂贵的重新计算,另一种情况需要即时应答。但是我们的代码不够复杂以区分这两者。

在实际代码中,这样的情况经常以复杂而微妙的方式出现。因此,简单的通用 API 可能会突然成为特殊情况的、令人困惑的草率的 nullish 行为的来源。

使用contains()方法修补Store类可能会有所帮助。但这会引入冗余查找,导致性能降低和竞态条件(race condition)

双重麻烦

JavaScript 也有同样的问题,但每个对象都有。
如果对象的属性不存在,则 JS 将返回一个值来指示不存在。JavaScript 的设计者可以选择此值为null

但相反,他们担心的是该属性存在并设置值为null的情况。在非天才之举下,JavaScript 添加了undefined来区分 null 属性和不存在的属性。

但是,如果该属性存在,并且设置为值为undefined?奇怪的是,JavaScript 到此为止,没有uberundefined

因此,JavaScript 最终不仅有一种形式,而且有两种形式的 NULL。

5. NULL 加剧了糟糕的语言决策

Java 在引用类型和基本类型之间静默转换。再加上 null,事情就变得更奇怪了。

例如,这不会编译通过:

int x = null; // compile error

这将会编译通过:

Integer i = null;
int x = i; // runtime error

尽管它在运行时会抛出 NullPointerException。

成员方法可以在 null 上调用,这已经够糟糕了;更糟糕的是,你甚至从未看到被调用的方法。

6. NULL 难以调试

C++ 是一个很好的例子,说明 NULL 是多么麻烦。在 NULL 指针上调用成员函数不一定会使程序崩溃。更糟糕的是:它可能会使程序崩溃。

#include <iostream>
struct Foo {
    int x;
    void bar() {
        std::cout << "La la la" << std::endl;
    }
    void baz() {
        std::cout << x << std::endl;
    }
};
int main() {
    Foo *foo = NULL;
    foo->bar(); // okay
    foo->baz(); // crash
}

当我用 gcc 编译它时,第一次调用成功;第二次调用失败。

为什么? foo->bar()在编译时是已知的,因此编译器会避免运行时虚表(vtable)查找,将其转换为静态调用,像是Foo_bar(foo)this作为第一个参数。由于bar不会间接引用 NULL 指针,因此它会成功。但是baz会应用NULL指针,这会导致段错误segmentation fault

但假设我们使得bar变成了virtual。这意味着它的实现可能会被子类覆盖。

    ...
    virtual void bar() {
    ...

作为虚函数,foo->bar()foo的运行时类型执行虚表(vtable)查找,以免bar()被覆盖。由于foo是 NULL,程序现在会在foo->bar()处崩溃,这都是因为我们把一个函数变成了virtual

int main() {
    Foo *foo = NULL;
    foo->bar(); // crash
    foo->baz();
}

对写main的程序员来说,NULL 使调试这段代码变得异常困难且不直观。

当然,间接引用 NULL 是 C++ 标准未定义的,因此从技术上讲,我们不应该对发生的任何事情感到惊讶。尽管如此,这是一个非病态的、常见的、非常简单的、真实世界的例子,是 NULL 在实践中反复无常的众多方式之一。

7. NULL 是不可组合的

编程语言是围绕可组合性构建的:将一个抽象应用于另一个抽象的能力。这也许是任何语言、库、框架、范式、API 或设计模式中最重要的一个特性:能够与其他特性正交使用。

事实上,可组合性确实是许多这些问题背后的根本问题。例如,StoreAPI 为不存在的值返回nil 无法与为不存在的电话号码存储nil进行组合。

C# 通过Nullable<T>解决了 NULL 的一些问题。可以在类型中包含可选性(可为 null )。

int a = 1;     // integer
int? b = 2;    // optional integer that exists
int? c = null; // optional integer that does not exist

但它存在一个Nullable<T>不适用于任意T的严重缺陷。它只能适用于不可为空的T。例如,它不会使Store问题得到改善。

  1. string一开始就是可为空的;你不能制造一个不可为空的string

  2. 即使string不可为空,从而使string?成为可能为空,你仍然无法消除这个情况下的歧义。并不存在string??

解决方案

NULL 已经变得如此普遍,以至于许多人只是认为它是必要的。我们已经在如此多的低级和高级语言中拥有它如此长的时间,它似乎是必不可少的,像是整数运算或 I/O。

不是这样!你可以拥有一个没有 NULL 的完整编程语言。NULL 的问题在于它是一个非值的值,一个哨兵,一个与其他所有东西混在一起的特殊情况。

相反,我们需要一个实体,其中包含有关(1)它是否包含值和(2)包含的值(如果存在)的信息。它应该能够“包含”任何类型。这就是 Haskell 的Maybe、Java 的 Optional、Swift 的Optional等的想法。

例如,在 Scala 中,Some[T]保存T类型的值。 None保存没有值的信息。这是Option[T]的两种子类型,它们可能具有值,也可能不具有值。

20240507-image.png

不熟悉 Maybes/Options 的读者可能会认为我们已经用一种形式的不存在 (NULL) 代替了另一种形式的不存在 (None)。但两者之间还是有区别的——微妙,但至关重要。

在静态类型语言中,不能通过将None替换为任何值来绕过类型系统。None只能被用于我们期望的一个Option。可选性在类型中显式表示。

在动态类型语言中,不能混淆 Maybes/Options 的用法和包含的值。

让我们重温一下前面的Store,但这次使用 ruby-possibly。如果存在,则Store类返回Some及其值,如果不存在,则返回None。对于电话号码,Some表示电话号码,None表示没有电话号码。因此,存在/不存在有两个层级:外部的 “Maybe” 表示在Store中的存在;内部的 “Maybe” 表示该名称的电话号码存在。我们已经成功地创作了 Maybes,这是我们无法用 nil 做到的。

cache = Store.new()
cache.set('Bob', Some('801-555-5555'))
cache.set('Tom', None())

bob_phone = cache.get('Bob')
bob_phone.is_some # true, Bob is in cache
bob_phone.get.is_some # true, Bob has a phone number
bob_phone.get.get # '801-555-5555'

alice_phone = cache.get('Alice')
alice_phone.is_some # false, Alice is not in cache

tom_phone = cache.get('Tom')
tom_phone.is_some # true, Tom is in cache
tom_phone.get.is_some #false, Tom does not have a phone number

本质区别在于,在 NULL 和所有其他类型之间不再存在静态类型或动态假设的组合,存在值和不存在之间不再有无意义的组合。

操纵 Maybes/Options

让我们继续使用更多 non-NULL 代码示例。假设在 Java 8+ 中,我们有一个可能存在也可能不存在的整数,如果它确实存在,我们就打印它。

Optional<Integer> option = ...
if (option.isPresent()) {
   doubled = System.out.println(option.get());
}

这很好。但是大多数 Maybe/Optional 实现,包括 Java 的实现,都支持更好的功能方式:

option.ifPresent(x -> System.out.println(x));
// or option.ifPresent(System.out::println)

这种功能方式不仅更简洁,而且更安全一点。请记住,如果值不存在,则option.get()将产生一个报错。在前面的示例中,get()由一个if守卫。在这个例子中,ifPresent()去除了我们对get()的需求。它使得现在明显没有 bug,而不是没有明显的 bug。

Options 可以看作是最大大小为 1 的集合。例如,如果值存在,我们可以将该值加倍,否则将其留空。

option.map(x -> 2 * x)

我们可以选择执行返回可选值的操作,并“展平(flatten)”结果。(译者注:即把Option<Option<T>>变成Option<T>

option.flatMap(x -> methodReturningOptional(x))

如果不存在,我们可以提供一个默认值:

option.orElseGet(5)

综上所述,Maybe/Option 的真正价值是

  1. 减少关于哪些值“存在”,哪些值不存在的不安全假设

  2. 便于对可选数据进行安全操作

  3. 明确声明任何不安全的存在假设(例如,使用.get()方法)

打倒 NULL!

NULL 是一个糟糕的设计缺陷,它继续造成持续的、无法估量的痛苦。只有少数几种语言设法避免了它的恐怖。

如果你真的选择了一种带有 NULL 的语言,至少要有智慧在自己的代码中避免这种糟糕的情况,并使用等价的 Maybe/Option。

常见语言中的 NULL:

语言

NULL

Maybe

NULL 分数

C

NULL

两星

C++

NULL

boost::optional, from Boost.Optional

三星

C#

null

两星

Clojure

nil

java.lang.Optional

四星

Common Lisp

nil

maybe, from cl-monad-macros

三星

F#

null

Core.Option

四星

Go

nil

两星

Groovy

null

java.lang.Optional

四星

Haskell

Maybe

五星

Java

null

java.lang.Optional

四星

JavaScript (ECMAScript)

null, undefined

Maybe, from npm maybe

一星

Objective C

nil, Nil, NULL, NSNull

Maybe, from SVMaybe

一星

OCaml

option

五星

Perl

undef

两星

PHP

NULL

Maybe, from monad-php

三星

Python

None

Maybe, from PyMonad

三星

Ruby

nil

Maybe, from ruby-possibly

三星

Rust

Option

五星

Scala

null

scala.Option

四星

Standard ML

option

五星

Swift

Optional

五星

Visual Basic

Nothing

两星

“分数”是根据:

没有 NULL。

五星

具有 NULL。在语言或标准库中具有替代方案。

四星

具有 NULL。在社区库有一个替代方案。

三星

具有 NULL。

两星

程序员最可怕的噩梦。具有多个 NULL。

一星

编辑

评级(Ratings)

不要把“评级(Ratings)”看得太重。真正的重点是总结各种语言中 NULL 的状况并展示 NULL 的替代方案,而不是对语言进行常规排名。

一些语言的信息已得到纠正。出于与运行时的兼容性原因,某些语言具有某种空指针(null pointer),但它们在语言本身中并不真正可用。

  • 示例:Haskell 的Foreign.Ptr.nullPtr用于 FFI(外部函数接口),用于在 Haskell 之间传递值。

  • 示例:Swift 的UnsafePointer必须与unsafeUnwrap!一起使用。

  • 反例:Scala 虽然习惯性地避免使用 null,但仍然以与 Java 相同的方式对待 null ,以增加互操作性:val x: String = null

何时可以使用 NULL

值得一提的是,在缩短 CPU 周期、用代码质量换取性能时,相同大小的特殊值(如 0 或 NULL)可能会很有用。这对于那些低级语言(如 C)来说很方便,因为它真的很重要,但它确实应该留在那里。

真正的问题

NULL 的更普遍的问题是哨兵值(sentinel values):与其他值一样处理,但具有完全不同的语义。indexOf返回整数索引或整数 -1 就是一个很好的例子。以 NUL 结尾的字符串是另一个例子。鉴于 NULL 的无处不在和现实世界的影响,这篇文章主要关注 NULL,但正如索伦只是魔苟斯的仆人一样,NULL 也只是哨兵潜在问题的表现。


译者注(扩展阅读)

1、Race Condition(竞态条件)

A race condition or race hazard is the condition of an electronics, software, or other system where the system's substantive behavior is dependent on the sequence or timing of other uncontrollable events, leading to unexpected or inconsistent results. It becomes a bug when one or more of the possible behaviors is undesirable.

The term race condition was already in use by 1954, for example in David A. Huffman's doctoral thesis "The synthesis of sequential switching circuits".

Race conditions can occur especially in logic circuits or multithreaded or distributed software programs.

译文:

竞态条件或竞态冒险是指在电子、软件或其他系统中,系统的实质性行为取决于其他不可控事件的顺序或时机,从而导致意想不到或不一致的结果。当一种或多种可能的行为不理想时,它将成为一个 bug。

竞态条件一词早在 1954 年就已出现,例如在 David A. Huffman 的博士论文《顺序开关电路的合成》中。

在逻辑电路或多线程或分布式软件程序中,尤其会出现竞态条件。

—— 维基百科

更多介绍的文章:

  1. 什么是 Race Condition?

    1. 问题模式:Check-Then-Act(单例模式中通常就会习惯性解决这个问题)

    2. 问题模式:Read-Modfy-Write(多线程修改共享变量通常就会出现这个问题)

  2. Race Condition

  3. What is a race condition?

2、Segmentation Fault(段错误)

In computing, a segmentation fault (often shortened to segfault) or access violation is a fault, or failure condition, raised by hardware with memory protection, notifying an operating system (OS) the software has attempted to access a restricted area of memory (a memory access violation). On standard x86 computers, this is a form of general protection fault. The operating system kernel will, in response, usually perform some corrective action, generally passing the fault on to the offending process by sending the process a signal. Processes can in some cases install a custom signal handler, allowing them to recover on their own, but otherwise the OS default signal handler is used, generally causing abnormal termination of the process (a program crash), and sometimes a core dump.

Segmentation faults are a common class of error in programs written in languages like C that provide low-level memory access and few to no safety checks. They arise primarily due to errors in use of pointers for virtual memory addressing, particularly illegal access. Another type of memory access error is a bus error, which also has various causes, but is today much rarer; these occur primarily due to incorrect physical memory addressing, or due to misaligned memory access – these are memory references that the hardware cannot address, rather than references that a process is not allowed to address.

Many programming languages have mechanisms designed to avoid segmentation faults and improve memory safety. For example, Rust employs an ownership-based model to ensure memory safety. Other languages, such as Lisp and Java, employ garbage collection, which avoids certain classes of memory errors that could lead to segmentation faults.

译文:

在计算机中,段错误(通常简称为 segfault)或访问权限冲突是一种故障或失败条件,由具有内存保护功能的硬件引发,通知操作系统(OS)软件已尝试访问内存的受限区域(违规访问内存)。在标准 x86 计算机上,这是一种通用保护故障。作为回应,操作系统内核通常会执行一些纠正措施,一般是通过向违规进程发送信号,将故障传递给该进程。在某些情况下,进程可以安装自定义信号处理程序,使其能够自行恢复,但在其他情况下,则会使用操作系统默认的信号处理程序,这通常会导致进程非正常终止(程序崩溃),有时还会导致内核转储。

段错误是 C 等语言编写的程序中常见的一类错误,这些语言提供低级内存访问,几乎没有安全检查。出现这种错误的主要原因是在使用指针进行虚拟内存寻址时出现错误,特别是非法访问。另一种内存访问错误是总线错误,它也有各种原因,但如今已少见得多;这些错误主要是由于不正确的物理内存寻址或内存访问错位造成的--这些是硬件无法寻址的内存引用,而不是进程不允许寻址的引用。

许多编程语言都有旨在避免段错误和提高内存安全性的机制。例如,Rust 采用基于所有权的模型来确保内存安全。其他语言(如 Lisp 和 Java)则采用垃圾回收机制,以避免可能导致段错误的某些内存错误。

—— 维基百科

更多介绍的文章:

  1. Segmentation Fault 错误原因总结

  2. What is a segmentation fault?

    1. 常见的触发这个错误的情况:空指针赋值、野指针、只读指针赋值

3、Sentinel Value(哨兵值)

In computer programming, a sentinel value (also referred to as a flag value, trip value, rogue value, signal value, or dummy data) is a special value in the context of an algorithm which uses its presence as a condition of termination, typically in a loop or recursive algorithm.

The sentinel value is a form of in-band data that makes it possible to detect the end of the data when no out-of-band data (such as an explicit size indication) is provided. The value should be selected in such a way that it is guaranteed to be distinct from all legal data values since otherwise, the presence of such values would prematurely signal the end of the data (the semipredicate problem). A sentinel value is sometimes known as an "Elephant in Cairo," due to a joke where this is used as a physical sentinel. In safe languages, most sentinel values could be replaced with option types, which enforce explicit handling of the exceptional case.

译文:

在计算机编程中,哨兵值(也称为标志值、触发值、伪造值、信号值或虚假数据)是算法上下文中的一个特殊值,该算法将哨兵值的存在作为终止条件,通常在循环或递归算法中使用。

哨兵值是一种内部数据,用于在没有提供外部数据(如显式的大小指示)时检测到数据的结束。在选择该值时,应确保它与所有合法数据值不同,否则,这些值的存在将过早地提示数据结束(半谓词问题)。哨兵值有时也被称为 "开罗的大象",这源于一个将其用作物理哨兵的笑话。在安全语言中,大多数前哨值都可以用选项类型来代替,这样就可以对特殊情况进行显式处理。

—— 维基百科


Comment