7. Closure

Closure是一段程式碼,跟函數一樣可以有參數,有傳回值,能夠呼叫他,只是沒有名字,所以closure相當於沒有名稱的函數。Closure可以儲存在變數或常數中,也可以放在陣列裡面,甚至可以當成參數值傳遞給另外一個函數。Closure能夠做的事情難以想像的多,並且讓程式碼大為精簡。

7.1 語法

Closure語法如下:

{ (parameters) -> Type in
    // 程式碼寫這
}

Parameters位置放的是closure的參數,與函數的參數寫法一模一樣,「->」後面放closure結束後的傳回值型態,保留字in後面則是這個closure所包含的程式碼,最後將整個closure用大括號包圍起來。Closure的整體結構與函數非常類似只是少個名稱而已。

Closure因為沒有名字所以無法直接拿來執行,要呼叫closure前必須先把closure放到變數或常數中,然後透過這個變數或常數來執行closure。例如下面這個非常簡單的closure。這個closure沒有參數也沒有傳回值,內容只有一行程式碼就是印出 “hello, world”,我們把這段程式碼放到常數printHello中,這個程序相當於給closure一個名字,名稱就是printHello。現在把printHello當成函數名稱呼叫他就相當於執行儲存的closure了。

let printHello = { () -> Void in
    print("hello, world")
}
printHello()
// Prints "hello, world"

如果closure沒有參數也沒有傳回值時,「() -> Void in」可以省略,省略後的寫法如下:

let printHello = { print("hello, world") }
printHello()
// Prints "hello, world"

Closure也可以接受參數,所以我們可以將要印出來的字串當參數傳給closure,這樣使用上會更為彈性,如下:

let printMessage = { (_ message: String) -> Void in
    print(message)
}
printMessage("Good day!")
// Prints "Good day!"

Closure的參數型態與傳回值型態編譯器都可以根據傳入值與傳出值型態而推論出來,因此語法上可以省略,省略後如下:

let printMessage = { (message) in
    print(message)
}
printMessage("Good day!")
// Prints "Good day!"

最後連參數名稱都可以省略。呼叫closure時編譯器會自動將傳進去的第一個參數取名為$0,第二個參數為$1,以此類堆,所以最後的程式碼如下,非常精簡漂亮。

let printMessage = { print($0) }
printMessage("Good day!")
// Prints "Good day!"

接下來看一個稍微複雜一點的例子。我們想要設計一個數學運算的函數,接受兩個數字,計算後將結果傳回,例如加法運算。但我們不希望這個函數只能計算加法,要各種運算方式都能夠處理,因此,如果能將運算公式交由呼叫者去決定,這樣這個函數在使用上就很彈性。所以這個函數這樣設計:

func math(_ a: Int, _ b: Int, by: (Int, Int) -> Int) -> Int {
    by(a, b)
}

函數math(_:_:by:)的前兩個參數均為Int型態,這裡先不考慮泛型。第三個參數by的資料型態為 (Int, Int) -> Int 表示這個參數為函數型態,所以可以在這個位置傳入closure。Closure的設計就可以讓呼叫者自己決定參數a與參數b要如何運算,例如加法運算。

var result = math(5, 3, by: { $0 + $1 })
print(result)
// Prints "8"

我們也可以事先將一些比較複雜的公式放在函數、變數或常數中,然後在參數by的位置填入該函數、變數或常數名稱。舉個例子,例如我們將排列數或組合數的運算方式寫成函數,然後用struct包裝起來方便之後可以擴充。這時候呼叫math函數時參數by的位置就可以填上struct中的函數名稱。

struct Formula {
    // 計算階乘
    privates static func factorial(_ n: Int) -> Int {
        (n == 0) ? 1 : n * factorial(n - 1)
    }
    // 計算排列數
    static func permutation(_ a: Int, _ b: Int) -> Int {
            factorial(a) / factorial(a - b)
    }
    // 計算組合數
    static func combination(_ a: Int, _ b: Int) -> Int {
        permutation(a, b) / factorial(b)
    }
}

var result = math(5, 3, by: Formula.permutation)
print(result)
// Prints "60"
result = math(6, 2, by: Formula.combination)
print(result)
// Prints "15"

現在我們來深入觀察一下數字的加法運算符號「+」。Int型態的符號「+」定義如下:

public static func + (lhs: Int, rhs: Int) -> Int

可以看得出來符號「+」是一個函數,且型態為 (Int, Int) -> Int,跟我們設計的math函數的第三個參數by所需要的型態是一樣的,因此,如果要做加法運算,程式碼可寫成如下:

var result = math(5, 3, by: +)
print(result)
// Prints "8"

除了「+」以外,「-」、「*」、「/」、「%」…等非常多的運算符號其型態都是 (Int, Int) -> Int,所以如果要進行這些運算,參數by直接填入這些運算符號就可以了。

result = math(10, 2, by: /)
print(result)
// Prints "5"
result = math(7, 4, by: %) // 計算餘數
print(result)
// Prints "3"
result = math(4, 2, by: -)
print(result)
// Prints "2"

我們可以在不重寫math(_:_:by:)原始碼的情況下,隨意改變計算的方式,只要符合參數型態,就可以讓math(_:_:by:)進行任何我們想要的運算。

7.2 Trailing Closure

由於closure是一段沒有函數名稱的程式碼,所以在這個地方的整體程式看起來就比較凌亂,為了讓程式碼看起來乾淨一些,closure有許多省略語法,前面已經看過不少。現在我們來認識一下trailing closure。如果closure的位置在函數的最後一個參數位置,呼叫這個函數的時候就可以將closure移到函數外面,並且可以省略參數標籤,這種closure語法稱為trailing closure。

下面這段程式碼中的函數someFunc,有一個型態為closure的參數,因為只有一個參數,所以closure位置也相當於是最後一個參數。

func someFunc(complete: () -> Void) {
    complete()
}

呼叫時可以將closure移到函數後面形成trailing closure,當closure移出去後,someFunc相當於沒有參數,這時()可以省略,因此以下兩種呼叫方式語法都是正確的。

someFunc() {
    // 程式碼寫這
}

someFunc {
    // 程式碼寫這
}

再以上一節的math函數為例,增加第四個參數complete。參數complete是一個closure,接受兩個參數,第一個參數放計算結果,第二個參數放錯誤資料,如果計算過程中有任何錯誤發生,可以透過第二個參數傳出來。新的math函數設計如下,先在Error的部分填入nil表示永遠不會有錯誤。

func math(_ a: Int, _ b: Int, by: (Int, Int) -> Int, complete: (Int, Error?) -> Void) {
    complete(by(a, b), nil)
}

因為complete是最後一個參數,因此可以將整個closure移到函數後面形成trailing closure形式,如下:

math(6, 3, by: +) {
    if $1 == nil {
        print($0)
        // Prints "9"
    }
}

對比不使用training closure,並且也不使用$0、$1的省略語法,比較一下兩者差異。

math(6, 3, by: +, complete: { (result, error) in
    if error == nil {
        print(result)
        // Prints "9"
    }
})

Multiple Trailing Closures

若在函數尾端的closure不只一個,這時候可以把一個以上的closure全部移到函數外面,這種寫法就稱為multiple trailing closures,例如:

func inputNumber(_ num: Int, pass: ()->Void, fail :()->Void) {
    if num > 0 {
        pass()
    } else {
        fail()
    }
}


inputNumber(20) {
    print("GOOD")
} fail: {
    print("BAD")
}

7.3 Capture

在函數中宣告的變數為該函數的區域變數或常數,生命週期也在該函數結束後跟著消失不見。但如果區域變數或常數為一段程式碼,並且還會被函數傳到外面時,這時這段程式碼就不會隨函數的結束而消失不見,除此之外,已經傳到外面的程式碼還可以存取原本函數中的區域變數,這樣的現象稱為closure捕獲了圍繞在closure外面的值,並且把捕獲的值當成自己的變數或常數來使用。

例如下面這個提供計數功能的函數,呼叫這個函數後會得到一個計數器,功能就像很多遊樂園入口處的管理人員拿著的計數器,只要入園一位遊客就按一下,用來統計園內有多少人一樣。

func counter(_ base: Int = 0) -> () -> Int {
    var i = 0
    let increment: () -> Int = {
        i += 1
        return i
    }
    return increment
}

現在呼叫這個函數,並且將他傳回的計數器放到一個常數frontdoor中,代表這是前門需要的計數器。由於傳回的型態是函數,並且捕獲counter(_:)中的變數i,所以將frontdoor當成函數名稱呼叫時,變數i的值就會加1。

let frontdoor = counter()
frontdoor()
frontdoor()
frontdoor()
let total = frontdoor()
print(total)
// Prints "4"

如果這時後門也取了一個計數器,這時後門計數的結果並不會跟前門的結果互相衝突,因為closure屬於reference type,所以counter()每次傳回的closure都會在不同的記憶體區段中,而捕獲的變數i就變成了每個closure中自己的區域變數,彼此不會互相干擾。

let backdoor = counter()
backdoor()
backdoor()
let m = backdoor()
print(m)
// Prints "3"

7.4 Escaping

當我們呼叫一個函數並且將值傳到這個函數時,傳進去的值會儲存在對映的參數裡面,而這個參數所佔用的記憶體會在函數結束後被釋放掉,也就是說參數的生命週期僅存在於函數執行時期,函數結束參數也就跟著結束了。但現在有個狀況,就是函數已經結束但是卻希望參數繼續活著不要消失,什麼時候會有這樣的需求呢?Closure。

我們來設計一個倒數計時的函數,設定的時間到了就會通知我們。這裡先介紹Timer這個類別。Timer定義在Foundation框架中,所以要import這個框架,但我們不用煩惱這件事情,透過Xcode建立的專案,預設匯入的框架都已經幫我們處理好了。

Timer類別中的scheduledTimer方法提供了倒數計時所需要的功能。這個方法的第一個參數為指定的一個時間,第二個參數填入false代表時間到了結束計時,第三個參數為closure,時間到了後要做的事情放在第三個參數中。執行下面這段程式碼會發現 “已經設定好計時器” 這個字串會先印出,5秒鐘後才會印出”倒數計時結束”。這代表一件事情,scheduledTimer方法中的closure區段是在另一個執行緒中執行。

let at = Date().timeIntervalSinceNow + 5
Timer.scheduledTimer(withTimeInterval: at, repeats: false) {_ in
   print("倒數計時結束")
}
print("已經設定好計時器")

現在我們將上面這幾行程式碼用函數包起來,並且呼叫這個函數,如下:

func countdown(_ second: Double, complete: () -> Void) {
    let at = Date().timeIntervalSinceNow + second
    Timer.scheduledTimer(withTimeInterval: at, repeats: false) {_ in
        complete()
    }
}

countdown(5) {
    print("倒數完成")
}
print("已經設定好計時器")

這時候就會得到一個語法錯誤訊息,如下:

Escaping closure captures non-escaping parameter ‘complete’

造成這個錯誤的原因是呼叫countdown(_:complete:)函數,並將秒數傳給Timer類別後,countdown函數就結束了。因為countdown函數結束所以理論上complete參數也要跟著結束,但因為這個參數是closure,裡面的程式碼會在5秒鐘後執行,所以現在參數不可以結束,必須要等這個參數裡面的程式碼執行完才可以結束,所以必須要在complete參數加上@escaping表示當函數結束並且佔用的記憶體等資源要釋放時,這個參數必須要「逃掉」不被回收。加上@escaping後的正確寫法如下:

func countdown(_ second: Double, complete: @escaping () -> Void) {
    let at = Date().timeIntervalSinceNow + second
    Timer.scheduledTimer(withTimeInterval: at, repeats: false) {_ in
        complete()
    }
}

7.5 Autoclosure

在說明autoclosure前我們先來看這段程式碼。這是一個or函數,接受兩個布林值參數,兩個參數中只要有一個為true,該函數就傳回true,否則就傳回false。

func or(_ cond1: Bool, _ cond2: Bool) -> Bool {
    if cond1 {
        return true
    } else if cond2 {
        return true
    }
    return false
}

呼叫的程式碼如下,因為 5 > 3 是true,雖然 6 < 2 是false,但因為是or運算,所以最後結果傳回true。

let result = or(5 > 3, 6 < 2)
// result == true

觀察函數內部的判斷式運作流程,第一行的判斷式只要確定第一個參數傳進來的是true,就直接返回,後面的程式碼就不執行了。但事實上當呼叫or(_:_:)函數時,5 > 3與6 < 2都會先執行完畢,然後再傳進or(_:_:)函數中,這時相當於呼叫or(true, false),所以在or(_:_:)函數中第二個判斷式原本要用來判斷6 < 2雖然不需要執行,但是在呼叫or(_:_:)函數時 6 < 2 早就已經執行完畢。如果此時第二個參數 6 < 2 需要比較長的時間運算,這時候or(_:_:)函數的運作效率就不好。

為了解決效率問題,我們把or函數的兩個參數改為closure形式,並且判斷式的部分要修改為函數呼叫,如下:

func or(_ cond1: () -> Bool, _ cond2: () -> Bool) -> Bool {
    if cond1() {
        return true
    } else if cond2() {
        return true
    }
    return false
}

這時可以發現,只要cond1()傳回true,cond2()就不會呼叫了,雖然解決了效率問題,但是呼叫or(_:_:)的方式要改,因為兩個參數是closure形式,所以呼叫時的參數型態要改為closure形式,如下:

let result = or({5 < 3}, {6 > 2})

現在參數形式變成這樣實在不理想,所以在or(_:_:)函數的參數位置加上@autoclosure,表示呼叫時傳遞進來的參數就會自動轉成closure形式,如下:

func or(_ cond1: @autoclosure () -> Bool, _ cond2: @autoclosure () -> Bool) -> Bool {
    if cond1() {
        return true
    } else if cond2() {
        return true
    }
    return false
}

let result = or(5 < 3, 6 > 2)

~補充說明~

  1. @autoclosure可以跟@escaping合在一起使用
  2. @autoclosure的closure型態不可以有參數,只能接受「() -> type」這樣的形式。
  3. @autoclosure具有延遲執行特性,意思是原本在參數傳遞前就要執行完的程式碼,現在改成在函數內部依狀況再決定是否要執行。

發表迴響