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.
Помилка №4: плутати «захист від append» і «повну незалежність».
all[:2:2] захищає лише від росту, але не від зміни елементів. Якщо ви хочете повний розрив зв’язку, потрібен make + copy. Часто новачки ставлять cap-limit і думають, що тепер це копія, а потім змінюють today[0] і дивуються, що змінився all[0].
Помилка №5: повертати підслайс із функції без обумовленого контракту.
Коли функція повертає all[:2], код, що викликає функцію, легко вирішує, що це окремий результат, і починає робити append. У результаті ламається вихідний список, і винним ніби стає той, хто писав функцію, хоча формально він зробив усе допустиме. Хороша практика — або повертати all[:2:2] (якщо це view), або повертати копію (якщо це результат, який будуть змінювати).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ