TypeScript: Técnicas de Programação Funcional

Image for post
Image for post
O melhor dos dois mundos

Há muita publicidade em torno da programação funcional (FP) e muitas crianças legais estão fazendo isso, mas não é uma bala de prata. Como outros paradigmas / estilos de programação, a programação funcional também tem seus prós e contras e um pode preferir um paradigma ao outro. Se você é um desenvolvedor TypeScript / JavaScript e deseja se aventurar na programação funcional, não se preocupe, não precisa aprender linguagens orientadas à programação funcional como Haskell ou Clojure, pois o JavaScript e, portanto, o TypeScript, também pode te ajudar nisso e esta publicação é para você.

Se você estiver procurando por programação funcional em Java ou Golang, verifique outras postagens da série.

Não vou me aprofundar em todos os conceitos de programação funcional detalhadamente. Ao invés disso, vou me concentrar nas coisas que você pode fazer no TypeScript que estão alinhadas com os conceitos de programação funcional. Também não vou discutir os prós e contras da programação funcional em geral.

Lembre-se de que, embora esta publicação seja sobre TypeScript, você pode facilmente fazer o mesmo em JavaScript, já que TypeScript é apenas um superconjunto digitado de JavaScript.

O que é programação funcional?

Conforme Wikipedia:

A programação funcional é um paradigma de programação — um estilo de construção da estrutura e dos elementos dos programas de computador — que trata a computação como a avaliação de funções matemáticas e evita a mudança de estado e dados mutáveis.

Portanto, na programação funcional, existem duas regras muito importantes:

  • Sem mutações de dados: significa que um objeto de dados não deve ser alterado após ser criado.
  • Nenhum estado implícito: O estado oculto / implícito deve ser evitado. Na programação funcional, o estado não é eliminado, mas tornado visível e explícito

Isso significa:

  • Sem efeitos colaterais: Uma função ou operação não deve alterar nenhum estado fora do seu escopo funcional. Ou seja, uma função deve retornar apenas um valor ao invocador e não deve afetar nenhum estado externo. Isso significa que os programas são mais fáceis de entender.
  • Somente funções puras: o código funcional é idempotente. Uma função deve retornar valores apenas com base nos argumentos passados ​​e não deve afetar (efeito colateral) ou depender do estado global. Tais funções sempre produzem o mesmo resultado para os mesmos argumentos.

Além desses, existem os conceitos de programação funcional abaixo que podem ser aplicados no TypeScript, abordaremos esses itens mais adiante.

Usar programação funcional não significa tudo ou nada, você sempre pode usar conceitos de programação funcional para complementar os conceitos orientados a objetos no TypeScript. Os benefícios da programação funcional podem ser utilizados sempre que possível, independentemente do paradigma ou linguagem que você usa. E é exatamente isso que vamos ver.

Programação Funcional em TypeScript

O TypeScript não é uma linguagem puramente funcional, mas oferece muitos conceitos que estão alinhados com as linguagens funcionais; portanto, vamos ver como podemos aplicar alguns dos conceitos de programação funcional acima no TypeScript.

Funções de primeira classe e de ordem superior

Funções de primeira classe (função como cidadão de primeira classe) significa que você pode atribuir funções a variáveis, passar uma função como argumento para outra função ou retornar uma função de outra. O TypeScript suporta isso e, portanto, facilita a escrita de conceitos como closure, currying e funções de ordem superior.

Uma função pode ser considerada como uma função de ordem superior apenas se tiver uma ou mais funções como parâmetros ou se retornar outra função como resultado.

No TypeScript, isso é bastante fácil de fazer

type mapFn = (it: string) => number;

// A função de ordem superior usa uma matriz e uma função como argumentos
function mapForEach(arr: string[], fn: mapFn): number[] {
const newArray: number[] = [];
arr.forEach(it => {
// Estamos executando o método passado
newArray.push(fn(it));
});
return newArray;
}

const list = ["Orange", "Apple", "Banana", "Grape"];

// estamos passando a matriz e uma função como argumentos para o método mapForEach.
const out = mapForEach(list, (it: string): number => it.length);

console.log(out); // [6, 5, 6, 5]

Mas então, em JavaScript / TypeScript, também poderíamos fazê-lo dessa maneira, usando métodos funcionais embutidos, como mapear, reduzir e assim por diante.

const list = ["Orange", "Apple", "Banana", "Grape"];

// estamos passando uma função como argumentos para o método map interno.
const out = list.map(it => it.length);

console.log(out); // [6, 5, 6, 5]

Closure e currying também são possíveis no TypeScript

// esta é uma função de ordem superior que retorna uma função
function add(x: number): (y: number) => number {
// Uma função é retornada aqui como closure
// a variável x é obtida do escopo externo deste método e memorizada no closure
return (y: number): number => x + y;
}

// estamos aplicando o método add para criar mais variações
var add10 = add(10);
var add20 = add(20);
var add30 = add(30);

console.log(add10(5)); // 15
console.log(add20(5)); // 25
console.log(add30(5)); // 35

Há muitas funções nativas de ordem superior declarativas no TypeScript/JavaScript como map, reduce, forEach, filter e assim por diante. Também existem muitas bibliotecas que fornecem interfaces funcionais para serem usadas no TypeScript/JavaScript.

Funções Puras

Como já vimos, uma função pura deve retornar valores apenas com base nos argumentos passados ​​e não deve afetar ou depender do estado global. É possível fazer isso no TypeScript facilmente.

Isso é bastante simples, veja abaixo esta é uma função pura. Ela sempre retornará a mesma saída para a entrada fornecida e seu comportamento é altamente previsível. Podemos armazenar em cache o método com segurança, se necessário.

function sum(a: number, b: number): number {
return a + b;
}

Se adicionarmos uma linha extra nessa função, o comportamento se tornará imprevisível, pois agora possui um efeito colateral que afeta um estado externo.

const holder = {};

function sum(a: number, b: number): number {
let c = a + b;
holder[`${a}+${b}`] = c;
return c;
}

Portanto, tente manter suas funções puras e simples. Usando ferramentas como ESLint e typescript-eslint, é possível aplicá-las.

Recursão

A programação funcional favorece a recursão ao invés do loop. Vamos ver um exemplo para calcular o fatorial de um número.

Na abordagem iterativa tradicional:

function factorial(num: number): number {
let result = 1;
for (; num > 0; num--) {
result *= num;
}
return result;
}

console.log(factorial(20)); // 2432902008176640000

O mesmo pode ser feito usando a recursão como abaixo, o que é favorecido na programação funcional.

const factorial = (num: number): number =>
num == 0 ? 1 : num * factorial3(num - 1);

console.log(factorial(20)); // 2432902008176640000

A desvantagem da abordagem recursiva é que ela será mais lenta em comparação com uma abordagem iterativa na maioria das vezes (a vantagem que buscamos é a simplicidade e a legibilidade do código) e pode resultar em erros de estouro de pilha (stack overflow), pois todas as chamadas de função precisam ser salvas como um quadro para a pilha (stack frame). Para evitar isso, ​​a recursão de cauda (tail recursion) é preferida, especialmente quando a recursão é feita muitas vezes. Na recursão de cauda, ​​a chamada recursiva é a última coisa executada pela função e, portanto, o quadro da pilha de funções não precisa ser salvo pelo compilador. A maioria dos compiladores pode otimizar o código de recursão de cauda da mesma maneira que o código iterativo é otimizado, evitando a penalidade de desempenho. A otimização de chamada de cauda faz parte das especificações do ECMAScript, mas, infelizmente, a maioria dos mecanismos JavaScript ainda não suporta isso.

Agora, usando a recursão de cauda, ​​a mesma função pode ser escrita como a abaixo, mas, dependendo do mecanismo, isso pode não ser otimizado, embora haja soluções alternativas, ele ainda teve um desempenho melhor nos benchmarks.

const factorialTailRec = (num: number): number => factorial(1, num);

const factorial = (accumulator: number, val: number): number =>
val == 1 ? accumulator : factorial(accumulator * val, val - 1);

console.log(factorialTailRec(20)); // 2432902008176640000

Considere usar recursão ao escrever código TypeScript para facilitar a leitura e a imutabilidade, mas se o desempenho for crítico ou se o número de iterações for grande, use loops padrão.

Lazy Evaluation

Lazy Evaluation ou avaliação não-estrita (non-strict evaluation) é o processo de adiar a avaliação de uma expressão até que ela seja necessária. Em geral, o TypeScript faz uma avaliação rigorosa/antecipada, mas para operandos como &&, || e ?: faz uma avaliação lenta. Podemos utilizar técnicas de curto-circuito, funções de ordem superior, closure e técnicas de memorização para fazer avaliações preguiçosas (lazy evaluation).

Veja este exemplo em que o TypeScript avalia tudo antecipadamente.

afunction add(x: number): number {
// isso é impresso, pois as funções são avaliadas primeiro
console.log("executing add");
return x + x;
}

function multiply(x: number): number {
// isso é impresso, pois as funções são avaliadas primeiro
console.log("executing multiply");
return x * x;
}

function addOrMultiply(
add: boolean,
onAdd: number,
onMultiply: number
): number {
return add ? onAdd : onMultiply;
}

console.log(addOrMultiply(true, add(4), multiply(4))); // 8
console.log(addOrMultiply(false, add(4), multiply(4))); // 16

Isso produzirá a saída abaixo e podemos ver que ambas as funções são executadas sempre

executing add
executing multiply
8
executing add
executing multiply
16

Podemos usar funções de ordem superior para reescrever isso em uma versão de avaliação preguiçosa:

function add(x: number): number {
console.log("executing add");
return x + x;
}

function multiply(x: number): number {
console.log("executing multiply");
return x * x;
}

type fnType = (t: number) => number;

// Agora é uma função de ordem superior, portanto, a avaliação das funções é adiada em if-else
function addOrMultiply(
add: boolean,
onAdd: fnType,
onMultiply: fnType,
t: number
): number {
return add ? onAdd(t) : onMultiply(t);
}
console.log(addOrMultiply(true, add, multiply, 4));
console.log(addOrMultiply(false, add, multiply, 4));
executing add
8
executing multiply
16

Ou por memorização como esta:

const cachedAdded = {};
function add(x: number): number {
if (cachedAdded[x]) {
return cachedAdded[x];
}
console.log("executing add");
const out = x + x;
cachedAdded[x] = out;
return out;
}

const cachedMultiplied = {};
function multiply(x: number): number {
if (cachedMultiplied[x]) {
return cachedMultiplied[x];
}
console.log("executing multiply");
const out = x * x;
cachedMultiplied[x] = out;
return out;
}

function addOrMultiply(
add: boolean,
onAdd: number,
onMultiply: number
): number {
return add ? onAdd : onMultiply;
}

console.log(addOrMultiply(true, add(4), multiply(4))); // 8
console.log(addOrMultiply(false, add(4), multiply(4))); // 16

Isso gera o resultado abaixo e podemos ver que as funções foram executadas apenas uma vez para os mesmos valores:

executing add
executing multiply
8
16

Observe que as técnicas de memorização funcionarão apenas quando suas funções forem puras e referencialmente transparentes.

Existem também outras maneiras de fazer avaliações preguiçosas como essa. Fazer avaliações preguiçosas no TypeScript pode não valer a complexidade do código algumas vezes, mas se as funções em questão forem pesadas em termos de processamento, vale a pena avaliar preguiçosamente.

Sistema de Tipos

O TypeScript possui um sistema de tipos robusto e também possui uma grande inferência de tipos. Embora o próprio JavaScript subjacente seja digitado de maneira fraca, o TypeScript, juntamente com um IDE compatível, pode preencher essa lacuna.

Transparência Referencial

Da Wikipedia:

Os programas funcionais não possuem instruções de atribuição, ou seja, o valor de uma variável em um programa funcional nunca muda uma vez definido. Isso elimina as chances de efeitos colaterais, porque qualquer variável pode ser substituída pelo seu valor real em qualquer ponto de execução. Portanto, os programas funcionais são referencialmente transparentes.

Infelizmente, não há muitas maneiras de limitar estritamente a mutação de dados no JavaScript, no entanto, usando funções puras e evitando explicitamente mutações e reatribuição de dados usando outros conceitos que vimos anteriormente, isso pode ser alcançado. Por padrão, o JavaScript passa variáveis ​​primitivas por valor e objetos por referência, portanto, precisamos tomar cuidado para não alterar dados dentro de funções. Bibliotecas como o Immutable.js também podem ser consideradas. Use const o máximo possível para evitar reatribuições.

Por exemplo, o abaixo produzirá um erro:

const list = ["Apple", "Orange", "Banana", "Grape"];

list = ["Earth", "Saturn"];

Mas isso não ajudará quando as variáveis ​​mantiverem referências a outros objetos, por exemplo, a mutação abaixo funcionará independentemente da palavra-chave const.

const list = ["Apple", "Orange", "Banana", "Grape"];

list.push("Earth"); // vai mudar a lista
list.push("Saturn"); // vai mudar a lista

A palavra-chave const permite que o estado interno das variáveis ​​referenciadas seja mutado e, portanto, de uma perspectiva de programação funcional, a palavra-chave const é útil apenas para constantes primitivas e para evitar reatribuições.

No entanto, com o TypeScript, podemos usar tipos especiais mapeados para tornar os objetos em algo de somente leitura e, portanto, evitar mutações acidentais de dados, erros capturados durante o tempo de compilação. Obrigado a @stereobooster e @juliang por me mostrar. Leia meu post sobre tipos mapeados e condicionais aqui para saber mais.

const list: Readonly<string[]> = ["Apple", "Orange", "Banana", "Grape"];

list.push("Earth"); // causará erro de compilação

ou

const list: ReadonlyArray<string> = ["Apple", "Orange", "Banana", "Grape"];

list.push("Earth"); // causará erro de compilação

Outras técnicas estão usando o Object.freeze ou métodos internos, como map, reducer, filter e assim por diante, pois eles não modificam os dados. Também podemos usar este plugin ESlint para restringir mutações.

Estruturas de dados

Ao usar técnicas de programação funcional, é recomendável usar tipos de dados, como Stacks, Maps e Queues, que possuem implementações funcionais.

Para salvar dados, os Maps são melhores que arrays ou conjuntos de hash na programação funcional.

Concluindo

Esta é apenas uma introdução para aqueles que estão tentando aplicar algumas técnicas de programação funcional no TypeScript. Muito mais pode ser feito no TypeScript e com o ECMAScript em constante evolução por baixo, isso deve ser ainda mais fácil. Como eu disse anteriormente, a programação funcional não é a super solução, mas oferece muitas técnicas úteis para um código mais compreensível, sustentável e testável. Pode coexistir perfeitamente bem com estilos de programação imperativos e orientados a objetos. De fato, todos nós devemos usar o melhor de tudo.

Espero que você ache isso útil. Se você tiver alguma dúvida ou se acha que perdi alguma coisa, adicione a dica como um comentário.

Se você gostou deste artigo, deixe um like ou um comentário.

Você pode me seguir no Twitter e LinkedIn.

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store