派生クラスへの「変身タイミング」のC++とJavaの違い

恥ずかしながら、知らなかったので投稿です。めちゃ細かい話です。ソースはこちらZIPはこちら。

まとめると

派生クラスの初期化の際には、原則的に基底クラス部分を初期化した後に、派生クラスに「変身」して、派生クラス部分が初期化されるのですが、

  • C++は、基底クラスのコンストラクタが終わるまで派生クラスに「変身」しない。
  • Javaは、基底クラスのコンストラクタの実行開始時からいきなり派生クラスに「変身」済み(ただしフィールドを除く)

オブジェクト指向といえばクラス、クラスといえばオブジェクト

オブジェクトといえば初期化、初期化といえばコンストラクタ!

というわけで、こんなオブジェクト継承関係を考えてみましょう。(Sample1.java)

public class Parent{
	public Parent(){
		System.out.println("親コンストラクタだよー。");
	}
	public void method(){
		System.out.println("親のメソッドだよー。");
	}
}
public class Child extends Parent {
	public Child(){
		super();
		System.out.println("子コンストラクタだよー。");
	}
	public void method(){
		System.out.println("子供のメソッドだよー。");
	}
}

ええと、特に意味のある例が思い浮かばなかったので、安直にParentとChildです。すいません。

この状態で

public class Launch{
	public static void main(String args){
		Child child = new Child();
		child.method();
	}
}

とすると、

% java Sample1 
親コンストラクタだよー。
子コンストラクタだよー。
子供のメソッドだよー。

と表示されます。これは予想通りですよね。親クラスのコンストラクタで、親クラスのフィールドが初期化されたあとに、その派生クラスの子クラスのコンストラクタが呼ばれて、子クラスが初期化されます。入門書通りです。

コンストラクタ中に子クラスのメソッドを呼ぶ…?

さて。親クラスを元にした派生クラスを色々と作って、それらの種類で処理を分ける…というのが、一般的なケースです。

とするなら。もしかすると、基底クラスの初期化の最中に子クラスの初期化をして、その結果を使いたいと思うかもしれません。

そう思ったら、こんなコードを書くかも。(Sample2.java)

abstract class Parent{ //抽象クラスになりました。
	public Parent(){
		System.out.println("親コンストラクタだよー。");
		/* 全派生クラス共通の初期化処理がこの間に入ってる(という気持ち) */
		final int result = doInit(); //派生クラスごとで違う初期化処理
		/* ここも全派生クラス共通の初期化処理が入ってる(という気持ち) */
		System.out.println("親コンストラクタ終わりだよー。結果は"+result+"だったよー。");
	}
	public void method(){
		System.out.println("親のメソッドだよー。");
	}
	protected abstract int doInit();
}
class Child extends Parent {
	public Child(){
		super();
		System.out.println("子コンストラクタだよー。");
	}
	public void method(){
		System.out.println("子供のメソッドだよー。");
	}
	protected int doInit(){
		System.out.println("子供が初期化してるよー。");
		return 184; //特に意味はない
	}
}

実行すると、

% java Sample2
親コンストラクタだよー。
子供が初期化してるよー。
親コンストラクタ終わりだよー。結果は184だったよー。
子コンストラクタだよー。
子供のメソッドだよー。

というわけで、Javaでは、親クラスのコンストラクタを実行中でもすでに「this」は子クラスに「変身」しており、親クラスのコンストラクタから、子クラスのメソッドを呼ぶことができます。

インスタンス変数は二回初期化される

ただし。インスタンス変数は違います。先程のソース、親子両方にfieldというフィールドを入れて、親クラスを0、子クラスを1と宣言時に初期化すると…。(Sample3.java)

% java Sample3 
親コンストラクタだよー。fieldは0だったよー。
子供が初期化してるよー。
親コンストラクタ終わりだよー。結果は184だったよー。
子コンストラクタだよー。fieldは1だったよー。
子供のコンストラクタだよー。

親クラスの値0で初期化されたあと、子クラスのコンストラクタ実行前に再度初期化されます。

親クラスのコンストラクタを呼ぶ前は「何者でもない」

また、親クラスのコンストラクタ実行前は「何者にもなっていない透明な存在」です。え?どういう事かって?

こういうことはできません。

abstract class Parent{ //抽象クラスになりました。
	public Parent(int param){
		/* なにか処理 */
	}
}
class Child extends Parent {
	public Child(){
		super(getParam()); // <= 残念、コンパイルできない!
	}
	protected int getParam(){ /* 基底クラスの初期化に使う値を、事前に計算したいなあ、と */
		return 184; //特に意味はない
	}
}

「スーパータイプのコンストラクタの呼び出し前は this を参照できません。」と言われてエラーでした。

結局、それぞれの実行タイミングはどうなってるの?

クラスのフィールドの初期化は、コンストラクタ内だけでなく、フィールドの宣言時にも行うことができます。大方予想はついていると思いますが、一応調べておきましょう。(Sample4.java)

abstract class Parent{
	protected static final int log(String msg){
		System.out.println(msg);
		return 0;
	}
	protected int field = log("親クラス・フィールド宣言時");
	public Parent(){
		log("親クラス・コンストラクタ");
	}
}
class Child extends Parent {
	protected int field = log("子クラス・フィールド宣言時");
	public Child(){
		super();
		log("子クラス・コンストラクタ");
	}
}

とすると、

% java Sample4
親クラス・フィールド宣言時
親クラス・コンストラクタ
子クラス・フィールド宣言時
子クラス・コンストラクタ

というわけで、原則的に「フィールド宣言→コンストラクタ」が親から子にわたって続く感じです。コンストラクタ内からフィールドにはアクセスできますから、まあ想像通りですね。

ただし、Javaの場合、親クラスのフィールド宣言開始時ですでに子クラスに「変身」していて、メソッドがオーバーライドされていた場合、子供クラスのコンストラクタが呼ばれる前でも、そちらが呼ばれてしまいます。複数人で開発していた場合、この仕様が思いがけないバグになるかもしれません。

明確に子クラスの責任としたいメソッドじゃない場合は、コンストラクタから呼ばれるメソッドはfinal宣言したほうがいいかもですね。

ソビエトロシアC++では親クラスが子クラスに変身する!

最後のJavaと似たようなコードを書きました。(Sample.cpp)

#include <iostream>
#include <string>

using namespace std;

int log(const string& msg){
	cout << msg << endl;
	return 0;
}

class Parent{
	protected:
		int attr;// = log("親クラスフィールド初期化"); //これはできないんでした。
	public:
		Parent():
		attr(log("親クラスフィールド初期化"))
		{
			log("親クラスコンストラクタ開始");
			doInit();
			log("親クラスコンストラクタ終了");
		}
		virtual ~Parent(){
		}
		virtual void doInit(){
			log("親クラスの初期化処理");
		};
		virtual void method(){
			log("*親メソッド*");
		}
};

class Child : public Parent{
	protected:
		int attr;
	public:
		Child():
		Parent(),
		attr(log("子クラスフィールド初期化"))
		{
			log("子クラスコンストラクタ");
		};
		virtual ~Child(){
		};
		virtual void doInit(){
			log("子クラスの初期化処理");
		};
		virtual void method(){
			log("*子メソッド*");
		}
};

int main(){
	Child child;
	child.method();
	return 0;
}

そもそもフィールド宣言時に初期化できなくて、コンストラクタの初期化子に書くんでしたね。さてコンパイルです。

% ./test 
親クラスフィールド初期化
親クラスコンストラクタ開始
親クラスの初期化処理 ← 子供のdoInit()じゃなくて、親のdoInit()が呼ばれてる!
親クラスコンストラクタ終了
子クラスフィールド初期化
子クラスコンストラクタ
*子メソッド*

コードの位置からすぐわかるように、Javaと同じように「フィールド初期化→コンストラクタ」の流れなのは同じです。

が、C++は親クラスの初期化が終わるまでは「厳密に親クラス」で、子クラスではないので、子クラスのメソッドを呼ぶことはできません。

子クラスに勝手にメソッドがオーバーライドされて…という事はなくなるため、この仕様はこの仕様で合理的な気がします。

まとめ

えっと、まあ、その、普通に使ってても結構気づかないことって多いんだなあ…って感じです…。


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください