При проектировании иерархии классов различайте наследование реализации и наследование интерфейса
Причина
Детали реализации в интерфейсе делают интерфейс хрупким; то есть делают его пользователей уязвимыми к необходимости перекомпилировать после изменений в реализации. Данные в базовом классе увеличивают сложность реализации базового класса и могут привести к дублированию кода.
Примечание
Определение:
в частности для того, чтобы позволить добавлять и изменять производные классы без влияния на пользователей базовых классов.
путём предоставления полезных операций для разработчиков связанных новых операций (иногда называется "программирование путём различия").
- наследование интерфейса — это использование наследования для разделения пользователей от реализаций,
- наследование реализации — это использование наследования для упрощения реализации новых возможностей
Чистый класс интерфейса — это просто набор чистых виртуальных функций; см. I.25.
В ранней ООП (например, в 1980-е и 1990-е годы) наследование реализации и наследование интерфейса часто смешивались и плохие привычки отмирают медленно. Даже сейчас, смешивание не редкость в старых кодовых базах и в старомодных учебных материалах.
Значение разделения двух видов наследования возрастает
(например, может быть сложно распространить обновление базового класса)
- с размером иерархии (например, десятки производных классов),
- с длительностью использования иерархии (например, десятилетия), и
- с количеством различных организаций, использующих иерархию
Плохой пример
class Shape { // ПЛОХО, смешанные интерфейс и реализация
public:
Shape();
Shape(Point ce = {0, 0}, Color co = none): cent{ce}, col {co} { /* ... */}
Point center() const { return cent; }
Color color() const { return col; }
virtual void rotate(int) = 0;
virtual void move(Point p) { cent = p; redraw(); }
virtual void redraw();
// ...
private:
Point cent;
Color col;
};
class Circle : public Shape {
public:
Circle(Point c, int r) : Shape{c}, rad{r} { /* ... */ }
// ...
private:
int rad;
};
class Triangle : public Shape {
public:
Triangle(Point p1, Point p2, Point p3); // вычислить центр
// ...
};
Проблемы:
и все классы, производные от Shape, и весь код, использующий Shape, нужно будет рассмотреть, возможно, изменить и вероятно перекомпилировать.
- По мере роста иерархии и добавления большего количества данных в
Shape, конструкторы становятся труднее писать и поддерживать. - Зачем вычислять центр для
Triangle? Мы могли никогда не использовать его. - Добавьте элемент данных в
Shape(например, стиль рисования или холст)
Реализация Shape::move() — это пример наследования реализации: мы определили move() один раз и для всех производных классов. Чем больше кода в таких реализациях функций-членов базового класса и чем больше данных совместно используется путём их размещения в базовом классе, тем больше выгоды мы получаем — и тем менее стабильна иерархия.
Пример
Эта иерархия Shape может быть переписана с использованием наследования интерфейса:
class Shape { // чистый интерфейс
public:
virtual Point center() const = 0;
virtual Color color() const = 0;
virtual void rotate(int) = 0;
virtual void move(Point p) = 0;
virtual void redraw() = 0;
// ...
};
Обратите внимание, что чистый интерфейс редко имеет конструкторы: нечего конструировать.
class Circle : public Shape {
public:
Circle(Point c, int r, Color c) : cent{c}, rad{r}, col{c} { /* ... */ }
Point center() const override { return cent; }
Color color() const override { return col; }
// ...
private:
Point cent;
int rad;
Color col;
};
Теперь интерфейс менее хрупкий, но больше работы в реализации функций-членов. Например, center должен быть реализован каждым классом, производным от Shape.
Пример: двойная иерархия
Как мы можем получить выгоду от стабильных иерархий из иерархий интерфейсов и выгоду от повторного использования реализации из наследования реализации? Одна популярная техника — двойные иерархии. Есть много способов реализовать идею двойных иерархий; здесь мы используем вариант множественного наследования.
Сначала мы придумываем иерархию классов интерфейса:
class Shape { // чистый интерфейс
public:
virtual Point center() const = 0;
virtual Color color() const = 0;
virtual void rotate(int) = 0;
virtual void move(Point p) = 0;
virtual void redraw() = 0;
// ...
};
class Circle : public virtual Shape { // чистый интерфейс
public:
virtual int radius() = 0;
// ...
};
Чтобы сделать этот интерфейс полезным, мы должны предоставить его классы реализации (здесь названные эквивалентно, но в пространстве имён Impl):
class Impl::Shape : public virtual ::Shape { // реализация
public:
// конструкторы, деструктор
// ...
Point center() const override { /* ... */ }
Color color() const override { /* ... */ }
void rotate(int) override { /* ... */ }
void move(Point p) override { /* ... */ }
void redraw() override { /* ... */ }
// ...
};
Теперь Shape — плохой пример класса с реализацией, но потерпите, потому что это просто простой пример техники, предназначенной для более сложных иерархий.
class Impl::Circle : public virtual ::Circle, public Impl::Shape { // реализация
public:
// конструкторы, деструктор
int radius() override { /* ... */ }
// ...
};
И мы могли бы расширить иерархии, добавив класс Smiley (:-)):
class Smiley : public virtual Circle { // чистый интерфейс
public:
// ...
};
class Impl::Smiley : public virtual ::Smiley, public Impl::Circle { // реализация
public:
// конструкторы, деструктор
// ...
}
Теперь есть две иерархии:
- интерфейс: Smiley -> Circle -> Shape
- реализация: Impl::Smiley -> Impl::Circle -> Impl::Shape
Поскольку каждая реализация наследуется как от своего интерфейса, так и от своего базового класса реализации, мы получаем решётку (DAG):
Smiley -> Circle -> Shape
^ ^ ^
| | |
Impl::Smiley -> Impl::Circle -> Impl::Shape
Как упоминалось, это просто один из способов построения двойной иерархии.
Иерархия реализации может использоваться напрямую, а не через абстрактный интерфейс.
void work_with_shape(Shape&);
int user()
{
Impl::Smiley my_smiley{ /* args */ }; // создать конкретную фигуру
// ...
my_smiley.some_member(); // использовать класс реализации напрямую
// ...
work_with_shape(my_smiley); // использовать реализацию через абстрактный интерфейс
// ...
}
Это может быть полезно, когда класс реализации имеет элементы, которые не предлагаются в абстрактном интерфейсе или если прямое использование элемента обеспечивает возможности оптимизации (например, если функция-член реализации является final).
Примечание
Другой (связанный) метод разделения интерфейса и реализации — Pimpl.
Примечание
Часто бывает выбор между предоставлением общей функциональности как функций (реализованного) базового класса и автономных функций (в пространстве имён реализации). Базовые классы дают более короткую запись и более лёгкий доступ к общим данным (в базовом классе) ценой того, что функциональность доступна только пользователям иерархии.
Применение
(кроме вызовов из функции-члена производного класса к функции-члену базового класса)
- Пометить преобразование производного класса в базовый класс с базовым классом, имеющим и данные, и виртуальные функции
- ???