Herencia, Polimorfismo y Sobrecarga de Métodos

Hasta ahora cada clase que escribiste vivía sola. Pero en cualquier sistema real vas a encontrarte con clases que comparten buena parte de su estado y su comportamiento: un Auto, una Moto y un Camión tienen marca, arrancan y frenan.

Copiar y pegar esos atributos en las tres clases funciona hasta el día en que cambia una regla. Ahí tenés que acordarte de los tres lugares. Ya sabés cómo termina eso.

La herencia resuelve ese problema, y el polimorfismo —que es su consecuencia, no un tema aparte— es lo que hace que valga la pena. Esta lección trata las dos cosas juntas, porque separadas no se entienden.


1. Herencia: extends y la prueba del “es un”

Una clase puede extender a otra y recibir automáticamente todos sus atributos y métodos:

public class Vehiculo {
    protected String marca;

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

    public void arrancar() {
        System.out.println("El vehículo arranca.");
    }

    public void frenar() {
        System.out.println("El vehículo frena.");
    }
}

public class Auto extends Vehiculo {
    public Auto(String marca) {
        super(marca);
    }

    @Override
    public void arrancar() {
        System.out.println("Auto " + marca + " arrancando con botón.");
    }

    public void abrirBaul() {
        System.out.println("Baúl abierto.");
    }
}

Auto no declara marca ni frenar(), pero los tiene. Los heredó.

Jerarquía de herencia: una superclase Vehículo y tres subclases Vehículo protected String marca; public void arrancar() public void frenar() Auto hereda: marca, frenar() @Override arrancar() + abrirBaul() Moto hereda: marca, frenar() @Override arrancar() + hacerCaballito() Camión hereda: marca, arrancar() @Override frenar() + cargar(kg) Cada subclase recibe todo lo de Vehículo y solo redefine lo que necesita cambiar. La flecha apunta al padre.
La herencia va de lo general a lo específico. Lo que está arriba lo tienen todos; lo que está abajo es lo propio de cada uno.

La prueba antes de escribir extends

Antes de heredar, hacete esta pregunta en voz alta: “¿un X es un Y?”

  • Un Auto es un Vehiculo. ✔ Herencia correcta.
  • Un Auto es un Motor. ✘ Un auto tiene un motor. Eso es composición, no herencia.

Si la frase suena rara, la herencia está mal. Y una herencia mal puesta no se nota el primer día: se nota seis meses después, cuando la subclase hereda cinco métodos que no tienen ningún sentido en ella.

Java tiene herencia simple: una clase extiende a una sola clase. No existe extends A, B. Para combinar comportamientos de varios orígenes están las interfaces, que vas a ver en la lección siguiente.


2. super: la cadena de constructores

Acá está la parte que más confunde al principio. Cuando instanciás una subclase, no se ejecuta un constructor: se ejecuta toda la cadena, desde el ancestro más lejano hasta la clase concreta.

Orden de ejecución de la cadena de constructores con super Auto a = new Auto("Toyota"); 1. Primero suben las llamadas a super(...) hasta llegar a Object. Object() La raíz implícita de toda clase Java. Se ejecuta primero. Vehiculo(String marca) this.marca = marca; → el estado heredado ya queda listo. Auto(String marca) El cuerpo propio de Auto corre ÚLTIMO, con todo lo de arriba listo. 2. Después se ejecutan los cuerpos, de arriba hacia abajo. Cuando el cuerpo de Auto empieza a correr, la parte heredada del objeto ya está completamente inicializada.
La llamada sube y la ejecución baja. Por eso una subclase nunca trabaja sobre un estado heredado a medio construir.

Tres reglas que el compilador aplica sin excepción:

  1. super(...) tiene que ser la primera sentencia del constructor. Igual que this(...), y por la misma razón: nada puede correr antes de que la parte heredada esté lista.
  2. Si no escribís super(...), Java inserta super() sin argumentos automáticamente.
  3. Si la superclase no tiene constructor sin argumentos, esa inserción automática falla y el compilador te obliga a llamar explícitamente a uno que sí exista.

Este es el error más frecuente de toda la lección:

public class Vehiculo {
    protected String marca;
    public Vehiculo(String marca) { this.marca = marca; }
    // Al escribir este constructor, Vehiculo dejó de tener uno sin argumentos
}

public class Auto extends Vehiculo {
    public Auto() {
        // ERROR: Java intenta insertar super() y Vehiculo no tiene ese constructor
    }
}

super también sirve para llamar a la versión del padre de un método que estás sobrescribiendo, algo muy común cuando querés extender el comportamiento en lugar de reemplazarlo:

@Override
public void arrancar() {
    super.arrancar();                  // primero hace lo que hace todo vehículo
    System.out.println("...y activa el arranque sin llave.");
}

3. Sobrescritura: @Override y el contrato que no podés romper

Sobrescribir es redefinir en la subclase un método que ya existe en la superclase, con la misma firma: mismo nombre, mismos tipos de parámetros, en el mismo orden.

La anotación @Override no es obligatoria, pero escribila siempre. No cambia nada en tiempo de ejecución; lo que hace es pedirle al compilador que verifique que realmente estás sobrescribiendo algo:

public class Auto extends Vehiculo {
    @Override
    public void arrancar(int velocidad) {   // ← ERROR de compilación, y eso es bueno
        ...
    }
}

Sin @Override, ese método compilaría perfecto. Java lo trataría como un método nuevo de Auto llamado arrancar que recibe un int, y el arrancar() original seguiría heredado sin cambios. Tu código correría, no haría lo que esperabas, y no habría ningún error para guiarte. @Override convierte un bug silencioso en un error de compilación.

Qué puede y qué no puede cambiar la subclase

ElementoRegla al sobrescribir
Nombre y parámetrosIdénticos. Si cambian, ya no es sobrescritura sino un método nuevo.
Tipo de retornoIgual, o un subtipo del original (retorno covariante).
VisibilidadIgual o más abierta. Un método public no puede volverse protected.
Excepciones checkedLas mismas, menos, o subtipos. Nunca una más amplia.
Métodos private, static o finalNo se pueden sobrescribir.

La regla de la visibilidad tiene una lógica muy concreta: si alguien puede tratar a un Auto como un Vehiculo, y Vehiculo.arrancar() es público, entonces arrancar() tiene que seguir siendo invocable en el Auto. Cerrarlo rompería esa promesa.


4. Sobrecarga y sobrescritura: se parecen los nombres, no los conceptos

Estas dos palabras se confunden todo el tiempo, y la diferencia de fondo es cuándo se decide qué método se ejecuta.

Diferencia entre sobrecarga, resuelta al compilar, y sobrescritura, resuelta al ejecutar Sobrecarga (overloading) — la decide el COMPILADOR Varios métodos distintos, con el mismo nombre, dentro de la misma clase: imprimir(String t) imprimir(int n) imprimir(String t, int n) Elige uno mirando los TIPOS de los argumentos que escribiste. Todo queda resuelto antes de ejecutar. Sobrescritura (overriding) — la decide la JVM Un mismo método, redefinido más abajo en la jerarquía: Vehiculo.arrancar() la versión general Auto.arrancar() @Override la versión que gana si el objeto es un Auto Elige uno mirando el OBJETO real que hay en el Heap. Solo se sabe cuando el programa ya está corriendo.
Sobrecarga: misma clase, firmas distintas, decisión en tiempo de compilación. Sobrescritura: clases distintas, misma firma, decisión en tiempo de ejecución.
public class Consola {
    public void imprimir(String texto) { ... }
    public void imprimir(int numero) { ... }
    public void imprimir(String texto, int veces) { ... }
}

Nada de esto es herencia. Es simplemente comodidad: tres formas de llamar a algo que conceptualmente es lo mismo.

El tipo de retorno no cuenta para la sobrecarga. int calcular() y double calcular() en la misma clase no compilan: el compilador no tiene forma de decidir cuál querés cuando escribís calcular(); a secas.


5. Polimorfismo y despacho dinámico

Acá se junta todo lo anterior. Una variable declarada como Vehiculo puede apuntar a cualquier objeto que sea un Vehiculo, incluidos los de sus subclases:

Vehiculo v = new Auto("Toyota");
v.arrancar();   // Imprime: "Auto Toyota arrancando con botón."

Fijate lo que pasó: la variable dice Vehiculo, pero se ejecutó el código de Auto. Eso es polimorfismo, y el mecanismo se llama despacho dinámico.

Despacho dinámico: el tipo de la variable y el tipo del objeto deciden cosas distintas Vehiculo v = new Auto("Toyota"); STACK Vehiculo v tipo declarado: Vehiculo HEAP objeto Auto tipo real: Auto — acá vive arrancar() sobrescrito v.arrancar(); "Auto Toyota arrancando con botón." El tipo de la VARIABLE decide qué métodos podés escribir. Lo revisa el compilador. El tipo del OBJETO decide qué código se ejecuta. Lo resuelve la JVM en cada llamada. Esas dos frases son, literalmente, la definición de polimorfismo.
La variable determina el contrato visible; el objeto determina la implementación que corre. Compilador y JVM miran cosas distintas.

Para qué sirve esto de verdad

El valor del polimorfismo no está en una llamada suelta, está en poder escribir código que no sabe con qué subclase está trabajando y no le importa:

public class Taller {
    // Este método no conoce Auto, Moto ni Camión. Y no necesita conocerlos.
    public void revisar(Vehiculo[] flota) {
        for (Vehiculo v : flota) {
            v.arrancar();   // cada objeto ejecuta SU propia versión
            v.frenar();
        }
    }
}

Vehiculo[] flota = {
    new Auto("Toyota"),
    new Moto("Honda"),
    new Camion("Scania")
};
new Taller().revisar(flota);

Mañana agregás una clase Bicicleta extends Vehiculo y Taller la maneja sin que toques una sola línea suya. Ese es el premio: código nuevo que se integra sin modificar el que ya funcionaba.

La alternativa sin polimorfismo es esta cadena, que crece para siempre y hay que tocar cada vez:

// El código que el polimorfismo te ahorra escribir
if (v instanceof Auto) {
    ((Auto) v).arrancarAuto();
} else if (v instanceof Moto) {
    ((Moto) v).arrancarMoto();
} else if (v instanceof Camion) {
    ...
}

Casteo y instanceof

Con una referencia de tipo Vehiculo solo podés llamar a lo que Vehiculo declara. Si necesitás algo específico de la subclase, tenés que hacer un casteo, y hacerlo con red:

Vehiculo v = new Auto("Toyota");

// v.abrirBaul();  // ERROR: Vehiculo no declara abrirBaul()

if (v instanceof Auto auto) {   // pattern matching, desde Java 16
    auto.abrirBaul();           // 'auto' ya viene casteado y listo
}

Sin la comprobación, un casteo a un tipo equivocado revienta en ejecución con ClassCastException. Y si te encontrás casteando seguido, tomalo como una señal: probablemente el método que necesitás debería estar declarado en la superclase.


6. Cuándo NO heredar

La herencia es la relación más fuerte que existe entre dos clases: la subclase queda atada a los detalles internos del padre para siempre. Cada cambio en la superclase puede romper subclases que nadie tocó.

Por eso la regla de la industria es preferir composición antes que herencia:

// Herencia forzada: ¿un Auto ES UN Motor? No.
public class Auto extends Motor { ... }

// Composición: un Auto TIENE UN motor. Esto sí.
public class Auto {
    private final Motor motor;

    public Auto(Motor motor) {
        this.motor = motor;
    }

    public void arrancar() {
        motor.encender();   // delega en el motor
    }
}

La composición te deja cambiar el motor sin tocar el auto, y probar el auto con un motor de mentira. La herencia no te deja ninguna de las dos cosas.

Cuando una clase no debe ser extendida, decilo con final y que el compilador lo haga cumplir:

public final class Coordenada { ... }   // nadie puede heredar de acá

public class Cuenta {
    public final void acreditar(double m) { ... }   // este método no se sobrescribe
}

7. Todo hereda de Object

Aunque no escribas extends, toda clase en Java hereda de Object. De ahí vienen métodos que ya usaste sin darte cuenta:

public class Auto extends Vehiculo {
    @Override
    public String toString() {
        return "Auto{marca='" + marca + "'}";
    }
}

Auto a = new Auto("Toyota");
System.out.println(a);   // Java llama a toString() solo

Sin sobrescribir toString(), System.out.println(a) imprime algo como Auto@1b6d3586: el nombre de la clase y un código hash. Inútil para depurar. Sobrescribirlo cuesta dos líneas y te devuelve horas.

Object también trae equals() y hashCode(), que tienen reglas propias y bastante trampa. Los vas a ver en profundidad en la lección de iteradores y ordenamiento.


8. Errores frecuentes

ErrorQué pasaCómo se arregla
Sobrescribir cambiando los parámetrosJava lo toma como un método nuevo. El original sigue heredado y tu código no hace nada de lo que esperás.Poner @Override siempre: convierte el bug en error de compilación.
Constructor de subclase sin super(...) cuando el padre no tiene constructor vacíoError de compilación poco claro sobre un constructor que nunca escribiste.Llamar explícitamente a super(argumentos) en la primera línea.
Llamar a un método sobrescribible desde el constructor del padreCorre la versión de la subclase antes de que sus campos estén inicializados: valores en null o 0 sin explicación.Que los constructores llamen solo a métodos private o final.
Castear sin comprobar con instanceofClassCastException en ejecución.Usar if (v instanceof Auto auto), o repensar por qué necesitás el casteo.
Heredar para reutilizar código, sin que exista un “es un”Jerarquías rígidas donde la subclase hereda métodos sin sentido.Componer: tener el objeto como atributo y delegarle.
Confundir sobrecarga con sobrescrituraSe espera polimorfismo y se obtiene una selección estática hecha por el compilador.Sobrecarga: misma clase, firmas distintas. Sobrescritura: subclase, misma firma.

9. Ejercicio práctico guiado

Desafío: jerarquía de empleados

  1. Creá una superclase Empleado con nombre y sueldoBase (ambos protegidos o privados con getters), un constructor que valide que el sueldo no sea negativo, y un método calcularSalario() que devuelva el sueldo base.
  2. Creá Gerente extends Empleado, que agregue un bono y sobrescriba calcularSalario() para sumarlo.
  3. Creá Vendedor extends Empleado, con ventasDelMes y una comisión del 8 %.
  4. Sobrescribí toString() en las tres.
  5. En el main, armá un Empleado[] con objetos de los tres tipos, recorrelo una sola vez y mostrá el salario de cada uno. El bucle no debe usar instanceof ni castear.
Ver solución sugerida
public class Empleado {
    private final String nombre;
    private final double sueldoBase;

    public Empleado(String nombre, double sueldoBase) {
        if (nombre == null || nombre.isBlank()) {
            System.out.println("Nombre inválido, se usó \"Sin nombre\" por defecto.");
            nombre = "Sin nombre";
        }
        if (sueldoBase < 0) {
            System.out.println("Sueldo base inválido, se usó 0 por defecto.");
            sueldoBase = 0;
        }
        this.nombre = nombre;
        this.sueldoBase = sueldoBase;
    }

    public String getNombre() { return nombre; }
    public double getSueldoBase() { return sueldoBase; }

    public double calcularSalario() {
        return sueldoBase;
    }

    @Override
    public String toString() {
        return getClass().getSimpleName() + " " + nombre;
    }
}

public class Gerente extends Empleado {
    private final double bono;

    public Gerente(String nombre, double sueldoBase, double bono) {
        super(nombre, sueldoBase);          // primera sentencia, obligatorio
        if (bono < 0) {
            System.out.println("Bono inválido, se usó 0 por defecto.");
            bono = 0;
        }
        this.bono = bono;
    }

    @Override
    public double calcularSalario() {
        return super.calcularSalario() + bono;   // extiende, no reemplaza
    }
}

public class Vendedor extends Empleado {
    private static final double COMISION = 0.08;
    private final double ventasDelMes;

    public Vendedor(String nombre, double sueldoBase, double ventasDelMes) {
        super(nombre, sueldoBase);
        if (ventasDelMes < 0) {
            System.out.println("Ventas inválidas, se usó 0 por defecto.");
            ventasDelMes = 0;
        }
        this.ventasDelMes = ventasDelMes;
    }

    @Override
    public double calcularSalario() {
        return super.calcularSalario() + ventasDelMes * COMISION;
    }
}

public class MainNomina {
    public static void main(String[] args) {
        Empleado[] nomina = {
            new Empleado("Ana Torres", 800000),
            new Gerente("Luis Paz", 1500000, 400000),
            new Vendedor("Sofía Ríos", 700000, 2500000)
        };

        double total = 0;
        // Un solo bucle, sin instanceof y sin casteos:
        for (Empleado e : nomina) {
            double salario = e.calcularSalario();
            total += salario;
            System.out.printf("%-22s $ %,.2f%n", e, salario);
        }
        System.out.printf("%-22s $ %,.2f%n", "TOTAL", total);
    }
}

Lo que hay que mirar acá es el bucle. No pregunta de qué tipo es cada empleado, y sin embargo cada uno calcula su salario a su manera. Si mañana agregás Pasante extends Empleado, ese bucle sigue funcionando sin una sola modificación. Eso es polimorfismo haciendo su trabajo.

Fijate también en super.calcularSalario(): Gerente y Vendedor no repiten la lógica del sueldo base, la reutilizan y le suman lo suyo.


Para llevarte

  • extends solo se justifica si la frase “un X es un Y” es verdadera. Si no, componé.
  • La cadena de constructores sube con super(...) y se ejecuta bajando: el padre siempre queda inicializado antes que el hijo.
  • Escribí @Override siempre: transforma un método fantasma silencioso en un error de compilación.
  • Sobrecarga = misma clase, firmas distintas, la decide el compilador. Sobrescritura = subclase, misma firma, la decide la JVM.
  • La variable define qué podés llamar; el objeto define qué se ejecuta. Ahí está todo el polimorfismo.
  • El beneficio real es escribir código que funciona con subclases que todavía no existen.
  • Castear seguido es un síntoma de que el diseño de la jerarquía está pidiendo un método en la superclase.
  • Preferí composición antes que herencia, y marcá con final lo que no debe extenderse.