Контейнерні класи та `std::initializer_list`
Від сирих масивів до безпечних контейнерів
Протягом попередніх лекцій ми навчилися створювати класи, які інкапсулюють дані та надають методи для керування станом об'єктів. Ми вивчили механізми композиції, агрегації та асоціації, які дозволяють моделювати складні відносини між об'єктами. Проте всі ці приклади оперували фіксованими наборами полів: у класу Fraction завжди є два цілих числа, у класу Point2D — дві координати, у класу Student — ім'я, ID та список курсів.
Що робити, коли кількість даних, які потрібно зберігати, невідома на момент компіляції? Уявіть систему управління завданнями (to-do list), де користувач може додавати і видаляти завдання довільно; редактор тексту, який зберігає рядки документа; ігровий інвентар, де кількість предметів постійно змінюється. У всіх цих випадках нам потрібен контейнерний клас (container class) — клас, спеціально спроєктований для зберігання колекції об'єктів з можливістю динамічної зміни розміру.
Стандартна бібліотека C++ надає потужні контейнери (std::vector, std::list, std::map), які покривають більшість практичних потреб. Проте розуміння того, як вони працюють зсередини, є фундаментальним для інженерної компетентності. На цій лекції ми спроєктуємо власний контейнерний клас IntArray із нуля, реалізувавши всі критично важливі операції: динамічне виділення пам'яті, зміну розміру, вставку, видалення та глибоке копіювання. Потім ми додамо підтримку елегантної ініціалізації через механізм std::initializer_list (C++11), який дозволяє писати IntArray numbers = {1, 2, 3, 4, 5}; замість багаторядкового ручного заповнення.
std::vector<int>, який є оптимізованішим і надійнішим за будь-яку власноручну реалізацію. Проте є три причини вивчати внутрішню будову контейнерів: (1) Розуміння механізмів: як працюють динамічні масиви під капотом; (2) Специфічні вимоги: інколи потрібен контейнер із нестандартною поведінкою (наприклад, контейнер із обмеженою ємністю або з автоматичним сортуванням); (3) Інтерв'ю та екзамени: реалізація динамічного масиву є класичним завданням для перевірки знання керування пам'яттю.Концепція контейнерного класу
Контейнерний клас (container class) — це клас, основною відповідальністю якого є зберігання, організація та надання доступу до колекції об'єктів. Контейнер інкапсулює деталі розміщення даних у пам'яті та надає зручний інтерфейс для маніпуляції елементами. У реальному світі контейнери — це коробки для зберігання речей, книжкові полиці, файлові шафи. У програмуванні контейнери — це масиви, списки, черги, стеки, словники.
Типовий функціонал контейнерних класів
Незалежно від внутрішньої структури (масив, зв'язний список, дерево), більшість контейнерів надають схожий набір операцій:
- Створення порожнього контейнера: конструктор за замовчуванням або параметризований конструктор із вказанням початкового розміру.
- Додавання елементів: методи
push_back(),insert(),add()тощо. - Видалення елементів: методи
pop_back(),remove(),erase(). - Доступ до елементів: оператор індексації
[], методиat(),front(),back(). - Перегляд розміру: метод
size()абоgetLength(), перевірка порожнечіempty(). - Очищення контейнера: метод
clear()для видалення всіх елементів. - Сортування (опціонально): метод
sort()для впорядкування елементів.
Контейнери можуть бути двох основних типів за способом зберігання елементів:
Контейнери значень (value containers): Зберігають копії об'єктів. Контейнер відповідає за створення та знищення цих копій. Це композиція: елементи є частинами контейнера. Приклад: std::vector<int> зберігає цілі числа за значенням.
Контейнери посилань (reference containers): Зберігають покажчики або посилання на зовнішні об'єкти. Контейнер не відповідає за створення чи знищення цих об'єктів. Це агрегація: елементи існують незалежно. Приклад: std::vector<Student*> зберігає покажчики на об'єкти Student, які керуються ззовні.
std::vector<std::unique_ptr<Student>>), які поєднують переваги обох підходів.Проєктування класу IntArray: інкапсуляція динамічного масиву
Почнемо з проєктування контейнерного класу для зберігання масиву цілих чисел. Наш клас повинен інкапсулювати два критично важливі елементи:
- Покажчик на динамічно виділений масив (
int* data) — зберігає дані в купі - Довжину масиву (
int length) — відстежує кількість елементів
Ці дві величини завжди мають бути синхронізовані: якщо length = 5, то data має вказувати на блок пам'яті з 5 цілими числами. Порушення цієї інваріанти призведе до непередбачуваної поведінки.
Мінімальна структура класу
#include <cassert>
class IntArray
{
private:
int length;
int* data;
public:
// Конструктори, деструктор, методи...
};
Чому поля приватні? Це фундаментальний принцип інкапсуляції: зовнішній код не повинен мати прямого доступу до внутрішніх даних контейнера. Якби поля були публічними, користувач міг би написати:
IntArray numbers(5);
numbers.data = nullptr; // ❌ Катастрофа: втратили масив без звільнення пам'яті
numbers.length = 100; // ❌ Брехня: length не відповідає реальному розміру
Приватизація полів змушує весь доступ проходити через контрольовані методи, які гарантують цілісність даних.
Конструктори: створення порожнього та параметризованого масиву
Нам потрібні два конструктори: для створення порожнього масиву та для створення масиву заданого розміру.
class IntArray
{
private:
int length;
int* data;
public:
// Конструктор за замовчуванням: створює порожній масив
IntArray()
: length(0), data(nullptr)
{
}
// Параметризований конструктор: створює масив заданого розміру
IntArray(int arrayLength)
: length(arrayLength)
{
assert(arrayLength >= 0); // Негативна довжина — помилка програміста
if (length > 0) {
data = new int[length]{}; // Виділяємо пам'ять, ініціалізуємо нулями
} else {
data = nullptr; // Масив довжини 0 не виділяє пам'ять
}
}
int getLength() const { return length; }
};
Проаналізуємо деталі реалізації:
assert(arrayLength >= 0): Негативна довжина масиву не має сенсу і є помилкою програміста. Функція assert() перевірить умову в режимі налагодження (Debug) і зупинить програму з повідомленням про помилку, якщо умова хибна. У режимі випуску (Release) assert видаляється компілятором для продуктивності.
data = new int[length]{}: Синтаксис {} після new виконує value initialization — ініціалізує всі елементи масиву нулями (для примітивних типів) або викликає конструктор за замовчуванням (для об'єктів). Без {} елементи залишилися б неініціалізованими з випадковими значеннями (garbage values).
data = nullptr для порожнього масиву: Якщо довжина дорівнює нулю, ми не виділяємо пам'ять. Покажчик має бути встановлений у nullptr, щоб деструктор міг безпечно викликати delete[] data (видалення nullptr є легальною no-op операцією).
assert, а не виняток?: У контексті конструктора перевірка arrayLength >= 0 використовує assert, оскільки негативна довжина є логічною помилкою програміста, а не коректною помилкою часу виконання (на кшталт відсутності файлу чи мережевого збою). У промисловому коді для публічного API часто використовують винятки (if (arrayLength < 0) throw std::invalid_argument("...")), але для внутрішніх класів assert є загальноприйнятим.Деструктор та очищення: ідіома RAII
Оскільки клас IntArray виділяє пам'ять динамічно через new[], він обов'язково має звільнити цю пам'ять у деструкторі. Це є основою ідіоми RAII (Resource Acquisition Is Initialization): ресурс захоплюється в конструкторі, звільняється в деструкторі, і користувач класу не бачить цього процесу.
class IntArray
{
private:
int length;
int* data;
public:
// ... конструктори ...
~IntArray()
{
delete[] data;
// Не потрібно присвоювати nullptr або обнуляти length —
// об'єкт знищується одразу після виходу з деструктора
}
// Метод для очищення масиву з продовженням життя об'єкта
void erase()
{
delete[] data;
data = nullptr; // Важливо: запобігаємо висячому покажчику
length = 0;
}
};
Різниця між деструктором і методом erase():
Деструктор ~IntArray(): Викликається автоматично при знищенні об'єкта (вихід зі scope або виклик delete). Після виконання деструктора об'єкт припиняє існування, тому немає сенсу присвоювати nullptr або обнуляти length — ці поля більше ніколи не будуть доступні.
Метод erase(): Викликається вручну для очищення масиву, не знищуючи сам об'єкт. Після erase() об'єкт продовжує існувати як порожній контейнер, тому ми обов'язково присвоюємо data = nullptr і length = 0, щоб уникнути висячого покажчика та некоректного стану.
Приклад використання:
int main()
{
IntArray numbers(5); // Виділяє масив на 5 елементів
numbers.erase(); // Очищає масив, але об'єкт numbers продовжує існувати
std::cout << numbers.getLength(); // Виведе 0
// При виході зі scope викликається ~IntArray(),
// який спробує delete[] data, але data == nullptr, тому це безпечно
return 0;
}
delete[] nullptr: Стандарт C++ гарантує, що delete і delete[] на nullptr є no-op операцією (нічого не роблять). Тому код delete[] data; безпечний навіть якщо data == nullptr, і нам не потрібна перевірка if (data != nullptr) delete[] data;.Доступ до елементів: перевантаження оператора []
Контейнерний клас без можливості доступу до елементів марний. Ми очікуємо писати numbers[3] = 42; так само, як із стандартними масивами. Для цього перевантажимо оператор індексації [].
class IntArray
{
private:
int length;
int* data;
public:
// ... конструктори, деструктор ...
// Оператор індексації для доступу до елементів
int& operator[](int index)
{
assert(index >= 0 && index < length); // Перевірка меж
return data[index];
}
// Константна версія для константних об'єктів
const int& operator[](int index) const
{
assert(index >= 0 && index < length);
return data[index];
}
};
Ключові моменти реалізації:
Повернення за посиланням int&: Оператор повертає посилання на елемент, а не копію. Це дозволяє модифікувати елемент: numbers[3] = 42; присвоює значення безпосередньо елементу масиву, а не тимчасовій копії.
Перевірка меж через assert: Доступ за межами масиву (index < 0 або index >= length) є логічною помилкою програміста. assert виявить це в режимі налагодження, але буде видалений у випуску для продуктивності. Стандартний std::vector має метод at(index), який кидає виняток std::out_of_range, але operator[] у STL також не перевіряє межі для швидкості.
Константна версія оператора: Перевантаження const дозволяє використовувати operator[] на константних об'єктах. Без неї код const IntArray numbers(5); int x = numbers[0]; не скомпілюється, оскільки компілятор не зможе викликати неконстантну версію на константному об'єкті.
Приклад використання:
int main()
{
IntArray numbers(5);
// Заповнюємо масив
for (int i = 0; i < numbers.getLength(); ++i) {
numbers[i] = i * 10;
}
// Читаємо елементи
for (int i = 0; i < numbers.getLength(); ++i) {
std::cout << numbers[i] << " "; // 0 10 20 30 40
}
return 0;
}
assert, а не повернення помилки?: Стандартна бібліотека C++ обрала філософію «довіряй програмісту»: operator[] у std::vector не перевіряє межі для максимальної швидкості. Якщо потрібна безпечна версія, використовується метод at(index), який кидає виняток. Ми дотримуємося цієї конвенції: operator[] швидкий, але небезпечний; програміст має гарантувати коректність індексу.Динамічна зміна розміру: методи reallocate() і resize()
Одна з ключових переваг контейнерних класів над статичними масивами — можливість змінювати розмір під час виконання. Реалізуємо два методи зміни розміру з різними семантиками:
Метод reallocate(): швидка зміна розміру з втратою даних
Цей метод видаляє всі існуючі елементи та створює новий масив заданого розміру. Це найшвидший спосіб зміни розміру, оскільки не потрібно копіювати старі дані.
void reallocate(int newLength)
{
// Видаляємо існуючий масив
erase();
// Якщо новий розмір нульовий або від'ємний, залишаємо контейнер порожнім
if (newLength <= 0) {
return;
}
// Виділяємо новий масив
data = new int[newLength]{};
length = newLength;
}
Використання:
IntArray numbers(5);
numbers[0] = 100;
numbers.reallocate(10); // Всі старі дані втрачено, тепер масив із 10 нулів
Метод resize(): зміна розміру зі збереженням даних
Цей метод зберігає стільки старих елементів, скільки поміститься в новому розмірі. Якщо новий розмір більший — додаються нульові елементи, якщо менший — зайві елементи відкидаються.
void resize(int newLength)
{
// Якщо розмір не змінився, нічого не робимо
if (newLength == length) {
return;
}
// Якщо потрібно зробити масив порожнім
if (newLength <= 0) {
erase();
return;
}
// Алгоритм зміни розміру зі збереженням даних:
// 1. Виділяємо новий масив
// 2. Копіюємо елементи зі старого масиву (скільки поміститься)
// 3. Видаляємо старий масив
// 4. Перемикаємо покажчик data на новий масив
// Крок 1: Виділяємо новий масив
int* newData = new int[newLength]{};
// Крок 2: Визначаємо кількість елементів для копіювання
// (менше значення зі старого та нового розміру)
int elementsToCopy = (newLength < length) ? newLength : length;
// Копіюємо елементи
for (int i = 0; i < elementsToCopy; ++i) {
newData[i] = data[i];
}
// Крок 3: Видаляємо старий масив
delete[] data;
// Крок 4: Перемикаємося на новий масив
data = newData;
length = newLength;
}
Приклад використання:
IntArray numbers(5);
// Заповнюємо масив: [0, 10, 20, 30, 40]
for (int i = 0; i < numbers.getLength(); ++i) {
numbers[i] = i * 10;
}
// Збільшуємо розмір до 8: [0, 10, 20, 30, 40, 0, 0, 0]
numbers.resize(8);
// Зменшуємо розмір до 3: [0, 10, 20]
numbers.resize(3);
Проаналізуємо критичні моменти:
Чому створюємо тимчасовий покажчик newData?: Ми не можемо змінити data напряму, оскільки нам спочатку потрібно скопіювати старі дані з data у новий масив. Лише після успішного копіювання ми видаляємо старий масив і перемикаємо data на newData.
Вибір кількості елементів для копіювання: Якщо новий розмір більший за старий, копіюємо всі старі елементи (решта заповнюється нулями). Якщо менший — копіюємо лише скільки поміститься.
Що станеться, якщо new кине виняток?: Якщо виділення пам'яті не вдалося, оператор new кине виняток std::bad_alloc. У такому разі старий масив data залишиться недоторканим, і об'єкт залишиться в коректному стані. Це є перевагою алгоритму «виділити новий → скопіювати → видалити старий» над «видалити старий → виділити новий».
resize() у реальних контейнерах: Наша реалізація завжди виділяє новий масив точного розміру. У std::vector використовується стратегія geometric growth: коли потрібно збільшити розмір, вектор виділяє в 1.5-2 рази більше пам'яті, ніж потрібно, щоб зменшити кількість перевиділень при багаторазовому додаванні елементів. Це покращує амортизовану складність операції push_back() до O(1).Операції вставки та видалення елементів
Зміна розміру масиву — це фундаментальна операція, але часто потрібно вставляти або видаляти елементи в конкретних позиціях без зміни всього розміру контейнера. Реалізуємо методи insertBefore() та remove().
Метод insertBefore(): вставка елемента за індексом
Цей метод вставляє новий елемент перед вказаним індексом, зміщуючи всі наступні елементи на одну позицію праворуч.
void insertBefore(int value, int index)
{
// Перевірка коректності індексу
// index == length дозволений (вставка в кінець)
assert(index >= 0 && index <= length);
// Створюємо новий масив на один елемент більший
int* newData = new int[length + 1];
// Копіюємо всі елементи до позиції index
for (int i = 0; i < index; ++i) {
newData[i] = data[i];
}
// Вставляємо новий елемент
newData[index] = value;
// Копіюємо всі елементи після позиції index
for (int i = index; i < length; ++i) {
newData[i + 1] = data[i];
}
// Видаляємо старий масив і перемикаємося на новий
delete[] data;
data = newData;
++length;
}
// Допоміжні методи для зручності
void insertAtBeginning(int value)
{
insertBefore(value, 0);
}
void insertAtEnd(int value)
{
insertBefore(value, length);
}
Візуалізація вставки insertBefore(99, 2) у масив [10, 20, 30, 40]:
Старий масив: [10, 20, 30, 40]
↓ index = 2
Новий масив: [10, 20, 99, 30, 40]
Проаналізуємо алгоритм:
Дозволений діапазон індексів: index може бути від 0 (вставка на початок) до length (вставка в кінець). Якщо index == length, всі старі елементи копіюються, а новий додається в кінець.
Два цикли копіювання: Перший цикл копіює елементи [0...index-1] без зміщення. Другий цикл копіює елементи [index...length-1] зі зміщенням на +1.
Складність операції: O(n), де n — кількість елементів. Для вставки потрібно скопіювати весь масив і зсунути частину елементів. Це відносно повільна операція, особливо для великих масивів.
Метод remove(): видалення елемента за індексом
void remove(int index)
{
// Перевірка коректності індексу
assert(index >= 0 && index < length);
// Якщо це останній елемент, просто очищаємо масив
if (length == 1) {
erase();
return;
}
// Створюємо новий масив на один елемент менший
int* newData = new int[length - 1];
// Копіюємо всі елементи до позиції index
for (int i = 0; i < index; ++i) {
newData[i] = data[i];
}
// Копіюємо всі елементи після позиції index (зі зміщенням -1)
for (int i = index + 1; i < length; ++i) {
newData[i - 1] = data[i];
}
// Видаляємо старий масив і перемикаємося на новий
delete[] data;
data = newData;
--length;
}
Візуалізація видалення remove(1) з масиву [10, 20, 30, 40]:
Старий масив: [10, 20, 30, 40]
↓ index = 1 (видаляємо)
Новий масив: [10, 30, 40]
Приклад використання:
int main()
{
IntArray numbers(3);
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers.insertBefore(15, 1); // [10, 15, 20, 30]
numbers.insertAtEnd(40); // [10, 15, 20, 30, 40]
numbers.remove(2); // [10, 15, 30, 40]
for (int i = 0; i < numbers.getLength(); ++i) {
std::cout << numbers[i] << " ";
}
// Виведе: 10 15 30 40
return 0;
}
insertBefore() і remove() мають складність O(n), оскільки при кожній операції виділяється новий масив і копіюється весь вміст. Для часто змінюваних контейнерів це неефективно. У реальному світі використовуйте std::vector (оптимізований для додавання в кінець), std::list (ефективна вставка/видалення в будь-якій позиції) або std::deque (ефективна вставка на обох кінцях).Елегантна ініціалізація через std::initializer_list
Наш клас IntArray функціональний, але ініціалізація елементів виглядає громіздко:
IntArray numbers(5);
numbers[0] = 10;
numbers[1] = 20;
numbers[2] = 30;
numbers[3] = 40;
numbers[4] = 50;
Порівняймо це зі стандартним масивом:
int array[] = {10, 20, 30, 40, 50}; // Елегантно і зрозуміло
Стандарт C++11 ввів std::initializer_list<T> — механізм, який дозволяє компілятору автоматично перетворювати список ініціалізації {...} у спеціальний легковаговий об'єкт-проксі. Якщо клас має конструктор, що приймає std::initializer_list, компілятор автоматично викличе його при ініціалізації через {}.
Реалізація конструктора з std::initializer_list
#include <initializer_list>
class IntArray
{
private:
int length;
int* data;
public:
// ... інші конструктори ...
// Конструктор, що приймає список ініціалізації
IntArray(const std::initializer_list<int>& list)
: IntArray(static_cast<int>(list.size())) // Делегуємо виділення пам'яті
{
// Копіюємо елементи зі списку в масив
int index = 0;
for (const int& element : list) {
data[index] = element;
++index;
}
}
};
Тепер можна писати:
IntArray numbers = {10, 20, 30, 40, 50}; // ✅ Елегантна ініціалізація!
Проаналізуємо реалізацію:
const std::initializer_list<int>&: Тип std::initializer_list<T> — це легковаговий об'єкт (зазвичай лише два покажчики: початок та кінець масиву), тому передача за посиланням уникає зайвого копіювання.
Делегуючий конструктор: Викликаємо IntArray(static_cast<int>(list.size())), щоб виділити масив потрібного розміру. Метод size() повертає std::size_t (unsigned), тому приводимо до int.
Цикл for (const int& element : list): std::initializer_list не підтримує operator[], але підтримує ітератори. Range-based for дозволяє елегантно перебрати всі елементи.
std::initializer_list не має operator[]?: Це давня дискусія в комітеті стандартів C++. std::initializer_list є мінімалістичним інтерфейсом для передачі легковагових списків. Для доступу за індексом потрібно використовувати ітератори або range-based for.Перевантаження оператора присвоювання
std::initializer_list також можна використати для перевантаження оператора присвоювання:
IntArray& operator=(const std::initializer_list<int>& list)
{
// Якщо новий розмір відрізняється, перевиділяємо пам'ять
int newLength = static_cast<int>(list.size());
if (newLength != length) {
delete[] data;
length = newLength;
data = new int[length];
}
// Копіюємо елементи зі списку
int index = 0;
for (const int& element : list) {
data[index] = element;
++index;
}
return *this;
}
Використання:
IntArray numbers(5); // Масив із 5 нулів
numbers = {1, 2, 3, 4, 5}; // Присвоюємо нові значення через список
numbers = {100, 200}; // Змінюємо розмір і значення одночасно
std::initializer_list, але немає перевантаження operator=, компілятор може неявно викликати конструктор для створення тимчасового об'єкта і потім використати дефолтний (поверхневий) оператор присвоювання. Це призведе до подвійного delete при знищенні тимчасового об'єкта! Розв'язання: завжди перевантажуйте operator= для std::initializer_list або робіть конструктор explicit.Глибоке копіювання та Правило трьох
Наш клас IntArray використовує динамічну пам'ять, тому дефолтний конструктор копіювання та оператор присвоювання (згенеровані компілятором) виконають поверхневе копіювання (shallow copy) покажчика data. Це катастрофа:
IntArray numbers1(3);
numbers1[0] = 10;
IntArray numbers2 = numbers1; // ❌ Поверхневе копіювання: обидва вказують на один масив
numbers2[0] = 99; // Змінюємо numbers2, але змінюється і numbers1!
// При знищенні обох об'єктів пам'ять звільниться двічі → crash
Розв'язання: реалізувати глибоке копіювання (deep copy):
// Конструктор копіювання
IntArray(const IntArray& other)
: length(other.length)
{
if (length > 0) {
data = new int[length];
for (int i = 0; i < length; ++i) {
data[i] = other.data[i]; // Копіюємо кожен елемент
}
} else {
data = nullptr;
}
}
// Оператор присвоювання
IntArray& operator=(const IntArray& other)
{
// Захист від самоприсвоювання
if (this == &other) {
return *this;
}
// Видаляємо старі дані
delete[] data;
// Копіюємо дані з other
length = other.length;
if (length > 0) {
data = new int[length];
for (int i = 0; i < length; ++i) {
data[i] = other.data[i];
}
} else {
data = nullptr;
}
return *this;
}
Правило трьох (Rule of Three): Якщо клас потребує одного з трьох (деструктор, конструктор копіювання, оператор присвоювання), він потребує всіх трьох. Наш IntArray має деструктор → обов'язково потрібні власні конструктор копіювання та оператор присвоювання.
Практичне завдання: розширення IntArray
Додайте до класу IntArray:
- Метод
print()для виведення всіх елементів - Метод
find(int value), що повертає індекс першого входження значення або -1, якщо не знайдено
#include <iostream>
class IntArray
{
private:
int length;
int* data;
public:
// ... весь попередній код ...
void print() const
{
std::cout << "[";
for (int i = 0; i < length; ++i) {
std::cout << data[i];
if (i < length - 1) {
std::cout << ", ";
}
}
std::cout << "]";
}
int find(int value) const
{
for (int i = 0; i < length; ++i) {
if (data[i] == value) {
return i;
}
}
return -1; // Не знайдено
}
};
int main()
{
IntArray numbers = {10, 20, 30, 40, 50};
numbers.print(); // [10, 20, 30, 40, 50]
std::cout << "\n";
int index = numbers.find(30);
if (index != -1) {
std::cout << "Found 30 at index " << index << "\n"; // Found 30 at index 2
}
return 0;
}
Висновки та наступні кроки
Ми пройшли повний шлях від концепції контейнерного класу до робочої реалізації динамічного масиву цілих чисел:
Ключові висновки:
- Контейнерні класи інкапсулюють колекції об'єктів і надають зручний інтерфейс для маніпуляції елементами без прямого доступу до внутрішніх структур даних.
- Динамічне керування пам'яттю: Використання
new[]/delete[]вимагає дотримання ідіоми RAII — ресурс захоплюється в конструкторі, звільняється в деструкторі. - Правило трьох: Класи з динамічною пам'яттю обов'язково потребують власної реалізації деструктора, конструктора копіювання та оператора присвоювання для уникнення проблем подвійного видалення.
std::initializer_list(C++11) дозволяє елегантно ініціалізувати контейнери синтаксисом{1, 2, 3}, роблячи власні класи зручнішими у використанні.- Операції вставки/видалення мають складність O(n) для масивів, оскільки вимагають копіювання всіх даних. Для часто змінюваних контейнерів краще використовувати зв'язні списки або інші структури.
- У промисловому коді використовуйте STL:
std::vector,std::list,std::dequeоптимізовані, протестовані роками і сумісні з алгоритмами STL. Власні контейнери пишіть лише для специфічних вимог.
На наступному етапі ми перейдемо до вивчення наслідування (inheritance) — другого фундаментального принципу ООП після інкапсуляції. Наслідування дозволяє моделювати відношення «є різновидом» (is-a): Dog є Animal, Car є Vehicle. Ми дослідимо порядок виклику конструкторів і деструкторів у ієрархіях, специфікатор доступу protected, види наслідування та механізм перевизначення методів.
- Scott Meyers, "Effective C++" (Item 11: Handle assignment to self in operator=) — детальний аналіз проблем самоприсвоювання.
- Nicolai M. Josuttis, "The C++ Standard Library" — вичерпний посібник із STL-контейнерів та їхньої внутрішньої будови.
- Bjarne Stroustrup, "The C++ Programming Language" (розділ про контейнери) — фундаментальний аналіз проєктування контейнерів від творця мови.
Асоціація та залежність між об
Відношення рівноправного партнерства (асоціація) та тимчасового використання (залежність) між класами. Односпрямовані та двоспрямовані зв
Вступ до наслідування: базові поняття та відношення «is-a»
Другий стовп ООП — наслідування (inheritance). Відношення «є різновидом» (is-a), побудова ієрархій, синтаксис public наслідування, ліквідація дублювання коду, фізична структура успадкованого об'єкта.