今日は前回の 無所有参照
を 暗黙アンラップされるオプショナル
と併用する話のサンプルコードで出てきた クラス
を 構造体
に書き換える中で起こった、循環参照とはまた違った値型の循環構造みたいなところを眺めていってみることにしますね。よろしくお願いします。
——————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #247
00:00 開始 00:44 構造体の循環定義を解消していく 01:46 そもそも何が問題なのか 04:34 値型は循環させた定義ができない 05:19 構造体のサイズが決まらない 09:31 クラスであれば循環してもサイズが決まる 14:39 構造体の循環定義をどう解消していこう 16:45 別の型を介して循環する場合も 19:27 循環問題を回避できる間接的列挙型 23:45 String 型は、なぜ 16 バイトなのだろう? 25:27 通常の列挙型だと循環時にサイズが決まらない 26:13 indirect enum による参照 28:33 間接的列挙型に弱参照の概念はない 30:57 構造体には間接的な扱いはない 32:28 今回の所感と次回の展望 ———————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #247
では、始めていきましょう。前回、循環参照の解消方法について勉強していました。このサンプルを見ている最中に、ある問題に引っかかりました。このサンプルを見ていると、「これって普通はクラスじゃなくて構造体でやるよね?」と感じ、構造体に書き換えた結果、どんな違いが出てくるかを確認していました。その途中で問題が発生しましたので、今日はその問題を見ていこうと思います。
クラスの場合は参照型なので、循環参照という問題が起こります。しかし、構造体は値型なので、そもそも循環参照が起こりません。そういう状況で、どうして問題が発生したのかを見ていきましょう。
まず、Playgroundを使って確認していきます。これが前回構造体に変更した際のサンプルコードです。City
型とCountry
型をそれぞれ構造体に変更しました。このサンプルでは、都市が国に所属し、国が都市を持つという循環参照の状態が発生しています。
前回、このコードを書き進めている途中で時間がなくなって終了しました。そこで、どこから修正していくかという話になります。問題としては、City
を構造体にしてもCountry
がクラスのままだとエラーは発生しませんが、Country
を構造体に変更するとエラーが発生します。
この問題の一つはunowned
です。unowned
は参照型ではない構造体には使えません。unowned
はクラスかクラスを限定するプロトコルでしか使えないため、Country
を構造体にしたからにはunowned
は使えません。しかし、unowned
は循環参照を防ぐためのマークなので、構造体にはそもそも必要ありません。これを削除することで、1つエラーは解消されましたが、新しい問題が出てきます。
次に出てくるエラーは、「値型の保存型プロパティとして循環参照を持つことはできない」というメッセージです。Country
が値型のCity
を持ち、City
がCountry
を持つと循環参照が発生するため、この状態は許可されていません。
この問題について少し詳しく説明します。例えば、struct A
という型があり、次のようなプロパティを持っているとします。
struct A {
var k: Int
}
この場合、A
のサイズはInt
型1個分なので8バイトです。
これで変数 bar
の L
を1個増やすと、サイズは16バイトになります。同じように、struct B
を作って、例えば A
を持つような型を作ったとします。メモリーレイアウトを考えると、B
のサイズは A
を内包している型なので、何バイトになるか想像できますよね。予想通り、16バイトになります。さらに struct B
に bool
型の変数 bar
を加えると1バイト増えて17バイトになります。
同じように、A
に対して例えば bool
型の変数 M
を加えると、A
は1バイト増えて17バイトになります。そうすると B
は A
が17バイトになって、さらに1バイトが加わるので18バイトになります。合っているか確認しましょう。結果は17バイトと18バイトになりますね。
ここで、var N
が B
型だったとするとどんな問題が起こるでしょうか。この場合、8バイト × 8バイト + 1バイトです。先ほどの計算で行くと18バイトですが、B
の内部を見ると A
のサイズになりますよね。先ほどは17バイトでしたが、今回は 18 + 1 + 1 = 20バイト
になります。なので、B
のサイズは35バイト、続いて36バイトという風に増えていきます。このようにサイズが決まらなくなる問題が発生します。こうした都合上、構造体は循環参照することができないのです。
これに対して、クラスではこの問題がなくなります。まず、この循環をなくして、サイズが構造体の場合は17バイト、クラスの場合は8バイトに固定されるようにします。クラスにすると、A
自体のサイズは17バイトですが、型としてのサイズは8バイトになります。これは64ビットCPUでのポインタのサイズです。クラスのインスタンス変数のサイズがポインタサイズとなり、その先に17バイトの領域がヒープ領域に存在するという形になります。
構造体の場合、インスタンス自体がデータ型そのもののサイズとなりますが、クラスの場合はポインタとして8バイト、データは別途ヒープ領域に確保される形になります。
構造体 B
をクラスに置き換えてみると、B
のサイズも内包する A
のポインタと bool
型の1バイトからなるので、結果的には9バイトになります。しかし、クラスに変えると内包されるのはポインタサイズの8バイトになるため、それぞれ8バイトになります。つまり、ポインタ分とヒープ領域の9バイトができることになります。
このことにより、構造体に起こりがちな循環参照によるサイズの決定不能という問題が解消されます。クラスではサイズが固定されるため、この問題が発生しません。 だから、8に対してこのルール定義値1で、この9。ヒープ領域には9バイト確保すればいいよっていうことがコンパイラーがわかります。Aの方はヒープ領域は17ですね。8が2つと1が1つなので、17バイトのヒープを確保すればいいです。もちろん、循環を作っても一緒で、Bは必ず8バイトなので、循環していようが何しようが、このサイズが決定します。例えば、25バイトかな。
Aも同じで、いくらAの中でBを持っていようと9バイトです。こうした感じで、コンパイル時にサイズが決まるということは、メモリ確保がコンパイルタイムにできるようになります。しかし、クラスはコンパイル時というよりランタイムにメモリ確保をするため、ヒープ領域は確定していません。クラスの場合は、特に循環参照などの問題が出てきますが、サイズの面では問題がないのです。
逆に言うと、クラスで循環参照の問題が特有に出てくるので、unowned
やweak
、strong
といったメモリのオーナーシップを使って、循環参照という参照型ならではの問題を回避する手立てが言語によって提供されているという感じです。
さらに逆に、構造体にすることによって循環参照という問題は起こりませんが、そもそも構造体が循環して定義することが許されていないので、そういった問題が発生しません。この事情により、構造体の場合は型を循環させることができないのです。メモリーを確定できないからという理由ですね。そのため、メモリーが確保できない都合があるためです。
例えば、struct Sheet
があったとします。ストラクトシート1個挟んでも一緒です。Aが何も指していないとしましょう。Bが例えばこうあるとして、構造体も初期値がわかりにくいので、初期値を消しておきましょう。Aは特に他のBやCを持たないけれど、BがCを持っています。この状態なら問題なく、Aは17バイト、Bは1バイトで通っていて、さらにCもサイズを出しておきます。プロパティを持っていないので0バイトです。
このように、ABCはそれぞれ存在できますが、ここでAがBを持つようにしましょう。さらに混乱しないようにAがBを持ち、BがAを持つとします。こうすると、コンパイルエラーになります。AがBを持っていて、BがAを持っているので、循環参照が発生します。このような場合、Aのサイズを決めるためにはBが必要で、Bのサイズを決めるためにはCが必要で、Cのサイズを決めるためにはAが必要となり、大変な問題が起こります。
値型ならではの特有の問題、つまり型の循環の問題が発生します。一方、列挙型(enum
)も面白い状況があります。例えば、enum
でCSX
やCSY
を持たせるとします。ここでは循環しちゃうので、どこかを切っておく必要がありますが、例えばここでは、値が循環できる列挙型の話をします。アソシエイトバリューとしてint
を持てるenum
ではこうなります。
enum E {
case x(Int)
case y(Int)
}
このように、表現力の高い列挙型がありますが、ここで自分自身の型を使おうとすると基本的にエラーになります。これは循環する列挙型です。このとき、「インダイレクトでマークされていないからダメ」というエラーが出るわけですが、これも同様に、enum
という列挙型があり、アソシエイトバリューとして自分自身を使うとサイズが決まらない問題が発生するからです。
例えば、x
かy
にint
型の値を取るとサイズはいくつ?という問題が発生します。試してみると9バイトになります。 こんな風にね、サイズ Int
を取るかもしれない分の 8 バイトが欲しいし、かつ X
か Y
かっていう候補が 2 通りある都合で、まあ 1 バイトぐらい取っておいたらいいのかなと思います。これがケースが 256 個になると、多分 10 バイトになると思うんですけど。そしてこれを Bool
にしても 9 バイトですよね。String
にすると 24 バイトぐらいかな、いや 17 バイトぐらいですかね。今 String
が 17 バイトですか。まあいいや。
とりあえずね、こんな感じで。このサイズが、両方 C
言語で言うユニオン型みたいな感じですね。1 つのメモリー領域をどう解釈していくか。その解釈をできるだけの最低限のメモリーを確保してくれて、ケースがどっちかっていうフラグと、あとは Int
と String
両方を十分に保存できるだけのメモリーサイズを確保してくれるわけです。
そうそう、コメントちょっと拾っちゃいますけど、面白いですよね。全然考慮に入れてなかったけど、循環させるとこういう問題が起こってくるのが面白いです。この循環の問題が、前回見たサンプルの中で構造体に変えたら起こっちゃいました。それをちょっと残りの時間で見ていきたいんですけど、まずはちょっと列挙型の話を終わらせてから行きますね。
とにかく、メモリーサイズの最低ではなく最大ですね。どっちかを取った時の最大です。String
が 16 バイトみたいですね、今は。昔は 24 バイトぐらいメモリーサイズを取ってなかったっけな String
って。まあ、中でバッファーを持っていると思うので大して意味ないと思うんですけど、16 バイトなんですね。でもこれも不思議ですよね。例えば 16 バイトで表現できない文字列を管理しようとしたら、きっとヒープを使うと思います。String
型は多分それでサイズを見てみます。メモリーレイアウトで sizeOfValue
で確認すると、16 バイトなのに 20 バイトの文字を表現できるはずがないので、裏でバッファーで持ってると思いますが、これが別に 1 文字ならスタックで処理することはないでしょう。そうすると 8 バイトで良さそうな気がしますが、何か特別な最適化でもしてるんですかね。as Any
とかやると 24 バイトぐらいになります。いや、32 バイトですかね。Any
だと 32 バイトぐらい取っちゃうのね。面白いですね。String
型はどういう理由で 16 バイトにしてるんでしょう。まあまあ、それは置いときます。
まとめると、String
は 16 バイト、Int
は 8 バイトで、かつケースを 2 択で表現する最大のメモリー領域、最低限ではなく最大です。これを表現できる最低のメモリー領域は 17 バイトという風に考えます。
ここを E
にした時、この最低限のサイズというのが E
のサイズプラス候補ということになります。ここで循環が発生するわけですね。E
のサイズを決めるためには E
のサイズプラス候補が必要になる。これにより循環してしまうわけです。
ここで indirect
をマークすることを指定されているので、indirect
にするとコンパイルが通ります。これも面白い点です。この時にサイズに注目してください。サイズが 8 バイトになりますね。indirect
を付けることによって列挙型の値がポインターとして管理されるわけです。メンバーによらず 8 バイトのポインターサイズになります。これにより、必ず 8 プラス選択肢の 1 バイト、つまり 9 バイトでどんな状況でもこの列挙型 E
を表現できるようになります。ですので循環せずに済むわけです。
このように enum
も普通の enum
だった場合には self
が 9 バイトということになります。以上が今回の内容でした。 それがインダイレクトENUMになると、セルフ自体にはポインタの8バイトが確保されます。その指す先が9バイト…。うーん、ここはちょっとわからないですが、とにかくこんな感じで確保されるようになります。これは三章型(インダイレクトな列挙型)になるので、循環して列挙型を扱っていくことができる、という構造になるわけですね。
で、ところで三章型になったわけですが、これって強参照や弱参照ができるのかな?でも値型だから、循環参照はここで起こらないから問題ないのかどうか…。ケースで Z
が AnyObject
を持ったらどうなるんでしょうね。でもこれは強参照するからいいのか…。強参照するので、循環した分だけリファレンスを持って、問題ないか。そうですね。循環参照を作るには、えーと、関連値(Associated Value)が必ず強参照で持たれるので、強参照循環が起こるとしたら普通に起こるということですね。
列挙型がどうこうという話ではなさそうですね。でもちょっと試してみたいのが、Weak 参照でできるかどうかです。やってみますね。
weak var E
こうすると、やっぱり駄目ですね。Weak 参照はクラスでしか使えないと言われました。いくら三章型的な存在に変化したとしても、これはできないんですね。
もう一つ試してみたいことがあります。クロージャも三章型じゃないですか。これを Weak 参照でオプショナルにしてみると…。
weak var optionalClosure: (() -> Void)?
これも駄目ですね。クロージャの場合は、キャプチャリストという別の手段を使って循環参照を解消します。なのでそちらを使えば大丈夫ですね。
とりあえずこんな感じで、列挙型は値型ですが特別なキーワードを使うことにより循環できるようになっている、ということです。
では、話を戻したいと思います。その前に、先ほどの例で C
を持たせた途端に循環してしまうという件を見てみましょう。
struct A {
var b: B
}
struct B {
var a: A
}
間違えました、B
と A
の間に循環構造があると、自分自身のサイズを決められないというエラーになります。構造体の場合、列挙型に使える indirect
キーワードは使えないので、値型が参照型に変化せずこの循環は解消できません。
元のコードに戻りますが、これをどうするかというと、構造体で都市(City)や国(Country)を表現するという要求を満たせるかを考える必要があります。前回、できる気満々でこのサンプルコードを構造体に変えていきましたが、循環してしまいました。
これは作りの問題かもしれないですが、そもそもそういう問題があまり起こった記憶もないですね。無理やり遊んでいるときは循環したりしますが、今回は真面目に値型をモデルデータ型に落とし込んでいましたので、ちょっと不思議な状況に陥っています。
時間になったので今回はここまでにします。次回、これを書き換えていけるかをもう少しやってみて、駄目かどうかをその時に判断しますね。今日はこれで終わりにします。お疲れ様でした。ありがとうございました。