0%

C++ 继承的底层设计与原理

C++ 作为面向对象语言,其次面向对象语言的重要特性封装、继承、多态,所以理解继承底层设计对于我们学习C++是非常重要的,其次他是C++的灵魂所在,本人也是走了些弯路所以打算深度学习一下!

环境

  1. GCC 8.3.0(本人的运行环境)
1
2
3
4
5
# 查看class的结构信息(Dump class hierarchy information
g++ -std=c++17 -O0 -fdump-lang-class main.cpp

# 编译
g++ -std=c++17 -O0 main.cpp -o main
  1. Clang 13.0.0
1
2
# 查看class的结构信息
clang++ -std=c++17 -O0 -c main.cpp -Xclang -fdump-vtable-layouts

例子

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
#include <iostream>

// 子类
struct Base {
virtual void test(int x) {
std::cout << "Base int x=" << x << std::endl;
}
void test(double x) {
std::cout << "Base double x=" << x << std::endl;
}
};

// 派生类
struct Derived : Base {
void test(int x) {
std::cout << "Derived int x=" << x << std::endl;
}
void test(double x) {
std::cout << "Derived double x=" << x << std::endl;
}
};

void testRef(Base& b) {
b.test(1);
b.test(1.1);
}

void test(Base b) {
b.test(1);
b.test(1.1);
}

/**
基类:Base
派生类:Derived

虚函数:允许在派生类中被重新定义,支持所谓的运行时多态(late binding)。只有当函数被声明为 virtual,在派生类中覆盖同名函数时才构成真正的多态。这时,如果通过基类指针或引用调用该函数,将调用实际对象类型的那个版本。

非虚函数:如果派生类中重新定义了非虚的基类函数(即使函数名和参数列表与基类相同),实际上是隐藏了基类版本的函数。这种场合下,如果派生类对象被当作基类类型处理(比如通过基类指针或引用调用),将调用基类的函数;而如果直接以派生类类型来调用,则使用派生类的函数。
*/
int main() {
Base* b = new Derived();
b->test(1); // Derived int x=1 (虚函数)
b->test(1.1); // Base double x=1.1 (非虚函数)

testRef(*b);
// Derived int x=1
// Base double x=1.1

test(*b);
// Base int x=1
// Base double x=1.1
return 0;
}

虚表

C++的虚函数底层实现上采用的都是虚表(virtual table),虚表中会把虚函数真实的函数地址记录下来,方便函数调用直接使用,因此虚函数的性能会略差一下因为多了一次寻址! 注意: 这个只是大部分编译器的实现,例如GCC!

例如下面例子TestA定义了虚函数foo1,那么TestA会生成一个虚函数表,其中定义了 foo1 指向 TestA::foo1 , 其中TestB继承自A它会继承A的虚函数表,如果重写会覆盖重写的函数,下面这个例子B的虚函数表中定义了一个 foo1 指向 TestA::foo1 ,TestC继承了TestB,重写了foo1方法,因此TestC的虚表中变成了TestC::foo1

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

struct TestA {
virtual void foo1() {
std::cout << "TestA.foo1\n";
}
};

struct TestB : virtual TestA {};

struct TestC : virtual TestB {
void foo1() {
std::cout << "TestC.foo1\n";
}
};

int main() {
TestA *ab = new TestB();
ab->foo1(); // 会查找TestB虚表中foo1函数地址(代码段地址),发现foo1为TestA::foo1,最终执行输出TestA.foo1

TestA *ac = new TestC();
ac->foo1(); // 会查找TestC虚表中foo1函数地址(代码段地址),发现foo1为TestC::foo1,最终执行输出TestC.foo1
}

虚表是C++元信息的体现,它记录了虚函数的函数地址,但是C++本质上并不会为runtime阶段提供类型的元信息,例如类型的字段、函数信息等,所以C++不支持反射。但是c++是一个直接面向内存的语言,只要拿到了内存什么语法限制(private?const?)都不存在了。

虚函数

虚函数是继承的核心,派生类允许重写父类的虚函数,进而实现多态!

代码示例: https://godbolt.org/z/TEEsYra7f 或者 https://coliru.stacked-crooked.com/a/9895523dd69257b6

单继承

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
#include <iostream>

struct TestA {
virtual void foo1() {
std::cout << "TestA.foo1\n";
}
virtual void foo2() {
std::cout << "TestA.foo2\n";
}
void foo3() {
std::cout << "TestA.foo3\n";
}
long arr[3];
};

struct TestB : TestA {
virtual void foo1() {
std::cout << "TestB.foo1\n";
}
virtual void foo3() {
std::cout << "TestB.foo3\n";
}
long arr[3];
};

struct TestC : TestB {
virtual void foo4() {
std::cout << "TestC.foo4\n";
}
};

// 理解这个需要理解 虚函数继承的实现方式和类对象的内存结构
struct TestAMemory {
struct TestATable {
void (*foo1)(); // 指向 TestA.foo1 函数 (offset=16)
void (*foo2)(); // 指向 TestA.foo2 函数
};
TestATable *vptr;
long arr[3];
};

// 普通继承的内存结构
// 优先定义基类,再定义派生类
struct TestBMemory {
struct TestBTable {
void (*foo1)(); // 指向 TestB.foo1 函数 (offset=16)
void (*foo2)(); // 指向 TestA.foo2 函数 (这里可以看到虚表会拷贝基类全部的虚函数,不管是否重写)
void (*foo3)(); // 指向 TestB.foo3 函数 (添加自己的虚函数)
};
TestBTable *vptr;
// TestA.arr
long arra[3];
// TestB.arr
long arrb[3];
};

int main() {
TestA *a = new TestB();
a->foo1(); // TestB.foo1
a->foo2(); // TestA.foo2
a->foo3(); // TestA.foo3

((TestB *)a)->foo1(); // TestB.foo1
((TestB *)a)->foo2(); // TestA.foo2
((TestB *)a)->foo3(); // TestB.foo3

// TestB的内存结构,这个参考就行了 ... (实际上TestB成员函数地址拿不到的,因为他是编译信息.)
TestBMemory *bb = (TestBMemory *)(a);
bb->vptr->foo1(); // TestB.foo1
bb->vptr->foo2(); // TestA.foo2
bb->vptr->foo3(); // TestB.foo3

// 修改成员变量
bb->arra[0] = 111;
bb->arrb[0] = 222;

// 当基类和派生类定义类相同的字段/函数,那么主要是看当前指针的类型是什么,类型是基类那么就是基类的函数,否则派生类的函数
std::cout << "(TestA)arr[0]: " << a->arr[0] << "\n"; // (TestA)arr[0]: 111
std::cout << "(TestB)arr[0]: " << ((TestB *)a)->arr[0] << "\n"; // (TestB)arr[0]: 222

// 同上
TestAMemory *aa = (TestAMemory *)(new TestA());
aa->vptr->foo1(); // TestA.foo1
aa->vptr->foo2(); // TestA.foo2
}

那么具体是如何实现的呢? 可以通过 g++ -O0 -fdump-lang-class main.cpp dump 类信息

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
Vtable for TestA
TestA::_ZTV5TestA: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI5TestA)
16 (int (*)(...))TestA::foo1
24 (int (*)(...))TestA::foo2

Class TestA
size=32 align=8 // TestA size = 32 = 8(vptr:TestA) + 24 (arr)
base size=32 base align=8
TestA (0x0x7fdde31d2960) 0
vptr=((& TestA::_ZTV5TestA) + 16) // 表示vtpr的指向,指向 (TestA::_ZTV5TestA addr +16), 即TestA::foo1函数地址.

Vtable for TestB
TestB::_ZTV5TestB: 5 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI5TestB)
16 (int (*)(...))TestB::foo1 // 重写基类虚函数
24 (int (*)(...))TestA::foo2 // 这里注意下. 会把未重写基类的copy过来
32 (int (*)(...))TestB::foo3 // 添加自己定义的虚函数

Class TestB
size=56 align=8
base size=56 base align=8 // size = 56 = 8(vptr:TestB) + 24(a.arr) + 24(b.arr)
TestB (0x0x7fdde322d068) 0
vptr=((& TestB::_ZTV5TestB) + 16) // 同上
TestA (0x0x7fdde31d2de0) 0
primary-for TestB (0x0x7fdde322d068) // primary-for TestB 表示继承关系,多继承需要看这个字段


Vtable for TestC
TestC::_ZTV5TestC: 6 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI5TestC)
16 (int (*)(...))TestB::foo1
24 (int (*)(...))TestA::foo2
32 (int (*)(...))TestB::foo3
40 (int (*)(...))TestC::foo4 // 可以看到直接copy的基类的全部虚函数+自己定义的虚函数

Class TestC // c -> b -> a
size=56 align=8 // size = 8(vptr:TestC) + 24(a.arr) + 24(b.arr)
base size=56 base align=8
TestC (0x0x7f639bfee680) 0
vptr=((& TestC::_ZTV5TestC) + 16)
TestB (0x0x7f639bfee6e8) 0
primary-for TestC (0x0x7f639bfee680)
TestA (0x0x7f639bfd7780) 0
primary-for TestB (0x0x7f639bfee6e8)

总结:

  1. 可以发现当定义了虚函数那么此时会生成一个虚函数表,虚函表记录了虚函数的函数地址,例如 TestA 内部会定义一个 vptr 指向 Vtable for TestA + 16 ,即 (int (*)(...))TestA::foo1 函数开始

  2. TestB 继承了 TestA,TestB内部也定义了一个 vptr 指向 Vtable for TestB , 定义了其申明的虚函数

  3. TestC 继承 TestB ,此时也只会有一份vptr指向 vtable testc

  4. 单继承仅会有一个 vptr ,指向其自己的 vtable

多继承

C++是支持多继承的,这个也是与Java等语言的区别(C++不支持接口Interface),其次多继承会比较复杂,比如涉及到交叉/菱形继承的问题,我们可以看下C++是符合实现多继承的!

继续上面那个例子,我们新增一个结构体, D 继承自A/B/C

1
2
3
4
5
6
7
8
9
10
struct TestD : TestA, TestB, TestC {
void foo1() {
std::cout << "TestD.foo1\n";
}
};

// 继承关系/多继承/菱形继承
// D -> A
// -> B -> A
// -> C -> B -> A

此时虚表为如下:

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
Vtable for TestD
TestD::_ZTV5TestD: 15 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI5TestD)
16 (int (*)(...))TestD::foo1 // vptr(TestD/TestA)
24 (int (*)(...))TestA::foo2
32 (int (*)(...))-32
40 (int (*)(...))(& _ZTI5TestD)
48 (int (*)(...))TestD::_ZThn32_N5TestD4foo1Ev // vptr(TestB) TestD::foo1
56 (int (*)(...))TestA::foo2
64 (int (*)(...))TestB::foo3
72 (int (*)(...))-88
80 (int (*)(...))(& _ZTI5TestD)
88 (int (*)(...))TestD::_ZThn88_N5TestD4foo1Ev //vptr(TestC) TestD::foo1
96 (int (*)(...))TestA::foo2
104 (int (*)(...))TestB::foo3
112 (int (*)(...))TestC::foo4

Class TestD
size=144 align=8 // size = 144 = vptr(TestD) + a.arr + vptr(TestB) + (a.arr,b.arr) + vptr(TestC) + (a.arr, b.arr)
base size=144 base align=8
TestD (0x0x7efe8ac38d98) 0 // vptr(TestD)偏移量为0
vptr=((& TestD::_ZTV5TestD) + 16)
TestA (0x0x7efe8acbe840) 0 // d::a内存
primary-for TestD (0x0x7efe8ac38d98)
TestB (0x0x7efe8acd5750) 32 // vptr(TestB)偏移量32
vptr=((& TestD::_ZTV5TestD) + 48)
TestA (0x0x7efe8acbe8a0) 32 // b::a 内存
primary-for TestB (0x0x7efe8acd5750)
TestC (0x0x7efe8acd57b8) 88 // vptr(TestC)偏移量88
vptr=((& TestD::_ZTV5TestD) + 88)
TestB (0x0x7efe8acd5820) 88 // c::b 内存
primary-for TestC (0x0x7efe8acd57b8)
TestA (0x0x7efe8acbe900) 88 // c::a 内存
primary-for TestB (0x0x7efe8acd5820)
  1. TestD 交叉继承造成结构的大小升级到了 144 ,导致 A冗余了2份,B 冗余了1份 ,是不是发现问题了,这么继承的话遇到重复继承基类导致内存会成倍的增加,怎么解决呢,下文会介绍到!
  2. 多继承会为每个基类分配一个 vptr 指针!
    1. vptr(TestD) 偏移量 0
    2. vptr(TestB) 偏移量 32
    3. vptr(TestC) 偏移量 88
  3. 多继承当涉及到类型转换的时候(向上/向下)类型转换的时候会涉及到指针的移动(下文会降到),具体的移动偏移量可以参考上面的class dump,向下转型需要使用 dynamic_cast ! 但是上面的例子多类型转换的时候会存在二义性,例如D向上换成A,会发现A内存中有3份到低是哪个,所以编译器不会让你转换,但是我们可以通过内存进行非安全转换!!
  4. 可以结合下面这个代码看下
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
typedef void (*VoidFunc)();

VoidFunc GetVoidFunc(void *ptr, int offset) {
long *pptr = (long *)ptr;
long *table = (long *)(*pptr);
long *func = table + offset;
return (VoidFunc)(*func);
}

// struct TestD : TestA, TestB, TestC {}
// TestD = vptr(TestA) + a.arr + vptr(TestB) + (a.arr,b.arr) + vptr(TestC) + (a.arr, b.arr) = 144
int main() {
// 多继承vptr会有偏移量
TestD d;

// vptr偏移量0 (TestD)
GetVoidFunc(&d, 0)(); // TestD::foo1
GetVoidFunc(&d, 1)(); // TestA::foo2

// vptr偏移量32(基类为TestB)
GetVoidFunc(((long *)&d) + 4, 0)(); // TestD.foo1
GetVoidFunc(((long *)&d) + 4, 1)(); // TestA.foo2
GetVoidFunc(((long *)&d) + 4, 2)(); // TestB.foo3

// vptr偏移量88(基类为TestC)
GetVoidFunc(((long *)&d) + 11, 0)(); // TestD.foo1
GetVoidFunc(((long *)&d) + 11, 1)(); // TestA.foo2
GetVoidFunc(((long *)&d) + 11, 2)(); // TestB.foo3
GetVoidFunc(((long *)&d) + 11, 3)(); // TestC.foo4
}

虚继承

代码示例: https://godbolt.org/z/rxeza5EEa 或者 https://coliru.stacked-crooked.com/a/44776e393808d238

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
#include <iostream>

struct TestA {
virtual void foo1() {
std::cout << "TestA.foo1\n";
}
virtual void foo2() {
std::cout << "TestA.foo2\n";
}
void foo3() {
std::cout << "TestA.foo3\n";
}
long arr[3];
};

struct TestB : virtual TestA {
virtual void foo1() {
std::cout << "TestB.foo1\n";
}
void foo2() {
std::cout << "TestB.foo2\n";
}
virtual void foo3() {
std::cout << "TestB.foo3\n";
}
long arr[3];
};

struct TestC : virtual TestB {
virtual void foo4() {
std::cout << "TestC.foo4\n";
}
};

struct TestD : virtual TestB {
virtual void foo4() {
std::cout << "TestC.foo4\n";
}
};

// TestE 继承关系
// -> C
// -> -> B
// E -> A
// -> -> B
// -> D
struct TestE : virtual TestC, virtual TestD {
void foo1() {
std::cout << "TestD.foo1\n";
}
};

typedef void (*VoidFunc)();

VoidFunc GetVoidFunc(void *ptr, int offset) {
long *pptr = (long *)ptr;
long *table = (long *)(*pptr);
long *func = table + offset;
return (VoidFunc)(*func);
}

VoidFunc GetVoidFunc(void *ptr, int size, int offset) {
long *pptr = ((long *)ptr) + size; // 指针偏移
long *table = (long *)(*pptr);
long *func = table + offset; // vtable 偏移
return (VoidFunc)(*func);
}
// https://stackoverflow.com/questions/6258559/what-is-the-vtt-for-a-class
// 72
int main() {
// (TestC)vptr + (TestB)vptr + 24 + (TestA)vptr + 24
TestC *c = new TestC();

GetVoidFunc(c, 0, 0)(); // TestC.foo4

GetVoidFunc(c, 1, 0)(); // TestB::foo1
GetVoidFunc(c, 1, 1)(); // TestB::foo2
GetVoidFunc(c, 1, 2)(); // TestB::foo3

GetVoidFunc(c, 5, 0)(); // TestB.foo1
GetVoidFunc(c, 5, 1)(); // TestB.foo2

// 堆是从低地址到高地址
// 强制类型转换会移动函数指针
TestB *b = c;
// 偏移量为8: 1个字节 = vptr(TestC)
std::cout << ((long)b - (long)c) << std::endl;

TestA *a = c;
// 偏移量为40: 5个字节 = vptr(TestC) + vptr(TestB) + 24
std::cout << ((long)a - (long)c) << std::endl;

TestE *d = new TestE(); // vptr(TestC) + vptr(TestB) + 24 + vptr(TestA) + 24 + vptr(TestD)
GetVoidFunc(d, 9, 0)(); // TestD::foo4

std::cout << "size: " << sizeof(TestA) << "\n"; // 32 = vptr(TestA) + 24
std::cout << "size: " << sizeof(TestB) << "\n"; // 64 = vptr(TestB) + 24 + vptr(TestA) + 24
std::cout << "size: " << sizeof(TestC) << "\n"; // 72 = vptr(TestC) + vptr(TestB) + 24 + vptr(TestA) + 24
std::cout << "size: " << sizeof(TestD) << "\n"; // 72 = vptr(TestD) + vptr(TestB) + 24 + vptr(TestA) + 24
std::cout << "size: " << sizeof(TestE) << "\n"; // 80 = vptr(TestE) + vptr(TestB) + 24 + vptr(TestA) + 24 + vptr(TestD)
}
  1. 虚继承后内存仅需 72,只需要维护基类的vptr 和 基类分配的内存即可,所以虚继承可以极大的降低内存开销
  2. 虚继承后内存中有且仅有一份基类的内存(包含多层引用),具体的内存逻辑图可以通过 dump class查看
  3. 多继承当进行强制类型转换时会通过移动指针实现,具体可以看下面例子,但是其实还有一些case,比如TestETestE会和TestC的地址一样,原因很简单就是两者在virtual table中函数申明都一样,所以没必要再分配一份内存了(这个属于GCC的优化吧)!
1
2
3
4
5
6
7
8
9
10
11
12
int main(){
TestE *ee = new TestE(); // vptr(TestE/TestC) + vptr(TestB) + 24 + vptr(TestA) + 24 + vptr(TestD)
TestC *cc = ee;
TestD *dd = ee;
TestB *bb = ee;
TestA *aa = ee;
std::cout << std::hex << ee << "\n"; // 0x600002ef80f0 (offset=0)
std::cout << std::hex << cc << "\n"; // 0x600002ef80f0 (offset=0)
std::cout << std::hex << bb << "\n"; // 0x600002ef80f8 (offset=8)
std::cout << std::hex << aa << "\n"; // 0x600002ef8118 (offset=40)
std::cout << std::hex << dd << "\n"; // 0x600002ef8138 (offset=72)
}
  1. 虚继承表如下图所示, 这里以 TestC 为例子
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
# TestB    
Vtable for TestB
TestB::_ZTV5TestB: 12 entries
0 32
8 (int (*)(...))0
16 (int (*)(...))(& _ZTI5TestB)
24 (int (*)(...))TestB::foo1
32 (int (*)(...))TestB::foo2
40 (int (*)(...))TestB::foo3
48 18446744073709551584
56 18446744073709551584
64 (int (*)(...))-32
72 (int (*)(...))(& _ZTI5TestB)
80 (int (*)(...))TestB::_ZTv0_n24_N5TestB4foo1Ev
88 (int (*)(...))TestB::_ZTv0_n32_N5TestB4foo2Ev

VTT for TestB
TestB::_ZTT5TestB: 2 entries
0 ((& TestB::_ZTV5TestB) + 24)
8 ((& TestB::_ZTV5TestB) + 80)

Class TestB
size=64 align=8
base size=32 base align=8
TestB (0x0x7f779a7cc618) 0
vptridx=0 vptr=((& TestB::_ZTV5TestB) + 24)
TestA (0x0x7f779a7b5660) 32 virtual
vptridx=8 vbaseoffset=-24 vptr=((& TestB::_ZTV5TestB) + 80)

## TestC

Vtable for TestC
TestC::_ZTV5TestC: 20 entries
0 40
8 8
16 (int (*)(...))0
24 (int (*)(...))(& _ZTI5TestC)
32 (int (*)(...))TestC::foo4
40 0
48 0
56 0
64 32
72 (int (*)(...))-8
80 (int (*)(...))(& _ZTI5TestC)
88 (int (*)(...))TestB::foo1
96 (int (*)(...))TestB::foo2
104 (int (*)(...))TestB::foo3
112 18446744073709551584
120 18446744073709551584
128 (int (*)(...))-40
136 (int (*)(...))(& _ZTI5TestC)
144 (int (*)(...))TestB::_ZTv0_n24_N5TestB4foo1Ev
152 (int (*)(...))TestB::_ZTv0_n32_N5TestB4foo2Ev

Construction vtable for TestB in TestC
TestC::_ZTC5TestC8_5TestB: 12 entries
0 32
8 (int (*)(...))0
16 (int (*)(...))(& _ZTI5TestB)
24 (int (*)(...))TestB::foo1
32 (int (*)(...))TestB::foo2
40 (int (*)(...))TestB::foo3
48 18446744073709551584
56 18446744073709551584
64 (int (*)(...))-32
72 (int (*)(...))(& _ZTI5TestB)
80 (int (*)(...))TestB::_ZTv0_n24_N5TestB4foo1Ev
88 (int (*)(...))TestB::_ZTv0_n32_N5TestB4foo2Ev

VTT for TestC
TestC::_ZTT5TestC: 5 entries
0 ((& TestC::_ZTV5TestC) + 32)
8 ((& TestC::_ZTV5TestC) + 88)
16 ((& TestC::_ZTV5TestC) + 144)
24 ((& TestC::_ZTC5TestC8_5TestB) + 24)
32 ((& TestC::_ZTC5TestC8_5TestB) + 80)

Class TestC
size=72 align=8 // vptr(TestC) + vptr(TestB) + b.arr + vptr(TestA) + a.arr
base size=8 base align=8
TestC (0x0x7f779a7cc750) 0 nearly-empty ## offset=0 (TestC vptr) -> 32
vptridx=0 vptr=((& TestC::_ZTV5TestC) + 32)
TestB (0x0x7f779a7cc7b8) 8 virtual ## offset=8 (TestB vptr) -> 88
subvttidx=24 vptridx=8 vbaseoffset=-24 vptr=((& TestC::_ZTV5TestC) + 88)
TestA (0x0x7f779a7b57e0) 40 virtual ## offset=40 (TestA vptr) -> 144
vptridx=16 vbaseoffset=-32 vptr=((& TestC::_ZTV5TestC) + 144)

# TestE
Class TestE
size=80 align=8 # 80 = vptr(TestE/TestC) + vptr(TestB) + 24 + vptr(TestA) + 24 + vptr(TestD)
base size=8 base align=8
TestE (0x0x7f9b2d91d1c0) 0 nearly-empty
vptridx=0 vptr=((& TestE::_ZTV5TestE) + 56)
TestC (0x0x7f9b2d91c9c0) 0 nearly-empty virtual
primary-for TestE (0x0x7f9b2d91d1c0)
subvttidx=40 vptridx=8 vbaseoffset=-48
TestB (0x0x7f9b2d91ca28) 8 virtual
subvttidx=64 vptridx=16 vbaseoffset=-24 vptr=((& TestE::_ZTV5TestE) + 120)
TestA (0x0x7f9b2d905960) 40 virtual
vptridx=24 vbaseoffset=-32 vptr=((& TestE::_ZTV5TestE) + 176)
TestD (0x0x7f9b2d91ca90) 72 nearly-empty virtual
subvttidx=80 vptridx=32 vbaseoffset=-56 vptr=((& TestE::_ZTV5TestE) + 232)
TestB (0x0x7f9b2d91ca28) alternative-path

析构函数

上面聊到了继承,但是没有聊到内存回收,我们知道不论是虚继承、普通继承他的内存分配机制大家上面应该是有所了解了,但是对于内存回收没谈到,C++作为一个非GC语言需要手动回收。析构函数使用虚函数完美的解决了内存回收,那么具体怎么使用呢?

注意:抽象类一定要为把析构函数定义为虚函数,否则系统不会回收!

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
#include <iostream>
#include <memory>

struct TestA {
virtual ~TestA() {
std::cout << "~TestA()\n";
}
};

struct TestB : virtual TestA {
~TestB() override {
std::cout << "~TestB()\n";
}
};

struct TestC : virtual TestA {
~TestC() override {
std::cout << "~TestC()\n";
}
};

struct TestD : virtual TestB, virtual TestC {
~TestD() override {
std::cout << "~TestD()\n";
}
};


int main() {
// 手动基类删除,这时候如果析构函数不是虚函数就会存在问题
TestA *a = new TestD();
delete a;

// 这种case不需要析构函数是虚函数,回收d的时候会依次回收继承的父类
TestD *td = new TestD();
delete td;

// 通 TestD *td 这种case,无需定义虚函数
std::shared_ptr<TestA> share_a = std::make_shared<TestD>();
}
// ~TestD()
// ~TestC()
// ~TestB()
// ~TestA()

还有一个case大家有兴趣可以看下,就是多继承类型转换会涉及到指针移动,因此如果没有虚继承很可能会出现 pointer being freed was not allocated: https://zhuanlan.zhihu.com/p/26392392 。 我相信通过本文的学习对于这个问题应该大家也能知道为啥会报错!

或者还有一种就是把构造(包含析构)函数设置为protected防止向上转型后调用析构函数,这么的话由派生类去析构就不会出现问题,代码例子: https://godbolt.org/z/d15KdT3PM

类型转换 和 void指针的坑

我在使用http_parser库封装http库的时候发现,http_parser库是c语言写的,对接C++的话暴露了一堆void指针,就导致很容易写出BUG代码!下面是我写了一个demo,为了复用我抽出了 headers_parserbody_parser ,导致需要void指针向上转型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <map>

using headers_t = std::map<std::string, std::string>;

struct headers_parser {
static void on_header(void* ptr, const std::string& key, const std::string& value) {
auto parser = static_cast<headers_parser*>(ptr);
parser->headers_[key] = value;
}
headers_t headers_{};
};

struct body_parser {
static void on_data(void* ptr, const std::string& data) {
auto parser = static_cast<body_parser*>(ptr);
parser->body_ = data;
}
std::string body_{};
headers_t extendion_headers_{};
};

struct response_parser : headers_parser, body_parser {
std::string status_{};
std::string version_{};
};

int main() {
response_parser parser{};
response_parser::on_data(&parser, "data");
response_parser::on_header(&parser, "k1", "v1");
}

运行一下???我去内存访问异常了,出现了段错误!

1
Process finished with exit code 139 (interrupted by signal 11:SIGSEGV)

怎么定位呢?估计是类型转换的问题,我debug调试了下发现 static_cast 在转换void指针的时候是非常的暴力!!就是内存填充一下就行了!你可以看一下下面这个例子!

1
2
3
4
5
6
7
8
9
10
11
int main() {
response_parser parser{};
std::cout << &parser << "\n";

void* ptr = &parser;
auto header = static_cast<headers_parser*>(ptr);
std::cout << header << "\n";

auto body = static_cast<body_parser*>(ptr);
std::cout << body << "\n";
}

内存结果就是如图所示!

image-20240415193637208

但是实际上内存是啥了?

image-20240415193909912

所以那里出问题了呢?问题就是 void* 导致编译器丢失了原类型,导致向上转型失败了!

那么很多时候我们和一些 c 语言的库进行打交道的时候,确实存在一堆 void* 指针,这时候如果我们直接 static_cast 转换的话会直接出BUG!怎么解决这个问题?

  1. 如果不涉及到 void* 指针, 下面代码没问题
1
2
3
4
5
6
7
8
9
10
int main() {
response_parser parser{};

auto header = static_cast<headers_parser*>(&parser);
std::cout << header << "\n";

auto body = static_cast<body_parser*>(&parser); // dynamic_cast 也可以,这里面static_cast性能更高
std::cout << body << "\n";
}

  1. 如果涉及到void 指针,只能将 void* 强制转换为 response_parser 然后再用类型转换!
1
2
3
4
5
6
7
8
9
int main() {
response_parser parser{};
void* ptr = &parser;
auto header = static_cast<headers_parser*>(static_cast<response_parser*>(ptr));
std::cout << header << "\n";

auto body = static_cast<body_parser*>(static_cast<response_parser*>(ptr));
std::cout << body << "\n";
}

那么怎么解决我们的问题?是的只能用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
#include <iostream>
#include <map>

using headers_t = std::map<std::string, std::string>;

template <typename T>
struct headers_parser {
static void on_header(void* ptr, const std::string& key, const std::string& value) {
auto parser = static_cast<headers_parser*>(static_cast<T*>(ptr));
parser->headers_[key] = value;
}
headers_t headers_{};
};

template <typename T>
struct body_parser {
static void on_data(void* ptr, const std::string& data) {
auto parser = static_cast<body_parser*>(static_cast<T*>(ptr));
parser->body_ = data;
}
std::string body_{};
headers_t extendion_headers_{};
};

struct response_parser : headers_parser<response_parser>, body_parser<response_parser> {
std::string status_{};
std::string version_{};
};

int main() {
response_parser parser{};
response_parser::on_data(&parser,"data");
response_parser::on_header(&parser,"k1","v1");
}

怎么评价 staic_cast 和 dynamic_cast 呢,在不清楚上下文的情况下用 dynamic_cast,否者用 staic_cast ,后者性能高!

object slicing

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
#include "spdlog/spdlog.h"
#include "gtest/gtest.h"

struct reader {
reader() = default;
virtual ~reader() = default;
virtual size_t read_some(char* buffer, size_t buffer_size) {
return 0;
};
};
struct buffer_reader final : reader {
explicit buffer_reader(std::string& buffer) : buffer_(buffer) {
}
size_t read_some(char* buffer, size_t buffer_size) override {
// auto size = buffer_size > buffer_.size() ? buffer_.size() : buffer_size;
// std::copy_n(buffer_.begin(), size, buffer);
return buffer_size;
}

private:
std::string& buffer_;
};

void test(reader& r) {
SPDLOG_INFO(r.read_some(nullptr, 100));
}

TEST(Struct, oo) {
std::string buffer = "hello world";
// 直接值赋值就会出现 object slicing !
reader r = buffer_reader(buffer);
SPDLOG_INFO(r.read_some(nullptr, 10)); // 输出0

buffer_reader br = buffer_reader(buffer);
test(br); // 输出100
// 解决:引用/指针,就不会出现值赋值
}

这个其实很坑的,大家注意奥!

总结

  1. 只要基类定义了虚函数,就会添加到基类的虚函数表中,子类重写后是否标记为虚函数(virtual修饰)都会添加到子类的虚函数表中
  2. 虚继承,可以降低内存开销,在一些交叉继承中有效果
  3. 子类继承的时候最好用 override 修饰一下函数重写,方便代码阅读
  4. 子类不涉及到重写,没必要设置一个虚函数,因为会额外分配内存
  5. 抽象类一定要把析构函数设置为虚函数,否则会存在内存泄漏或者内存回收出现空引用问题
  6. 如果遇到特别不理解的,看一下 dump class 看看或者查看下mem
  7. 虚表的设计或多或少有些冗余,因为在派生类中记录下来全部的虚函数的函数地址(是否重写都会记录),这个过程在编译期就决定了,可以通过查看汇编代码发现数据段中定义有virtual table.
  8. 多阅读源码、优秀的开源项目可以掌握不少技巧,多记录多尝试

其他小点

  1. 我们可以使用 traits 的 std::is_convertible 来判断是否可以进行类型转换, 实践可以参考 std::enable_shared_from_this
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
// -std=c++17  is_convertible_v 是c17提供的,c11可能写法麻烦点
#include <iostream>
#include <type_traits>

struct TestB {};
struct TestC : private TestB {};
struct TestD : TestB {};

template <typename T>
typename std::enable_if<std::is_convertible_v<T, TestB *>>::type DoPrint(T t) {
std::cout << "impl TestB*" << std::endl;
}

template <typename T>
typename std::enable_if<std::is_convertible_v<T, TestB>>::type DoPrint(T t) {
std::cout << "impl TestB" << std::endl;
}

void DoPrint(...) {
std::cout << "not impl TestB" << std::endl;
}

int main() {
DoPrint(new TestD()); // impl TestB*
DoPrint(TestD{}); // impl TestB
DoPrint(TestC{}); // not impl TestB
}
  1. 继承是可以限定作用域的,继承是无法使用基类private的成员的,除非基类给你开启friend,struct的话默认是public继承,class默认是private继承. protect继承会使用继承的public属性的成员变成protect,private会使用继承的成员变成(public&protect -> private)。 基类申明为final表示禁止继承,override可以显示申明重写!

  2. 继承构造函数,当我们想直接服用父类的构造函数需要手动再申明一次!!

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
// -std=c++11
#include <iostream>

struct TestB {
TestB(int f1, int f2) : f1_(f1), f2_(f2) {
}
virtual int sum() {
return f1_ + f2_;
}

private:
int f1_;
int f2_;
};

struct TestC : TestB {
using TestB::TestB; // 继承构造函数
};
struct TestD : TestB {
// 需要手动申明,使用构造函数委托
TestD(int f1, int f2) : TestB(f1, f2) {
}
};

int main() {
TestC c = TestC(1, 2);
std::cout << c.sum() << std::endl;

TestD d = TestD(1, 2);
std::cout << c.sum() << std::endl;
}

参考文章

本人坚持原创技术分享,如果你觉得文章对您有用,请随意打赏! 如果有需要咨询的请发送到我的邮箱!