Swift系列八 - 閉包

2021-04-30 11:00:15

什麼是閉包?閉包表示式又是什麼?

一、閉包表示式(Closure Expression)

在Swift中,可以通過func定義一個函數,也可以通過閉包表示式定義一個函數。

1.1. 閉包表示式的格式

{
  (參數列) -> 返回值型別 in
  函數體程式碼
}

1.2. 閉包表示式和函數的比較

定義一個普通的函數:

func sum(_ v1: Int, _ v2: Int) -> Int { v2 + v2 }
let result = sum(10, 20)
print(result)
// 輸出:30

定義閉包:

var sum = {
    (v1: Int, v2: Int) -> Int in
    return v1 + v2
}
let result = sum(10, 20)
print(result)
// 輸出:30

1.3. 閉包表示式的簡寫

定義函數:

func exec(v1: Int, v2: Int, fn: (Int, Int) -> Int) {
    print(fn(v1, v2))
}

要想使用exec函數,則必須傳入兩個Int型別的引數和一個返回Int型別的函數,然後exec內部執行了傳入的函數。

func sum(_ a: Int, _ b: Int) -> Int {
    return a + b
}

exec(v1: 10, v2: 20, fn: sum)
// 輸出:30

按照以往的知識,我們需要定義一個函數,然後把函數傳給exec就行了。其實我們也可以使用閉包表示式。

exec(v1: 10, v2: 20, fn: {
    (v1: Int, v2: Int) -> Int in
    return v1 + v2
})
// 輸出:30

上面的閉包表示式還可以簡寫:

1.3.1. 簡寫一

  • 省略引數型別和返回值;
  • 編譯器會自動推斷閉包表示式中引數型別和返回值型別。
exec(v1: 10, v2: 20, fn: {
    v1, v2 in return v1 + v2
})
// 輸出:30

1.3.2. 簡寫二

如果函數的返回值是一個單一表示式,可以省略return

exec(v1: 10, v2: 20, fn: {
    v1, v2 in v1 + v2
})
// 輸出:30

1.3.3. 簡寫三

如果閉包表示式不想寫引數,可以使用美元符$序號代替,序號從0開始,代表引數位置。

exec(v1: 10, v2: 20, fn: { $0 + $1 })
// 輸出:30

1.3.4. 簡寫四(不建議)

簡單的閉包表示式還可以直接使用運運算元代替。

exec(v1: 10, v2: 20, fn: +)
// 輸出:30

二、尾隨閉包

2.1. 特點一(最後一個實參)

如果將一個很長的閉包表示式作為函數的最後一個實參,使用尾隨閉包可以增強函數的可讀性。

尾隨閉包是一個被書寫在函數呼叫括號外面(後面)的閉包表示式。

以呼叫上面的exec函數為例:

exec(v1: 10, v2: 20) {
  $0 + $1
}
// 輸出:30

2.2. 特點二(唯一實參)

如果閉包表示式是函數的唯一實參,而且使用了尾隨閉包的語法,那就不需要在函數名後邊寫圓括號。

定義函數:

func exec(fn: (Int, Int) -> Int) {
    print(fn(10, 20))
}

呼叫方式:

// 方式一:
exec(fn: { $0 + $1 })

// 方式二:
exec() { $0 + $1 }

// 方式三:
exec { $0 + $1 }

/*
 輸出:
 30
 30
 30
 */

三、閉包(Closure)

閉包和閉包表示式嚴格意義上來講並不是同一個概念。

一個函數和它所捕獲的變數/常數環境組合起來,稱為閉包。

  • 一般指定義在函數內部的函數;
  • 一般它捕獲的是外層函數的區域性變數/常數。

範例程式碼:

typealias Fn = (Int) -> Int
func getFn() -> Fn {
    var num = 0
    func plus(_ i: Int) -> Int {
        num += 1
        return num
    }
    return plus
}
var fn = getFn()
print(fn(1))
print(fn(2))
print(fn(3))
print(fn(4))
/*
 輸出:
 1
 3
 6
 10
 */

為什麼var num = 0作為區域性變數還能一直累加?不是應該在函數執行完成後就被釋放了麼?我們通過組合一探究竟。

3.1. 組合分析閉包

3.1.1. 如果內部函數沒有捕獲外部變數:



通過分析可以看到,函數返回的是一個地址,也就是變數fn裡面存放的是函數地址。

3.1.2. 如果內部函數捕獲外部變數:



組合程式碼就變得複雜一點了,並且出現了swift_allocObject關鍵字,也就意味著在堆空間申請了一塊記憶體,記憶體存放的是num的值。每次呼叫fn,存取的num都是同一塊記憶體地址,所以才會出現區域性變數也能一直累加的效果。

3.1.3. 證明swift_allocObject存放的是num

第一步:原始碼斷點:

第二步:檢視swift_allocObject返回的地址:

第三步:檢視rax地址存放的初始化值:

第四步:執行fn(1)後:

第五步:執行fn(2)後:

結論: 內部函數一旦捕獲了外部的區域性變數,要想持續使用這個變數,就需要延遲變數的生命週期,所以在堆空間分配一塊記憶體來存放區域性變數的值。

思考:為什麼可以存取同一塊記憶體空間?
var fn = getFn()fn佔用16個位元組,前8個位元組存放返回的函數地址(plus的封裝),後8個位元組存放堆空間(num)的地址。如果var fn2 = getFn()fn1fn2前8個位元組可能相同,不同的是後面的8個位元組。

3.2. 閉包和類的比較

可以把閉包想象成是一個類的範例物件。

  • 記憶體在堆空間;
  • 捕獲的區域性變數/常數就是物件的成員(儲存屬性);
  • 組成閉包的函數就是類內部定義的方法。
class Closure {
    var num = 0
    func plus(_ i: Int) -> Int {
        num += i
        return num
    }
}
var cs = Closure()
print(cs.plus(1))
print(cs.plus(2))
print(cs.plus(3))
print(cs.plus(4))
/*
 輸出:
 1
 3
 6
 10
 */

四、自動閉包

範例程式碼:

// 如果第一個數大於0,返回第一個數,否則返回第二個數
func getFirst(_ v1: Int, _ v2: Int) -> Int {
    return v1 > 0 ? v1 : v2
}
getFirst(10, 20) // 10
getFirst(-2, 20) // 20
getFirst(0, -4) // -4

把上面的程式碼修改如下:

func getNumber() -> Int {
    print("getNumber")
    let a = 10
    let b = 10
    return a + b
}
let result1 = getFirst(10, getNumber())
print(result1)
/*
 輸出:
 getNumber
 10
 */

let result2 = getFirst(-1, getNumber())
print(result2)
/*
 輸出:
 getNumber
 20
 */

分析:不管第一個數是否大於0,都會執行第二個引數傳入的函數,這樣整體有點浪費(效能/空間)。我們可以嘗試把函數第二個入參型別修改為函數型別。

優化程式碼:

typealias VoidFunc = () -> Int
func getFirst(_ v1: Int, _ v2: VoidFunc) -> Int {
    print("getFirst")
    return v1 > 0 ? v1 : v2()
}

func getNumber() -> Int {
    print("getNumber")
    let a = 10
    let b = 10
    return a + b
}
getFirst(10, getNumber)
/*
 輸出:
 getFirst
 */

getFirst(-1, getNumber)
/*
 輸出:
 getFirst
 getNumber
 */

結果:只有需要的時候才會執行對應的程式碼。

但是,如果這樣修改後,每次都需要傳入一個函數會有點麻煩。Swift提供了自動閉包功能,可以把普通變數自動包裹成閉包,這樣就能滿足上面程式碼的所有的功能了。

關鍵字: @autoclosure
用法:在函數前面加上@autoclosure關鍵字即可。

自動閉包程式碼:

typealias VoidFunc = () -> Int
func getFirst(_ v1: Int, _ v2: @autoclosure VoidFunc) -> Int {
    print("getFirst")
    return v1 > 0 ? v1 : v2()
}

getFirst(10, 20) // 10
getFirst(-1, 10) // 10

自動閉包特點

  • @autoclosure會將普通引數(例如,20)封裝成閉包{ 引數 }(例如,{ 20 });
  • @autoclosure只支援() -> T(無參有返回值)格式的引數;
  • @autoclosure並非只支援最後一個引數,和位置沒有任何關係;
  • @autoclosure、無@autoclosure,構成函數過載;
  • 為了避免與期望衝突,使用了有@autoclosure的地方最好明確註釋清楚:這個值會被延遲執行(有可能不執行)。

延伸: 空合併運運算元??使用了@autoclosure技術。

public func ?? <T>(optional: T?, defaultValue: @autoclosure () throws -> T?) rethrows -> T?

五、應用

通過陣列的排序看下閉包表示式是如何使用的。

定義函數:

var arr = [20, 52, 19, 3, 80, 72]

3.1. 系統排序

在Swift中,Array為開發者提供了sort()排序函數,開發者可以直接使用。

arr.sort()
print(arr)
// 輸出:[3, 19, 20, 52, 72, 80]

3.2. 自定義排序

sort()是升序的,如果要降序呢?我們可以使用另外一個函數進行自定義排序。

Array提供的函數:

func sort(by areInIncreasingOrder: (Element, Element) throws -> Bool) rethrows

可以看到,該函數讓傳入一個閉包表示式。使用規則如下:

  • 返回true:第一個元素排在第二個元素前面;
  • 返回false:第一個元素排在第二個元素後面。

呼叫方式一(普通函數):

func compare(i1: Int, i2: Int) -> Bool {
  return i1 > i2
}
arr.sort(by: compare)
print(arr)
// 輸出:[80, 72, 52, 20, 19, 3]

呼叫方式二(閉包表示式):

arr.sort(by: {
    (i1: Int, i2: Int) -> Bool in
    return i1 > i2
})
arr.sort(by: { i1, i2 in return i1 > i2 })
arr.sort(by: { i1, i2 in i1 > i2 })
arr.sort(by: { $0 > $1 })
arr.sort(by: >)
arr.sort() { $0 > $1 }
arr.sort { $0 > $1 }
// 輸出:[80, 72, 52, 20, 19, 3]