何时进行Swift值类型的复制?

11
在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管理所有值的复制以确保最佳性能,并且您不应避免赋值来尝试预先优化此过程。

那么这是否意味着只有在修改传递的值类型时才会进行复制呢?有没有办法证明这实际上是基础行为?
为什么这很重要?如果我创建了一个大的不可变数组并想从函数到函数传递它,我肯定不想一直制作副本。在这种情况下,我应该只使用NSArray还是Swift Array也可以正常工作,只要我不尝试操作传入的数组?
现在只要我不明确使用var或inout使函数中的变量可编辑,则函数无论如何都不能修改数组。那么它是否仍然会复制?虽然另一个线程可以在其他地方修改原始数组(仅当它是可变的),但在调用函数时立即复制传入的数组是必要的(但仅当传入的数组是可变的)。因此,如果原始数组是不可变的且函数未使用var或inout,则没有必要在Swift中创建副本。对吗?那么苹果公司上面的那个短语是什么意思?

5
据我理解,Swift 数组的行为被称为“写时复制”,也就是说,对于不可变数组,Swift 会按照你的期望合理地进行操作:因为你无法对其进行写操作,所以它永远不会被复制。 - Matt Gibson
2个回答

18

TL;DR:

这是否意味着只有在修改传递的值类型时才会进行复制?

是的!

有没有一种方法可以证明这实际上是底层行为?

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

在这种情况下,我应该只使用NSArray,还是Swift Array也可以, 只要我不尝试操作传入的Array?

如果您将数组作为inout传递,则具有按引用传递语义, 因此显然避免不必要的复制。 如果您将数组作为普通参数传递, 则复制写入优化将发挥作用,您不应该注意到任何性能下降, 同时仍然受益于比NSArray更多的类型安全性。

现在只要我不使用var或inout明确使函数中的变量可编辑, 那么函数就无法修改数组。所以它仍然会复制吗?

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

如果原始数组是不可变的,并且函数未使用var或inout, 那么Swift创建副本没有意义。对吗?

确切地说,这就是复制写入机制。

那么苹果公司上述短语的含义是什么?

基本上,Apple的意思是你不需要担心复制值类型的“成本”,因为Swift会在幕后为您进行优化。

相反,您应该只考虑值类型的语义, 也就是说,一旦将其分配或用作参数,就会获得副本。 实际上,Swift编译器生成的内容是Swift编译器的业务。

值类型语义

Swift确实将数组视为值类型(而不是引用类型), 以及结构、枚举和大多数其他内置类型 (即标准库中的那些,而不是Foundation)。 在内存层面上,这些类型实际上是不可变的普通数据对象(POD), 这使得有趣的优化成为可能。 事实上,它们通常被分配在堆栈而不是堆上[1] (https://en.wikipedia.org/wiki/Stack-based_memory_allocation)。 这使得CPU能够非常高效地管理它们, 并在函数退出时自动释放它们的内存[2], 而无需任何垃圾回收策略。

每次分配或传递函数时都会复制值。 这种语义具有各种优点, 例如避免创建意外别名, 还可以使编译器更容易地保证存储在另一个对象中或由闭包捕获的值的生命周期。 我们可以考虑一下管理旧C指针有多难,就可以理解这一点。

有人可能会认为这是一个构思不良的策略,因为每次变量被赋值或函数被调用时都需要复制。但是,尽管这似乎与直觉相反,但复制小型类型通常相当便宜,甚至比传递引用更便宜。毕竟,指针通常与整数大小相同...
然而,对于大型集合(即数组、集合和字典),以及较大的结构体,关注点是合理的[3]。但是编译器有一个处理这些问题的技巧,即写时复制(参见后文)。
结构体可以定义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参数只是对预期类型的读写指针。 对于值类型,这意味着函数不会获得值的副本, 而是获得指向这些值的指针, 因此函数内部的变异将影响值参数(因此是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],如果实例的所有者本身在多个线程之间共享,则它无法做到这一点。
值类型和多线程
Swift的意图是减轻程序员在处理多线程环境时的负担,如在Apple的博客(https://developer.apple.com/swift/blog/?id=10)中所述。
选择值类型而不是引用类型的主要原因之一是更容易理解代码。如果您总是获得唯一的、复制的实例,您可以相信应用程序的其他部分没有在幕后更改数据。这在多线程环境中尤其有帮助,在这种环境中,不同的线程可能会在您下面更改您的数据。这可能会创建非常难以调试的恶意软件。
最终,写时复制机制是一种资源管理优化,就像任何其他优化技术一样,在编写代码时不应该考虑[5]。相反,应该从更抽象的角度来考虑值在被分配或作为参数传递时被有效地复制。

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

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

[3]Swift不允许结构体引用自身,因为编译器无法静态计算这种类型的大小。因此,认为一个结构体非常大,复制它将成为合理的关注点并不现实。

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

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


这是一个非常详尽和有用的回答,谢谢! - nylki

1
我不知道在Swift中每种值类型是否都是这样,但对于数组,我相当确定它是按写时复制的,因此只有在您修改它时才会复制它,正如您所说,如果将其作为常量传递,您无论如何都不会遇到这种风险。
另外,在Swift 1.2中,还有一些新的API可以用来实现自己的值类型的写时复制。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接