Зв'язки між об'єктами: композиція та агрегація
Чому самодостатні класи не вистачає
На попередніх етапах ми познайомилися з фундаментальними механізмами об'єктно-орієнтованого програмування: інкапсуляцією даних, конструкторами та деструкторами, перевантаженням операторів. Ми навчилися створювати автономні класи, які зберігають стан і надають методи для його модифікації. Проте реальні програмні системи рідко складаються з ізольованих класів-одинаків.
Уявіть собі завдання моделювання автомобіля в симуляційній програмі. Ви могли б створити монолітний клас Car, який містить сотні полів: параметри двигуна (enginePower, fuelType, cylinderCount), характеристики коробки передач (gearCount, transmissionType), властивості кожного колеса (tirePressure, wheelDiameter) і навіть дані про систему гальмування. Результатом стане клас-велетень на тисячі рядків, у якому важко орієнтуватися, тестувати окремі підсистеми та повторно використовувати код в інших проєктах.
Архітектурна проблема полягає не в тому, що ми можемо вмістити всю логіку в один клас, а в тому, що не повинні цього робити. Компілятор C++ не заборонить вам написати Car::calculateEngineTorque(), Car::simulateTransmissionShift() та Car::applyBrakingForce() у одному файлі, але така структура порушує фундаментальний принцип декомпозиції: один клас має виконувати одну добре визначену роль.
Саме для розв'язання цієї проблеми індустрія програмування виробила набір структурних шаблонів взаємодії між об'єктами. У реальному світі складні сутності природно розкладаються на дрібніші компоненти: автомобіль складається з двигуна, кузова та коліс; університет володіє факультетами; підприємство наймає працівників. Кожен із цих зв'язків має власну семантику життєвого циклу та відповідальності за керування ресурсами. Наша задача — навчитися точно відображати ці відносини в коді C++.
QMainWindow takes ownership of the widget», це сигнал: використовується композиція, і вам не потрібно вручну викликати delete для цього віджета. Якщо ж сказано «stores a pointer to external data», це агрегація — вам необхідно самостійно керувати життєвим циклом зовнішнього об'єкта.Ієрархія взаємодій між об'єктами
Перед тим, як заглибитися в технічну реалізацію, важливо усвідомити загальну карту типів зв'язків. У теорії об'єктно-орієнтованого проєктування прийнято виділяти чотири основних типи відносин між класами, кожен із яких відображає різну силу зв'язку та різну відповідальність за життєвий цикл об'єктів.
Композиція (Composition)
Це найсильніший тип зв'язку, що моделює відношення «ціле-частина» (part-of). Частина не може існувати без цілого, створюється разом із ним і знищується у момент знищення контейнера. Класичний приклад: серце є частиною тіла людини. Якщо тіло перестає існувати, серце теж припиняє функціонування. У програмуванні такі відносини часто реалізуються через зберігання об'єктів-частин за значенням як полів класу-контейнера.
Приклади непротирічних взаємозв'язків композиції:
- Будинок ↔ Кімната (
House↔Room): Кімната є фізичним внутрішнім об'ємом будівлі та не може існувати «сама по собі у просторі». Знесення будинку безумовно знищує всі його кімнати, а одна конкретна кімната не може належати двом різним спорудам одночасно. - Шахова дошка ↔ Клітинка (
ChessBoard↔Square): Клітинка (наприклад,E4) має зміст лише як координатна частина шахівниці. Вона не може існувати поза дошкою чи бути перенесена на іншу дошку як автономний об'єкт. - Замовлення ↔ Рядок замовлення (
Order↔OrderItem): Позиція в чеку («3 × кава по 60 грн») має сенс виключно всередині конкретного замовлення. Вона не може належати іншому чеку та ліквідується разом із видаленням замовлення. - Текстовий документ ↔ Розділ або абзац (
Document↔Paragraph): Абзац як структурна складова тексту створюється всередині документа, належить виключно йому і вивантажується з оперативної пам'яті при знищенні документа. - Математичний дріб ↔ Чисельник і знаменник (
Fraction↔numerator,denominator): Числові поля безпосередньо утворюють сутність дробу та не мають власного незалежного життєвого циклу.
Fraction не може існувати без numerator та denominator — це чиста композиція.Агрегація (Aggregation)
Це слабша форма зв'язку, що моделює відношення «володіє/містить» (has-a), але без жорсткого контролю життєвого циклу. Об'єкт-контейнер знає про свої частини та може з ними взаємодіяти, але не відповідає за їх створення чи знищення. Приклад: університетський факультет має викладачів. Викладачі можуть існувати до створення факультету, можуть переходити на інші факультети, а після розформування факультету продовжують працювати в університеті. У C++ агрегація часто реалізується через покажчики або посилання на зовнішні об'єкти.
Приклади непротирічних взаємозв'язків агрегації:
- Музичний плейлист ↔ Аудіотрек (
Playlist↔Song): Аудіофайл пісні автономно зберігається на накопичувачі або у спільній медіатеці, а плейлист лише посилається на нього. Один трек може одночасно належати десяткам плейлистів, а видалення списку відтворення не видаляє сам аудіофайл. - Спортивна команда ↔ Гравець (
Team↔Player): Спортсмен існує як автономна особистість до укладання контракту з клубом. Він може виступати за національну збірну або перейти в інший клуб, а розформування команди не припиняє життя чи кар'єри гравця. - Університетська кафедра ↔ Викладач (
Department↔Teacher): Викладач приймається на роботу ззовні, може суміщати ставки на кількох кафедрах, а реорганізація чи закриття кафедри не призводить до його фізичного знищення. - Аеропорт ↔ Літак (
Airport↔Airplane): Літак прибуває до аеропорту, тимчасово обслуговується і відлітає за іншим маршрутом. Аеропорт не конструює літак і не знищує його під час закриття термінала на ремонт. - Текстовий редактор ↔ Файл на диску (
TextEditor↔File): Програма-редактор лише відкриває зовнішній файл в ОС та утримує його дескриптор. Закриття вікна програми не призводить до видалення файлу з файлової системи.
Асоціація (Association)
Відношення «використовує» (uses-a) між об'єктами, які взаємодіють як рівноправні партнери без концепції володіння. Наприклад, пацієнт відвідує лікаря: обидва існують незалежно, можуть мати багато партнерів одночасно. Асоціація може бути односпрямованою (клас А знає про B, але B не знає про А) або двоспрямованою (bidirectional).
Залежність (Dependency)
Найслабший тип зв'язку: об'єкт тимчасово використовує інший об'єкт для виконання конкретної операції, але не зберігає на нього посилання. Типовий приклад: метод приймає аргумент або створює локальну змінну іншого класу. Залежність не впливає на життєвий цикл і не створює постійного зв'язку в пам'яті.
🔗 Композиція (Composition)
Приклад: Будинок ↔ Кімната, Шахівниця ↔ Клітинка
Реалізація: поля за значенням
UML: зафарбований ромб
◆🔗 Агрегація (Aggregation)
Приклад: Плейлист ↔ Треки, Команда ↔ Гравці
Реалізація: покажчики/посилання
UML: порожній ромб
◇У цій статті ми детально розглянемо перші два типи зв'язків — композицію та агрегацію, оскільки саме вони визначають структуру класів із вкладеними об'єктами та правила керування ресурсами. Асоціацію та залежність ми вивчимо в наступних лекціях, коли перейдемо до проєктування складніших систем взаємодії.
Композиція: нерозривний життєвий цикл частин і цілого
Композиція (composition) — це структурний шаблон, у якому складний об'єкт-контейнер (ціле, whole) повністю відповідає за існування своїх складових частин (parts). Частина створюється у момент конструювання цілого і знищується у момент його деструкції. Між двома об'єктами встановлюється відношення суворого володіння: частина належить рівно одному контейнеру і не може існувати поза його межами.
Формальні ознаки композиції
Для того, щоб відношення між класами кваліфікувалося як композиція, необхідно дотримання чотирьох умов:
- Частина є невід'ємною складовою цілого: Поле-член класу безпосередньо представляє сутність, без якої ціле втрачає сенс. Наприклад, дріб (
Fraction) не може існувати без чисельника і знаменника. - Частина може належати лише одному об'єкту за раз: Якщо
m_numeratorє полем об'єктаfraction1, то цей самий екземплярm_numeratorне може одночасно бути частиноюfraction2. Кожен об'єкт класу володіє власними копіями своїх полів. - Існування частини керується об'єктом: Контейнер має повний контроль над життєвим циклом частини. Якщо частина створюється динамічно (через
new), контейнер зобов'язаний звільнити пам'ять у своєму деструкторі. - Частина не знає про існування цілого: Поле
m_numeratorє звичайним цілим числом (int) і не має жодної інформації про класFraction, до якого воно належить. Це односпрямовані відносини: клас знає про свої поля, але поля не знають про клас.
Реалізація композиції: об'єкти за значенням
Найпростіший і найбезпечніший спосіб реалізації композиції в C++ — це оголошення полів класу за значенням, а не через покажчики. Розглянемо класичний приклад класу Fraction, який складається з двох цілочисельних частин:
class Fraction
{
private:
int numerator;
int denominator;
public:
Fraction(int num = 0, int den = 1)
: numerator(num), denominator(den)
{
if (denominator == 0) {
denominator = 1; // Захист від ділення на нуль
}
}
void print() const
{
std::cout << numerator << "/" << denominator;
}
};
Проаналізуємо, як цей клас задовольняє всім критеріям композиції:
- Невід'ємність: Дріб
Fractionне може існувати без чисельника і знаменника. Ці два поля визначають сутність об'єкта. - Унікальне володіння: Кожен об'єкт
Fraction f(3, 5);створює власну пару{numerator=3, denominator=5}. Якщо ми створюємо другий об'єктFraction g(1, 2);, він отримує власні, незалежні поля. - Керований життєвий цикл: Поля
numeratorіdenominatorавтоматично конструюються разом із об'єктомFraction(через список ініціалізації) і автоматично знищуються, коли об'єкт виходить із області видимості. - Односпрямованість: Ціле число
numeratorне має інформації про те, що воно є частиною дробу. Це просто примітивне значення типуint.
delete, відсутній ризик висячих покажчиків (dangling pointers). Це найбезпечніша та найрекомендованіша форма композиції у сучасному C++.Композиція користувацьких класів: зв'язок «Людина — Серце»
Приклад із дробом Fraction ілюструє композицію з примітивними числовими типами (int). Проте в об'єктно-орієнтованому програмуванні композиція найчастіше застосовується для поєднання повноцінних користувацьких класів.
Класичний приклад із реального світу — Людина та її Серце (Human ↔ Heart). Біологічне серце формується разом із організмом, б'ється впродовж усього життя людини та не може функціонувати як автономна сутність поза тілом.
Зверніть особливу увагу на архітектурне правило, якого часто не дотримуються новачки: контейнер зобов'язаний сам конструювати свої складові. Якщо в конструктор класу Human передавати вже готовий об'єкт Heart ззовні, це спотворює семантику композиції, адже створюється хибне враження, що серце існувало автономно до появи людини. При істинній композиції конструктор приймає лише параметри конфігурації (наприклад, базовий пульс), а сам екземпляр Heart конструюється безпосередньо у списку ініціалізації:
#include <iostream>
#include <string>
class Heart
{
private:
int beatsPerMinute;
public:
Heart(int bpm = 70)
: beatsPerMinute(bpm)
{
std::cout << "Серце сформовано (пульс: " << beatsPerMinute << " уд/хв)\n";
}
~Heart()
{
std::cout << "Серце зупинилося\n";
}
void beat() const
{
std::cout << "Серце б'ється з частотою " << beatsPerMinute << " уд/хв\n";
}
};
class Human
{
private:
std::string name;
Heart heart; // Композиція за значенням: серце фізично вбудоване в об'єкт Human
public:
// Конструктор приймає примітивні характеристики, а НЕ готовий об'єкт Heart!
// Human САМ відповідає за створення свого підоб'єкта:
Human(const std::string& personName, int normalBpm = 72)
: name(personName), heart(normalBpm)
{
std::cout << "Людина " << name << " народилася\n";
}
~Human()
{
std::cout << "Людина " << name << " завершила життєвий шлях\n";
}
void live() const
{
std::cout << name << " живе активним життям: ";
heart.beat(); // Делегування виконання підоб'єкту
}
};
Використання в програмі наочно демонструє нерозривність життєвих циклів:
int main()
{
{
Human ivan("Іван", 75);
ivan.live();
} // При виході зі scope ivan знищується, автоматично знищуючи і своє серце
return 0;
}
Черговість подій підкреслює семантику володіння:
- Спочатку конструюється підоб'єкт
heart(поля завжди ініціалізуються до початку виконання тіла конструктораHuman). - Потім виконується конструктор
Human. - При виході зі scope порядок суворо протилежний: спершу викликається деструктор
Human, а після нього автоматично запускається деструктор поляheart. Жоден зовнішній код не має можливості втрутитися в цей процес або викликати витік пам'яті.
Будинок та Кімнати: фіксована композиція кількох складових
Ще один непротирічний приклад — Будинок та Кімнати (House ↔ Room). Будинок не існує без своїх приміщень, а кімната є невіддільною просторовою частиною будівлі. Будинок самостійно конструює кімнати у власному списку ініціалізації:
class Room
{
private:
std::string title;
double areaSquareMeters;
public:
Room(const std::string& name, double area)
: title(name), areaSquareMeters(area)
{
}
void printInfo() const
{
std::cout << " - " << title << " (" << areaSquareMeters << " м²)\n";
}
};
class House
{
private:
std::string streetAddress;
Room livingRoom; // Перша обов'язкова частина
Room bedroom; // Друга обов'язкова частина
public:
// Конструктор будинку приймає площі, а самі кімнати конструює самостійно
House(const std::string& address, double livingArea, double bedArea)
: streetAddress(address),
livingRoom("Вітальня", livingArea),
bedroom("Спальня", bedArea)
{
}
void showPlan() const
{
std::cout << "План будинку за адресою: " << streetAddress << "\n";
livingRoom.printInfo();
bedroom.printInfo();
}
};
Цей приклад ілюструє, що ціле може складатись одночасно з кількох підоб'єктів одного типу (Room), кожним із яких воно одноосібно володіє.
Замовлення та Рядки замовлення: композиція з динамічною кількістю частин
Що робити, коли кількість складових заздалегідь невідома? Наприклад, Замовлення в інтернет-магазині (Order) може містити довільну кількість позицій (OrderItem).
Навіть при використанні динамічного контейнера std::vector композиція зберігається за умови, що замовлення самостійно створює свої рядки через метод addItem, який приймає примітивні дані товару й конструює об'єкт безпосередньо у внутрішньому буфері вектора за допомогою emplace_back:
#include <vector>
class OrderItem
{
private:
std::string itemName;
int quantity;
double unitPrice;
public:
OrderItem(const std::string& name, int qty, double price)
: itemName(name), quantity(qty), unitPrice(price)
{
}
double getTotalCost() const { return quantity * unitPrice; }
void print() const
{
std::cout << " • " << itemName << " x" << quantity
<< " = " << getTotalCost() << " грн\n";
}
};
class Order
{
private:
int orderNumber;
std::vector<OrderItem> items; // Композиція: вектор володіє екземплярами OrderItem
public:
Order(int id) : orderNumber(id) {}
// Метод додавання приймає характеристики товару, а НЕ готовий об'єкт!
// Замовлення САМО створює рядок OrderItem всередині свого вектора:
void addItem(const std::string& name, int qty, double price)
{
items.emplace_back(name, qty, price);
}
void printReceipt() const
{
std::cout << "Замовлення #" << orderNumber << ":\n";
double total = 0.0;
for (const auto& item : items) {
item.print();
total += item.getTotalCost();
}
std::cout << "Загальна сума: " << total << " грн\n";
}
};
Знищення об'єкта Order автоматично очищує вектор items, викликаючи деструктори для кожного OrderItem. Жоден рядок замовлення не може залишитися в пам'яті чи бути переданим іншому замовленню.
Композиція із динамічною пам'яттю: відповідальність класу
У деяких випадках частини об'єкта необхідно створювати динамічно — наприклад, коли розмір даних невідомий на момент компіляції або коли частина є великим об'єктом, передача якого за значенням неефективна. У таких ситуаціях композиція все одно зберігає повний контроль над життєвим циклом, але реалізується через покажчики-члени класу із ручним керуванням пам'яттю.
Розглянемо клас IntArray, який інкапсулює динамічний масив цілих чисел. Масив є невід'ємною частиною об'єкта і не може існувати без контейнера:
class IntArray
{
private:
int* data; // Покажчик на динамічний масив (частина об'єкта)
int length; // Розмір масиву
public:
// Конструктор виділяє пам'ять для масиву (створює частину)
IntArray(int size)
: length(size)
{
data = new int[length]{}; // Ініціалізація нулями
}
// Деструктор звільняє пам'ять (знищує частину)
~IntArray()
{
delete[] data;
data = nullptr;
}
// Оператор індексації для доступу до елементів
int& operator[](int index)
{
return data[index];
}
int getLength() const { return length; }
};
Аналіз реалізації з погляду композиції:
- Створення частини: Конструктор класу
IntArrayвикликаєnew int[length]— це означає, що клас відповідає за створення масиву. Користувач класу лише передає розмір, але не бере участі у виділенні пам'яті. - Знищення частини: Деструктор
~IntArray()викликаєdelete[] data— це гарантує, що пам'ять звільниться автоматично, коли об'єктIntArrayвийде зі scope або буде видалений черезdelete. - Приховування деталей: Зовнішній код не має доступу до покажчика
data(він приватний). Користувач взаємодіє з масивом виключно через публічний інтерфейсoperator[]іgetLength().
Приклад використання:
int main()
{
IntArray numbers(5); // Конструктор виділяє масив на 5 елементів
numbers[0] = 10;
numbers[1] = 20;
std::cout << "First element: " << numbers[0] << "\n";
// Деструктор IntArray автоматично викличе delete[] при виході зі scope
return 0;
}
При виході з функції main() об'єкт numbers знищується, що автоматично запускає деструктор ~IntArray(), який звільняє динамічний масив data. Користувач класу не бачить цього процесу і не повинен турбуватися про ручне звільнення пам'яті — це є суттю ідіоми RAII (Resource Acquisition Is Initialization), яку ми вивчали раніше.
Моделювання реальних систем через композицію
Композиція особливо ефективна для моделювання складних об'єктів, що природно розкладаються на дрібніші підсистеми. Повернімося до прикладу симуляції істоти у грі, яка переміщується по двовимірній карті. Замість того, щоб зберігати координати x і y безпосередньо в класі Creature, ми можемо інкапсулювати концепцію «точка у просторі» в окремий клас Point2D і вбудувати його в Creature через композицію.
Клас Point2D — інкапсуляція координат
class Point2D
{
private:
int x;
int y;
public:
// Конструктор за замовчуванням — початок координат (0, 0)
Point2D()
: x(0), y(0)
{
}
// Параметризований конструктор для заданої точки
Point2D(int xCoord, int yCoord)
: x(xCoord), y(yCoord)
{
}
// Метод для переміщення точки на нову позицію
void setPoint(int newX, int newY)
{
x = newX;
y = newY;
}
// Перевантаження оператора виводу для зручного друку
friend std::ostream& operator<<(std::ostream& out, const Point2D& point)
{
out << "(" << point.x << ", " << point.y << ")";
return out;
}
};
Клас Point2D повністю автономний: він інкапсулює поведінку двовимірної точки, не знаючи нічого про те, хто його використовує. Це дозволяє повторно використовувати його в різних контекстах — для позицій істот, координат будівель, точок на карті тощо.
Клас Creature — композиція з Point2D
class Creature
{
private:
std::string name;
Point2D location; // Композиція: location є частиною Creature
public:
// Конструктор приймає координати, а НЕ готовий об'єкт Point2D!
// Creature самостійно конструює свою точку location у списку ініціалізації:
Creature(const std::string& creatureName, int startX = 0, int startY = 0)
: name(creatureName), location(startX, startY)
{
}
// Делегування переміщення підоб'єкту Point2D
void moveTo(int x, int y)
{
location.setPoint(x, y);
}
// Вивід інформації про істоту разом із її координатами
friend std::ostream& operator<<(std::ostream& out, const Creature& creature)
{
out << creature.name << " is at " << creature.location;
return out;
}
};
Проаналізуємо відносини:
- Створення частини всередині контейнера: Конструктор
Creatureприймає примітивні координатиint startX, int startY, а не готовий об'єктPoint2D. Об'єктCreatureсамостійно ініціалізує свою внутрішню точку у списку ініціалізації (location(startX, startY)). Це фундаментальне правило композиції: якщо конструктор вимагатиме готовий підоб'єкт ззовні (Creature(name, Point2D pos)), це виглядає як впровадження зовнішньої залежності. При істинній композиції зовнішній код навіть не зобов'язаний створювати екземпляриPoint2Dсамостійно. - Частина належить цілому: Поле
locationоголошено за значенням, а не через покажчик. Це означає, що об'єктPoint2Dфізично вбудований у структуру пам'яті об'єктаCreature. Коли створюєтьсяCreature, одночасно конструюється і йогоlocation. - Тривалість життя частини залежить від цілого: Коли об'єкт
Creatureзнищується, автоматично викликається деструкторPoint2Dдля поляlocation. Ніяка зовнішня логіка не потрібна. - Делегування обов'язків: Метод
moveTo()класуCreatureне реалізує логіку переміщення самостійно — він делегує цю роботу методуsetPoint()підоб'єктаPoint2D. Це приклад композиційного підходу до розподілу відповідальності: кожен клас робить те, що він знає найкраще.
Використання у програмі
int main()
{
std::cout << "Enter a name for your creature: ";
std::string name;
std::cin >> name;
// Створюємо істоту на початковій позиції (5, 6)
// Зверніть увагу: ми передаємо координати (5, 6), а не готовий об'єкт Point2D
Creature creature(name, 5, 6);
std::cout << creature << "\n";
// Переміщуємо істоту на нову позицію (10, 15)
creature.moveTo(10, 15);
std::cout << "After moving: " << creature << "\n";
return 0;
}
Архітектурні переваги композиції
Чому варто виділяти Point2D в окремий клас замість того, щоб просто додати поля int x, y безпосередньо в Creature? Це питання має фундаментальне значення для розуміння принципів проєктування:
Перевага №1: Модульність і спрощення розуміння
Кожен клас зосереджений на одній відповідальності (Single Responsibility Principle). Клас Point2D знає все про координати і операції з ними (відстань, зсув, обертання), а клас Creature зосереджений на високорівневій логіці істоти (здоров'я, інвентар, поведінка). Розробник, який хоче зрозуміти систему координат, може відкрити лише Point2D.h і побачити все необхідне в одному місці.
Перевага №2: Повторне використання коду (Reusability)
Якщо завтра ви захочете додати клас Building, який також має координати, ви просто використаєте той самий клас Point2D:
class Building
{
private:
std::string type;
Point2D position; // Повторне використання вже готового класу
public:
Building(const std::string& buildingType, int posX, int posY)
: type(buildingType), position(posX, posY) {}
};
Без Point2D вам довелося б дублювати код роботи з координатами в кожному класі.
Перевага №3: Легкість розширення функціональності
Припустімо, ви вирішили додати обчислення відстані між двома точками або перевірку, чи лежить точка в певному прямокутнику. Ви просто додаєте ці методи в клас Point2D, і всі класи, що використовують його (і Creature, і Building), автоматично отримують доступ до нової функціональності без жодних змін у своєму коді.
UML-діаграма композиції: зафарбований ромб
Уніфікована мова моделювання (Unified Modeling Language, UML) використовує графічну нотацію для представлення відносин між класами. Композиція позначається зафарбованим (чорним) ромбом ◆ на стороні класу-контейнера, від якого веде суцільна лінія до класу-частини. Цей символ візуально підкреслює міцність зв'язку: ромб «прив'язує» частину до цілого, вказуючи на нерозривність життєвих циклів.
Інтерпретація діаграми:
- Зафарбований ромб на стороні
Creature: вказує, щоCreatureє «власником» частиниPoint2D. - Множинність «1»: за замовчуванням одна істота має рівно одну точку локації. Якби істота мала список точок маршруту, ми б вказали
1..*або0..*. - Напрямок залежності: стрілка йде від цілого до частини, що відповідає односпрямованості відносин у коді (клас
Creatureзнає проPoint2D, алеPoint2Dне знає проCreature).
Point2D існує незалежно, і Creature лише тримає на нього посилання. Ромб є критично важливим символом для розрізнення цих двох типів відносин.Розташування об'єктів у пам'яті: монолітний блок
Одна з ключових характеристик композиції — це фізична близькість у пам'яті. Коли ви оголошуєте поле за значенням (Point2D location;), компілятор вбудовує байти під-об'єкта безпосередньо у структуру батьківського об'єкта. Це означає, що Creature і його location розміщені в єдиному неперервному блоці пам'яті.
Структура об'єкта Creature у пам'яті
| Name | Type | Value |
|---|---|---|
| ◢name | std::string | |
| ◢location.x | int | |
| ◢location.y | int | 15 |
| Name | Type | Value |
|---|---|---|
| ◢departmentName | std::string | |
| ◢manager | Worker* | |
| ◢[manager->name] | std::string (в купі) | "Alice" |
Наслідки цієї різниці:
Непряма адресація: Доступ до engineering.manager->getName() вимагає двох операцій звернення до пам'яті: спочатку прочитати покажчик manager зі структури Department, потім перейти за цим покажчиком до об'єкта Worker у купі. У композиції достатньо одного звернення з фіксованим зміщенням.
Фрагментація пам'яті: Об'єкти розкидані по різних областях пам'яті, що погіршує кеш-локальність і може сповільнити обробку великих колекцій.
Гнучкість розміщення: Об'єкт Worker може бути створений у будь-який момент у будь-якому місці програми і переданий у кілька різних контейнерів одночасно (кілька відділів можуть посилатися на одного працівника).
Вибір між композицією та агрегацією: інженерні критерії
Новачки часто запитують: «Коли використовувати композицію, а коли агрегацію?» Відповідь залежить від семантики предметної області та вимог до життєвого циклу. Ось набір практичних критеріїв для прийняття рішення:
Використовуйте композицію, якщо:
Критерій №1: Частина не має сенсу поза контекстом цілого
Приклад: Чисельник і знаменник дробу (Fraction). Поняття «чисельник сам по собі» не має сенсу — він завжди є частиною конкретного дробу.
Критерій №2: Тривалість життя частини повністю визначається цілим
Приклад: Динамічний масив усередині класу IntArray. Масив створюється конструктором і знищується деструктором. Користувач класу не бачить і не контролює цю пам'ять.
Критерій №3: Необхідна висока продуктивність і кеш-локальність
Приклад: Ігрові об'єкти, що обробляються в тісних циклах (частинки, снаряди, ефекти). Зберігання координат і швидкості за значенням дозволяє процесору завантажити всі дані об'єкта за одне звернення до пам'яті.
Критерій №4: Ви хочете уникнути ручного керування пам'яттю
Композиція за значенням не вимагає викликів new/delete, що робить код безпечнішим і простішим для розуміння.
Використовуйте агрегацію, якщо:
Критерій №1: Частина може існувати незалежно від контейнера
Приклад: Працівник і відділ компанії. Працівник наймається до того, як його призначають у відділ, і продовжує існувати після розформування відділу.
Критерій №2: Частина може належати кільком контейнерам одночасно
Приклад: Студент, що відвідує кілька курсів. Об'єкт Student може зберігатися у списках різних об'єктів Course, але сам студент — єдиний екземпляр у системі.
Критерій №3: Частина створюється та керується зовнішньою підсистемою
Приклад: Система управління ресурсами, де текстури та моделі завантажуються центральним менеджером ресурсів (ResourceManager), а ігрові об'єкти (GameObject) лише зберігають покажчики на ці ресурси.
Критерій №4: Необхідна динамічна заміна частин під час виконання
Приклад: Система плагінів, де основна програма тримає покажчики на інтерфейси плагінів, які можуть завантажуватися та вивантажуватися в рантаймі.
new і зберігаєте покажчик, це схоже на агрегацію. Якщо оголошуєте поле за значенням (Type member;), це композиція. Але остаточне рішення залежить від семантики, а не лише від синтаксису.Змішана модель: композиція і агрегація в одному класі
У реальних системах часто зустрічаються класи, які використовують обидва типи відносин одночасно. Наприклад, клас університетського факультету може мати:
- Композиція: Назва факультету (
std::string name), адміністративні дані, бюджет — все це створюється разом із факультетом і знищується разом із ним. - Агрегація: Список викладачів (
std::vector<Teacher*> staff), які існують незалежно від факультету і можуть викладати на кількох факультетах.
class Faculty
{
private:
std::string name; // Композиція: невід'ємна частина факультету
int foundationYear; // Композиція
std::vector<Teacher*> staff; // Агрегація: покажчики на зовнішніх викладачів
public:
Faculty(const std::string& facultyName, int year)
: name(facultyName), foundationYear(year)
{
}
~Faculty()
{
// Композиція: name і foundationYear знищуються автоматично
// Агрегація: НЕ викликаємо delete для викладачів!
staff.clear(); // Очищуємо вектор покажчиків, але не знищуємо самих викладачів
}
void addTeacher(Teacher* teacher)
{
staff.push_back(teacher);
}
void printInfo() const
{
std::cout << "Faculty: " << name << " (founded " << foundationYear << ")\n";
std::cout << "Staff: " << staff.size() << " teachers\n";
}
};
Така змішана модель є нормою в промисловому коді: кожне поле класу обирається за принципом «що найкраще відповідає його природі». Немає потреби штучно використовувати лише один тип відносин для всього класу.
Альтернатива сирим покажчикам: std::reference_wrapper
У попередніх прикладах агрегації ми використовували сирі покажчики (Worker*), щоб зберегти посилання на зовнішній об'єкт. Проте сирі покажчики мають кілька недоліків: вони можуть бути nullptr, вимагають ручних перевірок валідності та не захищають від висячих покажчиків. У сучасному C++ існує безпечніша альтернатива для агрегації — std::reference_wrapper, обгортка над посиланням, яка дозволяє зберігати посилання у контейнерах STL.
Проблема зберігання посилань у векторах
Чому ми не можемо просто написати std::vector<Worker&> staff;? Причина в тому, що посилання в C++ не можна перепризначити після ініціалізації. Вектор вимагає можливості копіювати та переміщувати елементи, що несумісно з семантикою посилань. Спроба скомпілювати такий код призведе до помилки:
std::vector<Worker&> staff; // ❌ Помилка компіляції: вектор не може зберігати посилання
Рішення: std::reference_wrapper<T>
Клас std::reference_wrapper<T> (заголовок <functional>) є «обгорткою-посиланням», яка поводиться як посилання, але може бути скопійована та перепризначена. Всередині він зберігає покажчик, але надає інтерфейс, схожий на посилання:
#include <functional> // для std::reference_wrapper
#include <vector>
#include <iostream>
#include <string>
class Teacher
{
private:
std::string name;
public:
Teacher(const std::string& teacherName)
: name(teacherName)
{
}
std::string getName() const { return name; }
};
class Faculty
{
private:
std::string name;
std::vector<std::reference_wrapper<Teacher>> staff; // Вектор обгорток-посилань
public:
Faculty(const std::string& facultyName)
: name(facultyName)
{
}
void addTeacher(Teacher& teacher)
{
staff.push_back(std::ref(teacher)); // std::ref створює reference_wrapper
}
void printStaff() const
{
std::cout << "Faculty " << name << " staff:\n";
for (const auto& teacherRef : staff) {
// Автоматичне перетворення на Teacher&
std::cout << " - " << teacherRef.get().getName() << "\n";
}
}
};
Використання:
int main()
{
Teacher alice("Alice Johnson");
Teacher bob("Bob Smith");
Faculty computerScience("Computer Science");
// Додаємо викладачів (передаємо посилання, а не покажчики)
computerScience.addTeacher(alice);
computerScience.addTeacher(bob);
computerScience.printStaff();
// alice і bob продовжують існувати після знищення computerScience
return 0;
}
Переваги std::reference_wrapper у порівнянні з сирими покажчиками:
Перевага №1: Гарантія ненульового посилання
На відміну від покажчика, std::reference_wrapper не може бути nullptr. Ви не можете створити обгортку без валідного об'єкта. Це усуває необхідність перевірок if (ptr != nullptr).
Перевага №2: Семантика посилань
Синтаксис std::ref(teacher) чітко вказує: ми не передаємо володіння, а лише створюємо псевдонім на існуючий об'єкт. Це робить код більш зрозумілим для читача.
Перевага №3: Сумісність зі стандартними контейнерамиstd::vector<std::reference_wrapper<T>> дозволяє зберігати «посилання» у векторах, множинах та інших контейнерах STL, що неможливо зробити з чистими посиланнями T&.
std::reference_wrapper: Якщо ви впевнені, що об'єкт-частина завжди існує на момент використання контейнера і ніколи не буде nullptr, віддавайте перевагу std::reference_wrapper замість сирих покажчиків. Це робить код безпечнішим і виразнішим.Підводні камені агрегації: висячі покажчики та витоки
Агрегація надає велику гнучкість, але водночас покладає на програміста відповідальність за коректне керування життєвими циклами. Розглянемо типові помилки, з якими стикаються новачки.
Помилка №1: Знищення частини до знищення контейнера
int main()
{
Department* engineering;
{
Worker temp("Temporary Worker");
engineering = new Department("Engineering", &temp);
} // temp виходить зі scope і знищується тут
engineering->printInfo(); // ❌ Undefined Behavior: покажчик manager висить
delete engineering;
return 0;
}
Проблема: Об'єкт temp знищується при виході зі внутрішнього блоку, але відділ engineering продовжує зберігати покажчик на його адресу. Будь-який доступ до manager після цього є доступом до звільненої пам'яті.
Розв'язання: Гарантуйте, що об'єкти-частини живуть принаймні стільки ж, скільки контейнери, що на них посилаються. У разі сумнівів — використовуйте композицію замість агрегації.
Помилка №2: Подвійне видалення пам'яті
int main()
{
Worker* alice = new Worker("Alice");
Department dept1("HR", alice);
Department dept2("IT", alice); // Обидва відділи посилаються на alice
// Хтось помилково додає delete у деструктор Department
// Результат: перший деструктор видалить alice, другий спробує видалити знову → crash
return 0;
}
Проблема: Якщо програміст забув, що агрегація не має видаляти частини, і додав delete manager; у деструктор Department, обидва відділи спробують видалити одного й того ж працівника.
Розв'язання: Чітко документуйте відповідальність за пам'ять. Якщо клас не володіє покажчиком (агрегація), деструктор не повинен викликати delete.
Помилка №3: Витік пам'яті через відсутність видалення
int main()
{
Worker* alice = new Worker("Alice");
{
Department engineering("Engineering", alice);
// engineering знищується тут, але alice залишається у купі
}
// Забули викликати delete alice → витік пам'яті
return 0;
}
Проблема: Об'єкт alice створений динамічно, але ніхто не видалив його після використання. Оскільки відділ не володіє працівником, він не видаляє його у деструкторі. Якщо програміст забув про ручне видалення — пам'ять витікає.
Розв'язання: У сучасному C++ уникайте сирих покажчиків для володіння. Використовуйте розумні покажчики (std::unique_ptr, std::shared_ptr), які автоматично керують пам'яттю. Агрегація може зберігати std::shared_ptr або необроблений покажчик (T*), якщо час життя гарантовано зовні.
std::weak_ptr для агрегації: він дозволяє перевірити, чи ще існує об'єкт, на який він посилається, і не запобігає його видаленню.Композиція проти наслідування: коли не треба вигадувати ієрархії
Новачки, які вперше знайомляться з ООП, часто надмірно захоплюються наслідуванням (inheritance), намагаючись побудувати глибокі ієрархії класів для кожної задачі. Проте досвідчені інженери дотримуються правила: «Віддавайте перевагу композиції над наслідуванням» (Favor composition over inheritance).
Чому композиція часто кращий вибір
Розглянемо приклад: ви моделюєте автомобіль і хочете додати йому можливість увімкнення двигуна. Початківець може спробувати створити ієрархію:
// ❌ Неправильний підхід: наслідування для отримання функціональності
class Engine { /* ... */ };
class Car : public Engine { /* ... */ }; // "Автомобіль Є двигуном"?!
Це порушує логіку відносин «is-a»: автомобіль не є двигуном, він має двигун. Правильне рішення — композиція:
// ✅ Правильний підхід: композиція для моделювання частин
class Engine { /* ... */ };
class Car {
private:
Engine engine; // Автомобіль МІСТИТЬ двигун
};
Переваги композиції над наслідуванням:
- Гнучкість конфігурації: Ви можете легко замінити тип двигуна (електричний, бензиновий, дизельний) без зміни ієрархії класів. У наслідуванні таке неможливо без множинного наслідування, яке створює складні проблеми.
- Уникнення хиткої базової класи (Fragile Base Class Problem): Зміни у батьківському класі автоматично впливають на всіх нащадків, що може зламати код, який ви не контролюєте. Композиція ізолює зміни.
- Множинна функціональність без множинного наслідування: Якщо вам потрібно, щоб
Carміг «літати» і «плавати», у наслідуванні ви потрапите в пастку множинного наслідування (class Car : public Vehicle, public Flyable, public Swimmable). У композиції ви просто додаєте поляFlightModuleіFloatationModule.
Dog є Animal, SavingsAccount є BankAccount. Для всіх інших відносин використовуйте композицію або агрегацію.Практичний приклад: моделювання комп'ютера
Закріпимо знання на реалістичному прикладі: моделювання настільного комп'ютера. Комп'ютер має кілька компонентів, кожен із яких представляє різні типи відносин.
Структура системи
- Композиція: Корпус (
Case), материнська плата (Motherboard) — невід'ємні частини комп'ютера, які створюються разом із ним. - Агрегація: Процесор (
CPU), відеокарта (GPU), монітор (Monitor) — ці компоненти можуть існувати окремо, замінюватися або переставлятися між різними комп'ютерами.
Реалізація класів-компонентів
class CPU
{
private:
std::string model;
int coreCount;
public:
CPU(const std::string& cpuModel, int cores)
: model(cpuModel), coreCount(cores)
{
std::cout << "CPU " << model << " initialized\n";
}
~CPU()
{
std::cout << "CPU " << model << " powered down\n";
}
std::string getModel() const { return model; }
};
class Monitor
{
private:
int size; // Розмір діагоналі в дюймах
public:
Monitor(int inches)
: size(inches)
{
std::cout << size << "\" Monitor connected\n";
}
~Monitor()
{
std::cout << size << "\" Monitor disconnected\n";
}
};
class Motherboard
{
private:
std::string chipset;
public:
Motherboard(const std::string& chipsetName)
: chipset(chipsetName)
{
std::cout << "Motherboard " << chipset << " installed\n";
}
};
Клас Computer із змішаною моделлю
class Computer
{
private:
// Композиція: материнська плата створюється разом із комп'ютером
Motherboard motherboard;
// Агрегація: процесор і монітор існують незалежно
CPU* processor;
Monitor* display;
public:
Computer(const std::string& chipset, CPU* cpu, Monitor* monitor)
: motherboard(chipset), processor(cpu), display(monitor)
{
std::cout << "Computer assembled\n";
}
~Computer()
{
std::cout << "Computer disassembled\n";
// Композиція: motherboard знищується автоматично
// Агрегація: НЕ видаляємо processor і display
}
void printSpecs() const
{
std::cout << "Computer specs:\n";
std::cout << " CPU: " << processor->getModel() << "\n";
std::cout << " Display: " << display << " (pointer address)\n";
}
};
Демонстрація життєвих циклів
int main()
{
// Створюємо компоненти незалежно (агрегація)
CPU* intelCore = new CPU("Intel Core i7-12700K", 12);
Monitor* dellMonitor = new Monitor(27);
{
// Збираємо комп'ютер (material створюється всередині конструктора)
Computer gamingPC("Intel Z690", intelCore, dellMonitor);
gamingPC.printSpecs();
// Комп'ютер знищується при виході зі scope
}
std::cout << "\nComponents still exist after PC disassembly:\n";
// Компоненти можна використовувати в іншому комп'ютері
Computer officePC("AMD B550", intelCore, dellMonitor);
// Вручну звільняємо пам'ять компонентів
delete intelCore;
delete dellMonitor;
return 0;
}
Аналіз виводу показує:
- Материнська плата створюється двічі (по разу для кожного комп'ютера) і знищується разом із комп'ютерами — це композиція.
- Процесор і монітор створюються один раз, використовуються обома комп'ютерами і знищуються лише в кінці програми — це агрегація.
Зведення: композиція vs агрегація
Для закріплення матеріалу розглянемо порівняльну таблицю ключових характеристик обох типів відносин:
📦 Композиція (Composition)
Життєвий цикл: Частина створюється і знищується разом із цілим
Володіння: Суворе — частина належить рівно одному об'єкту
Реалізація: Поля за значенням або покажчики з
delete у деструкторіUML-символ: Зафарбований ромб
◆Приклад: Будинок ↔ Кімната, дріб
Fraction ↔ чисельник numerator🔗 Агрегація (Aggregation)
Життєвий цикл: Частина існує незалежно від контейнера
Володіння: Слабке — частина може належати кільком об'єктам
Реалізація: Покажчики/посилання без
delete у деструкторіUML-символ: Порожній ромб
◇Приклад: Плейлист ↔ треки, команда ↔ гравці, кафедра ↔ викладачі
Контрольні запитання для вибору типу відносин
Якщо ви не впевнені, який тип відносин використовувати, задайте собі ці запитання:
Питання №1: Чи може частина існувати без контейнера?
- Ні → Композиція
- Так → Агрегація
Питання №2: Чи хто створює частину — конструктор контейнера чи зовнішній код?
- Конструктор → Композиція
- Зовнішній код → Агрегація
Питання №3: Чи має деструктор контейнера видаляти частину?
- Так → Композиція
- Ні → Агрегація
Питання №4: Чи може частина одночасно належати кільком контейнерам?
- Ні → Композиція
- Так → Агрегація
Антипатерни та типові помилки проєктування
Завершимо огляд аналізом поширених помилок, які роблять початківці при моделюванні зв'язків між об'єктами.
Антипатерн №1: Божественний об'єкт (God Object)
Опис: Створення одного гігантського класу, який містить усі поля і методи, необхідні для всієї підсистеми, замість декомпозиції на дрібніші класи.
// ❌ Антипатерн: монолітний клас-велетень
class GameCharacter
{
private:
// Параметри персонажа
int health, mana, strength, agility;
// Інвентар
std::vector<Item> inventory;
// Система руху
double positionX, positionY, velocityX, velocityY;
// Анімація
std::string currentAnimation;
int animationFrame;
// Штучний інтелект
AIState aiState;
std::vector<Waypoint> pathToTarget;
// Звуки
std::string footstepSound, attackSound;
// ... ще 50 полів ...
public:
// ... 100+ методів для всього цього ...
};
Проблема: Клас перетворюється на неконтрольовану кашу з непов'язаних відповідальностей. Тестувати, розуміти та модифікувати такий код надзвичайно складно.
Розв'язання: Декомпозиція через композицію:
// ✅ Правильний підхід: декомпозиція на модулі
class GameCharacter
{
private:
Stats stats; // Композиція: параметри персонажа
Inventory inventory; // Композиція: система інвентаря
Transform transform; // Композиція: позиція та швидкість
AnimationController animator; // Композиція: система анімації
AIController* ai; // Агрегація: ШІ може бути спільним для типу ворогів
SoundProfile* sounds; // Агрегація: звукові профілі можуть бути спільними
public:
// Кожна підсистема має власний інтерфейс
};
Антипатерн №2: Надмірна вкладеність (Over-Nesting)
Опис: Створення глибокої ієрархії композиції (об'єкт містить об'єкт, який містить об'єкт...), що ускладнює доступ до даних.
// ❌ Антипатерн: ланцюг доступу довжиною 5 рівнів
player.getBody().getLeftArm().getHand().getFingers().getThumb().bend(45);
Проблема: Порушення закону Деметри (Law of Demeter / «Принцип найменшого знання»). Зовнішній код знає занадто багато про внутрішню структуру об'єкта.
Розв'язання: Делегування через методи верхнього рівня:
// ✅ Правильний підхід: приховування вкладеності
player.bendLeftThumb(45); // Метод делегує виклик вниз по ієрархії
Антипатерн №3: Псевдоагрегація через shared_ptr
Опис: Використання std::shared_ptr у всіх місцях замість явного визначення відповідальності за володіння.
// ❌ Сумнівний підхід: все через shared_ptr
class Department
{
private:
std::shared_ptr<Worker> manager; // Хто насправді володіє працівником?
};
Проблема: Коли всі об'єкти зберігаються через shared_ptr, неясно, хто відповідає за життєвий цикл. Це може призвести до циклічних посилань та витоків пам'яті.
Розв'язання: Чітко визначайте семантику володіння:
std::unique_ptr— для композиції (єдиний власник)- Сирий покажчик
T*абоstd::reference_wrapper— для агрегації (невладіюче посилання) std::shared_ptr— лише коли дійсно потрібне спільне володіння з динамічним підрахунком посилань
Практичне завдання: моделювання бібліотечної системи
Закріпимо матеріал на практиці. Спроєктуйте систему для управління бібліотекою з наступними сутностями:
Book: Книга з назвою, автором та ISBNLibrary: Бібліотека, яка володіє колекцією книг (композиція)Member: Член бібліотеки (читач), який може брати книгиLoan: Запис про позичання книги членом бібліотеки (агрегація: зберігає посилання наBookіMember, але не створює їх)
Підказка №1: Композиція для Library і Book
Бібліотека створює книги при їх додаванні у каталог і знищує всю колекцію при закритті. Використовуйте std::vector<Book> для зберігання за значенням або std::vector<std::unique_ptr<Book>> для динамічного виділення.
Підказка №2: Агрегація для Loan
Запис про позичання не створює ні книгу, ні читача — він лише фіксує факт зв'язку між ними. Використовуйте покажчики або std::reference_wrapper.
Підказка №3: Методи для Library
Реалізуйте:
addBook(const std::string& title, const std::string& author, const std::string& isbn)— додає книгу у колекціюprintCatalog()— виводить список всіх книгfindBookByISBN(const std::string& isbn)— повертає покажчик на книгу абоnullptr
Підказка №4: Деструктори та RAII
Переконайтеся, що при знищенні Library автоматично знищуються всі книги, але при знищенні Loan не знищуються ні книга, ні читач.
#include <iostream>
#include <string>
#include <vector>
#include <memory>
// Клас Book: інкапсулює дані про книгу
class Book
{
private:
std::string title;
std::string author;
std::string isbn;
public:
Book(const std::string& bookTitle, const std::string& bookAuthor, const std::string& bookISBN)
: title(bookTitle), author(bookAuthor), isbn(bookISBN)
{
}
std::string getISBN() const { return isbn; }
void print() const
{
std::cout << "\"" << title << "\" by " << author << " (ISBN: " << isbn << ")\n";
}
};
// Клас Library: композиція з Book
class Library
{
private:
std::string name;
std::vector<std::unique_ptr<Book>> catalog; // Композиція через unique_ptr
public:
Library(const std::string& libraryName)
: name(libraryName)
{
std::cout << "Library \"" << name << "\" opened\n";
}
~Library()
{
std::cout << "Library \"" << name << "\" closed. " << catalog.size() << " books archived.\n";
// unique_ptr автоматично звільнить всі книги
}
void addBook(const std::string& title, const std::string& author, const std::string& isbn)
{
catalog.push_back(std::make_unique<Book>(title, author, isbn));
}
void printCatalog() const
{
std::cout << "\n=== " << name << " Catalog ===\n";
for (const auto& book : catalog) {
book->print();
}
std::cout << "\n";
}
Book* findBookByISBN(const std::string& isbn)
{
for (const auto& book : catalog) {
if (book->getISBN() == isbn) {
return book.get(); // Повертаємо сирий покажчик (невладіюче посилання)
}
}
return nullptr;
}
};
// Клас Member: автономна сутність читача
class Member
{
private:
std::string name;
int memberID;
public:
Member(const std::string& memberName, int id)
: name(memberName), memberID(id)
{
}
std::string getName() const { return name; }
int getID() const { return memberID; }
};
// Клас Loan: агрегація з Book і Member
class Loan
{
private:
Book* borrowedBook; // Агрегація: не володіє книгою
Member* borrower; // Агрегація: не володіє читачем
std::string dueDate;
public:
Loan(Book* book, Member* member, const std::string& due)
: borrowedBook(book), borrower(member), dueDate(due)
{
std::cout << "Loan created: " << borrower->getName()
<< " borrowed ISBN " << borrowedBook->getISBN()
<< " (due: " << dueDate << ")\n";
}
~Loan()
{
std::cout << "Loan record archived\n";
// НЕ видаляємо borrowedBook і borrower — агрегація!
}
void printInfo() const
{
std::cout << "Borrower: " << borrower->getName()
<< ", Book ISBN: " << borrowedBook->getISBN()
<< ", Due: " << dueDate << "\n";
}
};
int main()
{
Library cityLibrary("City Central Library");
// Композиція: бібліотека створює і володіє книгами
cityLibrary.addBook("The C++ Programming Language", "Bjarne Stroustrup", "978-0321563842");
cityLibrary.addBook("Effective C++", "Scott Meyers", "978-0321334879");
cityLibrary.printCatalog();
// Автономні об'єкти членів бібліотеки
Member alice("Alice Johnson", 1001);
Member bob("Bob Smith", 1002);
// Агрегація: запис про позичання посилається на існуючі об'єкти
Book* effectiveCpp = cityLibrary.findBookByISBN("978-0321334879");
if (effectiveCpp != nullptr) {
Loan loan1(effectiveCpp, &alice, "2026-10-01");
loan1.printInfo();
}
// Бібліотека знищується, книги видаляються, але alice і bob продовжують існувати
return 0;
}
Висновки та наступні кроки
Ми дослідили два фундаментальні типи структурних зв'язків між об'єктами в C++: композицію та агрегацію. Обидва патерни є проявами принципу «має» (has-a), але відрізняються силою зв'язку та відповідальністю за життєві цикли.
Ключові висновки:
- Композиція моделює нерозривне володіння: частина створюється і знищується разом із цілим. Реалізується через поля за значенням або покажчики з ручним керуванням у деструкторі.
- Агрегація моделює слабке володіння: частина існує незалежно від контейнера, може належати кільком об'єктам одночасно. Реалізується через покажчики або
std::reference_wrapperбез видалення у деструкторі. - Вибір між ними визначається семантикою предметної області: якщо частина не має сенсу поза контекстом цілого — композиція; якщо частина може існувати автономно — агрегація.
- UML-нотація допомагає візуалізувати тип зв'язку: зафарбований ромб
◆для композиції, порожній ромб◇для агрегації. - Сучасні інструменти: Для безпечної реалізації використовуйте
std::unique_ptr(композиція),std::reference_wrapper(агрегація безnullptr),std::shared_ptr(спільне володіння).
На наступному етапі ми розглянемо інші типи зв'язків між об'єктами: асоціацію (рівноправне партнерство без концепції володіння) та залежність (тимчасове використання без збереження посилань). Потім перейдемо до вивчення контейнерних класів та std::initializer_list — потужного інструменту для ініціалізації колекцій у стилі {1, 2, 3}.
- Erich Gamma et al., "Design Patterns: Elements of Reusable Object-Oriented Software" — класичний посібник з патернів проєктування, де композиція розглядається як альтернатива наслідуванню.
- Scott Meyers, "Effective C++" (Item 38: Model "has-a" or "is-implemented-in-terms-of" through composition) — детальний аналіз вибору між композицією та наслідуванням.
Глибоке копіювання та оператор присвоювання
Shallow vs deep copy, перевантаження operator=, самоприсвоювання, Rule of Three. Правильне управління динамічною памяттю у класах.
Асоціація та залежність між об
Відношення рівноправного партнерства (асоціація) та тимчасового використання (залежність) між класами. Односпрямовані та двоспрямовані зв