ABI
O que a ABI é (e o que a FFI já resolveu)
Seção intitulada “O que a ABI é (e o que a FFI já resolveu)”A FFI de C (seção 17) resolve o lado consumo: você chama C. A ABI é o lado oposto, como outros falam com a tua linguagem em binário. Ela cobre duas coisas: (a) teus próprios módulos compilados se linkarem entre si, e (b) outras linguagens (C, e por extensão tudo que fala C) consumirem a tua como biblioteca pré-compilada. É o contrato de baixo nível: layout de tipos, nomes de símbolos, como chamadas acontecem.
A decisão que atravessa tudo é que a ABI é uma fronteira de dados, não de runtime. O que cruza são tipos concretos com layout definido; os conceitos do teu runtime (processos, channels, @mm) não atravessam crus, o runtime os opera por baixo. Isso espelha a FFI de C, e mantém a fronteira pequena e estável.
Layout: não-estável por padrão, @repr pra congelar
Seção intitulada “Layout: não-estável por padrão, @repr pra congelar”O compilador otimiza o layout dos structs (reordena campos pra minimizar padding, seção 2), e por padrão esse layout é não-estável, o que tem um significado preciso: a ordenação é determinística (mesmo código, mesmo compilador, mesmas flags dão sempre o mesmo layout), mas não é uma promessa que cruza fronteiras que você não controla. Três coisas podem mudá-la: a versão do compilador (a v1.1 pode fazer packing melhor que a v1.0), as flags de otimização, ou uma mudança num tipo embarcado (um campo a mais lá no fundo muda o padding de tudo que o contém).
A consequência prática é tranquila: dentro de um único build, tudo linka (teus módulos foram compilados juntos, com a mesma regra). O que o não-estável proíbe é pegar uma lib compilada separadamente (outra versão, outro momento, ou pra ser consumida por C) e esperar que os layouts batam. Pra esse caso, e só pra ele, você usa @repr(c) (seção 17), que congela o layout campo a campo naquele tipo.
E isso é opt-in por tipo, de propósito: não há flag global --abi=stable. Um flag global congelaria o layout do programa inteiro pra ganhar estabilidade que você só precisa em alguns tipos (os que cruzam a fronteira), matando a otimização de reordenação em tudo. O @repr(c) é o inverso: você paga estabilidade só onde precisa (o struct que vai pra fora), e o resto do programa segue otimizado. Estável é a exceção marcada, não o default caro.
A interface do runtime: a única fronteira que é congelada
Seção intitulada “A interface do runtime: a única fronteira que é congelada”A regra acima — instável por dentro, estabilidade como exceção marcada — tem uma contraparte, e é a interface de host contra a qual o runtime é escrito (seção 5). Ali o default se inverte: é ABI externa, e é congelada.
O motivo é pra que a fronteira serve. O layout de um struct é interno até você marcar o contrário, porque quase nenhum struct cruza. A interface de host é o oposto: ela existe só pra ser cruzada, e por código que não escrevemos e não podemos recompilar — um kernel, um alvo embarcado, o simulador de alguém. Uma coisa cujo propósito inteiro é ser implementada por outras pessoas não pode também ser livre pra mudar.
Então write(stream, buf, len) não ganha um parâmetro, e map(bytes) não passa a devolver um status. E a disciplina não é cortesia com quem implementa um host: o compilador emite chamadas contra essas mesmas assinaturas, então mudar uma quebraria a compatibilidade da Makoto com os programas dela própria. A restrição já existia; nomear a fronteira só a torna visível.
Deliberadamente não há campo de versão nem negociação de capacidade. Esses são mecanismos pra uma interface que muda, e pagar a cerimônia deles em toda implementação pra se segurar contra um evento que estamos nos comprometendo a não causar seria o mesmo erro do --abi=stable global: um custo em todo mundo por uma garantia de que ninguém precisa. E se o core algum dia tiver que quebrar terreno aqui, a resposta é a que a linguagem já dá em todo o resto (seção 22): o core anda devagar, e quando anda de forma incompatível, vem com o migrador.
A distinção vale ser dita com clareza, porque as duas vivem a uma linha de distância no mesmo programa:
- ABI interna — seus structs, suas chamadas entre módulos de um mesmo build. Otimizada, reordenada, não prometida entre versões do compilador. Congelar é opt-in por tipo, com
@repr(c). - ABI externa — a interface de host, e o que
@extern/@repr(c)expõem pro C. Congelada, porque outra pessoa compila contra ela.
Name mangling: interno mangled, fronteira crua
Seção intitulada “Name mangling: interno mangled, fronteira crua”Funções viram símbolos no binário, e símbolos não podem colidir (dois foo em módulos diferentes, ou List[int] vs List[string], precisam de nomes distintos). Isso exige mangling: codificar módulo e tipos no nome. Mas o mangling só importa em um lugar:
- Símbolos internos (teus módulos chamando teus módulos): mangled, e ninguém os digita, porque o compilador gera e resolve, máquina falando com máquina. O nome pode ser o que for.
- Símbolos na fronteira (
@extern, seção 17): nome cru, sem mangling, porque o C precisa escreverextern void minha_api();e achar o símbolo. O@externdesliga o mangling na fronteira por definição. E o@externnão aceita parâmetro genérico: um símbolo C é concreto e pré-compilado, e o C não instancia genérico, então uma@extern fn f[T]é rejeitada (genéricos atravessam como código a monomorfizar, não como símbolo binário).
Como o símbolo interno nunca é digitado à mão, o estilo do mangling é uma opção de build de otimização, no mesmo balde que tamanho, velocidade e debug, com o default em otimizado:
optimized(default): mangling compacto, símbolos menores, binário menor.readable: mangling legível (algo como_auto_core_int_fmtem vez de_ZN4core3fmt...), que torna stack traces, profiling e debugging legíveis (você lê o símbolo e entende, sem decodificar). Combina com o profiler e o observer (seções 19 e 22).
Nenhum dos dois afeta a fronteira; lá o @extern é sempre cru. É uma escolha de qualidade de vida das ferramentas, não de arquitetura.
Generics: monomorfização em comptime, não cruzam pré-compilados
Seção intitulada “Generics: monomorfização em comptime, não cruzam pré-compilados”Genéricos não cruzam a fronteira de ABI como binário pré-compilado, e a razão é a natureza da monomorfização. Um List[T] só vira código quando você escreve List[int]: o compilador monomorfiza em comptime (seção 10), gerando o código específico daquele tipo, no teu build. Não dá pra pré-compilar List[T] numa lib, porque o T não existe até o uso.
Isso não é limitação, é o que mantém os layouts consistentes: o List[int] da tua lib e o List[int] do programa que a consome são o mesmo layout, porque ambos são monomorfizados no mesmo build, pela mesma regra determinística. O genérico viaja como o que ele é (uma forma a instanciar), e é instanciado onde é usado, dos dois lados igual. Então só tipos concretos (@repr quando cruzam a fronteira) atravessam como binário; genéricos atravessam como código a monomorfizar.
Calling convention: a do LLVM/C agora
Seção intitulada “Calling convention: a do LLVM/C agora”Quando uma função chama outra, a calling convention decide onde os argumentos ficam (registradores ou stack), onde o retorno fica, e quem salva registradores. Por enquanto, a linguagem herda a convenção da plataforma via LLVM (System V e afins), a mesma que o C usa. A vantagem é direta: interop com C é grátis (teus símbolos já falam a língua do C), e não há duas convenções pra o compilador manter e converter. O @extern (seção 17) já é a fronteira, e ela coincide com a convenção interna, então não há conversão.
Uma convenção própria interna (mais argumentos em registrador, menos salvamentos, otimizada pro teu código chamando o teu) traria ganho, mas incremental, ao custo de complexidade real (manter duas convenções, converter na fronteira). Então fica pra depois, junto do backend próprio (ver Notas de implementação), a mesma lógica do “LLVM agora, próprio depois” do resto.
Conceitos de runtime na fronteira: o runtime é o middle-man
Seção intitulada “Conceitos de runtime na fronteira: o runtime é o middle-man”O que acontece quando algo externo precisa interagir com um processo ou channel teu (seções 3 e 4)? Eles não atravessam crus, são conceitos do teu runtime, e C não sabe o que é um processo. Em vez disso, o runtime é o middle-man: a ABI expõe o mínimo pra mandar e receber dados, e o teu runtime, do teu lado, cria o processo, atrela o channel e faz a transmissão. O externo nunca “segura um channel”, ele troca bytes com o teu runtime, que opera o channel por baixo.
Isso é idêntico ao que a FFI de C já faz (o callback que dá spawn é roteado pelo runtime, seção 17), e ao princípio do @mm(c) (quando você recebe algo do C, vem o free junto pra gerenciar a memória daquele objeto). A simetria fecha a fronteira: dados atravessam; conceitos de runtime não, porque o runtime os opera por baixo, dos dois lados.