17. ARC

ARC(Automatic Reference Counting)是一種追蹤與管理記憶體使用狀況的技術,在大多數的情況下,我們不用去理會記憶體如何配置與回收,但在少數情況下,我們必須要手動去調整一下ARC運作機制,而這種情狀只有在類別型態才會發生。

17.1 ARC機制

當一個類別要被實體化時,作業系統會為這個實體配置一塊記憶體空間,然後將某個變數或常數指向這個記憶體空間。當這個實體已不再使用時,作業系統會將這個實體所佔用的記憶體空間回收,並等待下一次分配。如何決定這塊記憶體是否還需要使用靠的是一個計數器,稱為參考計數器。每當一個變數或常數指向一個實體時,該實體的參考計數值就增加1,每當一個指向該實體的變數或常數不再使用時,參考計數值就會減1,一旦參考計數值為0的時候,代表已經沒有任何一個變數或常數使用這個記憶體,此時作業系統就會將這塊記憶體回收。

例如下面這個例子,當變數x指向SomeClass的實體時,該實體的參考計數值為1,當變數y = x時,參考計數值為2,代表有2個變數目前正在使用這個實體。

class SomeClass {

}

var x = SomeClass()
var y = x

為了確認記憶體是否如預期被回收,我們在SomeClass類別中加上反初始化器,當該實體被回收時會觸發反初始化器,所以我們可以知道這個實體所佔用的記憶體被釋放了。另外將變數x的型態調整為Optional型態,這樣才能設定x為nil讓參考計數值減1。

class SomeClass {
    deinit {
        print("我被回收了")
    }
}

var x: SomeClass? = SomeClass()
var y = x

接下來依序將x與y指定為nil,順序無所謂,當這兩個變數都為nil時,參考計數值會變成0,此時可以看到反初始化器運作,代表SomeClass實體所佔用的記憶體被回收了。

y = nil
x = nil
// Prints "我被回收了"

這套機制在大多數的情況下運作良好,但是當兩個類別彼此互相參考時,這時ARC機制就會出現問題。例如下面這段程式碼,Person類別中的屬性has用來指向Phone類別,而Phone類別中的屬性owner用來指向Person類別。所以透過Person可以取得他的手機資料,另外透過Phone也可以知道該手機屬於誰的,雙向都可以查詢,很方便的設計。

class Phone {
    var owner: Person?
    deinit {
        print("phone被回收了")
    }
}

class Person {
    var has: Phone?
    deinit {
        print("person被回收了")
    }
}

當Phone與Person這兩個類別都實體化,並且分別將各自的實體存入對方相關的屬性時,兩個實體的參考計數值為2。

var peter: Person? = Person()
var phone: Phone? = Phone()

peter?.has = phone
phone?.owner = peter

現在我們將變數peter與變數phone設定為nil,這時會發現兩個實體的反初始化器沒有運作,因為現在這兩個實體的參考計數值為1,還沒有到0,所以作業系統不會進行記憶體回收。

現在產生了嚴重的問題,這兩個實體的記憶體空間不會被回收,但我們也沒辦法使用他們,因為唯一能使用他們的兩個變數peter與phone現在已經是nil。這種現象就稱為memory leak(記憶體流失)。

17.2 強參考、弱參考與無主參考

當變數或常數指向一個實體的時候,代表該變數或常數與實體間建立了一個參考。參考有強參考strong reference、弱參考weak reference與無主參考unowned reference三種類型,預設為強參考。強參考的意思是,只要參考一經建立,參考計數就加1。到目前為止我們建立的參考全部都是強參考。所以如果兩個以上的類別間出現了循環參考,最後要釋放記憶體時,一不小心就會出現memory leak。這時要避免最後造成memory leak的話,就必須要先打斷循環參考。有幾種作法。

如果形成循環參考的參考類型都是強參考,就必須記得先將循環中的一個參考設為nil。例如前面的例子,在peter與phone都設為nil之前先將peter的has屬性設為nil,這樣就將循環參考打斷了。接下來將peter與phone設為nil時就可以看到兩個實體的反初始化器被觸發。

peter?.has = nil
peter = nil
phone = nil
// Prints "phone被回收了"
// Prints "person被回收了"

這樣做的確可以避免memory leak發生,但就是必須記得要將循環打斷。但往往問題發生的原因就是我們不記得要做這件事,或者也不確定是否會形成循環參考,所以不斷地把不需要先設定為nil的屬性都設定為nil。每次都這樣做實在太麻煩了。

這時候弱參考就可以派上用場。弱參考也是一種參考,但是弱參考建立後參考計數值不會增加。循環參考中的任何一個參考都可以指定成弱參考,但不一定合理。以Phone與Person類別為例,將弱參考放在Person的has屬性可能比較適合,因為我們認為當手機壞了或是掉了找不回來時,Person中的has屬性應該自動變成nil,代表這個人沒有手機了。如果是放在Phone的owner,表示當人不見時,該手機的owner就會變成nil,好像不太適合。

弱參考只要在變數宣告前加上weak即可。由於弱參考的屬性值會自動改為nil,因此弱參考所在的屬性必須使用var宣告,資料型態也必須使用Optional型態。如下圖中的虛線表示弱參考,所以phone所指向的實體的參考計數值還是維持1。

class Phone {
    var owner: Person?
    deinit {
        print("phone被回收了")
    }
}

class Person {
    weak var has: Phone?
    deinit {
        print("person被回收了")
    }
}

這時只要將peter與phone這兩個變數設定為nil時,就可以看到兩個實體的反初始化器都啟動了,代表這兩個實體所在的記憶體已經被回收。

peter = nil
phone = nil
// Prints "phone被回收了"
// Prints "person被回收了"

若peter的手機掉了,這時候透過peter去讀取has屬性內容時就會讓程式當掉,因為has的屬性值已經被改為nil,所以要記得先檢查has是否為nil。

phone = nil
if peter?.has == nil {
    print("手機掉了")
}
// Prints "phone被回收了"
// Prints "手機掉了"

除了弱參考可以打斷循環參考外,無主參考也可以。跟弱參考不同的地方是,弱參考的值會在參考對象回收後改為nil,但無主參考不會,因此無主參考用在當屬性需要被設定為常數或者不希望出現nil的變數時,要將該屬性設定為無主參考。無主參考在屬性前加上unowned修飾子即可。例如Person與IdCard之間形成的循環參考。IdCard為身份證中相關資料形成的類別,每張身份證必定屬於某個人,正常情況下不可能出現一張空白身份證,或者身份證擁有者換成別人。因此IdCard中的屬性Person應該要設定為常數。這與類別Phone的狀況不同,由於生產完的手機可能還沒賣出去,所以Phone中的owner屬性為Optional型態,並且Phone也可以賣給別人。但身份證不可能出現這樣的狀況,這時就應該使用無主參考了,因為弱參考無法使用在常數屬性上。下圖中owner的虛線箭頭為空心箭頭表示這是一個無主參考。

var peter: Person? = Person()
peter?.idcard = IdCard(person: peter!, id: "A123456789")

當peter被設定為nil後,IdCard的實體也就自然消失了。但要特別注意的是,如果在peter被設定為nil之前,有變數或常數儲存了IdCard的實體(例如下圖中的變數a),這時當peter被設定為nil時,IdCard實體不會消失,且IdCard實體中的owner屬性因為是無主參考的關係也不會變成nil,所以如果變數a透過owner去存取owner的值,必定導致程式當掉。

17.3 Closure形成循環參考

類別中的closure在某些情況下也會形成循環參考而導致memory leak狀況發生。來看下面這個通訊錄的例子。在AddressBook中有兩個屬性,分別存放名firstname與姓lastname,然後一個初始化器來初始化這兩個屬性。

class AddressBook {
    var firstname: String
    var lastname: String
    
    init(firstname: String, lastname: String) {
        self.firstname = firstname
        self.lastname = lastname
    }
}

我們都知道姓名的顯示在不同國家有不同的顯示方式,例如華人是先姓再名,歐美國家通常式先名再姓。這時我們在AddressBook中增加一個屬性name,用來組合firstname與lastname,並且把這個屬性設計成closure形式,方便在不更動原始碼的情況下,讓呼叫者自由調整姓在前還是名在前。在closure中先給一個預設的順序,先姓後名,符合中文姓名習慣。由於在這個closure中要能捕獲本身的屬性firstname與lastname的值,因此必須加上self,而closure中的程式碼因為有self的關係,所以var之前必須加上lazy修飾子,確保這段程式碼在實體產生後才會初始化。

class AddressBook {
    var firstname: String
    var lastname: String
    lazy var name: () -> String = {
        self.lastname + self.firstname
    }
    
    init(firstname: String, lastname: String) {
        self.firstname = firstname
        self.lastname = lastname
    }
    
    deinit {
        print("我被回收了")
    }
}

var contact: AddressBook?
contact = AddressBook(firstname: "大明", lastname: "王") 
print(contact!.name())
// Prints "王大明"

現在我們如果不喜歡預設的先名後姓,我們就可以自己調整順序了,甚至英文姓名想要全大寫或全小寫顯示都可以自己決定。

contact = AddressBook(firstname: "大明", lastname: "王")
contact!.name = {
    "尊敬的" + contact!.firstname + contact!.lastname
}
print(contact!.name())
// Prints "尊敬的大明王"

不論有沒有修改預設的closure,只要我們使用了屬性name就會形成強參考的循環參考,之後當contact被設定為nil後就會發現反初始化函數沒有運作,出現memory leak了。這個循環參考來自與AddressBook的實體與closure中的self,如果closure中沒有self保留字,或是變數contact沒有使用到name屬性,循環參考都不會形成。

要解決這個循環參考,必須在closure第一行定義一個捕獲列表,列表中宣告self為unowned參考類型就可以了。之後即使改寫了預設的closure也不需要在改寫的程式碼中再寫一次捕獲列表。

class AddressBook {
    var firstname: String
    var lastname: String
    lazy var name: () -> String = {
        [unowned self] in
        self.lastname + self.firstname
    }
    
    init(firstname: String, lastname: String) {
        self.firstname = firstname
        self.lastname = lastname
    }
    
    deinit {
        print("我被回收了")
    }
}

發表迴響