根據Swift官方文件,閉包(closure)會自動捕捉其所在上下文中的外部變數,即使是定義這些變數的上下文已經消失。寥寥數字,其實已經將閉包捕捉說的足夠清晰明瞭,只是其中隱含的諸如捕捉的具體含義、捕捉的時機、被捕捉變數的特性和捕捉列表的意義等細節,如不詳加研究,使用閉包還是會錯誤百出,難以揮灑自如。
本文中所有程式碼均在playground中執行,若欲在實際專案中測試,需做部分修改,但基本邏輯和結論不變
—————————— 本文部分結論和例子根據部分讀者意見做了修正更新,感謝他們 ———————————–
一、捕捉的含義
閉包捕捉等同於copy
閉包捕捉某個變數就意味著copy一份這個變數,值型別的變數直接複製值,引用型別的變數複製引用。複製後的變數名同被捕捉的變數,複製後的變數仍為變數,常量仍為常量。
看例子
值型別捕捉
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import UIKit struct Pet { var name: String init(name: String) { self.name = name } func printNameClosure() -> () -> Void { return { print(self.name) } } } var pet: Pet = Pet(name: "旺旺") let cl = pet.printNameClosure() //1 pet.name = "強強" cl() //2 |
結構體Pet的例項方法printNameClosure()
返回一個捕捉了self
例項本身的閉包,Pet為值型別,因此//1行程式碼執行完成後,閉包cl
複製了一份儲存在變數pet
中名為旺旺
的Pet例項,那麼當儲存在變數pet
的Pet例項改名為強強
時,閉包cl
所捕捉的Pet例項不變,名字仍為旺旺
,因此輸出結果為:
1 |
旺旺 |
我想您應該會有疑問,為什麼上述示例程式碼不寫的更簡潔些:
1 2 3 4 5 6 7 |
...//此行以前程式碼不變 var pet: Pet = Pet(name: "旺旺") let cl = { print(pet.name) } pet.name = "強強" cl() |
事實上,上述示例程式碼中的閉包cl
並未捕捉任何變數,關於閉包捕捉髮生的時機下文中會有詳細介紹。
引用型別捕捉
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import UIKit class Pet { var name: String init(name: String) { self.name = name } func printNameClosure() -> () -> Void { return { print(self.name) } } } var pet: Pet = Pet(name: "旺旺") let cl = pet.printNameClosure() //1 pet.name = "強強" cl() //2 |
這次Pet型別為類,是引用型別,因此//1行程式碼執行完成後,閉包cl
複製了一份變數pet
所指向的名為旺旺
的Pet例項引用,此時變數pet
與閉包cl
捕捉的pet
指向同一Pet例項,那麼當變數pet
所指向的Pet例項改名為強強
時,閉包cl
所捕捉的Pet例項名字也改為強強
,因此輸出結果為:
1 |
強強 |
二、引用型別變數被捕捉後的特性
引用型別變數被捕捉意味著變數所指向的類的引用被複制,也即引用計數會加一,因此為強持有。
因為引用型別變數捕捉的強持有特性,有時候會產生引用環,導致記憶體洩漏,解決辦法官網文件已有,這裡不再贅述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import UIKit class Pet { var name: String init(name: String) { self.name = name } func printNameClosure() -> () -> Void { return { print(self.name) } } } var pet: Pet? = Pet(name: "旺旺") let cl = pet?.printNameClosure() pet = nil //1 cl!() |
閉包cl
捕捉了變數pet
所指向的Pet例項,而引用型別閉包捕捉為強持有,因此變數pet
所指向的Pet例項的引用計數為2,那麼當在//1行設定變數pet
為nil
時,pet
所指向的Pet例項的引用計數減為1,並不銷燬,因此輸出結果為:
1 |
旺旺 |
三、閉包捕捉髮生的時機
1)當閉包所使用外部變數的作用域未結束時,閉包只是簡單使用外部變數,並不捕捉。
看例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import UIKit class Pet { var name: String init(name: String) { self.name = name } deinit { print("\(name)昇天了!") } } //someFunc函式內部是一個區域性上下文 func someFunc() { //區域性上下文1 var pet: Pet? = Pet(name: "旺旺") func printNameBlock() -> () -> Void { //區域性上下文2 return { print(pet?.name) } } let cl = printNameBlock()//1 cl() //2 pet = nil //3 cl() } someFunc() //4 |
函式someFunc()
的內部函式printNameBlock()
返回一個閉包,被返回的閉包定義在區域性上下文2
中,並使用了區域性上下文1
中的變數pet
。雖然//1行變數cl
儲存了內部函式printNameBlock()
返回的閉包,但這個閉包從初始化到銷燬整個生命週期中,並未脫離其使用的外部變數pet
的作用域即區域性上下文1
,那麼閉包cl
並不捕捉外部變數pet
。因此當//3行設定pet
為nil
時,變數pet
所指向的Pet例項變數被銷燬,最終的輸出結果為:
1 2 3 |
Optional("旺旺") 旺旺昇天了! nil |
2)如果閉包所使用的外部變數的作用域結束,而閉包或因被返回,或作為引數傳遞給其他函式而仍然存在時,閉包自動捕捉其使用的外部變數。
看閉包被返回的例子
閉包被返回
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import UIKit class Pet { var name: String init(name: String) { self.name = name } deinit { print("\(name)昇天了!") } } //someFunc函式內部是一個區域性上下文 func someFunc() { //區域性上下文1 var pet: Pet? = Pet(name: "旺旺") func printNameBlock() -> () -> Void { //區域性上下文2 let pet2 = pet return { print(pet2?.name) } } let cl = printNameBlock()//1 cl() //2 pet = nil //3 cl() } someFunc() //4 |
上述示例與1)中示例相比,僅在內部函式printNameBlock()
的區域性上下文2
中新增加了一個變數pet2
,指向區域性上下文1
中變數pet
所指向的名為旺旺
的Pet例項,內部函式printNameBlock()
返回的閉包使用了變數pet2
,當這個閉包被返回時,其使用的外部變數pet2
的作用域即區域性上下文2
也同時結束,因此變數pet2
被捕捉。那麼//1行執行結束後,閉包cl
捕捉了變數pet2
,則變數pet
所指向的名為旺旺
的Pet例項的引用為2,當在//3行設定pet
為nil
時,其指向的名為旺旺
的Pet例項的引用只是降為1而已,並不銷燬,因此最後的輸出結果為:
1 2 3 |
Optional("旺旺") Optional("旺旺") 旺旺昇天了 |
之所以仍然輸出旺旺昇天了
,是因為//4行someFunc()
呼叫結束後,閉包cl
被銷燬,其捕捉的變數隨即也都被銷燬。
閉包被傳遞
在非同步請求時,任務常常被包裝為閉包,作為引數提交給GCD或NSOperationQueue執行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import UIKit struct Pet { //區域性上下文1 var name: String init(name: String) { self.name = name } mutating func changeNameTo(name: String) { //區域性上下文2 //非同步 let dispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) dispatch_async(dispatchQueue) { //區域性上下文3 self.name = name sleep(1) //1 } } } var pet = Pet(name: "旺旺") pet.changeNameTo("強強") sleep(3) //2 print(pet.name) //3 |
上例中的Pet是一個struct,是個值型別,其例項方法changNameTo(_:)
使用非同步修改了自己的名字,非同步的任務閉包使用了區域性上下文1
中的self
即例項本身,但是否捕捉self
,還取決於非同步任務執行結束時,區域性上下文1
是否結束。在上述示例中,//1行使得閉包任務睡眠1s,因此保證了閉包任務執行結束時,區域性上下文2
已經結束,也即區域性上下文1
已經結束,因此閉包任務捕捉了self
例項本身,//2行睡眠了3秒,保證了//3行輸出Pet例項名字時,非同步任務已經執行完成,但由於傳入非同步任務的閉包捕捉了self
,因此並不能達到修改Pet名字的目的,輸出結果為:
1 |
旺旺 |
稍微修改下,讓區域性上下文2
睡眠1s。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import UIKit struct Pet { //區域性上下文1 var name: String init(name: String) { self.name = name } mutating func changeNameTo(name: String) { //區域性上下文2 //非同步 let dispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) dispatch_async(dispatchQueue) { //區域性上下文3 self.name = name } sleep(1) //1 } } var pet = Pet(name: "旺旺") pet.changeNameTo("強強") sleep(3) //2 print(pet.name) //3 |
由於//1行程式碼讓區域性上下文2
睡眠1s,因此導致非同步任務執行結束時,self
所在的區域性上下文1
仍在,那麼非同步任務閉包並不捕捉self
,因此可以達到修改Pet名字的目的,那麼輸出結果為:
1 |
強強 |
如果將上述兩個例子都改為同步,那麼,根據同步的性質,同步任務閉包一定不捕捉self
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import UIKit struct Pet { //區域性上下文1 var name: String init(name: String) { self.name = name } mutating func changeNameTo(name: String) { //區域性上下文2 //同步 let dispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) dispatch_sync(dispatchQueue) { //區域性上下文3 self.name = name sleep(1) //1 } } } var pet = Pet(name: "旺旺") pet.changeNameTo("強強") sleep(3) //2 print(pet.name) //3 |
由於是同步任務,意味著區域性上下文2
一直會等到區域性上下文3
返回才返回,也即在區域性上下文3
執行時,區域性上下文2
一直未結束,因此同步任務閉包並不捕捉self
,則結果為:
1 |
強強 |
需要注意的是,Swift中對值型別的使用達到了空前的程度,因此我們常用struct定義model,如果在model我們還非同步請求資料,那麼根據閉包的捕捉特性,可能請求了兩年也不會有結果。如果改為引用型別,則沒有這種隱患,因此如果模型需使用非同步請求資料,定義時選擇引用型別更合適。
3)閉包中如果定義了捕捉列表,閉包在定義時立即capture捕捉列表中所有變數,並將捕捉的變數一律改為常量,供自己實用。
閉包捕捉一般發生在所使用的外部常量所在上下文結束時,但如果閉包定義了捕捉列表,閉包在初始化時立即捕捉捕捉列表中的變數,並將捕捉的變數一律改為常量,這也是捕捉列表應有的意義。
對比兩個示例,一個新增了捕捉列表,一個沒有。
1 2 3 4 5 6 7 |
var i = 18 let cl = { //並未新增捕捉列表 print(i) } i = 100 cl() |
閉包cl
所使用的外部變數i
的區域性作用域一直未結束,因此閉包cl
只是簡單的使用變數i
,並不捕捉,無論i
如何變,cl
呼叫時都會使用自己被呼叫時刻i
的最新值,因此輸出結果為:
1 |
100 |
如果新增捕捉列表:
1 2 3 4 5 6 7 |
var i = 18 let cl = { [i] in //新增了捕捉列表 print(i) } i = 100 cl() |
閉包cl
所使用的外部變數i
的區域性作用域雖未結束,但由於閉包cl
定義了捕捉列表,因此閉包cl
在其定義完成時,即捕捉了變數i
,copy了一份i
,由於i
是值型別,copy後與變數i
不再有任何關係,因此輸出結果:
1 |
18 |
當然由於捕捉列表中捕捉的變數均被改為常量,在閉包內無法修改捕捉變數的值:
1 2 3 4 5 6 7 8 |
var i = 18 let cl = { [i] in //新增了捕捉列表 i = 56 //此行報錯 print(i) } i = 100 cl() |
上述示例在閉包內部修改了捕捉變數i
的值,但由於捕捉列表中的變數在捕捉後均被改為常量,因此會報錯。
四、結論
經過上述分析,closure capture主要有四個特性,
1)閉包capture某個變數等於copy一份這個變數,值型別的變數直接複製值,引用型別的變數直接複製引用值,與函式中引數傳遞類似,複製後的變數名同被捕捉的變數。
2)如果閉包所使用的外部變數的作用域未結束,閉包只是簡單使用這些外部變數,並不捕捉。
3)閉包捕捉髮生在閉包所使用的外部變數的作用域結束,而閉包或因被返回,或作為引數傳遞給其他函式而仍然存在時。
第2和第3點講的都是閉包捕捉的時機,其實可以總結為一句話,閉包捕捉髮生在其所使用的外部變數即將銷燬的時刻,也即你再不捕捉我就沒了。這也意味著,當閉包捕捉多個外部變數,而這些外部變數的作用域不同時,閉包按照各個外部變數作用域結束的先後次序進行變數捕捉,並非一次性捕捉。
4)閉包中如果定義了捕捉列表,閉包在定義時立即capture捕捉列表中所有變數,並將捕捉的變數一律改為常量,供自己實用。
閉包捕捉蘋果官方文件中介紹的非常簡略,上述只是所有的特性也是我多番實驗得出的結論,對於理解closure capture暫時應該是夠了。
五、鳴謝
由於文章寫的有點倉促,部分結論未經嚴謹論證就直接擺出來了,幸好部分網友大牛及時指正,真是無與倫比的感謝。他們是strider,小吻子, 來扶爺試玩個波。非常感謝他們,也歡迎各路大神繼續留言討論批評指正。