Swift 刚刚正式发布了 5.3 版本,增加了很多新特性,比如上一篇的多尾随闭包。从 0.9 到 5.3 横跨数年,Swift 在语法、运行效率、易用性都在不断的提升和优化。对于开发者来说,如何写出高质量的 Swift 代码将提升程序的运行效率
。
本文基于官方文档 Writing High-Performance Swift Code
[本文难度:中,需要一定 Swift 基础]
预备知识
Swift 的编译流程
相对于Objective-C
,Swift
语言在编译过程中增加了了 SIL
优化,是专门针对 Swift
的二次优化。从上图可以看出,在 AST
的基础上,进一步生成了 Swift
的高级中间语言 SIL
,它与 LLVM IR
一起进行解析和优化,SIL
是其他语言没有的,在这里进行额外的优化。举个例子:
func test1() { print("hahaha") }
func test2() { test1() }
test2()
// 这样的代码,编译器会优化成直接调用 print("hahaha"),从而忽略对中间方法的调用。
优化虽然能提升运行效率,也会减缓编译时间(参考Debug
与Release
的时间),Debug 模式下默认关闭优化。
修改优化等级方式: BuildSetting - Compilation Mode。
Swift 的派发方式
在 Swift 中的派发方式分为:
-
静态派发/直接派发
:在编译时就确定了,与动态派发相比非常快,编译器知道要执行的函数,不需要像动态派发那样直到运行时才确定调用方法,意味着不会有多余的通信开销。 -
动态派发-方发表
:引用类型,尤其是类和协议的派发方式,其中类为V-Table(Virtual Table 虚函数表), 协议为PWT(Protocol Witness Table 协议见证表?) -
动态派发-消息
:最为灵活,支持运行时更新新的函数实现。
其中静态派发方法最快
,可以粗暴的理解为直接取址。协议的派发方式也是使用动态派发-方发表
的方式,苹果对其进行了强化以实现 Swift
中各种强大的 Protocol
特性。消息派发可参考 Objective-C
。
编译过程中编译器会自动识别可优化为静态派发的部分。
Swift 中的写时复制(Copy On Write)
简单的说,在 Swift 中大量使用着值类型 (Value Type)
,一般情况下使用新的变量去获取值类型对象时就会触发复制
的操作,在很多时候,只是持有对象并不会对对象进行修改,这时就会造成不必要的复制开销。而 Swfit 的写时复制
意味着只有对象会发生更改时才会触发复制操作
。
正文
1. final,private\fileprivate,internal
Swift
在编译过程中会对“确定”
的代码进行优化,是否“确定”
与代码的派发方式有关,动态派发的代码为“不确定”,进而不能优化。在开发中对代码使用 private
,final
等来标识代码,编译器能更好的的进行优化。
动态派发很强,但直接派发很快。
被 final
修饰的属性、类、方法不会被覆盖
,编译器就可以知道能优化为直接调用,而不用去进行动态派发。final
能让编译器在优化时能更好的识别并优化。
private
、fileprivate
均表示在其范围外部不可见,进而帮助编译器自动推断出 final
并删掉对方法和属性的间接引用。
internal
是 Swift 的默认标识,不需要显式标记。表示内部的,编译器能自动推断 final
。
熟练使用 final、private 等标识并不仅仅在于给代码设定权限,更加规范。也帮助了编译器更好的优化我们的代码。
2. 容器中的类型
容器主要是指 Array
与 Dictionary
,Swift 中两者均可以存入值类型
和引用类型
,值类型的效率比引用类型高,也是 Swift 推荐的方式。某些情况下,容器在使用值类型可能会造成不必要的开销,对此主要有以下几点优化:
1)与引用类型不同的是,值类型在容器中时,只有在递归过程中才会进行引用计数
,避免了额外的保留,进而优化了容器的使用效率。
2)开发中可以将 Array 看做是 OC 中 NSArray 与 NSMutableArray 当不需要与 NSArray 有桥接关系时 使用 ContiguousArray
来当做引用类型
的容器。ContiguousArray 与 Array 的不同点在于其强制在内存上连续
,效率比 Array 更高。
3)容器在 写时复制
的特性下,可能造成不必要的副本,在方法中需要修改的参数使用 inout
来避免这种情况。
3. 溢出检查
Swift 会对数值运算进行溢出检查,当确定计算不会造成溢出时,溢出检查就显得多余了,在需要进行大量运算的地方溢出检查就会对运算效率造成影响。此时,我们可以使用 Wrapping operations
( &+、&-、&* )来避免溢出检查。
let value = 1 &+ 1
4. 泛型
编译器会查看泛型的每一次调用,并将其转换为专门的调用(参考类型推断)。仅当泛型的的声明在当前模块中可见时,优化器才能执行特化。仅当声明与泛型调用位于同一个文件中时,才会发生这种情况,除非使用了-whole-module-optimization
标志。注意标准库是一种特殊情况,标准库中的定义在所有模块中均可见,并且可以进行专门化。
5. 大值类型
值类型复制时会创建一个副本,大值类型可能很耗时,降低效率。例如,值类型的树结构。
对这样的树类型采用“写时复制”
时,比如使用 Array
将其包装。但这又引入了 Array 的所有方法,以及 Array 本身与 OC 的交互,索引的访问,都降低了效率。对此,自定义结构是个不错的建议:
final class Ref<T> {
var val: T
init(_ v: T) {val = v}
}
struct Box<T> {
var ref: Ref<T>
init(_ x: T) { ref = Ref(x) }
var value: T {
get { return ref.val }
set {
if !isKnownUniquelyReferenced(&ref) {
ref = Ref(newValue)
return
}
ref.val = newValue
}
}
}
自定义的 Box 简化了 Array 非必要部分,针对树结构重新设计容器。
6. 明确类协议
将仅由类满足的协议标记为类协议,编译器可以基于仅类满足该协议的特点来优化程序。
例如,如果 ARC 内存管理系统知道它正在处理类,则可以轻松保留(增加对象的引用计数)。在没有这种特点的情况下,编译器必须假定对象可以满足协议,并且需要保留或释放不确定的对象,这可能会很昂贵。
protocol Pingable: AnyObject { func ping() -> Int }
7. let/var 逃逸闭包
任何时候使用 let/var
来创建闭包绑定时,都会产生逃逸闭包,而当逃逸闭包被 var 捕获时,就会分配到堆区。当被 let 捕获时,是当做值捕获的,不必再存储副本。
如果闭包并没有逃逸,方法传递时就使用inout
将其进行转义,这样就不会再被堆区那套保留/释放所影响。
总结
通过借助 编译过程优化
、值类型
、派发方式
、类型推断
以及 特定函数
等语言特性来对 Swift 进行优化。想要达到一个好的优化水准就需要对这些特性有深入了解。对优化过程中衍生出来的问题也要有一定认知。