Модуль: Core Go · Уровень: Senior
TL;DR#
Указатель в Go — это типизированный адрес значения в памяти; Go всегда передаёт аргументы по значению, поэтому указатель — единственный способ позволить функции мутировать чужое значение и избежать копии. Арифметики указателей нет (кроме unsafe.Pointer), есть GC, и где физически живёт значение (стек или куча) решает escape-анализ, а не наличие &. Выбор value vs pointer receiver влияет не только на мутацию и стоимость копий, но и на method set, а значит — на то, реализует ли тип интерфейс.
Теория#
Что такое указатель#
Указатель *T хранит адрес значения типа T. Размер указателя равен машинному слову (8 байт на amd64/arm64). Над указателями определены ровно две операции: взятие адреса &x и разыменование *p. Нулевое значение указателя — nil.
x := 42
p := &x // p имеет тип *int
*p = 100 // мутируем x через указатель
fmt.Println(x) // 100В отличие от C, в Go нет указателей на произвольные смещения и нельзя «перепрыгнуть» в соседнюю память: указатель либо валиден и указывает на целостный объект (или элемент), либо nil. Это инвариант, на который опирается точный (precise) сборщик мусора — рантайм обязан знать, какие слова в памяти являются указателями.
Передача всегда по значению#
В Go нет передачи по ссылке. Любой аргумент функции, любое присваивание, любой элемент при копировании структуры — копируются побитово. «Ссылочное» поведение слайсов, мап, каналов, функций и интерфейсов — это иллюзия: копируется их небольшой header/дескриптор, который внутри содержит указатель на разделяемые данные.
type Big struct{ data [1024]int }
func mutate(b Big) { b.data[0] = 1 } // мутирует КОПИЮ, оригинал не меняется
func mutateP(b *Big){ b.data[0] = 1 } // мутирует оригиналПри вызове mutate(b) копируется 8 КБ. При mutateP(&b) копируется 8 байт (адрес). Это и есть две главные причины использовать указатели: мутация и избегание дорогих копий.
Стоимость копирования#
Копия — это memmove размером sizeof(T). Для маленьких типов (int, мелкие структуры до ~машинного слова-двух) копия дешевле, чем разыменование через указатель, и дружелюбнее к кэшу/инлайнингу. Для больших структур копия дорогая.
Грубый ориентир: если структура больше ~3–4 машинных слов (24–32 байт) или содержит массивы — рассматривайте указатель. Но это эвристика; реальное решение принимается профилированием и анализом семантики.
Value vs pointer receiver#
type Counter struct{ n int }
func (c Counter) Get() int { return c.n } // value receiver: работает с копией
func (c *Counter) Inc() { c.n++ } // pointer receiver: мутирует оригиналКогда что выбирать:
| Критерий | Value receiver | Pointer receiver |
|---|---|---|
| Нужно мутировать | нет | да |
| Большая структура | копия дорогая → избегать | да |
Содержит sync.Mutex, sync.WaitGroup и т.п. | нельзя (копировать нельзя) | да |
Маленький immutable тип (time.Time, числовые обёртки) | да | можно, но не обязательно |
| Семантическая «ссылочность» (slice/map внутри) | допустимо | чаще да для консистентности |
Главное правило консистентности: не смешивайте value и pointer receivers у одного типа. Если хотя бы один метод нуждается в pointer receiver — делайте указательными все. Это убирает сюрпризы с method set и сигнализирует, что тип «должен жить за указателем».
Как receiver влияет на method set#
Method set определяет, какие методы доступны у типа и, главное, реализует ли тип интерфейс.
- Method set типа
Tвключает все методы с value receiver. - Method set типа
*Tвключает методы с value receiver И pointer receiver.
type Stringer interface{ String() string }
type Foo struct{}
func (f *Foo) String() string { return "foo" } // pointer receiver
var _ Stringer = &Foo{} // OK: *Foo в method set
var _ Stringer = Foo{} // ОШИБКА КОМПИЛЯЦИИ: Foo не реализует StringerПричина асимметрии: чтобы вызвать pointer-receiver метод, нужен адресуемый объект, у которого можно взять &. Из значения T, лежащего в интерфейсе (или возвращённого функцией), нельзя автоматически взять адрес, поэтому язык не включает pointer-методы в method set T.
Автоматическое взятие адреса / разыменование#
Компилятор автоматически вставляет & и * при вызове методов, если операнд адресуем:
c := Counter{}
c.Inc() // компилятор переписывает в (&c).Inc(), т.к. c адресуема
p := &Counter{}
p.Get() // компилятор переписывает в (*p).Get()Но это работает только для адресуемых выражений. Неадресуемы: возвращаемые значения функций, элементы мапы, константы, литералы, результаты приведения типов.
func newCounter() Counter { return Counter{} }
newCounter().Inc() // ОШИБКА: результат функции не адресуем, нельзя взять &
m := map[string]Counter{"a": {}}
m["a"].Inc() // ОШИБКА: элемент мапы не адресуемЛитералы &T{} и new#
&T{...} создаёт значение и сразу берёт его адрес — идиоматический способ получить *T. new(T) выделяет зануленный T и возвращает *T. &T{} эквивалентно new(T) для пустого литерала, но &T{...} выразительнее, когда нужна инициализация полей.
p1 := &Counter{n: 5}
p2 := new(Counter) // *Counter, поля зануленыВажно: само наличие & не означает «аллокация в куче» — см. escape-анализ.
Escape-анализ: стек или куча#
Go не различает «стековые» и «кучные» объекты на уровне синтаксиса. Компилятор выполняет escape analysis: если он может доказать, что значение не «убегает» за пределы фрейма функции (его адрес не сохраняется куда-то с большим временем жизни), значение размещается на стеке — даже если вы взяли &. Если доказать нельзя — значение «убегает» (escape) в кучу, где им управляет GC.
func stackAlloc() int {
x := 1
p := &x // p используется локально → x остаётся на стеке
return *p
}
func heapAlloc() *int {
x := 1
return &x // адрес x уходит наружу → x escape'ит в кучу
}Посмотреть решения компилятора:
go build -gcflags='-m' ./...Вывод вида moved to heap: x или &x escapes to heap.
Типичные причины escape:
- Возврат указателя на локальную переменную.
- Сохранение указателя в поле объекта, живущего дольше.
- Передача в
interface{}(boxing) — значение часто уходит в кучу, т.к. интерфейс хранит указатель на данные. - Замыкание захватывает переменную по ссылке.
- Слишком большой объект для стека / неизвестный на этапе компиляции размер.
Следствие: возврат *T из конструктора не обязательно «дороже» возврата T по аллокациям — зависит от того, escape’ит ли значение в любом случае. Стек в Go растяжимый (segmented/contiguous growable stacks), поэтому стековые аллокации фактически бесплатны (просто сдвиг указателя стека) и не нагружают GC.
Указатель на элемент массива и слайса#
Элементы массива и слайса адресуемы, на них можно взять указатель:
arr := [3]int{1, 2, 3}
p := &arr[1] // *int на второй элемент
*p = 20 // arr == [1 20 3]
s := []int{1, 2, 3}
q := &s[0]
*q = 100 // s == [100 2 3]Опасность со слайсами: при append, превышающем cap, слайс перевыделяется, и старый указатель q продолжает указывать на старый массив — мутации через него больше не видны в новом слайсе.
s := make([]int, 1, 1)
q := &s[0]
s = append(s, 2) // cap превышен → новый backing array
*q = 999 // меняет СТАРЫЙ массив, s[0] не изменитсяЭлементы мапы, наоборот, не адресуемы (мапа может рехешироваться/переместить бакеты), поэтому &m[k] запрещён.
Отсутствие арифметики указателей и unsafe.Pointer#
В обычном Go нельзя делать p++, p + 1, сравнивать указатели на «больше/меньше» с целью обхода памяти. Разрешено только ==/!=. Это сделано ради безопасности памяти и точного GC.
Исключение — пакет unsafe. unsafe.Pointer — это «универсальный» указатель, между которым и uintptr можно конвертировать, что и даёт фактическую арифметику:
import "unsafe"
s := []int32{10, 20, 30}
p := unsafe.Pointer(&s[0])
// сдвиг на один элемент:
p2 := unsafe.Add(p, unsafe.Sizeof(s[0]))
fmt.Println(*(*int32)(p2)) // 20Правила безопасности unsafe:
uintptr— это просто число, GC его не отслеживает. Объект, на который указывал толькоuintptr, может быть собран или перемещён. ПреобразованиеPointer → uintptr → арифметика → Pointerдолжно быть в одном выражении, без промежуточного сохранения в переменнуюuintptr.- Используйте
unsafe.Addиunsafe.Slice(Go 1.17+) вместо ручной арифметики черезuintptr— они выражают намерение и безопаснее. - Любой
unsafeломает гарантии портируемости и совместимости; применяйте только в горячих участках, FFI/cgo, сериализации.
Подводные камни / gotchas#
Цикл for и взятие адреса переменной цикла (до Go 1.22)#
До Go 1.22 переменная цикла переиспользовалась, и &v во всех итерациях давал один и тот же адрес:
var ptrs []*int
for _, v := range []int{1, 2, 3} {
ptrs = append(ptrs, &v) // ДО Go 1.22: все указывают на одно и то же, итог [3 3 3]
}В Go 1.22+ переменная цикла создаётся заново на каждой итерации — баг исчез. На senior-собеседовании важно знать обе версии семантики и GOEXPERIMENT/версию модуля.
Метод с pointer receiver на неадресуемом значении#
m := map[string]Counter{"a": {}}
m["a"].Inc() // не компилируется: элемент мапы неадресуемРешение: достать копию, изменить, положить обратно; или хранить map[string]*Counter.
Висячий указатель на старый backing array после append#
См. пример выше — типичный источник «исчезающих» мутаций.
Копирование структуры с мьютексом#
type S struct{ mu sync.Mutex; n int }
func (s S) Bad() { s.mu.Lock() } // value receiver копирует мьютекс — go vet ругаетсяsync-примитивы нельзя копировать после первого использования. Всегда pointer receiver. go vet ловит это (copylocks).
Сравнение указателей vs значений#
p1 == p2 сравнивает адреса, а не содержимое. Два разных объекта с одинаковыми полями дадут false. Для сравнения содержимого — reflect.DeepEqual или сравнение значений *p1 == *p2 (если тип сравним).
nil pointer dereference#
Разыменование nil указателя — паника runtime error: invalid memory address or nil pointer dereference (SIGSEGV, перехватываемая рантаймом).
var p *Counter
p.Inc() // паника, если метод обращается к c.nТонкость: вызов метода с pointer receiver на nil сам по себе НЕ паникует — паника происходит при разыменовании поля внутри. Можно даже намеренно писать методы, корректно работающие с nil-получателем (частый паттерн в иммутабельных деревьях):
func (n *Node) Size() int {
if n == nil { return 0 }
return 1 + n.left.Size() + n.right.Size()
}Типизированный nil в интерфейсе#
func get() error {
var p *MyErr = nil
return p // интерфейс error НЕ nil! (тип *MyErr, значение nil)
}
get() == nil // false — классическая ловушкаИнтерфейс равен nil только когда и тип, и значение nil. Возврат типизированного nil-указателя делает интерфейс «не-nil».
&T{} не равно «куча»#
Распространённое заблуждение, что & всегда аллоцирует в куче. Решает escape-анализ. И наоборот — большое значение без & может уйти в кучу.
Вопросы на собеседовании#
В: Go передаёт аргументы по значению или по ссылке? О: Всегда по значению. Передачи по ссылке в языке нет. Слайсы, мапы, каналы ведут себя «ссылочно» потому, что копируется их header/дескриптор, содержащий указатель на общие данные, но сам дескриптор — копия. Чтобы функция мутировала переменную вызывающего, нужно явно передать указатель.
В: В чём разница между value и pointer receiver и как это связано с интерфейсами?
О: Value receiver работает с копией и не мутирует оригинал; pointer — мутирует и не копирует структуру. Method set типа T содержит только value-методы, а *T — и value-, и pointer-методы. Поэтому если интерфейсный метод реализован с pointer receiver, интерфейс удовлетворяет *T, но не T. Причина — для вызова pointer-метода нужен адресуемый объект, а значение T в интерфейсе не адресуемо.
В: Как Go решает, где разместить значение — на стеке или в куче?
О: Через escape-анализ на этапе компиляции. Если компилятор доказывает, что адрес значения не переживёт фрейм функции, значение остаётся на стеке (аллокация почти бесплатна, GC не задействован). Если адрес «убегает» (возврат указателя, сохранение в долгоживущий объект, boxing в интерфейс, захват замыканием) — значение размещается в куче. Проверяется через go build -gcflags=-m. Наличие & само по себе не определяет место размещения.
В: Почему нельзя взять адрес элемента мапы (&m[k])?
О: Мапа может перехешироваться и переместить бакеты при росте, инвалидируя любой удержанный адрес. Чтобы не создавать висячих указателей, язык делает элементы мапы неадресуемыми. Элементы слайсов и массивов адресуемы, т.к. их backing array не двигается без явного переаллока.
В: Что произойдёт с указателем на элемент слайса после append? О: Если append не превысил cap, backing array тот же — указатель валиден. Если cap превышен, слайс получает новый backing array, а старый указатель указывает на прежний массив; мутации через него больше не видны в новом слайсе. Это частый источник трудноуловимых багов.
В: Есть ли в Go арифметика указателей? Как обойти ограничение?
О: В безопасном Go нет — разрешены только ==/!=. Это нужно для memory safety и точного GC. Обход — через unsafe.Pointer и unsafe.Add/unsafe.Slice. Ключевой риск: uintptr не отслеживается GC, поэтому конвертацию Pointer→uintptr→Pointer нужно делать одним выражением, иначе объект может быть собран/перемещён между шагами.
В: Паникует ли вызов pointer-receiver метода на nil-указателе?
О: Сам вызов — нет; получатель просто равен nil. Паника возникает при разыменовании поля внутри метода. Это позволяет писать методы, корректно обрабатывающие nil-получатель (if n == nil { return ... }), что используется, например, в рекурсивных структурах.
В: Почему функция, возвращающая error с типизированным nil-указателем внутри, не равна nil?
О: Интерфейс — это пара (тип, значение). Он равен nil только если оба компонента nil. При возврате var p *MyErr; return p тип в интерфейсе — *MyErr (не nil), значение — nil. Поэтому err != nil. Лекарство — возвращать литеральный nil или проверять перед возвратом.
В: Когда копирование значения предпочтительнее указателя?
О: Для маленьких immutable типов: лучше кэш-локальность, нет разыменований, дружелюбнее инлайнингу, не создаётся мусор для GC, нет шеринга и гонок. Указатель оправдан при необходимости мутации, для больших структур, для типов с некопируемыми полями (sync.Mutex) и когда нужна идентичность объекта.
На что копают на senior+#
- Различие «синтаксис vs размещение». Senior чётко разделяет наличие
&/newи фактическую аллокацию: место решает escape-анализ. Follow-up: «приведи случаи, когда&xНЕ даёт heap-аллокацию» и «когда возврат значения по копии всё равно уходит в кучу» (boxing в интерфейс, слишком большой объект). - Связь method set ↔ интерфейсы ↔ адресуемость. Ожидают объяснения именно через адресуемость, а не «так в спеке». Follow-up: почему
T{}.PointerMethod()работает (переменная адресуема), аfuncReturningT().PointerMethod()— нет. - Стоимость в цифрах и профилирование. Senior не гадает, а ссылается на
-gcflags=-m,go test -bench -benchmem, pprof; понимает, что разыменование тоже не бесплатно (cache miss) и что для мелких типов копия может быть быстрее указателя. - Влияние на GC. Указатели увеличивают работу GC (scan, write barriers); структуры без указателей внутри (pointer-free) сканируются дешевле. Follow-up: как «уплощение» структур и отказ от лишних указателей снижают GC pressure; что такое write barrier.
- unsafe и инварианты рантайма. Понимание, почему
uintptrопасен, правилunsafe.Pointerиз документации пакета, что moving stacks могут двигать стек (но не кучу — пока), и почему точный GC требует знания layout указателей. - Семантика receiver как контракт API. Senior рассуждает о консистентности (все методы одного типа — одинаковый стиль receiver), о том, что выбор pointer receiver делает тип «не value-type» и влияет на копируемость, сравнимость и потокобезопасность.
- Историческая семантика loop var (Go 1.22). Способность назвать версию, где поведение
&vв цикле изменилось, и объяснить мотивацию изменения и совместимость по версии модуля вgo.mod.