Saltar al contenido principal

Dart eficaz: Uso

Pautas para utilizar las características del lenguaje con el fin de escribir código mantenible.

Puedes usar estas pautas todos los días en los cuerpos de tu código Dart. Puede que los usuarios de tu librería no noten que has interiorizado estas ideas, pero los mantenedores de la misma sin duda lo harán.

Bibliotecas

#

Estas pautas te ayudan a componer tu programa a partir de múltiples archivos de una manera consistente y mantenible. Para que estas pautas sean breves, utilizan "importar" para cubrir las directivas import y export. Las pautas se aplican por igual a ambas.

USA cadenas en las directivas part of

#

Regla de linter: use_string_in_part_of_directives

Muchos desarrolladores de Dart evitan usar part por completo. Les resulta más fácil razonar sobre su código cuando cada librería es un solo archivo. Si eliges usar part para dividir parte de una librería en otro archivo, Dart requiere que el otro archivo a su vez indique de qué librería forma parte.

Dart permite que la directiva part of use el nombre de una librería. Nombrar las librerías es una característica heredada (legacy) que ahora está desaconsejada. Los nombres de las librerías pueden introducir ambigüedad al determinar a qué librería pertenece una parte (part).

La sintaxis preferida es usar una cadena URI que apunte directamente al archivo de la librería. Si tienes alguna librería, my_library.dart, que contiene:

my_library.dart
dart
library my_library;

part 'some/other/file.dart';

Entonces el archivo part debe usar la cadena URI del archivo de la librería:

bien dart
part of '../../my_library.dart';

No el nombre de la librería:

mal dart
part of my_library;

NO importes librerías que estén dentro del directorio src de otro paquete

#

Regla de linter: implementation_imports

El directorio src bajo lib está especificado para contener librerías privadas para la propia implementación del paquete. La forma en que los mantenedores de paquetes gestionan las versiones de sus paquetes tiene en cuenta esta convención. Son libres de realizar cambios drásticos en el código bajo src sin que ello suponga un cambio de ruptura (breaking change) para el paquete.

Eso significa que si importas la librería privada de otro paquete, una versión de parche menor, teóricamente no disruptiva de ese paquete, podría romper tu código.

NO permitas que una ruta de importación entre o salga de lib

#

Regla de linter: avoid_relative_lib_imports

Una importación package: te permite acceder a una librería dentro del directorio lib de un paquete sin tener que preocuparte por dónde está almacenado el paquete en tu computadora. Para que esto funcione, no puedes tener importaciones que requieran que lib esté en alguna ubicación del disco relativa a otros archivos. En otras palabras, una ruta de importación relativa en un archivo dentro de lib no puede salir y acceder a un archivo fuera del directorio lib, y una librería fuera de lib no puede usar una ruta relativa para entrar en el directorio lib. Hacer cualquiera de las dos cosas conduce a errores confusos y programas rotos.

Por ejemplo, supongamos que tu estructura de directorios se ve así:

  • my_package/
    • lib/
      • api.dart
    • test/
      • api_test.dart

Y supongamos que api_test.dart importa api.dart de dos formas:

api_test.dart
mal dart
import 'package:my_package/api.dart';
import '../lib/api.dart';

Dart piensa que estas son importaciones de dos librerías completamente no relacionadas. Para evitar confundir a Dart y a ti mismo, sigue estas dos reglas:

  • No uses /lib/ en las rutas de importación.
  • No uses ../ para salir del directorio lib.

En su lugar, cuando necesites acceder al directorio lib de un paquete (incluso desde el directorio test del mismo paquete o cualquier otro directorio de nivel superior), usa una importación package:.

api_test.dart
bien dart
import 'package:my_package/api.dart';

Un paquete nunca debería salir (out) de su directorio lib e importar librerías de otros lugares del paquete.

PREFIERE rutas de importación relativas

#

Regla de linter: prefer_relative_imports

Siempre que la regla anterior no entre en juego, sigue esta. Cuando una importación no cruce el límite de lib, prefiere usar importaciones relativas. Son más cortas. Por ejemplo, supongamos que tu estructura de directorios se ve así:

  • my_package/
    • lib/
      • src/
        • stuff.dart
        • utils.dart
      • api.dart
    • test/
      • api_test.dart
      • test_utils.dart

Así es como las distintas librerías deberían importarse entre sí:

lib/api.dart
bien dart
import 'src/stuff.dart';
import 'src/utils.dart';
lib/src/utils.dart
bien dart
import '../api.dart';
import 'stuff.dart';
test/api_test.dart
bien dart
import 'package:my_package/api.dart'; // Don't reach into 'lib'.

import 'test_utils.dart'; // Relative within 'test' is fine.

Null

#

NO inicialices explícitamente variables en null

#

Regla de linter: avoid_init_to_null

Si una variable tiene un tipo no anulable, Dart informa un error de compilación si intentas usarla antes de que haya sido definitivamente inicializada. Si la variable es anulable, entonces se inicializa implícitamente en null por ti. No existe el concepto de "memoria no inicializada" en Dart ni necesidad de inicializar explícitamente una variable en null para estar "seguro".

bien dart
Item? bestDeal(List<Item> cart) {
  Item? bestItem;

  for (final item in cart) {
    if (bestItem == null || item.price < bestItem.price) {
      bestItem = item;
    }
  }

  return bestItem;
}
mal dart
Item? bestDeal(List<Item> cart) {
  Item? bestItem = null;

  for (final item in cart) {
    if (bestItem == null || item.price < bestItem.price) {
      bestItem = item;
    }
  }

  return bestItem;
}

NO uses un valor por defecto explícito de null

#

Regla de linter: avoid_init_to_null

Si haces opcional un parámetro anulable pero no le das un valor por defecto, el lenguaje utiliza implícitamente null como valor por defecto, por lo que no es necesario escribirlo.

bien dart
void error([String? message]) {
  stderr.write(message ?? '\n');
}
mal dart
void error([String? message = null]) {
  stderr.write(message ?? '\n');
}

NO uses true o false en operaciones de igualdad

#

Usar el operador de igualdad para evaluar una expresión booleana no anulable frente a un literal booleano es redundante. Siempre es más sencillo eliminar el operador de igualdad, y usar el operador de negación unaria ! si es necesario:

bien dart
if (nonNullableBool) {
   ...
}

if (!nonNullableBool) {
   ...
}
mal dart
if (nonNullableBool == true) {
   ...
}

if (nonNullableBool == false) {
   ...
}

Para evaluar una expresión booleana que es anulable, deberías usar ?? o una comprobación explícita != null.

bien dart
// If you want null to result in false:
if (nullableBool ?? false) {
   ...
}

// If you want null to result in false
// and you want the variable to type promote:
if (nullableBool != null && nullableBool) {
   ...
}
mal dart
// Static error if null:
if (nullableBool) {
   ...
}

// If you want null to be false:
if (nullableBool == true) {
   ...
}

nullableBool == true es una expresión viable, pero no debería usarse por varias razones:

  • No indica que el código tenga algo que ver con null.

  • Dado que no está evidentemente relacionada con null, se puede confundir fácilmente con el caso no anulable, donde el operador de igualdad es redundante y se puede eliminar. Eso solo es cierto cuando la expresión booleana de la izquierda no tiene posibilidad de producir null, pero no cuando sí la tiene.

  • La lógica booleana es confusa. Si nullableBool es null, entonces nullableBool == true significa que la condición se evalúa como false.

El operador ?? deja claro que está ocurriendo algo relacionado con null, por lo que no se confundirá con una operación redundante. La lógica también es mucho más clara; el resultado de que la expresión sea null es el mismo que el literal booleano.

El uso de un operador null-aware como ?? en una variable dentro de una condición no promueve la variable a un tipo no anulable. Si deseas que la variable sea promovida dentro del cuerpo de la sentencia if, es mejor usar una comprobación explícita != null en lugar de ??.

EVITA variables late si necesitas verificar si están inicializadas

#

Dart no ofrece ninguna forma de saber si una variable late ha sido inicializada o asignada. Si accedes a ella, ejecuta inmediatamente el inicializador (si tiene uno) o lanza una excepción. A veces tienes algún estado que se inicializa de forma diferida (lazy) donde late podría encajar bien, pero también necesitas poder saber si la inicialización ya ha ocurrido.

Aunque podrías detectar la inicialización almacenando el estado en una variable late y teniendo un campo booleano separado que rastree si la variable ha sido establecida, eso es redundante porque Dart mantiene internamente el estado de inicialización de la variable late. En su lugar, suele ser más claro hacer la variable no-late y anulable. Luego puedes ver si la variable ha sido inicializada comprobando si es null.

Por supuesto, si null es un valor inicializado válido para la variable, entonces probablemente sí tenga sentido tener un campo booleano separado.

CONSIDERA los patrones de promoción de tipos o de comprobación de nulos para usar tipos anulables

#

Comprobar que una variable anulable no es igual a null promueve la variable a un tipo no anulable. Eso te permite acceder a los miembros de la variable y pasarla a funciones que esperan un tipo no anulable.

Sin embargo, la promoción de tipos solo se admite para variables locales, parámetros y campos final privados. Los valores que están abiertos a manipulación no se pueden promover de tipo.

Declarar los miembros como private y final, como recomendamos generalmente, suele ser suficiente para eludir estas limitaciones. Pero esa no es siempre una opción.

Un patrón para eludir las limitaciones de la promoción de tipos es usar un patrón de comprobación de nulos (null-check pattern). Esto confirma simultáneamente que el valor del miembro no es nulo y vincula ese valor a una nueva variable no anulable de su mismo tipo base.

bien dart
class UploadException {
  final Response? response;

  UploadException([this.response]);

  @override
  String toString() {
    if (this.response case var response?) {
      return 'Could not complete upload to ${response.url} '
          '(error code ${response.errorCode}): ${response.reason}.';
    }
    return 'Could not upload (no response).';
  }
}

Otra alternativa es asignar el valor del campo a una variable local. Las comprobaciones de nulos en esa variable promoverán su tipo, por lo que puedes tratarla de forma segura como no anulable.

bien dart
class UploadException {
  final Response? response;

  UploadException([this.response]);

  @override
  String toString() {
    final response = this.response;
    if (response != null) {
      return 'Could not complete upload to ${response.url} '
          '(error code ${response.errorCode}): ${response.reason}.';
    }
    return 'Could not upload (no response).';
  }
}

Ten cuidado al usar una variable local. Si necesitas escribir de nuevo en el campo, asegúrate de no escribir en su lugar en la variable local. (Hacer que la variable local sea final puede prevenir tales errores). Además, si el campo pudiera cambiar mientras la variable local aún está en el alcance, entonces la variable local podría tener un valor desactualizado.

A veces es mejor simplemente usar ! en el campo. En algunos casos, sin embargo, usar una variable local o un patrón de comprobación de nulos puede ser más limpio y seguro que usar ! cada vez que necesites tratar el valor como no nulo:

mal dart
class UploadException {
  final Response? response;

  UploadException([this.response]);

  @override
  String toString() {
    if (response != null) {
      return 'Could not complete upload to ${response!.url} '
          '(error code ${response!.errorCode}): ${response!.reason}.';
    }

    return 'Could not upload (no response).';
  }
}

Cadenas (Strings)

#

Aquí tienes algunas mejores prácticas a tener en cuenta al componer cadenas en Dart.

USA cadenas adyacentes para concatenar literales de cadena

#

Regla de linter: prefer_adjacent_string_concatenation

Si tienes dos literales de cadena (no valores, sino la forma de literal con comillas reales), no necesitas usar + para concatenarlos. Al igual que en C y C++, simplemente colocarlos uno al lado del otro es suficiente. Esta es una buena manera de crear una sola cadena larga que no cabe en una sola línea.

bien dart
raiseAlarm(
  'ERROR: Parts of the spaceship are on fire. Other '
  'parts are overrun by martians. Unclear which are which.',
);
mal dart
raiseAlarm(
  'ERROR: Parts of the spaceship are on fire. Other ' +
      'parts are overrun by martians. Unclear which are which.',
);

PREFIERE usar interpolación para componer cadenas y valores

#

Regla de linter: prefer_interpolation_to_compose_strings

Si vienes de otros lenguajes, estarás acostumbrado a usar largas cadenas de + para construir una cadena a partir de literales y otros valores. Eso funciona en Dart, pero casi siempre es más limpio y corto usar interpolación:

bien dart
'Hello, $name! You are ${year - birth} years old.';
mal dart
'Hello, ' + name + '! You are ' + (year - birth).toString() + ' y...';

Ten en cuenta que esta pauta se aplica a la combinación de múltiples literales y valores. Está bien usar .toString() cuando conviertes únicamente un solo objeto a cadena.

EVITA usar llaves en la interpolación cuando no sean necesarias

#

Regla de linter: unnecessary_brace_in_string_interps

Si estás interpolando un identificador simple que no va seguido inmediatamente por más texto alfanumérico, se deben omitir las llaves {}.

bien dart
var greeting = 'Hi, $name! I love your ${decade}s costume.';
mal dart
var greeting = 'Hi, ${name}! I love your ${decade}s costume.';

Colecciones

#

Por defecto, Dart admite cuatro tipos de colecciones: listas, mapas, colas (queues) y conjuntos (sets). Las siguientes mejores prácticas se aplican a las colecciones.

USA literales de colección cuando sea posible

#

Regla de linter: prefer_collection_literals

Dart tiene tres tipos de colecciones principales: List, Map y Set. Las clases Map y Set tienen constructores no nombrados como la mayoría de las clases. Pero debido a que estas colecciones se usan con tanta frecuencia, Dart tiene una sintaxis integrada más agradable para crearlas:

bien dart
var points = <Point>[];
var addresses = <String, Address>{};
var counts = <int>{};
mal dart
var addresses = Map<String, Address>();
var counts = Set<int>();

Ten en cuenta que esta pauta no se aplica a los constructores nombrados de esas clases. List.from(), Map.fromIterable() y sus similares tienen sus usos. (La clase List también tiene un constructor no nombrado, pero está prohibido en Dart con null safe).

Los literales de colección son particularmente potentes en Dart porque te dan acceso al operador spread para incluir los contenidos de otras colecciones, y a if y for para realizar control de flujo mientras construyes los contenidos:

bien dart
var arguments = [
  ...options,
  command,
  ...?modeFlags,
  for (var path in filePaths)
    if (path.endsWith('.dart')) path.replaceAll('.dart', '.js'),
];
mal dart
var arguments = <String>[];
arguments.addAll(options);
arguments.add(command);
if (modeFlags != null) arguments.addAll(modeFlags);
arguments.addAll(
  filePaths
      .where((path) => path.endsWith('.dart'))
      .map((path) => path.replaceAll('.dart', '.js')),
);

NO uses .length para ver si una colección está vacía

#

Reglas de linter: prefer_is_empty, prefer_is_not_empty

El contrato de Iterable no requiere que una colección conozca su longitud o pueda proporcionarla en tiempo constante. Llamar a .length solo para ver si la colección contiene algo puede ser extremadamente lento.

En su lugar, existen getters más rápidos y legibles: .isEmpty y .isNotEmpty. Usa el que no requiera que niegues el resultado.

bien dart
if (lunchBox.isEmpty) return 'so hungry...';
if (words.isNotEmpty) return words.join(' ');
mal dart
if (lunchBox.length == 0) return 'so hungry...';
if (!words.isEmpty) return words.join(' ');

EVITA usar Iterable.forEach() con un literal de función

#

Regla de linter: avoid_function_literals_in_foreach_calls

Las funciones forEach() se usan ampliamente en JavaScript porque el bucle for-in integrado no hace lo que normalmente deseas. En Dart, si deseas iterar sobre una secuencia, la forma idónea de hacerlo es usar un bucle.

bien dart
for (final person in people) {
  ...
}
mal dart
people.forEach((person) {
  ...
});

Ten en cuenta que esta pauta dice específicamente "literal de función". Si deseas invocar alguna función ya existente en cada elemento, forEach() está bien.

bien dart
people.forEach(print);

También ten en cuenta que siempre está bien usar Map.forEach(). Los mapas no son iterables, por lo que esta pauta no se aplica.

NO uses List.from() a menos que pretendas cambiar el tipo del resultado

#

Dado un Iterable, hay dos formas obvias de producir una nueva List que contenga los mismos elementos:

dart
var copy1 = iterable.toList();
var copy2 = List.from(iterable);

La diferencia obvia es que la primera es más corta. La diferencia importante es que la primera preserva el argumento de tipo del objeto original:

bien dart
// Creates a List<int>:
var iterable = [1, 2, 3];

// Prints "List<int>":
print(iterable.toList().runtimeType);
mal dart
// Creates a List<int>:
var iterable = [1, 2, 3];

// Prints "List<dynamic>":
print(List.from(iterable).runtimeType);

Si deseas cambiar el tipo, entonces llamar a List.from() es útil:

bien dart
var numbers = [1, 2.3, 4]; // List<num>.
numbers.removeAt(1); // Now it only contains integers.
var ints = List<int>.from(numbers);

Pero si tu objetivo es solo copiar el iterable y conservar su tipo original, o si no te importa el tipo, entonces usa toList().

USA whereType() para filtrar una colección por tipo

#

Regla de linter: prefer_iterable_whereType

Supongamos que tienes una lista que contiene una mezcla de objetos y deseas obtener solo los enteros de ella. Podrías usar where() de esta manera:

mal dart
var objects = [1, 'a', 2, 'b', 3];
var ints = objects.where((e) => e is int);

Esto es verboso, pero, peor aún, retorna un iterable cuyo tipo probablemente no sea el que deseas. En el ejemplo aquí, retorna un Iterable<Object> a pesar de que probablemente quieras un Iterable<int> ya que ese es el tipo por el que estás filtrando.

A veces se ve código que "corrige" el error anterior agregando cast():

mal dart
var objects = [1, 'a', 2, 'b', 3];
var ints = objects.where((e) => e is int).cast<int>();

Eso es verboso y hace que se creen dos envoltorios (wrappers), con dos capas de indirección y comprobaciones de tiempo de ejecución redundantes. Afortunadamente, la librería principal tiene el método whereType() para este caso de uso exacto:

bien dart
var objects = [1, 'a', 2, 'b', 3];
var ints = objects.whereType<int>();

Usar whereType() es conciso, produce un Iterable del tipo deseado, y no tiene niveles innecesarios de envoltura (wrapping).

NO uses cast() cuando una operación cercana sea suficiente

#

A menudo, cuando tratas con un iterable o stream, realizas varias transformaciones en él. Al final, deseas producir un objeto con un determinado argumento de tipo. En lugar de añadir una llamada a cast(), verifica si una de las transformaciones existentes puede cambiar el tipo.

Si ya estás llamando a toList(), reemplázalo con una llamada a List<T>.from() donde T sea el tipo de lista resultante que deseas.

bien dart
var stuff = <dynamic>[1, 2];
var ints = List<int>.from(stuff);
mal dart
var stuff = <dynamic>[1, 2];
var ints = stuff.toList().cast<int>();

Si estás llamando a map(), dale un argumento de tipo explícito para que produzca un iterable del tipo deseado. La inferencia de tipos a menudo elige el tipo correcto por ti basándose en la función que pasas a map(), pero a veces necesitas ser explícito.

bien dart
var stuff = <dynamic>[1, 2];
var reciprocals = stuff.map<double>((n) => n * 2);
mal dart
var stuff = <dynamic>[1, 2];
var reciprocals = stuff.map((n) => n * 2).cast<double>();

EVITA usar cast()

#

Esta es la generalización más suave de la regla anterior. A veces no hay ninguna operación cercana que puedas usar para corregir el tipo de algún objeto. Incluso en esos casos, cuando sea posible evita usar cast() para "cambiar" el tipo de una colección.

Prefiere cualquiera de estas opciones en su lugar:

  • Créala con el tipo correcto. Cambia el código donde se crea por primera vez la colección para que tenga el tipo correcto.

  • Castea los elementos al acceder. Si iteras inmediatamente sobre la colección, castea cada elemento dentro de la iteración.

  • Castea ávidamente usando List.from(). Si eventualmente vas a acceder a la mayoría de los elementos de la colección, y no necesitas que el objeto esté respaldado por el objeto activo original, conviértelo usando List.from().

    El método cast() retorna una colección diferida (lazy) que comprueba el tipo de elemento en cada operación. Si realizas solo unas pocas operaciones en unos pocos elementos, esa evaluación diferida puede ser buena. Pero en muchos casos, la sobrecarga de la validación diferida y del empaquetado supera los beneficios.

Aquí hay un ejemplo de cómo crearla con el tipo correcto:

bien dart
List<int> singletonList(int value) {
  var list = <int>[];
  list.add(value);
  return list;
}
mal dart
List<int> singletonList(int value) {
  var list = []; // List<dynamic>.
  list.add(value);
  return list.cast<int>();
}

Aquí se muestra cómo castear cada elemento al acceder:

bien dart
void printEvens(List<Object> objects) {
  // We happen to know the list only contains ints.
  for (final n in objects) {
    if ((n as int).isEven) print(n);
  }
}
mal dart
void printEvens(List<Object> objects) {
  // We happen to know the list only contains ints.
  for (final n in objects.cast<int>()) {
    if (n.isEven) print(n);
  }
}

Aquí se muestra cómo castear ávidamente usando List.from():

bien dart
int median(List<Object> objects) {
  // We happen to know the list only contains ints.
  var ints = List<int>.from(objects);
  ints.sort();
  return ints[ints.length ~/ 2];
}
mal dart
int median(List<Object> objects) {
  // We happen to know the list only contains ints.
  var ints = objects.cast<int>();
  ints.sort();
  return ints[ints.length ~/ 2];
}

Por supuesto, estas alternativas no siempre funcionan, y a veces cast() es la respuesta correcta. Pero considera ese método un poco riesgoso y no deseable: puede ser lento y puede fallar en tiempo de ejecución si no tienes cuidado.

Funciones

#

En Dart, incluso las funciones son objetos. Aquí hay algunas mejores prácticas que involucran funciones.

USA una declaración de función para vincular una función a un nombre

#

Regla de linter: prefer_function_declarations_over_variables

Los lenguajes modernos se han dado cuenta de lo útiles que son las funciones locales anidadas y los closures. Es común tener una función definida dentro de otra. En muchos casos, esta función se usa como un callback inmediatamente y no necesita un nombre. Una expresión de función es excelente para eso.

Pero, si necesitas darle un nombre, usa una sentencia de declaración de función en lugar de vincular una lambda a una variable.

bien dart
void main() {
  void localFunction() {
    ...
  }
}
mal dart
void main() {
  var localFunction = () {
    ...
  };
}

NO crees una lambda cuando una referencia a función (tear-off) sea suficiente

#

Regla de linter: unnecessary_lambdas

Cuando te refieres a una función, método o constructor nombrado sin paréntesis, Dart crea una referencia a función (tear-off). Esto es un closure que recibe los mismos parámetros que la función e invoca la función subyacente cuando lo llamas. Si tu código necesita un closure que invoque una función nombrada con los mismos parámetros que acepta el closure, no envuelvas la llamada en una lambda. Usa una referencia a función (tear-off).

bien dart
var charCodes = [68, 97, 114, 116];
var buffer = StringBuffer();

// Function:
charCodes.forEach(print);

// Method:
charCodes.forEach(buffer.write);

// Named constructor:
var strings = charCodes.map(String.fromCharCode);

// Unnamed constructor:
var buffers = charCodes.map(StringBuffer.new);
mal dart
var charCodes = [68, 97, 114, 116];
var buffer = StringBuffer();

// Function:
charCodes.forEach((code) {
  print(code);
});

// Method:
charCodes.forEach((code) {
  buffer.write(code);
});

// Named constructor:
var strings = charCodes.map((code) => String.fromCharCode(code));

// Unnamed constructor:
var buffers = charCodes.map((code) => StringBuffer(code));

Variables

#

Las siguientes mejores prácticas describen cómo usar mejor las variables en Dart.

SIGUE una regla consistente para var y final en variables locales

#

La mayoría de las variables locales no deberían tener anotaciones de tipo y deberían declararse usando únicamente var o final. Existen dos reglas de amplio uso para decidir cuándo usar una u otra:

  • Usa final para variables locales que no se vuelven a asignar y var para aquellas que sí.

  • Usa var para todas las variables locales, incluso aquellas que no se reasignan. Nunca uses final para variables locales. (Por supuesto, se sigue fomentando el uso de final para campos y variables de nivel superior).

Cualquiera de las dos reglas es aceptable, pero elige una y aplícala de manera consistente en todo tu código. De esa manera, cuando un lector vea var, sabrá si significa que la variable se asigna más adelante en la función.

EVITA almacenar lo que puedes calcular

#

Al diseñar una clase, a menudo deseas exponer múltiples vistas de un mismo estado subyacente. Con frecuencia se ve código que calcula todas esas vistas en el constructor y luego las almacena:

mal dart
class Circle {
  double radius;
  double area;
  double circumference;

  Circle(double radius)
    : radius = radius,
      area = pi * radius * radius,
      circumference = pi * 2.0 * radius;
}

Este código tiene dos cosas mal. Primero, probablemente esté desperdiciando memoria. El área y la circunferencia, estrictamente hablando, son cachés. Son cálculos almacenados que podríamos recalcular a partir de otros datos que ya tenemos. Están cambiando mayor memoria por menor uso de CPU. ¿Sabemos si tenemos un problema de rendimiento que justifique ese intercambio?

Peor aún, el código es incorrecto. El problema con las cachés es la invalidación: ¿cómo sabes cuándo la caché está desactualizada y necesita ser recalculada? Aquí, nunca lo sabemos, a pesar de que radius es mutable. Puedes asignar un valor diferente y el area y la circumference mantendrán sus valores anteriores, ahora incorrectos.

Para manejar correctamente la invalidación de la caché, necesitaríamos hacer esto:

mal dart
class Circle {
  double _radius;
  double get radius => _radius;
  set radius(double value) {
    _radius = value;
    _recalculate();
  }

  double _area = 0.0;
  double get area => _area;

  double _circumference = 0.0;
  double get circumference => _circumference;

  Circle(this._radius) {
    _recalculate();
  }

  void _recalculate() {
    _area = pi * _radius * _radius;
    _circumference = pi * 2.0 * _radius;
  }
}

Eso es muchísimo código para escribir, mantener, depurar y leer. En su lugar, tu primera implementación debería ser:

bien dart
class Circle {
  double radius;

  Circle(this.radius);

  double get area => pi * radius * radius;
  double get circumference => pi * 2.0 * radius;
}

Este código es más corto, usa menos memoria y es menos propenso a errores. Almacena la cantidad mínima de datos necesarios para representar el círculo. No hay campos que se desincronicen porque solo existe una única fuente de verdad.

En algunos casos, es posible que necesites almacenar en caché el resultado de un cálculo lento, pero solo hazlo después de saber que tienes un problema de rendimiento, hazlo con cuidado, y deja un comentario explicando la optimización.

Miembros

#

En Dart, los objetos tienen miembros que pueden ser funciones (métodos) o datos (variables de instancia). Las siguientes mejores prácticas se aplican a los miembros de un objeto.

NO envuelvas un campo en un getter y setter innecesariamente

#

Regla de linter: unnecessary_getters_setters

En Java y C#, es común ocultar todos los campos detrás de getters y setters (o propiedades en C#), incluso si la implementación solo los reenvía al campo. De esa manera, si alguna vez necesitas hacer más trabajo en esos miembros, puedes hacerlo sin necesidad de tocar los lugares de llamada. Esto se debe a que llamar a un método getter es diferente a acceder a un campo en Java, y acceder a una propiedad no es binariamente compatible con acceder a un campo bruto en C#.

Dart no tiene esta limitación. Los campos y los getters/setters son completamente indistinguibles. Puedes exponer un campo en una clase y luego envolverlo en un getter y setter sin tener que tocar ningún código que use ese campo.

bien dart
class Box {
  Object? contents;
}
mal dart
class Box {
  Object? _contents;
  Object? get contents => _contents;
  set contents(Object? value) {
    _contents = value;
  }
}

PREFIERE usar un campo final para hacer una propiedad de solo lectura

#

Si tienes un campo que el código externo debería poder ver pero no asignar, una solución simple que funciona en muchos casos es simplemente marcarlo como final.

bien dart
class Box {
  final contents = [];
}
mal dart
class Box {
  Object? _contents;
  Object? get contents => _contents;
}

Por supuesto, si necesitas asignar internamente al campo fuera del constructor, es posible que necesites usar el patrón de "campo privado, getter público", pero no recurras a eso hasta que lo necesites.

CONSIDERA usar => para miembros simples

#

Regla de linter: prefer_expression_function_bodies

Además de usar => para expresiones de función, Dart también te permite definir miembros con él. Ese estilo se adapta bien a miembros simples que solo calculan y retornan un valor.

bien dart
double get area => (right - left) * (bottom - top);

String capitalize(String name) =>
    '${name[0].toUpperCase()}${name.substring(1)}';

A las personas que escriben código parece encantarles =>, pero es muy fácil abusar de él y terminar con código que es difícil de leer. Si tu declaración tiene más de un par de líneas o contiene expresiones profundamente anidadas (las cascadas y los operadores condicionales son los culpables habituales), hazte un favor a ti mismo y a todos los que tengan que leer tu código y usa un cuerpo de bloque y algunas sentencias.

bien dart
Treasure? openChest(Chest chest, Point where) {
  if (_opened.containsKey(chest)) return null;

  var treasure = Treasure(where);
  treasure.addAll(chest.contents);
  _opened[chest] = treasure;
  return treasure;
}
mal dart
Treasure? openChest(Chest chest, Point where) => _opened.containsKey(chest)
    ? null
    : _opened[chest] = (Treasure(where)..addAll(chest.contents));

También puedes usar => en miembros que no retornan un valor. Esto es común cuando un setter es pequeño y tiene un getter correspondiente que usa =>.

bien dart
num get x => center.x;
set x(num value) => center = Point(value, center.y);

NO uses this. excepto para redirigir a un constructor nombrado o para evitar el solapamiento (shadowing)

#

Regla de linter: unnecessary_this

JavaScript requiere un this. explícito para referirse a los miembros del objeto cuyo método se está ejecutando actualmente, pero Dart (al igual que C++, Java y C#) no tiene esa limitación.

Solo hay dos ocasiones en las que necesitas usar this.. Una es cuando una variable local con el mismo nombre solapa (shadows) al miembro al que deseas acceder:

mal dart
class Box {
  Object? value;

  void clear() {
    this.update(null);
  }

  void update(Object? value) {
    this.value = value;
  }
}
bien dart
class Box {
  Object? value;

  void clear() {
    update(null);
  }

  void update(Object? value) {
    this.value = value;
  }
}

La otra ocasión para usar this. es al redirigir a un constructor nombrado:

mal dart
class ShadeOfGray {
  final int brightness;

  ShadeOfGray(int val) : brightness = val;

  ShadeOfGray.black() : this(0);

  // This won't parse or compile!
  // ShadeOfGray.alsoBlack() : black();
}
bien dart
class ShadeOfGray {
  final int brightness;

  ShadeOfGray(int val) : brightness = val;

  ShadeOfGray.black() : this(0);

  // But now it will!
  ShadeOfGray.alsoBlack() : this.black();
}

Ten en cuenta que los parámetros del constructor nunca solapan a los campos en las listas de inicializadores del constructor:

bien dart
class Box extends BaseBox {
  Object? value;

  Box(Object? value) : value = value, super(value);
}

Esto parece sorprendente, pero funciona como deseas. Afortunadamente, el código como este es relativamente raro gracias a los parámetros formales de inicialización y a los superinicializadores.

INICIALIZA los campos en su declaración cuando sea posible

#

Si un campo no depende de ningún parámetro del constructor, puede y debe ser inicializado en su declaración. Requiere menos código y evita duplicaciones cuando la clase tiene múltiples constructores.

mal dart
class ProfileMark {
  final String name;
  final DateTime start;

  ProfileMark(this.name) : start = DateTime.now();
  ProfileMark.unnamed() : name = '', start = DateTime.now();
}
bien dart
class ProfileMark {
  final String name;
  final DateTime start = DateTime.now();

  ProfileMark(this.name);
  ProfileMark.unnamed() : name = '';
}

Algunos campos no se pueden inicializar en sus declaraciones porque necesitan hacer referencia a this (para usar otros campos o llamar a métodos, por ejemplo). Sin embargo, si el campo está marcado como late, entonces el inicializador puede acceder a this.

Por supuesto, si un campo depende de los parámetros del constructor, o se inicializa de manera diferente por diferentes constructores, entonces esta pauta no se aplica.

Constructores

#

Las siguientes mejores prácticas se aplican al declarar constructores para una clase.

USA parámetros formales de inicialización cuando sea posible

#

Regla de linter: prefer_initializing_formals

Muchos campos se inicializan directamente a partir de un parámetro del constructor, como:

mal dart
class Point {
  double x, y;
  Point(double x, double y) : x = x, y = y;
}

Tenemos que escribir x cuatro veces aquí para definir un campo. Podemos hacerlo mejor:

bien dart
class Point {
  double x, y;
  Point(this.x, this.y);
}

Esta sintaxis this. antes del parámetro de un constructor se denomina "parámetro formal de inicialización". No siempre puedes aprovecharlo. A veces deseas tener un parámetro nombrado cuyo nombre no coincida con el nombre del campo que estás inicializando. Pero cuando puedes usar parámetros formales de inicialización, deberías hacerlo.

NO uses late cuando una lista de inicializadores del constructor sea suficiente

#

Dart requiere que inicialices los campos no anulables antes de que puedan ser leídos. Dado que los campos se pueden leer dentro del cuerpo del constructor, esto significa que obtienes un error si no inicializas un campo no anulable antes de que se ejecute el cuerpo.

Puedes hacer que este error desaparezca marcando el campo como late. Eso convierte el error en tiempo de compilación en un error en tiempo de ejecución si accedes al campo antes de que se inicialice. Eso es lo que necesitas en algunos casos, pero a menudo la solución correcta es inicializar el campo en la lista de inicializadores del constructor:

bien dart
class Point {
  double x, y;
  Point.polar(double theta, double radius)
    : x = cos(theta) * radius,
      y = sin(theta) * radius;
}
mal dart
class Point {
  late double x, y;
  Point.polar(double theta, double radius) {
    x = cos(theta) * radius;
    y = sin(theta) * radius;
  }
}

La lista de inicializadores te da acceso a los parámetros del constructor y te permite inicializar campos antes de que puedan ser leídos. Por lo tanto, si es posible usar una lista de inicializadores, eso es mejor que hacer el campo late y perder algo de seguridad estática y rendimiento.

USA ; en lugar de {} para cuerpos de constructores vacíos

#

Regla de linter: empty_constructor_bodies

En Dart, un constructor con un cuerpo vacío se puede terminar simplemente con un punto y coma. (De hecho, es obligatorio para los constructores const).

bien dart
class Point {
  double x, y;
  Point(this.x, this.y);
}
mal dart
class Point {
  double x, y;
  Point(this.x, this.y) {}
}

NO uses new

#

Regla de linter: unnecessary_new

La palabra clave new es opcional al llamar a un constructor. Su significado no está claro porque los constructores factory significan que una invocación new podría no devolver realmente un objeto nuevo.

El lenguaje todavía permite new, pero considéralo obsoleto (deprecated) y evita usarlo en tu código.

bien dart
Widget build(BuildContext context) {
  return Row(
    children: [
      RaisedButton(child: Text('Increment')),
      Text('Click!'),
    ],
  );
}
mal dart
Widget build(BuildContext context) {
  return new Row(
    children: [
      new RaisedButton(child: new Text('Increment')),
      new Text('Click!'),
    ],
  );
}

NO uses const de manera redundante

#

Regla de linter: unnecessary_const

En contextos donde una expresión debe ser constante, la palabra clave const es implícita, no necesita escribirse y no debería hacerse. Esos contextos son cualquier expresión dentro de:

  • Un literal de colección const.
  • Una llamada a constructor const
  • Una anotación de metadatos.
  • El inicializador para una declaración de variable const.
  • Una expresión de caso switch: la parte justo después de case antes de los :, no el cuerpo del caso.

(Los valores por defecto no están incluidos en esta lista porque las versiones futuras de Dart pueden admitir valores por defecto que no sean const).

Básicamente, en cualquier lugar donde sería un error escribir new en lugar de const, Dart te permite omitir const.

bien dart
const primaryColors = [
  Color('red', [255, 0, 0]),
  Color('green', [0, 255, 0]),
  Color('blue', [0, 0, 255]),
];
mal dart
const primaryColors = const [
  const Color('red', const [255, 0, 0]),
  const Color('green', const [0, 255, 0]),
  const Color('blue', const [0, 0, 255]),
];

Manejo de errores

#

Dart utiliza excepciones cuando ocurre un error en tu programa. Las siguientes mejores prácticas se aplican al capturar y lanzar excepciones.

EVITA bloques catch sin cláusula on

#

Regla de linter: avoid_catches_without_on_clauses

Una cláusula catch sin calificador on captura cualquier cosa lanzada por el código en el bloque try. Es muy probable que el manejo de excepciones tipo Pokémon (Pokémon exception handling) no sea lo que deseas. ¿Tu código maneja correctamente StackOverflowError o OutOfMemoryError? Si pasas incorrectamente el argumento equivocado a un método en ese bloque try, ¿deseas que tu depurador te señale el error o prefieres que ese útil ArgumentError sea devorado? ¿Deseas que las sentencias assert() dentro de ese código prácticamente desaparezcan porque estás capturando los AssertionError lanzados?

La respuesta probablemente sea "no", en cuyo caso deberías filtrar los tipos que capturas. En la mayoría de los casos, deberías tener una cláusula on que te limite a los tipos de fallas en tiempo de ejecución de las que eres consciente y estás manejando correctamente.

En casos raros, es posible que desees capturar cualquier error en tiempo de ejecución. Esto suele ser en código de frameworks o de bajo nivel que intenta aislar el código arbitrario de la aplicación para que no cause problemas. Incluso en estos casos, suele ser mejor capturar Exception que capturar todos los tipos. Exception es la clase base para todos los errores en tiempo de ejecución y excluye los errores que indican fallos de programación (programmatic bugs) en el código.

NO descartes errores de bloques catch sin cláusula on

#

Si realmente sientes que necesitas capturar todo lo que se puede lanzar desde una región de código, haz algo con lo que captures. Regístralo (log), muéstralo al usuario o vuelve a lanzarlo (rethrow), pero no lo descartes silenciosamente.

LANZA objetos que implementen Error únicamente para errores programáticos

#

La clase Error es la clase base para errores programáticos. Cuando se lanza un objeto de ese tipo o una de sus subinterfaces como ArgumentError, significa que hay un error (bug) en tu código. Cuando tu API desea informar a un invocador que se está utilizando de manera incorrecta, lanzar un Error envía esa señal claramente.

Por el contrario, si la excepción es algún tipo de fallo en tiempo de ejecución que no indica un error en el código, lanzar un Error es engañoso. En su lugar, lanza una de las clases Exception principales u otro tipo.

NO captures explícitamente Error o tipos que lo implementen

#

Regla de linter: avoid_catching_errors

Esto se desprende de lo anterior. Dado que un Error indica un fallo de programación (bug) en tu código, debería desenredar toda la pila de llamadas (callstack), detener el programa e imprimir un rastreo de la pila (stack trace) para que puedas localizar y solucionar el error.

Capturar errores de estos tipos interrumpe ese proceso y oculta el fallo de programación. En lugar de añadir código de manejo de errores para lidiar con esta excepción a posteriori, regresa y corrige el código que está provocando que se lance en primer lugar.

USA rethrow para volver a lanzar una excepción capturada

#

Regla de linter: use_rethrow_when_possible

Si decides volver a lanzar una excepción, prefiere usar la sentencia rethrow en lugar de lanzar el mismo objeto de excepción usando throw. rethrow preserva el rastreo de la pila (stack trace) original de la excepción. throw, por otro lado, restablece el rastreo de la pila a la última posición desde la que se lanzó.

mal dart
try {
  somethingRisky();
} catch (e) {
  if (!canHandle(e)) throw e;
  handle(e);
}
bien dart
try {
  somethingRisky();
} catch (e) {
  if (!canHandle(e)) rethrow;
  handle(e);
}

Asincronía

#

Dart tiene varias características de lenguaje para admitir la programación asíncrona. Las siguientes mejores prácticas se aplican a la codificación asíncrona.

PREFIERE async/await en lugar de usar Futures en bruto

#

El código asíncrono es notoriamente difícil de leer y depurar, incluso cuando se usa una buena abstracción como los futures. La sintaxis async/await mejora la legibilidad y te permite usar todas las estructuras de control de flujo de Dart dentro de tu código asíncrono.

bien dart
Future<int> countActivePlayers(String teamName) async {
  try {
    var team = await downloadTeam(teamName);
    if (team == null) return 0;

    var players = await team.roster;
    return players.where((player) => player.isActive).length;
  } on DownloadException catch (e) {
    log.error(e);
    return 0;
  }
}
mal dart
Future<int> countActivePlayers(String teamName) {
  return downloadTeam(teamName)
      .then((team) {
        if (team == null) return Future.value(0);

        return team.roster.then((players) {
          return players.where((player) => player.isActive).length;
        });
      })
      .onError<DownloadException>((e, _) {
        log.error(e);
        return 0;
      });
}

NO uses async cuando no tenga ningún efecto útil

#

Es fácil adquirir el hábito de usar async en cualquier función que realice algo relacionado con la asincronía. Pero en algunos casos, es redundante. Si puedes omitir async sin cambiar el comportamiento de la función, hazlo.

bien dart
Future<int> fastestBranch(Future<int> left, Future<int> right) {
  return Future.any([left, right]);
}
mal dart
Future<int> fastestBranch(Future<int> left, Future<int> right) async {
  return Future.any([left, right]);
}

Los casos en los que async es útil incluyen:

  • Estás usando await. (Este es el caso obvio).

  • Estás retornando un error de forma asíncrona. async y luego throw es más corto que return Future.error(...).

  • Estás retornando un valor y deseas que se envuelva implícitamente en un future. async es más corto que Future.value(...).

bien dart
Future<void> usesAwait(Future<String> later) async {
  print(await later);
}

Future<void> asyncError() async {
  throw 'Error!';
}

Future<String> asyncValue() async => 'value';

CONSIDERA usar métodos de orden superior para transformar un Stream

#

Esto es paralelo a la sugerencia anterior sobre iterables. Los Streams admiten muchos de los mismos métodos y también manejan correctamente cosas como la transmisión de errores, el cierre, etc.

EVITA usar Completer directamente

#

Muchas personas nuevas en la programación asíncrona quieren escribir código que produzca un future. Los constructores en Future no parecen ajustarse a sus necesidades, por lo que eventualmente encuentran la clase Completer y la usan.

mal dart
Future<bool> fileContainsBear(String path) {
  var completer = Completer<bool>();

  File(path).readAsString().then((contents) {
    completer.complete(contents.contains('bear'));
  });

  return completer.future;
}

Completer se necesita para dos tipos de código de bajo nivel: nuevas primitivas asíncronas e interfaces con código asíncrono que no utiliza futures. La mayor parte del resto del código debería usar async/await o Future.then(), ya que son más claros y facilitan el manejo de errores.

bien dart
Future<bool> fileContainsBear(String path) {
  return File(path).readAsString().then((contents) {
    return contents.contains('bear');
  });
}
bien dart
Future<bool> fileContainsBear(String path) async {
  var contents = await File(path).readAsString();
  return contents.contains('bear');
}

USA pruebas para Future<T> al eliminar la ambigüedad de un FutureOr<T> cuyo argumento de tipo podría ser Object

#

Antes de poder hacer algo útil con un FutureOr<T>, normalmente necesitas hacer una comprobación is para ver si tienes un Future<T> o un simple T. Si el argumento de tipo es un tipo específico como en FutureOr<int>, no importa cuál prueba uses, is int o is Future<int>. Cualquiera funciona porque esos dos tipos son disjuntos.

Sin embargo, si el tipo de valor es Object o un parámetro de tipo que posiblemente podría instanciarse con Object, entonces las dos ramas se superponen. Future<Object> en sí implementa Object, por lo que is Object o is T donde T es algún parámetro de tipo que podría instanciarse con Object devuelve true incluso cuando el objeto es un future. En su lugar, realiza explícitamente la prueba para el caso de Future:

bien dart
Future<T> logValue<T>(FutureOr<T> value) async {
  if (value is Future<T>) {
    var result = await value;
    print(result);
    return result;
  } else {
    print(value);
    return value;
  }
}
mal dart
Future<T> logValue<T>(FutureOr<T> value) async {
  if (value is T) {
    print(value);
    return value;
  } else {
    var result = await value;
    print(result);
    return result;
  }
}

En el mal ejemplo, si le pasas un Future<Object>, lo trata incorrectamente como un valor síncrono en bruto.