JavaRush /Курси /C++ SELF /Pure virtual (= 0) та абстрактні класи

Pure virtual (= 0) та абстрактні класи

C++ SELF
Рівень 52 , Лекція 0
Відкрита

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> «про запас» — це як носити в кишені каструлю: теоретично можна, але у вашого оточення точно виникнуть запитання.

Корисно тримати в голові таку мінітаблицю:

Поняття Як виглядає в коді Що означає
Віртуальна функція
virtual void f();
У базового типу є реалізація; нащадок може перевизначити
Pure virtual
virtual void f() = 0;
Реалізації в базовому класі немає; нащадок зобовʼязаний перевизначити
Абстрактний клас є хоча б один = 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; — це не прикраса, а страховка від дуже неприємних сюрпризів.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ