Aprenda tudo sobre “reduce” (ou “fold”)

O que é esse loop, por que existe, casos de uso, algoritmo interno e como usá-lo para unificar map e filter loops.

Junto das condicionais, as estruturas de repetição têm um papel vital na programação. Nosso reduce é uma dessas — também chamada fold em algumas linguagens com interfaces levemente diferentes. Mistificada, julgam-na difícil, complexa, mas é apenas um paradigma diferente de escrever repetições.

Essa barreira mental vem de provavelmente terem lhe ensinado que basta aprender “lógica e algoritmos” e que depois tudo em programação seria igual, no máximo mudando as “letras” (sintaxe). Essa “lógica” seria um script com passos ordenados, metáfora de receita de bolo.

Deixe esse dogma de lado antes de continuar a leitura. Será divertido esmiuçar o reduce. Usaremos JavaScript nos exemplos. 😉

Por que esse “novo loop” existe?

Na programação funcional, as estruturas de repetição C-likecomo for e while são desencorajadas. Em algumas linguagens essas estruturas nem existem, como em Elixir, Haskell e Elm.

Isso porque esses loops, como for, são sintaxes imperativas, ou seja, dão ordens e ditam uma rotina de procedimentos: “enquanto isso for verdade, faça tais passos”. E esse não é o modo declarativo, nem funcional de se programar. Digamos que é por isso o reduce/fold existe, assim como o map e filter. Vamos entender a importância disso antes de definir o reduce.

Do imperativo ao declarativo

Como motivador de discussões, iniciaremos uma atividade simples: converter um array de horas numéricas em um array de horários pares no formato textual “HH:MM”.

Dado exemplo de entrada:

[1, 2, 3, 4, 5, 6]

Produziria:

[“02:00”, “04:00”, “06:00”]

Escrevermos em formato imperativo antes. Para depois ir construindo um pensamento declarativo.

Formato imperativo

Usaremos a estrutura for:

const hours = [1, 2, 3, 4, 5, 6];

let evenHourTimes = [];

for (const hour of hours) {
  if (hour % 2 === 0) {
    evenHourTimes.push(`0${hour}:00`);
  }
}

Esse script faz três coisas:

  • Define, do lado de fora do loop, um acumulador evenHourTimes (tempos de hora par) inicializado como um array vazio;
  • A cada iteração (cada passo da “repetição”) há uma decisão (if) de adicionar algo no acumulador ou não, logo tem características de filtragem;
  • A cada iteração, há conversão do item atual de número inteiro para texto, logo tem características de mapeamento.

(Adianto: um loop com livre acesso ao acumulador é a primeira pista de ele ser um reduce! Em breve explico melhor. 😉)

Formato declarativo: primeira tentativa com map e filter

Partindo do raciocínio que esse script faz filtragem e mapeamento, aplicaremos as funções de ordem superiormap e filter. No JavaScript, linguagem multi-paradigma, esses dois são métodos das instâncias de Array. São chamadas de “alta ordem” por serem funções que recebem outras funções como argumento. As funções-argumento, por sua vez, são chamadas callbacks (porque a função superior irá chamá-los “de volta” a cada iteração). Vejamos:

const hours = [1, 2, 3, 4, 5, 6];

const evenHourTimes = hours
  .filter(h => h % 2 === 0)
  .map(h => `0${h}:00`);

Ao invés callbacks, podemos chamar essas funções-argumento de predicados também, porque descrevem o comportamento dos filtros e mapeamentos. Ou seja, ao invés de dar ordens (no imperativo), os dados são simplesmente declarados (no indicativo): “cada item deste array será tal coisa agora”. A semântica do código influencia bastante nosso raciocínio aqui.

complexidade computacional ainda é linear, assim como a implementação imperativa. Ou seja, não se preocupe com performance agora, nem vá tentar otimizar precocemente achando que “chamar duas funções” algum dia será o real gargalo em um sistema.

Formato declarativo: segunda tentativa com reduce/fold

É chegada a hora! Aqui vai uma definição fria, tente absorver apenas o algoritmo, sem viés de linguagem (caso não entenda muito, calma!):

O reduce/fold é uma função que recebe um callback, aplica-o a cada elemento de uma coleção, e a saída de cada iteração alimenta a entrada da seguinte.

Na primeira forma declarativa, há quem se incomode por estarem sendo feitos dois loops ao invés de um. Eis o pulo do gato: map e filter, quando juntos em série, podem virar um único reduce. Segue a reescrita, embora ainda não seja o resultado final:

const hours = [1, 2, 3, 4, 5, 6];

const evenHourTimes = hours.reduce((times, hour) => {
  if (hour % 2 === 0) {
    times.push(`0${hour}:00`);
  }
  return times;
}, []);

(Perceba que, por razões didáticas e até de legibilidade, estamos combinando as funcional e procedural aqui, algo permitido no JavaScript.)

Dissecando: 👀

  • O método reduce é nossa função de ordem superior, está ligado ao array de números hours e aceita dois argumentos: um callback (descrevendo cada iteração) e um valor inicial do acumulador;
  • A função anônima de callback será executada a cada iteração, e nela serão injetados repetidamente dois argumentos: o acumulador times (cujo valor inicial é um array vazio) e o valor atual hour proveniente do array original;
  • É responsabilidade do callback sempre retornar o acumulador, pois é assim que internamente cada execução irá se comunicar com a próxima, ou seja, a saída de cada iteração é parte da entrada da iteração seguinte;
  • O que nosso callback faz é receber cada hour, decidir se a adiciona como texto no times, e retornar esse acumulador (passando-o adiante);
  • O resultado final do reduce é o último valor de acumulador retornado.

Lembre-se da nossa forma imperativa, ela é bem semelhante. Então não há razões para susto. É um paradigma diferente, leve o tempo que precisar para digerir, pois é assim pra todo mundo que lê um for pela primeira vez. 🤷

Note também que, pelo callback ainda ser uma função bem “procedural”, esta escrita ainda é bastante pragmática e modificável, mesmo para iniciantes no paradigma funcional. Fica permitido a livre alteração do acumulador, não só adicionando um elemento a cada iteração, como até removendo itens, reescrevendo-o por completo ou adicionando vários itens por vez. Além disso, nesta escrita é possível inserir efeitos colaterais, como alterar o DOM ou invocar APIs assíncronas a cada iteração.

Formato declarativo: terceira tentativa (predicados no reduce)

Por motivos de pureza, às vezes você deseja desestimular a flexibilidade do callback. Assim reforçamos sua semântica como predicado usando sintaxes de função inlineespalhamento e ternário condicional. Tudo completamente declarativo:

const hours = [1, 2, 3, 4, 5, 6];

const evenHourTimes = hours.reduce((times, hour) => (
  hour % 2 === 0 ? [...times, `0${hour}:00`] : times
), []);

Pode digerir calmamente isso também.

Qual o melhor?

Essa resposta não existe. São dois paradigmas diferentes e possivelmente complementares. Conforme vimos, a maior diferença está no sentido (semântica). Entenda-os todos, e para cada situação aplique a que você julgar adequada.

“Pra quem só tem martelo, todo problema é prego.”

Só não caia na armadilha clássica de iniciante em programação funcional: achar que o paradigma é limitado ou mais difícil. É apenas costume de não saber modelar sua solução dentro desse “novo” formato.

Implementando o reduce/fold do zero

Facilita entender algo quando se conhece seu comportamento interno. A coisa vira mais banal, simples, “mortal” ao invés de mística.

Abriremos a caixa preta do reduce. Chamaremos nossa função de fold, apenas pra tirar da mente que esse loop só “reduz”. Bora? 👊

Apelando ao imperativo

Muito semelhante ao primeiro código do artigo. Implementação com for:

function fold(collection, predicate, initial) {
  let accumulator = initial;

  for (const item of collection) {
    accumulator = predicate(accumulator, item);
  }

  return accumulator;
}

Podemos reescrever nosso velho algoritmo usando a fold agora:

const hours = [1, 2, 3, 4, 5, 6];

const evenHourTimes = fold(hours, (times, hour) => (
  hour % 2 === 0 ? [...times, `0${hour}:00`] : times
), []);

Talvez você pense que trapaceamos o paradigma funcional usando um imperativo por baixo. Mas isso não é um problema. Concorda que todos os dias trapaceamos a computação ao usarmos linguagens textuais quando na verdade é tudo onda elétrica (sequer “uns” e “zeros”) por baixo dos panos?

Paradigmas de programação são abstrações e formas de programar criadas de pessoas para pessoas, e não há impureza conceitual em usar um modelo para construir outro. Pelo contrário! Uma linguagem de alto nível costuma depender de engenhocas de baixo nível.

Deixando tudo declarativo

Mesmo assim, você pode desejar um pouco mais de pureza na vida. Então conheceremos a base de todas as repetições funcionais: a recursividade:

function fold(collection, predicate, accumulator) {
if (collection.length === 0) {
return accumulator;
} else {
const [item, ...rest] = collection;
return fold(rest, predicate, predicate(accumulator, item));
}
}

Caso nunca tenha visto esse termo, recursões são um tópico imenso da Matemática Discreta, fácil de aprender e difícil de masterizar. Simploriamente, uma recursão é apenas uma função que chama a si própria. Caso não entenda de primeira, tudo bem, mas quando tiver a chance, tenha o brio de compreendê-la. 👩‍💻

Desta vez evitamos a escrita em linha única, mas não há quebra significativa de paradigma declarativo. Isso porque o corpo de nossa função continua sendo uma série de declarações internas, ao invés de uma rotina de passos.

A possível “sujeira conceitual” seria o comando return que é desnecessariamente imperativo, admito. Mas é o que deu pra fazer com a sintaxe do JavaScript. Portanto, usar uma inline arrow function para escrever isso é possível, mas não há grandes razões conceituais para isso… e degradaria bastante a legibilidade.

Tradeoffs destas implementações

Especificamente para o JavaScript (e outras linguagens como Python e até Java), no momento em que escrevo isso, a melhor forma de escrever a fold por baixo dos panos seria a imperativa. Por razões capciosas. 👀

É um tópico à parte, complicado de explicar, mas resumirei. Atualmente (março de 2020), não há implementação dos interpretadores para otimização de recursão por calda (Tail Call Optimisation, TCO), nem outro mecanismo para otimizar recursões. E isso implica em degradação de performance, pois cada vez que a função chama a si própria, essa chamada é empilhada em um espaço “especial” da memória; e essa stack (pilha) tem limite; e esse limite excedido é erro de stack overflow (estouro da pilha).

Outras linguagens multi-paradigmas não possuem esse problema e implementam a otimização de recursões, como C++. Linguagens puramente funcionais tendem lidar bem com isso também, como Elixir, Elm e Haskell.

Considerações finais

Expondo as aplicações, dicas, atalhos e detalhes internos do reduce, acredito o ter clarificado como importante ferramenta no cinto de utilidades de toda pessoa desenvolvedora. E este loop tem tanta complexidade quanto os outros, sejam declarativos ou imperativos.

Ao encontrar um loop funcional confuso, a culpa é a mesma de que se fosse um procedural ilegível: quem escreveu. Logo, independente de qual paradigma você esteja usado em cada momento, sempre se preocupe com a legibilidade, manutenibilidade, testes e modularização do código.

Assim, o reduce é isso, sem misticismo: um loop com acesso total a um acumulador comum. 🎉

Deixe um comentário