了解DART中的零安全

2020-07-26 00:30:11

零安全性是自从我们在DART 2.0中用健全的静态类型系统替换了原来不完善的可选类型系统以来,我们对DART所做的最大更改。当DART首次推出时,编译时空安全是一个需要长时间介绍的罕见功能。今天,科特林、斯威夫特、拉斯特和其他语言都有自己的答案,这已经成为一个非常熟悉的问题。下面是一个例子:

如果在没有空安全性的情况下运行此DART程序,它将在调用.length时引发NoSuchMethodError异常。空值是Null类的实例,并且Null没有“length”getter。RuntimeFailures太烂了。在像DART这样被设计为在终端用户设备上运行的语言中尤其如此。如果服务器应用程序出现故障,您通常可以在任何人注意到之前重新启动它。但是,当一款颤动应用程序在用户手机上崩溃时,他们会很不高兴。当你的用户不高兴时,你也不高兴。

开发人员喜欢像DART这样的静态类型语言,因为它们使类型检查器能够在编译时发现代码中的错误,通常是在IDE中。越早发现错误,就越早可以修复它。当语言设计者纠缠于“修复空引用错误”时,他们的意思是丰富静态类型检查器,以便语言可以检测到类似上面尝试调用可能为空的值的.length的错误。

这个问题没有一个真正的解决方案。Rust和Kotlin都有他们自己的方法,这在这些语言的上下文中是有意义的。这个医生详细介绍了我们对DART的回答。它包括对静态类型系统的更改,以及一套其他修改和新的语言特性,让您不仅可以编写空安全代码,而且希望您可以享受这样做的乐趣。

这份文件很长。如果您想要更短的内容来涵盖启动和运行所需了解的内容,请从概述开始。当您准备更深入地理解并且有时间时,请回到这里,这样您就可以了解语言是如何处理NULL的,为什么我们这样设计它,以及如何编写惯用的、现代的、NULL安全的DART。(剧透提醒:它的结局与您今天编写DART的方式惊人地接近。)。

一种语言处理空引用错误的各种方法各有优缺点。这些原则指导了我们做出的选择:

默认情况下,代码应该是安全的。如果您编写了新的DART代码,并且没有使用任何显式不安全的特性,那么它在运行时就不会抛出空引用错误。静态捕获所有可能的空引用错误。如果您希望将某些检查推迟到运行时以获得更大灵活性,您可以这样做,但您必须通过使用代码中文本上可见的某些功能来选择。

换句话说,我们不会给你一件救生衣,让你每次出海时都记得穿上救生衣。取而代之的是,我们给你一艘不会沉没的船。除非你跳下船,否则你不会湿的。

空安全代码应该很容易编写。大多数现有的DART代码都是动态正确的,不会抛出空引用错误。您喜欢DART程序现在的外观,我们希望您能够继续以这种方式编写代码。安全不应该要求牺牲可用性,不应该向类型检查器支付赎罪,也不应该要求显著改变您的思维方式。

生成的空安全代码应该是完全正确的。静态检查上下文中的“稳健性”对不同的人意味着不同的事情。对于US,在NULL安全的上下文中,这意味着如果表达式具有不允许NULL的非静态类型,则该表达式的任何可能的执行都不能计算为NULL。该语言通过静态检查提供了这一保证,但也可能涉及一些运行时检查。(不过,请注意第一个原则:您可以选择运行时检查点的任何地方。)。

稳健性对于用户信心非常重要。一艘主要停留在安全地带的船不会让你热衷于在公海上冒险。但这对我们勇敢的编译器黑客来说也很重要。当语言对程序的语义属性做出硬保证时,这意味着编译器可以执行假设这些属性为真的优化。当涉及到NULL时,这意味着我们可以生成更小的代码来消除不必要的NULL检查,并且更快的代码不需要在调用接收器的方法之前验证它是非NULL。

有一点需要注意:我们只保证完全零安全的DART程序的健全性。DART支持混合包含较新的空安全代码和较旧的旧旧代码的程序。在这些“混合模式”程序中,仍然可能出现空引用错误。在混合模式程序中,您可以在空安全的部分获得所有静态安全好处,但在整个应用程序为空安全之前,您不会获得完全的运行时安全性。

请注意,消除NULL不是目标。Null没有错,相反,能够表示没有avalue是非常有用的。将对特殊“缺席”值的支持直接构建到语言中,使得在缺席的情况下工作变得灵活和可用。它支持可选的参数,方便吗?支持NULL的运算符和默认初始化。不是空是不好的,而是在你意想不到的地方有空去,这会导致问题。

因此,对于NULL安全性,我们的目标是让您控制和洞察NULL可以在哪里流过您的程序,并确定它不会流过会导致崩溃的地方。

空安全始于静态类型系统,因为其他一切都依赖于此。您的DART程序中有大量的类型:像int和String这样的原始类型,像List这样的集合类型,以及您和您使用的包定义的所有类和类型。在NULL安全之前,静态类型系统允许值NULL流入任何这些类型的表达式。

在类型理论行话中,Null类型被视为所有类型的子类型:

某些表达式上允许的一组操作-getter、setter、method和Operators-由其类型定义。如果类型是列表,您可以对其调用.add()或[]。如果它是int,您可以调用+。但是null值没有定义这些方法中的任何一个。允许NULL流入某个其他类型的表达式意味着这些操作中的任何一个都可能失败。这确实是NULL引用错误的症结所在--每一次失败都来自于试图在NULL上查找它没有的方法或属性。

Null安全通过更改类型层次结构从根本上消除了该问题。Null类型仍然存在,但它不再是所有类型的子类型。相反,类型层次结构如下所示:

由于Null不再是子类型,因此除了特殊的Null类之外,任何类型都不允许值为Null。默认情况下,我们已使所有类型都不可为空。如果您有一个字符串类型的变量,它将始终包含一个字符串。至此,我们已经修复了所有空引用错误。

如果我们认为null一点用处都没有,我们可以到此为止。但是NULL是有用的,所以我们仍然需要一种方法来处理它。可选参数就是一个很好的说明性例子。请考虑此空安全DART代码:

Make Coffee(串咖啡,[串?乳制品]){if(乳制品!=NULL){打印(";$咖啡与$乳制品";);}否则{打印(";黑$咖啡";);}}。

在这里,我们希望允许DILE参数接受任何字符串或NULL值,但不接受其他任何内容。为了表达这一点,我们通过拍打来给乳制品一个可为空的类型?位于基础基类型字符串的末尾。在幕后,这实质上是定义基础类型和Nulltype的联合。那么弦呢?如果DART具有功能齐全的联合类型,则将是string|Null的简写形式。

如果你有一个可以为空类型的表达式,你能对结果做什么呢?因为我们的原则在默认情况下是安全的,所以答案不是很多。我们不能让您在其上调用底层类型的方法,因为如果值为空,这些方法可能会失败:

如果我们让你运行它会崩溃的。我们唯一可以让您安全访问的方法和属性是由基础类型和Null类定义的方法和属性。这只是toString()、==和hashCode。因此,您可以将可为空的类型用作映射键,将它们存储在集合中,将它们与其他值进行比较,并在字符串插值中使用它们,但仅此而已。

它们如何与不可为空的类型交互?将不可空类型传递给需要可空类型的对象始终是安全的。如果函数接受字符串?那么传递字符串是允许的,因为它不会引起任何问题。我们通过使每个可空类型成为其底层类型的超类型来对此进行建模。您还可以安全地将NULL传递给需要可空类型的对象,因此Null也是每个可空类型的子类型:

但反过来,将可空类型传递给预期基础不可空类型的对象是不安全的。需要字符串的代码可以调用值上的字符串方法。如果你传递一个字符串呢?对于它,NULL可能会流入,这可能会失败:

这个程序不安全,我们不应该允许它。然而,达特一直有一种叫做隐含向下投射的东西。例如,如果将Object类型的值传递给需要字符串的函数,则类型检查器允许这样做:

为了保持可靠性,编译器会悄悄地将AS字符串强制转换为requiesStringNotObject()的参数。该强制转换可能会失败并在运行时抛出异常,但是在编译时,DART说这是可以的。Sburon-可空类型被建模为可空类型的子类型,隐式向下转换会让您传递字符串吗?指向需要字符串的对象。允许这样做会违反我们默认安全的目标。因此,在零安全的情况下,我们将完全移除隐式向下转换。

这会使对RequireStringNotNull()的调用产生编译错误,这正是您想要的。但这也意味着所有隐式向下转换都会变成编译错误,包括对requistringNotObject()的调用。你得自己加上明显的悲观情绪:

我们认为这总体上是一个很好的变化。我们的印象是,大多数用户从来不喜欢隐含的向下投射。特别要指出的是,你以前可能已经被这件事灼伤过:

发现窃听器了吗?Where()方法是惰性的,所以它返回一个Iterable,而不是一个列表。当此程序试图将该Iterable转换为filterEvens声明其返回的List类型时,它会编译,但随后会在运行时抛出异常。删除隐式向下转换后,这将成为编译错误。

我们说到哪儿了?好的,就好像我们把你程序中的所有类型分成了两部分:。

存在不可为空类型的区域。这些类型允许您访问所有有趣的方法,但永远不能包含NULL。然后是所有对应的可空类型的并行族。这些允许为空,但是您不能用它们做很多事情。我们让值从不可为空的一侧流向可为空的一侧,因为这样做是安全的,但不是相反的方向。

这似乎表明可为空的类型基本上毫无用处。他们没有办法,你离不开他们。别担心,我们有一整套功能来帮助您将值从可为空的那一半转移到我们很快就会达到的另一边。

这一部分有点晦涩难懂。您基本上可以跳过它,除了最后的两个obullet,除非您对类型系统感兴趣。想象一下,程序中的所有类型之间都有边,而这些边是彼此的子类型和超类型。如果你要画它,就像这个文档中的图表一样,它会形成一个巨大的有向图,在顶部附近有像Object这样的超类型,在底部附近有像你自己的类型这样的叶类。

如果有向图到达顶部某一点,其中有一个类型(直接或间接)是超类型,则该类型称为顶层类型。同样,如果在底部有一个奇怪的类型,它是每个类型的子类型,那么您就有了一个底部类型。(在本例中,您的有向图是晶格。)。

如果您的类型系统具有顶层和底层类型,这是很方便的,因为这意味着类型级操作,如最小上界(类型推理使用该操作根据条件表达式的两个分支的类型确定条件表达式的类型)总是可以生成类型。在Null安全之前,Object是DART的顶层类型,Null是底层类型。

由于对象现在不可为空,因此它不再是top类型。NULL不是它的子类型。DART没有命名的TOP类型。如果您需要顶级字体,您想要Object吗?同样,Null不再是底部类型。如果是这样的话,一切都还是可以为空的。相反,我们添加了一个名为Never的新底层类型:

如果要指示允许任何类型的值,请使用Object?代替Object。事实上,使用Object变得非常不寻常,因为该类型意味着“可能是除了这个奇怪的禁止值NULL之外的任何可能的值”。

在需要Bottom类型的极少数情况下,使用Never代替Null。如果你不知道是否需要底型,你很可能不需要。

我们将类型的宇宙分为可为空的和不可为空的两个部分。为了保持健全性和我们的原则,即除非您请求,否则您永远不会在运行时得到null引用错误,我们需要保证null从不出现在不可为null端的任何类型中。

消除隐式向下强制转换,并将Null作为底层类型移除,覆盖了类型流经程序、跨赋值以及从参数进入函数调用的参数的所有主要位置。NULL可以潜入的其余主要位置是当变量第一次出现时以及当您离开函数时。因此,还有一些额外的编译错误:

如果函数具有不可为空的返回类型,则通过该函数的每条路径都必须到达返回值的RETURN语句。在NullSafety之前,达特对丢失退货相当松懈。例如:

如果你分析了这个,你会得到一个温和的提示,也许你忘了退货,但如果没有,没什么大不了的。这是因为如果执行到达函数体的末尾,则DART隐式返回NULL。因为每个类型都可以为空,所以从技术上讲,这个函数是安全的,即使它可能不是您想要的。

对于健全的非空类型,此程序是完全错误和不安全的。在空安全性下,如果返回类型不可为空的函数不能可靠地返回值,则会出现编译错误。我所说的“可靠地”,是指语言分析通过函数的所有控制流路径。只要他们都还东西,就满足了。分析相当智能,因此即使此函数也可以:

String AlwaysReturns(Int N){if(n==0){return';Zero';;}Else if(n<;0){抛出ArgumentError(';负值不允许。';);}Else{if(n>;1000){return';BIG';}Else{Return。ToString();}。

在下一节中,我们将更深入地研究新的流分析。

当您声明一个变量时,如果您没有给它一个显式的初始值设定项,Dartdefault会用NULL初始化该变量。这很方便,但是如果变量的类型是不可为空的,那么显然是完全不安全的。所以我们必须收紧不可为空的变量:

顶级变量和静态字段声明必须具有初始值设定项。由于这些变量可以从程序中的任何位置访问和赋值,因此编译器不可能保证变量在使用之前已经被赋值。唯一安全的选择是要求声明本身具有产生正确类型的值的初始化表达式:

实例字段必须在声明处有初始值设定项,使用初始化形式,或者在构造函数的初始化列表中进行初始化。那是一大堆行话。以下是示例:

类SomeClass{int at声明=0;int initializingFormal;int initializationList;SomeClass(this.。InitializingFormal):initializationList=0;}。

换句话说,只要字段在您到达构造函数体之前有一个值,您就是好的。

局部变量是最灵活的情况。不可为空的局部变量不需要有初始值设定项。这非常好:

Int tracingFibonacci(Int N){int result;if(n<;2){result=n;}Else{result=tracingFibonacci(n-2)+tracingFibonacci(n-1);}print(Result);return result;}。

规则只是在使用局部变量之前必须对其进行明确赋值。对于这一点,我们也可以依赖于我提到的新的流程分析。只要变量使用的每个路径都先对其进行初始化,那么使用就可以了。

可选参数必须有默认值。如果您没有为可选的位置参数或命名参数传递实参,则语言将使用默认值填充它。如果不指定默认值,则默认值为NULL,如果参数的stype不可为空,则不会出现这种情况。

因此,如果希望参数是可选的,则需要使其可为空或指定有效的非空默认值。

这些限制听起来很繁重,但实际上并不算太差。它们与现有的关于最终变量的限制非常相似,您使用这些限制已经很多年了,甚至没有真正注意到。另外,请记住,这些只适用于不可为空的变量。您可以始终将类型设置为可空,然后将默认初始化设置为空。

即便如此,这些规则确实会引起摩擦。幸运的是,我们有一套新的语言特性来润滑最常见的模式,这些新的限制会降低您的速度。不过,首先是讨论流分析的时候了。

控制流分析在编译器中已经存在多年了。它大部分对用户是隐藏的,并且在编译器优化期间使用,但是一些新的语言已经开始使用相同的技术来实现可见的语言特性。DART已经以类型提升的形式提供了少量的流分析:

Bool isEmptyList(Object Object){if(Object Is List){Return Object。IsEmpty;//<;--确定!}否则{return false;}}。

注意,在标记的行上,我们可以调用isEmpty on Object。该方法是在列表上定义的,而不是在对象上定义的。这之所以有效,是因为类型检查器会查看程序中的所有IS表达式和控制流路径。如果某个控制流构造的主体仅在变量上的某个IS表达式为真时才执行,那么在该主体内,变量的类型被“提升”为被测试的类型。

在这里的示例中,If语句的Then分支仅在Object实际包含列表时运行。因此,DART将Object提升为类型列表,而不是其声明的类型Object。这是一个很方便的功能,但是非常有限.。在空安全之前,以下功能相同的程序不起作用:

Bool isEmptyList(Object Object){if(Object is!List)返回false;返回Object。IsEmpty;//<;--错误!}

同样,只有当Object包含列表时才能访问.isEmpty调用,因此该程序是动态正确的。但是类型提升规则并不聪明,因为它认为返回语句意味着只有当Object是列表时才能缓存第二条语句。

为了零安全,我们采用了这个有限的分析,并在几个方面使其更加强大。

首先,我们修复了长期存在的抱怨,即类型提升在早期返回其他无法访问的代码路径方面不够聪明。在分析函数时,它现在会考虑返回、中断、抛出,并且任何其他方式的执行都可能在函数中提前终止。在零安全情况下,此函数:

现在是完全有效的。由于if语句将在Object不是列表时退出函数,因此DART会将Object提升为在第二个语句中列出。这是一个非常好的改进,它帮助了很多DART代码,甚至是与可空性无关的代码。

你也可以。

.