Swift學習(9)- 閉包(程式碼完善版)

Swift學習(9)- 閉包(程式碼完善版)

August 23, 2021·Jingyao Zhang
Jingyao Zhang

image

閉包是自包含的函式程式碼區塊,可以在程式中被傳遞與使用。Swift中的閉包與CObjective-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

運算子方法

還有更簡短的寫法。SwiftString型別定義了大於號>的運算子方法,接受兩個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"].

當閉包很長無法單行書寫時,尾隨閉包特別實用。例如,SwiftArray型別有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]

上述程式建立了一個整數數字與其英文名稱的對應字典,以及一個待轉換的整數陣列。

現在可傳遞尾隨閉包給numbersmap(_:)方法,建立對應的字串陣列:

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,閉包回傳outputmap(_:)則將其加入新陣列。

此例中,利用尾隨閉包語法,優雅地將閉包功能包裝在函式後方,而不需將整個閉包寫在map(_:)括號內。


值捕捉

閉包可以捕捉其定義時上下文中的常數或變數。即使這些常數或變數的原作用域已不存在,閉包仍可在主體內存取與修改這些值。

Swift中,最簡單可捕捉值的閉包形式是巢狀函式,也就是定義在其他函式主體內的函式。巢狀函式可捕捉其外部函式的所有參數與定義的常數、變數。

舉例來說,這裡有個名為makeIncrementer的函式,內含一個名為incrementer的巢狀函式。incrementer()從上下文捕捉了runningTotalamount兩個值。捕捉後,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,表示每次呼叫incrementerrunningTotal要增加的數量。函式內還定義了巢狀函式incrementer,負責實際加總。該函式會將runningTotal加上amount並回傳。

若單獨看incrementer(),會發現它有些特別:

/*
func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
}
 */

incrementer()沒有任何參數,但在主體內存取了runningTotalamount。這是因為它從外部函式捕捉了這兩個變數的參考。捕捉參考可確保runningTotalamountmakeIncrementer呼叫結束後仍存在,且下次呼叫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: Result7.

再次呼叫原本的incrementByTen會繼續增加自己的runningTotal,與incrementBySeven捕捉的變數無關:

incrementByTen()  // 回傳40

注意

若將閉包指定給類別實例的屬性,且閉包捕捉了該實例或其成員,會造成循環強參考。Swift 可用捕捉列表解決此問題。


閉包是參考型別

上述例子中,incrementBySevenincrementByTen都是常數,但這些常數指向的閉包仍可修改其捕捉的變數。這是因為函式與閉包都是參考型別。

無論將函式或閉包指定給常數還是變數,實際上都是將變數或常數的值設為對應函式或閉包的參考。上述例子中,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:)接受自動閉包作為conditionmessage參數;condition只會在debug模式下求值,message僅在conditionfalse時才求值。

自動閉包讓開發者能延遲求值,直到呼叫閉包時才執行。延遲求值對有副作用或高運算成本的程式碼特別有用,因為可控制執行時機。以下程式展示閉包如何延遲求值:

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參數必須允許逃逸出函式作用域。

最後更新於