“Si hacer debugging es el proceso de remover errores en el software, entonces la programación debe ser el proceso de introducir esos errores” — Edsger Dijkstra
Ouch Dijkstra, ¡eso dolió!
Pero si lo piensas por un instante, lo que dice Dijkstra tiene mucho sentido. ¿No te ha sucedido que sueles pasar más tiempo buscando errores del que quisieras? No te preocupes, eso a mi también me pasa.
El reto es que cada vez que introducimos cambios en el código, estamos abriendo la posibilidad de introducir errores, es decir, extiende tu código y evita más errores. Si añadirlos es parte normal del proceso, la pregunta es: ¿qué podemos hacer entonces para mitigar o reducir esos errores?
La pregunta anterior puede tener varias respuestas, pero este artículo me gustaría enfocarlo en una de las alternativas: el principio de abierto cerrado.
El principio de abierto cerrado es el segundo principio de diseño SOLID, el cual se puede resumir así: mejor extiende tu código en vez de modificarlo.
En este artículo, pretendo mostrarte cómo utilizar el principio para que reduzcas el riesgo de introducir errores en tu código.
Antes de mostrarte cómo hacerlo, vamos a dar un vistazo general a los demás principios SOLID.
Introducción rápida a los principios de diseño
Los principios de diseño SOLID se los debemos al Robert Martin, mejor conocido como el tío Bob.
Él no inventó como tal los principios, sino que se dedicó a organizarlos y promoverlos como ideas relevantes para diseñar software. Luego, alguien le hizo caer en la cuenta que esas ideas coincidían con la sigla SOLID.
Los 5 principios del tío Bob son:
- Single Responsibility Principle. Principio de única responsabilidad. Tus clases, módulos o componentes deberían tener una sola razón para ser modificados. ¿Has visto esas navajas suizas que tienen muchas herramientas en un solo dispositivo? Bueno, una navaja suiza es un ejemplo de lo que no debes hacer cuando aplicas este principio. La razón es que cuando tienes una clase que hace demasiadas cosas, va a estar sufriendo cambios constantemente, ya que cada cosa va a requerir cambios en algún punto.
- Open-Closed Principle. Principio de abierto cerrado. El principio del que trata este artículo. Lo discutiremos en detalle más adelante.
- Liskov Substitution Principle. Principio de sustitución de Liskov. Este es uno de los principios que me ha resultado más difícil de entender. En síntesis, habla de que cuando tienes herencia de clases, debe haber una coherencia entre la clase padre y la clase hija. La coherencia debe ser tal que si tu programa empieza a usar un objeto de la clase hija en vez de un objeto de la clase padre, no debería haber ningún problema en el programa.
- Interface Segregation Principle. Principio de Segregación de Interfaces. Cuando tienes una clase compartida por múltiples clientes, a veces es mejor separarla en clases más específicas. La razón es que puedes introducir errores que afectan a otros clientes, cuando desarrollas una funcionalidad para alguno en particular.
- Dependency Inversion Principle. Principio de Inversión de Dependencias. Aquí el tío Bob plantea varias ideas, pero la que me parece más interesante es que deberías programar contra abstracciones. Es decir, si estás usando un lenguaje tipado como TypeScript, Java o C#, es bueno que tus funciones reciban o retornen tipos de interfaces o clases abstractas ya que facilita que luego puedas cambiar la implementación.
Después de esa introducción a muy alto nivel, volvamos al tema central.
Principio de abierto cerrado: Abierto para extensión, cerrado para modificación
Abierto para extensión y cerrado para modificación significa que deberías poder añadir nuevas funcionalidades a tu aplicación sin modificar su código base.
Algunas opciones para que logres implementar el principio:
- A través de herencia.
- Empleando inyección de dependencias.
- Aplicando patrones de diseño como estrategia, método plantilla, decorador, adaptador, entre otros.
Un ejemplo de extender y no modificar
Pasemos a la práctica. Imagina que tienes una función en la cual debes importar datos con distintos formatos, para luego hacer un procesamiento. Supongamos que esa función, en JavaScript, se ve así:
function procesarDatos(formato, datos) { switch(formato) { case 'xml': //transformar datos de XML a objetos... break; case 'csv': //transformar datos de CSV a objetos... break; } //Continua procesamiento de datos }
A simple vista, esta función se ve muy normal. Sin embargo, tiene un inconveniente: cada vez que se requiera soportar un nuevo formato, es necesario modificar la función.
¿Por qué queremos evitar esto? Porque al estar modificando frecuentemente la función, podemos introducir errores que afecten su funcionamiento. Además, una cosa es el procesamiento de datos y otra la transformación. Una función que procesa datos no debería verse afectada por el formato de estos.
Este es un clásico ejemplo del principio abierto cerrado.
¿Cómo podemos entonces cerrar la función a modificaciones, pero mantenerla abierta a extensiones? Anteriormente mencioné algunas opciones. En este caso, usaremos la herencia.
Un primer paso es que creemos clases especializadas en la transformación de datos:
class ConvertidorDatos { constructor(formato) { this.formato = formato; } convertir(datos) { } } class ConvertidorXML extends ConvertidorDatos { constructor() { super('xml'); } convertir(datos) { console.log('convirtiendo desde XML'); //lógica para transformar desde XML... } } class ConvertidorCSV extends ConvertidorDatos { constructor() { super('csv'); } convertir(datos) { console.log('convirtiendo desde CSV'); //lógica para transformar desde CSV... } }
Como puedes ver en el código anterior, la conversión desde cada formato se hace en una clase especializada. Y, ¿esto qué implica? Que si el día de mañana necesitamos añadir un nuevo formato (por ejemplo, JSON), deberemos entonces añadir una nueva clase y cualquier modificación que tengamos que hacer, queda totalmente aislada de lo demás. Esto evita que dañemos lo que ya funciona.
Todo esto está muy bien, pero te debes estar preguntando cómo queda la función procesarDatos. Bueno, esa función quedaría así:
function procesarDatos(formato, datos) { //convertidor sería un objeto de tipo ConvertidorDatos const convertidor = obtenerConvertidor(formato); const datosTransformados = convertidor.convertir(datos); //Continua procesamiento de datos }
Como puedes notar, a la función le quitamos la responsabilidad de transformar los datos, y la dejamos solo con el deber de procesarlos (principio SOLID # 1: principio de única responsabilidad).
Adicional al anterior, apareció una nueva función: obtenerConvertidor. Esta función tiene la responsabilidad de devolver el objeto de tipo ConvertidorDatos que corresponda con el formato que le estamos dando. Para esto, esa función podría implementar un patrón de método fábrica.
De esta forma, hemos logrado cerrar la función procesarDatos a modificaciones ya que cuando se requiera soportar un nuevo formato de datos, no es necesario modificar la función. Por el contrario, la manera de hacerlo es extendiendo, creando una nueva clase convertidora de datos.
¿Qué te pareció este principio? ¿Ves fácil aplicarlo en tus desarrollos?
Puedes ver todos los enlaces al código de este post en los siguientes enlaces
- https://gist.github.com/manuelzapata/ec3abdfbab280084073abf3350b19e86
- https://gist.github.com/manuelzapata/da5326f2880a5b3dc7ebd86b8e262e17
- https://gist.github.com/manuelzapata/171e01341b5d0ff3c7927d86366f25f3
Artículo escrito por Manuel Zapata.
Manuel se desempeña como desarrollador y arquitecto de software. Hace unos meses lanzó su sitio web donde está ofreciendo un mini curso gratuito de principios de diseño.
Si quieres formar parte de la comunidad KeepCoding, compartiendo información relevante sobre desarrollo Web, Mobile, Big Data o Blockchain puedes escribirnos a [email protected] .