Pull to refresh

Как избегать late-переменных в Dart

Level of difficultyMedium
Reading time9 min
Views1.8K

Что такое late-поле

Поля объектов можно инициализировать 4 способами:

class Foo {
  int a = 1;                  // 1) В месте объявления
  int b;
  int c;
  int d; // This is an error.

  Foo(this.b)                 // 2) В аргументе конструктора с `this`.
    : c = b + 1               // 3) В списке инициализации.
  {
    d = a + b;                // 4) Где угодно ещё.
  }
}
  1. a: в месте объявления (строка 2).

  2. b: в аргументе конструктора с this (строка 7).

  3. c: в списке инициализации конструктора, перед кодом в фигурных скобках (строка 8).

  4. d: где угодно ещё (строка 10).

Последний вариант не работает для не-nullable переменных. К тому моменту, когда код конструктора в фигурных скобках начнёт выполняться, объект уже должен быть полностью инициализирован так, что все его поля можно использовать, включая d в этом примере. Поэтому, если d не инициализирована в 1, 2 или 3, программа не скомпилируется.

Это можно поправить, если объявить d как late:

class Foo {
  int a = 1;
  int b;
  int c;
  late int d; // OK.

  Foo(this.b)
    : c = b + 1
  {
    d = a + b;
  }
}

Это значит "Я проинициализирую эту переменную потом". И ошибки нет.

Прочитайте документацию, как это работает:

Недостатки late-переменных

При компиляции нет проверки инициализации

Если забудете проинициализировать late-переменную, то узнаете об этом, только во время выполнения, когда программа выбросит исключение.

По сути вы говорите: "Доверься мне, что я инициализирую d где-нибудь до того, как её в первый раз прочитают. Поэтому компилятор пропускает проверку и добавляет код для проверки во время выполнения. Если удалить строку 10, никаких предупреждений не будет. Если прочитаете d до того, как она будет записана в первый раз, будет исключение во время выполнения.

Так теряется половина преимуществ null safety, потому что проверка инициализации при компиляции -- это половина пользы (другая половина -- невозможность записать null). Переменная late -- это почти то же самое, что nullable-переменная, которую вы читаете с !

Поэтому создание late-переменной -- это крайняя мера перед тем, как сделать её просто nullable, а не удобный способ передвинуть инициализацию из странного списка в конструкторе внутрь фигурных скобок конструктора.

На сильных уровнях оптимизации нет проверок во время выполнения

Инициализация late не проверяется даже во время выполнения, если собирать программу с флагом -O4. Это усложняет отладку.

Нужно больше памяти

Программа должна знать, была ли late-переменная инициализирована. Для этого компилятору часто необходимо дополнительное поле, которое занимает память.

Дополнительная проверка и 'if' перед каждым чтением

Каждый раз, когда читается late-переменная, нужно убедиться, что она инициализирована, поэтому добавляется дополнительная проверка.

Дополнительная запись при каждой записи

Каждый раз, когда записывается late-переменная, программа устанавливает внутренний флаг, что переменная инициализирована. Даже при повторной записи.

Почему используют late, и как без него обойтись

Дальше разберём причины, по которым многие используют late, и ка на самом деле без него обойтись для каждой причины. В конце я покажу, когда всё-таки лучше использовать late.

1. Функция других переданных значений

Одна из причин для late -- хранить поле, переданное в конструктор, и какое-то вычисленное из него значение:

class StringListLate {
  final List<String> strings;
  late int length;

  StringListLate(this.strings) {
    length = _calculateLength();
  }

  void add(String str) {
    strings.add(str);
    length = _calculateLength();
  }

  int _calculateLength() {
    int result = 0;
    for (final str in strings) {
      result += str.length;
    }
    return result;
  }
}

Нам точно нужен метод _calculateLength, чтобы вызвать его в add(). Чтобы не дублировать код, хочется вызвать этот же метод _calculateLength() в списке инициализации. Это это метод экземпляра, поэтому его можно вызвать только после инициализации объекта. Поэтому в примере выше length объявлено как late. Это плохо.

Как сделать лучше?

1.1. Двойная инициализация

Проще всего инициализировать нулём при объявлении:

class StringListInitialized {
  final List<String> strings;
  int length = 0;

  StringListInitialized(this.strings) {
    length = _calculateLength();
  }

  // ...
}

Но это работает только со скалярами и простейшими объектами, для которых есть const-конструкторы. У сложных объектов может не быть такого состояния, которое можно считать пустым, или конструирование такого объекта слишком дорого.

Ещё это добавляет одну запись, хотя оптимизатор может её исключить.

1.2. Статический метод

Другое решение -- сделать метод статическим. Тогда его можно будет вызвать до инициализации всех полей, потому что он не использует состояние объекта:

class StringListStatic {
  final List<String> strings;
  int length;                                                     // ИЗМЕНЕНО

  FooStatic(this.strings) : length = _calculateLength(strings);   // ИЗМЕНЕНО

  void add(String str) {
    strings.add(str);
    length = _calculateLength(strings);                           // ИЗМЕНЕНО
  }

  static int _calculateLength(List<String> strings) {             // ИЗМЕНЕНО
    int result = 0;
    for (final str in strings) {
      result += str.length;
    }
    return result;
  }
}

Заодно это убирает вторую запись length.

2. Создать объект, сохранить его и функцию от него

Вот ещё одна ситуация, в которой многие используют late. В этом примере мы создаём объект, сохраняем его в поле, а также сохраняем что-то производное от него в другом поле:

class Foo {
  final bar = Bar();
  late final int value = bar.value;
}

Здесь приходится использовать late , потому что иначе у bar нельзя прочитать value.

Когда используем late, инициализация value откладывается до момента, когда это значение впервые будет прочитано. Если это когда-то случится, то объект уже точно инициализирован к тому времени.

2.1. Геттер

Проще всего вообще не заводить вторую переменную, а использовать геттер вместо неё. Это сработает, если переменная никогда не расходится с тем полем, из которого мы её инициализируем. Так ещё и память экономится, потому что на одну переменную меньше:

class Foo {
  final bar = Bar();
  int get value => bar.value;
}

2.2. Метод-фабрика

Если вторая не финальная и может поменяться в каком-то другом месте, можно использовать метод-фабрику вместо конструктора:

class Foo {
  final Bar bar;
  int value;

  Foo._(this.bar, this.value);

  factory Foo() {
    final bar = Bar();
    return Foo._(bar, bar.value);
  }
}

В фабрике можно делать что угодно. Можно создать объект и передать в конструктор его и что-то производное от него -- двумя разными аргументами.

В этом примере используем приватный конструктор _, поэтому внешний код может создавать объекты только с помощью фабрики. Вызов такой фабрики ничем не отличается от вызова конструктора по умолчанию.

Если вы не знали про фабрики, прочитайте про конструкторы, включая фабрики.

2.3. Переадресующий конструктор

Можно сделать переадресующий конструткор. Он менее интуитивен, чем метод-фабрика:

class Foo {
  final Bar bar;
  int value;

  Foo() : this._(Bar());  // Переадресующий конструктор
  Foo._(this.bar) : value = bar.value;
}

Предлагаемое изменение в языке

Всё это было бы не нужно, если бы вот такой код работал:

class MyClass {
  final int a;
  final int b;
  final int c;

  MyClass(this.a)
    : b = a + 1,
      c = b + 1;  // Ошибка.
}

Но он выдаёт такую ошибку:

The instance member ‘b’ can’t be accessed in an initializer.

… и так устроен Dart сейчас. Есть предложение разрешить в списке инициализации ссылаться на переменные, которые уже инициализированы в списке раньше, чтобы код выше просто заработал. Если хотите, можете проголосовать за это предложение, потому что так команда Dart приоритизирует свою работу.

3. initState

Во Flutter иногда хочется проинициализировать что-то в состоянии виджета из свойств виджета. initState() -- это самое раннее, когда можно читать свойства из widget. Это значит, что всё инициализируемое в initState() должно быть или nullable, или late:

class MyWidget extends StatefulWidget {
  final int x;

  MyWidget(this.x);

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  late int y;

  @override
  void initState() {
    super.initState();
    y = widget.x + 1;
  }
  // ...
}

3.1. Аргументы в конструкторе State

Проще всего добавить аргументы в конструктор состояния:

class MyWidget extends StatefulWidget {
  final int x;

  MyWidget(this.x);

  @override
  State<MyWidget> createState() => _MyWidgetState(x);
}

class _MyWidgetState extends State<MyWidget> {
  int y;

  _MyWidgetState(int x) : y = x + 1;
  // ...
}

Есть правило линтера, которое запрещает это: no_logic_in_create_state

Его добавили, потому что люди путались в самой концепции StatefulWidget, как в этом вопросе. Это было до null-safety, поэтому я думаю, что сейчас аргументы в конструкторах состояний могут быть полезны.

4. Ожидание асинхронных операций

Иногда значение для поля недоступно в момент создания объекта, например, если нужно дождаться данных из сети. Можно сделать такое поле late и записать его, когда сетевой вызов завершится:

class MyBloc {
  final int id;
  late final MyModel model;

  MyBloc(this.id);

  Future<void> init() async {
    model = await getModelFromNetwork(id);
  }
  // ...
}

// Теперь создаём объект так:
final bloc = MyBloc(id);
await bloc.init();

Но тогда будут новые заботы:

  • Конструктор должен завершиться синхронно, поэтому для асинхронной инициализации нужен отдельный метод.

  • Или надо делать его публичным и надеяться, что клиент всегда будет его вызывать, или нужно поле в конструкторе, чтобы проверить, завершился ли уже вызов и можно ли безопасно читать поле.

4.1. Асинхронный статический метод

Вместо этого можно сделать статический метод, сделать всё ожидание в нём и вернуть готовый к использованию объект:

class MyBloc {
  final int id;
  final MyModel model;

  MyBloc._(this.id, this.model);

  static Future<MyBloc> create(int id) async {
    return MyBloc._(id, await getModelFromNetwork(id));
  }
  // ...
}

// Теперь создаём объект так:
final bloc = await MyBloc.create(id);

5. Локальные late-переменные

Локальные переменные тоже могут быть late, если их инициализация зависит от условия:

late int n;

if (condition) {
  n = 1;
  // Делаем что-то
} else {
  n = 2;
  // Делаем что-то
}

Здесь мы делаем какую-то полезную работу в ветках if и поэтому не можем заменить это на одну строку:
final n = condition ? 1 : 2;

5.1. Это работает и без late

Прсто удалите late в этом примере. Dart достаточно умён, чтобы с этим справиться. Подробнее читайте здесь.

6. Сослаться в выражении на его же результат

Этот код создаёт overlay entry и помещает туда виджет child. Оверлей закрывается, если на него нажать:

late OverlayEntry entry;

entry = OverlayEntry(
  builder: (context) => Stack(
    children: [
      Positioned.fill(
        child: GestureDetector(
          onTap: entry.remove, // Ссылаемся на результат всего выражения.
        ),
      ),
      child,
    ],
  ),
);

Overlay.of(context).insert(entry);

Это называется ссылаться в выражении на его же результат. Чтобы это работало, entry должна быть late. Иначе строка 8 вызывает ошибку.

6.1. Объект-посредник

Выражение, которое ссылается на свой же результат, сложно читать. Можно распутать этот пример. Для начала выделим overlay entry так, чтобы оно не знало, как ему себя скрыть, и код уже станет чище:

class DismissibleOverlayEntry extends StatelessWidget {
  final VoidCallback dismiss;
  final Widget child;

  const DismissibleOverlayEntry({required this.dismiss, required this.child});

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned.fill(
          child: GestureDetector(
            onTap: dismiss,
          ),
        ),
        child,
      ],
    );
  }
}

Теперь создадим объект-посредник. Проще всего взять ChangeNotifier:

final dismiss = ChangeNotifier(); // Посредник, чтобы дёрнуть вызов.

final entry = OverlayEntry(
  builder: (context) => DismissibleOverlayEntry(
    dismiss: dismiss.notifyListeners, // Предупреждение.
    child: child,
  ),
);

dismiss.addListener(entry.remove);
Overlay.of(context).insert(entry);

Посредник создаётся заранее и передаётся в выражение, чтобы оно уже не зависело от собственного результата.

И когда посредник получил уведомление, мы удаляем overlay entry.

К сожалению, ChangeNotifier.notifyListeners() имеет аннотацию @protected, поэтому мы получаем предупреждение, если вызовем его. Чтобы это исправить, чаще всего делают тривиальный подкласс:

class PublicNotifier extends ChangeNotifier {
  void notifyPublic() => notifyListeners();
}

Когда всё-таки лучше использовать late

Всё оправданное использование late, которое я встречал, крутится вокруг циклических ссылок.

В этом примере нужно сохранить StreamSubscription, чтобы потом отменить подписку. Но подписка создаётся только методом listen(), которому нужен callback, который должен ссылаться на this:

class A {
  final MyBloc bloc;
  late final StreamSubscription _streamSubscription;

  A(this.bloc) {
    _streamSubscription = bloc.states.listen(_onNewState);
  }

  void _onNewState(MyBlocState? state) {}

  void dispose() {
    _streamSubscription.cancel();
  }
  // ...
}

Альтернативой была бы nullable-переменная, потому что поле всё равно не особо используется:

class A {
  final MyBloc bloc;
  StreamSubscription? _streamSubscription; // ИЗМЕНЕНО

  A(this.bloc) {
    _streamSubscription = bloc.states.listen(_onNewState);
  }

  void _onNewState(MyBlocState? state) {}

  void dispose() {
    _streamSubscription?.cancel();         // Обратите внимание на '?'
  }
  // ...
}

Так мы потеряем final, и новому читателю придётся искать, меняется ли переменная где-то ещё. Поэтому late final кажется меньшим злом.

Другой частый пример -- TabController, которому нужен TickerProvider:

class _MyWidgetState extends State<MyWidget> with TickerProviderStateMixin {
  late final _tabController = TabController(length: 2, vsync: this);
  // ...
}

Код, который потом будет использовать контроллер, ожидает, что он не-null, поэтому лучше сделать его не-nullable, пусть даже late.

В любом случае, приложите все усилия, чтобы инициализировать late-переменную в точке объявления, как в примере с _tabController. Так хотя бы не будет промежутка, где переменная не инициализирована.

А как у вас?

Вы используете late по какой-то ещё причине, которую я не назвал? Или у вас есть другое решение, как от него избавляться? Напишите в комментарии, чтобы я добавил это в статью.

Tags:
Hubs:
Total votes 3: ↑3 and ↓0+3
Comments1

Articles