Herencia en Java — teoría con alumnos y profesores (y el super() que me faltaba en el primer extends)

La primera vez que escribí extends iba muy contento. Tenía una clase Persona con DNI y nombre, y quería que Alumno la aprovechara en vez de copiar el código. Escribí esto:

public class Alumno extends Persona {
    private double nota;

    public Alumno(String dni, String nombre, double nota) {
        this.nota = nota;
    }
}

Y Java, sin dejarme ni ejecutar:

error: constructor Persona in class Persona cannot be applied to given types;
  required: String,String
  found:    no arguments

Yo no había llamado a ningún constructor de Persona. Precisamente ese era el problema: si no lo llamas tú, Java intenta llamar a uno sin parámetros, y mi Persona no tenía. Entender quién construye qué cuando una clase hereda de otra es la mitad de la herencia. La otra mitad es el polimorfismo, y de las dos va este artículo.

Seguimos con el ejemplo de clases y objetos: en la universidad hay alumnos y profesores, y los dos son personas.

Qué es la herencia: «es un»

En el artículo anterior viste que una Asignatura tiene alumnos: eso es composición, un objeto dentro de otro. La herencia es otra relación: un Alumno es una Persona. Un Profesor también es una Persona.

Cuando dos clases comparten datos y comportamiento, en lugar de copiar el código en las dos lo pones una vez en una clase padre y las hijas lo heredan:

  • Persona (la superclase o clase padre) tiene lo común: DNI, nombre, presentarse.
  • Alumno y Profesor (las subclases o clases hijas) heredan todo eso y añaden lo suyo: la nota, el departamento.

Para saber si toca herencia, haz la prueba del «es un». «Un alumno es una persona» suena bien: herencia. «Una asignatura es un alumno» no tiene sentido: ahí lo que hay es «una asignatura tiene alumnos», composición.

Las tres clases completas

Aquí tienes el mapa entero antes de ir pieza a pieza:

public abstract class Persona {

    protected String nombre;            // las subclases pueden leerlo directamente
    private final String dni;           // solo Persona lo toca: las hijas usan getDni()

    public Persona(String dni, String nombre) {
        this.dni = dni;
        this.nombre = nombre;
        System.out.println("1) Persona lista: " + nombre);
    }

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

    // Cada tipo de persona dice qué es: aquí no sabemos cómo
    public abstract String getRol();

    public String presentarse() {
        return "Hola, soy " + nombre + " (" + getRol() + ")";
    }

    @Override
    public String toString() {
        return getRol() + " " + nombre + " [" + dni + "]";
    }
}
public class Alumno extends Persona {

    private double nota;                // lo que Persona no tiene

    public Alumno(String dni, String nombre, double nota) {
        super(dni, nombre);             // primero se construye la parte de Persona
        this.nota = nota;
        System.out.println("2) Alumno listo: nota " + nota);
    }

    public double getNota() { return nota; }

    @Override
    public String getRol() { return "alumno"; }

    @Override
    public String presentarse() {
        return super.presentarse() + " y llevo un " + nota;   // reutiliza la del padre
    }
}
public class Profesor extends Persona {

    private final String departamento;

    public Profesor(String dni, String nombre, String departamento) {
        super(dni, nombre);
        this.departamento = departamento;
    }

    public String getDepartamento() { return departamento; }

    @Override
    public String getRol() { return "profesor"; }

    // Sobrecarga: mismo nombre, parámetros distintos (no sustituye a la otra)
    public String presentarse(String asignatura) {
        return presentarse() + " y doy " + asignatura;
    }
}

Fíjate en lo que no está en Alumno ni en Profesor: ni dni, ni nombre, ni getNombre(), ni toString(). Lo heredan de Persona. Las hijas solo escriben lo que las hace distintas.

extends: qué se hereda y qué no

class Alumno extends Persona significa: «un Alumno tiene todo lo que tiene una Persona, más lo que yo añada aquí». Por eso esto funciona aunque Alumno no defina getDni():

Alumno ana = new Alumno("11111111H", "Ana", 7.5);
System.out.println(ana.getDni() + " " + ana.getNombre());   // 11111111H Ana

Lo que no se hereda:

  • Los constructores. Cada clase tiene los suyos (ahora vemos cómo se encadenan).
  • **El acceso a lo private.** El dni existe dentro de cada Alumno, pero el código de Alumno no puede tocarlo directamente. Si escribes return dni; dentro de Alumno, el compilador responde dni has private access in Persona. Para eso está getDni().

En Java una clase solo puede tener un padre: class A extends B, C no compila. Y si no escribes extends, tu clase hereda de Object, la madre de todas. Por eso cualquier objeto tiene toString() y equals(), aunque no los escribas.

Constructores y super()

Cuando haces new Alumno(...), Java construye el objeto de arriba abajo: primero la parte de Persona y después la parte de Alumno. La llamada super(dni, nombre) es la que dice «constrúyeme la parte de mi padre con estos datos».

Alumno ana = new Alumno("11111111H", "Ana", 7.5);

Salida:

1) Persona lista: Ana
2) Alumno listo: nota 7.5

Los dos println de los constructores lo demuestran: aunque hemos llamado al constructor de Alumno, el de Persona termina antes.

Las tres reglas de super()

  1. Va en la primera línea del constructor. Si pones algo antes, el error es call to super must be first statement in constructor. Tiene lógica: no puedes rematar la parte del hijo si la del padre aún no existe.
  2. **Si no lo escribes, Java pone super() sin parámetros** por ti. Funciona solo si el padre tiene un constructor vacío.
  3. Si el padre no tiene constructor vacío, estás obligado a llamarlo tú. Es mi error del principio: Persona solo tenía Persona(String, String), el super() invisible no encontraba a quién llamar y saltaba constructor Persona in class Persona cannot be applied to given types.

«arreglar» ese error añadiendo a Persona un constructor vacío para que compile. Compila, sí, pero ahora puedes crear alumnos sin DNI ni nombre (null). La solución buena es llamar a super(dni, nombre) desde el hijo.

super frente a this

thissuper
this(...) / super(...)Llama a otro constructor de esta claseLlama a un constructor del padre
this.x / super.metodo()El atributo o método de este objetoLa versión del padre de un método
¿Dónde va la llamada al constructor?Primera líneaPrimera línea

Como los dos tienen que ir en la primera línea, en un mismo constructor usas uno u otro, nunca los dos.

protected: el punto medio

En clases y objetos la regla era: atributos private. Con herencia aparece un modificador más: protected. Un miembro protected lo ven las subclases (y las clases del mismo paquete), pero no el resto del mundo.

En Persona lo hemos usado en nombre, y por eso Alumno puede escribir nombre directamente en sus métodos sin pasar por el getter. El dni, en cambio, sigue siendo private.

ModificadorMisma claseMismo paqueteSubclase (otro paquete)Resto
private✅❌❌❌
(sin nada)✅✅❌❌
protected✅✅✅❌
public✅✅✅✅

protected abre la puerta a todas las subclases, también a las que escriba otra persona dentro de un año. En FP2 lo normal es dejar los atributos private y usar protected solo cuando las hijas los necesitan de verdad. Si dudas, private + getter.

Sobrescribir frente a sobrecargar

Estas dos palabras se parecen tanto que se confunden en todos los exámenes. Vamos con calma.

Sobrescribir (override): mismo método, otra versión

Sobrescribir es que una hija escriba el mismo método que el padre (mismo nombre, mismos parámetros) para sustituir su comportamiento. Alumno sobrescribe presentarse():

@Override
public String presentarse() {
    return super.presentarse() + " y llevo un " + nota;
}

Con super.presentarse() aprovechamos lo que ya hacía Persona («Hola, soy Ana (alumno)») y le añadimos el final. Sin super., llamaríamos a nuestro propio presentarse(): el método se llamaría a sí mismo sin parar hasta un StackOverflowError.

System.out.println(ana.presentarse());
Hola, soy Ana (alumno) y llevo un 7.5

Pon siempre @Override. No cambia nada si aciertas, pero si te equivocas en el nombre (presentarce) o en los parámetros, el compilador te avisa con method does not override or implement a method from a supertype. Sin la anotación, Java crearía en silencio un método nuevo que nadie llama.

Dos reglas al sobrescribir:

  • No puedes reducir la visibilidad. Si en Persona es public, en Alumno tiene que seguir siendo public. Si no, el error es attempting to assign weaker access privileges; was public.
  • **Un método final no se puede sobrescribir.** Si getDni() fuera final en Persona, intentar redefinirlo daría overridden method is final.

Sobrecargar (overload): mismo nombre, otros parámetros

Sobrecargar es tener varios métodos con el mismo nombre y parámetros distintos. Ya lo hiciste con los constructores de Alumno en el artículo anterior. Profesor tiene dos presentarse: el heredado, sin parámetros, y uno nuevo que recibe la asignatura:

Profesor pepe = new Profesor("22222222J", "Pepe", "Informática");
System.out.println(pepe.presentarse());
System.out.println(pepe.presentarse("FP2"));
Hola, soy Pepe (profesor)
Hola, soy Pepe (profesor) y doy FP2

Los dos conviven: el nuevo no sustituye al otro, se suma.

Sobrescribir (override)Sobrecargar (overload)
ParámetrosLos mismosDistintos
¿Dónde?En una subclaseEn la misma clase o en una subclase
¿Sustituye al original?SíNo, convive con él
¿Quién decide cuál se ejecuta?El objeto real, al ejecutarEl tipo de los argumentos, al compilar
Anotación@OverrideNinguna

La trampa del equals

Esta es la confusión más cara de todas. Mira este equals:

public boolean equals(Alumno otro) {          // ¡sobrecarga, no sobrescritura!
    return dni.equals(otro.dni);
}

Parece correcto, pero el equals de Object recibe un Object, no un Alumno. Como los parámetros son distintos, esto no sobrescribe: sobrecarga. Y el resultado depende de cómo esté declarada la variable:

Alumno a = new Alumno("111");
Alumno b = new Alumno("111");
Object c = b;
System.out.println(a.equals(b));   // elige equals(Alumno)
System.out.println(a.equals(c));   // elige equals(Object) de Object: compara referencias
true
false

¡El mismo objeto b da dos respuestas distintas! Por eso en clases y objetos escribimos equals(Object o) con @Override: con la anotación, este error ni siquiera compila.

Polimorfismo: una variable, muchas formas

Ahora viene lo potente. Como un Alumno es una Persona, puedes guardarlo en una variable de tipo Persona:

Persona p = new Alumno("33333333P", "Luis", 4.0);

Y si un Alumno y un Profesor son los dos Persona, caben en el mismo array:

Persona[] gente = { ana, pepe, new Alumno("33333333P", "Luis", 4.0) };

for (Persona p : gente) {
    System.out.println(p.presentarse());
}
Hola, soy Ana (alumno) y llevo un 7.5
Hola, soy Pepe (profesor)
Hola, soy Luis (alumno) y llevo un 4.0

El bucle escribe siempre p.presentarse(), pero cada objeto responde a su manera: los alumnos con su nota, el profesor con la versión de Persona. Eso es el polimorfismo: el mismo mensaje, distintas respuestas según el objeto que lo recibe.

Tipo de la variable frente a tipo del objeto

Hay dos tipos en juego, y cada uno manda en una cosa:

  • El tipo de la variable (Persona) decide qué puedes llamar. Lo comprueba el compilador.
  • El tipo del objeto (Alumno) decide qué versión se ejecuta. Se decide al ejecutar.

Por eso esto no compila, aunque el objeto sea un alumno:

Persona p = new Alumno("33333333P", "Luis", 4.0);
p.getNota();     // error: cannot find symbol

El compilador solo sabe que p es una Persona, y las personas no tienen nota. No mira qué objeto habrá dentro al ejecutar.

En Python esto no pasa porque no declaras tipos: si el objeto tiene el método, se llama. Java comprueba antes de ejecutar, y eso es justo lo que te salva de muchos errores en programas grandes.

Casting e instanceof: volver al tipo concreto

¿Y si dentro del bucle necesito la nota de los alumnos? Primero pregunto qué es el objeto con instanceof y después lo convierto (casting o downcasting) al tipo concreto:

for (Persona p : gente) {
    if (p instanceof Alumno a) {
        System.out.println(a.getNombre() + ": " + (a.getNota() >= 5 ? "aprobado" : "suspenso"));
    } else if (p instanceof Profesor prof) {
        System.out.println(prof.getNombre() + " es de " + prof.getDepartamento());
    }
}
Ana: aprobado
Pepe es de Informática
Luis: suspenso

p instanceof Alumno a hace dos cosas a la vez: comprueba que p es un Alumno y, si lo es, te lo da ya convertido en la variable a. Es la forma moderna (Java 16 o posterior). En muchos apuntes verás la forma clásica, que hace lo mismo en dos pasos:

if (p instanceof Alumno) {
    Alumno a = (Alumno) p;     // cast explícito
    System.out.println(a.getNota());
}

Hacer el cast sin comprobar. Si el objeto no es de ese tipo, compila pero revienta al ejecutar con ClassCastException. Por ejemplo, (Alumno) pepe (con pepe guardado en una variable Persona) da: class Profesor cannot be cast to class Alumno. Antes de un cast hacia abajo, siempre instanceof.

El cast hacia arriba (de Alumno a Persona) nunca hace falta escribirlo ni puede fallar: todo alumno es una persona. El que es peligroso es el de bajada, de Persona a Alumno, porque no toda persona es un alumno.

Si te ves escribiendo muchos instanceof seguidos para hacer cosas distintas según el tipo, normalmente es señal de que ese comportamiento debería ser un método sobrescrito. El polimorfismo bien usado elimina casi todos los casts.

Clases y métodos abstractos

¿Tiene sentido hacer new Persona(...)? En nuestra universidad, no: todo el mundo es alumno o profesor. Persona existe para compartir código, no para crear objetos. Para eso está abstract:

public abstract class Persona {
    ...
    public abstract String getRol();     // sin cuerpo: solo la firma y ;
}
  • Una clase abstracta no se puede instanciar. new Persona(...) da Persona is abstract; cannot be instantiated. Pero sí puede tener atributos, constructores (los usan las hijas con super) y métodos normales.
  • Un método abstracto no tiene cuerpo: dice «toda persona sabe decir su rol, pero cada tipo lo dice a su manera». Si una clase tiene un método abstracto, la clase tiene que ser abstracta.
  • Las subclases están obligadas a implementarlo. Si Alumno no escribiera getRol(), el error sería Alumno is not abstract and does not override abstract method getRol() in Persona.

Lo interesante es que Persona usa getRol() en presentarse() y en toString() aunque no sepa qué devolverá. Cuando el objeto real es un Alumno, se ejecuta el getRol() de Alumno:

System.out.println(ana);     // alumno Ana [11111111H]

El padre deja el hueco y cada hija lo rellena.

final: cerrar la puerta

final tiene tres usos, y con herencia aparecen dos nuevos:

DóndeQué significa
Atributo finalSe asigna una vez y no cambia (ya lo conocías)
Método finalLas subclases no pueden sobrescribirlo
Clase finalNadie puede heredar de ella (cannot inherit from final)

String es una clase final: no puedes hacer class MiString extends String. Así nadie puede cambiar cómo se comporta algo tan básico.

abstract y final son opuestos. abstract dice «alguien tiene que completarme»; final dice «nadie puede cambiarme». Por eso no se pueden poner juntos en el mismo método o clase.

Míralo paso a paso en Python Tutor

Python Tutor ejecuta el código línea a línea y dibuja las variables y los objetos. Elige Java en el desplegable, pega este programa y avanza con Next:

public class Tutor {
    static abstract class Persona {
        String nombre;
        Persona(String nombre) { this.nombre = nombre; }
        abstract String rol();
        String presentarse() { return nombre + " (" + rol() + ")"; }
    }
    static class Alumno extends Persona {
        double nota;
        Alumno(String nombre, double nota) { super(nombre); this.nota = nota; }
        String rol() { return "alumno"; }
    }
    static class Profesor extends Persona {
        Profesor(String nombre) { super(nombre); }
        String rol() { return "profesor"; }
    }
    public static void main(String[] args) {
        Persona[] gente = { new Alumno("Ana", 7.5), new Profesor("Pepe") };
        for (Persona p : gente) {
            System.out.println(p.presentarse());
        }
        Persona q = gente[0];
        Alumno a = (Alumno) q;       // q apunta a un Alumno: el cast es seguro
        System.out.println(a.nota + " " + (a == q));
    }
}

Salida:

Ana (alumno)
Pepe (profesor)
7.5 true

Qué observar:

  1. Al crear el Alumno, mira la pila: primero entra el constructor de Alumno, que salta enseguida al de Persona con super(nombre). nombre se rellena antes que nota.
  2. El objeto Alumno del montón tiene los dos atributos: nombre (heredado) y nota (propio). No hay un objeto Persona aparte: es uno solo.
  3. Dentro del bucle, p es siempre de tipo Persona, pero cuando presentarse() llama a rol(), el salto va al rol() de Alumno en la primera vuelta y al de Profesor en la segunda.
  4. En el cast, fíjate en que a y q apuntan al mismo objeto. El cast no crea ni copia nada: solo cambia el tipo con el que lo miras. Por eso a == q es true.

Resumen y tabla rápida

ConceptoEn JavaSi vienes de Python
Heredarclass Alumno extends Personaclass Alumno(Persona):
Llamar al constructor del padresuper(dni, nombre) (primera línea)super().__init__(dni, nombre)
Versión del padre de un métodosuper.presentarse()super().presentarse()
Sobrescribirmismo método + @Overrideredefinir el método
Sobrecargarmismo nombre, otros parámetrosno existe (se usan valores por defecto)
Comprobar el tipop instanceof Alumno aisinstance(p, Alumno)
Clase que no se instanciaabstract classABC + @abstractmethod
Herencia múltipleNo (un solo padre)Sí

Tres ideas para quedarte:

  1. Primero el padre: super(...) en la primera línea construye la parte heredada; si no lo escribes, Java intenta super() sin parámetros.
  2. Sobrescribir sustituye, sobrecargar suma: @Override siempre, y cuidado con el equals(Alumno).
  3. La variable decide qué puedes llamar; el objeto decide qué se ejecuta. Para bajar de tipo, instanceof antes del cast.

En la práctica construirás tres programas completos con herencia: una nómina con varios tipos de empleado, unas figuras geométricas abstractas y unas cuentas bancarias de ahorro y corrientes.

Siguiente paso: prácticaHerencia en Java — práctica con una nómina, unas figuras y dos tipos de cuenta (y el saldo que hice public para que compilara)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

3 comentarios

Deja una respuesta

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