【iOS】objc.io – Swift 进阶 – 整理 – (五)结构体和类

值类型和引用类型

var a:Int = 3 
varb=a 
b+=1
// a = 3, b = 4
复制代码

赋值意味着按值拷贝。每个值类型变量所持有的值都是独立的。具有这种行为特征的类型被称为具有值语义 (value semantics)。

标准库中 Int 的定义,就是一个结构体,因此也就具有值语义:

public struct Int: FixedWidthInteger, SignedInteger { ... }
复制代码

值类型的特征就是变量和值之间的直接关系:在变量的背后,值 (值类型的实例) 直接保存在变量指向的内存位置。这不但适用于像整数这样简单的值类型,同样也适用于更复杂的类型。例如,包含多个属性的自定义结构体 (在机器码的层面,因为编译器的优化,这个说法不一定成立。但对开发人员来说是不透明的,因此至少在语义上,该说法是准确的)。

var view1 = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) 
var view2 = view1
view2.frame.origin = CGPoint(x: 50, y: 50)
view1.frame.origin // (50, 50)
view2.frame.origin // (50, 50)
复制代码

尽管只对 view2.frame.origin 赋值了一个新的 CGPoint 值,view1 的 frame 属性也会被修改。 view1 和 view2 在某种意义上是相同的,是屏幕上看到的同一个 view。因为 UIView 是一个引用类型,所以 view1 和 view2 这 两个变量包含的引用,背后都指向内存中同一个 UIView 实例。

view2 = UILabel() // 对 view2 重新赋值
复制代码

重新赋值这个操作改变了 view2 所指向的实例 (或者说是对象)。

这就是引用类型的本质:变量不含有 “事物” 本身 (例如,UIView 或 URLSession 的实例),而是持有一个对 “事物” 的引用。其他变量也可以含有对同一个实例的引用,并可以通过任意一个指向它的变量对其做修改。具有这些特性的类型被称为具有引用语义 (reference semantics)。

相比于值类型,这里多了一个间接层在发挥作用。一个值类型变量含有的是值本身,而一个引用类型变量含有的是一个指向其他某个地方的值的引用。这种间接性允许我们可以在程序的不同部分访问同一个对象。

可变性

class ScoreClass {
    var home: Int
    var guest: Int
    init(home: Int, guest: Int) {
        self.home = home
        self.guest = guest 
    }
}
struct ScoreStruct {
    var home: Int
    var guest: Int
    // 编译器生成成员初始化方法。
}

var scoreClass = ScoreClass(home: 0, guest: 0) 
var scoreStruct = ScoreStruct(home: 0, guest: 0) 
scoreClass.home += 1
scoreStruct.guest += 1

////

let scoreClass = ScoreClass(home: 0, guest: 0) 
let scoreStruct = ScoreStruct(home: 0, guest: 0) 
scoreClass.home += 1 // 可以工作
scoreStruct.guest += 1 
// 错误:可变操作符的左侧是不可变类型 
// 'scoreStruct' 是一个 `let` 常量
复制代码

用 let 声明一个变量,意味着在初始化之后,就不能改变它的值了。由于 scoreClass 变量的值是一个指向 ScoreClass 实例的引用,所以这意味着我们只是不能再给 scoreClass 赋值另外一个引用而已。但修改我们所创建的 ScoreClass 实例的属性时,并不需要改变 scoreClass 变量的值。我们只需要通过引用得到实例,然后在实例上修改那些我们用 var 所声明的属性。

对于结构体的情况,这个行为是非常不同的。因为结构体是值类型,所以 scoreStruct 变量不包含一个对其他某个地方的实例的引用,而是 ScoreStruct 实例本身。由于用 let 声明的变量的值,在初始化之后就不能再被修改,所以即使在结构体里一个属性是用 var 声明的,我们也不能修改它。会有这种行为的原因是,其实修改一个结构体的属性这件事,在语义上是等同于对变量赋值一个新的结构体实例。

scoreStruct.guest += 1
// 等同于:
scoreStruct = ScoreStruct(home: scoreStruct.home,
guest: scoreStruct.guest + 1)
复制代码

那如果我们把属性都声明成 let,而把 scoreClass 以及 scoreStruct 这两个变量声明成 var 的 话,只意味着我们可以改变变量本身的值。在类的情况下,变量的值是一个指向实例的引用,所以我们可以改变的是变量持有的引用,然而,因为 home 属性被定义为了 let,所以我们没办法更改 scoreClass 所指向的实例的这个属性。我们同样也不能修改结构体的属性:属性都被定义成了 let,所以即使scoreStruct 被定义成 var,也不能修改它的属性了。不过,我们还是可以给 scoreStruct 变量赋值一个新的结构体实例。

最后,如果我们把属性和变量都定义为 let 的话,编译器会禁止任何形式的修改:即不能把一个新的实例赋值给 someClass 或 someStruct,也不能修改实例的任何属性。

推荐默认使用 var 来定义结构体的属性。这允许通过在变量这一层上,使用 var 或 let 来控 制结构体实例的可变性,从而为你提供了更大的灵活性。相比于类,在结构体上使用 var 定义属性,并不会引入潜在的全局可变状态,因为修改一个结构体的属性,实际上只是创建一份带着修改值的结构体的拷贝。我们应该谨慎地使用 let,只应该把那些即使实例被保存在一个 var 变量,但在初始化之后确实就不应该再改变的属性 (例如,因为改变了某个属性,就会使结构体进入一种无效的状态) 声明为常量。

要理解在属性和变量上用 let 和 var 的所有不同组合的关键是要记住两点:

  • 类型为类的变量的值,是一个指向实例的引用;而类型为结构体的变量的值,是结构体实例本身。
  • 修改一个结构体的属性,即使修改的是多层的嵌套属性,都等同于给变量赋值一个全新的结构体实例。

可变方法

在结构体上用 func 关键字定义的普通方法,是不能修改结构体的任何属性的。这是因为被隐式传入的 self 参数,默认是不可变的。我们必须明确地使用 mutating func 关键字来创建一个可变方法

extension ScoreStruct { 
    mutating func scoreGuest() {
        self.guest += 1 
    }
}
复制代码

在可变方法中,我们可以认为 self 是一个用 var 声明的变量,所以也就可以修改那些在 self 中,用 var 声明的属性。

在可变方法中,我们可以认为 self 是一个用 var 声明的变量,所以也就可以修改那些在 self 中, 用 var 声明的属性。编译器把 mutating 关键字作为一个标记,以此来决定哪些方法是不能在 let 常量上被调用的。 我们只能对用 var 声明的变量调用可变方法,因为调用一个可变方法,就等于对变量赋值一个 新的值 (事实上,在可变方法中,还允许对 self 赋值一个全新的值)。如果尝试在一个 let 变量 上调用可变方法的话,编译器会显示错误。而且即使可变方法实际上并不会改变 self, mutating 标注也足以禁止此方法在 let 变量上被调用。

属性和下标的 setter 都是隐式的可变方法。在极少数的情况下,你会希望使用一个不可变的 setter 来实现计算属性,例如,你的结构体封装了一个全局资源,而相应属性的 setter 只是去修改这个全局状态。这个时候,你可以用 nonmutating set 来标注相应的 setter。编译器允许你在一个 let 常量上调用此类 setter。

类并没有也不需要可变方法,正如我们上面所见,即使一个类变量被声明为 let,我们依然可以改变实例的属性。和结构体的普通方法类似,在类的方法中,self 表现得就像一个用 let 声明的变量。不过虽然我们不能对 self 重新赋值,但可以通过使用 self 来修改其所指向的实例的属性,只要该属性被声明为 var 就可以。

inout 参数

我们在上面提到过,因为在结构体的可变方法中,访问的是一个可变的 self,所以它能改变这个 self 中任何一个被声明为 var 的属性。但除了利用 self 之外,我们还可以通过 inout 参数编写可以直接修改参数的函数。例如,之前的 scoreGuest 可变方法还可以像这样写成一个全局函数:

func scoreGuest(_ score: ScoreStruct) { 
    score.guest += 1
    // 错误:可变操作符的左边是不可变类型: 
    // 'score' 是一个 'let' 常量。
}
复制代码

函数的参数默认就像 let 变量一样,是不可变的。我们虽然可以把参数复制并赋值给一个局部 的 var 变量,但对这个变量的修改,并不会对传入的原来的值产生影响。为了解决这个问题, 我们可以在参数类型之前加上 inout 关键字.

为了向 scoreGuest 函数传递 inout 参数,必须做两件事:首先,作为 inout 参数传递的变量必须是用 var 定义的;其次,当把这个变量传递给函数时,必须在变量名前加上 & 符号。站在调用者的角度,& 符号可以清楚的表明,这个函数会修改传入的变量的值。

虽然 & 符号可能会让你想起 C 和 Objective-c 中的取址操作符,或者是 C++ 中的引用传递操作符,但在 Swift 中,其作用是不一样的。就像对待普通的参数一样,Swift 还是会复制传入的 inout 参数,但当函数返回时,会用这些参数的值覆盖原来的值。也就是说,即使在函数中对一个 inout 参数做多次修改,但对调用者来说只会注意到一次修改的发生,也就是在用新的值覆盖原有值的时候。同理,即使函数完全没有对 inout 参数做任何的修改,调用者也还是会注意到一次修改 (willSet 和 didSet 这两个观察者方法都会被调用)。

生命周期

在生命周期管理方面,结构体和类是非常不同的。相比类,结构体要简单得多,因为它们不会有多个所有者,它们的生命周期,是和含有结构体实例的变量的生命周期绑定的。当变量离开作用域时,其内存将被释放,结构体实例也会被销毁。

与此相反,一个类的实例可以被多个所有者引用,这就需要一种更精细的内存管理模型。Swift 使用自动引用计数 (ARC) 来追踪一个实例的引用计数。当引用计数降至 0 时 (例如所有包含引用的变量都离开了作用域,或被设置成了 nil),Swift 运行时会调用对象的 deinit 方法并释放内存。因此,对那些在最终被释放时需要执行清理工作的共享对象,是可以用类来实现的,像是文件句柄 (必须在某个时间点关闭底层的文件描述符),或是 view controller (可能需要做各种清理工作,例如,注销观察者)。

循环引用

当两个或多个对象互相之间有强引用的时候,就会产生循环引用,它会让这些对象都无法被释放 (除非开发者显式地打破这种循环)。这会造成内存泄漏,并让那些潜在的清理任务无法执行。

因为结构体是值类型,所以在结构体之间是不会产生循环引用的 (因为不存在对结构体的引用)。这即是优势又是限制:一方面我们可以少担心一件事,但同时也意味着无法用结构体实现循环 数据结构(cyclical data structure)。类的情况正好相反:因为一个实例可以有多个所有者,所以可以使用类来实现循环数据结构,但必须要小心,不要产生循环引用了。

产生循环引用的情况可以有多种:从两个对象互相之间强引用,到由许多对象组成的复杂循环,以及在闭包中捕获对象。

即使已经无法通过变量访问到实例,但他们还是互相强引用着对方。这被称为循环引用.

弱引用

为了打破循环引用,我们需要使其中一个引用变为弱引用或 unowned 引用。把一个对象赋值给一个弱引用变量,并不会改变实例的引用计数。在 Swift 里,弱引用变量是归零 (zeroing) 的:一旦所指向的对象被销毁,变量会自动被设置成 nil。这也是为什么弱引用变量必须是可选值的原因。

当使用代理 (delegate) 时,弱引用是非常有用的,并且这在 Cocoa 中很常见。代理对象 (例如,一个 table view) 需要一个指向它的代理的引用,但它不应该拥有代理,否则就可能会产生一个循环引用。因此,指向代理的引用通常都是弱引用,而另一个对象 (例如,一个 view controller) 的职责就是确保代理对象在需要的时候确实存在。

Unowned 引用

但有时候,我们希望一个引用既是弱引用,但同时又不是一个可选值。例如,也许我们知道, view 永远都会拥有一个 window (所以这个属性不应该是可选值),但又不希望 view 强引用 window。

对于 unowned 引用,我们的责任是,确保 “被引用者” 的生命周期比 “引用者” 要长。在这个例 子中,我们必须确保 window 的生命周期比 view 长。如果 window 在 view 之前被销毁,并且 之后再访问这个 unowned 变量的话,程序就会崩溃。

要注意的是,这里的崩溃与未定义行为是不同的。在对象中,Swift 运行时使用另外一个引用计数来追踪 unowned 引用。当对象没有任何强引用的时候,会释放所有资源 (例如,对其他对象的引用)。然而,只要对象还有 unowned 引用存在,其自身所占用的内存就不会被回收。这块内存会被标记为无效,有时也称作僵尸内存 (zombie memory)。被标记为僵尸内存之后,只要我们尝试访问这个 unowned 引用,就会发生一个运行时错误.

但我们也可以通过 unowned(unsafe) 来绕过这个保护机制,当访问一个被标记为 unowned(unsafe) 的无效引用时,行为是未定义的。

闭包和循环引用

在 Swift 中,类不是唯一的引用类型。函数 (也包括闭包) 同样也是引用类型。如果一个闭包捕获了一个引用类型的变量,那么在闭包中会持有一个对这个变量的强引用.

通常的模式是这样的:对象 A 引用对象 B,对象 B 保存了一个闭包,这个闭包又引用对象 A (实 际上,循环引用可能会涉及多个中间对象和闭包)。例如,我们添加一个名为 onRotate 的回调 到上面的 Window 类中,这个回调是一个可选值:

class Window {
    weak var rootView: View? 
    var onRotate: (() -> ())? = nil
}
// 如果我们设置 onRotate 回调,并在闭包中使用 view 的话,我们就产生了一个循环引用
var window: Window? = Window()
var view: View? = View(window: window!) 
window?.onRotate = { print("We now also need to update the view: \(view)") }
复制代码

我们可以从三个地方打破这个循环引用 :

  • 我们可以把 view 对 window 的引用改为弱引用。但不幸的是,因为没有其他强引用存在,window 会在创建之后立即被销毁。
  • 我们可能会希望把 onRotate 属性标记为 weak,但 Swift 并不允许将类型是函数的属性标记为 weak。
  • 我们可以使用一个捕获列表 (capture list),并在捕获列表中弱引用 view,这样就能确保闭包不会强引用 view。在这个例子中,这是唯一正确的做法。

捕获列表可以做的不仅仅只是将变量标记为 weak 或 unowned。比如,如果我们想有一个指向 window 的弱引用变量的话,可以直接在捕获列表中初始化这样一个变量;或者甚至可以在其中定义完全不相关的变量,像以下这样:

window?.onRotate = { [weak view, weak myWindow=window, x=5*5] in 
    print("We now also need to update the view: \(view)")
    print("Because the window \(myWindow) changed")
}
复制代码

这几乎与在闭包语句之前定义变量完全相同,除了定义的地方是在捕获列表中。捕获列表中变
量的作用域就在闭包之内,这些变量在闭包作用域之外是不可用的。

在 unowned 引用和弱引用之间做选择

这个问题取决于对象的生命周 期。如果对象具有独立的生命周期 (也就是说,你不能保证哪一个对象存在的时间会比另一个 长),那么弱引用是唯一安全的选择。

另一方面,如果你可以保证,非强引用的对象与持有这个引用的对象的生命周期是一样的,甚至于更长的话,unowned 引用通常是更方便的。因为它的类型不需要是可选值,并可以被声明为 let,而弱引用则必须是用 var 声明的可选值。生命周期相同的情况是很常见的,特别当两个对象之间是父子关系时。当父对象使用强引用来控制其子对象的生命周期,并且我们可以保证没有其他对象知道子对象存在的话,子对象对父对象的引用就可以是 unowned。

相比弱引用,unowned 引用的开销也小一点,通过它访问属性或调用方法的速度会快一点点。不过,应该只在对效率非常敏感的代码路径中,才把这个优点作为考虑的因素之一。

使用 unowned 引用的缺点也很明显,如果你错误地判断了对象的生命周期,你的程序可能会崩溃。

在结构体和类之间做抉择

当设计一个类型时,我们必须考虑的是,是否会在我们程序的不同部分之间共享这个类型实例的所有权;或者,是否存在只要多个实例表示相同的值时,就可以被交换使用的情况。要共享一个实例的所有权的话,我们必须使用类。否则,我们可以使用结构体。

例如,因为 URL 是一个结构体,所以它的实例就不能被共享。每次我们把一个 URL 实例赋值给一个变量,或传递给一个函数,编译器都会生成一份它的拷贝。然而,这并不是一个问题,因为如果两个URL 实例表示同一个 URL 的话,我们就认为它们是可交换的。这同样适用于像是整数,布尔值,字符串这样的其他结构体:我们不在意两个整数或两个字符串的背后是否共享同一块内存;我们在意的是它们是否表示相同的值。

相反,我们不会认为两个 UIView 实例是可互换的。即使所有属性都是一样的,在视图层次的不同位置,它们还是表示在屏幕上的不同 “对象”。因此,UIView 是用类来实现的,以便我们可以把一个指向特定实例的引用传递给我们程序的多个部分:一个 view 被它的父视图引用,但也被它的子视图作为父视图而引用。另外,我们可以把对 view 的引用保存在其他地方,例如,保存在一个 view controller 里。可以通过所有引用修改同一个 view 实例,并且这些修改会自动反映到其他所有引用中。

话虽这么说,但当我们设计一个不需要共享所有权的类型时,其实不一定非要使用结构体。我们还是可以用类来实现,并提供一个不可变的 API,这样这个类本质上具有值语义。从这个意义上讲,我们可以只使用类,而不必大幅改变我们设计程序的方式。当然,我们会损失一些编译期的检查,并可能承担额外的引用计数的操作所带来的开销。但至少我们可以使它工作。

另一方面,如果我们没有类 (或者说引用类型) 可供使用的话,我们会失去整个共享所有权的概念,并且将不得不从上到下,重新架构我们的程序 (这里假定的是我们之前依赖的是类)。所以虽然我们可以基于一些权衡来用类实现一个结构体,但反过来却并不一定如此。

在我们的武器库中,结构体作为一种工具,它被有意设计得不如类那么强大。作为回报,结构体提供了简洁性:没有引用,没有生命周期,没有子类。这意味着我们不必担心很多问题,仅举几个例子:循环引用,副作用,通过共享引用而产生的竞态条件以及继承规则等问题都将不复存在。

另外,结构体提供了更好的性能,特别是对简单的类型。例如,如果 Int 是一个类的话,元素类型为 Int 的数组会占用更多的内存,因为除了实例本身需要的内存之外,数组中要保存指向实际实例的引用 (指针),以及每个实例需要的额外开销 (例如,保存它的引用计数)。更重要的是,迭代这样一个数据会慢得多,因为对于每个元素,访问它的代码都必须经由额外的间接层,因此可能无法有效地利用 CPU 缓存。特别是如果数组中的 Int 实例被分配在内存中的位置都相距很远的话,情况就更糟糕。

具有值语义的类

在上面,我们已经概述了结构体具有值语义 (即每个变量包含一个独立的值),而类具有引用语义 (即多个变量背后可以都指向同一个类实例) 的特点。虽然这没错,但我们可以实现不可变的类,让其行为上更像一个值类型;而且我们也能实现,至少第一眼看上去,不像值类型的结构体。

当实现一个类时,我们可以着眼于一个点,就是让引用语义不再对其行为产生影响。首先我们把所有的属性都声明为 let, 使它们都变成不可变。其次,为了避免因为子类而重新引入任何可变行为,我们把类标记为 final 来禁止子类化:

final class ScoreClass {
    let home: Int
    let guest: Int
    init(home: Int, guest: Int) {
        self.home = home
        self.guest = guest 
    }
}
let score1 = ScoreClass(home: 0, guest: 0)
let score2 = score1
复制代码

score1 和 score2 这两个变量持有的引用,背后还是都指向同一个 ScoreClass 实例,毕竟这是类的工作方式。但总而言之,因为背后的实例完全是不可变的,所以我们可以当它们包含的是独立的值,来使用它们。

这方面的一个例子是 Foundation 中的 NSArray 类。NSArray 本身没有暴露任何可变操作的 API,所以基本上它的实例可以当作值类型来用。但现实情况有点复杂,因为 NSArray 有一个可变的子类 NSMutableArray,所以除非一个 NSArray 的实例是你亲手创建的,否则我们就无法假定确实是在处理这样一个类型的实例。这就是为什么在上面,要把我们的类声明为 final,并且这也是为什么从一个你无法控制的 API 得到一个 NSArray 后,在做下一步之前,我们建议你先对得到的结果做一个 copy 操作。

具有引用语义的结构体

反过来,结构体包含引用类型属性的话,也会表现出令人惊讶的行为。让我们扩展 ScoreStruct 类型,引入一个计算属性 pretty,这个属性会根据当前的比分,返回一个漂亮的格式化过的字符串:

struct ScoreStruct {
    var home: Int
    var guest: Int
    let scoreFormatter: NumberFormatter
    init(home: Int, guest: Int) {
        self.home = home
        self.guest = guest
        scoreFormatter = NumberFormatter() 
        scoreFormatter.minimumIntegerDigits = 2
    }
    var pretty: String {
        let h = scoreFormatter.string(from: home as NSNumber)!
        let g = scoreFormatter.string(from: guest as NSNumber)!
        return "\(h)\(g)" 
    }
}
let score1 = ScoreStruct(home: 2, guest: 1)
score1.pretty // 02 – 01
复制代码

在初始化方法中,我们创建了一个 NumberFormatter 实例,并把它设置为即使分数小于 10,
也至少显示两位数字。在 pretty 属性中,我们使用它来产生格式化输出。现在我们来生成一份 score1 的拷贝,然后在这份拷贝上设置 scoreFormatter 属性:

let score2 = score1 
score2.scoreFormatter.minimumIntegerDigits = 3
// 虽然我们是在 score2 上做的修改,但 score1.pretty 的输出也发生了改变
score1.pretty // 002 – 001
复制代码

会发生这种情况的原因是,NumberFormatter 是一个类,也就是说,在结构体中的 scoreFormatter 属性,包含的是指向一个 NumberFormatter 实例的引用。当我们把 score1 赋值给 score2 变量时,产生了一份 score1 的拷贝。虽然一个结构体会拷贝它所有属性值, 因为 scoreFormatter 的值只是一个引用,所以 score2 和 score1 中持有的引用,背后都指向同一个 NumberFormatter 实例。

理论上说,ScoreStruct 还是具有值语义:当你把一个实例赋值给另一个变量,或你把它作为函数的参数传递时,都会生成一份完整的拷贝。但是,是否具有值语义这件事,取决于我们对于值类型的认知是什么。如果我们有意要存储一个引用来作为结构体的属性之一,也就是说,我们把引用本身视为值的话,那么上面的结构体就表现的完全符合预期了。但我们可能是希望结构体包含了 NumberFormatter 实例本身,以便每个拷贝都有自己的格式化方式。对于这种情况,上面结构体的行为就不正确了。

为了避免上面例子中那种不符合预期的行为,我们要么可以把类型修改为类 (这样使用这个类型的用户就不会期望它具有值语义了),要么我们可以把这个 NumberFormatter 类型的属性变为一个私有属性,这样它就不能被外部修改了。但后面那种方案并不完美:我们还是可以 (无意中) 在这个类型上暴露其他的公有方法,并在这些方法中修改内部的这个 NumberFormatter 属性。

在结构体中存储引用时要非常小心,因为这样做通常都会导致意外的行为。

写时复制优化

对于值类型,因为赋值或作为函数的参数传递,都会产生一份拷贝,所以会有大量的复制操作。虽然编译器试图更智能地去对待是否要复制这件事,让它可以在能证明即使不复制也是安全的时候来避免产生复制操作,但对于一个值类型的实现者来说,有另外一种优化的方式来解决这个问题,那就是使用一种名为写时复制的技术来实现该类型。这对于那些持有大量数据的类型尤其重要,像是标准库中的集合类型 (Array,Dictionary,Set 和 String),它们都在实现中使用了写时复制。

写时复制的意思是,在结构体中的数据,一开始是在多个变量之间共享的:只有在其中一个变量修改了它的数据时,才会产生对数据的复制操作。因为数组是用写时复制实现的,所以如果我们创建一个数组,并把它赋值给另外一个变量的话,数组的数据实际上并不会被复制:

var x = [1, 2, 3] 
var y = x
复制代码

从内部来看,x 和 y 持有的数组都包含一个指向同一块内存缓冲区的引用。此缓冲区是保存数组中实际元素的地方。当我们修改 x (或者 y) 时,因为数组监测到有多个变量共享一块缓冲区,所以在做修改之前,会先产生一份这个缓冲区的拷贝。这意味着我们可以独立地修改这两个变量,并且那些昂贵的复制元素的操作,都只会发生在它必须发生的时候

x.append(5) 
y.removeLast() 
x // [1, 2, 3, 5]
y // [1, 2]
复制代码

对于我们自己的类型,写时复制的行为不是可以免费获得的:我们必须自己实现它,就像标准库在集合类型上实现它一样。实际上,因为标准库已经提供了处理大量数据时所需的一些最常见的类型,所以只有在极少数的情况下,才需要为自定义的结构体实现写时复制。即使我们定义了一个可以包含大量数据的结构体,在其内部我们也经常使用内建集合来表示这些数据,因此,也就能从这些内建集合的写时复制优化中受益。

写时复制的权衡

在实现写时复制之前,我们要注意使用它时需要做的一些权衡。值类型的一个优点就是它们不会产生引用计数方面的开销。但是,因为实现写时复制的结构体,依赖于保存在内部的一个引用,所以这个结构体每产生一份拷贝都会增加这个内部引用的引用计数。实际上,我们是放弃了值类型不需要引用计数的这个优点,来减轻值类型的复制语义这个特性所可能带来的成本。

增加或减少一个引用计数,都是一个相对较慢的操作 (这里的慢,比较的是把一些字节复制到栈上另一个位置之类的操作)。因为这样一个操作必须是线程安全的,因此就会有锁的开销。由于标准库中所有可变长度的类型 (数组,字典,集合,字符串),内部都依赖于写时复制,所以对于含有这种类型的属性的结构体,每次复制也都会带来操作引用计数的开销。甚至当含有多个这种类型的属性时,这种开销会发生很多次。这里有一个例外情况,对于长度最多只有15个 UTF-8 编码单元 (code unit) 的短字符串 ,Swift 实现了一种优化来避免为其分配一个缓冲区。

SwiftNIO 项目中就有一个实际的例子:在这个项目中,一个 HTTP 请求就是用结构体来实现的,它包含了多个属性,像是 HTTP 方法和头。当这样一个结构体被复制时,不仅要复制它所有的字段,而且所有内部的数组,字典和字符串的引用计数也都会增加。当传递这种类型的值时 (这种操作很常见),这种开销会导致性能的显著下降,而用类实现它的话,性能会好很多,因为相比在传递时要复制所有字段,用类的话,只需要复制引用本身就可以了,并且也只需要更新这一个引用的引用计数。

在这种特殊情况下,如何使用写时复制技术来结合这两种类型的优点: 具有值语义的同时,兼具使用类的性能优势。在 dotSwift 2019 上,SwiftNIO 团队的 Johannes Weiss 对此也有一个很棒的分享。

实现写时复制

我们从一个极其简单的版本开始,用结构体实现 HTTPRequest:

struct HTTPRequest {
    var path: String
    var headers: [String: String]
}
复制代码

为了最小化在上面提到的引用计数的开销,首先,我们会把结构体所有属性都封装到一个私有的 Storage 类中:

struct HTTPRequest { fileprivate class Storage {
    var path: String
    var headers: [String: String]
    init(path: String, headers: [String: String]) {
            self.path = path
            self.headers = headers
    }
}
    private var storage: Storage
    init(path: String, headers: [String: String]) {
        storage = Storage(path: path, headers: headers)
    }
}
复制代码

这样做的话,HTTPRequest 结构体只会包含 storage 这一个属性,并在复制时,只需要增加这一个内部的 Storage 实例的引用计数。现在,为了把私有的 Storage 实例的 path 和 headers 属性暴露给外部,我们在结构体上增加相应的计算属性:

extension HTTPRequest {
    var path: String {
        get { return storage.path }
        set { /* to do */ } }
    var headers: [String: String] {
        get { return storage.headers }
        set { /* to do */ }
    }
}
复制代码

在这个实现中,属性的 setter 是最重要的部分:因为存储在内部的 Storage 实例有可能被多个变量所共享,所以在这些 setter 中,我们不能只是简单的在 Storage 实例上设置新的值。由于,把请求的相关数据都保存在一个类的实例中,这样的实现细节不应该被暴露出去,所以我们必须保证这个基于类的结构体的行为,和原始的纯结构体版本是一致的。这意味着,修改 HTTPRequest 类型变量的一个属性时,受到改变影响的应该只是这个变量而已。

首先,每次属性的 setter 被调用的时候,我们就生成一份内部 Storage 类实例的拷贝。为了生成拷贝,在 Storage 上我们添加一个 copy 方法:

extension HTTPRequest.Storage {
    func copy() -> HTTPRequest.Storage {
        print("Making a copy...") // 调试语句
        return HTTPRequest.Storage(path: path, headers: headers)
    }
}
复制代码

然后,在设置新的值之前,我们产生一份拷贝并赋值给 storage 属性:

extension HTTPRequest {
    var path: String {
        get { return storage.path }
        set {
            storage = storage.copy()
            storage.path = newValue
        }
    }
    var headers: [String: String] {
        get { return storage.headers }
        set {
            storage = storage.copy()
            storage.headers = newValue
        }
    }
}
复制代码

虽然现在 HTTPRequest 结构体的背后完全由一个类实例所支撑,但它仍然展现出了值语义,它的所有属性就好像是直接定义在结构体本身一样:

let req1 = HTTPRequest(path: "/home", headers: [:]) 
var req2 = req1
req2.path = "/users" 
assert(req1.path == "/home") // 通过
复制代码

但当前的实现还不够高效。无论是否有其他变量引用同一个 Storage 实例,只要我们修改属性,就会创建一份内部 Storage 实例的拷贝:

var req = HTTPRequest(path: "/home", headers: [:])
forxin0..<5{
    req.headers["X-RequestId"] = "\(x)"
    
}
/*
 Making a copy...
 Making a copy...
 Making a copy...
 Making a copy...
 Making a copy...
*/
复制代码

每次我们修改 request,就会生成一份 Storage 的拷贝。但所有这些拷贝都是不需要的,因为只有 req 这一个变量持有指向 Storage 实例的引用。

为了实现一个高效的写时复制,我们需要知道,一个对象 (在我们的例子中是 Storage 实例) 是否被唯一引用,也就是说,它是否只有一个所有者。如果是的话,我们可以直接修改对象,否则,我们则在修改之前,创建一份对象的拷贝。

我们可以使用 isKnownUniquelyReferenced 函数来检查一个引用类型的实例是否只有一个所有者。如果你把一个 Swift 的类实例传递给此函数,并且这个实例没有其他强引用的话,这个函数就返回 true,反之,如果有其他强引用存在,此函数返回 false。

利用这一点,现在我们可以继续对 HTTPRequest 做出改进:在更改 storage 之前,先检查其引用是否唯一。为了避免在每个属性的 setter 中都写一遍这个检查的语句,我们将把这部分的逻辑封装到一个 storageForWriting 属性中:

extension HTTPRequest {
    private var storageForWriting: HTTPRequest.Storage {
        mutating get {
            if !isKnownUniquelyReferenced(&storage) {
                self.storage = storage.copy()
            }
            return storage
        }
}
    var path: String {
        get { return storage.path }
        set { storageForWriting.path = newValue }
    }
    var headers: [String: String] {
        get { return storage.headers }
        set { storageForWriting.headers = newValue }
    }
}

// 为了测试上面的代码,让我们再写一遍之前的循环:

var req = HTTPRequest(path: "/home", headers: [:]) 
var copy = req
for x in 0..<5 { 
    req.headers["X-RequestId"] = "\(x)"
} 
// Making a copy...
复制代码

调试的语句只在我们第一次修改 req 的时候被打印了一次。在之后的迭代中,因为引用都是唯 一的,所以没有产生复制。结合编译器的优化,写时复制可以避免大多数不必要的复制值类型 的操作。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享