Patrones de diseño comunes en Java
Introducción
Los patrones de diseño son soluciones probadas y documentadas a problemas recurrentes en el desarrollo de software. En el mundo de Java, estos patrones son especialmente relevantes ya que nos permiten crear aplicaciones más mantenibles, extensibles y robustas sin tener que "reinventar la rueda". Funcionan como plantillas que pueden ser adaptadas para resolver problemas específicos en diferentes contextos, y representan las mejores prácticas acumuladas por la comunidad de desarrolladores a lo largo de décadas.
En este artículo exploraremos los patrones de diseño más comunes utilizados en Java, analizando su estructura, casos de uso y ejemplos prácticos que podrás implementar en tus propios proyectos. Dominar estos patrones no solo mejorará la calidad de tu código, sino que también te permitirá comunicarte de manera más efectiva con otros desarrolladores utilizando un vocabulario común.
Categorías de patrones de diseño
Los patrones de diseño se dividen tradicionalmente en tres categorías principales:
Patrones creacionales
Los patrones creacionales se enfocan en los mecanismos de creación de objetos, intentando crear objetos de manera adecuada según la situación. Ayudan a hacer un sistema independiente de cómo se crean, componen y representan sus objetos.
Patrón Singleton
El patrón Singleton garantiza que una clase tenga una única instancia y proporciona un punto de acceso global a ella. Es útil cuando necesitas exactamente un objeto para coordinar acciones en todo el sistema, como en gestores de configuración o pools de conexiones.
public class Configuracion {
// La única instancia de la clase
private static Configuracion instancia;
// Variables de configuración
private String urlBaseDatos;
private int tiempoEspera;
// Constructor privado para evitar instanciación externa
private Configuracion() {
// Inicialización por defecto
urlBaseDatos = "jdbc:mysql://localhost:3306/mibd";
tiempoEspera = 30;
}
// Método para obtener la instancia única
public static synchronized Configuracion getInstancia() {
if (instancia == null) {
instancia = new Configuracion();
}
return instancia;
}
// Métodos getter y setter
public String getUrlBaseDatos() {
return urlBaseDatos;
}
public void setUrlBaseDatos(String urlBaseDatos) {
this.urlBaseDatos = urlBaseDatos;
}
public int getTiempoEspera() {
return tiempoEspera;
}
public void setTiempoEspera(int tiempoEspera) {
this.tiempoEspera = tiempoEspera;
}
}
Uso del Singleton:
public class AplicacionPrincipal {
public static void main(String[] args) {
// Acceder a la configuración desde cualquier parte de la aplicación
Configuracion config = Configuracion.getInstancia();
// Usar la configuración
System.out.println("URL de la BD: " + config.getUrlBaseDatos());
// Modificar la configuración
config.setTiempoEspera(60);
// La misma instancia refleja los cambios
System.out.println("Tiempo de espera: " + config.getTiempoEspera());
}
}
Patrón Factory Method
Este patrón define una interfaz para crear objetos, pero deja que las subclases decidan qué clase instanciar. Permite que una clase delegue la creación de objetos a sus subclases.
// Producto abstracto
interface Documento {
void abrir();
void guardar();
void cerrar();
}
// Productos concretos
class DocumentoTexto implements Documento {
@Override
public void abrir() {
System.out.println("Abriendo documento de texto");
}
@Override
public void guardar() {
System.out.println("Guardando documento de texto");
}
@Override
public void cerrar() {
System.out.println("Cerrando documento de texto");
}
}
class DocumentoPDF implements Documento {
@Override
public void abrir() {
System.out.println("Abriendo documento PDF");
}
@Override
public void guardar() {
System.out.println("Guardando documento PDF");
}
@Override
public void cerrar() {
System.out.println("Cerrando documento PDF");
}
}
// Creador abstracto
abstract class EditorDocumentos {
// Factory Method
public abstract Documento crearDocumento();
// Operación que usa el factory method
public void editarDocumento() {
// Crear documento usando el factory method
Documento doc = crearDocumento();
// Usar el documento
doc.abrir();
// Realizar ediciones...
doc.guardar();
doc.cerrar();
}
}
// Creadores concretos
class EditorTexto extends EditorDocumentos {
@Override
public Documento crearDocumento() {
return new DocumentoTexto();
}
}
class EditorPDF extends EditorDocumentos {
@Override
public Documento crearDocumento() {
return new DocumentoPDF();
}
}
Uso del Factory Method:
public class AplicacionDocumentos {
public static void main(String[] args) {
// Crear un editor de texto
EditorDocumentos editorTexto = new EditorTexto();
editorTexto.editarDocumento();
// Crear un editor de PDF
EditorDocumentos editorPDF = new EditorPDF();
editorPDF.editarDocumento();
}
}
Patrón Builder
El patrón Builder separa la construcción de un objeto complejo de su representación, permitiendo el mismo proceso de construcción para crear diferentes representaciones. Es útil cuando un objeto tiene muchos atributos, algunos opcionales.
class Pizza {
// Atributos obligatorios
private String masa;
private String salsa;
// Atributos opcionales
private boolean queso;
private boolean champiñones;
private boolean pepperoni;
private boolean jamon;
// Constructor privado - solo accesible a través del Builder
private Pizza(Builder builder) {
this.masa = builder.masa;
this.salsa = builder.salsa;
this.queso = builder.queso;
this.champiñones = builder.champiñones;
this.pepperoni = builder.pepperoni;
this.jamon = builder.jamon;
}
// Clase Builder
public static class Builder {
// Atributos obligatorios
private final String masa;
private final String salsa;
// Atributos opcionales - con valores por defecto
private boolean queso = false;
private boolean champiñones = false;
private boolean pepperoni = false;
private boolean jamon = false;
// Constructor con parámetros obligatorios
public Builder(String masa, String salsa) {
this.masa = masa;
this.salsa = salsa;
}
// Métodos para establecer atributos opcionales
public Builder conQueso() {
this.queso = true;
return this;
}
public Builder conChampiñones() {
this.champiñones = true;
return this;
}
public Builder conPepperoni() {
this.pepperoni = true;
return this;
}
public Builder conJamon() {
this.jamon = true;
return this;
}
// Método para construir el objeto final
public Pizza build() {
return new Pizza(this);
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Pizza con masa ").append(masa);
sb.append(" y salsa ").append(salsa);
if (queso) sb.append(", con queso");
if (champiñones) sb.append(", con champiñones");
if (pepperoni) sb.append(", con pepperoni");
if (jamon) sb.append(", con jamón");
return sb.toString();
}
}
Uso del patrón Builder:
public class PizzeriaApp {
public static void main(String[] args) {
// Crear diferentes pizzas usando el mismo proceso
Pizza pizzaMargarita = new Pizza.Builder("fina", "tomate")
.conQueso()
.build();
Pizza pizzaCuatroQuesos = new Pizza.Builder("gruesa", "tomate")
.conQueso()
.conChampiñones()
.build();
Pizza pizzaCompleta = new Pizza.Builder("normal", "barbacoa")
.conQueso()
.conChampiñones()
.conPepperoni()
.conJamon()
.build();
System.out.println(pizzaMargarita);
System.out.println(pizzaCuatroQuesos);
System.out.println(pizzaCompleta);
}
}
Patrones estructurales
Los patrones estructurales se ocupan de cómo se componen las clases y objetos para formar estructuras más grandes y complejas.
Patrón Adapter
El patrón Adapter permite que interfaces incompatibles trabajen juntas, convirtiendo la interfaz de una clase en otra que el cliente espera.
// Interfaz que el cliente espera usar
interface ReproductorMultimedia {
void reproducir(String archivo);
void detener();
}
// Clase existente con interfaz incompatible
class ReproductorMP3 {
public void iniciarReproduccion(String nombreArchivo) {
System.out.println("Reproduciendo archivo MP3: " + nombreArchivo);
}
public void pausarReproduccion() {
System.out.println("Reproducción de MP3 en pausa");
}
public void detenerReproduccion() {
System.out.println("Reproducción de MP3 detenida");
}
}
// Adaptador que hace que ReproductorMP3 funcione como un ReproductorMultimedia
class AdaptadorMP3 implements ReproductorMultimedia {
private ReproductorMP3 reproductorMP3;
public AdaptadorMP3(ReproductorMP3 reproductorMP3) {
this.reproductorMP3 = reproductorMP3;
}
@Override
public void reproducir(String archivo) {
reproductorMP3.iniciarReproduccion(archivo);
}
@Override
public void detener() {
reproductorMP3.detenerReproduccion();
}
}
Uso del patrón Adapter:
public class AplicacionMusica {
public static void main(String[] args) {
// Crear el reproductor MP3 original
ReproductorMP3 reproductorMP3 = new ReproductorMP3();
// Crear el adaptador
ReproductorMultimedia adaptador = new AdaptadorMP3(reproductorMP3);
// Usar la interfaz ReproductorMultimedia
adaptador.reproducir("cancion.mp3");
adaptador.detener();
}
}
Patrón Decorator
El patrón Decorator permite añadir responsabilidades adicionales a un objeto dinámicamente. Proporciona una alternativa flexible a la herencia para extender la funcionalidad.
// Componente base
interface Cafe {
String getDescripcion();
double getPrecio();
}
// Implementación concreta del componente
class CafeSimple implements Cafe {
@Override
public String getDescripcion() {
return "Café simple";
}
@Override
public double getPrecio() {
return 1.0;
}
}
// Decorador base
abstract class DecoradorCafe implements Cafe {
protected Cafe cafeDecorado;
public DecoradorCafe(Cafe cafe) {
this.cafeDecorado = cafe;
}
@Override
public String getDescripcion() {
return cafeDecorado.getDescripcion();
}
@Override
public double getPrecio() {
return cafeDecorado.getPrecio();
}
}
// Decoradores concretos
class DecoradorLeche extends DecoradorCafe {
public DecoradorLeche(Cafe cafe) {
super(cafe);
}
@Override
public String getDescripcion() {
return cafeDecorado.getDescripcion() + ", con leche";
}
@Override
public double getPrecio() {
return cafeDecorado.getPrecio() + 0.5;
}
}
class DecoradorAzucar extends DecoradorCafe {
public DecoradorAzucar(Cafe cafe) {
super(cafe);
}
@Override
public String getDescripcion() {
return cafeDecorado.getDescripcion() + ", con azúcar";
}
@Override
public double getPrecio() {
return cafeDecorado.getPrecio() + 0.2;
}
}
class DecoradorCanela extends DecoradorCafe {
public DecoradorCanela(Cafe cafe) {
super(cafe);
}
@Override
public String getDescripcion() {
return cafeDecorado.getDescripcion() + ", con canela";
}
@Override
public double getPrecio() {
return cafeDecorado.getPrecio() + 0.3;
}
}
Uso del patrón Decorator:
public class Cafeteria {
public static void main(String[] args) {
// Crear un café simple
Cafe miCafe = new CafeSimple();
System.out.println(miCafe.getDescripcion() + " - €" + miCafe.getPrecio());
// Decorar con leche
miCafe = new DecoradorLeche(miCafe);
System.out.println(miCafe.getDescripcion() + " - €" + miCafe.getPrecio());
// Decorar con azúcar
miCafe = new DecoradorAzucar(miCafe);
System.out.println(miCafe.getDescripcion() + " - €" + miCafe.getPrecio());
// Crear otro café con diferentes combinaciones
Cafe otroCafe = new DecoradorCanela(new DecoradorLeche(new CafeSimple()));
System.out.println(otroCafe.getDescripcion() + " - €" + otroCafe.getPrecio());
}
}
Patrones de comportamiento
Los patrones de comportamiento se ocupan de la comunicación entre objetos, cómo interactúan y distribuyen responsabilidades.
Patrón Observer
El patrón Observer define una dependencia uno a muchos entre objetos, de modo que cuando un objeto cambia de estado, todos sus dependientes son notificados y actualizados automáticamente.
import java.util.ArrayList;
import java.util.List;
// Interfaz para el observador
interface Observador {
void actualizar(String mensaje);
}
// Clase concreta de observador
class Usuario implements Observador {
private String nombre;
public Usuario(String nombre) {
this.nombre = nombre;
}
@Override
public void actualizar(String mensaje) {
System.out.println(nombre + " ha recibido una notificación: " + mensaje);
}
}
// Sujeto observado
class CanalNoticias {
private List<Observador> suscriptores = new ArrayList<>();
private String nombre;
private String ultimaNoticia;
public CanalNoticias(String nombre) {
this.nombre = nombre;
}
// Suscribir observador
public void suscribir(Observador observador) {
suscriptores.add(observador);
System.out.println("Se ha suscrito un nuevo observador al canal " + nombre);
}
// Desuscribir observador
public void desuscribir(Observador observador) {
suscriptores.remove(observador);
System.out.println("Un observador ha cancelado su suscripción al canal " + nombre);
}
// Notificar a todos los observadores
private void notificarObservadores() {
for (Observador observador : suscriptores) {
observador.actualizar(ultimaNoticia);
}
}
// Cambio de estado que dispara la notificación
public void publicarNoticia(String noticia) {
this.ultimaNoticia = "ÚLTIMA HORA [" + nombre + "]: " + noticia;
System.out.println("Nueva noticia publicada en " + nombre);
notificarObservadores();
}
}
Uso del patrón Observer:
public class AplicacionNoticias {
public static void main(String[] args) {
// Crear el sujeto observado
CanalNoticias canal = new CanalNoticias("Canal 24h");
// Crear observadores
Observador usuario1 = new Usuario("Carlos");
Observador usuario2 = new Usuario("Ana");
Observador usuario3 = new Usuario("Miguel");
// Suscribir observadores
canal.suscribir(usuario1);
canal.suscribir(usuario2);
canal.suscribir(usuario3);
// Publicar noticia - todos los observadores son notificados
canal.publicarNoticia("Descubierto nuevo planeta habitable");
// Desuscribir un observador
canal.desuscribir(usuario2);
// Publicar otra noticia - solo los suscriptores restantes son notificados
canal.publicarNoticia("Avances en la cura contra el cáncer");
}
}
Patrón Strategy
El patrón Strategy define una familia de algoritmos, encapsula cada uno y los hace intercambiables. Permite que el algoritmo varíe independientemente de los clientes que lo utilizan.
// Interfaz Strategy
interface EstrategiaOrdenamiento {
void ordenar(int[] array);
}
// Implementaciones concretas de la estrategia
class OrdenamientoBurbuja implements EstrategiaOrdenamiento {
@Override
public void ordenar(int[] array) {
System.out.println("Ordenando con algoritmo de burbuja");
// Implementación del algoritmo de burbuja
for (int i = 0; i < array.length - 1; i++) {
for (int j = 0; j < array.length - i - 1; j++) {
if (array[j] > array[j + 1]) {
// Intercambiar elementos
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
}
class OrdenamientoInsercion implements EstrategiaOrdenamiento {
@Override
public void ordenar(int[] array) {
System.out.println("Ordenando con algoritmo de inserción");
// Implementación del algoritmo de inserción
for (int i = 1; i < array.length; i++) {
int valorActual = array[i];
int j = i - 1;
while (j >= 0 && array[j] > valorActual) {
array[j + 1] = array[j];
j--;
}
array[j + 1] = valorActual;
}
}
}
class OrdenamientoRapido implements EstrategiaOrdenamiento {
@Override
public void ordenar(int[] array) {
System.out.println("Ordenando con algoritmo rápido (QuickSort)");
quickSort(array, 0, array.length - 1);
}
private void quickSort(int[] array, int inicio, int fin) {
if (inicio < fin) {
int indiceParticion = particionar(array, inicio, fin);
quickSort(array, inicio, indiceParticion - 1);
quickSort(array, indiceParticion + 1, fin);
}
}
private int particionar(int[] array, int inicio, int fin) {
int pivote = array[fin];
int i = inicio - 1;
for (int j = inicio; j < fin; j++) {
if (array[j] <= pivote) {
i++;
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
int temp = array[i + 1];
array[i + 1] = array[fin];
array[fin] = temp;
return i + 1;
}
}
// Contexto que utiliza una estrategia
class OrdenadorArray {
private EstrategiaOrdenamiento estrategia;
// Constructor que permite inyectar la estrategia
public OrdenadorArray(EstrategiaOrdenamiento estrategia) {
this.estrategia = estrategia;
}
// Cambiar la estrategia en tiempo de ejecución
public void setEstrategia(EstrategiaOrdenamiento estrategia) {
this.estrategia = estrategia;
}
// Usar la estrategia
public void ordenarArray(int[] array) {
estrategia.ordenar(array);
}
// Método para imprimir el array
public static void imprimirArray(int[] array) {
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
System.out.println();
}
}
Uso del patrón Strategy:
public class AplicacionOrdenamiento {
public static void main(String[] args) {
// Array a ordenar
int[] numeros = {64, 34, 25, 12, 22, 11, 90};
// Mostrar array original
System.out.println("Array original:");
OrdenadorArray.imprimirArray(numeros);
// Crear un ordenador con la estrategia de burbuja
OrdenadorArray ordenador = new OrdenadorArray(new OrdenamientoBurbuja());
// Ordenar con la estrategia actual
ordenador.ordenarArray(numeros);
System.out.println("Array ordenado con algoritmo de burbuja:");
OrdenadorArray.imprimirArray(numeros);
// Desordenar el array nuevamente
numeros = new int[]{64, 34, 25, 12, 22, 11, 90};
// Cambiar la estrategia
ordenador.setEstrategia(new OrdenamientoRapido());
// Ordenar con la nueva estrategia
ordenador.ordenarArray(numeros);
System.out.println("Array ordenado con algoritmo rápido:");
OrdenadorArray.imprimirArray(numeros);
}
}
Aplicación de patrones en el mundo real
Los patrones de diseño no son solo conceptos teóricos, sino soluciones prácticas que se utilizan ampliamente en aplicaciones reales y frameworks populares en Java. Veamos algunos ejemplos:
-
Spring Framework: Utiliza el patrón Singleton para manejar beans, Dependency Injection para reducir el acoplamiento, y Factory para crear objetos.
-
Java Collections: La clase
Collections
actúa como una Factory para diferentes tipos de colecciones. También se utiliza el patrón Iterator para recorrer colecciones. -
Java I/O: El sistema de entrada/salida de Java utiliza el patrón Decorator (
BufferedReader
,InputStreamReader
, etc.) para añadir funcionalidades. -
Swing: La biblioteca de UI de Java utiliza Observer para el manejo de eventos (listeners) y Composite para construir interfaces de usuario complejas.
Cuándo utilizar cada patrón
Elegir el patrón adecuado para cada situación es crucial. Aquí algunas pautas:
-
Singleton: Cuando necesites exactamente una instancia de una clase, como gestores de conexiones, pools o configuraciones.
-
Factory: Cuando la creación de un objeto involucre lógica compleja o cuando quieras ocultar los detalles de creación.
-
Builder: Cuando tengas objetos complejos con muchos atributos opcionales o cuando quieras hacer inmutable un objeto con muchos parámetros.
-
Adapter: Cuando necesites hacer funcionar interfaces incompatibles o integrar bibliotecas externas.
-
Decorator: Cuando quieras añadir responsabilidades a objetos de forma dinámica y transparente.
-
Observer: Cuando un cambio en un objeto requiera cambios en otros y no quieras acoplarlos fuertemente.
-
Strategy: Cuando tengas una familia de algoritmos que deben ser intercambiables según la situación.
Resumen
Los patrones de diseño son herramientas esenciales en el arsenal de todo desarrollador Java. Nos permiten resolver problemas comunes de diseño de manera elegante y efectiva, facilitando la creación de código más mantenible, reutilizable y extensible. En este artículo hemos explorado algunos de los patrones más utilizados: Singleton, Factory Method, Builder, Adapter, Decorator, Observer y Strategy.
Cada patrón tiene su propio propósito y contexto de aplicación, y dominar cuándo y cómo utilizarlos te convertirá en un desarrollador más competente. Recuerda que los patrones no son soluciones rígidas, sino plantillas flexibles que deben adaptarse a tus necesidades específicas. La práctica y la experiencia te ayudarán a desarrollar un instinto para aplicar el patrón adecuado en cada situación.