DartにおけるConstructorとFactoryの実装パターンを考える

はじめに

Dartはインスタンス生成の手法に幅があり、様々な表現を選択することができます。

この記事ではどういった表現があるのかを整理し、普段どのような狙いや考えを持って表現選択をしているのかを可能な限り言語化して示します。

紹介するのはあくまで自身が正しいと考えている内容であり、Dartの公式が提唱する言葉の表現や意図とは異なっている内容もありますので、注意してください。基本は公式ドキュメントの情報を信用しましょう。

「公式ドキュメントを読んで言語表現の意図を理解したものの、実際に現場レベルではどういう意図や狙いを含めて使っているのか?」を知りたい方に向けて、自身の経験知をサンプルとして、何かしらの気づきや参考となるヒントを共有できればと思いこの内容を書きました。

目次


Dartのインスタンス生成パターン

1. Default Constructors

最も基本的なパターン。別名称に「Generative Constructor」という呼び方もある。

特に何も制約や意図がなければこの表現を選択する。

class Account {
  String name;
  int age;

  Account(this.name, this.age);
}

2. Private Constructors

Constructorの可視性を限定したい場合のパターン。

Dartは可視性を _ で制御しているので、この表現になる。

正確な種別としてはNamed Constructorの部分集合。

class Account {
  final String name;
  final int age;

  Account._(this.name, this.age);
}

3. Named Constructors

Constructorを名前表現で示すパターン。

名前表現によって、Constructor自体に意味を加えることや宣言時点でフィールドの値が決定する様な表現に利用できる。

Javaなどでは引数の数を変えること(オーバーロード)で意味を変えていたが、Dartは名前宣言する形で表現できる。

例ではフィールドを final 宣言しており、Named Constructorに : で繋げて age の値を代入している。これは Initializer List という機能で、Constructorの機能を実行する前に初期化処理するもの (ボディと呼ばれる {} で囲む処理以前に呼び出される ) 。これにより、変数に値をセットでき、 final 宣言で再代入不可な定数として振る舞わせることができる

void main() {
  final senior = Account.senior('シニア');
  print(senior);
}

class Account {
  final String name;
  final int age;

  Account.teen(this.name) : age = 10;

  Account.young(this.name) : age = 20;

  Account.senior(this.name) : age = 60 {
    // ここが初期化処理の"ボディ"よ呼ばれる箇所
    print('seniorだよ');
    print(this.age); // 60
  }
}

4. Redirect Constructors

Constructorの処理先をデリゲートするパターン。

親クラスのConstructorに処理を委譲しつつ、呼び出し時点の定数として 文字列 “名無しさん” を渡している。

class Account {
  final String name;
  final int age;

  Account(this.name, this.age);
}

class User extends Account {
  User.guest(int age) : super('名無しさん', age);
}

5. Const Constructors

インスタンスのフィールドがimmutable(不変)な存在であることを宣言するパターン。

constructor宣言の前につける const は宣言フィールドの全てが final でなければならない。

データクラスでは頻繁に活用する。

class Account {
  final String name;
  final int age;

  // const宣言は存在するインスタンスのフィールドがfinal宣言でなければ宣言ができない
  // initializerのボディを記述するとコンパイルエラーになる
  const Account(this.name, this.age);
}

6. Constant Constructors

インスタンス生成の宣言で const を指定するパターン。

Const Constructorsと棲み分ける理由は、Const Constructorsはフィールドは再代入不可であるが、変数が入りうるもので、Constant Constructorsはクラス宣言時 (つまり静的) に値が決まるので扱い方が変わる。

これの棲み分けはオリジナルなので注意。

void main() {
  // AccountのConstructorにconstがついていないとconst宣言ができない
  // 引数に変数を入れることができない
  final a = const Account('a', 1);
  final aa = const Account('a', 1);
  print(identical(a, aa)); // true
}

class Account {
  final String name;
  final int age;

  // const宣言は存在するインスタンスのフィールドがfinal宣言でなければ宣言ができない
  // initializerのボディを記述するとコンパイルエラーになる
  const Account(this.name, this.age);
}

7. Factory Constructors

Factoryという修飾子を付与してインスタンスの生成処理を実装するパターン。

宣言クラスのインスタンスだけでなく、宣言クラスのInterfaceを実装する子クラスのインスタンスや、生成過程に処理を仕込みたい場合に選択する。

宣言クラスに関係しないクラスのインスタンスを返すことはできない。コンパイルエラーになる。

class Account {
  final String name;
  final int age;

  Account(this.name, this.age);

  factory Account.fromJson(Map<String, dynamic> json) {
    return Account(
      json['name'] as String,
      json['age'] as int,
    );
  }
}

8. Factory & Private Constructors

Factoryの表現にPrivate Constructorを加えたパターン。

この表現によりインスタンスはFactoryを介した生成に制限したいという意図を含めることができるので、意図を含める場合に選択する。

class Account {
  final String name;
  final int age;

  Account._(this.name, this.age);

  factory Account.fromJson(Map<String, dynamic> json) {
    return Account._(
      json['name'] as String,
      json['age'] as int,
    );
  }
}

9. Factory & Abstract Constructors

Factoryの表現をAbstract Classで活用するパターン。

Factory ConstructorはAbstract Classにも宣言でき、Abstract Classをimplements or extends したクラスのインスタンスが返ることを示す。

abstract class Account {
  Account();

  Future<void> verify();

  factory Account.fromJson(Map<String, dynamic> json) {
    if (json['account_type'] == null) {
      throw ArgumentError.notNull();
    }
    if (json['account_type'] == 'user') {
      return UserAccount();
    }
    if (json['account_type'] == 'manager') {
      return ManagerAccount();
    }
    throw ArgumentError('unsupported account type [${json['account_type']}]');
  }
}

class UserAccount implements Account {
  UserAccount();

  
  Future<void> verify() async {
    print('User account verify');
  }
}

class ManagerAccount implements Account {
  
  Future<void> verify() async {
    print('Manager account verify');
  }
}

10. Factory & Singleton Constructors

Factoryの表現を活用してシングルトンオブジェクトを表現するパターン

Factory修飾子をつけたConstructorは必ず新しいインスタンスを生成して返すという制約がない。それは、一度生成済みのインスタンス(= メモリ展開済みの値) があれば返すという選択をしても良いということ。つまりはシングルトンオブジェクトを表現できる。

FlutterのGlobal State Patternなどで選択するシーンを見かける。

class Account {
  final String name;
  final int age;

  Account._(this.name, this.age);

  static Account? _singleton;

  factory Account() => _singleton ??= Account._('singleton', 30);
}

11. Top Level Constructors

Top Levelの宣言フィールドで関数として表現するパターン。

テスト方面でのテストデータ生成などにこの表現を選択する。

class Account {
  final String name;
  final int age;

  Account(this.name, this.age);
}

Account createTestAccount({
  String name = 'name',
  int age = 30,
}) {
  return Account(name, age);
}

12. Static Constructors

Staticメソッドで表現するパターン。

Factory Constructorが存在するので、この表現を選択することはあまりない。

関係性の薄いクラスのインスタンス生成を担わせている場合は責務表現を見直した方が良い。

class Account {
  final String name;
  final int age;

  Account(this.name, this.age);

  static Dog createDog() {
    return Dog();
  }
}

class Dog {}

実装パターンの選択や表現の工夫

インスタンス生成に関する実装パターンの列挙を踏まえて、実践的なプラクティスを紹介する。

Factory Constructorsではfromやofといった表現を意識する

イディオム(文化、慣習)として、Factory Constructorsは fromXXXofXXX といった表現を選択する。

これにより、そのFactory Constructorsが何のデータソースからの生成を期待しているのか分かりやすくする。鉄板なのが factory Account.fromJson(Map<String, dynamic> json) {}

インスタンス生成にメッセージを含めたい場合はNamed Constructorsを使う

Dartに触り始めた人は特に、Named Constructorsの使い所に迷う人が多い。

Named Constructorsの最大の利点はインスタンスのConstructorに意味 (メッセージ) を持たせられること。そのConstructorがどういったシーンで呼び出されてほしいのか、どういった状態で生成されるのかを明らかにする。

Account.init() という表現 (分かりやすくしたが、この表現自体の良し悪し自体は文脈による) はインスタンスの初期に使うものだということを汲み取れる。

Notification.cancelOrder(String customerName) : title = '注文をキャンセルしました', message = '$customerName様の注文をキャンセルしました'

上記の表現は、キャンセル通知データ(基本的に通知情報は定数であるという特徴がある) の生成に使うものだということを汲み取れる。

この様に、Constructorに付加的なメッセージやパターンを加える場合に活用できる。

データクラスの様な使い方をする場合はConst/Constant Constructorsを使う

immutableなデータクラスを意識し活用することは堅牢なコードを書く上では重要になる。

インスタンスのフィールドおよびクラス自体がimmutableであることを明示するために const Account() といった、const宣言は積極的に活用する。

const でのインスタンス生成ではimmutableにしない場合でもConst Constructorで宣言しておくことにメリットはある。

  1. initializerのボディ定義をコンパイルエラーにすることができる

  2. final 宣言以外の変数定義がなされるとコンパイルエラーにすることができる

上記2つの制約が加わり、データクラス以外の誤った使われ方ができない様にある & データクラスであることを明確に宣言するという使い方ができる様になる。

抽象型を意識させ、考えを限定したい場合はFactory & Abstract Constructorsを使う

FactoryはAbstract Classでも宣言できる。

Abstract Classを参照してインスタンスを生成し、振る舞う表現にすると意図的に意識を抽象のインターフェースに向けることになる。classを型表現としてパターン化し、組み換え可能性や、変更可能性を示す場合には、Factory & Abstract Constructorsを使うのが良い。

DAOクラスでインターフェースを共有し、実行とテストを分離したい場合などで活用される。

フィールド情報とは離れた値からインスタンスを生成する場合は、Factory Constructorsを使う

Factory Constructorsはフィールド情報 (型や命名や関係性など) から離れた値からインスタンスを生成する場合に用いることが多い。

傾向として ”多い” だけなので、厳密にそうするべきと提唱されているものではない。

鉄板なのがJSONデータから生成する場合 ( fromJson()) や Protocol Bufferで生成されるDTOへの変換など。

離れ値からインスタンス生成するシーンがあったら、Factory Constructorを使うのが無難である。

インスタンス生成過程に処理を仕込みたい場合は、Factory Constructorsを使う

Factory Constructorsは最終的に宣言している型に関連するクラスのインスタンスを返すことを保証しているが、返す過程に処理を仕込むことができる。

例えば引数で渡った値のバリデーションやネストしたデータ構造での値の参照など。

生成過程に何か処理を加えたい場合にはFactory Constructorsを選択することを検討する。

class Account {
	final String name;

  Account(this.name);


  factory Account.fromJson(Map<String, dynamic> json) {
    if (json['account_name'] == null) {
      throw ArgumentError.notNull();
    }
		return Account(json['account_name']);
  }
}

限定的な範囲での扱いを期待する場合はPrivate Constructorsを使う

Private Constructorsは宣言範囲を限定する (同一ファイル内) に縛りたい場合に使える。

インスタンスは生成したいが、生成手法をFactoryに限定させたい (ex: 必ずJSONデータからの生成のみにしたいなど) というシーンがあったら、Factory & Private Constructorsの組み合わせが最も適している。

Constructorのフィールド数が4以上ある場合はNamed Parameterを積極的に使う

Constructorに関連する ”引数” の機能になるが、Constructorに渡す引数の数が4以上になる場合は、積極的にNamed Parameterを使う。

Effective DartなどにはどういったシーンでNamed Parameterを使うべきかといった推奨は存在しない。

引数の数が4以上になると認知負荷が上がって、呼び出し時の引数の値セットが苦痛になるため、Named Parameterの機能により、どの値をセットするのかをIDEによる保管が効かせられる状態にする。

Constructorのフィールドの型が重複する場合はNamed Parameterを積極的に使う

こちらもConstructorに関する ”引数” の機能について、Constructorに渡す引数の型が重複する場合は、積極的にNamed Parameterを使う。

引数の型が同じであると誤ったデータ渡しが発生しうる。例えば商品の金額と個数はプリミティブ型であるintを使っている場合、誤った渡され方が可能になる。

これはレビューで防ぐことやテストで防ぐことができるから大丈夫という見方もできるが、そういった予防線があったとしても、誤りが起きうる可能性は極力排除した方が良いので、重複した型を渡す場合は明示的に何の値を渡す/受け取るのかがわかる様に、Named Parameterを使う。

class Product {
	int price;
	int quantity;

	Product({
		required this.price,
		required this.quantity
	});
}

// 100と8を何のフィールドに渡そうとしているのかが明示的になり、誤りにくくなる
final product = Product(price: 100, quantity: 8);

(余談) そもそもプリミティブ型を使わずに値インスタンスを使う方が良いという見方もできる

シングルトンパターンを表現する場合はFactory Constructorsを使う

Factory Constructorsを活用することで、シングルトンオブジェクトを簡単に作れる。

これは公式ドキュメントでもFactory Constructorsの使い所として例示されているもの。

宣言クラスに関係したインスタンスを返す場合はStaticを選択せず、Factoryを使う

Factory Constructorsと類似でStatic Constructorsが存在する。どちらの表現を選択するのが良いのかというのが論点として上がる様だが、両者の違いは返るインスタンスの型に制約があるかどうか。型に関係性があるならFactory Constructorsを使う、ないがそこに宣言したい理由があればStatic Constructorsを選択する。

経験上、Static Constructorsを選択するシーンはあまりない。関係距離が遠いインスタンスの生成は必要なシーンがあるかもしれないが、それをStatic Constructorsを介してまでやる場合はそもそもの概念整理を見誤ってる可能性が高いので、モデリングを再考した方が良いと考える。

所感

書き上げてきて ”Dartは表現がいろいろあるんだな〜” というのが率直な感想です。

Constructorはシーンによって使う表現が変わりますが、Effective Dartではあまり例がなく、どういう時に何を選択すれば良いんだっけ…?という判断軸がいまいち掴みづらいのが実情かなと。

書き終えた後に自身が書いた過去のコードを見直すと、ここで取り上げたプラクティスに反している書き方をしている箇所が見受けられ、当時の理解が及んでいない (雰囲気で書いている) 面が見えました。恥ずかしくなると共に、改めて自身の理解が深まっていることも実感できたので、前向きに捉えます。

Dart自体まだまだ進化していきそうな雰囲気を感じます。もうすぐ2022年も終わりますが、2023年の中頃にはDart 3のリリースが控えており、完全にNull Safeな世界に進みそうで楽しみです。

ここまで読んでいただきありがとうございました。この記事が何らかお役に立てれば幸いです。

それでは、よいDartライフを! ;)

参考

SHARES