
はじめに
Dart で == を使ったとき、「同じ値なのに false になる」という経験はないでしょうか。
この記事を書こうと思ったきっかけは、LinkedHashMap を Equatable パッケージで等価比較しようとしたときの出来事でした。LinkedHashMap は挿入順序を保持するデータ構造なので、当然順序も含めて比較してくれるだろうと思っていたのですが、実際には順序が無視されて等しいと判定されてしまいました。なぜこうなるのかを調べていくうちに、Dart の等価性の仕組み全体に興味を持ち、さまざまなデータ構造について網羅的に検証してみることにしました。
本記事では、クラス・コレクション・Record・enum といったデータ構造について、標準の == と Equatable パッケージ、collection パッケージを使った場合の挙動を整理します。
Dart の == のデフォルト挙動
Dart のすべてのクラスは Object を継承しており、デフォルトの == は 参照の同一性(identity) で比較します。つまり、同じ値を持っていても別のインスタンスであれば false になります。
class SimpleClass { final String name; final int age; const SimpleClass(this.name, this.age); } final a = SimpleClass('Alice', 30); final b = SimpleClass('Alice', 30); print(a == b); // false — 別インスタンス print(a == a); // true — 同一インスタンス
値で比較したい場合は、== と hashCode を自分でオーバーライドする必要があります。
1. クラスの等価性
手動で == / hashCode をオーバーライド
class SimpleClassWithOverride { final String name; final int age; const SimpleClassWithOverride(this.name, this.age); @override bool operator ==(Object other) => identical(this, other) || other is SimpleClassWithOverride && name == other.name && age == other.age; @override int get hashCode => name.hashCode ^ age.hashCode; } final a = SimpleClassWithOverride('Alice', 30); final b = SimpleClassWithOverride('Alice', 30); print(a == b); // true
これで値比較が可能になりますが、フィールドが増えるたびに == と hashCode の両方をメンテナンスしなければならず、書き漏れによるバグの温床になりやすいです。
Equatable パッケージで楽にする
equatable パッケージを使えば、props ゲッターに比較対象のフィールドを列挙するだけで値比較が実現できます。
class SimpleEquatable extends Equatable { final String name; final int age; const SimpleEquatable(this.name, this.age); @override List<Object?> get props => [name, age]; } final a = SimpleEquatable('Alice', 30); final b = SimpleEquatable('Alice', 30); print(a == b); // true
2. ネストしたクラスの落とし穴
ネストしたクラスでは、外側だけでなく 内側のクラスも 値比較に対応していなければなりません。
標準 == — 当然 false
class Address { final String city; final String street; const Address(this.city, this.street); } class Person { final String name; final Address address; const Person(this.name, this.address); } final a = Person('Alice', Address('Tokyo', '1-1')); final b = Person('Alice', Address('Tokyo', '1-1')); print(a == b); // false
両方 Equatable — 正しく true
class AddressEquatable extends Equatable { final String city; final String street; const AddressEquatable(this.city, this.street); @override List<Object?> get props => [city, street]; } class PersonEquatable extends Equatable { final String name; final AddressEquatable address; const PersonEquatable(this.name, this.address); @override List<Object?> get props => [name, address]; } final a = PersonEquatable('Alice', AddressEquatable('Tokyo', '1-1')); final b = PersonEquatable('Alice', AddressEquatable('Tokyo', '1-1')); print(a == b); // true
外側だけ Equatable — false になる!
class PersonWithPlainAddress extends Equatable { final String name; final Address address; // Address は Equatable ではない const PersonWithPlainAddress(this.name, this.address); @override List<Object?> get props => [name, address]; } final a = PersonWithPlainAddress('Alice', Address('Tokyo', '1-1')); final b = PersonWithPlainAddress('Alice', Address('Tokyo', '1-1')); print(a == b); // false!
Equatable の props 内の各要素は、その要素自身の == で比較されます。Address がデフォルトの参照比較のままなので、props のリスト比較の途中で false になります。Equatable を使うなら、ネストの末端まで徹底する必要があります。
3. コレクション型の等価性
List
final a = [1, 2, 3]; final b = [1, 2, 3]; print(a == b); // false — 参照比較
List の == はデフォルトで参照比較になります。ただし、const リテラル同士はコンパイル時に同一インスタンスとして正規化されるため true になります。
const a = [1, 2, 3]; const b = [1, 2, 3]; print(a == b); // true print(identical(a, b)); // true — 同一インスタンス
Map
final a = {'key': 'value'}; final b = {'key': 'value'}; print(a == b); // false — 参照比較
Map も同様に参照比較です。const Map 同士は true になります。
Set
final a = {1, 2, 3}; final b = {1, 2, 3}; print(a == b); // false — 参照比較
LinkedHashMap
Dart の Map リテラル ({}) は実は LinkedHashMap であり、挿入順序を保持します。しかし == は他のコレクションと同じく参照比較です。
final map = {'a': 1, 'b': 2}; print(map is LinkedHashMap); // true
4. collection パッケージによるディープ比較
collection パッケージの DeepCollectionEquality を使えば、コレクションの中身を再帰的に値比較できます。
List のディープ比較
import 'package:collection/collection.dart'; final a = [1, 2, 3]; final b = [1, 2, 3]; print(const DeepCollectionEquality().equals(a, b)); // true // ネストした List も OK final c = [[1, 2], [3, 4]]; final d = [[1, 2], [3, 4]]; print(const DeepCollectionEquality().equals(c, d)); // true
List の順序
DeepCollectionEquality は 順序を考慮します。順序を無視したい場合は .unordered() を使います。
final a = [1, 2, 3]; final b = [3, 2, 1]; print(const DeepCollectionEquality().equals(a, b)); // false print(const DeepCollectionEquality.unordered().equals(a, b)); // true
Map / LinkedHashMap の順序
Map の比較はキーと値のペアで行われるため、挿入順序は等価性に影響しません。
final a = LinkedHashMap<String, int>.from({'a': 1, 'b': 2}); final b = LinkedHashMap<String, int>.from({'b': 2, 'a': 1}); // keys の順序は異なる print(a.keys.toList()); // [a, b] print(b.keys.toList()); // [b, a] // でも DeepCollectionEquality では等しい print(const DeepCollectionEquality().equals(a, b)); // true
これは DeepCollectionEquality が Map を比較するとき、内部的に MapEquality を使い、キーの集合が一致し各キーに対応する値が等しいかで判定しているためです。
Equatable でも同様です。LinkedHashMap をフィールドに持つ Equatable クラスで比較しても、挿入順序は無視されます。
class ConfigEquatable extends Equatable { final String name; final LinkedHashMap<String, int> settings; const ConfigEquatable(this.name, this.settings); @override List<Object?> get props => [name, settings]; } final a = ConfigEquatable('app', LinkedHashMap.from({'a': 1, 'b': 2})); final b = ConfigEquatable('app', LinkedHashMap.from({'b': 2, 'a': 1})); print(a == b); // true — 挿入順序が異なるが等しいと判定される
なぜ Equatable は LinkedHashMap の順序を無視するのか
この挙動を理解するには、Equatable の内部実装を見る必要があります。Equatable の == は props リストの各要素を objectsEquals() という関数で比較しています。この関数は要素の型に応じて比較方法を振り分けます。
// equatable パッケージ内部の objectsEquals() より抜粋 bool objectsEquals(Object? a, Object? b) { if (identical(a, b)) return true; // ... else if (a is Iterable && b is Iterable) { return iterableEquals(a, b); // List はここで順序付き比較 } else if (a is Map && b is Map) { return mapEquals(a, b); // Map はここで順序なし比較 } // ... }
ポイントは、LinkedHashMap は Map を実装しているため、Map の分岐に入る という点です。そして mapEquals() の実装は以下のようになっています。
bool mapEquals(Map<Object?, Object?> a, Map<Object?, Object?> b) { if (identical(a, b)) return true; if (a.length != b.length) return false; for (final key in a.keys) { if (!b.containsKey(key) || !objectsEquals(a[key], b[key])) return false; } return true; }
mapEquals() は a の各キーが b にも存在するか、そしてそのキーに対応する値が等しいかだけを検査しています。キーの列挙順序を比較する処理は一切ありません。LinkedHashMap がいくら挿入順序を保持していても、Equatable はそれを「順序付きデータ構造」ではなく「キーと値のペアの集合」として扱うため、順序は無視されます。
つまり、LinkedHashMap の順序を考慮した比較が必要な場合は、Equatable や DeepCollectionEquality に頼ることはできません。keys.toList() を取り出して List として比較するなど、自前で対応する必要があります。
Set のディープ比較
final a = {1, 2, 3}; final b = {1, 2, 3}; print(const DeepCollectionEquality().equals(a, b)); // true
5. Record (Dart 3)
Dart 3 で導入された Record は、言語レベルで構造的等価性を持つ唯一の型 です。クラスやコレクションと異なり、特別な実装なしで値比較ができます。
final a = (1, 'hello'); final b = (1, 'hello'); print(a == b); // true // 名前付きフィールドも OK final c = (name: 'Alice', age: 30); final d = (name: 'Alice', age: 30); print(c == d); // true
ただし、Record 内の各フィールドはそのフィールド自身の == で比較されます。つまり、Record に非 Equatable なオブジェクトを含めると、そこで比較が崩れます。
// Address は標準の == (参照比較) final a = (name: 'Alice', address: Address('Tokyo', '1-1')); final b = (name: 'Alice', address: Address('Tokyo', '1-1')); print(a == b); // false // AddressEquatable なら OK final c = (name: 'Alice', address: AddressEquatable('Tokyo', '1-1')); final d = (name: 'Alice', address: AddressEquatable('Tokyo', '1-1')); print(c == d); // true
6. Equatable のフィールドに List / Map がある場合
Equatable は props 内の List や Map を自動的にディープ比較してくれます。これは内部で DeepCollectionEquality 相当の処理が行われているためです。
class UserWithTags extends Equatable { final String name; final List<String> tags; const UserWithTags(this.name, this.tags); @override List<Object?> get props => [name, tags]; } final a = UserWithTags('Alice', ['dart', 'flutter']); final b = UserWithTags('Alice', ['dart', 'flutter']); print(a == b); // true — List の中身までディープ比較される
Map フィールドも同様にディープ比較されます。
class UserWithMeta extends Equatable { final String name; final Map<String, String> meta; const UserWithMeta(this.name, this.meta); @override List<Object?> get props => [name, meta]; } final a = UserWithMeta('Alice', {'role': 'admin'}); final b = UserWithMeta('Alice', {'role': 'admin'}); print(a == b); // true
7. enum
enum は各値がシングルトンであるため、標準の == でそのまま正しく比較できます。
enum Color { red, green, blue } print(Color.red == Color.red); // true print(Color.red == Color.blue); // false
まとめ
| データ構造 | 標準 == |
値比較する方法 |
|---|---|---|
| クラス | 参照比較 | ==/hashCode オーバーライド or Equatable |
| ネストしたクラス | 参照比較 | 末端まで Equatable にする |
| List | 参照比較 | DeepCollectionEquality (順序あり) |
| Map | 参照比較 | DeepCollectionEquality (順序なし) |
| Set | 参照比較 | DeepCollectionEquality |
| LinkedHashMap | 参照比較 | DeepCollectionEquality (挿入順は無視) |
| Record | 構造的等価性 | そのまま使える (内部要素の == に依存) |
| enum | シングルトン比較 | そのまま使える |
const コレクション |
同一インスタンス | そのまま使える (コンパイル時正規化) |
覚えておくべき 3 つのポイント
- Equatable はネストの末端まで徹底する — 外側だけ Equatable にしても、内側が参照比較なら全体として
falseになります - List は順序を含めて比較される、Map(LinkedHashMapも) は順序を無視する —
DeepCollectionEqualityでも Equatable でもこの挙動は同じです - Record は Dart で唯一、追加実装なしに構造的等価性を持つ — ただし内部に参照比較のオブジェクトを含めると崩れます
🎓 学生の方へ | 1day インターンイベント開催!
Speeda ソフトウェアエンジニア職 のインターンイベントを開催します!
Speedaプロダクトチームではアジャイル開発手法の一種であるXP(エクストリームプログラミング)を実践しており、今回のインターンではプラクティスの中でも特徴的なペアプログラミングをみなさんに体験していただきます!
チーム開発を更に深めることができるペアプログラミングの経験は我々の文化を体験することや、今後のエンジニアとしての活動の幅を広げるきっかけになると思います。
少しでもご興味をお持ちいただけた方は、ぜひお気軽にエントリーください。