JavaRush /Курси /Go SELF /Коли embedding шкідливий

Коли embedding шкідливий

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

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()), або робити акуратний метод-обгортку на зовнішньому типі.

1
Опитування
Вбудовування і композиція, рівень 23, лекція 4
Недоступний
Вбудовування і композиція
Вбудовування і композиція
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ