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.
== 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.
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);
});
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.
https://api.dart.dev/stable/2.14.2/dart-core/Object/operator_equals.html
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.
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.
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.
Comments