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

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 · наследование и полиморфизм · программирование
Оставить комментарий (отменить)