一直想记录点这方面的知识,后续内容一部分来自深度探索C++对象模型,一部分来自C++性能优化指南;像那些(More)Effective C++、Effective STL、(More)Exceptional C++和源码剖析这几本书都非常值得阅读,这里不会分析怎么高效的使用STL组件,毕竟不同的业务场景有适合的组件。也不会去说明一个函数该不该定义成虚函数,类的抽象继承等。另外还有较复杂的Boost库。
这里的gcc版本是7.3.0,且主要是C++ 03,C++ 11以后用的不是很熟练,经验还不是很足,后面会一点点学习。
这里不会结合多线程技术,不然分析的够复杂,只在单线程下,然后内存布局和编译后的结果,以及怎么写出效率更高一些的代码,我以前也写过几篇相关的,但都比较简单。
还是那句,先把功能写正确,再去慢慢优化。
一) 当定义类实例时,发生了什么?
7 class CBase {
8 public:
9 CBase(const uint32_t u = 1) : m_data(u) {
10 std::cout << "CBase ctor" << std::endl;
11 }
12
13 virtual ~CBase() {
14 std::cout << "~CBase dtor" << std::endl;
15 }
16
17 virtual void ShowData() {
18 std::cout << this->m_data << std::endl;
19 }
20
21 void ShowData(const uint32_t unused) {
22 std::cout << sizeof(*this) << std::endl;
23 }
24 private:
25 uint32_t m_data;
26 };
27
28 int main(void) {
29 CBase *pBase = new CBase;
30 pBase->ShowData();
31 pBase->ShowData(4);
32 delete pBase;
33 pBase = NULL;
34
35 return 0;
36 }
这里,有虚析构函数,默认构造函数,虚函数,那定义该类的实例时,占用内存是多少呢?内存局部如何呢?以及调用某个虚函数或非虚函数的性能如何呢等。
下面从反汇编开始分析,会大概分析下。
首先有两个符号:
0000000100000bdc T __ZN5CBase8ShowDataEj
0000000100000ba0 T __ZN5CBase8ShowDataEv
CBase::ShowData(unsigned int)
CBase::ShowData()
可以跟着gdb调度看下结果,这里会简单的回答下上面的问题。
CBase *pBase = new CBase;
100000c22: bf 10 00 00 00 movl $16, %edi
100000c27: e8 28 01 00 00 callq 296
100000c2c: 48 89 c3 movq %rax, %rbx
开辟一个CBase实例内存空间但未调用构造函数,大小为:指向虚表的指针+一个整型+对齐,一共16字节。
pBase->ShowData();
100000c40: 48 8b 45 e8 movq -24(%rbp), %rax //取得pBase
100000c44: 48 8b 00 movq (%rax), %rax //取得vptr
100000c47: 48 83 c0 10 addq $16, %rax //加上偏移量
100000c4b: 48 8b 00 movq (%rax), %rax //取得ShowData地址
100000c4e: 48 8b 55 e8 movq -24(%rbp), %rdx
100000c52: 48 89 d7 movq %rdx, %rdi //设置参数
100000c55: ff d0 callq *%rax //调用虚函数
这一堆代码调用了虚函数,pBase->ShowData()
会被编译器转换成如下形式:(*pBase->vptr[x])(pBase)
,如上面的注释,x表示ShowData在虚表中的索引位置。
为了印证上面说的,调试过程如下:
(gdb) p *pBase
$4 = {_vptr.CBase = 0x1000010b0 , m_data = 1}
(gdb) i r rax
rax 0x100600000 4301258752
(gdb) ni
0x0000000100000c47 30 pBase->ShowData();
(gdb) i r rax
rax 0x1000010b0 4294971568
(gdb) ni
0x0000000100000c4b 30 pBase->ShowData();
(gdb) i r rax
rax 0x1000010c0 4294971584
(gdb) i r rax
rax 0x100000ba0 4294970272 //这个是符号__ZN5CBase8ShowDataEv
然后再看一下:
pBase->ShowData(4);
100000c57: 48 8b 45 e8 movq -24(%rbp), %rax
100000c5b: be 04 00 00 00 movl $4, %esi //第二个参数
100000c60: 48 89 c7 movq %rax, %rdi //第一个参数
100000c63: e8 b6 00 00 00 callq 182
上面成员函数会被编译翻译成__ZN5CBase8ShowDataEv(pBase, 4),如上汇编代码。
题外话,为什么要定义虚析构函数,以及带默认形参的构造函数,如果这里不声明一个构造函数,那编译器是否会为我们合成一个构造函数,或拷贝构造函数?这些问题可以参考上面的书籍,或者手动写个继承类,看下结果如何。
二) 当考虑多重继承后结果又是如何(单继承比较简单)
7 class CBase {
8 public:
9 virtual void ShowData() {
10 }
11
12 virtual ~CBase() {
13 }
14 };
15
16 class CBaseOne {
17 public:
18 virtual void ShowData() {
19 }
20
21 virtual ~CBaseOne() {
22 }
23 };
24
25 class CDerivedOne : public CBase, public CBaseOne {
26 public:
27 void ShowData() {
28 }
29
30 ~CDerivedOne() {
31 }
32 };
33
34 int main(void) {
35 CBase *pBase = new CDerivedOne; //a
36 pBase->ShowData();
37
38 CBaseOne *pOne = new CDerivedOne; //b
39 pOne->ShowData();
40
41 delete pBase;
42 delete pOne;
43
44 pBase = NULL;
45 pOne = NULL;
46
47 return 0;
48 }
首先这里没有在派生类中声明构造函数,这里编译器会合成一个,但是仅仅做他认为该做的事情,如设置vptr。如果派生类或者基类有数据要初始化则需要手动调用,然后编译器再扩展。
这里a和b的执行效果是一样的吗?
看下两个语句的构造过程:
CBase *pBase = new CDerivedOne;
100000a09: bf 10 00 00 00 movl $16, %edi
100000a0e: e8 5d 01 00 00 callq 349
100000a13: 48 89 c3 movq %rax, %rbx
100000a16: 48 89 df movq %rbx, %rdi
100000a19: e8 10 01 00 00 callq 272
100000a1e: 48 89 5d e8 movq %rbx, -24(%rbp)
CBaseOne *pOne = new CDerivedOne;
100000a35: bf 10 00 00 00 movl $16, %edi
100000a3a: e8 31 01 00 00 callq 305
100000a3f: 48 89 c3 movq %rax, %rbx
100000a42: 48 89 df movq %rbx, %rdi
100000a45: e8 e4 00 00 00 callq 228
100000a4a: 48 85 db testq %rbx, %rbx
100000a4d: 74 06 je 6 <_main+0x55>
100000a4f: 48 8d 43 08 leaq 8(%rbx), %rax
100000a53: eb 05 jmp 5 <_main+0x5A>
100000a55: b8 00 00 00 00 movl $0, %eax
100000a5a: 48 89 45 e0 movq %rax, -32(%rbp)
从100000a4a〜100000a5a就和第一个区别开来,这里创建好实例后,需要调整指针到CBaseOne的偏移处。因为指针CBaseOne类型决定了这个指针能寻址的范围以及如何解释这块内存空间的位。
这里汇编的意义是判断pOne是否为空,是的话则赋值为空;否则取实例首地址+8的偏移量处,即CBaseOne的开始处,大致如下:
pOne = pOne ? (CBaseOne *)(((char *)pOne) + sizeof(class CBase)) : 0
。
delete pBase;
100000a71: 48 83 7d e8 00 cmpq $0, -24(%rbp)
100000a76: 74 17 je 23 <_main+0x8F>
100000a78: 48 8b 45 e8 movq -24(%rbp), %rax
100000a7c: 48 8b 00 movq (%rax), %rax
100000a7f: 48 83 c0 10 addq $16, %rax
100000a83: 48 8b 00 movq (%rax), %rax
100000a86: 48 8b 55 e8 movq -24(%rbp), %rdx
100000a8a: 48 89 d7 movq %rdx, %rdi
100000a8d: ff d0 callq *%rax
delete pOne;
100000a8f: 48 83 7d e0 00 cmpq $0, -32(%rbp)
100000a94: 74 17 je 23 <_main+0xAD>
100000a96: 48 8b 45 e0 movq -32(%rbp), %rax
100000a9a: 48 8b 00 movq (%rax), %rax
100000a9d: 48 83 c0 10 addq $16, %rax
100000aa1: 48 8b 00 movq (%rax), %rax
100000aa4: 48 8b 55 e0 movq -32(%rbp), %rdx
100000aa8: 48 89 d7 movq %rdx, %rdi
100000aab: ff d0 callq *%rax
如果delete pOne,反编译出的汇编代码和delete pBase一样,可能跟编译器或代码复杂度有关。书上说会在delete 时可能调整指针到适合的位置。
还有其他可能,在运行时需要调整指针的位置。通过以上两个实现,这时里没有列出虚拟继承和单继承,前者很复杂,在项目中也没有遇到过。
如果引入虚函数,带来的一定的弹性设计,但因为引入间接性,导致执行的时候会比较慢一些,而不像其他直接在编译时就能决定的事。
当然,通过对象调用某个虚函数则会被编译器静态决定为该类型的函数,不会引发虚机制。
三) 当这么定义构造函数时真实效率如何?
7 class CBase {
8 public:
9 CBase(const std::string &addr) {
10 m_addr = addr;
13 }
18 private:
19 std::string m_addr;
22 };
23
24 int main(int argc, char **argv) {
25 CBase t("hello");
26 return 0;
27 }
这里故意没定义析构函数,反汇编结果看,编译器帮我们合成了一个,如下:
__ZN5CBaseD1Ev:
100000ccc: 55 pushq %rbp
100000ccd: 48 89 e5 movq %rsp, %rbp
100000cd0: 48 83 ec 10 subq $16, %rsp
100000cd4: 48 89 7d f8 movq %rdi, -8(%rbp)
100000cd8: 48 8b 45 f8 movq -8(%rbp), %rax
100000cdc: 48 89 c7 movq %rax, %rdi
100000cdf: e8 30 01 00 00 callq 304
100000ce4: 90 nop
100000ce5: c9 leave
100000ce6: c3 retq
构造函数反汇编指令如下:
100000c78: 55 pushq %rbp
100000c79: 48 89 e5 movq %rsp, %rbp
100000c7c: 53 pushq %rbx
100000c7d: 48 83 ec 18 subq $24, %rsp
100000c81: 48 89 7d e8 movq %rdi, -24(%rbp)
100000c85: 48 89 75 e0 movq %rsi, -32(%rbp)
100000c89: 48 8b 45 e8 movq -24(%rbp), %rax
100000c8d: 48 89 c7 movq %rax, %rdi
100000c90: e8 79 01 00 00 callq 377
100000c95: 48 8b 45 e8 movq -24(%rbp), %rax
100000c99: 48 8b 55 e0 movq -32(%rbp), %rdx
100000c9d: 48 89 d6 movq %rdx, %rsi
100000ca0: 48 89 c7 movq %rax, %rdi
100000ca3: e8 72 01 00 00 callq 370
这里先调用了string的默认构造函数,再调用赋值操作符,两次,为了印证结果,gdb的过程如下:
=> 0x0000000100000c89 <+17>: mov -0x18(%rbp),%rax
0x0000000100000c8d <+21>: mov %rax,%rdi
0x0000000100000c90 <+24>: callq 0x100000e0e
(gdb) ni
0x0000000100000c90 9 CBase(const std::string &addr) {
(gdb) p *this
$3 = {m_addr = {static npos = 18446744073709551615,
_M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffab0 "\370\373\277\357\376\177"}, _M_string_length = 140732920756888, {
_M_local_buf = " 5\000\000\001\000\000\000\000\000\000\000\001\000\000",
_M_allocated_capacity = 4294980896}}}
以上还没调用string的默认构造函数,该处内存上的位模式是脏的;
(gdb) p *this
$4 = {m_addr = {static npos = 18446744073709551615,
_M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffa40 ""}, _M_string_length = 0, {
_M_local_buf = "\000\065\000\000\001\000\000\000\000\000\000\000\001\000\000",
_M_allocated_capacity = 4294980864}}}
执行完后0x0000000100000c90后的内容如上,此时为空串,长度为0;
(gdb) p this
$5 = (CBase * const) 0x7ffeefbffa30
(gdb) p *this
$6 = {m_addr = {static npos = 18446744073709551615,
_M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffa40 "hello"}, _M_string_length = 5, {
_M_local_buf = "hello\000\000\000\000\000\000\000\001\000\000",
_M_allocated_capacity = 478560413032}}}
最后CBase构造函数返回时的内容正是我们调用时的结果值。
当我们开-O3选项时的结果,结果比较乱,没gdb调度执行过程,这里大概凑合着看下:
100000e26: 48 89 44 24 20 movq %rax, 32(%rsp)
; : allocator_type(std::move(__a)), _M_p(__dat) { }
100000e2b: 48 8d 43 10 leaq 16(%rbx), %rax
; { _M_string_length = __length; }
100000e2f: 48 c7 44 24 28 05 00 00 00 movq $5, 40(%rsp)
当我们这么定义构造函数时结果如何呢?
9 CBase(const std::string &addr) : m_addr(addr) {
10 //m_addr = addr;
11 }
100000cb8: 55 pushq %rbp
100000cb9: 48 89 e5 movq %rsp, %rbp
100000cbc: 48 83 ec 10 subq $16, %rsp
100000cc0: 48 89 7d f8 movq %rdi, -8(%rbp)
100000cc4: 48 89 75 f0 movq %rsi, -16(%rbp)
100000cc8: 48 8b 45 f8 movq -8(%rbp), %rax
100000ccc: 48 8b 55 f0 movq -16(%rbp), %rdx
100000cd0: 48 89 d6 movq %rdx, %rsi
100000cd3: 48 89 c7 movq %rax, %rdi
100000cd6: e8 45 01 00 00 callq 325
(gdb)
0x0000000100000cd6 9 CBase(const std::string &addr) : m_addr(addr) {
(gdb)
11 }
(gdb) p *this
$2 = {m_addr = {static npos = 18446744073709551615,
_M_dataplus = {> = {> = {}, }, _M_p = 0x7ffeefbffa40 "hello"}, _M_string_length = 5, {
_M_local_buf = "hello\000\000\000\000\000\000\000\001\000\000",
_M_allocated_capacity = 478560413032}}}
你看,就调用一次构造函数吧。所以平时写代码的时候还是建议使用初始化列表。
未完待续。。。