こんにちは!FlutterやDartの開発現場で、日々コードの海に潜っている先輩エンジニアです。
今回は、Dartのオブジェクト指向を語る上で絶対に避けて通れない、そして中級者へのステップアップとして絶対に押さえておきたい「クラス階層の制御(`base`, `interface`, `final`修飾子)」について解説します。
他の言語(JavaやC#など)からDartに入った方だと、「あれ、なんだこの新しいキーワードは?」と戸惑うポイントですよね。ここをクリアすると、あなたが設計したAPIやクラス群の「意図」をコンパイラにしっかり理解させ、他の開発者が間違った使い方をしようとした瞬間にコンパイルエラーで守ってくれる、非常に堅牢なコードが書けるようになります。
ここをクリアすれば、Dartの型システムを掌握したも同然です。一緒にバッチリマスターしていきましょう!
—
1. なぜクラスの継承・実装を制御する必要があるのか?
私たちがライブラリやフレームワークを設計するとき、「このクラスはそのまま継承して拡張してほしいけれど、勝手にインターフェースとして実装(implements)されると困る」あるいはその逆のシチュエーションが必ず生まれます。
従来のDart(Null安全導入前や初期)では、すべてのクラスがデフォルトで「どこからでも継承でき、どこからでも実装できる」オープンな状態でした。これは自由度が高い一方で、ライブラリの作者が「内部の構造(プライベートなフィールドやメソッド)」を将来のアップデートで変更したとき、勝手にそれを`implements`していた外部のコードが突然壊れるという「脆弱な基底クラスの問題」を引き起こしていました。
そこでDart 3から導入されたのが、クラスの振る舞いを厳格に絞り込む以下の3つの修飾子です。
- `base`: 継承(`extends`)は許可するが、実装(`implements`)は禁止する。
- `interface`: 実装(`implements`)は許可するが、継承(`extends`)は禁止する。
- `final`: 継承も実装も一切禁止する(完全な終端クラス)。
頭の中でごちゃ混ぜになりやすいので、まずはイメージ図を見てみましょう。
[通常のクラス (class)] —> 継承も実装も自由(従来通り)
[baseクラス] —> 継承: 〇 / 実装: × (内部の振る舞いを守りたい時)
[interfaceクラス] —> 継承: × / 実装: 〇 (共通の型・契約だけを強制したい時)
[finalクラス] —> 継承: × / 実装: × (これ以上広げさせない時)
それでは、それぞれの修飾子を具体的なコードとコンパイラの挙動を交えて見ていきましょう!
—
2. `base` 修飾子:内部実装を守りながら拡張を許す
`base`クラスは、「コードの再利用(継承)はさせたいけれど、メソッドの内部実装の繋がり(スーパーコールの保証など)は絶対に壊されたくない」というときに使います。
`base`が付けられたクラスを継承するサブクラスは、必ず `base`(または `final`)で宣言しなければならないというルール(伝播性)があります。
コード例:`base` の挙動
// base修飾子をつけた基底クラス
base class Vehicle {
void move() {
print(‘乗り物が移動します’);
}
}
// 【OK】baseクラスを継承(extends)するのは許可される
base class Car extends Vehicle {
@override
void move() {
super.move();
print(‘車輪で走ります’);
}
}
// 【コンパイルエラー!】
// interfaceや通常のclassとしてimplementsすることは許されない
// エラーメッセージ: The type ‘Vehicle’ can’t be implemented outside of its library…
class FakeVehicle implements Vehicle {
@override
void move() {
print(‘偽物の乗り物’);
}
}
【ここがポイント!】
`base`を使うことで、「このクラスを`implements`して、中身のロジックを完全に無視したハリボテのクラスを作られるリスク」をコンパイル時に完全にシャットアウトできます。フレームワーク側の基底クラスなどで非常によく使われるパターンです。
—
3. `interface` 修飾子:「契約(型)」だけを強制する
Javaなどの `interface` や、TypeScriptの `interface` に近い挙動をDartに持たせるのが `interface` 修飾子です。
これをつけると、「このクラスが持つメソッドやプロパティの形(契約)は強制するけれど、このクラス自体の具体的な実装コードを使い回させる(継承する)ことは絶対に許可しない」という設計になります。
コード例:`interface` の挙動
// interface修飾子をつけたクラス
interface class Logger {
void log(String message) {
// デフォルトの実装を持たせることも可能
print(‘LOG: $message’);
}
}
// 【OK】implementsして「型とメソッドの強制」を受けるのは許可される
class ConsoleLogger implements Logger {
@override
void log(String message) {
print(‘[Console] $message’);
}
}
// 【コンパイルエラー!】
// extendsして中身の実装を流用することは禁止される
// エラーメッセージ: The type ‘Logger’ can’t be extended outside of its library…
class AdvancedLogger extends Logger {
// エラーになるため、ここには到達できない
}
【ここがポイント!】
「継承の乱用によるスパゲッティコード」を防ぐための強力な武器です。「中身のコードの共有はさせたくないが、共通の窓口(型)として扱わせたい」というポリモフィズム(多態性)を設計したいときに迷わず使ってください。
—
4. `final` 修飾子:変更の連鎖を断ち切る究極の形
変数宣言で使う `final` は「再代入不可」を意味しますが、クラスに付与する `final` は「このクラス階層はここで完結(ファイナライズ)する」ことを意味します。
そのクラスの継承も、実装も、同一ファイル内であっても一切許可しません。
コード例:`final` の挙動
// final修飾子をつけたクラス
final class AppConfig {
final String appName = ‘Dart Master App’;
int get timeoutSeconds => 30;
}
// 【すべてコンパイルエラー!】
// extendsすることはできない
class SpecialConfig extends AppConfig {}
// implementsすることもできない
class MockConfig implements AppConfig {
@override
String get appName => ‘Mock’;
@override
int get timeoutSeconds => 0;
}
【ここがポイント!】
「このクラスはこれ以上拡張されることを想定していない」「サードパーティに勝手にサブクラスを作られたくない」というユーティリティクラスやデータモデルにおいて、非常に高い安全性を担保できます。迷ったらまずは `final` にしておく、というのも現代のDart設計における堅実なアプローチの一つです。
—
5. まとめと早見表
ここまで、`base`、`interface`、`final` という3つの強力な修飾子を見てきました。最後に、それぞれの違いを整理した早見表を頭に焼き付けておきましょう。
| 修飾子 | 継承 (`extends`) | 実装 (`implements`) | 主なユースケース |
| :— | :—: | :—: | :— |
| 通常 (`class`) | 〇 | 〇 | 従来の自由な設計(レガシー互換) |
| `base` | 〇 | × | 内部実装の安全性を保ちつつコードを共有したいとき |
| `interface` | × | 〇 | 型の契約(インターフェース)だけを強制したいとき |
| `final` | × | × | 拡張を完全に禁止し、クラスの仕様を固定したいとき |
これらの修飾子を適切に使い分けることで、あなたの書いたコードは「意図しないバグの温床」から守られ、保守性の高い美しいアーキテクチャへと生まれ変わります。
最初は少し厳格に感じるかもしれませんが、コンパイラがあなたの意図を完璧に守ってくれる心地よさが分かってくると、もうこれらの修飾子なしでの開発には戻れなくなりますよ!
今回の解説が、皆さんのDartライフをより豊かで楽しいものにするきっかけになれば幸いです。それでは、また次回の記事でお会いしましょう!