Pular para o conteúdo

Especificação §5

Gerenciamento de memória

language-design.md §5 · 601 linhas · 49 min de leitura

process A@mm(gc)heap privado
process B@mm(arena)heap privado
process C@mm(none)heap privado

A propriedade cruza a fronteira de processo por @transfer. Nada é compartilhado: não existe memória que dois processos possam tocar.

Três processos, três estratégias, um programa. Cada heap morre com o seu processo.

O problema de ter múltiplas estratégias de MM é o risco de “coloring”: GC só pode falar com GC, Arena só pode falar com Arena. Isso seria o mesmo problema que async introduz no sistema de tipos, e que o Zig resolve desacoplando async da tipagem via interface de IO.

A solução aqui é análoga: uma MemoryManager interface. Cada alocação carrega um ponteiro implícito para o seu MemoryManager. O runtime chama a interface quando precisa alocar, liberar ou rastrear, sem saber qual estratégia está por baixo. O resultado é mix-match livre entre estratégias, sem coloring, sem tracking inter-estratégia manual.

A interface tem quatro métodos. A assinatura é o contrato que toda estratégia (gc/arena/none) implementa:

decl MemoryManager {
fn alloc(size: usize, align: usize) -> Result[Ptr, error{OOM}]
fn resize(ptr: Ptr, new_size: usize, old_size: Optional[usize]) -> bool
fn free(ptr: Ptr, size: Optional[usize])
fn trace(visit: fn(Ptr)) // derivado pelo compilador; override opcional
}

Quatro decisões definem esse contrato, cada uma a favor de honestidade ou de segurança:

  • alloc recebe alinhamento (potência de 2), não só tamanho; importa para SIMD, cache lines e FFI. É o modelo do Zig.
  • resize retorna bool, não um Ptr novo. O realloc clássico do C devolve um ponteiro possivelmente diferente, escondendo uma cópia+free atrás de uma chamada que parece barata. Aqui resize só tenta crescer/encolher in-place; se devolve false, você decide se faz alloc+copia+free. Sem cópia mágica, a mesma filosofia de não-magia que cortou o ? de propagação (seção 7).
  • size é Optional[usize] em free/resize, preenchido pelo compilador. Como a liberação é automática no modelo colorless (seção 6), e quem chama free é o runtime, não você, não adianta “passe o size se quiser performance”: não há quem passe. A virada é que o compilador frequentemente sabe o size estaticamente (size_of[T]() é constante). Então ele emite free(ptr, size_of[T]()) quando o tipo é conhecido (o caso comum performance por padrão, o MM pula o lookup interno), e free(ptr, none) quando é dinâmico/type-erased (o MM consulta sua própria metadata simplicidade quando necessário). É melhor que parâmetro default: a escolha ótima vem de quem realmente sabe (o compilador), sem decisão humana. Exigência de implementação: o MM tem que funcionar com none (sempre liberar via metadata própria) e pode otimizar quando recebe o size.
  • trace é derivado pelo compilador (detalhado abaixo).

trace é o que permite mix-match seguro com GC: o coletor passa um callback visit, e o objeto chama visit(campo) para cada campo-ponteiro que contém, marcando o alvo como vivo. A pergunta de design é quem escreve a lógica de enumeração, e a resposta é: o compilador, nunca você.

O compilador conhece o layout de cada tipo (quais campos são ponteiros, onde estão). Então ele sintetiza o trace automaticamente, via a mesma reflection de comptime que auto-deriva serialize (seção 10):

decl Node { next: *Node; data: *byte; count: int }
// compilador gera o trace: visita next, visita data, ignora count (não é ponteiro).
// você não escreve nada.

Isso elimina por construção o bug clássico de GC manual (“esqueci um campo no trace o coletor não vê coleta objeto vivo use-after-free”): o compilador nunca esquece um campo. É a mesma jogada do match exaustivo: a segurança vem de o compilador conhecer a estrutura. Override manual existe só como escape-hatch para casos exóticos (layout custom de FFI, ponteiros “escondidos” que a reflection não enxerga); 99% dos tipos usam o derivado.

O trace no-op de arena/none não é um método chamado e ignorado; é uma chamada que nunca é gerada. A intuição (“o GC pula arena/none”) é o resultado certo; a correção é quem decide e quando: não é o GC em runtime, é o compilador em compilação. Se o GC consultasse “esse objeto é arena ou gc?” para cada objeto do grafo, seria um branch por objeto: milhões de objetos, milhões de branches, e um tag de MM lido a cada visita, o que é caro. Mas o compilador já sabe estaticamente qual MM cada alocação usa (é o que faz o dead-MM elimination funcionar, ver adiante), então ele simplesmente não emite tracing para objetos arena/none. O trace de Arena/Manual existe na interface por completude, mas o corpo { } nunca roda porque nunca é invocado.

// no-op runtime (NÃO é assim), um branch por objeto:
loop obj in grafo { if obj.mm.is_managed() { obj.mm.trace(obj, visit) } }
// no-op estático (é assim): custo zero:
// ao ver que 'buf' é @mm(arena), o compilador NÃO emite tracing para 'buf'.
// o GC nunca o encontra na sua raiz de varredura.

A fronteira gcarena (um objeto GC que contém um ponteiro para um objeto arena) também é estática: o trace gerado do objeto GC, ao chegar nesse campo, vê pelo tipo do ponteiro (que o compilador conhece) que aponta para algo arena-managed e não recursa ali. O compilador emite “este campo aponta pra arena não trace este campo”. Zero overhead, de novo, e nenhum tag em runtime. Quando o GC roda, encontra objetos Arena no grafo e nunca tenta rastreá-los, movê-los ou corrompê-los, porque a chamada simplesmente não existe.

Cada estratégia é uma implementação da MemoryManager interface:

Estratégia Descrição
gc Garbage collector: rastreamento automático de referências vivas
arena Arena allocation: liberação em bloco quando a arena é destruída
none Manual, como C: alloc/free explícitos pelo desenvolvedor
c Memória vinda do C via FFI: a interface aponta pro free/deallocator do C (seção 17)

@mm define o MemoryManager padrão para novas alocações dentro daquele contexto: processo, função ou objeto. Não é uma tag fixa no tipo; é o allocator que será usado quando nenhuma outra instrução existir.

// MM padrão global: definido na compilação
// --mm=gc (padrão se não especificado)
// Override por processo: todas as novas alocações dentro de worker usam arena
@mm(arena)
spawn worker(data)
// Override por objeto: esta alocação específica usa arena
buf: HugeBuffer @mm(arena) = ...
// Sem anotação: usa o MM do contexto atual
x: SmallStruct = ... // usa @mm do processo/função corrente

Quando um objeto é alocado, ele recebe um ponteiro implícito para o MemoryManager que o criou. Esse ponteiro viaja com o objeto, independente de qual processo o contém agora.

// Processo A: @mm(arena)
buf: HugeBuffer @mm(arena) = ... // buf.mm = ArenaInstance
spawn gc_worker(buf @transfer)
// Processo B: @mm(gc), mas buf.mm ainda aponta para Arena
// Processo B aloca novas coisas com GC
// Quando buf sai de escopo: runtime chama buf.mm.free() → Arena.free()
// O GC não toca em buf. A arena não vaza.

Não há tracking inter-estratégia. O runtime não precisa entender “GC rastreando Arena”; ele só chama buf.mm.free().

@transfer entre processos com MM diferentes é válido. O objeto chega no processo destino com seu mm intacto. O processo destino usa seu @mm para novas alocações; o mm do objeto recebido é usado apenas para liberá-lo quando sair de escopo. (Tanto @transfer quanto @promote são comportamentos transient, no sentido da seção 6: anotam como um valor atravessa a fronteira entre processos, mover ou copiar, não um valor que persiste.)

Na mesma linha, uma nota sobre infraestrutura: o control-block de um processo e o buffer de um channel bufferizado são do runtime, não do @mm de quem cria. Então um processo @mm(none) ainda spawna filhos (cada um com o seu @mm) e usa channel bufferizado normalmente: none significa que as tuas alocações de heap são gerenciadas manualmente (alloc/free explícitos, estilo C, conforme a tabela de estratégias), ele não proíbe o heap e não mexe na contabilidade do runtime.

// Válido: Arena → GC via @transfer
@mm(arena) spawn producer(data)
@mm(gc) spawn consumer(chan)
// producer
result: Data @mm(arena) = process(data)
chan <- result @transfer
// consumer recebe result com result.mm = Arena
// consumer aloca suas próprias coisas com GC
// quando result sai de escopo: result.mm.free() → Arena.free()

@promote copia um objeto para um novo MemoryManager. É uma ferramenta de performance, não de corretude: o mix-match funciona sem ela. Em termos dos métodos da interface: @promote(gc) ≈ dest.alloc(size, align) copia bytes (eventual) origem.free. É uma diretiva de memória, então usa o sigil @, no mesmo lugar de @mm/@transfer:

// Sem promote: funciona corretamente, zero custo extra de design
chan <- result @transfer
// Com promote: copia result para o GC antes de transferir,
// permitindo destruir a arena de origem sem esperar o consumer
chan <- result @promote(gc) @transfer

O nível de cópia é um parâmetro, level: shallow | deep (provavelmente um enum), com default shallow:

result @promote(gc) // shallow (default)
result @promote(gc, deep) // deep, explícito
  • shallow (default, barato): copia o objeto; os ponteiros internos continuam apontando para os mesmos filhos originais. Cópia profunda por reflexo seria errado aqui: uma “otimização” que copia um grafo inteiro silenciosamente vira a coisa mais cara do programa, e duplicar filhos compartilhados quebra identidade/aliasing. O grafo pós-shallow é só mais um caso de “objeto de um MM apontando para objeto de outro MM”, que o trace já cobre (o GC traceja a cópia-gc, encontra o ponteiro para o filho arena e não recursa).
  • deep (caro, explícito): copia o objeto e o subgrafo alcançável para o MM destino. É o que você usa para cortar o cordão com a origem, quando quer destruir a arena inteira e precisa que nada mais aponte para ela. O deep carrega o aviso de custo, igual @mm(none) carrega o aviso de responsabilidade.

A origem não é liberada automaticamente. @promote é puramente “copia para lá”; o original segue as regras normais de lifetime (morre quando sairia de escopo de qualquer jeito). Quem quer liberar cedo chama o free da estratégia de origem explicitamente (destruir a arena em bloco). Nenhuma semântica de free embutida no @promote.

Dois diagnósticos do compilador acompanham o @promote, e o primeiro é maior do que parece:

free de região com ponteiro vivo: detecção local mais território unsafe no geral. Um shallow que retém ponteiro para a origem e depois libera a origem deixa um ponteiro dangling. Mas isso não é um caso especial do promote; é o caso geral de liberar manualmente uma estratégia enquanto há ponteiros vivos para dentro dela (dá pra criar a mesma situação com arena.free() e um & cru, sem promote nenhum). E provar que não há ponteiro vivo no momento do free é análise de lifetime, que a linguagem decidiu não ter. A resolução honesta é dois níveis combinados:

  • Detecção local (cortesia): no mesmo escopo, o compilador vê o @promote(gc) shallow reter ponteiro para buf (arena) e um arena.free() depois erro, com mensagem clara. Pega o caso óbvio.
  • Unsafe no caso geral: quando o ponteiro escapa para outra função, arena/none herda a semântica unsafe: você pediu gerência manual, você é dono do lifetime. É o preço do escape-hatch, coerente com “sem lifetimes”. O GC continua sendo o caminho seguro-por-construção; arena/none é o rápido-mas-você-cuida. E a honestidade tem que ser explícita aqui: para o caso difícil (grafo de aliasing mutável complexo, ponteiros escapando entre estruturas, exatamente onde o custo do borrow checker do Rust se paga) esta linguagem dá menos garantia estática que o Rust, não uma versão enxuta da mesma. Fora do GC não há lifetimes nem o tooling que prova ausência de dangling; há disciplina humana, como em C. É o preço consciente de não ter lifetimes, e o GC é a rede que o torna pagável.

Duas coisas afinam essa rede no caminho manual (arena/none) sem virar um borrow checker. As duas ficam totalmente fora do caminho GC, então código seguro nunca as encontra.

O gate local é sempre ligado, sound e grátis. Onde uma alocação e seu free vivem na mesma função e o ponteiro não escapa (não é retornado, não é guardado numa struct que escapa, não é @transferado), o lifetime é decidível — não precisa de lifetimes pra saber isso — então o compilador faz disso um erro duro, não cortesia: um alloc manual sem free em algum caminho é erro de leak, um segundo free é erro de double-free, um uso após free é erro de use-after-free. É o check de cortesia local (acima) promovido a gate. É sound e completo nesse fragmento (nunca perde um bug e nunca rejeita programa válido ali), porque o fragmento é decidível; não precisa de anotação; e é exatamente “você não compila memória manual obviamente quebrada”.

A fronteira de escape continua unsafe por padrão; @consume é o upgrade opt-in. No momento em que um ponteiro manual escapa a função, a propriedade vira indecidível (Rice), então um gate sound teria que rejeitar programas válidos — a conta do borrow checker. Makoto não manda essa conta por padrão: um ponteiro arena/none que escapa mantém a semântica unsafe acima (disciplina humana, GC como rede). Mas um autor que quer a garantia através da fronteira marca o parâmetro @consume (escrito após o tipo, como @mm: fn free_it(p: *T @consume)): isso torna o ponteiro linear (posse afim) — o chamado agora é dono e precisa liberá-lo-ou-transferi-lo exatamente uma vez, e o chamador não pode usá-lo depois (a move-invalidation do @transfer, generalizada). Isso dá o gate sound cross-função (leak/double-free/UAF pegos através da chamada) sem lifetimes — posse é move, não borrow, e lifetimes são o imposto dos borrows, que a linguagem não tem.

@consume é um contrato opt-in, no slot exato de @requires/@ensures (seção 14): uma promessa por-função checada onde é escrita e invisível onde não é. Passa no teste do opt-in do mesmo jeito — um chamador só encontra @consume numa API cujo autor o escolheu, e só no caminho manual, que já é opt-in. Não é tainted coloring (seção 1): é a cor própria da memória (posse) numa operação de memória, não a cor de um sistema vazando em outro. O default continua “C com uma rede de GC e um gate local”; @consume é o upgrade summonável “prove a posse” pra API rara que o quer, nunca a regra ambiente. É um decorador (consume cobre posse-e-free; emprestar é o default silencioso), nunca um zoológico de lifetimes.

deep desnecessário: warning, nunca erro. O compilador não sabe se você precisava de deep (depende da sua intenção futura de destruir a origem). Mas detecta o caso degenerado (deep sobre objeto sem ponteiros, ou cujos ponteiros já são todos do MM destino), onde deep e shallow são provadamente idênticos warning (“deep desnecessário; use shallow”). deep supérfluo é desperdício, não bug.

autofree: o gate de posse, rodado como otimização

Seção intitulada “autofree: o gate de posse, rodado como otimização”

O gate de posse local (acima) prova algo valioso e só usa para diagnosticar: no fragmento decidível — uma alocação cujo ponteiro não escapa, cujo último uso é determinável nesta função — a lifetime é conhecida sem lifetimes. O autofree transforma essa mesma prova numa otimização. Onde a análise prova uma lifetime linear, o compilador insere o free do objeto logo após seu último uso em vez de deixar o objeto para o MemoryManager recuperar depois. É um pré-passe de compilação: tenta autofree; onde não conseguir provar, deixa para o MM.

O ganho é maior no gc. O custo de um coletor escala com o número de objetos vivos que ele tem que traçar; um objeto com lifetime provadamente-linear nunca precisou ser um objeto gerenciado — nasce, é usado e morre dentro de um frame. Autofree-á-lo o remove do mundo do coletor: menos raízes a marcar, menos objetos a varrer, menos heap que chega a uma coleta. E generaliza, porque liberar é colorless (free(p) roteia pelo mm do próprio objeto, acima): o mesmo pré-passe ajuda arena e none também, não só o coletor. É a escape analysis do Go com a decisão empurrada um degrau além — não meramente “stack ou heap”, mas “libera no último uso, ou entrega à estratégia”.

Soundness é o ponto inteiro, então ele só age sobre o provável. Qualquer erro aqui é um use-after-free, então autofree insere um free só onde a prova de escape-e-último-uso é total (o fragmento decidível, 100% de precisão); no instante em que um ponteiro escapa, é guardado onde possa sobreviver ao frame, ou tem último uso indecidível, o objeto cai de volta para o MM exatamente como antes. E como só libera um objeto que a prova mostra já morto, autofree não pode mudar comportamento observável — muda quando a memória é recuperada, nunca o que o programa computa. O mesmo fonte produz a mesma saída com autofree ligado ou desligado. Quando o último uso é uma instrução simples no próprio escopo do objeto, o reclaim cai logo depois dela em vez de no fim do escopo (então um rabo longo de trabalho não-relacionado não segura mais o objeto vivo); quando o último uso atravessa branches, o reclaim volta para a saída de escopo, sempre guardado para que nenhum caminho libere duas vezes. E o reclaim é colorless — roteia pelo gerente real do objeto em runtime, já que a estratégia @mm ambiente é dinâmica: um objeto gc é recuperado do coletor, um none é liberado, e um de arena é um no-op deliberado.

Esse no-op de arena é uma limitação real, dita sem rodeio: arena é um alocador bump, e um bump não consegue liberar um objeto — ele recupera a região inteira de uma vez, que é sua razão de existir. Então autofree não consegue liberar um objeto de arena individual cedo; só pode declinar, e deixar o reclaim em-massa da arena fazer o trabalho. O único jeito de dar morte precoce a um objeto de arena seria não pô-lo na arena — alocar na stack, ou no heap liberável none — mas isso quebra a garantia de neutralidade acima: um objeto que nunca chega ao mm.alloc some do mm.used e da observabilidade de memória da seção 19, então a memória observada do programa passaria a depender de o autofree ter rodado. Autofree recusa esse trade por padrão: a garantia de que ligado/desligado é invisível vale mais que um free precoce que a arena foi projetada para não precisar. (Retarget poderia ser oferecido um dia como opt-in explícito, abrindo mão da neutralidade; não é o default, nem implicado por @mm(arena).)

Autofree é ligado por padrão (--no-autofree desliga), e sua agressividade é um botão de build, --autofree=<modo>, no espírito do dial de warning-para-erro de um compilador C:

  • default — autofree age em alocações gc e arena (arena só até onde um bump permite — veja o no-op acima; o reclaim real é no gc). none fica intocado: seu contrato de free manual e o gate de leak/double-free/UAF continuam exatamente como documentado acima.
  • conservative — a análise roda sobre toda estratégia, mas none mantém seu contrato manual: uma alocação @mm(none) ainda exige seu free explícito, e um faltante ainda é erro de leak. Autofree nunca insere um free em none aqui (isso daria double-free com o seu manual); é cobertura opt-in da análise sem afrouxar um único diagnóstico de none.
  • optimized — autofree age em none também: uma alocação @mm(none) com lifetime provadamente-linear e sem free manual ganha um inserido para você, e portanto não é mais erro de leak — o compilador liberou. Você ainda é dono dos casos de escape (lifetimes improváveis continuam unsafe, como sempre); esse modo só deixa o fragmento decidível da memória manual ser tão ergonômico quanto o gc, com o free manual reservado ao que de fato escapa.

O padrão é o meio-termo seguro: alivia o gc de graça (um objeto com lifetime local provável nunca precisou ser um objeto gerenciado) e deixa o contrato do caminho manual intocado, então nenhum programa none existente muda de significado. optimized é para o autor que quer que a prova também descarregue frees manuais; conservative para quem quer a análise em todo lugar mas nenhum erro de none afrouxado.

O analisador de memória: um borrow-checker que você consulta, não um que briga com você

Seção intitulada “O analisador de memória: um borrow-checker que você consulta, não um que briga com você”

A mesma análise de escape-e-lifetime que move o gate e o autofree consegue dizer mais do que impõe. Uma ferramenta em cima dela é um borrow-checker invertido: onde o Rust torna o check obrigatório (o imposto que você paga em todo programa), Makoto o torna consultivo por padrão e reserva o bloqueio de compilação pro que ela consegue provar ser catastrófico. Você ganha a utilidade do borrow-checker — bugs pegos em compile-time — sem a conta, porque o GC continua sendo a rede e o gate decidível continua sendo a única coisa que tem que passar.

Ele reporta em camadas, por quão certo está:

  • Provadamente-fatal sempre erro. O gate do fragmento decidível (leak / double-free / use-after-free de ponteiro manual que não escapa), uma obrigação de @consume descumprida, um uso após @transfer — e qualquer caso mais largo que a análise consiga provar, não apenas suspeitar. Esses travam o build. Corrupção de memória nunca é um warning.
  • Provavelmente-errado um warning. Na zona indecidível (Rice), onde um ponteiro escapa e pode ser usado depois do free, ou um alias é ambíguo, a análise é honesta que está chutando: avisa, e o programa ainda compila. --strict-mm (um dial -Werror=mm) promove esses a erro pro autor que quer o rigor.
  • Poderia-ser-mais-apertado uma dica. “esse ponteiro nunca escapa — poderia ser @consume”, “isso é autofree-ável” — fora do caminho crítico, opt-in.

Duas coisas impedem a ferramenta de ser inútil ou tirânica. A rigidez é um dial de build, nunca fonte que você reescreve: --strict-mm (o -Werror=mm) promove a camada de warning inteira a erro pra compilação toda de uma vez — um projeto liga o rigor com uma flag, e um build que quer mais granularidade seta por módulo na config do projeto, nunca anotando função por função. Você não toca uma linha de código pra deixar o analisador mais estrito, e não há keyword por-função pra espalhar (isso seria exatamente o imposto que esta linguagem recusa). E todo warning é silenciável no seu site: o autor que sabe que um achado específico é falso-positivo silencia aquele um escrevendo o trailer auditado assume "razão" na própria operação sinalizada — free(p) assume "checei o aliasing; o alias está morto aqui". É a mesma keyword que o unsafe já usa (nenhuma nova), agora lida como “analisador, eu descarreguei isso”, e continua greppável e não-vazia como todo assume, então a supressão é ela mesma parte da superfície de auditoria. Um projeto crescente portanto nunca afoga achados reais sob uma maré crescente de falsos. Isso é seguro justamente porque a ferramenta é opt-in: ela não precisa se defender de um dev preguiçoso, porque um dev preguiçoso nunca a liga; ela existe pra servir quem quer o par de olhos extra, e esse é bem servido por poder responder a ela.

Por baixo, não é um checker fixo mas um framework: o compilador provê o núcleo sound e as camadas heurísticas, e os contratos comptime da seção 14 deixam um codebase adicionar seus próprios invariantes — o autor escreve a análise de memória que o domínio dele precisa, checada em compile-time, em vez de aceitar uma disciplina de borrow fixa. A stdlib semeia isso com predicados como mem.is_pointer_free[T]() (true quando T não guarda ponteiro algum — seguro pra bit-copy, nada pro coletor tracejar), uma comptime fn sobre a estrutura refletida do tipo; ponha num contrato, @requires(mem.is_pointer_free[T]()), e uma instanciação que viola é um erro de compilação, não um trap de runtime. É o oposto do trade do Rust: o rigor é a coisa que você convoca, não a que te persegue.

Na prática a ferramenta tem três superfícies: -Wmm em qualquer build liga os warnings; mk analyze roda tudo e imprime um relatório (erros, depois warnings, depois dicas, com uma contagem), sem produzir binário, então um projeto pode gatear o CI nele; e strict-mm true no mk.project persiste o --strict-mm pra todo build daquele projeto, o knob de config pra onde a regra “dial de build, não fonte que você reescreve” aponta.

Quando um processo retorna um valor:

  • Dado recebido via @transfer, não mutado volta com mm original
  • Dado novo sem anotação usa o mm do caller (o heap do processo filho foi descartado)
  • Dado novo com anotação explícita usa o mm especificado
return x // x volta com seu mm original (foi passado via @transfer)
return new_val // new_val usa mm do caller
return new_val @mm(gc) // mm explícito
spawn worker(data) // sem limite (padrão)
@limit(16mb)
spawn worker(data) // limite fixo

Se o processo ultrapassar o limite: o scheduler interrompe a alocação, o processo morre, e o motivo OOM chega ao supervisor como o valor de erro do catch |e| (seção 8), que decide a estratégia (reiniciar menor, degradar, propagar).

Toda alocação pode, em tese, falhar, mas OOM não é threaded por-chamada nas operações de alto nível (List.push, string.append, Buffer.write, encode). Isso seria coloring: se cada push/concat devolvesse Result[_, error{OOM}], a falha-de-alocação infectaria toda assinatura que toca uma coleção, exatamente o que o colorless desta seção evita (o allocator viaja com o objeto via @mm, não nas assinaturas; threadar OOM exigiria trazê-lo de volta). A regra é a do BEAM: a alocação que falha mata o processo, e o supervisor trata (seção 8). O erro aparece uma vez, na fronteira de supervisão (como o valor do catch |e|), não em cada sítio de alocação.

E há um motivo físico para isso não ser perda: num SO com overcommit (o Linux padrão), você nem capta o OOM de verdade no call-site: o alloc retorna sucesso e o OOM killer mata o processo num acesso à memória lá na frente, não na chamada. OOM só é captável/recuperável quando há um orçamento abaixo do limite da máquina: uma arena com cap, um @limit, um buffer fixo, ou um alvo sem overcommit (embarcado). A régua:

  • Alocação ilimitada que falha = a máquina acabou = crash (e, no SO comum, nem detectável no ponto). Tratamento é o supervisor.
  • Alocação com orçamento que falha = você bateu no seu teto = recuperável.

A escotilha para o caso recuperável é descer ao mm.alloc, que devolve Result[Ptr, error{OOM}] (a interface MemoryManager, acima), para o autor de allocator e para o padrão “tenta caber neste orçamento; se não, degrada” (alocar um buffer grande e, na falha, fazer streaming em vez de morrer). Dois mundos sem conflito: alto nível colorless-que-crasha (o caso de 99%), baixo nível Result-explícito (o autor de MM e o OOM-com-orçamento).

Isto é o design convergente: o Rust também decidiu alocação infalível por padrão (push/Box::new abortam) com try_reserve como o opt-in falível; aqui se ganha o supervisor do BEAM como o lugar de tratar, que o Rust não tem. E prevenir OOM, em vez de tratar, é a camada ortogonal de backpressure: um channel limitado bloqueia o sender quando enche, throttlando o produtor antes de a memória estourar (seção 4). Throttling é prevenção (a montante); o crash supervisionado é o que sobra quando a prevenção não bastou.

Não existe @shared. Todo caso de uso que memória compartilhada resolveria é endereçável via @transfer + channel, com mais clareza e sem abrir vetor de corrupção cruzada entre heaps. Se múltiplos processos precisam de acesso ao mesmo dado, o padrão é um processo dedicado que detém o dado e serve requisições via channel.

MemoryManager sobre MemorySource é estratégia sobre fonte: como eu gerencio sobre de onde vem a RAM. O runtime repete essa forma um nível acima. O scheduler, os channels e o ciclo de vida de processo são a estratégia; a máquina embaixo é a fonte. Então o runtime não é uma coisa de que a linguagem depende — é uma implementação de uma interface, acoplada em tempo de link, e as peças dele que você nunca convoca são peças que você nunca linka.

A interface é dividida em módulos de capacidade, e é essa divisão que torna isso literal:

módulo o que o host fornece quem precisa
fatal como esta máquina para tudo (um trap tem que terminar em algum lugar)
mem memória crua — o MemorySource qualquer coisa que aloca
io bytes pra fora io.*, e relatar um trap
time um relógio monotônico timeout(...), o quantum do scheduler
task stacks, paralelismo de hardware spawn, channels, supervisão

Um alvo implementa os módulos que ele tem. Um kernel em boot inicial tem memória e uma serial, mas não tem timer nem threads: implementa fatal+mem+io e ganha structs, enums, genéricos, defer, catch, closures e generators — este último porque um generator é uma máquina de estados stackless (seção 11), então não precisa de scheduler nenhum. Quando esse kernel depois ganha timer e alocador de páginas, implementa time+task e spawn, channels e supervisão passam a funcionar dentro dele, com o mesmo compilador e sem dialeto.

O que um alvo não implementa, ele não ganha — e isso é erro de compilação, nunca no-op silencioso. Um spawn num alvo sem o módulo task não vira um spawn que não faz nada; ele não compila, nomeando a capacidade que falta. O checker conhece o alvo, então o diagnóstico aponta pro spawn, não pra um erro de link sobre símbolo indefinido.

Este é o mesmo mecanismo da eliminação de @mm morto — “dead-code elimination, só que sobre implementações de uma interface” — aplicado ao próprio runtime. Tree-shaking aqui não é o linker adivinhando o que é inalcançável: a dependência simplesmente não existe.

A MemoryManager (acima) decide como gerenciar, mas precisa pedir memória bruta de algum lugar, e esse lugar muda por alvo de compilação. Daí uma segunda interface, mais embaixo: a MemorySource, que entrega páginas brutas. Mesma jogada do desacoplamento de IO no Zig e da própria MemoryManager: mais uma interface no ponto de variação.

decl MemorySource {
fn map(pages: usize) -> Result[RawPtr, error{OOM}] // pede memória bruta ao alvo
fn unmap(ptr: RawPtr, pages: usize)
}

A pilha fica em três camadas, cada uma plugável no seu ponto:

seu código (colorless, seção 6)
↓ usa
MemoryManager gc / arena / none "COMO gerencio"
↓ pede páginas de
MemorySource native / wasm / bare-metal "DE ONDE vem a RAM bruta"

Uma implementação de MemorySource por alvo:

Alvo MemorySource.map usa
Linux / Mac mmap
Windows VirtualAlloc
WASM / Web memory.grow sobre a linear memory
Bare-metal recorta de uma região fixa definida no link (sem OS)

As duas seleções são compile-time e ortogonais: o MemoryManager vem do @mm/flag; a MemorySource, do alvo (--target=wasm seleciona a Source wasm). Qualquer gerência sobre qualquer fonte (GC sobre wasm, arena sobre bare-metal) porque o Manager só fala com a MemorySource, nunca com o OS. É o mesmo desacoplamento do colorless memory, um nível abaixo.

Isso esclarece os allocators do Zig, que misturam duas camadas numa interface só: o PageAllocator (pede páginas ao OS) é uma MemorySource; o FixedBuffer/Stack e o GeneralPurpose (heap) são MemoryManager; o heap, inclusive, é um Manager que por baixo pede páginas a uma Source. A separação em duas é o que dá o shipping multi-alvo limpo: o Manager não muda entre alvos; só a Source troca.

Fronteira honesta: nem toda estratégia pede à Source. Um Manager pode ser inicializado sobre um buffer que já existe (na stack, ou um array estático) e nunca tocar a MemorySource, uma arena sobre memória pré-existente. A MemorySource é a fonte default (de onde vêm páginas quando a estratégia precisa de mais), não uma obrigação.

Se nenhuma alocação no programa usa @mm(gc), o coletor inteiro não deve ir pro binário. Idem arena, idem cada estratégia. Isso é tree-shaking de estratégia de MM, e cai quase de graça do design: como cada MemoryManager é uma implementação de interface e o conjunto de estratégias usadas é conhecido em compilação (basta varrer os @mm(...) e os Arena.new()/GC.new() do código), o que não aparece não linka. É dead-code elimination comum, sobre implementações de uma interface, via a compilação condicional do comptime (seção 10).

Estratégia em compile-time, instância em runtime

Seção intitulada “Estratégia em compile-time, instância em runtime”

A estratégia (qual MemoryManager) é sempre decidida em compilação; a instância é livre em runtime. Três níveis, do permitido ao barrado:

  • Nível 0, instâncias em runtime (permitido, já vem de graça). Criar uma arena nova por requisição e descartá-la no fim é trivial, porque MemoryManager é interface. A estratégia (arena) é fixa; a instância é runtime. A forma comum, anônima: o argumento de @mm(arena) é sempre um keyword de estratégia (gc/arena/none), conhecido em compilação, e um bloco @mm(arena) { ... } escopa uma arena nova anônima que nasce no { e morre no }. Você não nomeia nem passa um valor de allocator (isso quebraria o colorless, ver abaixo); o lifetime da região é o bloco. Uma sub-arena é só um @mm(arena) { ... } aninhado.
    fn handle(req: Request) {
    @mm(arena) { ... } // arena nova anônima; tudo aqui aloca nela; morre no fim
    }
  • Nível 1, estratégia como variável de runtime (barrado). Escolher qual estratégia via um valor de runtime (mm := x ? Arena.new() : GC.new()) torna mm um valor de interface despachado por vtable: toda alocação vira chamada indireta, e o compilador não consegue mais o dead-MM elimination, porque qualquer estratégia pode ser escolhida tarde demais pra varredura. O nº1 e o swap-de-estratégia-em-runtime são mutuamente exclusivos.
  • Nível 2, carregar MM de plugin/dlopen (barrado). O compilador não sabe nem quais estratégias existem; mata o nº1 e abre incompatibilidade de ABI. Sem retorno, ainda mais num alvo bare-metal.

O caso que parece pedir Nível 1 quase sempre é dois caminhos com estratégias fixas, que preserva o dead-MM elimination:

if config.low_latency {
@mm(arena) serve() // o compilador vê arena E gc no código →
} else { // embarca os dois, mas SABE quais (se 'none' nunca
@mm(gc) serve() // aparece, 'none' não vai)
}

O if escolhe o caminho, não a estratégia abstrata. Dá flexibilidade de runtime sem cegar o compilador.

Liberar memória: free(p) e o módulo mm, ancorados num ponteiro

Seção intitulada “Liberar memória: free(p) e o módulo mm, ancorados num ponteiro”

Sob gc você nunca libera: o runtime faz, automático, na saída de escopo (obj.mm.free(ptr)), e o coletor nunca libera algo que um ponteiro vivo ainda alcança. Sob arena, a liberação é em bloco e majoritariamente gerida pelo runtime (a região morre com o bloco @mm(arena) { }). O único lugar onde você libera à mão é @mm(none) (manual, estilo C): ali, free(p) libera o objeto que p aponta. Você escreve só o ponteiro; o compilador preenche o size (free(ptr, size_of[T]()) quando estático, free(ptr, none) quando dinâmico, ver a interface acima), então não há size manual pra passar. free roteia pelo mm do próprio objeto (colorless: o allocator viaja com o objeto), então free(p) sempre libera com a estratégia certa sem você nomear.

As operações de região inteira mais raras (liberar cedo, resetar, consultar uso) vivem no módulo mm e são ancoradas num ponteiro, nunca num handle ambiente de “arena corrente”:

use mm
@mm(arena) {
p := &node.val
// ...
mm.release(p) // libera a região inteira onde p vive (a arena), cedo
mm.reset(p) // esvazia a região de p mas mantém a arena
n := mm.used(p) // bytes usados na região de p
}

O design é deliberado em dois pontos. Não há handle ambiente (self, @mm.free(), um acessor de “arena corrente”): toda operação ancora num ponteiro concreto que você já segura, então a resposta pra “qual região, e de onde vem?” é sempre “o objeto que p aponta”, nunca um valor que surge magicamente. Não há allocator-como-valor passado pra funções (o padrão do Zig), porque isso quebra o colorless (a função passaria a saber qual memória usa, e passar um buffer de arena pra uma função GC vira mismatch, ver o rationale). Se você realmente precisa segurar uma região como valor nomeado (raro, orquestrando memória você mesmo), Arena.new() é o escape-hatch explícito: você a criou com as próprias mãos, então não há nada mágico em de onde ela veio.

Liberar uma região (mm.release, ou um bloco de arena terminando) enquanto um ponteiro vivo pra dentro dela sobrevive é o caso de use-after-free acima: o compilador pega no escopo local (cortesia), e o caso geral que escapa herda a semântica unsafe de arena/none.

O Wasm GC (parte do WebAssembly 3.0, padrão W3C desde 2025) deixa um módulo usar o coletor do host em vez de embarcar o seu: bundle menor, sem o duplo-GC que vaza. Mas ele não é uma MemorySource: não entrega bytes brutos, e sim objetos gerenciados tipados que o host rastreia e move (struct/array heap types). É uma terceira coisa, um heap de objetos do host, e por isso entra como backing alternativo da estratégia gc, não como fonte de memória.

A estratégia gc passa a ter dois lowerings:

@mm(gc) ┌─ default (todo alvo): seu coletor ──> MemorySource
"managed" ────┤─ --gc=wasmgc (opt-in): heap do host (sem MemorySource)
└─ exceto objeto com & → rebaixa pra linear (seu coletor)
@mm(arena/none) ─► sempre MemorySource (linear), todo alvo

--gc=wasmgc é opt-in. Sem ele, mesmo na web o gc é o seu coletor sobre linear memory, uniforme com o nativo, e ponteiro em objeto gc funciona normal. Com ele, @mm(gc) lowera pra Wasm GC.

Por que isso não muda nenhum código de usuário: @mm(gc) sempre significou “managed”, não “meu coletor”. E o que o Wasm GC precisa para rastrear (a estrutura de tipos dos objetos) é o que o type system já tem; lowerar é emitir os structs como heap types e deixar o host coletar. O trace da MemoryManager interface fica relativo ao alvo: no nativo dirige o seu coletor; no Wasm GC é subsumido pela estrutura de tipos emitida.

Ponteiro em objeto @mm(gc) sob --gc=wasmgc: rebaixa pra linear. Wasm GC dá referências gerenciadas opacas, não endereços, e o host pode mover o objeto, então não dá para tirar um *T cru pra dentro dele (a regra de ponteiro da seção 6 precisa de um lugar addressable). Em vez de proibir, o objeto do qual se tira & cai pra linear memory (volta a ser seu-coletor-sobre-linear só para aquele objeto). Consequência: --gc=wasmgc nunca muda a semântica, só o backing; o mesmo fonte compila e roda com ou sem o flag. A demoção é estática e diagnosticável: o compilador sabe quais @mm(gc) viram ponteiro e pode apontar o & culpado. O ganho de bundle escala com pureza-de-ponteiro: zero & em objetos gc zero coletor embarcado.

Suporte e fallback. --gc=wasmgc produz um módulo que exige engine com Wasm GC, escolha por-build, não um bundle duplo que decide no load. Para Safari antigo ou runtime de servidor sem GC, não passe o flag: o build linear roda em todo lugar. O Wasm GC está nos browsers modernos (Chrome 119, Firefox 120, Safari 18.2) mas ainda amadurece, então os dois lowerings ficam na caixa, e o compilador exige o engine certo conforme o flag.

Como o coletor acha suas raízes: shadow stack, stackmaps e o híbrido

Seção intitulada “Como o coletor acha suas raízes: shadow stack, stackmaps e o híbrido”

Um coletor preciso precisa saber, numa coleta, exatamente quais ponteiros vivos um processo em execução segura — suas raízes — para marcar o que elas alcançam e varrer o resto. Há duas formas honestas de achá-las, e a linguagem entrega as duas mais a união delas, selecionadas com --gc-roots. A escolha é um trade de custo/cobertura que a precisão do coletor nunca dobra: qualquer que seja o modo, um objeto vivo nunca é varrido e um ponteiro morto nunca é seguido.

  • Shadow stack (--gc-roots=shadow, o padrão). O compilador roota todo ponteiro gc explicitamente: cada função, na entrada, empurra um pequeno frame numa lista encadeada por-processo — um slot por ponteiro-gc local — e o desempilha no retorno. Numa coleta o coletor caminha essa lista e marca *slot para cada slot. É portável (sem desenrolamento de stack específico do alvo), funciona em todo backend incluindo wasm, e roota ponteiros de memória tão naturalmente quanto os de registrador — um ponteiro gc dentro de um agregado na stack é só mais um slot. Seu custo é o push/pop e os stores de slot, pagos em toda chamada haja coleta ou não.
  • Stackmaps (--gc-roots=stackmaps, só host). Em vez de o programa fazer a própria contabilidade de raízes, o backend registra onde os ponteiros gc vivem em cada safepoint (os statepoint/.llvm_stackmaps do LLVM), e numa coleta o runtime caminha a stack nativa (DWARF CFI) para lê-los. Nada é empilhado ou desempilhado no caminho quente — as raízes só são recuperadas quando uma coleta de fato roda — então código chamada-intensivo que raramente coleta paga menos. O preço é precisar que os ponteiros gc sejam promovidos a valores SSA que o statepoint captura (sroa+mem2reg), o que a regra sem &local da linguagem torna verdade para escalares locais comuns, e precisar de desenrolamento de stack real, então é só-host por ora (o alvo wasm de linear memory mantém o shadow stack).
  • O híbrido (o que --gc-roots=stackmaps de fato roda). Um ponteiro gc aninhado num agregado passado por valor fica em memória no callee (a ABI o entrega by-pointer/byval), invisível ao stackmap. Então o modo stackmaps não é fonte-única: ele toma a união de dois conjuntos de raízes complementares — as raízes escalares promovidas do walk do .llvm_stackmaps, e as raízes residuais aninhadas-em-agregado (mais todo @pin) de um shadow stack enxuto que roota só essas. Nenhum sozinho é completo; juntos cobrem todo o conjunto vivo sem resíduo, então não há programa que o modo tenha que recusar.

O padrão é o shadow stack porque é o que funciona em todo alvo sem maquinaria de desenrolamento; stackmaps é o opt-in para builds host que querem as chamadas mais baratas. Ambos são precisos; diferem só em onde a contabilidade vive (no programa vs no metadado do backend) e quando é paga (toda chamada vs só numa coleta).

O coletor padrão é mark-sweep: marca o conjunto vivo a partir das raízes e recupera o resto no lugar, nunca movendo um objeto, então um *T cru para dentro de um objeto gc continua válido através de uma coleta (a regra de ponteiro da seção 6 vale de graça). O coletor moving/compacting opt-in (--gc-collector=moving) desliza os sobreviventes de cada classe-de-tamanho juntos para cortar fragmentação e acelerar bump-allocation, ao custo de ter que corrigir todo ponteiro para um objeto movido — o que o mesmo trace derivado-pelo-compilador provê (um trace espelho que reescreve endereços de campo), e o que a maquinaria de raízes reescreve para as raízes. Um objeto que o C segura por ponteiro cru, ou que não pode mover por qualquer razão, é marcado @pin (seção 23): o coletor exclui seu span da compactação, então seu endereço fica estável enquanto o pin é mantido. Moving casa com o shadow stack (corrige os slots de shadow diretamente); combiná-lo com stackmaps fonte-única é recusado em tempo de compilação, porque um gc.relocate do statepoint entregaria o ponteiro não-relocado.

O núcleo da linguagem é seguro. Mas existe uma pequena família de escape-hatches (ponteiro cru da seção 6, FFI com C, free manual de arena/none, e union de reinterpretação de bytes) onde as garantias do compilador precisam ser suspensas. Em vez de uma regra solta por caso, todos compartilham uma marca, unsafe, e um modelo único de contenção.

enum e union resolvem coisas diferentes, não são dois sabores do mesmo. enum é “uma de N variantes, e o programa sabe qual” (tagged, seguro; seção 2). union é “os mesmos bytes lidos de N formas, e você sabe qual” (untagged, unsafe). union não é “enum inseguro”: é uma ferramenta de reinterpretação de memória, para FFI (struct C que é union) e bit-tricks (ler um float como seus bits).

A sintaxe reusa a forma de campos (a do produto) como molde, com semântica de sobreposição:

unsafe union FloatBits {
f: f32
bits: u32
}

Num produto, os campos são lado a lado (tamanho = soma). Num union, são sobrepostos: tamanho = o do maior campo, e todos começam no mesmo endereço. Acesso a campo (leitura ou escrita) é unsafe; escrever um campo e ler outro reinterpreta os bytes, e a leitura reinterpretadora carrega o assume que documenta a intenção (ver “Contenção”, abaixo):

var x: FloatBits
y := unsafe {
x.f = 1.5 // escreve 4 bytes como float
return x.bits // lê os MESMOS 4 bytes como u32 → o padrão IEEE de 1.5
} assume "reinterpretação intencional float→bits"

O que o torna perigoso: não há discriminante. O union não guarda “qual campo está ativo”. Escrever f e ler bits é intencional (é o ponto). Mas escrever f e ler um campo ptr: *Node que também esteja no union fabrica um ponteiro a partir de bits de float: lixo, provável crash. O compilador não impede, porque não sabe qual campo está ativo. É exatamente por isso que é unsafe.

“E se a union soubesse o campo ativo?” Então você reinventou o enum. Carregar “qual campo está ativo” através de fronteiras e do tempo exige um discriminante, e union-com-discriminante é a definição de enum. Você pagaria o custo de memória do tag e o de runtime do check, perdendo as duas únicas coisas que justificam o union (zero overhead de memória, zero check). A escolha “quero rastrear o campo ativo?” já tem resposta: chama-se enum. Não é um terceiro construto: é union (cru, você cuida) vs enum (tagged, seguro), a mesma dualidade de @mm(none) vs gc.

unsafe é cidadão de primeira classe: um modificador que compõe na frente de bloco, expressão, fn e union, igual comptime/pub/@generator compõem (Princípio 3, seção 14):

unsafe expr // operação única (unsafe x.bits, unsafe raw_read(p))
unsafe { ...vários... } // bloco para um grupo de operações
unsafe fn risky() {} // a função inteira é contexto unsafe, e sinaliza ao chamador
unsafe union Bits {} // declaração marcada

unsafe expr para uma operação, unsafe { } para um grupo: mesma lógica de forma-curta/bloco do loop/match. É o lugar único onde as garantias estão suspensas: deref de ponteiro cru, acesso a union, FFI, e o free manual de arena com ponteiro vivo (o caso unsafe do @promote, acima). Um conceito que amarra a família escape-hatch inteira.

Contrato de fronteira: @requires e a pós-condição (design by contract)

Seção intitulada “Contrato de fronteira: @requires e a pós-condição (design by contract)”

A contenção do perigo acontece em dois níveis: o contrato na fronteira da função, que torna a função segura de chamar, e o abate da operação no corpo (próxima subseção). O primeiro é design-by-contract, e é a camada que constrói abstrações seguras sobre primitivas unsafe.

@requires(cond) declara uma pré-condição na função. É uma diretiva (sigil @, como @mm/@promote), não keyword: contrato configura o compilador, então cabe no sigil, a custo de zero keyword nova. O compilador a enforça em cada chamada: prova estaticamente onde consegue (custo zero) e insere check de runtime com panic determinístico onde não consegue. Múltiplas pré-condições empilham, uma por linha, e o compilador diz qual falhou, melhor que um &&:

@requires(i < buf.len)
@requires(buf.len > 0)
fn at(buf: Buffer, i: usize) -> u8 { ... }
b := at(buf, 3) // o compilador checa as duas condições AQUI, na chamada

O ponto central: uma pré-condição checável, declarada como contrato e enforçada pelo compilador, torna a função segura, não unsafe. O chamador não re-declara a condição (o contrato é checado automaticamente), e at é fn, não unsafe fn. Uma função é segura sse toda pré-condição é (a) checável-e-contratada via @requires, ou (b) garantida por invariante de tipo. Se sobra um resíduo inexprimível (caso B, validade de ponteiro cru, liveness de FFI), a função continua unsafe fn, e esse resíduo é abatido no corpo ou repassado.

A pós-condição (o ensures do contrato) tem duas formas equivalentes; você escolhe a que lê melhor:

  • No tipo de retorno (tersa): a condição referencia o retorno pelo próprio tipo, e é a forma do retorno único (-> u8 == buf.bytes[i]); múltiplas condições sobre esse retorno se separam por ,. Multi-retorno usa a diretiva (abaixo); a tersa não tenta espremê-lo na assinatura.
  • Diretiva @ensures(cond) (explícita): fora da assinatura, e o jeito natural com muitas condições ou muitos retornos. Empilha igual ao @requires (uma por linha, e o compilador diz qual falhou).

Em ambas, o retorno é referenciado pelo tipo (o tipo é o slot), o que basta enquanto cada tipo de retorno é único. Quando um tipo se repete (-> (u8, u8)) e há @ensures falando dele, o tipo deixa de desambiguar; aí, e só aí, o retorno ganha um nome:

// tersa, tipo como slot (sem nome, o caso comum):
@requires(i < buf.len)
fn at(buf: Buffer, i: usize) -> u8 == buf.bytes[i]
{ return buf.bytes[i] }
// diretiva, empilhando (tipos distintos, sem nome):
@requires(i < buf.len)
@ensures(u8 > 0) // condição sobre o retorno 'u8'
@ensures(Node.count > 0) // condição sobre o outro retorno, 'Node'
fn lookup(...) -> (u8, Node) { ... }
// colisão de tipo COM contrato → named return (obrigatório só aqui):
@ensures(lo > 0)
@ensures(lo < 100) // múltiplas condições pro mesmo retorno = múltiplas linhas
@ensures(hi > 50)
fn split(...) -> (lo: u8, hi: u8)
{ return (a, b) } // 'lo'/'hi' são rótulos do slot, não variáveis: o return é livre

Named return é rótulo do contrato, não variável (Modelo 1). O nome aponta o slot pra o @ensures poder falar dele; não cria variável no corpo, e o return continua livre (return (a, b), qualquer expressão; o compilador casa por posição). É opcional na forma tersa (use se quiser ser explícito) e obrigatório só na colisão-de-tipo-com-contrato acima. Quem não cai nesse caso nunca escreve um nome de retorno: não vaza (teste do opt-in, seção 1).

Checada em cada return, estática onde prova, panic runtime onde não. Reuso de contrato sai de graça: a condição é uma expressão booleana, e uma chamada de função é uma expressão booleana, então um predicado reusável é uma função booleana comum (@requires(in_bounds(i, buf.len))), sem construto novo. Não há um contract dedicado: ele não passaria no teste do opt-in (seção 1), porque todo leitor teria que aprendê-lo pelo ganho raro de nomear um contrato, enquanto a função booleana faz o mesmo com peças que já existem.

E isto não contradiz “assert nunca no header” (próxima subseção): @requires/pós-condição são contratos, que moram na assinatura em toda linguagem com DbC (Eiffel, Ada, Dafny); assert é abate de operação, que mora no corpo. São níveis diferentes (fronteira vs operação), e é por isso que o @requires na chamada subsume o assert (cond) que o chamador teria que escrever: o contrato já foi checado na fronteira. Honestidade: pós-condições de segurança às vezes são caso-B inexprimíveis (o as_bytes_mut do Rust, “o caller tem que manter o slice válido depois”); essas caem no assume. A pós-condição checável é pro grosso (resultado em range, não-nulo, ordenado).

Contenção: todo perigo é abatido ou repassado, explicitamente

Seção intitulada “Contenção: todo perigo é abatido ou repassado, explicitamente”

unsafe não é uma região de permissão onde o perigo é livre e silencioso (o modelo Rust). É uma obrigação de tratamento, análoga ao catch: você não pode só fazer a operação perigosa e seguir como se nada fosse; tem que declarar como o perigo foi neutralizado, ou repassá-lo explicitamente. O perigo deixa de ser “permitido numa região” e vira “uma dívida que alguém paga”. É o Princípio 1 (o perigo é sempre marcado) levado ao extremo: não basta marcar que é perigoso, você mostra o abate.

Toda operação unsafe exige um lidador, e há três:

Lidador Quando Semântica
assert (cond) há uma pré-condição checável (ponteiro válido, índice em range) forte (default): runtime checa; se falsa panic, não UB. É o mesmo trato do or_panic: troca comportamento indefinido por falha limpa.
assume "razão" o perigo é intencional e não-checável (reinterpretar bytes) confia (fallback): sem check, sem UB; você assume a responsabilidade. “Seguro por este motivo; confie.”
repassar você não abate a função vira unsafe fn; o chamador lida.

São duas keywords, uma intenção cada: assert (cond) checa (runtime verifica, panic se falsa); assume "razão" confia (não checa, e nunca licencia UB, o compilador não otimiza em cima da afirmação, você só assume a responsabilidade). A separação é deliberada: uma keyword só, ora checando ora não, borraria a linha mais importante do unsafe, “o compilador garante” vs “é promessa humana”. A keyword grita qual dos dois é, em vez de o leitor reparar paren-vs-aspas. (assert é o caso comum, checável; assume é o fallback, pro que não dá pra checar.)

// FORTE (default): assert + condição → runtime checa, panic se falsa
b := unsafe raw_read(buf.ptr + i) assert (i < buf.len)
// CONFIA (fallback): assume + razão textual → você assume, sem check
y := unsafe x.bits assume "reinterpretação intencional float→bits"

assert (e seu par assume) espelham o catch visualmente (são trailers da operação), mas têm semântica própria e não reusam catch: catch é reativo, lida com um valor de erro que já aconteceu (|e| é o erro concreto, você inspeciona e reage). assert é preventivo, a pré-condição é checada antes, e o perigo nunca vira valor. São tempos opostos; fundir os dois borraria a diferença, e não há “erro de unsafe” para descartar com |_|. Mesma cara estrutural (trailer = “lida com o que a operação acima levanta”), conceitos distintos.

O assume encolhe até o irredutível, e é isso que o impede de virar assume "" decorativo. A razão tem que ser não-vazia e significativa (assume "" é erro). Mais: o compilador rejeita o assume onde a operação é provavelmente-segura ou checável. Uma reinterpretação de mesma largura sem ponteiro (fbits, ambos 4 bytes escalares) é memory-safe por construção e o compilador a prova, sem exigir assert nenhum; bounds e validade checável viram @requires/assert (cond). Sobra o assume só para o genuinamente opaco (proveniência de ponteiro, liveness de FFI), onde não existe operação a checar (se existisse, seria caso A). Você não consegue forçar uma checagem onde não há uma: isso é a fronteira do unsafe. O que resta é a superfície de auditoria, o conjunto pequeno, indexável e não-vazio de “aqui a segurança repousa em julgamento humano”, onde o review foca.

A razão é um artefato de compile-time, então pode ser qualquer valor que o compilador consiga renderizar em compile-time — não só um literal de string. Um const de razão reusado em vários sites, uma comptime fn que monta a string a partir do tipo que está descarregando, uma constante de enum ou error com Display: o compilador dobra cada um para o seu texto onde o assume foi escrito, exatamente como se você tivesse digitado o literal ali. Isso mantém a superfície de auditoria intacta — a ferramenta de review resolve a razão estaticamente e um grep ainda cai numa string legível — enquanto deixa um projeto nomear suas justificativas recorrentes uma vez só. O que ele recusa é um valor de runtime (um var, um error computado): o assume não faz check em runtime, então uma razão que ele só saberia em tempo de execução não tem onde ser usada e, pior, não dá pra ler da fonte, o que anula o propósito inteiro. A regra é uma linha: a razão tem que ser comptime-conhecida. (Isso compõe com os contratos comptime da seção 14 — a mesma comptime fn que prova um predicado também pode nomear a razão.) Quando você quer um payload de falha em vez de uma razão estática — retornar um error específico se uma condição não valer — isso não é o assume; é o assert (cond) else return MyError de sempre: o check existe, então é trabalho do assert, e o error viaja no return normal (não há keyword raise; o caminho de erro é um valor como qualquer outro).

Predicados comptime: provando um contrato em tempo de compilação

Seção intitulada “Predicados comptime: provando um contrato em tempo de compilação”

Um predicado de contrato é “uma função ordinária” (acima) — e uma função pode ser uma comptime fn (seção 10). Permitir uma em @requires, na pós-condição, no assert e no assume é o passo natural seguinte, e muda quando o contrato é decidido. comptime roda no interpretador do próprio compilador, então um predicado comptime pode carregar lógica mais rica que uma cadeia booleana chata — inspecionar um tipo via reflect(T), computar sobre constantes, checar uma invariante de layout — e o compilador o avalia durante a compilação:

comptime fn is_ieee_reinterpret[A, B]() -> bool {
// mais rico que um && booleano: reflete sobre os dois tipos, checa que as larguras de bits batem, etc.
return size_of[A]() == size_of[B]() && reflect(A).is_float && reflect(B).is_unsigned_int
}
unsafe union FloatBits { f: f32; bits: u32 }
y := unsafe x.bits assert (is_ieee_reinterpret[f32, u32]()) // PROVADO em tempo de compilação

O modelo de avaliação é descarga estática com fallback runtime, decidido por se os argumentos do predicado são conhecidos em compile-time:

  • Todos os argumentos comptime-conhecidos o predicado folda em compile-time. Se folda para true, o contrato é descarregado de graça — nenhum check em runtime é emitido. É isso que promove um assume a um assert provado: onde você escrevia assume "float→bits" (uma promessa humana, parte da superfície de auditoria), agora escreve assert (is_ieee_reinterpret[f32, u32]()) e o compilador verifica a promessa, tirando aquela linha da superfície de auditoria por inteiro. Se folda para false, é erro de compilação — o contrato é provadamente violado, pego antes do programa rodar.
  • Algum argumento é valor de runtime o predicado lowera para um check em runtime, exatamente como um assert (cond) ordinário: verificado no ponto, panic se falso. O corpo comptime ainda dá a lógica mais rica; só as entradas dele serem runtime empurram o veredito para runtime. (Isso cai naturalmente: uma comptime fn folda quando suas entradas são constantes e roda como código ordinário quando não são.)

O retorno é um bool, ou um valor que o compilador reduz a um em tempo de compilação por uma regra definida e dois-valorada: um Optional (some true, none false, a regra de presença) ou um Result (Ok true, Err false, a regra de sucesso). Isso não é truthiness de runtime — a linguagem não tem, e if/assert continuam exigindo bool estrito (seção 14) — é uma redução de constante de um veredito foldado em comptime: o predicado rodou no compilador e produziu um some/none/Ok/Err concreto, que é inequivocamente um sim ou um não. Um resultado três-valorado deliberadamente não é redutível: Ordering (Less/Equal/Greater) e o Indeterminate da ball (seção 14) não têm um único caso “true”, então um predicado que quer comparar retorna o bool da comparação (a.cmp(b) == Equal), nunca um Ordering cru — um contrato é sim/não, e contrabandear um talvez para dentro dele é o booleano-três-valorado que a linguagem rejeita de propósito. No caminho de fallback runtime o predicado precisa ser um bool puro (não há constante de compile-time a reduzir, e nem truthiness de runtime pra se apoiar).

Isso mantém a superfície de auditoria encolhendo na direção certa: todo assume que um predicado comptime consegue provar vira um assert que o compilador checa, então o que sobra em assume é só o genuinamente improvável — proveniência de ponteiro, liveness de FFI — que nenhuma avaliação em compile-time alcança.

assert/assume moram na operação, nunca no header

Seção intitulada “assert/assume moram na operação, nunca no header”

assert não vai na assinatura da função. Os outros modificadores (pub/unsafe/comptime) são declarações simples, uma palavra que liga um bit. assert (cond) carrega uma expressão, lógica de contenção. Botá-lo no header misturaria a assinatura (o contrato de tipos) com a implementação (a condição checada), uma quebra de camada que os outros não cometem. E há uma incoerência mais funda: a contenção acontece dentro do corpo, onde a operação unsafe é abatida, não na fronteira. Pôr assert no header seria anunciar na porta uma coisa que acontece na cozinha.

Então: a contenção é uma propriedade que emerge do corpo, não uma marca da assinatura. Uma função é unsafe fn (repassa) ou fn normal (safe), e ela ser safe apesar de conter operações unsafe é consequência de ter abatido todas as operações unsafe no corpo com assert/assume, não de uma palavra no header:

// SAFE: sem 'unsafe' no header. É safe porque abateu o perigo no CORPO.
fn read_at(buf: Buffer, i: usize) -> u8 {
return unsafe raw_read(buf.ptr + i) assert (i < buf.len) // contenção AQUI
}
read_at(b, 3) // chamador não vê unsafe
// UNSAFE: repassa. Tem 'unsafe' no header porque NÃO abateu, deixou subir.
unsafe fn raw_at(buf: Buffer, i: usize) -> u8 {
return unsafe raw_read(buf.ptr + i) // sem lidador → perigo sobe → header unsafe
}
_ := unsafe raw_at(b, 3) assert (3 < b.len) // descarta o u8 com '_'; abate no call site

A regra do compilador: se o corpo tem uma operação unsafe sem assert, a função exige unsafe fn no header (ou é erro). Se todas foram abatidas com assert/assume, a função é fn normal. O unsafe fn não é “uma cor a mais que você escolhe”: é a consequência forçada de deixar perigo não-abatido no corpo. Você não decide pô-lo; o compilador o exige.

Isso colapsa a aparente explosão de “tipos de função”. Os modificadores reais são quatro bits ortogonais, cada um uma palavra simples, e assert/assume não estão entre eles:

pub? comptime? @generator? unsafe? fn

pub unsafe fn não é um tipo novo de função: é fn com o subconjunto de marcas ligado, produto cartesiano de ortogonais (Princípio 3), não coloring. E assert/assume aparecem em dois lugares, ambos “operação”, nunca “assinatura”: abatendo uma operação intrínseca (unsafe x.bits assume "...") ou uma chamada a unsafe fn (unsafe raw_at(b, 3) assert (3 < b.len)). Zero header envolvido.

O bloco unsafe: escopo, valores e múltiplos asserts

Seção intitulada “O bloco unsafe: escopo, valores e múltiplos asserts”

unsafe { } não é uma região de permissão (Rust): é um escopo-expressão que agrupa operações, e cada operação perigosa dentro dele é abatida individualmente. Isso responde a um punhado de perguntas que parecem separadas mas têm a mesma raiz: onde o assert está determina o que ele pode falar.

O bloco lê variáveis de fora (escopo léxico normal; passar valores pra dentro é só referenciá-los, não há sandbox isolado) e retornar valor pra fora é por return explícito, nunca implícito (um bloco sem return não vale nada); como o unsafe {} aqui está em posição de expressão (result := unsafe {...}), o return entrega o valor a quem o recebe, em vez de sair da função. Cada operação unsafe leva o seu assert, e há múltiplos asserts por bloco: um por operação, ou um trailer compartilhado para operações que dividem a mesma pré-condição:

result := unsafe {
a := raw_read(p) assert (p_valid) // pré-condição DESTA leitura
base := a * stride // variável criada DENTRO do bloco
b := raw_read(q + base) assert (base < len) // assert posterior enxerga 'base'
return a + b // valor sai por return (expressão → vai pro 'result')
}

A regra que amarra tudo: um assert é a pré-condição da operação que ele abate, checado antes dela. Daí três consequências:

  • Uma variável criada dentro do bloco pode ser falada por um assert que vem depois dela (já existe, é o caso de base acima), mas não pelo trailer-de-entrada (unsafe { … } assert (c)), que é a pré-condição de entrada, checada antes do bloco, quando o que está dentro ainda não nasceu.
  • Os valores que entram numa operação são o sujeito do seu assert. O resultado dela, não: o assert roda antes, então não fala do próprio retorno. Garantia sobre um resultado é pós-condição, não assert: o assert cobre a entrada (antes), a pós-condição cobre o retorno (depois), os dois tempos que uma operação tem.
  • O trailer compartilhado (unsafe { … } assert (c)) é a pré-condição de entrada válida para todas as operações do bloco, e convive com asserts pontuais onde a pré-condição de uma operação difere das demais.

Por que unsafe é o único coloring que a linguagem aceita

Seção intitulada “Por que unsafe é o único coloring que a linguagem aceita”

O contágio do unsafe (“chamar unsafe fn exige contexto unsafe”) é coloring, com a mesma estrutura de async infectando o call stack: a propriedade sobe a árvore de chamadas. Negar isso seria desonesto. A pergunta certa não é “como evito?”, e sim “por que matamos o coloring de async/MM mas aceitamos (até queremos) o de unsafe?”. A resposta é uma distinção que separa coloring bom de ruim:

Coloring de implementação (ruído, mate) vs. coloring de contrato (informação de segurança, preserve).

  • async colore como a função roda por baixo (suspende ou não). É detalhe de implementação: você não deveria ter que saber se um read_file usa epoll. A cor vaza interno e te obriga a propagá-lo.
  • MM colore qual estratégia gerencia a memória. De novo, implementação: o consumidor não devia se importar se um objeto é arena ou GC.
  • unsafe colore que a função tem pré-condições que o compilador não verifica e que você é obrigado a garantir na mão. Isso não é implementação, é o contrato. deref de ponteiro cru te dá use-after-free se você não garantir validade, e só você, no seu contexto, consegue garantir. A cor não é detalhe que vaza; é uma obrigação que tem que chegar em você, senão você assume um risco sem saber. Suprimir o contágio não removeria ruído; esconderia perigo. Seria como tirar o aviso de “frágil” da caixa porque etiqueta incomoda.

E há a diferença operacional decisiva: async é incontível (sobe até main, sem botão de parada); unsafe é contível. Qualquer função que cumpra as pré-condições absorve o perigo e para a propagação ali, expondo interface segura por cima, é exatamente o que read_at faz acima (abate com assert, e ela mesma é fn safe). O contágio sobe só até a primeira camada que encapsula o perigo com segurança, normalmente bem fundo (a função do bit-trick, a que fala com C). Na prática, unsafe vive em ilhas pequenas com fronteiras seguras, não num call stack inteiro pintado. É o Princípio 1 operando transitivamente, com um ponto de contenção onde alguém prova que as pré-condições valem, e esse “ponto de contenção” é o conceito-chave.

Um union com campo-ponteiro é opaco para o GC. O trace derivado (acima) funciona porque o compilador sabe quais campos são ponteiros e os visita; num union, ele não sabe se o campo-ponteiro está ativo, pode estar sobreposto com bits de float. Se o GC tracejasse esse campo, leria bits-de-float como ponteiro corrupção do coletor. A regra:

union sem ponteiros (puros escalares, f/bits) tracejável trivialmente (o trace derivado é “não visite nada”), tudo bem em qualquer MM. union com ponteiro fora do GC: tem que viver em @mm(none)/arena, e os ponteiros lá dentro são responsabilidade sua. Erro de compilação se alocado sob @mm(gc).

A justificativa é cirúrgica: saber o campo ativo é possível localmente (análise de fluxo, em compilação; o compilador rastreia “qual foi a última escrita” dentro de um unsafe linear, a mesma análise do var nunca-mutado e do @transfer, e pode avisar “você escreveu f e leu ptr, tem certeza?”) mas impossível globalmente (heap, runtime; depende de uma escrita em outro escopo/instante). O GC opera sempre no caso global (traceja o heap muito depois, sem contexto da última escrita). Logo o conhecimento de fluxo melhora os diagnósticos locais, não a tracejabilidade; a fronteira do GC se mantém. Localmente esperto, globalmente cauteloso.