1. Вступ
Щойно ви опанували слайси, легко вирішити, що досить усюди використовувати []Item, а операції виконувати звичайними функціями: AddItem(items, x), MarkDone(items, id) і так далі. Так можна — на перших кроках це цілком нормально. Але досить швидко зʼявляється відчуття безладу: функції розростаються, параметри плутаються, а код стає схожим на інструкцію до мікрохвильової печі, з якої випала половина сторінок.
У Go є простий прийом: створити іменований тип поверх слайса — type Items []Item. Тоді ви можете оголошувати методи й отримувати читабельний API: items.Add(...), items.MarkDone(...), items.FindByID(...). Це не якесь «ООП‑магічне перетворення», а просто зручний спосіб організувати код: операції над колекцією живуть поруч і викликаються однаково.
Важливо памʼятати один ключовий факт: методи не можна оголошувати на «чистому» типі []Item, тому що він неіменований. Методи оголошують лише на іменованих типах, визначених у вашому пакеті. У блозі Go про параметри типів є чудовий приклад, де фігурує іменований слайс‑тип MySlice []string і метод String() string, який показує саму ідею «слайс‑типу з методами».
Модель: Item і колекція Items
Щоб приклади були зв’язними, уявімо, що ми продовжуємо міні‑застосунок «список справ» (todo). Раніше в нас був Item (одна задача), і ми могли гарно друкувати його через String(). Тепер ми хочемо «розумний список справ» — не просто []Item, а тип із методами.
Спочатку оголосимо модель. Тут нічого надзвичайного: поля, нульові значення — усе, як ви вже бачили. Залишимо модель простою: ID, Title, Done.
package main
type Item struct {
ID int
Title string
Done bool
}
type Items []Item
Відтепер Items — окремий тип. Його «нутрощі» все ще слайс, але назовні ми можемо видавати акуратні методи, а не розсип утиліт по проєкту.
2. Що копіюється, коли одержувач — Items
Зараз буде трохи «фізики слайсів» — без неї легко наступити на граблі. Слайс — це не «вбудований список об’єктів», а представлення поверх масиву: всередині він зберігає вказівник на дані, довжину та місткість (capacity).
Тому, коли ви пишете метод із одержувачем за значенням, ось так:
func (it Items) Len() int { return len(it) }
копіюється не «весь список задач», а лише заголовок слайса (вказівник + довжина + місткість). Дані (елементи) зазвичай лишаються спільними. Звідси випливають два різні типи змін:
- змінювати елементи (наприклад, it[i].Done = true) часто видно зовні, тому що дані спільні;
- змінювати довжину через append — це вже гра із заголовком: append(it, x) може повернути новий заголовок, і його треба або повернути, або записати через вказівник.
Можна уявити це такою схемою:
flowchart LR
A["Items (одержувач)"] --> H["заголовок: вказівник + довжина + місткість"]
H --> D["базовий масив (дані)"]
B["Items у main"] --> H2["заголовок: вказівник + довжина + місткість"]
H2 --> D
Два різні «заголовки» можуть вказувати на одні й ті самі дані. Саме тому частина операцій нібито змінює все сама, а частина — ні.
3. Методи для читання колекції
Коли ми говоримо про методи колекції, багато хто автоматично думає про Add і Delete. Але починати краще з методів читання: вони майже завжди працюють з одержувачем за значенням і не створюють спірних ситуацій.
Len() — дрібниця, яка підвищує читабельність
Зробити len(items) нескладно. Але items.Len() іноді читається приємніше, особливо коли колекція стає частиною доменної моделі й ви хочете сховати деталі. Це не обов’язковий стиль, але добрий навчальний приклад.
package main
func (it Items) Len() int {
return len(it)
}
FindByID() — типовий пошук у колекції
Списки задач майже завжди шукають елемент за ID. Зробімо метод, який повертає (Item, bool): «знайдено / не знайдено». Поки що ми не ускладнюємо все помилками — для простого шару моделі це нормально.
package main
func (it Items) FindByID(id int) (Item, bool) {
for _, x := range it {
if x.ID == id {
return x, true
}
}
return Item{}, false
}
Тут зручно, що виклик виходить майже як англійське речення:
item, ok := items.FindByID(10)
4. Мутація елементів: чому for _, v := range вас «зраджує»
Зараз ми підходимо до класичної пастки: ви хочете пройтися по Items і змінити поля елементів. Логіка в голові проста: «я ж перебираю елементи списку, отже змінюю елементи списку». Але range по слайсу дає вам копію елемента в змінній v. Це ви вже зустрічали раніше, але тут помилка особливо болюча: код виглядає правильним і навіть компілюється… просто нічого не змінює.
Покажімо неправильний варіант (і так, він справді нічого не зробить):
package main
func (it Items) MarkAllDoneWrong() {
for _, x := range it {
x.Done = true // змінюємо копію, а не елемент у слайсі
}
}
Правильний варіант — змінювати через індекс. Індекс дає доступ до справжнього елемента в базовому масиві (backing array):
package main
func (it Items) MarkAllDone() {
for i := range it {
it[i].Done = true
}
}
У цей момент корисно запам’ятати коротке правило: якщо ви хочете змінювати елементи слайса, найчастіше пишуть for i := range s, а не for _, v := range s. Це не заборона, а практичний рефлекс, який економить години налагодження й кілька нервових клітин.
5. Зміна довжини: append і вибір стилю API
Ось де починається найцікавіше: елементи змінювати можна ніби напряму, а от довжину — ні. Причина в тому, що append повертає новий слайс: іноді він лишається в тому самому масиві, іноді переїжджає в новий, якщо місткість (capacity) закінчилася. Тому результат append потрібно використовувати.
В офіційних поясненнях поведінки функцій над слайсами підкреслюється: якщо операція змінює довжину, вона має повернути новий слайс викликаючому коду — з тієї самої причини, з якої append повертає значення.
Далі у вас є два нормальні підходи. Обидва — у стилі Go; просто обирайте один і не змішуйте без потреби.
Value‑стиль: метод повертає новий Items
Цей стиль нагадує функціональний підхід: ви не намагаєтеся змінити змінну слайса всередині методу, а чесно кажете: «ось нова версія колекції».
package main
func (it Items) Add(x Item) Items {
return append(it, x)
}
Використання:
items = items.Add(Item{ID: 1, Title: "Вивчати Go"})
Якщо забути про присвоювання, ви натрапите на класичне: «чому не додалося?». І це не містика — ви просто проігнорували повернений заголовок.
У статті про стійкі функції над слайсами прямо показано: ігнорувати результат операцій, які повертають новий слайс (зокрема подібних до append і slices.Delete), — помилка. Довжина слайса у старої змінної лишиться попередньою, навіть якщо дані «під капотом» уже зсунулися.
Pointer‑стиль: метод мутує *Items
Цей стиль подобається тим, хто хоче писати items.Add(...) без items = .... Тоді одержувач — це вказівник, і всередині ми маємо записати результат append назад у *it.
package main
func (it *Items) Add(x Item) {
*it = append(*it, x)
}
Використання:
items.Add(Item{ID: 2, Title: "Писати тести"})
Цей підхід особливо зручний, коли у вас багато методів, що змінюють довжину (Add, Delete, Insert), і ви хочете, щоб усі вони працювали однаково: викликали метод — колекція змінюється.
6. Шпаргалка й міні‑приклад: зручний API для todo
Коли студенти губляться, зазвичай плутаються не в синтаксисі, а в природі змін. Слайс одночасно схожий і на значення, і на посилання — звідси й когнітивний дисонанс. Нижче — дуже практична таблиця, яка допомагає тримати все в порядку.
| Що робимо в методі | Приклад | Чи видно це ззовні, якщо одержувач — Items | Що зазвичай обирають |
|---|---|---|---|
| Змінюємо поля елементів | |
Так (дані спільні) | одержувач за значенням Items |
| Переставляємо елементи місцями | |
Так | одержувач за значенням Items |
| Змінюємо довжину (append, видалення) | |
Ні, якщо не повернути або не записати назад | або Items + return, або *Items |
І ще одна корисна річ: іменований слайс‑тип — це не лише про методи. Він допомагає й не загубити їх під час роботи з кодом. В історії з MySlice із блогу про Go проблема була в тому, що функція повертала []string, а не MySlice, і метод String() «зникав». Це хороший мисленнєвий якір: іменований тип живе доти, доки ви не перетворите його назад на «голий» []T.
Тепер зберімо маленький фрагмент, який показує, як це все виглядає в main. Ми не будуємо повноцінний застосунок і не йдемо ні в сховище, ні в CLI — нам важливий саме ефект: API став людяним.
Зробімо два методи: Add (pointer‑стиль) і MarkDone (зміна елемента за індексом).
package main
func (it *Items) Add(x Item) {
*it = append(*it, x)
}
func (it Items) MarkDone(id int) bool {
for i := range it {
if it[i].ID == id {
it[i].Done = true
return true
}
}
return false
}
І маленький main, який цим користується:
package main
import "fmt"
func main() {
items := Items{}
items.Add(Item{ID: 1, Title: "Вивчати Go"})
items.Add(Item{ID: 2, Title: "Пити воду"})
ok := items.MarkDone(2)
fmt.Println("позначено:", ok) // позначено: true
fmt.Println("довжина:", items.Len()) // довжина: 2
fmt.Println(items[1].Done) // true
}
Так, тут ми все ще напряму звертаємося до items[1]. Це нормально: Items — це слайс, індексування працює. Але в хорошому дизайні ви зазвичай поступово піднімаєте рівень абстракції: назовні віддаєте методи (наприклад, items.DoneIDs() або items.ListUndone()), а пряме індексування використовуєте там, де воно справді потрібне.
7. Типові помилки
Помилка № 1: спроба оголосити метод на []Item.
Часто це виглядає так: студент пише func (s []Item) Add(...) і дивується, чому компілятор свариться. Причина проста: []Item — неіменований тип, тож оголошувати на ньому методи не можна. Це виправляється одним кроком: вводимо іменований тип type Items []Item і працюємо через нього.
Помилка № 2: «Додавання не працює», бо проігнорували результат append.
У value‑стилі ви пишете items.Add(x), але не присвоюєте результат назад, і довжина не змінюється. Мозок каже «я ж додав!», а Go відповідає: «Я повернув вам новий слайс, а ви його не взяли». Це саме той клас помилок, про який попереджають на прикладах slices.Delete: ви змінили дані, але не оновили змінну‑заголовок, і слайс лишився зі старою довжиною.
Помилка № 3: спроба змінювати елементи через for _, v := range.
Код компілюється, виглядає логічно, але зміни не зберігаються, тому що v — копія елемента. Для мутацій елементів використовуйте індекс: for i := range it { it[i]... }. Ця помилка особливо підступна: вона не викликає паніки й не дає підказок — просто «нічого не відбувається».
Помилка № 4: змішування стилів API без причини.
Наприклад, Add зроблено в pointer‑стилі (мутує), Delete — у value‑стилі (повертає новий слайс), а ще кілька методів повертають bool, але при цьому «іноді мутують, іноді ні». Той, хто користується вашим типом, починає вгадувати, чи треба писати items = items.Delete(...) чи воно зміниться само. Краще обрати один стиль для методів, що змінюють довжину, і триматися його.
Помилка № 5: втрата методів під час перетворення на []Item.
Іноді ви десь пишете функцію, яка приймає []Item і повертає []Item, і раптом ваш гарний Items втрачає методи, бо ви повернули вже «голий» слайс. Це той самий ефект, що й в історії з MySlice: тип перетворився на []T, і метод String() (або будь‑який інший) більше недоступний.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ