Voltar ao início

Go Internals - Reflection

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

Introdução

Em Go Internals - Interfaces tratamos _type e itab como a resposta do runtime a "o que é este valor, e como chamo seus métodos?" Reflection é a API pública que percorre esses mesmos descritores depois que o compilador saiu de cena.

encoding/json, ORMs, containers de DI e um volume surpreendente de código "genérico antes de Go ter generics" vivem em cima de reflect.Type e reflect.Value. O custo não é mistério: cada ValueOf, walk de campos, Interface() e especialmente Call é código comum que pode alocar, checar flags e montar frames de argumentos — sem as otimizações que um call site monomórfico recebe.

Mesmos descritores, outro pacote

O compilador embute metadados de tipo no binário. Em runtime esses descritores são abi.Type (e vizinhos mais ricos como abi.StructType, abi.FuncType). O artigo de Interfaces usava o nome antigo _type; no Go 1.26 a forma compartilhada mora em internal/abi.

reflect não inventa um segundo sistema de tipos. reflect.Type é uma interface implementada em grande parte por rtype, que envolve abi.Type:

// reflect/type.go — simplified
type rtype struct {
  t abi.Type
}

func TypeOf(i any) Type {
  return toType(abi.TypeOf(i))
}

func TypeFor[T any]() Type {
  return toRType(abi.TypeFor[T]())
}

TypeOf recebe um valor de interface e lê o tipo concreto dele — o mesmo header de empty interface que você já conhece (Type + Data). TypeFor[T]() evita a rota pela interface quando o tipo é conhecido em compile time, o que é o melhor default para tabelas de lookup e setup one-shot.

flowchart LR
  A["interface / TypeFor[T]"] --> B["abi.Type descriptor"]
  B --> C["reflect.rtype"]
  C --> D["reflect.Type methods"]

Então, quando você chama t.Kind(), t.NumField() ou t.Implements(u), está perguntando metadados que o linker já reteve para GC, interfaces e o runtime — não algo que reflection "descobre" magicamente no último segundo.

Layout de Value: typ_, ptr, flag

reflect.Value é três palavras de bookkeeping em torno de um valor Go:

// reflect/value.go — simplified
type Value struct {
  typ_ *abi.Type
  ptr  unsafe.Pointer
  flag
}
  • typ_ — qual tipo concreto este Value afirma carregar.
  • ptr — ou os bits do valor (em alguns casos de direct interface) ou um ponteiro para os dados quando flagIndir está setado.
  • flagKind empacotado nos bits baixos, mais bits de read-only / indir / addr / method acima disso.

ValueOf é literalmente desempacotar uma empty interface:

func ValueOf(i any) Value {
  if i == nil {
    return Value{} // invalid: IsValid() == false
  }
  return unpackEface(i)
}

Isso volta à armadilha de nil de interface: ValueOf((*T)(nil)) é um Value válido de kind Pointer cujo ponteiro é nil. ValueOf(nil) (nil interface sem tipo) é o Value zero e panica em quase todo método. Mesma história de duas palavras que em error; pacote diferente.

flowchart TB
  I["any / eface"] --> U["unpackEface"]
  U --> V["Value{typ_, data, flag}"]
  V --> R["Field / Index / Call / Interface"]

Addressability e settability

A maior parte da confusão de "por que Set panicou?" é flag, não tipos.

  • CanAddr — o Value aponta para uma localização addressable (flagAddr). Locais passados por ValueOf(x) em geral não são addressable; ValueOf(&x).Elem() muitas vezes é.
  • CanSet — addressable e não read-only. Campos unexported alcançados via reflection carregam flagStickyRO / flagEmbedRO, então em alguns casos você lê, mas não atribui via Set.
type user struct {
  Name string
  age  int
}

func demo() {
  u := user{Name: "lucas", age: 30}
  v := reflect.ValueOf(u) // copy, not addressable
  // v.Field(0).SetString("x") // panics: not settable

  p := reflect.ValueOf(&u).Elem()
  p.Field(0).SetString("other") // ok: exported + addressable
  // p.Field(1).SetInt(31)      // panics: unexported
}

Reflection aplica as regras de visibilidade de Go em runtime. É de propósito: permitir Set em campos unexported de outro pacote furaria o encapsulamento sempre que alguém chamasse reflect.

Percorrendo structs e tags

Metadados de struct andam nos mesmos descritores que GC e o compilador usam: nomes de campo, offsets, tipos e tags ficam ao lado de abi.Type para kinds struct. Field(i) / FieldByName devolvem StructField com Offset, Type e Tag.

type Item struct {
  ID   int    `json:"id"`
  Name string `json:"name,omitempty"`
}

func dump(v any) {
  t := reflect.TypeOf(v)
  rv := reflect.ValueOf(v)
  for i := 0; i < t.NumField(); i++ {
    f := t.Field(i)
    fmt.Println(f.Name, f.Tag.Get("json"), rv.Field(i).Interface())
  }
}

Encoders quentes costumam cachear Type → plano de campos uma vez (veja como encoding/json ganhou type caches ao longo dos anos). Re-resolver nomes e tags a cada request é burn puro de CPU em cima do que o walk já aloca.

Method / MethodByName em um tipo concreto só expõem métodos exported. A nota do linker na docs é real: usar essas APIs pode manter vivos métodos que dead-code elimination droparia, o que pode crescer binários mesmo se a chamada nunca roda em runtime.

Call e MakeFunc

Value.Call é onde reflection deixa de ser "ler metadados" e passa a montar uma chamada de verdade:

  1. Checa kind, regras de export, contagem e assignability dos argumentos.
  2. Monta argumentos de stack/registradores segundo a ABI da plataforma (funcLayout).
  3. Invoca via helpers do runtime (call / stubs de ABI).
  4. Embala resultados de volta em []Value.

Esse caminho não parece um f(a, b) direto que o compilador inlineou. Slices de argumento []Value, slices de resultado e às vezes cópias de valores que não são direct interface aparecem em profiles e estatísticas de alocação.

func add(a, b int) int { return a + b }

func viaReflect(n int) int {
  fv := reflect.ValueOf(add)
  sum := 0
  for i := 0; i < n; i++ {
    out := fv.Call([]reflect.Value{
      reflect.ValueOf(i),
      reflect.ValueOf(i + 1),
    })
    sum += int(out[0].Int())
  }
  return sum
}

MakeFunc vai além: aloca um makeFuncImpl com entry point stub e um callback Go que recebe []Value. Útil para proxies e test doubles; caro se você colocar no hot path por request.

sequenceDiagram
  participant App as application
  participant RV as reflect.Value.Call
  participant ABI as funcLayout / call stub
  participant Fn as target function
  App->>RV: Call([]Value)
  RV->>RV: check kinds, assignability
  RV->>ABI: build arg frame
  ABI->>Fn: invoke
  Fn-->>ABI: results
  ABI-->>RV: pack []Value
  RV-->>App: return

Tipos construídos e caches

SliceOf, MapOf, ChanOf, ArrayOf, FuncOf e StructOf sintetizam tipos em runtime. Internamente consultam um sync.Map indexado por kind + tipos elemento; miss aloca um objeto no formato de abi.Type e registra para GC e reflection continuarem funcionando.

elem := reflect.TypeFor[int]()
t1 := reflect.SliceOf(elem)
t2 := reflect.SliceOf(elem)
fmt.Println(t1 == t2) // true — cached identity

Esse cache é por que construir []int via reflection repetidamente fica barato depois do primeiro hit, enquanto inventar shapes grandes e únicos com StructOf ainda aloca e retém metadados pela vida do processo.

De onde as alocações realmente vêm

A frase "reflection aloca" soa como se import "reflect" taxasse o heap sozinho. Os culpados usuais são mais específicos:

Operação Por que pode alocar
ValueOf em valor pequeno não-ponteiro que escapa via interface Boxing em any antes do unpack (mesmo caso do artigo de Interfaces)
Interface() em valor addressable Pode copiar para um objeto novo no heap para a interface não aliasar stack/memória mutável
Call / CallSlice []Value de argumentos e resultados, mais a maquinaria de frame ABI
New / MakeSlice / MakeMap Alocação explícita no heap do valor construído
Primeiro miss de SliceOf / StructOf Novos metadados de tipo no runtime

APIs mais estreitas ajudam quando você já sabe o kind: Int(), String(), Bytes() evitam a ida e volta por Interface(). Prefira TypeFor[T]() a cerimônia de TypeOf((*T)(nil)).Elem() quando puder.

// allocates on the Call path when used in a hot loop
out := reflect.ValueOf(fmt.Sprintf).Call([]reflect.Value{
  reflect.ValueOf("%d"),
  reflect.ValueOf(42),
})

// usually cheaper once types are fixed: stay concrete, or cache Value/Type
s := fmt.Sprintf("%d", 42)
_ = s
_ = out

Meça com go test -bench=. -benchmem no helper real, não num toy que só constrói um Type uma vez. Escape analysis ainda vale: -gcflags='-m=2' num repro pequeno mostra quais argumentos de ValueOf saem da stack.

Diretrizes práticas

  • Use reflection em boundaries (decode, plugins, helpers de teste), não em loops apertados por item quando o conjunto de tipos é fechado.
  • Cacheie reflect.Type e índices de campo/método depois do primeiro uso de um tipo dado.
  • Prefira TypeFor[T]() e getters específicos de kind a Interface() quando o tipo estático é conhecido.
  • Trate Call como ferramenta de compatibilidade; se um método está no hot path, exponha API concreta ou genérica.
  • Lembre de visibilidade e addressability — CanSet falhando costuma ser ponteiro faltando ou campo unexported, não bug misterioso do runtime.

Conclusão

Reflection em Go é uma API fina e estrita sobre os mesmos descritores abi.Type que interfaces e o GC já precisam. Type faz perguntas a esses metadados; Value carrega uma visão pointer-sized dos dados mais flags de kind, RO e addressability. O custo acumula quando você boxa via any, re-percorre structs ou monta frames de chamada a cada invocação — não porque o pacote é "lento por natureza."

Próximo na série é Concorrência — channels, select e as sync primitives que estacionam e acordam goroutines em cima do scheduler com que começamos.