NSU Programming Программирование на C++ и Python

Наследование

Программы, написанные в рамках объектно-ориентированного подхода, можно рассматривать как модели систем, состоящих из взаимодействующих объектов. Проиллюстрируем эту мысль на примере модели текстовых символов. Каждый символ является представителем какого-то класса: буквы, цифры, знаки препинания и т.д. Усложним немного задачу и зададимся целью описать не абстрактные объекты, а символы графического текстового редактора, в состояние которых входят размер, цвет и шрифт.

Наших знаний достаточно, чтобы начать реализовывать классы, описывающие разные типы символов. На этом пути мы достаточно быстро столкнемся со сложностями. Каждый символ имеет размер, цвет и шрифт. Это значит, что нам придется описывать эти свойства несколько раз в разных классах. Подобное дублирование кода является верным признаком плохо спроектированной программы. При описании общих свойств символов нам пришлось бы синхронно редактировать многие классы, что почти гарантирует появление ошибок.

Есть и другая проблема. Наши классы имеют иерархическую структуру: гласные и согласные являются подклассом букв, согласные подразделяются на звонкие и глухие. Наконец, все классы являются символами. Нам нужен механизм, который позволит отразить эти отношения в коде.

Обе эти проблемы в ООП решаются с помощью наследования классов. Давайте начнем писать код:

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++ приведены ниже.

Документация