Go Internals - Reflection
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 esteValueafirma carregar.ptr— ou os bits do valor (em alguns casos de direct interface) ou um ponteiro para os dados quandoflagIndirestá setado.flag—Kindempacotado 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— oValueaponta para uma localização addressable (flagAddr). Locais passados porValueOf(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 carregamflagStickyRO/flagEmbedRO, então em alguns casos você lê, mas não atribui viaSet.
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:
- Checa kind, regras de export, contagem e assignability dos argumentos.
- Monta argumentos de stack/registradores segundo a ABI da plataforma (
funcLayout). - Invoca via helpers do runtime (
call/ stubs de ABI). - 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.Typee índices de campo/método depois do primeiro uso de um tipo dado. - Prefira
TypeFor[T]()e getters específicos de kind aInterface()quando o tipo estático é conhecido. - Trate
Callcomo ferramenta de compatibilidade; se um método está no hot path, exponha API concreta ou genérica. - Lembre de visibilidade e addressability —
CanSetfalhando 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.