Java Generics Hell - インスタンススコープのジェネリクス

Java Generics Hell アドベントカレンダー 8日目。

読者の推奨スキルとしてはOCJP Silverぐらいを想定している。

ようやくクラスの型変数の話にたどり着いた。なかなか話題が多くて大変なのである……。

インスタンススコープ

ここのところしつこくJavaジェネリクスの型変数には2種類のスコープがあることを挙げてきた。

  • メソッドの範囲で有効な型変数
  • クラスのインスタンスの範囲で有効な型変数

前回、メソッドスコープのジェネリクスについて取り上げた。入門書などではあまり触れられていない構文かもしれないが、機能的にはメソッドスコープのほうが単純だと思うので先にメソッドスコープを取り上げた次第である。

メソッドスコープではメソッドの IN / OUT つまり、引数と戻り値を型変数を用いて関連性を示すことができるということを書いた。インスタンススコープではあるクラスのインスタンスに対しての IN / OUTの範囲で型変数を用いて関連性を示すことができる。つまり、あるインスタンスを外から眺めた場合に

といったところで型変数によって汎化することができる。エンクロージングインスタンスというのは内部クラスのインスタンスのことで、気になる人は拙稿 Javaのクラス宣言5種+αあたりを参照して欲しい。通常はあまり意識することはないだろう。

クラスの内部のコードとしては、ローカル変数宣言などでも型変数を用いることができるし、型変数へのバインドに型変数を用いることもできる。

ジェネリクスの事例

ジェネリックなクラスの「利用」に関してはjava.util.Listなどのコレクション・フレームワークがよく挙げられる。オブジェクトを保持する目的の汎用ライブラリだ。また、Java8で導入されたStream APIでも多用されている。いわゆる関数型プログラミングを行うライブラリといったところだろうか。

ジェネリクスについて学ぶ際、どこを目標にするかという問題がある。

  • 型変数のあるメソッド、型変数のあるクラスの既存ライブラリを使うことができる
  • 型変数のあるメソッド、型変数のあるクラスを設計することができる

入門レベルだとまずは前者の「利用」が目的となるだろう。しかし、「利用」を確実なものとしようとすれば、メソッド宣言、クラス宣言から型変数がどういう意味合いで宣言されているか読み解ける必要が生じるだろう。そうしたコードリーディングと、自身のプログラミングの試行錯誤でもってジェネリックなメソッド、クラスを設計することができるようになるだろう。

まずは利用、そこから設計に進むのが順当だろうと思う。本稿では軽く、型変数を用いたクラス設計について大雑把な指針を示しておこう。

ジェネリクスで汎化するにあたって、その型変数の個別の具体的な機能に依存することはできない。ここまででまだ取り上げていないが型変数の境界を利用することで、継承階層によってある程度対象となるクラスを具体化することもできるが、境界がない場合は「全てのクラスに対して行える操作」の範囲で型変数の型を取り扱わなければならない。

具体的には

  • 正確にはjava.lang.Objectに宣言されるメソッド
  • 参照の代入
  • 参照の有無 (要するにnullかそうでないか)

ということになる。

ジェネリクスの事例としてコレクションフレームワークが挙げられるのは、まさにこの「参照の代入」「参照の有無」という機能を中心としているのでジェネリクスの題材として具合が良いのである。

データ抽象

言語によっては予め言語に特別な意味を持った型が組み込まれている。Javaでもプリミティブ型や配列といった特別な型が存在する。

プログラマが独自に定義できるデータ型、つまるところJavaでいうクラスのようなものだけでその言語全ての型が取り扱われるならば、それらは同じように抽象的に扱えるわけである。そうした概念を「データ抽象」という。

データ抽象とジェネリクスは関連が深い。抽象化されているからジェネリクスの型変数で抽象的に同じように扱えるというわけである。しかし、Javaの場合はプリミティブ型や配列は特殊な型として言語設計されているため、これらをジェネリクスで一般的なクラスと同列に扱うことができない。

演算子オーバーロード

データ抽象と関連深いテーマに演算子オーバーロードがある。プログラマによって+や-といった「演算子」を型ごとに独自に定義することができる機能である。

C++ではintなどのプリミティブ型に対してもジェネリックなコードを書くことが出来るが、こうした型に対して行える「抽象的な操作」こそ、演算子なのである。つまり、intの値が2つ与えられた時、ふたつを「足す」というコードを書くことが出来るが、同じようにdouble型に対しても「足す」ことが出来る。同じ操作が出来るから、ジェネリクスによって汎化させることができる。

この「同じ操作」を表現するためのインターフェースとして現れるのが「演算子オーバーロード」である。単なるメソッド呼び出しのシンタックスシュガーというわけではない。

Javaの初期に演算子オーバーロードが導入されなかった理由としては、初期のJavaジェネリクスが間に合わなかったことが挙げられるだろう。ジェネリクスなしでの演算子オーバーロードはコードの見た目を整えるシンタックスシュガー的な意味合いだけになってしまう。初期のJavaの設計段階で掲げられたKeep it simpleという標語を考えれば、データ抽象的な意味合いを持たない演算子オーバーロードなどはまさにKeep it simpleのために捨てられるような機能であったと思う。

今のところ、Javaでもプリミティブ型に対してもジェネリクスで扱えるように拡張しようという計画はあるが、演算子オーバーロードを導入しようという話は聞かない。

まとめ

  • インスタンススコープのジェネリクスではインスタンスのメソッド操作、フィールド操作といった広い範囲で型の関連性を示すことが出来る
  • 型変数で扱う型といえども、それらに対して「同じ操作」が出来る前提で抽象的な設計をする
  • もっと根源にある「同じ操作」とは「代入」であり、他にもnullとの比較やjava.lang.Objectに定義されるequalsやhashCodeなどを用いれる
  • このためコレクションフレームワークと相性がよい
  • 型変数の境界を用いればより具体的な型で抽象化するようなこともできる(後に取り上げる予定)
  • データ抽象という概念と、そのための演算子オーバーロードJavaでは導入されていない)

次回は型変数の境界あたりを取り上げたい。