1. Вступ
До цього моменту ми жили у світі окремих змінних: a, b, x, name. Це чудовий старт, бо мозок звикає до типів, операцій і if/for. Але минає кілька задач — і з’являється новий клас проблем: потрібно зберігати не одне значення, а багато.
Наприклад: 7 витрат за днями тижня, 10 спроб гравця, 5 оцінок, список чисел до нуля, результати тесту за питаннями. Першою реакцією новачка зазвичай буває: «Ну, ок, заведу кілька змінних».
var d1, d2, d3, d4, d5, d6, d7 int
fmt.Scan(&d1, &d2, &d3, &d4, &d5, &d6, &d7)
І здається, що все нормально — аж до того моменту, коли задача стає трохи цікавішою: «знайди максимум», «порахуй середнє», «виведи у зворотному порядку», «заміни кожен третій елемент», «перевір, чи повторюються значення». Із сімома змінними ви починаєте писати код, який схожий на бухгалтерський звіт на 40 рядків: багато повторів і майже ніякої логіки.
Тут і відбувається перехід від «змінні як окремі коробки» до ідеї: «у мене має бути одна коробка, всередині якої лежить багато однакових значень». І ключ до цієї ідеї вже є: ми вміємо for.
Уявіть полицю з пронумерованими комірками. Ви хочете вміти сказати: «покладіть число в комірку № 3», «прочитайте комірку № 5», «пройдіться по всіх комірках циклом». Ось це і є масив або зріз: дані лежать поруч, а доступ до них відбувається через індекс.
Найприємніше те, що індексація виглядає природно і знайомо за рядками: ми вже зверталися до символів за індексом — ті самі квадратні дужки:
x := week[3] // взяти 4-й елемент
week[3] = 100 // змінити 4-й елемент
І тепер можна показати зручну, «циклову» версію введення: замість семи окремих змінних — один контейнер і один цикл. Спочатку просто подивімося на ідею, не заглиблюючись у типи, щоб відчути сенс:
var week [7]int
for i := 0; i < 7; i++ {
fmt.Scan(&week[i])
}
Ми читаємо 7 чисел і кожне кладемо в комірку з номером i. Це ніби ви вчите програму розкладати значення по полицях — саме цього й треба досягти, коли даних багато.
Далі в лекції ми розберемо два варіанти цієї «полиці» в Go: масив (фіксований розмір) і зріз (розмір може змінюватися). Зовні вони схожі, але поводяться по-різному — і це важливий крок до розуміння Go.
2. Масив [N]T: фіксована довжина — частина типу
Ми домовилися мислити «полицею»: є багато комірок, до кожної можна звернутися за номером, а цикл for уміє пройтися по всіх комірках. Тепер важливо вибрати, яка саме це полиця в Go.
Перший варіант — масив. Це полиця із заздалегідь відомою кількістю комірок: рівно 7 днів тижня, рівно 3 спроби, рівно 12 місяців. У Go це виражається прямо в типі: запис [N]T означає «контейнер на N елементів типу T».
Ключовий момент, який спершу дивує, а потім починає подобатися: число N — це не просто «розмір десь усередині», а частина типу. Тому [3]int і [4]int — різні типи. Мова ніби змушує вас бути чесними: якщо функція чекає рівно 7 значень, це видно одразу, ще до запуску програми.
Далі ми подивимося, як створювати масиви, звертатися до елементів за індексом і що відбувається під час присвоювання — саме там проявляється характер масиву: це справжня «коробка з даними», яка копіюється цілком.
Масив у Go — це «коробка» фіксованого розміру. Причому розмір — не просто значення, а частина типу. Тобто [3]int і [4]int — це два різні типи, як int і string. Спочатку це може трохи дратувати («ну чому не можна просто взяти й…?»), але згодом ви побачите, що мова так спонукає нас писати чесніший код.
Синтаксис масиву виглядає так: [N]T, де N — довжина, а T — тип елементів. Наприклад, [7]int — масив із семи цілих чисел.
Створення масиву та індексація
Індексація масиву стандартна: a[0], a[1], …, a[len(a)-1]. Індекси починаються з нуля — так, програмісти обрали саме такий шлях, і тепер уже пізно відступати.
package main
import "fmt"
func main() {
week := [3]int{10, 20, 30} // створення та ініціалізація масиву з трьох елементів
week[1] = 99
fmt.Println(week) // [10 99 30]
}
Тут важливо відчути просту річ: елементи масиву лежать безпосередньо всередині нього — просто «в коробці».
Копіювання масиву під час присвоювання
Найважливіша особливість масиву — під час присвоювання копіюються усі елементи. Це схоже на копіювання файла: ви отримуєте незалежну копію.
package main
import "fmt"
func main() {
a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println("a:", a) // a: [1 2 3]
fmt.Println("b:", b) // b: [99 2 3]
}
b := a означає: «створи новий масив b і скопіюй у нього всі елементи з a». Тому зміна b не впливає на a.
3. Зріз []T: довжина не є частиною типу, і це інший характер
Зріз — головний робочий інструмент Go для роботи зі списками. Він виглядає як масив, індексується як масив, обходиться як масив… але всередині це не «коробка з елементами», а радше «ручка», яка вказує на дані, розташовані десь іще. Якщо зовсім коротко: зріз — це подання частини масиву, і різні зрізи можуть «дивитися» на одні й ті самі дані.
Тут зазвичай виникає запитання: «Навіщо так складно?». Відповідь прагматична: це дає гнучкість і ефективність, але вимагає дисципліни. Сьогодні ми вчимося базової дисципліни: розуміти копіювання та передавання у функцію.
Літерал зрізу та індексація
Зріз створюється літералом []T{...}:
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
s[0] = 99
fmt.Println(s) // [99 2 3]
}
Поки що все виглядає як масив. Різниця проявиться, коли ми почнемо присвоювати та передавати.
Присвоювання зрізу: копіюється «опис», а не елементи
Коли ви робите t := s, Go не копіює всі елементи. Він копіює «опис зрізу», а самі елементи зазвичай залишаються спільними. Тому зміна елемента через t видна і через s.
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
t := s
t[0] = 99
fmt.Println("s:", s) // s: [99 2 3]
fmt.Println("t:", t) // t: [99 2 3]
}
Якщо масив — це «скопіювали коробку», то зріз — це «скопіювали адресну табличку на складі». Табличок стало дві, але склад той самий.
4. Передавання у функцію: за значенням, але з різними наслідками
Фраза «у Go все передається за значенням» звучить як заклинання, яке ви ще багато разів почуєте. Вона означає: під час виклику функції Go копіює значення аргументу у параметр.
І тут виникає тонкий момент: що є «значенням» масиву і що є «значенням» зрізу?
У масиву значення — це всі його елементи. У зрізу значення — це маленька «структура-опис», яка вказує на елементи десь іще. Тому формально копіювання є в обох випадках, але наслідки виходять різні.
Контрастний приклад: масив «не змінюється», зріз «змінюється»
package main
import "fmt"
func setFirstArray(a [3]int) {
a[0] = 100
}
func setFirstSlice(s []int) {
s[0] = 100
}
func main() {
a := [3]int{1, 2, 3}
s := []int{1, 2, 3}
setFirstArray(a)
setFirstSlice(s)
fmt.Println("array:", a) // array: [1 2 3]
fmt.Println("slice:", s) // slice: [100 2 3]
}
З масивом усе чесно: у setFirstArray прийшла копія масиву, ви змінили копію — оригінал не постраждав.
Із зрізом хитріше: у setFirstSlice прийшла копія опису, але цей опис вказує на ті самі елементи, тому зміна s[0] змінює спільні дані — і це видно ззовні.
5. Мініприклад: «Аналізатор витрат» і дві стратегії
Щоб не розглядати колекції у вакуумі, давайте продовжимо нашу лінію маленьких консольних програм, які щось рахують і друкують. Уявімо, що в нас є мінізастосунок «Аналізатор витрат»: ми хочемо зберігати витрати за днями й порахувати суму.
Є два сценарії.
Перший сценарій: ми рахуємо витрати рівно за 7 днів (тиждень). Це зручно виразити масивом [7]int: розмір фіксований, дані завжди «повні».
Другий сценарій: ми рахуємо витрати за довільну кількість днів — скільки введе користувач. Тут потрібен зріз []int.
Тижневі витрати як масив [7]int
Зробімо функцію для обчислення суми за тиждень. Зверніть увагу: тип параметра — саме [7]int.
package main
import "fmt"
func sumWeek(week [7]int) int {
total := 0
for _, v := range week {
total += v
}
return total
}
func main() {
week := [7]int{100, 0, 50, 20, 10, 0, 70}
fmt.Println(sumWeek(week)) // 250
}
Тут є дуже «go-шна» чесність: функція sumWeek каже: «я працюю тільки з тижнем рівно з 7 елементів». Жодних сюрпризів.
Але й плата за це теж чесна: під час виклику sumWeek(week) масив копіюється цілком (хоча для 7 int це не страшно). У майбутньому, коли дані стануть більшими, це матиме значення.
Довільні витрати як зріз []int
Тепер зробимо майже таку саму функцію, але для зрізу:
package main
import "fmt"
func sumDays(days []int) int {
total := 0
for _, v := range days {
total += v
}
return total
}
func main() {
days := []int{100, 0, 50, 20}
fmt.Println(sumDays(days)) // 170
}
Функція sumDays не вимагає фіксованої довжини: їй підходить список будь-якої довжини.
І важлива деталь: передавання days у функцію копіює тільки «опис зрізу», а не всі елементи. Саме тому зрізи в більшості реальних програм стали колекцією за замовчуванням у Go.
6. Ще одна відмінність: порівняння ==
На цьому етапі легко потрапити в пастку: «Ну, раз зріз схожий на масив, давайте порівняємо два зрізи через ==». І компілятор вам ввічливо, за мірками компіляторів, скаже: «ні».
У Go тут діє просте правило.
Масиви можна порівнювати через ==, якщо їхні елементи теж порівнювані.
Зрізи порівнювати через == не можна, крім порівняння з nil. Але що таке nil-зріз, ми розбиратимемо окремо, спокійно й без паніки — хоча слово «panic» у Go зазвичай з’являється раптово.
package main
import "fmt"
func main() {
a := [2]int{1, 2}
b := [2]int{1, 2}
fmt.Println(a == b) // true
s := []int{1, 2}
_ = s
fmt.Println(s == s) // помилка компіляції: зрізи не можна порівнювати так
}
Чому так? Дуже по-людськи: зріз — це не «значення цілком», а «опис доступу до даних». Порівнювати два описи й вирішувати, що це автоматично означає «дані однакові», надто неоднозначно: порівнювати адресу, довжину, елементи? А як бути з величезними зрізами? Тому Go не бере це на себе й змушує вас явно обирати спосіб порівняння.
7. Закріплення: таблиця та ментальна модель
Іноді корисно на хвилину стати «людиною-таблицею»: не для зубріння, а щоб мозок краще бачив контраст.
Таблиця відмінностей
| Властивість | Масив [N]T | Зріз []T |
|---|---|---|
| Довжина | фіксована і входить у тип | зберігається у значенні, а тип довжину не містить |
| Присвоювання b := a | копіюються всі елементи | копіюється опис, елементи зазвичай спільні |
| Передавання у функцію | копіюється весь масив | копіюється опис, елементи зазвичай спільні |
| Порівняння == | можна (якщо елементи порівнювані) | не можна (крім порівняння з nil) |
| Типовий зміст | «рівно N елементів» | «скільки завгодно елементів» |
«Коробка» проти «ручки»
Щоб це відкладалося не лише в голові, а й у практиці, корисно тримати просту картинку. Масив — це коробка з даними. Зріз — це ручка, яка вказує на дані, а ручок може бути кілька.
flowchart LR
A["масив [3]int"] -->|копіювання| B["копія масиву [3]int"]
A1["зріз []int"] -->|копіювання| B1["копія зрізу []int"]
A1 --> D(("спільні елементи"))
B1 --> D
У верхній частині схеми «копіювання» створює нову коробку. У нижній — створює другу ручку до тих самих елементів.
8. Типові помилки
Помилка № 1: плутати [N]T і []T як «одне й те саме, тільки дужки різні».
Таке враження природне: синтаксис схожий, індексація схожа, range схожий. Але типи різні, і семантика копіювання теж різна. Корисна звичка — вголос промовляти: «[7]int — це тиждень фіксованої довжини», «[]int — список довільної довжини».
Помилка № 2: очікувати, що t := s створює незалежну копію елементів зрізу.
Це часта помилка надто оптимістичного новачка: здається, що раз є присвоювання, то дані теж мають скопіюватися. Із зрізами копіюється лише опис, тому зміни елементів видно через обидві змінні. Якщо вам справді потрібна незалежність, доведеться явно копіювати елементи (але сьогодні ми не йдемо далі — зараз важливо просто зрозуміти, чому взагалі виникає такий ефект).
Помилка № 3: змінювати масив у функції й дивуватися, що зовні нічого не змінилося.
З масивом у параметрі ви працюєте з копією. Це не баг, а контракт. Якщо ви хочете, щоб зміни були видимі ззовні, масив потрібно або повертати з функції як результат, або використовувати інший підхід (але такі деталі ми вводитимемо поступово, щоб не перетворити лекцію на «все про все одразу»).
Помилка № 4: намагатися порівняти два зрізи через == і сперечатися з компілятором.
Компілятор майже завжди виграє суперечку, бо він не втомлюється і в нього немає дедлайнів. Якщо вам потрібне порівняння вмісту, це окреме завдання й окремий, явно обраний спосіб. Зараз достатньо запам’ятати правило: масиви порівнювати можна (за певних умов), зрізи — ні.
Помилка № 5: писати функції «під масив», а потім намагатися підсунути туди зріз.
[7]int і []int — різні типи. Функція sumWeek([7]int) не приймає []int, навіть якщо у зрізі рівно 7 елементів. І це знову чесність Go: типи мають збігатися, інакше ви б постійно гадали, що саме мав на увазі автор функції.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ