1. Вступ
Коли ви тільки починаєте писати на Go, слайси виглядають як динамічний масив: є []int, можна звертатися до s[i], можна проходитися циклом, можна зберігати багато елементів. І все було б добре, якби слайси іноді не поводилися… як живі. То один і той самий слайс раптом видно через дві змінні, то довжина й ємність чомусь різні, то межі індексації не збігаються з тим, що ви очікуєте.
Щоб перестати вгадувати й почати передбачати поведінку, програмісти Go тримають у голові просту модель: слайс — це не самі елементи, а маленька структура-опис, яка вказує, де лежать дані та скільки з них доступно. Цю модель і називають slice header, або заголовком слайса.
Слайс як «ручка» до даних: pointer + len + cap
Зараз важлива думка, яку варто перечитати двічі, особливо якщо ви втомилися: значення слайса []T — це не «контейнер з елементами», а опис вікна, через яке ми дивимося на певний набір елементів у памʼяті.
У цього «опису» (slice header) логічно три частини:
| Частина | Як зрозуміти по-людськи | За що відповідає в поведінці |
|---|---|---|
|
«куди дивимося» | чому два слайси можуть бачити одні й ті самі елементи |
|
«скільки елементів видно» | межі індексації 0..len-1, скільки пройде range |
|
«який запас є далі» | чому у слайса буває ємність більша за довжину |
Можна уявити це так — умовно, не як реальний вивід компілятора:
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)» робить код нудним — а нудний код, як відомо, рідше ламається. І це майже комплімент.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ