【Dart最前線】`base`・`interface`・`final`クラス修飾子で挑む、破られないドメインモデル設計
コードレビューをしていて、次のような設計に遭遇したことはないだろうか。
「このクラスは単なるデータホルダーであり、サブクラス化されることを想定していないのに、`extends` が自由に行われており、将来の仕様変更でリファクタリングが困難になっている」
あるいは、
「特定のインターフェースを強制したいのに、具象クラスの内部実装まで勝手に継承(Implementation Inheritance)されてしまい、基底クラス側の改修で予期せぬバグが多発している」
Dart 3以降、我々はもはや「野放図なオブジェクト指向」を書く必要はない。`base`、`interface`、`final` という強力なクラス修飾子を手に入れたからだ。これらは単なる文法上の飾りではなく、コンパイラに意図を強制し、実行時エラーや保守性崩壊の芽をコンパイル時にねじ伏せるためのアーキテクチャの武器である。
今回は、フロントエンド(Flutter)や非同期API連携を伴う堅牢なWeb/App開発の現場において、これらの修飾子をどう選び、どう組み合わせるべきか、テクニカルリードの視点からロジカルかつシャープに解説しよう。
—
1. なぜ「普通のクラス」ではダメなのか?(従来の課題)
Dart 2時代、すべてのクラスはデフォルトで「どこからでも継承でき(`extends`)、どこからでも実装できる(`implements`)」状態にあった。これはライブラリ設計者にとって悪夢である。
- カプセル化の破壊: サブクラスがスーパースペースのプライベートではないprotected的な振る舞いや内部メソッドに依存し始め、基底クラスを修正できなくなる。
- 脆弱な基底クラス問題 (Fragile Base Class Problem): ライブラリのバージョンアップで基底クラスにメソッドを追加した際、ユーザー側がたまたま同じシグネチャのメソッドをサブクラスに定義していた場合に名前衝突が起きる。
これらを解決するのが、クラスの「利用形態(利用許可)」を制限する修飾子である。まずはそれぞれのセマンティクスを正確に把握しよう。
—
2. 3つの修飾子の本質とコンパイラ挙動
各修飾子が「同一ライブラリ内(Same Library)」と「外部ライブラリ(External Library)」でどう振る舞うのかを整理する。Dartのアクセス制御の単位は「ファイル(ライブラリ)」単位である点に注意してほしい。
| 修飾子 | `extends`(継承)の許可 | `implements`(実装)の許可 | ユースケースの核心 |
| :— | :— | :— | :— |
| `base` | 強制(サブクラスも `base` 等が必要) | 禁止 | メソッドの実装を継承させたいが、型としての契約だけ(`implements`)はさせたくない場合。 |
| `interface` | 禁止 | 許可 | 振る舞いの契約(API)だけを強制し、内部実装の共有を完全に断ち切りたい場合。 |
| `final` | 完全禁止 | 完全禁止 | 継承も実装も一切許さず、そのクラス単体で完結させたい場合(最も安全)。 |
—
3. 【プロダクションコード設計】堅牢なAPIクライアント・ドメインモデル
実際のフロントエンド・非同期API連携のアーキテクチャを想定しよう。
ここでは、以下の要件を満たすコードを設計する。
1. APIレスポンスの基底クラス: 継承によるコード共有は許可するが、インターフェースとしての実装(`implements`)はさせない(`base`)。
2. ネットワーク・トランスポートの契約: 具象クラスに依存せず、型安全な振る舞いだけを強制する( vetro的な `interface`)。
3. ステート管理のエンティティ: 予期せぬ拡張を防ぎ、値の安全性を担保する(`final`)。
以下のコードは、そのままプロダクションで使えるクオリティに仕上げている。脳内トレースしながら読み進めてほしい。
// =================================================================
// 1. APIクライアントのトランスポート層 (interface クラス)
// =================================================================
/// ネットワーク通信の契約を定義する。
/// このクラスを `extends` することは禁止されており、純粋なインターフェースとしてのみ機能する。
interface class HttpTransport {
Future
// 外部ライブラリ(あるいは別ファイル)から HttpTransport を implements するのはOK
class DioHttpAdapter implements HttpTransport {
@override
Future
// ❌ コンパイルエラー: interface クラスは extends できない
// class BadAdapter extends HttpTransport {}
// =================================================================
// 2. ドメインモデルの基底層 (base クラス)
// =================================================================
/// エンティティの共通ロジック(JSONパースのヘルパー等)を継承させたいが、
/// 「見た目だけ真似た偽のエンティティ」を `implements` で作られたくない場合に `base` を使う。
base class DomainEntity {
final String id;
const DomainEntity({required this.id});
// 共通のバリデーションロジックなど
bool get isValid => id.isNotEmpty;
}
// ✅ 正しい継承: base クラスを継承する場合は、サブクラスも `base` または `final` でなければならない。
base class UserEntity extends DomainEntity {
final String name;
const UserEntity({required super.id, required this.name});
}
// ❌ コンパイルエラー: base クラスは implements できない
// class FakeUserEntity implements DomainEntity {
// @override
// String get id => ‘fake’;
// }
// =================================================================
// 3. 不変のステート管理 (final クラス)
// =================================================================
/// 状態を表すクラス。これ以上の派生は一切不要なため、`final` で完全に封印する。
/// コンパイラは、このクラスが継承・実装されないことを知っているため、
/// パフォーマンス最適化やパターンマッチングの網羅性チェックに大きく寄与する。
sealed class UiState
const UiState();
}
final class UiLoading
const UiLoading();
}
final class UiSuccess
final T data;
const UiSuccess(this.data);
}
final class UiError
const Object error;
const UiError(this.error);
}
—
4. なぜこれがパフォーマンスと保守性に直結するのか?(アーキテクトの視点)
「なぜわざわざコンパイルエラーのリスクを増やすような制限をつけるのか?」と疑問に思うジュニアエンジニアもいるかもしれない。しかし、ここにはDart VMとAOTコンパイラ、そしてチーム開発におけるスケールの哲学がある。
① コンパイラの最適化(Devirtualization)
Dartは動的言語的な側面も持っているが、AOT(Ahead-Of-Time)コンパイル時に「このクラスが絶対に継承されない(`final` である、あるいは `base` で外部から拡張されない)」と分かっている場合、Dart VMやコンパイラは仮想メソッド呼び出し(Vtable lookup)を直接呼び出し(Direct call)に最適化(Devirtualization)できる。
これにより、高頻度で実行されるUIのレンダリングループや、大量のJSONを処理するドメイン層において、マイクロベンチマークレベルで確実にオーバーヘッドを削減できる。
② リファクタリングへの耐性
大規模なWebフロントエンドやFlutterアプリにおいて、「誰も意図していないところで勝手にクラスが継承され、内部プロパティに依存されていた」という状態は、コードベースの癌(がん)である。
修飾子によって「拡張を許可する場所(`base`)」と「契約だけを強制する場所(`interface`)」を厳格に分離することで、ライブラリの作者は安心して内部実装を刷新できるようになる。
—
5. まとめ:明日から使える設計のチェックリスト
コードレビューや新規設計の際、以下の基準でクラス修飾子を選択してほしい。
1. 何も考えずに `class` と書くのをやめる。まずは `final` が使えないかを検討する(デフォルト・クローズド・デザイン)。
2. 共通の実装コードを子孫に継承させたいが、型としての偽装を防ぎたいなら `base class`。
3. 実装はどうでもよく、関数のシグネチャ(契約)だけを他のクラスに強制したいなら `interface class`。
4. 状態やDTOなど、拡張の余地を1ミリも残したくないなら `final class` (あるいは `sealed` との組み合わせ)。
言語の仕様に守られるコードを書くこと。それこそが、複雑化するモダンWeb/App開発を生き抜くための最も確実なエンジニアリングだ。