Блог GrubLoader
/go-range-указатели-и-слайсы

Go: range, указатели и слайсы

Привет, дорогой читатель! Сегодня будет про Go. Не «что такое горутины» и не «почему Go быстрее PHP на бенчмарке из Хабра», а про одну мелкую, но адски распространённую ошибку, которую я вижу у каждого второго, кто «пишет на Go уже три года». А почему вижу? Да потому что я сам в процессе изучения этого прекрасного языка, кучу статей перечитал, запутался, потом прочитал другую кучу статьей, запутался второй раз. И при этом, я еще и документацию читал, и репозитории пользовательские смотрел.

Речь пойдёт про range, указатели и слайсы. Да, вот это самое место, где код выглядит правильно, компилируется, тесты иногда даже зелёные — а потом в проде начинается цирк. Также уточню, что особенности справедливы для Go ≤ 1.21.

Как пишут почти все (и зря)

Есть у нас слайс структур. Нужно собрать указатели на них. Что делает средний Go-разраб?

type User struct {
	ID   int
	Name string
}

users := []User{
	{ID: 1, Name: "Alice"},
	{ID: 2, Name: "Bob"},
	{ID: 3, Name: "Charlie"},
}

var ptrs []*User
for _, u := range users {
	ptrs = append(ptrs, &u)
}

Код читаемый. Go-шный. Компилятор молчит. Линтер молчит. Разработчик доволен собой и идёт пить кофе.

А теперь внимание, фокус-покус.

Что происходит на самом деле

Переменная u в rangeОДНА.
Она переиспользуется на каждой итерации.

То есть &u — это один и тот же адрес памяти, который просто перезаписывается.

В итоге:

fmt.Println(ptrs[0].Name) // Charlie
fmt.Println(ptrs[1].Name) // Charlie
fmt.Println(ptrs[2].Name) // Charlie

Поздравляю. У тебя три указателя на одного и того же Чарли.
Алиса и Боб пошли нахер.

Почему это особенно мерзко

  • Код выглядит логично
  • Ошибка не всегда сразу видна
  • В маленьких тестах может «работать»
  • В проде превращается в баг-призрак

И самое страшное — люди копируют этот код годами, не понимая, почему потом всё едет.

Как писать ПРАВИЛЬНО

Вариант 1. Брать адрес элемента слайса
for i := range users {
	ptrs = append(ptrs, &users[i])
}

Здесь всё честно:

  • users[i] — реальный элемент слайса
  • адрес стабильный
  • без сюрпризов

Это правильный и предпочтительный способ, как мне видится.


Вариант 2. Создавать локальную копию (если уж очень тебе надо)
for _, u := range users {
	u := u
	ptrs = append(ptrs, &u)
}

Работает, но выглядит как ~~говно~~ магический ритуал с бубном.
Подходит для случаев, когда ты осознанно хочешь копию, а не оригинал.

Почему вообще так сделано в Go?

Потому что:

  • range создаёт одну переменную
  • Go оптимизирует аллокации
  • язык не обязан подстраиваться под твоё ожидание, что «ну вроде логично же»

Go вообще часто такой:

«Я сделал ровно то, что ты написал. Если ты долбо%б — это твои проблемы».

И в этом его прелесть. Очень похоже на C++ по поведению.

А что в итоге?

Никогда не бери &u из for range. Бери &slice[i]. Даже, если у тебя самый последний компилятор Go. Почему всё равно стоит писать &slice[i], даже если компилятор ≥ 1.22?

  • код читаем одинаково во всех версиях
  • не зависит от знания тонких изменений языка
  • не ломается при даунгрейде тулчейна
  • не требует исторической экспертизы от читающего

Если ты узнал в этом куске кода себя — ничего страшного. Все мы через это проходили. Себя я тоже узнал=) Главное — перестать писать так дальше.