C++ 入门基础之八

多继承

多继承概念

  • a) 一个类有多个直接基类(父类)的继承关系称为多继承

  • b) 类 C 可以根据访问控制同时继承类 A 和类 B 的成员,并添加自己的成员

  • c) 多继承声明语法
1
2
3
4
class  派生类名 : 访问控制  基类名1 ,  访问控制  基类名2 ,  … , 访问控制  基类名n
{
数据成员和成员函数声明
};

多继承的简单应用

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

using namespace std;

class Base1 {

public:
Base1(int a) {
this->a = a;
}

void printA() {
cout << "a = " << a << endl;
}

private:
int a;
};

class Base2 {

public:
Base2(int b) {
this->b = b;
}

void printB() {
cout << "b = " << b << endl;
}

private:
int b;
};

class Base3 : public Base1, public Base2 {

public:
Base3(int a, int b, int c) : Base1(a), Base2(b) {
this->c = c;
}

void printC() {
cout << "c = " << c << endl;
}

private:
int c;
};

int main() {
Base3 base(1, 2, 3);
base.printA();
base.printB();
base.printC();
return 0;
}

程序运行的输出结果如下:

1
2
3
a = 1
b = 2
c = 3

派生类的构造函数和成员访问

在多继承的派生类中,其构造函数和成员访问的特性如下:

  • 拥有多个基类的派生类的构造函数,可以用初始化列表调用基类构造函数来初始化数据成员。
  • 执行顺序与单继承构造函数情况类似,多个直接基类构造函数执行顺序取决于定义派生类时指定的各个继承基类的顺序。
  • 一个派生类对象拥有多个直接或间接基类的成员。不同名成员访问不会出现二义性,如果不同的基类有同名成员,那么派生类对象访问时应该加以识别。

虚继承

虚继承的概念

总结:

  • 如果一个派生类从多个基类继承,而这些基类又有一个共同的基类(公共基类),则在对该基类中声明的成员进行访问时,可能会产生二义性。
  • 如果在多条继承路径上有一个公共的基类,那么在继承路径的某处汇合点,这个公共基类就会在派生类的对象中产生多个基类子对象
  • 要使这个公共基类在派生类中只产生一个子对象,必须对这个基类声明为虚继承,使这个基类成为 虚基类
  • 虚继承声明需要使用关键字:virtual

虚继承的简单应用

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

using namespace std;

class Base {

public:
Base(int x) {
this->x = x;
cout << "Base 类的构造函数被调用" << endl;
}

void printX() {
cout << "x = " << x << endl;
}

private:
int x;
};

// 声明虚继承
class Base1 : virtual public Base {

public:
Base1(int a, int x) : Base(x) {
this->a = a;
}

void printA() {
cout << "a = " << a << endl;
}

private:
int a;
};

// 声明虚继承
class Base2 : virtual public Base {

public:
Base2(int b, int x) : Base(x) {
this->b = b;
}

void printB() {
cout << "b = " << b << endl;
}

private:
int b;
};

class Base3 : public Base1, public Base2 {

public:
// 由于父类和虚基类没有默认的无参构造函数,所以这里的派生类需要在初始化列表中,显式调用父类、虚基类的有参构造函数
Base3(int a, int b, int c, int x) : Base1(a, x), Base2(b, x), Base(x) {
this->c = c;
}

void printC() {
cout << "c = " << c << endl;
}

private:
int c;
};

int main() {
Base3 base(1, 2, 3, 4); // 虚基类Base的构造函数只会被调用一次
base.printA();
base.printB();
base.printC();
base.printX(); // 当不声明虚继承的时候,此写法会产生二义性,C++编译器会出现编译错误
return 0;
}

程序运行的输出结果如下:

1
2
3
4
5
Base 类的构造函数被调用
a = 1
b = 2
c = 3
x = 4

值得一提的是,如果虚基类声明了非默认形式的(即带参数的)构造函数,并且没有声明默认形式的(无参)构造函数,此时在整个继承关系中,直接或者间接继承虚基类的所有派生类,都必须在构造函数的成员初始化列表中列出对虚基类的初始化。因为涉及到多重继承和虚继承,为避免派生类因调用多个父类的构造函数后多次构造更上层虚基类,所以需要派生类自己显示调用继承而来的虚基类的构造函数,而继承链上其它所有对虚基类的构造函数调用将被忽略。简单一句话概况:父类不会帮子类调用虚基类的构造函数,子类在构造时必须自己初始化所有虚基类。

虚继承的适用场景

  • 虚继承只适用于有共同基类(公共基类)的多继承场景(钻石菱形 ◇),如右图所示
  • 对于 V 字形的多继承场景(代码如下),虚继承是没办法解决二义性问题的,如右图所示
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
#include <iostream>

using namespace std;

class Base1 {

public:
Base1(int a) {
this->a = a;
}

void print() {
cout << "a = " << a << endl;
}

private:
int a;
};

class Base2 {

public:
Base2(int b) {
this->b = b;
}

void print() {
cout << "b = " << b << endl;
}

private:
int b;
};

class Base3 : virtual public Base1, virtual public Base2 {

public:
Base3(int a, int b) : Base1(a), Base2(b) {

}
};

int main() {
Base3 base(1, 2);

// 虚继承只适用于有共同基类(公共基类)的多继承场景(钻石菱形 ◇)
// 即使上面声明了虚继承,但此写法仍然会产生二义性,C++编译器会出现编译错误
// base.print();

base.Base1::print();
base.Base2::print();
return 0;
}

程序运行的输出结果如下:

1
2
a = 1
b = 2

多态

多态是面向对象的三大概念(如下)之一,按字面的意思就是多种形态。当类之间存在层次结构,并且类之间是通过继承关联时,就会使用到多态。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
#include <iostream>

using namespace std;

class Parent {

public:
Parent(int a) {
this->a = a;
}

void print() {
cout << "I'm parent, a = " << a << endl;
}

private:
int a;
};

class Child : public Parent {
public:

Child(int a, int c) : Parent(a) {
this->c = c;
}

// 子类重写父类中的函数
void print() {
cout << "I'm child, c = " << c << endl;
}

private:
int c;
};

int main() {
Child child(3, 7);

// 执行子类的函数,默认情况下子类中重写的函数将隐藏父类中的函数
child.print();

// 执行父类的函数,通过作用域分辨符"::"可以访问到父类中被隐藏的函数
child.Parent::print();

return 0;
}

程序运行的输出结果如下:

1
2
I'm child, c = 7
I'm parent, a = 3

函数重写与函数重载的区别

  • 函数重载

    • 必须在同一个类中进行
    • 子类无法重载父类的函数,父类同名函数将被子类的覆盖
    • 重载是在编译期间根据参数类型、个数和顺序决定函数的调用
  • 函数重写

    • 必须发生于父类与子类之间
    • 父类与子类中的函数必须有完全相同的原型
    • 使用 virtual 关键字声明之后,能够产生多态(如果不使用 virtual 关键字声明,那叫重定义)

虚函数

类型兼容原则遇上函数重写

类型兼容原则 遇上函数重写时,执行以下代码后会出现意外的现象,即被调用的永远是父类的函数。

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

using namespace std;

class Parent {

public:
Parent(int a) {
this->a = a;
}

void print() {
cout << "I'm parent, a = " << a << endl;
}

private:
int a;
};

class Child : public Parent {
public:

Child(int c) : Parent(c) {
this->c = c;
}

// 子类重写父类中的函数
void print() {
cout << "I'm child, c = " << c << endl;
}

private:
int c;
};

int main() {
Parent* p = NULL;
Parent parent(6);
Child child(5);

// 执行父类的函数
p = &parent;
p->print();

// 执行父类的函数
p = &child;
p->print();

return 0;
}

程序运行的输出结果如下:

1
2
I'm parent, a = 6
I'm parent, a = 5

C/C++ 是静态编译型语言,在执行编译时,编译器会自动根据指针的类型判断指向的是一个什么样的对象。但在编译 print() 函数的时候,编译器不可能知道指针 p 究竟指向了什么对象,因为程序还没有运行。同时编译译器没有理由报错,于是编译器认为最安全的做法是编译到父类的 print() 函数,因为父类和子类肯定都有相同的 print() 函数。这就是所谓的 静态多态静态联编,函数调用在程序执行之前就已经准备好了;有时候这也被称为 早绑定,因为 print() 函数在程序编译期间就已经设置好了。这就引出了面向对象新的需求,希望根据实际的对象类型来判断重写函数的调用;如果父类指针指向的是父类对象则调用父类中定义的函数,如果父类指针指向的是子类对象则调用子类中定义的重写函数,如图所示。

虚函数的应用

C++ 中通过 virtual 关键字对多态进行支持,使用 virtual 关键字声明的函数被重写后即可展现多态特性,一般称之为 虚函数

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

using namespace std;

class Parent {

public:
Parent(int a) {
this->a = a;
}

// 使用 "virtual" 关键字声明父类的函数
virtual void print() {
cout << "I'm parent, a = " << a << endl;
}

private:
int a;
};

class Child : public Parent {
public:

Child(int c) : Parent(c) {
this->c = c;
}

// 使用 "virtual" 关键字声明重写父类中的函数
// 只要父类中的函数有 "virtual" 关键字的声明,那么子类的 "virtual" 声明可写可不写,一般建议都写上
virtual void print() {
cout << "I'm child, c = " << c << endl;
}

private:
int c;
};

int main() {
Parent* p = NULL;
Parent parent(6);
Child child(5);

// 执行父类的函数
p = &parent;
p->print();

// 执行子类的函数
p = &child;
p->print();

return 0;
}

程序运行的输出结果如下:

1
2
I'm parent, a = 6
I'm child, c = 5

此时,编译器看的是指针的内容,而不是它的类型。因此,由于 ParentChild 类的对象的地址存储在 *p 中,所以会调用各自的 print() 函数。正如所看到的,父类 Parent 的每个子类都有一个 print() 函数的独立实现。这就是多态的一般使用方式,即使用一个父类的指针或引用去调用子类中被重写的方法。有了多态就可以有多个不同的实现类,它们都带有同一个名称但具有不同实现的函数,函数的参数甚至可以是相同的。

虚析构函数

虚析构函数的作用:为了避免内存泄漏,通过父类的指针,可以将所有子类对象的析构函数都执行一遍(释放所有的子类资源)。即虚析构函数使得在删除指向子类对象的父类指针时,可以调用子类的析构函数来实现释放子类中堆内存的目的,从而防止内存泄漏。

  • 析构函数可以是虚的,虚析构函数用于指引 delete 运算符正确析构动态对象
  • 构造函数不能是虚函数,因为建立一个派生类对象时,必须从类层次的根开始,沿着继承路径逐个调用基类的构造函数

虚析构函数的简单应用

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>

using namespace std;

class A {

public:
A() {
this->p = new char[20];
strcpy(p, "Hello A");
cout << "A 类的构造函数被调用" << endl;
}

virtual ~A() {
delete[] this->p;
cout << "A 类的析构函数被调用" << endl;
}

private:
char* p;
};


class B : public A {

public:
B() {
this->p = new char[20];
strcpy(p, "Hello B");
cout << "B 类的构造函数被调用" << endl;
}

~B() {
delete[] this->p;
cout << "B 类的析构函数被调用" << endl;
}

private:
char* p;
};

int main() {
// 此写法,如果上面不使用 "virtual" 修饰A类(基类)的析构函数,派生类与所有基类的析构函数依然都会被自动调用一次
B* b = new B();
delete b;

cout << endl;

// 此写法,如果上面不使用 "virtual" 修饰A类(基类)的析构函数,那么只有A类(基类)的析构函数会被调用一次,B类(派生类)的析构函数不会被调用,这样就会造成内存泄漏
// 虚析构函数的作用是,通过父类的指针,可以将所有子类对象的析构函数都执行一遍(释放所有的子类资源)。
A* a = new B();
delete a;
return 0;
}

程序运行的输出结果如下:

1
2
3
4
5
6
7
8
9
A 类的构造函数被调用
B 类的构造函数被调用
B 类的析构函数被调用
A 类的析构函数被调用

A 类的构造函数被调用
B 类的构造函数被调用
B 类的析构函数被调用
A 类的析构函数被调用

虚析构函数的作用总结

  • a) 如果基类的析构函数不加 virtual 关键字修饰,那么就是普通析构函数
    • 当基类中的析构函数没有声明为虚析构函数时,派生类开始从基类继承,基类的指针指向派生类的对象时,delete 基类的指针时,只会调用基类的析构函数,不会调用派生类的析构函数
  • b) 如果基类的析构函数加 virtual 关键字修饰,那么就是虚析构函数
    • 当基类中的析构函数声明为虚析构函数时,派生类开始从基类继承,基类的指针指向派生类的对象时,delete 基类的指针时,先调用派生类的析构函数,再调用基类中的析构函数

多态的理论基础

  • 联编:是指一个程序模块、代码之间互相关联的过程
  • 静态联编:是程序的匹配、连接在编译阶段实现,也称为早期联编(早绑定)
    • 函数重载属于静态联编
  • 动态联编:是指程序联编推迟到运行时进行,所以又称为晚期联编(迟绑定)
    • 虚函数、switch 语句和 if 语句属于动态联编

多态理论联系实际应用(代码示例):

  • C++ 与 C 相同,是静态编译型语言
  • 在编译时,编译器会自动根据指针的类型判断指向的是一个什么样的对象,所以编译器认为父类指针指向的是父类对象
  • 由于程序没有运行,所以不可能知道父类指针指向的具体是父类对象还是子类对象
  • 从程序安全的角度,编译器假设父类指针只指向父类对象,因此编译的结果为调用父类的成员函数,这种特性就是 静态联编

多态成立的三个必要条件

  • a) 要有继承
  • b) 要有虚函数重写
  • c) 父类指针或引用指向子类对象

C++ 11 的 override 和 final

override 关键字:用来检查函数是否重写,在子类中的函数声明里加上该关键字 virtual void fun() override {},编译器就会自动检查对应的函数是否重写了父类中的函数
final 关键字:在类的声明中加上该关键字 class A final {};,目的是为了不让这个类被继承。或者,在一个函数后加上该关键字,表示这个函数不能被重写 void fun() final {}