За кулисами C++: статическое, реинтерпретирующее приведения типов и приведение типов в стиле C
Вы когда-нибудь задумывались, почему приведения типов в стиле 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):const_cast
static_cast
static_cast
, за которым следуетconst_cast
reinterpret_cast
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++ всегда первой размещает таблицу виртуальных функций, независимо от того, в каком порядке класс наследуется от родителей.