Go: полный курс — от основ до современного языка · Глава 5 из 12

Глава 5. Указатели и память

Прогресс сохранится в этом браузере (войдите, чтобы синхронизировать).

Введение

Любая переменная в программе живёт по какому-то адресу в оперативной памяти. Обычно мы об этом не думаем: пишем x = 5 и работаем со значением. Но рано или поздно возникает вопрос: как сделать так, чтобы функция изменила переданную ей переменную? Или как передать в функцию большую структуру, не копируя её целиком? Ответ на оба вопроса — указатели.

Указатель — это переменная, которая хранит не значение, а адрес другого значения. Хорошая аналогия: значение — это дом, а указатель — бумажка с его адресом. Держа адрес, ты можешь прийти к дому и что-то в нём поменять, при этом сама бумажка маленькая и её легко передать другому. В этой главе разберём, как в Go взять адрес (&), как объявить указатель (*T), как через него добраться до значения (разыменование *p) и почему это делает программы одновременно и мощнее, и опаснее.

Тема указателей у многих вызывает тревогу — виной тому память о C, где неаккуратная работа с указателями роняет программы и открывает уязвимости. Спешу успокоить: в Go указатели намеренно сделаны безопасными. Здесь нет арифметики указателей (нельзя прибавить к адресу число и уехать в произвольную ячейку), нет ручного освобождения памяти (за это отвечает сборщик мусора), а компилятор сам решает, где разместить данные, чтобы указатель не «протух». Всё, что тебе останется помнить, — одно правило безопасности: не разыменовывай nil. Поэтому воспринимай указатели не как источник опасности, а как аккуратный инструмент «дать функции право менять мой объект».

В Go всё передаётся по значению

Ключевой факт, который надо усвоить раз и навсегда: в Go всё передаётся по значению. Когда ты передаёшь переменную в функцию, функция получает копию. Изменения копии не затрагивают оригинал. Это касается и обычных чисел, и структур. Указатели не отменяют это правило — они его обходят: мы копируем не сам объект, а его адрес, и по этому адресу добираемся до оригинала.

package main

import "fmt"

// zero пытается обнулить аргумент, но получает КОПИЮ.
func zero(z int) {
    z = 0
}

func main() {
    x := 5
    zero(x)
    fmt.Println(x) // 5 — оригинал не изменился
}

Почему язык устроен именно так? Передача по значению делает функции предсказуемыми: глядя на вызов zero(x), ты по умолчанию знаешь, что x не изменится, если только функция явно не получила его адрес. В языках, где всё передаётся по ссылке, любой вызов потенциально может испортить твои данные, и приходится держать это в голове. В Go «мутация на расстоянии» всегда видна в коде — по оператору & при вызове или по типу *T в сигнатуре. Указатели — это осознанное разрешение «да, тебе можно менять мой объект».

Адрес памяти и оператор &

Чтобы узнать адрес переменной, перед ней ставят оператор & («амперсанд», «взятие адреса»). Результат — шестнадцатеричное число вроде 0xc000012028. Само по себе оно нам редко интересно, но оно — пропуск к тому, чтобы менять переменную «на расстоянии».

package main

import "fmt"

func main() {
    a := 43
    fmt.Println(a)     // 43 — значение
    fmt.Println(&a)    // 0xc0000140a0 — адрес (у тебя будет другой)
    fmt.Printf("%p\n", &a) // тот же адрес через глагол %p
}

Ты уже встречал & раньше, даже не осознавая: функция fmt.Scan принимает адрес переменной, чтобы записать в неё введённое значение. Без & она получила бы копию и не смогла бы ничего вернуть наружу.

var meters float64
fmt.Print("Сколько метров: ")
fmt.Scan(&meters) // передаём АДРЕС, чтобы Scan записал значение в meters
fmt.Println("проплыл", meters, "метров")

Тип указателя *T и разыменование

Если у переменной тип int, то адрес этой переменной имеет тип *int — «указатель на int». Звёздочка здесь — часть типа. Чтобы из указателя достать само значение, тоже используют звёздочку, но уже как оператор: *p означает «значение, лежащее по адресу p». Эта операция называется разыменованием (dereferencing).

package main

import "fmt"

func main() {
    a := 43
    var b = &a // b имеет тип *int, хранит адрес a

    fmt.Println(b)  // 0xc0000140a0 — адрес
    fmt.Println(*b) // 43 — разыменование: значение по адресу
}

Одна и та же звёздочка играет две роли, и это путает новичков. В объявлении типа (var b *int) звёздочка говорит «это указатель». В выражении (*b) звёздочка означает «пойди по адресу и возьми значение». Различай их по контексту: рядом с типом — часть типа, перед переменной-указателем — операция.

Изменение значения через указатель

Разыменование можно поставить и слева от присваивания. Тогда мы говорим: «значение по этому адресу — измени его». Именно так функция получает возможность поменять переменную вызывающего кода.

package main

import "fmt"

func main() {
    a := 43
    b := &a

    *b = 42        // "по адресу b запиши 42"
    fmt.Println(a) // 42 — изменили a через указатель!
}

Передача по значению против передачи через указатель

Вернёмся к функции zero, которая раньше не сработала. Если параметр объявить как *int и передать адрес, функция сможет изменить оригинал: она получит копию адреса, но адрес-то указывает на ту же самую ячейку памяти.

package main

import "fmt"

// Теперь zero принимает указатель и меняет значение по адресу.
func zero(z *int) {
    *z = 0
}

func main() {
    x := 5
    zero(&x)       // передаём адрес x
    fmt.Println(x) // 0 — теперь оригинал изменился
}

Сравни две версии в одном примере, чтобы почувствовать разницу: одна и та же логика, но *int даёт эффект, а int — нет.

func byValue(v int)   { v = 100 }   // меняет копию
func byPointer(v *int) { *v = 100 } // меняет оригинал

func main() {
    n := 1
    byValue(n)
    fmt.Println(n) // 1

    byPointer(&n)
    fmt.Println(n) // 100
}

Зачем указатели нужны

Две главные причины. Первая — изменяемость: если функция должна поменять переданные данные (заполнить структуру, обнулить поле), ей нужен указатель. Вторая — производительность: большие структуры дорого копировать при каждом вызове. Передав указатель (несколько байт адреса), мы избегаем копирования всего объекта. На этом же основаны методы с указательным получателем, которые ты встретишь в главе про структуры.

type Account struct {
    Owner   string
    Balance int
}

// deposit меняет баланс оригинала — нужен указатель.
func deposit(a *Account, amount int) {
    a.Balance += amount // Go сам разыменует: (*a).Balance
}

func main() {
    acc := Account{Owner: "Аня", Balance: 100}
    deposit(&acc, 50)
    fmt.Println(acc.Balance) // 150
}

Небольшое удобство: при доступе к полю структуры через указатель писать (*a).Balance не обязательно — Go автоматически разыменует, и можно писать просто a.Balance. Это синтаксический сахар, экономящий скобки.

Стек, куча и escape-анализ

Возникает резонный вопрос: если указатель на локальную переменную можно вернуть из функции, не «протухнет» ли он, ведь локальные переменные вроде бы живут на стеке и исчезают при выходе? В C это была бы классическая ошибка — висячий указатель. Но Go делает escape-анализ: компилятор сам определяет, «убегает» ли переменная за пределы функции (например, мы вернули её адрес). Если да — переменная размещается в куче и живёт, пока на неё есть ссылки; сборщик мусора освободит её потом. Если нет — остаётся на стеке, что дёшево.

// newCounter возвращает адрес локальной переменной — и это БЕЗОПАСНО.
// Компилятор увидит, что n "убегает", и разместит её в куче.
func newCounter() *int {
    n := 0
    return &n
}

func main() {
    p := newCounter()
    *p++
    fmt.Println(*p) // 1 — указатель полностью валиден
}

Практический вывод: в Go можно смело возвращать указатели на локальные переменные — язык гарантирует их корректность. Тебе не нужно вручную думать про стек и кучу; но полезно понимать, что «лишние» указатели могут увеличивать нагрузку на сборщик мусора, поэтому для мелких значений (int, небольшая структура) передача по значению часто и проще, и быстрее.

Создание указателей через new

Встроенная функция new(T) выделяет память под значение типа T, инициализирует его нулевым значением и возвращает указатель *T. На практике для структур чаще пишут &T{...}, но new полезен для базовых типов, когда нужен именно указатель.

p := new(int) // p имеет тип *int, *p == 0
*p = 7
fmt.Println(*p) // 7

Когда указатель НЕ нужен

Указатели — мощный инструмент, но не бесплатный: они усложняют рассуждение о коде (кто и когда меняет объект?) и нагружают сборщик мусора. Не надо ставить * везде «на всякий случай». Хорошие ориентиры: используй указатель, если функция должна изменить аргумент, если структура большая и копировать её дорого, или если тебе нужно значение nil как маркер «нет данных». В остальных случаях — особенно для мелких значений вроде int, bool, небольших структур из пары полей — предпочитай передачу по значению: код проще и часто быстрее.

Отдельно отметим: слайсы, карты и каналы уже содержат внутри себя указатель на общие данные, поэтому передавать *[]int или *map[K]V почти никогда не нужно — функция и так сможет изменить элементы. Об этой «указательной природе» слайсов подробно поговорим в следующей главе.

Сравнение указателей

Указатели можно сравнивать оператором ==. Два указателя равны, если они показывают на одну и ту же ячейку памяти (или оба nil). Это важно отличать от равенства значений: две разные переменные могут хранить одинаковое число, но их адреса будут разными. Сравнение указателей отвечает на вопрос «это буквально один и тот же объект?», а не «значения совпадают?».

a := 5
b := 5
p1 := &a
p2 := &a
p3 := &b

fmt.Println(p1 == p2) // true — оба показывают на a
fmt.Println(p1 == p3) // false — a и b это разные ячейки
fmt.Println(*p1 == *p3) // true — но значения одинаковы

На этой идее строится, например, проверка «одинаковый ли это узел» при обходе графов и связных списков: сравнивать сами узлы по значению может быть дорого или некорректно, а сравнение указателей — мгновенное и однозначное.

Указатель на поле структуры

Брать адрес можно не только у целой переменной, но и у поля структуры или элемента слайса. Это позволяет передать «ссылку» на конкретную часть большого объекта и менять именно её. Приём встречается, когда нужно точечно модифицировать вложенные данные без копирования всего контейнера.

type Point struct{ X, Y int }

func moveRight(x *int) {
    *x += 10
}

func main() {
    p := Point{X: 1, Y: 2}
    moveRight(&p.X) // адрес конкретного поля
    fmt.Println(p)  // {11 2}
}

nil-указатель

Нулевое значение любого указателя — nil: «указывает в никуда». Разыменование nil-указателя роняет программу паникой nil pointer dereference. Поэтому перед разыменованием указателя, который мог не быть проинициализирован, его проверяют на nil. Здесь снова выручает короткое замыкание оператора &&: проверку p != nil ставят слева, и если она ложна, правая часть не выполняется.

package main

import "fmt"

func main() {
    var p *int      // nil
    fmt.Println(p)  // <nil>

    // Безопасно: если p == nil, *p не вычисляется.
    if p != nil && *p > 0 {
        fmt.Println("положительное")
    } else {
        fmt.Println("указатель пуст или не положителен")
    }
}

Кейс из реального проекта

Рассмотрим кэш конфигурации в сервисе. Мы хотим лениво загрузить настройки один раз и потом переиспользовать. Указатель на структуру позволяет отличить «ещё не загружено» (nil) от «загружено», а функция обновления меняет объект по адресу, не копируя его. Плюс метод-геттер безопасно работает с возможным nil.

package main

import "fmt"

type Config struct {
    Host    string
    Port    int
    Debug   bool
}

type ConfigCache struct {
    current *Config // nil, пока не загрузили
}

// Load загружает конфиг в кэш, изменяя объект по адресу.
func (c *ConfigCache) Load(host string, port int) {
    c.current = &Config{Host: host, Port: port, Debug: false}
}

// Addr безопасно строит адрес, учитывая, что конфиг мог не загрузиться.
func (c *ConfigCache) Addr() string {
    if c.current == nil {
        return "config not loaded"
    }
    return fmt.Sprintf("%s:%d", c.current.Host, c.current.Port)
}

// EnableDebug включает отладку в уже загруженном конфиге через указатель.
func (c *ConfigCache) EnableDebug() {
    if c.current == nil {
        return
    }
    c.current.Debug = true // меняем оригинал, а не копию
}

func main() {
    var cache ConfigCache
    fmt.Println(cache.Addr()) // config not loaded

    cache.Load("api.internal", 8080)
    fmt.Println(cache.Addr()) // api.internal:8080

    cache.EnableDebug()
    fmt.Println(cache.current.Debug) // true
}

Здесь указатель играет сразу три роли: маркер «загружено / не загружено» (nil), способ дёшево делить один объект между методами и механизм изменения этого объекта на месте. Всё это — типовой паттерн в бэкенде.

Типичные ошибки

Разыменование nil-указателя

Как НЕ надо: обращаться к *p или p.Field, не убедившись, что p != nil. Функция могла вернуть nil-указатель при ошибке, а ты сразу читаешь поле — и получаешь панику в рантайме. Всегда проверяй указатели, которые пришли извне и могли быть не заданы.

// Плохо: если find вернул nil, следующая строка паникует.
u := find(id)
fmt.Println(u.Name)

// Хорошо: сначала проверка.
u := find(id)
if u == nil {
    fmt.Println("не найден")
    return
}
fmt.Println(u.Name)

Ожидание изменений при передаче по значению

Как НЕ надо: передавать структуру в функцию по значению и надеяться, что её изменения увидит вызывающий код. Функция работает с копией — оригинал останется прежним. Если нужно изменить объект, передавай *T, а не T.

Указатель на переменную цикла

Как НЕ надо (до Go 1.22): сохранять &item из цикла range в слайс. Переменная цикла переиспользовалась, поэтому все сохранённые указатели показывали на одну ячейку с последним значением. В Go 1.22+ переменная своя на каждой итерации, но если пишешь код для более старых версий — копируй элемент в локальную переменную перед взятием адреса.

// Надёжно везде: берём адрес локальной копии.
var ptrs []*Item
for _, it := range items {
    it := it // локальная копия
    ptrs = append(ptrs, &it)
}

Практика

  1. Напиши функцию swap(a, b *int), которая меняет местами значения двух переменных через указатели. Проверь на паре чисел.
  2. Реализуй increment(counter *int), увеличивающую значение по адресу на единицу. Вызови её несколько раз в цикле.
  3. Сделай структуру Rectangle{Width, Height int} и функцию scale(r *Rectangle, factor int), умножающую обе стороны на множитель. Убедись, что оригинал меняется.
  4. Напиши функцию safeValue(p *int) int, которая возвращает *p, если p != nil, и ноль в противном случае.
  5. Экспериментально: выведи адрес одной и той же переменной внутри функции и в main, передав её сначала по значению, потом по указателю. Сравни адреса и объясни разницу.

Итог

Указатели снимают ограничение «всё по значению»: храня адрес, мы можем менять оригинал и дёшево передавать большие данные. Мы разобрали оператор взятия адреса &, тип *T, разыменование *p для чтения и записи, разницу между передачей по значению и через указатель, роль указателей для изменяемости и производительности, функцию new и опасность nil. Главное правило безопасности — проверять указатель на nil перед разыменованием, и здесь помогает короткое замыкание &&. Указатели лежат в основе методов, интерфейсов и почти всех структур данных, которые мы встретим дальше. В следующей главе — массивы, слайсы и карты, где указательная природа слайсов сыграет важную роль.

Содержание серии (12)
{# Звук celebration (этап 4.3):