¿Por qué tipos nullable?

Null en código Dart indica que una variable no tiene valor, pero algunos lenguajes no permiten null. Aprende por qué ambos enfoques funcionan.

Hace unas semanas, anunciamos la beta de null safety de Dart, una característica de productividad importante destinada a ayudarte a evitar errores de null. Hablando de valores null, en el subreddit /r/dart_lang un usuario preguntó recientemente:

Pero ¿por qué aún tenemos/queremos valores null? ¿Por qué no deshacernos de ello completamente? Actualmente también estoy jugando con Rust y no tiene null en absoluto. Así que parece ser posible vivir sin él.

Me encanta esta pregunta. ¿Por qué no deshacerse de null completamente? Este artículo es una versión expandida de lo que respondí en ese hilo.

La respuesta corta es que, sí, es totalmente posible vivir sin null, y lenguajes como Rust lo hacen. Pero los programadores usan null, así que antes de poder quitárselo, necesitamos entender por qué se usa. ¿Qué es null usualmente haciendo cuando lo usamos en lenguajes que sí lo tienen?

Resulta que null se usa típicamente para representar la ausencia de un valor, lo cual es eminentemente útil. Algunas personas no tienen segundo nombre. Algunas direcciones de correo no tienen números de apartamento. Algunos monstruos no tienen ningún tesoro para soltar cuando los matas.

En casos como ese, queremos una forma de expresar, "Esta variable podría tener un valor de tipo X, o puede no tener ningún valor en absoluto." La pregunta entonces es ¿cómo modelamos eso?

Una opción es decir que una variable puede contener un valor del tipo esperado, o puede contener el valor mágico null. Si intentamos usar el valor cuando es null, obtenemos un fallo en tiempo de ejecución. Esto es lo que Dart hacía antes de null safety, lo que hace SQL, lo que hace Java para tipos no primitivos, y lo que hace C# para tipos de clase.

Pero fallar en tiempo de ejecución es terrible. Significa que nuestros usuarios experimentan el bug. Nosotros programadores preferiríamos encontrar esos fallos antes de que ellos lo hagan. De hecho, estaríamos felices si pudiéramos encontrar los bugs incluso antes de ejecutar nuestro programa. Entonces, ¿cómo modelamos la ausencia de un valor de una manera que el sistema de tipos entienda? En otras palabras, ¿cómo damos a los valores "potencialmente ausentes" y valores "definitivamente presentes" diferentes tipos estáticos?

Hay dos soluciones principales:

  1. Usa un tipo option o maybe

  2. Usa un tipo nullable

Solución 1: Option types

#

Esto es lo que ML y la mayoría de los lenguajes funcionales derivados de ML (incluyendo Rust, Scala y Swift) hacen. Cuando sabemos que definitivamente tendremos un valor, simplemente usamos el tipo subyacente. Si escribimos int significa, "Definitivamente hay un entero aquí."

Para expresar un valor potencialmente ausente, envolvemos el tipo subyacente en un tipo option. Así que Option<int> representa un valor que podría ser un entero o podría no ser nada en absoluto. Es como un tipo de colección que puede contener cero o un elemento.

Desde la perspectiva del sistema de tipos, no hay una relación directa entre int y Option<int>. Tratar estos como tipos distintos significa que no podemos pasar accidentalmente un Option<int> potencialmente ausente a algo que espera un int real. Tampoco podemos intentar accidentalmente usar un Option<int> como si fuera un entero ya que no soporta ninguna de esas operaciones. No podemos realizar aritmética en un Option<int> más de lo que podríamos en un List<int>.

Para crear un valor de un tipo option a partir de un valor presente del tipo subyacente (digamos 3), construyes el option como Some(3). Para crear un tipo option cuando el valor está ausente, escribes algo como None().

Para usar un entero potencialmente ausente almacenado en un Option<int>, primero tenemos que verificar y ver si el valor está ahí. Si es así, podemos extraer el entero del option y usarlo, igual que leer un valor de una colección. Los lenguajes que tienen tipos option usualmente también tienen una bonita sintaxis de pattern matching, que nos da una forma elegante de verificar si el valor está ahí y usarlo si es así.

Solución 2: Nullable types

#

La otra opción (je), es lo que Kotlin, TypeScript, y ahora Dart hacen. Nullable types son un caso especial de union types.

(Tangente: La nomenclatura se vuelve realmente confusa aquí. Option types — lo que ML y amigos hacen arriba — son un caso especial de algebraic datatypes. Otro nombre para algebraic datatypes es "discriminated unions". Pero, a pesar de que "union" está en el nombre, "discriminated unions" son bastante diferentes de "union types". Como dijo Phil Karlton, solo hay dos problemas difíciles en ciencias de la computación: invalidación de caché y nombrar cosas.)

Similar al enfoque de tipos option, usamos el tipo subyacente para representar un valor definitivamente presente. Así que int nuevamente significa que absolutamente tenemos un entero. Si queremos un entero potencialmente ausente, en su lugar usamos el int? tipo nullable. El pequeño signo de interrogación es azúcar sintáctico para escribir lo que es esencialmente un tipo union como int | Null.

Igual que con los tipos option, un tipo nullable no soporta las mismas operaciones que el tipo subyacente. El sistema de tipos no nos intentará realizar aritmética en un int nullable porque eso no es seguro. De igual manera, no podemos pasar un entero nullable a algo que requiere un entero actual.

Sin embargo, el sistema de tipos es un poco más flexible que con los tipos option. El sistema de tipos entiende que un tipo union es un supertipo de sus ramas. En otras palabras, int es un subtipo de int?. Eso significa que podemos pasar un entero definitivamente presente a algo que espera un entero potencialmente presente ya que eso es seguro de hacer. Es un upcast, igual que podemos pasar un String a una función que toma Object. Dart solo nos prohíbe ir en la otra dirección — de nullable a non-nullable — porque eso sería un downcast y esos podrían fallar.

Cuando tenemos un valor de un tipo nullable y queremos ver si hay un valor real o null ahí, verificamos el valor imperativamente igual que lo haríamos naturalmente en C o Java:

foo(int? i) {
  if (i != null) {
    print(i + 1);
  }
}

El lenguaje luego usa flow analysis para determinar qué partes del programa están protegidas detrás de esas verificaciones. El análisis determina que el código solo puede alcanzarse si la variable no es null, así que dentro de esas regiones, el sistema de tipos ajusta el tipo de la variable para que sea non-nullable. Así que, aquí, trata i como teniendo tipo int dentro de la declaración if.

¿Qué solución debería tomar un lenguaje?

#

Así que cuando nosotros en el equipo de Dart decidimos hacer que el lenguaje maneje null de una manera más segura, ¿cómo deberíamos proceder para elegir la solución 1 o 2? Podemos empezar por observar a nuestros usuarios. ¿Cómo quieren escribir código que verifique valores ausentes? En lenguajes funcionales, pattern matching es una de las estructuras principales de control de flujo, y los usuarios allí están muy cómodos con él. Usar tipos option y pattern matching es natural en ese estilo.

En lenguajes imperativos derivados de C, código como mi ejemplo anterior es la forma idiomática de verificar null. Usar flow analysis y tipos nullable hace que ese código familiar funcione correctamente y de forma segura. De hecho, con Dart, hemos encontrado que la mayor parte del código existente ya es estáticamente null safe con el nuevo sistema de tipos porque el nuevo flow analysis analiza correctamente el código ya escrito.

(Esto en algunos aspectos no es una sorpresa. La mayor parte del código ya es dinámicamente correcto con respecto al manejo de null. Si no lo fuera, estaría fallando todo el tiempo. Gran parte del trabajo es simplemente hacer que el sistema de tipos sea lo suficientemente inteligente para ver que ese código ya es correcto, para que la atención del usuario se dibuje hacia los pocos bits que no lo son.)

Así que si nuestro objetivo es maximizar la familiaridad y la comodidad del usuario (lo cual son criterios importantes en el diseño de lenguajes), simplemente deberíamos seguir el camino que las estructuras de control de flujo de nuestro lenguaje nos trazan.

Representando ausencia y presencia

#

Hay una forma más profunda de abordar esta pregunta basada en diferencias entre cómo se representan los tipos option y los tipos nullable. Esa diferencia de representación nos fuerza a algunos compromisos clave, y esos podrían inclinarnos en una dirección u otra.

Con el primer enfoque, un valor de tipo option tiene una representación en tiempo de ejecución distinta del valor subyacente. Digamos que elegimos tipos option en Dart, y creaste uno y luego lo hiciste upcast a Object:

var optionalInt = Some(3);
Object obj = optionalInt;
print(obj is int); // false

Nota la última línea. Un valor Option<int>, incluso cuando está presente, no es el mismo tipo de cosa que un valor del tipo subyacente. Some(3) y 3 son valores distintos, distinguibles.

Así no es como funcionan los tipos nullable:

var nullableInt = 3 as int?;
Object obj = nullableInt;
print(obj is int); // true

Los tipos nullable existen en el sistema de tipos estático, pero la representación en tiempo de ejecución de los valores usa el tipo subyacente. Si tienes un "3 nullable", en tiempo de ejecución es solo el número 3. Si tienes un valor ausente de algún tipo nullable, en tiempo de ejecución solo tienes el valor mágico solitario null.

Puedes preguntar si un valor es de un tipo nullable:

print(obj is int?);

Pero la expresión is int? es equivalente a:

print(obj is int || obj is Null);

Optionals anidados

#

Dado que los valores de tipos option son diferentes del tipo subyacente, esto nos da una capacidad importante: Los tipos option pueden anidarse.

Digamos que tenemos algún servicio de red que da cadenas de recursos cuando se le da una solicitud con algún ID entero. Algunos recursos no están presentes y el servidor responderá sin datos para ese ID. Dado que golpear la red es lento, queremos cachear localmente los resultados de solicitudes que ya hemos realizado.

En Dart antes de null safety, podríamos usar un mapa así:

Map<int, String> cache;

Así que antes de hacer una solicitud de red para algún ID, usamos el operador de subíndice en el mapa de caché para buscar el ID del recurso. Ese operador está definido en Map para devolver null si la clave no está presente. Pero la clave también podría estar presente y asociada con un valor null. Si hacemos una búsqueda y obtenemos null, podría significar cualquiera de:

  • La clave no estaba presente en el mapa. Esto significa que aún no hemos hecho la solicitud, así que deberíamos pedirle al servidor que busque el recurso.

  • La clave estaba presente y asociada con null. Esto significa que ya le preguntamos al servidor, encontramos que el recurso no estaba presente, y lo almacenamos en el caché. Deberíamos usar ese resultado y no consultar al servidor nuevamente.

Porque solo hay un solo valor null en todo el sistema, no tenemos una representación en tiempo de ejecución que pueda distinguir estos dos casos. Es por eso que la clase Map tiene un método separado containsKey(). Esa API proporciona una forma de distinguir estos dos casos.

Ahora, si Dart estuviera construido alrededor de tipos option, el caché se vería así:

Map<int, Option<String>> cache;

Y el operador de subíndice devolvería un valor opcional:

class Map<K, V> {
  Option<V> operator [](K key) => ...
  ...
}

En el caso de nuestro Map<int, Option<String>>, eso significa que el tipo de retorno es Option<Option<String>>. ¡Nota el anidamiento! Ahora, cuando buscamos una clave en el caché, podemos obtener algunos resultados diferentes:

  • Un Some(Some(string)) significa que el recurso sí existía en el servidor, y lo tenemos en el caché ahora.

  • Un Some(None()) significa que le preguntamos al servidor y el recurso no estaba ahí, así que hemos cachecado el hecho de que el recurso no existe.

  • Un None() significa que el caché no contiene este ID en absoluto.

Podemos distinguir los últimos dos casos porque los options siempre envuelven su valor subyacente en algún estado extra. En tiempo de ejecución, podemos determinar cuántas capas hay y quitarlas individualmente.

Los tipos nullable, dado que no tienen una representación en tiempo de ejecución explícita, se aplanan implícitamente. Así que int? y int?? son tipos equivalentes para el sistema de tipos y tienen conjuntos equivalentes de valores en tiempo de ejecución. Es por eso que los fans de los tipos option los describen como "más expresivos": porque los tipos opcionales te dan una forma de representar más tipos de valores que los tipos nullable.

Sustitución nullable

#

Otra forma de pensar sobre "expresividad" es cuánto esfuerzo toma para el usuario expresar lo que realmente quieren expresar. Un lenguaje es más expresivo si el usuario puede alcanzar su objetivo saltando menos obstáculos.

Una ventaja de no tener una representación distinta para tipos nullable es que los valores pueden fluir de contextos non-nullable a nullable mucho más fácilmente. Digamos que tienes una función que acepta un parámetro entero opcional. Con tipos option, la firma se vería algo así:

takesMaybeInt(Option<int> optionalInt) {}

Para llamar a esta función con un entero conocido, primero debe envolverse en un option:

takesMaybeInt(Some(3));

Con tipos nullable, dado que no hay diferencia de representación, puedes pasar un valor del tipo subyacente directamente:

takesMaybeInt(3);

Obtienes esta flexibilidad en todas partes del sistema de tipos. Puedes sobrescribir un método que devuelve un tipo nullable para devolver un tipo non-nullable. Puedes pasar un List<int> a una función que quiere un List<int?>.

Así que mientras los tipos nullable pierden la capacidad de anidarse y representar múltiples tipos distintos de "ausencia", a cambio hacen mucho más fácil trabajar con la única noción bendecida de null.

Nullability para Dart

#

Dart es un lenguaje imperativo donde las personas ya usan declaraciones if para verificar valores ausentes en tiempo de ejecución. También es un lenguaje orientado a objetos donde ya tenemos un valor especial null con su propia representación en tiempo de ejecución. Así que la solución 2, tipos nullable, fue la respuesta natural para nosotros. Permite que nuestros usuarios escriban el tipo de código con el que están familiarizados, y aprovecha cómo el runtime ya representa los valores.

Para más información sobre nullability en Dart, revisa la sección Dónde aprender más de la documentación de null safety de Dart.

Rompecabezas con una pieza faltante.
Una de estas piezas del rompecabezas es null.

Más de Dart