Skip to content

Ejercicios Prácticos: POO Avanzada en Java

Narrativa del Proyecto: ¡Bienvenido al equipo de desarrollo de Innovatec Solutions! Nuestra empresa está en plena expansión y nuestro sistema de gestión de recursos humanos, basado en hojas de cálculo, se ha quedado obsoleto. Tu misión es liderar el diseño y la implementación del núcleo de nuestro nuevo sistema, "Innovatec HR Core", utilizando los principios más sólidos de la Programación Orientada a Objetos en Java.

Este proyecto no solo gestionará empleados, sino que deberá ser lo suficientemente flexible para adaptarse a nuevos roles, normativas y funcionalidades en el futuro. ¡Es hora de demostrar tus habilidades como arquitecto de software!

Ejercicios de Consolidación

1) La Jerarquía de Innovatec: Creando la Base de Empleados | Nivel: Fácil

Objetivo:

Aplicar el concepto de Herencia para modelar la relación fundamental entre una persona y un empleado en el sistema.

Tarea a realizar:
  1. Crea una clase Persona con los atributos protected nombre y nif. Incluye un constructor para inicializarlos y un método toString() que devuelva una representación básica de la persona.
  2. Crea una clase Empleado que herede de Persona.
  3. Empleado debe añadir un atributo private llamado idEmpleado (un String).
  4. El constructor de Empleado debe recibir el nombre, nif y el idEmpleado. Asegúrate de llamar correctamente al constructor de la clase padre (Persona) usando super().
  5. Sobrescribe el método toString() en Empleado para que, además de la información de la persona, muestre el ID del empleado. Utiliza super.toString() para reutilizar la lógica del padre.
  6. Crea una clase Main para probar tu código: instancia un objeto Empleado y muestra su información por consola.
Aplicación en el Mundo Real:

Establecer jerarquías de clases es fundamental en cualquier aplicación empresarial. Permite definir entidades generales (como una Persona) y luego especializarlas (Empleado, Cliente, Proveedor) reutilizando código y manteniendo una estructura lógica y ordenada.

2) Organizando la Empresa: Departamentos y sus Miembros | Nivel: Fácil

Objetivo:

Aplicar el concepto de Composición para modelar la relación "tiene un/a" entre un Departamento y los Empleados que lo componen.

Tarea a realizar:
  1. Crea una clase Departamento. Esta clase tendrá un atributo private nombre (String) y un atributo private miembros que será una List<Empleado>.
  2. En el constructor de Departamento, inicializa el nombre y la lista de miembros (como un ArrayList vacío).
  3. Crea un método agregarMiembro(Empleado empleado) que añada un empleado a la lista.
  4. Crea un método listarMiembros() que imprima por consola los detalles de todos los empleados del departamento.
  5. En tu clase Main, crea una instancia de Departamento (ej: "Ingeniería de Software") y varias instancias de Empleado. Agrega los empleados al departamento y luego llama a listarMiembros().
Aplicación en el Mundo Real:

La composición es una de las técnicas de diseño más importantes. En lugar de que un Departamento sea un Empleado (lo cual no tiene sentido), un Departamento tiene Empleados. Este modelo es flexible y representa con precisión las relaciones del mundo real en la mayoría de los casos.

3) Identidad Única: El Contrato equals y hashCode | Nivel: Fácil

Objetivo:

Comprender y aplicar correctamente la sobrescritura de los métodos equals(), hashCode() y toString() de la clase Object.

Tarea a realizar:
  1. Partiendo de la clase Empleado del ejercicio 1, modifica su lógica de igualdad. En Innovatec, dos empleados se consideran la misma persona a efectos contractuales si su nif es idéntico.
  2. Sobrescribe el método equals(Object obj) en la clase Empleado para que compare dos empleados basándose únicamente en su nif.
  3. Cumple con el contrato equals-hashCode: sobrescribe también el método hashCode() para que genere un código hash basado en el nif.
  4. En tu clase Main, crea dos objetos Empleado con el mismo nif pero diferente idEmpleado (por ejemplo, un recontrato). Comprueba que p1.equals(p2) devuelve true y que p1 == p2 devuelve false.
Aplicación en el Mundo Real:

Sobrescribir equals y hashCode es absolutamente crucial cuando se trabaja con colecciones como HashSet o HashMap. Sin una implementación correcta, estas colecciones no funcionarán como se espera, llevando a bugs muy difíciles de detectar. Por ejemplo, podrías acabar con empleados "duplicados" en un conjunto que debería contener solo entradas únicas.

4) Definiendo Roles: El Empleado Abstracto | Nivel: Fácil

Objetivo:

Utilizar clases abstractas para definir un "contrato" común y compartir código entre diferentes tipos de empleados.

Tarea a realizar:
  1. Convierte la clase Empleado en una clase abstracta. No tiene sentido instanciar un "empleado genérico".
  2. Añade un método abstracto a Empleado llamado public abstract double calcularSalario();. Esto obliga a todas las subclases concretas a definir cómo se calcula su salario.
  3. Crea dos clases concretas que hereden de Empleado:
    • Programador: Añade un atributo salarioBase (double). Su calcularSalario() simplemente devolverá este valor.
    • Manager: Añade un atributo salarioBase y un bonus (double). Su calcularSalario() devolverá la suma de ambos.
  4. En tu clase Main, intenta instanciar un Empleado. El código no debería compilar. Luego, instancia un Programador y un Manager y muestra sus salarios.
Aplicación en el Mundo Real:

Las clases abstractas son perfectas para modelar entidades que comparten una base común pero cuya implementación completa depende de sus "hijos". Un Vehiculo es abstracto, pero un Coche y una Moto son concretos. En RRHH, un Empleado es un concepto abstracto, mientras que los roles específicos son las implementaciones concretas.

5) ¡Bug en Producción! El Cálculo de Nóminas | Nivel: Fácil

Objetivo:

Depurar un fragmento de código que hace un mal uso del polimorfismo, provocando una ClassCastException.

Setup Inicial:

Utiliza las clases Empleado (abstracta), Programador y Manager del ejercicio anterior. Adicionalmente, crea una nueva clase ConsultorExterno que NO hereda de Empleado pero sí de Persona.

public class ConsultorExterno extends Persona {
    public ConsultorExterno(String nombre, String nif) {
        super(nombre, nif);
    }
    public void facturar() {
        System.out.println("Facturando como consultor externo...");
    }
}
Tarea a realizar:

El siguiente código intenta procesar una lista de Personas. El objetivo es calcular el total de salarios de los Empleados y, específicamente para los Managers, llamar a un método aprobarVacaciones(). El código, sin embargo, se rompe.

  1. Identifica la causa de la ClassCastException.
  2. Corrige el código usando el operador instanceof para asegurar que los castings sean seguros.
  3. Añade un método aprobarVacaciones() a la clase Manager para que el código corregido funcione.

Código con Bug:

import java.util.ArrayList;
import java.util.List;

public class ProcesadorNominas {
    public void procesar(List<Persona> personas) {
        double totalSalarios = 0.0;
        for (Persona p : personas) {
            // Asumimos que todos son empleados para el cálculo de salario
            Empleado emp = (Empleado) p; // <-- ¡Peligro!
            totalSalarios += emp.calcularSalario();

            // Y si es un Manager, aprobamos vacaciones
            Manager mgr = (Manager) p; // <-- ¡Doble peligro!
            mgr.aprobarVacaciones();
        }
        System.out.println("Total salarios: " + totalSalarios);
    }
}

Aplicación en el Mundo Real:

Este es uno de los errores más comunes al trabajar con polimorfismo. Tratar a todos los objetos de una colección como si fueran del mismo subtipo es una receta para el desastre. El uso defensivo de instanceof antes de un downcasting es una práctica de programación esencial para crear software robusto.

Ejercicios de Refuerzo

6) El Manager Moderno: Extendiendo Funcionalidad | Nivel: Medio

Objetivo:

Reforzar el uso de super tanto para llamar a constructores como para extender la funcionalidad de métodos heredados.

Tarea a realizar:
  1. Añade un método public String getDetalles() a tu clase Empleado (abstracta). Este método debe devolver un String con el nombre y el ID del empleado.
  2. Ahora, en la clase Manager, sobrescribe el método getDetalles().
  3. La nueva implementación en Manager debe ampliar la del padre. Debe llamar al método getDetalles() de la clase Empleado usando super.getDetalles() y añadirle información adicional, como por ejemplo, el bonus que gestiona. El resultado final debería ser algo como: "Nombre: Elena Soler, ID: MGR-01, Gestiona un bonus de: 10000.0".
  4. Crea un Main para instanciar un Manager y un Programador y llama al método getDetalles() en ambos para ver la diferencia.

  5. Pista: Recuerda que super.metodo() te permite invocar la versión del método de la clase padre desde la clase hija, ideal para no repetir código.

Aplicación en el Mundo Real:

Esta técnica es extremadamente común. Permite construir funcionalidades complejas de forma incremental. Una clase base proporciona una funcionalidad por defecto, y las clases hijas la refinan o la expanden sin tener que reescribir la lógica original.

7) Habilidades Transversales: La Interfaz Notificable | Nivel: Medio

Objetivo:

Diseñar y utilizar una interfaz para dotar de una capacidad ("puede hacer") a clases que no tienen una relación de herencia directa.

Tarea a realizar:
  1. Crea una interfaz llamada Notificable que defina un único método: void enviarNotificacion(String mensaje).
  2. En Innovatec, tanto los Managers como los ConsultorExternos deben poder recibir notificaciones, pero los Programadores no.
  3. Haz que las clases Manager y ConsultorExterno implementen la interfaz Notificable. Proporciona una implementación simple para el método, por ejemplo, que imprima en consola a quién se notifica y el mensaje.
  4. Crea una clase SistemaDeNotificaciones con un método enviarNotificacionMasiva(String mensaje, List<Object> destinatarios).
  5. Dentro de este método, itera sobre la lista de destinatarios. Usando instanceof, comprueba si cada objeto es Notificable y, si lo es, haz el casting y llama a su método enviarNotificacion.
  6. Prueba tu sistema enviando una notificación a una lista que contenga un Manager, un Programador y un ConsultorExterno.

  7. Pista: Piensa en la interfaz como un "contrato". Cualquier clase que lo firme (implements) está obligada a tener el comportamiento definido en él, sin importar de quién herede.

Aplicación en el Mundo Real:

Las interfaces son la base del diseño de software flexible en Java. Permiten que sistemas como los gestores de eventos (ActionListener), los comparadores (Comparable, Comparator) o los frameworks de Inyección de Dependencias funcionen, conectando componentes que no necesitan conocer los detalles internos de los demás, solo el "contrato" que cumplen.

8) ¿Herencia o Composición? El Dilema del 'Jefe de Equipo' | Nivel: Medio

Objetivo:

Analizar un problema de diseño y justificar por qué la composición es a menudo preferible a la herencia.

Tarea a realizar:

En Innovatec, cualquier Programador puede, temporalmente, asumir el rol de JefeDeEquipo. Este rol le da una nueva responsabilidad: revisarCodigo(). Un enfoque inicial podría ser crear una clase JefeDeEquipo que herede de Programador. Sin embargo, esto es rígido. ¿Qué pasa si un día un DiseñadorUX también puede ser JefeDeEquipo? No podría heredar de Programador.

Tu tarea es modelar esta relación usando composición, siguiendo el principio de "Favorecer la composición sobre la herencia".

  1. Crea una interfaz Rol con un método desempenar().
  2. Crea una clase RolJefeDeEquipo que implemente Rol. Su método desempenar() imprimirá "Revisando código del equipo...".
  3. Modifica la clase Programador para que tenga un Rol. Añade un atributo private Rol rolActual;.
  4. Añade métodos en Programador para asignarRol(Rol nuevoRol) y realizarRol(). El segundo método comprobará si el rol no es nulo y llamará a rolActual.desempenar().
  5. En Main, crea un Programador. Inicialmente no tendrá rol. Luego, asígnale el RolJefeDeEquipo y haz que lo desempeñe.

  6. Pista: Con este diseño, podrías crear un RolMentor, RolFormador, etc., y asignarlos dinámicamente a cualquier Empleado sin cambiar la jerarquía de clases.

Aplicación en el Mundo Real:

Este es un ejemplo clásico del Patrón de Diseño "Strategy" o "State". Permite cambiar el comportamiento de un objeto en tiempo de ejecución. Es mucho más flexible que la herencia, que fija el comportamiento en tiempo de compilación. Se usa en sistemas de videojuegos (cambiar el estado de un personaje), aplicaciones financieras (cambiar la estrategia de cálculo de impuestos), etc.

9) La Magia del Polimorfismo: La Calculadora de Nóminas | Nivel: Medio

Objetivo:

Aplicar el polimorfismo para procesar una colección de objetos de diferentes tipos de forma uniforme.

Tarea a realizar:
  1. Asegúrate de tener tu jerarquía de Empleado (abstracta) con las subclases Programador y Manager, cada una con su propia implementación de calcularSalario().
  2. Crea una clase CalculadoraNominas con un método calcularTotalSalarios(List<Empleado> empleados).
  3. Este método debe iterar sobre la lista de Empleados. Por cada empleado en la lista, debe invocar su método calcularSalario() y sumar el resultado a un total.
  4. Fíjate en que el método no necesita saber si el objeto es un Programador o un Manager. Simplemente confía en que, por ser un Empleado, tendrá un método calcularSalario() disponible. Esta es la esencia del polimorfismo.
  5. En tu Main, crea una List<Empleado>, añade varias instancias de Programador y Manager, y pasa la lista a tu CalculadoraNominas para obtener el coste total de la plantilla.

  6. Pista: La variable del bucle for será de tipo Empleado, pero en tiempo de ejecución, Java sabrá a qué objeto real apunta (un Programador o un Manager) y llamará a la versión correcta (@Override) del método calcularSalario().

Aplicación en el Mundo Real:

El polimorfismo es la clave para escribir código limpio, mantenible y extensible. Si mañana Innovatec crea un nuevo rol DiseñadorUX que hereda de Empleado, la CalculadoraNominas funcionará con él sin necesidad de modificar una sola línea de su código. Esto se conoce como el Principio Abierto/Cerrado (abierto a la extensión, cerrado a la modificación).

Ejercicios de Ampliación

10) Interfaces Evolucionadas: Métodos default | Nivel: Alto

Objetivo:

Investigar y aplicar una característica avanzada de las interfaces en Java (a partir de la versión 8): los métodos default.

Tarea a realizar:
  1. Investiga: ¿Qué es un método default en una interfaz de Java? ¿Para qué sirve y qué problema soluciona?
  2. Vamos a mejorar nuestra interfaz Notificable. A veces, queremos enviar una notificación con una prioridad.
  3. Añade un nuevo método a la interfaz Notificable: void enviarNotificacionUrgente(String mensaje).
  4. En lugar de obligar a todas las clases existentes (Manager, ConsultorExterno) a implementar este nuevo método (lo que rompería el código existente), impleméntalo como un método default.
  5. La implementación por defecto de enviarNotificacionUrgente llamará al método abstracto enviarNotificacion pero añadiendo un prefijo como "[URGENTE] ".
  6. En tu clase Main, toma una instancia de Manager (que es Notificable) y llama al nuevo método enviarNotificacionUrgente(). Verás que funciona sin que hayas tenido que añadir ningún código a la clase Manager.
  7. Extra: Sobrescribe el método default en la clase ConsultorExterno para darle un comportamiento personalizado (ej: "Enviando SMS URGENTE a...").
Aplicación en el Mundo Real:

Los métodos default fueron una adición crucial a Java. Permiten a los diseñadores de APIs (como las de la propia librería estándar de Java) añadir nueva funcionalidad a interfaces existentes sin romper la compatibilidad con todo el código que ya las utilizaba. Un ejemplo famoso fue la adición del método forEach() a la interfaz Iterable.

11) Refactorización a Patrones: El Patrón Strategy | Nivel: Alto

Objetivo:

Refactorizar un código problemático (usando if-else anidados) aplicando el Patrón de Diseño Strategy. Este patrón es una manifestación perfecta de "favorecer la composición sobre la herencia" y el polimorfismo.

Setup Inicial (Código a refactorizar):

En la clase Manager, el cálculo del bonus se ha vuelto complejo. Actualmente, el código es así y es difícil de mantener:

public class Manager extends Empleado {
    // ... atributos ...
    private String nivelRendimiento; // "BUENO", "EXCELENTE", "A MEJORAR"

    // ... constructor ...

    // MÉTODO A REFACTORIZAR
    public double calcularBonus() {
        if ("EXCELENTE".equals(nivelRendimiento)) {
            return (salarioBase + bonus) * 0.20;
        } else if ("BUENO".equals(nivelRendimiento)) {
            return (salarioBase + bonus) * 0.10;
        } else if ("A MEJORAR".equals(nivelRendimiento)) {
            return bonus * 0.5; // Solo una parte del bonus base
        } else {
            return 0.0;
        }
    }
    // ... otros métodos ...
}
Tarea a realizar:
  1. Crea una interfaz EstrategiaDeCalculoBonus. Debe tener un método double calcular(double salarioBase, double bonus).
  2. Crea tres clases que implementen esta interfaz: BonusExcelente, BonusBueno y BonusAMejorar. Cada una contendrá la lógica de cálculo que estaba en el if-else.
  3. Modifica la clase Manager. Elimina el atributo nivelRendimiento y el método calcularBonus().
  4. En su lugar, añade un atributo private EstrategiaDeCalculoBonus estrategiaBonus; (¡Composición!).
  5. Modifica el constructor del Manager para que reciba una EstrategiaDeCalculoBonus y la asigne.
  6. Crea un nuevo método public double obtenerBonusFinal() en Manager que delegue el cálculo a su objeto de estrategia: return estrategiaBonus.calcular(this.salarioBase, this.bonus);.
  7. En Main, crea varios Managers, cada uno con una estrategia diferente, y comprueba que sus bonus se calculan correctamente.
Aplicación en el Mundo Real:

El Patrón Strategy es uno de los patrones de comportamiento más importantes. Se usa para permitir que el algoritmo o la lógica de una operación varíe independientemente de los clientes que lo utilizan. Lo encuentras en sistemas de validación de formularios, estrategias de compresión de archivos, algoritmos de renderizado en videojuegos, etc.

12) Diseño y Revisión por Pares: El Módulo de Fichaje | Nivel: Alto

Objetivo:

Diseñar una solución de software a pequeña escala, documentarla con un diagrama de clases y realizar una revisión por pares (Peer Review) para mejorar el diseño.

Tarea a realizar:

Innovatec necesita un nuevo módulo para el control horario. Los requisitos iniciales son: * Los empleados deben poder "fichar" al entrar y al salir. * Existen diferentes tipos de contrato con distintas políticas de fichaje (ej: ContratoFlexible, ContratoEstricto). * El sistema debe poder generar un reporte de horas trabajadas para un empleado en un periodo.

Parte 1: Tu Diseño (Individual) 1. Diseña la arquitectura de clases para este módulo. Piensa en qué clases necesitas (RegistroFichaje, PoliticaDeFichaje, etc.) y cómo se relacionan. 2. Usa los conceptos de herencia, composición, clases abstractas e interfaces donde creas que aportan más valor. 3. Crea un diagrama de clases en Mermaid que represente tu diseño. 4. Escribe una breve justificación (2-3 párrafos) explicando por qué tomaste ciertas decisiones de diseño (ej: "He usado una interfaz PoliticaDeFichaje para poder añadir nuevas políticas en el futuro sin cambiar el código de Empleado...").

Parte 2: Revisión por Pares (En parejas) 1. Intercambia tu diagrama y justificación con un compañero. 2. Analiza su diseño y proporciona feedback constructivo por escrito sobre al menos dos puntos. El feedback debe ser profesional y razonado. Ejemplos de buen feedback: * "Tu uso de la herencia en la clase X es interesante, pero ¿has considerado si la composición podría hacerlo más flexible para el requisito Y?" * "Me gusta mucho cómo has definido la interfaz Z. Creo que simplifica enormemente la lógica. Una posible mejora sería añadirle un método validarFichaje() para encapsular esa lógica." 3. Recibe el feedback de tu compañero y reflexiona sobre cómo podrías mejorar tu diseño inicial.

Aplicación en el Mundo Real:

El diseño de software y la revisión de código (Code Review) son dos de las actividades más importantes en un equipo de desarrollo profesional. Permiten compartir conocimiento, mejorar la calidad del software, detectar errores de diseño antes de que se escriba una sola línea de código y fomentar una cultura de colaboración y mejora continua.