JavaScript
Node.js
Programação funcional

Map, filter e reduce: primeiros passos de programação funcional em JavaScript

Uma introdução baseada em exemplos para as mais importantes funções de mapeamento de arrays em JavaScript.

Eduardo Velho
Eduardo Velho

Professor e pesquisador, PhD

Compartilhar em

JavaScript é uma linguagem de programação bastante peculiar. Teve o seu primeiro protótipo desenvolvido em 10 dias, possui sintax e nome inspirados no Java, mas na verdade é mais parecido com um dialeto de Lisp (uma das primeiras linguagens de programação funcional) do que qualquer outra coisa.

Por muito tempo, pessoas que estavam aprendendo JavaScript eram ensinadas a utilizar a linguagem conforme o paradigma de programação orientada a objetos (OOP). No entanto, embora JS seja uma linguagem multiparadigmática, é um pouco estranho utilizá-la como se fosse uma versão do Java que roda no browser, até porque, pra começo de conversa, o recurso de classes (que na verdade é apenas syntax sugar para funções com estado interno) só surgiu em 2015 com o advento do padrão ES6. Antes disso, classes, herança, decorators, interfaces e todo o resto que vem junto com a OOP eram implementados utilizando funções e esticando aqui e ali os prototypes da linguagem.

Essa história de "programação funcional" (FP) parece ter sido popularizada na última década pelos frameworks e linguagens que compilam para JS, tais como React, Elm, Reason e PureScript, que propõem o desenvolvimento de front-ends à partir de um paradigma que mescla programação funcional com programação reativa orientada a eventos.

No entanto, o que a gente chama de programação funcional em JS, na verdade se parece mais com uma série de práticas que buscam diminuir a incidência de erros do desenvolvedor. Analisando códigos que se propõem a ser FP, é possível observar as seguintes práticas recorrentes:

  • Utilizar constantes (const) ao invés de variáveis (let, var). Variáveis são utilizadas caso não exista outra opção, seja por motivos de performance ou versatilidade;
  • Preferência pelo pattern de composição ao invés de herança;
  • Realizar mutações somente quando necessário. É preferível uma função que retorne um novo valor ao invés de alterar uma variável que já foi declarada (utilizar Array.concat ao invés de Array.push, por exemplo);
  • Entendimento de que o estado da aplicação é um ponto crítico para possíveis falhas e que deve ser centralizado e gerenciado com bastante cuidado (Redux e Elm se destacam nesse sentido);
  • Preferência por código declarativo ao invés de imperativo;
  • Evitar comandos de iteração de bloco (for, while) em favor de funções de alta-ordem (Array.map, Array.filter, Array.reduce).

Evidentemente, essas práticas realmente podem diminuir a incidência de erros e bugs no código. Separar o estado da aplicação (estrutura de dados) das lógicas que operam sobre esses dados (as funções) é algo que já vem sendo defendido há algum tempo (inclusive, desde o desenvolvimento do Haskell, lá nos anos 90). Deste modo, fica mais fácil de depurar, criar funções testáveis e também de reaproveitar o código. Isso acontece porque o paradigma FP faz com que toda a base de código seja uma grande composição de diversas funções, que simplesmente recebem dados como parâmetro e retornam novos dados que foram processados à partir desses inputs. Observe o comparativo logo abaixo:

// Código imperativo
function reverseString(str) {
  const strLength = str.length - 1;
  let reverseStr = '';
  for(const strIndex in str) {
    const charAtIndex = strLength - strIndex;
    const nextChar = str[charAtIndex]
    reverseStr = reverseStr.concat(nextChar)
  }
  
  return reverseStr;
}

// Código funcional
function reverseStringVersionA(str) {
  const strLength = str.length - 1;
  return str
    .split('')
    .map((charAt, strIndex) => str[strLength - strIndex])
    .join('')
}

const reverseStringVersionB = str => str.split('').reverse().join('');

A questão aqui não é comparar qual é a melhor implementação para reverseString, apenas mostrar as diferenças entre a abordagem imperativa e a abordagem funcional. Fica evidente que nas implementações de código FP (versão A e versão B) houve uma manipulação da string que chegou como parâmetro da função, que foi modificado à partir de uma lógica que quando aplicada sobre esse input (o parâmetro str), resultou em uma string invertida. Já na versão imperativa, uma string vazia foi iniciada, e esse valor sofreu mutação diversas vezes até alcançar o resultado final.

Programação funcional é como uma esteira de linha de produção, onde a matéria bruta (o parâmetro str) passa por pequenos processos de montagem (split, map e join) até que, ao final, vira um novo produto (a string invertida!). Já a programação imperativa é como se fosse uma receita de bolo, onde alguém pega os ingredientes (o parâmetro str) e segue os passos dessa receita (iniciar uma string vazia e alterá-la) até chegar no resultado final (a string invertida!). Com isso, é possível sintetizar que:

O código FP é declarativo, diz como as coisas são. Já o código imperativo é procedural, diz como as coisas devem ser feitas.

Ok, agora você já sabe o que é programação funcional e quais são os seus objetivos. Sendo assim, o próximo passo é aprender o "feijão com arroz" de FP em JavaScript, que são as funções Array.map, Array.filter e Array.reduce. Se você entender essas três funções, bem como os seus objetivos, todo o básico de FP vai ficar molezinha.

Vamos nessa!

Map

Desde a versão ES6 é que o JavaScript possui uma implementação nativa de uma função map. Antes disso, era comum que desenvolvedores utilizassem bibliotecas como lodash e underscore, que implementavam diversas funções típicas de linguagens baseadas em FP (map, filter, zip, fold, flip e por aí vai). Com a chegada do ES6 ficou muito mais fácil programar em FP, pois além de diversas funções úteis para esse paradigma, também tivemos a implementação das arrow functions, que facilitaram bastante o uso das funções de alta-ordem.

Desta forma, o JavaScript implementa a função map no prototype dos vetores (Array.map), a qual pode ser invocada da seguinte forma:

// Exemplo 1
function double(n) {
  return n * 2;
}

const exampleArray = [2, 3, 4, 5, 6];
const doubledExampleArray = exampleArray.map(double);
console.log(doubledExampleArray); // [4, 6, 8, 10, 12]

// Exemplo 2
const tripleNumbers = numbersArray =>
  numbersArray.map(numbersArray => numbersArray * 3);

const numbers = tripleNumbers(exampleArray);
console.log(numbers); // [6, 9, 12, 15, 18]

// Exemplo 3
const names = [
  { firstName: 'Gon', lastName: 'Freecss' },
  { firstName: 'Killua', lastName: 'Zoldyck' },
  { firstName: 'Kurapika', lastName: 'Kurta' },
  { firstName: 'Leorio', lastName: 'Paradinight' },
]

const fullNames = names.map(
  ({ firstName, lastName }) => `<p>${firstName} ${lastName}</p>`
);

console.log(fullNames);
// ["<p>Gon Freecs</p>", "<p>Killua Zoldyck</p>", "<p>Kurapika Kurta</p>", "<p>Leorio Paradinight</p>"]

// Exemplo 4
const urls = [
  'https://google.com',
  'https://youtube.com',
  'https://instagram.com',
  'https://facebook.com',
];

async function bulkDownloadHTML(urls) {
  const htmlPromises = urls.map(async url => {
    const res = await fetch(url);
    return res.text();
  })
  
  const htmlList = await Promise.all(htmlPromises);
  return htmlList;
}

Talvez seja a primeira vez que você esteja vendo arrow functions em um trecho de código. É um pouco estranho se acostumar com essa sintax, mas você pega o jeito rapidinho. Recomendo a leitura do artigo da Mozilla sobre arrow functions para entender como essas paradinhas funcionam.

Note também que a função map recebe outra função como parâmetro. Quando isso acontece a gente diz que se trata de uma função de alta-ordem (ou high-order function), que é o caso das funções map, filter e reduce.

Voltando para os exemplos de código, podemos observar que exampleArray é um vetor de números inteiros; e que doubledExampleArray é um vetor que possui os dobros dos inteiros de exampleArray. Nesse sentido, podemos ler a operação realizada na linha 7 da seguinte forma:

Para cada número do vetor exampleArray, aplique a função double.

Basicamente é isso que o map faz. Ele espera receber uma função como parâmetro (digamos a função double), e executa essa função para cada item do vetor. No caso da linha 7, a função double vai ser invocada cinco vezes, porque essa é a quantidade de itens que o vetor exampleArray possui.

Note também o Exemplo 3, que é um pouco mais complexo. Não vou ficar explicando os detalhes aqui, porque esse é o tipo de coisa que a gente aprende testando e errando. Mas observe que map também funciona para vetores mais complexos, de forma que é possível "mapear" um vetor de objetos para uma string de HTML.

Também vou deixar aqui algumas regras gerais sobre a função map:

  1. A função map espera receber outra função como parâmetro;
  2. map itera o vetor que está antes do ponto (ex: vetorAqui.map);
  3. A função passada para map PRECISA retornar um valor, ou retornará undefined;
  4. map SEMPRE retorna outro vetor que possui exatamente o mesmo tamanho (length) que o vetor de entrada;
  5. É possível mapear funções assíncronas. Mas cuidado! O resultado é um vetor de Promises não resolvidas (ver o uso de Promise.all no Exemplo 4).

Agora vamos para a função filter.

Filter

Assim como a função map, o filter chegou no JavaScript à partir da versão ES6. filter também é uma função de alta-ordem, mas diferente do map, que "transforma" os itens de um vetor, filter serve para, adivinha, "filtrar" ou "selecionar" alguns itens de um vetor. A função que é passada por parâmetro para filter é como se fosse uma "regra" que determina quais itens do vetor vão fazer parte de um novo vetor que será retornado.

O código a seguir demonstra alguns exemplos de utilização da função filter:

// Exemplo 1
function isOdd(n) {
  return n % 2 > 0;
}

const exampleArray = [2, 3, 4, 5, 6, 7];
const filteredExampleArray = exampleArray.filter(isOdd);
console.log(filteredExampleArray); // [3, 5, 7]

// Exemplo 2
const bands = [
  { name: 'Avenged Sevenfold', formed: 1999 },
  { name: 'Megadeth', formed: 1983 },
  { name: 'Blind Guardian', formed: 1984 },
  { name: 'Iron Maiden', formed: 1975 },
  { name: 'Nirvana', formed: 1987 },
];

const bandsFromThe80s = bands
  .filter(({ formed }) => formed >= 1980 && formed <= 1989)
  .map(({ name }) => name);

console.log(bandsFromThe80s);
// ["Megadeth", "Blind Guardian", "Nirvana"]

// Exemplo 3
const products = [
  { name: 'Laptop', category: 'computers' },
  { name: 'Chocolate', category: 'food' },
  { name: 'Pizza', category: 'food' },
  { name: 'Lord of the Rings - The Two Towers', category: 'book' },
]

const foodProducts = products.filter(({ category }) => category === 'food');
console.log(foodProducts);
// [{ name: 'Chocolate', category: 'food' }, { name: 'Pizza', category: 'food' }]

É bem mais fácil entender filter do que map, conforme fica evidente no código acima. No Exemplo 1, a função isOdd serve como "regra", definindo que somente os números ímpares é que devem ser selecionados para o novo vetor; da mesma forma, no Exemplo 2 podemos ver uma regra que seleciona somente as bandas dos anos 80; por fim, no Exemplo 3, determina-se que somente os produtos do tipo "comida" é que devem ser selecionados.

Seguem aqui algumas regras para a função filter:

  1. filter espera como parâmetro uma função que retorna true ou false;
  2. filter itera o vetor de entrada e aplica a função para cada item;
  3. As iterações que retornam true são "selecionadas" para o vetor que será retornado;
  4. filter SEMPRE vai retornar um vetor que é igual ou menor ao vetor de entrada;
  5. filter SEMPRE vai retornar valores que são um subconjunto dos itens do vetor de entrada.

Conforme o Exemplo 2, é possível perceber que filter e map podem ser encadeados, de forma que é possível ler o trecho de código das linhas 19-21 da seguinte forma:

Para cada banda, selecione somente as que tiverem começado nos anos 80 (filter). Depois disso (map), retorne o nome das bandas que está guardado na propriedade name.

A seguir, vamos explorar essas possibilidades de encadeamento de funções analisando como map, filter e reduce podem ser utilizados juntos.

Reduce

Agora é que o negócio fica complicado. reduce é uma função estranha, muito fácil de criar trechos de código que são uma verdadeira aberração da natureza. No entanto, é uma das funções mais divertidas do JavaScript, pois possui um grande poder de síntese, de forma que operações muito complexas podem ser simplificadas em poucas linhas de código.

Mas, cuidado! reduce pode facilmente gerar código pouco performático, mesmo em aplicações simples. Além disso, existe uma tendência de que códigos que usam reduce sejam mais verbosos do que utilizando outras abordagens. Dito isso: use-o, mas somente se você souber o que está fazendo. Aprendê-lo, no entanto, é essencial visto que eventualmente você vai se deparar com uma base de código que utiliza esse recurso.

Enrolação à parte, antes eu disse que map servia para "transformar" os itens de um vetor; e que filter servia para "filtrar" esses itens. Tá, mas, e o reduce? Eu diria que reduce serve para "juntar" ou "empilhar" ou itens de um vetor em uma coisa só. Sabe àquela função que faz um somatório dos valores de um vetor de números? Pois é, dá pra implementar isso utilizando reduce! Ou àquela outra que junta um vetor de strings em uma string só? Idem!

No entanto, utilizar reduce pode se tornar complexo, porque, diferente de map e filter, a função reduce (provavelmente) não vai retornar um vetor, porque seu objetivo é "juntar coisas".

Estranho, né? As coisas vão ficar mais evidentes com o exemplo a seguir:

function sum(a, b) {
  console.log(a, b)
  return a + b;
}

const exampleArray = [1, 2, 3, 4, 5, 6, 7];
const summation = exampleArray.reduce(sum, 0);
console.log(summation) // 28

O exemplo acima pode ser lido da seguinte forma:

Some todos os números do vetor.

Fim, acabou.

Zueira, mas na verdade é isso mesmo. Em detalhes, o que acontece é que reduce espera dois parâmetros: o primeiro é uma função que também recebe dois parâmetros; e o segundo é o estado inicial do processo de "redução". Com isso, reduce vai iterar cada item através da função que foi passada como parâmetro, de forma que o primeiro argumento vai ser o estado atual do processo (a "pilha"); e o segundo parâmetro o item que está sendo iterado. Confira o exemplo a seguir:

function sum(a, b) {
  console.log(`a=${a}, b=${b}`)
  return a + b;
}

const exampleArray = [1, 2, 3, 4, 5, 6, 7];
const summation = exampleArray.reduce(sum, 0);
// a=0  b=1
// a=1  b=2
// a=3  b=3
// a=6  b=4
// a=10 b=5
// a=15 b=6
// a=21 b=7
console.log(summation); // 28

As linhas 8-14 mostram, para cada iteração, a saída do console.log da linha 2. O valor de a é o estado atual da "pilha", que começou em 0 porque esse foi o valor definido no segundo argumento passado para a função reduce, mas poderia ser qualquer outro número. Perceba que a cada iteração o valor de a é atualizado, de modo que é sempre respectivo a soma de a e b da iteração anterior. Desta forma, o valor de b é o item do vetor que está sendo iterado. Por fim, após essas várias iterações, na linha 15 temos a saída do console.log com o valor 28, que é o somatório de 1, 2, 3, 4, 5, 6 e 7.

É um pouco complicado, né? Mas pode ficar tranquilo, também levei um tempo para conseguir entender. Como um segundo exercício, analise as iterações do código a seguir:

// Exemplo 2
function joinString(strA, strB) {
  const joinedStr = strA + strB;
  console.log(joinedStr)
  return joinedStr;
}

function concat(arr) {
  return arr.reduce(joinString, '');
}

const strArray = [
  "Caminhando sobre as brasas dos cadáveres no chão\n",
  "Sinta a mente esvaziada, toda dor é uma ilusão\n",
  "Levitando junto aos fluxos das ações em ascensão\n",
  "O desapego purifica a aura da especulação\n",
  "Meditando atrás de bem-estar, enquanto financia a dor\n",
  "Hoje eu canto pra acabar com toda paz interior\n",
  "(El Efecto)"
];

const lyrics = concat(strArray);

// Caminhando sobre as brasas dos cadáveres no chão

// Caminhando sobre as brasas dos cadáveres no chão
// Sinta a mente esvaziada, toda dor é uma ilusão

// Caminhando sobre as brasas dos cadáveres no chão
// Sinta a mente esvaziada, toda dor é uma ilusão
// Levitando junto aos fluxos das ações em ascensão

// Caminhando sobre as brasas dos cadáveres no chão
// Sinta a mente esvaziada, toda dor é uma ilusão
// Levitando junto aos fluxos das ações em ascensão
// O desapego purifica a aura da especulação

// Caminhando sobre as brasas dos cadáveres no chão
// Sinta a mente esvaziada, toda dor é uma ilusão
// Levitando junto aos fluxos das ações em ascensão
// O desapego purifica a aura da especulação
// Meditando atrás de bem-estar, enquanto financia a dor

// Caminhando sobre as brasas dos cadáveres no chão
// Sinta a mente esvaziada, toda dor é uma ilusão
// Levitando junto aos fluxos das ações em ascensão
// O desapego purifica a aura da especulação
// Meditando atrás de bem-estar, enquanto financia a dor
// Hoje eu canto pra acabar com toda paz interior

// Caminhando sobre as brasas dos cadáveres no chão
// Sinta a mente esvaziada, toda dor é uma ilusão
// Levitando junto aos fluxos das ações em ascensão
// O desapego purifica a aura da especulação
// Meditando atrás de bem-estar, enquanto financia a dor
// Hoje eu canto pra acabar com toda paz interior
// (El Efecto)

console.log(lyrics);
// Caminhando sobre as brasas dos cadáveres no chão
// Sinta a mente esvaziada, toda dor é uma ilusão
// Levitando junto aos fluxos das ações em ascensão
// O desapego purifica a aura da especulação
// Meditando atrás de bem-estar, enquanto financia a dor
// Hoje eu canto pra acabar com toda paz interior
// (El Efecto)

Viu só como a cada iteração a string foi se construindo? É exatamente isso que reduce faz! Ele pega um vetor e aplica algum tipo de lógica sob os itens, de forma a "reduzi-los" em uma coisa só.

Anteriormente eu tinha abordado a possibilidade de encadear map, filter e reduce para construir algo ainda mais complexo. Com isso, confira o código a seguir:

// Exemplo 3

const bands = [
  { name: 'Avenged Sevenfold', formed: 1999 },
  { name: 'Megadeth', formed: 1983 },
  { name: 'Static-X', formed: 1994 },
  { name: 'System of a Down', formed: 1994 },
  { name: 'Blind Guardian', formed: 1984 },
  { name: 'Iron Maiden', formed: 1975 },
  { name: 'Disturbed', formed: 1994 },
  { name: 'Slipknot', formed: 1995 },
  { name: 'Nirvana', formed: 1987 },
  { name: 'All That Remains', formed: 1998 },
];

const nameCellSize = 20;
const formedCellSize = 10;

function generatePadding(length) {
  return new Array(length).fill(' ').join('');
}

const nameLabel = 'Nome';
const formedLabel = 'Formada em';

const nameLabelPadding = generatePadding(nameCellSize - nameLabel.length);
const formedLabelPadding = generatePadding(formedCellSize - formedLabel.length);

const nameLabelWithPadding = nameLabel.concat(nameLabelPadding);
const formedLabelWithPadding = formedLabel.concat(formedLabelPadding);

const tableHeader = `* ${nameLabelWithPadding} * ${formedLabelWithPadding} *`;

const tableBorders = new Array(tableHeader.length).fill('*').join('');

const initialTable = `${tableBorders}\n${tableHeader}\n`;

function createCell({ name, formed }) {
  const nameWithPadding = name.concat(
    generatePadding(nameCellSize - name.length)
  );
  
  const formedString = formed.toString();
    
  const formedWithPadding = formedString.concat(
    generatePadding(formedCellSize - formedString.length)
  );

  return `* ${nameWithPadding} * ${formedWithPadding} *\n`;
}

function joinCells(a, b) {
  return a.concat(b);
}

const tableBandsFromThe90s = bands
  .filter(({ formed }) => formed >= 1990 && formed <= 1999)
  .map((band) => createCell(band))
  .reduce(joinCells, initialTable)
  .concat(tableBorders);

console.log(tableBandsFromThe90s);

// *************************************
// * Nome                 * Formada em *
// * Avenged Sevenfold    * 1999       *
// * Static-X             * 1994       *
// * System of a Down     * 1994       *
// * Disturbed            * 1994       *
// * Slipknot             * 1995       *
// * All That Remains     * 1998       *
// *************************************

Tem bastante coisa acontecendo aí. Mas, em síntese, esse script filtra as bandas dos anos 90 iterando a constante bands, e depois cria essa tabelinha bacana em formato de string, que foi printada na tela através de um console.log.

Fique à vontade para experimentar com esse código. Sugiro utilizar o interpretador do Node.js ou jogar o código no console do navegador e ir testando. Te garanto que você vai aprender bastante com esse exercício.

Considerações finais

Talvez você possa estar se perguntando: "por que eu devo utilizar essas funções estranhas ao invés dos mais simples e convencionais for e while?".

Tudo que foi explorado aqui pode ser implementado utilizando for e while, que são muito mais amigáveis aos desenvolvedores iniciantes. No entanto, uma das vantagens de utilizar funções como map, filter e reduce é a possibilidade de deixar o seu código mais idiomático. Quando eu vejo o uso de filter em uma base de código nem preciso ler os detalhes de implementação para saber que se trata de uma lógica de filtragem. No entanto, se a mesma lógica é implementada utilizando for...in, eu preciso dedicar tempo para entender a implementação ou recorrer aos comentários do código para saber que se trata de um filtro.

Acho muito ruim quando alguém termina um texto com um "depende", então vou dizer que vale a pena se esforçar para aprender um pouco sobre programação funcional. Conforme foi abordado na introdução, JavaScript é uma linguagem multiparadigmática, de forma que não é necessário, por exemplo, abandonar um paradigma para utilizar outro. Frameworks como o Angular, por exemplo, utilizam lógicas que mesclam FP, OOP e programação orientada à eventos, de forma a extrair o melhor dessas abordagens.

Dito isso, você não deve abandonar for e while, utilize-os se entender que uma abordagem FP ficou confusa ou difícil de entender. Por exemplo, eu geralmente utilizo for...of com async/await em detrimento de map ou forEach,pois evita o famoso "promise hell"; também utilizo delete e push em casos onde a lógica de retornar novos vetores seria muito custosa, computacionalmente falando. Mas, acredito que todo projeto pode se beneficiar com "pequenos toques" de FP.

Espero que esse artigo ajude você a melhorar o seu código.

Até a próxima!

Entrar em contato