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 {
        <<abstract>>
        +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; — это не украшение, а страховка от очень неприятных сюрпризов.

1
Задача
C++ SELF, 52 уровень, 0 лекция
Недоступна
Два приветствия
Два приветствия
1
Задача
C++ SELF, 52 уровень, 0 лекция
Недоступна
Сумма площадей
Сумма площадей
1
Задача
C++ SELF, 52 уровень, 0 лекция
Недоступна
Форматтер чата
Форматтер чата
1
Задача
C++ SELF, 52 уровень, 0 лекция
Недоступна
Печать заметок
Печать заметок
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ