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

Один из первых вопросов на любом Go-собеседовании: почему функция иногда «не видит» изменения, а иногда видит?

Передача по значению — это когда функция получает не саму переменную, а её копию. Изменения внутри функции не затрагивают оригинал. В Go именно так передаётся всё, включая указатели — просто указатель хранит адрес, и его копия указывает туда же.

Что выведет этот код?

Смотрим на структуру «Кот» и функцию, которая пытается отпраздновать его день рождения:

package main

import "fmt"

type Cat struct {
	Name string
	Age  int
}

func birthday(c Cat) {
	c.Age++
}

func main() {
	tom := Cat{Name: "Tom", Age: 3}
	birthday(tom)
	fmt.Println(tom.Age)
}

Вывод:

3

Коту не исполнилось 4 года. Функция birthday честно увеличила Age — но не у настоящего кота, а у его точной копии, которая перестала существовать, как только функция закончилась.

Почему так происходит

В Go, когда вы передаёте структуру в функцию как func birthday(c Cat), компилятор берёт всё содержимое переменной — все поля структуры — и копирует их в новую переменную c внутри функции. Это отдельный кусок памяти. Что бы функция ни делала с c, снаружи её просто не существует.

Представьте, что вы даёте другу не сам паспорт, а его ксерокопию. Он может хоть всю её исчеркать — в вашем настоящем паспорте ничего не изменится.

Как починить: передаём указатель

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

package main

import "fmt"

type Cat struct {
	Name string
	Age  int
}

func birthday(c *Cat) {
	c.Age++
}

func main() {
	tom := Cat{Name: "Tom", Age: 3}
	birthday(&tom)
	fmt.Println(tom.Age)
}

Вывод:

4

Теперь работает. &tom — это «адрес переменной tom в памяти», что-то вроде номера ячейки. Параметр c *Cat получает этот адрес и внутри функции обращается по нему к настоящему коту, а не к копии.

Как это работает под капотом

Внутри функции строка c.Age++ для указателя — это сокращённая запись. Go автоматически «разыменовывает» указатель, то есть идёт по адресу и работает уже с данными, которые там лежат:

func birthday(c *Cat) {
	c.Age++
}

// Go сам разворачивает это в (*c).Age++
// вручную писать (*c).Age тоже можно, но никто так не делает

Полезно держать в голове простую модель: у каждой переменной есть адрес в памяти (как номер дома) и значение (что находится по этому адресу). Указатель — это просто переменная, которая хранит чужой адрес вместо значения. *p означает «сходить по адресу и взять то, что там лежит».

А почему слайсы вдруг «видят» изменения?

Тут начинается ловушка, в которую попадают даже опытные разработчики. Слайсы, карты (map) и каналы в Go — это не сами данные, а маленькие структуры-обёртки, внутри которых лежит указатель на настоящие данные. Когда вы копируете слайс, копируется обёртка, а указатель внутри неё продолжает смотреть на тот же массив в памяти:

package main

import "fmt"

func addOne(nums []int) {
	nums[0] = 100
}

func main() {
	arr := []int{1, 2, 3}
	addOne(arr)
	fmt.Println(arr)
}

Вывод:

[100 2 3]

Хотя nums — это копия заголовка слайса, указатель внутри неё ведёт на тот же массив, что и arr. Изменение nums[0] и изменение arr[0] — это буквально одна и та же ячейка памяти. Подробнее про устройство слайса — в следующем уроке.

Value receiver и pointer receiver у методов

Та же логика распространяется на методы структур. Метод можно объявить с получателем (receiver) по значению или по указателю — это меняет, работает ли он с копией или с оригиналом:

type Cat struct {
	Name string
	Age  int
}

// value receiver — работает с КОПИЕЙ Cat
func (c Cat) Older() Cat {
	c.Age++
	return c
}

// pointer receiver — работает с ОРИГИНАЛОМ через адрес
func (c *Cat) GrowUp() {
	c.Age++
}

Вызов tom.Older() вернёт нового кота на год старше, но самого tom не тронет. А вызов tom.GrowUp() изменит tom напрямую — Go сам подставит &tom, когда видит, что метод объявлен с указателем-получателем.

Частые ошибки на собеседовании

  • Путают «Go передаёт по ссылке» и «Go передаёт по значению, а указатель — это тоже значение (адрес)». Второе верно. Указатель не превращает Go в язык передачи по ссылке — он просто передаёт по значению адрес.
  • Ждут, что структура внутри функции изменится сама по себе, забыв, что параметр — это независимая копия.
  • Думают, что раз слайс «сам меняется», то и append внутри функции всегда виден снаружи — это не так, если слайс при этом пересоздаётся (см. следующий урок).
  • Смешивают receiver: в одном типе часть методов с value receiver, часть — с pointer receiver. Компилятор это разрешит, но на собеседовании спросят «почему так плохо» — правило: если хоть одному методу типа нужен pointer receiver, делайте pointer receiver у всех методов этого типа для единообразия.

Итоги-шпаргалка

  • Go всегда передаёт аргументы по значению — то есть копирует то, что передаёте.
  • Указатель (*T) — это тоже значение, просто его содержимое — адрес другой переменной. Передав указатель, функция получает возможность менять оригинал по этому адресу.
  • &x берёт адрес переменной, *p разыменовывает указатель (идёт по адресу и достаёт значение).
  • Слайсы, карты и каналы «выглядят» изменяемыми при передаче, потому что внутри них уже есть указатель на данные — копируется только обёртка.
  • Pointer receiver у метода — для изменения оригинала или для больших структур (не копировать зря); value receiver — когда метод только читает данные и структура маленькая.
Проверьте себя
1. Функция принимает структуру Cat как func birthday(c Cat) и делает c.Age++. Что произойдёт с оригинальной переменной снаружи функции?
AAge увеличится, потому что структуры в Go передаются по ссылке
BAge не изменится — функция работала с копией структуры
CПрограмма не скомпилируется
DAge увеличится только для структур с экспортируемыми полями
2. Почему изменение nums[0] внутри функции func f(nums []int) видно снаружи, хотя Go передаёт по значению?
AСлайсы — исключение из правила, Go передаёт их по ссылке
BЗаголовок слайса копируется, но внутри него лежит указатель на тот же самый массив в памяти
CЭто баг, который встречается только в старых версиях Go
DПотому что nums объявлен как []int, а не как массив фиксированной длины