【C++】继承多态详解(未完成)
慕雪年华

在之前的CPP大作业中,为了应付期末(是这样的)关于继承和多态部分的内容只是草草过了一遍,并没有深挖背后的实现原理,以及使用的时候一些注意事项。

本篇博客是对类和对象继承多态部分的深化!

[TOC]

1.继承派生关系

继承是提高代码复用性的一个重要手段。它允许我们在保持基类原有属性的基础上,对其进行一定的扩张,增加不同的功能以应对实际情况。

与其相似的增加代码复用性的语法,还有模板

1.1 基本用法

继承和派生是父与子的关系,其中子类拥有父类成员的同时,还会拥有自己的成员

  • 继承是一个特殊的语法,用于多个类有公共部分的时候
  • 父类:基类
  • 子类:派生类
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
//举例:网站的公共部分
class ART {
public:
void header()//所有网站页面都有这个
{
cout << "文章" << "归档" << "友链" << endl;
}

void footer()//所有网站页面都有这个
{
cout << "关于我们" << endl;
cout << "网站访问量" << endl;
}

void func()//文章页面
{
cout << "文章" << endl;
}
};
class LINK {
public:
void header()//所有网站页面都有这个
{
cout << "文章" << "归档" << "友链" << endl;
}

void footer()//所有网站页面都有这个
{
cout << "关于我们 " << " 网站访问量" <<endl;
}

void func()//友链页面
{
cout << "友链" << endl;
}
};

在上面的情况中,ART和LINK类中都有网站的公共部分,这时候就出现了代码的重复。继承的出现就是用于解决这个问题的

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
//下面使用继承的方式来写,WEB类是网站的公共部分
class WEB {
public:
void header()//所有网站页面都有这个
{
cout << "文章" << "归档" << "友链" << endl;
}

void footer()//所有网站页面都有这个
{
cout << "关于我们" << endl;
cout << "网站访问量" << endl;
}
};

//ART、LINK是两个子类,继承了WEB的公共部分
//这样就减少了代码量
class ART : public WEB{
public:
void func()//文章页面
{
cout << "文章" << endl;
}
};

class LINK : public WEB {
public:
void func()//友链页面
{
cout << "友链" << endl;
}
};

测试可以发现,ART和LINK作为派生类,在继承了基类WEB的成员的基础上,还拥有了它们独特的单独成员

image

同一个类可以同时继承多个基类

1
2
3
class C : public A,public B{
//.....
};

1.2 权限问题

继承有3中类型:public、private、protected。这里会显示出类中protected权限和private权限的区别

1
2
3
4
5
6
7
8
class A{
public:
int a;
protected:
int b;
private:
int c;
};

当我们分别用上面三种方式对类A进行继承的时候,得到的结果是不同的

  • 用什么继承方式,派生类中继承的成员就变成什么类型
  • 不管用什么继承方式,都无法访问基类中的私有成员

image

关于权限问题,我们还需要了解下面几点:

  • 基类的私有成员在派生类中不可见,但实际上它也被继承过去了。但是编译器和语法的限制让我们无法访问。
  • 保护限定符由此出现,如果在基类中的成员不想被外界直接访问,则可以定义为保护
  • class默认继承方式为私有,struct默认继承方式为保护
  • 实际中我们一般使用public继承,保护/私有方式不利于维护和拓展

1.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
35
36
37
38
39
40
//继承同名成员的处理
// 普通的同名成员
class DAD1 {
public:
DAD1()
{
_a = 100;
}

void func()//同名函数
{
cout << "DAD func" << endl;
}
void func(int i)
{
cout << "DAD func int: " << i << endl;
}

int _a;//基类中的该变量
};

class SON1 : public DAD1{
public:
SON1()
{
_a = 20;
}
void Print()
{
cout <<"SON: " << _a << endl;//优先访问派生类的_a
cout <<"DAD: " << DAD1::_a << endl;//访问基类的_a
}

void func()//同名函数
{
cout << "SON func" << endl;
}

int _a;//派生类的同名变量
};

image

实际操作中,不建议写同名的成员

1.4 静态成员

在继承体系中,基类的静态成员有且只能有一个。即所有的子类和他们的对象,都是只有那一个静态成员的。我们可以用这个特性来对继承派生中出现的对象进行计数。

1
2
3
4
5
6
7
8
9
10
class Person
{
public :
Person () {++ _count ;}
protected :
string _name ; // 姓名
public :
static int _count; // 统计人的个数。
};
int Person :: _count = 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
//访问同名的静态成员
class DAD2{
public:
static int D_a;

static void Test1()
{
cout << "DAD2 Test1 " << endl;
}
static void Test1(int n)
{
cout << "DAD2 Test1(int) " << n << endl;
}
};

int DAD2::D_a = 100;

class SON2 : public DAD2 {
public:
static int D_a;

static void Test1()
{
cout << "SON2 Test1 " << endl;
}
};

int SON2::D_a = 200;

image

1.5 友元

友元关系不会被继承,基类的友元函数无法访问派生类的私有/保护成员

image

1.6 默认成员函数

我们知道,C++类和对象中有6个默认成员函数

image

在派生类中,这些默认成员函数有新的使用方法

  • 派生类的构造函数必须在初始化列表中调用基类的构造函数,初始化父类的一部分成员。如果你没有写,编译器会自动调用默认构造函数(先调用基类,在调用子类)
  • 派生类的拷贝构造同上,必须显式调用基类拷贝构造函数
  • 派生类的赋值重载也需要调用基类赋值重载完成操作
  • 派生类的析构函数编译器会自动调用基类,先析构派生类,再析构基类成员(符合栈后进先出原则)
  • 在基类析构函数不是虚析构的时候,子类析构和父类析构构成隐藏关系

构造和析构顺序

子类继承父类后,当创建子类对象,也会调用父类的构造函数

  • 继承中先调用父类构造函数
  • 再调用子类构造函数

析构顺序与构造相反

显示调用构造函数

如何显示调用构造函数呢,下面是一个代码示例

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 Person
{
public:
Person(string name, string sex, int age)
{
_name = name;
_sex = sex;
_age = age;
}
protected:
string _name;
string _sex;
int _age;
};

class Student : public Person
{
public:
Student(string name,string sex,int age,int no)
:Person(name,sex,age),
_No(no)
{}

int _No;//学号
};


int main()
{
Student sobj("李华","男",18,1000);
}

运行结果如下,可见子类正常调用了基类构造函数并进行了初始化

image

1.7 基类和派生类赋值问题

派生类成员可以赋值给基类的 对象/指针/引用。一般我们把这种情况称为切片,形象地表示把派生类中父类那部分切来赋值过去。

但是!反过来是不行的哦,你不能把基类对象赋值给派生类对象。

基类的指针/引用可以用强制类型转换给基类的指针/引用。但是这样不够安全,除非基类的指针指向的是对应的派生类。

如果基类是多态类型,可以使用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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
using namespace std;

class Person
{
protected :
string _name;
string _sex;
int _age;
};

class Student : public Person
{
public :
int _No ;//学号
};


int main()
{
Student sobj ;
// 1.子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj ;
Person* p1 = &sobj;
Person& p2 = sobj;

//2.基类对象不能赋值给派生类对象
//sobj = pobj;//err

// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
p1 = &sobj;//子类对象给基类指针
Student* ps1 = (Student*)p1; //基类指针指向子类,正常转换
ps1->_No = 15;
cout<<ps1->_No <<endl;

p1 = &pobj;//基类对象给基类指针
Student* ps2 = (Student*)p1; //转换虽然可以,但是会存在越界访问
ps2->_No = 10;
cout<<ps2->_No <<endl;

return 0;
}

image

关于最后提到的越界访问问题,我们知道,指针变量的大小都是相同的,其指针类型的区别主要在访问能力的不同。比如char*指针解引用只能访问1个字节,int*指针解引用可以访问4个字节,以此类推,Student*指针解引用可以访问sizeof(Student)个字节的空间。

而子类对象的大小都是大于等于基类对象的大小的。这就导致子类指针访问基类对象内容时,一次解引用访问的空间超长,造成了越界访问


实际上,当我们切片讲子类对象赋值给父类对象的时候,编译器会进行切片操作,即新的父类对象中的内容只会包含父类的成员。子类多出去的那一部分成员会被剔除。

这一点我们可以在VS的调试中证实

image

因为基类的成员变量被设置成了保护,所以我们不能直接在外部进行修改。需要显式调用基类的构造函数

1.8 虚继承(菱形继承问题)

有的时候,继承会出现下面这种情况:一个子类继承了两个基类,而这两个基类又同时是一个基类的派生类

image

这时候,D里面就会有两份A的内容,相当于两份公共部分。这是我们不想看到的,因为会造成空间浪费。而且直接访问的时候,编译器会报错“对变量X的访问不明确”


比如:intel和amd联合推出的NUC小电脑中,有一款CPU是他们合作开发的

如何解决同时继承AMD和INTEL的问题?

  • 这时候会出现两个同名变量,一个是AMD里面有的,另外一个是INTEL里面有的
    因为他们是从CPU里面继承来的。
  • 虽然我们可以指定作用域来分别修改和访问。但是实际上这个公共部分就出现了浪费(比如是网站的公共部分,多给你一份没有啥意义)

image

和前面说道的同名问题一样,我们可以指定作用域来访问特定的变量,但是这样是治标不治本的方法,并没有解决空间浪费的问题。

1
2
3
4
//解决方法1(治表不治本)
//用类域来修改和访问
cout << "intel: " << n1.INTEL::_Structure << endl;
cout << "amd: " << n1.AMD::_Structure << endl;

这就需要我们使用虚继承来操作:给B和C对A的继承加上virtural关键字

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
class CPU {
public:
CPU()
:_Structure("x86")
{ }

char _Structure[100];
};

class INTEL : virtual public CPU {
public:
INTEL()
:i_Brand("intel")
{}

char i_Brand[10];
};

class AMD : virtual public CPU {
public:
AMD()
:a_Brand("amd")
{}

char a_Brand[10];
};

//同时继承AMD和INTEL
//相当于有两个_Structure变量
//实际上我们只需要一个就够了
class NUC :public AMD, public INTEL {

};
void test1()
{
NUC n1;
//对“_Structure”的访问不明确
//cout << n1._Structure << endl;//err

//解决方法1(治表不治本)
//用类域来修改和访问
cout << "intel: " << n1.INTEL::_Structure << endl;
cout << "amd: " << n1.AMD::_Structure << endl;

//解决方法2,在AMD和INTEL对CPU的继承上加virtual
cout << "n1访问:" << n1._Structure << endl;
//现在就没有报错了
//因为这时候AMD和INTEL中的_Structure都会指向同一个地址
cout << "&intel: " << &(n1.INTEL::_Structure) << endl;
cout << "&amd: " << &(n1.AMD::_Structure) << endl;

//修改INTEL中的_Structure,也会连代修改AMD中的_Structure
}

这时候直接访问变量就不会报错了。因为这时候,B和C中的该变量指向了同一个地址,修改操作会同步。

image

继承模型

这种虚继承的模型是什么样子的呢?进入调试窗口,可以看到这里分别分为了3个模块,保存了不同基类的成员。而它们之中的_Stucture成员只有一个,不会出现异义

image

那这里空着的空间是用来做什么的呢?是内存对齐吗?

非也!

虚基表

下图能帮你了解这个内存的区块是怎么划分的

image

这样做就有一个好处,即便我们使用不同的基类指针(比如amd或者intel)来指向nuc的子类对象,它们都可以通过虚基表里面存放的偏移量来计算CPU成员的位置。从而避免了出现异义的问题。


而如果我们在继承的时候去掉virtual关键字,即使用普通继承,它的继承模型又会不同

image

这里就因为内存对齐的问题,我们无法看清楚它的全貌。不过在菱形继承问题中,不使用虚继承会造成两个CPU对象的继承,导致访问不明确的特性是可以看出来的。

1.9 继承和组合

  • 继承:上述所说。每一个派生类对象都是一个基类对象is-a
  • 组合:在一个类里面包含另外一个类的对象成员。每一个B对象中都包含了一个A has-a

实际情况中,建议优先选择组合,而不是继承。

  • 继承增加了代码的复用性,但是在一定程度上破坏了基类的封装性。派生类和基类的关联很强,耦合度高。
  • 对象组合是另外一种复用的选择,这时候,对象A的内部结构是不得而知的。这样就减小了对象之间的关联性,耦合度低,保护了封装,更方便代码的维护

不过,继承还有另外一种用途,那就是多态。我们下边会讲解的!

总结

多继承所导致的菱形继承问题,在一定程度上让C++的语法变得复杂了。比如java是没有多继承的。在实际使用情况中,不建议使用多继承。

image

2.多态

  • 静态多态:运算符重载
  • 动态多态:派生类和虚函数组成的多态

多态通俗地讲就是多种形态,当不同的对象去完成相同的事情的时候,会产生不同的状态。

2.1 虚函数

2.1.1 基本使用以及动态多态

虚函数,并不代表这个函数是虚无的。而表示这个函数在一定情况下会被替换(就好比继承中的虚继承问题)。要实现动态多态,就需要借助虚函数来实现。以下面这个动物说话的代码为例

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 Animal {
public:
//void Talk()
virtual void Talk()//虚函数
{
cout << "Animal is talking" << endl;
}
};

class CAT : public Animal{
public:
void Talk()//同名函数
{
cout << "CAT is talking" << endl;
}
};

class DOG : public Animal {
public:
void Talk()//同名函数
{
cout << "DOG is talking" << endl;
}
};
//基类中不使用虚函数时,该函数的内容已确定
//不管传参什么类,都会调用Animal自己的Talk函数
//加上虚函数virtual后,会调用CAT和DOG的Talk函数
void MakeTalk(Animal& it) {
it.Talk();//调用对应的Talk函数
}

当基类Animal中的Talk函数没有用virtual修饰时,不管给这个函数传参什么类的对象,它都会调用Animal自己的Talk函数

image

当我们用虚函数进行修饰后,就会调用派生类CAT和DOG的Talk函数,这就实现了一个简单的动态多态。

image

对于虚函数,有几点需要注意:

  • 当基类的指针或引用指向派生类的对象时,就会触发动态多态,派生类中的同名函数会覆写基类中的虚函数
  • 不能定义静态虚函数——因为静态函数是属于整个类的,而不是属于某一个对象
  • 不能定义虚构造函数——总不能用派生类的构造来覆写基类的构造吧?
  • 析构函数可以是虚函数

2.1.2 虚析构函数

有的时候,我们需要析构一个子类对象时,往往会给基类的析构函数加上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
class Queue {
public:
Queue()
:_a(new int[10])
{ }
virtual ~Queue() {
cout << "~Queue" << endl;
delete[] _a;
}
private:
int* _a;
};

class MyStack :public Queue {
public:
MyStack(int capa)
:_a1(new int[capa])
{}
~MyStack() {
cout << "~MyStack" << endl;
delete[] _a1;
}
private:
int* _a1;
};

int main()
{
Queue* q1=new MyStack(4);//父类指针指向子类
delete q1;//调用子类的析构函数

return 0;
}

2.2 纯虚函数

在虚函数的基础上,C++定义了纯虚函数:有些时候,在基类里面定义某一个函数是没有意义的,这时候我们可以把它定义为纯虚函数,具体的实现让派生类去同名覆写。

纯虚函数的基本形式如下

1
2
//virtual 函数返回类型 函数名()=0;
virtual void Print()=0;

派生类中必须重写基类的纯虚函数,否则该类也是抽象类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class A {
public:
//virtual void Print();//虚函数
virtual void Print() = 0;//纯虚函数
};

class B :public A {
public:
void Print() {
cout << "B print " << endl;
}
};
class C :public A {
public:
void Print() {
cout << "C print " << endl;
}
};

当我们在派生类中覆写了该函数后,即可实例化对象并调用该函数

image

和虚函数一样,使用基类的引用或指针来接收派生类的对象,即可调用对应的函数

image

2.3 抽象类

包含纯虚函数的类就是抽象类,抽象类有下面几个特点:

  • 抽象类无法实例化对象
  • 抽象类的派生类必须重写基类的纯虚函数,不然派生类也是抽象类
  • 如果在基类中定义的纯虚函数是const修饰的,则派生类中对应的函数也需要用const修饰

image