🤔

図とObjectIdentifierを使用し、自動参照カウント(ARC)について深く理解する!

2025/02/16に公開

The Swift Programming Language(日本語版)輪読会を開催し、参加者のみなさんで議論し、自動参照カウント(ARC)についての理解が深まったので、図とSwiftのコードを使用し、わかりやすく解説します。

自動参照カウント(ARC)とは

Swiftは自動参照カウント(以下ARC)を使用して、アプリのメモリ使用状況を追跡および管理します。Swiftではほとんどの場合、メモリ管理について自身で考える必要はありません。ARCは、クラスインスタンスが不要になったときに、クラスインスタンスによって使用されていたメモリを自動的に解放します。
ただし、場合によっては、ARCがメモリを管理するために、各コード間の関係についてより多くの情報を必要とすることがあります。(強循環参照) その状況について下記で説明し、ARCでアプリの全てのメモリを管理できるようにする方法の解説を行います!

ARCの仕組み

クラスの新しいインスタンスを作成するたびに、ARCはそのインスタンスに関する情報を格納するためにメモリの一部を割り当てます。このメモリには、インスタンスの型に関する情報と、そのインスタンスに関連付けられた格納プロパティの値が保持されます。
さらに、インスタンスが不要になった場合、ARCはそのインスタンスが使用していたメモリを解放し、代わりにメモリを他の目的に使用できるようにします。これにより、クラスインスタンスが不要になったときにメモリ内のスペースを占有しないようにします。
ARCは各クラスインスタンスを現在参照しているプロパティ、定数、変数の数を追跡します。そのインスタンスへのアクティブな参照が少なくとも1つ存在する限り、ARCはインスタンスの割り当てを解除しません。
その割り当ての解除を防ぐために、クラスインスタンスをプロパティ、定数、または変数に割り当てると、そのプロパティ、定数、または変数はインスタンスへの強参照を作成します。参照は、強参照が残っている限り割り当てを解除できないため、「強い」参照と呼ばれています。

ARCの挙動(ARC in Action)

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) の初期化が進行中です")
    }
    deinit {
        print("\(name) のインスタンス割り当てが解除されました")
    }
}
var reference1: Person?
var reference2: Person?
var reference3: Person?
reference1 = Person(name: "ひなっこ") // ⚠️強参照1
// 出力: ひなっこ の初期化が進行中です
reference2 = reference1 // ⚠️強参照2
reference3 = reference1 // ⚠️強参照3

上記のコードでは、Person インスタンスへ 3 つの強参照があります。
上記のコードに下記のコードを書き加えてみる。

reference1 = nil
reference2 = nil

2つの変数にnilを代入してこれらの2つの強参照(元の参照を含む)を解除すると、1つの強参照が残り、Personインスタンスの割り当てが解除されません。

最後に、下記のコードを書き加えると、インスタンス割り当てが解除されます。

reference3 = nil
// 出力: ひなっこ のインスタンス割り当てが解除されました

ARCは、最後の3番目の強参照がなくなり、Personインスタンスを使用していないことが明らかになる時点まで、Personインスタンスの割り当てを解除しません。

私自身が理解に苦しんだ。インスタンスの割り当てと強参照について、次の章から解説します。

図でARCを理解する

輪読会を開催した際に熊谷さんが説明してくださった内容を参考にしながら作成した図で説明します。

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) の初期化が進行中です")
    }
    deinit {
        print("\(name) のインスタンス割り当てが解除されました")
    }
}
var reference1: Person?
var reference2: Person?
var reference3: Person?
reference1 = Person(name: "ひなっこ") // ⚠️強参照1
// 出力: ひなっこ の初期化が進行中です
reference2 = reference1 // ⚠️強参照2
reference3 = reference1 // ⚠️強参照3

①上記のコードの状態の図のインスタンスの割り当ての図

②下記のコードを書き加えたときのインスタンスの割り当ての図

reference1 = nil
reference2 = nil


上記の2つの変数にnilを代入する意味は、2つの強参照を解除する意味だったのかとようやく理解することができました。(参照型の値を書き換えてnilにしたので、reference3もnilになるのではないかという解釈が誤っていたことに気づいた。)
③下記のコードを書き加えたときのインスタンスの割り当ての図

reference3 = nil
// 出力: ひなっこ のインスタンス割り当てが解除されました

reference3にnilを代入することで最後の強参照を解除し、Personインスタンスを使用していないことが明らかになったので、Personインスタンスの割り当てが解除されました。

🤔💬図で理解したことによって、だいぶ理解できたぞ!これをSwiftのコードで証明できないかなと、復習がてらに輪読会後考えてみました。

ObjectIdentifierで理解する

ObjectIdentifierを使用することで、Swiftのコードで証明できそう。
クラスインスタンスやメタタイプの一意な識別子を保持するために使用される構造体。
https://developer.apple.com/documentation/swift/objectidentifier
ObjectIdentifierを使用して、強参照と強参照の解除について証明してみる。下記で紹介する①と②のコードはPlaygroundで実行することができるので、みなさんも試してみてください。

①reference3の強参照を解除しない

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) の初期化が進行中です")
    }
    deinit {
        print("\(name) のインスタンス割り当てが解除されました")
    }
}

var reference1: Person?
var reference2: Person?
var reference3: Person?

reference1 = Person(name: "ひなっこ")
reference2 = reference1
reference3 = reference1
let id1 = ObjectIdentifier(reference1!)
let id2 = ObjectIdentifier(reference2!)
let id3 = ObjectIdentifier(reference3!)

print("reference1のObjectIdentifier: \(id1)")
print("reference2のObjectIdentifier: \(id2)")
print("reference3のObjectIdentifier: \(id2)")

reference1 = nil

reference2 = nil

//reference3 = nil // ⚠️ここをコメントアウトして実行してみる

print(reference3?.name ?? "nameの値がありません")

if let ref1 = reference1 {
    let ref1Id = ObjectIdentifier(ref1)
    print("reference1のObjectIdentifier: \(ref1Id)")
} else {
    print("reference1はメモリ空間を参照していません。")
}

if let ref2 = reference2 {
    let ref2Id = ObjectIdentifier(ref2)
    print("reference2のObjectIdentifier: \(ref2Id)")
} else {
    print("reference2はメモリ空間を参照していません。")
}

if let ref3 = reference3 {
    let ref3Id = ObjectIdentifier(ref3)
    print("reference3のObjectIdentifier: \(ref3Id)")
} else {
    print("reference3はメモリ空間を参照していません。")
}

上記のコードを実行すると下記のように出力されます。

出力
ひなっこ の初期化が進行中です
reference1のObjectIdentifier: ObjectIdentifier(0x000060000021dfe0)
reference2のObjectIdentifier: ObjectIdentifier(0x000060000021dfe0)
reference3のObjectIdentifier: ObjectIdentifier(0x000060000021dfe0)
ひなっこ
reference1はメモリ空間を参照していません。
reference2はメモリ空間を参照していません。
reference3のObjectIdentifier: ObjectIdentifier(0x000060000021dfe0)

上記のコードを見ることでreference1、reference2、reference3は最初『0x000060000021dfe0』という同じメモリ領域を参照しているということがわかります。
reference1 = nilreference2 = nilにした後のreference3の値を見てみると、reference3のPersonインタンスの強参照は残っているので、次のプリント文を実行すると、
print(reference3?.name ?? "nameの値がありません") =ひなっこと出力されます。
reference1・reference2にnilを代入することで、reference1とreference2の強参照は解除され、reference1・reference2はメモリ空間を参照していません。という出力がされます。
reference3にnilを代入していないので、『0x000060000021dfe0』を強参照したままになっています。

②reference3の強参照を解除する

class Person {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) の初期化が進行中です")
    }
    deinit {
        print("\(name) のインスタンス割り当てが解除されました")
    }
}

var reference1: Person?
var reference2: Person?
var reference3: Person?

reference1 = Person(name: "ひなっこ")
reference2 = reference1
reference3 = reference1
let id1 = ObjectIdentifier(reference1!)
let id2 = ObjectIdentifier(reference2!)
let id3 = ObjectIdentifier(reference3!)

print("reference1のObjectIdentifier: \(id1)")
print("reference2のObjectIdentifier: \(id2)")
print("reference3のObjectIdentifier: \(id2)")

reference1 = nil

reference2 = nil

reference3 = nil

print(reference3?.name ?? "nameの値がありません")

if let ref1 = reference1 {
    let ref1Id = ObjectIdentifier(ref1)
    print("reference1のObjectIdentifier: \(ref1Id)")
} else {
    print("reference1はメモリ空間を参照していません。")
}

if let ref2 = reference2 {
    let ref2Id = ObjectIdentifier(ref2)
    print("reference2のObjectIdentifier: \(ref2Id)")
} else {
    print("reference2はメモリ空間を参照していません。")
}

if let ref3 = reference3 {
    let ref3Id = ObjectIdentifier(ref3)
    print("reference3のObjectIdentifier: \(ref3Id)")
} else {
    print("reference3はメモリ空間を参照していません。")
}

上記のコードを実行すると下記のように出力されます。

出力
ひなっこ の初期化が進行中です
reference1のObjectIdentifier: ObjectIdentifier(0x000060000021dfe0)
reference2のObjectIdentifier: ObjectIdentifier(0x000060000021dfe0)
reference3のObjectIdentifier: ObjectIdentifier(0x000060000021dfe0)
nameの値がありません
reference1はメモリ空間を参照していません。
reference2はメモリ空間を参照していません。
reference3はメモリ空間を参照していません。
ひなっこ のインスタンス割り当てが解除されました

上記のコードを見ることでreference1、reference2、reference3は最初『0x000060000021dfe0』という同じメモリ領域を参照しているということがわかります。
reference1・reference2・reference3にnilにした後のreference3の値を見てみると、reference3のPersonインタンスの強参照は解除されているので、次のプリント文を実行すると、
print(reference3?.name ?? "nameの値がありません") =nameの値がありませんと出力されます。
reference1・reference2・reference3すべてで強参照は解除され、reference1・reference2・reference3はメモリ空間を参照していません。という出力がされます。
そしてすべての強参照が解除され、Personインスタンスのdeinitが実行され、ひなっこ のインスタンス割り当てが解除されましたと出力されます。

おわりに

主催しているしわしわ輪読会に参加してくださった皆さまのおかげでARCについての理解を深めることができました。わからないことについて輪読会でわいわい議論をすることで理解を深めることができました!The Swift Programming Language(日本語版)の輪読を引き続き開催するので、興味がある方は是非ご参加ください!

参考文献

https://www.swiftlangjp.com/language-guide/automatic-reference-counting.html

https://ios-swift-reading-circle.connpass.com/event/341458/

https://developer.apple.com/documentation/swift/objectidentifier

Discussion