閉包捕捉(closure capture)淺析

發表於2016-05-12

 

根據Swift官方文件,閉包(closure)會自動捕捉其所在上下文中的外部變數,即使是定義這些變數的上下文已經消失。寥寥數字,其實已經將閉包捕捉說的足夠清晰明瞭,只是其中隱含的諸如捕捉的具體含義、捕捉的時機、被捕捉變數的特性和捕捉列表的意義等細節,如不詳加研究,使用閉包還是會錯誤百出,難以揮灑自如。

本文中所有程式碼均在playground中執行,若欲在實際專案中測試,需做部分修改,但基本邏輯和結論不變
—————————— 本文部分結論和例子根據部分讀者意見做了修正更新,感謝他們 ———————————–

一、捕捉的含義

閉包捕捉等同於copy

閉包捕捉某個變數就意味著copy一份這個變數,值型別的變數直接複製值,引用型別的變數複製引用。複製後的變數名同被捕捉的變數,複製後的變數仍為變數,常量仍為常量。

看例子

值型別捕捉

結構體Pet的例項方法printNameClosure()返回一個捕捉了self例項本身的閉包,Pet為值型別,因此//1行程式碼執行完成後,閉包cl複製了一份儲存在變數pet中名為旺旺的Pet例項,那麼當儲存在變數pet的Pet例項改名為強強時,閉包cl所捕捉的Pet例項不變,名字仍為旺旺,因此輸出結果為:

我想您應該會有疑問,為什麼上述示例程式碼不寫的更簡潔些:

事實上,上述示例程式碼中的閉包cl並未捕捉任何變數,關於閉包捕捉髮生的時機下文中會有詳細介紹。

引用型別捕捉

這次Pet型別為類,是引用型別,因此//1行程式碼執行完成後,閉包cl複製了一份變數pet所指向的名為旺旺的Pet例項引用,此時變數pet與閉包cl捕捉的pet指向同一Pet例項,那麼當變數pet所指向的Pet例項改名為強強時,閉包cl所捕捉的Pet例項名字也改為強強,因此輸出結果為:


二、引用型別變數被捕捉後的特性

引用型別變數被捕捉意味著變數所指向的類的引用被複制,也即引用計數會加一,因此為強持有。

因為引用型別變數捕捉的強持有特性,有時候會產生引用環,導致記憶體洩漏,解決辦法官網文件已有,這裡不再贅述。

閉包cl捕捉了變數pet所指向的Pet例項,而引用型別閉包捕捉為強持有,因此變數pet所指向的Pet例項的引用計數為2,那麼當在//1行設定變數petnil時,pet所指向的Pet例項的引用計數減為1,並不銷燬,因此輸出結果為:


三、閉包捕捉髮生的時機

1)當閉包所使用外部變數的作用域未結束時,閉包只是簡單使用外部變數,並不捕捉。

看例子:

函式someFunc()的內部函式printNameBlock()返回一個閉包,被返回的閉包定義在區域性上下文2中,並使用了區域性上下文1中的變數pet。雖然//1行變數cl儲存了內部函式printNameBlock()返回的閉包,但這個閉包從初始化到銷燬整個生命週期中,並未脫離其使用的外部變數pet的作用域即區域性上下文1,那麼閉包cl並不捕捉外部變數pet。因此當//3行設定petnil時,變數pet所指向的Pet例項變數被銷燬,最終的輸出結果為:

2)如果閉包所使用的外部變數的作用域結束,而閉包或因被返回,或作為引數傳遞給其他函式而仍然存在時,閉包自動捕捉其使用的外部變數。

看閉包被返回的例子

閉包被返回

上述示例與1)中示例相比,僅在內部函式printNameBlock()區域性上下文2中新增加了一個變數pet2,指向區域性上下文1中變數pet所指向的名為旺旺的Pet例項,內部函式printNameBlock()返回的閉包使用了變數pet2,當這個閉包被返回時,其使用的外部變數pet2的作用域即區域性上下文2也同時結束,因此變數pet2被捕捉。那麼//1行執行結束後,閉包cl捕捉了變數pet2,則變數pet所指向的名為旺旺的Pet例項的引用為2,當在//3行設定petnil時,其指向的名為旺旺的Pet例項的引用只是降為1而已,並不銷燬,因此最後的輸出結果為:

之所以仍然輸出旺旺昇天了,是因為//4行someFunc()呼叫結束後,閉包cl被銷燬,其捕捉的變數隨即也都被銷燬。

閉包被傳遞

在非同步請求時,任務常常被包裝為閉包,作為引數提交給GCD或NSOperationQueue執行。

上例中的Pet是一個struct,是個值型別,其例項方法changNameTo(_:)使用非同步修改了自己的名字,非同步的任務閉包使用了區域性上下文1中的self即例項本身,但是否捕捉self,還取決於非同步任務執行結束時,區域性上下文1是否結束。在上述示例中,//1行使得閉包任務睡眠1s,因此保證了閉包任務執行結束時,區域性上下文2已經結束,也即區域性上下文1已經結束,因此閉包任務捕捉了self例項本身,//2行睡眠了3秒,保證了//3行輸出Pet例項名字時,非同步任務已經執行完成,但由於傳入非同步任務的閉包捕捉了self
,因此並不能達到修改Pet名字的目的,輸出結果為:


稍微修改下,讓區域性上下文2睡眠1s。


由於//1行程式碼讓區域性上下文2睡眠1s,因此導致非同步任務執行結束時,self所在的區域性上下文1仍在,那麼非同步任務閉包並不捕捉self,因此可以達到修改Pet名字的目的,那麼輸出結果為:


如果將上述兩個例子都改為同步,那麼,根據同步的性質,同步任務閉包一定不捕捉self:


由於是同步任務,意味著區域性上下文2一直會等到區域性上下文3返回才返回,也即在區域性上下文3執行時,區域性上下文2一直未結束,因此同步任務閉包並不捕捉self,則結果為:


需要注意的是,Swift中對值型別的使用達到了空前的程度,因此我們常用struct定義model,如果在model我們還非同步請求資料,那麼根據閉包的捕捉特性,可能請求了兩年也不會有結果。如果改為引用型別,則沒有這種隱患,因此如果模型需使用非同步請求資料,定義時選擇引用型別更合適。

3)閉包中如果定義了捕捉列表,閉包在定義時立即capture捕捉列表中所有變數,並將捕捉的變數一律改為常量,供自己實用。

閉包捕捉一般發生在所使用的外部常量所在上下文結束時,但如果閉包定義了捕捉列表,閉包在初始化時立即捕捉捕捉列表中的變數,並將捕捉的變數一律改為常量,這也是捕捉列表應有的意義。

對比兩個示例,一個新增了捕捉列表,一個沒有。


閉包cl所使用的外部變數i的區域性作用域一直未結束,因此閉包cl只是簡單的使用變數i,並不捕捉,無論i如何變,cl呼叫時都會使用自己被呼叫時刻i的最新值,因此輸出結果為:


如果新增捕捉列表:


閉包cl所使用的外部變數i的區域性作用域雖未結束,但由於閉包cl定義了捕捉列表,因此閉包cl在其定義完成時,即捕捉了變數i,copy了一份i,由於i是值型別,copy後與變數i不再有任何關係,因此輸出結果:


當然由於捕捉列表中捕捉的變數均被改為常量,在閉包內無法修改捕捉變數的值:


上述示例在閉包內部修改了捕捉變數i的值,但由於捕捉列表中的變數在捕捉後均被改為常量,因此會報錯。

四、結論

經過上述分析,closure capture主要有四個特性,
1)閉包capture某個變數等於copy一份這個變數,值型別的變數直接複製值,引用型別的變數直接複製引用值,與函式中引數傳遞類似,複製後的變數名同被捕捉的變數。

2)如果閉包所使用的外部變數的作用域未結束,閉包只是簡單使用這些外部變數,並不捕捉。
3)閉包捕捉髮生在閉包所使用的外部變數的作用域結束,而閉包或因被返回,或作為引數傳遞給其他函式而仍然存在時。
第2和第3點講的都是閉包捕捉的時機,其實可以總結為一句話,閉包捕捉髮生在其所使用的外部變數即將銷燬的時刻,也即你再不捕捉我就沒了。這也意味著,當閉包捕捉多個外部變數,而這些外部變數的作用域不同時,閉包按照各個外部變數作用域結束的先後次序進行變數捕捉,並非一次性捕捉。

4)閉包中如果定義了捕捉列表,閉包在定義時立即capture捕捉列表中所有變數,並將捕捉的變數一律改為常量,供自己實用。

閉包捕捉蘋果官方文件中介紹的非常簡略,上述只是所有的特性也是我多番實驗得出的結論,對於理解closure capture暫時應該是夠了。

五、鳴謝

由於文章寫的有點倉促,部分結論未經嚴謹論證就直接擺出來了,幸好部分網友大牛及時指正,真是無與倫比的感謝。他們是strider,小吻子, 來扶爺試玩個波。非常感謝他們,也歡迎各路大神繼續留言討論批評指正。

相關文章