Модуль
1Указатели: основы2Value и pointer receivers3Устройство типов в памяти← вы здесь4Стек, куча и escape analysis
Урок 3~15 минут

Устройство типов в памяти

Зачем знать устройство типов

Большинство Go-программ прекрасно работают без этих знаний. Но три сценария, где это становится критично:

1. Объяснение неочевидного поведения. Почему nil-интерфейс не равен интерфейсу с nil-указателем? Почему после b := a[1:3] запись в b[0] меняет a? Ответы — в структуре памяти.

2. Оптимизация struct. Переставить поля в правильном порядке — одна строчка кода, которая может уменьшить размер структуры в полтора раза. В горячих путях это прямо влияет на GC-паузы и cache locality.

3. Работа с unsafe и CGo. Без понимания layout'а невозможно писать zero-copy сериализацию или вызывать C-библиотеки через cgo.


Интерфейс: два указателя

Каждый интерфейс в памяти — это всегда ровно 16 байт: два машинных слова.

go
// Интерфейс с методами (io.Reader, error, fmt.Stringer...)
type iface struct {
    tab  *itab           // тип + таблица методов
    data unsafe.Pointer  // указатель на значение
}
 
// Пустой интерфейс (interface{} / any)
type eface struct {
    _type *_type         // только описание типа
    data  unsafe.Pointer
}

itab — это кэшированная пара (интерфейс, конкретный тип). Он создаётся один раз и переиспользуется:

go
type itab struct {
    inter *interfacetype  // какой интерфейс
    _type *_type          // какой конкретный тип
    hash  uint32          // копия _type.hash для type switch
    fun   [1]uintptr      // начало vtable — адреса методов
}

Вызов метода через интерфейс — это call *itab.fun[i]. Один дополнительный уровень косвенности. На современных CPU с предсказателем ветвлений это почти бесплатно.


Ловушка №1: nil-интерфейс ≠ интерфейс с nil-указателем

Это самая частая нетривиальная ошибка в Go. Вот классический пример:

go
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
 
func getError(fail bool) error {
    var err *MyError  // nil указатель на MyError
    if fail {
        err = &MyError{"что-то пошло не так"}
    }
    return err  // ОШИБКА ДИЗАЙНА
}
 
func main() {
    err := getError(false)
    if err != nil {
        fmt.Println("ошибка:", err)  // этот код выполнится!
    }
}

Что происходит в памяти:

err (error interface):
  tab  → itab{inter: error, _type: *MyError}  ← НЕ nil!
  data → nil

nil-интерфейс:
  tab  → nil
  data → nil

Функция вернула интерфейс error, в котором tab указывает на тип *MyError. Интерфейс не nil — даже хотя указатель внутри него nil.

Правильное решение:

go
func getError(fail bool) error {
    if fail {
        return &MyError{"что-то пошло не так"}
    }
    return nil  // возвращаем nil-интерфейс напрямую
}

Правило: никогда не возвращай конкретный nil-указатель через интерфейс. Возвращай nil напрямую.


Ловушка №2: присваивание интерфейса не копирует значение

go
x := 42
var i interface{} = x   // копия 42 уходит в heap, i.data → копия
 
x = 100
fmt.Println(i)  // 42 — i хранит копию, не ссылку на x

Для больших структур это важно — каждое присваивание в interface{} аллоцирует копию в heap. Именно поэтому горячий код избегает interface{} в пользу дженериков (Go 1.18+) или конкретных типов.

go
// Дорого: аллокация на каждый вызов
func logValue(v interface{}) { fmt.Println(v) }
 
// Дёшево: без аллокации, компилятор знает тип
func logInt(v int) { fmt.Println(v) }

String: почему копирование строки — O(1)

go
type stringHeader struct {
    Data unsafe.Pointer  // указатель на байты (read-only)
    Len  int
}

Строка занимает 16 байт в стеке, независимо от содержимого. Данные лежат в отдельном блоке памяти — неизменяемом.

go
a := "hello world"  // 16 байт на стеке, 11 байт в heap
b := a              // скопировано 16 байт, данные общие
 
unsafe.Sizeof(a)   // 16
unsafe.Sizeof(b)   // 16

Срезы строк — тоже O(1):

go
s := "Hello, World!"
sub := s[7:12]  // новый 16-байтовый заголовок, Data сдвинут на 7
// sub.Data = s.Data + 7
// sub.Len  = 5
// никакого копирования байт!

Конкатенация — другое дело:

go
a + b  // всегда аллоцирует новый блок и копирует оба

Именно поэтому конкатенация в цикле — O(n²), а strings.Builder — O(n).


Slice: когда append создаёт новый массив

go
type sliceHeader struct {
    Data unsafe.Pointer
    Len  int
    Cap  int
}

Слайс — 24 байта. Ключ к пониманию append — это поле Cap.

Случай 1: есть место (Len < Cap)

go
a := make([]int, 3, 6)  // Len=3, Cap=6, есть 3 свободных слота
 
b := a         // b.Data == a.Data — один массив!
b = append(b, 99)  // пишет в a[3], Data не меняется
 
fmt.Println(a[:4])  // [0 0 0 99] — a тоже изменился!

Это тихая ошибка. b выглядит как копия, но backing array общий.

Случай 2: места нет (Len == Cap)

go
a := []int{1, 2, 3}  // Len=3, Cap=3
 
b := append(a, 4)
// Cap исчерпан → новый массив (примерно ×2 по размеру)
// b.Data ≠ a.Data
 
b[0] = 99
fmt.Println(a[0])  // 1 — a не тронут, разные массивы

Стратегия роста capacity

Go не просто удваивает — алгоритм более тонкий:

go
// Упрощённо (реальный код в runtime/slice.go):
// cap < 256  → newcap = oldcap * 2
// cap >= 256 → newcap = oldcap + (oldcap + 3*256) / 4
go
s := make([]int, 0)
for i := 0; i < 10; i++ {
    s = append(s, i)
    fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
}
// len=1 cap=1
// len=2 cap=2
// len=3 cap=4
// len=4 cap=4
// len=5 cap=8
// ...

Совет: если знаешь финальный размер — передавай capacity в make:

go
result := make([]Item, 0, len(input))  // ноль аллокаций при append

Безопасное копирование слайса

go
original := []int{1, 2, 3, 4, 5}
 
// НЕПРАВИЛЬНО: b и original делят массив
b := original[:]
 
// ПРАВИЛЬНО: независимая копия
c := make([]int, len(original))
copy(c, original)
 
// Или:
c2 := append([]int{}, original...)

Map: почему всегда по ссылке

go
var m map[string]int  // m — это *hmap, т.е. указатель

unsafe.Sizeof(m) == 8 всегда. m — это просто адрес структуры hmap в heap. Именно поэтому:

go
func addItem(m map[string]int, key string, val int) {
    m[key] = val  // модифицирует оригинал без &m
}
 
func main() {
    scores := map[string]int{}
    addItem(scores, "Alice", 100)
    fmt.Println(scores["Alice"])  // 100
}

Сравни со срезом: append возвращает новый заголовок, map — нет. Это разные модели.

Внутреннее устройство: buckets

go
type hmap struct {
    count      int            // текущее кол-во элементов
    B          uint8          // log₂ кол-ва buckets
    hash0      uint32         // случайный seed (защита от DoS)
    buckets    unsafe.Pointer // указатель на массив bucket'ов
    oldbuckets unsafe.Pointer // при перехэшировании
}

Каждый bucket хранит до 8 пар ключ-значение. При load factor > 6.5 карта начинает incremental rehashing: новые buckets создаются вдвое большего размера, данные переносятся по несколько штук при каждой операции записи.

go
// hash0 — ключевая защита:
// без рандомного seed атакующий может подобрать ключи
// с одним hash, вызвав деградацию до O(n) на каждый lookup

Итерация по map недетерминирована

go
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
    fmt.Println(k, v)
}
// порядок разный каждый раз — намеренно рандомизирован

Go специально рандомизирует порядок итерации (используя hash0), чтобы код не мог случайно начать зависеть от него.


Struct: padding и выравнивание

Каждый тип имеет alignment — по какому адресу он может начинаться:

ТипAlignment
bool, int8, uint8, byte1 байт
int16, uint162 байта
int32, uint32, float32, rune4 байта
int64, uint64, float64, pointer, uintptr8 байт
string (ptr+len)8 байт
slice (ptr+len+cap)8 байт

Компилятор вставляет padding, чтобы каждое поле лежало на своей границе.

Пример: 24 байта вместо 10

go
type Wasteful struct {
    a bool    // offset 0,  size 1
              // padding 7 байт (до выравнивания int64)
    b int64   // offset 8,  size 8
    c bool    // offset 16, size 1
              // padding 7 байт (выравнивание всей структуры = 8)
}
// unsafe.Sizeof(Wasteful{}) = 24
// реальных данных: 1+8+1 = 10 байт
// потери: 14 байт (58%!)

Оптимизация: от большего к меньшему

go
type Efficient struct {
    b int64   // offset 0, size 8
    a bool    // offset 8, size 1
    c bool    // offset 9, size 1
              // padding 6 байт
}
// unsafe.Sizeof(Efficient{}) = 16
// сэкономили 8 байт — 33% памяти

Реальный пример: структура HTTP-запроса

go
// Плохо — 72 байта
type RequestBad struct {
    method  string   // 16
    isDone  bool     // 1 + 7 pad
    path    string   // 16
    isHTTPS bool     // 1 + 7 pad
    code    int32    // 4 + 4 pad
}
 
// Хорошо — 56 байт
type RequestGood struct {
    method  string   // 16
    path    string   // 16
    code    int32    // 4
    isDone  bool     // 1
    isHTTPS bool     // 1 + 2 pad
}

При 1 миллионе объектов в памяти это разница в 16 МБ — и это напрямую влияет на сколько объектов помещается в CPU cache.

Инструменты

go
import "unsafe"
 
fmt.Println(unsafe.Sizeof(Wasteful{}))         // 24
fmt.Println(unsafe.Sizeof(Efficient{}))        // 16
fmt.Println(unsafe.Offsetof(Wasteful{}.b))     // 8
fmt.Println(unsafe.Alignof(Wasteful{}.b))      // 8

Или используй go vet и линтер fieldalignment:

bash
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment ./...
# struct with 72 pointer bytes could be 56

Unsafe: когда и зачем

unsafe обходит систему типов Go. Используй только когда без него не обойтись.

Читать байты любого значения

go
import "unsafe"
 
x := float64(1.5)
// смотрим на сырые байты float64
bits := *(*uint64)(unsafe.Pointer(&x))
fmt.Printf("%064b\n", bits)
// 0 01111111111 1000000000000000000000000000000000000000000000000000
// знак=0, экспонента=1023, мантисса=0.5

Zero-copy преобразование []byte ↔ string

go
// Стандартный способ: аллоцирует копию
s := string(bytes)
b := []byte(s)
 
// Unsafe: zero-copy, но UB если bytes мутируется
func bytesToString(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}
 
// Безопаснее через reflect:
import "reflect"
func bytesToStringReflect(b []byte) string {
    return *(*string)(unsafe.Pointer(&reflect.SliceHeader{
        Data: (*reflect.SliceHeader)(unsafe.Pointer(&b)).Data,
        Len:  len(b),
    }))
}

Арифметика указателей

go
type Point struct{ X, Y int32 }
p := Point{1, 2}
ptr := unsafe.Pointer(&p)
 
// сдвигаемся на 4 байта (sizeof int32) к полю Y
yPtr := (*int32)(unsafe.Pointer(uintptr(ptr) + unsafe.Offsetof(p.Y)))
fmt.Println(*yPtr)  // 2

Важно: uintptr — это просто число, не указатель. GC не знает о нём и может переместить объект между двумя строками. Конвертируй обратно в unsafe.Pointer в той же инструкции.

Когда unsafe оправдан

СитуацияОправдан?
Zero-copy сериализация (protobuf, flatbuffers)Да
CGo, взаимодействие с C-библиотекойДа
Атомарные операции над кастомными типамиИногда
Обход системы типов из лениНет
Чтение приватных полей чужого пакетаНет

Итог: размеры типов

go
var (
    i   int
    s   string
    sl  []int
    m   map[string]int
    ch  chan int
    p   *int
    fn  func()
    ifc interface{}
)
 
fmt.Println(unsafe.Sizeof(i))   // 8
fmt.Println(unsafe.Sizeof(s))   // 16
fmt.Println(unsafe.Sizeof(sl))  // 24
fmt.Println(unsafe.Sizeof(m))   // 8  (указатель)
fmt.Println(unsafe.Sizeof(ch))  // 8  (указатель)
fmt.Println(unsafe.Sizeof(p))   // 8  (указатель)
fmt.Println(unsafe.Sizeof(fn))  // 8  (указатель на closure)
fmt.Println(unsafe.Sizeof(ifc)) // 16 (два указателя)

Запомни эту таблицу. Когда будешь проектировать структуры для горячих путей — она поможет сразу оценить цену решения.

unsafe.Sizeof(x) — твой лучший друг при оптимизации. Запусти его на своих структурах и посмотри, сколько байт тратится впустую на padding.
Интерфейс — два указателя: тип и данные. Это позволяет рантайму знать реальный тип значения и найти нужный метод.
// интерфейс с методами
type iface struct {
    tab  *itab           // тип + таблица методов
    data unsafe.Pointer  // указатель на значение
}

type itab struct {
    inter *interfacetype
    _type *_type
    hash  uint32         // для type switch
    fun   [1]uintptr     // таблица методов (vtable)
}
СТЕК (16 байт)
itab→ type info8B
data→ value8B
itab: тип + методы
_type→ *int
hash0x...
fun[0]→ String()
fun[1]→ Error()
data: значение
42
var r io.Reader = os.Stdin
// r.tab  → itab{inter: io.Reader, _type: *os.File}
// r.data → указатель на os.Stdin

// type assertion — проверяет r.tab._type:
f, ok := r.(*os.File)  // ok=true

// type switch — использует itab.hash:
🎯
Миссия 1 из 5
Сколько байт занимает переменная типа string в Go (на 64-битной платформе)?