Como Closures Funcionam por Dentro:
Quando a Stack Não Basta
No artigo “Como uma chamada de função funciona por dentro”, em que levamos chamadas da stack da VM ao llvm.call, nossas funções ainda eram estáticas: seus parâmetros morriam quando a chamada terminava. Agora vamos dar à Pulso a capacidade de criar funções que lembram do mundo onde nasceram — mesmo depois que esse mundo deixou de existir na stack. Isso exige quebrar uma suposição que mantivemos até aqui: tudo pode viver na stack.
n = 10frame encerrado{ n: 10 }vive com a closureVOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
No artigo “Como uma chamada de função funciona por dentro”, compilamos funções nomeadas para LLVM IR. A Pulso agora suporta:
- declaração de funções com parâmetros
- retorno de valores via
retorne - chamadas encadeadas como expressões
- duas passadas de compilação (declaração + implementação)
Mas há uma limitação sutil: as variáveis que uma função enxerga são apenas seus parâmetros e suas próprias variáveis locais. Ela não consegue "ver" variáveis declaradas fora dela. Em outras palavras, ainda não temos escopo léxico de verdade — ou melhor, temos, mas não conseguimos transportar esse escopo para fora da função.
Uma closure é uma função que carrega consigo uma referência ao ambiente onde foi criada. Sem closures, funções são apenas blocos de código. Com closures, funções se tornam valores que encapsulam tanto código quanto dados.
somador(x)n = 10Leitura técnica: em nosso LLVM IR, o valor da closure será representado por dois ponteiros: um para a função e outro para a estrutura de ambiente.
Considere este programa Pulso:
fn cria_somador(n) {
fn somador(x) {
retorne x + n;
}
retorne somador;
}
fn main() {
var s = cria_somador(10);
mostre(s(5)); // deveria imprimir 15
}
A função somador referencia n — um parâmetro de cria_somador. Quando cria_somador retorna, seu frame na stack é destruído. Onde n viveu? Na stack. E depois que o frame morre? A memória é reutilizada.
Mas somador foi retornada e atribuída a s. Se chamarmos s(5) mais tarde, ela ainda precisa acessar n. A stack não serve mais.
Stack durante cria_somador(10):
┌─────────────────────────────┐
│ Frame: cria_somador │
│ n = 10 │ ← somador precisa disso depois
│ somador = fn(x) { x + n } │
└─────────────────────────────┘
↓ retorna
Stack após retorno:
┌─────────────────────────────┐
│ (frame reutilizado) │ ← n foi sobrescrito!
└─────────────────────────────┘
Mas 's' ainda aponta para somador...
Se uma variável pode ser acessada depois que sua função original retornou, ela não pode viver apenas na stack. Precisamos de um lugar que sobreviva ao frame: o heap.
n = 10é liberado no retorno{ n: 10 }vive enquanto a closure for alcançávelPara quem está começando: a stack acompanha chamadas; o heap guarda objetos cujo tempo de vida não coincide com uma única chamada.
Uma closure é composta de duas partes:
- Código: o corpo da função, que o LLVM já sabe compilar.
- Ambiente: as variáveis do escopo externo que a função referencia — os upvalues.
Em linguagens de alto nível, isso é invisível. Embaixo do capô, a closure é um struct com dois campos:
// Conceitualmente, uma closure é:
struct Closure {
// ponteiro para a função real
func: fn(env: *mut u8, args: ...) -> f64,
// ponteiro para o ambiente capturado (heap)
env: *mut u8,
}
Quando chamamos uma closure, não chamamos a função diretamente. Chamamos um trampoline — uma pequena rotina que recebe os argumentos, recupera o ambiente do struct e então invoca a função real passando tanto os argumentos quanto o ambiente.
Para a Pulso, vamos simplificar: em vez de um trampoline separado, a closure será representada como um struct de dois ponteiros que o LLVM entende nativamente. Quando chamamos uma closure, passamos o ambiente como primeiro argumento implícito.
Uma closure é um par (função, ambiente). A função é código estático. O ambiente é memória dinâmica no heap. Separar esses dois conceitos é o que permite que a mesma função seja reutilizada com ambientes diferentes.
Na mini-vm do artigo “Construindo um compilador e uma máquina virtual do zero em Rust”, cada frame tinha acesso às variáveis do frame pai? Não. Cada frame era isolado. Para suportar closures, uma VM precisa de upvalues: referências a variáveis de frames superiores.
Em uma VM com closures (como Lua), o mecanismo funciona assim:
- Quando uma função interna referencia uma variável externa, a VM não copia o valor. Ela cria um ponteiro (upvalue) para a célula de memória onde a variável vive.
- Se a variável vive na stack e a função externa retorna, a VM move essa célula para o heap. Todos os upvalues que apontavam para a stack agora apontam para o heap.
- A closure carrega um array de upvalues. Quando é chamada, esses upvalues são resolvidos para os valores atuais.
Esse mecanismo é elegante, mas complexo. Para a Pulso compilada via LLVM, vamos usar uma abordagem mais direta: alocar as variáveis capturadas no heap desde o início.
VMs interpretadas usam upvalues com open/close (mover da stack para o heap sob demanda). Compiladores nativos podem ser mais diretos: se uma variável será capturada, alocamos no heap imediatamente e acessamos via ponteiro.
Em C, alocar no heap é malloc. Em Rust, é Box::new. Em LLVM IR, usamos a função malloc da libc (ou GC_malloc se tivermos um garbage collector — que ainda não temos).
A estratégia para a Pulso:
- Análise: durante a compilação, identificamos quais variáveis de uma função são referenciadas por funções aninhadas.
- Alocação no heap: em vez de
alloca(stack), usamosmallocpara criar um bloco de memória no heap. - Struct de captura: criamos um struct LLVM com um campo para cada variável capturada.
- Passagem implícita: toda closure recebe um ponteiro para seu ambiente como primeiro parâmetro (depois do ponteiro para a própria closure, se necessário).
Para simplificar, vamos implementar um modelo onde:
- Funções que não capturam nada continuam como antes (funções estáticas).
- Funções que capturam variáveis se tornam closures: recebem um ponteiro
envcomo primeiro argumento. - O ambiente é um
structalocado no heap commalloc. - Quando criamos uma closure, alocamos o ambiente, preenchemos os campos e retornamos um par (ponteiro para função, ponteiro para ambiente).
O heap é a stack que não morre. Quando movemos uma variável do frame para o heap, estamos dizendo ao sistema operacional: "essa memória não será liberada quando a função retornar". O preço é que agora precisamos gerenciá-la — ou aceitar vazamentos de memória por enquanto.
Vamos ver como o programa exemplo se parece em LLVM IR. A closure somador captura n. O ambiente é um struct com um campo n:
; Struct do ambiente: { double }
%Env_somador = type { double }
; A função somador recebe env como primeiro parâmetro
define double @somador(ptr %env, double %x) {
entry:
; Carrega n do ambiente
%n_ptr = getelementptr %Env_somador, ptr %env, i32 0, i32 0
%n = load double, ptr %n_ptr
; x + n
%result = fadd double %x, %n
ret double %result
}
; cria_somador aloca o ambiente no heap e retorna a closure
define { ptr, ptr } @cria_somador(double %n) {
entry:
; Aloca env no heap: malloc(sizeof(Env_somador))
%env = call ptr @malloc(i64 8) ; sizeof(double) = 8
; Armazena n no env
%n_ptr = getelementptr %Env_somador, ptr %env, i32 0, i32 0
store double %n, ptr %n_ptr
; Retorna a closure como par { func_ptr, env_ptr }
%closure = insertvalue { ptr, ptr } undef, ptr @somador, 0
%closure2 = insertvalue { ptr, ptr } %closure, ptr %env, 1
ret { ptr, ptr } %closure2
}
Note as mudanças fundamentais em relação às funções estáticas:
| Função Estática | Closure |
|---|---|
define double @f(double %x) | define double @f(ptr %env, double %x) |
| Parâmetros são suficientes | Recebe %env como primeiro argumento implícito |
| Sem alocação extra | Chamador aloca env no heap com malloc |
| Retorna o valor diretamente | Retorna { ptr, ptr } = (função, ambiente) |
Chamada: call @f(args) | Chamada: extrair função e env do struct, depois call |
Uma closure compilada é apenas uma função com um parâmetro extra. O compilador esconde esse parâmetro do programador, mas ele existe em LLVM IR. O "truque" está em alocar o ambiente no heap e passar o ponteiro adiante.
Agora vamos ao código Rust. A mudança principal no CodeGen é que precisamos rastrear, para cada função, quais variáveis do escopo externo ela referencia. Para simplificar, vamos assumir que toda função aninhada é uma closure (mesmo que não capture nada — o overhead é mínimo).
A AST estendida
pub enum Expr {
// ... Number, Variable, Binary, Call como antes ...
// NOVO: criação de closure (função anônima ou nomeada aninhada)
Lambda {
params: Vec<String>,
body: Vec<Stmt>,
// variáveis do escopo externo que esta função referencia
captures: Vec<String>,
},
}
pub enum Stmt {
// ... Var, Print, If, Function, Return como antes ...
}
Na prática, o parser já sabe quais variáveis uma função aninhada referencia. Vamos assumir que essa análise foi feita e o vetor captures vem preenchido.
Gerando o tipo do ambiente
// Para uma closure que captura ["n"], criamos:
// %Env = type { double }
fn build_env_type(&self, captures: &[String])
-> Result<(StructType<'ctx>, Vec<usize>), String>
{
let f64_type = self.context.f64_type();
let field_types: Vec<BasicTypeEnum> =
captures.iter().map(|_| f64_type.into()).collect();
let env_type = self.context.struct_type(&field_types, false);
// Mapeia nome da variável capturada para índice no struct
let mut indices = Vec::new();
for (i, name) in captures.iter().enumerate() {
self.capture_indices.insert(name.clone(), i);
}
Ok((env_type, indices))
}
Compilando uma closure
fn compile_lambda(&mut self,
params: &[String],
body: &[Stmt],
captures: &[String],
) -> Result<StructValue<'ctx>, String>
{
// 1. Cria o tipo do ambiente no heap
let (env_type, _) = self.build_env_type(captures)?;
let env_ptr_type = env_type.ptr_type(inkwell::AddressSpace::default());
// 2. Declara o tipo da closure: { ptr (função), ptr (env) }
let closure_type = self.context.struct_type(&[
env_ptr_type.into(), // ponteiro para função (simplificado: usamos ptr genérico)
env_ptr_type.into(), // ponteiro para ambiente
], false);
// 3. Cria a função interna com assinatura: (ptr env, double, double, ...) -> double
let mut param_types: Vec<BasicMetadataTypeEnum> = vec![env_ptr_type.into()];
for _ in params {
param_types.push(self.context.f64_type().into());
}
let fn_type = self.context.f64_type().fn_type(¶m_types, false);
let fn_val = self.module.add_function("lambda", fn_type, None);
// 4. Compila o corpo da função (similar ao artigo anterior,
// mas variáveis capturadas são lidas de %env em vez de alloca)
let entry = self.context.append_basic_block(fn_val, "entry");
self.builder.position_at_end(entry);
// Mapeia parâmetros (incluindo env)
let env_param = fn_val.get_nth_param(0).unwrap().into_pointer_value();
self.variables.clear();
self.env_ptr = Some(env_param);
self.env_type = Some(env_type);
for (i, param_name) in params.iter().enumerate() {
let p = fn_val.get_nth_param((i + 1) as u32).unwrap().into_float_value();
let alloca = self.builder.build_alloca(
self.context.f64_type(), param_name
).map_err(|e| e.to_string())?;
self.builder.build_store(alloca, p).map_err(|e| e.to_string())?;
self.variables.insert(param_name.clone(), alloca);
}
// Compila o corpo
for stmt in body {
self.compile_stmt(stmt)?;
}
// Garante retorno
if self.builder.get_insert_block().unwrap().get_terminator().is_none() {
self.builder.build_return(
Some(&self.context.f64_type().const_float(0.0))
).map_err(|e| e.to_string())?;
}
// 5. Aloca o ambiente no heap com malloc
let malloc = self.module.get_function("malloc").unwrap();
let size = env_type.size_of().unwrap();
let env_ptr = self.builder.build_call(
malloc, &[size.into()], "env_alloc"
).map_err(|e| e.to_string())?
.try_as_basic_value().left().unwrap()
.into_pointer_value();
// 6. Preenche o ambiente com os valores capturados
for (i, name) in captures.iter().enumerate() {
let src_ptr = self.variables.get(name)
.or_else(|| self.parent_vars.get(name))
.ok_or_else(|| format!("captura '{}' não encontrada", name))?;
let val = self.builder.build_load(
self.context.f64_type(), *src_ptr, name
).map_err(|e| e.to_string())?;
let field_ptr = self.builder.build_struct_gep(
env_type, env_ptr, i as u32, &format!("env.{}", name)
).map_err(|e| e.to_string())?;
self.builder.build_store(field_ptr, val).map_err(|e| e.to_string())?;
}
// 7. Monta e retorna a closure { fn_ptr, env_ptr }
let mut closure = closure_type.get_undef();
closure = self.builder.build_insert_value(
closure, fn_val.as_global_value().as_pointer_value(), 0, "closure.fn"
).unwrap().into_struct_value();
closure = self.builder.build_insert_value(
closure, env_ptr, 1, "closure.env"
).unwrap().into_struct_value();
Ok(closure)
}
Chamando uma closure
fn compile_call(&self,
closure_val: StructValue<'ctx>,
args: &[FloatValue<'ctx>],
) -> Result<FloatValue<'ctx>, String>
{
// Extrai fn_ptr e env_ptr da closure
let fn_ptr = self.builder.build_extract_value(
closure_val, 0, "fn_ptr"
).unwrap().into_pointer_value();
let env_ptr = self.builder.build_extract_value(
closure_val, 1, "env_ptr"
).unwrap().into_pointer_value();
// Monta argumentos: [env, arg1, arg2, ...]
let mut call_args: Vec<BasicMetadataValueEnum> = vec![env_ptr.into()];
for arg in args {
call_args.push(arg.into());
}
// Converte o ptr genérico para function pointer
let fn_type = self.context.f64_type().fn_type(
&[self.context.ptr_type(inkwell::AddressSpace::default()).into()],
true // vararg simplificado — na prática usamos assinatura exata
);
let result = self.builder.build_indirect_call(
fn_type, fn_ptr, &call_args, "call_closure"
).map_err(|e| e.to_string())?;
Ok(result.try_as_basic_value().left().unwrap().into_float_value())
}
O compilador faz três coisas novas: (1) cria um struct para o ambiente, (2) aloca esse struct no heap com malloc, e (3) passa o ponteiro do ambiente como primeiro argumento de toda chamada de closure. O resto é o mesmo compilador do artigo anterior.
Vamos ver o fluxo completo. A estrutura do projeto:
pulso-closures/
├── Cargo.toml
└── src/
├── main.rs
└── codegen.rs
use inkwell::context::Context;
use std::process::Command;
mod codegen;
use codegen::{CodeGen, Expr, Stmt};
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Programa Pulso:
// fn cria_somador(n) {
// retorne fn(x) { retorne x + n; };
// }
// fn main() {
// var s = cria_somador(10);
// mostre(s(5));
// }
let cria_somador_body = vec![
Stmt::Return(Some(Box::new(Expr::Lambda {
params: vec!["x".to_string()],
captures: vec!["n".to_string()],
body: vec![
Stmt::Return(Some(Box::new(Expr::Binary {
op: "+".to_string(),
left: Box::new(Expr::Variable("x".to_string())),
right: Box::new(Expr::Variable("n".to_string())),
}))),
],
}))),
];
let main_body = vec![
Stmt::Var(
"s".to_string(),
Box::new(Expr::Call {
name: "cria_somador".to_string(),
args: vec![Expr::Number(10.0)],
}),
),
Stmt::Print(Box::new(Expr::CallClosure {
closure: Box::new(Expr::Variable("s".to_string())),
args: vec![Expr::Number(5.0)],
})),
];
let program = vec![
Stmt::Function {
name: "cria_somador".to_string(),
params: vec!["n".to_string()],
body: cria_somador_body,
},
Stmt::Function {
name: "main".to_string(),
params: vec![],
body: main_body,
},
];
let context = Context::create();
let mut codegen = CodeGen::new(&context, "pulso_closures");
// Declara malloc e funções
codegen.declare_externals();
codegen.declare_functions(&program);
// Compila
for stmt in &program {
if let Stmt::Function { name, params, body } = stmt {
codegen.compile_function(name, params, body)?;
}
}
println!("=== LLVM IR gerado ===");
codegen.print_ir();
codegen.emit_object("programa.o")?;
let status = Command::new("clang")
.args(&["programa.o", "-o", "programa", "-lm"])
.status()?;
if status.success() {
println!("\n=== Executável gerado. Rodando... ===");
Command::new("./programa").status()?;
}
Ok(())
}
Compilando e executando
$ cargo run
=== LLVM IR gerado ===
; (trecho simplificado do IR real)
define { ptr, ptr } @cria_somador(double %n) {
entry:
%env = call ptr @malloc(i64 8)
%n_ptr = getelementptr { double }, ptr %env, i32 0, i32 0
store double %n, ptr %n_ptr
%closure = insertvalue { ptr, ptr } undef, ptr @lambda_0, 0
%closure1 = insertvalue { ptr, ptr } %closure, ptr %env, 1
ret { ptr, ptr } %closure1
}
define double @lambda_0(ptr %env, double %x) {
entry:
%n_ptr = getelementptr { double }, ptr %env, i32 0, i32 0
%n = load double, ptr %n_ptr
%add = fadd double %x, %n
ret double %add
}
define i32 @main() {
entry:
%s = call { ptr, ptr } @cria_somador(double 1.000000e+01)
; ... extrai fn e env, chama lambda_0(env, 5.0) ...
%call = call double @lambda_0(ptr %env, double 5.000000e+00)
%printf_call = call i32 (ptr, ...) @printf(ptr @fmt, double %call)
ret i32 0
}
=== Executável gerado. Rodando...
15
O número 15 veio de uma closure. O valor n = 10 sobreviveu ao retorno de cria_somador porque foi alocado no heap. Quando s(5) foi chamado, a função lambda_0 recuperou n do ambiente e somou com x. Tudo em código de máquina nativo.
- Funções aninhadas (lambdas) que capturam variáveis do escopo pai
- Ambiente de captura alocado no heap via
malloc - Struct de ambiente com campos tipados em LLVM IR
- Passagem implícita de
envcomo primeiro argumento - Chamada indireta de closure com extração de fn_ptr e env_ptr
- Retorno de closures como valores de primeira classe
Nossa primeira implementação usa malloc e mantém o ambiente vivo até o processo terminar. Isso deixa a representação visível, porém não é uma política de memória aceitável para uma linguagem usada em programas longos. Depois de aprender como a closure nasce, precisamos decidir quando ela morre.
| Estratégia | Como decide liberar | Vantagem | Custo |
|---|---|---|---|
free manual | o programa escolhe | implementação pequena | vazamentos e uso após liberação |
| Contagem de referências | último dono desaparece | liberação previsível | ciclos precisam de tratamento |
| Garbage collector | objeto deixa de ser alcançável | modelo simples para a linguagem | runtime e pausas de coleta |
| Ownership | escopos e movimentos provados | sem GC geral | sistema de tipos mais complexo |
Por que uma closure pode criar ciclos?
Imagine um ambiente que guarda outra closure, e essa segunda closure aponta de volta para o primeiro ambiente. Cada objeto mantém a contagem do outro acima de zero. Um coletor traçador percebe que nenhum deles é alcançável a partir do programa; a contagem de referências pura não percebe.
- duas closures criadas pela mesma função preservam ambientes diferentes;
- uma closure pode ser chamada depois do retorno da função criadora;
- capturas múltiplas mantêm a ordem dos campos do ambiente;
- closure sem captura vira somente ponteiro de função, sem alocação;
- assinatura incorreta falha antes da emissão do
callindireto.
A partir daqui a Pulso deixou de ser apenas uma sintaxe sobre números: ela possui valores com identidade, tempo de vida e comportamento. Essa é a fronteira onde compilador, runtime e sistema de tipos começam a se encontrar.
Com closures e heap, a Pulso ganhou funções de primeira classe, mas cada malloc deste artigo ainda vaza memória. O artigo “Quem limpa a memória? Construindo um garbage collector do zero em Rust” deve construir um garbage collector mark-and-sweep: mapear stack, globais e closures como raízes; percorrer o grafo de objetos; marcar tudo que continua alcançável; e varrer o restante. Compararemos coleta traçadora, contagem de referências e ownership para entender por que linguagens diferentes escolhem runtimes diferentes.
Structs, arrays, módulos e JIT continuam importantes, mas abrem novos ciclos. A coleta de memória é o capítulo que encerra este: ela resolve a dívida criada exatamente pela última funcionalidade que implementamos.
Estamos vazando memória com cada closure?
Sim. Cada cria_somador aloca 8 bytes no heap e nunca libera. Em uma linguagem real, precisaríamos de free, reference counting ou um garbage collector. Por enquanto, aceitamos o vazamento como trade-off didático. Em um artigo futuro sobre GC ou ownership, resolveremos isso.
Por que não usar alloca para o ambiente e copiar na stack?
Porque alloca vive apenas durante a função atual. Se retornássemos um ponteiro para memória de alloca, estaríamos retornando um dangling pointer — a stack será reutilizada pela próxima chamada. O heap é o único lugar seguro para dados que sobrevivem ao frame.
E se a closure capturar uma variável que também é uma closure?
O ambiente conteria um campo do tipo { ptr, ptr } (outra closure). Em LLVM IR, isso seria um struct aninhado. O princípio é o mesmo: alocamos no heap e passamos ponteiros. A complexidade aumenta, mas a mecânica não muda.
O LLVM não tem suporte nativo a closures?
Não diretamente. LLVM é uma IR de baixo nível e não conhece o conceito de "closure". Ele conhece funções, ponteiros, structs e chamadas. É responsabilidade do frontend (nosso compilador) modelar closures usando esses blocos fundamentais. Linguagens como Swift, Rust e Haskell fazem exatamente isso por baixo.
Como linguagens como Rust fazem closures sem garbage collector?
Rust representa cada closure como um tipo anônimo que guarda aquilo que foi capturado. Ownership e borrowing determinam se a captura acontece por referência, por referência mutável ou por valor. Quando é preciso compartilhar dados por mais tempo, tipos como Rc ou Arc podem cuidar da contagem de referências. Em vez de depender de um coletor de lixo geral, o compilador verifica os tempos de vida e o valor é liberado quando seu dono deixa de existir.
Podemos otimizar closures que não capturam nada?
Sim. Se a análise estática mostrar que uma função aninhada não referencia variáveis externas, ela pode ser compilada como uma função estática normal — sem env, sem malloc. Isso é o que compiladores reais fazem. Nosso compilador poderia fazer a mesma otimização com uma passada de análise de escopo.
PROJETO COMPLETO · CÓDIGO DO CAPÍTULO
Continue exatamente de onde paramos.
O pacote reúne a linguagem Pulso, o compilador Cadência, a máquina virtual, a micro-CPU e os exemplos de LLVM IR usados nesta sequência. O README indica a ordem dos estágios e os comandos para executar cada experiência.