JavaRush /Курси /Go SELF /Повний slice expression s[...

Повний slice expression s[a:b:c]

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

1. Вступ

У звичайному слайсі робота append зазвичай виглядає цілком передбачувано: елемент додається в кінець — і все. Але щойно зʼявляється підслайс, що дивиться в середину спільного масиву, append раптом може почати писати «в хвіст» цього ж масиву та перезаписувати елементи, які ви вважали чужими. З боку це схоже на містичне псування памʼяті, хоча насправді йдеться лише про використання вільної ємності.

Уявіть, що у вас є невеликий навчальний застосунок: список завдань у памʼяті. Поки без файлів і без структур — лише []string.

package main

import "fmt"

func main() {
	tasks := []string{"learn Go", "drink water", "sleep"}
	fmt.Println(tasks) // [learn Go drink water sleep]
}

Тепер припустімо, що ви хочете показати користувачеві «перші два завдання» — тобто зробити view на початок:

package main

import "fmt"

func main() {
	tasks := []string{"learn Go", "drink water", "sleep", "repeat"}
	first := tasks[:2]

	fmt.Println(first) // [learn Go drink water]
}

А тепер десь усередині логіки ви вирішили дописати в first підказку — наприклад, для UI або звіту — і пишете:

first = append(first, "TIP: don't panic")

І саме тут може статися таке: ви раптом бачите, що в tasks змінився третій елемент. Це не тому, що Go «шкідливий». Причина в тому, що в first міг бути cap більший за len, і append акуратно використав вільне місце в тому самому backing array.

До речі, сама ідея append як «розумного додавання» зʼявилася саме для того, щоб програміст не писав руками розширення масиву та copy на кожне зростання. Go свідомо дав нам append, щоб спростити типовий код. Але за зручність доводиться платити: потрібно розуміти, що відбувається з cap.

2. len і cap у підслайса: швидкий рефреш

Перед триіндексним зрізом корисно ще раз «помацати руками» механіку:

  • len — це скільки елементів входить у слайс,
  • cap — це скільки елементів ви потенційно можете отримати, якщо розширите вікно (reslice/append), не переходячи на новий масив.

І найважливіше: у підслайса cap зазвичай тягнеться до кінця backing array, а не до поточного len.

Давайте зробимо діагностичний приклад. Він маленький, але дуже показовий:

package main

import "fmt"

func main() {
	tasks := []string{"a", "b", "c", "d"}
	first := tasks[:2]

	fmt.Println(len(first), cap(first)) // 2 4
}

Чому cap(first) дорівнює 4? Бо first починається з індексу 0 того самого backing array, і до кінця масиву ще є місце: потенційно можна розширити вікно до 4 елементів.

А тепер підслайс із середини:

package main

import "fmt"

func main() {
	tasks := []string{"a", "b", "c", "d", "e"}
	mid := tasks[1:3] // бачимо b,c

	fmt.Println(mid)                // [b c]
	fmt.Println(len(mid), cap(mid)) // 2 4 (зазвичай)
}

len(mid) = 2, а cap(mid) зазвичай дорівнює cap(tasks) - 1, бо вікно починається з індексу 1 і може «рости праворуч» ще доволі далеко.

І от тепер головний висновок, який нам потрібен сьогодні: якщо у підслайса є зайва ємність (cap > len), то append може дописувати в той самий backing array і тим самим змінювати «чужі» елементи вихідного слайса. Це прямий наслідок моделі slice header (pointer + len + cap).

3. Проблема append у підслайсі: чому ламаються сусідні дані

Зараз ми спеціально влаштуємо маленьку «катастрофу», щоб потім так само красиво її уникнути. Важливо: ми не робимо нічого забороненого. Ми робимо те, що виглядає розумно, а потім дивуємося. Це майже 80% дорослого програмування (решта 20% — розставляти fmt.Printf у несподіваних місцях).

package main

import "fmt"

func main() {
	tasks := []string{"learn", "water", "sleep", "repeat"}

	first := tasks[:2]           // [learn water], але cap може бути 4
	first = append(first, "tip") // дописуємо в "перший список"

	fmt.Println(first) // [learn water tip]
	fmt.Println(tasks) // [learn water tip repeat]  ← сюрприз
}

Що сталося? first — це вікно на backing array tasks. У first була вільна ємність, і append вирішив: «Навіщо мені виділяти новий масив, якщо в старому ще є місце? Я просто запишу елемент у хвіст». І записав "tip" туди, де раніше лежав "sleep".

Ця поведінка не випадковість і не «оптимізація, яка часом спрацьовує». Це базова модель append: якщо cap вистачає, зростаємо на місці; якщо ні — виділяємо новий масив і копіюємо дані. Тому в Go так наполегливо повторюють: результат append завжди зберігаємо. append може повернути слайс, що вказує або на старий масив, або на новий.

Нам потрібен захист: ми хочемо вміти сказати «ось тобі підслайс, але рости праворуч йому не можна». І тут зʼявляється герой лекції: повний, або триіндексний, slice expression.

4. Повний slice expression s[a:b:c]: синтаксис і сенс

Триіндексний зріз виглядає лячно: s[a:b:c]. Але сенс у нього дуже інженерний і прямолінійний: ми задаємо не лише len, а й обмежуємо cap результату.

Формально для t := s[a:b:c] виконуються дві формули:

Вираз Що отримаємо
len(t)
b - a
cap(t)
c - a

І є правило допустимості меж:

0 <= a <= b <= c <= cap(s)

Зверніть увагу: верхня межа c порівнюється саме з cap(s), а не з len(s). Це логічно: ми керуємо ємністю, а ємність — це про backing array.

Чому я підкреслюю офіційність цього синтаксису? Бо це частина мови та специфікації slice expressions. Не «хак для обраних», а передбачений механізм: «я свідомо задаю межі видимості й межі зростання».

5. Головний прийом: зробити cap == len, щоб append не псував сусідів

Тепер найпрактичніше: найчастіше вам не потрібен довільний c. Вам потрібен простий і потужний патерн — обмежити cap рівно до len, щоб будь-який append був змушений виділити новий backing array.

Для цього використовують такі форми:

  • s[:n:n] — беремо перші n елементів і не даємо слайсу рости праворуч;
  • загальна форма s[a:b:b] — «cap закінчується там само, де закінчується len».

Спочатку подивімося на різницю в cap:

package main

import "fmt"

func main() {
	tasks := []string{"a", "b", "c", "d"}

	view1 := tasks[:2]   // len=2, cap=4
	view2 := tasks[:2:2] // len=2, cap=2

	fmt.Println(len(view1), cap(view1)) // 2 4
	fmt.Println(len(view2), cap(view2)) // 2 2
}

А тепер — головний ефект. Спочатку «небезпечний» варіант:

package main

import "fmt"

func main() {
	tasks := []string{"learn", "water", "sleep", "repeat"}

	first := tasks[:2]
	first = append(first, "tip")

	fmt.Println(tasks) // [learn water tip repeat]
}

Тепер «захищений» варіант:

package main

import "fmt"

func main() {
	tasks := []string{"learn", "water", "sleep", "repeat"}

	first := tasks[:2:2] // cap обмежили
	first = append(first, "tip")

	fmt.Println(tasks) // [learn water sleep repeat]
	fmt.Println(first) // [learn water tip]
}

Чому тепер tasks не змінюється? Бо у first cap == len == 2. Отже, додати третій елемент «на місці» не можна. append зобовʼязаний створити новий backing array і перенести туди дані, щоб розмістити новий елемент.

Важливий нюанс: s[a:b:c] не робить дані незалежними сам по собі. До першого append у вас і далі спільний backing array, і якщо ви змінюєте наявні елементи (наприклад, first[0] = "X"), це змінить вихідний tasks. Триіндексний зріз захищає саме від запису «в хвіст» через зростання, а не від зміни вже наявних елементів.

6. Як обирати c і що захищає cap‑limit

Як читати a:b:c, щоб не плутатися

Щоб s[a:b:c] не виглядав як заклинання, тримайте просту «карту»:

  • a — звідки починається вікно (start),
  • b — де закінчується довжина (end of len),
  • c — де закінчується ємність (end of cap).

І все це — у координатах вихідного backing array.

Давайте наочно розглянемо це на нашому tasks:

package main

import "fmt"

func main() {
	tasks := []string{"a", "b", "c", "d", "e"}

	mid := tasks[1:3:3] // start=1, endLen=3, endCap=3
	fmt.Println(mid)                // [b c]
	fmt.Println(len(mid), cap(mid)) // 2 2
}

Тут a = 1, b = 3, c = 3. Отже:

len = b-a = 3-1 = 2
cap = c-a = 3-1 = 2

Тобто ми взяли "b", "c" і повністю заборонили цьому поданню рости праворуч.

Якщо спробувати зробити append(mid, "X"), append не зможе писати в хвіст вихідного масиву (там далі лежить "d") і буде змушений виділити новий backing array.

Мінісхема: що саме захищає обмеження cap

Іноді простіше зрозуміти наочно. Уявімо backing array як полицю з комірками, а слайс — як рамку-вікно.

Припустімо:

tasks backing array:
[0] a | [1] b | [2] c | [3] d | [4] e

Якщо ви берете mid := tasks[1:3], то у mid є вікно на b, c, але cap зазвичай тягнеться до кінця backing array:

mid len: 2 елементи (b, c)
mid cap: може рости праворуч до e

І append(mid, "X") може записати "X" у позицію [3], перезаписавши d.

Якщо ви берете mid := tasks[1:3:3], то ви кажете: «моє вікно закінчується на 3, і стеля зростання теж на 3». Тобто:

mid len: 2 елементи (b, c)
mid cap: 2 елементи (рівно b, c)

Рости праворуч уже не можна. Це і є захист.

7. Практика: безпечне подання для звіту та відмінність від копії

Приклад: «подання для звіту, яке можна доповнювати»

Тепер зберемо ідею в сценарій, який реально трапляється в коді. Припустімо, у нас є список задач, і ми хочемо сформувати окремий список для виводу: беремо перші N задач і додаємо в кінець рядок "---" як роздільник. Ми хочемо, щоб додавання роздільника не змінювало вихідний список задач.

Зробимо функцію, яка повертає «безпечний» view з обмеженим cap. Ми використовуємо функцію, бо так код ближчий до реальної розробки.

package main

import "fmt"

func topView(tasks []string, n int) []string {
	if n > len(tasks) {
		n = len(tasks)
	}
	return tasks[:n:n] // cap == len, захист від append
}

func main() {
	tasks := []string{"learn", "water", "sleep", "repeat"}

	view := topView(tasks, 2)
	view = append(view, "---")

	fmt.Println(tasks) // [learn water sleep repeat]
	fmt.Println(view)  // [learn water ---]
}

Тут ми не робили копію вручну. Ми просто повернули підслайс із «обрізаною ємністю», тож подальший append у view уже не може зіпсувати вихідний масив.

Але ще раз — це важливо: якщо замість append ви зробите view[0] = "X", то ви зміните tasks[0]. Бо це все ще view на ті самі елементи. Обмеження cap — про зростання, а не про володіння даними.

Чому це не копія: чесно розрізняємо дві стратегії

Дуже легко потрапити в самообман: «раз append після обмеження cap не змінює вихідний слайс, значить це копія». Ні. Це схоже на ситуацію: «я поставив обмежувач швидкості й тепер машина не розганяється», але машина та сама.

Обмеження cap робить дві речі.

По-перше, воно забороняє підслайсу використовувати чужий хвіст backing array як свою «зону зростання». Саме це й потрібно, щоб append не переписував сусідні елементи.

По-друге, воно робить поведінку передбачуванішою: щойно ви спробуєте збільшити слайс через append, дані переїдуть у новий backing array. І це часто зручно, бо ви отримуєте нову область памʼяті як побічний ефект зростання.

Але якщо вам потрібна незалежність уже зараз, без будь-якого зростання, або якщо ви хочете гарантовано відокремити дані для подальших змін елементів, це вже інша стратегія. Важливо просто не плутати: s[a:b:c] — це про керування ємністю, а не про фізичне копіювання.

8. Типові помилки під час роботи з s[a:b:c]

Помилка №1: думати, що s[a:b:c] — це «якийсь хак», який можна не знати.
На практиці це офіційний синтаксис slice expressions, і він існує рівно для одного інженерного завдання: контролювати cap результату. Це не «трюк для олімпіадників», а спосіб зробити код передбачуваним, особливо коли в проєкті багато функцій, які приймають або повертають підслайси.

Помилка №2: очікувати, що s[a:b:c] робить копію елементів.
Після sub := s[a:b:c] у вас і далі спільний backing array. Якщо ви змінюєте наявні елементи sub[i] = ..., це впливає на вихідний s. Обмеження cap захищає передусім від ефектів зростання через append, але не скасовує aliasing.

Помилка №3: плутати ролі індексів і писати «як відчув».
Корисно тримати в голові: a — початок вікна, b — кінець довжини, c — кінець ємності. Якщо ви не можете швидко пояснити словами, що означає конкретна трійка a:b:c, краще зупинитися й порахувати len = b-a, cap = c-a на папері. Це швидше, ніж потім відлагоджувати «примарне псування даних».

Помилка №4: порушувати порядок a <= b <= c і отримувати panic.
Триіндексний зріз суворіший, ніж здається: не можна ставити c менше за b, не можна ставити b менше за a. Go не намагається «вгадати, що ви мали на увазі», він чесно падає, бо межі вікна не можуть бути відʼємними або переплутаними.

Помилка №5: забувати, що c порівнюється з cap(s), а не з len(s).
Новачки часто мислять так: «У мене ж усього len елементів». Але cap може бути більшим, особливо якщо слайс вийшов після append або make(..., len, cap). Тому допустимість c визначається ємністю backing array, а не поточною довжиною слайса.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ