Herencia y jerarquías de tipos en Dart

Jerarquía de tipos y herencia en Dart

Cuando utilizas extends, estás estableciendo una relación de “es un” (is-a) entre dos clases. Una subclase herda los miembros (propiedades y métodos) de su superclase, permitiéndote reutilizar código y, lo más importante, aprovechar el polimorfismo. En Dart, toda jerarquía de tipos tiene como raíz implícita a Object, lo que significa que cualquier instancia puede ser tratada como un Object. Para que la herencia sea robusta, debemos respetar el Principio de Sustitución de Liskov: cualquier parte de tu programa que espere un tipo Animal debería poder recibir un Perro sin romperse.

La herencia funciona gracias a la asignación de tipos en el runtime. Cuando heredas, puedes realizar un upcasting (tratar a un Coche como un Vehiculo) de forma automática y segura, porque un Coche es un Vehiculo. Sin embargo, el downcasting (tratar un Vehiculo como un Coche) es una operación peligrosa que requiere el operador as o una comprobación previa con is. Si intentas forzar un tipo mediante as y la instancia real no coincide, el programa lanzará una excepción en tiempo de ejecución. Deberías usar la herencia solo cuando exista una especialización real de comportamiento; si solo quieres reutilizar funciones, es preferible la composición.

Para proteger la integridad de tus jerarquías, Dart 3 [disponible desde Dart 3.0] introdujo los class modifiers. Por ejemplo, una sealed class permite que la jerarquía esté cerrada: solo las clases definidas en el mismo archivo pueden extenderla, lo que es fundamental para el pattern matching.

// Usamos sealed para que la jerarquía de Vehiculo sea cerrada en este archivo
sealed class Vehiculo {
  final String marca;
  
  Vehiculo(this.marca);

  void circular() => print('El vehículo $marca se está desplazando.');
  
  String info() => 'Vehículo de marca $marca';
}

class Coche extends Vehiculo {
  final int puertas;

  // super() se usa para llamar al constructor de la superclase
  Coche(String marca, this.puertas) : super(marca);

  @override
  void circular() {
    super.circular(); // Reutilizamos la lógica base de la superclase
    print('...y lo hace con $puertas puertas cerradas.');
  }

  @override
  String info() => 'Coche $marca con $puertas puertas';

  void abrirMaletero() => print('Maletero de $marca abierto.');
}

class Moto extends Vehiculo {
  Moto(String marca) : super(marca);

  @override
  void circular() => print('La moto $marca circula entre el tráfico.');
}

// Ejemplo para ilustrar el uso de 'covariant'
class Animal {
  void comer(Object alimento) => print('El animal está comiendo algo genérico.');
}

class Perro extends Animal {
  // 'covariant' permite restringir el tipo del parámetro en una sobreescritura
  @override
  void comer(covariant String alimento) => print('El perro mastica un hueso de $alimento.');
}

void main() {
  // 1. Upcasting automático: Coche es tratado como Vehiculo
  final lista: List<Vehiculo> = [
    Coche('Toyota', 5),
    Moto('Yamaha'),
  ];

  print('--- Polimorfismo ---');
  for (var v in lista) {
    // El runtime decide qué método ejecutar (dispatch)
    print(v.info());
    v.circular();
  }

  print('\n--- Casting y comprobación ---');
  final miVehiculo: Vehiculo = Coche('Tesla', 4);

  // El downcasting requiere comprobar el tipo con 'is' para ser seguro
  if (miVehiculo is Coche) {
    miVehiculo.abrirMaletero();
  }

  print('\n--- Covariant en acción ---');
  final miPerro = Perro();
  miPerro.comer('hueso de chuletón');
}

En el código anterior, observa cómo Coche utiliza super(marca) para delegar la inicialización a la clase base, manteniendo la integridad de la propiedad marca. Cuando sobreescribimos circular con @override, no solo estamos cambiando el comportamiento, sino que estamos instruyendo al compilador para que verifique que el método realmente existe en Vehiculo, evitando errores tipográficos que pasarían desapercibidos de otro modo.

El uso de is en if (miVehiculo is Coche) es vital. En esa línea, el compilador realiza un smart cast: después de la comprobación, trata a miVehiculo internamente como un Coche, permitiéndote acceder a abrirMaletero() sin necesidad de usar as. Por otro lado, la palabra clave covariant en Perro.comer es una excepción controlada a la contravarianza de parámetros; le permite a la subclase especificar que, aunque Animal.comer acepta cualquier Object, Perro.comer solo aceptará un String, algo que sería ilegal en una sobreescritura estándar para mantener el soundness del sistema de tipos.

El error frecuente

El error más común al trabajar con jerarquías es el “downcasting ciego” mediante el operador as. Si intentas forzar un tipo sin verificar si la instancia lo es, el programa colapsará.

void errorDeCasting() {
  // Supongamos que tenemos una lista de Vehículos
  final lista: List<Vehiculo> = [Moto('Honda')];

  // Queremos tratar el primer elemento como un Coche
  // Esto fallará en runtime porque la instancia real es una Moto
  final miCoche = lista[0] as Coche; 
  
  // Error: TypeError: 'Moto' is not a subtype of type 'Coche'
  print(miCoche.abrirMaletero());
}

Si usas as, estás asumiendo que sabes exactamente qué objeto hay en esa posición de memoria. Si no puedes garantizarlo, usa siempre is para realizar un casting seguro.

43

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio