Qué es SOLID
A medida que los sistemas de software crecen, el código tiende a volverse más complejo, más difícil de mantener y propenso a errores. Eso no es una falla de los programadores: es una consecuencia de agregar funcionalidad sobre algo que ya existe. Para atacar ese problema, la ingeniería de software desarrolló un conjunto de principios de diseño orientados a construir aplicaciones robustas, mantenibles y escalables.
Entre los más importantes están los principios SOLID, propuestos por Robert C. Martin —conocido como Uncle Bob— y hoy parte de los fundamentos del desarrollo profesional. SOLID es un acrónimo, una letra por principio:
| Letra | Principio | En una frase |
|---|---|---|
| S | Single Responsibility Principle | Una clase, una responsabilidad. |
| O | Open/Closed Principle | Abierta para extender, cerrada para modificar. |
| L | Liskov Substitution Principle | La subclase reemplaza a la base sin romper nada. |
| I | Interface Segregation Principle | Nadie depende de métodos que no usa. |
| D | Dependency Inversion Principle | Depender de abstracciones, no de implementaciones. |
Aplicarlos da como resultado software más mantenible, más escalable, más reutilizable, más testeable y menos acoplado. Son las cinco palabras que aparecen siempre en la bibliografía, y conviene entenderlas como consecuencias y no como objetivos sueltos.
Por qué importa: el ejemplo que crece
Supongamos una aplicación para gestionar alumnos en una universidad. Al comienzo, todo parece simple:
public class Alumno {
private int legajo;
private String nombre;
}
Pero después se agregan cosas: persistencia en archivos, persistencia en base de datos, exportación a PDF, envío de correos, generación de estadísticas. Sin una arquitectura adecuada, se termina con clases gigantes:
public class Alumno {
private int legajo;
private String nombre;
public void guardarBD() { }
public void guardarArchivo() { }
public void enviarEmail() { }
public void generarPDF() { }
public void generarEstadisticas() { }
}
Y eso trae tres problemas que se retroalimentan:
- Alto acoplamiento: la clase pasa a depender del motor de base de datos, del sistema de archivos, del servidor de correo y de la biblioteca de PDF. Un cambio en cualquiera de esos cuatro obliga a tocarla.
- Baja cohesión: adentro de
Alumnoconviven cosas que no tienen nada que ver entre sí. La clase deja de representar una sola idea. - Código difícil de modificar: para cambiar el formato del PDF hay que abrir la clase que representa a un alumno, y eso significa tocar el corazón del sistema.
Los principios SOLID buscan evitar exactamente esto. Y el diagnóstico se puede hacer antes de que el problema explote: hay dos medidas que lo anticipan.
Acoplamiento y cohesión
Para que los módulos de un programa sean comprensibles, mantenibles y reutilizables se busca siempre la misma combinación: módulos de bajo acoplamiento y de alta cohesión. Son dos cosas distintas que se miden en direcciones opuestas.
| Acoplamiento | Cohesión | |
|---|---|---|
| Qué mide | La relación con otros módulos. | La coherencia interna del módulo. |
| Desde dónde se mira | Visión externa: mira al sistema. | Visión interna, pero respecto del módulo solo. |
| Lo que se busca | Lo más bajo posible. | Lo más alta posible. |
Acoplamiento
Mide el grado de dependencia entre clases. Para lograr bajo acoplamiento, una clase debe tener pocas dependencias con otras, y así las modificaciones tienen efectos acotados. Los métodos deberían tener pocos parámetros y pocas llamadas a otros métodos: cada parámetro es algo que hay que conocer para poder usarlo.
Un ejemplo de acoplamiento alto:
public class PedidoService {
private EmailService email = new EmailService();
public void confirmarPedido() {
email.enviar();
}
}
El problema no es que use un servicio de correo, sino que lo construye él mismo con new. Queda atado a esa implementación: difícil de modificar —si mañana hay que avisar por WhatsApp, hay que abrir esta clase—, difícil de probar —no se puede reemplazar el envío de correo por un objeto de prueba— y difícil de reutilizar. La versión de bajo acoplamiento recibe la dependencia desde afuera:
public class PedidoService {
private Notificador notificador;
public PedidoService(Notificador notificador) {
this.notificador = notificador;
}
}
Esa diferencia —crear la dependencia adentro o recibirla— es el principio DIP, y también es lo que hace posible probar la clase sin depender del correo real.
Cohesión
Mide qué tan relacionadas están las responsabilidades de una clase. Para lograr alta cohesión, la clase debe representar una sola entidad: tiene que poder describírsela con un sustantivo. Y hay una prueba rápida para los métodos: cada método debería poder describirse con una oración que tenga un solo verbo activo. Si hacen falta dos verbos, el método hace dos cosas.
Baja cohesión, que es el caso del ejemplo del principio:
public class Cliente {
void guardar() { }
void generarFactura() { }
void enviarEmail() { }
void calcularEstadisticas() { }
}
Alta cohesión, que es el mismo problema repartido:
class Cliente { }
class ClienteRepository { }
class FacturaService { }
class EmailService { }
Fijate que la solución tiene más clases, no menos. Eso desconcierta al principio —parece que se agrega complejidad— pero cada clase queda chica, se puede entender de un vistazo, se puede probar sola y se puede reutilizar en otro lugar del sistema donde también haga falta enviar un correo.
SRP · Responsabilidad única
Una clase debe tener una única responsabilidad y un único motivo para cambiar.
Esa segunda parte de la frase es la que da la medida. No es «la clase hace una sola cosa» —algo vago—, sino «hay una sola razón por la que esta clase se modificaría». Y eso se puede verificar enumerando quién decide cada cambio.
El ejemplo incorrecto:
public class Pedido {
public void guardar() { }
public void imprimirFactura() { }
public void enviarMailConfirmacion() { }
}
El problema, dicho de la manera en que se nota: si cambia la base de datos, o el formato de la factura, o el sistema de correo, la misma clase tiene que modificarse. Son tres motivos de cambio independientes metidos en el mismo archivo, y cada vez que se toca se arriesga a romper los otros dos.
La versión que aplica SRP reparte eso en cuatro clases:
public class Pedido {
}
public class PedidoRepository {
public void guardar(Pedido pedido) { }
}
public class FacturaService {
public void imprimirFactura(Pedido pedido) { }
}
public class EmailService {
public void enviarConfirmacion(Pedido pedido) { }
}
Las ventajas: clases chicas, fácil mantenimiento, mayor reutilización y mejor testing. Y un detalle que aparece enseguida en la práctica: EmailService se puede usar para enviar cualquier correo del sistema, no solo la confirmación de un pedido. La reutilización no se buscó: salió sola.
OCP · Abierto y cerrado
Las entidades deben estar abiertas para la extensión y cerradas para la modificación.
O sea: se tiene que poder agregar comportamiento nuevo sin tocar el código que ya funciona. Suena contradictorio, y la salida es el polimorfismo de la lección 1.
El ejemplo incorrecto:
public class CalculadoraDescuento {
public double calcular(String tipo, double monto) {
if (tipo.equals("VIP")) {
return monto * 0.8;
}
if (tipo.equals("REGULAR")) {
return monto * 0.9;
}
return monto;
}
}
Cada descuento nuevo obliga a modificar la clase y agrega otro if a la cadena. Con diez tipos de cliente, ese método es una lista de diez condiciones que hay que leer entera para saber qué hace, y cada cambio puede afectar a los tipos que ya andaban. La clase está abierta a la modificación, que es justo lo que se quiere evitar.
La versión correcta mueve la decisión al polimorfismo:
public abstract class Descuento {
public abstract double aplicar(double monto);
}
public class DescuentoVip extends Descuento {
public double aplicar(double monto) {
return monto * 0.8;
}
}
public class DescuentoRegular extends Descuento {
public double aplicar(double monto) {
return monto * 0.9;
}
}
Ahora, para agregar un descuento nuevo no se toca ninguna clase existente: se crea una subclase más. La calculadora deja de tener condiciones y pasa a tener una sola línea. Las ventajas que da el apunte: menos riesgo de errores, código extensible y mantenimiento más fácil.
LSP · Sustitución de Liskov
Los objetos de una subclase deben poder reemplazar a los objetos de la clase base sin alterar el funcionamiento esperado. Y las clases base no deben tener comportamientos que dependan de las clases derivadas, ni siquiera de que existan.
Este es el principio que explica por qué una herencia mal armada es peor que no heredar. Es el que se anticipó en la lección 1 con el ejemplo del cuadrado y el rectángulo.
El ejemplo incorrecto:
public class Ave {
public void volar() {
System.out.println("Volando");
}
}
public class Pinguino extends Ave {
public void volar() {
throw new UnsupportedOperationException();
}
}
El problema no es que el pingüino no vuele: es que, por la herencia, se comprometió a volar y para zafar tira una excepción. Entonces este código, que es perfectamente razonable desde el punto de vista del tipo, falla:
Ave ave = new Pinguino();
ave.volar(); // produce una excepción
Un Pinguino no puede sustituir a un Ave, y la culpa no es del pingüino: es del diseño, que puso volar() donde no correspondía.
La solución del apunte es separar los comportamientos en interfaces distintas, que es lo mismo que se vio en la lección 3:
interface Ave {
void comer();
}
interface AveVoladora {
void volar();
}
class Paloma implements Ave, AveVoladora {
public void comer() {
System.out.println("comiendo semillas");
}
public void volar() {
System.out.println("Volando");
}
}
class Pinguino implements Ave {
public void comer() {
System.out.println("Comiendo");
}
}
Ahora la paloma es un ave y vuela, y el pingüino es un ave y no promete volar. Ya no hay ningún método que falle: la clase dice solo lo que puede cumplir. Nota sobre el apunte: en el original, los métodos de las interfaces aparecen con cuerpo. En una interfaz eso es un método default, que desde Java 8 se escribe con esa palabra reservada; la versión de arriba los declara sin cuerpo, que es la forma clásica y la que no deja lugar a dudas.
ISP · Segregación de interfaces
Ningún cliente debe depender de métodos que no utiliza.
El ejemplo incorrecto es el de la impresora multifunción:
public interface Multifuncion {
void imprimir();
void escanear();
void enviarFax();
}
public class ImpresoraBasica implements Multifuncion {
public void imprimir() {
}
public void escanear() {
throw new UnsupportedOperationException();
}
public void enviarFax() {
throw new UnsupportedOperationException();
}
}
El síntoma es el mismo que en LSP y es inconfundible: métodos que tiran UnsupportedOperationException. Significa que la clase está obligada a prometer cosas que no hace. Y hay un daño extra: quien programa contra Multifuncion no puede saber, mirando la interfaz, cuáles de los tres métodos funcionan.
La solución: una interfaz por capacidad, y cada clase implementa las que puede cumplir.
public interface Impresora {
void imprimir();
}
public interface Scanner {
void escanear();
}
public interface Fax {
void enviarFax();
}
public class ImpresoraBasica implements Impresora {
public void imprimir() { }
}
public class ImpresoraConEscanner implements Impresora, Scanner {
public void imprimir() { }
public void escanear() { }
}
Fijate que ImpresoraConEscanner implementa dos interfaces: es la ventaja de la lección 3 a favor del diseño. Cada interfaz es chica y dice una cosa; cada clase arma con ellas lo que realmente sabe hacer.
DIP · Inversión de dependencias
Los módulos de alto nivel no deben depender de los de bajo nivel. Ambos deben depender de abstracciones.
«Módulo de alto nivel» es el que tiene la lógica del negocio —PedidoService—, y «de bajo nivel» el que hace el trabajo de detalle —EmailService—. El problema del ejemplo incorrecto es que el de arriba construye al de abajo:
public class PedidoService {
private EmailService email = new EmailService();
}
La solución invierte la dirección de la dependencia: se define una abstracción y los dos módulos dependen de ella. El de alto nivel la usa, y los de bajo nivel la implementan.
public interface Notificador {
void enviar(String mensaje);
}
public class EmailNotificador implements Notificador {
public void enviar(String mensaje) {
System.out.println(mensaje);
}
}
public class WhatsappNotificador implements Notificador {
public void enviar(String mensaje) {
System.out.println(mensaje);
}
}
public class PedidoService {
private Notificador notificador;
public PedidoService(Notificador notificador) {
this.notificador = notificador;
}
}
El resultado práctico es que PedidoService ya no sabe si los avisos salen por correo, por WhatsApp o por cualquier otra cosa que aparezca después. Y probarlo es trivial: se le pasa un Notificador de mentira que no envía nada. En una línea, este principio es el mismo que el de SRP aplicado a las dependencias: que cada cosa se ocupe de lo suyo.
Qué es un patrón de diseño
Un patrón de diseño es una solución a un problema de diseño, considerada efectiva y reutilizable.
Los más conocidos fueron publicados en el libro Design Patterns, escrito por el grupo Gang of Four (GoF) —Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides—, que en 1995 recogió 23 patrones de diseño comunes. Son soluciones simples y elegantes a problemas específicos y frecuentes del diseño orientado a objetos, basadas en la experiencia y en que se ha demostrado que funcionan.
Otra forma de definirlo, más operativa: una situación conocida en la que hay un problema que resolver, y una forma de resolverlo que es apropiada para ese problema en esa situación. Cuando el problema que se tiene encaja en la situación que describe el patrón, el patrón da una solución que ya se sabe que funciona.
Un patrón de diseño define tres cosas:
- Las clases que participan.
- El rol de cada clase y su semántica.
- La interacción entre las clases.
Se pueden usar en cualquier lenguaje orientado a objetos, adaptando el diseño general a las características de la implementación. Y el GoF los clasificó según su propósito en tres categorías, que son las tres secciones que vienen: creacionales, estructurales y de comportamiento. En cada categoría hay dos estrategias: una basada en herencia —la «estrategia de clase»— y otra basada en asociación de objetos —la «estrategia de objeto»—.
Para qué sirven
- Ayudan a arrancar en el diseño de un programa complejo, que es el momento donde uno se queda mirando la pantalla en blanco.
- Dan una descomposición de objetos inicial ya pensada.
- Están pensados para que el programa sea escalable y fácil de mantener.
- Otras desarrolladoras y desarrolladores ya los usaron y les fue bien.
- Ayudan a reutilizar técnicas: se resuelve un problema nuevo con la forma del que ya se resolvió antes.
- Muchos programadores los conocen y ya saben cómo aplicarlos, lo que hace que el diseño sea un lenguaje común en el equipo.
- Manejan un alto nivel de abstracción: el mismo diseño se puede aplicar a situaciones distintas.
Patrones creacionales
Su objetivo es abstraer el proceso de instanciación de objetos y ocultar los detalles de cómo se crean o se inicializan. Dicho de otro modo: sacar los new del medio del código y ponerlos en un solo lugar, con nombre propio.
Abstract Factory
Provee una interfaz para crear familias de objetos relacionados o dependientes sin especificar su clase concreta.
La solución del apunte tiene tres pasos:
- Crear una clase Factory abstracta que provea una interfaz común de creación de las familias de objetos.
- Crear clases Factories que hereden de esa y que implementen los métodos creando las instancias concretas.
- En la aplicación, usar solo la clase Factory abstracta una vez determinada la familia de objetos a crear.
Consecuencias: independencia de las clases concretas y la posibilidad de intercambiar familias completas de objetos de manera rápida y transparente. El ejemplo típico es un juego o una aplicación con varios «estilos» visuales: se cambia la fábrica y cambian todos los botones, ventanas y menús a la vez, sin tocar el código que los usa.
Builder
Separa la construcción de un objeto complejo de su representación, de forma que el mismo proceso de construcción pueda crear representaciones diferentes. Se usa cuando un objeto tiene muchísimas partes y construirlo de una vez con un constructor con quince parámetros sería inmanejable.
Factory Method
Define una interfaz para crear un objeto pero permite a las subclases decidir qué objeto instanciar. Permite que una clase delegue en sus subclases la creación de objetos. Sus ventajas según el apunte: centraliza la creación, facilita la escalabilidad y abstrae al usuario de la instancia a crear.
Prototype
Especifica el tipo de objeto a crear usando una instancia como prototipo y copiándola para crear objetos nuevos. En lugar de construir desde cero, se clona un objeto que ya existe y está configurado. Se apoya en la copia de objetos, y permite lo que ya se vio con la serialización: fabricar objetos nuevos a partir de un modelo.
Singleton
Asegura que una clase dispone de una única instancia y provee un único punto de acceso global a la misma. Es necesario cuando hay clases que tienen que gestionar de manera centralizada un recurso: un archivo de configuración, un registro de eventos, un pool de conexiones.
La solución tiene tres partes:
- La propia clase es responsable de crear la única instancia.
- Permite el acceso global a esa instancia mediante un método de clase —o sea,
static, como se vio en la lección 3—. - Declara el constructor como privado para que no se pueda instanciar directamente.
Consecuencias: se logra el acceso controlado a la instancia, pero a cambio de «ensuciar» la clase con responsabilidades que no son las suyas. El apunte lo dice sin vueltas: se lo considera un patrón fácil pero intrusivo. Es de los pocos casos donde la bibliografía recomienda pensarlo dos veces antes de usarlo, porque un Singleton es, en la práctica, una variable global disfrazada.
Patrones estructurales
Se ocupan de cómo se componen las clases y los objetos para formar estructuras más grandes, manteniendo esas estructuras flexibles y eficientes.
Adapter
Convierte la interfaz de una clase en otra distinta, que es la que esperan los clientes. Permite que cooperen clases que de otra manera no podrían, porque tienen interfaces incompatibles. Es el adaptador de enchufe: del lado de adentro hay una cosa y del lado de afuera otra, y el adaptador traduce.
Bridge
Desvincula una abstracción de su implementación, de manera que ambas puedan variar de forma independiente. Sirve para que dos dimensiones de cambio no se multipliquen entre sí.
Composite
Combina objetos en estructuras de árbol para representar jerarquías de parte-todo. Permite que los clientes traten de manera uniforme a los objetos individuales y a los compuestos. Sirve para diseñar clases que agrupen objetos complejos, los cuales a su vez están formados por objetos complejos o simples.
Motivación: las aplicaciones gráficas, como los editores de dibujo, permiten agrupar componentes simples en componentes más grandes. Y ahí está lo valioso del patrón: el código que trabaja con la figura agrupada es el mismo que trabaja con la figura suelta.
Aplicabilidad: cuando se quieren representar jerarquías parte-todo y que los clientes puedan obviar la diferencia entre una composición de objetos y un objeto individual.
Beneficios: define jerarquías formadas por objetos primitivos y compuestos, simplifica el cliente y facilita agregar nuevos tipos de componentes. Desventaja: puede hacer que un diseño sea demasiado general, que es un riesgo que comparten varios patrones.
Decorator
Agrega dinámicamente nuevas responsabilidades a un objeto, proporcionando una alternativa flexible a la herencia para extender la funcionalidad.
Motivación: las interfaces de usuario a las que se les agregan propiedades o comportamientos, como los cambios de «piel» de una aplicación.
Aplicabilidad: para agregar responsabilidades a objetos individuales de forma dinámica y transparente, sin afectar a los otros; para responsabilidades que pueden retirarse; y cuando extender mediante herencia no es viable.
Beneficios: más flexibilidad que la herencia estática, evita clases cargadas de funciones en la parte alta de la jerarquía, y respeta OCP: las clases quedan abiertas a la extensión y cerradas a la modificación, con menos errores al agregar funcionalidad y menos costo de mantenimiento. Desventajas: un decorador y su componente no son idénticos, y el diseño termina lleno de objetos pequeños.
Este patrón ya se usó sin saberlo en la lección 4: el BufferedReader que envuelve a un InputStreamReader que envuelve a un System.in es un decorador. Cada capa le agrega una capacidad al mismo flujo.
Facade
Proporciona una interfaz unificada para un conjunto de interfaces de un subsistema. Define una interfaz de alto nivel que hace que el subsistema sea más fácil de usar. Simplifica el acceso a un conjunto de clases, dando una única clase que todos usan para comunicarse con ese conjunto.
Motivación: minimizar la comunicación y las dependencias entre subsistemas.
Aplicabilidad: cuando se quiere dar una interfaz simple para un subsistema complejo, cuando hay muchas dependencias entre los clientes y las clases que implementan una abstracción, y cuando se quieren dividir los subsistemas en capas.
Beneficios: oculta a los clientes los componentes del subsistema —menos objetos con los que tratar— y promueve un débil acoplamiento. Y algo importante: no impide que las aplicaciones usen las clases del subsistema directamente cuando haga falta. Permite elegir entre facilidad de uso y generalidad.
Flyweight
Usa el compartimiento para permitir un gran número de objetos de grano fino de forma eficiente. La idea es no repetir en memoria lo que se puede compartir.
Proxy
Proporciona un sustituto o representante de otro objeto para controlar el acceso a éste.
Solución: crear una jerarquía donde intervengan el objeto original y el objeto proxy; que el proxy tenga una referencia al original; y redefinir todas las llamadas en el proxy incorporando código antes de derivarlas al original.
Motivación: retrasar el costo de creación e inicialización hasta que sea realmente necesario, como pasa con un archivo que contiene imágenes.
Consecuencias: agregar funcionalidad de manera transparente, y permitir optimizaciones, ocultar complejidad y establecer mecanismos de seguridad en los accesos.
Patrones de comportamiento
Estos patrones se ocupan de cómo se reparten las responsabilidades y cómo se comunican los objetos. El apunte agrupa los problemas que resuelven en dos familias:
- Recursos compartidos. Cuando varias operaciones que ocurren a la vez acceden a los mismos datos o recursos, puede pasar que interfieran entre sí. Para evitar eso, la operación tiene que estar protegida y acceder al recurso en solitario. Pero si todas las operaciones están completamente protegidas, pueden bloquearse entre sí y no terminar nunca: eso es un bloqueo, donde cada operación espera que la otra haga algo antes de seguir, y ambas esperan para siempre. Este es el problema que se resuelve con los mecanismos de sincronización de la lección 6.
- Secuencia de operaciones. Si las operaciones acceden a un recurso compartido de a una, puede hacer falta garantizar que lo hagan en un orden particular. Por ejemplo, un objeto nunca debería borrarse de una estructura antes de haber sido agregado a ella.
Chain of Responsibility
Evita acoplar el emisor de una petición a su receptor, dando a más de un objeto la posibilidad de responderla. Crea una cadena con los objetos receptores y pasa la petición a través de la cadena hasta que alguno la trate. El caso clásico es un sistema de aprobación: la solicitud pasa por el jefe de departamento, después por el gerente, después por el director, y la resuelve el primero que tenga autoridad para hacerlo.
Command
Encapsula una petición en un objeto, permitiendo parametrizar a los clientes con distintas peticiones, encolar o llevar un registro de las peticiones y poder deshacer las operaciones. Desacopla el código que solicita un servicio del que lo presta.
Solución: crear una clase abstracta o una interfaz con un solo método, execute(); que cada clase descendiente implemente ese método; y que para invocarlo se instancie una de las clases y se llame al método.
Motivación: objetos como botones y menús, que tienen que realizar una petición en respuesta a una entrada del usuario, y las transacciones.
Consecuencias: permite mantener referencias a métodos; desacopla el objeto que invoca la operación del que sabe cómo realizarla; las órdenes son objetos de primera clase, o sea que se pueden manipular y extender como cualquier otro objeto; se pueden ensamblar órdenes en una orden compuesta; y es fácil agregar órdenes nuevas, porque no hay que cambiar las clases que ya existen.
Interpreter
Dado un lenguaje, define una representación de su gramática junto con un intérprete que usa esa representación para interpretar las sentencias del lenguaje.
Iterator
Proporciona un modo de acceder secuencialmente a los elementos de un objeto agregado sin exponer su representación interna. Es exactamente lo que hace el Iterator de la lección 4: se recorre una lista sin saber si por dentro es un arreglo o una lista enlazada.
Mediator
Define un objeto que encapsula cómo interactúa un conjunto de objetos. Promueve un bajo acoplamiento al evitar que los objetos se refieran unos a otros explícitamente, y permite variar la interacción entre ellos de forma independiente.
Memento
Representa y externaliza el estado interno de un objeto sin violar la encapsulación, de forma que después pueda volver a ese estado. Es la base de las funciones de deshacer: se guarda una «foto» del objeto y se puede restaurar.
Observer
Define una dependencia de uno a muchos entre objetos, de forma que cuando un objeto cambia de estado se notifica y actualizan automáticamente todos los objetos. Es el mecanismo que está detrás de los eventos de las interfaces gráficas: el botón avisa a todos los que estén escuchando.
State
Permite que un objeto modifique su comportamiento cada vez que cambia su estado interno. Va a parecer que cambia la clase del objeto.
Motivación: una conexión TCP, que tiene tres estados: establecida, escuchando y cerrada.
Aplicabilidad: cuando el comportamiento de un objeto depende de su estado y tiene que cambiar en tiempo de ejecución; y cuando las operaciones tienen largas sentencias condicionales con múltiples ramas que dependen del estado, que se suele representar con constantes.
Beneficios: localiza el comportamiento según el estado y lo divide en clases distintas; hace explícitas las transiciones entre estados; y los objetos de estado se pueden compartir. Es importante compararlo con el switch de la lección 2: lo que acá se convierte en clases, allá era una estructura de control gigante.
Strategy
Define una familia de algoritmos, encapsula uno de ellos y los hace intercambiables. Permite que un algoritmo varíe independientemente de los clientes que lo usan.
Necesidad: un mismo objeto debe poder tener un comportamiento que se determina en tiempo de ejecución.
Solución: delegar el comportamiento en otro objeto; armar una jerarquía con los diferentes comportamientos; e inyectar el comportamiento al objeto a través de un método o del constructor —que es exactamente lo que hace el DIP—.
Consecuencias: se eliminan los condicionales; se crea una jerarquía paralela a la jerarquía base; y los comportamientos quedan agrupados por familias. Y dos advertencias honestas del apunte: a veces no es tan fácil aislar el comportamiento, y a veces no alcanza.
Template Method
Define en una operación el esqueleto de un algoritmo, delegando en las subclases algunos de sus pasos. Permite que las subclases redefinan ciertos pasos sin cambiar la estructura del algoritmo.
Aplicabilidad: para implementar las partes de un algoritmo que no cambian y dejar que las subclases implementen lo que sí varía; cuando el comportamiento repetido de varias subclases se debería factorizar en una clase común para evitar código duplicado; y para controlar las extensiones de las subclases.
Beneficios: es una técnica fundamental de reutilización de código; extrae el comportamiento común; y aplica el «principio de Hollywood»: no nos llames, nosotros te llamamos —la clase padre llama a las operaciones de la subclase y no al revés—. Las operaciones de enganche que quedan definidas en la clase base proporcionan el comportamiento predeterminado, que las subclases pueden modificar.
Visitor
Representa una operación sobre los elementos de una estructura de objetos. Permite definir una nueva operación sin cambiar las clases de los elementos sobre los que opera. Es lo que se usa cuando hay que agregar una operación nueva a una jerarquía de clases que no se quiere —o no se puede— modificar.
Dónde usar los patrones
El apunte dedica una sección entera a los momentos del diseño en los que los patrones aparecen como solución. Vale la pena porque responde a la pregunta que surge siempre: ¿y esto para qué me sirve a mí?
Encontrar los objetos apropiados
La parte difícil del diseño orientado a objetos es descomponer un sistema en objetos. Es difícil porque entran en juego muchos factores a la vez —encapsulación, granularidad, dependencia, flexibilidad, reutilización, evolución— y a menudo en conflicto entre sí.
Hay distintas aproximaciones: escribir la declaración del problema y sacar de ahí los sustantivos y los verbos para armar clases y operaciones; enfocarse en las colaboraciones y responsabilidades del sistema; o modelar el mundo real y trasladar los objetos del análisis al diseño. Siempre va a haber desacuerdo sobre cuál es mejor.
Lo importante es esto: muchos objetos de un diseño vienen del análisis, pero los diseños orientados a objetos terminan teniendo clases que no tienen ningún equivalente en el mundo real. Algunas son de bajo nivel, como los arreglos. Otras son de nivel más alto: el patrón Composite, por ejemplo, introduce una abstracción para tratar de manera uniforme objetos que no existen físicamente. Y acá está la advertencia central: modelar estrictamente el mundo real lleva a un sistema que refleja las realidades de hoy, pero no necesariamente las de mañana. Las abstracciones que aparecen durante el diseño son la clave para que el diseño sea flexible.
Los patrones ayudan a identificar las abstracciones menos obvias. Un objeto que representa un algoritmo no existe en la naturaleza y sin embargo es fundamental en los diseños flexibles: eso es Strategy. Un objeto que representa cada estado de una entidad tampoco: eso es State. Esos objetos rara vez aparecen en el análisis, y casi nunca en las primeras etapas del diseño; se descubren cuando se busca un diseño más flexible y reutilizable.
Determinar el número y el tamaño de los objetos
Los objetos pueden variar enormemente en tamaño y en cantidad. Los patrones también resuelven esa cuestión: Facade describe cómo representar subsistemas completos como objetos, y Flyweight cómo soportar un enorme número de objetos pequeños. Otros describen formas de descomponer un objeto en objetos más chicos: Abstract Factory y Builder producen objetos cuya única responsabilidad es crear otros objetos, y Visitor y Command producen objetos cuya única responsabilidad es implementar una petición sobre otro objeto o grupo de objetos.
Especificar las interfaces de los objetos
Cada operación declara su nombre, los parámetros que recibe y el valor que devuelve: eso es la signatura. El conjunto de todas las signaturas de un objeto es su interfaz, y caracteriza el conjunto completo de peticiones que se le pueden enviar. Un tipo es un nombre que se usa para denotar una interfaz particular.
Y acá hay una idea que ordena todo lo de la lección 3: los objetos solo se conocen a través de sus interfaces. No hay forma de saber nada de un objeto, ni de preguntarle nada, sin pasar por su interfaz. Pero la interfaz no dice nada sobre la implementación: dos objetos con implementaciones completamente distintas pueden tener interfaces idénticas. Eso es una ventaja, no un problema.
Los patrones ayudan a definir interfaces, a identificar sus elementos clave y a decidir qué no poner en ellas. También especifican relaciones entre interfaces: Decorator y Proxy exigen que las interfaces del decorador y del proxy sean idénticas a las del objeto decorado o representado, y en Visitor la interfaz del visitante tiene que reflejar todas las clases que puede visitar.
Programar hacia una interfaz, no hacia una implementación
Es uno de los dos principios que el apunte destaca en esta sección. Manipular objetos solamente en términos de la interfaz definida por clases abstractas tiene dos beneficios:
- Los clientes no se enteran de los tipos específicos de objeto que están usando: los objetos se ajustan a la interfaz que los clientes esperan.
- Los clientes no se enteran de las clases que implementan esos objetos: solo conocen las abstracciones del interface.
De ahí el principio, formulado como una regla concreta: no declarar variables que sean instancias de una clase particular; declararlas de una abstracción. Y si en algún lugar del sistema hay que instanciar clases concretas —en algún lado hay que hacer el new—, para eso están justamente los patrones creacionales: Abstract Factory, Builder, Factory Method, Prototype y Singleton. Aseguran que el sistema esté escrito en términos de interfaces y no de implementaciones.
Herencia o composición: caja blanca o caja negra
Las dos técnicas más comunes para reutilizar funcionalidad son la herencia de clases y la composición de objetos, y el apunte las compara con una imagen muy clara.
Y de ahí sale el segundo principio de esta sección: favorecer la composición de objetos sobre la herencia de clases. Las razones que da el apunte son concretas: la composición ayuda a conservar el encapsulamiento de cada clase, y la jerarquía se mantiene chica, con lo que es menos probable que se formen estructuras inmanejables. El costo es el inverso: un diseño basado en composición tiene más objetos, y el comportamiento del sistema depende de cómo se relacionan en lugar de estar definido en una sola clase.
Lo ideal sería no tener que crear componentes nuevos para reutilizar: alcanzaría con ensamblar los que ya existen. Pero eso casi nunca pasa, porque el conjunto de componentes disponibles nunca es lo suficientemente rico. Así que herencia y composición trabajan juntas: la herencia facilita crear componentes nuevos que después se pueden combinar. Lo que el apunte advierte es el desequilibrio: los diseñadores usan demasiado la herencia, y muchos diseños quedarían más reutilizables y más simples con más composición.
La delegación
La delegación es un modo de hacer composición tan poderoso para la reutilización como la herencia. En la delegación hay dos objetos involucrados en el manejo de una petición: un objeto receptor que delega operaciones en su delegado. En la práctica, una clase mantiene una referencia a una instancia de otra clase.
Su ventaja principal es que hace más fácil componer comportamientos en tiempo de ejecución y cambiar la forma en que están compuestos. Varios patrones se apoyan en ella: State, Strategy y Visitor dependen de la delegación; en State el objeto delega la petición a un objeto que representa su estado actual, y en Strategy la delega a un objeto que representa la estrategia para resolver esa petición. Un objeto tendrá un solo estado, pero puede tener muchas estrategias para peticiones distintas. Otros patrones la usan de forma menos estricta: Mediator introduce un objeto que media la comunicación, Chain of Responsibility pasa la petición de un objeto al siguiente y Bridge puede delegar en una implementación particular.
Las clases genéricas
Es la tercera técnica de reutilización, además de la herencia y la composición, y permite definir un tipo sin especificar los otros tipos que utiliza: esos tipos se pasan como parámetros en el punto de uso. Una lista puede parametrizarse por el tipo de elementos que contiene, que es exactamente lo que hacen List<String> o Map<Integer, String> en la lección 4. Muchos diseños se pueden implementar con cualquiera de las tres técnicas.
La estructura en compilación y en ejecución
Un punto que el apunte marca como fundamental: la estructura de un programa en ejecución tiene poco que ver con la estructura de su código. La del código se fija al compilar y son clases en relaciones de herencia fijas; la de ejecución son cambios rápidos de comunicación entre objetos. Son dos estructuras ampliamente independientes, y como consecuencia el código no revela nada sobre cómo va a trabajar el sistema. Las estructuras en ejecución las tiene que imponer el diseñador, no el lenguaje. Varios patrones capturan explícitamente esa distinción: Composite y Decorator construyen estructuras complejas en tiempo de ejecución, y la de Observer es difícil de entender si no se conoce el patrón.
Razones de cambio
Cada patrón permite que algunos aspectos de la estructura varíen independientemente de otros, y así el sistema se vuelve más robusto frente a un tipo particular de cambio. El apunte lista ocho situaciones y los patrones que las resuelven. Es la tabla más útil de toda la lección, porque sirve como índice de consulta: se reconoce el problema y se sabe dónde buscar.
| Si el problema es… | La solución… | Patrones |
|---|---|---|
| Crear un objeto especificando una clase | Nombrar la clase al crear el objeto lleva a una implementación particular en lugar de a una interfaz, y eso complica los cambios futuros. Se resuelve creando los objetos indirectamente. | Abstract Factory, Factory Method, Prototype |
| Depender de operaciones específicas | Especificar una operación compromete a una única forma de satisfacer la petición. Se resuelve con peticiones codificadas de forma flexible, que se puedan cambiar tanto en compilación como en ejecución. | Chain of Responsibility, Command |
| Depender del hardware o del software de base | El software atado a un hardware concreto es difícil de portar, y hasta de mantener en su plataforma original. Se limitan las dependencias con la plataforma. | Abstract Factory, Bridge |
| Depender de la implementación de un objeto | Los clientes que conocen la representación interna de un objeto obligan a cambiarlos cuando esa representación cambia. Se oculta la representación. | Abstract Factory, Bridge, Proxy |
| Depender de un algoritmo | Los algoritmos se extienden, se optimizan y se reemplazan durante el desarrollo. Los objetos que dependen de uno tienen que modificarse cuando cambia. | Builder, Iterator, Strategy, Template Method, Visitor |
| Acoplamiento entre clases | Al cambiar o eliminar una clase hay que cambiar las relacionadas: el sistema se vuelve difícil de entender, mantener y portar. Menos acoplamiento aumenta la probabilidad de que una clase se pueda reutilizar sola. | Abstract Factory, Bridge, Chain of Responsibility, Command, Facade, Mediator, Observer |
| Extender la funcionalidad heredando | Definir una subclase exige conocer la clase padre, lo que puede romper el encapsulamiento. La composición y la delegación dan alternativas más flexibles para combinar comportamientos. El costo es que una composición excesiva puede dar diseños más difíciles de entender. | Bridge, Chain of Responsibility, Composite, Decorator, Observer, Strategy |
| No poder alterar las clases | Hay clases que no se pueden modificar: porque el código fuente no está disponible —una biblioteca comercial— o porque el cambio obligaría a tocar muchísimas subclases. | Adapter, Decorator, Visitor |
Antipatrones
Como en casi todas las disciplinas, también existen malos usos más o menos extendidos que no aportan soluciones efectivas. Si un patrón es una buena práctica, un antipatrón es una mala práctica, y hay dos nociones:
- Los que describen una mala solución a un problema, que da como resultado una mala situación.
- Los que describen cómo salir de una mala situación y convertirla en una buena solución.
Los antipatrones merecen atención porque a menudo es tan importante ver y entender las malas soluciones como las buenas. Los más útiles son justamente los del segundo tipo: los que permiten rehacer un buen diseño a partir de uno malo. Brown, Malveau, MacCormick y Mowbray escribieron un libro sobre el tema, dedicado a identificarlos, diagnosticarlos y prevenirlos.
La definición del apunte: «forma literaria que describe una solución comúnmente dada a un problema, que genera consecuencias negativas decididamente».
Hay tres clasificaciones, y la primera es la que más se relaciona con los patrones de diseño:
De desarrollo de software
Son los más relacionados con los patrones de diseño, y por lo tanto los que más hay que evitar:
| Antipatrón | De qué se trata |
|---|---|
| The Blob | Las «clases gigantes»: una clase que absorbe todo el sistema. |
| Lava Flow | El «código muerto»: restos de versiones anteriores que nadie se anima a borrar. |
| Functional Decomposition | «Diseño no orientado a objetos»: clases que en realidad son funciones disfrazadas. |
| Poltergeists | Clases que aparecen, hacen algo al pasar y no se sabe bien qué hacen. |
| Golden Hammer | «Para un martillo, todo son clavos»: usar siempre la misma solución, aunque no corresponda. |
| Spaghetti Code | «Muchos if o switch»: flujo de control enredado y difícil de seguir. |
| Cut-and-Paste Programming | «Cortar y pegar código»: duplicar en lugar de reutilizar. |
El Golden Hammer y el Spaghetti Code son los dos que se reconocen en el ejemplo del principio: la clase Alumno que hace todo es un The Blob, y la CalculadoraDescuento llena de if es un Spaghetti Code. Los antipatrones son la misma historia contada desde el lado del error.
De arquitectura de software
- Stovepipe Enterprise: «aislamiento en la empresa».
- Stovepipe System: «aislamiento entre sistemas».
- Vendor Lock-In: «arquitectura dependiente de un producto»: se queda atada a la herramienta de un proveedor.
- Architecture by Implication: dar por sentada la arquitectura sin definirla.
- Design by Committee: la «navaja suiza», diseñada por comité, con todo lo que pidió cada uno.
- Reinvent the Wheel: «reinventar la rueda».
De gestión de proyectos
- Analysis Paralysis: analizar sin parar para no decidir nunca.
- Death by Planning: planificar de más y no llegar a construir.
- Corncob: «personas problemáticas».
- Irrational Management: gestión sin fundamento.
- Project Mismanagement: proyecto mal gestionado.
De todos ellos, los más relacionados con los patrones son los de desarrollo de software, y son los que hay que evitar en la medida de lo posible.
Ejercicios
1. Acoplamiento y cohesión
Para cada caso, indicar si el acoplamiento es alto o bajo y si la cohesión es alta o baja, y proponer la corrección:
- Una clase
Alumnocon los métodoscalcularPromedio(),imprimirConstancia()yenviarMailANotificar(). - Una clase
AlumnoRepositoryconguardar(),buscarPorLegajo()yeliminar(). - Una clase
FacturaServiceque adentro hacenew ImpresoraFiscalEpson().
2. Reconocer el principio violado
Indicar qué principio SOLID se viola en cada caso y por qué:
- En
VehiculoexisteabrirBaul()y la claseMoto extends Vehiculolo implementa tirandoUnsupportedOperationException. - La interfaz
EmpleadotienecobrarSueldo(),firmarContrato()ydirigirEquipo(), y la implementa tanto unOperariocomo unGerente. - La clase
ReporteServicearma el reporte, lo guarda en la base de datos y lo manda por correo. - Un método con una cadena de siete
ifque pregunta el tipo de cliente para calcular un precio.
3. Aplicar OCP
Reescribir esta clase para que cumpla con el principio de abierto y cerrado:
public class CalculadoraEnvio {
public double costo(String zona, double peso) {
if (zona.equals("LOCAL")) return peso * 10;
if (zona.equals("PROVINCIA")) return peso * 25;
if (zona.equals("NACIONAL")) return peso * 40;
return 0;
}
}
4. Elegir el patrón
Indicar qué patrón conviene y por qué:
- El sistema tiene que leer su configuración una sola vez y todos los objetos necesitan consultarla.
- Se quiere poder deshacer la última operación que hizo el usuario.
- Un objeto tiene que avisar a otros cuando cambia, sin saber quiénes son.
- El envío de notificaciones puede ser por correo, por WhatsApp o por Telegram, y se elige según el usuario.
- Se quiere una única clase que oculte las quince llamadas necesarias para inicializar el sistema de facturación.
5. Herencia o composición
Decidir, para cada caso, si conviene heredar o componer, y justificar con el criterio de «es un» o «tiene un»:
AutoyMotor.- Una
ListaDeTareasque necesita guardar los elementos de forma ordenada. - Un editor de texto al que se le quieren agregar correctores: de ortografía, de sintaxis y de estilo, y poder combinarlos en cualquier orden.
Respuestas sugeridas
1. (a) Baja cohesión: la clase mezcla el cálculo académico con la impresión y el envío de correo. Hay que separar Alumno (los datos y el promedio), un servicio de impresión y un EmailService. (b) Alta cohesión y bajo acoplamiento: las tres operaciones son de repositorio, la clase tiene un solo motivo para cambiar —cómo se guardan los alumnos— y su dependencia es la base de datos. (c) Alto acoplamiento: la clase construye ella misma la impresora, así que queda atada a una marca y a un modelo. Se corrige recibiendo una abstracción Impresora por constructor.
2. (a) LSP: la moto no puede sustituir al vehículo porque tiene un método que no funciona. Lo correcto es que abrirBaul() no esté en Vehiculo, sino en una interfaz VehiculoConBaul que implementen solo los que tienen baúl. (b) ISP: el Operario está obligado a implementar dirigirEquipo(), que no le corresponde. Se separa en interfaces por capacidad: Cobrable, Contratable, ConEquipoACargo. (c) SRP: la clase tiene tres motivos para cambiar —el formato del reporte, la base de datos y el correo—. Se reparte en ReporteService, ReporteRepository y EmailService. (d) OCP: cada tipo de cliente nuevo obliga a modificar el método. Conviene Strategy —una familia de objetos de cálculo— o State si además depende del estado del cliente.
3.
public abstract class CostoDeEnvio {
public abstract double costo(double peso);
}
public class EnvioLocal extends CostoDeEnvio {
public double costo(double peso) { return peso * 10; }
}
public class EnvioProvincial extends CostoDeEnvio {
public double costo(double peso) { return peso * 25; }
}
public class EnvioNacional extends CostoDeEnvio {
public double costo(double peso) { return peso * 40; }
}
Agregar una zona nueva ahora significa crear una clase, sin tocar la calculadora ni las zonas que ya funcionan. Es la misma transformación que en el ejemplo de los descuentos: la lista de condiciones se convierte en una jerarquía, y la decisión se resuelve por polimorfismo.
4. (a) Singleton: hay un recurso que se gestiona de manera centralizada y un único punto de acceso. Con la advertencia del apunte: es fácil pero intrusivo. (b) Command: cada operación se encapsula en un objeto con su execute(), y guardando los objetos ejecutados se puede implementar el deshacer. (c) Observer: dependencia de uno a muchos con notificación automática. (d) Strategy: familia de algoritmos intercambiables, elegidos en tiempo de ejecución. También sirve Bridge si lo que varía es la implementación de una abstracción. (e) Facade: una única clase con una interfaz simple que oculta un subsistema complejo.
5. (a) Composición: un auto tiene un motor; no es un motor. La herencia acá sería un error de sentido común. (b) Composición: la lista tiene elementos, y conviene que ListaDeTareas guarde adentro un ArrayList o una List en lugar de heredar de ella. Así se puede cambiar la estructura interna sin romper nada, y nadie puede llamar a métodos de la lista que la clase no quiso exponer. (c) Composición, y es el ejemplo más claro de todos: los correctores se quieren combinar en cualquier orden y en cualquier cantidad, y eso es dinámico. La herencia es estática: TextoConOrtografiaYSintaxisYEstilo requeriría una clase por cada combinación. Es el problema que resuelve justamente el patrón Decorator.
Resumen
- Los principios SOLID son cinco reglas de diseño orientado a objetos para que el software sea mantenible, escalable, reutilizable, testeable y poco acoplado. El problema que atacan es el de las clases que crecen hasta hacer todo.
- Acoplamiento es la dependencia con otros módulos —se busca bajo— y cohesión es la coherencia interna del módulo —se busca alta—. Cada método debería describirse con un solo verbo activo.
- SRP: una clase, una responsabilidad, un motivo para cambiar. OCP: extender sin modificar, y para eso está el polimorfismo. LSP: la subclase tiene que poder reemplazar a la base; el síntoma de que no se cumple son los métodos que tiran
UnsupportedOperationException. ISP: interfaces chicas, nadie depende de métodos que no usa. DIP: depender de abstracciones, no de implementaciones, y recibir las dependencias en lugar de crearlas. - Un patrón de diseño es una solución efectiva y reutilizable a un problema de diseño. Los 23 del GoF se agrupan en creacionales, estructurales y de comportamiento, con estrategias de clase o de objeto.
- Creacionales —Abstract Factory, Builder, Factory Method, Prototype, Singleton—: abstraen la creación de objetos. El Singleton garantiza una instancia única con constructor privado, y es fácil pero intrusivo.
- Estructurales —Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy—: cómo se arman unos con otros. Decorator agrega responsabilidades sin heredar y respeta OCP; el
BufferedReaderde la lección 4 es un decorador. - De comportamiento —Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor—: cómo se comunican y reparten el trabajo. State y Strategy eliminan las cadenas de
ifdelegando en otro objeto. - Los patrones ayudan a encontrar los objetos, a decidir su tamaño, a definir interfaces y a reutilizar. Los dos principios que el apunte destaca: programar hacia una interfaz, no hacia una implementación, y favorecer la composición sobre la herencia —caja negra sobre caja blanca—.
- La delegación hace que la composición sea tan poderosa como la herencia, y permite cambiar el comportamiento en tiempo de ejecución.
- La tabla de razones de cambio es la herramienta de consulta: se reconoce el problema y se sabe qué patrones lo resuelven.
- Los antipatrones son las malas prácticas documentadas: The Blob, Lava Flow, Golden Hammer, Spaghetti Code, Cut-and-Paste, entre otras. Conocerlos sirve tanto como conocer los patrones.
Con esto el código se puede diseñar para durar. Lo que falta es que el programa haga varias cosas a la vez: eso es la lección 6.
Licencia. Obra derivada de los apuntes de clase del Prof. Matías E. García —Clase Teórica 4—, publicada en profmatiasgarcia.com.ar bajo CC BY-NC-SA 2.5 Argentina; el desarrollo del texto, los diagramas y los ejercicios son de Prof Mauro Bobyk.