slacr_

Just to record my life and thoughts.
笔记/编程/杂乱/极简

[C++笔记]继承

Apr 28, 2023C_C++3600 words in 24 min

继承与派生

派生类生成过程

吸收基类成员、改造基类成员、添加新的成员

访问控制

三种继承方式 public protected private
无论什么继承方式, 都无法访问基类private成员; public继承访问属性不变; private继承访问属性变private; protected继承访问属性变protected.

类型兼容规则

类型兼容规则是指在需要基类对象的任何地方,都可以使用公有派生类的对象来替代。

  • 派生类的对象可以隐含转换为基类对象。
  • 派生类的对象可以初始化基类的引用。
  • 派生类的指针可以隐含转换为基类的指针。

由于类型兼容规则的引入,对于基类及其公有派生类的对象,可以使用相同的函数统一进行处理。因为当函数的形参为基类的对象(或引用、指针)时,实参可以是派生类的对象(或指针) .而没有必要为每一个类设计单独的模块,大大提高了程序的效率。

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

class GrandFather {
public:
void say() {
cout << "GrandFather Say" << endl;
}
};

class Father : public GrandFather {
public:
void say() {
cout << "Father Say" << endl;

}
};

class Son : public Father {
public:
void say() {
cout << "Son Say" << endl;
}
};


void test(GrandFather& gf) {
gf.say();
}

int main() {
GrandFather gf;
Father f;
Son s;
test(gf);
test(f);
test(s);
return 0;
}

虽然发生了函数重写, 但通过这个基类型的引用(/指针/对象), 只能访问基类继承的成员.

继承中的构造析构

继承中普通构造函数

派生类继承了基类除自身构造析构函数之外的成员. 基类的构造函数并没有继承下来,要完成这些工作,就必须给派生类添加新的构造函数。派生类对于基类的很多成员对象是不能直接访问的,因此要完成对基类成员对象的初始化, 需要通过调用基类的构造函数

在构造派生类的对象时,会首先调用基类的构造函数,来初始化它们的数据成员,然后按照构造函数初始化列表中指定的方式初始化派生类新增的成员对象,最后才执行派生类构造函数的函数体。
当一个类同时有多个基类时,对于所有需要给予参数进行初始化的基类,都要显式给出基类名和参数表。对于使用默认构造函数的基类,可以不给出类名。

派生类构造函数执行的一般次序如下。
(1)调用基类构造函数,调用顺序按照它们被继承时声明的顺序(从左向右)。
(2) 对派生类新增的成员对象初始化,调用顺序按照它们在类中声明的顺序。
(3) 执行派生类的构造函数体中的内容。

构造函数初始化列表中基类名、对象名之间的次序无关紧要,它们各自出现的顺序可以是任意的,无论它们的顺序怎样安排,基类构造函数的调用和各个成员对象的初始化顺序都是确定的。

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>
using namespace std;
class Base_1 {
public:
int i; // 有参
Base_1(int i) : i(i) { cout << "Base_1 Construct_" << i << endl; };
};

class Base_2 {
public:
int j; // 有参
Base_2(int j) : j(j) { cout << "Base_2 Construct_" << j << endl; };
};

class Base_3 {
public: // 默认
Base_3(){ cout << "Base_3 Construct_*" << endl; }
};

class Derived : public Base_3, public Base_2, public Base_1 { // 继承顺序
public:
Base_1 M_1; // 函数执行顺序
Base_2 M_2;
//Base_1::Base_1(int i) : i(i) {};
Derived(int a, int b, int c, int d) : Base_1(a), Base_2(b), M_1(c), M_2(d){
// 初始化列表中对基类有参构造函数引用性声明, 并传递初始化值, 没有声明的基类构造函数则调用默认构造.
cout << "Derived Construct" << endl;
}
};

int main() {
Derived d(1, 2, 3, 4);
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
28
29
30
#include <iostream>
using namespace std;

class B {
public:
int x;
B(int x): x(x){}
B(const B& b) {
cout << "base copy" << endl;
this->x = b.x;
}
};

class D : public B {
public:
int y;
D(int x, int y):B(x), y(y) {}
D(const D& d) : B(d) { // 重写拷贝构造, 这里用到类型兼容, 基类要接受的是基类指针.
cout << "derived copy" << endl;
this->y = d.y;
}

};
int main() {
class D D_1(1, 2);
cout << D_1.x << " " << D_1.y << endl;
class D D_2(D_1);
// 默认调用派生类拷贝构造, 默认的拷贝构造是递归的, 会将派生类对象作为参数调用基类的拷贝构造
return 0;
}

继承中析构函数

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 B {
public:
int x;
B(int x) : x(x) {}
~B() { cout << "base destruct" << endl; }
};

class D : public B {
public:
int y;
D(int x, int y) :B(x), y(y) {}
~D(){ cout << "derived destruct" << endl; }
// 派生类不继承基类析构函数
// 编译器会自动生成一个默认析构
// 派生类析构函数中没写也隐含调用基类析构函数, 析构与构造顺序相反.
};
int main() {
class D D_1(1, 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
32
33
34
35
36
37
#include <iostream>
using namespace std;

class Base {
public:
int x;
Base() :x(0) {}
Base(int x) : x(x) {}
};


class Base_1 : public Base {
public:
int y;
Base_1() :y(0) {}
Base_1(int y) : y(y) {}
};

class Base_2 : public Base {
public:
int y;
Base_2() :y(0) {}
Base_2(int y) : y(y) {}
};


class Derived : public Base_1, public Base_2 {
public:
int z;
Derived(int z) : z(z) {}
};

int main() {
Derived d(1);
return 0;
}

类图:

Derived中含有两个不同的x 和 y

将Base_1 Base_2 改成虚继承.

1
2
3
4
5
6
7
8
9
10
11
12
class Base_1 : virtual public  Base {
public:
int y;
Base_1() :y(0) {}
Base_1(int y) : y(y) {}
};

class Base_2 : virtual public Base {
public:
int y;

};

类中多了一个虚基类的成员. 发生虚继承的子类中成员多了一个四个字节的虚基类表指针vbptr.vbptr指向该作用域下的虚基类表vbtable.虚基类表中记录了该类中虚基类成员的偏置值. 由该偏置值就可以获取唯一的虚基类成员. 虚函数地址表由编译器为每个类建立.

vbi是一个该类中虚基类信息表指针, 指向虚基类信息表, 虚基类信息表中记录了虚基类的一些信息, 比如class和offset偏置值.

分析虚继承发生了什么:
将基类的成员的吸收形式改为一个虚基类表指针和一将基类的成员开辟在派生类后面, 这个虚基类指针指向了一个虚基类表. 虚基类表中的每个继承的成员偏置值可以按照此时相对位置填充.
当Derived同时公共继承Base_1和Base_2时, 还是将其成员全部吸收,新增本类成员, 将虚基类成员开辟在本类后, 相同的基类成员在虚基类中只会出现一次, 然后更新虚基类表指针与虚基类表.

复杂点, 试试把Derived也设置成虚继承.

1
2
3
4
5
class Derived : virtual public Base_1, virtual public Base_2 {
public:
int z;
Derived(int z) : z(z) {}
};

可以看到, Base_1, Base_2 都变成了虚基类, 本类中多了一个虚基类指针vbptr, 用来记录从Base_1, Base_2中虚继承下来的成员. 指向的虚基类表记录了三个虚基类的偏置值. 虚基类Base_1中 又记录了他的虚基类Base的的vbptr.
Base现在既是Derived的虚基类, 也是Base的虚基类Base_1的虚基类.

x的地址还是唯一的在虚基类base中, 当我们访问x时会在本类寻找, 本类没有在利用虚函数指针在虚基类表中寻找虚基类的地址偏置值, 再在虚基类中寻找. 虚基类中基类成员是唯一的.

虚继承的目的是为了解决多继承(网状), 出现继承同一个类的多个相同成员副本的问题. 单继承(树状)则不会产生这样的问题. 这样做并不会影响程序的执行, 但本例中没有必要. 除非呈现下图类似的网状关系.

构造一个类的对象的一般顺序:
(1) 如果该类有直接或间接的虚基类,则先执行虚基类的构造函数。
(2) 如果该类有其他基类,则按照它们在继承声明列表中出现的次序,分别执行它们
的构造函数,但构造过程中,不再执行它们的虚基类的构造函数。
(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
#include <iostream>
using namespace std;

class Base {
public:
int a, b, c;
private:
int d, e, f;
};

class Derived : public Base {
public:
int x, y, z;

};

int main() {
Derived d;
Derived* ptr_derived = &d;
Base* ptr_base = &d;
// 基类指针接收派生类对象, 只需使基类指针定向到派生类中该基类的首地址, 由于基类指针的偏置量确定, 故无法访问派生类的成员.
// 指针类型转换时不止是复制地址
return 0;
}

知识点

  1. 派生类成按访问属性可分为: 不可访问成员, 私有成员, 保护成员, 共有成员.
  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
#include <iostream>
using namespace std;
class Base {
public:
void test(int a, int b) {
cout << "from base" << endl;
}
};

class Derived : public Base{
public:
using Base::test; // 或者用using 关键词在当前作用域下声明 using 将一个作用域中的名字引入到另一个作用域中
void test(int a) {
cout << "from derived" << endl;
}
};

int main() {
Derived d;
// d.test(1, 1); // 既使参数表不同, 符合重载的形式, 但作用域的关系, 派生类只要存在同名成员, 基类的同名成员就是不可见的, 只有在相同的作用域中定义的函数才可以重载。
d.Base::test(1, 1); // 加作用域以区分
d.test(1, 1); // 使用using关键词后可以调用
return 0;
}
  1. 多继承会出现一种菱形继承的情况, 产生的派生类中存在同级作用域下的同名成员, 此时必须用直接基类的类作用域加以区分.
  2. void 指针可以指向任何类型的对象,因此 void 类型指针和具体类型的指针具有一般与特殊的关系;基类指针可以指向任何派生类的对象,因此基类指针和派生类指针也具有一般和特殊的关系。从特殊的指针转换到一般的指针是安全的,因此允许隐含转换;从一般的指针转换到特殊的指针是不安全的,因此只能显式地转换。
  3. 而从派生类对象到基类对象的转换之所以能够执行,是因为基类对象的复制构造函数接收一个基类引用的参数,而用派生类对象是可以给基类引用初始化的,因此基类的复制构造函数可以被调用,转换就能够发生。

参考

  1. 《C++语言程序设计(第4版)》 IBSN 9787302227984
  2. C++参考手册
  3. Microsoft C++文档
  • Author:

    slacr_

  • Copyright:

  • Published:

    April 28, 2023

  • Updated:

    April 28, 2023

Buy me a cup of coffee ☕.

1000000