Dart の等価性を完全理解する

はじめに

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 はここで順序なし比較
  }
  // ...
}

ポイントは、LinkedHashMapMap を実装しているため、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 つのポイント

  1. Equatable はネストの末端まで徹底する — 外側だけ Equatable にしても、内側が参照比較なら全体として false になります
  2. List は順序を含めて比較される、Map(LinkedHashMapも) は順序を無視するDeepCollectionEquality でも Equatable でもこの挙動は同じです
  3. Record は Dart で唯一、追加実装なしに構造的等価性を持つ — ただし内部に参照比較のオブジェクトを含めると崩れます

🎓 学生の方へ | 1day インターンイベント開催!

Speeda ソフトウェアエンジニア職 のインターンイベントを開催します!

Speedaプロダクトチームではアジャイル開発手法の一種であるXP(エクストリームプログラミング)を実践しており、今回のインターンではプラクティスの中でも特徴的なペアプログラミングをみなさんに体験していただきます!

チーム開発を更に深めることができるペアプログラミングの経験は我々の文化を体験することや、今後のエンジニアとしての活動の幅を広げるきっかけになると思います。

少しでもご興味をお持ちいただけた方は、ぜひお気軽にエントリーください。

エントリーはこちら!

Page top