Модуль: 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 receiverPointer 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.