`*dynamic_cast<T*>(...)`的含义是什么?

12
最近我在查看一个开源项目的代码时,发现了一堆形如T& object = *dynamic_cast<T*>(ptr);的语句。(实际上这是在宏定义中用于声明许多函数的类似模式。)
对我来说,这看起来像是一种代码异味。我的理解是,如果你知道转换会成功,为什么不使用static_cast呢?如果你不确定,那么难道不应该使用断言进行测试吗?因为编译器可以假设你*的任何指针都不为空。
我在IRC上问了其中一个开发人员,他说他认为static_cast向下转型是不安全的。他们可以添加一个assert,但即使他们不这样做,当实际使用obj时,你仍然会得到空指针引用和崩溃。(因为在失败时,dynamic_cast将把指针转换为null,然后当你访问任何成员时,你将从值非常接近零的某个地址读取,而操作系统不允许这样做。)如果你使用static_cast,并且出现问题,你可能只会得到一些内存损坏。因此,通过使用*dynamic_cast选项,你正在以速度为代价换取稍微更好的可调试性。你不需要付出断言的代价,而是基本上依靠操作系统捕获nullptr引用,至少这是我理解的。
当时我接受了这个解释,但它仍然让我感到困扰,所以我思考了一下。
我的理解是这样的。
如果我正确地理解标准,static_cast指针转换基本上意味着进行一些固定的指针算术运算。也就是说,如果我有一个A * a,并将其静态转换为相关类型B *,那么编译器实际上要做的是向该指针添加一些偏移量,该偏移量仅取决于类型AB的布局(以及可能的C++实现)。可以通过将指针静态转换为void *并输出它们来测试这个理论,在静态转换之前和之后。我预计,如果你查看生成的汇编代码,static_cast将变成“将某个固定常数加到对应于指针的寄存器中”。 dynamic_cast指针转换意味着首先检查RTTI,并仅在基于动态类型它是有效的情况下进行静态转换。如果不是,则返回nullptr。因此,我希望编译器最终会将一个表达式dynamic_cast<B*>(ptr)(其中ptr的类型为A*)扩展为类似于以下的表达式:
(__validate_dynamic_cast_A_to_B(ptr) ? static_cast<B*>(ptr) : nullptr)

然而,如果我们对dynamic_cast的结果进行*操作,并将nullptr的*隐式承诺分支从未发生,则nullptr的检查是未定义行为。符合标准的编译器可以“反向推理”并消除空指针检查,这一点在Chris Lattner的著名博客文章中得到了强调。
如果测试函数__validate_dynamic_cast_A_to_B(ptr)对优化器不透明,即它可能具有副作用,则即使“知道”nullptr分支不会发生,优化器也无法摆脱它。但是,也许这个函数对优化器来说不是不透明的-也许它非常了解其可能的副作用。
因此,我的期望是优化器将基本上将dynamic_cast(ptr)转换为static_cast(ptr),并且交换这些应该给出相同的生成汇编。 如果是真的,那将证明我最初的论点,即*dynamic_cast是代码异味,即使您不真正关心代码中的未定义行为,并且只关心“实际”发生的情况。因为,如果符合标准的编译器可以将其静默更改为static_cast,则您不会获得任何您认为的安全性,因此您应该显式地使用static_cast或显式断言。至少,在代码审查中,这将是我的选择。我试图弄清楚这个论点是否正确。
以下是标准对dynamic_cast的规定:
[5.2.7] 动态转换 [expr.dynamic.cast] 1. 表达式 dynamic_cast(v) 的结果是将表达式 v 转换为类型 T 的结果。T 必须是指向完整类类型的指针或引用,或者是 "cv void" 指针。dynamic_cast 运算符不得去除 const 属性。 ... 8. 如果 C 是 T 所指向或引用的类类型,则运行时检查逻辑执行如下: (8.1) - 如果在由 v 指向(引用)的最派生对象中,v 指向(引用)了一个 C 对象的公共基类子对象,并且如果只有一个类型为 C 的对象从由 v 指向(引用)的子对象派生,则结果指向(引用)该 C 对象。 (8.2) - 否则,如果 v 指向(引用)最派生对象的公共基类子对象,并且最派生对象的类型具有一个明确且公共的类型为 C 的基类,则结果指向(引用)最派生对象的 C 子对象。 (8.3) - 否则,运行时检查失败。
假设在编译时已知类层次结构,则每个类在彼此布局中的相对偏移量也已知。如果 v 是指向类型 A 的指针,并且我们想将其转换为类型 B 的指针,并且转换是明确的,则 v 必须进行的移位是编译时常量。即使 v 实际上指向更派生类型 C 的对象,这个事实也不会改变 A 子对象相对于 B 子对象所在位置,对吧?因此,无论类型 C 是什么,即使它是来自另一个编译单元的某个未知类型,据我所知,dynamic_cast(ptr) 的结果只有两个可能的值,nullptr 或 "从 ptr 固定偏移量"。
然而,当我们实际查看一些代码生成时,情况变得有些复杂。
以下是我制作的一个简单程序,用于调查此问题:
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编译器探索器显示,使用选项-O3 -std=c++11gcc 5.3 x86汇编如下所示:
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改为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函数的调用,使用两个类的typeinfo,不管怎样。即使所有优化都开启,并且B被标记为final。

  • 这个低级别的调用是否有我没有考虑到的副作用?我的理解是vtable基本上是固定的,对象中的vptr不会改变...我是对的吗?我只对vtable的实际实现有基本的了解,而且说实话,我通常会避免在我的代码中使用虚函数,所以我并没有深入思考或积累经验。

  • 我是否正确认为符合规范的编译器可以将*dynamic_cast<T*>(ptr)替换为*static_cast<T*>(ptr)作为有效的优化?

  • 是否真的"通常"(指在x86机器上,假设在一个"通常"复杂度的层次结构中的类之间进行转换)无法消除dynamic_cast,并且实际上即使在直接使用*之后也会产生nullptr,导致在访问对象时nullptr解引用和崩溃?

  • "始终使用dynamic_cast+测试或某种断言,或者使用*static_cast<T*>(ptr)替换*dynamic_cast<T*>(ptr)"是一个明智的建议吗?


6
好的,请问您需要翻译哪些内容?每问一句话。 - πάντα ῥεῖ
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Chris Beck
你的问题似乎过于宽泛。dynamic_cast 的工作原理和用途是众所周知的。询问一个可能错误使用它的任意项目对我来说有些不合适,但好吧。 - πάντα ῥεῖ
这是核心问题:“我是否正确,符合规范的编译器可以将*dynamic_cast<T*>(ptr)替换为*static_cast<T*>(ptr)作为有效的优化?”大部分其余问题只是与此相关的研究。 - Chris Beck
2
这是一个非常大的问题,即使其中大部分都是研究。也许可以通过删除一些额外的语言并去掉其中的其他三个问题来缩小它?将其缩减为仅仅您的问题和相关信息,以解释您为什么提出此问题以及一些背景。 - jonspaceharper
显示剩余5条评论
1个回答

9
T& object = dynamic_cast<T*>(ptr);这种写法在失败时会导致未定义行为,这是不可取的。我认为没有必要再进一步解释。即使当前编译器可以正常工作,但在将来版本中使用更加激进的优化器时可能就无法正常工作。
如果您想进行检查并且不想自己编写断言,则可以使用引用形式,在失败时引发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或整个程序知识的情况下,您必须在运行时进行检查(因为CD可能对您不可见)。如果在B上使用了final,则应该能够避免这样做,但我不惊讶编译器尚未优化该情况。

我明白了,我没有正确理解什么是侧向转换。谢谢你的解释。 - Chris Beck

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接