我试图理解在C++中基类和派生类型之间的强制转换是如何工作的,所以我写了一个小的概念验证程序
class Foo {
public:
Foo() {}
};
// Bar is a subclass of Foo
class Bar : public Foo {
public:
Bar() : Foo() {}
void bar() { std::cout << "bar" << std::endl; }
void bar2() { std::cout << "bar with " << i << std::endl; }
private:
int i = 0;
};
其中Foo是基数,Bar从Foo派生。
目前,我对选角的理解是:
- 强制转换是运行时的事情。编译器可以在编译期间检查它们,但实际的类型转换发生在运行时
- 上转换(例如
Foo f = Bar()
),无论是显式还是隐式,都应该是正确的 - 向下强制转换(例如
Bar b = Foo()
)在C++中是禁止的,尽管我们可以通过使用static_cast
强制执行该强制转换
我写了3个不同的程序来验证我的理解。每个程序都是用
g++ -std=c++17 -Wall -Wextra -pedantic
案例1
int main() {
Foo f;
Bar &b = static_cast<Bar &>(f);
return 0;
}
代码编译成功,运行该程序不会出错
我的想法:好的,尽管实际的类型转换不正确,因为我们在运行时将Foo的示例视为Bar,但我们没有看到任何错误,因为我们实际上并没有在b
上操作
案例2
int main() {
Foo f;
Bar &b = static_cast<Bar &>(f);
b.bar();
return 0;
}
代码编译成功,运行本程序不会出错,并打印"bar
我开始感到困惑:为什么这个程序会工作并且"bar"会被打印出来?虽然这里我们把Foo当作Bar,但是底层示例仍然是一个Foo,并且它没有定义名为"bar"的方法。这段代码是如何工作的?
案例3
int main() {
Foo f;
Bar &b = static_cast<Bar &>(f);
b.bar2();
return 0;
}
代码编译成功,运行本程序不会出错,并打印"bar with 1981882368"(某个随机数
我更困惑的是:如果我们从内存布局的Angular 来考虑,底层的Foo示例没有为Bar中定义的成员i
保留空间。
请帮助我理解以上节目!提前感谢!
2条答案
按热度按时间elcex8rz1#
强制转换是运行时的事情。编译器可以在编译期间检查它们,但实际的类型转换发生在运行时
不,除了
dynamic_cast
之外,所有类型转换都是纯编译时构造,毕竟在C中运行时(几乎)没有类型。上转换(例如
Foo f = Bar()
),无论是显式还是隐式,都应该始终有效是的,向上投射是安全的。
向下强制转换(例如
Bar b = Foo()
)在C中是禁止的,尽管我们可以通过使用static_cast
强制执行该强制转换不,不禁止,只是有一些重要的规则。有些类型转换是隐式的,有些必须显式请求。
情况1:这是未定义行为(UB),因为
b
没有指向真正的Bar
对象。此强制转换假设用户知道他们在做什么,可能是因为他们有一些关于对象真实类型的外部信息,尽管这里不是这样。情况2:你已经触发了UB,任何事情都可能发生。在这种情况下,编译器可能只是调用
bar
,并将b
作为this
指针传递。这当然是不正确的,但这是你的问题。由于该方法不使用this
指针,因此没有太多需要中断的地方。案例3:现在你真的深入研究了这个UB,编译器可能只是计算了
this+offsetof(Bar,i)
,并假设地址指向一个整数,事实上它不是,这是你的问题,因为你违背了向下转换到正确类型的承诺。xnifntxz2#
我想这个问题是关于这是如何发生的,而不是 C++ 标准说应该发生什么。
让我们看看在一个没有优化的针对x64编译的example program中发生了什么(和你做的一样):
装配中的相关部件:
这里需要注意的重要事项包括:
bar2
的调用被写为call _ZN3Bar4bar2Ev
。没有任何东西 * 物理上 * 需要Bar
的示例,方法“属于”类是一个高级的错觉,它不像它们实际上在任何真实的意义上被打包在那里。实际上只有一个函数带有一个有趣的(mangled)name,并且它 * 期望 * 一个指向适当类型的对象的指针作为隐藏参数,但是你可以继续违反它的期望,当然,当你这样做的时候可能会发生意想不到的事情,因为bar2
将盲目地前进,而不管它接收到什么垃圾作为其隐式this
参数。顺便说一下,虚调用的情况会有一些不同。即使它们不依赖于方法 name,也不会检查你调用方法的对象是否实际上有一个合理的类型。我不会深入讨论虚调用,因为它们不是问题的一部分,你可以阅读一些其他的问答,如How are virtual functions and vtable implemented?。
bar2
访问成员i
,如下所示:mov eax, DWORD PTR [rax]
,即它从它接收的任何地址的零偏移量加载4字节量(无论bar2
接收到什么作为其隐藏的第一个参数,即使它不是地址,也会被mov
当作地址使用)。不涉及类型,不涉及成员名,不进行检查。内存是盲目访问的,无论发生什么,都会发生。这一切都是相当驯服的--即使违反了各种规则,“默认的事情”(就像什么都没有错一样继续,让结果成为“自然”发生的事情)还是发生了。这在没有优化的情况下编译时有点常见(但不是普遍的,也不保证)。甚至在 * 有 * 优化的情况下编译时也可能发生,但你更有可能看到各种各样的编译器恶作剧。