Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

ООП на практике: интерфейсы, иерархии и посетитель

Механизмы разобраны: наследование, перегрузка, виртуальные методы. Этот урок – о том, как из них собирают работающую архитектуру и где проходят границы применимости.

Интерфейс

Отдельного ключевого слова в C++ нет. Интерфейс – это абстрактный класс без полей, у которого все методы чисто виртуальные:

class Serializable {
public:
    virtual std::string Serialize() const = 0;
    virtual ~Serializable() = default;
};

class Comparable {
public:
    virtual int CompareTo(const Comparable& other) const = 0;
    virtual ~Comparable() = default;
};

class Document : public Serializable, public Comparable {
public:
    std::string Serialize() const override;
    int CompareTo(const Comparable& other) const override;
};

Множественное наследование от интерфейсов – единственный его вид, который практически не создаёт проблем: полей нет, значит, нечему дублироваться.

Виртуальный деструктор в интерфейсе не опция. Объекты будут жить за указателем на интерфейс и удаляться через него – без virtual ~Interface() это неопределённое поведение и утечка всего, чем владел конкретный класс.

Иерархия Expression

Сквозной пример, к которому мы будем возвращаться, – арифметическое выражение:

struct Expression {
    virtual double Evaluate() const = 0;
    virtual ~Expression() = default;
};

struct Number : Expression {
    explicit Number(double value) : value_(value) {}

    double Evaluate() const override { return value_; }
    double Value() const { return value_; }

private:
    double value_;
};

struct BinaryOperation : Expression {
    BinaryOperation(Expression* left, char op, Expression* right)
        : left_(left), op_(op), right_(right) {}

    ~BinaryOperation() override {
        delete left_;      // владеем операндами – значит, удаляем
        delete right_;
    }

    double Evaluate() const override {
        double l = left_->Evaluate();
        double r = right_->Evaluate();

        switch (op_) {
            case '+': return l + r;
            case '-': return l - r;
            case '*': return l * r;
            case '/': return l / r;
        }

        return 0;
    }

    const Expression* Left() const { return left_; }
    const Expression* Right() const { return right_; }
    char Op() const { return op_; }

private:
    Expression* left_;
    char op_;
    Expression* right_;
};

Использование:

// 3 + 4.5 * 5
Expression* sub = new BinaryOperation(new Number(4.5), '*', new Number(5));
Expression* expr = new BinaryOperation(new Number(3), '+', sub);

std::cout << expr->Evaluate();   // 25.5
delete expr;                     // рекурсивно удалит всё дерево

Обратите внимание на владение: BinaryOperation удаляет свои операнды, поэтому delete expr разрушает дерево целиком, а sub отдельно удалять не нужно – он уже принадлежит expr. Именно такие негласные правила и заменяются на std::unique_ptr, когда до него доходит очередь.

Проблема расширения

Добавим операцию «напечатать выражение». Понадобится виртуальный метод Print во всех классах. Потом «упростить» – ещё один. Потом «посчитать глубину», «перевести в польскую запись», «проверить деление на ноль»…

Иерархия растёт по одной оси и раздувается по другой:

Добавить класс (UnaryOperation) Добавить операцию (Print)
Виртуальные методы дёшево: новый класс, реализовать методы дорого: править все классы
Посетитель дорого: править всех посетителей дёшево: новый посетитель

Выбор зависит от того, что в вашей задаче меняется чаще. Для языка программирования набор узлов AST стабилен, а операций над ним много – значит, посетитель.

Посетитель

Операция выносится в отдельный класс, а иерархия получает один-единственный дополнительный метод.

struct Visitor {
    virtual void VisitNumber(const Number* number) = 0;
    virtual void VisitBinaryOperation(const BinaryOperation* binary) = 0;
    virtual ~Visitor() = default;
};

struct Expression {
    virtual double Evaluate() const = 0;
    virtual void Visit(Visitor* visitor) const = 0;
    virtual ~Expression() = default;
};

struct Number : Expression {
    void Visit(Visitor* visitor) const override { visitor->VisitNumber(this); }
    // ...
};

struct BinaryOperation : Expression {
    void Visit(Visitor* visitor) const override { visitor->VisitBinaryOperation(this); }
    // ...
};

Ключевой момент – в методе Visit тип this уже конкретный, поэтому выбирается нужная перегрузка посетителя. Один виртуальный вызов «раскрывает» фактический тип, второй уводит в нужную операцию. Приём так и называется – двойная диспетчеризация.

Теперь операция – это класс:

struct PrintVisitor : Visitor {
    void VisitNumber(const Number* number) override {
        std::cout << number->Value();
    }

    void VisitBinaryOperation(const BinaryOperation* binary) override {
        std::cout << '(';
        binary->Left()->Visit(this);
        std::cout << ' ' << binary->Op() << ' ';
        binary->Right()->Visit(this);
        std::cout << ')';
    }
};

PrintVisitor printer;
expr->Visit(&printer);      // ((3) + ((4.5) * (5)))

Чтобы посетитель мог что-то делать, классам иерархии пришлось открыть доступ к своим данным – Value(), Left(), Right(), Op(). Это неизбежная плата: инкапсуляция слабеет ровно настолько, насколько операции вынесены наружу.

dynamic_cast

Иногда нужно узнать фактический тип объекта:

Expression* expr = Parse(code);

if (Number* number = dynamic_cast<Number*>(expr)) {
    std::cout << "это число: " << number->Value();
}

dynamic_cast возвращает корректный указатель или nullptr. Для ссылок вместо nullptr бросает std::bad_cast. Работает только для полиморфных классов – с хотя бы одним виртуальным методом, поскольку опирается на информацию о типе, доступную через таблицу виртуальных методов.

Цепочка if (dynamic_cast<A*>) ... else if (dynamic_cast<B*>) ... – верный признак того, что здесь должен быть виртуальный метод или посетитель. Такая цепочка ломается ровно тогда, когда кто-то добавит наследника и забудет её обновить, а компилятор об этом не скажет. Виртуальный метод забыть нельзя: не переопределив чисто виртуальный, класс просто не создастся.

Границы применимости

Виртуальные вызовы – не единственный и не всегда лучший инструмент.

Композиция вместо наследования. Если нужно переиспользовать код, а не подставлять объект вместо базового, – заведите поле. Иерархия из пяти уровней «ради переиспользования» читается хуже, чем пять независимых классов с общими полями.

Шаблоны вместо виртуальности. Если конкретный тип известен на этапе компиляции, шаблон даёт тот же полиморфизм без vptr и без косвенного вызова. Так работает std::sort с компаратором: никаких виртуальных методов, всё встраивается.

Свободные функции вместо методов. Не всё, что работает с объектом, обязано быть его методом. Distance(const Point&, const Point&) не привилегирована по отношению к точке и не должна раздувать её интерфейс.

ООП в C++ – один из доступных инструментов, а не обязательная форма записи. Язык не заставляет складывать всё в классы, и это стоит использовать: main, свободные функции и структуры без методов – совершенно нормальный код.