sábado, 13 de mayo de 2017

Principios SOLID de la programación orientada a objetos

Solid es un acrónimo para establecer los cinco principios básicos de la programación orientada a objetos y diseño. El objetivo de tener un buen diseño de programación es llegar a la fase de mantenimiento de una forma más legible y sencilla así como conseguir crear nuevas funcionalidades sin tener que modificar en gran medida código antiguo. Los costes de mantenimiento pueden abarcar el 80% de un proyecto de software por lo que hay que valorar un buen diseño.
Las reglas SOLID son un conjunto de principios que, aplicados correctamente,  ayudan a escribir software de calidad en cualquier lenguaje de programación orientado a objetos. El código será más fácil de leer, testear y mantener.

Los procesos de refactorización serán mucho más sencillos si se cumplen estas reglas.

Los principios en los que se basa SOLID son los siguientes:
Principio de Responsabilidad Única
Principio Open/Closed
Principio de Sustitución de Liskov
Principio de Segregación de Interfaces
Principio de Inversión de Dependencias


Principios SOLID de la programación orientada a objetos






Principio de Responsabilidad Única


Un objeto debe realizar una única cosa. Es muy habitual, encontrar clases que tienen varias responsabilidades lógicas a la vez.

Cómo detectar el incumplimiento de este principio:

Si, en una misma clase están involucradas dos capas de la arquitectura. En toda arquitectura, 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 de que algo va mal.

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. Hay que tratar de agruparlos para separarlos en distintas clases.

Los métodos que usan cada una de las variables de esa clase
Si existen dos variables, y una de ellas se utiliza en unos cuantos métodos y otra en otros cuantos, esto puede ser indicio que cada variable 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 instancias
Si necesitamos instanciar demasiadas clases, es posible que estemos haciendo trabajo de más. También ayuda fijarse a qué clases pertenecen esas instancias. Si se agrupan con facilidad, puede que nos esté avisando de que estamos haciendo cosas muy diferentes.

Cuesta testear la clase

Si no somos capaces testear fácilmente una clase, es momento de plantearse dividir la clase en dos.

Cada vez que se escribe 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

Si una clase es demasiado grande, será posible dividirla en clases más manejables.

Ejemplo

Un objeto que necesita ser impreso por pantalla.

public class Vehiculo {
    public int getCuentaRuedas() {
        return 4;
    }
    public int getVelocidadMaxima() {
        return 200;
    }
    Override public String ACadena() {
        return "Cuenta Ruedas=" + getCuentaRuedas() + ", Velocidad Máxima =" + getVelocidadMaxima();
    }
    public void Imprime() {
        System.out.println(ACadena());
    }
}

Aunque parece una clase de lo más razonable, se detecta que mezcla dos conceptos diferentes: la lógica de negocio y la lógica de presentación. Este código puede dar problemas en muchas situaciones distintas:
Si hay que presentar el resultado de distinta forma, es necesario  cambiar una clase que especifica la forma que tienen los datos. Está imprimiendo por pantalla, pero si se necesita mostrar en formato HTML. La implementación cambia.
Para mostrar el mismo dato de dos formas distintas, no existe esta opción si sólo tenemos un método Imprime().

Una solución simple consiste en crear una clase para imprimir:

public class ImprimeVehiculo{
    public void Imprime(Vehiculo vehiculo){
        System.out.println(vehiculo.toString());
    }
}

Si fueran necesarias distintas variaciones para presentar la misma clase de forma diferente (por ejemplo, texto plano y HTML), siempre se puede crear una interfaz y crear implementaciones específicas.
Otro ejemplo el de objetos a los que se les añade el método save(). Una vez más, la capa de lógica y la de persistencia deberían permanecer separadas.

El Principio de Responsabilidad Única es indispensable para proteger el código frente a cambios, ya que implica que sólo haya un motivo por el que modificar una clase.


Principio Open/Closed


Este principio dice que una entidad de software debería estar abierta a extensión pero cerrada a modificación. Es decir, será necesario extender el comportamiento de las clases sin necesidad de modificar su código. Esto ayudará 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.
La forma de hacer esto dando a las clases una única responsabilidad, de modo que sea posible añadir nuevas características que no les afecten. Esto no significa que cumpliendo el primer principio se cumpla automáticamente el segundo

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 ésta 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.

¿Cómo detectar que estamos violando el principio Open/Closed?


Una de las formas más sencillas para detectarlo es darnos cuenta de qué clases 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

Tenemos una clase con un método que se encarga de dibujar un vehículo por pantalla. Cada vehículo tiene su propia forma de ser pintado. Nuestro vehículo tiene la siguiente forma:

public class Vehiculo {

    public TipoVehiculo getType(){
        ...
    }
    ...
}

Es una clase que especifica su tipo mediante un enumerado. Podemos tener un enum con un par de tipos:

public enum TipoVehiculo {
    COCHE,
    MOTO
}

El método de la clase que se encarga de pintarlos:

public void pintar(Vehiculo vehiculo) {
    switch (vehiculo.getType()) {
        case COCHE:
            pintarCoche(vehiculo);
            break;
        case MOTORBIKE:
            pintarMoto(vehiculo);
            break;
    }
}

Mientras no sea necesario pintar más tipos de vehículos ni se vea este switch repetido en varias partes, no sería necesario modificarlo. Incluso por el hecho de que cambie la forma de dibujar un coche o una moto estaría encapsulado en sus propios métodos y no afectaría al resto del código.

Pero puede llegar un punto en el que necesitemos dibujar un nuevo tipo de vehículo, y luego otro… Esto implica crear un nuevo enumerado, un nuevo case y un nuevo método para implementar el dibujado. En este caso si hay que aplicar el principio Open/Closed.

El paso evidente es sustituir ese enumerado por clases reales, y que cada clase sepa cómo pintarse:

public abstract class Vehiculo {
    ...
    public abstract void pintar();
}
public class Coche extends Vehiculo {
    Override public void pintar() {
        // Pinta el coche
    }
}
public class Moto extends Vehiculo {

    Override public void pintar() {
        // Pinta la moto
    }
}
Ahora el  método anterior se reduce a:
public void pintar(Vehiculo vehiculo) {
    vehiculo.pintar();
}

Añadir nuevos vehículos ahora es tan sencillo como crear la clase correspondiente que extienda de Vehiculo:

public class Camion extends Vehiculo {

    Override public void pintar() {
        // Pinta el camión
    }
}

Este ejemplo choca con el Principio de Responsabilidad Única. Esta clase está guardando la información del objeto y la forma de pintarlo. ¿Implica eso que es incorrecto? No necesariamente.  Tendremos que evaluar si el hecho de tener el método pintar en nuestros objetos afecta negativamente la mantenibilidad y testabilidad del código. En ese caso habría que buscar alternativas.

Una alternativa para cumplir ambos requisitos sería aplicar este polimorfismo a clases que sólo tengan un método de pintado y que reciban el objeto a pintar por constructor. Tendríamos por tanto un PintarCoche que se encargue de pintar coches o un PintarMoto que dibuje únicamente motos, todos ellos implementando pintar(), que estaría definido en una clase o interfaz padre.

¿Cuándo debemos cumplir con este principio?


Como el resto de principios, sólo será aplicable si es realmente necesario.
Intentar hacer un código 100% Open/Closed es prácticamente imposible, y puede hacer que sea ilegible e incluso más difícil de mantener. Las reglas SOLID son ideas muy potentes, pero hay que aplicarlas donde corresponda sin obsesionarse con cumplirlas en cada punto del desarrollo. Es más sencillo limitarse a usarlas cuando  haya surgido la necesidad real.

Principio de sustitución de Liskov

El principio de sustitución de Liskov nos dice que si en alguna parte del código se utiliza una clase, y esta clase es extendida, tenemos que ser capaces de utilizar cualquiera de sus clases hijas y que el programa siga siendo válido. Esto obliga a asegurarse de que al extender una clase no se altera el comportamiento de la clase padre.
Este principio desmiente la idea de que las clases son una forma directa de modelar la realidad. Esto no siempre es así.

¿Cómo detectar que estamos violando el principio de sustitución de Liskov?

Si se crea una clase que se extiende de otra, pero uno de los métodos sobra, y no se sabe qué hacer con él. Las opciones más rápidas son dejarlo vacío, o lanzar una excepción cuando se use, asegurándote 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, probablemente no se esté cumpliendo el principio de sustitución de Liskov.

Si los tests de la clase padre no funcionan para la hija, también se está violando este principio.

Ejemplo

Si intentamos modelar un cuadrado como una concreción de un rectángulo.

public class Rectangulo {
    private int ancho;
    private int alto;
     public int leerAncho() {
        return ancho;
    }
     public void ponerAncho(int ancho) {
        this.ancho = ancho;
    }
     public int leerAlto() {
        return alto;
    }

    public void ponerAlto(int alto) {
        this.alto = alto;
    }
     public int calcularArea() {
        return ancho * alto;
    }
}

Un test que comprueba el área:
public void testeaArea() {
    Rectangulo r = new Rectangulo();
    r.ponerAncho(5);
    r.ponerAlto(4);
    assertEquals(20, r.calcularArea());
}

La definición del cuadrado sería la siguiente:

public class Cuadrado extends Rectangulo {

    Override public void ponerAncho(int ancho) {
        super.ponerAncho(ancho);
        super.ponerAlto(ancho);
    }

    Override public void ponerAlto(int alto) {
        super.ponerAlto(alto);
        super.ponerAncho(alto);
    }
}
Ahora en el test si se cambia el rectángulo por un cuadrado. Este test no se cumple, el resultado sería 16 en lugar de 20. Esta violando el principio de sustitución de Liskov.
¿Cómo se soluciona?

Ampliando esta jerarquía de clases. Se pueden extraer a otra clase padre las características comunes y hacer que la antigua clase padre y su hija hereden de ella. Al final lo más probable es que la clase tenga tan poco código que se sustituya por un interfaz. Esto no supone ningún problema.

public interface IRectangulo {
    int leerAncho();
    int leerAlto();
    int calculaArea();
}
 public class Rectangulo extends IRectangulo {
    ...
}
 public class Cuadrado extends IRectangulo {
    ...
}

Para este caso en particular, la solución es más sencilla. No se cumple que un cuadrado es un rectángulo porque estamos dando la opción de modificar el ancho y alto después de la creación del objeto. Para solventar esta situación se debe utilizar la  inmutabilidad.

La inmutabilidad consiste en que una vez que se ha creado un objeto, el estado del mismo no puede volver a modificarse. La inmutabilidad tiene múltiples ventajas, entre ellas un mejor uso de memoria  o seguridad en múltiples hilos de ejecución.

public class Rectangulo {
     public final int ancho;
    public final int alto;

    public Rectangulo(int ancho, int alto) {
        this.ancho = ancho;
        this.alto = alto;
    }
}
public class Cuadrado extends Rectangulo {
     public Cuadrado(int lado) {
        super(lado, lado);
    }
}

Al instanciar el objeto, lo que hagamos con él será válido, ya usemos un rectángulo o un cuadrado. El problema era que la asignación de una parte del estado modificaba otro campo. Pero, con este nuevo enfoque, al no permitir las modificaciones, el funcionamiento de ambas clases es totalmente predecible.

El principio de Liskov ayuda a utilizar la herencia de forma correcta, y a tener  más cuidado a la hora de extender clases. Ahorrará muchos errores derivados de modelar lo que vemos en la vida real en clases siguiendo la misma lógica. No siempre hay una modelización exacta, por lo que este principio nos ayudará a descubrir la mejor forma de hacerlo.

Principio de Segregación de Interfaces


Ninguna clase debería depender de métodos que no usa. Cuando se crean 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 ayudan a desacoplar módulos entre sí. Siempre es posible 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.

Cuando esas interfaces intentan definir más cosas de las debidas, lo que se denominan fat interfaces. Ocurrirá que las clases hijas acabarán por no usar muchos de esos métodos, y habrá que darles una implementación. Muy habitual es lanzar una excepción, o simplemente no hacer nada.

Pero, esto es peligroso. Si lanzamos una excepción, el módulo que define esa interfaz utilizará el método en algún momento, y hará fallar el programa. El resto de implementaciones puede generar efectos secundarios que no esperamos, y a los que sólo podemos responder conociendo el código fuente del módulo en cuestión, cosa que tampoco se debe hacer.

¿Cómo detectar que estamos violando el Principio de segregación de interfaces?


Si al implementar una interfaz, uno o varios de los métodos no tienen sentido y es necesario dejarlos vacíos o lanzar excepciones, es muy probable que se esté violando este principio. Es mejor divídir la interfaz en varias interfaces que definan comportamientos más específicos.

Ejemplo

Una tienda de CDs de música, y que tiene modelados los productos así.

public interface Producto
{
  String leerNombre();
  int leerCantidad();
  int leerNumeroDisco();
  Date leerFechaPublicacion();
}
 public class CD implements Producto {
  ...
}

El producto tiene una serie de propiedades que la clase CD sobrescribirá. Si se amplía para DVDs. El problema es que para los DVDs se necesita almacenar también la clasificación por edades. Lo más directo sería simplemente añadir la nueva propiedad a la interfaz:

public interface Producto
{
  ...
  int leerEdadRecomendada();
}

Ahora los CDs se ven obligados a implementar leerEdadRecomendada(), pero no lo van a utilizar, así que lanzarán una excepción:

public class CD implements Producto {
   ...
   Override
  public int leerEdadRecomendada()
  {
    throw new UnsupportedOperationException();
  }
}

Se forma una dependencia en la que cada vez que se añade algo a Producto, hay que modificar CD con cosas que no necesita.

public interface DVD extends Producto {
  int leerEdadRecomendada();
}

Y hacer que las clases se extiendan de aquí. Esto soluciona el problema a corto plazo, pero otras cosas pueden seguir sin funcionar bien. Si hay otro producto que necesite categorización por edades, necesitaremos repetir parte de esta interfaz. Esto no permitiría realizar operaciones comunes a productos que tengan esta característica. La alternativa es segregar las interfaces, y que cada clase utilice las que necesite. Se crea una nueva interfaz.

public interface ClasificacionEdad {
  int getRecommendedAge();
}
Y ahora la clase DVD implementará los dos interfaces:
public class CD implements Producto {
  ...
}
 public class DVD implements Producto, ClasificacionEdad {
  ...
}

La ventaja es que ahora es posible tener código ClasificacionEdad, y todas las clases que implementen esta interfaz podrían participar con código común. Si además de productos vendemos actividades, que necesitarían una interfaz diferente. Estas actividades también podrían implementar la interfaz ClasificacionEdad, y podemos tener este código, independientemente del tipo de producto o servicio que vendamos:

public void chequeaUsuarioPuedeComprar(Usuario usuario, ClasificacionEdad){
  return usuario.leeEdad() >= ClasificacionEdad.leeEdadRecomendada();
}

¿Qué hacer con código antiguo?


Si ya tenemos código que utiliza fat interfaces, la solución consiste en utilizar el patrón de diseño “Adapter”. El patrón Adapter permite convertir unas interfaces en otras, por lo que es posible utilizar adaptadores que conviertan la interfaz antigua en la nueva.

Principio de inversión de dependencias
Este principio será el que más necesario para hacer que el código sea testable y mantenible.

Permite que el código no dependa de los detalles de implementación, como pueden ser el framework, la base de datos, cómo se conecte al servidor, etc.
Todos estos aspectos se especificarán mediante interfaces, y el núcleo no tendrá que conocer cuál es la implementación real para funcionar.

Las clases de alto nivel no deberían depender de las clases de bajo nivel. Ambas deberían depender de las abstracciones.
Las abstracciones no deberían depender de los detalles. Los detalles deberían depender de las abstracciones.

Cuando un módulo depende de otro, se crea una nueva instancia y se utiliza sin más complicaciones. Esta forma de hacer las cosas, que a primera vista parece la más sencilla y natural, traerá bastantes problemas posteriormente, entre ellos:

Las parte más genérica del código (el dominio o lógica de negocio) dependerá de los detalles de la implementación. Esto no es bueno, porque no podrá ser reutilizado, ya que estará acoplado al framework que utilizamos, o la forma de persistir los datos, etc. Si se cambia algo de eso, tenemos que rehacer también la parte más importante del 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 el módulo y, por tanto, es más difícil predecir los efectos de un cambio en uno de estos 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 la clase depende de otras y no hay forma de sustituir el comportamiento de esas otras clases, no se puede testar de forma aislada. Si algo en los tests falla, no hay forma de saber de un primer vistazo qué clase es la culpable.


¿Cómo detectar que estamos violando el Principio de inversión de dependencias?


Cualquier instanciación de clases complejas o módulos es una violación de este principio. Si al escribir un test, en cuanto no se pueda probar esa clase con facilidad porque dependa del código de otra clase será un buen indicio.

Es necesario utilizar alguna alternativa para suministrarle lass dependencias. Por ejemplo mediante un constructor o  mediante setters (propiedades que lo único que hacen es asignar un valor).

Utilizar un inyector de dependencias. Un módulo que se encarga de instanciar los objetos que se necesitan y pasárselos a las nuevas instancias de otros objetos. Se puede hacer una inyección muy sencilla a mano, o utilizar alguna de las librerías que existen si necesitamos algo más complejo.

Ejemplo

Tenemos una cesta de la compra que lo que hace es almacenar la información y llamar al método de pago para que ejecute la operación. El código sería así:

public class CestaCompra {
     public void comprar(Tiendas tiendas) {
         SqlDatabase db = new SqlDatabase();
        db.save(tiendas);
                 TarjetaCredito tarjetacredito = new TarjetaCredito();
        tarjetacredito.pagar(tiendas);
    }
}
 public class SqlDatabase {
    public void guardar(Tiendas tiendas){
        // Guarda los datos en una base de datos SQL
    }
}
 public class TarjetaCredito {
    public void pagar(Tiendas tiendas){
        // Ejecuta un pago utilizando una tarjeta de crédito
    }
}

Aquí se incumplen todas las reglas. Una clase de más alto nivel, como es la cesta de la compra, está dependiendo de otras de alto nivel, como el mecanismo para almacenar la información o para realizar el método de pago. Se encarga de crear instancias de esos objetos y después utilizarlas.

Si deseamos añadir métodos de pago, o enviar la información a un servidor en vez de guardarla en una base de datos local. No hay forma de hacer todo esto sin desmontar toda la lógica.

El Primer paso consiste en dejar de depender de concreciones. Se crean interfaces que definan el comportamiento que debe dar una clase para poder funcionar como mecanismo de persistencia o como método de pago:

public interface Persistencia {
    void guardar(Tiendas tiendas);
}
 public class SqlDatabase implements Persistencia {
        Override
    public void guardar(Tiendas tiendas){
        // Guarda los datos en una base de datos SQL
    }
}
 public interface MetodoPago {
    void pagar(Tiendas tiendas);
}
 public class TarjetaCredito implements MetodoPago {
       Override
    public void pagar(Tiendas tiendas){
        // Ejecuta el pago con tarjeta de crédito
    }
}

Ahora ya no se depende de la implementación particular. Pero aún hay que seguir instanciándolo en CestaCompra.
El segundo paso es invertir las dependencias. Estos objetos se deben pasar por constructor:

public class CestaCompra {
        private final Persistencia persistencia;
    private final MetodoPago metodoPago;

    public CestaCompra(Persistencia persistencia, MetodoPago metodoPago) {
        this.persistencia = persistencia;
        this.metodoPago = metodoPago;
    }
     public void comprar(Tiendas tiendas) {
        persistencia.guardar(tiendas);
        metodoPago.pagar(tiendas);
    }
}

Si ahora queremos pagar con Paypal y guardar los datos en el servidor, definimos las concreciones específicas y se las pasamos al constructor de la clase CestaCompra.

public class Server implements Persistencia {
     Override
    public void guardar(Tiendas tiendas) {
        // Guarda los datos en el servidor
    }
}

public class Paypal implements MetodoPago {
     Override
    public void pagar(Tiendas tiendas) {
        // ejecuta el pago utilizando una cuenta de PayPal
    }
}

Este mecanismo nos obliga a organizar el código de una forma distinta a como estamos acostumbrados, y en contra de lo que la lógica dicta inicialmente, pero a la larga compensa por la flexibilidad que otorga a la arquitectura de nuestra aplicación.

No hay comentarios:

Publicar un comentario