https://youtu.be/70VTu_7A6EE
今は オプショナル
について眺めていっているところですけれど、その特殊性からついついいろいろ脱線気味です。今回も、前回の最後の方に教えてもらった参照型を扱う際の オプショナル
にみられるメモリー確保の様子が面白かったのでもう少しだけ詳しくそれを見てから、改めて オプショナル
の基本に戻っていこうと思います。よろしくお願いしますね。
———————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #141
00:00 開始 00:10 今回の展望 01:38 多重オプショナルのメモリーサイズ 03:44 前回の自分の認識は間違い 04:59 それぞれの段数に応じた nil を示すポインター 13:17 オプショナル型は最大で何段階まで多重化できるか 14:09 オプショナルの段数の範囲はなぜ 9 ビットなの? 16:47 オプショナル型を限界まで多段にしてみる 17:27 型パラメーターの段数上限と、実の型としての段数上限 22:15 自作の nil が機能しているか確かめる 25:35 オプショナル型にインスタンスが入る場合も自作してみる 26:02 あくまでも参照型を扱うオプショナル型での話 26:49 こうしてオプショナル型を見てきた印象 27:14 NULL ポインターと 0 番地 28:35 メモリー保護 28:50 クロージング ————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #141
引き続き、オプショナルの話をしていこうと思います。前回、オプショナルが何重にもなったときのメモリの取り方に関する話題がありました。これが非常に面白かったのと、私自身の認識が間違っていた部分もあったので、もう少し詳しく見てみたいなと思います。
この話はアプリケーションを作る上では直接的には役に立たないことが多いですが、機会がないとなかなか見ることがない部分ですし、詳しい方々が関わる話題でもあるので、こうした話を聞くことでステップアップの機会につながるかもしれません。では、そのあたりを再度確認していきましょう。
具体的に、オプショナルが何重にもラップされたときの話です。例えば、ストラクトがあって内部に Int
型の値を持っているとします。ストラクトが 8 バイトのサイズだとすると、このストラクトがオプショナルになると 9 バイトになり、さらにオプショナルが重なると 10 バイトになります。
しかし、このストラクトがクラスになると状況は変わります。値型から参照型に変わると、8 バイトのサイズが同じ状態で続くのです。この違いは前回教えてもらって、とても興味深い点でした。また、クラスの場合、イニシャライザを必ず実行しなければならないという点で異なりますが、今回はストラクトでもう一度確認してみましょう。
例えば Optional
を 3 重にした MyValue
型があったとして、初期化されているとします。このときのメモリの取り方について、以前は MyValue
がポインタで、各ポインタが 8 バイトを指していると考えていました。しかし、この認識は間違いだったようです。実際のコードを見ると、全く異なる最適化が行われています。
具体的には、ポインタが指しているアドレスが nil
の場合、ゼロポインタを指しています。これが特定の階層の nil
の意味を持つようになっています。これを確認するために、複雑なコードが必要かもしれませんが、例えば bitcast
を使ってゼロを試してみると、初期化されていないことが確認できます。
ストラクトと参照型のオプショナルの違いを見てみましょう。例えば、あるインスタンスがあり、その中に 8 バイトのメモリ領域を持っているとします。これがオプショナルで包まれている場合、そのアドレスが nil
の場合にはゼロになります。しかし、アドレスが1や2、3と異なる場合でも nil
として認識されます。これが4になると bad access
になるわけです。
このように、オプショナルの階層が増えるたびにメモリの取り方が異なり、特定の値が nil
として扱われることがわかります。普段はあまり意識しない部分ですが、詳しく見ていくと思わぬ発見があるかもしれませんね。 バックアクセスについてですが、ここでレッドオブジェクトを let obj = myValue
としてインスタンスを作成します。そうすると、このアドレスの部分に実際のポインターを渡すためにはどうすれば良いのでしょうか。これは let objAddress = UnsafePointer(&myValue)
のようにして、バリューを渡すことができます。
戻り値として受け渡されたアドレスを取得し、このアドレスを渡せば良いのかと思います。ここで UInt
に変換しないといけないのかもしれません。結局のところ、アンマネージドな(管理されていない)アクセスを使用した方が楽かもしれません。とりあえずイメージ通り動くか試してみましょう。これもバックアクセスとして動作するようです。
やはり、関数風の場合、この辺りの無理やりな方法は仕方がないですね。アンマネージドパス、アンリーティングなオブジェクトとして let unmanagedObj = Unmanaged.passUnretained(myValue).toOpaque()
を使うとどうでしょうか。オブジェクトのアドレスをそのままこのポインターのところに渡して、ビットパターンを壊さずに UInt
にキャストし、それをアドレスとして渡すと、ちゃんと myValue
が出ているのです。
この辺りは、本当にこのオブジェクトで作った myValue
なのかを確認するために、エクステンションで MyValue
を CustomStringConvertible
にし、description
としてバリューのディスクリプションを返すようにしましょう。それで実行するとゼロになりますよね。例えばオブジェクトのバリューを100にすれば、100になるわけです。
こういうふうにポインターをそのまま渡して、myValue
にキャストすると、ちゃんとオブジェクトのインスタンスが存在することが証明されます。ここで、自分が間違っていた話が明らかになったと思います。
このようにリンクされたポインターではなく、完全に myValue
がもう一度参照されている状態です。さらに、参照型のオプショナルについてもこうした最適化がされています。例えば、オブジェクトが nil
であれば、それは nil
ですし、1だったら1、3だったら3というように、段階を踏んで、ソフトな参照とダイレクトな参照が最適化されています。
構造体の場合は、1ビット多くフラグを持っていて、それがオプショナルかどうかの情報を付与して管理します。これが参照型と値型の違いです。このようにメモリ配置が異なりますが、それぞれに適した方法で最適化が図られているのです。 なかなか自分の中では特に「ヌルポインター」という言葉はよく聞きますが、「1ポインター」という言葉は聞いたことがありません。しかし、「2ポインター」や「3ポインター」など、いろいろとポインターのアドレスを応用してまとめてくれているようです。
前回の勉強会で、中作何回かという話題でオプショナルのラップ回数が何重までできるかという話がありました。そのときのスラックでの情報共有によると、オプショナルのラップ数の制限が大体255だったと思います。とにかく制限が厳しいのは、オプショナルの扱いがポインターと関係しているからかもしれません。
このポインターに関して、512という数値がしばしば出てきますが、どうして512ビットが上限なのかはよくわかっていません。もしかして、何か特殊な内部の都合があるのかもしれません。例えば、特別な値が指定されているとか、コンパイラの設計思想が関係しているとかが考えられます。具体的には、0x200
が512という意味で、16進数だと512になります。しかし、それがなぜかはまだ解明されていません。
実際にコードを試してみて512
が上限だとわかったのですが、さらに調べていくうちに513
になるとポインターとして有効に働かないことがわかりました。繰り返し実験しても同じ結果でした。ここで一つ気をつけなければいけないのは、メモリレイアウトの問題や環境による違いが影響しているかもしれない点です。
いずれにしても、典型的なメモリサイズの問題や、処理がうまく動くかどうかは試行錯誤が必要です。たとえば、コード内でいくつまで正しく動作するかを確認するためには、一定の手間がかかりますね。実際に試してみると511
までは動くが512
を超えると動かないなど、具体的な数値を確認することができます。
総括すると、オプショナルのラップ回数やポインターの上限など、まだまだ解消されていない謎が多いですが、こうした検証作業を通して次第に理解が深まるのではないかと思います。もし有効な理論や推論があるなら、ぜひ共有して議論したいところです。 さて、現在試しているのは511ですけど、ちょっと曖昧な感じで確信が持てません。でも、どうにもおかしいですね。実際には、510でなければnilになるはずです。512をコピーしてきたと思いますが、間違いでした。512がエラーになるのは確認しました。こういった試行錯誤を繰り返すうちに混乱してきます。
エラーが発生する理由として、例えば「はてなが1個だと0」という現象があります。つまり、実際の数より1個少ない状態になるということです。ですから、はてなが511個なら510までしか認識されません。これでnilとなり、最上位のオプションとしてもnilという結果になります。コメントでの指摘によると、やはり511みたいですね。そうなると、8ビットではなく9ビットということです。512にすると1個エラーになるので、実際には0から511の範囲になります。ここでは、ビットの利用効率が良好ですが、9ビットの問題は解消できません。
このような状況ですが、Swiftのオプショナルの機能について話しましょう。アドレスをゼロとして設定した場合にも、何のエラーも発生しないことがありますが、その理由がわかりません。その際、「サイズが違う」と言われることもあります。ただし、動作上の問題はありません。nilの判定も通常通り行えます。
例えば、if let y = optionalValue { } else { }
のような判定が普通にできるということです。特定の条件下でnilになるかどうかという判定も行えます。ここで重要なのは、オプショナルの階層構造です。1段階のオプショナルと2段階のオプショナルでは異なる動作をします。
switch optionalValue {
case .none:
print("Nil case")
case .some(let value):
print("Value: \\(value)")
}
このようにスイッチケースを使うと分かりやすいです。例えば、オプショナルの中にオプショナルが入った場合、条件によってはnilではないこともあります。
また、アドレスの確認には UInt(bitPattern: address)
を使うと、Y
が得られます。これにより、カスタムストリングコンバーティブルが得られ、その結果として期待通りに動作することが確認できます。
このように、Swiftのオプショナルについてしっかり理解することが重要です。 面白いですよね。これが単純型のオプショナルの話です。これがストラップ(配列)になってしまうと、そもそものバイトタイプが変わってくるから、どこで得られるかと言うと、そもそものメモリマネージメントが関わってきます。これは当たり前ですが、ビットテストのサイズが異なってくるんです。たとえば、ここが8バイト、9バイト、10バイト、11バイトになっているので、データタグの中身は8バイトなのですが、サイズがズレてきてしまうという問題があります。こういった問題は単純型に限定されますが、このような特徴が見えるわけです。
今、ちょうどいい具合の時間になってきましたので、そろそろ勉強会を終わりにしようかと思います。こんな話が役に立ったかどうかはわかりませんが、自分としてはとても面白かったです。特に見落としていた NullPointer の部分が面白かったですね。
NullPointer については、単にポインターのアドレスがゼロになっているだけだと思っていたのですが、調べてみると面白いブログを見つけました。それは、C言語でゼロ番地にアクセスする方法についてのまとめです。これを見て衝撃を受けたのが、ゼロポインターはコンパイラーが特別扱いするということです。処理系でコンパイラーがゼロだった場合、最適化の状況によって特別にエラーにするのです。メモリプロテクションでエラーになるのだと思っていたのですが、この点が見落としていたところでした。
NullPointerや 1
や 2
といった特定の値を意識することで、より丁寧な理解が深まりそうだなと思いました。今回、オプショナルに関するお話もいろいろさせていただきました。
コメントとして「メモリプロテクションは仮想メモリ機能がないと動かない」といただきましたが、確かにその通りですね。だからコンパイラーの方で安全性を万全に期すのでしょうね。
以上がメモリ周りのお話でした。これで勉強会を今日は終わりにします。お疲れ様でした。ありがとうございました。