Linguagem de programação Jam
(rapha.land)- Jam é uma linguagem ainda em fase pré-v1.0 que busca combinar segurança, baixa curva de aprendizado e alto desempenho, mantendo a sensação de uso imediato de linguagens da família C, sem GC
- O núcleo é semântica de valor mutável e um sistema de drop ao estilo Rust, em que o compilador cuida de ownership, borrow e limpeza automática sem expor referências ou sintaxe de lifetime no código do usuário
- O modelo de inicialização evita tanto
undefinedquanto zero-inicialização implícita, e trata inicialização tardia e out-parameters comMaybe(T)e análise deunsafeAssumeInit() exportexpõe funções Jam com ABI C e structs Jam são projetadas para ter layout compatível com C, reduzindo a necessidade de shimsunsafeseparados ou anotaçõesrepr- O compilador ainda está em fase de bootstrap, implementado em C++, não é público, e há planos de abri-lo como open source após criar 108 projetos distintos em Jam
A posição de linguagem que Jam pretende ocupar
- Jam ainda está antes da v1.0 e, embora os mecanismos descritos já funcionem no compilador, detalhes podem mudar antes da estabilização
- O objetivo é criar uma linguagem segura que mantenha uma sensação de família C fácil de entender de imediato, como Go, Zig e C moderno, mas reduzindo classes de bugs do C
- O eixo central do design é composto por dois pontos
- Mutable value semantics de Racordon, Abrahams et al. 2022
- drop system do Rust
- A motivação parte da ideia de que equipes reais têm níveis mistos de experiência e membros menos experientes tendem a cometer erros, então a linguagem deve bloquear mais falhas antes da code review
Diferenças em relação a Rust, Zig e C++
- Rust tem uma filosofia forte de segurança, mas a distância entre “conseguir usar Rust até certo ponto” e “ser produtivo em Rust” pode ser grande, tornando a curva de aprendizado um peso para equipes
- Zig oferece uma linguagem mais C-like, com superfície menor e modelo mental imediato, mas não é uma linguagem segura no nível da linguagem
- leitura de memória não inicializada, cleanup manual e prevenção de use-after-free não são forçados pelo próprio idioma
- grandes projetos de produção em Zig ou C++ dependem bastante de ferramentas de verificação como Valgrind, AddressSanitizer e fuzzing
- Na era da IA, considera-se que grande parte do código de produção é escrita ou rascunhada por ferramentas, e o gargalo sai da escrita de código para a code review
- o volume de código aumenta, enquanto a superfície de revisão fica estável, então o compilador precisa capturar mais bugs
Sistema automático de drop
- Bindings em Jam possuem valores, e quando um binding de um tipo com drop sai do escopo, o compilador sintetiza a chamada de drop
- No exemplo do tipo
File, ele declarafn drop(self: mut File)e, emuseFile(), basta escreverconst f: File = { fd: 7 };- não há cleanup explícito,
defernem marcação do fim de lifetime - no LLVM IR, é gerado
call void @__drop_File(ptr %1)logo antes doret
- não há cleanup explícito,
- O nome com mangling
__drop_Fileevita colisões entre funções de drop de vários tipos no nível do LLVM self: mut Fileé reduzido para um parâmetro ponteiro, e o call site passa diretamente o endereço do binding- Em Zig, o mesmo cleanup exige
defer f.deinit()explícito- se essa linha for removida, a chamada de deinit também desaparece do IR
- vazamento de file descriptor acontece quando o programador esquece do cleanup
- O RAII de C++ também executa destrutores automaticamente no fim do escopo, mas Jam adota o modelo de drop mais simples do Rust
- a direção é evitar complexidades como rule of 0/3/5, virtual destructor, exceções em construtores, exceções em destrutores,
std::exit,std::abort,longjmpe signals - Jam mantém uma função de drop por tipo e a executa em toda saída de escopo
- a direção é evitar complexidades como rule of 0/3/5, virtual destructor, exceções em construtores, exceções em destrutores,
Inicialização e Maybe(T)
- Em Jam, não existe valor
undefined, e não é possível declarar um binding sem valor- todo
vareconstexige um inicializador real - structs são montadas calculando antes os valores dos campos, criando o literal da struct e então fazendo o binding
- todo
- Zig permite
var f: File = undefined; return f.fd;, o que em runtime pode ler lixo da stack- em modo Debug, há preenchimento com
0xaapara tornar o uso incorreto visível - em modo Release, o conteúdo vira bytes arbitrários
- em modo Debug, há preenchimento com
- Go zero-inicializa todo
varpara evitar leitura de lixo, mas isso tem custo mesmo para campos que serão sobrescritos logo em seguida - Jam evita tanto
undefinedquanto zero implícito - Para inicialização tardia e out-parameters, usa
Maybe(T)empty()cria um slot cujo conteúdo ainda não tem significadowrite()preenche o slotunsafeAssumeInit()extrai o valor
- Um lint acompanha se o slot foi escrito, e o analisador rejeita como erro de compilação qualquer chamada de
unsafeAssumeInit()cuja inicialização ele não consiga provar- o prefixo
unsafefica como âncora pesquisável por revisores humanos e por IA
- o prefixo
Saída de escopo, return, break e continue
- O compilador rastreia uma pilha de escopos de drop e empilha um novo escopo a cada fronteira de bloco léxico
- Quando um bloco termina ou antes de sair dele por um branch, o compilador emite o drop dos bindings daquele escopo
- bindings dentro de
if,else, braços dematch,whilee corpo deforsofrem drop no fim desse bloco - um
returndentro de bloco aninhado faz drop de todos os escopos ativos, do mais interno para o mais externo, antes doret breakecontinuefazem drop dos escopos abertos dentro do corpo do loop antes de ir para a saída do loop ou para a próxima iteração
- bindings dentro de
- No exemplo de nested break,
outersofre drop no fim da iteração 0, e no caminho dobreakda iteração 1 a ordem éinnere depoisouter
Modos de parâmetro e remoção de referências de primeira classe
- O fato de um binding sofrer drop numa chamada de função é determinado pelo modo do parâmetro
- O modo padrão é borrow somente leitura
- a callee lê o valor e o binding do caller permanece inicializado
- não ocorre drop no retorno da chamada
muté um borrow exclusivo de leitura e escrita- o binding do caller continua inicializado após a chamada
- Apenas
moveconsome o valor- a callee recebe a posse e faz drop no fim da própria execução
- o binding do caller vira Uninit após a chamada, e lê-lo gera erro de compilação
- Não há marcador no call site;
f(x)tem a mesma forma em todos os modos - Jam não possui tipo de referência de primeira classe
- não é possível guardar um borrow em variável, retorná-lo ou armazená-lo em campo de struct
- o borrow de parâmetro existe só durante o call frame e expira quando a chamada retorna
- não há necessidade de annotation de lifetime porque não existe lifetime para anexar
- A API de coleções também mantém formato de valor
v[i] = xvirav.setAt(i, x)let y = v[i]usa o getterv.at(i), que retorna o elemento como valor
- A checagem de exclusividade no call site verifica sobreposição de caminhos no conjunto de borrows gerado pelos argumentos
swap(p.x, p.y)é válido por serem subcaminhos disjuntosmoveX(p, p.x)gera erro porquepep.xse sobrepõem
ABI C e FFI
- A ABI nativa do Rust é instável, então ao cruzar limites de distribuição é preciso recodificar em forma C
- dereference de raw pointer é
unsafe - ownership é transferida manualmente com
Box::into_raweBox::from_raw - para passar struct por valor, é necessária annotation separada como
#[repr(C)] - ferramentas como
cbindgeneabi_stableexistem para reduzir esse trabalho manual na fronteira
- dereference de raw pointer é
- Jam considera que, por não ter referência de primeira classe, lifetime nem layout compactado por niche, um valor Jam permanece com formato de valor até o nível mais baixo
- structs Jam já são projetadas para ter layout compatível com C
exportexpõe funções Jam com nome simples, sem mangling, usando convenção de chamada Cexport fn counterAdd(c: mut Counter, n: i64) i64pode ser chamado em C comoint64_t counterAdd(Counter *c, int64_t n);- o parâmetro
mut Counteré reduzido paraCounter *apontando para armazenamento pertencente ao caller
- O corpo da função do lado Jam continua sendo Jam normal, então regras de drop, análise de inicialização e exclusividade no call site continuam valendo
- No caminho de entrada a partir de C, declara-se a assinatura C com
extern- funções
externseguem literalmente a ABI C - o mecanismo de modos de parâmetro não se aplica fora dessa fronteira
- buffers podem ser passados para C com raw pointers, e Jam não verifica o que o código C faz com eles
- funções
- O que Jam quer oferecer é manter o lado Jam safe by default, sem exigir uma API espelho
unsafeseparada ou uma camada de shim ao expor bibliotecas Jam via ABI C
Pattern matching
- O
matchde Jam tem formatoPattern Blocke não usa=>- o scrutinee usa parênteses, como em
match (opcode) _é o braço catch-all- os braços são avaliados de cima para baixo, com first-match sequencial e sem fallthrough implícito
- o scrutinee usa parênteses, como em
- O principal caso de uso citado é o dispatcher de opcode de um emulador de Game Boy
- com dispatch de 256 opcodes base e 256 opcodes prefixados
- Também há suporte a matching de payload de enum
- o padrão da variante faz match na tag e liga os campos do payload a variáveis locais novas dentro do braço
- o compilador verifica exhaustiveness sobre o conjunto de variantes
- se uma nova variante for adicionada, sites de
matchque não a tratem passam a falhar na compilação
matchtambém funciona como expressão- cada bloco de braço produz o valor da expressão final
- todos os braços devem produzir o mesmo tipo
- o match precisa ser exaustivo
- Internamente, todo
matché compilado por um pipeline de árvore de decisão baseado em Luc Maranget 2008- cascatas de literais inteiros podem ser reduzidas por
simplifycfgdo LLVM aswitche jump tables quando isso for lucrativo
- cascatas de literais inteiros podem ser reduzidas por
Design de tempo de compilação
- O pipeline de compilação do Rust passa por vários IRs e etapas de análise
tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code- resolução de traits é um problema de busca, borrow checking é análise de regiões em função inteira
- monomorphization aumenta o volume de código antes do LLVM
- O pipeline do Jam foi desenhado para ser mais curto
tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code- usa um único IR tipado, o JIR
- O JIR já nasce tipado no momento em que o AstGen o produz
- Jam entende que não possui comptime-as-values que force lowering não tipado
- posicionamento de drop, checagem de init-before-use e regra de exclusividade no call site são executados como passes locais de dataflow sobre o JIR
- Como há annotation de tipo em cada binding, considera-se menor o peso de inferência global de tipos e de busca aberta por traits
- AST e JIR são estruturas de dados planas
- nós pequenos de tamanho fixo são empacotados em arrays contíguos
- usa-se índice em vez de ponteiro, e payloads grandes ficam em pools laterais
- a ideia é fazer o compilador percorrer arrays amigáveis ao cache em vez de rastrear árvores alocadas no heap
- No backend, o LLVM domina o tempo de otimização em builds de release
- há plano de usar Cranelift em debug build e LLVM em release build
- Cranelift está no roadmap, mas ainda não foi concluído
- O compilador atual é uma implementação em C++ em fase de bootstrap da linguagem, e ainda não há benchmarks de tempo de build dignos de citação
- afirmações sobre tempo de compilação são alegações de design, não resultados medidos
Desempenho em runtime e exemplos
- O objetivo é que Jam alcance o desempenho de Rust e Zig
- Jam não tem GC, runtime de memória gerenciada nem header por alocação
- o codegen produz LLVM IR direto e sem rodeios
- Ainda não se considera que Jam tenha chegado ao nível de Rust e Zig
- Rust e Zig passaram muito tempo refinando intrinsics específicos por alvo na standard library, dicas de auto-vectorization, containers cientes de allocator, ajuste de hot paths e tuning de passes do LLVM
- Jam também precisará desse tipo de trabalho para fechar os últimos 10% a 30%
- Nas cargas medidas até agora, a diferença é vista não como “outra classe”, mas dentro de um pequeno fator constante
- Um demo de Tetris executado no terminal foi escrito em Jam
Plano de lançamento e trabalho restante
- Jam ainda não é público
- o compilador existe e funciona, mas ainda não teve lançamento mais amplo
- Para uso no dia a dia, o projeto está trabalhando em
- superfície estável
- gerenciador de pacotes
- LSP
- formatador
- o restante do tooling
- Ainda há temas que serão tratados em textos separados
- sistema de modos de parâmetro
- regra de exclusividade
- genéricos
- comptime do Jam
- standard library
- sistemas de allocator
- modelo de panic
- exploração de MLIR para pipeline de codegen de GPU
- trabalho de ABI de Rust para FFI
- Cranelift
- caminho para compilador self-hosted
- O plano de open source é tornar Jam público depois de criar 108 projetos distintos com a linguagem
- o número 108 é um marco arbitrário inspirado nas 108 Stars of Destiny de Suikoden 2
- por enquanto, o acesso foi dado a um pequeno grupo de usuários, com intenção de ampliar conforme o tooling evoluir
- O acesso antecipado pode ser solicitado pela lista beta em jamlang.org
1 comentários
Comentários no Lobste.rs
Esse tipo de texto gerado por LLM faz algo com que engenheiros, especialmente os mais jovens, deveriam tomar cuidado: substituir dados quantitativos por prosa qualitativa e plausível
Convencer por narrativa é mais fácil, tanto para quem escreve quanto para quem lê, do que reunir e analisar números sólidos. O cérebro humano gosta de histórias, e histórias funcionam melhor quando são simples e organizadas. Dados reais muitas vezes refletem um mundo complexo, cheio de nuances na medida em que você se dispõe a examiná-los
Basta comparar com este post quantitativo sobre profiling do compilador Rust, escrito por um contribuidor do rustc
Um bom texto técnico pode e deve conter ambos, quando apropriado, mas não pode deixar de lado aquilo que realmente precisa transmitir. Depois de comandar uma organização grande de trabalho de garantia, passei a entender como a escrita técnica é difícil, e precisamos ter cuidado com o quanto isso pode piorar à medida que a acessibilidade dos LLMs aumenta
A diferença central em relação ao Zig é que há
drope não há um componente específico e facilmente mal-usado, oundefined?Não há
undefinede todos os valores precisam ser inicializados, masMaybe(T).empty()retorna um valor cujo conteúdo “ainda não tem significado”, e se você chamarunsafeAssumeInit()logo em seguida, ele provavelmente vai devolver lixo. Então não é seguro no sentido de Rust, em que o compilador trataunsafecomo uma contaminação que exige umunsafe { .. }explícitoO exemplo que mostra segurança e o recurso
dropé este código:Se não entendi errado, isso não é inseguro? Mesmo deixando de lado a alocação manual do descritor de arquivo, ele chama
close(7)e depois retorna7. Como não há rastreamento de tempo de vida, o usuário não tem como expressar que o tempo de vida do descritor de arquivo terminou antes do retorno deuseFile()No exemplo de ABI, quando
export fn counterAdd(c: mut Counter, n: i64) i64 { .. }viraint64_t counterAdd(Counter *c, int64_t n);, como se expressa secpode ou não serNULL? Em Rust, há uma ABI definida para isso, e tantoextern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64quantoextern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64são possíveisA versão em Rust também não precisa de
unsafe. Dá para definir a API com referências. Ironicamente, o único lugar ondeunsafepoderia ser necessário no Rust moderno é algo como#[unsafe(no_mangle)], isto é,#[no_mangle]; mas, por algum motivo, o exemplo é montado para usar ponteiros brutos no lado do RustEste exemplo mais adiante também:
Não deveria haver um
unsafeem algum lugar aqui? Comosnprintfrecebe ponteiros brutos, seguindo a diretriz mencionada antes de que operaçõesunsafedevem ser encontráveis pelo nome, parece que deveria haver algo comounsafeSnprintfe uma redefinição de símbolo“Uma pista honesta: na linha
extern, você está falando com C, e as regras de C prevalecem”, hmm.as_raw_fd()do Rust, e ali existe o mesmo problema de segurançaIsso é um mal-entendido sobre a estabilidade de FFI da biblioteca padrão do Rust. Referências compartilhadas, referências mutáveis,
Boxe osOptiondeles todos têm ABI definida e estável. Portanto, todo o procedimentoBox::into_raw/from_rawdo exemplo é desnecessárioTempos de vida simplesmente não existem no nível binário. Se você opta por definir uma ABI estável para enums, a otimização de niche é desativada
O motivo de a maioria dos tipos não definir uma ABI estável é que muitas vezes você não quer uma ABI estável, porque isso impediria mudanças internas no tipo
Não entendo essa escolha. Há uma grande diferença entre “lançar” algo incompleto e simplesmente abrir o código-fonte. Se isso vai ser feito de qualquer forma mais tarde, qual seria o prejuízo de abrir enquanto o projeto está sendo construído?
A vantagem é que pessoas que gostam da direção podem experimentar por conta própria e talvez até contribuir. Claro, na “era da IA”, não está claro se essas contribuições seriam lucro líquido. Também permite que as pessoas entendam melhor o que está sendo construído e avaliem as afirmações sobre por que isso é excelente. Sem isso, o projeto fica muito menos interessante
Além disso, há pessoas que não usam nenhuma dessas ferramentas. Minha equipe atual nem consegue chegar a um acordo sobre adotar um formatador automático, mas no restante é ótima. Então adiar a abertura enquanto cria essas ferramentas não faz tanta diferença
As pessoas continuam tentando criar um “Rust sem lifetimes irritantes” e continuam falhando. Outro comentário abordou um dos modos de falha: o problema de retornar parte de um valor que sofreu
dropacontece porque não é possível retornar uma referência. Outro problema clássico é este:Há três respostas:
Há bons motivos para escolher qualquer uma das três, mas Jam parece querer ser a opção 1, como Rust, enquanto na prática parece ser a 2 por causa da semântica de valor. Se isso significa que tudo é copiado, é provável que impeça escrever estruturas de dados seguras e eficientes
Em especial, se você descarta o verificador de empréstimos, fica muito mais difícil dar suporte a tipos alocados na stack sem introduzir várias pistas. Por exemplo, copiar ao emprestar; tanto Inko quanto Swift fazem isso
Olhando a referência da linguagem, não há referências, mas há ponteiros
muteconst, e não encontrei nada sobre a segurança delesUm grande fator que torna Zig “Zig” é a ausência de RAII, e Rust é o verificador de empréstimos. Mas não entendo muito bem quem realmente precisa do ponto a que essas escolhas de design chegam: “RAII sem referências”
Ainda assim, acho que há espaço para experimentar nesse nicho, e vejo esse tipo de tentativa com bons olhos. Só acho que esta abordagem não é a certa
A direção em que venho pensando ultimamente é uma combinação do
comptimedo Zig, permissões de referência parecidas com as de Pony, lifetimes tratados como valores em tempo de compilação e o branding de lifetimes em allocatorsO que espero é adicionar segurança de referências à estratégia de allocators do Zig e obter lifetimes quase sem necessidade de anotação
Novas linguagens são boas, mas não gosto que tudo vire um frontend para LLVM. Sei que backends são difíceis, mas de vez em quando gostaria que houvesse outras opções
Soa quase como Swift