📂CPP
🔖CPP
2023-04-10 21:30阅读: 185评论: 0推荐: 1

CPP对象模型

C++对象模型

主要参考资料:

对象模型的底层细节视编译器的不同而不同,下面的实验全部默认使用GNU g++编译器完成,若使用VisualC++或其他编译器进行实验,我会特别注明

C++对象的内存布局

主要记录单一、具体继承(concrete inheritance)的情况,多继承和虚继承会单独整理。

多少内存才能够实现一个class object

按照【1】上的说法,一个C++ class object需要的内存由3部分构成

  • nonstatic data member的总和大小
  • 为了支持virtual机制而加入指针大小(virtual机制主要是虚函数)
  • 由于对齐要求填补内存(与struct内存对齐的规则相似)

因此,如果没有使用virtual机制,那么一个C++ class object的大小与struct的大小相同。

static data member不会放进对象布局中,它们会被放入程序的global data segment中。

那么对象在布局的存储顺序如何?-- “较晚出现的member在对象中有较高的地址”.

下面是具体实例,三个类,只使用单一继承

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
struct Alibaba{ public : char a ; Alibaba() { a = 1; } virtual ~Alibaba() { // 加入一个vptr std::cout << "destruct Alibaba\n"; }; }; class Bilibili: public Alibaba{ public: int b; int c ; Bilibili() { b = 2; c = 3; } virtual ~Bilibili() { std::cout << "destruct Bilibili\n"; }; }; class DiDi:public Bilibili { public: int d; DiDi() { d = 4; } virtual ~DiDi() { std::cout << "destruct DiDi\n"; }; }; int main() { Alibaba* a = new Alibaba(); printf("size of Alibaba = %d\n", static_cast<int>(sizeof(*a))); // 16 : 1 + 8 (vptr) + 7(padding , 以8为对齐标准) Bilibili* b = new Bilibili(); printf("size of Bilibili = %d\n", static_cast<int>(sizeof(*b))); // 24 : 1 + 4 + 4 + 8(vptr) + 7(padding , ) DiDi* d = new DiDi(); printf("size of DiDi = %d\n", static_cast<int>(sizeof(*d))); // 24 : 1 + 4 + 4 + 4 + 8(vptr) + 3(padding ) }

如上所示,对象Alibaba的大小由1个char和1个虚函数表指针组成,最后以虚函数表指针的大小(8字节)作为对齐要求,在对象后补齐7字节,一共16字节。

对象Bilibili继承Alibaba,它会继承Alibaba的char型data member,然后自己又拥有两个int型data member,并且不会继承Alibaba用来对齐的空闲内存。

对于vptr,对象Bilibili只会复用Alibaba的vptr的内存位置,但是它的内容将不同于Alibaba对象,指向不同的虚函数表。

同样,对于Didi这个对象,会把父类的data member继承过来,加上自己的data member,但是不会新加一个vptr的内存空间,而是重用Bilibili的vptr的内存空间。

下面图示了三个对象在内存中的布局:

image-20221128171054902

可以看到不论有多少层继承层次,vptr在对象内存中的位置始终只有一个,且指向了不同的virtual table,它们的值是编译器在对象的构造函数中悄悄设置的。而且对象的vptr指向虚函数表的第一个虚函数,在它之上的是type_info指针,这与RTTI(运行时类信息)有关。

可以使用【2】的方法,借助GDB对内存布局进行验证。

注意 : Bilibil没有把Alibaba的paddings全部继承,而是在其上减少了padding至int的对齐标准,然后把int放在原来的padding中。

Didi也是一样的,没有继承Bilinili的paddings。

但是【1】上P105页的例子却把padding原封不动地继承了,我也在linux平台试过书上的例子(g++版本为7.5),结果是8 、8、 12,与书上的结果不一致

一个没有date member的对象的大小

一个空对象(没有任何datamember)的大小为1字节,不为0,因为编译器需要赋予这个对象地址来区别其他对象。

但是没有non static data member却有虚函数的对象大小为8字节,只包含一个vptr指针。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
class A { }; class B { public: virtual ~B() { } }; int main() { A a; // 空类 B b; // 空类,但是有虚函数 printf("size of a = %d\n",sizeof(a)); // 1 printf("size of b = %d\n",sizeof(b)); // 8 }

普通成员函数的存储位置

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
class Widget{ public: int a; Widget() { a = 1; cout << "Wiget()\n"; } void fun1() { cout << "Widget::fun1()\n"; } }; int main() { Widget<int> a; cout << sizeof(a) <<endl; // 4 a.fun1(); }

通过上面的演示我们可以知道,成员函数不占对象空间的大小,那么它们在哪?编译器如何定位并调用它们?

在编译器产生可执行文件之后,成员函数的代码就已经存放于ELF文件中了,位于代码段。成员函数是“跟着类走的”,C++根据类名命名成员函数。

注意,编译器生成函数代码的前提是,程序确实使用其中某个函数,也就是说如果我们不调用a.fun1(),那么fun1就不会被生成。

可以使用命令nm和c++filt(它可以demangle C++的符号名称,以便我们观察)可以清楚看到编译器对成员函数的命名:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
nm 可执行文件名 | c++filt ...... 000000000000093e W Widget::fun1() 0000000000000912 W Widget::Widget() 0000000000000912 W Widget::Widget()

没有经过c++filt处理的fun1名称为_ZN6Widget4fun1Ev,编译器就是直接用的后者。

当编译器看见:

copy
  • 1
a.fun1();

不会根据对象去调用函数,而是直接将其改写为:

copy
  • 1
_ZN6Widget4fun1Ev(&a) //注意,所有成员函数的都会隐式地传入this指针。

可以使用objdump -d命令进行验证,查找到main函数中对fun1的调用:

copy
  • 1
  • 2
891: 48 89 c7 mov %rax,%rdi # rdi存放的是this指针 894: e8 a5 00 00 00 callq 93e <_ZN6Widget4fun1Ev>

可以观察到编译器的行为与上面的nm命令的输出吻合,无论是函数虚拟地址还是mangle后的函数名。

关于vptr的存储位置

可以观察前述章节的代码与图示,vptr是编译器自动帮助我们在对象的存储空间中加上的一个成员变量,单继承体系下,vptr是否始终在一个对象的最底端

假设有这么一个类A,它并没有定义虚函数,因此无vptr;B类继承A类后,添加一个虚函数的定义,编译器势必为B类增加vptr,但是B类的vptr放在哪里?在父对象A的成员开头(即整个对象的开头),还是在B这个子对象的开头?

答案是前者。在单继承体系下,无论多少层继承,vptr只会在对象最底端。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
class A { public: int a; A() { a = 1; } }; class B { public: int b; B() { b = 2; } virtual int getB() { return b; } virtual ~B() { } }; int main() { A* a = new A(); B* b = new B(); printf("size of a = %d\n",sizeof(*a)); // 4 printf("size of b = %d\n",sizeof(*b)); // 16 b->getB(); }

可以使用gdb将a和b处的内存打印出来:

image-20221128203146635

a指针的一个前一个字节就是存放int a这个变量,为1,可见A对象没有vptr。b指针的前8个字节则是vptr,而后8字节则是int b和padding内存,因此对象b继承A的成员函数,再加上自己的成员函数后,会把vptr放在整个对象的顶端。

怎么证明0x555555754d88(我在x86平台实验,小端)就是vptr呢?首先,将0x555555754d88地址处的内容打印出来:

image-20221128203429700

B对象只有一个虚函数,即它的虚构函数,因此推测0x5555555548a8是析构函数的地址。怎么验证呢?我们只要观察汇编语言在main函数退出时,析构B时对析构函数的调用地址就可以了(使用gdb的layout asm):

image-20221128203748087

可以看到确实调用了0x5555555548a8,因此这个地址就是虚析构函数的地址,而指向这块内容的0x555555754d88确实就是vptr。

image-20221128204746902

总结

C++中的对象需要的字节大小与C语言中的struct几乎相同。

只有当涉及到虚拟机制时,比如对象定义了虚成员函数时,C++才会在对象中添加一个vptr成员(g++中为8字节,一个指针的大小),它指向vtable。

至于成员函数、vtable则不会占用对象的空间,它们都是“跟着类走的”,当编译器生成可执行代码时,每个类的成员函数、vtable就已经存放在ELF文件中了,其中成员函数在代码段,而vtable在只读数据段。

虚拟机制--虚函数

在C++中,多态表示“以一个public base class 指针(或reference),寻址出一个derived class object”的意思。

C++的多态只在涉及指针的时候体现!

基本原理

假设有一个Widget类定义了3个虚函数。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
class Widget { public: virtual void h() { cout << "Widget::h()" << endl; } virtual void i() { cout << "Widget::i()" << endl; } virtual void j() { cout << "Widget::j()" << endl; } };

编译器原本的对象内存布局中添加一个vptr指针,这个指针指向一个数组(虚函数表),数组的每个元素都是一个函数地址,对象的每个被声明为virtual的函数在这个数组中“注册”。

如果有一个对虚函数的调用

copy
  • 1
  • 2
  • 3
Widget *p; ... p->h();

那么它被编译器转化为:

copy
  • 1
(*p._vtpr->_vtable[0])(p)

其中 _vtpr就是vptr指针,_vtable则是虚函数表的起始地址。image-20230219203039441

简单说,vptr(虚函数指针)和vtable(虚函数表)这两个数据结构就能够搭建C++的虚函数机制。

vtable的生成时机和存储位置

与成员函数相同,vtable在编译阶段就已经生成了。

且vtable也是“跟着类走的”,不管这个类是基类还是子类,不管子类有没有重写基类的虚函数,编译器会为每个类单独“制作”一份vtable,vtable每一项都存储虚函数的地址,排列顺序与用户在类中定义虚函数的顺序一致。

简单举个例子:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
class Widget { public: virtual void h() { cout << "Widget::h()" << endl; } virtual void i() { cout << "Widget::i()" << endl; } virtual void j() { cout << "Widget::j()" << a << endl; } }; class Widget_Derive : public Widget{ // 没有重写任何虚函数,但是只要程序确实使用了基类,编译器将为这个类另外生成一个vtable } int main() { Widget parent; Widget_Derive child; }

使用nm命令解析可执行文件,可以观察到编译器为vtable生成了两个符号。

image-20230222193544116

而他们位于elf文件的只读程序段:

image-20230222193927624

vptr在什么时候被赋值?

先说结论:在构造函数中被赋值,而且可能在不同的构造函数作用域中有不同的值。

下面看一个简单的例子:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
class A { public: int a ; A() {a = 1;} virtual ~A(){}; virtual int fun(){}; }; class B : public A { public: int b; B() {b = 1;} virtual ~B(){}; virtual int fun(){}; }; int main() { B b; }

B的内存布局在g++完成编译的那一刻就已经确定了:

image-20230324201238270

可以使用objdump查看B的构造函数:

image-20230324201710573

可以看到,B的构造函数首先调用A的构造函数构造父类;而在A的构造函数中,编译器会将A的vtable的地址赋值给vptr;

返回到B的构造函数后,编译器又把vptr重新设置为指向B的vtable。

可见,在两个构造函数中的vptr的指向是不同的!执行哪个个类的构造函数,就需要先将vptr改成指向该类的vtable

vtable存储虚成员函数地址

vtable存储的是对象的虚函数指针,其排列顺序与我们的定义顺序是相同的。我们可以写一个程序手动调用这些函数进行验证:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
// widget.c class Widget { public: virtual void h() { cout << "Widget::h()" << endl; } virtual void i() { cout << "Widget::i()" << endl; } virtual void j() { cout << "Widget::j()" << a << endl; } }; class Widget_Derive : public Widget{ // 没有重写任何虚函数,但是只要程序确实使用了基类,编译器将为这个类另外生成一个vtable } int main() { Widget* d = new Widget(); long * vptr = (long *)d; long * vtable = (long *)(*vptr); for (int i = 0; i < 3; i++) { printf("vtable[%d] = %p\n", i, (void*)vtable[i]); } typedef void(*Func)(void); Func h = (Func)vtable[0]; Func i = (Func)vtable[1]; Func j = (Func)vtable[2]; h(); i(); j(); return 0; }

输出为:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
vtable[0] = 0x557d206e5b66 vtable[1] = 0x557d206e5b9e vtable[2] = 0x557d206e5bd6 Widget::h() Widget::i() Widget::j()

稍稍解释一下main中的部分代码:

copy
  • 1
  • 2
Widget* d = new Widget(); long * vptr = (long *)d;

先创建一个类对象,然后将其地址强制转换成long型指针,这个long型指针就是vptr,它指向虚函数表,我们只需对它进行解引用,再强制将long型整数转化为long型指针,便能得到虚函数表的第一个元素的地址:

copy
  • 1
long * vtable = (long *)(*vptr);

接下来定义函数类型,再对虚函数表的元素进行解引用,就得到了对应虚函数的地址:

copy
  • 1
  • 2
  • 3
  • 4
typedef void(*Func)(void); // 定义函数指针 Func h = (Func)vtable[0]; // 将解引用得到的long型整数转化为函数指针 Func i = (Func)vtable[1]; Func j = (Func)vtable[2];

最后直接调用就可以了。

注意,我的实验在x86-64,g++编译器上完成,如果你使用x86-64,mingw编译器,你可能要将long改成long long

copy
  • 1
  • 2
long long* vptr = (long long *)d; long long * vtable = (long long *)(*vptr);

因为不同编译器的基础类型的大小是不一致的

此外,我们可以借助g++的编译选项生成类的结构图:

copy
  • 1
g++ -fdump-class-hierarchy widget.cpp -g -o widget

会生成一个名为widget.cpp.002t的文件,找到其中关于widget的部分:

image-20230222194735294

还有Widget_Derive部分:

image-20230222194609385

发现其结构与设想的差不多。值得注意的有两个地方:

  • Widget_Derive的vtable的函数指针与Widget的相同,但尽管如此,Widget_Derive还是有一个独立的vtable
  • 实际上vptr指向的并不是vtable的首地址,在虚函数指针之上的信息与RTTI有关。这与多继承、dynamic_cast、异常等话题相关,我将在相关文章中补充。

成员函数的this指针

首先说明,虽然这一节放在了虚拟机这一章,但这个特性不是虚成员函数独有的,而是所有非static 成员函数都适用。

拿上一小节的Widget说,看上去它的成员函数的声明为:

copy
  • 1
  • 2
  • 3
void h(); void i(); void j();

但是实际的声明却是:

copy
  • 1
  • 2
  • 3
void h(Widget*); void i(Widget*); void j(Widget*);

这个隐藏参数就是this指针。

假如我们在Widget中加入一个成员变量a,并稍微改写一下成员函数

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
// callVFunc.cpp class Widget { public: int a; virtual void h() { cout << "Widget::h()" << a << endl; } virtual void i() { cout << "Widget::i()" << a << endl; } virtual void j() { cout << "Widget::j()" << a << endl; } };

实际上的成员函数定义被编译器改写为:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
virtual void h(Widget* this) { cout << "Widget::h()" << this->a << endl; } virtual void i(Widget* this) { cout << "Widget::i()" << this->a << endl; } virtual void j(Widget* this) { cout << "Widget::j()" << this->a << endl; }

如何验证呢?我们再使用这里的的例子,手动调用虚函数成员试试看,唯一区别就是Widget加入了一个成员变量a,且成员函数使用了这个变量a,就像上面代码块那样:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
// callVFunc.cpp class Widget { public: int a; virtual void h() { cout << "Widget::h()" << a << endl; } virtual void i() { cout << "Widget::i()" << a << endl; } virtual void j() { cout << "Widget::j()" << a << endl; } }; int main() { Widget* d = new Widget(); long * vptr = (long *)d; long * vtable = (long *)(*vptr); typedef void(*Func)(void); Func h = (Func)vtable[0]; Func i = (Func)vtable[1]; Func j = (Func)vtable[2]; h(); i(); j(); return 0; }

得到的结果是 segmentation fault!为什么?

根据前面的铺垫,成员函数h、i、j这三个虚函数将使用this指针来取得Widget的成员变量a,但是我们在调用它们时,没有传入任何参数,因此当成员函数将会取得一个非法指针当作this指针,当它们试图this->a,实际上会对非法指针进行解引用,最终程序就会被操作系统强制中断。

解决的方法也很简单,我们传递正确的this指针 就可以了:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
typedef void(*Func)(Widget*); Func h = (Func)vtable[0]; Func i = (Func)vtable[1]; Func j = (Func)vtable[2]; h(d); i(d); j(d);

程序将正常退出,并能够获取到成员变量a的值。

另一种验证方法,就是直接产看汇编代码,眼见为实。我们拿修改之前的代码进行编译,但为了对照,以正常手段调用一次h成员函数

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
int main() { Widget* d = new Widget(); long * vptr = (long *)d; long * vtable = (long *)(*vptr); typedef void(*Func)(void); Func h = (Func)vtable[0]; Func i = (Func)vtable[1]; Func j = (Func)vtable[2]; h(); // 通过函数指针调用h、i、j i(); j(); d->h(); // 正常手段调用h虚成员函数 return 0; }

接着使用objdump -d 命令生成反编译文件:

copy
  • 1
  • 2
$> g++ callVfunc.cpp -o callVfunc $> objdump -d callVfunc > callVfunc.objdump //

打开最终的反汇编文件,搜索 main 函数,可以看到只有最后一个函数传递了有效的this指针。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
00000000000009ea <main>: 9ea: 55 push %rbp 9eb: 48 89 e5 mov %rsp,%rbp 9ee: 53 push %rbx 9ef: 48 83 ec 38 sub $0x38,%rsp 9f3: bf 10 00 00 00 mov $0x10,%edi # operatpr new的参数(size = 16) 9f8: e8 a3 fe ff ff callq 8a0 <_Znwm@plt> # 调用operator new 申请内存空间 9fd: 48 89 c3 mov %rax,%rbx # new 出来后的地址存在rbx a00: 48 c7 03 00 00 00 00 movq $0x0,(%rbx) # 首先将这个地址锁指向的内存的8字节清零,也就是vptr清零 a07: c7 43 08 00 00 00 00 movl $0x0,0x8(%rbx) # 然后将这个地址+8处的4字节清令,也就是成员变量a清零 a0e: 48 89 df mov %rbx,%rdi # 将this指针当作参数 a11: e8 78 01 00 00 callq b8e <_ZN6WidgetC1Ev> # 调用构造函数, 在这之后vptr被设置成了正确的值,指向对应vtable a16: 48 89 5d c0 mov %rbx,-0x40(%rbp) # 将widget*的值 存到esp指针处,在栈低位置 a1a: 48 8b 45 c0 mov -0x40(%rbp),%rax # 将widget*的值 存到eax a1e: 48 89 45 c8 mov %rax,-0x38(%rbp) # 将wdiget*de值 存到esp往上8字节的位置。 --我也不知道为什么要存两次。 a22: 48 8b 45 c8 mov -0x38(%rbp),%rax a26: 48 8b 00 mov (%rax),%rax # rax现在存放了vptr的值 a29: 48 89 45 d0 mov %rax,-0x30(%rbp) # 把vptr的值放到栈上 a2d: 48 8b 45 d0 mov -0x30(%rbp),%rax # a31: 48 8b 00 mov (%rax),%rax # rax现在存放虚函数表中第一个函数,即h的地址 a34: 48 89 45 d8 mov %rax,-0x28(%rbp) # 将成员函数h的地址放到栈上 a38: 48 8b 45 d0 mov -0x30(%rbp),%rax a3c: 48 83 c0 08 add $0x8,%rax a40: 48 8b 00 mov (%rax),%rax a43: 48 89 45 e0 mov %rax,-0x20(%rbp) # 将成员函数i的地址放到栈上 a47: 48 8b 45 d0 mov -0x30(%rbp),%rax a4b: 48 83 c0 10 add $0x10,%rax a4f: 48 8b 00 mov (%rax),%rax a52: 48 89 45 e8 mov %rax,-0x18(%rbp) # 将成员函数j的地址放到栈上 a56: 48 8b 45 d8 mov -0x28(%rbp),%rax a5a: ff d0 callq *%rax # 调用 h,无参数传递 a5c: 48 8b 45 e0 mov -0x20(%rbp),%rax a60: ff d0 callq *%rax # 调用 i,无参数传递 a62: 48 8b 45 e8 mov -0x18(%rbp),%rax a66: ff d0 callq *%rax # 调用 j,无参数传递 a68: 48 8b 45 c0 mov -0x40(%rbp),%rax # 取得this指针 a6c: 48 8b 00 mov (%rax),%rax a6f: 48 8b 00 mov (%rax),%rax a72: 48 8b 55 c0 mov -0x40(%rbp),%rdx a76: 48 89 d7 mov %rdx,%rdi a79: ff d0 callq *%rax # 调用 h,传递this指针! a7b: b8 00 00 00 00 mov $0x0,%eax a80: 48 83 c4 38 add $0x38,%rsp a84: 5b pop %rbx a85: 5d pop %rbp a86: c3 retq

下图为main函数的栈帧示意图,希望能帮助你理解上面的汇编程序:

image-20230219203101579

注意,x64架构在寄存器够用的情况下,使用寄存器进行函数传参,否则改为用栈传。32为机器则全部通过栈来传递参数。参考《深入理解计算机体系》的内容(中文版,原书第三版,P168),第一个参数可以存放在rdi,第二个参数存在rsi寄存器,....,通过寄存器一共可以传递6个参数,从第七个参数开始,都会通过栈来传递。

是的,栈上有两个Widget指针,使用GDB验证:

image-20230218194146565

本人才刚刚入了C++的门,目前不太清楚编译器为什么会这样做

另外vtable这个变量被编译器优化掉了。

纯虚函数(待补充)

构造函数

构造函数的合成

“当一个类没有声明构造函数时,编译器会合成一个default constructor”,这句话是错误的。只有当编译器需要默认构造函数,而不是程序员需要时,编译器才会合成一个构造函数。

“一个类没有声明构造函数”只是“编译器合成默认构造函数”必要条件,不是充分条件!

有四种情况,编译器会合成(\扩充)用户没有(\已经)定义的默认构造函数:

  • class Foo没有定义任何构造函数,class Foo带有一个member objcet,假设其类型为class Bar,而class Bar显示定义了默认构造函数。那么编译器有责任在class Foo初始化时,将class Bar的member object也初始化, 但是class Foo的其他成员的初始化由程序员负责,编译器不会初始它们。看个例子:

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    class Foo { public: Foo() { // 显示定义了默认构造函数 .... } } class Bar { public: Foo foo; // member object char* str; // 非对象成员 }

    那么当定义一个Bar成员时:

    copy
    • 1
    • 2
    • 3
    int main() { Bar bar; }

    编译器有责任将bar中的foo初始化,因此它将为Bar合成一个默认构造函数,并在里面调用 Foo的默认构造函数:

    copy
    • 1
    • 2
    • 3
    • 4
    Bar::Bar() { // 伪代码,编译器生成的 foo.Foo::Foo();// 调用Foo的显示定义的默认构造函数 }

    但是合成的构造函数,不会对bar.str进行初始化!,它的初始化是程序员的责任。

    因此程序员如果要初始化str,他应显示定义一一个Bar的构造函数,里面对str进行初始化操作:

    copy
    • 1
    • 2
    • 3
    • 4
    Bar::Bar() { // 程序员显示定义的 str = 0;// 对str进行初始化 }

    那么现在一来,编译器就没法合成一个Bar的默认构造函数了,但是foo还没有被初始化呢,将它初始化是编译器的责任。因此编译器会扩充程序员定义的Bar构造函数,插入调用foo的默认构造函数的代码:

    copy
    • 1
    • 2
    • 3
    • 4
    Bar::Bar() { str = 0;// 程序员显示定义的,对str进行初始化 foo.Foo::Foo();// 编译器插入的,调用Foo的显示定义的默认构造函数 }

    简单验证,使用nm即可

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    class Bar { }; int main() { Bar a; return 0; }

    使用nm命令查看符号表:

    copy
    • 1
    nm 手动定义构造函数的情况下 | c++filt | grep Bar

    没有任何输出!表示编译器没有生成Bar的默认构造函数!

    但是如果Bar类存在一个Foo基类:

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    class Bar { public: Foo foo; // member object };

    再使用nm命令则看到:

    copy
    • 1
    0000000000000852 W Bar::Bar()

    这次,编译器则帮我们生成了一个默认构造函数。

  • 子类class Foo没有定义任何构造函数,父类class Bar显示定义了一个默认构造函数,则当有一个子类class Foo被定义时,编译器有责任将子类中来自父类的部分进行初始化。同上,编译器会生成\扩充现有的class Foo的默认构造函数,在其中添加调用 Bar::Bar()的代码。

  • 带有virtual function 的class,编译器有责任将它们的vptr设置正确,因此会为每一个用户定义的构造函数加上一些代码设置vptr的值。特别的,如果这个类没有定义任何构造函数,编译器将合成一个默认构造函数,以正确设置vptr。

  • 带有virtual base的class,同上。

易错点:

  • 编译器而合成出来的default constructor不会显示设定class内每一个data member的默认值

我起初不信邪,在堆上创建了对象,并用GDB观察了它的初始化情况,发现date member 的值是初始化了的,然后我就觉得书的内容有些过时。

但是下面这个实验:在栈上而不在堆上,创建了具有data member的对象,发现它们确实没有被初始化。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
using namespace std; class Widget { public: int a; virtual void h() { cout << "Widget::h()" <<endl; } virtual void i() { cout << "Widget::i()" <<endl; } virtual void j() { cout << "Widget::j()" << endl; } }; int main() { Widget a; return 0; }

image-20221128224306982使用GDB观察在prinf语句之后可以看到四个成员变量都是随机值,一个都没有被设置为默认值。

但是,在堆上创建的对象的成员变量确实是初始化了的,是谁将其初始化的?操作系统吗?还是编译器?答案是编译器,它会额外安插代码实现将堆上对象的成员变量初始化。但注意这些初始化代码没有安插在构造函数中,因此“默认构造函数不会初始化成员变量”还是正确的。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
int main() { Widget* a = new Widget(); return 0; }

编译这段代码并使用objdump -d 反编译,查看其main函数:

image-20230218215530933

可以看到编译器在调用构造函数之前对其成员变量进行了初始化

但用同样的方法查看在栈上的对象,则根本没有初始化的步骤

copy
  • 1
  • 2
  • 3
  • 4
  • 5
int main() { Widget a; return 0; }

image-20230218215758489

构造函数与初始化列表

4中情况下必须使用初始化列表:

  • 初始化一个reference member时
  • 初始化一个 const member时
  • 调用一个base class 的constructor,且它拥有一组参数时
  • 调用一个member class 的constructor,且它拥有一组参数时

有两个注意点,还是用实例说明:

  1. 初始化顺序与成员变量的声明顺序有关,哪个在前就先被初始化

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    class A{ private : int a_; int b_; public : A(int c, int d): b_(d),a_(c) { } };

    由于a_ 声明比b_ 早,即时初始化列表对b_ 的初始化写在前面,编译器还是先初始化a_

    会变成下面这样:

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    class A{ private : int a_; int b_; public : A(int c, int d):{ // 编译器自动安插的代码 a_ = d; b_ = c; } };
  2. 就初始化列表而言,编译器自动安插的代码,永远在用户声明的代码之前

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    class A{ private : int a_; int b_; public : A(int c): b_(c) { a_ = 0; } };

    会转化成:

    copy
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    class A{ private : int a_; int b_; public : A(int c) { // 编译器根据初始化列表安插的代码一定在user code前面 b_ = c; // user code a_ = 0; } };

注意,这两个规则可能会带来比较隐晦的bug:当成员变量的初值是相互关联的时候,我们必须关注这种初始化顺序!否则可能带来未定义行为。

初始化列表的性能优势

构造函数有两个阶段:

  • 初始化阶段:类中所有类型的成员变量在初始化阶段都会进行初始化操作,不管该成员是否出现在初始化列表中
  • 计算阶段:在构造函数的函数体内执行

注意这里的初始化阶段,所有成员变量都会被强制初始化。尤其对类成员变量来说,如果不显示使用初始化列表,编译器也会在这个阶段使用默认构造函数初始化这个成员变量。见如下实验代码:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
class Member1 { public: int a_; Member1() { a_ = 0; std::cout << "Member1 default constructor\n"; } Member1(int a):a_(a) { std::cout << "Member1 initialized with " << a_ << std::endl; } Member1(const Member1& other):a_(other.a_){ std::cout << "Member1 copy constructor\n"; } Member1& operator=(const Member1& other){ a_= other.a_; std::cout << "Member1 assignment constructor\n"; } }; class Member2 { public: int a_; Member2() { a_ = 0; std::cout << "Member2 default constructor\n"; } Member2(int a):a_(a) { std::cout << "Member2 initialized with " << a_ << std::endl; } Member2(const Member2& other):a_(other.a_){ std::cout << "Member2 copy constructor\n"; } Member2& operator=(const Member2& other){ a_= other.a_; std::cout << "Member2 assignment constructor\n"; } }; class Something{ public: Member1 b1; Member2 b2; Something():b1(0) { // b1 使用初始化列表,而b2使用赋值进行拷贝 b2 = Member2(0); std::cout << "Something default consstructed\n"; } }; int main() { Something s1; }

输出如下:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
Member1 initialized with 0 Member2 default constructor // 1 初始化阶段,编译器强制调用默认构造函数 Member2 initialized with 0 // 2 这是临时对象的构造函数 Member2 assignment constructor // 3 Something default consstructed

可以看到,第二个成员没有使用初始化列表,它比第一个成员多出了两个动作。

copy
  • 1
Member2 default constructor

表示在初始化阶段,编译器强制对b2进行了默认初始化。

copy
  • 1
  • 2
Member2 initialized with 0 Member2 assignment constructor

表示在计算阶段(也就是执行大括号中的语句时),会首先创建一个临时变量,然后调到用copy assignment对b2进行赋值。

在初始化阶段,编译器强制初始化所有成员函数,而初始化列表是能够影响这一阶段的动作的:

  • b1成员变量使用了初始化列表进行初始化,因此编译器直接使用另一个重载构造函数进行了初始化
  • b2成员变量虽然没有显示地使用初始化列表进行初始化,但编译器还是使用了它的默认构造函数进行了初始化!如果Member2没有定义任何一个构造函数的重载,编译器甚至帮你合成它(见构造函数的合成一节),但如果你定义了其他形式的构造函数却没有定义默认构造函数,编译器会报错,因为用户自定义的构造函数会抑制编译器合成默认构造函数。

在计算阶段,则先构建一个临时对象,然后调用赋值函数对b2对象进行赋值,那自然就多了接下来的两步操作。

因此,在构造函数中使用初始化列表对成员变量进行初始化,能够提升程序效率。

同理,复制构造函数中,也应倾向于使用初始化列表对成员进行初始化!

因为当一个对象的复制构造函数被调用时,这个对象本身还没有被创建,因此复制构造函数也会有上述的两个阶段,应在其初始化阶段将成员变量一次性地构造完整。

为什么构造函数不能是虚函数

如果你尝试在构造函数前加上virtual关键字,编译器是会直接报错的,连编译都不能通过。

试着考虑一下为什么编译器禁止这样做?我们知道,虚函数的调用依靠vptr,但是在前文中我们知道,vptr会在构造函数中才被正确赋值!我们如何能在vptr被正确赋值前,调用虚构造函数呢?所以编译器直接禁止了构造函数被声明为虚函数。

构造函数中调用虚函数

【4】中条款9说:“构造期间,不要调用virtual函数,因为这类调用从不下降至derivedclass”

为什么?整理了【1】中的说法:虚函数调用与vptr有关,而vptr的设置在整个对象的初始化阶段会被不断地赋予新值。编译器为了不出现未定义行为,编译器不会在构造函数中使用常用的虚函数调用方法。(具体看看下面的例子)

比如有下面的继承体系:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
// CallVFuncInCstor.cpp class Widget { public: Widget() { fun(); cout << "Widget Cstor\n"; } virtual void fun() { cout << "Widget fun\n"; } }; class Widget_Drived : public Widget{ public: Widget_Drived(){ fun(); cout << "Widget_Drived Cstor\n"; } virtual void fun() { cout << "Widget_Drived fun\n"; } }; int main() { Widget_Drived* d = new Widget_Drived(); }

输出为:

copy
  • 1
  • 2
  • 3
  • 4
Widget fun Widget Cstor Widget_Drived fun Widget_Drived Cstor

可能初学者认为会出现两次Widget_Drived fun,因为他们觉得:按照虚函数的调用规则,编译器会按照动态类型进行调用,而这里的动态类型为Widget_Drived*,因此即时在基类Widget的构造函数中调用fun函数,依然会路由到Widget_Drived类的fun函数。

但很遗憾,事实与想象不一样。现在main()函数中添加使用子类指针对fun的调用,作为对比:

copy
  • 1
  • 2
  • 3
  • 4
int main() { Widget_Drived* d = new Widget_Drived(); d->fun(); }

编译然后反汇编,先看看main函数中,编译器是怎么处理d->fun()调用的:

image-20230221220044761

符合想象,就是通过vptr到函数地址的那套理论方法。

再来看看Widget_Derived的构造函数是怎样调用fun的:

image-20230221220825907

可以看到,编译器是直接调用了Widget_Drived::fun()函数,根本就没有从vtable中获取函数地址的这个步骤

同样的,Widget的构造函数也是直接调用了Widget::fun()函数

image-20230221220958117

总结:从语法上讲,构造函数调用虚函数是没有问题的,是能够通过编译的。但是从实际效果上,无法实现虚函数的作用。编译器不会在构造函数中使用vtable来调用函数,而是直接使用具体的函数地址调用。

那么为什么编译器会有这样的行为呢?就像本节开头说的,我们可以从vptr在什么时候被赋值这里看出一些端倪。

因为vptr在构造函数期间是不断变化的,在不同的构造函数vptr指向不同的vtable,在这期间调用的虚函数只能路由至本类的虚函数:

  • 比如class B 继承 class A, 类B的对象在构造时,首先会调用类A的构造函数,然后返回至类B的构造函数继续整个对象的构造;
  • 在类A的构造函数中vptr指向的是类A的虚函数表,返回至类B的构造函数后vptr又被调整为指向类B的虚函数表。
  • 因此在构造函数中调用虚函数一定路由到各自类定义的虚函数,而不管this指针指向的是A对象或者B对象(实际类型)。
  • 所以,在构造函数中的虚函数调用,编译器为了效率考量,索性直接使用 call 指令调用对应的函数,而不会走虚拟机制。因为这两种方式的结果一样,但是前者的效率更高。

另外一种说法是,如果允许构造函数能够正真地执行虚拟机制,有可能会发生未定义行为。还是这个例子, class B 继承 class A,类B对象在创建时,首先执行类A的构造函数,类B的成员还未初始化完成,如果此时允许虚拟机制的发生,那么就有可能读写未初始化的变量,这可能造成未定义行为。

拷贝构造函数

默认拷贝函数

如果程序员没有为class提供一个copy constructor,而这个class object以相同class的另一个object作为初值时,编译器会进行默认的default memberwise intialization,也可认为编译器生成了一个trivial的默认拷贝构造函数。trivial的copy constructor效率更高,因为它执行的bitwise copy。但是涉及一些情况时,编译器会生成/合成nontrivial的copy constructor:

  1. class 内有member object
  2. class 继承自一个base class
  3. class 声明一个或多个virtual function
  4. 涉及虚继承

至于拷贝赋值函数,与拷贝构造函数表现出相似的行为。在不需要编译器合成non-trivial的拷贝赋值函数的情况下,也会执行bitwise 语义的拷贝操作,编译器实际上也不会合成拷贝赋值函数。只有在特定情况下(与拷贝构造函数相似),编译器会合成拷贝赋值函数。

拷贝构造函数的参数为什么要const& 修饰?

首先,如果不加上&符号,编译都通不过:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
class A { public: int a_ = 1; A(int a):a_(a) { } A(A other) { // 编译器报错 } };

因为如果调用拷贝函数的时候传值,那么编译器需要先构造一个临时变量,这个临时变量是通过拷贝构造函数完成,那么拷贝构造函数又是传值的,所以编译器需要首先构造一个临时变量,这个临时变量是通过拷贝构造函数完成的,而拷贝构造函数又是传值的。。。。。。看到了吗,会一直递归下去,所以编译器禁止拷贝构造函数传值。

那么const呢?如果去掉const,那么类A调用拷贝构造函数的方法只能有非const对象完成,不能使用const对象拷贝构造一个新对象:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
class A { public: int a_ = 1; A(int a):a_(a) { } A(A& other) { } }; int main() { const A constA(1); A nonConstA(2); A first(constA); // compliler error: binding reference of type ‘A&’ to ‘const A’ discards qualifiers A second(nonConstA); // 正常 return 0; }

另外,更有意思的是,如果有一个getA()函数返回一个A对象,

copy
  • 1
  • 2
  • 3
A getA() { return A(2); }

然后试图调用该函数获得一个对象:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
int main() { A a = getA(); // 编译错误! return 0; }

编译器将报错: cannot bind non-const lvalue reference of type ‘A&’ to an rvalue of type ‘A’

为什么?首先getA()将返回一个临时对象,临时对象的值类型为右值, 然后使用右值去调用A的拷贝构造函数,而A的拷贝构造函数的形参是一个non-const 左值引用,不能绑定右值

但是const 左值引用是能够绑定右值的,所以只要将形参加上const修饰符即可。

析构函数

为什么析构函数最好定义为虚函数?

因为有可能造成内存泄漏。

看看下面一个简单的例子:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
class Base { public: Base () { cout << "Base\n"; } ~Base() { cout << "~Base\n"; } }; class Derive : public Base { public: char* member; Derive() { member = new char(11); cout << "Derive : new char(11)\n"; } ~Derive() { delete [] member; cout << "~Derive : delete member\n"; } }; int main() { Derive* d = new Derive(); delete d; }

输出为:

copy
  • 1
  • 2
  • 3
  • 4
Base Derive : new char(11) ~Derive : delete member ~Base

我们可以推测出当子类对象构造时会安插父类对象的构造函数,同样的子类对象的析构函数会安插对父类析构函数的调用:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
~Derive() { delete [] member; cout << "~Derive : delete member\n"; Base::~Base(); // 编译器安插的代码 }

现在我们使用父类指针指涉一个子类对象:

copy
  • 1
  • 2
Base* b = new Derive(); delete b;

输出为:

copy
  • 1
  • 2
  • 3
Base Derive : new char(11) ~Base

发现缺少了子类析构函数的调用!也就是说子类中的member指针发生了内存泄漏。

解决这个问题的方法就是将父子类的析构函数都写成虚函数

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
class Base { public: Base () { cout << "Base\n"; } virtual ~Base() { cout << "~Base\n"; } }; class Derive :public Base { public: char* member; Derive() { member = new char(11); cout << "Derive : new char(11)\n"; } virtual ~Derive() { delete [] member; cout << "~Derive : delete member\n"; } };

那么子类对象发生析构时,会根据虚函数表中记录的析构函数进行调用,由于Derive类的虚函数表的析构函数指向子类的析构函数,因此最终调用Derive的虚函数,又由于子类析构函数中被安插父类的析构函数,因此整个子类对象将被完美释放,不会有内存泄漏产生。

注意,如果只有子类的析构函数前加了virtual,而父类前没有加virtual关键字,那么同样会发生内存泄漏:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
class Base { public: Base () { cout << "Base\n"; } // 基类 没有 加上virtual关键字 ~Base() { cout << "~Base\n"; } }; class Derive :public Base { public: char* member; Derive() { member = new char(11); cout << "Derive : new char(11)\n"; } virtual ~Derive() { delete [] member; cout << "~Derive : delete member\n"; } }; int main() { Derive* de = new Derive(); delete de; std::cout << "---------------------\n"; Base* b = new Derive(); delete b; }

输出为:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
Base Derive : new char(11) ~Derive : delete member ~Base --------------------- Base Derive : new char(11) ~Base # 没有调用到~Derive(), 发生了内存泄漏。

如果想从汇编层面观察它们的具体行为,可以使用Linux命令行objdump观察编译器对上面两个delete的转换。Derive指针指向的对象,编译器这样调用它的析构函数:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
b6f: 48 8b 45 e0 mov -0x20(%rbp),%rax b73: 48 8b 00 mov (%rax),%rax b76: 48 83 c0 08 add $0x8,%rax b7a: 48 8b 00 mov (%rax),%rax b7d: 48 8b 55 e0 mov -0x20(%rbp),%rdx b81: 48 89 d7 mov %rdx,%rdi b84: ff d0 callq *%rax

可以看到编译器由vptr再到vatable再到函数指针查找对象的析构函数,由于指针所指向的对象是Derive对象,它的虚函数表中的析构函数对应为Derive,那么最后就会调用Derive。

再来看Base指针指向的对象,编译器这样调用它的析构函数:

copy
  • 1
  • 2
bbb: 48 89 df mov %rbx,%rdi bbe: e8 d3 00 00 00 callq c96 <Base::~Base()>

可以看到编译器直接调用Base的析构函数,而不是通过虚拟机制查虚函数表,所以即使Base指针这里指向的确实是Derive对象,但是编译器不会走虚拟机制,所以这里调用的是~Base()!

析构函数内调用虚函数

前文已经讨论了在构造函数中不能达到调用虚函数的效果,那么在析构函数中调用虚函数会怎样?

结论:我们能在析构函数内调用虚函数,语法层面是没有问题的,但是达不到动态绑定的效果。

验证程序:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
#include "static_fun.h" using namespace std; class Base { public: virtual void fun1() { cout << "Base::fun1\n"; } Base () { } ~Base() { fun1(); cout << "~Base()\n"; } }; class Derive :public Base { public: virtual void fun1() { cout << "Derive::fun1\n"; } Derive() { } virtual ~Derive() { fun1(); cout << "~Derive()\n"; } }; int main() { Derive* de = new Derive(); delete de; }

输出:

copy
  • 1
  • 2
  • 3
  • 4
Derive::fun1 ~Derive() Base::fun1 ~Base()

可以看到,调用了一遍Derive::fun1, 也调用了一遍Base::fun1,说明在析构函数中调用虚函数是达不到想要的效果的。详见“构造函数中调用调用虚函数”这一节,原因是类似的,都是vptr的动态赋值造成的。

name magling、函数重载、extern "C"

class的static data member不会放在对象布局中,而是存放于global data segment中,但不同class定义了同名的static data member,造成了名称冲突怎么办?--- 不会有名称冲突,编译器会暗中将每个static data member 进行name magling,我猜是将类名与成员变量名一起编码了。

name magling能够在继承体系中,区分不同sub object的同名成员变量,我猜想C++编译器也是将类名与成员变量名一起编码了。

此外,正是name magling的作用,使得C++能够有函数重载的功能(C语言则没有)。 对于nonmember function和member function,C++编译器都会对函数名称进行name magling。对于member function原理是将类名命名空间编码在函数名中外,还将参数类型参数个数参数顺序一同编码在了函数名中。注意这里不包括函数返回值。即返回值不同不会触发重载机制,会触发编译器报错 :)。对于nonmember function,除了没有类名以外都和member function采用相同的name magling规则。

但是如果对nonmember function前加上 extern “C”,那么就会压抑C++编译器的name magling作用,从而达到兼容C语言API的效果(C语言不会对函数进行name magling,以此也可推论得出C语言不会有函数重载)。

为了兼容C和C++,通常会在头文件使用宏__cplusplus来尽心区分。比如C的库文件有 memset函数,头文件通常这么处理:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
#ifdef __cplusplus extern "C" { #endif void* memset(void*, int, size_t); #ifdef __cplusplus } #endif

如果使用C编译器,就不会有__cplusplus这个宏,什么都不会发生;如果使用C++编译器,那么编译器会自动定义__cplusplus这个宏,于是头文件的extern "C"也就被加上了,以此来抑制name magling机制,做到与C语言的兼容。

C++全局对象

存在哪里

先来复习一下C语言下的全局变量的存储。泛泛地讲,初始化了的全局变量存储在data段,未初始化的(或者以0为初始值的)全局变量“存储”在bss段(实际上,在Elf文件本身没有给未初始化的全局变量分配空间,它只是记录了这种需求,但是操作系统会为其分配虚拟地址并初始化为0)。

还是看个例子吧,以后复习的时候,好说服我自己 :

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
int c_no_init; int c_does_init = 1; static int c_static_no_int; static int c_static_does_init = 1; int main() { return 0; }

用nm + c++filt处理可执行文件:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
... 0000000000201010 D c_does_init 0000000000201140 B c_no_init ... 0000000000201154 b c_static_no_int 0000000000201014 d c_static_does_init

D、d表示该符号存储在data段,B、b表示该符号存储在bss段;大写表示全局符号,小写表示局部(指仅本文件可见)符号,这是static修饰全局变量的作用:使得该符号仅在本编译单元内可见。

在C++中,基本数据类型的表现形式与C语言相同,但是对于自定义类,编译器处理时有着不同的行为。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
class Widget { int a_; public: Widget() { cout << "Widget()\n"; } Widget(int a):a_(a) { cout << "Widget(int a)\n"; } }; Widget aaa; // B Widget aab(1); // B Widget aac = aab; // B static Widget aad; // b static Widget aae(1); // b static Widget aaf = aab; // b int main() { return 0; }

可以使用nm命令检查,无论是否“在语义上”被初始化了,全局对象都存放在bss段。也就是说,C++的全局对象无论是被初始化还是未被初始化,编译器统统将其当作未初始化,一股脑放在bss段中。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
... 0000000000201134 B aaa 0000000000201138 B aab 000000000020113c B aac ... b aad 000000000020114c b aae 0000000000201150 b aaf

ELF文件格式规定,bss段中的变量一定是未初始化的,由操作系统将其赋值0。

但是如果你执行这段程序,观察输出,发现它们确实都调用了构造函数:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
Widget() Widget(int a) Widget(const Widget& other) Widget() Widget(int a) Widget(const Widget& other)

这些构造函数是在哪里被调用的?可以使用GDB调试看看,先在_start()处打个断点:、

image-20230222204734983

可以看到6个对象的成员变量都是0,也就是说操作系统确实为这些对象分配了内存,且初始化为了0,但此时还没有调用构造函数。

然后在main()处打断点运行,此时所有的cout语句都已经输出,且6个对象都已正确被初始化:

image-20230222205142023

也就是说在main函数运行前,全局变量已经被初始化了,那么猜想是库函数帮助我们将c++对象初始化了。接下来的问题是,库函数怎样将C++全局对象初始化。

全局变量如何初始化、析构

以下内容参考自《程序员的自我修养》一书

如果你使用readelf —S命令查看上述可执行文件,你可以观察到两个与C++全局对象有关的段:.init .fini段。

copy
  • 1
  • 2
  • 3
  • 4
Name Type Address Offset Size EntSize Flags Link Info Align ....... .init PROGBITS 0000000000000688 00000688 0000000000000017 0000000000000000 AX 0 0 4 .fini PROGBITS 00000000000009c4 000009c4 0000000000000009 0000000000000000 AX 0 0 4

库函数在用户程序的主函数运行之前,会调用.init段中的初始化函数将全局变量初始化,在用户主函数运行结束后,又会调用.fini段中的函数将全局变量析构。

大致的做法是,编译器将遍历每个目标文件中的全局对象,为他们生成一个构造函数并存放在 某个目标文件的.ctor段中。

每个目标文件都有可能存在全局对象,因此在链接阶段会将所有目标文件的.ctor段合并到可执行文件的.ctor段中。这将形成一个函数指针数组,每个元素指向一个全局对象的初始化函数。而.init段中的代码,就是遍历这个数组并调用所有全局对象的初始化函数,在main函数执行前将全局对象初始化完成。

至于全局析构,思想与全局初始化一致,库函数将执行.fini段的代码,依次调用全局对象的析构函数。

不知道书上的g++版本是啥,我使用g++7.5进行实验时,目标文件中没找到.init段,只看到了一个_Z41__static_initialization_and_destruction_0ii 函数,g++大概是用这个函数完成了所有全局对象的初始化。

C++函数中的局部静态对象

存在哪里

先说它的作用,static修饰函数内的局部变量,会使得改变量仅被初始化一次。

还是先来复习C语言语义下的局部静态变量

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
void fun() { static int int_not_init; static int int_does_init = 1; } int main() { fun(); return 0; }

还是使用nm去解析可执行文件,可以看到初始化了的局部静态变量在data段中,而未初始化了的局部静态变量在bss段中。

copy
  • 1
  • 2
0000000000201010 d int_does_init.1795 0000000000201018 b int_not_init.1794

再看C++语义下的局部静态自定义类对象

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
class Widget { public: int a_; Widget() { cout << "Widget()\n"; } Widget(int a):a_(a) { cout << "Widget(int a)\n"; } Widget(const Widget& other):a_(other.a_) { cout << "Widget(const Widget& other)\n"; } }; void fun() { static Widget obj_not_init; static Widget obj_does_init = Widget(1); } int main() { fun(); return 0; }

使用nm解析,发现了与C++全局对象相似的结果,局部静态对象无论在语义上进行不进行初始化,它们统统被放在了bss段

copy
  • 1
  • 2
000000000020213c b fun()::obj_not_init 0000000000202148 b fun()::obj_does_init

如何初始化

先修改一下程序以便调试:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
class Widget { public: int a_; Widget() { cout << "Widget()\n"; } Widget(int a):a_(a) { cout << "Widget(int a)\n"; } Widget(const Widget& other):a_(other.a_) { cout << "Widget(const Widget& other)\n"; } }; void fun() { static Widget obj_does_init = Widget(1); obj_does_init.a_ += 1; cout << "obj_does_init.a_ = " << obj_does_init.a_ <<endl; } int main() { fun(); fun(); return 0; }

输出为:

copy
  • 1
  • 2
obj_does_init.a_ = 2 obj_does_init.a_ = 3

使用GDB调试,在mani函数处打个端点,然后执行至main的前一条代码,此时gdb命令输入 print fun()::obj_does_init,发现他没有被初始化,gdb命令输入next执行到return前一行,再打印这个变量的值,发现它已经被初始化。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
(gdb) b main Breakpoint 1 at 0xac6: file local_static_object.cpp, line 25. (gdb) r Starting program: /home/ubuntu/Learn/Labs/CPP/C++新经典--对象模型/ch7/local_static_object Breakpoint 1, main () at local_static_object.cpp:25 25 fun(); (gdb) print fun()::obj_does_init $1 = {a_ = 0} (gdb) n Widget() Widget(int a) 26 return 0; (gdb) print fun()::obj_does_init $2 = {a_ = 1} # 已被初始化 (gdb)

以此推测局部静态变量是在调用函数fun()中被初始化的,那么编译器如何保证局部静态变量只被初始化一次呢?换句话说,编译器如何能够跳过:

copy
  • 1
static Widget obj_does_init = Widget(1);

而直接执行以下代码?

copy
  • 1
  • 2
obj_does_init.a_ += 1; cout << "obj_does_init.a_ = " << obj_does_init.a_ <<endl;

经过查阅各种资料,得出以下结论

  • 局部静态变量的初始化会引入一个64为无符号整数名为guard的变量,以及mutex锁

    使用nm命令查看:

    copy
    • 1
    • 2
    0000000000202140 b guard variable for fun()::obj_does_init 000000000020213c b fun()::obj_does_init

    guard变量存放于bss段,且在虚拟空间上是紧挨着局部静态变量的。guard变量的第一位用作标志位,表示静态变量是否被初始化,其余63位“implementation-defined.”

  • 进入fun函数时,首先判断guard变量是否为1

    • 如果是1,则表示局部静态变量已经被初始化,因此跳过初始化步骤执行
    • 如果是0,则表示局部静态变量还没有初始化,所以尝试初始化
      • 但是为了线程安全,必须在这里设置一把锁,只有取得锁的线程才能进行初始化。获取锁的操作为__cxa_guard_acquire,线程会一直等待,直到获取这把锁
        • 若__cxa_guard_acquire返回0,则表示其他线程已经在等待过程中初始化了局部静态对象,因此本线程依然不用去初始化对象
        • 若__cxa_guard_acquire返回1,则表示这个局部静态对象还没有初始化,且已经取得了mutex锁,此时本线程可以排他地进行局部静态变量的初始化
      • 初始化完了,将guard变量设置成1,释放mutex锁

使用objdunp命令查看fun函数

copy
  • 1
$>objdump -d local_static_object > local_static_object.objdump
copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
0000000000000afa <_Z3funv>: ------------------------------常规操作:更新栈帧,保存被调用者需要保存的寄存器rbx------------------------------------------ afa: 55 push %rbp afb: 48 89 e5 mov %rsp,%rbp afe: 41 54 push %r12 b00: 53 push %rbx ----------------------------- 下面判断本线程到底该不该执行初始化操作---------------------------- b01: 0f b6 05 38 16 20 00 movzbl 0x201638(%rip),%eax # 202140 <_ZGVZ3funvE13obj_does_init> #这是guard变量 b08: 84 c0 test %al,%al # 看看它是不是等于1 b0a: 0f 94 c0 sete %al b0d: 84 c0 test %al,%al b0f: 74 38 je b49 <_Z3funv+0x4f> # 如果等于1则跳过初始化 b11: 48 8d 3d 28 16 20 00 lea 0x201628(%rip),%rdi # 202140 <_ZGVZ3funvE13obj_does_init> #这是guard变量 b18: e8 b3 fe ff ff callq 9d0 <__cxa_guard_acquire@plt> # 为了线程安全,调用__cxa_guard_acquire b1d: 85 c0 test %eax,%eax b1f: 0f 95 c0 setne %al b22: 84 c0 test %al,%al b24: 74 23 je b49 <_Z3funv+0x4f> # 返回值为0,跳过初始化 --------------------------------下面进行局部静态变量的初始化-------------------------------------------- b26: 41 bc 00 00 00 00 mov $0x0,%r12d # 只有当guard变量为0,且获取了锁,且获取锁的阶段也没有发生初始化时 b2c: be 01 00 00 00 mov $0x1,%esi # 本线程才会进行局部静态对象的初始化 b31: 48 8d 3d 04 16 20 00 lea 0x201604(%rip),%rdi # 20213c <_ZZ3funvE13obj_does_init> b38: e8 ef 00 00 00 callq c2c <_ZN6WidgetC1Ei> # 调用Widget的构造函数! b3d: 48 8d 3d fc 15 20 00 lea 0x2015fc(%rip),%rdi # 202140 <_ZGVZ3funvE13obj_does_init> b44: e8 17 fe ff ff callq 960 <__cxa_guard_release@plt> ----------------------初始化操作已经完成,下面是fun的函数体代码,以及一些锁相关的操作--------------- ..... ...... bb4: 5b pop %rbx bb5: 41 5c pop %r12 bb7: 5d pop %rbp bb8: c3 retq

主要参考资料

C++类的static成员变量

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
class Something { public: int b_; Something() { } }; class Widget { public: static Something st ; static int a_; Widget() { cout << "Widget()\n"; } Widget(int a){ cout << "Widget(int a)\n"; } Widget(const Widget& other){ cout << "Widget(const Widget& other)\n"; } }; // static 成员变量只能在declearation之外定义 int Widget::a_ = 1; Something Widget::st = Something(); int main() { }

首先是语法方面,static 成员变量只能在类外定义,而且通常写在.cpp文件而不是.h文件。为什么?因为类的的定义常常写在头文件中,会被include进好多个目标文件中,如果static成员.h文件内定义,那么多个编译单元将出现相同的强符号,违反ODR(One Definition Rule)。

老办法,使用nm指令查看目标文件:

copy
  • 1
  • 2
0000000000201010 D Widget::a_ 0000000000201134 B Widget::st

不出意料地,基本变量存放在data段且已经被初始化,对象则存放于bss段,且两个符号都是全局变量。

而初始化的时机与全局变量是相同的,都是在main函数执行前,由库函数做初始化。具体分析就不展开了,与之前的方法相似。

const变量/对象的存储位置

const局部变量

const局部基础变量和自定义变量都存储在栈上

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
struct diy_class{ int a; int b; diy_class(int a, int b ) : a(a), b(b){ } }; int main() { int b = 1; // 这个肯定在栈上 const int a = 10; // 比较a b两个变量的地址,看看a在哪里 printf("address a = %p, address b = %p\n", &a, &b); const diy_class dd(1,2); printf("address of diy_class = %p \n", &dd); // address a = 0x7ffd6926e44c, address b = 0x7ffd6926e448 // address of diy_class = 0x7ffd6926e450 }

对比3个变量的地址, 可知b在上。或者你也可以用GDB用 info locals 查看栈上的变量:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
(gdb) # 打断点在printf("address a = %p, address b = %p\n", &a, &b);处 (gdb) info locals b = 1 a = 10 dd = {a = -8016, b = 32767} # 这个栈变量还没有被初始化

const全局变量

再定义一个const全局基础变量,打印其地址

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
const int global_const_inited = 1; // 存储于只读常量区 int main() { int b = 1; // 这个肯定在栈上 const int a = 10; // 比较a b两个变量的地址,看看a在哪里 printf("address a = %p, address b = %p\n", &a, &b); const diy_class dd(1,2); printf("address of diy_class = %p \n", &dd); // address a = 0x7ffd6926e44c, address b = 0x7ffd6926e448 // address of diy_class = 0x7ffd6926e450 printf("address of global_const_inited = %p\n", &global_const_inited); // address of global_const_inited = 0x560d0df107f8 }

可以看到全局常量的地址明显不在栈上,那在哪? -- 常量数据区,可以用nm命令查看符号表验证:

copy
  • 1
  • 2
$ nm const_storage_cpp | c++filt | grep global_const 00000000000007f8 r global_const_inited

其变量名前的符号为r,表示该变量存储在只读常量区。

接下来看看自定义变量:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
const int global_const_inited = 1; // 只读常量区 const diy_class global_const_diy(1,2); int main() { int b = 1; // 这个肯定在栈上 const int a = 10; // 比较a b两个变量的地址,看看a在哪里 printf("address a = %p, address b = %p\n", &a, &b); const diy_class dd(1,2); printf("address of diy_class = %p \n", &dd); printf("address of global_const_inited = %p\n", &global_const_inited); printf("address of global_const_diy = %p\n", &global_const_diy); // address of global_const_inited = 0x558b9d1dc888 // address of global_const_diy = 0x558b9d3dd018 }

两个地址很相近,那么表示自定义对象的地址也在只读常量区吗? 我们使用nm命令验证以下:

copy
  • 1
  • 2
  • 3
$ nm const_storage_cpp | c++filt | grep global_const 0000000000201018 b global_const_diy 0000000000000888 r global_const_inited

发现并不是,对于只读自定义对象,存储在了BSS段。这与static自定义对象相同,它们都“存储”在了ELF文件的BSS段,并在main函数前完成初始化,详见我之前写的内容

不能修改const变量?

能修改const变量吗? --- 我们可以绕过编译器的限制,但是不能绕过操作系统的限制。要分情况看:

经过上文的探索,g++对const变量大致分为两种处理方式

  • 将变量存储在只读数据段
  • 将变量存储在栈和BSS段

操作系统在加载只读数据段时,会将该段设置为只读,无论进程以怎样的方式对它进行修改,都会触发缺页中断的读错误,操作系统在确定进程没有权限进行写时,会立刻向进程强制发送SIGV信号,然后进程就结束了。因此这种类型只读变量的不可变性是由操作系统和ELF格式决定的,无论如何都不能改变这种类型的只读变量。

然而BSS段和栈段是可读、可写的。只要我们通过了编译器的检查,我们可以使用某种方式在运行期对这种类型的只读变量进行修改。

具体可以看看下面的程序:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
struct diy_class{ int a; int b; diy_class(int a, int b ) : a(a), b(b){ } }; const int global_const_inited = 1; // 只读常量区 const diy_class global_const_diy(1,2); int main() { // 1. 编译器错误 ! // global_const_diy.a = 10; // 2. 绕过编译器,成功修改。C++种使用const_cast去除变量的只读属性 diy_class* cc = const_cast<diy_class*>(&global_const_diy) ; cc->a = 10; printf("global_const_diy.a = %d\n", global_const_diy.a); // 3. 逃过编译器的检查,但没能逃过操作系统的检查. Segmentation fault! int* ee = const_cast<int*>(&global_const_inited); *ee = 2; printf("global_const_inited = %d", global_const_inited); }

注意在C++中,使用const_cast去除变量的只读属性

常量折叠

常量折叠 - 知乎 (zhihu.com)

C语言中呢?

大体上说,C语言在基础变量上的行为与C++是一样的。

但对于自定义全局对象,C语言仍然会将它定义在只读数据段中

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
struct diy_class{ int a; int b; }; const int global_const_inited = 1; // 只读常量区 const struct diy_class global_const_diy= {1,2}; // 依然是只读常量区
copy
  • 1
  • 2
nm const_stotage | grep global_const_diy 00000000000007f8 R global_const_diy

所以,在C语言中,全局的自定义变量也是不能修改的:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
const int global_const_inited = 1; // 只读常量区 char* str = "sdasd"; struct diy_class{ int a; int b; }; const struct diy_class global_const_diy= {1,2}; int main() { // 只有局部const变量能够被修改 const int a = 1; int* aa = (int*)&a; *aa = 10; printf("a = %d\n", a); // 局部struct也能修改 const struct diy_class local_const_diy = {1,2}; // local_const_diy.a = 2; struct diy_class* local_const_diy_aa = (struct diy_class* )&local_const_diy; local_const_diy_aa->a = 10; printf("local_const_diy.a = %d\n", local_const_diy.a); // 全局struct就不能修改了, 同样segmentation fault struct diy_class* global_const_diy_aa = (struct diy_class* )&global_const_diy; global_const_diy_aa->a = 10; printf("global_const_diy.a = %d\n", global_const_diy.a); }

输出如下,看到前两个变量成功绕过编译器检查修改成功:

copy
  • 1
  • 2
  • 3
a = 10 local_const_diy.a = 10 Segmentation fault (core dumped)

RVO与NRVO

RVO and NRVO (pvs-studio.com)

cppref

RVO和NRVO都是编译器的优化措施,以减少函数返回对象的不必要的拷贝和析构,区别在于:

  • RVO(Return Value Optimization) 针对的是匿名对象的优化
  • NRVO(Named Return Value Optimization)针对的具名对象的优化

发生RVO优化的两个条件是

  1. return后的表达式被evaluated成一个纯右值(prvalue, 除了std::move转换来的右值,其他的右值为纯右值,临时对象就是典型的纯右值)
  2. 函数签名类型与返回对象类型一致。

RVO演示代码如下:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
class A { public: A() { cout << "constructor\n"; } ~A() { cout << "destructor\n"; } }; A getARVO() { return A(); } int main() { A a = getA(); }

只会有一次构造函数以及以此析构函数,这对应的是main函数的a,而getARVO中的临时对象则不会调用构造和析构函数。

发生NRVO优化的两个条件与RVO类似,但是不要求return的是纯右值,它可以是一个左值,即一个具名对象

NRVO演示代码如下:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
A getANRVO() { A a; return a; } int main() { A a= getANRVO(); }

同样的只是发生一次构造和一次析构。

发生这两个优化的共同条件为,返回对象类型与函数签名类型一致。因此要慎用std::move

copy
  • 1
  • 2
  • 3
  • 4
A getANRVO() { A a; return std::move(a); }

编译器不会进行返回值优化,因为std::move作用后的变量类型转为右值,与函数签名不一致

RVO几乎是所有编译器都会默认开启的,但NRVO优化就看编译器的实现了。在linux平台,NRVO与RVO一样都是默认开启的,当然也可以手动关闭NRVO优化;但是VisualC++中,则不会默认开启NRVO,需手动调高编译器的优化等级。

多重继承与虚继承下的class layout

依然只是探讨在公有继承下的class layout

下面两个是我在学习C++对象模型时常用的工具:

g++命令行选项-fdump-class-hierarchy很有用,它能够生成一个文件描述类的布局和结构

copy
  • 1
$> g++ filename.cpp -fdump-class-hierarchy -o filename

objdump -d 能够反汇编可执行文件,有时是验证程序的最有效手段

多继承--无虚拟机制

有下面的类结构:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
class Base1 { public: int a; int b; }; class Base2 { public: int c; int d; }; class Derive : public Base1 , public Base2 { public: int e; int f; };

Derive类对象的底层数据布局为,先存放Base1的成员变量,再堆叠Base2的成员变量,最后是Derive变量的成员变量。图中一格为4字节,我个人的作图习惯是从图片上方表示高地址,而图片下方表示低地址。

image-20230227144153751

即时是这样简单的结构,编译器在背后也做了很多工作:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
int main() { Derive* d_ptr = new Derive(); Base2* b2_ptr = d_ptr; Base1* b1_ptr = d_ptr; printf("address of d_ptr = %p\n", d_ptr); printf("address of b1_ptr = %p\n", b1_ptr); printf("address of b2_ptr = %p\n", b2_ptr); // 指针调整 if (d_ptr == b2_ptr) { // 明明两个指针在数值上不相同,但从C++的语义上看,两个指针指向同一个对象,所以编译器还是进行指针调整。 printf("d_ptr == b2_ptr\n"); } if (d_ptr == b1_ptr) { printf("d_ptr == b1_ptr\n"); } }

输出如下:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
address of d_ptr = 0x560982eebe70 address of b1_ptr = 0x560982eebe70 address of b2_ptr = 0x560982eebe78 d_ptr == b2_ptr d_ptr == b1_ptr

请注意,b2_ptr与d_ptr的值在数值上是不相同的,但是if语句判断却为真。

那就说明即使是这样的简单赋值语句都有一些转换操作:

copy
  • 1
Base2* b2_ptr = d_ptr;

编译器默默添加了将d_ptr指针的值加上了8的代码,使得b2_ptr指向Base2Subobject的开头部分。

image-20230227145102193

但是当你比较d_ptr和b2_ptr时,从数值上看它们确实不相同,但是在C++语义中,这两个指针应该看作指向了同一个对象的指针。因此,当执行

copy
  • 1
if (d_ptr == b2_ptr)

编译器又默默添加了将b1_ptr指针加上8的代码,然后再进行比较,那当然是相同了。

可以使用objdump观察汇编代码,可以看到有两处:

copy
  • 1
addq $8, %rax

其中8为两个int的大小。编译器确实暗地里安插了一些代码。

多继承--虚函数

假设类结构与定义如下,Base1 和 Base2都定义了虚函数,且存在两个int成员变量

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
class Base1 { public: int a; int b; virtual void f1(){ cout << "Base1::f1 called\n"; } virtual void f2(){ cout << "Base1::f1 called\n"; } }; class Base2 { public: int c; int d; virtual void f2(){ cout << "Base2::f2 called\n"; } virtual void f3() { cout << "Base2::f3 called\n"; } virtual void f4() { cout << "Base2::f4 called\n"; } };

它们的虚函数表如下所示(一格为8字节),vtable[0]开始的地址为函数指针,指向内存的具体函数,vtable[-1]则是存放运行时类信息的指针,它是实现RTTI的底层数据结构,vtable[-2]则一种差值,表示子对象的起始地址距离“最派生对象”(Most Derived Object)的距离,在这里由于Base1和Base2没有涉及到多继承,因此它们的vtable[-2]都是0。

image-20230227165847371

子类继承Base1和Base2,并重写父类的函数,也定义了自己的虚函数。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
class Derive : public Base1 , public Base2 { public: int e; int f; virtual void f2(){ // override Base2 cout << "Derive::f2 called\n"; } virtual void f3() { // override Base2 cout << "Derive::f3 called\n"; } virtual void f5() { // Derive Defined cout << "Derive::f4 called\n"; } };

这种情况下,子类的内存中存在两个vptr,vptr1与Base1共用,vptr2则指向Base2的vtable。这样说的好像子类Derive有两张vtable一样,但其实不是,它只有一张vtable,vptr1和vpt2分别指向同一张vtable的不同位置,初步画一下Derive的vtable布局,并不完全正确:

image-20230227165705995

你可以使用g++ 的 -fdump-class-hierarchy编译选项验证Derive的vtable布局:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
Vtable for Derive Derive::_ZTV6Derive: 11 entries# derive的vtable在物理上是连续的,但是在逻辑上又可以分成两部分 0 (int (*)(...))0 # Most Derived Object Offest 8 (int (*)(...))(& _ZTI6Derive) # RTTI for Derive 16 (int (*)(...))Base1::f1 24 (int (*)(...))Derive::f2 32 (int (*)(...))Derive::f3 40 (int (*)(...))Derive::f5 48 (int (*)(...))-16 # Most Derived Object Offest 56 (int (*)(...))(& _ZTI6Derive) # RTTI for Derive 64 (int (*)(...))Derive::_ZThn16_N6Derive2f2Ev # what ? 72 (int (*)(...))Derive::_ZThn16_N6Derive2f3Ev # waht ? 80 (int (*)(...))Base2::f4

其中的Most Derived Object Offest就是上图的Δ,表示从基类到继承体系“最底下”的那一个子类的距离是多少,这个子类对象就是所谓的 “Most Deirved Object”(MOD)(最派生对象?)。Vtable总是由MOD控制,这与dynamic_cast的实现有关。

在sub vtable for Subobject Base2中,有两个不认识的符号_ZThn16_N6Derive2f2Ev,可以使用c++filt demangle这个符号:

copy
  • 1
  • 2
$> c++filt _ZThn16_N6Derive2f2Ev non-virtual thunk to Derive::f2()

关键词是thunk,它解决this指针的调整问题。啥意思呢?

比如new 了一个Derive对象,但把它赋值给Base2*类型的指针,由上一节可以知道编译器在暗地里将会改变b2_ptr的值,改变量记作Δ。如果调用b2_ptr->f2(),我们期望调用Derive重载的那个f2,即Derive::f2()。目前为止,按照我之前画的vtable,这个调用是没问题的,确实会调用到Derive::f2():

image-20230227165744491

我们知道成员函数的隐式参数为默认为this指针,问题就出在了this指针这里。虽然b2_ptr->f2();调用到了Derive::f2(),但是传入的this指针却指向的是Base2!

因此C++语言设计者门使用了一种叫做thunk的技术,在运行期改变this的指向,将指向子对象的指针调整为指向完整对象。

具体做法是:

  • 将子对象距离完整对象的距离Δ在编译期计算出来
  • 子对象的suubvtable中本该存放 f2、 f3指针的项,修改为指向另一个函数的指针,这个函数的作用是:将this 减去 Δ,并调用f2、f3

如下图所示,这才是本节示例的vtable示意图:

image-20230227165930396

_ZThn16_N6Derive2f2Ev的汇编代码如下:

copy
  • 1
  • 2
  • 3
0000000000000f70 <_ZThn16_N6Derive2f3Ev>: # non-virtual thunk to Derive::f2() f70: 48 83 ef 10 sub $0x10,%rdi # Δ是个立即数,它的值在编译器就会被算出来 f74: eb d8 jmp f4e <_ZN6Derive2f3Ev> # 调用Derive::f2()

多继承--虚继承

虚继承解决的问题

假设有如下的继承体系

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
class BasedBase { public: int bb_a; int bb_b; }; class Base1 : public BasedBase{ public: int a; int b; }; class Base2 : public BasedBase{ public: int c; int d; }; class Derive : public Base1 , public Base2 { public: int e; int f; };

这样的继承结构有二义性的问题,因为Derive对象内部存在2份BasedBase对象:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
int main() { Derive d; d.bb_a = 1; // compiler error d.bb_b = 1; // compiler error }

根本不能通过编译,报错:

copy
  • 1
Non-static member 'bb_a' found in multiple base-class subobjects of type 'BasedBase':

通过引入虚继承,则可以解决程序二义性问题:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
class BasedBase { public: int bb_a; int bb_b; }; class Base1 :virtual public BasedBase{ // 加上virtual public: int a; int b; }; class Base2 :virtual public BasedBase{// 加上virtual public: int c; int d; }; class Derive : public Base1 , public Base2 {// 不需要virtual public: int e; int f; };

这样程序便能完成编译了,解决了二义性问题

copy
  • 1
  • 2
  • 3
  • 4
  • 5
int main() { Derive d; d.bb_a = 1; d.bb_b = 1; }

虚继承下的class layout

额外的参考资料:

虚继承实在是过于“implementation dependent”了,查了很多资料都有些许不同,尤其是Visualc++的实现方式与g++的实现方式差的有点多。

这里我只分析下g++的实现方式,也谈不上“分析”,只是粗浅地看一下虚继承下是怎样进行成员定位的。其上的顶层设计,比如为什么将偏移值放在vtable,至于为什么又引入了virtual table table(VTT),实在是功力不足,没法子。

首先,为了能够在GDB中方便观察对象的布局,在main函数中为d对象赋上几个比较显眼的值。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
int main() { Derive* derive = new Derive(); derive->a = 1; derive->b = 2; derive->c = 3; derive->d = 4; derive->f = 6; // 为bb_a赋值 derive->bb_a = 0xff; // 为bb_b赋值 derive->bb_b = 0xfe; }

使用GDB进行调试,首先使用p sizeof(*derive)查看对象大小,为48,然后用x/命令查看对象内存的内容:

image-20230227220528991

可以看到在对象偏移的0处,是一个指针指向Derive的vtable的某一项,在对象偏移的16字节处又有一一个指针,它指向一个叫做VTT的结构(它参与了Derive对象的构造过程,详见本节上方的额外参考),实际上它指向的还是subvtalbe,只不过VTT与Vatble在物理上连续了而已。而虚基类的两个成员变量则在对象的最高处偏移处,其值分别为0xff 0xfe。而且VTT与Derive的vatable在内存中是连续的,相邻的,这样一个指针能同时获取两张表的信息。

再使用g++的-fdump-class-hierarchy选项查看Derive类的vtable,可以看到又额外的两个偏移值40、24,表示this指针与虚基类的距离。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
Vtable for Derive Derive::_ZTV6Derive: 6 entries 0 40 # 与virtualbase的距离 8 (int (*)(...))0 # MOD Offset 16 (int (*)(...))(& _ZTI6Derive) # RTTI 24 24 # 与virtualbase的距离 32 (int (*)(...))-16 # MOD Offset 40 (int (*)(...))(& _ZTI6Derive) # RTTI

因为没有定义虚函数,所以Vtable中没有虚函数指针。此外,从这里看出来,在g++中,不止虚函数的实现依赖于虚函数表,而且虚基类的实现也依赖于虚函数表,因此不能下“没定义虚函数就不会有虚函数表”这样的结论。

试着画出了Derive类的布局图:

image-20230403222204608

可以总结,虚函表中的偏移量都是this指针相对于某个地址的距离,要么是this指针到MOD的距离,要么是this指针到VirtualBase成员变量的距离。

上面的cpp程序中,我们使用Derive指针存取VirtualBase的成员变量时:

copy
  • 1
  • 2
Derive* derive = new Derive(); derive->bb_a = 0xff;

this指针指向的是对象的开头,那么就依靠第1个vptr去取得存放于Vtable的偏移量。

如果使用子类的指针去存取VirtualBase的成员变量时:

copy
  • 1
  • 2
Base2* b2_ptr = derive; // 编译器自动进行this指针调整 b2_ptr->bb_a = 0xee;

此时的this指针指向Derive对象起始地址 + 16的地方,此时需要依靠第2个vptr去取得存放于Vtable的偏移量。

空口无凭,看一下反汇编代码吧,主要是其中的main函数

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
0000000000000a4a <main>: ########################################### 一些栈帧操作 ################################### ... ########################################### new derive 对象 ################################### a53: bf 30 00 00 00 mov $0x30,%edi # 48字节为Derive对象的大小 a58: e8 b3 fe ff ff callq 910 <_Znwm@plt> # 调用new .... ##################################### derive对象的普通成员变量的存取 ################################### a97: 48 89 5d e0 mov %rbx,-0x20(%rbp) # -0x20(%rbp)存derive的值,指向Derive对象 a9b: 48 8b 45 e0 mov -0x20(%rbp),%rax a9f: c7 40 08 01 00 00 00 movl $0x1,0x8(%rax) aa6: 48 8b 45 e0 mov -0x20(%rbp),%rax aaa: c7 40 0c 02 00 00 00 movl $0x2,0xc(%rax) ab1: 48 8b 45 e0 mov -0x20(%rbp),%rax ab5: c7 40 18 03 00 00 00 movl $0x3,0x18(%rax) abc: 48 8b 45 e0 mov -0x20(%rbp),%rax ac0: c7 40 1c 04 00 00 00 movl $0x4,0x1c(%rax) ac7: 48 8b 45 e0 mov -0x20(%rbp),%rax acb: c7 40 20 05 00 00 00 movl $0x5,0x20(%rax) ad2: 48 8b 45 e0 mov -0x20(%rbp),%rax ad6: c7 40 24 06 00 00 00 movl $0x6,0x24(%rax) ##################################### derive对象的虚基类的变量的存取 ################################### add: 48 8b 45 e0 mov -0x20(%rbp),%rax # 为derive->bb_a赋值, 取得this指针后再去vtable中去偏移量,然后相加 ae1: 48 8b 00 mov (%rax),%rax ae4: 48 83 e8 18 sub $0x18,%rax ae8: 48 8b 00 mov (%rax),%rax aeb: 48 89 c2 mov %rax,%rdx aee: 48 8b 45 e0 mov -0x20(%rbp),%rax af2: 48 01 d0 add %rdx,%rax # this指针加上偏移量 af5: c7 00 ff 00 00 00 movl $0xff,(%rax) afb: 48 8b 45 e0 mov -0x20(%rbp),%rax # 为derive->bb_b赋值 aff: 48 8b 00 mov (%rax),%rax b02: 48 83 e8 18 sub $0x18,%rax b06: 48 8b 00 mov (%rax),%rax b09: 48 89 c2 mov %rax,%rdx b0c: 48 8b 45 e0 mov -0x20(%rbp),%rax b10: 48 01 d0 add %rdx,%rax # this指针加上偏移量 b13: c7 40 04 fe 00 00 00 movl $0xfe,0x4(%rax) #####################################执行 Base2 b2_ptr = derive,调整this指针 ################################### b1a: be 30 00 00 00 mov $0x30,%esi b1f: 48 8d 3d cf 01 00 00 lea 0x1cf(%rip),%rdi # cf5 <_ZStL19piecewise_construct+0x1> b26: b8 00 00 00 00 mov $0x0,%eax b2b: e8 c0 fd ff ff callq 8f0 <printf@plt> b30: 48 83 7d e0 00 cmpq $0x0,-0x20(%rbp) b35: 74 0a je b41 <main+0xf7> b37: 48 8b 45 e0 mov -0x20(%rbp),%rax b3b: 48 83 c0 10 add $0x10,%rax # 将derive指针上移16字节指向subobject Base2 b3f: eb 05 jmp b46 <main+0xfc> b41: b8 00 00 00 00 mov $0x0,%eax ##################################### derive对象的虚基类的变量的存取 ################################### b46: 48 89 45 e8 mov %rax,-0x18(%rbp) # -0x18(%rbp)中存b2_ptr的值,指向Derive对象的Base2子对象 b4a: 48 8b 45 e8 mov -0x18(%rbp),%rax b4e: 48 8b 00 mov (%rax),%rax b51: 48 83 e8 18 sub $0x18,%rax # 减了0x18后,rax = vtable + 24, 我也不知道为什么 b55: 48 8b 00 mov (%rax),%rax # vtable + 24存储一个值为24 b58: 48 89 c2 mov %rax,%rdx # rdx存偏移值24 b5b: 48 8b 45 e8 mov -0x18(%rbp),%rax b5f: 48 01 d0 add %rdx,%rax # b2_ptr + 24,正好指向 int bb_b 成员变量 b62: c7 00 ee 00 00 00 movl $0xee,(%rax) ##################################### main函数退出 ################################### .....

观察普通成员变量与虚基类成员变量的存取,发现存取虚基类的成员变量所生成的代码明显更多,因为它需要根据vptr找到vtable,然后再从vtable中获取偏移量,最后this指针加上偏移量的值才是虚基类的成员函数。

dynamic_cast

关于dynamic_cast原理,看到了几个优质的资源:

第一个视频中Arthur O'Dwyer实现了自己的dynamic_cast, 虽然没有仿照C++源码的图搜索算法,但是他把dynamic_cast该做什么,什么时候才是真正的dynamic_cast等相关话题讲得很清楚。

第二个资料则是分析C++源码的dynamic_cast实现。

本文作者:别杀那头猪

本文链接:https://www.cnblogs.com/HeyLUMouMou/p/17304385.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   别杀那头猪  阅读(185)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
💬
评论
📌
收藏
💗
关注
👍
推荐
🚀
回顶
收起