実現したいこと
次の三つのプロパティを持つ Todo クラスを Dart で作るとします。
- content
- ToDo の内容
- dueAt
- 期限
- 任意だが、
notify
が有効なら必須 ← これ
- 任意だが、
- 期限
- notify
- 期限前に通知を送るかどうか
- 期限がなければ使われない
- 期限前に通知を送るかどうか
他の例
いくつかの引数のうち一つだけを排他的に必須にしたいこともあります。
例えば Flutter で child
と builder
の片方を必ず指定させ、他方は指定させないケースです。
その例はここでは省略しますが、この記事の情報を活用して実現できると思います。
ありがちな実装
class Todo {
const Todo(
this.content, {
this.dueAt,
this.notify = false,
}) : assert(notify == false || dueAt != null);
final String content;
final DateTime? dueAt;
final bool notify;
}
期限は nullable にして、assert()
によって「通知を無効にしていれば期限は任意」と「そうでなければ期限は必須」のチェックを行っています。
不満な点
動作はしますが、assertion は実行時までエラーにならず、リリースビルドでは無視されます。
通知を有効にしたのに期限を省略する(または null
を渡す)という誤った使い方をこの Todo クラスの使用側で確実に防ぐように対策していない場合に、本番の実行時に想定外の動作として初めて現れる可能性が残ります。
また先ほどの Flutter の例では、「child
と builder
の両方渡してはいけない」というプレッシャーを使用者に与えるだけで不可能にはなっていないので、間違いが起こるかもしれません。
コンストラクタのドキュメントにはその注意書きもわざわざしなければならなくなります。
改善 – コンストラクタを分ける 1
コンストラクタを分けることで使い方を制限するのが安全です。
名前付きコンストラクタを活用しましょう。
class Todo {
const Todo(this.content)
: dueAt = null,
notify = false;
const Todo.schedule(
this.content, {
required DateTime this.dueAt,
this.notify = false,
});
final String content;
final DateTime? dueAt;
final bool notify;
}
こうすると、期限を指定したい場合に名前付きコンストラクタ(Todo.schedule
)のほうを使うように強制できます。
内容のみを指定したい場合にも、余計な引数のないデフォルトコンストラクタの一択になって使いやすいという利点があります。
ポイント
required DateTime this.dueAt,
この行がポイントです。
dueAt
というプロパティの型は nullable な DateTime?
ですが、引数では non-nullable な DateTime
に限定しています。
この方法の見つけにくさ
余談なので折りたたんでいます(クリックで開閉)
この方法は先月の途中まで知らなくて、null
が渡されることを下記のような別方法で防ごうとしたときに出る警告について GitHub で報告したときに教わって知りました。
const Todo.schedule(
this.content, {
required DateTime dueAt,
this.notify = false,
}) : dueAt = dueAt; // Use an initializing formal to assign a parameter to a field.
prefer_initializing_formals の lint ルールを有効にしていると「Use an initializing formal to assign a parameter to a field.」と警告されます。
dart fix
か IDE 上での quick fix によってイニシャライザリストを使わない方法に自動改善できるのですが、そのことに気づいていませんでした。
上記の方法で型を狭められることがどこにも明記されていないことについて issue が立てられたので、ドキュメントの改善がそのうちあるかもしれません。
Flutter SDK のソースコードではどうなっているか
余談なので折りたたんでいます(クリックで開閉)
この記事を書くにあたって Flutter SDK のソースコードで同じ方法が使われていないかと一部を見たのですが、見当たりませんでした。
それどころか、non-nullable なプロパティに対する引数なのにわざわざ null
を防ぐ assertion が行われている箇所が多数あって無駄に思えました(例: Padding クラスの padding)。
Null safety より前のコードを引きずったまま新しいバージョンで可能になった書き方を反映できていないだけかもしれません。
改善 – コンストラクタを分ける 2
上の方法では、使わないプロパティは初期化リストで初期化しないといけませんでした。
null でいい場合でも明示的な初期化が必要です。
const Todo(this.content)
: dueAt = null,
notify = false;
この初期化リストの記述は、コンストラクタやプロパティの数が多いほど面倒になります。
その場合には redirecting factory コンストラクタが役立ちます。
class Todo {
const Todo._(this.content, {this.dueAt, this.notify = false});
const factory Todo(String content) = Todo._;
const factory Todo.schedule(
String content, {
required DateTime dueAt,
bool notify,
}) = Todo._;
final String content;
final DateTime? dueAt;
final bool notify;
}
redirecting factory は他の種類のコンストラクタより少し難しいですが、うまく使うと便利です。
適所を見つけて活用したいですね。
クラス自体を分ける
コンストラクタを分けずにクラスを分ける方法も可能です。
class Todo {
const Todo._(
this.content, {
this.dueAt,
this.notify = false,
});
const Todo(this.content)
: dueAt = null,
notify = false;
// 上記の代わりにfactoryかredirecting factoryでもいい
// factory Todo(String content) = Todo._;
final String content;
final DateTime? dueAt;
final bool notify;
}
class ScheduledTodo extends Todo {
const ScheduledTodo(
super.content, {
required DateTime super.dueAt,
super.notify,
}) : super._();
@override
DateTime get dueAt => super.dueAt!;
}
required DateTime super.dueAt
の部分はコンストラクタを分ける場合とほぼ同じで、this
が super
になっただけです。
それよりも注目したいのは super._()
です。
super.dueAt
のような super 引数は通常は親クラスのデフォルトコンストラクタに対して渡されますが、この指定によって Todo._()
に対して渡すことができます。
イニシャライザリストで super._(content, dueAt: dueAt, notify: notify)
と書く代わりに全部を super 引数で渡していて、他に渡すものがないので super._()
だけを書いている形ですね。
クラスを分けるメリット
コンストラクタを分けた場合、Todo.schedule
の dueAt
という引数が non-nullable であってもプロパティは nullable なままです。
final todo = Todo.schedule('Buy milk', dueAt: DateTime(2023, 1, 31));
final dueAt = todo.dueAt; // nullable
一方、クラスを分けた場合は DateTime get dueAt => super.dueAt!;
のようにゲッターで override して non-nullable にすることができます。
このようなメリットはありますが、やや手間がかかるので私はコンストラクタを分ける方法を主に選択すると思います。
でも、引数とプロパティを子クラスでのみ non-nullable に変える方法は、タイトルの目的以外でも使いたい機会がありそうです。
注意
Todo 型にすると当然ながら nullable になるのでご注意ください。
final todo = ScheduledTodo('Buy milk', dueAt: DateTime(2023, 1, 31));
final dueAt = todo.dueAt; // non-nullable
final Todo todo2 = todo;
final dueAt2 = todo2.dueAt; // nullable