Generics e comptime
Duas camadas
Seção intitulada “Duas camadas”Generics e metaprogramação são uma coisa só por baixo: comptime. Há duas camadas de uso:
- Camada declarativa: a sintaxe
[...]com constraints explícitas. É o que a maior parte do código toca, com leitura clara e erro limpo. - Engine: comptime cru, ou seja, funções que computam tipos, blocos de compilação, reflexão. A ferramenta de força de quando você precisa gerar um tipo, não só parametrizar.
A camada declarativa desugar para o engine. Você usa o declarativo no dia a dia e desce para o engine só quando precisa de metaprogramação de verdade.
type é um valor de compilação
Seção intitulada “type é um valor de compilação”A base é a mesma do Zig: em tempo de compilação, tipos são valores de primeira classe. Existe um type cujos valores são tipos. Um generic é só uma função que recebe ou devolve type, executada pelo compilador:
// o que você escreve (declarativo):decl List[T] { items: *T, len: usize }
// o que é, por baixo (engine):comptime fn List(T: type) -> type { return decl { items: *T; len: usize }}decl Nome[T] {...} é açúcar para uma comptime fn que retorna type. List[int] é uma chamada em compilação que produz um tipo concreto.
[...] é compilação, (...) é runtime
Seção intitulada “[...] é compilação, (...) é runtime”A distinção é temporal: [...] guarda os parâmetros resolvidos em compilação, e (...) guarda os de runtime. A pergunta que decide onde cada parâmetro vai é uma só: “o compilador precisa deste valor para gerar o código?”
fn repeat[n: int](s: string) -> string // n: compilação; s: runtimefn zeros[N: usize]() -> [N]int // N dimensiona o array em compilaçãofn add(a: int, b: int) -> int // tudo runtime; [...] omitido[...] aceita tipos e valores de compilação ([n: int], [N: usize]), não só type. Cada valor distinto em [...] gera uma função especializada no binário. Isso é monomorfização, a mesma coisa que Rust e C++ fazem com generics, aqui exposta a qualquer valor comptime:
repeat[3]("ab") // o compilador materializa repeat__3, com o 3 cravado // (o loop sobre n some; vira 3 concatenações fixas) // depois ("ab") entra na função pronta, em runtimeOs argumentos de tipo são inferidos onde os valores os fixam. Você pode escrevê-los explícitos (id[int](5), Box[int]{5}, Tree[int].Leaf(5)), mas onde os argumentos já fixam os parâmetros o compilador os recupera: id(5) (do argumento da chamada), Box{5} (do campo) e Tree.Leaf(5) (do payload da variante) todos resolvem T = int, e a forma inferida produz exatamente a mesma especialização que a explícita. Onde nada fixa um parâmetro — uma variante sem payload como Tree.Empty, ou um parâmetro que aparece só no tipo de retorno — você o escreve explícito ou deixa uma anotação suprir (t: Tree[int] = Tree.Empty).
Constraints declaradas, e o erro limpo que o Zig não tem
Seção intitulada “Constraints declaradas, e o erro limpo que o Zig não tem”A crítica mais comum ao comptime do Zig é a constraint implícita (duck typing): você descobre que um tipo não serve quando a instanciação explode três camadas fundo, com erro críptico. Aqui a constraint é declarada, e o compilador checa no boundary da instanciação, errando limpo. São dois eixos ortogonais:
Capability-based (“T precisa saber fazer X”):
fn serialize[T + Serializable](val: T) -> []byteserialize[MyStruct](x)// ERRO na chamada: MyStruct não satisfaz Serializable (falta 'serialize')// e não um erro enterrado dentro de serializeWhitelist-based (“T só pode ser um destes tipos”; é de primeira classe, enquanto Rust precisa de sealed traits como contorno):
fn serialize[T: {i8, u16, bool}](val: T) -> []byteCombinado:
fn process[T: {i8, u16} + Serializable](val: T) -> []byteEm todas, o parâmetro é nomeado (T), e o corpo usa esse T. A regra é um símbolo, uma intenção: : introduz uma whitelist (lista de tipos entre chaves {...}, ou seja, “T é um destes”), e + introduz uma interface (capability, ou seja, “T implementa isto”). Os dois compõem: T + Serializable (só interface), T: {i8, u16} (só whitelist), T: {i8, u16} + Serializable (as duas). Por que dois símbolos e não só :? Porque um nome não diz o que é: T: Foo seria ambíguo entre “T é o tipo Foo” e “T implementa Foo”. Com : (e {...}) é sempre pertencimento, e com + é sempre implementação. A forma é plana: sem operadores aninháveis (|, &, parênteses), uma constraint nunca vira álgebra de tipos. É no máximo “uma whitelist, e ou interfaces”.
comptime fn e comptime {}
Seção intitulada “comptime fn e comptime {}”Três construtos, sem sobreposição: [...]/(...) é a fase dos parâmetros, comptime fn é a função sumir do binário, e comptime {} é um bloco de compilação:
| Construto | Vai pro binário? | Para quê |
|---|---|---|
fn f(...) |
sim | runtime puro |
fn f[...](...) |
sim (uma cópia por valor de [...]) |
runtime especializado em compilação |
comptime fn f(...) |
não | computar tipos/constantes (o engine) |
comptime { ... } |
não (é executado, não chamado) | rodar lógica de compilação inline |
Uma fn com [...] vai pro binário (a cópia especializada roda em runtime); só os parâmetros do colchete foram resolvidos cedo. Uma comptime fn é o oposto: o corpo inteiro roda no compilador e nada sobra. Dentro de uma comptime fn tudo já é compilação, então a distinção [...]/(...) não se aplica lá dentro, pois parâmetros normais bastam. Uma comptime fn só pode ser chamada em contexto de compilação (numa const, num [...], dentro de outra comptime fn ou de um bloco comptime).
O quadrante: runtime × compilação
Seção intitulada “O quadrante: runtime × compilação”comptime é um modificador ortogonal (“avalie isto em compilação”), aplicável a binding e a bloco. Junto com let/var (seção 6), fecha o quadrante:
| runtime | compilação | |
|---|---|---|
| imutável | let / := |
const |
| mutável | var |
comptime var |
const é o apelido curto de comptime let: uma constante de compilação nomeada, o caso comum. Não se escreve comptime let pela mesma razão que o := pelado é o imutável-runtime; quem quer ser explícito pode escrever comptime let. comptime var é o rascunho mutável de compilação: o contador de um loop que gera código, o acumulador de campos de um struct gerado:
const SIZE := 64 // = comptime let; constante de compilaçãocomptime var fields := [] // mutável, só existe em compilaçãocomptime { loop f in reflect(T).fields { fields.push(...) } // construindo em compilação}const x := runtime() é erro: o RHS de const tem que ser comptime-known. Imutável-de-runtime é o :=/let da seção 6.
Fronteira do comptime
Seção intitulada “Fronteira do comptime”O que roda em compilação é quase forçado pela arquitetura, não escolha livre: comptime é o núcleo puro e determinístico da linguagem, menos os efeitos de runtime. Sem IO, sem spawn nem processos, sem channel, sem syscall; e um comptime não lê valor de runtime. É a linguagem rodando no interpretador do compilador. (Exceção declarada: o @cimport (seção 17) não é comptime de usuário, e sim uma fase privilegiada do compilador, que lê o header e roda o compilador C embutido e locado na toolchain. A pureza “sem IO, sem syscall” vale pro código comptime que você escreve; o @cimport é o compilador tocando o sistema durante a compilação, não a linguagem no interpretador.)
Famílias de tipos de primeira classe
Seção intitulada “Famílias de tipos de primeira classe”Um construtor de tipo é uma entidade de primeira classe, não só “uma função que faz tipo”. List é uma família que o sistema de tipos conhece como tal, o que permite abstrair sobre o construtor não-aplicado (C[_]) e, com isso, escrever map, filter, iterators e collectors uma vez para qualquer container:
decl Mappable[C[_]] { fn map[A, B](xs: C[A], f: fn(A) -> B) -> C[B] }decl Filterable[C[_]] { fn filter[A](xs: C[A], p: fn(A) -> bool) -> C[A] }decl Collectable[C[_]] { fn collect[A](it: Iterator[A]) -> C[A] }
// List implementa Mappable estruturalmente (satisfação implícita, seção 9)fn (l: List[T]) map[A, B](f: fn(A) -> B) -> List[B] { ... }
// pipeline lazy (seção 11) colando com isto:source.filter(...).map(...).collect[List]()Por que map nomeia o receiver e write não. Uma interface escreve o tipo do receiver só quando o resultado depende dele. write(data: []byte) -> error (seção 9) é uma ação: o tipo de quem escreve não aparece no resultado, fica implícito. map é uma transformação: o que sai (C[B]) é função do que entra (C[A]), então o tipo do receiver é a assinatura, e o xs: C[A] não é exceção, é o que transformação exige. A mesma regra dá implícito para um e explícito para o outro porque ação e transformação são coisas diferentes.
Constraints de container. Uma função genérica sobre containers usa o mesmo vocabulário dos tipos ({conjunto} e + interface), levantado para o container pelo [...], que carrega o elemento:
C[<elemento>]: {<containers>} + <interface>- dentro do bracket restringe o elemento:
_(qualquer),_: {u8, string}(conjunto),_ + Display(capability). : {List, Set}restringe quais containers C pode ser (fora do bracket fica o container).+ Writeableexige que o container implemente a interface.
Todos opcionais, combinando como no nível de tipo:
fn ex[C[_]: {List, Set} + Writeable](...) // C é List ou Set, e implementa Writeablefn ex[C[_: {u8, string, byte}] + Writeable](...) // qualquer container de {u8,string,byte}, Writeablefn ex[C[_: {u8, string, byte}]: {List, Set}](...) // List ou Set, de {u8,string,byte}fn ex[C[_]: {List, Set}](...) // List ou Set, elemento qualquerMúltiplos containers se separam por vírgula, como qualquer type-var.
C[_] vs C[A]: qualquer container vs container de A. São declarações distintas, e a escolha diz o que você expressa. C[_] é “qualquer container”, de elemento anônimo: quando a transformação troca o elemento (entra A, sai B), você nomeia os elementos como type-vars à parte e aplica C a cada um:
fn pmap[C[_], A, B](c: C[A], f: fn(A) -> B) -> C[B] // ex.: List[u8] → List[string]C[A] é “container de A”: liga o elemento A na própria declaração (introduz C e A), para quando há um elemento, nomeado e preso ao container:
fn keep[C[A]](c: C[A], p: fn(A) -> bool) -> C[A] // filtra; elemento preservadoC[A] exige um container: [u8, string](c: u8, ...) é erro, porque u8 não é container type. Em resumo: C[_] mais vars separadas quando o elemento muda (A vira B), e C[A] quando há um elemento só, ligado ao container.
Onde isso paga o aluguel aqui: pmap (um processo por elemento, coletando de volta) escrito uma vez para qualquer container (a maquinaria de spawn, supervisão e coleta não se duplica por List/Set; C só precisa ser reconstruível elemento a elemento, a capacidade que Mappable exige), e collect, que materializa um iterator preguiçoso (seção 11) no container que você escolhe:
fn collect[D[_] + Collectable, A](it: Iterator[A]) -> D[A] // o D vem do tipo do destino
como_lista: List[int] = primos.collect() // mesma origem...como_set: Set[int] = primos.collect() // ...container de saída diferenteIsso é HKT “raso”: poderoso sem ser o footgun do Scala e do Haskell. Declarar e usar famílias de qualquer aridade é livre (Table[R, C, V], Tensor[A, B, C, D], sem custo de kind). O que tem orçamento é abstrair sobre o construtor (C[_]), e ele vem com três cortes:
- Aridade 1 e 2 só.
C[_](List,Set,Option) eC[_,_](Map[K,V],Result[T,E]) cobrem o grosso de FP. Sem kinds de ordem superior (_[_]): proibir buracos-de-família dentro de[...]é uma regra de uma linha que mata o abismo (oTraversegenérico, os monad transformers). A operação concreta continua escrevível na mão; só não se abstrai sobre todo par de containers. C[_]só declarado, nunca inferido. Onde se usa uma família como parâmetro, ela está escrita no[...]. O compilador resolveCpor aplicação direta (a lista éList[int], logoC[A] = List[A]eA = int): unificação de primeira ordem, não de kinds. Mata o inferno de inferência de kind do Scala.- Sem resolução implícita de instância. “Como
mapsabe construir umC[B]?” vem de uma interface explícita com satisfação estrutural (seção 9), resolvida por monomorfização. OCconcreto é conhecido em compilação, o que é necessário pra construir oC[B](um valor de interface apagado por vtable não saberia qual container montar), e não vem de uma busca implícita de typeclass. O HKT entra só como a assinatura da interface; o mecanismo de achar a implementação continua sendo o já-decidido. É isso que desarma o footgun de coerência (Haskell, Scala).
O resultado: C[_] te dá map, filter e collect genéricos sobre containers, mas não Traverse genérico nem qualquer coisa que peça o compilador adivinhar um kind. O footgun3000 vira chave de fenda: afiada, com escopo, sem coice.
Comptime e compilação condicional
Seção intitulada “Comptime e compilação condicional”Como comptime executa lógica em compilação, ele é também a base da compilação condicional: gerar ou omitir código conforme constantes de compilação. É o mecanismo que vai sustentar decisões de configuração de runtime (por exemplo, não embarcar um coletor se nenhuma alocação usa @mm(gc)). O desenho específico disso está na seção 5 (shipping de MM).
Reflexão comptime: reflect(T)
Seção intitulada “Reflexão comptime: reflect(T)”O compilador já conhece o layout de cada tipo: quais campos existem, de que tipo, quais são ponteiros. Ele usa isso internamente pra derivar o trace do GC (seção 5). reflect(T) expõe esse conhecimento ao código comptime, pra que serialize, hash e trace se derivem sozinhos, varrendo a estrutura em compilação. É reflexão de tipo, em compilação, não de valor em runtime: não há any (a munição de preguiça do interface{}, que faz parar de pensar no tipo), e o runtime sai sem custo, com o acesso a campo já resolvido em código reto.
reflect(T) é uma comptime fn que recebe um tipo e devolve um descritor da sua estrutura. Como todo comptime (seção 10), só roda em contexto de compilação e não lê valor de runtime: entrega a forma, e o valor entra no código que ela ajuda a gerar.
reflect não é builtin: como toda função, vive num pacote, o compiler, a interface de compile-time da linguagem (use compiler.{reflect, fail} a traz, seção 1). Junto dele mora fail, a falha-de-compilação com mensagem, usada nos braços não-suportados adiante (fail("kind não serializável")); é ela que ocupa o lugar do que seria um intrínseco solto. Os acessores que os descritores expõem (f.get, e.tag, variant.get/construct) vêm com eles. Já to_bytes/from_bytes de escalar (valor para bytes e de volta, nos braços Int/Float/Bool) são métodos do mem, onde também vivem os primitivos de layout (size_of/align_of/offset_of).
A forma do tipo é um variant-set. A primeira pergunta sobre um tipo é qual a sua forma: struct? enum? slice? inteiro? Isso é reflect(T).kind, e ele é um variant-set (seção 7): uma soma fechada com um kind por construtor-de-tipo da linguagem, cada um carregando o que aquela forma tem. Você dá match exaustivo:
comptime match reflect(T).kind { Struct(s) => ... // s.fields: descritores ricos dos campos Enum(e) => ... // e.tag(v): discriminante em runtime; e.variants: cada uma com .name, .tag, .payload (Optional[type]), .get(v), .construct(p) Slice(elem) => ... // elem: type do elemento Array(elem, n) => ... // elem + tamanho fixo n Optional(inner) => ... // inner: type interno Tuple(parts) => ... // parts: descritores posicionais (como s.fields, mas por índice) Int(i) => ... // i: { bits, signed } Float(f) => ... // f: { bits } Bool => ... String => ... // texto UTF-8 válido (seção 16): kind próprio, NÃO é Slice(byte), o decode revalida UTF-8 Ptr(p) | ManyPtr(p) | Channel(_) | Function(_) | RawPtr => fail("não vira bytes: endereço/identidade não significam nada do outro lado") _ => fail("kind não suportado") // rede de segurança}A forma de um tipo é uma soma fechada, exatamente o que o variant-set foi feito pra descrever, então a reflexão se descreve com a própria maquinaria da linguagem, sem mecanismo novo. O match é exaustivo (seção 7): cobrir uma forma nova vira obrigatório, e a regra que a encoding cravou (“auto-derivação falha pra *T cru”) não é caso especial, e sim um braço fail. O comptime match é necessário, não só permitido: os braços fazem coisas de tipos diferentes (o braço Struct usa s.fields, sem sentido se T é Int), então só o braço da forma real de T compila. E como todo match (seção 7), o comptime match exige cobertura: cubra todos os kinds ou use _, senão a derivação compila para alguns T e falha misteriosamente para outros (o footgun da não-exaustividade). É o modificador ortogonal comptime (seção 10), antes sobre fn/let/var/{} e agora sobre construtos de controle.
Descritor de campo rico. Cada campo em s.fields é um descritor com f.name (string), f.type (um valor type), f.offset e f.is_pointer, e, sendo objeto, tem métodos. Inclui também os decoradores de core e de extensão presentes no campo. Note o limite: bibliotecas não definem decoradores (seção 20), então não há tag-de-lib pra reflexão ler. Customização de serialização é por política de nomes ou override de método; tag-driven (estilo serde) é uma extensão de compilador, e a fricção é o ponto.
Ler é f.get(v). O descritor sabe se extrair de um valor: f.get(v) devolve o campo f de v, com tipo f.type. É a ponte entre o descritor (comptime) e o valor (runtime): um método no descritor, sem builtin novo (o @field do Zig) nem sintaxe nova. Como cada f.type difere, o laço sobre s.fields é desenrolado em compilação (abaixo), e cada iteração já conhece o tipo concreto.
Produto lê com acessor total; soma, com parcial. O .get se estende às outras formas, e a diferença entre elas é a diferença produto-vs-soma. Numa tupla (produto, como struct) cada elemento de parts é um descritor posicional com p.get(v) -> p.type, total, pois o elemento sempre está lá. Num enum (soma) cada variante de e.variants tem variant.get(v) -> Optional[variant.payload], parcial: presente só na variante ativa, ausente nas outras. O Optional aí não é remendo, e sim a soma aparecendo no tipo (produto tem todos os membros; soma tem um). A leitura fica uniforme: comptime loop mais .get nas três formas, total no produto, parcial na soma. (Cada variante também expõe .tag, o discriminante, e .construct(payload), o dual de .get para a escrita.)
Construir é reflect(T).construct(fn(f) => …), o dual de f.get. Você passa a receita “dado o descritor de um campo, produza o valor dele”, e construct monta o T. A receita recebe o descritor rico, então é agnóstica de formato (a fonte é decisão sua), e construct garante avaliação na ordem de declaração dos campos (num formato de stream, a ordem importa). A receita pode ser falível: se ela devolve Result[f.type, E] (o caso do decode, onde ler um campo pode falhar), então construct tem o tipo Result[T, E] e curto-circuita no primeiro Err. Isso preserva o “sem init parcial” (ou sai um T inteiro, ou um Err, nunca um T pela metade) e é o que torna decodificar um produto possível sem operador ? (cortado) e sem um catch que só escaparia o lambda. Para o array homogêneo há a forma irmã construct_array(n, fn(i) => …) (contagem mais produtor por índice, também falível):
reflect(T).construct(fn(f) => decode[f.type](src)) // posicional/stream// desenrola → T{ c0: decode[T0](src), c1: decode[T1](src), … }reflect(T).construct(fn(f) => decode[f.type](obj[f.name])) // por nome (JSON)A construção é por forma, roteada pelo match do kind: produto é o literal atômico que construct sela (T{…} numa expressão só). Struct e tupla são produtos heterogêneos (descritores posicionais de tipos distintos, s.fields/parts), montados por construct(fn(m) => …). O array [n]T é produto homogêneo: reflect(Array) expõe (elem, n), não descritores posicionais (isso é da tupla), porque são n cópias do mesmo elem; monta com a forma irmã construct_array(n, fn(i) => …), com n vindo do tipo (não do stream). Slice []T é o único dinâmico: builder por push, e como a contagem não está no tipo, é gravada na frente (um usize serializado) e relida antes do push. Soma (enum) constrói por-variante via variant.construct(payload) (o dual de variant.get), roteada pela tag lida (variant.tag). Produto (struct, tupla, array) tem que ser atômico, não um builder campo-a-campo, porque a linguagem não tem valor parcialmente inicializado (todo binding nasce com RHS; não há o undefined do Zig). Um builder de struct fabricaria um T com campos lixo; o literal ou compila inteiro ou não compila, e nunca existe um T pela metade. (Slice escapa disso porque seu builder é uma List crescendo normal, não um struct meio-pronto.)
O desenrolar, e a simetria leitura/escrita. Como varrer reflect(T).fields em compilação vira código de runtime? O loop/match sobre algo comptime-conhecido é desenrolado e resolvido em compilação; se o corpo tem operação de runtime, cada cópia é código de runtime. Não é otimização, é necessidade: f.get(v) muda de tipo a cada campo, e um laço de runtime não pode ter corpo cujo tipo varia por iteração. Em contexto comptime (dentro de comptime {}/comptime fn) o desenrolar é implícito; em código de runtime ele leva o marcador comptime loop/comptime match. Daí a simetria: a escrita acumula com comptime loop (acumulação normal, desenrolada), e a leitura monta com construct (atômico, porque não se dá push num struct).
fn serialize[T](v: T, out: mut Buffer) { // escreve a forma de v em 'out' (out-param = a forma do Serializable, sem alocar a cada chamada) comptime match reflect(T).kind { Struct(s) => comptime loop f in s.fields { serialize(f.get(v), out) } Tuple(parts) => comptime loop p in parts { serialize(p.get(v), out) } // posicional, como struct Enum(e) => { put_varint(out, e.tag(v)) // discriminante (varint; já serializa variante sem payload) comptime loop variant in e.variants { comptime if variant.payload.is_present() { // só variantes COM payload p := variant.get(v) // Optional[payload]: presente só na ativa if p.is_present() { serialize(p.or_panic(), out) } } } } Slice(_) => { put_varint(out, v.len); loop x in v { serialize(x, out) } } // contagem (varint) na frente; `.len` é campo do slice Array(_, _) => loop x in v { serialize(x, out) } // n vem do tipo; sem contagem Optional(_) => { out.write_byte(v.is_present() as byte); if v.is_present() { serialize(v.or_panic(), out) } } String => { put_varint(out, v.bytes.len); out.write(v.bytes) } // contagem + bytes UTF-8 (kind próprio, seção 16; `.bytes` é a view, sem `len()` pelado) Int(_) | Float(_) | Bool => out.write(v.to_bytes()) Ptr(_) | ManyPtr(_) | Channel(_) | Function(_) | RawPtr => fail("não serializa: endereço/identidade não significam nada do outro lado") _ => fail("kind não serializável") }}
fn decode[T](src: Readable) -> Result[T, error{Malformed, Truncated}] { // dual de serialize; lê de um Readable (o público envolve []byte num Buffer.over(data)) comptime match reflect(T).kind { Struct(_) => reflect(T).construct(fn(f) => decode[f.type](src)) // construct FALÍVEL: a receita devolve Result, construct propaga o 1º Err Tuple(_) => reflect(T).construct(fn(p) => decode[p.type](src)) // idem, posicional Enum(e) => { tag := read_varint(src) catch |er| { return Err(er) } // discriminante (varint; dual de put_varint) comptime loop variant in e.variants { if variant.tag == tag { // achou a variante pelo discriminante comptime if variant.payload.is_present() { p := decode[variant.payload.or_panic()](src) catch |er| { return Err(er) } return Ok(variant.construct(p)) } else { return Ok(variant.construct()) } // variante sem payload } } return Err(Malformed) // tag fora do conjunto } Slice(elem) => { n := read_varint(src) catch |er| { return Err(er) }; var xs := List[elem].new(); loop _ in 0..n { xs.push(decode[elem](src) catch |er| { return Err(er) }) } return Ok(xs) } // contagem (varint), depois N Array(elem, n) => reflect(T).construct_array(n, fn(i) => decode[elem](src)) // HOMOGÊNEO: elem + contagem do tipo, construct falível (NÃO p.type) Optional(inner) => { b := io.read_byte(src) catch |er| { return Err(er) }; if b == 0 { return Ok(none) }; x := decode[inner](src) catch |er| { return Err(er) }; return Ok(x) } // x: inner → coage p/ Optional[inner] String => { n := read_varint(src) catch |er| { return Err(er) }; raw := io.read_bytes(src, n) catch |er| { return Err(er) }; s := string.from[u8](raw) catch |_| { return Err(Malformed) }; return Ok(s) } // contagem + bytes, revalida UTF-8 (InvalidUtf8 → Malformed) Int(_) | Float(_) | Bool => T.from_bytes(src) // mem.from_bytes(Readable) → Result[T, Truncated] _ => fail("kind não desserializável") }}Na prática, quem usa nunca vê reflect. O autor da json escreveu o encode/decode acima uma vez; você só declara o tipo e chama:
decl User { id: u64 name: string admin: bool}
u := User{ id: 42; name: "ana"; admin: true }txt := json.encode(u) // {"id":42,"name":"ana","admin":true}back := json.decode[User](txt) // Result[User, json.Error] → Ok(User{42; "ana"; true})Nenhuma linha de serialização escrita, nenhuma macro derive, nenhuma tag. A constraint [T + Serializable] na assinatura de json.encode dispara a derivação por reflexão sobre User, em compilação, e o encode que roda é código reto (escreve a chave e o valor de cada campo, em sequência), sem laço nem reflexão em runtime.
A mesma maquinaria serve hash, e por isso um struct é chave de Map/Set sem declarar nada:
decl Point { x: int; y: int }
m := Map[Point, string]()m.set(Point{1; 2}, "origem") // hash de Point derivado por reflexão, sem interface Hasherp := m.get(Point{1; 2}) // Optional[string] → "origem"Quando o wire-name difere do campo, a customização é uma política de nomes passada à chamada (de snake para camel, por exemplo), não uma anotação por campo. É coerente com “decorador é etiqueta de subsistema, não tag de lib” (seção 20).
Decode e invariantes de tipo. construct seta os campos direto, pulando o construtor que mantém um invariante. Um tipo com invariante (um SortedList sempre ordenado, um Email sempre válido, um NonZero nunca zero) decodificado pela derivação automática sairia com os bytes crus da fonte, possivelmente inválido, e o resto do programa que confia no invariante quebraria. A linha é em duas camadas: (1) se o tipo define um hook validate(self) -> error{…}, o decode auto-derivado o chama depois do construct e curto-circuita no Err, atalho leve pra invariante que é só checagem (validate rejeita, não transforma; serve NonZero, Email, “exijo bytes já canônicos, senão Err”); (2) pra invariante que precisa transformar na decodificação (ordenar, normalizar), o tipo sobrescreve o decode (o override que já existe como escape-hatch) e roteia pelo seu construtor, pois validate não ordena e o construtor sim. Por tipo você usa um ou o outro; a auto-derivação crua fica para tipos sem invariante. (reflect vê campos privados, por ser auto-reflexão do código sobre si mesmo, então claro que alcança tudo; é justamente por isso que o decode chega aos privados, e por isso validate e override existem pra reimpor o que o construtor garantiria.)
Tipos core-blessed refletem pela representação. Duration (o u64-ns, seção 14) reflete como o Int que é: serializa e hasheia pelo braço Int, sem kind próprio. BigInt (núcleo) reflete pela estrutura interna (sinal mais dígitos, um Slice), pelos braços Struct e Slice. A régua: um tipo só ganha kind próprio (como String) quando a serialização não decorre da forma. string precisa porque o decode tem que revalidar UTF-8 e porque ela não é estruturalmente um slice no sistema de tipos; Duration e BigInt não têm esse invariante cruzado, então a forma já basta.
Reflexão é a camada engine da seção 10 (“comptime cru … reflexão”), a ferramenta de força que a maioria nunca toca direto: você escreve [T + Serializable] e a derivação roda por baixo. Quem desce até reflect(T) é autor de biblioteca (encoding, json, hash, Map/Set) ou de algo que gera tipos.