Pular para o conteúdo
Quiz
← Início

Programação funcional

100 questões

Certas
0
Erradas
0
Não respondidas
100
Dificuldade
Número de questões

100 questões neste filtro

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 é 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.

Palavras que vamos usar o tempo todo
PalavraO que significaExemplo
ArgumentoUm valor que você dá a uma funçãoEm double(4), o 4
Valor de retornoO que a função devolvedouble(4) retorna 8
Efeito colateralQualquer coisa que a função faz no mundo de fora além de devolver um valorImprimir, escrever um arquivo, mudar uma variável global
ExpressãoUm pedaço de código que produz um valor2 + 3 ou double(4)
ImutávelNão pode mudar depois de criadoUma foto impressa: para mudar, você imprime outra
Preguiçoso / ansiosoPreguiçoso (lazy) espera até o resultado ser necessário. Ansioso (eager) calcula na horaUma 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.
Os dados fluem por pequenas máquinas
↑ Voltar ao sumário

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.

Pura ou não?
FunçãoPura?Por quê
(xs) => xs.length * 2SimSó lê o argumento e devolve um número novo
(x) => x + Math.random()NãoEntrada oculta: um número aleatório
() => Date.now()NãoEntrada oculta: o relógio
(x) => { total += x; return total; }NãoMuda uma variável de fora (saída oculta) e a lê (entrada oculta)
(xs) => { xs.push(0); return xs; }NãoMuda o próprio argumento
(price) => price * taxRate, com taxRate um let globalNãoEntrada 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 3
O teste da substituição: chamar duas vezes não é o mesmo que dar nome ao resultado uma vez

A 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 push num 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.
↑ Voltar ao sumário

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 (): 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

FerramentaO que protegeO que não protege
constO nome não pode apontar para outro valorO interior de um objeto ou array: xs.push(3) ainda funciona
Object.freezeAs propriedades do primeiro nível do objeto, em tempo de execuçãoObjetos 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 arrayCó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ém
Cópia rasa contra cópia de cada nível

Imutabilidade 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 compartilha a lista antiga: [0 | list]
Células novas para acrescentar um item a uma lista de 1.000 itens
Inserir no começo: [x | list]
1 células
Inserir no fim: list ++ [x]
1,000 células
↑ Voltar ao sumário

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.

Os três grandes
FunçãoO que fazTamanho do resultadoExemplo
mapAplica uma função a cada item e junta os resultados na mesma ordemIgual à entrada: n entram, n saem[1, 2, 3].map(x => x * 2) dá [2, 4, 6]
filterMantém só os itens para os quais um teste diz sim. Não os alteraIgual 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 parcialQualquer: 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
[1, 2, 3].reduce((acc, n) => acc + n * 2, 10)
  • 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 um TypeError.
  • 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. map e filter são casos particulares em que o acumulador é uma lista. Continuamos a usá-los porque dizem ao leitor a intenção: map significa 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ário

Closures: 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)); // 15
Uma fábrica de funções: cada chamada cria uma função nova com seu próprio n

Quais variáveis uma função enxerga é decidido pelo , 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 c
Dois contadores, duas memórias independentes

Ela 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]
Uma variável compartilhada contra uma variável por volta

Ú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) deixa count em 0, e o compilador avisa que o novo count nunca é usado. Para acumular, use Enum.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.
↑ Voltar ao sumário

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.

  1. 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).
  2. 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))
Duas cláusulas: o caso base e o passo

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)
Versões com recursão de cauda. Chame fact(5, 1)

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.

AspectoRecursão comumRecursão de cauda
Trabalho depois da chamadaSim (1 + len(tail))Nenhum
Pilha com otimizaçãoCresce com nConstante, O(1)
Argumento auxiliarNão precisaUm 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.

↑ Voltar ao sumário

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); // 7
pipe é um reduce sobre uma lista de funções: (4+1)=5, 5*2=10, 10-3=7

Isso 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 => B e g: B => C, a função composta tem tipo A => C. O tipo do meio B some da assinatura final, e o verificador de tipos reclama se os canos não encaixam.
  • Associativa. (f ∘ g) ∘ h é igual a f ∘ (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 => x nã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 + 1 e dbl = n * 2, compose(inc, dbl)(5) é 11 e compose(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 parcial
Dar só o primeiro argumento cria uma função pronta para usar

A 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()
↑ Voltar ao sumário

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;
		}
	}
}
Uma união discriminada: o campo kind é o discriminante

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 .

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.

Quantos valores distintos cada tipo tem?
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 um MatchError. 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 e tail é [].
  • O operador pin `^`. Uma variável num padrão normalmente casa com qualquer coisa e é religada. ^x não liga nada e exige um valor igual ao que x já guarda.
↑ Voltar ao sumário

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ódulo Stream é 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 de Enum, como Enum.to_list ou Enum.take.
  • JavaScript e TypeScript: um generator (function*) é uma sequência preguiçosa. Chamá-lo não executa nada e devolve um iterador. Cada next() executa o corpo até o próximo yield, entrega o valor e pausa. Por isso while (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); // 3
Uma sequência infinita que não custa nada até você pedir

Vertical 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)
[1,2,3,4] |> Stream.map(imprime m) |> Stream.filter(imprime f; x > 2) |> Enum.take(1)

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.

↑ Voltar ao sumário

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' }
chain é o flatMap do Result: o primeiro erro viaja até o fim

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.

FerramentaFunção que recebeResultado
mapA => B (uma função comum)Mesmo contêiner, conteúdo novo
flatMapA => Container<B> (pode falhar ou mudar o formato)Um contêiner plano
↑ Voltar ao sumário

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        |        |
  |        +--------------------------+        |
  +---------------------------------------------+
Efeitos na borda, decisões no meio

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 decisão é um dado simples, então os testes podem compará-la, registrá-la ou reproduzi-la

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.

↑ Voltar ao sumário

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

Saiba a que grupo pertence cada método de array
Devolve um array novo (seguro)Altera o array no lugar (cuidado)
map, filter, slice, concatpush, 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 escreve x |> 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/1 transforma uma função nomeada em valor. Os dois servem para passar funções a Enum.map e companhia: Enum.map([1, 2, 3], &(&1 * 2)) dá [2, 4, 6].
  • `Enum` sobre mapas devolve listas. Enum funciona com qualquer coisa que implemente o protocolo Enumerable (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, use Map.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.
IdeiaElixirTypeScript
Encadear passosx |> f() |> g()pipe(f, g)(x) (escreva o seu) ou cadeias de métodos
Dados imutáveisSemprereadonly, toSorted, cópias com spread
RepetiçãoRecursão, Enum, formap, reduce, laços
Valor ausente ou erro{:ok, v} / {:error, e}, withT | undefined, um tipo Result
Tipo somaTuplas marcadas com átomosUniões discriminadas
Sequência preguiçosaStreamGenerators
↑ Voltar ao sumário

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
Como as ideias se conectam
Referência rápida
ConceitoRegra em uma linhaArmadilha típica
Função puraMesmos argumentos, mesmo resultado, sem efeitosEntradas ocultas: o relógio, números aleatórios, globais
ImutabilidadeCrie um valor novo, mantenha o antigoconst e o spread raso não protegem dados aninhados
reduceValor inicial + função de passo; o passo precisa ser associativo para dividir o trabalhoEsquecer o valor inicial numa lista vazia
ClosureFunção + as variáveis onde nasceuLaços com var; vazamentos por objetos grandes capturados
Chamada de caudaÚltima ação da função, então nada fica pendenteSupor que o Node.js elimina chamadas de cauda
ComposiçãoA saída de uma é a entrada da próxima; associativa, não comutativaConfundir a ordem de compose e pipe
Soma / produtoSoma = ou, os valores se somam; produto = e, os valores se multiplicamCampos opcionais que permitem estados impossíveis
PreguiçaCalcule só o que o consumidor puxaEsquecer a operação terminal
Result / flatMapEncadeie passos; o primeiro erro pula o restoUsar map onde se precisa de flatMap
Núcleo e cascaDecida em código puro, aja na bordaPôr o relógio no fundo da lógica

Erros comuns

  • Achar que const torna 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.each com uma variável para acumular em Elixir. Use Enum.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() altera xs.
Assista ao vídeo: Learning Functional Programming with JavaScript, Anjana Vakil (JSUnconf)↑ Voltar ao sumário