Java
Tutorial Java: Herencia
Java herencia: conceptos y ejemplos prácticos. Aprende los conceptos de herencia en Java y cómo implementarlos con ejemplos prácticos.
Aprende Java y certifícateConcepto de herencia y extensión de clases
La herencia permite crear nuevas clases basadas en clases existentes. Mediante este mecanismo, se establece una relación jerárquica entre clases donde una clase "hija" o "subclase" hereda atributos y comportamientos de una clase "padre" o "superclase". En Java, la herencia se implementa utilizando la palabra clave extends
.
Cuando se trabaja con herencia, se crea una relación "es un" entre las clases. Por ejemplo, si se tiene una clase Vehículo
y se crea una clase Automóvil
que hereda de ella, se puede afirmar que un automóvil "es un" vehículo. Esta relación permite reutilizar código y establecer una jerarquía de tipos.
Para implementar la herencia en Java, se utiliza la siguiente sintaxis:
public class ClasePadre {
// Atributos y métodos de la clase padre
}
public class ClaseHija extends ClasePadre {
// Atributos y métodos adicionales de la clase hija
}
Veamos un ejemplo práctico para entender mejor este concepto:
public class Animal {
private String nombre;
private 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");
}
// Getters y setters
public String getNombre() {
return nombre;
}
public int getEdad() {
return edad;
}
}
public class Perro extends Animal {
private String raza;
public Perro(String nombre, int edad, String raza) {
super(nombre, edad); // Llamada al constructor de la clase padre
this.raza = raza;
}
public void ladrar() {
System.out.println(getNombre() + " está ladrando");
}
public String getRaza() {
return raza;
}
}
En este ejemplo, la clase Perro
extiende de la clase Animal
, heredando sus atributos (nombre
y edad
) y métodos (comer()
y dormir()
). Además, la clase Perro
añade un nuevo atributo (raza
) y un nuevo método (ladrar()
).
Para utilizar estas clases, se puede crear una instancia de Perro
y acceder tanto a los métodos heredados como a los propios:
public class Main {
public static void main(String[] args) {
Perro miPerro = new Perro("Firulais", 3, "Labrador");
// Métodos heredados de Animal
miPerro.comer();
miPerro.dormir();
// Método propio de Perro
miPerro.ladrar();
// Acceso a atributos mediante getters
System.out.println("Nombre: " + miPerro.getNombre());
System.out.println("Edad: " + miPerro.getEdad());
System.out.println("Raza: " + miPerro.getRaza());
}
}
La salida de este programa sería:
Firulais está comiendo
Firulais está durmiendo
Firulais está ladrando
Nombre: Firulais
Edad: 3
Raza: Labrador
Acceso a miembros heredados
Cuando una clase hereda de otra, tiene acceso a todos los miembros públicos y protegidos de la clase padre. Los miembros privados de la clase padre no son directamente accesibles desde la clase hija, pero se pueden acceder a través de métodos públicos o protegidos de la clase padre.
public class Figura {
protected double area;
public void calcularArea() {
// Método a ser sobrescrito por las subclases
}
public double getArea() {
return area;
}
}
public class Circulo extends Figura {
private double radio;
public Circulo(double radio) {
this.radio = radio;
}
@Override
public void calcularArea() {
area = Math.PI * radio * radio;
}
}
En este ejemplo, la clase Circulo
tiene acceso al atributo protegido area
de la clase Figura
y puede modificarlo directamente en su método calcularArea()
.
Sobreescritura de métodos
Una de las características más importantes de la herencia es la capacidad de sobreescribir métodos de la clase padre en la clase hija. Esto permite que la clase hija proporcione una implementación específica de un método que ya existe en la clase padre.
Para sobreescribir un método, se debe usar la anotación @Override
y mantener la misma firma del método (nombre, parámetros y tipo de retorno):
public class Animal {
public void hacerSonido() {
System.out.println("El animal hace un sonido");
}
}
public class Gato extends Animal {
@Override
public void hacerSonido() {
System.out.println("El gato maúlla: Miau");
}
}
public class Perro extends Animal {
@Override
public void hacerSonido() {
System.out.println("El perro ladra: Guau");
}
}
La anotación @Override
no es obligatoria, pero se recomienda su uso porque ayuda al compilador a verificar que realmente se está sobreescribiendo un método de la clase padre y no creando uno nuevo por error.
Extensión de clases finales
En Java, se puede prevenir que una clase sea heredada utilizando la palabra clave final
. Una clase declarada como final
no puede ser extendida:
public final class ClaseFinal {
// Esta clase no puede ser heredada
}
// Esto generaría un error de compilación
public class SubClase extends ClaseFinal {
// Error: Cannot inherit from final class
}
De manera similar, se pueden declarar métodos como final
para evitar que sean sobreescritos en las subclases:
public class Padre {
public final void metodoFinal() {
// Este método no puede ser sobreescrito
}
}
public class Hijo extends Padre {
// Esto generaría un error de compilación
@Override
public void metodoFinal() {
// Error: Cannot override final method
}
}
Herencia y constructores
Cuando se crea una instancia de una clase hija, primero se ejecuta el constructor de la clase padre. Si la clase padre no tiene un constructor sin parámetros, la clase hija debe llamar explícitamente a un constructor de la clase padre utilizando la palabra clave super()
:
public class Vehiculo {
private String marca;
public Vehiculo(String marca) {
this.marca = marca;
}
public String getMarca() {
return marca;
}
}
public class Coche extends Vehiculo {
private int puertas;
public Coche(String marca, int puertas) {
super(marca); // Llamada al constructor de Vehiculo
this.puertas = puertas;
}
public int getPuertas() {
return puertas;
}
}
La llamada a super()
debe ser la primera instrucción en el constructor de la clase hija. Si no se incluye explícitamente, el compilador intentará insertar una llamada a super()
(sin argumentos), lo que causará un error si la clase padre no tiene un constructor sin parámetros.
Upcasting y downcasting
La herencia permite realizar conversiones entre tipos relacionados:
- Upcasting: Convertir una referencia de una subclase a una superclase. Este tipo de conversión es implícita y siempre segura.
- Downcasting: Convertir una referencia de una superclase a una subclase. Este tipo de conversión debe hacerse explícitamente y puede generar una
ClassCastException
si el objeto no es realmente una instancia de la subclase.
// Upcasting (implícito)
Animal miAnimal = new Perro("Rex", 5, "Pastor Alemán");
miAnimal.comer(); // Método de Animal
// miAnimal.ladrar(); // Error: método no disponible en Animal
// Downcasting (explícito)
if (miAnimal instanceof Perro) {
Perro miPerro = (Perro) miAnimal;
miPerro.ladrar(); // Ahora sí podemos llamar a métodos específicos de Perro
}
Con las mejoras en el operador instanceof
en versiones recientes de Java, se puede simplificar el downcasting:
// Pattern matching con instanceof
if (miAnimal instanceof Perro perro) {
perro.ladrar(); // No es necesario hacer casting explícito
}
Herencia y composición
La herencia no siempre es la mejor solución para reutilizar código. En muchos casos, la composición (incluir instancias de otras clases como atributos) puede ser una alternativa más flexible:
// Enfoque de herencia
public class CocheDeportivo extends Coche {
private boolean modoSport;
// ...
}
// Enfoque de composición
public class CocheDeportivo {
private Coche coche;
private boolean modoSport;
public CocheDeportivo(Coche coche) {
this.coche = coche;
}
// Métodos que delegan en el objeto coche
public String getMarca() {
return coche.getMarca();
}
// ...
}
La composición ofrece mayor flexibilidad y evita algunos problemas asociados con la herencia, como el acoplamiento fuerte entre clases. Una regla general es "favorecer la composición sobre la herencia" cuando sea posible.
Consideraciones de diseño
Al diseñar jerarquías de clases, se deben tener en cuenta varios principios:
- Principio de sustitución de Liskov: Las instancias de una subclase deben poder sustituir a las instancias de la superclase sin afectar la corrección del programa.
- Principio de responsabilidad única: Una clase debe tener una sola razón para cambiar.
- Principio abierto/cerrado: Las clases deben estar abiertas para extensión pero cerradas para modificación.
Palabra clave super: constructor y métodos
La palabra clave super
en Java es un mecanismo fundamental para la interacción entre clases relacionadas por herencia. Este elemento permite a una subclase acceder a miembros (atributos y métodos) de su superclase.
En Java, super
se utiliza principalmente en dos contextos: para invocar constructores de la superclase y para acceder a métodos o atributos de la superclase.
Invocación de constructores con super()
Cuando se crea una instancia de una subclase, se debe inicializar primero la parte correspondiente a la superclase. Esto se logra mediante la llamada al constructor de la superclase utilizando super()
:
public class Vehiculo {
private String matricula;
public Vehiculo(String matricula) {
this.matricula = matricula;
System.out.println("Constructor de Vehiculo ejecutado");
}
// Getters y setters
public String getMatricula() {
return matricula;
}
}
public class Motocicleta extends Vehiculo {
private int cilindrada;
public Motocicleta(String matricula, int cilindrada) {
super(matricula); // Llamada al constructor de Vehiculo
this.cilindrada = cilindrada;
System.out.println("Constructor de Motocicleta ejecutado");
}
public int getCilindrada() {
return cilindrada;
}
}
Al crear una instancia de Motocicleta
, se observa el siguiente comportamiento:
Motocicleta moto = new Motocicleta("1234BCD", 250);
// Salida:
// Constructor de Vehiculo ejecutado
// Constructor de Motocicleta ejecutado
Es importante destacar algunas reglas clave sobre el uso de super()
en constructores:
- La llamada a
super()
debe ser la primera instrucción en el constructor de la subclase. - Si la superclase no tiene un constructor sin parámetros y la subclase no llama explícitamente a algún constructor de la superclase, se producirá un error de compilación.
- Si la superclase tiene un constructor sin parámetros y la subclase no incluye una llamada explícita a
super()
, el compilador insertará automáticamente una llamada asuper()
sin argumentos.
public class Ejemplo {
public static void main(String[] args) {
Camion camion = new Camion("5678XYZ", 5000);
System.out.println("Matrícula: " + camion.getMatricula());
System.out.println("Capacidad: " + camion.getCapacidadCarga() + " kg");
}
}
class Vehiculo {
private String matricula;
public Vehiculo(String matricula) {
this.matricula = matricula;
}
public String getMatricula() {
return matricula;
}
}
class Camion extends Vehiculo {
private double capacidadCarga;
public Camion(String matricula, double capacidadCarga) {
super(matricula);
this.capacidadCarga = capacidadCarga;
}
public double getCapacidadCarga() {
return capacidadCarga;
}
}
Acceso a métodos de la superclase
El segundo uso principal de super
es para acceder a métodos de la superclase, especialmente cuando estos han sido sobreescritos en la subclase. Esto permite extender la funcionalidad de un método en lugar de reemplazarla completamente:
public class Empleado {
private String nombre;
private double salarioBase;
public Empleado(String nombre, double salarioBase) {
this.nombre = nombre;
this.salarioBase = salarioBase;
}
public double calcularSalario() {
return salarioBase;
}
public String getNombre() {
return nombre;
}
}
public class Gerente extends Empleado {
private double bonificacion;
public Gerente(String nombre, double salarioBase, double bonificacion) {
super(nombre, salarioBase);
this.bonificacion = bonificacion;
}
@Override
public double calcularSalario() {
// Utiliza el método de la superclase y añade la bonificación
return super.calcularSalario() + bonificacion;
}
}
En este ejemplo, la clase Gerente
sobreescribe el método calcularSalario()
pero utiliza super.calcularSalario()
para aprovechar la implementación de la superclase y extenderla con funcionalidad adicional.
Veamos cómo se comporta en la práctica:
public class PruebaEmpleados {
public static void main(String[] args) {
Empleado empleado = new Empleado("Ana García", 2000);
Gerente gerente = new Gerente("Carlos López", 3000, 1000);
System.out.println(empleado.getNombre() + ": " + empleado.calcularSalario() + "€");
System.out.println(gerente.getNombre() + ": " + gerente.calcularSalario() + "€");
}
}
// Salida:
// Ana García: 2000.0€
// Carlos López: 4000.0€
Acceso a atributos de la superclase
Aunque es menos común, super
también puede utilizarse para acceder a atributos de la superclase, especialmente cuando existe un sombreado de variables (cuando la subclase declara una variable con el mismo nombre que una variable en la superclase):
public class Padre {
protected String mensaje = "Mensaje de la clase Padre";
}
public class Hijo extends Padre {
private String mensaje = "Mensaje de la clase Hijo";
public void mostrarMensajes() {
System.out.println(mensaje); // Accede al atributo de Hijo
System.out.println(super.mensaje); // Accede al atributo de Padre
}
}
Al ejecutar:
Hijo hijo = new Hijo();
hijo.mostrarMensajes();
// Salida:
// Mensaje de la clase Hijo
// Mensaje de la clase Padre
Cadenas de llamadas a super en jerarquías multinivel
En jerarquías de clases con múltiples niveles, las llamadas a super()
se propagan hacia arriba en la jerarquía:
public class Animal {
public Animal() {
System.out.println("Constructor de Animal");
}
}
public class Mamifero extends Animal {
public Mamifero() {
super(); // Opcional, se inserta automáticamente
System.out.println("Constructor de Mamífero");
}
}
public class Felino extends Mamifero {
public Felino() {
super(); // Opcional, se inserta automáticamente
System.out.println("Constructor de Felino");
}
}
public class Gato extends Felino {
public Gato() {
super(); // Opcional, se inserta automáticamente
System.out.println("Constructor de Gato");
}
}
Al crear un objeto Gato
, se ejecutan todos los constructores en orden ascendente en la jerarquía:
Gato miGato = new Gato();
// Salida:
// Constructor de Animal
// Constructor de Mamífero
// Constructor de Felino
// Constructor de Gato
Uso de super con métodos estáticos
super
no se puede utilizar en un contexto estático, ya que hace referencia a la instancia actual de la superclase:
public class Ejemplo {
public static void metodoEstatico() {
// Error: No se puede usar super en un contexto estático
// super.otroMetodo();
}
}
Patrones comunes y buenas prácticas
Al trabajar con super
, se recomienda seguir estas buenas prácticas:
- Extender métodos en lugar de reemplazarlos completamente: Cuando sea apropiado, utiliza
super.metodo()
para aprovechar la implementación de la superclase.
public class Figura {
public void dibujar() {
System.out.println("Preparando lienzo...");
}
}
public class Circulo extends Figura {
@Override
public void dibujar() {
super.dibujar(); // Mantiene la funcionalidad base
System.out.println("Dibujando un círculo");
}
}
- Evitar cadenas largas de herencia: Las jerarquías de clases profundas pueden hacer que el seguimiento de las llamadas a
super()
sea complicado. - Documentar claramente el propósito de las llamadas a super: Especialmente cuando se extienden métodos complejos.
public class ComponenteUI extends ComponenteBase {
@Override
public void inicializar() {
// Primero ejecutamos la inicialización básica
super.inicializar();
// Luego añadimos nuestra inicialización específica
configurarEstilos();
registrarEventos();
}
}
Ejemplo práctico: Implementación de un sistema de formas geométricas
Veamos un ejemplo más completo que ilustra el uso de super
tanto para constructores como para métodos:
public class Forma {
private String color;
public Forma() {
this("Negro"); // Constructor por defecto
}
public Forma(String color) {
this.color = color;
}
public double calcularArea() {
return 0.0; // Implementación base
}
public String getColor() {
return color;
}
public String obtenerDescripcion() {
return "Forma de color " + color;
}
}
public class Rectangulo extends Forma {
private double ancho;
private double alto;
public Rectangulo(double ancho, double alto) {
this(ancho, alto, "Azul");
}
public Rectangulo(double ancho, double alto, String color) {
super(color); // Llamada al constructor de Forma
this.ancho = ancho;
this.alto = alto;
}
@Override
public double calcularArea() {
return ancho * alto;
}
@Override
public String obtenerDescripcion() {
return super.obtenerDescripcion() + " - Rectángulo de " + ancho + "x" + alto;
}
}
public class Cuadrado extends Rectangulo {
public Cuadrado(double lado) {
this(lado, "Rojo");
}
public Cuadrado(double lado, String color) {
super(lado, lado, color); // Llamada al constructor de Rectangulo
}
@Override
public String obtenerDescripcion() {
return super.obtenerDescripcion().replace("Rectángulo", "Cuadrado");
}
}
Al utilizar estas clases:
public class PruebaFormas {
public static void main(String[] args) {
Forma forma = new Forma();
Rectangulo rectangulo = new Rectangulo(5, 3);
Cuadrado cuadrado = new Cuadrado(4);
System.out.println(forma.obtenerDescripcion());
System.out.println("Área: " + forma.calcularArea());
System.out.println(rectangulo.obtenerDescripcion());
System.out.println("Área: " + rectangulo.calcularArea());
System.out.println(cuadrado.obtenerDescripcion());
System.out.println("Área: " + cuadrado.calcularArea());
}
}
// Salida:
// Forma de color Negro
// Área: 0.0
// Forma de color Azul - Rectángulo de 5.0x3.0
// Área: 15.0
// Forma de color Rojo - Cuadrado de 4.0x4.0
// Área: 16.0
Este ejemplo muestra cómo super
permite:
- Reutilizar constructores de la superclase
- Extender métodos sobreescritos
- Crear jerarquías de clases coherentes donde cada nivel añade funcionalidad específica
Herencia simple vs. múltiple en Java
Java implementa un modelo de herencia simple, lo que significa que una clase puede heredar directamente de una única superclase. Esta decisión de diseño fue tomada deliberadamente para evitar la complejidad y ambigüedad que puede surgir con la herencia múltiple, donde una clase hereda de dos o más superclases simultáneamente.
Herencia simple en Java
En Java, la herencia simple se implementa mediante la palabra clave extends
, permitiendo que una clase herede atributos y métodos de una única superclase:
public class Vehiculo {
protected String marca;
public void arrancar() {
System.out.println("El vehículo está arrancando");
}
}
public class Coche extends Vehiculo {
private int numeroPuertas;
public void abrirMaletero() {
System.out.println("Abriendo maletero");
}
}
Este enfoque de herencia simple ofrece varias ventajas:
- Claridad conceptual: La jerarquía de clases es más fácil de entender y visualizar.
- Evita ambigüedades: No hay confusión sobre qué método heredar cuando existen métodos con el mismo nombre en diferentes superclases.
- Simplifica la implementación del lenguaje: El modelo de ejecución y la resolución de métodos son más sencillos.
Sin embargo, también presenta algunas limitaciones:
- Restricción de reutilización: A veces se necesita incorporar comportamientos de múltiples fuentes.
- Jerarquías profundas: Puede llevar a crear jerarquías de clases excesivamente profundas para modelar ciertos dominios.
El problema del diamante
Una de las principales razones por las que Java evita la herencia múltiple es el llamado problema del diamante (o problema de la herencia en diamante). Este problema ocurre cuando una clase hereda de dos superclases que, a su vez, heredan de una misma clase base.
A
/ \
B C
\ /
D
En este diagrama, si la clase A
define un método y tanto B
como C
lo sobreescriben de manera diferente, ¿qué implementación debería heredar D
? Esta ambigüedad puede generar comportamientos inesperados y difíciles de depurar.
Alternativas a la herencia múltiple en Java
Aunque Java no soporta herencia múltiple de clases, ofrece mecanismos alternativos para lograr funcionalidad similar:
Interfaces
Las interfaces son la principal alternativa a la herencia múltiple en Java. Una clase puede implementar múltiples interfaces, lo que permite definir contratos de comportamiento desde diferentes fuentes:
public interface Nadador {
void nadar();
}
public interface Volador {
void volar();
}
public class Pato extends Ave implements Nadador, Volador {
@Override
public void nadar() {
System.out.println("El pato está nadando");
}
@Override
public void volar() {
System.out.println("El pato está volando");
}
}
Con las mejoras introducidas en Java 8 y posteriores, las interfaces pueden incluir:
- Métodos default: Implementaciones concretas que las clases heredan automáticamente.
- Métodos estáticos: Funcionalidad a nivel de interfaz.
public interface Reproductor {
void reproducir();
default void pausar() {
System.out.println("Reproducción pausada");
}
static boolean esCompatible(String formato) {
return List.of("mp3", "wav", "ogg").contains(formato);
}
}
Composición
La composición es otra alternativa donde una clase contiene instancias de otras clases en lugar de heredar de ellas:
public class Motor {
public void encender() {
System.out.println("Motor encendido");
}
public void apagar() {
System.out.println("Motor apagado");
}
}
public class SistemaNavegacion {
public void calcularRuta(String destino) {
System.out.println("Calculando ruta hacia " + destino);
}
}
public class Automovil {
private Motor motor;
private SistemaNavegacion navegacion;
public Automovil() {
this.motor = new Motor();
this.navegacion = new SistemaNavegacion();
}
public void iniciarViaje(String destino) {
motor.encender();
navegacion.calcularRuta(destino);
System.out.println("Iniciando viaje");
}
}
La composición ofrece mayor flexibilidad y un acoplamiento más débil entre componentes, siguiendo el principio de "favorecer la composición sobre la herencia".
Comparación con lenguajes que soportan herencia múltiple
Algunos lenguajes como C++ y Python sí permiten herencia múltiple, pero implementan diferentes estrategias para resolver las ambigüedades:
- C++: Utiliza herencia virtual y resolución explícita de ambigüedades.
- Python: Implementa un algoritmo de resolución de método (MRO - Method Resolution Order) basado en el orden de las clases base.
# Ejemplo en Python (no en Java)
class A:
def metodo(self):
print("Método de A")
class B(A):
def metodo(self):
print("Método de B")
class C(A):
def metodo(self):
print("Método de C")
class D(B, C): # Herencia múltiple
pass
# Python usará el método de B debido al orden en D(B, C)
Herencia múltiple simulada con interfaces default
Con la introducción de métodos default en interfaces (Java 8+), se puede simular parcialmente la herencia múltiple:
public interface DispositivoEntrada {
default void conectar() {
System.out.println("Dispositivo de entrada conectado");
}
}
public interface DispositivoSalida {
default void conectar() {
System.out.println("Dispositivo de salida conectado");
}
}
public class DispositivoHibrido implements DispositivoEntrada, DispositivoSalida {
// Error de compilación: hereda métodos default no relacionados con el mismo nombre
// Se debe resolver la ambigüedad explícitamente
@Override
public void conectar() {
// Podemos elegir una implementación o combinar ambas
DispositivoEntrada.super.conectar();
DispositivoSalida.super.conectar();
System.out.println("Dispositivo híbrido conectado completamente");
}
}
Cuando una clase implementa múltiples interfaces con métodos default que tienen la misma firma, se produce una ambigüedad que debe resolverse explícitamente sobrescribiendo el método en cuestión. Se puede acceder a la implementación específica de cada interfaz mediante la sintaxis InterfaceName.super.methodName()
.
Jerarquías de herencia en la biblioteca estándar de Java
La biblioteca estándar de Java ofrece ejemplos de cómo se diseñan jerarquías de clases con herencia simple:
// Ejemplo simplificado de la jerarquía de colecciones
public interface Collection<E> extends Iterable<E> {
// Métodos comunes a todas las colecciones
}
public interface List<E> extends Collection<E> {
// Métodos específicos de listas
}
public class ArrayList<E> implements List<E> {
// Implementación concreta basada en arrays
}
public class LinkedList<E> implements List<E>, Deque<E> {
// Implementación concreta basada en nodos enlazados
// También implementa la interfaz Deque
}
Este diseño muestra cómo Java utiliza interfaces para crear jerarquías flexibles sin necesidad de herencia múltiple de clases.
Buenas prácticas al diseñar jerarquías de herencia
Al trabajar con herencia en Java, se recomienda seguir estas prácticas:
- Mantener jerarquías poco profundas: Evitar crear cadenas de herencia con más de 2-3 niveles.
- Usar interfaces para comportamientos: Definir comportamientos mediante interfaces y reservar la herencia de clases para estructura.
- Preferir composición cuando sea apropiado: Evaluar si la relación es realmente "es un" (herencia) o "tiene un" (composición).
- Diseñar para extensión o prohibirla: Hacer clases finales si no están diseñadas para ser extendidas.
// Ejemplo de diseño híbrido con herencia e interfaces
public abstract class Empleado {
protected String nombre;
protected double salarioBase;
// Estructura común a todos los empleados
}
public interface Comisionable {
double calcularComision();
}
public class Vendedor extends Empleado implements Comisionable {
private double ventasRealizadas;
@Override
public double calcularComision() {
return ventasRealizadas * 0.05;
}
}
Consideraciones de rendimiento
En términos de rendimiento, la herencia simple de Java ofrece ventajas:
- Resolución de métodos más eficiente: El compilador puede determinar más fácilmente qué método invocar.
- Estructura de memoria más simple: La disposición de objetos en memoria es más directa.
- Menor sobrecarga en tiempo de ejecución: Menos complejidad en la máquina virtual.
Sin embargo, el uso excesivo de interfaces puede tener un pequeño impacto en el rendimiento debido a la indirección adicional, aunque las optimizaciones de la JVM moderna minimizan estas diferencias.
Problemas comunes y buenas prácticas de herencia
Problemas comunes al usar herencia
Herencia profunda y frágil
Las jerarquías de clases con muchos niveles de profundidad suelen volverse difíciles de mantener. Cada nivel adicional aumenta la complejidad y hace que el sistema sea más frágil ante cambios.
// Jerarquía demasiado profunda
class Vehiculo { /* ... */ }
class VehiculoTerrestre extends Vehiculo { /* ... */ }
class VehiculoMotorizado extends VehiculoTerrestre { /* ... */ }
class Automovil extends VehiculoMotorizado { /* ... */ }
class AutomovilDeportivo extends Automovil { /* ... */ }
class FerrariF40 extends AutomovilDeportivo { /* ... */ }
Este tipo de diseño crea un acoplamiento excesivo entre clases. Un cambio en VehiculoTerrestre
podría afectar a todas las clases descendientes, generando efectos en cascada difíciles de predecir.
Herencia por conveniencia
Uno de los errores más comunes es utilizar la herencia simplemente para reutilizar código, sin que exista una verdadera relación "es un" entre las clases.
// Uso incorrecto de herencia
class Utilidades {
public void metodoUtil() { /* ... */ }
}
// ¡Mal diseño! Un Empleado no "es una" Utilidad
class Empleado extends Utilidades {
private String nombre;
private double salario;
// ...
}
Este enfoque viola el principio de sustitución de Liskov, que establece que las instancias de una subclase deben poder sustituir a las de la superclase sin afectar la corrección del programa.
Problema de la clase base frágil
Se produce cuando los cambios en una clase base rompen el funcionamiento de las subclases. Esto ocurre especialmente cuando las subclases dependen de detalles de implementación de la superclase.
class Coleccion {
protected Object[] elementos;
public void agregar(Object elemento) {
// Implementación que las subclases podrían sobrescribir
// pero también podrían depender de su comportamiento
}
}
class ListaEspecial extends Coleccion {
@Override
public void agregar(Object elemento) {
// Depende de la implementación interna de Coleccion
super.agregar(elemento);
// Lógica adicional
}
}
Si la implementación de agregar()
en Coleccion
cambia, podría romper el comportamiento de ListaEspecial
.
Violación del encapsulamiento
La herencia puede romper el encapsulamiento al exponer detalles internos de la superclase a las subclases.
class Cuenta {
protected double saldo; // Expuesto a subclases
public void depositar(double monto) {
if (monto > 0) {
saldo += monto;
}
}
}
class CuentaTrampa extends Cuenta {
// Puede manipular directamente el saldo sin validaciones
public void hackearSaldo() {
saldo = 1000000;
}
}
Este diseño permite que las subclases manipulen el estado interno de la superclase de formas que podrían violar sus invariantes.
Métodos con comportamiento inesperado
Cuando se sobreescriben métodos sin respetar el contrato de la superclase, se pueden generar comportamientos inesperados.
class Rectangulo {
protected int ancho;
protected int alto;
public void setAncho(int ancho) {
this.ancho = ancho;
}
public void setAlto(int alto) {
this.alto = alto;
}
public int getArea() {
return ancho * alto;
}
}
class Cuadrado extends Rectangulo {
// Viola el comportamiento esperado de un Rectangulo
@Override
public void setAncho(int ancho) {
this.ancho = ancho;
this.alto = ancho; // Un cuadrado mantiene lados iguales
}
@Override
public void setAlto(int alto) {
this.alto = alto;
this.ancho = alto; // Un cuadrado mantiene lados iguales
}
}
Este ejemplo ilustra el problema clásico de la relación Cuadrado-Rectángulo, donde la herencia parece natural pero viola el principio de sustitución de Liskov.
Buenas prácticas para el uso de herencia
Favorecer la composición sobre la herencia
La composición ofrece mayor flexibilidad y un acoplamiento más débil que la herencia. Se debe considerar primero si una relación "tiene un" es más apropiada que una relación "es un".
// En lugar de herencia
class Motor {
public void arrancar() { /* ... */ }
public void detener() { /* ... */ }
}
class Automovil {
private Motor motor; // Composición
public Automovil() {
this.motor = new Motor();
}
public void arrancar() {
motor.arrancar();
// Lógica adicional específica del automóvil
}
}
Este enfoque permite cambiar la implementación de Motor
sin afectar a Automovil
, y facilita la incorporación de diferentes tipos de motores.
Diseñar clases para herencia o prohibirla
Las clases deben diseñarse explícitamente para ser heredadas o marcarse como final
para prohibir la herencia.
// Diseñada para herencia
public abstract class Plantilla {
// Métodos diseñados para ser sobreescritos
protected abstract void inicializar();
protected abstract void procesar();
protected abstract void finalizar();
// Método plantilla que define el algoritmo
public final void ejecutar() {
inicializar();
procesar();
finalizar();
}
}
// Prohibida la herencia
public final class Utilidad {
private Utilidad() {} // Constructor privado
public static void metodoUtil() { /* ... */ }
}
El patrón de diseño Template Method ilustrado en Plantilla
es un ejemplo de diseño explícito para herencia.
Documentar el comportamiento para herederos
Es crucial documentar cómo se espera que las subclases interactúen con la superclase, especialmente para métodos diseñados para ser sobreescritos.
/**
* Clase base para procesadores de texto.
* <p>
* <b>Nota para implementadores:</b> Las subclases deben sobrescribir
* el método {@link #procesarTexto} para implementar su lógica específica.
* El método {@link #validarEntrada} puede sobrescribirse para añadir
* validaciones adicionales, pero debe llamarse a super.validarEntrada().
*/
public abstract class ProcesadorTexto {
/**
* Procesa el texto de entrada según la implementación específica.
* @param texto El texto a procesar
* @return El texto procesado
*/
protected abstract String procesarTexto(String texto);
/**
* Valida que el texto de entrada cumpla con los requisitos básicos.
* Las subclases que sobrescriban este método deben llamar a super.validarEntrada().
* @param texto El texto a validar
* @throws IllegalArgumentException si el texto no cumple los requisitos
*/
protected void validarEntrada(String texto) {
if (texto == null) {
throw new IllegalArgumentException("El texto no puede ser null");
}
}
/**
* Método público que coordina la validación y procesamiento.
* Este método no debe ser sobrescrito.
*/
public final String procesar(String texto) {
validarEntrada(texto);
return procesarTexto(texto);
}
}
Esta documentación clara ayuda a los desarrolladores a entender cómo extender correctamente la clase.
Preferir interfaces sobre clases abstractas
Las interfaces proporcionan un acoplamiento más débil y permiten la implementación de múltiples contratos.
// Preferir esto:
interface Reproductor {
void reproducir();
void pausar();
void detener();
}
class ReproductorAudio implements Reproductor {
// Implementación específica
}
class ReproductorVideo implements Reproductor {
// Implementación específica
}
// Sobre esto:
abstract class DispositivoReproductor {
abstract void reproducir();
abstract void pausar();
abstract void detener();
}
class ReproductorAudio extends DispositivoReproductor {
// Implementación específica
}
Las interfaces permiten mayor flexibilidad en la jerarquía de clases y facilitan la implementación de patrones como el Adapter.
Utilizar composición con delegación
La delegación permite reutilizar comportamiento sin los problemas de la herencia.
interface Coleccion {
void agregar(Object elemento);
void eliminar(Object elemento);
int tamaño();
}
class ColeccionBase implements Coleccion {
// Implementación básica
// ...
}
class ColeccionEspecial implements Coleccion {
private final Coleccion coleccionInterna = new ColeccionBase();
@Override
public void agregar(Object elemento) {
// Lógica específica
coleccionInterna.agregar(elemento);
}
@Override
public void eliminar(Object elemento) {
coleccionInterna.eliminar(elemento);
}
@Override
public int tamaño() {
return coleccionInterna.tamaño();
}
// Métodos adicionales específicos
}
Este patrón se conoce como Decorator y permite añadir funcionalidad sin los riesgos de la herencia.
Aplicar el principio de segregación de interfaces
Es mejor tener varias interfaces específicas que una grande y monolítica.
// En lugar de una interfaz grande
interface Trabajador {
void trabajar();
void comer();
void dormir();
}
// Preferir interfaces segregadas
interface Trabajable {
void trabajar();
}
interface Alimentable {
void comer();
}
interface Descansable {
void dormir();
}
class Empleado implements Trabajable, Alimentable, Descansable {
// Implementación
}
class Robot implements Trabajable {
// Solo necesita implementar trabajar()
}
Este enfoque permite que las clases implementen solo los comportamientos que realmente necesitan.
Limitar la visibilidad de los miembros heredables
Se debe restringir el acceso a los miembros que las subclases pueden sobrescribir para minimizar el riesgo de romper el comportamiento.
public class BaseDeDatos {
// Método público parte de la API
public final void ejecutarConsulta(String consulta) {
validarConsulta(consulta);
Object resultado = realizarConsulta(consulta);
procesarResultado(resultado);
}
// Métodos protegidos que las subclases pueden sobrescribir
protected void validarConsulta(String consulta) {
// Validación básica
}
protected Object realizarConsulta(String consulta) {
// Implementación por defecto
return null;
}
// Método privado no accesible para subclases
private void procesarResultado(Object resultado) {
// Lógica interna
}
}
Este diseño permite que las subclases personalicen partes específicas del comportamiento mientras protege la estructura general del algoritmo.
Evitar la herencia de clases concretas
Heredar de clases concretas (no abstractas) suele ser más riesgoso porque estas clases no fueron diseñadas explícitamente para la herencia.
// Preferir:
abstract class Figura {
abstract double calcularArea();
}
class Circulo extends Figura {
private double radio;
@Override
double calcularArea() {
return Math.PI * radio * radio;
}
}
// Sobre:
class Utilidades {
public double calcularPromedio(double[] valores) {
// Implementación
return 0;
}
}
class UtilidadesExtendidas extends Utilidades {
// Herencia riesgosa de una clase concreta
}
Las clases abstractas establecen un contrato claro para las subclases, mientras que las clases concretas pueden cambiar su implementación sin considerar a los herederos.
Aplicar el principio "Tell, Don't Ask"
Este principio sugiere que se debe decir a los objetos qué hacer, no preguntar por su estado para tomar decisiones.
// Evitar:
if (empleado.getTipo() == TipoEmpleado.GERENTE) {
salario = empleado.getSalarioBase() * 1.5;
} else {
salario = empleado.getSalarioBase();
}
// Preferir:
salario = empleado.calcularSalario();
Implementando este principio en la jerarquía de clases:
abstract class Empleado {
protected double salarioBase;
public abstract double calcularSalario();
}
class Gerente extends Empleado {
@Override
public double calcularSalario() {
return salarioBase * 1.5;
}
}
class Desarrollador extends Empleado {
@Override
public double calcularSalario() {
return salarioBase;
}
}
Este enfoque encapsula la lógica específica en cada subclase, haciendo el código más mantenible y extensible.
Patrones de diseño alternativos a la herencia
Cuando la herencia no es la mejor opción, se pueden considerar estos patrones:
- Patrón Strategy: Encapsula algoritmos en clases separadas que implementan una interfaz común.
interface EstrategiaOrdenamiento {
void ordenar(int[] datos);
}
class OrdenamientoBurbuja implements EstrategiaOrdenamiento {
@Override
public void ordenar(int[] datos) {
// Implementación del ordenamiento burbuja
}
}
class OrdenamientoRapido implements EstrategiaOrdenamiento {
@Override
public void ordenar(int[] datos) {
// Implementación del quicksort
}
}
class Ordenador {
private EstrategiaOrdenamiento estrategia;
public void setEstrategia(EstrategiaOrdenamiento estrategia) {
this.estrategia = estrategia;
}
public void ordenar(int[] datos) {
estrategia.ordenar(datos);
}
}
- Patrón Decorator: Añade responsabilidades a objetos dinámicamente sin modificar su estructura.
interface Notificador {
void enviar(String mensaje);
}
class NotificadorEmail implements Notificador {
@Override
public void enviar(String mensaje) {
System.out.println("Enviando email: " + mensaje);
}
}
class NotificadorDecorador implements Notificador {
protected Notificador notificadorBase;
public NotificadorDecorador(Notificador notificador) {
this.notificadorBase = notificador;
}
@Override
public void enviar(String mensaje) {
notificadorBase.enviar(mensaje);
}
}
class NotificadorSMS extends NotificadorDecorador {
public NotificadorSMS(Notificador notificador) {
super(notificador);
}
@Override
public void enviar(String mensaje) {
super.enviar(mensaje);
System.out.println("Enviando SMS: " + mensaje);
}
}
Ejercicios de esta lección Herencia
Evalúa tus conocimientos de esta lección Herencia con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
Clases abstractas
Streams: reduce()
Streams: flatMap()
Llamada y sobrecarga de funciones
Métodos referenciados
Métodos de la clase String
Representación de Fecha
Operadores lógicos
Tipos de datos
Estructuras de iteración
Streams: forEach()
Objetos
Funciones lambda
Uso de Scanner
CRUD en Java de modelo Customer sobre un ArrayList
Tipos de variables
Streams: collect()
Operadores aritméticos
Interfaz funcional Consumer
API java.nio 2
API Optional
Interfaz funcional Function
Encapsulación
Interfaces
Uso de API Optional
Representación de Hora
Herencia básica
Clases y objetos
Interfaz funcional Supplier
HashMap
Sobrecarga de métodos
Polimorfismo de tiempo de ejecución
OOP en Java
Creación de Streams
Streams: min max
Métodos avanzados de la clase String
Polimorfismo de tiempo de compilación
Excepciones
Herencia avanzada
Estructuras de selección
Uso de interfaces
HashSet
Objeto Scanner
Streams: filter()
Operaciones de Streams
Interfaz funcional Predicate
Streams: sorted()
Configuración de entorno
CRUD en Java de modelo Customer sobre un HashMap
Uso de variables
Clases
Streams: distinct()
Streams: count()
ArrayList
Datos de referencia
Interfaces funcionales
Métodos básicos de la clase String
Instalación
Funciones
Estructuras de control
Herencia de clases
Streams: map()
Funciones y encapsulamiento
Streams: match
Gestión de errores y excepciones
Datos primitivos
Todas las lecciones de Java
Accede a todas las lecciones de Java y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Instalación De Java
Introducción Y Entorno
Configuración De Entorno Java
Introducción Y Entorno
Ecosistema Jakarta Ee De Java
Introducción Y Entorno
Tipos De Datos
Sintaxis
Variables
Sintaxis
Operadores
Sintaxis
Estructuras De Control
Sintaxis
Funciones
Sintaxis
Excepciones
Programación Orientada A Objetos
Clases Y Objetos
Programación Orientada A Objetos
Encapsulación
Programación Orientada A Objetos
Herencia
Programación Orientada A Objetos
Clases Abstractas
Programación Orientada A Objetos
Interfaces
Programación Orientada A Objetos
Sobrecarga De Métodos
Programación Orientada A Objetos
Polimorfismo
Programación Orientada A Objetos
La Clase Scanner
Programación Orientada A Objetos
Métodos De La Clase String
Programación Orientada A Objetos
Listas
Framework Collections
Conjuntos
Framework Collections
Mapas
Framework Collections
Funciones Lambda
Programación Funcional
Interfaz Funcional Consumer
Programación Funcional
Interfaz Funcional Predicate
Programación Funcional
Interfaz Funcional Supplier
Programación Funcional
Interfaz Funcional Function
Programación Funcional
Métodos Referenciados
Programación Funcional
Creación De Streams
Programación Funcional
Operaciones Intermedias Con Streams: Map()
Programación Funcional
Operaciones Intermedias Con Streams: Filter()
Programación Funcional
Operaciones Intermedias Con Streams: Distinct()
Programación Funcional
Operaciones Finales Con Streams: Collect()
Programación Funcional
Operaciones Finales Con Streams: Min Max
Programación Funcional
Operaciones Intermedias Con Streams: Flatmap()
Programación Funcional
Operaciones Intermedias Con Streams: Sorted()
Programación Funcional
Operaciones Finales Con Streams: Reduce()
Programación Funcional
Operaciones Finales Con Streams: Foreach()
Programación Funcional
Operaciones Finales Con Streams: Count()
Programación Funcional
Operaciones Finales Con Streams: Match
Programación Funcional
Api Optional
Programación Funcional
Api Java.nio 2
Entrada Y Salida (Io)
Api Java.time
Api Java.time
Certificados de superación de Java
Supera todos los ejercicios de programación del curso de Java y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender el concepto de herencia en la programación orientada a objetos
- Aprender a usar la palabra clave
extends
para heredar de una superclase en Java - Entender la jerarquía de clases en Java, y cómo todas las clases heredan de
Object
- Aprender cómo los modificadores de acceso (
private
,public
,protected
) afectan a la herencia - Aprender a usar la palabra clave
super
para acceder a miembros de la superclase en la subclase - Comprender cómo los constructores de la superclase son llamados durante la creación de un objeto de subclase
- Entender cómo la herencia facilita el polimorfismo en Java
- Conocer las limitaciones de la herencia en Java, incluyendo la falta de herencia múltiple