Указатели в Go: когда они действительно нужны
Указатели — одна из первых тем, где новички в Go принимают решения наугад. Разбираем конкретные ситуации, в которых передача по указателю оправдана, и те, где она только мешает — с замерами, примерами и таблицей выбора.

Зачем вообще думать об указателях
Go — язык со сборщиком мусора, и указатели здесь не про ручное управление памятью, как в C. Но выбор между передачей значения и передачей указателя влияет на три вещи: производительность (копирование vs. разыменование), мутабельность (может ли функция изменить оригинал) и семантику API (что читатель кода ожидает от сигнатуры). Ошибка в любом из трёх пунктов — это либо лишние аллокации, либо неожиданные мутации, либо код, который сложно ревьюить.
Четыре ситуации, где указатель оправдан
1. Функция должна изменить переданное значение
Самый очевидный случай. Go передаёт аргументы по значению — функция получает копию. Если нужно изменить оригинал, передавайте указатель.
// Go 1.21+
type Config struct {
Timeout int
Retries int
Verbose bool
}
// Принимаем указатель — изменения видны вызывающей стороне
func ApplyDefaults(cfg *Config) {
if cfg.Timeout == 0 {
cfg.Timeout = 30
}
if cfg.Retries == 0 {
cfg.Retries = 3
}
}
Если Config передать по значению, вызывающий код не увидит изменений — классическая ошибка первых недель с Go.
2. Структура большая, копирование дорого
Копирование структуры в 16 байт — бесплатно. Копирование структуры в 4 КБ на каждый вызов в горячем цикле — уже заметно. Порог зависит от нагрузки, но эмпирическое правило из практики: структуры до 64–128 байт дешевле копировать, чем гонять через указатель и нагружать сборщик мусора. Для структур крупнее — указатель обычно выигрывает.
3. Нужно выразить отсутствие значения (nil)
У значимого типа нет состояния «не задано». int по умолчанию — 0, string — пустая строка. Если ваш API должен отличать «пользователь передал 0» от «пользователь ничего не передал», указатель даёт nil как третье состояние.
type Filter struct {
MinPrice *int // nil означает "фильтр не задан"
MaxPrice *int
}
Альтернатива — обёртки вроде sql.NullInt64 или тип Optional, но в стандартной библиотеке Go их нет как дженерика, поэтому указатель остаётся самым компактным способом.
4. Интерфейс или стандартная библиотека требуют указатель
Ряд интерфейсов из стандартной библиотеки реализуется через методы с указательным ресивером — json.Unmarshaler, encoding.TextUnmarshaler, sort.Interface (при мутации слайса). Если ваш тип реализует такой интерфейс, метод должен принимать *T, а значит, и передавать экземпляр по указателю.
Когда указатель не нужен и мешает
Маленькие значимые типы
Координата, деньги, временной интервал, RGB-цвет — структуры в 8–32 байта. Передавать их по указателю значит: аллокация в куче (если компилятор решит, что указатель «убегает»), дополнительная нагрузка на GC, потеря кеш-линии при разыменовании. Копия — дешевле и безопаснее.
Слайсы, карты, каналы
Эти типы уже содержат внутренний указатель на данные. Передача []byte по значению копирует заголовок слайса (24 байта на amd64: указатель, длина, ёмкость), но не сами данные. Оборачивать слайс ещё одним указателем — двойная косвенность без выигрыша. Исключение: если функция должна изменить сам заголовок — например, дополнить слайс через append и вернуть новый заголовок вызывающему. Но и тут идиоматичнее вернуть новый слайс через return.
Чистые функции и конкурентность
Если функция не должна менять аргумент — не давайте ей возможности. Передача по значению — встроенная защита от случайной мутации. В конкурентном коде это ещё и защита от data race без мьютекса: каждая горутина работает с собственной копией.
Указатель в сигнатуре — это обещание: «я могу изменить то, что вы мне дали». Если вы не собираетесь менять — не обещайте.
Escape analysis: куда уходит указатель
Компилятор Go решает, где разместить переменную — на стеке или в куче. Если указатель «убегает» из функции (возвращается, сохраняется в структуру, передаётся в горутину), переменная уезжает в кучу и попадает под сборку мусора. Проверить это можно флагом компилятора:
go build -gcflags='-m' ./...
Вывод покажет строки вида moved to heap: cfg. Если вы видите аллокацию там, где не ожидали, — это повод пересмотреть, нужен ли указатель.
Важно: escape analysis меняется между версиями компилятора. То, что оставалось на стеке в Go 1.20, может убежать в кучу в Go 1.22 и наоборот. Полагаться на конкретное поведение стоит только после замеров на целевой версии.
Таблица выбора
| Ситуация | Передача | Почему |
|---|---|---|
| Структура ≤ 128 байт, не мутируется | По значению | Дешёвое копирование, нет аллокации в куче, безопасно для горутин |
| Структура > 128 байт, не мутируется | По указателю | Экономим на копировании, но учитываем нагрузку на GC |
| Функция изменяет поля структуры | По указателю | Иначе изменения не видны вызывающему |
| Нужно выразить nil / отсутствие | Указатель на поле или структуру | Единственный способ без обёрток |
| Слайс, карта, канал | По значению | Внутри уже указатель; двойная косвенность не нужна |
| Мелкие value-объекты (координаты, деньги) | По значению | Иммутабельность, кеш-дружественность, простота |
| Реализация интерфейса с *T ресивером | По указателю | Требование компилятора |
Пороговое значение 128 байт — ориентир, а не догма. Для горячих путей проверяйте бенчмарком на вашей нагрузке.
Типичные ошибки и как их обнаружить
- Указатель «из привычки». Разработчик, пришедший из Java или C#, передаёт всё по указателю, потому что «объект — это ссылка». В Go маленькие структуры — значения, и указатель здесь добавляет аллокации, а не убирает.
- Возврат указателя на локальную переменную цикла (до Go 1.22). До версии 1.22 переменная цикла
forпереиспользовалась между итерациями. Взятие её адреса приводило к тому, что все указатели ссылались на одно и то же значение — последнее. С Go 1.22 переменная цикла создаётся заново на каждой итерации, но если вы поддерживаете старые версии — ловушка на месте. - nil-паника в цепочке вызовов. Указатель может быть
nil. Если функция принимает*Configи не проверяет наnil, а вызывающий забыл инициализировать — паника в рантайме. Значение по умолчанию безопаснее: пустая структура хотя бы не рухнет. - Data race через разделяемый указатель. Две горутины получили один
*Config— одна пишет, другая читает. Без мьютекса это гонка. Передача по значению обошлась бы без синхронизации.
Обнаружить: go vet ловит часть проблем, go build -gcflags='-m' показывает побеги в кучу, go run -race находит гонки. Все три команды стоит запускать в CI.
Что делать дальше
Начните с ревью текущего кода: запустите go build -gcflags='-m' ./... и посмотрите, какие переменные убегают в кучу. Если среди них мелкие структуры, которые не мутируются, — замените указатель на значение и прогоните бенчмарк. Для конкурентных участков добавьте -race в тесты, если ещё не добавили. Семантика указателей в Go простая, но последствия выбора проявляются под нагрузкой — и лучше узнать о них в тестовом стенде, чем в продакшене в три часа ночи.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Effective Go — Pointers vs. Values (официальная документация Go), Go Wiki: Compiler Optimizations — Escape Analysis
Частые вопросы
Правда ли, что в Go указатели безопаснее, чем в C?
Как понять, что структура «маленькая» и передавать её по значению?
Слайс — это уже указатель? Зачем тогда бывает *[]int?
Что будет, если я передам nil-указатель в функцию?
Изменилось ли поведение переменной цикла с указателями в новых версиях Go?
Как escape analysis влияет на производительность?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.