https://www.youtube.com/watch?v=BW7AR_n6kU0
今回も引き続き A Swift Tour
の「オブジェクトとクラス」を見ていきます。今回は Swift のクラスだけが持つ主だった特徴である デイニシャライザー
と サブタイピング
について眺めていけたらいいなと思ってます。どうぞよろしくお願いしますね。
——————————————————————————— 熊谷さんのやさしい Swift 勉強会 #47
00:00 開始 00:44 デイニシャライザー 04:11 値型と参照型 05:51 値型での解放処理 07:18 参照型での解放処理 08:31 インスタンスの共有 10:19 変数を宣言よりも前で使うということ 14:44 deinit の書式 17:19 意図したタイミングで deinit を実行 22:53 型の種類分け 24:31 deinit はクラス型にだけ存在する 25:17 デイニシャライザーから窺えること 29:15 サブタイピング 30:27 ポリモーフィズム 31:50 サブタイプ多相 33:10 アドホック多相 36:53 パラメーター多相 40:50 クラス継承 41:55 多重継承はできない 44:30 多重継承に似た仕組み 46:30 質疑応答 47:51 ミックスイン 49:36 is-a 関係 49:54 多重継承の問題点 53:06 ダイヤモンド継承 57:18 多重継承についてのまとめ 59:28 所感と次回の展望 ———————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #47
引き続きオブジェクトクラスについてお話しします。前回はイニシャライザーについてお話ししましたが、まだまだ話すことはたくさんあります。ただ、そればかり話していると進行が遅くなり、退屈してしまうかもしれません。そこで、今回はイニシャライザーの続きに加えて、新しい話題に進んでいきましょう。
今回のテーマはデイニシャライザーです。これまでお話ししてきたイニシャライザーやプロパティの定義といった話題は、クラスに限らず構造体など他の型にも通用するものでしたが、デイニシャライザーはクラスだけに用意されている特徴的な機能です。
デイニシャライザーはクラスのインスタンスが解放されるときに呼び出されるメソッドです。実際に試してみると理解が深まるので、プレイグラウンドで見てみましょう。
以下は、デイニシャライザーの例です:
class MyClass {
deinit {
print("Instance is being deinitialized")
}
}
do {
let myObject = MyClass()
// このブロックの終わりでmyObjectは解放されます
}
このコードでは、MyClass
のインスタンスがスコープを抜けるときにデイニシャライザーが呼び出され、「Instance is being deinitialized」というメッセージが出力されます。
クラスは参照カウンターによって管理され、同じインスタンスが複数箇所で共有されることがあります。そのため、クラスのインスタンスが完全に不要になったときにデイニシャライザーが自動的に呼ばれます。
一方、構造体(ストラクト)にはデイニシャライザーは存在せず、代わりにdefer
文を使用して終了処理を行うことができます:
struct MyStruct {
var value: Int
}
do {
var myStruct = MyStruct(value: 10)
defer {
print("MyStruct is about to be deinitialized")
}
// その他の処理
}
このコードでは、defer
文によりスコープを抜けるときに「MyStruct is about to be deinitialized」というメッセージが出力されます。
クラスと構造体の大きな違いは、クラスのインスタンスが複数の変数で共有される参照型であるのに対し、構造体は値型として一つの変数にしか値が割り当てられない点です。そのため、クラスのほうが複雑なメモリ管理を必要とし、デイニシャライザーが重要な役割を果たしています。
例えば、以下のコードではクラスのインスタンスが複数の場所で共有される例を示しています:
class SharedObject {
deinit {
print("SharedObject is being deinitialized")
}
}
var object1: SharedObject? = SharedObject()
var object2: SharedObject? = object1
object1 = nil
// この時点ではobject2が参照しているためデイニシャライザーは呼ばれません
object2 = nil
// ここでSharedObjectのインスタンスは不要となりデイニシャライザーが呼ばれます
このように、クラスのインスタンスは複数の変数で共有され、全ての参照が無くなった時点でデイニシャライザーが呼ばれます。
今回のデイニシャライザーのお話はこれで以上です。次回にはまた別の興味深いトピックを取り上げていきましょう。 基本的なところです。予断ですが、コメントでいただいたもので、今デファーで宣言前の変数も使えてしまっている状態について、確かこの勉強会が始まった頃にちょこっと触れたことがありますね。その頃にできていたから、今もできるんじゃないかということです。オブジェクトをプリントできなくなっているのはコロコロ変わりますね。面白いですね。これが自然な形なんでしょうね。やはりバグが含まれたと言う感じだったのかもしれません。違和感がありましたからね。もしまた使えることがあったとしても、それはバグだろうと思って使わない方がいいでしょうね。
直したポイントがあったらしいですね。ズームのコメントにもありました。本当だ、これを見てなるほどコンパイラーって面白いですね。よかったです。単に使えるだけならよかったんですけど、このリファレンス型のときに解放されてしまったインスタンスを使えてしまうようなとても危険な動きを見せていたのを考えると、直ってくれてよかったです。
でも、宣言より前に変数を使えるというのは、他の言語で一般的になんか不自然だと思うかもしれませんが、言語によってはできるんです。必ずしもおかしいわけではないというのは一つのポイントです。言語によっては、変数宣言をするとそのブロックのトップで変数を定義したものとみなす動きをする場合もあります。そういった言語を使用していると、分かりやすさは置いといて、行っていることが間違いではないという場面が出てくることもあります。これは言語仕様という観点で見れば一応要注意な感じがします。要注意と言っても、そういう書き方も認めるという注意です。
とりあえず、Swiftの場合にはそのような仕様にはなっていないので、デファーで宣言より前の変数が使えてしまう、もしくは他の何かの構文で使えてしまう場合、それはバグなのではないかと考えるのがよいでしょう。
デイニットの話をしていましたね。せっかくなので、もう少しデイニットについて補足しておきたいと思います。デイニットの特徴的な点を一つ二つ挙げてみると、まず引数リストは書きませんというところです。何か他のメソッド的な感覚、特にイニシャライザーと対にした感覚から、デイニットに引数リストを添えたくなるかもしれません。しかし、そもそも引数リストがないので書く必要がないという発想です。ですから、デイニットは必ず引数リストなしで実装されます。
また、このデイニットは明示的には呼べないという特徴もあります。インスタンスに対してデイニットを呼ぶことはできません。基本的にこのデイニットは自動的に暗黙的に呼ばれるものです。そのため、アクセスコントロールの指定もデイニットには存在しません。例えばパブリックやプライベートの指定もできません。これも必要ないからです。
ついでにもう一つ余談ですが、デイニットは明示的に呼べないという話をしましたが、実際にはUnsafeMutablePointerを使うことで明示的に呼ぶことも可能です。例えば、print("Exit")
としてスコープを抜ける前に表示させたい場合、ポインターで扱うオブジェクトに対して、たとえば以下のようにコードを書きます。
let pointer = UnsafeMutablePointer<Object>.allocate(capacity: 1)
これが前回のお話に出てきたアロケートですね。 アロケートは、前回説明したように、通常は言語が暗黙的に行ってくれるので、プログラマーはイニシャライザーだけを気にすれば良いという話をしました。しかし、「UnsafeMutablePointer」を使う場合には、自分でアロケートを行う必要があります。
ここで解放してしまうとどうなるのでしょうか。オブジェクトに対してアロケートを行い、それから自分でアロケートをしないといけないという感じですね。これでまず実行してみます。そうすると、イニシャライザーが呼ばれないまま「Exit」が表示されました。これは想定通りの動きでしたね。
この「UnsafeMutablePointer」の場合、デアロケートする前にオブジェクトを開放したい場合は、「deinitialize」を用います。今回はキャパシティが1個だから、1個で良いでしょう。このように書いて実行してみると、エラーが発生しました。何かが間違っているようで、バッドアクセスのエラーが出ました。たまたま発生したエラーではないようです。
プレイグラウンドがまれに不調になることがあるので、特に原因が見つからなければリセットしてみましょう。デファーをやめてみますか。デファーをやめて自分で制御できる場合は、あえてデファーを使わなくても良いですが、デファーを使った方が楽な場合もあります。プレイグラウンドが原因であれば、再起動してみましょう。
言いたいのは、「deinitialize」は通常明示的に呼ばれないのが基本ですが、「UnsafeMutablePointer」を使っているときには明示的に呼ぶ必要があるということです。これは重要な注意点です。「UnsafeMutablePointer」を使ってクラスを扱うときには、必ず「deinitialize」を呼んでください。これを忘れると重大な問題が発生する可能性があります。
「initialize」を呼ぶ部分がコードで明示されていないため、「deinitialize」を呼び忘れることが起こり得ます。アロケートは明示的に行いますから、「deallocate」も必要だろうと想像がつきやすいです。しかし、値型の場合には「deinitialize」という発想が型自体に無いため、呼ぶ必要はありません。これがポインターを扱う際の注意事項です。
さて、話を戻しますが、参照型と値型があり、クラスには「deinit」が存在します。整理してお話しますと、値型には構造体、列挙型、タプル、オプショナルなどがあります。一方、参照型は基本的にはクラスだけです。ただし、クロージャも参照型として存在しています。クロージャ内で定義した値型はクロージャを共有すると共有できるという特徴がありますが、これはまた後で詳しく説明します。
ややこしい話になりましたが、要するに「deinitializer」は参照型のクラスに特別に用意されている機能です。すべての参照がなくなった時点で自動的に呼び出されます。今回の「UnsafeMutablePointer」に関する話は、実際に使う機会があれば思い出してもらえれば良いと思います。片隅に留めておいてください。 このデイニシャライザーが参照型にあってクラスにあるという話をしましたが、別の見方をすると、デイニシャライザーがクラスにあるということは、クラスは自分自身の状態の後始末をする機能を唯一持っているとも捉えることができます。つまり、このクラスが何らかのデータを持っていた場合、このデータを初期化して最終処理をするという設計が可能です。これにより、クラスは状態を制御するのに適していると言えます。
一方、構造体の場合はデイニシャライザーを持つことができません。初期化はできますが、後始末を完全に制御することはできません。後始末が不要な程度のデータであれば構造体でも問題ありませんが、後でクローズするなどの処理が必要な場合には適していません。そういう観点から見ると、構造体は状態制御には向かないと言えます。
このように、構造体とクラスの性格の違いや使い分けが浮かび上がってきます。デイニシャライザーを通して、この違いが見えてくるのです。クラスは状態を制御するもの、構造体は値を表現するものという捉え方もできます。これは言い過ぎかもしれませんが、様々な型を束ねて一つの型を作るのが構造体、クラスは状態を持ってそれを制御するものという感じで捉えることができます。
構造体が値型であることとクラスが参照型であることも、この考え方でしっくりくるのではないでしょうか。もし構造体とクラスの使い分けがわかりにくいと感じる人がいるならば、このような感覚で比較してみると差異が見えてくるかもしれません。デイニシャライザーは、クラスだけにあって構造体にはないという点も含めて、そういった特徴を理解するきっかけになり得ます。
次に、サブタイピングについて話しましょう。これもクラスだけにある特徴の一つです。オブジェクト指向言語、例えばC++やJavaなどではクラス型が基本であり、サブタイピングは当たり前のものとして受け入れられています。しかし、Swiftにおいてはクラスだけの特徴です。これがSwiftがマルチパラダイム言語と呼ばれる理由の一つだと言えます。
サブタイピングとは、クラスの継承ができることを指します。これは「ポリモーフィズム」の一種です。ポリモーフィズムは複数の形態を持つことを意味します。ポリモーフィズムにはいくつかの種類があり、そのうちの一つがクラス継承、つまりサブタイピングです。
ポリモーフィズムには3種類あります。その一つがサブタイプ多層で、これはオブジェクト指向のクラス継承に当たります。ポリモーフィズムは多様性、多形性とも呼ばれ、ある一つのものが様々な形態を見せることを意味します。
サブタイピングとは、一つのベースクラスがあり、その中に様々な継承クラスが存在し、それらを同じベースクラスとして一括りにして扱えるというものです。つまり、多様な特徴を持つクラスを一つのベースとしてまとめるというものです。 その他にあるポリモーフィズムとして、オーバーロードがあります。これもポリモーフィズムの一環で、専門用語ではアドホック多層と呼ばれることがあります。
オーバーロードとは何かというと、これこそ当たり前の機能で、多くの人が馴染んでいるかと思います。例えば、パラメータ v
を Int
型で取るアクションがあったときに、もう一つ、値を Double
型で取るアクションを定義することができます。これがオーバーロードです。同じ名前、同じ関数シグネーチャで複数の機能を持たせるのがオーバーローディングです。
例として、アクションに対して Int
型を渡したときには Int
型用のものが呼ばれ、Double
型を渡したときには Double
型の結果が得られるという動作になります。このように、11行目でプレイグラウンドが動いていれば、Int
という文字が表示され、Double
型で渡されたときには Double
と表示される、これがオーバーローディング(アドホック多層)です。
現代のプログラミング言語のほとんどはオーバーローディングをサポートしていますが、オーバーロードをサポートしていない言語も存在します。その場合、Int
用のアクションには Int
用の名前を、Double
型用のときには Double
用の名前をつけないといけなかったという時代がありました。これにより呼び出すときも対応する名前を使わなければならず、煩わしさがありました。しかし、オーバーロードというポリモーフィズムにより、これが楽になったのです。
関数を意識したポリモーフィズムはオーバーロードであり、型を意識したポリモーフィズムがサブタイピングです。さらに、サブタイピングの中にはジェネリクスを使った方法としてパラメータ多層も存在します。
ジェネリクスについては、パラメータを取るときにオーバーロードではなく、型は何でもよいというアプローチです。例えば、何らかの型 T
を取り、それをパラメータで受け取って何かを行います。これがパラメータ多層(ジェネリクス)です。
ジェネリクスの例として、今回のプレイグラウンドで示したようなものがあります。オーバーロードとの違いが分かりにくいかもしれませんが、複数の関数を用意するのではなく、さまざまな型に対応した関数を1個定義しているという感じです。コンパイラの設計によって違いはありますが、あくまでもソースコードを書く観点からの話です。
ジェネリクスはストラクトのバリュー型に対しても使えます。例えば、何らかの T
型を持つプロパティを持つストラクトを定義する場合、これもパラメータ多層(ジェネリクス)になります。このように、ジェネリクスは関数だけでなく、もう少し幅広い表現が可能です。
プロトコル指向はパラメータ多層に近い感覚があり、オブジェクト指向はサブタイプ多層、つまり型そのものを多層化する感覚になります。ポリモーフィズムにはこれら3種類があり、Swift のクラスはサブタイピングをサポートしています。 サブタイピングの基本はクラス継承ですね。おなじみのパターンとして、ベースクラスがあり、それが何らかの機能を持っているとします。サブクラスはベースクラスから継承されたものになっていて、そのサブクラスはベースクラスの機能を受け継いでいます。よって、サブクラスのインスタンスを作ると、そのインスタンスはベースクラスの機能を持っているという感じです。
Swiftのオブジェクト指向、特にサブタイピングの特徴として重要なのは、多重継承ができない点です。たとえば、オブジェクトがクラス定義されているときに、サブクラスが複数のクラスを継承することはできません。これがSwiftのオブジェクト指向の大事な特徴の一つです。多くの言語でオブジェクト指向は多重継承をサポートしていませんが、多重継承が可能な言語も存在します。特に、多重継承が可能な言語からSwiftに移行してきた人にとって、多重継承ができないことは大きなポイントになるでしょう。
多重継承の代替手段として、Swiftではプロトコルを使います。Javaの場合はインターフェースを使って同様のことを行います。コメントでも指摘されていましたが、多重継承は複雑になりがちで、管理が難しくなることがあります。そのため、多重継承は基本的に嫌われているという認識があります。多重継承そのものが破滅を招く可能性もあるので、無いほうが良いという考え方もあります。ただし、多重継承を研究してみたい場合は、C++に手を出してみると良いでしょう。
多重継承ができない代わりに、Swiftにはプロトコルがあります。プロトコルを使用すると、クラス継承と並行してプロトコルを継承させることができます。さらに、Swiftのプロトコルにはデフォルト実装を持たせることができるため、プロトコルを介してクラスに共通の機能を適用することができます。これにより、多重継承と似たような効果を持たせることが可能です。
多重継承とプロトコル拡張の大きな違いは、実装がどこにあるかです。多重継承の場合はクラスに対して実装が行われますが、プロトコル拡張の場合はプロトコル自体が実装を持つ仕組みになっています。この違いにより、プロトコル拡張は多重継承のような複雑な問題を回避することができます。この点がプロトコル拡張と多重継承の面白い違いでもあります。
例えば、多様な機能を持たせたいとき、オブジェクト指向ではクラスを継承していかないといけないという性格があります。AからB、BからCというふうにクラスを積み重ねて作っていくわけです。UIキットを見ればよく分かると思いますが、このときに、例えばCからBに合流することができないのです。
クラスA、B、C、Dを作っていったときに、ログ機能が欲しいと感じた場合、継承階層のどこかにログ機能を持たせる必要があります。多重継承が使えない場合、どこかしらの継承ツリーにログ機能を付け加えなければならないのです。その際、多重継承ができれば、新たなログクラスを作り、それを適宜サブクラスに割り込ませることができます。 クラスBはAのようにしますが、ここでAとログの両方の機能を持たせようとすると、それが多重継承になってきます。使いどころとしては、こういったタイミングですね。例えば、Javaでも似たような発想があったと思います。何だったかな、忘れちゃったのですが、インジェクションしてあげる機能ですね。
多重継承は破綻をきたすことがありますが、別の手法で機能を入れ込んでいく考え方として、プロトコル拡張が存在します。要は、2つの特徴を組み合わせたような形にする発想です。ただし、これは近畿とされることもあります。継承は本来「イズア」関係(「AはBである」の関係)だから、多重継承だと話がややこしくなることもあります。
多重継承の問題点についてですが、Bをさらに継承した子供のCが、Bで実装したログのメソッドを使えるのが問題なのかという点ですが、これは具体的な例を挙げると理解しやすいと思います。
たとえば、Cがログの機能を持ち続けるのは大丈夫かもしれません。しかし、複数のクラスからログを継承したときに問題が発生します。ログを継承したXがあったとして、TがそのXをさらに継承したとします。さらにCがそれを継承するような状況になった場合、このログの実装がBについてなのか、Cについてなのかが分からなくなります。
さらに問題になるのは、例えばログというクラスがプロパティを持っていたときに、そのプロパティがどのメモリにアロケートされるのかが分からなくなります。こういう場合、Bもログを継承しているので、バッファを持つことになります。どのメモリにアロケートするかの問題は難しいです。
ここで、ログがプロトコルになったとして、Bの中でログを実装し、さらにCがBを継承したらどうなるかという点ですが、実際にはあまり変わりません。プロトコルはバッファなどのストレージを持つことができないという制約があります。プロトコル自体にはメモリがアロケートされないため、プロトコルエクステンションでもストアドプロパティを持つことはできません。 そうすることによって、どこでもプロパティーを扱うことはできるんですけど、最終的にメモリーをどこで確保しているかっていうのは、プロトコルに準拠させた方が責任を持ってやるっていう制限ができます。しかし、クラスになってしまうと、クラス自身がプロパティーを持ってしまいます。例えば、クラスの中にvar buffer
というプロパティーを持つと、このクラス自身が実体を保存するバージョンを持ってしまうわけです。
こうしたときに、クラスBがバッファーを持つ場合、そのときにクラス継承を使うと、そのクラスそのものの実装としてバッファーが存在することになります。クラスBから見れば、親クラスがメモリ領域バッファーを持っているわけです。同様にクラスBが見ている場合も、Bの親クラスとしてメモリ空間バッファーがあります。つまり、クラスXに対してもバッファーというメモリ空間があり、クラスBに対してもバッファーというメモリ空間があります。
このときにクラスBを作成すると、クラスBの継承元のクラスXを通してバッファーを持っており、クラスBそのものもバッファーを持っているため、結果としてBとその親クラスの両方がバッファーを持っていることになります。このとき、Bはどちらのバッファーを使うのかという問題が、コンパイラーレベルで生じます。
この問題について、C++のような多重継承を考慮した言語の場合は「バーチャル」などのキーワードを使って、どのメモリーを使うのかを明示してあげる必要があります。このあたりは十分に理解されていない場合もありますので、多重継承の問題、特に「ダイヤモンド継承」について、それを議論している人の意見を見ると参考になるかもしれません。
とにかく言いたいこととしては、多重継承を使うことで複雑になり、手に負えなくなるということです。このため、言語によっては多重継承を回避し、シングル継承のみを採用することもあります。Kotlinでは多重継承に対するいろいろな回避策が用意されており、「デリゲーティブプロパティ」なども含まれます。
これに関連して、ミックスインの話も出ました。たとえば、JavaやRubyではミックスインという手法があり、継承に頼らずに実装をミックスインすることで、多重継承の問題を回避しています。このように、多重継承を避けるための様々な手法が模索されているということです。
さて、本日は時間になりましたので、これで終わりにします。もし言い忘れていたことがあったらOJTに書いたり、次回の勉強会で補足したいと思います。とにかく、Swiftでは単一継承であることと、クラスの独特な特徴が今日の重要なポイントでした。
オブジェクト指向は日頃何気なく使ってきましたが、実際には非常に複雑な機能です。それを解消してシンプルにする方法を模索しているのが現代の流れです。Swiftではプロトコル指向を取り入れることで、その問題を解決しようとしています。この話は、また今後の勉強会でプロトコルについて話すときに触れていきたいと思います。
それでは、今日はこれで終わりにします。次回はオブジェクトのサブタイピング、特にオーバーライドについて見ていきたいと思います。お疲れ様でした。ありがとうございました。