https://youtu.be/wnXYRi1M5GY
今回から A Swift Tour
の 構造体
に入っていきます。本書での 構造体
を説明するページ分量は少ない様子なのですけど、せっかくなので構造体の特徴的なところで思い当たるところを探してじっくり見ていこうかと思っています。どうぞよろしくお願いしますね。それでも時間が余ったときは、練習問題、それから続いて プロトコルと拡張
の項に入っていくかもしれないです。
——————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #60
00:00 開始 01:10 構造体の基本 04:08 クラスと構造体は別のもの 04:48 クラス継承 05:25 ミュータブルとイミュータブル 09:02 クラスは原則、ミュータブル 09:49 クラスの self 10:38 構造体の self 11:47 self が表すもの 14:33 クラスの self が書き換えられたとすると 20:27 クラスクラスター 23:59 NSString のクラスクラスター 29:01 ファクトリーメソッド 31:47 構造体の self は書き換えてもメモリー配置に影響しない 33:31 クラスのイニシャライザー 37:38 プロトコルで規定したイニシャライザー 39:44 構造体のイニシャライザー 40:16 イニシャライザーの役割 41:35 構造体におけるイニシャライザーの制約 45:50 スタック領域とヒープ領域 52:14 スタックオーバーフロー 54:24 クロージング ———————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #60
今日は構造体についてお話しします。セクションとしては「列挙型と構造体」の中の構造体の説明に入っていきます。スライドは2枚、自作のスライドを使っており、練習問題も含めて構造体の説明を行います。構造体はシンプルな機能ですが、せっかくの機会なので、ゆっくりとじっくり見ていこうと思います。
まず、スライドに沿って進めていきます。構造体は struct
というキーワードを使って定義できます。構造体はメソッドやイニシャライザーなど、クラスと同じ機能を多くサポートしています。この点が構造体の大きなメリットの一つとなります。
例題を見てみましょう。構造体は struct
で名前をつけて定義します。この勉強会でも繰り返し強調していますが、従来の、だいたいC言語やObjective-Cなどの構造体では、プロパティとして値を持つのが一般的なやり方でした。しかし、Swiftでは構造体にメソッドを持たせることができます。それ以外にも、スタティックメソッドやスタティックプロパティを持つことができ、プロトコルに準拠することもでき、型拡張も可能です。スライドにある通り、クラスとほぼ遜色なく様々な機能を搭載できます。
C++のような言語では、struct
も class
もほとんど同じ機能を持ちますが、アクセスコントロール周りで違いがあります。struct
はメンバーがデフォルトでパブリックになり、class
はプライベートになるなどの違いがあります。Swiftの場合、クラスと構造体は全く異なるものとして扱われています。クラスは別のクラスから継承することができ、継承元の機能を引き継ぎ、新たな特徴を追加できるのが特徴です。しかし、構造体は継承ができません。
もう一つの違いとして、構造体をどの変数に入れるかでその性格が変わります。例えば、let
で定義した構造体では、そのプロパティは読み取り専用になります。逆に、var
で定義した場合、そのプロパティは読み書き可能です。
こうして見ると、Swiftの構造体とクラスはそれぞれ異なる特徴を持ち、それぞれの使用シーンに合わせて使い分けができるのが魅力です。次回も引き続き、構造体の詳細な機能について見ていきたいと思います。 バリューに対して値を代入することはできませんが、クラスに関しては中のプロパティをlet
で保護することはしません。しかし、struct
になるとlet
に変数やインスタンスを入れた場合、それが保護の対象になります。var
になると値を書き換えることができるというのが構造体(struct
)ならではの特徴です。
構造体に限っては、mutating
というキーワードが用意されており、このmutating
がついたメソッド内では中の値を書き換えることができるようになります。このmutating
がない場合、値を書き換えようとするとエラーになります。
この性質はプロパティへのアクセスを保護するためのもので、var
ではmutating
付きのメソッドを使うことができますが、let
の場合は自分自身を書き換える機能を使えないためエラーになります。12行目で内部の値を書き換えるコードがなかったとしても、mutating
があるのでlet
の場合には呼び出せないという保護機能が働きます。
クラスの場合には内部を保護する発想はないため、mutating
は不要です。そのため、クラスに対してはmutating
キーワードは使いません。また、クラスと構造体ではいくつか列挙できる違いがあります。
例えば、ファンクション内でself
に別のインスタンスを代入することですが、クラスではできません。この時のエラーに注目すると、"selfはimmutableです"というエラーが出ます。これがクラスのself
の特徴です。
一方、構造体では、通常のファンクションの場合もself
はimmutableですが、mutating
ファンクションにすると、self
への代入が可能になります。mutating
が付いたキーワード内でのself
はmutableになります。
構造体の場合、全てのメモリ領域がself
に割り当てられます。そのため、self
のメモリサイズを調べることで、構造体内の全体のメモリ配置を確認できます。例えば、MemoryLayout.size(ofValue: self)
のような方法で構造体のサイズを取ると、その構造体のメモリレイアウトがわかります。
このように、構造体とクラスそれぞれに特有の動きがあるので、それを理解しながら使い分けることが非常に重要です。 なので、self
が書き換えられないと mutating
が意味を発揮しないという解釈でいいのかなと思います。これがクラスになると、8バイト
に変わるわけです。これはメモリーのアドレスを保存するだけの 64ビット
(8バイト)です。この領域だけに変わります。要は、self
はメモリーの座標を入れているだけで、その指し先に v1
, v2
, v3
があるという雰囲気がここから伺えます。
そういう都合なので、self
がもし書き換えられたとします。例えば、func
で mutating
な関数を作ったとしましょう。これで self
に新しいインスタンスを入れることが mutating
だったとすると、これは可能になります。こうしたときに何が起こるかというと、例えば a
として A
のインスタンスを入れるとします。すると、変数 a
に対してはこの右辺で作ったインスタンス、つまり右辺で作ったインスタンスのあるヒープ領域のアドレスを a
が指している状態になります。
ここで例えば let b = a
とすると、変数 b
も a
と同じヒープ領域の指し先を指していることになります。このとき、a
と b
のアドレス的に一致する演算子は true
を返すことになります。しかし、ここで b
に対して mutating
を呼んだ場合、もし self
が書き換え可能だったとすると、ここで新しいヒープを作って自分自身の指し先を変えることになります。こうすると、a
と b
の指し先が変わってしまいますよね。このように、アドレスの指し先が大きく変わります。
この例だけを見れば、そういうものだと納得できるかもしれませんが、これがもし変数のどこかに代入されたりすると、問題が発生します。例えば、a
だけの時に mutating
を呼んだ場合どうなるでしょうか。もともと a
のインスタンスが mutating
を呼んだときに別のインスタンスに差し替えられると、参照カウントをどう管理するかという問題が出てきます。もし、これについて何も配慮していなかったとすると、もともと33行目で入れておいたインスタンスが行方不明になり、メモリリークが発生します。
もし参照カウントがちゃんと管理されていた場合、代入前にデアロケートが走り、その後新しいインスタンスが設定されます。要は、今まで使っていた a
が全然違うものに差し替わるわけですね。そうすると内部状態も変わってきますし、インスタンスが違えば内部状態も変わってきます。話している限りでは、そういう仕様であれば何とでもなるような気がしないでもないですが、学習コストや認識コストが高くなり、安定したコードが書きにくくなってきます。
例えば let b
で別の変数に参照させた時も、参照先が変わってしまうと問題になりますよね。そのため、インスタンスの保証ができなくなってしまうということですね。この self
のインスタンスが同じものかどうかの保証が難しくなります。特にシングルトンパターンのようなものを作る場合に、何かの拍子で別のインスタンスに差し替わってしまうと、いろいろな変数に入れて取り回していたときに、いつの間にか変わってしまうことがあります。それにより、大変な状態になることがあります。 ただ、Objective-Cはセルフがミュータブルだったので、いつでも書き換えられましたね。そのため、そういう発想はあまりしなかったかもしれません。しかし、もしセルフを変えてみると、いろいろと面白い動きを見せたかもしれません。Objective-Cでは、セルフを書き換えますよね。例えば、インスタンスタイプでinit
などを実装するときにself = [super init]
と書き換えています。こういったイニシャライザーのときにはこのような定石がありましたが、何気なくセルフを書き換えていること自体がよく考えるとすごいことをしていますよね。
このセルフを中で書き換えられるおかげで、クラスファクトリーを作ることができます。例えば、Swiftではできないですが、Swiftで仮に書くと、クラスを型のように扱い、別のバリュー型のインターフェースを持つクラスを作ることができます。例えば、StringValue
クラスやNumberValue
クラスなどを作ります。そして、イニシャライザーの中で値を取り、switch
文で最適なクラスにインスタンスを変更します。つまり、セルフがミュータブルだったときには、値に応じて最適な振る舞いをする互換性のあるクラスにインスタンスを付け替え、プログラマーには意識させることなく動作させることができます。なので、self = NumberValue
やself = StringValue
というふうに内部的に型を見たときにはValue
型を作っていたはずなのに、ナンバーバリューが得られるということができます。
実際にこれを使っているのがNSString
などですね。これはフレームワークの標準的なメソッドやファンデーションで見られることがあります。例えば、次のようにNSString
を使ってみます。
let s: NSString = "テキスト"
このとき、type(of: s)
はNSString
にはなっていません。NSString
を作ったはずなのに、こういう型になっています。同じように、s
に対してNSString
でパラメータを渡しても同じような結果になります。
例えば、
let s = NSString(format: "%@", "some text")
このようにフォーマットを使って書くと、type(of: s)
は別の異なる型になります。
また、C言語のストリングを使ったり、UTF-8
などの文字コードを使った場合も違いが出ることがあります。具体的に、Unsigned integer
などのメンバーも考慮しなければいけませんね。例えば、以下のようにしても同じ結果が得られます。
let s = NSString(utf8String: "some text")
さらに、NSCFConstantString
などパフォーマンスを向上させるための内部的な最適化もされています。文字列リテラルをNSString
で受けると、例えば空文字列だけを管理する方法が異なる場合があります。
このように、内部的なクラスの違いが見受けられることがあります。これらを理解することで、Objective-Cの内部の動作をより深く理解することができます。 きっとね、今コメントに頂いたみたいに、空文字の時はあらかじめ空が用意されているんじゃないか、みたいな頂点の所を読んでみるとか、ポイントを指してるみたいな。もしかすると、特定のワードはあるかもしれないですね。空文字以外あんまり思いつかないですけど。
とりあえず、こういうふうに与えられた色数や初期値によって適切なものを選んでいくっていうことができる。こういうふうな機能もセリフが書き換えられればできるんですけど、Swiftはそういうことができないんです。なので、Swiftでこういったことをやりたい時にはファクトリーメソッドっていうのを作ってあげることになります。
ここの例を復活させると、こうやってイニシャライザーだとどうにもならないので、init
じゃなくてmakeValue
みたいなメソッドをstatic func
みたいにインスタンスに依存しないメソッドとして用意してあげて、この中でself
に対してインスタンスを作るのではなくてリターンしてあげるっていうふうな書き方が必要です。
具体的に書くと、ここでvalue.makeValue
っていうふうに書いてあげる。こういうふうな組みになってくる。ケースが網羅されてないからエラーになっていますけど、こういうふうに作ってあげるみたいな。今コードがあんまりしっかり書けてないので無限ループにはならないか、こうやってあげるとならないですね。こういうふうに書いてあげるという仕組みになっちゃいます。
これはSwiftがself
をクラスにおいてはイミュータブルにしたからっていう、そういった性格で出てくる制約と言ったらいいのかな、特徴の違いですね。何か他に言うことあったかな。
構造体のほうはいくらself
を書き換えられたとしても、ややこしいことは全く起こらなくて、あくまでも構造体におけるself
っていうのは一回確保されてしまえば、そのメモリは状況にもよりますけど、基本的にはスタック領域に全メモリ、今回の場合だと24バイトが取られて、そのスタック領域の24バイトが置かれてる場所は、いくら中身を書き換えても変わることがない。あくまでも書き換わるのは、プロパティの値だけが変わるので、スタック領域のメモリが急にどっかに行っちゃうみたいな、それによって生じる問題はありません。ミュータブルな関数のときにはself
が書き込み可能になっても、何にも問題なく世界が作られていくよ、みたいな感じになっています。こういったところもクラスとストラクトの内部的な動きの違いが見えるところですね。 他にも何かあった気がしますが、今回はイニシャライザーについて詳しく話しましょう。この勉強会ではイニシャライザーについてかなり長く話してきましたが、もう一度整理しておきます。
クラスの場合は、イニシャライザーをクラス内で定義することはできますが、エクステンションの中でディジグネイティブイニシャライザーを実装することはできません。エラーとして「ディジグネイティブイニシャライザーはエクステンションでは実装できません」と表示されます。しかし、構造体についてはエクステンションでも問題なくイニシャライザーを実装できます。この差は、クラスには継承が存在し、初期化を正確に行うことが難しいためです。継承により思いがけないイニシャライザーの呼び出しが発生することがあり、初期化の忘れなどのエラーを引き起こす可能性があります。
クラスのイニシャライザーはディジグネイティブイニシャライザー(基本的なイニシャライザー)とコンビニエンスイニシャライザー(補助的なイニシャライザー)の2種類に分けられます。エクステンションの中でコンビニエンスイニシャライザーは定義できますが、ディジグネイティブイニシャライザーは定義できません。エクステンションの中でイニシャライザーの定義が制限されるのは、初期化が正確に行われることを保証するためです。
ファイナルをつけたクラス(継承を禁止したクラス)でも、エクステンションでディジグネイティブイニシャライザーは定義できないため、この制約は変わりません。ただし、プロトコルが規定しているイニシャライザーをエクステンションで実装しようとすると、エラーが発生します。コンビニエンスイニシャライザーは自分自身のディジグネイティブイニシャライザーに繋がなければならないルールがあります。これにより、プロトコルが要求するイニシャライザーを正確に実装する必要があります。
一方で、構造体の場合、コンビニエンスというキーワードが必要なく、ディジグネイティブやコンビニエンスなどの区別を気にせずに初期化を行うことができます。そのため、エラーもなくスムーズに初期化を実装できます。
クラスと構造体の初期化の違いについて、クラスは継承という複雑な仕組みがあるため、イニシャライザーを厳密に制御する必要がありますが、構造体はシンプルに初期化が行えるので、その違いを理解することが重要です。徐々に慣れてくると、どの場面でどのようなイニシャライザーが必要かがわかってくるでしょう。初期化の保証が必要なため、これらの制約が存在するのです。
以上が、Swiftにおけるクラスと構造体のイニシャライザーに関する説明です。 ただ、構造体も必ずしもどんな場合でも初期化子が正確に動くわけではなくて、例えば自分のモジュールの中では self
が書き換えられますが、このエクステンションが外のモジュールで行われたときには self
に対して書き込むことが禁止されます。この場合、必ず既に用意されている初期化子を使用する必要がありますね。他のクラスで言うコンビニエンスイニシャライザーのような感じです。モジュール外ではエクステンションがされるときとされないときがあり、また、外部の人が関与してくるときに確実に初期化できる初期化子を使用しなければ、矛盾が生じる可能性があります。
例えば、コンテンツを String
として持ち、UnsafeMutablePointer<CChar>
として文字数を管理する構造体を考えてみましょう。これをイニシャライズした後にサイズだけ適当に変えてしまうと、文字列の長さは0なのにカウントは異なるという矛盾が生じることがあります。こうした懸念から、モジュールが分かれると初期化子についての配慮が必要になるわけです。しかし、基本的には同じモジュール内であれば自由度が高く、構造体の性格の違いとして見られる点ですね。
ここから先に進もうかなと思いましたが、コメントが盛り上がっているのでそちらを見てみましょう。スタックとヒープの区別が難しいという話ですね。
実際、自分も完全に理解しているわけではないですが、ざっくり言うと、現代のコンピューターではメモリー領域が4つに分けられて管理されています。最近の大容量メモリー空間がどうなっているかは分かりませんが、昔の8ビットCPUの時代はメモリー領域が非常に狭く、例えば0番地から 0x7FFF
まではプログラム領域としてバイナリーコードが入る場所でした。また、そこから 0x8000
から 0x8FFF
まではヒープ領域、さらに 0x9000
からはスタック領域、といった感じで区分されていたのです。
プログラム領域、ヒープ領域、スタック領域、そしてスタティックデータ領域という区分が一般的に使われていました。呼び方にはいくつか種類がありますが、このような区分方法は、メモリー管理の基本的な考え方として覚えておくと良いでしょう。 データがキーワードに出てきた時って、他の3つ何か覚えてますか?
多分、コードとデータ、ヒープ、スタックみたいな感じですね。
なるほど。じゃあ、やっぱりスタティックかもしれないですね。意味合いは一緒かもしれません。
そうそう、一緒な気がしますね。ちょっとメモリー配置が例に挙げたものはめちゃくちゃな気がするけど、スタックとかデータとか。ヒープとスタックがくっついているのは結構ポイントですね。もしかするとスタティックとプログラムが先にくっつくかなって気もしないじゃないけど。変更しないものが入る領域がプログラムとでスタティック。
だから、RAMはヒープとスタックですよね。ROMはヒープも…。
そうですね。ROMに入ることもある候補ですね。ROM、RAM。ヒープとスタックはRAMでしかありえないって。
そうですよね。本当だ、本当だ。そんな感じだ。そういう特徴からも見られるように、プログラムとスタティック領域、コードとデータ領域はコンパイラーが吐き出すんですよ、あらかじめ。それに対してヒープとスタックはランタイムで確保するんです。ヒープ領域はあらかじめ広大な領域が、広大とか言えないか、割り当てられていて、malloc
とかnew
とかそういったのを使って動的に空いているところを確保して、それで使っていくメモリーですね。
そういえば、ヒープとスタックをなぜそうやって分けているのか、分かるような分からないような。
分かるわ。全部スタックでやるわけにはいかないからか。ヒープはOSとかが管理していて、コンパイラーが管理していて、どこが空いているかみたいな感じで探して一定領域を確保して、使い終わったら解放するとかね。そういう制御ができるんで、細やかにメモリー確保とライフサイクル管理ができる代わりに、いろいろコストが最初イニシャライズやアロケートでかかるんですけど、スタック領域は現在の座標が何番ですよみたいな。何にも使っていない場合で今回の例では9000でしょ。この感じになっていてこれで8バイト使うよみたいな時には、カレントを8バイトずらして、ある変数に対してはさっきまでカレントだった9000を割り当ててあげて、これでどんどん使っていって、新たにBを使うよみたいな時にはポインターをさらに動かしていって、こういうふうに動かしていって確保して。
開放する時にはこのメモリーをデアロケートするとかではなくて、純粋に1個前に戻します、2個前に戻します、みたいにポインターで管理するんですよ。これによって空いているところは何ですかとかをOSに問い合わせて何個確保しますよとか、そういったいろんな手続きを省いて、純粋にメモリーの指先を必要なバイト数ずらす。だからさっきの行動体でV1
、V2
、V3
を定義した24バイトの行動体をスタックに確保する時には、純粋に現在の位置をポインターにとって、その変数のポインターにとって現在位置を28バイト先にずらします、みたいな。そういった風な形でシャクッとメモリー管理ができる。あらかじめ一定領域がすでにmalloc
されているっていうようなそんなイメージ。malloc
って今通じないかそろそろね。そういうふうにあらかじめ一定領域が確保されているメモリー領域がスタック領域。
これを使って関数呼び出しの時とかには、引数に渡した値を全部スタックにドンと入れて、関数からリターンする時にその分をスタックポインターを戻すってことをやっていく。なので、このスタックが関数引数が28バイトあったらこうやって28。その中で引数が16バイトの関数をさらに呼び出すと16。それでさらにまた16の呼び出すとみたいにポインターが動いていって、スタック領域ってあらかじめコンパイラーがね、何バイトとか決めてるんで、これがどんどんリターンせずにどんどん呼んでいったりすると、その領域をオーバーしてしまってスタックオーバーフローっていうね、エラーが出るっていう。待機呼び出し延々とやっているとあっという間に引数渡しがどんどん走っていってスタックが増えていってオーバーフローする。スタックポインターね。
どんだけ今の話で伝わったかわかんないですけど、スタック領域ってね、そういうメモリー管理を単純化した領域。またどっかの機会でスタックとヒープ出てくると思うので、またその時いろいろお話ししましょう。時間が過ぎてしまったので今日はこれぐらいで終わりにしようと思います。
はい、お疲れ様でした。ありがとうございました。