Swift 最佳實踐之 Closure

語言: CN / TW / HK

Swift 作為現代、高效、安全的編程語言,其背後有很多高級特性為之支撐。

『 Swift 最佳實踐 』系列對常用的語言特性逐個進行介紹,助力寫出更簡潔、更優雅的 Swift 代碼,快速實現從 OC 到 Swift 的轉變。

該系列內容主要包括:

  • Optional
  • Enum
  • Closure
  • Protocol
  • Generic
  • Property Wrapper
  • Structured Concurrent
  • Result builder
  • Error Handle
  • Advanced Collections (Asyncsequeue/OptionSet/Lazy)
  • Expressible by Literal
  • Pattern Matching
  • Metatypes(.self/.Type/.Protocol)

ps. 本系列不是入門級語法教程,需要有一定的 Swift 基礎

本文是系列文章的第三篇,介紹 Closure,內容主要包括如何利用 Inferring Type 簡化閉包的使用、escaping-closure 與 nonescaping-closure 的區別、Capture List 注意事項、Trailing Closures 以及 Auto Closures 等。

Overview


Swift Closure 與 Objective-C Block 有很多相似之處,都屬於匿名函數 / lambdas-expressions 的範疇。

相比之下,Swift Closure 更安全、更簡潔。

首先,簡要回顧一下閉包的基本語法,Closure 完整定義如下,幾個關鍵組成:

  • 參數列表
  • 返回值類型
  • 關鍵字 in
  • closure body statements

swift { (parameters) -> type in statements }

聲明 Closure 變量:

swift let closure: (parameters) -> type

看個簡單的例子,如下,為數組排序方法 sorted 傳入了用於排序操作的 closure,其類型為:(String, String) -> Bool,即有 2 個 String 類型的參數,返回值為 Bool

swift let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"] let reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in return s1 > s2 })

由於 closure body 只有一行代碼,故可以直接將其放在 in 後面:

swift names.sorted(by: { (s1: String, s2: String) -> Bool in return s1 > s2 })

Inferring Type


得益於 Swift 強大的類型推演能力,上述排序閉包可以簡化為:

swift reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 } )

即不用顯式寫明參數、返回值的類型,編譯器根據上下文完全可以推演出來

從 Swift 5.1 起,對於只有一個表達式 (Single-expression) 的方法 / 閉包,會隱式返回該表達式的值 swift-evolution/0255-omit-return · GitHub

簡單講,就是對於 Single-expression 的方法 / 閉包可以省略 return 關鍵字:

swift reversedNames = names.sorted(by: { s1, s2 in s1 > s2 } )

Swift 為 Closure 參數提供了一種簡寫方式,即分別用 $0$1 來表示參數列表中的參數,此時可以忽略閉包參數列表,如下:

```swift reversedNames = names.sorted(by: { $0 > $1 } )

// 如後文所述還有更簡潔的版本 // reversedNames = names.sorted(by: >) ```

對於只有一個參數的閉包可以使用參數的簡寫形式 $0

對於有 2 個及以上參數的情況慎用簡寫形式,可能會影響代碼可讀性

Escaping Closures


escaping-closure、nonescaping-closure 是 Swift Closure 相較 OC Block 出現的一個新概念。

escapingnonescaping 描述的是 Closure 作為方法參數時的分類:

  • 當作為參數的 Closure,其生命週期不會逃逸出所在方法時,稱為 nonescaping-closure,(意味着該閉包在方法調用鏈上會被執行),如:

    swift func foo(_ closure: () -> Void) { // closure 沒有逃逸出 foo closure() }

    如下,雖在方法 bar 中沒有直接執行 closure,但在其調用鏈上的 foo 會執行 closureclosure 並沒有逃逸出 bar

    swift func bar(_ closure: () -> Void) { foo(closure) }

  • 當 Closure 的生命週期逃逸出所在方法時,稱為 escaping-closure。

    如下,foo 將參數 escapingClosure 存儲在屬性 closure 中,使其逃逸出 foo,即 foo 返回後 escapingClosure 還存在:

    ```swift public class EscapingClosureDemo { var closure: (() -> Void)?

    func foo(_ escapingClosure: @escaping () -> Void) { closure = escapingClosure } } ```

    此時,參數 escapingClosure 的定義須加上關鍵字 @escaping,顯式表明定義的是 escaping-closure,否則編譯報錯:

    escaping-nonescaping-error.png

    OC 中相當於所有 block 默認都是 escaping

    在 Swift 3 以前, 閉包類型的參數默認是 escaping,並提供了 @noescape 關鍵字用於聲明 nonescaping Closure

    但從 Swift 3 開始,閉包參數默認是 nonescaping,對於 escaping closure 須顯式聲明 @escaping,並廢棄了 @noescape

    swift-evolution/0103-make-noescape-default · GitHub

Why❓

為什麼要區分 escaping、nonescaping,並在 Swift 3 中將默認值從 escaping 改成 nonescaping?

主要原因有兩個:

  • 編譯器優化 👍,對於 escaping closure 需要更復雜的內存管理,而 nonescaping closure 編譯器可以做優化
  • 顯式提醒開發人員 ⚡️:「你正在危險的邊緣試探——正在定義 / 調用的是 escaping closure!」

escaping-closure 為何就危險了❓

原因在於 escaping-closure 可能會產生循環引用 (Strong Reference Cycles),而 nonescaping closure 一定是不會有循環引用的。

因此,在 escaping-closure 中不允許隱式捕獲 self,以免在「不經意間 😴」引起循環引用:

explicitly-reference-self.png

Capturing Values


我們從一個簡單的問題開始:Can You Answer This Simple Swift Question Correctly?

closure-quiz.png

在 1334 位回答者中只有 44% 回答正確 🙈

正確答案:

  • 1 -- Objc
  • 2 -- Swift

1 和 2 的區別在於:1 用了捕獲列表 (Capture List),而 2 沒有。

Capture List:

  • [] 聲明的表達式列表,表達式間用 , 分隔,放在參數列表前 (如有):

    ```swift func bar() { var age = 10 var name = "Jim"

    let closure = { [age, name] in // 等價於 [age = age, name = name] // 此處的 age、name 與閉包外的已沒任何關係,僅名字相同而以 print("(name) is (age) years old!") }

    age = 11 name = "Tom" closure() // Jim is 10 years old! } ```

  • capture list 在閉包定義時完成初始化賦值 ([age = age, name = name])

    所以上面輸出的是 Jim is 10 years old!,而不是 Tom is 11 years old!

    如果不用 capture list,而是直接引用,則直到 closure 執行時才去取值: no-capture-list.png

    but,如果捕獲的是引用類型 (reference types),那情況就不一樣了:

    String 在 Swift 中是值類型,而非引用類型

    ```swift class Foo { var age: Int var name: String

    init(age: Int, name: String) { self.age = age self.name = name } }

    func bar() { var foo = Foo(age: 10, name: "Jim") let closure = { [foo] in // 捕獲引用類型 (foo) print("(foo.name) is (foo.age) years old!") }

    foo.age = 11 foo.name = "Tom"

    closure() // Tom is 11 years old! } ```

    雖然,捕獲引用類型時,其輸出有所不同,但「capture list 在閉包定義時完成初始化賦值」的特性並沒有變,只不過此時賦值的是「指針」

    如下,若給 foo 賦一個新值,則與 closure 內捕獲的就沒任何關係了:

    ```swift func bar() { var foo = Foo(age: 10, name: "Jim") let closure = { [foo] in print("(foo.name) is (foo.age) years old!") }

    foo = Foo(age: 11, name: "Tom") // 此時,foo 指向新實例

    closure() // Jim is 10 years old! } ```

    capture-list.png

  • 通過 capture list 還可以指定捕獲類型:strong (默認)、weak 以及 unowned,用於避免循環引用:

    ```swift { print(self.name) } // implicit strong capture { [self] in print(self.name) } // explicit strong capture { [weak self] in print(self?.name) } // weak capture { [unowned self] in print(self.name) } // unowned capture

    // 還可以在 capture list 用表達式賦值 { [name = self.name] in print(name)} // strong capture name ```

總之,capture list 在閉包定義時完成初始化賦值 (而非執行時):

  • 對於值類型 (value types),capture value 初始化實質上就是 copy,從此以後閉包內外的值就分道揚鑣,沒任何關係了 (只是名字相同而已)
  • 對於引用類型 (reference types),capture value 初始化 copy 的實質上是個指針,閉包內外引用的還是同一個實例

Trailing Closures


如果方法的最後一個參數是 closure,那麼在調用時可以簡化:

```swift func foo() { // 正常調用 bar(doSomething: { print("normal call!") })

// trailing closure call // 省略()、label bar { print("trailing closure call!") } }

func bar(doSomething: () -> Void) { doSomething() } ```

對於有多個 closure 參數的情況,建議只對最後一個參數用 trailing closure call,以免影響可讀性:

```swift func foo() { // ❎ 不建議 bar { print("1") } doSomething1: { print("2") }

// ✅ bar(doSomething: { print("1") }) { print("2") } }

func bar(doSomething: () -> Void, doSomething1: () -> Void) { doSomething() doSomething1() } ```

Auto closures


如下,給 closure 加上 @autoclosure 後,在調用時可以直接用表達式,傳入的表達式會自動封裝成 closure,而無需顯式的寫成閉包的形式:

```swift func foo() { bar(doSomething: print("a")) // ❌ bar(doSomething: { print("a") }) baz(value: 1) // ❌ baz(value: { 1 }) }

func bar(doSomething: @autoclosure () -> Void) { print("b") doSomething() }

func baz(value: @autoclosure () -> Int) { print(value()) }

// 輸出: // b // a // 1 ```

  • 對 closure 加 @autoclosure 的前提是其沒有參數
  • 表達式是 lazy,只有到閉包執行時才執行表達式,而非方法調用時

正是由於 @autoclosure 的 lazy 特性,其常被用於那些期望懶加載的場景,如:Swift 標準庫提供的 assert 函數:

swift public func assert( _ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String = String(), file: StaticString = #file, line: UInt = #line ) { // Only assert in debug mode. if _isDebugAssertConfiguration() { if !_fastPath(condition()) { _assertionFailure("Assertion failed", message(), file: file, line: line, flags: _fatalErrorFlags()) } } }

由於 conditionmessage 是 auto closure,故可以簡化 assert 調用:

assert(someCondition(), "Failed!")

如果它們是常規閉包,則調用時有點麻煩:

assert({ someCondition() }, { "Failed!" })

類似的,如 Alamofire 中對 Error 的擴展:

alamofire-error-autoclosure.png

在項目中經常會對 Dictionary 的取值做些保護並提供默認值,如:

swift extension Dictionary { func value<T>(forKey key: Key, defaultValue: @autoclosure () -> T) -> T { self[key].flatMap { $0 as? T } ?? defaultValue() } }

First-class functions first, closures second


這一小節有點挖牆腳的意思:「優先考慮一等函數,其次才是閉包」,前提是閉包參數與函數參數匹配,如:

```swift let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]

// closure let reversedNames = names.sorted(by: { $0 > $1 } )

// first-class function let reversedNames = names.sorted(by: >) ```

```swift let domain: String? = "docs.swift.org"

// if let var url: URL? if let domain { url = URL(string: domain) }

// closure let url = domain.flatMap { URL(string: $0) }

// first-class function let url = domain.flatMap(URL.init) // 等價於 domain.flatMap(URL.init(string:)) ```

```swift let subviews: [UIView] = [...]

// closure subviews.forEach { addSubview($0) }

// first-class functions subviews.forEach(addSubview) ```

```swift let ages = [1, 2, 3]

// closure ages.forEach { baz(value: $0) }

// first-class functions ages.forEach(baz(value:))

func baz(value: Int) { print(value) } ```

小結

本文對 Closure 的主要特性以及最佳實踐進行了簡要分析介紹。

如,利用 Inferring Type 可以簡化閉包的使用,Capture List 對於值類型和引用類型的區別,Trailing Closures 以及 Auto closures 的使用等。

最後,提到某些場景下利用函數一等公民身份相比閉包可以簡化代碼。

參考資料

Apple-Documentation-closures

Swift clip: First class functions

Swift’s closure capturing mechanics

Using @autoclosure when designing Swift APIs

swift-evolution/0255-omit-return · GitHub

swift-evolution/0103-make-noescape-default · GitHub

swift/Assert.swift at main · apple/swift · GitHub