一尘不染

快速值类型何时复制

swift

在Swift中,当您传递值类型时,对函数说一个数组。制作了数组的副本以供该函数使用。

但是https://developer.apple.com/library/ios/documentation/Swift/Conceptual/Swift_Programming_Language/ClassesAndStructures.html#//apple_ref/doc/uid/TP40014097-CH13-XID_134上的文档也说:

上面的描述指的是字符串,数组和字典的“复制”。您在代码中看到的行为将始终就像发生了复制一样。但是,Swift仅在绝对必要时才在幕后执行实际副本。Swift会管理所有值复制以确保最佳性能,因此,您不应避免分配以试图抢占该优化。

因此,这是否意味着仅在修改传递的值类型时才进行复制?

有没有办法证明这实际上是潜在的行为?

为什么这很重要?如果我创建了一个大的不可变数组,并希望将其从一个函数传递到另一个函数,那么我当然不想继续制作它的副本。在这种情况下,我应该只使用NSArrray还是只要我不尝试操纵传入的数组就可以使Swift
Array正常工作?

现在,只要我没有通过使用var或inout显式使函数中的变量可编辑,那么该函数无论如何都无法修改数组。那它仍然会复制吗?允许另一个线程可以在其他地方修改原始数组(仅当它是可变的时),在调用该函数时立即进行复制(但仅当传入的数组是可变的时)。因此,如果原始数组是不可变的,并且该函数未使用var或inout,则Swift不会创建副本。对?那么,苹果这个词是什么意思呢?


阅读 239

收藏
2020-07-07

共1个答案

一尘不染

TL; DR:

因此,这是否意味着仅在修改传递的值类型时才进行复制?

是!

有没有办法证明这实际上是潜在的行为?

请参见写时复制优化部分中的第一个示例。

在这种情况下,我应该只使用NSArrray还是只要我不尝试操纵传入的数组就可以使Swift Array正常工作?

如果将数组传递为inout,则将具有按引用传递的语义,因此显然避免了不必要的复制。如果您将数组作为普通参数传递,那么写时复制优化将开始进行,您不会注意到任何性能下降,同时仍然可以享受到比类型更高的类型安全性NSArray

现在,只要我没有通过使用var或inout显式使函数中的变量可编辑,那么该函数无论如何都无法修改数组。那它仍然会复制吗?

您将获得抽象意义上的“副本”。实际上,由于写时复制机制,底层存储将被共享,从而避免了不必要的复制。

如果原始数组是不可变的,并且该函数未使用var或inout,则Swift不会创建副本。对?

确实如此,因此是写时复制机制。

那么,苹果这个词是什么意思呢?

本质上,Apple意味着您不必担心复制值类型的“成本”,因为Swift会在后台为您优化它。

取而代之的是,您应该考虑值类型的语义,即在分配或将它们用作参数后立即获取副本。Swift的编译器实际生成的是Swift的编译器业务。

值类型语义

实际上,Swift确实将数组以及结构,枚举和大多数其他内置类型(即那些属于标准库而不是Foundation的 类型 )视为 值类型 (与
引用类型
相反)。在内存级别上,这些类型实际上是不可变的普通旧数据对象(PO​​D),可实现有趣的优化。实际上,它们通常是分配在堆栈而不是堆[1]上的(https://en.wikipedia.org/wiki/Stack-
based_memory_allocation)。这使CPU可以非常有效地管理它们,并在函数退出后立即自动释放其内存[2],而不需要任何垃圾回收策略。

赋值或作为函数传递值时,将复制值。此语义具有各种优点,例如避免创建意外的别名,还可以使编译器更轻松地保证存储在另一个对象中或由闭包捕获的值的生存期。我们可以考虑管理好旧的C指针来理解原因有多难。

可能有人认为这是一个构思错误的策略,因为它涉及到每次分配变量或调用函数时都进行复制。但是可能违反直觉的是,复制小型类型通常很便宜,即使比传递引用便宜也不便宜。毕竟,指针通常与整数大小相同…

但是,对于大型集合(即数组,集合和字典)以及较小程度的超大型结构而言,顾虑是合理的[3]。但是编译器有一个技巧来处理这些问题,即 写时复制
(请参阅后面)。

关于什么 mutating

结构可以定义mutating方法,这些方法可以使结构的字段发生突变。这与以下事实并不矛盾:值类型仅是不可变的POD,因为事实上,调用mutating方法仅仅是将变量重新分配给与先前值相同的全新值的巨大语法糖,除了字段被突变了。以下示例说明了这种语义对等:

struct S {
  var foo: Int
  var bar: Int
  mutating func modify() {
    foo = bar
  }
}

var s1 = S(foo: 0, bar: 10)
s1.modify()

// The two lines above do the same as the two lines below:
var s2 = S(foo: 0, bar: 10)
s2 = S(foo: s2.bar, bar: s2.bar)

引用类型语义

与值类型不同,引用类型本质上是在内存级别指向堆的指针。它们的语义更接近我们在基于引用的语言(例如Java,Python或Javascript)中获得的语​​义。这意味着它们在分配或传递给函数时不会被复制,
它们的地址为
。由于CPU不再能够自动管理这些对象的内存,因此Swift使用引用计数器在后台处理垃圾回收(https://en.wikipedia.org/wiki/Reference_counting)。

这样的语义具有避免复制的明显优势,因为所有内容都是通过引用分配或传递的。缺点是,与几乎所有其他基于引用的语言一样,存在意外别名的危险。

关于什么 inout

一个inout参数无非是读写指针预期的类型。对于值类型,这意味着该函数将不会获得该值的副本,而是指向此类值的指针,因此该函数内部的突变将影响value参数(因此使用inout关键字)。换句话说,这为值类型参数提供了函数上下文中的参考语义:

func f(x: inout [Int]) {
  x.append(12)
}

var a = [0]
f(x: &a)

// Prints '[0, 12]'
print(a)

在引用类型的情况下,它将使引用本身可变,就像传递的参数是对象地址的地址一样:

func f(x: inout NSArray) {
  x = [12]
}

var a: NSArray = [0]
f(x: &a)

// Prints '(12)'
print(a)

写时复制

写时复制(https://en.wikipedia.org/wiki/Copy-on-
write)是一种优化技术,可避免不必要的可变变量副本,该副本副本已在所有Swift的内置集合(即数组)中实现,集合和字典)。当您分配数组(或将其传递给函数)时,Swift不会复制所述数组,而实际上使用了引用。第二个数组突变后,副本将立即进行。可以使用以下代码段(Swift
4.1)演示此行为:

let array1 = [1, 2, 3]
var array2 = array1

// Will print the same address twice.
array1.withUnsafeBytes { print($0.baseAddress!) }
array2.withUnsafeBytes { print($0.baseAddress!) }

array2[0] = 1

// Will print a different address.
array2.withUnsafeBytes { print($0.baseAddress!) }

实际上,array2它并没有array1立即得到副本,因为它指向相同的地址。相反,副本是由的突变触发的array2

这种优化还发生在结构的更深层,这意味着,例如,如果您的集合是由其他集合组成的,则后者也会从写时复制机制中受益,如以下代码片段所示(Swift 4.1):

var array1 = [[1, 2], [3, 4]]
var array2 = array1

// Will print the same address twice.
array1[1].withUnsafeBytes { print($0.baseAddress!) }
array2[1].withUnsafeBytes { print($0.baseAddress!) }

array2[0] = []

// Will print the same address as before.
array2[1].withUnsafeBytes { print($0.baseAddress!) }

复制写时复制

实际上,在Swift中实现写时复制机制相当容易,因为其某些参考计数器API会向用户公开。技巧包括将引用(例如,类实例)包装在结构内,并在对其进行突变之前检查该引用是否被唯一引用。在这种情况下,可以安全地更改包装的值,否则应将其复制:

final class Wrapped<T> {
  init(value: T) { self.value = value }
  var value: T
}

struct CopyOnWrite<T> {
  init(value: T) { self.wrapped = Wrapped(value: value) }
  var wrapped: Wrapped<T>
  var value: T {
    get { return wrapped.value }
    set {
      if isKnownUniquelyReferenced(&wrapped) {
        wrapped.value = newValue
      } else {
        wrapped = Wrapped(value: newValue)
      }
    }
  }
}

var a = CopyOnWrite(value: SomeLargeObject())

// This line doesn't copy anything.
var b = a

但是,这里有一个导入警告!阅读文档,isKnownUniquelyReferenced我们会收到以下警告:

如果作为对象传递的实例同时被多个线程访问,则此函数可能仍返回true。因此,您只能从具有适当线程同步的变异方法中调用此函数。

这意味着上面介绍的实现不是线程安全的,因为我们可能会遇到这样的情况,即错误地认为包装的对象可以安全地进行突变,而实际上这样的突变会破坏另一个线程中的不变性。但这并不意味着Swift的写时复制在多线程程序中固有地存在缺陷。关键是要了解“同时由多个线程访问”的真正含义。在我们的示例中,如果CopyOnWrite在多个线程之间共享相同的实例,例如作为共享全局变量的一部分,就会发生这种情况。被包装的对象将具有线程安全的写时复制语义,但是保存该对象的实例将受到数据竞争的影响。原因是Swift必须建立唯一所有权才能正确评估isKnownUniquelyReferenced
[4],如果实例的所有者本身在多个线程之间共享,则无法执行此操作。

值类型和多线程

如Apple的博客(https://developer.apple.com/swift/blog/?id=10)所述,Swift旨在减轻程序员在处理多线程环境时的负担:

选择值类型而不是引用类型的主要原因之一是能够更轻松地推理代码。如果您始终获得一个唯一的,复制的实例,则可以相信,应用程序的其他部分都不会更改底层数据。这在多线程环境中特别有用,在该环境中,不同的线程可能会从您的身下改变您的数据。这会产生难以调试的讨厌的错误。

最终,写时复制机制是一种资源管理优化,与任何其他优化技术一样,编写代码时不应考虑[5]。取而代之的是,应该用更抽象的术语来思考,并考虑在赋值或作为参数传递时有效地复制值。


[1] 仅适用于用作局部变量的值。用作引用类型(例如,类)的字段的值也存储在堆中。

[2] 可以通过检查在处理值类型而不是引用类型时生成的LLVM字节码来确认这一点,但是Swift编译器非常渴望执行常量传播,因此构建一个最小的示例有些棘手。

[3] Swift不允许结构引用自身,因为编译器将无法静态计算此类类型的大小。因此,想到一个太大的结构以至于对其进行复制将成为合理的考虑并不是很现实。

[4] 顺便说一下,这就是isKnownUniquelyReferenced接受inout参数的原因,因为它是当前Swift建立所有权的方式。

[5]
尽管传递值类型实例的副本应该是安全的,但是有一个开放的问题表明当前的实现存在一些问题(https://bugs.swift.org/browse/SR-6543)。

2020-07-07