Полиморфная кухня

1. Наследование и полиморфизм в C++

В языке C++ наследование и полиморфизм — очень важные понятия. Эта тема довольно обширна, для её понимания необходимо хорошо уяснить для себя, как объекты реального мира соотносятся с объектами в языке, как и когда происходит создание и уничтожение объектов, как работать с памятью, ссылками, указателями и зачем необходимы списки инициализации. Не лишним будем знание стандартных контейнеров, умных указателей и понимание смысла константности.

Напишем класс Pancake. Добавим три поля: радиус блинчика, толщину и сорт муки. Конструктор этого класса снабдим списком инициализации. Последний штрих — напишем функцию print().

class Pancake
{
public:
    Pancake(int radiusMm, int thicknessMm, int flourSort) :
        radiusMm_(radiusMm),
        thicknessMm_(thicknessMm),
        flourSort_(flourSort)
    {
    }

    void print() const
    {
        std::cout << "Pancake." << std::endl;
    }

protected:
    unsigned radiusMm_;     // RRRRRRRR RRRRRRRR RRRRRRRR RRRRRRRR
    unsigned thicknessMm_;  // TTTTTTTT TTTTTTTT TTTTTTTT TTTTTTTT
    char flourSort_;        // SSSSSSSS 00000000 00000000 00000000
};

Посмотрим на размер объектов получившегося класса. В нашем случае это 12 байтов (см. комментарии в коде).

Можно создавать новые классы на основе существующих. Возьмём за основу класс Pancake и создадим новый класс CaramelPancake. Первый будем называть базовым классом, последний — производным.

Детально рассмотрим следующий фрагмент.

class CaramelPancake : public Pancake
{
public:
    CaramelPancake(int radiusMm,  int thicknessMm,
                   int flourSort, int caramelMl) :
        Pancake(radiusMm, thicknessMm, flourSort),
        caramelMl_(caramelMl)
    {
    }

    void print() const
    {
        print(); // Override?
    }

// protected:
    // unsigned radiusMm_;    // RRRRRRRR RRRRRRRR RRRRRRRR RRRRRRRR
    // unsigned thicknessMm_; // TTTTTTTT TTTTTTTT TTTTTTTT TTTTTTTT
    // char flourSort_;       // SSSSSSSS 00000000 00000000 00000000

private:
    int caramelMl_;           // CCCCCCCC CCCCCCCC CCCCCCCC CCCCCCCC
};

Объявление производного класса включает в себя : public Pancake (если не указать явно, по умолчанию будет private). Несложно догадаться, что это указание базового класса (которых может быть несколько, они перечисляются через запятую). Что в данном контексте означает слово public? Взглянем на таблицу.

 

: public

: protected

: private

public

public

protected

private

protected

protected

protected

private

private

Читать её следует так: если функция или поле базового класса были объявлены с квалификатором public или protected, то при открытом наследовании (вторая колонка) этот квалификатор сохраняется. Поля и функции, объявленные как private, вовсе будут недоступны из производного класса. Но будут ли такие поля присутствовать в производном классе? Как ни странно, да (ответ прост — каждый блин с карамелью в первую очередь является блином). Теперь посмотрим на последнюю строку листинга: мы добавили новое поле. Побитовое представление каждого объекта нового класса будет дополнено новым полем. Таким образом, размер объекта этого производного класса — 16 байтов (12 байтов занимает базовая часть, ещё четыре мы добавили).

Каким образом конструируются объекты производного класса? Сперва конструируется та часть, которая является базовой. И поскольку базовый класс не имеет конструктора по умолчанию, мы обязаны добавить в список инициализации вызов конструктора базового класса: Pancake(radiusMm, thicknessMm, flourSort). (Если у базового класса есть конструктор по умолчанию, в этом нет необходимости, конструктор будет вызван.)

Порядок уничтожения объекта производного класса обратный: сначала вызываются деструкторы для полей, специфичных для этого класса, а затем — деструктор базового класса (деструкторы базовых классов).

А теперь самое интересное: в производном классе мы объявили и определили функцию print(), но в базовом классе уже есть точно такая же.

Рассмотрим разные варианты. Пусть функция CaramelPancake::print() выглядит следующим образом.

void print() const
{
    print(); // Pancake::print() or CaramelPancake::print()?
}

Будет ли бесконечная цепочка рекурсивных вызовов? Да. Потому что функция оказалась замещена (override). То есть осуществляется вызов CaramelPancake::print().

Изменим функцию: сделаем вызов функции Pancake::print().

void print() const
{
    Pancake::print();
}

Что с этими классами теперь можно сделать?

 int main()
{
    using namespace Kitchen;

    std::cout << sizeof(Pancake) << std::endl;
    std::cout << sizeof(CaramelPancake) << std::endl;

    CaramelPancake cp(120, 15, 1, 2); // #8.
    Pancake p = cp; // Slicing. // #9.
    p = cp; // Slicing. // #10.

    ///////////////////////////////////////

    Pancake pancake(120, 10, 1); // #14.
    CaramelPancake caramelPancake(120, 15, 1, 2); // #15.

    Pancake* pointer2pancake = &caramelPancake; // #17.

    // CaramelPancake* pointer2caramelPancake = &pancake; // Error. // #19.

    CaramelPancake* pointer2caramelPancake = // #21.
        static_cast<CaramelPancake*>(&pancake); // Danger. // #22.

    caramelPancake.print(); // #24.
}

В строке № 8 мы создаём объект производного класса. В следующей строке мы создаём объект базового класса, вызывая конструктор копирования. Каждый блин с карамелью — это блин, но не наоборот. Блин ничего не знает о своих потомках. Поэтому часть, относящаяся только к производному классу, будет потеряна. Это называется срезкой (slicing). То же самое происходит в строке № 10. (Само собой, если конструктора копирования или оператора присваивания нет, они будут сгенерированы компилятором.)

Поэтому типичным подходом в таких случаях является работа через указатели и ссылки.

Создадим новые объекты базового и производного классов (строки №№ 14 и 15). 17-я строка заключает в себе весь смысл нашего предприятия: указатель на объект производного класса может быть с лёгкостью преобразован к указателю на базовый класс. Это открывает широкие возможности, которые мы очень подробно рассмотрим ниже.

Обратное не является верным: нельзя неявно привести указатель на базовый класс к указателю на производный класс (строка № 19). Не каждый блин — блин с карамелью. Один из вариантов обхода этого ограничения представлен в коде (строки №№ 21—22), но он является потенциально опасным.

В строке № 24 происходит вызов фукнции CaramelPancake::print(), которая, как мы видели, в свою очередь вызывает Pancake::print(). Более подробно обо всём этом, опять же, далее.

Строим иерархию классов

Производный класс сам может быть использован в качестве базового. Можно строить целые деревья, а если учесть, что в качестве базовых классов можно указать не один класс, то даже более широко — направленные ациклические графы.

Напишем код, реализующий следующую иерархию.

 
                              Salad
                 ┌────────────────┼────────────────┐
              Olivier         Vinegret      FruitSalad
                                         ┌────────┴─────┐
                                       Rojak          SomTam
Версия первая

class Salad
{
public:
    void print() const
    {
        std::cout << "Salad." << std::endl;
    }
};



class Olivier : public Salad
{
public:
    void print() const
    {
        std::cout << "Olivier." << std::endl;
    }
};



class Vinegret : public Salad
{
public:
    void print() const
    {
        std::cout << "Vinegret." << std::endl;
    }
};



class FruitSalad : public Salad
{
public:
    void print() const
    {
        std::cout << "Fruit salad." << std::endl;
    }
};



class Rojak : public FruitSalad
{
public:
    void print() const
    {
        std::cout << "Rojak." << std::endl;
    }
};



class SomTam : public FruitSalad
{
public:
    void print() const
    {
        std::cout << "Som tam." << std::endl;
    }
};

Создадим экземпляры всех этих классов (для удобства воспользуемся умными указателями).

int main()
{
    using namespace Kitchen;

    typedef std::unique_ptr<Salad> salad_ptr;
    std::list<salad_ptr> salads;
    salads.push_back(salad_ptr(new Salad));
    salads.push_back(salad_ptr(new Olivier));
    salads.push_back(salad_ptr(new Vinegret));
    salads.push_back(salad_ptr(new FruitSalad));
    salads.push_back(salad_ptr(new Rojak));
    salads.push_back(salad_ptr(new SomTam));

    for (auto& salad : salads)
    {
        salad->print();
    }
}

Консольный вывод будет следующим.

Salad.
Salad.
Salad.
Salad.
Salad.
Salad.

Среда исполнения не знает ничего о том, объект какого класса кроется за указателем (на объект якобы базового класса). Поэтому в каждом из случаев вызывается функция Salad::print().

Версия вторая

Если мы хотим вызывать print() соответствующих классов, то эту функцию следует сделать виртуальной (сигнатура должна совпадать вплоть до константности).

class Salad
{
public:
    virtual void print() const // Virtual.
    {
        std::cout << "Salad." << std::endl;
    }
};



class Olivier : public Salad
{
public:
    virtual void print() const
    {
        std::cout << "Olivier." << std::endl;
    }
};



class Vinegret : public Salad
{
public:
    virtual void print() const
    {
        std::cout << "Vinegret." << std::endl;
    }
};



class FruitSalad : public Salad
{
public:
    virtual void print() const
    {
        std::cout << "Fruit salad." << std::endl;
    }
};



class Rojak : public FruitSalad
{
public:
    virtual void print() const
    {
        std::cout << "Rojak." << std::endl;
    }
};



class SomTam : public FruitSalad
{
public:
    virtual void print() const
    {
        std::cout << "Som tam." << std::endl;
    }
};

Классы Salad и его производные стали полиморфными. Во всех производных классах print() автоматически будет виртуальной, поэтому ключевое слово virtual у них указывать не обязательно, но желательно. (Если в производном классе нет замещающей версии виртуальной функции из базого класса, будет вызываться версия из базового класса.)

Теперь консольный вывод выглядит следующим образом.

Salad.
Olivier.
Vinegret.
Fruit salad.
Rojak.
Som tam.

Как это работает? Для каждого класса, содержащего хотя бы одну виртуальную функцию (классы, наследующиеся от таковых, тоже сюда подходят), создаётся специальная таблица — таблица виртуальных функций. А в каждый объект (как правило, в начало), добавляется указатель на одну из таких таблиц.

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

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

Версия третья

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

  1. Salad::Salad().
  2. Salad::Salad(), Olivier::Olivier().
  3. Salad::Salad(), Vinegret::Vinegret().
  4. Salad::Salad(), FruitSalad::FruitSalad().
  5. Salad::Salad(), FruitSalad::FruitSalad(), Rojak::Rojak().
  6. Salad::Salad(), FruitSalad::FruitSalad(), SomTam::SomTam().
  7. Salad::~Salad().
  8. Salad::~Salad().
  9. Salad::~Salad().
  10. Salad::~Salad().
  11. Salad::~Salad().
  12. Salad::~Salad().

Симметрия «конструктор — деструктор» нарушена. Чтобы её восстановить, нужно к базовому классу добавить виртуальный деструктор. И снова мы очень просто добиваемся желаемого эффекта: вызывается функция (деструктор) объекта соответствующего класса (а не базового). Выше мы уже сказали, что порядок уничтожения объекта производного класса таков: вызываются деструкторы полей самого производного класса, а затем деструктор базового класса (деструкторы базовых классов).

class Salad
{
public:
    virtual ~Salad()
    {
    }

    virtual void print() const // Virtual.
    {
        std::cout << "Salad." << std::endl;
    }
};

// ...
  1. Salad::Salad().
  2. Salad::Salad(), Olivier::Olivier().
  3. Salad::Salad(), Vinegret::Vinegret().
  4. Salad::Salad(), FruitSalad::FruitSalad().
  5. Salad::Salad(), FruitSalad::FruitSalad(), Rojak::Rojak().
  6. Salad::Salad(), FruitSalad::FruitSalad(), SomTam::SomTam().
  7. Salad::~Salad().
  8. Olivier::~Olivier(), Salad::~Salad().
  9. Vinegret::~Vinegret(), Salad::~Salad().
  10. FruitSalad::~FruitSalad(), Salad::~Salad().
  11. Rojak::~Rojak(), FruitSalad::~FruitSalad(), Salad::~Salad().
  12. SomTam::~SomTam(), FruitSalad::~FruitSalad(), Salad::~Salad().

Версия четвёртая

В большинстве случаев на этом можно остановиться. А теперь задумаемся: как можно сделать «просто салат» или «просто фруктовый салат»? В этом мало смысла. Сделаем функцию Salad::print() не просто виртуальной, а чисто виртуальной, добавив = 0 и убрав тело. Также полностью уберём функцию FruitSalad::print(), что будет означать, что в классе FruitSalad она по-прежнему чисто виртуальная, а сам класс FruitSalad — абстрактный.

class Salad
{
public:
    virtual ~Salad()
    {
    }

    virtual void print() const = 0; // Pure virtual.
};



class Olivier : public Salad
{
public:
    virtual void print() const
    {
        std::cout << "Olivier." << std::endl;
    }
};



class Vinegret : public Salad
{
public:
    virtual void print() const
    {
        std::cout << "Vinegret." << std::endl;
    }
};



class FruitSalad : public Salad
{
};



class Rojak : public FruitSalad
{
public:
    virtual void print() const
    {
        std::cout << "Rojak." << std::endl;
    }
};



class SomTam : public FruitSalad
{
public:
    virtual void print() const
    {
        std::cout << "Som tam." << std::endl;
    }
};

Если класс содержит хотя бы одну чисто виртуальную функцию, он называется абстрактным. Как правило, у чисто виртуальных функций нет тела. Если производный класс не определяет эту функцию (как FruitSalad), он тоже считается абстрактным. Нельзя создавать объекты абстрактных классов.

int main()
{
    using namespace Kitchen;

    typedef std::unique_ptr<Salad> salad_ptr;
    std::list<salad_ptr> salads;
    // salads.push_back(salad_ptr(new Salad)); // Error.
    salads.push_back(salad_ptr(new Olivier));
    salads.push_back(salad_ptr(new Vinegret));
    // salads.push_back(salad_ptr(new FruitSalad)); // Error.
    salads.push_back(salad_ptr(new Rojak));
    salads.push_back(salad_ptr(new SomTam));

    for (auto& salad : salads)
    {
        salad->print();
    }
}

Консольный вывод.

Olivier.
Vinegret.
Rojak.
Som tam.

Заключение

В статье мы рассмотрели основные возможности языка C++, касающиеся наследования и полиморфизма, а также реализовали иерархию классов (в особенности третий и четвёртый варианты).

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

Исходные файлы

  1. pancakes.cpp
  2. salads1.cpp
  3. salads2.cpp
  4. salads3.cpp
  5. salads4.cpp
25 августа 2016 · наследование и полиморфизм · программирование