原文: 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 閉包的另一個好處就是編譯器可以采取更激進的性能優化. 例如, 編譯器因為知道了閉包的生命周期, 所以可以刪掉一些不必要的 retain
和 release
. 另外, 非逃逸的閉包的上下文也可以保存在棧而不是堆里, 雖然我不知道現在 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 已經提上了日程并且應該會在下個版本里完成.