20
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

SwiftAdvent Calendar 2021

Day 15

参照カウンタになじむ Part. 1

Last updated at Posted at 2021-12-14

みなさん、こんにちは。freddi です。この記事は Swift Advent Calendar 2021 の Day 15 の記事として、参照カウンタの挙動を理解するために、Auto Reference Counting の挙動や weak / unowned について解説しました。

この記事の目標

この記事では、読者の皆さんに以下のようになってほしいと思い執筆しました。

  • 参照カウンタがどのように増減しているかをうまく言語化できるようになる
  • なぜ weakunowned が必要なのかをしっかり説明できるようになる

ただ、ボリューミィになってしまったので以下のことは解説してません。ですがこちらもかなり重要なので、近日中に別記事としてあげます。

  • クロージャと [unowned(or weak) self] について

事前知識として、以下の知識があればスムーズに記事が読めると思います

  • Swift の変数・関数の書き方、呼び出し方
  • Swift の class の書き方、使い方。struct との違い(あれば)

この記事を通じて、皆様がより深く参照カウンタに慣れ親しむことができるようになることを願っています。

検証等はコンパイラを利用して注意深く説明していますが、もし間違いがあればコメントやTwitter等でのご指摘をお願いします。

はじめに ~ Swiftの参照カウンタの基本 ~

Swift はオブジェクトの寿命の管理の手段として 参照カウンタ を採用しています。class オブジェクトの参照があるたびに、そのオブジェクトの参照のカウンタがインクリメントされます1。そして、ある一定の条件でデクリメント2されます。最後に、この値が02になったときにそのオブジェクトはメモリから解放されます。

その参照カウンタですが、私達が書いたコード中で増減すべき場所はコンパイラが自動的に決める Auto Reference Counting (以下 ARC)という仕組みが、Swiftではコンパイル時に働いています。ARC によってプログラマではなくコンパイラが参照カウンタの増減をしてくれています。

ARCの働きを見てみる

では、実際にARCがどのように働くのか例を見てみましょう。
以下のコードを用意してみました。さらに、コンパイラが生成した中間言語3を元に、ARCによって自動的に入った参照カウンタの場所を解説したコメントも載せました。このコメントの #の部分で実際に参照カウンタのコードが挿入されています。4

class Neko {
    var name: String

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

    func meow() { print("にゃー") }
}

func nekoMeow() {
    let neko = Neko(name: "Tom") // ここで、name = "Tom" な Neko 型のオブジェクト(ここでは便宜上 A とする)が生成される。Aの参照カウンタは 1 で neko からの参照
    let neko_ref = neko     
    // #0 A が neko_ref によって参照されるのでインクリメント。A の参照カウンタは 2 で, neko 及び neko_ref からの参照
    neko_ref.meow()
    // #1 このスコープでは今後一切 neko は利用されないので、neko への参照分デクリメントされる。 A の参照カウンタは 1 で, neko_ref からの参照     
    // #2 このスコープでは今後一切 neko_ref は利用されないので、neko_ref への参照分デクリメントされる。 A の参照カウンタは 0
    // カウンタが 0 以下なので A 解放される
}

nekoMeow の # の部分を疑似的な参照カウンタを操作するコードに書き換えると以下のようになります。あくまで疑似的なコードなのでコンパイルはできません。5

func nekoMeow() {
    let neko = Neko(name: "Tom") // 最初はカウンタは1
    let neko_ref = neko     
    strong_retain(neko_ref)
    neko_ref.meow()
    strong_release(neko)
    strong_release(neko_ref)
}

参照カウンタの増減の命令は、↑のコードの解説から見ると以下の条件で入ることがわかります

  • オブジェクトの生成・変数への代入によって参照カウンタはインクリメントされる
  • 変数が利用されなくなったことがわかる時点で参照カウンタはデクリメントされる

また、このあとの出てくるのでもう一つ補足しておきます

  • オブジェクトが解放されると、そのオブジェクトが保持している他のオブジェクトへの参照カウンタはデクリメントされる

よく、初心者の方が混乱しやすいポイントとしては、参照カウンタがカウントする対象だと思います。
参照カウンタは neko , neko_ref のような変数・定数が保持するのではなく、オブジェクト自体に対して行われます。あえて上記の説明で便宜上オブジェクトを A と読んだのはそれが理由です。

循環参照によって参照カウンタが0にならない例

では、お気づきになった方やご存じの方もいると思いますが、この参照カウンタが 0 になってくれない場合はオブジェクトが解放されません。
↓のコードを例をみてみます。こちらもコンパイラが生成した中間言語を元に3、ARCによってどのように参照カウンタのコードが入るかを解説したコメントも載せました。

前項のポイントがスッキリ入ったら、下記のコードではなぜ参照カウンタが0にならないか、スムーズに理解できると思います。

class Neko {
    var name: String
    var partner: Neko? = nil

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

func catPartner() {
    let tom = Neko(name: "Tom") // ここで、name = "Tom" な Neko 型のオブジェクト(ここでは便宜上 A とする)が生成される。Aの参照カウンタは 1 で tom からの参照
    let jerry = Neko(name: "Jerry") // ここで、name = "Jerry" な Neko 型のオブジェクト(ここでは便宜上 B とする)が生成される。Bの参照カウンタは 1 で jerry からの参照

    // #1 B が A の partner によって参照されるのでインクリメント。 Bの参照カウンタは 2
    tom.partner = jerry
    // #2 A が B の partner によって参照されるのでインクリメント。 Aの参照カウンタは 2
    jerry.partner = tom
    // #3 このスコープでは今後一切 tom は利用されないので、tom への参照分デクリメントされる。 A の参照カウンタは 1   
    // #4 このスコープでは今後一切 jerry は利用されないので、jerry への参照分デクリメントされる。 B の参照カウンタは 1
    // 結果 A も B も解放されない
}

なるべく前項のソースと同じコメントの文体にしてまとめてみました。最終的には A と B が解放されないことがわかります。

まず、オブジェクトが解放される際、そのオブジェクトが参照しているオブジェクトのカウントがデクリメントされます。たとえば、Neko オブジェクトが解放されたときは、 partner が参照しているオブジェクトのカウンタがデクリメントされます。

しかし、上記のコードでは tomjerry 互いの partner によって A と B のお互いの参照カウンタのインクリメントが起こりますが、どちらも解放されないがために 最終的に互いの参照分のデクリメントが起こらず、解放されない状態になります。

このとき、お互いが何かしら参照を持ち合ってしまった状態を 循環参照 (Retain Cycle, Reference Cycle) と呼びます。また、このように適切にオブジェクトが解放されない状態を メモリリーク と呼びます。

メモリリークは、デバイスのメモリの圧迫やそれによるクラッシュ・副次的にデバイスの発熱を起こしてしまう、などというバグを生んでしまいます。

unownedな参照 と weakな参照

さて、前項で問題になるのは「循環参照によって、参照カウントが0までデクリメントされない」点です。
Swift はこれを解決する手段を持っています。それが unownedな参照weakな参照 です。それらと対になるのが、強参照 で、いままでみてきた参照は強参照になります。このことは、先程の擬似コード中で参照カウンタを操作するコードに strong という文字があったためわかりやすいと思います。

unownedな参照

変数宣言の際、letvar の前に unowned をつけると、その変数の参照は unownedな参照 になります.
実際に、unowned を適切な場所につけてみます。

class Neko {
    var name: String
    unowned var partner: Neko? = nil // unowned を追加

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

func catPartner() {
    let tom = Neko(name: "Tom") // ここで、name = "Tom" な Neko 型のオブジェクト(ここでは便宜上 A とする)が生成される。Aの参照カウンタは 1 で tom からの参照
    let jerry = Neko(name: "Jerry") // ここで、name = "Jerry" な Neko 型のオブジェクト(ここでは便宜上 B とする)が生成される。Bの参照カウンタは 1 で jerry からの参照

    // #0 B が A の partner によって参照されるが unownedな参照なので、見かけ上インクリメントなし。 Bの参照カウンタは 1 のまま
    tom.partner = jerry
    // #1 A が B の partner によって参照されるが unownedな参照なので、見かけ上インクリメントなし。 Aの参照カウンタは 1 のまま
    jerry.partner = tom

    // #2 このスコープでは今後一切 jerry は利用されないので、jerry が参照してる B の参照分デクリメントされる。 B の参照カウンタは 0 
    // #3 このスコープでは今後一切 tom は利用されないので、tom が参照してる A の参照分デクリメントされる。 A の参照カウンタは 0
}

上記のソースコードの説明通り、unownedな参照では、見かけ上で強参照な参照カウンタでのインクリメントが起こらなくなります。6
それにより、循環参照によって参照カウンタが0にならなかったコードが、unownedな参照によりインクリメントが各所で抑えられて解放されるようになりました。

一応、このコードのコンパイル後にどこに参照カウンタの操作が入るか、擬似コードを載せておきます。7

func catPartner() {
    let tom = Neko(name: "Tom") // 最初はカウンタは1
    let jerry = Neko(name: "Jerry") // 最初はカウンタは1

    // strong_retain(jerry) 見かけ上インクリメントされていないように振る舞う
    tom.partner = jerry
    // strong_retain(tom) 見かけ上インクリメントされていないように振る舞う
    jerry.partner = tom

    strong_release(jerry)
    strong_release(tom) 
}

weakな参照

ただし、unowned には問題があります。それは、オブジェクトが解放されているかを無視して参照してしまうことです。

まず、以下のようなコードではクラッシュが発生します。今回の例は実際の参照カウンタのコードを覗くとかなり複雑なので、「どのように振る舞うように考えればいいか」をコメントとして書いてます。

func catPartner() {
    unowned let tom = Neko(name: "Tom")      // unowned なので参照カウンタが見かけ上インクリメントされない。A の参照カウンタは 0。このあとすぐに解放される
    unowned let jerry = Neko(name: "Jerry")  // unowned なので参照カウンタが見かけ上インクリメントされない。B の参照カウンタは 0。このあとすぐに解放される

    // tom が参照している A は既に解放済み。即クラッシュ
    tom.partner = jerry
    jerry.partner = tom
}

tom.partner = jerry のその前の時点で、tom の参照してる A は解放されています。その後、tom.partner = jerrytom に参照しようとしていますが、 既に解放されたオブジェクトに参照しようとしているので、クラッシュしてしまいます。

一見わざとらしいコードでクラッシュしてしまいますが、unowned によるクラッシュが起こりうるコードは書くことは、プロダクトコードでは非常に多くなると私は考えています。

最初に書いたとおり今回深く話さない内容ですが、たとえばクロージャに unowned selfself が参照しようとしてる場合、そのクロージャが利用している self が解放されたあとに呼ばれる可能性が十分にあります。その場合、self に参照した瞬間にクラッシュする可能性が高いです。RxSwift や Combine を使うとこの問題に直面することがあると思います。

その際、weakな参照 を利用すると安全にオブジェクトに参照することができます。 以下がその例です。こちらも実際の参照カウンタのコードの位置ではなく、「どのように振る舞うか」をコメントしています。

func catPartner() {
    weak var tom = Neko(name: "Tom")     // weak なので参照カウンタが見かけ上インクリメントされない。制約として var による宣言とオプショナル
    // ここで、name = "Tom" な Neko 型のオブジェクト(ここでは便宜上 A とする)が生成される。weakな参照なので Aの参照カウンタは 0 で直ぐに解放される
    weak var jerry = Neko(name: "Jerry") // weak なので参照カウンタが見かけ上インクリメントされない。制約として var による宣言とオプショナル
    // 省略(ほぼ同上)

    // tom が参照している A は既に解放済み。しかし tom は nil になるので何もしないがクラッシュしない
    tom?.partner = jerry
    jerry?.partner = tom
}

weak をつける場合は var とオプショナルな型で宣言しなければなりません。しかし、もしオブジェクトが開放された際はその変数は nil になるだけなので、クラッシュを避けることができます。weakunowned 同様、class の変数につけることもできます。

class Neko {
    var name: String
    weak var partner: Neko? = nil // weak を追加

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

今後の学習(兼 参考文献)

今後の学習として、様々な資料を出しておきます。

Apple公式の参照カウンタの解説

WWDC 2021 で発表された ARC in Swift: Basics and beyond
という動画ありますが、この記事の50%はこの動画がベースです。最初この動画を見ると若干難しい表現が多くとっつきにくいと思ったのでこの記事を執筆しました。逆に、この動画を見るときはこの私の記事が助けになるかもしれません。

weak vs unowned

weak-vs-unowned(資料)という登壇では、weakunowned について詳しく比較・解説しています。この記事の発展としてぜひ見てほしい資料の一つです。

Cookpad さんの記事

この記事からは少々発展してしまいますが、iOSアプリのメモリリークを発見、改善する技術 という記事がだいぶメモリリークへの取り組みとして良い事例だと思いますので、ここで紹介しておきます。

最後に

ここでは参照カウンタや ARC について基本的な内容を解説しました。次回は、冒頭にある通り”クロージャと [unowned(or weak) self] について”詳しく解説します。お楽しみください。

  1. このことを retain と呼ぶこともあります

  2. このことを release と呼ぶこともあります。なぜ release かというと、参照カウンタはデクリメント時にそのカウンタを評価して、0であれば解放(release)するという仕様になっているからだと考えられます。この仕様は Swift の中間言語である SIL の仕様書から読み取ることができます。 https://github.com/apple/swift/blob/main/docs/SIL.rst#strong-release 2

  3. Swift の中間言語は SIL(Swift Intermediate Language) と呼ばれます。数年前の Advent Calendar にて解説した記事があるので御覧ください https://qiita.com/freddi_/items/d2c2b2db223dc4a50494 2

  4. swiftc path-to-swift-code -emit-sil にて検証。バージョンは swift-driver version: 1.26.9 Apple Swift version 5.5 (swiftlang-1300.0.31.1 clang-1300.0.29.1)

  5. Swiftの中間言語に変換されたコードでは、参照カウンタのインクリメントは strong_retain で、デクリメントと解放は strong_release です。https://github.com/apple/swift/blob/main/docs/SIL.rst#strong-retain https://github.com/apple/swift/blob/main/docs/SIL.rst#strong-release

  6. 実は unowned もカウンタが存在しますがややこしくなるため説明していません。詳しくは https://github.com/apple/swift/blob/main/docs/SIL.rst#unowned-release をみてみたり、ご自身で unownedな参照があるコードを中間言語にコンパイルしてみてください。

  7. コード中で、コメントアウトされている2つの strong_retain は実際には挿入されます。unownedな参照としての振る舞いは、class の変数に代入された際の、class側のコードで定義されています。

20
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?