GraphLMS

Основы
Начать
Глава 05 · Основы~11 мин чтения

Указатели по-человечески

О чём глава

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

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

Сначала — почему копия мешает

Возьмём функцию, которая «должна» удвоить число. Наивная попытка:

package main
 
import "fmt"
 
func doubleCopy(n int) {
	n = n * 2 // меняем локальную копию
}
 
func main() {
	x := 21
	doubleCopy(x)
	fmt.Println("после doubleCopy:", x) // 21 — ничего не изменилось
}

Функция получила копию x в параметре n. Удвоила копию. Вернулась — копия исчезла, а x как был 21, так и остался. Это не баг Go, это его честное правило: аргументы передаются по значению, то есть копированием. Чтобы функция дотянулась до самой переменной вызывающего, ей нужен её адрес.

Что такое указатель

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

Два оператора управляют этим танцем:

  • &xвзять адрес переменной x. Результат имеет тип *T.
  • *pразыменовать указатель p: добраться до значения, на которое он показывает. Это работает и на чтение, и на запись.

Запомнить легко: & упаковывает переменную в адрес, * распаковывает адрес обратно в переменную. Они обратны друг другу.

x := 10
p := &x      // p имеет тип *int, хранит адрес x
fmt.Println(*p) // 10 — читаем значение по адресу
*p = 20      // пишем по адресу
fmt.Println(x)  // 20 — изменили саму x, не копию

Мутация вызывающего: чиним удвоение

Теперь та же функция, но принимающая адрес. Внутри мы разыменовываем указатель и пишем по нему — а значит, дотягиваемся до настоящей переменной main.

package main
 
import "fmt"
 
func double(n *int) {
	*n = *n * 2 // читаем по адресу, удваиваем, пишем обратно по адресу
}
 
func main() {
	x := 21
	fmt.Println("до:   ", x)
	double(&x) // передаём адрес x, а не саму x
	fmt.Println("после:", x) // 42 — изменилась исходная переменная
}

Вывод:

до:    21
после: 42

Обратите внимание: в double мы пишем *n = ..., а не n = .... Присвоить самому n новое значение было бы бессмысленно — мы бы поменяли локальную копию адреса. Нам нужно то, что по этому адресу, отсюда звёздочка.

Общая память без копирования

Указатель — это ещё и способ дать двум кускам кода смотреть на одни и те же данные. Пока существует адрес, можно читать и менять значение из разных мест, и все увидят одну и ту же картину — копий-то нет.

package main
 
import "fmt"
 
func main() {
	score := 100
	a := &score
	b := &score // оба указателя смотрят на одну переменную
 
	*a += 50 // меняем через a
	*b += 10 // меняем через b
 
	fmt.Println("score =", score) // 160 — обе правки попали в одну ячейку
	fmt.Println("через a:", *a, "через b:", *b)
}

a и b — разные переменные-указатели, но внутри у них один и тот же адрес. Поэтому правка через любой из них видна через другой и через саму score. Это ровно то, что делает функция, принимающая *int: она получает доступ к ячейке вызывающего.

nil — указатель в никуда

Нулевое значение для любого указателя — nil. Это «адрес, которого нет»: такой указатель ни на что не показывает. Объявленный, но не инициализированный указатель равен nil:

var p *int       // p == nil
fmt.Println(p == nil) // true

Попытка разыменовать nil*p, когда p == nil — это паника во время выполнения (nil pointer dereference), и программа падает. Поэтому, если есть сомнение, пришёл ли указатель «настоящим», проверяют его перед разыменованием:

package main
 
import "fmt"
 
func describe(p *int) {
	if p == nil {
		fmt.Println("указателя нет (nil)")
		return
	}
	fmt.Println("значение по адресу:", *p)
}
 
func main() {
	x := 7
	describe(&x)  // значение по адресу: 7
	describe(nil) // указателя нет (nil)
}

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

Указатели на структуры

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

Приятная мелочь: чтобы добраться до поля через указатель, не нужно писать (*p).Field. Go разрешает короткую форму p.Field — он сам разыменует указатель за вас. Обе записи означают одно и то же, но короткая читается человечнее.

package main
 
import "fmt"
 
type Account struct {
	Owner   string
	Balance int
}
 
func deposit(a *Account, amount int) {
	a.Balance += amount // то же, что (*a).Balance += amount
}
 
func main() {
	acc := Account{Owner: "Ира", Balance: 100}
	deposit(&acc, 250)
	deposit(&acc, 50)
	fmt.Printf("%s: %d\n", acc.Owner, acc.Balance) // Ира: 400
}

Если бы deposit принимал Account по значению, каждый вызов работал бы с копией, и баланс в main остался бы 100. Указатель даёт функции доступ к настоящему счёту.

Кстати, у адреса структуры есть удобный синтаксис создания. &Account{...} сразу делает структуру и берёт её адрес — получаете *Account без промежуточной переменной:

package main
 
import "fmt"
 
type Point struct{ X, Y int }
 
func main() {
	p := &Point{X: 3, Y: 4} // p имеет тип *Point
	p.X = 10                 // короткая форма разыменования
	fmt.Println(p.X, p.Y)    // 10 4
}

Когда передавать по указателю

Не нужно превращать всё подряд в указатели «на всякий случай». Простое правило:

  • Передавайте по указателю, если функция должна изменить аргумент так, чтобы изменение увидел вызывающий.
  • Передавайте по указателю, если значение большое (крупная структура) и копировать его на каждый вызов накладно.
  • Передавайте по значению во всех остальных случаях — для маленьких значений (int, bool, небольшие структуры) это проще, безопаснее и часто не медленнее.

Отдельно держите в голове: срезы (slices), отображения (maps) и каналы уже внутри себя содержат ссылку на общие данные. Передавать map по указателю обычно не нужно — функция и так меняет тот же самый map. Но это тема следующих глав; здесь важно лишь не удивляться, что не каждый «изменяемый» тип требует *.

Частые ошибки

  • Меняют сам указатель вместо значения по нему. Внутри функции n = 42 (где n имеет тип *int) перезаписывает локальную копию адреса и ничего не даёт вызывающему. Чтобы поменять данные, нужно *n = 42.
  • Разыменовывают nil. *p при p == nil — это паника. Если указатель может быть «пустым», проверяйте if p != nil перед разыменованием.
  • Делают указатель там, где он не нужен. *int для функции, которая просто читает число и ничего не меняет, только усложняет код и плодит лишние &/*. Нужна мутация или большая структура — да; иначе по значению чище.
  • Ждут стабильного адреса в выводе. Печатать сам адрес через %p ради обучения смысла мало: конкретное число непредсказуемо и меняется от запуска к запуску. Показывайте и проверяйте эффект мутации, а не голый адрес.

Что дальше

Указатели — это фундамент, на котором стоит многое в Go: методы с получателем-указателем, изменяемые структуры, передача больших данных без копий. Дальше вы увидите, как они работают вместе со структурами и методами, и почему срезы и карты «изменяются сами» без явных * — про это в главе про коллекции. А если захочется понять, что вообще происходит с памятью под капотом, загляните в главу про модель памяти — это уже более глубокий материал из продвинутого курса по конкурентности.