1 pontos por GN⁺ 3 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • 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 undefined quanto zero-inicialização implícita, e trata inicialização tardia e out-parameters com Maybe(T) e análise de unsafeAssumeInit()
  • export expõe funções Jam com ABI C e structs Jam são projetadas para ter layout compatível com C, reduzindo a necessidade de shims unsafe separados ou anotações repr
  • 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 declara fn drop(self: mut File) e, em useFile(), basta escrever const f: File = { fd: 7 };
    • não há cleanup explícito, defer nem marcação do fim de lifetime
    • no LLVM IR, é gerado call void @__drop_File(ptr %1) logo antes do ret
  • O nome com mangling __drop_File evita 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, longjmp e signals
    • Jam mantém uma função de drop por tipo e a executa em toda saída de escopo

Inicialização e Maybe(T)

  • Em Jam, não existe valor undefined, e não é possível declarar um binding sem valor
    • todo var e const exige um inicializador real
    • structs são montadas calculando antes os valores dos campos, criando o literal da struct e então fazendo o binding
  • Zig permite var f: File = undefined; return f.fd;, o que em runtime pode ler lixo da stack
    • em modo Debug, há preenchimento com 0xaa para tornar o uso incorreto visível
    • em modo Release, o conteúdo vira bytes arbitrários
  • Go zero-inicializa todo var para evitar leitura de lixo, mas isso tem custo mesmo para campos que serão sobrescritos logo em seguida
  • Jam evita tanto undefined quanto zero implícito
  • Para inicialização tardia e out-parameters, usa Maybe(T)
    • empty() cria um slot cujo conteúdo ainda não tem significado
    • write() preenche o slot
    • unsafeAssumeInit() 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 unsafe fica como âncora pesquisável por revisores humanos e por IA

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 de match, while e corpo de for sofrem drop no fim desse bloco
    • um return dentro de bloco aninhado faz drop de todos os escopos ativos, do mais interno para o mais externo, antes do ret
    • break e continue fazem 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
  • No exemplo de nested break, outer sofre drop no fim da iteração 0, e no caminho do break da iteração 1 a ordem é inner e depois outer

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 move consome 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] = x vira v.setAt(i, x)
    • let y = v[i] usa o getter v.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 disjuntos
    • moveX(p, p.x) gera erro porque p e p.x se 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_raw e Box::from_raw
    • para passar struct por valor, é necessária annotation separada como #[repr(C)]
    • ferramentas como cbindgen e abi_stable existem para reduzir esse trabalho manual na fronteira
  • 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
  • export expõe funções Jam com nome simples, sem mangling, usando convenção de chamada C
    • export fn counterAdd(c: mut Counter, n: i64) i64 pode ser chamado em C como int64_t counterAdd(Counter *c, int64_t n);
    • o parâmetro mut Counter é reduzido para Counter * 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 extern seguem 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
  • O que Jam quer oferecer é manter o lado Jam safe by default, sem exigir uma API espelho unsafe separada ou uma camada de shim ao expor bibliotecas Jam via ABI C

Pattern matching

  • O match de Jam tem formato Pattern Block e 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 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 match que não a tratem passam a falhar na compilação
  • match també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 simplifycfg do LLVM a switch e jump tables quando isso for lucrativo

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

 
GN⁺ 3 시간 전
Comentários no Lobste.rs
  • Resolução de traits é um problema de busca. Verificação de empréstimos é análise no escopo da função inteira. Monomorfização aumenta a quantidade de código antes mesmo que a etapa mais lenta, o LLVM, veja qualquer coisa…

    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

    • Fico em dúvida até se devo levar este projeto a sério
    • Gostei especialmente da expressão “substituir dados quantitativos por prosa qualitativa/evocativa”
      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á drop e não há um componente específico e facilmente mal-usado, o undefined?

    Não há undefined e todos os valores precisam ser inicializados, mas Maybe(T).empty() retorna um valor cujo conteúdo “ainda não tem significado”, e se você chamar unsafeAssumeInit() logo em seguida, ele provavelmente vai devolver lixo. Então não é seguro no sentido de Rust, em que o compilador trata unsafe como uma contaminação que exige um unsafe { .. } explícito

    O exemplo que mostra segurança e o recurso drop é este código:

    const File = struct {  
        fd: i32,  
        fn drop(self: mut File) {  
            close(self.fd);  
        }  
    };
    
    export fn useFile() i32 {  
        const f: File = { fd: 7 };  
        return f.fd;  
    }  
    

    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 retorna 7. 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 de useFile()

    No exemplo de ABI, quando export fn counterAdd(c: mut Counter, n: i64) i64 { .. } vira int64_t counterAdd(Counter *c, int64_t n);, como se expressa se c pode ou não ser NULL? Em Rust, há uma ABI definida para isso, e tanto extern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64 quanto extern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64 são possíveis

    A versão em Rust também não precisa de unsafe. Dá para definir a API com referências. Ironicamente, o único lugar onde unsafe poderia 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 Rust

    Este exemplo mais adiante também:

    extern fn snprintf(buf: *mut[] u8, size: u64, fmt: *const[] u8, ...) i32;
    
    fn render(value: i32) i32 {  
        var buf: [16]u8 = [0; 16];  
        return snprintf(&buf[0], 16, "n=%d", value);  
    }  
    

    Não deveria haver um unsafe em algum lugar aqui? Como snprintf recebe ponteiros brutos, seguindo a diretriz mencionada antes de que operações unsafe devem ser encontráveis pelo nome, parece que deveria haver algo como unsafeSnprintf e uma redefinição de símbolo

    “Uma pista honesta: na linha extern, você está falando com C, e as regras de C prevalecem”, hmm

    • Também li dessa forma. Mas não é diferente do .as_raw_fd() do Rust, e ali existe o mesmo problema de segurança
  • Nada do que torna a ABI do Rust instável existe em Jam. Não há referências de primeira classe, nem tempos de vida, nem layouts niche-packed a apagar

    Isso é um mal-entendido sobre a estabilidade de FFI da biblioteca padrão do Rust. Referências compartilhadas, referências mutáveis, Box e os Option deles todos têm ABI definida e estável. Portanto, todo o procedimento Box::into_raw/from_raw do exemplo é desnecessário
    Tempos 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

  • Jam ainda não foi tornado público. O compilador existe e roda, mas estou adiando uma divulgação mais ampla enquanto trabalho nas coisas que o tornam bom para uso diário — uma superfície estável, gerenciador de pacotes, LSP, formatador e o restante das ferramentas que você só nota quando elas não existem…

    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 drop acontece porque não é possível retornar uma referência. Outro problema clássico é este:

    let mut arr = vec![1];  
    let x = &arr[0];  
    arr.push(2);  
    // O que acontece se `x` for usado?  
    

    Há três respostas:

    1. Rejeitar. Para isso, é necessário algum tipo de conceito de empréstimo. Normalmente é compartilhado XOR mutável; só com mutável fica inconveniente, e só com compartilhado não é seguro
    2. Permitir. Porque não existem referências por meio de outras variáveis, e tudo é GC ou ponteiro com contagem de referências
    3. Permitir e causar comportamento indefinido em tempo de execução

    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

    • Acho que Inko está se saindo muito bem. Claro, descontando meu viés óbvio, mas ela também tem seus próprios trade-offs
      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
    • Não conheço bem Jam, mas a semântica de valor mutável ao estilo Hylo tem uma forma de empréstimo chamada subscripts. Então ela fica mais perto de um meio-termo
    • Quando li essa parte, a primeira pergunta que me veio foi: “se não há referências nem anotações de lifetime, como armazenar uma referência dentro de uma struct?”
      Olhando a referência da linguagem, não há referências, mas há ponteiros mut e const, e não encontrei nada sobre a segurança deles
  • Um 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 comptime do 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 allocators
    O 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