Herencia: especialización y sustitución (no “reutilización rápida”)
Usa herencia cuando una clase derivada sea una especialización real de la base y pueda sustituirla sin romper expectativas. La pregunta guía no es “¿puedo reutilizar código?”, sino: ¿un objeto de la subclase puede usarse donde se espera la clase base sin sorpresas?
Si la respuesta es “a veces” o “depende del método”, la herencia probablemente no es el mecanismo correcto (y suele ser mejor composición, delegación o separar interfaces).
Checklist rápido antes de heredar
- Relación “es-un” con significado de dominio: la subclase añade o refina comportamiento, no lo contradice.
- Contrato compatible: no exige precondiciones más fuertes ni debilita garantías.
- Invariantes preservadas: lo que la base promete seguir siendo cierto.
- Comportamiento común estable: lo compartido no cambia con frecuencia ni depende de detalles volátiles.
- Jerarquía poco profunda: idealmente 1–2 niveles; más niveles suelen indicar diseño forzado.
LSP (Principio de Sustitución de Liskov) en práctica
LSP dice, en términos prácticos: si un código funciona con la clase base, debe funcionar igual con cualquier subclase sin tener que “saber” cuál es. No significa que el resultado sea idéntico en valores, sino que se mantienen las reglas: precondiciones, postcondiciones e invariantes.
Violación típica 1: sobrescribir para “desactivar” comportamiento
Se ve cuando la subclase no puede cumplir el contrato del método base y lo “anula” lanzando excepciones, no haciendo nada, o cambiando el significado.
Java (ejemplo problemático)
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
class ReportExporter { public void export(String path) { if (path == null || path.isBlank()) { throw new IllegalArgumentException("path requerido"); } // ... escribe archivo ... }}class PreviewExporter extends ReportExporter { @Override public void export(String path) { // "En preview no exportamos" // Violación: el método deja de cumplir el contrato de exportar. throw new UnsupportedOperationException("No disponible en preview"); }}Si un cliente recibe un ReportExporter y llama a export, espera que exporte (o al menos que falle por las mismas razones contractuales). Aquí falla por una razón nueva introducida por la subclase: el cliente debe empezar a preguntar “¿eres PreviewExporter?” o capturar excepciones específicas. Eso rompe sustitución.
C# (ejemplo problemático)
public class PaymentMethod { public virtual void Charge(decimal amount) { if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount)); // ... cobra ... }}public class DisabledPaymentMethod : PaymentMethod { public override void Charge(decimal amount) { // "Desactivado" // Violación: cambia el contrato (ahora siempre falla). throw new InvalidOperationException("Método desactivado"); }}Si necesitas “desactivar” un comportamiento, normalmente no es una subclase; es un estado/configuración o una estrategia intercambiable.
Violación típica 2: fortalecer precondiciones o debilitar garantías
Una subclase no debería exigir más de lo que exigía la base. Si la base acepta cualquier valor válido, la subclase no puede restringirlo arbitrariamente.
Java
class ImageProcessor { public void resize(int width, int height) { if (width <= 0 || height <= 0) throw new IllegalArgumentException(); // ... // Garantía: deja la imagen con el tamaño solicitado. }}class SquareOnlyProcessor extends ImageProcessor { @Override public void resize(int width, int height) { if (width != height) { // Precondición más fuerte que la base: solo cuadrados. throw new IllegalArgumentException("Solo tamaños cuadrados"); } super.resize(width, height); }}Un cliente que usa ImageProcessor puede pedir 800x600; con la subclase, falla. Eso rompe LSP.
Efectos colaterales de heredar “por conveniencia”
1) Jerarquías profundas
Cuando la motivación es reutilizar un par de métodos o campos, aparecen cadenas como BaseThing -> BaseThingWithCache -> BaseThingWithCacheAndLogging -> .... Cada nivel añade reglas implícitas, y entender el comportamiento requiere recorrer toda la jerarquía.
2) Fragilidad ante cambios en la base
Una modificación en la clase base (un nuevo método virtual, un cambio en el orden de llamadas, un nuevo campo protected usado internamente) puede romper subclases sin tocar su código. Es un acoplamiento fuerte a decisiones internas.
3) Acoplamiento a implementación (especialmente con protected)
protected permite que subclases dependan de detalles internos. Úsalo con criterio: si una subclase necesita tocar demasiados detalles internos, probablemente la base no está ofreciendo un contrato correcto (o la relación no es de especialización).
Cuándo sí: clase base abstracta con comportamiento común estable
Una clase base abstracta tiene sentido cuando hay un núcleo estable que todas las especializaciones comparten, y puntos de variación claros. Un patrón frecuente es el Template Method: la base define el flujo y delega pasos a subclases.
Ejemplo en Java: flujo estable + pasos variables
abstract class FileImporter { public final void importFile(String path) { validatePath(path); String raw = readAll(path); Object data = parse(raw); persist(data); } protected void validatePath(String path) { if (path == null || path.isBlank()) { throw new IllegalArgumentException("path requerido"); } } protected String readAll(String path) { // ... lectura común ... return "..."; } protected abstract Object parse(String raw); protected abstract void persist(Object data);}class CsvImporter extends FileImporter { @Override protected Object parse(String raw) { // parse CSV return new Object(); } @Override protected void persist(Object data) { // guardar en BD // ... }}Claves del diseño: importFile es final para mantener el flujo estable; los puntos de extensión están explícitos en métodos abstract o protected bien definidos.
Ejemplo en C#: abstract + override + protección mínima
public abstract class NotificationSender { public void Send(string to, string message) { Validate(to, message); var payload = BuildPayload(to, message); Deliver(payload); } protected virtual void Validate(string to, string message) { if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("to requerido", nameof(to)); if (message is null) throw new ArgumentNullException(nameof(message)); } protected abstract object BuildPayload(string to, string message); protected abstract void Deliver(object payload);}public sealed class EmailSender : NotificationSender { protected override object BuildPayload(string to, string message) { return new { To = to, Body = message }; } protected override void Deliver(object payload) { // ... SMTP ... }}Observa el equilibrio: Validate es virtual por si una subclase necesita ampliar validaciones, pero la base mantiene el flujo en Send (no virtual) para evitar que una subclase “se salte” pasos críticos.
Reglas y límites para mantener jerarquías sanas
Regla 1: no heredes para “apagar” métodos
Si una subclase no puede soportar un método de la base, no es una subclase válida. Alternativas típicas: dividir responsabilidades (interfaces separadas), composición (delegar en un colaborador), o estados/estrategias.
Regla 2: evita exponer demasiado con protected
Usa protected para puntos de extensión, no como “acceso libre” a internals. Prefiere métodos protected pequeños y con propósito (por ejemplo BuildPayload) en lugar de campos protected que permitan manipulación arbitraria.
Regla 3: mantén la jerarquía poco profunda
Si necesitas más de 2 niveles, revisa: ¿estás modelando variaciones ortogonales (cache, logging, validación) que encajan mejor como composición?
Regla 4: diseña contratos explícitos para override
Documenta (aunque sea con comentarios breves) qué puede y qué no puede cambiar una subclase: qué precondiciones se mantienen, qué se garantiza, qué invariantes no se rompen. En C#, considera sealed en overrides cuando no quieras más variación; en Java, final.
Mini-ejercicio: refactorizar una jerarquía problemática hacia composición
Situación inicial (problema)
Tienes una jerarquía para “reutilizar” el envío de notificaciones, pero algunas subclases desactivan comportamiento o lo cambian de forma incompatible.
C# (jerarquía frágil)
public class Notifier { public virtual void Notify(string userId, string text) { // ... envía por email por defecto ... }}public class SmsNotifier : Notifier { public override void Notify(string userId, string text) { // ... envía SMS ... }}public class SilentNotifier : Notifier { public override void Notify(string userId, string text) { // "no hacer nada" (desactiva) // LSP: el cliente cree que notificó, pero no ocurrió. }}El cliente que depende de Notifier asume que “notificar” produce un efecto observable (o al menos reportable). SilentNotifier rompe esa expectativa.
Objetivo del refactor
- Eliminar la necesidad de subclases que “apagan” comportamiento.
- Modelar el canal como una estrategia (composición) y la desactivación como otra estrategia explícita.
- Mantener un contrato claro:
Notifydelega en un canal; el canal define qué significa “notificar”.
Paso a paso
Paso 1: extrae una interfaz/contrato de canal
public interface INotificationChannel { void Send(string userId, string text);}Paso 2: implementa canales concretos (incluyendo el “silencioso” explícito)
public sealed class EmailChannel : INotificationChannel { public void Send(string userId, string text) { // ... email ... }}public sealed class SmsChannel : INotificationChannel { public void Send(string userId, string text) { // ... SMS ... }}public sealed class NoOpChannel : INotificationChannel { public void Send(string userId, string text) { // explícito: no hace nada (útil para tests o modo mantenimiento) }}Paso 3: convierte Notifier en un orquestador por composición
public sealed class Notifier { private readonly INotificationChannel _channel; public Notifier(INotificationChannel channel) { _channel = channel ?? throw new ArgumentNullException(nameof(channel)); } public void Notify(string userId, string text) { // aquí podrías validar, registrar, medir, etc. _channel.Send(userId, text); }}Paso 4: elimina la jerarquía y ajusta el uso
// Antes: Notifier n = new SilentNotifier(); n.Notify(...); // comportamiento sorpresa// Ahora: var n = new Notifier(new NoOpChannel()); n.Notify(...); // intención explícitaVersión equivalente en Java (mismo ejercicio)
interface NotificationChannel { void send(String userId, String text);}final class EmailChannel implements NotificationChannel { public void send(String userId, String text) { // ... email ... }}final class NoOpChannel implements NotificationChannel { public void send(String userId, String text) { // no-op explícito }}final class Notifier { private final NotificationChannel channel; Notifier(NotificationChannel channel) { if (channel == null) throw new IllegalArgumentException("channel requerido"); this.channel = channel; } public void notify(String userId, String text) { channel.send(userId, text); }}Qué debes comprobar al terminar
- Ya no existe una subclase que viole LSP “apagando” métodos.
- La variación (email/sms/no-op) es intercambiable sin herencia.
- El código cliente expresa intención al elegir el canal.
- La jerarquía se reemplazó por composición, reduciendo fragilidad y acoplamiento.