Перейти к содержимому

Указатели и память в Go

Указатели — фундамент понимания, как в Go работают передача данных, мутации, и почему компилятор решает класть переменную в stack или heap. На собесе джуна постоянно спрашивают: “что такое escape analysis?”, “зачем pointer receiver?”, “какая разница между new(T) и &T{}?”. Здесь — детальный разбор под капотом.

  1. Базовое определение и API
  2. Внутреннее устройство (ПОД КАПОТОМ)
  3. Тонкие моменты / Gotchas
  4. Производительность / Memory considerations
  5. Типичные вопросы на собеседовании Junior
  6. Practice — мини-задачки
  7. Источники

Указатель — значение, которое хранит адрес другой переменной в памяти. В Go тип *T обозначает указатель на значение типа T.

var x int = 42
var p *int = &x // p — указатель на x
fmt.Println(*p) // 42 — разыменование
*p = 100 // мутация значения по адресу
fmt.Println(x) // 100

Операторы:

  • & — взятие адреса (&x возвращает *T).
  • * — разыменование (*p возвращает T).
var p *int
fmt.Println(p == nil) // true
*p = 5 // panic: runtime error: invalid memory address or nil pointer dereference

Обе формы возвращают *T, указывающий на обнулённое значение.

p1 := new(int) // *int, *p1 == 0
p2 := &struct{ X int }{} // указатель на пустую struct
p3 := new(MyStruct) // эквивалент &MyStruct{}
ФормаЧто делаетКогда удобно
new(T)Возвращает *T нулевого значенияКогда лень писать литерал
&T{}Литерал + взятие адресаКогда нужно сразу задать поля
&T{X: 1}То же + инициализацияИдиоматично для конструкторов

⚠️ Подвох: new(T) нельзя вызвать с инициализацией. Поэтому &T{поля} чаще встречается в идиоматичном коде.

В Go всё передаётся по значению. Если параметр — указатель, копируется указатель (адрес), но обращение к данным — одно и то же.

func incVal(x int) { x++ } // меняет копию
func incPtr(x *int) { *x++ } // меняет оригинал
a := 10
incVal(a)
fmt.Println(a) // 10
incPtr(&a)
fmt.Println(a) // 11
Передавать по значениюПередавать по указателю
Маленькие структуры (≤ ~64 байт)Большие структуры
Иммутабельные типы (int, string, time)Когда нужно мутировать
Без необходимости менять оригиналКогда нужна “shared” семантика
Slices, maps, chans, funcs (уже header)Когда nil валиден как состояние

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


Go runtime управляет двумя областями памяти:

  • Stack — у каждой горутины свой стек (стартует с ~2-8 КБ, динамически растёт). Аллокации очень быстрые (указатель вверх/вниз). Освобождение — автоматическое при выходе из функции.
  • Heap — общая куча, освобождается garbage collector (GC). Аллокации медленнее, GC создаёт нагрузку.

Где именно окажется переменная — решает компилятор на этапе компиляции через escape analysis.

┌─────────────────────────┐
│ Heap (общая, GC) │ ← медленнее, GC pressure
│ ┌─────┐ ┌────┐ │
│ │ obj │ │obj │ │
│ └─────┘ └────┘ │
├─────────────────────────┤
│ Stack горутины 1 │ ← быстро, авто-освобождение
│ ┌──────────────┐ │
│ │ frame func2 │ │
│ ├──────────────┤ │
│ │ frame func1 │ │
│ └──────────────┘ │
├─────────────────────────┤
│ Stack горутины 2 │
│ ... │
└─────────────────────────┘

Компилятор Go анализирует, переживёт ли переменная свою функцию (т.е. адрес/ссылка на неё выйдет за пределы фрейма). Если да — переменная “убегает в heap”. Если нет — остаётся на стеке.

// Не убегает — компилятор положит на stack
func sumLocal() int {
x := 42
return x
}
// Убегает — возвращаем указатель → x должен пережить функцию
func makePtr() *int {
x := 42
return &x // компилятор кладёт x в heap
}
Окно терминала
go build -gcflags="-m" main.go
# или подробнее:
go build -gcflags="-m -m" main.go

Пример вывода:

./main.go:5:6: can inline sumLocal
./main.go:10:6: moved to heap: x
  1. Возврат указателя на локальную переменную:
    func f() *Foo { return &Foo{} } // Foo escapes
  2. Сохранение в интерфейс (interface storage):
    var i interface{} = bigStruct // bigStruct escapes если не fit в interface
    Под капотом interface хранит указатель на значение (см. файл 08).
  3. Захват замыканием (closure):
    func g() func() int {
    x := 1
    return func() int { return x } // x escapes
    }
  4. Отправка в канал:
    ch <- &x // x escapes
  5. Сохранение в slice/map глобальном или возвращаемом наружу.
  6. Слишком большой объект (больше размера стекового фрейма) — runtime может разместить в heap.
  7. Динамический размер через make([]T, n) где n неизвестен компилятору.
  8. Использование reflect часто триггерит escape.

В отличие от C/C++, в Go нельзя p++ или p + 4. Это намеренное решение для безопасности памяти.

p := &arr[0]
// p++ // ❌ ошибка компиляции

Для “арифметики” есть unsafe.Pointer + uintptr (опасно, использовать только в исключениях).

import "unsafe"
var x int64 = 0x0102030405060708
p := unsafe.Pointer(&x) // нетипизированный указатель
b := (*byte)(p) // приводим к *byte
fmt.Println(*b) // первый байт

Правила:

  • Можно конвертировать *Tunsafe.Pointer*U.
  • Можно конвертировать unsafe.Pointeruintptr (но uintptr — не указатель, GC не отслеживает!).
  • Использовать в большинстве проектов не нужно. На собесе достаточно знать, что существует.
arr := [3]int{1, 2, 3}
pArr := &arr // *[3]int, указывает на весь массив
s := arr[:] // slice — уже содержит указатель на underlying array
pSlice := &s // *[]int — указатель на slice header (редко нужен)

⚠️ Подвох: &arr[0] — указатель на элемент. &arr — указатель на массив. Это разные типы.


type User struct{ Name string }
var u *User
fmt.Println(u.Name) // panic: nil pointer dereference

Особенно болезненно при работе с возвращаемыми из БД/JSON структурами.

⚠️ Gotcha 2: Возврат адреса параметра-копии бесполезен

Заголовок раздела «⚠️ Gotcha 2: Возврат адреса параметра-копии бесполезен»
func badPtr(x int) *int {
return &x // x — это копия; "работает", но смысл странный
}
// Это валидно, но x escapes в heap. Чаще признак неверного дизайна.
m := map[string]int{"a": 1}
// p := &m["a"] // ❌ cannot take the address of m["a"]

Потому что map может перераспределить bucket’ы при росте, и адрес станет невалидным.

⚠️ Gotcha 4: Address of slice element — валиден, но осторожно

Заголовок раздела «⚠️ Gotcha 4: Address of slice element — валиден, но осторожно»
s := []int{1, 2, 3}
p := &s[0] // OK
s = append(s, 4, 5, 6, 7) // если выделился новый массив, p указывает на старый
*p = 100
fmt.Println(s[0]) // может быть 1, а не 100!

== сравнивает адреса, а не значения.

a := 5
b := 5
pa := &a
pb := &b
fmt.Println(pa == pb) // false — разные адреса
fmt.Println(*pa == *pb) // true — равные значения

Метод с pointer receiver можно вызвать на nil — паники не будет, пока не обратитесь к полям.

type List struct{ next *List }
func (l *List) Len() int {
if l == nil { return 0 }
return 1 + l.next.Len()
}
var l *List
fmt.Println(l.Len()) // 0, не паника!

В Go разрешено брать адрес композитного литерала:

p := &MyStruct{X: 1} // OK, обычная практика

Но не литерала примитива:

// p := &42 // ❌ cannot take the address of 42
n := 42
p := &n // OK

⚠️ Gotcha 8: Range и адрес итерационной переменной (Go < 1.22)

Заголовок раздела «⚠️ Gotcha 8: Range и адрес итерационной переменной (Go < 1.22)»

В Go < 1.22 переменная цикла переиспользуется, и взятие её адреса даёт один и тот же адрес:

// Go < 1.22
for i, v := range items {
go func() { fmt.Println(i, v) }() // часто бажит
}

В Go 1.22+ каждая итерация — новая переменная. Бага не будет (но привычку проверять оставьте).

⚠️ Gotcha 9: Передача массива по указателю vs slice

Заголовок раздела «⚠️ Gotcha 9: Передача массива по указателю vs slice»

Массив (фиксированный размер) при передаче копируется целиком. Slice — нет, slice header копируется, но underlying array общий.

func modArr(a [3]int) { a[0] = 999 } // не повлияет
func modPtr(a *[3]int) { a[0] = 999 } // повлияет
func modSlice(s []int) { s[0] = 999 } // повлияет (slice уже "по ссылке")

⚠️ Gotcha 10: Поля struct по указателю — не нужны для slice/map

Заголовок раздела «⚠️ Gotcha 10: Поля struct по указателю — не нужны для slice/map»
type Cache struct {
items map[string]int // уже header — не оборачивайте в указатель
}

map, slice, chan, func уже содержат внутренний указатель. Делать *map[string]int — почти всегда ошибка дизайна.


ОперацияСтекКуча
AllocationИнкремент указателяПоиск свободного блока (mcache → mcentral → mheap)
FreeАвто (frame pop)GC mark-sweep
Affecting GC?НетДа, создаёт давление
  • Один раз при инициализации сервиса.
  • Long-lived объекты (кэш, конфиг).
  • Малое количество аллокаций в горячем пути.
  • В hot path (handler HTTP, обработчик event’а): тысячи аллокаций/сек создают GC pressure.
  • В коде, где можно было обойтись стеком (mini struct as value).
Окно терминала
go test -bench . -benchmem # покажет alloc/op
go test -gcflags="-m" ./... # покажет escape decisions

Компилятор может inline короткие функции, тогда переменная может остаться на стеке вызывающей функции (escape analysis работает после inlining).

Когда много кратковременных аллокаций — pool помогает:

var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) } }
b := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(b)

Но это уже мидл-тема; джуну достаточно знать, что такая штука есть.

Слайс структур []Foo лежит в памяти подряд → CPU cache friendly. Слайс указателей []*Foo — каждая struct отдельно в heap → больше cache misses.

type Point struct{ X, Y float64 }
a := make([]Point, 1000) // данные подряд, быстро
b := make([]*Point, 1000) // указатели, потом heap-jumping

Для маленьких структур и hot loops — предпочитайте []T, а не []*T.


  1. Что такое указатель в Go? Значение, хранящее адрес другой переменной. Тип *T, операторы & и *.

  2. Чем отличаются new(T) и &T{}? Оба возвращают *T. new(T) — обнулённое значение, без возможности инициализации. &T{} — то же, но можно сразу задать поля. Идиоматичнее &T{...}.

  3. Поддерживает ли Go арифметику указателей? Нет (только через unsafe). Это сделано для безопасности памяти.

  4. Что такое escape analysis? Анализ компилятора, который решает: разместить переменную на stack или в heap. Если ссылка на переменную “убегает” из функции — переменная попадает в heap.

  5. Назови 3 случая, когда переменная убегает в heap. Возврат указателя на локальную, захват замыканием, отправка в канал, сохранение в interface, сохранение в long-lived slice/map.

  6. Как посмотреть результат escape analysis? go build -gcflags="-m" ./...

  7. Что такое stack growth у горутины? Каждая горутина стартует с маленьким стеком (~2-8 КБ), но может расти (runtime копирует стек в больший буфер). Поэтому глубокая рекурсия в Go возможна, но дорогая.

  8. Что произойдёт при разыменовании nil pointer? panic: runtime error: invalid memory address or nil pointer dereference.

  9. Можно ли взять адрес элемента map? Нет, &m[k] запрещено. Map может перераспределить bucket’ы.

  10. Можно ли взять адрес элемента slice? Да, &s[i]. Но после append это может оказаться “повисшим” указателем на старый массив.

  11. В чём разница между value receiver и pointer receiver методом? Value — работает с копией, не меняет оригинал. Pointer — может мутировать, не копирует структуру. (Подробнее в файле о структурах.)

  12. Когда использовать pointer receiver? Большая структура (избежать копий), мутация полей, консистентность (если у типа есть хоть один pointer receiver — лучше все сделать pointer).

  13. Передаётся ли slice по значению или по ссылке? Slice — это header (ptr, len, cap), он передаётся по значению. Но указатель внутри header — общий, поэтому изменения элементов видны.

  14. Зачем нужен unsafe.Pointer? Низкоуровневая работа: конверсия между типами указателей, FFI, оптимизации. В обычном коде не используется.

  15. *int — это указатель или значение? Это тип “указатель на int”. Значение этого типа — адрес.

  16. Что такое nil pointer и nil interface — это одно и то же? Нет! var p *T = nil — действительно nil. Но var i any = (*T)(nil) — interface не nil, т.к. содержит тип. (См. файл об интерфейсах.)

  17. Почему []T быстрее []*T для маленьких T? Cache locality: элементы лежат подряд в памяти. С []*T — указатели подряд, но сами объекты разбросаны по heap.

  18. Что делает GC в Go? Конкурентный mark-and-sweep: помечает достижимые объекты в heap, освобождает остальные. (Подробнее в мидл-темах.)

  19. Может ли pointer receiver метод быть вызван на nil-указателе? Да, метод вызывается, паника будет только при обращении к полям через nil.

  20. Что вернёт unsafe.Sizeof(&x) на 64-битной системе? 8 байт (размер указателя). Не размер того, на что он указывает.


Какой будет вывод?

func main() {
x := 10
p := &x
*p = 20
fmt.Println(x, *p)
}
Ответ `20 20`. p указывает на x, *p = 20 меняет x.
func swap(a, b *int) { *a, *b = *b, *a }
func main() {
x, y := 1, 2
swap(&x, &y)
fmt.Println(x, y)
}
Ответ `2 1`. Многоместное присваивание + указатели.

Какие переменные убегут в heap?

func f1() int { x := 1; return x }
func f2() *int { x := 2; return &x }
func f3() any { x := 3; return x }
func f4() func() int { x := 4; return func() int { return x } }
Ответ - f1: x — stack - f2: x — heap (возвращаем указатель) - f3: x — heap (хранение в interface) - f4: x — heap (захват замыканием)
type Node struct{ Next *Node; Val int }
var n *Node
fmt.Println(n.Val) // ?
Ответ Паника `nil pointer dereference`.
type Node struct{ Next *Node; Val int }
func (n *Node) Len() int {
if n == nil { return 0 }
return 1 + n.Next.Len()
}
var n *Node
fmt.Println(n.Len()) // ?
Ответ `0`. Метод можно вызвать на nil-указателе, если он проверяет это первым делом.
s := []int{1, 2, 3}
p := &s[0]
s = append(s, 4)
*p = 100
fmt.Println(s) // ?
Ответ Зависит от capacity. Если append выделил новый массив — `[1 2 3 4]` и `*p` пишет в "мёртвый" старый. Если cap позволил — `[100 2 3 4]`.
m := map[string]int{"a": 1}
// p := &m["a"]
Ответ Ошибка компиляции: cannot take the address of m["a"].
func main() {
var p *int
if p == nil { fmt.Println("nil") }
var i any = p
if i == nil { fmt.Println("i nil") } else { fmt.Println("not nil") }
}
Ответ ``` nil not nil ``` Потому что `i` содержит тип `*int` (не nil interface, хоть значение и nil-указатель).
func makeUser(name string) *User {
return &User{Name: name}
}
Ответ `moved to heap: User{...}` или похожее. Возврат указателя ⇒ escape.

Сколько байт занимает указатель на 64-битной системе?

Ответ 8 байт. (`unsafe.Sizeof(p)` где p — любой указатель.)

  1. Effective Go — Pointers vs. Values
  2. Go Memory Model (для контекста, потребуется в мидл-темах)
  3. Dave Cheney — Five things that make Go fast
  4. William Kennedy — “Escape Analysis Flaws” (Ardan Labs blog)
  5. Go FAQ — When are function parameters passed by value?