Future<void> vs Future<Null>, ¿cuál es la diferencia?

Una de las mejoras que pudimos hacer en Dart 2 (además de mejor verificación estática, seguridad de tipos en runtime, new/const opcional…

Una de las mejoras que pudimos hacer en Dart 2 (además de mejor verificación estática, seguridad de tipos en tiempo de ejecución, new/const opcional, mejoras en las librerías core, y más) es formalizar void de una forma que es tanto más útil como menos propensa a errores. Esto es especialmente limpio en programación asíncrona donde puedes escribir Future<void> para funciones asíncronas que no retornan una respuesta cuando su trabajo se completa. Antes de esto probablemente usabas Future<Null>, así que preguntas comunes que nos hacen son: ¿Cuál es la diferencia entre Future<void> y Future<Null>? ¿Cuál debería usar, y cuándo?

Meme retro de un hombre gesticulando junto al texto 'VOID*'.

Como mi objetivo aquí es ser útil, voy a empezar con un TLDR.

TL;DR: Quieres usar el tipo void el 99.99% de las veces.

También recomiendo que actives dos reglas de lint relacionadas con void en tus proyectos, ahora mismo:

  • prefer_void_to_null: Te ayuda a no volver a la costumbre de escribir el mayormente anticuado Null.

  • void_checks: Te da semántica de voidness que puede resultarte más intuitiva, aunque no son estrictamente necesarias para código seguro.

El resto de este artículo no es lectura ligera. Es una combinación de historia, casos límite, y teoría de tipos. Lo que estoy a punto de escribir es como intentar explicar un Monad, pero haré lo mejor que pueda, y espero que me sigas en el recorrido.

Trivia rápida sobre Future<Null>

#

Para demostrar el valor que podrías obtener de leer este post, te planteo una pregunta de trivia.

Digamos que nos limitamos a dos Futures — un Future<User> y un Future<Null>. ¿Qué hacen las siguientes líneas de código?

// does this fail in your editor?
Future<User> f1 = Future<Null>.value(null);

// does this fail at runtime?
Future<Null> f2 = Future<User>.value(0);

¿Ayuda si cambiamos a awaits?

User u = await Future<Null>.value(null);
Null n = await Future<User>.value(0);

pauses for dramatic effect

La respuesta es que ninguna de estas cuatro líneas mostrará errores en tu editor, a menos que hayas desactivado los downcasts implícitos. Así es, tomar un Future<Null> y pretender que es algo que no es, como Future<User>, es 100% legal. ¡Este no es el caso con Future<void>!

También de forma poco intuitiva, la segunda y cuarta línea fallan en tiempo de ejecución (no es "seguro" descartar el resultado de un Future de esta manera), y las otras tienen éxito silenciosamente.

Estos comportamientos deberían hacerte sentir incómodo. ¡Pero no te preocupes! Ya conoces la solución: usa Future<void>. Voy a explicar por qué este comportamiento poco intuitivo funciona así.

Cómo funciona void ahora

#

Debatí mucho conmigo mismo sobre cuál sería el mejor orden para este artículo. Hay muchos conceptos que explicar aquí. Sin embargo, intentaré ir en orden de información más útil a información menos útil. Por lo tanto, siendo void mucho más útil que Null en Dart 2, tiene sentido empezar con la semántica de void de Dart 2.

La idea fundamental detrás de void en Dart se deriva del objetivo de que un valor void no debería ser usado.

void f() {}
print(f()); // error!

¡Esto es esperemos lo que esperas que pase! Con eso, hasta ahora todo bien.

Vale la pena notar que lograr este objetivo y abrir void como un tipo bastante normal puede ser un poco extraño:

f(void argument) {
  print(argument); // Error!
  // we can receive the argument, but we may not use it!
}

Y aquí es donde, en Dart 2, nuestro nuevo "void generalizado" empieza a ponerse interesante, y potente…aunque a veces confuso.

Te planteo la pregunta…¿qué tipo de valor podríamos pasar como argumento de f?

f(void argument) { … }
f(x); // what is x?

La respuesta a esto es…¡cualquiera! Porque no te dejamos usar el valor de x dentro de la función f, podemos decir con confianza que no hay valor que x pueda tomar que pueda causar errores en tiempo de ejecución.

f(1); // will have no difference compared to
f(“foo”); // will have no difference compared to
f([1, 2, 3]); // will have no difference compared to
// ...

Si derivamos la mejor semántica para void basándonos en la idea de que es un valor que no será usado, esto es lo que obtenemos. Lo que significa que es un valor que puede ser llenado con absolutamente cualquier cosa. Es como un vacío: una entrada sin salida.

Ahora, hay casos donde algo siempre está libre de errores en tiempo de ejecución, pero aún debería causar errores estáticos. ¡Este es un gran caso para un lint! Y de hecho esta es la idea detrás del lint void_checks. Busca lugares donde pasas cualquier cosa que no sea null a una ubicación void, y animaría a los equipos a activarlo. No es necesario para la seguridad, pero pasar cualquier cosa que no sea null a una ubicación void aún es probablemente un accidente, que el lint marcará para ti.

Dado que todo esto está basado en teoría de tipos fundamental, void funciona bien con cada parte de Dart, incluso en continuaciones que aprovechan la inferencia de tipos en el contexto de un Future<void>:

Future<void>().then((x) {
  print(x); // error! x has type “void” so you can’t print it!
  // This is what we want!
});

// and yes, this is an error too:
Foo f = await voidFuture();

En este punto, el desarrollador promedio probablemente sabe todo lo que necesita saber sobre void para usarlo efectivamente.

GIF de reacción de un robot con el subtítulo 'Congratulations. You are being rescued'.

Si aún me estás siguiendo, sin embargo, hay algunos detalles más interesantes.

Incluso con void_checks activado, las ubicaciones void no tendrán garantizado que contengan null. Toma este override:

class A {
  void f(Object o) {}
}

class B extends A {
  @override
  Object f(Object o) => o;
}

No queremos hacer este override ilegal, porque es seguro, útil, y sería un cambio que rompería compatibilidad con Dart 1. Y así nos vemos obligados a aceptar que las ubicaciones void pueden contener absolutamente cualquier valor. Tampoco podemos "optimizar fuera" el valor que A.f() retorna, porque en tiempo de ejecución puede ser un B!

Más bien, tenemos la opción inteligente de hacer void un tipo hermano de Object. Después de todo, puede contener cualquier valor, y todos los valores son Objects. Esto no es algo que diseñamos, es simplemente la realidad. Reconocer esa realidad lo convierte en algo que podemos aprovechar.

Texto del meme: 'Preparaos. La compatibilidad hacia atrás viene'.

Al hacer void un hermano de Object, no nos dejamos ningún requisito de que un valor void quede completamente sin usar. Intentamos mantener void para sí mismo, pero relajamos deliberadamente estas restricciones en algunos lugares por compatibilidad hacia atrás:

dynamic f() => voidFn(); // this is legal
voidFn() as dynamic; // this is legal

Estos son casos especiales que serían legales para un Object, y los hicimos legales también para void, en interés de hacer de Dart 2 un lanzamiento más suave.

Hacer void un hermano de Object también significa que void puede usarse como parámetro de tipo sin inflar la salida compilada (sin "template specialization," para los de C++). Esta reducción es buena para mantener las apps web y de Flutter pequeñas, a expensas de permitir lo siguiente:

<void>[1, 2, 3].toString(); // this is legal and prints [1, 2, 3]

Y por último, puede parecer inútil tipar un parámetro como void, especialmente dado que es una forma de Object. Sin embargo, los valores void pueden ser pasados a ubicaciones void locations:

f(void x) { … }
f(voidFn()); // this is legal

Esto es útil para secuenciación, que por ejemplo es usada por Mockito al hacer mock de métodos que retornan void. (Reemplaza f con when en el código anterior para una aproximación más cercana.)

En resumen:

  • El tipo void es un hermano de Object.

  • Casi siempre, un objeto void no puede ser usado.

  • Algo marcado como void podría ser, en la práctica, absolutamente cualquier cosa.

  • Cualquier cosa puede ser "descartada" en una ubicación marcada como void, y el lint void_checks restringe este comportamiento.

  • Un valor void puede ser pasado a otras ubicaciones void.

El tipo bottom

#

Antes de hablar sobre Null, es crítico que hablemos sobre el tipo "bottom".

Este es un tipo que ocurre naturalmente en teoría de tipos, que tiene una definición académica corta con algunas aplicaciones prácticas.

Si dejas de leer esta sección porque es tan extrañamente raro, esa es fuerte evidencia de mi TLDR original. A menos que quieras que tu código sea igual de raro, probablemente quieres void. Ahora exploremos este raro agujero de conejo y veamos qué tan profundo llega, ¿de acuerdo?

El tipo bottom es el subtipo de todos los tipos. En términos orientados a objetos más simples, eso significa que es un Person. Y un Car. Y un Animal. Y cualquier otro tipo de cualquier programa jamás escrito.

Si esto suena absurdo, es porque lo es. Me gusta pensar en ello como un tipo "marcador de posición". Pero tipo "absurdo" o "imaginario" también sería un nombre algo razonable. En cambio, se llama el tipo bottom, porque está en el fondo de la jerarquía de tipos, que, en informática, está al revés. ¯_(ツ)_/¯. Formalmente, se puede referir por el símbolo ⊥, que también puedes reconocer como el símbolo de "falso."

Si intentas imaginar un valor que es un Person y un Car y un Animal, no podrás pensar en nada. Y sorprendentemente, ¡de ahí viene el uso práctico de ⊥!

¿Qué tal si escribo una función que nunca retorna? Hay dos formas fáciles de hacerlo:

loopForever() {
  while(true); // first way
}

alwaysThrow() {
  throw Exception(); // second way
}

¿Cuál es el mejor tipo de retorno para estas dos funciones?

Depende. Como la función nunca retorna, el tipo de retorno realmente no importa. Puedes usar cualquier tipo — incluso el absurdo tipo bottom, que puede usarse de varias formas en varios lenguajes.

Texto del meme: 'No puedes tener un error en tiempo de ejecución en código que nunca se ejecuta'.

C++ tiene esto como noreturn. Rust tiene !, y Scala tiene Nothing. Pero mi ejemplo favorito viene de Haskell, que tiene una función undefined muy comúnmente usada que aborta el programa. Retorna, lo adivinaste, el tipo bottom.

Si asumimos una función undefined en Dart como la de Haskell, que simplemente lanza una excepción al ser llamada y retorna ⊥, entonces se vería así:

Foo foo = cond ? val : undefined();

En la línea de uso de ejemplo, el programa puede ejecutarse de forma segura y almacenar val cuando cond es true. Y cuando cond es false, el programa lanzará una excepción durante undefined(). Es seguro "almacenar" el resultado de undefined() en foo, independientemente del tipo de foo, ¡porque ese almacenamiento nunca ocurrirá realmente!

undefined() ****aquí no retorna nada. Pero la lección aquí no es que podamos hacer foo vacío…sino que el tipo bottom es vacío como una promesa vacía. Es más vacío que vacío. Simplemente nunca pasa.

Una cosa que debo tener cuidado de declarar es que puedes y, dependiendo del uso, a menudo deberías retornar void de estas funciones en la práctica. Usualmente código como return loopForever() es más probable que sea un error que un patrón útil. Sin embargo, la elección es tuya.

El tipo bottom también es útil para listas vacías de solo lectura. En Dart, los Lists son covariantes, así que un List<int> puede usarse como un List<Object>. Si luego intentas meter un String dentro de ese List<Object>, una verificación en tiempo de ejecución lo detectará y lanzará un error.

Eso significa que si hacemos un List de ⊥, no hay nada que podamos poner dentro, pero podemos tratarlo como una lista de cualquier cosa:

List<int> intList = <⊥>[];
  for(int i in intList) {
  print(i * 2); // valid, because this simply never happens
}

Hay casos aún más interesantes cuando miras lo que se llama colocación "contravariante" del tipo bottom.

Primer plano de un meme de una persona gesticulando con la palabra 'Contravariance'.

Digamos que definimos una función que tiene un parámetro ⊥:

void f(⊥ x) {}

Esto es casi lo opuesto al ejemplo de undefined(). En lugar de una función que nunca retorna, hemos declarado una función que no puede ser llamada. Ningún valor real es asignable a ese parámetro x. No puedes pasar un Person porque no es también un Car, y no puedes pasar un Car porque no es también un Person. Lo único que podrías pasar es el tipo absurdo mismo:

f(undefined());

Pero como acabamos de ver, undefined() nunca retorna, así que f() aún no es llamada realmente en este caso.

Tipar un parámetro como ⊥ puede parecer inútil, pero tiene valor esotérico porque todas las funciones que pueden ser llamadas son subtipos de funciones que no pueden serlo. **(Piénsalo: una función que puede ser llamada no tiene que serlo. Y si una función no es llamada, no puede producir errores en tiempo de ejecución.)

Si aún estás conmigo, respira profundo y date una palmadita en la espalda.

Específicamente, es seguro hacer cast de cualquier Function(X) a un Function(⊥), para cualquier X. Esto es mejor que usar el tipo Function omnipresente de Dart, porque es más específico.

Con esto puedes, por ejemplo, almacenar cualquier función unaria en un campo y llamarla dinámicamente, para saltarte los errores estáticos y reemplazarlos con errores en tiempo de ejecución si cometiste un error:

Function(⊥) f;
f = (int x) => x + 1;

// the validity of 123 as an argument to f is checked at runtime
(f as dynamic)(123);

Este es un truco ingenioso para ayudarte en un apuro.

Ahora podemos hablar sobre Null.

Null en Dart 2

#

El tipo conceptual "bottom" (un tipo que es subtipo de todos los tipos) existe en Dart, pero eso no es todo. ¡Tal valor también existe en Dart 2! En Dart lo llamamos Null, y ese valor es — lo adivinaste — null.

Dado que puedo hacer cualquier cosa null(/Null), el tipo absurdo no es tan absurdo en Dart. Lo que lo complica un poco.

Nota: El valor null, por supuesto, no es un Car, y no es un Person. Y sí recibimos peticiones por tipos non-nullable en Dart. Así que si hacemos tal cambio a Dart, necesitaremos un nuevo tipo bottom, probablemente llamado algo como Nothing, y será un tipo bottom más verdadero entonces.

No solo Null tiene todos los mismos usos absurdos que el tipo bottom, sino que también tiene una vía de escape. Si alguna vez realmente necesitas retornar de una función que no puede retornar, o llamar a una función que no puede ser llamada, ¡te damos una salida! Puedes pasar null ahí. Sería, honestamente, un poco injusto de nuestra parte hacer lo contrario, cuando miras el panorama completo.

Sin embargo, da muchas advertencias a una declaración por lo demás simple:

Null nothing() => null;

Null es el tipo más específico para foo(), así que es una elección lógica y un buen punto de partida. El analyzer también es lo suficientemente amable para advertirte si llamas métodos en él que no existen:

nothing().x; // error! No member x on Null

Pero esto puede dar una falsa ilusión de seguridad.

nothing().toString(); // no error: Null defines toString()
Foo foo = nothing(); // no error: foo will become null

// Acceptable for all parameter types in f(x),
// runtime errors if f(x) expects non-null
f(nothing());

Si tu objetivo es que nothing() sea un sinónimo de null, entonces este sería el comportamiento que quieres, y Null es el tipo correcto para tu función que retorna null.

Había buenas razones para sugerir Future<Null>, y funcionó mientras desarrollábamos la nueva semántica de void. Sin embargo, tiene los mismos agujeros:

(await futureNull()).toString(); // no error!
Foo foo = await futureNull(); // no error!

Esencialmente, al hacer que alguna función f() retorne un Future<Null>, estás haciendo que await f() sea un sinónimo de null en tu programa. ¡Eso es peligroso porque puedes usar null como si fuera absolutamente cualquier cosa, absolutamente cualquier tipo. No creo que eso sea lo que la mayoría de la gente quiere.

Texto del meme: 'Null no es el tipo que buscas'.

¿Por qué más podrías usar Future<Null> en lugar de Future<void>? Todo se remonta a ese desconcertante tipo bottom. Podrías considerar Future<Null> para futures que deben ser awaitable pero que nunca se completan, o que siempre se completan con un error. Esto es directamente comparable a por qué una función retornaría Null.

La misma lógica se aplica a Stream<Null>: Usa esto para un Stream que nunca envía ningún evento. Esto es directamente comparable a por qué una lista vacía de solo lectura puede ser tipada como List<Null>.

Algunos tipos más complejos para los que dar una guía son StreamController<Null> y Sink<Null>. Estos son comparables a las funciones. La razón más sólida para usar estos tipos es decir que nunca deberían usarse. La siguiente más sólida es que te gustaría que solo acepten sinónimos de Null, lo cual es razonable solo si no tienes void_checks activado.

Imagina que Dart no tuviera null como vía de escape para todos los valores. ¿Querrías hacer un tipo Sink<⊥> que solo puede aceptar eventos de un Stream<⊥>, que por sí mismo nunca emite un evento? Si la respuesta es sí, entonces adelante y haz un Sink<Null>.

Todos estos usos tienen garantías débiles porque el valor null mismo es una vía de escape para por qué de otro modo serías más propenso a usar el tipo Null mismo. Sin embargo, no cometas el error de pensar que el valor es la vía de escape misma, en lugar del búnker. Uno es seguro y robusto y el otro es para ahorrarte el esfuerzo de cavar hacia arriba a través de hormigón armado cuando el búnker se incendia contigo dentro.

Texto del meme: 'No siempre uso null en mi código Dart, pero cuando lo hago, dejo un largo comentario explicando por qué.'

En resumen, aquí están los usos principales de tipar algo como Null:

  • Como un sinónimo de null, utilizable en todas partes como todo

  • Como un marcador de posición para una ejecución que tu programa nunca alcanzará

  • Como un contenedor que debe permanecer vacío para poder hacerse pasar por cualquier tipo

  • Como una salida sin entradas

Comparación final

#

Si llegaste a este punto del artículo, mereces algún tipo de recompensa — escríbeme y veré sobre invitarte una cerveza la próxima vez que estés libre en Portland OR. :)

Hablé mucho sobre el tipo bottom, pero no entré en su opuesto en absoluto, Top. El tipo top es, como esperarías, el polo opuesto del tipo bottom. Y mientras el nombre de Dart para el tipo bottom es Null, el nombre de Dart para el tipo top es uno de dynamic, Object, y void (tres cabezas de la misma moneda).

Puede parecer extraño que palabras como "nada" y "vacío" en inglés puedan significar dos cosas tan disímiles en teoría de tipos. Ambas son descripciones adecuadas de los tipos opuestos top y bottom exactos, y por lo tanto, de Null y void. ¡Puede parecer extraño que dos tipos opuestos sean candidatos atractivos para el mismo trabajo! Puede parecer frustrante que Dart tenga tantas rarezas y casos límite, ahora que estás leyendo sobre cuál usar.

Una ilustración mostrando frustración con rarezas y casos límite en teoría de tipos.

Nuestro error, creo, fue haber recomendado alguna vez Null para usarse como void en primer lugar. Habría sido en aquel momento un diservicio a los usuarios retener lo que era un consejo útil, dada la semántica de Dart 1. Pero accidentalmente hicimos un tipo muy esotérico muy comúnmente usado.

Bueno, ya has leído el artículo ahora. ¿Qué piensas? ¿Esperas encontrar un uso válido para Null en algún momento pronto? ¿Tienes una comprensión firme de dónde es útil?

Y más importante, ¿puedes ayudarnos a correr la voz de que void está aquí para hacerlo todo más simple?

Más de Dart