Swift學習(9)- 閉包(程式碼完善版)
閉包是自包含的函式程式碼區塊,可以在程式中被傳遞與使用。Swift
中的閉包與C
、Objective-C
中的區塊(blocks)以及 Python 語言中的匿名函式(Lambdas)相當類似。
閉包可以捕捉並儲存其所在上下文中的任意常數與變數的參考,這個行為稱為捕捉常數與變數。Swift
會自動管理捕捉過程中涉及的所有記憶體操作。
注意
如果對捕捉(Capturing)這個概念還不熟悉也沒關係,後續的值捕捉章節會有更詳細的說明。
在函式章節中介紹的全域與巢狀函式,其實也是一種特殊的閉包。閉包有以下三種形式:
- 全域函式是有名稱但不會捕捉任何值的閉包
- 巢狀函式是有名稱且可以捕捉其封閉函式域內值的閉包
- 閉包運算式是利用輕量級語法所寫、可捕捉其上下文中變數或常數值的匿名閉包
Swift
的閉包運算式語法非常簡潔,並鼓勵在常見情境下進行語法最佳化,主要優化如下:
- 利用上下文推斷參數與回傳值型別
- 單一運算式閉包可省略
return
關鍵字 - 參數名稱縮寫
- 尾隨閉包語法
閉包運算式
巢狀函式作為複雜函式的一部分時,其自包含的程式碼區塊定義與命名方式帶來了便利。但在某些情境下,撰寫未完整宣告且沒有函式名稱的類函式結構程式碼會更有彈性,特別是在需要將函式作為參數傳遞時。
閉包運算式是一種建立內嵌閉包的方式,語法簡潔且不失清晰。以下將透過對sorted(by:)
這個案例的多次語法簡化,展示閉包語法的演進,每次都用更簡明的方式實現相同功能。
排序方法
Swift
標準函式庫提供了名為sorted(by:)
的方法,會根據開發者提供的排序閉包運算式的判斷結果,對陣列中的值(型別已確定)進行排序。排序完成後,sorted(by:)
會回傳一個與原陣列型別與大小相同的新陣列,元素順序已排序。原本的陣列不會被sorted(by:)
修改。
以下閉包運算式範例,使用sorted(by:)
方法對String
型別的陣列進行字母逆序排序。初始陣列如下:
let names = ["Chris", "Alex", "Ewa", "Barry", "Jensen"]
sorted(by:)
方法接受一個閉包,該閉包需傳入與陣列元素型別相同的兩個值,並回傳布林值,表示排序後第一個參數是否應排在第二個參數前面。若第一個參數值應排在第二個參數前,閉包需回傳true
,否則回傳false
。
此例對String
陣列排序,因此閉包型別需為(String, String) -> Bool
。
提供排序閉包的一種方式,是撰寫一個符合要求的普通函式,並將其作為sorted(by:)
的參數:
func backward(_ s1: String, _ s2: String) -> Bool {
return s1 > s2
}
var reversedNames = names.sorted(by: backward) // reversedNames為["Jensen", "Ewa", "Chris", "Barry", "Alex"]
print("複雜閉包逆序:\(reversedNames).")
---
output: 複雜閉包逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].
若第一個字串(s1)大於第二個字串(s2),backward(_:_:)
會回傳true
,表示s1
應排在s2
前。對字串來說,「大於」表示「字母順序較後」。例如字母「B」大於「A」,字串「Tom」大於「Tim」。此閉包將進行字母逆序排序,「Jensen」會排在「Eva」前。
但這種寫法對於一個簡單的運算(a > b)來說太繁瑣。利用閉包運算式語法可更簡潔地撰寫內嵌排序閉包。
閉包運算式語法
閉包運算式語法的一般形式如下:
/*
{ (parameters) -> return type in
statements
}
*/
閉包運算式的參數可以是in-out
參數,但不能設定預設值。若命名了可變參數,也可使用,元組亦可作為參數與回傳值。
以下範例展示前述backward(_:_:)
函式的閉包運算式版本:
reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
return s1 > s2
})
print("內嵌閉包運算式逆序:\(reversedNames).")
---
output: 內嵌閉包運算式逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].
注意,內嵌閉包的參數與回傳值型別宣告與backward(_:_:)
相同。不同的是,閉包的型別宣告寫在大括號內,而非外部。
閉包的主體由in
關鍵字引入,表示參數與回傳型別宣告已完成,接下來是閉包主體。
由於此閉包主體極短,可改寫為單行:
reversedNames = names.sorted(by: {(s1: String, s2: String) -> Bool in return s1 > s2})
print("簡潔內嵌閉包運算式逆序:\(reversedNames).")
---
output: 簡潔內嵌閉包運算式逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].
此例中sorted(by:)
的呼叫方式不變,參數改為內嵌閉包。
根據上下文推斷型別
因為排序閉包是作為sorted(by:)
的參數傳入,Swift
可推斷其參數與回傳型別。sorted(by:)
被字串陣列呼叫,因此參數必為(String, String) -> Bool
。因此,閉包宣告時可省略型別、回傳箭頭->
與參數括號:
reversedNames = names.sorted(by: {s1, s2 in return s1 > s2})
print("隱式推斷型別閉包運算式逆序:\(reversedNames).")
---
output: 隱式推斷型別閉包運算式逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].
實際上,內嵌閉包作為參數傳遞時,幾乎總能推斷出參數與回傳型別,因此很少需要完整格式。
若完整格式有助於可讀性,Swift
仍鼓勵使用完整格式。此例中,閉包用途明確,讀者可推測其為字串排序。
單一運算式閉包的隱式回傳
單行運算式閉包可省略return
關鍵字,隱式回傳結果,如下:
reversedNames = names.sorted(by: {s1, s2 in s1 > s2})
print("極致省略閉包逆序: \(reversedNames).")
---
output: 極致省略閉包逆序: ["Jensen", "Ewa", "Chris", "Barry", "Alex"].
此例中,sorted(by:)
的參數型別明確為Bool
,且閉包主體僅有一個運算式(s1 > s2),因此可省略return
。
參數名稱縮寫
Swift
自動為內嵌閉包提供參數名稱縮寫,可直接用$0
、$1
、$2
等順序存取參數。
若使用參數名稱縮寫,可省略參數列表,型別由函式型別推斷。閉包接受的參數數量取決於最大縮寫編號。in
關鍵字也可省略,閉包僅由主體構成:
reversedNames = names.sorted(by: { $0 > $1 })
print("省略參數名稱閉包逆序:\(reversedNames).")
---
output: 省略參數名稱閉包逆序:["Jensen", "Ewa", "Chris", "Barry", "Alex"].
此例中,$0
與$1
分別代表第一、第二個String
參數,因sorted(by:)
期望字串參數,縮寫型別自動推斷為String
。
運算子方法
還有更簡短的寫法。Swift
的String
型別定義了大於號>
的運算子方法,接受兩個String
並回傳Bool
,正好符合sorted(by:)
的需求。因此可直接傳遞>
運算子,Swift
會自動推斷:
reversedNames = names.sorted(by: >)
更多運算子方法內容請參考運算子方法
尾隨閉包
若需將很長的閉包運算式作為最後一個參數傳給函式,使用尾隨閉包語法會更方便。尾隨閉包是寫在函式括號之後的閉包運算式,函式會將其作為最後一個參數呼叫。使用尾隨閉包時,不需寫參數標籤:
/*
func someFunctionThatTakesAClosure(closure: () -> Void) {
// 函式主體
}
// 不使用尾隨閉包
someFunctionThatTakesAClosure(closure: {
// 閉包主體
})
// 使用尾隨閉包
someFunctionThatTakesAClosure {
// 閉包主體
}
*/
在閉包運算式語法章節的字串排序閉包,也可改寫為尾隨閉包:
reversedNames = names.sorted() { $0 > $1 }
print("尾隨閉包:\(reversedNames).")
---
output: 尾隨閉包:["Jensen", "Ewa", "Chris", "Barry", "Alex"].
若閉包為唯一參數,使用尾隨閉包時甚至可省略()
:
reversedNames = names.sorted { $0 > $1 }
print("尾隨閉包省略(): \(reversedNames).")
---
output: 尾隨閉包省略(): ["Jensen", "Ewa", "Chris", "Barry", "Alex"].
當閉包很長無法單行書寫時,尾隨閉包特別實用。例如,Swift
的Array
型別有map(_:)
方法,接受一個閉包運算式作為唯一參數,該閉包會對陣列每個元素呼叫一次,並回傳映射值。映射方式與回傳型別由閉包決定。
閉包應用於每個元素後,map(_:)
會回傳一個新陣列,內容為映射後的值。
以下範例說明如何用尾隨閉包將Int
陣列[16, 58, 510]轉換為對應的String
陣列[“OneSix”, “FiveEight”, “FiveOneZero”]:
let digitNames = [
0: "Zero", 1: "One", 2: "Two", 3: "Three", 4: "Four",
5: "Five", 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine"
]
let numbers = [16, 58, 510]
上述程式建立了一個整數數字與其英文名稱的對應字典,以及一個待轉換的整數陣列。
現在可傳遞尾隨閉包給numbers
的map(_:)
方法,建立對應的字串陣列:
let strings = numbers.map {
(number) -> String in
var number = number
var output = ""
repeat {
output = digitNames[number % 10]! + output
number /= 10
} while number > 0
return output
}
// `strings`常數會被推斷為[String]
// 其值為["OneSix", "FiveEight", "FiveOneZero"]
map(_:)
會對每個元素呼叫一次閉包,不需指定number
型別,因可由陣列型別推斷。
此例中,區域變數number
的值由閉包參數取得,因此可在閉包內修改(閉包或函式的參數預設為常數),閉包指定回傳型別為String
,以表明新陣列型別。
閉包每次被呼叫時會建立一個output
字串並回傳。利用取餘運算子(number % 10)取得最後一位數字,並用digitNames
字典取得對應字串。此閉包可用於建立任意正整數的字串表示。
注意
字典
digitNames
下標後接驚嘆號(!),因為字典下標回傳可選值(Optional Value),表示查找失敗時會為nil
。此例中,number % 10
必為有效下標,因此可強制解包。
從digitNames
取得的字串會加到output
前方,逆序建立數字的字串版本。(如number為16,number % 10為6,58則為8,510則為0。)
number
接著除以10,因為是整數,未除盡部分會被捨去,因此16變1,58變5,510變51。
整個流程重複進行,直到number /= 10
為0,閉包回傳output
,map(_:)
則將其加入新陣列。
此例中,利用尾隨閉包語法,優雅地將閉包功能包裝在函式後方,而不需將整個閉包寫在map(_:)
括號內。
值捕捉
閉包可以捕捉其定義時上下文中的常數或變數。即使這些常數或變數的原作用域已不存在,閉包仍可在主體內存取與修改這些值。
Swift
中,最簡單可捕捉值的閉包形式是巢狀函式,也就是定義在其他函式主體內的函式。巢狀函式可捕捉其外部函式的所有參數與定義的常數、變數。
舉例來說,這裡有個名為makeIncrementer
的函式,內含一個名為incrementer
的巢狀函式。incrementer()
從上下文捕捉了runningTotal
與amount
兩個值。捕捉後,makeIncrementer
會將incrementer
作為閉包回傳。每次呼叫incrementer
時,會以amount
為增量增加runningTotal
的值。
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
var runningTotal = 0
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}
return incrementer
}
var testIncrementer = makeIncrementer(forIncrement: 10)
testIncrementer() // 此時testIncrementer()的值為10
print(testIncrementer()) // 輸出20,10 + 10
---
output: 20
makeIncrementer
的回傳型別為() -> Int
,表示回傳的是一個函式,而非單純值。該函式每次呼叫時不接受參數,只回傳Int
。關於函式回傳其他函式,請參考函式型別作為回傳型別。
makeIncrementer(forIncrement:)
定義了一個初始值為0的整數變數runningTotal
,用來儲存目前總計。該值為incrementer
的回傳值。
makeIncrementer(forIncrement:)
有一個Int
參數,外部參數名為forIncrement
,內部為amount
,表示每次呼叫incrementer
時runningTotal
要增加的數量。函式內還定義了巢狀函式incrementer
,負責實際加總。該函式會將runningTotal
加上amount
並回傳。
若單獨看incrementer()
,會發現它有些特別:
/*
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}
*/
incrementer()
沒有任何參數,但在主體內存取了runningTotal
與amount
。這是因為它從外部函式捕捉了這兩個變數的參考。捕捉參考可確保runningTotal
與amount
在makeIncrementer
呼叫結束後仍存在,且下次呼叫incrementer
時仍可存取。
注意
為了最佳化,若某值不會被閉包改變,或閉包建立後不會改變,Swift 可能會捕捉並保存該值的拷貝。
Swift 也會自動管理被捕捉變數的記憶體,包括釋放不再需要的變數。
以下是makeIncrementer
的使用範例:
let incrementByTen = makeIncrementer(forIncrement: 10)
此例定義了一個名為incrementByTen
的常數,指向一個每次呼叫會將runningTotal
加10的incrementer
函式。多次呼叫可得到:
incrementByTen() // 回傳10
incrementByTen() // 回傳20
var result = incrementByTen() // 回傳30
print("三次呼叫IncrementByTen()回傳結果為:\(result)。")
---
output: 三次呼叫IncrementByTen()回傳結果為:30。
若再建立另一個incrementer
,會有自己的參考,指向全新獨立的runningTotal
:
let incrementBySeven = makeIncrementer(forIncrement: 7)
result = incrementBySeven() // 回傳7
print("Result:\(result).")
---
output: Result:7.
再次呼叫原本的incrementByTen
會繼續增加自己的runningTotal
,與incrementBySeven
捕捉的變數無關:
incrementByTen() // 回傳40
注意
若將閉包指定給類別實例的屬性,且閉包捕捉了該實例或其成員,會造成循環強參考。Swift 可用捕捉列表解決此問題。
閉包是參考型別
上述例子中,incrementBySeven
與incrementByTen
都是常數,但這些常數指向的閉包仍可修改其捕捉的變數。這是因為函式與閉包都是參考型別。
無論將函式或閉包指定給常數還是變數,實際上都是將變數或常數的值設為對應函式或閉包的參考。上述例子中,incrementByTen
是常數,指向閉包的參考,而非閉包本身。
這也代表若將閉包指定給兩個不同的常數或變數,兩者都會指向同一個閉包:
let alsoIncrementByTen = incrementByTen
print("alsoIncrementByTen的值是:\(alsoIncrementByTen()).")
---
output: alsoIncrementByTen的值是:50.
逃逸閉包
當閉包作為參數傳入函式,但該閉包在函式回傳後才被執行,稱該閉包「逃逸」出函式。定義接受閉包參數的函式時,可在參數型別前加上@escaping
,表示該閉包允許逃逸。
一種讓閉包逃逸的方法,是將閉包儲存在函式外部定義的變數中。例如,許多啟動非同步操作的函式會接受閉包參數作為completion handler
。這類函式在非同步操作開始後立即回傳,但閉包直到操作結束時才被呼叫。此時,閉包需逃逸出函式,因為要在函式回傳後才執行。例如:
var completionHandlers: [() -> Void] = []
func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
completionHandlers.append(completionHandler)
}
someFunctionWithEscapingClosure(_:)
接受一個閉包參數,該閉包被加入函式外部的陣列。若未標記為@escaping
,會出現編譯錯誤(Converting non-escaping parameter ‘completionHandler’ to generic parameter ‘Element’ may allow it to escape)。
將閉包標記為@escaping
代表必須在閉包內明確參考self
。如下例,傳給someFunctionWithEscapingClosure(_:)
的閉包是逃逸閉包,需明確參考self
;而傳給someFunctionWithNoneEscapingClosure(_:)
的閉包是非逃逸閉包,可隱式參考self
。
func someFunctionWithNoneEscapingClosure(closure: () -> Void) {
closure()
}
class someClass {
var x = 10
func doSometing() {
someFunctionWithEscapingClosure {
self.x = 100
}
someFunctionWithNoneEscapingClosure {
x = 200
}
}
}
let instance = someClass()
instance.doSometing()
print(instance.x) // 輸出“200”
completionHandlers.first?() // someFunctionWithEscapingClosure的閉包已逃逸到外部,函式回傳後可呼叫 // xxx.first?() 可選鏈式呼叫
print(instance.x) // 輸出“100”
---
output: 200
100
自動閉包
自動閉包是一種自動建立的閉包,用來包裝傳給函式作為參數的運算式。這種閉包不接受任何參數,被呼叫時會回傳包裝的運算式值。這種語法可省略閉包的大括號,直接用一般運算式取代顯式閉包。
我們經常呼叫採用自動閉包的函式,但很少自己實作。舉例來說,assert(condition:message:file:line:)
接受自動閉包作為condition
與message
參數;condition
只會在debug模式下求值,message
僅在condition
為false
時才求值。
自動閉包讓開發者能延遲求值,直到呼叫閉包時才執行。延遲求值對有副作用或高運算成本的程式碼特別有用,因為可控制執行時機。以下程式展示閉包如何延遲求值:
var customersInLine = ["Chris", "Jensen", "Ewa", "Alex", "Barry"]
print(customersInLine.count) // 輸出“5”
let customerProvider = { customersInLine.remove(at: 0) }
print(customersInLine.count) // 輸出“5”
print("NOW SERVING \(customerProvider())!") // 輸出“NOW SERVING Chris!”
print(customersInLine.count) // 輸出“4”
---
output: 5
5
NOW SERVING Chris!
4
雖然閉包內的程式碼會移除customersInLine
的第一個元素,但在閉包被呼叫前,元素不會被移除。若閉包永遠不被呼叫,則內部運算式永遠不會執行,陣列內容也不會變。注意,customerProvider
型別不是String
,而是() -> String
,即無參數、回傳String
的函式。
將閉包作為參數傳給函式時,也能達到延遲求值效果。
// customersInLine is ["Jensen", "Ewa", "Alex", "Barry"]
func server(customer customerProvider: () -> String) {
print("Now serving \(customerProvider())!")
}
server(customer: { customersInLine.remove(at: 0) }) // 輸出“Now serving Jensen!”
---
output: Now serving Jensen!
上述server(customer:)
接受一個回傳顧客姓名的顯式閉包。以下版本則將參數標記為@autoclosure
,改為接受自動閉包。此時可直接傳入運算式,參數會自動轉為閉包:
// customersInLine is ["Ewa", "Alex", "Barry"]
func server(customer customerProvider: @autoclosure () -> String) {
print("Now Serving \(customerProvider())!")
}
server(customer: customersInLine.remove(at: 0)) // 輸出“Now Serving Ewa!”
---
output: Now Serving Ewa!
注意
過度使用autoclosure會讓程式難以理解。上下文與函式名稱應清楚表明求值會延遲執行。
若要讓自動閉包可「逃逸」,需同時標記@autoclosure
與@escaping
。@escaping
說明見前述逃逸閉包。
// customersInLine is ["Alex", "Barry"]
var customerProviders: [() -> String] = []
func collectCustomerProviders(_ customerProvider: @autoclosure @escaping () -> String) {
customerProviders.append(customerProvider)
}
collectCustomerProviders(customersInLine.remove(at: 0))
collectCustomerProviders(customersInLine.remove(at: 0))
print("Collected \(customerProviders.count) closures.") // 輸出“Collected 2 closures.” 現在有兩個閉包被加入外部customerProviders陣列
print(customersInLine) // customersInLine 仍為 ["Alex", "Barry"]
for customerProvider in customerProviders {
print("Now serving \(customerProvider())!")
} // 輸出“Now serving Alex!”、“Now serving Barry!”
//print(customersInLine) // 此時customersInLine陣列為空
---
output: Collected 2 closures.
["Alex", "Barry"]
Now serving Alex!
Now serving Barry!
上述程式中,collectCustomerProviders(_:)
並未呼叫傳入的customerProvider
閉包,而是將其加入customerProviders
陣列,該陣列定義於函式外部,代表閉包可在函式回傳後被呼叫。因此,customerProvider
參數必須允許逃逸出函式作用域。