JavaRush /Курси /Go SELF /Копіювання як межа володіння:

Копіювання як межа володіння: copy і «власний буфер»

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

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, елементів «офіційно» немає.

Невеличка схема

Щоб не плутатися, зручно тримати ось таку мінітаблицю:

Що обмежує На що впливає
len(dst)
скільки
copy
зможе записати
len(src)
скільки взагалі є що копіювати
cap(dst)
скільки можна було б мати при reslice/append, але
copy
це не використовує

3. Шаблон «власний буфер»: make + copy

Переходимо до найпрактичнішої частини: як зробити так, щоб у нас з’явився «власний буфер», тобто новий backing array. Тут є дуже простий і канонічний шаблон. Настільки канонічний, що його можна побачити навіть у дискусіях про розвиток мови: виділяємо новий буфер, копіюємо в нього елементи, а далі працюємо вже з ним.

Шаблон такий:

  1. створюємо dst := make([]T, len(src))
  2. робимо copy(dst, src)
  3. повертаємо або використовуємо 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)
sub := s[a:b]
швидко, без алокацій зміни елементів видно обом сторонам
Обмеження
cap
sub := s[a:b:b]
append
не «протече» в хвіст вихідного масиву
зміни елементів усе ще спільні
Копія (власний буфер)
dst := make(...); copy(dst, sub)
повна незалежність даних потребує виділення пам’яті й копіювання

У доброму коді ви свідомо обираєте стратегію відповідно до призначення функції. Якщо функція називається 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 справді достатньо. Гарний стиль — спочатку зробити правильно й передбачувано (часто через копію на межі), а оптимізувати лише тоді, коли є реальна потреба і ви розумієте, де саме вузьке місце.

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