сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Интерфейсы и основы Go

Указатели в Go

Указатели без арифметики

Человек, пришедший из C, пишет func New() *T { return &T{} } и вздрагивает: он только что вернул адрес локальной переменной. В C это классическая ошибка с повисшим указателем. В Go это самая обычная идиома, и разбираться, почему так, - хороший способ понять модель памяти языка.

Стержень: указатель - ссылка на конкретное значение, арифметики нет, а где значению жить, решает компилятор.

// Формулировки: «безопасно ли вернуть &t из функции?», «что будет при p.X, если p == nil?», «когда передавать указателем?»

Что можно и чего нельзя

Указатель хранит адрес значения: & берёт адрес, * разыменовывает. И на этом всё - арифметики указателей в Go нет, p++ не скомпилируется, привести указатель к числу и обратно штатными средствами нельзя.

Это не занудство, а две конкретные выгоды. Первая: выход за границы памяти через указатель невозможен в принципе, потому что сдвинуть его некуда. Вторая: сборщик мусора точно знает, на что указывает каждый указатель, и может двигать объекты и определять живое без догадок. Обход последовательностей делают слайсом с индексом, и этого хватает.

Разыменование nil даёт панику с текстом runtime error: invalid memory address or nil pointer dereference. Именно панику - со стеком, с трассировкой и с возможностью перехватить через recover, а не порчу чужой памяти и не загадочное поведение через двадцать минут.

// Тонкость, которую любят спрашивать: вызвать метод на nil-указателе можно, если тело не обращается к полям. Проверено: метод с получателем *T, начинающийся с проверки if t == nil, спокойно вызывается на nil и возвращает свой ответ. На этом строят nil-безопасные методы вроде func (t *Tree) Size() int - для пустого дерева ноль.

разыменование
*p - обращение к значению по адресу
nil pointer dereference
паника при обращении к полю nil-указателя; перехватывается через recover

Escape analysis: кто решает, где жить значению

В C переменная функции живёт на стеке, стек сворачивается при выходе, и адрес становится мусором. В Go выбор между стеком и кучей делает не программист, а компилятор, и делает он его на этапе сборки.

Анализ простой по идее: если компилятор доказал, что адрес значения не переживает вызов, значение остаётся на стеке - это дёшево, стек сворачивается сам. Если адрес утекает наружу, значение размещается в куче, и дальше за ним следит сборщик мусора. Повисшего указателя не возникает никогда.

Посмотреть решения можно сборкой с флагом -gcflags=-m. На конструкторе func New() *T { return &T{X: 1} } вывод скажет: &T{...} escapes to heap. А рядом обнаружится вторая частая причина ухода в кучу, про которую забывают: укладка значения в интерфейс. Строчка p.X escapes to heap появляется просто потому, что поле передали в fmt.Println, а он принимает any.

// Поэтому конструктор вида func New() *T { return &T{} } - нормальная идиома Go, а не подозрительный код. И поэтому же fmt.Println в горячем цикле стоит дороже, чем кажется: каждый аргумент уезжает в кучу.

escape analysis
анализ компилятора, решающий, разместить значение на стеке или в куче
-gcflags=-m
флаг сборки, печатающий решения этого анализа

Когда указатель, а когда значение

Указатель берут по двум причинам: нужно менять оригинал или структура крупная и копия заметна. Всё остальное по умолчанию передают значением - оно проще, безопаснее в конкурентном коде и часто остаётся на стеке.

Брать указатель везде подряд - распространённая ошибка новичка. Каждый указатель на структуру, уехавшую в кучу, это работа сборщику мусора, а сборщик в Go хоть и быстрый, но не бесплатный.

Отдельная категория - типы, которые нельзя копировать после начала использования: sync.Mutex, sync.WaitGroup, sync.Once и всё, что содержит их внутри. Копия получает собственное состояние блокировки, и синхронизация тихо перестаёт работать: два потока спокойно заходят в критическую секцию, каждый через свою копию мьютекса.

// Ловит это go vet, и сообщение вполне внятное: Bad passes lock by value: Store contains sync.Mutex. Поэтому методы у типов с мьютексом объявляют на указателе, а WaitGroup передают указателем либо замыкают в горутине. Проверено на живом примере - vet находит это сразу.

go vet
штатный статический анализатор; ловит копирование блокировок и ещё десяток типовых ошибок

Как отвечать: «Безопасно ли возвращать указатель на локальную переменную?»

Да, в Go это обычная и безопасная идиома, в отличие от C. Компилятор делает escape analysis: видит, что адрес локальной переменной уходит наружу и переживает вызов, и размещает значение не на стеке, а в куче. Дальше за ним следит сборщик мусора, поэтому повисшего указателя не возникает. Проверить решение компилятора можно сборкой с -gcflags=-m: в выводе будет строчка про escapes to heap. Поэтому конструкторы вида func New() *T { return &T{} } - нормальный идиоматичный код, и бояться тут нечего. Заодно упомяну две смежные вещи. Арифметики указателей в Go нет, p++ не скомпилируется, и это отрезает выход за границы памяти и упрощает работу сборщику. А разыменование nil даёт предсказуемую панику с трассировкой, которую можно перехватить, а не порчу памяти. Причём вызвать метод на nil-указателе можно, если тело не трогает поля - на этом строят nil-безопасные методы.

Назван механизм, дан способ проверить его инструментом, идиома конструктора подана как норма, и добавлены смежные факты про отсутствие арифметики и поведение nil - видно понимание модели памяти, а не заученная фраза.

На чём валят

  • Боятся возвращать &t из функции по привычке из C - в Go это безопасно.
  • Ищут арифметику указателей: p++ в Go не компилируется.
  • Копируют структуру с sync.Mutex внутри и тихо теряют синхронизацию.
  • Считают, что вызов любого метода на nil-указателе обязательно паникует.
  • Берут указатель везде подряд и грузят сборщик мусора без нужды.
  • Забывают, что аргумент fmt.Println уезжает в кучу из-за укладки в интерфейс.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 9, остальные разбираются в тренажёре.

  1. #go_pointers1 / 5
    var p *T = nil, затем обращение к полю p.X. Что произойдёт?
    A)Паника рантайма: nil pointer dereference
    B)Вернётся нулевое значение поля X
    C)Компилятор поймает разыменование nil заранее и не соберёт
    D)p автоматически укажет на новую нулевую структуру
    показать ответ и разбор
    +A)Паника рантайма: nil pointer dereference

    // разбор: Разыменование nil-указателя (чтение p.X при p==nil) — паника рантайма «invalid memory address or nil pointer dereference». Компилятор в общем случае её не ловит: nil мог прийти откуда угодно в рантайме. Защита — проверка на nil перед разыменованием или гарантия инициализации (new/&). Метод с указателем-получателем на nil вызвать можно, если внутри не трогать поля.

  2. #go_pointers2 / 5
    Можно ли в Go сдвинуть указатель на следующий элемент через p++?
    A)Да, для указателей на элементы массива это разрешено
    B)Нет: арифметики указателей в языке нет, только через пакет unsafe
    C)Да, но только внутри функций, помеченных как небезопасные
    D)Нет: вместо этого используется встроенная функция next(p)
    показать ответ и разбор
    +B)Нет: арифметики указателей в языке нет, только через пакет unsafe

    // разбор: Указатель в Go — ссылка на конкретное значение, и складывать его с числами нельзя: это отрезает целый класс ошибок с выходом за границы и позволяет сборщику мусора точно знать, что живо. Обход последовательности делают слайсом и индексом. Настоящая арифметика доступна лишь через unsafe.Pointer и uintptr, и это осознанный выход за гарантии языка.

  3. #go_pointers3 / 5
    Функция возвращает указатель на локальную переменную. Это безопасно?
    A)Нет: после возврата переменная уничтожена, указатель повиснет
    B)Нет: компилятор отклонит такой код с ошибкой о ссылке на локальную переменную
    C)Да, но только если переменная объявлена через new, а не литералом
    D)Да: компилятор увидит, что адрес уходит наружу, и разместит значение в куче
    показать ответ и разбор
    +D)Да: компилятор увидит, что адрес уходит наружу, и разместит значение в куче

    // разбор: В Go это обычная и безопасная идиома, в отличие от C. Escape analysis на этапе компиляции определяет, что адрес переживает вызов, и переменная размещается в куче, а не на стеке; дальше за ней следит сборщик мусора. Посмотреть решение компилятора можно сборкой с -gcflags=-m: в выводе появится «moved to heap».

  4. #go_pointers4 / 5
    Когда стоит передавать структуру указателем, а не значением?
    A)Указатель нужен для структур крупнее машинного слова — копия обойдётся дороже
    B)Значение подходит лишь для структур из одного поля, дальше только указатель
    C)Когда нужно менять оригинал или структура крупная и копирование заметно
    D)Когда структура содержит только экспортируемые поля и уходит в другой пакет
    показать ответ и разбор
    +C)Когда нужно менять оригинал или структура крупная и копирование заметно

    // разбор: Два повода: изменяемость (метод должен править оригинал) и цена копии у крупных структур. У мелких значение часто выгоднее — оно остаётся на стеке, не нагружает сборщик мусора и исключает гонки по общей памяти. Отдельное правило: если хоть один метод типа объявлен на указателе, остальные делают такими же — смешанный набор получателей путает и ломает интерфейсы.

  5. #go_pointers5 / 5
    Чему равен указатель var p *int, если его не инициализировали?
    A)Неопределённому мусорному адресу
    B)Адресу нулевой ячейки памяти
    C)nil
    D)Указателю на переменную со значением 0
    показать ответ и разбор
    +C)nil

    // разбор: Нулевое значение любого указателя в Go — nil, и переменные всегда инициализируются нулевым значением своего типа, мусора в памяти не остаётся. Сравнивать с nil можно напрямую. Разыменование такого указателя роняет панику nil pointer dereference, поэтому проверка перед обращением — обычная практика.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.