Harris'Blog

谨记,技术债迟早要还,宁可慢一点,也要保证高质量

理解Swift中struct和class在不同情况下性能的差异

如果你被问起Swift中struct和class有什么不同的时候你会怎么回答?
我想大多数人的第一反应应该是struct是值类型class是引用类型他俩在语义上面不同。在想其他可能就是他们之间的相同点了,比方都可以用来定义类型,都可以有property,都可以有method,所以单从语法上理解他们没什么意义,其实单从语义不同这一条就可以延伸出很多,从初始化方式,内存管理,方法派发方式都是截然不同,当然这不是我们本次讨论的重点,本次主要讨论struct和class在不同情况下性能的差异

我们主要从三个性能维度来比较struct和class的性能差异

  • 内存分配
  • 引用计数
  • 方法派发

内存分配

内存分配分为栈内存分配和堆内存分配两种

栈内存的存储结构比较简单,你可以简单的理解为push到栈底pop出来这么简单,而要做的就是通过移动栈针来分配和销毁内存

堆内存相比栈有着更为复杂的存储结构,他的分配方式你可以理解为在堆中寻找合适大小的空闲内存块来分配内存,把内存块重新插入堆来销毁内存,当然这些仅仅只是堆内存相比栈内存性能消耗大的一个方面,更重要的是堆内存支持多线程操作,相应的就要通过同步等方式保证线程的安全性。

下面我们从代码角度来说一下struc和class在内存分配方面的性能差异

1
2
3
4
5
6
7
8
9
struct Point {
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
var point2 = point1
point2.x = 5
// use `point1`
// use `point2`

定义一个struct Point 含有property x,y method draw

首先在代码执行之前编译器会在栈上分配一块4个word大小的内存,分配的过程就是我在上面提到的移动栈针

用(0,0)来初始化point1 并把point1赋值给point2,你可以理解为将value拷贝到分配好的栈内存上,因为struct是值类型所以point1和point2是两个独立的instance

修改point2.x不会影响point1

最后use point1 use point2离开作用域 移动栈针,销毁栈内存

接下来我们看一下class的情况

1
2
3
4
5
6
7
8
9
class Point {
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
let point2 = point1
point2.x = 5
// use `point1`
// use `point2`

只是把struct换成class其他地方不变
首先在代码执行之前编译器会在栈上分配两个word大小的内存,不过这一次不是用来存储Point的property

代码执行初始化point1在堆上寻找合适大小的内存块分配

然后将value拷贝到堆内存存储,并在栈上存储一个指向这块堆内存的指针

可以看到堆上分配的是4个word大小的内存块,剩余的两个蓝色格子存放的是关于class生命周期相关函数表的指针
let point2 = point1执行的是引用语言,只是在point2的堆内存存储了一个指向point1堆内存的指针

修改point2.x也会影响到point1

最后use point1 use point2 离开作用域,堆内存销毁(查找并把内存块重新插入到栈内存中),栈内存销毁(移动栈针)
当然栈内存在分配的过程中还要保证线程安全(这也是一笔很大的开销)

怎么样,是不是觉得上述情况下Point用struc表示比class表示在内存分配方面要快很多

下面看一个在实际应用中的优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Color { case blue, green, gray }
enum Orientation { case left, right }
enum Tail { case none, tail, bubble }
var cache = [String : UIImage]()
func makeBalloon(_ color: Color, orientation: Orientation, tail: Tail) -> UIImage {
let key = "\(color):\(orientation):\(tail)"
if let image = cache[key] {
return image
}
...
}

这段代码的应用场景是我们常见的iMessage聊天界面气泡生产能function,三个enum表示气泡的不同情况,比方蓝色朝左带尾巴,因为用户可能经常滑动聊天列表来查看消息makeBalloon这个方法的调用时非常频繁的,为了避免多次生成UIImage,我们用Dictionary来充当一个cache,用不同情况下的enum序列化一个String来充当cache的key。

思考一下这样写有什么问题么?
问题就出在key上面,用String充当key,首先在类型安全上面无法保证存放的一定是一个气泡的类型,因为是String所以你可以存放阿猫阿狗等等。其次String的character是存储在堆上面的,所以每次调用makeBalloon虽然命中cache,但仍然不停的设计堆内存分配和销毁

怎么解决?

1
2
3
4
5
struct Attributes : Hashable {
var color: Color
var orientation: Orientation
var tail: Tail
}

let key = Attributes(color: color, orientation: orientation,tail: tail)

看到优化代码你应该恍然大悟,原来Swift已经为我们准备好了更好地类型struct并且他可以作为Dictionary的key,这样就不涉及任何堆内存分配销毁,并且Attributes可以确保类型安全

引用计数

Swift是如何知道堆内存是安全的并且可以释放了呢?答案就是引用计数,原理就是在instance内部插入一个refCount的property来管理引用计数,新增引用refCount+1,引用销毁refCount-1,refCount为0时,Swift就知道了存储instance的这块内存是安全的可以释放。

引用计数方面的性能消耗当然不是加一减一那么简单,首先是一对间接调用retain和release,其次引用计数支持多线程,所以同样需要保证线程安全

我们先来看下class的情况,下面是一段伪代码,旨在解释清楚引用计数的原理

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point {
var refCount: Int
var x, y: Double
func draw() { ... }
}
let point1 = Point(x: 0, y: 0)
let point2 = point1
retain(point2)
point2.x = 5
// use `point1`
release(point1)
// use `point2`
release(point2)

内存分配方面不再赘述,let point1 = Point(x: 0, y: 0)这块内存的refCount 为1
retain(point2)refCount变为2 然后use point1 离开作用域release(point1) refCount 减为1release(point2)refCount减为0 然后销毁内存

从上一个内存分配的环节可知struct Point的内存分配均为栈内存分配,它不涉及任何引用计数。

那如果struct中存有引用类型的property呢?

1
2
3
4
5
6
7
8
9
struct Label {
var text: String
var font: UIFont
func draw() { ... }
}
let label1 = Label(text: "Hi", font: font)
let label2 = label1
// use `label1`
// use `label2`

上面的代码中定义了一个struct Lable 含有一个String值类型但是character存储在堆上,UIFont是一个class 存储在堆上
let label1 = Label(text: "Hi", font: font)初始化label1 看一下会发生什么

let label2 = label1lable1赋值给label2

看到了吧,一共有四次引用计数操作,也就是会有六个调用,我们看一下这个过程的伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Label {
var text: String
var font: UIFont
func draw() { ... }
}
let label1 = Label(text: "Hi", font: font)
let label2 = label1
retain(label2.text._storage)
retain(label2.font)
// use label1
release(label1.text._storage)
release(label1.font)
// use label2
release(label2.text._storage)
release(label2.font)

在引用计数这个维度你想到了什么?
结论就是
在引用计数这个维度不包含引用属性的struct性能消耗最小,因为他不涉及引用计数,含有一个引用类型的struct性能消耗等于class,含有两个及以上引用类型property的struct在引用计数方面的性能消耗是class的整数倍

下面看一个在实际应用中的优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Attachment {
let fileURL: URL
let uuid: String
let mineType: String
init?(fileURL: URL, uuid: String, mimeType: String) {
guard mineType.isMineType
else { return nil }
self.fileURL = fileURL
self.uuid = uuid
self.mineType = mimeType
} }

这段代码是我们给message新建的一个model,表示我们聊天时发送的文件,它含有一个URL类型的fileURL表示文件的位置,一个String类型的uuid方便我们在不同设备定位这个文件,因为不可能支持所有的文件格式,所以有一个String类型的mineType用于过滤不支持的文件格式

想一下这么创建model会有什么性能上的问题或者有什么优化的方案么?
来看一下Attachment的存储情况

因为URL为引用类型,String的character存储在堆上,所以Attachment在引用计数的层面消耗有点大,感觉可以优化,那怎么优化?

首先uuid声明为String类型真的好么?答案是不好,一是无法在类型上限制uuid的安全性,二是涉及到引用计数,那怎么优化呢?在16年Swift Fundation中加入了UUID这个类型,是一个值类型不涉及引用计数,也能在类型安全上让我们满意,再来看一下mineType

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension String {
var isMimeType: Bool {
switch self {
case "image/jpeg":
return true
case "image/png":
return true
case "image/gif":
return true
default:
return false
}
}
}

我们是通过给String扩展的方式来过滤我们支持的文件格式,看到这里你肯定想到了Swift为我们提供的更好的表示多种情况的抽象类型enum吧,它同为值类型,不涉及引用计数(enum在个别情况下可以作为引用类型,这里暂时不讨论)

1
2
3
4
5
enum MimeType : String {
case jpeg = "image/jpeg"
case png = "image/png"
case gif = "image/gif"
}

优化后的模型:

1
2
3
4
5
6
7
8
9
10
11
struct Attachment {
let fileURL: URL
let uuid: UUID
let mimeType: MimeType
init?(fileURL: URL, uuid: UUID, mimeType: String) {
guard let mimeType = MimeType(rawValue: mimeType)
else { return nil }
self.fileURL = fileURL
self.uuid = uuid
self.mimeType = mimeType
} }


优化后的model在达到了最佳状态,看完这个例子后不妨瞅瞅自己写过的代码,是否也有这种可以优化的地方

方法派发

静态派发
编译器将函数地址直接编码在汇编中,调用的时候根据地址直接跳转到实现,编译器可以进行内联等优化(struct都是静态派发)

动态派发
运行时查找函数表,找到后再跳转到实现,当然,动态派发仅仅多一个一个查表环节并不是他慢的原因,真正的原因是他阻止了编译器可以进行的内联等优化手段(class派发情况比较特殊,这里我们只讨论动态派发的情况)

看一个内联优化的例子

1
2
3
4
5
6
7
8
9
10
struct Point {
var x, y: Double
func draw() {
// Point.draw implementation
} }
func drawAPoint(_ param: Point) {
param.draw()
}
let point = Point(x: 0, y: 0)
drawAPoint(point)

这段代码要发生这几个调用,

  1. drawAPoint(point)

  2. point.draw()

  3. Point.draw implementation

因为struct中的method是静态派发,也就是说编译器就知道函数的实现地址,那么它可以进行第一步优化

  1. point.draw()

  2. Point.draw implementation

当然还可以进行第二步优化

  1. Point.draw implementation

看到了吧。通过编译器的优化可以省略掉两步调用,直接跳转到函数的实现,理解了静态派发快的原因了吧

如果是把上述情况换为class,那编译器就无法进行内联优化,从方法派发这个维度来说struct的静态派发更胜一筹

资料参考2016WWDC416

如何自己实现值类型的Copy-On-Write

起因

对于Swift中的值类型,为了避免不必要的拷贝行为,使用了copy on write 的优化手段,那么它是怎么实现的呢?

认知Copy-On-Write

定义一个Swift中的Array

1
var numbers1 = [1, 2, 3, 4, 5]

虽然Array是一个struct,但numbers1中的数字并不保存在numbers1对象里,它们会另外存储在系统堆内存中。numbers1对象里,只会保存指向堆内存的一个引用。我们可以用下面的代码确认这个事情:

1
MemoryLayout.size(ofValue: numbers1) // 8

可以看到,这段代码的返回的结果是8,也就是一个64位内存地址占用的空间,事实上,无论数组里存放多少元素,一个Array对象的大小都是一个内存地址的大小。

此时,如果我们复制一个numbers1对象:

1
var numbers2 = numbers1

由于Array实现了Copy-On-Write机制,numbers1和numbers2会指向系统堆中的同一个位置。直到我们修改了其中的一个对象:

1
numbers2[0] = 11

这时,numbers2就会拷贝numbers1的内容,并把数组第一个位置的值设置成11。但是,假设没有numbers2,分配出来的系统堆就只会有numbers1对象的一个引用。这时,先拷贝原数组的值,再进行修改就显得多余了,作为一项可以优化的手段,我们可以选择直接在numbers1的原始内存中修改。

以上谈到的这个机制,就是Swift中的Copy-On-Write。通过之前的描述你应该能感受到,不同的自定义类型很难存在通用的COW实现机制。因此,这不是一个Swift语言的福利,我们需要自己编写额外的代码。

并且,通过观察Array的工作机制不难发现,当一个值类型中包含引用类型的对象时,为了在拷贝对象时的值语义正确,我们必须明确处理被包含的引用对象的拷贝规则。因此,也可以说,COW并不是一个可有可无的优化手段,在某些情况下,还是我们必须思考和处理的问题。

接下来,我们就自己实现一个支持COW的MyArray,以此,加深对COW运行机制的了解。

一个简单粗暴的Copy-On-Write实现

首先,为了让所有的MyArray对象共享存储元素的空间,我们让它包含一个NSMutableArray对象:

1
2
3
4
5
6
7
struct MyArray {
var data: NSMutableArray
init(data: NSMutableArray) {
self.data = data.mutableCopy() as! NSMutableArray
}
}

然后,为了在操作MyArray对象时隐藏data,我们再给它添加一个插入元素的方法:

1
2
3
4
5
extension MyArray {
func append(element: Any) {
data.insert(element, at: self.data.count)
}
}

在创建新的MyArray对象时,为了实现值语义,我们让self.data等于了init参数的一个拷贝。但这样做并没有Copy-On-Write的效果,我们看下面的例子:

1
2
3
4
5
6
let m = MyArray(data: [1, 2, 3])
let n = m
m.append(11)
m.data === n.data // true

在上面的代码里,尽管m和n都是常量,但data是一个引用类型,我们仍旧可以修改它引用的内容,这种修改并不会被认为是修改MyArray对象本身。不信,你回头去看,我们甚至都不需要使用mutating来修饰append(:)方法。

但是,把m拷贝到n之后,尽管我们修改了m,但m和n引用相等的比较结果,仍旧是true。也就是说,m和n中的data仍旧是同一个对象。当然,这也不意外,毕竟我们没有特别处理拷贝MyArray对象时,data引用内容的处理方式。

那么,究竟该如何实现COW的效果呢?

由于Swift并不像C++一样允许我们通过拷贝构造函数来明确定义对象的拷贝行为。我们只能在需要COW的属性上下功夫,把它用一个computed property封装起来。然后,把所有修改属性的操作,都交由这个computed property完成。

例如,我们给MyArray.data添加一个新的属性:

1
2
3
4
5
6
7
8
struct MyArray {
var dataCOW: NSMutableArray {
mutating get {
data = data.mutableCopy() as! NSMutableArray
return data
}
}
}

很简单,每当读取dataCOW的时候,我们就创建一个data的拷贝。但是,由于我们在get里修改了data,就像我们之前提到的,这也是一个修改self的行为,因此,我们也要使用mutating来修饰。

接下来,所有要对data的修改操作,我们可以使用dataCOW来完成。例如:append(:)方法可以修改成这样:

1
2
3
4
5
extension MyArray {
mutating func append(_ element: Any) {
dataCOW.insert(element, at: self.data.count)
}
}

这样,append(:)就会在一个data的拷贝上添加元素了。并且,由于append(:)使用了mutating修饰,我们也无法再修改常量MyArray对象了。此时,编译器会对m.append(11)这行代码报错。这样就在实现了COW效果的同时,完美隐藏了MyArray内部使用了引用类型作为数据存储的事实。我们把m改成变量,这时之前的引用相等比较就会返回false了。

1
2
3
4
5
6
var m = MyArray(data: [1, 2, 3])
let n = m
m.append(11)
m.data === n.data // false here

怎么样,是不是这个实现方式比你想象的要简单的多?的确,这样可以工作,但是却很暴力,它存在一个明显的硬伤,当我们需要多次修改MyArray对象,而只需要最后的结果时,所有中间的拷贝操作就成了浪费。例如,我们通过for循环给MyArray添加内容:

1
2
3
for i in 1...10 {
m.append(i)
}

如果你理解了MyArray的COW机制,就会立刻发现for循环每执行一次,MyArray.data就会被拷贝一次。但中间过程的拷贝明显是没意义的。因此,这个方案有点儿过于简单粗暴了。