Javaを鍛える|OOP基礎編 #3

継承の基本と実践

  • Java
  • OOP
  • 継承
  • Javaを鍛える

📋 この記事の目次
  1. 継承は「共通フォーマットを引き継ぎ、差分だけ追加する」ことだと考える
  2. 実践:Ticket・BugTicket・FeatureTicketに分ける
  3. 挫折ポイントTOP3と対処法
  4. この先、何を学べばいいか
  5. まとめ
  6. 🎓 理解度テスト

前回、Ticketクラスのデータはカプセル化で守られるようになりました。しかし、TicketCraftにはもう1つ根深い問題が残っています。

public class Ticket {
    private String type; // "bug" か "feature" か "inquiry"
    // ...

    public String summary() {
        if (type.equals("bug")) {
            return "[バグ] " + title + "(重要度: " + severity + ")";
        } else if (type.equals("feature")) {
            return "[要望] " + title + "(提案者: " + requestedBy + ")";
        } else {
            return title;
        }
    }
}

新しいチケット種別を1つ増やすたびに、summary()だけでなくシステム中のif (type.equals(...))に手を入れる羽目になります。今回は継承で、この「種類ごとの分岐」をクラスの分離に置き換えます。


継承は「共通フォーマットを引き継ぎ、差分だけ追加する」ことだと考える

会社で新しい部署(例: 海外事業部)を作るとき、経費申請書をゼロから作り直す会社は稀です。多くの場合、全社共通の申請書フォーマットを土台にして、部署固有の追加項目だけを足します

継承もこれと同じ発想です。

  • 共通の土台(親クラス)に、全チケットに共通するフィールド・メソッドをまとめる
  • 個別の種類(子クラス)は、親クラスをextendsし、その種類だけに必要な追加分だけを書く

実践:Ticket・BugTicket・FeatureTicketに分ける

Step 1: 親クラス(基底クラス)を用意する

public class Ticket {
    private final String id;
    private final String title;
    private String status = "open";
    private String assignee;

    public Ticket(String id, String title) {
        this.id = id;
        this.title = title;
    }

    public String getId() { return id; }
    public String getTitle() { return title; }
    public String getStatus() { return status; }

    public void close() {
        this.status = "closed";
    }

    public String summary() {
        return "[チケット] " + title;
    }
}

Step 2: 子クラスでextendsする

public class BugTicket extends Ticket {
    private final String severity; // "low" / "medium" / "high"
    private final String stepsToReproduce;

    public BugTicket(String id, String title, String severity, String stepsToReproduce) {
        super(id, title); // 親クラスのコンストラクタを明示的に呼び出す
        this.severity = severity;
        this.stepsToReproduce = stepsToReproduce;
    }

    @Override
    public String summary() {
        return "[バグ] " + getTitle() + "(重要度: " + severity + ")";
    }

    public String getStepsToReproduce() {
        return stepsToReproduce;
    }
}
public class FeatureTicket extends Ticket {
    private final String requestedBy;

    public FeatureTicket(String id, String title, String requestedBy) {
        super(id, title);
        this.requestedBy = requestedBy;
    }

    @Override
    public String summary() {
        return "[要望] " + getTitle() + "(提案者: " + requestedBy + ")";
    }
}

これで、種類ごとの差分(severityrequestedBy等)は各子クラスの中だけに閉じ込められました。3つ目の種類(InquiryTicket)を追加したくなっても、既存のBugTicketFeatureTicketは一切変更せずに済みます。

コンストラクタ連鎖の注意点

super(id, title);は、子クラスのコンストラクタの最初の行で呼び出す必要があります。今回のTicketのように親クラスに引数なしコンストラクタが存在しない場合、これを省略するとコンパイルエラーになるため、その場でミスに気づけます。厄介なのは、親クラスに引数なしコンストラクタが存在してしまっているケースです。この場合はコンパイラが黙って引数なしコンストラクタを呼び出してしまい、idtitleが初期化されないままインスタンスが作られ、あとで原因不明のNullPointerExceptionに悩まされることになります。

🧓 ベテランの現場コラム 新人時代、super()の呼び出し忘れによる「なぜかnullが入っている」バグに丸1日溶かした経験があります。コンパイラはsuper()を省略すると自動的に親クラスの引数なしコンストラクタを呼ぼうとするため、親クラスに引数なしコンストラクタが無いとコンパイルエラーになって気づけますが、もし親クラスに引数なしコンストラクタが存在してしまっていると、エラーにならず静かに不完全な初期化のまま動いてしまうので特に注意が必要です。

protectedとアクセス制御

Ticketのフィールドをprivateのままにすると、子クラスからも直接アクセスできません。子クラスに公開してよいフィールドはprotectedにする選択肢もありますが、TicketCraftでは前回のカプセル化の教訓(不変条件をsetterで守る)を活かし、getterを介したアクセスに統一しています。「継承したら何でもprotectedにする」のではなく、本当に子クラスへの公開が必要かを都度考えることが大切です。

🧓 ベテランの現場コラム 「継承すればコードが再利用できる」と聞くと、なんでもextendsしたくなりますが、継承は 「AはBの一種である(is-a関係)」 が成立するときだけ使うべきものです。BugTicket is a Ticketは自然ですが、無関係な概念同士を「コードを再利用したいから」という理由だけでextendsすると、後々の変更で身動きが取れなくなります。


挫折ポイントTOP3と対処法

1. super()の呼び出し忘れ・呼び出し順の間違い

コンストラクタの最初の行で呼ぶというルールを徹底しましょう。

2. is-a関係が成立しないのにextendsしてしまう

「単にコードを再利用したいだけ」なら、継承ではなく別のクラスをフィールドとして持つ設計(コンポジション)の方が適していることが多いです。この連載でも、後の回で「継承ではなく別クラスを組み合わせる」設計判断が何度も出てきます。

3. 継承階層を深くしすぎる

Ticket → UrgentBugTicket → CriticalUrgentBugTicketのように何段も継承を重ねると、どこで何が定義されているか追いづらくなります。Javaは単一継承(1つの親クラスしか持てない)なので、階層は浅く保つのが基本です。


この先、何を学べばいいか

BugTicketFeatureTicketのクラス分割はできましたが、まだ呼び出し側ではif (ticket instanceof BugTicket) { ... }のような分岐が必要な場面が残っています。次回はポリモーフィズムで、呼び出し側からこの型判定そのものをなくします。


まとめ

  • 継承は「共通フォーマットを引き継ぎ、差分だけ追加する」仕組み
  • super()はコンストラクタの最初の行で必ず呼ぶ
  • 継承は「is-a関係」が成立する場合にのみ使う。コード再利用だけが目的ならコンポジションを検討する
  • 継承階層は浅く保つ

🎓 理解度テスト

学んだ内容を確認しましょう。全5問。