Trabalhando com Coleções: De Lazy Sequences à Recursão Otimizada¶
Bem-vindo a mais um encontro em nossa jornada com Clojure. Hoje, vamos nos aprofundar em três pilares essenciais para a manipulação de dados na linguagem. Primeiro, exploraremos como agrupar e resumir dados utilizando poderosas funções de agregação, transformando coleções brutas em insights valiosos. Em seguida, aprenderemos a ordenar e selecionar esses dados, uma etapa crucial para apresentar informações de forma clara e significativa. Por fim, desvendaremos o conceito de lazy sequences (sequências preguiçosas), um dos segredos por trás da eficiência e do excelente gerenciamento de memória de Clojure. Dominar essas três áreas permitirá que você construa pipelines de dados expressivos, legíveis e de alto desempenho.
1. Agrupamento e Agregação de Dados¶
1.1. A Importância Estratégica da Agregação¶
Agrupar dados é um passo fundamental em qualquer processo de análise de informações. Raramente os dados brutos, em sua forma original, nos contam a história completa. A agregação nos permite transformar grandes coleções de registros individuais em resumos concisos e significativos. Ao agrupar dados por atributos comuns — como categorias de produtos, departamentos ou datas — podemos calcular métricas como totais, médias e contagens, revelando padrões e insights que, de outra forma, permaneceriam ocultos na massa de informações.
Imagine que você recebeu dados de várias frases, e já fez uma análise inicial que retornou, para cada palavra, o número de vezes que ela aparece em uma parte do texto.
Esses dados vieram em partes, como pequenas listas de contagens:
Objetivo:
Agrupar todas as ocorrências da mesma palavra, para depois poder somar as contagens.
Passo 1 – Juntar todas as sublistas em uma única sequência
Agora dados-unificados é:
Passo 2 – Agrupar os pares pela palavra (a primeira posição de cada vetor)
Resultado:
{"era" [["era" 1] ["era" 1]],
"uma" [["uma" 1]],
"vez" [["vez" 1]],
"um" [["um" 1] ["um" 1]],
"rei" [["rei" 1]]}
Explicação:
- Cada item da lista é um par
["palavra" 1]. - A função
group-byagrupa esses pares, usando a palavra como chave (ou seja, ofirstde cada vetor). - O resultado é um mapa onde cada chave é uma palavra, e o valor é uma lista de todas as vezes que essa palavra apareceu nos dados originais.
O Que Fazer Depois?
Você pode somar as contagens:
(def agrupado (group-by first dados-unificados))
(def contagens
(into {}
(for [[palavra ocorrencias] agrupado]
[palavra (reduce + (map second ocorrencias))])))
Resultado:
1.2. A Função¶
A principal ferramenta de Clojure para agrupamento é a função group-by. Sua operação é elegantemente simples: ela recebe uma função (a função de agrupamento) e uma coleção. Em seguida, retorna um mapa onde as chaves são o resultado da aplicação da função a cada item da coleção, e os valores são listas contendo todos os itens que geraram aquela chave específica.
Vamos ver um exemplo simples, inspirado no paradigma map/reduce, para entender seu funcionamento. Imagine que temos uma lista de pares, onde cada par contém uma palavra e uma contagem.
1.3. Caso de Uso: Análise de Carrinhos de Compras¶
Vamos aplicar esses conceitos a um cenário mais robusto: analisar o faturamento de uma loja online a partir de uma coleção de carrinhos de compra. Cada carrinho contém múltiplos itens, e cada item tem informações sobre seu departamento e preço.
Estrutura de Dados de Exemplo:
(def carts
[{:settled? true
:line-items [{:dept :toys :total 10}
{:dept :clothing :total 25}]}
{:settled? false ; Carrinho abandonado, deve ser ignorado
:line-items [{:dept :kitchen :total 50}]}
{:settled? true
:line-items [{:dept :toys :total 15}]}])
Nosso objetivo é calcular o faturamento total por departamento. Para isso, construiremos um pipeline de processamento passo a passo.
- Filtragem com
filter: Primeiro, selecionamos apenas os carrinhos que foram finalizados (:settled? true). - Extração com
mapcat: Em seguida, extraímos todos os:line-itemsdos carrinhos finalizados e os colocamos em uma única sequência.mapcaté perfeito para isso, pois aplica uma função (:line-items) e concatena os resultados. - Transformação com
map: Antes de agrupar, é uma boa prática simplificar os dados. Usaremosmapcom uma função auxiliar,line-summary, para transformar cada item de linha em um resumo contendo apenas os dados de que precisamos: o departamento (:dept) e o total (:total). Isso descarta informações irrelevantes e torna o próximo passo mais eficiente. - Agrupamento com
group-by: Agora que temos uma sequência limpa e simplificada de itens, aplicamosgroup-bypara agrupá-los por departamento (:dept).
Vamos combinar esses passos em um pipeline legível usando o macro ->>:
O resultado neste ponto é um mapa onde cada chave é um departamento e o valor é uma lista de todos os resumos de itens daquele departamento:
{:toys [{:dept :toys, :total 10} {:dept :toys, :total 15}],
:clothing [{:dept :clothing, :total 25}]}
- Agregação com
map-valsereduce: Por fim, precisamos somar os totais de cada departamento. Uma abordagem idiomática é usarmedley.core/map-vals(uma função da biblioteca Medley que aplica uma função a cada valor de um mapa) combinada comreducepara somar os totais. - Vamos aplicar isso ao nosso mapa agrupado:
O mapa final nos dá um resumo valioso dos dados brutos, mostrando o faturamento total para cada departamento.
Juntando tudo, a função completa seria:
(defn revenue-by-department [carts]
(->> carts
(filter :settled?)
(mapcat :line-items)
(map line-summary)
(group-by :dept)
(map-vals
(fn [summaries] (reduce + (map :total summaries))))))
Com os dados devidamente agregados, o próximo passo lógico é organizá-los para facilitar a visualização e a tomada de decisões.
2. Ordenação e Manipulação de Coleções¶
2.1. A Necessidade de Ordenar e Selecionar Dados¶
Após a agregação, os dados estão resumidos, mas não necessariamente organizados de uma maneira útil. A ordenação é o passo que nos permite responder a perguntas como "Quais são os departamentos mais lucrativos?" ou "Quais os 10 produtos menos vendidos?". Selecionar subconjuntos de dados, como os "Top 5", é igualmente importante para focar a atenção nos resultados mais relevantes. Clojure oferece um rico conjunto de funções para realizar essas tarefas de forma concisa e eficiente.
2.2. Ordenação com e Inversão com¶
A função sort-by é a ferramenta ideal para ordenar coleções com base em um critério específico. Ela recebe uma função-chave e uma coleção, e ordena a coleção em ordem crescente com base no valor retornado pela função-chave para cada elemento.
Consideremos uma coleção de livros, onde cada livro é um mapa com título e avaliação (:rating):
(def books
[{:title "Jaws", :rating 4.5}
{:title "Emma", :rating 4.8}
{:title "2001", :rating 4.2}])
;; Ordena os livros pela avaliação, do menor para o maior
(sort-by :rating books)
O resultado será uma sequência de livros ordenada de 4.2 a 4.8.
Para obter uma ordem decrescente (do maior para o menor), simplesmente combinamos sort-by com a função reverse, que inverte a ordem de uma sequência.
2.3. Acesso Posicional e Limitação de Resultados¶
Clojure fornece funções simples para acessar elementos específicos de uma sequência ou limitar o número de resultados.
first: Retorna o primeiro item de uma coleção.nth: Retorna o item em um índice específico (base zero).take: Retorna os primeirosnitens de uma coleção. É importante notar quetakeretorna uma sequência preguiçosa (lazy sequence), o que a torna extremamente eficiente.
Podemos combinar take com nosso exemplo de ordenação para obter os "Top 3" livros mais bem avaliados:
2.4. Combinando Operações com o Macro¶
Pipelines de dados como o que acabamos de construir podem se tornar difíceis de ler devido ao aninhamento de funções: (take 3 (reverse (sort-by :rating books))). Para resolver isso, Clojure oferece o macro de encadeamento ->> (conhecido como thread-last). Ele reescreve o código aninhado em uma sequência linear e legível, de cima para baixo. O macro insere o resultado de cada linha como o último argumento da função na linha seguinte.
Vamos refatorar nosso exemplo completo para obter os títulos dos três livros mais bem avaliados:
(->> books
(sort-by :rating) ; (sort-by :rating books)
reverse ; (reverse <resultado-anterior>)
(take 3) ; (take 3 <resultado-anterior>)
(map :title)) ; (map :title <resultado-anterior>)
Este código é funcionalmente idêntico ao aninhado, mas sua legibilidade é muito superior. Ele descreve claramente a sequência de transformações aplicadas aos dados.
Mas como Clojure consegue executar esses pipelines de forma tão eficiente, especialmente com coleções potencialmente enormes? A resposta está em um dos conceitos mais poderosos da linguagem: as Lazy Sequences.
3. A Eficiência das Lazy Sequences (Sequências Preguiçosas)¶
3.1. O que são Lazy Sequences?¶
A maioria das linguagens de programação opera de forma gulosa (eager evaluation). Quando você aplica uma série de transformações a uma coleção, cada etapa é executada completamente, gerando uma coleção intermediária em memória, antes de passar para a próxima. Se você mapear, filtrar e depois pegar os 10 primeiros itens de uma lista de um milhão de elementos, uma abordagem gulosa criaria duas listas intermediárias de um milhão de elementos cada.
Clojure, por outro lado, favorece a avaliação preguiçosa (lazy evaluation). Muitas de suas funções de sequência, como map e filter, não calculam os elementos imediatamente. Em vez disso, elas retornam um objeto de sequência "preguiçoso" que sabe como calcular os elementos sob demanda. Um elemento da sequência só é efetivamente calculado quando alguma outra parte do programa realmente precisa dele. Isso resulta em enormes benefícios de desempenho e gerenciamento de memória, pois evita a criação de coleções intermediárias desnecessárias. É importante notar que, enquanto funções de transformação como map e filter são preguiçosas, funções que precisam consumir a sequência inteira para produzir um único valor, como reduce, são inerentemente gulosas (eager).
3.2. Lidando com o Infinito¶
Uma das consequências mais poderosas da avaliação preguiçosa é a capacidade de representar e manipular coleções infinitas. Como os elementos são calculados apenas quando necessários, podemos definir uma sequência que, teoricamente, nunca termina.
Funções como range sem argumentos ou repeat criam sequências infinitas:
Tentar realizar uma operação gulosa em uma sequência infinita resultaria em um loop infinito e esgotamento de memória. No entanto, em Clojure, é perfeitamente seguro combinar uma sequência infinita com uma função como take:
Isso funciona porque take só precisa dos primeiros 5 itens. Ele solicita um item de cada vez da sequência range, e assim que obtém o quinto item, ele para. O restante da sequência infinita nunca é calculado.
3.3. O Benefício Prático da Preguiça¶
Vamos ilustrar o benefício de desempenho e eficiência com um cenário prático: identificar o primeiro número primo em uma sequência muito grande de inteiros.
;; Função auxiliar para testar se um número é primo
(defn primo? [n]
(and (> n 1)
(not-any? #(zero? (mod n %))
(range 2 (inc (Math/sqrt n))))))
;; Função que simula uma transformação de dados cara (por exemplo, uma consulta)
(defn detalhes-numero [n]
(Thread/sleep 1) ; simula operação lenta, como uma consulta de BD
{:valor n :par? (even? n)})
;; Pipeline preguiçoso em Clojure
(first
(filter (comp primo? :valor)
(map detalhes-numero (range 1e6))))
O que acontece, passo a passo:¶
firstpede um único item do pipeline.filtersolicita um elemento demap.mappega o primeiro número de(range 1e6)(ou seja,0), aplicadetalhes-numeroe entrega o resultado afilter.filtertesta o campo:valorcomprimo?.- Se não for primo, descarta e pede o próximo.
- Se for primo, entrega o mapa para
first.
first, tendo recebido o que precisava, encerra o pipeline imediatamente.
Por que isso é eficiente?¶
Em Clojure, tanto map quanto filter são lazy sequences — ou seja, eles só processam os dados quando necessário.
O first pede elementos um a um, e a computação para assim que encontra o primeiro primo, sem precisar:
- Mapear toda a coleção de 1 milhão de itens;
- Nem filtrar a lista inteira.
Em uma linguagem ou abordagem gulosa (eager), o programa:
- Executaria
mappara todos os elementos, - Criaria uma coleção completa de
detalhes, - Depois aplicaria
filterem tudo, - E só então pegaria o primeiro resultado — desperdiçando memória e tempo.
Resultado¶
Ao executar o código, o retorno será algo como:
E você notará que a execução é praticamente instantânea, porque o pipeline parou logo após encontrar o primeiro número primo (2) — os demais milhões de elementos nem foram processados.
Moral da história¶
Esse exemplo mostra de forma testável e mensurável o poder da avaliação preguiçosa (lazy evaluation) em Clojure:
- As funções
map,filter,take,drop,firste outras trabalham sob demanda. - O pipeline processa somente o necessário, item por item.
- Isso evita uso excessivo de memória e cálculo desnecessário, mesmo com coleções potencialmente infinitas.
3.4. Contraste: Funções Lazy vs. Eager¶
Embora a preguiça seja o padrão para a maioria das transformações de dados em Clojure, existem situações, especialmente aquelas que envolvem efeitos colaterais (como imprimir na tela ou escrever em um arquivo), onde a avaliação completa e imediata é necessária.
| Lazy (Preguiçoso) | Eager (Guloso) |
|---|---|
Funções: map, filter, range, take, repeat, cycle |
Funções: doseq, doall, dorun, reduce |
| Comportamento: Retornam imediatamente uma representação da sequência. O cálculo de cada elemento é adiado até que seja realmente solicitado. Ideal para pipelines de transformação de dados. | Comportamento: Forçam a avaliação completa da sequência que recebem. São usadas intencionalmente quando o objetivo principal são os efeitos colaterais de cada passo, e não o valor de retorno final. |
Em resumo, a preguiça é o comportamento padrão e desejável para transformações de dados, garantindo eficiência. A avidez (eager) é uma ferramenta usada intencionalmente para controlar a execução de efeitos colaterais.
5. Recursão e Otimização de Cauda em Clojure¶
Na programação funcional, a recursão é a abordagem natural para lidar com repetição. Essa preferência está ligada ao pilar da imutabilidade: como as estruturas de dados não podem ser alteradas, laços imperativos como for e while, que dependem da mutação de variáveis, tornam-se pouco adequados.
A recursão, por outro lado, é perfeitamente compatível com dados imutáveis, pois opera gerando novos valores a cada chamada, até atingir um caso base — a condição que interrompe a repetição.
5.1 O Problema Comum: O Custo da Pilha¶
Funções recursivas simples podem rapidamente consumir toda a pilha de chamadas (call stack). Cada nova chamada empilha um novo frame de execução. Quando o número de chamadas é grande, ocorre o erro clássico StackOverflowError.
Exemplo:
;; ATENÇÃO: este código causará erro com números grandes.
(defn count-down [n]
(if-not (zero? n)
(do
(if (= 0 (rem n 100))
(println "count-down:" n))
(count-down (dec n)))))
;; (count-down 100000) ;; -> java.lang.StackOverflowError
Cada chamada empilha uma nova execução de count-down. Como a pilha é limitada, a recursão profunda esgota a memória disponível.
5.2 A Solução de Clojure: recur e a Otimização de Chamada de Cauda¶
Clojure resolve esse problema com a forma especial recur, que implementa a otimização de chamada de cauda (Tail Call Optimization, TCO).
Mas o recur não é uma chamada de função — ele é uma instrução de salto controlado: diz ao compilador para reiniciar a execução da função (ou de um loop) com novos valores para os parâmetros, sem empilhar uma nova chamada.
Para que o recur funcione:
- Ele deve estar em posição de cauda, ou seja, ser a última expressão da função;
- Deve receber exatamente o mesmo número de argumentos que a função (ou o
loop) original.
5.3 Transformando uma Função Recursiva em recur (passo a passo)¶
Vamos entender como transformar uma função recursiva comum em uma versão com recur.
Passo 1 — Função recursiva tradicional¶
Essa versão funciona, mas cria uma nova chamada a cada passo. (soma 100000) vai causar StackOverflowError.
Passo 2 — Adicionar um acumulador¶
Introduzimos um argumento extra (acc) para guardar o resultado parcial:
Agora acumulamos o resultado, mas ainda empilhamos chamadas.
Passo 3 — Colocar a recursão em posição de cauda¶
A chamada recursiva precisa ser a última operação da função:
Passo 4 — Substituir a chamada por recur¶
Por fim, trocamos a chamada explícita pela forma especial:
Agora a função não empilha novas chamadas — o recur apenas reinicia o corpo da função com os novos valores.
Chamando (soma 100000 0) → executa perfeitamente, sem erro e sem consumir pilha adicional.
5.4 Como o recur “sabe” o que repetir¶
O recur não chama a função pelo nome.
Ele é uma forma especial do compilador que sabe para qual ponto voltar:
- Dentro de uma
defn, ele volta para o início da própria função; - Dentro de um
loop, ele volta para o início do loop mais próximo.
Isso é resolvido em tempo de compilação — o compilador associa cada recur ao bloco que ele controla, garantindo segurança e eficiência.
5.5 Iterações Locais com loop/recur¶
Além de recur dentro de funções, Clojure oferece a combinação loop/recur para criar laços locais e eficientes, sem precisar definir uma função nomeada.
O loop define as variáveis locais (result, x), e o recur reinicia o laço com novos valores.
É uma forma funcional, segura e performática de representar iteração — sem mutabilidade e sem consumo de pilha.
5.6 Conclusão¶
O recur permite que Clojure use recursão como forma natural de iteração, conciliando elegância funcional e eficiência prática.
Transformar uma função recursiva comum em uma versão com recur segue um processo sistemático:
- Introduzir um acumulador se necessário;
- Garantir que a recursão esteja em posição de cauda;
- Substituir a chamada recursiva por
recur.
Assim, obtemos o mesmo comportamento de um loop imperativo, mas preservando os princípios da imutabilidade e da programação funcional pura.
Conclusão¶
Neste encontro, exploramos um conjunto de ferramentas que formam a espinha dorsal da manipulação de dados em Clojure. Vimos como a agregação com group-by nos permite resumir dados complexos. Aprendemos a usar o rico conjunto de funções de sequência, como sort-by, take e reverse, para organizar e focar em informações relevantes. E, crucialmente, entendemos como a avaliação preguiçosa torna esses pipelines de dados não apenas possíveis, mas extremamente eficientes, mesmo com coleções massivas ou infinitas. O domínio desses conceitos — agregação, manipulação de sequências e preguiça — é o que permite a construção de código idiomático, expressivo, legível e de alto desempenho em Clojure.