JavaRush /Курси /Go SELF /Заголовок слайса — pointer + len + cap

Заголовок слайса — pointer + len + cap

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

1. Вступ

Коли ви тільки починаєте писати на Go, слайси виглядають як динамічний масив: є []int, можна звертатися до s[i], можна проходитися циклом, можна зберігати багато елементів. І все було б добре, якби слайси іноді не поводилися… як живі. То один і той самий слайс раптом видно через дві змінні, то довжина й ємність чомусь різні, то межі індексації не збігаються з тим, що ви очікуєте.

Щоб перестати вгадувати й почати передбачати поведінку, програмісти Go тримають у голові просту модель: слайс — це не самі елементи, а маленька структура-опис, яка вказує, де лежать дані та скільки з них доступно. Цю модель і називають slice header, або заголовком слайса.

Слайс як «ручка» до даних: pointer + len + cap

Зараз важлива думка, яку варто перечитати двічі, особливо якщо ви втомилися: значення слайса []T — це не «контейнер з елементами», а опис вікна, через яке ми дивимося на певний набір елементів у памʼяті.

У цього «опису» (slice header) логічно три частини:

Частина Як зрозуміти по-людськи За що відповідає в поведінці
pointer
«куди дивимося» чому два слайси можуть бачити одні й ті самі елементи
len
«скільки елементів видно» межі індексації 0..len-1, скільки пройде range
cap
«який запас є далі» чому у слайса буває ємність більша за довжину

Можна уявити це так — умовно, не як реальний вивід компілятора:

slice header:
+---------+-----+-----+
| pointer | len | cap |
+---------+-----+-----+

Важливо: ми не будемо у цій лекції заглиблюватися в «справжні вказівники» в коді (&, *, unsafe). Поки нам достатньо розуміти ідею: всередині є посилання на дані.

2. len: видима довжина та межі індексації

Коли ви пишете len(s), ви питаєте у слайса: скільки елементів зараз доступно через це вікно. І це число — закон. Не орієнтир, не «скільки ми плануємо туди покласти», не «скільки там десь лежить», а саме: скільки можна безпечно читати або записувати за індексом.

Саме len визначає коректні індекси:

  • перший індекс: 0
  • останній індекс: len(s) - 1
  • індекс len(s) — уже за межею (і при зверненні спричинить паніку)

Невеликий приклад, щоб наочно побачити звʼязок len та індексів:

package main

import "fmt"

func main() {
	s := []int{10, 20, 30}
	fmt.Println(len(s)) // 3

	fmt.Println(s[0]) // 10
	fmt.Println(s[2]) // 30
	fmt.Println(s[3]) // panic: index out of range
}

Якщо ви раніше працювали з мовами, де з «десь там» повертають null або undefined, то Go швидко відучить вас від такої звички: вихід за межі — це не «мʼяка помилка», а аварійна зупинка програми (panic). І, чесно кажучи, у навчальних задачах це навіть корисно: помилку видно одразу.

4. cap: ємність, запас і чому це не «ще елементи»

Ємність (cap) зазвичай викликає у новачків рівно дві емоції: «А навіщо?» і «Чому вона не дорівнює len?». Обидві — нормальні.

Давайте домовимося про правильне розуміння:

cap(s) — це скільки елементів може бути в цій самій ділянці памʼяті, починаючи з поточного початку слайса, без перенесення в інше місце.

Тобто cap — це запас, який допомагає ефективно розширювати слайс. Але важливо: cap не дає права звертатися за індексом. Індексація живе за len, а cap — це радше про внутрішню механіку та продуктивність.

Щоб побачити ситуацію «len менший за cap», нам потрібен спосіб створити слайс так, щоб він був коротший за запас. Найпростіший спосіб — make, але тут ми використовуємо його лише як інструмент для демонстрації len/cap, без занурення в деталі.

package main

import "fmt"

func main() {
	s := make([]int, 2, 5) // len=2, cap=5
	fmt.Println(len(s), cap(s)) // 2 5
	fmt.Println(s)              // [0 0]
}

Примітка: у прикладі вище ми використали функцію make. Докладно ми розберемо її в наступній лекції, а тут вона потрібна лише для того, щоб створити слайс із різними len і cap.

Ось що ми отримали в прикладі вище:

  • len=2 означає: є два доступні елементи, обидва поки мають нульове значення (zero value для int).
  • cap=5 означає: у цьому ж шматку памʼяті можна розмістити до 5 елементів, але прямо зараз видно лише 2.

Якщо ви спробуєте звернутися до s[2], ви отримаєте паніку, попри те, що cap=5. І це логічно: запас не дорівнює доступу.

package main

import "fmt"

func main() {
	s := make([]int, 0, 3) // len=0, cap=3
	fmt.Println(len(s), cap(s)) // 0 3

	// fmt.Println(s[0]) // panic: len=0, індексувати не можна
	fmt.Println(s) // []
}

Запамʼятайте коротку формулу:

  • len відповідає за коректність доступу до елементів;
  • cap відповідає за те, наскільки «комфортно» слайс буде рости.

5. pointer: чому два слайси можуть бачити одні й ті самі елементи

Тепер найбільш «магічна» частина: pointer у заголовку. Слово страшне, але ідея проста. Усередині слайса є інформація про те, де лежить перший елемент (умовно: адреса початку даних). Тому два різні значення-слайси можуть посилатися на один і той самий набір елементів.

Саме це пояснює поведінку:

package main

import "fmt"

func main() {
	s := []int{1, 2, 3}
	t := s       // копіюємо header, а не елементи
	t[0] = 99

	fmt.Println(s) // [99 2 3]
	fmt.Println(t) // [99 2 3]
}

З погляду моделі slice header це виглядає так:

  • t := s копіює лише header (pointer/len/cap).
  • pointer у t і у s вказує на один і той самий базовий масив (інколи кажуть backing array).
  • тому зміна t[0] змінює дані «внизу», і це видно через s.

Можна намалювати схему. Вона умовна, але корисна:

s (header) ----+
               |
               v
           [ 1 ][ 2 ][ 3 ]   (дані)
               ^
               |
t (header) ----+

Поки ви не відчуєте цю картинку, слайси здаватимуться непередбачуваними. Коли відчуєте — вони стануть цілком логічними.

6. Чому слайс копіюється «дешево»

Важливий момент: у Go усе передається за значенням. Це означає, що під час присвоювання або передання параметра у функцію Go копіює значення змінної.

Але ось хитрість: значення масиву і значення слайса — різні за «вагою».

  • У масиву [N]int значення містить N елементів.
  • У слайса []int значення містить заголовок із трьох частин (pointer/len/cap).

Тому копіювати масив — це копіювати всі елементи, а копіювати слайс — це копіювати «маленьку картку-опис».

Подивимося на це через функцію. Спочатку приклад, де ми змінюємо елемент:

package main

import "fmt"

func setFirst(s []int) {
	s[0] = 100
}

func main() {
	nums := []int{1, 2, 3}
	setFirst(nums)
	fmt.Println(nums) // [100 2 3]
}

Чому зміни видно зовні? Бо у функцію прийшла копія header, але pointer у цьому header усе ще вказує на ті самі дані.

Тут варто сформулювати обережно: функція не отримала «посилання на слайс» (ми ще не вчилися працювати з вказівниками). Функція отримала копію значення, просто це значення — маленький опис, усередині якого є pointer на спільні дані.

7. Практика: спостерігаємо len і cap у коді

Маленький інструмент для відладки: друкуємо стан слайса

Коли ви навчаєтеся, мозок часто просить: «Можна побачити це в цифрах?» Можна. І навіть потрібно — особливо в Go, де fmt.Printf чудово допомагає в діагностиці.

Зробимо маленьку функцію printSliceState, яку будемо використовувати в нашому навчальному застосунку. Цей застосунок буде простим: ми читаємо кілька чисел (наприклад, витрати або оцінки), кладемо їх у слайс і час від часу друкуємо його стан.

package main

import "fmt"

func printSliceState(name string, s []int) {
	fmt.Printf("%s: len=%d cap=%d data=%v\n", name, len(s), cap(s), s)
}

func main() {
	a := make([]int, 2, 5)
	printSliceState("a", a) // a: len=2 cap=5 data=[0 0]
}

Зверніть увагу: ми не друкуємо pointer (і це нормально). На цьому рівні ми можемо безпосередньо спостерігати len і cap, а pointer розуміти за наслідками: спільні елементи, зміни видно через різні змінні тощо.

Як модель допомагає писати передбачуваний код у застосунку

Коли ви робите програму, яка зберігає набір даних, зазвичай є два сценарії.

Перший сценарій — ви заздалегідь знаєте, скільки буде елементів (наприклад, користувач вводить n, і ви читаєте рівно n чисел). Тоді зручно створити слайс довжини n і заповнити його за індексами. У цьому сценарії len — це «скільки реально є елементів», і ви заповнюєте їх один раз.

package main

import (
	"fmt"
)

func main() {
	var n int
	fmt.Scan(&n)

	nums := make([]int, n) // len=n, cap=n
	for i := 0; i < len(nums); i++ {
		fmt.Scan(&nums[i])
	}

	fmt.Println(nums) // наприклад: [5 10 15]
}

Тут модель заголовка допомагає не заплутатися: len(nums) — це і «скільки чисел ми читаємо», і «скільки індексів доступно». Усе чесно.

Другий сценарій — ви не знаєте, скільки буде елементів, і збираєте їх поступово. Тоді зазвичай обирають інший стиль — через додавання в кінець. Але цей стиль ми докладно розберемо трохи пізніше; зараз важливо інше: саме cap — той параметр, який робить поступове зростання ефективним.

До речі, невеличкий історичний факт: до появи зручного механізму додавання елементів програмісти часто писали ручну перевірку if n+1 > cap(list) { ... }, виділяли новий буфер і копіювали дані. Такий код — і мотивацію для нього — і досі можна зустріти в розповідях про еволюцію Go.

Ментальна перевірка: як «читати» будь-який слайс

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

Перше запитання: де дані? (той самий pointer, тобто «чи маю я спільний базовий масив із кимось іще?»). Це запитання рятує від здивування на кшталт «чому змінилося в іншому місці».

Друге запитання: скільки елементів я бачу? (це len). Це запитання рятує від panic: index out of range.

Третє запитання: чи є запас, і навіщо він мені? (це cap). Це запитання рятує від сліпої віри в те, що додавання елементів завжди безкоштовне, і допомагає писати код, який не створює зайвого навантаження на памʼять.

Якщо вам хочеться ще виразніше відчути різницю між len і cap, можна додати у ваш застосунок діагностичний друк перед заповненням і після нього, щоб побачити, що cap не зобов’язаний збігатися з len:

package main

import "fmt"

func main() {
	s := make([]int, 2, 5)
	fmt.Println(len(s), cap(s), s) // 2 5 [0 0]

	s[0] = 7
	s[1] = 8
	fmt.Println(len(s), cap(s), s) // 2 5 [7 8]
}

Тут видно важливе: заповнення елементів не змінює ні len, ні cap. Це логічно: заголовок не зобов’язаний змінюватися лише тому, що ви змінили дані всередині.

8. Типові помилки в розумінні slice header

Помилка №1: думати, що t := s копіює елементи.
Це одна з найчастіших пасток. Новачок очікує, що раз у нього «нова змінна», то це й «новий список». У випадку слайса копіюється заголовок, а дані часто залишаються спільними. У результаті зміна t[0] може змінити s[0]. Щоб не дивуватися, корисно подумки проговорювати: «я зробив другу ручку до тих самих даних».

Помилка №2: плутати cap із «кількістю елементів» та індексувати за cap.
Іноді студент бачить cap=10 і вирішує: «О, у мене 10 елементів», після чого звертається до s[9]. Але якщо len=2, ви щойно спробували піднятися на 9-й поверх будівлі, де зведено лише два. Go реагує передбачувано: паніка. Правило просте: індексуємо лише до len, а сам по собі запас cap доступу не дає.

Помилка №3: вважати, що len — це «скільки я вже заповнив», а не «скільки елементів існує».
Це особливо помітно під час make([]int, n): багато хто думає, що це «порожній список ємності n». Насправді це слайс довжини n, і там уже є n елементів зі значеннями за замовчуванням. Якщо вам потрібен варіант «поки порожньо, але із запасом», це інший сценарій. Зараз достатньо запамʼятати ідею: len — це реально наявні елементи, навіть якщо вони нульові.

Помилка №4: боятися слова pointer і через це відмовлятися від моделі.
Слово «вказівник» багатьох лякає, бо в інших мовах або в C/C++ воно повʼязане з болем і стражданнями — а інколи й із нічними кошмарами. Але в контексті slice header це слово описує лише факт: усередині зберігається посилання на початок даних. Вам не потрібно вміти працювати з адресами вручну, щоб правильно розуміти слайси. Достатньо памʼятати, що спільний pointer означає спільні елементи.

Помилка №5: не використовувати len як єдине джерело істини для циклів.
Іноді пишуть цикл за очікуваним розміром, за введеним n, за якоюсь константою і забувають, що слайс уже може мати іншу довжину. Це породжує або вихід за межі, або пропуск елементів. Звичка «цикл завжди йде до len(s)» робить код нудним — а нудний код, як відомо, рідше ламається. І це майже комплімент.

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