Рефлексия в 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и цена рефлексии в наносекундах.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Что такое рефлексия и где она нужна?» — разминка:
encoding/json,fmt, ORM, валидаторы. - «Чем
reflect.Typeотличается отreflect.Kind?» — проверяют, видит ли кандидат разницу между типом и его устройством. - «Почему
SetIntпаникует?» — правило изменяемости, главный вопрос темы. - «Можно ли через
reflectизменить приватное поле?» — уточнение того же правила. - «Как
encoding/jsonузнаёт имя поля в JSON?» — теги. - «Чем
reflect.DeepEqualотличается от==?» — и где он отвечает не то, что ждут. - «Сколько стоит рефлексия?» — и что с этим делают библиотеки.
Стержень у лестницы один: reflect.Value — описание конкретного значения,
пришедшего через интерфейс, и что с ним можно сделать, решает то, как его
получили. Получили копию — можно читать. Дошли через указатель — можно
менять. Прошли через неэкспортируемое имя — снова только читать. А у среза и
карты содержимое доступно и без указателя — копия заголовка ведёт к тем же
данным.
База: значение уходит в интерфейс и возвращается
Пакет описывает себя одной фразой:
Package reflect implements run-time reflection, allowing a program to
manipulate objects with arbitrary types.
Пакет reflect реализует рефлексию во время выполнения, позволяя программе работать с объектами произвольных типов.
Вход в рефлексию — две функции, и обе принимают any.
Сигнатуры из go doc reflect, фрагмент:
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 .:
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, там же — почему таблица такая:
| как получен Value | CanAddr | CanSet | запись |
|---|---|---|---|
reflect.ValueOf(x) | false | false | SetInt — паника |
reflect.ValueOf(&x) | false | false | SetInt — паника |
reflect.ValueOf(&x).Elem() | true | true | SetInt меняет x |
неэкспортируемое поле через Elem() | true | false | SetInt — паника |
reflect.ValueOf(s), s — срез | false | false | Set — паника |
reflect.ValueOf(s).Index(0) | true | true | SetInt меняет s[0] |
reflect.ValueOf(m), m — карта | false | false | SetMapIndex меняет m |
Механизм 1: Type — это тип, Kind — его устройство
Первая развилка, на которой путаются. Документация определяет Kind так:
A Kind represents the specific kind of type that a Type represents.
Kind представляет конкретную разновидность типа, который представляет Type.
На деле это значит: Type — тип целиком, с именем и пакетом; Kind — одна из
фиксированного набора разновидностей (int, struct, ptr, slice,
map…). Проверка на именованном типе над int.
Фрагмент bench/goreflect/internals.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, сокращён (... — пропущенные ветки):
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 можно изменить, только если он адресуем и получен не через неэкспортируемые поля структуры.
И что такое «адресуем»:
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.
Значение адресуемо, если это элемент среза, элемент адресуемого массива, поле адресуемой структуры или результат разыменования указателя.
Проверка на трёх способах получить 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) пишет прямо в неё.
Внутри это три слова и битовые флаги. reflect.Value — структура из указателя
на тип, указателя на данные и поля flag (reflect/value.go:39–63).
CanSet — одна проверка флагов (value.go:354).
Фрагмент reflect/value.go:354, go1.24.7:
return v.flag&(flagAddr|flagRO) == flagAddrflagAddr ставит Elem() у указателя (value.go:1261), а сеттеры перед
записью проверяют эти же флаги и паникуют с одним из двух текстов
(value.go:251–261): про неадресуемое значение или про неэкспортируемое поле.
Второй текст — следующий механизм.
Отсюда рабочий шаблон для кода, который меняет значения через reflect.
Фрагмент функции — шаблон для своего кода, в прогоне его нет:
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:
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 запрещает.
Пометка «только чтение» ставится в момент получения поля: 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.
Теги доступны через интерфейс рефлексии и участвуют в идентичности типов структур, а в остальном игнорируются.
Смысл тегу придают библиотеки, и читают они его через reflect.StructTag.
Фрагмент bench/goreflect/internals.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.
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)) не равны в глубину.
На этом падают тесты: функция вернула []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 не равен себе, как и при обычном сравнении чисел.
Глубже: сколько стоит рефлексия и откуда цена
«Рефлексия медленная» — ответ без числа. Замер разбирает, из чего цена
складывается: одно и то же поле 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).
Прямое присваивание — доли наносекунды, одна запись в память. Кратность к нему скрипт не печатает намеренно: делитель меньше наносекунды, и отношение было бы шумом.
Отсюда то, что делают библиотеки: ищут поля по имени один раз на тип и
запоминают результат. 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:
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:
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.23 | Value.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 в теге пропускается, если его значение нулевое). Урок его не измерял. | Ещё один ключ, который читает 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.27 | encoding/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.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
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{}))Практика · оцените
Проверка знаний
x := 3; v := reflect.ValueOf(x); v.SetInt(42). Что произойдёт?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Рефлексия — это работа со значением, тип которого известен только во время выполнения.
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надintType —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 раза, без аллокаций. Механизм объясняет, откуда разница; её величину на своей машине надо мерить.
На самом деле
- Он получает копию —
ValueOfпринимаетany. В прогоне послеx = 7.1значениеv.Float()осталось3.4, аSetIntна таком значении паникует:using unaddressable value. reflect.ValueOf(&x)описывает сам указатель (Kindptr), и он тоже не адресуем:CanSet—false, та же паника. Изменяетreflect.ValueOf(&x).Elem().- Нельзя заменить саму переменную. Но
reflect.ValueOf(s).Index(0).SetInt(7)меняет элемент среза у вызывающего — в прогонеs = [7 2 3], — аSetMapIndexпишет в карту, хотяCanSetу еёValue—false. У массива жеValueOf(a).Index(0)— элемент копии,CanSet—false. - У
Celsius(21)Type —main.Celsius, Kind —int, иTypeOf(Celsius(21)) == TypeOf(21)даётfalse. Переключатель по типуcase intего не узнаёт, переключатель по Kind — узнаёт. - Видно и читается:
age.Int()вернул30,fmtпечатает его. Нельзя изменить (CanSet—false) и нельзя получить черезInterface()— оба вызова паникуют. - Для пустого ключа — нет. У
json:""и у поля без тегаGet("json")даёт"", аLookup—"", trueпротив"", false. - Не считает:
[]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).< - Go 1.22
- Функция
reflect.TypeFor[T]()(api/go1.22.txt): тип по параметру типа, без значения.< - Go 1.23
Value.SeqиValue.Seq2(api/go1.23.txt): обход значения цикломrangeпо функции.<- Go 1.24
- В
encoding/jsonпоявился параметр тегаomitzero. Release notes: When marshaling, a struct field with the newomitzerooption in the struct field tag will be omitted if its value is zero. Урок его не измерял.< - 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 — ниже.< - Go 1.27
encoding/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 урок не запускался.<
Что разобрано
- Что здесь на самом деле спрашивают
- База: значение уходит в интерфейс и возвращается
- Механизм 1: Type — это тип, Kind — его устройство
- Механизм 2: правило изменяемости — `CanSet`
- Механизм 3: неэкспортируемое поле — читается, но не меняется
- Механизм 4: теги — откуда `encoding/json` берёт имена
- Механизм 5: `DeepEqual` — «равно» без оператора `==`
- Глубже: сколько стоит рефлексия и откуда цена
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- История версий
- Практика
- Проверка знаний
Источники и что читать дальше
6 ИСТОЧНИКОВ
- Пакет 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
- Пакет 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
- Спецификация 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
- Пакет 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
- Пакет 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
- Пакет 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