Как у наших ворот за горою
Жил да был бутерброд с колбасою.
Захотелось ему прогуляться,
На траве-мураве поваляться.
И сманил он с собой на прогулку
Краснощёкую сдобную булку.
Корней Чуковский
В трёх первых частях серии мы подробно рассмотрели многие аспекты, касающиеся наследования и полиморфизма в 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 (не забудем #include <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 в компиляторах нужно подключать отдельно.