1. Откуда берутся “магические” баги со слайсами
Когда вы только начинаете программировать, хочется верить в простую сказку: «Если я сделал новый слайс, значит это новый список». И в некоторых языках так и бывает. Но Go — язык, который экономит вам копирования (и делает это довольно успешно), поэтому за кулисами часто оказывается, что вы создали не «новый список», а «новое окно на старые данные».
Слайс хранит pointer+len+cap (это официальная модель, которой удобно мыслить). Из-за этого два слайса могут иметь разные len, но один backing array. А append — вообще отдельный персонаж: он может создать новый массив, а может дописать в старый. И это «может» зависит от cap.
Именно тут рождается классика жанра: «я добавил элемент в под‑слайс, а у меня испортились данные в исходном слайсе». Давайте разберём, как это выглядит на практике — на примере маленького учебного приложения.
Мини‑приложение ToDo: режем список задач
Чтобы баги с aliasing были не абстрактными, а жизненными, представим, что мы пишем крошечный консольный планировщик. Пока без структур и файлов: просто список задач []string. Это соответствует нашему текущему уровню курса: строки и слайсы уже есть, а структуры мы оставим в покое.
Начнём с утилиты, которая печатает слайс вместе с len/cap, чтобы мы видели «скрытую ёмкость»:
package main
import "fmt"
func dump(name string, s []string) {
fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}
Комментарий: dump — это наш «рентген». Он не покажет память напрямую, но покажет главное: есть ли у слайса запас cap, в который append теоретически может писать.
2. Баг №1: append в под‑слайс переписывает исходный слайс
Сейчас воспроизведём самый известный баг дня: берём «задачи на сегодня» как под‑слайс, делаем append, а потом видим, что «задачи на завтра» внезапно изменились.
package main
import "fmt"
func dump(name string, s []string) {
fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}
func main() {
tasks := []string{"почитать", "погулять", "помыть посуду", "поспать"}
today := tasks[:2] // "окно" на первые две задачи
dump("tasks", tasks)
dump("today", today)
today = append(today, "срочно: купить хлеб")
dump("tasks", tasks)
dump("today", today)
}
Один из типичных выводов может выглядеть так (у вас конкретные числа cap могут отличаться, но эффект важнее):
tasks: len=4 cap=4 [почитать погулять помыть посуду поспать]
today: len=2 cap=4 [почитать погулять]
tasks: len=4 cap=4 [почитать погулять срочно: купить хлеб поспать]
today: len=3 cap=4 [почитать погулять срочно: купить хлеб]
Мы добавили элемент в today, а изменился tasks[2]. Почему? Потому что today имел cap=4, то есть у него был доступный «хвост» в backing array, и append решил: «зачем выделять новый массив, если можно записать прямо сюда».
Можно представить это примерно так:
Backing array (tasks):
[почитать] [погулять] [помыть посуду] [поспать]
^ ^
| |
today окно: [0..2) len=2, но cap позволяет дотянуться до конца массива
После append(today, "...") третий элемент backing array перезаписался, и исходный tasks это увидел.
4. Баг №2: “иногда работает” из‑за cap
Самая раздражающая часть этого бага в том, что он не всегда проявляется. И это делает его идеальным кандидатом в «пятничные» баги: в четверг работало, в пятницу упало, а в понедельник снова работает, потому что вы перезапустили программу и ёмкости распределились чуть иначе.
Здесь важно понять простое правило:
Если у слайса хватает cap, append может дописать в текущий backing array. Если не хватает — он вынужден выделить новый backing array и скопировать туда элементы.
Сделаем пример, где мы явно управляем cap, чтобы увидеть два разных поведения.
Случай A: cap маленький → append выделяет новый массив
package main
import "fmt"
func dump(name string, s []string) {
fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}
func main() {
tasks := make([]string, 2, 2)
tasks[0] = "почитать"
tasks[1] = "погулять"
today := tasks[:] // len=2 cap=2
today = append(today, "срочно: купить хлеб")
dump("tasks", tasks) // tasks: [почитать погулять]
dump("today", today) // today: [почитать погулять срочно: купить хлеб]
}
Здесь cap(today)==2, места нет, значит append создаёт новый backing array для today. Исходный tasks не меняется.
Случай B: cap большой → append пишет в общий массив
package main
import "fmt"
func dump(name string, s []string) {
fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}
func main() {
tasks := make([]string, 4, 4)
tasks[0] = "почитать"
tasks[1] = "погулять"
tasks[2] = "помыть посуду"
tasks[3] = "поспать"
today := tasks[:2] // len=2 cap=4
today = append(today, "срочно: купить хлеб")
dump("tasks", tasks) // tasks[2] перезаписан
dump("today", today)
}
Вот почему баг «плавающий»: он зависит от cap, а cap часто зависит от того, как именно вы собирали слайс до этого (через make, через append, через возврат из функции, и так далее).
5. Баг №3: функция вернула под‑слайс — и его “сломали” сверху
Теперь сделаем ситуацию, которая встречается в реальном коде чаще, чем хочется: у вас есть функция, которая возвращает под‑слайс (например, «задачи на сегодня»), а где-то выше по коду кто-то делает с ним append, считая, что это отдельный список.
Сначала напишем «наивную» функцию:
package main
func todayTasks(all []string) []string {
if len(all) < 2 {
return all
}
return all[:2] // под-слайс, общий backing array
}
И использование:
package main
import "fmt"
func todayTasks(all []string) []string {
if len(all) < 2 {
return all
}
return all[:2]
}
func main() {
all := []string{"почитать", "погулять", "помыть посуду", "поспать"}
today := todayTasks(all)
today = append(today, "срочно: купить хлеб")
fmt.Println(all) // [почитать погулять срочно: купить хлеб поспать]
fmt.Println(today) // [почитать погулять срочно: купить хлеб]
}
Технически функция todayTasks не сделала ничего «неправильного». Она честно вернула под‑слайс. Но с точки зрения продукта/логики приложения это может быть катастрофой: вы хотели «добавить в план на сегодня», а не «переписать задачу на завтра».
И вот это место важно почувствовать: aliasing — это не «баг языка». Это нормальная модель работы слайсов. Баг появляется, когда контракт владения не проговорён в коде.
Почему именно append особенно опасен
В прошлых темах мы уже закрепили правило «результат append всегда сохраняем». Сегодня добавим вторую часть, более коварную: append не только меняет len результата, он ещё и может писать в общий backing array.
Можно кратко сформулировать так:
append безопасен как операция, но опасен как намерение, если вы не уверены, кто владеет буфером.
Ситуация становится ещё неприятнее, если вы где-то сохраняете «оригинальные» данные, рассчитывая, что они неизменны:
original := tasks
today := tasks[:2]
today = append(today, "X")
// original и tasks — это тоже "окна" на тот же backing array
Тут original — не «копия», а такой же алиас.
6. Как чинить: три стратегии
Сейчас будет самая практичная часть лекции: что делать, чтобы append в под‑слайс не делал вам сюрпризы.
Я дам три стратегии. Они не «взаимоисключающие», и выбор зависит от того, что именно вы хотите по смыслу.
Небольшая таблица решений
| Что вы хотите по смыслу | Что делаем в коде | Почему работает |
|---|---|---|
| «Я возвращаю view, но запрещаю расширять его через append» | ограничиваем cap через s[a:b:b] | append будет вынужден выделить новый массив |
| «Я возвращаю независимый список, его можно менять как угодно» | делаем make + copy | новый backing array, никакого aliasing |
| «Я возвращаю под‑слайс и честно разрешаю менять общий массив» | ничего не делаем, но явно документируем контракт | это тоже нормальный вариант, но должен быть осознанным |
Дальше — разберём первые две стратегии на коде.
7. Защита №1: ограничиваем cap у под‑слайса
Этот приём — как поставить «ограничитель» на дверь: ходить можно, расширяться нельзя.
Если вы хотите вернуть «задачи на сегодня» как view, но при этом не хотите, чтобы кто-то смог append-ом залезть в хвост общего массива, делайте так:
package main
func todayTasksSafe(all []string) []string {
if len(all) < 2 {
return all[:len(all):len(all)] // cap=len, даже если len меньше 2
}
return all[:2:2] // len=2 cap=2
}
Обратите внимание на all[:2:2]: третий индекс — это «граница ёмкости». В результате cap у возвращённого слайса становится ровно len, и append не сможет дописать в хвост общего массива.
Проверим:
package main
import "fmt"
func todayTasksSafe(all []string) []string {
if len(all) < 2 {
return all[:len(all):len(all)]
}
return all[:2:2]
}
func main() {
all := []string{"почитать", "погулять", "помыть посуду", "поспать"}
today := todayTasksSafe(all)
today = append(today, "срочно: купить хлеб")
fmt.Println(all) // [почитать погулять помыть посуду поспать]
fmt.Println(today) // [почитать погулять срочно: купить хлеб]
}
Здесь append всё равно сработает, но он будет вынужден выделить новый backing array для today, потому что cap(today)==2.
Очень важный нюанс: ограничение cap не делает данные независимыми. Если вы сделаете today[0] = "...", это всё равно изменит all[0], потому что элементы разделяются. Этот приём защищает именно от «роста в хвост».
8. Защита №2: делаем копию через copy
Если по смыслу вам нужна полноценная копия (например, «возвращаю отдельный список задач на сегодня, который дальше редактируется как черновик»), то правильный путь — создать новый слайс и скопировать элементы.
Сделаем функцию «независимые задачи на сегодня»:
package main
func todayTasksCopy(all []string) []string {
n := 2
if len(all) < n {
n = len(all)
}
src := all[:n]
dst := make([]string, len(src))
copy(dst, src)
return dst
}
И проверим:
package main
import "fmt"
func todayTasksCopy(all []string) []string {
n := 2
if len(all) < n {
n = len(all)
}
src := all[:n]
dst := make([]string, len(src))
copy(dst, src)
return dst
}
func main() {
all := []string{"почитать", "погулять", "помыть посуду", "поспать"}
today := todayTasksCopy(all)
today[0] = "переписал в копии"
today = append(today, "срочно: купить хлеб")
fmt.Println(all) // [почитать погулять помыть посуду поспать]
fmt.Println(today) // [переписал в копии погулять срочно: купить хлеб]
}
Теперь today полностью независим: можно менять элементы, можно расширять, можно делать что угодно — исходный all не пострадает.
Да, это копирование, и оно стоит ресурсов. Но обычно дешевле один раз скопировать, чем потом три дня искать, почему «в списке задач внезапно поменялся третий элемент» (а поменялся он, потому что кто-то в другом месте делал append).
9. Как диагностировать aliasing на месте
Когда вы ловите странное поведение, полезно мыслить как детектив: «где у этого слайса лишний cap?»
Самый простой практический приём на нашем уровне курса — временно печатать len/cap для подозрительных слайсов и смотреть, есть ли у под‑слайса запас:
package main
import "fmt"
func dump(name string, s []string) {
fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}
func main() {
all := []string{"A", "B", "C", "D"}
sub := all[:2]
dump("all", all) // all: len=4 cap=4 ...
dump("sub", sub) // sub: len=2 cap=4 ... <-- вот “лишняя ёмкость”
}
Если вы видите cap(sub) > len(sub), то append(sub, "...") потенциально способен писать в общий backing array. Не всегда напишет (иногда Go решит по-другому, например если append вызывает рост больше, чем оставшийся cap), но риск уже есть.
И это как раз тот момент, когда стоит либо ограничить cap, либо копировать, либо явно запретить append по контракту.
10. Типичные ошибки
Ошибка №1: думать, что sub := s[a:b] создаёт “новый список”.
На практике это почти всегда просто новое окно на те же данные. Если дальше вы меняете элементы под‑слайса, вы меняете backing array, а значит — потенциально меняете и исходный слайс. Это не баг компилятора и не «память сломалась», это нормальная модель слайсов.
Ошибка №2: делать append в под‑слайс и удивляться, что испортился s.
Если у под‑слайса cap > len, то append может записать в «хвост» общего массива. Внешне это выглядит как «я добавил элемент в одно место, а изменилось другое». Исправляется либо ограничением cap (s[a:b:b]), либо копированием.
Ошибка №3: надеяться, что “вроде не ломается, значит всё хорошо”.
Самые вредные баги — плавающие. Сегодня append выделил новый массив и всё красиво, завтра — дописал в общий буфер и вы получили сюрприз. Если логика требует независимости, делайте её явно через copy. Если логика требует «view без роста» — делайте cap-limit явно.
Ошибка №4: путать “защиту от append” и “полную независимость”.
all[:2:2] защищает только от роста, но не от изменения элементов. Если вы хотите полный разрыв связи, нужен make + copy. Часто новички делают cap-limit и уверены, что теперь «это копия», а потом меняют today[0] и удивляются, что изменился all[0].
Ошибка №5: возвращать под‑слайс из функции без оговорённого контракта.
Когда функция возвращает all[:2], вызывающий код легко решит, что это «отдельный результат», и начнёт делать append. В результате ломается исходный список, и виноват как будто бы «тот, кто писал функцию», хотя формально он сделал всё допустимое. Хорошая практика — либо возвращать all[:2:2] (если это view), либо возвращать копию (если это результат, который будут менять).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ