Javaの継承で初期化順序が分からなくなる問題を完全整理|static・フィールド・初期化ブロック・コンストラクタ

Java

Javaの継承を勉強していると、staticフィールド、インスタンスフィールド、初期化ブロック、コンストラクタ、オーバーライドが一度に登場した瞬間、一気に分かりにくくなります。

特にJava Silverのような資格試験では、これらを全部組み合わせて「最終的に何が出力されるか」を問われることがあります。

今回は、継承が絡んだJavaの初期化順序を、少し難しめのコード問題を使って整理します。

まずは問題:このコードの実行結果は?

class Parent {
    static int s = initStatic("Ps");

    int x = initInstance("Pi", 1);

    static int initStatic(String label) {
        System.out.print(label);
        return 10;
    }

    int initInstance(String label, int value) {
        System.out.print(label);
        return value;
    }

    Parent() {
        System.out.print("Pc");
        System.out.print(value());
    }

    int value() {
        return x;
    }
}

class Child extends Parent {
    static int s = initStatic("Cs");

    int x = initInstance("Ci", 2);

    {
        System.out.print("Cb");
    }

    Child() {
        System.out.print("Cc");
    }

    @Override
    int value() {
        return x;
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.print(Child.s + ":");
        Child c = new Child();
        System.out.print(":" + c.x + ":" + ((Parent)c).x);
    }
}

実行結果として正しいものはどれでしょうか。

  1. PsCs10:PiPc1CiCbCc:2:1
  2. PsCs10:PiPc0CiCbCc:2:1
  3. Cs10:PiPc0CiCbCc:2:1
  4. PsCs10:PiPc0CbCiCc:2:2

正解は2番の「PsCs10:PiPc0CiCbCc:2:1」です。

ここから、どの処理がどの順番で実行されているのかを順番に見ていきます。

1. Childのstaticフィールドにアクセスすると親から初期化される

最初に実行されるのは次の行です。

System.out.print(Child.s + ":");

Child.s にアクセスするため、Childクラスの初期化が必要になります。

ただしChildはParentを継承しています。Javaでは、子クラスを初期化する前に親クラスを先に初期化します。

まずParentのstaticフィールド初期化子が実行されます。

static int s = initStatic("Ps");

initStatic("Ps") によって、まず Ps が表示されます。

続いてChild側のstaticフィールドが初期化されます。

static int s = initStatic("Cs");

ここで Cs が表示されます。

したがって、この時点では次のようになります。

PsCs10:

2. new Child()でも親クラス側から処理される

次にChildのインスタンスを生成します。

Child c = new Child();

ここで、いきなりChildのコンストラクタが実行されるわけではありません。

まず親クラスParent側のインスタンス初期化が行われます。

int x = initInstance("Pi", 1);

これによって Pi が表示され、Parent側の x に1が代入されます。

3. 親コンストラクタ内でもオーバーライドは有効

続いてParentのコンストラクタが実行されます。

Parent() {
    System.out.print("Pc");
    System.out.print(value());
}

まず Pc が表示されます。

問題は次の value() です。

Parentにも value() はありますが、Childでオーバーライドされています。

@Override
int value() {
    return x;
}

Javaのオーバーライドされたインスタンスメソッドは、参照変数の型ではなく実際のインスタンス型を基準に呼び出されます。

現在生成している実体はChildなので、Parentのコンストラクタ内から呼んでいても Child.value() が実行されます。

4. Child.value()が呼ばれるのに、なぜ0なのか

ここがこの問題で最も難しいポイントです。

Childの value() は次のようになっています。

int value() {
    return x;
}

そしてChildには次のフィールドがあります。

int x = initInstance("Ci", 2);

一見すると2が返りそうです。

しかし、この時点ではまだParentのコンストラクタを実行している途中です。

Child側のインスタンスフィールド初期化は、Parentのコンストラクタが終了した後に行われます。

インスタンス領域自体はすでに確保されていますが、Child側の x はまだ明示的な初期化が行われていません。

そのため、この時点での値はint型のデフォルト値である 0 です。

したがってここまでの出力は、

PsCs10:PiPc0

となります。

5. 親コンストラクタ終了後にChild側を初期化

Parentのコンストラクタが終了すると、ようやくChild側のインスタンス初期化が始まります。

まずフィールド初期化子です。

int x = initInstance("Ci", 2);

Ci が表示され、Child側の x に2が入ります。

次にインスタンス初期化ブロックです。

{
    System.out.print("Cb");
}

ここで Cb が表示されます。

最後にChildのコンストラクタ本体です。

Child() {
    System.out.print("Cc");
}

これによって Cc が表示されます。

ここまでで出力は、

PsCs10:PiPc0CiCbCc

となりました。

6. フィールドはオーバーライドされない

最後に次のコードが実行されます。

System.out.print(":" + c.x + ":" + ((Parent)c).x);

ここでは、メソッドとは違うルールが使われます。

Javaのフィールドはオーバーライドされません。同名のフィールドを子クラスで定義すると、親クラスのフィールドを隠蔽する形になります。

c.x はChild型としてアクセスしているためChild側の x、つまり2です。

一方、

((Parent)c).x

はParent型としてフィールドへアクセスするため、Parent側の x、つまり1になります。

したがって最終的な出力は、

PsCs10:PiPc0CiCbCc:2:1

です。

Javaの継承時の初期化順序を整理

今回のコードを理解するために、初期化順序を整理しておきます。

クラス初期化

子クラスを初めて初期化する場合、基本的には次の順番です。

  1. 親クラスのstaticフィールド初期化子・static初期化ブロック
  2. 子クラスのstaticフィールド初期化子・static初期化ブロック

同じクラス内では、staticフィールド初期化子とstatic初期化ブロックはソースコードに書かれた順番で実行されます。

インスタンス生成

new Child() の場合は、概ね次のように進みます。

  1. インスタンス領域を確保し、各フィールドにデフォルト値を設定
  2. 親クラスのインスタンスフィールド初期化子・初期化ブロック
  3. 親クラスのコンストラクタ
  4. 子クラスのインスタンスフィールド初期化子・初期化ブロック
  5. 子クラスのコンストラクタ

そして、同じクラス内にあるフィールド初期化子と初期化ブロックについては、書かれた順番に実行されます。

特に注意したい「コンストラクタからオーバーライドメソッドを呼ぶ」ケース

今回最も注意したいのは、Parentコンストラクタ内の次の処理です。

Parent() {
    System.out.print(value());
}

親クラスのコンストラクタを実行している最中でも、オーバーライドされたインスタンスメソッドは子クラス側のものが呼ばれます。

ところが、その時点では子クラス側のフィールド初期化が完了していない可能性があります。

その結果、今回のように「Child.value()は呼ばれたのに、Child.xはまだ0」という状態が発生します。

これは資格試験だけの話ではなく、実務でもコンストラクタからオーバーライド可能なメソッドを呼ぶ設計を避けた方がよい理由のひとつです。

試験で迷ったら「親→子」と「記述順」をまず書く

Java Silverなどの資格試験で複雑な初期化問題が出た場合、すべて頭の中だけで追いかけると混乱しやすくなります。

まず次のように整理すると解きやすくなります。

static
親 → 子

instance
親のフィールド・初期化ブロック
↓
親コンストラクタ
↓
子のフィールド・初期化ブロック
↓
子コンストラクタ

そして同じクラスの中では、

フィールド初期化子と初期化ブロックは書かれた順番

と覚えておくと整理しやすくなります。

まとめ

  • static初期化は親クラスから子クラスへ進む
  • インスタンス生成時も親クラス側から処理される
  • 同一クラス内のフィールド初期化子と初期化ブロックは記述順
  • 親コンストラクタ実行中でもオーバーライドされたメソッドは子側が呼ばれる
  • その時点では子クラスのフィールドがまだデフォルト値の場合がある
  • フィールドはメソッドと違ってオーバーライドされない

このあたりは、普段Javaを書いていても意外と意識しない仕様です。

一方でJava Silverのような資格試験では、複数のルールを組み合わせた実行結果問題として非常に出題しやすい部分でもあります。

確認問題

実際の出力がどうなるかを考えてみましょう。

class Parent {
    static {
        System.out.print("A");
    }

    int x = init("B", 1);

    {
        System.out.print("C");
    }

    Parent() {
        System.out.print("D");
        System.out.print(value());
    }

    int init(String s, int n) {
        System.out.print(s);
        return n;
    }

    int value() {
        return x;
    }
}

class Child extends Parent {
    static {
        System.out.print("E");
    }

    int x = init("F", 2);

    {
        System.out.print("G");
    }

    Child() {
        System.out.print("H");
    }

    @Override
    int value() {
        return x;
    }
}

public class Main {
    public static void main(String[] args) {
        new Child();
    }
}

AEBCD0FGH

Java Silverの模擬問題で実力チェック

Java Silver SE 17を想定したオリジナル模擬問題を公開しています。

単純な用語暗記だけではなく、コードの実行結果、コンパイル可否、継承、record、sealed、例外処理など、Javaの仕様をコードを読みながら確認できる問題を中心にしています。

今回のような「実務ではあまり書かないけれど、Javaの仕様として理解しておきたい」問題の確認にも使えます。

コメント

タイトルとURLをコピーしました