Start Debugging

unawaited_futures и discarded_futures в Dart: какой линтер ловит какой вызов без await

unawaited_futures смотрит только внутрь async-тел, discarded_futures только внутрь синхронных, и оба пропускают стрелочные функции, tear-off и литералы коллекций. Разбор по случаям на Dart 3.12.2 и Flutter 3.44.8 и analysis_options.yaml, который я рекомендую.

Короткий ответ: два линтера делят работу по виду тела функции, в котором находится вызов. unawaited_futures сообщает о Future, потерянном внутри тела async. discarded_futures сообщает о Future (или FutureOr), потерянном внутри синхронного тела, а во Flutter это initState, dispose и каждый блок onPressed: () { ... }. На одной и той же строке они никогда не срабатывают вместе. Если включать только один, включайте unawaited_futures: он дешёвый, и каждое срабатывание, скорее всего, настоящая ошибка. Добавляйте discarded_futures, когда готовы писать unawaited(...) у каждого намеренного вызова “выстрелил и забыл” в коде виджетов. Всё ниже измерено с помощью dart analyze на Dart 3.12.2 (Flutter 3.44.8, meta 1.19.0).

Ни одно из правил не входит ни в наборы core и recommended пакета package:lints, ни в package:flutter_lints 6.0.0, поэтому свежий проект после flutter create не сообщает ни о том, ни о другом, пока вы их не включите.

Сравнение вкратце

unawaited_futuresdiscarded_futures
Где смотритинструкции внутри тел async / async*инструкции внутри синхронных тел
Какие типы сообщаетFuture и подтипы (включая TickerFuture)Future, подтипы и FutureOr
Доступно сдавно, до Dart 2.18Dart 2.18
В lints / flutter_lintsнет / нетнет / нет
Типичное срабатывание во FlutterNavigator.pushNamed(...) внутри onPressed: () async { ... }_load(); в initState, _controller.forward();
Подавляется через unawaited(...)дада
Подавляется через @awaitNotRequired (meta 1.17.0+)дада
Массовое исправление через dart fix --applyнет (только быстрое исправление в IDE)нет (только быстрое исправление в IDE)
Уровень шума во Flutter-приложениинизкийсредний или высокий

Почему правил два, а не одно

Оба правила представляют собой тонкие обёртки над одним и тем же посетителем, UnusedFuturesVisitor в pkg/linter/lib/src/util/unused_futures.dart. Посетитель обходит три вида узлов: инструкции-выражения, секции каскадов и интерполяции строк. Для каждого выражения он спрашивает у правила, “интересно” ли оно, и этот фильтр и есть вся разница между двумя линтерами. Вот фильтр из unawaited_futures.dart на теге 3.12.2:

// dart-lang/sdk, tag 3.12.2, pkg/linter/lib/src/rules/unawaited_futures.dart
isInteresting: (node) {
  var type = node.staticType;
  // This rule is not currently concerned with `FutureOr`.
  if (type == null || !type.isOrImplementsFuture) {
    return false;
  }
  // This rule is only concerned with code in async functions.
  return node.thisOrAncestorOfType<FunctionBody>()?.isAsynchronous ?? false;
},

А вот из discarded_futures.dart:

// dart-lang/sdk, tag 3.12.2, pkg/linter/lib/src/rules/discarded_futures.dart
isInteresting: (node) {
  var type = node.staticType;
  // This rule does concern itself with `FutureOr`.
  if (type == null || !type.isOrImplementsFutureOrFutureOr) {
    return false;
  }
  // This rule is only concerned with code in sync functions.
  return node.thisOrAncestorOfType<FunctionBody>()?.isSynchronous ?? false;
},

Из этого кода прямо следуют два вывода. Во-первых, решает ближайший охватывающий FunctionBody, а не внешний метод. Блочное замыкание () { save(); }, написанное внутри метода async, является синхронным телом, поэтому оно относится к discarded_futures. Во-вторых, FutureOr покрывается только на синхронной стороне. FutureOr<int>, потерянный внутри метода async, не будет замечен ни одним из правил.

Мотивация тоже разная. unawaited_futures нацелен на забытый await: вы уже находитесь в функции async, так что исправление занимает одно ключевое слово. discarded_futures нацелен на забытый async: синхронная функция, запускающая асинхронную работу, обычно означает, что сама функция должна была быть async, а её вызывающие должны были её дожидаться. Официальная документация discarded_futures говорит именно это и указывает на unawaited_futures как на правило, которое берёт управление, как только функция становится async.

Набор случаев: что и где срабатывает

Я собрал в один файл все формы вызова, какие смог придумать, и запустил dart analyze с обоими правилами поверх package:lints/recommended.yaml:

# analysis_options.yaml, Dart 3.12.2
include: package:lints/recommended.yaml
linter:
  rules:
    - unawaited_futures
    - discarded_futures
// Dart 3.12.2, meta 1.19.0
import 'dart:async';
import 'package:meta/meta.dart';

Future<void> save() async {}
FutureOr<int> maybeAsync() => 1;
@awaitNotRequired
Future<void> log(String m) async {}

class Repo {
  Future<void> flush() async {}
}

Future<void> asyncCaller() async {
  save(); // A1
  maybeAsync(); // A2 FutureOr in async
  final f = save(); // A3 assigned
  unawaited(save()); // A4
  log('x'); // A5 awaitNotRequired
  Repo()..flush(); // A6 cascade
  print('${save()}'); // A7 interpolation
  var list = [save()]; // A8 collection
  Future.delayed(Duration.zero, () => 1); // A9
  <String, Future<void>>{}.putIfAbsent('k', save); // A10
  void inner() {
    save(); // A11 sync closure inside async
  }
  [1].forEach((_) async {
    save(); // A12 async closure
  });
  save().then((_) {}); // A13 .then chain
  save().ignore(); // A14 ignore()
  await f;
  inner();
  list.length;
}

void syncCaller() {
  save(); // S1
  maybeAsync(); // S2 FutureOr in sync
  final f = save(); // S3 assigned
  unawaited(save()); // S4
  log('x'); // S5 awaitNotRequired
  Repo()..flush(); // S6 cascade
  takesFuture(save()); // S7 argument
  f.ignore();
}

Future<void> syncReturn() {
  return save(); // S8 returned
}

void takesFuture(Future<void> f) {}

void Function() handler = () => save(); // S9 arrow in sync closure
void Function() handler2 = () {
  save(); // S10 block sync closure
};
Future<void> Function() handler3 = () async {
  save(); // S11 async closure
};

Результаты, считанные с вывода dart analyze:

СлучайКодunawaited_futuresdiscarded_futures
A1save(); в asyncсообщает-
A2maybeAsync(); (FutureOr) в async--
A3, S3final f = save();--
A4, S4unawaited(save());--
A5, S5вызываемый метод с @awaitNotRequired--
A6Repo()..flush(); в asyncсообщает-
A7'${save()}' в asyncсообщает-
A8литерал коллекции [save()]--
A9Future.delayed(d, computation)--
A10Map.putIfAbsent--
A11блочное замыкание внутри метода async-сообщает
A12замыкание asyncсообщает-
A13save().then((_) {});сообщает (на then)-
A14save().ignore();--
S1save(); в синхронном коде-сообщает
S2maybeAsync(); в синхронном коде-сообщает
S6Repo()..flush(); в синхронном коде-сообщает
S7takesFuture(save());--
S8return save();--
S9стрелочное замыкание () => save()--
S10() { save(); }-сообщает
S11() async { save(); }сообщает-

Несколько строк заслуживают пары слов.

Слепые зоны, общие для обоих правил

Две строки в этой таблице являются настоящими ложноотрицательными срабатываниями, и обе отслеживаются в upstream.

Стрелочные тела (S9). () => save() является синхронным телом-выражением, которое возвращает Future. Посетитель смотрит только на инструкции, поэтому ничего не срабатывает, даже если замыкание присвоено void Function() и вызывающий код выбрасывает future. Это dart-lang/sdk#61155.

Tear-off в колбэки void. Я также запустил вот это:

// Dart 3.12.2
void execute(void Function() cb) => cb();

void tearoffs() {
  execute(save); // Future<void> Function() passed as void Function()
}

Ни одно из правил на это не сообщило. Dart позволяет передавать Future<void> Function() там, где ожидается void Function(), поэтому future теряется внутри execute, и ни один линтер его не видит. Этому посвящён dart-lang/sdk#56921. Родственный линтер, avoid_void_async, ловит только функции async, объявленные как возвращающие void, но не tear-off.

Добавьте литералы коллекций (A8) и FutureOr внутри тел async (A2), и получится полный список форм, в которых потерянный future остаётся незамеченным. unawaited на сегодня также не принимает FutureOr (dart-lang/sdk#63818), поэтому для S2 чистое решение: final _ = maybeAsync(); либо ожидание результата.

Как это выглядит в виджете Flutter

Именно это разделение определяет уровень шума в приложении на Flutter, потому что большинство колбэков виджетов синхронны. На Flutter 3.44.8 с обоими правилами поверх package:flutter_lints/flutter.yaml:

// Flutter 3.44.8, Dart 3.12.2
class _HomeState extends State<Home> with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(vsync: this);

  Future<void> _load() async {}

  @override
  void initState() {
    super.initState();
    _load(); // F1: discarded_futures
    _c.forward(); // F2: discarded_futures (TickerFuture implements Future)
  }

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      TextButton(
        onPressed: () => Navigator.of(context).push(
            MaterialPageRoute<void>(builder: (_) => const Home())), // F3: nothing
        child: const Text('a'),
      ),
      TextButton(
        onPressed: () {
          Navigator.of(context).pushNamed('/x'); // F4: discarded_futures
        },
        child: const Text('b'),
      ),
      TextButton(
        onPressed: () async {
          await _load();
          if (!context.mounted) return;
          Navigator.of(context).pushNamed('/x'); // F5: unawaited_futures
        },
        child: const Text('c'),
      ),
    ]);
  }
}

F2 раздражает больше всего. AnimationController.forward(), reverse(), repeat() и animateTo() возвращают TickerFuture, и почти никто их не ожидает. При включённом discarded_futures каждый из них требует unawaited(...). F1, напротив, это именно та ошибка, ради которой правило существует: _load() в initState выбрасывает свою ошибку в зону, а не в написанный вами try/catch, и если он вызывает setState после того, как виджета уже нет, вы попадаете на территорию проверок mounted.

F3 и F4 делают одно и то же. Сообщается только блочная форма, а значит, замена колбэка с { ... } на => заглушает discarded_futures, ничего не исправляя.

Когда выбирать unawaited_futures

Когда добавлять discarded_futures

Если добавить его в существующее приложение на Flutter, ожидайте, что большинство срабатываний придётся на вызовы AnimationController и навигацию в блочных колбэках. Ни у одного из правил в Dart 3.12.2 нет массового исправления через dart fix (dart fix --dry-run в моём воспроизведении ничего для них не предложил), поэтому, в отличие от миграций, которые можно применить по всему репозиторию с помощью dart fix, эта чистка делается одним быстрым исправлением в IDE за раз.

Пометка API как безопасных для отбрасывания через @awaitNotRequired

Для собственных API, задуманных как “выстрелил и забыл” (журналирование, аналитика, прогрев кеша), аннотируйте объявление, а не оборачивайте каждое место вызова:

// Dart 3.12.2, meta 1.19.0
import 'package:meta/meta.dart';

@awaitNotRequired
Future<void> track(String event) async {
  // send to analytics, errors handled inside
}

void onTap() {
  track('tap'); // neither rule reports this
}

@awaitNotRequired появился в package:meta 1.17.0, а в 1.18.1 его разрешили на typedef, так что параметр-колбэк, типизированный аннотированным typedef, тоже освобождается. Оба линтера учитывают его (A5 и S5 выше), а проверка обходит и переопределяемые члены, поэтому переопределение аннотированного метода остаётся освобождённым. Используйте его, только когда функция сама обрабатывает свои ошибки: аннотация сообщает анализатору, что отбрасывание future безопасно, и обеспечить это должны вы. Flutter 3.47 реэкспортирует аннотацию из package:flutter/foundation.dart; если пакет, который её использует, ломает вашу сборку на 3.44, см. исправление неопределённого имени awaitNotRequired.

Нюанс, который выбирает за вас

Если ваша кодовая база опирается на стрелочные колбэки (onPressed: () => doThing()), discarded_futures даёт меньше, чем можно судить по его шуму, потому что стрелочная форма для него невидима (S9, F3). В такой кодовой базе одного unawaited_futures плюс командного правила “асинхронная работа живёт в колбэках async” достаточно, чтобы получить основную часть пользы. Если же ваша кодовая база использует блочные колбэки и загрузчики в initState, то discarded_futures остаётся единственным правилом, которое вообще видит эти вызовы, и оно стоит возни с unawaited.

Разработчики на C# узнают эту проблему: это версия CS4014, “this call is not awaited” для Dart, только C# сообщает о ней лишь в методах async, то есть это половина unawaited_futures. Dart позволяет включить и другую половину.

Моя рекомендация

Включите unawaited_futures в каждом проекте на Dart и Flutter уже сегодня. Добавьте discarded_futures в пакеты и в приложения, где вы хотите, чтобы “выстрелил и забыл” было явным, и смиритесь с записью unawaited(_controller.forward()). Вот конфигурация, которую использую я:

# analysis_options.yaml, Flutter 3.44.8 / Dart 3.12.2
include: package:flutter_lints/flutter.yaml

linter:
  rules:
    - unawaited_futures
    - discarded_futures
    - avoid_void_async

avoid_void_async является дополнением: он сообщает о void bad() async {} и заставляет объявлять Future<void>, так что future существует в системе типов, где его видят два других правила (и ваши вызывающие). Он не закрывает брешь с tear-off, но не даёт создавать новые функции async, future которых никто не может ожидать.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад