前回に 無所有参照
を 暗黙アンラップされるオプショナル
と併用する話のサンプルコードで クラス
を 構造体
に書き換える中で起こった循環定義の話が駆け足気味で終わったのでそれを最初に軽く振り返りつつ、それから再び本題に戻って、そんなサンプルコードを踏まえた 強参照循環
の解消についてを眺めていってみますね。どうぞよろしくお願いします。
———————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #250
00:00 開始 00:21 前回のおさらい 01:05 おさらい : データサイズが決まらない問題 01:24 おさらい : 関係性を整えることで解決を図る問題 04:00 おさらい : 所感 05:15 ジェネリクスにおける循環定義 08:13 配列や集合で構造体の循環定義が可能な理由 12:39 プロパティーラッパーで参照に振り替えるのは学習コストが高め? 14:21 名前で用途を明確に表すことも重要そう 15:58 選択肢に挙げて検討できることが大切 17:10 値型の循環定義をクラス型にして回避することの是非 18:32 初期化の安全性を担保できない問題 19:12 初期化フェーズより外も巻き込んで担保する 21:22 初期化をマイクロモジュールに閉じ込めるアイデア 21:58 暗黙アンラップされるオプショナルで担保するアイデア 24:17 間違えないためにできることを制限していく 28:50 クロージング ————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #250
では、始めていきますね。今日は循環参照を打開するための方法について話しますが、少し脱線しながら進めます。前回、タプルに上がっていた City
と Country
について、これらが循環定義となっているため、構造体に変えるとコンパイルエラーが発生するという話をしました。それについていろいろと打開策を考えてみたわけです。
前回の内容を振り返りながら、面白いお話もあるので、それも紹介したいと思います。問題となったのは、データサイズが決まらないために循環定義が無理であるということでした。もう一つの方法としては、国の中に都市を並べて内側に入れるという方法も考えられます。すでに出ているアイデアですが、都市が国を持たない形にすることで、循環参照の問題を回避できます。
例えば、国の中に都市をプロパティとして持たせて、その国がどこまで含まれるかを判定するメソッドを国の中に持たせることができます。毎回チェックするのが問題になりそうな場合には、キャッシュを用いてチェックを高速化する方法も考えられます。たとえば、メモライズやキャッシングの技術を使うことで、この問題を解決できます。また、データが重複しないようにセットにして持たせることも考えられます。この場合、City
を Equatable
から Hashable
に変更すれば、効率的な探索が可能になります。
都市の数が膨大でなければ、このように最適化を図ることで、探索コストの問題も解決できるし、循環定義も対応できます。実際には、都市の数が膨大でも、ハッシュを利用すれば大丈夫でしょう。このようにしていけば、かなりの部分で問題は解決できると考えられます。
CapitalCity
についても同様です。例えば、City
に追加情報を持たせることで、別のプロパティやメソッドで対応できるようになります。それぞれの仕様に応じて最適な方法を見つけることが重要であり、その解決策を見つけることがプログラマーの腕の見せどころとなります。そこがプログラミングの面白さでもありますね。
実際のコードでも、循環参照を避ける方法は色々ありますが、例えば City
に Country
を持たせるとハッシュ化できない問題が出てくることがあります。その場合には、何らかの形で Hashable
に対応させる必要があります。これらの問題を順序よく解決していくことが大事です。 まず、一時判定を行い、ファンドサイトの名前とサイトファンドの名前が一致すれば良しとしましょう。名前だけを判定するのが適切ですので、この方法で進めます。
次に、ハッシュを使った処理も同じように進めることができます。ハッシュの関数に対して combine(into:_:)
メソッドを用いることで、名前の整合性を確保します。そして、これをコンパイルして通るかどうかを確認します。もしコンパイルが通らなかった場合、少し修正が必要です。例えば、ここをエクステンションに分けると整理しやすくなります。
エクステンションを用いて、Country
に City
を関連付ける場合、その順序で問題なく動作します。これにより、特定の都市が特定の国に所属し、逆もまた然りという関係が明確になります。フィールドがプリントされるかどうかの指摘を受けましたが、配列でも問題なく動作することが確認できました。また、同様に Set
でも問題なく動作します。
重要なのは、循環定義があるかどうかではなく、それによってコンパイル時にデータサイズが確定しないところが問題となります。ここでは、内部的にバッファーが管理されているので、問題なく動作することが理解できます。例えば、整数型 (Int
) の配列は8バイトのメモリサイズを持ちます。これは、配列が内部でバッファーへのポインターとして管理されているためです。
文字列 (String
) も同様に、内部でバッファーを持つ形で管理されています。Int
型の場合、サイズは8バイトで、文字列型の場合は16バイトを占有します。これにより、配列やセットのサイズが確定します。
具体的に言うと、Country
のサイズは16バイト、City
のサイズは24バイトです。これにより、構造体が循環しているかどうかに関係なく、サイズが確定するために問題は生じません。ですので、コンパイルも問題なく通るというわけです。
以上で、Swift の言語仕様を使ってデータサイズや循環定義を管理する方法について確認しました。おそらく、この方法で進めれば不要なエラーを避けることができるでしょう。 こう考えると、循環定義を回避するための手段が存在するなら、それを使っても問題はないという解釈ができるのではないでしょうか。その延長で、新しい問題が生まれるかどうかは分かりませんが、プロパティラッパーを使って擬似的に構造体を機能的にカバーする手法を取っても、セットや配列とやっていることは大して変わらないと思います。これを考えると、立てた仮定に基づいて、インダイレクトな enum
(列挙型)が参照型っぽいものになっていることを理解すれば、知識のハードルが少し高くなるかもしれませんが、プロパティラッパーを使ってインダイレクトを添えることで、8バイトのポインタ表現だけでそのプロパティを表現できるようにするという手法が取れます。
例えば、CapitalCity
を2つ定義してみるとき、CapitalCity
がリティとなるとサイズが決まらないから無理ですが、上段のコードならできるようになります。インダイレクトを使うことで、実質8バイトで扱えるようになり、全面的に許容されます。
このような情報を集めたとき、どちらの手法も良いように感じられます。前回はプロパティラッパーの使用が危険だと感じられたものですが、今ではインダイレクトも悪くないのではないかと感じています。ただ、インダイレクトという名前は汎用的すぎるので、もう少し具体的な名前があったほうが良いと感じます。このインダイレクトは循環定義を回避するためだけに存在しているようですが、そのためにはちょっと怖いという感じもあります。
インダイレクトの列挙型のおかげで、参照型らしい振る舞いをしないので、これは問題ではありません。それでも、どちらが正解かは時が経つにつれて変わるかもしれませんし、今はどうこうと結論を出す必要はないと思います。インダイレクトを使った瞬間定義の回避は可能ですが、そのためだけに使うのは危険と感じるでしょう。コードが必要になったときやレビューで急にこのようなコードが投げられたときに、振る舞いが変わってくることがあるので、良い機会だったのでこの点を意識して構造体を使うのは良いのではないでしょうか。
瞬間定義したときにクラスに変えようという方法も前々回に話に挙げましたが、それが良いのかどうかは今回の話を加味するとまた変わってくるでしょう。構造体には構造体の良さがあり、クラスにはクラスの良さがあります。瞬間定義を回避しないと、初期化の部分は複雑になります。前回も、現構成では議論が落ち着きましたが、より複雑な構成では現行のAPI上で安全に実装できない可能性もあります。 確かに初期化は難しいですね。どうしても問題が発生しやすい部分です。他にも、以前挙げていたマッピングするテーブルを別のインスタンスで用意する方法もあります。ただ、初期化にはいろいろな方法が絡んでくるため、どこかで連携を取る必要があります。理想的にはイニシャライズフェーズ、つまりイニシャライザーの中で完結させるのが一番望ましいです。それを目指していくことが基本となりますが、ランタイムに持ち越すことは避けられません。
UIキットでは、例えば awakeFromNib
が呼び出されてイニシャライザーが完結するまでのフェーズがあり、ここまでは不安定だよという世界をフレームワークで切り分けてカバーしています。こうやってコンポーネントが完全に揃うまでの過程を制御する感じですね。循環参照やその他の複雑な問題も、ライフサイクルで折り合いをつけつつ解決しています。
そのため、イニシャライズフェーズをどこまで拡大していって、それ以外の部分で気をつけるのかをタグ付けして対策を講じることが重要です。場合によっては、マイクロフレームワークとして初期化フェーズを明確に分ける方法もあるでしょう。初期化まではローカルで行い、それが終わった後にパブリックにエクスポートする方法などです。状況によって方法が異なるので、可能な限りギリギリまでシステムを保護することが大事です。バグを生みにくくするためにも、こうした取り組みは有益ですね。
初期化は本当に大変で、循環参照の問題がある限り完全に解決するのは難しいです。無理して何とかする場合もありますが、結局のところ、年が定まらないと国を設定できないような問題と同じで、設定できない状況に陥ることがあります。そういった問題回避のために、暗黙アンラップのオプショナルを使って対処する方法もあります。これは、イニシャライズフェーズを拡大しつつ、少なくとも使用時にはデータが用意されるという安心感を与えるためのものです。
オプショナルでは「使用時には確実に存在する」という約束ごとが伴います。この約束を守ることで、APIを設計する際には、ユーザーが触れる最小限の機会を確保しつつ、不安定な状態を防ぐことができます。結果として、ランタイムエラーを回避し、システムの安定性を保つことができるのです。
以上のように、循環参照が関わると考える範囲が広がり、適切な対策を取らないと非常に不安定な状況に陥りがちです。API設計者としては、できる限りの対策を講じて、ユーザーがシステムの未整備な部分に触れないよう配慮することが重要です。 他には何かありますかね。そっか、前回キャッシュの話をしましたが、今回は順番とは少し違う内容にします。「ファッショナルにしたから分かりにくくなった」というコメントがありましたが、これはたとえば以下のようなコードがあります。
struct City {
var name: String
init(name: String) {
self.name = name
}
}
このように構造体を作成します。そして、一致判定についてですが、例えばAとBがそれぞれ City(name: "東京")
だった場合、一致判定を行うと、2つの方法で比較ができます。1つは名前で比較する方法、もう1つはインスタンスのアイデンティティ(同一性)で比較する方法です。この2つの方法で結果が異なることになります。
これを考慮すると、構造体にした場合は特定の一致判定を行うためには別の方法を使わないといけません。通常は Equatable
を使うことも多いと思います。
struct City: Equatable {
var name: String
init(name: String) {
self.name = name
}
}
こうすることで、Aの一致判定とBの一致判定がどちらで行われても問題なく比較ができるようになります。これはクラスでも同様です:
class City: Equatable {
var name: String
init(name: String) {
self.name = name
}
static func == (lhs: City, rhs: City) -> Bool {
return lhs.name == rhs.name
}
}
ただし、クラスの場合はうまく比較ができるようにするためには、必ず比較の方法を実装してあげないといけません。これが構造体とクラスの存在意義の違いでもあり、個人的にはこの点が面白いところだと思います。
また、クラスだとオブジェクトのアイデンティティでも比較ができるため、この違いを理解して使い分ける必要があります。特に構造として定義する場合は、大文字を使うということも留意すべきです。
ここまで説明してきたように、クラスと構造体にはそれぞれのメリットとデメリットがあります。構造体は値の比較が安定してできるため、比較方法を簡単に統一したい場合には便利です。一方、クラスにはオブジェクト指向のメリットもあるため、状況に応じて使い分けると良いでしょう。
それでは、今日はこのへんでおしまいにしましょう。次回はまた元の話題に戻って進めていきます。お疲れ様でした。ありがとうございました。