В чем смысл `* dynamic_cast <T *> (...)`?

Недавно я смотрел в коде проекта с открытым исходным кодом, и я увидел кучу операторов формы T & object = *dynamic_cast<T*>(ptr);.

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

Для меня это выглядело как кодовый запах. Мое рассуждение состояло в том, что если вы знаете, что у вас получится, то почему бы не использовать static_cast? Если вы не уверены, не следует ли использовать тест для тестирования? Поскольку компилятор может предположить, что любой указатель, что вы * не является нулевым.

Я спросил одного из разработчиков на irc об этом, и он сказал, что он считает, что static_cast downcast небезопасен. Они могли бы добавить утверждение, но даже если это не так, он говорит, что вы все равно получите разыменование и сбой нулевого указателя, когда фактически используется obj. (Поскольку при ошибке dynamic_cast преобразует указатель в нуль, тогда, когда вы обращаетесь к любому члену, вы будете считывать с некоторого адреса значения, очень близкого к нулю, что ОС не позволит.) Если вы используете a static_cast, и это плохо, вы можете просто получить некоторое повреждение памяти. Таким образом, используя опцию *dynamic_cast, вы торгуете со скоростью для немного лучшей отладки. Вы не платите за утверждение, вместо этого вы в основном полагаетесь на ОС, чтобы поймать разворот nullptr, по крайней мере, то, что я понял.

Я принял это объяснение в то время, но это беспокоило меня, и я подумал об этом еще немного.

Вот мои рассуждения.

Если я понимаю стандартное право, приведенный указатель static_cast в основном означает выполнение некоторой фиксированной арифметики указателей. То есть, если у меня есть A * a, и я статирую его связанным типом B *, то что компилятор на самом деле собирается сделать с этим, это добавить смещение к указателю, смещение зависит только от макета типы A, B, (и какая реализация С++ потенциально). Эту теорию можно проверить с помощью статических указателей на void * и вывести их до и после статического броска. Я ожидаю, что если вы посмотрите на сгенерированную сборку, static_cast превратится в "добавить некоторую фиксированную константу в регистр, соответствующий указателю".

A dynamic_cast средство для перевода указателя, сначала проверьте RTTI и выполняйте статическую трансляцию, если она действительна на основе динамического типа. Если это не так, верните nullptr. Поэтому я ожидаю, что компилятор в какой-то момент расширит выражение dynamic_cast<B*>(ptr), где ptr имеет тип A* в выражение типа

(__validate_dynamic_cast_A_to_B(ptr) ? static_cast<B*>(ptr) : nullptr)

Однако, если мы тогда *, результат dynamic_cast, * of nullptr равен UB, поэтому мы неявно обещаем, что ветвь nullptr никогда не произойдет. И соответствующим компиляторам разрешено "отступать" от этого и исключать "нулевые проверки", находящуюся в центре внимания в Chris Lattner знаменитый пост в блоге.

Если тестовая функция __validate_dynamic_cast_A_to_B(ptr) непрозрачна для оптимизатора, то есть она может иметь побочные эффекты, то оптимизатор не может избавиться от нее, даже если она "знает" ветку nullptr не произойдет. Однако, вероятно, эта функция не является непрозрачной для оптимизатора - возможно, она очень хорошо понимает возможные побочные эффекты.

Итак, я ожидаю, что оптимизатор, по существу, преобразует *dynamic_cast<T*>(ptr) в *static_cast<T*>(ptr), и что их перестановка должна дать ту же сгенерированную сборку.

Если true, это оправдывало бы мой первоначальный аргумент, что *dynamic_cast<T*> - это запах кода, даже если вы действительно не заботитесь о UB в своем коде и только заботитесь о том, что происходит на самом деле. Поскольку, если соответствующему компилятору будет разрешено без необходимости изменять его на static_cast, тогда вы не получаете никакой безопасности, как вы думаете, поэтому вы должны явно указать t21 или явно утверждать. По крайней мере, это будет мой голос в обзоре кода. Я пытаюсь выяснить, действительно ли этот аргумент прав.


Вот что говорится в стандарте dynamic_cast:

[5.2.7] Динамический ролик [expr.dynamic.cast]
1. Результат выражения dynamic_cast<T>(v) является результатом преобразования выражения v в тип T. T должен быть указателем или ссылкой на полный тип класса или "указатель на cv void". Оператор dynamic_cast не должен отбрасывать константу.
...
8. Если C - тип класса, к которому указывает или ссылается T, проверка выполнения выполняется логически следующим образом: (8.1). Если в самом производном объекте, указанном (указанном) на v, v указывает (относится) к подобъекту общедоступного базового класса объекта C, и если только один объект типа C получается из подобъекта, на который указывает (ссылается) на v результат (относится) к этому объекту C.
(8.2) - В противном случае, если v указывает (относится) к подобъекту открытого базового класса самого производного объекта, а тип самого производного объекта имеет базовый класс типа C, который является однозначным и общедоступным, результирующие точки (ссылаются) на подобъект C самого производного объекта.
(8.3) - В противном случае проверка времени выполнения не выполняется.

Предполагая, что иерархия классов известна во время компиляции, также известны относительные смещения каждого из этих классов в макетах eachothers. Если v является указателем на тип A, и мы хотим отнести его к указателю типа B, и приведение недвусмысленно, тогда сдвиг, который должен принимать v, является константой времени компиляции. Даже если v фактически указывает на объект более производного типа C, этот факт не меняется, где подобъект A лежит относительно подобъекта B, правильно? Так что независимо от типа C, даже если это неизвестный тип из другой единицы компиляции, насколько мне известно, результат dynamic_cast<T*>(ptr) имеет только два возможных значения: nullptr или "фиксированное смещение от ptr > ".


Тем не менее, сюжет немного сгущается, фактически просматривая какой-то код gen.

Вот простая программа, которую я сделал, чтобы исследовать это:


int output = 0;

struct A {
  explicit A(int n) : num_(n) {}
  int num_;

  virtual void foo() {
    output += num_;
  }
};

struct B final : public A {
  explicit B(int n) : A(n), num2_(2 * n) {}

  int num2_;

  virtual void foo() override {
    output -= num2_;
  }
};

void visit(A * ptr) {
  B & b = *dynamic_cast<B*>(ptr);
  b.foo();
  b.foo();
}

int main() {
  A * ptr = new B(5); 

  visit(ptr);

  ptr = new A(10);
  visit(ptr);

  return output;
}

В соответствии с проводником компилятора godbolt, сборка gcc 5.3 x86 для этого с параметрами -O3 -std=c++11 выглядит следующим образом:


A::foo():
        movl    8(%rdi), %eax
        addl    %eax, output(%rip)
        ret
B::foo():
        movl    12(%rdi), %eax
        subl    %eax, output(%rip)
        ret
visit(A*):
        testq   %rdi, %rdi
        je      .L4
        subq    $8, %rsp
        xorl    %ecx, %ecx
        movl    typeinfo for B, %edx
        movl    typeinfo for A, %esi
        call    __dynamic_cast
        movl    12(%rax), %eax
        addl    %eax, %eax
        subl    %eax, output(%rip)
        addq    $8, %rsp
        ret
.L4:
        movl    12, %eax
        ud2
main:
        subq    $8, %rsp
        movl    $16, %edi
        call    operator new(unsigned long)
        movq    %rax, %rdi
        movl    $5, 8(%rax)
        movq    vtable for B+16, (%rax)
        movl    $10, 12(%rax)
        call    visit(A*)
        movl    $16, %edi
        call    operator new(unsigned long)
        movq    vtable for A+16, (%rax)
        movl    $10, 8(%rax)
        movq    %rax, %rdi
        call    visit(A*)
        movl    output(%rip), %eax
        addq    $8, %rsp
        ret
typeinfo name for A:
typeinfo for A:
typeinfo name for B:
typeinfo for B:
vtable for A:
vtable for B:
output:
        .zero   4

Когда я изменяю dynamic_cast на a static_cast, вместо этого получаю следующее:


A::foo():
        movl    8(%rdi), %eax
        addl    %eax, output(%rip)
        ret
B::foo():
        movl    12(%rdi), %eax
        subl    %eax, output(%rip)
        ret
visit(A*):
        movl    12(%rdi), %eax
        addl    %eax, %eax
        subl    %eax, output(%rip)
        ret
main:
        subq    $8, %rsp
        movl    $16, %edi
        call    operator new(unsigned long)
        movl    $16, %edi
        subl    $20, output(%rip)
        call    operator new(unsigned long)
        movl    12(%rax), %edx
        movl    output(%rip), %eax
        subl    %edx, %eax
        subl    %edx, %eax
        movl    %eax, output(%rip)
        addq    $8, %rsp
        ret
output:
        .zero   4

Здесь то же самое с clang 3.8 и теми же параметрами.

dynamic_cast:


visit(A*):                            # @visit(A*)
        xorl    %eax, %eax
        testq   %rdi, %rdi
        je      .LBB0_2
        pushq   %rax
        movl    typeinfo for A, %esi
        movl    typeinfo for B, %edx
        xorl    %ecx, %ecx
        callq   __dynamic_cast
        addq    $8, %rsp
.LBB0_2:
        movl    output(%rip), %ecx
        subl    12(%rax), %ecx
        movl    %ecx, output(%rip)
        subl    12(%rax), %ecx
        movl    %ecx, output(%rip)
        retq

B::foo():                            # @B::foo()
        movl    12(%rdi), %eax
        subl    %eax, output(%rip)
        retq

main:                                   # @main
        pushq   %rbx
        movl    $16, %edi
        callq   operator new(unsigned long)
        movl    $5, 8(%rax)
        movq    vtable for B+16, (%rax)
        movl    $10, 12(%rax)
        movl    typeinfo for A, %esi
        movl    typeinfo for B, %edx
        xorl    %ecx, %ecx
        movq    %rax, %rdi
        callq   __dynamic_cast
        movl    output(%rip), %ebx
        subl    12(%rax), %ebx
        movl    %ebx, output(%rip)
        subl    12(%rax), %ebx
        movl    %ebx, output(%rip)
        movl    $16, %edi
        callq   operator new(unsigned long)
        movq    vtable for A+16, (%rax)
        movl    $10, 8(%rax)
        movl    typeinfo for A, %esi
        movl    typeinfo for B, %edx
        xorl    %ecx, %ecx
        movq    %rax, %rdi
        callq   __dynamic_cast
        subl    12(%rax), %ebx
        movl    %ebx, output(%rip)
        subl    12(%rax), %ebx
        movl    %ebx, output(%rip)
        movl    %ebx, %eax
        popq    %rbx
        retq

A::foo():                            # @A::foo()
        movl    8(%rdi), %eax
        addl    %eax, output(%rip)
        retq

output:
        .long   0                       # 0x0

typeinfo name for A:

typeinfo for A:

typeinfo name for B:

typeinfo for B:

vtable for B:

vtable for A:

static_cast:


visit(A*):                            # @visit(A*)
        movl    output(%rip), %eax
        subl    12(%rdi), %eax
        movl    %eax, output(%rip)
        subl    12(%rdi), %eax
        movl    %eax, output(%rip)
        retq

main:                                   # @main
        retq

output:
        .long   0                       # 0x0

Итак, в обоих случаях, кажется, что dynamic_cast не может быть устранено оптимизатором:

Кажется, он вызывает вызовы таинственной функции __dynamic_cast, используя типinfo обоих классов, независимо от того, что. Даже если все оптимизации включены, а B отмечен как final.

  • Есть ли у этого низкоуровневого вызова побочные эффекты, которые я не рассматривал? Я понял, что vtables по существу фиксированы и что vptr в объекте не меняется... я прав? У меня есть только базовое знакомство с тем, как фактически реализуются виртуальные потоки, и я обычно избегаю виртуальных функций в своем коде, поэтому я не думал об этом глубоко или накопленный опыт.

  • Я прав, что соответствующий компилятор может заменить *dynamic_cast<T*>(ptr) на *static_cast<T*>(ptr) как правильную оптимизацию?

  • Верно ли это, что "обычно" (что означает, что на машинах x86, скажем так, и кастинг между классами в иерархии "обычной" сложности) a dynamic_cast не может быть оптимизирован и фактически будет производить a nullptr, даже если вы * это сразу после, приводя к nullptr разыменованию и сбою при доступе к объекту?

  • Является ли "всегда заменить *dynamic_cast<T*>(ptr) либо тестом dynamic_cast +, либо каким-либо утверждением, либо с помощью *static_cast<T*>(ptr)" звукового совета?

Ответ 1

T& object = *dynamic_cast<T*>(ptr); нарушается, потому что он вызывает UB при ошибке, периоде. Я не вижу необходимости в этом. Даже если он работает с текущими компиляторами, он может не работать в более поздних версиях с более агрессивными оптимизаторами.

Если вы хотите проверить и не хотите беспокоиться о написании утверждения, используйте ссылочную форму, которая бросает bad_cast при ошибке:

T& object = dynamic_cast<T&>(*ptr);

dynamic_cast - это не просто проверка времени выполнения. Он может делать вещи static_cast не может. Например, он может перемещаться в сторону.

A   A (*)
|   |
B   C
\   /
 \ /
  D

Если фактический наиболее производный объект является D, и у вас есть указатель на базу A, помеченную *, вы можете на самом деле dynamic_cast его получить указатель на подобъект B:

struct A { virtual ~A() = default; };
struct B : A {};
struct C : A {};
struct D : B, C {};
void f() {
    D d;
    C& c = d;
    A& a = c;
    assert(dynamic_cast<B*>(&a) != nullptr);
}

Обратите внимание, что здесь static_cast было бы совершенно неправильно.

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

В мире без final или знаниях всей программы вам нужно выполнить проверку во время выполнения (потому что C и D могут быть недоступны для вас). С final на B вы сможете уйти, не выполняя этого, но я не удивлен, если компиляторы еще не успели оптимизировать этот случай.