Swift 程式語言 — Auto Reference Counting(1)

讓我們一起來理解什麼是 ARC,並且學會解決 Strong Reference Cycle。

Jeremy Xue
Jeremy Xue ‘s Blog
16 min readFeb 7, 2020

--

Photo by Alina Grubnyak on Unsplash

前言:

Swift 使用自動引用計數(ARC)來追蹤和管理應用程式的記憶體使用情況。在大多數的情況下,這意味著記憶體管理在 Swift 中正常作業,你不需要自己去思考記憶體管理。當這些 class 實例不再被需要時,ARC 自動釋放這些記憶體。

然而,在某些狀況下,ARC 需要更多有關程式碼部分之間關係的訊息,以便為你管理記憶體。本篇章節介紹了這些情況,並且展示如何使用 ARC 來管理你應用程序的記憶體。在 Swift 使用 ARC 與在 Transitioning to ARC Release Notes 中描述的在 Objective-C 中使用 ARC 非常相似。

引用計數只適用於 class 實例。struct 和 enum 是值類型(value type),而不是(reference type),沒有透過引用存儲和傳遞。

|ARC 如何運作

每次創建新的 class 實例時,ARC 會分配一塊記憶體還存儲有關該實例的資訊。這個記憶體保存有關實例類型的資訊,以及該實例關聯的任何存儲屬性的值。

此外,當不再需要某個實例時,ARC 會釋放該實例使用的記憶體,以便該記憶體可用於其他目的。這樣可以確保不再需要 class 實例時,它們不會佔用記憶體空間。但是,如果 ARC 要釋放仍在使用的實例,則將無法再訪問該實例的屬性或調用該實例的方法。實際上,如果你嘗試訪問該實例,則你的應用很可能會 crash。

為了確保實例仍然需要時不會消失,ARC 會追蹤當前引用每個 class 實例的屬性、常數和變數的數量。只要仍存在至少一個對該實例的活動引用,ARC 將不會釋放該實例。

為此,無論何時將 class 實例分配給屬性、常數或變數,該屬性、常數或變數都會對該實例進行強引用(strong reference)。該引用被稱為 “強” 引用,因為它在該實例上保持強大持有,並且只要強引用仍存在,就不允許對其釋放。

|ARC

這是一個 ARC 如何運作的範例。此範例使用一個簡單的 class,名為 Person 作為開頭,該 class 定義了一個存儲常數屬性 name

Person class 具有一個初始化器,該初始化器設置實例的 name 屬性,並且印出訊息來表示正在進行初始化。Person class 還具有一個反初始化器,它在釋放該 class 的實例時印出訊息。

下一段程式碼定義三個類型為 Person? 的變數,這些變數用於後續程式碼片段中設置對新的 Person 實例的多個引用。因為這些變數是可選類型(Person?,而非 Person),所以它們會自動使用 nil 值初始化,並且目前不引用 Person 實例。

你現在可以創建一個新的 Person 實例,並且將其分配上列三個變數之一:

注意到在調用 Person class 初始化器時,會印出的訊息 “John Appleseed is being initialized”,這確認了初始化已經發生。

因為新的 Person 實例已經被分配給 reference1 變數,因此現在有一個從 reference1 到新的 Person 實例的強引用。因為至少有一個強引用,ARC 確保這個 Person 保持在記憶中,並且不會被釋放。

如果將同一個 Person 實例分配給另外兩個變數,則將對該實例的多建立兩個強引用:

現在,這個 Person 實例具有三個強引用。

如果你透過分配 nil 給兩個變數來斷開兩個強引用(包含原始引用),則將保留一個強引用,並且 Person 實例不會被釋放:

直到第三個(也是最後一個)引用之前,ARC 不會釋放 Person 實例,這時很明顯,你不再使用 Person 實例:

|Class 實例間的強引用循環

在上面的範例中,ARC 可以追蹤創建的 Person 實例的引用數量,並且在 Person 實例不再需要時釋放。

但是,程式碼是有可能會出現 class 實例永遠不會具有無強引用的狀況。這可能發生在兩個 class 實例互相有強引用存在,而使每個實例保持另一個實例存在。這就是所謂的強引用循環(strong reference cycle)。

透過將 class 之間的某些關係定義為弱引用或無主引用而不是強引用,可以解決強引用循環問題。在後面 “解決 class 實例之前的強引用循環中” 介紹這個過程。但是,再學會如何解決強引用循環前,了解這種循環是如何產生是很有用的。

這是一個如何意外創建強引用循環的範例。此範例定義了 PersonApartment 兩個 class,來對公寓和其居民進行建模:

每個 Person 實例都具有一個類型為 Stringname 屬性和一個可選的 apartment 屬性,其初始化為 nilapartment 屬性是可選的,因為一個人不總是擁有公寓。

相同的,每個 Apartment 實例都具有 String 類型的 unit 屬性,並且具有一個可選的 tenant 屬性,其初始化為 niltenant 屬性是可選的,因為公寓不一定總是有房客。

這兩個 class 都定義了反初始化器,它顯示出該 class 實例正在被反初始化的事實。這使你能夠查看 PersonApartment 實例是否如預期釋放。

下段程式碼定義了兩個可選類型的變數,稱為 johnunit4A,這兩個變數將在下面設置為特定的 ApartmentPerson 實例。由於是可選的,這些變數具有 nil 的初始值:

現在你可以創建特定的 PersonApartment 實例,並且將這些實例分配給 johnunit4A 變數:

這是在創建和分配這兩個實例後,強引用的樣子。現在,john 變數具有對新的 Person 實例的強引用,而 unit4A 變數具有對 Apartment 實例的強引用。

現在,你可以將兩個實例連接再一起,以便讓該人具有一個公寓,並且該公寓具有一個房客。請注意,驚嘆號(!)用於展開和訪問存儲在 johnunit4A 可選變數中的實例,以便這些實例的屬性可以被設置:

在兩個實例連接再一起後,強引用的樣子:

不幸的是,連接這兩個實例會在他們之間產生強引用循環。現在,Person 實例具有對 Apartment 的強引用,而 Apartment 實例具有對 Person 的強引用。因此,當你斷開由 johnunit4A 變數持有的強引用,引用計數不會降為 0,並且實例不會被 ARC 釋放。

請注意,當你將這兩個變數設置為 nil 時,反初始化器也不會被調用。強引用循環阻止了 PersonApartment 實例被釋放,導致在你的應用中發生記憶體洩漏(memory leak)。

這是將 johnunit4A 變數設為 nil 後,強引用的樣子如下:

PersonApartment 實例之間的強引用將會保留著並且無法斷開。

|解決 Class 實例間的強引用循環

當使用 class 類型的屬性時,Swift 提供兩種方式來解決強引用循環:弱引用(weak reference)和無主引用(unowned reference)。

弱引用和無主引用使在循環中的一個實例引用另一個實例時,不會保持強引用。如此實例間可以互相引用,而不會產生強引用循環。

當另一個實例具有較短的生命時間時(也就是另一個實例可以先被釋放時),請使用弱引用。在上面的 Apartment 範例中,對公寓來說,在其生命時間內的某個時間點沒有住戶 tenant 是合理的。因此,弱引用是一個適合的方法來斷開引用循環。

|Weak References

弱引用是一種不會保持強大持有的引用,因此不會阻止 ARC 處理引用的實例。這個行為可以防止引用成為強引用循環的一部分。你透過在屬性或變數前面放置 weak 關鍵字來表示弱引用。

因為弱引用不會對其所引用的實例保持強引用,所以有可能在弱引用仍在引用時,該實例被釋放。因此,當釋放他所引用的實例時,ARC 自動設置弱引用為 nil。而且,由於弱引用需要在運行時(runtime)允許將其值更改為 nil,因此它們始終被宣告為可選類型的變數,而非常數。

你可以檢查弱引用的值是否存在(就像是其他可選值),並且你將永遠不會引用不再存在的無效實例。

當 ARC 將弱引用設為 nil 時,不會調用屬性觀察器。

下面的範例與上面的 PersonApartment 範例相同,但有一個重要的區別。這次 Apartment 類型的 tenant 屬性被宣告為弱引用:

像之前一樣,創建兩個變數(john 和 unit4A)間的強引用以及兩個實例之間的連接:

這是兩個實例連接後,引用的樣子如下:

Person 實例仍然具有對 Apartment 實例的強引用,但現在 Apartment 實例具有對 Person 實例的弱引用。這意味著當你透過設置 nil 來斷開 john 變數所持有的強引用時,將不再具有對 Person 實例的強引用:

因為不再有對 Person 實例的強引用,因此其被釋放,並且 tenant 屬性被設置為 nil

唯一剩餘來自 unit4A 變數對 Apartment 實例的強引用。如果你斷開該強引用,則不會再有對 Apartment 實例的強引用:

因為再也沒有對 Apartment 實例的強引用,因此它也被釋放:

在使用垃圾回收(Garbage Collection)的系統中,弱指針(weak pointer)有時用於實現簡單的緩存機制,因為沒有強引用的對象只有在記憶體壓力觸發垃圾回收時才被釋放。然而,對於 ARC 來說,值會在它們最後一個強引用被刪除時立即釋放,這使弱引用不適用於此目的。

|Unowned References

類似於弱引用,無主引用也不會對其引用的實例保持強引用。但是,與弱引用不同,當另一個實例具有相同或更長的生命時間時,將使用無主引用。你可以透過在屬性或變數前面放置 unowned 關鍵字來表示無主引用。

無主引用預期始終有值。因此,ARC 永遠不會設置無主引用的值為 nil,這意味著無主引用是被定義為非可選類型的。

*Important

僅有當確認引用始終引用到不被釋放的實例時,才使用無主引用。如果你在實例釋放後嘗試訪問無主引用的值,則會收到運行錯誤。

以下範例定義了兩個 class — CustomerCreditCard,它們分別為銀行客戶和可能的信用卡建模。這兩個 class 各自存儲另一個實例存儲作為屬性。這種關係有可能產生強引用循環。

CustomerCreditCard 之間的關係與上面弱引用範例中的 ApartmentPerson 之間的關係略有不同。在此數據模型中,客戶可能有或沒有信用卡,但是信用卡始終與該客戶相關聯。信用卡實例永遠不會比所引用的客戶存活得久。為此,Customer class 具有可選的 card 屬性,但是 CreditCard class 具有 unowned (且非可選)的 customer 屬性。

此外,新的 CreditCard 實例只能透過傳遞 number 值和 customer 實例給自定義的 CreditCard 初始化器創建。這樣可以確保在創建 CreditCard 實例時,CreditCard 實例始終具有與其關聯的 customer 實例。

因為信用卡始終具有一個客戶,所以可以定義其 customer 屬性為無主引用,來避免強引用循環:

CreditCard class 的 number 屬性定義為 UInt64 類型而不是 Int,來確保 number 屬性的 number 屬性的容量夠大,能夠在 32-bit 和 64-bit 系統上存儲 16 位卡號

下一個程式碼片段定義了一個可選的 Customer 變數,稱為 john,該變數將用於存儲對特定客戶的引用。由於是可選的,該變數具有 nil 的初始值:

現在,你可以創建一個 Customer 實例,並將其用於初始化新的 CreditCard 實例並將其分配給該客戶的 card 屬性:

現在你已經將兩個實例連接在一起,則該引用的樣子如下:

現在,Customer 實例具有對 CreditCard 實例的強引用,並且 CreditCard 實例具有對 Customer 實例的無主引用。

因為無主引用的因素,當斷開 john 變數所持有的強引用時,將不再有對 Customer 實例的強引用:

由於沒有對 Customer 實例的強引用,因此該實例被釋放。在這個情況發生後,將不再有對 CreditCard 實例的強引用,該實例也被釋放:

上面最後一段程式碼片段顯示了,在 john 變數設為 nil 之後,Customer 和 CreditCard 實例的反初始化器皆印出其 “反初始化” 的訊息。

上面的範例展示了如何安全地使用無主引用。當你需要關閉運行時安全檢查的情況下(例如:性能因素),Swift 還提供了不安全的無主引用。與所有的不安全操作一樣,你有責任檢查程式碼的安全性。透過編寫 unowned(unsafe) 來表示不安全的無主引用。如果在釋放引用的實例後嘗試訪問不安全的無主引用,你的程序將嘗試訪問該實例之前的記憶體位置,這是不安全的操作。

|無主引用和隱式展開的可選屬性

上面關於弱引用及無主引用的範例涵蓋了兩個較常見需要斷開強引用循環的情況。

PersonApartment 範例展示了一種情況,其中兩個屬性(都允許為 nil)有可能導致強引用循環。使用弱引用是這種狀況最好的解決方式。

CustomerCreditCard 範例展示了一種狀況,其中一個允許為 nil 的屬性和另一個不能為 nil 屬性可能導致強引用循環。使用無主引用是這種情況最好的解決方式。

然而,還有第三種情況,其中兩個屬性都始終有值,並且初始化完成後,兩個屬性都不應該為 nil。在這種情況下,將一個 class 的一個無主屬性和另一個 class 隱式展開的可選屬性結合起來很有用。

下列的範例定義了兩個 classCountryCity,每個 class 都將另一個 class 的實例存儲為屬性。在此數據模型中,每個國家必須總是有一個首都,並且每個城市必須總是有一個國家。為此,Country class 具有 capitalCity 屬性,而 City class 具有一個 country 屬性:

為了建立兩個 class 之間的依賴關係,City 的初始化器接收一個 Country 實例,並且存儲該實例在其 country 屬性中。

City 的初始化器被 Country 的初始化器調用。但是,Country 的初始化器不能傳遞 selfCity 的初始化器,直到新的 Country 實例被完全初始化為止,如 Two-Phase Initialization 中所述。

為了滿足此要求,你可以將 CountrycapitalCity 屬性宣告為一個隱式展開的可選屬性,在其類型標注(City!)結尾由感嘆號來表示。這意味著與任何其他可選項相同,capitalCity 屬性具有默認值 nil,不需要展開其值也能夠訪問它,如 Implicitly Unwrapped Optionals 中所述。

由於 capitalCity 具有 nil 的默認值,因此,一旦 Country 實例在其初始化器中設置其 name 屬性,便認為該 Country 實例已經被完全初始化。這意味著,一但設置了 name 屬性,Country 初始化器就可以開始引用並傳遞隱式的 self 屬性。因此,當 Country 初始化器設置自己的 capitalCity 屬性時,Country 初始化器可以將 self 作為 City 初始化器的參數之一傳遞。

這些意味著你可以創建 CountryCity 實例在單個語句中,而不需產生強引用循環,並且可以直接訪問 capitalCity 屬性,而不需要使用驚嘆號來展開其可選值:

在上面的範例中,使用隱式展開可選項意味著所有兩階段初始化器的要求都得到滿足。一旦初始化完成,capitalCity 屬性就可以像非可選值一樣使用和訪問 ,同時仍避免了強引用循環。

--

--

Jeremy Xue
Jeremy Xue ‘s Blog

Hi, I’m Jeremy. [好想工作室 — iOS Developer]