Varianza en sitio de declaración en Dart

Un análisis profundo de una característica experimental

Gráfico mostrando la varianza declaration-site de Dart.

La varianza declaration-site fue mi proyecto de prácticas en el equipo de Dart, y he documentado mi experiencia personal en el equipo en el artículo Life as a Dart intern. Como el implementador principal de la característica de varianza declaration-site, quiero compartir los usos y beneficios de la varianza sound.

Discutiremos cómo usar la varianza, por qué queremos usar los modificadores, cómo la característica puede construirse sobre clases que no usan los modificadores, y qué beneficios nos proporciona esta característica.

Nota: La implementación de la varianza aún no ha terminado. Aunque puedes probarla habilitando el experimento (instrucciones abajo), la característica podría cambiar antes de finalizar.

Antes de profundizar en la característica de varianza declaration-site de Dart, haremos un pequeño desvío para discutir qué significa la varianza y cómo se usa.

¿Qué es la varianza?

#

Para introducir brevemente la varianza, podemos mirar este ejemplo:

dart
main() {
  Iterable<Object> objectIterable = <int>[1, 2, 3];
}

A partir de esto, un Iterable de enteros puede sustituirse como un Iterable de Objects porque un entero es un Object y puede usarse en un Iterable en cualquier lugar donde un Object lo sería. El lenguaje permite esto diciendo que dos instanciaciones del mismo tipo genérico (como Iterable<int> y Iterable<Object> aquí) se consideran subtipos si sus argumentos de tipo (int y Object) lo son. Esta relación de subtipado se considera covarianza.

Esto es conveniente y lógico. Tiene sentido, es decir, hasta que le echas un vistazo a la varianza de los parámetros de un método. Supongamos que quieres hacer un objectWriter en Dart:

dart
class Writer<T> {
  void write(T x) => print(x);
}

main() {
  Writer<Object> objectWriter = Writer<int>();
  objectWriter.write(2);
}

Luego, con entusiasmo, haces que tu objectWriter escriba un String y descubres que produce un error en tiempo de ejecución. El compilador permite este código, pero cuando lo ejecutas, lanza una excepción.

dart
main() {
  // ...
  // Runtime error!
  // Unhandled exception: type 'String' is not a subtype of type 'int' of 'x'
  objectWriter.write("Hello world!");
}

¿Por qué? Con la contravarianza, la relación de subtipado se invierte en comparación con la covarianza. Necesitamos poder escribir cualquier Object al objectWriter, pero de antes sabemos que el objectWriter es en realidad un escritor de enteros disfrazado.

El último tipo de varianza que necesitas conocer es la invarianza. La relación de subtipado invariante significa que no hay relación de subtipo entre dos tipos invariantes a menos que sean el exactamente el mismo tipo.

dart
class ReaderWriter<T> {
  void write(T x) => print(x);
  T read() => null;
}

main() {
  ReaderWriter<int> intRW = ReaderWriter<Object>();

  // Any object can be returned, not just integers. This is unsafe.
  intRW.read();

  ReaderWriter<Object> anotherObjectRW = ReaderWriter<int>();

  // We actually have an integer ReaderWriter. Error!
  anotherObjectRW.write(Hello world!);
}

¿Cuál es la característica de varianza en Dart?

#

Como el equipo de Dart propuso añadir modificadores de varianza explícitos al lenguaje, presentaremos algunos de los cambios que se esperan.

Dart tendrá modificadores de varianza que pueden aplicarse a parámetros de tipo en clases y mixins. La sintaxis es similar a los modificadores de varianza en C#.

Puedes usar las palabras clave out, in, y inoutpara declarar un parámetro de tipo covariante, contravariante e invariante respectivamente. Esto se usa con tipos genéricos de la siguiente manera:

dart
class MyContravariantClass<in T> {}
mixin MyInvariantMixin<inout T> {}

¿Por qué definir varianza explícita para tipos genéricos? ¿Por qué queremos esta característica?

#

El sistema de tipos estático de Dart trata actualmente todos los parámetros de tipo como covariantes. Eso es correcto y conveniente para genéricos donde el tipo se usa en un lugar covariantemente seguro como un tipo de retorno. Pero es incorrecto cuando el argumento de tipo debería ser contravariante o invariante:

dart
class Writer<T> {
  void write(T x) => print(x);
}

main() {
  Writer<Object> objectWriter = Writer<int>();
  objectWriter.write(Im a string!);
}

Cuando usas objectWriter, esperas poder escribir cualquier Object. Desafortunadamente, el objectWriter solo escribe enteros. El compilador no lo sabe mejor y cuando ejecutas el código, recibes el temido error en tiempo de ejecución. Para evitar la falta de soundness, Dart lanza un error en tiempo de ejecución si usas un argumento de tipo de forma insegura.

Afortunadamente, añadir un modificador de varianza convierte este uso incorrecto de un error en tiempo de ejecución a un error en tiempo de compilación.

dart
// Add a contravariant variance modifier
class Writer<in T> {
  void write(T x) => print(x);
}

main() {
  // Compile-time error: The constructor returns type 'Writer<int>'
  // that isn't of expected type 'Writer<Object>'.
  Writer<Object> objectWriter = Writer<int>();
}

Esto es mucho mejor. Mucho antes de que escribas objectWriter.write("I'm a string!"), el compilador te notificará que hay algo mal.

Ahora, echemos un vistazo a lo que proporciona añadir parámetros tipados de forma segura usando un modificador de varianza.

Parámetros de tipo en miembros

#

Si marcas un parámetro de tipo genérico con out, el compilador emite un error estático si usas ese tipo en un método o campo en un lugar que no es covariantemente seguro como un tipo de retorno. Igualmente, un parámetro de tipo marcado como in solo puede usarse en un lugar que sea contravariantemente seguro como un tipo de parámetro de método. Los parámetros de tipo marcados como inout pueden usarse en cualquier lugar.

Aquí hay algunos errores de posición de varianza en métodos y usos correctos que pueden resultarte útiles. La misma verificación de errores también ocurre para los mixins.

dart
class Reader<out T> {
  // Compile-time error: Can't use 'out' type variable 'T' in an 'in' position.
  void write(T x) => print(x);

  // OK
  T read() => null;
}

class Writer<in T> {
  // Compile-time error: Can't use 'in' type variable 'T' in an 'out' position
  // in the return type.
  T read() => null;

  // OK
  void write(T x) => print(x);
}

class ReaderWriter<inout T> {
  // Both OK
  void write(T value) => print(x);
  T read() => null;
}

Los errores también pueden emitirse en campos.

dart
class Reader<out T> {
  // Compile-time error: Can't use 'out' type variable 'T' in an 'in' position.
  T readableWritableValue;

  // OK
  final T readableValue;
}

class Writer<in T> {
  // Compile-time error: Can't use 'in' type variable 'T' in an 'out' position.
  T readableWritableValue;

  // Compile-time error: Can't use 'in' type variable 'T' in an 'out' position.
  final T readableValue;
}

class ReaderWriter<inout T> {
  // Both OK
  T readableWritableValue;
  final T readableValue;
}

Asignación y subtipado

#

Los errores que el compilador reporta por el uso incorrecto de parámetros de tipo ayudan al autor de la clase genérica a escribir código correcto. La otra mitad es el conjunto de errores que ayudan a otros a usar la clase correctamente. Uno de los cambios que viene con los modificadores de varianza sound es el cambio de subtipado que podemos ver a través de la asignación.

Si un parámetro de tipo genérico es covariante, entonces puedes asignarlo cuando su argumento de tipo es un subtipo del argumento de tipo del tipo esperado. Por ejemplo, puedes asignar un Reader<int> a algo que espera un Reader<Object>.

dart
class Reader<out T> { /* ... */ }
main() {
  Reader<Object> objectReader = Reader<int>();
}

De igual manera, si un parámetro de tipo genérico es contravariante, la asignación se permite cuando su argumento de tipo es un supertipo del argumento de tipo del tipo esperado. Puedes asignar un Writer<Object> a algo que espera un Writer<int>, así:

dart
class Writer<in T> { /* ... */ }
main() {
  Writer<int> intWriter = Writer<Object>();
}

Para parámetros invariantes, el argumento de tipo debe ser el mismo tipo.

dart
class ReaderWriter<inout T> { /* ... */ }
main() {
  ReaderWriter<int> intReader = ReaderWriter<int>();
}

Herencia de interfaces

#

Así que podrías estar preguntándote: “¿Podemos optar por una verificación más fuerte en tiempo de compilación con varianza incluso cuando extendemos clases más antiguas?” Las buenas noticias son que sí puedes; sin embargo, hay algunas restricciones.

Los parámetros out solo pueden extender posiciones de parámetros que son covariantes o tienen la varianza de tipo por defecto de Dart.

Ten en cuenta que cualquier método heredado de clases legacy podría seguir siendo variantemente no sound, y por lo tanto podría seguir causando errores en tiempo de ejecución. De lo contrario, todos los métodos nuevos en una subclase con parámetros de tipo que tengan modificadores de varianza emitirán errores si los tipos están en posiciones no sound.

dart
class LegacyReader<T> {}
class NewReader<out T> extends LegacyReader<T> {} // OK

class Reader<out T> {}
class IntegerReader<out T> extends Reader<T> {} // OK

Los parámetros in solo pueden extender posiciones de parámetros que son contravariantes.

dart
class Writer<in T> {}
class StringWriter<in T> extends Writer<T> {} // OK

Los parámetros inout pueden extender todas las posiciones de parámetros. Sin embargo, un parámetro definido como inout solo puede ser heredado por otras posiciones invariantes.

dart
class ReaderWriter<inout T> {}
class NewReaderWriter<inout T> extends LegacyReader<T> {} // OK
class IntegerReaderWriter<inout T> extends Reader<T> {} // OK
class StringReaderWriter<inout T> extends Writer<T> {} // OK

¿Cómo puedo dar feedback sobre la característica de varianza?

#

Recomendamos probar la característica de varianza usando el último canal dev. Experimenta con este ejemplo para entender cómo funciona la varianza y qué puede hacer por ti.

dart
class Writer<in T> {
  void write(T value) => print(value);
}

class Reader<out T> {
  final T value;
  Reader(this.value);
  T read() => value;
}

main() {
  Writer<int> intWriter = Writer<Object>();
  intWriter.write(2);

  Reader<Object> objectReader =
    Reader<String>("Wow, this is soundly variant!");
  print(objectReader.value);
}

Como la característica de varianza aún está siendo implementada, necesitas establecer un flag experimental para habilitarla:

dart --enable-experiment=variance variance_example.dart

¡Apreciamos cualquier tipo de feedback! Puedes decirnos qué piensas en este issue de GitHub.

Resumen

#

Los genéricos existentes de Dart son covariantes por defecto, lo que hace fácil empezar a escribir nuevas clases y comenzar a usarlas. Esto significa, sin embargo, que más errores aparecen en tiempo de ejecución en lugar de en tiempo de compilación. El usuario también paga el coste de verificaciones adicionales en tiempo de ejecución. La idea principal detrás de la varianza es proporcionar a los usuarios verificaciones de errores más informativas en tiempo de compilación.

La varianza se define solo para los parámetros de clases y mixins genéricos. Los usuarios pueden usar la característica de varianza añadiendo una de las palabras clave in, out o inout antes de un parámetro de tipo.

Además, las nuevas interfaces genéricas con estos modificadores pueden extender interfaces legacy sin modificadores de varianza.

La varianza declaration-site te permite obtener un montón de nuevos beneficios, incluyendo:

  • Verificación de posición variante en tiempo de compilación dentro de los miembros de la interfaz

  • Eliminación de los molestos errores en tiempo de ejecución que ocurren con down casting y up casting

  • Cambios adicionales de subtipado basados en la varianza declarada

  • Verificación de errores más informativa y accesible

Ahora no tendrás que preocuparte si ese objectWriter es realmente un escritor de cualquier objeto. Tú sabes que lo es.

Más de Dart