再议Swift中的 struct 和 class

之前在这篇文章里我们浅析了一下 struct 和 class, 本文章来源于王巍的 Swift进阶.

在 Swift 中, 要存储结构化数据, 我们可以采用结构体, 枚举, , 使用闭包捕获变量. 在Swift的标准库中, 绝大多数的公开类型是结构体, 枚举和类只占一小部分.
结构体和类的主要不同点:

  • 结构体(和枚举)是值类型, 而类是引用类型, 在设计结构体时, 我们可以要求编译器保证不可变性, 但是对于类来说, 需要程序员自己来做.
  • 内存的管理方式不同, 结构体可以被持有及访问, 但是类的实例只能通过引用来间接访问, 结构体不能被引用, 但是会被复制 (结构体的持有者只有一个, 类的实例可以有多个持有者)
  • 使用类, 我们可以使用继承来共享代码, 而结构体(以及枚举)是不能被继承的. 想要在不同结构体或者枚举之间共享代码, 我们需要使用组合, 泛型, 协议扩展等技术.

1. 值类型与引用类型

引用类型

软件中拥有生命周期的对象非常多, 比如文件句柄,通知中心,网络接口,数据库连接,view controller 都是很好的例子, 对于这些引用类型, 我们可以在初始化和销毁的时候进行特定的操作, 在对他们进行比较的时候, 我们是检查两者的内存地址. 这些类型的实现都是用了对象.

值类型

软件中有许多值类型, URL, 二进制数据, 日期, 错误, 字符串, 数字等, 这些类型都可以使用结构体来实现.

值类型永远不会改变, 它们具有不可变的特性, 在绝大多数情况下, 这是好事.

  1. 使用不变的数据可以让代码容易理解.
  2. 让代码天然具有线程安全的特性 (不能改变的数据在线程之间是可以安全共享的)

在Swift中, 结构体可以用来构建值类型的, 结构体不能通过引用来比较, 你只能通过他们的属性来比较两个结构体.

  • 虽然我们可以使用 var 来在结构体中声明可变的变量属性, 但是这个可变性只体现在变量本身, 而不是里面的值.
  • 改变一个结构体变量的属性, 在概念上来说, 和为整个变量赋值一个全新的结构体等价, 我们总是使用一个新的结构体, 并设置被改变的属性值, 然后用它代替原来的结构体.
struct Size {
    var width: Int
    var height: Int
}
struct Point {
    var x: Int
    var y: Int
}
struct Rectangle {
    var origin: Point
    var size: Size
    
    init(x: Int = 0, y: Int = 0, width: Int, height: Int) {
        origin = Point(x: x, y: y)
        size = Size(width: width, height: height)
    }
}
var screen = Rectangle(width: 100, height: 100) {
    didSet {
        print(screen)
    }
}
screen.origin.x = 10
// 此时会触发 didSet
  1. 对结构体进行改变,在语义上来说,与重新 为它进行赋值是相同的。即使在一个更大的结构体上只有某一个属性被改变了,也等同于整个结构体被用一个新的值进行了替代.
  2. 在一个嵌套的结构体的最深层的某个改变,将会一路向上反映到最外层的实例上,并且一路上触发所有它遇到的 willSet 和 didSet.
  3. 虽然我们将整个结构体替换为了新的结构体,但是一般来说这不会损失性能, 编译器可以原地进行变更.
  4. 由于这个结构体没有其他所有者,实际上我们没有必要进行复制, 如果有多个持有者的话, 重新赋值意味着发生复制, 对于写时复制的结构体, 工作方式又有变化.

值语义与引用语义

结构体只有一个持有者, 比如, 将一个结构体变量传递给一个函数时, 函数将接收到结构体的复制, 它也只能改变他自己的这份赋值, 这叫做值语义(value semantics), 也被叫做复制语义.

对于对象来说, 它是通过传递引用来工作的, 因此类对象会拥有多个持有者, 这叫做引用语义(reference semantics).

写时复制

值总是需要复制这件事听起来可能有点低效, 但是, 编译器可以帮我们进行优化. 以避免不必要的复制操作.

  • 结构体复制的时候发生的是按照字节进行的浅复制.
  • 除非结构体中含有类,否则复制时都不需要考虑其中属性的引用计数.
  • 使用 let 来声明结构体时,编译器可以确定之后这个结构体的任何一个字节都不会被改变.
  • 编译器还可能使用 传递引用 而非 的方式来优化一个常量结构体.

编译器所做的对于 值类型的复制优化值语义类型的写时复制 并不是一回事, 写时复制必须由开发者来实现, 想要实现写时复制, 你需要检测所包含的类是否有共享的引用.

如果一个结构体只由其他结构体组成,那编译器可以确保不可变性。同样地,当使用结构体时,
编译器也可以生成非常快的代码, 对一个只含有结构体的数组进行操作的效率,通
常要比对一个含有对象的数组进行操作的效率高得多, 这是因为结构体通常是直接存储在数组的内存上, 而对象的数组中包含的只有对象的引用, 在很多情况下, 编译器将结构体放在栈上, 而不放在堆上.

在Swift标准库中, 向Array, Dictionary 和 Set 这样的集合类型是通过一种叫作写时复制(copy-on-write)的技术实现的.

var x = [1,2,3]
var y=x
  • 在内部, 这些 Array 结构体含有指向某个内存的引用, 这个内存就是数组中元素所存储的位置, 两个数组的引用指向的是内存中同一个位置, 这两个数组共享了它们的存储部分.
  • 当 x 发生改变时, 这个共享会被检测到,内存将会被复制, 这样一来,我们得以独立地改变两个变量
x.append(5) 
y.removeLast() 
x // [1, 2, 3, 5]
y // [1, 2]

这种行为就是写时复制, 它的工作方式是

  1. 每当数组被改变, 它首先检查它对存储缓冲区的引用是否是唯一的, 或者说, 检查数组本身是不是这块缓冲区的唯一拥有者.
  2. 如果是,那么缓冲区可以进行原地变更, 此时不会有复制进行.
  3. 如果不是, 如在本例中, 缓冲区有一个以上的持有者, 那么数组就需要先进行复制, 然后对复制的值进行变化,而保持其他的持有者不受影响.

当你自己的类型内部含有一个或多个可变引用,同时你想要保持值语义时,你应该为其实现写时复制.
为了维护值语义,通常都需要进行在每次变更时,都进行昂贵的复制操作,但是写时复制技术避免了在非必要的情况下的复制操作.

实现写时复制

1. 创建一个不包含值语义的结构体

struct MyData {
    var _data: NSMutableData 
    init(_ data: NSData) {
        _data = data.mutableCopy() as! NSMutableData 
    }
}
extension MyData {
    func append(_ byte: UInt8) {
        var mutableByte = byte
        _data.append(&mutableByte, length: 1) 
    }
}

let theData = NSData(base64Encoded: "wAEP/w==")!
let x = MyData(theData)
let y = x
x._data === y._data  // true

x.append(0x55)
y // <c0010fff 55>
print(y, x)   // x, y 的 _data 值是一样的.

如果我们复制结构体变量,里面进行的是浅复制. 这意味着对象本身不会被复制, 而只有指向 NSMutableData 对象的引用会被复制.

2. 创建包含值语义的结构体 写时复制(性能较差)

struct MyData {
    fileprivate var _data: NSMutableData
    fileprivate var _dataForWriting: NSMutableData {
        mutating get {
            _data = _data.mutableCopy() as! NSMutableData 
            return _data
        } 
    }
    init() {
        _data = NSMutableData()
    }
    init(_ data: NSData) {
        _data = data.mutableCopy() as! NSMutableData
    }
}
extension MyData {
    mutating func append(_ byte: UInt8) {
        var mutableByte = byte
        _dataForWriting.append(&mutableByte, length: 1) 
    }
}

let theData = NSData(base64Encoded: "wAEP/w==")! var x = MyData(theData)
lety=x
x._data === y._data // true
x.append(0x55)
y // <c0010fff>
x._data === y._data // false

现在, 这个结构体具有值语义了, 如果我们将 x 赋值给变量 y, 两个变量将继续指向底层相同的 NSMutableData 对象. 不过, 当我们对其中某个调用 append 时, 将会进行复制.
但是, 当多次修改同一个变量时, 这种方式非常浪费

var buffer = MyData(NSData())
for byte in 0..<5 as CountableRange<UInt8> {
    buffer.append(byte) 
}

每次调用 append 时, 底层的 _data 对象都要被复制一次, 因为 buffer 没有和其他的 MyData 实例共享存储, 解决办法是对它进行原地变更, 这种方法会高效得多 (同时也是安全的)

3. 创建包含值语义的结构体 写时复制(性能较好)
为了提供高效的写时复制特性, 我们需要知道一个对象 (比如这里的 NSMutableData) 是否是唯一的. 如果它是唯一引用, 那么我们就可以直接原地修改对象. 否则, 我们需要在修改前创建对象的复制.
在 Swift 中, 我们可以使用 isKnownUniquelyReferenced 函数来检查某个引用只有一个持有者, 不过, 对于 OC 类, 它会直接返回fasle, 所以我们需要直接创建一个包装类.

final class Box<A> {
    var unbox: A
    init(_ value: A) { self.unbox = value }
}
var x = Box(NSMutableData())
isKnownUniquelyReferenced(&x) // true

如果有多个引用指向相同的对象, 这个函数将会返回 false.

vary=x isKnownUniquelyReferenced(&x) // false
struct MyData {
    private var _data: Box<NSMutableData>
    var _dataForWriting: NSMutableData {
        mutating get {
            if !isKnownUniquelyReferenced(&_data) {
                _data = Box(_data.unbox.mutableCopy() as! NSMutableData) 
                print("Making a copy")
            }
            return _data.unbox
        } 
    }
    init() {
        _data = Box(NSMutableData())
    }
    init(_ data: NSData) {
        _data = Box(data.mutableCopy() as! NSMutableData)
    }
}
extension MyData {
    mutating func append(_ byte: UInt8) {
        var mutableByte = byte
        _dataForWriting.append(&mutableByte, length: 1) 
    }
}

测试一下代码

var bytes = MyData()
var copy = bytes
for byte in 0..<5 as CountableRange<UInt8> {
    print("Appending 0x\(String(byte, radix: 16))")
    bytes.append(byte) 
}
/*
Appending 0x0 
Making a copy 
Appending 0x1 
Appending 0x2 
Appending 0x3 
Appending 0x4
*/

bytes // <00010203 04> 
copy // <>    两个不共享内存, 所以 copy 数据是空的

运行代码, 你会看到上面加入的调试语句只在第一次调用 append 的时候被打印了一次. 在接下来的循环中, 引用都是唯一的, 所以也就没有进行复制操作.

这项技术让你能够在创建保留值语义的结构体的同时, 保持像对象和指针那样的高效操作, 得益于写时复制和与其关联的编译器优化, 大量的不必要的复制操作都可以被移除掉.

在当创建结构体时,类也还是有其用武之地的.

  1. 定义一个只有单个实例的从不 会被改变的类型.
  2. 封装一个引用类型,而并不想要写时复制.
  3. 需要将接口暴露给 Objective-C

2. 闭包和可变性

var i = 0
func uniqueInteger() -> Int {
    i += 1
    return I
}

let otherFunc = uniqueInteger
let otherFunc2 = otherFunc

otherFunc() // 1
otherFunc2() // 2

每次我们调用该函数时, 共享的变量 i 都会改变. 如果我们传递这些闭包和函数, 它们会以引用的方式存在, 并共享同样的状态.

Swift 的结构体一般被存储在栈上, 而非堆上. 不过对于可变结构体, 这其实是一种编译器优化:

  • 默认情况下结构体是存储在堆上的, 但是在绝大多数时候, 编译器会优化内存, 将结构体存储到栈上.
  • 编译器这么做是因为那些被逃逸闭包捕获的变量需要在栈帧之外依然存在.
  • 当编译器侦测到结构体变量被一个函数闭合的时候, 优化将不再生效, 此时这个结构体将存储在堆上. 这样一来, 就算 uniqueIntegerProvider 退出了作用域, i 也将继续存在.
func uniqueIntegerProvider() -> () -> Int { 
    var i = 0
    return {
        I+=1
        return I 
    }
}

3. 内存

在标准库中大部分的类型是 结构体 或者 枚举, 他们是值类型, 它们只会有一个持有者, 所以它们所需要的内存可以被自动地创建和释放. 当使用值类型时, 不会产生循环引用的问题.

struct Person {
    let name: String
    var parents: [Person]
}
var john = Person(name: "John", parents: []) 
john.parents = [John] 
john//John,parents:[John,parents:[]]

因为值类型的特点,当你把 john 加到数组中的时候,其实它被复制了, john 的值被加入到数组中, 如果 Person 是一个类的话,那么必然会造成循环引用.

对于类,Swift 使用自动引用计数 (ARC) 来进行内存管理, 每次你创建一个对象的新的引用 (比如为类变量赋值), 引用计数会被加一, 一旦引用失效(比如变量离开了作用域), 引用计数将被减一, 如果引用计数为零, 对象将被销毁. 遵循这种行为模式的变量也被叫做 强引用

循环引用

class View {
    var window: Window 
    init(window: Window) {
        self.window = window 
    }
}
class Window {
    var rootView: View?
}
var window: Window? = Window() // window: 1
var view: View? = View(window: window!) // window: 2, view: 1 window?.rootView = view // window: 2, view: 2
view = nil // window: 2, view: 1
window = nil // window: 1, view: 1
  1. 我们创建了 window 对象, window 的引用计数将为 1.
  2. 之后创建 view 对象时, 它持有了 window 对象的强引用,所以这时候 window 的引用计数为 2, view 的计数为 1.
  3. 接下来, 将 view 设置为 window 的 rootView 将会使 view 的引用计数加一. 此时 view 和 window 的引 用计数都是 2.
  4. 当把两个变量都设置为 nil 后,它们的引用计数都会是 1.

即使它们已经不能通过变量进行访问了, 但是它们却互相有着对彼此的强引用. 这就被叫做引用循环 . 因为存在引用循环, 这样的两个对象在程序
的生命周期中将永远无法被释放.

要打破循环, 需要确保其中一个引用要么是 weak, 要么是 unowne.
weak引用

  • 当你将一个变量标记为 weak 时, 将某个值赋值给这个变量时, 它的引用计数不会被改变.
  • Swift 中的弱引用 是趋零的: 当一个弱引用变量所引用的对象被释放时, 这个变量将被自动设为 nil。这也是弱引用必须被声明为可选值的原因.
class View {
    var window: Window 
    init(window: Window) {
        self.window = window 
    }
    deinit {
        print("Deinit View") 
    }
}
class Window {
    weak var rootView: View?
    deinit {
        print("Deinit Window") 
    }
}

var window: Window? = Window() 
var view: View? = View(window: window!) 
window?.rootView = view 
view = nil 
window = nil 
/*
Deinit Window 
Deinit View
*/

unowned引用
因为 weak 引用的变量可以变为 nil, 所以它们必须是可选值类型. 但是, 如果我们知道我们的 view 将一定有一个 window, 这样这个属性就不应该是可选值,而同时我们又不想一个 view 强引用 window. 这种情况下, 我们可以使用 unowned 关键字.

class View {
    unowned var window: Window 
    init(window: Window) {
        self.window = window 
    }
    deinit {
        print("Deinit View") 
    }
}
class Window {
    var rootView: View?
    deinit {
        print("Deinit Window") 
    }
}

var window: Window? = Window() 
var view: View? = View(window: window!) 
window?.rootView = view 
view = nil 
window = nil 
/*
Deinit Window 
Deinit View
*/

这样写依然没有引用循环, 但是我们要负责保证 window 的生命周期比 view ⻓. 如果 window 先被销毁, 然后我们访问了 view 上这个 unowned 的变量的话, 就会造成运行崩溃.

对每个 unowned 的引用, Swift 运行时将为这个对象维护另外一个引用计数.

  • 当所有的 strong 引用消失时, 对象将把它的资源 (比如对其他对象的引用) 释放掉.
  • 不过, 这个对象本身的内存将继续存在, 直到所有的 unowned 引用也都消失.
  • 这部分内存将被标记为无效 (有时候我们也 把它叫做僵尸 (zombie) 内存), 当我们试图访问这样的 unowned 引用时, 就会发生运行时错误.

除了 weak, unowned, 我们还可以选择 unowned(unsafe), 它不会做运行时的检查. 当我们访问一个已经无效的 unowned(unsafe) 引用时, 这时候结果将是未定义的.

在 unowned 和 weak 之间进行选择

  • 如果这些对象的生命周期互不相关, 也就是说, 你不能保证哪一个对象存在的时间会比另一个⻓, 那么 weak 就是唯一的选择.
  • 如果你可以保证非强引用对象拥有和强引用对象同样或者更⻓的生命周期的话, unowned 引用通常会更方便一些. 因为我们可以不需要处理可选值, 而且变量将可以被
    let 声明, 而与之相对, 弱引用必须被声明为可选的 var.

4. 闭包和内存

在 Swift 中, 除了类以外, 函数 (包括闭包) 也是引用类型. 闭包可以捕获变量, 如果这些变量自身是引用类型的话, 闭包将持有对它们的强引用.

闭包捕获它们的变量的一个问题是它可能会 (意外地) 引入引用循环.
常⻅的模式是这样的: 对 象 A 引用了对象 B, 但是对象 B 存储了一个包含对象 A 的回调.

class View {
    var window: Window 
    init(window: Window) {
        self.window = window 
    }
    deinit {
        print("Deinit View") 
    }
}
class Window {
    weak var rootView: View?
    var onRotate: (() -> ())?
    deinit {
        print("Deinit Window") 
    }
}
var window: Window? = Window() 
var view: View? = View(window: window!) 
window?.rootView = view 

view 强引用了 window, 但是 window 只是弱引用 view, 一切安好.

window?.onRotate = {
    print("We now also need to update the view: \(view)")
}

但是, 回调引用 view, 那么循环产生.


打破循环的方式

  1. 让指向 window 的引用变为 weak, 不过, 这会导致 window 消失, 因为没有其他指向它的强引用了.
  2. 让 window 的 onRotate 闭包声明为 weak, 不过 Swift 不允许将闭包标记为 weak.
  3. 通过使用捕获列表(capturelist)来让闭包不去引用视图. 正解.

捕获列表

window?.onRotate = { [weak view] in
    print("We now also need to update the view: \(view)")
}

捕获列表也可以用来初始化新的变量, 甚至可以定义完全不相关的变量. 不过这些变量的作用域只在闭包内部, 在闭包外是不能使用的.

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

总结:

我们研究了 Swift 中结构体和类的种种不同.

  • 对于需要同一性保证的实体, 类会是更好的选择. 而对于值类型, 结构体会更好.
  • 当我们想要在结构体中包含对象时, 我们往往需要像是写时复制这样的额外步骤, 来确保这个值保持值语义.
  • 我们还讨论了在处理类时, 要如何避免引用循环的问题.
  • 通常来说, 一个问题既可以用结构体解决, 也可以用类解决. 具体使用哪个, 要根据你的需求来决定. 不过, 就算是那些一般来说会使用引用来解决的问题, 也可能可以从使用值类型中受益.
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,014评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,796评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,484评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,830评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,946评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,114评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,182评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,927评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,369评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,678评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,832评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,533评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,166评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,885评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,128评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,659评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,738评论 2 351

推荐阅读更多精彩内容