1. El polimorfismo no siempre es magia
Si te fijas en los ejemplos básicos, el polimorfismo en C# parece el triunfo total del sentido común: hereda, sobreescribe, llama todo por el tipo base — y todo funciona. Pero en la práctica aparecen matices. Vamos a conocerlos más de cerca.
Ocultamiento de métodos y la palabra clave new
Imagina que tienes una clase base y una derivada, en cada una declaras un método con el mismo nombre, pero sin la palabra clave override. Si en la clase derivada defines ese método de nuevo, pero sin override, entonces está ocultando la implementación del método base, no sobreescribiéndolo. El compilador, como un padre cuidadoso, enseguida te avisa con una advertencia y te sugiere poner explícitamente new:
class Animal
{
public void Speak()
{
Console.WriteLine("El animal hace un sonido.");
}
}
class Cat : Animal
{
public new void Speak()
{
Console.WriteLine("¡Miau!");
}
}
// Uso
Animal animal = new Cat();
animal.Speak(); // Mostrará: "El animal hace un sonido."
¡Wow! Incluso si creamos un objeto de tipo Cat y lo metemos en una variable de tipo Animal, se llamará el método original de la clase base. ¿Por qué? ¡Porque el método no fue declarado como virtual! Trampa número uno: si quieres usar polimorfismo, no olvides las palabras clave virtual y override. Usa new solo si de verdad quieres ocultar y no sobreescribir el método (por cierto, esto se necesita muy rara vez y solo por buenas razones).
Llamadas a constructores y polimorfismo
Otro truco poco obvio: los constructores no son virtuales. Si declaras un constructor en la clase base y otro en la derivada, no serán polimórficos. Mira el ejemplo:
class Animal
{
public Animal()
{
Console.WriteLine("Constructor Animal");
}
}
class Cat : Animal
{
public Cat()
{
Console.WriteLine("Constructor Cat");
}
}
// Uso
Animal animal = new Cat();
// Mostrará:
// Constructor Animal
// Constructor Cat
Pero si llamas métodos desde el constructor de la clase base que pueden ser sobreescritos en la derivada, el resultado puede ser inesperado — ¡el método virtual se llamará antes de inicializar el hijo! Así que no llames métodos virtuales/abstractos en constructores.
El problema de la "encapsulación rota" con override
Los métodos virtuales están bien, pero si en la clase base calculaste que cierto método se va a comportar de una forma concreta, y luego ese método es sobreescrito en la hija y rompe la lógica — pueden aparecer bugs inesperados.
class Animal
{
public virtual void Eat()
{
Console.WriteLine("El animal come.");
}
public void Live()
{
Eat(); // ¡Puede llamar cualquier versión sobreescrita!
}
}
class Cat : Animal
{
public override void Eat()
{
Console.WriteLine("El gato come pescado.");
}
}
Animal a = new Cat();
a.Live(); // Mostrará "El gato come pescado."
Si en la clase base Animal el método Eat() mostraba "el animal come", y luego en la clase hija en el Eat() sobreescrito añadieron algo peligroso, esto puede romper el funcionamiento de toda la clase. Este problema se llama violación del principio de sustitución de Barbara Liskov (Liskov Substitution Principle, LSP). Cuando diseñes, piensa siempre si el comportamiento de las clases hijas seguirá siendo lógico respecto a la base.
Casting y problemas con la conversión de tipos
El polimorfismo te permite tener diferentes objetos en el mismo "montón": por ejemplo, una lista del tipo base, donde puede haber perros y gatos (ambos heredan de Animal). Pero si quieres llamar algo específico:
List<Animal> pets = new List<Animal> { new Cat(), new Dog() };
foreach (var pet in pets)
{
if (pet is Cat cat)
{
cat.Purr();
}
}
Si te olvidas de comprobar el tipo y haces un cast imprudente, te comes un feo error InvalidCastException. A veces esto lleva a demasiadas comprobaciones de tipo, complica el código y señala que quizá tu diseño necesita un repaso.
2. Problemas con la abstracción
La abstracción es una herramienta genial para simplificar el trabajo del usuario con el objeto y limitar el acceso al estado interno. ¡Pero aquí también hay trampas!
Pasarse con los niveles de abstracción (Over-Abstraction)
Algunos desarrolladores novatos (y no tan novatos) se flipan tanto con el "OOP correcto" que crean auténticas tartas de capas de clases base, interfaces y capas abstractas. Al final, entender cómo funciona es difícil incluso para el propio autor.
interface IAnimal
{
void Speak();
}
abstract class Feline : IAnimal
{
public abstract void Speak();
}
class Cat : Feline
{
public override void Speak()
{
Console.WriteLine("¡Miau!");
}
}
¿Para qué sirve la clase abstracta intermedia si no añade nada? La abstracción por la abstracción complica el mantenimiento y lía la arquitectura.
Jerarquía mal pensada
Piensa qué pasa si pones una acción en lo más alto de la jerarquía, pero en realidad no es válida para todos:
abstract class Animal
{
public abstract void Fly();
}
class Cat : Animal
{
public override void Fly()
{
throw new NotImplementedException("¡Los gatos no vuelan!");
}
}
Tendrás que rellenar las clases hijas con métodos de relleno que lanzan excepciones, o aceptar que tu interfaz no refleja la realidad. Esto es el típico anti-patrón de "jerarquía incorrecta". En estos casos es mejor sacar esos métodos a interfaces aparte (por ejemplo, IFlyable).
Consejo: no intentes hacer una abstracción "para todo".
Problemas con clases abstractas y cambios en el API
En cuanto una clase abstracta llega a producción y la gente empieza a heredar de ella, cualquier cambio se vuelve arriesgado. Añadir un nuevo método abstracto obliga a todos los hijos a implementarlo — si no, el código ni compila. Esto complica mucho el mantenimiento de librerías y APIs públicas.
Por eso existen los interfaces con implementación por defecto (Default Interface Methods — mira la lección 116): permiten ampliar interfaces sin tener que cambiar todo el código existente de golpe.
Violación de la encapsulación al abstraer
Cuando haces una clase abstracta, a menudo tienes que declarar sus miembros como protected para que las clases derivadas puedan usarlos. Esto a menudo lleva a que se filtre lógica interna que sería mejor ocultar. Al final, los hijos acceden a datos y operaciones que pueden romper la integridad interna de la clase base.
3. Escenarios prácticos de errores
Para que no pienses que esto solo pasa en los deberes, vamos a ver ejemplos reales — a veces incluso los programadores con experiencia pisan estos charcos.
Ejemplo con métodos no declarados como virtual
Supón que amplías tu app de aprendizaje de logging (ver Día 24). Tienes un logger base:
class BaseLogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}
class FileLogger : BaseLogger
{
public void Log(string message)
{
// Escribimos en archivo
Console.WriteLine("En archivo: " + message);
}
}
// Uso:
BaseLogger logger = new FileLogger();
logger.Log("¡Hola!"); // Esperado: "En archivo: ¡Hola!", realidad: "¡Hola!"
El de FileLogger pensaba que sobreescribía el método, pero se olvidó de poner override y no hizo el método base virtual. Como resultado, se llama la versión base.
Recomendación: marca siempre los métodos que se pueden sobreescribir como virtual en la base y override en las derivadas.
Ejemplo de mala abstracción: Animales "flexibles"
¡Seguimos con los animales! Creamos la interfaz IFlyable para no obligar a todos los animales a implementar el método Fly:
interface IFlyable
{
void Fly();
}
class Bird : IFlyable
{
public void Fly() => Console.WriteLine("¡El pájaro vuela!");
}
class Cat
{
// El gato no implementa IFlyable
}
Ahora puedes escribir una función que trabaje con "voladores" sin tocar a los gatos:
void MakeItFly(object creature)
{
if (creature is IFlyable flyingThing)
{
flyingThing.Fly();
}
else
{
Console.WriteLine("Esta criatura no sabe volar.");
}
}
Así no estropeas la arquitectura con métodos abstractos de pega.
Problemas con clases abstractas "duras" al ampliar
Imagina que publicas una librería con esta clase abstracta:
public abstract class Creature
{
public abstract void DoAction();
}
Los usuarios de tu librería empiezan a crear sus clases heredando de esta. Un año después quieres ampliar el API y añades:
public abstract class Creature
{
public abstract void DoAction();
public abstract void Sleep(); // ¡Nuevo método!
}
Ahora todas las clases de usuario no compilan porque tienen que implementar el nuevo método abstracto. Así que ojo al diseñar abstracciones y, si puedes, prefiere interfaces con métodos por defecto.
4. Consejos para evitar los típicos charcos
¡Que tus apps sean flexibles como gimnastas, pero no te rompas las piernas!
- No abuses de la herencia: si puedes usar composición (meter un objeto dentro de otro), hazlo.
- Haz los métodos virtuales solo si de verdad sabes que hay que sobreescribirlos.
- No declares clases abstractas sin sentido ni crees jerarquías "por si acaso".
- Comprueba la lógica al sobreescribir métodos: no rompas los invariantes (reglas) de las clases base.
- No añadas nuevos métodos abstractos a clases base públicas e interfaces después de publicar la librería.
- Usa interfaces para un acoplamiento flojo entre partes del programa.
- Para ampliar el API usa Default Interface Methods.
GO TO FULL VERSION