Entender null safety
Una inmersión profunda en los cambios del lenguaje y las bibliotecas de Dart relacionados con null safety.
Written by Bob Nystrom
July 2020
Null safety es el cambio más grande que hemos realizado en Dart desde que reemplazamos el sistema original de tipos opcionales no seguros con un sistema de tipos estáticos seguro (sound) en Dart 2.0. Cuando Dart se lanzó por primera vez, la seguridad contra nulos en tiempo de compilación era una característica poco común que requería una larga introducción. Hoy en día, Kotlin, Swift, Rust y otros lenguajes tienen sus propias respuestas a lo que se ha convertido en un problema muy familiar. Aquí tienes un ejemplo:
// Without null safety:
bool isEmpty(String string) => string.length == 0;
void main() {
isEmpty(null);
}
Si ejecutas este programa Dart sin null safety, arroja una
excepción NoSuchMethodError en la llamada a .length. El
valor null es una
instancia de la clase Null, y Null no tiene un getter "length". Los fallos
en tiempo de ejecución son terribles. Esto es especialmente cierto en un lenguaje como Dart que está diseñado
para ejecutarse en el dispositivo de un usuario final. Si una aplicación de servidor falla, a menudo puedes
reiniciarla antes de que alguien se dé cuenta. Pero cuando una aplicación Flutter se bloquea en el
teléfono de un usuario, no se pondrá contento. Y cuando tus usuarios no están contentos, tú tampoco lo estás.
A los desarrolladores les gustan los lenguajes tipados estáticamente como Dart porque permiten que el
comprobador de tipos encuentre errores en el código en tiempo de compilación, generalmente directamente en el IDE.
Cuanto antes encuentres un error, antes podrás solucionarlo. Cuando los diseñadores de lenguajes
hablan de "solucionar errores de referencia nula", se refieren a enriquecer el comprobador de tipos
estáticos para que el lenguaje pueda detectar errores como el intento anterior de llamar a
.length en un valor que podría ser null.
No hay una única solución real para este problema. Rust y Kotlin tienen sus propios enfoques que tienen sentido en el contexto de esos lenguajes. Este documento detalla todos los aspectos de nuestra respuesta para Dart. Incluye cambios en el sistema de tipos estáticos y un conjunto de otras modificaciones y nuevas características del lenguaje para permitirte no solo escribir código seguro contra nulos, sino también, con suerte, disfrutar haciendo lo propio.
Este documento es largo. Si quieres algo más corto que cubra justo lo que
necesitas saber para empezar a trabajar, comienza con la visión general. Cuando estés
listo para una comprensión más profunda y tengas tiempo, vuelve aquí para entender
cómo maneja el lenguaje los valores null, por qué lo diseñamos de esa manera, y
cómo escribir un Dart moderno, idiomático y seguro contra nulos. (Alerta de spoiler: termina siendo
sorprendentemente similar a cómo escribes Dart hoy en día).
Las diversas formas en que un lenguaje puede abordar los errores de referencia nula tienen sus pros y sus contras. Estos principios guiaron las decisiones que tomamos:
-
El código debe ser seguro por defecto. Si escribes nuevo código Dart y no utilizas ninguna característica explícitamente insegura, nunca arrojará un error de referencia nula en tiempo de ejecución. Todos los posibles errores de referencia nula se detectan de forma estática. Si deseas posponer parte de esa comprobación al tiempo de ejecución para obtener mayor flexibilidad, puedes hacerlo, pero tienes que elegirlo mediante alguna característica que sea visible textualmente en el código.
En otras palabras, no te estamos dando un chaleco salvavidas y dejándolo a tu criterio para que recuerdes ponértelo cada vez que salgas al agua. En su lugar, te damos un barco que no se hunde. Te mantienes seco a menos que te tires por la borda.
-
El código seguro contra nulos debe ser fácil de escribir. La mayor parte del código Dart existente es dinámicamente correcto y no arroja errores de referencia nula. Te gusta cómo se ve tu programa Dart ahora, y queremos que puedas seguir escribiendo código de esa manera. La seguridad no debería requerir sacrificar la usabilidad, pagar penitencia al comprobador de tipos o tener que cambiar significativamente la forma en que piensas.
-
El código seguro contra nulos resultante debe ser totalmente seguro (sound). La "solidez" (soundness) en el contexto de la comprobación estática significa cosas diferentes para cada persona. Para nosotros, en el contexto de null safety, eso significa que si una expresión tiene un tipo estático que no permite
null, entonces ninguna ejecución posible de esa expresión puede evaluarse comonull. El lenguaje proporciona esta garantía principalmente a través de comprobaciones estáticas, pero también puede haber algunas comprobaciones en tiempo de ejecución involucradas. (Sin embargo, ten en cuenta el primer principio: cualquier lugar donde ocurran esas comprobaciones en tiempo de ejecución será tu elección).La solidez (soundness) es importante para la confianza del usuario. Un barco que en su mayoría se mantiene a flote no es uno con el que te entusiasme navegar en alta mar. Pero también es importante para nuestros intrépidos desarrolladores de compiladores. Cuando el lenguaje ofrece garantías estrictas sobre las propiedades semánticas de un programa, significa que el compilador puede realizar optimizaciones que asumen que esas propiedades son verdaderas. Cuando se trata de
null, significa que podemos generar código más pequeño que elimine las comprobaciones denullinnecesarias, y código más rápido que no necesite verificar si un receptor no esnullantes de llamar a métodos en él.Una advertencia: Solo garantizamos la solidez (soundness) en programas Dart que son totalmente seguros contra nulos (null-safe). Dart admite programas que contienen una mezcla de código nuevo seguro contra nulos y código heredado más antiguo. En estos programas de versiones mixtas, aún pueden ocurrir errores de referencia nula. En un programa de versión mixta, obtienes todos los beneficios de la seguridad estática en las partes que son seguras contra nulos, pero no obtienes la solidez completa en tiempo de ejecución hasta que toda la aplicación sea segura contra nulos.
Ten en cuenta que eliminar null no es un objetivo. No hay nada de malo con null.
Por el contrario, es muy útil poder representar la ausencia de un
valor. Incorporar soporte para un valor especial "ausente" directamente en el lenguaje
hace que trabajar con la ausencia sea flexible y usable. Sirve de base para los
parámetros opcionales, el práctico operador null-aware ?. y la inicialización predeterminada.
No es null lo que es malo, es que null vaya a donde no lo esperas
lo que causa problemas.
Por lo tanto, con null safety, nuestro objetivo es darte control e información sobre
dónde puede fluir null a través de tu programa y la certeza de que no puede fluir
a algún lugar que cause un bloqueo.
Anulabilidad en el sistema de tipos
#Null safety comienza en el sistema de tipos estáticos porque todo lo demás se apoya en
eso. Tu programa Dart tiene todo un universo de tipos en él: tipos primitivos
como int y String, tipos de colección como List, y todas las clases
y tipos que tú y los paquetes que usas definen. Antes de null safety, el sistema
de tipos estáticos permitía que el valor null fluyera en expresiones de cualquiera de esos
tipos.
En la jerga de la teoría de tipos, el tipo Null se trataba como un subtipo de todos los tipos:
El conjunto de operaciones (getters, setters, métodos y
operadores) permitidos en algunas expresiones está definido por su tipo. Si el tipo
es List, puedes llamar a .add() o [] en él. Si es
int, puedes llamar a +.
Pero el valor null no define ninguno de esos métodos. Permitir que null
fluya en una expresión de algún otro tipo significa que cualquiera de esas operaciones puede
fallar. Este es realmente el quid de los errores de referencia nula: cada fallo proviene
de intentar buscar un método o propiedad en null que este no posee.
Tipos no nulos y nulos
#Null safety elimina ese problema de raíz al cambiar la jerarquía de tipos.
El tipo Null todavía existe, pero ya no es un subtipo de todos los tipos.
En su lugar, la jerarquía de tipos se ve de la siguiente manera:
Dado que Null ya no es un subtipo, ningún tipo excepto la clase especial Null
permite el valor null. Hemos hecho que todos los tipos sean no nulos por defecto. Si
tienes una variable de tipo String, siempre contendrá una cadena. Con esto,
hemos solucionado todos los errores de referencia nula.
Si no pensáramos que null es útil en absoluto, podríamos detenernos aquí. Pero null es
útil, por lo que todavía necesitamos una forma de manejarlo. Los parámetros opcionales son un buen
caso ilustrativo. Considera este código Dart seguro contra nulos:
// Using null safety:
void makeCoffee(String coffee, [String? dairy]) {
if (dairy != null) {
print('$coffee with $dairy');
} else {
print('Black $coffee');
}
}
Aquí, queremos permitir que el parámetro dairy acepte cualquier cadena, o el valor
null, pero nada más. Para expresar eso, le damos a dairy
un tipo nullable (que admite nulos) añadiendo
? al final del tipo base subyacente String. Por debajo,
esto define esencialmente una unión
del tipo subyacente y el tipo Null.
Así que String? sería una abreviatura de String|Null si Dart tuviera
tipos de unión completos.
Uso de tipos nulos
#Si tienes una expresión con un tipo nullable, ¿qué puedes hacer con el resultado?
Dado que nuestro principio es la seguridad por defecto, la respuesta es: no mucho. No podemos permitirte
llamar a métodos del tipo subyacente en él porque podrían fallar si el valor
es null:
// Hypothetical unsound null safety:
void bad(String? maybeString) {
print(maybeString.length);
}
void main() {
bad(null);
}
Esto fallaría si te permitiéramos ejecutarlo. Los únicos métodos y propiedades a los que
podemos permitirte acceder de forma segura son los definidos tanto por el tipo subyacente como por la
clase Null. Eso es solo toString(), == y
hashCode. Así que puedes usar
tipos nullables como claves de mapa, almacenarlos en conjuntos (sets), compararlos con otros valores
y usarlos en la interpolación de cadenas, pero poco más.
¿Cómo interactúan con los tipos no nulos (non-nullable)? Siempre es seguro pasar un
tipo no nulo a algo que espera un tipo nullable. Si una función
acepta String? entonces se permite pasar un String porque no causará ningún
problema. Modelamos esto haciendo que cada tipo nullable sea un supertipo de su
tipo subyacente. También puedes pasar de forma segura null a algo que espera un tipo nullable, por lo que
Null también es un subtipo de cada tipo nullable:
Pero ir en la otra dirección y pasar un tipo nullable a algo que espera
el tipo no nulo subyacente no es seguro. El código que espera un String
puede
llamar a métodos de String en el valor. Si le pasas un String?
a este, null podría
fluir hacia él y eso podría fallar:
// Hypothetical unsound null safety:
void requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
void main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}
Este programa no es seguro y no deberíamos permitirlo. Sin embargo, Dart siempre ha tenido
algo llamado conversiones implícitas descendentes (implicit downcasts). Si tú, por ejemplo, pasas un valor de
tipo Object a una función que espera un String, el comprobador de tipos lo permite:
// Without null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
Para mantener la solidez (soundness), el compilador inserta silenciosamente una conversión as String en el
argumento de requireStringNotObject(). Esa conversión podría fallar y lanzar una
excepción en tiempo de ejecución, pero en tiempo de compilación, Dart dice que esto está bien. Dado que
los tipos no nulos se modelan como subtipos de los tipos nullables, las conversiones implícitas descendentes
te permitirían pasar un String? a algo que espera un String. Permitir eso
violaría nuestro objetivo de ser seguros por defecto. Por lo tanto, con null safety estamos
eliminando las conversiones implícitas descendentes por completo.
Esto hace que la llamada a requireStringNotNull() produzca un error de compilación, que
es lo que deseas. Pero también significa que todas las conversiones implícitas descendentes se convierten en errores
de compilación, incluida la llamada a requireStringNotObject(). Tendrás que añadir la
conversión explícita tú mismo:
// Using null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);
}
Creemos que este es un cambio positivo en general. Nuestra impresión es que a la mayoría de los usuarios nunca les gustaron las conversiones implícitas descendentes. En particular, es posible que te hayas visto afectado por esto antes:
// Without null safety:
List<int> filterEvens(List<int> ints) {
return ints.where((n) => n.isEven);
}
¿Detectas el error? El método .where() es perezoso (lazy), por lo que devuelve un Iterable, no un
List. Este programa se compila pero luego lanza una excepción en tiempo de ejecución cuando intenta
convertir ese Iterable al tipo List que filterEvens declara que
devuelve. Con la eliminación de las conversiones implícitas descendentes, esto se convierte en un error de compilación.
¿Dónde estábamos? Correcto, bien, entonces es como si hubiéramos tomado el universo de tipos en tu programa y lo hubiéramos dividido en dos mitades:
Hay una región de tipos no nulos. Esos tipos te permiten acceder a todos los
métodos interesantes, pero nunca pueden contener null. Y luego hay una
familia paralela de todos los tipos nullables correspondientes. Esos permiten null,
pero no puedes hacer mucho con ellos. Permitimos que los valores fluyan del lado no nulo
al lado nullable porque hacerlo es seguro, pero no en la otra dirección.
Eso hace parecer que los tipos nullables son básicamente inútiles. No tienen métodos y no puedes deshacerte de ellos. No te preocupes, tenemos un conjunto completo de características para ayudarte a mover valores de la mitad nullable al otro lado, a las cuales llegaremos pronto.
Top y bottom
#Esta sección es un poco esotérica. En su mayoría puedes omitirla, excepto por dos
puntos al final, a menos que te interesen las cuestiones de sistemas de tipos. Imagina todos los
tipos de tu programa con conexiones entre aquellos que son subtipos y supertipos
entre sí. Si fueras a dibujarlo, como en los diagramas de este documento, formaría
un enorme grafo dirigido con supertipos como Object cerca de la parte superior y clases
hoja como tus propios tipos cerca de la parte inferior.
Si ese grafo dirigido llega a un punto en la parte superior donde hay un único tipo que es el supertipo (directa o indirectamente), ese tipo se llama el tipo superior (top type). Del mismo modo, si hay un tipo peculiar en la parte inferior que es subtipo de todos los tipos, tienes un tipo inferior (bottom type). (En este caso, tu grafo dirigido es una retícula [lattice].)
Es conveniente si tu sistema de tipos tiene un tipo superior e inferior, porque significa
que las operaciones a nivel de tipo como el límite superior mínimo (que la inferencia de tipos utiliza para
deducir el tipo de una expresión condicional basándose en los tipos de sus dos
ramas) siempre pueden producir un tipo. Antes de null safety, Object era el tipo
superior de Dart y Null era su tipo inferior.
Dado que Object ahora no admite nulos, ya no es un tipo superior. Null no es un
subtipo de este. Dart no tiene un tipo superior nombrado. Si necesitas un tipo superior, quieres
Object?. Del mismo modo, Null ya no es el tipo inferior. Si lo fuera, todo
seguiría admitiendo nulos. En su lugar, hemos añadido un nuevo tipo inferior llamado Never:
En la práctica, esto significa:
-
Si deseas indicar que permites un valor de cualquier tipo, usa
Object?en lugar deObject. De hecho, se vuelve bastante inusual usarObjectya que ese tipo significa "podría ser cualquier valor posible excepto este único valor extrañamente prohibido:null". -
En la rara ocasión en que necesites un tipo inferior, usa
Neveren lugar deNull. Esto es especialmente útil para indicar que una función nunca retorna, para ayudar al análisis de alcanzabilidad. Si no sabes si necesitas un tipo inferior, probablemente no lo necesites.
Garantizar la corrección
#Dividimos el universo de tipos en mitades nullables y no nullables. Para
mantener la solidez (soundness) y nuestro principio de que nunca puedes obtener un error de referencia
nula en tiempo de ejecución a menos que lo solicites, debemos garantizar que null
nunca
aparezca en ningún tipo del lado no nulo.
Eliminar las conversiones implícitas descendentes y quitar Null como tipo inferior cubre
la mayoría de los lugares principales por donde los tipos fluyen a través de un programa entre asignaciones y
desde argumentos hacia parámetros en llamadas a funciones. Los principales lugares restantes
por donde null puede colarse son cuando una variable se crea por primera vez y cuando
sales de una función. Así que hay algunos errores de compilación adicionales:
Retornos no válidos
#Si una función tiene un tipo de retorno no nulo, entonces cada ruta a través de la
función debe llegar a una sentencia return que devuelva un valor. Antes de null
safety, Dart era bastante laxo con respecto a los retornos faltantes. Por ejemplo:
// Without null safety:
String missingReturn() {
// No return.
}
Si analizabas esto, obtenías una sutil sugerencia (hint) de que tal vez
habías olvidado un retorno, pero si no, no pasaba nada. Esto se debe a que si la ejecución
llega al final del cuerpo de una función, Dart devuelve implícitamente null. Como cada tipo es nullable,
técnicamente esta función es segura, aunque probablemente no sea lo que
deseas.
Con tipos no nulos seguros, este programa está completamente equivocado y no es seguro. Con null safety, obtienes un error de compilación si una función con un tipo de retorno no nulo no devuelve un valor de manera confiable. Por "confiable", me refiero a que el lenguaje analiza todas las rutas de flujo de control a través de la función. Mientras todas devuelvan algo, se considera correcto. El análisis es bastante inteligente, por lo que incluso esta función está bien:
// Using null safety:
String alwaysReturns(int n) {
if (n == 0) {
return 'zero';
} else if (n < 0) {
throw ArgumentError('Negative values not allowed.');
} else {
if (n > 1000) {
return 'big';
} else {
return n.toString();
}
}
}
Nos sumergiremos más profundamente en el nuevo análisis de flujo en la siguiente sección.
Variables no inicializadas
#Cuando declaras una variable, si no le das un inicializador explícito, Dart
inicializa la variable de forma predeterminada con null. Eso es conveniente, pero obviamente
totalmente inseguro si el tipo de la variable es no nulo. Así que tenemos que endurecer las
reglas para las variables no nulas:
-
Las declaraciones de variables de nivel superior y campos estáticos deben tener un inicializador. Dado que se puede acceder a estas y asignarlas desde cualquier parte del programa, es imposible que el compilador garantice que la variable haya recibido un valor antes de ser utilizada. La única opción segura es requerir que la declaración misma tenga una expresión de inicialización que produzca un valor del tipo correcto:
dart// Using null safety: int topLevel = 0; class SomeClass { static int staticField = 0; } -
Los campos de instancia deben tener un inicializador en la declaración, usar un formal de inicialización o inicializarse en la lista de inicialización del constructor. Eso es mucha jerga técnica. Aquí tienes los ejemplos:
dart// Using null safety: class SomeClass { int atDeclaration = 0; int initializingFormal; int initializationList; SomeClass(this.initializingFormal) : initializationList = 0; }In otras palabras, mientras el campo tenga un valor antes de llegar al cuerpo del constructor, todo estará bien.
-
Las variables locales son el caso más flexible. Una variable local no nula no necesita tener un inicializador. Esto está perfectamente bien:
dart// Using null safety: int tracingFibonacci(int n) { int result; if (n < 2) { result = n; } else { result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1); } print(result); return result; }La regla es simplemente que una variable local debe estar definitivamente asignada antes de ser utilizada. También podemos confiar en el nuevo análisis de flujo al que aludí para esto. Mientras cada ruta que conduzca al uso de una variable la inicialice primero, el uso es válido.
-
Los parámetros opcionales deben tener un valor predeterminado. Si no pasas un argumento para un parámetro opcional posicional o nombrado, entonces el lenguaje lo completa con el valor predeterminado. Si no especificas un valor predeterminado, el valor predeterminado por defecto es
null, y eso no es posible si el tipo del parámetro es no nulo.Por lo tanto, si quieres que un parámetro sea opcional, debes hacerlo nullable o especificar un valor predeterminado no
nullválido.
Estas restricciones suenan onerosas, pero no son tan malas en la práctica. Son
muy similares a las restricciones existentes en torno a las variables final y es
probable que hayas estado trabajando con ellas durante años sin siquiera notarlo. Además,
recuerda que esto solo se aplica a las variables no nulas. Siempre puedes hacer
que el tipo sea nullable y luego obtener la inicialización predeterminada a null.
Aun así, las reglas causan fricción. Afortunadamente, tenemos un conjunto de nuevas características del lenguaje para facilitar los patrones más comunes donde estas nuevas limitaciones te frenan. Primero, sin embargo, es hora de hablar del análisis de flujo.
Análisis de flujo
#El análisis de flujo de control ha existido en los compiladores durante años. En su mayoría está oculto para los usuarios y se utiliza durante la optimización del compilador, pero algunos lenguajes más nuevos han comenzado a usar las mismas técnicas para características visibles del lenguaje. Dart ya tiene un toque de análisis de flujo en forma de promoción de tipos:
// With (or without) null safety:
bool isEmptyList(Object object) {
if (object is List) {
return object.isEmpty; // <-- OK!
} else {
return false;
}
}
Observa cómo en la línea marcada podemos llamar a isEmpty en object. Ese método está
definido en List, no en Object. Esto funciona porque el comprobador de tipos analiza
todas las expresiones is y las rutas de flujo de control en el programa. Si el
cuerpo de alguna estructura de flujo de control solo se ejecuta cuando una determinada expresión is
en una variable es verdadera, entonces dentro de ese cuerpo el tipo de la variable se "promueve"
al tipo probado.
En el ejemplo de aquí, la rama then de la sentencia if solo se ejecuta cuando
object realmente contiene una lista. Por lo tanto, Dart promueve object
al tipo
List en lugar de su tipo declarado Object. Esta es una característica útil, pero es
bastante limitada. Antes de null safety, el siguiente programa funcionalmente idéntico
no funcionaba:
// Without null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // <-- Error!
}
Una vez más, solo puedes llegar a la llamada a .isEmpty cuando object contiene una lista, por lo que
este programa es dinámicamente correcto. Pero las reglas de promoción de tipos no eran lo suficientemente
inteligentes como para ver que la sentencia return significa que la segunda sentencia solo se puede
alcanzar cuando object es una lista.
Para null safety, hemos tomado este análisis limitado y lo hemos hecho mucho más potente de varias maneras.
Análisis de alcanzabilidad
#En primer lugar, solucionamos la queja de larga data
de que la promoción de tipos
no es inteligente con respecto a los retornos tempranos y otras rutas de código inalcanzables. Al analizar
a función, ahora se tienen en cuenta return, break, throw y cualquier otra
forma en que la ejecución pueda terminar temprano en una función. Con null safety, esta función:
// Using null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty;
}
Ahora es perfectamente válida. Dado que la sentencia if saldrá de la función cuando
object no sea un List, Dart promueve object
a List en la segunda
sentencia. Esta es una mejora realmente agradable que ayuda a una gran cantidad de código Dart, incluso
al que no está relacionado con la anulabilidad (nullability).
Never para código inalcanzable
#También puedes programar este análisis de alcanzabilidad. El nuevo tipo inferior Never
no tiene valores. (¿Qué tipo de valor es simultáneamente un String, un bool y un
int?) Entonces, ¿qué significa que una expresión tenga el tipo Never? Significa
que esa expresión nunca puede terminar de evaluarse con éxito. Debe lanzar una
excepción, abortar o asegurar de otro modo que el código circundante que espera el
resultado de la expresión nunca se ejecute.
De hecho, según el lenguaje, el tipo estático de una expresión throw es
Never. El tipo Never está declarado en las bibliotecas principales y puedes usarlo
como una anotación de tipo. Quizás tengas una función de ayuda para facilitar el
lanzamiento de un determinado tipo de excepción:
// Using null safety:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
Podrías usarlo de esta manera:
// Using null safety:
class Point {
final int x, y;
Point(this.x, this.y);
Point operator +(Object other) {
if (other is int) return Point(x + other, y + other);
if (other is! Point) wrongType('int | Point', other);
print('Adding two Point instances together: $this + $other');
return Point(x + other.x, y + other.y);
}
// toString, hashCode, and other implementations...
}
Este programa se analiza sin errores. Observa que la última línea del método +
accede a .x y .y en other. Se ha promovido a
Point a pesar de
que la función no tiene ningún return ni throw. El análisis de flujo
de control sabe que el tipo declarado de wrongType() es Never
lo que significa que
la rama then de la sentencia if debe abortar de alguna manera. Dado que la sentencia
final solo se puede alcanzar cuando other es un Point, Dart lo promueve.
En otras palabras, el uso de Never en tus propias APIs te permite extender el
análisis de alcanzabilidad de Dart.
Análisis de asignación definitiva
#Mencioné esto brevemente con las variables locales. Dart debe asegurarse de que una variable local no nula siempre esté inicializada antes de leerse. Usamos el análisis de asignación definitiva para ser lo más flexibles posible al respecto. El lenguaje analiza cada cuerpo de función y rastrea las asignaciones a variables locales y parámetros a través de todas las rutas de flujo de control. Siempre que la variable esté asignada en cada ruta que llegue a algún uso de esta, la variable se considera inicializada. Esto te permite declarar una variable sin inicializador y luego inicializarla después usando un flujo de control complejo, incluso cuando la variable tiene un tipo no nulo.
También usamos el análisis de asignación definitiva para hacer que las variables final sean más
flexibles. Antes de null safety, podía ser difícil usar final para variables
locales si necesitabas inicializarlas de alguna manera compleja:
// Using null safety:
int tracingFibonacci(int n) {
final int result;
if (n < 2) {
result = n;
} else {
result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
}
print(result);
return result;
}
Esto sería un error ya que la variable result es final pero no tiene
inicializador. Con el análisis de flujo más inteligente bajo null safety, este programa
está bien. El análisis puede determinar que result está definitivamente inicializada exactamente una
vez en cada ruta de flujo de control, por lo que se cumplen las restricciones para marcar una variable como final.
Promoción de tipos en verificaciones de nulos
#El análisis de flujo más inteligente ayuda a una gran cantidad de código Dart, incluso al código no relacionado con la
anulabilidad. Pero no es una coincidencia que estemos haciendo estos cambios ahora. Hemos
dividido los tipos en conjuntos nullables y no nullables. Si tienes un valor
de un tipo nullable, realmente no puedes hacer nada útil con él. En los casos
en que el valor es null, esa restricción es buena. Evita que tu programa
falle.
Pero si el valor no es null, sería bueno poder moverlo al lado no nulo
para poder llamar a métodos en él. El análisis de flujo es una de las
formas principales de hacerlo para variables locales y parámetros
(y campos final privados, a partir de Dart 3.2). Hemos extendido la
promoción de tipos para que también examine expresiones == null y != null.
Si verificas una variable local con tipo nullable para ver si no es null,
Dart promueve la variable al tipo no nulo subyacente:
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments != null) {
result += ' ' + arguments.join(' ');
}
return result;
}
Aquí, arguments tiene un tipo nullable. Normalmente, eso te prohíbe llamar a
.join() en él. Pero debido a que hemos protegido esa llamada en una sentencia if
que
verifica para asegurarse de que el valor no sea null, Dart lo promueve de List<String>?
a List<String> y te permite llamar a métodos en él o pasarlo a funciones que
esperan listas no nullables.
Esto suena como algo bastante menor, pero esta promoción basada en el flujo sobre las
comprobaciones de nulos es lo que hace que la mayor parte del código Dart existente funcione bajo null safety. La
mayor parte del código Dart es dinámicamente correcto y evita lanzar errores de referencia nula al
verificar si hay null antes de llamar a los métodos. El nuevo análisis de flujo en las comprobaciones de nulos
convierte esa corrección dinámica en corrección estática demostrable.
También, por supuesto, funciona con el análisis más inteligente que hacemos para la alcanzabilidad. La función anterior se puede escribir igualmente como:
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments == null) return result;
return result + ' ' + arguments.join(' ');
}
El lenguaje también es más inteligente sobre qué tipos de expresiones causan promoción. Un
== null o != null explícito, por supuesto, funciona. Pero las conversiones explícitas usando
as,
o las asignaciones, o el operador postfijo !
(que cubriremos más adelante) también causan
promoción. El objetivo general es que si el código es dinámicamente correcto y es razonable
deducirlo estáticamente, el análisis debe ser lo suficientemente astuto para hacerlo.
Ten en cuenta que la promoción de tipos originalmente solo funcionaba en variables locales, y ahora también funciona en campos final privados a partir de Dart 3.2. Para más información sobre cómo trabajar con variables no locales, consulta Trabajar con campos nulos.
Advertencias de código innecesario
#Tener un análisis de alcanzabilidad más inteligente y saber por dónde puede fluir null a través de
tu programa ayuda a asegurar que añadas código para manejar null. Pero también podemos
usar ese mismo análisis para detectar el código que no necesitas. Antes de null safety,
si escribías algo como:
// Using null safety:
String checkList(List<Object> list) {
if (list?.isEmpty ?? false) {
return 'Got nothing';
}
return 'Got something';
}
Dart no tenía forma de saber si ese operador null-aware ?. era útil o no.
Por lo que sabe, podrías pasar null a la función. Pero en el Dart seguro contra nulos,
si has anotado esa función con el tipo List que ahora no admite nulos, entonces
sabe que list nunca será null. Eso implica que el operador ?.
nunca hará
nada útil y puedes, y deberías, simplemente usar ..
Para ayudarte a simplificar tu código, hemos añadido advertencias para el código innecesario como
este ahora que el análisis estático es lo suficientemente preciso como para detectarlo. El uso de un
operador null-aware o incluso una comprobación como == null o != null
en un
tipo no nulo se reporta como una advertencia.
Y, por supuesto, esto también interactúa con la promoción de tipos no nulos. Una vez que una
variable ha sido promovida a un tipo no nulo, obtienes una advertencia si realizas de nuevo una
comprobación redundante de null:
// Using null safety:
String checkList(List<Object>? list) {
if (list == null) return 'No list';
if (list?.isEmpty ?? false) {
return 'Empty list';
}
return 'Got something';
}
Obtienes una advertencia en el operador ?. aquí porque en el momento en que se ejecuta, ya
sabemos que list no puede ser null. El objetivo de estas advertencias no es solo
limpiar código inútil. Al eliminar las comprobaciones innecesarias de null, nos aseguramos
de que se destaquen las comprobaciones significativas restantes. Queremos que puedas mirar
tu código y ver por dónde puede fluir null.
Trabajar con tipos nulos
#Ahora hemos acorralado a null en el conjunto de tipos nullables. Con el análisis de flujo,
podemos permitir de manera segura que algunos valores no null salten la valla hacia el lado no nulo
donde podemos usarlos. Ese es un gran paso, pero si nos detenemos aquí, el
sistema resultante sigue siendo dolorosamente restrictivo. El análisis de flujo solo ayuda con
variables locales, parámetros y campos final privados.
Para intentar recuperar la mayor cantidad de la flexibilidad que tenía Dart antes de null safety —y superarla en algunos lugares—, tenemos un puñado de otras nuevas características.
Métodos null-aware más inteligentes
#El operador null-aware ?. de Dart es mucho más antiguo que null safety. La
semántica en tiempo de ejecución establece que si el receptor es null, se omite el
acceso a la propiedad en el lado derecho y la expresión se evalúa como null:
// Without null safety:
String notAString = null;
print(notAString?.length);
En lugar de lanzar una excepción, esto imprime "null". El operador null-aware es una buena herramienta para hacer usables los tipos nullables en Dart. Aunque no podemos permitirte llamar a métodos en tipos nullables, sí podemos y te permitimos usar operadores null-aware en ellos. La versión del programa posterior a null safety es:
// Using null safety:
String? notAString = null;
print(notAString?.length);
Funciona exactamente como el anterior.
Sin embargo, si alguna vez has usado operadores null-aware en Dart, probablemente te hayas encontrado con una molestia al usarlos en cadenas de métodos. Supongamos que deseas ver si la longitud de una cadena potencialmente ausente es un número par (sé que no es un problema muy realista, pero acompáñame en esto):
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);
Aunque este programa usa ?., sigue lanzando una excepción en tiempo de ejecución. El
problema es que el receptor de la expresión .isEven es el resultado de toda la
expresión notAString?.length a su izquierda. Esa expresión se evalúa como
null, por lo que obtenemos un error de referencia nula al intentar llamar a .isEven. Si
alguna vez has usado ?. en Dart, probablemente aprendiste por las malas que tienes que aplicar
el operador null-aware a cada propiedad o método en una cadena después de usarlo la
primera vez:
String? notAString = null;
print(notAString?.length?.isEven);
Esto es molesto, pero, peor aún, oculta información importante. Considera:
// Using null safety:
showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
Aquí tienes una pregunta: ¿Puede el getter doohickey en Thing devolver null?
Parece que podría porque estás usando ?. en el resultado. Pero también puede ser
que el segundo ?. solo esté allí para manejar los casos en los que thing
es
null, no el resultado de doohickey. No puedes saberlo.
Para solucionar esto, tomamos prestada una idea inteligente del diseño de C# para esta misma característica.
Cuando usas un operador null-aware en una cadena de métodos, si el receptor se evalúa
como null, entonces todo el resto de la cadena de métodos se cortocircuita y
se omite. Esto significa que si doohickey tiene un tipo de retorno no nulo,
entonces puedes y debes escribir:
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey.gizmo);
}
De hecho, obtendrás una advertencia de código innecesario en el segundo ?. si
no lo haces. Si ves código como:
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}
Entonces sabes con certeza que significa que doohickey en sí mismo tiene un tipo de retorno
nullable. Cada ?. corresponde a una ruta única que puede hacer que
null fluya
hacia la cadena de métodos. Esto hace que los operadores null-aware en las cadenas de métodos sean
más concisos y precisos.
Mientras estábamos en ello, añadimos un par de otros operadores null-aware:
// Using null safety:
// Null-aware cascade:
receiver?..method();
// Null-aware index operator:
receiver?[index];
No hay un operador de llamada a función null-aware, pero puedes escribir:
// Allowed with or without null safety:
function?.call(arg1, arg2);
Operador de aserción no nula (not-null assertion)
#Lo fantástico de usar el análisis de flujo para mover una variable nullable al lado no nulo es que es demostrablemente seguro. Puedes llamar a métodos en la variable previamente nullable sin renunciar a nada de la seguridad o rendimiento de los tipos no nulos.
Pero muchos usos válidos de tipos nullables no se pueden demostrar que sean seguros de una manera que satisfaga al análisis estático. Por ejemplo:
// Using null safety, incorrectly:
class HttpResponse {
final int code;
final String? error;
HttpResponse.ok()
: code = 200,
error = null;
HttpResponse.notFound()
: code = 404,
error = 'Not found';
@override
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error.toUpperCase()}';
}
}
Si intentas ejecutar esto, obtendrás un error de compilación en la llamada a toUpperCase().
El campo error es nullable porque no tendrá un valor en una respuesta exitosa.
Podemos ver al inspeccionar la clase que nunca accedemos al mensaje de error
cuando es null. Pero eso requiere entender la relación entre el valor de
code y la anulabilidad de error. El comprobador de tipos no puede
ver esa conexión.
En otras palabras, nosotros, los humanos que mantenemos el código, sabemos que error no será
null en el punto en que lo usamos y necesitamos una forma de afirmarlo (assert). Normalmente,
afirmas los tipos usando una conversión as, y puedes hacer lo mismo aquí:
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}
Convertir error al tipo String no nulo lanzará una excepción en tiempo de
ejecución si la conversión falla. De lo contrario, nos da una cadena no nula en la que podemos
llamar a métodos.
"Eliminar la anulabilidad mediante conversión" ocurre con la suficiente frecuencia como para que tengamos una nueva
sintaxis abreviada. Un signo de exclamación postfijo (!) toma la expresión de la izquierda y
la convierte a su tipo no nulo subyacente. Así que la función anterior es
equivalente a:
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error!.toUpperCase()}';
}
Este "operador bang" de un solo carácter es particularmente útil cuando el
tipo subyacente es detallado. Sería realmente molesto tener que escribir as Map<TransactionProviderFactory, List<Set<ResponseFilter>>>
solo para eliminar un único
? de algún tipo.
Por supuesto, como cualquier conversión (cast), usar ! conlleva una pérdida de seguridad estática. La conversión
debe verificarse en tiempo de ejecución para preservar la solidez (soundness) y puede fallar y lanzar una
excepción. Pero tú tienes el control sobre dónde se insertan estas conversiones y puedes
siempre verlas al mirar tu código.
Variables late
#El lugar más común donde el comprobador de tipos no puede demostrar la seguridad del código es alrededor de variables y campos de nivel superior. Aquí tienes un ejemplo:
// Using null safety, incorrectly:
class Coffee {
String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
void main() {
var coffee = Coffee();
coffee.heat();
coffee.serve();
}
Aquí, el método heat() se llama antes de serve(). Eso significa que _temperature
se inicializará con un valor no nulo antes de ser utilizado. Pero no es factible
que un análisis estático determine eso. (Podría ser posible para un ejemplo
trivial como este, pero el caso general de intentar rastrear el estado de cada
instancia de una clase es inviable).
Debido a que el comprobador de tipos no puede analizar los usos de los campos y las variables de nivel superior, tiene una regla conservadora de que los campos no nulos deben inicializarse en su declaración (o en la lista de inicialización del constructor para los campos de instancia). Por lo tanto, Dart reporta un error de compilación en esta clase.
Puedes corregir el error haciendo que el campo sea nullable y luego usando operadores de aserción no nula en los usos:
// Using null safety:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature! + ' coffee';
}
Esto funciona bien. Pero envía una señal confusa al mantenedor de la clase.
Al marcar _temperature como nullable, implicas que null es un valor útil y
significativo para ese campo. Pero esa no es la intención. El campo _temperature
nunca debería ser observado en su estado null.
Para manejar el patrón común de estado con inicialización tardía, hemos añadido un
nuevo modificador, late. Puedes usarlo así:
// Using null safety:
class Coffee {
late String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
Ten en cuenta que el campo _temperature tiene un tipo no nulo,
pero no está inicializado.
Además, no hay una aserción explícita de no nulo cuando se usa.
Hay algunos modelos que puedes aplicar a la semántica de late,
pero yo lo pienso así: el modificador late significa
"aplicar las restricciones de esta variable en tiempo de ejecución en lugar de en tiempo de compilación".
Es casi como si la palabra "late" (tarde) describiera cuándo
impone las garantías de la variable.
En este caso, dado que el campo no está definitivamente inicializado, cada vez que se
lee el campo, se inserta una comprobación en tiempo de ejecución para asegurarse de que se le ha asignado un
valor. Si no ha sido así, se lanza una excepción. Darle a la variable el tipo
String significa "nunca deberías verme con un valor que no sea una cadena" y
el modificador late significa "verifica eso en tiempo de ejecución".
En cierta forma, el modificador late es más "mágico" que usar ? porque cualquier
uso del campo podría fallar, y no hay nada textualmente visible en el
lugar de uso. Pero sí tienes que escribir late en la declaración para obtener este
comportamiento, y creemos que ver el modificador allí es lo suficientemente explícito
para que esto sea mantenible.
A cambio, obtienes una mejor seguridad estática que usando un tipo nullable. Debido a que el
tipo del campo es no nulo ahora, es un error de compilación intentar asignar
null o un String nullable al campo. El modificador late
te permite posponer
la inicialización, pero aun así te prohíbe tratarlo como una variable
nullable.
Inicialización perezosa
#El modificador late también tiene otros poderes especiales. Puede parecer paradójico,
pero puedes usar late en un campo que tiene un inicializador:
// Using null safety:
class Weather {
late int _temperature = _readThermometer();
}
Al hacer esto, el inicializador se vuelve perezoso (lazy). En lugar de ejecutarse tan pronto como se construye la instancia, se pospone y se ejecuta perezosamente la primera vez que se accede al campo. En otras palabras, funciona exactamente como un inicializador en una variable de nivel superior o en un campo estático. Esto puede ser útil cuando la expresión de inicialización es costosa y es posible que no se necesite.
Ejecutar el inicializador de forma perezosa te brinda una ventaja adicional cuando usas late en
un campo de instancia. Por lo general, los inicializadores de campos de instancia no pueden acceder a this
porque no tienes acceso al nuevo objeto hasta que todos los inicializadores de campos
se hayan completado. Pero con un campo late, eso ya no es así, por lo que
sí puedes
acceder a this, llamar a métodos o acceder a campos en la instancia.
Variables final late
#También puedes combinar late con final:
// Using null safety:
class Coffee {
late final String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
A diferencia de los campos final normales, no tienes que inicializar el campo en su
declaración o en la lista de inicialización del constructor. Puedes asignarle un valor
más tarde en tiempo de ejecución. Pero solo puedes asignarle un valor una vez, y ese hecho se verifica
en tiempo de ejecución. Si intentas asignarle un valor más de una vez —como llamar tanto a
heat() como a chill() aquí— la segunda asignación lanza una excepción.
Esta es una excelente manera de modelar el estado que se inicializa eventualmente y es
inmutable después.
En otras palabras, el nuevo modificador late en combinación con los otros
modificadores de variables de Dart cubre la mayor parte del espacio de características de lateinit en Kotlin y
lazy en Swift. Incluso puedes usarlo en variables locales si deseas una pequeña
evaluación perezosa local.
Parámetros nombrados obligatorios
#Para garantizar que nunca veas un parámetro null con un tipo no nulo, el
comprobador de tipos requiere que todos los parámetros opcionales tengan un tipo nullable o
un valor predeterminado. ¿Qué pasa si deseas tener un parámetro nombrado con un tipo no nulo
y sin valor predeterminado? Eso implicaría que quieres requerir que el llamador
lo pase siempre. En otras palabras, quieres un parámetro que sea nombrado
pero no opcional.
Visualizo los diversos tipos de parámetros de Dart con esta tabla:
mandatory optional
+------------+------------+
positional | f(int x) | f([int x]) |
+------------+------------+
named | ??? | f({int x}) |
+------------+------------+
Por razones no del todo claras, Dart ha admitido durante mucho tiempo tres esquinas de esta tabla, pero
dejó vacía la combinación de nombrado+obligatorio. Con null safety, completamos ese
espacio. Declaras un parámetro nombrado obligatorio colocando required antes del
parámetro:
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}
Aquí, todos los parámetros deben pasarse por nombre. Los parámetros a y c son
opcionales y se pueden omitir. Los parámetros b y d son obligatorios y deben
pasarse. Ten en cuenta que la obligatoriedad (required-ness) es independiente de la anulabilidad (nullability). Puedes tener
parámetros nombrados obligatorios de tipos nullables y parámetros nombrados opcionales de
tipos no nulos (si tienen un valor predeterminado).
Esta es otra de esas características que creo que mejoran a Dart independientemente de null safety. Simplemente hace que el lenguaje se sienta más completo para mí.
Campos abstractos
#Una de las características más interesantes de Dart es que mantiene algo llamado el principio de acceso uniforme. En términos humanos, significa que los campos son indistinguibles de los getters y setters. Es un detalle de implementación si una "propiedad" en alguna clase Dart es calculada o almacenada. Debido a esto, al definir una interfaz utilizando una clase abstracta, es típico usar una declaración de campo:
abstract class Cup {
Beverage contents;
}
La intención es que los usuarios solo implementen esa clase y no la extiendan. La sintaxis de campo es simplemente una forma más corta de escribir un par getter/setter:
abstract class Cup {
Beverage get contents;
set contents(Beverage);
}
Pero Dart no sabe que esta clase nunca se usará como un tipo concreto.
Ve esa declaración de contents como un campo real.
Y, desafortunadamente, ese campo no es nulo y no tiene inicializador,
por lo que obtienes un error de compilación.
Una solución es usar declaraciones explícitas de getter/setter abstractos como en el segundo ejemplo. Pero eso es un poco detallado, por lo que con null safety también añadimos soporte para declaraciones de campos abstractos explícitos:
abstract class Cup {
abstract Beverage contents;
}
Esto se comporta exactamente como el segundo ejemplo. Simplemente declara un getter y setter abstractos con el nombre y tipo dados.
Trabajar con campos nulos
#Estas nuevas características cubren muchos patrones comunes y hacen que trabajar con null
sea bastante sencillo la mayor parte del tiempo. Pero aun así, nuestra experiencia es que los campos
nullables todavía pueden ser difíciles. En los casos en que puedas hacer que el campo sea late
y
no nulo, tienes la solución ideal. Pero en muchos casos necesitas verificar si el
campo tiene un valor, y eso requiere hacerlo nullable para que puedas observar el
null.
Los campos nullables que son tanto privados como final pueden realizar la promoción de tipos (salvo por algunas razones particulares). Si no puedes hacer que un campo sea privado y final por cualquier motivo, de todos modos necesitarás una alternativa.
Por ejemplo, podrías esperar que esto funcione:
// Using null safety, incorrectly:
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
void checkTemp() {
if (_temperature != null) {
print('Ready to serve ' + _temperature + '!');
}
}
String serve() => _temperature! + ' coffee';
}
Dentro de checkTemp(), verificamos si _temperature es null. Si no lo es,
accedemos a él y terminamos llamando a + en él. Desafortunadamente, esto no está permitido.
La promoción de tipos basada en el flujo solo se puede aplicar a campos que son tanto privados como final.
De lo contrario, el análisis estático no puede demostrar que el valor del campo no
cambie entre el punto en que verificas null y el punto en que lo usas.
(Considera que en casos patológicos, el campo en sí podría ser anulado por un
getter en una subclase que devuelva null la segunda vez que se le llame).
Así que, dado que nos importa la solidez (soundness), los campos públicos y/o no final no se promueven,
y el método anterior no se compila. Esto es molesto.
En casos simples como este, la mejor opción es colocar un ! al usar el campo.
Parece redundante, pero así es más o menos como se comporta Dart hoy en día.
Otro patrón que ayuda es copiar el campo a una variable local primero y luego usar esa en su lugar:
// Using null safety:
void checkTemp() {
var temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}
Dado que la promoción de tipos sí se aplica a las variables locales, esto ahora funciona bien. Si necesitas cambiar el valor, recuerda guardarlo de nuevo en el campo y no solo en la variable local.
Para más información sobre el manejo de estos y otros problemas de promoción de tipos, consulta Fixing type promotion failures.
Anulabilidad y genéricos
#Como la mayoría de los lenguajes modernos de tipado estático, Dart tiene clases genéricas y métodos genéricos. Estos interactúan con la anulabilidad de formas que parecen contraintuitivas, pero que tienen sentido una vez que analizas las implicaciones. La primera es que "¿es este tipo nullable?" ya no es una simple pregunta de sí o no. Considera:
// Using null safety:
class Box<T> {
final T object;
Box(this.object);
}
void main() {
Box<String>('a string');
Box<int?>(null);
}
En la definición de Box, ¿es T un tipo nullable o no nulo? Como
puedes ver, se puede instanciar con cualquiera de los dos. La respuesta es que T
es un
tipo potencialmente nullable. Dentro del cuerpo de una clase o método genérico, un
tipo potencialmente nullable tiene todas las restricciones de los tipos nullables
y de los tipos no nulos.
Lo primero significa que no puedes llamar a ningún método en él, excepto a los pocos definidos en Object. Lo segundo significa que debes inicializar cualquier campo o variable de ese tipo antes de que se utilicen. Esto puede hacer que sea bastante difícil trabajar con parámetros de tipo.
En la práctica, aparecen algunos patrones. En clases tipo colección donde el parámetro de tipo se puede instanciar con cualquier tipo de dato, simplemente tienes que lidiar con las restricciones. En la mayoría de los casos, como en el ejemplo de aquí, significa asegurarte de tener acceso a un valor del tipo del argumento de tipo siempre que necesites trabajar con uno. Afortunadamente, las clases de tipo colección rara vez llaman a métodos en sus elementos.
En los lugares donde no tienes acceso a un valor, puedes hacer que el uso del parámetro de tipo sea nullable:
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
}
Observa el ? en la declaración de object. Ahora el campo tiene un tipo explícitamente
nullable, por lo que no hay problema en dejarlo sin inicializar.
Cuando haces que el tipo de un parámetro de tipo sea nullable como T? aquí, es posible que
necesites eliminar la anulabilidad mediante una conversión. La forma correcta de hacerlo es usando una
conversión explícita as T, no el operador !:
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}
El operador ! siempre lanza una excepción si el valor es null. Pero si el parámetro de
tipo se ha instanciado con un tipo nullable, entonces null es un valor perfectamente
válido para T:
// Using null safety:
void main() {
var box = Box<int?>.full(null);
print(box.unbox());
}
Este programa debería ejecutarse sin errores. El uso de as T logra eso. El uso de
! lanzaría una excepción.
Otros tipos genéricos tienen algún límite (bound) que restringe los tipos de argumentos que se pueden aplicar:
// Using null safety:
class Interval<T extends num> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty => max <= min;
}
Si el límite no admite nulos, entonces el parámetro de tipo tampoco admite nulos. Esto significa que tienes las restricciones de los tipos no nulos: no puedes dejar campos y variables sin inicializar. La clase de ejemplo aquí debe tener un constructor que inicialice los campos.
A cambio de esa restricción, puedes llamar a cualquier método en valores del tipo del parámetro de tipo que estén declarados en su límite. Tener un límite no nulo, sin embargo, impide que los usuarios de tu clase genérica la instancen con un argumento de tipo nullable. Esa es probablemente una limitación razonable para la mayoría de las clases.
También puedes usar un límite (bound) nullable:
// Using null safety:
class Interval<T extends num?> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty {
var localMin = min;
var localMax = max;
// No min or max means an open-ended interval.
if (localMin == null || localMax == null) return false;
return localMax <= localMin;
}
}
Esto significa que en el cuerpo de la clase obtienes la flexibilidad de tratar el
parámetro de tipo como nullable, pero también tienes las limitaciones de la anulabilidad.
No puedes llamar a nada en una variable de ese tipo a menos que manejes la anulabilidad primero.
En el ejemplo de aquí, copiamos los campos en variables locales y verificamos esas locales para
null para que el análisis de flujo las promueva a tipos no nulos antes de usar <=.
Ten en cuenta que un límite nullable no impide que los usuarios instancen la clase
con tipos no nulos. Un límite nullable significa que el argumento de tipo puede
ser
nullable, no que deba serlo. (De hecho, el límite predeterminado en los parámetros de tipo si
no escribes una cláusula extends es el límite nullable Object?). No hay
forma de requerir un argumento de tipo nullable. Si deseas que los usos del parámetro de
tipo sean confiablemente nullables y se inicialicen implícitamente a null,
puedes usar T? dentro del cuerpo de la clase.
Cambios en la biblioteca principal
#Hay un par de otros ajustes aquí y allá en el lenguaje, pero son
menores. Por ejemplo, el tipo predeterminado de un bloque catch sin cláusula on ahora es
Object en lugar de dynamic. El análisis de caída (fallthrough) en las sentencias switch
utiliza el nuevo análisis de flujo.
Los cambios restantes que realmente te importan están en las bibliotecas principales.
Antes de embarcarnos en la gran aventura de null safety, nos preocupaba que resultara
imposible hacer que nuestras bibliotecas principales fueran seguras contra nulos sin romper
masivamente el mundo. Resultó no ser tan terrible. Hay algunos cambios
significativos, pero en su mayor parte, la migración se realizó sin problemas. La mayoría de las bibliotecas
principales no aceptaban null y pasaron de forma natural a tipos no nulos, o lo
hacían y lo aceptan con gracia con un tipo nullable.
Sin embargo, hay algunos detalles importantes:
El operador de índice de Map admite nulos
#Esto no es realmente un cambio, sino más bien algo a tener en cuenta. El operador de índice [] en
la clase Map devuelve null si la clave no está presente. Esto implica que el
tipo de retorno de ese operador debe ser nullable: V? en lugar de V.
Podríamos haber cambiado ese método para lanzar una excepción cuando la clave no esté
presente y luego haberle dado un tipo de retorno no nulo más fácil de usar. Pero el código
que usa el operador de índice y verifica si hay null para ver si la clave está ausente
es muy común, alrededor de la mitad de todos los usos según nuestro análisis. Romper todo
ese código habría incendiado el ecosistema de Dart.
En su lugar, el comportamiento en tiempo de ejecución es el mismo y, por lo tanto, el tipo de retorno está obligado a ser nullable. Esto significa que generalmente no puedes usar inmediatamente el resultado de una búsqueda en un mapa:
// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.
Esto te genera un error de compilación al intentar llamar a .length en una cadena
nullable. En los casos en que sepas que la clave está presente, puedes guiar al comprobador de tipos
usando !:
// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.
Consideramos agregar otro método a Map que hiciera esto por ti: buscar
la clave, lanzar una excepción si no la encontraba o devolver un valor no nulo en caso contrario. ¿Pero cómo
llamarlo? Ningún nombre sería más corto que el carácter único !, y ningún
nombre de método sería más claro que ver un ! con su semántica integrada allí
mismo en el lugar de la llamada. Así que la forma idiomática de acceder a un elemento que se sabe presente
en un mapa es usar []!. Uno se acostumbra.
Sin constructor de List sin nombre
#El constructor sin nombre de List crea una nueva lista con el tamaño indicado pero
no inicializa ninguno de los elementos. Esto abriría un agujero muy grande en
las garantías de solidez si crearas una lista de un tipo no nulo y luego
accedieras a un elemento.
Para evitar eso, hemos eliminado el constructor por completo. Es un error llamar a
List() en código seguro contra nulos, incluso con un tipo nullable. Eso suena alarmante, pero
en la práctica la mayoría del código crea listas usando literales de lista, List.filled(),
List.generate() o como resultado de transformar alguna otra colección. Para
el caso extremo en el que desees crear una lista vacía de algún tipo, añadimos un
nuevo constructor List.empty().
El patrón de crear una lista completamente sin inicializar siempre se ha sentido fuera de lugar en Dart, y ahora lo está aún más. Si tienes código afectado por esto, siempre puedes solucionarlo utilizando una de las muchas otras formas de producir una lista.
No se puede establecer una longitud mayor en listas que no admiten nulos
#Esto es poco conocido, pero el getter length en List también tiene un
setter correspondiente. Puedes establecer la longitud en un valor más corto para truncar la lista. Y
también puedes establecerla en una longitud más larga para rellenar la lista con elementos
no inicializados.
Si hicieras eso con una lista de un tipo no nulo, violarías la
solidez cuando más tarde accedieras a esos elementos no escritos. Para evitar eso, el
setter de length lanzará una excepción en tiempo de ejecución si (y solo si) la lista tiene un
tipo de elemento no nulo y la estableces en una longitud más larga. Sigue siendo
válido truncar listas de todos los tipos, y puedes hacer crecer listas de tipos nullables.
Hay una consecuencia importante de esto si defines tus propios tipos de lista que
extiendan ListBase o apliquen ListMixin. Ambos tipos proporcionan una
implementación de insert() que anteriormente abría espacio para el elemento insertado
estableciendo la longitud. Eso fallaría con null safety, por lo que en su lugar cambiamos
la implementación de insert() en ListMixin (que comparte ListBase) para
llamar a add() en su lugar. Tu clase de lista personalizada debería proporcionar una definición de
add() si deseas poder usar ese método insert() heredado.
No se puede acceder a Iterator.current antes o después de la iteración
#La clase Iterator es la clase de "cursor" mutable utilizada para recorrer los elementos
de un tipo que implementa Iterable. Se espera que llames a moveNext()
antes de acceder a cualquier elemento para avanzar al primer elemento. Cuando ese método
devuelve false, has llegado al final y no hay más elementos.
Solía ocurrir que current devolvía null si lo llamabas ya sea antes
de llamar a moveNext() por primera vez o después de que terminara la iteración. Con null
safety, eso requeriría que el tipo de retorno de current fuera E?
y no E.
Eso a su vez significa que cada acceso a un elemento requeriría una comprobación de null
en tiempo de ejecución.
Esas comprobaciones serían inútiles dado que casi nadie accede al elemento actual
de esa manera errónea. En su lugar, hemos hecho que el tipo de current
sea
E. Dado que puede haber un valor de ese tipo disponible antes o después de
iterar, hemos dejado el comportamiento del iterador indefinido si lo llamas cuando no se
supone que debas hacerlo. La mayoría de las implementaciones de Iterator lanzan un StateError.
Resumen
#Ese es un recorrido muy detallado por todos los cambios del lenguaje y las bibliotecas en torno a null safety. Es un montón de cosas, pero este es un cambio bastante grande en el lenguaje. Más importante aún, queríamos llegar a un punto en el que Dart todavía se sienta cohesivo y usable. Eso requiere cambiar no solo el sistema de tipos, sino también una serie de otras características de usabilidad a su alrededor. No queríamos que se sintiera como si null safety se hubiera añadido a la fuerza.
Los puntos fundamentales a tener en cuenta son:
Los tipos son no nulos por defecto y se hacen nullables añadiendo
?.-
Los parámetros opcionales deben ser nullables o tener un valor predeterminado. Puedes usar
requiredpara hacer que los parámetros nombrados no sean opcionales. Las variables de nivel superior y los campos estáticos no nullables deben tener inicializadores. Los campos de instancia no nullables deben inicializarse antes de que comience el cuerpo del constructor. -
Las cadenas de métodos después de operadores null-aware se cortocircuitan si el receptor es
null. Hay nuevos operadores de cascada null-aware (?..) y de índice (?[]). El operador postfijo de aserción no nula "bang" (!) convierte su operando nullable al tipo no nulo subyacente. -
El análisis de flujo te permite convertir de forma segura variables locales y parámetros nullables (y campos final privados, a partir de Dart 3.2) en otros no nullables usables. El nuevo análisis de flujo también tiene reglas más inteligentes para la promoción de tipos, retornos faltantes, código inalcanzable e inicialización de variables.
-
El modificador
latete permite usar tipos no nullables yfinalen lugares donde de otro modo no podrías hacerlo, a expensas de la comprobación en tiempo de ejecución. También te proporciona campos inicializados de forma perezosa. La clase
Listse modifica para evitar elementos no inicializados.
Finalmente, una vez que absorbas todo eso y lleves tu código al mundo de null safety, obtienes un programa sólido (sound) que los compiladores pueden optimizar y donde cada lugar donde puede ocurrir un error en tiempo de ejecución es visible en tu código. Esperamos que sientas que vale la pena el esfuerzo para llegar allí.
A menos que se indique lo contrario, la documentación en este sitio refleja Dart 3.12.2. Página actualizada por última vez el 2025-05-05. Ver fuente oreportar un problema.