Introdução a programação funcional com JavaScript/TypeScript
- Lucas Akira
- Sem categoria
- abr 10, 2022
Um dos termos da moda que encontramos no mundo da programação faz um tempo é a tal da programação funcional, um paradigma de programação mais antigo que a orientação a objetos, que vem ganhando muito destaque principalmente na comunidade de JavaScript sendo a inspiração de muitas ferramentas famosas que utilizamos no nosso dia-a-dia como o React ou o RxJS, ou do lado da evolução da própria linguagem usando como exemplo as Promises que vem da ideia de Monadas, ou os métodos de array como o map e o reduce.
Então hoje vamos conhecer alguns desses conceitos que podem ser mais úteis do que pensamos no nosso dia-a-dia seja para aplicarmos em nossos códigos, ou entendermos melhor como a linguagem funciona.
Paradigma funcional
Assim como a orientação a objetos, a programação funcional é um paradigma de programação declarativo. Ela se baseia em representar o programa como um conjunto de aplicações e composições de funções puras.
Para ficar mais fácil de entender vamos fazer uma breve comparação com outros paradigmas:
- No imperativo você diz como que o problema vai ser resolvido;
- No declarativo você diz com o que o problema vai ser resolvido;
- No paradigma estruturado, o programa é um conjunto de instruções imperativas onde vamos modificando o estado do nosso programa (representado pelas nossas variáveis) por meio de estruturas de controle e laços de repetição;
- No paradigma procedural, nós fazemos o mesmo que o paradigma estruturado só que nós organizamos o nosso programa ao redor de sub-rotinas (que é o que nós chamamos de funções no JavaScript), que são pedaços de código que podemos reutilizar ao longo do programa;
- No paradigma orientado a objetos, o programa é representado por várias unidades independentes de código, cujo nós chamamos de objetos, que possuem comportamentos (funções/sub-rotinas) e estados (variáveis) próprios e que interagem entre si. O programa geralmente é feito de um jeito imperativo;
- No paradigma funcional, o programa vai ser feito de um jeito declarativo (que é basicamente o contrário de todos os os paradigmas anteriores, já que não vamos focar no como e sim no que), e será representado por meio de funções (dessa vez no sentido mais matemático do que no sentido de sub-rotina), e diferente do paradigma procedural onde elas eram só conjuntos de comandos para serem reutilizados, no paradigma funcional nós tratamos as funções como se elas fossem dados da linguagem, o que nos permite compor elas como se fossem blocos de lego para formar o nosso programa (semelhante ao que é feito na programação orientada a objetos, porém lá a composição ocorre entre objetos e não entre funções ou entre sub-rotinas).
Conceitos e aplicações práticas
Agora que já introduzimos o paradigma, vamos ver caracterísiticas atribuídas a programas funcionais (embora nem todas sejam exclusivas da programação funcional) e como elas podem impactar no nosso código do dia-a-dia.
Funções puras
Assim como programas orientados a objeto são constituídos de objetos, e programas procedurais são feitos de sub-rotinas, programas no paradigma funcional são feitos de funções puras.
Uma função pura é uma função que obdece a dois critérios:
- Sempre que ela for executada com os mesmos parâmetros, ela vai retornar o mesmo resultado;
- Ela não causa nenhum efeito colateral;
Logo, bons exemplos de funções puras são funções matemáticas, por exemplo:
const add = (x: number, y: number): number => x + y;
const subtract = (x: number, y: number): number => x - y;
const multiply = (x: number, y: number): number => x * y;
const div = (x: number, y: number): number => x / y;
Elas obdecem aos dois critérios citados, afinal se, por exemplo, somarmos 1 e 2 o resultado sempre vai ser 3, e por a única coisa que elas fazerem ser retornar um valor, elas também não causam nenhum efeito colateral.
O benefícios do primeiro critério envolvem uma maior confiabilidade e previsibilidade do código, pois se a função vai sempre retornar a mesma coisa, dado os mesmos parâmetros, só precisamos nos preocupar com o valor gerado e com mais nada, afinal podemos confiar que ela não vai ter um comportamento inesperado quando executada com esses parâmetros, e isso torna mais fácil de prever o comportamento do software e isso diminui muito a chance de novos bugs aparecerem, além de facilitar muito os testes dado essa previsibilidade do resultado.
Efeitos colaterais
Agora que já falamos do primeiro critério vamos nos aprofundar mais no segundo, vamos entender porque um dos focos da programação funcional é evitar e isolar os efeitos colaterais (o que não significa sumir com eles, apenas que vamos escrever tudo que for possível de forma pura e isolar o que não for para que possamos lidar com essas impurezas o mínimo possível).
Um efeito colateral é quando uma função chama uma função impura dentro dela ou depende/modifica/afeta algo externo a ela.
Por exemplo, quando ela modifica uma variável de fora do escopo dela, ela está afetando todos os outros lugares onde aquela variável está sendo utilizada.
Quando ela escreve em um arquivo, ela está afetando algo fora do escopo da função (no caso o seu sistema de arquivos).
Quando ela lê o input de um usuário ela depende de algo de fora que não são os seus parâmetros.
Dessa forma podemos perceber que se uma das vantagens do primeiro critério é a confiabilidade e a previsibilidade, quando lidamos com efeitos colaterais as desvantagens são exatamento o contrário.
Afinal, se ela afeta algo de fora dela não temos confiança de que podemos executar ela em qualquer lugar, mesmo em códigos que rodam em paralelo, por exemplo, imagine um código que possui duas threads e elas tentam modificar a mesma variável ao mesmo tempo (e as duas tentam escrever nela valores diferentes), isso é um problema enorme quando lidamos com código assíncrono ou paralelo.
Outro problema de afetar ou depender de coisas fora dela, é que não podemos prever o resultado final da execução dessa função com tanta facilidade, pois agora além de prever o resultado retornado por ela, temos que prever as mudanças no software em todos os lugares afetados pela mudança que ocorreu dentro da função, e se ela depende de uma variável fora do escopo dela, então também não tem como prever ou garantir que dado os mesmos parâmetros, ela vai retornar os mesmos resultados, pois se a variável de fora dela mudar por algum motivo, muito provavelmente o resultado da execução dessa função vai acabar mudando também.
Alguns exemplos de efeitos colaterais seriam:
- Modificar uma variável;
- Modificar uma estrutura de dados;
- Definir um propriedade em um objeto;
- Lançar uma exceção;
- Mostrar algo na tela ou ler input do usuário;
- Ler ou escrever em um arquivo;
- Conectar em um banco de dados;
- Fazer requisições HTTP;
Trânsparencia referencial
Um benefício interessante que surge do uso dos dois critérios é a trânsparencia referencial que é um princípio que diz que você pode trocar a aplicação (execução) de uma função pelo resultado de forma segura (sem causar nenhum efeito colateral). Por exemplo, a nossa função add
, por ser uma função pura, pode ter os benefícios da trânsparencia referencial onde podemos intercambiar facilmente o seu resultado pela execução dela. Ex:
console.log(add(1, 2)); // 3
console.log(3); // 3
Isso permite que funções constantes (que sempre retornam o mesmo resultado) sejam intercambiáveis com seus valores. Ex:
const one = () => 1;
console.log(one()); // 1
console.log(1); // 1
Esse é um princípio muito interessante de se trabalhar quando vamos compor várias funções, assunto esse que falaremos mais para frente.
Imutabilidade
Uma das consequências de só utilizarmos funções puras para lidar com nosso código é que o estado dele é imutável. O que significa que não vamos mais usar variáveis apenas constantes, pareceu algo de outro mundo para você? Então vamos ver como podemos passar a utilizar apenas constantes no nosso código.
A primeira coisa é que não modificamos mais os valores, nós geramos novos valores e colocamos eles em uma nova constante. Ex:
// Estado mutável
let x = 1;
x = x + 5;
console.log(x); // 6
// Estado imutável
const count = 1;
const incrementedByOneCount = count + 1;
E no caso de estarmos lidando com estruturas de dados, como por exemplo objetos literais ou os arrays, nós copiamos eles e depois transferimos para uma nova constante, e no caso de operações como adicionar um item ou modificar um item de um array, nós geramos uma nova estrutura de dados sempre.
// Exemplo mutável
const evens = [2, 4, 6];
evens.push(8);
console.log(evens); // [2, 4, 6, 8]
// Exemplo imutável
const odds = [1, 3, 5];
const newOdds = [...odds, 7];
console.log(odds); // [1, 3, 5]
console.log(newOdds); // [1, 3, 5, 7]
Os benefícios causados pela imutabilidade no código além dos já citados por serem livres de efeitos colaterais, incluem uma maior expressividade no código (uma vez que você sempre vai ter que pensar em nomes mais descritivos para as novas variáveis que derivam do estado original), além de um registro como, como se fosse uma linha do tempo, da mudança de estados da sua aplicação (esse conceito é muito bem utilizado pelo devtools do Redux, que leva esse conceito a ferramenta de debug e permite que você “volte no tempo” entre essas mudanças de estado no código).
Recursão
E se você estava esperto já deve ter notado que sem variáveis, praticamente não tem como utilizarmos laços de repetição, visto que quase todos eles necessitam modificar alguma variável para funcionar como contador para as iterações.
Logo para podermos utilizar funções recursivas no lugar, e se você não lembra o que é uma, basicamente elas são funções que chamam elas mesmas, sendo uma das mais conhecidas a função de fatorial:
const factorial = (x: number) => (x <= 1) ? 1 : x * factorial(x - 1);
O ternário foi utilizado para aplicar uma condição de parada funcionando como a condição de um laço while por exemplo. A versão dela sem ser recursiva seria algo como:
function factorial(x: number) {
let result = x;
while (result > 1) {
result = result * result - 1;
}
return result;
}
E se você já estudou um pouco sobre recursão deve se lembrar do detalhe de que não podemos abusar dela senão teríamos o famoso erro de stack overflow por termos chamadas demais a funções de uma vez só. Porém podemos resolver isso de forma simples utilizando uma técnica chamada de recursão de cauda que você pode conferir em mais detalhes nessa discussão do stack overflow.
Composição de funções
Agora que já entendemos o núcleo da programação funcional que são as funções puras, vamos ver um outro princípio muito importante que é a composição de funções. Se você se lembra das aulas de matemática do ensino médio, deve se lembrar que uma função composta é resultado da aplicação de uma função no resultado da aplicação de outra função, ex:
f(x) = x + 1 // Função f
g(x) = x * 5 // Função g
(f o g)(x) = (x * 5) + 1 // Função composta de f e g
E podemos aplicar isso, no JavaScript por exemplo, por causa da transparência referencial que vimos mais cedo, afinal se fossemos escrever o exemplo acima em JavaScript, teríamos algo como:
const f = x => x + 1;
const g = x => x * 5;
const fApplyG = x => f(g(x));
fApplyG(1); // 6
E perceba que podemos simplesmente trocar g(x) pelo resultado da execução dela que nada iria mudar, o resultado final continuaria sendo um resultado dentro da imagem da função f matematicamente falando. Um exemplo mais real de como a composição de funções pode ser útil seria termos 3 funções independentes e daí juntarmos elas em uma só na hora de processar um texto. Ex:
const upperCase = text => text.toUpperCase();
const concatRight = last => first => first.concat(last);
const concatWithExclamation = concatRigh("!");
const scream = text => concatWithExclamation(upperCase(text));
scream("Olá Mundo"); // OLÁ MUNDO!
Utilizamos composição para criar uma nova função a partir de duas já existentes assim mantendo cada função com uma única responsabilidade. Utilizar a composição de funções é como ir montando um lego, nós pegamos várias funções que fazem coisas muito simples e vamos compondo elas até termos o nosso software rodando.
Funções de primeira classe
Você deve ter notado que a função concatRight do exemplo anterior é um pouco diferente do que você está habiutado a criar. Isso é porque ela utiliza uma técnica chamada currying para ser criada, e para entendermos como essa técnica funciona, precisamos saber que em JavaScript (e em linguagens funcionais), as funções são cidadãos de primeira classe, que é quando a linguagem considera funções como qualquer outro tipo de dado, assim podemos colocar elas dentro de variáveis, passar elas como parâmetros, retornar elas de outras funções e etc. Em uma linguagem sem suporte a funções de primera classe não é possível ter callbacks por exemplo, como é o caso do Java antes dos lambdas, onde eles usavam uma classe anônima que implementava um único método no lugar.
Funções de alta ordem
Mas no que ter funções de primeira classe na linguagem pode ser útil? Uma das coisas é o que nós citamos antes que é o poder de se ter callbacks, mas existem inumeras outras coisas legais que podemos fazer com essa feature, e uma delas é criarmos high order functions (ou funções de alta ordem) que é um termo para descrever funções que recebem uma função como parâmetro e devolvem uma outra função como saída (bem parecido com o padrão decorator da orientação a objetos).
Um ótimo exemplo de high order function seria o filter (que está presente na forma de método nos arrays, e nesse caso ela não é uma high order function), e antes de continuarmos dê uma olhada no exemplo de implementação dela abaixo:
// forma curta
const filter = <T>(predicate: (item: T) => boolean) => (items: T[]) => {
return items.filter(predicate);
};
// forma longa
function filter<T>(predicate: (value: T) => boolean) {
return function (items: T[]) {
return items.filter(predicate);
};
}
Função predicado é um tipo de função que retorna um boolean, por isso primeiro nós recebemos um predicate, e depois o array que será filtrado, assim a nossa função filter é uma high order function, onde nós passamos uma função para ela e nós ganhamos uma função capaz de filtrar um array usando aquele predicado como validador, e assim podemos criar várias versões diferentes de filter apenas mudando a callback inicial, ex:
const filter = <T>(predicate: (item: T) => boolean) => {
return (items: T[]) => items.filter(predicate);
};
const filterEven = filter((number: number) => number % 2 === 0);
const filterAdults = filter((age: number) => age > 18);
const filterOnlyFoo = filter((name: string) => name.startsWith("Foo "));
console.log(filterEven([1, 2, 3, 4, 5, 6]));
console.log(filterAdults([10, 27, 32, 14, 25, 6]));
console.log(filterOnlyFoo(["Foo Bar", "John Doe", "Jane Doe", "Bar Bazz"]));
// Saída em ordem dos console.log
// [2, 4, 6]
// [27, 32, 25]
// ["Foo Bar"]
Outro exemplo legal do uso desse conceito é para melhorar a função passada para a nossa high order function.
Um cenário muito comum quando trabalhamos com eventos em JS é utilizar o preventDefault
para evitar o envio de um formulário por exemplo. E para ilustrar isso vamos ao trecho abaixo de código:
const newsletterForm = document.querySelector(".newsletter") as HTMLFormElement;
const contactForm = document.querySelector(".contact") as HTMLFormElement;
function signUpInNewsletter() {
console.log("Cadastrando e-mail na newsletter...");
}
function sendContactMessage() {
console.log("Enviando formulário de contato por e-mail...");
}
newsletterForm.addEventListener("submit", (event) => {
event.preventDefault();
signUpInNewsletter();
});
contactForm.addEventListener("submit", (event) => {
event.preventDefault();
sendContactMessage();
});
Perceba que temos dois formulários diferentes, um para registrar a pessoa em uma newsletter, e outro para pessoa enviar uma mensagem de contato, perceba que mesmo tendo cada funcionalidade isolada em sua função de forma que possamos reutilizar essas lógicas em outros pontos do código se necessário, ainda assim temos que sempre criar uma função intermediária que vai chamar o preventDefault e ai sim executar a lógica que nós queremos.
Não seria interessante que nós tivessemos uma função que consegue adicionar esse comportamento de chamar o preventDefault a uma função já existente, assim não teríamos que ficar recriando essa função intermediária toda vez e ainda manteríamos cada código com uma responsabilidade só?
Então é isso que vamos fazer por meio de uma high order function, nós podemos criar ela da seguinte maneira:
const withPreventDefault = (callback: () => any) => (event: Event) => {
event.preventDefault();
callback();
};
const newsletterForm = document.querySelector(".newsletter") as HTMLFormElement;
const contactForm = document.querySelector(".contact") as HTMLFormElement;
function signUpInNewsletter() {
console.log("Cadastrando e-mail na newsletter...");
}
function sendContactMessage() {
console.log("Enviando formulário de contato por e-mail...");
}
newsletterForm.addEventListener(
"submit",
withPreventDefault(signUpInNewsletter)
);
contactForm.addEventListener("submit", withPreventDefault(sendContactMessage));
Assim nós conseguimos aplicar o padrão decorator muito comum na orientação a objetos por meio de uma high order function, e graças a isso podemos decorar qualquer função para que ela chame o preventDefault
antes de ser executada quando passamos ela para um addEventListener
por exemplo.
Currying
E agora que nós aprendemos todos esses conceitos legais finalmente temos uma boa base para entender o que é a técnica de currying. Currying é uma técnica onde nós convertemos uma função que possui mais de um parâmetro em uma cadeia de funções que só possuem um parâmetro. Pegando por exemplo a nossa concatRight
que era o objetivo original. Normalmente ela seria escrita assim:
const concatRight = (last: string, first: string) => first.concat(last);
Perceba que ela tem dois parâmetros last e first, se aplicarmos a técnica de currying nela, nós vamos obter a função original do nosso exemplo:
const concatRight = (last: string) => (first: string) => first.concat(last);
Perceba que em vez de receber os dois parâmetros de uma vez só, primeiro nós recebemos só o last
, ai retornamos uma função que recebe o first
e só ai o código da função com dois parâmetros é escrito.
Podemos fazer isso pois se uma função é criada dentro (ou nesse caso por outra), ela consegue se lembrar do contexto onde ela foi criada, que no caso da função que recebe o first pela função que recebe o last, e portanto faz com que ela tenha acesso ao last.
Um dos benefícios dessa técnica é poder converter funções com n números de argumentos em funções unárias (funções com apenas um argumento), o que é bem útil quando vamos compor funções uma vez que temos que passar o resultado de uma para a outra e isso significa que as funções precisam ser unárias para fazermos isso de forma fácil.
Aplicação parcial
Outra vantagem dessa técnica é a possibilidade de fazermos a aplicação parcial de uma função que foi transformada pelo currying. Aplicação parcial é a habilidade de passarmos só uma parte dos parâmetros de uma função e decidirmos o resto depois, o que é bem útil para gerar funções derivadas outras funções. Um exemplo claro disso é a concatRight
do nosso exemplo:
const endWithExclamation = concatRight("!");
const endWithInterrogation = concatRight("?");
Perceba que por termos aplicado o currying na função concatRight
com dois argumentos agora podemos aplicar ela parcialmente e só passar o primeiro parâmetro (que no caso é o last
) e criar duas versões diferentes dela uma que adiciona a exclamação no final da string e outra que adiciona a interrogação ao final da string.
Vale lembrar que a aplicação parcial é diferente do currying apesar de ser um dos seus benefícios, pois o currying converte uma função com vários argumentos em várias unárias para que possamos usar a função original de forma unária, e a aplicação parcial é só uma consequencia disso.
Mas é possível usar aplicação parcial sem a técnica de currying, pois em JavaScript as funções são objetos também e toda função tem um método chamado bind que permite que nós façamos a aplicação parcial de qualquer função. Ex:
const sum3Numbers = (a: number, b: number, c: number) => a + b + c;
const sum2NumbersWith1 = sum3Numbers.bind(null, 1);
const sumWith3 = sum3Numbers.bind(null, 1, 2);
const six = sum3Numbers.bind(null, 1, 2, 3);
console.log(sum3Numbers(1, 2, 3)); // 6
console.log(sum2NumbersWith1(2, 3)); // 6
console.log(sumWith3(3)); // 6
console.log(six()); // 6
Note que com o método bind podemos passar só 1 argumento dos 3 que ela aceita, e ele nos dá uma versão dela só aceitando os 2 últimos e com o primeiro já definido pelo que passamos para o bind, mas também podemos passar dois dos 3 ou até mesmo todos os parâmetros de uma vez só e ganhamos sempre uma versão dela com esses parâmetros já aplicados.
Conclusão
Obrigado por ler até aqui, conta para gente aqui nos comentários se alguma dessas coisas mudou sua forma de programar, e nos vemos no próximo artigo.