За кулисами C++: статическое, реинтерпретирующее приведения типов и приведение типов в стиле C

Добавлено 22 мая 2021 в 15:23

Вы когда-нибудь задумывались, почему приведения типов в стиле C и приведения reinterpret_cast считаются злом? Давайте подробно разберемся, что с ними не так.

Введение

C++ знает 5 разных приведений типов (да, приведение в стиле C не является reinterpret_cast):

  • static_cast: наименее опасное, может понижать указатели.
  • const_cast: удаляет модификатор const. При неправильном использовании это может быть убийственным, поскольку цель может быть действительно константной, и вы получите какие-нибудь ошибки недопустимого доступа.
  • dynamic_cast: безопасное переключение между классами, требует RTTI: а RTTI в C++ – это то, что часто вообще не включено.
  • reinterpret_cast: преобразует всё, что имеет тот же размер, например, int, в FancyClass* на x86. Теперь это уже не просто приведение, а просто способ сказать компилятору, выбросить информацию о типе и обрабатывать данные по-другому.
  • Приведение в стиле C с использованием синтаксиса (тип)переменная. Худшее из когда-либо изобретенных приведений. Оно пытается выполнить следующие приведения в указанном порядке (также смотрите стандарт C++, 5.4 expr.cast, параграф 5):
    1. const_cast
    2. static_cast
    3. static_cast, за которым следует const_cast
    4. reinterpret_cast
    5. reinterpret_cast, за которым следует const_cast
    А вы думали, что это просто единичное плохое приведение, на самом деле это гидра!

Практическое правило должно быть следующим: никогда не используйте reinterpret_cast или приведение в стиле C; если вам нужно выполнить приведение указателей, выполняйте их приведение через void* и только в случае крайней необходимости используйте reinterpret_cast – что означает, что вам действительно нужно реинтерпретировать данные. Помните, C++ – это экспертный язык, он дает вам полный контроль над вашей машиной, но с силой приходит ответственность!

Пример

Давайте посмотрим, почему «жесткие» приведения на самом деле зло, и почему их следует избегать, как чумы. В этом примере мы предположим следующую иерархию классов:

class ParentWithoutVtable
{    
    char empty;
};

class ParentWithVTable
{
    public:
        virtual ~ParentWithVTable () { }
};

class Derived : public ParentWithoutVtable, public ParentWithVTable
{
};

Теперь рассмотрим следующий код:

int main () 
{
    ParentWithoutVtable* p = new Derived ();
    Derived* a = reinterpret_cast<Derived*>(p); // (1)
    Derived* b = static_cast<Derived*>(p);      // (2)
    Derived* c = (Derived*)(p);                 // (3)
}

Что происходит? Что ж, 1 будет с треском провалиться, 2 будет работать, и 3 зависит от того, правильно ли вы включили заголовок или просто определили типы! Давайте сначала посмотрим на (1): он не работает, потому что указатель ParentWitoutVTable* указывает не на начало вновь созданного объекта Derived в памяти, а на другое место. Например, Visual C++ в качестве первого элемента класса помещает указатель таблицы виртуальных методов, поэтому реальный макет Derived выглядит примерно так:

struct __Derived
{
    function_ptr vtable; char empty;
}

Таким образом, если мы получим указатель на объект без vtable, он будет указывать на empty, в противном случае доступ через указатель не удастся. Теперь reinterpret_cast не знает об этом, и если вы вызовете функцию Derived, она будет думать, что empty – это vtable. static_cast работает правильно, но для этого он должен знать полное объявление обоих типов. Именно здесь приведение в стиле C опасно: пока доступно полное объявление, всё будет работать нормально без каких-либо предупреждений, но если вы решите использовать предварительное объявление типов, приведение в стиле C всё равно не будет выдавать предупреждение (в то время как static_cast не сработает!) и выполнит reinterpret_cast, который сломает ваш код. Так что имейте это в виду и избегайте приведения в стиле C любой ценой.

Изменение порядка, в котором Derived наследуется от своих родителей, не решает эту проблему. Стандарт C++ не дает никаких гарантий, как будут располагаться члены:

порядок наследования не имеет значения, за исключением случаев, указанных в семантике инициализации конструктором (12.6.2), очисткой (12.4) и разметкой хранилища (9.2, 11.1).

и в упомянутом абзаце говорится:

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

По сути, компилятор может разместить таблицу виртуальных функций там, где он хочет – например, Visual C++ всегда первой размещает таблицу виртуальных функций, независимо от того, в каком порядке класс наследуется от родителей.

Теги

C++ / Cppconst_castdynamic_castreinterpret_caststatic_castПриведение типа в стиле CПриведение типовЯвное преобразование типа

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.