Hoy voy a hablar de los principios SOLID con ECMAScript 6, el nuevo estándar de JavaScript.
Hace ya 2 años, traduje 5 artículos de Derek Greer, que trataban este tema. Pero hoy, con la llegada del nuevo estándar de ECMAScript, y la nueva forma de crear clases en JavaScript, tiene mucho sentido volver a retomarlo.
En los artículos que traduje, Derek Greer utilizó unos ejemplos reales, pero bastante complejos, y desde mi punto de vista poco pedagógicos.
En esta ocasión, pretendo explicar los principios SOLID con ejemplos, tal vez no muy reales, pero sí pedagógicos. Por eso además los escribiré en español, la lengua de este artículo.
Vamos a empezar con un pequeño código de ejemplo:
class ProcesadorDeDatos {
procesaDatos(datos) {
var datosFormateados = this.formatearDatos(datos);
this.imprimirDatos(datosFormateados);
}
formatearDatos(datos) {
console.log("Formateando datos...");
return "******************************"
+"\n"+datos+"\n"
+"******************************";
}
imprimirDatos(datos) {
console.log("Imprimiendo datos...");
console.log(datos);
}
}
var p = new ProcesadorDeDatos();
p.procesaDatos("Principios SOLID con JavaScipt");
Es un código muy sencillo que lo único que hace es formatear unos datos y sacarlos por consola:Formateando datos...
Imprimiendo datos...
******************************
Principios SOLID con JavaScipt
******************************
El principio de responsabilidad única
Un objeto debería tener una sola responsabilidad.
Este principio no dice que un objeto solo pueda hacer una cosa. Lo que dice es que un objeto solo tiene que tener una única responsabilidad. En España tenemos un refrán que lo resume muy bien: "Zapatero a tus zapatos".
Aplicando este principio al ejemplo anterior, nos quedaría lo siguiente:
class FormateadorDeDatos {
formatearDatos(datos) {
console.log("Formateando datos...");
return "******************************"
+"\n"+datos+"\n"
+"******************************";
}
}
class ImpresoraDeDatos {
imprimirDatos(datos) {
console.log("Imprimiendo datos...");
console.log(datos);
}
}
class ProcesadorDeDatos {
procesaDatos(datos) {
var formateador = new FormateadorDeDatos();
var datosFormateados = formateador.formatearDatos(datos);
var impresora = new ImpresoraDeDatos();
impresora.imprimirDatos(datosFormateados);
}
}
var p = new ProcesadorDeDatos();
p.procesaDatos("Principios SOLID con JavaScipt");
De un objeto que lo hacía todo, hemos pasado a 3: uno que se dedica a formatear los datos, otro clase que se dedica a imprimirlos por pantalla, y otro que lo orquesta todo.¿Podíamos haber dejado el objeto "ProcesadorDeDatos" cuyo responsabilidad sería procesar datos? Sí, por qué no. Lo único que su responsabilidad sería más genérica.
Entonces, ¿cómo saber como de específico o de genérico tenemos que crear nuestros objetos para cumplir con el principio de responsabilidad única? Creo que sólo la experiencia da respuesta a esta pregunta.
El principio abierto/cerrado
Las entidades de software (clases, módulos, funciones, etc.) deberían estar abiertas para su extensión, pero cerradas para su modificación.
Esto lo que viene a decir es que debemos pensar en ampliar funcionalidad, no modificando las entidades, sino extendiéndolas.
Imaginemos en el ejemplo anterior que queremos a veces formatear en mayúsculas, otras veces en minúsculas, y otras veces simplemente añadir los asteriscos.
Podíamos hacer algo aśi:
class FormateadorDeDatos {
formatearDatos(datos, tipoDeFormateo) {
console.log("Formateando datos...");
var datosFormateados = "******************************"
+"\n"+datos+"\n"
+"******************************";
switch(tipoDeFormateo) {
case 'M':
return datosFormateados.toUpperCase();
case 'm':
return datosFormateados.toLowerCase();
default:
return datosFormateados;
}
}
}
Así, dependiendo de si nos pasan una 'M', una 'm' u otra cosa, pues se formateará en mayúsculas, en minúsculas, o simplemente se añaden los asteriscos.En tu instinto de programador habrán empezado a saltarlas alarmas. Estarás pensando: "Qué feo es ese código ¿no?" "¿Y qué pasa si queremos añadir otro tipo de formateo, hay que volver a tocar el switch?".
Efectivamente, huele mal. Y el problema es que estamos ampliando funcionalidad modificando la entidad, cuando en realidad lo que pide es que extendamos la entidad para ampliar la funcionalidad.
Vemos como quedaría:
class FormateadorDeDatos {
formatearDatos(datos) {
console.log("Formateando datos...");
return "******************************"
+"\n"+datos+"\n"
+"******************************";
}
}
class FormateadorDeDatosMayusculas extends FormateadorDeDatos {
formatearDatos(datos) {
var datosFormateados = super.formatearDatos(datos);
return datosFormateados.toUpperCase();
}
}
class FormateadorDeDatosMinusculas extends FormateadorDeDatos {
formatearDatos(datos) {
var datosFormateados = super.formatearDatos(datos);
return datosFormateados.toLowerCase();
}
}
Parece que ahora el código hace menos daño a los ojos ¿no?El principio de sustitución de Liskov
Las instancias de tipos base deberían poder ser reemplazados por instancias de sus subtipos sin alterar el correcto funcionamiento del programa.
En el caso anterior, hemos creado 2 clases subtipo: "FormateadorDeDatosMayusculas" y "FormateadorDeDatosMinusculas", y ambos heredan del tipo base "FormateadorDeDatos".
En la llamada a dichas clases podríamos hacer algo así:
class ProcesadorDeDatos {
procesaDatos(datos, tipoDeFormateo) {
var datosFormateados = "";
switch(tipoDeFormateo) {
case 'M':
var formateador = new FormateadorDeDatosMayusculas();
datosFormateados = formateador.formatearDatos(datos, tipoDeFormateo);
break;
case 'm':
var formateador = new FormateadorDeDatosMinusculas();
datosFormateados = formateador.formatearDatos(datos, tipoDeFormateo);
break;
default:
var formateador = new FormateadorDeDatos();
datosFormateados = formateador.formatearDatos(datos, tipoDeFormateo);
}
var impresora = new ImpresoraDeDatos();
impresora.imprimirDatos(datosFormateados);
}
}
var p = new ProcesadorDeDatos();
p.procesaDatos("Principios SOLID con JavaScipt");
p.procesaDatos("Principios SOLID con JavaScipt", 'M');
p.procesaDatos("Principios SOLID con JavaScipt", 'm');
Otra vez, tu instinto de programador te habrá avisado de que algo no está bien en el código anterior. Te estarás preguntando: "¿Por qué tenemos que repetir el código donde se formatean los datos?".Efectivamente hay una mejor forma de hacerlo, y es aplicando el principio de sustitución de Liskov:
class ProcesadorDeDatos {
procesaDatos(datos, tipoDeFormateo) {
var formateador = this.dameFormateador(tipoDeFormateo);
var datosFormateados = formateador.formatearDatos(datos);
var impresora = new ImpresoraDeDatos();
impresora.imprimirDatos(datosFormateados);
}
dameFormateador(tipoDeFormateo) {
switch(tipoDeFormateo) {
case 'M':
return new FormateadorDeDatosMayusculas();
case 'm':
return new FormateadorDeDatosMinusculas();
default:
return new FormateadorDeDatos();
}
}
}
var p = new ProcesadorDeDatos();
p.procesaDatos("Principios SOLID con JavaScipt");
p.procesaDatos("Principios SOLID con JavaScipt", 'M');
p.procesaDatos("Principios SOLID con JavaScipt", 'm');
Como vemos, hemos creado la función "dameFormateador" que retorna el tipo "FormateadorDeDatos", o cualquiera de sus subtipos. Y ahora desde la función "procesaDatos" se trabaja indistintamente con una instancia del tipo "FormateadorDeDatos" o con una instancia de cualquiera de sus subtipos.El principio de segregación de la interfaz
Los clientes no deberían ser forzados a depender de los métodos que no utilizan.
Esto lo que viene a decir es que no crees interfaces demasiado genéricas pues puedes obligar a implementar métodos que los clientes no utilizan.
Te estarás preguntando: "¿qué tiene que ver este principio con JavaScript, si JavaScript no tiene interfaces?".
Si por interfaces nos referimos a algún tipo abstracto proporcionado por el lenguaje para establecer contratos y permitir desacoplamiento, entonces, tal afirmación sería correcta.
Sin embargo, JavaScript tiene otro tipo de interfaces. En el libro Design Patterns: Elements of Reusable Object-Oriented Software por Gamma y otros, nos encontramos con la siguiente definición de una interfaz:
Todas las operaciones declaradas por un objeto se definen por su nombre, los objetos que cogen como parámetros y su valor de retorno. Esto es conocido como firma de la operación. El conjunto de todas las firmas definidas por las operaciones de un objeto se le denomina interfaz del objeto. La interfaz de un objeto engloba el conjunto de solicitudes que se le pueden enviar al objeto.Así pues, independientemente de si un lenguaje proporciona una construcción separada para la representación de las interfaces o no, todos los objetos tienen una interfaz implícita compuesta por el conjunto de propiedades y métodos públicos del objeto.
Vayamos con nuestro ejemplo. Supongamos que queramos tener un tipo base "ImpresoraDeDatos" que obligue a implementar sus métodos, y luego tener 2 subtipos: "ImpresoraDeDatosPorConsola" e "ImpresoraDeDatosEnCapa".
Pero claro, el subtipo "ImpresoraDeDatosEnCapa", necesita de un método extra "dameCapa" para recuperar la capa en donde se va a concatenar el texto.
Un error común es subir este último método al tipo base:
De nuevo, tu instinto de programador te estará alertando, y estarás pensando: "¿por qué en el subtipo que imprime por consola necesito implementar el método que retorna la capa en donde se concatena el texto si no lo necesito?"class ImpresoraDeDatos { imprimirDatos(datos) { throw new Error("Método no implementado"); } dameCapa() { throw new Error("Método no implementado"); } } class ImpresoraDeDatosPorConsola extends ImpresoraDeDatos { imprimirDatos(datos) { console.log("Imprimiendo datos..."); console.log(datos); } dameCapa() { console.log("La impresora por pantalla no necesita capa"); } } class ImpresoraDeDatosEnCapa extends ImpresoraDeDatos { imprimirDatos(datos) { console.log("Imprimiendo datos..."); var capa = this.dameCapa();
capa.innerHTML += "<br />"+datos;
} dameCapa() { return document.getElementById("imprimir"); } }
Efectivamente, el problema viene porque desde el tipo base se obliga a implementar un método que en realidad no siempre es necesario.
Una posible solución sería la siguiente:
class ImpresoraDeDatos {
imprimirDatos(datos) {
throw new Error("Método no implementado");
}
}
class ImpresoraDeDatosPorConsola extends ImpresoraDeDatos {
imprimirDatos(datos) {
console.log("Imprimiendo datos...");
console.log(datos);
}
}
class ImpresoraDeDatosEnCapa extends ImpresoraDeDatos {
imprimirDatos(datos) {
console.log("Imprimiendo datos...");
var capa = this.dameCapa();
capa.innerHTML += "<br />"+datos;
}
dameCapa() {
return document.getElementById("imprimir");
}
}
Parece que ahora el código está mejor ¿no?El principio de inversión de dependencias
No deberíamos depender de concreciones sino de abstracciones.
Podemos hacer una analogía con la relación entre un consumidor y su proveedor. Si el proveedor depende de otras empresas, y una de esas empresas falla, el proveedor falla, y el consumidor se molesta. Sin embargo, si el proveedor delega en el consumidor la responsabilidad de elegir las empresas, el proveedor sólo se preocupa de su labor. Ahora si una empresa falla, es el consumidor el que debe de corregir el problema, ya sea reclamando a la empresa o simplemente cambiando de empresa.
Hasta ahora, en nuestra clase "ProcesadorDeDatos", tenemos algo así:
class ProcesadorDeDatos {
procesaDatos(datos, tipoDeFormateo, tipoDeImpresora) {
var formateador = this.dameFormateador(tipoDeFormateo);
var datosFormateados = formateador.formatearDatos(datos);
var impresora = this.dameImpresora(tipoDeImpresora);
impresora.imprimirDatos(datosFormateados);
}
dameFormateador(tipoDeFormateo) {
switch(tipoDeFormateo) {
case 'M':
return new FormateadorDeDatosMayusculas();
case 'm':
return new FormateadorDeDatosMinusculas();
default:
return new FormateadorDeDatos();
}
}
dameImpresora(tipoDeImpresora) {
switch(tipoDeImpresora) {
case 'C':
return new ImpresoraDeDatosPorConsola();
case 'c':
return new ImpresoraDeDatosEnCapa();
default:
return new ImpresoraDeDatos();
}
}
}
var p = new ProcesadorDeDatos();
p.procesaDatos("Principios SOLID con JavaScipt", "M", "C");
Lo primero que hay que identificar son las dependencias que esta clase tiene, que son "FormateadorDeDatos" e "ImpresoraDeDatos".Podemos pues mejorar la implementación haciendo lo siguiente:
class ProcesadorDeDatos {
constructor(tipoDeFormateo, tipoDeImpresora) {
switch(tipoDeFormateo) {
case 'M':
this.formateador = new FormateadorDeDatosMayusculas();
break;
case 'm':
this.formateador = new FormateadorDeDatosMinusculas();
break;
default:
this.formateador = new FormateadorDeDatos();
}
switch(tipoDeImpresora) {
case 'C':
this.impresora = new ImpresoraDeDatosPorConsola();
break;
case 'c':
this.impresora = new ImpresoraDeDatosEnCapa();
break;
default:
this.impresora = new ImpresoraDeDatos();
}
}
procesaDatos(datos) {
var datosFormateados = this.formateador.formatearDatos(datos);
this.impresora.imprimirDatos(datosFormateados);
}
}
var p = new ProcesadorDeDatos("M", "c");
p.procesaDatos("Principios SOLID con JavaScipt");
Con esto hemos identificado las dependencias y las hemos puesto como atributos de clase, pero la elección de los subtipos la sigue haciendo la propia clase en vez del cliente.Para cumplir con este último principio deberíamos tener algo así:
class ProcesadorDeDatos {
constructor(_formateador, _impresora) {
this.formateador = _formateador;
this.impresora = _impresora;
}
procesaDatos(datos) {
var datosFormateados = this.formateador.formatearDatos(datos);
this.impresora.imprimirDatos(datosFormateados);
}
}
var p = new ProcesadorDeDatos(new FormateadorDeDatosMayusculas(),
new ImpresoraDeDatosEnCapa());
p.procesaDatos("Principios SOLID con JavaScipt");
De esta forma, la elección del tipo de formateador y de impresora, ya no depende de la clase "ProcesadorDeDatos" sino del cliente que instancia esta clase.¿Mejor, no?
Notas:
Nota 1: el código fuente de los ejemplos de este articulo están en mi GitHub.Nota 2: los ejemplos de este artículo están realizados con BabelJS que te permite ejecutar código ECMAScript 6 en los navegadores actuales que no soportan todavía dicho estándar.
Nota 3: las imágenes de este artículo son de Derick Bailey.
Nota 4: mis traducciones de los 5 artículos de Derek Greer son las siguientes:
Muchas gracias amigo, la verdad se me hacía difícil de entender principalmente los últimos 3 principios. Pero con los ejemplos que diste y la explicación, fue más fácil de entender. Saludos.
ResponderEliminar