Goose: sistemas sem heap e o que o Rust pode aprender
Goose é uma linguagem de sistemas memory-safe sem heap, sem GC e sem anotações de lifetime. O que o design só com pilha revela sobre Rust e regiões.

Aqui vai uma afirmação que vai incomodar pelo menos duas comunidades de linguagens: o borrow checker foi um desvio. Não um erro — o modelo de ownership do Rust resolveu problemas reais e puxou a indústria inteira para frente —, mas um desvio em relação a uma ideia mais antiga e mais simples, que agora estamos redescobrindo. Essa ideia são as regiões: memória alocada em grandes arenas com comportamento de pilha, liberada de uma vez quando o escopo termina. A linguagem Goose, um novo projeto de linguagem de sistemas que está circulando esta semana, é a aposta moderna mais agressiva nessa ideia que já vi, e o design dela força uma pergunta incômoda: quanto do que aceitamos em C++ e Rust é realmente estrutural, e quanto é só o heap falando?
Goose não tem alocador de heap. Não tem garbage collector, nem reference counting, nem destrutores, nem anotações de lifetime, nem unsafe. Todo valor dinâmico vive inline em uma de algumas poucas pilhas de dados, que o compilador atribui estaticamente. Crescer é incrementar um ponteiro. Liberar uma estrutura aninhada com um milhão de elementos é um único store no topo da pilha. O autor relata benchmarks mais rápidos que C++ idiomático (3,3x) e Rust seguro (1,12x) usando menos memória — e a reação pública de John Carmack destacou exatamente a parte que mais me interessa: as referências relativas que transformam uma estrutura de dados no próprio formato de arquivo. Esteja ou não o Goose destinado a software de produção, ele é um experimento mental sério sobre como é uma linguagem de sistemas quando você remove o heap e se recusa a pedir desculpas por isso.
O modelo de memória é a linguagem inteira
A maioria dos recursos de linguagem é negociável. Sintaxe, sistemas de módulos, até o tratamento de erros — dá para imaginar duas linguagens razoáveis fazendo escolhas opostas. Modelos de memória não são assim. Escolha um e tudo que vem depois fica forçado: suas estruturas de dados, sua história de concorrência, sua FFI, suas mensagens de erro. O Goose entende isso e se inclina totalmente nessa direção.
A regra é simples de enunciar: um programa tem a pilha de chamadas nativa, dados estáticos e N pilhas de dados, onde o compilador descobre o valor de N. No máximo um valor redimensionável está vivo por pilha, e ele está sempre no topo. Essa invariante única traz um ganho impressionante. Como o valor que cresce nunca se move — não há nada acima dele que precisasse ser realocado —, as referências para um array em crescimento continuam válidas para sempre. Em C++, um std::vector<T> que realoca invalida todos os ponteiros e iteradores para ele, e é por isso que gerações de programadores C++ já se queimaram ao manter uma referência através de um push_back. O Rust seguro contorna isso tornando o problema inexpressável; você usaria índices u32 no lugar, pagando com verificações de limites e ergonomia. O Goose simplesmente deixa você manter a referência. As regras de tipo e de lifetime são checadas em tempo de compilação, com o compilador inferindo em que cada referência está “enraizada” e rejeitando as que poderiam sobreviver aos seus donos — mas sem anotações, sem regras de aliasing, sem exclusividade.
struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later
Se você passou um tempo com o borrow checker do Rust, isso parece um truque de mágica. Para onde foram os lifetimes? A resposta honesta: para dentro de uma restrição. O sistema de ownership do Rust consegue provar segurança para padrões de compartilhamento arbitrários — grafos, ciclos, estruturas mutáveis compartilhadas entre escopos. O Goose prova segurança para um conjunto bem mais estreito de padrões e simplesmente recusa o resto. Essa troca — expressividade por simplicidade — é a mais antiga do design de linguagens, e é o fio que vou puxar aqui. Se você leu nosso texto sobre o que soundness realmente significa no borrow checker do Rust, o Goose é o outro lado dessa moeda: soundness alcançada não por uma análise mais poderosa, mas por uma linguagem menor.
Tudo inline: layout de dados como recurso de primeira classe
A segunda grande ideia é que não existe um único tipo de array. O Goose tem uma família de arrays que diferem apenas no que acontece com o tamanho: fixo em tempo de compilação, congelado após a construção, crescível dentro de uma capacidade inline, apenas crescente, crescente e encolhível. Todos seguem o formato [metadados][elementos...] — contíguos, compactados, nunca um ponteiro para um bloco de elementos. Strings são apenas arrays de u8, então uma struct pode conter partes de tamanho variável posicionadas inline, na ordem de declaração. Um registro com um campo de string de tamanho variável e uma lista de subregistros de tamanho variável é uma única sequência de bytes, sem nenhuma indireção.
Os números divulgados chamam atenção: um registro de pedido com 29 bytes e zero alocações no Goose, contra 160 bytes e uma alocação usando std::string + std::vector<Item> em C++, ou 153 bytes e quatro alocações em Rust. Agora, uma correção justa surgiu quase imediatamente na discussão: o exemplo de tutorial do próprio Goose, “um vetor de strings”, ignora a small-string optimization, que todas as STLs mainstream implementam. Strings curtas em C++ não alocam. Isso não derruba o argumento — a SSO ajuda strings de até cerca de 15–22 bytes e não faz nada no caso geral de dados aninhados de tamanho variável —, mas é um lembrete de que tabelas de benchmark na página inicial de uma linguagem são marketing, mesmo quando a linguagem é boa. As perdas também estão lá, e o autor merece crédito por isso, o que é mais do que a maioria dos projetos publica.
Existe um custo real, e o Goose não esconde: um array de elementos de tamanho variável é sequencial. Você pode iterar sobre ele, mas não pode indexá-lo — o elemento k está onde os bytes caírem. Se você quer acesso aleatório, mantém um segundo array de referências como índice. Esse é o tipo de troca que pessoas de design orientado a dados fazem manualmente há décadas (é basicamente como funcionam formatos colunares e layouts estilo flatbuffers), mas aqui isso é o padrão, e sair dele dá trabalho extra. Se isso é um recurso ou uma camisa de força depende inteiramente dos seus padrões de acesso.
Referências relativas: sua estrutura de dados é o seu formato de arquivo
Minha ideia favorita no Goose é a referência relativa, e é justamente a que Carmack também destacou. Uma referência normal é um endereço de máquina: oito bytes, absolutos, sem sentido fora do processo. Uma referência relativa guarda o mesmo link como um offset de 1, 2 ou 4 bytes — medido a partir do próprio campo ou da base de um pool nomeado. Estruturas construídas com links autorrelativos são independentes de posição. Elas significam a mesma coisa onde quer que estejam na memória. Isso quer dizer que salvar uma estrutura de dados é um write(), e carregá-la é um read() mais uma etapa de verificação.
Essa última parte importa mais do que parece. A versão ingênua desse truque — fazer mmap de um arquivo cheio de ponteiros — é um desastre clássico de segurança. A versão do Goose valida: from_bytes verifica o framing, cada tag, cada tamanho, cada link antes de os bytes virarem um valor, então um arquivo corrompido ou hostil retorna false, não um ponteiro selvagem. Você ganha a velocidade de estruturas mapeadas com mmap com algo parecido com a segurança de um parser.
O enquadramento de Carmack estava exatamente certo: “‘Parsear’ dados de arquivo e de rede costuma ser 100x mais lento do que precisaria.” Quem já perfilou um caminho quente de desserialização sabe disso. A resposta da indústria até agora foram formatos zero-copy guiados por schema — Cap'n Proto, FlatBuffers —, que funcionam, mas acoplam um compilador de schema e uma biblioteca de runtime ao seu projeto. A resposta do Goose é que o layout em memória e o layout na rede são a mesma coisa por construção, porque a linguagem nunca teve ponteiros para endereços absolutos, para começo de conversa. O exemplo de árvore binária de busca é quase atrevido: nós de 12 bytes, com links incluídos, inserir é um push e um store, e a árvore inteira persiste simplesmente escrevendo seus bytes. Quem gosta de Lisp vai reconhecer o movimento filosófico — colapsar uma distinção que todo mundo achava fundamental —, o que faz parte de por que o Lisp ainda importa como referência de design sessenta anos depois.
Já estivemos aqui antes: regiões, arenas e o ML Kit
A resposta historicamente informada ao Goose apareceu na hora certa nos comentários: “Acho que me lembro de que o pessoal do ML seguiu por esse caminho. Acho que chamavam de regiões.” Exato. O gerenciamento de memória baseado em regiões remonta ao trabalho do ML Kit nos anos 1990 — a inferência de regiões de Tofte e Talpin permitiu que um compilador ML substituísse o garbage collection por regiões estáticas com comportamento de pilha, alocando e liberando regiões inteiras sem nenhuma contabilidade por objeto. E o veredito histórico honesto é que isso foi considerado um beco sem saída: a inferência de regiões funcionava, mas programas reais mostraram crescimento de memória patológico o suficiente — regiões mantendo dados mortos vivos por tempo demais — que a comunidade de pesquisa majoritariamente seguiu adiante. Os garbage collectors mantiveram a coroa.
Mas a ideia nunca morreu de fato; ela só mudou de função. Alocação em arena é prática padrão em engines de jogos, compiladores e servidores de alta performance. Os memory pools do Apache, as arenas por requisição em servidores web, o padrão de “resetar o buffer de scratch a cada frame” em game loops — tudo isso são regiões com outro nome, gerenciadas manualmente porque a linguagem não ajuda. O que o Goose faz de diferente em relação ao ML Kit vale ser dito com precisão: em vez de inferir regiões para uma linguagem funcional de propósito geral (difícil e imprevisível), ele expõe o modelo de pilha diretamente e torna a intenção do programador parte do tipo. T[>..] diz “isto cresce, as referências continuam válidas”. O trabalho do compilador é verificar uma disciplina declarada, não descobrir uma. Esse é um problema bem mais fácil, e é a mesma razão pela qual o Rust teve sucesso onde esquemas anteriores de lifetime automático tiveram dificuldades: programadores toleram um contrato que conseguem enxergar.
Há uma lição mais ampla aqui sobre como linguagens de produção absorvem pesquisa. Inferência de regiões como substituição totalmente automática do GC: fracassou. Regiões como padrão manual de sistemas: onipresentes. Borrow checking: um grande sucesso que, por sua vez, toma emprestado muito das ideias de regiões e de tipos lineares. Processos sem compartilhamento ao estilo Erlang: confirmados, e as threads do Goose são literalmente isso — workers compilados como programas separados, com memória própria, com valores cruzando filas tipadas como um memcpy, e data races não representáveis. Ninguém acerta o quadro completo na primeira tentativa. As ideias circulam por décadas antes de chegar a uma forma que funcione.
Onde o modelo deixa de funcionar
Eu disse que abriria com uma afirmação contraintuitiva e depois admitiria onde ela quebra. Aqui está a concessão. O borrow checker não é um desvio se os padrões de compartilhamento do seu programa são genuinamente arbitrários — e uma enorme classe de programas tem exatamente esse formato. A pergunta mais afiada na discussão sobre o Goose também foi a mais simples: como você constrói uma tabela hash que mapeia chaves para strings, quando o programa insere e remove strings aleatórias em momentos aleatórios? A remoção é o problema. Uma pilha apenas crescente não consegue reaproveitar um buraco no meio. Os contornos sugeridos — uma pilha por classe de tamanho, compactação periódica, um pool com freelist em que “liberar” um slot significa apenas que ele pode ser reutilizado — funcionam, e o design do pool é genuinamente engenhoso (um slot obsoleto lê um valor diferente do tipo correto em vez de corromper a memória). Mas cada um deles é o programador reimplementando à mão um pedaço de um gerenciador de memória, para aquela estrutura de dados específica.
Algo parecido com o uso de memória: sem GC, uma pilha que cresce mantém tudo o que um dia precisou ter até o fim do escopo. Programas de longa duração com tempos de vida irregulares — a questão do documento JSON levantada na thread, caches, estado de sessão — ou se encaixam no padrão de escopo disciplinado ou não se encaixam, e quando não se encaixam, nenhuma elegância salva você. O modo de falha do ML Kit (dados mortos presos em regiões vivas) não desaparece no Goose; ele se torna visível e manual, o que é discutivelmente melhor, mas continua ali. Um comentarista definiu a postura certa: o Goose é uma linguagem para um propósito restrito, e tudo bem. Cargas de trabalho orientadas a dados, compiladores, sistemas de entidades de jogos, qualquer coisa em formato de pipeline — ler, transformar, emitir, resetar — se encaixa muito bem. Um grafo de objetos mutável e de longa vida não se encaixa.
E mais um alerta sobre os números de destaque. Suítes de benchmark feitas pelo próprio autor são um gênero com modos de falha conhecidos: você compara o seu melhor Goose com o C++ “idiomático” de outra pessoa, e é na idiomaticidade que as brigas começam. O deslize com a SSO mostra que o código de comparação não foi ajustado de forma adversarial. O número de 1,12x sobre o Rust seguro é mais interessante do que o de 3,3x, justamente por ser pequeno — ele diz que o modelo de memória ganha em layout e na eliminação de alocações, não em mágica, e uma versão do Rust bem ajustada, usando arenas e índices, fecharia boa parte da diferença. Esse é, em si, o ponto: você pode escrever Rust baseado em arenas, mas a linguagem torna isso desajeitado, e o desajeitado é o que determina que código de fato é escrito.

O que as linguagens de produção realmente vão roubar do Goose
Quase ninguém vai escrever Goose de produção nos próximos anos — linguagens feitas por uma pessoa só, compilando por um único backend em C, não chegam lá rápido, e o próprio autor reconhece isso ao falar sobre o status do projeto. Essa não é a questão interessante. A questão interessante é quais ideias escapam para as linguagens que já usamos, porque é assim que isso sempre funciona. Minhas previsões, em ordem de confiança:
- Estruturas independentes de posição com carregamento verificado. Serialização zero-copy com uma etapa de segurança é boa demais para continuar de nicho. Espere versões integradas à linguagem daquilo que o FlatBuffers faz de forma desajeitada, especialmente conforme serviços intensivos em dados continuarem batendo no custo de parsing.
- Arrays como uma família, não como um tipo. “O que acontece com o tamanho?” é uma pergunta organizadora melhor do que “pilha ou heap?”. Fixo, congelado, com capacidade limitada, apenas crescente — essas distinções existem em todo codebase como convenções informais. Transformá-las em tipos é barato e pega bugs reais.
- Valores de função em tempo de compilação. O Goose trata valores de função como entidades de tempo de compilação, então
xs.filter() { it > 0 }compila para o loop que parece ser. Templates de C++ e a monomorfização do Rust já fazem isso; a diferença é que o Goose torna essa a única forma, eliminando por completo a armadilha de ponteiros de função em tempo de execução. - Retornos estaticamente verificados através de múltiplos frames.
return err from loadretorna de uma função N frames acima, verificado em tempo de compilação, sem unwinder, sem pirâmide deResult, sem?em cada chamada. Sistemas de efeitos estão rondando essa mesma ideia pelo outro lado. Uma delas vai chegar a uma linguagem mainstream nesta década.
O modelo de threads sem compartilhamento também merece menção, principalmente porque é a parte menos inovadora e a mais confirmada: são os processos do Erlang, são os isolates do Dart, é o modelo de atores sem a ideologia. O fato de ele continuar sendo reinventado mostra que o consenso da indústria sobre estado mutável compartilhado está resolvido na teoria e ignorado na prática. O Goose, pelo menos, torna impossível ignorar isso.
O argumento real: simplicidade pela restrição
Afastando-se um pouco, o design do Goose é um único movimento repetido: encolher o conjunto de programas representáveis até que a segurança fique barata de provar. Sem heap, não há ponteiros pendurados no heap. Pilhas apenas crescentes significam que referências não podem ser invalidadas. Threads com workers separados significam que race conditions não podem existir. Construção sem cópias significa que não há temporários acidentais. Cada restrição elimina uma categoria inteira de bugs e uma categoria inteira de maquinaria da linguagem — as regras de aliasing do borrow checker, o destrutor, o unwinder — que existia para gerenciá-los.
Isso é o argumento “less is exponentially more” de Rob Pike sem luvas, e cai na mesma tensão em que o Go de Pike caiu: restrições parecem libertação até que o seu programa seja justamente aquele que a restrição exclui, e aí parecem um muro. O Go ficou sem genéricos por uma década e sobreviveu; também excluiu muitos programas corretos. O Goose exclui bem mais. A razão para prestar atenção mesmo assim é que as antigas regras de simplicidade de Pike envelheceram bem justamente porque o orçamento de complexidade é real — cada garantia que uma linguagem oferece automaticamente é uma garantia que todo programador que a usa não precisa manter na cabeça.
Então: o borrow checker foi um desvio? Para programação de sistemas de propósito geral, não — a expressividade do Rust é genuinamente necessária para os casos de compartilhamento arbitrário, e o sucesso dele deu à indústria um vocabulário para falar sobre ownership. Mas e para a grande maioria silenciosa do código de sistemas que tem formato de pipeline, de arena, de requisição? O Goose é a prova de que poderíamos ter segurança de memória, velocidade e binários pequenos com uma fração da cerimônia — se estivéssemos dispostos a dizer “não” para mais programas. Design de linguagens é a arte de escolher quais programas proibir. O Goose proíbe outros do que estamos acostumados, e o resultado é o esboço de linguagem de sistemas mais interessante que li em anos. Não espero enviá-lo para produção. Espero continuar roubando dele.
A aposta do Goose é que uma linguagem pode proibir mais programas do que o Rust e ainda assim ser útil — os benchmarks sugerem que vale levá-la a sério, perdas incluídas.


