C++,从汇编角度看《虚拟继承的邪恶》
刷到一篇文章:
作者:
原文:虛擬繼承的邪惡
讨论到这样的一个程序,最终输出什么???
代码有简化命名
using namespace std;class A
{
public:A(int a = 0) : v(a) {};int v;
};template <typename T>
class B : public virtual A
{
};class C : public B<C>
{
public:C(int a) : A(a) {};
};class D : public C
{
public:D(int a) : C(a) {};
};int main()
{cout << C(123).v << endl;cout << D(456).v << endl;return 0;
}
答案是:
123
0
是不是反直觉?为什么D(456)
变成了0
???
原文给出的问题答案:
事實上,上面的行為完全符合 C++ 標準的定義。簡單講,C++ 在初始化物件的時候,有下面幾個步驟:1、按 depth-first traversal 對每一個 virtual base class 初始化一次。
2、依序初始化所有的 direct non-virtual base classes。
接下来看看代码到底是怎么生成的:
A(int a = 0) : v(a) {};
这一行,没有任务问题,很常规的构造函数,赋值
0000000000401232 <A::A(int)>:A(int a = 0) : v(a) {};401232: 55 push %rbp401233: 48 89 e5 mov %rsp,%rbp401236: 48 89 7d f8 mov %rdi,-0x8(%rbp)40123a: 89 75 f4 mov %esi,-0xc(%rbp)40123d: 48 8b 45 f8 mov -0x8(%rbp),%rax401241: 8b 55 f4 mov -0xc(%rbp),%edx401244: 89 10 mov %edx,(%rax)401246: 90 nop401247: 5d pop %rbp401248: c3 ret401249: 90 nop
B::B()
默认生成的构造函数,奇怪,为什么没有调用A::A
呢?
因为,在这个用例中,B类从来没有被实例化过,B只是一个继承关系中的传宗接代工具人
。
如果B没有实例化,那么,A是B的虚基类,A的构造就不需要B来实现,B只会调用B的非虚基类构造函数,此例中,B没有非虚父类
000000000040124a <B<C>::B()>:
class B : public virtual A {};40124a: 55 push %rbp40124b: 48 89 e5 mov %rsp,%rbp40124e: 48 89 7d f8 mov %rdi,-0x8(%rbp) # this401252: 48 89 75 f0 mov %rsi,-0x10(%rbp) # 指针401256: 48 8b 45 f0 mov -0x10(%rbp),%rax40125a: 48 8b 10 mov (%rax),%rdx40125d: 48 8b 45 f8 mov -0x8(%rbp),%rax401261: 48 89 10 mov %rdx,(%rax) # 指针里的内容 存到了 this的位置401264: 90 nop401265: 5d pop %rbp401266: c3 ret401267: 90 nop
接下来,看到了两个C::C(int)
构造函数
为什么会出现两个C::C(int)
呢,因为C直接实例化时一个,C被当作传宗接代工具人
时另一个。一个需要调用虚基类构造,一个不能。
先看第一个:
这个没有调用虚基类A的构造函数,所以,这个是D使用的基类C的构造函数,当D
实例化时,A
由D
负责实例化,所以C这里不会调用A的构造函数,虽然代码里写了。。。C(int a) : A(a) {};
,在这里,A(a)
从来没有用到过,不会执行。
%edx,-0x14(%rbp)
,dx
寄存器是第三个传参,即(int a)
,保存到的-0x14(%rbp)
地址从没使用过。A(a)
没有发生
0000000000401268 <C::C(int)>:C(int a) : A(a) {};401268: 55 push %rbp401269: 48 89 e5 mov %rsp,%rbp40126c: 48 83 ec 20 sub $0x20,%rsp401270: 48 89 7d f8 mov %rdi,-0x8(%rbp) # this401274: 48 89 75 f0 mov %rsi,-0x10(%rbp) # 还是一个指针401278: 89 55 ec mov %edx,-0x14(%rbp) # int a 后面没有用到40127b: 48 8b 45 f8 mov -0x8(%rbp),%rax40127f: 48 8b 55 f0 mov -0x10(%rbp),%rdx401283: 48 83 c2 08 add $0x8,%rdx401287: 48 89 d6 mov %rdx,%rsi40128a: 48 89 c7 mov %rax,%rdi40128d: e8 b8 ff ff ff call 40124a <B<C>::B()> # B::B(this, 指针 + 8)401292: 48 8b 45 f0 mov -0x10(%rbp),%rax401296: 48 8b 10 mov (%rax),%rdx401299: 48 8b 45 f8 mov -0x8(%rbp),%rax40129d: 48 89 10 mov %rdx,(%rax) # 指针里的内容 存到了 this的位置4012a0: 90 nop4012a1: c9 leave4012a2: c3 ret4012a3: 90 nop
这一段,是C被实例化时的构造,会调用A::A
,对应cout << C(123).v << endl;
这一段代码,很符合自觉,int a
的值被传下去了,并且首先构造了A,然后编译器又帮我我们自动调用了B的构造。
00000000004012a4 <C::C(int)>:4012a4: 55 push %rbp4012a5: 48 89 e5 mov %rsp,%rbp4012a8: 48 83 ec 10 sub $0x10,%rsp4012ac: 48 89 7d f8 mov %rdi,-0x8(%rbp) # this4012b0: 89 75 f4 mov %esi,-0xc(%rbp) # int a4012b3: 48 8b 45 f8 mov -0x8(%rbp),%rax4012b7: 48 8d 50 08 lea 0x8(%rax),%rdx4012bb: 8b 45 f4 mov -0xc(%rbp),%eax4012be: 89 c6 mov %eax,%esi4012c0: 48 89 d7 mov %rdx,%rdi4012c3: e8 6a ff ff ff call 401232 <A::A(int)> # A::A(this + 8, a)4012c8: 48 8b 45 f8 mov -0x8(%rbp),%rax4012cc: ba 88 20 40 00 mov $0x402088,%edx4012d1: 48 89 d6 mov %rdx,%rsi4012d4: 48 89 c7 mov %rax,%rdi4012d7: e8 6e ff ff ff call 40124a <B<C>::B()> # B::B(this, $0x402088)4012dc: ba 80 20 40 00 mov $0x402080,%edx4012e1: 48 8b 45 f8 mov -0x8(%rbp),%rax4012e5: 48 89 10 mov %rdx,(%rax) # *this = $0x4020804012e8: 90 nop4012e9: c9 leave4012ea: c3 ret4012eb: 90 nop
D类同理,D被实例化,先构造虚基类A
但是D(int a) : C(a) {};
,我们自己写了D的构造函数,但没有按照C++规范在虚继承的最后的派生类中构造虚基类,所以,很不幸的是这里编译器帮我们构造了虚基类A,调用了虚基类的默认构造或有默认值的构造函数A::A(int a = 0)
,这里是按a=0
进行了A的构造,编译器没有向我们发出警告。。。
然后,由于虚基类应该由我们构造,我们没指定,进行了默认构造
接下来是非虚基类的构造,C
的构造,由于C不是A-B-C-D
这一链条中的最后的派生类,C
不会构造A
(还记得吗,编译器生成了两个C::C
),B
也不会构造A
(编译器生成的B::B只有一个,非最远派生类的构造函数,不会构造A
)
所以D(int a) : C(a) {};
并没有按照我们预期的工作。。。
00000000004012ec <D::D(int)>:D(int a) : C(a) {};4012ec: 55 push %rbp4012ed: 48 89 e5 mov %rsp,%rbp4012f0: 48 83 ec 10 sub $0x10,%rsp4012f4: 48 89 7d f8 mov %rdi,-0x8(%rbp) # this4012f8: 89 75 f4 mov %esi,-0xc(%rbp) # int a4012fb: 48 8b 45 f8 mov -0x8(%rbp),%rax4012ff: 48 83 c0 08 add $0x8,%rax401303: be 00 00 00 00 mov $0x0,%esi401308: 48 89 c7 mov %rax,%rdi40130b: e8 22 ff ff ff call 401232 <A::A(int)> # A::A(this + 8, 0),编译器帮我们构造了A,但用了默认构造或有默认值的构造函数401310: 48 8b 45 f8 mov -0x8(%rbp),%rax401314: b9 28 20 40 00 mov $0x402028,%ecx401319: 8b 55 f4 mov -0xc(%rbp),%edx40131c: 48 89 ce mov %rcx,%rsi40131f: 48 89 c7 mov %rax,%rdi401322: e8 41 ff ff ff call 401268 <C::C(int)> # C::C(this, $0x402028, a),但这里调用的C::C构造不会用到 a401327: ba 20 20 40 00 mov $0x402020,%edx40132c: 48 8b 45 f8 mov -0x8(%rbp),%rax401330: 48 89 10 mov %rdx,(%rax) # *this = $0x402020401333: 90 nop401334: c9 leave401335: c3 ret
最后回头看main
,一切都很平常,正常调用C::C
和D::D
,只是调用C::C
时候,C
写了构造A
,而调用D::D
之后没写构造A
,追踪造成第一次输出正常,第二输出了0
int main()
{401176: 55 push %rbp401177: 48 89 e5 mov %rsp,%rbp40117a: 48 83 ec 20 sub $0x20,%rspcout << C(123).v << endl;40117e: 48 8d 45 e0 lea -0x20(%rbp),%rax401182: be 7b 00 00 00 mov $0x7b,%esi401187: 48 89 c7 mov %rax,%rdi40118a: e8 15 01 00 00 call 4012a4 <C::C(int)> # C::C(-20(bp), 123)40118f: 8b 45 e8 mov -0x18(%rbp),%eax401192: 89 c6 mov %eax,%esi401194: bf 40 40 40 00 mov $0x404040,%edi401199: e8 d2 fe ff ff call 401070 <std::basic_ostream<char, std::char_traits<char> >::operator<<(int)@plt> # <<(cout, -18(rbp)) 此时,c->v 直接获得地址40119e: be 30 10 40 00 mov $0x401030,%esi4011a3: 48 89 c7 mov %rax,%rdi4011a6: e8 a5 fe ff ff call 401050 <std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_ostream<char, std::char_traits<char> >& (*)(std::basic_ostream<char, std::char_traits<char> >&))@plt>cout << D(456).v << endl;4011ab: 48 8d 45 f0 lea -0x10(%rbp),%rax4011af: be c8 01 00 00 mov $0x1c8,%esi4011b4: 48 89 c7 mov %rax,%rdi4011b7: e8 30 01 00 00 call 4012ec <D::D(int)> # D::D(-10(bp), 456)4011bc: 8b 45 f8 mov -0x8(%rbp),%eax4011bf: 89 c6 mov %eax,%esi4011c1: bf 40 40 40 00 mov $0x404040,%edi4011c6: e8 a5 fe ff ff call 401070 <std::basic_ostream<char, std::char_traits<char> >::operator<<(int)@plt> # <<(cout, -8(rbp)) 此时,d->v 直接获得地址4011cb: be 30 10 40 00 mov $0x401030,%esi4011d0: 48 89 c7 mov %rax,%rdi4011d3: e8 78 fe ff ff call 401050 <std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_ostream<char, std::char_traits<char> >& (*)(std::basic_ostream<char, std::char_traits<char> >&))@plt>return 0;4011d8: b8 00 00 00 00 mov $0x0,%eax4011dd: c9 leave4011de: c3 ret
原作者这段写的一点没错,不过通过汇编的角度观察,我们知道了编译器是如何实现的:
虚继承的派生类构造函数,编译器为一个构造函数生成了两个实现,分别是这个派生类直接实例化时的构造韩慧,会先构造虚基类,一个是作为其他类的基类时,不会构造虚基类,虚基类由其最后的派生类负责构造。
如果派生类忘记构造虚基类,编译器会帮助我们进行执行基类的默认构造或有默认值的构造,而这,没有警告,悄悄发生。
事實上,上面的行為完全符合 C++ 標準的定義。簡單講,C++ 在初始化物件的時候,有下面幾個步驟:1、按 depth-first traversal 對每一個 virtual base class 初始化一次。
2、依序初始化所有的 direct non-virtual base classes。
最后,补一个作者提到的google c++ 规范
眾所周知,虛擬繼承是為了解決多重繼承產生的 diamond problem 而來的概念。大概是因為虛擬繼承有這些不為人知的眉角,Google C++ Style Guide 才會明定如果要多重繼承,所有的 direct base class 都得是純粹的 interface 而不能帶有成員變數。
c++
魔法无比强大。。。