Ir al contenido principal

Principios SOLID

SOLID es un concepto acuñado por Robert C. Martin a comienzos de la década del 2000, formado a partir de 5 principios básicos de la programación orientada a objetos. El objetivo de aplicar estos cinco principios es crear un código limpio y altamente mantenible, y es que el código mantenible no solo depende de programar con estilo siguiendo una guía, o de probar el código escrito, sino de la estructura con la que lo escribimos.

Algunos de los principios se explican por si mismos, sin embargo hay otros que requieren un poco más de explicación. El orden no significa nada, se les dio ese orden debido a que solid formaba una regla mnemotécnica sencilla de recordar:
  • S o SRP - Principio de responsabilidad única (Single responsibility principle): la noción de que un objeto solo debería tener una única responsabilidad. Una clase debería tener una única razón para cambiar.
  • O o OCP - Principio de abierto/cerrado (Open/closed principle): la noción de que las “entidades de software … deben estar abiertas para su extensión, pero cerradas para su modificación”.
  • L o LSP - Principio de sustitución de Liskov (Liskov substitution principle): la noción de que los “objetos de un programa deberían ser reemplazables por instancias de sus subtipos sin alterar el correcto funcionamiento del programa”. Ver también diseño por contrato.
  • I o ISP - Principio de segregación de la interfaz (Interface segregation principle): la noción de que “muchas interfaces cliente específicas son mejores que una interfaz de propósito general”.
  • D o DIP - Principio de inversión de la dependencia (Dependency inversion principle): la noción de que se debe “depender de abstracciones, no depender de implementaciones”.

Single responsability principle (SRP)

El principio de Responsabilidad Única nos viene a decir que un objeto debe realizar una única cosa. Es muy habitual, si no prestamos atención a esto, que acabemos teniendo clases que tienen varias responsabilidades lógicas a la vez.
El Principio de Responsabilidad Única nos guía a separar los comportamientos basándonos en ejes del cambio. Podríamos decir que, y aquí está la clave de todo, cada responsabilidad es un eje del cambio si y sólo si el cambio ocurre. Esto significa que si tuviéramos una clase con tres responsabilidades invariantes en su comportamiento, no estaríamos incurriendo en una violación del SRP. De hecho, tal y como indica Robert C. Martin en su libro Agile Software Development, ni este principio ni cualquier otro, debería aplicarse si no hay síntoma. Si alguna de esas tres responsabilidades definidas por el comportamiento cambiarán en distintos momentos en el tiempo, es decir, se volvieran variantes, entonces sí que deberíamos separar dichas responsabilidades en otras clases ya que tendríamos el síntoma de tener una clase con varios ejes del cambio.
Si puedes pensar en más de un motivo para cambiar una clase en momentos diferentes, entonces, esa clase tiene más de una responsabilidad y si una clase tienen más de una responsabilidad, entonces las responsabilidades están acopladas por lo que los cambios en una de esas responsabilidades pueden perjudicar la capacidad de dicha clase para cumplir con otras. Este tipo de acoplamiento nos llevan a diseños frágiles que luego producen errores y problemas inesperados cuando son cambiados.
Reúne las cosas que cambian por las mismas razones. Separa las cosas que cambian por diferentes razones.
Existen una serie de indicadores que sirven para avisarnos de que estamos incumpliendo este principio:
  • En una misma clase están involucradas dos capas de la arquitectura: esta puede ser difícil de ver sin experiencia previa. En toda arquitectura, por simple que sea, debería haber una capa de presentación, una de lógica de negocio y otra de persistencia. Si mezclamos responsabilidades de dos capas en una misma clase, será un buen indicio.
  • El número de métodos públicos: Si una clase hace muchas cosas, lo más probable es que tenga muchos métodos públicos, y que tengan poco que ver entre ellos. Detecta cómo puedes agruparlos para separarlos en distintas clases. Algunos de los puntos siguientes te pueden ayudar.
  • Los métodos que usan cada uno de los campos de esa clase: si tenemos dos campos, y uno de ellos se usa en unos cuantos métodos y otro en otros cuantos, esto puede estar indicando que cada campo con sus correspondientes métodos podrían formar una clase independiente. Normalmente esto estará más difuso y habrá métodos en común, porque seguramente esas dos nuevas clases tendrán que interactuar entre ellas.
  • Por el número de imports: Si necesitamos importar demasiadas clases para hacer nuestro trabajo, es posible que estemos haciendo trabajo de más. También ayuda fijarse a qué paquetes pertenecen esos imports. Si vemos que se agrupan con facilidad, puede que nos esté avisando de que estamos haciendo cosas muy diferentes.
  • Nos cuesta testear la clase: si no somos capaces de escribir tests unitarios sobre ella, o no conseguimos el grado de granularidad que nos gustaría, es momento de plantearse dividir la clase en dos.
  • Cada vez que escribes una nueva funcionalidad, esa clase se ve afectada: si una clase se modifica a menudo, es porque está involucrada en demasiadas cosas.
  • Por el número de líneas: a veces es tan sencillo como eso. Si una clase es demasiado grande, intenta dividirla en clases más manejables.

Ejemplo:

public class Rectangle
{
    public double Sides { get; } = 4;
    public double Height { get; set; }
    public double Width { get; set; }

    public static double SumAreas(IEnumerable rectangles)
    {
        //   
    }

    public static double SumPerimeters(IEnumerable rectangles)
    {
        //
    }
}

Violación del SRP

La violación ocurre al momento de declarar los métodos SumAreas y SumPerimeters dentro de la misma clase que Rectangle y es que a pesar de que están relacionadas con el rectángulo como tal, la sumatoria forma parte de nuestra lógica de la aplicación, no de la lógica que podría tener un rectángulo en la vida real.

Cumpliendo el SRP

Para cumplir con el principio, quitamos la funcionalidad de sumatorias de la clase Rectangle e introducimos un par de clases encargadas de realizar las operaciones sobre el los conjuntos de rectángulos, su código es más o menos este:
public class AreaOperations
{
    public static double Sum(IEnumerable<Rectangle> rectangles)
    {
        //
        

public class PerimeterOperations
{
    public static double Sum(IEnumerable<Rectangle> rectangles)
    {
        //
Entonces así cada clase tiene una sola responsabilidad: una representa un rectángulo y las otras se encargan de hacer operaciones relacionadas con ellos.

Open/Closed principle (OCP)

El código debe estar abierto para ser extendido pero debe estar cerrado a modificaciones, es decir debes poder expandir el comportamiento del módulo sin modificar el código de este. Esto se logra mediante Polimorfismo y el uso de abstracciones para la API de nuestro código.
Este principio nos dice que una entidad de software debería estar abierta a extensión pero cerrada a modificación. ¿Qué quiere decir esto? Que tenemos que ser capaces de extender el comportamiento de nuestras clases sin necesidad de modificar su código. Esto nos ayuda a seguir añadiendo funcionalidad con la seguridad de que no afectará al código existente. Nuevas funcionalidades implicarán añadir nuevas clases y métodos, pero en general no debería suponer modificar lo que ya ha sido escrito.
El principio Open/Closed se suele resolver utilizando polimorfismo. En vez de obligar a la clase principal a saber cómo realizar una operación, delega esta a los objetos que utiliza, de tal forma que no necesita saber explícitamente cómo llevarla a cabo. Estos objetos tendrán una interfaz común que implementarán de forma específica según sus requerimientos.
Una de las formas más sencillas para detectar que se está incumpliendo el principio es catalogar las clases que modificamos más a menudo. Si cada vez que hay un nuevo requisito o una modificación de los existentes, las mismas clases se ven afectadas, podemos empezar a entender que estamos violando este principio.

Ejemplo

public class PerimeterOperations
{
    public double Sum(IEnumerable<object> shapes)
    {
        double perimeter = 0;
        foreach (var shape in shapes)
        {
            if (shape is Rectangle)
                perimeter += 2 * ((Rectangle)shape).Height + 2 * ((Rectangle)shape).Width;
            else if (shape is EquilateralTriangle)
                perimeter += ((EquilateralTriangle) shape).SideLength * 3;
        }
        return perimeter;
    }
}
public class AreaOperations
{
    public double Sum(IEnumerable<object> shapes)
    {
        double area = 0;
        foreach (var shape in shapes)
        {
            if(shape is Rectangle)
                area += ((Rectangle)shape).Height * ((Rectangle)shape).Width;
            else if(shape is EquilateralTriangle)
                area += Math.Sqrt(3) *  Math.Pow(((EquilateralTriangle)shape).SideLength,2) / 4;
        }
        return area;
    }
}

Violación del OCP

Probablemente ya tengas una idea de en qué parte del código se está violando este principio… pero si no: en las clases de operaciones, y es que tu programa está abierto a la extensión… pero no a la modificación. Ponte a pensar en qué va a pasar mañana que los círculos se pongan de moda. Tendrías que modificar el código de las operaciones para que funcione con otra figura y así para cada figura que se le ocurra a los usuarios de tu programa.

Cumpliendo el OCP

La solución a esta violación se dará mediante el uso de abstracciones (en este caso la interfaz IGeometricShape) a través de la cual indicaremos que nuestras figuras comparten propiedades y métodos (en este caso el área y el perímetro):
public interface IGeometricShape
{
    double Area();
    double Perimeter();
}
Y también tenemos que modificar las clases de operaciones para que acepten ahora objetos que cumplan con ese comportamiento, para así poder llamar a esos métodos, sin tener que preocuparse de qué tipo son “realmente” los objetos con los que está trabajando:
public double Sum(IEnumerable<IGeometricShape> shapes)
{
    double area = 0;
    foreach (var shape in shapes)
            area += shape.Area();
    return area;
}
public double Sum(IEnumerable shapes)
{
    double perimeter = 0;
    foreach (var shape in shapes)
            perimeter += shape.Perimeter();
    return perimeter;
}
De este modo para cuando en el futuro agreguémos nuevas figuras, únicamente tendremos que hacer que implementen ese comportamiento en común y no tendremos que modificar el código ya existente.

Liskov substitution principle (LSP)

Si S es un subtipo de T, entonces los objetos de tipo T en un programa pueden ser reemplazados por objetos de tipo S sin cambiar el comportamiento de este.
El principio de sustitución de Liskov nos dice que si en alguna parte de nuestro código estamos usando una clase, y esta clase es extendida, tenemos que poder utilizar cualquiera de las clases hijas y que el programa siga siendo válido. Esto nos obliga a asegurarnos de que cuando extendemos una clase no estamos alterando el comportamiento de la padre.
La primera en hablar de él fue Bárbara Liskov (de ahí el nombre), una reconocida ingeniera de software americana.
Detectar que se está incumpliendo este principio es bastante complicado, ya que indica que debe ser posible realizar la sustitución en cualquier parte. Sin embargo el ejemplo más típico se da cuando creas una clase que extiende de otra, pero de repente uno de los métodos te sobra, y no sabes qué hacer con él. Las opciones más rápidas son bien dejarlo vacío, bien lanzar una excepción cuando se use, asegurándose de que nadie llama incorrectamente a un método que no se puede utilizar. Si un método sobrescrito no hace nada o lanza una excepción, es muy probable que estés violando el principio de sustitución de Liskov. Si tu código estaba usando un método que para algunas concreciones ahora lanza una excepción, ¿cómo puedes estar seguro de que todo sigue funcionando?
Otra herramienta que te avisará fácilmente son los tests. Si los tests de la clase padre no funcionan para la hija, también estarás violando este principio.

Ejemplo

public class Square : Rectangle
{
    private double _height;
    private double _width;

    public override double Height
    {
        get { return _height; }
        set
        {
            _height = value;
            _width = value;
        }
    }

    public override double Width
    {
        get { return _width; }
        set
        {
            _width = value;
            _height = value;
        }
    }
}

Violación del LSP

Supón que como parte del crecimiento de tu programa, decidiste comenzar a escribir pruebas unitarias, y escribiste una como la siguiente:
Rectangle rectangle = new Square();
rectangle.Width = 3;
rectangle.Height = 6;

double expected = 18;
double actual = rectangle.Area();

Assert.AreEqual(expected, actual);
Hay algunas cosas raras… sin embargo el código compila y se ejecuta, sin embargo la prueba falla. Y es que de esto se trata el todo el principio de sustitución de Liskov: los subtipos de una clase deben comportarse siempre como esta. En otras palabras: deriva de una clase solo para agregarle capacidades, no para modificar las que ya cuenta.

Cumpliendo el LSP

La solución es bastante simple: debemos hacer que Square no derive de Rectangle, y que en su lugar, implemente IGeometrcShape:
public class Square : IGeometricShape
De ese modo nadie, ni tú mismo, podrá pensar que en este ámbito los cuadrados y los rectángulos están relacionados.

Interface segregation principle (ISP)

No tenemos que darle a los módulos de un programa más información de la que necesitan para funcionar.
El principio de segregación de interfaces viene a decir que ninguna clase debería depender de métodos que no usa. Por tanto, cuando creemos interfaces que definan comportamientos, es importante estar seguros de que todas las clases que implementen esas interfaces vayan a necesitar y ser capaces de agregar comportamientos a todos los métodos. En caso contrario, es mejor tener varias interfaces más pequeñas.
Las interfaces nos ayudan a desacoplar módulos entre sí. Esto es así porque si tenemos una interfaz que explica el comportamiento que el módulo espera para comunicarse con otros módulos, nosotros siempre podremos crear una clase que lo implemente de modo que cumpla las condiciones. El módulo que describe la interfaz no tiene que saber nada sobre nuestro código y, sin embargo, nosotros podemos trabajar con él sin problemas.
Si al implementar una interfaz ocurre que uno o varios de los métodos no tienen sentido y hace falta dejarlos vacíos o lanzar excepciones, es muy probable que se esté violando este principio. Lo ideal será dividir la interfaz en varias interfaces específicas.
Recordemos que no pasa nada porque una clase ahora necesite implementar varias interfaces. El punto importante es que use todos los métodos definidos por esas interfaces.

Ejemplo

public interface IGeometricShape
{
    double Area();
    double Perimeter();
}

Violación del ISP

Sin tener la menor intención nosotros introdujimos esta violación cuando cumplimos con el OCP, y es que el ISP nos indica que debemos separar las interfaces para que los componentes de software que trabajan con ellas tengan únicamente la información que de ellas necesitan y no más. Y se usa tanto en el cálculo de suma de áreas y en el de perímetros. Pero, ¿por qué tendría que saber la clase encargada de sumar los perímetros, que los elementos con los que trabaja también poseen un área?

Cumpliendo el ISP

El cumplir con este principio nos lleva a separar la interfaz IGeometricShape en dos: IHasPerimeter y IHasArea, para así pasarle únicamente la información necesaria a cada uno de los métodos dentro de nuestro programa:
public interface IHasArea
{
    double Area();
}
public interface IHasPerimeter
{
    double Perimeter();
}
public interface IGeometricShape : IHasArea, IHasPerimeter
{
}

Dependency inversion principle (DIP)

Este principio nos indica que debemos remover la dependencia que los módulos tienen entre sí, y dejar las interacciones como meras abstracciones cuyo funcionamiento interno pueda ser reemplazado sin tener que afectar todos los módulos de la aplicación en donde se haga referencia al comportamiento que se está modificando.
En la programación vista desde el modo tradicional, cuando un módulo depende de otro módulo, se crea una nueva instancia y la utiliza sin más complicaciones. Esta forma de hacer las cosas, que a primera vista parece la más sencilla y natural, nos va a traer bastantes problemas posteriormente, entre ellos:
Las parte más genérica de nuestro código (lo que llamaríamos el dominio o lógica de negocio) dependerá por todas partes de detalles de implementación. Esto no es bueno, porque no podremos reutilizarlo, ya que estará acoplado al framework de turno que usemos, a la forma que tengamos de persistir los datos, etc. Si cambiamos algo de eso, tendremos que rehacer también la parte más importante de nuestro programa. No quedan claras las dependencias: si las instancias se crean dentro del módulo que las usa, es mucho más difícil detectar de qué depende nuestro módulo y, por tanto, es más difícil predecir los efectos de un cambio en uno de esos módulos. También nos costará más tener claro si estamos violando algunos otros principios, como el de responsabilidad única.
Es muy complicado hacer tests: Si tu clase depende de otras y no tienes forma de sustituir el comportamiento de esas otras clases, no puedes testarla de forma aislada. Si algo en los tests falla, no tendrías forma de saber de un primer vistazo qué clase es la culpable. Este es muy fácil de detectar los incumplimientos de este principio: cualquier instanciación de clases complejas o módulos es una violación de este principio. Además, si escribes tests te darás cuenta muy rápido, en cuanto no puedas probar esa clase con facilidad porque dependan del código de otra clase.
Para poder darle a tu módulo las dependencias que para trabajar, será necesario utilizar alguna de las alternativas que existen para suministrar esas dependencias. Aunque hay varias, las que más se suelen utilizar son mediante constructor y mediante setters. Muchas veces se hace uso de un framework especializado.

Ejemplo

public class GreatCalculator
{
    public double TotalAreas { get; private set; }
    public double TotalPerimeters { get; private set; }

    public void Calculate()
    {
        var figuras = new IGeometricShape[]
        {
            new Rectangle {Width = 10, Height = 5},
            new EquilateralTriangle {SideLength = 5},
            new Rectangle {Width = 4, Height = 6},
//…

Violación del DIP

Esta violación ocurre en la clase nueva que acabas de agregar, justo en el método Calculate, y es que este él mismo está creando las figuras con las que opera (en el arreglo figuras). ¿Qué va a pasar en el futuro cuando se quiera añadir otra figura? ¿o cuando se quiera cambiar el tamaño de algunas de las figuras ya existentes?

Cumpliendo el DIP

Para cumplir con este principio tenemos que remover la dependencia que la clase GreatCalculator tiene con el arreglo figuras, haciendo que el objeto que la llame sea el encargado de proveerle con las figuras con las que tiene que operar:
public void Calculate(IEnumerable figuras)
{
De este modo se reduce su dependencia, y está preparado para operar con cualquier número de IGeometricShapes que deseemos.

Fuentes:

Comentarios