はじめに
Dartの名前付き引数は非常に便利ですが、引数がオプションかつnullを許容(nullable)する場合に、引数の受け取り側ではnullが渡されると、「引数にnullが指定された」のか「引数が指定されなかった」のか見分けが付かない問題(?)があります。
void hoge({ int? foo }) {
print(foo ?? 'undefined');
}
hoge(0); // "0"
hoge(); // "undefined"
hoge(null); // "undefined" <- ?
今回、このnullと引数なしを区別する方法を確認したので、結果を書き残しておこうと思います。
環境
- Dart SDK 3.2.6
問題点と具体例
ここに「名前」と「誕生年」を持つPersonクラスがあります。
class Person {
String name;
DateTime? birthday;
Person({
required this.name, // 名前は必須
this.birthday, // 誕生年は任意
});
@override
toString() => '${name}, ${birthday?.year}';
}
昨今のプライバシーに配慮し、「誕生年」の入力は任意としたいので、「誕生年」はDateTime?
のnull許容型としていることに注目です。
immutableにするべきでは?フィールド変数をPrivate化するべきでは?getter、setterを付けるべきでは?といった部分は広い心でスルーして頂けると幸いです。
さて、フィールド変数を直接編集するのはお行儀がよくないので、フィールド変数を更新するupdate
メソッドを実装したいと思います。
class Person {
...
+ void update({
+ String? name, // nameが未設定の場合はnull
+ DateTime? birthday, // birthdayが未設定の場合はnull
+ }) {
+ this.name = name ?? this.name; // 引数が指定されていない場合は既存のまま
+ this.birthday = birthday ?? this.birthday; // 引数が指定されていない場合は既存の値のまま
+ }
...
}
このようにオプションの名前付き引数を使用することで、クラスの使用側は下記のように更新したい項目のみを指定することが出来ます。
Person person = Person(name: 'foo', birthday: DateTime(2000));
print(person); // "foo, 2000"
person.update(name: 'bar');
print(person); // "bar, 2000"
person.update(birthday: DateTime(2001));
print(person); // "bar, 2001"
person.update(name: 'bazz', birthday: DateTime(2002));
print(person); // "bazz, 2002"
一見うまくいっているように思えますが、「誕生年」を削除するために引数birthday
に対してnullを指定すると想定外の動きをします。
...
print(person); // "bazz, 2002"
person.update(birthday: null);
print(person); // "bazz, 2002" <- birthdayがnullで更新されていない
これはupdate
の処理を見返すと一目瞭然で、「誕生年」の更新処理がthis.birthday = birthday ?? this.birthday
となっており、引数のbirthday
にnullが設定されても、引数未設定のnullと同義となり更新されないためです。
...
void update({
String? name,
DateTime? birthday, // 引数が未設定でもnull、nullが指定されても勿論null
}) {
this.name = name ?? this.name;
this.birthday = birthday ?? this.birthday; // birthdayがnullなので更新されない
}
...
// 下記2つは処理的に同義でbirthdayは更新されない
person.update(birthday: null);
person.update();
ではnullで更新が出来るよう、birthday
の更新処理をthis.birthday = birthday
とすると、今度はupdate(name: 'foo')
のように「名前」だけ更新したい場合でも「誕生年」がthis.birthday = null
で更新されます。
問題が分かったところで、以降では実装が簡単な順番で解決策を見ていこうと思います。
デフォルトの値として「未定義」を設定する
名前付き引数には引数が未設定の場合にデフォルトの値を設定することが可能です。そこで下記のようにconst String undefinedString = '_undefined_'
といった未設定を表す変数を用意し、これを引数のデフォルト値として与えることでnullと引数未設定を見分けます。
+ const String undefinedString = '_undefined_'; // 未設定を表す文字列型定数
+ const DateTime undefinedDateTime = DateTime(9999); // 未設定を表す日付型定数 // エラー
...
void update({
- String? name,
- DateTime? birthday,
+ String name = undefinedString, // デフォルト値を設定したのでString?ではなくString
+ DateTime? birthday = undefinedDateTime,
}) {
- this.name = name ?? this.name;
- this.birthday = birthday ?? this.birthday;
+ // nameが未設定なら既存の値、設定されているなら新しい値で更新
+ this.name = name == undefinedString ? this.name : name;
+ // birthdayが未設定なら既存の値、設定(nullも可)されているなら新しい値で更新
+ this.birthday = birthday == undefinedDateTime ? this.birthday : birthday;
}
...
一見すると正しく処理されるように思えますが、コメントにもあるようにconst DateTime undefinedDateTime = DateTime(9999)
の部分でコンパイルエラーとなります。これはDateTimeクラスがconstコンストラクタとなっていないため、コンパイル時定数(compile-time constants)と出来ないためです。
ご存じの通り、オプション引数はコンパイル時定数で指定する必要があるため(The default value of an optional parameter must be constant.)、定数の修飾子をfinalやなしには出来ません。
未設定を意味する定数をデフォルト値に設定するという方向性は正しいように思えるので、以降ではこの考えを元に解決を図ります。
なお、仮に引数が全てコンパイル時定数で指定可能であればこの手法が一番簡単かと思います。
引数を全てObjectとして受け取る
最初の解決策は引数のDateTime
クラスがコンパイル時定数に出来ない事が問題でした。そこで今回は引数を一旦Object
として受け取り、後に必要に応じてDateTime?
にキャストする方法を実装してみます。
(birthday
のキャストがDateTime?
とnullableでキャストしている点にご注意下さい。)
const Object undefined = Object(); // 未設定を表す定数
...
update({
Object name = undefined, // デフォルト値として未設定を設定
Object? birthday = undefined, // デフォルト値として未設定を設定
}) {
// 未設定の場合は既存の値を、設定されている場合はStringにキャストして設定
this.name = name == undefined ? this.name : name as String;
// 未設定の場合は既存の値を、設定されている場合はDateTime?にキャストして設定
this.birthday = birthday == undefined ? this.birthday : birthday as DateTime?;
}
これはコンパイルも通り、処理結果の方も問題ありません。しかし引数が全てObject
型のため、引数が何の型を想定しているのか実装を確認する必要があります。もちろんコメントを丁寧に記載することである程度手間を省略できますが、それでも誤った型が設定可能であり、バグの温床となることは明白ですので好ましくありません。
interfaceで公開し、実装と分ける
今までの問題を全て解決したのが、interfaceを使うパターンです。使用者には引数の型が正しいinterfaceだけ公開し、非公開の実装部の方では引数をObject
として受け取り処理します(ほぼ参考先の丸パクリ)。
const Object undefined = Object();
+ abstract interface class Person {
+ String get name;
+ DateTime? get birthday;
+
+ Person._();
+
+ factory Person({
+ required String name,
+ DateTime? birthday,
+ }) = _Person;
+
+ void update({
+ String name,
+ DateTime? birthday,
+ });
+ }
- class Person {
+ class _Person extends Person {
+ @override
String name;
+ @override
DateTime? birthday;
- Person({
+ _Person({
required this.name,
this.birthday,
- });
+ }) : super._();
+ @override
update({
Object name = undefined,
Object? birthday = undefined,
}) {
this.name = name == undefined ? this.name : name as String;
this.birthday = birthday == undefined ? this.birthday : birthday as DateTime?;
}
+ @override
toString() => '${name}, ${birthday?.year}';
}
コードが肥大化し若干複雑ですが、引数がObject
クラスで何でも受けて入れてしまう問題が解決します。
まとめ
コード量に目を瞑れるのであれば3つ目の解決策がベストです。ただ2つ目も個人開発なら、コメントや変数名を工夫することでミスは減らせると思うので悪くない。1つ目は使える機会があまりに限定的なので忘れて良さそう。
私のDartの理解がイマイチなので、もしかするとオプション引数のnullと無しを判別出来る文法とか用意されているのかもしれませんが、その時は悪しからず・・・。