fbpx

Los principios SOLID

En este articulo hablaremos de los principios SOLID del diseño orientado a objetos.

En primer lugar, empezaremos explorando las razones por las que surgieron y por qué deberíamos tenerlos en cuenta a la hora de diseñar software. A continuación, esbozaremos cada principio junto con un código de ejemplo.

¿Por qué SOLID?

Los principios SOLID fueron introducidos por Robert C. Martin en su artículo del año 2000 «Design Principles and Design Patterns». Estos conceptos fueron desarrollados posteriormente por Michael Feathers, quien nos presentó el acrónimo SOLID. Y 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 nos animan a crear 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 cinco conceptos siguientes conforman nuestros principios SOLID:

  • Single Responsibility (Responsabilidad única)
  • Open/Closed (Abierto/Cerrado)
  • Liskov Substitution (Sustitución de Liskov)
  • Interface Segregation (Segregación de interfaces)
  • Dependencies Inversion (Inversión de dependencias)

Aunque estos conceptos pueden parecer desalentadores, pueden entenderse fácilmente con algunos ejemplos de código sencillos. En las siguientes secciones, profundizaremos en estos principios, con un ejemplo rápido en Java para ilustrar cada uno de ellos.

Single Responsibility (Responsabilidad única)

Empecemos por el principio de responsabilidad única. Como cabría esperar, este principio establece que una clase sólo debe tener una responsabilidad. Además, sólo debe tener una razón para cambiar.

¿Cómo nos ayuda este principio a construir mejor software? Veamos algunos de sus beneficios:

  • Pruebas – Una clase con una sola responsabilidad tendrá muchos menos casos de prueba.
  • Menor acoplamiento – 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.

Por ejemplo, veamos una clase para representar un simple libro:

public class Libro {

    private String nombre;
    private String autor;
    private String texto;

    //constructor, getters y setters
}

En este código, almacenamos el nombre, el autor y el texto asociado a una instancia de un Libro.

Añadamos ahora un par de métodos para consultar el texto:

public class Libro {

   private String nombre;
   private String autor;
   private String texto;

   //constructor, getters y setters

   //métodos que se relacionan directamente con las propiedades del libro
   public String reemplazarPalabraEnTexto(String palabra, String reemplazoPalabra){
       return text.replaceAll(palabra, reemplazoPalabra);
   }

   public boolean isPalabraEnTexto(String palabra){
       return text.contains(palabra);
   }
}

Ahora nuestra clase Libro 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?

Vamos a tirar la cautela al viento y añadir un método de impresión:

public class LibroMalo {
   //…
   void imprimirTextoEnConsola(){
       // nuestro código para formatear e imprimir el texto
   }
}

Sin embargo, este código viola el principio de responsabilidad única que esbozamos antes.

Para arreglar nuestro lío, debemos implementar una clase separada que se ocupe sólo de imprimir nuestros textos:

public class ImpersoraLibro {

   // métodos para imprimir el texto
   void imprimirTextoEnConsola(String texto){
       //nuestro código para formatear e imprimir el texto
   }

   void imprimirTextoEnOtroMedio(String text){
       // código para escribir en cualquier otro lugar..
   }
}

Impresionante. No sólo hemos desarrollado una clase que libera al Libro de sus tareas de impresión, sino que también hemos desarrollado una clase que libera al Libro de sus tareas de impresión.

Open for Extension, Closed for Modification (Abierto a extensión, cerrado a modificación)

Ha llegado el momento de la O de SOLID, conocida como el principio abierto-cerrado. En pocas palabras, las clases deben estar abiertas a la ampliación, pero cerradas a la modificación. De este modo, evitamos modificar el código existente y causar nuevos errores potenciales en una aplicación que, por lo demás, es feliz.

Por supuesto, la única excepción a la regla es la corrección de errores en el código existente.

Exploremos el concepto con un rápido ejemplo de código. Como parte de un nuevo proyecto, imaginemos que hemos implementado una clase Guitarra.

Es completa e incluso tiene un botón de volumen:

public class Guitarra {
   private String marca;
   private String modelo;
   private int volumen;

   //Constructores, getters & setters
}

Lanzamos la aplicación y a todo el mundo le encanta. Pero después de unos meses, decidimos que la Guitarra es un poco aburrida y que le vendría bien un patrón de llamas para darle un aspecto más rockero.

En este punto, podría ser tentador simplemente abrir la clase Guitarra y añadir un patrón de llama – pero quién sabe qué errores podría arrojar en nuestra aplicación.

En su lugar, vamos a atenernos al principio abierto-cerrado y simplemente extender nuestra clase Guitarra:

public class SuperGuitarraConLlamas extends Guitarra {
   private String colorLlama;

   //constructor, getters + setters
}

Al extender la clase Guitarra, podemos estar seguros de que nuestra aplicación existente no se verá afectada.

Liskov Substitution (Sustitución de Liskov)

El siguiente en nuestra lista es la sustitución de Liskov, que es posiblemente 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 sustituir B por A sin alterar el comportamiento de nuestro programa.

Pasemos directamente al código para ayudarnos a entender este concepto:

public interface Coche {
   void encendermotor();
   void acelerar();
}

Arriba, definimos una simple interfaz de Coche con un par de métodos que todos los coches deberían ser capaces de cumplir: encender el motor y acelerar hacia adelante.

Implementemos nuestra interfaz y proporcionemos algo de código para los métodos:

public class MotorCoche implements Coche {
   private Motor motor;

   //Constructores, getters + setters

   public void encenderMotor() {
       //¡enciende el motor!
       motor.encender();
   }

   public void acelerar() {
       //¡muévete hacia adelante!
       motor.darPotencia(1000);
   }
}

Como describe nuestro código, tenemos un motor que podemos encender, y podemos aumentar la potencia.

Pero espera – ahora estamos viviendo en la era de los coches eléctricos:

public class CocheEléctrico implements Coche {

   public void encenderMotor() {
       throw new AssertionError(«¡No tengo motor!»);
   }

   public void acelerar() {
       //¡esta aceleración es una locura!
   }
}

Al meter un coche sin motor en la mezcla, estamos cambiando inherentemente el comportamiento de nuestro programa. Esta es una violación flagrante de 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 reelaborar nuestro modelo en interfaces que tengan en cuenta el estado sin motor de nuestro coche.

Interface Segregation (Segregación de interfaces)

La I de SOLID significa segregación de interfaces, y significa simplemente que las interfaces más grandes deben dividirse en interfaces más pequeñas. De este modo, nos aseguramos de que las clases implementadoras sólo tengan que preocuparse de los métodos que les interesan.

Para este ejemplo, vamos a probar nuestras manos como guardianes del zoo. Y más concretamente, vamos a trabajar en el recinto de los osos.

Empecemos con una interfaz que describe nuestras funciones como cuidadores de osos:

public interface CuidadorDeOsos {
   void lavarElOso();
   void alimentarAlOso();
   void acariciaraloso();
}

Como ávidos cuidadores de zoo, estamos más que contentos de lavar y alimentar a nuestros queridos osos. Pero somos muy conscientes de los peligros de acariciarlos. Por desgracia, nuestra interfaz es bastante grande, y no tenemos más remedio que implementar el código para acariciar al oso.

Arreglemos esto dividiendo nuestra gran interfaz en tres separadas:

public interface OsoLimpiador {
   void lavarElOso();
}
interfaz pública OsoAlimentador {
   void alimentarElOso();
}
public interface OsoPetter {
   void acariciarElOso();
}

Ahora, gracias a la segregación de interfaces, podemos implementar sólo los métodos que nos interesan:

public class OsoCuidador implements OsoLimpiador, OsoAlimentador {
   public void lavarElOso() {
       //Creo que nos hemos dejado un punto...
   }

   public void alimentarElOso() {
       //Martes de atún...
   }
}

Y por último, podemos dejar las cosas peligrosas para los imprudentes:

public class Persona implements OsoPetter {
   public void acariciarElOso() {
       //¡Buena suerte con eso!
   }
}

Yendo más allá, podríamos incluso dividir nuestra clase BookPrinter de nuestro ejemplo anterior para utilizar la segregación de interfaces de la misma manera. Implementando una interfaz Printer con un único método de impresión, podríamos instanciar clases ConsoleBookPrinter y OtherMediaBookPrinter separadas.

Dependency Inversion (Inversión de dependencia)

El principio de inversión de dependencias se refiere al desacoplamiento de módulos de software. Así, en lugar de que los módulos de alto nivel dependan de los de bajo nivel, ambos dependerán de abstracciones.

Para demostrarlo, vayamos a la vieja escuela y demos vida a un ordenador Windows 98 con código:

public class Windows98Maquina {}

Pero, ¿de qué sirve un ordenador sin monitor y teclado? Vamos a añadir uno de cada a nuestro constructor para que cada Windows98Computer que instanciar viene preempaquetado con un Monitor y un TecladoEstandar:

public class Windows98Ordenador {
   private final Teclado tecladoEstandar;
   private final Monitor monitor;

   public Windows98Ordenador() {
       monitor = nuevo Monitor();
       teclado = nuevo TecladoEstandar();
   }
}

Este código funcionará, y podremos utilizar el TecladoEstandar y el Monitor libremente dentro de nuestra clase Windows98Maquina.

¿Problema resuelto? No del todo. Al declarar el TecladoEstandar y el Monitor con la palabra clave new, hemos unido fuertemente estas tres clases.

Esto no sólo hace que nuestro Windows98Ordenador difícil de probar, pero también hemos perdido la capacidad de cambiar nuestra clase TecladoEstandar con una diferente en caso de necesidad. Y también estamos atascados con nuestra clase Monitor.

Vamos a desacoplar nuestra máquina de la TecladoEstandar mediante la adición de una interfaz de teclado más general y el uso de esto en nuestra clase:

public interface Teclado { }
public class Windows98Ordenador{
   private final Teclado teclado;
   private final Monitor monitor;

   public Windows98Ordenador(Teclado teclado, Monitor monitor) {
       this.teclado = teclado
       this.monitor = monitor;
   }
}

Aquí, estamos utilizando el patrón de inyección de dependencia para facilitar la adición de la dependencia de teclado en la clase Windows98Ordenador.

Modifiquemos también nuestra clase TecladoEstandar para que implemente la interfaz Teclado de forma que sea adecuada para inyectarla en la clase Windows98Ordenador:

public class TecladoEstándar implements Teclado { }

Ahora nuestras clases están desacopladas y se comunican a través de la abstracción Teclado. Si queremos, podemos cambiar fácilmente el tipo de teclado en nuestra máquina con una implementación diferente de la interfaz. Podemos seguir el mismo principio para la clase Monitor.

Excelente. Hemos desacoplado las dependencias y somos libres de probar nuestra Windows98Ordenador con cualquier marco de pruebas que elijamos.