En el sistema de tipos de Dart, la varianza determina cómo los tipos complejos (como las listas o las funciones) cambian su relación de subtipado cuando sus componentes también cambian. En términos simples, si tenemos un tipo Sub que es subtipo de Super, la varianza define si Container<Sub> también es un subtipo de Container<Super>.
En Dart, la covarianza permite que un List<String> sea tratado como un List<Object>. Esto ocurre porque la relación de sub-tipado se mantiene en la misma dirección: como un String es un Object, una lista de strings se comporta como una lista de objetos desde el punto de vista de la lectura. Sin embargo, esto introduce un problema de soundness (integridad) en el sistema de tipos. Si el sistema permitiera tratar la lista de strings como una lista de objetos de forma totalmente segura para la escritura, podrías intentar insertar un int en una lista que, en realidad, solo puede contener strings. Para evitar un desastre en la memoria, Dart permite la covarianza en las listas pero lanza un TypeError en tiempo de ejecución si intentas una operación de escritura incompatible.
Por otro lado, la varianza también afecta a las funciones. En Dart, los tipos de retorno de las funciones son covariantes (puedes devolver un tipo más específico de lo prometido), pero los tipos de sus parámetros son contravariantes (puedes aceptar un tipo más genérico de lo esperado). Para manejar excepciones en la jerarquía de clases, Dart introduce la keyword covariant en los parámetros de métodos, permitiendo que una subclase refine el tipo de un parámetro, asumiendo la responsabilidad de la validación en runtime.
A diferencia de lenguencias como C# o Kotlin, que utilizan palabras clave como in (contravarianza) o out (covarianza) en la declaración del tipo genérico para asegurar la integridad en tiempo de compilación, Dart opta por una flexibilidad pragmática en sus colecciones mutables.
abstract class Animal {
String get species;
}
class Dog extends Animal {
@override
String get species => 'Perro';
void bark() => print('¡Guau!');
}
class Cat extends Animal {
@override
String get species => 'Gato';
}
void main() {
// 1. Covarianza en colecciones (Lectura segura, escritura peligrosa)
final List<Dog> dogs = [Dog()];
// List<Dog> es subtipo de List<Animal> por covarianza.
// Esto es seguro para lectura: todo Dog es un Animal.
final List<Animal> animals = dogs;
print('Especies detectadas: ${animals.map((a) => a.species).toList()}');
// 2. El riesgo de la mutabilidad: El problema de la falta de soundness
// Al tratar la lista de perros como una lista de objetos genérica:
final List<Object> objects = dogs;
try {
// El compilador lo permite (un int es un Object), pero la lista original
// subyacente solo acepta Dog. Esto rompe la integridad del tipo.
objects.add(42);
} catch (e) {
print('Error detectado en runtime: $e');
}
// 3. El keyword 'covariant' para refinar parámetros en subclases
class AnimalHandler {
void handle(Animal animal) {
print('Procesando un animal genérico...');
}
}
class DogHandler extends AnimalHandler {
@override
// Sin 'covariant', el compilador rechazaría el cambio de Animal a Dog
// porque la firma del método no coincidiría exactamente.
void handle(covariant Dog animal) {
print('Procesando un perro específicamente:');
animal.bark();
}
}
final handler = DogHandler();
handler.handle(Dog());
// 4. Varianza en tipos de función
// Un tipo de función es subtipo si sus retornos son covariantes
// y sus parámetros son contravariantes.
// Esta función cumple con el contrato: recibe un Animal y devuelve un Animal.
void processAnimal(Animal a) => print('Procesando ${a.species}...');
// Podemos pasarle una función que sea más específica en el retorno (covarianza)
// o más genérica en el parámetro (contravarianza).
final Function genericFunc = (Animal a) => Dog();
}
Desglose del ejemplo
En el primer bloque, vemos la covarianza en acción con animals = dogs. El motor de Dart permite esta asignación porque es seguro “leer” de la lista; cualquier elemento que extraigas de animals será, como mínimo, un Animal. Sin embargo, cuando asignamos objects = dogs, estamos cayendo en un agujero de seguridad. La variable objects tiene el tipo List<Object>, por lo que objects.add(42) es sintácticamente válido para el compilador, pero la implementación real de la lista sigue siendo una de Dog. El error TypeError ocurre en el momento exacto en que intentas corromper la estructura de la lista.
En la clase DogHandler, el uso de covariant Dog animal es fundamental. En Dart, cuando sobrescribes un método, los tipos deben ser idénticos. No obstante, en la lógica de objetos, a menudo necesitamos que una subclase sea más específica. covariant le dice al compilador: “Confía en mí, yo me encargaré de que el tipo sea el correcto en tiempo de ejecución”. Si llamáramos a handler.handle(Cat()) con un DogHandler, el programa fallaría.
Finalmente, la parte de funciones ilustra la teoría de tipos: la función genericFunc es un ejemplo de cómo el sistema permite flexibilidad. Un tipo de función es un subtipo de otro si es “más capaz” de aceptar argumentos (contravarianza) y “más capaz” de devolver resultados (covarianza).
El error frecuente
Un error común es confiar en la covarianza para realizar operaciones de escritura en colecciones tipadas bajo un tipo base, creyendo que el compilador los detendrá.
void errorDeSeguridad() {
final List<String> nombres = ['Dart', 'Especialista'];
// El compilador permite esto por covarianza
final List<Object> listaGenerica = nombres;
// Error: El compilador no ve el problema, pero el runtime sí.
// Esto no es un error de compilación, es un TypeError en ejecución.
listaGenerica.add(123);
}
Si trabajas con arquitecturas donde las colecciones se pasan a través de múltiples capas de abstracción, evita pasar colecciones mutables como tipos base (List<Object>) si pretendes realizar escrituras en ellas; usa tipos específicos para mantener la seguridad del tipo en tiempo de compilación.
N° 55