Refatorando para JavaScript Funcional: Composição de Funções (parte 2)

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.

No parte 1 deste artigo, partimos de um for simples de somatória…

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

… e refatoramos com um reduce e um pouco de separação de responsabilidades. 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 sumTotal = lines =>
  lines.reduce((total, line) => (total += totalLine(line)), 0);

console.log(sumTotal(lineItems));

Agora seguimos com nossa refatoração separando ainda mais as responsabilidades, identificando padrões e elaborando códigos genéricos que possam ser reutilizados facilmente. Vamos lá!

Dividindo para conquistar

Podemos melhorar nosso código agora separando em funções diferentes o momento de cálculo de uma linha e o cálculo final:

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

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

const sumTotal = lines => lines.map(totalLine).reduce(sum, 0);

console.log(sumTotal(lineItems));

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

Note que a, b e xs não parecem bons nomes de variável, certo? Enquanto line parece um nome bem adequado e preciso em seu significado. As variáveisa, b e xs são todas de escopos limitadíssimos e suas implementações são um tanto genéricas. São nomes imparciais. Já o argumento line participa de uma implementação bem mais específica, apesar do escopo igualmente limitado, é suposto que line possua quantity e price, e ambos possam ser multiplicados entre si. Portanto, esse é o motivo de nomes genéricos e nomes específicos. Trata-se de níveis de abstração.

Mas, por que precisamos de uma função de soma se já temos o + nativo do JavaScript? Diferente de linguagens como Ruby, no JavaScript o + não equivale a uma função comum (em Ruby + é método), eu não consigo manuseá-la como qualquer outra: enviar como argumento, receber como retorno ou mesmo verificar aridade e outros atributos comuns de uma função. Tampouco posso sobrescrevê-la! Isso acontece porque o + não é uma função, é um operador. Nossa função de soma simplesmente reescreve o operador + como uma função. A vantagem desta abordagem é que agora podemos alimentar o reduce com nossa nova função +.

(curiosidade: em JavaScript podemos verificar a aridade de uma função assim, fn.length, veja mais detalhes no MDN)

map = fn => xs =>

O próximo passo é reescrever o map e o reduce!

— Calma, mas por que diabos vou reescrever uma função nativa (mais uma!)?
— Porque ela não permite composição direta.

Para que o map e o reduce permitam composição direta vou precisar de duas coisas:

  • Acessá-las antes de conhecer o dado que irá ser recebido (pois a composição que irá fornecer os dados).

  • Modificá-las com uso de uma outra função base antes de conhecer o dado que irá ser recebido.

Em outras palavras preciso alimentá-la com seus dois argumentos em dois momentos diferentes.

Nativamente só temos acesso à função map quando já temos um array: [].map, [1, 2, 3, 4].map. Podemos resolver isso utilizando o protótipo do map: Array.prototype.map, mas ainda vamos precisar apontar para esse map a função de mapeamento antes de passar o dado. Portanto: Array.prototype.map.call(dado, fn) não nos serve.

Primeiro preciso desatrela-la do dado, depois preciso escrevê-las em dois passos; dois momentos. No primeiro, ela recebe a função de operação (fn), em seguida recebe o dado. Para isso, escrevo de forma que uma função retorne outra. A sintaxe do => ajuda bastante neste tipo de escrita.

Acho que mostrando o código fica mais fácil de entender o que preciso:

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

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

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

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

const sumTotal = lines => reduce(sum, 0)(map(totalLine)(lines));

console.log(sumTotal(lineItems));

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

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

A minha função _map_ recebe apenas uma função e retorna uma outra função que exige apenas um argumento, que é o dado que vamos processar. Ou seja: map(totalLines) irá retornar xs => xs.map(totalLines). Assim, posso chamar as duas funções em dois momentos, map(totalLines)(lines):

Ao chamar a última função enviando o xs, o map é processado e retorna seu valor final.

Já a função reduce recebe dois argumentos, a função de processamento e o valor inicial, de retorno ela entrega uma outra função que recebe somente o dado a ser processado. Ao final teremos: reduce(sum, 0)(lines). Nossa intenção aqui é padronizar a entrada de nossas funções para que a entrada de uma seja a saída da outra, por isso é importante termos funções com apenas um único argumento de entrada.

Repare que tenho duas funções exigindo somente um argumento, lines:

map(totalLines)(lines)

reduce(sum, 0)(lines)

Podemos melhorar a leitura dando um nome adequado para map(totalLines) e outro para reduce(sum, 0) , dessa forma teremos duas novas funções. Lembrando que cada uma delas tem aridade 1, ou seja, esperam somente um argumento:

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

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

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

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

const calculateTotals = map(totalLine);

const sumAll = reduce(sum, 0);

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

console.log(sumTotal(lineItems));

const calculateTotals = map(totalLine);

const sumAll = reduce(sum, 0);

Perceba como o dado flui por entre as funções:


lines | const sumTotal = ( | ) => | <---- sumAll( <--- calculateTotals( <--+ ))

Uma peça de LEGO funciona devido a sua parte inferior ser encaixável na sua parte inferior ou ao contrário. Funções capazes de compor precisam ter encaixes similares._Uma peça de LEGO funciona devido a sua parte inferior ser encaixável na sua parte inferior ou ao contrário. Funções capazes de compor precisam ter encaixes similares.

São como peças de LEGO, a entrada de uma se encaixa perfeitamente na saída da outra. Esse é o conceito básico das composições. Como só há possibilidade de retornar um único valor, o encaixe mais simples e viável são entre funções unárias, ou seja, que recebem somente um único argumento. Existem composições mais complexas e específicas envolvendo vários argumentos de entrada, como é o caso das composições de reducers do Redux, por exemplo. Nesse caso, um dos argumentos é repassado inteiro para todas as funções que fazem parte da composição. Mas esse tipo de composição não será o foco aqui.

Lógica genérica e lógica de negócio

A essa altura está muito claro o que é lógica de negócio e o que não é. Vou separar esses dois tipos de código para facilitar ainda mais:

// 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));

Perceba o nome das funções e argumentos. Enquanto nosso código genérico, de biblioteca é bem genérico, o nosso código específico diz um pouco sobre o contexto onde ele é aplicado. Isso não é sobre programação funcional, esse conceito pode ser utilizado em qualquer paradigma de programação. A separação fica mais evidente quando as responsabilidades estão bem divididas. E, sem dúvida esse é um dos bons motivos para levar muito a sério o princípio de responsabilidade única.

A camada mais genérica do meu código praticamente nunca precisará de manutenção, apenas em caso de bugs. Já a camada de negócio, poderá ser remodelada o quanto quiser. Garantir a camada genérica simples e funcionando bem é um bom caminho para evitar bugs.

Uma coisa que sempre faço em meus códigos é experimentar a sonoridade dele. Leia cada função da nossa camada de negócio em voz alta:

  • total da linha é: dado uma linha, quantidade da linha multiplicado pelo preço da linha.

  • cálculo de totais é um mapeamento dos totais das linhas.

  • soma total é: dado uma linha, a soma de todos os cálculos de totais desta linha.

Viu só?

O que faremos com essas duas camadas de código? Vejamos na última parte deste artigo, quero ainda mostrar finalmente as composições, tubulações e ainda um pouco de curry e o real motivo de termos reescrito (ou remodelado) tantas funções nativas. Vamos nessa?

Thanks to Igor Marques da Silva.

We are hiring new talents. Do you want to work with us? become@codeminer42.com