Java 逆引き集 | 抽象クラス vs インターフェース — API 設計の選択

Java Java
スポンサーリンク

抽象クラス vs インターフェース — API 設計の選択

「共通の型をどう表すか」を決めるのが抽象クラスとインターフェース。どちらも“契約”を提供しますが、設計の狙いが少し違います。初心者でも迷わないように、違い・使い分け・コード例を実務目線でまとめます。


違いの要点(一目で把握)

  • 抽象クラス:
    • 役割: 共通の土台(フィールドや実装を持てる“半完成品”)。
    • 特徴: 状態(フィールド)を持てる、実装ありのメソッドを配布できる、継承は1つだけ(単一継承)。
    • 向き: 「is-a(〜である)」の階層で、共通処理もまとめたいとき。
  • インターフェース:
    • 役割: 能力・契約の宣言(実装は持たないのが基本。Java8+はdefault実装あり)。
    • 特徴: フィールドは基本定数のみ、複数実装可能(多重“実装”)、実装は各クラスに委ねる。
    • 向き: 「can-do(〜できる)」の能力や役割を横断的に付与したいとき。

基本構文と最小例

抽象クラス(共通の土台+一部強制)

abstract class Document {
    private final String title;           // 状態を持てる
    public Document(String title) { this.title = title; }
    public String getTitle() { return title; }

    public void printHeader() {           // 共有実装を配布
        System.out.println("=== " + title + " ===");
    }

    public abstract String renderBody();  // 子に実装を強制
}

class Report extends Document {
    public Report(String title) { super(title); }
    @Override
    public String renderBody() { return "Report content"; }
}
Java

インターフェース(契約のみ+必要ならdefault)

interface Exportable {
    String export();                      // 契約(実装は各クラス)
    default String exportWithHeader(String name) { // 共有の軽い実装も可(Java8+)
        return "## " + name + "\n" + export();
    }
}

class Order implements Exportable {
    @Override
    public String export() { return "order: data"; }
}
Java

実務での使い分け指針

  • 抽象クラスを選ぶ場面
    • 共通の状態を持たせたい: 例)id, name, createdAt を親で管理。
    • 共通の実装を配布したい: 例)入力検証、前処理/後処理のテンプレート。
    • 階層で“型の一貫性”を保ちたい: 同じ系統のオブジェクト群でベースを共有。
  • インターフェースを選ぶ場面
    • 複数の能力を組み合わせたい: 例)Serializable + Comparable + Exportable。
    • 呼び出し側の依存を薄くしたい: 契約に依存し、実装は後から差し替え。
    • 横断的な役割付与: ロギング可能、保存可能、通知可能などの“機能タグ”。
  • 併用が王道
    • 抽象クラスで土台+インターフェースで能力: 例)抽象のRepositoryがQueryable, Auditableを実装。

API設計の定番パターン

パターン1: テンプレートメソッド(抽象クラス)

abstract class Importer {
    public final void run(String path) {           // 流れを固定
        var raw = read(path);
        var normalized = normalize(raw);
        save(normalized);
    }
    protected abstract String read(String path);   // 差し替え点
    protected abstract String normalize(String s);
    protected abstract void save(String s);
}

class CsvImporter extends Importer {
    protected String read(String path) { return "csv"; }
    protected String normalize(String s) { return s.trim(); }
    protected void save(String s) { /* write to DB */ }
}
Java
  • 狙い: 処理の流れは親で固定、差分は子が実装。拡張容易。

パターン2: 多重実装(インターフェース)

interface Validatable { boolean isValid(); }
interface Persistable { void save(); }

class User implements Validatable, Persistable {
    public boolean isValid() { return true; }
    public void save() { /* DB */ }
}
Java
  • 狙い: 契約の組み合わせで能力を付与。依存が軽く差し替えやすい。

パターン3: 戦略の差し替え(インターフェース)

interface Formatter { String format(String s); }

class JsonFormatter implements Formatter {
    public String format(String s) { return "{\"msg\":\"" + s + "\"}"; }
}
class CsvFormatter implements Formatter {
    public String format(String s) { return "msg," + s; }
}

class Reporter {
    private final Formatter formatter;
    Reporter(Formatter f) { this.formatter = f; }
    String report(String s) { return formatter.format(s); }
}
Java
  • 狙い: 実装の差し替え前提の契約化。テスト・拡張が容易。

例題で身につける(解説つき)

例題1: 図形の面積(抽象クラスで共通性)

abstract class Shape {
    public abstract double area();        // 共通の契約
    public String describe() {            // 共有実装
        return "area=" + area();
    }
}
class Circle extends Shape {
    private final double r;
    Circle(double r) { this.r = r; }
    public double area() { return Math.PI * r * r; }
}
Java
  • ポイント: 共通メソッド(describe)を配布しつつ、計算は子が担う。

例題2: 通知手段の拡張(インターフェースで戦略)

interface Notifier { void notify(String msg); }
class EmailNotifier implements Notifier { public void notify(String msg) { /* send email */ } }
class SlackNotifier implements Notifier { public void notify(String msg) { /* send slack */ } }

class AlertService {
    private final Notifier notifier;
    AlertService(Notifier n) { this.notifier = n; }
    public void alert(String msg) { notifier.notify(msg); }
}
Java
  • ポイント: 呼び出し側は Notifier に依存。手段の追加が容易。

例題3: 抽象+インターフェースの併用

interface Auditable { void audit(String action); }

abstract class BaseRepository<T> implements Auditable {
    public void audit(String action) { System.out.println("audit:" + action); }
    public abstract void save(T t);     // 差し替え点
}

class UserRepository extends BaseRepository<String> {
    public void save(String u) { audit("save"); /* write to DB */ }
}
Java
  • ポイント: 抽象で土台、インターフェースで横断機能。拡張と共通化を両立。

すぐ使えるテンプレート

  • 抽象クラス(土台+差し替え)
abstract class Base {
    public final Result run(Input in) { pre(in); var x = step(in); return post(x); }
    protected void pre(Input in) {}
    protected abstract Mid step(Input in);
    protected Result post(Mid m) { return new Result(m); }
}
Java
  • インターフェース(契約+戦略差し替え)
interface Strategy {
    Output apply(Input in);
}
class UseCase {
    private final Strategy strategy;
    UseCase(Strategy s) { this.strategy = s; }
    Output handle(Input in) { return strategy.apply(in); }
}
Java
  • インターフェースのdefaultで共通化
interface Exportable {
    String export();
    default String exportPretty() { return "[EXPORT]\n" + export(); }
}
Java

判断のためのチェックリスト

  • 状態や共通実装を配りたい? → 抽象クラス。
  • 複数の役割を組み合わせたい? → インターフェース。
  • 処理の流れを固定し差分だけ差し替えたい? → 抽象クラス(テンプレートメソッド)。
  • 呼び出し側は契約にだけ依存したい? → インターフェース。
  • 将来、別実装を増やす予定がある? → インターフェース優先。
  • 単一継承の制約に引っかかる? → インターフェースで解決。

まとめ

  • 抽象クラス: 共通の“土台”を持たせ、状態や実装を共有しながら差分を子に委ねる。
  • インターフェース: 能力・契約を宣言し、実装は自由に差し替え可能。複数の役割を合成しやすい。
  • 併用が実務の定番: 土台は抽象、役割はインターフェース。拡張性と保守性を両立。

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