Los principios de diseño SÓLIDOS son los principios de diseño más importantes que necesita conocer para escribir código limpio. Tener un dominio sólido de los principios SOLID es una habilidad indispensable para cualquier programador. Son la base sobre la que se desarrollan otros patrones de diseño.
En este artículo, abordaremos los principios de diseño SOLID utilizando algunos ejemplos de la vida real y comprenderemos su importancia.
Junto con el polimorfismo, la abstracción y la herencia, los principios SOLID son realmente importantes para ser bueno en la programación orientada a objetivos.
Los principios SÓLIDOS son importantes por múltiples razones:
Exploremos ahora cada uno de los principios SOLID en detalle con ejemplos del mundo real.
S en principios SOLID significa Principio de Responsabilidad Única. El principio de responsabilidad única establece que una clase debe tener una sola razón para cambiar. Esto limita la cantidad de lugares que necesitamos para realizar cambios al incorporar requisitos adicionales en nuestro proyecto.
Cada clase debe tener exactamente una razón para cambiar.
Por ejemplo, digamos que estamos diseñando una aplicación bancaria en Java donde tenemos una clase SavingsAccount
que permite operaciones básicas como débito, crédito y sendUpdates
. El método sendUpdate
toma una enumeración llamada NotificationMedium
(como correo electrónico, SMS, etc.) y envía la actualización con el medio apropiado. Escribiremos el código para eso como se muestra a continuación.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void sendNotification(NotificationMedium medium) { if (medium == NotificationMedium.SMS) { // Send SMS here } else if (medium == NotificationMedium.EMAIL) { // Send Email here } } }
public enum NotificationMedium { SMS, EMAIL }
Ahora, si observa la clase SavingsAccount
anterior, puede cambiar debido a múltiples razones:
Si hay algún cambio en la lógica central de la clase SavingsAccount
(como debit
, credit
, etc.).
Si el banco decide introducir un nuevo medio de Notificación (digamos WhatsApp).
Esto es una violación del principio de responsabilidad única de los principios SOLID. Para solucionarlo, crearemos una clase separada que envíe la notificación.
Refactoricemos el código anterior de acuerdo con los principios SOLID.
public class SavingsAccount { public int balance; public String name; public SavingsAccount(int initialBalance, String name) { this.balance = initialBalance; this.name = name; System.out.println("Created a savings account with balance = " + initialBalance); } public void debit(int amountToDebit) { // debit business logic } public void credit(int amountToCredit) { // credit business logic } public void printBalance() { System.out.println("Name: " + name+ " Account Balance: " + balance); } public void sendNotification(Medium medium) { Sender.sendNotification(medium, this); } }
public enum NotificationMedium { SMS, EMAIL }
public class Sender { public static void sendNotification(NotificationMedium medium, SavingsAccount account) { // extract account data from the account object if (medium == NotificationMedium.SMS) { //logic to send SMS here } else if (medium == NotificationMedium.EMAIL) { // logic to send Email here } } }
Ahora, dado que hemos refactorizado el código, si hay algún cambio en NotificationMedium
o en el formato, cambiaremos la clase Sender
. Sin embargo, si hay un cambio en la lógica central de SavingsAccount
, habrá cambios en la clase SavingsAccount
.
Esto soluciona la infracción que observamos en el primer ejemplo.
El principio Abrir Cerrar establece que debemos diseñar las clases de tal manera que estén abiertas a la extensión (en caso de agregar características adicionales) pero cerradas a la modificación. Estar cerrado por modificaciones nos ayuda de dos maneras:
Para ver un ejemplo del principio Abrir Cerrar, echemos un vistazo al ejemplo de un carrito de compras (como el implementado en sitios web de comercio electrónico).
Voy a crear una clase llamada Cart
que contendrá una lista de Item
que puede agregarle. Dependiendo del tipo de artículo y de los impuestos que le corresponden, queremos crear un método que calcule el valor total del carrito dentro de la clase Cart
.
Las clases deben estar abiertas para extensión y cerradas para modificación.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart() { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { if (item.getItemType() == GIFT) { // 8% tax on gift + 2% gift wrap cost value += (item.getValue()*1.08) + item.getValue()*0.02 } else if (item.getItemType() == ItemType.ELECTRONIC_ITEM) { value += (item.getValue()*1.11); } else { value += item.getValue()*1.10; } } return value; } }
@Getter @Setter public abstract class Item { protected double price; private ItemType itemType; public double getValue() { return price; } }
public enum ItemType { ELECTRONIC, GIFT }
En el ejemplo anterior, el método calculateCartValue
calcula el valor del carrito iterando sobre todos los artículos dentro del carrito e invocando la lógica basada en el tipo de artículo.
Aunque este código parece correcto, viola los principios SOLID.
Digamos que necesitamos agregar una nueva regla para un tipo diferente de artículo (por ejemplo, comestibles) mientras calculamos el valor del carrito. En ese caso, tendríamos que modificar la clase original Cart
y escribir otra condición else if
dentro de ella que verifique artículos del tipo Grocery
.
Sin embargo, con poca refactorización, podemos hacer que el código se adhiera al principio de apertura/cierre. Veamos cómo.
Primero, haremos que la clase Item
sea abstracta y crearemos clases concretas para diferentes tipos de Item
(s) como se muestra a continuación.
public abstract class Item { protected double price; public double getValue() { return price; } }
public class ElectronicItem extends Item { public ElectronicItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.11; } }
public class GiftItem extends Item { public GiftItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.08 + super.getValue()*0.02; } }
public class GroceryItem extends Item { public GroceryItem(double price) { super.price = price; } @Override public double getValue() { return super.getValue()*1.03; } }
Dentro de cada clase de artículo concreta como GroceryItem
, GiftItem
y ElectronicItem
implementan el método getValue()
que contiene la lógica empresarial para el cálculo del valor y los impuestos.
Ahora, haremos que la clase Cart
dependa de la clase abstracta Item
e invocaremos el método getValue()
para cada artículo como se muestra a continuación.
import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } }
Ahora, en este código refactorizado, incluso si se introducen nuevos tipos de Item
, la clase Cart
permanece sin cambios. Debido al polimorfismo, cualquiera que sea el tipo real de Item
dentro de los items
ArrayList
, se invocaría el método getValue()
de esa clase.
El principio de sustitución de Liskov establece que en un código dado, incluso si reemplazamos el objeto de una superclase con un objeto de la clase hija, el código no debería romperse. En otras palabras, cuando una subclase hereda una superclase y anula sus métodos, debe mantener la coherencia con el comportamiento del método de la superclase.
Por ejemplo, si hacemos las siguientes clases Vehicle
y dos clases Car
y Bicycle
. Ahora, digamos que creamos un método llamado startEngine()
dentro de la clase Vehículo, se puede anular en la clase Car
, pero no será compatible con la clase Bicycle
ya que Bicycle
no tiene motor (consulte el ejemplo de código a continuación)
La subclase debe mantener coherencia con el comportamiento de la superclase al anular métodos.
class Vehicle { public void startEngine() { // start engine of the vehicle } } class Car extends Vehicle { @Override public void startEngine() { // Start Engine } } class Bicycle extends Vehicle { @Override public void startEngine(){ throw new UnsupportedOperationException("Bicycle doesn't have engine"); } }
Ahora, digamos que hay algún código que espera un objeto de tipo vehículo y se basa en el método startEngine()
. Si, al llamar a ese fragmento de código en lugar de pasar un objeto de tipo Vehicle
, pasamos un objeto Bicycle
, se producirían problemas en el código. Dado que el método de la clase Bicycle
(s) generará una excepción cuando se llame al método startEngine()
. Esto sería una violación de los Principios SOLID (principio de sustitución de Liskov)
Para resolver este problema, podemos crear dos clases MotorizedVehicle
y NonMotorizedVehicle
y hacer que Car
herede de la clase MotorizedVehicle
y que Bicycle
herede de NonMotorizedVehicle
class Vehicle { } class MotorizedVehicle extends Vehicle { public void startEngine() { // start engine here } } class Car extends MotorizedVehicle { @Override public void startEngine() { // Start Engine } } class NonMotorizedVehicle extends Vehicle { public void startRiding() { // Start without engine } } class Bicycle extends NonMotorizedVehicle { @Override public void startRiding(){ // Start riding without the engine. } }
La "I" en los Principios SOLID significa el Principio de Segregación de Interfaz.
El principio de segregación de interfaces establece que en lugar de tener interfaces más grandes que obliguen a las clases de implementación a implementar métodos no utilizados, deberíamos tener interfaces más pequeñas e implementar clases. De esta manera, las clases sólo implementan los métodos relevantes y permanecen limpias.
Divida sus interfaces en varias interfaces más pequeñas en lugar de una interfaz grande.
Por ejemplo, veamos el marco de Colecciones integrado en Java. Entre otras estructuras de datos, Java también proporciona estructuras de datos LinkedList
y ArrayList
.
La clase ArrayList
implementa las siguientes interfaces: Serializable
, Cloneable
, Iterable
, Collection
, List
y RandomAccess
.
La clase LinkedList
implementa Serializable
, Cloneable
, Iterable
, Collection
, Deque
, List
y Queue
.
¡Son bastantes interfaces!
En lugar de tener tantas interfaces, los desarrolladores de Java podrían haber combinado Serializable
, Cloneable
, Iterable
, Collecton
, List
y RandomAccess
en una sola interfaz, digamos la interfaz IList
. Ahora, tanto las clases ArrayList
como LinkedList
podrían haber implementado esta nueva interfaz IList
.
Sin embargo, dado que LinkedList
no admite el acceso aleatorio, podría haber implementado los métodos en la interfaz RandomAccess
y podría haber generado UnsupportedOperationException
cuando alguien intenta llamarlo.
Sin embargo, eso sería una violación del principio de segregación de interfaz en los principios SOLID, ya que "forzaría" a la clase LinkedList a implementar los métodos dentro de la interfaz RandomAccess
aunque no sea necesario.
Por lo tanto, es mejor dividir la interfaz según el comportamiento común y dejar que cada clase implemente muchas interfaces en lugar de una interfaz grande.
El principio de inversión de dependencia establece que las clases del nivel superior no deberían depender directamente de las clases del nivel inferior. Esto provoca un estrecho acoplamiento entre los dos niveles.
En lugar de eso, las clases inferiores deberían proporcionar una interfaz de la que deberían depender las clases de nivel superior.
Depender de interfaces en lugar de clases
Por ejemplo, continuemos con el ejemplo Cart
que vimos arriba y mejorémoslo para agregar algunas opciones de pago. Supongamos que tenemos dos tipos de opciones de pago con nosotros DebitCard
y Paypal
. Ahora, en la clase Cart
, queremos agregar un método para placeOrder
que calcularía el valor del carrito e iniciaría el pago en función del pago proporcionado. método.
Para hacer esto, podríamos haber agregado dependencia en el ejemplo anterior Cart
agregando las dos opciones de pago como campos dentro de la clase Cart
. Sin embargo, eso uniría estrechamente la clase Cart
con la clase DebitCard
y Paypal
.
En lugar de eso, crearíamos una interfaz Payment
y haríamos que las clases DebitCard
y Paypal
implementaran las interfaces Payment
. Ahora, la clase Cart
dependerá de la interfaz Payment
y no de los tipos de pago individuales. Esto mantiene las clases poco acopladas.
Vea el código a continuación.
public interface Payment { void doPayment(double amount); } public class PaypalPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate paypal payment } } public class DebitCardPayment implements Payment { @Override public void doPayment(double amount) { // logic to initiate payment via debit card } } import java.util.ArrayList; import java.util.List; public class Cart { private List<Item> items; private Payment paymentOption; public Cart(Payment paymentOption) { this.items = new ArrayList<>(); this.paymentOption = paymentOption; } public void addToCart(Item item) { items.add(item); } public double calculateCartValue() { double value = 0.0; for(Item item: items) { value += item.getValue(); } return value; } public void placeOrder() { this.paymentOption.doPayment(calculateCartValue()); } }
Si está interesado en aprender más sobre programación orientada a objetos, patrones de diseño GoF y entrevistas de diseño de bajo nivel, consulte mi curso altamente calificado Programación orientada a objetos + Patrones de diseño Java.