如何编写高质量的 Swift 代码

Swift 刚刚正式发布了 5.3 版本,增加了很多新特性,比如上一篇的多尾随闭包。从 0.9 到 5.3 横跨数年,Swift 在语法、运行效率、易用性都在不断的提升和优化。对于开发者来说,如何写出高质量的 Swift 代码将提升程序的运行效率

本文基于官方文档 Writing High-Performance Swift Code
[本文难度:中,需要一定 Swift 基础]


预备知识

Swift 的编译流程

Swift 编译流程

相对于Objective-CSwift 语言在编译过程中增加了了 SIL 优化,是专门针对 Swift 的二次优化。从上图可以看出,在 AST 的基础上,进一步生成了 Swift 的高级中间语言 SIL,它与 LLVM IR 一起进行解析和优化,SIL 是其他语言没有的,在这里进行额外的优化。举个例子:

func test1() { print("hahaha") }
func test2() { test1() }
test2()

// 这样的代码,编译器会优化成直接调用 print("hahaha"),从而忽略对中间方法的调用。

优化虽然能提升运行效率,也会减缓编译时间(参考DebugRelease的时间),Debug 模式下默认关闭优化。

修改优化等级方式: BuildSetting - Compilation Mode。

Swift 的派发方式

派发方式

在 Swift 中的派发方式分为:

  1. 静态派发/直接派发:在编译时就确定了,与动态派发相比非常快,编译器知道要执行的函数,不需要像动态派发那样直到运行时才确定调用方法,意味着不会有多余的通信开销。
  2. 动态派发-方发表:引用类型,尤其是类和协议的派发方式,其中类为V-Table(Virtual Table 虚函数表), 协议为PWT(Protocol Witness Table 协议见证表?)
  3. 动态派发-消息:最为灵活,支持运行时更新新的函数实现。

其中静态派发方法最快,可以粗暴的理解为直接取址。协议的派发方式也是使用动态派发-方发表的方式,苹果对其进行了强化以实现 Swift 中各种强大的 Protocol 特性。消息派发可参考 Objective-C

编译过程中编译器会自动识别可优化为静态派发的部分。

Swift 中的写时复制(Copy On Write)

简单的说,在 Swift 中大量使用着值类型 (Value Type),一般情况下使用新的变量去获取值类型对象时就会触发复制的操作,在很多时候,只是持有对象并不会对对象进行修改,这时就会造成不必要的复制开销。而 Swfit 的写时复制意味着只有对象会发生更改时才会触发复制操作


正文

1. final,private\fileprivate,internal

Swift在编译过程中会对“确定”的代码进行优化,是否“确定”与代码的派发方式有关,动态派发的代码为“不确定”,进而不能优化。在开发中对代码使用 privatefinal 等来标识代码,编译器能更好的的进行优化。

动态派发很强,但直接派发很快。

final 修饰的属性、类、方法不会被覆盖,编译器就可以知道能优化为直接调用,而不用去进行动态派发。final 能让编译器在优化时能更好的识别并优化。

privatefileprivate 均表示在其范围外部不可见,进而帮助编译器自动推断出 final 并删掉对方法和属性的间接引用。

internal 是 Swift 的默认标识,不需要显式标记。表示内部的,编译器能自动推断 final

熟练使用 final、private 等标识并不仅仅在于给代码设定权限,更加规范。也帮助了编译器更好的优化我们的代码。

2. 容器中的类型

容器主要是指 ArrayDictionary,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 {
  var val: T
  init(_ v: T) {val = v}
}

struct Box {
    var ref: Ref
    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 进行优化。想要达到一个好的优化水准就需要对这些特性有深入了解。对优化过程中衍生出来的问题也要有一定认知。

你可能感兴趣的:(如何编写高质量的 Swift 代码)