Привет, Хабр!

Сегодня разберём, как устроен reflect.Value изнутри и что на происходит, когда вы вызываете .Field(i).

Что прячется в reflect.Value — и как это связано с вашей памятью

Когда вы пишете в коде reflect.ValueOf(x), вам возвращается объект, внутри которого — по сути, три вещи:

type Value struct {
    typ_ *abi.Type      // описание типа (reflect.Type — это обёртка вокруг)
    ptr  unsafe.Pointer // указатель на данные, которые мы отражаем
    flag flag           // битовая маска, определяющая поведение
}

Чтобы понимать, как это работает, придётся мысленно опуститься на уровень ABI Go‑рантайма. Сам по себе reflect.Value не создаёт копий объекта — он лишь смотрит на память, где уже лежит значение, и говорит: «Вот здесь *abi.Type, вот указатель, а вот флаги — действуй, рантайм».

flag — битовый компакт-пакет с контекстом

flag — это uintptr, каждая часть которого закодирована под конкретные задачи:

type flag uintptr

const (
    flagKindWidth = 5                // нижние 5 битов: reflect.Kind (например, Int, Struct)
    flagKindMask  = 1<<flagKindWidth - 1

    flagIndir     = 1 << 7           // ptr — это *указатель* на значение, а не значение напрямую
    flagAddr      = 1 << 8           // можно ли безопасно брать адрес (&)
    flagMethod    = 1 << 9           // значение представляет собой method value

    // Есть и другие, внутренние: флаги readonly (sticky), embedded readonly и др.
)

Если flagIndir установлен, это значит, что ptr указывает не на само значение, а на адрес, по которому лежит значение.

Если flagIndir НЕ установлен, то ptr напрямую указывает на байты самого значения. Например, если у вас Value из int64(42), ptr будет указывать прямо на 8 байт с этим числом.

flagAddr говорит: можно ли брать адрес (.Addr()). Он установлен только тогда, когда у Value есть доступ к оригинальной памяти.

flagMethod используется, если это метод‑значение (obj.Method(i)), а не просто поле.

reflect.Value — это буквально метаобъект, который носит с собой:

  • знание где лежит значение;

  • как к нему обращаться (через указатель или напрямую);

  • и какие действия разрешены (можно ли брать адрес, можно ли писать и т. д.).

Как выглядят разные Value

Простой способ понять различие между флагами:

type S struct{ A int }

var s = S{A: 42}

v1 := reflect.ValueOf(s)   // значение
v2 := reflect.ValueOf(&s)  // указатель на значение

fmt.Println(v1.CanAddr()) // false
fmt.Println(v2.CanAddr()) // false
fmt.Println(v2.Elem().CanAddr()) // true

Почему v1.CanAddr()false? Потому что s был передан по значению, и reflect.ValueOf(s) получил копию. Т.е ptr указывает на временную область памяти, которую никто не сможет изменить. Отсюда — CanAddr() == false, и, соответственно, CanSet() == false.

Когда же мы делаем v2 := reflect.ValueOf(&s), а затем v2.Elem(), мы получаем Value, который:

  • знает, где лежит s.A в памяти;

  • знает, что это оригинальный s;

  • и, соответственно, может менять поле A.

CanSet(), CanAddr() и связанная логика

Внутри reflect.Value метод CanSet устроен так:

func (v Value) CanSet() bool {
    return v.flag&(flagAddr|flagRO) == flagAddr
}

То есть, чтобы Set() был возможен:

Нужно иметь доступ к адресу — flagAddr должен быть установлен. Поле не должно быть read‑only — это flagRO, который складывается из двух других: flagStickyRO — если значение пришло от неэкспортного поля или от копии.flagEmbedRO — если значение получено из вложенного анонимного поля и тоже неэкспортное.

Иными словами:

Если CanSet() == false, это либо потому что вы получили значение по значению (копию), либо потому что значение закрыто (то же private поле в другой пакете).

CanAddr() проверяет, можно ли безопасно взять &v, то есть сделать v.Addr(). Это возможно только если reflect.Value обёрнут вокруг оригинальной памяти, а не временной копии. В частности, если вы делаете ValueOf(someInt).Addr(), и someInt — не &int, то получите панику.

Самое интересное: Value.Field(i)

Метод Value.Field(i) — это API для доступа к полям структуры через reflection. Его поведение кажется высокоуровневым, но под всем этим жёсткая манипуляция памятью, типами и битами флагов.

Реализация из стандартной библиотеки:

func (v Value) Field(i int) Value {
    tt := (*structType)(unsafe.Pointer(v.typ()))
    field := &tt.Fields[i]

    fl := v.flag&(flagStickyRO|flagIndir|flagAddr) | flag(field.Typ.Kind())
    if !field.Name.IsExported() {
        if field.Embedded() { fl |= flagEmbedRO } else { fl |= flagStickyRO }
    }
    ptr := add(v.ptr, field.Offset, "same as &v.field")
    return Value{field.Typ, ptr, fl}
}

Проверка: точно ли это struct

До этой функции рантайм уже проверил: v.Kind() должен быть reflect.Struct. Если нет — будет panic("reflect: Field of non-struct type").

Field(i) не работает с указателями, с интерфейсами, с чем угодно другим. Только reflect.Value, который ссылается на структуру, может быть обрабатываем в этой функции.

Получаем structType

tt := (*structType)(unsafe.Pointer(v.typ()))

reflect.Type — это интерфейс, но typ_ в Value — это abi.Type, и она имеет конкретную реализацию — reflect.rtype, а дальше — *reflect.structType.

Этот каст через unsafe.Pointer — чистая оптимизация: не создаём никаких интерфейсов, не проверяем ничего через type switches. Просто знаем, что v — это struct, и знаем, у него будет *structType.

Тип structType содержит описание всех полей:

type structType struct {
    rtype
    fields []structField
}

Каждый structField — это:

type structField struct {
    Name name       // имя поля (в том числе экспортность)
    Typ  *rtype     // тип поля
    Offset uintptr  // смещение относительно начала struct'а
    ...
}

Определяем флаги доступа

fl := v.flag&(flagStickyRO|flagIndir|flagAddr) | flag(field.Typ.Kind())

Здесь рантайм переносит флаги из родительского Value в новый Value, который будет представлять поле:

  • flagStickyRO — означает, что родительский объект уже был read‑only.

  • flagIndir — указывает, является ли ptr указателем на значение.

  • flagAddr — можно ли брать адрес (используется Addr()).

Дополнительно добавляется Kind поля — это низшие 5 битов, чтобы знать, что это Int, String, Struct и т. д.

Проверка экспортности

if !field.Name.IsExported() {
    if field.Embedded() {
        fl |= flagEmbedRO
    } else {
        fl |= flagStickyRO
    }
}

Если поле неэкспортное — вы не сможете его модифицировать вне пакета, даже если оно CanAddr. Если оно анонимное, то накладывается flagEmbedRO, иначе flagStickyRO.

Именно из‑за этих флагов CanSet() и CanAddr() позже скажут вам «нет».

Расчёт указателя

ptr := add(v.ptr, field.Offset, "same as &v.field")

А вот это — костыльный хайлайт. add — это рантайм‑функция:

func add(p unsafe.Pointer, x uintptr, whySafe string) unsafe.Pointer

Она просто делает:

return unsafe.Pointer(uintptr(p) + x)

Т.е, берём указатель на начало struct, прибавляем смещение поля — и получаем указатель на поле.

Тут нет аллокаций, нет копирования, вы просто говорите: покажи мне другой байт в памяти.

Конструируем новый Value

return Value{field.Typ, ptr, fl}

То есть — мы создаём reflect.Value, который: ссылается на конкретное поле; знает его тип; унаследовал или наложил ограничения доступа; и может быть использован дальше в цепочке: .Field(i).Set(...), .Addr(), .Interface() и т. п.

Почему без flagIndir офсет равен 0?

Если родительский reflect.Value НЕ содержит flagIndir, то ptr уже указывает на само значение, а не на указатель к нему.

type T struct{ A int }
var t = T{A: 10}
v := reflect.ValueOf(t)

Здесь v.ptr указывает на временную копию t, хранящуюся в runtime. Нет никакого двойного уровня, и Offset реально равен 0 — вы сразу на месте. Никаких разыменований.

Что с производительностью?

Операция Field(i) по сути:

  • делает один unsafe.Pointer cast;

  • читает uintptr;

  • складывает смещение;

  • устанавливает флаги.

По CPU — это ~10–20 инструкций, то есть очень быстро.

Но как только вы делаете:

v.Field(i).Interface()

Go обязан:

  1. Прочитать байты из указателя ptr;

  2. Копировать их в новую область памяти (heap);

  3. Обернуть это в interface{} (ещё одна структура: тип + data);

  4. Вернуть.

И вот здесь случаются:

  • Аллокация

  • Копирование

  • Потеря типобезопасности

По данным бенчмарка:

Способ

Время

Аллокации

Прямой доступ (obj.A)

0.5 ns

0

reflect.Field(i)

20–30 ns

0

reflect.Field(i).Interface()

700–1000 ns

1–2

Метод Value.Field(i) не делает ничего необычно: он просто по смещению и описанию типа достаёт указатель на поле. Вся сила (и боль) начинается потом — когда вы начинаете читать, писать или преобразовывать результат.

Чтобы делать reflection быстро и безопасно:

  • Работайте на уровне Value, не Interface;

  • Всегда проверяйте CanSet и CanAddr;

  • Кэшируйте Type.Field(i) и индексные смещения;

  • Избегайте Interface() в горячих путях.

Дешевый буст

Самая дорогая часть любого рефлект‑кода — поиск (Type.NumField() + имя) и проверка тегов. Решение — строим индекс один раз и храним его в sync.Map или plain map с atomic.Pointer.

type fieldMeta struct {
    idx   int
    write func(dst, src reflect.Value)
}

var cache sync.Map // reflect.Type -> []fieldMeta

func metaOf(t reflect.Type) []fieldMeta {
    if v, ok := cache.Load(t); ok { return v.([]fieldMeta) }

    m := make([]fieldMeta, 0, t.NumField())
    for i := 0; i < t.NumField(); i++ {
        sf := t.Field(i)
        if !sf.IsExported() { continue }
        idx := i                         // capture
        m = append(m, fieldMeta{
            idx: idx,
            write: func(dst, src reflect.Value) {
                dst.Field(idx).Set(src.Field(idx))
            },
        })
    }
    cache.Store(t, m)
    return m
}

Теперь CopyStruct(dst, src) превращается в тривиальный цикл без поиска тегов и имён.

Generics × Reflection в Go 1.22

Новая функция reflect.TypeFor[T any]() делает жизнь проще, когда нужно получить reflect.Type для интерфейса, не теряя сам интерфейс по пути:

var errorType = reflect.TypeFor[error]() // раньше было TypeOf((*error)(nil)).Elem()

Содержит всего три строки, но избавляет от забывчивых костылей.

Прямой доступ vs reflection vs unsafe

type demo struct {
    A int
    B string
}

func BenchmarkDirect(b *testing.B) {
    d := demo{42, "hi"}
    for i := 0; i < b.N; i++ { _ = d.A }
}

func BenchmarkReflect(b *testing.B) {
    v := reflect.ValueOf(&demo{42, "hi"}).Elem()
    for i := 0; i < b.N; i++ { _ = v.Field(0).Int() }
}

func BenchmarkUnsafe(b *testing.B) {
    d := demo{42, "hi"}
    ap := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&d)) + unsafe.Offsetof(d.A)))
    for i := 0; i < b.N; i++ { _ = *ap }
}

Direct: ~0.5 ns/op, 0 alloc

Unsafe: ~0.6 ns/op, 0 alloc (CPU‑паритет, но никакой безопасности)

Reflect: 350–450 ns/op, +1 alloc (сам Value.Int() возвращает int64)


Если вам интересна внутренняя механика кода, работа с памятью и архитектурные паттерны — обратите внимание на ближайшие разборы по C++ и backend-разработке. Ниже — несколько тем, где внимание к деталям и работа «на уровне байта» не просто приветствуются, а необходимы:

  • 9 июняОтладка C++: от printf до asan и зелёных тестов
    Практика поиска багов, логирования и анализа памяти с помощью отладчиков, core dump, sanitizers и valgrind. Базовый набор инструментов, который стоит держать под рукой.

  • 11 июняВзаимодействие микросервисов: REST, события, очереди
    Разбираем ключевые стили коммуникации между сервисами, когда и зачем применять асинхронность, брокеры сообщений и CQRS — с акцентом на архитектурную устойчивость.

  • 17 июня Асинхрон в C++ с Boost.Asio
    Подход к построению масштабируемых сетевых приложений: неблокирующие вызовы, обработка событий через io_context и практика async-программирования.

Больше про языки программирования эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.

Комментарии (0)