掌握聚合最新动态了解行业最新趋势
API接口,开发服务,免费咨询服务

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

函数参数

函数参数是程序里最重要的对值进行抽象的方式。Swift 现在提供三种方式的参数传递:

  • 通过具有所有权的值进行传递。这是一般参数的规则,我们无法显式地指明使用该方式。

  • 通过共享的值进行传递。这是对 nonmutating 方法的 self 参数的规则,我们无法显式地指明使用该方式。

  • 通过引用传递。这是对 inout 参数和 mutating 方法的 self 参数的规则。

译者注:没错,那些 nonmutating 的方法也具有 self 参数。(否则你就无法在方法内部使用 self 了!)

我们提议,允许我们可以指明使用那些非标准的情况:

  • 函数的参数可以被显式指明为 owned:

      func append(_ values: owned [Element]) {
        ...
      }
    

    This cannot be combined with shared or inout.

    owned 不能和 shared 或者 inout 一起使用。

    它只是对默认情况的一种显式的表达。我们不应该希望用户经常把它们写出来,除非用户正在处理不可复制的类型。

  • 函数的参数可以被显式指明为 shared。

      func ==(left: shared String, right: shared String) -> Bool {
        ...
      }
    

    This cannot be combined with owned or inout.

    shared 不能和 owned 或 inout 一起使用。

    如果函数参数是一个存储引用表达式的话,该存储在调用期间将被作为读取来进行访问。否则,参数表达式将被作为 r-value 来求值,并且临时值在调用中被共享。允许函数参数的临时值被共享是非常重要的,很多函数仅仅是因为自己事实上并不会去拥有参数,它们的参数就将被标记为 shared。而其实上,在语义上这些参数被作为对一个已存在的变量的引用时,才是标记 shared 的更为重要的情况。举个例子,我们这里将等号操作符的参数标为 shared,是因为它们需要在不事先声明的情况下,就能够对不可复制值也进行比较。同时,这也不能妨碍程序员去比较一般的字面值。

    和 inout 一样,shared 是函数类型的一部分。不过与 inout 不同,大多数函数兼容性检查 (比如方法重写的检查和函数转换的检查) 在 shared 和 owned 不匹配时也应该成功。如果一个带有 owned 参数的函数被转换 (或是重写) 为了一个 shared 参数的函数,参数类型实际上必须是可复制的。

  • 方法可以被显式声明为 consuming。

      consuming func moveElements(into collection: inout [Element]) {
        ...
      }
    

    这会使 self 被当作一个 owned 值传入,所以 consuming 不能和 mutating 或 nonmutating 混用。

    在方法内,self 依然是一个不可变的绑定值。

    译者注:这里提出的 consuming 实际上是对 mutating 的一种更加严谨的细分。如果没有添加相应的约定,那么在使用 mutating 时,self 的独占性保证只能动态进行。而这也正是 struct 中 mutating 现在不受程序员待见的原因之一。

函数结果

我们在本节的开头进行过一些讨论,想要对 Swift 的词法访问模型进行扩展,让它能支持从函数中返回暂态量并不是一件容易的事。实现这样访问,需要在访问的开始和结束时都执行一些和存储相关的代码。而在一个函数返回后,访问其实就没有进一步执行代码的能力了。

当然了,我们可以返回一个包含暂态量的回调,然后等待调用者使用完这个暂态量后再调用回调,这样我们就能处理暂态量的存储代码了。然而,单单只是这样做还不够,因为被调用者有可能会依赖于它的调用者所做出的保证。举例来说,比如 struct 上的一个 mutating,它想要返回的是对一个存储属性的 inout引用。想要一切正确,我们不仅要保证在访问属性后方法能够进行清理工作,还要保证绑定在 self 上的变量也一直有效。我们真正想要做的是在被调用侧以及调用侧所有有效的作用域内对当前上下文进行维护,并且简单地将暂态量作为参数,在调用侧进入一个新的嵌套的作用域。在编程语言中,这是一个已经被充分理解的情况了,那就是协程 (co-routine)。(因为作用域限制,你也可以将它想象为一个回调函数的语法糖,其中的 return 和 break 等都按照期望工作。)

事实上,协程可以用来解决很多有关暂态量的问题。我们会在接下来的几个子章节内探索这个问题。

译者注:协程的概念可以帮助简化线程调度的问题,也是一个良好的异步编程模型的基础。

for 循环

和三种传递参数的方式相同,我们也可以将对一个序列进行循环的方式分为三种。每种方式都可以用一个 for 循环来表达。

Consuming 迭代

第一种迭代方式是 Swift 中我们已经很属性的方式了:消耗 (consuming) 迭代,这种迭代中每一步都由一个 owned 值来代表。这也是我们对那些值可能是按需生成的任意序列的唯一的迭代方式。对于不可复制类型的集合,这种迭代方式可以让集合最终被结构,同时循环将获取集合中元素的所有权。因为这种方式会取得序列所产生的值的所有权,而且任意一个序列都不能被多次迭代,所以对于 Sequence 来说,这是一个 consuming 操作。

我们可以显式地将迭代变量声明为 owned 来指明这种迭代方式:

  for owned employee in company.employees {
    newCompany.employees.append(employee)
  }

当非可变的迭代的要求不能被满足的时候,这种方式也应该被默认地使用。(而且,这也是保证源码兼容性所必须的。)

接下来两种方式只对集合有意义,而不适用于任意的序列。

译者注:Swift 中,集合 (Collection) 一定是序列 (Sequence),但是序列不一定是集合。

Non-mutating 迭代

非可变迭代 (non-mutating iteration) 所做的事情是简单地访问集合中的每个元素,并且保持它们完好不变。这样,我们就不需要复制这些元素了;迭代变量可以简单地绑定给一个 shared 的值。这就是 Collection 上的 nonmutating 操作。

我们可以显式地将迭代变量声明为 shared 来指明这种迭代方式:

  for shared employee in company.employees {
    if !employee.respected { throw CatastrophicHRFailure() }
  }

在序列类型满足 Collection 时,这种行为是默认行为,因为对于集合来说,这是一种更优化的做法。

  for employee in company.employees {
    if !employee.respected { throw CatastrophicHRFailure() }
  }

如果序列操作的是一个存储引用表达式的话,在循环持续过程中,存储会被访问。注意,这意味着独占性原则将隐式地保证在迭代期间集合不会被修改。程序可以对操作使用固有函数 (intrinsic function) copy 来显式地要求迭代作用在存储的复制值上。

译者注:其实我们或多或少已经从 Swift 的值特性和独占性中获取好处了。比如对于一个可变数组,我们可以在迭代它的同时,修改它内容:

var array = [1,2,3]
for v in array {
   let index = array.index(of: v)!
   array.remove(at: index)
}

这正得益于对于迭代变量的复制,而这种操作在很多其他语言里是难以想象的。不过,这种语义上没有问题的做法却可能在实际中给程序员造成一些困扰。使用明确的标注来规范这种写法确实会是更好的选择。

关于固有函数,是指实现由编译器进行处理的那些“内嵌”在语言中的函数。我们会在后面的章节再进行详细说明。

Mutating 迭代

一个可变迭代将访问每个元素,并且有可能对元素作出改变。所迭代的变量是一个对元素的 inout 引用。这是对 MutableCollection 的 mutating 操作。

这种方式必须显式地用 inout 来声明迭代变量:

  for inout employee in company.employees {
    employee.respected = true
  }

序列操作的必须是一个存储引用表达式。在循环的持续时间中,存储将会被访问,和上面一种方式一样,这将阻止对于集合的重叠访问。(但是如果集合类型定义的操作是一个非可变操作的话,这条规则便不适用,比如一个引用语义的集合就是如此。)

表达可变和不可变迭代

可变迭代和不可变迭代都要求集合在迭代的每一步创建一个暂态量。在 Swift 中,我们有若干种表达的方式,但是最合理的方式应该是使用协程。因为协程在为调用者产生 (yield) 值的时候不会丢弃自己的执行上下文,所以一个协程产生多个值就是很正常的用法了,这也非常符合循环的基本代码模式。由此产生的一类协程通常被称为生成器 (generator),这也正是很多种主要语言实现迭代的方式。在 Swift 中,为了也能实现这种模式,我们需要允许对生成器函数进行定义,比如:

  mutating generator iterateMutable() -> inout Element {
    var i = startIndex, e = endIndex
    while i != e {
      yield &self[i]
      self.formIndex(after: &i)
    }
  }

对于使用者一方,用生成器来实现 for 循环的方式是很明显的;不过,如何直接在代码中允许生成器的使用却不那么明显的事情。如上所述,因为逻辑上整个协程必须运行在对原来值访问的作用域中,所以对于协程使用的方式,有一些有趣的限制。对于一般的生成器而言,如果生成器函数返回的确实是某种类型的生成器对象,那么编译器必须确保这个对象不会逃逸出访问范围。这是复杂度的一个重要来源。

一般化的访问方法

Swift 现在提供的用来获取属性和下标的工具相当粗糙:基本上只有 get 和 set 方法。对于性能很关键的任务来说,这些工具是远远不足的,因为它们并不支持直接对值进行访问,而一定会发生复制。标准库中可以使用稍微多一些的工具,可以在特定有限的情况下提供直接的访问,但是它们仍然很弱,而且基于不少原因,我们并不希望将它们暴露给用户。

所有权给我们提供了一个重新审视这个问题的机会,因为 get 返回的是一个拥有所有权的值,所以它无法用于那些不可复制类型。访问方法 (getter 或者 setter) 真正需要的是产生一个共享值的能力,而不只是单单能返回值。同样地,想要达成这一目的的一个可行方式是让访问方法能使用某种特殊的协程。和生成器不同,这个协程只能进行一次发生。而且我们没有必要为程序员设计调用它的方式,因为这种协程只会被用在访问方法中。

我们的想法是,不去定义 get 和 set,而是将在存储声明中定义 read 和 modify:

  var x: String
  var y: String
  var first: String {
    read {
      if x < y { yield x }
      else { yield y }
    }
    modify {
      if x < y { yield &x }
      else { yield &y }
    }
  }

一个存储声明必须定义 get 或者 read (或者定义为存储属性) 中的一个,但是不应该进行同时定义。

如果想要可变的话,存储声明必须再定义 set 或者 modify 中的一个。不过也可以选择同时定义两者,这种情况下 set 会被用作赋值,而 modify 会被用作更改。这在优化某些复杂的计算属性时会很有用,因为它可以允许更改操作原地进行,而不用强制对首先读取的旧值进行重新赋值。不过,需要特别注意,modify 的行为必须和 get 和 set 的行为相一致。

固有函数

move

Swift 优化器一般会尝试将值进行移动,而不是复制它们,但是强制进行移动也有其意义。正因如此,我们提议加入 move 函数。从概念上说,move 函数是一个 Swift 标准库的顶层函数:

  func move<T>(_ value: T) -> T {
    return value
  }

然后,这个函数有一些特定的含义。该函数不能被间接使用,参数表达式必须是某种形式的本地所有的存储:它可以是一个 let,一个 var,或者是一个 inout 的绑定。调用 move 函数在语义上等同将当前值从参数变量中移动出来,并将其以表达式制定的类型进行返回。返回的变量在最终初始化分析中将作为未初始化来对待。接下来变量所发生的事情依赖于变量的种类而定:

  • var 变量将被作为未初始化而简单传回。除非它被赋以新值或者被再次初始化,否则对它的使用都是非法的。

  • inout 绑定和 var 类似,不过它不能在未初始化的情况下离开作用域。或者说,如果程序要离开一个有 inout 绑定的作用域的话,程序必须为这个变量赋新的值,而不论它是以何种方式离开作用域 (包括抛出错误的时候)。将 inout 暂时作为未定义变量的安全性是由独占性原则所保证的。

  • let 变量不能被再次初始化,所以它不能再被使用。

这对于现在的最终初始化分析是一个直接的补充,它能确保在使用一个本地变量之前,它总是被初始化过的。

copy

copy 是 Swift 标准库中的一个顶层函数:

  func copy<T>(_ value: T) -> T {
    return value
  }

参数必须是一个存储引用表达式。函数的语义和上面的代码一致:参数值会被返回。该函数的意义如下:

  • 它可以阻止语法上的特殊转换。举例来说,我们上面讨论过,如果 shared 参数是一个存储引用,那么存储在调用期间是被访问的。程序员可以通过事前在存储引用上调用 copy 来阻止这种访问,并且强制复制操作在函数调用前完成。

  • 对于那些阻止隐式复制的类型来说,这是必须的。我们会对不可复制类型进行进一步叙述。

endScope

endScope 是 Swift 标准库中的顶层函数:

  func endScope<T>(_ value: T) -> () {}

参数必须是一个引用本地 let,var 或者独立的 (非参数,非循环) inout 或者 shared 声明。如果参数是 let 或者 var,则变量会被立即销毁。如果参数是 inout 或者 shared,则访问将立即终止。

最终初始化分析必须保证声明在这个调用后没有再被使用。如果存储是一个被逃逸闭包捕获的 var 的话,应该给出错误。

对于想要在控制流到达作用域结尾前就想要终止访问的情形来说,这很有用。同样地,对销毁值时的微优化也能起到作用。

enScope 保证在调用的时候输入的值是被销毁的,或者对它的访问已经结束。不过它没有承诺这些事情确实发生在这个时间点:具体的实现仍然可以更早地结束它们。

透镜 (Lenses)

现在,Swift 中所有的存储引用表达式都是具体的:每一个组件都静态地对应一种存储声明。在社区中,大家在允许程序对存储进行抽象这件事上一致兴趣盎然,比如说:

  let prop = Widget.weight

这里 prop 会是一个对 weight 属性的抽象引用,它的类型是 (Widget) -> Double。

译者注:对于类型上的方法来说,这种透镜抽象是一直存在的 - 因为方法不存在所有权的内存问题。

这个特性和所有权模型有关,因为一个一般的函数的结果一定是一个 owned 的值:不会是 shared,也不是可变值。这意味着,上述这种透镜抽象只能抽象读操作,而不能对应写操作,而且我们只能为可复制的属性创建这种抽象。这也意味着使用透镜的代码会比使用具体存储引用的代码需要更多的复制。

设想,要是透镜抽象不是简单的函数,而是它们各自类型的值。那么透镜的使用将会变成一个对静态未知成员进行访问的抽象的存储引用表达式。这就需要语言的实现能够执行某种程度的动态访问。然而,访问未知的属性和访问实现未知的已知属性有几乎一样的问题;也就是说,为了实现泛型和还原类型,语言已经需要做类似的事情了。

总体来说,只要我们有所有权模型,这样的特性就正好可以符合我们的模型。

不可复制的类型

不可复制的类型在很多高级的情况中会十分有用。比如,它们可以被用来高效地表达唯一的所有权。它们也可以用来表达一些含有像是原子类型这样的某种独立标识的值。它们还可以被用作一种正式的机制,来鼓励代码能够更高效地和那些复制起来开销很大的类型 (比如很大的) 一起使用。它们之间统一的主题是,我们不想类型被隐式地复制。

Swift 中处理不可复制类型的复杂度主要有两个来源:

  • 语言必须提供能将值进行移动和共享,而不强制进行复制的工具。我们已经对这些工具进行了提案,因为它们对优化可复制类型的使用也同等重要。

  • 泛型系统必须能够表达不可复制的类型,同时不引入大量的源码兼容性问题,也不需要强制所有人使用不可复制类型。

如果不是因为这两个原因的话,这个特性本身是很小的。就只需要用我们上面提到的 move 固有函数那样,隐式地使用移动来代替复制,并且在遇到无法适用的情况下给出诊断信息即可。

moveonly 上下文

不过,泛型确实会是一个问题。在 Swift 中,最直白的为可复制特性建模的方式无非就是添加一个 Copyable 协议,让那些可以被复制的类型遵守这个协议。这样一来,不加限制的类型参数 T 就无法被假设为可复制类型。不过,这么做对源码兼容性和可用性来说都会是巨大的灾难,而且我们也不想让程序员在首次被介绍使用泛型代码的时候就去操心那些不可复制类型的问题。

另外,我们也不想让类型需要显式地被声明为支持 Copyable。对复制的支持应该是默认的。

所以,逻辑上来说解决的方式是,维持现在所有类型都是可复制类型的默认假设,然后允许上下文选择将这个假设关掉。我们将这些上下文叫做 moveonly 上下文。在一个 moveonly 上下文中词法嵌套的所有上下文也都隐式地成为 moveonly。

一个类型可以提供 moveonly 上下文:

  moveonly struct Array<Element> {
    // Element and Array<Element> are not assumed to be copyable here
  }

这将阻止在该类型声明,它的泛型参数 (如果有的话),以及它们在继承链上所关联的类型上进行 Copyable假设。

扩展也可以提供 moveonly 上下文:

  moveonly extension Array {
    // Element and Array<Element> are not assumed to be copyable here
  }

不过使用带有条件的协议遵守时,类型可以声明为条件可复制:

  moveonly extension Array: Copyable where Element: Copyable {
    ...
  }

不论是在约束条件里满足还是直接满足 Copyable,它都会是一个类型的继承特性,并且一定要在定义该类型的同一个模块中进行声明。(或者有可能的话,应该在同一个文件中进行声明。)

对于一个类型的非 moveonly 的扩展,将会把可复制性的假设重新引入这个类型及其泛型参数中。这么做的目的是为了标准库中的类型能够在不打破现有扩展的兼容性的同时,添加对不可复制元素的支持。如果一个类型没有进行任何的 Copyable 声明,那么为它添加一个非 moveonly 的扩展将会发生错误。

译者注:这里的意思是,针对那些 moveonly 定义的类型,我们不能为它随意添加非 moveonly 的扩展。这是显而易见的,否则就会发生复制特性的冲突。而对于那些非 moveonly 的类型 (因为它们是隐式默认支持复制,或者说满足 Copyable 的),以及在条件约束下满足 Copyable 的情况来说,添加非 moveonly 扩展是没有问题的。

函数也可以定义一个 moveonly 上下文:

  extension Array {
    moveonly func report<U>(_ u: U)
  }

这将会使任何新的泛型参数和它们的继承关联类型上的复制假设无效。

很多关于 moveonly 上下文的细节我们扔在考虑之中。关于这个问题,在我们最终寻找到正确的设计之前,还需要很多的进行语言实现的经验。

我们正在考虑的一种可能性是,对于可复制类型的值,moveonly 上下文也将会取消其可复制假设。对于那些需要特别注意复制操作的代码来说,这会提供一种重要的优化工具。

不可复制类型的 deinit

对那些定义为 moveonly 的不遵守 (也不条件遵守) Copyable 的值类型,可以为其定义一个 deinit 方法。注意,deinit 必须被定义在类型的主定义域内,而不能定义在扩展中。

在值不再被需要时,deinit 将会被调用以销毁这个值。这让不可复制类型可以被用来表达对于资源的唯一所有权。比如说,这里有一个简单的处理文件的类型,它保证了值被销毁时,文件句柄一定会被关闭:

  moveonly struct File {
    var descriptor: Int32

    init(filename: String) throws {
      descriptor = Darwin.open(filename, O_RDONLY)

      // 在 `init` 里任何非正常退出都会阻止 deinit 被调用
      if descriptor == -1 { throw ... }
    }

    deinit {
      _ = Darwin.close(descriptor)
    }

    consuming func close() throws {
      if Darwin.fsync(descriptor) != 0 { throw ... }

      // 这是一个 consuming 函数,所以它拥有对自己的所有权。
      // 其他的任何方式都不会对 self 产生消耗,所以函数将在
      // 退出时通过调用 deinit 进行销毁。
      // 而 deinit 将会通过描述符实际关闭文件句柄。
    }
  }

Swift 对值的销毁 (以及对 deinit 的调用) 发生在一个值被最后使用后,以及正式解构的时间点前的期间内。不过这个定义中对于“使用”的定义暂时还没有完全决定。

如果这个值类型是一个 struct,那么 deinit 中 self 只能被用来引用类型的存储属性。self 的存储属性会被当作本地 let 常量被看待,并用于最终初始化分析;也就是说,它们是属于 deinit 方法,并且可以被移出去的。

如果值类型是一个 enum 的话,deinit 里的 self 只能被当作 switch 的操作数来使用。在 switch内,任何一个用来初始化对应绑定的关联值,都拥有对这些值的所有权。这样的 switch 会使 self 处于未初始化状态。

显式可复制类型

在不可复制类型里,还有一种我们在探索的想法,那就是将一个类型声明为不可被隐式复制。比如,一个很大的结构体可以被正式地进行复制,但是如果不必要地对它进行复制的话,就会对性能产生过大的影响。这样的类型应该需要遵守 Copyable,而且它应该在调用 copy 函数时请求一份复制。不过,编译器应当像在处理不可复制类型那样,在任何隐式复制发生时给出诊断信息。

实现的优先级

这篇文档陈列了很多工作,我们可以将其总结如下:

  • 强制独占性原则:

    • 静态强制
    • 动态强制
    • 动态强制的优化
  • 新的标注和声明:

    • shared 参数
    • consuming 方法
    • 本地 shared 和 inout 声明
  • 新的固有函数和它们的区别:

    • move 函数及其关联的影响
    • endScope 函数及其关联的影响
  • 协程特性:

    • 通用的访问方法
    • 生成器
  • 不可复制类型

    • 未来的设计工作
    • 不可复制类型的区别
    • moveonly 上下文

在接下来的版本中,最主要的目的是 ABI 稳定。对于这些特性的优先级划分和分析必须以它们对 ABI 的影响为中心。在将这一点纳入考虑后,我们主要对 ABI 方面有如下思考:

独占性原则将会改变对参数作出的保证,因此它将影响 ABI。我们必须在 ABI 锁定之前将这条规则纳入到语言中,否则我们将永远失去改变这个保守假设的机会。不过,除非我们打算将一部分工作放到运行时去做,否则具体的实现独占性原则的方式并不会对 ABI 产生影响。况且将部分工作放到运行时并不是必要的,它在未来的发布版本中也可以被改变。(另外需要说明,在技术上独占性原则可能给优化器造成重大的影响,但是这应该是一个普通的项目进程上的考虑,而不会影响到 ABI。)

译者注:Swift 的 ABI 稳定是一个提了有两年的议题了。现在看来,Swift 4 中 ABI 稳定依然无法达成,也就是说不同 Swift 编译器编译出的二进制并不能互相通用 (举例来说,就是新版本 Swift 的 app 不能调用旧版本的 Swift 框架)。如果没有 ABI 稳定,Swift app 就还是必须包含 Swift 运行库的复制,我们也不可能使用二进制的框架。Apple 当前内部 app 和自己的框架几乎都不是 Swift 版本的,也在很大程度上受到 Swift ABI 稳定

声明:所有来源为“聚合数据”的内容信息,未经本网许可,不得转载!如对内容有异议或投诉,请与我们联系。邮箱:marketing@think-land.com

  • 营运车判定查询

    输入车牌号码或车架号,判定是否属于营运车辆。

    输入车牌号码或车架号,判定是否属于营运车辆。

  • 名下车辆数量查询

    根据身份证号码/统一社会信用代码查询名下车辆数量。

    根据身份证号码/统一社会信用代码查询名下车辆数量。

  • 车辆理赔情况查询

    根据身份证号码/社会统一信用代码/车架号/车牌号,查询车辆是否有理赔情况。

    根据身份证号码/社会统一信用代码/车架号/车牌号,查询车辆是否有理赔情况。

  • 车辆过户次数查询

    根据身份证号码/社会统一信用代码/车牌号/车架号,查询车辆的过户次数信息。

    根据身份证号码/社会统一信用代码/车牌号/车架号,查询车辆的过户次数信息。

  • 风险人员分值

    根据姓名和身份证查询风险人员分值。

    根据姓名和身份证查询风险人员分值。

0512-88869195
数 据 驱 动 未 来
Data Drives The Future