C++强制转换:角色扮演到底是怎么运作的

dfty9e19  于 2022-12-24  发布在  其他
关注(0)|答案(2)|浏览(149)

我试图理解在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保留空间。
请帮助我理解以上节目!提前感谢!

elcex8rz

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),并假设地址指向一个整数,事实上它不是,这是你的问题,因为你违背了向下转换到正确类型的承诺。

xnifntxz

xnifntxz2#

我想这个问题是关于这是如何发生的,而不是 C++ 标准说应该发生什么。
让我们看看在一个没有优化的针对x64编译的example program中发生了什么(和你做的一样):

#include <iostream>

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;
};

int main() {
  Foo f;
  Bar &b = static_cast<Bar &>(f);
  b.bar2();
  return 0;
}

装配中的相关部件:

_ZN3Bar4bar2Ev:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR [rbp-8], rdi
        mov     esi, OFFSET FLAT:.LC0
        mov     edi, OFFSET FLAT:_ZSt4cout
        call    _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
        mov     rdx, rax
        mov     rax, QWORD PTR [rbp-8]
        mov     eax, DWORD PTR [rax]
        mov     esi, eax
        mov     rdi, rdx
        call    _ZNSolsEi
        mov     esi, OFFSET FLAT:_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
        mov     rdi, rax
        call    _ZNSolsEPFRSoS_E
        nop
        leave
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rax, [rbp-9]
        mov     rdi, rax
        call    _ZN3FooC1Ev
        lea     rax, [rbp-9]
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    _ZN3Bar4bar2Ev
        mov     eax, 0
        leave
        ret

这里需要注意的重要事项包括:

  • 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当作地址使用)。不涉及类型,不涉及成员名,不进行检查。内存是盲目访问的,无论发生什么,都会发生。

这一切都是相当驯服的--即使违反了各种规则,“默认的事情”(就像什么都没有错一样继续,让结果成为“自然”发生的事情)还是发生了。这在没有优化的情况下编译时有点常见(但不是普遍的,也不保证)。甚至在 * 有 * 优化的情况下编译时也可能发生,但你更有可能看到各种各样的编译器恶作剧。

相关问题