Swiftで遊ぼう! - 869 - ARCとメモリーマネージメント
- Swiftで遊ぼう!の前書き-> Life-LOG OtherSide
- 初心者はここから!-> 50オヤジでもできるiOS開発
- 私の本業、オフィシャルなブログ-> Life-LOG
2019年5月28日:Xcode 10.2.1 で動作確認
2017年3月21日:記事をまとめました
へなちょこオヤジ初心者プログラマーの屋台ブルーです。
いつまで経っても初心者から脱却できないのは、やはり基礎知識が欠落しているからです。
「iOSプラットフォームでアプリを開発するときに重要なのは、少ないメモリーの有効活用」ってことを、素人の私でも理解できます。しかし、The Swift Programming Language本のメモリー関連のパートは量が多く前半のパートしか読み込んでいないので、「Automatic Reference Counting(ARC)が自動的にコントロールしてくれるんなら、何にも考えなくたって問題無いじゃん!」って勝手に解釈して不十分な知識のままでいました。
昨日、「[weak self]」って何だ?って話になり、思い出せない... というオヤジ特有の健忘症になってしまいました(^^;) 総復習が必要なことを認識して、ARC and Memory Management in Swift | raywenderlich.comを読みました!
おおおお、素晴らしい記事でした!ARCの基本がすんなり頭に入ったんです。
今日からは、「ARCの基本を習得したへなちょこオヤジ初心者プログラマー半歩前進」と呼んで下さい(^^;)
この記事を書いている17歳のMaxime Defauwくん、プログラミングを教えるの上手いんです。17歳の学生に教えてもらった51オヤジが、ここで日本語にまとめて初心者の皆さんに教えましょう(笑)*1
記事の内容を複数に分けます*2。
学ぶこと
- playgroundを使ってARCの挙動を学ぶ
Xcodeを立ち上げて、新しいplaygroundを立ち上げます。Platformは「iOS」で名前は「MemoryManagement」にします。
次のコードをplaygroundに入力します。
class User { var name: String init(name: String) { self.name = name print("User \(name) は初期化されます") } deinit { print("User \(name) の割り当ては解放されます") } } let user1 = User(name: "ユウジ")
このUserクラスは、プロパティは1つだけ、String型のname変数を持っています。初期化ステップ「init()」とデイニシャライザ「deinit」も持たせています。ここで、plyagroundのデバックエリア*3に「User ユウジ は初期化されます」と表示されているでしょう。
しかし、「デイニシャライザ」は呼ばれていません。ということは「デアロケーション」が生じていないことになります。どうしてでしょう? 実は「スコープ」の問題が絡んでいるからです。スコープは有効範囲のことで、playgroundのファイル上にスコープが存在するため、ファイルを閉じない限りインスタンスが閉じることはありません。実はこのスコープ、「{}」を使って有効範囲を指定することができます。ということで次のように変更します。
do { let user1 = User(name: "ユウジ") }
「do文」を使ってスコープを区切るという方法を使うと可視化できるんです。素晴らしい!私は感動しました。こうすれば、デイニシャライザが呼ばれます。
この仕組みを使って、ARCの挙動を調べていくんですが、その前にSwiftで扱うインスタンスのライフタイムを確認します。
- アロケーション(ヒープエリアもしくはスタックからメモリー領域を確保します)
- イニシャライゼーション(int()コードが呼ばれます)
- インスタンスの使用(オブジェクトの利用)
- デイニシャライゼーション(deinitコードが呼ばれます)
- デアロケーション(使用したメモリーをヒープやスタックに戻します)
私も勘違いしていましたが、デイニシャライザとデアロケーションは全く異なるライフタイムステージを意味しているので、しっかり理解する必要があります。デイニシャライザはメモリーが解放される前に実行されます。
さて、Swiftのパワフルなメモリー管理機能であるAutomatic Reference Counting(ARC)は、オブジェクトのリファレンスをカウントして「0」になると自動的にデアロケーションに導くシステムです。
「doブロック」内で、メモリーが割り当てられ、user1インスタンスが作成されると、リファレンスカウンタが「1」になります。しかし、user1が「doブロック」のスコープ外に出ると、リファレンスカウンタは1つ減り「0」になると自動的にデイニシャライザが呼ばれ、次にデアロケーションも生じてメモリーが解放されます。
古い言語はちゃんとコードして消す必要があった事を考えるとARCは素晴らしいですね。でも、完璧じゃ無いんです。理解していないとメモリーリークが生じます。
playgroundを使って、ARCの挙動を確かめていきます。
リファレンス・サイクル (循環参照)
メモリー管理は、ARCに全て任せ何も考えたくないのが人情ですが、複数のオブジェクトがお互いに参照しあう絡み合った関係になると、悲しいことにARCはうまく動かないんです。Swiftもバージョンを重ねて進化すると本当の意味でオートになるかもしれません。しかし、現状では、絡み合った参照関係は、デベロッパーが判断してARCがちゃんと動くように調整しなければなりません。
2つのクラス宣言でお互いのインスタンスをプロパティとして持つ場合
クラス青 の 青インスタンス を作成すると、クラス青 の初期化が生じてリファレンスカウントは「1」になります。さらにプロパティ「緑」は クラス緑 のインスタンスなので初期化が必要になり、リファレンスカウントは「2」になります。
同様に クラス緑 の 緑インスタンス のリファレンスカウントも「2」になります。
この2つのオブジェクト(インスタンス)がスコープから外れてARCが働いた場合
スコープから外れることで 青インスタンス のリファレンスカウントは1つ減り「1」になります。しかし、プロパティとして保持している クラス緑 のインスタンスである プロパティ「緑」は保持したままになり、リファレンスカウントは減りません。同じように 緑インスタン もリファレンスカウントが「1」のままで、プロパティ「青」を保持したままになります。この参照を「循環参照」と呼ばれ、リファレンスカウントは「0」にならないためARCは働かず、メモリーは開放されません。いわゆるメモリーリークが生じるわけです。
これをコードど確認します。Userクラスの下(do文の上)に次のPhoneクラスを加えます。
class Phone { let model: String var owner: User? init(model: String) { self.model = model print("Phone \(model) は初期化されます") } deinit { print("Phone \(model) の割り当ては解放されます") } }
そして「do{}文」を次のようにします。
do { let user1 = User(name: "ユウジ") let iPhone = Phone(model: "iPhone 7") }
この新しいPhoneクラスもシンプルです。プロパティは2つで、1つはmodelプロパティと、オプショナル型のUser型のownerですが、オプショナル型なので初期化に必要ありません。まだUserクラスとPhoneクラスは循環参照の関係にないので、スコープから外れればARCの働きでインスタンスは自動的に消去されます。
このPhoneクラスのプロパティをUserクラスに持たせることで循環参照を成立させます。以下のコードをUserクラスのnameプロパティの下に加えます。
private(set) var phones: [Phone] = [] func add(phone: Phone) { phones.append(phone) phone.owner = self }
このphonesプロパティを「privatte」設定にしています。このクラス宣言のスコープ外から値を変更させないためです。add()メソッド以外の方法で設定させないように制限させたいからです。どうしてかというと、このメソッドを使うことで、phoneクラスのownerプロパティに必ず値が代入されるからです*4。このプロパティを加えることで循環参照が成立する状況は整いました。
まだUserクラスのphonesプロパティは値を保持していいないためスコープ外に出ることでインスタンスはちゃんと消えています。ここでuser1のadd()メソッドを使ってphonesプロパティに値を保持させると、循環参照が成立してしまってデイニシャライザは動かなくなります。
ちゃんとメモリーリークが生じています。
じゃあどうやってこれを回避するのか?
17歳の少年から学んだARCの原理を私なりに消化吸収して説明していきます。
Weak リファレンス
ARCを混乱に陥らせる「循環参照」の説明をしました。これを避けるためにリファレンスカウントに影響を与えるオブジェクトに「weak」キーワードをつけて、「弱い参照」を明示させるんです。何も付いていないオブジェクトは全て「強い参照」になるからです。この「弱い参照」が何かというと、リファレンスカウント数を増やさないんです。
weak リファレンスを使う状況というのは、そのクラスにとって、必ずしも必要の無いプロパティに設定する場合です。ということは、必ずオプショナル(optional)型として宣言される変数になるわけです。オブジェクトのライフサイクルに関わってこなくなるので、リファレンスカウントが「0」になると、自動的に「nil」になります。
これをイメージで説明すると
クラス青 にとって、プロパティ「緑」は必ずしも必要ではないので、オプショナル型の変数なので「weak」リファレンスとして設定します。ということで、青インスタンス が生成される時にリファレンスカウントは「1」にしかなりません。すると、このインスタンスが、スコープ外に出たときにARCが動いて、自動的にカウントを1つ減らします。
すると 青インスタンス のリファレンスカウントは「0」になり、デイニシャライザが実行され、プロパティ「緑」は自動的に「nil」になり、デアロケートも生じて、青インスタンス は消えます。
青インスタンス が消失すると、緑インスタンスのリファレンスカウントも「0」になりデイニシャライザを経て、デアロケートされて無くなるんです。
こういう理屈です。
これをコードで実現するためにPhoneクラスのownerプロパティを「weak」にするんです。
class Phone { let model: String weak var owner: User? // 下記にコードは続きます
「weak」キーワードを加えたとたん、デバックエリアにデイニシャライザのコメントが出現します。
循環参照を「weak」プロパティで切る方法を理解しました。
次は「unowned」です。
Unowned リファレンス
リファレンスカウントの数を増やさない「weak」の解説をしました。実は、もう1つのリファレンス拡張子、「unowned」もリファレンスカウントを増やしません。。「weak」と何が違うのか? 「weak」は、オプショナル型の変数にしか付けられません。そのため、消滅すれば自動的に「nil」になります。「unowned」は、オプショナル型変数に付けることができないので、消滅したunownedプロパティにアクセスが生じると、当然のようにランタイムエラーになります。
「strong」「weak」「unowned」の関係性を表にすると次のようになります。
ではplyagroundで確かめていきます。新しいクラスを定義します。
class CarrierSubscription { let name: String let countryCode: String let number: String let user: User init(name: String, countryCode: String, number: String, user: User) { self.name = name self.countryCode = countryCode self.number = number self.user = user print("CarrierSubscription \(name) は初期化されます") } deinit { print("CarrierSubscription \(name) の割り当ては解放されます") } }
このCarrierSubscriptionクラスを説明します。
4つのプロパティを持っていますが、クラス名(キャリア契約)の意味から明らかなように、契約者は必ず1人なので、Userクラスの定数「let」を持ちます。
Userは、契約をいくつしても構わないので、次のプロパティをnameプロパティの下に加えます。
var subscriptions: [CarrierSubscription] = []
Phoneクラスは、契約が付いたかどうか分かるようにプロパティを持たせるのですが、未契約の端末は「nil」として扱えるようにするため、オプショナル型にして、メソッドで設定できるようにします。
var carrierSubscription: CarrierSubscription? func provision(carrierSubscription: CarrierSubscription) { self.carrierSubscription = carrierSubscription } func decommission() { self.carrierSubscription = nil }
そして次にCarrierSubscriptionクラスのinit()内に次のコードを加えます。
user.subscriptions.append(self)
契約を1つ結ぶ時に必ずUserが作られます。しかし、そのユーザーは契約をいくつもできるので、新しい契約をすればその情報をユーザーに付け加える作業を加えました。
そして、do{}ブロック内に次のコードを加えます。
let subscription1 = CarrierSubscription(name: "Softbnk", countryCode: "080", number: "31415926", user: user1) iPhone.provision(carrierSubscription: subscription1)
加えることでデイニシャライザは直ちに無くなるでしょう。再び循環参照に陥ったってことです。
どこに問題があるのでしょうか?
CarrierSubscriptionクラスのインスタンスを作ると、循環参照に陥ります。
CarrierSubscriptionクラスの「let user: User」は強参照で、Userクラスの「var subscriptions: [CarrierSubscription] = []」も強参照のためです。
この2つのプロパティは、オプショナル型ではないので、「weak」が使えません。
そこで「unowned」の出番になります。
じゃあ、どっちのプロパティを「unowned」にすべきか?それが問題です。
こういう問題に遭遇したときに、少しばかり< strong>ドメイン知識が必要<_strong>になります。
ユーザーがキャリア契約を持っていますが、キャリア契約がユーザーを持っているということではありません。更にいえば、ユーザー無しのキャリア契約は存在しないんです。
キャリア契約の無いユーザーが存在しても、ユーザーのいないキャリ契約はありえません。
CarrierSubscriptionクラスが存在すれば、必ずユーザーがいなければならないということは、CarrierSubscriptrionが消えたら、そのユーザーは消えるべきです。ということは、Userプロパティは、「unowned」にすべきです。
class CarrierSubscription { let name: String let countryCode: String let number: String unowned let user: User // コードは続く...
userプロパティを「unowned」にするとリファレンスカウントは増えないので、スコープから外れるとCarrierSubscriptionインスタンスは消えるのでデイニシャライザが働きます。
ここまで読めば、weakとunownedの使い分けができるようになりましたね。しかし、ここまでの話は2つのオブジェクトの循環参照の話です。次は、クロージャの説明です。
クロージャの循環参照
オブジェクトと同様にクロージャもリファレンスタイプになりえるので、循環参照に陥る原因を作ってしまうんです。クロージャ(関数)内に自分自身(self)のプロパティを保持してしまうと生じます。分かるかな?こんな表現で...
コードで確かめていきます。CarrierSubscriptionクラスに次の新しいプロパティを加えます。
lazy var completePhoneNumber: () -> String = { self.countryCode + " " + self.number }
少しだけこのプロパティの説明をします。「lazy」の枕詞がついているプロパティは、遅延格納型プロパティと呼ばれ、最初に使用されるまで割り当てられない(初期化時に割り当てられない)って意味です。というのもself.countryCodeやself.numberは初期化ステップの終了後でないと利用できないからです。そして、このプロパティは典型的なクロージャ型です。このように関数も型として持つことができます。
次にdo{}ブロック内の最後に次のコードで、先ほど加えたプロパティを呼び出すと、クラス内に保持しているパロパティをを参照して実行されます。
print(subscription1.completePhoneNumber())
このコードを加えた途端、CarrierSubscriptionクラスは循環参照に陥りデイニシャライザは呼ばれなくなります。
さて、クロージャの循環参照を断ち切るためにとうすべきか?
キャプチャ・リスト(capture list)
クロージャの循環参照を切るためにキャプチャ・リストを使います。クロージャはリファレンスタイプなので、クラスのコピーは、参照のコピーになるので、オリジナルの値へ参照が生じるんです。これをplaygroundを使って説明しましょう。
var x = 5 var y = 5 let someClosure = { print("\(x), \(y)") } someClosure() // 5, 5 と表示されます。 print("\(x), \(y)") // 5, 5 と表示されます。
この結果に疑問の余地はありませんが、このクロージャ定義の下で変数を変更すると次のようになります。
var x = 5 var y = 5 let someClosure = { print("\(x), \(y)") } x = 6 y = 6 someClosure() // 6, 6 と表示されます。 print("\(x), \(y)") // 6, 6 と表示されます。
クロージャはリファレンスタイプなのでクロージャ内から参照しているxとyの値が変わるのと同様に変更されてしまします。クロージャ内で値を保持することをキャプチャといい、どの値をキャプチャするかリストにするのがキャプチャ・リストです。クロージャの前半で「[]」を使ってリスト表示させます。
var x = 5 var y = 5 let someClosure = { [x] in print("\(x), \(y)") } x = 6 y = 6 someClosure() // 5, 6 と表示されます。 print("\(x), \(y)") // 6, 6 と表示されます。
キャプチャ・リストにあるxはクロージャー内で値を保持します。しかし、yはリファレンスのままなので後の変更が反映してしまっています。
このキャプチャ・リストに「weak」や「unowned」を付けることで循環参照を断ち切るんです。CarrierSubscriptionクラスに組み込んでいるクロージャをみると、このクロージャはCarrierSubscriptionインスタンスが消失すると存在できないので、「unowned」が最適と思われます。
lazy var completePhoneNumber: () -> String = { [unowned self] in self.countryCode + " " + self.number }
こうすると、ほら、デバックエリアに「CarrierSubscription Softbnk の割り当ては解放されています」が表示され、無事デイニシャライザが呼ばれました。
ここで重要な表記法の説明が必要です。「[unowned self]」という書き方、実は省略形です。元になる表記法は次のようになります。
var closure = { [unowned newID = self] in // ここでnewIDを使用 }
newIDは自分で指定することができますが、慣用的な書き方をすれば、上で書いたクロージャは次のようになります。
lazy var completePhoneNumber: () -> String = { [unowned unownedSelf = self] in unownedSelf.countryCode + " " + unownedSelf.number }
明日は、クロージャーで値を保持する時に、weakがいいのかunownedがいいのか、どちらを選択すべきか、もう少し深く考えてみます。
次のクラスを考えます。
class WWDCGreeting { let who: String init(who: String) { self.who = who } lazy var greetingMaker: () -> String = { [unowned self] in return "Hello \(self.who)." } }
ここで定義されているクロージャのプロパティは、unownedの参照にしているためクラスが消失した状態で、参照は許されません。
plyagroundで次のようにコードします。
class WWDCGreeting { let who: String init(who: String) { self.who = who } lazy var greetingMaker: () -> String = { [unowned self] in return "Hello \(self.who)." } } let greetingMaker: () -> String do { let mermaid = WWDCGreeting(who: "caffinated mermaid") greetingMaker = mermaid.greetingMaker } print(greetingMaker())
do{}ブロック外から外に出ると、mermaidインスタンスは消滅するので、クロージャプロパティの存在も消えているためgreetingMaker()はランタイムエラーを生じます。
ここでちょっと実験をしてみます。このクロージャのキャプチャ・リストを消去してみたらどうでしょう? 循環参照が残ってしまうので、do{}ブロックのスコープからmermaidインスタンスが外れても消滅しません。ということは、このgreetingMaker()も残ってしまうということです...
もし、こういうコーディングを知らず知らずに続けてしまって、エラーが生じていても動作するアプリケーションができてしまうと怖いですね(^^;)
話を元に戻します。クロージャ内のキャプチャ・リストに「unowned」を設定しても悪くないのですが、ランタイムエラーに陥る可能性があるというのは気持ちがいいものではありません。こういう場合はやはり「weak」を使う方が望ましいでしょう。
「weak」にすれば、必ずオプショナル型にしなければなりません。ということは、使用をするためにアンラップする必要がありまずが、「nil」を使わないguard文を利用するコードが望ましいんです。Rayさんところで、「ストロング・ウイーク・ダンス」という作法らしいです。
lazy var greetingMaker: () -> String = { [weak self] in guard let weakSelf = self else { return "Hey no greeting!" } return "Hello \(weakSelf.who)." }
ここまでで、「weak」と「unowned」の基本的事項は終了です。よく分かっていなかった私は感銘を受けました。
次はXcodeの新しいデバックツールの説明をしていきます。
循環参照を見つけるためにXcode 8から導入されたランタイム時のバグを検出する機能です。「Runtime Issues」というデバック機能をみていきます。
「Debug Memory Graph」という機能で、raywenderlich.comのチュートリアル、ARC and Memory Management in Swift | raywenderlich.comにあるリンクからプロジェクトをダウンロードしてXcode 10を使って確認するといいのですが、どうもプロジェクトが少し古いので、最新のXcodeで動かないんです。自分で修正できる人はデバックしてみてください。
このプロジェクト、シンプルなコンタクトアプリが立ち上がります。テーブルビューを使って詳細ページが開いたり、新規のコンタクト情報を加えることができます。これはこれでちゃんと動作しているように見えます。
でも実は、メモリーリークが隠されているんです。
コンタクト情報を左スワイプして消去します。3つぐらいスワイプした状態(シュミレータは動かしたまま)でXcode 8に戻ります。Xcodeの下部にデバックエリアが表示されています。この中で「Debug Memory Graph」ボタンを押します。
するとランタイムでデバックが作動して、左のナビゲータ・エリアが「デバック・ナビゲータ」に切り替わります。
ラインタイム時に問題になったコードに紫色のマークが付きます。これが「Runtime Issue」です。
次にこのファイルを選択します。すると画面中央にグラフィカルにクラスの関係が表示されます。
これを見れば明らかですが、プロジェクト内のContactクラスとNumberクラスに循環参照が形成されています。ということはコンタクト情報を消去してもデアロケーションが生じないということで、メモリーリークが生じています。
両者のクラスを見ます。まずContractクラス
class Contact { var firstName: String var lastName: String var avatar: UIImage var number: Number? // コードは続く
次にNumberクラス
class Number { var countryCode: String var numberString: String var contact: Contact // コードは続く
この循環参照を切るために2つの可能性があります。どちらの参照を弱くすべきか?アップルの用意しているドキュメント、Practical Memory Managementにその答えが書いてあります。コンタクト情報は電話番号情報を内包しているため、電話番号情報はあっても無くてもコンタクト情報は存在します。しかし、コンタクト情報の無い電話番号情報は存在しません。すると、コンタクトは電話番号と強いリファレンスが必要ですが、電話番号にとってコンタクト情報が消えれば消えてしまう弱い関係性で保持する方がベストということです。Numberクラスを次のよにします。
class Number { var countryCode: String var numberString: String unowned var contact: Contact // コードは続く
これでエラーが消えて、アプリケーションは正常に動作します。
最後のトピックに入ります。
バリュー・タイプとリファレン・タイプ
このトピックはボーナス情報になるますが、Swiftでコーディングするために非常に重要な情報です。「ジェネリック」の知識が必要になります。
バリュー・タイプの扱いをみます。これは値を保持する型の事で、struct型やenum型、プロパティで使うarrayやdictionaryもバリュータイプにあたり、回帰参照させるとエラーになります。
struct Node { var payload = 0 var next: Node? = nil } // これは許されません
しかし、クラスに変更すると問題無いんです。クラスは回帰参照できます。
class Node { var payload = 0 var next: Node? = nil } // エラーは消えます
次のようなクラスを用意します。
class Person { var name: String var friends: [Person] = [] init(name: String) { self.name = name print("New person instance: \(name) は初期化されます") } deinit { print("Person instance \(name) の割り当ては解放されます") } }
リファレンス・タイプのクラスが、回帰参照するarray型(バリュー・タイプ)のプロパティを持た場合、do{}ブロックを使ってイニシャライザとデイニシャライザの働きをみてみます。
do { let yuji = Person(name: "Yuji") let kenji = Person(name: "Kenji") }
インスタンスを作った状態のままならスコープから抜けた時にデイニシャライザは呼ばれます。
では循環参照をさせてみたらどうでしょう。
do { let yuji = Person(name: "Yuji") let kenji = Person(name: "Kenji") yuji.friends.append(kenji) kenji.friends.append(yuji) }
見事にメモリーリークが生じました!
循環参照を切る時に使うのが、「weak」か「unownerd」ですが、クラス(リファレンス・タイプ)のプロパティにしか使えません!array型に適用できないんです!
じゃあどうする?
ここでジェネリックのパワーを使うんです。ジェネリック・ラッパー*5という機能で、arrayにunownedの力を身にまとわせます。
class Unowned<T: AnyObject> { unowned var value: T init(_ value: T) { self.value = value } }
そしてPersonクラス定義のfreindsプロパティを変更します。
class Person { var name: String var friends: [Unowned<Person>] = [] init(name: String) { self.name = name print("New person instance: \(name) は初期化されます") } deinit { print("Person instance \(name) の割り当ては解放されます") } }
そしてdo{}ブロック内を次のようにすると
do { let yuji = Person(name: "Yuji") let kenji = Person(name: "Kenji") yuji.friends.append(Unowned(kenji)) kenji.friends.append(Unowned(yuji)) }
見事に循環参照が切れてデイニシャライザが動きました。
これでARCの基本が理解できたでしょう。