A programação funcional é um paradigma de programação que trata a computação como a avaliação de funções matemáticas e evita mudanças de estado e dados mutáveis. Em JavaScript, esse paradigma ganhou destaque por permitir um código mais limpo, previsível e fácil de testar. Nesta aula, vamos explorar os quatro pilares fundamentais da programação funcional: funções puras, imutabilidade, composição e currying. Você verá como aplicar esses conceitos no seu dia a dia para escrever um código mais robusto e modular.

Diferente dos paradigmas imperativos, onde o foco está em como executar tarefas passo a passo, a programação funcional se concentra no que deve ser calculado. Isso leva a funções que são independentes do estado externo, o que facilita a depuração e o raciocínio sobre o código. Vamos mergulhar em cada conceito com exemplos práticos e, ao final, você terá exercícios para fixar o aprendizado.

Funções puras

Uma função pura é aquela que, dado o mesmo conjunto de entradas, sempre retorna o mesmo resultado, e não possui efeitos colaterais observáveis. Em outras palavras, ela não modifica variáveis fora do seu escopo, não faz chamadas de I/O (como leitura de arquivos ou requisições HTTP), não altera o estado do sistema e não depende de valores que possam mudar (como variáveis globais). Funções puras são a base da programação funcional porque garantem previsibilidade e facilitam testes.

Por exemplo, considere uma função que calcula o dobro de um número. Ela é pura porque sempre retorna o mesmo valor para a mesma entrada e não altera nada fora dela. Já uma função que incrementa um contador global é impura, pois modifica o estado externo. Vamos ver um exemplo em código:

// Função pura: sempre retorna o mesmo resultado para a mesma entrada
function dobro(x) {
  return x * 2;
}

// Função impura: depende de uma variável externa que pode mudar
let contador = 0;
function incrementar() {
  contador++;
  return contador;
}

console.log(dobro(5)); // 10
console.log(dobro(5)); // 10

console.log(incrementar()); // 1
console.log(incrementar()); // 2

Vantagens das funções puras:

  • Previsibilidade: o resultado é sempre o mesmo para as mesmas entradas.
  • Testabilidade: você não precisa configurar um estado global para testar.
  • Paralelismo: funções puras podem ser executadas em paralelo sem risco de corrida.
  • Memoização: é possível armazenar resultados para evitar recomputação.

Imutabilidade

Imutabilidade significa que, uma vez criado, um dado não pode ser alterado. Em vez de modificar objetos ou arrays existentes, criamos novos dados a partir dos originais. Isso evita efeitos colaterais e torna o código mais seguro, especialmente em aplicações complexas com muitos componentes interagindo. Em JavaScript, os objetos e arrays são mutáveis por padrão, mas podemos adotar práticas para tratá-los como imutáveis.

Por exemplo, em vez de usar push para adicionar um item a um array, que modifica o array original, usamos o operador spread ou concat para criar um novo array. Da mesma forma, para atualizar um objeto, usamos Object.assign ou o operador spread para criar um novo objeto com as propriedades alteradas. Vamos ver um exemplo:

const numeros = [1, 2, 3];

// Mutável (impuro): modifica o array original
const numerosMutados = numeros;
numerosMutados.push(4);
console.log(numeros); // [1, 2, 3, 4] - o original foi alterado!

// Imutável: cria um novo array sem alterar o original
const numerosImutaveis = [1, 2, 3];
const novosNumeros = [...numerosImutaveis, 4];
console.log(numerosImutaveis); // [1, 2, 3] - original intacto
console.log(novosNumeros); // [1, 2, 3, 4]

// Objetos
const pessoa = { nome: 'Ana', idade: 30 };
const pessoaAtualizada = { ...pessoa, idade: 31 };
console.log(pessoa); // { nome: 'Ana', idade: 30 } - original intacto
console.log(pessoaAtualizada); // { nome: 'Ana', idade: 31 }

Para garantir imutabilidade em estruturas mais complexas, bibliotecas como Immutable.js ou Immer podem ser usadas, mas o JavaScript moderno já oferece métodos como map, filter e reduce que retornam novos arrays, incentivando a imutabilidade.

Composição

Composição é a técnica de combinar funções simples para criar funções mais complexas. Em programação funcional, isso é feito passando a saída de uma função como entrada de outra. A composição permite construir pipelines de processamento de dados de forma declarativa e reutilizável. Em JavaScript, podemos usar funções como compose (de bibliotecas como Ramda) ou simplesmente encadear chamadas de funções.

Por exemplo, suponha que temos duas funções: uma que dobra um número e outra que soma 1. Podemos compô-las para criar uma nova função que primeiro dobra e depois soma 1. A composição pode ser feita de forma manual ou com uma função utilitária. Vamos ver:

const dobrar = x => x * 2;
const somar1 = x => x + 1;

// Composição manual: aplicar somar1 após dobrar
const dobrarESomar1 = x => somar1(dobrar(x));
console.log(dobrarESomar1(5)); // 11

// Função compose genérica (da direita para a esquerda)
function compose(...fns) {
  return function(x) {
    return fns.reduceRight((acc, fn) => fn(acc), x);
  };
}

const transformar = compose(somar1, dobrar);
console.log(transformar(5)); // 11

// Função pipe (da esquerda para a direita)
function pipe(...fns) {
  return function(x) {
    return fns.reduce((acc, fn) => fn(acc), x);
  };
}

const transformarPipe = pipe(dobrar, somar1);
console.log(transformarPipe(5)); // 11

A composição é poderosa porque permite criar funções pequenas e focadas, que podem ser combinadas de várias maneiras. Isso promove a reutilização e a clareza, já que cada função faz apenas uma coisa.

Currying

Currying é a técnica de transformar uma função que recebe múltiplos argumentos em uma sequência de funções que recebem um único argumento. Por exemplo, em vez de uma função soma(a, b), temos soma(a)(b). O currying facilita a reutilização de funções e a criação de funções parciais, onde alguns argumentos são fixados e outros ficam para serem fornecidos depois.

Em JavaScript, o currying pode ser feito manualmente ou com funções utilitárias. Vamos ver um exemplo de currying manual e como usá-lo:

// Função normal
function soma(a, b) {
  return a + b;
}
console.log(soma(2, 3)); // 5

// Versão curried
function somaCurried(a) {
  return function(b) {
    return a + b;
  };
}
const somaCom2 = somaCurried(2);
console.log(somaCom2(3)); // 5

// Com arrow functions
const somaCurriedArrow = a => b => a + b;
const somaCom5 = somaCurriedArrow(5);
console.log(somaCom5(10)); // 15

// Função utilitária para currying de funções com N argumentos
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn(...args);
    } else {
      return (...nextArgs) => curried(...args, ...nextArgs);
    }
  };
}

const soma3 = (a, b, c) => a + b + c;
const soma3Curried = curry(soma3);
console.log(soma3Curried(1)(2)(3)); // 6
console.log(soma3Curried(1, 2)(3)); // 6

O currying é útil em cenários como configurar funções de forma parcial, criar funções de alta ordem e melhorar a composição. Por exemplo, podemos usar currying para criar uma função de saudação personalizada:

const saudacao = (tipo) => (nome) => `${tipo}, ${nome}!`;
const ola = saudacao('Olá');
const tchau = saudacao('Tchau');

console.log(ola('Maria')); // Olá, Maria!
console.log(tchau('João')); // Tchau, João!

Boas práticas na programação funcional

Ao adotar a programação funcional em JavaScript, algumas boas práticas podem ajudar: prefira funções puras sempre que possível; evite mutação de dados, criando novos objetos/arrays; use métodos como map, filter e reduce em vez de loops imperativos; e quebre funções grandes em funções menores e compostas. Além disso, aproveite o currying para criar funções reutilizáveis e a composição para construir pipelines claros.

É importante lembrar que JavaScript não é uma linguagem puramente funcional, mas podemos aplicar esses princípios para melhorar a qualidade do código. Não é necessário aplicar tudo o tempo todo; use o bom senso e as necessidades do projeto.

Referências

Exercícios

  1. Crie uma função pura chamada areaRetangulo que recebe base e altura e retorna a área. Teste com dois valores diferentes e verifique se o resultado é sempre o mesmo para as mesmas entradas.

    ✓ Resposta:
    function areaRetangulo(base, altura) {
      return base * altura;
    }
    console.log(areaRetangulo(2, 3)); // 6
    console.log(areaRetangulo(2, 3)); // 6
  2. Dado o array const numeros = [1, 2, 3, 4];, escreva uma expressão que retorne um novo array com todos os números dobrados, sem modificar o original.

    ✓ Resposta:
    const numeros = [1, 2, 3, 4];
    const dobrados = numeros.map(x => x * 2);
    console.log(dobrados); // [2, 4, 6, 8]
    console.log(numeros); // [1, 2, 3, 4]
  3. Componha as funções add1 e multiply2 para criar uma função que primeiro multiplica por 2 e depois soma 1. Use a função pipe mostrada na aula.

    ✓ Resposta:
    const add1 = x => x + 1;
    const multiply2 = x => x * 2;
    function pipe(...fns) {
      return function(x) {
        return fns.reduce((acc, fn) => fn(acc), x);
      };
    }
    const add1AfterMultiply2 = pipe(multiply2, add1);
    console.log(add1AfterMultiply2(5)); // 11
  4. Implemente uma função curry genérica e use-a para criar uma versão curried da função subtract(a, b, c) que retorna a - b - c. Teste com subtractCurried(10)(2)(3).

    ✓ Resposta:
    function curry(fn) {
      return function curried(...args) {
        if (args.length >= fn.length) {
          return fn(...args);
        } else {
          return (...nextArgs) => curried(...args, ...nextArgs);
        }
      };
    }
    const subtract = (a, b, c) => a - b - c;
    const subtractCurried = curry(subtract);
    console.log(subtractCurried(10)(2)(3)); // 5
  5. Crie uma função pura formatName que recebe um objeto { primeiroNome, sobrenome } e retorna a string "Sobrenome, PrimeiroNome". Use imutabilidade para criar uma versão que retorne um novo objeto com uma propriedade nomeCompleto adicionada, sem alterar o original.

    ✓ Resposta:
    function formatName(pessoa) {
      return `${pessoa.sobrenome}, ${pessoa.primeiroNome}`;
    }
    function addNomeCompleto(pessoa) {
      return { ...pessoa, nomeCompleto: formatName(pessoa) };
    }
    const pessoa = { primeiroNome: 'Maria', sobrenome: 'Silva' };
    const novaPessoa = addNomeCompleto(pessoa);
    console.log(novaPessoa); // { primeiroNome: 'Maria', sobrenome: 'Silva', nomeCompleto: 'Silva, Maria' }
    console.log(pessoa); // { primeiroNome: 'Maria', sobrenome: 'Silva' }