1. Embedding розширює публічний API
Спершу embedding здається маленькою перемогою над кількістю літер: менше .Inner., менше «обгортки», менше коду. Але за цю економію доводиться платити: зовнішній тип раптом починає виглядати так, ніби вміє значно більше, ніж уміє насправді. Код стає важче читати й легше неправильно зрозуміти, а все, що читається зусиллям, швидко стає затишним місцем для багів.
Ключова думка лекції така: embedding — це не лише про «зручніше звертатися». Embedding змінює поверхню типу, тобто те, що доступно користувачеві через .. А поверхня типу — це ваш мініконтракт: що цьому типу можна робити.
Давайте закріпимо це на маленькому фрагменті нашого навчального застосунку — трекера задач. Почнімо з простої моделі:
package main
type Task struct {
ID int
Title string
Done bool
}
Поки що все нудно й безпечно. Небезпека починається там, де ми вирішуємо: «Давайте вбудуємо щось корисне».
2. Основні проблеми embedding
Витік внутрішньої залежності
Дуже часто ви пишете структуру «сервіс/застосунок», а всередині вам потрібен помічник — генератор ID, логер, валідатор чи лічильник. У якийсь момент не хочеться писати s.idGen.Next(), і ви робите embedding. І тут починається магія, яку в бойовому коді зазвичай описують так: «Чому це взагалі можна викликати?!».
Зробімо маленький генератор ID:
package main
type IDGen struct {
next int
}
func (g *IDGen) Next() int {
g.next++
return g.next
}
Тепер створімо «проєкт» — контейнер задач — і вбудуємо IDGen, щоб не писати зайве:
package main
type Project struct {
Name string
IDGen // вбудовано
}
Використання виглядає приємно:
package main
import "fmt"
func main() {
p := Project{Name: "Дім"}
fmt.Println(p.Next()) // 1
fmt.Println(p.Next()) // 2
}
І тут виникає запитання, яке має вас трохи насторожити: а чому користувач Project узагалі повинен мати доступ до Next()? Це ж внутрішня механіка видачі ID, а не «функція проєкту».
З embedding ви ніби кажете: «Проєкт уміє Next()». І читач коду починає думати, що це нормальна операція домену. А це вже проблема дизайну, а не синтаксису.
Роздування поверхні типу
Роздування поверхні типу — це коли зовнішній тип отримує десятки методів і полів, які не відповідають його відповідальності. Особливо неприємно це тоді, коли ви вбудовуєте не маленький «value object», а щось важке з великим набором методів.
Покажімо це на своєму типі списку задач. Зробімо тип-обгортку над слайсом — ми вже вміємо робити методи на «слайс-типах»:
package main
type TaskList []Task
func (tl *TaskList) Add(t Task) {
*tl = append(*tl, t)
}
func (tl TaskList) DoneCount() int {
count := 0
for _, t := range tl {
if t.Done {
count++
}
}
return count
}
Тепер «проєкт» зберігає задачі. І ми можемо зробити embedding:
package main
type Project struct {
Name string
TaskList // вбудовано
}
Виглядає мило: p.Add(...), p.DoneCount(). Але в embedding є побічний ефект: назовні «вилазить» не лише поведінка, яку ви хотіли, а й сам факт, що ви зберігаєте задачі саме так — через TaskList. Крім того, з’являється прямий доступ до поля p.TaskList.
Зовні тепер можна зробити ось так:
package main
func main() {
p := Project{Name: "Дім"}
// Зовнішній код може безпосередньо замінити весь список задач.
p.TaskList = TaskList{}
}
Іноді це нормально. Але часто таке рішення ламає ваші майбутні плани: ви, наприклад, захочете зберігати задачі не в слайсі, а в структурі з індексом за ID або тримати інваріанти — «ID унікальні», «порожній Title заборонено». А зовнішній код уже звик, що можна руками копирсатися в TaskList.
Ось це і є класичний приклад прихованої ціни embedding: ви випадково зафіксували внутрішній устрій типу в його публічній поведінці.
Неочевидне походження полів і методів
Коли код читають новачки, та й не лише новачки, відбувається проста річ: людина бачить p.Next() і намагається зрозуміти, де це визначено. Якщо метод зовнішній — усе просто: шукаємо func (Project) Next. Якщо він промоутований (promoted) — треба пам’ятати про embedding, потім знайти вбудований тип, а вже потім — метод у ньому.
Це не катастрофа, але когнітивне навантаження зростає. А що більший цей тиск, то легше помилитися. І це не теоретична естетика: код, який погано читається, справді простіше неправильно супроводжувати.
З погляду підтримки promoted-методи часто стають невидимою магією: код компілюється, але не пояснює сам себе.
Тому просте правило для початківців — і не лише для них: якщо походження методу важливе для розуміння, краще зробити доступ явним.
Порівняйте:
p.TaskList.DoneCount()
і:
p.DoneCount()
Друге коротше, але перше одразу відповідає на запитання: «Це про задачі».
Embedding ховає важливі параметри й змішує відповідальності
Іноді embedding використовують як «контейнер для всього»: «Покладу всередину структуру, і хай вона там живе». Але якщо всередину ховається річ, яку має контролювати код, що викликає метод, ви отримуєте API, у якому вже важко нормально керувати поведінкою.
У Go-спільноті є відома думка: не ховайте те, що за змістом має бути параметром виклику, всередину структури. Це робить поведінку заплутаною й змішує різні часи життя (lifetime) та області відповідальності.
Не будемо заглиблюватися в той конкретний механізм — він у нас з’явиться пізніше в курсі. Але урок із дизайну нам корисний уже зараз: якщо залежність впливає на те, як працює метод, то дуже часто її краще передавати явно — параметром або через контрольоване поле — а не «піднімати» назовні через embedding так, що незрозуміло, звідки вона і хто за неї відповідає.
У термінах простого трекера задач це означає таке: якщо процес створення задач залежить від генератора ID і валідації, це ваші внутрішні деталі. Користувач має бачити метод AddTask(title) — а не мати можливість напряму крутити Next() у генератора.
3. Практичний рефакторинг без embedding
Тепер зробімо «правильну» версію Project. Ми приберемо embedding, сховаємо залежності й відкриємо назовні лише те, що належить до відповідальності проєкту.
Почнімо з нової структури:
package main
type Project struct {
Name string
tasks TaskList // не вбудовано і не експортуємо
idGen IDGen // теж внутрішня частина
}
Одразу видно дві переваги.
По-перше, зовнішній код більше не бачить, що всередині Project задачі зберігаються саме як TaskList. По-друге, зовнішній код не може «погратися» з генератором ID.
Тепер додамо нормальний доменний метод: додати задачу за заголовком.
package main
func (p *Project) AddTask(title string) Task {
t := Task{
ID: p.idGen.Next(),
Title: title,
}
p.tasks.Add(t)
return t
}
Використання стає таким:
package main
import "fmt"
func main() {
var p Project
p.Name = "Дім"
t := p.AddTask("Купити молоко")
fmt.Println(t.ID, t.Title) // 1 Купити молоко
}
Зверніть увагу на ефект: користувач тепер не знає і не повинен знати, як саме ви видаєте ID. Він знає лише одне: «додати задачу». Це і є хороший API.
4. Як безпечно віддавати задачі назовні
Коли ви закриваєте нутрощі, одразу виникає запитання: «Добре, а як показати задачі?» І тут дуже легко знову наступити на ті самі граблі — тільки з іншого боку.
Якщо ви повернете внутрішній слайс як є, користувач зможе змінювати його елементи. А це знову витік внутрішнього стану. Ми вже знаємо про aliasing слайсів і про те, чому «віддати свій буфер назовні» буває небезпечно. Тож зробімо акуратний метод, який повертає копію.
package main
func (p Project) Tasks() []Task {
out := make([]Task, len(p.tasks))
copy(out, p.tasks)
return out
}
Використання:
package main
import "fmt"
func main() {
p := Project{Name: "Дім"}
p.AddTask("Купити молоко")
p.AddTask("Прибрати кімнату")
for _, t := range p.Tasks() {
fmt.Println(t.ID, t.Title)
// 1 Купити молоко
// 2 Прибрати кімнату
}
}
Тепер зовнішній код отримав дані, але не отримав права змінювати внутрішній стан проєкту напряму. Це і є контрольована поверхня типу: ви віддаєте назовні те, що потрібно, і зберігаєте інваріанти в себе.
5. Шпаргалка: коли embedding шкідливий
Іноді хочеться простої шпаргалки, але без перетворення лекції на список на три сторінки. Тому зробімо компактну таблицю, яку корисно тримати в голові, коли рука тягнеться вбудувати черговий тип.
| Ситуація | Що зазвичай відбувається | Чому це погано | Що робити натомість |
|---|---|---|---|
| Вбудовуємо «сервісний» тип (генератор, логер, валідатор) | Методи залежності починають виглядати як методи предметної області | Розмивається зміст, а відповідальності змішуються | Іменоване поле + методи-обгортки |
| Вбудовуємо структуру з великим API | Зовнішній тип «уміє» все, що вміє залежність | Поверхня типу розростається, і його важче супроводжувати | Виділити лише ті вузькі методи, які справді потрібні |
| Вбудовуємо тип, який хочемо змінити в майбутньому | Користувачі починають спиратися на деталі реалізації | Складно змінювати нутрощі без поломок | Сховати поле, видати стабільний API |
| Embedding робить походження методу неочевидним | Читач не розуміє, звідки це взялося | Код важче читати й легше неправильно використовувати | Явний доступ x.inner.M() або явні методи зовнішнього типу |
Ця таблиця не закон, але добрий фільтр: якщо ви впізнали свій випадок, варто зупинитися й ще раз подумати.
6. Мінісхема розширення типу
Іноді простіше один раз побачити очима, ніж десять разів прочитати. Уявімо наш Project у двох варіантах як поверхню методів.
flowchart TD
A["Project (з embedding)"] --> B["Методи Project"]
A --> C["Промоутовані методи TaskList"]
A --> D["Промоутовані методи IDGen"]
E["Project (без embedding)"] --> F["Методи Project (AddTask, Tasks, ...)"]
E --> G["TaskList і IDGen сховані всередині"]
Варіант із embedding майже завжди дає більше точок входу. А більше точок входу — більше способів використати тип неправильно, навіть без злого наміру.
7. Типові помилки
Помилка №1: embedding робиться «бо так коротше».
Коли ви обираєте embedding лише для того, щоб скоротити x.inner.M() до x.M(), ви платите читабельністю й керованістю API. За місяць або за пів року, коли проєкт почне жити власним життям, виявиться, що назовні стирчить купа методів, за які ваш тип узагалі не має відповідати.
Помилка №2: через embedding назовні «витікає» залежність, яку хотілося тримати внутрішньою.
Частий приклад — вбудований генератор ID або технічний помічник. У підсумку користувач типу дістає доступ до методів на кшталт Next(), які не мають сенсу в доменній моделі. Це перетворює внутрішню механіку на публічну обіцянку, а такі обіцянки потім боляче змінювати.
Помилка №3: embedding використовують в експортованому типі без перевірки, що стало доступно через крапку.
Якщо тип публічний, його поверхня — це фактично ваш контракт із зовнішнім кодом. Embedding може роздути цей контракт випадково. А випадкові контракти, як правило, не переживають першої серйозної переробки архітектури.
Помилка №4: видача внутрішнього стану назовні під виглядом зручності.
Коли ви вбудовуєте TaskList і дозволяєте звертатися до p.TaskList напряму, ви спрощуєте життя користувачеві рівно до того моменту, коли вам знадобляться інваріанти, валідація або зміна структури зберігання. Якщо назовні потрібно віддати дані, краще повернути копію — через make + copy — ніж ділитися внутрішнім буфером.
Помилка №5: promoted-методи погіршують читабельність, але це ігнорується.
Код, у якому незрозуміло, звідки взявся метод, важче читати й супроводжувати — особливо в командах і особливо для новачків. У таких місцях краще або писати явну кваліфікацію (p.tasks.DoneCount()), або робити акуратний метод-обгортку на зовнішньому типі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ