1. Чому композиція часто краща за наслідування
Коли ви щойно опанували наслідування, може здатися, що це універсальний швейцарський ніж: створили class A, успадкували class B: A, перевизначили пару методів — і ось вам «повторне використання». Але після кількох ітерацій ви помічаєте, що ієрархія починає жити власним життям, мов кактус на підвіконні: начебто невеликий, а раптом займає пів кімнати й колеться щоразу, коли до нього торкаєшся.
Проблема не в тому, що наслідування «погане». Воно просто відповідає на дуже конкретне запитання: «X є Y?» (відношення is-a). Якщо відповідь справді «так», наслідування може бути чудовим. Якщо ж порівняння натягнуте, починається архітектурна комедія: Car успадковується від Engine, Report успадковується від Printer, а LibraryCLI раптово успадковується від FileManager (і десь плаче один інженер зі здоровим глуздом).
Композиція відповідає на інше запитання: «X має Y?» (has-a). І в реальних застосунках «має» трапляється частіше, ніж «є».
«is-a» проти «has-a»: швидка перевірка
Якщо ви вагаєтеся, що перед вами — наслідування чи композиція, спробуйте просту перевірку: промовте фразу вголос. Так, буквально вголос. Це корисно: ваш мозок у режимі «озвучування» раптом стає прискіпливішим, ніж компілятор.
Наприклад, «Собака є твариною» звучить нормально — Dog : Animal виглядає природно. А от «Машина є двигуном» звучить дивно — машина має двигун, але не є ним. Те саме в коді: якщо ви наслідуєте «заради зручного доступу до методу» або «щоб не писати те саме», ви дуже швидко приходите до ієрархій, де типи брешуть уже самою своєю назвою.
Невелика таблиця, щоб закріпити інтуїцію:
| Запитання | Якщо відповідь «так» | Якщо відповідь «ні» |
|---|---|---|
| «X є Y?» | наслідування може підійти | ймовірно, потрібна композиція |
| «X має Y?» | композиція підходить майже завжди | можливо, це взагалі різні сутності |
І маленька схема (вона не компілюється, але добре «компілюється» в голові):
flowchart LR
A["Наслідування (is-a)"] --> B["Глибина ієрархії зростає"]
C["Композиція (has-a)"] --> D["Компоненти легко комбінувати"]
B --> E["Часто виникають приведення вниз і складні перевизначення"]
D --> F["Простіше змінювати частини без переписування типів"]
2. Композиція у Swift: компонент у властивості
Композиція у Swift на практиці виглядає дуже просто: один об’єкт зберігає інший об’єкт або значення у властивості. Жодних секретних ключових слів. Усе прозоро: усередині лежить компонент, яким ви користуєтеся.
Почнімо з мінімального й приземленого прикладу: у нашому навчальному CLI-застосунку (умовно назвімо його LibraryCLI; ми поступово робитимемо його розумнішим упродовж курсу) нам потрібен вивід у консоль. Можна було б скрізь писати print(...), але тоді логіка застосунку змішується з введенням і виведенням. Тому ми заведемо невеликий компонент Console.
import Foundation
final class Console {
func write(_ text: String) {
print(text)
}
func read() -> String? {
readLine()
}
}
Тепер інший об’єкт може мати консоль:
import Foundation
final class GreeterApp {
private let console: Console
init(console: Console) {
self.console = console
}
func run() {
console.write("Введіть імʼя:")
let name = console.read() ?? "Незнайомець"
console.write("Привіт, \(name)!") // Привіт, Незнайомцю!
}
}
Тут немає наслідування як такого. GreeterApp не є консоллю. Він просто користується консоллю як компонентом. Це і є композиція у її найпростішому вигляді.
3. Делегування як переадресування роботи
Слово «делегування» часто плутають із «патерном делегата» (де є протокол і weak). Але зараз нам потрібна простіша ідея: об’єкт може делегувати частину роботи внутрішньому компоненту. Тобто: «я сам цього не роблю, у мене є той, хто це робить, а я лише викликаю його».
Це особливо корисно, коли ви хочете залишити зовні простий API, а всередині розкласти реалізацію на зрозумілі шматочки. Наприклад, нехай наш CLI-шар має метод printError(...), але фактично друк виконує Console, а форматування повідомлення бере на себе окремий форматувальник.
Зробімо форматувальник:
import Foundation
struct MessageFormatter {
func error(_ text: String) -> String {
"ПОМИЛКА: \(text)"
}
}
Тепер застосунок делегує форматування форматувальнику та виведення консолі:
import Foundation
final class LibraryCLI {
private let console: Console
private let formatter: MessageFormatter
init(console: Console, formatter: MessageFormatter) {
self.console = console
self.formatter = formatter
}
func printError(_ text: String) {
let line = formatter.error(text)
console.write(line)
}
}
let cli = LibraryCLI(console: Console(), formatter: MessageFormatter())
cli.printError("Файл не знайдено") // ПОМИЛКА: Файл не знайдено
Зверніть увагу на приємний ефект: LibraryCLI виглядає «розумним» зовні (printError), але всередині він делегує відповідальність двом компонентам, і кожен із них маленький і зрозумілий. Це зменшує спокусу робити величезний базовий клас «на всі випадки життя».
4. Приклад: бібліотека і команди з компонентів
Тепер зробімо крок ближче до «справжнього» CLI: заведемо сутність Library, яка зберігає книги. Для простоти книга буде struct, щоб не тягнути за собою посилальну семантику там, де вона не потрібна.
import Foundation
struct Book {
let title: String
}
final class Library {
private(set) var books: [Book] = []
func add(title: String) {
books.append(Book(title: title))
}
}
І ось ключовий момент: наш CLI-клас не успадковується від Library. Він має бібліотеку.
import Foundation
final class LibraryCLI {
private let console: Console
private let library: Library
init(console: Console, library: Library) {
self.console = console
self.library = library
}
func runOnce() {
console.write("Введіть команду:")
let line = console.read() ?? ""
if line.hasPrefix("add ") {
let title = String(line.dropFirst(4))
library.add(title: title)
console.write("Додано: \(title)")
} else if line == "list" {
let titles = library.books.map { $0.title }
console.write("Книги: \(titles)")
} else {
console.write("Невідома команда")
}
}
}
Так, парсер дуже простий, але нам зараз важливий не «ідеальний синтаксис команд», а саме розподіл відповідальності: Library відповідає за зберігання, Console — за введення та виведення, LibraryCLI — за об’єднання сценарію та просту маршрутизацію команд.
Якби ми реалізували це через наслідування, виникла б спокуса: «а давайте зробимо class LibraryCLI: Library». Тоді CLI раптово стане «бібліотекою», що семантично дивно. Ба більше, ви змішаєте дві різні відповідальності в одному об’єкті, і далі будь-яка зміна в зберіганні впливатиме на UI-шар.
5. struct і class у композиції
У Swift композиція залежить від того, який у вас компонент: значення (struct) або посилальний об’єкт (class). Це не просто справа смаку — від цього залежить, як поводиться стан під час копіювання, передавання у функції та зберігання в кількох місцях.
Swift прямо подає struct, enum і кортеж як основний спосіб композиції значень, коли ви збираєте об’єкт із частин і очікуєте семантику значення. Це дуже зручно для форматувальників, налаштувань, невеликих моделей даних і «чистих» утиліт без внутрішнього змінюваного стану.
class більше підходить, коли вам потрібен спільний стан або керована ідентичність об’єкта. Наприклад, Library вище — клас: ми хочемо, щоб у застосунку була одна бібліотека, і щоб зміни в ній були видимі всім, хто на неї дивиться.
Корисна проміжна схема, яку варто тримати в голові:
Компонент-значення (struct) → зручно копіювати, зберігати, передавати, тестувати
Компонент-об’єкт (class) → зручно спільно використовувати стан і керувати життєвим циклом
І невеликий приклад, який показує різницю «на пальцях»:
import Foundation
struct CounterValue {
var value: Int = 0
}
final class CounterRef {
var value: Int = 0
}
var a = CounterValue()
var b = a
b.value += 1
print(a.value) // 0
print(b.value) // 1
let r1 = CounterRef()
let r2 = r1
r2.value += 1
print(r1.value) // 1
print(r2.value) // 1
У композиції це означає: якщо ви поклали всередину об’єкта struct, ви отримуєте вбудовану копію. Якщо поклали class, то отримуєте посилання на спільний об’єкт. Обидва варіанти нормальні, просто їх потрібно обирати свідомо.
6. Ініціалізація: чому з композицією простіше
Є практичний, майже побутовий аргумент на користь композиції: ініціалізація об’єктів часто стає простішою. Коли ви використовуєте наслідування, з’являються правила «спочатку власні збережувані властивості, потім super.init», designated/convenience init та інші нюанси, які легко переплутати на перших порах.
Навіть у специфікації Swift підкреслюється, що правила ініціалізації для класів стають складнішими саме через наслідування і потребу розрізняти, чи делегує ініціалізатор, чи ні. Ми в курсі не перетворюємо це на «теоретичну механіку компілятора», але корисно знати причину: складність виникає не через недосконалість мови, а через природу наслідування.
Композиція в цьому сенсі простіша: ви просто приймаєте компоненти в init і зберігаєте їх у властивостях. Жодних ланцюжків super.
Невеликий приклад «застосунок збирається з деталей»:
import Foundation
final class App {
private let console: Console
private let library: Library
init() {
self.console = Console()
self.library = Library()
}
}
А якщо ви хочете підміняти окремі компоненти (наприклад, інший Console), ви теж робите це природно — але без наслідування.
7. Як не перетворити композицію на порожню обгортку
Композиція іноді спонукає до іншої крайності: ви створюєте об’єкт-обгортку, який повторює весь API внутрішнього компонента «один в один». Виходить така «прозора обгортка», яка нічого не додає, зате збільшує обсяг коду і кількість точок, де можуть з’явитися помилки.
Хороше практичне правило: зовнішній об’єкт має або додавати сенс — наприклад, форматувати, логувати, обмежувати доступ, — або об’єднувати сценарій із кількох кроків. Якщо ви просто зробили class Car { let engine: Engine; func start() { engine.start() } } — це ще нормальна демонстрація делегування. Але якщо ви додали туди ще 30 методів, які просто викликають 30 методів engine, ви фактично винесли API назовні без користі.
Порівняймо два варіанти на маленькому прикладі логування.
Варіант «порожня обгортка» (працює, але сенсу мало):
import Foundation
final class ConsoleLogger {
private let console: Console
init(console: Console) { self.console = console }
func write(_ text: String) {
console.write(text)
}
}
Варіант «обгортка додає сенс»:
import Foundation
struct PrefixFormatter {
let prefix: String
func format(_ text: String) -> String { "\(prefix) \(text)" }
}
final class PrefixedLogger {
private let console: Console
private let formatter: PrefixFormatter
init(console: Console, formatter: PrefixFormatter) {
self.console = console
self.formatter = formatter
}
func info(_ text: String) {
console.write(formatter.format(text))
}
}
let logger = PrefixedLogger(
console: Console(),
formatter: PrefixFormatter(prefix: "[INFO]")
)
logger.info("Старт") // [INFO] Старт
У другому варіанті композиція виправдана: вона створює виразніший, спеціалізований API (info) і ховає деталі форматування та виведення.
8. Композиція і ARC: володіння та цикли посилань
Коли ваші компоненти — класи, ви неминуче повертаєтеся до теми володіння: хто кого утримує сильним посиланням (strong) і чи може виникнути цикл. Це не привід боятися композиції — це лише нагадування, що «вкладений об’єкт» теж є об’єктом і має свій життєвий цикл.
Найчастіший сценарій у композиції виглядає так: «верхній» об’єкт (наприклад, LibraryCLI) сильно утримує компоненти (Console, Library). Це нормально: доки живе застосунок, живуть і компоненти.
Небезпека з’являється, коли компоненти починають зберігати зворотні посилання на власника. Наприклад, якщо Library почне зберігати посилання на LibraryCLI, а LibraryCLI зберігає Library, ви отримаєте цикл утримання. Ми вже говорили про такі цикли на темі ARC, тому зараз важливе лише одне: при композиції корисно ставити собі запитання «у який бік спрямовані посилання?». Якщо вони завжди йдуть «згори вниз» (контейнер → компоненти) — зазвичай усе добре.
Мінісхема «здорової» композиції:
flowchart TD
CLI["LibraryCLI"] --> Console["Console"]
CLI --> Library["Library"]
CLI --> Formatter["MessageFormatter (struct)"]
Тут стрілки спрямовані лише вниз — і це типовий безпечний дизайн.
9. Типові помилки при композиції та делегуванні
Помилка №1: наслідування заради доступу до коду, а не через сенс «is-a».
Дуже спокусливо успадкуватися, щоб «не писати заново» або «отримати методи без додаткових зусиль». Але якщо фраза «X є Y» звучить неприродно, ви майже гарантовано ускладните собі життя: з’являться дивні перевизначення, неочікувані залежності та потреба приводити типи, щоб дістатися до специфічної поведінки.
Помилка №2: об’єкт-комбайн, який знає про все одразу.
Композиція не означає, що потрібно скласти в один клас десять компонентів і керувати ними як диспетчер аеропорту. Якщо ваш LibraryCLI одночасно парсить команди, форматує таблиці, працює з файловою системою і ще «трохи» шифрує дані — це не композиція, а одна велика відповідальність, замаскована під набір маленьких класів.
Помилка №3: делегування без додавання сенсу — «обгортка заради обгортки».
Коли зовнішній об’єкт просто повторює API внутрішнього компонента метод за методом, ви отримуєте зайвий шар, який ускладнює читання та налагодження, але не дає виграшу. Делегування має або додавати сенс — формат, правила, сценарій, — або ховати деталі реалізації.
Помилка №4: випадковий спільний стан через class там, де ви очікували семантику значення, як у struct.
Якщо ви поклали всередину компонент-клас і передали його в два місця, ви розділили стан. Іноді це правильно (одна бібліотека на весь застосунок), але іноді стає джерелом «примарних» багів: ви змінили значення в одному місці, а воно раптом змінилося в іншому. Це не магія, це посилання.
Помилка №5: цикли утримання між компонентами.
Композиція зазвичай безпечна, доки залежності спрямовані згори вниз. Але якщо ви починаєте давати компонентам зворотні посилання на власника, легко утворити цикл і дивуватися, чому deinit не викликається. Тут допомагає дисципліна напрямку посилань і акуратне проєктування відповідальностей, яке не змушує компоненти «знати про верхній світ».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ