Herencia en Java — práctica con una nómina, unas figuras y dos tipos de cuenta (y el saldo que hice public para que compilara)
Cuando empecé con herencia hice una clase Cuenta con el saldo private, como manda la teoría, y una CuentaCorriente que heredaba de ella. Al escribir el retirar de la hija:
error: saldo has private access in Cuenta
Mi solución fue rápida y mala: cambié private por public y a correr. Compilaba, funcionaba… y en la corrección me lo marcaron en rojo: cualquier clase del programa podía hacer cuenta.saldo = 1000000. Lo que necesitaba era protected, que abre el saldo a las hijas y lo deja cerrado para el resto. Ese matiz aparece en el tercer programa de hoy.
En la teoría viste las piezas: extends, super, protected, sobrescribir y sobrecargar, polimorfismo, casting y clases abstractas. Aquí las juntamos en tres programas completos:
- Una nómina con empleados fijos, freelance y becarios, cada uno con su forma de cobrar.
- Un lienzo de figuras abstractas con herencia en varios niveles.
- Un banco con cuentas de ahorro y corrientes que sobrescriben la misma operación de formas distintas.
Tabla de Contenidos
Programa 1: una nómina con tres tipos de empleado
Todos los empleados tienen nombre y cobran algo cada mes, pero cada tipo calcula su salario a su manera. Es el caso de libro para una clase abstracta.
public abstract class Empleado {
private final String nombre;
public Empleado(String nombre) {
this.nombre = nombre;
}
public String getNombre() { return nombre; }
// Cada tipo de empleado cobra de una forma: lo deciden las hijas
public abstract double salarioMensual();
public abstract String getTipo();
// final: la línea de la nómina es igual para todos, nadie la cambia
public final String lineaNomina() {
return String.format("%-9s %-7s %9.2f €", getTipo(), nombre, salarioMensual());
}
}
public class EmpleadoFijo extends Empleado {
private static final double PLUS_TRIENIO = 30.0; // 30 € por cada 3 años
private final double sueldoBase;
private final int anios;
public EmpleadoFijo(String nombre, double sueldoBase, int anios) {
super(nombre);
this.sueldoBase = sueldoBase;
this.anios = anios;
}
@Override
public double salarioMensual() {
int trienios = anios / 3; // división entera: 7 años = 2 trienios
return sueldoBase + trienios * PLUS_TRIENIO;
}
@Override
public String getTipo() { return "Fijo"; }
}
public class Freelance extends Empleado {
private final double precioHora;
private int horas; // cambia cada mes
public Freelance(String nombre, double precioHora) {
super(nombre);
this.precioHora = precioHora;
}
public void registrarHoras(int horas) {
if (horas > 0) this.horas += horas;
}
@Override
public double salarioMensual() {
return horas * precioHora;
}
@Override
public String getTipo() { return "Freelance"; }
}
public class Becario extends EmpleadoFijo {
public Becario(String nombre) {
super(nombre, 600, 0); // un becario es un fijo con base 600 y 0 años
}
@Override
public String getTipo() { return "Becario"; }
}
public class Nomina {
public static void main(String[] args) {
Freelance marta = new Freelance("Marta", 25);
marta.registrarHoras(40);
marta.registrarHoras(32);
Empleado[] plantilla = {
new EmpleadoFijo("Carmen", 1800, 7),
marta,
new Becario("Iván"),
new EmpleadoFijo("Jorge", 1500, 2)
};
double total = 0;
for (Empleado e : plantilla) {
System.out.println(e.lineaNomina()); // cada uno calcula su salario
total += e.salarioMensual();
}
System.out.printf("TOTAL%22.2f €%n", total);
int fijos = 0;
for (Empleado e : plantilla) {
if (e instanceof EmpleadoFijo) fijos++; // ¿cuenta el becario?
}
System.out.println("Con contrato fijo: " + fijos);
}
}
Salida:
Fijo Carmen 1860,00 € Freelance Marta 1800,00 € Becario Iván 600,00 € Fijo Jorge 1500,00 € TOTAL 5760,00 € Con contrato fijo: 3
¿Cómo sabe lineaNomina() cuánto cobra cada uno?
No lo sabe, y no le hace falta. lineaNomina() está escrita una sola vez en Empleado y llama a salarioMensual() y a getTipo(), que son abstractos. Cuando el objeto real es Carmen, se ejecutan los de EmpleadoFijo; cuando es Marta, los de Freelance. El padre escribe el «esqueleto» y las hijas rellenan los huecos.
Y como lineaNomina() es final, ninguna hija puede cambiar el formato de la nómina: todas las líneas salen alineadas igual, se añada el tipo de empleado que se añada.
¿Por qué Carmen cobra 1860 y Jorge 1500?
Por los trienios. Carmen lleva 7 años: 7 / 3 es 2 en división entera, así que suma 2 × 30 = 60 € a su base. Jorge lleva 2 años: 2 / 3 es 0, y no suma nada. Es la división entera que ya conoces de los primeros temas, ahora dentro de un método sobrescrito.
¿Por qué «Con contrato fijo» da 3 y no 2?
Porque Becario extends EmpleadoFijo. Un becario es un empleado fijo (con base 600 y 0 años), así que instanceof EmpleadoFijo también es true para Iván. instanceof no pregunta «¿eres exactamente de esta clase?», sino «¿eres de esta clase o de alguna hija?».
Si quisieras contar solo los fijos «puros», podrías comprobar e.getClass() == EmpleadoFijo.class. Pero piensa antes si de verdad lo necesitas: casi siempre, que un becario cuente como fijo es justo lo que quieres.
Fíjate en que Becario no escribe salarioMensual(). Hereda el de EmpleadoFijo y solo cambia getTipo(). Heredar de una clase concreta (no abstracta) también es válido: sobrescribes solo lo que cambia.
Un detalle de formato
%-9s reserva 9 caracteres alineados a la izquierda (el - es lo que alinea a la izquierda) y %9.2f, 9 caracteres para el número con 2 decimales, alineado a la derecha. «Freelance» tiene justo 9 letras: con %-8s se descuadraba toda la columna.
Programa 2: un lienzo de figuras con herencia en varios niveles
Ahora la jerarquía tiene tres pisos: Figura → Rectangulo → Cuadrado. Y cada piso llama al de arriba con super.
public abstract class Figura {
private final String nombre;
protected Figura(String nombre) { // protected: solo lo usan las hijas
this.nombre = nombre;
}
public String getNombre() { return nombre; }
public abstract double area();
public abstract double perimetro();
@Override
public String toString() {
return String.format("%-10s área %7.2f perímetro %6.2f", nombre, area(), perimetro());
}
}
public class Circulo extends Figura {
private final double radio;
public Circulo(double radio) {
super("Círculo");
this.radio = radio;
}
@Override
public double area() { return Math.PI * radio * radio; }
@Override
public double perimetro() { return 2 * Math.PI * radio; }
}
public class Rectangulo extends Figura {
private final double base;
private final double altura;
public Rectangulo(double base, double altura) {
this("Rectángulo", base, altura); // reutiliza el constructor de abajo
}
protected Rectangulo(String nombre, double base, double altura) {
super(nombre);
this.base = base;
this.altura = altura;
}
@Override
public double area() { return base * altura; }
@Override
public double perimetro() { return 2 * (base + altura); }
}
public class Cuadrado extends Rectangulo {
public Cuadrado(double lado) {
super("Cuadrado", lado, lado); // un cuadrado es un rectángulo con base = altura
}
// No hace falta escribir area() ni perimetro(): los hereda de Rectangulo
}
public class Lienzo {
public static Figura mayor(Figura[] figuras) {
Figura max = figuras[0];
for (Figura f : figuras) {
if (f.area() > max.area()) max = f; // polimorfismo: cada una calcula su área
}
return max;
}
public static void main(String[] args) {
Figura[] figuras = {
new Circulo(2),
new Rectangulo(3, 4.5),
new Cuadrado(3.5),
new Circulo(1)
};
double areaTotal = 0;
for (Figura f : figuras) {
System.out.println(f);
areaTotal += f.area();
}
System.out.printf("Área total: %.2f%n", areaTotal);
System.out.println("La mayor: " + mayor(figuras).getNombre());
int rectangulares = 0;
for (Figura f : figuras) {
if (f instanceof Rectangulo) rectangulares++;
}
System.out.println("Rectangulares: " + rectangulares);
}
}
Salida:
Círculo área 12,57 perímetro 12,57 Rectángulo área 13,50 perímetro 15,00 Cuadrado área 12,25 perímetro 14,00 Círculo área 3,14 perímetro 6,28 Área total: 41,46 La mayor: Rectángulo Rectangulares: 2
¿Qué pasa exactamente al hacer new Cuadrado(3.5)?
Es una cadena de constructores, de abajo arriba en las llamadas y de arriba abajo en la construcción:
Cuadrado(3.5)llama asuper("Cuadrado", 3.5, 3.5), el constructorprotecteddeRectangulo.- Ese constructor llama a
super("Cuadrado"), el deFigura, que guarda el nombre. - Vuelve a
Rectangulo, que guardabaseyaltura. - Vuelve a
Cuadrado, que no tiene nada más que hacer.
Un solo objeto, construido en tres capas. Y como Cuadrado hereda area() y perimetro() de Rectangulo, un cuadrado de lado 3,5 tiene área 3,5 × 3,5 = 12,25 sin escribir ni una fórmula más.
¿Por qué Rectangulo tiene dos constructores?
El público, Rectangulo(base, altura), es el que usa todo el mundo. El protected, que además recibe el nombre, solo existe para que Cuadrado pueda decir «soy un rectángulo, pero me llamo Cuadrado». Así el nombre sigue siendo final en Figura y nadie tiene que cambiarlo después.
Hacer que Cuadrado guarde su propio atributo lado además de heredar base y altura. Acabas con datos duplicados que pueden desincronizarse. Si el padre ya guarda lo que necesitas, pásaselo con super y no lo repitas.
¿Por qué «Rectangulares» da 2?
Igual que con el becario: Cuadrado extends Rectangulo, así que el cuadrado es un rectángulo para instanceof. Los dos círculos no cuentan.
¿te has fijado en que el círculo de radio 2 tiene el área y el perímetro iguales (12,57)? No es un error: con radio 2, π·r² y 2·π·r dan lo mismo, 4π. Es el único radio en el que pasa.
¿Y mayor() cómo compara figuras tan distintas?
mayor recibe un Figura[] y solo usa area(), que toda figura tiene. No necesita saber si son círculos o cuadrados. Si mañana añades un Triangulo extends Figura, mayor funciona sin tocar una línea. Esa es la ventaja práctica del polimorfismo: el código que trabaja con el padre sirve para todas las hijas, incluso las que aún no existen.
Programa 3: un banco con cuentas de ahorro y corrientes
Aquí las hijas sobrescriben el mismo método, retirar, y cada una lo hace de forma distinta: una añade reglas a las del padre y la otra las sustituye.
public class Cuenta {
private final String titular;
protected double saldo; // las hijas necesitan tocarlo
public Cuenta(String titular, double saldoInicial) {
this.titular = titular;
this.saldo = saldoInicial;
}
public void ingresar(double cantidad) {
if (cantidad > 0) saldo += cantidad;
}
// Regla general: no se puede sacar más de lo que hay
public boolean retirar(double cantidad) {
if (cantidad <= 0 || cantidad > saldo) return false;
saldo -= cantidad;
return true;
}
public double getSaldo() { return saldo; }
@Override
public String toString() {
return String.format("%s: %.2f €", titular, saldo);
}
}
public class CuentaAhorro extends Cuenta {
private static final int MAX_RETIRADAS = 2; // al mes
private final double interes; // 0.02 = 2 %
private int retiradas = 0;
public CuentaAhorro(String titular, double saldoInicial, double interes) {
super(titular, saldoInicial);
this.interes = interes;
}
@Override
public boolean retirar(double cantidad) {
if (retiradas == MAX_RETIRADAS) return false; // regla extra de la hija
boolean ok = super.retirar(cantidad); // y además, la regla del padre
if (ok) retiradas++;
return ok;
}
public void aplicarIntereses() {
saldo += saldo * interes; // protected: lo tocamos directamente
retiradas = 0; // empieza un mes nuevo
}
@Override
public String toString() {
return "[Ahorro] " + super.toString();
}
}
public class CuentaCorriente extends Cuenta {
private final double limiteDescubierto; // cuánto puede quedar en negativo
public CuentaCorriente(String titular, double saldoInicial, double limiteDescubierto) {
super(titular, saldoInicial);
this.limiteDescubierto = limiteDescubierto;
}
@Override
public boolean retirar(double cantidad) {
// Aquí NO llamamos a super.retirar(): su regla (no bajar de 0) es justo la que cambiamos
if (cantidad <= 0 || saldo - cantidad < -limiteDescubierto) return false;
saldo -= cantidad;
return true;
}
@Override
public String toString() {
return "[Corriente] " + super.toString();
}
}
public class Banco {
public static void main(String[] args) {
Cuenta[] cuentas = {
new CuentaAhorro("Sergio", 500, 0.02),
new CuentaCorriente("Laura", 100, 300),
new Cuenta("Pablo", 50)
};
// El mismo bucle, tres reglas distintas
for (Cuenta c : cuentas) {
System.out.println(c.retirar(150) + " " + c.retirar(150) + " " + c.retirar(150));
}
// Fin de mes: intereses solo a las cuentas de ahorro
for (Cuenta c : cuentas) {
if (c instanceof CuentaAhorro ahorro) {
ahorro.aplicarIntereses();
}
System.out.println(c);
}
}
}
Salida:
true true false true true false false false false [Ahorro] Sergio: 204,00 € [Corriente] Laura: -200,00 € Pablo: 50,00 €
Tres veces retirar(150), tres resultados distintos
El primer bucle llama exactamente igual a las tres cuentas, pero cada una aplica sus reglas:
| Cuenta | 1.ª | 2.ª | 3.ª | ¿Por qué falla la 3.ª? |
|---|---|---|---|---|
| Ahorro (500 €) | ✅ 350 | ✅ 200 | ❌ | Ya lleva 2 retiradas este mes |
| Corriente (100 €, descubierto 300) | ✅ −50 | ✅ −200 | ❌ | Bajaría a −350, por debajo de −300 |
| Normal (50 €) | ❌ | ❌ | ❌ | No hay 150 € desde el principio |
¿Cuándo llamar a super.retirar() y cuándo no?
Es la pregunta clave de este programa:
CuentaAhorroañade una regla (máximo de retiradas) a la del padre (no sacar más de lo que hay). Por eso comprueba lo suyo y después delega ensuper.retirar(). Así no repite la validación del saldo.CuentaCorrientecambia la regla del padre: precisamente quiere permitir saldo negativo. Si llamara asuper.retirar(), el padre rechazaría cualquier retirada que deje el saldo por debajo de 0. Por eso no lo llama y escribe su propia versión completa.
Pregúntate «¿lo que hace el padre sigue siendo verdad para mi hija?». Si sí, llama a super y añade lo tuyo. Si no, sobrescribe sin super.
¿Por qué saldo es protected?
Porque aplicarIntereses() y el retirar de CuentaCorriente necesitan modificarlo, y no existe un setSaldo público (no queremos que nadie de fuera pueda poner el saldo que quiera). protected es justo el punto medio: las hijas lo ven, el resto del programa no. Es mi historia del principio, pero bien resuelta.
El titular, en cambio, sigue private: ninguna hija lo necesita, y el toString del padre ya lo imprime.
Hacer saldo public «para que compile». Funciona igual en tu main, pero rompe la encapsulación: cualquier clase podría saltarse retirar y sus reglas. Si una hija necesita un atributo, protected; si nadie más lo necesita, private.
¿Por qué hace falta instanceof para los intereses?
Porque aplicarIntereses() solo existe en CuentaAhorro. La variable c es de tipo Cuenta, y c.aplicarIntereses() no compilaría. Con c instanceof CuentaAhorro ahorro comprobamos el tipo y obtenemos la variable ahorro ya convertida. Sergio tenía 200 € y, con un 2 %, pasa a 204 €.
super.toString() para no repetir
Las dos hijas sobrescriben toString() para añadir la etiqueta [Ahorro] o [Corriente], pero el resto del texto lo pone super.toString(). Si mañana cambias el formato del saldo en Cuenta, cambia en las tres a la vez.
Pruébalo tú
- En la nómina, añade un tipo
Comercial extends EmpleadoFijoque cobre su sueldo de fijo más un 5 % de sus ventas del mes. Pista:super.salarioMensual(). - En el lienzo, añade un
Triangulo(con sus tres lados) y comprueba quemayor()sigue funcionando sin cambiarlo. ¿Qué pasa si los lados no forman un triángulo? - En el banco, haz que la
CuentaCorrientecobre una comisión de 2 € cada vez que una retirada la deje en negativo. ¿En qué método va? ¿Necesitasprotectedpara algo más?
Míralo paso a paso en Python Tutor
Python Tutor ejecuta el código línea a línea y dibuja los objetos y las llamadas. Elige Java, pega este programa y avanza con Next:
public class Tutor {
static class Cuenta {
double saldo;
Cuenta(double saldo) { this.saldo = saldo; }
boolean retirar(double cantidad) {
if (cantidad > saldo) return false;
saldo -= cantidad;
return true;
}
}
static class Ahorro extends Cuenta {
int retiradas = 0;
Ahorro(double saldo) { super(saldo); }
boolean retirar(double cantidad) {
if (retiradas == 1) return false; // solo una retirada
boolean ok = super.retirar(cantidad); // salta al método del padre
if (ok) retiradas++;
return ok;
}
}
public static void main(String[] args) {
Cuenta[] cuentas = { new Cuenta(100), new Ahorro(100) };
for (Cuenta c : cuentas) {
System.out.println(c.retirar(30) + " " + c.retirar(30) + " " + c.saldo);
}
}
}
Salida:
true true 40.0 true false 70.0
Qué observar:
- El array tiene dos objetos de clases distintas. El de
Ahorrotiene dos atributos:saldo(heredado) yretiradas(suyo). - En la primera vuelta,
c.retirar(30)entra directamente en elretirardeCuenta: solo hay un marco en la pila. - En la segunda vuelta, la misma línea entra en el
retirardeAhorro. Al llegar asuper.retirar(cantidad), verás dos marcos apilados: el deAhorroesperando y el deCuentatrabajando. Los dos tienenthisapuntando al mismo objeto. - En la segunda retirada del ahorro, el método sale en la primera línea (
retiradas == 1) sin llegar a llamar al padre: por eso el saldo se queda en 70.
Resumen
| Programa | Idea clave | Lo que te llevas |
|---|---|---|
| Nómina | Clase abstracta + método final que usa métodos abstractos | El padre define el esqueleto y las hijas rellenan los huecos |
| Lienzo de figuras | Herencia en varios niveles + constructor protected | Cada piso llama al de arriba con super; instanceof incluye a los nietos |
| Banco | Sobrescribir el mismo método con y sin super + protected | Si la regla del padre sigue valiendo, super; si no, sustitúyela |
En los ejercicios lo harás tú solo: una jerarquía de animales, unas figuras que se comparan entre sí y la plantilla completa de un restaurante, además de tres fragmentos con trampa.
Siguiente paso: ejerciciosHerencia en Java — ejercicios con animales, figuras y la plantilla de un restaurante (y el cast que convirtió a mi gato en perro)¿Te ha servido este artículo? Así puedes apoyar SergioLearns →
