编写高性能的Swift代码(译)

jopen 4年前

这篇文章搜集了一些编写高性能的swift代码的一些方法和技巧。本文面向的读者是编译器和标准库的开发者。
本文提到的一些技巧可以提高Swift程序的质量,可以减少错误的发生并提高可读性。明确的标注final类以及面向协议(protocols)编程是两个很明显的例子。然而,本文中还有一些技巧是不正规的,扭曲的,只是为了解决编译器或者语言的临时限制而提出的。本文中的许多建议都是在权衡了程序运行时、二进制应用大小,代码可读性而提出的。

打开优化选项

首先需要打开优化选项,Swift提供了三种优化级别:

  • -O none: 这是开发中最通用的优化选项,它提供了最少的优化并保留了所有的调试信息(debug info)
  • -O: 这是大部分发布版本时的优化选项,编译器会进行激进的优化,他会剧烈的更改类型以及发布的代码,调试信息也会发布但是会删减
  • -O unchecked: 这是为特殊的类库或者应用准备的,当人们基于安全性的考虑不想保留调试信息时。编译器会删除所有溢出检查以及隐式的类型转换检查。通常情况下不要食用我这种优化选项,因为这样无法检查内存安全问题以及整型一出问题。只有在你已经确定你的代码在整型溢出以及类型转化方面没有问题才使用这种优化选项。
    使用方法如下:
    xcrun swiftc -Onone Assembly.swift -o none

优化整个模块

默认情况下Swift独立的编译每个文件,这可以让XCode并行的编译多个文件,以达到提高编译速度的效果。然而,独立的编译每个文件使得编译器无法进行某些优化。Swift也可编译整个项目,就好像它只是一个文件一样。SWi ft还可以优化整个程序就好像它是一个编译单元一样。这种模式可以使用命令行标记(command linw flag) -whole-module-optimization。 在这种模式下编译的程序需要花费更长的编译时间,但是性能更高。
这种模式可以使用Xcode 编译选项 Whole Module Optimization 打开

减少动态分发

和OC相似,Swift默认是一个十分动态的语言。但和OC不同的是,它可以让程序猿移除或者减少这种动态性以提高性能。这一部分给出了几个例子演示语言的这个特性。

动态分发

类默认情况下采用动态分发的方式调用方法以及访问属性,因此在下面的代码片段中,a.aProperty, a.doSomething() 以及 a.doSomethingElse()采用动态分发的形式进行调用。

class A {    var aProperty: [Int]    func doSomething() { ... }    dynamic doSomethingElse() { ... }  }    class B : A {    override var aProperty {      get { ... }      set { ... }    }      override func doSomething() { ... }  }    func usingAnA(a: A) {    a.doSomething()    a.aProperty = ...  }

在Swift中,动态分发是通过查找虚拟方法表[1]进行迂回的调用。如果编程时在声明前面附加了dynamic关键字,Swift将会通过OC发送消息的方式完成方法调用。这两种方式都比直接调用方法速度慢,因为他阻止了很多的编译器优化[2]并且对比直接调用增加了很多额外的工作。在性能要求很严格的代码中,程序猿往往希望能够严格控制这种动态性为。

建议:当声明不需要重载时请使用final关键字

final关键字是那些不需要重载的类、方法、属性的声明的修饰符。这意味着编译器可以直接进行方法调用,而不是采用迂回的方式。在下面的代码片段中,C.array1和D.array1会采用直接调用[3]的方式,而D.array2采用了查找虚拟方法表的方式

final class C {    // No declarations in class 'C' can be overridden.    var array1: [Int]    func doSomething() { ... }  }    class D {    final var array1 [Int] // 'array1' cannot be overridden by a computed property.    var array2: [Int]      // 'array2' *can* be overridden by a computed property.  }    func usingC(c: C) {     c.array1[i] = ... // Can directly access C.array without going through dynamic dispatch.     c.doSomething() = ... // Can directly call C.doSomething without going through virtual dispatch.  }    func usingD(d: D) {     d.array1[i] = ... // Can directly access D.array1 without going through dynamic dispatch.     d.array2[i] = ... // Will access D.array2 through dynamic dispatch.  }

建议:当声明不需要在文件外部访问时使用private修饰符

使用private关键字修饰的声明只在声明它的那个文件中可见。这可以让编译器能够确保不会有潜在的重载。因此虽然没有在声明前面添加final修饰符,但是编译器可以推断出final(编译器认为和final一样),所以编译器会自动去除不直接的方法调用而采用直接调用字段的方式。例如,在下面的代码片段中,e.doSomething()和f.myPrivateVar会采用直接调用的方式,因为编译器假定在相同文件中不会重载这个声明

private class E {    func doSomething() { ... }  }    class F {    private var myPrivateVar : Int  }    func usingE(e: E) {    e.doSomething() // There is no sub class in the file that declares this class.                    // The compiler can remove virtual calls to doSomething()                    // and directly call A’s doSomething method.  }    func usingF(f: F) -> Int {    return f.myPrivateVar  }

高效的使用容器类型

Swift语言一个重要的特性是在标准库中提供了通用的容器类,如数组和词典(Array和Dictionary)。这部分怎样高效的使用这些类型

建议:在数组中使用值类型

swift语言的类型可以分为两大类:值类型(结构体,枚举和元组)和引用类型(类)。这两者之间的一个关键的区别是值类型不能存储在NSArray中(NSArray只能添加引用类型的数据)。这样在使用值类型时,优化器可以避免大部分数组可能要处理的像数组备份之类的繁重的工作。
另外,和引用类型相比,值类型只有在它包含引用类型的数据时才进行引用计数,使用不包含引用类型的值类型可以在数组中避免额外的持有、释放操作。

// Don't use a class here.  struct PhonebookEntry {    var name : String    var number : [Int]  }    var a : [PhonebookEntry]

注意这里有一个在大的值类型以及引用类型之间的权衡问题。在某种情况下,拷贝和移动值类型所要话费的开销要比对引用类型进行引用计数的开销大。

建议:不需要桥接NSArray时,使用保存引用类型的ContiguousArray

如果你需要一个存储引用类型的数组,并且这个数据不需要桥接至NSArray,请使用ContiguousArray

class C { ... }  var a: ContiguousArray<C> = [C(...), C(...), ..., C(...)]

使用适当的改变而不是对象分配

Swift中标准库中所有的容器类型都是值类型,在复制时采用COW(copy-on-write 写时复制)[4]而不是即时复制。在很多情况下,这可以让编译器通过持有容器而不是深度拷贝,从而省掉不必要的拷贝。如果容器的引用计数大于 1 并容器时被改变时,就会拷贝底层容器。例如:在下面这种情况:当 d 被分配给 c 时不拷贝,但是当 d 经历了结构性的改变追加 2,那么 d 将会被拷贝,然后 2 被追加到 b:

var c: [Int] = [ ... ]  var d = c        // No copy will occur here.  d.append(2)      // A copy *does* occur here.

有时用户不小心COW会引入意外的复制操作。例如,在函数中,试图通过对象分配执行修改。在 Swift 中,所有的参数传递时都会被拷贝一份,例如,参数在调用点之前持有一份,然后在调用的函数结束时释放。也就是说,像下面这样的函数:

func append_one(a: [Int]) -> [Int] {    a.append(1)    return a  }    var a = [1, 2, 3]  a = append_one(a)

尽管a的版本不会因为添加1到数组而改变,执行完append_one之后也不再使用,但是由于赋值操作,a仍然可能被复制[5]。可以使用inout参数避免这种问题:

func append_one_in_place(inout a: [Int]) {    a.append(1)  }    var a = [1, 2, 3]  append_one_in_place(&a)

未检查的操作

Swift通过在进行算术运算的时候对溢出进行检查排除了整数溢出错误。这种检查对于那些不可能引起内存安全问题并要求高效的代码时没有必要的。

建议:如果你能保证不会发生溢出,使用未检查的整数算术运算

如果你能保证内存安全,在对性能要求很高的代码中省略溢出检查

a : [Int]  b : [Int]  c : [Int]    // Precondition: for all a[i], b[i]: a[i] + b[i] does not overflow!  for i in 0 ... n {    c[i] = a[i] &+ b[i]  }

泛型

Swift通过泛型(generic type)提供了一种非常强大的抽象机制。Swift编译器对任意的类型T发射可以执行MySwiftFunc<T>的具体的代码块,生成的代码使用函数指针表以及一个包含T的盒子作为额外的参数。MySwiftFunc<Int> 和 MySwiftFunc<String>之间的不同主要由传递过去的不同的函数指针表以及由盒子提供的抽象大小进行描述。下面是泛型的例子:

class MySwiftFunc<T> { ... }    MySwiftFunc<Int> X    // Will emit code that works with Int...  MySwiftFunc<String> Y // ... as well as String.

当优化器启用时,Swift 编译器寻找这段代码的调用,并试着确认在调用中具体使用的类型(例如:非泛型类型)。如果泛型函数的定义对优化器来说是可见的,并知道具体类型,Swift 编译器将生成一个有特殊类型的特殊泛型函数。那么调用这个特殊函数的这个过程就可以避免关联泛型的消耗。一些泛型的例子:

class MyStack<T> {    func push(element: T) { ... }    func pop() -> T { ... }  }    func myAlgorithm(a: [T], length: Int) { ... }    // The compiler can specialize code of MyStack[Int]  var stackOfInts: MyStack[Int]  // Use stack of ints.  for i in ... {    stack.push(...)    stack.pop(...)  }    var arrayOfInts: [Int]  // The compiler can emit a specialized version of 'myAlgorithm' targeted for  // [Int]' types.  myAlgorithm(arrayOfInts, arrayOfInts.length)

建议:将泛型的声明放在使用它的文件中

只有在泛型声明在当前模块可见的情况下优化器才能执行特殊化。这只有在使用泛型的代码和声明泛型的代码在同一个文件中才能发生。注意标准库是一个例外。在标准库中声明的泛型对所有模块可见并可以进行特殊化。

建议:允许编译器进行特殊化

只有当调用位置和被调函数位于同一个编译单元的时候编译器才能对泛型代码进行特殊化。我们可以使用一个技巧让编译器对被调函数进行优化,这个技巧就是在被调函数所在的编译单元中执行类型检查。执行类型检查的代码会重新分发这个调用到泛型函数---可是这一次它携带了类型信息。在下面的代码中,我们在函数play_a_game中插入了类型检查,使得代码的速度提高了几百倍。

//Framework.swift:    protocol Pingable { func ping() -> Self }  protocol Playable { func play() }    extension Int : Pingable {    func ping() -> Int { return self + 1 }  }    class Game<T : Pingable> : Playable {    var t : T      init (_ v : T) {t = v}      func play() {      for _ in 0...100_000_000 { t = t.ping() }    }  }    func play_a_game(game : Playable ) {    // This check allows the optimizer to specialize the    // generic call 'play'    if let z = game as? Game<Int> {      z.play()    } else {      game.play()    }  }    /// -------------- >8    // Application.swift:    play_a_game(Game(10))

大的值类型的开销

在 swift 语言中,值类型保存它们数据独有的一份拷贝。使用值类型有很多优点,比如值类型具有独立的状态。当我们拷贝值类型时(相当于复制,初始化参数传递等操作),程序会创建值类型的一个拷贝。对于大的值类型,这种拷贝时很耗费时间的,可能会影响到程序的性能。
让我们看一下下面这段代码。这段代码使用值类型的节点定义了一个树,树的节点包含了协议类型的其他节点,计算机图形场景经常由可以使用值类型表示的实体以及形态变化,因此这个例子很有实践意义

protocol P {}  struct Node : P {    var left, right : P?  }    struct Tree {    var node : P?    init() { ... }  }

当树进行拷贝时(参数传递,初始化或者赋值)整个树都需要被复制.这是一项花销很大的操作,需要很多的 malloc/free 调用以及以及大量的引用计数操作
然而,我们并不关系值是否被拷贝,只要在这些值还在内存中存在就可以。

对大的值类型使用COW(copy-on-write,写时复制和数组有点类似)

减少复制大的值类型数据开销的办法时采用写时复制行为(当对象改变时才进行实际的复制工作)。最简单的实现写时复制的方案时使用已经存在的写时复制的数据结构,比如数组。swift的数据是值类型,但是当数组作为参数被传递时并不每次都进行复制,因为它具有写时复制的特性。
在我们的Tree的例子中我们通过将tree的内容包装成一个数组来减少复制的代价。这个简单的改变对我们tree数据结构的性能影响时巨大的,作为参数传递数组的代价从 O(n)变为O(1)

struct tree : P {    var node : [P?]    init() {      node = [ thing ]    }  }

但是使用数组实现COW机制有两个明显的不足,第一个问题是数组暴露的诸如append以及count之类的方法在值包装的上下文中没有任何作用,这些方法使得引用类型的封装变得棘手。也许我们可以通过创建一个封装的结构体并隐藏这些不用的API来解决这个问题,但是却无法解决第二个问题。第二个问题就是数组内部存在保证程序安全性的代码以及和OC交互的代码。Swift要检查给出的下表是否搂在数组的边界内,当保存值的时候需要检查是否需要扩充存储空间。这些运行时检查会降低速度。
一个替代的方案是实现一个专门的使用COW机制的数据结构代替采用数组作为值的封装。构建这样一个数据结构的示例如下所示:

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 (!isUniquelyReferencedNonObjC(&ref)) {              ref = Ref(newValue)              return            }            ref.val = newValue          }      }  }

类型Box可以代替上个例子中的数组

不安全的代码

swift语言的类都是采用引用计数进行内存管理的。swift编译器会在每次对象被访问的时候插入增加引用计数的代码。例如,考虑一个遍历使用类实现的一个链表的例子。遍历链表是通过移动引用到链表的下一个节点来完成的:elem = elem.next,每次移动这个引用,swift都要增加next对象的引用计数并减少previous对象的引用计数,这种引用计数代价昂贵但是只要使用swift类就无法避免

final class Node {   var next: Node?   var data: Int   ...  }

建议:使用未托管的引用避免引用计数的负荷

在效率至上的代码中你可以选择使用未托管的引用。Unmanaged<T>结构体允许开发者对特别的引用关闭引用计数

var Ref : Unmanaged<Node> = Unmanaged.passUnretained(Head)    while let Next = Ref.takeUnretainedValue().next {    ...    Ref = Unmanaged.passUnretained(Next)  }

协议

建议:将只有类实现的协议标记为类协议

Swift可以指定协议只能由类实现。标记协议只能由类实现的一个好处是编译器可以基于这一点对程序进行优化。例如,ARC内存管理系统能够容易的持有(增加该对象的引用计数)如果它知道它正在处理一个类对象。如果编译器不知道这一点,它就必须假设结构体也可以实现协议,那么它就必须准备好持有或者释放不同的数据结构,而这代价将会十分昂贵
如果限制只能由类实现某协议那么就标记该协议为类协议以获得更好的性能

protocol Pingable : class { func ping() -> Int }

脚注

【1】 虚拟方法表或者vtable是被一个实例引用的一种包含类型方法地址的类型约束表。进行动态分发时首先从对象中查找这张表然后查找表中的方法
【2】 这是因为编译器并不知道那个具体的方法要被调用
【3】例如,直接加载一个类的字段或者直接调用一个方法
【4】解释COW是什么
【5】在特定情况下优化器能够通过内联和ARC优化技术移除retain,release因为没有引起复制

来自: http://www.jianshu.com/p/8a11133a7268