
SOLID: Principios de Programación
Predecir el futuro siempre ha sido casi imposible, más aun en la programación. Actualmente, muchas de las personas que programan o escriben código usan asistente para su trabajo, los cuales pueden incrementar la productividad de manera exponencial en manos expertas, pero en manos inexpertas puede ser lo contrario. Sin embargo, los humanos seguimos siendo los responsables de nuestro trabajo, por lo que debemos validar, cuestionar y testear lo construidos por lo asistentes en base a nuestra experiencia y conocimiento. Hoy en día, más que nunca, tópicos tales como: fundamentos de programación, patrones de diseño, conceptos y la experiencia son excelentes herramientas para cuestionar lo que hacen las maquinas, guiándolas para construir soluciones reales.
Debido a mi formación más practica que teórica en la programación, yo nunca he visto mucho sobre patrones o formas correctas de escribir código, pero en este tiempo que estado estudiando afuera del país, he visto cosas que creo que son interesantes (si eres programador, seguramente te lo pasaron en la universidad), y que pueden ayudar a otros al momento de construir soluciones más robustas y solidas. La programación es una materia bastante estudiada desde los años 1930s, con Alan Tunning como uno de los pioneros de la materia, y como cualquier industria, se han construido marcos teóricos, patrones, frameworks y diferentes lenguajes a través del tiempo.
Python es uno de los lenguajes más popular de los programadores, debido a su alta orientación a objetos y su interpretabilidad, permitiendo que personas de otras áreas del conocimiento (no informáticos) aprendan y trabajen con el. Un lenguaje “object-oriented” es un tipo de lenguaje que se basa en el concepto de objetos, donde las clases son representadas por objetos (sustantivos) y sus acciones son métodos (las cosas que pueden hacer esos objetos). En base a este tipo de lenguaje, se han diseñado cinco tipos de principios, que son:
- Single-Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
Estos cinco principios representan un letra, y en conjunto forma la palabra SOLID.
En este articulo hablaré un poco sobre estos principios, y la importancia de ellos para construir soluciones de calidad.
Single-Responsibility Principle (SRP)
El primer principio es el single-responsibility principle (SRP) que fue creado por Robert C. Martin . Su declaración es la siguiente:
A class should have only one reason to change. (Una clase debería tener una sola razón para cambiar.)
Esto significa que una clase debería tener una sola responsabilidad, expresadas en sus métodos. Si una clase tiene más de una tarea, deberíamos separarla en dos dedicadas clases con nombres distintos. Esto suena un poco abstracto, pero imaginemos la siguiente idea, expresada en el código de abajo:
1class MLOService:2 def __init__(self):3 self.model = None4 self.data = None56 def load_data(self):7 # Code to load data from a file8 pass910 def train(self):11 # Code to train the model12 pass1314 def predict(self):15 # Code to make predictions using the model16 pass17
Pensemos que estamos construyendo un pipeline de machine learning, y tenemos una clase que hace todos el proceso, desde la extracción de los datos, entrenamiento y sus predicciones. Esta clase se ve extraña, tiene muchas cosas, y todo esta muy condensado en solo un objeto. Una buena practica seria separar el problema en partes más pequeñas, construyendo distintas clases para cada tarea. Si re factorizamos este código, quedaría de la siguiente manera:
1class DataLoader:2 def load_data(self):3 # Code to load data from a file4 pass56class ModelTrainer:7 def __init__(self, data):8 self.data = data9 self.model = None1011 def train(self):12 # Code to train the model13 pass1415class Predictor:16 def __init__(self, model):17 self.model = model1819 def predict(self):20 # Code to make predictions using the model21 pass
La refactorizacion ha hecho que el código sea mucho más modular, separando tareas por cada clase, y permitiendo que otros cambios se amolde a la estructura de las clases. Estos es una buena practica, ya que divide el problema, y permite dejar cada clase como un objeto separado, preocupándose cada una de ella a lo que mejor saben hacer, más que tener una clase global que unifique todo.
Open-Closed Principle (OCP)
El segundo principio se llama open-closed principle, y fue introducido por Bertrand Meyer en 1998. Este principio dice lo siguiente:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. (Las entidades de software (clases, módulos, funciones, etc.) deberían estar abiertas a la extensión, pero cerradas a la modificación)
No es tan claro al leerlo, pero su esencia es que cualquier clase debe ser escrita en tal manera que sea usada como es. La clase puede ser “extendida” (mejorada), pero no debe ser modificada. En términos simples, tu puedes modificar la clase, pero su modificaciones debe ser en base a agregar código adicional, no cambiar el actual. Un ejemplo de este principio es el siguiente:
1class DataLoader:2 def __init__(self, loaded_type):3 if loaded_type == 'SQL':4 self.loaded_type = loaded_type5 elif loaded_type == 'API':6 self.loaded_type = loaded_type7 elif loaded_type == 'CSV':8 self.loaded_type = loaded_type910 def load_data(self):11 if self.loaded_type == 'SQL':12 return self._load_from_sql()13 elif self.loaded_type == 'API':14 return self._load_from_api()15 elif self.loaded_type == 'CSV':16 return self._load_from_csv()171819 def _load_from_sql(self):20 # Code to load data from SQL database21 pass22 def _load_from_api(self):23 # Code to load data from an API24 pass25 def _load_from_csv(self):26 # Code to load data from a CSV file27 pass2829
El problema del código es que funciona perfectamente para los tipos de datos descritos, pero para cualquier otro tipo de dato, se genera un problema. Si necesitas añadir otra fuente, se deberá ajustar la clase previa, y no solo añadir algo nuevo. Una forma de solucionarlo en la siguiente:
1from abc import ABC, abstractmethod23class DataLoader(ABC):4 @abstractmethod5 def load_data(self):6 pass789class SQLDataLoader(DataLoader):10 def load_data(self):11 # Code to load data from SQL database12 pass1314class APIDataLoader(DataLoader):15 def load_data(self):16 # Code to load data from an API17 pass1819class CSVDataLoader(DataLoader):20 def load_data(self):21 # Code to load data from a CSV file22 pass2324# Usage25def get_data(loader: DataLoader):26 return loader.load_data()2728## SQL29sql_loader = SQLDataLoader()30data = get_data(sql_loader)
En este caso el código se modularizo con una clase DataLoader, la cual tiene un método load_data, que no esta explicitamente definido, pero que sirve para que subclases lo usen de la manera que quieran, tal como: SQLDataLoader, APIDataLoader, etc. Si es necesario añadir otra forma de extracción de datos, se agrega otra subclase y no se modifica el código anterior.
Liskov Substitution Principle (LSP)
Este principio fue introducido por Barbara Liskov en la OOPSLA conferencia de 1987. El principio reclama lo siguiente:
Subtypes must be substitutable for their base types. (Los subtipos deben poder sustituir a sus tipos base.)
Este principio en términos simples demuestra a que si tienes una pieza de código que funciona con una clase, entonces cualquier subclase debería ser capaz de usar los métodos de la clase madre sin romper el código. Esta idea se plantea en el siguiente código:
12class DataLoader:3 def load_data(self):4 pass56 def transform_data(self, data):7 pass89class SQLDataLoader(DataLoader):10 def load_data(self):11 return [{"id": 1, "raw_value": "100"}]1213 def transform_data(self, data):14 return [{"id": d["id"], "value": int(d["raw_value"])} for d in data]1516class PreprocessedLoader(DataLoader):17 def load_data(self):18 return [{"id": 1, "value": 100}]1920 def transform_data(self, data):21 raise NotImplementedError("Data ya viene transformada")22
Si pensamos que PreprocessedLoader es una clase que ya tiene los datos pre-procesados, no tiene sentido que tenga un método que transforme sus datos (transform_data), ya que ellos ya vienen directamente procesados de la fuente. Debido a esto, es mejor construir dos clases de la siguiente manera:
1class DataLoader(ABC):2 @abstractmethod3 def load_data(self):4 pass56 class Transformable(ABC):7 @abstractmethod8 def transform_data(self, data):9 pass1011 class SQLDataLoader(DataLoader, Transformable):12 # carga y transforma1314 class PreprocessedLoader(DataLoader):15 # solo carga, no promete transformar
De esta forma se soluciona el problema, y se logra poder tener una estructura más robusta.
Interface Segregation Principle (ISP)
El principio ISP viene de la misma idea que el single-responsibility principle. Su principal objetivo es:
Clients should not be forced to depend upon methods that they do not use. Interfaces belong to clients, not to hierarchies. (Los clientes no deberían verse obligados a depender de métodos que no utilizan. Las interfaces pertenecen a los clientes, no a las jerarquías)
Un cliente se entiende como una clase o subclase, y un interfase como los métodos y atributos. En simple, si una clase no usa un particular atributo o método, entonces el método debe ser agregado dentro de una clase distinta. Un ejemplo ilustrativo es el siguiente:
1class ModelsHandler:2 def __init__(self):3 pass45 def train_model(self, data):6 # Code to train the model7 pass89 def get_feature_importance(self):10 # Code to calculate feature importance11 pass1213class XGBoostHandler(ModelsHandler):14 def train_model(self, data):15 # Code to train the XGBoost model16 pass1718 def get_feature_importance(self):19 # Code to calculate feature importance for XGBoost20 pass2122class LinealRegression(ModelsHandler):23 def train_model(self, data):24 # Code to train the LightGBM model25 pass2627 def get_feature_importance(self):28 # Code to calculate feature importance for LightGBM29 raise NotImplementedError("Fax functionality not supported")30
Este código tiene el problema de que ModelHandler contiene un método que no puede ser replicado a todas las subclases, especialmente para el modelo de regresión lineal. La forma de solucionarlo es la siguiente:
1from abc import ABC, abstractmethod2class ModelsHandler(ABC):3 @abstractmethod4 def train_model(self, data):5 pass678class XGBoostHandler(ModelsHandler):9 def train_model(self, data):10 # Code to train the XGBoost model11 pass1213 def get_feature_importance(self):14 # Code to calculate feature importance for XGBoost15 pass1617class LinealRegression(ModelsHandler):18 def train_model(self, data):19 # Code to train the LightGBM model20 pass21
La nueva clase ModelsHandler solo contiene train_model metodo, dejando cualquier otro metodo para la subclase, no la clase madre. Esta es la forma de solucionar este problema. Si un modelo tiene una especifica función, esa función solo sera declarado a este modelo, no a todos los modelos.
Dependency Inversion Principle (DIP)
Este principio es el siguiente:
Abstractions should not depend upon details. Details should depend upon abstractions. (Las abstracciones no deberían depender de los detalles. Los detalles deberían depender de las abstracciones).
Este principio se puede expresar de mejor manera con el siguiente ejemplo:
1class DataSourceSQL:2 def load_data(self, data):3 # Code to load and preprocess data from SQL4 pass567class RouterOptimizer:8 def __init__(self):9 self.data_source = DataSourceSQL()1011 def optimize(self):12 # Code to optimize routing based on data13 pass1415 def run(self, data):16 processed_data = self.data_source.load_data(data)17 return self.optimize(processed_data)18
Aqui tenemos un algoritmo de optimizacion de rutas de despacho, que depende de la base de datos para construir la ruta más optima. En este codigo, la clase RouterOptimizer depende de la clase DataSource, pero si agregamos otra fuente de datos, tendriamos que modificar tanto la clase del optimizador como el DataSource. Este comportamiento no es optimo, así que la forma de solucionarlo es la siguiente:
1from abc import ABC, abstractmethod234class DataSourceInterface(ABC):5 @abstractmethod6 def load_data(self, data):7 pass8910class DataSourceSQL(DataSourceInterface):11 def load_data(self, data):12 # Code to load and preprocess data13 pass1415class DataSourceAPI(DataSourceInterface):16 def load_data(self, data):17 # Code to load and preprocess data18 pass192021class RouterOptimizer:22 def __init__(self, data_source: DataSourceInterface):23 self.data_source = data_source2425 def optimize(self):26 # Code to optimize routing based on data27 pass2829 def run(self, data):30 processed_data = self.data_source.load_data(data)31 return self.optimize(processed_data)323334api_source = DataSourceAPI()35optimizer = RouterOptimizer(api_source)36optimizer.run(data)
En esta nueva forma, la clase DataSource fue construida como una interfase, por lo que, se puede agregar cualquier otra fuente de datos, sin afectar a la clase del algoritmo de optimización, removiendo la dependencia antes descrita.
Conclusión.
Buenas practicas o frameworks son marcos de referencia, aunque no siempre son seguidos al pie de la letra, es bueno saberlos ya que han sido iterados y probados a lo largo del tiempo, y siguen manteniéndose como excelentes estándares en la industria.
Referencias
1: https://www.youtube.com/playlist?list=PL4CE9F710017EA77A .
2: https://www.youtube.com/watch?v=agkWYPUcLpg&t=1s
3: https://realpython.com/solid-principles-python/