Dart のクラス・mixin・拡張メソッド:Dart 3.11 で実行検証
コンストラクタと const、extends・implements・with の違い、mixin の実行順、拡張メソッドの静的ディスパッチの罠、==/hashCode、Dart 3 のクラス修飾子を実エラー付きで。
シリーズ:Dart 講座 3/6(中級)。 Dart の関数とコレクションは書けて、これから Flutter アプリやパッケージのクラス設計をする方向けです。読み終えると、extends・implements・with・拡張メソッドを使い分け、mixin の実行順を予測し、正しい ==/hashCode を書き、Dart 3 のクラス修飾子のエラーを推測ではなく読んで直せるようになります。
Flutter の受託開発や社内アプリの現場では、クラスをあまり「選ばず」に書きがちです。StatelessWidget は継承するもの、const はリンターに言われたら付けるもの、SingleTickerProviderStateMixin はサンプルからコピーするもの。この記事は、それでは済まなくなった瞬間のためのものです。重複が消えない Set、なぜか「消える」拡張メソッド、パッケージ更新後に突然出る can't be implemented outside of its library。以下の出力とエラーはすべて実際の実行結果からのコピーです。
実行環境: Dart SDK 3.11.5(stable、macos_arm64)、Apple Silicon Mac(M1 Pro)。単一ファイルを dart run(JIT)で実行し、計測は dart compile exe(AOT)でも行いました。アナライザのメッセージは dart analyze の出力です。出力中のパスは ~/demo/ に置き換えています。引用した Flutter のソースは Flutter 3.41.9 のものです。
コンストラクタ:初期化リスト・名前付き・factory・const
実務のコードで必ず出会う4種類のコンストラクタを1ファイルにまとめました。
class Point {
final int x, y;
const Point(this.x, this.y);
}
class Temperature {
final double celsius;
final double fahrenheit;
// 初期化リスト:本体より先に動き、final フィールドを設定できる
Temperature(this.celsius)
: assert(celsius >= -273.15, 'below absolute zero'),
fahrenheit = celsius * 9 / 5 + 32;
Temperature.freezing() : this(0); // 名前付き+リダイレクト
}
class Logger {
final String name;
static final _cache = <String, Logger>{};
Logger._(this.name) { print(' created Logger($name)'); }
factory Logger(String name) => _cache.putIfAbsent(name, () => Logger._(name));
}
void main() {
const a = Point(1, 2);
const b = Point(1, 2);
final c = Point(1, 2);
final d = Point(1, 2);
print('const a vs const b identical: ${identical(a, b)}');
print('new c vs new d identical: ${identical(c, d)}');
print('a == c: ${a == c}');
print(Temperature(100).fahrenheit);
print(Temperature.freezing().fahrenheit);
final l1 = Logger('net');
final l2 = Logger('net');
print('factory returned same object: ${identical(l1, l2)}');
try {
Temperature(-300);
} catch (e) {
print('${e.runtimeType}: $e');
}
}
$ dart run ctor.dart
const a vs const b identical: true
new c vs new d identical: false
a == c: false
212.0
32.0
created Logger(net)
factory returned same object: true
ポイントは3つです。
- const オブジェクトは正規化(canonicalize)される。
const Point(1, 2)を2回書いてもオブジェクトは1個なのでidenticalはtrue。const なしなら2個になり、Pointは==を override していないのでa == cもfalseです。 - factory コンストラクタは既存のオブジェクトを返せる。
Logger('net')を2回呼んでも "created" は1回だけ。通常の(生成)コンストラクタにはできません。 - assert が動いていない。
Temperature(-300)は何も投げず、catch も何も出力しませんでした。dart runは assert を有効にしないからです。dart run --enable-asserts ctor.dartにすると最後の行が_AssertionError: '~/demo/ctor.dart': Failed assertion: line 11 pos 16: 'celsius >= -273.15': below absolute zeroになりました。Flutter のデバッグビルドでは assert が有効、リリースビルドでは無効です。assert は開発中のチェックであって、入力検証の代わりにはなりません。
const の規則はコンパイラが強制します。最初によく見るメッセージはこの3つです。
error - Can't define a const constructor for a class with non-final fields. … - const_constructor_with_non_final_field
error - Arguments of a constant creation must be constant expressions. … - const_with_non_constant_argument
error - Const variables must be initialized with a constant value. … - const_initialized_with_non_constant_value
2つ目と3つ目は var n = 3; const p = P(n); で出ました。const オブジェクトはコンパイル時に分かる値からしか作れません。
実測:const と非 const の割り当て
Flutter のリンターはなぜ const を付けろと何度も言うのか。EdgeInsets と Text をまねた小さなクラスで、100 万個のリストを const あり・なしで作り比べました。
class Insets {
final double l, t, r, b;
const Insets.all(double v) : l = v, t = v, r = v, b = v;
}
class Label {
final String text;
final Insets padding;
const Label(this.text, {this.padding = const Insets.all(8)});
}
const n = 1000000;
List<Label> buildConst() => List.generate(n, (_) => const Label('Hello', padding: Insets.all(8)));
List<Label> buildNew() => List.generate(n, (_) => Label('Hello', padding: Insets.all(8)));
それぞれ計測なしで3回動かしてから9回計り、中央値を出しました。メモリは新しいプロセスで1回作る前後の ProcessInfo.currentRss の差、異なるオブジェクトの数は Set.identity() で数えています。
$ dart compile exe constbench.dart -o constbench && ./constbench
const Label x 1000000: median 2.73 ms
new Label x 1000000: median 40.51 ms
$ ./constbench rss-const ; ./constbench rss-new
rss-const: RSS +7.7 MiB, distinct objects in list: 1
rss-new: RSS +118.9 MiB, distinct objects in list: 1000000
const 版のリストは同じ1個のオブジェクトへの参照を 100 万個持つだけなので、7.7MiB はほぼリスト本体の大きさです。const なしは要素ごとに Label と Insets の2個を割り当てています。JIT(dart run)では 8.10ms 対 40.10ms でした。RSS は大まかな目安ですが、2回目の実行でも 0.1MiB 以内で再現しました。
Flutter ではもう1つ得があります。Flutter 3.41.9 の Element.updateChild は子を再ビルドする前に child.widget == newWidget を確認し、Widget.operator == は @nonVirtual で super == other、つまり同一性の比較です。const のサブツリーは build() のたびに同じオブジェクトなので更新を省略でき、毎回 Text('Hello') を new するとその都度更新処理を通ります。
ゲッター・セッター、継承、抽象クラス
ゲッターとセッターは呼び出し側からはフィールドに見えるので、後から検証を足しても呼び出し側を変えずに済みます。
class Volume {
int _level = 5;
int get level => _level;
set level(int v) {
if (v < 0 || v > 10) throw RangeError.range(v, 0, 10, 'level');
_level = v;
}
bool get isMuted => _level == 0;
}
void main() {
final v = Volume();
v.level = 0;
print('level=${v.level} muted=${v.isMuted}');
try { v.level = 11; } on RangeError catch (e) { print(e); }
}
level=0 muted=true
RangeError (level): Invalid value: Not in inclusive range 0..10: 11
_level のアンダースコアは、クラスではなくライブラリ(ファイル)単位の private です。Java や C# から来た方が最初に戸惑う点で、後半のクラス修飾子でも効いてきます。
Dart のクラスはすべて暗黙のインターフェースも定義します。extends はコードを継承し、implements は型だけを受け取ってメンバーを全部自分で書きます。書き忘れるとアナライザが止めます。
class Animal { String get name => 'a'; String describe() => name; }
class Fake implements Animal {}
error - Missing concrete implementations of 'Animal.describe' and 'getter Animal.name'. Try implementing the missing methods, or make the class abstract. - non_abstract_class_inherits_abstract_member
テスト用のフェイクに implements を使うのはこのためです。型は同じで、振る舞いは何も引き継ぎません。
on 付き mixin と、実際の実行順
mixin は複数のクラス階層に足せるメンバーのまとまりです。on Animal を付けると Animal のサブクラスにしか使えなくなる代わりに、super を呼べます。
abstract class Animal {
String get name;
String describe() => '$name: Animal.describe';
}
mixin Walker on Animal {
@override
String describe() => 'Walker -> ${super.describe()}';
String walk() => '$name walks';
}
mixin Swimmer on Animal {
@override
String describe() => 'Swimmer -> ${super.describe()}';
}
class Duck extends Animal with Walker, Swimmer {
@override
final String name = 'duck';
@override
String describe() => 'Duck -> ${super.describe()}';
}
class Penguin extends Animal with Swimmer, Walker {
@override
final String name = 'penguin';
}
class FakeAnimal implements Animal {
@override
String get name => 'fake';
@override
String describe() => 'FakeAnimal wrote everything itself';
}
void main() {
print(Duck().describe());
print(Penguin().describe());
print(FakeAnimal().describe());
print(Duck() is Walker && Duck() is Swimmer && Duck() is Animal);
}
Duck -> Swimmer -> Walker -> duck: Animal.describe
Walker -> Swimmer -> penguin: Animal.describe
FakeAnimal wrote everything itself
true
with Walker, Swimmer は Animal → Animal + Walker → Animal + Walker + Swimmer → Duck という鎖を作ります。一番右の mixin がクラスに最も近いので最初に動き、その super は左隣の mixin です。Penguin のように順番を入れ替えると出力も入れ替わります。
on Animal の mixin を Animal でないクラスに付けると次のエラーです。
error - 'Walker' can't be mixed onto 'Object' because 'Object' doesn't implement 'Animal'. Try extending the class 'Walker'. - mixin_application_not_implemented_interface
Flutter の SingleTickerProviderStateMixin が State のサブクラスにしか付けられないのも、on State で宣言されているからです。
よくある間違い:拡張メソッドは静的に選ばれる
拡張メソッド(extension)は int や String など自分のものでない型にメソッドを足せます。便利ですが、罠が1つあります。
extension on Object { String get kind => 'Object'; }
extension on String { String get kind => 'String'; }
extension IntPrice on int {
String get yen => '¥${toString().replaceAllMapped(RegExp(r'\B(?=(\d{3})+$)'), (_) => ',')}';
}
class Box { String get kind => 'Box member'; }
extension on Box { String get kind => 'Box extension'; }
void main() {
String s = 'hi';
Object o = s; // 同じオブジェクト、別の静的型
dynamic d = s;
print('s.kind = ${s.kind}');
print('o.kind = ${o.kind}');
print('Box().kind = ${Box().kind}');
print(1234567.yen);
try { print(d.kind); } catch (e) { print('d.kind -> ${e.runtimeType}'); }
}
s.kind = String
o.kind = Object
Box().kind = Box member
¥1,234,567
d.kind -> NoSuchMethodError
実際に起きること: コンパイラは変数の静的な型で拡張を選びます。o の中身は実行時には String ですが宣言型が Object なので、Object 向けの拡張が動きます。dynamic 経由では拡張がまったく探されず、実行時に失敗します。クラス本来のメンバーは常に拡張より優先され、Box の拡張にはアナライザが warning - The declaration 'kind' isn't referenced. - unused_element を出しました。
直し方: 実行時の型で振る舞いを変えたいなら、拡張ではなく本物のメソッド(またはインターフェース)にします。拡張は、価格の書式化のようにサブタイプで意味が変わらないヘルパーに使います。Flutter では jsonDecode の結果が dynamic なので、拡張を呼ぶのは値にきちんと型を付けてからにしましょう。
よくある間違い:== だけ override して hashCode を忘れる(Set の重複バグ)
class TagA { final String id; TagA(this.id); } // override なし
class TagB {
final String id;
TagB(this.id);
@override
bool operator ==(Object other) => other is TagB && other.id == id;
// hashCode は override していない
}
class TagC {
final String id;
const TagC(this.id);
@override
bool operator ==(Object other) => other is TagC && other.id == id;
@override
int get hashCode => id.hashCode;
}
class MutableTag {
String id;
MutableTag(this.id);
@override
bool operator ==(Object other) => other is MutableTag && other.id == id;
@override
int get hashCode => id.hashCode;
}
void main() {
print('A: ${{TagA('x'), TagA('x')}.length} ==: ${TagA('x') == TagA('x')}');
print('B: ${{TagB('x'), TagB('x')}.length} ==: ${TagB('x') == TagB('x')}');
print('B contains: ${{TagB('x')}.contains(TagB('x'))}');
print('C: ${{TagC('x'), TagC('x')}.length} ==: ${TagC('x') == TagC('x')}');
final t = MutableTag('a');
final set = {t};
t.id = 'b';
print('mutated key: contains(t)=${set.contains(t)} length=${set.length}');
}
A: 2 ==: false
B: 2 ==: true
B contains: false
C: 1 ==: true
mutated key: contains(t)=false length=1
実際に起きること: TagB は == で等しいのに、{TagB('x'), TagB('x')} の長さは 2 です。Set や Map はまず hashCode で決まるバケットを探します。既定の hashCode はオブジェクトごとに違うので、2個目は別のバケットに入り、比較すらされません。contains が失敗するのも同じ理由です。最後の行はさらに厄介で、追加後に id を変えたキーは古いバケットに残るため、Set は自分が持っているオブジェクトを見つけられません。
オプションなしの dart run と dart analyze は、このファイルに何も警告を出しませんでした。リントはありますが、有効にしないと動きません。hash_and_equals(package:lints の core に含まれ、dart create や flutter create のプロジェクトでは recommended/flutter_lints 経由で有効)を有効にするとこう出ます。
info - Missing a corresponding override of 'hashCode'. Try overriding 'hashCode' or removing '=='. - hash_and_equals
直し方: 同じ final フィールドから両方を override し、キーは不変にします。値のようなデータなら、第4回のレコードを使えば == と hashCode が最初から正しく付いてきます。
Dart 3 のクラス修飾子:実際のコンパイルエラー
Dart 3.0 で base・interface・final・sealed・mixin class が加わりました。これらはライブラリの外のコードに何を許すかを決めます。lib/api.dart に次を宣言しました。
base class Repo { void save() {} }
interface class Codec { String encode(String s) => s; }
final class Token { final String v; Token(this.v); }
sealed class Shape {}
class Circle extends Shape {}
abstract interface class Clock { DateTime now(); }
mixin class Loggable { void log(String m) => print(m); }
main.dart から規則を全部破ってみます。
import 'lib/api.dart';
class MyRepo implements Repo { @override void save() {} } // 3行目
class MyRepo2 extends Repo {} // 4行目
class MyCodec extends Codec {} // 5行目
class FakeCodec implements Codec { @override String encode(String s) => s; }
class MyToken extends Token { MyToken() : super('x'); } // 7行目
class FakeToken implements Token { @override String get v => ''; } // 8行目
class Square extends Shape {} // 9行目
class SystemClock implements Clock { @override DateTime now() => DateTime.now(); }
void main() {
Codec();
Shape(); // 14行目
Clock();
Loggable().log('mixin class used as a class');
}
dart analyze main.dart はエラー9件(4行目と15行目の同種のものは省略)。
error - main.dart:3:7 - The type 'MyRepo' must be 'base', 'final' or 'sealed' because the supertype 'Repo' is 'base'. - subtype_of_base_or_final_is_not_base_final_or_sealed
error - main.dart:3:25 - The class 'Repo' can't be implemented outside of its library because it's a base class. - invalid_use_of_type_outside_library
error - main.dart:5:23 - The class 'Codec' can't be extended outside of its library because it's an interface class. - invalid_use_of_type_outside_library
error - main.dart:7:23 - The class 'Token' can't be extended outside of its library because it's a final class. - invalid_use_of_type_outside_library
error - main.dart:8:28 - The class 'Token' can't be implemented outside of its library because it's a final class. - invalid_use_of_type_outside_library
error - main.dart:9:22 - The class 'Shape' can't be extended, implemented, or mixed in outside of its library because it's a sealed class. - invalid_use_of_type_outside_library
error - main.dart:14:3 - Abstract classes can't be instantiated. Try creating an instance of a concrete subtype. - instantiate_abstract_class
implements Codec・implements Clock・Codec()・Loggable() は通りました。実務で効くのは次の2点です。
- 同じライブラリの中では何も制限されない。
Circle extends Shapeは同じファイルなので問題なし。だからこのエラーは、たいていパッケージを更新した後に初めて出ます。 - 普通のクラスはもう mixin にできない。
class Plain {} class X with Plain {}はerror - The class 'Plain' can't be used as a mixin because it's neither a mixin class nor a mixin. - class_used_as_mixinになります。同じファイルの先頭に// @dart=2.19を書くと実行でき、hiと出力されました。普通のクラスを mixin していた古いパッケージは、SDK 制約を Dart 3 に上げたときにまさにここで壊れます。アップグレード全体の話はFlutter アプリ 16 本の移行記録にまとめています。
修飾子が Dart 3.0 の機能であることも確認しました。// @dart=2.19 を付けるとアナライザは This requires the 'class-modifiers' language feature to be enabled … - experiment_not_enabled(sealed には 'sealed-class')を出します。
判断表
| やりたいこと | 使うもの | 注意 |
|---|---|---|
| コードを再利用し、is-a のサブタイプになる | extends |
スーパークラスは1つだけ |
| 型だけ欲しく中身は自前(フェイク、アダプタ) | implements |
メンバーを全部書く |
| 無関係な階層にメンバーを共有 | mixin + with |
一番右の mixin が先に動く |
基底クラスの super が必要な mixin |
mixin M on Base |
Base のサブクラス限定 |
int・String・他パッケージの型にヘルパー |
extension | 静的ディスパッチ。多態には使わない |
| キーごとに1個、キャッシュを返す | factory コンストラクタ |
サブタイプも返せる |
| 共有できる不変の値(ウィジェット) | const コンストラクタ |
全フィールド final、正規化される |
| 実装は許すが継承は禁止 | interface class |
ライブラリ外にだけ効く |
| 継承は許すが実装は禁止 | base class |
サブクラスも base/final/sealed |
| 網羅的 switch 用の閉じたサブタイプ集合 | sealed class |
第4回 |
| ライブラリ外にサブタイプを作らせない | final class |
パッケージ作者に一番安全 |
正直な限界とバージョンのメモ
- すべて Dart 3.11.5 で実行しました。クラス修飾子・
mixin class・sealedが 3.0 の機能であることは// @dart=2.19で確かめましたが、古い SDK 自体はインストールしていません。 - const の計測は割り当てのマイクロベンチマークで、Flutter のフレーム時間ではありません。実際の効果はツリーのどれだけを const にできるかで変わります。RSS には GC ヒープの余裕分も含まれるので、MiB は相対比較として見てください。
- この回では Flutter アプリは動かしていません。
updateChildの挙動は Flutter 3.41.9 のフレームワークのソースからの引用です。 analysis_options.yamlなしのdart analyzeではhash_and_equalsは有効になりません。dart create/flutter createのプロジェクトではpackage:lints/flutter_lints経由で有効です。
練習問題
問題 1. 出力を予想してから実行してください。
abstract class Base { String hi() => 'Base'; }
mixin A on Base { @override String hi() => 'A>${super.hi()}'; }
mixin B on Base { @override String hi() => 'B>${super.hi()}'; }
mixin C on Base { @override String hi() => 'C>${super.hi()}'; }
class X extends Base with A, C, B {}
void main() => print(X().hi());
問題 2. final seen = <Email>{}; に Email('a@example.com') を2回入れると2件になります。Email は final String address を1つ持ち、== だけ override しています。1件になるよう直し、'A@example.com' も同じアドレスとして扱ってください。
解答
1. B>C>A>Base。一番右の mixin(B)が最初に動き、super のたびに1つ左へ進みます。
2. == と同じ正規化済みの値から hashCode を作ります。
class Email {
final String address;
Email(String a) : address = a.toLowerCase();
@override
bool operator ==(Object other) => other is Email && other.address == address;
@override
int get hashCode => address.hashCode;
}
初期化リストで正規化しておけば、== と hashCode は自動的に食い違わなくなります。3件入れて seen.length が 1 になることを確認しました。(address: a.toLowerCase()) のようなレコードでも同じ結果になります。
関連記事:Java のクラス・レコード・インターフェース · Swift の構造体・クラス・プロトコル · PHP の OOP・インターフェース・enum
前回: 第 2 回 関数とコレクション · 次回: 第 4 回 レコード・パターン・sealed クラス
よくある質問
const コンストラクタで Flutter のウィジェットは本当に軽くなりますか?
計測では差がはっきり出ました。小さなオブジェクト 100 万個のリストを作ると、const 版は 2.73ms・RSS 増加 7.7MiB(全要素が同じ1個のインスタンス)、const なしは 40.51ms・118.9MiB でした(AOT、Apple Silicon Mac M1 Pro)。さらに Flutter は同一オブジェクトのウィジェットの再ビルドを省略します。
複数の mixin が同じメソッドを override したら、どの順で動きますか?
右から左です。class Duck extends Animal with Walker, Swimmer なら describe() は Duck → Swimmer → Walker → Animal の順に動きました。with の並びを入れ替えると出力も入れ替わります。
変数の型を Object や dynamic にすると拡張メソッドが効かなくなるのはなぜですか?
拡張メソッドは実行時の型ではなく、コンパイル時の静的な型で選ばれるからです。同じ String でも Object 型の変数経由なら Object 向けの拡張が呼ばれ、dynamic 経由では拡張が探されず NoSuchMethodError になります。