Los principios SOLID de la programación orientada a objetos explicados en un lenguaje sencillo

Los Principios SOLID son cinco principios del diseño de clases orientado a objetos. Son un conjunto de reglas y mejores prácticas a seguir al diseñar una estructura de clases.

Estos cinco principios nos ayudan a comprender la necesidad de ciertos patrones de diseño y arquitectura de software en general. Por eso creo que es un tema que todo desarrollador debería aprender.

Este artículo le enseñará todo lo que necesita saber para aplicar los principios SÓLIDOS a sus proyectos.

Empezaremos echando un vistazo a la historia de este término. Luego, vamos a entrar en los detalles esenciales, el por qué y el cómo de cada principio, creando un diseño de clase y mejorándolo paso a paso.

¡Así que tome una taza de café o té y saltemos!

Antecedentes

Los principios SOLID fueron introducidos por primera vez por el famoso informático Robert J. Martin (también conocido como el tío Bob) en su artículo en 2000. Pero el acrónimo SOLID fue introducido más tarde por Michael Feathers.

El tío Bob es también autor de los libros más vendidos Clean Code y Clean Architecture , y es uno de los participantes de la "Agile Alliance".

Por lo tanto, no es una sorpresa que todos estos conceptos de codificación limpia, arquitectura orientada a objetos y patrones de diseño estén de alguna manera conectados y complementarios entre sí.

Todos tienen el mismo propósito:

"Crear código comprensible, legible y comprobable en el que muchos desarrolladores puedan trabajar en colaboración".

Veamos cada principio uno por uno. Siguiendo las siglas SOLID, son:

  • El S ingle principio de responsabilidad
  • El O pen-Closed Principio
  • El principio de sustitución de L iskov
  • El I Nterface Segregación Principio
  • El D ependency Inversión Principio

El principio de responsabilidad única

El principio de responsabilidad única establece que una clase debe hacer una cosa y, por lo tanto, debe tener una sola razón para cambiar .

Para exponer este principio de manera más técnica: Solo un cambio potencial (lógica de base de datos, lógica de registro, etc.) en la especificación del software debería poder afectar la especificación de la clase.

Esto significa que si una clase es un contenedor de datos, como una clase de libro o una clase de estudiante, y tiene algunos campos relacionados con esa entidad, debería cambiar solo cuando cambiemos el modelo de datos.

Es importante seguir el principio de responsabilidad única. En primer lugar, debido a que muchos equipos diferentes pueden trabajar en el mismo proyecto y editar la misma clase por diferentes razones, esto podría dar lugar a módulos incompatibles.

En segundo lugar, facilita el control de versiones. Por ejemplo, digamos que tenemos una clase de persistencia que maneja las operaciones de la base de datos y vemos un cambio en ese archivo en las confirmaciones de GitHub. Siguiendo el SRP, sabremos que está relacionado con el almacenamiento o cosas relacionadas con la base de datos.

Los conflictos de fusión son otro ejemplo. Aparecen cuando diferentes equipos cambian el mismo archivo. Pero si se sigue el SRP, aparecerán menos conflictos: los archivos tendrán una única razón para cambiar y los conflictos que existen serán más fáciles de resolver.

Errores comunes y antipatrones

En esta sección veremos algunos errores comunes que violan el principio de responsabilidad única. Luego hablaremos sobre algunas formas de solucionarlos.

Veremos el código de un programa simple de facturación de librería como ejemplo. Comencemos por definir una clase de libro para usar en nuestra factura.

class Book { String name; String authorName; int year; int price; String isbn; public Book(String name, String authorName, int year, int price, String isbn) { this.name = name; this.authorName = authorName; this.year = year; this.price = price; this.isbn = isbn; } } 

Esta es una clase de libro simple con algunos campos. Nada sofisticado. No estoy haciendo que los campos sean privados para que no tengamos que lidiar con captadores y definidores y podamos enfocarnos en la lógica.

Ahora creemos la clase de factura que contendrá la lógica para crear la factura y calcular el precio total. Por ahora, suponga que nuestra librería solo vende libros y nada más.

public class Invoice { private Book book; private int quantity; private double discountRate; private double taxRate; private double total; public Invoice(Book book, int quantity, double discountRate, double taxRate) { this.book = book; this.quantity = quantity; this.discountRate = discountRate; this.taxRate = taxRate; this.total = this.calculateTotal(); } public double calculateTotal() { double price = ((book.price - book.price * discountRate) * this.quantity); double priceWithTaxes = price * (1 + taxRate); return priceWithTaxes; } public void printInvoice() { System.out.println(quantity + "x " + book.name + " " + book.price + "$"); System.out.println("Discount Rate: " + discountRate); System.out.println("Tax Rate: " + taxRate); System.out.println("Total: " + total); } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Aquí está nuestra clase de factura. También contiene algunos campos sobre facturación y 3 métodos:

  • calcular el método total, que calcula el precio total,
  • método printInvoice , que debería imprimir la factura en la consola, y
  • método saveToFile , responsable de escribir la factura en un archivo.

Debería darse un segundo para pensar en lo que está mal con este diseño de clase antes de leer el siguiente párrafo.

Ok, ¿qué está pasando aquí? Nuestra clase viola el principio de responsabilidad única de múltiples formas.

La primera violación es el método printInvoice , que contiene nuestra lógica de impresión. El SRP establece que nuestra clase solo debería tener una razón única para cambiar, y esa razón debería ser un cambio en el cálculo de la factura de nuestra clase.

Pero en esta arquitectura, si quisiéramos cambiar el formato de impresión, necesitaríamos cambiar la clase. Es por eso que no deberíamos tener lógica de impresión mezclada con lógica empresarial en la misma clase.

Hay otro método que viola el SRP en nuestra clase: el método saveToFile . También es un error muy común mezclar la lógica de persistencia con la lógica empresarial.

No piense solo en términos de escribir en un archivo, podría ser guardar en una base de datos, hacer una llamada a la API u otras cosas relacionadas con la persistencia.

Entonces, ¿cómo podemos arreglar esta función de impresión?

Podemos crear nuevas clases para nuestra lógica de impresión y persistencia, por lo que ya no necesitaremos modificar la clase de factura para esos fines.

Creamos 2 clases, InvoicePrinter e InvoicePersistence, y movemos los métodos.

public class InvoicePrinter { private Invoice invoice; public InvoicePrinter(Invoice invoice) { this.invoice = invoice; } public void print() { System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $"); System.out.println("Discount Rate: " + invoice.discountRate); System.out.println("Tax Rate: " + invoice.taxRate); System.out.println("Total: " + invoice.total + " $"); } }
public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } }

Ahora nuestra estructura de clases obedece al Principio de Responsabilidad Única y cada clase es responsable de un aspecto de nuestra aplicación. ¡Excelente!

Principio abierto-cerrado

El Principio Abierto-Cerrado requiere que las clases estén abiertas para extensión y cerradas para modificaciones.

La modificación significa cambiar el código de una clase existente y la extensión significa agregar nueva funcionalidad.

Entonces, lo que este principio quiere decir es: deberíamos poder agregar nueva funcionalidad sin tocar el código existente para la clase. Esto se debe a que cada vez que modificamos el código existente, corremos el riesgo de crear posibles errores. Por lo tanto, debemos evitar tocar el código de producción probado y confiable (en su mayoría) si es posible.

Pero, ¿cómo vamos a agregar una nueva funcionalidad sin tocar la clase ?, puede preguntar. Generalmente se hace con la ayuda de interfaces y clases abstractas.

Ahora que hemos cubierto los conceptos básicos del principio, apliquémoslo a nuestra aplicación Factura.

Digamos que nuestro jefe se acercó a nosotros y nos dijo que quieren que las facturas se guarden en una base de datos para que podamos buscarlas fácilmente. Creemos que está bien, esto es fácil, jefe, ¡dame un segundo!

Creamos la base de datos, nos conectamos a ella y agregamos un método de guardado a nuestra clase InvoicePersistence :

public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String filename) { // Creates a file with given name and writes the invoice } public void saveToDatabase() { // Saves the invoice to database } }

Desafortunadamente, nosotros, como desarrolladores perezosos de la librería, no diseñamos las clases para que fueran fácilmente ampliables en el futuro. Entonces, para agregar esta característica, hemos modificado la clase InvoicePersistence .

Si el diseño de nuestra clase obedeciera al principio Abierto-Cerrado, no necesitaríamos cambiar esta clase.

Entonces, como el desarrollador perezoso pero inteligente de la librería, vemos el problema del diseño y decidimos refactorizar el código para obedecer el principio.

interface InvoicePersistence { public void save(Invoice invoice); }

Cambiamos el tipo de InvoicePersistence a Interfaz y agregamos un método de guardado. Cada clase de persistencia implementará este método de guardado.

public class DatabasePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to DB } }
public class FilePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // Save to file } }

Entonces, nuestra estructura de clases ahora se ve así:

Ahora nuestra lógica de persistencia es fácilmente ampliable. Si nuestro jefe nos pide que agreguemos otra base de datos y tengamos 2 tipos diferentes de bases de datos como MySQL y MongoDB, podemos hacerlo fácilmente.

Puede pensar que podríamos crear varias clases sin una interfaz y agregar un método de guardado a todas ellas.

Pero digamos que ampliamos nuestra aplicación y tenemos varias clases de persistencia como InvoicePersistence , BookPersistence y creamos una clase PersistenceManager que administra todas las clases de persistencia:

public class PersistenceManager { InvoicePersistence invoicePersistence; BookPersistence bookPersistence; public PersistenceManager(InvoicePersistence invoicePersistence, BookPersistence bookPersistence) { this.invoicePersistence = invoicePersistence; this.bookPersistence = bookPersistence; } }

Ahora podemos pasar cualquier clase que implemente la interfaz InvoicePersistence a esta clase con la ayuda del polimorfismo. Ésta es la flexibilidad que brindan las interfaces.

Principio de sustitución de Liskov

The Liskov Substitution Principle states that subclasses should be substitutable for their base classes.

This means that, given that class B is a subclass of class A, we should be able to pass an object of class B to any method that expects an object of class A and the method should not give any weird output in that case.

This is the expected behavior, because when we use inheritance we assume that the child class inherits everything that the superclass has. The child class extends the behavior but never narrows it down.

Therefore, when a class does not obey this principle, it leads to some nasty bugs that are hard to detect.

Liskov's principle is easy to understand but hard to detect in code. So let's look at an example.

class Rectangle { protected int width, height; public Rectangle() { } public Rectangle(int width, int height) { this.width = width; this.height = height; } public int getWidth() { return width; } public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } }

We have a simple Rectangle class, and a getArea function which returns the area of the rectangle.

Now we decide to create another class for Squares. As you might know, a square is just a special type of rectangle where the width is equal to the height.

class Square extends Rectangle { public Square() {} public Square(int size) { width = height = size; } @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); } @Override public void setHeight(int height) { super.setHeight(height); super.setWidth(height); } }

Our Square class extends the Rectangle class. We set height and width to the same value in the constructor, but we do not want any client (someone who uses our class in their code) to change height or weight in a way that can violate the square property.

Therefore we override the setters to set both properties whenever one of them is changed. But by doing that we have just violated the Liskov substitution principle.

Let's create a main class to perform tests on the getArea function.

class Test { static void getAreaTest(Rectangle r) { int width = r.getWidth(); r.setHeight(10); System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea()); } public static void main(String[] args) { Rectangle rc = new Rectangle(2, 3); getAreaTest(rc); Rectangle sq = new Square(); sq.setWidth(5); getAreaTest(sq); } }

Your team's tester just came up with the testing function getAreaTest and tells you that your getArea function fails to pass the test for square objects.

In the first test, we create a rectangle where the width is 2 and the height is 3 and call getAreaTest. The output is 20 as expected, but things go wrong when we pass in the square. This is because the call to setHeight function in the test is setting the width as well and results in an unexpected output.

Interface Segregation Principle

Segregation means keeping things separated, and the Interface Segregation Principle is about separating the interfaces.

The principle states that many client-specific interfaces are better than one general-purpose interface. Clients should not be forced to implement a function they do no need.

This is a simple principle to understand and apply, so let's see an example.

public interface ParkingLot { void parkCar(); // Decrease empty spot count by 1 void unparkCar(); // Increase empty spots by 1 void getCapacity(); // Returns car capacity double calculateFee(Car car); // Returns the price based on number of hours void doPayment(Car car); } class Car { }

We modeled a very simplified parking lot. It is the type of parking lot where you pay an hourly fee. Now consider that we want to implement a parking lot that is free.

public class FreeParking implements ParkingLot { @Override public void parkCar() { } @Override public void unparkCar() { } @Override public void getCapacity() { } @Override public double calculateFee(Car car) { return 0; } @Override public void doPayment(Car car) { throw new Exception("Parking lot is free"); } }

Our parking lot interface was composed of 2 things: Parking related logic (park car, unpark car, get capacity) and payment related logic.

But it is too specific. Because of that, our FreeParking class was forced to implement payment-related methods that are irrelevant. Let's separate or segregate the interfaces.

Ahora hemos separado el estacionamiento. Con este nuevo modelo, incluso podemos ir más allá y dividir el PaidParkingLot para admitir diferentes tipos de pago.

Ahora nuestro modelo es mucho más flexible, extensible y los clientes no necesitan implementar ninguna lógica irrelevante porque solo proporcionamos funcionalidad relacionada con el estacionamiento en la interfaz del estacionamiento.

Principio de inversión de dependencia

El principio de inversión de dependencia establece que nuestras clases deben depender de interfaces o clases abstractas en lugar de clases y funciones concretas.

En su artículo (2000), el tío Bob resume este principio de la siguiente manera:

"Si el OCP establece el objetivo de la arquitectura OO, el DIP establece el mecanismo principal".

These two principles are indeed related and we have applied this pattern before while we were discussing the Open-Closed Principle.

We want our classes to be open to extension, so we have reorganized our dependencies to depend on interfaces instead of concrete classes. Our PersistenceManager class depends on InvoicePersistence instead of the classes that implement that interface.

Conclusion

In this article, we started with the history of SOLID principles, and then we tried to acquire a clear understanding of the why's and how's of each principle. We even refactored a simple Invoice application to obey SOLID principles.

I want to thank you for taking the time to read the whole article and I hope that the above concepts are clear.

I suggest keeping these principles in mind while designing, writing, and refactoring your code so that your code will be much more clean, extendable, and testable.

If you are interested in reading more articles like this, you can subscribe to my blog's mailing list to get notified when I publish a new article.