Dart 2: El legado del `void`

Una de las preguntas que más veo en StackOverflow, Gitter e incluso en los canales de soporte internos de Google es la diferencia entre…

Mapa de supercúmulos cósmicos y vacíos, con el Vacío de Boötes y otros vacíos circledos.
Una representación semi-precisa del universo de tipos tipo-void en Dart 2

Una de las preguntas que más veo en StackOverflow, Gitter, e incluso en los canales de soporte internos de Google es la diferencia entre los siguientes tipos integrados en Dart 2: Object, dynamic, void, y Null.

Resumiendo, Null (o Bottom en otros lenguajes, es decir, "Nada") no debería usarse en la mayoría del código real de usuario, y sospecho que veremos más artículos y lints en el futuro cercano para desalentar su uso suavemente.

Los otros tres no son tan claros, porque en Dart 2 cualquier cosa puede ser dynamic, Object o void en tiempo de ejecución, variando solo por la firma de tipo static. Así que veamos algunos ejemplos prácticos de cuándo deberías usar cada firma de tipo.

Object

#

Object es la clase raíz de la jerarquía de clases de Dart, y cualquier otra clase en Dart es una subclase de Object — incluyendo tipos "primitivos" como int, double, o bool. Garantiza algunas cosas: una propiedad hashCode, un operador ==, un método toString.

Prácticamente hablando, uso Object como un tipo unión de pobre — esperando que los usuarios usen el operador is para determinar el tipo real de algo antes de usarlo. No uso dynamic, porque, como se describe en la siguiente sección, deshabilita el análisis estático importante y permite más fácilmente llegar a un estado inválido.

Object readProperty(String name) { ... }

void main() {
  var age = readProperty('name');
  if (age is int) {
    print('I am $age years old');
  } else if (age is String) {
    print(age);
  }
}

Otra opción es usar Object para declarar que no te importa cuál es el tipo interno de una estructura de datos, por ejemplo List<Object> podría significar "una lista de cualquier cosa". Esto es útil cuando, por ejemplo, escribes una función que combina el hashCode de cada elemento en una List:

int hashList(List<Object> elements) { ... }

Una propiedad agradable de Object (comparado con dynamic) es que obtendrás análisis inmediato y feedback del compilador si intentas llamar un método en él que no existe de forma fiable. Por ejemplo, lo siguiente produce un error estático:

void main() {
  Object a = 5;
  a.aMethodThatDoesNotExist();
}

En la práctica sin embargo, Object es bastante (e intencionalmente) limitado. Mi esperanza es que Dart obtenga soporte para sobrecarga de métodos y eso me permitirá disminuir drásticamente mi uso del tipo Object en código real.

dynamic

#

Personalmente nunca uso el tipo dynamic en Dart 2. Desde mi perspectiva, es una especie de unión de Object y una instrucción especial que le dice a las herramientas y compiladores que deshabiliten las verificaciones de análisis estático. Es decir, lo siguiente es legal, y solo presentará un error en tiempo de ejecución (¡no estáticamente!):

void main() {
  dynamic x = 5;
  x.aMethodThatDoesNotExist();
}

En Dart 1, dynamic estaba en todas partes, y cualquier otro tipo estático era para soporte de IDE y análisis estático — pero el compilador (y runtime) trataba todo como dynamic. Todavía hay algunos "gotchas" desafortunados en Dart 2 que pueden accidentalmente crear una variable de tipo dynamic, sin embargo:

computeAge() => 5; // Return type is dynamic

void main() {
  var name; // Static type is dynamic
  var animals = []; // Static and runtime type is List<dynamic>
}

Peor aún, las llamadas dynamic pierden información de tipos que es vital en Dart 2:

class User {
  String name;
}

void main() {
  var users = []; // Implicitly List<dynamic>, remember?
  users.add(new User()..name = 'Matan');

  // Runtime error: List<dynamic> is not a Iterable<String>
  Iterable<String> names = users.map((u) => u.name);
}

La razón de este error es porque la llamada real aquí es:

users.map((dynamic u) => u.name);

… lo cual no tiene suficiente información de tipo estático para producir un Iterable<String>. Al corregir users para tener el tipo adecuado (y evitar llamadas dynamic), todo funciona:

void main() {
  // We also could have written `var users = <User>[
  var users = [new User()..name = 'Matan'];

  // OK!
  Iterable<String> names = users.map((u) => u.name);
}

void

#

Por último, tenemos void, el tipo más nuevo en Dart 2. En Dart 1 void solo era utilizable como tipo de retorno de una función (como void main()), pero en Dart 2 ha sido generalizado, y es utilizable en otros lugares, por ejemplo Future<void>.

Un tipo void es semánticamente similar a Object (podría ser cualquier cosa), excepto con restricciones adicionales — un tipo void no puede usarse para nada (ni siquiera == o hashCode), y es inválido asignar algo a un tipo void:

void foo() {}

void main() {
  var bar = foo(); // Invalid
}

En la práctica, uso void para significar "cualquier cosa y no me importan los elementos" o, más comúnmente, para significar "omitido", como en Future<void> o Stream<void>:

/// Clear the cache.
Future<void> purgeCache() { ... }

En el fragmento de código anterior, no quiero que los usuarios intenten usar el valor de retorno del Future proporcionado, ya que no es relevante. He visto ejemplos de uso de Future<Null> para este propósito, y eso era en realidad un workaround antes de que Future<void> fuera posible.

Por ejemplo, esto es estáticamente OK, pero en tiempo de ejecución es inválido en Dart 2:

import 'dart:async';

Future<String> _doAThing() async => 'Test';
Future<Null> doAThing() async => _doAThing();

void main() async {
  // Future<String> is not a subtype of type FutureOr<Null>
  await doAThing();
}

… mientras que usar Future<void> para doAThing() es válido y correcto.

Otro ejemplo podría ser un Stream que se dispara sin ningún dato de evento:

/// Fires an event when a user signs-out of the system.
Stream<void> get onLogOut { ... }

Otro uso más práctico es implementar una clase con argumentos de tipo genérico que no vas a usar. Por ejemplo, al implementar el popular patrón Visitor, podemos ignorar el argumento de tipo C (context) cuando no es usado pasando void:

abstract class Visitor<N, C> {
  N visitNode(N node, [C context]);
}

class IdentityVisitor<N> extends Visitor<N, void> {
  @override
  N visitNode(N node, [_]) => node;
}

Espero que este breve artículo te ayude con decisiones de API sobre el uso de Object, dynamic, void. ¡Deja comentarios si tienes otras preguntas o ideas!

Más de Dart