最近我在查看一个开源项目的代码时,发现了一堆形如
对我来说,这看起来像是一种代码异味。我的理解是,如果你知道转换会成功,为什么不使用
我在IRC上问了其中一个开发人员,他说他认为
当时我接受了这个解释,但它仍然让我感到困扰,所以我思考了一下。
我的理解是这样的。
如果我正确地理解标准,
然而,如果我们对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 固定偏移量"。
然而,当我们实际查看一些代码生成时,情况变得有些复杂。
以下是我制作的一个简单程序,用于调查此问题:
根据godbolt编译器探索器显示,使用选项
当我将
这里是使用相同选项和
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 *
,那么编译器实际上要做的是向该指针添加一些偏移量,该偏移量仅取决于类型A
、B
的布局(以及可能的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++11
的gcc 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)
"是一个明智的建议吗?
dynamic_cast
的工作原理和用途是众所周知的。询问一个可能错误使用它的任意项目对我来说有些不合适,但好吧。 - πάντα ῥεῖ*dynamic_cast<T*>(ptr)
替换为*static_cast<T*>(ptr)
作为有效的优化?”大部分其余问题只是与此相关的研究。 - Chris Beck