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在对象中有较高的地址”.

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

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指针。

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
}

普通成员函数的存储位置

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++的符号名称,以便我们观察)可以清楚看到编译器对成员函数的命名:

nm  可执行文件名 | c++filt 
......
000000000000093e W Widget::fun1()
0000000000000912 W Widget::Widget()
0000000000000912 W Widget::Widget()

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

当编译器看见:

a.fun1();

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

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

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

 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只会在对象最底端。

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个虚函数。

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的函数在这个数组中“注册”。

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

Widget *p;
...
p->h(); 

那么它被编译器转化为:

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

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

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

vtable的生成时机和存储位置

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

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

简单举个例子:

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在什么时候被赋值?

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

下面看一个简单的例子:

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

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

输出为:

vtable[0] = 0x557d206e5b66
vtable[1] = 0x557d206e5b9e
vtable[2] = 0x557d206e5bd6
Widget::h()
Widget::i()
Widget::j()

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

Widget* d = new Widget();       
long * vptr = (long *)d;  

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

long * vtable = (long *)(*vptr); 

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

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

long long* vptr = (long long *)d;         
long long * vtable = (long long *)(*vptr);  

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

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

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说,看上去它的成员函数的声明为:

void h();
void i();
void j();

但是实际的声明却是:

void h(Widget*);
void i(Widget*);
void j(Widget*);

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

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

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

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

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,就像上面代码块那样:

// 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指针 就可以了:

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成员函数

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 命令生成反编译文件:

$> g++ callVfunc.cpp  -o callVfunc
$> objdump -d callVfunc > callVfunc.objdump  //

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

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的其他成员的初始化由程序员负责,编译器不会初始它们。看个例子:

    class Foo {
    public:
        Foo() { // 显示定义了默认构造函数
            ....
        }
    }
    class Bar {
    public:
        Foo foo; // member object
        char* str; // 非对象成员
    }
    

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

    int main() {
        Bar bar;
    }
    

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

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

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

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

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

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

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

    简单验证,使用nm即可

    class Bar {
    
    };
    int main() {
        Bar a;  
        return 0;
    }
    

    使用nm命令查看符号表:

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

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

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

    class Bar {
    public:
        Foo foo; // member object
    
    };
    

    再使用nm命令则看到:

    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的对象,发现它们确实没有被初始化。

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语句之后可以看到四个成员变量都是随机值,一个都没有被设置为默认值。

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

int main()
{
	Widget* a = new Widget();
	return 0;
}

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

image-20230218215530933

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

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

int main()
{
	Widget a;
	return 0;
}

image-20230218215758489

构造函数与初始化列表

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

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

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

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

    class  A{ 
    private :
        int a_;
        int b_;
     public :
        A(int c, int d):  b_(d),a_(c) {
    			
        }
    };
    

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

    会变成下面这样:

    class  A{ 
    private :
        int a_;
        int b_;
     public :
        A(int c, int d):{
            // 编译器自动安插的代码
    		a_ = d;
             b_ = c;
        }
    };
    
  2. 就初始化列表而言,编译器自动安插的代码,永远在用户声明的代码之前

    class  A{ 
    private :
        int a_;
        int b_;
     public :
        A(int c):  b_(c) {
    		a_ = 0;
        }
    };
    

    会转化成:

    class  A{ 
    private :
        int a_;
        int b_;
     public :
        A(int c) {
             // 编译器根据初始化列表安插的代码一定在user code前面
             b_ = c;
            // user code
    		a_ = 0;
        }
    };
    

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

初始化列表的性能优势

构造函数有两个阶段:

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

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

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

输出如下:

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

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

Member2 default constructor 

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

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的设置在整个对象的初始化阶段会被不断地赋予新值。编译器为了不出现未定义行为,编译器不会在构造函数中使用常用的虚函数调用方法。(具体看看下面的例子)

比如有下面的继承体系:

// 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();
}

输出为:

Widget fun
Widget Cstor
Widget_Drived fun
Widget_Drived Cstor

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

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

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& 修饰?

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

class A {
public:
    int a_ = 1;
    A(int a):a_(a) {

    }
    A(A other) { // 编译器报错

    }
};

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

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

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对象,

A getA() {
    return A(2);
}   

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

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修饰符即可。

析构函数

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

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

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

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

输出为:

Base
Derive : new char(11)
~Derive : delete member
~Base

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

~Derive() { 
    delete [] member;
    cout << "~Derive : delete member\n";
    Base::~Base(); // 编译器安插的代码
}

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

Base* b = new Derive();
delete b;

输出为:

Base
Derive : new char(11)
~Base

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

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

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关键字,那么同样会发生内存泄漏:

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

输出为:

Base
Derive : new char(11)
~Derive : delete member
~Base
---------------------
Base
Derive : new char(11)
~Base                 # 没有调用到~Derive(), 发生了内存泄漏。

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

 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指针指向的对象,编译器这样调用它的析构函数:

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

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

析构函数内调用虚函数

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

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

验证程序:

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

输出:

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函数,头文件通常这么处理:

#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)。

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

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处理可执行文件:

...
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语言相同,但是对于自定义类,编译器处理时有着不同的行为。

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段中。

...
0000000000201134 B aaa
0000000000201138 B aab
000000000020113c B aac
...
 b aad
000000000020114c b aae
0000000000201150 b aaf

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

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

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段。

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语言语义下的局部静态变量

void fun() {
    static int int_not_init;
    static int int_does_init = 1;
}

int main() {
    fun();
    return 0;
}

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

0000000000201010 d int_does_init.1795
0000000000201018 b int_not_init.1794

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

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段

000000000020213c b fun()::obj_not_init
0000000000202148 b fun()::obj_does_init

如何初始化

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

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

输出为:

obj_does_init.a_ = 2
obj_does_init.a_ = 3

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

(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()中被初始化的,那么编译器如何保证局部静态变量只被初始化一次呢?换句话说,编译器如何能够跳过:

static Widget obj_does_init = Widget(1);

而直接执行以下代码?

obj_does_init.a_ += 1;
cout << "obj_does_init.a_ = " << obj_does_init.a_ <<endl;

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

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

    使用nm命令查看:

    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函数

$>objdump -d local_static_object >  local_static_object.objdump
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成员变量

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指令查看目标文件:

0000000000201010 D Widget::a_
0000000000201134 B Widget::st

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

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

const变量/对象的存储位置

const局部变量

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

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 查看栈上的变量:

(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全局基础变量,打印其地址

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命令查看符号表验证:

$ nm const_storage_cpp | c++filt | grep global_const
00000000000007f8 r global_const_inited

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

接下来看看自定义变量:

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命令验证以下:

$ 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段和栈段是可读、可写的。只要我们通过了编译器的检查,我们可以使用某种方式在运行期对这种类型的只读变量进行修改。

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

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语言仍然会将它定义在只读数据段中

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

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

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

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

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演示代码如下:

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演示代码如下:

A getANRVO() {
    A a;
    return a;
}
int main() {
    A a= getANRVO();
}

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

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

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很有用,它能够生成一个文件描述类的布局和结构

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

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

多继承--无虚拟机制

有下面的类结构:

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

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

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

输出如下:

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语句判断却为真。

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

Base2* b2_ptr = d_ptr;

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

image-20230227145102193

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

if (d_ptr == b2_ptr) 

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

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

addq $8, %rax

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

多继承--虚函数

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

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,并重写父类的函数,也定义了自己的虚函数。

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布局:

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这个符号:

$> 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的汇编代码如下:

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()

多继承--虚继承

虚继承解决的问题

假设有如下的继承体系

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对象:

int main() {
    Derive d;
    d.bb_a = 1; // compiler error
    d.bb_b = 1; // compiler error
}

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

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

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

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

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

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对象赋上几个比较显眼的值。

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指针与虚基类的距离。

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的成员变量时:

Derive* derive = new Derive();    
derive->bb_a = 0xff;

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

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

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

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

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

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实现。

posted @ 2023-04-10 21:30  别杀那头猪  阅读(134)  评论(0编辑  收藏  举报