Saltar al contenido
herencia.java · devschool

Herencia en Java

Lección 16 de 26 · 11 min de lectura · Actualizado el

En esta lección
  1. El problema: código repetido
  2. extends: superclase y subclase
  3. Qué se hereda y qué no
  4. Constructores y super()
  5. Sobrescribir métodos con @Override
  6. super.metodo(): reutilizar la versión del padre
  7. Jerarquías de clases
  8. Object: la raíz de todas las clases
  9. final: impedir la herencia
  10. Herencia simple
  11. Herencia frente a composición
  12. Errores frecuentes
  13. Resumen

La herencia permite crear una clase nueva a partir de otra que ya existe, aprovechando todo lo que tiene y añadiendo o cambiando solo lo necesario. Es uno de los pilares de la programación orientada a objetos y la base de lo que verás después: clases abstractas, interfaces y polimorfismo.

En esta lección construiremos, paso a paso, el sistema de nóminas de una empresa con empleados, programadores y gerentes.

El problema: código repetido

Imagina que la empresa tiene programadores y gerentes. Sin herencia, escribirías dos clases casi iguales:

public class Programador {
    private String nombre;
    private double salarioBase;
    private String lenguaje;
    // getNombre(), calcularSalario()...
}

public class Gerente {
    private String nombre;
    private double salarioBase;
    private double bonus;
    // getNombre(), calcularSalario()... ¡otra vez!
}

El nombre, el salario base y sus métodos están duplicados. Si mañana hay que añadir el DNI a todos los empleados, tendrás que cambiarlo en cada clase, y es fácil olvidarse de alguna.

La solución es sacar lo común a una clase Empleado y hacer que las demás hereden de ella.

extends: superclase y subclase

public class Empleado {
    private String nombre;
    protected double salarioBase;

    public Empleado(String nombre, double salarioBase) {
        this.nombre = nombre;
        this.salarioBase = salarioBase;
    }

    public String getNombre() { return nombre; }

    public double calcularSalario() {
        return salarioBase;
    }

    public String describir() {
        return nombre + " cobra " + calcularSalario() + " €";
    }
}
public class Programador extends Empleado {
    private String lenguaje;

    public Programador(String nombre, double salarioBase, String lenguaje) {
        super(nombre, salarioBase);
        this.lenguaje = lenguaje;
    }

    public String getLenguaje() { return lenguaje; }
}
  • Empleado es la superclase (o clase padre, o clase base).
  • Programador es la subclase (o clase hija, o clase derivada).
  • extends significa “hereda de” o “es una ampliación de”.

Un Programador tiene todo lo de un Empleado más lo suyo:

Programador ana = new Programador("Ana", 2200, "Java");
System.out.println(ana.getNombre());       // Ana         (heredado)
System.out.println(ana.getLenguaje());     // Java        (propio)
System.out.println(ana.calcularSalario()); // 2200.0      (heredado)
System.out.println(ana.describir());       // Ana cobra 2200.0 €

Qué se hereda y qué no

Elemento de la superclase¿Lo hereda la subclase?
Atributos y métodos publicSí, y puede usarlos directamente
Atributos y métodos protectedSí, y puede usarlos directamente
Atributos y métodos sin modificadorSolo si la subclase está en el mismo paquete
Atributos y métodos privateExisten dentro del objeto, pero la subclase no puede tocarlos
ConstructoresNo se heredan nunca

Lo de private sorprende al principio. Un Programador tiene un nombre (ocupa sitio en el objeto), pero su código no puede escribir nombre directamente:

public String saludo() {
    return "Hola " + nombre;   // error: nombre has private access in Empleado
}

Tiene que usar el getter público: "Hola " + getNombre().

protected

Si quieres que las subclases puedan usar un atributo directamente, pero que el resto del mundo no, decláralo protected. Es lo que hemos hecho con salarioBase. La tabla completa de modificadores está en paquetes y modificadores de acceso.

Consejo: aunque protected es cómodo, muchos programadores prefieren dejar los atributos private y dar a las subclases métodos protected para leerlos o cambiarlos. Así la superclase sigue controlando sus datos.

Constructores y super()

Los constructores no se heredan, así que cada subclase escribe el suyo. Pero la parte “de empleado” del objeto la tiene que inicializar el constructor de Empleado. Para llamarlo se usa super(...):

public Programador(String nombre, double salarioBase, String lenguaje) {
    super(nombre, salarioBase);   // primero, la parte de Empleado
    this.lenguaje = lenguaje;     // después, lo propio
}

Reglas:

  • super(...) debe ser la primera instrucción del constructor (en Java 21; Java 25 permite algunas instrucciones antes, siempre que no usen this).
  • Si no lo escribes, Java añade por su cuenta super() sin argumentos. Si la superclase no tiene un constructor sin parámetros, el programa no compila:
public class Becario extends Empleado {
    public Becario(String nombre) { }
    // error: constructor Empleado in class Empleado cannot be applied to given types;
}

Orden de construcción

Al crear un objeto de una subclase, siempre se construye primero la parte del padre. Si añadimos un mensaje a cada constructor:

// En Empleado:    System.out.println("Creando empleado " + nombre);
// En Programador: System.out.println("Creando programador de " + lenguaje);

Programador ana = new Programador("Ana", 2200, "Java");
// Creando empleado Ana
// Creando programador de Java

Tiene sentido: la casa se construye desde los cimientos.

Sobrescribir métodos con @Override

Los gerentes cobran su salario base más un bonus. El método heredado calcularSalario() no nos vale tal cual, así que lo sobrescribimos: escribimos en la subclase un método con la misma firma (mismo nombre, mismos parámetros y el mismo tipo de retorno, o uno más concreto).

public class Gerente extends Empleado {
    private double bonus;

    public Gerente(String nombre, double salarioBase, double bonus) {
        super(nombre, salarioBase);
        this.bonus = bonus;
    }

    @Override
    public double calcularSalario() {
        return salarioBase + bonus;
    }
}
Gerente luis = new Gerente("Luis", 3000, 500);
System.out.println(luis.calcularSalario()); // 3500.0
System.out.println(luis.describir());       // Luis cobra 3500.0 €

Fíjate en la última línea. describir() está escrito en Empleado y llama a calcularSalario(); como el objeto es un Gerente, se ejecuta la versión del gerente. Esta idea es la base del polimorfismo.

Para qué sirve @Override

@Override es una anotación: le dice al compilador “este método debería sobrescribir uno del padre”. No es obligatoria, pero te protege de erratas:

@Override
public double calcularSalaro() { ... }
// error: method does not override or implement a method from a supertype

Sin la anotación, Java pensaría que es un método nuevo, compilaría sin quejarse y el gerente seguiría cobrando sin bonus. Ponla siempre.

Cuidado: al sobrescribir no puedes hacer el método más restrictivo. Si en el padre es public, en el hijo debe seguir siendo public.

super.metodo(): reutilizar la versión del padre

A veces no quieres sustituir el método del padre, sino ampliarlo. Con super.metodo() llamas a la versión de la superclase:

@Override
public double calcularSalario() {
    return super.calcularSalario() + bonus;   // lo del padre + el bonus
}

@Override
public String describir() {
    return super.describir() + " (gerente)";
}
System.out.println(luis.describir()); // Luis cobra 3500.0 € (gerente)

Así, si algún día cambia la forma de calcular el salario base en Empleado, el gerente lo aprovecha sin tocar su código.

Jerarquías de clases

Una subclase puede tener a su vez subclases. Un director es un gerente con un 10 % extra:

public class Director extends Gerente {
    public Director(String nombre, double salarioBase, double bonus) {
        super(nombre, salarioBase, bonus);
    }

    @Override
    public double calcularSalario() {
        return super.calcularSalario() * 1.10;
    }
}
Director eva = new Director("Eva", 4000, 1000);
System.out.println(eva.calcularSalario()); // 5500.0

La jerarquía queda así:

Object
└── Empleado
    ├── Programador
    └── Gerente
        └── Director

Un Director es un Gerente, que es un Empleado. Evita jerarquías muy profundas: con dos o tres niveles suele bastar. Cuantos más niveles, más difícil es saber de dónde viene cada comportamiento.

Object: la raíz de todas las clases

Si una clase no pone extends, Java hace que herede de Object. Por eso todas las clases heredan, directa o indirectamente, de Object, y todos los objetos tienen algunos métodos aunque tú no los escribas. Los más importantes son:

MétodoQué hace por defecto
toString()Devuelve algo como Empleado@45ee12a7
equals(Object o)Compara si son el mismo objeto en memoria (como ==)
hashCode()Devuelve un número asociado al objeto; debe ser coherente con equals

Sobrescribir toString()

System.out.println(objeto) llama a toString(). La versión de Object no es muy útil, así que se suele sobrescribir:

@Override
public String toString() {
    return "Empleado[" + getNombre() + "]";
}

System.out.println(ana); // Empleado[Ana]

Sobrescribir equals()

Dos empleados con el mismo nombre (en un caso real, el mismo DNI) deberían considerarse iguales, pero == y el equals heredado dicen que no porque son objetos distintos:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Empleado otro)) return false;
    return nombre.equals(otro.nombre);
}

@Override
public int hashCode() {
    return Objects.hash(nombre);   // import java.util.Objects;
}
Empleado e1 = new Empleado("Marta", 1800);
Empleado e2 = new Empleado("Marta", 1800);
System.out.println(e1 == e2);      // false: dos objetos distintos
System.out.println(e1.equals(e2)); // true: mismos datos

Si sobrescribes equals, sobrescribe también hashCode; si no, los objetos se comportarán mal en un HashSet o un HashMap (lo verás en mapas y conjuntos). El instanceof Empleado otro es pattern matching, que se explica en polimorfismo.

final: impedir la herencia

Como viste en encapsulación, final bloquea la herencia:

  • Una clase final no puede tener subclases. String es final para que nadie pueda cambiar su comportamiento.
  • Un método final no se puede sobrescribir, aunque la clase sí se pueda heredar.
public class Empleado {
    public final String getNombre() { return nombre; }  // ninguna subclase podrá cambiarlo
}

Úsalo cuando cambiar un comportamiento en una subclase pueda romper algo importante.

Herencia simple

En Java una clase solo puede tener una superclase. Esto no compila:

public class Becario extends Empleado, Estudiante { }   // error de sintaxis

Otros lenguajes, como C++, permiten herencia múltiple, pero trae problemas: si las dos superclases tienen un método con el mismo nombre, ¿cuál se hereda? Java lo evita. Para que una clase “sea” varias cosas a la vez se usan interfaces, que verás en clases abstractas e interfaces.

Herencia frente a composición

La herencia es potente, pero no siempre es la herramienta adecuada. Hazte esta pregunta:

  • “Es un” → herencia. Un programador es un empleado.
  • “Tiene un” → composición: la clase guarda un objeto de la otra como atributo. Un departamento tiene empleados, pero no es un empleado.
public class Departamento {
    private String nombre;
    private List<Empleado> empleados = new ArrayList<>();   // tiene empleados

    public Departamento(String nombre) { this.nombre = nombre; }

    public void contratar(Empleado e) { empleados.add(e); }

    public int getTamanio() { return empleados.size(); }
}
Departamento it = new Departamento("Informática");
it.contratar(new Empleado("Ana", 2200));
it.contratar(new Empleado("Luis", 3000));
System.out.println(it.getTamanio()); // 2

Hacer Departamento extends ArrayList<Empleado> “funcionaría”, pero expondría decenas de métodos de lista que no tienen sentido para un departamento. Por eso se suele decir: prefiere la composición a la herencia, y usa herencia solo cuando la relación “es un” sea clara.

Errores frecuentes

  • No llamar a super(...) cuando el padre no tiene constructor sin parámetros. El compilador se queja de que el constructor del padre no se puede aplicar.
  • Poner super(...) después de otra instrucción. Debe ir la primera.
  • Acceder a atributos private del padre. Usa los getters o hazlos protected.
  • Olvidar @Override y escribir mal el nombre. Creas un método nuevo sin darte cuenta.
  • Sobrescribir equals sin hashCode. Los conjuntos y mapas dejan de funcionar bien.
  • Heredar solo para reutilizar código cuando la relación es “tiene un”. Usa composición.

Resumen

ConceptoIdea clave
extendsLa subclase hereda atributos y métodos de la superclase
super(...)Llama al constructor del padre; primera línea del constructor
super.metodo()Ejecuta la versión del padre de un método
@OverrideMarca un método sobrescrito y detecta erratas
protectedVisible para subclases y para el paquete
ObjectRaíz de todas las clases: toString, equals, hashCode
finalClase que no se hereda o método que no se sobrescribe
Herencia simpleSolo una superclase; para más, interfaces
Composición“Tiene un”: guardar un objeto como atributo

En la siguiente lección verás cómo definir clases que solo sirven de plantilla, las clases abstractas e interfaces.

Pon a prueba lo que has aprendido

[Java] ¿Qué imprime new B()?
class A {
    A() { System.out.print("A"); }
}
class B extends A {
    B() { System.out.print("B"); }
}

[Java] ¿Qué imprime este código?
class Empleado {
    double salario() { return 1000; }
}
class Gerente extends Empleado {
    @Override
    double salario() { return super.salario() + 500; }
}

Empleado e = new Gerente();
System.out.println(e.salario());

[Java] ¿Por qué no compila la clase Becario?
class Empleado {
    private String nombre;
    Empleado(String nombre) { this.nombre = nombre; }
}

class Becario extends Empleado {
    Becario() { }
}

[Java] Un coche tiene un motor. ¿Cómo lo modelarías?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.