Dart Compare two objects – how to deep equal

eye-catch Dart and Flutter

Comparing two objects is often necessary. How can we implement the two objects that are the same class created by us? When we want to write unit tests for the class we probably need to know how to do it. This post will explain some ways and compare the differences.

Sponsored links

== operator for class does not work

First of all, let’s check the problem. We have the following simple class.

class MyObject {
  int uid;
  MyObject(this.uid);
}

We want to compare the two objects but it doesn’t work as expected.

All codes to confirm the behavior are unit tests in this post.

test('should return false', () {
  final obj1 = MyObject(1);
  final obj2 = MyObject(1);
  expect(obj1 == obj2, false);
  expect(obj2 == obj1, false);
});

Override == operator and hashCode

To make it work, we need to override == operator and hashCode. Let’s try to override it.

// BAD. Don't use this code
class MyObject1 {
  int uid;
  MyObject1(this.uid);

  @override
  bool operator ==(Object other) {
    return hashCode == other.hashCode;
  }

  @override
  int get hashCode => uid.hashCode;
}

We need to write @override annotation for them. After defining those functions it works as expected.

test('should return true when comparing the same object', () {
  final obj1 = MyObject1(1);
  final obj2 = MyObject1(1);
  expect(obj1 == obj2, true);
  expect(obj2 == obj1, true);
});

Note that the hash-based collection HashMaps/HashSets doesn’t work if == operator and hashCode are overridden for a mutable object because it must be consistent over its lifetime.

Comparing with primitive data type

However, the previous code was wrong because hashCode is used for the comparison. When comparing it with the same int value the result becomes true.

test('should return true when comparing with int', () {
  final obj1 = MyObject1(1);
  final obj2 = 1;
  // ignore: unrelated_type_equality_checks
  expect(obj1 == obj2, true);
});

The result is false if the order is opposite because our logic in the override function isn’t called. The function in int is used instead.

test('should return false when comparing with int (opossite order)', () {
  final obj1 = MyObject1(1);
  final obj2 = 1;
  // ignore: unrelated_type_equality_checks
  expect(obj2 == obj1, false);
});

I think you don’t use hashCode for the comparison if you have a target property that you want to compare. Following is the proper way.

// GOOD
class MyObject2 {
  int uid;
  MyObject2(this.uid);

  @override
  bool operator ==(Object other) {
    return other is MyObject2 && uid == other.uid;
  }

  @override
  int get hashCode => uid.hashCode;
}

It’s necessary to check if the object is the same instance because we can’t change the required data type like the following. Without the instance check, other.uid is not accessible.

@override
bool operator ==(MyObject2 other) {
  return other is MyObject2 && uid == other.uid;
}
//'MyObject2.==' ('bool Function(MyObject2)') isn't a valid override of 'Object.==' ('bool Function(Object)').dartinvalid_override

Following tests become green.

test('should return true when comparing the same object', () {
  final obj1 = MyObject2(1);
  final obj2 = MyObject2(1);
  expect(obj1 == obj2, true);
  expect(obj2 == obj1, true);
});
test('should return true when comparing with int', () {
  final obj1 = MyObject2(1);
  final obj2 = 1;
  // ignore: unrelated_type_equality_checks
  expect(obj1 == obj2, false);
});
test('should return false when comparing with int (opossite order)', () {
  final obj1 = MyObject2(1);
  final obj2 = 1;
  // ignore: unrelated_type_equality_checks
  expect(obj2 == obj1, false);
});

When overriding the function we must follow the following rule. That’s why I wrote the comparison in two ways. As you can see, the result was different between obj1==obj2 and obj2==obj1 in the example above.

Total: It must return a boolean for all arguments. It should never throw.
Reflexive: For all objects o, o == o must be true.
Symmetric: For all objects o1 and o2, o1 == o2 and o2 == o1 must either both be true, or both be false.
Transitive: For all objects o1, o2, and o3, if o1 == o2 and o2 == o3 are true, then o1 == o3 must be true.

https://api.dart.dev/stable/2.14.2/dart-core/Object/operator_equals.html
Sponsored links

For class that has multiple properties

The previous example was too simple. In many cases, a class has more than 2 properties. How should we implement it in this case? I prepared the following example.

class ComparedClass {
  List<String> texts;
  int uid;
  int type;
  ComparedClass({
    required this.texts,
    required this.uid,
    this.type = 1,
  });

It is still simple but I think it’s enough for the example. I defined type property in order to switch comparison logic to show the difference. There is only one logic at the moment. It compares only one property. It treats as the same object if the other object has the same uid.

@override
bool operator ==(Object other) {
  switch (type) {
    default:
      return other is ComparedClass && uid == other.uid;
  }
}

How should we define hashCode? We should use hashValues to generate hash code. This function is provided by Flutter. If the class has Iterable property like List or Map, use hashList function.

  @override
  int get hashCode => hashValues(hashList(texts), uid);  

The test looks like this.

group('type 1', () {
  test('should return true when uid is the same', () {
    final obj1 = ComparedClass(texts: ["123"], uid: 1);
    final obj2 = ComparedClass(texts: ["1234"], uid: 1);
    expect(obj1 == obj2, true);
    expect(obj2 == obj1, true);
  });
  test('should return false when uid is different', () {
    final obj1 = ComparedClass(texts: ["123"], uid: 1);
    final obj2 = ComparedClass(texts: ["1234"], uid: 2);
    expect(obj1 == obj2, false);
    expect(obj2 == obj1, false);
  });
});

It returns true if uid is the same even though the other property is different.

runtimeType check

If we need to define an extended class and want to compare the two objects we need to take care of it.
If the extended class doesn’t override == operator the comparison doesn’t work as expected.

group('type 1', () {
  test('should return true when comparing with extended class', () {
    final obj1 = ComparedClass(texts: ["123"], uid: 1);
    final obj2 = ExtendedClass(foo: "2", texts: ["1234"], uid: 1);
    expect(obj1 == obj2, true);
    expect(obj2 == obj1, true);
  });
});

If we want to treat them the same object it is no problem. How can we implement if we want to handle them differently? There are two ways.

  • Override == operator in the extended class
  • Add runtimeType check in the base class

The extended class probably has members that the base class doesn’t have. We can add the info to the hashCode. Even if the member is a function we can add the hashCode.

The second way is better in my opinion because we don’t have to override hashCode and == operator for each extended class. The following is an example of it.

@override
bool operator ==(Object other) {
  switch (type) {
    case 2:
      return other is ComparedClass &&
          runtimeType == other.runtimeType && // runtime type check
          uid == other.uid;
    default:
      return other is ComparedClass && uid == other.uid;
  }
}

We can treat them as different objects if we have this run time type check.

group('type2', () {
  test('should return false when comparing with extended class', () {
    final obj1 = ComparedClass(texts: ["123"], uid: 1, type: 2);
    final obj2 = ExtendedClass(foo: "2", texts: ["1234"], uid: 1, type: 2);
    expect(obj1 == obj2, false);
    expect(obj2 == obj1, false);
  });
});

Comparing all properties

We have used only uid property so far. It’s time to compare texts property which is List data type. There are several functions that can compare Iterable data type objects. Case 3 to 7 are added in the following code.

@override
bool operator ==(Object other) {
  switch (type) {
    case 2:
      return other is ComparedClass &&
          runtimeType == other.runtimeType &&
          uid == other.uid;
    case 3:
      return other is ComparedClass &&
          runtimeType == other.runtimeType &&
          uid == other.uid &&
          const IterableEquality().equals(texts, other.texts);
    case 4:
      return other is ComparedClass &&
          runtimeType == other.runtimeType &&
          uid == other.uid &&
          listEquals(texts, other.texts);
    case 5:
      return other is ComparedClass &&
          runtimeType == other.runtimeType &&
          uid == other.uid &&
          const DeepCollectionEquality().equals(texts, other.texts);
    case 6: // cause stack overflow
      return other is ComparedClass &&
          runtimeType == other.runtimeType &&
          const DeepCollectionEquality().equals(this, other);
    case 7:
      return identical(this, other);
    default:
      return other is ComparedClass && uid == other.uid;
  }
}

3, 4, and 5 are the same result. It depends on your preference which one to choose.

[3, 4, 5].forEach((type) {
  group('type$type', () {
    test('should return false when list contains different item', () {
      final obj1 = ComparedClass(texts: ["123"], uid: 1, type: type);
      final obj2 = ComparedClass(texts: ["123", "22"], uid: 1, type: type);
      expect(obj1 == obj2, false);
      expect(obj2 == obj1, false);
    });
    test('should return true when list contains the same items', () {
      final obj1 = ComparedClass(texts: ["123", "22"], uid: 1, type: type);
      final obj2 = ComparedClass(texts: ["123", "22"], uid: 1, type: type);
      expect(obj1 == obj2, true);
      expect(obj2 == obj1, true);
    });
  });
});

DeepCollectionEquality causes stack overflow

Be careful that if we specify this and other to DeepCollectionEquality().equals function, it causes a stack overflow. I haven’t checked the code of the function but I guess our comparison function is probably called internally and it causes a function call loop.

case 6: // cause stack overflow
  return other is ComparedClass &&
      runtimeType == other.runtimeType &&
      const DeepCollectionEquality().equals(this, other);

identical cannot be used for the same behavior

Following one is different behavior from others.

case 7:
  return identical(this, other);

It checks whether the two objects are the same instance or not. Even if the two objects have the same values it returns false if the instances are different. Look at the following tests.

group('type7', () {
  test('should return true when the two are the same instance', () {
    final obj1 = ComparedClass(texts: ["123"], uid: 1, type: 7);
    final obj2 = obj1;
    expect(obj1 == obj2, true);
    expect(obj2 == obj1, true);
  });
  test('should return false when the two are the different instances', () {
    final obj1 = ComparedClass(texts: ["123"], uid: 1, type: 7);
    final obj2 = ComparedClass(texts: ["123"], uid: 1, type: 7);
    expect(obj1 == obj2, false);
    expect(obj2 == obj1, false);
  });
});

The second test creates two different objects with exactly the same values but its result is false.

identical

Less code with Equatable package

It’s tedious to override two methods every time when a new class needs to be created. We can make it simpler by using Equatable package.

What we have to do is only add the target properties to the return value of props getter. Let’s add only two properties here.

class MyClass3 extends Equatable {
  final int id;
  final int age;
  final String name;

  MyClass3(this.id, this.age, this.name);

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

In production code, all properties should be added but only two are added for testing.

It returns true if the id and name are the same. It returns false if one of them is different.

group("MyClass3", () {
  test('should return true if id and name are identical', () {
    final obj1 = MyClass3(1, 22, "kevin");
    final obj2 = MyClass3(1, 52, "kevin");
    expect(obj1 == obj2, true);
    expect(obj2 == obj1, true);
  });
  test('should return false if name is identical but id is different', () {
    final obj1 = MyClass3(1, 22, "kevin");
    final obj2 = MyClass3(2, 22, "kevin");
    expect(obj1 == obj2, false);
    expect(obj2 == obj1, false);
  });
});

It’s easier and more readable than implementing it on our own.

End

Compared to JavaScript or TypeScript, I felt I needed more effort to do the same thing but it seems to be the same as Java.
If you want to check the complete source code you can clone my repository.

File not found · yuto-yuto/flutter_samples
Contribute to yuto-yuto/flutter_samples development by creating an account on GitHub.

Comments

Copied title and URL