1. Навіщо нам узагалі потрібна «межа володіння» у слайсі
Коли ви тільки починаєте працювати зі слайсами, їх легко сприймати як окремі списки: «взяв підслайс — отримав нову штуку». Але в Go все інакше, і в цьому є своя логіка: мова економить пам’ять і не копіює дані без потреби. Проблема починається там, де ваш код психологічно очікує незалежності, а фактично отримує спільну пам’ять.
Уявіть побутову аналогію: ви з другом зняли квартиру (backing array), а кожному видали по «кімнаті» (підслайсу). Поки ви просто дивитеся й рахуєте меблі, усе гаразд. Але щойно ви починаєте «переставляти шафи» (міняти елементи), виявляється, що це одна квартира. У сусіда раптом змінився вид з вікна… бо ви пересунули стіну.
На практиці «межа володіння» потрібна, коли:
- ви хочете повернути з функції «шматок» даних, але не хочете, щоб викликач міг випадково зламати вашу внутрішню логіку;
- ви хочете зберегти знімок стану (snapshot), який не має змінюватися, навіть якщо вихідний слайс змінюватиметься;
- ви хочете безпечно працювати з підслайсами у стилі «взяв — обробив — поклав», не переживаючи про побічні ефекти.
Обмеження cap через s[a:b:c] — це хороший захист саме від небажаного append, але це все ще спільний backing array. Повна незалежність досягається лише копіюванням.
2. copy(dst, src): що робить і скільки копіює
У Go copy поводиться передбачувано: вона вбудована, проста й доволі прямолінійна. Є й сувора новина: copy не займається магією і не «вирощує» слайси — вона копіює рівно стільки елементів, скільки можна скопіювати в уже наявне місце.
Якщо сказати словами, сигнатура така: copy(dst, src) копіює елементи з src у dst і повертає кількість скопійованих елементів. Типовий шаблон у стандартній бібліотеці такий: n := copy(p, b.slice) — далі n використовують, щоб перейти до наступної ділянки даних.
Ключове правило, яке варто запам’ятати:
copy копіює min(len(dst), len(src)) елементів.
Отже, якщо в dst довжина 0, копіювати нікуди, навіть коли cap великий. Це одна з найчастіших пасток, і до неї ми ще повернемося.
Мініприклад: копіюємо скільки влізе
package main
import "fmt"
func main() {
src := []int{10, 20, 30}
dst := make([]int, 2) // len=2
n := copy(dst, src)
fmt.Println(n) // 2
fmt.Println(dst) // [10 20]
}
Тут dst має довжину 2, отже максимум 2 елементи й можна скопіювати.
Мініприклад: copy не змінює довжину
package main
import "fmt"
func main() {
src := []int{1, 2, 3}
dst := make([]int, 0, 3) // len=0, cap=3
n := copy(dst, src)
fmt.Println(n) // 0
fmt.Println(dst) // []
}
Це виглядає «несправедливо» лише перші п’ять хвилин. Потім ви розумієте: Go змушує вас явно керувати тим, скільки елементів «вважаються частиною слайса» (тобто входять у len). Ємність (cap) — це просто запас пам’яті, але поки len=0, елементів «офіційно» немає.
Невеличка схема
Щоб не плутатися, зручно тримати ось таку мінітаблицю:
| Що обмежує | На що впливає |
|---|---|
|
скільки зможе записати |
|
скільки взагалі є що копіювати |
|
скільки можна було б мати при reslice/append, але це не використовує |
3. Шаблон «власний буфер»: make + copy
Переходимо до найпрактичнішої частини: як зробити так, щоб у нас з’явився «власний буфер», тобто новий backing array. Тут є дуже простий і канонічний шаблон. Настільки канонічний, що його можна побачити навіть у дискусіях про розвиток мови: виділяємо новий буфер, копіюємо в нього елементи, а далі працюємо вже з ним.
Шаблон такий:
- створюємо dst := make([]T, len(src))
- робимо copy(dst, src)
- повертаємо або використовуємо dst
Приклад: клонуємо слайс повністю
package main
import "fmt"
func cloneInts(src []int) []int {
dst := make([]int, len(src))
copy(dst, src)
return dst
}
func main() {
a := []int{1, 2, 3}
b := cloneInts(a)
b[0] = 99
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [99 2 3]
}
Тепер a і b незалежні: у них різні backing array.
Приклад: для рядків — те саме
package main
import "fmt"
func cloneStrings(src []string) []string {
dst := make([]string, len(src))
copy(dst, src)
return dst
}
func main() {
names := []string{"Ann", "Bob"}
safe := cloneStrings(names)
safe[1] = "Bobby"
fmt.Println(names) // [Ann Bob]
fmt.Println(safe) // [Ann Bobby]
}
Для новачків важливе зауваження: рядки в Go самі по собі незмінні, але слайс рядків — змінний. Ви можете змінити елемент слайса, тобто значення в комірці. Тому незалежна копія слайса рядків — це справді корисна річ.
4. Копіюємо підслайс, а не весь слайс
У практиці часто не потрібно копіювати весь слайс: іноді достатньо лише ділянки. Наприклад, «останні 3 команди користувача», «перші 5 чисел», «підпослідовність токенів» (у нашому випадку йдеться саме про слайси, не про рядки чи руни). І тут легко переплутати дві різні дії: взяти підслайс і скопіювати його.
Нарізка sub := s[a:b] створює нове вікно на стару пам’ять. Копіювання створює нову пам’ять. Тому правильна послідовність така: спочатку ми обираємо діапазон, а потім робимо для нього власний буфер.
Приклад: беремо шматок і робимо незалежним
package main
import "fmt"
func clonePart(src []int, a, b int) []int {
part := src[a:b]
dst := make([]int, len(part))
copy(dst, part)
return dst
}
func main() {
s := []int{10, 20, 30, 40}
x := clonePart(s, 1, 3) // [20 30]
x[0] = 999
fmt.Println(s) // [10 20 30 40]
fmt.Println(x) // [999 30]
}
Тут x — незалежна копія діапазону [1:3].
Чому s[a:b:c] не замінює copy
Після триіндексного зрізу у підслайса стає обмежений cap, і append уже не зможе тихо переписати хвіст вихідного масиву. Але якщо ви зробите sub[0] = ..., ви все одно зміните вихідні дані. Обмеження ємності — це «паркан від розширення», а copy — це «переїзд в окрему квартиру».
5. len важливіший за cap: чому copy не «заповнює ємність»
Саме тут у новачків часто починається маленька особиста драма: «Ну я ж виділив cap! Чому copy нічого не скопіював?!» Спокійно. Це не баг і не підступ, а логічна модель: len — це розмір логічних даних, а cap — технічний запас пам’яті.
Якщо у вас len(dst) == 0, значить ви офіційно заявили: «У слайсі зараз немає елементів». copy поважає цю заяву і не робить вигляд, що ви «насправді мали на увазі інше».
Неправильний варіант
package main
import "fmt"
func main() {
src := []int{1, 2, 3}
dst := make([]int, 0, len(src)) // len=0
copy(dst, src)
fmt.Println(dst) // []
}
Правильний варіант №1: одразу робимо потрібну довжину
package main
import "fmt"
func main() {
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
fmt.Println(dst) // [1 2 3]
}
Правильний варіант №2: виділили cap, потім зробили reslice
Іноді з погляду архітектури зручно спочатку виділити буфер із запасом, а потім уже вирішити, скільки елементів він міститиме. Тоді ви робите reslice до потрібної довжини й лише потім копіюєте.
package main
import "fmt"
func main() {
src := []int{1, 2, 3}
dst := make([]int, 0, len(src))
dst = dst[:len(src)] // тепер len=3
copy(dst, src)
fmt.Println(dst) // [1 2 3]
}
Цей варіант трохи технічніший, але він допомагає зрозуміти головне: copy працює за len, а cap — це лише стеля, яку ви можете використати через reslice або append.
Вибір стратегії для API: view, cap-limit або копія
Тут важливо не впасти в культ суцільного копіювання. Копія — це не завжди добре і не завжди потрібно. Іноді вам потрібен саме view, бо це швидко й зручно. Іноді ви хочете захиститися від append, тобто обмежити cap. А іноді вам потрібна повна незалежність — отже, копія.
Слайси — це вікно на масив. Тому ваш вибір по суті такий: чи хочемо ми ділити це вікно ще з кимось, чи хочемо власне приміщення.
Зручно мислити так:
| Стратегія | Як виглядає | Що гарантує | Чого НЕ гарантує |
|---|---|---|---|
| Аліас (view) | |
швидко, без алокацій | зміни елементів видно обом сторонам |
Обмеження |
|
не «протече» в хвіст вихідного масиву |
зміни елементів усе ще спільні |
| Копія (власний буфер) | |
повна незалежність даних | потребує виділення пам’яті й копіювання |
У доброму коді ви свідомо обираєте стратегію відповідно до призначення функції. Якщо функція називається ViewLastN, вона має право повернути аліас. Якщо називається SnapshotLastN — користувач чекатиме копію. Назва тут майже як контракт.
6. Приклад: «Список справ» і безпечний Last(n)
Тепер зробимо невеличкий приклад, який легко буде уявляти далі під час курсу. Нехай у нас є консольна програма «Список справ»: вона зберігає задачі у слайсі рядків. Ми хочемо вміти отримувати останні n задач для показу в інтерфейсі або для подальшої обробки. І тут виникає важливе дизайнерське питання: повертати view чи копію?
Почнімо з мінімальної моделі даних — просто []string. Жодних структур, файлів чи баз даних — ми тренуємо саме слайси.
Крок 1: додавання задач
package main
import "fmt"
func addTask(tasks []string, title string) []string {
return append(tasks, title)
}
func main() {
var tasks []string
tasks = addTask(tasks, "Купити хліб")
fmt.Println(tasks) // [Купити хліб]
}
Поки все просто: append повертає новий слайс, і ми його зберігаємо.
Крок 2: «небезпечна» версія LastView
package main
func lastView(tasks []string, n int) []string {
if n > len(tasks) {
n = len(tasks)
}
return tasks[len(tasks)-n:]
}
Ця функція швидка і без додаткових алокацій. Але якщо хтось потім зробить result[0] = "...", він змінить вихідні дані, бо це view.
Крок 3: «безпечна» версія LastSnapshot
Ось тут ми застосовуємо сьогоднішній інструмент: copy як межу володіння.
package main
func lastSnapshot(tasks []string, n int) []string {
if n > len(tasks) {
n = len(tasks)
}
part := tasks[len(tasks)-n:]
dst := make([]string, len(part))
copy(dst, part)
return dst
}
І давайте перевіримо, що це справді захищає.
package main
import "fmt"
func main() {
tasks := []string{"A", "B", "C", "D"}
view := lastView(tasks, 2)
snap := lastSnapshot(tasks, 2)
view[0] = "XXX"
snap[1] = "YYY"
fmt.Println(tasks) // [A B XXX D]
fmt.Println(view) // [XXX D]
fmt.Println(snap) // [C YYY]
}
Тут видно одразу дві ідеї:
- view змінив вихідний tasks (бо спільний backing array).
- snap не змінив вихідний tasks (бо власний буфер).
Це і є «межа володіння»: ми ніби сказали «ось цей шматок даних тепер мій, і я обіцяю, що зовнішні зміни не протечуть сюди, а мої зміни не протечуть назовні».
Маленька блок-схема
flowchart TD A[Вихідний backing array] -->|tasks| B[tasks: len,cap] A -->|lastView| C[view: нове вікно
у спільну пам’ять] A -->|part| D[part: підслайс] D -->|copy у новий масив| E[dst: власний backing array] E -->|lastSnapshot| F[snap: незалежні дані]
Сенс діаграми простий: lastView дає нове вікно на стару пам’ять, а lastSnapshot дає нове вікно на нову пам’ять.
7. Типові помилки під час копіювання слайсів
Помилка № 1: «я зробив копію» через t := s. Це не копія даних, а копія заголовка слайса (pointer/len/cap). Самі елементи залишаються спільними, бо backing array той самий. Якщо ви хочете «власний буфер», вам потрібен make + copy.
Помилка № 2: виділили cap, але забули про len, і copy скопіював нуль елементів. Це та сама пастка make([]T, 0, n). copy дивиться на len(dst), а не на cap(dst). Якщо треба копіювати, робіть make([]T, len(src)) або reslice до потрібної довжини перед копіюванням.
Помилка № 3: сподіватися, що обмеження cap через s[a:b:c] робить дані незалежними. Триіндексний зріз справді рятує від несподіваного append, але зміни елементів (sub[i] = ...) усе одно йдуть у спільний backing array. Це захист від росту, а не розрив зв’язку.
Помилка № 4: копіювати «не той діапазон» через off-by-one. Дуже легко промахнутися на один індекс: хотіли останні 3 елементи, а взяли 2 або 4. Надійний прийом — перед copy тимчасово надрукувати a, b, len(part) і сам part. І пам’ятати, що зріз — це напівінтервал [a, b).
Помилка № 5: копіювати «про всяк випадок» у гарячому циклі, а потім дивуватися швидкості. Копіювання — це робота й пам’ять. Іноді view справді достатньо. Гарний стиль — спочатку зробити правильно й передбачувано (часто через копію на межі), а оптимізувати лише тоді, коли є реальна потреба і ви розумієте, де саме вузьке місце.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ