Go Internals - Interfaces
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:
efacepara interfaces vazias (interface{}/any)ifacepara 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:
- O tipo concreto implementa o method set dessa interface?
- 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:
- Fast path (leitura lock-free): busca o par
(interfacetype, *_type)na tabela atual. - Hit: reutiliza o
itabexistente e segue. - Miss: cai no slow path, pega lock do runtime, valida compatibilidade de method set, aloca/insere novo
itabe publica. - 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
itabno 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:
- Carregar
itab - Carregar ponteiro de função do slot do método
- 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'epprofjuntos: 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.