Привет, дорогой читатель! Сегодня будет про 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?
- код читаем одинаково во всех версиях
- не зависит от знания тонких изменений языка
- не ломается при даунгрейде тулчейна
- не требует исторической экспертизы от читающего
Если ты узнал в этом куске кода себя — ничего страшного. Все мы через это проходили. Себя я тоже узнал=) Главное — перестать писать так дальше.
