Resumo teórico
Imagine uma máquina de salgadinhos. Você coloca uma moeda, aperta um botão e um salgadinho cai. Aperte o mesmo botão com a mesma moeda e vem o mesmo salgadinho. A máquina não lembra de ontem, não muda as outras máquinas e não se importa com que horas são. Programação funcional é um jeito de escrever programas em que as peças se comportam assim: pequenas máquinas que recebem algo e devolvem algo.
Um programa é montado ligando essas máquinas, como os canos de um parque aquático. Como cada máquina depende só do que você coloca nela, dá para testá-la sozinha, levá-la para outro lugar, rodar várias ao mesmo tempo e ainda saber o que vai acontecer. É por isso que ideias nascidas em linguagens como Haskell e Elixir hoje vivem dentro de TypeScript, Rust e Java, e você vai encontrá-las em quase todo código moderno.
Neste resumo você vai aprender: o que é uma função pura, por que dados que nunca mudam são um presente, como funcionam map, filter e reduce, o que uma closure lembra, como uma função pode chamar a si mesma (recursão) sem quebrar o computador, como juntar funções (composição e currying), como descrever dados com tipos que não mentem (tipos de dados algébricos e casamento de padrões), como fazer o trabalho só quando ele é preciso (avaliação preguiçosa), como tratar valores ausentes e erros como dados comuns (Option e Result), onde devem morar os efeitos colaterais inevitáveis e como tudo isso aparece em Elixir e TypeScript. No final você estará pronto para o quiz.
O panorama: programas feitos de funções
Uma funçãoum pedaço de código com nome que recebe entradas e devolve um resultado é como uma máquina com uma entrada para os ingredientes e uma bandeja para o resultado. As coisas que você coloca são os argumentos (ou parâmetros). A coisa que sai é o valor de retorno. Em programação funcional gostamos tanto de funções que as tratamos como qualquer outra coisa, como um número ou uma palavra: podemos guardar uma função numa variável, entregá-la a outra função ou receber uma de volta como resultado. Uma linguagem que permite isso tem funções de primeira classe.
| Palavra | O que significa | Exemplo |
|---|---|---|
| Argumento | Um valor que você dá a uma função | Em double(4), o 4 |
| Valor de retorno | O que a função devolve | double(4) retorna 8 |
| Efeito colateral | Qualquer coisa que a função faz no mundo de fora além de devolver um valor | Imprimir, escrever um arquivo, mudar uma variável global |
| Expressão | Um pedaço de código que produz um valor | 2 + 3 ou double(4) |
| Imutável | Não pode mudar depois de criado | Uma foto impressa: para mudar, você imprime outra |
| Preguiçoso / ansioso | Preguiçoso (lazy) espera até o resultado ser necessário. Ansioso (eager) calcula na hora | Uma receita que você só cozinha quando os convidados chegam, contra comida feita antes |
A programação funcional se apoia em dois grandes hábitos. O primeiro é escrever funções puras, cujo resultado depende só dos argumentos. O segundo é usar dados imutáveis, que nunca mudam: para fazer uma mudança você cria um valor novo. Os efeitos colaterais inevitáveis, como falar com a rede, são empurrados para as bordas do programa.
entrada --> [ limpar ] --> [ filtrar ] --> [ somar ] --> saída
Cada caixa é uma função.
Cada seta carrega um valor, não uma variável alterada.Funções puras e transparência referencial
Uma função pura é uma função que se comporta como as da matemática. Duas regras: (1) os mesmos argumentos sempre dão o mesmo resultado, e (2) chamá-la não muda nada que alguém consiga notar fora dela. O botão de uma calculadora é puro: 7 vezes 6 é 42 hoje, amanhã e em Marte.
Tudo o que uma função faz além de calcular o resultado é um efeito colateral: escrever na tela, num arquivo, na rede ou num banco de dados; mudar uma variável global; ou mudar o argumento que recebeu. Ler algo que pode mudar também é problema: o relógio, o que o usuário digita, um número aleatório ou uma variável global mutável. Essas são entradas ocultas, porque não aparecem na lista de parâmetros. Os efeitos colaterais são saídas ocultas.
| Função | Pura? | Por quê |
|---|---|---|
(xs) => xs.length * 2 | Sim | Só lê o argumento e devolve um número novo |
(x) => x + Math.random() | Não | Entrada oculta: um número aleatório |
() => Date.now() | Não | Entrada oculta: o relógio |
(x) => { total += x; return total; } | Não | Muda uma variável de fora (saída oculta) e a lê (entrada oculta) |
(xs) => { xs.push(0); return xs; } | Não | Muda o próprio argumento |
(price) => price * taxRate, com taxRate um let global | Não | Entrada oculta: o global pode mudar |
Trocar uma chamada pelo seu valor
Uma expressão é referencialmente transparente quando você pode trocá-la pelo valor dela e o programa não muda. É como raciocinamos em álgebra: se y = 3 * 4, você pode escrever 12 onde vir y. Funções puras sempre permitem isso. Uma função que muda estado quebra a regra:
let calls = 0;
const next = (): number => {
calls += 1;
return calls;
};
const a = next() + next(); // 1 + 2 = 3
calls = 0;
const x = next(); // 1
const b = x + x; // 1 + 1 = 2, não 3A transparência referencial permite bons truques. A memoização guarda o resultado de uma chamada e o devolve quando os mesmos argumentos voltam. Isso só é correto para funções puras: se a função lesse o relógio, o cache devolveria uma hora velha; se escrevesse num arquivo, a segunda chamada pularia a escrita sem avisar.
Como tornar uma função pura
Transforme a entrada oculta em parâmetro. Uma função isExpired(coupon) que pergunta ao relógio que horas são vira isExpired(coupon, now). Agora o teste passa o instante que quiser e não precisa de relógio falso. O relógio de verdade é lido num só lugar, na borda do programa. A mesma correção vale para withTax(price): escreva withTax(price, taxRate).
- Testes sem mocks. Um mock é um substituto para algo que o teste não controla, como o relógio ou o banco de dados. Uma função pura não tem nada disso: monte valores, chame, compare o resultado.
- Determinística não é o mesmo que pura. Uma função
sum(xs)que também imprime o resultado sempre devolve o mesmo número, mas imprimir é um efeito, então ela não é pura e uma chamada dela não pode ser pulada por um cache. - Mutação local é aceitável. Se uma função cria um array novo por dentro, preenche com
pushnum laço e o devolve, ninguém de fora percebe. Ela continua pura, porque o que mudou nunca escapou antes do fim. - Testes baseados em propriedades. Geram centenas de entradas aleatórias e verificam uma regra como
decode(encode(x)) == x. Quando uma falha, rodam a função muitas vezes com entradas cada vez menores para achar a menor que falha. Isso só funciona se cada execução depender só da entrada, então funções puras são o alvo perfeito.
Imutabilidade: dados que nunca mudam
Um valor é imutável quando nunca muda depois de criado. As operações que parecem modificá-lo devolvem um valor novo e deixam o antigo como estava. Pense numa foto impressa: para pôr um chapéu na pessoa, você imprime uma foto nova. A do álbum continua igual, então ninguém que a esteja olhando leva susto.
Isso elimina uma família inteira de bugs, aqueles em que uma parte do programa muda dados pelas costas de outra parte que os estava usando. Também ajuda a concorrência (muitas tarefas rodando ao mesmo tempovários trabalhos avançando durante o mesmo período): o perigo clássico é o estado mutável compartilhado, duas tarefas mexendo nos mesmos dados enquanto pelo menos uma delas escreve. Se os dados não podem mudar, podem ser compartilhados à vontade. Esse é um dos grandes motivos pelos quais Elixir e Erlang escalam bem em muitos núcleos de processador.
Imutabilidade em TypeScript
| Ferramenta | O que protege | O que não protege |
|---|---|---|
const | O nome não pode apontar para outro valor | O interior de um objeto ou array: xs.push(3) ainda funciona |
Object.freeze | As propriedades do primeiro nível do objeto, em tempo de execução | Objetos e arrays aninhados, a não ser que você congele cada um (deep freeze) |
readonly number[] / Readonly<T> | Em tempo de compilação, o verificador de tipos recusa os métodos que mudam o array | Código que contorna os tipos. Não custa nada em tempo de execução |
Copiar com a sintaxe spread ({ ...a }) é rasa: cria um objeto externo novo, mas os campos que guardam objetos ou arrays continuam apontando para os mesmos valores internos. Para atualizar dados aninhados de forma imutável, copie cada nível do caminho:
const a = { name: 'Ana', tags: ['x'] };
const bad = { ...a, name: 'Bia' };
bad.tags.push('y'); // a.tags mudou também! mesmo array
const good = { ...a, tags: [...a.tags, 'y'] }; // array novo tambémImutabilidade em Elixir
Em Elixir todos os dados são imutáveis. List.delete(list, 2) devolve uma lista nova e não toca em list; para guardar o resultado você precisa ligá-lo a um nome. Um nome pode ser religado (x = 2 depois de x = 1), mas isso não é mutação: o valor antigo continua existindo para quem já o tinha. Uma função criada quando x valia 1 continua enxergando 1, porque uma função anônima captura valores, não nomes.
Por que não é lento: compartilhamento estrutural
Copiar uma lista grande a cada mudança seria péssimo. O truque é que o valor novo reaproveita as partes do antigo que não mudaram. Uma lista de Elixir é uma corrente de células, cada uma apontando para a próxima. Inserir no começo cria uma única célula nova que aponta para a lista antiga. Inserir no fim exige uma cópia nova de todas as células anteriores, porque cada uma precisa apontar para uma próxima célula nova.
nova: [0] --+
v
list: [1] -> [2] -> [3] -> []
1 célula nova. A lista antiga fica intacta e compartilhada.- Inserir no começo: [x | list]
- 1 células
- Inserir no fim: list ++ [x]
- 1,000 células
Funções de ordem superior: map, filter, reduce
Uma função de ordem superior é uma função que recebe outra função como argumento, devolve uma função, ou as duas coisas. Ela separa 'como percorrer os dados' de 'o que fazer com cada item'. A função de ordem superior sabe percorrer. A função que você entrega diz o que fazer.
| Função | O que faz | Tamanho do resultado | Exemplo |
|---|---|---|---|
map | Aplica uma função a cada item e junta os resultados na mesma ordem | Igual à entrada: n entram, n saem | [1, 2, 3].map(x => x * 2) dá [2, 4, 6] |
filter | Mantém só os itens para os quais um teste diz sim. Não os altera | Igual ou menor | [1, 2, 3, 4].filter(x => x % 2 === 0) dá [2, 4] |
reduce (também chamada fold) | Percorre os itens carregando um acumulador, um resultado parcial | Qualquer: um número, uma lista, um objeto | [1, 2, 3].reduce((acc, x) => acc + x, 0) dá 6 |
Numa cadeia como xs.filter(...).map(...), cada passo recebe a coleção produzida pelo passo anterior. [1, 2, 3, 4] passa pelo filter dos pares e vira [2, 4], depois pelo map vezes dez e vira [20, 40].
Como o reduce funciona
O reduce recebe um valor inicial para o acumulador e uma função. Para cada item, a função recebe o acumulador e o item, e o que ela devolve vira o próximo acumulador. Acompanhar os acumuladores no papel é a forma mais segura de calcular uma redução:
início acc = 10
item 1: 10 + 2 = 12
item 2: 12 + 4 = 16
item 3: 16 + 6 = 22 <- resultado- Sempre dê um valor inicial. Ele é a resposta para a coleção vazia: 0 para uma soma, 1 para um produto, uma lista vazia para juntar listas. Em JavaScript,
[].reduce((a, b) => a + b)sem valor inicial lança umTypeError. - O acumulador pode ser uma estrutura. Com um par como
{count, sum}você calcula várias coisas numa única passada pelos dados, sem listas temporárias. - O reduce é o geral.
mapefiltersão casos particulares em que o acumulador é uma lista. Continuamos a usá-los porque dizem ao leitor a intenção:mapsignifica que n entram e n saem. - A ordem dos argumentos muda entre linguagens. Em Elixir,
Enum.reduce(list, initial, fn x, acc -> ... end)dá o item primeiro e o acumulador depois. Montar uma lista com[x | acc]produz os itens invertidos, porque inserir no começo é a operação barata.
Uma armadilha clássica
O map chama sua função com três argumentos: o item, o índice e o array inteiro. parseInt(text, base) recebe a base como segundo argumento, então ['1', '2', '3'].map(parseInt) dá [1, NaN, NaN]. O jeito seguro é escrever a função por extenso: xs.map(s => parseInt(s, 10)), ou usar Number.
Reduzir em paralelo
Imagine dividir uma lista longa em pedaços, reduzir cada pedaço num trabalhador diferente e depois combinar os resultados parciais em ordem. A resposta é igual à de um trabalhador só apenas se a função de combinação for associativa: o agrupamento não importa. Soma, produto, máximo e junção de listas são associativos. A subtração não é: (10 - 3) - 2 = 5, mas 10 - (3 - 2) = 9. A função também precisa ser pura, ou a ordem de execução apareceria no resultado.
↑ Voltar ao sumárioClosures: funções com memória
Quando você cria uma função dentro de outra função, e a interna usa variáveis da externa, ela leva consigo o acesso a essas variáveis. Mesmo depois que a função externa terminou, as variáveis continuam vivas enquanto a função interna existir. Esse par, o código mais as variáveis que ele lembra, é uma closure.
function makeAdder(n: number): (x: number) => number {
return (x) => x + n; // lembra de n
}
const add5 = makeAdder(5);
console.log(add5(10)); // 15Quais variáveis uma função enxerga é decidido pelo escopo léxicoa regra de que um nome é visível onde foi escrito no código, ou seja, pelo lugar onde a função está escrita no código-fonte. Essa é a base da aplicação parcial e do currying, que veremos adiante.
Cada chamada tem a sua memória
Toda vez que a função externa roda, é criado um conjunto novo de variáveis. Duas closures nascidas de duas execuções diferentes têm estados separados. Só closures nascidas na mesma execução compartilham suas variáveis.
function counter(): () => number {
let c = 0;
return () => {
c += 1;
return c;
};
}
const a = counter();
const b = counter();
a(); a();
console.log(b()); // 1: b tem o seu próprio cEla lembra a variável, não uma cópia
Em JavaScript e TypeScript uma closure guarda uma referência à variável. Se a variável mudar depois, a função vê o valor novo. Então um let rate capturado e reatribuído é uma entrada oculta da função. Declará-lo com const elimina a surpresa.
A velha armadilha do laço vem daí. Com var, a variável tem escopo de função, então todas as closures criadas num laço compartilham um único i, que vale 3 depois do laço. Com let, a variável tem escopo de bloco, e o for cria uma ligação nova a cada volta, então cada closure recebe a sua.
const fs: Array<() => number> = [];
for (var i = 0; i < 3; i++) fs.push(() => i);
console.log(fs.map((f) => f())); // [3, 3, 3]
// com `let i`, imprimiria [0, 1, 2]Útil, com dois avisos
- Estado privado sem classes (o padrão módulo). Uma função declara uma variável local e devolve um objeto cujos métodos a usam. O que não é devolvido fica fora de alcance. Mas lembre que esse estado mutável privado torna os métodos impuros.
- Elixir captura valores. Uma função anônima pega os valores das variáveis de fora quando é criada, e religar um nome dentro dela não muda nada fora. Assim,
Enum.each(list, fn _ -> count = count + 1 end)deixacountem 0, e o compilador avisa que o novocountnunca é usado. Para acumular, useEnum.reduce, em que o acumulador é passado e devolvido. - Vazamentos de memória. As variáveis capturadas vivem enquanto a closure viver. Uma closure de vida longa, como um tratador de evento, um timer ou uma entrada de cache, que captura um array enorme mantém o array inteiro na memória. Capture só o que precisa (por exemplo, guarde o tamanho numa constante) ou remova o tratador.
Recursão e chamadas de cauda
Recursão significa que uma função resolve um problema chamando a si mesma sobre uma versão menor do mesmo problema. Imagine um conjunto de bonecas russas: para contar as bonecas, você abre a maior, conta 1 e depois conta as bonecas de dentro, que é o mesmo trabalho com um conjunto menor. Uma função recursiva correta sempre tem duas partes.
- Caso base: a versão tão pequena que você responde direto (a lista vazia tem 0 itens; a menor boneca não tem nada dentro).
- Passo recursivo: uma chamada sobre uma entrada menor, de modo que cada chamada chegue mais perto do caso base.
Sem caso base, ou sem chegar perto dele, a função nunca para. Por quê? Cada chamada precisa de um quadro (frame) na pilha de chamadas, uma pilha de bilhetes que guarda os parâmetros, as variáveis locais e o lugar para onde voltar. O quadro só é jogado fora quando a chamada retorna. Uma recursão sem fim empilha quadros até a pilha encher; em JavaScript você vê RangeError: Maximum call stack size exceeded.
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
# sum([4, 5, 6]) = 4 + (5 + (6 + 0))Para uma lista de n itens isso faz n + 1 chamadas: uma por item e uma para a lista vazia. As somas esperam na ida e são feitas na volta. Como ainda há trabalho a fazer depois que cada chamada retorna, essa função não tem recursão de cauda.
Chamadas de cauda
Uma chamada está em posição de cauda quando é a última coisa que a função faz: o que a chamada devolve é exatamente o que a função devolve, sem soma, junção ou outro trabalho pendente. Uma função cujas chamadas recursivas estão todas em posição de cauda tem recursão de cauda. Para chegar lá, use um acumulador: faça antes o trabalho pendente e leve o resultado parcial como argumento.
def len(list, acc \\ 0)
def len([], acc), do: acc
def len([_ | tail], acc), do: len(tail, acc + 1)
def fact(0, acc), do: acc
def fact(n, acc), do: fact(n - 1, acc * n)Com a otimização de chamada de cauda, o ambiente de execução troca o quadro atual pelo novo em vez de empilhar outro, então uma recursão de um milhão de passos usa uma quantidade constante de pilha, como um laço. A máquina virtual do Erlang, onde o Elixir roda, sempre faz isso. Entre os motores de JavaScript só o JavaScriptCore faz, e só em modo estrito, então em TypeScript portátil (o Node.js usa o V8) uma recursão de um milhão de níveis ainda estoura. Lá, escreva um laço ou use reduce.
| Aspecto | Recursão comum | Recursão de cauda |
|---|---|---|
| Trabalho depois da chamada | Sim (1 + len(tail)) | Nenhum |
| Pilha com otimização | Cresce com n | Constante, O(1) |
| Argumento auxiliar | Não precisa | Um acumulador |
Elixir não tem while nem for com um contador que muda, porque isso exige uma variável mutável. A repetição é uma função que chama a si mesma com os valores novos como argumentos. No código do dia a dia você raramente a escreve à mão: Enum.map, Enum.reduce e as compreensões for são construídos sobre ela.
Composição e currying
A composição junta duas funções numa nova: a saída da primeira vira a entrada da segunda. É como ligar canos: a abertura por onde a água sai de um precisa encaixar na abertura por onde ela entra no próximo. Em matemática, compose(f, g)(x) significa f(g(x)): a função escrita por último é aplicada primeiro.
Muita gente acha confusa essa leitura da direita para a esquerda, então as bibliotecas também oferecem o pipe, que aplica as funções na ordem em que você as escreve: pipe(f, g)(x) é g(f(x)), como uma linha de montagem. pipe(f, g, h)(x) é h(g(f(x))).
const pipe =
(...fns: Array<(n: number) => number>) =>
(x: number): number =>
fns.reduce((acc, f) => f(acc), x);
pipe((n) => n + 1, (n) => n * 2, (n) => n - 3)(4); // 7Isso mostra que funções são valores comuns: podem ficar num array e ser percorridas como qualquer outro dado. Usar reduceRight no lugar de reduce dá o compose.
Regras da composição
- Os tipos precisam encaixar. Para
f: A => Beg: B => C, a função composta tem tipoA => C. O tipo do meioBsome da assinatura final, e o verificador de tipos reclama se os canos não encaixam. - Associativa.
(f ∘ g) ∘ hé igual af ∘ (g ∘ h). Então você pode tirar os parênteses e puxar um trecho do meio de um pipeline para uma função com nome sem mudar o resultado. - Identidade. A função identidade
x => xnão faz nada, e compor com ela não muda nada. É o elemento neutro, como o 0 na soma. - Não é comutativa. A ordem importa. Com
inc = n + 1edbl = n * 2,compose(inc, dbl)(5)é 11 ecompose(dbl, inc)(5)é 12.
Currying e aplicação parcial
O currying (nome do lógico Haskell Curry) muda o formato de uma função: (a, b) => ... vira a => b => .... Uma função curried recebe um argumento de cada vez, e cada passo devolve uma função que lembra os argumentos anteriores por meio de uma closure.
const multiply = (a: number) => (b: number): number => a * b;
[1, 2, 3].map(multiply(3)); // [3, 6, 9]
const add = (a: number, b: number) => a + b;
const add10 = add.bind(null, 10); // aplicação parcialA aplicação parcial é dar só alguns dos argumentos e guardar uma função que espera o resto. As duas ideias são primas: o currying muda o formato da função, a aplicação parcial é o ato de fixar argumentos. Uma função curried facilita a aplicação parcial, mas também dá para fazê-la sem currying, com bind ou com uma pequena lambda como n => multiply(3, n).
O pipe do Elixir
O Elixir tem o operador embutido |>. Na compilação ele reescreve a |> f(b) como f(a, b): o lado esquerdo vira o primeiro argumento. A biblioteca padrão foi desenhada para isso: o dado principal é sempre o primeiro parâmetro. Um pipeline então se lê na ordem em que as coisas acontecem, sem chamadas aninhadas.
aninhado (lido de dentro para fora): c(b(a(x)))
pipe (lido de cima para baixo): x
|> a()
|> b()
|> c()Tipos de dados algébricos e casamento de padrões
Os tipos de dados algébricos (ADTs) são feitos com apenas duas operações. Um tipo soma significa 'ou': o valor é exatamente uma entre várias variantes, cada uma marcada por uma etiqueta. Um tipo produto significa 'e': o valor tem todos os seus campos ao mesmo tempo. Uma Shape que é um círculo ou um retângulo, nunca os dois, é uma soma. Um ponto com x e y é um produto.
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'rect'; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.r ** 2;
case 'rect': return s.w * s.h;
default: {
const unreachable: never = s;
return unreachable;
}
}
}Em TypeScript o tipo soma é a união discriminada: toda variante tem um campo em comum (aqui kind) com um literal diferente em cada uma. Quando você testa esse campo com if ou switch, o compilador restringe o tipo (narrowing) dentro de cada ramo, e assim s.r só é permitido no caso do círculo. Em Elixir a mesma ideia é uma tupla marcada com um átomo, como {:circle, r}.
Exaustividade: o compilador como ajudante
never é o tipo sem valores. Depois que todas as variantes foram tratadas, o que sobra no default é never, e a atribuição compila. Se alguém adiciona uma variante triangle, o que sobra deixa de ser never, e o compilador aponta cada switch que precisa ser atualizado. Essa é a verificação de exaustividadeo compilador confirma que toda variante tem um caso.
Por que 'algébrico'? Contando valores
Porque podemos contar os valores possíveis como na aritmética. Num produto as possibilidades se multiplicam; numa soma elas se somam.
- boolean
- 2 valores
- boolean | null (2 + 1)
- 3 valores
- [boolean, boolean] (2 x 2)
- 4 valores
- { a: boolean; b: 'x'|'y'|'z' } | null ((2 x 3) + 1)
- 7 valores
Comparar esse número com o número de estados reais do seu problema é um jeito objetivo de julgar um modelo. Uma requisição está carregando, ou terminou com sucesso e dados, ou falhou com uma mensagem: 3 estados. Se você a modela como um registro com loading, data e error, todos opcionais, permite muitas combinações sem sentido, como 'carregando e com erro'. Listar exatamente os estados válidos como um tipo soma torna os estados ilegais irrepresentáveis: o compilador recusa o inválido, e nenhum teste é necessário para ele.
Casamento de padrões em Elixir
- `=` é o operador de casamento, não de atribuição. O lado esquerdo é um padrão: os literais precisam ser iguais e as variáveis são ligadas às partes correspondentes.
{:ok, value} = {:error, :timeout}falha com umMatchError. Escrever{:ok, value} = ...é um jeito de afirmar que deu certo. - As cláusulas são testadas em ordem. Uma função com várias cláusulas faz casamento nos argumentos, e a primeira cláusula que serve roda. Ponha os casos específicos primeiro e o
_que pega tudo por último. O compilador avisa de uma cláusula que nunca pode ser alcançada, mas não reordena nada. - Listas são um tipo soma recursivo. Uma lista é
[]ou[head | tail], um primeiro item ligado ao resto. Depois de[head | tail] = [1],headé 1 etailé[]. - O operador pin `^`. Uma variável num padrão normalmente casa com qualquer coisa e é religada.
^xnão liga nada e exige um valor igual ao quexjá guarda.
Avaliação preguiçosa
Normalmente um programa é ansioso (eager): calcula uma expressão no instante em que a vê. Na avaliação preguiçosa (lazy), o programa guarda só a receita e a cozinha quando alguém pede o resultado. Isso evita trabalho inútil e permite descrever sequências sem fim, desde que você pegue só uma parte delas.
Preguiça nas três linguagens
- Elixir: o módulo
Enumé ansioso, cada função percorre a coleção inteira e devolve uma lista pronta. O móduloStreamé preguiçoso:Stream.map(list, f)devolve um stream, que só descreve o trabalho. O trabalho começa quando o stream é entregue a uma função deEnum, comoEnum.to_listouEnum.take. - JavaScript e TypeScript: um generator (
function*) é uma sequência preguiçosa. Chamá-lo não executa nada e devolve um iterador. Cadanext()executa o corpo até o próximoyield, entrega o valor e pausa. Por issowhile (true)não é problema: só se calcula o que é pedido. - Java: um pipeline de stream tem uma fonte, zero ou mais operações intermediárias (
filter,map,sorted) e uma operação terminal (collect,sum,forEach,reduce). As intermediárias são preguiçosas. Só a terminal inicia a travessia. Sem operação terminal, zero elementos são processados.
function* naturals(): Generator<number> {
let n = 1;
while (true) {
yield n;
n += 1;
}
}
const it = naturals();
it.next();
it.next();
console.log(it.next().value); // 3Vertical contra horizontal
Um pipeline ansioso trabalha na horizontal: termina o passo um para todos os elementos, depois o passo dois para todos. Um pipeline preguiçoso trabalha na vertical: puxa um elemento por todos os passos, depois o próximo. Quando o consumidor para cedo, o preguiçoso faz só o trabalho necessário e não cria listas intermediárias.
ansioso (todo o map, depois todo o filter):
m1 m2 m3 m4 f1 f2 f3 f4
preguiçoso (um elemento por vez, para no primeiro acerto):
m1 f1 | m2 f2 | m3 f3 -> achou, para
(o elemento 4 nunca é tocado)Do mesmo modo, 1..1_000_000 |> Stream.map(...) |> Enum.take(2) executa a função só nos dois primeiros números. O consumidor define a quantidade de trabalho: Enum.take(2) puxa um elemento por vez e interrompe a travessia depois do segundo.
Tipos Option e Result, functors e monads por intuição
Como uma função deve dizer 'não achei nada' ou 'algo deu errado'? Usar null ou lançar uma exceção esconde esse fato da assinatura. As linguagens funcionais transformam ausência e falha em valores comuns do tipo, e assim o compilador obriga você a tratá-las.
Option
Option (também chamado Maybe ou Optional) é um tipo soma com duas variantes: Some(value) e None. Uma função que devolve Option<User> avisa, na assinatura, que o usuário pode não existir. O TypeScript tem isso embutido: com strictNullChecks ligado, uma função que devolve string | undefined faz de n.length um erro de compilação até você checar n. Sem a opção, undefined cabe em qualquer tipo e o erro só aparece em tempo de execução.
Result
Result é a mesma ideia para erros: Ok(value) ou Error(reason). A falha faz parte do contrato. Em Elixir a convenção é {:ok, value} ou {:error, reason}, e quem chama decide com case ou with. As funções que terminam em !, como File.read!, lançam uma exceção, para quando a falha é inesperada.
Functors: contêineres com um bom map
map(f) num Option aplica f ao valor de dentro, se houver, e não faz nada se o Option estiver vazio. Sua função só conhece o caso feliz; o Option cuida do outro. Você pode pensar num Option como uma lista com zero ou um item. Arrays, Option, Result e promises têm um map assim, e os chamamos de functors: contêineres cujo map transforma o conteúdo e mantém o formato. 'Bem comportado' significa duas leis: aplicar map com a função identidade não muda nada, e aplicar map com f e depois com g é igual a aplicar map com a composição de f e g.
Monads: encadear passos que podem falhar
E se a função que você dá ao map também devolve um Option, como parse: string => Option<number>? Então map(parse) produz Option<Option<number>>, uma caixa dentro de outra. O flatMap (também chamado chain, bind ou andThen) aplica a função e achata o resultado. Os arrays se comportam igual: map com uma função que devolve arrays dá um array de arrays, e flatMap dá um array plano.
Por intuição, uma monad é um contêiner com flatMap. Com map o formato é fixo: só o conteúdo muda. Com flatMap a função devolve um contêiner novo, então um passo pode mudar o rumo dos seguintes: um Option pode ficar vazio, um Result pode virar erro, um array pode crescer ou encolher.
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const chain = <A, B>(r: Result<A>, f: (a: A) => Result<B>): Result<B> =>
r.ok ? f(r.value) : r;
const half = (n: number): Result<number> =>
n % 2 === 0 ? { ok: true, value: n / 2 } : { ok: false, error: 'odd ' + n };
// 12 -> 6 -> 3 -> half(3) falha
chain(chain(chain({ ok: true, value: 12 }, half), half), half);
// { ok: false, error: 'odd 3' }Em Elixir, o with faz esse trabalho. Enquanto cada padrão casa, a execução continua e as variáveis anteriores ficam disponíveis. No primeiro passo que não casa, o with devolve aquele valor e pula o resto. O then de uma promise faz os dois papéis: se sua função devolve um valor simples, age como map, e se devolve outra promise, age como flatMap, esperando por ela, então Promise.resolve(1).then(n => Promise.resolve(n + 1)) é uma Promise<number>, não uma promise de promise.
| Ferramenta | Função que recebe | Resultado |
|---|---|---|
map | A => B (uma função comum) | Mesmo contêiner, conteúdo novo |
flatMap | A => Container<B> (pode falhar ou mudar o formato) | Um contêiner plano |
Gerenciando efeitos colaterais
Um programa sem efeitos colaterais seria inútil: não leria um arquivo, não responderia a uma requisição web nem mostraria nada na tela. Por isso quem programa em estilo funcional não tenta eliminar os efeitos. Ele os empurra para a borda. A ideia central se chama núcleo funcional, casca imperativa (functional core, imperative shell).
+--------- casca imperativa (fina) ----------+
| relógio, aleatório, arquivos, rede, banco |
| +--------------------------+ |
| entra->| núcleo funcional |->sai |
| | puro: decide, calcula, | |
| | valida, formata | |
| +--------------------------+ |
+---------------------------------------------+O núcleo recebe dados simples e devolve dados simples. A casca toca o mundo: relógio, gerador aleatório, arquivos, rede, banco de dados e tela. Um teste prático para classificar uma operação: ela daria o mesmo resultado se repetida com os mesmos valores? Se não, ela pertence à casca.
O ganho: testes fáceis
Testes com efeitos são lentos, frágeis e exigem preparação: subir um banco, falsificar o relógio, limpar arquivos. Quando a lógica está em funções puras, cada teste é uma chamada com valores e uma comparação. Você ganha uma base larga de testes rápidos do núcleo e só poucos testes de integração para a casca, que é tão fina e sem decisões que poucos bastam.
Duas técnicas
Estado aleatório explícito. Um gerador de números pseudoaleatórios é uma função determinística do seu estado interno. A impureza vem de esconder esse estado numa variável global. Se você o torna explícito, next(state) devolve {value, newState} e é pura. Guardar a semente (o estado inicial) basta para reproduzir uma execução que falhou. Erlang e Elixir seguem esse modelo em :rand.uniform_s e funções parecidas.
Efeitos como dados. O núcleo não envia o e-mail. Ele devolve uma descrição do que fazer, e a casca executa:
type Effect = { type: 'sendEmail'; to: string };
function decide(state: State, cmd: Command): Effect[] {
return [{ type: 'sendEmail', to: 'ana@example.test' }];
}
// casca: for (const e of decide(s, c)) perform(e);A descrição pode ser comparada, registrada em log, filtrada ou reproduzida. É a ideia por trás de Elm, Redux e dos 'comandos' de sistemas orientados a eventos.
Onde mora o estado que muda em Elixir?
Os dados são imutáveis, mas um processo servidor, como um GenServer, guarda sim um estado que muda com o tempo. Um processo é um laço: espera uma mensagem, calcula a resposta e o próximo estado, e chama a si mesmo de novo com esse estado, por meio de uma chamada de cauda. O 'estado mutável' é só a sequência de argumentos dessas chamadas. O efeito fica confinado ao processo, o acesso é serializado pela caixa de mensagens dele, e os callbacks que calculam o próximo estado podem ser puros e testados diretamente.
Estilo funcional em Elixir e TypeScript
O Elixir foi feito para o estilo funcional, então os bons hábitos vêm de graça. O TypeScript deixa você escrever dos dois jeitos, então é preciso escolher bem. Aqui estão os detalhes que mais surpreendem.
Métodos que copiam e métodos que alteram no lugar em JavaScript
| Devolve um array novo (seguro) | Altera o array no lugar (cuidado) |
|---|---|
map, filter, slice, concat | push, pop, splice |
toSorted, toReversed (ES2023) | sort, reverse |
xs.sort(...) altera xs e também o devolve. Então const ys = xs.sort(...) dá um único array com dois nomes, e xs === ys é verdadeiro. Use xs.toSorted(...) ou ordene uma cópia com [...xs].sort(cmp). Tipar parâmetros como readonly T[] faz o compilador impedir você de mutar.
Outra armadilha: sort sem função de comparação compara a forma de texto dos itens. Então [10, 9, 1].sort() dá [1, 10, 9]. Um teste com exemplos de um dígito não notaria. Uma propriedade como 'cada elemento é menor ou igual ao seguinte', testada com entradas geradas, acha o problema rápido.
Detalhes do Elixir
- Pipe.
c(b(a(x)))se escrevex |> a() |> b() |> c(). O dado vai primeiro e cada linha é um passo. - Operador de captura `&`.
&(&1 * 2)cria uma função anônima em que&1é o primeiro argumento. Com nome e aridade,&String.upcase/1transforma uma função nomeada em valor. Os dois servem para passar funções aEnum.mape companhia:Enum.map([1, 2, 3], &(&1 * 2))dá[2, 4, 6]. - `Enum` sobre mapas devolve listas.
Enumfunciona com qualquer coisa que implemente o protocoloEnumerable(listas, mapas, ranges, streams), mas devolve listas.Enum.map(%{a: 1}, fn {k, v} -> {k, v * 2} end)dá[a: 2], uma lista de pares. Para receber um mapa de volta, useMap.new(map, fn {k, v} -> {k, v * 2} end). Com mais de uma chave, não conte com a ordem em que um mapa é percorrido. - O `map` do TypeScript existe só em arrays e sempre devolve um array.
| Ideia | Elixir | TypeScript |
|---|---|---|
| Encadear passos | x |> f() |> g() | pipe(f, g)(x) (escreva o seu) ou cadeias de métodos |
| Dados imutáveis | Sempre | readonly, toSorted, cópias com spread |
| Repetição | Recursão, Enum, for | map, reduce, laços |
| Valor ausente ou erro | {:ok, v} / {:error, e}, with | T | undefined, um tipo Result |
| Tipo soma | Tuplas marcadas com átomos | Uniões discriminadas |
| Sequência preguiçosa | Stream | Generators |
Juntando tudo
Todas as ideias se apoiam umas nas outras. Dados imutáveis tornam as funções puras. Funções puras podem ser memoizadas, testadas sem mocks e rodadas em paralelo. Como funções são valores, podemos passá-las (ordem superior), lembrar variáveis (closures), fixar argumentos (currying) e ligá-las (composição). Sem contadores mutáveis, a repetição é recursão. Tipos soma tornam os estados ilegais irrepresentáveis, e Option e Result tornam a falha visível. A preguiça economiza trabalho, e o núcleo funcional mantém na casca os efeitos inevitáveis.
dados imutáveis --> funções puras --> testes fáceis, paralelo, memoizar
| |
v v
recursão, reduce composição, currying, map/filter
| |
+----> ADTs, Option/Result <---+
|
v
núcleo funcional + casca imperativa| Conceito | Regra em uma linha | Armadilha típica |
|---|---|---|
| Função pura | Mesmos argumentos, mesmo resultado, sem efeitos | Entradas ocultas: o relógio, números aleatórios, globais |
| Imutabilidade | Crie um valor novo, mantenha o antigo | const e o spread raso não protegem dados aninhados |
| reduce | Valor inicial + função de passo; o passo precisa ser associativo para dividir o trabalho | Esquecer o valor inicial numa lista vazia |
| Closure | Função + as variáveis onde nasceu | Laços com var; vazamentos por objetos grandes capturados |
| Chamada de cauda | Última ação da função, então nada fica pendente | Supor que o Node.js elimina chamadas de cauda |
| Composição | A saída de uma é a entrada da próxima; associativa, não comutativa | Confundir a ordem de compose e pipe |
| Soma / produto | Soma = ou, os valores se somam; produto = e, os valores se multiplicam | Campos opcionais que permitem estados impossíveis |
| Preguiça | Calcule só o que o consumidor puxa | Esquecer a operação terminal |
| Result / flatMap | Encadeie passos; o primeiro erro pula o resto | Usar map onde se precisa de flatMap |
| Núcleo e casca | Decida em código puro, aja na borda | Pôr o relógio no fundo da lógica |
Erros comuns
- Achar que
consttorna um objeto imutável. Ele só impede que o nome seja reatribuído. - Achar que uma função determinística é automaticamente pura. Imprimir é um efeito.
- Usar
Enum.eachcom uma variável para acumular em Elixir. UseEnum.reduce. - Usar
list ++ [x]num laço. Ele copia a lista inteira a cada vez. - Esperar que uma função com recursão de cauda seja segura no Node.js para um milhão de passos.
- Esquecer que
xs.sort()alteraxs.