Cuando programas durante un tiempo, empiezas a notar un patrón incómodo: copias el mismo código en distintas clases porque todas comparten comportamiento similar. Una clase Perro y una clase Gato tienen nombre, edad y un método para comer, pero las escribes dos veces. Si luego necesitas cambiar cómo funciona el método de comer, debes cambiarlo en cada lugar.
La herencia es el mecanismo de Java que resuelve exactamente ese problema. Te permite definir una clase base con el comportamiento común, y hacer que otras clases la extiendan para reutilizar ese código sin repetirlo, agregando o modificando solo lo que necesitan.
Es uno de los cuatro pilares de la programación orientada a objetos junto con la encapsulación, el polimorfismo y la abstracción. En este artículo aprenderás cómo funciona, cómo usarla correctamente y cuándo no usarla.
El concepto: clase padre y clase hija
La herencia establece una relación entre dos clases:
- Clase padre (también llamada superclase o clase base): define los atributos y métodos comunes.
- Clase hija (también llamada subclase o clase derivada): hereda todo lo de la clase padre y puede agregar atributos, métodos propios o modificar el comportamiento heredado.
La relación que establece la herencia se describe con la frase "es un". Si tienes sentido decir "un Perro es un Animal", entonces la herencia es apropiada. Si no tiene sentido decir "un X es un Y", probablemente la herencia no es la relación correcta.
Tu primera herencia en Java: extends
En Java, la herencia se establece con la palabra clave extends en la declaración de la clase hija.
// Clase padre
public class Animal {
String nombre;
int edad;
public Animal(String nombre, int edad) {
this.nombre = nombre;
this.edad = edad;
}
public void comer() {
System.out.println(nombre + " está comiendo.");
}
public void dormir() {
System.out.println(nombre + " está durmiendo.");
}
public void describir() {
System.out.println("Nombre: " + nombre + ", Edad: " + edad);
}
}
// Clase hija
public class Perro extends Animal {
String raza;
public Perro(String nombre, int edad, String raza) {
super(nombre, edad); // llama al constructor del padre
this.raza = raza;
}
public void ladrar() {
System.out.println(nombre + " dice: ¡Guau!");
}
}
// Uso
public class Main {
public static void main(String[] args) {
Perro perro = new Perro("Max", 3, "Labrador");
// Métodos heredados de Animal
perro.describir(); // Nombre: Max, Edad: 3
perro.comer(); // Max está comiendo.
perro.dormir(); // Max está durmiendo.
// Método propio de Perro
perro.ladrar(); // Max dice: ¡Guau!
System.out.println("Raza: " + perro.raza); // Labrador
}
}La clase Perro no redefinió comer() ni dormir(). Los heredó automáticamente. Solo agregó lo que es específico de un perro: la raza y el método ladrar().
La palabra clave super
super es una referencia a la clase padre. Tiene dos usos principales: llamar al constructor del padre y llamar a métodos del padre desde la clase hija.
Llamar al constructor del padre
Cuando la clase padre tiene un constructor con parámetros, la clase hija debe llamarlo explícitamente con super() como primera instrucción de su propio constructor.
public class Vehiculo {
String marca;
int anio;
public Vehiculo(String marca, int anio) {
this.marca = marca;
this.anio = anio;
System.out.println("Constructor de Vehiculo ejecutado.");
}
}
public class Coche extends Vehiculo {
int numeroPuertas;
public Coche(String marca, int anio, int numeroPuertas) {
super(marca, anio); // debe ser la primera línea
this.numeroPuertas = numeroPuertas;
System.out.println("Constructor de Coche ejecutado.");
}
}
// Al crear un Coche:
// Constructor de Vehiculo ejecutado.
// Constructor de Coche ejecutado.El constructor del padre siempre se ejecuta primero. Si no llamas a super() explícitamente y el padre tiene un constructor sin parámetros, Java lo llama automáticamente. Si el padre no tiene constructor sin parámetros, no llamar a super() es un error de compilación.
Llamar a métodos del padre
public class Animal {
public void describir() {
System.out.println("Soy un animal.");
}
}
public class Perro extends Animal {
@Override
public void describir() {
super.describir(); // llama al método del padre primero
System.out.println("Más específicamente, soy un perro.");
}
}
// Uso
Perro perro = new Perro("Rex", 2, "Pastor Alemán");
perro.describir();
// Soy un animal.
// Más específicamente, soy un perro.Sobreescritura de métodos: @Override
La sobreescritura (override) permite que una clase hija redefina un método heredado del padre para cambiar su comportamiento. La anotación @Override es técnicamente opcional, pero siempre debes usarla porque le indica al compilador tu intención y te protege de errores de tipeo en el nombre del método.
public class Animal {
String nombre;
public Animal(String nombre) {
this.nombre = nombre;
}
public void hacerSonido() {
System.out.println(nombre + " hace un sonido genérico.");
}
}
public class Perro extends Animal {
public Perro(String nombre) {
super(nombre);
}
@Override
public void hacerSonido() {
System.out.println(nombre + " dice: ¡Guau!");
}
}
public class Gato extends Animal {
public Gato(String nombre) {
super(nombre);
}
@Override
public void hacerSonido() {
System.out.println(nombre + " dice: ¡Miau!");
}
}
public class Pajaro extends Animal {
public Pajaro(String nombre) {
super(nombre);
}
@Override
public void hacerSonido() {
System.out.println(nombre + " dice: ¡Pío pío!");
}
}
// Uso
Animal[] animales = {
new Perro("Max"),
new Gato("Luna"),
new Pajaro("Pico")
};
for (Animal a : animales) {
a.hacerSonido();
}
// Max dice: ¡Guau!
// Luna dice: ¡Miau!
// Pico dice: ¡Pío pío!Aquí entra en juego el polimorfismo: aunque el array es de tipo Animal, cada objeto ejecuta su propia versión del método hacerSonido(). Java decide en tiempo de ejecución qué versión usar según el tipo real del objeto.
Qué se hereda y qué no
No todo lo que tiene una clase padre se hereda automáticamente. Las reglas son:
| Qué tiene el padre | ¿Se hereda? |
|---|---|
| Atributos y métodos public | Sí |
| Atributos y métodos protected | Sí |
| Atributos y métodos sin modificador (package-private) | Solo si están en el mismo paquete |
| Atributos y métodos private | No directamente (existen pero no son accesibles) |
| Constructores | No (pero se pueden llamar con super()) |
| Métodos static | Se heredan pero no se sobreescriben (se ocultan) |
public class Padre {
public String publico = "público";
protected String protegido = "protegido";
String paquete = "paquete";
private String privado = "privado";
public String getPrivado() {
return privado; // los hijos acceden al privado a través de getters
}
}
public class Hijo extends Padre {
public void mostrar() {
System.out.println(publico); // ✅ accesible
System.out.println(protegido); // ✅ accesible
System.out.println(paquete); // ✅ accesible (mismo paquete)
// System.out.println(privado); // ❌ error de compilación
System.out.println(getPrivado()); // ✅ accesible a través del getter
}
}Herencia multinivel
Java permite cadenas de herencia: una clase hija puede ser a su vez padre de otra clase. Cada nivel hereda todo lo de los niveles superiores.
public class Animal {
String nombre;
public Animal(String nombre) {
this.nombre = nombre;
}
public void respirar() {
System.out.println(nombre + " está respirando.");
}
}
public class Mamifero extends Animal {
public Mamifero(String nombre) {
super(nombre);
}
public void amamantar() {
System.out.println(nombre + " puede amamantar a sus crías.");
}
}
public class Perro extends Mamifero {
String raza;
public Perro(String nombre, String raza) {
super(nombre);
this.raza = raza;
}
public void ladrar() {
System.out.println(nombre + " dice: ¡Guau!");
}
}
// Uso
Perro perro = new Perro("Max", "Beagle");
perro.respirar(); // heredado de Animal
perro.amamantar(); // heredado de Mamifero
perro.ladrar(); // propio de PerroJava no tiene límite oficial en la profundidad de la jerarquía, pero en la práctica más de tres o cuatro niveles suele ser una señal de que el diseño se puede simplificar.
Java no tiene herencia múltiple de clases
A diferencia de otros lenguajes como C++, Java no permite que una clase herede de más de una clase al mismo tiempo. Esto es una decisión de diseño deliberada para evitar el problema del diamante, una ambigüedad que surge cuando dos padres tienen el mismo método y la clase hija no sabe cuál usar.
// ❌ Esto no es posible en Java
public class ClaseHija extends ClasePadreA, ClasePadreB {
// Error de compilación
}Sin embargo, Java sí permite implementar múltiples interfaces, lo que soluciona muchos de los casos donde necesitarías herencia múltiple:
public interface Nadador {
void nadar();
}
public interface Volador {
void volar();
}
// Una clase puede implementar múltiples interfaces
public class Pato extends Animal implements Nadador, Volador {
public Pato(String nombre) {
super(nombre);
}
@Override
public void nadar() {
System.out.println(nombre + " está nadando.");
}
@Override
public void volar() {
System.out.println(nombre + " está volando.");
}
}La clase Object: la raíz de todo
En Java, toda clase hereda implícitamente de Object, incluso si no lo declaras. Object es la raíz de toda la jerarquía de clases y proporciona métodos que todas las clases tienen disponibles:
toString(): devuelve una representación en texto del objeto.equals(Object obj): compara si dos objetos son iguales.hashCode(): devuelve un número entero que identifica al objeto.getClass(): devuelve la clase del objeto en tiempo de ejecución.
Es una buena práctica sobreescribir toString() y equals() en tus clases:
public class Producto {
private String nombre;
private double precio;
public Producto(String nombre, double precio) {
this.nombre = nombre;
this.precio = precio;
}
@Override
public String toString() {
return "Producto{nombre='" + nombre + "', precio=" + precio + "}";
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Producto otro = (Producto) obj;
return Double.compare(precio, otro.precio) == 0
&& nombre.equals(otro.nombre);
}
}
// Uso
Producto p1 = new Producto("Laptop", 999.99);
Producto p2 = new Producto("Laptop", 999.99);
System.out.println(p1); // Producto{nombre='Laptop', precio=999.99}
System.out.println(p1.equals(p2)); // trueClases abstractas
Una clase abstracta es una clase que no se puede instanciar directamente. Existe para ser extendida por otras clases. Puede tener métodos abstractos (sin implementación, que las hijas deben sobreescribir) y métodos concretos (con implementación, que las hijas heredan).
public abstract class Figura {
String color;
public Figura(String color) {
this.color = color;
}
// Método abstracto: sin implementación, cada figura lo define a su manera
public abstract double calcularArea();
// Método concreto: implementación común para todas las figuras
public void describir() {
System.out.println("Figura de color " + color
+ " con área: " + calcularArea());
}
}
public class Circulo extends Figura {
double radio;
public Circulo(String color, double radio) {
super(color);
this.radio = radio;
}
@Override
public double calcularArea() {
return Math.PI * radio * radio;
}
}
public class Rectangulo extends Figura {
double ancho;
double alto;
public Rectangulo(String color, double ancho, double alto) {
super(color);
this.ancho = ancho;
this.alto = alto;
}
@Override
public double calcularArea() {
return ancho * alto;
}
}
// Uso
Figura[] figuras = {
new Circulo("rojo", 5),
new Rectangulo("azul", 4, 6)
};
for (Figura f : figuras) {
f.describir();
}
// Figura de color rojo con área: 78.53981633974483
// Figura de color azul con área: 24.0
// Esto no es posible: no se puede instanciar una clase abstracta
// Figura f = new Figura("verde"); // Error de compilaciónLa palabra clave final: prohibir la herencia
final aplicado a una clase impide que sea extendida. Aplicado a un método impide que sea sobreescrito. Es útil cuando necesitas garantizar que el comportamiento no cambie.
// Clase que no puede ser extendida
public final class Constantes {
public static final double PI = 3.14159265358979;
public static final double GRAVEDAD = 9.8;
}
public class Animal {
// Método que no puede ser sobreescrito
public final void respirar() {
System.out.println("Respirando...");
}
}
public class Perro extends Animal {
// @Override
// public void respirar() { } // ❌ Error de compilación
}instanceof: verificar el tipo de un objeto
El operador instanceof comprueba si un objeto es instancia de una clase o de alguna de sus subclases. Es útil cuando trabajas con polimorfismo y necesitas saber el tipo real de un objeto.
Animal animal = new Perro("Max", 3, "Beagle");
System.out.println(animal instanceof Animal); // true
System.out.println(animal instanceof Perro); // true
System.out.println(animal instanceof Gato); // false
// Desde Java 16: pattern matching con instanceof
if (animal instanceof Perro perro) {
// No necesitas hacer el cast manualmente
perro.ladrar();
}Ejemplo completo: sistema de empleados
Un ejemplo realista que integra todo lo que viste en este artículo:
// Clase abstracta base
public abstract class Empleado {
protected String nombre;
protected String id;
protected double salarioBase;
public Empleado(String nombre, String id, double salarioBase) {
this.nombre = nombre;
this.id = id;
this.salarioBase = salarioBase;
}
// Cada tipo de empleado calcula su salario de forma distinta
public abstract double calcularSalario();
public void mostrarInfo() {
System.out.println("ID: " + id);
System.out.println("Nombre: " + nombre);
System.out.printf("Salario: $%.2f%n", calcularSalario());
System.out.println("---");
}
@Override
public String toString() {
return nombre + " (ID: " + id + ")";
}
}
// Empleado a tiempo completo
public class EmpleadoFijo extends Empleado {
public EmpleadoFijo(String nombre, String id, double salarioBase) {
super(nombre, id, salarioBase);
}
@Override
public double calcularSalario() {
return salarioBase;
}
}
// Empleado por horas
public class EmpleadoPorHoras extends Empleado {
private int horasTrabajadas;
private double precioPorHora;
public EmpleadoPorHoras(String nombre, String id,
double salarioBase, int horasTrabajadas, double precioPorHora) {
super(nombre, id, salarioBase);
this.horasTrabajadas = horasTrabajadas;
this.precioPorHora = precioPorHora;
}
@Override
public double calcularSalario() {
return salarioBase + (horasTrabajadas * precioPorHora);
}
}
// Empleado con comisión (ventas)
public class EmpleadoVentas extends Empleado {
private double totalVentas;
private double porcentajeComision;
public EmpleadoVentas(String nombre, String id, double salarioBase,
double totalVentas, double porcentajeComision) {
super(nombre, id, salarioBase);
this.totalVentas = totalVentas;
this.porcentajeComision = porcentajeComision;
}
@Override
public double calcularSalario() {
return salarioBase + (totalVentas * porcentajeComision / 100);
}
}
// Programa principal
public class Main {
public static void main(String[] args) {
Empleado[] empleados = {
new EmpleadoFijo("Ana García", "E001", 2500.00),
new EmpleadoPorHoras("Luis Pérez", "E002", 500.00, 80, 15.50),
new EmpleadoVentas("María López", "E003", 1000.00, 15000.00, 5.0)
};
System.out.println("=== NÓMINA DEL MES ===\n");
double totalNomina = 0;
for (Empleado emp : empleados) {
emp.mostrarInfo();
totalNomina += emp.calcularSalario();
}
System.out.printf("Total nómina: $%.2f%n", totalNomina);
}
}Salida:
=== NÓMINA DEL MES ===
ID: E001
Nombre: Ana García
Salario: $2500.00
---
ID: E002
Nombre: Luis Pérez
Salario: $1740.00
---
ID: E003
Nombre: María López
Salario: $1750.00
---
Total nómina: $5990.00Cuándo usar herencia y cuándo no
La herencia es una herramienta poderosa, pero usarla en el lugar equivocado genera código rígido y difícil de mantener. Antes de usar extends, hazte estas preguntas:
- ¿Tiene sentido la relación "es un"? Un
Perroes unAnimal: sí. UnMotores unCoche: no (un motor forma parte de un coche, no es un coche). - ¿La clase hija necesita todo lo que tiene el padre? Si la clase hija hereda métodos que no tiene sentido que tenga, la herencia no es la relación correcta.
- ¿El comportamiento compartido va a cambiar de forma coordinada? Si los cambios en el padre deben reflejarse en todos los hijos, la herencia tiene sentido. Si cada clase evoluciona de forma independiente, mejor usar composición.
Un principio muy conocido en diseño de software dice: prefiere la composición sobre la herencia. Cuando la relación entre dos clases es "tiene un" en lugar de "es un", la composición (incluir un objeto como atributo) suele ser una mejor opción que extender.
// ❌ Herencia inapropiada: un Motor no "es un" Coche
public class Coche extends Motor { }
// ✅ Composición apropiada: un Coche "tiene un" Motor
public class Coche {
private Motor motor; // composición
public Coche(Motor motor) {
this.motor = motor;
}
}Conclusión
La herencia es uno de los conceptos más importantes de la programación orientada a objetos en Java. Te permite reutilizar código, organizar jerarquías de clases de forma lógica y aprovechar el polimorfismo para escribir código más flexible y extensible.
Los puntos clave para recordar: usa extends para heredar, super para acceder al padre, @Override para sobreescribir métodos, clases abstract cuando no tiene sentido instanciar la clase base directamente, y final cuando necesitas prohibir la extensión. Y siempre verifica que la relación entre clases sea genuinamente "es un" antes de aplicar herencia.
Para seguir avanzando, te recomendamos leer nuestros artículos sobre los 4 pilares de la programación orientada a objetos y sobre manejo de excepciones en Java, que complementan muy bien lo que aprendiste aquí.
José Alfredo Ortiz
4 months ago