のんびり精進

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

Freezedの代替方法を考える(同一性判定編)

以前に考えていたときに試したコードなどが発掘されたので記事にすることにしました。

まず Freezed について

pub.dev

用途

Freezed は Dart のクラスの自作しにくい機能を自動生成によって楽に実現できるパッケージで、様々な機能があります。

  • 同一性(Equality)の判定が可能(Equatability)
  • 不変性(Immutability)
  • copyWith()
  • toString()
  • Json とクラスオブジェクトの間の変換
  • Union/Sealed クラス

以前は Dart 2.12 の一部機能(non-null, late)の先取りでもありましたが、今はこれくらいでしょうか。

こう見ると、どれも Freezed を使わずにできないこともないなと思います。
それでも Freezed を使うのは、自作に手間がかかるからです。

でも満足できていますか?
Freezed を使うことによる辛さもあるのではないでしょうか。

辛いところ

  • 少し癖のある Freezed 用の書き方を把握しないといけない
  • 元ファイルと同じ場所に生成されてフォルダ内/ファイルツリーがごちゃつく
  • 生成するのに時間がかかる(build.yaml で対象ファイルを絞ったとしても)
  • 生成後に Dart Analyze をやり直さないと生成前の解析状態のままになることが多い
  • ブランチを切り替えるたびに生成&解析を行わないといけない(レビュー時など)
    • 生成されたファイルを git の管理対象にすれば避けられるがコンフリクトが増える
  • ちょっと変えて試したいときもいちいち生成&再解析が必要
  • 生成用パッケージ(freezed, build_runner 等)の依存関係で悩むことがある
  • もし生成用パッケージ側のエラーが出ればお手上げ

一部は Freezed ではなく build_runner が原因かもしれませんが、辛みがあることに変わりはありませんね。
楽をするために別の苦労が増えるのを好まない私は個人的には使っていません。

でも、業務になると別です。
自分の好みでなくても、チーム全員が簡単にできる方法が他にない限りは Freezed が採用されがちな現状です。

本記事について

書いた理由は、Freezed を使わない選択肢を広めることで私自身が苦痛から解放されたいからです。
好まないものを使うストレスは結構大きいものです。

このシリーズ(といってもおそらく二編)では、冒頭に書いた Freezed の用途のうち同一性と immutability(copyWith() のことも含む)に絞ります。
残りはたぶん気が向かない限り書きません。

まず今回は同一性を判定できるようにする方法です。
先に言ってしまうと、第一編は package:equatable の機能を検証していくだけなので割としょうもないです。

同一性(Equality)

同一性(同値性)を判定できる機能をクラスに持たせるのは ==hashCode をオーバーライドして実装するだけでできるので、自作するのも難しくありません。
でもコレクションの同一性はちょっと難しいです。

final list1 = [1, 2, 3];
final list2 = [1, 2, 3];

print(list1 == list2);  // false

同じ値が同じ順序で入っている List であっても、等価演算子==)で比較すると false になります。
比較処理を自力で実装するなら for などを使って要素を一つずつ比較することになります。

その手間を省ける方法として package:collection に含まれる DeepCollectionEquality が使えます。1
さらに、それを利用している package:equatable があります。
この記事では equatable のほうを使います。

equatable パッケージ

pub.dev

状態管理のメジャーなパッケージの一つである flutter_bloc の作者が作ったものです。
安心感がありますね。

まずはコレクションではなく String のプロパティを持つクラスを例に見てみましょう。

equatable を使わない場合

class Person {
  const Person(this.name);

  final String name;
}

...

final taro1 = Person('Taro');
final taro2 = Person('Taro');

print(taro1 == taro2);  // false

name の値が同じオブジェクトを二つ作って比較しても false になります。

const を付けるとコンパイル時に用意され、使い回されて結果に影響してしまうため、 この記事の検証ではあえて付けないようにしています。

equatable を使う場合

同じ値が同一と判定されるようにするために equatable を使うと次のようになります。

import 'package:equatable/equatable.dart';

class Person extends Equatable {
  const Person(this.name);

  final String name;

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

...

final taro1 = Person('Taro');
final taro2 = Person('Taro');

print(taro1 == taro2);  // true

このように比較対象となるプロパティを List に入れて props というゲッターで返すだけで、 そのプロパティ同士の比較をして同一性が判定されるようになります。
とても簡単ですね。

equatable とコレクション

この equatable では内部で package:collection が使用されていて、コレクションの比較までできるようになっています。

先ほどと同じ [1, 2, 3] という List を用い、その List を持つクラスのオブジェクト同士が同一と判定されるかどうかを見てみましょう。

class MyList extends Equatable {
  const MyList(this.list);

  final List<int> list;

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

...

final list1 = MyList([1, 2, 3]);
final list2 = MyList([1, 2, 3]);

print(list1 == list2);  // true

ちゃんと同一だと判定されました!
これができるなら、Freezed の機能のうち同一性の部分は代替方法があると言えるのではないでしょうか。

equatable の機能をもっと検証

でもコレクションがもっとネストしていたり別のオブジェクトを含んでいたりしても大丈夫なのでしょうか。
そう思いながら使うのは安心できませんので様々なケースを確認していきます。

Map

class MyMap extends Equatable {
  const MyMap(this.map);

  final Map<String, int> map;

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

...

final map1 = MyMap({'a': 1, 'b': 2, 'c': 3});
final map2 = MyMap({'a': 1, 'b': 2, 'c': 3});
final map3 = MyMap({'b': 2, 'c': 3, 'a': 1});

print(map1 == map2);  // true
print(map1 == map3);  // true

同一と判定されました。
フィールドの順序は同一性に影響しないようです。2
これは Freezed でも同じです(コードは省略しますが確認済みです)。

Map の順序に関する補足

Map は実は抽象クラスであり、どのような種類になるのかは実装によるのですが、デフォルトコンストラクタ(factory)や リテラルLinkedHashMap を生成するようになっています。
HashMap には順序がなく、LinkedHashMap にはあります( 関連記事 )。

それを踏まえると、MyMapmap というプロパティは LinkedHashMap なので順番が保持されるわけですが、 だからといって「二つのオブジェクトが持つ Map の順序が異なれば同一と判定されない」と思って使っていると不具合が起こります。
ご注意ください。

List

先ほど Map のフィールドの順序を見たので、List の要素の順序も見ておきましょう。
List は index が大事なので順序が無視されることはないと思いますが、念のためです。

少し前に書いた MyList をそのまま使って確認します。

final list1 = MyList([1, 2, 3]);
final list2 = MyList([3, 2, 1]);

print(list1 == list2);  // false

ちゃんと false になりました。

ネストしたコレクション

Map の中に Map、さらにその中に List がある構造で試してみます。

class NestedMap extends Equatable {
  const NestedMap(this.map);

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

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

...

final map1 = NestedMap({
  'a': {'b': [1, 2], 'c': [3, 4]},
  'd': {'e': [5, 6], 'f': [7, 8]},
});
final map2 = NestedMap({
  'd': {'f': [7, 8], 'e': [5, 6]},
  'a': {'c': [3, 4], 'b': [1, 2]},
});

print(map1 == map2);

このように深くて複雑な構造でも同一性の判定に支障がないことを確認できました。

オブジェクトを含むオブジェクト

中のオブジェクトとそれを持つオブジェクトの両方で同一性判定の機能を持たせておけばいいはずです。
つまり、関連するクラスすべてで Equatable を使うということです。

では試してみます。

class Person extends Equatable {
  const Person({required this.name, required this.age});

  final String name;
  final int age;

  @override
  List<Object> get props => [name, age];
}

class Members extends Equatable {
  const Members(this.list);

  final List<Person> list;

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

...

final sakura = Person(name: 'Sakura', age: 22);
final taro1 = Person(name: 'Taro', age: 20);
final taro2 = Person(name: 'Taro', age: 25);

final members1 = Members([sakura, taro1]);
final members2 = Members([sakura, taro1]);
final members3 = Members([sakura, taro2]);

print(members1 == members2);  // true
print(members1 == members3);  // false

Person クラスでは nameage の両方が一致している場合だけ同一と判定され、 Members クラスでは Person のオブジェクトの List を比較して判定されるようにしています。
それによって、sakura と taro1 の List を持つ members1 と members2 は同一と判定され、sakura と taro2 (taro1 と年齢違い) の List を持つ members3 は同一でないという期待どおりの結果となりました。

もし Person の同一性判定を name だけで行うようにすれば members3 も同一になります。

class Person extends Equatable {
  const Person({required this.name, required this.age});

  final String name;
  final int age;

  @override
  List<Object> get props => [name];  // 判定対象からageを外した
}

...

print(members1 == members2);  // true
print(members1 == members3);  // true

同一性については以上です。
何の苦労も不安もなく使えそうですね!

equatable パッケージには mixin も用意されています。
詳しくはドキュメントの EquatableMixin の部分をご覧ください。

ついでに得られる利点

同一性とは関係ないのですが、

class Person {
  const Person(this.name, this.age);
  
  final String name;
  final int age;
}

...

const john = Person('John', 42);
print(john);  // Instance of 'Person'

のようにクラスを作ってそのオブジェクトを print すると、「Instance of 'Person'」と出力されて中身のプロパティの名前やそれが持つ値の情報は出力されません。

一方 Equatable を継承したクラスでは、予め用意されている toString() によって良い感じに出力されます。
先ほどのクラスに Equatable を用いると下記の出力になります。

Person(John, 42)

ちょっとしたことですが便利ですね。

もちろん Freezed もそこはうまくできていますが、この機能も equatable で代替できることを確認できたことになります。

続編もあります

immutability についても あまり間をあけずに公開したいと思います。
書きました。

kabochapo.hateblo.jp


  1. DeepCollectionEquality は Freezed でも使用されています。

  2. Map の順序が異なっていても同一と判定されますが、hashCode は異なる値になるようです(Freezed で作ったクラスでは同じ値になります)。同一判定になるオブジェクト同士は hashCode も同値になるべきですが、そうなっていない(おそらくバグ)のでご注意ください。