C++ 入门基础之九

大纲

多态的原理

多态的实现原理

  • 当类中声明了虚函数时,编译器会在类中生成一个虚函数表
  • 虚函数表是一个存储类成员函数指针的数据结构
  • 虚函数表是由编译器自动生成和维护的
  • 虚函数(virtual)会被编译器放入虚函数表中
  • 当存在虚函数时,每个对象中都有一个指向虚函数表的指针(C++ 编译器给父类对象、子类对象提前设置了 VPTR 虚函数表指针,因此 C++ 编译器不需要区分子类对象或者父类对象,只需要在 base 指针中,找 VPTR 指针即可)
  • VPTR 虚函数表指针一般作为类对象的第一个成员

多态的实现原理图解

  • (a) 多态实现原理的图解 如图 所示
  • (b) 通过 VPTR 虚函数表指针调用重写函数的过程是在程序运行时进行的,因此需要通过寻址操作才能确定真正应该调用的函数,而普通成员函数是在编译时就确定了调用的函数
  • (c) 在效率上,虚函数的效率要低很多,因此出于效率考虑,没有必要将所有成员函数都声明为虚函数,即使 C++ 编译器允许这么做
  • (d) 由于有了虚函数表,C++ 编译器不再需要知道是子类对象还是父类对象,这往往会给我们造成一种假象:C++ 编译器能识别子类对象或者父类对象

证明 VPTR 指针的存在

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

using namespace std;

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

// 不声明虚函数
void print() {
cout << "I'm parent1" << endl;
}

private:
int a;
};

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

// 声明虚函数
virtual void print() {
cout << "I'm parent2" << endl;
}

private:
int a;
};

int main() {
// 由于指针也是一种数据类型,由于在Parent2类中声明了虚函数,若Parent2类里存在VPTR指针,那么下面两个类的大小应该是不一样的
cout << "sizeof(Parent1): " << sizeof(Parent1) << endl;
cout << "sizeof(Parent2): " << sizeof(Parent2) << endl;
return 0;
}

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

1
2
sizeof(Parent1): 4
sizeof(Parent2): 8

父类指针和子类指针的步长可能是不一样的

  • (a) 指针也只一种数据类型,对 C++ 类对象的指针执行 ++-- 运算符仍然是合法的
  • (b) “多态是用父类的指针指向子类的对象” 和 “父类指针步长的自加(++)” 是两个完全不同的概念
  • (c) 当子类继承父类后,没有添加任何自己的成员变量和成员函数,那么此时父类指针和子类指针的步长才是一样的
  • (d) 指针运算是按照指针所指的类型进行的,父类指针和子类指针的步长可能是不一样的,不要用父类指针自加(++)、自减(--)的方式来操作子类的对象数组
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
#include <iostream>
using namespace std;

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

virtual void print()
{
cout << "I'm parent" << endl;
}

private:
int a;
};

class Child : public Parent
{
public:

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

virtual void print()
{
cout << "I'm child" << endl;
}
private:
int b;
int c;
};

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

Child array[] = { Child(1, 2), Child(3,4), Child(5, 6) };
parent = array;
child = array;

// 指针自加运算后运行可能会出错,这里父类指针和子类指针的步长是不一样的,不要用父类指针自加(`++`)、自减(`--`)的方式来操作子类的对象数组
parent++;
child++;

parent++;
child++;

return 0;
}

在父类的构造函数中调用虚函数,不能实现多态

子类的 VPTR 指针是分步完成初始化的,当执行父类的构造函数时,子类 的 VPTR 指针指向父类的虚函数表,当父类的构造函数执行完毕后,才会把子类的 VPTR 指针指向子类的虚函数表。因此,在父类的构造函数中调用虚函数,不能实现多态

  • (a) 分析图解 如图 所示
  • (b) 对象在创建的时,由编译器对 VPTR 指针进行初始化
  • (c) 只有当对象的构造全部完成后,VPTR 指针的指向才能最终确定
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
#include <iostream>

using namespace std;

class Parent {

public:
Parent(int a) {
this->a = a;
// 在父类的构造函数中调用虚函数
print();
}

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

virtual void print() {
cout << "I'm child, c = " << c << endl;
}

private:
int c;
};

int main() {
Child child(5, 8);
return 0;
}

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

1
I'm parent, a = 5

纯虚函数和抽象类

纯虚函数和抽象类的基本概念

基本概念:

  • (a) 纯虚函数是一个在基类中声明的虚函数,且在基类中没有被定义,要求任何派生类都必须定义自己的版本
  • (b) 纯虚函数为各派生类提供一个公共界面,可以实现接口的封装和设计、软件的模块功能划分等
  • (c) 纯虚函数的声明形式: virtual 类型 函数名 ( 参数表 ) = 0;
  • (d) 一个拥有纯虚函数的基类,通常称之为 “抽象类”

使用限制:

  • (a) 可以声明抽象类的指针和引用
  • (b) 抽象类不能创建对象(实例化)
  • (c) 抽象类不能作为函数的参数类型和返回值类型
  • (d) 如果基类中存在纯虚函数,那么派生类必须实现所有的纯虚函数,否则这个派生类也是一个抽象类

纯虚函数和抽象类的应用案例

在本节的案例代码中,定义了一个图形抽象类 Figure,并声明了负责计算图形面积的纯虚函数 getArea(),然后再定义 Circle、Triangle、Squre 派生类,并各自实现了纯虚函数 getArea() 来计算不同图形的面积。

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

using namespace std;

// 抽象类
class Figure {

public:
// 声明纯虚函数,计算面积
virtual double getArea() = 0;
};

class Circle : public Figure {

public:

Circle(double r) {
this->r = r;
}

// 实现纯虚函数,计算圆的面积
virtual double getArea() {
double area = 3.14 * r * r;
cout << "圆的面积: " << area << endl;
return area;
}

private:
double r;
};

class Triangle : public Figure {

public:
Triangle(double a, double b) {
this->a = a;
this->b = b;
}

// 实现纯虚函数,计算三角形的面积
virtual double getArea() {
double area = a * b / 2;
cout << "三角形的面积: " << area << endl;
return area;
}

private:
double a;
double b;
};

class Square : public Figure {

public:
Square(double a, double b) {
this->a = a;
this->b = b;
}

// 实现纯虚函数,计算四边形的面积
virtual double getArea() {
double area = a * b;
cout << "四边形的面积: " << area << endl;
return area;
}

private:
double a;
double b;
};

void printArea(Figure* base) {
base->getArea();
}

int main() {
// Figure f; // 错误写法,抽象类不能实例化

Triangle Triangle(20, 30);
Circle circle(6.8);
Square square(50, 60);

// 可以声明抽象类的指针
Figure* pBase = new Circle(5.3);
pBase->getArea();

// 可以声明抽象类的引用
Figure& base = square;
base.getArea();

printArea(&Triangle);

return 0;
}

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

1
2
3
圆的面积: 88.2026
四边形的面积: 3000
三角形的面积: 300

纯虚函数和抽象类在多继承中的应用

C++ 中没有 Java 中的接口概念,但可以使用抽象类和纯虚函数模拟 Java 中的接口(代码如下)。也就是说,C++ 中可以使用抽象类和纯虚函数来实现接口类的功能。值得一提的是,C++ 中的接口类只有函数原型定义,没有任何数据的定义,同时继承多个接口类不会带来二义性和复杂性等问题。C++ 面向抽象类编程(Java 面向接口编程)是项目开发中重要技能之一。

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

using namespace std;

// 定义接口类一
class Interface1 {

public:
virtual int add(int a, int b) = 0;
virtual void print() = 0;
};

// 定义接口类二
class Interface2 {

public:
virtual int mult(int a, int b) = 0;
virtual void print() = 0;
};

// 定义父类
class Parent {

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

virtual ~Parent() {

}

virtual int getA() {
return a;
}

private:
int a;
};

// 定义子类,首先继承父类,然后继承多个接口类
class Child : public Parent, public Interface1, public Interface2 {

public:
int add(int a, int b) {
return a + b;
}

int mult(int a, int b) {
return a * b;
}

void print() {
cout << "Child::print() 函数被执行" << endl;
}

};

int main() {
Child child;
child.print();

Parent* parent = &child;
cout << "a = " << parent->getA() << endl;

Interface1* interface1 = &child;
int result1 = interface1->add(2, 5);
cout << "2 + 5 = " << result1 << endl;

Interface2* interface2 = &child;
int result2 = interface2->mult(3, 6);
cout << "3 * 6 = " << result2 << endl;

return 0;
}

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

1
2
3
4
Child::print() 函数被执行
a = 8
2 + 5 = 7
3 * 6 = 18

纯虚函数和抽象类在多继承中的总结

C++ 中没有 Java 中的接口概念:

  • 绝大多数面向对象语言都不支持多继承
  • 绝大多数面向对象语言都支持接口的概念
  • C++ 中没有 Java 中的接口概念,但可以使用抽象类和纯虚函数模拟 Java 中的接口
  • C++ 中的接口类只有函数原型定义,没有任何数据的定义(代码如下)
1
2
3
4
5
6
7
class Interface  
{
public:
virtual void func1() = 0;
virtual void func2(int i) = 0;
virtual void func3(int i) = 0;
};

工程上多继承的使用说明:

  • (a) 多继承已经被实际开发经验所抛弃
  • (b) 工程开发中真正意义上的多继承是几乎不被使用的
  • (c) 多继承带来的代码复杂性远多于其带来的便利
  • (d) 多继承对代码维护性上的影响是灾难性的
  • (e) 在设计方法上,任何多继承都可以使用单继承代替
  • (f) 在多继承中,使用虚继承不能完全解决二义性的问题

工程上继承多个接口类的使用说明:

  • (a) 继承多个接口类不会带来二义性和复杂性等问题
  • (b) 多继承可以通过精心设计的单继承和接口类来代替
  • (c) 接口类只是一个功能说明,而不是功能实现,子类需要根据功能说明定义功能实现

纯虚析构函数

在 C++ 中,纯析构函数的作用与普通的虚析构函数相似,但有一个重要的区别:纯析构函数是一个纯虚函数,这意味着它没有默认的实现。纯析构函数通常在基类中声明,用于确保派生类在销毁时执行适当的清理操作。这种做法特别有用的情况是,基类可能含有一些纯虚函数,需要确保在对象被销毁时这些函数被调用,以执行正确的清理工作。当一个类中含有纯虚函数时,这个类就成为了抽象类,无法被直接实例化。因此,纯析构函数确保了派生类在销毁时能够调用基类的析构函数,而且必须提供自己的实现来满足抽象类的要求。

纯虚析构函数的基本概念

基本概念:

  • (a) 纯析构函数是一个纯虚函数
  • (b) 纯虚析构函数的声明形式: virtual ~ 类名 () = 0;
  • (c) 一个拥有纯虚析构函数的基类,也算是 “抽象类”

使用限制:

  • (a) 纯虚析构函数必须在类内声明,且在类外实现
  • (b) 一个拥有纯虚析构函数的类,不能创建对象(实例化),因为它也算是 “抽象类”

纯虚析构函数的应用案例

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

using namespace std;

class Animal {

public :
virtual void speak() {
cout << "Animal speaking ..." << endl;
}

// 声明纯虚析构函数(必须在类内声明,类外实现)
virtual ~Animal() = 0;

};

// 定义纯虚析构函数
Animal::~Animal() {
cout << "调用 Animal 的析构函数" << endl;
}

class Cat : public Animal {

public:
Cat(char *name) {
// 分配内存空间
this->m_Name = new char[strlen(name) + 1];
strcpy(this->m_Name, name);
}

virtual void speak() {
cout << "Cat speaking ..." << endl;
}

~Cat() {
cout << "调用 Cat 的析构函数" << endl;
// 释放内存空间
if (this->m_Name != nullptr) {
delete[] this->m_Name;
this->m_Name = nullptr;
}
}

public:
char *m_Name;

};

int main() {
// 错误写法,如果类拥有纯析构函数,那么这个类也算是抽象类,即该类不能实例化对象
// Animal *animal = new Animal();

Animal *animal = new Cat("Tom");
animal->speak();
delete animal;

return 0;
}

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

1
2
3
Cat speaking ...
调用 Cat 的析构函数
调用 Animal 的析构函数

向上类型转换和向下类型转换

在 C++ 中,类型转换是将一个数据类型转换为另一个数据类型的过程。向上类型转换(Upcasting)和向下类型转换(Downcasting)是两种常见的类型转换方式。

向上类型转换(Upcasting)

  • 向上类型转换是指将派生类对象指针或引用转换为基类对象指针或引用的过程。
  • 在继承关系中,派生类对象可以被看作是基类对象的一种,因此可以将派生类对象的指针或引用隐式地转换为基类类型。
  • 这种类型转换是安全的,因为派生类对象包含了基类的所有成员,但是基类不能访问派生类特有的成员。
1
2
3
4
5
6
7
8
9
10
11
12
class Base {

};

class Derived : public Base {

};

int main() {
Derived derivedObj;
Base * basePtr = &derivedObj; // 向上类型转换,派生类转换为基类(安全)
}

向下类型转换(Downcasting)

  • 向下类型转换是指将基类对象的指针或引用转换为派生类类型的指针或引用。
  • 这种类型转换是不安全的,因为基类对象可能没有派生类特有的成员,而尝试访问这些特有成员可能导致未定义的行为。
  • 在进行向下类型转换时,可以使用 dynamic_cast 来进行类型转换,这样可以检查类型转换的有效性。
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
class Base {

public:
virtual void foo() {

}

};

class Derived : public Base {

};

int main() {
Base baseObj;
Base *basePtr = &baseObj;

// 向下类型转换,基类转换为派生类(不安全)
Derived *derivedPtr = dynamic_cast<Derived *>(basePtr);
if (derivedPtr) {
// 转换成功
} else {
// 转换失败
}

return 0;
}

提示

在向下类型转换中,使用 dynamic_cast 可以在运行时检查类型转换的有效性。如果转换不合法,则会返回 nullptr(或者在引用的情况下抛出 std::bad_cast 异常),因此可以避免不安全的类型转换行为。