Principios SOLID del diseño orientado a objetos

Por: Andrew Copley, Solutions Architect en Apiux Tecnología
Es irlandés y vive en Chile hace varios años. Tiene un PhD en Física del Imperial College de Londres y hace parte del equipo fundador de Apiux. Es de los que va a la oficina todos los días. Le gusta caminar, pero disfruta más pedalear. En este blog explicará los principios SOLID. 

 

¡Hola, y bienvenidos a todos! Este blog será más breve que los anteriores, pero igual vamos a descubrir los principios SOLID, los cuales son importantes y deberíamos tener en mente cuando estamos desarrollando. 

 

Como siempre, antes de programar, hay una fase de diseño de clases, métodos, etc. Y en esta fase uno debiese buscar y aplicar los principios SOLID.  

 

Los principios SOLID fueron introducidos por Robert C. Martin y es un acrónimo de cinco principios de OOD. Estos principios pretenden establecer buenas prácticas en el desarrollo de software en términos de su mantenimiento y expansión a medida que el proyecto se amplía. 

 

Básicamente, los cinco principios apuntan a hacer que el código sea más fácil de cambiar, reducir cambios innecesarios y desacoplar dominios de la aplicación con el uso de abstracciones (interfaces).

 

Principios SOLID

 

SOLID significa:

 

S: (Single) Principio de responsabilidad única.

O: (Open) Principio abierto-cerrado.

L: (Liskov) Principio de sustitución de Liskov.

I: (Interface) Principio de segregación de interfaz.

D: (Dependency) Principio de inversión de dependencia.

 

Examinemos estos principios, uno por uno, a través de código Python como ejemplo.

 

Te puede interesar: Crear una API simple con Python, Flask y Google Cloud Kubernetes Engine

 

1. Principio de responsabilidad única – Principios SOLID

 

De forma precisa declara:

 

Una clase debiese tener una sola razón por cambiarse, lo que significa que una clase debiese tener una sola función.

 

Como ejemplo consideremos una aplicación que toma unas formas, círculo o cuadrado, y nos devuelve la sumatoria de todas las áreas de la colección. Primero creamos las clases de cada forma con sus constructores.

 

class Square():

length=None
def __init__(self, length):
    self.length=length

class Circle():

radius=None
def __init__(self, radius):
    self.radius=radius

 

Luego creamos una clase que acepta un array de formas en su constructor:

 

class AreaCalculator():
shapes=None
def __init__(self,shapes=[]):
    self.shapes=shapes

def sum(self):
    area=[]
    for shape in self.shapes:
        if type(shape) is Square:
            area.append((shape.length**2))
        if type(shape) is Circle:
            area.append(math.pi*(shape.radius)**2)

    return sum(area)
       
       
    def output(self):
        return «Sum of provided shapes={}”.format(self.sum()

 

A continuación, para usar la clase, hay que crear una instancia de la clase, pasando un array de formas ya instanciadas, y luego llamar al método output.

 

shapes=[Circle(2),Square(5),Square(6)]
areas=AreaCalculator(shapes)
print(areas.output())

 

El problema con el método de output es que “AreaCalculator” maneja la lógica para generar los datos. Se preocupa de no solo hacer el cálculo de la suma de todas las formas sino también del formato el resultado generado. 

 

Esto viola el principio de responsabilidad única. La clase AreaCalculator solo debiese preocuparse de la suma de las áreas de las formas proporcionadas. 

 

No le debiese importar si el usuario está pidiendo formato JSON o HTML.Entonces, para corregir esto, vamos a separar la lógica de formato de la lógica del cálculo con una nueva clase SumCalculatorOutputter.

 

class SumCalculatorOutputter():

calculator=None
def __init__(self,calculator:AreaCalculator):
    self.calculator=calculator


def JSON(self):
    data={«sum»:self.calculator.sum()}
    return json.dumps(data)

def HTML(self):
    return «Sum of provided shapes={}».format(self.calculator.sum())


output=SumCalculatorOutputter(areas)
print(output.HTML())
print(output.JSON()) 

 

Ahora, la clase SumCalculatorOutputter maneja cualquier lógica de formato para devolver los datos al usuario. Así que, de esta forma, ya cumple con el principio de responsabilidad única.

 

2. Principio abierto-cerrado

 

De forma precisa declara:

 

Los objetos o entidades debiesen estar abiertos por extensión, pero cerrados por modificación.

 

¿Qué significa esto? Bueno, si queremos ampliar la funcionalidad de una clase, esto debiese ser posible sin modificar el código ya existente. Volvamos a ver nuestra clase AreaCalculator y revisemos el método sum:

 

class AreaCalculator():

shapes=None
def __init__(self,shapes=[]):
    self.shapes=shapes

def sum(self):

    area=[]
    for shape in self.shapes:
    if type(shape) is Square:
        area.append((shape.length**2))
    if type(shape) is Circle:
        area.append(math.pi*(shape.radius)**2)

    return sum(area)

 

Si tenemos que agregar unas formas adicionales como triángulos, pentágonos, hexágonos, etc, tendremos que editar constantemente este archivo para agregar más bloques if/else según la forma. 

 

Así, estamos violando el principio abierto-cerrado. Pero hace sentido, cada forma se encarga de calcular su propia área, porque cada cálculo es particular a cada forma. Entonces con encapsular el cálculo de área dentro de la clase en sí, nos permite agregar más formas sin tener que modificar la clase AreaCalculator. 

 

Vamos a modificar nuestras clases Square y Circle con agregar un nuevo método ‘área’ y agregar una clase nueva, Triangle.

 

class Square():

length=None
def __init__(self, length):
    self.length=length

def area(self):
      return self.length**2


class Circle():

radius=None
def __init__(self, radius):
    self.radius=radius

def area(self):
    return math.pi*(self.radius)**2


class Triangle():

length=None
height=None

    def __init__(self, length, height):
        self.length=length
        self.height=height

    def area(self):
        return self.length*self.height/2

 

Y como consecuencia, debieramos simplificar AreaCalculator así:

 

class AreaCalculator():

shapes=None
def __init__(self,shapes=[]):
    self.shapes=shapes

def sum(self):
    area=[]
    for shape in self.shapes:
        area.append(shape.area())

    return sum(area)

 

Entonces podemos ejecutar nuevamente el cálculo con:

 

shapes=[Circle(2),Square(5),Square(6), Triangle(4,2)]
areas=AreaCalculator(shapes)
output=SumCalculatorOutputter(areas)
print(output.HTML())
print(output.JSON())

 

Sin embargo, tenemos otro problema: ¿Cómo sabe que el objeto pasado a AreaCalculator es realmente una forma o si tiene un método llamado área? Vamos a ocupar el concepto de una interfaz y con su uso vamos a obligar que nuestra clase tiene método. 

 

Cada lenguaje tiene su propia forma de implementar una interfaz, por ejemplo, Java y PHP cuentan con la keyword interface. 

 

Python de otra manera, permite heredar de una clase que ocupa el decorator @abstractmethod a través de importar módulo abc (Abstract Base Class). En general, Python permite que se implemente una interfaz de forma informal donde no es obligatorio concretar el método. 

 

Sin embargo, en este caso, queremos una interfaz formal a través del decorator abstractmethod. El diagrama de clase se ve así:

 

 

Y el código en Python:

 

from abc import ABC, abstractmethod

class ShapeInterface(ABC):

    @abstractmethod
    def area():
        pass

class Square(ShapeInterface):

length=None
def __init__(self, length):
        self.length=length

    def area(self):
        return self.length**2

class Circle(ShapeInterface):

    radius=None
    def __init__(self, radius):
        self.radius=radius       

    def area(self):
        return math.pi*(self.radius)**2


class Triangle(ShapeInterface):

    length=None   
    height=None

    def __init__(self, length, height):
        self.length=length
        self.height=height

    def area(self):
        return self.length*self.height/2

 

class AreaCalculator():

shapes=None
def __init__(self,shapes=[]):
    self.shapes=shapes

def sum(self):
    area=[]
    for shape in self.shapes:
            if isinstance(shape,ShapeInterface):
                area.append(shape.area())

        return sum(area)  

shapes=[Circle(2),Square(5),Square(6), Triangle(4,2)]
areas=AreaCalculator(shapes)
output=SumCalculatorOutputter(areas)
print(output.HTML())
print(output.JSON())

 

Nota que cada forma hereda de la clase ShapeInterface y se implementó un chequeo sobre el tipo de la clase para asegurarnos que esté implementado el método ‘área’. 

 

Y con estos ajustes cumplimos con el principio abierto-cerrado.

 

3. Principio de sustitución de Liskov

 

De forma precisa declara:

 

Clases derivadas de una clase base debiese puede ser sustituta para la clase base.

 

A partir de la clase AreaCalculator vamos a considerar una nueva clase VolumeCalculator que extiende la clase AreaCalculator. 

 

Dado que la clase VolumeCalculator es una derivación de la clase AreaCalculator, e incorporando nuestro principio, queremos ocupar VolumeCalculator donde antes ocupamos AreaCalculator, en la clase SumCalculatorOutputter. 

 

Primero, se extienden las definiciones del interfaz e introducimos unas nuevas formas 3D, Spheroid y Cuboid, las cuales tienen volumen. Nuestro diagrama de clase se ve así, seguido por el código Python:

 

 

class ShapeInterface(ABC):

    @abstractmethod
    def area():
        pass


    @abstractmethod
    def volume():
        pass

class Square(ShapeInterface):
   
    length=None   
    def __init__(self, length):
        self.length=length
   
    def area(self):
        return self.length**2
       
    def volume(self):
        pass
   

class Cuboid(ShapeInterface):
   
    length=None   
    def __init__(self, length):
        self.length=length
   
    def area(self):
        return (self.length**2)*8
       
    def volume(self):
        return self.length**3
   


   
class Circle(ShapeInterface):
   
    radius=None
    def __init__(self, radius):
        self.radius=radius       
       
    def area(self):
        return math.pi*(self.radius)**2
       
    def volume(self):
        pass

class Spheroid(ShapeInterface):
   
    radius=None
    def __init__(self, radius):
        self.radius=radius       
       
    def area(self):
        return 4*math.pi*(self.radius)**2
       
    def volume(self):
        return 4*math.pi*(self.radius**3)/3

class VolumeCalculator(AreaCalculator):

    def __init__(self,shapes=[]):
        super().__init__(shapes)
       
    def sum(self):
   
        volume=[]
        for shape in self.shapes:
            if isinstance(shape,ShapeInterface):
                volume.append(shape.volume())
               
        return volume

       
shapes1=[Circle(2),Square(5),Square(6)]
shapes2=[Spheroid(2),Cuboid(5), Cuboid(6)]

areas=AreaCalculator(shapes1)
volumes=VolumeCalculator(shapes2)

output1=SumCalculatorOutputter(areas)
output2=SumCalculatorOutputter(volumes)

 

Sin embargo, se nota que el resultado es diferente para la clase VolumeCalculator. Se ha devuelto una lista de valores en vez de un float.

 

 

En este caso es muy fácil solucionar con return sum(volume) en el método sum para darnos una vez más un float.

 

 

4. El principio de segregación de interfaz:

 

De forma precisa declara:

 

Un cliente nunca debiese ser forzado a implementar una interfaz que no ocupa. Tampoco los clientes debiesen depender de métodos que no ocupan.

 

Se nota que en el principio 3 tuvimos que aumentar la clase ShapeInterface con un método abstracto “volume()”, y luego tuvimos que implementar este método no solo en las clases Spheroid y Cuboid sino también en Circle and Square. 

 

Circle y Square son formas planas, así que no tienen el concepto de volumen. Entonces estamos obligando a las clases Circle y Square a implementar un método, para lo cual no tiene uso, violando el principio de segregación de interfaz. 

 

En cambio, se podría crear otra interfaz y las formas 3D pueden implementar esta interfaz. Una vez más incluimos el diagrama de clase y el código.

 

class ShapeInterface(ABC):

@abstractmethod
def area():
    pass

class ThreeDimShapeInterface(ABC):

@abstractmethod
def area():
    pass

@abstractmethod
def volume():
    pass

class Square(ShapeInterface):

length=None
def __init__(self, length):
        self.length=length
   
    def area(self):
        return self.length**2
       
class Cuboid(ThreeDimShapeInterface):
   
    length=None   
    def __init__(self, length):
        self.length=length
   
    def area(self):
        return (self.length**2)*8
       
    def volume(self):
        return self.length**3
   
class Circle(ShapeInterface):
   
    radius=None
    def __init__(self, radius):
        self.radius=radius       
       
    def area(self):
        return math.pi*(self.radius)**2
       
class Spheroid(ThreeDimShapeInterface):
   
    radius=None
    def __init__(self, radius):
        self.radius=radius       
       
    def area(self):
        return 4*math.pi*(self.radius)**2
       
    def volume(self):
        return 4*math.pi*(self.radius**3)/3


class VolumeCalculator(AreaCalculator):

    def __init__(self,shapes=[]):
        super().__init__(shapes)
       
    def sum(self):
   
        volume=[]
        for shape in self.shapes:
            if isinstance(shape,ThreeDimShapeInterface):
                volume.append(shape.volume())
               
        return sum(volume)

       
shapes1=[Circle(2),Square(5),Square(6)]
shapes2=[Spheroid(2),Cuboid(5), Cuboid(6)]

areas=AreaCalculator(shapes1)
volumes=VolumeCalculator(shapes2)

output1=SumCalculatorOutputter(areas)
output2=SumCalculatorOutputter(volumes)

print(output1.JSON())       
print(output2.JSON())

 

5. Principios de inversión de dependencias

 

De forma precisa declara:

 

Las entidades debiese depender de abstracciones, no de concreciones. Específicamente un módulo de alto nivel no debiese depender del módulo de un nivel más bajo, sino ambos debiesen depender de una abstracción. 

 

A continuación, se muestra un ejemplo de un PasswordReminder que se conecta a una base de datos de Postgres.

 

class MyPostgresConnection():

def connect():
    return ‘Database connection’


class PasswordReminder():

dbConnection=None
def __init__(self,dbConnection: MyPostgresConnection):
    self.dbConnection=dbConnection

 

Primero, MyPostgresConnection es el módulo de bajo nivel mientras que PasswordReminder sea de alto nivel. 

 

Entonces este fragmento de código anterior viola nuestro principio, ya que está forzando a la clase PasswordReminder a depender de la clase MyPostgresConnection. 

 

Si más adelante cambia el motor de base de datos, también tendría que editar la clase Passwordreminder, y esto violaría el principio abierto-cerrado. 

 

A la clase PasswordReminder no le debiese importar la base de datos que ocupa la aplicación. 

 

Una vez más podríamos usar una interfaz y la clase de bajo nivel lo implementaría. La interfaz tiene un método connect y dentro de nuestra clase MyPostgresConnection se implementa de forma concreta.

 

class DBConnectionInterface(ABC):

@abstractmethod
def connect():
    pass


class MyPostgresConnection(DBConnectionInterface):

    def connect():
        return ‘Database connection’
       
class PasswordReminder():

    dbConnection=None
    def __init__(self,dbConnection: DBConnectionInterface):
        self.dbConnection=dbConnection

 

Entonces, podríamos crear una instancia de MyPostgresConnection y pasarla en el constructor de PasswordReminder sin tener que preocuparnos del motor de base de datos.

 

dbconnect=MyPostgresConnection()
pw=PasswordReminder(dbconnect) 

 

En conclusión, este blog breve introduce los principios SOLID de OOD. En la práctica son pocas las veces que se ocupan todos los principios en un desarrollo. 

 

En mi experiencia, los principios que he ocupado explícitamente con más frecuencia son el principio abierto-cerrado y el principio de inversión de dependencias. También estos dos principios se combinan bien con varios patrones de desarrollo como el patrón de fabrica. 

 

Bueno, si quieren investigar más, pueden seguir el enlace en la introducción de este blog. Los ejemplos de código son de Python 3.6.9 y en los principios 1-4 se puede ejecutar sin modificación en la línea de comando. 

 

El ejemplo de la versión 5 requiere una implementación concreta de hacer una conexión con una base de datos. Espero que este blog te haya servido. Te invito a ser parte de nuestra comunidad de desarrolladores. Da clic en el botón y revisa todas nuestras vacantes disponibles. ¡Hasta pronto!

 

Apiux Jobs
No Comments

Sorry, the comment form is closed at this time.