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

4. Приведение dynamic_cast и RTTI

Как у наших ворот за горою
Жил да был бутерброд с колбасою.
Захотелось ему прогуляться,
На траве-мураве поваляться.
И сманил он с собой на прогулку
Краснощёкую сдобную булку.
Корней Чуковский

В трёх первых частях серии мы подробно рассмотрели многие аспекты, касающиеся наследования и полиморфизма в C++. Сегодня поговорим о динамическом приведении типа и об идентификации типа на этапе исполнения программы.

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

Как всегда, придумаем какую-нибудь несложную иерархию.

class Sandwich
 {
 public:
 virtual ~Sandwich()
 {
 }
 
 virtual void Print() const
 {
 std::cout << "Sandwich." << std::endl;
 }
 };
 
 class Butterbrot : public Sandwich
 {
 public:
 void Print() const
 {
 std::cout << "Butterbrot." << std::endl;
 }
 };
 
 class ButterbrotWithCaviar : public Butterbrot
 {
 public:
 void Print() const
 {
 std::cout << "Butterbrot with caviar." << std::endl;
 }
 };
 
 class HamSandwich : public virtual Sandwich
 {
 public:
 void Print() const
 {
 std::cout << "Ham sandwich." << std::endl;
 }
 };
 
 class CheeseSandwich : public virtual Sandwich
 {
 public:
 void Print() const
 {
 std::cout << "Cheese sandwich." << std::endl;
 }
 };
 
 class HamAndCheeseSandwich : public HamSandwich, public CheeseSandwich
 {
 public:
 void Print() const
 {
 std::cout << "Ham and cheese sandwich." << std::endl;
 }
 };

Повышающее приведение

Повышающее приведение также называют восходящим (upcast), поскольку мы движемся от корня дерева вниз (корень традиционно находится наверху).

void Upcast(ButterbrotWithCaviar* butterbrotWithCaviar)
 {
 // OK: Butterbrot is a direct base class of ButterbrotWithCaviar.
 Butterbrot* pButterbrot = dynamic_cast<Butterbrot*>(butterbrotWithCaviar);
 // pButterbrot = butterbrotWithCaviar; // Implicit conversion.
 pButterbrot->Print();
 
 // OK: Sandwich is an indirect base class of ButterbrotWithCaviar.
 Sandwich* pSandwich = dynamic_cast<Sandwich*>(butterbrotWithCaviar);
 // pSandwich = butterbrotWithCaviar; // Implicit conversion.
 pSandwich->Print();
 
 // OK.
 void* pVoid = dynamic_cast<void*>(butterbrotWithCaviar);
 // pVoid->Print(); // Error.
 }

Как видно, можно явно и неявно приводить указатель (или даже ссылку) к типу, который является прямым или косвенным базовым классом, а также к типу void*.

Множественное наследование

На секунду представим, что в нашем примере используется не виртуальное наследование, а обычное. Тогда появится неоднозначность, если мы попытаемся привести указатель на HamAndCheeseSandwich к указателю на Sandwich. Поэтому придётся производить такое приведение в два этапа (HamAndCheeseSandwich → HamSandwich → Sandwich).

HamAndCheeseSandwich* pHcs = new HamAndCheeseSandwich;
 
 // Error: ambiguous.
 // Sandwich* pS = dynamic_cast<Sandwich*>(pHcs);
 
 // 1. Cast to HamSandwich.
 HamSandwich* pHs = dynamic_cast<HamSandwich*>(pHcs);
 // 2. OK: unambiguous.
 Sandwich* pS = dynamic_cast<Sandwich*>(pHs);

Виртуальное наследование

Но если всё же вернуться к изначальному варианту, когда использовалось виртуальное наследование, то такой неоднозначности не будет (объект производного класса хранит только одну копию базового класса).

HamAndCheeseSandwich* pHcs = new HamAndCheeseSandwich;
 Sandwich* pS = dynamic_cast<Sandwich*>(pHcs);

Понижающее приведение

Этот вид приведения также называют нисходящим (downcast).

Отдельно рассмотрим приведение указателей и ссылок. Может случиться так, что за указателем скрывается объект базового класса (Butterbrot), а мы пытаемся его привести к указателю на производный класс (ButterbrotWithCaviar). Но базовый класс ничего не знает о производном. И тогда результирующий указатель будет нулевым. Если же за указателем будет скрываться объект производного класса, то приведение пройдёт безо всяких ошибок.

void Downcast(Butterbrot* butterbrot)
 {
 ButterbrotWithCaviar* pBwc =
 dynamic_cast<ButterbrotWithCaviar*>(butterbrot);
 if (pBwc == nullptr)
 {
 // ...
 }
 }

В подобной ситуации при работе со ссылками будет выброшено исключение std::bad_cast.

void Downcast(Butterbrot& butterbrot)
 {
 try
 {
 ButterbrotWithCaviar& bwc =
 dynamic_cast<ButterbrotWithCaviar&>(butterbrot);
 }
 catch (const std::bad_cast& e)
 {
 std::cout << e.what() << std::endl;
 }
 }

Проверки никогда не бывают лишними. Нулевой указатель или исключение будет и в том случае, когда классы вообще не связаны отношением наследования.

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

Перекрёстное приведение

Более сложные (разрешённые) случаи называются перекрёстным приведением (crosscast).

void Crosscast()
 {
 HamSandwich* pHs = new HamAndCheeseSandwich;
 CheeseSandwich* pCs = dynamic_cast<CheeseSandwich*>(pHs);
 }

За указателем на HamSandwich скрывается объект типа HamAndCheeseSandwich (это важный момент). Можно привести его к указателю на «братский» CheeseSandwich.

Динамическая идентификация типа данных

Динамическая идентификация типа данных (Run-Time Type Identification, RTTI) позволяет сказать, к какому типу относится объект прямо во время исполнения программы. Будем использовать ту же иерархию. Создадим объект базового класса, объект производного класса, а также указатель и ссылку на базовый класс (за которыми будет скрываться объект производного класса).

Butterbrot butterbrot;
 ButterbrotWithCaviar butterbrotWithCaviar;
 Butterbrot* ptr = &butterbrotWithCaviar;
 Butterbrot& ref = butterbrotWithCaviar;

Воспользуемся RTTI (не забудем подключить заголовочный файл <typeinfo>).

// Butterbrot (compile-time).
 std::cout << typeid(butterbrot).name() << std::endl;
 
 // ButterbrotWithCaviar (compile-time).
 std::cout << typeid(butterbrotWithCaviar).name() << std::endl;

Чтобы определить тип, нужно воспользоваться typeid(), результатом которого является экземпляр класса type_info. А вот то, что возвращает type_info::name, зависит от конкретной реализации (implementation-defined).

В этих двух случаях (см. пример кода выше) результат будет известен уже на этапе компиляции: Butterbrot и ButterbrotWithCaviar.

Рассмотрим другой пример.

// Butterbrot* (compile-time).
 std::cout << typeid(ptr).name() << std::endl;
 
 // ButterbrotWithCaviar (run-time).
 std::cout << typeid(*ptr).name() << std::endl;

Оба этих случая похожи: разница только в звёздочке. Однако в первом случае тип (Butterbrot*) известен уже на этапе компиляции, а во втором он определяется уже во время выполнения программы (ButterbrotWithCaviar).

Определение типа работает и со ссылками.

// ButterbrotWithCaviar (run-time).
 std::cout << typeid(ref).name() << std::endl;

Если мы попытаемся динамически определить тип, когда указатель равен nullptr, то будет брошено исключение std::bad_typeid.

Butterbrot* p = nullptr;
 try
 {
 typeid(*p);
 }
 catch (const std::bad_typeid& e)
 {
 std::cout << e.what() << std::endl;
 }

А вот если за ссылкой будет скрываться nullptr, то это уже неопределённое поведение (undefined behavior), исключение std::bad_typeid выброшено не будет.

Butterbrot& pRef = *p; 
 // typeid(pRef); // Undefined behavior (dereferencing nullptr).

Заключение

Динамическое приведение типа dynamic_cast работает с полиморфными классами (иначе программа не скомпилируется). Оно влечёт накладные расходы, однако позволяет использовать все преимущества динамического полиморфизма (перемещение по иерархии наследования, включая виртуальное; работа старого кода с новым, подключаемым динамически и т. д.). И не следует забывать о проверках указателей и исключениях. Также стоит отметить, что зачастую RTTI в компиляторах нужно подключать отдельно.

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

  1. dynamic_cast-and-rtti.cpp
7 января 2017 · наследование и полиморфизм · программирование