所有权宣言 - Swift 官方文章 Ownership Manifesto 译文评注版(上)

技术文章 来源:OneV's Den 发布:2017-03-16 浏览:863

摘要:Swift 团队最近在邮件列表里向社区发了一封邮件,讲述了关于内存所有权方面的一些未来的改变方向。作为上层 API 的使用者来说,我们可能并不需要了解背后所有的事实,但是 Apple 的这封邮件中对 Swift 的值和对象的内存管理进行了很全面的表述,一步步说明了前因后果。如果你想深入学习和了解 Swift 的话,这篇文章是非常棒的参考资料。我尝试翻译了一下全文,并且加上了一些自己的注解。虽然这篇文章比较长,但是如果你想要进阶 Swift 的话,不妨花时间通读全文 (甚至通读全文若干遍)。

Swift 团队最近在邮件列表里向社区发了一封邮件,讲述了关于内存所有权方面的一些未来的改变方向。作为上层 API 的使用者来说,我们可能并不需要了解背后所有的事实,但是 Apple 的这封邮件中对 Swift 的值和对象的内存管理进行了很全面的表述,一步步说明了前因后果。如果你想深入学习和了解 Swift 的话,这篇文章是非常棒的参考资料。我尝试翻译了一下全文,并且加上了一些自己的注解。虽然这篇文章比较长,但是如果你想要进阶 Swift 的话,不妨花时间通读全文 (甚至通读全文若干遍)。

如果你没有时间通读全文,又想简单了解一下到底发生了什么的话,可以往下翻到最后,有一个我自己的简易的总结版本。

这篇文档本身是对今后 Swift 方向的一个提案,所以涉及的关键字和具体实现细节可能会有出入,不过这并不影响文章背后的思想。您可以在 Swift 的 repo 里找到这篇文档的原文

这篇文章很长,读起来会比较花时间,不过因为内容还算循序渐进,只要静下心来就不会有太多困难。我在有些部分添加了个人的注解,会补充介绍一些背景知识和我自己的看法,你可以将它看成是我个人在读译本文时的笔记 (和吐槽),它们将以 “译者注” 的方式在文中以引出出现。不过只是一家之言,仅供参考,还望斧正。

介绍

将“所有权”作为一个重要特性添加到 Swift 中,会为程序员带来很多好处。这份文档同时扮演了所有权这一特性的“宣言”和“元提案”的角色。在本文中我们会陈述所有权相关工作的基本目的,并描述要达到这些目的所使用的一般方法。我们还会为一系列特定的变化和特性进行提案,每个提案都会在将来在更细的粒度上分别进行讨论。这篇文档想要做的是在全局上提供一个框架,以帮助理解每个变化所带来的贡献。

问题现状

在 Swift 中广泛使用的值类型写时复制 (copy-on-write) 特性取得了很大成功。不过,这个特性也还有一些不足:

译者注:Swift 中的“写时复制”是指,值类型只在被改动前进行复制。传统意义上的值类型会在被传递或者被赋值给其他变量时就发生复制行为,但是这将会带来极大的,也是不必要的性能损耗。写时复制将在值被传递和赋值给变量时首先检查其引用计数,如果引用计数为 1 (唯一引用),那么意味着并没有其他变量持有该值,对当前值的复制也就可以完全避免,以此在保持值类型不可变性的优良特性的同时,保证使用效率。Swift 中像是 Array 和 Dictionary 这样的类型都是值类型,但是底层实现确是引用类型,它们都利用了写时复制的技术来保证效率。

  • 引用计数和引用唯一性的测试必然导致额外开销。

  • 在大多数情况下引用计数可以决定性能特性,但是分析和预判写时复制的性能还是十分复杂。

  • 在任何时候值都有可能被复制,这种复制会使值“逃逸”出原有作用范围,这会导致绝大部分底层缓冲区都会被申请在堆内存上。如果能在栈上申请内存的话,会比现在高效得多,但是这需要我们能够阻止,或者至少识别出那些将要逃逸的值。

有些低层级的程序对于性能有着更严格的要求。通常它们并不要求绝对的高性能,但是却需要可以预测的性能特性。比如说,处理音频对于一个现在处理器来说并不是什么繁杂的工作,就算使用很高的采样率一般也能应付自如。但是只要有一点点预期外的停顿,就会立刻引起用户的注意。

译者注:也就是说,相比于绝对的高性能,我们可能更希望有平稳的性能特性,来处理这些工作。避免代码性能上出现“尖刺”,让程序运行在可以预估的水平。这样一样,即使绝对性能不足,针对用户体验我们也可以很好的对策 (比如降低码率),这可能比整体提升更重要,也更容易。

另一个很常见的编程任务是优化现有代码,比如你在处理某项工作时遇到了性能上的瓶颈。通常我们会找到执行时间或者是内存使用上的“热点”,然后以某种方式修复它们。但是当这些热点是由于隐式的值复制导致的话,Swift 中现在几乎没有工具能对应这种情况。程序员可能会尝试退回到用非安全指针来处理,但是这种行为将让你丧失标准库中集合类型所带来的安全性和表达能力的优势。

译者注:如果你觉得退回到非安全指针太过的话,也许退回到使用 NSArray 或者 NSDictionary 也会是一种选择。但是要注意数组或是字典中的类型最好也是 NSObject 子类,这样的回退才有意义。由于 Swift 中的类型和 Foundation 中的类型也存在一些隐式的桥接转换,这方面的性能开销往往被忽视。但是这样的妥协方式也并不理想,你同样失去了 Swift 的类型安全和泛型特性,同时可能你还需要大幅修改已有的模型类型,往往也得不偿失。

我们认为,通过引入一些可选的特性,我们将能够纠正这些问题。我们将这一系列特性统合称为所有权

什么是所有权?

所有权是指某段代码具有最终销毁一个值的责任。所有权系统则是管理和转移所有权的一整套规则及约定。

任何具有销毁概念的语言,也都有所有权的概念。在像是 C 和非 ARC 的 Objective-C 这样的语言中,所有权是由程序员自行进行管理的。在其他一些语言中,像是一部分 C++ 里,所有权由语言进行管理。即使在隐式内存管理的语言中,也存在有所有权概念的库,这是因为除了内存以外,还有其他的编程资源,而理解那些代码应该释放那些资源,是一件非常重要的事情。

译者注:除了内存以外,其他的资源可能包括比如音频单元控制权、端口等等。这些资源也需要申请和释放,它们在这方面的运行逻辑和内存有相似之处。

Swift 已经有一套所有权系统了,但是它往往“鲜为人知”:这套系统是语言的实现细节,程序员几乎没有办法对其施加影响。我们想要提案的内容可以总结为以下几点:

  • 我们应该向所有权系统中添加一条核心规则 - 独占性原则 (Law of Exclusivity)。这条原则应该阻止以互相冲突的方式同时访问某个变量 (比如,将一个变量以 inout 的方式传递给两个不同的函数)。这应该是一个必须的非可选改变,但是我们相信,这个改变对绝大多数的程序都不会产生不利影响。

  • 我们应该添加一些特性,给程序员一定的手段来控制类型系统。首先,是允许被“共享”的值能传递下去。这将是一个可选的变更,它将由一系列的标注和语言特性组成,而程序员可以简单地选择不使用它。

  • 我们应该添加一个特性,来让程序员表达唯一的所有权。换句话说,就是表达某个类型不能被隐式地复制。这将是一个可选的特性,能为那些想在这个层级进行控制的富有经验的程序员提供可行方式。我们不打算让普通的 Swift 程序也能够与这样的类型一同工作。

以此三个改变作为支柱,我们将要把这门语言的所有权系统从实现细节提升到一个更加可见的层面。这三个改变虽然优先级稍有不同,但是它们确是不可分割的,我们稍后会详述原因。由于这邪恶原因,我们将三者进行捆绑内聚,并将它们统称作“所有权”特性。

更多细节

Swift 现在的所有权系统中的基本问题在于复制,有关所有权的这三个改变全都在尝试避免复制。

在程序里,一个值可能会被用在很多地方。我们的实现需要保证在这些被用到地方,值的复制是存在且可用的。只要这个值的类型是可以复制的,那么我们只要复制这个值就肯定可以满足使用的需求了。但是,对于绝大多数的使用场景来说,实际上它们自己并不需要对复制的值拥有所有权。确实存在需要这么做的情况:比如一个变量并不拥有它当前的值,它只能将值存储在别处,而存储的地方是被其他东西持有的。但是这种做法一般并没有什么实际用处。而像是从类的实例中读取一个值这样的简单操作,只要求实例本身可用,而不要求读取代码自身实际拥有那个值的所有权。有时候这种差别十分明显,但是有时候却又难以分辨。举例来说,编译器在一般情况下是无法知道某个函数将对它的参数做怎样的操作的。在是否传递值的所有权这件事上,编译器只能回退到默认规则。当默认规则不正确的时候,程序就会在运行时多出额外的复制操作。所以,我们能做的是在某些方面让程序能被写得更加明确,这能帮助编译器了解它们是否需要值的所有权。

我们想要支持不可复制的类型,而这个方法和我们的想法是吻合的。对于大多数资源的销毁,唯一性十分重要:内存只能被回收一次,文件只能被关闭一次,锁也只能被释放一次。很自然地,对于这类资源的引用的所有者,应该也是唯一的,它不应该能被复制。当然,我们可以人为地允许所有权被共享,譬如我们可以添加一个引用计数,只在计数变为 0 时销毁资源,但是这势必会对使用这些资源带来额外的开销。更糟的是,这种做法会引入并行 (concurrency) 和可重入 (re-entrancy) 的问题。如果所有权是唯一的,并且语言本身强制规定了对资源的某些操作只能发生在拥有这个资源的代码中的话,那么自然而然地,同一时间内就只能有一段代码执行这些操作。而一旦所有权可以被共享,这一特性就随之消失了。所以,在一门语言里添加对不可复制的类型的支持会十分有意思,因为它能让我们以优秀和高效的抽象表达形式来操作资源。不过,要支持这些类型的话,我们需要完整应对抽象的所有方面,比如正确地标注函数的参数,以指明其是否需要所有权转移。如果标注不正确的话,只是使用增加复制的方式,编译器也无法保证在幕后一切都正确运行。

译者注:所有权唯一将会使资源管理的问题极为简化,但是事实上这样也会让程序变得无用。通过巧妙的语言设计 (或者说增加编译器开发者和语言开发者的压力),可以在保持唯一性的同时与程序其他部分“共享”,不过这么做也会带来很多的复杂度。本文后面就将展示这些复杂度以及对应的方式。

想要将这些问题里的任何一个解决好,我们都需要解决变量的非独占访问 (non-exclusive access) 的问题。Swift 现在是允许对一个同样的变量进行嵌套式访问的。比如说,你可以将同一个变量作为两个不同的 inout 参数进行传递,或者是在一个变量上调用某个方法,并且在这个方法所接受的回调参数中再去访问同一个变量。这类行为基本上是不被鼓励的,不过它们也没有被禁止。不仅如此,编译器和标准库在这种时候都必须“卑躬屈膝”,以保证如果发生问题的时候程序不要表现得过于离谱。举例来说,在发生原地的元素替换更改时,Array 必须要持有它自己的内存。若不这样做的话,试想要是在更改的时候我们以某种方式把原来的数组变量重新赋了值,那么这块内存就将被释放掉,而元素却还正在被更改。同样地,编译器一般也很难证明某个内存里的值在一个函数中不同的地方是否相同,因为它只能假设任何一个非透明的函数调用都有可能重写内存。这导致的结果是编译器只能像一个被迫害妄想症患者那样到处添加复制,保证冗余。更糟糕的是,非独占访问极大地限制了显式标注的实用性。比方说,一个 shared 参数只有在保证该参数在整个方法调用中都有效时,才有意义。但是,只有通过对一个变量的当前值进行复制并传递复制的值,才能可靠地保证该值可以在可重入的方式下进行更改。另外,非独占访问也让特定的重要的模式变得不可能实现,比如无法“盗取”当前值并创建新值,在执行的途中别的代码要是可以获取某个变量的话,是一件很糟糕的事情。要解决这个问题,唯一的方法是建立一个规则,来阻止多个上下文在同一时间访问同一变量。这就是我们的提案之一 - 独占性原则。

所有这三个目标都是紧密相连,并且互为加强的。独占性原则使得显式标注在默认情况下能确实优化代码,并且对不可复制的类型进行强制规范。显式标注在独占性原则的作用下,可以带来更多的优化机会,并让我们在函数中使用不可复制类型。不可复制类型能够验证即使对于可复制类型来说,标注也是最优选项,它们也为独占性原则能直接适用创造了更多的情境。

成功的标准

如上所述,开发核心团队希望能将所有权作为可选的加强引入 Swift。程序员在很大程度上应该可以忽略所有权的问题,也不必为之操心。如果这一点被证明无法满足的话,我们会拒绝关于所有权的提案,而不会将这个明显的负担强加到普通的程序中去。

译者注:这实在是一个好消息。

独占性原则会引入一些新的静态和动态的限制。我们相信这些限制只会影响很小的一部分代码,而且这部分代码我们应该已经在文档中写明了可能产生非确定的结果。当我们进行动态限制的时候,还会造成一些性能上的损失。我们希望它所带来的优化潜力能够至少“弥补”这个损失。我们也会为程序员提供工具,来在必要的时候跳过这些安全检查。在文档后面的部分,我们会讨论很多这方面的限制。

核心定义

任何关于所有权系统的讨论都会基于更低的抽象层级。我们将要讨论的是一些语言实现方面的话题。在这个上下文中,当我们提到“值”这个词时,我们所表述的是具有特定语义的,用户口中的值的实例。

举例来说,下面的 Swift 代码:

  var x = [1,2,3]
  var y = x

人们通常会说这里 x 和 y 具有相同的值。让我们把这种值称为语义值。但是在实现层面上,因为变量 x 和 y 是能被独立改变的,所以 y 的值必须是 x 的值的复制。我们把这个叫做值的实例。一个值实例可以保持不变,并在内存中到处移动,不过进行复制的话则一定会导致新的值实例。在这篇文档剩下的部分,当我们不加修饰地使用“值”这个词时,我们指的是值实例这种更低层级的表述。

复制和销毁一个值实例的意义,随着类型不同稍有区别:

  • 有些类型只需要按照字节表示进行操作,而不需要额外工作,我们将这种类型叫做平凡类型 (trivial)。比如,Int 和 Float 就是平凡类型,那些只包含平凡值的 struct 或者 enum 也是平凡类型。我们在本文中关于所有权的大部分表述都不适用于这种类型的值。不过独占性原则在这里依然适用。

  • 对于引用类型,值实例是一个对某个对象的引用。复制这个值实例意味着创建一个新的引用,这将使引用计数增加。销毁这个值实例意味着销毁一个引用,这会使引用计数减少。不断减少引用计数,最后当然它会变成 0,并导致对象被销毁。但是需要特别注意的是,我们这里谈到的复制和销毁值,只是对引用计数的操作,而不是复制或者销毁对象本身。

  • 对于写时复制的类型,值实例中包含了一个指向内存缓冲区的引用,它的工作方式和引用类型基本相同。我们要再次提醒,复制值并不意味着将缓冲区中的内容复制到一个新的缓冲区中。

对每种类型,使用的规则是相似的。

译者注:在 Swift 中,值类型和引用类型的区别是相当重要的。当前 Swift 的最大的使用场景是和 Cocoa 框架合作制作 app,而 Cocoa 包括 Foundation 仍然是一个引用类型占主导地位的框架。值类型在 Swift 里使用非常广泛,你几乎很难避免混用两种类型。从 Swift 3 开始,开发团队正在将 Foundation 框架逐步转换为值类型 (比如 NSURL 到 URL,NSData 到 Data 的转换),但是在底层它们包含了一个指向原来的对象类型的桥接。这就使得上面的最后一种情况 (写时复制类型) 变得非常普遍。另外,在我们自己创建的 Swift struct 和 enum 中,也经常会有引用类型作为成员的情况存在。而在这种情况下,写时复制并不是直接具备的特性,它需要我们进行额外的实现,否则我们就只能将它看作是引用类型来使用,否则很可能出现问题。在处理包含引用类型的值时,务必多多斟酌,特别小心。

内存

一般来说,一个值可以以两种方式中的一种被持有:它可能是“临时”的,也就是说一个特定的执行上下文对这个值进行了计算,并将它当作操作数;或者它可以是“静止”的,被存放在内存某处。

对于临时值,它们的所有权规则十分直接,我们无需多加关注。临时值是由一些表达式所创建的结果,这些表达式被用在特定的地方,而这些值也就只需要在被用在这些地方。所以语言实现需要做的事情就很清楚了:只需要完成将它们直接送到需要的地方就可以了,而不必强制对它们进行复制。用户已经明白会发生的是什么,因此这部分没有实际需要改进的必要。

那么,我们关于所有权的讨论将很大程度上围绕内存中保存的值来进行。在 Swift 中,关于对内存的处理,有五个紧密相关的概念。

存储声明 (storage declaration) 是一个语法概念,它声明了相关内存在这门语言里被处理的方式。现在,存储声明通过 let,var 和 subscript 引入。存储声明是带有类型的,它也包含了一些定义,来规定读取和写入存储时的方式。var 或者 let 的默认实现除了创建一个新变量来存储值以外并没有做什么。不过存储声明也可以是被计算出来的,也就是,并没有必要说一个变量背后一定会有对应的存储。

存储引用表达式 (storage reference expression) 也是一个语法概念,它是一个对存储进行引用的表达式。这个概念与其他语言的 “l-value” 比较相似,不过不同的是它不需要一定被用在赋值语句的左边,因为存储也可能是不变的。

译者注:本文中会多次提到存储引用表达式,所以为了确保能理解什么是存储引用表达式,我在这里啰嗦几句。所谓的 l-value 表达式,指的是一个指向某个具体存储位置的表达式。在其他语言中,l-value 应该是可以被赋值的,而在 Swift 中,l-value 并不需要能被赋值,是因为有常量值的存在,存储将不会改变。举例来说,比如对某个点 Ponit 的坐标的访问表达式 p.x 就是一个存储引用表达式,它访问的是具体存储的 x 值。如果 x 是以 var 的形式声明的,那么它和其他语言的 “l-value” 就是等同的,如果 x 的定义方式是 let,则不可赋值,但这并不影响 p.x 作为存储引用表达式存在。另外,如果正方形有一个面积计算属性 var area: Float { retrun side * side },square.area 则不是存储引用表达式,因为它求值后并不是对存储的引用。

存储引用 (storage reference) 是一个语言语义的概念,它声明了一个指向特定存储的完全填满的引用。换句话说,它是存储引用表达式抽象求值后的结果,不过它并不会实际去访问存储。如果存储是一个成员,那么基本会包含值或是指向存储的引用。如果存储是一个下标 (subscript),它将包含索引的值。比如,像是 widgets[i].weight 这样的存储引用表达式可能被抽象求值为下述存储引用:

  • 属性 var weight: Double 的存储
  • 下标 subscript(index: Int) 在索引值 19: Int 位置的存储
  • 本地变量 var widgets: [Widget] 的存储

变量 是一个语义概念,它指的是内存中存储一个值的唯一地点。变量不需要是可变的 (至少在我们的文档中它不需要可变)。通常来说,存储声明是变量被创建的原因,不过它们也会在内存中被动态地创建 (比如使用 UnsafeRawPointer)。变量总是属于某一个特定的类型,也有一定的生命周期,在编程语言中,生命周期是指从变量开始存在的时间点到它被销毁的时间点之间的时间。

内存地址 (memory location) 是指内存中一连串的可以被标记位置的范围。在 Swift 里,这里基本上是一个实现细节上的概念。Swift 不保证任意的变量会在它的生命周期中都保持在同一个内存地址上,Swift 甚至不能保证变量一定是被存储在内存地址上。但是也有将变量¥临时强制放置在一个不变的地址上的时候,比如以 inout 方式将变量传递给 withUnsafeMutablePointer 时就遵循这条规则。

访问

对于存储引用表达式的某种特定的求值被称为访问。访问的方式有三种:读取赋值,以及修改。赋值和修改都是写操作,不同的是赋值会将原来的值完全替换掉,而不会去读取它。修改的话需要依赖旧值。

所有的存储引用表达式都可以基于表达式出现的上下文,被归类到这三种访问类型中的一种。需要注意,这种归类是表面上的工作:它只依赖于当前上下文的语义规则,而不会去在程序的更深层次进行考虑和分析,也不会关心动态行为。比如,通过 inout 参数传递的存储引用不会关心被调用者有没有实际使用当前值,也不关心到底有没有实施写操作或者只是简单地使用它,这个访问在调用者里总是会被判断为更改访问。

存储引用表达式的求值可以分为两个阶段:首先会求得一个存储引用,之后,对存储引用的访问会持续一段时间。这两个阶段通常是接连进行的,但是在复杂的情况下,它们也可以被分开单独执行,比如在 inout参数不是调用的最后一个参数时,就会发生这种情况。阶段分离的目的是将访问的持续时间最小化,而同时保持适应 Swift 的从左到右的最容易进行扩展的求值规则。

译者注:如果能够接受之前的五个概念的分别的话,将表达式的求值和使用 (对存储的访问) 过程分开处理也就是自然而然的事情了。虽然这让心智模型变得更加复杂,但是却能对应更多的使用情况,而且相对而言付出的代价可以接受。

独占性原则

建立起这些概念后,我们就能简要地提出这提案的第一个部分 - 独占性原则了。所谓独占性原则,是指:

如果一个存储引用表达式的求值结果是一个由变量所实现的存储引用,那么对这个引用的访问的持续时间段,不应该与其他任何对这个相同变量的访问持续时间段产生重合,除非这两个访问都是读取访问。

这里有一个地方故意说得比较模糊:这条原则只指出了访问“不应该”产生重合,但是它没有指出如何强制做到这一点。这是因为我们将对不同类型的存储使用不同的方法来强制这个机制。我们将在下一个大节里讨论那些机制。首先,我们想要谈一谈这条规则会带来的一些结果,以及我们满足这条规则所要使用的策略。

独占性的持续时间

独占性原则说的是访问在它们的持续时间内必须是独占的。这个持续时间是由导致该次访问的直接上下文所决定的。也就是说,这是程序的一种静态特性,而从介绍部分中我们知道,横在我们面前的安全问题是一个动态的问题。按照一般经验,我们知道使用静态的方式来解决动态问题往往只能在保守的范围内生效;在动态的程序中,肯定会存在方案失效的时候。所以,一个自然的问题是,在这里要如何才能让一个通用的原则生效。

举例来说,当我们用 inout 参数的方式来传递存储的时候,访问会贯穿与整个调用所持续的过程中。这需要调用方保证在调用过程中没有其他对这个存储进行访问。这么一刀切的手法会不会有点太过粗糙?因为毕竟在被调用的函数中可能会有很多地方其实并不会用到这个 inout 参数。也许我们应该在追踪 inout 参数的访问这件事情上再细一些,我们可以在被调用的函数中来进行追踪,而不是粗暴地在整个调用者上施加独占性原则。其实问题在于,这个想法实在是太动态了,所以我们很难为它提供一个高效的实现。

调用方的 inout 规则有一个关键的优点;对于被传递的存储到底是什么,调用方有着大量的信息。这意味着调用方规则通常能以纯静态的方式让独占性原则适用,而不必添加动态检查或是做一些猜疑性质的假设。比如,要是有一个函数调用了某个本地变量上的 mutating 方法 (mutating 方法实际做的就是将 self作为 inout 参数传入),除非变量被一个逃逸闭包 (escaping closure) 所捕获,否则函数就能轻而易举地检查每次对变量的访问,并确认这些访问和调用没有重叠,以此来保证满足独占性原则。不仅如此,这个保证还能被向下传递给被调用者,被调用者可以使用这些信息来证明它自己的访问是安全的。

译者注:我们往往会认为实际工作中 inout 的使用非常罕见,这说明你忽视了 mutating 方法的实质就是 inout 参数调用。在标准库中,很多关于数组或者字典的变更的方法都是 mutating 的,也符合这个原则。

相反,被调用方对于 inout 的规则却无法从这样的信息中受益:这些信息在调用发生的时候就被抛弃了。这就造成了我们在介绍一节中谈到的现今普遍的优化问题。比如,假设被调用者从参数加载了一个值,然后调用一个优化器无法进行推断的函数:

  extension Array {
    mutating func organize(_ predicate: (Element) -> Bool) {
      let first = self[0]
      if !predicate(first) { return }
      ...
      // something here uses first
    }
  }

在被调用方的规则下,优化器必须把 self[0] 的值复制到 first 中,因为它只能假设 predicate 有可能以某种形式改变 self 上绑定的值。在调用方规则下,优化器则能在数组没有被改变的时候,一直使用数组中的元素值,而不需要进行复制。

不仅如此,试想上面的例子里,如果要遵守被调用方规则的话,我们能写的代码会变为怎样呢?像这样的高阶操作不应该需要担忧调用者传入的 predicate 会以再入的方式改变数组。上面例子里,像使用本地变量 first 而不是反复地访问 self[0] 这样简单的实现选择,在语义上就会变得特别重要;而想要维护这种事情的难度是不可想象的。所以 Swift 库一般都禁止这种再入式的访问。不过,因为标准库并不能完全地阻止程序员这么做,所以实现必须在运行时做一些额外的工作,来确保这样的代码不会导致未定义的行为,或者是让整个进程发生错误。如果作出限制的话,这些限制只会作用于那些在良好书写的代码中本不应该出现的情况,所以对大多数程序员来说,并没有什么损失。

因此,这个提案提出了类似调用方的 inout 那样的访问持续时间的规则,它能让接下来的调用有机会被优化,同时保证需要付出的语义代价很小。

译者注:也就是说,所需要修改的代码很少,代码“变丑”或者“变复杂”的程度在可控范围之内。

值和引用类型的构成

我们已经谈了很多关于变量的内容了。读者可能会想要知道,属性 (property) 的情况是如何的。

在我们上面陈述的定义体系中,属性是一个存储声明,一个存储属性会在它的容器中创建一个对应的变量。对这个变量的访问显然需要遵守独占性原则,但是因为属性是被组织到一起放在一个容器中的,这会不会导致一些额外的限制?特别是独占性原则应不应该阻止那些对同一个变量或者值,但是是通过不同属性所进行的访问。

属性可以被分为三种类型: - 值类型的实例属性, - 引用类型的实例属性,以及 - 在任意类型上的 static和 class 属性。

译者注:相比于 Objective-C,Swift 中的属性似乎并不是特别明显。因为 Objective-C 毕竟有 @property 这种显式的方式声明属性,而在 Swift 中,写在具体类型而非方法中的“变量声明”将自动成为属性。

我们提议总是将引用类型属性和 static 属性看作各自独立的属性,而在某个特定 (但是很重要) 的特殊情况以外,将值类型的属性当作是非独立的来进行处理。这可能会带来很大的限制,对为何这个提议是必要的,以及为什么我们对不同类型的属性加以区别,我们会详加说明。主要有三个原因。

独立性和容器

第一个原因和容器有关。

对值类型来说,访问单个的属性和访问值的整体都是可能的。显然,访问一个单个属性和访问值的整体是冲突的,因为访问值的整体其实就是同时访问这个值里所有的属性。举例来说,比如有一个变量 p: Point(这个变量并不需要是一个本地变量),它包含三个存储属性 x,y 和 z。要是能够同时并且独立地改变 p 和 p.x 的话,独占性原则就会有一个巨大的漏洞。所以我们必须在这里强制独占性原则,我们有三个选择:

(在阅读关于强制适用独占性原则的部分后,再来看这节内容会更容易理解。)

第一种方法是简单地将 p.x 的访问也看作是对 p 的访问。这很巧妙地就将漏洞堵上了,因为我们对 p所适用的独占性原则很自然地将冲突的访问排除了。但是这也同时将对于其他属性的同时访问给排除了,因为对 p 中其他属性的访问都会触发对 p 的访问,从而使独占性原则生效。

另外两种方法需要让这种关系倒过来。我们可以将所有对单独的存储属性的独占性原则分离出来,而不是对整体值进行独占:对于 p 的访问会被看作是对 p.x,p.y 和 p.z 的方式。或者我们可以将独占性适用的方式参数化,并且记录正在被访问的属性的路径,比如 ““,”.x” 之类。不过,这些方式存在两个问题。

首先,我们并不总是知道全部的属性,或者那些属性是存储属性;某个类型的实现对我们来说有可能是不透明的,比如泛型或者还原的类型。对计算属性的访问必须被当作对整个值的访问,因为它需要将变量传递给 getter 或者 setter,而这些访问方法会是 inout 或者 shared 的。所以实际上它是和其他所有属性冲突的。使用动态的信息是可以让它们正常工作,但是这会在值类型的访问方法中引入很多记录方法,这和值类型被作为低耗费的抽象工具这一核心设计目标大相径庭。

其次,虽然这种模式可以相对容易地应用在独占性的静态适用上,但是想用在动态中就需要一大堆动态的记录,这也与我们的性能目标格格不入。

译者注:这里考虑的属性访问路径和 Objective-C 的 KVC 有形似之处。不过这也正是 Swift 所极力避免避免的问题。类似这样的标注,或者说动态的修改,对于性能的损失是不可忽视的,在尽可能的情况下,我们都希望适用静态的方法来保证独占性。只有在确实无法静态决定的情况下,再使用动态方式。

所以,虽然有方法能让我们将对不同的属性的访问和对整体值的访问独立开来,但是这要求我们强制对整体值使用独占性原则,而且还需要两种属性都是存储属性。虽然这是一种很重要的特殊情况,但是它也仅仅只是一种特殊情况。对于其他情况,我们必须回退到一般的规则,认为对于一个属性的访问同时也是对整体值的访问。

这些思考对于 static 属性以及引用类型的属性是不适用的。在 Swift 中没有同时访问一个类中所有属性的语言结构,而且说一个类型所有的 static 属性本身就是没有意义的事情,因为任何一个模块都能够在任何时候向某个类型添加 static 属性。

独立访问的具体表现

第二个原因是基于用户期望的考虑。

避免对于不同属性的重叠访问最多只会造成一些小麻烦。在独占性原则下,我们至少可以避免“让人惊讶的长距离访问”:调用变量上的一个变量有可能开启一长串不明显的事件序列,然后最后返回并修改了原来的变量。现在有独占性原则,这就不会再发生了,因为这将会导致两个对同一变量互相冲突且重叠的访问。

作为对比,引用类型中有许多已经成为习惯的模式正是依赖这种“基于通知”的方式进行更新的。实际上,在 UI 代码中,同一个对象上的不同属性被并行修改并不是一件罕见的事儿:比如被用户 UI 操作修改的同时,被一些后台操作修改。阻止独立访问将会打破这种做法,这是无法接受的。

对于 static 属性,程序员期望它们是独立的全局变量;在一个全局变量被访问的时候,去禁止别的访问访问,这是说不通的。

独立性和优化器

第三点和属性的优化潜力有关。

独占性原则的一部分目标是让一大类的优化能够适用于值。比如,值类型上的一个非 mutating 方法可以假设 self 在方法调用期间会完全保持一致。它不需要担心某个未知的函数会在调用期间返回并修改了 self 的值,因为这种修改将违背独占性原则。即使在 mutating 的方法中,除非知会这个方法,否则也没有其他代码能访问 self。这些假设对于优化 Swift 代码是非常关键的。

不过,这些假设一般来说对全局变量和引用类型属性的内容来说都不适用。类的引用可以被随意地共享,所以优化器必须假定某个未知方法可能会访问到同一个实例。另外,系统中的任何代码 (如果忽略访问权限控制的话) 都有可能访问到全局变量。所以就算将对不同属性的访问当作非独立的来对待,语言的实现能获得的好处也及其有限。

下标

虽然现在这门语言中在技术上来说下标从来不会是存储属性,但大多数的讨论对下标依然适用。通过下标访问值类型的一个部分和访问整个值是同等对待的,对和这个值的其他访问发生重叠的时候,也应如此考虑。这导致的最重要的结果就是两个不同的数组元素不能被同时访问。这会妨碍到某些操作数组时的通常做法,不过有些 (比如并行地改变一个数组的不同切片这类) 事情在 Swift 中本来就充满了问题。我们认为,通过有目的的对集合的 API 进行改进,可以将缓和所带来的主要影响。

译者注:在日常开发中,对于数组的操作可能是并行编程中比较常见的多线程问题。在很大程度上,下标操作和实例属性的访问类似,我们可以通过加锁或者 GCD 做 barrier 的方式来确保数组线程安全。如果能在语言层面将独占性原则解决的话,将极大程度降低并行程序开发的难度。这也意味着今后的标准库中我们可以获得线程安全的数组,或者甚至整个标准库乃至第三方代码都会是默认线程安全的。

独占性原则的强制适用

想要让独占性原则适用,我们有三种可行机制:静态强制,动态强制,以及未定义强制。所要选择的机制必须能由存储声明简单地决定,因为存储的定义和对它的所有直接的访问方法都必须满足声明的要求。一般来说,我们通过存储被声明为的类型,它的容器 (如果存在的话),以及任何存在的声明标记 (比如 var 或者 inout 之类) 来决定适用哪种机制。

静态强制

在静态强制的机制下,编译器将检查独占性原则是否被违反,如果违反,则给出编译错误。因为这种方法安全可靠,并且不会产生运行时的损耗,所以应该是优先考虑的机制。

这种机制只能在所有东西都能被完美决定时使用。比如,对于值类型,因为独占性原则递归适用于所有属性,这保证了基本的存储是被独占访问的,所以独占性原则可以适用。对一般的引用类型则不适用,因为无法证明对于某个特定对象的引用是对这个对象的唯一的引用。不过,如果我们能够支持唯一引用的 class 类型的话,独占性原则就可以静态地适用它们的属性了。

在一些想定的情况下,编译器可能会为了保持源码兼容性和避免发生错误,从而隐式地插入复制操作。这应该只在需要源码兼容的模式下被使用。

Swift 4 的路线图已经发布,主要会在 String 的部分引入部分的源码非兼容改动。同时 Swift 4 的编译器会支持通过特定的编译标记来在文件粒度上支持选择按 Swift 3 还是 Swift 4 进行编译。这种方式比现在 Swift 2.3 和 Swift 3 共存的方式要进步一些,不过也并不是说 Swift 4 就不用做迁移了…不过看情况似乎会比 2 到 3 的时候容易很多,因为至少我们可以做到一个文件一个文件进行迁移。

静态强制会被用在:

  • 各类的非可变变量

  • 本地变量,除非被闭包的使用影响 (之后会详述)

  • inout 参数

  • 值类型的实例属性

动态强制

在动态强制下,语言实现将会维护一个记录,来确定每个变量现在是否正在被访问。如果发现冲突,它就将触发一个动态错误。如果编译器侦测到动态强制下一定会发现冲突的话,也可以由编译器给出一个静态错误。

进行记录时,对于每个变量需要两个 bit,将它标记为“未访问”,“读取”和“已修改”三种状态中的一种。虽然多个读取操作能够在同时一起生效,不过只要做法稍微聪明些,我们就可以通过在访问时将原来状态进行存储的方式,来避免对所有的访问进行完全的记录。

我们应该尽最大努力进行记录。这个做法需要能可靠地检测出那些必然会违反独占性原则的情况。我们没有要求它检测竞态条件 (race condition) 的状况,不过好消息是虽然没做要求,但它通常还是可以检测出竞态,这是一件好事。记录必须要能正确处理并行读取的情况,而不应该比如将记录永久地停在“读取”状态。但是,在并行读取时,即使是还有活动的读取者,将记录值设为“未读取”的状态却是可以接受的。这可以让记录使用非原子 (non-atomic) 操作。不过,在将一个类的不同属性的记录值打包到一个单独的字节时,却必须使用原子操作,因为在一个类中,对不同变量的并行访问是允许的。

译者注:对于对 Objective-C 不熟悉的读者来说,原子操作和非原子操作可能是比较陌生的概念。原子操作指的是不会被线程调度机制打断的调用。比如在满足原子操作的 getter 中,同一个属性的 setter 被调用,那么 getter 还是能返回完整的正确值,但是非原子操作的属性则不具备这个特性,因此非原子要快得多。Swift 现在没有设置原子属性的语法,所有的属性默认都是非原子操作。如果需要在 Swift 中让属性满足原子操作,现在我们可能需要自行进行加/解锁。另外注意,原子/非原子操作和线程安全的程序并没有太大关系,它只是针对一个属性的一次读写操作所做的特性设定。

当编译器检测到一个“实例内部”的访问时,也就是说在这种情况下,访问期间没有别的代码会执行,也就没有对同一个变量进行再入式访问的可能。此时,编译器就能避免更新记录的值,而只需要检查它现在是否具有一个恰当的值。这对读取来说很正常,因为读取操作往往只会在访问过程中复制值。当变量是 private 或者 internal 时,编译器可以检测到所有可能的访问都是内部访问,它就能够将所有的记录都去掉。我们希望这应该是非常常见的情形。

动态强制会被用在:

  • 使用了闭包,且有必要时的本地变量 (之后会详述)

  • class 类型的实例属性

  • static 和 class 属性

  • 全局变量

我们需要为动态强制提供一个标注,来让它在特定的属性和类中降级去使用未定义强制的机制。当有人觉得动态强制的性能消耗太重时,这可以为他们提供一种将这个特性去除的方式。在独占性实现后的早期阶段,留有余地是尤其重要的,因为我们可能还在探索不同的实现方法,有可能还没有找到全面的优化方式。

在之后,我们可以对类实例进行隔离,这让我们可以对一些类实例的属性使用静态强制。

未定义强制

未定义强制的意思是冲突既不会被静态检测,也不会被动态检测,冲突的结果将是未定义行为。对于 Swift “默认安全”的设计下的一般代码来说,这不是一个好的机制,但是这确实是像不安全指针 (unsafe pointer) 这类东西的唯一真正选择。

译者注:Unsafe 家族继续在 Swift 中扮演垃圾桶角色。对于 C 的库,如果没有更好的替代,可能我们也只能接受牺牲安全性的事实。但是还是建议在真的要在 Swift 中使用 C 库之前,再三斟酌。诚然花力气去把 C 库用 Swift 进行重写不是一件很讨好的事情,但是还是应该在代码的安全特性和直接使用 C 库的便捷程度中进行权衡。如果选择使用 C 代码,建议尽量使用 struct 来进行一定的封装,避免过多地使用 Unsafe 类型来进行交互。

未定义强制将被用于:

  • 不安全指针的 memory 属性。

译者注:Swift 1 和 2 里是 memory 属性,Swift 3 中应该已经被改名为 pointee 了。如果说是 Swift 团队又打算把它改回 memory 的话…我也很无语。希望只是原作者的笔误。

被闭包捕获的本地变量的独占性

独占性原则的静态强制依赖于我们能够静态地知道对变量的使用发生在哪里。对于本地变量来说,这种分析通常都很直接,但是当一个变量被闭包所捕获后,控制流就会让使用情况变得难以理解,从而使整个事情变复杂。就算是非逃逸的闭包,也是有可能被重入或者并行执行的。对于闭包捕获的变量,我们采取以下原则:

  • 如果闭包 C 有可能逃逸,那么假设有被 C 捕获的任意变量 V,对 V 的有可能在一段逃逸时间后才被执行的访问 (也包括在 C 本身中的访问),都必须遵守动态强制的原则,除非所有的访问都是读取访问。

  • 如果闭包 C 不会逃逸出函数,那么它在函数内的使用地点都是已知的。在每处使用时,这个闭包要么被直接调用,要么被作为参数传递给另一个调用。对于每次这种调用时的非逃逸闭包,对每个被闭包 C 所捕获的变量 V,如果任意一个闭包含有对 V 的写操作,那这些闭包内的所有的访问都必须使用动态强制,并且这个闭包调用会被静态强制认为是试图对 V 进行写操作的调用。除此之外,所有的访问都可以使用静态强制,而且对闭包的调用会被视作对 V 的读取操作。

可能这些规则会随着时间而逐渐改进。比如我们应该可以对闭包的直接调用的规则进行一些改善。

所有权使用的具体的工具

共享值

本章中的很多讨论里会出现一个新概念:共享值 (shared value)。正如其名,共享值指的是一个被当前上下文和拥有它的另一部分程序所共享的值。因为程序的多个部分能够同时使用这个值,所以为了贯彻独占性原则,这个值对于所有上下文 (包括拥有这个值的上下文) 来说,都必须是只读的。这一概念可以使程序对值进行抽象,而不必对它们进行复制。这和 inout 可以让程序对变量进行抽象有异曲同工之妙。

译者注:程序对值或者变量进行抽象可能不太容易理解。可以参考 inout 的实现方式,其实 inout使程序在函数返回前对传入的参数进行赋值操作,就是一种抽象行为:这个关键字 inout,将传入变量且在返回时重新赋值变量这个操作,抽象为了一个修饰词。下述的 shared 与此类似,只不过它所抽象的目标对象是值。

(熟悉 Rust 的读者可能会在共享值和 Rust 的不变借入 (immutable borrow) 的概念之间找到相似之处。)

译者注:不愧是把大半个 Rust 团队挖过来了…本来还打算今年学一下 Rust,现在看来把 Swift 4 学好就行了…最近三年果然还是坚持了每年学一门新语言,它们分别叫 Swift 2,Swift 3 和 Swift 4。

当一个共享值的源值是存储引用时,共享值实际上就是一个对存储的不可变引用。存储在共享值持续期间被作为读取操作进行访问,这样依赖,独占性原则会保证在访问期间不会有其他访问能对原来的变量进行修改。有些类型的共享值还可能被绑定到临时值上 (比如一个 r-value)。因为临时值总是被当前执行上下文所拥有,而且只在一个地方被使用,所以这不会带来额外的语义上的问题。

就像是普通的变量或者是 let 绑定时那样,我们可以在进行绑定后的作用域中使用共享值。如果要使用共享值的地方也要求所有权的话,Swift 将简单地对这个值进行隐式的赋值 - 这和普通的变量或者 let 绑定依然是一样的。

共享值的局限

文档的这个部分将描述几种生成和使用共享值的方式。不过,我们现在的设计还没有提供一个通用的,“一等公民”的机制来使用共享值。程序并不能返回一个共享值,不能构建一个共享值组成的数组,也不能将共享值存储为 struct 的字段等。这些限制和 inout 引用所存在的限制是相似的。事实上,它们之间的相似非常多,因此我们甚至可以引入一个术语来包含它们两者:我们将它们叫做暂态量 (ephemerals)。

我们的设计没有给暂态量提供完备的支持,这是精心考量的决定,这主要是基于三点考虑:

  • 我们需要将这个提案的范围限制在未来几个月内可以确实实现的范围内。我们希望这个提案能给语言及其实现带来大量好处,但是提案本身已经涉及广阔,而且略有激进了。对暂态量的完整支持将会给实现和设计增加很多复杂度,这显然会导致提案超出预计范围。另外,余下的语言设计问题都很庞大,而且已经有几个现存的语言尝试了将暂态量作为一等特性,不过它们的结果并不能说完全令人满意。

  • 类型系统是在复杂度与表述清晰度之间的权衡。只要将类型系统做得更复杂,你就总是能接受更多的程序,但是这并不一定是好的权衡。在像 Rust 这样的重视引用的语言中,背后的生命周期限定 (lifetime-qualification) 系统向用户模型中添加了很多复杂度。这些复杂度对用户来说确实成为了负担。而且它依然不可避免地时不时要回退到不安全的代码,来绕开所有权系统的一些限制。在当前看来,将作用域限定引入 Swift 并不是一个可以直接做出的决定。

  • 在 Swift 中,一个类似 Rust 的生命周期 (作用域) 系统的功能并不需要像 Rust 中那样强大。Swift 有意地提供了一个让类型的作者和 Swift 编译器本身都能够保留很多实现自由度的语言模型。

    比如,Swift 中的多态存储就比 Rust 的要灵活一些。Swift 里的 MutableCollection 会实现一个通过索引来访问元素的 subscript 下标方法,但是这个方法几乎可以以任何方式来进行实现并满足这个需求。如果有代码访问了这个 subscript,而它又正好是通过直接访问底层内存来实现的话,这个访问就将会发生在原地。但是如果 subscript 是以 getter 和 setter 的计算属性方式方式实现的话,访问将会发生在一个临时变量中,getter 和 setter 则会在需要时被调用。因为 Swift 的访问模型是高度词法化的,它保留了在访问的末端运行任意代码的可能性。想象一下,如果我们要实现一个循环,来将这些临时的可变引用添加到一个数组里,我们就需要在循环的每次迭代里都能把任意的代码添加到执行队列里,这样才能在函数操作完数组后进行清理工作。这肯定不会是一个低损耗的抽象!一个在生命周期规则下的,和 Rust 更相似的 MutableCollection 接口,需要保证 subscript 返回的是一个指向已存在的内存的指针;这样一来,下标就完全不能支持计算方式的实现了。

    译者注:Swift 的下标访问是一个很有意思的话题。与一般的值变量的复制行为不同,数组下标的访问正是直接的原地访问。但是这是借助于额外的地址器 (Addressors) 来完成的。在数组的下标方法中,并没有返回对应下标元素的值,而是返回了可以获取到元素值的更底层的访问方法 (accessor)。这样一来,写时复制的优化便可以对数组下标访问适用。而那些我们直接返回值的下标方法并不能从中获益,因为它们其实还是通过“计算”来返回下标访问,虽然这个计算本身仅仅只是返回了单个的值。有关更多地址器的问题,Swift 团队也有详细的文档进行介绍。

    对于简单的 struct 成员,也存在同样的问题。Rust 的生命周期规则中有这样规定:如果你有一个指向 struct 的指针,那么你可以创建一个指向该 struct 中的一个字段的指针,并且这个新指针会和原来的指针具有同样的生命周期。不过,这条规则不仅假定了字段是确实被存储在内存中的,而且假定了这个字段是被简单存储的,也就是说,你可以用一个简单的指针指向它,而且这个指针将满足所指向类型的指针的标准应用二进制接口 (Application Binary Interface, ABI)。这意味着 Rust 不能使用很多内存布局优化手段,比如像是将很多布尔字段打包到一个字节里,或者仅仅是减少某个字段的对齐方式等。我们不愿意将这种保证引入到 Swift 中。

基于上述原因,虽然我们对进一步探索能够承载暂态值更多应用的更复杂的系统抱有理论上的兴趣,我们现在暂时并不打算进行相关的提案。因为这样一个系统所主要包含的是对类型系统的改变,所以我们并不担心这会在长期导致 ABI 稳定上的问题。我们也不用担心这会造成源码不兼容的情况。我们相信,关于这方面任何的增强都可以当作是对我们所提案的特性的扩展和推论来完成。

本地暂态量绑定

在 Swift 中,对存储进行抽象,就只能将这个存储通过 inout 参数的方式传递,这是一个很蠢的限制。想要一个本地 inout 绑定的程序员,可以通过引入一个闭包,并且立即调用这个闭包的方式来轻易地绕开这个限制。这件原本是很简单的事情,不应该要如此麻烦才能达成。

共享值会让这个限制更加明显,用本地的共享值对一个本地的 let 值进行替换是一件很有意思的事情:共享值可以避免进行复制,而为此付出的代价是阻止其他的对原来存储的访问。我们不会鼓励程序员在他们的代码中通篇去使用 shared 来代替 let,特别是优化器通常都能够将复制操作给去除掉。但是,优化器也并不是永远都能移除复制操作,因此 shared 这个微优化在一些特定的情形下会很有用。而且,当与不可复制类型打交道时,去除掉正式的复制操作可能在语义上也是必要的。

我们提议直接去除掉这些限制:

  inout root = &tree.root

  shared elements = self.queue

本地暂态量的初始赋值是必须的,而且它必须是一个存储引用表达式。对于这类值的访问持续到剩余作用域的结束。

译者注:也就是说,让 inout 和 shared 的声明方式能够被一般程序员使用。不过其实对于绝大多数顶层应用的开发者来说,应该是不太用得到这两个声明关键字的。

继续阅读见:所有权宣言 - Swift 官方文章 Ownership Manifesto 译文评注版(下)原    文:OneV's Den
作    者:王巍

免责声明:

  1. SDK.cn遵循行业规范,所有转载文章均征得作者同意授权并明确标注来源和链接。
  2. 我们十分尊重原创作者的付出,本站禁止二次转载如需转载请与原作者取得联系。
  3. 转载SDK.cn的原创文章需注明文章作者、链接和"来源:SDK.cn"并保留文章及标题完整性未经作者同意不得擅自修改。
  4. 作者投稿可能会经SDK.cn适当编辑修改或补充。
推荐工具 意见反馈