Módulo 12: Herencia

Objetivos del módulo

  • Identificar relaciones de generalización y especialización (“es un”) entre clases
  • Crear subclases con extends y saber qué se hereda y qué no
  • Encadenar constructores con super(...) y conocer el orden de construcción (incluidas las sentencias previas a super(...) de Java 25)
  • Sobrescribir métodos con @Override, reutilizar la versión de la superclase con super.metodo() y conocer las reglas de la sobrescritura
  • Distinguir sobrescritura de sobrecarga
  • Usar protected con criterio
  • Conocer la clase Object e implementar correctamente toString, equals y hashCode
  • Usar final para impedir la herencia o la sobrescritura
  • Entender por qué Java solo permite herencia simple y diseñar jerarquías
  • Elegir entre herencia y composición (“es un” frente a “tiene un”)

1. Generalización y especialización

1.1. El problema: código repetido

La tienda empieza a vender productos perecederos (yogures, leche), que además tienen días hasta caducar, y productos digitales (libros electrónicos, juegos), que tienen un tamaño de descarga. Con lo que sabes hasta ahora, escribirías tres clases independientes:

public class ProductoPerecedero {
    private String codigo;            // repetido
    private String nombre;            // repetido
    private double precio;            // repetido
    private int diasParaCaducar;      // propio
    public String getNombre() { ... } // repetido
    public double precioConIva() { ... } // repetido
    ...
}

Y lo mismo en ProductoDigital. Si mañana cambia el IVA, tendrás que tocarlo en tres sitios, y si olvidas uno, tendrás un error. Además, el programa no “sabe” que un perecedero y un digital son ambos productos.

1.2. La solución: una superclase común

📘 Concepto: La herencia permite definir una clase nueva (subclase, clase hija o derivada) a partir de otra existente (superclase, clase padre o base). La subclase hereda los atributos y métodos de la superclase y puede añadir otros nuevos o redefinir los heredados. Expresa una relación “es un”: un producto perecedero es un producto.

  • Generalización: detectar lo común a varias clases y subirlo a una superclase (Producto).
  • Especialización: crear subclases que añaden lo particular (ProductoPerecedero, ProductoDigital).

Diagrama UML: Producto como superclase con codigo, nombre y precio; ProductoPerecedero y ProductoDigital como subclases unidas con flechas de triángulo hueco

Figura 1. En UML, la herencia se dibuja con una flecha de punta triangular hueca que va de la subclase a la superclase. Los signos - y + indican private y public.

Término Ejemplo
Superclase / clase padre / clase base Producto
Subclase / clase hija / clase derivada ProductoPerecedero, ProductoDigital
Relación ProductoPerecedero es un Producto
Palabra clave class ProductoPerecedero extends Producto

2. extends: crear una subclase

// Superclase: lo que tienen en común TODOS los productos
public class Producto {
    private final String codigo;
    private final String nombre;
    private double precio;

    public Producto(String codigo, String nombre, double precio) {
        this.codigo = codigo;
        this.nombre = nombre;
        setPrecio(precio);
    }

    public String getCodigo() { return codigo; }
    public String getNombre() { return nombre; }
    public double getPrecio() { return precio; }

    public void setPrecio(double precio) {
        if (precio < 0) {
            throw new IllegalArgumentException("Precio negativo");
        }
        this.precio = precio;
    }

    public double precioConIva() {
        return precio * 1.21;
    }
}
// ===== ProductoPerecedero.java =====
// Subclase: un ProductoPerecedero ES UN Producto con algo más
public class ProductoPerecedero extends Producto {
    private int diasParaCaducar;          // atributo propio

    public ProductoPerecedero(String codigo, String nombre, double precio, int diasParaCaducar) {
        super(codigo, nombre, precio);    // inicializa la parte heredada
        this.diasParaCaducar = diasParaCaducar;
    }

    public int getDiasParaCaducar() { return diasParaCaducar; }

    public boolean estaCaducado() {       // método propio
        return diasParaCaducar <= 0;
    }

    public void pasarDia() {
        diasParaCaducar--;
    }
}

// ===== ProductoDigital.java =====
public class ProductoDigital extends Producto {
    private final double tamanoMb;

    public ProductoDigital(String codigo, String nombre, double precio, double tamanoMb) {
        super(codigo, nombre, precio);
        this.tamanoMb = tamanoMb;
    }

    public double getTamanoMb() { return tamanoMb; }

    public double segundosDescarga(double megabitsPorSegundo) {
        return tamanoMb * 8 / megabitsPorSegundo;
    }
}
public class PruebaHerencia {
    public static void main(String[] args) {
        ProductoPerecedero yogur = new ProductoPerecedero("A01", "Yogur", 0.45, 1);
        ProductoDigital juego = new ProductoDigital("D01", "Juego", 19.99, 4200);

        // Métodos HEREDADOS de Producto
        System.out.println(yogur.getNombre() + ": " + yogur.getPrecio() + " €");
        System.out.printf("%s con IVA: %.2f €%n", juego.getNombre(), juego.precioConIva());

        // Métodos PROPIOS de cada subclase
        yogur.pasarDia();
        System.out.println("¿Yogur caducado? " + yogur.estaCaducado());
        System.out.printf("Descarga a 300 Mb/s: %.0f s%n", juego.segundosDescarga(300));
    }
}
Yogur: 0.45 €
Juego con IVA: 24.19 €
¿Yogur caducado? true
Descarga a 300 Mb/s: 112 s

Con una sola palabra, extends, cada subclase obtiene getNombre(), getPrecio(), setPrecio() y precioConIva() sin escribirlos. Si cambias precioConIva() en Producto, el cambio llega automáticamente a todas las subclases.

2.1. Qué se hereda y qué no

Miembro de la superclase ¿Se hereda? ¿Se puede usar por su nombre en la subclase?
Atributos y métodos public ✔ ✔
Atributos y métodos protected ✔ ✔
Miembros package-private ✔ Solo si la subclase está en el mismo paquete
Atributos y métodos private Existen en el objeto ✘ (hay que usar los getters/setters)
Constructores ✘ Se invocan con super(...)
Miembros static Accesibles ✔ (pero pertenecen a la superclase)

El caso de private merece atención: el objeto ProductoPerecedero contiene los atributos codigo, nombre y precio, pero el código escrito en la subclase no puede nombrarlos:

public class ProductoDigital extends Producto {
    public ProductoDigital(String nombre) {
        super(nombre);
    }

    public String etiqueta() {
        return "[digital] " + nombre;          // private: no accesible en la subclase
    }
}
ProductoDigital.java:7: error: nombre has private access in Producto
        return "[digital] " + nombre;          // private: no accesible en la subclase
                              ^
1 error

La solución es usar el getter: "[digital] " + getNombre(). Así la superclase sigue controlando sus datos, incluso frente a sus propias subclases.

En el montón, un objeto ProductoPerecedero contiene una parte heredada de Producto con codigo, nombre y precio, y una parte propia con diasParaCaducar

Figura 2. Un objeto de la subclase es un único objeto que contiene la parte de la superclase y la suya propia.

⚠️ Importante: La herencia es transitiva: si Cuadrado extends Rectangulo y Rectangulo extends Figura, un Cuadrado hereda de ambos. Y la relación es en un solo sentido: un ProductoPerecedero es un Producto, pero un Producto cualquiera no es un ProductoPerecedero (no tiene pasarDia()).


3. Constructores y super(...)

3.1. La primera tarea de un constructor: construir la parte de la superclase

Los constructores no se heredan, pero la parte heredada del objeto también hay que inicializarla. Por eso el constructor de la subclase debe llamar a un constructor de la superclase con super(...):

public ProductoPerecedero(String codigo, String nombre, double precio, int dias) {
    super(codigo, nombre, precio);    // 1. construye la parte de Producto
    this.diasParaCaducar = dias;      // 2. inicializa la parte propia
}

Si no escribes ninguna llamada a super(...) ni a this(...), el compilador inserta automáticamente super(); (sin argumentos). Eso falla si la superclase no tiene constructor sin parámetros:

// ===== Producto.java =====
public class Producto {
    private final String nombre;

    public Producto(String nombre) {           // el único constructor tiene parámetros
        this.nombre = nombre;
    }
}

// ===== ProductoDigital.java =====
public class ProductoDigital extends Producto {
    private final double tamanoMb;

    public ProductoDigital(String nombre, double tamanoMb) {
        // falta super(nombre): el compilador intenta llamar a super() y no existe
        this.tamanoMb = tamanoMb;
    }
}
ProductoDigital.java:4: error: constructor Producto in class Producto cannot be applied to given types;
    public ProductoDigital(String nombre, double tamanoMb) {
                                                           ^
  required: String
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error

El error aparece en la línea del constructor de ProductoDigital, aunque el problema esté en lo que falta dentro de él: la llamada super(nombre).

3.2. El orden de construcción

Cuando se crea un objeto de una jerarquía de varios niveles, cada constructor llama primero al de su superclase, hasta llegar a Object. Después, los cuerpos se ejecutan desde arriba (lo más general) hacia abajo (lo más concreto), y en cada nivel los inicializadores de atributos se ejecutan justo antes del cuerpo del constructor:

// ===== SerVivo.java =====
public class SerVivo {
    public SerVivo() {
        System.out.println("1. Constructor de SerVivo");
    }
}

// ===== Animal.java =====
public class Animal extends SerVivo {
    private String nombre = inicializar("2. Inicializador del atributo nombre de Animal");

    public Animal(String nombre) {
        super();                                   // explícito o no, se ejecuta primero
        System.out.println("3. Constructor de Animal");
        this.nombre = nombre;
    }

    static String inicializar(String mensaje) {
        System.out.println(mensaje);
        return "?";
    }
}

// ===== Perro.java =====
public class Perro extends Animal {
    private final String raza;

    public Perro(String nombre, String raza) {
        super(nombre);
        System.out.println("4. Constructor de Perro");
        this.raza = raza;
    }
}
public class PruebaOrden {
    public static void main(String[] args) {
        System.out.println("Creando un Perro...");
        Perro p = new Perro("Toby", "Beagle");
        System.out.println("Perro creado");
    }
}
Creando un Perro...
1. Constructor de SerVivo
2. Inicializador del atributo nombre de Animal
3. Constructor de Animal
4. Constructor de Perro
Perro creado

Cuatro cajas apiladas Object, SerVivo, Animal y Perro: las llamadas super suben de Perro a Object y los cuerpos se ejecutan en orden 1, 2, 3, 4 de arriba abajo

Figura 3. Las llamadas suben por la jerarquía; los cuerpos se ejecutan al bajar. Cuando el constructor de Perro ejecuta su cuerpo, la parte de Animal ya está completamente construida.

📘 Concepto: Tiene lógica: una subclase puede usar en su constructor los métodos heredados (por ejemplo, getNombre()), así que la parte de la superclase tiene que estar lista antes.

3.3. Sentencias antes de super(...): cuerpos de constructor flexibles (Java 25)

Hasta Java 24, super(...) tenía que ser obligatoriamente la primera sentencia. Eso obligaba a trucos para validar los argumentos antes de construir la superclase. Java 25 incorpora los cuerpos de constructor flexibles (JEP 513): se pueden escribir sentencias antes de super(...) o this(...), siempre que no usen el objeto que se está construyendo (no se puede leer this ni llamar a sus métodos; sí asignar sus propios atributos).

public class ProductoPerecedero extends Producto {
    private final int dias;

    public ProductoPerecedero(String nombre, double precio, int dias) {
        // Java 25 (JEP 513): se pueden escribir sentencias ANTES de super(...)
        // siempre que no usen el objeto que se está construyendo (this)
        if (dias < 0) {
            throw new IllegalArgumentException("Días negativos: " + dias);
        }
        System.out.println("  Validación hecha antes de construir la superclase");
        super(nombre, precio);
        this.dias = dias;
    }
}
public class PruebaFlexible {
    public static void main(String[] args) {
        System.out.println("Leche (7 días):");
        new ProductoPerecedero("Leche", 0.99, 7);
        System.out.println("Pan (-1 días):");
        new ProductoPerecedero("Pan", 1.20, -1);
    }
}
Leche (7 días):
  Validación hecha antes de construir la superclase
  Constructor de Producto
Pan (-1 días):
Exception in thread "main" java.lang.IllegalArgumentException: Días negativos: -1
    at ProductoPerecedero.<init>(ProductoPerecedero.java:8)
    at PruebaFlexible.main(PruebaFlexible.java:6)

Con el pan, la validación falla antes de construir nada: no se llega a ejecutar el constructor de Producto. Si compilas este mismo código para una versión anterior, el compilador lo rechaza:

$ javac --release 21 *.java
ProductoPerecedero.java:6: error: flexible constructors is not supported in -source 21
        super(nombre, precio);
             ^
  (use -source 25 or higher to enable flexible constructors)
1 error

💡 Consejo: Usa esta posibilidad para validar o preparar argumentos antes de super(...). En código que deba funcionar también con Java 21 o anteriores, sigue escribiendo super(...) en primera línea. Y recuerda que la regla de “una sola llamada a super(...) o this(...)” sigue vigente.


4. Sobrescritura de métodos

4.1. Redefinir un método heredado

A veces una subclase hereda un método pero necesita que se comporte de otra forma. Por ejemplo, los perecederos que están a punto de caducar se venden con un 30 % de descuento. La subclase sobrescribe (override) el método: declara uno con la misma firma y su propio cuerpo.

public class Producto {
    private final String codigo;
    private final String nombre;
    private final double precio;

    public Producto(String codigo, String nombre, double precio) {
        this.codigo = codigo;
        this.nombre = nombre;
        this.precio = precio;
    }

    public String getNombre() { return nombre; }
    public double getPrecio() { return precio; }

    // Precio que paga el cliente; las subclases pueden redefinirlo
    public double precioFinal() {
        return precio;
    }

    @Override
    public String toString() {
        return String.format("%s %s %.2f €", codigo, nombre, precioFinal());
    }
}
public class ProductoPerecedero extends Producto {
    private final int diasParaCaducar;

    public ProductoPerecedero(String codigo, String nombre, double precio, int diasParaCaducar) {
        super(codigo, nombre, precio);
        this.diasParaCaducar = diasParaCaducar;
    }

    // SOBRESCRITURA: misma firma que en Producto, distinto comportamiento
    @Override
    public double precioFinal() {
        if (diasParaCaducar <= 2) {
            return super.precioFinal() * 0.7;   // reutiliza el cálculo de la superclase
        }
        return super.precioFinal();
    }

    // Amplía el toString heredado en lugar de repetirlo
    @Override
    public String toString() {
        return super.toString() + " (caduca en " + diasParaCaducar + " días)";
    }
}
public class PruebaSobrescritura {
    public static void main(String[] args) {
        Producto teclado = new Producto("P01", "Teclado", 29.99);
        ProductoPerecedero leche = new ProductoPerecedero("A01", "Leche", 0.99, 10);
        ProductoPerecedero yogur = new ProductoPerecedero("A02", "Yogur", 0.50, 1);
        ProductoDigital juego = new ProductoDigital("D01", "Juego", 19.99, 4200);

        System.out.println(teclado);
        System.out.println(leche);
        System.out.println(yogur);     // precioFinal() con un 30 % de descuento
        System.out.println(juego);
    }
}
P01 Teclado 29.99 €
A01 Leche 0.99 € (caduca en 10 días)
A02 Yogur 0.35 € (caduca en 1 días)
D01 Juego 19.99 € [descarga de 4200.0 MB]

Fíjate en tres cosas:

  1. super.precioFinal() llama a la versión de la superclase. Así la subclase amplía el comportamiento heredado en lugar de copiarlo.
  2. super.toString() hace lo mismo con toString: cada subclase añade su información al final.
  3. El toString de Producto llama a precioFinal(), y para el yogur se ejecuta la versión de ProductoPerecedero (0,35 € en lugar de 0,50 €). Java siempre ejecuta la versión del método que corresponde al objeto real. Este mecanismo, el enlace dinámico, es la base del polimorfismo del módulo 13.

La clase ProductoPerecedero hereda de Producto, que hereda de Object; para yogur.toString se usa la versión de ProductoPerecedero, para getNombre se sube a Producto y para hashCode hasta Object

Figura 4. Al llamar a un método, Java lo busca primero en la clase del objeto y, si no lo encuentra, sube por la jerarquía.

4.2. @Override: deja que el compilador te vigile

La anotación @Override indica que pretendes sobrescribir un método. No es obligatoria, pero úsala siempre: si te equivocas en la firma, el compilador te avisa en lugar de crear en silencio un método nuevo.

// ===== Producto.java =====
public class Producto {
    public double precioFinal() {
        return 10;
    }
}

// ===== ProductoA.java =====
public class ProductoA extends Producto {
    @Override
    public double preciofinal() {            // error 1: f minúscula
        return 5;
    }
}

// ===== ProductoB.java =====
public class ProductoB extends Producto {
    @Override
    protected double precioFinal() {         // error 2: menos visibilidad
        return 5;
    }
}

// ===== ProductoC.java =====
public class ProductoC extends Producto {
    @Override
    public int precioFinal() {               // error 3: tipo de retorno incompatible
        return 5;
    }
}
ProductoA.java:2: error: method does not override or implement a method from a supertype
    @Override
    ^
ProductoB.java:3: error: precioFinal() in ProductoB cannot override precioFinal() in Producto
    protected double precioFinal() {         // error 2: menos visibilidad
                     ^
  attempting to assign weaker access privileges; was public
ProductoC.java:3: error: precioFinal() in ProductoC cannot override precioFinal() in Producto
    public int precioFinal() {               // error 3: tipo de retorno incompatible
               ^
  return type int is not compatible with double
ProductoC.java:2: error: method does not override or implement a method from a supertype
    @Override
    ^
4 errors

Sin @Override, el error de ProductoA compilaría: tendrías un método nuevo llamado preciofinal que nadie llama, y te pasarías una tarde buscando por qué no se aplica tu precio.

4.3. Reglas de la sobrescritura

Regla Detalle
Misma firma Mismo nombre y mismos tipos de parámetros (en el mismo orden)
Tipo de retorno El mismo o, si es una referencia, un subtipo (retorno covariante)
Visibilidad Igual o más abierta, nunca más restrictiva (public no puede pasar a protected)
Métodos final No se pueden sobrescribir
Métodos private No se heredan, así que no se sobrescriben (un método igual en la subclase es otro método)
Métodos static No se sobrescriben: un static igual en la subclase oculta al de la superclase
Excepciones No puede declarar excepciones comprobadas más amplias (módulo 15)

El tipo de retorno covariante permite que la versión sobrescrita devuelva un tipo más concreto, lo que evita conversiones a quien la llama:

// En Producto:  public Producto copiar() { return new Producto(nombre); }

// Tipo de retorno COVARIANTE: devuelve un subtipo de Producto
@Override
public ProductoDigital copiar() {
    return new ProductoDigital(nombre, tamanoMb);   // nombre es protected: accesible
}
4200.0 MB, ¿mismo objeto? false

4.4. Sobrescritura frente a sobrecarga

Se confunden constantemente porque sus nombres se parecen, pero son mecanismos distintos:

  Sobrecarga (overloading, módulo 07) Sobrescritura (overriding)
Dónde En la misma clase (o heredado + nuevo) En una subclase
Nombre El mismo El mismo
Parámetros Distintos (número o tipos) Idénticos
Tipo de retorno Puede ser cualquiera El mismo o covariante
Para qué Ofrecer variantes de una operación (println(int), println(String)) Cambiar el comportamiento heredado
Cuándo se decide qué método se ejecuta En compilación, según los argumentos En ejecución, según el objeto real
Anotación No tiene @Override

5. protected

protected da acceso a las subclases (aunque estén en otro paquete) y a las clases del mismo paquete. Es útil cuando una subclase necesita algo que no quieres hacer público:

public class Empleado {
    protected double salarioBase;     // las subclases pueden leerlo y modificarlo
    ...
}

public class Tecnico extends Empleado {
    @Override
    public double calcularSalario() {
        return salarioBase + horasExtra * PRECIO_HORA_EXTRA;   // acceso directo
    }
}

⚠️ Importante: Un atributo protected es mucho menos privado de lo que parece: cualquier subclase, escrita por cualquiera, puede ponerlo a un valor inválido y romper el invariante de la superclase, y además lo ve todo el paquete. La práctica recomendada es: atributos private y, si las subclases lo necesitan, métodos protected (un getter, un setter con validación o un método de ayuda). Los atributos protected se reservan para casos sencillos y jerarquías que controlas tú.


6. La clase Object

6.1. La raíz de todas las clases

📘 Concepto: Toda clase de Java que no indica extends hereda implícitamente de java.lang.Object. Por tanto, todas las clases descienden directa o indirectamente de Object y heredan sus métodos.

public class PruebaObject {
    public static void main(String[] args) {
        Punto a = new Punto(1, 2);
        Punto b = new Punto(1, 2);
        Object o = a;                          // toda clase ES UN Object

        System.out.println("toString:  " + a.toString());
        System.out.println("equals:    " + a.equals(b));
        System.out.println("hashCode:  " + a.hashCode() + " / " + b.hashCode());
        System.out.println("getClass:  " + o.getClass().getName());
        System.out.println("¿Punto hereda de Object? " + (a.getClass().getSuperclass() == Object.class));
    }
}
toString:  Punto@1dbd16a6
equals:    false
hashCode:  498931366 / 1933863327
getClass:  Punto
¿Punto hereda de Object? true
Método de Object Qué hace por defecto ¿Sobrescribirlo?
String toString() NombreClase@hash en hexadecimal Casi siempre
boolean equals(Object o) Lo mismo que == (identidad) Si dos objetos distintos pueden representar “lo mismo”
int hashCode() Un número derivado de la identidad del objeto Siempre que sobrescribas equals
Class<?> getClass() Devuelve la clase real del objeto en ejecución No se puede (es final)
Object clone() Copia superficial (requiere implementar Cloneable) Mejor usar un constructor de copia
void finalize() Obsoleto y marcado para eliminación Nunca
wait(), notify(), notifyAll() Coordinación entre hilos No (curso avanzado)

6.2. equals: igualdad de contenido

Para que dos objetos distintos se consideren iguales (dos productos con el mismo código, dos libros con el mismo ISBN), hay que sobrescribir equals. La firma debe ser exactamente public boolean equals(Object o): el parámetro es Object, no Producto (si pones Producto, estás sobrecargando, no sobrescribiendo, y @Override te lo advertirá).

La implementación sigue siempre el mismo patrón de cuatro pasos:

import java.util.Objects;

public class Producto {
    private final String codigo;      // identifica al producto
    private final String nombre;
    private final double precio;

    public Producto(String codigo, String nombre, double precio) {
        this.codigo = Objects.requireNonNull(codigo, "El código es obligatorio");
        this.nombre = nombre;
        this.precio = precio;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                              // 1. mismo objeto
        if (o == null || getClass() != o.getClass()) return false; // 2. null u otra clase
        Producto otro = (Producto) o;                            // 3. conversión segura
        return codigo.equals(otro.codigo);                       // 4. campos significativos
    }

    @Override
    public int hashCode() {
        return Objects.hash(codigo);      // MISMOS campos que en equals
    }

    @Override
    public String toString() {
        return codigo + " " + nombre + " " + precio + " €";
    }
}
import java.util.HashSet;

public class PruebaEquals {
    public static void main(String[] args) {
        Producto a = new Producto("P01", "Teclado", 29.99);
        Producto b = new Producto("P01", "Teclado inalámbrico", 34.99);   // mismo código
        Producto c = new Producto("P02", "Ratón", 15.50);

        System.out.println("a.equals(b): " + a.equals(b) + " | a == b: " + (a == b));
        System.out.println("a.equals(c): " + a.equals(c));
        System.out.println("a.equals(null): " + a.equals(null));
        System.out.println("a.equals(\"P01\"): " + a.equals("P01"));
        System.out.println("hashCode a/b: " + (a.hashCode() == b.hashCode()));

        HashSet<Producto> conjunto = new HashSet<>();
        conjunto.add(a);
        conjunto.add(b);
        conjunto.add(c);
        System.out.println("Elementos en el conjunto: " + conjunto.size());
    }
}
a.equals(b): true | a == b: false
a.equals(c): false
a.equals(null): false
a.equals("P01"): false
hashCode a/b: true
Elementos en el conjunto: 2

El método debe cumplir el contrato de equals definido en la documentación de Object:

Propiedad Significado
Reflexiva x.equals(x) es true
Simétrica x.equals(y) es true si y solo si y.equals(x) es true
Transitiva Si x.equals(y) y y.equals(z), entonces x.equals(z)
Consistente Repetir la llamada sin cambiar los objetos da el mismo resultado
No nulidad x.equals(null) es false (nunca lanza excepción)

Cuando hay varios campos, compáralos según su tipo: Objects.equals(a, b) para referencias que pueden ser null, Double.compare(a, b) == 0 para double y == para enteros y boolean:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Punto p = (Punto) o;
    return Double.compare(x, p.x) == 0
            && Double.compare(y, p.y) == 0
            && Objects.equals(etiqueta, p.etiqueta);   // seguro con null
}

@Override
public int hashCode() {
    return Objects.hash(x, y, etiqueta);
}
true
false
false

La tercera línea es false porque 0.1 + 0.2 no es exactamente 0.3 en coma flotante. equals compara valores exactos: si necesitas tolerancia, es mejor guardar los importes como enteros (como el Dinero del módulo 11).

💡 Consejo: Usamos getClass() != o.getClass() porque es la opción más segura en jerarquías: un Producto nunca será igual a un ProductoDigital. La alternativa con instanceof (que verás en el módulo 13) permite que un objeto de una subclase sea igual a uno de la superclase, pero puede romper la simetría si la subclase también redefine equals. IntelliJ genera ambos estilos con Alt+Insert → equals() and hashCode().

6.3. hashCode: el compañero inseparable de equals

Las colecciones basadas en hash (HashSet, HashMap, módulo 16) usan hashCode() para decidir en qué “cajón” guardar cada objeto, y solo después usan equals dentro del cajón. Mira qué pasa si sobrescribes equals pero olvidas hashCode:

import java.util.HashSet;

public class PruebaHashSet {
    public static void main(String[] args) {
        Producto a = new Producto("P01", "Teclado");
        Producto b = new Producto("P01", "Teclado");
        System.out.println("a.equals(b): " + a.equals(b));
        System.out.println("hashCode: " + a.hashCode() + " / " + b.hashCode());

        HashSet<Producto> conjunto = new HashSet<>();   // un conjunto no admite repetidos
        conjunto.add(a);
        conjunto.add(b);
        System.out.println("Elementos en el conjunto: " + conjunto.size());
        System.out.println("¿Contiene P01? " + conjunto.contains(new Producto("P01", "Teclado")));
    }
}
a.equals(b): true
hashCode: 2060468723 / 622488023
Elementos en el conjunto: 2
¿Contiene P01? false

a y b son “iguales”, pero tienen hash distinto (el heredado de Object), así que el conjunto los guarda en cajones distintos, admite el “duplicado” y no encuentra el producto al buscarlo. En cambio, con hashCode implementado (Producto de la sección 6.2), el conjunto se comporta como se espera: 2 elementos, no 3.

⚠️ Importante: El contrato de hashCode: si a.equals(b) es true, entonces a.hashCode() == b.hashCode() obligatoriamente. Lo contrario no es necesario (dos objetos distintos pueden coincidir en hash), aunque conviene que ocurra poco. La forma práctica de cumplirlo: usar en hashCode exactamente los mismos campos que en equals, con Objects.hash(campo1, campo2, ...).

6.4. toString y getClass

Ya sabes sobrescribir toString (módulo 10). Ahora entiendes por qué lleva @Override y por qué es public: sobrescribe el toString() público de Object. Por su parte, getClass() devuelve la clase real del objeto en ejecución; getClass().getSimpleName() es muy útil para mensajes y trazas.


7. final en clases y métodos

Ya conoces final en variables y atributos. Aplicado a la herencia:

  • Una clase final no puede tener subclases. Ejemplos de la biblioteca: String, Integer, Math. Se usa en clases inmutables (módulo 11) y de utilidades, y cuando la herencia rompería el diseño.
  • Un método final no puede sobrescribirse. Se usa cuando un método es esencial para el funcionamiento correcto de la clase y no debe cambiar.
// ===== Dinero.java =====
public final class Dinero {              // nadie puede heredar de Dinero
    private final long centimos;

    public Dinero(long centimos) {
        this.centimos = centimos;
    }
}

// ===== Propina.java =====
public class Propina extends Dinero {    // ERROR
    public Propina() {
        super(100);
    }
}

// ===== Producto.java =====
public class Producto {
    public final double precioConIva(double precio) {   // método final: no se puede sobrescribir
        return precio * 1.21;
    }
}

// ===== ProductoSinIva.java =====
public class ProductoSinIva extends Producto {
    @Override
    public double precioConIva(double precio) {          // ERROR
        return precio;
    }
}
Propina.java:1: error: cannot inherit from final Dinero
public class Propina extends Dinero {    // ERROR
                             ^
ProductoSinIva.java:3: error: precioConIva(double) in ProductoSinIva cannot override precioConIva(double) in Producto
    public double precioConIva(double precio) {          // ERROR
                  ^
  overridden method is final
2 errors
final en… Significa
Variable local o parámetro No se puede reasignar
Atributo Se asigna una vez (en la declaración o en el constructor)
Método No se puede sobrescribir
Clase No se puede heredar de ella

💡 Consejo: El libro Effective Java (Joshua Bloch) da una regla muy citada: “diseña y documenta para la herencia o prohíbela”. Si una clase no está pensada para tener subclases, márcala final.


8. Herencia simple y jerarquías

8.1. Java solo permite heredar de una clase

En Java cada clase tiene exactamente una superclase directa (salvo Object, que no tiene ninguna). No existe la herencia múltiple de clases:

public class Hibrido extends Vehiculo, Electrico {    // no se puede heredar de dos clases
}
Hibrido.java:1: error: '{' expected
public class Hibrido extends Vehiculo, Electrico {    // no se puede heredar de dos clases
                                     ^
1 error

¿Por qué? Por el llamado problema del diamante: si Vehiculo y Electrico tuvieran ambos un método arrancar() con implementaciones distintas, ¿cuál heredaría Hibrido? Y si ambos heredaran atributos de un antepasado común, ¿tendría Hibrido dos copias? C++ permite la herencia múltiple y tiene reglas complejas para estos casos; Java la evita. A cambio, una clase puede implementar varias interfaces (módulo 13), lo que cubre la mayoría de las necesidades sin estos problemas.

8.2. Jerarquías de clases

La herencia simple da lugar a jerarquías en forma de árbol, con Object en la raíz:

Árbol de herencia con Object arriba; debajo Empleado (con Gerente y Tecnico) y Figura (con Circulo y Rectangulo, y Cuadrado bajo Rectangulo)

Figura 5. Dos jerarquías de ejemplo que desarrollaremos en la sección 10. Toda clase tiene una única superclase directa.

💡 Consejo: Las jerarquías poco profundas (2 o 3 niveles) son más fáciles de entender y mantener. Si tu jerarquía crece a 5 o 6 niveles, probablemente estés usando la herencia donde convendría la composición.


9. Composición frente a herencia

La herencia es potente, pero se abusa de ella. La pregunta clave antes de escribir extends es: ¿”es un” o “tiene un”?

  • Herencia (“es un”): un ProductoPerecedero es un Producto; un Gerente es un Empleado. Todo lo que se puede hacer con la superclase tiene sentido con la subclase.
  • Composición (“tiene un”): un Coche tiene un Motor; un Pedido tiene líneas de pedido; un Cliente tiene una dirección. Una clase contiene como atributo un objeto de otra y delega en él.

Dos paneles: herencia correcta Producto-ProductoPerecedero y herencia incorrecta Motor-Coche tachada; composición correcta Coche-Motor y Pedido-LineaPedido con rombo relleno

Figura 6. Si Coche heredara de Motor, tendría métodos sin sentido para un coche (como getPotenciaCv() como si el coche fuera el motor) y no podría cambiar de motor. La relación correcta es la composición.

// Un coche NO ES un motor: TIENE un motor (composición)
public class Coche {
    private final String matricula;
    private final Motor motor;              // el coche se compone de un motor

    public Coche(String matricula, int potenciaCv) {
        this.matricula = matricula;
        this.motor = new Motor(potenciaCv); // el coche crea y controla su motor
    }

    // DELEGACIÓN: el coche ofrece las operaciones que tienen sentido para un coche
    public void arrancar() {
        motor.encender();
    }

    public String getMatricula() {
        return matricula;
    }

    @Override
    public String toString() {
        return matricula + " (" + motor.getPotenciaCv() + " CV, motor "
                + (motor.isEncendido() ? "encendido" : "apagado") + ")";
    }
}
1234-KLM (110 CV, motor apagado)
1234-KLM (110 CV, motor encendido)
  Herencia Composición
Relación “es un” “tiene un” / “usa un”
Acoplamiento Fuerte: la subclase depende de los detalles de la superclase Débil: solo se usa la interfaz pública del componente
Flexibilidad Fija en compilación Se puede cambiar el componente en ejecución
Reutiliza Todo lo de la superclase (lo quieras o no) Solo lo que delegas explícitamente
Ejemplo Tecnico extends Empleado Coche tiene un Motor

💡 Consejo: La recomendación clásica del diseño orientado a objetos es “favorece la composición sobre la herencia”. Usa la herencia solo cuando la relación “es un” sea verdadera siempre y la subclase pueda usarse en cualquier lugar donde se espere la superclase sin sorpresas (lo que se conoce como principio de sustitución de Liskov).


10. Ejemplos completos

10.1. Jerarquía de empleados

Empleado define lo común (DNI, nombre, salario base y su validación, equals/hashCode por DNI y un toString común). Gerente y Tecnico redefinen cómo se calcula el salario y su puesto. El toString de Empleado llama a getPuesto() y calcularSalario(), que cada subclase redefine:

import java.util.Objects;

public class Empleado {
    public static final double SALARIO_MINIMO = 1200.0;   // mínimo fijado por la empresa

    private final String dni;
    private final String nombre;
    protected double salarioBase;     // protected: las subclases lo leen directamente

    public Empleado(String dni, String nombre, double salarioBase) {
        if (salarioBase < SALARIO_MINIMO) {
            throw new IllegalArgumentException("Salario por debajo del mínimo");
        }
        this.dni = dni;
        this.nombre = nombre;
        this.salarioBase = salarioBase;
    }

    public String getDni() { return dni; }
    public String getNombre() { return nombre; }

    public double calcularSalario() {
        return salarioBase;
    }

    public String getPuesto() {
        return "Empleado";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Empleado otro = (Empleado) o;
        return dni.equals(otro.dni);
    }

    @Override
    public int hashCode() {
        return Objects.hash(dni);
    }

    @Override
    public String toString() {
        return String.format("%-8s %-10s %9.2f €", getPuesto(), nombre, calcularSalario());
    }
}
// ===== Gerente.java =====
public class Gerente extends Empleado {
    private final double bonus;
    private final int personasACargo;

    public Gerente(String dni, String nombre, double salarioBase, double bonus, int personasACargo) {
        super(dni, nombre, salarioBase);
        this.bonus = bonus;
        this.personasACargo = personasACargo;
    }

    @Override
    public double calcularSalario() {
        // base + bonus + 50 € por persona a cargo
        return super.calcularSalario() + bonus + 50 * personasACargo;
    }

    @Override
    public String getPuesto() {
        return "Gerente";
    }
}

// ===== Tecnico.java =====
public class Tecnico extends Empleado {
    private static final double PRECIO_HORA_EXTRA = 18.5;

    private final String especialidad;
    private int horasExtra;

    public Tecnico(String dni, String nombre, double salarioBase, String especialidad) {
        super(dni, nombre, salarioBase);
        this.especialidad = especialidad;
    }

    public void registrarHorasExtra(int horas) {
        if (horas > 0) {
            horasExtra += horas;
        }
    }

    @Override
    public double calcularSalario() {
        return salarioBase + horasExtra * PRECIO_HORA_EXTRA;   // usa el atributo protected
    }

    @Override
    public String getPuesto() {
        return "Técnico";
    }

    @Override
    public String toString() {
        return super.toString() + " (" + especialidad + ", " + horasExtra + " h extra)";
    }
}
public class Nomina {
    public static void main(String[] args) {
        Empleado ana = new Empleado("11111111H", "Ana", 1500);
        Gerente luis = new Gerente("22222222J", "Luis", 2800, 600, 4);
        Tecnico eva = new Tecnico("33333333P", "Eva", 1900, "Redes");
        eva.registrarHorasExtra(10);

        System.out.println(ana);
        System.out.println(luis);
        System.out.println(eva);

        Tecnico copia = new Tecnico("33333333P", "Eva", 2000, "Sistemas");
        System.out.println("¿Misma persona (mismo DNI)? " + eva.equals(copia));
    }
}
Empleado Ana          1500.00 €
Gerente  Luis         3600.00 €
Técnico  Eva          2085.00 € (Redes, 10 h extra)
¿Misma persona (mismo DNI)? true

Gerente usa super.calcularSalario() (respeta lo que decida la superclase) y Tecnico usa directamente el atributo protected salarioBase. Ambas opciones funcionan; la primera es más robusta si mañana cambia el cálculo base.

10.2. Jerarquía de figuras

Cuadrado hereda de Rectangulo y solo necesita un constructor y su nombre: area() y perimetro() le sirven tal cual. El método describir() está escrito una sola vez en Figura, pero muestra el área correcta de cada figura porque llama a la versión sobrescrita de cada objeto:

public class Figura {
    private final String color;

    public Figura(String color) {
        this.color = color;
    }

    public String getColor() {
        return color;
    }

    // Una figura genérica no sabe calcular su área: las subclases lo sobrescriben.
    // (En el módulo 13 lo convertiremos en un método abstracto.)
    public double area() {
        return 0;
    }

    public double perimetro() {
        return 0;
    }

    public String getNombre() {
        return "Figura";
    }

    // Método común que usa los métodos que redefinen las subclases
    public String describir() {
        return String.format("%-10s %-6s área %7.2f  perímetro %6.2f", getNombre(), color, area(), perimetro());
    }
}
// ===== Circulo.java =====
public class Circulo extends Figura {
    private final double radio;

    public Circulo(String color, double radio) {
        super(color);
        this.radio = radio;
    }

    @Override
    public double area() {
        return Math.PI * radio * radio;
    }

    @Override
    public double perimetro() {
        return 2 * Math.PI * radio;
    }

    @Override
    public String getNombre() {
        return "Círculo";
    }
}

// ===== Cuadrado.java =====
// Un cuadrado ES UN rectángulo con base = altura: hereda area() y perimetro()
public class Cuadrado extends Rectangulo {
    public Cuadrado(String color, double lado) {
        super(color, lado, lado);
    }

    @Override
    public String getNombre() {
        return "Cuadrado";
    }
}
public class PruebaFiguras {
    public static void main(String[] args) {
        Circulo c = new Circulo("rojo", 1.5);
        Rectangulo r = new Rectangulo("azul", 4, 2.5);
        Cuadrado q = new Cuadrado("verde", 3);
        Figura f = new Figura("negro");

        System.out.println(c.describir());
        System.out.println(r.describir());
        System.out.println(q.describir());
        System.out.println(f.describir());
    }
}
Círculo    rojo   área    7.07  perímetro   9.42
Rectángulo azul   área   10.00  perímetro  13.00
Cuadrado   verde  área    9.00  perímetro  12.00
Figura     negro  área    0.00  perímetro   0.00

(Rectangulo es análoga a Circulo, con base y altura). La última línea revela un defecto de diseño: tiene sentido crear un Circulo, pero ¿qué es una “Figura” a secas con área 0? En el módulo 13 lo resolveremos declarando Figura como clase abstracta.

10.3. La tienda con herencia

La jerarquía de productos completa. ProductoPerecedero sobrescribe precioFinal() (descuentos por caducidad) y hayStock() (un caducado no se vende), y valida sus argumentos antes de super(...) (Java 25). ProductoDigital redefine la gestión del stock: no tiene unidades físicas, cuenta descargas.

import java.util.Objects;

public class Producto {
    private final String codigo;
    private final String nombre;
    private final double precioBase;
    private int stock;

    public Producto(String codigo, String nombre, double precioBase, int stock) {
        if (precioBase < 0 || stock < 0) {
            throw new IllegalArgumentException("Precio y stock deben ser >= 0");
        }
        this.codigo = codigo;
        this.nombre = nombre;
        this.precioBase = precioBase;
        this.stock = stock;
    }

    public String getCodigo() { return codigo; }
    public String getNombre() { return nombre; }
    public double getPrecioBase() { return precioBase; }
    public int getStock() { return stock; }

    public double precioFinal() {
        return precioBase;
    }

    public boolean hayStock(int cantidad) {
        return cantidad <= stock;
    }

    public void retirarStock(int cantidad) {
        if (!hayStock(cantidad)) {
            throw new IllegalStateException("No hay stock suficiente de " + nombre);
        }
        stock -= cantidad;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        return codigo.equals(((Producto) o).codigo);
    }

    @Override
    public int hashCode() {
        return Objects.hash(codigo);
    }

    @Override
    public String toString() {
        return String.format("%s %-8s %6.2f €", codigo, nombre, precioFinal());
    }
}
// ===== ProductoPerecedero.java =====
public class ProductoPerecedero extends Producto {
    private int diasParaCaducar;

    public ProductoPerecedero(String codigo, String nombre, double precioBase, int stock, int dias) {
        if (dias < 0) {
            throw new IllegalArgumentException("Días negativos");    // Java 25: antes de super
        }
        super(codigo, nombre, precioBase, stock);
        this.diasParaCaducar = dias;
    }

    public void pasarDia() {
        if (diasParaCaducar > 0) {
            diasParaCaducar--;
        }
    }

    @Override
    public double precioFinal() {
        if (diasParaCaducar == 0) return 0;                          // ya no se vende
        if (diasParaCaducar <= 2) return super.precioFinal() * 0.7;  // -30 %
        return super.precioFinal();
    }

    @Override
    public boolean hayStock(int cantidad) {
        return diasParaCaducar > 0 && super.hayStock(cantidad);     // caducado = sin stock
    }

    @Override
    public String toString() {
        return super.toString() + " caduca en " + diasParaCaducar + " d";
    }
}

// ===== ProductoDigital.java =====
public class ProductoDigital extends Producto {
    private final double tamanoMb;
    private int descargas;

    public ProductoDigital(String codigo, String nombre, double precioBase, double tamanoMb) {
        super(codigo, nombre, precioBase, 0);   // no tiene stock físico
        this.tamanoMb = tamanoMb;
    }

    @Override
    public boolean hayStock(int cantidad) {
        return true;                            // siempre disponible
    }

    @Override
    public void retirarStock(int cantidad) {
        descargas += cantidad;                  // en lugar de restar stock, cuenta descargas
    }

    @Override
    public String toString() {
        return super.toString() + " " + tamanoMb + " MB, " + descargas + " descargas";
    }
}
public class Tienda {
    public static void main(String[] args) {
        Producto teclado = new Producto("P01", "Teclado", 29.99, 5);
        ProductoPerecedero yogur = new ProductoPerecedero("A01", "Yogur", 0.60, 40, 3);
        ProductoDigital ebook = new ProductoDigital("D01", "eBook", 9.99, 2.4);

        teclado.retirarStock(2);
        yogur.retirarStock(10);
        yogur.pasarDia();                  // quedan 2 días: descuento
        ebook.retirarStock(150);

        System.out.println(teclado);
        System.out.println(yogur);
        System.out.println(ebook);
        System.out.println("¿Hay 1000 eBooks? " + ebook.hayStock(1000));
        yogur.pasarDia();
        yogur.pasarDia();
        System.out.println("¿Hay yogures? " + yogur.hayStock(1) + " -> " + yogur);
        teclado.retirarStock(10);
    }
}
P01 Teclado   29.99 €
A01 Yogur      0.42 € caduca en 2 d
D01 eBook      9.99 € 2.4 MB, 150 descargas
¿Hay 1000 eBooks? true
¿Hay yogures? false -> A01 Yogur      0.00 € caduca en 0 d
Exception in thread "main" java.lang.IllegalStateException: No hay stock suficiente de Teclado
    at Producto.retirarStock(Producto.java:34)
    at Tienda.main(Tienda.java:19)

IllegalStateException es la excepción estándar para “la operación no es posible en el estado actual del objeto” (en el módulo 15 estudiarás la jerarquía de excepciones).


11. Errores comunes

Error Síntoma Solución
Usar un atributo private de la superclase x has private access in Superclase Usar el getter o un método protected
Olvidar super(...) cuando la superclase no tiene constructor vacío constructor X in class X cannot be applied to given types Llamar a super(argumentos)
Usar this antes de super(...) Error de compilación Antes de super(...) solo código que no use el objeto
Equivocarse en la firma al sobrescribir Se crea un método nuevo que nunca se llama Poner siempre @Override
Reducir la visibilidad al sobrescribir attempting to assign weaker access privileges Mantener o ampliar la visibilidad
Copiar el código del método de la superclase Duplicación y errores al mantener Llamar a super.metodo() y añadir lo nuevo
equals(Producto o) en lugar de equals(Object o) No se usa en colecciones ni en comparaciones genéricas Parámetro Object + @Override
equals sin hashCode Duplicados en HashSet, búsquedas fallidas Implementar ambos con los mismos campos
Atributos protected por comodidad Subclases que rompen invariantes private + métodos protected
Herencia para reutilizar código sin relación “es un” Jerarquías absurdas (Coche extends Motor) Composición y delegación
Intentar heredar de dos clases ’{‘ expected Una superclase + interfaces (módulo 13)

12. Ejercicios

Ejercicios resueltos

Ejercicio 1: Vehículos

Crea una superclase Vehiculo (matrícula, marca, velocidad máxima, numRuedas() que devuelve 4 y toString) y dos subclases: Coche (número de puertas) y Moto (cilindrada y si lleva sidecar, con dos constructores). Moto debe sobrescribir numRuedas() y añadir necesitaCarnetA() (más de 125 cc). Los toString de las subclases deben reutilizar el de la superclase.

Ver solución
public class Vehiculo {
    private final String matricula;
    private final String marca;
    private final int velocidadMaxima;

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

    public int numRuedas() {
        return 4;
    }

    @Override
    public String toString() {
        return marca + " " + matricula + " (" + velocidadMaxima + " km/h, " + numRuedas() + " ruedas)";
    }
}
public class Moto extends Vehiculo {
    private final int cilindrada;
    private final boolean sidecar;

    public Moto(String matricula, String marca, int velocidadMaxima, int cilindrada, boolean sidecar) {
        super(matricula, marca, velocidadMaxima);
        this.cilindrada = cilindrada;
        this.sidecar = sidecar;
    }

    public Moto(String matricula, String marca, int velocidadMaxima, int cilindrada) {
        this(matricula, marca, velocidadMaxima, cilindrada, false);
    }

    @Override
    public int numRuedas() {
        return sidecar ? 3 : 2;
    }

    public boolean necesitaCarnetA() {
        return cilindrada > 125;
    }

    @Override
    public String toString() {
        return "Moto " + super.toString() + ", " + cilindrada + " cc";
    }
}
// ===== Coche.java =====
public class Coche extends Vehiculo {
    private final int puertas;

    public Coche(String matricula, String marca, int velocidadMaxima, int puertas) {
        super(matricula, marca, velocidadMaxima);
        this.puertas = puertas;
    }

    @Override
    public String toString() {
        return "Coche " + super.toString() + ", " + puertas + " puertas";
    }
}

// ===== PruebaVehiculos.java =====
public class PruebaVehiculos {
    public static void main(String[] args) {
        Coche c = new Coche("1234-KLM", "Seat", 190, 5);
        Moto m = new Moto("5678-BCD", "Vespa", 95, 125);
        Moto s = new Moto("9012-FGH", "Ural", 105, 750, true);
        System.out.println(c);
        System.out.println(m + " -> ¿carnet A? " + m.necesitaCarnetA());
        System.out.println(s + " -> ¿carnet A? " + s.necesitaCarnetA());
    }
}
Coche Seat 1234-KLM (190 km/h, 4 ruedas), 5 puertas
Moto Vespa 5678-BCD (95 km/h, 2 ruedas), 125 cc -> ¿carnet A? false
Moto Ural 9012-FGH (105 km/h, 3 ruedas), 750 cc -> ¿carnet A? true

El toString de Vehiculo llama a numRuedas(); para las motos se ejecuta la versión sobrescrita, de modo que la Ural (con sidecar) muestra 3 ruedas sin que Vehiculo sepa nada de sidecares. El segundo constructor de Moto delega con this(...) en el primero, que es el único que llama a super(...).

Ejercicio 2: Traza de constructores

Sin ejecutarlo, escribe la salida del programa. Ten en cuenta this(...), super(...) explícitos y los super() implícitos.

// ===== A.java =====
public class A {
    public A() {
        this(1);
        System.out.println("A()");
    }

    public A(int n) {
        System.out.println("A(int " + n + ")");
    }
}

// ===== B.java =====
public class B extends A {
    public B() {
        System.out.println("B()");
    }

    public B(int n) {
        super(n * 10);
        System.out.println("B(int " + n + ")");
    }
}

// ===== C.java =====
public class C extends B {
    public C() {
        super(7);
        System.out.println("C()");
    }

    public C(String s) {
        System.out.println("C(String " + s + ")");
    }
}

// ===== Traza.java =====
public class Traza {
    public static void main(String[] args) {
        System.out.println("--- new C()");
        new C();
        System.out.println("--- new C(\"x\")");
        new C("x");
    }
}
Ver solución
--- new C()
A(int 70)
B(int 7)
C()
--- new C("x")
A(int 1)
A()
B()
C(String x)
  • new C(): C() llama a super(7) → B(int 7) llama a super(70) → A(int 70) imprime primero. Después terminan B(int 7) y C(). Fíjate en que A() no se ejecuta: B(int) eligió explícitamente A(int).
  • new C("x"): C(String) no llama a nadie, así que el compilador inserta super() → B(), que a su vez tiene un super() implícito → A(), que llama con this(1) a A(int 1). Orden de los cuerpos: A(int 1), A(), B(), C(String x).

Ejercicio 3: equals y hashCode de un libro

Crea una clase Libro cuyo equals considere iguales dos libros con el mismo ISBN, aunque uno se haya escrito con guiones y otro sin ellos. Implementa hashCode coherente y comprueba el contrato (reflexiva, simétrica, null) y que un HashSet no admite duplicados.

Ver solución
// ===== Libro.java =====
import java.util.Objects;

public class Libro {
    private final String isbn;        // identifica la edición
    private final String titulo;

    public Libro(String isbn, String titulo) {
        // Normalizamos: sin guiones ni espacios, para que "978-84..." y "97884..." sean iguales
        this.isbn = Objects.requireNonNull(isbn).replace("-", "").replace(" ", "");
        this.titulo = titulo;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Libro otro = (Libro) o;
        return isbn.equals(otro.isbn);
    }

    @Override
    public int hashCode() {
        return isbn.hashCode();
    }

    @Override
    public String toString() {
        return titulo + " [" + isbn + "]";
    }
}

// ===== PruebaLibro.java =====
import java.util.HashSet;

public class PruebaLibro {
    public static void main(String[] args) {
        Libro a = new Libro("978-84-376-0494-7", "Cien años de soledad");
        Libro b = new Libro("9788437604947", "Cien años de soledad (bolsillo)");
        Libro c = new Libro("978-84-206-6040-3", "Niebla");

        System.out.println("Reflexiva:  " + a.equals(a));
        System.out.println("Simétrica:  " + a.equals(b) + " " + b.equals(a));
        System.out.println("Con null:   " + a.equals(null));
        System.out.println("hashCode coherente: " + (a.hashCode() == b.hashCode()));

        HashSet<Libro> catalogo = new HashSet<>();
        catalogo.add(a);
        catalogo.add(b);
        catalogo.add(c);
        System.out.println("Libros distintos: " + catalogo.size());
    }
}
Reflexiva:  true
Simétrica:  true true
Con null:   false
hashCode coherente: true
Libros distintos: 2

La clave es normalizar en el constructor: como el ISBN se guarda siempre sin guiones ni espacios, equals puede limitarse a comparar cadenas y hashCode puede delegar en el hashCode de String (que ya cumple su contrato). Si normalizaras solo dentro de equals, tendrías que hacer lo mismo en hashCode, o el contrato se rompería.

Ejercicio 4: Cuentas de ahorro y corrientes

Crea una clase Cuenta con ingresar y un retirar final que delegue la decisión en un método protected boolean puedeRetirar(double). Crea CuentaAhorro (máximo 3 retiradas al mes e intereses en finDeMes()) y CuentaCorriente (permite quedar en descubierto hasta un límite), redefiniendo solo puedeRetirar.

Ver solución
public class Cuenta {
    private final String titular;
    private double saldo;

    public Cuenta(String titular, double saldo) {
        this.titular = titular;
        this.saldo = saldo;
    }

    public double getSaldo() { return saldo; }
    public String getTitular() { return titular; }

    public void ingresar(double cantidad) {
        if (cantidad <= 0) throw new IllegalArgumentException("Cantidad no válida");
        saldo += cantidad;
    }

    // Las subclases deciden si se puede retirar redefiniendo este método
    protected boolean puedeRetirar(double cantidad) {
        return cantidad <= saldo;
    }

    // final: la mecánica de retirar no se puede cambiar, solo la condición
    public final boolean retirar(double cantidad) {
        if (cantidad <= 0 || !puedeRetirar(cantidad)) {
            return false;
        }
        saldo -= cantidad;
        return true;
    }

    @Override
    public String toString() {
        return String.format("%s: %.2f €", titular, saldo);
    }
}
// ===== CuentaAhorro.java =====
public class CuentaAhorro extends Cuenta {
    private static final int MAX_RETIRADAS = 3;
    private final double interes;
    private int retiradas;

    public CuentaAhorro(String titular, double saldo, double interes) {
        super(titular, saldo);
        this.interes = interes;
    }

    @Override
    protected boolean puedeRetirar(double cantidad) {
        if (retiradas >= MAX_RETIRADAS || !super.puedeRetirar(cantidad)) {
            return false;
        }
        retiradas++;
        return true;
    }

    public void finDeMes() {
        ingresar(getSaldo() * interes / 12);
        retiradas = 0;
    }
}

// ===== CuentaCorriente.java =====
public class CuentaCorriente extends Cuenta {
    private final double descubierto;     // cuánto se puede quedar en negativo

    public CuentaCorriente(String titular, double saldo, double descubierto) {
        super(titular, saldo);
        this.descubierto = descubierto;
    }

    @Override
    protected boolean puedeRetirar(double cantidad) {
        return cantidad <= getSaldo() + descubierto;
    }
}
public class PruebaCuentas {
    public static void main(String[] args) {
        CuentaAhorro ahorro = new CuentaAhorro("Marta", 1000, 0.03);
        for (int i = 1; i <= 4; i++) {
            System.out.println("Retirada " + i + " de 100: " + ahorro.retirar(100));
        }
        ahorro.finDeMes();
        System.out.println(ahorro);

        CuentaCorriente corriente = new CuentaCorriente("Javier", 200, 300);
        System.out.println("Retirar 450: " + corriente.retirar(450));
        System.out.println("Retirar 100: " + corriente.retirar(100));
        System.out.println(corriente);
    }
}
Retirada 1 de 100: true
Retirada 2 de 100: true
Retirada 3 de 100: true
Retirada 4 de 100: false
Marta: 701.75 €
Retirar 450: true
Retirar 100: false
Javier: -250.00 €

Este diseño se llama método plantilla: la superclase fija el algoritmo (retirar, que es final para que nadie lo altere) y deja un “hueco” (puedeRetirar, protected) que las subclases rellenan. Así el saldo solo se modifica en un lugar y ninguna subclase necesita acceder a él directamente. Observa que CuentaAhorro usa super.puedeRetirar(cantidad) para conservar la comprobación de saldo de la superclase.

Ejercicio 5: ¿Sobrecarga o sobrescritura?

Indica qué métodos de SaludoFormal sobrescriben y cuáles sobrecargan, y escribe la salida del programa.

// ===== Saludo.java =====
public class Saludo {
    public String saludar() {
        return "Hola";
    }

    public String saludar(String nombre) {
        return saludar() + ", " + nombre;
    }
}

// ===== SaludoFormal.java =====
public class SaludoFormal extends Saludo {
    @Override
    public String saludar() {
        return "Buenos días";
    }

    public String saludar(String nombre, String tratamiento) {
        return saludar(tratamiento + " " + nombre);
    }
}

// ===== PruebaSaludo.java =====
public class PruebaSaludo {
    public static void main(String[] args) {
        Saludo s = new Saludo();
        SaludoFormal f = new SaludoFormal();
        System.out.println(s.saludar("Ana"));
        System.out.println(f.saludar());
        System.out.println(f.saludar("Ana"));
        System.out.println(f.saludar("García", "señora"));
    }
}
Ver solución
Hola, Ana
Buenos días
Buenos días, Ana
Buenos días, señora García
  • SaludoFormal.saludar() sobrescribe Saludo.saludar() (misma firma, lleva @Override).
  • SaludoFormal.saludar(String, String) sobrecarga: es un método nuevo con otros parámetros.
  • saludar(String) se hereda sin cambios, pero dentro llama a saludar(), y en un objeto SaludoFormal se ejecuta la versión sobrescrita: por eso f.saludar("Ana") da “Buenos días, Ana” y no “Hola, Ana”.
  • f.saludar("García", "señora") construye “señora García” y llama a saludar(String), que a su vez usa el saludar() sobrescrito.

Ejercicio 6: De la herencia a la composición

Un compañero ha escrito class Cliente extends Direccion “para reutilizar la calle y la ciudad”. Explica por qué es un mal diseño y rehazlo con composición, con una Direccion inmutable que valide el código postal y un método mudarse en Cliente.

Ver solución

Un cliente no es una dirección: con la herencia, Cliente tendría todos los métodos de Direccion como propios, no podría cambiar de dirección (la “parte dirección” es él mismo), no podría tener dos (envío y facturación) y cualquier cambio en Direccion afectaría a Cliente. La relación correcta es “un cliente tiene una dirección”.

public final class Direccion {
    private final String calle;
    private final String ciudad;
    private final String codigoPostal;

    public Direccion(String calle, String ciudad, String codigoPostal) {
        if (!codigoPostal.matches("\\d{5}")) {
            throw new IllegalArgumentException("Código postal no válido: " + codigoPostal);
        }
        this.calle = calle;
        this.ciudad = ciudad;
        this.codigoPostal = codigoPostal;
    }

    public String getCiudad() { return ciudad; }

    @Override
    public String toString() {
        return calle + ", " + codigoPostal + " " + ciudad;
    }
}
// ===== Cliente.java =====
public class Cliente {
    private final String nombre;
    private Direccion direccion;          // TIENE UNA dirección

    public Cliente(String nombre, Direccion direccion) {
        this.nombre = nombre;
        this.direccion = direccion;
    }

    public void mudarse(Direccion nueva) {
        this.direccion = nueva;           // cambiar de dirección: imposible con herencia
    }

    public String getCiudad() {           // delegación
        return direccion.getCiudad();
    }

    @Override
    public String toString() {
        return nombre + " - " + direccion;
    }
}

// ===== PruebaCliente.java =====
public class PruebaCliente {
    public static void main(String[] args) {
        Direccion casa = new Direccion("C/ Mayor 1", "Valencia", "46001");
        Cliente ana = new Cliente("Ana", casa);
        Cliente luis = new Cliente("Luis", casa);    // comparten la misma dirección (inmutable)
        System.out.println(ana);
        ana.mudarse(new Direccion("Av. del Puerto 20", "Alicante", "03001"));
        System.out.println(ana + " | ciudad: " + ana.getCiudad());
        System.out.println(luis);
    }
}
Ana - C/ Mayor 1, 46001 Valencia
Ana - Av. del Puerto 20, 03001 Alicante | ciudad: Alicante
Luis - C/ Mayor 1, 46001 Valencia

Al ser Direccion inmutable, dos clientes pueden compartir el mismo objeto sin riesgo: cuando Ana se muda, se le asigna una dirección nueva y la de Luis no cambia. getCiudad() es un ejemplo de delegación: Cliente ofrece la operación y se la encarga a su componente.

Ejercicios propuestos

Ejercicio 7: Animales

Crea una clase Animal (nombre, edad, sonido() que devuelve "..." y presentarse()) y las subclases Perro, Gato y Vaca, que sobrescriben sonido(). Perro añade la raza y un método traerPelota(). Comprueba que presentarse(), definido solo en Animal, usa el sonido de cada animal.

Ejercicio 8: Universidad

Modela Persona (DNI, nombre, email) con dos subclases: Alumno (expediente y notas) y Profesor (departamento y asignaturas). Implementa equals/hashCode por DNI en Persona y toString en las tres clases reutilizando super.toString(). ¿Debería un Alumno ser igual a un Profesor con el mismo DNI? Justifica tu elección entre getClass() e instanceof.

Ejercicio 9: Socios premium

En la biblioteca, crea Socio (número, nombre, máximo 3 préstamos) y SocioPremium, que admite 10 préstamos y no paga multas por retraso. Usa un método protected int maxPrestamos() sobrescrito en lugar de duplicar la lógica de préstamo.

Ejercicio 10: Orden de inicialización completo

Escribe una jerarquía de tres clases en la que cada una tenga un bloque static, un atributo con inicializador que imprima un mensaje y un constructor. Predice y comprueba el orden de todas las líneas al crear dos objetos de la clase más baja. (Pista: los bloques static se ejecutan una sola vez, de la superclase a la subclase, antes que todo lo demás).

Ejercicio 11: equals para Fecha

Añade equals y hashCode a la clase inmutable Fecha del módulo 11 y comprueba con un HashSet que dos fechas iguales creadas por caminos distintos (new Fecha(1, 3, 2028) y new Fecha(28, 2, 2028).sumarDias(2)) se consideran la misma.

Ejercicio 12: Becarios

Añade a la jerarquía de empleados una clase Becario que no cumple el salario mínimo de Empleado. ¿Qué problema aparece? Propón dos soluciones (cambiar la jerarquía o la validación) y discute cuál respeta mejor el principio de sustitución.


Resumen

Concepto Sintaxis / ejemplo Idea clave
Herencia class B extends A B “es un” A; hereda sus miembros
Superclase / subclase Producto / ProductoPerecedero Generalización / especialización
Miembros private heredados Existen, pero no se nombran Acceso mediante getters
super(...) super(codigo, nombre, precio); Construye la parte de la superclase
super() implícito Lo añade el compilador Falla si no hay constructor vacío
Orden de construcción Object → … → subclase Llamadas suben, cuerpos bajan
Sentencias antes de super Validaciones (Java 25, JEP 513) Sin usar this
Sobrescritura @Override public double precioFinal() Misma firma, otro comportamiento
super.metodo() super.toString() + "..." Reutilizar la versión heredada
Retorno covariante ProductoDigital copiar() Devolver un subtipo
Sobrecarga saludar() / saludar(String) Otros parámetros; se decide al compilar
protected protected double salarioBase; Paquete + subclases
Object raíz de todas las clases toString, equals, hashCode, getClass
equals public boolean equals(Object o) Reflexiva, simétrica, transitiva, consistente, null → false
hashCode Objects.hash(campos) Iguales según equals → mismo hash
final clase / método No heredable / no sobrescribible
Herencia simple una superclase directa Varias interfaces sí (módulo 13)
Composición private final Motor motor; “Tiene un”; favorécela frente a la herencia