🍩

循環参照を避けるための基礎知識

2021/12/31に公開
1

まえがき

この文章は書籍Effective Swiftのための下書きの一部「必ずしもクロージャで[weak self]が必要ないことも理解しよう」の項目をアルファ版として公開してみます。

私の勘違いがあったりするかもしれません。できればコメントください。

なお、下書きのためこちらのタイミングで非公開にすることはあります。

必ずしもクロージャで[weak self]が必要ないことも理解しよう

「@escapingなんて、あんなのただの飾りです!」

循環参照を発生させメモリリークを起こさないようにするために、
キャプチャリストに[weak self]をキャプチャするようなクロージャを作成することがあるはずです。

大抵の場合はその方法はコストが低く循環参照を避けられる手順なのは間違いないのですが、
必ずしも循環参照によるメモリリークを防ぐ場合に[weak self]が必要ではないということも理解すると、コード自体がより自分自身の意味を明確に表現できます。

値型と参照型

循環参照の説明のために、基礎知識として値型と参照型について説明します。
Swiftの型(クラス、構造体、列挙型、アクター)は値型と参照型の2つに分類されます。

  • 値型
    • 構造体
    • 列挙型
  • 参照型
    • クラス
    • アクター

値型と参照型の大きな違いとして、値型を別の変数へ代入しようとした場合はコピーされ、元とは独立した別のインスタンスが作成され新しいメモリ領域を確保します。
これによって値自体は同じものがコピーされるものの、コピー元のメモリ領域に確保されているインスタンスは共有されず不変となります。

// Value type example
struct S {
    var data: Int = -1 
}
var a = S()
var b = a	   // aはbへコピー
a.data = 42	 // a.dataを変更
println("\(a.data), \(b.data)")	// => "42, -1"

TODO: 図

一方、参照型を別の変数へ代入しようとした場合は参照を渡します(暗黙的に共有インスタンスが作成され、その参照を渡す)。

// Reference type example
class C { 
    var data: Int = -1 
}
var x = C()
var y = x		// xはyに参照が代入された
x.data = 42	// xの参照するdataを変更する(とyのも変わる)
println("\(x.data), \(y.data)")	// =>"42, 42"

TODO: 図

値型には実装の流れをシンプルにするメリットがあり、コードの可読性やマルチスレッド環境での安全性を高めます。
参照型は実装自体を簡単にでき、比較すると新しくメモリ領域を確保しない分効率が高く、速度的にも高いというメリットはあるでしょう。
もちろんメモリ効率や速度が重要になる場面において参照型のそれが役立つのであって、
デバッグが難しいコードや機能追加が難しいコードになるデメリットとを比較して値型と参照型を選択すべきです。

循環参照によるメモリリークに話を戻すと、値型というのは値をコピーしているためにそのコピー元がどうなろうと知ったことではありません。つまり参照型は参照元を参照しつづける必要があり、その参照方法によって循環参照の原因を作るわけです。

次は循環参照がなぜメモリを解放できない状態(つまりメモリリークに)繋がるのかを説明するため、Swiftが採用している参照カウント方式というアーキテクチャとARCについて説明します。

参照カウンタ

Swiftでは参照カウント方式というメモリ管理アーキテクチャを採用しています。
参照カウント方式とは参照される際にカウントをインクリメントし、
不要になった際にデクリメントします。
そしてこのカウントが0になった場合にオブジェクトを解放する方式です。
つまりそれぞれの参照型のオブジェクトには寿命として他オブジェクトからの参照カウントを保持しているというわけです。

TODO: 図

State -参照-> classA // +1
class Person {
    let name: String

    init(name: String) { 
        self.name = name 
    }
}

// johnの参照カウントは1
let john = Person(name: "John Appleseed")
// johnの参照カウントが0でないため、
// この段階ではjohnのオブジェクトは解放されない

上記の例に何ら問題はありません。

この参照カウント方式はシンプルさはメリットではあるのですが、参照カウントが適切にインクリメントやデクリメントされることが前提となるため、それをミスすると簡単にメモリリークするという欠点について説明しようと思います。

脚注:
(歴史的には同じく参照カウント方式を採用しているObjective-Cは、参照カウントを増減させるコードをiOS5以前は記述していました。
しかしそのパターンを理解すればあとは難しいことでもなかったのですが人間はミスをしたり、パターンがそもそもわからないという点もありました。そのためObjective-CではiOS 5の時代にはAutomatic Reference Counting(ARC)という技術が追加され、
コードのコンパイル時に参照カウントを増減させるコードを自動で挿入してくれるようになり、それまでのマニュアルでの参照カウントの増減方法をManual Reference Counting(MRC)として区別するようになりました。そしてSwiftでも参照カウント方式でARCが使われるようになったわけです)

例としてdoブロックを使い、そのスコープを抜ける際にオブジェクトが自動で解放されるコードを示します。

class Person {
    let name: String

    init(name: String) {
        self.name = name
    }

    deinit {
        // deinitを追加し、解放されたことがわかるようにする
        print("\(name) is being deinitialized")
    }
}

do { 
    // johnの参照カウントは1
    let john = Person(name: "John Appleseed")
    // ARCはjohnの参照はこのdoに限定されていることで、
    // ここでjohnのカウントを下げるコードを自動で挿入する。
    // カウントを下げるコードによって-1されて結果参照カウントは0になる。
}
// この時点でjohnにはアクセスできないが、解放されてもいる。

参照型の参照方法

話題を参照型の参照方法に進めます。参照方法は3つに別れ、その特徴は次のようになります

  • 強参照 storng
    • 参照先が解放されることはない前提
  • 弱参照 weak
    • 参照先が解放されると参照元オブジェクトはnilになる
  • 非所有参照 unowned
    • 参照先の解放後に参照元オブジェクトにアクセスするとクラッシュする

非所有参照はファーストインプレッションでは危険に思えて存在意義を疑ってしまいますが、
Objective-Cでもunsafe_unretainedが存在していて、ARCが導入されたタイミングでweakが新しく作成されました。
たとえば参照先の解放が起こらない前提のコードで解放が起こってしまった場合、それは想定しなかった不具合の可能性はあります。
しかし想定しなかった不具合なのかそれとも単に解放されてしまったミスなのかを判断して対処するのが恒久的な対処であり、
もし開発者自身がunonwedを使っているのならそれはミスではなく、コード自身からそれをミスではなく想定外だと考えられます。

強参照

強参照を説明するためにクラスPersonとApartmentを定義したサンプルを示します。

class Person {
    let name: String
    var apartment: Apartment?

    init(name: String) { 
        self.name = name 
    }
    
    deinit { 
        print("\(name) is being deinitialized") 
    }
}

class Apartment {
    let unit: String
    var tenant: Person?

    init(unit: String) {
        self.unit = unit
    }

    deinit { 
        print("Apartment \(unit) is being deinitialized") 
    }
}

残念なことにこれらはお互いに強参照しており循環参照の関係にあります。

TODO図:

Person -strong-> Apartment
Person <-strong- Apartment

そしてこの循環参照の関係は次のようにそれぞれをnilにしただけではdeinitが実行されません。

// johnの参照カウントは1
var john: Person? = Person(name: "John Appleseed")
// unit4Aの参照カウントは1
var unit4A: Apartment? = Apartment(unit: "4A") 

// unit4Aに参照カウント+1で2
john!.apartment = unit4A
// johnに参照カウント+1で2
unit4A!.tenant = john

// johnに参照カウント-1で1
john = nil
// unit4Aに参照カウント=1で1
unit4A = nil

このように循環参照をしている状態でも、johnとunit4Aの参照に対してnilにセットしていけばdeinitは実行されます。

// johnの参照カウントは1
var john: Person? = Person(name: "John Appleseed")
// unit4Aの参照カウントは1
var unit4A: Apartment? = Apartment(unit: "4A") 

// unit4Aに参照カウント+1で2
john!.apartment = unit4A
// johnに参照カウント+1で2
unit4A!.tenant = john

// unit4Aに参照カウント-1で1
john!.apartment = nil
// johnに参照カウント=1で1
unit4A!.tenant = nil

// johnに参照カウント-1で0
john = nil
// unit4Aに参照カウント=1で0
unit4A = nil

しかしこのように短いコードでも循環参照させてしまうとそこそこ複雑です。
循環参照を避けるために強参照ではなく弱参照および非所有参照によって循環参照によって避けられます。

弱参照

次の例ではApartmentのtenantプロパティを弱参照としてweak指定し、
それぞれをnilにするコードのみでdeintが実行されるようになります。

TODO: 図

Person -strong-> Apartment
Person <-weak- Apartment
class Person {
    let name: String
    var apartment: Apartment?

    init(name: String) { 
        self.name = name 
    }
    
    deinit { 
        print("\(name) is being deinitialized") 
    }
}

class Apartment {
    let unit: String
    weak var tenant: Person?

    init(unit: String) {
        self.unit = unit
    }

    deinit { 
        print("Apartment \(unit) is being deinitialized") 
    }
}

// johnの参照カウントは1
var john: Person? = Person(name: "John Appleseed")
// unit4Aの参照カウントは1
var unit4A: Apartment? = Apartment(unit: "4A")

// unit4Aの参照カウント+1で2
john!.apartment = unit4A
// weakなのでjohnの参照カウント+0で1のまま
unit4A!.tenant = john

// jhonの参照カウントは-1で0。jhonが解放されunit4Aの参照が-1で1
john = nil
// unit4Aの参照カウントは-1で0
unit4A = nil

ここでわかるように、弱参照は片方からの指定でも充分に相互による強参照の循環参照を避けられます。
非所有参照による循環参照を防ぐ内容については省略します。

クロージャによる循環参照

クロージャ自身を分類すると参照型であり、クロージャの処理で実行時に外部のコンテキストから定数/変数をキャプチャします。
キャプチャは値型なら値をコピーし、参照型ならその参照を取得します。
参照型であるクラスの強参照による循環参照があったように、
参照型であるクロージャのキャプチャでも循環参照は発生します。

例としてPersonのfullNameクロージャを用意し、それが参照型であるがためにPerson自身を強参照している例です。

Person -strong-> fullName: () -> String
fullName: () -> String -strong-> Person
class Person {
    let firstName: String
    let lastName: String

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }

    deinit {
        print("\(firstName) is being deinitialized")
    }

    lazy var fullName: () -> String = { /* [unowned self] */ in
        "\(self.firstName) \(self.lastName)"
    }
}

do {
    let john = Person(
        firstName: "John",
        lastName: "Appleseed"
    )

    print(john.fullName())
}

もちろんこれはfullNameクロージャのキャプチャリストに[weak self]としたり、
[unowned self]とすることで循環参照は解決可能です。
この場合、弱参照と非所有参照のどちらが適しているかでいうと非所有参照でしょう。
そもそもfullNameクロージャを実行している自分自身がnilである可能性はないのです。
言い換えるとjohnが解放されているならfullNameクロージャは呼び出せません。
もし呼び出せるなら何かしらの不具合が潜んでいるはずです。

何がいいたいかを整理します。
非所有参照によって循環参照を解決している場合、その参照先は参照元より先に解放されるべきではないことをコードが表現できます。
コードでその設計の意図を表現できるということは重要で、
例えばコードコメントを書いてもそのコメントが実装と離れてしまうことや自然言語のミスなどもあります。
コード自体に制約を科していくとでより強い制約になるために、意図を表現することは有効でしょう。

他の例として非所有参照によってコードを記述している箇所がクラッシュした場合を考えてみます。

func sample(completion: () -> ()) {
    login { // 何かの非同期処理
        completion { [unonwend self] in
            self?.view.text = ...
        }    
    }    
}
// 考え:
// unownedに設定してある...
// 本当はselfは解放されるべきではないので修正すべきはここではない!
// (ここを修正してもいいかもしれないが、他のコードをみてみよう)

この例ではクラッシュした原因の対処としてweakにしてしまうのももちろんありですが、
そもそも別のミスで解放されるべきでないオブジェクトが先に解放されてしまっているという不具合もありえるわけです。
そうなった場合、本質的に修正すべきは他の箇所であって、表面上に見えている以外の部分でまた別の不具合があり得ます。
言い換えると迅速に火消しを行うことは重要ではありますが、火事になりやすい環境であれば別の火事もまた発生します。
火消しの後に本質的な改善と仕組み化をすることを考慮したいものです。

しかし、適しているから非所有参照にすべきかというとそうは言い切れません。
弱参照でもやりたいことは満たしています。
Person型が自身を参照している場合は誰がみてもわかりやすい例ですが、sampleメソッドの例は他の実装をみないと実際は判断できません。
つまり弱参照にすることでやりたいことの基準を満たせるならそれで良いことが多いはずです。
自分たちのチームでどのようにしていくかを考える点でしょう。

クロージャによる循環参照が強参照でも解決されるパターン

弱参照や非所有参照によってクロージャの循環参照を解決する点を解説しましたが、
クラス間の強参照で循環参照を解決した方法と同じように、
クロージャをnilによることで相互参照を解除できるために循環参照もまた解決は可能です。

クロージャをOptionalにしnilをセットすることで循環参照を解決する例を示します。

class Person {
    let firstName: String
    let lastName: String

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }

    deinit {
        print("\(firstName) is being deinitialized")
    }

    lazy var fullName: (() -> String)? = {
        "\(self.firstName) \(self.lastName)"
    }
}

do {
    let john = Person(
        firstName: "John",
        lastName: "Appleseed"
    )

    print(john.fullName?())
    john.fullName = nil
}

この例で示したいのは、循環参照を避けるための手段が強参照を使わないという手段だけではないということです。
この例のようにOptionalとすることで実行時にクロージャがあるかないかの確実性が薄くなったり、nilをセットするタイミングを考えてコードを書くのが良いとは言い切れません。

@escapingがなくてもクロージャはescapingされることもある

関数の引数としてクロージャを渡すとき、
そのクロージャのパラメータに@escapingと書くことで、
クロージャが関数をエスケープされることを明示できます。
具体的には関数の引数として渡したクロージャをプロパティに保持させる場合、
コンパイラから@escapingを書くことを促されます。
つまり@escapingがあるかどうかを目印にし、
それがなければ循環参照の対策をしなくていいように思えます。
しかし実際はそんなことはありません。
言い換えると関数の引数に@escapingがあることは、
循環参照を避けるために気を付ける要素にはなりますが、
逆にそれがないことが循環参照を気にしないでいいわけではないということです。

例はクロージャをオプショナルとした場合を示します。
この場合、
引数のクロージャは@escapingがないのにもかかわらずコンパイルエラーにはなりませんし、
強参照のままなので循環参照を解決していないません。

import UIKit

var greeting = "Hello, playground"

class Person {
    let firstName: String
    let lastName: String

    private let fullNameFetcher = FullNameFetcher()

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }

    deinit {
        print("\(firstName) is being deinitialized")
    }

    func fullName() {
        fullNameFetcher.perform { /* [unowned self] in */
            print("\(self.firstName) \(self.lastName)")
        }
    }
}

class FullNameFetcher {
    private var closure: () -> () = { }

    init() {}

    // クロージャがOptionalな場合、
    // @escapingがなくてもコンパイルエラーにならない!
    func perform(closure: /*@escaping*/ (() -> ())?) {
        if let closure = closure {
            self.closure = closure
        } else {
            self.closure = {}
        }

        self.closure()
    }
}

do {
    let john = Person(
        firstName: "John",
        lastName: "Appleseed"
    )

    john.fullName()
}

気を付けるべきは@escapingのあるなしではないのです。
@escpaingでなくてもOptionalかどうかも見なければいけません。
しかし@escapingでなく、かつOptionnalでもない、
というチェックの仕方はシンプルではないために複雑さを感じるでしょう。
さらにOptionalであっても、
クロージャをその場でnilにしている可能性もあり、
実質@escaping循環参照される可能性はないのです。

ここまで読んでわかったはずですが、
参照型であるクロージャは参照型であるクラスの循環参照と同じで、
強参照による循環参照を起こさないことが重要です。
それらを理解すれば@escapingあるなしやOptionalかどうかは、
循環参照の解決においては重要視することではないはずです。

まとめ

  • Swiftは参照カウント方式です
    • 参照カウントは1からはじまります
    • 参照型は参照されるごとに参照カウントが+1されます
    • 参照されないと明示されると参照カウントが-1されます
    • 参照カウントを0にできなければメモリリークします
  • ARCによって参照カウントが増減されるコードを自動挿入します
    • ARCは自動で循環参照を解決してくれる機能ではありません
  • 参照型を相互に強参照すると循環参照となります
    • 循環参照を解決しないとメモリリークが起こります
  • クラスの循環参照を解決することは可能です
    • nilをセットする適切なタイミングが分かることが条件です
    • 相互に強参照しないことが簡単な解決方法です
  • クロージャも参照型です
    • クラスの循環参照と解決方法は同じです
  • 関数の引数時の@escaping
    • なくてもOptionalであればescapingできます
    • 目安と考えなくても良いでしょう