Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Programación Orientada a Objetos

Herencia y Polimorfismo Conceptual

Jerarquías, Especialización y Flexibilidad en el Diseño

Universidad Nacional de Rio Negro - Sede Andina

En los capítulos anteriores exploramos los fundamentos de OOP (Fundamentos de la Programación Orientada a Objetos) y las relaciones entre objetos (Encapsulamiento y Relaciones entre Objetos). Ahora abordamos dos pilares fundamentales que permiten reutilizar código y diseñar sistemas flexibles: la herencia y el polimorfismo.

Este capítulo se enfoca en los conceptos y el modelado, no en la sintaxis de implementación. Para la sintaxis específica de Java, consultá Herencia y Polimorfismo en Java.

La pregunta central acá es cuándo y por qué conviene diseñar una jerarquía; la implementación concreta aparece en el capítulo siguiente.


Herencia: Especialización y Generalización

¿Qué es la Herencia?

La herencia es un mecanismo que permite crear nuevas clases basadas en clases existentes, heredando sus atributos y comportamiento, y añadiendo o modificando funcionalidad.

Analogía: La Taxonomía Biológica

La herencia en programación se inspira en la clasificación biológica:

Cada nivel hereda las características del nivel superior:

Generalización y Especialización

La herencia puede verse desde dos perspectivas:

Especialización (de arriba hacia abajo):

Generalización (de abajo hacia arriba):

Conceptos de Generalización y Especialización en una jerarquía de clases.

Figure 1:Conceptos de Generalización y Especialización en una jerarquía de clases.

La Relación “Es-Un”

La herencia modela una relación “es-un” (o “es-un-tipo-de”):

Afirmación¿Válida?Justificación
Un Perro es un AnimalHerencia correcta
Un Auto es un VehículoHerencia correcta
Un Círculo es una FiguraHerencia correcta
Un Motor es un AutoMotor es parte de Auto
Un Empleado es una EmpresaEmpleado trabaja para Empresa

Ejemplo: Jerarquía de Figuras Geométricas

Modelemos un sistema de figuras geométricas:

Análisis:


Encapsulamiento en Jerarquías: Mantén tus Secretos

Una confusión común es pensar que heredar de una clase significa “poder acceder a todos sus detalles internos”. Esto es incorrecto. El encapsulamiento sigue siendo fundamental incluso en jerarquías de herencia.

Herencia NO Significa Exposición de Detalles

Cuando una subclase hereda de una superclase:

Ejemplo: Empleado y Gerente (Forma Correcta)

Considera una jerarquía donde Gerente extiende Empleado:

Incorrecto ❌:

public class Empleado {
    private String nombre;
    private double salario;  // ¿Acceso directo desde Gerente?
    
    public double getSalario() {  // ¿Usar getters para quitar encapsulamiento?
        return salario;
    }
}

public class Gerente extends Empleado {
    private double bonificacion;
    
    public void calculaSueldoTotal() {
        // ❌ Rompe encapsulamiento: expone detalles internos
        return this.getSalario() + bonificacion;
    }
}

Correcto ✅:

public class Empleado {
    private String nombre;
    private double salario;
    
    // Método de dominio, no getter
    public double calcularSueldoFinal() {
        return salario;  // Lógica encapsulada
    }
    
    public boolean tieneNombre(String nombre) {
        return this.nombre.equals(nombre);
    }
}

public class Gerente extends Empleado {
    private double bonificacion;
    
    // Override de método de dominio: especialización, no exposición
    @Override
    public double calcularSueldoFinal() {
        return super.calcularSueldoFinal() + bonificacion;
    }
}

La clave: Gerente no accede a salario. En su lugar, llama a calcularSueldoFinal() (método de dominio) que la superclase define, especializándolo con super.calcularSueldoFinal().

El Rol de protected: Para Jerarquías, No para Exposición

protected es un modificador diseñado para métodos que subclases necesitan especializar, no para romper encapsulamiento:

Verificar sin Getters

Para verificar que una Gerente funciona correctamente, pruebas deben usar métodos de dominio:

@Test
void testGerenciaCalculaSueldoConBonificacion() {
    Gerente g = new Gerente("Carlos", 3000, 1000);
    
    // ✅ Prueba el comportamiento, no accede a internals
    assertEquals(4000, g.calcularSueldoFinal(), 0.01);
}

@Test
void testGerenciaTieneNombre() {
    Gerente g = new Gerente("Carlos", 3000, 1000);
    
    // ✅ Usa método de dominio heredado
    assertTrue(g.tieneNombre("Carlos"));
    assertFalse(g.tieneNombre("Javier"));
}

No necesitas getters para verificar correctitud. Necesitas métodos de dominio bien diseñados.


Herencia vs Composición

¿Cuándo Usar Herencia?

La herencia es apropiada cuando:

  1. Existe una relación “es-un” clara y semánticamente correcta

  2. La subclase es una especialización de la superclase

  3. La subclase puede sustituir a la superclase sin problemas

  4. Se comparte comportamiento significativo (no solo datos)

Ejemplo correcto:

¿Cuándo Usar Composición?

La composición es apropiada cuando:

  1. Existe una relación “tiene-un” o “usa-un”

  2. Un objeto contiene o utiliza otro objeto

  3. No hay sustitución semántica posible

  4. Se quiere flexibilidad para cambiar componentes

Ejemplo incorrecto de herencia (debería ser composición):

Incorrecto:

Auto hereda de Motor ❌ (Un Auto NO ES UN Motor)

Correcto:

Auto tiene-un Motor ✓ (Composición)

“Favor Composition Over Inheritance”

Este es un principio de diseño ampliamente aceptado:

Prefiere la composición sobre la herencia

¿Por qué?

AspectoHerenciaComposición
AcoplamientoFuerte (código entrelazado)Débil (componentes independientes)
FlexibilidadFija en tiempo de compilaciónPuede cambiar en runtime
ReutilizaciónSolo a través de la jerarquíaDe cualquier clase
ComplejidadJerarquías profundas son difícilesMás modular y simple
EncapsulamientoSe hereda implementaciónSolo se usa interfaz pública

Ejemplo: El problema del “Cuadrado y Rectángulo”

Intuitivamente, un Cuadrado “es un” Rectángulo (matemáticamente, un cuadrado es un rectángulo con lados iguales). Pero en código:

El problema de la herencia entre Cuadrado y Rectángulo.

Figure 2:El problema de la herencia entre Cuadrado y Rectángulo.

El problema: Si Cuadrado hereda de Rectángulo, ¿qué pasa cuando alguien llama setAncho(5) seguido de setAlto(3)? Un cuadrado no puede tener ancho ≠ alto.

Solución con composición:

Solución usando una interfaz común en lugar de herencia directa.

Figure 3:Solución usando una interfaz común en lugar de herencia directa.

Ambos implementan Figura, pero no hay herencia entre ellos.


Encapsulamiento en Jerarquías: Mantén tus Secretos

La herencia permite que una subclase reutilice y especialice el comportamiento de una superclase. Sin embargo, esto NO significa que las subclases deban tener acceso ilimitado a los detalles internos de su superclase. El encapsulamiento debe mantenerse incluso en jerarquías de herencia.

Cómo Accede una Subclase a la Superclase

Una subclase tiene acceso a:

  1. Métodos públicos de la superclase: puede invocarse desde cualquier lugar

  2. Métodos protegidos de la superclase: solo para subclases (y su paquete)

  3. Métodos privados de la superclase: NO tiene acceso (ni siquiera subclases)

La pregunta clave es: ¿Qué debería exponer la superclase?

❌ Antipatrón: Exposición mediante Getters/Setters

Un error común es exponer atributos internos mediante getters/setters genéricos:

public class Empleado {
    private String nombre;
    private double salarioBase;
    
    // ❌ Getters/setters genéricos
    public String getNombre() { return nombre; }
    public void setNombre(String n) { nombre = n; }
    
    public double getSalarioBase() { return salarioBase; }
    public void setSalarioBase(double s) { salarioBase = s; }
}

public class Gerente extends Empleado {
    private double bonificacion;
    
    public double calcularSalario() {
        // ❌ Acceso mediante getter
        return getSalarioBase() + bonificacion;
    }
}

Problemas:

✅ Patrón Correcto: Métodos de Dominio

En lugar de exponer atributos internos, la superclase debe proporcionar métodos que reflejen la intención del negocio:

public class Empleado {
    private String nombre;
    private double salarioBase;
    
    // ✅ Método de dominio (público, con nombre semántico)
    public double calcularSalario() {
        return salarioBase;
    }
    
    // ✅ Operación de dominio (en lugar de setter genérico)
    public void aumentarSalario(double monto) {
        if (monto > 0) {
            salarioBase += monto;
        }
    }
    
    // ✅ Consulta de dominio (en lugar de getter genérico)
    public boolean tieneSalarioMenor(double limite) {
        return salarioBase < limite;
    }
}

public class Gerente extends Empleado {
    private double bonificacion;
    
    @Override
    public double calcularSalario() {
        // ✅ Usa método público de dominio
        return super.calcularSalario() + bonificacion;
    }
    
    // ✅ Otra operación de dominio
    public void otorgarBonificacion(double monto) {
        if (monto > 0) {
            bonificacion = monto;
        }
    }
}

Ventajas:

Nota Sobre protected

El modificador protected permite que las subclases (y clases del mismo paquete) accedan directamente a atributos o métodos.

public class Empleado {
    protected double salarioBase;  // Accesible para subclases
}

¿Es protected una excepción al encapsulamiento?

Sí y no:

Ejemplo de buen uso de protected:

public class Empleado {
    private double salarioBase;
    
    // ✅ Método protected: tiene propósito en la jerarquía
    protected double obtenerSalarioBase() {
        return salarioBase;  // Controlado
    }
}

public class Gerente extends Empleado {
    @Override
    public double calcularSalario() {
        return obtenerSalarioBase() + bonificacion;  // ✅
    }
}

Versus:

public class Empleado {
    protected double salarioBase;  // ❌ Atributo protected
}

public class Gerente extends Empleado {
    @Override
    public double calcularSalario() {
        return salarioBase + bonificacion;  // ❌ Acoplado a implementación
    }
}

Aplicación de Reglas de Cátedra

Este principio está formalmente documentado en la cátedra:

En el contexto de herencia: una subclase debe tratar a su superclase como una caja negra, accediendo solo a la interfaz pública de dominio.

Ejemplo Completo: Jerarquía de Empleados

Veamos cómo se vería una jerarquía correctamente diseñada:

Superclase: Empleado

public abstract class Empleado {
    private String nombre;
    private String dni;
    private double salarioBase;
    
    protected Empleado(String nombre, String dni, double salarioBase) {
        this.nombre = nombre;
        this.dni = dni;
        this.salarioBase = salarioBase;
    }
    
    // Métodos públicos de dominio
    public abstract double calcularSalario();
    
    public void aumentarSalario(double porcentaje) {
        if (porcentaje > 0) {
            salarioBase *= (1 + porcentaje / 100);
        }
    }
    
    public String obtenerResumen() {
        return nombre + " - Salario: $" + calcularSalario();
    }
}

Subclase: Gerente

public class Gerente extends Empleado {
    private double bonificacion;
    
    public Gerente(String nombre, String dni, double salarioBase) {
        super(nombre, dni, salarioBase);
        this.bonificacion = 0;
    }
    
    @Override
    public double calcularSalario() {
        // Usa métodos públicos de la superclase
        // (simplificado; en realidad usaría un método protected si fuera necesario)
        return calcularSalarioBase() + bonificacion;
    }
    
    public void asignarBonificacion(double monto) {
        if (monto >= 0) {
            bonificacion = monto;
        }
    }
}

Lo importante:


Polimorfismo: Muchas Formas

¿Qué es el Polimorfismo?

Polimorfismo (del griego: “muchas formas”) es la capacidad de tratar objetos de diferentes tipos de manera uniforme a través de una interfaz común.

Analogía: El Control Universal

Imaginá un control remoto universal:

Analogía del control universal para explicar el polimorfismo.

Figure 4:Analogía del control universal para explicar el polimorfismo.

Beneficios del Polimorfismo

  1. Código genérico: Escribís código que funciona con cualquier subtipo

  2. Extensibilidad: Agregás nuevos tipos sin modificar código existente

  3. Mantenibilidad: Cambios localizados en cada clase

  4. Abstracción: Trabajás con conceptos, no con implementaciones

Ejemplo sin polimorfismo:

// ❌ Código frágil y difícil de extender
void calcularAreaTotal(List<Object> figuras) {
    double total = 0;
    for (Object obj : figuras) {
        if (obj instanceof Circulo) {
            Circulo c = (Circulo) obj;
            total += 3.14159 * c.radio * c.radio;
        } else if (obj instanceof Rectangulo) {
            Rectangulo r = (Rectangulo) obj;
            total += r.ancho * r.alto;
        } else if (obj instanceof Triangulo) {
            Triangulo t = (Triangulo) obj;
            total += (t.base * t.altura) / 2;
        }
        // ¿Y si agrego Pentágono? Debo modificar este método
    }
    return total;
}

Ejemplo con polimorfismo:

// ✓ Código limpio y extensible
double calcularAreaTotal(List<Figura> figuras) {
    double total = 0;
    for (Figura f : figuras) {
        total += f.area();  // Cada figura sabe calcular su área
    }
    return total;
}
// Si agrego Pentágono, solo creo la clase. Este código no cambia.

Tipos de Polimorfismo

1. Polimorfismo de Subtipo (Herencia)

El más común: una variable del tipo base puede referenciar cualquier subtipo.

Figura figura;

figura = new Circulo(5);
System.out.println(figura.area());  // área del círculo

figura = new Rectangulo(4, 6);
System.out.println(figura.area());  // área del rectángulo

2. Polimorfismo de Interfaz

Similar, pero usando interfaces en lugar de clases base.

Polimorfismo a través de interfaces compartidas.

Figure 5:Polimorfismo a través de interfaces compartidas.

Cualquier clase que implemente Ordenable puede ser ordenada, sin importar qué tan diferentes sean.

3. Polimorfismo Paramétrico (Genéricos)

Escribir código que funciona con cualquier tipo (profundizado en la sección de genéricos en Parte 2).

// Una lista que funciona con cualquier tipo T
Lista<T>

Lista<String> nombres;
Lista<Integer> numeros;
Lista<Persona> personas;

Principio de Sustitución de Liskov (LSP)

¿Qué es el LSP?

El Principio de Sustitución de Liskov (LSP) es uno de los principios SOLID y establece:

“Los objetos de una superclase deben poder ser reemplazados por objetos de sus subclases sin alterar la correctitud del programa.”

En otras palabras: si S es subclase de T, entonces cualquier código que funcione con T debe funcionar igual de bien con S.

Violaciones del LSP

Ejemplo 1: El Cuadrado y el Rectángulo (revisitado)

Rectangulo r = obtenerRectangulo();  // Puede ser Cuadrado
r.setAncho(5);
r.setAlto(4);
assert r.area() == 20;  // ¡FALLA si r es Cuadrado!

El código espera que setAncho() y setAlto() sean independientes. Cuadrado viola esta expectativa.

Ejemplo 2: El Ave que no vuela

Violación del Principio de Sustitución de Liskov.

Figure 6:Violación del Principio de Sustitución de Liskov.

Opciones problemáticas:

Solución: Rediseñar la jerarquía

Rediseño de la jerarquía para cumplir con LSP.

Figure 7:Rediseño de la jerarquía para cumplir con LSP.

Cómo Cumplir con LSP

Reglas prácticas:

  1. Precondiciones: La subclase no puede exigir más que la superclase

  2. Postcondiciones: La subclase no puede prometer menos que la superclase

  3. Invariantes: La subclase debe mantener todas las invariantes de la superclase

  4. Comportamiento: La subclase debe comportarse como la superclase espera

Test del “Si funciona con la clase base...”

Antes de crear una herencia, preguntate:


Clases Abstractas e Interfaces

Abstracción en el Diseño

A veces queremos definir un concepto que no tiene sentido instanciar directamente, pero que sirve como base para otras clases.

Ejemplo: ¿Qué es una “Figura” sin forma específica?

Figura f = new Figura();  // ¿Qué forma tiene? ¿Cuál es su área?

No tiene sentido crear una “Figura genérica”. Lo que queremos es definir el concepto de Figura para que Círculo, Rectángulo, etc. lo especialicen.

Clases Abstractas

Una clase abstracta es una clase que:

Estructura de una clase abstracta y su implementación en una subclase.

Figure 8:Estructura de una clase abstracta y su implementación en una subclase.

Interfaces

Una interface define un contrato: un conjunto de métodos que una clase debe implementar, sin especificar cómo.

Definición de contrato mediante una interfaz.

Figure 9:Definición de contrato mediante una interfaz.

Tanto Circulo (una figura) como Boton (un componente de UI) pueden ser Dibujable, aunque no comparten ninguna otra característica.

¿Cuándo Usar Cada Una?

Usar Clase Abstracta cuando...Usar Interface cuando...
Hay código que compartirSolo hay contrato (firma de métodos)
Existe una relación “es-un”Existe una capacidad “puede-hacer”
Las subclases están relacionadasLas clases no están relacionadas
Querés definir estado comúnNo hay estado compartido

Ejemplo combinado:

Uso combinado de interfaces y clases abstractas.

Figure 10:Uso combinado de interfaces y clases abstractas.


Introducción a los Principios SOLID

Los principios SOLID son cinco principios de diseño orientado a objetos que promueven código mantenible, extensible y robusto.

LetraPrincipioResumen
SSingle ResponsibilityUna clase, una responsabilidad
OOpen/ClosedAbierto a extensión, cerrado a modificación
LLiskov SubstitutionLas subclases deben ser sustituibles
IInterface SegregationInterfaces pequeñas y específicas
DDependency InversionDepender de abstracciones, no de concreciones

S - Principio de Responsabilidad Única (SRP)

“Una clase debe tener una, y solo una, razón para cambiar.”

Ejemplo de violación:

Clase que viola el Principio de Responsabilidad Única.

Figure 11:Clase que viola el Principio de Responsabilidad Única.

Diseño correcto:

Separación de responsabilidades siguiendo SRP.

Figure 12:Separación de responsabilidades siguiendo SRP.

O - Principio Abierto/Cerrado (OCP)

“Las entidades de software deben estar abiertas a extensión pero cerradas a modificación.”

Ejemplo de violación:

// Cada vez que agrego una figura, modifico este método
double calcularArea(Figura f) {
    if (f.tipo == "circulo") {
        return 3.14 * f.radio * f.radio;
    } else if (f.tipo == "rectangulo") {
        return f.ancho * f.alto;
    }
    // Si agrego triángulo, debo modificar este código
}

Diseño correcto (usando polimorfismo):

// Nunca modifico este código, solo agrego nuevas clases
double calcularArea(Figura f) {
    return f.area();  // Cada figura sabe su área
}

// Para agregar triángulo: creo clase Triangulo con area()
// El código anterior no cambia

L - Principio de Sustitución de Liskov (LSP)

Ya lo vimos en detalle: las subclases deben poder reemplazar a la superclase sin afectar el funcionamiento.

I - Principio de Segregación de Interfaces (ISP)

“Los clientes no deben depender de interfaces que no usan.”

Ejemplo de violación:

Interfaz con demasiados métodos (Fat Interface).

Figure 13:Interfaz con demasiados métodos (Fat Interface).

Diseño correcto:

Segregación de interfaces según las necesidades de los clientes.

Figure 14:Segregación de interfaces según las necesidades de los clientes.

D - Principio de Inversión de Dependencias (DIP)

“Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones.”

Ejemplo de violación:

Dependencia de una implementación concreta (violación DIP).

Figure 15:Dependencia de una implementación concreta (violación DIP).

Diseño correcto:

Inversión de dependencias usando abstracciones.

Figure 16:Inversión de dependencias usando abstracciones.


Diseño de Jerarquías de Clases

Guías para Diseñar Jerarquías

  1. Empezá simple: No crees jerarquías antes de necesitarlas

  2. Máximo 3-4 niveles: Jerarquías profundas son difíciles de entender

  3. Verificá el LSP: Cada subclase debe ser sustituible

  4. Preferí composición: Solo usá herencia cuando sea claramente apropiada

  5. Interfaces sobre clases abstractas: Cuando solo necesitás contrato

Errores Comunes

Error 1: Herencia para reutilizar código

// ❌ MAL: Stack hereda de ArrayList solo por reutilizar código
Stack extends ArrayList  // Un Stack NO ES una ArrayList

// ✓ BIEN: Stack USA una ArrayList internamente
Stack {
    private ArrayList<T> elementos;
}

Error 2: Jerarquías demasiado profundas

// ❌ MAL: Demasiados niveles
SerVivo → Animal → Vertebrado → Mamifero → Carnivoro → Canino → Perro → Pastor → PastorAleman

// ✓ BIEN: Más plano, usar interfaces para capacidades
Animal → Perro
Perro implementa: Mamifero, Carnivoro, Domesticable

Error 3: Herencia para modelar estados

// ❌ MAL: Estados como clases
Usuario → UsuarioActivo
       → UsuarioInactivo
       → UsuarioBloqueado

// ✓ BIEN: Estado como atributo
Usuario {
    private Estado estado;  // ACTIVO, INACTIVO, BLOQUEADO
}

Ejemplo Completo: Sistema de Medios de Pago

Diseñemos un sistema que maneje diferentes medios de pago:

Análisis del Dominio

Medios de pago identificados:

Diseño con Herencia y Polimorfismo

Jerarquía completa de un sistema de medios de pago.

Figure 17:Jerarquía completa de un sistema de medios de pago.

Aplicación de Principios

SRP: Cada clase tiene una responsabilidad clara

OCP: Agregar nuevo medio de pago no requiere modificar código existente

LSP: Cualquier MedioPago puede ser usado donde se espera un MedioPago

ISP: Interface MedioPago es pequeña y enfocada

DIP: El sistema depende de MedioPago (abstracción), no de TarjetaCredito (concreción)


Resumen

Herencia

Polimorfismo

Principio de Sustitución de Liskov

Clases Abstractas e Interfaces

Principios SOLID


Ejercicios

Próximo paso

Para seguir, conviene pasar a Herencia y Polimorfismo en Java, donde estas mismas ideas se llevan a sintaxis, mecanismos y ejemplos concretos de implementación.