編寫高效能的 Swift 程式碼

oschina發表於2015-12-16

下面這篇文件收集了一系列編寫高效能 Swift 程式碼的要訣和技巧。文件的目標讀者是編譯器和標準庫開發人員。

文件中的一些技巧可以幫助提升您的 Swift 程式質量,使您的程式碼不容易出錯且可讀性更好。顯式地標記最終類和類協議是兩個顯而易見的例子。 然而文件中還有一些技巧是不符合規矩的,扭曲的,僅僅解決一些比編譯器或語言的特殊的臨時性需求。文件中的很多建議來自於多方面的權衡,例如:執行時、位元組大小、程式碼可讀性等等。

啟用優化

第一個應該做的事情就是啟用優化。Swift 提供了三種不同的優化級別:

  • -Onone: 這意味著正常的開發。它執行最小優化和儲存所有除錯資訊。
  • -O: 這意味著對於大多數生產程式碼。編譯器執行積極地優化,可以大大改變提交程式碼的型別和數量。除錯資訊將被省略但還是會有損害的。
  • -Ounchecked: 這是一個特殊的優化模式,它意味著特定的庫或應用程式,這是以安全性來交換的。編譯器將刪除所有溢位檢查以及一些隱式型別檢查。這不是在通常情況下使用的,因為它可能會導致記憶體安全問題和整數溢位。如果你仔細審查你的程式碼,那麼對整數溢位和型別轉換來說是安全的。

在 Xcode UI 中,可以修改的當前優化級別如下:

整個元件優化

預設情況下 Swift 單獨編譯每個檔案。這使得 Xcode 可以非常快速的並行編譯多個檔案。然而,分開編譯每個檔案可以預防某些編譯器優化。Swift 也可以猶如它是一個檔案一樣編譯整個程式,猶如就好像它是一個單一的編譯單元一樣優化這個程式。這個模式可以使用命令列 flag-whole-module-optimization 來啟用。在這種模式下編譯的程式將最最有可能需要更長時間來編譯,單可以執行得更快。

這個模式可以通過 XCode 構建設定中的“Whole Module Optimization”來啟用。

降低動態排程

Swift 在預設情況下是一個類似 Objective-C 的非常動態的語言。與 Objective-C 不同的是,Swift 給了程式設計師通過消除和減少這種特性來提供執行時效能的能力。本節提供幾個可被用於這樣的操作的語言結構的例子。

動態排程

類使用動態排程的方法和預設的屬性訪問。因此在下面的程式碼片段中,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 中,動態排程預設通過一個 vtable[1](虛擬函式表)間接呼叫。如果使用一個 dynamic 關鍵字來宣告,Swift 將會通過呼叫 Objective-C 通知來傳送呼叫代替。這兩種情況中,這種情況會比直接的函式呼叫較慢,因為它防止了對間接呼叫本身之外程式開銷的許多編譯器優化[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 關鍵詞,並據此去掉對方面的間接呼叫和屬性的訪問。例如在如下的 e.doSomething()  和 f.myPrivateVar 中,就將可以被直接訪問,假定在同一個檔案中,E, F 並沒有任何覆蓋的宣告:

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
}

高效的使用容器型別

通用的容器 Array 和 Dictionary 是有 Swift 標準庫提供的一個重要的功能特性。本節將介紹如何用一種高效能的方式使用這些型別。

建議:在陣列中使用值型別

在 Swift 中,型別可以分為不同的兩類:值型別(結構體,列舉,元組)和引用型別(類)。一個關鍵的區分是 NSArray 不能含有值型別。因此當使用值型別時,優化器就不需要去處理對 NSArray 的支援,從而可以在陣列上省去大部分消耗。

此外,相比引用型別,如果值型別遞迴地含有引用型別,那麼值型別僅僅需要引用計數器。而如果使用沒有引用型別的值型別,就可以避免額外的開銷,從而釋放陣列內的流量。

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

var a : [PhonebookEntry]

記住要在使用大值型別和使用引用型別之間做好權衡。在某些情況下,拷貝和移動大值型別資料的消耗要大於移除橋接和持有/釋放的消耗。

建議:當 NSArray 橋接不必要時,使用 ContiguousArray 儲存引用型別。如果你需要一個引用型別的陣列,而且陣列不需要橋接到 NSArray 時,使用 ContiguousArray 替代 Array:

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

建議:使用適當的改變而不是物件分配。

在 Swift 中所有的標準庫容器都使用 COW(copy-on-write) 執行拷貝代替即時拷貝。在很多情況下,這可以讓編譯器通過持有容器而不是深度拷貝,從而省掉不必要的拷貝。如果容器的引用計數大於 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 的版本沒有任何改變 ,在 append_one 後也沒有使用 ,  但 a 也許會被拷貝。這可以通過使用 inout 引數來避免這個問題:

func append_one_in_place(inout a: [Int]) {
  a.append(1)
}

var a = [1, 2, 3]
append_one_in_place(&a)

未檢查操作

Swift 通過在執行普通計算時檢查溢位的方法解決了整數溢位的 bug。這些檢查在已確定沒有記憶體安全問題會發生的高效的程式碼中,是不合適的。

建議:當你確切的知道不會發生溢位時使用未檢查整型計算。

在對效能要求高的程式碼中,如果你知道你的程式碼是安全的,那麼你可以忽略溢位檢查。

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 通過泛型型別的使用,提供了一個非常強大的抽象機制 。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 物件的引用計數並減少前一個物件的引用計數,這種引用計數代價昂貴但是隻要使用 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 因為沒有引起復制

相關文章