1. Навіщо потрібен абстрактний тип
Коли застосунок виходить за межі кількох файлів і кількох функцій, швидко зʼясовується, що «я знаю конкретний клас» — це розкіш. Іноді клієнтському коду потрібно лише «щось, що вміє друкувати завдання» або «щось, що вміє показувати повідомлення». І зовсім не важливо, як саме це працює. У житті все схоже: вам не принципово, який саме курʼєр привезе піцу, — важливо, щоб піца приїхала.
Уявімо, що в нас є навчальний консольний застосунок Tasky — умовний трекер завдань. Раніше ми могли друкувати завдання безпосередньо через std::cout. Але тепер нам потрібні два режими виведення: «просто текстом» і «в рамці». Переписувати весь код під два варіанти й розкидати if по всьому проєкту зовсім не хочеться.
Створімо невелику модель завдання. Вона вже зʼявлялася раніше, але для контексту коротко нагадаємо її ще раз:
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
Тож постає логічне запитання: як написати функцію «надрукувати завдання», щоб вона працювала з різними стилями друку й водночас не знала, який саме стиль вибрано?
2. Pure virtual і абстрактність
Pure virtual: = 0 — це не «присвоєння нуля»
Синтаксис = 0 в оголошенні методу виглядає так, ніби ми намагаємося «присвоїти нуль функції» — що, звісно, звучить як магія з темних підвалів C++. Насправді це спеціальна форма запису: pure virtual function, тобто «метод без реалізації в базовому класі, обовʼязковий для перевизначення».
У стандартах і супровідній документації для цієї частини оголошення використовують окремий термін pure-specifier. Тобто це не «випадковий трюк компілятора», а повноцінна конструкція мови.
Найпростіший «інтерфейс» для друку завдання може виглядати так:
#include <iostream>
struct ITaskPrinter {
virtual ~ITaskPrinter() = default;
virtual void print(const Task& t) const = 0; // pure virtual
};
Ключові моменти тут такі.
Ми написали virtual void print(...) const = 0;. Це означає, що базовий тип обіцяє: в усіх нащадків буде метод print, але сам не визначає, як саме відбувається друк. І так, = 0 — це не значення і не «нульовий вказівник», а частина синтаксису оголошення віртуальної функції.
Чому абстрактний клас не можна створити
Коли в класі зʼявляється хоча б одна pure virtual функція, клас стає абстрактним. Це означає, що обʼєкт такого класу не можна створити безпосередньо. І це цілком логічно: якщо в типі є «обовʼязковий метод без реалізації», то що взагалі має робити обʼєкт, коли цей метод викличуть? Розгублено мовчати? Писати «404: реалізацію не знайдено»? Компілятор розвʼязує цю проблему радикально: «не створюємо».
Ось демонстрація «на рівні відчуттів»:
int main() {
// ITaskPrinter p; // Помилка компіляції: абстрактний клас
}
Натомість абстрактний клас можна використовувати через посилання або вказівник на базовий тип — саме так, як ми вже робили з поліморфізмом. Тобто «сам обʼєкт базового типу» створити не можна, а от «посилання або вказівник на базовий тип» — цілком можна, адже фактично це буде обʼєкт нащадка.
До речі, уточнення й тонкощі перевірки «abstract class types» — це не лише навчальна тема. Такі речі справді обговорюють і фіксують на рівні WG21 — комітету C++.
3. Реалізація інтерфейсу на прикладі Tasky
Реалізація: нащадок зобовʼязаний перевизначити = 0
Тепер до найпрактичнішого: зробімо два принтери завдань. Один друкує «звичайним текстом», другий — «у рамці». Почнімо з простого:
#include <iostream>
struct PlainTaskPrinter : ITaskPrinter {
void print(const Task& t) const override {
std::cout << "#" << t.id << ": " << t.title << "\n";
}
};
Другий варіант — «у рамці»; це мініверсія, без ефектного ASCII-арту на пів екрана:
#include <iostream>
struct BoxTaskPrinter : ITaskPrinter {
void print(const Task& t) const override {
std::cout << "[#" << t.id << "] " << t.title << (t.done ? " (done)\n" : "\n");
}
};
Зверніть увагу: ми поставили override. Це наш пасок безпеки: компілятор підтвердить, що ми справді перевизначили метод базового інтерфейсу, а не просто написали «схожий» метод. Класична помилка — забути const або переплутати параметри.
Тепер напишімо функцію, яка друкує всі завдання, нічого не знаючи про конкретний принтер:
#include <vector>
void print_all(const std::vector<Task>& tasks, const ITaskPrinter& printer) {
for (const auto& t : tasks) {
printer.print(t);
}
}
І зберімо невелике демо:
#include <vector>
int main() {
std::vector<Task> tasks = {
{1, "Write lecture about abstract classes", false},
{2, "Drink tea (compulsory)", true},
};
PlainTaskPrinter plain;
BoxTaskPrinter box;
print_all(tasks, plain);
// #1: Write lecture about abstract classes
// #2: Drink tea (compulsory)
print_all(tasks, box);
// [#1] Write lecture about abstract classes
// [#2] Drink tea (compulsory) (done)
}
Ось у цьому і полягає головний сенс інтерфейсу або абстрактного класу: клієнтський код залежить від можливостей (print), а не від конкретного класу PlainTaskPrinter чи BoxTaskPrinter.
Абстрактність успадковується
Це дуже поширена ситуація серед новачків: «Я успадкував клас… а він усе одно абстрактний… компілятор свариться… життя — біль». Це не баг. Це логіка.
Якщо нащадок не реалізував усі pure virtual методи, він теж стає абстрактним. Наприклад:
struct BrokenPrinter : ITaskPrinter {
// void print(...) не реалізовано => клас усе ще абстрактний
};
І знову маємо те саме:
int main() {
// BrokenPrinter bp; // Помилка компіляції: усе ще абстрактний
}
Така поведінка — насправді турбота про ваше майбутнє. Компілятор ніби каже: «Ти обіцяв реалізувати контракт. Не реалізував — я не дам створити обʼєкт, який потім вибухне під час першого ж виклику».
override: страховка від «майже такого самого методу»
На практиці найприкріша помилка з віртуальними методами виникає тоді, коли вам здається, що ви все перевизначили, а насправді написали іншу сигнатуру. Тоді віртуальний виклик потрапляє в базову версію — або код узагалі не компілюється, якщо база pure virtual, — а ви сидите й дивитеся на екран так, ніби це він вас зрадив.
Типовий приклад: ви забули const у методі, і це вже інший метод.
struct AlmostPrinter : ITaskPrinter {
void print(const Task& t) override { // <-- немає const, буде помилка завдяки override
std::cout << t.title << "\n";
}
};
І от тут override — наш герой. Без override компілятор міг би промовчати й створити новий метод print, який не перевизначає інтерфейсний. А з override він чесно скаже: «друже, ти нічого не перевизначив».
Практичне правило таке: кожен метод, який, як вам здається, ви перевизначаєте, має містити override. Це як пристібатися в машині: можна й не пристібатися, але статистика та фізика будуть проти вас.
Віртуальний деструктор в інтерфейсі
В абстрактних класах, які використовують поліморфно (через Base* / Base&), вкрай важливо, щоб деструктор базового класу був віртуальним. Інакше видалення обʼєкта нащадка через вказівник на базу може призвести до того, що деструктор нащадка не викличеться, а це вже шлях до витоку ресурсів або інших неприємних наслідків.
Ми вже використали правильну форму:
struct ITaskPrinter {
virtual ~ITaskPrinter() = default;
virtual void print(const Task& t) const = 0;
};
Чому = default тут доречне? Тому що нам не потрібна власна логіка. Ми просто хочемо забезпечити «віртуальність» і коректну поведінку. Тобто це не «особлива реалізація», а правильне налаштування класу.
Навіть якщо зараз ми не видаляємо обʼєкти через delete вручну — і це чудово, — правило залишається тим самим: поліморфна база — віртуальний деструктор. Це частина базової гігієни C++.
4. «Інтерфейс» у C++ як стиль
У деяких мовах є ключове слово interface. У C++ окремого такого слова немає, тому роль інтерфейсу зазвичай виконує абстрактний клас — часто без полів даних, із віртуальним деструктором і набором pure virtual методів.
Важливо розуміти одну тонкість: абстрактний клас може мати поля і навіть методи з реалізацією. Але коли ми в навчальному сенсі кажемо «інтерфейс», то зазвичай маємо на увазі «тип лише про поведінку». Тобто тримати всередині інтерфейсу std::vector<Task> «про запас» — це як носити в кишені каструлю: теоретично можна, але у вашого оточення точно виникнуть запитання.
Корисно тримати в голові таку мінітаблицю:
| Поняття | Як виглядає в коді | Що означає |
|---|---|---|
| Віртуальна функція | |
У базового типу є реалізація; нащадок може перевизначити |
| Pure virtual | |
Реалізації в базовому класі немає; нащадок зобовʼязаний перевизначити |
| Абстрактний клас | є хоча б один = 0 | Не можна створити обʼєкт безпосередньо |
| «Інтерфейс» (стиль) | абстрактний клас без даних | Тип описує «що вміє», а не «що зберігає» |
І ще одна схема, щоб не плутатися, хто є хто:
classDiagram
class ITaskPrinter {
<<абстрактний>>
+print(Task) const = 0
+~ITaskPrinter()
}
class PlainTaskPrinter {
+print(Task) const
}
class BoxTaskPrinter {
+print(Task) const
}
ITaskPrinter <|-- PlainTaskPrinter
ITaskPrinter <|-- BoxTaskPrinter
5. Типові помилки
Помилка № 1: сприймати = 0 як «там зберігається нуль» або «це значення за замовчуванням».
Зазвичай це трапляється через візуальну схожість із присвоєнням. Але в pure virtual немає жодного «значення». Це маркер: «реалізації в цьому класі немає». Якщо тримати це в голові, читати таке оголошення стає значно простіше: = 0 — не про дані, а про обовʼязок нащадка.
Помилка № 2: намагатися створити обʼєкт інтерфейсу безпосередньо.
Новачок пише ITaskPrinter p; і дивується помилці компілятора. Абстрактний клас — це тип для взаємодії, а не готовий обʼєкт. Потрібно створювати конкретний клас-нащадок і працювати через ITaskPrinter& / ITaskPrinter*.
Помилка № 3: забути реалізувати один pure virtual метод і отримати «клас усе ще абстрактний».
Це особливо підступно, коли інтерфейс розрісся до кількох методів, а ви реалізували лише два з трьох. У підсумку код виглядає «майже готовим», але обʼєкт створити не можна. Гарна звичка — додавати методи в інтерфейс обережно й одразу виправляти всі реалізації. Інакше ви самі влаштовуєте собі маленький апокаліпсис під час збирання.
Помилка № 4: не ставити override і випадково не перевизначити метод.
Найчастіший варіант — невідповідність const, типу параметра або навіть звичайна описка в імені. Без override компілятор може промовчати, а ви отримаєте дивну поведінку — або неможливість створити обʼєкт, якщо база була pure virtual. З override ви отримуєте зрозумілу помилку саме там, де виникла проблема, — тобто одразу, доки «слід гарячий».
Помилка № 5: забути віртуальний деструктор у поліморфній базі.
Іноді це здається «неважливим»: мовляв, «у мене ж інтерфейс, я нічого не зберігаю». Але правило про віртуальний деструктор повʼязане не з тим, чи зберігаєте ви дані, а з тим, як саме ви знищуєте обʼєкт через базовий тип. Тому virtual ~I() = default; — це не прикраса, а страховка від дуже неприємних сюрпризів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ