ADRIANOLAUREANO← Artigos
Compiladores LLVM Closures Heap

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.

ARTIGO 14 · FUNÇÕES QUE CARREGAM MEMÓRIA

2ponteiros

2regiões de memória

1ambiente capturado

MAPA DE MEMÓRIA · A FUNÇÃO TERMINA, A CAPTURA CONTINUA
STACK · TEMPORÁRIAcria_somadorn = 10frame encerrado
captura
HEAP · DURÁVELENV #42{ n: 10 }vive com a closure
FN PTR+ENV PTRclosure

VOCABULÁRIO VISUAL

Antes de avançar, conheça as peças deste capítulo.

01Closurefunção acompanhada de seu ambiente
02Capturavariável externa usada pela função
03Upvaluereferência da VM a uma captura
04Heapmemória que sobrevive ao frame
05Lifetimeintervalo em que um valor é válido
06GC/ownershippolíticas para liberar o ambiente
Você não precisa memorizar agora. Este mapa existe para consultar sempre que um termo reaparecer.
01 /
Não Começaremos do Zero
Funções com parâmetros e retorno já existem. Vamos fazê-las lembrar.

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.

A ideia que precisa ficar

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.

SEM MÁGICAUma closure é a soma de duas peçasA função diz o que fazer; o ambiente preserva os valores de que ela precisa.
ƒCódigosomador(x)
+
{ }Ambienten = 10
=
λClosurepode ser chamada depois

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

02 /
O Problema da Stack
Por que variáveis locais não podem ser capturadas diretamente.

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...
O frame de cria_somador morre, mas a closure sobrevive. A stack não pode ser o único lar das variáveis capturadas.
A ideia que precisa ficar

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.

O PROBLEMA DO TEMPO DE VIDAA função termina; a lembrança precisa continuarCompare o destino da variável local com o da variável capturada.
STACK · TEMPORÁRIAframe de cria_somadorn = 10é liberado no retorno
→ captura →
HEAP · DURÁVELambiente da closure{ n: 10 }vive enquanto a closure for alcançável

Para quem está começando: a stack acompanha chamadas; o heap guarda objetos cujo tempo de vida não coincide com uma única chamada.

03 /
O Que é uma Closure, de Verdade
Não é mágica. É um par: código + ambiente.

Uma closure é composta de duas partes:

  1. Código: o corpo da função, que o LLVM já sabe compilar.
  2. 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.

A ideia que precisa ficar

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.

04 /
Upvalues: Como a VM Lidava com Isso
Um breve desvio pela stack machine para entender o problema.

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:

  1. 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.
  2. 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.
  3. 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.

A ideia que precisa ficar

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.

05 /
Da Stack para o Heap
malloc, Box e a memória que sobrevive ao frame.

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:

  1. Análise: durante a compilação, identificamos quais variáveis de uma função são referenciadas por funções aninhadas.
  2. Alocação no heap: em vez de alloca (stack), usamos malloc para criar um bloco de memória no heap.
  3. Struct de captura: criamos um struct LLVM com um campo para cada variável capturada.
  4. 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 env como primeiro argumento.
  • O ambiente é um struct alocado no heap com malloc.
  • Quando criamos uma closure, alocamos o ambiente, preenchemos os campos e retornamos um par (ponteiro para função, ponteiro para ambiente).
A ideia que precisa ficar

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.

06 /
Representando Closures em LLVM IR
Structs, ponteiros e a convenção de chamada modificada.

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áticaClosure
define double @f(double %x)define double @f(ptr %env, double %x)
Parâmetros são suficientesRecebe %env como primeiro argumento implícito
Sem alocação extraChamador aloca env no heap com malloc
Retorna o valor diretamenteRetorna { ptr, ptr } = (função, ambiente)
Chamada: call @f(args)Chamada: extrair função e env do struct, depois call
A ideia que precisa ficar

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.

07 /
Implementando com Inkwell
Structs, malloc e a nova convenção de chamada.

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(&param_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())
}
A ideia que precisa ficar

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.

08 /
Programa Completo: Closure que Soma
Do AST ao executável que imprime 15.

Vamos ver o fluxo completo. A estrutura do projeto:

pulso-closures/
├── Cargo.toml
└── src/
    ├── main.rs
    └── codegen.rs
src/main.rs — construindo a AST manualmente
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.

O que agora funciona
  • 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 env como primeiro argumento
  • Chamada indireta de closure com extração de fn_ptr e env_ptr
  • Retorno de closures como valores de primeira classe
09 /
Quem Libera o Ambiente da Closure?
Mover a captura para o heap resolve o tempo de vida, mas cria uma nova responsabilidade.

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égiaComo decide liberarVantagemCusto
free manualo programa escolheimplementação pequenavazamentos e uso após liberação
Contagem de referênciasúltimo dono desapareceliberação previsívelciclos precisam de tratamento
Garbage collectorobjeto deixa de ser alcançávelmodelo simples para a linguagemruntime e pausas de coleta
Ownershipescopos e movimentos provadossem GC geralsistema 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.

Testes que o projeto deve ganhar
  • 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 call indireto.

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.

09 /
A Próxima Ponte
Agora que criamos objetos no heap, precisamos aprender a encerrá-los com segurança.
09Pulsolinguagem com lexer, parser e AST
10Cadência + mini-vmcompilador e máquina virtual de bytecode
11micro-cpucomo uma CPU real executa instruções
12LLVMgerando código nativo a partir da AST
13Funçõesparâmetros, retorno e chamadas nativas
14Closurescaptura de variáveis e heap allocation ← você está aqui
15Garbage collectorraízes, grafo de objetos, marcação e varredura
A última peça deste ciclo

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.

10 /
Perguntas Frequentes
O que costuma ficar depois do primeiro malloc.
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.

Baixar código final deste artigo (.zip)Rust · Pulso · Cadência · micro-CPU · LLVM IR

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.