Синтаксис си/с++

Абстрактные классы и интерфейсы в C++

Введение

Статья 534 разобрала виртуальные функции — абстрактные классы доводят эту концепцию до логического предела, объявляя методы, которые ОБЯЗАНЫ быть переопределены в производных классах, без какой-либо реализации по умолчанию в самом базовом классе, что приближает их по духу к понятию «интерфейс» из других языков программирования.

Концепция

Чисто виртуальная функция (virtual void method() = 0;, с характерным синтаксисом = 0 вместо тела функции) объявляет метод без какой-либо реализации в этом классе, обязывая КАЖДЫЙ конкретный (не абстрактный) производный класс предоставить собственную реализацию — класс, содержащий хотя бы одну чисто виртуальную функцию, становится абстрактным классом, объекты которого нельзя создать напрямую (попытка new AbstractClass() является ошибкой компиляции), но можно использовать как базовый тип для полиморфных ссылок/указателей на реально созданные объекты производных, конкретных классов. C++ не имеет отдельного ключевого слова interface (в отличие от некоторых других объектно-ориентированных языков) — концепция «чистого интерфейса» выражается через абстрактный класс, состоящий ИСКЛЮЧИТЕЛЬНО из чисто виртуальных функций без каких-либо полей данных или реализованной логики.

Пример кода: Абстрактные классы и интерфейсы в C++

#include <iostream>
#include <memory>

class Drawable { // абстрактный класс — выступает в роли "интерфейса"
public:
    virtual void draw() const = 0; // ЧИСТО виртуальная функция — НЕТ реализации, обязательна для переопределения
    virtual ~Drawable() = default; // virtual-деструктор — статья 532/534
};

// Drawable shape; // ОШИБКА КОМПИЛЯЦИИ — нельзя создать объект АБСТРАКТНОГО класса напрямую

class Circle : public Drawable {
public:
    void draw() const override // ОБЯЗАТЕЛЬНОЕ переопределение — иначе Circle сам остался бы абстрактным
    {
        std::cout << "Рисуем круг" << std::endl;
    }
};

class Square : public Drawable {
public:
    void draw() const override
    {
        std::cout << "Рисуем квадрат" << std::endl;
    }
};

void renderAll(const std::vector<std::unique_ptr<Drawable>> &shapes)
{
    for (const auto &shape : shapes) {
        shape->draw(); // полиморфный вызов — каждый конкретный тип рисует себя по-своему
    }
}
// "Чистый интерфейс" — абстрактный класс ИСКЛЮЧИТЕЛЬНО из чисто виртуальных методов, без данных
class Serializable {
public:
    virtual std::string serialize() const = 0;
    virtual void deserialize(const std::string &data) = 0;
    virtual ~Serializable() = default;
};

class Document : public Serializable, public Drawable // МНОЖЕСТВЕННОЕ наследование от нескольких "интерфейсов"
{
public:
    void draw() const override { std::cout << "Отображаем документ" << std::endl; }
    std::string serialize() const override { return "данные документа"; }
    void deserialize(const std::string &data) override { /* ... */ }
};

Пояснения к коду

Drawable::draw() const = 0; показывает синтаксис чисто виртуальной функции — характерное = 0 (а не реальное числовое значение присваивания, что может сбить с толку при первом знакомстве с этим синтаксисом) сигнализирует, что у этого метода нет реализации в данном классе, и попытка создать объект Drawable напрямую (закомментированная строка) является ошибкой компиляции именно из-за наличия этой непредоставленной реализации. Circle/Square, каждый предоставляющий собственную реализацию draw(), показывают типичный паттерн использования абстрактного класса как общего интерфейса для семейства related, но различающихся по конкретной реализации классов, обрабатываемых единообразно (как в renderAll) благодаря полиморфизму. Document : public Serializable, public Drawable показывает практичное применение множественного наследования (статья 533) именно для случая объединения нескольких «интерфейсов» — поскольку оба базовых класса состоят исключительно из чисто виртуальных методов без данных, проблема ромбовидного наследования (которая беспокоила бы при наследовании от классов с реальными данными) здесь не возникает, что делает этот случай множественного наследования значительно менее проблематичным по сравнению с общим случаем.

Подводные камни

  • Попытка создать объект абстрактного класса напрямую — это явная, обнаруживаемая на этапе компиляции ошибка, но разработчики, только начинающие работать с абстрактными классами, иногда неверно ожидают, что такая попытка приведёт к ошибке времени выполнения или будет как-то иначе «молчаливо» обработана, тогда как реальность — немедленная, явная ошибка компиляции, не позволяющая собрать программу вовсе при такой попытке.
  • Забытое переопределение хотя бы одной из нескольких чисто виртуальных функций в производном классе — производный класс, не предоставивший реализацию хотя бы одной чисто виртуальной функции базового класса, САМ остаётся абстрактным (даже если разработчик не намеревался делать его абстрактным), что выявляется при попытке создать объект ЭТОГО производного класса как точно такая же ошибка компиляции о невозможности создания объекта абстрактного типа, что может на первый взгляд сбить с толку, если разработчик не сразу осознаёт, что именно отсутствие переопределения какого-то одного, возможно упущенного из внимания метода базового класса делает производный класс абстрактным.
  • Объявление деструктора абстрактного класса не как virtual — аналогично подводным камням статей 532-534, это приводит к неполному уничтожению объектов производных классов при удалении через указатель на абстрактный базовый тип, и для абстрактных классов, предназначенных именно для полиморфного использования через указатели/умные указатели на базовый тип (как в примере с std::vector<std::unique_ptr<Drawable>>), virtual-деструктор не менее важен, чем для любого другого полиморфного базового класса.
  • Чрезмерное проектирование иерархии с глубоко вложенными, многоуровневыми абстрактными классами там, где более простая, «плоская» структура (один абстрактный интерфейс, несколько конкретных реализаций) решала бы задачу не менее эффективно — избыточная сложность иерархии наследования (особенно с множеством промежуточных, частично абстрактных уровней) усложняет понимание и поддержку кода без соразмерной практической выгоды, и при проектировании абстракций стоит стремиться к разумной простоте иерархии, соразмерной реальной сложности предметной области, а не создавать глубокую иерархию «на всякий случай», предвосхищая гипотетическую будущую потребность в дополнительной гибкости.