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 лежит последнее присвоенное значение.
Это пример, где указатели честно делают то, что вы попросили, но вы попросили случайно не то.
Шпаргалка: что означает каждый вариант
Чтобы не запоминать “магические формулы”, полезно сопоставить цель и код. Здесь таблица — не ради бюрократии, а чтобы в голове было меньше каши.
| Цель | Как писать | Что вы получаете |
|---|---|---|
| Взять указатели на реальные элементы слайса (чтобы менять исходный слайс) | |
Указатели на элементы underlying array |
| Взять указатели на копии (например, сделать “снимок” значений) | |
Указатели на отдельные переменные‑копии |
| Случайно собрать “все одинаковые указатели” | |
Один адрес много раз, значения “последние” |
Обратите внимание на второй вариант: иногда указатель на копию — это нормально, если вы действительно хотите независимые значения. Просто важно понимать, что это именно копии, и изменения по ним не должны “магически” попадать в исходный слайс.
Микро‑диагностика: проверяем адреса через %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. Указатели стоит применять тогда, когда действительно нужен доступ к исходным данным по адресу, а не как “универсальный ускоритель” (он им не является).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ