Javaの型システムとコンストラクタ

今回はJavaの型システムのコンストラクタについて考えてみたい。

Javaの型システム、あるいはJavaオブジェクト指向において、コンストラクタという存在は特殊な存在だ。

コンストラクタ内からはそのクラスのインスタンスフィールドにアクセスできる。これは通常のインスタンスメソッドと同等のスコープであってstaticメソッドのそれとは異なる。しかし、コンストラクタを呼び出すにあたってはインスタンスのメソッドという体ではなく、staticメソッドのように(インスタンスではなく)クラスに属するものとして呼び出すことになる。(もっともnewという専用のキーワードを用いるのでそうは見えないかもしれないが)

クラスやinterface、つまりJavaの「型」によるポリモフィズムの世界を考えるとき、コンストラクタはのけものである。継承関係を持つクラスであってもコンストラクタは継承されないし、オーバーライドすることもできない。

コンストラクタのおさらい

コンストラクタは次のように宣言する。隅カッコは省略可能を意味する

【アクセス修飾子】 【型変数宣言】 クラス名(【引数, ...】) {
  【this(【引数, ...】);】
  【super(【引数, ...】);】
}

ここでアクセス修飾子はpublic protected privateを宣言でき、省略するとパッケージプライベートとなる。public修飾子を用いることが多いが、abstractクラスにおいてはprotectedとするし、singletonパターンやstaticメソッドだけからなるユーティリティクラスなどではコンストラクタをprivateにして外部からの想定外のインスタンス化を防ぐことだろう。

また通常のインスタンスメソッドでは使用可能なsynchronized, strictfp, finalといったキーワードを指定することはできない。synchronizedキーワードの指定はsynchronized(this){}とsynchronized句を書くのと同等の簡易記法であって、ロックオブジェクトがthisなのであるからコンストラクタに指定してもナンセンスであることが分かるだろう(詳しく知りたい人はオワコンであるVectorとListの同期の話 - プログラマーの脳みそあたりを参照)。またコンストラクタはオーバーライドできないのであるからfinalキーワードがナンセンスであることも分かるだろう。

コンストラクタ内でだけ厳密浮動小数演算を行いたい場合にstrictfpキーワードを用いれないのは不便な気もするが、コンストラクタなのだから演算結果をフィールドなどに保持することになると想定され、だったら最初からコンストラクタ限定ではなくクラス全体をstrictfp指定しておけよということなのではないだろうか。

話は変わって、ジェネリクスの型変数宣言だが、コンストラクタでもメソッドスコープのジェネリクス型変数の宣言を行うことが出来る。しかし、未だにコンストラクタのメソッドスコープの型変数が役に立つシチュエーションに遭遇したことがない。コンストラクタの型変数宣言については忘れてくれていい。なお、型変数にバインドする場合は次のように記述する。

new <String>Hoge();

繰り返しになるがコンストラクタの型変数については忘れてくれていい

メソッドスコープのジェネリクスとはなんぞという人は拙稿のJavaジェネリクス再入門 - プログラマーの脳みそあたりを参考にしてほしい。

コンストラクタは同クラス内に宣言された他のコンストラクタ(シグネチャの違うオーバーロード。なおシグネチャとは大雑把に言えば引数の形状のことである)に処理を委譲することができる。この際にはthis(引数);という呼び出し方をする。なお、このthis()はコンストラクタのブロックの先頭に書かなくてはならないという制約がある。

一般に引数の少ないコンストラクタから引数の多いコンストラクタを呼び出す形になる。

public Hoge() {
	this("no name");
}
public Hoge(String name) {
	this(name, -1);
}
public Hoge(String name, int age) {
	this.name = name;
	this.age = age;
}

つまるところ引数のデフォルト値を定義するようなものだ。なお、this()の手前には文が書けないのでMapを作って値をput()して…といったものをthisの引数に渡したい場合、適当なstaticメソッドを用意してデータを加工するのがよい。

継承階層とコンストラク

同様にスーパークラスのコンストラクタに処理を委譲する場合はsuper();を用いるスーパークラスにデフォルトコンストラクタ(引数のないコンストラクタのことを指す)が存在しない場合、明示的にsuper(引数);として宣言しなくてはならない。

継承関係にあるクラスのコンストラクタが呼ばれた場合、super()の記述があればまず親クラスの該当コンストラクタが処理される。super()の記述がない場合、親クラスのデフォルトコンストラクタが呼び出され処理される。

ここでコンストラクタの動きをおさらいしておこう。Javaのクラスは、コンストラクタが全く宣言されていない場合、暗黙にデフォルトコンストラクタが存在するものとして扱われる

public class Hoge {
	// コンストラクタが宣言されていない
}

コンストラクタは宣言されていないが、もちろんnewを行うことができる。

Hoge hoge = new Hoge();

これは、暗黙に次のようなコンストラクタが存在しているとみなされるためだ。

public class Hoge {
	// 空の実装のpublicコンストラクタ
	public Hoge() {}
}


次に、明示的に引数をもつコンストラクタが宣言された場合を見てみよう。

public class Piyo {
	public Piyo(int i) {}
}

この場合、次のような引数なしのコンストラクタ呼び出しはコンパイルエラーとなる。

Piyo piyo = new Piyo(); // コンパイルエラー!

コンストラクタが宣言されている場合、宣言されたシグネチャのコンストラクタのみが存在することとなる。デフォルトコンストラクタはあくまでひとつもコンストラクタが宣言されていない場合のデフォルトだということだ。

次に、継承を見てみよう。親クラスAがあり、子クラスBがある場合

public class A {
	public A() {
		System.out.println("A");
	}
}

public class B extends A {
	public B() {
		System.out.println("B");
	}

	public static void main(String[] args) {
		B b = new B();
	}
}

これを実行するとコンソールには以下のように出力されるだろう

A
B

クラスBのコンストラクタが実行される前に、親であるクラスAのコンストラクタが暗黙に呼び出される。クラスAを継承しているということは、クラスAのコンストラクタを呼び出した上で、拡張的な機能を持つということだ。なので親であるクラスAのコンストラクタを呼びださなくてはならない。明示的に書くと次のようになる。

public class B extends A {
	public B() {
		super(); // 親クラスのコンストラクタ呼び出し
		System.out.println("B");
	}
}

この時、親クラスAに複数のコンストラクタがある場合、そのうちのどれを使うかを選択することになる。

public class A {
	public A(int i) {
		System.out.println("A int "+i);
	}
	public A(String s) {
		System.out.println("A String "+s);
	}
}

public class B extends A {
	public B() {
		super("B");
		System.out.println("B");
	}

	public static void main(String[] args) {
		B b = new B();
	}
}

これを実行すると

A String B
B

と表示されることだろう。

ここで、クラスAが引数をもつコンストラクタを定義しているわけだが、クラスBではおなじシグネチャのコンストラクタを定義すること無く、Stringの値を"B"と固定してしまっているわけだ。すると、親クラスで引数がStringのコンストラクタを定義しているにも関わらずクラスBは引数Stringのコンストラクタを持たない。

あるいは次のようにStringの他にパラメータを受け取るようなコンストラクタを定義することもあるだろう

public B(String s, int i) {
	super(s);
	this.i = i;
	System.out.println("B");
}


このように、クラスが継承関係にある場合、親クラスは必ず必要となるパラメータをコンストラクタを通じて必ず受け取るということを子クラスに課すことができる。しかし、それは子クラスのコンストラクタのシグネチャを固定化するという制約ではない。最低限必要な情報は渡せ、ということだが、別にnewの呼び出し方を決めようというわけではない

あるクラスで、必ずある状態を持っていなければならないのだとしよう。デフォルトコンストラクタでインスタンスを生成し、setXXXで状態をセットするということをした場合、オブジェクトが生成されてから正しく使えるようになるまでの間に不完全な状態が存在することになる。コンストラクタ内で処理することでこうした不完全な状態を持つことなくインスタンス生成を保証できるようになる。

継承関係を持った場合でも、前述のsuper()の呼び出しによってコンストラクタの原子性は保たれる。

ポリモフィズムとコンストラク

あるメソッドの引数に

public someMethod(List<Hoge> list) {}

といった形でデータが渡されてきた場合、Listの中にはHoge型か、あるいはその継承クラスのオブジェクトが格納されていることになる。もしくはHogeがinterfaceである場合、Hogeインターフェイスの実装型が格納されていることになる。

このとき、listからgetしたオブジェクトをinstanceofで何型か調べて条件分岐することも可能ではあるが
そのようなコードが肯定される状況というのは極めて少ない。一般に、Hoge型であると宣言されている以上、実体が何型であるかなぞ気にせず一律にHoge型であろうとして取り扱う。それを可能とするためにHoge型の継承クラスはHoge型で提供されるメソッドをすべて機能する形で提供しなくてはならない。リスコフの置換原則というやつである。

Hoge型を継承したPiyo型があったとして、一旦Hoge型の変数に代入されてしまえば、以後はHoge型に定義された
メソッドしか呼び出してはならなくなる。Piyo型がPiyo型独自の情報を内部に持っていたとしてそれらの設定を行うためにはPiyo型がHoge型変数になってしまう前、具象型のPiyoを知っている箇所で行う必要がある。(ダウンキャストは禁じ手だ)

さらに言えば、インスタンスはどこかでnewされなければならず、そのコードは具体的にクラス名を記載した上でnewを行う。その箇所にその具象型の生成に必要な情報を流し込み、その箇所に具象性を閉じ込めてしまうのである。一度生成されてしまえばListにaddしてsomeMethodに渡すことができる。someMethod内ではその型が何かは問われずHoge型として扱われる。ポリモフィズムによって同じ形状のメソッドで同じように扱われるにあたって、コンストラクタはその具象性を隠す場所となる

リフレクション

本来はnewを行う場所というのはその具象型を知った上で書かれたコードのはずだった。ところが、具象型を知らずしてインスタンス生成を行うことが可能なのである。そう、リフレクションによってね。

Class<?> clazz = Class.forName("Hoge");
Object object = clazz.newInstance();

デフォルトコンストラクタのある場合は上記のように簡素にjava.lang.ClassからnewInstance()することでインスタンス生成を行える。引数を伴うコンストラクタを呼び出す場合はjava.lang.reflect.Constructorを取得してnewInstance()する。

Constructor<?> constructor = clazz.getConstructor(String.class);
Object object = constructor.newInstance("hoge");

これにより具象型を知らずともクラス名を文字列で与えればインスタンス生成が可能であることが分かるだろう。このとき、コンストラクタのシグネチャがどのようなものであるか、Javaの型システムは何らの情報を与えてくれない。

リフレクションを多用したプログラムを書いているとコンストラクタのシグネチャに対して制限がかけたいと考えることもあるだろう。しかし、それはJavaオブジェクト指向、あるいはJavaの型システムの外の話なのである。コンストラクタに具象性を閉じ込めたからこそ僕らはポリモフィズムを扱えるのであり、コンストラクタが具象性を一手に引き受けるからこそシグネチャは定まらないのである。

もしどうしても具象型のインスタンスを生成する機能を型システムの範疇で扱いたければ、いわゆるBuilderクラスを作ることになる。

何を優先するか

しかしながら単にデータを格納しておくだけのクラスに対してまでいちいちBuilderクラスを作るのは馬鹿らしいとかんがえる向きもある。例えばO/Rマッパーであるとか、DIコンテナであるとか。とかくフレームワークと呼ばれるようなプログラムではユーザが定義したフレームワークが知らないクラスをフレームワーク内部でインスタンス生成したくなることがある。

このとき、super()の呼び出しによってコンストラクタの原子性が〜という議論は多くの場合当てはまらない。Javaの型システムにおけるコンストラクタの機能性を捨ててしまっておよそ問題がないというシチュエーションが多いのである。しかるに、フレームワークインスタンス生成の対象となるクラスには「デフォルトコンストラクタを持つこと」というルールが課せられることになる。もちろん、Javaの型システムはこの制約に対して何らの機能性を提供しない。C#ジェネリックだとnew制約でこうした制約を課すことができるが、無難な妥協点であると言えよう。

Javaの場合はデフォルトコンストラクタの有無をコンパイルタイムに判断してエラーとすることはできないが、
デフォルトコンストラクタによるオブジェクト生成が行いたいのであれば引数にjava.lang.Classを渡すのが一般的である。java.lang.reflect.Constructorを渡せばよいのではないかと考えるかもしれないが、newInstance()メソッドの引数に何を渡すのかという部分で大抵はどうにもならなくなるのでやめておいた方がいい。

より複雑なオブジェクトの初期化ルーチンが必要であればBuilderクラスを作って渡すことになるだろう。Java8であれば簡素なBuilderならラムダ式で幾らかは記述の面倒臭さは緩和されるかもしれない(といっても許容できないと考える人も多いだろう)。Builderパターンの場合はつまるところBuilderのコンストラクタにオブジェクト生成に必要な特殊条件を詰め込むことになる。結局はどこかに具象型の具象性を隠さねばならない。

まとめ

僕達がオブジェクトの生成で取れるスタンスはみっつある。

  • コンストラクタに具象性を隠す
  • Builderクラスを作ってそこに具象性を隠す(たらい回し)
  • 具象性を排除してデフォルトコンストラクタのみの制約にする

一般に型システムを考えるとき、コンストラクタは「型」の部分に含まれない。しかしながら、型システムの具象性の始末をつけてくれるのは他ならないコンストラクタだ。型システムとポリモフィズムが光なのだとすれば、コンストラクタは影の部分なのかもしれない。