Pular para o conteúdo

Especificação §2

Sistema de tipos

language-design.md §2 · 165 linhas · 10 min de leitura

  • Expressivo mas simples: tipos algébricos (sum types, product types), generics, pattern matching, sem borrow checker, sem lifetimes
  • Explícito e “matematicamente provável”: o compilador consegue raciocinar sobre o código sem análise de lifetime
  • Sem classes: composição sobre herança, sempre
  • Structs (product types)
  • Enums / sum types
  • Interfaces (ver seção 9)
  • Generics (ver seção 10)
  • Pattern matching exaustivo
  • union cru: reinterpretação de bytes, unsafe, para FFI e bit-tricks (escape-hatch; ver seção 5)

Uma keyword, decl, declara struct, enum e interface, e o corpo diz qual dos três, como match comuta sobre o sujeito e loop repete sobre a condição. Não há struct/enum/interface como keywords separadas: o ato é definir uma destas três formas, e o corpo (campos, variantes ou assinaturas) desambigua qual.

decl Point { x: int; y: int } // campos 'nome: tipo' → produto (struct)
decl Shape { Circle(int); Square } // variantes 'Nome(...)' → soma (enum)
decl Drawable { fn draw() } // assinaturas 'fn ...' → comportamento (interface)

Campo (nome: tipo), variante (Nome/Nome(...)) e assinatura (fn ...) são sintaticamente distintos, e como método é função-com-receiver à parte (abaixo), um corpo é homogêneo, nunca mistura os três. “Struct”, “enum” e “interface” seguem sendo os nomes das três formas (produto, soma, comportamento); decl é a única keyword.

E há uma razão mais funda pra uma keyword só dar conta dos três: eles são, todos, contratos. Uma struct é um contrato de estrutura: estes campos, com estes tipos. Uma enum é um contrato de valores: uma destas variantes, e nenhuma fora da lista. Uma interface é um contrato de comportamento: estas funções existem, com estas assinaturas. O que struct/enum/interface sempre compartilharam não foi a forma do corpo; foi o ato, e o ato é um só, declarar um contrato, que é exatamente o que decl nomeia. O corpo só diz sobre o quê o contrato é.

Um enum é uma soma fechada, e suas variantes formam um domínio que se sub-seta: Shape{Circle} é “um Shape, garantido Circle”, um conjunto de variantes, a mesma mecânica que unifica com os error-sets (seção 7).

Um tipo recursivo passa por indireção. Um struct ou enum não pode conter a si mesmo por valor — direto (decl Node { next: Node }) ou transitivamente através de outro agregado por-valor (decl Cons { head: int; tail: Optional[Cons] }) — porque um layout flat teria tamanho infinito. A auto-referência passa por um ponteiro *Node, um many-pointer [*]Node, ou um slice []Node, cada um uma alça de tamanho fixo pra outro lugar, então decl Node { value: int; next: Optional[[*]Node] } é a forma de um nó de lista. É a regra que C e Rust compartilham (o Box do Rust): o compilador reporta o ciclo por-valor e nomeia a correção, ele nunca boxa em silêncio — alocação escondida quebraria o contrato “você só paga pelo que invoca” da seção 5.

O union fica de fora: seu corpo é campos, idêntico ao do produto, então o corpo não o desambigua. Precisa de keyword própria, e ela carrega o unsafe que o sistema de reinterpretação de bytes exige.

O corpo vazio: Unit, Never e o Top deliberadamente ausente. O corpo vazio de cada forma é um tipo distinto, e a habitação os mantém separados. decl Marker {} é a struct vazia, um Unit: habitado por exatamente um valor de tamanho-zero — um marcador/tag (o slot de valor de um Set[T], um parâmetro phantom, um token “isto aconteceu”). decl Never { _ } é o enum vazio, o tipo Never: zero variantes, não-habitado, nenhum valor construível — o bottom na posição de valor (o mesmo {} não-habitado que error{} nomeia do lado dos error-sets, seção 7, e que noreturn nomeia na posição de retorno, seção 14). O _ solitário no corpo é o marcador explícito de “sem variantes”; um {} de fato vazio resolve pra struct (Unit), o mais útil dos três, então o Never pede o _ pra dizer “eu quis dizer zero, não esqueci”. A interface vazia seria o Top: nenhum comportamento exigido, então todo tipo a satisfaz — que é exatamente Any, e Makoto deliberadamente não tem como escrevê-lo (seção 9). A assimetria é o ponto: Unit (um valor) e Never (nenhum valor) são expressáveis; Top (todos os valores, i.e. um bound que não promete nada) não é, porque um contrato que não garante comportamento não garante nada que você possa fazer, e reabre o lazy-typing que Optional e as interfaces fecharam. Unit / Never / Top — dois você nomeia, um você não pode, de propósito.

decl cria estrutura nova. Quando você só quer um segundo nome pra um tipo que já existe, é alias:

alias UUID = u8 // UUID é u8, intercambiável

É um synonym transparente: UUID e u8 são o mesmo tipo, então uuid + 32 compila (UUID é u8). A linguagem já trazia isso embutido (char é apelido de grapheme, seção 16); alias é a keyword que nomeia o ato. Um tipo distinto, com identidade própria e não-intercambiável, não é alias: é um decl (uma struct envolvendo o valor).

O = separa os dois atos: decl Point { ... } não leva =, porque o corpo {...} é a definição (o tipo nasce da estrutura); alias UUID = u8 leva =, porque não há estrutura nova, só a equação “este nome é aquele tipo”. Tem = apelido (já existe); não tem estrutura nova. E type fica intocado: continua sendo só o kind (T: type, -> type, seção 10). alias nomeia tipos, type é o tipo dos tipos.

  • Classes / herança
  • Borrow checker
  • Lifetimes explícitos
  • Box<Arc<'_Type<'c<uint>>>> e similares

Em vez de lifetimes, a garantia vem da linearidade de escopo nos pontos de transferência: quando um valor é enviado com @transfer, o compilador invalida a variável no escopo atual imediatamente. Não há “empréstimo”: ou o dado é seu, ou você transferiu. O compilador só precisa verificar: “essa variável já foi transferida?”. Se sim, erro de compilação. (Essa mesma ideia de checagem local reaparece na mutação dentro de um processo, com mut e ponteiros; ver seção 6.)

A linguagem não tem herança: composição é o mecanismo de reúso, no modelo do Go (embedding), mas com a promoção declarada em vez de inferida de um campo anônimo. Dois fatos do modelo de tipos que isso pressupõe:

  • Struct só tem campos. Métodos não vivem dentro do struct; são funções livres com um receiver: fn (d: *Dog) speak() -> string { ... } (modelo Go; o . da chamada é o UFCS da seção 14).
  • Métodos são funções normais, então levam generics próprios: fn (d: *Data) compute[T](x: T) -> T { ... }. O [T] (seção 10) funciona num método como em qualquer função, porque um método é uma função com receiver, não um construto à parte.
  • Funções associadas (construtores, fábricas) têm o receiver sem binding: fn (Arena) new() -> Arena. O tipo no receiver, sem nome, marca “associada ao tipo, sem instância”; com nome (fn (a: Arena) used()) é método, sem nome é associada. A chamada é por ponto, como qualquer membro do tipo: Arena.new(), string.from[u8](bytes), o mesmo . do UFCS (seção 14), resolvendo um membro associado em vez de um método de instância. (Sem keyword static: a presença ou ausência do binding já é a marca, e static arrastaria a conotação de linkage do C, coloring disfarçado.)

O @embeds(Tipo, methods|fields|all) declara que o struct compõe outro tipo, promovendo o que você especifica (métodos, campos, ou tudo); o promovido vira acessível direto no embarcador:

decl Animal {}
fn (a: *Animal) speak() -> string { return "..." }
@embeds(Animal, methods)
decl Dog {
animal: Animal
nome: string
}
d.speak() // promovido: chama Animal.speak direto, sem citar 'animal'
d.animal.speak() // o caminho nomeado também funciona

O que separa isso do embedding do Go é que a promoção é anunciada: o @embeds no topo diz “este struct promove métodos do Animal”. No Go, a promoção é inferida de um campo anônimo (um Animal sem nome perdido no struct), e nada na definição avisa que métodos vão subir. Aqui você lê o decorator e sabe: a promoção é explícita, não um efeito colateral de omitir um nome.

Duas posições, mesma regra da linguagem. O @embeds segue a convenção que o asm já usa (seção 18): decorator em cima para config do todo (como @supervisor acima do spawn), decorator ao lado para ligar a um campo específico (como @asm(in, …) no parâmetro). Você escolhe pela legibilidade: em cima é enxuto pra struct pequena; ao lado lê melhor quando a struct cresce (cada campo mostra, na sua linha, que promove):

// EM CIMA: config do todo (anônimo, no nível da struct)
@embeds(Animal, methods)
decl Dog {
animal: Animal
nome: string
}
// AO LADO: liga a promoção ao campo
decl Robot {
motor: Motor @embeds(methods) // campo 'motor: Motor', promove os métodos
sensors: Sensors @embeds(all) // campo 'sensors: Sensors', promove tudo
serie: string
}

A diferença entre as duas formas do @embeds é só onde o tipo mora, e isso decorre de ter ou não um campo:

  • Campo nomeado: campo: Tipo @embeds(what). O campo é normal (motor: Motor), e o @embeds(what) só anota o que promover; o tipo está no campo, não se repete. (what é methods/fields/all.)
  • Anônimo: @embeds(Tipo, what) sozinho. Sem campo antes, então o tipo vai no decorator (a única forma de dizê-lo). É o embedding sem nome.

E as posições coexistem na mesma struct (como @supervisor pode estar em cima ou antes do spawn): você pode ter @embeds em cima, ao lado de campos, e anônimo dentro, tudo de uma vez:

@embeds(LivingThing, all) // (em cima) embarca LivingThing no nível da struct
decl Android {
brain: Brain @embeds(methods) // (ao lado, nomeado) campo 'brain: Brain', promove métodos
power: Power @embeds(all) // (ao lado, nomeado) campo 'power: Power', promove tudo
@embeds(Chassis, fields) // (anônimo, dentro) sem campo → o tipo vai no decorator
serie: string
}

Aqui o Android promove de quatro fontes ao mesmo tempo (LivingThing de cima, Brain e Power dos campos, e Chassis anônimo), e a regra de conflito abaixo vale sobre tudo que é promovido, não importa de qual posição veio.

Campo nomeado vs. anônimo, e o conflito. Você pode embarcar com nome (animal: Animal) ou sem (Animal solto). A diferença só aparece no conflito de método:

// ERRO: anônimo + colisão de método
@embeds(Animal, methods)
decl Dog {
Animal // Dog e Animal ambos têm speak() → d.speak() seria ambíguo
nome: string
}
// OK: nomeie o campo, e a colisão se resolve pelo caminho
@embeds(Animal, methods)
decl Dog {
animal: Animal
nome: string
}
fn (d: *Dog) speak() -> string { return "Woof" }
fn (a: *Animal) speak() -> string { return "..." }
d.speak() // Dog.speak, o próprio
d.animal.speak() // Animal.speak, pelo caminho nomeado
  • Sem colisão o anônimo funciona limpo (d.speak() chama o do Animal, sem nome).
  • Com colisão o anônimo é erro de compilação (d.speak() ambíguo, sem como desambiguar). A correção é dar nome ao campo.

Sem shadowing, a promoção é seletiva. Com campo nomeado + @embeds, promovem-se os métodos que não colidem (você chama d.qualquer_metodo_do_animal() direto), e só os que colidem caem pro caminho nomeado (d.animal.speak()). O método do embarcado nunca some: quando há colisão, o próprio (d.speak()) ganha, mas o do Animal continua com endereço claro (d.animal.speak()), não escondido. É a diferença de “sem shadowing”: o Go deixaria o Dog.speak mascarar o Animal.speak silenciosamente; aqui os dois têm nome, e a colisão se resolve por caminho explícito, não por uma regra invisível de quem-ganha.

A mesma regra vale pra embeds irmãos (vários @embeds no mesmo struct): se nenhum método colide entre os embarcados, nada precisa de nome; se dois irmãos têm o mesmo método, os campos precisam de nome pra desambiguar.

Satisfação de interface segue da promoção. Se @embeds(Animal, methods) promove métodos que satisfazem uma interface (seção 9), o embarcador a satisfaz, porque os métodos são dele agora. E não é mágico: segue do @embeds visível no topo. Animal satisfaz Speaker, Dog embarca promovendo métodos Dog satisfaz Speaker.

Extrair a parte embarcada: b as A. O embedding promove métodos/campos para acesso, mas B não vira A: um tipo concreto é nominal (do_something(value: A) quer um A, não “algo que embarca A”). Quando você precisa passar a parte-A de um B, a extração é explícita com as:

fn do_something(value: A) { ... }
b := B{ ... } // B embarca A (@embeds(A, all))
do_something(b) // ERRO: B não é A (nominal)
do_something(b as A) // OK: extrai o A embarcado, dropando o que é específico de B

b as A produz o A embarcado por valor (cópia). É a única forma quando o embed é anônimo (não há b.a para acessar); para embed nomeado é um sinônimo de b.a. A regra mantém a explicitude: nada de B-vira-A implícito, você escreve a conversão. (Para uso estrutural, “qualquer coisa com estes métodos”, o caminho é uma interface, não o tipo concreto; o embedding faz o embarcador satisfazer as interfaces do embarcado, seção 9.)