Dart/Flutter - Compare Two Objects

This tutorial explains the default behavior of object comparison in Dart and how you can change it.

Just like other programming languages, Dart has an operator for comparing two objects. The operator is called equality and it uses a double equals symbol (==). It's used to check whether two objects are equal. If you want to understand how the operator works or change the behavior, you can read this tutorial.

Comparing Primitive Immutable Data Types

As you already know, Dart comes with some primitive data types. There are int, double, boolean, and string. All of them are immutable. Once the value is created, it cannot be mutated. A Dart variable can be reassigned if it's not declared with const or final. Below is a string variable declared using var modifier, which means it can be reassigned.

  var str1 = 'This is a string';
  str1 = 'This is a new string';

In the example above, even though the variable is reassigned, the first string is not modified. Instead, Dart creates a new object at a different memory location. That's the basics of the immutability concept that you need to understand.

Each object in Dart has an equality operator (==). By default, it returns true if the compared objects refer to the same object in the memory.

In the code below, we have several variables with primitive data types and we are going to compare them using the equality operator..

  int i1 = 1;
  int i2 = 1;
  int i3 = 2;
  double d1 = 1.0;
  double d2 = 1.0;
  double d3 = 1.5;
  bool b1 = true;
  bool b2 = true;
  bool b3 = false;
  String s1 = 'text';
  String s2 = 'text';
  String s3 = 'another text';

  print('i1 == i2: ${i1 == i2}');
  print('i1 == i3: ${i1 == i3}');
  print('d1 == d2: ${d1 == d2}');
  print('d1 == d3: ${d1 == d3}');
  print('b1 == b2: ${b1 == b2}');
  print('b1 == b3: ${b1 == b3}');
  print('s1 == s2: ${s1 == s2}');
  print('s1 == s3: ${s1 == s3}');

Output:

  i1 == i2: true
  i1 == i3: false
  d1 == d2: true
  d1 == d3: false
  b1 == b2: true
  b1 == b3: false
  s1 == s2: true
  s1 == s3: false

As you can see from the output, if two values are equal, it will return true. As I have written above, the default behavior of the equality operator is that it returns true if two objects have the same memory reference. Does it mean two variables with a primitive data type and the same value refer to the same object, even though they are declared separately.

For a better understanding, let's see the example below. Dart has a method named identical which checks whether two references are to the same object. What if we try to use the identical for comparing primitive data types.

  print('identical(i1, i2): ${identical(i1, i2)}');
  print('identical(i1, i3): ${identical(i1, i3)}');
  print('identical(d1, d2): ${identical(d1, d2)}');
  print('identical(d1, d3): ${identical(d1, d3)}');
  print('identical(b1, b2): ${identical(b1, b2)}');
  print('identical(b1, b3): ${identical(b1, b3)}');
  print('identical(s1, s2): ${identical(s1, s2)}');
  print('identical(s1, s3): ${identical(s1, s3)}');

Output:

  identical(i1, i2): true
  identical(i1, i3): false
  identical(d1, d2): true
  identical(d1, d3): false
  identical(b1, b2): true
  identical(b1, b3): false
  identical(s1, s2): true
  identical(s1, s3): false

The output shows that if two objects are immutable and have the same value, they refer to the same object in the memory. Primitive data types which include int, double, boolean, and string are immutable. As a result, Dart can canonicalize their literals even though you don't declare the variables as const. That explains how the equality operator works on primitive data types.

Comparing Mutable Data Types

Dart also has built-in mutable data types such as List, Set, and Map. In addition, you can also create a custom class to define your own data type. For example, we have a class named Item as shown below.

  class Item {
    final String name;
    final double price;

    const Item({
      required this.name,
      required this.price,
    });
  }

Then, we create some instances of the class and compare the instances.

  final Item itemA1 = Item(name: 'A', price: 100);
  final Item itemA2 = Item(name: 'A', price: 100);
  final Item itemA3 = itemA1;
  final Item itemB = Item(name: 'B', price: 200);

  print('itemA1 == itemA2: ${itemA1 == itemA2}');
  print('itemA1 == itemA3: ${itemA1 == itemA3}');
  print('itemA1 == itemB: ${itemA1 == itemB}');

  print('identical(itemA1, itemA2): ${identical(itemA1, itemA2)}');
  print('identical(itemA1, itemA3): ${identical(itemA1, itemA3)}');
  print('identical(itemA1, itemB): ${identical(itemA1, itemB)}');

Output:

  itemA1 == itemA2: false
  itemA1 == itemA3: true
  itemA1 == itemB: false

  identical(itemA1, itemA2): false
  identical(itemA1, itemA3): true
  identical(itemA1, itemB): false

It's very clear that the result of itemA1 == itemB is false because they are different objects whose fields have different values. Meanwhile, itemA1 == itemA3 returns true because the object of itemA3 is assigned to itemA1. As a result, they refer to the same object in the memory.

The most interesting result is itemA1 == itemA3. From the explanation above, the result makes sense because the two variables do not refer to the same object, despite having the same value. The question is, is it possible to make the comparison returns true. The answer is yes, and there are several ways to do it.

Canonicalization with const Constructor

First, you can create the objects using const constructor. That allows Dart to perform canonicalization if there are multiple callers that create the object using the same arguments. Therefore, if Dart detects a previous instance with the same arguments already created, it will refer to the existing object.

To define a const constructor, all fields of the class must be final. The Item class above actually uses a const constructor. But the example above doesn't use the const modifier when constructing the objects. Below is a different example where the constructor is called using the const modifier to enable canonicalization.

  final Item itemA4 = const Item(name: 'A', price: 100);
  final Item itemA5 = const Item(name: 'A', price: 100);

  print('itemA4 == itemA5: ${itemA4 == itemA5}');
  print('identical(itemA4, itemA5): ${identical(itemA4, itemA5)}');

Output:

  itemA4 == itemA5: true
  identical(itemA4, itemA5): true

Because of the canonicalization, the itemA5 refers to the same object as the itemA4. The result shows that those two objects are identical. As a result, the comparison also returns true.

Below is another way to declare the variables that also triggers the const constructor to perform canonicalization.

  const Item itemA4 = Item(name: 'A', price: 100);
  const Item itemA5 = Item(name: 'A', price: 100);

Override Equality Operator

The equality operator can be overridden as well. For example, you can change the logic to return true if each field has the same value. In the code below, we override the equality operator to return true if the compared objects have the same name and price. By overriding the method, Dart no longer uses the memory reference for comparison. Keep in mind that if the equality operator is overridden, you also have to override the hashCode property to keep the consistency.

  class Item {
    final String name;
    final double price;

    const Item({
      required this.name,
      required this.price,
    });

    @override
    bool operator ==(Object other) {
      if (identical(this, other)) {
        return true;
      }

      return (other is Item
          && other.runtimeType == runtimeType
          && other.name == name
          && other.price == price
      );
    }

    @override
    int get hashCode => Object.hash(name, price);
  }

If you try to run the comparison, it will return true if the name and price fields have the same values, despite the fact that they are two different objects in the memory.

  print('itemA1 == itemA2: ${itemA1 == itemA2}');
  print('identical(itemA1, itemA2): ${identical(itemA1, itemA2)}');

Output:

  itemA1 == itemA2: true
  identical(itemA1, itemA2): false

Using equatable Package

An easier way to override the equality operator is by using the equatable package. You can install it by using dart pub add equatable (or flutter pub add equatable for Flutter).

To use the package, you need to import package:equatable/equatable.dart. Then, modify the class to extend Equatable. It requires you to override the props getter whose value is the list of fields or properties to be compared. Equatable will override the equality operator and the hashCode property, so you don't need to override them by yourself. If the class already extends another class, you can use the EquatableMixin instead (replace extends Equatable with with EquatableMixin).

  class Item extends Equatable {
    final String name;
    final double price;

    const Item({
      required this.name,
      required this.price,
    });

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

The result should be the same as the previous example.

Summary

In this tutorial, we have learned the fundamentals of object comparison in Dart. By default, Dart uses memory reference to determine whether two objects are equal. For primitive immutable data types, Dart canonicalizes the same values to refer to the same object in the memory. For mutable data types, if the compared objects refer to different instances, the comparison will return false by default. You can override the equality operator by yourself or using the equatable package. Alternatively, you can use a const constructor if you want Dart to canonicalize objects created with the same arguments.

You can also read about: