Comprendiendo los Principios SOLID en JAVA
1. Introducción
En este tutorial, discutiremos los principios SOLID del diseño orientado a objetos. Primero, comenzaremos explorando las razones por las que surgieron y por qué deberíamos considerarlos al diseñar software. Luego, describiremos cada principio junto con algunos ejemplos de código.
2. La razón detrás de los principios SOLID
Los principios SOLID fueron introducidos por Robert C. Martin en su artículo de 2000 “Design Principles and Design Patterns”. Estos conceptos fueron posteriormente desarrollados por Michael Feathers, quien nos presentó el acrónimo SOLID. En los últimos 20 años, estos cinco principios han revolucionado el mundo de la programación orientada a objetos, cambiando la forma en que escribimos software.
Entonces, ¿qué es SOLID y cómo nos ayuda a escribir mejor código? En pocas palabras, los principios de diseño de Martin y Feathers alientan a crear software más mantenible, comprensible y flexible. Como resultado, a medida que nuestras aplicaciones crecen en tamaño, podemos reducir su complejidad y ahorrarnos muchos dolores de cabeza más adelante.
Los cinco conceptos que componen nuestros principios SOLID son:
- Single Responsibility (Responsabilidad única)
- Open/Closed (Abierto para extensión, cerrado para modificación)
- Liskov Substitution (Sustitución de Liskov)
- Interface Segregation (Segregación de interfaces)
- Dependency Inversion (Inversión de dependencias)
Aunque estos conceptos pueden parecer abrumadores, se pueden entender fácilmente con algunos ejemplos sencillos de código. En las siguientes secciones, profundizaremos en estos principios, con ejemplos rápidos en Java para ilustrar cada uno.
3. Responsabilidad única
Comencemos con el principio de responsabilidad única. Como podríamos esperar, este principio establece que una clase debe tener solo una responsabilidad. Además, solo debería tener una razón para cambiar.
Beneficios del principio de responsabilidad única
Este principio nos brinda varios beneficios:
- Testing: Una clase con una sola responsabilidad tendrá muchas menos pruebas.
- Menor acoplamiento: Menos funcionalidad en una sola clase tendrá menos dependencias.
- Organización: Clases más pequeñas y bien organizadas son más fáciles de buscar que las monolíticas.
Código de ejemplo
Veamos un ejemplo de una clase que representa un libro:
public class Book {
private String name;
private String author;
private String text;
// Constructor, getters y setters
}
Aquí almacenamos el nombre, el autor y el texto asociado con una instancia de un Book
(libro). Ahora, agreguemos un par de métodos que consulten el texto:
public class Book {
private String name;
private String author;
private String text;
// Constructor, getters y setters
public String replaceWordInText(String word, String replacementWord) {
return text.replaceAll(word, replacementWord);
}
public boolean isWordInText(String word) {
return text.contains(word);
}
}
Ahora nuestra clase Book
funciona bien, y podemos almacenar tantos libros como queramos en nuestra aplicación. Pero, ¿qué pasa si queremos imprimir el texto?
public class BadBook {
// ...
void printTextToConsole() {
// nuestro código para formatear e imprimir el texto
}
}
Sin embargo, este código viola el principio de responsabilidad única. Para solucionar este problema, deberíamos implementar una clase separada que se encargue únicamente de imprimir nuestros textos:
public class BookPrinter {
void printTextToConsole(String text) {
// nuestro código para formatear e imprimir el texto
}
void printTextToAnotherMedium(String text) {
// código para escribir en cualquier otra ubicación...
}
}
¡Perfecto! No solo hemos desarrollado una clase que libera al Book
de sus deberes de impresión, sino que también podemos aprovechar nuestra clase BookPrinter
para enviar nuestro texto a otros medios. Ya sea a correo electrónico, registro o cualquier otra cosa, tenemos una clase separada dedicada a esta única preocupación.
4. Abierto para Extensión, Cerrado para Modificación
Ahora es el momento de la O en SOLID, conocido como el principio abierto-cerrado. En resumen, las clases deben estar abiertas para extensión pero cerradas para modificación. Al hacerlo, nos detenemos de modificar el código existente y causamos posibles nuevos errores en una aplicación que estaba funcionando bien.
La única excepción a esta regla es cuando se corrigen errores en el código existente.
Código de ejemplo
Imaginemos que hemos implementado una clase Guitar
que ya tiene un control de volumen:
public class Guitar {
private String make;
private String model;
private int volume;
// Constructores, getters y setters
}
Una vez lanzada la aplicación, todos la aman. Pero después de unos meses, decidimos que la Guitar
es un poco aburrida y que podría usar un patrón de llamas para verse más rockera. A este punto, puede ser tentador simplemente abrir la clase Guitar
y agregar el patrón de llamas, pero ¿quién sabe qué errores eso podría causar en nuestra aplicación?
En vez de eso, sigamos el principio abierto-cerrado y extendamos nuestra clase Guitar
:
public class SuperCoolGuitarWithFlames extends Guitar {
private String flameColor;
// constructor, getters + setters
}
Al extender la clase Guitar
, podemos asegurarnos de que nuestra aplicación existente no se vea afectada.
5. Sustitución de Liskov
A continuación, en nuestra lista está el principio de Sustitución de Liskov, que es probablemente el más complejo de los cinco principios. En pocas palabras, si la clase A es un subtipo de la clase B, deberíamos poder reemplazar B por A sin interrumpir el comportamiento de nuestro programa.
Código de ejemplo
Definamos una interfaz simple Car
:
public interface Car {
void turnOnEngine();
void accelerate();
}
Ahora implementamos nuestra interfaz y brindamos algo de código para los métodos:
public class MotorCar implements Car {
private Engine engine;
// Constructores, getters y setters
public void turnOnEngine() {
// encender el motor!
engine.on();
}
public void accelerate() {
// ¡moverse hacia adelante!
engine.powerOn(1000);
}
}
Ahora, ¡estamos viviendo en la era de los automóviles eléctricos!
public class ElectricCar implements Car {
public void turnOnEngine() {
throw new AssertionError("¡No tengo motor!");
}
public void accelerate() {
// ¡esta aceleración es loca!
}
}
Al arrojar un auto sin motor en la mezcla, inherentemente estamos cambiando el comportamiento de nuestro programa, lo que viola claramente la sustitución de Liskov y es un poco más difícil de arreglar que nuestros dos principios anteriores.
Una posible solución sería reorganizar nuestro modelo en interfaces que tomen en cuenta el estado sin motor de nuestro Car
.
6. Segregación de Interfaces
La I en SOLID significa segregación de interfaces, y simplemente significa que las interfaces más grandes deben dividirse en interfaces más pequeñas. De este modo, podemos asegurarnos de que las clases que las implementen solo necesiten preocuparse por los métodos que les interesan.
Código de ejemplo
Supongamos que estamos trabajando como cuidadores de osos. Comencemos con una interfaz que describa nuestros roles como cuidadores de osos:
public interface BearKeeper {
void washTheBear();
void feedTheBear();
void petTheBear();
}
Como ávidos cuidadores de zoológico, estamos más que felices de lavar y alimentar a nuestros adorables osos. Pero somos muy conscientes de los peligros de acariciarlos. Desafortunadamente, nuestra interfaz es bastante grande, y no tenemos opción más que implementar el código para acariciar al oso.
Para solucionar esto, deberíamos dividir nuestra gran interfaz en tres interfaces separadas:
public interface BearCleaner {
void washTheBear();
}
public interface BearFeeder {
void feedTheBear();
}
public interface BearPetter {
void petTheBear();
}
Ahora, gracias a la segregación de interfaces, podemos implementar solo los métodos que nos importan:
public class BearCarer implements BearCleaner, BearFeeder {
public void washTheBear() {
// creo que nos perdimos un poco...
}
public void feedTheBear() {
// Martes de atún...
}
}
public class CrazyPerson implements BearPetter {
public void petTheBear() {
// ¡Buena suerte con eso!
}
}
De hecho, podríamos incluso dividir nuestra clase BookPrinter
del ejemplo anterior para usar la segregación de interfaces de la misma manera. Al implementar una interfaz Printer
con un solo método print
, podríamos instanciar dos clases separadas: ConsoleBookPrinter
y OtherMediaBookPrinter
.
7. Inversión de Dependencias
El principio de inversión de dependencias se refiere a la desacoplación de los módulos de software. Así, en lugar de que los módulos de alto nivel dependan de los módulos de bajo nivel, ambos dependerán de abstracciones.
Código de ejemplo
Traigamos a la vida un viejo Windows98Machine
:
public class Windows98Machine {}
Pero, ¿qué utilidad tiene una computadora sin un monitor y un teclado? Agreguemos uno de cada uno a nuestro constructor para que cada Windows98Machine
que instanciemos venga equipado con un Monitor
y un StandardKeyboard
:
public class Windows98Machine {
private final StandardKeyboard keyboard;
private final Monitor monitor;
public Windows98Machine() {
monitor = new Monitor();
keyboard = new StandardKeyboard();
}
}
Este código funcionará, y podremos usar el StandardKeyboard
y el Monitor
con libertad dentro de nuestra clase Windows98Machine
. Pero, al declarar el StandardKeyboard
y el Monitor
con la palabra clave new
, hemos acoplado fuertemente estas tres clases entre sí.
Desacoplando las Dependencias
No solo hace que nuestro Windows98Machine
sea difícil de probar, sino que también hemos perdido la capacidad de cambiar nuestra clase StandardKeyboard
por otra en caso de ser necesario. Y estamos atados a nuestra clase Monitor
también.
Desacoplemos nuestra máquina del StandardKeyboard
agregando una interfaz más general Keyboard
y usándola en nuestra clase:
public interface Keyboard {}
public class Windows98Machine {
private final Keyboard keyboard;
private final Monitor monitor;
public Windows98Machine(Keyboard keyboard, Monitor monitor) {
this.keyboard = keyboard;
this.monitor = monitor;
}
}
Aquí, estamos utilizando el patrón de inyección de dependencias para facilitar la adición de la dependencia Keyboard
en la clase Windows98Machine
.
Implementando la Interfaz de Teclado
Además, modifiquemos nuestra clase StandardKeyboard
para que implemente la interfaz Keyboard
, de modo que sea adecuada para inyectar en la clase Windows98Machine
:
public class StandardKeyboard implements Keyboard {}
Ahora nuestras clases están desacopladas y se comunican a través de la abstracción Keyboard
. Si queremos, podemos fácilmente cambiar el tipo de teclado en nuestra máquina por una implementación diferente de la interfaz. Podemos seguir el mismo principio para la clase Monitor
.
8. Conclusión
En este artículo, hemos realizado una profunda inmersión en los principios SOLID del diseño orientado a objetos.
Comenzamos con un breve recorrido por la historia de los SOLID y las razones por las cuales existen estos principios. Letra por letra, hemos desglosado el significado de cada principio con un ejemplo rápido de código que lo viola. Luego, vimos cómo solucionar nuestro código y hacerlo adherirse a los principios SOLID.
Consejos prácticos
- Organización del código: Asegúrate de que tus clases y métodos tienen una sola responsabilidad.
- Extensibilidad: Usa la herencia de manera que no modifiques las clases existentes y mantengas el código limpio.
- Pruebas unitarias: A medida que apliques estos principios, las pruebas serán más sencillas debido a la menor complejidad de las clases.
- Readaptación de las interfaces: No dudes en dividir interfaces grandes en varias más pequeñas para mantener la claridad.
Al implementar y practicar los principios SOLID en tu trabajo diario en JAVA, mejorarás tu habilidad y generarás un código más limpio, mantenible y escalable.