Voltar ao início

Go Internals - Interfaces

8 min de leitura
Cover Image for Go Internals - Interfaces
Lucas LemosLucas Lemos

Introdução

Em Go Internals - Tipos Nativos vimos layouts concretos como headers de slice e maps Swiss-table. Interfaces são o outro lado dessa história: elas escondem tipos concretos no nível da linguagem, mas o runtime ainda precisa de metadados exatos e alvos de chamada para executar métodos com segurança.

Se você já se perguntou por que atribuir para interface{} pode alocar, por que algumas type assertions são quase gratuitas enquanto outras não são, ou por que dispatch dinâmico aparece como indireção extra em CPU profiles, esta é a camada certa para abrir.

Dois formatos no runtime: eface e iface

No runtime, valores de interface são representados por duas words, mas há duas variantes:

  • eface para interfaces vazias (interface{} / any)
  • iface para interfaces não vazias (io.Reader, fmt.Stringer, method sets customizados)

Em linhas gerais:

// conceitual, simplificado de runtime2.go
type eface struct {
  _type *_type
  data  unsafe.Pointer
}

type iface struct {
  tab  *itab
  data unsafe.Pointer
}

eface aponta direto para metadado de tipo (_type) e dado. iface usa itab, que combina tipo concreto + tipo da interface + metadados de dispatch de métodos. Em ambos os casos, a segunda word é um ponteiro para onde o valor concreto vive (ou os próprios bits do valor em casos pequenos sem ponteiros após lowering do compilador).

flowchart LR
  E["eface: _type + data"] --> T["_type metadata"]
  E --> D["valor concreto"]
  I["iface: itab + data"] --> IT["itab: mapeamento interface+concreto"]
  I --> D2["valor concreto"]

O ponto importante: valores de interface não são "caixas mágicas". São structs concretas que compilador e runtime compartilham.

Como itab é usado

Para uma atribuição em interface não vazia, o runtime precisa responder:

  1. O tipo concreto implementa o method set dessa interface?
  2. Se sim, onde ficam os entry points de função para cada método da interface?

Esse mapeamento fica em itab (veja runtime/iface.go). Quando disponível, chamadas de método via interface viram chamadas por ponteiro de função indexado nessa tabela.

sequenceDiagram
  participant C as valor concreto
  participant R as runtime/iface
  participant I as valor iface
  C->>R: converte para tipo de interface
  R->>R: resolve ou cria itab
  R->>I: guarda ponteiro itab + ponteiro data
  I->>I: chama método pelo slot de função do itab

Lookup de itab é cacheado. Conversões de primeira vez para um par concreto/interface custam mais que conversões repetidas em hot paths.

O que "lookup de itab cacheado" significa no runtime

Em runtime/iface.go, o Go mantém uma tabela global de itab indexada pelo par:

  • tipo de interface
  • tipo concreto

A conversão para interface não vazia costuma seguir este fluxo:

  1. Fast path (leitura lock-free): busca o par (interfacetype, *_type) na tabela atual.
  2. Hit: reutiliza o itab existente e segue.
  3. Miss: cai no slow path, pega lock do runtime, valida compatibilidade de method set, aloca/insere novo itab e publica.
  4. Conversões futuras: voltam ao fast path com hit.

Na prática, o trabalho mais caro de compatibilidade costuma ser pago uma vez por par de tipos durante a vida do processo e depois amortizado.

flowchart TB
  A["conversão concreto -> interface"] --> B{"itab está na tabela?"}
  B -->|sim| C["reutiliza itab cacheado"]
  B -->|não| D["slow path: lock + cria/insere itab"]
  D --> E["publica entrada na tabela"]
  E --> C

Por que isso importa em serviços quentes

Duas consequências práticas:

  • Picos em cold start / primeiro acesso: endpoints que passam por muitos tipos concretos atrás da mesma interface podem pagar uma rajada de criação de itab no começo.
  • Estado estável mais barato: quando os pares comuns já existem, o custo de conversão cai para probe de tabela + carregamento de ponteiros.

É por isso que microbenchmarks curtos podem enganar: se o teste mede repetidamente só as primeiras conversões, ele superestima o custo de conversão de interface em relação a serviços de longa duração.

Cache não remove todo o custo de interface

Mesmo com cache de itab quente:

  • chamadas de método continuam com dispatch indireto por ponteiro de função,
  • valores boxed ainda podem escapar dependendo do tempo de vida,
  • assertions e switches ainda fazem checagens em runtime.

Ou seja, o cache remove custo repetido de resolução, não todo o custo de comportamento dinâmico.

Boxing: para onde os valores vão

Quando você atribui um valor concreto para interface, o runtime guarda uma referência desse valor na word de dados da interface. Se isso implica alocação no heap depende de escape analysis e do formato do valor.

type Big struct {
  a, b, c, d, e, f, g, h int64
}

func box() any {
  v := Big{a: 1}
  return v // frequentemente escapa: interface vive além do frame local
}

Se o compilador prova que o valor boxed não sobrevive ao frame, ele pode ficar na stack. Caso contrário, boxing passa pelo caminho de alocação que você já viu no artigo de allocator.

Comando útil:

go build -gcflags='-m=2' ./...

Procure por mensagens "escapes to heap" perto de conversões para interface. Esse output costuma ser o caminho mais curto para explicar uma alocação inesperada em benchmark.

Custo de dispatch de método na prática

Uma chamada direta concreta costuma ter alvo estático. Uma chamada via interface tem passos extras:

  1. Carregar itab
  2. Carregar ponteiro de função do slot do método
  3. Chamar indiretamente com o ponteiro de dados do receiver

Esse overhead em geral é pequeno, mas real em loops muito quentes.

type Adder interface {
  Add(int, int) int
}

type impl struct{}

func (impl) Add(a, b int) int { return a + b }

func viaInterface(x Adder, n int) int {
  s := 0
  for i := 0; i < n; i++ {
    s += x.Add(i, i+1)
  }
  return s
}

O custo maior muitas vezes não é a chamada indireta em si, mas oportunidades de otimização perdidas ao redor dela (por exemplo, menos inlining comparado a chamadas diretas monomórficas).

Type assertions e type switches

Type assertion de interface para tipo concreto verifica metadados de tipo em runtime:

v, ok := x.(MyType)

Em eface, o runtime compara _type concreto. Em iface, pode envolver checagens de relação interface/concreto e caminhos cacheados. Assertion falha tem custo de branch, mas não panic na forma comma-ok.

Type switch é uma sequência dessas checagens com ajuda de compilador/runtime:

switch v := x.(type) {
case string:
  _ = v
case int:
  _ = v
default:
}

Em caminhos críticos, assertions repetidas podem pesar mais que o dispatch se estiverem dentro de loops profundamente aninhados.

Armadilha de nil: interface nil vs typed nil

Um dos bugs de produção mais comuns com interface:

var p *MyError = nil
var err error = p
fmt.Println(err == nil) // false

err não é nil porque o valor de interface tem:

  • metadado de tipo não-nulo (*MyError)
  • ponteiro de dados nulo

As duas words precisam ser nil para a interface em si ser nil.

flowchart TB
  A["valor de interface"] --> B["type word != nil"]
  A --> C["data word == nil"]
  B --> D["interface != nil"]
  C --> D

Por isso retornar (*MyError)(nil) como error pode quebrar lógica de if err != nil.

Interface vazia vs interface restrita

Escolher entre any e uma interface específica muda o comportamento em runtime:

Escolha Runtime shape Trade-off típico
any / interface{} eface Mais genérico, normalmente exige assertions depois
interface com métodos iface + itab Polimorfismo direto por method set, overhead de dispatch

any é ótimo para containers genéricos e fronteiras com reflexão, mas se o call site imediatamente faz assertion para um de dois tipos, uma interface mais estreita ou generics pode ser mais clara e barata.

Dispatch por interface vs generics

Ambos resolvem abstração, mas pagam custos diferentes:

Tópico Interfaces Generics (stenciling/dictionaries conforme o caso)
Dispatch Dinâmico em runtime (itab) Geralmente estático nos pontos de instanciação
Checagens de tipo Assertions em runtime possíveis Constraints em compile-time + caminhos gerados
Tamanho de código Menor, código compartilhado Pode crescer com muitas instanciações
Perf em hot loop Pode perder inlining/devirt Frequentemente mais perto de chamadas concretas

Não existe vencedor universal. Para fronteiras de plugin e valores heterogêneos, interfaces são a ferramenta certa. Para loops numéricos/de dados apertados com tipos previsíveis, generics podem tirar dispatch dinâmico do hot path.

Diretrizes práticas

  • Mantenha limites de interface nas bordas de pacote, não em todo helper interno.
  • Prefira interfaces pequenas e focadas em comportamento (Read, Write, Do) em vez de "god interfaces".
  • Meça antes de trocar interfaces por generics ou tipos concretos.
  • Use -gcflags='-m=2' e pprof juntos: um explica escapes, o outro mostra onde o tempo realmente vai.

Conclusão

Interfaces em Go são objetos concretos de runtime: eface e iface, com itab sustentando dispatch de métodos em interfaces não vazias. Os custos centrais vêm de boxing/escapes, chamadas indiretas e padrões de assertion, não de um único "modo lento" escondido.

Próximo na série é Reflection — como reflect.Type e reflect.Value se apoiam nesses mesmos descritores de tipo do runtime, e por que caminhos pesados em reflection costumam alocar mais do que parece.