Yogesh Parwani

Building awesome mobile experiences, one pixel at a time.

Yogesh Parwani

Building awesome mobile experiences, one pixel at a time.

Yogesh Parwani

Building awesome mobile experiences, one pixel at a time.

Apr 25, 2022

8 min read

Immutability & Equality in Flutter & Dart [Functional Programming — Part 4]

Previously, we covered how to build modular and scalable applications using composition. This article covers immutability and equality in Dart & Flutter.

Immutable?

Firstly, Mutable: something that can change over time. For instance, when we define a variable int a = 10, it is susceptible to change. a=20 is entirely valid. In contrast, we have immutability.

Immutability — as you could have guessed — is something that cannot be changed. It didn’t make much (or any) sense when I heard it the first time. What’s the use of an application that does not change its state? Now, I ask myself how I didn’t come across this earlier.

void main(List<String> args) {
  // Mutable: Something that can be changed.
  var a = 10;
  a = 20;
  
  // Immutable: Something that cannot be changed.
  final b = 10;
  // b = 20; // Error: final(immutable) variable cannot be reassigned.
}

Immutability can be achieved using two keywords in Dart: final and const. Which to prefer when? Let’s find out!

final vs const

final

final variables are evaluated at runtime. If we don’t intend to change the value, it is recommended to use final instead of the var keyword or specify explicit types(int, String). The Dart analyzer is smart enough to infer the types.

const

On the other hand, const is a compile-time constant and is implicitly final. So the value must be known at compile-time and can not be reassigned.

Okay, But why should we strive for immutable data structures?

Why prefer immutability?

  • Fewer bugs: Changing values in place has the possibility of breaking codebases. Immutability helps to avoid accidental reassignments. With mutable, someone can accidentally update the object we were accessing, resulting in hard to trace bugs.

  • Code becomes predictable and easier to read.

  • As const evaluates at compile-time, the compiler knows the value in advance and stores it in the memory. The exact value is referred to throughout the application instead of creating new objects every time. So this helps to save some memory and has small performance benefits.

In our Flutter application, we have EdgeInsets.all(8.0) being used at 10 places, as an example. By using const, we tell the compiler to create only a single instance and use the same instance throughout the application where padding of 10 is required.

const can not only be used with primitives but also with classes, lists, maps, and sets whose values do not change.

Okay, Fair enough! But if we keep using immutable data structures everywhere, how will we update the state?

How to update immutable states?

Of course, how useful is an application that does not change and only shows static content? Not much, Right? So How do we update the state then? Instead of changing values, we replace them. We create a copy of the instance we need to access and use it. It prevents unexpected side effects.

We can use the copyWith method to make our life easier, but we need to implement it first.

void main(List<String> args) {
  final _user = User(name: 'John', age: 18);
  print(_user); // User(name: 'John', age: 18)
  final _newUser = _user.copyWith(age: 20);
  print(_newUser); // User(name: 'John', age: 20)
}

class User {
  final String name;
  final int age;

  const User({required this.name, required this.age});

  User copyWith({
    String? name,
    int? age,
  }) =>
      User(
        name: name ?? this.name,
        age: age ?? this.age,
      );

  @override
  String toString() => 'User(name: $name, age: $age)';
}

copwWith creates a copy of the instance and only updates the values passed explicitly.

Primitives like int are by default immutable. For example, an 8 is always an 8; we can’t mutate it to any other number. In the above snippet, we saw how to make a class immutable.

Use the @immutable annotation to get analyzer support in creating immutable classes.

What about other data structures like lists, maps, etc.? Let’s find out!

Immutable lists, Maps

We can use final and const with lists, but there’s a caveat.

void main(List<String> args) {
  final _finalList = [1, 2, 3];
  const _constList = [1, 2, 3];
  
  // _finallist = [5, 6, 7]; // Error: final list cannot be reassigned.
  // _constlist = [5, 6, 7]; // Error: const list cannot be reassigned.
  
  _finalList.add(4);
  print(_finalList); // [1, 2, 3, 4]
  
  // _constList.add(4); // Error: Unsupported operation: Cannot modify an unmodifiable list.
}

Since both lists are either final or const, we can not assign a new list(Ref. lines 5 and 6). However, in the case of the final, we can still add/remove the elements inside the list. We can utilize unmodifiable factory constructors to make these data structures completely immutable.

void main(List<String> args) {
  final _list = List<int>.unmodifiable(
    <int>[1, 2, 3],
  );
  // _list.add(4); // Error: Unsupported operation: Cannot modify an unmodifiable list.
  
  final _map = Map<String, int>.unmodifiable(
    <String, int>{'a'

If we try to add/remove elements, we will get the following error at runtime. Well, this certainly works but wouldn’t it be better if we could achieve this at compile time? The kt_dart package helps us with immutable data structures, but Since we’re already using dartz, we can use the IList, which provides an immutable list.

void main(List<String> args) {
  final list = IList.from(<int>[1, 2, 3]);
  
  final _newList = list.appendElement(4);
  print(_newList); // ilist[1, 2, 3, 4]
  
  final _newList2 = list.prependElement(0);
  print(_newList2); // ilist[0, 1, 2, 3]
  
  final _newList3 = list.plus(IList.from(<int>[10, 11, 12]));
  print(_newList3); // ilist[1, 2, 3, 10, 11, 12]
  
  // Use toList method get a new dart list from the ilist.
  final _normalList = _newList.toList();
  print(_normalList.runtimeType); // List<int>

On line no. 2, we create an IList instance from a regular list. It is entirely immutable. To add/remove elements, we have the appendElement and prependElement, which adds the element at the last position and first position, respectively. It does not update the elements in place but instead returns a new list.

Equality

Before jumping in, here’s a fun exercise. Try to guess the output of the following.

void main(List<String> args) {
  print(1 == 1);
  print(10 == 01);
  print(1 == 1.0);
  print(User(name: 'John', age: 18) == User(name: 'John', age: 18));
  print([2, 3, 4] == [3, 4, 2]);
  print([1, 2, 3] == [1, 2, 3]);
  print(
    <String, String>{'name': 'John Doe'} ==
      <String, String>{'name': 'John Doe'},
  );
  print(1 is double);
}

class User {
  final String name;
  final int age;
  
  User({required this.name, required this.age});
}

Equality is the means to check if two objects are equal by using the == operator. Equality is of two types: Referential equality and Value equality. For example, we have a User class with two fields: name and age. When we initialize the User class, objects are created and stored in the memory. Each object has a unique memory address assigned to it.

Referential Equality returns true when two objects refer to the same object. For the objects to return true, they should point to the same object in the memory.

Value Equality returns true when both two objects have the same value. Objects can be at a different location in the memory, but if they have the same value, it returns true.

Equality operator

By default, Dart checks for referential equality. If we compare any two non-constant objects in dart with the same value, it evaluates to false. The default implementation of the == operator is to return true if both the objects are identical, in other words, if both the objects are the same.

To support value equality, a class needs to be:

  • Total: It should always return a boolean and never throw an error or return null.

  • Reflexive: a == a should always return true.

  • Symmetric: a == b should be the same as b == a. Either both should evaluate to true, or both should evaluate to false. If a is equal to b, then b should be equal to a.

  • Transitive: If a == b is true and b == c is true then a == c should be true.

void main(List<String> args) {
  final _userOne = User(name: 'John', age: 18);
  final _userTwo = User(name: 'John', age: 18);
  
  print(_userOne == _userTwo); // false
  
  final _userThree = User2(name: 'John', age: 18);
  final _userFour = User2(name: 'John', age: 18);
  
  print(_userThree == _userFour); // true
}

// Normal class
class User {
  final String name;
  final int age;
  
  const User({
    required this.name,
    required this.age,
  });
}

// Class with value equality support
@immutable
class User2 {
  final String name;
  final int age;
  
  const User2({required this.name, required this.age});
  
  @override
  bool operator ==(covariant User2 other) {
    if (identical(this, other)) return true;
    
    return other.runtimeType == User2 && other.name == name && other.age == age;
  }
}

User, a regular class, and User2, a class with value equality. When comparing 2 User objects with the same value, it evaluates to false, as it uses referential equality by default. However, when comparing two User2 objects with similar values, it evaluates to true as we have implemented the value equality.

But wait, that’s not all!

hashCode

While overriding the == operator, we need to make sure to override the hashCode. But first, what’s a hashCode anyway?

The hashCode is of type int and represents the state of the object. The default implementation represents the identity of the object.

If the == operator is overridden, we should also override the hashCode to represent the state; otherwise, the objects can not be used in hash-based data structures like Map.

hash_and_equals add this in your analysis_options.yaml to get analyzer support for hashCode.

@immutable
class User2 {
  final String name;
  final int age;
  
  const User2({required this.name, required this.age});
  
  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    
    return other is User2 && other.name == name && other.age == age;
  }
  
  @override
  int get hashCode => name.hashCode ^ age.hashCode;
}

List, Map, Set Equality

Collections like lists and maps, when compared, checks for referential equality. Methods like listEquals and mapEquals are to our rescue when we need to check for value equality.

To check element by element, we’ll need deep equality provided by the collection package via DeepCollectionEquality, and it is one of the packages that the Flutter uses as a dependency. It supports two modes: ordered and unordered.

If we’re using IList, we get the value equality support by default.

void main(List<String> args) {
  final _listOne = [1, 2, 3];
  final _listTwo = [1, 2, 3];
  
  final _mapOne = {'name': 'John', 'age': 18};
  final _mapTwo = {'name': 'John', 'age': 18};
  
  print(_listOne == _listTwo); // false
  print(_mapOne == _mapTwo); // false
  
  final _equality = DeepCollectionEquality();
  
  print(_equality.equals(_listOne, _listTwo)); // true
  print(_equality.equals(_mapOne, _mapTwo)); // true
  
  // Unordered version
  print(DeepCollectionEquality.unordered().equals(
    [1, 2, 4, 3],
    [1, 2, 3, 4],
  )); // true
}

Now will be a good time to reveal the answers to the above quiz.

void main(List<String> args) {
  print(1 == 1); // true
  print(10 == 01); // false
  print(1 == 1.0); // true
  print(User(name: 'John', age: 18) == User(name: 'John', age: 18)); // false
  print([2, 3, 4] == [3, 4, 2]); // false
  print([1, 2, 3] == [1, 2, 3]); // false
  print(
    <String, String>{'name': 'John Doe'} ==
      <String, String>{'name': 'John Doe'},
  ); // false
  print(1 is double); // false
}

class User {
  final String name;
  final int age;
  
  User({required this.name, required this.age});
}

print(1 == 1) and print(10 == 01) as you would have guessed returns true and false respectively.

Line no. 5 returns false because, by default, dart checks for referential equality. To support value equality, we need to override the == operator and hashCode.

For lines 6–11, the expression evaluates to false. Methods like listEquals and mapEquals to compare the value equality or the DeepCollectionEquality from the collection package.

For print(1 == 1.0) and print(1 is double) there’s a lot more going on under the hood, visit this article for more.

Final Thoughts

So, Immutability and equality will help reduce unexpected bugs and make the code predictable. At the start, it might seem like a lot of work, but it’s worth the effort, in my opinion. In the next article, we will go in-depth into higher-order functions and recursion.

Awesome! Pat yourself on the back for reaching till the end. I hope I added some value to the time you invested. Find out more examples on the GitHub repository, and reach out on Twitter or LinkedIn for suggestions/Questions or any topic you’d like me to cover. You can support it by clapping👏, and thanks for reading :) Follow for more😄

Until next time, folks!

LET'S
CONNECT

LET'S
CONNECT

LET'S
CONNECT