language icon indicating copy to clipboard operation
language copied to clipboard

Lazy initialization functionality of 'late' keyword should be split into a dedicated 'lazy' keyword

Open nebkat opened this issue 8 months ago • 6 comments

The late modifier is described as having two use cases:

  • Declaring a non-nullable variable that's initialized after its declaration.
  • Lazily initializing a variable.

I believe the latter should be given a dedicated keyword lazy to avoid confusion between these two use cases.


Take the following example:

class Foo {
  final Timer _timer;
  int counter = 0;

  Foo()
  : _timer = Timer.periodic(const Duration(seconds: 1), (timer) => counter++);

  // ❌ main.dart:9:68: Error: Can't access 'this' in a field initializer to read 'counter'.
}

Right, I can only access this inside the constructor:

class Foo {
  final Timer _timer;
  int counter = 0;

  Foo() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) => counter++);
  }

  // ❌ main.dart:9:5: Error: The setter '_timer' isn't defined for the class 'Foo'.
  // ❌ main.dart:5:15: Error: Final field '_timer' is not initialized.
}

Not going to work either - OK let's make it late final instead.

class Foo {
  late final Timer _timer;
  int counter = 0;

  Foo() {
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) => counter++);
  }

  // ✅ Everything works
}

But now I have a constructor body despite having no dependency on any constructor parameters! Let's follow the Effective Dart advice of "DO initialize fields at their declaration when possible" and move things around.

class Foo {
  late final Timer _timer = Timer.periodic(const Duration(seconds: 1), (timer) => counter++);
  int counter = 0;

  // ✅ Finally, this looks great!
  // ❓ But wait, the Timer no longer runs, what gives?
  // 🙁 My `late` variable just became a lazy variable
  // ❌ It won't get created until someone asks for it - which is never
}
  • This is particularly unintuitive behavior that stems from this dual use of the late keyword.
  • Once it happens there is no good way to debug (best case you come across a similar StackOverflow question).
  • Very easy to miss when learning because it only becomes a problem when lazy initializing something with side effects.
    • I had frequently used such patterns with StreamControllers(onListen: ...) not aware that I was lazy initializing (but no problem there)
  • Even the documentation for lazy initialization describes the use of the late modifier as "paradoxical"!

If a separate lazy keyword was introduced then late members would ideally be repurposed as non-constructor default initializers with access to this - but I suspect this will never be possible due to how it would affect existing code. Just deprecating late declarations with initializers would be a great improvement.

Another thing that would help even without introducing a new keyword would be statically detecting lazy variables which are not used anywhere to warn that the initializer will never get called (similarly to normal unused variables).

Lastly, at a minimum, I believe the documentation should be improved to note this danger.

nebkat avatar Apr 17 '25 00:04 nebkat

One problem you have to consider is that lazy evaluation is not limited to just late variables. Top level variables and static variables are lazily evaluated also.

Both of the following implementations have the same problem:

import 'dart:async';

// using top-level variables

final Timer _timer = Timer.periodic(
  const Duration(seconds: 1),
  (timer) => counter++,
);

int counter = 0;

void main() async {
  for (int i = 0; i < 5; i++) {
    await Future.delayed(Duration(seconds: 1));
    print(counter);
  }
}

output:

0
0
0
0
0

import 'dart:async';

// using static variables

class Foo {
  static final Timer _timer = Timer.periodic(
    const Duration(seconds: 1),
    (timer) => counter++,
  );

  static int counter = 0;
}

void main() async {
  for (int i = 0; i < 5; i++) {
    await Future.delayed(Duration(seconds: 1));
    print(Foo.counter);
  }
}

output:

0
0
0
0
0

mmcdon20 avatar Apr 17 '25 02:04 mmcdon20

So all top-level and class static variables are implicitly late?

I was not aware of that one either, though there it is described right above "Late variables":

Top-level and class variables are lazily initialized; the initialization code runs the first time the variable is used.

That should probably say "top-level and class static variables" (https://github.com/dart-lang/site-www/pull/3579) since the following does get initialized immediately:

class Foo {
  final Timer _timer = Timer.periodic(const Duration(seconds: 1), (timer) => print(timer.tick));
}

This seems like all the more reason to provide a more serious warning when those variables are not used, at least when their type is Timer, StreamSubscription, or other types that have side-effects.

nebkat avatar Apr 17 '25 13:04 nebkat

So all top-level and class static variables are implicitly late?

They are not implicitly late, but they are implicitly lazy.

I was not aware of that one either, though there it is described right above "Late variables":

Top-level and class variables are lazily initialized; the initialization code runs the first time the variable is used.

That should probably say "top-level and class static variables" (dart-lang/site-www#3579) since the following does get initialized immediately:

class Foo {
  final Timer _timer = Timer.periodic(const Duration(seconds: 1), (timer) => print(timer.tick));
}

Class variables means static members (as opposed to instance variables).

https://en.wikipedia.org/wiki/Class_variable

This seems like all the more reason to provide a more serious warning when those variables are not used, at least when their type is Timer, StreamSubscription, or other types that have side-effects.

Fair enough.

mmcdon20 avatar Apr 17 '25 15:04 mmcdon20

While you can see late as having two meanings, it really is the same behavior with two roles.

It's all about not needing to be initiallized when the variable is created. The rest is just how it's initiallized.

With an initializer expression, it's initiallized at the latest just been its read, so it cannot be read as uninitialized.

Without an initializer expression, it can be initiallized when read, what is a runtime error.

You can see the former as being "lazy" and the latter as being "late", but I'm not sure burning an extra keyword is worth it to make that distinction, when the presence of the initializer expression represents the exact same distinction.

lrhn avatar Apr 19 '25 14:04 lrhn

There are 3 closely related objectives:

  1. Late initialization
  2. Late initialization of instance members with access to this
  3. Lazy initialization

The current implementation combines 1 and 3 with the logic that an immediate assignment to a late variable would defeat the purpose of objective 1, therefore as a matter of convenience (for the language/compiler) we can treat that as objective 3.

What I do not like about this is that it gives a different outcome to:

late SideEffect foo;
foo = SideEffect();
// and
late SideEffect foo = SideEffect();

Without any knowledge of the language one would intuitively expect these to behave the exact same way - and it is this intuition which should guide the language, not the other way around. Quite a bit of reading is required before fully understanding the same behavior with two roles idea.

Objective 2, which would have been a great use of a late with immediate assignment, further complicates things. The documentation frequently recommends late variables as a way to have access to this, but usually in examples where there is a dependency on the constructor parameters.

Since most variables do not have side effects it is all set up to eventually have developers find out the hard way ☹


You can see the former as being "lazy" and the latter as being "late", but I'm not sure burning an extra keyword is worth it to make that distinction, when the presence of the initializer expression represents the exact same distinction.

The late keyword remains usable as an identifier so I would say it's a small cost to pay for improved developer intuition:

late SideEffect foo;
foo = SideEffect();

late SideEffect bar = SideEffect(); // ❌ Late variables cannot have an initializer

lazy SideEffect baz = SideEffect();

Ideally a third keyword or another solution for inline initialization with this, but that might be outside the scope of this discussion.

nebkat avatar Apr 21 '25 11:04 nebkat

i dont think theres any value in separating lazy and late. the difference between them is just the presence of an initializer.

Instead, what I think the real problem here is, is that we want to be able to initialize instance variables that require other instance variables at construction time without requiring that we use late nor a constructor body, which could be tedious or bug-prone if we have multiple constructors.

could we say that late is not required for those instance variables, and do something along the lines of:

class Foo {
  Foo();
  Foo.factory();
  // implicitly loaded immediately after construction, no matter what constructor was used.
  // users would not notice an outward difference between "slightly late" and normal non-late variables
  // if we don't like that, we could have an `@late` that is similar in utility to `@override` that indicates that its expected behavior, and maybe shuts up a lint warning about using an instance member in a non-late initializer like this
  final _timer = Timer.periodic(const Duration(seconds: 1), (_) => count++);
  int count = 0;
}
// equivalent to
class Foo {
  Foo() {
    _timer;
  }
  Foo.factory() {
    _timer;
  }
  late final _timer = Timer.periodic(const Duration(seconds: 1), (_) => count++);
  int count = 0;
}

basically, let there be, effectively, a second initialization step to load all implicitly late variables. variables explicitly marked late would retain existing semantics, and we cannot currently define non-late initializers that use the class, so it would be completely non-breaking.

we do not need another keyword, just expand existing behavior.

TekExplorer avatar May 14 '25 18:05 TekExplorer