Указатели в 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, остальные разбираются в тренажёре.
- var p *T = nil, затем обращение к полю p.X. Что произойдёт?A)Паника рантайма: nil pointer dereferenceB)Вернётся нулевое значение поля XC)Компилятор поймает разыменование nil заранее и не соберётD)p автоматически укажет на новую нулевую структуру
показать ответ и разбор
+A)Паника рантайма: nil pointer dereference// разбор: Разыменование nil-указателя (чтение p.X при p==nil) — паника рантайма «invalid memory address or nil pointer dereference». Компилятор в общем случае её не ловит: nil мог прийти откуда угодно в рантайме. Защита — проверка на nil перед разыменованием или гарантия инициализации (new/&). Метод с указателем-получателем на nil вызвать можно, если внутри не трогать поля.
- Можно ли в Go сдвинуть указатель на следующий элемент через p++?A)Да, для указателей на элементы массива это разрешеноB)Нет: арифметики указателей в языке нет, только через пакет unsafeC)Да, но только внутри функций, помеченных как небезопасныеD)Нет: вместо этого используется встроенная функция next(p)
показать ответ и разбор
+B)Нет: арифметики указателей в языке нет, только через пакет unsafe// разбор: Указатель в Go — ссылка на конкретное значение, и складывать его с числами нельзя: это отрезает целый класс ошибок с выходом за границы и позволяет сборщику мусора точно знать, что живо. Обход последовательности делают слайсом и индексом. Настоящая арифметика доступна лишь через unsafe.Pointer и uintptr, и это осознанный выход за гарантии языка.
- Функция возвращает указатель на локальную переменную. Это безопасно?A)Нет: после возврата переменная уничтожена, указатель повиснетB)Нет: компилятор отклонит такой код с ошибкой о ссылке на локальную переменнуюC)Да, но только если переменная объявлена через new, а не литераломD)Да: компилятор увидит, что адрес уходит наружу, и разместит значение в куче
показать ответ и разбор
+D)Да: компилятор увидит, что адрес уходит наружу, и разместит значение в куче// разбор: В Go это обычная и безопасная идиома, в отличие от C. Escape analysis на этапе компиляции определяет, что адрес переживает вызов, и переменная размещается в куче, а не на стеке; дальше за ней следит сборщик мусора. Посмотреть решение компилятора можно сборкой с -gcflags=-m: в выводе появится «moved to heap».
- Когда стоит передавать структуру указателем, а не значением?A)Указатель нужен для структур крупнее машинного слова — копия обойдётся дорожеB)Значение подходит лишь для структур из одного поля, дальше только указательC)Когда нужно менять оригинал или структура крупная и копирование заметноD)Когда структура содержит только экспортируемые поля и уходит в другой пакет
показать ответ и разбор
+C)Когда нужно менять оригинал или структура крупная и копирование заметно// разбор: Два повода: изменяемость (метод должен править оригинал) и цена копии у крупных структур. У мелких значение часто выгоднее — оно остаётся на стеке, не нагружает сборщик мусора и исключает гонки по общей памяти. Отдельное правило: если хоть один метод типа объявлен на указателе, остальные делают такими же — смешанный набор получателей путает и ломает интерфейсы.
- Чему равен указатель var p *int, если его не инициализировали?A)Неопределённому мусорному адресуB)Адресу нулевой ячейки памятиC)nilD)Указателю на переменную со значением 0
показать ответ и разбор
+C)nil// разбор: Нулевое значение любого указателя в Go — nil, и переменные всегда инициализируются нулевым значением своего типа, мусора в памяти не остаётся. Сравнивать с nil можно напрямую. Разыменование такого указателя роняет панику nil pointer dereference, поэтому проверка перед обращением — обычная практика.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.