Saltar al contenido principal

El sistema de tipos de Dart

Por qué y cómo escribir código Dart seguro (sound).

El lenguaje Dart es seguro en cuanto a tipos (type safe): utiliza una combinación de comprobación estática de tipos y comprobaciones en tiempo de ejecución para garantizar que el valor de una variable coincida siempre con el tipo estático de la variable, lo que a veces se denomina tipado seguro (sound typing). Aunque los tipos son obligatorios, las anotaciones de tipo son opcionales debido a la inferencia de tipos.

Uno de los beneficios de la comprobación estática de tipos es la capacidad de encontrar errores en tiempo de compilación utilizando el analyzer estático de Dart.

Puedes solucionar la mayoría de los errores de análisis estático agregando anotaciones de tipo a las clases genéricas. Las clases genéricas más comunes son los tipos de colección List<T> y Map<K,V>.

Por ejemplo, en el siguiente código, la función printInts() imprime una lista de enteros y main() crea una lista y la pasa a printInts().

análisis estático: fallo dart
void printInts(List<int> a) => print(a);

void main() {
  final list = [];
  list.add(1);
  list.add('2');
  printInts(list);
}

El código anterior genera un error de tipo en list (resaltado arriba) en la llamada a printInts(list):

error - The argument type 'List<dynamic>' can't be assigned to the parameter type 'List<int>'. - argument_type_not_assignable

El error resalta una conversión implícita insegura (unsound) de List<dynamic> a List<int>. La variable list tiene el tipo estático List<dynamic>. Esto se debe a que la declaración de inicialización var list = [] no proporciona al analyzer suficiente información para inferir un argumento de tipo más específico que dynamic. La función printInts() espera un parámetro de tipo List<int>, lo que provoca una discrepancia de tipos.

Al agregar una anotación de tipo (<int>) en la creación de la lista (resaltado abajo), el analyzer se queja de que un argumento de cadena no se puede asignar a un parámetro int. Quitar las comillas en list.add('2') da como resultado un código que pasa el análisis estático y se ejecuta sin errores ni advertencias.

análisis estático: éxito dart
void printInts(List<int> a) => print(a);

void main() {
  final list = <int>[];
  list.add(1);
  list.add(2);
  printInts(list);
}

Pruébalo en DartPad.

¿Qué es la seguridad de tipos (soundness)?

#

La seguridad de tipos (soundness) consiste en garantizar que tu programa no pueda entrar en ciertos estados no válidos. Un sistema de tipos seguro (sound) significa que nunca podrás entrar en un estado en el que una expresión se evalúe como un valor que no coincida con el tipo estático de la expresión. Por ejemplo, si el tipo estático de una expresión es String, en tiempo de ejecución se garantiza que solo obtendrás una cadena cuando la evalúes.

El sistema de tipos de Dart, al igual que los sistemas de tipos en Java y C#, es seguro (sound). Impone esa seguridad mediante una combinación de comprobación estática (errores en tiempo de compilación) y comprobaciones en tiempo de ejecución. Por ejemplo, asignar un String a un int es un error en tiempo de compilación. Convertir un objeto a un String usando as String falla con un error en tiempo de ejecución si el objeto no es un String.

Los beneficios de la seguridad de tipos (soundness)

#

Un sistema de tipos seguro (sound) tiene varios beneficios:

  • Revelar errores relacionados con tipos en tiempo de compilación.
    Un sistema de tipos seguro obliga al código a ser inequívoco con respecto a sus tipos, por lo que los errores relacionados con tipos que podrían ser difíciles de encontrar en tiempo de ejecución se revelan en tiempo de compilación.

  • Código más legible.
    El código es más fácil de leer porque puedes confiar en que un valor realmente tiene el tipo especificado. En sound Dart, los tipos no pueden mentir.

  • Código más mantenible.
    Con un sistema de tipos seguro, cuando cambias una parte del código, el sistema de tipos puede advertirte sobre las otras partes del código que acaban de romperse.

  • Mejor compilación ahead of time (AOT).
    Aunque la compilación AOT es posible sin tipos, el código generado es mucho menos eficiente.

Consejos para pasar el análisis estático

#

La mayoría de las reglas para los tipos estáticos son fáciles de entender. Estas son algunas de las reglas menos obvias:

  • Usa tipos de retorno seguros al sobrescribir métodos.
  • Usa tipos de parámetros seguros al sobrescribir métodos.
  • No uses una lista dinámica como una lista con tipo.

Veamos estas reglas en detalle, con ejemplos que utilizan la siguiente jerarquía de tipos:

una jerarquía de animales donde el supertipo es Animal y los subtipos son Alligator, Cat y HoneyBadger. Cat tiene los subtipos Lion y MaineCoon

Usa tipos de retorno seguros al sobrescribir métodos

#

El tipo de retorno de un método en una subclase debe ser del mismo tipo o un subtipo del tipo de retorno del método en la superclase. Considera el método getter en la clase Animal:

dart
class Animal {
  void chase(Animal a) {
     ...
  }
  Animal get parent => ...
}

El método getter parent devuelve un Animal. En la subclase HoneyBadger, puedes reemplazar el tipo de retorno del getter con HoneyBadger (o cualquier otro subtipo de Animal), pero no se permite un tipo no relacionado.

análisis estático: éxito dart
class HoneyBadger extends Animal {
  @override
  void chase(Animal a) {
     ...
  }

  @override
  HoneyBadger get parent => ...
}
análisis estático: fallo dart
class HoneyBadger extends Animal {
  @override
  void chase(Animal a) {
     ...
  }

  @override
  Root get parent => ...
}

Usa tipos de parámetros seguros al sobrescribir métodos

#

El parámetro de un método sobrescrito debe tener el mismo tipo o un supertipo del parámetro correspondiente en la superclase. No "reduzcas" (tighten) el tipo de parámetro reemplazando el tipo con un subtipo del parámetro original.

Considera el método chase(Animal) para la clase Animal:

dart
class Animal {
  void chase(Animal a) {
     ...
  }
  Animal get parent => ...
}

El método chase() toma un Animal. Un HoneyBadger persigue cualquier cosa. Está bien sobrescribir el método chase() para que tome cualquier cosa (Object).

análisis estático: éxito dart
class HoneyBadger extends Animal {
  @override
  void chase(Object a) {
     ...
  }

  @override
  Animal get parent => ...
}

El siguiente código reduce el parámetro en el método chase() de Animal a Mouse, una subclase de Animal.

análisis estático: fallo dart
class Mouse extends Animal {
   ...
}

class Cat extends Animal {
  @override
  void chase(Mouse a) {
     ...
  }
}

Este código no es seguro en cuanto a tipos (type safe) porque entonces sería posible definir un gato y enviarlo tras un caimán:

dart
Animal a = Cat();
a.chase(Alligator()); // Not type safe or feline safe.

No uses una lista dinámica como una lista con tipo

#

Una lista dynamic es útil cuando deseas tener una lista con diferentes tipos de elementos. Sin embargo, no puedes usar una lista dynamic como una lista con tipo.

Esta regla también se aplica a instancias de tipos genéricos.

El siguiente código crea una lista dynamic de Dog y la asigna a una lista de tipo Cat, lo que genera un error durante el análisis estático.

análisis estático: fallo dart
void main() {
  List<Cat> foo = <dynamic>[Dog()]; // Error
  List<dynamic> bar = <dynamic>[Dog(), Cat()]; // OK
}

Comprobaciones en tiempo de ejecución (Runtime checks)

#

Las comprobaciones en tiempo de ejecución se ocupan de los problemas de seguridad de tipos que no se pueden detectar en tiempo de compilación.

Por ejemplo, el siguiente código lanza una excepción en tiempo de ejecución porque es un error convertir una lista de perros en una lista de gatos:

tiempo de ejecución: fallo dart
void main() {
  List<Animal> animals = <Dog>[Dog()];
  List<Cat> cats = animals as List<Cat>;
}

Conversiones descendentes implícitas desde dynamic

#

Las expresiones con un tipo estático de dynamic se pueden convertir implícitamente a un tipo más específico. Si el tipo real no coincide, la conversión lanza un error en tiempo de ejecución. Considera el siguiente método assumeString:

análisis estático: éxito dart
int assumeString(dynamic object) {
  String string = object; // Check at run time that `object` is a `String`.
  return string.length;
}

En este ejemplo, si el objeto es un String, la conversión tiene éxito. Si no es un subtipo de String, como un int, se lanza un TypeError:

tiempo de ejecución: fallo dart
final length = assumeString(1);

Inferencia de tipos

#

El analyzer puede inferir tipos para campos, métodos, variables locales, y la mayoría de los argumentos de tipo genérico. Cuando el analyzer no tiene suficiente información para inferir un tipo específico, utiliza el tipo dynamic.

Aquí tienes un ejemplo de cómo funciona la inferencia de tipos con genéricos. En este ejemplo, una variable llamada arguments contiene un mapa que empareja claves de cadena con valores de varios tipos.

Si tipificas explícitamente la variable, podrías escribir esto:

dart
Map<String, Object?> arguments = {'argA': 'hello', 'argB': 42};

Alternativamente, puedes usar var o final y dejar que Dart infiera el tipo:

dart
var arguments = {'argA': 'hello', 'argB': 42}; // Map<String, Object>

El literal de mapa infiere su tipo a partir de sus entradas, y luego la variable infiere su tipo a partir del tipo del literal de mapa. En este mapa, las claves son ambas cadenas, pero los valores tienen tipos diferentes (String e int, que tienen como límite superior Object). Por lo tanto, el literal de mapa tiene el tipo Map<String, Object>, y también lo tiene la variable arguments.

Inferencia de campos y métodos

#

Un campo o método que no tiene un tipo especificado y que sobrescribe un campo o método de la superclase, hereda el tipo del método o campo de la superclase.

Un campo que no tiene un tipo declarado o heredado pero que se declara con un valor inicial, obtiene un tipo inferido basado en el valor inicial.

Inferencia de campos estáticos

#

Los campos estáticos y las variables obtienen sus tipos inferidos de su inicializador. Ten en cuenta que la inferencia falla si encuentra un ciclo (es decir, inferir un tipo para la variable depende de conocer el tipo de esa variable).

Inferencia de variables locales

#

Los tipos de variables locales se infieren de su inicializador, si lo hay. Las asignaciones posteriores no se tienen en cuenta. Esto podría significar que se infiera un tipo demasiado preciso. Si es así, puedes agregar una anotación de tipo.

análisis estático: fallo dart
var x = 3; // x is inferred as an int.
x = 4.0;
análisis estático: éxito dart
num y = 3; // A num can be double or int.
y = 4.0;

Inferencia de argumentos de tipo

#

Los argumentos de tipo para las llamadas al constructor y las invocaciones de métodos genéricos (generic methods) se infieren en función de una combinación de información descendente (downward) del contexto de ocurrencia e información ascendente (upward) de los argumentos del constructor o método genérico. Si la inferencia no está haciendo lo que deseas o esperas, siempre puedes especificar explícitamente los argumentos de tipo.

análisis estático: éxito dart
// Inferred as if you wrote <int>[].
List<int> listOfInt = [];

// Inferred as if you wrote <double>[3.0].
var listOfDouble = [3.0];

// Inferred as Iterable<int>.
var ints = listOfDouble.map((x) => x.toInt());

En el último ejemplo, x se infiere como double utilizando información descendente. El tipo de retorno del closure se infiere como int utilizando información ascendente. Dart utiliza este tipo de retorno como información ascendente al inferir el argumento de tipo del método map(): <int>.

Inferencia mediante límites (bounds)

#

Con la característica de inferencia mediante límites, el algoritmo de inferencia de tipos de Dart genera restricciones combinando las restricciones existentes con los límites de tipo declarados, no solo aproximaciones de mejor esfuerzo.

Esto es especialmente importante para los tipos F-bounded (con límites F), donde la inferencia mediante límites infiere correctamente que, en el ejemplo siguiente, X puede vincularse a B. Sin la característica, el argumento de tipo debe especificarse explícitamente: f<B>(C()):

dart
class A<X extends A<X>> {}

class B extends A<B> {}

class C extends B {}

void f<X extends A<X>>(X x) {}

void main() {
  f(B()); // OK.

  // OK. Without using bounds, inference relying on best-effort approximations
  // would fail after detecting that `C` is not a subtype of `A<C>`.
  f(C());

  f<B>(C()); // OK.
}

Aquí tienes un ejemplo más realista utilizando tipos cotidianos en Dart como int o num:

dart
X max<X extends Comparable<X>>(X x1, X x2) => x1.compareTo(x2) > 0 ? x1 : x2;

void main() {
  // Inferred as `max<num>(3, 7)` with the feature, fails without it.
  max(3, 7);
}

Con la inferencia mediante límites, Dart puede descomponer (deconstruct) los argumentos de tipo, extrayendo información de tipo del límite de un parámetro de tipo genérico. Esto permite que funciones como f en el siguiente ejemplo preserven tanto el específico tipo iterable (List o Set) y el tipo de elemento. Antes de la inferencia mediante límites, esto no era posible sin perder la seguridad de tipos o información de tipo específica.

dart
(X, Y) f<X extends Iterable<Y>, Y>(X x) => (x, x.first);

void main() {
  var (myList, myInt) = f([1]);
  myInt.whatever; // Compile-time error, `myInt` has type `int`.

  var (mySet, myString) = f({'Hello!'});
  mySet.union({}); // Works, `mySet` has type `Set<String>`.
}

Sin la inferencia mediante límites, myInt tendría el tipo dynamic. El algoritmo de inferencia anterior no detectaría la expresión incorrecta myInt.whatever en tiempo de compilación, y en su lugar lanzaría una excepción en tiempo de ejecución. Por el contrario, mySet.union({}) sería un error en tiempo de compilación sin la inferencia mediante límites, porque el algoritmo anterior no podía preservar la información de que mySet es un Set.

Para obtener más información sobre el algoritmo de inferencia mediante límites, lee el documento de diseño (design document).

Sustituir tipos

#

Cuando sobrescribes un método, estás reemplazando algo de un tipo (en el método antiguo) con algo que podría tener un tipo nuevo (en el método nuevo). De manera similar, cuando pasas un argumento a una función, estás reemplazando algo que tiene un tipo (un parámetro con un tipo declarado) con algo que tiene otro tipo (el argumento real). ¿Cuándo puedes reemplazar algo que tiene un tipo con algo que tiene un subtipo o un supertipo?

Al sustituir tipos, resulta útil pensar en términos de consumidores y productores. Un consumidor absorbe un tipo y un productor genera un tipo.

Puedes reemplazar el tipo de un consumidor por un supertipo y el tipo de un productor por un subtipo.

Veamos ejemplos de asignación de tipos simples y asignación con tipos genéricos.

Asignación de tipos simples

#

Al asignar objetos a objetos, ¿cuándo puedes reemplazar un tipo por otro diferente? La respuesta depende de si el objeto es un consumidor o un productor.

Considera la siguiente jerarquía de tipos:

una jerarquía de animales donde el supertipo es Animal y los subtipos son Alligator, Cat y HoneyBadger. Cat tiene los subtipos Lion y MaineCoon

Considera la siguiente asignación simple donde Cat c es un consumidor y Cat() es un productor:

dart
Cat c = Cat();

En una posición de consumo, es seguro reemplazar algo que consume un tipo específico (Cat) con algo que consume cualquier cosa (Animal), por lo que se permite reemplazar Cat c por Animal c, porque Animal es un supertipo de Cat.

análisis estático: éxito dart
Animal c = Cat();

Pero reemplazar Cat c por MaineCoon c rompe la seguridad de tipos, porque la superclase podría proporcionar un tipo de Cat con comportamientos diferentes, como Lion:

análisis estático: fallo dart
MaineCoon c = Cat();

En una posición de producción, es seguro reemplazar algo que produce un tipo (Cat) por un tipo más específico (MaineCoon). Por lo tanto, se permite lo siguiente:

análisis estático: éxito dart
Cat c = MaineCoon();

Asignación de tipos genéricos

#

¿Son las reglas las mismas para los tipos genéricos? Sí. Considera la jerarquía de listas de animales: una List de Cat es un subtipo de una List de Animal, y un supertipo de una List de MaineCoon:

List<animal> -&gt; List<cat> -&gt; List<mainecoon>
</mainecoon></cat></animal>

En el siguiente ejemplo, puedes asignar una lista de MaineCoon a myCats porque List<MaineCoon> es un subtipo de List<Cat>:

análisis estático: éxito dart
List<MaineCoon> myMaineCoons = ...
List<Cat> myCats = myMaineCoons;

¿Qué pasa si vamos en la otra dirección? ¿Puedes asignar una lista de Animal a una List<Cat>?

análisis estático: fallo dart
List<Animal> myAnimals = ...
List<Cat> myCats = myAnimals;

Esta asignación no pasa el análisis estático porque crea una conversión descendente (downcast) implícita, lo cual no está permitido desde tipos que no sean dynamic como Animal.

Para que este tipo de código pase el análisis estático, puedes usar una conversión (cast) explícita.

dart
List<Animal> myAnimals = ...
List<Cat> myCats = myAnimals as List<Cat>;

Sin embargo, una conversión explícita aún podría fallar en tiempo de ejecución, dependiendo del tipo real de la lista que se está convirtiendo (myAnimals).

Métodos

#

Al sobrescribir un método, las reglas de productor y consumidor se siguen aplicando. Por ejemplo:

La clase Animal que muestra el método chase como consumidor y el getter padre como productor

Para un consumidor (como el método chase(Animal)), puedes reemplazar el tipo de parámetro por un supertipo. Para un productor (como el método getter parent), puedes reemplazar el tipo de retorno por un subtipo.

Para obtener más información, consulta Usa tipos de retorno seguros al sobrescribir métodos y Usa tipos de parámetros seguros al sobrescribir métodos.

Parámetros covariantes

#

Algunos patrones de codificación (poco utilizados) se basan en reducir (tighten) un tipo al sobrescribir el tipo de un parámetro con un subtipo, lo cual no es válido. En este caso, puedes usar la palabra clave covariant para decirle al analyzer que estás haciendo esto intencionadamente. Esto elimina el error estático y en su lugar comprueba si hay un tipo de argumento no válido en tiempo de ejecución.

Lo siguiente muestra cómo podrías usar covariant:

análisis estático: éxito dart
class Animal {
  void chase(Animal x) {
     ...
  }
}

class Mouse extends Animal {
   ...
}

class Cat extends Animal {
  @override
  void chase(covariant Mouse x) {
     ...
  }
}

Aunque este ejemplo muestra el uso de covariant en el subtipo, la palabra clave covariant se puede colocar tanto en el método de la superclase como en el de la subclase. Por lo general, el método de la superclase es el mejor lugar para colocarlo. La palabra clave covariant se aplica a un solo parámetro y también se admite en setters y campos.

Otros recursos

#

Los siguientes recursos contienen más información sobre el código seguro en Dart (sound Dart):