Módulo 12: Herencia
Objetivos del módulo
- Identificar relaciones de generalización y especialización (“es un”) entre clases
- Crear subclases con
extendsy saber qué se hereda y qué no - Encadenar constructores con
super(...)y conocer el orden de construcción (incluidas las sentencias previas asuper(...)de Java 25) - Sobrescribir métodos con
@Override, reutilizar la versión de la superclase consuper.metodo()y conocer las reglas de la sobrescritura - Distinguir sobrescritura de sobrecarga
- Usar
protectedcon criterio - Conocer la clase
Objecte implementar correctamentetoString,equalsyhashCode - Usar
finalpara 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).
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.
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 RectanguloyRectangulo extends Figura, unCuadradohereda de ambos. Y la relación es en un solo sentido: unProductoPerecederoes unProducto, pero unProductocualquiera no es unProductoPerecedero(no tienepasarDia()).
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
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 escribiendosuper(...)en primera línea. Y recuerda que la regla de “una sola llamada asuper(...)othis(...)” 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:
super.precioFinal()llama a la versión de la superclase. Así la subclase amplía el comportamiento heredado en lugar de copiarlo.super.toString()hace lo mismo contoString: cada subclase añade su información al final.- El
toStringdeProductollama aprecioFinal(), y para el yogur se ejecuta la versión deProductoPerecedero(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.
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
protectedes 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: atributosprivatey, si las subclases lo necesitan, métodosprotected(un getter, un setter con validación o un método de ayuda). Los atributosprotectedse 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
extendshereda implícitamente dejava.lang.Object. Por tanto, todas las clases descienden directa o indirectamente deObjecty 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: unProductonunca será igual a unProductoDigital. La alternativa coninstanceof(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 redefineequals. 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: sia.equals(b)estrue, entoncesa.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 enhashCodeexactamente los mismos campos que enequals, conObjects.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
finalno 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
finalno 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:
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
ProductoPerecederoes unProducto; unGerentees unEmpleado. Todo lo que se puede hacer con la superclase tiene sentido con la subclase. - Composición (“tiene un”): un
Cochetiene unMotor; unPedidotiene líneas de pedido; unClientetiene una dirección. Una clase contiene como atributo un objeto de otra y delega en él.
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 asuper(7)→B(int 7)llama asuper(70)→A(int 70)imprime primero. Después terminanB(int 7)yC(). Fíjate en queA()no se ejecuta:B(int)eligió explícitamenteA(int).new C("x"):C(String)no llama a nadie, así que el compilador insertasuper()→B(), que a su vez tiene unsuper()implícito →A(), que llama conthis(1)aA(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()sobrescribeSaludo.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 asaludar(), y en un objetoSaludoFormalse ejecuta la versión sobrescrita: por esof.saludar("Ana")da “Buenos días, Ana” y no “Hola, Ana”.f.saludar("García", "señora")construye “señora García” y llama asaludar(String), que a su vez usa elsaludar()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 |