Язык C++
Наследование
Программы, написанные в рамках объектно-ориентированного подхода, можно рассматривать как модели систем, состоящих из взаимодействующих объектов. Проиллюстрируем эту мысль на примере модели текстовых символов. Каждый символ является представителем какого-то класса: буквы, цифры, знаки препинания и т.д. Усложним немного задачу и зададимся целью описать не абстрактные объекты, а символы графического текстового редактора, в состояние которых входят размер, цвет и шрифт.
Наших знаний достаточно, чтобы начать реализовывать классы, описывающие разные типы символов. На этом пути мы достаточно быстро столкнемся со сложностями. Каждый символ имеет размер, цвет и шрифт. Это значит, что нам придется описывать эти свойства несколько раз в разных классах. Подобное дублирование кода является верным признаком плохо спроектированной программы. При описании общих свойств символов нам пришлось бы синхронно редактировать многие классы, что почти гарантирует появление ошибок.
Есть и другая проблема. Наши классы имеют иерархическую структуру: гласные и согласные являются подклассом букв, согласные подразделяются на звонкие и глухие. Наконец, все классы являются символами. Нам нужен механизм, который позволит отразить эти отношения в коде.
Обе эти проблемы в ООП решаются с помощью наследования классов. Давайте начнем писать код:
class Character {
char symbol_;
size_t size_;
std::array<int, 3> color_;
std::string font_;
public:
Character(char sym) : symbol_(sym) {/*...*/}
void set_size(size_t s);
void set_color(int r, int g, int b);
void set_font(const std::string& font);
// ...
};
Мы объявили общий класс символов и поместили в него поля, которые есть у любого символа. Остальные классы будут наследниками класса Character
:
class Letter: public Character {
bool upper_case_;
public:
Letter(char lett) : Character(lett), upper_case_(false) {/* ... */}
void set_upper_case();
void set_lower_case();
// ...
};
Мы воспользовались механизмом публичного наследования, написав после имени класса ключевое слово public
и имя класса-предка. Существуют и другие виды наследования, но используются они весьма редко, и мы не будем их обсуждать. При публичном наследовании в классе-наследнике доступны все публичные поля и методы класса предка. Это значит, что следующий код будет корректно скомпилирован:
Letter lett('b');
lett.set_size(8);
При создании объекта класса-наследника сначала вызывается конструктор класса-предка, а потом только конструктор класса-наследника. Класс Character
не имеет конструктора по умолчанию, поэтому нам необходимо явно вызвать конструктор с параметрами в списке инициализации конструктора класса Letter
. При уничтожении объекта деструкторы вызываются в обратном порядке — сначала деструктор наследника, а потом деструктор предка.
Продолжим создавать нашу модель символов и объявим класс цифр:
class Digit: public Character {
int integer_value_;
public:
Digit(char digi) : Character(digi) {/* ... */}
int integer_value() const {return integer_value_;}
// ...
};
У нас уже есть три класса, составляющие иерархическую структуру. Давайте посмотрим как можно использовать созданные типы данных и как можно их улучшить.
Полиморфизм
Наследование в ООП является механизмом реализации идеи полиморфизма. В упрощенной формулировке полиморфизм означает использование объектов разных типов в некотором едином интерфейсе. Ниже мы покажем несколько примеров полиморфизма в C++. Полиморфизм, наряду с инкапсуляцией, составляет основу парадигмы ООП.
Текст является списком символов, который поддерживает эффективную вставку и удаление символа в произвольном месте, а также последовательный перебор символов. Подходящим контейнером в данной ситуации является std::list
. Осталось придумать как хранить в контейнере объекты разных типов Letter
и Digit
. Решением является использование объекта std::list<Character*>
. Такой контейнер может содержать указатели на объекты классов-наследников Letter
и Digit
:
Letter l1('H');
Letter l2('i');
Digit d1('9');
Digit d2('2');
std::list<Character*> document{&l1, &l2, &d1, &d2};
Мы можем объявить функцию, которая изменит размер всех символов в документе, независимо от их типа:
void set_size(const std::list<Character*>& doc, size_t size) {
std::for_each(doc.begin(), doc.end(),
[&size](Character* chptr) {chptr->set_size(size);});
}
Обращение к объектам разных классов наследников через указатель на объект их общего базового класса является примером полиморфизма в C++.
Виртуальные функции
Символы в графическом текстовом редакторе нужно отрисовывать на экране. Добавим метод draw
в классы Letter
и Digit
:
class Letter: public Character {
// ...
public:
void draw(size_t posx, size_t posy) const {
std::cout << "draw Letter\n";
}
// ...
};
class Digit: public Character {
// ...
public:
void draw(size_t posx, size_t posy) const {
std::cout << "draw Digit\n";
}
// ...
};
В нашем учебном примере мы будем выводить в консоль сообщения при вызове методов вместо реализации реальной логики. Чтобы полиморфно вызывать метод draw
, он должен быть также определен и в базовом классе:
class Character {
// ...
public:
// здесь есть проблема
void draw(size_t posx, size_t posy) const {
std::cout << "draw Character\n";
}
// ...
};
Как указано в комментарии, в текущем виде классы не будут работать так как мы хотим:
void draw_document(const std::list<Character*>& doc) {
size_t x = 0;
size_t y = 0;
std::for_each(doc.begin(), doc.end(), [&x, &y](Character* chptr) {
chptr->draw(x, y);
x += chptr->size();
});
}
Letter l1('H');
Digit d1('9');
std::list<Character*> document{&l1, &d1};
draw_document(document);
// draw Character
// draw Character
В обоих случаях был вызван метод базового класса. Чтобы указать компилятору на необходимость диспетчеризации функции draw
, в базовом классе ее следует отметить ключевым словом virtual
class Character {
// ...
public:
// здесь есть проблема, уже другая
virtual void draw(size_t posx, size_t posy) {
std::cout << "draw Character\n";
}
// ...
};
Если снова скомпилировать и исполнить код из примера выше, то мы снова обнаружим, что продолжает вызываться метод базового класса. Дело в том, что мы (якобы) случайно изменили сигнатуру виртуального метода в базовом классе, сделав его неконстантным. В этой ситуации константные методы перегружают метод базового класса, но не переопределяют его. Чтобы отлавливать подобные ситуации на этапе компиляции нам следует добавить ключевое слово override
в методы классов-потомков:
class Character {
// ...
public:
virtual void draw(size_t posx, size_t posy) const {
std::cout << "draw Character\n";
}
// ...
};
class Letter: public Character {
// ...
public:
virtual void draw(size_t posx, size_t posy) const override {
std::cout << "draw Letter\n";
}
// ...
};
class Digit: public Character {
// ...
public:
virtual void draw(size_t posx, size_t posy) const override {
std::cout << "draw Digit\n";
}
// ...
};
Теперь компилятор будет искать в базовом классе метод, который должен быть переопределен. И если не найдет его, то выдаст ошибку компиляции. Нам удалось реализовать второй механизм полиморфизма в C++ — вызовы виртуальных методов. При вызове виртуального метода от указателя на базовый класс во время выполнения программы выполняется проверка типа объекта и вызывается необходимая версия метода. Такое поведение называется динамической диспетчеризацией.
Абстрактные классы
Наша модель для символов имеет серьезный недостаток: мы в принципе можем создавать объекты класса Character
, которые по смыслу не должны создаваться. Класс Character
нужен лишь для определения общего интерфейса и реализации полиморфизма. Превратим класс Character
в абстрактный, превратив его метод draw
в чисто виртуальный:
class Character {
// ...
public:
virtual void draw(size_t posx, size_t posy) const = 0;
// ...
};
Чисто виртуальные методы не имеют определения. Класс, у которого есть хотя бы один чисто виртуальный метод, не может иметь объектов. Чтобы класс-наследник мог создавать объекты, в нем должны быть переопределены все чисто виртуальные методы всех базовых классов.
Наследование и включение
В заключение обсудим вопрос правильного проектирования иерархий классов. Создание класса-наследника не всегда является правильным решением. Вместо этого бывает лучше сделать объект полем нового класса и через работу с этим полем использовать функциональность класса. Выбор между этими двумя решениями не всегда очевиден. Правило, которое позволяет принять хорошее решение состоит в следующем: класс-наследник должен описывать подмножество объектов класса-предка. Именно так: объект класса-наследника должен в полной мере являться объектом класса-предка. Если это условие не выполняется, то скорее всего лучше подойдет включение, а не наследование.
Резюме
В этой части мы обсудили основные принципы наследования в C++: механизм публичного наследования, работу с объектами-потомками через указатель на объект-предок, виртуальные методы, абстрактные классы. Также мы показали, что наследование классов в C++ является механизмом реализации принципа полиморфизма.
За рамками нашего обсуждения остались многие продвинутые инструменты наследования, такие как множественное наследование, protected
поля и методы, protected
и private
механизмы наследования. Заинтересованный читатель сможет самостоятельно разобраться с этими вопросами. Некоторые ссылки на ресурсы для более глубокого изучения темы наследования C++ приведены ниже.