Pular para o conteúdo

Especificação §14

Sintaxe e convenções

language-design.md §14 · 238 linhas · 26 min de leitura

A sintaxe inteira se justifica por cinco regras transversais, e cada decisão de grafia cai de uma delas, não de gosto:

  1. O default é o não-marcado. O caso comum e seguro nunca leva keyword; só se marca capacidade ou perigo extra. Vale para mutabilidade (let/:= pelado vs var/mut/*T/@transfer), visibilidade (pub público, priv privado-ao-arquivo, privado-ao-módulo pelado) e compilação (comptime marca; runtime é pelado).
  2. Conceito = palavra, operação = símbolo. Se estrutura o programa, é keyword (fn, match, loop); se é uma ação num ponto, é símbolo (<-, ., :=).
  3. Modificadores ortogonais compõem. Um prefixo aplicado a várias coisas, em vez de uma keyword por combinação: pub fn, comptime fn, @generator fn, comptime var.
  4. Mesma ideia, mesma cara. Construtos conceitualmente iguais se parecem, e por isso match absorve switch, select e o branch sobre erro de um Result (seção 7), loop absorve for, while e infinito, e overloading é só match sobre tipo (seção 13).
  5. Nome honesto: diz o que devolve e o que pode falhar. O nome entrega o resultado e a consequência de borda, sem decoder ring: nada de nome inócuo escondendo um panic (o anti-padrão unwrap, rebatizado or_panic, que mostra a consequência), nem de termo emprestado que não existe na linguagem (is_some virou is_present, pois não há some). É o “sem fluxo de controle escondido” aplicado a nome: or_panic/or(default) em vez de unwrap/unwrap_or; get -> Optional (“talvez”) distinto de at -> T (“eu garanto”); equals em vez do eql abreviado. Vocabulário universal e honesto (push/read/map) ou de domínio preciso (seal/open do AEAD, que carrega o “autenticado” que encrypt perderia) fica; o enganoso, não.

let/var/const, com o atalho :=; o imutável é o default (ver seção 6):

Forma Significado
x := v imutável, runtime (atalho, caso comum)
let x := v imutável, runtime (explícito)
var x := v mutável, runtime
const x := v constante de compilação (= comptime let)

Tipo explícito ao estilo Odin (x: T = v); := é : = com o tipo omitido. A regra: := (ou : T =) declara; = pelado só atribui a um var, pois o : marca declaração. Descartar um retorno é explícito: _ := expr (o _ é a variável-ausente, seção 28); largar um valor no chão sem _ := é erro, porque descartar é uma intenção, e intenção se escreve. comptime é o modificador ortogonal de “avalie em compilação” (comptime fn/let/var/loop/match/{}; ver seção 10).

Binding múltiplo e tupla. Vários valores de uma vez formam uma tupla (a, b) (seção 7): coords := (10, 20) liga a tupla a um nome. Para ligar a vários nomes (atribuição paralela, ou desestruturar um retorno-tupla) as duas grafias valem e casam por posição: a, b := f() (nomes soltos) ou (a, b) := f() (com os parênteses da tupla). Trocar dois valores é a, b = b, a.

for, while e loop infinito diferem só no cabeçalho, não no corpo, então há uma keyword, loop, e o cabeçalho decide a forma:

loop { } // sem cabeçalho → infinito
loop cond { } // expressão booleana → while
loop x in xs { } // IDENT in iterável → for-each (coleção, generator, range)
loop i in 0..8 { } // range exclusivo → contado (..= é inclusivo)
loop i := 0; i < 8; i = i+1 { } // C-style; os ';' delimitam as 3 partes (parênteses opcionais)

break/continue funcionam igual em todas. No C-style os parênteses são opcionais (estilo Go): numa linha, os ; explícitos já delimitam as três cláusulas (a inserção automática de ; só age em quebra de linha, e aqui não há nenhuma), e o { fecha o cabeçalho. Os parênteses só são necessários se você quebrar o cabeçalho em várias linhas (ou para isolar um literal composto no cabeçalho, como em Go).

Um construto para “dado um valor, escolher um galho”, que absorve o que outras linguagens dividem em switch/match/select, mais o branch sobre o erro de um Result (seção 7). O compilador distingue três formas pelo token logo após match: um sujeito casa padrões (o “switch”); { abre operações de channel (o “select”); |e| pendurado num op ramifica o erro daquela operação:

// com sujeito → casa padrões (o "switch"/"match clássico")
match resultado {
Ok(v) => usa(v)
Err(e) => trata(e)
}
match código {
200 | 201 | 204 => ok() // or-pattern com '|' (cobre o caso do fallthrough)
404 | 410 => sumiu()
_ => outro() // default é sempre '_' explícito
}
// sem sujeito (só '{') → casa operações de channel prontas (o "select"; ver seção 4)
match {
v := -> chan_a => handle(v)
_ := -> chan_b => sinal() // '_ :=' descarta o valor recebido
} timeout(5s) { // timeout é trailer do select; timeout(0) = não-bloqueante
expirou()
}
// trailer '|e|' num op → ramifica o erro de uma operação falível (o Ok vira a variável; seção 7)
v := do_something(x) match |e| {
E1 => { log(e); return Err(e) } // value-binding: o arm escapa (loga, propaga)
E2 => runtime.panic(e) // ou aborta
}

O select (match {}) aceita um timeout(...) como trailer: o bloco roda se nenhum channel responder no prazo; sem ele, o select espera indefinidamente, e timeout(0) é o poll não-bloqueante. A forma op match |e| é a que pendura o match numa operação: o sujeito (o erro) vem por posição e o |e| o nomeia. Só vale com erro união; variante única é erro de compilação (use catch; seção 7).

Exaustividade: em sum type ou enum (domínio fechado) o compilador exige todos os galhos e proíbe o _. Um _ num match exaustivo é erro de compilação, porque anularia a rede de segurança: adicionar uma variante cairia em silêncio no _ em vez de quebrar a build até você tratá-la (a mesma régua do “capacidade declarada e não-exercida é erro”, seção 6). Em domínio aberto (int, string) o _ é obrigatório, sendo o default-case (não há keyword default; o _ faz esse papel), exigido porque o domínio do valor não tem escopo fechado. Não há fallthrough; o or-pattern | cobre “vários valores, mesmo galho”. Os arms se separam por quebra de linha (não por vírgula), um por linha; numa linha só, separam por ; (o terminador de statement, como em unsafe { a; b }), nunca por vírgula. A vírgula fica para grupos-de-valor (argumentos e tuplas em (), conjuntos type-level, e literais de array/slice []int{1, 2, 3}); os corpos de membros distintos (campos e variantes de decl, literais de struct Vec2{1; 2}, arms de match) separam por ;. O separador segue o conteúdo, não o bracket: um literal de struct e um literal de array usam ambos { }, mas os campos da struct são membros distintos (;) enquanto os elementos do array são um grupo-de-valor (,).

A linguagem não tem retorno implícito. Um bloco não vale a sua última expressão; o => de um arm não entrega o lado direito, pois ele só separa padrão e corpo, e o corpo (como os arms do select de canal) roda lógica. Para produzir um valor ou sair, sempre explícito: return, break, panic.

return X tem um único sentido, decidido pela posição do construto:

  • expressão (o construto produz um valor): return X entrega X ao lugar que recebe a expressão. v := match f() { Ok(x) => return x } dá x a v; x := loop { ... return v } dá v a x. Isso vale pro match e pro loop; if é statement, então um valor escolhido por condição é o ternário (v := c ? a : b).
  • statement (roda solto): return X é early-return, ou seja, sai da função. if c { return err } propaga; f() match |e| { Timeout => return e } sai.

O arm ou braço herda a posição do construto; não há regra por arm. panic aborta; break sai da loop (veja abaixo); ambos divergem sempre.

loop como valor. Um loop pode estar em posição de valor (x := loop { ... return v }), entregando por um return explícito do mesmo jeito que um match em posição de valor. É o que o if deliberadamente não tem. Um loop em posição de valor precisa entregar em todo caminho de saída (como o corpo de uma função): um loop { ... return v } infinito precisa de um return alcançável; um loop condicional/for precisa return em todo caminho do corpo, ou o checker aponta o caminho que pode terminar sem valor. (Um corpo que sempre retorna ainda precisa de ao menos uma iteração; o caso coleção-vazia / condição-imediatamente-falsa é pego em runtime.) Um loop que só itera por efeito é um statement comum, inalterado. Isso convive com o caminho funcional (xs.reduce(...), o .collect() de um generator): esses transformam uma sequência que já existe, enquanto um loop em posição de valor computa um valor do zero.

break e continue: sem valor, label opcional. break sai de um loop e continue pula pra próxima iteração; nenhum carrega valor (um loop entrega seu valor por return, então um break que sairia de um loop em posição de valor é erro). Um break/continue pelado mira o loop mais interno; uma label mira um loop nomeado, escrita estilo Go antes do loop:

outer: loop x in xs {
loop y in ys {
if found(x, y) { break outer } // sai dos dois loops
if skip(y) { continue outer } // próximo x
}
}

Uma label é só o nome de um loop (outer:); ela se distingue de um binding tipado (x: T) pela keyword loop que segue os dois-pontos.

return distribuído (açúcar). Um match em posição de valor exigiria return em cada braço, o que é verboso. O atalho: return match ... distribui o return pros braços. return match s { Idle => Running } equivale a match s { Idle => return Running }. As duas formas convivem; escolha pela leitura. (Em value-binding não há return externo pra distribuir, então os braços levam o return: v := match f() { Ok(x) => return x }.) O => continua sem entregar por conta própria: a entrega mora sempre num return explícito, no braço ou no que distribui. O if não entra nisso: if é statement, nunca valor, então pra escolher entre dois valores por condição você usa o ternário abaixo, não um if.

Pra escolher entre dois valores por uma condição, o ternário cond ? a : b, uma expressão:

icon := liked ? "♥" : "♡"
max := a > b ? a : b

É açúcar pra a escolha binária que o match faria verboso (match cond { true => return a; false => return b }). Vale só pra duas alternativas; três ou mais é match (ternário aninhado vira sopa, e o match lê melhor).

Uma função anônima passada inline a outra função. Duas formas, ambas só em posição de argumento:

xs.filter(fn(l) => l.contains("ERROR")) // forma curta: uma expressão, '=>' entrega
xs.map(fn(l) { parse(l); return enrich(l) }) // forma de corpo: várias linhas, 'return' explícito

No lambda de uma expressão, o => entrega o lado direito, e isso não contradiz o => do match (que não entrega), porque os dois são coisas diferentes: um lambda é, por definição, um produtor de valor (existe pra computar um resultado), enquanto um arm de match é um galho (faz algo naquele caso). fn(x) => e é só açúcar de fn(x) { return e }; o => entrega porque o corpo do lambda é o retorno. No fundo (filosofia, seção 1), esse => é o marcador explícito de saída (o análogo do return, da família do ?: do ternário), então a forma curta não viola o não-retorno-implícito: você não adivinha que e é a saída por posição (“é a última expressão”), você lê no =>. O preço é o glifo => ter dois papéis (saída no lambda, separador no arm), aceito porque os dois nunca se cruzam (no lambda o => é seguido de expressão; no arm, de corpo) e “maps to” é a intuição comum aos dois.

Lambda é só encurtador de anônima, não substitui declaração. As duas formas valem exclusivamente como argumento direto de uma chamada. Ligar a uma variável (f := fn(x) => …), retornar, guardar em struct, ou usar no topo do arquivo é erro de compilação; pra função nomeada, reutilizável ou de topo, é fn nome(...) { ... }. A cerca é proposital: corta na origem o const f = x => … espalhado (as arrow functions de JS viram nome de variável em todo canto); aqui, anônima não se liga e não persiste, e quem quer reusar dá nome. Essa cerca é a regra do escopo transient (seção 6): lambda é uma expressão transient, que existe só cruzando a fronteira de uma chamada, nunca solta.

A cerca é contra a função anônima, não contra a nomeada, e não é uma cerca contra dar nome a um escopo. Daí saem duas consequências, que puxam pra lados opostos de propósito.

Função nomeada também não liga a variável — g: fn(int) -> int = double é erro de compilação. Não porque vazaria escopo (função nomeada não fecha sobre nada), mas porque isso é renomear: double já tem nome, e o jeito de chamar de outra coisa é trocar o nome dela. Um binding que só apelida uma função existente não compra nada e custa um segundo nome pra uma coisa só. Quer retornar uma função, ou reusar uma? Retorna, ou chama, pelo nome. (Isso resolve a aparente abertura do “ou um binding de tipo função” da seção 6: o que segura uma função é um parâmetro de tipo função — o callback que a recebe — não um binding local.)

Uma função pode ser declarada dentro de outra. fn a() { fn b() { ... } } é válido: b é visível só dentro do corpo de a (sombreando um b de topo de mesmo nome), pode ser chamada antes da declaração, irmãs podem chamar umas às outras, e ela pode recursar. Não obriga todo auxiliar a ser global só porque é usado uma vez — nem toda função merece escopo de módulo. Mas aninhar é escopo, não captura: b não enxerga os locais de a; tudo que ela precisa chega por parâmetro. Fechar sobre um escopo continua exclusivo da lambda, que paga por esse poder sendo transient. Assim a função aninhada é a resposta pra “preciso de um auxiliar aqui”, e a lambda é a resposta pra “preciso desta receita, agora, sobre estes valores” — e nenhuma das duas vira a arrow function solta do JS.

Cleanup determinístico em qualquer função ou bloco; roda na saída do escopo (incluindo return antecipado), múltiplos defers em LIFO. É o mecanismo de liberação para @mm(none)/manual:

fn read_config() -> Result[Config, error] {
f := open("cfg") catch |e| { return e }
defer f.close() // roda ao sair, em qualquer caminho
...
}

Os argumentos são congelados no defer; só a chamada é adiada. defer f.close() fecha o f daquele ponto: o receptor e cada argumento são avaliados quando o defer é alcançado, e a chamada roda depois com esses valores. É esse o sentido do construto — adiar aquele call site, com as entradas naquele estado. Ler os argumentos depois redirecionaria a limpeza em silêncio: reatribua f após o defer e você fecharia o arquivo errado, que é exatamente o bug que o defer existe pra evitar. Uma limpeza que precisa enxergar um valor posterior não é uma chamada adiada: mova a chamada pro fim do escopo e passe o que você quer. Congelar um agregado congela o valor, como qualquer passagem por valor (§6), então um p.x = 9 posterior não é visto pela limpeza.

var n := 0
defer show(n) // congelado: mostra 0
n = 9 // ... não 9

timeout é keyword: o deadline obrigatório do <-> (seção 4, onde vira error.Timeout no Result) e o trailer do match sem sujeito (o bloco que roda se nenhum channel responder no prazo). O valor é um Duration (tipo builtin, u64 nanossegundos; duração nunca é negativa), escrito com literais de unidade de lexer: 5s, 300ms, 10ns, 6m, 7h, 18d, notação literal (como 0xFF ou 1_000), convertida pelo lexer para Duration, sem importar nada. timeout(_) é o deadline infinito (explícito, sem prazo).

reply := chan <-> req timeout(5s) // deadline no <-> → error.Timeout no Result
match { ... } timeout(100ms) { ... } // deadline no select → trailer com bloco (seção 4)
chan <-> req timeout(_) // <-> sem deadline (infinito, explícito)

O pacote time (stdlib) traz aritmética rica e durações de precisão arbitrária (Duration[T]/ArbitraryDuration sobre qualquer inteiro, como picosegundos); o Duration builtin do timeout é o u64-ns, e quem precisa de mais importa time (opt-in puro).

Um sigil só, @, para tudo que fala com o compilador. A regra é se há configuração:

  • Diretiva com argumento usa parênteses: @mm(arena), @limit(16mb), @supervisor(strategy: rest_for_one), @promote(gc, deep) (postfixa no valor: result @promote(gc); ver seção 5).
  • Tag pura dispensa parênteses: @transfer (postfixa no valor: chan <- result @transfer), @generator (prefixa em fn).

@transfer substitui a antiga notação #(...); @generator fn substitui a keyword generator. (Não existe @shared; ver seção 5.)

Os construtos que marcam e contêm o perigo de memória (semântica completa na seção 5). unsafe é um modificador ortogonal (Princípio 3), como pub/comptime/@generator; assert/assume são o trailer de contenção de uma operação unsafe, espelhando o catch na forma mas não na semântica:

  • unsafe marca a região ou operação onde as garantias estão suspensas, em quatro formas: unsafe expr (operação única), unsafe { } (grupo), unsafe fn (a função repassa perigo ao chamador), unsafe union (declaração marcada). Em fn, unsafe é a consequência forçada de haver operação unsafe não-abatida no corpo, não uma escolha de grafia; abater tudo com assert/assume no corpo dá um fn normal. Os modificadores de função ficam em pub? comptime? unsafe? fn; @generator é um decorador como os demais (acima da função ou inline), sem slot fixo.
  • assert/assume são os lidadores, sempre trailer de uma operação, nunca no header (carregam lógica, não um bit). Duas keywords, uma intenção cada: assert (cond) checa, e o runtime verifica e dá panic se falsa; assume "razão" confia, afirmação documentada sem check (e sem licença pra UB). Não abater é repassar (a função vira unsafe fn).
  • @requires(cond) e a pós-condição (no tipo de retorno) são o contrato de fronteira da função: design by contract, diretivas @ (zero keyword), distintas do assert/assume de operação. O compilador os checa em cada chamada e return: estático onde prova, panic onde não. Uma pré-condição checável e contratada torna a função segura de chamar (o chamador não re-declara; é a camada que constrói safe sobre unsafe). Múltiplos @requires empilham; reuso de predicado é função booleana comum (sem construto contract, que não passaria no teste do opt-in, seção 1).
b := unsafe raw_read(buf.ptr + i) assert (i < buf.len) // forte: checa, panic se falsa
y := unsafe x.bits assume "float→bits" // fraca: afirma, sem check
unsafe { p.next = q; q.prev = p } assert (p != q) // trailer de bloco

use é a única keyword de import: traz o módulo (acesso qualificado), nomes específicos, ou uma chamada inline fora do topo:

use json // monta o módulo → json.parse(...)
use json.{parse, decode} // levanta nomes → parse(...) direto
use json as j // alias
use json.parse(data) // inline: chamada pontual, sem trazer nada pro escopo do arquivo

(O sistema completo, com unidade, resolução de path, visibilidade, pacotes, comptime e inicialização, está na seção 15. Aqui fica só a keyword use.)

  • Inteiros estilo Zig, com largura de bits arbitrária de 1 a 65535: iN/uN para qualquer N nesse intervalo, em que i3, u7, u777 e i65535 são válidos. Não é lista fixa, é família paramétrica; i8/u8/i16/u16/i32/u32/i64/u64/i128/u128 são só os casos comuns dela. isize/usize para largura de ponteiro; int/uint como apelidos da largura default; byte = u8, bit = u1. Largura não-múltipla-de-8 se comporta com a largura declarada (a aritmética de um u3 é checada contra o intervalo de 3 bits) mas ocupa o próximo tamanho endereçável (um u3 solto ocupa 1 byte; um elemento de []u3/[*]u3 também ocupa 1 byte cada, endereçável e indexável, ao custo de 5 bits desperdiçados; ocupa os 3 bits reais só dentro de um packed struct). Não se toma *T de um campo sub-byte (nem solto, nem de packed), e o motivo é a memória ser endereçada a byte: um ponteiro normal guarda um endereço de byte, e não há como ele nomear “os 3 bits no offset 3 dentro deste byte”. Permitir (como o Zig faz) exigiria um tipo de ponteiro-de-bit separado, que carrega o offset e não interopera com ponteiro normal, ou seja, duas espécies de ponteiro no sistema de tipos. Escolhemos uma espécie só (mais simples); se a necessidade aparecer, esse ponteiro-de-bit pode ser adicionado então. Na fronteira C (@repr(c)/@extern, seção 23), uma largura sem ABI C definida (u3, já que C não tem u4) é erro de compilação, sem widening silencioso; pra cruzar você escolhe explicitamente um tipo C-compatível (u8).

    Overflow trapa por padrão. +, -, * dão panic em runtime quando o resultado verdadeiro sai do intervalo da largura (aritmética checada, como Zig e Rust em debug), o que é o que torna a largura um invariante real em vez de um truncamento silencioso. Uma expressão constante que estoura é erro de compilação: o checker dobra x: u8 = 200 + 100 e rejeita, não espera o runtime. Quando você quer aritmética modular pede no operador: +%, -%, *% dão wrap em complemento de dois, então um wrap é sempre visível no código em vez de implícito. As conversões de largura que perdem informação (300 as u8, 3.9 as i32) passam pelo cast as explícito pela mesma razão. O shift bit a bit << é a exceção, e de propósito: um shift é uma operação de bits sobre a largura, não uma contagem aritmética, então os bits empurrados além do topo são mascarados para a largura ((19: u8) << 4 é 304 mod 256 = 48), o significado C/hardware de um shift. Não trapa e não existe <<%, porque um shift para fora da largura é a operação funcionando como definida, não um overflow de magnitude. (Use * / *% quando quiser “multiplicar por potência de dois” com as semânticas de trap / wrap de magnitude.)

  • byte e bit: convenção, não regra. Uma ressalva honesta, do mesmo espírito da unidade de string (seção 16) e do byte da FFI-C (seção 23): por definição, byte não tem tamanho fixo. Ao longo da história foi 3, 4, 6, 7 e 9 bits, sempre “um grupo de bits”, nunca um tamanho prescrito; o octeto (8 bits) é a convenção que venceu, não uma lei da computação. A linguagem adota essa convenção (byte = u8) porque todo alvo que ela compila é octeto e porque a alternativa, um byte[N] genérico, destruiria a ergonomia dos literais de tamanho sem ganhar poder nenhum. Poder nenhum porque “um grupo de N bits” já é o uN: a precisão arbitrária de bits é a família iN/uN (acima), e quem quer 3 bits escreve u3, não byte[3]. Então byte é só o nome do octeto (o átomo de armazenamento, o elemento de []byte, a unidade de IO) e bit é só o nome amigável do u1; a largura mora no tipo (uN), e os dois são apelidos de casos seus. (Num alvo não-octeto hipotético, casar o octeto com a unidade nativa seria trabalho da MemorySource daquele alvo (seção 5), não do tipo byte, que permanece a convenção universal.)

  • Floats nas larguras IEEE: f16/f32/f64/f128/f256 (mais bf16 para ML); float como apelido default. A família fixa para onde o IEEE 754 para de nomear formato de uso real: o padrão define binary16/32/64/128/256 (e, em princípio, qualquer binary{32k} acima de 128, que ninguém usa), então a linguagem traz esses cinco e nada no meio. Largura arbitrária não é um float fixo do jeito que uN é um inteiro fixo, porque não há formato canônico nem hardware pra ela: um float mais largo tem que escolher bits de expoente versus mantissa, escolha que o lado inteiro nunca enfrenta (um grupo de N bits já é uN). Quando você de fato precisa de mais de 256 bits de float, recorre ao BigFloat abaixo, que é uma ferramenta diferente com custo diferente.

  • Um float é sempre um número real finito: NaN é erro, e overflow trapa como o do inteiro. É o lado float da mesma honestidade que faz + trapar no overflow de inteiro. NaN é literalmente não-é-número: uma operação que o produziria (0.0/0.0, inf - inf, 0 * inf, raiz de negativo) é erro de runtime, nunca um valor que propaga em silêncio. (Propagação silenciosa de NaN é exatamente o unknown-escondido que a linguagem gasta esforço pra banir, seções 1 e 14.) Estourar a largura também não dá inf: trapa, igual overflow de i8, então um float que você segura é garantidamente finito e comparável. O ganho é uma ordem limpa: sem NaN e sem inf, todo float é totalmente ordenado, então < devolve um bool simples e não há necessidade das duas funções de comparação (um < parcial e um isless total) a que as linguagens que mantêm o NaN do IEEE são obrigadas. Infinito real e direcionado (um limite, trabalho projetivo) é um ExtendedFloat opt-in separado e deliberado (math, o modelo Mathematica: +inf/-inf são valores direcionados definidos, enquanto um 0/0 genuinamente indefinido continua erro, nunca um inf). Você pede ExtendedFloat quando a ciência precisa do ponto no infinito; o float do dia a dia continua finito por padrão, porque inf-por-default é justamente o valor que começa a fazer as vezes de “unknown”.

  • Comparação: ordem total pro finito, ordem parcial só pra bola rigorosa. Como um float é sempre finito (acima), e int/Decimal/BigInt/BigFloat são exatos, todos carregam uma ordem total: < > <= >= == != devolvem bool simples, e o Ordering { Less; Equal; Greater } compartilhado (um enum builtin, como Optional e Result, já que o resultado de uma operação tão fundamental não deve ficar atrás de um import) é o resultado de um .cmp() de três vias, o tipo que um tipo de usuário implementa pra virar ordenável (o comportamento Comparable; <=/>=/!= são derivados dele, não primitivos separados). A única exceção é o BigFloatArb (a bola com limite de erro): duas bolas cujos intervalos se sobrepõem não têm ordem decidível (o verdadeiro pode cair pra qualquer lado). Seria mentira o < devolver bool ali, então a bola não tem operadores de comparação; tem um método .cmp(b) -> ArbOrdering, onde ArbOrdering { Less; Equal; Greater; Indeterminate } adiciona o quarto caso, e o match exaustivo te obriga a encarar o Indeterminate (aumenta a precisão e re-tenta, ou decide você). É a separação padrão ordem-total-versus-ordem-parcial (o Ord vs PartialOrd do Rust), e fica contida: Indeterminate é um variant de enum definido local ao resultado da bola, exatamente como Optional.none é um variant definido, não um valor “unknown” vazando pro sistema de tipos. Um booleano de três valores é recusado de propósito: um bool que pode ser “talvez” é um unknown contrabandeado pro único tipo cujo trabalho inteiro é certeza, e envenenaria todo &&/||/if com lógica de três valores (tainted coloring, seção 1). A incerteza da aritmética de intervalo mora em um variant de um método, e em nenhum outro lugar.

  • Precisão arbitrária mora em math (stdlib), não no escopo global, mas lê como aritmética comum. É a resolução deliberada de duas forças: o não-envenenamento mantém esses nomes fora do escopo de todo programa (você nunca paga por BigInt em código que não o nomeia, o teste do opt-in), enquanto o privilégio de operador (Convenções, abaixo) os abençoa como numéricos-core pra usarem + - * / como qualquer número, nunca a.add(b). Um tipo de biblioteca nunca poderia ter os operadores (sem overload, seção 13); é justamente por isso que precisão arbitrária é core-blessed em vez de biblioteca pura. Cinco tipos, cada um uma ferramenta distinta:

    • BigInt: inteiro ilimitado, sem parâmetro de largura. Não trapa, cresce (sinal mais um slice de limbs), que é o oposto conceitual limpo do iN que trapa. Pra contagem exata, criptografia, teoria dos números.
    • BigFloat[M]: número de ponto flutuante de precisão arbitrária com M bits de mantissa de precisão de trabalho, corretamente arredondado a cada operação (o modelo MPFR). Precisão é relativa: M bits significativos onde quer que o valor esteja na reta. É o cavalo-de-batalha da computação científica (magnitudes muito grandes ou muito pequenas, funções especiais, constantes).
    • BigFloatArb[M]: o mesmo float de M bits carregando um limite de erro rigoroso (o modelo Arb/FLINT: um ponto médio e um raio), então depois de uma cadeia de operações você sabe quais dígitos sobreviveram. Custa mais (cada operação rastreia o limite) e você o pede quando precisa de certeza em vez de um número que só parece preciso (cancelamento catastrófico que você precisa detectar, resultados que sustentam uma prova).
    • Decimal e Decimal[P, S]: números exatos em base-10, pra dinheiro e contabilidade. A base é o ponto inteiro: 0.10 é exato em base-10 e dízima em base-2, e é por isso que moeda nunca anda sobre float binário. Decimal é de precisão arbitrária (um BigDecimal, com o arredondamento tornado explícito numa divisão); Decimal[P, S] é a forma limitada (P dígitos significativos totais, S casas decimais, o formato do DECIMAL(p, s) do SQL) pra quando você quer o tamanho fixado no tipo.

    Por que não aritmética de significância. Existe uma terceira escola de float (a do Mathematica: rastrear quantos dígitos seguem confiáveis e descartar o resto, adaptativamente). A linguagem não a traz, de propósito. Ela torna a precisão dinâmica e implícita, escondendo justamente a decisão que as outras duas tornam explícita: BigFloat[M] faz você escolher M, BigFloatArb faz você pagar um limite visível; a significância decide por você e muda de ideia a cada operação. É o tipo de mágica prestativa que todo o design rejeita (o custo tem que ser visível, seção 1). Quem precisa de precisão adaptativa a compõe a partir de BigFloatArb (re-roda mais largo até o limite ficar apertado o bastante), explicitamente, em vez de recebê-la como default silencioso.

    Literais resolvem por contexto, como um caractere (seção 16). Um literal numérico é comptime sem tipo fixo, exatamente como 5 vira u8/i64/int e 'w' vira byte/codepoint/grapheme. Ele toma o tipo declarado do binding: x: BigInt = 123456789012345678901234567890, x: Decimal = 9.99. Quando o contexto não desambigua, você tem que anotar: um x := <literal acima de i64> solto é erro pedindo um tipo, não promoção silenciosa pra BigInt, a mesma disciplina que faz 'é'.byte ser escolha explícita em vez de chute.

  • Optional[T] para ausência; [N]T array dimensionado, []T slice, [*]T ponteiro-de-muitos (buffer cru que se possui; seção 6).

  • noreturn, o tipo da divergência: o de uma função que nunca devolve o controle ao chamador. É distinto da ausência de valor de retorno: fn f() retorna (o controle volta, só não traz valor; e fn() é o tipo dessa função, sem precisar de um unit), enquanto fn panic(msg: string) -> noreturn não retorna (não existe “a linha depois”). Divergem: loop {} infinito, panic, exit. Não é construto especial de builtin, pois código de usuário também declara -> noreturn (um event-loop, um abort), e a regra do type-checker é uniforme: um valor noreturn encaixa em qualquer contexto de tipo, porque o caminho que o produziria nunca chega lá. É isso que faz x := parse(s) catch |e| { panic(e) } tipar com x: int: o braço de erro chama panic (-> noreturn), diverge, e o caminho de erro nunca alcança a atribuição, então x é o int do Ok. (Em teoria de tipos é o bottom; o nome fala do que importa na prática, o retorno de controle, não “nunca”.) Um canto do fn(): como o retorno é ausente (não um valor, não noreturn), um genérico que abstrai sobre o tipo de retorno, g[R](f: fn() -> R), não casa com fn() (não há R a que ligar). É raríssimo e contornável (envolva num fn() -> Empty); fica registrado porque a generalidade “genérico sobre o retorno” não cobre a função-sem-retorno.

  • Literais de tamanho, no molde dos de duração (acima): notação de lexer convertida para usize em bytes, sem importar nada. O átomo é o byte; ordens de grandeza acima dele vêm em base-10 e base-2, distintas pelo prefixo: 512b (bytes), 4kb/4kib (10³ vs 2¹⁰), 16mb/16mib, 2gb/2gib, 8tb/8tib. O b é byte; o i (base-2) só aparece com prefixo (kib, nunca 512ib). Não há literal de bit: bit é largura-de-tipo (uN), não quantidade-de-memória; memória é endereçada a byte, então tamanho conta bytes, e número pelado num contexto de tamanho (@limit(512)) conta bytes, o átomo. Para valor de runtime, os helpers mem.kib(n)/mem.mib(n) (stdlib), simétricos com time.millis(n): literal para o estático, helper para o dinâmico.

  • Literal de struct posicional ou nomeado: Vec2{1.0; 2.0} ou Vec2{x: 1.0; y: 2.0}.

Ausência, valor-default e null são três coisas, e a linguagem só tem a primeira. Optional[T] é ausência tipada (none/some), a única forma de dizer “pode não estar aqui”. Não existe null (sem ponteiro, referência ou binding nulo, invariante verificado por grep na stdlib). E não há zero-value implícito estilo Go: todo binding nasce com um RHS explícito (seção 10, “não há undefined”), então 0, "" e List.new() só surgem quando você os escreve, e aí são valores presentes e reais, não “faltando”. Um int que vale 0 ≠ um Optional[int] que é none ≠ um campo que não chegou; um "" ≠ ausência. Default é sempre explícito: o literal que você digita, ou um argumento (opt.or(d), map.get_or_insert(k, default)), não um preenchimento automático do tipo. (Só mem.zero([]byte) zera bytes crus, no nível de memória, e não é um zero-value de tipo.) Num formato de fio, o token null (o null do JSON, por exemplo) é um valor do modelo do formato (uma variante de um enum Value), e mapeia pra Optional.none só na fronteira; não é um null da linguagem (ver a stdlib encoding).

  • snake_case para valores e funções; PascalCase para tipos. É convenção, não regra de escopo: visibilidade é pub, nunca capitalização (ao contrário do Go).
  • Ponto-e-vírgula opcional, auto-inserido no fim de statement (estilo Go); como consequência, chaves {} estilo K&R ({ na mesma linha).
  • Pipeline com . via UFCS (x.f() equivale a f(x)): xs.filter(p).map(g).collect(). Não há |>.
  • Concat de string com +; sem operador dedicado.
  • Operadores aritméticos (+ - * /) são privilégio dos tipos numéricos do core: a família iN/uN/float, o core-blessed Duration (builtin), e a família de precisão arbitrária em math (BigInt, BigFloat[M], BigFloatArb[M], Decimal/Decimal[P, S]). Tipo definido por usuário não ganha overload de operador: usa método nomeado (a.add(b)), coerente com a seção 13 (sem overload) e com “operação = símbolo só para o que o core define”. A régua resolve a aparente tensão (BigInt e Duration têm +, coleções não): o símbolo aritmético é do numérico-core; o resto nomeia. Ser core-blessed é o que deixa os tipos de math vestirem operadores enquanto ficam fora do escopo global (seção 28): a benção é sobre o símbolo, não sobre onde o nome mora.
  • Sem operador ? de propagação de erro (ver seção 7): a propagação é explícita via catch.