Dartを掌握する極限の知見:`base`・`interface`・`final`修飾子がもたらす堅牢なAPI設計
コードレビューをしていて、次のようなクラス定義に出くくしたことはないだろうか。
// リファレンスをただ写経しただけの、意志のないコード
class NetworkClient {
void send() {}
}
このクラスがライブラリやモジュールの境界を越えて公開された瞬間、あなたのAPIの安全性は崩壊へのカウントダウンを始める。なぜなら、任意の外部ユーザーがこれを `extends`(継承)して内部挙動をオーバーライドしたり、`implements`(実装)して型契約を勝手に書き換えたりすることが可能になるからだ。
Dart 3で導入されたクラス修飾子(`base`, `interface`, `final`)は、単なる気休めの構文糖衣ではない。これは、コンパイラとAOT/JITの最適化エンジンに対して「この型がコードベース内でどう振る舞うべきか」を厳格に誓約する、極めて強力な設計の武器である。
今回は、フロントエンド(Flutter)からサーバーサイド(Dart)まで、大規模開発で「絶対にバグらせない」ためのクラス設計戦略を、Dartコアの裏側の挙動まで踏み込んで叩き込む。
—
1. なぜ「デフォルトの挙動」は危険なのか?
Dartのクラスは、歴史的経緯(そしてJavaやC#の影響)から、デフォルトですべてのクラスが `public` かつ 継承・実装可能(`omnipresent`) である。
つまり、あなたが何気なく書いた次のようなコードは:
class UserSession {
bool get isAuthenticated => true5;
void clear() { / トークン破棄ロジック / }
}
外部の利用者がこれを勝手に `extends` し、`clear()` の中身を空っぽにしたサブクラスをDIコンテナに登録したとする。コンパイルは平然と通る。しかし、実行時にはセキュリティホールが生まれる。
「そんな行儀の悪いコードは書かせない」というドキュメント上の規約は、チーム開発やオープンソースにおいて無力だ。規約は破られるためにあるが、コンパイラは決して妥協しない。 だからこそ、修飾子で物理的に制限をかける必要がある。
—
2. 三大修飾子の本質的理解
Dart 3以降、クラスには以下の修飾子を付与できる。それぞれの制約を「継承(`extends`)」と「実装(`implements`)」の軸で完全にマトリクス化して頭に叩き込んでほしい。
| 修飾子 | 同一ライブラリ内での継承 (`extends`) | 同一ライブラリ内での実装 (`implements`) | ライブラリ外での継承 (`extends`) | ライブラリ外での実装 (`implements`) |
| :— | :—: | :—: | :—: | :—: |
| (なし) | 可 | 可 | 可 | 可 |
| `base` | 可 | 不可 | 可 | 不可 |
| `interface`| 不可 | 可 | 不可 | 可 |
| `final` | 不可 | 不可 | 不可 | 不可 |
> 💡 Dartの「ライブラリ」の単位とは?
> Dartにおけるライブラリとは、基本的に1つのファイル(または `part` で結合されたファイル群)を指す。つまり、同一ファイル内であれば、これらの制約は緩むが、他のファイル(`import` する側)から見たときに厳格な壁として機能する。
—
3. 実務で直結するデザインパターン:プロダクションコード解説
では、実際のフロントエンド(状態管理やAPIクライアント)の設計を想定した、保守性の高いコードを見てみよう。ここでは `base`, `interface`, `final` を適材適所で配置し、不正な利用をコンパイルエラーで弾く設計を構築する。
コピペで動く堅牢なコンポーネント設計
// =================================================================
// 1. interface クラス: 「振る舞い(契約)」だけを強制する
// =================================================================
/// UIコンポーネントやロガーなど、実装の詳細を隠蔽し、
/// インターフェースだけを外部に公開したい場合に使う。
interface class AnalyticsLogger {
void logEvent(String name, Map
}
// 外部ライブラリ(別ファイル)での利用を想定した実装
class FirebaseAnalyticsAdapter implements AnalyticsLogger {
@override
void logEvent(String name, Map
print(‘[Firebase] Event: $name, Params: $params’);
}
}
// ❌ コンパイルエラーになる例(ライブラリ外からの extends は禁止)
// class HackedLogger extends AnalyticsLogger {
// @override
// void logEvent(String name, Map
// }
// =================================================================
// 2. base クラス: 内部ロジック(テンプレートメソッド)の安全な共有
// =================================================================
/// 共通のステート管理ロジックやライフサイクルを持ちつつ、
/// サブクラスに特定のメソッド(buildState等)の実装を強制する。
/// ただし、型としての契約(implements)ではなく、あくまで「実装の継承」を強制する。
base class BaseAsyncController
bool _isLoading = false;
bool get isLoading => _isLoading;
// サブクラスでオーバーライドされることを前提としたベース処理
Future
_isLoading = true;
notifyListeners();
try {
final result = await executeFetch();
return result;
} finally {
_isLoading = false;
notifyListeners();
}
}
// サブクラスに実装を強制する protected 的なメソッド
// (Dartには protected キーワードがないため base で代用する)
Future
throw UnimplementedError();
}
void notifyListeners() {
print(‘State updated. isLoading: $_isLoading’);
}
}
// 外部ファイルでの正しい継承(extends はOK、implements は NG)
base class UserProfileController extends BaseAsyncController
@override
Future
await Future.delayed(const Duration(milliseconds: 500));
return ‘User: Alice’;
}
}
// ❌ コンパイルエラーになる例(implements は許可されない)
// class FakeController implements BaseAsyncController
// …
// }
// =================================================================
// 3. final クラス: 継承・実装を完全に遮断し、ドメインモデルを保護する
// =================================================================
/// イミュータブルな値オブジェクトや、設計者が意図しない拡張を
/// 一切許したくないデータ構造に付与する。
final class Money {
final int amount;
final String currency;
const Money(this.amount, {this.currency = ‘JPY’});
Money operator +(Money other) {
if (currency != other.currency) {
throw ArgumentError(‘Cannot add different currencies’);
}
return Money(amount + other.amount, currency: currency);
}
}
// ❌ コンパイルエラーになる例(final クラスの継承・実装は一切不可)
// class UsdMoney extends Money {
// UsdMoney(int amount) : super(amount, currency: ‘USD’);
// }
—
4. なぜこれがパフォーマンスとAOTコンパイルに寄与するのか?
表面的な「バグを防ぐ」というメリットの他に、コアコミッターの視点からもう一つの重要な事実を伝えよう。それはパフォーマンス(特にAOTコンパイルとDevirtualization)への影響だ。
Dart VMやFlutterのAOTコンパイラ(特にiOS向けのReleaseビルド)は、コードの最適化において「このメソッドがオーバーライドされる可能性があるか(Polymorphism)」を常に気にかけている。
1. クラスがデフォルト(継承可能)な場合:
コンパイラは「いつかどこかのコードでこのメソッドがオーバーライドされるかもしれない」と仮定せざるを得ない。そのため、メソッド呼び出しに Vtable(仮想メソッドテーブル) を経由した動的ディスパッチ(Dynamic Dispatch)が必要になり、インライン展開などの強力な最適化が阻害される。
2. `final` や `base`(適切に閉じられた)クラスの場合:
コンパイラは「このクラスはこれ以上拡張されない」と確信できるため、Devirtualization(脱仮想化) を行い、メソッド呼び出しをダイレクトな関数呼び出し(Static Call)へと昇格させることができる。さらに、不要なメタデータの生成を削るため、バイナリサイズ(App Size)の削減にも直結する。
APIの安全性を高めることが、そのままマシーンコードの実行効率向上につながる——これこそがDartを深く知るエンジニアの設計美学だ。
—
5. テクニカルリードからの実践的指針:どう選び分けるべきか?
明日からのコードレビュー、あるいは新規設計で、チームメンバーにどう指示すべきか。以下のアルゴリズムでクラス修飾子を選択してほしい。
1. 値オブジェクト、DTO、ドメインエンティティ、または単一の完結した処理クラスか?
👉 迷わず `final` をつけろ。拡張の余地を与えるな。
2. デザインパターン(Template Method等)で共通処理を共有させ、サブクラスに一部の実装を強制したいか?
👉 `base` をつけ、外部からの `implements` を禁止して型の整合性を守れ。
3. プラグインのインターフェースや、モジュール間の境界となる抽象定義(Adapterの受け口など)か?
👉 `interface` をつけ、継承(実装の共有)による密結合を防ぎつつ、実装(契約)だけを強制しろ。
「とりあえず `class` と書く」という習慣を今日で終わりにしよう。型に厳格なガードレールを敷くことこそが、プロダクトを長期にわたって健やかに保つ唯一の道である。