Optional 與 Non-Escaping 兼具的閉包

原文: Optional Non-Escaping Closures
作者: Ole Begemann
譯者: kemchenj

Swift 里如何區分 escaping (逃逸)和 non-escaping (非逃逸)的閉包呢? escaping closure(逃逸閉包)作為函數參數在函數 return 之后(可能會)被調用. 也就是說這個 escaping 閉包被傳遞到了外部, 逃出當前的函數的作用域.

逃逸閉包經常會跟異步操作聯系在一起, 就像下面的例子:

  • 一個函數發起一個后臺任務然后立刻返回, 通過一個 completion handler 回調去匯報結果
  • 一個 View 的類保存了一個閉包去處理按鈕點擊事件, 每次用戶點擊這個按鈕的時候就會去調用這個閉包, 這個閉包就會逃出這個屬性的生命周期
  • 你用 DispatchQueue.async 在一個線程里發起異步任務, 這個任務閉包就比發起異步任務的函數存活得更久.

與之相對的, DispatchQueue.sync 會等到任務閉包執行完成, 然后再 return -- 這個閉包就永遠不會逃逸了. 類似的還有 map 和其它標準庫里常用的序列和集合的算法.

為什么區分開 escaping 和 non-escaping 的閉包那么重要?

四個字概括, 內存管理. 一個閉包會持有閉包內捕獲的變量的強引用, 如果你訪問了成員變量和調用了函數的話, 還會對 self 產生強引用, 因為這會隱式地把 self 作為參數傳入.

一不小心就會非常容易引入 reference cycles(循環引用), 這就是編譯器要求你顯式地在閉包內寫出 self 的原因. 這會強制你去思考潛在的循環引用風險并且手動通過 capture lists(捕獲列表) 去解決它.

但無論如何, 都不可能會在一個 non-escaping 的閉包里發生循環引用 -- 編譯器會保證在函數 return 的時候, 閉包會 release 所有捕獲的變量. 所以, 編譯器只要求在 escaping 的閉包里顯式地把 self 寫出來. 這會讓 non-escaping 的閉包更容易使用.

non-escaping 閉包的另一個好處就是編譯器可以采取更激進的性能優化. 例如, 編譯器因為知道了閉包的生命周期, 所以可以刪掉一些不必要的 retainrelease. 另外, 非逃逸的閉包的上下文也可以保存在棧而不是堆里, 雖然我不知道現在 Swift 的編譯器對這個優化得怎么樣 (open March 2016 bug report 這個提案好像說到了 Swift 還沒做這個優化)

閉包默認為 non-escaping ...

從 Swift 3開始, non-escaping 閉包閉包默認聲明為 non-escaping , 如果你想讓參數閉包逃逸的話, 你需要加上一個 @escaping 去修飾類型. 舉個現實的例子, 下面是 DispatchQueue.async (escaping) 和 DispatchQueue.sync (non-escaping) 的聲明:

class DispatchQueue {
    ...
    func async(/* other params omitted */, execute work: @escaping () -> Void)
    func sync<T>(execute work: () throws -> T) rethrows -> T
}

Swift 3 之前, 是另一種做法: 默認為 escaping, 并且你需要加上 @non-escaping 修飾. 新的做法更好, 因為默認情況下是安全的(不會發生循環引用): 一個函數參數如果有潛在的循環引用的風險, 就必須顯式地書寫出來. 這樣子, @escaping 修飾符就可以作為開發者使用函數時的一個安全提示存在.

... 但只適用于"即時函數參數"(immediate function parameters)

默認 non-escaping 有一個很重要的規則: 它只適用于作為參數傳入函數的閉包, 例如: 任何作為參數傳入的閉包. 其它所有閉包都是 escaping 的.

作為"即時函數參數"是什么意思?

讓我們來看一些例子. 最簡單的例子就是像 map 這樣的高階函數: 一個接收閉包參數的函數. 就像我們看的的這樣, 這是一個 non-escaping 的閉包(這里刪掉了一些跟討論無關的 map 的細節):

func map<T>(_ transform: (Iterator.Element) -> T) -> [T]

函數參數總是逃逸的

與此不同的是閉包類型的變量或者屬性, 它們都默認為 escaping, 甚至不用顯示地聲明(實際上, 顯式地加上 escaping 還會報錯). 這其實很合理, 因為把一個值賦值給變量很明顯就會讓這個值逃逸到變量的作用域, non-escaping 的閉包明顯就不能這么做. 說起來可能會讓你覺得有點暈, 但顯而易見的是, 參數列表里的閉包跟任何別的情況都不一樣.

Optional 的閉包必須為 escaping

更讓人意外的是, 參數作為閉包, 但是被封裝到別的類型里(例如元組, 枚舉或者是可選類型), 就都是 escaping 的. 因為這里的函數都不是作為即時參數傳入, 它會自動轉變為 escaping. 這造成的結果就是, Swift 3.0 里你沒辦法在函數的參數里聲明一個既是 Optional 又是 non-escaping 的閉包. 思考下面的例子, transform 函數接收一個 Int 類型的參數 n 和一個 Optional 類型的閉包 f. 它會返回 f(n), 而 f 為 nil 的時候返回 n:

/// 如果 `f` 為 nil 的話直接返回 `n`
/// 如果 `f` 不為 nil 的話就返回 `f(n)`
func transform(_ n: Int, with f: ((Int) -> Int)?) -> Int {
    guard let f = f else { return n }
    return f(n)
}

這里, 閉包 f 是 escaping 的, 因為 ((Int) -> Int)? 其實是 Optional<(Int) -> Int> 的簡寫, 而不是作為即時參數存在. 這不是我們想要的, 因為這里 f 不可能逃逸.

用默認值去取代 optional

Swift 團隊注意到了這個做法的局限性并且計劃在以后的版本里修復它. 現在必須留意到這一點, 但現在還是沒有辦法把一個 escaping 的閉包強制轉換為 non-escaping, 不過在很多情況下, 你可以通過給閉包參數一個默認值去避免讓參數聲明為 Optional. 在下面的例子里, 默認值是直接把原參數傳回的閉包:

/// 當 f 沒有傳入的時候, 直接給 f 提供一個默認的實現
func transform(_ n: Int, with f: (Int) -> Int = { $0 }) -> Int {
    return f(n)
}

使用重載提供 Optional 和 non-escaping 兩個版本

如果不能夠提供默認值的話, Michael Ilseman 建議使用重載作為一種變通方法, 你可以寫給函數寫兩個版本, 一個用 Optional 作為參數, 另一個使用 non-optional, non-escaping:

// 重載 1: optional, escaping
func transform(_ n: Int, with f: ((Int) -> Int)?) -> Int {
    print("Using optional overload")
    guard let f = f else { return n }
    return f(n)
}

// 重載 2: non-optional, non-escaping
func transform(_ input: Int, with f: (Int) -> Int) -> Int {
    print("Using non-optional overload")
    return f(input)
}

我加上了一些說明去示范一下這些函數怎么被調用. 讓我們用不同的參數來測試一下, 不出意外, 如果你傳 nil, 那么類型檢查的時候就會選擇重載 1:

transform(10, with: nil) // → 10
// 使用了 Optional 版本的重載

如果你傳入一個 Optional 的閉包也會是同樣的重載:

let f: ((Int) -> Int)? = { $0 * 2 }
transform(10, with: f) // → 20
// 使用了 Optional 版本的重載

甚至變量的類型是 non-optional的, Swift 也會選擇第一個重載. 這是因為閉包被保存在變量里的時候會自動逃逸, 所以這里不適用于第二個重載, 雖然我們希望重載的是第二個:

let g: (Int) -> Int = { $0 * 2 }
transform(10, with: g) // → 20
// 使用了 Optional 版本的重載

但是如果你是直接把閉包傳入就會變得不一樣了, 現在會重載到 non-escaping 的版本:

transform(10) { $0 * 2 } // → 20
// 使用了 non-optional 版本的重載

現在調用高階函數傳入閉包已經變得習以為常, 在大多數情況下, 使用這種方法可以讓你用一種更加愉悅的方式去傳入 nil. 如果你打算這么做的話, 請確保你在文檔里列清楚為什么你需要兩種重載.

Typealias 必須 escaping

最后一件你要注意的是, 在 Swift 3里你給閉包起別名的時候不能聲明 escaping 或者 non-escaping. 如果你使用閉包別名作為函數參數類型的話, 需要注意這個閉包是且只能是 escaping 的. 一個 Fix for this bug 已經提上了日程并且應該會在下個版本里完成.

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容