FP — Composición de objetos con reducers
En el post anterior me adentré un poco en el mundo de la programación funcional (FP) explicando algunas de sus reglas, convenciones y ejemplo con dos de las funciones más usadas: curry y compose. Sin embargo me gustaría ir comentando casos prácticos para ver su verdadero potencial y el cómo puede facilitarnos reutilizar y optimizar en gran medida nuestro código. Hoy hablaré de los reducers o funciones reductoras.
Definiremos como reducer aquellas funciones que producirán un objeto en base a tres parámetros:
- Una lista de valores o propiedades
- Una función de transformación
- Un valor inicial
Generalmente el caso más común es el de la función Array.prototype.reduce
para generar un resultado en base a un array.
// Sumar todos los números de un array
[1, 2, 3, 4, 5].reduce((a, b) => a + b, 0) // 15
En el ejemplo podemos identificar fácilmente los 3 elementos necesarios:
Sin embargo necesitamos los 3 elementos para poder usar la función reduce
, por lo que no podríamos usarla junto a compose o curry. Para ello podemos recurrir de nuevo a alguna librería que nos provea una alternativa diseñada para FP como nuestra amiga Ramda.
Componiendo objetos con reduce
A menudo necesitamos transformar los objetos que nos provee un origen de datos externo a nuestra aplicación de modo que se adapten a lo que necesitamos. Un caso común es el de consultar una API REST para obtener los datos que necesitamos.
fetch('http://my-api.com/people/1/')
.then(res => res.json())
.then(people => console.log(people)){
id: 1,
name: "John Doe",
birthDate: 157766400000,
origin: "ES"
}
Dicha API nos provee las fechas como enteros epoch time. Podríamos tener una función que las transforme de modo que podamos manejar objetos Date
nativos de Javascript.
const transformUser = (user) => Object.assign({}, user, {
birthDate: new Date(user.birthDate),
})
fetch('http://my-api.com/people/1/')
.then(res => res.json())
.then(transformUser)
.then(people => console.log(people)){
id: 1,
name: "John Doe",
birthDate: "1975-01-01T00:00:00.000Z",
origin: "ES"
}
Si analizamos la función transformUser
podríamos distinguir los 3 elementos que necesitamos para una función reducer:
- Una lista de elementos: las propiedades
id
,name
,birthDate
yorigin
- Una función de transformación:
Object.assign
- Un valor inicial:
user
Vamos a intentar lograr lo mismo con la función reduce
ayudándonos de las funciones compose
y toPairs
que nos dará un array con parejas de clave-valor con las propiedades del objeto.
import { compose, reduce, toPairs } from 'ramda'
const reduceProps = (result, [key, value]) => {
result[key] = (key === 'birthDate') ? (new Date(value)) : value
return result
}
const reduceUser = compose(reduce(reduceProps, {}), toPairs)
fetch('http://my-api.com/people/1/')
.then(res => res.json())
.then(reduceUser)
.then(people => console.log(people)){
id: 1,
name: "John Doe",
birthDate: "1975-01-01T00:00:00.000Z",
origin: "ES"
}
Sencillo ¿no? Compliquémoslo más. Digamos que nuestra aplicación necesitaría disponer de un objeto con la siguiente estructura:
{
id: 1
firstName: "John",
lastName: "Doe",
age: "41 years",
country: "Spain"
}
Prácticamente necesitamos crear un objeto completamente nuevo, la función reduceProps
tiene que hacer muchas cosas y el número de propiedades que necesitemos podría incrementar con el tiempo, con lo que habría que modificarla y aumentaría la complejidad de la misma ¿Veis por donde voy?
Solución alternativa con FP
Vamos a darle la vuelta al planteamiento inicial. ¿Y si en lugar de componer el objeto a partir de lo que nos provee lo hacemos a partir de lo que necesitamos? ¿Y si pudiéramos usar la misma función no solo para nuestros usuarios sino para cualquier tipo de objeto?
En la solución que voy a proponer contaremos también con los 3 elementos necesarios del caso anterior, pero vamos a organizarlos de una manera distinta.
- En lugar de una lista de valores tendremos una lista que describa el origen de los datos y como transformarlos a la que llamaremos
meta
. - Tendremos una función principal
transform
que se encargará de generar el objeto. - Y un objeto inicial desde donde extraeremos la información al que llamaremos
origin
.
import { curry, reduce, clone } from 'ramda'
const transform = curry((meta, origin) => reduce(
(result, { from, to, reducer, defaults }) => {
if (origin[from]) {
result[to||from] = reducer ? reducer(origin[from]) : clone(origin[from])
} else if (defaults) {
result[to||from] = clone(defaults)
}
return result
}, {}, meta)
)
Básicamente transform
espera recibir una lista de objetos con las propiedades from
, to
, reducer
y defaults
, y un objeto origin
que usaremos como fuente de datos.
from
será la propiedad deorigin
desde donde leemos el valorto
será un parámetro opcional para renombrar el valor en el nuevo objeto generadoreducer
será una función opcional que transformará dicho valor, si no existe el valor se copiará tal cual usando la funciónclone
para asegurarnos de que no haya side effectsdefaults
será un parámetro opcional que se asignará al nuevo objeto como valor por defecto en caso de quefrom
no exista
Internamente la función usa un objeto vacío result
donde irá almacenando el resultado de cada operación.
Además, la función la hemos pasado a través de curry
por lo que podemos omitir el objeto origin
y definir nuevas funciones que pospongan su resolución, lo cual nos da la ventaja de poder usar esta función para crear otras nuevas. Crearemos ahora una función específica reduceUser
para nuestros usuarios y dos extra para simples transformaciones.
import { last, head, split, join, append, of } from 'ramda'
const getCountryByCode = code => {
switch (code) {
case 'ES': return 'Spain'
case 'US': return 'USA'
case 'GB': return 'United Kingdom'
case 'FR': return 'France'
case 'DE': return 'Germany'
default: return 'Unknown'
}
}
const epochToYears = (epoch) => {
const diff = (new Date()) - (new Date(epoch))
return parseInt(diff / 1000 / 3600 / 24 / 365.4)
}
const reduceUser = transform([
{
from: 'id',
}, {
from: 'name',
to: 'firstName',
reducer: compose(head, split(' '))
}, {
from: 'name',
to: 'lastName',
reducer: compose(last, split(' '))
}, {
from: 'birthDate',
to: 'age',
// 'of' crea un array de un solo elemento
reducer: compose(join(' '), append('years'), of, epochToYears),
defaults: 'Private'
}, {
from: 'origin',
to: 'country',
reducer: getCountryByCode
}
])
Y ahora probemos de nuevo con nuestra API para usuarios.
fetch('http://my-api.com/people/1/')
.then(res => res.json())
.then(reduceUser)
.then(people => console.log(people)){
id: 1,
firstName: "John",
lastName: "Doe",
age: "41 years",
country: "Spain"
}
Sencillo ¿no? ¿Y si en lugar de un usuario recibimos una lista de usuarios? Bastaría con añadir map
.
import { map } from 'ramda'
fetch('http://my-api.com/people/')
.then(res => res.json())
.then(map(reduceUser))
.then(people => console.log(people))[{
id: 1,
firstName: "John",
lastName: "Doe",
age: "41 years",
country: "Spain"
}, {
id: 2,
firstName: "Mary",
lastName: "McManamara",
age: "38 years",
country: "United Kingdom"
}]
Añadamos nuevas funcionalidades para complicarlo más ¿Y si ahora nuestra API nos provee además una propiedad friends
con una lista de amigos para cada usuario? Sólo tendríamos que modificar la función reduceUser
y añadir un nuevo elemento a la lista, el resto de funciones se quedan exactamente igual. Y no solo eso, podemos reutilizar la misma función reduceUser
de forma recursiva.
const reduceUser = transform([
{
from: 'id',
}, {
from: 'name',
to: 'firstName',
reducer: compose(head, split(' '))
}, {
from: 'name',
to: 'lastName',
reducer: compose(last, split(' '))
}, {
from: 'birthDate',
to: 'age',
reducer: compose(join(' '), append('years'), of, epochToYears),
defaults: 'Private'
}, {
from: 'origin',
to: 'country',
reducer: getCountryByCode
}, {
from: 'friends',
reducer: map(user => reduceUser(user))
}
])
Nota: en este caso hay que usar una función extra para usar reduceUser
porque a diferencia de las funciones definidas con function
, las definidas como expresión no son hoisted y provocaría un error al intentar acceder a una variable que aún no está inicializada.
import { map } from 'ramda'
fetch('http://my-api.com/people/')
.then(res => res.json())
.then(map(reduceUser))
.then(people => console.log(people))[{
id: 1,
firstName: "John",
lastName: "Doe",
age: "41 years",
country: "Spain",
friends: [{
id: 3,
firstName: "Zoe",
lastName: "Smith",
age: "Private",
}]
}, {
id: 2,
firstName: "Mary",
lastName: "McManamara",
age: "38 years",
country: "United Kingdom"
}]
Una funcionalidad nueva solo nos ha llevado una línea de código más.
Refactoring de transform
Hay un caso que no cubre nuestra función transform
y es el de poder combinar varias propiedades en una, o simplemente que el reducer
pueda tener la opción de conocer otras propiedades del objeto como reglas a la hora de aplicar la transformación. Lo único que necesitamos es añadir el objeto origin
como segundo argumento cuando llamemos al reducer
dentro de transform
.
const transform = curry((meta, origin) => reduce(
(result, { from, to, reducer, defaults }) => {
if (origin[from]) {
// reducer tendrá como segundo parámetro opcional el objeto original
result[to||from] = reducer ? reducer(origin[from], origin) : clone(origin[from])
} else if (defaults) {
result[to||from] = clone(defaults)
}
return result
}, {}, meta)
)
Vamos a ver como podríamos usarlo. Como ejemplo podríamos especificar que los usuarios que tengan en el apellido el prefijo "Mc"
y sean de "GB"
se indique que son de Escocia. Vamos a crear una nueva función getLocation
que acepte dos argumentos y la usaremos como reducer.
import { slice } from 'ramda'
const getLocation = (origin, user) => {
const preffix = compose(slice(0,2), last, split(' '))(user.name)
if (origin === 'GB' && preffix == 'Mc') {
return getCountryByCode(origin) + ' (Scotland)'
} else {
return getCountryByCode(origin)
}
}
const reduceUser = transform([
{
from: 'id',
}, {
from: 'name',
to: 'firstName',
reducer: compose(head, split(' '))
}, {
from: 'name',
to: 'lastName',
reducer: compose(last, split(' '))
}, {
from: 'birthDate',
to: 'age',
reducer: compose(join(' '), append('years'), of, epochToYears),
defaults: 'Private'
}, {
from: 'origin',
to: 'country',
reducer: getLocation // <- Cambiamos el reducer por el nuevo
}, {
from: 'friends',
reducer: map(user => reduceUser(user))
}
])
fetch('http://my-api.com/people/')
.then(res => res.json())
.then(map(reduceUser))
.then(people => console.log(people))[{
id: 1,
firstName: "John",
lastName: "Doe",
age: "41 years",
country: "Spain", // Se mantiene igual
friends: [{
id: 3,
firstName: "Zoe",
lastName: "Smith",
age: "Private",
}]
}, {
id: 2,
firstName: "Mary",
lastName: "McManamara",
age: "38 years",
country: "United Kingdom (Scotland)" // Funciona!
}]
De nuevo, una modificación con un pequeño cambio y una pequeña función extra.
Conclusión
Haciendo nuestro código modulable y orientado a FP conseguimos 3 ventajas:
- Reutilización: En lugar de tener funciones grandes y complejas, tenemos composiciones de pequeñas funciones simples que podemos usar muchas veces.
- Testing: Cada función es independiente y puede pasar sus propios test unitarios para garantizar su funcionamiento.
- Reescritura: Un pequeño cambio o nueva funcionalidad no debería obligarnos a tener que hacer grandes cambios.
No hay truco, solo necesitamos adaptarnos a pensar de manera funcional en lugar de recurrir a soluciones imperativas o fuertemente orientadas a objetos. A veces solo es cuestión de darle la vuelta al planteamiento para verlo desde otra perspectiva.
“No estoy loco, ahora lo entiendo. Soy mentalmente divergente.” — 12 Monkeys (1995)
Originally published at https://www.nauzethdez.com on October 18, 2016.