C++ 对象模型

最近在看《深度探索 C++ 对象模型》一书,收获颇丰。如果你对 C++ 底层机制感兴趣、想知道编译器对我们的代码动了什么“手脚”,推荐阅读该书。

本文不打算整理或复述《深度探索 C++ 对象模型》一书的内容,因为这本书需要花费一定的时间心力阅读,一篇文章恐难覆盖全书内容。因此,本文仅展示部分代码及运行结果,加以必要的注释、解释等,以阐明 C++ 对象模型的部分知识点。代码在 64 位 WSL(Ubuntu 22.04)系统运行,使用 g++ 编译器。系统及编译器信息展示如下:

1
2
3
4
5
6
7
$ uname -a
Linux arcsin2-pc 5.15.133.1-microsoft-standard-WSL2 #1 SMP Thu Oct 5 21:02:42 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
$ g++ --version
g++ (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0
Copyright (C) 2021 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

由于部分代码涉及到裸指针操作和强制类型转换等,且 C++ 对象模型在不同编译器中的实现存在区别,本文代码并不保证跨平台运行,也不保证输出结果确定。本文的分析和解释仅针对上述系统和编译器。

0. 多态与虚函数表

在开始之前,实现要对 C++ 多态实现机制:虚函数表有了解。下面代码展示了如何通过强制类型转换,通过 vptr 访问虚函数表从而调用虚函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

class Object {
public:
virtual void f() { cout << "f called" << endl; }

virtual void g() { cout << "g called" << endl; }

virtual void h() { cout << "h called" << endl; }
};

int main() {
Object obj;
auto vptr = *reinterpret_cast<void (***)(Object *)>(&obj);
vptr[0](&obj);
vptr[1](&obj);
vptr[2](&obj);
return 0;
}

代码运行结果如下:

1
2
3
4
$ g++ virtual_function.cpp && ./a.out 
f called
g called
h called

通过 vptr 访问虚函数表的不同 slot 即可实现调用不同的虚函数。

1. new 与 malloc 的区别

首先给出代码及运行结果:

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
#include <iostream>
using namespace std;

class Base {
public:
virtual void show() { cout << "Base" << endl; }

virtual ~Base() = default;

// private:
int data_;
};

class Derive : public Base {
public:
void show() override { cout << "Derive" << endl; }

// private:
int age_;
};

int main() {
cout << sizeof(Base) << endl;
cout << sizeof(Derive) << endl;

Base b;
b.show();

Derive d;
d.show();

cout << &d << "\t" << &d.data_ << "\t" << &d.age_ << endl;
cout << &b << "\t" << &b.data_ << endl;

Derive *p = reinterpret_cast<Derive *>(malloc(sizeof(Derive)));
auto pd = reinterpret_cast<double *>(p);
*pd = *reinterpret_cast<double *>(&d);
p->show(); // Derive

*pd = *reinterpret_cast<double *>(&b);
p->show(); // Base

p = new (pd) Derive; // placement operator new
p->show(); // Derive

auto ptr = new Derive();
ptr->show(); // Derive
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
$ g++ new_and_malloc.cpp && ./a.out 
16
16
Base
Derive
0x7ffcdc4153b0 0x7ffcdc4153b8 0x7ffcdc4153bc
0x7ffcdc4153a0 0x7ffcdc4153a8
Derive
Base
Derive
Derive

这段代码旨在说明面试中的一个常见问题:newmalloc 有什么区别?当然答案其实很简单,就是前者不仅分配内存,而且调用了构造函数;而后者仅进行内存分配。我们结合代码分析。

在代码第 35 行,通过 malloc 在堆内存申请足够空间以放置 Derive 类的对象。然后通过指针操作,将分配的内存区域的 vptr ,即:虚函数表指针分布赋予 Base 类和 Derive 类的虚函数表地址,于是代码 38 行和 41 行分别输出 BaseDerive,实现了手动指定 vptr 指针以调用不同的虚函数。这对理解 C++ 多态的实现机制:虚函数表有一定帮助。

代码 43 行,通过一个 placement operator new,调用了 Derive 类的构造函数,从而代码 44 行输出 Derive

代码 4647 行则通过 new 来构造对象,编译器负责帮我们分配内存即指定 vptr ,我们能够输出 Derive

2. C++ class 与 C struct 的转换

本小节通过代码来直观感受:如何用 C 语言提供的机制来实现 C++ 的语义和运行时特征。代码如下:

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
63
64
65
66
67
68
69
70
71
72
73
#include <iostream>
using namespace std;

// C++ object
class X {
public:
X() = default;

virtual ~X() = default;

virtual void foo() { cout << "foo" << endl; }

X(const X &x) = default;
};

X foobar() {
X xx;
X *px = new X{};

xx.foo();
px->foo();

delete px;
return xx;
}

// C struct
struct c_X {
int **vptr;
};

void c_X_destructor(c_X *ptr) {}

void c_X_foo(c_X *ptr) { cout << "c_X_foo" << endl; }

int *virtual_function_table[] = {
nullptr, // type info struct (not used here)
(int *)c_X_destructor, // virtual destructor
(int *)c_X_foo // virtual function foo
};

void c_X_constructor(c_X *ptr) { ptr->vptr = virtual_function_table; }

void c_X_copy(c_X *ptr, c_X src) { ptr->vptr = src.vptr; }

void foobar(c_X &_result) {
c_X_constructor(&_result);

c_X *px = (c_X *)malloc(sizeof(c_X));
if (px != nullptr) {
c_X_constructor(px);
}

c_X_foo(&_result); // call foo directly

((void (*)(c_X *))(px->vptr[2]))(px); // call foo by vptr

if (px != nullptr) {
((void (*)(c_X *))(px->vptr[1]))(px); // call destructor by vptr
free(px);
}

return; // NRVO
}

int main() {
cout << "C++ style: " << endl;
foobar();

cout << "\nC style: " << endl;
c_X obj;
foobar(obj);
}

运行结果:

1
2
3
4
5
6
7
8
$ g++ object_model.cpp && ./a.out 
C++ style:
foo
foo

C style:
c_X_foo
c_X_foo

上述代码展示了如何用 C 语言机制实现构造函数、析构函数、拷贝构造函数等。其中的关键是虚函数表以及结构体内指向虚函数表的 vptr 指针。同时,转换的 C 语言代码还应用了编译器常用的优化策略:NRVO(Named Return Value Optimization),即:在函数参数内添加一个参数以传递返回值,从而消除拷贝构造函数的调用,提高程序性能。

3. 成员初始化顺序

C++ 构造函数中的初始化列表的书写顺序并不代表成员的实际初始化顺序。C++ 标准规定,类成员的初始化顺序和声明顺序相同,而和构造函数的初始化列表无关。下面的代码展示了可能的错误和正确的写法:

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
#include <iostream>
using namespace std;

class X {
public:
explicit X(int val) : j(val), i(j) {} // 错误 按成员声明顺序依次初始化 应改为: i(val), j(val)

void show() const { cout << "i = " << i << " j = " << j << endl; }

private:
int i;
int j;
};

class Y {
public:
explicit Y(int val) : i(val), j(i) {}

void show() const { cout << "i = " << i << " j = " << j << endl; }

private:
int i;
int j;
};

int main() {
X x{10};
x.show(); // i = random , j = 10

Y y{10};
y.show(); // i = 10 , j = 10
return 0;
}

运行结果如下(多次运行以说明类 X 的数据成员 i 并没有被正确初始化):

1
2
3
4
5
6
7
8
9
$ g++ init_order.cpp && ./a.out 
i = 32624 j = 10
i = 10 j = 10
$ g++ init_order.cpp && ./a.out
i = 32757 j = 10
i = 10 j = 10
$ g++ init_order.cpp && ./a.out
i = 32637 j = 10
i = 10 j = 10

正确的做法是:在初始化列表内按照成员的声明顺序依次初始化,不可随意改变顺序。

4. 类成员指针

本小节仅为说明一个很少用的用法,即:如何通过指针获取类成员函数并通过类成员函数指针进行函数调用。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;

class Object {
public:
void func() { cout << "func called with value = " << value << endl; }

void func2(int add) { cout << "func called with value + add = " << value + add << endl; }

int value{10};
};

int main() {
Object obj;
auto func_ptr = &Object::func;
(obj.*func_ptr)();

obj.value = 1;
auto func_ptr2 = reinterpret_cast<void (*)(Object *, int)>(&Object::func2); // warning
func_ptr2(&obj, 1);
return 0;
}

运行结果如下(编译器会发出警告,下列运行结果保留编译器输出信息):

1
2
3
4
5
6
7
$ g++ member_pointer.cpp && ./a.out 
member_pointer.cpp: In function ‘int main()’:
member_pointer.cpp:19:76: warning: converting from ‘void (Object::*)(int)’ to ‘void (*)(Object*, int)’ [-Wpmf-conversions]
19 | auto func_ptr2 = reinterpret_cast<void (*)(Object *, int)>(&Object::func2); // warning
| ^
func called with value = 10
func called with value + add = 2

代码 1516 行展示了成员函数指针的用法;1920 行展示了如何通过强制类型转换和 this 指针进行类成员函数调用。可以看出,程序运行结果与我们预期相同。不过,编译器对 19 行代码发出了警告,实际项目中不可使用此方式编码。

5. 虚拟继承的对象布局

虚拟继承是避免菱形继承中子类含有多个基类对象的方法。本小节解释虚拟继承是如何实现的?其对对象的内存布局会产生什么影响?见以下代码:

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
63
64
65
66
67
68
69
#include <iostream>
using namespace std;

class Point2d {
public:
float _x{1.0f};
float _y{2.0f};
};

/**
* offset : member
* 0 : vptr
* 8 : next
* 16 : _x
* 20 : _y
*/
class Vertex : public virtual Point2d {
public:
Vertex *next; // 虚基类的数据成员 _x _y 在 Vertex 类尾部
};

/**
* offset : member
* 0 : vptr
* 8 : _z
* 12 : _x
* 16 : _y
*/
class Point3d : public virtual Point2d {
public:
float _z; // 虚基类的数据成员 _x _y 在 Vertex 类尾部
};

/**
* offset : member
* 0 : vptr
* 8 : next // class Vertex
* 16 : vptr
* 24 : _z // class Point2d
* 28 : mumble // data member of class Vertex3d
* 32 : _x
* 36 : _y // class Point2d
*/
class Vertex3d : public Vertex, public Point3d {
public:
float mumble;
};

int main() {
cout << sizeof(Point2d) << endl;
cout << sizeof(Vertex) << endl;
cout << sizeof(Point3d) << endl;
cout << sizeof(Vertex3d) << endl;

Point2d p2d;
cout << &p2d << " " << &p2d._x << " " << &p2d._y << endl;

Vertex ver;
cout << &ver << " " << &ver._x << " " << &ver._y << " " << &ver.next << endl;

Point3d p3d;
cout << &p3d << " " << &p3d._x << " " << &p3d._y << " " << &p3d._z << endl;

Vertex3d ver3d;
cout << &ver3d << " " << &ver3d._x << " " << &ver3d._y << " " << &ver3d._z
<< " " << &ver3d.next << " " << &ver3d.mumble << endl;

return 0;
}

运行结果如下:

1
2
3
4
5
6
7
8
9
$ g++ virtual_inheritance.cpp && ./a.out 
8
24
24
40
0x7ffce4dad0d8 0x7ffce4dad0d8 0x7ffce4dad0dc
0x7ffce4dad0e0 0x7ffce4dad0f0 0x7ffce4dad0f4 0x7ffce4dad0e8
0x7ffce4dad100 0x7ffce4dad10c 0x7ffce4dad110 0x7ffce4dad108
0x7ffce4dad120 0x7ffce4dad140 0x7ffce4dad144 0x7ffce4dad138 0x7ffce4dad128 0x7ffce4dad13c

这段代码的输出结果并不那么重要,关键是代码中每个类前面的注释,注释展示了对象的内存布局。以菱形继承的最低层子类 Vertex3d 为例,由于其直接基类 VertexPoint3d 都是虚拟继承自类 Point2d ,因此,其内存布局和普通的继承有很大区别。注释阐明了一种可能实现方式的内存布局,vptr 指针在这里不仅仅指向虚函数表,也需要在某个 slot 内指明虚基类 Point2d 的地址与 this 指针的偏移量。这样,就能够在运行时通过该偏移量动态访问到虚拟基类,并调用正确的函数。

6. 构造函数析构函数与虚函数

假如我们在构造函数中调用一个虚函数,会发生什么呢?调用的究竟是基类的实现还是子类改写的实现呢?以下面代码为例:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#include <iostream>
using namespace std;

class Point2d {
double x_, y_;

public:
virtual ~Point2d() {
cout << "this = " << this << "\t";
cout << "Point2d size = " << size() << endl;
}

virtual int size() { return sizeof(Point2d); }

/**
* Constructor 内调用的 virtual 函数并不具备多态行为,而是调用当前类的函数
*/
Point2d() {
cout << "this = " << this << "\t";
cout << "Point2d size = " << size() << endl;
}
};

class Point3d : public virtual Point2d {
double y_;

public:
~Point3d() {
cout << "this = " << this << "\t";
cout << "Point3d size = " << size() << endl;
}

int size() override { return sizeof(Point3d); }

Point3d() {
cout << "this = " << this << "\t";
cout << "Point3d size = " << size() << endl;
}
};

class Vertex : public virtual Point2d {
public:
~Vertex() {
cout << "this = " << this << "\t";
cout << "Vertex size = " << size() << endl;
}

int size() override { return sizeof(Vertex); }

Vertex() {
cout << "this = " << this << "\t";
cout << "Vertex size = " << size() << endl;
}
};

class Vertex3d : public Point3d, public Vertex {
public:
~Vertex3d() {
cout << "this = " << this << "\t";
cout << "Vertex3d size = " << size() << endl;
}

int size() override { return sizeof(Vertex3d); }

Vertex3d() {
cout << "this = " << this << "\t";
cout << "Vertex3d size = " << size() << endl;
}
};

class PVertex3d : public Vertex3d {
double d_;

public:
~PVertex3d() {
cout << "this = " << this << "\t";
cout << "PVertex3d size = " << size() << endl;
}

int size() override { return sizeof(PVertex3d); }

PVertex3d() {
cout << "this = " << this << "\t";
cout << "PVertex3d size = " << size() << endl;
}
};

int main() {
cout << sizeof(Point2d) << endl;
cout << sizeof(Point3d) << endl;
cout << sizeof(Vertex) << endl;
cout << sizeof(Vertex3d) << endl;
cout << sizeof(PVertex3d) << endl;

{
Vertex3d obj;
cout << "Destructor:" << endl;
}

cout << "------------------------------------" << endl;

{
PVertex3d pv3d;
cout << "Destructor:" << endl;
}
return 0;
}

程序运行结果如下:

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
$ g++ virtual_func_in_constructor.cpp && ./a.out 
24
40
32
48
56
this = 0x7ffd70141fa8 Point2d size = 24
this = 0x7ffd70141f90 Point3d size = 40
this = 0x7ffd70141fa0 Vertex size = 32
this = 0x7ffd70141f90 Vertex3d size = 48
Destructor:
this = 0x7ffd70141f90 Vertex3d size = 48
this = 0x7ffd70141fa0 Vertex size = 32
this = 0x7ffd70141f90 Point3d size = 40
this = 0x7ffd70141fa8 Point2d size = 24
------------------------------------
this = 0x7ffd70141fb0 Point2d size = 24
this = 0x7ffd70141f90 Point3d size = 40
this = 0x7ffd70141fa0 Vertex size = 32
this = 0x7ffd70141f90 Vertex3d size = 48
this = 0x7ffd70141f90 PVertex3d size = 56
Destructor:
this = 0x7ffd70141f90 PVertex3d size = 56
this = 0x7ffd70141f90 Vertex3d size = 48
this = 0x7ffd70141fa0 Vertex size = 32
this = 0x7ffd70141f90 Point3d size = 40
this = 0x7ffd70141fb0 Point2d size = 24

输出展示了构造函数与析构函数的调用顺序与继承关系的关联。同时,运行结果也说明了:构造函数内调用的 virtual 函数并不具备多态行为,而是调用当前类的实现。这是因为 C++ 语言在进行当前类的构造前,首先把 vptr 指针指向当前类的虚函数表。在指向基类的构造函数时,vptr 指向基类的虚函数表,调用的自然就是基类的实现。只有当子类的构造函数全部完成(所有基类和成员的构造函数此时必定已执行完成),vptr 指向子类的虚函数表,子类也就展现出属于子类的多态行为。析构函数中调用虚函数有类似的效果。

7. 模板实例化与静态变量

首先看代码:

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
#include <iostream>
using namespace std;

template <class T> void func(T t) {
static int count = 0;
cout << "count = " << count << endl;
++count;
}

class Base {
public:
static int count;
};

template <class T> class Derive : public Base {
public:
void show() {
cout << "count = " << count << endl;
++count;
}
};

int Base::count = 0;

int main() {
func(1); // 0
func(1.0); // 0
func(2); // 1
func(1.0f); // 0
cout << "-------------" << endl;
Derive<int> di;
di.show(); // 0
Derive<double> dd;
dd.show(); // 1
Derive<char> dc;
dc.show(); // 2
return 0;
}

运行结果如下:

1
2
3
4
5
6
7
8
9
$ g++ template_function.cpp && ./a.out 
count = 0
count = 0
count = 1
count = 0
-------------
count = 0
count = 1
count = 2

这段代码说明了一个结论:模板的不同实例内的静态变量是不同的,如代码 2629 行的输出所示。如果想让某个模板类的所有类型实例共享一个静态变量,应该按照代码 1021 行实现,即:在基类中声明一个静态变量,模板类继承自该基类。这样即可实现模板的所有类型实例都共享一个静态变量。

8. 总结

本文通过代码和运行结果辅以适当解释说明了 C++ 对象模型的部分关键知识点。全文内容基于《深度探索 C++ 对象模型》一书,推荐想进一步了解 C++ 对象模型的读者阅读本书。


C++ 对象模型
https://arcsin2.cloud/2024/02/19/C-对象模型/
作者
arcsin2
发布于
2024年2月19日
许可协议