Relaciones entre clases OOP: Asociación, Agregación, Composición y Herencia
Los objetos individuales son útiles, pero la verdadera potencia de la POO surge cuando los objetos se relacionan entre sí y colaboran para resolver tareas complejas. Este documento explica cómo están construidas las clases y los diferentes tipos de relaciones que pueden existir entre ellas, ideal para preparar exámenes de certificación técnica.
1. Componentes de una clase (Su estructura interna)
Una clase es como un molde. Está formada por varios componentes bien definidos:
1. Nombre de clase
El nombre único de la clase (por ejemplo, Cliente, CuentaBancaria). Por convención comienza con mayúscula.
2. Atributos (Campos de datos)
Son las variables dentro de la clase. Describen el estado que tendrá un futuro objeto.
- Cada atributo tiene un nombre (por ejemplo,
saldo) y un tipo de dato (por ejemplo,double,String) - Generalmente se declaran como
private(consulta encapsulación)
3. Constructor
Un método especial que se invoca automáticamente cuando se crea una nueva instancia de la clase usando new.
Propósito: Inicializa los nuevos objetos, es decir, asigna valores iniciales a los atributos.
- El constructor tiene el mismo nombre que la clase y no retorna nada (ni siquiera
void) - Puede haber varios constructores (sobrecarga), por ejemplo uno sin parámetros y otro que inicializa todos los atributos
// Ejemplo de constructor
public class CuentaBancaria {
private double saldo;
private String titular;
// Constructor
public CuentaBancaria(String nombreTitular, double saldoInicial) {
titular = nombreTitular;
saldo = saldoInicial;
}
// Constructor sin parámetros
public CuentaBancaria() {
this("Desconocido", 0.0);
}
// Constructor sobrecargado
public CuentaBancaria(String nombreTitular) {
this(nombreTitular, 0.0);
}
}
// Creación de objetos:
CuentaBancaria miCuenta = new CuentaBancaria("Juan Pérez", 1000.0);
CuentaBancaria cuentaVacia = new CuentaBancaria();
4. Métodos (Funciones miembro)
Definen el comportamiento de los objetos. Operan sobre los atributos.
- Getters/Setters: Métodos públicos para leer y escribir atributos
privatede forma controlada - Métodos de negocio: Implementan la funcionalidad principal (por ejemplo,
depositar(double monto),retirar(double monto))
5. Modificadores de visibilidad (Access Modifiers)
Determinan desde dónde se puede acceder a los componentes de la clase. Son esenciales para la encapsulación.
private: Solo visible desde la propia claseprotected: Visible desde la propia clase y sus subclases (herencia)public: Visible desde cualquier parte- (sin modificador/default): Solo visible dentro del mismo paquete
2. Relaciones entre clases: Cómo se relacionan
Las clases raramente existen en aislamiento. Trabajan juntas. Estas relaciones se modelan en diagramas de clases UML.
a) Asociación
Descripción: La relación más general. Describe una conexión semántica entre dos clases independientes. Una clase “conoce” a otra.
Representación UML: Línea continua.
Ejemplo: Un Profesor imparte clases a un Estudiante. El profesor y el estudiante existen independientemente. La relación suele ser bidireccional (ambos se “conocen”).
Implementación: Generalmente mediante atributos que contienen referencias.
// Ejemplo de asociación
public class Profesor {
private String nombre;
private String departamento;
private List<Estudiante> estudiantes; // Profesor SE ASOCIA CON Estudiante
public Profesor(String nombre, String departamento) {
this.nombre = nombre;
this.departamento = departamento;
this.estudiantes = new ArrayList<>();
}
public void agregarEstudiante(Estudiante estudiante) {
estudiantes.add(estudiante);
estudiante.agregarProfesor(this); // Relación bidireccional
}
public void imparte() {
System.out.println("Profesor " + nombre + " imparte clases a " + estudiantes.size() + " estudiantes");
}
// Getter
public String getNombre() { return nombre; }
public List<Estudiante> getEstudiantes() { return new ArrayList<>(estudiantes); }
}
public class Estudiante {
private String nombre;
private int matricula;
private List<Profesor> profesores; // Estudiante SE ASOCIA CON Profesor
public Estudiante(String nombre, int matricula) {
this.nombre = nombre;
this.matricula = matricula;
this.profesores = new ArrayList<>();
}
public void agregarProfesor(Profesor profesor) {
profesores.add(profesor);
}
public void estudia() {
System.out.println("Estudiante " + nombre + " estudia con " + profesores.size() + " profesores");
}
// Getter
public String getNombre() { return nombre; }
public List<Profesor> getProfesores() { return new ArrayList<>(profesores); }
}
b) Agregación
Descripción: Una forma más específica de asociación. Describe una relación “es-parte-de” o “tiene-un” en la que las partes pueden existir sin el todo. Es una relación débil.
Representación UML: Línea continua con un rombo sin relleno en el lado del todo.
Ejemplo: Un Departamento tiene Empleados. Si el departamento se disuelve, los empleados no desaparecen, sino que pueden asignarse a otro departamento. La parte (empleado) existe independientemente del todo (departamento).
Implementación: Similar a la asociación, pero los ciclos de vida de los objetos no están acoplados.
// Ejemplo de agregación
public class Departamento {
private String nombre;
private String ubicacion;
private List<Empleado> empleados; // Departamento TIENE Empleados (Agregación)
public Departamento(String nombre, String ubicacion) {
this.nombre = nombre;
this.ubicacion = ubicacion;
this.empleados = new ArrayList<>();
}
public void agregarEmpleado(Empleado empleado) {
this.empleados.add(empleado);
empleado.setDepartamento(this);
}
public void removerEmpleado(Empleado empleado) {
this.empleados.remove(empleado);
empleado.setDepartamento(null); // El empleado sigue existiendo
}
public void disolver() {
// Los empleados no se destruyen, solo se desvinculan del departamento
for (Empleado e : empleados) {
e.setDepartamento(null);
}
empleados.clear();
System.out.println("Departamento " + nombre + " disuelto, empleados siguen existiendo");
}
// Getter
public String getNombre() { return nombre; }
public List<Empleado> getEmpleados() { return new ArrayList<>(empleados); }
}
public class Empleado {
private String nombre;
private String cargo;
private Departamento departamento; // Empleado puede existir sin departamento
public Empleado(String nombre, String cargo) {
this.nombre = nombre;
this.cargo = cargo;
this.departamento = null; // Empleado puede crearse sin departamento
}
public void setDepartamento(Departamento departamento) {
this.departamento = departamento;
}
public void trabaja() {
String nombreDepartamento = departamento != null ? departamento.getNombre() : "sin departamento";
System.out.println(nombre + " trabaja como " + cargo + " en " + nombreDepartamento);
}
// Getter
public String getNombre() { return nombre; }
public Departamento getDepartamento() { return departamento; }
}
c) Composición
Descripción: Una forma aún más fuerte de la relación “es parte de”. Las partes no pueden existir sin el todo. El todo es responsable de la duración de vida de las partes. Relaciones de propiedad estrictas.
Representación UML: Línea sólida con un rombo relleno en el lado del todo.
Ejemplo: Un Auto está compuesto por un Motor. El motor no tiene existencia independiente sin el auto. Cuando el auto se desguaza, el motor también se destruye. La duración de vida está fuertemente acoplada.
Implementación: El todo crea las partes en su propio constructor.
// Ejemplo de Composición
public class Auto {
private String marke;
private String modell;
private Motor motor; // Auto ESTÁ COMPUESTO POR Motor
private List<Rad> raeder; // Auto ESTÁ COMPUESTO POR Ruedas
public Auto(String marke, String modell) {
this.marke = marke;
this.modell = modell;
// Las partes se crean con el Auto (acoplamiento estricto de duración de vida)
this.motor = new Motor(2.0, 150); // Motor se crea con Auto
this.raeder = new ArrayList<>();
// Se crean 4 ruedas
for (int i = 0; i < 4; i++) {
raeder.add(new Rad("225/45R17"));
}
}
public void starten() {
motor.starten();
System.out.println(marke + " " + modell + " wird gestartet");
}
public void verschrotten() {
// Todas las partes se destruyen cuando el Auto se desguaza
motor.zerstoeren();
for (Rad rad : raeder) {
rad.zerstoeren();
}
raeder.clear();
System.out.println("Auto und alle Teile wurden verschrottet");
}
// Getter
public String getMarke() { return marke; }
public Motor getMotor() { return motor; }
}
public class Motor {
private double hubraum;
private int leistungPS;
private boolean laeuft;
public Motor(double hubraum, int leistungPS) {
this.hubraum = hubraum;
this.leistungPS = leistungPS;
this.laeuft = false;
}
public void starten() {
this.laeuft = true;
System.out.println("Motor (" + hubraum + "L, " + leistungPS + "PS) gestartet");
}
public void zerstoeren() {
System.out.println("Motor zerstört");
}
// Getter
public double getHubraum() { return hubraum; }
public int getLeistungPS() { return leistungPS; }
}
public class Rad {
private String groesse;
private double druck;
public Rad(String groesse) {
this.groesse = groesse;
this.druck = 2.5;
}
public void zerstoeren() {
System.out.println("Rad (" + groesse + ") zerstört");
}
// Getter
public String getGroesse() { return groesse; }
}
d) Generalización y Especialización (Herencia)
Descripción: Esta es la relación “es un” y se implementa mediante herencia.
Generalización: Extraer características comunes de varias clases en una clase base más general (por ejemplo, Perro, Gato → Animal).
Especialización: Derivar una clase más específica (subclase) de una clase más general (clase base). La subclase hereda todas las propiedades y las refina o amplía (por ejemplo, Animal → Perro; el perro añade el método ladrar()).
Representación UML: Línea sólida con una flecha hueca que va de la subclase a la clase base.
Ejemplo: Gerente es un Empleado. Hereda todos los atributos (nombre, salario) y quizá añade un atributo bonus.
// Ejemplo de Herencia
public class Empleado {
protected String nombre;
protected double salarioBase;
protected int idEmpleado;
private static int proximoId = 1;
public Empleado(String nombre, double salarioBase) {
this.nombre = nombre;
this.salarioBase = salarioBase;
this.idEmpleado = proximoId++;
}
public void trabajar() {
System.out.println(nombre + " trabaja con salario base de " + salarioBase + "€");
}
public double calcularSalario() {
return salarioBase;
}
// Getter
public String getNombre() { return nombre; }
public int getIdEmpleado() { return idEmpleado; }
}
public class Gerente extends Empleado {
private double bono;
private List<Empleado> equipo;
public Gerente(String nombre, double salarioBase, double bono) {
super(nombre, salarioBase); // Llamar al constructor de la clase base
this.bono = bono;
this.equipo = new ArrayList<>();
}
public void agregarMiembroEquipo(Empleado empleado) {
equipo.add(empleado);
}
@Override
public void trabajar() {
System.out.println(nombre + " gestiona equipo de " + equipo.size() + " empleados");
}
@Override
public double calcularSalario() {
return salarioBase + bono; // Cálculo adicional
}
public void reunionEquipo() {
System.out.println(nombre + " realiza reunión de equipo");
for (Empleado e : equipo) {
System.out.println("- " + e.getNombre());
}
}
// Getter
public double getBono() { return bono; }
public List<Empleado> getEquipo() { return new ArrayList<>(equipo); }
}
public class Desarrollador extends Empleado {
private List<String> lenguajes;
public Desarrollador(String nombre, double salarioBase, List<String> lenguajes) {
super(nombre, salarioBase);
this.lenguajes = new ArrayList<>(lenguajes);
}
@Override
public void trabajar() {
System.out.println(nombre + " programa en " + lenguajes);
}
public void aprenderLenguaje(String lenguaje) {
lenguajes.add(lenguaje);
System.out.println(nombre + " aprende " + lenguaje);
}
// Getter
public List<String> getLenguajes() { return new ArrayList<>(lenguajes); }
}
3. Métodos y Atributos Estáticos vs. No Estáticos
Esta diferencia es fundamental y se refiere a si una característica pertenece a la clase o al objeto.
| Característica | No estático (miembro de instancia) | Estático (miembro de clase) |
|---|---|---|
| Pertenencia | Pertenece al objeto individual (instancia) | Pertenece a la clase misma |
| Número en memoria | Una copia por objeto. 100 objetos = 100 copias del atributo | Una sola copia para la clase completa. Compartida por todos los objetos |
| Llamada | A través del objeto: nombreObjeto.nombreMetodo() | A través del nombre de clase: NombreClase.nombreMetodo() |
| Acceso a… | Puede acceder a miembros estáticos y no estáticos | Puede acceder SOLO a miembros estáticos. No puede acceder a miembros no estáticos (porque ¿cuál objeto se refiere?) |
| Uso típico | Atributos que varían de objeto a objeto (por ejemplo, saldo, nombre) | Métodos de utilidad (por ejemplo, Math.sqrt()), constantes (por ejemplo, Math.PI), variables de contador (por ejemplo, numeroObjetosCreados) |
// Ejemplo de Estático vs. No Estático
public class Estudiante {
// Atributo no estático (una copia por objeto)
private String nombre;
private int matricula;
// Atributo estático (una sola copia para la clase)
private static int cantidadEstudiantes = 0;
private static final String UNIVERSIDAD = "Universidad Técnica";
public Estudiante(String nombre, int matricula) {
this.nombre = nombre;
this.matricula = matricula;
cantidadEstudiantes++; // Acceso a atributo estático
}
// Método no estático
public void estudiar() {
System.out.println(nombre + " estudia en " + UNIVERSIDAD);
System.out.println("Cantidad actual de estudiantes: " + cantidadEstudiantes);
}
// Método estático
public static int getCantidadEstudiantes() {
return cantidadEstudiantes;
// return nombre; // ERROR: nombre no es estático y no se puede acceder
}
// Método estático de utilidad
public static boolean esMatriculaValida(int numero) {
return numero >= 100000 && numero <= 999999;
}
// Getter y Setter
public String getNombre() { return nombre; }
public int getMatricula() { return matricula; }
public static String getUniversidad() { return UNIVERSIDAD; }
}
// Uso
public class Main {
public static void main(String[] args) {
// Crear objetos
Estudiante est1 = new Estudiante("Max Mustermann", 123456);
Estudiante est2 = new Estudiante("Erika Mustermann", 234567);
// Llamar métodos no estáticos a través de objetos
est1.estudiar();
est2.estudiar();
// Llamar métodos estáticos a través de la clase
System.out.println("Cantidad estudiantes: " + Estudiante.getCantidadEstudiantes());
System.out.println("Universidad: " + Estudiante.getUniversidad());
// Método estático de utilidad
boolean valida = Estudiante.esMatriculaValida(123456);
System.out.println("Matrícula válida: " + valida);
// Acceso a atributos no estáticos
System.out.println("Nombre estudiante 1: " + est1.getNombre());
// ERROR: No se puede llamar método estático en objeto
// Estudiante.getNombre(); // Error de compilación
}
}
4. Clases genéricas (Generics) - por ejemplo List<T>
¿Cuál es el problema?
Antes de los Generics, las colecciones (como ArrayList) se definían para el tipo Object. Se podía insertar cualquier cosa (Strings, Integers, etc.). Al extraer datos, había que verificar el tipo manualmente mediante casting y reconvertir (String s = (String) myList.get(0);). Los errores en tiempo de ejecución eran frecuentes.
¿Cuál es la solución?
Las clases genéricas. Se trata de plantillas de clase que utilizan uno o varios marcadores de posición para tipos de datos (frecuentemente T como “Type”, E como “Element”).
Propósito: Seguridad de tipos en tiempo de compilación. El compilador verifica que solo se inserten objetos del tipo correcto. Se elimina el casting y se previenen los errores en tiempo de ejecución.
// Ejemplo de Generics
// Sin Generics (anticuado, propenso a errores)
List myOldList = new ArrayList();
myOldList.add("Hola");
myOldList.add(123); // El compilador no dice nada, pero...
String s = (String) myOldList.get(1); // Error en tiempo de ejecución: ClassCastException!
// Con Generics (typsicher)
List<String> myList = new ArrayList<>(); // T se convierte en String
myList.add("Hola");
// myList.add(123); // ERROR DE COMPILACIÓN: 123 no es un String!
String s = myList.get(0); // Sin casting necesario, seguro.
// Clase genérica personalizada
public class Box<T> {
private T inhalt;
public void setInhalt(T inhalt) {
this.inhalt = inhalt;
}
public T getInhalt() {
return inhalt;
}
public boolean istLeer() {
return inhalt == null;
}
}
// Uso de la clase genérica
public class GenericsBeispiel {
public static void main(String[] args) {
// Box para Strings
Box<String> stringBox = new Box<>();
stringBox.setInhalt("Hola Mundo");
String inhalt = stringBox.getInhalt(); // Sin casting necesario
// Box para Integer
Box<Integer> integerBox = new Box<>();
integerBox.setInhalt(42);
Integer zahl = integerBox.getInhalt(); // Sin casting necesario
// Box para objetos propios
Box<Student> studentBox = new Box<>();
studentBox.setInhalt(new Student("Max", 123456));
Student student = studentBox.getInhalt();
System.out.println("String Box: " + inhalt);
System.out.println("Integer Box: " + zahl);
System.out.println("Student Box: " + student.getName());
}
}
// Métodos genéricos
public class Utility {
// Método genérico para intercambiar
public static <T> void tausche(T[] array, int i, int j) {
T temp = array[i];
array[i] = array[j];
array[j] = temp;
}
// Método genérico para máximo
public static <T extends Comparable<T>> T maximum(T x, T y, T z) {
T max = x;
if (y.compareTo(max) > 0) max = y;
if (z.compareTo(max) > 0) max = z;
return max;
}
}
5. Ventajas de los contenedores genéricos (Templates en C++) frente a arrays
| Característica | Arrays | Contenedores genéricos (p. ej. ArrayList<T>, List<T>) |
|---|---|---|
| Tamaño | Estático/Fijo. El tamaño se define al crear el array y no puede cambiar después | Dinámico/Creciente. El tamaño se adapta automáticamente según la cantidad de elementos |
| Seguridad de tipos | Ofrecen seguridad de tipos básica, pero solo pueden almacenar un tipo fijo | Ofrecen seguridad de tipos completa en tiempo de compilación mediante Generics |
| Funcionalidad | Muy limitada. Operaciones básicas (lectura, escritura en índice) | Ofrecen muchos métodos útiles: .add(), .remove(), .contains(), .size(), etc. |
| Rendimiento | Muy rápido para acceso directo por índice | Algo más lento debido al overhead de la gestión dinámica, pero generalmente insignificante |
| Flexibilidad | Baja | Muy alta. Existen diferentes contenedores para diferentes propósitos (listas, conjuntos, mapas, colas) |
// Comparación: Array vs. ArrayList
public class ArrayVsContainer {
public static void main(String[] args) {
// Array - tamaño fijo
String[] namenArray = new String[3];
namenArray[0] = "Alice";
namenArray[1] = "Bob";
namenArray[2] = "Charlie";
// namenArray[3] = "David"; // ERROR: ArrayIndexOutOfBoundsException!
// ArrayList - tamaño dinámico
ArrayList<String> namenList = new ArrayList<>();
namenList.add("Alice");
namenList.add("Bob");
namenList.add("Charlie");
namenList.add("David"); // Sin problema!
namenList.add("Eve"); // Añade tantos elementos como sea necesario!
// Comparación de funcionalidad
System.out.println("Longitud del Array: " + namenArray.length);
System.out.println("Tamaño del ArrayList: " + namenList.size());
// ArrayList tiene más métodos
namenList.remove("Bob"); // Eliminar elemento
boolean enthaeltAlice = namenList.contains("Alice"); // Verificar
Collections.sort(namenList); // Ordenar
System.out.println("ArrayList después de eliminar y ordenar: " + namenList);
}
}
Ventaja de síntesis: Los contenedores genéricos combinan la seguridad de tipos con la flexibilidad de una estructura de datos dinámica, lo que los hace superiores a los arrays en prácticamente cualquier caso de uso.
Resumen para la prueba IHK
- Componentes de una clase: Nombre, atributos, constructor, métodos
- Relaciones:
- Asociación: Relación “conoce”
- Agregación: “tiene-un” (suelta, la parte sigue existiendo)
- Composición: “consta-de” (fuerte, la parte se destruye)
- Herencia: “es-un” (Generalización/Especialización)
- Estático vs. No-Estático: Clase vs. Objeto
- Generics: Hacen las clases type-safe al utilizar marcadores de posición para tipos de datos
- Contenedores vs. Arrays: Los contenedores son dinámicos, type-safe y más funcionales
Conceptos relevantes para la prueba
Distinciones importantes
| Concepto | Descripción | Símbolo UML | Ciclo de vida |
|---|---|---|---|
| Asociación | Relación “conoce” entre clases independientes | Línea | Independiente |
| Agregación | Relación “tiene-un”, acoplamiento débil | Línea con rombo vacío | Independiente |
| Composición | Relación “consta-de”, acoplamiento fuerte | Línea con rombo lleno | Dependiente |
| Herencia | Relación “es-un”, reutilización de código | Línea con flecha hueca | Heredado |
Tareas típicas de prueba
- Dibuja diagramas de clases UML con diferentes relaciones
- Implementa asociación, agregación y composición
- Explica la diferencia entre miembros estáticos y no-estáticos
- Utiliza Generics para contenedores type-safe
- Compara arrays con contenedores genéricos
Estos conceptos son fundamentales para comprender la arquitectura de software orientada a objetos y forman la base para diseños de sistemas complejos.
Continúa en la ruta de aprendizaje OOP
Todos los artículos OOP están completos. Regresa al primer artículo: Objektorientierte Programmierung OOP Grundlagen.

