Как объявить интерфейс в C++? (чисто виртуальные функции и абстрактные классы)
Правильное объявление интерфейса в C++
В C++ нет ключевого слова interface, но концепция интерфейса реализуется через абстрактные классы — классы, содержащие хотя бы одну чисто виртуальную функцию (pure virtual function). Ваш базовый подход верен, но есть несколько важных деталей, которые делают интерфейс правильным и безопасным.
1. Базовый шаблон интерфейса
Вот минимальный правильный интерфейс в современном C++:
class IDrawable {
public:
// Виртуальный деструктор (обязательно!)
virtual ~IDrawable() = default;
// Чисто виртуальные методы интерфейса
virtual void draw() = 0;
virtual void resize(int width, int height) = 0;
// Запрещаем копирование (опционально, но рекомендуется)
IDrawable(const IDrawable&) = delete;
IDrawable& operator=(const IDrawable&) = delete;
// Разрешаем перемещение (опционально)
IDrawable(IDrawable&&) = default;
IDrawable& operator=(IDrawable&&) = default;
protected:
// Защищенный конструктор — нельзя создать экземпляр напрямую
IDrawable() = default;
};
2. Почему виртуальный деструктор?
Если вы удаляете объект через указатель на базовый класс, без виртуального деструктора поведение неопределено:
IDrawable* shape = new Circle();
delete shape; // Без virtual ~IDrawable() — UB!
Делайте деструктор = default, если нет особых нужд. Чисто виртуальный деструктор (virtual ~IDrawable() = 0) — это антипаттерн, так как требует отдельной реализации.
3. Пример реализации
class Circle : public IDrawable {
public:
void draw() override {
std::cout << "Рисуем круг" << std::endl;
}
void resize(int w, int h) override {
radius_ = std::min(w, h) / 2;
}
private:
int radius_ = 10;
};
4. Множественное наследование и ромбовидная проблема
Если класс наследует несколько интерфейсов, используйте виртуальное наследование только при необходимости:
class IShape : public virtual IDrawable { /* ... */ };
class IColorful : public virtual IDrawable { /* ... */ };
class ColoredCircle : public IShape, public IColorful { /* ... */ };
Однако для простых интерфейсов без общих предков виртуальное наследование не нужно.
5. Современные альтернативы (C++20)
В C++20 появились концепты (concepts), которые можно использовать как "интерфейсы на этапе компиляции":
template <typename T>
concept Drawable = requires(T obj) {
{ obj.draw() } -> std::same_as<void>;
{ obj.resize(int{}, int{}) } -> std::same_as<void>;
};
// Использование:
template <Drawable D>
void render(D& drawable) {
drawable.draw();
}
6. Best practices
- Именование: добавляйте префикс
I(например,IDrawable) — это распространенное соглашение. - Чисто виртуальные методы: только они, без полей данных (интерфейс = контракт, а не реализация).
- Деструктор: всегда
virtualи= default. - Копирование: запретите (
= delete), так как полиморфные объекты копировать через базовый класс опасно. - Override: всегда используйте ключевое слово
overrideв производных классах — это ловит ошибки на этапе компиляции.
Ваш изначальный код был почти правильным, но отсутствие виртуального деструктора — критическая ошибка. Добавьте его, и всё заработает!
<<Альтернативный подход: интерфейсы через CRTP (Curiously Recurring Template Pattern)
Если вы не хотите платить за виртуальные вызовы (vtable) в runtime, можно использовать статический полиморфизм через шаблоны. Этот подход часто применяется в высокопроизводительном коде или в embedded-системах.
template <typename Derived>
class DrawableBase {
public:
void draw() {
static_cast<Derived*>(this)->draw_impl();
}
void resize(int w, int h) {
static_cast<Derived*>(this)->resize_impl(w, h);
}
};
class Circle : public DrawableBase<Circle> {
public:
void draw_impl() {
std::cout << "Круг (статический полиморфизм)" << std::endl;
}
void resize_impl(int w, int h) {
radius_ = std::min(w, h) / 2;
}
private:
int radius_ = 10;
};
Плюсы и минусы
- + Нет накладных расходов на vtable (быстрее, меньше памяти).
- + Методы могут быть инлайнированы.
- − Нельзя хранить разнотипные объекты в одном контейнере (нужен
std::variantили type erasure). - − Код сложнее читать и поддерживать.
Используйте CRTP, когда важна производительность и тип объекта известен на этапе компиляции. Для обычных задач классические виртуальные интерфейсы — лучший выбор.