This lesson is not translated yet – the Russian original is below.
Механизмы разобраны: наследование, перегрузка, виртуальные методы. Этот урок – о том, как из них собирают работающую архитектуру и где проходят границы применимости.
Интерфейс
Отдельного ключевого слова в 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, свободные функции и структуры без
методов – совершенно нормальный код.