再议Swift中的 struct 和 class

2018-11-18  本文已影响112人  Lin__Chuan

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

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

1. 值类型与引用类型

引用类型

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

值类型

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

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

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

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

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).

写时复制

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

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

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

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

var x = [1,2,3]
var y=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 的结构体一般被存储在栈上, 而非堆上. 不过对于可变结构体, 这其实是一种编译器优化:

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引用

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 运行时将为这个对象维护另外一个引用计数.

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

在 unowned 和 weak 之间进行选择

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 中结构体和类的种种不同.

上一篇下一篇

猜你喜欢

热点阅读