【実務・中級編】Dartの「base」「interface」「final」修飾子による、変数宣言時の継承・実装制限 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

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 params);
}

// 外部ライブラリ(別ファイル)での利用を想定した実装
class FirebaseAnalyticsAdapter implements AnalyticsLogger {
@override
void logEvent(String name, Map params) {
print(‘[Firebase] Event: $name, Params: $params’);
}
}

// ❌ コンパイルエラーになる例(ライブラリ外からの extends は禁止)
// class HackedLogger extends AnalyticsLogger {
// @override
// void logEvent(String name, Map params) {}
// }

// =================================================================
// 2. base クラス: 内部ロジック(テンプレートメソッド)の安全な共有
// =================================================================
/// 共通のステート管理ロジックやライフサイクルを持ちつつ、
/// サブクラスに特定のメソッド(buildState等)の実装を強制する。
/// ただし、型としての契約(implements)ではなく、あくまで「実装の継承」を強制する。
base class BaseAsyncController {
bool _isLoading = false;
bool get isLoading => _isLoading;

// サブクラスでオーバーライドされることを前提としたベース処理
Future fetch() async {
_isLoading = true;
notifyListeners();
try {
final result = await executeFetch();
return result;
} finally {
_isLoading = false;
notifyListeners();
}
}

// サブクラスに実装を強制する protected 的なメソッド
// (Dartには protected キーワードがないため base で代用する)
Future executeFetch() {
throw UnimplementedError();
}

void notifyListeners() {
print(‘State updated. isLoading: $_isLoading’);
}
}

// 外部ファイルでの正しい継承(extends はOK、implements は NG)
base class UserProfileController extends BaseAsyncController {
@override
Future executeFetch() async {
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` と書く」という習慣を今日で終わりにしよう。型に厳格なガードレールを敷くことこそが、プロダクトを長期にわたって健やかに保つ唯一の道である。

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