Dart 3クラス修飾子の極意:`base`・`interface`・`final`で堅牢なドメインモデルを構築する
コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。
// よくある「やりたいことは分かるが、意図が漏れ出している」コード
class NetworkResponse {
final int statusCode;
NetworkResponse(this.statusCode);
}
このクラスの作者は「単なるデータホルダーとして使ってほしい」「勝手にサブクラスを作って挙動を変えないでほしい」と思っていたかもしれない。しかし、Dart 2時代、このクラスはプロジェクト内のどこからでも自由に `extends`(継承)できてしまい、意図しないサブクラスが乱立する温床となった。ライブラリのバージョンアップ時に基底クラスの内部構造を変更した途端、予期せぬ依存関係が崩壊してビルドが通らなくなる——誰もが一度は経験する悪夢だ。
Dart 3で導入されたクラス修飾子(`base`, `interface`, `final`)は、この「API設計者の意図」と「言語の型システム」の乖離を完全に埋めるための決定打である。
今回は、フロントエンドの状態管理、コンポーネント設計、非同期API連携の現場で、バグの起きない堅牢なアーキテクチャを構築するためのDart 3クラス修飾子の極意を伝授する。
—
1. なぜ従来のDartでは不十分だったのか?
Dart 2までのクラス宣言は、デフォルトで「オープン」だった。つまり、ひとつのクラスを書けば、それは自動的に「継承(extends)可能」「実装(implements)可能」「型として利用可能」というすべての権利を外部に開放していた。
これでは、レイヤー化アーキテクチャ(Presentation / Domain / Data)における依存関係の制御や、ライブラリのパブリックAPIのカプセル化において限界がある。
コンパイラやAOTコンパイル(Dart VMの最適化)の観点からも、クラスがどこでどう継承・実装されているか分からない状態は、デモグラフィックなコード解析やツリーシェイキング、仮想メソッドテーブル(vtable)の最適化において足かせとなる。
Dart 3では、クラスの「再利用性」と「拡張性」を完全に分離し、設計者の意図をコンパイラに強制させることができるようになった。
—
2. 3つの新修飾子のメンタルモデル
まずは、それぞれの修飾子が何を許可し、何を禁止するのかを正確に把握しよう。
| 修飾子 | 同一ライブラリ内での継承 (`extends`) | 同一ライブラリ内での実装 (`implements`) | 外部ライブラリでの継承 (`extends`) | 外部ライブラリでの実装 (`implements`) |
| :— | :—: | :—: | :—: | :—: |
| (修飾子なし) | ◯ | ◯ | ◯ | ◯ |
| `base` | ◯ | ✕ | ◯ | ✕ |
| `interface` | ✕ | ◯ | ✕ | ◯ |
| `final` | ✕ | ✕ | ✕ | ✕ |
> 💡 「ライブラリ」の定義: Dartにおけるライブラリとは、ひとつのファイル、または `part` directiveで結合されたファイル群を指す。パッケージ単位ではない点に注意せよ。
`base`:実装の継承を強制する
「内部ロジックやフィールド構造は共有させたいが、外部から勝手にインターフェースとしてモック化(implements)されたら困る」という場合に使用する。主に基底クラスとしての振る舞いを守るためのものだ。
`interface`:型の契約(API)だけを強制する
「中身の実装ロジックは共有させず、特定のメソッドシグネチャ(契約)の形だけを強制したい」場合に使用する。Javaの `interface` に近いが、Dartでは通常のクラスをインターフェース化できる。
`final`:継承チェインを断ち切る
「このクラスの構造も実装も、これ以上一歩たりとも改変させない」という最終形態。パフォーマンス最適化の観点でも、コンパイラはこのクラスにサブクラスが存在しないことを確信できるため、vtableのルックアップを最適化(devirtualization)できる。
—
3. 実務で即座に使えるプロダクションコード設計
では、実際のWebフロントエンド(FlutterやDart Web)の設計に落とし込んでみよう。
ここでは、「非同期API連携」「状態管理」「UIコンポーネント」を統合した堅牢なモジュール設計の例を示す。
以下のコードは、そのままコピーして動作させることができる。
// ライブラリの境界を明確にするため、型や振る舞いを厳密に制御する
/// 1. 【final修飾子】
/// APIレスポンスのデータホルダー。
/// 構造の拡張や勝手な継承を一切禁止し、データの不変性と安全性を保証する。
final class ApiResult
final T? data;
final String? errorMessage;
final bool isSuccess;
const ApiResult.success(this.data)
: errorMessage = null,
isSuccess = true;
const ApiResult.failure(this.errorMessage)
: data = null,
isSuccess = false;
}
/// 2. 【base修飾子】
/// 非同期処理の基底クラス。
/// サブクラスでのメソッドオーバーライドや内部状態(_isLoadingなど)の保護を強制しつつ、
/// 具象ViewModelでのコード共有を許可する。
base abstract class BaseViewModel {
bool _isLoading = false;
bool get isLoading => _isLoading;
// 状態変更のフックをカプセル化
void setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
// サブクラスに実装を強制する抽象メソッド
void notifyListeners();
// 共通の非同期実行ラッパー
Future
setLoading(true);
try {
final result = await action();
return ApiResult.success(result);
} catch (e) {
return ApiResult.failure(e.toString());
} finally {
setLoading(false);
}
}
}
/// 3. 【interface修飾子】
/// UIコンポーネントが満たすべき契約(Contract)。
/// 実装の継承はさせず、特定のUIパーツとしての型制約だけを外部に公開する。
interface class ThemeableWidgetContract {
String getThemeId() => ‘default_light’;
}
// — 具象レイヤー(利用側) —
/// BaseViewModelを継承した具象ViewModel
/// baseクラスなので `extends` は可能だが、 `implements` しようとするとコンパイルエラーになる。
base class UserViewModel extends BaseViewModel {
String? username;
@override
void notifyListeners() {
// 実際はここでUIの再描画などを発火
print(‘[StateChanged] isLoading: $isLoading, username: $username’);
}
Future
final result = await executeGuarded(() async {
// ネットワーク遅延のシミュレーション
await Future.delayed(const Duration(milliseconds: 500));
if (userId == ‘error’) throw Exception(‘User not found’);
return ‘Dart Master’;
});
if (result.isSuccess) {
username = result.data;
} else {
username = ‘Guest’;
print(‘[Error] ${result.errorMessage}’);
}
}
}
// interfaceクラスを実装(implements)した具象コンポーネント
class ProfileCard implements ThemeableWidgetContract {
@override
String getThemeId() => ‘premium_dark’;
void render() {
print(‘Rendering ProfileCard with theme: ${getThemeId()}’);
}
}
void main() async {
print(‘=== Dart 3 Class Modifiers Demo ===’);
final viewModel = UserViewModel();
// 正常系フロー
print(‘\n— Fetching Valid User —‘);
await viewModel.fetchUser(‘123’);
// 異常系フロー
print(‘\n— Fetching Invalid User —‘);
await viewModel.fetchUser(‘error’);
// インターフェースのテスト
print(‘\n— Component Contract Test —‘);
final card = ProfileCard();
card.render();
}
この設計が優れている理由(コードレビューの視点)
1. `ApiResult` を `final` にした理由:
データ構造が勝手に拡張されるのを防ぐ。これにより、後続のパターンマッチング(`switch (result)`)において、コンパイラが「網羅性(exhaustiveness checking)」を完璧に担保できるようになる。余計な `default` ケースを書く必要がなくなるため、保守性が劇的に向上する。
2. `BaseViewModel` を `base` にした理由:
ViewModelとしての共通ロジック(`executeGuarded` によるエラーハンドリングやローディング状態の管理)のコード重複を防ぎつつ、開発者が勝手に `implements` してロジックをすり替える不正を防ぐ。「振る舞いの継承」に特化させることができる。
3. `ThemeableWidgetContract` を `interface` にした理由:
UIコンポーネント群に対して「このメソッドを持っていなければならない」というルール(型契約)だけを強制し、内部の実装や状態管理の仕方は各コンポーネントの自由裁量に委ねることができる。
—
4. パフォーマンスとコンパイル時の恩恵
これらの修飾子は、単なる「お行儀の良いコードを書くためのルール」ではない。Dart VMおよびAOTコンパイラ(dart2native)にとって非常に重要な最適化ヒントとなる。
- Devirtualization(仮想メソッド呼び出しの排除):
クラスが `final` である場合、コンパイラはそのクラスがサブクラスを持たないことを完全に把握できる。これにより、動的なディスパッチ(vtable lookup)を静的な関数呼び出しに最適化でき、CPUのパイプライン効率が向上する。
- Tree Shakingの精度向上:
不要なインターフェースや使われないコードパスがコンパイル時に静的に特定しやすくなり、最終的なWeb(JavaScript/Wasm)やモバイルのバイナリサイズを削減できる。
—
5. まとめ:リードエンジニアからの提言
大規模なフロントエンド開発やコンポーネント設計において、最大の敵は「他の開発者(あるいは未来の自分)が意図しない方法でコードを拡張してしまうこと」である。
コメントやドキュメントで「このクラスは継承しないでください」と書くのは今日で終わりにしよう。代わりに `base`、`interface`、`final` という言語の力を借りて、「間違ったコードがそもそもコンパイルエラーになる世界」を構築するのだ。
それこそが、プロダクションの品質を極限まで高めるプロフェッショナルのアプローチである。さあ、今日のコードレビューからさっそく適用してみよう。