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:

  1. Una nómina con empleados fijos, freelance y becarios, cada uno con su forma de cobrar.
  2. Un lienzo de figuras abstractas con herencia en varios niveles.
  3. Un banco con cuentas de ahorro y corrientes que sobrescriben la misma operación de formas distintas.

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:

  1. Cuadrado(3.5) llama a super("Cuadrado", 3.5, 3.5), el constructor protected de Rectangulo.
  2. Ese constructor llama a super("Cuadrado"), el de Figura, que guarda el nombre.
  3. Vuelve a Rectangulo, que guarda base y altura.
  4. 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:

Cuenta1.ª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:

  • CuentaAhorro añ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 en super.retirar(). Así no repite la validación del saldo.
  • CuentaCorriente cambia la regla del padre: precisamente quiere permitir saldo negativo. Si llamara a super.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 EmpleadoFijo que 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 que mayor() sigue funcionando sin cambiarlo. ¿Qué pasa si los lados no forman un triángulo?
  • En el banco, haz que la CuentaCorriente cobre una comisión de 2 € cada vez que una retirada la deje en negativo. ¿En qué método va? ¿Necesitas protected para 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:

  1. El array tiene dos objetos de clases distintas. El de Ahorro tiene dos atributos: saldo (heredado) y retiradas (suyo).
  2. En la primera vuelta, c.retirar(30) entra directamente en el retirar de Cuenta: solo hay un marco en la pila.
  3. En la segunda vuelta, la misma línea entra en el retirar de Ahorro. Al llegar a super.retirar(cantidad), verás dos marcos apilados: el de Ahorro esperando y el de Cuenta trabajando. Los dos tienen this apuntando al mismo objeto.
  4. 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

ProgramaIdea claveLo que te llevas
NóminaClase abstracta + método final que usa métodos abstractosEl padre define el esqueleto y las hijas rellenan los huecos
Lienzo de figurasHerencia en varios niveles + constructor protectedCada piso llama al de arriba con super; instanceof incluye a los nietos
BancoSobrescribir el mismo método con y sin super + protectedSi 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 →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *