Start Debugging

unawaited_futures vs discarded_futures no Dart: qual lint pega qual chamada sem await

unawaited_futures só olha dentro de corpos async, discarded_futures só dentro de corpos síncronos, e os dois deixam passar arrow functions, tear-offs e literais de coleção. Medido caso a caso no Dart 3.12.2 e no Flutter 3.44.8, com o analysis_options.yaml que eu recomendo.

Resposta curta: os dois lints dividem o trabalho de acordo com o tipo de corpo de função em que a chamada está. unawaited_futures reporta um Future descartado dentro de um corpo async. discarded_futures reporta um Future (ou FutureOr) descartado dentro de um corpo síncrono, o que no Flutter significa initState, dispose e todo bloco onPressed: () { ... }. Eles nunca disparam na mesma linha. Se você for habilitar só um, habilite unawaited_futures: é barato e cada ocorrência provavelmente é um bug. Adicione discarded_futures quando estiver disposto a escrever unawaited(...) em cada chamada fire-and-forget intencional no código de widgets. Tudo abaixo foi medido com dart analyze no Dart 3.12.2 (Flutter 3.44.8, meta 1.19.0).

Nenhuma das duas regras está no núcleo nem no conjunto recommended de package:lints, nem no package:flutter_lints 6.0.0, então um projeto novo criado com flutter create não reporta nenhuma delas até você ativá-las.

A comparação em resumo

unawaited_futuresdiscarded_futures
Onde olhastatements dentro de corpos async / async*statements dentro de corpos síncronos
Tipos que reportaFuture e subtipos (incl. TickerFuture)Future, subtipos e FutureOr
Disponível desdeantiga, anterior ao Dart 2.18Dart 2.18
Em lints / flutter_lintsnão / nãonão / não
Ocorrência típica no FlutterNavigator.pushNamed(...) dentro de onPressed: () async { ... }_load(); em initState, _controller.forward();
Silenciada por unawaited(...)simsim
Silenciada por @awaitNotRequired (meta 1.17.0+)simsim
Correção em massa com dart fix --applynão (apenas quick fix da IDE)não (apenas quick fix da IDE)
Nível de ruído em um app Flutterbaixomédio a alto

Por que existem duas regras em vez de uma

As duas regras são wrappers finos do mesmo visitor, UnusedFuturesVisitor, em pkg/linter/lib/src/util/unused_futures.dart. O visitor percorre três tipos de nós: expression statements, seções de cascata e interpolações de string. Para cada expressão, ele pergunta à regra se a expressão é “interessante”, e esse filtro é toda a diferença entre os dois lints. Este é o filtro de unawaited_futures.dart na tag 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;
},

E o de 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;
},

Duas consequências saem direto desse código. Primeiro, quem decide é o FunctionBody envolvente mais próximo, não o método externo. Um closure de bloco () { save(); } escrito dentro de um método async é um corpo síncrono, então pertence ao discarded_futures. Segundo, FutureOr só é coberto no lado síncrono. Um FutureOr<int> descartado dentro de um método async não é reportado por nenhuma das regras.

A motivação também difere. unawaited_futures mira o await esquecido: você já está em uma função async, então a correção é uma palavra-chave. discarded_futures mira o async esquecido: uma função síncrona que dispara trabalho assíncrono normalmente indica que a própria função deveria ter sido async e que quem a chama deveria esperar por ela. A documentação oficial de discarded_futures diz exatamente isso, e aponta para unawaited_futures como a regra que assume quando a função passa a ser async.

O arquivo de casos: o que dispara onde

Coloquei em um único arquivo todos os formatos de chamada que consegui imaginar e rodei dart analyze com as duas regras habilitadas sobre 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
};

Os resultados, lidos da saída do dart analyze:

CasoCódigounawaited_futuresdiscarded_futures
A1save(); em asyncreportado-
A2maybeAsync(); (FutureOr) em async--
A3, S3final f = save();--
A4, S4unawaited(save());--
A5, S5callee com @awaitNotRequired--
A6Repo()..flush(); em asyncreportado-
A7'${save()}' em asyncreportado-
A8literal de coleção [save()]--
A9Future.delayed(d, computation)--
A10Map.putIfAbsent--
A11closure de bloco dentro de um método async-reportado
A12closure asyncreportado-
A13save().then((_) {});reportado (em then)-
A14save().ignore();--
S1save(); em sync-reportado
S2maybeAsync(); em sync-reportado
S6Repo()..flush(); em sync-reportado
S7takesFuture(save());--
S8return save();--
S9closure arrow () => save()--
S10() { save(); }-reportado
S11() async { save(); }reportado-

Algumas linhas merecem uma frase cada.

Os pontos cegos que as duas regras compartilham

Duas linhas dessa tabela são falsos negativos reais, e ambos estão registrados no repositório upstream.

Corpos arrow (S9). () => save() é um corpo de expressão síncrono que retorna o Future. O visitor só olha para statements, então nada dispara, mesmo quando o closure é atribuído a um void Function() e o future é jogado fora por quem chama. Esse é o dart-lang/sdk#61155.

Tear-offs passados a callbacks void. Também rodei isto:

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

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

Nenhuma das regras reportou. O Dart permite um Future<void> Function() onde se espera um void Function(), então o future é descartado dentro de execute e nenhum lint o enxerga. O dart-lang/sdk#56921 cobre isso. O lint relacionado é avoid_void_async, que só pega funções async declaradas como retornando void, não tear-offs.

Some os literais de coleção (A8) e o FutureOr dentro de corpos async (A2) e você tem a lista completa dos formatos em que um future descartado passa sem ser reportado. unawaited também não aceita um FutureOr hoje (dart-lang/sdk#63818), então para S2 a correção limpa é final _ = maybeAsync(); ou aguardar o valor.

Como isso aparece em um widget Flutter

A mesma divisão é o que define o nível de ruído em um app Flutter, porque a maioria dos callbacks de widgets é síncrona. No Flutter 3.44.8 com as duas regras sobre 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 é o que mais irrita as pessoas. AnimationController.forward(), reverse(), repeat() e animateTo() retornam todos um TickerFuture, e quase ninguém os aguarda. Com discarded_futures ligado, cada um deles precisa de unawaited(...). F1, por outro lado, é exatamente o bug para o qual a regra existe: _load() em initState joga o erro na zone em vez de em um try/catch que você escreveu, e se ele chamar setState depois que o widget já saiu, você está no território da verificação de mounted.

F3 e F4 fazem a mesma coisa. Só a forma de bloco é reportada, o que significa que trocar um callback de { ... } para => silencia discarded_futures sem corrigir nada.

Quando escolher unawaited_futures

Quando adicionar discarded_futures

Se você a adicionar a um app Flutter existente, espere que a maioria das ocorrências sejam chamadas de AnimationController e navegação em callbacks de bloco. Nenhuma das regras tem correção em massa via dart fix no Dart 3.12.2 (dart fix --dry-run não propôs nada para elas no meu repro), então, diferente das migrações que você pode aplicar no repositório inteiro com dart fix, essa limpeza é feita com um quick fix da IDE por vez.

Marcando APIs como seguras para descartar com @awaitNotRequired

Para as suas próprias APIs projetadas para fire-and-forget (log, analytics, aquecimento de cache), anote a declaração em vez de envolver cada ponto de chamada:

// 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 chegou no package:meta 1.17.0, e o 1.18.1 passou a permiti-lo em typedefs, então um parâmetro de callback tipado com um typedef anotado também fica isento. Os dois lints o respeitam (A5 e S5 acima), e a verificação também percorre os membros sobrescritos, então uma sobrescrita de um método anotado continua isenta. Use apenas quando a função trata os próprios erros: a anotação diz ao analisador que descartar o future é seguro, e cabe a você tornar isso verdade. O Flutter 3.47 reexporta a anotação de package:flutter/foundation.dart; se um pacote que a usa quebrar o seu build do 3.44, veja a correção do nome indefinido awaitNotRequired.

A pegadinha que escolhe por você

Se a sua base de código depende de callbacks arrow (onPressed: () => doThing()), discarded_futures entrega menos do que o seu ruído sugere, porque a forma arrow é invisível para ele (S9, F3). Nessa base de código, unawaited_futures sozinho mais uma regra do time de “trabalho assíncrono vai em callbacks async” já dá a maior parte do valor. Se a sua base usa callbacks de bloco e loaders em initState, discarded_futures é a única regra que enxerga essas chamadas, e vale o esforço dos unawaited.

Quem vem de C# vai reconhecer o formato do problema: é a versão Dart do CS4014, “this call is not awaited”, só que o C# o reporta apenas em métodos async, que é a metade do unawaited_futures. O Dart permite que você ative a outra metade.

Minha recomendação

Ative unawaited_futures em todo projeto Dart e Flutter hoje. Adicione discarded_futures em pacotes e em apps em que você quer que o fire-and-forget seja explícito, e aceite escrever unawaited(_controller.forward()). Esta é a configuração que eu uso:

# 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 é o complemento: ele reporta void bad() async {} e obriga você a declarar Future<void>, de modo que o future exista no sistema de tipos onde as outras duas regras (e quem chama) conseguem enxergá-lo. Ele não corrige a lacuna dos tear-offs, mas impede que você crie novas funções async cujos futures ninguém consegue aguardar.

Relacionados

Fontes

Comments

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

< Voltar