Refatorando para JavaScript Funcional: composição de funções (parte 3)

If you cannot read in Portuguese, try to use the Google Translator. Each code sample here will be written in English. Feel free to translate this article and share.

Na parte anterior remodelamos algumas funções nativas: map, reduce e o operador +. Finalmente vamos entender o motivo de ter feito isso e concluir os temas que eu queria abordar nessa pequena trilogia.

Lembra de onde paramos?

// generic logic

const sum = (a, b) => a + b;

const map = fn => xs => xs.map(fn);

const reduce = (fn, ini) => xs => xs.reduce(fn, ini)

const sumAll = reduce(sum, 0);

// business logic

const totalLine = line =>
  line.quantity * line.price;

const calculateTotals = map(totalLine);

const sumTotal = lines => 
  sumAll(calculateTotals(lines));

console.log(sumTotal(lineItems));

Bastante código não é? Confie em mim que esse código ainda vai diminuir. O importante é que temos pela primeira vez uma boa separação de responsabilidades e ainda de níveis de abstração. Agora é hora de nossa composições.

Compondo

Abstraindo um pouco mais podemos extrair a lógica de função que chama função e fazer o seguinte:

// generic logic

const sum = (a, b) => a + b;

const map = fn => xs => xs.map(fn);

const reduce = (fn, ini) => xs => xs.reduce(fn, ini)

const sumAll = reduce(sum, 0);

const comp = (f, g) => (...args) => f(g(...args))

// business logic

const totalLine = line =>
  line.quantity * line.price;

const calculateTotals = map(totalLine);

const sumTotal = lines =>
  comp(sumAll, calculateTotals)(lines)

console.log(sumTotal(lineItems));

const comp = (f, g) => (…args) => f(g(…args))

Assim, nossa função final, a sumTotal é nada mais que uma composição de outras duas funções. Repare que a sumTotal tem como única entrada o dado a ser processado, e essa entrada é repassada para a composição. Sempre que isso ocorre, podemos eliminar a entrada e dizer que nossa nova função é apenas o resultado da nossa composição.

// generic logic

const sum = (a, b) => a + b;

const map = fn => xs => xs.map(fn);

const reduce = (fn, ini) => xs => xs.reduce(fn, ini)

const sumAll = reduce(sum, 0);

const comp = (f, g) => (...args) => f(g(...args))

// business logic

const totalLine = line =>
  line.quantity * line.price;

const calculateTotals = map(totalLine);

const sumTotal = comp(sumAll, calculateTotals)

console.log(sumTotal(lineItems));

const sumTotal = lines => comp(sumAll, calculateTotals)(lines)

passou a ser:

const sumTotal = comp(sumAll, calculateTotals)

A esse padrão chamamos de point-free notation (ou estilo tácito). Ocorre quando o processamento de uma função nos retorna uma função cuja entrada é idêntica à entrada da função que estamos escrevendo. Acima isso também acontece na linha 18 em calculateTotals. Note que calculateTotals recebe o mesmo conjunto de argumentos que map(totalLine), afinal calculateTotals é o map(totalLine). Assim podemos também dizer que sumTotal é comp(sumAll, calculateTotals), ou seja: a soma total é uma composição de somar tudo e calcular os totais.

Para o problema proposto, podemos encerrar nossa refatoração aqui sem nenhum prejuízo. Porém, optei por ir um pouco além a fim de apresentar alguns conceitos importantes e como eles se relacionam com composição de funções.

Compondo ou tubulando: compose e pipe

Podemos ainda aperfeiçoar nosso algoritmo de composição para que receba quantas funções quisermos, basta processar cada função em um reduce invertido:

const compose = (fn, ...fns) => (...args) =>
  fns.reverse().reduce((acc, f) => f(acc), fn(...args))

— Invertido?
— Sim, pois a expressão f(g(a)) é sempre lida de dentro pra fora f←g←a←.

Porém, quando colocado em uma composição:

f(g(a)) →compose(f, g)(a)

…a leitura acaba sendo da direita para a esquerda. Primeiro processa g, depois a.

Assim, equivalente ao compose, temos também o pipe que seria o mesmo procedimento, porém na ordem inversa das funções

f(g(a)) →pipe(g, f)(a)

O pipe, como seu nome sugere, é uma tubulação, que transforma os dados passando pela função mais a esquerda atá a mais a direita. Podemos escrever o compose e o pipe facilmente usando um como o inverso do outro:

const pipe = (fn, ...fns) => (...args) =>
  fns.reduce((acc, f) => f(acc), fn(...args));

const compose = (...fns) => pipe(fns.reverse());  

Caso precise de um pouco mais de explicação sobre o código acima, o Drew Tipson tem um excelente artigo explicando este código (que por sinal, copiei dele).

Voltando ao nosso código, ele já está assim:

// generic logic

const sum = (a, b) => a + b;

const map = fn => xs => xs.map(fn);

const reduce = (fn, ini) => xs => xs.reduce(fn, ini)

const sumAll = reduce(sum, 0);

/*

const pipe = (fn, ...fns) => (...args) =>
  fns.reduce((acc, f) => f(acc), fn(...args));

*/

const compose = (...fns) => pipe(...fns.reverse());  

// business logic

const totalLine = line =>
  line.quantity * line.price;

const calculateTotals = map(totalLine);

const sumTotal = compose(sumAll, calculateTotals);

console.log(sumTotal(lineItems));

Falta agora a cereja do bolo, a última técnica que mostrarei aqui.


Haskell Curry, inventor da técnica currying junto com Moses Schönfinkel e Gottlob Frege

Curry

E se nossas funções sempre que não receberem todos os parâmetros esperados, retornem uma outra função a fim de receber os demais parâmetros? Podemos eliminar alguns=> e retornar funções automaticamente sempre que necessário. A esta técnica chamamos de curry, segue um exemplo de como podemos construir uma função que faça esse curry automático:

const curry = (f, ...args) =>
  (f.length <= args.length)
    ? f(...args)
    : (...more) => curry(f, ...args, ...more);

Esse código acima também foi trabalhado pelo Drew Tipson em seu artigo sobre lentes.

Como isso pode nos ajudar? Lembra do nosso map e do nosso reduce? Eles agora podem ser escritos da seguinte forma:

const curry = (f, ...args) =>
  (f.length <= args.length)
    ? f(...args)
    : (...more) => curry(f, ...args, ...more);

const map = curry(fn, xs) => xs.map(fn));

const reduce = curry((fn, ini, xs) => xs.reduce(fn, ini));

const map = curry((fn, xs) => xs.map(fn));

const reduce = curry((fn, ini, xs) => xs.reduce(fn, ini));

Com isso vamos continuar fazendo a aplicação parcial para que as funções sejam sempre unárias, só que agora não precisamos mais escrever explicitamente uma função que retorna outra. A função curry cuidará disso para a gente.

Currying com bind

Uma boa alternativa para utilizar técnicas de currying é a função bind. Com ela conseguimos fazer aplicações parciais exatamente como faríamos com um curry automático, só que com um pouco mais de código na passagem dos argumentos.

O bind amarra as funções a um contexto e a algumas entradas.O bind amarra as funções a um contexto e a algumas entradas.

O bind está presente no protótipo de funções de javascript, isso significa que toda função responde à função bind. Toda função bind recebe como primeiro argumento o contexto da função a ser gerado, o this. As demais funções são argumentos de entrada que será aplicados à função gerada.

Olha esse exemplo:

const add = (a, b) => a + b;

add.length // 2

const add10 = add.bind(this, 10);

add10.length // 1

console.log(add10(5)); // 15

Nesse caso, o contexto atual, this, é repassado. A função add tem aridade 2, enquanto a função add10 tem aridade 1, ou seja, só precisa de mais um argumento já que o primeiro argumento será sempre 10.

O bind está muito bem documentado no MDN e já é largamente utilizado para evitar o callback-hell. Espero que a partir de agora ele te sirva também como um bom companheiro de suas composições, map, reduce, filter ou de qualquer outra função que receba outra função.

Conclusão

Recapitulando:

  • O compose nos ajuda a compor funções de forma que o retorno de uma função passa a ser a entrada da outra.

  • O pipe faz a mesma coisa sendo que a última função será a última a ser executada.

  • O curry nos ajuda a escrever funções que recebem seus argumentos gradativamente.

  • O bind pode ser usado para fazer curry manualmente.

Note que para compor funções preciso necessariamente que as funções durante a composição recebam somente um único argumento, equivalente ao retorno da função seguinte. O curry nos ajuda a montarmos funções que recebem apenas um argumento. Esse argumento é o dado a ser transformado, note que ele flui da direita-pra-esquerda no caso da composição e da esquerda-pra-direita no caso do tubulação (pipe).

Iniciamos na intenção de refatorar um código e terminamos com muito mais código do quê começamos! No entanto temos uma série de mini funções recombináveis, algumas delas um tanto genéricas e que podemos utilizar para centenas de outras coisas na mesma aplicação para resolvermos inúmeros problemas. O nosso generic code é nada menos que uma biblioteca de utilidades. Apesar da linguagem não nos fornecer essas funções como padrão, hoje podemos contar com algumas bibliotecas que nos entregam essas mesmas funções com todas as características discutidas aqui. Uma delas é o Ramda. Veja como ficaria nosso código utilizando o Ramda.

Lembra do nosso código inicial?

const lineItems = [
  { name: 'Carmenere', price: 35, quantity: 2 },
  { name: 'Cabernet', price: 50, quantity: 1 },
  { name: 'Merlot', price: 98, quantity: 10 },
];


const sumTotal = lines => {
  let total = 0;

  for(let i = 0; i < lines.length; i++) {
    total += lines[i].quantity * lines[i].price;
  }

  return total;
}

console.log(sumTotal(lineItems)); // 1100

Depois de nossa refatoração, e ainda usando o R.map, R.compose e R.sum do Ramda, ele ficou assim:

const lineItems = [
  { name: 'Carmenere', price: 35, quantity: 2 },
  { name: 'Cabernet', price: 50, quantity: 1 },
  { name: 'Merlot', price: 98, quantity: 10 },
];

const totalLine = line => line.quantity * line.price;

const calculateTotals = R.map(totalLine);

const sumTotal = R.compose(R.sum, calculateTotals);

console.log(sumTotal(lineItems)); // 1100

Simples não é?

Entendendo este básico, podemos agora implementar algumas funcionalidades interessantes no nosso problema, como por exemplo: regras de desconto complexas. Estou pensando em evoluir este problema pouco a pouco e abordar uma série de conceitos sobre programação funcional utilizando JavaScript com ou sem bibliotecas.

Por ora é isso. Bons estudos. Nos vemos nos comentários (ou no Telegram: halanpin).

Atualização (09/03)

Hoje discutindo no Telegram um pequeno problema trazido no https://t.me/javascriptbrasil, o João Ferreira trouxe uma solução muito interessante aplicando exatamente os conceitos que tentei explicar aqui. Segue o gist.

Referências e citações:

wikipedia

Artigos

Vídeos

Documentações e guias

Libs

Thanks to Igor Marques da Silva.