JavaRush /Курсы /Go SELF /Грабли указателей в Go: адрес range‑переменной

Грабли указателей в Go: адрес range‑переменной

Go SELF
19 уровень , 4 лекция
Открыта

1. Главные грабли в for range

Когда мы впервые узнаём про & и *, руки сразу чешутся: «О! Значит, я могу пройтись по слайсу и собрать указатели на элементы, чтобы потом менять их прямо “на месте”». И это нормальное желание: указатели часто используют ровно для того, чтобы явно сказать коду: «я хочу менять исходные данные, а не работать с копией».

Проблема в том, что цикл for range выглядит простым, почти как человеческий язык. Мозг расслабляется. А Go в этот момент такой: «Отлично, сейчас я проверю, насколько внимательно ты различаешь “элемент слайса” и “переменную цикла”». И если вы промахнулись — можно получить очень странные эффекты: изменения «не применяются», либо вдруг все указатели выглядят подозрительно одинаково.

Мини‑задача: собрать указатели на найденные строки

Чтобы не обсуждать указатели в вакууме, давайте продолжим линию учебного мини‑приложения: у нас есть список задач в виде []string. Без структур, без классов, без тяжёлой архитектуры — просто список строк. Мы хотим найти задачи, которые содержат подстроку (например, go), и вернуть указатели на эти строки, чтобы позже можно было их изменить прямо в исходном списке.

Звучит как очень практичная штука: нашли подходящие задачи — и сделали, например, пометку в тексте. Да, мы пока не делаем полноценный “todo manager”, но именно такие маленькие утилитарные куски кода потом становятся кирпичиками больших программ.

Ловушка: &v в for _, v := range tasks

Сейчас будет момент, где важна аккуратность слов. В for _, v := range tasks переменная v — это переменная цикла, в которую на каждой итерации кладётся значение элемента. Это значение может быть копией. А мы, беря &v, берём адрес именно этой переменной, а не адрес элемента массива внутри слайса.

Отдельный нюанс, который важно знать именно в Go 1.25: начиная с Go 1.22 у цикла for поправили давнюю «граблю», связанную с тем, что переменные цикла могли неожиданно разделяться между итерациями; теперь поведение цикла стало безопаснее в типовых случаях. Это отмечено в анонсе Go 1.22.
Но даже с этим улучшением смысловая проблема остаётся: &v — это указатель на переменную цикла, а не на элемент исходного слайса. То есть вы получите не «ссылку на задачу в списке», а «ссылку на временное значение, которое было в переменной цикла».

Давайте посмотрим на это глазами отладки.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	for _, v := range tasks {
		fmt.Printf("v=%q addr=%p\n", v, &v)
	}
}

Здесь адреса могут выглядеть “нормально” (они могут быть разными), и именно поэтому эта ошибка так коварна: вы видите адреса и думаете “ну всё, я точно взял адрес элементов”. А на самом деле вы взяли адреса переменных‑копий, которые живут отдельно от tasks.

Чтобы стало совсем очевидно, сделаем опыт: соберём указатели, изменим значение через указатель и посмотрим, изменился ли исходный слайс.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	ptrs := make([]*string, 0, len(tasks))
	for _, v := range tasks {
		ptrs = append(ptrs, &v) // адрес переменной цикла, не элемента tasks
	}

	*ptrs[1] = "HACKED"
	fmt.Println("через указатель:", *ptrs[1])     // через указатель: HACKED
	fmt.Println("в исходном слайсе:", tasks[1])   // в исходном слайсе: learn go
}

Это и есть ключевой сигнал: если вы хотели менять tasks, а меняется только то, что лежит по указателю — значит указатель не на элемент tasks.

Вторая грабля: «одинаковые указатели» из‑за переиспользуемой переменной

Теперь про эффект, который чаще всего выглядит как детектив: вы выводите список указателей, а они почему-то одинаковые, и все значения как будто «последние». В Go 1.25 “классический” вариант &v в for range уже не так часто приводит к одинаковым адресам, но этот баг никуда не делся как явление: его легко собрать своими руками, если вы по какой-то причине вынесли переменную наружу и переиспользуете её.

Самый частый сюжет такой: разработчик где-то слышал «не бери адрес у range‑переменной», и решил “исправить” код… сделав ещё хуже.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	var v string // одна переменная на все итерации
	ptrs := make([]*string, 0, len(tasks))

	for _, item := range tasks {
		v = item
		ptrs = append(ptrs, &v) // адрес один и тот же
	}

	fmt.Printf("%p %p %p\n", ptrs[0], ptrs[1], ptrs[2]) // адреса одинаковые (обычно)
	fmt.Println(*ptrs[0], *ptrs[1], *ptrs[2])           // sleep sleep sleep
}

Почему так происходит? Потому что v — это одна конкретная переменная, у неё один адрес. В цикле вы лишь перезаписываете значение этой переменной. В итоге вы складываете в ptrs много раз один и тот же адрес, а к концу цикла в v лежит последнее присвоенное значение.

Это пример, где указатели честно делают то, что вы попросили, но вы попросили случайно не то.

Шпаргалка: что означает каждый вариант

Чтобы не запоминать “магические формулы”, полезно сопоставить цель и код. Здесь таблица — не ради бюрократии, а чтобы в голове было меньше каши.

Цель Как писать Что вы получаете
Взять указатели на реальные элементы слайса (чтобы менять исходный слайс)
for i := range s { ptrs = append(ptrs, &s[i]) }
Указатели на элементы underlying array
Взять указатели на копии (например, сделать “снимок” значений)
for _, v := range s { x := v; ptrs = append(ptrs, &x) }
Указатели на отдельные переменные‑копии
Случайно собрать “все одинаковые указатели”
var x T; for ... { x = ...; ptrs = append(ptrs, &x) }
Один адрес много раз, значения “последние”

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

Микро‑диагностика: проверяем адреса через %p

Когда ловишь подобный баг, хочется «сразу понять», что именно происходит. И здесь есть простой, почти школьный приём: печатать адреса через %p и смотреть, совпадают ли они с тем, что вы ожидаете.

Представьте, что вы сомневаетесь, указываете ли вы на элементы слайса или на копии. Тогда можно сделать такой “рентген”:

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"a", "b", "c"}

	for i := range tasks {
		fmt.Printf("&tasks[%d]=%p value=%q\n", i, &tasks[i], tasks[i])
	}
}

Если в вашем реальном коде адреса “почему-то одинаковые”, вы почти наверняка берёте адрес одной и той же переменной, которую переиспользуете. Если адреса разные, но изменения “не доходят” до слайса — вы, вероятно, берёте адрес копий (например, &v), и тогда надо менять подход.

3. Как правильно: адрес элемента по индексу &tasks[i]

Теперь давайте сделаем то же самое, но правильно. Мы пройдёмся по индексам и возьмём адрес реального элемента в массиве, лежащем под слайсом. Этот элемент адресуемый (addressable), поэтому &tasks[i] допустим и даёт то, что мы хотим.

package main

import (
	"fmt"
)

func main() {
	tasks := []string{"buy milk", "learn go", "sleep"}

	ptrs := make([]*string, 0, len(tasks))
	for i := range tasks {
		ptrs = append(ptrs, &tasks[i]) // адрес реального элемента
	}

	*ptrs[1] = "LEARN GO (done)"
	fmt.Println(tasks[1]) // LEARN GO (done)
}

Вот это поведение уже похоже на «управляемые изменения исходных данных».

Чтобы закрепить идею, полезно держать в голове такую картинку (упрощённую):

flowchart LR
    tasks["tasks: []string (slice header)"] --> arr["Underlying array (строки)"]
    arr --> e0["tasks[0] = 'buy milk'"]
    arr --> e1["tasks[1] = 'learn go'"]
    arr --> e2["tasks[2] = 'sleep'"]

    p1["p1 = &tasks[1] (*string)"] --> e1
    v["v (переменная цикла)"] -. "копия значения" .-> e1
    pv["pv = &v"] --> v

&tasks[i] указывает на элемент в underlying array, а &v указывает на отдельную переменную v, которая просто получала значения по очереди.

4. Практика: FindTaskPtrs для мини‑приложения

Теперь соберём небольшой, но законченный пример, который можно компилировать и запускать. Сделаем функцию FindTaskPtrs, которая ищет задачи по подстроке и возвращает []*string, то есть указатели на элементы исходного слайса.

Здесь мы используем strings.Contains, потому что это читаемо: задача содержит текст.

package main

import (
	"fmt"
	"strings"
)

func FindTaskPtrs(tasks []string, query string) []*string {
	found := make([]*string, 0)
	for i := range tasks {
		if strings.Contains(tasks[i], query) {
			found = append(found, &tasks[i])
		}
	}
	return found
}

func main() {
	tasks := []string{"buy milk", "learn go", "sleep", "go to gym"}

	ptrs := FindTaskPtrs(tasks, "go")
	fmt.Println(len(ptrs)) // 2

	*ptrs[0] = strings.ToUpper(*ptrs[0])
	*ptrs[1] = strings.ToUpper(*ptrs[1])

	fmt.Println(tasks) // [buy milk LEARN GO sleep GO TO GYM]
}

Здесь очень важная деталь: мы итерируемся по индексам for i := range tasks, потому что наша цель — адреса элементов. Если бы мы написали for _, v := range tasks и возвращали &v, то изменения через указатели не меняли бы tasks, и это было бы неприятным сюрпризом.

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

Ошибка №1: брать &v в for _, v := range tasks и ожидать, что изменения затронут tasks.
Эта ошибка выглядит особенно честно: вы действительно получили *string, действительно разыменовали и поменяли значение. Только поменяли вы переменную цикла (то есть отдельное значение), а не элемент underlying array. Лечится это простым переключением мышления: если вам нужен адрес элемента — итерируйтесь по индексам и берите &tasks[i].

Ошибка №2: “исправлять” проблему &v, вынося переменную наружу, и случайно получить «все указатели одинаковые».
Это тот самый случай, когда код становится логически хуже, хотя визуально кажется “аккуратнее”: var v string; for ... { v = item; ptrs = append(ptrs, &v) }. Здесь вы многократно сохраняете один и тот же адрес. Если вы видите, что все значения в конце одинаковые (обычно “последние”), это почти всегда оно. Исправление — не переиспользовать одну переменную для всех итераций, а работать либо с &tasks[i], либо с отдельной новой переменной внутри итерации, если нужны копии.

Ошибка №3: путать “хочу ссылку на элемент” и “хочу снимок значения”.
Иногда задача действительно требует копий: например, вы хотите сохранить текущие значения, чтобы потом их сравнить, даже если исходный слайс изменится. Тогда “указатели на копии” — допустимая модель, просто она должна быть выбранной осознанно. В таком случае лучше прямо назвать переменную как-нибудь вроде snapshot := tasks[i], чтобы читатель кода видел: это отдельная сущность, а не элемент массива.

Ошибка №4: отлаживать результат, глядя только на значения, и не смотреть на адреса.
По значениям часто ничего не видно: строки могут совпадать, данные маленькие, всё “как будто нормально”. Но адреса через %p мгновенно показывают, указываете ли вы на один и тот же объект или на разные. Это особенно полезно, когда вы подозреваете “одинаковые указатели”, но не уверены, что именно одинаковое: значение или адрес.

Ошибка №5: использовать указатели “на всякий случай”, а потом удивляться aliasing‑эффектам.
Указатель — это почти всегда про разделение данных: теперь одно изменение может быть видно в нескольких местах. Это мощно, но требует дисциплины. Если вам нужно просто вычислить новый список строк, часто проще вернуть новый []string, чем раздавать наружу []*string. Указатели стоит применять тогда, когда действительно нужен доступ к исходным данным по адресу, а не как “универсальный ускоритель” (он им не является).

1
Задача
Go SELF, 19 уровень, 4 лекция
Недоступна
Призрачная правка
Призрачная правка
1
Задача
Go SELF, 19 уровень, 4 лекция
Недоступна
Три одинаковых
Три одинаковых
1
Задача
Go SELF, 19 уровень, 4 лекция
Недоступна
Живые ссылки
Живые ссылки
1
Задача
Go SELF, 19 уровень, 4 лекция
Недоступна
Снимок значений
Снимок значений
1
Опрос
Указатели, 19 уровень, 4 лекция
Недоступен
Указатели
Указатели, new vs make
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ