Avoiding late variables in Dart
What is a late field
Instance fields can be initialized in 4 ways:
a
: at the point of declaration (line 2).b
: as a constructor argument usingthis
(line 7).c
: in a constructor’s initializer list before the code in braces starts (line 8).d
: anywhere else (line 10).
The last one does not work for non-nullable variables. By the time the constructor body (the code in braces) starts, the object must be fully initialized so all its fields are usable, including d
in the example. So if d
is not initialized in 1
, 2
, or 3
, it fails to compile.
We can fix this by declaring d
to be late
:
It literally means “I will initialize it later”. And you got no error.
Read more on how this works:
- https://dart.dev/null-safety/understanding-null-safety#late-variables
- https://dart.dev/null-safety/understanding-null-safety#lazy-initialization
- https://dart.dev/null-safety/understanding-null-safety#late-final-variables
Disadvantages of late variables
No compile-time check for initialization
If you forget to initialize a late variable, you only know this at runtime when the program fails.
Essentially you say “Trust me to initialize d
somehow before it is read for the first time”. So the compiler skips the initialization check and inserts a runtime check. If we remove the line 10, we get no warning. If we try to read d
before it is set for the first time, we get a runtime error.
This defeats many points of null safety, because the compile time guarantee of initialization is half of its profit (the other half being the inability to write null
). A late variable is almost the same as declaring a variable to be nullable and then reading it with !
So introducing a late variable should be seen as the last resort before making it nullable, and not as a handy feature to move initialization from a weird initializer list into the constructor’s braces.
Runtime checks are skipped at higher optimization levels
The checks on late variables are skipped when building with -O4
flag. This makes such errors harder to debug.
Takes more memory
Your program needs to know if a late variable has been initialized or not. For this, the compiler often creates a hidden internal variable that takes memory.
Additional read and ‘if’ before each read
Every time you read a late variable the program must ensure it is initialized, so an extra check is added.
Additional write before each write
Every time you write to a late variable the program sets the internal flag to note that it is initialized.
Motivation for late, and how to avoid it
Next, we will dive into motivation for late variables and see how they could be avoided in each case. Finally, I will show when it pays to use late
variables.
1. A function of other passed values
One motivation for late
could be a field to store some function of other field values that are passed to the constructor:
We definitely need _calculateLength
method to call it in add()
. To avoid code duplication, it is tempting to also call the same _calculateLength()
in the initializer list. But we cannot call it there because it is an instance method and can only be called on a fully initialized object. So we might make length
late as in the example above, which is bad.
How to get it right?
1.1. Double initialization
Instead, the easiest fix is to initialize it at the definition anyway:
This only works with scalars and simplest objects with const
constructors. More complex objects may not have a state considered empty, or constructing such a state just to be overwritten is expensive.
This adds one extra write, although a compiler may optimize it away.
1.2. Static method
Another solution is to make this method static. It is alright to call static methods in the initializer list because we do not use the not-yet-constructed object:
This should work even faster as there is only one write related to length
.
2. Create an object, store it and a function of it
Here is another motivation for late
. In this example we create an object, store it in a field, and also store some function of it in another field:
Here we must use late
because otherwise the initializer of value
cannot refer to bar
.
If we use late
, the whole initialization of value
is deferred until the first time it is used. If it ever happens, the object is initialized by then so we can use any fields.
2.1. Getter
The easiest way is to not have the second variable at all and use a getter instead. It is possible if its value never diverts from its initializer, and the function to get it is as cheap as a field read. You even save memory by dropping the variable:
2.2. Factory method
If the second variable is not final and can be later updated from another source, use the factory method instead of the default constructor:
A factory can do anything. It may create the object and then call a constructor passing both the object and a derivative of it as two arguments.
Here we use a private constructor _
so the outer code can only create Foo
objects with the factory method. And the usage is no different than it is with the default constructor.
If you are new to this, read about constructors, including factory constructors.
2.3. Redirecting constructor
You can also use a redirecting constructor, which is a bit less intuitive to read than the factory method:
A possible language change
We would not need this whole section if we could just write:
So far this gives an error:
The instance member ‘b’ can’t be accessed in an initializer.
… and this is the design of Dart language now. There is a suggestion to allow the initializer list to use fields that were initialized earlier, so that the above code would just work. If you wish to see it happen, you may upvote that suggestion as this is how the Dart team prioritizes their work.
3. initState
In Flutter, you may want to initialize something in the state from the widget properties. initState()
is the earliest point where widget
can be accessed by the state. It means that anything initialized in initState()
must be either nullable or late:
3.1. Arguments in the State constructor
The easy fix is to add a state constructor with arguments:
There is a lint against that: no_logic_in_create_state
It was introduced because people were confused with the concept of a stateful widget itself, like in this question. That was before the introduction of null-safety, so I believe nowadays state constructors with arguments may have merit.
4. Awaiting asynchronous calls
Sometimes you cannot have a value for a final field immediately, e.g. you must wait for a network call. You may make the field late
and set it after the call completes:
This introduces a lot of new concerns:
- As the constructor must complete synchronously, we need a separate function for asynchronous initialization.
- Either we must make that function public and rely on clients to call and wait for it (as in the example), or we initiate it from the constructor and introduce a flag or a future for the client to know if it is complete and the object is safe to use.
4.1. Asynchronous static method
The fix is to create an asynchronous static method that does all the waiting and then initializes the object with complete data:
5. Late local variables
Local variables can also be late
if their initialization is based on a condition:
Here we also do some work under if
, and it prevents using a simple ternary form likefinal n = condition ? 1 : 2;
5.1. This actually works without late
Just remove late
from this example. Dart is smart enough to handle this. Read more about it here.
6. Refer to the result in an expression
This code creates an overlay entry and puts the child
widget in it. The overlay is dismissed when tapped:
This is called an expression referring to its own result. For this to work, the entry
variable must be late. Otherwise, the line 8 triggers an error.
6.1. Create an intermediary
An expression referring to its own result is hard to read. The example above can be refactored to untangle that. Start by extracting the overlay entry so that it is unaware of how it closes itself, which already feels cleaner:
Then create an intermediary. A ChangeNotifier
is the simplest option:
This intermediary is created in advance and then passed to the expression, so it does no longer refer to its own result.
Then you can remove the overlay entry when the intermediary is triggered.
Unfortunately, ChangeNotifier.notifyListeners()
is @protected
, so this usage triggers a warning. A common fix to that is to make a trivial subclass:
Cases with no good alternative to late
All legitimate uses of late
that I have seen boil down to circular references.
In the example below, we must save StreamSubscription
to cancel it later. The subscription is only created by listen()
method which needs a callback, and we need this callback to reference this
:
An alternative would be a nullable variable because the field is not used much anyway:
This way we lose final
, and a new reader must scan the code to see if the subscription ever changes. So late final
seems the lesser evil.
Another common example is TabController
, which needs TickerProvider
:
The code will expect the controller to be not null, so it’s better to have it non-nullable even if late
.
Anyway, do your best to initialize a late variable at its declaration, as _tabController
above. This does not create a gap when the variable is not initialized.
Give some feedback
Do you find yourself using late
fields for reasons not covered here? Do you have another workaround for any of the cases? Let me know in the comments, so I can add it here.