La programación orientada a objetos avanzada en Java no consiste en crear clases por todas partes ni en usar herencia porque “queda más profesional”. Va justo de lo contrario: elegir bien qué responsabilidad tiene cada clase, cuándo una clase debe contener a otra, cuándo una clase debe heredar de otra y cómo aprovechar mecanismos como el polimorfismo, las clases abstractas o los métodos sobrescritos sin convertir el código en una maraña difícil de mantener.
En mi caso, después de trabajar en desarrollo software, consultoría tecnológica, proyectos web y docencia en FP Informática, cada vez tengo más claro que la programación se entiende mejor cuando se conecta con decisiones prácticas: qué clase debe hacer qué, qué relación expresa mejor el problema y qué diseño será más fácil de ampliar dentro de unas semanas. Esa mezcla entre construir soluciones y explicarlas con claridad forma parte de mi trayectoria profesional.
En esta guía vamos a ver programación orientada a objetos avanzada en Java desde un enfoque muy práctico: composición, herencia, constructores, super, sobrescritura de métodos, @Override, polimorfismo, clases abstractas, final, diseño de jerarquías y errores frecuentes. La idea no es memorizar palabras raras, sino entender cuándo usar cada recurso y, sobre todo, cuándo no usarlo.
Qué significa realmente hacer programación orientada a objetos avanzada
Antes de hablar de programación orientada a objetos avanzada en Java, conviene recordar una idea sencilla: una clase es una plantilla, un objeto es una instancia concreta de esa plantilla y una responsabilidad es aquello que una clase debe saber o hacer dentro del programa. Esta base es importante porque muchos malos diseños no empiezan por una mala sintaxis, sino por una mala decisión de responsabilidades.
Por ejemplo, si tengo una clase Factura, tiene sentido que calcule su total. Si tengo una clase Cliente, tiene sentido que almacene datos del cliente. Pero si Cliente empieza a calcular importes, generar PDFs y validar descuentos, probablemente estoy mezclando responsabilidades. Y cuando eso ocurre, la programación orientada a objetos deja de ayudar y empieza a estorbar.
En programación orientada a objetos avanzada en Java, la pregunta clave no es “¿puedo hacerlo con una clase?”, sino “¿qué clase debería encargarse de esto?”. Parece una diferencia pequeña, pero cambia por completo la forma de diseñar.
Cuando explico estos temas en clase, suelo insistir mucho en esta idea: crear más clases no significa diseñar mejor. Diseñar mejor significa que cada clase tenga un motivo claro para existir. Si una clase guarda datos, procesa lógica, imprime resultados y además decide qué otras clases se crean, seguramente está haciendo demasiado.
Una buena forma de empezar cualquier diseño orientado a objetos es hacerse tres preguntas:
| Pregunta | Para qué sirve |
|---|---|
| ¿Qué responsabilidad tiene esta clase? | Evita clases que hacen de todo |
| ¿Quién debe guardar este dato? | Mejora la encapsulación |
| ¿Quién debe realizar esta operación? | Reparte mejor la lógica del programa |
La programación orientada a objetos avanzada en Java empieza justo ahí: en aprender a pensar con estructura antes de escribir código.
Composición en Java: cuando una clase “tiene un” objeto de otra
La composición aparece cuando una clase contiene objetos de otras clases como parte de su estado. Es una relación del tipo “tiene un”. Por ejemplo, un pedido tiene líneas de pedido, un coche tiene un motor, una factura tiene conceptos o un cliente tiene una dirección. El documento fuente lo plantea como una de las decisiones centrales para construir programas complejos a partir de clases pequeñas y bien delimitadas.
La composición es uno de los conceptos más importantes en programación orientada a objetos avanzada en Java porque ayuda a evitar uno de los errores clásicos: usar herencia para todo. No todo lo que está relacionado debe ir en una jerarquía. Muchas veces una clase no “es” otra clase, sino que simplemente la contiene o la utiliza.
Imagina este ejemplo:
public class Direccion {
private String calle;
private String ciudad;
public Direccion(String calle, String ciudad) {
this.calle = calle;
this.ciudad = ciudad;
}
public String getResumen() {
return calle + ", " + ciudad;
}
}
public class Cliente {
private String nombre;
private Direccion direccion;
public Cliente(String nombre, Direccion direccion) {
this.nombre = nombre;
this.direccion = direccion;
}
public void mostrarFicha() {
System.out.println(nombre);
System.out.println(direccion.getResumen());
}
}
Aquí Cliente no hereda de Direccion, porque un cliente no es una dirección. Un cliente tiene una dirección. Esa frase tan simple evita muchísimos diseños confusos.
La composición suele ser más flexible que la herencia porque reduce el acoplamiento. Si mañana cambia la forma de representar una dirección, puedo modificar la clase Direccion sin tener que rehacer toda una jerarquía de clientes. También puedo reutilizar Direccion en otras clases, como Proveedor, Empleado o CentroEducativo.
En proyectos reales, esta diferencia se nota muchísimo. Si eliges mal y usas herencia donde deberías haber usado composición, cada cambio empieza a doler. Aparecen subclases que no encajan, métodos que se heredan pero no se usan, excepciones artificiales y código que solo entiende quien lo escribió.
Por eso, una buena regla práctica para programación orientada a objetos avanzada en Java es esta: si dudas entre herencia y composición, empieza pensando en composición. Usa herencia solo cuando puedas decir de forma natural que una clase es un tipo especializado de otra.
Herencia en Java: superclases, subclases y especialización
La herencia permite crear una clase nueva a partir de otra existente. La clase base recibe el nombre de superclase, clase padre o clase general. La clase que hereda se llama subclase, clase hija o clase especializada. En Java, la palabra clave que expresa esta relación es extends.
En programación orientada a objetos avanzada en Java, la herencia no debería entenderse como un atajo para reutilizar código, sino como una forma de expresar especialización. Es decir, una subclase debe ser realmente un tipo de la superclase.
Un ejemplo clásico:
public class Persona {
private String nombre;
public Persona(String nombre) {
this.nombre = nombre;
}
public String getNombre() {
return nombre;
}
public void presentarse() {
System.out.println("Hola, soy " + nombre);
}
}
public class Alumno extends Persona {
private String expediente;
public Alumno(String nombre, String expediente) {
super(nombre);
this.expediente = expediente;
}
public String getExpediente() {
return expediente;
}
}
Aquí sí tiene sentido usar herencia: un alumno es una persona. Alumno hereda el atributo nombre y el método getNombre() de Persona, y además añade su propio dato específico: expediente.
La herencia también está muy relacionada con la visibilidad. Una subclase no puede acceder directamente a los miembros private de la superclase. Puede acceder a métodos public y, si el diseño lo permite, a miembros protected. Pero cuidado: que protected exista no significa que debamos usarlo siempre. Muchas veces es mejor mantener atributos private y ofrecer métodos bien diseñados para acceder a ellos.
Esta es una de esas cosas que al principio parecen detalles técnicos, pero luego separan un código limpio de uno frágil. Si abres demasiado el acceso interno de tus clases, las subclases pueden acabar dependiendo de detalles que no deberían conocer.
Usa herencia cuando:
| Usa herencia cuando… | Evítala cuando… |
|---|---|
| La subclase es realmente un tipo de la superclase | Solo quieres reutilizar dos métodos |
| Hay comportamiento común real | La relación correcta es “tiene un” |
| Tiene sentido tratar subclases como superclase | La jerarquía obliga a métodos que no encajan |
| El diseño será estable y comprensible | Cada nueva clase rompe el diseño anterior |
En mi experiencia como docente, la herencia se entiende rápido con ejemplos como Persona, Alumno y Profesor. El problema aparece cuando se intenta aplicar a cualquier relación. Por eso siempre vuelvo a la misma prueba: ¿puedo decir “esto es un tipo de aquello” sin forzar la frase? Si no puedo, probablemente no necesito herencia.
Constructores en herencia y uso de super
Uno de los puntos que más se atragantan en programación orientada a objetos avanzada en Java es el funcionamiento de los constructores dentro de una jerarquía. Y tiene sentido, porque aquí Java no se limita a “copiar” lo que hay en la superclase: debe inicializar correctamente la parte heredada y la parte propia del objeto.
Cuando se crea un objeto de una subclase, también se inicializa la parte correspondiente a la superclase. Aunque hablamos de “parte Persona” y “parte Alumno”, el resultado final es un único objeto. La inicialización empieza por la superclase y continúa por la subclase.
Por eso se usa super(...): para llamar al constructor de la clase padre.
public class Empleado {
private String nombre;
private double salarioBase;
public Empleado(String nombre, double salarioBase) {
this.nombre = nombre;
this.salarioBase = salarioBase;
}
}
public class Programador extends Empleado {
private String lenguajePrincipal;
public Programador(String nombre, double salarioBase, String lenguajePrincipal) {
super(nombre, salarioBase);
this.lenguajePrincipal = lenguajePrincipal;
}
}
La llamada a super(nombre, salarioBase) debe ser la primera instrucción del constructor de la subclase. Si no la escribes, Java intenta llamar automáticamente a super() sin parámetros. Y aquí aparece un error muy habitual: si la superclase no tiene constructor vacío, el código no compila.
Ejemplo de error típico:
public class Persona {
public Persona(String nombre) {
// Constructor con parámetro
}
}
public class Alumno extends Persona {
public Alumno() {
// Error: Java intenta llamar a super()
}
}
La solución es llamar explícitamente a un constructor válido:
public class Alumno extends Persona {
public Alumno() {
super("Sin nombre");
}
}
El orden de ejecución sería:
| Paso | Qué ocurre |
|---|---|
| 1 | Se solicita crear un objeto de la subclase |
| 2 | Se ejecuta primero el constructor de la superclase |
| 3 | Se inicializan los atributos heredados |
| 4 | Vuelve el control al constructor de la subclase |
| 5 | Se inicializan los atributos propios |
| 6 | El objeto queda listo para usarse |
Cuando se entiende este orden, muchos errores dejan de parecer misteriosos. En programación orientada a objetos avanzada en Java, saber qué constructor se ejecuta primero ayuda muchísimo a depurar jerarquías y entender por qué ciertos atributos tienen un valor u otro.
Sobrescritura de métodos con @Override
La sobrescritura de métodos permite que una subclase proporcione su propia versión de un método heredado. En Java se usa la anotación @Override para indicar que estamos redefiniendo un método que ya existe en la superclase.
Ejemplo:
public class Animal {
public void emitirSonido() {
System.out.println("Sonido genérico");
}
}
public class Perro extends Animal {
@Override
public void emitirSonido() {
System.out.println("Guau");
}
}
public class Gato extends Animal {
@Override
public void emitirSonido() {
System.out.println("Miau");
}
}
Perro y Gato heredan de Animal, pero cada uno redefine el método emitirSonido(). El mensaje es el mismo, pero el comportamiento cambia según el objeto real.
Esta idea es fundamental en programación orientada a objetos avanzada en Java, porque permite diseñar clases que comparten una interfaz común, pero se comportan de forma específica. Dicho de otra forma: no obligo al programa principal a saber todos los detalles. Cada clase se responsabiliza de su propio comportamiento.
Las reglas básicas de la sobrescritura son:
| Regla | Explicación |
|---|---|
| Misma firma | Mismo nombre y mismos parámetros |
| Retorno compatible | El tipo de retorno debe ser igual o compatible |
| Visibilidad no más restrictiva | No puedes reducir la visibilidad heredada |
@Override recomendado | Ayuda al compilador a detectar errores |
Métodos static no se sobrescriben | Pertenecen a la clase, no al objeto |
Métodos final no se sobrescriben | final bloquea ese cambio |
También puedes usar super dentro de un método sobrescrito cuando no quieres sustituir completamente el comportamiento, sino ampliarlo.
public class Documento {
public void imprimir() {
System.out.println("Imprimiendo documento...");
}
}
public class Factura extends Documento {
@Override
public void imprimir() {
super.imprimir();
System.out.println("Añadiendo datos fiscales de la factura");
}
}
Aquí Factura reutiliza parte del comportamiento de Documento, pero añade algo propio. Esta es una forma bastante limpia de especializar sin duplicar código.
En la práctica, recomiendo usar @Override siempre que sobrescribas un método. No es solo decoración. Si te equivocas en el nombre del método o en los parámetros, el compilador puede avisarte. Y ese pequeño aviso puede ahorrarte bastante tiempo de depuración.
Polimorfismo en Java: menos if y más diseño orientado a objetos
El polimorfismo permite tratar objetos de distintas subclases como si fueran objetos de una superclase común. Es una de las consecuencias más potentes de combinar herencia y sobrescritura. El documento lo define como la capacidad de trabajar con objetos de distintas subclases mediante una referencia común.
Ejemplo sencillo:
Animal a1 = new Perro();
Animal a2 = new Gato();
a1.emitirSonido(); // Guau
a2.emitirSonido(); // Miau
Aunque las variables son de tipo Animal, Java ejecuta el método correspondiente al objeto real: Perro o Gato. A esto se le llama enlace dinámico o despacho dinámico.
El polimorfismo se entiende todavía mejor con colecciones:
ArrayList<Animal> animales = new ArrayList<>();
animales.add(new Perro());
animales.add(new Gato());
for (Animal animal : animales) {
animal.emitirSonido();
}
El bucle no necesita preguntar si el animal es un perro o un gato. Solo llama a emitirSonido(). Cada objeto responde con su propia versión del método.
Este punto es clave en programación orientada a objetos avanzada en Java: el polimorfismo reduce condicionales innecesarios. En vez de llenar el programa de if, else if o switch para comprobar tipos, dejamos que cada clase implemente su comportamiento.
Comparación rápida:
| Diseño pobre | Diseño orientado a objetos |
|---|---|
Muchos if para comprobar tipos | Cada subclase implementa su comportamiento |
| La clase principal conoce demasiados detalles | La responsabilidad se reparte |
| Añadir un nuevo tipo obliga a tocar mucho código | Añadir una subclase afecta menos |
| El código crece en complejidad | La jerarquía expresa mejor el modelo |
Esto lo he visto muchas veces en código real y también en ejercicios de alumnos: cuando el programa empieza a tener demasiados condicionales preguntando “qué tipo de objeto eres”, suele ser una señal de que falta polimorfismo.
Por ejemplo, si tienes notificaciones por email, SMS y mensaje interno, no necesitas que la clase principal pregunte constantemente qué tipo de notificación tiene delante. Puedes crear una superclase común y hacer que cada subclase implemente su forma de enviar el mensaje.
La programación orientada a objetos avanzada en Java se vuelve mucho más clara cuando el objeto que sabe hacer algo es quien realmente lo hace.
Clases abstractas, métodos abstractos y final
Una clase abstracta es una clase que no puede instanciarse directamente. Se usa como base para otras clases. Puede contener atributos, constructores, métodos normales y métodos abstractos. Un método abstracto declara qué operación debe existir, pero no define cómo se realiza. Las subclases concretas deben implementarlo.
Ejemplo:
public abstract class Figura {
private String color;
public Figura(String color) {
this.color = color;
}
public String getColor() {
return color;
}
public abstract double calcularArea();
}
public class Rectangulo extends Figura {
private double base;
private double altura;
public Rectangulo(String color, double base, double altura) {
super(color);
this.base = base;
this.altura = altura;
}
@Override
public double calcularArea() {
return base * altura;
}
}
Aquí Figura representa una idea general. Tiene sentido hablar de figuras, pero quizá no tiene sentido crear una “figura genérica” sin saber qué tipo de figura es. Por eso Figura puede ser abstracta. En cambio, Rectangulo sí es una figura concreta y puede implementar calcularArea().
En programación orientada a objetos avanzada en Java, abstract sirve para expresar intención de diseño: “todas las subclases deben saber hacer esto, pero cada una lo hará a su manera”.
La diferencia entre clase normal y clase abstracta puede resumirse así:
| Aspecto | Clase normal | Clase abstracta |
|---|---|---|
| Instanciación | Se puede crear con new | No se puede instanciar directamente |
| Métodos abstractos | No puede tenerlos | Puede tenerlos |
| Uso principal | Representar objetos concretos | Servir como base común |
| Ejemplo | Rectangulo, Circulo | Figura |
Por otro lado, final hace lo contrario en cierto sentido: bloquea cambios. Una clase final no puede tener subclases y un método final no puede ser sobrescrito. Se usa cuando queremos proteger una decisión de diseño. Por ejemplo, puede tener sentido impedir que se modifique un método crítico de validación, seguridad o cálculo.
public final class ConfiguracionSistema {
// Nadie puede heredar de esta clase
}
public class CuentaBancaria {
public final void validarIdentidad() {
System.out.println("Validando identidad...");
}
}
La clave está en no usar abstract ni final por costumbre. Úsalos cuando quieras comunicar una decisión clara:
| Necesidad | Herramienta | Efecto |
|---|---|---|
| Forzar implementación | abstract | Obliga a implementar métodos |
| Impedir herencia | final class | Bloquea subclases |
| Impedir sobrescritura | final method | Bloquea cambios en un método |
| Permitir acceso controlado | protected | Facilita extensión sin hacerlo público |
En resumen: abstract abre un contrato que las subclases deben cumplir; final cierra una puerta que no quieres que nadie cruce.
Cómo diseñar buenas jerarquías de clases
Diseñar una jerarquía no consiste en dibujar cajas unidas por flechas. Consiste en identificar conceptos generales, conceptos especializados, comportamientos comunes y comportamientos específicos. Esta es una de las partes más importantes de la programación orientada a objetos avanzada en Java.
Antes de crear una jerarquía, hazte preguntas como estas:
| Pregunta | Decisión de diseño |
|---|---|
| ¿Todas las clases comparten atributos? | Llevarlos a la superclase |
| ¿Comparten comportamiento? | Definir método común |
| ¿Cada una realiza una operación de forma distinta? | Usar método abstracto o sobrescritura |
| ¿La relación es “tiene un”? | Usar composición |
| ¿La superclase tiene sentido como objeto real? | Puede ser concreta o abstracta |
| ¿El diseño se puede ampliar? | Favorecer polimorfismo |
Imagina esta jerarquía:
Empleado
|
|--- Programador
| |
| |--- ProgramadorBackend
|
|--- Diseñador
Puede tener sentido si cada nivel añade algo real. Pero si ProgramadorBackend no incorpora datos ni comportamientos propios, quizá no merece ser una clase independiente. Una jerarquía demasiado profunda suele hacer el código más difícil de seguir.
Aquí entra también el principio de sustitución: una subclase debería poder usarse donde se espera la superclase sin romper el comportamiento esperado del programa. Por ejemplo, si un método recibe una Figura y llama a calcularArea(), debería funcionar correctamente con un Rectangulo, un Circulo o cualquier otra subclase concreta de Figura.
Cuando una subclase necesita anular métodos de forma artificial, lanzar excepciones porque no soporta operaciones heredadas o cambiar completamente el significado de la superclase, la jerarquía huele mal.
La comparación entre herencia y composición suele aclararlo:
| Relación | Mejor opción | Motivo |
|---|---|---|
| Un alumno es una persona | Herencia | Relación de especialización |
| Un pedido tiene líneas | Composición | El pedido contiene líneas |
| Un coche tiene motor | Composición | El coche no es un motor |
| Un PDF es un documento | Herencia | Especializa comportamiento común |
| Un usuario tiene dirección | Composición | La dirección forma parte de sus datos |
| Un rectángulo es una figura | Herencia | Comparte concepto general |
La frase útil es muy simple: herencia significa “es un tipo de”; composición significa “tiene un”. Si no puedes decir la frase de forma natural, revisa el diseño.
En mi caso, una de las lecciones que más me llevo de la consultoría y la docencia es que un buen diseño no se mide solo por compilar. Se mide por lo fácil que resulta explicarlo, modificarlo y mantenerlo. Esa idea encaja mucho con mi forma de trabajar: tecnología útil, clara y aplicable.
Errores frecuentes al trabajar con POO avanzada en Java
La programación orientada a objetos avanzada en Java tiene mucho potencial, pero también algunos errores típicos. La buena noticia es que casi todos se pueden detectar con una mezcla de criterio, pruebas y depuración.
Estos son algunos de los más comunes:
| Error frecuente | Consecuencia | Solución |
|---|---|---|
Olvidar super(...) | Error si no existe constructor vacío | Llamar al constructor correcto |
No usar @Override | Crees que sobrescribes, pero no lo haces | Añadir @Override |
| Confundir sobrecarga y sobrescritura | El método esperado no se ejecuta | Revisar firma del método |
| Usar herencia para “tiene un” | Diseño rígido y confuso | Usar composición |
Abusar de protected | Menor encapsulación | Preferir private y métodos controlados |
| Crear jerarquías profundas | Código difícil de seguir | Simplificar niveles |
| Forzar subclases que no encajan | Métodos vacíos o excepciones raras | Revisar el modelo del dominio |
Estos errores aparecen tanto en ejercicios como en proyectos reales. Al principio, es normal pensar que una jerarquía grande es más “avanzada”. Pero muchas veces lo avanzado es justo lo contrario: saber simplificar.
La depuración también ayuda mucho. Para entender una jerarquía, puedes colocar un punto de ruptura en el constructor de la superclase, otro en el constructor de la subclase y ejecutar el programa paso a paso. Así puedes ver qué constructor se ejecuta primero, qué atributos se inicializan y qué método se invoca en una llamada polimórfica.
Ejemplo:
Animal animal = new Perro();
animal.emitirSonido();
En depuración, deberías comprobar que se ejecuta Perro.emitirSonido(), no Animal.emitirSonido(). Ese tipo de comprobación es oro cuando estás aprendiendo polimorfismo.
También conviene probar:
| Qué probar | Qué comprobar |
|---|---|
| Constructores | Que cada subclase inicializa bien sus datos |
| Métodos heredados | Que la subclase puede usar lo común |
| Métodos sobrescritos | Que se ejecuta la versión correcta |
| Polimorfismo | Que una referencia común funciona con subclases |
| Casos límite | Datos vacíos, cero, negativos si procede |
| Documentación | Que la jerarquía se entiende leyendo el código |
Y hablando de documentación: no documentes lo obvio. No hace falta escribir “este método calcula el área” si el método se llama calcularArea(). Lo útil es documentar intención, restricciones y decisiones de diseño. Por ejemplo, por qué una clase es abstracta, por qué un método es final o qué deben cumplir las subclases.
Caso práctico: sistema de notificaciones con herencia y polimorfismo
Vamos a aterrizar la programación orientada a objetos avanzada en Java con un caso práctico: un sistema de notificaciones. Queremos enviar distintos tipos de notificación: correo electrónico, SMS y mensaje interno. Todas las notificaciones tienen destinatario y mensaje, pero cada una se envía de forma diferente. Este caso permite combinar clase abstracta, subclases, sobrescritura y polimorfismo.
Primero identificamos el diseño:
| Elemento | Decisión |
|---|---|
| Concepto común | Notificacion |
| Subclases | NotificacionEmail, NotificacionSMS, NotificacionInterna |
| Método común | enviar() |
| Comportamiento variable | Cada tipo se envía distinto |
Uso de abstract | Notificacion no se instancia directamente |
| Uso de polimorfismo | Una lista puede contener cualquier subtipo |
Creamos la superclase abstracta:
public abstract class Notificacion {
private String destinatario;
private String mensaje;
public Notificacion(String destinatario, String mensaje) {
this.destinatario = destinatario;
this.mensaje = mensaje;
}
public String getDestinatario() {
return destinatario;
}
public String getMensaje() {
return mensaje;
}
public abstract void enviar();
}
Ahora creamos dos subclases concretas:
public class NotificacionEmail extends Notificacion {
public NotificacionEmail(String destinatario, String mensaje) {
super(destinatario, mensaje);
}
@Override
public void enviar() {
System.out.println("Enviando email a " + getDestinatario());
System.out.println("Mensaje: " + getMensaje());
}
}
public class NotificacionSMS extends Notificacion {
public NotificacionSMS(String destinatario, String mensaje) {
super(destinatario, mensaje);
}
@Override
public void enviar() {
System.out.println("Enviando SMS a " + getDestinatario());
System.out.println(getMensaje());
}
}
Y ahora usamos polimorfismo:
import java.util.ArrayList;
public class AppNotificaciones {
public static void main(String[] args) {
ArrayList<Notificacion> notificaciones = new ArrayList<>();
notificaciones.add(new NotificacionEmail(
"[email protected]", "Entrega corregida"
));
notificaciones.add(new NotificacionSMS(
"600123123", "Nueva tarea publicada"
));
for (Notificacion n : notificaciones) {
n.enviar();
System.out.println("---");
}
}
}
El bucle no necesita saber si la notificación es email o SMS. Solo llama a enviar(). Cada objeto ejecuta su propia versión del método. Esta es la gracia del polimorfismo.
Este ejemplo resume muy bien la programación orientada a objetos avanzada en Java:
Notificaciondefine lo común.NotificacionEmailyNotificacionSMSespecializan el comportamiento.super(...)inicializa la parte común.@Overridegarantiza que estamos sobrescribiendo bien.- El bucle trabaja con el tipo general.
- Cada subclase decide cómo enviar.
Si más adelante añadimos NotificacionInterna, no necesitamos reescribir todo el programa principal. Creamos una nueva subclase, implementamos enviar() y la añadimos a la lista. Ese es el tipo de diseño que merece la pena buscar: claro, ampliable y fácil de probar.
Tabla resumen de conceptos clave
| Concepto | Qué significa | Cuándo usarlo |
|---|---|---|
| Composición | Una clase contiene objetos de otra | Cuando la relación es “tiene un” |
| Herencia | Una clase especializa a otra | Cuando la relación es “es un tipo de” |
| Superclase | Clase general | Cuando contiene atributos o métodos comunes |
| Subclase | Clase especializada | Cuando añade o modifica comportamiento |
super | Acceso al constructor o método de la clase padre | En constructores y métodos sobrescritos |
@Override | Indica sobrescritura de método | Siempre que redefinas un método heredado |
| Polimorfismo | Tratar subclases como superclase | Para reducir condicionales y ampliar mejor |
| Clase abstracta | Clase base no instanciable | Cuando quieres forzar métodos comunes |
| Método abstracto | Método sin implementación | Cuando cada subclase debe implementarlo |
final | Bloquea herencia o sobrescritura | Cuando quieres proteger una decisión de diseño |
protected | Acceso desde subclases | Con moderación y criterio |
Conclusión: la POO avanzada va de criterio, no de complicar el código
La programación orientada a objetos avanzada en Java no va de usar todos los recursos del lenguaje en cada proyecto. Va de saber elegir. A veces necesitas herencia. Otras veces, composición. A veces una clase abstracta expresa muy bien un contrato común. Otras veces, una clase normal es suficiente. A veces el polimorfismo te evita muchos condicionales. Otras veces, una jerarquía mal diseñada te mete en un lío.
La idea más importante es esta: un buen diseño orientado a objetos debe ser claro, coherente, ampliable y fácil de mantener. No basta con que compile. Tiene que poder entenderse, probarse, depurarse y documentarse.
Si estás aprendiendo programación orientada a objetos avanzada en Java, mi recomendación es que no intentes memorizarlo todo de golpe. Empieza por detectar responsabilidades. Luego distingue entre “es un tipo de” y “tiene un”. Después practica herencia, super, @Override, polimorfismo y clases abstractas con ejemplos pequeños. Y cuando algo funcione, pregúntate si también se entiende.
Porque al final, programar bien no consiste en crear muchas clases. Consiste en crear las clases necesarias, con relaciones justificadas y responsabilidades claras.
Preguntas frecuentes sobre programación orientada a objetos avanzada en Java
¿Qué diferencia hay entre herencia y composición?
La herencia expresa una relación “es un tipo de”. Por ejemplo, un alumno es una persona. La composición expresa una relación “tiene un”. Por ejemplo, un pedido tiene líneas de pedido. Si no puedes decir la frase de forma natural, probablemente debes revisar el diseño.
¿Qué es el polimorfismo en Java?
El polimorfismo permite tratar objetos de diferentes subclases como si fueran objetos de una superclase común. Por ejemplo, puedes tener una lista de Animal con objetos Perro y Gato, y llamar a emitirSonido() sin comprobar manualmente el tipo de cada objeto.
¿Para qué sirve super en una subclase?
super sirve para acceder al constructor o a métodos de la superclase. En un constructor, super(...) permite inicializar correctamente la parte heredada del objeto. En un método sobrescrito, super.metodo() permite reutilizar parte del comportamiento de la clase padre.
¿Cuándo conviene usar una clase abstracta?
Conviene usar una clase abstracta cuando quieres definir una base común para varias subclases, pero no tiene sentido crear objetos directos de esa clase. También sirve para obligar a las subclases concretas a implementar ciertos métodos.
¿Qué diferencia hay entre un método abstracto y un método sobrescrito?
Un método abstracto se declara sin cuerpo en una clase abstracta y obliga a las subclases concretas a implementarlo. Un método sobrescrito es una nueva versión de un método heredado, definida dentro de una subclase.
¿Para qué sirve final en Java?
En el contexto de herencia, final sirve para impedir cambios. Una clase final no puede tener subclases y un método final no puede ser sobrescrito. Se usa cuando quieres proteger una decisión de diseño.
¿Por qué es recomendable usar @Override?
Porque ayuda al compilador a detectar errores. Si intentas sobrescribir un método pero te equivocas en el nombre o en los parámetros, @Override permite detectar el problema antes de ejecutar el programa.
¿Cuál es el error más frecuente al aprender POO avanzada?
Uno de los errores más frecuentes es usar herencia para todo. La herencia es útil, pero si la relación real es “tiene un”, la composición suele ser una opción más clara y flexible.


