抽象クラス 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判断のためのチェックリスト
- 状態や共通実装を配りたい? → 抽象クラス。
- 複数の役割を組み合わせたい? → インターフェース。
- 処理の流れを固定し差分だけ差し替えたい? → 抽象クラス(テンプレートメソッド)。
- 呼び出し側は契約にだけ依存したい? → インターフェース。
- 将来、別実装を増やす予定がある? → インターフェース優先。
- 単一継承の制約に引っかかる? → インターフェースで解決。
まとめ
- 抽象クラス: 共通の“土台”を持たせ、状態や実装を共有しながら差分を子に委ねる。
- インターフェース: 能力・契約を宣言し、実装は自由に差し替え可能。複数の役割を合成しやすい。
- 併用が実務の定番: 土台は抽象、役割はインターフェース。拡張性と保守性を両立。
