オブジェクト指向を語るとき、対象をどのように解釈し、抽象的に表現するのかということについての話題は多い。しかし、なぜそうするのか、どのような利点があるのかを、具体的かつ、簡潔に語られることは少ないように、私は感じている。
例えば、オブジェクト指向の利点として語られる要素の一つに「再利用」がある。しかし周知されている再利用とは、コンポーネントとしての再利用、そのポータビリティであり、その汎用性だ。それがオブジェクト指向ではなく、ライブラリ化による利点に置き換えられているように思うのだ。
ライブラリを多用するPythonの事例を見ても分かるように、その中身がオブジェクト指向であるかどうかなど、誰も気にしていない。
私が思うオブジェクト指向の利点の一つは、抽象化がもたらす融通だ。一般的には柔軟性として表現される特性だが、「融通が利く」という表現の方が、より適切に思う。その言葉が包含する、
- 臨機応変さ
- 寛容さ
の様なニュアンスを感じさせるからだ。たとえ想定外のデータ型が引き渡されても、何の修正もなく、いつもと同じように動いてくれる。そのようなロジックだ。
Any型とジェネリクスを題材に、そのような片鱗を、この投稿で示すことができたらと思う。
ちなみに、サンプル・コードにはKotlin*1を用いている。この投稿末尾にあるサンプル・コードをPlayground*2へコピー&ペーストすれば、実行結果をオンラインで確認することもできる。
シナリオと対応
前提として、次のようなシナリオを考える。
- 何らかのデータ型の情報が存在する
- 荷造り機(packer)は、受け取った情報をコンテナに詰める
- 検査機(censor)が、受け取ったコンテナの中身を報告する。
「何らかのデータ型」は不特定だ。Intかもしれないし、Stringかもしれない。Booleanの場合もあるだろう。このようなシナリオを、できる限りシンプルに実装したい。
packer、censorの処理を汎用的にしようと思えば、コンテナも汎用的に定義しておく必要がある。どのようなデータ型でも対応できるようにコンテナを定義するならば、次の2通りの方法が考えられるだろう
- Any型を納めるコンテナを定義する
- ジェネリクスなコンテナを定義する
Any型の場合
KotlinにおけるAnyとは、全てのデータ型のスーパータイプだ。あるいは、全てのデータ型はAnyのサブタイプとも表現できる。全てのデータ型の基底型となるAny型の値を格納するコンテナを定義する。
class Container(var value: Any)
そのようなコンテナを受け取るpacker、censorは、次のように定義できる。
fun packer(value: Any): Container{ return Container(value) } fun officer(containers: List<Container>){ for(item in containers){ println("value = ${item.value}") } }
別に用意した変数へ、コンテナへ任意の値を代入してみる。ここでキャストしていることに注目してほしい。
fun main() { val intContainer = Container(100) val strContainer = Container("hello") //要キャスト val i: Int = intContainer.value as Int val s: String = strContainer.value as String println("i = ${i}") println("s = ${s.uppercase()}") var containers: List<Container> = emptyList() containers += packer(100) containers += packer("amazon") containers += packer(false) officer(containers) }
前述したように、Any型は全てのデータ型のスーパータイプだ。Any型の値を、適切なデータ型の変数へ代入する場合、ダウン・キャストする必要がある。これは、次のことを意味している。
- 対応すべきデータ型に応じて、キャスト処理が必要となる。
- 不適切なコーディングで、ダウン・キャストに失敗するリスクがある。
特に前者について、前提となるシナリオをより厳密に検討する必要があるだろう。独自に定義したデータ型も含め、あらゆるデータ型に対応するには、それぞれに対するキャスト処理が求められるからだ。
ジェネリクスの場合
ジェネリクス型の値を格納するコンテナを定義する。
class Container<T>(val value: T)
コンテナ型の仕様変更に伴い、packer、censorの定義も更新する必要がある。しかし、処理そのものに変化はない。
fun <T> packer(value: T): Container<T>{ return Container(value) } fun officer(containers: List<Container<out Any>>){ for(item in containers){ println("value = ${item.value}") } }
別に用意した変数とコンテナへ任意の値を代入してみる。ここでは、キャスト無しにコンテナの値を代入していることに注目してほしい。
fun main() { val intContainer = Container(100) val strContainer = Container("hello") //キャスト不要 val i: Int = intContainer.value val s: String = strContainer.value println("i = ${i}") println("s = ${s.uppercase()}") //out Anyで変位指定を解除→あらゆる代入を許可 var containers: List<Container<out Any>> = emptyList() containers += packer(100) containers += packer("amazon") containers += packer(false) officer(containers) }
Any型の場合と異なり、コンテナの値の代入にキャストを必要としていない。そのため、基本的にどのようなデータ型に対しても、共通の処理で対応することができる。
例えば、仕様変更によって特定のデータ型への対応が追加されたとしても、基本的にコードを変更する必要がなくなる。冒頭で触れた再利用による恩恵とは、このようなことを指しているのだ。
広く一般的に流通、共用されることを前提としたライブラリの実装においても、この特性が活用される。転じて、それがライブラリの汎用性にも通じている。結果として、ライブラリの再利用性も向上することになる。
Any型とジェネリクス~抽象的であること
問題は抽象的であるかどうかだ。
Any型は全データ型のスーパータイプであり、全てにおいて汎用的に機能しそうに理解されるかもしれないが、そういうことではない。
Any型という定められたデータ型を宣言した以上、それは紛れもなくAny型である。厳密に定義した以上、型推論も機能しない。従って、異なるデータ型の変数へ代入するには、キャストが欠かせない。
ただし、Containerの中身がAny型なのであって、ConteinerそのものはAny型ではないのだから、censorはContainer型をそのまま受け取ることができる。
一方、ジェネリクスとは、抽象化されたデータ型を指している。ただし定型的なデータ型にせよ、ユーザーが定義したものにせよ、Any型のサブタイプを想定している。
データ型が厳密に定義されていないのは曖昧にされているということであり、つまり抽象化されているということだ。そのような値を特定のデータ型変数へ代入する際、型推論が機能する。結果として、キャスト無しの代入も機能する。
データ型を厳密に定義していないものの、Any型ではないという特性は、コンテナのリスト生成と、それを受け取るcensorにも影響する。この特性を反映した表現が、「out Any」だ。それは変位指定と、型投影という仕組みだ。
変位指定と型投影~抽象性を担保するための仕掛け
問題は抽象性をどのように担保し、維持するかだ。厳密な定義を避けながらも、ジェネリクスの変化を制限する。それが変位指定と型投影だ。
「out Any」は共変という、スーパータイプとサブタイプ間の関係性を表現している。つまり
Container<Any> | Any型を格納するコンテナ |
Container<out Any> | Any型のサブタイプを格納するコンテナ |
前者の場合、100だろうが"amazon"だろうが、Boolean型のfalseだろうが、いずれの値もAny型として処理される。
後者の場合、100だろうが"amazon"だろうが、Boolean型のfalseだろうが、少なくともAny型ではない何らかの値が代入されるものとして、Any型のサブタイプである
もし「Container<out Any>」と指定すべきところを、「Container<Any>」とした場合、「Container += Container(100)」の様な代入は失敗する。なぜなら、厳密に「Container<Any>」型と定義された変数への代入なのだから、厳密に「Container<Any>」へ値をキャストしなければならないからだ。つまり厳密に定義したことによって、抽象性が失われてしまったのだ。
この代入を成功させようとすれば、Any型のシナリオと同様、次のようにキャストすることになる。Any型のシナリオに比べて、キャスト型を統一できるのは利点だが、だったら最初から抽象性を活かすことのできるジェネリクスを採用すればよいと思うのだ。
var containers: List<Container<Any>> = emptyList() containers += packer(100) as Container<Any> containers += packer("amazon") as Container<Any> containers += packer(false) as Container<Any>
サンプル・コード