Deep Engineering
Средний·Опубликовано·50 МИН

Рефлексия в Go: reflect.Type, reflect.Value и правило изменяемости

Собеседование по рефлексии идёт лестницей: что такое reflect.Type и reflect.Value — чем Type отличается от Kind — почему SetInt паникует — можно ли изменить приватное поле — как encoding/json узнаёт имена из тегов — чем DeepEqual отличается от == — сколько всё это стоит. Прогон отвечает на каждый шаг: ValueOf видит копию, и изменить её нельзя; у Celsius Type — main.Celsius, а Kind — int; неэкспортируемое поле читается, но не меняется; nil-срез не равен пустому даже для DeepEqual; а в замере на go1.24.7 поиск поля по имени оказался дороже записи по готовому индексу больше чем в десять раз.

Полное техническое изложение

TL;DR

Рефлексия — это работа со значением, тип которого известен только во время выполнения. reflect.TypeOf и reflect.ValueOf принимают any и возвращают описание того, что лежит внутри интерфейса: reflect.Type — сам тип, reflect.Value — значение вместе с правами на него. Так устроены encoding/json, fmt и любые библиотеки, которые обходят поля чужих структур.

Главное следствие: саму переменную можно изменить, только если до неё дошли через указатель. reflect.ValueOf(x) держит копию x — в прогоне после x = 7.1 он по-прежнему показывает 3.4, — и SetInt на нём паникует: reflect: reflect.Value.SetInt using unaddressable value. Изменяет reflect.ValueOf(&x).Elem() — разыменованный указатель, то есть сама переменная. С тем, что доступно через значение, иначе: элемент среза меняется через reflect.ValueOf(s).Index(0) без &s, а запись в карту — через SetMapIndex, хотя CanSet у Value карты false. Решает CanSet конкретного Value, а у других методов — свои условия.

Дальше — то, что отличает понимание от пересказа. У именованного типа Celsius над int Type — main.Celsius, а Kind — int, и код, которому нужны «все целые», переключается по Kind. Неэкспортируемое поле структуры, до которой дошли через указатель, адресуемо и читается, но CanSet у него false, а Interface() паникует. encoding/json узнаёт имена из тегов через StructTag.Get, и у Get пустой ключ не отличается от отсутствующего — отличает Lookup. reflect.DeepEqual считает nil-срез не равным пустому, а функцию, не равную nil, — не равной даже самой себе. А цена — прежде всего поиск по имени: в замере на go1.24.7, linux/amd64, 2 ядра FieldByName оказался дороже записи по заранее найденному индексу в 13,1 раза, без аллокаций. Механизм объясняет, откуда разница; её величину на своей машине надо мерить.

Порог входа
Перед уроком достаточно понимать
  • что такое интерфейс и пустой интерфейс any: значение вместе со своим динамическим типом — подробно это в уроке об интерфейсах, он дальше по линии; здесь хватит этой фразы;
  • что срез — заголовок с указателем на общий массив, а карта — ссылка на таблицу: уроки о срезах и о картах;
  • как объявить структуру и чем экспортируемое поле (с заглавной буквы) отличается от неэкспортируемого — это урок о структурах;
  • что такое указатель и разыменование — урок об указателях.
Заранее знать не нужно
  • reflect.Type, reflect.Value, Kind и почему он не то же, что тип;
  • CanAddr, CanSet, Elem и тексты паник;
  • теги структур, reflect.DeepEqual, Value.IsNil и цена рефлексии в наносекундах.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Что такое рефлексия и где она нужна?» — разминка: encoding/json, fmt, ORM, валидаторы.
  2. «Чем reflect.Type отличается от reflect.Kind?» — проверяют, видит ли кандидат разницу между типом и его устройством.
  3. «Почему SetInt паникует?» — правило изменяемости, главный вопрос темы.
  4. «Можно ли через reflect изменить приватное поле?» — уточнение того же правила.
  5. «Как encoding/json узнаёт имя поля в JSON?» — теги.
  6. «Чем reflect.DeepEqual отличается от ==?» — и где он отвечает не то, что ждут.
  7. «Сколько стоит рефлексия?» — и что с этим делают библиотеки.

Стержень у лестницы один: reflect.Value — описание конкретного значения, пришедшего через интерфейс, и что с ним можно сделать, решает то, как его получили. Получили копию — можно читать. Дошли через указатель — можно менять. Прошли через неэкспортируемое имя — снова только читать. А у среза и карты содержимое доступно и без указателя — копия заголовка ведёт к тем же данным.

База: значение уходит в интерфейс и возвращается

Пакет описывает себя одной фразой:

Package reflect implements run-time reflection, allowing a program to manipulate objects with arbitrary types.

перевод

Пакет reflect реализует рефлексию во время выполнения, позволяя программе работать с объектами произвольных типов.

— Пакет reflect — обзор
контракт языкаПравила изменяемости, адресуемости и доступа к неэкспортируемым полям записаны в документации пакета reflect; прогон bench/goreflect/internals.go их иллюстрирует, а не устанавливает.

Вход в рефлексию — две функции, и обе принимают any.

Сигнатуры из go doc reflect, фрагмент:

GO
func TypeOf(i any) Type
func ValueOf(i any) Value

Значит, путь всегда один: значение сначала кладётся в интерфейс, и уже из интерфейса пакет достаёт тип и данные. Обратный путь — метод Value.Interface(), который снова отдаёт any. За введением документация пакета отправляет к статье The Laws of Reflection (Законы рефлексии); здесь те же правила разобраны по прогону. Путь туда и обратно — в первом разделе.

Вывод bench/goreflect/internals.go, раздел 1, без пояснительных строк:

1) ТУДА И ОБРАТНО: ИНТЕРФЕЙС -> REFLECT.VALUE -> ИНТЕРФЕЙС
──────────────────────────────────────────────────────────
  reflect.TypeOf(x)        -> float64
  v.Kind()                 -> float64
  v.Float()                -> 3.4
  v.Interface().(float64)  -> 3.4
  x = 7.1; v.Float()       -> 3.4

Последняя строка — ключ ко всему уроку. После x = 7.1 значение v по-прежнему 3.4: в ValueOf ушла копия x, как уходит копия при любой передаче аргумента в Go. v описывает эту копию, а не переменную.

Этого достаточно для ответа уровня Junior: рефлексия — это способ во время выполнения узнать тип значения, лежащего в интерфейсе, прочитать его и, при соблюдении правил, изменить. reflect.TypeOf возвращает тип, reflect.ValueOf — значение; Interface() возвращает его обратно в any. Где она нужна: везде, где код работает с типами, которых не знает при компиляции, — сериализация, fmt, ORM, валидаторы по тегам.

Всё это помещается в короткую программу. Полный файл bench/goreflect/cases/example/main.go — его можно скопировать в пустой каталог рядом с go.mod (module example, go 1.24) и запустить командой go run .:

GO
package main
 
import (
	"fmt"
	"reflect"
)
 
func main() {
	x := 3
	v := reflect.ValueOf(x) // a copy of x
	fmt.Println(v.CanSet())
 
	e := reflect.ValueOf(&x).Elem() // the variable x itself
	e.SetInt(42)
	fmt.Println(e.CanSet(), x)
 
	s := []int{1, 2, 3}
	reflect.ValueOf(s).Index(0).SetInt(7) // an element of the shared array
	fmt.Println(s)
}

Вывод bench/goreflect/example.sh:

go1.24.7 linux/amd64 | полный пример: go run .

  false
  true 42
  [7 2 3]

Первые две строки — правило для самой переменной, третья — уточнение для контейнеров; оба разобраны в Механизме 2.

Одну таблицу стоит запомнить сразу — она отвечает на третий вопрос лестницы. Адресуемое значение — то, у которого есть адрес в памяти, как у переменной под &x; точное правило — в Механизме 2, там же — почему таблица такая:

как получен ValueCanAddrCanSetзапись
reflect.ValueOf(x)falsefalseSetInt — паника
reflect.ValueOf(&x)falsefalseSetInt — паника
reflect.ValueOf(&x).Elem()truetrueSetInt меняет x
неэкспортируемое поле через Elem()truefalseSetInt — паника
reflect.ValueOf(s), s — срезfalsefalseSet — паника
reflect.ValueOf(s).Index(0)truetrueSetInt меняет s[0]
reflect.ValueOf(m), m — картаfalsefalseSetMapIndex меняет m

Механизм 1: Type — это тип, Kind — его устройство

Первая развилка, на которой путаются. Документация определяет Kind так:

A Kind represents the specific kind of type that a Type represents.

перевод

Kind представляет конкретную разновидность типа, который представляет Type.

— Пакет reflect — Kind

На деле это значит: Type — тип целиком, с именем и пакетом; Kind — одна из фиксированного набора разновидностей (int, struct, ptr, slice, map…). Проверка на именованном типе над int.

Фрагмент bench/goreflect/internals.go:

GO
type Celsius int

Вывод bench/goreflect/internals.go, раздел 2, без пояснительных строк:

2) TYPE ПРОТИВ KIND
───────────────────
  значение           Type             Kind
  Celsius(21)        main.Celsius     int
  21                 int              int
  User{}             main.User        struct
  &User{}            *main.User       ptr
  []Celsius{}        []main.Celsius   slice
  map[string]int{}   map[string]int   map
  TypeOf(Celsius(21)) == TypeOf(21)  -> false

Celsius и int — разные типы, и их reflect.Type не равны. Но устройство у них одно — Kind int. Отсюда практическое правило: код, который должен работать с любым типом над int, переключается по Kind, а не по типу.

Фрагмент bench/goreflect/internals.go, сокращён (... — пропущенные ветки):

GO
func byKind(i any) string {
    v := reflect.ValueOf(i)
    switch v.Kind() {
    case reflect.Int, reflect.Int64:
        return fmt.Sprintf("целое: %d", v.Int())
    ...
}
 
func byType(i any) string {
    switch x := i.(type) {
    case int:
        return fmt.Sprintf("целое: %d", x)
    ...
}

Вывод того же раздела 2, продолжение:

  byKind(Celsius(21))      -> "целое: 21"
  byType(Celsius(21))      -> "не знаю: main.Celsius"

Переключатель по типу видит main.Celsius и в case int не попадает. Так же устроен encoding/json: кодировщик для типа он выбирает по switch t.Kind() (encoding/json/encode.go:408 в go1.24.7), поэтому Celsius для него — обычное целое.

Ещё одно наблюдение из той же таблицы: у &User{} Kind — ptr, у []Celsius{} — slice. Kind говорит, что делать дальше: у указателя звать Elem(), у структуры — перебирать поля, у среза — элементы. Обход произвольного значения так и устроен — switch по Kind стоит и в encoding/json, и в fmt (fmt/print.go:777).

Механизм 2: правило изменяемости — CanSet

Центральный вопрос темы. Правило записано в документации одной фразой:

A Value can be changed only if it is addressable and was not obtained by the use of unexported struct fields.

перевод

Value можно изменить, только если он адресуем и получен не через неэкспортируемые поля структуры.

— Пакет reflect — Value.CanSet

И что такое «адресуем»:

A value is addressable if it is an element of a slice, an element of an addressable array, a field of an addressable struct, or the result of dereferencing a pointer.

перевод

Значение адресуемо, если это элемент среза, элемент адресуемого массива, поле адресуемой структуры или результат разыменования указателя.

— Пакет reflect — Value.CanAddr

Проверка на трёх способах получить Value для x := 3.

Вывод bench/goreflect/internals.go, раздел 3, без пояснительных строк:

3) ПРАВИЛО ИЗМЕНЯЕМОСТИ: CanSet
───────────────────────────────
  ValueOf(x)          Kind=int  CanAddr=false CanSet=false
  ValueOf(&x)         Kind=ptr  CanAddr=false CanSet=false
  ValueOf(&x).Elem()  Kind=int  CanAddr=true  CanSet=true
  ValueOf(x)          .SetInt(42) -> паника: reflect: reflect.Value.SetInt using unaddressable value
  ValueOf(&x)         .SetInt(42) -> паника: reflect: reflect.Value.SetInt using unaddressable value
  ValueOf(&x).Elem()  .SetInt(42) -> x = 42

Объяснение следует из Базы. ValueOf(x) держит копию x; если бы SetInt её изменил, x осталась бы прежней, и ошибка ушла бы молча. Пакет делает её громкой: у копии нет адреса, CanSet — false, SetInt паникует.

ValueOf(&x) — частая вторая попытка. В интерфейс ушёл указатель — и тоже копией: Value описывает сам указатель (Kind ptr), а указатель, лежащий в интерфейсе, не адресуем. Паника та же. Только Elem() разыменовывает указатель — а результат разыменования по правилу выше адресуем. Это и есть переменная x, и SetInt(42) пишет прямо в неё.

деталь реализации · Go 1.24Устройство reflect.Value и номера строк — reflect/value.go из go1.24.7; это деталь реализации, а не часть документированного контракта.

Внутри это три слова и битовые флаги. reflect.Value — структура из указателя на тип, указателя на данные и поля flag (reflect/value.go:39–63). CanSet — одна проверка флагов (value.go:354).

Фрагмент reflect/value.go:354, go1.24.7:

GO
return v.flag&(flagAddr|flagRO) == flagAddr

flagAddr ставит Elem() у указателя (value.go:1261), а сеттеры перед записью проверяют эти же флаги и паникуют с одним из двух текстов (value.go:251–261): про неадресуемое значение или про неэкспортируемое поле. Второй текст — следующий механизм.

Отсюда рабочий шаблон для кода, который меняет значения через reflect.

Фрагмент функции — шаблон для своего кода, в прогоне его нет:

GO
v := reflect.ValueOf(ptr)
if v.Kind() != reflect.Pointer || v.IsNil() {
    return errors.New("нужен ненулевой указатель")
}
v = v.Elem() // дальше v адресуем: поля, элементы, SetXxx

Такая же проверка стоит в json.Unmarshal (encoding/json/decode.go:170 в go1.24.7) — поэтому ему передают &dst, а не dst.

Срез и карта: менять содержимое можно без &

Правило «только через указатель» касается самой переменной — заменить её значение. То, что доступно через значение, подчиняется своим условиям, и у контейнеров они другие. Проверка на срезе, массиве и карте:

Вывод bench/goreflect/internals.go, раздел 8, без пояснительных строк:

8) КОНТЕЙНЕРЫ: СРЕЗ, МАССИВ И КАРТА БЕЗ УКАЗАТЕЛЯ
─────────────────────────────────────────────────
  ValueOf(s)               CanAddr=false CanSet=false
  ValueOf(s).Index(0)      CanAddr=true  CanSet=true
  ValueOf(s).Index(0).SetInt(7)  -> s = [7 2 3]
  ValueOf(s).Set(ValueOf([]int{9}))  -> паника: reflect: reflect.Value.Set using unaddressable value
  ValueOf(a).Index(0)      CanAddr=false CanSet=false   (a — массив [3]int)
  ValueOf(m)               CanAddr=false CanSet=false
  ValueOf(m).SetMapIndex("b", 2)  -> m = map[a:1 b:2]
  ValueOf(m).MapIndex("a") CanAddr=false CanSet=false
  ValueOf(m).MapIndex("a").SetInt(5) -> паника: reflect: reflect.Value.SetInt using unaddressable value

Срез. ValueOf(s) — копия заголовка среза, и заменить сам срез (Set) без &s нельзя — та же паника, что у числа. Но заголовок указывает на общий массив, поэтому Index(0) у среза всегда адресуем: Value.Index ставит ему flagAddr безусловно (reflect/value.go:1423 в go1.24.7). SetInt(7) меняет s[0] у вызывающего — в прогоне s = [7 2 3].

Массив — наоборот. ValueOf(a) копирует его целиком, и элемент копии наследует её неадресуемость (value.go:1410): CanSet — false.

Карта. У Value карты CanSet — false, но запись в карту делает не Set, а SetMapIndex, и адреса он не требует — только чтобы карта была получена не через неэкспортируемое поле (reflect/map_swiss.go:407–410 в go1.24.7). В прогоне m = map[a:1 b:2]. А значение, прочитанное через MapIndex, — копия: SetInt на нём паникует.

Все три случая повторяют правило адресуемости самого языка. Спецификация (раздел Address operators) перечисляет, что адресуемо: either a variable, pointer indirection, or slice indexing operation; or a field selector of an addressable struct operand; or an array indexing operation of an addressable array (переменная, разыменование указателя или индексирование среза; или селектор поля адресуемой структуры; или индексирование адресуемого массива). Индексирование среза стоит в списке без условий, массива — только адресуемого, а элемента карты в списке нет вовсе.

Отсюда точная формулировка: Set и SetInt работают, когда CanSet конкретного Value — true; у SetMapIndex и других методов — свои условия, записанные в их документации. Наличие адреса само по себе не значит, что SetInt применим: к указателю или карте он не подходит по Kind.

Механизм 3: неэкспортируемое поле — читается, но не меняется

Вторая половина правила. Структура с неэкспортируемым полем.

Фрагмент bench/goreflect/internals.go:

GO
type User struct {
    Name string
    age  int
}
 
u := User{Name: "Ann", age: 30}
v := reflect.ValueOf(&u).Elem()
name, age := v.FieldByName("Name"), v.FieldByName("age")

Вывод bench/goreflect/internals.go, раздел 4, без пояснительных строк:

4) НЕЭКСПОРТИРУЕМОЕ ПОЛЕ: ЧИТАЕТСЯ, НО НЕ МЕНЯЕТСЯ
──────────────────────────────────────────────────
  поле Name: CanAddr=true CanSet=true
  поле age:  CanAddr=true CanSet=false
  age.Kind()               -> int
  age.Int()                -> 30
  age.Interface()          -> паника: reflect.Value.Interface: cannot return value obtained from unexported field or method
  age.SetInt(31)           -> паника: reflect: reflect.Value.SetInt using value obtained using unexported field
  name.SetString("Bob")    -> u = {Name:Bob age:30}
  json.Marshal(u)          -> {"Name":"Bob"}

Поле age адресуемо — до него дошли через указатель, и по правилу адресуемости поле адресуемой структуры адресуемо. Но CanSet — false: вторая половина правила запрещает менять то, что получено через неэкспортируемое имя. Читать при этом можно — age.Int() вернул 30.

Interface() тоже паникует, и это не случайность. В исходнике (reflect/value.go:1492–1494) объяснено почему: Do not allow access to unexported values via Interface, because they might be pointers that should not be writable or methods or function that should not be callable (Не давать доступ к неэкспортируемым значениям через Interface, потому что это могут быть указатели, которые нельзя использовать для записи, или методы и функции, которые нельзя вызывать). Если бы Interface() отдал неэкспортируемый указатель, через него можно было бы записать то, что SetInt запрещает.

деталь реализации · Go 1.24Номера строк — reflect/value.go и fmt/print.go из go1.24.7.

Пометка «только чтение» ставится в момент получения поля: Value.Field добавляет флаг flagStickyRO, если поле неэкспортируемое и не встроенное (value.go:1284–1289; у встроенного ставится flagEmbedRO), и дальше флаг наследуется всем, что из этого поля получено.

Две библиотеки в прогоне ведут себя по-разному, и обе — по этому правилу. fmt поле age печатает ({Name:Bob age:30}): он читает его методами вроде Int(), а Interface() — и вместе с ним методы String и Error значения — зовёт только при CanInterface() (fmt/print.go:768). encoding/json в вывод его не включает вовсе: неэкспортируемые поля без встраивания он пропускает (encoding/json/encode.go:1130–1132).

Ответ на вопрос «можно ли через reflect изменить приватное поле» поэтому такой: нет — CanSet у него false, и сеттер паникует. Читать можно.

Механизм 4: теги — откуда encoding/json берёт имена

Тег поля для самого языка почти ничего не значит:

The tags are made visible through a reflection interface and take part in type identity for structs but are otherwise ignored.

перевод

Теги доступны через интерфейс рефлексии и участвуют в идентичности типов структур, а в остальном игнорируются.

— Спецификация Go — Struct types

Смысл тегу придают библиотеки, и читают они его через reflect.StructTag.

Фрагмент bench/goreflect/internals.go:

GO
type Account struct {
    ID     int    `json:"id"`
    Email  string `json:"email,omitempty" db:"email_addr"`
    Note   string `json:""`
    Secret string `json:"-"`
    Plain  string
}

Вывод bench/goreflect/internals.go, раздел 5, без пояснительных строк:

5) ТЕГИ СТРУКТУРЫ: Get И Lookup
───────────────────────────────
  поле    Get("json")          Lookup("json")
  ID      "id"                 "id", true
  Email   "email,omitempty"    "email,omitempty", true
  Note    ""                   "", true
  Secret  "-"                  "-", true
  Plain   ""                   "", false
  Email.Tag.Get("db")      -> "email_addr"
  json.Marshal             -> {"id":1,"Note":"n","Plain":"p"}

Строки Note и Plain — то, о чём спрашивают. Get для обоих вернул "": ключа json нет у Plain, а у Note он есть, но пустой. Различает их только Lookup, и документация говорит об этом прямо:

To determine whether a tag is explicitly set to the empty string, use StructTag.Lookup.

перевод

Чтобы узнать, задан ли в теге явно пустой ключ, используйте StructTag.Lookup.

— Пакет reflect — StructTag.Get

encoding/json различать их и не нужно: он читает тег через Get("json") (encoding/json/encode.go:1134 в go1.24.7), и пустое имя для него значит «взять имя поля» — в выводе Note и Plain вышли под своими именами. А "-" — особый случай из документации пакета: As a special case, if the field tag is "-", the field is always omitted (Особый случай: если тег поля — «-», поле всегда пропускается). Secret в JSON нет.

Два практических вывода. Тег — просто строка в типе, и смысл её задаёт библиотека, а не язык: по спецификации, кроме рефлексии и идентичности типов, теги игнорируются, так что что значит omitempty, решает encoding/json. И ключей в теге может быть несколько — db:"email_addr" у того же поля читает своя библиотека своим Get("db"), не мешая encoding/json.

Механизм 5: DeepEqual — «равно» без оператора ==

== для срезов, карт и функций разрешён только со значением nil, а у указателей сравнивает адреса. reflect.DeepEqual сравнивает содержимое — но по своим правилам, и три из них противоречат интуиции.

Вывод bench/goreflect/internals.go, раздел 6, без пояснительных строк:

6) DEEPEQUAL: СОДЕРЖИМОЕ, А НЕ ОПЕРАТОР ==
──────────────────────────────────────────
  []int(nil) и []int{}            -> false
  []int{1,2} и []int{1,2}         -> true
  карты {a:1,b:2} и {b:2,a:1}     -> true
  &User{Ann,30} и &User{Ann,30}   -> true
  User{Ann,30} и User{Ann,31}     -> false
  Celsius(1) и 1                  -> false
  f и f (одна функция)            -> false
  func()(nil) и func()(nil)       -> true
  NaN и NaN                       -> false
  те же указатели через ==        -> false

nil-срез не равен пустому. Для len они одинаковы, для range — тоже, а DeepEqual их различает. Это записано в документации:

Note that a non-nil empty slice and a nil slice (for example, []byte{} and []byte(nil)) are not deeply equal.

перевод

Заметьте, что пустой срез, не равный nil, и nil-срез (например, []byte{} и []byte(nil)) не равны в глубину.

— Пакет reflect — DeepEqual

На этом падают тесты: функция вернула []int{}, в ожидании записано nil (или наоборот) — и сравнение через DeepEqual даёт false, хотя «оба пустые».

Функция не равна даже самой себе. Правило в документации короткое: Func values are deeply equal if both are nil; otherwise they are not deeply equal (Функции равны в глубину, если обе nil; иначе не равны). Одна и та же f, не равная nil, в прогоне дала false. Поэтому и структура с заполненным полем-функцией через DeepEqual не равна своей копии.

Разные типы никогда не равны — Celsius(1) и 1 дали false, хотя Kind у них один. Это тот же Механизм 1: DeepEqual сравнивает сначала типы.

Остальное — как ожидают. Карты сравниваются по парам, порядок вставки не важен; указатели на равные структуры равны, хотя == для тех же указателей даёт false; неэкспортируемые поля тоже сравниваются — age 30 и 31 дали false. И NaN не равен себе, как и при обычном сравнении чисел.

Глубже: сколько стоит рефлексия и откуда цена

наблюдение замераbench/goreflect/cost.go, go1.24.7 linux/amd64, 2 ядра, GOMAXPROCS=2; поле Port — седьмое из восьми; результат пишется в глобальную переменную; лучший из 15 кругов по 300 мс (минимум, а не типичное время), круги способов чередуются. Машина общая: наносекунды от запуска к запуску гуляют. Это наблюдение одной конфигурации; на другой машине, версии и структуре кратность надо мерить заново.

«Рефлексия медленная» — ответ без числа. Замер разбирает, из чего цена складывается: одно и то же поле Port (седьмое из восьми) записывают четырьмя способами.

Вывод bench/goreflect/cost.go, раздел 1, без пояснительных строк:

1) ЗАПИСЬ ПОЛЯ PORT ЧЕТЫРЬМЯ СПОСОБАМИ
──────────────────────────────────────
  поле Port — седьмое из восьми, индекс 6

  способ                                 нс/оп  аллок  байт
  cfg.Port = n                            0.33      0     0
  ValueOf(&cfg).Elem().FieldByName       76.21      0     0
  ValueOf(&cfg).Elem().Field(idx)         5.82      0     0
  field.SetInt (Value поля запомнен)      2.02      0     0

  FieldByName дороже Field(idx)          в 13.1 раза
  Field(idx) дороже запомненного Value   в  2.9 раза

Три вывода, по убыванию важности.

Дороже всего — поиск по имени. Value.FieldByName спрашивает тип, есть ли поле с таким именем (reflect/value.go:1348–1354). Тип перебирает свои поля и сравнивает имена (reflect/type.go:1284–1287). Найдя поле, он собирает полное описание StructField — тип, имя, тег, смещение (reflect/type.go:1101–1126), и только потом Value переходит к полю по индексу. В прогоне это 76,21 нс против 5,82 у записи по готовому индексу.

Сравнения имён — не главная часть этой цены. Тот же скрипт отдельно мерит только поиск, без записи, — первого поля и седьмого.

Вывод bench/goreflect/cost.go, раздел 2, без пояснительных строк:

2) ПОИСК ПО ИМЕНИ: ПЕРВОЕ ПОЛЕ ПРОТИВ СЕДЬМОГО
──────────────────────────────────────────────
  только поиск, без записи               нс/оп  аллок  байт
  FieldByName("Host") — 1-е поле         53.97      0     0
  FieldByName("Port") — 7-е поле         76.18      0     0

  поиск 1-го поля / поиск 7-го поля      0.71

У первого поля одно сравнение, у седьмого — семь, а цена различается далеко не в семь раз: первое поле стоит 0,71 от седьмого. Большая часть цены постоянна — это сборка StructField и переход к полю после того, как поле найдено. Шесть лишних сравнений добавляют остальное. В соседнем прогоне practice.go запись через FieldByName дала 78,77 нс против 7,10. В обоих прогонах на этой машине кратность больше десяти; механизм объясняет, откуда она, а её величину у себя надо мерить.

Аллокаций нет ни у одного способа — это наблюдение реализации go1.24.7, а не гарантия пакета. Указатель &cfg кладётся в интерфейс без копирования структуры, reflect.Value — значение из трёх слов, а индекс поля (StructField.Index, срез) для первых 256 полей берётся из статической таблицы, а не выделяется (reflect/type.go:1120–1126).

Прямое присваивание — доли наносекунды, одна запись в память. Кратность к нему скрипт не печатает намеренно: делитель меньше наносекунды, и отношение было бы шумом.

деталь реализации · Go 1.24Кеш полей encoding/json и номера строк — encoding/json/encode.go из go1.24.7; это устройство пакета, а не его документированный контракт.

Отсюда то, что делают библиотеки: ищут поля по имени один раз на тип и запоминают результат. encoding/json держит кеш fieldCache — sync.Map из reflect.Type в описание полей, — и разбирает теги структуры только при первой встрече с её типом (encoding/json/encode.go:1325–1332). Своему коду, который ходит по полям в горячем цикле, стоит делать так же: reflect.Type можно класть ключом в карту, а путь к полю хранить как []int из StructField.Index и передавать в Value.FieldByIndex. У поля верхнего уровня в этом срезе один элемент — тогда хватает Field(i); у поля встроенной структуры элементов больше (см. «Дальше спросят»).

Сводная таблица, которую удобно держать в голове:

что делаетечем платите
FieldByName на каждый вызовперебор полей и сборка StructField
Field(i) по запомненному индексуValueOf, Elem, проверка флагов
запомненный Value поля + SetIntтолько проверка флагов и Kind
обычное присваиваниеодна запись в память

Как отвечать на собеседовании

Короткий ответ: рефлексия в Go — это пакет reflect, который по значению в интерфейсе даёт его тип (reflect.Type) и само значение (reflect.Value). Читать можно всегда — неэкспортируемое через Int(), String() и подобные, но не через Interface(). Set и SetInt работают, только если CanSet конкретного Value — true: значение адресуемо и получено не через неэкспортируемое поле; поэтому, чтобы заменить саму переменную, передают указатель и зовут Elem(). Элемент среза и запись в карту доступны и без указателя — у Index и SetMapIndex свои условия. Ею пользуются encoding/json, fmt и всё, что работает с неизвестными при компиляции типами; цена — прежде всего поиск по имени, и библиотеки кешируют его по типу.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На «почему SetInt паникует» — копия и адрес. «ValueOf(x) получил копию x; изменить её — значит ничего не изменить, поэтому CanSet — false, и паника using unaddressable value. ValueOf(&x) описывает сам указатель — тоже копию. Изменяет ValueOf(&x).Elem(): разыменованный указатель адресуем. А у среза ValueOf(s).Index(0).SetInt(7) меняет s[0] и без &s — элемент среза адресуем всегда; в карту пишут SetMapIndex, которому CanSet не нужен».

На «чем Type отличается от Kind» — именованный тип. «У type Celsius int Type — main.Celsius, Kind — int. Переключатель по типу case int его не поймает, переключатель по Kind поймает; поэтому обход значений в библиотеках — switch v.Kind()».

На «можно ли изменить приватное поле» — флаг «только чтение». «Поле адресуемо, если дошли через указатель, но получено через неэкспортируемое имя: CanSet — false, SetInt паникует, Interface() тоже. Прочитать можно — Int(), String(); так fmt и печатает приватные поля».

На «как json узнаёт имена» — теги. «Тег — строка в типе; json читает его через StructTag.Get("json"). "-" — пропустить, пустое имя — взять имя поля. Get не отличает пустой ключ от отсутствующего, Lookup отличает».

На «DeepEqual против ==» — три исключения. «Сравнивает содержимое, но nil-срез не равен пустому, функция равна только nil — даже сама себе нет, а значения разных типов не равны никогда».

На «сколько стоит» — механизм и замер с условиями. «Платят за поиск по имени: FieldByName на каждый вызов перебирает поля и собирает StructField. Поэтому encoding/json разбирает тип один раз и держит кеш. В моём замере на go1.24.7, linux/amd64 запись через FieldByName была дороже записи по готовому индексу в 13,1 раза, без аллокаций; на другой машине число будет своим — его меряют».

Дальше спросят

Спросят дальше

Как через reflect проверить, что в интерфейсе лежит nil?

Короткий ответ

Это вопрос про «nil, который не nil» — подробно он разобран в уроке об интерфейсах, Механизм 3. Коротко: интерфейс с нулевым указателем внутри не равен nil, потому что первое его слово — тип — заполнено. reflect позволяет заглянуть внутрь.

Вывод bench/goreflect/internals.go, раздел 7, без пояснительных строк:

7) nil ВНУТРИ ИНТЕРФЕЙСА: IsNil И НУЛЕВОЙ VALUE
───────────────────────────────────────────────
  i := any((*User)(nil)); i == nil     -> false
  reflect.ValueOf(i).Kind()            -> ptr
  reflect.ValueOf(i).IsNil()           -> true
  var e any; e == nil                  -> true
  reflect.ValueOf(e).IsValid()         -> false
  reflect.ValueOf(e).IsNil()           -> паника: reflect: call of reflect.Value.IsNil on zero Value
  reflect.ValueOf(42).IsNil()          -> паника: reflect: call of reflect.Value.IsNil on int Value

Две ловушки. IsNil определён только для указателя, карты, среза, канала, функции и интерфейса — на int он паникует. А у пустого интерфейса (var e any) ValueOf возвращает нулевой Value, и IsNil на нём тоже паникует. Документация предупреждает: IsNil is not always equivalent to a regular comparison with nil in Go (IsNil не всегда равносилен обычному сравнению с nil в Go).

Поэтому проверка пишется в два шага — сначала == nil, потом Kind.

Фрагмент bench/goreflect/internals.go:

GO
func isNil(i any) bool {
    if i == nil {
        return true
    }
    v := reflect.ValueOf(i)
    switch v.Kind() {
    case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
        return v.IsNil()
    }
    return false
}

Вывод того же раздела 7, следующая строка:

  isNil(i), isNil(e), isNil(42)        -> true, true, false

Но правильный ответ на собеседовании начинается не с неё: такая функция обычно лечит симптом. Если функция объявлена возвращающей error, она должна возвращать литерал nil, а не типизированный нулевой указатель, — тогда проверка err == nil работает без рефлексии.

Спросят дальше

Как обойти все поля структуры и прочитать их теги?

Короткий ответ

Через тип: NumField даёт число полей, Field(i) — описание поля reflect.StructField с именем, типом и тегом. Ровно так устроен раздел 5 прогона.

Фрагмент bench/goreflect/internals.go, раздел 5:

GO
t := reflect.TypeOf(Account{})
for i := 0; i < t.NumField(); i++ {
    f := t.Field(i)
    val, ok := f.Tag.Lookup("json")
    fmt.Printf("  %-7s %-20q %q, %v\n", f.Name, f.Tag.Get("json"), val, ok)
}

Значения полей берут у reflect.Value тем же индексом: v.Field(i). Если поле может оказаться неэкспортируемым, перед Interface() или записью проверяют CanInterface() или CanSet() — иначе паника из Механизма 3. У встроенных структур поля вложены, и полный путь к ним — срез индексов (StructField.Index, Value.FieldByIndex).

Спросят дальше

Когда рефлексия оправдана, а когда нет?

Короткий ответ

Оправдана, когда типы неизвестны при компиляции: сериализация в JSON и другие форматы, fmt, ORM, валидация по тегам, внедрение зависимостей. Это код библиотек, и цену там платят один раз на тип — кешируя разбор, как encoding/json.

Не оправдана, когда типов немного и они известны: тогда switch x := i.(type) короче, проверяется компилятором и не паникует при опечатке. А когда нужен один алгоритм для разных типов — обобщения (дженерики): ошибки ловит компилятор, а не паника во время выполнения.

Признак злоупотребления виден в коде: reflect в бизнес-логике, а не в слое, который обслуживает произвольные типы.

Частые заблуждения

Утверждение

reflect.ValueOf(x) даёт доступ к переменной x

На самом деле

Он получает копию — ValueOf принимает any. В прогоне после x = 7.1 значение v.Float() осталось 3.4, а SetInt на таком значении паникует: using unaddressable value.

Утверждение

Чтобы изменить значение, достаточно передать в ValueOf указатель

На самом деле

reflect.ValueOf(&x) описывает сам указатель (Kind ptr), и он тоже не адресуем: CanSet — false, та же паника. Изменяет reflect.ValueOf(&x).Elem().

Утверждение

Без указателя через reflect ничего изменить нельзя

На самом деле

Нельзя заменить саму переменную. Но reflect.ValueOf(s).Index(0).SetInt(7) меняет элемент среза у вызывающего — в прогоне s = [7 2 3], — а SetMapIndex пишет в карту, хотя CanSet у её Value — false. У массива же ValueOf(a).Index(0) — элемент копии, CanSet — false.

Утверждение

Type и Kind — одно и то же

На самом деле

У Celsius(21) Type — main.Celsius, Kind — int, и TypeOf(Celsius(21)) == TypeOf(21) даёт false. Переключатель по типу case int его не узнаёт, переключатель по Kind — узнаёт.

Утверждение

Неэкспортируемое поле через reflect не видно

На самом деле

Видно и читается: age.Int() вернул 30, fmt печатает его. Нельзя изменить (CanSet — false) и нельзя получить через Interface() — оба вызова паникуют.

Утверждение

Get и Lookup у тега возвращают одно и то же

На самом деле

Для пустого ключа — нет. У json:"" и у поля без тега Get("json") даёт "", а Lookup — "", true против "", false.

Утверждение

reflect.DeepEqual считает пустой срез равным nil

На самом деле

Не считает: []int(nil) и []int{} дают false, и это записано в документации. Функция же равна только nil — одна и та же функция, не равная nil, сама с собой даёт false.

Утверждение

Рефлексия медленная, потому что всё время выделяет память

На самом деле

В замере записи поля аллокаций нет ни у одного способа. Цена здесь — поиск по имени: в замере на go1.24.7, linux/amd64 FieldByName был дороже записи по готовому индексу в 13,1 раза. Отсутствие аллокаций — наблюдение этой версии, а не гарантия.

История версий

ВерсияИзменениеЧто это значит для кода
Go 1.20У reflect.Value появились SetZero, Comparable и Equal (файл api/go1.20.txt).Обнулить значение — одним вызовом, сравнить два Value — без Interface()
Go 1.22Функция reflect.TypeFor[T]() (api/go1.22.txt): тип по параметру типа, без значения.Type получают по имени типа, без значения
Go 1.23Value.Seq и Value.Seq2 (api/go1.23.txt): обход значения циклом range по функции.Срез, массив, карту, канал и строку обходят одним range
Go 1.24В encoding/json появился параметр тега omitzero. Release notes: When marshaling, a struct field with the new omitzero option in the struct field tag will be omitted if its value is zero (при кодировании поле структуры с новым параметром omitzero в теге пропускается, если его значение нулевое). Урок его не измерял.Ещё один ключ, который читает json из тега
Go 1.25Функция func TypeAssert[T any](v Value) (T, bool) (api/go1.25.txt). По документации она равносильна записи с двумя результатами: v2, ok := v.Interface().(T). При несовпадении типа возвращает нулевое значение и false, а не паникует; на поле, полученном через неэкспортируемое имя, паникует, как и Interface(). Проверено прогоном на go1.25.1 — ниже.Утверждение типа с ok прямо от Value
Go 1.27encoding/json переведён на реализацию v2. Release notes: Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ (поведение кодирования и декодирования сохранено, но точный текст сообщений об ошибках может отличаться). Номера строк encode.go в уроке — из go1.24.7; на 1.27 урок не запускался.Ссылки на исходник encoding/json могут не совпасть

TypeAssert в прогоне на go1.25.1 — правильный тип даёт значение и true, неправильный — нулевое значение и false, как утверждение типа с ok; без ok обычное утверждение паникует. Паника на неэкспортируемом поле — другая и от типа не зависит. Полный модуль — bench/goreflect/cases/typeassert (go 1.25 в go.mod).

Вывод bench/goreflect/typeassert.sh на go1.25.1:

go1.25.1 linux/amd64 | reflect.TypeAssert — два результата, как comma-ok

  TypeAssert[int](ValueOf(1))           -> 1, true
  TypeAssert[string](ValueOf(1))        -> "", false
  v.Interface().(string) с ok           -> "", false
  v.Interface().(string) без ok         -> паника: interface conversion: interface {} is int, not string
  TypeAssert[int](поле age)             -> паника: reflect.TypeAssert: cannot return value obtained from unexported field or method

На чём проверено. Все прогоны урока — go1.24.7 и go1.25.1, linux/amd64; сверено 14.10.2026. Правило изменяемости, тексты паник и поведение DeepEqual между этими версиями не изменились: internals.go на go1.25.1 печатает то же самое, кроме строки версии (runs/internals-go1.25.txt). Новее вышли Go 1.26 и Go 1.27; на них урок не запускался, строки о них в таблице взяты из release notes.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Фрагмент bench/goreflect/practice.go: объявление типа и часть main. Тип Celsius объявлен над int. Переменная x равна 3; v получен из x, p — через указатель на x и Elem(). Через p записали 10. Что напечатает этот код?
type Celsius int

x := 3
v := reflect.ValueOf(x)
fmt.Println(v.CanSet())
p := reflect.ValueOf(&x).Elem()
p.SetInt(10)
fmt.Println(x, v.Int())
fmt.Println(reflect.TypeOf(Celsius(21)).Kind())
fmt.Println(reflect.DeepEqual([]int(nil), []int{}))

Практика · оцените

Структура из восьми полей, пишут седьмое — Port. Во сколько раз запись через рефлексию с поиском по имени, FieldByName("Port").SetInt, дороже записи через Field(idx) с заранее найденным индексом?
раза

Проверка знаний

Вопрос 1 из 5

x := 3; v := reflect.ValueOf(x); v.SetInt(42). Что произойдёт?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Пакет reflect — обзорОфициальная документация. Что делает пакет: «Package reflect implements run-time reflection, allowing a program to manipulate objects with arbitrary types» (Пакет reflect реализует рефлексию во время выполнения, позволяя программе работать с объектами произвольных типов). Что такое Value: «A call to ValueOf returns a Value representing the run-time data» (Вызов ValueOf возвращает Value, представляющий данные времени выполнения). И куда пакет отправляет за введением: «See "The Laws of Reflection" for an introduction to reflection in Go» (Введение в рефлексию в Go — в статье „Законы рефлексии“).https://pkg.go.dev/reflect
  2. Пакет reflect — Value.CanSet и Value.CanAddrОфициальная документация. Правило изменяемости: «A Value can be changed only if it is addressable and was not obtained by the use of unexported struct fields» (Value можно изменить, только если он адресуем и получен не через неэкспортируемые поля структуры). Что адресуемо: «A value is addressable if it is an element of a slice, an element of an addressable array, a field of an addressable struct, or the result of dereferencing a pointer» (Значение адресуемо, если это элемент среза, элемент адресуемого массива, поле адресуемой структуры или результат разыменования указателя). И про Interface: «It panics if the Value was obtained by accessing unexported struct fields» (Паникует, если Value получен обращением к неэкспортируемым полям структуры).https://pkg.go.dev/reflect#Value.CanSet
  3. Спецификация Go — Struct types (теги)Официальная документация. Что такое тег для языка: «The tags are made visible through a reflection interface and take part in type identity for structs but are otherwise ignored» (Теги доступны через интерфейс рефлексии и участвуют в идентичности типов структур, а в остальном игнорируются).https://go.dev/ref/spec#Struct_types
  4. Пакет reflect — StructTag.Get и Lookup; пакет encoding/json — MarshalОфициальная документация. Разница Get и Lookup: «To determine whether a tag is explicitly set to the empty string, use StructTag.Lookup» (Чтобы узнать, задан ли в теге явно пустой ключ, используйте StructTag.Lookup). Как тег читает encoding/json: «The name may be empty in order to specify options without overriding the default field name» (Имя может быть пустым — чтобы задать параметры, не меняя имени поля по умолчанию) и «As a special case, if the field tag is "-", the field is always omitted» (Особый случай: если тег поля — "-", поле всегда пропускается).https://pkg.go.dev/reflect#StructTag.Get
  5. Пакет reflect — DeepEqual и Value.IsNilОфициальная документация. Срезы: «Note that a non-nil empty slice and a nil slice (for example, []byte{} and []byte(nil)) are not deeply equal» (Заметьте, что пустой срез, не равный nil, и nil-срез (например, []byte{} и []byte(nil)) не равны в глубину). Функции: «Func values are deeply equal if both are nil; otherwise they are not deeply equal» (Функции равны в глубину, если обе nil; иначе не равны). И IsNil: «Note that IsNil is not always equivalent to a regular comparison with nil in Go» (Заметьте, что IsNil не всегда равносилен обычному сравнению с nil в Go).https://pkg.go.dev/reflect#DeepEqual
  6. Пакет reflect — Value.SetMapIndex и TypeAssert (go1.25)Официальная документация. Запись в карту — отдельный метод со своими условиями: «SetMapIndex sets the element associated with key in the map v to elem» (SetMapIndex записывает в карту v элемент elem по ключу key); CanSet от самого Value карты он не требует. TypeAssert, появившийся в Go 1.25, возвращает два результата: «TypeAssert is semantically equivalent to: v2, ok := v.Interface().(T)» (TypeAssert по смыслу равносилен: v2, ok := v.Interface().(T)).https://pkg.go.dev/reflect#Value.SetMapIndex