CPP对象模型
C++对象模型
主要参考资料:
- 【1】《深入理解C++对象模型》,这本书的内容值得学习,但是没有讲解常用的现代编译器的做法,只给一个理论框架。而且知识点散乱,难以整理和回顾。你可以继续看看【5】这本书
- 【2】学习使用GDB来观察C++对象模型
- 【3】使用其他GNU工具学习对象模型
- 【4】 effective C++
- 【5】《C++新经典 对象模型》,大部分代码使用VisioC++编译器进行讲解,也会穿插g++编译器的实现
- 【6】《Itanium C++ ABI》,晦涩难懂,但能提供一些线索
- 【7】[CppCon的BackToBasics系列](Back to Basics: Class Layout - Stephen Dewhurst - CppCon 2020 - YouTube)
对象模型的底层细节视编译器的不同而不同,下面的实验全部默认使用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的内存空间。
下面图示了三个对象在内存中的布局:
可以看到不论有多少层继承层次,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处的内存打印出来:
a指针的一个前一个字节就是存放int a这个变量,为1,可见A对象没有vptr。b指针的前8个字节则是vptr,而后8字节则是int b和padding内存,因此对象b继承A的成员函数,再加上自己的成员函数后,会把vptr放在整个对象的顶端。
怎么证明0x555555754d88(我在x86平台实验,小端)就是vptr呢?首先,将0x555555754d88地址处的内容打印出来:
B对象只有一个虚函数,即它的虚构函数,因此推测0x5555555548a8是析构函数的地址。怎么验证呢?我们只要观察汇编语言在main函数退出时,析构B时对析构函数的调用地址就可以了(使用gdb的layout asm):
可以看到确实调用了0x5555555548a8,因此这个地址就是虚析构函数的地址,而指向这块内容的0x555555754d88确实就是vptr。
总结
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则是虚函数表的起始地址。
简单说,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生成了两个符号。
而他们位于elf文件的只读程序段:
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++完成编译的那一刻就已经确定了:
可以使用objdump查看B的构造函数:
可以看到,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的部分:
还有Widget_Derive部分:
发现其结构与设想的差不多。值得注意的有两个地方:
- 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函数的栈帧示意图,希望能帮助你理解上面的汇编程序:
注意,x64架构在寄存器够用的情况下,使用寄存器进行函数传参,否则改为用栈传。32为机器则全部通过栈来传递参数。参考《深入理解计算机体系》的内容(中文版,原书第三版,P168),第一个参数可以存放在rdi,第二个参数存在rsi寄存器
,....,通过寄存器一共可以传递6个参数,从第七个参数开始,都会通过栈来传递。
是的,栈上有两个Widget指针,使用GDB验证:
本人才刚刚入了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;
}
使用GDB观察在prinf语句之后可以看到四个成员变量都是随机值,一个都没有被设置为默认值。
但是,在堆上创建的对象的成员变量确实是初始化了的,是谁将其初始化的?操作系统吗?还是编译器?答案是编译器,它会额外安插代码实现将堆上对象的成员变量初始化。但注意这些初始化代码没有安插在构造函数中,因此“默认构造函数不会初始化成员变量”还是正确的。
copy
- 1
- 2
- 3
- 4
- 5
int main()
{
Widget* a = new Widget();
return 0;
}
编译这段代码并使用objdump -d 反编译,查看其main函数:
可以看到编译器在调用构造函数之前对其成员变量进行了初始化
。
但用同样的方法查看在栈上的对象,则根本没有初始化的步骤
copy
- 1
- 2
- 3
- 4
- 5
int main()
{
Widget a;
return 0;
}
构造函数与初始化列表
4中情况下必须使用初始化列表:
- 初始化一个reference member时
- 初始化一个 const member时
- 调用一个base class 的constructor,且它拥有一组参数时
- 调用一个member class 的constructor,且它拥有一组参数时
有两个注意点,还是用实例说明:
-
初始化顺序与成员变量的声明顺序有关,哪个在前就先被初始化
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; } };
-
就初始化列表而言,编译器自动安插的代码,永远在用户声明的代码之前
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()调用的:
符合想象,就是通过vptr到函数地址的那套理论方法。
再来看看Widget_Derived的构造函数是怎样调用fun的:
可以看到,编译器是直接调用了Widget_Drived::fun()函数,根本就没有从vtable中获取函数地址的这个步骤。
同样的,Widget的构造函数也是直接调用了Widget::fun()函数
总结:从语法上讲,构造函数调用虚函数是没有问题的,是能够通过编译的。但是从实际效果上,无法实现虚函数的作用
。编译器不会在构造函数中使用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:
- class 内有member object
- class 继承自一个base class
- class 声明一个或多个virtual function
- 涉及虚继承
至于拷贝赋值函数,与拷贝构造函数表现出相似的行为。在不需要编译器合成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
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
extern "C" {
void* memset(void*, int, size_t);
}
如果使用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()
处打个断点:、
可以看到6个对象的成员变量都是0,也就是说操作系统确实为这些对象分配了内存,且初始化为了0,但此时还没有调用构造函数。
然后在main()
处打断点运行,此时所有的cout语句都已经输出,且6个对象都已正确被初始化:
也就是说在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锁
- 但是为了线程安全,必须在这里设置一把锁,只有取得锁的线程才能进行初始化。获取锁的操作为__cxa_guard_acquire,线程会一直等待,直到获取这把锁
使用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
主要参考资料
- 深入理解函数内静态局部变量初始化 - william-cheung - 博客园 (cnblogs.com)
- 《C++新经典 对象模型》6.4节
- 《Itanium C++ ABI》2.8节
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去除变量的只读属性
常量折叠
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和NRVO都是编译器的优化措施,以减少函数返回对象的不必要的拷贝和析构,区别在于:
- RVO(Return Value Optimization) 针对的是匿名对象的优化
- NRVO(Named Return Value Optimization)针对的具名对象的优化
发生RVO优化的两个条件是
- return后的表达式被evaluated成一个纯右值(prvalue, 除了std::move转换来的右值,其他的右值为纯右值,临时对象就是典型的纯右值)
- 函数签名类型与返回对象类型一致。
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字节,我个人的作图习惯是从图片上方表示高地址,而图片下方表示低地址。
即时是这样简单的结构,编译器在背后也做了很多工作:
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的开头部分。
但是当你比较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。
子类继承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布局,并不完全正确:
你可以使用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():
我们知道成员函数的隐式参数为默认为this指针,问题就出在了this指针这里
。虽然b2_ptr->f2();调用到了Derive::f2(),但是传入的this指针却指向的是Base2!
因此C++语言设计者门使用了一种叫做thunk的技术,在运行期改变this的指向,将指向子对象的指针调整为指向完整对象。
具体做法是:
- 将子对象距离完整对象的距离Δ在编译期计算出来
- 子对象的suubvtable中本该存放 f2、 f3指针的项,修改为指向另一个函数的指针,这个函数的作用是:将this 减去 Δ,并调用f2、f3
如下图所示,这才是本节示例的vtable示意图:
_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/命令查看对象内存的内容:
可以看到在对象偏移的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类的布局图:
可以总结,虚函表中的偏移量都是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原理,看到了几个优质的资源:
- CppCon 2017: Arthur O'Dwyer “dynamic_cast From Scratch” - YouTube
- [演讲PPT PDF版](https://github.com/CppCon/CppCon2017/blob/master/Presentations/dynamic_cast From Scratch/dynamic_cast From Scratch - Arthur O'Dwyer - CppCon 2017.pdf)
- dynamic_cast 的实现方法分析以及性能优化 – Lancern's Blog
第一个视频中Arthur O'Dwyer实现了自己的dynamic_cast, 虽然没有仿照C++源码的图搜索算法,但是他把dynamic_cast该做什么,什么时候才是真正的dynamic_cast等相关话题讲得很清楚。
第二个资料则是分析C++源码的dynamic_cast实现。
本文作者:别杀那头猪
本文链接:https://www.cnblogs.com/HeyLUMouMou/p/17304385.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步