JavaRush /Курси /Go SELF /Типові баги, пов’язані з al...

Типові баги, пов’язані з aliasing

Go SELF
Рівень 12 , Лекція 5
Відкрита

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), або повертати копію (якщо це результат, який будуть змінювати).

1
Опитування
Слайси й aliasing, рівень 12, лекція 5
Недоступний
Слайси й aliasing
Слайси й aliasing
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ