Componer soluciones en Javascript
Componer soluciones pequeñas para resolver problemas complejos
El desarrollo de software va de resolver problemas. Y para ello tendemos a romperlos en problemas mas pequeños que nos permitan ir abordando cada uno por separado. Es el famoso divide and conquer.
Una vez que hemos resuelto cada sub-problema solo nos queda aunar todas las soluciones en una que resuelva el problema inicial. Mientras más divisiones tenga un problema mucho más fácil de testear y de reutilizar. Piensalo así: tu día a día se basa en resolver problemas pero si no dedicas el tiempo suficiente a separar cada solución, te encontraras a ti mismo resolviendo una y otra vez los mismos problemas.
Por ejemplo, obtener el primer elemento de un array es un miniproblema bastante común que tendemos a solucionar con su versión imperativa:
arr[0]
Provocando que tengamos que por cada pieza de código que hable de arrays y que se necesite acceder al primer elemento, tengamos que recurrir a ese código cada vez:
otherArray[0]
En lugar de identificar ese problema y solucionarlo de una vez por todas:
const head = arr => arr[0]
Así nace la función head que dado cualquier array nos devuelve el primer elemento y además podemos agregarle comportamiento extra para cuando por ejemplo el array esté vacío (ergo su primer elemento sea undefined):
const head = arr => Maybe.of(arr[0]);
Si no sabes que es Maybe te recomiendo que leas este artículo.
Y me preguntareís: ¿Qué sentido tiene usar head(arr)
en lugar de arr[0]
si el código a escribir es más o menos el mismo?
La respuesta es que una función se puede componer.
Composición de funciones
Las funciones son como piezas de un puzzle donde para poder unirlas unas con otras el enganche macho-hembra ha de coincidir. Pero como pasa en la vida misma que dos piezas no encagen no significa que nada pueda unirlas.
Los conectores de las funciones son sus argumentos y sus valores de retorno. Esto es si tengo una función F que toma un tipo A y devuelve un tipo B y después tengo una función G que toma un tipo B y devuelve un tipo C, la composición de F ∘ G será una función que tome un tipo A y devuelva un tipo C.
Parece un poco obtuso lo se por eso te dejo a continuación un dibujin:
Las flechas representan las funciones y los circulos los tipos, porque si lo piensas ¿una función qué es? Una transformación de un tipo a otro. Por ejemplo la función splitBySpace
tiene esta anotación en Javascript:
splitBySpace :: String -> Array
¿Tiene sentido no?.
Si tenemos una función que devuelve B y tenemos otra que recibe B, es como si tuvieramos dos piezas de puzzle que encajasen perfectamente.
La clave está en que a diferencia de un puzzle donde cada maldita pieza solo puede encagar con otra maldita pieza (quien haya intentado hacer un puzzle de mil piezas de un trigal entenderá perfectamente el rintintín que hay en la frase), las funciones pueden encajar con muchas otra funciones diferentes.
La función splitBySpace
encaja con la función head
y con la función reverse
respectivamente pudiendo de esta forma generar funciones nuevas que combinen funcionalidad:
// splitAndReverse :: String -> Array
const splitAndReverse = str => reverse(splitBySpace(str))
// firstWord :: String -> String
const firstWord = str => head(splitBySpace(str))
La composición final no es más que pasarle el resultado de una función como parámetro a la siguiente. Y así con todas.
Además tanto firstWord
como splitAndReverse
no son solo funciones que han nacido de componer otras, si no que además pueden ser parte de otras composiciones para seguir creando funciones:
toUpperCase :: String -> String
toUpperCase = str => str.toUpperCase() // transformo la función en algo composable.
toUpperCaseFirstWord :: String -> String
toUpperCaseFirstWord = str => toUpperCase(firstWord(str));
Lo de
Array a
que he mostrado antes se refiere a un Array que dentro tiene tipos ‘a’. Donde ‘a’ es cualquier tipo. Cuando defino una funciónArray a
→a
me refiero a que tenemos un Array de tipos ‘a’ y devuelve un tipo ‘a’. Es decir si tengo un array de numeros, head devuelve un numero. Es bastante intuitivo.
Componer funciones con Javascript
Lo de realizar la composición manualmente es útil para entender el procedimiento de composición pero cuando tenemos más de 3 funciones el proceso se hace tedioso e ilegible. Es por eso que nos urge tener una utilidad que nos permita abstraernos del proceso de composición mediante el aislamiento de los detalles de implementación.
Vamos a definir una función compose que dado un numero N de funciones nos devuelva una nueva función que sea el resultado de haber compuesta todas:
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
Toma castaña.
Básicamente estoy tomando un array de funciones y reduciendolas por la derecha — esto es porque la composicón ocurre de derecha a izquierda, es decir la ultima función es la primera en aplicarse —. El valor inicial del reduce es x
— que representa el parámetro que se le pasa a la función compuesta — y en las siguientes iteraciónes x
va a ir tomando el valor resultado de aplicar la función actual al valor anterior.
Se que estas cosas son terribles sobre todo si estas empezando, por lo que te dejo a continuación un poco de pseudocódigo:
compose (funciones) {
funcionesDadasLaVuelta = reverse(funciones) // la primera es la ultima ahora
const funcionCompuesta = param => {
let res = param;
for (var i = 0; i < funcionesDadasLaVuelta; i++) {
res = funcionesDadasLaVuelta[i](res)
}
return res;
}
return funcionCompuesta;
}
Bien, ahora podemos reescribir los ejemplos anteriores para utilizar nuestra utilidad:
const firstWord = str => compose(head, splitBySpace)(str)
const toUpperCaseFirstWord = str => compose(toUpperCase, firstWord)(str);
Programación tácita
Con fuerza tónica en la primera a y acentuada por ir de esdrújula por la vida, que no confundir con la taza pequeña en la que estoy disfrutando ahora mismo mi otra pasión en este mundo aparte de componer funciones: el café.
Bueno al tema. La programación tácita, también conocida como point-free style, es un paradigma de programación que consiste en razonar con nuestras funciones sin hacer referencia explicita a sus argumentos.
En otras palabras, si nuestra utilidad compose toma N funciones y devuelve una función que toma un parámetro y devuelve un resultado, podemos crear nuevas funciones sin necesidad de hacer referencia a sus argumentos. De tal forma que en lugar de hacer:
const firstWord = str => compose(head, splitBySpace)(str)
Podemos hacer simplemente:
const firstWord = compose(head, splitBySpace)
Fijaros como sería hacer esto sin composición:
const firstWord = str => {
const arr = str.split(' ');
return arr[0];
};
El resultado es el mismo — una función que dada un string
nos devuelve la primera paralabra — en cambio la potencial reutilización es bastante limitada. De entrada si mañana queremos volver a separar un string por espacios tendremos que volver a escribir str.split(“ “)
ya que ese código esta complementamente acoplado a la función firstWord
.
Aún separando en funciones y ganando reutilización, el código empleado sin composición es mayor:
const head = arr => arr[0];
const splitBySpace = str => str.split(' ');
const firstWord = str => {
const arr = splitBySpace(str);
return head(arr);
};
Como en un enfoque imperativo una función necesita unos argumentos y para aplicarla tengo que llamarla pasándoselos, nos obliga a referenciar una y otra vez sus parámetros y por tanto huir el del paradigma point-style.
Aplicación parcial de funciones
Hasta ahora hemos visto como poder componer funciones de arity 1 (un argumento) pero ¿qué ocurre cuando tengo una función de un arity superior?
Por ejemplo, echemos un vistazo a la función split
en Javascript:
cadena.split([separator])
Primero la función no es standalone, sino que está contenida en el prototype String, esto nos obliga a tener que hacer referencia a sus argumentos para poder utilizarla.
Podemos aplicar el patrón de inversion of control para solucionar este problema:
const split = (str, separator) => str.split(separator)
Ahora el control sobre el uso del split
de String lo tiene la función split que nos hemos creado, de tal forma que podemos decidir como aplicarla y usarla.
El patrón de inversion of control [1] se suele usar de forma intercambiable con el patrón de inyección de dependencias. No es correcto. La inyección de dependencias se implementa con inversión de control [2], pero no son patrones diferentes.
Ahora veremos que ocurre si quiero componerla con una función llamada toUpperCase
que toma un String y devuelve un String, de tal forma que obtengamos una función que primero pase una cadena a mayúsculas y después lo transformemos a un Array diviendo la cadena mediante un separador:
Las funciones no se pueden enganchar. Es como intentar conectar el cargador del móvil de un pais civilizado (tipo F) en una cafetería de Reino Unido.
Esto ocurre porque split
es una función completa, esto quiere decir que se aplica en un único paso tomando todos sus argumentos (en nuestro caso 2) y nuestro problema es que solo podemos proporcionarle un parámetro a la vez.
Para solucionar este problema tenemos que convertir split
en una función parcial, esto es que se pueda aplicar parcialmente pasandole solo parte de sus argumentos.
Una función es parcial cuando le pasamos solo parte de sus argumentos y nos devuelve una función que toma el resto de argumentos.
Así podemos convertir split en:
const split = separator => str => str.split(separator);
Una función que toma un separador y devuelve una función (la que hace el split) que toma una cadena y devuelve el resultado de aplicar split sobre la cadena y el separador.
¿Recuerdas la función firstWord
? Podemos redefinirla usando nuestro nuevo split:
const firstWord = compose(head, split(' '))
Nuestro split(‘‘)
es una aplicación parcial que produce otra función que es la que toma la cadena y le hace el split. Cuando la función está completamente aplicada se comporta igual que la función splitBySpace que teniamos antes.
Cuando una función puede ser aplicada parcialmente decimos que está curried. Podemos transformar funciones no-curried a funciones curried usando utilidades como R.curry (disponible en ramda).
Resolviendo problemas reales
Imaginemos que queremos implementar el protocolo HTTP para poder recibir peticiones y generar respuestas en distitnos formatos. Para ello los clientes (que han de seguir también este protocolo) nos van a enviar peticiones que tienen esta pinta:
GET /users HTTP/1.1
Host: elrincondev.com
User-Agent: ExampleBrowser/1.0
Accept: */*
Nuestro cometido será parsear esas peticiones, extraer la información útil (método, path, headers, body, etc…) y generar una respuesta HTTP en consecuencia que enviaremos a los clientes.
Podemos hacerlo todo en una función monolítica y el día que haya que reutilizar todo hacer copy paste (con desastroso resultado), pero como acabamos de aprender que es la composición vamos a y ir solucionando cada problema y despues a componer la solución final.
El problema
Queremos parsear peticiones HTTP para poder enrrutarlas a los distintos controladores y entonces poder generar una respuesta también usando el protocolo HTTP.
Los sub-problemas
Parsear peticiones HTTP
Enrrutar peticiones
Generar respuestas HTTP
Nuestro equipo ha sido encomendado a solucionar el primer sub-problema. De tal forma que dado una petición — como la definida arriba — debemos de obtener el método http y el path. De momento nuestro programa solo va a procesar peticiones GET (que no llevan body)
Las soluciones
Vamos a desgranar nuestro sub-problema en problemas más pequeños y vamos a ir uno a uno solucionándolos. Finalmente compondremos las soluciones y podremos dar por el resuelto nuestro sub-problema. Los pasos a realizar para poder obtener la información que nos han solicitado son:
Debemos representar cada línea de la petición.
Seguidamente debemos quedarnos solo con la primera línea que contiene la info que necesitamos.
Tenemos que separar toda la info de esa línea.
Finalmente debemos quedarnos solo con el método y el path.
Empezamos definiendo una función que dado una cadena nos devuelva un array donde cada elemento sea una línea. Así tendremos una función que irá del tipo String
al tipo Array String:
const splitByLine = split("\n");
He usado el split que hemos definido anteriormente, de tal forma que splitByLine
es ahora una función que toma un string y devuelve un array de string.
El carácter especial
\n
representa un retorno de carro. Por lo que es el que usaremos para separar una línea de otra.
Ahora necesitamos un mecanismo para poder obtener solo el primer elemento de un Array. Ya lo teniamos definido de antes: nuestra función head
.
Seguidamente vamos a separar cada elemento de la línea de información de la request, es decir, de “GET /users HTTP/1.1“ necesitamos obtener el método y el path:
const getRequestInfo = split(" ");
De igual forma que con splitByLine, getRequestInfo devuelve un Array con toda la info de la petición.
Finalmente como de ese supuesto Array solo necesitamos los 2 primeros elementos, vamos a hacernos otra función:
const getFirstPair = ([first, two]) => [first, two];
Uso desectructuración para solo quedarme con los dos primeros elementos y devolverlos en un pair — o array de dos elementos —.
Componiendo la solución final
Haciendo uso de nuestro compose vamos a ir componiendo todas las sub-soluciones para obtener una función que solucione el problema original: obtener el método y el path de una request:
Recuerda que el orden en el cual le pasamos las funciones al compose es invertido debido a que por definición la última función es la primera en aplicarse. Esto es fácil de entender en un compose de dos funciones:
compose2 = (f, g) => x => f(g(x));
Para poder pasarle el parámetro a f
, tenemo que aplicar primero g
.
Adiccionalmente puedes hacer uso de otra utilidad llamada pipe, el cual se implementa con un reduceLeft y nos permite pasar las funciones en orden de ejecución:
const parseRequest = pipe(
splitByLine,
head,
getRequestInfo,
getFirstPair
);
Ambas funciones (pipe y compose) realizan una composición como hemos detallado en el artículo, tan solo cambia el como se aplican sus argumentos.
Puedes encontrar la utiliad pipe en ramda.
Conclusión
Bien podriamos haber resuelto el problema desde el princpio con un código parecido al siguiente:
const parseRequest = request => {
const lines = request.split('\n');
const firstLine = lines[0];
const [method, path] = firstLine.split(' ');
return [method, path];
};
Pero esta versión presenta varios inconvenientes:
Es más verbosa al tener que imperativamente definir cada paso e ir referenciando todos los argumentos entre medias.
No nombramos procesos si no resultados. En nuestro ejemplo anterior veiamos claramente las transformaciones que ibamos haciendo sobre la request (splitByLine, getRequestInfo, getFirstPair…). Aquí solo nombramos los resultados de los procesos (lines, firstLine…). Es mucho más sencillo nombrar procesos (ya que por norma general vienen pre-definidos en el dominio del problema), que los resultados producidos por cada proceso (que normalente son la representación de un resultado intermedio).
Solo podemos probar esta función en integración. De la forma anterior podiamos tener testeado unitariamente cada proceso.
Si mañana en otro desarrollo necesitamos coger el primer elemento de un array o separar un String en un Array de líneas, deberemos volver a picarnos todo ese código.
Es más dificil agregar o eliminar procesos a esa función, obligandonos muchas veces a repasar la nomenclatura de las variables intermedias.
La función viola el princpio de única responsabilidad. Nuestro parseRequest creado por composición tiene una sola responsabilidad: Aunar procesos. En cambio esta función — al definir impertaviamente cada proceso — hace muchas cosas más.
La composición tiene múltiples ventajas. No solo nos ahorra código y variables intermedias si no que nos presenta un marco de pensamiento. Así, pensamos en como solucionar cada paso y como componer soluciones en lugar de tener que decir constantemente que es lo que queremos hacer. Razonamos entorno a procesos y en terminos de Input y Output.
Además la composición es una técnica muy usada, no solo en funciones como ya hemos visto, sino en objetos o en patrones de las librerías más populares. Por ejemplo, los HOCs de React son candidatos perfectos para la composición ya que como minimo todos tienen este anotación:
Component -> Component
Es decir, toma un Component y devuelve otro Component. Por tanto se podrían componer para generar funciones que aunen el comportamiento de varios HOCs.
En definitiva, estamos rodeados de composición.
Nada más por hoy :).
Excelentes aportes !!!