のんびり精進

調べた情報などをおすそ分けできれば。

Freezedの代替方法を考える(Immutability編)

前編では、同一性を判定できる機能を Freezed を使わずにクラスに持たせる方法を確認しました。

kabochapo.hateblo.jp

今回は immutability です。
前回は equatable を使うだけでできるという仮定で検証作業ばかりだったのですが、今回はいくつかの方法を試して比較しつつデメリットも考えて結論を出します。

何もしない場合

Equatable クラスには @immutableアノテーションが付いているので、それを継承したクラスでもすべてのプロパティを final にしなければならず、プロパティに値を代入し直すことができません。
したがって、次のようにプロパティの値が丸ごと差し替えられるのを防ぐことができます。

final list1 = MyList([1, 2, 3]);
list1.list = [1, 20, 3];  // エラーになる

しかし、コレクションのプロパティが final であっても中身の変更はできてしまいます。

final list1 = MyList([1, 2, 3]);
list1.list[1] = 20;
print(list1.list);  // [1, 20, 3]

また、別の変数に入れてからいじると元の変数にまで影響します。

final list1 = MyList([1, 2, 3]);
final copiedList = list1;
copiedList.list[1] = 20;
print(list1.list);  // [1, 20, 3]

次のように private にしてゲッターで List を返すようにしても効果はありません。

class MyList extends Equatable {
  const MyList(List<int> list) : _list = list;

  final List<int> _list;

  List<int> get list => _list;

  @override
  List<Object> get props => [_list];
}

...

final list1 = MyList([1, 2, 3]);
list1.list[1] = 20;
print(list1.list);  // [1, 20, 3]

Freezed に関する勘違い

調査にあたり Freezed における immutability の実現方法も調べていたのですが、生成されたコードを見てもそれらしき記述が見当たりませんでした。
それでも自分の見落としだと思って調べ続けた結果、判明しました。

Freezed はあまり immutable じゃない!

Freezed を使えば先ほどのようなコレクションの問題は起きないと思い込んでいました。
「あまり」immutable じゃないと書きましたが、コレクションに限れば「全然」です。

@freezed
class MyFreezedList with _$MyFreezedList {
  const factory MyFreezedList(List<int> list) = _MyFreezedList;
}

これを生成元として Freezed でクラスを生成して試します。

コレクションを変更できてしまう!

final l = [1, 2, 3];
final freezedList = MyFreezedList(l);

l[1] = 20;
print(freezedList.list);  // [1, 20, 3]

元の List の値を変えると、オブジェクトで持っている List まで変わってしまいました。

freezedList.list[2] = 30;
print(l);  // [1, 20, 30]

逆にオブジェクト側の List をいじっても元の List に影響しました。

Flutter で StateNotifier を使う場合も同じです。
下記のように StateNotifier で管理する state が List を持っているとき、select() でその List を取り出してから中身を変更することができ、その変更は state 内の List にも元の List にも影響します。

@freezed
class MyListState with _$MyListState {
  const factory MyListState(List<int> list) = _MyListState;
}

class MyListNotifier extends StateNotifier<MyListState> {
  MyListNotifier({required List<int> list}) : super(MyListState(list));
}
final originalList = [1, 2, 3];

...

StateNotifierProvider<MyListNotifier, MyListState>(
  create: (context) => MyListNotifier(list: originalList),
  child: MaterialApp(home: ...),
)

...

final list = context.select((MyListState state) => state.list);
list[1] = 20;

// 関連する全てのListに影響
print(list);                              // [1, 20, 3]   
print(context.read<MyListState>().list);  // [1, 20, 3]
print(originalList);                      // [1, 20, 3]

ディープコピーもしてくれない!

final freezedList1 = MyFreezedList([1, 2, 3]);
final freezedList2 = freezedList1.copyWith();

freezedList2.list[2] = 20;
print(freezedList1.list);  // [1, 20, 3]

コピーして作ったオブジェクトの List をいじると、コピー元のオブジェクト内の List に影響しました。
これでは全く対策されていないのと同じですね。
Freezed の immutability の機能を推すツイートや記事が散見されていたのですが、何だったのでしょう。
build_collection + built_value の上位種みたいなものじゃなかったのでしょうか…。

なお、作者はコレクションを immutable にするつもりがないようです(issue)。1
ディープコピーについては 提案 が出ています。

Freezed と同等の immutability を実現するには

Freezed の immutability の機能が今見た程度であれば、それと同等にするのは簡単です。

  • クラスに @immutableアノテーションを付けてほぼ強制的に各プロパティを final にさせる
  • @immutable を自分で付けなくても Equatable に付いているので、それを継承すればいい
  • プロパティの値を変更できない代わりとして copyWith() が必要だが、自作は難しくない

Freezed と同等で良い方はここまでで OK です。

どこまで堅牢にするか

コレクションを操作しないルールにするだけで解決するチーム環境ではそれでいいと思います。

しかし、世の中には想定を超えることをしてしまう人や、レビュー等で何度伝えてもできない人もいます。
そういう環境に入ったことがない方には理解しがたいかもしれませんが…。

コード面でどこまで対策をしておくかは手間等と効果のバランス次第ですが、間違った方法を使おうとしてもできないようにするのは、これまでの経験から重要に感じています。
そのように不安を少しでも減らしておきたい方はこの先を参考にしてください。

コレクションも immutable にする

immutability の実現方法は一つではありません。

  • オブジェクト内のコレクション自体が unmodifiable
  • mutable だけれど別の変数に入れていじってもオブジェクト内のコレクションに影響しない

一長一短がありますので、それぞれの方法を見ながらデメリットも考えます。

① コレクションを unmodifiable 化

これらを使うとコレクションの一階層目を unmodifiable(変更不可)にするのが簡単にできます。
まず二種類の違いを確認しましょう。

List.unmodifiable と UnmodifiableListView

下記のようにすると、どちらの方法でも変更不可になります。
残念ながらエラーは静的解析で検出されずにランタイムに起こりますので、その点はちょっと使いにくいです。

final list = [1, 2, 3];
final unmodifiableList1 = List.unmodifiable(list);
final unmodifiableList2 = UnmodifiableListView(list);

unmodifiableList1[1] = 20;  // エラー(Cannot modify an unmodifiable list)
unmodifiableList2[2] = 30;  // エラー(Cannot modify an unmodifiable list)

こう見ると List.unmodifiableUnmodifiableListView の違いがわからないのですが、 次のように元 List を変更してみるとわかります。

final list = [1, 2, 3];
final unmodifiableList1 = List.unmodifiable(list);
final unmodifiableList2 = UnmodifiableListView(list);

list[1] = 20;
print(unmodifiableList1);  // [1, 2, 3]
print(unmodifiableList2);  // [1, 20, 3]

UnmodifiableListView のほうは元の List の変更が影響する結果になりました。
その理由はドキュメントには(Dart 2.12.4 時点では)書かれていませんが、 こちらの記事 で解説されています。

may perform a bit better, because an UnmodifiableListView does not create a copy of the original list. Instead, it wraps the original in a view that prevents modification.

https://dart.academy/immutable-data-patterns-in-dart-and-flutter/

UnmodifiableListView のほうは元 List をコピーしない(のでパフォーマンスが少し良い)ということです。
コピーせずに元の List をラップして使っているので影響してしまうわけですね。

UnmodifiableMapView と UnmodifiableSetView もコピーしないのか

final map = {'a': 1, 'b': 2, 'c': 3};
final unmodifiableMap1 = Map.unmodifiable(map);
final unmodifiableMap2 = UnmodifiableMapView(map);

// 元の Map の中身を変更
map['b'] = 20;

print(unmodifiableMap1);  // {a: 1, b: 2, c: 3}
print(unmodifiableMap2);  // {a: 1, b: 20, c: 3}
final set = {1, 2, 3};
final unmodifiableSet1 = Set.unmodifiable(set);
final unmodifiableSet2 = UnmodifiableSetView(set);

// 元の Set に要素を追加
set.add(4);

print(unmodifiableSet1);  // [1, 2, 3]
print(unmodifiableSet2);  // [1, 2, 3, 4]

UnmodifiableListView と同様にやはり UnmodifiableMapViewUnmodifiableSetView もコピーしない仕組みになっているようです。

ちなみに、Set.unmodifiableUnmodifiableSetViewDart 2.12 で追加されたばかりの新しい機能だそうです。

どちらを使うか

UnmodifiableXxxView で元のコレクションに加えた変更が影響することは危険な場合がありますね。
それを考慮すると、選択肢は

  • 影響するのは怖いので List.unmodifiable を使う(パフォーマンスが少し劣るのは許容する)
  • 元のコレクションをいじらないよう自分で注意しながら UnmodifiableListView を使う(パフォーマンスを優先)

の二択になります。
使用箇所のパフォーマンスの重要性などに応じて決めましょう。

変更できなくなるのは一階層のみ

Xxx.unmodifiableUnmodifiableXxxView はどちらも深い階層までまとめて変更不可にしてくれません。

final list = [[1, 2], [3, 4]];
final unmodifiableList1 = List<List<int>>.unmodifiable(list);
final unmodifiableList2 = UnmodifiableListView(list);

unmodifiableList1[0][1] = 20;
unmodifiableList2[0][3] = 40;

print(unmodifiableList1);  // [[1, 20], [3, 40]]
print(unmodifiableList2);  // [[1, 20], [3, 40]]

内側の List は変更が反映されました。
また、次のように元の List で深い層をいじった場合も影響します。

final list = [[1, 2], [3, 4]];
final unmodifiableList1 = List<List<int>>.unmodifiable(list);
final unmodifiableList2 = UnmodifiableListView(list);

list[0][1] = 20;
print(unmodifiableList1);  // [[1, 20], [3, 4]]
print(unmodifiableList2);  // [[1, 20], [3, 4]]

実装(一階層のみ unmodifiable)

深い階層まで変更不可にする前に一階層用の実装をしてみましょう。

unmodifiable にするとき、(内部処理は未確認ですが)何らかの変換が行われていると仮定します。
オブジェクトからコレクションを取り出すときに毎回その変換が起こるのは無駄がありますし、 大きなコレクションでは変換が終わるまで待たされることが問題になるかもしれません。2

そのコストを考慮し、初期化リストでの一度の変換で済むようにします。

class MyUnmodifiableList extends Equatable {
  MyUnmodifiableList(List<int> list) : list = List.unmodifiable(list);

  final List<int> list;

  @override
  List<Object> get props => [list];
}

...

final unmodifiableList = MyUnmodifiableList([1, 2, 3]);
unmodifiableList.list[1] = 20;  // エラー(Cannot modify an unmodifiable list)

Dart 2.12 で導入された late キーワードを活用すればコンストラクタで行うこともできます。

MyUnmodifiableList(List<int> list) {
  this.list = List.unmodifiable(list);
}

late final List<int> list;

オブジェクトからコレクションを取り出すたびに変換が起こるのを気にしなくていい場合には、コレクションのプロパティを private にしてゲッターで公開し、そのゲッターで返すときに変換すればいいと思います。

実装(深い階層まで unmodifiable)

深い階層まで対応させるのは、コレクションの中身すべてに unmodifiable を適用していくだけです。
例えば二次元の List なら下記のようになります。

class NestedList extends Equatable {
  NestedList(List<List<int>> list)
      : list = List.unmodifiable(list.map<List<int>>(
          (v) => List<int>.unmodifiable(v),
        ));

  final List<List<int>> list;

  @override
  List<Object> get props => [list];
}

...

final nestedList = NestedList([[1, 2], [3, 4]]);
nestedList.list[0] = [20];   // エラー(Cannot modify an unmodifiable list)
nestedList.list[0][1] = 20;  // エラー(Cannot modify an unmodifiable list)

.map() でイテレートしながら内側まで unmodifiable にしています。

Map の中に Map があってその中に List があるような複雑な構造でも意外と簡単で、これもやはり .map() を使って隈なく unmodifiable を適用していくだけです。

class ComplexCollection extends Equatable {
  ComplexCollection(Map<String, Map<String, List<int>>> map) {
    this.map = Map.unmodifiable(map).map((k, v) {
      return MapEntry(k, Map.unmodifiable(v).map((k, v) {
        return MapEntry(k, List.unmodifiable(v));
      }));
    });
  }

  late final Map<String, Map<String, List<int>>> map;

  @override
  List<Object> get props => [map];
}

実際には、unmodifiable を使うときに Map<String, Map<String, List<int>>>.unmodifiable(...) のようにジェネリック型を指定しないといけなくて長い記述になるので見づらくなります。
この読みにくさの問題は実用の判断に影響しそうです。

UnmodifiableXxxView のほうはジェネリック型の指定が不要なので、多少すっきりした記述になります。

unmodifiable のデメリット

ここまで unmodifiable にする方法を見ました。
そうやって変更できなくするのは安全に思えるのですが、使うときに不便なこともあります。

例えば Flutter で StateNotifier を使うとき、それを継承したクラスで管理する state は immutable である前提なので、 state が持つ値を変えたいときにはプロパティの値を直接変更できません。
代わりに Freezed で state のクラスを作ってその copyWith() で新たな state オブジェクトを作るスタイルが一般的です。

もし state がコレクションのプロパティを持っていてその一部を変更した状態にしたければ、 次のようにコレクションを取り出して一部だけを変えたものを copyWith() に渡せばできます。

final list = state.list;
list[0] = 'newValue';
state = state.copyWith(list: list);

それに対し、コレクションを unmodifiable にする方法では部分的な変更が不可能なので再利用できません。
再利用したいケースでは実質的に次の方法に絞られます。

② ディープコピーによって影響を及ぼさなくする方法

unmodifiable にしないまま immutability を実現する方法です。
immutable と unmodifiable という言葉の区別はちょっと難しいですね。
先にそこをはっきりさせておきましょう。

immutable と unmodifiable

前者は 「変わらない」こと、後者は「変更できない」ことを表していると思います。

List.unmodifiableList.immutable という名前でないのは、 おそらくコレクションが「変更できない」ことを明確にするためです。
unmodifiable であれば immutable になりますので、immutability のための方法の一つとして unmodifiable にする方法がある、という関係性です。

  • オブジェクトが持つ値を全く変更できない(unmodifiable)
  • オブジェクトから取り出した値を変更できるが、オブジェクトが保持する値に影響しない

オブジェクトの immutability を「変わらない/不変」という言葉と照らして考えると、次のどちらも immutable だと言えると思います。

では、後者のほうをこれから見ていきます。

ディープコピー

先ほどごちゃごちゃと unmodifiable の方法を書いておきながらアレなのですが、この方法でいいと思います。
楽かつ使いやすいです。

Map<String, Map<String, List<int>>> という深めの階層構造であっても、次のように割と簡潔に書けます。

final copiedMap = originalMap.map((k, v) {
  return MapEntry(k, v.map((k, v) {
    return MapEntry(k, v.map((v) {
      return v;
    }).toList());
  }));
});

または、少し楽をして一番内側だけ List.of()Map.of() で済ませます。

final copiedMap = originalMap.map((k, v) {
  return MapEntry(k, v.map((k, v) {
    return MapEntry(k, List.of(v));
  }));
});

.map() を使ってコレクションの一番内側までイテレートしながら新たな MapEntry を作っていくのは先ほどと同じですが、 Map.unmodifiable 等による変換がないのでシンプルです。
記述しやすくて読みやすいだけでなく、変換がなくてイテレーションのコストだけで済むのも良いです。

これをクラスに組み込む方法については、

  • 初期化リストかコンストラクタでディープコピーを用意する場合
    • 元のコレクションが変更されても影響を受けない
    • オブジェクトから取り出したコレクションが変更されると影響を受ける
  • ゲッターで返すときにディープコピーする場合
    • 元のコレクションが変更されると影響を受ける
    • ゲッターで返したコレクションが変更されても影響を受けない

となります。
外部での操作による影響を完全に受けなくするには、両方のタイミングでコピーしないといけません。

下記のコードでは一応両方でコピーするようにしました。
どこまで厳密にやるかはご自身で判断してください。

class ComplexCollection2 extends Equatable {
  ComplexCollection2(Map<String, Map<String, List<int>>> map) {
    _map = _deepCopy(map);
  }

  late final Map<String, Map<String, List<int>>> _map;

  Map<String, Map<String, List<int>>> get map => _deepCopy(_map);

  @override
  List<Object> get props => [_map];

  Map<String, Map<String, List<int>>> _deepCopy(
    Map<String, Map<String, List<int>>> originalMap,
  ) {
    return originalMap.map((k, v) {
      return MapEntry(k, v.map((k, v) {
        return MapEntry(k, List.of(v));
      }));
    });
  }
}

ディープコピーのメソッドは各クラスに書いてもいいのですが、複数のクラスで同じ型が使われている場合には、 流用できるように static なメソッドにでもしておくといいかもしれません。

また、長い型名が何度も書かれているのをどうにかしたい感じがありますが、もうすぐ導入されそうな 型エイリアス (関数以外の型にも使えるエイリアス)で短い名前への置き換えが可能になるはずです。

ディープコピーのもう一つの方法

ディープコピーするには、JSON に変換してから戻す方法もあります。
この方法も考えたのですが、戻すと型が dynamic になってしまい、それを一気に Map<String, Map<String, List<int>>> 等にキャストすることはできないので、結局イテレートしながら一階層ずつキャストしないといけません。
また、先ほどの方法よりコストが少し大きそうなので、不採用としました。

参考に、JSON 形式を Map<String, Map<String, List<int>>> 型に変換するメソッドを書いておきます。

Map<String, Map<String, List<int>>> fromJson(Map<String, dynamic> json) {
  return json.map((k, dynamic v) {
    return MapEntry(k, (v as Map<String, dynamic>).map((k, dynamic v) {
      return MapEntry(k, (v as List).map((dynamic v) {
        return v as int;
      }).toList());
    }));
  });
}

読みやすくするために改行を少なめにしていますが、フォーマットすると実際にはもっと改行が入って見た目が変わると思います。

もっと簡潔な方法があればぜひ教えてください。3

copyWith()

オブジェクトを immutable にするからには copyWith() メソッドもほぼ必須です。
難しいことは何もないのでさっと済ませます。

class Foo extends Equatable {
  const Foo({
    required this.number,
    required this.text,
    this.isValid = false,
  });

  final int number;
  final String text;
  final bool isValid;

  @override
  List<Object> get props => [number, text, isValid];

  Foo copyWith({
    int? number,
    String? text,
    bool? isValid,
  }) {
    return Foo(
      number: number ?? this.number,
      text: text ?? this.text,
      isValid: isValid ?? this.isValid,
    );
  }
}

自身と同じ型のオブジェクトを作り直すので、戻り値はクラスと同じ型です。
また、値を変えたいプロパティと同名の引数以外は省略可能にするために、仮引数の型はすべて nullable にします。
省略された引数は null であり、その場合は既存の値がそのまま設定されます。

null にする機能

Freezed で作った場合には null を設定するとプロパティの値を null にすることができますが、その機能は上記コードでは実現できません。
しかし、引数が指定されていないのか null が渡されたのかを区別できないのが Dart の仕様であり、Freezed を使った場合だけ異なるのは必ずしも良いとは限りません。

個人的には resetNumber()nullifyNumber() のような説明的な名前のメソッドを作ってリセットできるようにするほうが明示できて良いと思います。

copyWith() を改良

コレクションの操作を失敗するタイミングの一つが copyWith() を使うときです。

class MyList {
  const MyList({this.list = const []});

  final List<int> list;

  MyList copyWith({List<int>? list}) {
    return MyList(list: list ?? this.list);
  }
}

...

final originalList = [1, 2, 3];
final myList = MyList(list: originalList);

final newList = myList.list;
final myList2 = myList.copyWith(list: newList..add(4));

print(originalList);  // [1, 2, 3, 4]
print(myList2.list);  // [1, 2, 3, 4]

このように元のコレクションに影響させてしまうミスです。

これを防ぐために、ディープコピー済みのコレクションを copyWith() 時に受け取って使えるようにするのはどうでしょうか。
区別するためにメソッド名を update() とします。

typedef ListUpdater<T> = List<T> Function(List<T>);

...

MyList update({ListUpdater<int>? list}) {
  return MyList(
    list: list == null ? this.list : list(List.of(this.list)),
  );
}

...

final myList2 = myList.update(list: (list) => list..add(4));

print(originalList);  // [1, 2, 3]
print(myList2.list);  // [1, 2, 3, 4]

update() の引数を単なる List ではなく builder 関数にしました。 ここでは型を ListUpdater としましたが、わかりにくければ ListBuilder とか。

その builder に List が渡されるのですが、その List はディープコピー済みのものなので、上記のように直接 add() しても MyList 内の List や元の List に影響することがありません。

このように改良しても final newList = mylist.list; のように直接取り出すのを禁止するわけではないので確実な対策にはなりませんが、少しリスクを減らせます。

まとめ

二編全体のまとめです。

同一性を判定できるようにするには

  • Equatable を継承し、props で全プロパティの List を返すようにするだけで OK

Freezed と同等の immutablity を実現するには

  • copyWith() を作るだけでいい
    • Freezed を使ってもコレクションは immutable にならない
    • Equatable@immutable なので各プロパティは必然的に final になる

コレクションも immutable にするには

  • 二つの方法がある
    • unmodifiable にする方法
    • ディープコピーで影響を受けなくする方法
  • unmodifiable は少しコストがかかる可能性があり、使いにくくもなるため、ディープコピーがおすすめ

コレクション関連でやや手間がかかりますが、コレクションを持つクラスばかりではないと思いますので、一部のクラスだけちょっと面倒を我慢して作るだけならいいんじゃないでしょうか。

おまけ

Dart Data Class という Android Studio / IntelliJ IDEA 用のプラグインをうっきーさん @ukkey0518 が教えてくださいました。(ありがとうございます! ⇒ ツイート

f:id:kabochapo:20210423174023p:plain

これもコード生成なのですが、同じファイルの対象クラス内に追記されますし、 Freezed で生成される読みにくいコードと違って手入力の代わりに自動で入力されたような普通のコードなので、 コンフリクトの解消がしやすそうです。

また、一瞬で生成されて体感が断然良いです。
高速なのは、一クラスずつ&機能単位の生成だからだと思います。

ただし

  • 古い Dart を引きずっている(new が付く、null safe じゃない、など)
  • Map と JSON との間の変換はキャストが甘すぎて使い物にならない
  • プロジェクト全体分の生成をしたいときに大変そう

といった問題や制限があります。
ゲッターを追加、toString() を追加、のような個々の生成機能によってクラス作成を補助する程度には使えると思います。

「Data Class Section」を選ぶと複数の機能の追加をまとめて行えました。

f:id:kabochapo:20210423174055g:plain:w300

VS Code でも検索すると類似のプラグインが複数出てきます。
また、JSON との変換などの特定機能に絞ったプラグインIntelliJVS Code の両方にあります。
それらは試していませんが、もしかしたら上記プラグインより実用的かもしれません。

build_runner 系の生成しか頭になかったですが、build_runner を避けて別のツールを使うのもありですね。
意欲と技術力のある方は自分で Dart 等で変換ツールを作っても良さそうです。


  1. この issue について「待ち」さん @freqmodu874 に教えていただきました。ありがとうございました! https://twitter.com/freqmodu874/status/1382891789204426752?s=20

  2. UnmodifiableXxxView のほうは元のコレクションのラップなので、変換のような処理ではなくコストが低い可能性もあります。

  3. バックエンドとのやり取りに普段 gRPC を使っていて JSON の変換が不要なので不慣れです。gRPC はおすすめですよ(参考記事)。