1. Introducción
En este tutorial, discutiremos los principios SÓLIDOS del diseño orientado a objetos.
Primero, comenzaremos explorando las razones por las que surgieron y por qué deberíamos considerarlas al diseñar software. Luego, describiremos cada principio junto con un código de ejemplo para enfatizar el punto.
2. La razón de los principios SÓLIDOS
Los principios SOLID fueron conceptualizados por primera vez por Robert C. Martin en su artículo de 2000, Principios de diseño y patrones de diseño. Estos conceptos fueron posteriormente desarrollados por Michael Feathers, quien nos presentó el acrónimo SOLID. Y en los últimos 20 años, estos 5 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 un mejor código? En pocas palabras, los principios de diseño de Martin y Feathers nos animan a crear un software más fácil de mantener, comprensible y flexible . En consecuencia, a medida que nuestras aplicaciones crecen en tamaño, podemos reducir su complejidad y ahorrarnos muchos dolores de cabeza en el futuro.
Los siguientes 5 conceptos conforman nuestros principios SOLID:
- S Responsabilidad ingle
- O pluma / Cerrado
- Sustitución de L iskov
- Me Nterface Segregación
- D Inversión ependency
Si bien algunas de estas palabras pueden parecer desalentadoras, se pueden entender fácilmente con algunos ejemplos de código simples. En las siguientes secciones, profundizaremos en lo que significa cada uno de estos principios, junto con un ejemplo rápido de Java para ilustrar cada uno.
3. Responsabilidad única
Comencemos con el principio de responsabilidad única. Como era de esperar, este principio establece que una clase solo debe tener una responsabilidad. Además, solo debería tener una razón para cambiar.
¿Cómo nos ayuda este principio a crear un mejor software? Veamos algunos de sus beneficios:
- Pruebas : una clase con una responsabilidad tendrá muchos menos casos de prueba
- Acoplamiento más bajo : menos funcionalidad en una sola clase tendrá menos dependencias
- Organización : las clases más pequeñas y bien organizadas son más fáciles de buscar que las monolíticas
Tomemos, por ejemplo, una clase para representar un libro simple:
public class Book { private String name; private String author; private String text; //constructor, getters and setters }
En este código, almacenamos el nombre, el autor y el texto asociados con una instancia de un libro .
Agreguemos ahora un par de métodos para consultar el texto:
public class Book { private String name; private String author; private String text; //constructor, getters and setters // methods that directly relate to the book properties public String replaceWordInText(String word){ return text.replaceAll(word, text); } public boolean isWordInText(String word){ return text.contains(word); } }
Ahora, nuestra clase de libros funciona bien y podemos almacenar tantos libros como queramos en nuestra aplicación. Pero, ¿de qué sirve almacenar la información si no podemos enviar el texto a nuestra consola y leerlo?
Echemos la precaución al viento y agreguemos un método de impresión:
public class Book { //... void printTextToConsole(){ // our code for formatting and printing the text } }
Sin embargo, este código viola el principio de responsabilidad única que describimos anteriormente. Para arreglar nuestro desorden, deberíamos implementar una clase separada que se ocupa solo de imprimir nuestros textos:
public class BookPrinter { // methods for outputting text void printTextToConsole(String text){ //our code for formatting and printing the text } void printTextToAnotherMedium(String text){ // code for writing to any other location.. } }
Increíble. No solo hemos desarrollado una clase que libera al Libro de sus tareas de impresión, sino que también podemos aprovechar nuestra clase de Impresora de libros para enviar nuestro texto a otros medios.
Ya sea por 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', más formalmente conocido como el principio abierto-cerrado . En pocas palabras, las clases deben estar abiertas para extensión, pero cerradas para modificaciones. Al hacerlo, evitamos modificar el código existente y causar posibles errores nuevos en una aplicación que de otro modo sería feliz.
Por supuesto, la única excepción a la regla es cuando se corrigen errores en el código existente.
Exploremos más el concepto con un ejemplo de código rápido. Como parte de un nuevo proyecto, imagina que hemos implementado una clase de guitarra .
Es completamente desarrollado e incluso tiene una perilla de volumen:
public class Guitar { private String make; private String model; private int volume; //Constructors, getters & setters }
Lanzamos la aplicación y a todos les encanta. Sin embargo, después de unos meses, decidimos que la guitarra es un poco aburrida y le vendría bien un patrón de llama impresionante para que pareciera un poco más "rock and roll".
En este punto, puede ser tentador abrir la clase de Guitarra y agregar un patrón de llama, pero quién sabe qué errores podrían producirse en nuestra aplicación.
En cambio, sigamos el principio de abierto-cerrado y simplemente ampliemos nuestra clase de guitarra :
public class SuperCoolGuitarWithFlames extends Guitar { private String flameColor; //constructor, getters + setters }
Al extender la clase de Guitarra podemos estar seguros de que nuestra aplicación existente no se verá afectada.
5. Sustitución de Liskov
El siguiente en nuestra lista es la sustitución de Liskov, que posiblemente sea el más complejo de los 5 principios. En pocas palabras, si la clase A es un subtipo de la clase B , entonces deberíamos poder reemplazar B con A sin interrumpir el comportamiento de nuestro programa.
Pasemos directamente al código para ayudarnos a comprender este concepto:
public interface Car { void turnOnEngine(); void accelerate(); }
Arriba, definimos una interfaz de automóvil simple con un par de métodos que todos los automóviles deberían poder cumplir: encender el motor y acelerar hacia adelante.
Implementemos nuestra interfaz y proporcionemos algo de código para los métodos:
public class MotorCar implements Car { private Engine engine; //Constructors, getters + setters public void turnOnEngine() { //turn on the engine! engine.on(); } public void accelerate() { //move forward! engine.powerOn(1000); } }
Como describe nuestro código, tenemos un motor que podemos encender y podemos aumentar la potencia. Pero espera, es 2019, y Elon Musk ha sido un hombre ocupado.
We are now living in the era of electric cars:
public class ElectricCar implements Car { public void turnOnEngine() { throw new AssertionError("I don't have an engine!"); } public void accelerate() { //this acceleration is crazy! } }
By throwing a car without an engine into the mix, we are inherently changing the behavior of our program. This is a blatant violation of Liskov substitution and is a bit harder to fix than our previous 2 principles.
One possible solution would be to rework our model into interfaces that take into account the engine-less state of our Car.
6. Interface Segregation
The ‘I ‘ in SOLID stands for interface segregation, and it simply means that larger interfaces should be split into smaller ones. By doing so, we can ensure that implementing classes only need to be concerned about the methods that are of interest to them.
For this example, we're going to try our hands as zookeepers. And more specifically, we'll be working in the bear enclosure.
Let's start with an interface that outlines our roles as a bear keeper:
public interface BearKeeper { void washTheBear(); void feedTheBear(); void petTheBear(); }
As avid zookeepers, we're more than happy to wash and feed our beloved bears. However, we're all too aware of the dangers of petting them. Unfortunately, our interface is rather large, and we have no choice than to implement the code to pet the bear.
Let's fix this by splitting our large interface into 3 separate ones:
public interface BearCleaner { void washTheBear(); } public interface BearFeeder { void feedTheBear(); } public interface BearPetter { void petTheBear(); }
Now, thanks to interface segregation, we're free to implement only the methods that matter to us:
public class BearCarer implements BearCleaner, BearFeeder { public void washTheBear() { //I think we missed a spot... } public void feedTheBear() { //Tuna Tuesdays... } }
And finally, we can leave the dangerous stuff to the crazy people:
public class CrazyPerson implements BearPetter { public void petTheBear() { //Good luck with that! } }
Going further, we could even split our BookPrinter class from our example earlier to use interface segregation in the same way. By implementing a Printer interface with a single print method, we could instantiate separate ConsoleBookPrinter and OtherMediaBookPrinter classes.
7. Dependency Inversion
The principle of Dependency Inversion refers to the decoupling of software modules. This way, instead of high-level modules depending on low-level modules, both will depend on abstractions.
To demonstrate this, let's go old-school and bring to life a Windows 98 computer with code:
public class Windows98Machine {}
But what good is a computer without a monitor and keyboard? Let's add one of each to our constructor so that every Windows98Computer we instantiate comes pre-packed with a Monitor and a StandardKeyboard:
public class Windows98Machine { private final StandardKeyboard keyboard; private final Monitor monitor; public Windows98Machine() { monitor = new Monitor(); keyboard = new StandardKeyboard(); } }
This code will work, and we'll be able to use the StandardKeyboard and Monitor freely within our Windows98Computer class. Problem solved? Not quite. By declaring the StandardKeyboard and Monitor with the new keyword, we've tightly coupled these 3 classes together.
Not only does this make our Windows98Computer hard to test, but we've also lost the ability to switch out our StandardKeyboard class with a different one should the need arise. And we're stuck with our Monitor class, too.
Let's decouple our machine from the StandardKeyboard by adding a more general Keyboard interface and using this in our class:
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; } }
Here, we're using the dependency injection pattern here to facilitate adding the Keyboard dependency into the Windows98Machine class.
Let's also modify our StandardKeyboard class to implement the Keyboard interface so that it's suitable for injecting into the Windows98Machine class:
public class StandardKeyboard implements Keyboard { }
Now our classes are decoupled and communicate through the Keyboard abstraction. If we want, we can easily switch out the type of keyboard in our machine with a different implementation of the interface. We can follow the same principle for the Monitor class.
Excellent! We've decoupled the dependencies and are free to test our Windows98Machine with whichever testing framework we choose.
8. Conclusion
In this tutorial, we've taken a deep dive into the SOLID principles of object-oriented design.
Comenzamos con un breve resumen de la historia de SOLID y las razones por las que existen estos principios.
Letra por letra, hemos desglosado el significado de cada principio con un ejemplo de código rápido que lo viola. Luego vimos cómo arreglar nuestro código y hacer que se adhiera a los principios SOLID.
Como siempre, el código está disponible en GitHub.