原文作者:
发布时间:2020年12月8日-9分钟阅读
几周前,我们宣布了Dart null安全测试版,这是一个重要的生产力功能,旨在帮助你避免空值错误。说到空值,在/r/dart_lang子reddit中,最近有用户问道:"为什么我们还有空值?"
但为什么我们还需要空值呢?为什么不把它完全去掉呢?我目前也在玩Rust,它根本就没有null。所以似乎可以不用它。
我喜欢这个问题。为什么不彻底摆脱null呢?这篇文章是我在那个帖子上回答的扩展版。
简短的回答是,是的,没有null是完全可以生存的,像Rust这样的语言也是如此。但是程序员确实会使用null,所以在我们夺走它之前,我们需要了解为什么要使用它。当我们在有null的语言中使用它时,null通常在做什么?
原来null通常用来表示没有值,这是非常有用的。有些人没有中间名。有些人没有中间名,有些邮寄地址没有公寓号。有些怪物在你击杀它们时没有任何宝藏可掉落。
在这样的情况下,我们希望有一种方法来表达:"这个变量可能有一个类型为X的值,也可能根本没有值。" 那么问题是我们如何建模呢?
一种选择是说,一个变量可以包含一个预期类型的值,也可以包含神奇的值null。如果我们尝试使用null时的值,我们就会得到一个运行时失败。这就是Dart在null安全之前所做的事情,SQL所做的事情,Java对非原始类型所做的事情,以及C#对类类型所做的事情。
但是在运行时失败是很糟糕的。这意味着我们的用户体验到了bug。我们程序员宁愿在他们之前发现这些失败。事实上,如果我们能在运行程序之前就发现bug,我们会很高兴。那么,我们如何以类型系统能够理解的方式来模拟一个值的缺失?换句话说,我们如何给 "可能不存在 "的值和 "肯定存在 "的值赋予不同的静态类型?
主要有两种解决方案。
- 使用一个选项或者类型
- 使用可空类型
解决方案1:期权类型
这就是ML和大多数从ML衍生出来的函数式语言(包括Rust、Scala和Swift)所做的事情。当我们知道我们肯定会有一个值时,我们只需要使用底层类型。如果我们写int,就意味着,"这里肯定有一个整数"。
为了表达一个可能不存在的值,我们将底层类型包裹在一个选项类型中。所以Option<int>表示一个可能是整数的值,也可能什么都不是。它就像一个集合类型,可以包含零或一项。
从类型系统的角度来看,int和Option<int>之间没有方向关系。将这两种类型视为不同的类型意味着我们不能意外地将一个可能不存在的 Option<int>传递给期望真正的 int 的东西。我们也不能意外地试图将 Option<int>当作一个整数来使用,因为它不支持任何这些操作。我们不能对 Option<int>进行算术运算,就像我们不能对 List<int>进行运算一样。
为了从标的类型的现值(比如 3)中创建一个期权类型的值,我们可以像Some(3)那样构造期权。为了在值不存在的情况下创建一个期权类型,我们可以写一些类似于 None()的东西。
为了使用存储在 Option<int>中的一个可能不存在的整数,我们必须首先检查并查看该值是否存在。如果有,我们可以从选项中提取整数并使用它,就像从一个集合中读取一个值一样。有选项类型的语言通常也有很好的模式匹配语法,这给我们提供了一个优雅的方法来检查值是否存在,如果存在就使用它。
解决方案2:可空类型
另一种选择(呵呵),是Kotlin、TypeScript和现在的Dart所做的。可空类型是联合类型的一个特例。
(切题。命名在这里变得非常混乱。选项类型--也就是上面ML和朋友们所做的--是代数数据类型的一个特例。代数数据类型的另一个名字是 "判别联合"。但是,尽管名字里有 "联合",但 "判别联合 "与 "联合类型 "是完全不同的。正如Phil Karlton所说,计算机科学中只有两个难点:缓存无效和给事物命名)。)
类似于期权类型的方法,我们用底层类型来表示一个绝对的现值。所以int又意味着我们绝对有一个整数。如果我们想要一个可能不存在的整数,我们就改用int? nullable类型。这个小问号是语法上的糖,用来写本质上是一个联合类型,比如int | null。
就像选项类型一样,可空类型不支持与底层类型相同的操作。类型系统不会让我们尝试在一个可空类型上执行算术,因为那不安全。同样,我们也不能将一个可空型整数传递给需要实际整数的东西。
然而,类型系统比选项类型更灵活一些。类型系统理解一个联合类型是其分支的超类型。换句话说,int是int?的一个子类型。这意味着我们可以将一个绝对存在的整数传递给一个期望可能存在的整数的东西,因为这样做是安全的。这是一个上位变量,就像我们可以把一个String传给一个取Object的函数一样。Dart只是禁止我们走另一条路--从nullable到non-nullable--因为那会是一个下投,而且那些会失败。
当我们有一个可空类型的值,我们想看看那里是否有一个实际的值或null值,我们必须检查这个值,就像我们在C或Java中自然会做的那样。
foo(int? i) {
if (i != null) {
print(i + 1);
}
}
然后,语言使用流程分析来确定程序中哪些部分是在这些检查后面进行防护的。分析确定只有在变量不为null的情况下,代码才能到达,所以在这些区域里面,类型系统会将变量的类型收紧为不可空。所以,在这里,它在if语句里面把i当作有类型的int。
一个语言应该采取哪种解决方案呢?
那么,当我们Dart团队决定让语言以更安全的方式处理null时,我们应该如何去选择解决方案1或2呢?我们可以从观察我们的用户开始。他们希望如何编写检查无值的代码?在函数式语言中,模式匹配是主要的控制流结构之一,那里的用户对它非常适应。在这种风格下,使用选项类型和模式匹配是很自然的。
在由C语言派生出来的命令式语言中,像我前面的例子这样的代码是检查null值的惯用方式。使用流式分析和可空类型,使得这种熟悉的代码能够正确、安全地工作。事实上,在Dart中,我们发现大多数现有的代码在使用新的类型系统时已经是静态空安全的,因为新的流式分析正确地分析了已经写好的代码。
(这在某些方面并不奇怪。大多数代码在处理null方面已经是动态正确的。如果不是这样,它就会一直崩溃。大部分的工作只是让类型系统足够聪明,能够看到这些代码已经是正确的,这样用户的注意力就会被吸引到少数不正确的地方。)
所以,如果我们的目标是最大限度地提高熟悉度和用户的舒适度(这是语言设计的重要标准),我们就应该遵循我们语言的控制流结构为我们铺设的路径。
表示缺席和存在
基于期权类型和可空类型的表示方式的差异,有一种更深层次的方法来处理这个问题。这种表示方式的差异迫使我们进行一些关键的权衡,这些权衡可能使我们倾向于一个方向或另一个方向。
在第一种方法下,期权类型的值有一个不同于底层值的运行时表示。假设我们在Dart中选择了期权类型,你创建了一个,然后将其上传到Object中。
var optionalInt = Some(3);
Object obj = optionalInt;
print(obj is int); // false
注意最后一行。一个 Option<int>值,即使存在,也与基础类型的值不是同一种东西。Some(3) 和 3 是不同的、可区分的值。
这不是nullable类型的工作方式。
var nullableInt = 3 as int?;
Object obj = nullableInt;
print(obj is int); // true
可空类型存在于静态类型系统中,但数值的运行时表示使用底层类型。如果你有一个 "可空型3",在运行时它只是数字3。如果你有某个可空类型的不存在的值,在运行时,你只是有孤独的魔力值null。
你可以询问一个值是否为可空类型。
print(obj is int?);
但is int? 表达式相当于:。
print(obj is int || obj is Null);
嵌套选项
由于期权类型的值与标的类型不同,这就给了我们一个重要的能力。选项类型可以嵌套。
比方说,我们有一些网络服务,当给定某个整数ID的请求时,会给出资源字符串。有些资源不存在,服务器会响应该ID没有数据。由于打网络的速度很慢,所以我们希望将已经执行的请求结果进行本地缓存。
在空安全之前的Dart中,我们可能会使用这样的地图。
Map<int, String> cache;
所以在对某个ID进行网络请求之前,我们使用缓存地图上的下标操作符来查询资源的ID。该运算符在Map上的定义是,如果key不存在,则返回null。但是key也可能存在,并且关联一个null值。如果我们进行查询,得到的结果是null,这可能意味着两种情况。
- 键在地图中不存在 这意味着我们还没有完成请求,所以我们应该要求服务器查找资源。
- 键是存在的,并且与
null相关联。这意味着我们已经询问了服务器,发现资源不存在,并将其存储在缓存中。我们应该使用这个结果,而不是再次查询服务器。
因为整个系统中只有一个null值,所以我们没有一个运行时的表示方式可以区分这两种情况。这就是为什么Map类有一个单独的containsKey()方法。该API提供了一种区分这两种情况的方法。
现在,如果Dart是围绕选项类型构建的,那么缓存就会是这样的。
Map<int, Option<String>> cache;
而下标操作符将返回一个可选的值。
class Map<K, V> {
Option<V> operator [](K key) => ....
...
}
在我们的Map<int, Option<String>>中,这意味着返回类型是Option<Option<String>>。注意嵌套! 现在,当我们在缓存中查找一个键时,我们可以得到一些不同的结果。
Some(Some(string))意味着资源在服务器上确实存在,我们现在在缓存中就有它。Some(None())表示我们确实询问了服务器,但资源并不存在,所以我们已经缓存了资源不存在的事实。None()表示缓存中根本不包含这个ID。
我们可以区分最后两种情况,因为选项总是将其底层值包裹在一些额外的状态中。在运行时,我们可以确定有多少层,并分别剥离它们。
可空类型,因为它们没有显式的运行时表示,所以被隐式地扁平化了。所以int?和int??是类型系统的等价类型,在运行时具有等价的值集。这就是为什么选项类型的粉丝将它们描述为 "更具表现力 "的原因:因为选项类型为你提供了一种比可空类型更多种类的值的表示方法。
可空类型的替换
关于 "表现力 "的另一种思考方式是,用户需要付出多少努力才能表达他们真正想要表达的东西。如果用户能够在跳过更少的圈子的同时达到他们的目标,那么这种语言的表达能力就会更强。
对于可空类型没有明确的表示方法的一个好处是,值可以更容易地从非可空类型流向可空类型。比方说,你有一个接受一个可选整数参数的函数。对于选项类型,签名会是这样的。
takesMaybeInt(Option<int> optionalInt) {}
要用一个已知的整数来调用这个函数,必须先用一个选项来包装。
takesMaybeInt(Some(3));
对于可空类型,由于没有表示方式上的差异,你可以直接传递一个底层类型的值。
takesMaybeInt(3);
在类型系统中,你可以随处获得这种灵活性。你可以重写一个返回可空类型的方法来返回一个非可空类型。你可以将一个List<int>传递给一个想要List<int?>的函数。
因此,虽然可空类型失去了嵌套和表示多种不同类型的 "缺失 "的能力,但作为回报,它们使我们更容易使用null这个受祝福的概念。
Dart的可空性
Dart是一种命令式语言,人们已经使用if语句在运行时检查不存在的值。它也是一种面向对象的语言,我们已经有了一个特殊的null值,有了自己的运行时表示。所以解决方案2,可空类型,是我们自然而然的答案。它让我们的用户可以写出他们熟悉的代码,并利用运行时已经存在的表示值的方式。
关于Dart中nullability的更多信息,请查看Dart null安全文档中的Where to learn more部分。
通过www.DeepL.com/Translator (免费版)翻译