Указатели и память в Go
Указатели — фундамент понимания, как в Go работают передача данных, мутации, и почему компилятор решает класть переменную в stack или heap. На собесе джуна постоянно спрашивают: “что такое escape analysis?”, “зачем pointer receiver?”, “какая разница между
new(T)и&T{}?”. Здесь — детальный разбор под капотом.
Содержание
Заголовок раздела «Содержание»- Базовое определение и API
- Внутреннее устройство (ПОД КАПОТОМ)
- Тонкие моменты / Gotchas
- Производительность / Memory considerations
- Типичные вопросы на собеседовании Junior
- Practice — мини-задачки
- Источники
1. Базовое определение и API
Заголовок раздела «1. Базовое определение и API»Что такое указатель
Заголовок раздела «Что такое указатель»Указатель — значение, которое хранит адрес другой переменной в памяти. В Go тип *T обозначает указатель на значение типа T.
var x int = 42var p *int = &x // p — указатель на xfmt.Println(*p) // 42 — разыменование*p = 100 // мутация значения по адресуfmt.Println(x) // 100Операторы:
&— взятие адреса (&xвозвращает*T).*— разыменование (*pвозвращаетT).
Нулевое значение указателя — nil
Заголовок раздела «Нулевое значение указателя — nil»var p *intfmt.Println(p == nil) // true*p = 5 // panic: runtime error: invalid memory address or nil pointer dereferencenew(T) vs &T{}
Заголовок раздела «new(T) vs &T{}»Обе формы возвращают *T, указывающий на обнулённое значение.
p1 := new(int) // *int, *p1 == 0p2 := &struct{ X int }{} // указатель на пустую structp3 := 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 := 10incVal(a)fmt.Println(a) // 10incPtr(&a)fmt.Println(a) // 11Rule of thumb: значение или указатель?
Заголовок раздела «Rule of thumb: значение или указатель?»| Передавать по значению | Передавать по указателю |
|---|---|
| Маленькие структуры (≤ ~64 байт) | Большие структуры |
| Иммутабельные типы (int, string, time) | Когда нужно мутировать |
| Без необходимости менять оригинал | Когда нужна “shared” семантика |
| Slices, maps, chans, funcs (уже header) | Когда nil валиден как состояние |
Простое правило: если сомневаешься — используй указатель для structs, особенно если у типа есть методы с pointer receiver.
2. Внутреннее устройство (ПОД КАПОТОМ)
Заголовок раздела «2. Внутреннее устройство (ПОД КАПОТОМ)»Stack vs Heap
Заголовок раздела «Stack vs Heap»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 ││ ... │└─────────────────────────┘Escape analysis
Заголовок раздела «Escape analysis»Компилятор Go анализирует, переживёт ли переменная свою функцию (т.е. адрес/ссылка на неё выйдет за пределы фрейма). Если да — переменная “убегает в heap”. Если нет — остаётся на стеке.
// Не убегает — компилятор положит на stackfunc sumLocal() int { x := 42 return x}
// Убегает — возвращаем указатель → x должен пережить функциюfunc makePtr() *int { x := 42 return &x // компилятор кладёт x в heap}Команда для просмотра escape
Заголовок раздела «Команда для просмотра escape»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Что заставляет переменную убегать в heap
Заголовок раздела «Что заставляет переменную убегать в heap»- Возврат указателя на локальную переменную:
func f() *Foo { return &Foo{} } // Foo escapes
- Сохранение в интерфейс (interface storage):
Под капотом interface хранит указатель на значение (см. файл 08).var i interface{} = bigStruct // bigStruct escapes если не fit в interface
- Захват замыканием (closure):
func g() func() int {x := 1return func() int { return x } // x escapes}
- Отправка в канал:
ch <- &x // x escapes
- Сохранение в slice/map глобальном или возвращаемом наружу.
- Слишком большой объект (больше размера стекового фрейма) — runtime может разместить в heap.
- Динамический размер через
make([]T, n)гдеnнеизвестен компилятору. - Использование
reflectчасто триггерит escape.
Указатели НЕ поддерживают арифметику
Заголовок раздела «Указатели НЕ поддерживают арифметику»В отличие от C/C++, в Go нельзя p++ или p + 4. Это намеренное решение для безопасности памяти.
p := &arr[0]// p++ // ❌ ошибка компиляцииДля “арифметики” есть unsafe.Pointer + uintptr (опасно, использовать только в исключениях).
unsafe.Pointer (кратко)
Заголовок раздела «unsafe.Pointer (кратко)»import "unsafe"
var x int64 = 0x0102030405060708p := unsafe.Pointer(&x) // нетипизированный указательb := (*byte)(p) // приводим к *bytefmt.Println(*b) // первый байтПравила:
- Можно конвертировать
*T↔unsafe.Pointer↔*U. - Можно конвертировать
unsafe.Pointer↔uintptr(ноuintptr— не указатель, GC не отслеживает!). - Использовать в большинстве проектов не нужно. На собесе достаточно знать, что существует.
Указатели на массив vs slice
Заголовок раздела «Указатели на массив vs slice»arr := [3]int{1, 2, 3}pArr := &arr // *[3]int, указывает на весь массивs := arr[:] // slice — уже содержит указатель на underlying arraypSlice := &s // *[]int — указатель на slice header (редко нужен)⚠️ Подвох: &arr[0] — указатель на элемент. &arr — указатель на массив. Это разные типы.
3. Тонкие моменты / Gotchas
Заголовок раздела «3. Тонкие моменты / Gotchas»⚠️ Gotcha 1: nil pointer dereference
Заголовок раздела «⚠️ Gotcha 1: nil pointer dereference»type User struct{ Name string }var u *Userfmt.Println(u.Name) // panic: nil pointer dereferenceОсобенно болезненно при работе с возвращаемыми из БД/JSON структурами.
⚠️ Gotcha 2: Возврат адреса параметра-копии бесполезен
Заголовок раздела «⚠️ Gotcha 2: Возврат адреса параметра-копии бесполезен»func badPtr(x int) *int { return &x // x — это копия; "работает", но смысл странный}// Это валидно, но x escapes в heap. Чаще признак неверного дизайна.⚠️ Gotcha 3: Pointer к элементу map нельзя взять
Заголовок раздела «⚠️ Gotcha 3: Pointer к элементу map нельзя взять»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] // OKs = append(s, 4, 5, 6, 7) // если выделился новый массив, p указывает на старый*p = 100fmt.Println(s[0]) // может быть 1, а не 100!⚠️ Gotcha 5: Сравнение указателей
Заголовок раздела «⚠️ Gotcha 5: Сравнение указателей»== сравнивает адреса, а не значения.
a := 5b := 5pa := &apb := &bfmt.Println(pa == pb) // false — разные адресаfmt.Println(*pa == *pb) // true — равные значения⚠️ Gotcha 6: Pointer receiver на nil
Заголовок раздела «⚠️ Gotcha 6: Pointer receiver на nil»Метод с 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 *Listfmt.Println(l.Len()) // 0, не паника!⚠️ Gotcha 7: Address of function literal/composite literal
Заголовок раздела «⚠️ Gotcha 7: Address of function literal/composite literal»В Go разрешено брать адрес композитного литерала:
p := &MyStruct{X: 1} // OK, обычная практикаНо не литерала примитива:
// p := &42 // ❌ cannot take the address of 42n := 42p := &n // OK⚠️ Gotcha 8: Range и адрес итерационной переменной (Go < 1.22)
Заголовок раздела «⚠️ Gotcha 8: Range и адрес итерационной переменной (Go < 1.22)»В Go < 1.22 переменная цикла переиспользуется, и взятие её адреса даёт один и тот же адрес:
// Go < 1.22for 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 — почти всегда ошибка дизайна.
4. Производительность / Memory considerations
Заголовок раздела «4. Производительность / Memory considerations»Стек дёшев, куча дорога
Заголовок раздела «Стек дёшев, куча дорога»| Операция | Стек | Куча |
|---|---|---|
| Allocation | Инкремент указателя | Поиск свободного блока (mcache → mcentral → mheap) |
| Free | Авто (frame pop) | GC mark-sweep |
| Affecting GC? | Нет | Да, создаёт давление |
Когда escape в heap не страшен
Заголовок раздела «Когда escape в heap не страшен»- Один раз при инициализации сервиса.
- Long-lived объекты (кэш, конфиг).
- Малое количество аллокаций в горячем пути.
Когда escape больно
Заголовок раздела «Когда escape больно»- В hot path (handler HTTP, обработчик event’а): тысячи аллокаций/сек создают GC pressure.
- В коде, где можно было обойтись стеком (mini struct as value).
Профилирование
Заголовок раздела «Профилирование»go test -bench . -benchmem # покажет alloc/opgo test -gcflags="-m" ./... # покажет escape decisionsInline и escape
Заголовок раздела «Inline и escape»Компилятор может inline короткие функции, тогда переменная может остаться на стеке вызывающей функции (escape analysis работает после inlining).
sync.Pool для переиспользования
Заголовок раздела «sync.Pool для переиспользования»Когда много кратковременных аллокаций — pool помогает:
var bufPool = sync.Pool{ New: func() any { return new(bytes.Buffer) } }b := bufPool.Get().(*bytes.Buffer)defer bufPool.Put(b)Но это уже мидл-тема; джуну достаточно знать, что такая штука есть.
Указатели и cache locality
Заголовок раздела «Указатели и cache locality»Слайс структур []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.
5. Типичные вопросы на собеседовании Junior
Заголовок раздела «5. Типичные вопросы на собеседовании Junior»-
Что такое указатель в Go? Значение, хранящее адрес другой переменной. Тип
*T, операторы&и*. -
Чем отличаются
new(T)и&T{}? Оба возвращают*T.new(T)— обнулённое значение, без возможности инициализации.&T{}— то же, но можно сразу задать поля. Идиоматичнее&T{...}. -
Поддерживает ли Go арифметику указателей? Нет (только через
unsafe). Это сделано для безопасности памяти. -
Что такое escape analysis? Анализ компилятора, который решает: разместить переменную на stack или в heap. Если ссылка на переменную “убегает” из функции — переменная попадает в heap.
-
Назови 3 случая, когда переменная убегает в heap. Возврат указателя на локальную, захват замыканием, отправка в канал, сохранение в interface, сохранение в long-lived slice/map.
-
Как посмотреть результат escape analysis?
go build -gcflags="-m" ./... -
Что такое stack growth у горутины? Каждая горутина стартует с маленьким стеком (~2-8 КБ), но может расти (runtime копирует стек в больший буфер). Поэтому глубокая рекурсия в Go возможна, но дорогая.
-
Что произойдёт при разыменовании nil pointer?
panic: runtime error: invalid memory address or nil pointer dereference. -
Можно ли взять адрес элемента map? Нет,
&m[k]запрещено. Map может перераспределить bucket’ы. -
Можно ли взять адрес элемента slice? Да,
&s[i]. Но послеappendэто может оказаться “повисшим” указателем на старый массив. -
В чём разница между value receiver и pointer receiver методом? Value — работает с копией, не меняет оригинал. Pointer — может мутировать, не копирует структуру. (Подробнее в файле о структурах.)
-
Когда использовать pointer receiver? Большая структура (избежать копий), мутация полей, консистентность (если у типа есть хоть один pointer receiver — лучше все сделать pointer).
-
Передаётся ли slice по значению или по ссылке? Slice — это header (ptr, len, cap), он передаётся по значению. Но указатель внутри header — общий, поэтому изменения элементов видны.
-
Зачем нужен
unsafe.Pointer? Низкоуровневая работа: конверсия между типами указателей, FFI, оптимизации. В обычном коде не используется. -
*int— это указатель или значение? Это тип “указатель на int”. Значение этого типа — адрес. -
Что такое nil pointer и nil interface — это одно и то же? Нет!
var p *T = nil— действительно nil. Ноvar i any = (*T)(nil)— interface не nil, т.к. содержит тип. (См. файл об интерфейсах.) -
Почему
[]Tбыстрее[]*Tдля маленьких T? Cache locality: элементы лежат подряд в памяти. С[]*T— указатели подряд, но сами объекты разбросаны по heap. -
Что делает GC в Go? Конкурентный mark-and-sweep: помечает достижимые объекты в heap, освобождает остальные. (Подробнее в мидл-темах.)
-
Может ли pointer receiver метод быть вызван на nil-указателе? Да, метод вызывается, паника будет только при обращении к полям через nil.
-
Что вернёт
unsafe.Sizeof(&x)на 64-битной системе? 8 байт (размер указателя). Не размер того, на что он указывает.
6. Practice — мини-задачки
Заголовок раздела «6. Practice — мини-задачки»Задача 1
Заголовок раздела «Задача 1»Какой будет вывод?
func main() { x := 10 p := &x *p = 20 fmt.Println(x, *p)}Ответ
`20 20`. p указывает на x, *p = 20 меняет x.Задача 2
Заголовок раздела «Задача 2»func swap(a, b *int) { *a, *b = *b, *a }func main() { x, y := 1, 2 swap(&x, &y) fmt.Println(x, y)}Ответ
`2 1`. Многоместное присваивание + указатели.Задача 3 — escape analysis
Заголовок раздела «Задача 3 — escape analysis»Какие переменные убегут в 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 (захват замыканием)Задача 4
Заголовок раздела «Задача 4»type Node struct{ Next *Node; Val int }var n *Nodefmt.Println(n.Val) // ?Ответ
Паника `nil pointer dereference`.Задача 5
Заголовок раздела «Задача 5»type Node struct{ Next *Node; Val int }func (n *Node) Len() int { if n == nil { return 0 } return 1 + n.Next.Len()}var n *Nodefmt.Println(n.Len()) // ?Ответ
`0`. Метод можно вызвать на nil-указателе, если он проверяет это первым делом.Задача 6
Заголовок раздела «Задача 6»s := []int{1, 2, 3}p := &s[0]s = append(s, 4)*p = 100fmt.Println(s) // ?Ответ
Зависит от capacity. Если append выделил новый массив — `[1 2 3 4]` и `*p` пишет в "мёртвый" старый. Если cap позволил — `[100 2 3 4]`.Задача 7
Заголовок раздела «Задача 7»m := map[string]int{"a": 1}// p := &m["a"]Ответ
Ошибка компиляции: cannot take the address of m["a"].Задача 8
Заголовок раздела «Задача 8»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-указатель).Задача 9 — что покажет -gcflags="-m"?
Заголовок раздела «Задача 9 — что покажет -gcflags="-m"?»func makeUser(name string) *User { return &User{Name: name}}Ответ
`moved to heap: User{...}` или похожее. Возврат указателя ⇒ escape.Задача 10
Заголовок раздела «Задача 10»Сколько байт занимает указатель на 64-битной системе?
Ответ
8 байт. (`unsafe.Sizeof(p)` где p — любой указатель.)7. Источники
Заголовок раздела «7. Источники»- Effective Go — Pointers vs. Values
- Go Memory Model (для контекста, потребуется в мидл-темах)
- Dave Cheney — Five things that make Go fast
- William Kennedy — “Escape Analysis Flaws” (Ardan Labs blog)
- Go FAQ — When are function parameters passed by value?