Tabla de Contenidos

03.2 - POO II: Relaciones entre Clases, Herencia y Abstracción

1. Herencia

La herencia es un mecanismo fundamental de la Programación Orientada a Objetos (POO) que permite crear nuevas clases a partir de clases existentes, reutilizando sus atributos y métodos.

1.1 Sintaxis y Funcionamiento

public class Vehiculo {
    protected String matricula;
    protected String marca;

    public Vehiculo(String matricula, String marca) {
        this.matricula = matricula;
        this.marca = marca;
    }

    public void acelerar() {
        IO.println("El vehículo está acelerando.");
    }
}

public class Coche extends Vehiculo {
    private int numeroPuertas;

    public Coche(String matricula, String marca, int numeroPuertas) {
        super(matricula, marca); // Llamada obligatoria al constructor del padre
        this.numeroPuertas = numeroPuertas;
    }
}

1.2 Reglas y Elementos Clave

1.3 La palabra clave super

La palabra clave super es una referencia directa a la superclase (clase padre) desde una subclase. Funciona de manera similar a this (que hace referencia a la propia instancia actual), pero apuntando a los miembros de la clase superior en la jerarquía de herencia.

Tiene dos usos fundamentales:

public class Vehiculo {
    public Vehiculo(String marca) {
        this.marca = marca;
    }

    public void arrancar() {
        IO.println("El vehículo está listo para circular.");
    }
}

public class Coche extends Vehiculo {
    private int puertas;

    public Coche(String marca, int puertas) {
        super(marca); // 1. Llama al constructor de Vehiculo
        this.puertas = puertas;
    }

    @Override
    public void arrancar() {
        super.arrancar(); // 2. Ejecuta la lógica original de arrancar() del padre
        IO.println("El coche ha comprobado el estado del motor y encendido las luces.");
    }
}

2. La Superclase Object y sus Métodos Fundamentales

En Java, toda clase hereda implícitamente de java.lang.Object si no especifica otra superclase mediante extends. Por ello, cualquier objeto en Java hereda una serie de métodos base definidos en Object.

Para garantizar un comportamiento correcto en nuestras aplicaciones, es imprescindible aprender a sobrescribir (@Override) tres de sus métodos clave.

2.1 El método toString()

Devuelve una representación en formato de texto (String) del objeto.

public class Coche {
    private String matricula;
    private String marca;

    public Coche(String matricula, String marca) {
        this.matricula = matricula;
        this.marca = marca;
    }

    @Override
    public String toString() {
        return "Coche{matricula='" + matricula + "', marca='" + marca + "'}";
    }
}

2.2 Comparación de Objetos: equals() y hashCode()

Por defecto, el operador de igualdad (==) entre objetos compara referencias en memoria, no su contenido. Dos objetos distintos en memoria con exactamente los mismos atributos devolverán false si se comparan con ==.

El método equals(Object obj)

Permite definir qué significa que dos objetos sean conceptualmente iguales (por ejemplo, dos coches son iguales si tienen la misma matrícula, independientemente de si están en posiciones de memoria distintas).

@Override
public boolean equals(Object obj) {
    // 1. Si apuntan a la misma dirección de memoria, son iguales
    if (this == obj) return true;
    
    // 2. Si el objeto recibido es nulo o de una clase diferente, no son iguales
    if (obj == null || getClass() != obj.getClass()) return false;
    
    // 3. Conversión de tipo (Casting) y comparación de atributos clave
    Coche otro = (Coche) obj;
    return this.matricula != null && this.matricula.equals(otro.matricula);
}

El método hashCode()

Devuelve un número entero (int) que representa el código hash del objeto. Es utilizado por las estructuras de datos basadas en tablas hash (como HashSet, HashMap, etc.) para organizar y buscar elementos rápidamente.

@Override
public int hashCode() {
    // Genera un hash único basado en el atributo identificador
    return matricula != null ? matricula.hashCode() : 0;
}

El Contrato entre equals y hashCode:

Si dos objetos son iguales según el método equals(), obligatoriamente deben devolver el mismo valor de hashCode(). Si no se cumple esta regla, las colecciones avanzadas (como conjuntos o mapas) fallarán al buscar o guardar los objetos.

3. Sobrescritura de Métodos y Polimorfismo

3.1 Sobrescritura de Métodos (@Override)

La sobrescritura de métodos es el mecanismo mediante el cual una subclase proporciona una implementación específica para un método que ya ha sido definido en su superclase.

Concepto Firma del Método Ámbito Objetivo
Sobrecarga Mismo nombre, diferentes parámetros Misma clase (o heredado) Ofrecer diferentes formas de invocar la misma acción
Sobrescritura Mismo nombre, mismos parámetros Entre Superclase y Subclase Cambiar o personalizar el comportamiento heredado

public class Vehiculo {

    public void acelerar() {
        IO.println("El vehículo está ganando velocidad.");
    }
}

public class Coche extends Vehiculo {
    @Override
    public void acelerar() {
        IO.println("El coche acelera progresivamente inyectando combustible.");
    }
}

3.2 Polimorfismo y Enlazado Dinámico

El polimorfismo es la capacidad de tratar a objetos de distintas subclases como si fuesen instancias de su superclase común, permitiendo que cada uno responda a la misma llamada ejecutando su propio comportamiento sobrescrito.

Se fundamenta en dos mecanismos:

Vehiculo v = new Coche(); // Referencia: Vehiculo | Objeto real en memoria: Coche

3.3 Colecciones Polimórficas

La verdadera potencia del polimorfismo se aprecia al gestionar grupos de objetos heterogéneos bajo un mismo tipo base (como un array de la superclase):

// Colección polimórfica: almacena distintos tipos de vehículos
Vehiculo[] parking = new Vehiculo[3];
parking[0] = new Coche("1234-ABC", "SEAT", 5);
parking[1] = new Moto("5678-DEF", "Yamaha", true);
parking[2] = new Coche("9012-GHI", "Ford", 3);

// Enlazado dinámico: cada objeto ejecuta la versión de acelerar() de su clase real
for (Vehiculo v : parking) {
    v.acelerar();
}

4. Herencia vs. Composición

El diseño orientado a objetos exige elegir adecuadamente cómo asociar y conectar las clases.

4.1 Herencia: Relación "Es un" (Is-A)

4.2 Composición: Relación "Tiene un" (Has-A)

// Clase componente
public class Motor {
    private String tipo;

    public Motor(String tipo) {
        this.tipo = tipo;
    }

    public void arrancar() {
        IO.println("Motor " + tipo + " en marcha.");
    }
}

// Composición: Coche "TIENE UN" Motor
public class Coche {
    private String marca;
    private Motor motor; // Atributo de tipo objeto Motor

    public Coche(String marca, Motor motor) {
        this.marca = marca;
        this.motor = motor;
    }

    public void encender() {
        IO.print(marca + ": ");
        motor.arrancar(); // Delegación de responsabilidad
    }
}

5. Clases y Métodos Abstractos (abstract)

Una clase abstracta representa un concepto incompleto o modelo base. Sirve como plantilla para que otras subclases deriven de ella.

5.1 Características Clave

public abstract class Vehiculo {
    private String matricula;

    public Vehiculo(String matricula) {
        this.matricula = matricula;
    }

    public String getMatricula() {
        return matricula;
    }

    // Método concreto común
    public void mostrarFicha() {
        System.out.println("Vehículo Matrícula: " + matricula);
    }

    // Método abstracto (sin cuerpo, de implementación obligatoria en hijas)
    public abstract void desplazar();
}

6. Interfaces (interface)

Una interface establece un contrato formal de comportamiento. Define qué capacidades debe tener una clase sin importar la jerarquía de herencia a la que pertenezca.

6.1 Características Clave

// Método abstracto implícitamente: public abstract
void cargarBateria(int porcentaje); 

// Método con implementación por defecto
default void mostrarEstadoCarga() {
    IO.println("Comprobando conector e inicio de carga...");
}

6.2 Importancia en Arquitectura de Software y Testing

Las interfaces no son solo una herramienta sintáctica; son el pilar maestro del diseño de software profesional por tres razones clave:

7. Clases de envoltorio

En Java existen dos categorías principales de tipos de datos: tipos primitivos (int, double, char, boolean, etc.) y tipos por referencia (objetos).

Dado que los tipos primitivos no heredan de la superclase universal Object ni implementan interfaces, Java proporciona las clases de envoltorio (wrapper classes). Estas clases envuelven un valor primitivo dentro de un objeto, permitiendo que sea tratado como tal en colecciones, tipos genéricos y métodos que esperan un Object.

7.1 Correspondencia de tipos

Cada tipo de dato primitivo tiene su correspondiente clase de envoltorio en el paquete estándar java.lang:

7.2 Autoboxing y Unboxing

Desde Java 5, la conversión entre tipos primitivos y sus clases de envoltorio asociadas se realiza de forma automática mediante el compilador:

// Unboxing: el objeto Double se convierte automáticamente a double primitivo
Double precioObj = 19.99;
double precio = precioObj;

7.3 Conversión de cadenas y métodos de utilidad

Las clases envoltorio proporcionan potentes métodos estáticos de utilidad, siendo la conversión desde cadenas de texto (String) una de las operaciones más habituales:

// Métodos de utilidad
int maximo = Integer.max(10, 20);
String binario = Integer.toBinaryString(15); // Devuelve "1111"

Ejercicios

Ejercicio 1

Crea la superclase Vehicle y la subclase ElectricCar:

En la clase principal (Main), instancia dos vehículos con la misma matrícula para verificar el funcionamiento de equals() e imprime una instancia de ElectricCar para comprobar la salida de toString().

Ejercicio 2

Implementa una jerarquía de empleados para calcular sueldos:

En la clase principal (Main), crea un array de tipo Employee[] que contenga al menos una instancia de SalariedEmployee y otra de ComissionEmployee. Recorre el array con un bucle e imprime el nombre y el sueldo de cada empleado.

Ejercicio 1c

Aplica el principio de composición frente a la herencia para modelar la relación “Tiene un” (Has-A) entre un ordenador y su procesador:

En la clase principal (Main), instancia un objeto Procesador, pásalo como argumento al instanciar un objeto Ordenador y ejecuta el método ejecutarTarea().

Ejercicio 1d

Abstrae capacidades independientes mediante la implementación de múltiples interfaces en una misma clase:

En la clase principal (Main), instancia un objeto de DispositivoIot, invoca sus métodos enviar() y resetear(), y ejecuta el método predeterminado ''diag