Immutable Data Patterns in Dart and Flutter

By MONTY RASMUSSEN

Immutable data is data that cannot be modified after initialization. Immutable data is ubiquitous in the Dart language. In fact, most basic variable types work this way. For example, strings, numeric types, or Booleans, once created, cannot be changed again. The string variable itself does not contain string data; it is just a reference to the location of the string data in memory. References to non-final types to strings can be reassigned and can point to new string data, but once a string is created, the string data itself does not change, either in content or length:

var str = "This is a string.";
str = "This is another string.";
Copy the code

This code declares a string variable named (though defined by var, it can be inferred by type) STR. Between quotes and character data is a string, the data is placed in memory, and then the string is stored in memory to the address of the STR.

The second line creates a brand new string and reassigns the reference to the string’s memory location to the STR variable, overwriting the reference to the first string. But the original string does not change, and if you no longer use it in your code, the string will be marked as inaccessible, and eventually the Dart garbage collector will reclaim the string in memory.

There are many advantages to using immutable data patterns. Immutable data patterns are inherently thread-safe because once created, their contents cannot be changed, ensuring that the data is the same no matter what code accesses it. You can safely use this data by passing references to the data, and you don’t have to worry about protecting copies of the data from accidental changes. Using immutable data in a project makes it much simpler and easier to develop because data consistency and data security are no longer a concern.

We’ll start talking about data immutable features built into Dart by looking at the final Const keyword, which are two basic ways to declare immutable data.

This article’s code has been tested for Dart 2.8.4 and Flutter 1.17.5.

Final and Const Const constants

For starters, the distinction between the final and const keywords in Dart may be difficult. When we declare constants using final and const ourselves, it is important that we understand the difference between the two and know where they are used.

A variable declared by final can only be initialized once, and cannot be reassigned once it has been initialized with a value:

final str = "This is a final string.";
str = "This is another string.";  // error
Copy the code

Dart will no longer allow us to modify (reassign) the final declared variable STR once it is initialized. A final variable may depend on how the code is run to determine its final value, but it must be assigned at initialization time. Final variables are similar to regular variables in every respect except that reassignment is prohibited.

Constants in Dart are compile-time constants and are decorated with const. The value or state of a constant is determined at compile time. Constant values do not depend on how the code is run; once the program is running, they are frozen and cannot be changed again.

Constants in Dart have three main properties:

  • The immutability of a constant is dependent

For example, if you want to create a data structure of constant type (list, map, etc.), every element in the data structure must be a constant.

  • Constant values must be determined at compile time.

    For example, datetime.now () cannot be declared constant because it relies on data that is only available at run time to create itself.

    The properties of the SizedBox ina Flutter are of final type. Its constructor is also of constant type, so the SizedBox can be a constant: const SizedBox(width: 10 + 10). The code contains everything you need to construct the instance. The Dart compiler can perform simple math operations or string concatenation during compilation.

  • Constants cannot be recreated.

    For any given constant value, an object is created in memory no matter how many times the constant expression is evaluated. Constant objects can be reused as needed, but are never recreated.

Constant examples:

const str = "This is a constant string.";
const SizedBox(width: 10);  // a constant object
const [1, 2, 3];            // a constant collection
1 + 2;                      // a constant expression
Copy the code

The STR constant is assigned a string and is always a compile-time constant. The SizedBox instance created here can be immutable and Dart can set it before executing the program because all properties of the SizedBox are internally of type final and we initialize the parameters with values.

Constant data sets are also fine, as long as each element is also constant.

The expression 1 + 2 can be evaluated by the Dart compiler before executing the code, so it can also be treated as a constant.

Because constants are shared, data comparisons in Dart default to whether or not they are the same object, but comparisons between two seemingly independent instances of constants are equal because they refer to exactly the same object in memory:

List<int> get list => [1, 2, 3];
List<int> get constList => const [1, 2, 3];
var a = list;
var b = list;
var c = constList;
var d = constList;
print(a == b);  // false
print(c == d);  // true
Copy the code

Even though a, B, C, and D all refer to identical sets, only comparisons between const declared constants are considered equal. Dart compares references to sets, not elements in sets. Even though each call to get constList returns a set, But because the collection is declared const, DART actually initializes the collection into memory only once and returns the same reference each time.

Next, we will examine how the Flutter framework takes advantage of immutable data.

Immutable data in Flutter

There are many places that Flutter applications can use a data immutable model to improve readability or performance. Many classes in the Flutter framework are designed to be created with immutable modes. Two common examples are SizedBox and Text:

Row(
  children: <Widget>[
    const Text("Hello"),
    const SizedBox(width: 10),
    const Text("Hello"),
    const SizedBox(width: 10),
    const Text("Can you hear me?"),
  ],
)
Copy the code

Row has five children. When we use the const keyword to create instances of classes with const constructors (more on that later), these values are created at compile time and each unique value is stored in memory only once. The first two Text instances resolve to references to the same object in memory, as do the two SizedBox instances. If you were to add a const SizedBox(width: 15), a separate constant instance would be created for the new value.

You can also create instances using the new keyword instead of const. The result is the same, but if you want to reduce the memory footprint of your program or improve its performance, you are better off using const.

Let’s look at another Text example:

Final size = 12.0; const Text( "Hello", style: TextStyle( fontSize: size, // error ), )Copy the code

There’s a lot more to this code.

We’re trying to create a constant instance of Text, but note that the member inside this constant also needs to be a constant. The string “hello” will work even if we omit the const keyword. Similarly, Dart will try to create a constant TextStyle, Because it knows that TextStyle must be constant to be part of a constant Text instance. But because TextStyle depends on the variable size, it cannot be a constant and does not have a value until run time. This is where the editor tells you that there’s something wrong with this line of code. To fix this, you must either replace size with a constant reference or just use a number, such as 12.0. Of course, the same error will be reported if size is declared with final in advance.

Sometimes, you need to prevent data representing state in your application from being accidentally changed, and we’ll take a look at how the Dart language ensures this.

Create your own immutable data class

It is also easy to create an immutable class by declaring the constructor as const and modifying the variable as final.

class Employee {
  final int id;
  final String name;
  
  const Employee(this.id, this.name);
}
Copy the code

The Employee class has two properties, both declared final, and these properties are automatically initialized by the constructor. The constructor uses the const keyword to tell Dart that you can instantiate this class as a compile-time constant:

const emp1 = Employee(1, "Jon");
var emp2 = const Employee(1, "Jon");
final emp3 = const Employee(1, "Jon");
Copy the code

Here only one constant instance of Employee is created and a reference to it is assigned to each variable. For emp1, we do not need to declare the constructor via const, since we have already declared the reference to the variable using const, which implies that the constructor is decorated by const. Of course, it is ok to display the specification.

The EMP2 variable is a regular variable of type Employee, but we have specified a reference to an invariant constant object for it. The EMP3 variable is equal to emp2 because neither of them will be assigned a new reference. No matter how you use these two variables, no matter how you pass them, you can be sure that the id of this object is 1, the name inside this object is “Jon,” and if you check this variable in memory, you’ll always see the same thing.

Note that it is not common to declare a final type attribute ina data class private. They cannot be changed, and generally do not make sense by limiting read access to them. Of course, if you do have a reason to block access to these property pairs from other code, or if the class is user-neutral for internal state, you can also consider using a private declaration.

If you have successfully created a data immutable pair, is there anything in Dart that will help you understand that you have successfully created a data immutable pair? And then we look down.

1. Use annotations

You can use the @imutable annotation in the Meta package to help us analyze classes that we want to declare immutable, and then give warnings.

import 'package:meta/meta.dart';
@immutable
class Employee {
  int id;            // not final
  final String name;
  Employee(this.id, this.name);
}
Copy the code

The @immutable annotation does not make your class immutable, but in this case you will get a warning that one or more fields are not of final type. If you modify a constructor with const, but its attributes are mutable, you are also warned. You will also receive a warning if a class is modified by @immutable, but its subclasses are not immutable. Some attribute types can add additional complexity if they are declared immutable, such as objects and collections. Next, we’ll look at how to deal with these problems. Complex objects in immutable classes What if an employee’s name is represented by an object more complex than a string? Here’s an example:

class EmployeeName {
  String first;
  String middleInitial;
  String last;
  EmployeeName(this.first, this.middleInitial, this.last);
}
Copy the code

So Employee now looks like this:

class Employee {
  final int id;
  final EmployeeName name;
  const Employee(this.id, this.name);
}
Copy the code

In most cases, the employee class is used the same way as before, but with one major difference. Since we have not defined EmployeeName as an immutable class, its attributes may change after initialization:


var emp = Employee(1, EmployeeName('John', 'B', 'Goode'));
emp.name = EmployeeName('Jane', 'B', 'Badd');  // blocked
emp.name.last = 'Badd';                        // allowed
Copy the code

The Name attribute in Employee is of final type, so Dart disallows reassignment of it. However, the properties in EmployeeName are not protected in the same way, allowing you to change that data. If you intended employee data to be immutable, this could be an unexpected bug. To solve this problem, make sure that all the classes you use are immutable.

2. Unchanging data sets

Collections also present a challenge for immutable data. Even if fianl is used to modify a list or map, elements in a collection can be modified. In addition, lists and maps in Dart are themselves mutable and complex objects, so they can add, remove, or reorder their elements. Consider a simple example using chat message data:

class Message {
  final int id;
  final String text;
  const Message(this.id, this.text);
}
class MessageThread {
  final List<Message> messages;
  const MessageThread(this.messages);
}
Copy the code

Such classes declare that the data is relatively safe. Each message created is immutable, and once MessageThread is created, you cannot modify or replace elements inside the messages list. However, list collections can still be manipulated by external code:

final thread = MessageThread([
  Message(1, "Message 1"),
  Message(2, "Message 2"),
]);
thread.messages.first.id = 10;                 // blocked
thread.messages.add(Message(3, "Message 3"));  // Uh-oh. This works!
Copy the code

Probably not what you meant. So how can this be prevented? There are several ways to do this.

Returns a copy of this collection

If you don’t mind using a mutable copy of the collection, you can use the Dart getter to return a copy of the primary list every time you access it from outside the class:

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => _messages.toList();
  const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3"));  // new list
Copy the code

With this MessageThread class, the actual message list is private. This can only be set once through the constructor. Messages defines a getter called getter, which returns a copy of the _messages list. When external code calls the add() method of the list, it does so on a separate copy of the list, so the original table is not modified. While a new message data is added to the returned copy, the list in the MessageThread object remains the same.

This approach is simple, but not without its drawbacks. First, if the list is large or accessed too frequently, it will cause performance problems because the list will be shallow copied if messages are not accessed. Second, it can confuse users of the classes, as they appear to allow them to modify the original list. They may not yet know that the returned pair is a copy, which can cause some unexpected things to happen.

Returns a collection or view that cannot be modified

Another way to prevent changes to collections in data classes is to use getters to return versions that cannot be modified or views that cannot be modified:

class MessageThread {
  final List<Message> _messages;
  List<Message> get messages => List.unmodifiable(_messages);
  const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3"));  // exception!

Copy the code

This approach is very similar to the one discussed earlier. It still returns a copy of the collection, but now the copy we return is immutable. We use the factory method constructor to return a new collection. Now, when a user tries to add a new message to a copy of the list, an exception is thrown at run time and changes are prevented. This method is better, but there are still some disadvantages. The compiler did not warn that calling the add() method would fail at run time, and the user was not explicitly told that they were using a copy rather than a direct reference.

We can improve the method a bit using the UnmodifiableListView class in the DART: Collection library:

import 'dart:collection';
class MessageThread {
  final List<Message> _messages;
  UnmodifiableListView<Message> get messages =>
      UnmodifiableListView<Message>(_messages);
  const MessageThread(this._messages);
}
thread.messages.add(Message(3, "Message 3"));  // exception!
Copy the code

It is probably better to execute this way because UnmodifiableListView does not create a copy of the original collection. Instead, the raw data is wrapped in a view that prevents modification. Unfortunately, this approach also works at runtime. Although there are still some drawbacks, this approach is sufficient as a solution because it is sufficient for many situations.

What about other collection types? Other collections (such as maps and sets) also have the unmodifiable() factory constructor, and the Dart: Collection library provides the corresponding unmodifiable view for us to use.

There are a few other things to consider when trying to prevent changes to collections.

3. Truly immutable data sets

You may have noticed that we use the getter to return a copy of the original data, but the original data is still not immutable. While we can use the private type _messages to further secure the data, for some Virgoan developers, it may be desirable to keep the raw data immutable. One way to do this is to create an immutable version or view of the collection when constructing the MessageThread object:

class MessageThread { final List<Message> messages; const MessageThread._internal(this.messages); factory MessageThread(List<Message> messages) { return MessageThread._internal(List.unmodifiable(messages)); }}Copy the code

The first thing we need to do is hide the constant constructor from code outside the library. We make it private by changing it to a named constructor prefixed with an underscore. The messagethread._internal () constructor can do exactly the same job as our old default constructor, but it can only be accessed through internal code. Then, change the default public constructor constructor to the factory method constructor. The way a factory works static is very similar to a method in that the factory must explicitly return an instance of the class, rather than automatically, as a regular constructor does. This is a useful distinction, because here we need to make adjustments to the list of incoming messages before we are ready to use it as an initializer for the final property. The factory constructor copies the incoming list into the unmodifiable list, which is then passed to the private constructor that creates the instance. Users have no sensible choice because they create instances in the same way as before:

final thread = MessageThread([
  Message(1, "Message 1"),
  Message(2, "Message 2"),
]);
Copy the code

Such code still works, and no one knows that they are calling the factory constructor instead of the regular constructor without looking at the source code. This solution, by the way, is a bit like the singleton pattern in Dart. Now, the stored list collection becomes immutable, not even by code in the same class or library. However, for most apps, verifying data updates is what makes their functionality meaningful, so how do we securely update immutable data?

Update immutable data

Once you have all your application state safely stored in an immutable structure, you may wonder how to update it. A single instance of your class should not be mutable (at least externally), but its data state certainly needs to change. There are several different ways to do this, but here are just a few of them.

1. Use top-level functions

One of the most common ways to update immutable state is to use some kind of status update capability (library). In Redux, reducer was used. Similarly, when BLoC mode was used, there was a similar structure. Regardless of where the status update capability is implemented, it is generally responsible for taking input, executing business logic, and then outputting new state based on input and old state. Starting with the simplest example, let’s take a look at the previously introduced immutable Employee state update capability. Note that these functions are not part of the Employee class:

class Employee {
  final int id;
  final String name;
  const Employee(this.id, this.name);
}
Employee updateEmployeeId(Employee oldState, int id) {
  return Employee(id, oldState.name);
}
Employee updateEmployeeName(Employee oldState, String name) {
  return Employee(oldState.id, name);
}
Copy the code

This is the easiest way to do it, and it is useful to ensure that only supported updates are completed. Basically, each function references the previous employee state, then uses that data and the new data to construct an entirely new instance and returns it to the caller. This way, even a simple variable update requires a lot of boilerplate code.

Another disadvantage of this approach is that it presents some difficulties in refactoring. If you were to add, remove, or change any of the Employee attributes, you might end up needing to change many things.

This approach tends to keep the business logic separate from the data because the update functionality is often written in a completely different part of the code base. This can also be an advantage for some projects.

2. Class methods

If you want to manage state in a state class, you can use class methods instead of using top-level functions.

class Employee { final int id; final String name; const Employee(this.id, this.name); Employee updateId(int id) { return Employee(id, name); } Employee updateName(String name) { return Employee(id, name); }}Copy the code

With this approach, you can save yourself some tedious naming, since it is obvious that each update method belongs to the Employee class. Again, you no longer need to explicitly pass the old state because it assumes that the current instance is the old state. If you don’t look closely at the code, it looks like both update methods have the same code, but updateId() is creating a new Employee instance name using the passed ID parameter and old. The updateName() method does the opposite.

The disadvantage of this approach is that the logic for updating values is somewhat fixed and is tied directly to the status class. In some cases, this might be exactly what you want, and in other cases, neither approach matters.

Creating an update method for every attribute in an immutable class can be cumbersome. Next, we’ll look at a scenario that combines functionality into a single method.

3 copies,

A common practice with immutable data used in Dart and the Flutter project is to add copyWith methods to classes. It can make any policy you use simpler and more uniform:

class Employee { final int id; final String name; const Employee(this.id, this.name); Employee copyWith({int id, String name}) { return Employee( id ?? this.id, name ?? this.name, ); }}Copy the code

This copyWith() method should normally use named optional parameters that have no default value. The return statement uses the Dart if null operator?? To determine whether the employee’s copy should get a new value for each attribute or leave the value in the existing state. If the method receives a value ID, it will not be null, so the value will be used in the replica. This. id will be used if it does not exist or is explicitly set to null. The replication method is very flexible, allowing any number of properties to be updated in a single call.

Example usage copyWith() :

final emp1 = Employee(1, "Bob");
final emp2 = emp1.copyWith(id: 3);
final emp3 = emp1.copyWith(name: "Jim");
final emp4 = emp1.copyWith(id: 3, name: "Jim");
Copy the code

After executing this code, the EMP2 variable will reference a copy of EMP1 with an updated ID value, but the name will not change. The EMP3 copy will have a new name and the old ID. The EMP4 copy operation is the same as creating a new object entirely, because it replaces each value. Status update features or methods can take advantage of copyWith() to perform tasks that can greatly simplify your code:

 Employee updateEmployeeId(Employee oldState, int id) {
  return oldState.copyWith(id: id);
}
Employee updateEmployeeName(Employee oldState, String name) {
  return oldState.copyWith(name: name);
}
Copy the code

You might even think that using status updates here is overkill, since the copyWith() method is just a wrapper around creating a new object. In many cases, it is ok to allow external code to use the replication function directly, because there is no way to corrupt the original object’s data. When the properties of immutable classes are also immutable classes, you may need to nest calls to copyWith() to update the nested properties. Let’s talk about this case.

Update properties of complex objects

What if one or more of your properties are also immutable objects? The CopyWith method needs to be implemented for each dependent class.

class EmployeeName { final String first; final String last; const EmployeeName({this.first, this.last}); EmployeeName copyWith({String first, String last}) { return EmployeeName( first: first ?? this.first, last: last ?? this.last, ); } } class Employee { final int id; final EmployeeName name; const Employee(this.id, this.name); Employee copyWith({int id, EmployeeName name}) { return Employee( id: id ?? this.id, name: name ?? this.name, ); }}Copy the code

Employee now contains an attribute of type EmployeeName, and both classes are immutable and have copyWith() to facilitate updates. Using this setting, you can do the following if you need to update an employee’s last name:

final updatedEmp = oldEmp.copyWith(
  name: oldEmp.name.copyWith(last: "Smith"),
);
Copy the code

As you can see, both versions of copyWith() must be used in order to update an employee’s last name.

Update the collection

How you update an immutable collection depends both on how you set it up and how far you intend to go. To simplify the introduction, we use a simple data class:

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);
  NumberList(this._numbers);
}
Copy the code

This class has a mutable list, but exposes only a copy copy that cannot be modified to the outside world. To update the list, use the following methods:

NumberList addNumber(NumberList oldState, int number) { final list = oldState.numbers.toList(); return NumberList(list.. add(number)); }Copy the code

This method is not very effective. The expression oldState.numbers gives us a copy of the oldState list, but it is immutable, so we need to use it toList() to make another copy, which is mutable. Then, we create a new NumberList and pass it a copy of the list with the new number added. We use the Dart cascade operator,.. Add to the list before adding it to the constructor. We can try an update method:

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);
  NumberList(this._numbers);
  NumberList add(int number) {
    return NumberList(_numbers..add(number));
  }
}
Copy the code

There are many benefits to this approach. The implementation is less complex and requires less code. One thing to note is that we’re doing class reuse for _numbers. This approach can be implemented inside class code, and you may be happy with this approach, but it can have side effects for equality comparisons.

Some state management patterns generate state flow. Each time a new state is created (each update), the new state instance is fed into the stream and passed to the listening UI code. For maximum efficiency, you can check to see if the newly received state is actually different from the previous state. The code above our add() creates a new instance of NumberList instead of a new instance of _numbers. Depending on how equality comparisons are implemented, the comparison code might be fooled into thinking that we’re constantly producing the same state, because the list references _numbers stored there never change. For this and other reasons, some people want to recreate the list after each change:

class NumberList {
  final List<int> _numbers;
  List<int> get numbers => List.unmodifiable(_numbers);
  NumberList(this._numbers);
  NumberList add(int number) {
    return NumberList(_numbers.toList()..add(number));
  }
}
Copy the code

Solve this problem by adding toList() as it creates a copy of _numbers, adding the new value to that copy, and then returning a new instance of NumberList that contains our new updated list.

Four, conclusion

There are many ways to handle objects and collections immutable, and by now you should be familiar with some of the methods in Dart that prevent complex data from being accidentally changed. We didn’t do the code demo, but if you want to learn more about this, check out Dart packages such as build_value to learn more.

Welcome to follow the wechat public account “Flutter Programming and Development”.