C++:多态
先看到多态的定义:
C++的多态是指在面向对象程序设计中,允许使用基类的指针或引用来调用派生类的虚函数的特性。这样的调用将根据对象的实际类型来动态绑定到适当的函数实现,实现了不同对象调用相同函数的不同行为。
上面的句子确实有点晦涩,但是重点就是最后一句话:不同对象调用相同函数时,函数展现不同的行为。
C++的多态是基于虚函数的,所以在讲解多态前,我们要先了解什么是虚函数。
虚函数
通过使用虚函数,可以实现在程序运行时根据对象的实际类型来确定调用的函数。
看到以下案例:
cpp
class person
{
public:
void func()
{
cout << "func被person调用" << endl;
}
};
class student : public person
{
public:
void func()
{
cout << "func被student调用" << endl;
}
};
这段代码中,存在着person
与student
的父子关系,两者都存在一个func
函数,由于函数同名,此时person
的函数被隐藏。
接下来我们用不同的引用来调用这个函数:
cpp
student s;
person& rp = s;
student& rs = s;
rp.func();
rs.func();
输出结果:
cpp
func被person调用
func被student调用
在
student& rs = s;
中,我们把student
类的对象s
交给了一个student
的引用rp
维护,此时再使用rs
调用func
函数,那么这个student
类就会调用自己的函数,输出"func被student调用"
。
在
person& rp = s;
中,我们把student
类的对象s
交给了一个person
的引用rp
维护,此时再使用rp
调用func
函数,那么这个student
类就会被当作一个person
类来处理,输出"func被person调用"
。
但是在实际应用应用中,这是一个不合理的行为。我们把一个把student
类的对象s
交给了一个person
的引用,此时这个student
类的对象就可以访问person
的函数了。
类比:这个行为就好像一个小偷偷走了别人的银行卡,这笔钱就属于这个小偷了。银行在取钱时,不应该通过这个身份证来确定允不允许这个人取钱,而是通过对方是不是这笔钱真正的主人。
以上类比中,身份证就是 引用/指针,而拿着身份证去取别人钱的行为,就是利用别人类型的 指针/引用 去调用别人函数。
既然多态要根据对象是谁,从而展现同一个函数的不同形态,那么当然要先解决 "确认对象是谁" 这个问题,不能让一个对象拿着其他类型的 指针/引用,导致调用了错误的函数。为此C++推出了虚函数,虚函数可以识别到这个对象的真实类型,调用正确的函数。
虚函数是构成多态的必要条件之一,现在我们来讲解虚函数的语法,帮助大家了解如何构成虚函数。
虚函数语法
在类中被virtua关键字修饰的函数,就是虚函数
cpp
class person
{
public:
virtual void func()
{
cout << "func被person调用" << endl;
}
};
以上代码中,func
函数就是一个虚函数了。
虚函数重写
当派生类中有一个与基类完全相同的虚函数,则会发生虚函数的重写
完全相同包括:
- 函数名相同
- 参数列表相同
- 返回值相同
示例:
cpp
class person
{
public:
virtual void func()
{
cout << "func被person调用" << endl;
}
};
class student : public person
{
public:
virtual void func()
{
cout << "func被student调用" << endl;
}
};
上述代码中,person
与student
的func
函数构成了重写:
- 函数名都是func
- 返回值都是void
- 参数都是没有参数
person
的func
被virtual
修饰,是一个虚函数student
的func
被virtual
修饰,是一个虚函数
即函数完全相同 + 两个函数都是虚函数,此时就构成了虚函数的重写。
虚函数重写后,此时再调用func函数,就已经可以通过对象到底是谁来调用对应的函数了。
同样的代码再执行一次:
cpp
student s;
person& rp = s;
student& rs = s;
rp.func();
rs.func();
输出结果:
cpp
func被student调用
func被student调用
此时哪怕我们把student
的对象交给person
的引用来维护,虚函数func
依然会根据对象本身来调用函数,两次都输出func被student调用
。
但是虚函数的重写有两个特例:
协变
协变是指子类可以将父类中的返回类型进行适当的改变,以适应不同的使用场景。
在讲解协变之前,先来看一个简单的例子。
cpp
class person{
public:
virtual void eat() {
cout << "person is eating" << endl;
}
};
class student : public person{
public:
void eat() override {
cout << "student is eating" << endl;
}
};
在上述代码中,我们定义了一个基类 person
和派生类 student
。基类中有一个虚函数 eat()
,派生类中对这个函数进行了重写。
现在,我们可以创建一个 person
的指针,指向一个 student
类型的对象,并调用 eat()
函数:
cpp
person* p = new student();
p->eat(); // 输出:person is eating
这是因为虚函数允许基类指针指向派生类对象,调用虚函数时会根据指针所指向的对象类型来动态调用合适的函数。
接下来,我们来讨论协变。假设我们在 person
类中添加一个新的虚函数 sleep()
:
cpp
class person{
public:
virtual void eat() {
cout << "person is eating" << endl;
}
virtual person* sleep() {
cout << "person is sleeping" << endl;
return this;
}
};
现在,我们在 student
类中对 sleep()
函数进行重写:
cpp
class student: public person{
public:
void eat() override {
cout << "student is eating" << endl;
}
student* sleep() override {
cout << "student is sleeping" << endl;
return this;
}
};
注意到在 student
类中对 sleep()
的返回类型进行了改变,从 person*
修改为 student*
。这就是协变的体现,子类可以将父类中的返回类型进行适当的改变。
然后,我们可以创建一个 person
的指针,指向一个 student
类型的对象,并调用 sleep()
函数:
cpp
person* p = new student();
student* c = p->sleep(); // 输出:student is sleeping
尽管 sleep()
函数的返回类型在基类中是 person*
,在派生类中是 student*
,但由于协变的特性,我们仍然可以将 person*
类型的指针赋值给 student*
类型的指针变量,而不会发生编译错误。
那么如果可以随意改变基类与派生类之间的返回值,那不就违背了一开始的返回值必须相同吗?
其实协变是有条件的:
在进行协变时,子类中的返回类型必须要是父类中返回类型的派生类。
也就是说,如果我们想要返回值不同,也是有要求的,返回值之间必须构成父子关系,才能进行协变。
接口继承
在C++中,接口继承是指一个类继承另一个类的接口部分,即只继承虚函数而不继承函数体部分。这样做的目的是为了在派生类中重写虚函数,以实现特定的功能。
我们看到以下代码:
cpp
class person
{
public:
virtual void func(int a = 5)
{
cout << "被person调用 a = " << a << endl;
}
};
class student : public person
{
public:
virtual void func(int a = 10)
{
cout << "被student调用 a = " << a << endl;
}
};
以上代码中,两个虚函数func
构成重写,但是person
中的func
,参数a
的默认值为5;student
中的func
,参数a
的默认值为10。这不影响的参数列表相同,参数列表是指参数的类型要相同,与默认值无关。
执行以下代码:
cpp
student s;
person& rp = s;
student& rs = s;
rp.func();
rs.func();
输出结果:
cpp
被student调用 a = 5
被student调用 a = 10
奇怪的事情发生了:我们确实使用student
对象调用了函数func
,所以两次调用都显示了被student调用
,说明调用了student
中的函数。但是为什么通过person&
调用的函数,a
的值是5?
这就涉及到了接口继承。
当两个函数构成虚函数时,并且通过 基类的引用/指针 调用函数,此时根据多态,会调用到派生类对应的函数,同时会发生接口继承。
如下:
上面的virtual void func(int a = 5)
会被继承给派生类,把下面的virtual void func(int a = 10)
替换掉,所以最后虽然我们最后通过多态调用到了正确的函数,但是由于接口继承,我们的接口依然是基类的,所以a = 5
。
但是如果我们直接通过,student&
来调用student
的函数,此时就是自己调用自己的函数,没有发生多态,所以没有发生接口继承,最后a = 10
。
所以发生接口继承的要求就是:
- 两个虚函数构成重写
- 通过基类的 指针 / 引用 来调用
其实这也是多态的要求,我们等下会详细讲解。
也因为这个接口继承规则,我们派生类中的virtual关键字可以省略!
cpp
class person
{
public:
virtual void func()
{
cout << "被person调用" << endl;
}
};
class student : public person
{
public:
void func()
{
cout << "被student调用" << endl;
}
};
上述代码中,两个函数func构成重写,此时基类的接口virtual void func()
会继承给派生类,导致void func()
被替换为virtual void func()
,最后变成虚函数。
此处共讲解了两个知识点:
- 接口继承
- 派生类中的virtual关键字可以省略(虚函数重写特例)
两者之间是因果关系。
最后进行一次虚函数重写语法总结:
基本语法:
当派生类中有一个与基类完全相同的虚函数,则会发生虚函数的重写
- 函数名相同
- 参数列表相同
- 返回值相同
特例:
1.当返回值在构成协变的情况下,可以不同(返回值是父子关系)
- 派生类的virtual可以不写(因为继承了基类接口的virtual)
多态构成
讲完了虚函数,其实多态就已经讲完了一大半了,想要构成多态,条件是:
- 必须通过基类的指针或者引用调用虚函数
- 基类必须存在相应的虚函数,子类必须对虚函数重写
多态的结果:
多态会根据 指针/引用 指向的对象的类型来调用对应的函数,而不是根据 指针/引用 本身的类型
讲完如何构成多态,接下来我们对比一下C++的类中,成员函数有哪些特殊形态:
成员函数状态对比
在C++的类中,我们的成员函数之间可以构成:重载,重写,重定义(隐藏)。接下来我们对比三者:
函数重载:
功能:当函数传入不同类型的参数时,执行不同的效果
要求:
- 重载的函数要在同一个作用域
- 函数名相同
- 参数列表不同
函数重写:
功能:派生类的虚函数将基类的虚函数重写,以达成多态
要求:
- 两个函数分别处于基类与派生类
- 函数名相同
- 参数列表相同
- 返回值相同
- 两个函数都是虚函数
函数重定义:
功能:派生类的同名函数屏蔽了基类的同名函数的直接访问
要求:
- 两个函数分别处于基类与派生类
- 函数名相同
- 当函数名相同,只要不构成重写,那就是重定义
抽象类
C++中的抽象类是一种特殊的类,它不能被实例化,只能用作其他类的基类。抽象类的目的是为了定义通用的接口,并强制派生类实现这些接口中的方法。
要创建一个抽象类,需要在类的定义中至少有一个纯虚函数(没有函数体的虚函数)。
当一个虚函数没有函数体,以 = 0
结尾,这个函数就是一个纯虚函数。
如下:
cpp
class person
{
public:
virtual void func() = 0;
};
此时func
就是一个纯虚函数。
当一个类有纯虚函数,那么这个类就是一个抽象类。
抽象类不能实例化出对象,其派生类也不能实例化出对象。除非派生类对这个纯虚函数进行重写,派生类才可以实例化出对象。
抽象类存在的意义,就是强制派生类进行重写函数。
多态原理
那么C++是如何实现多态的呢?
这就和虚函数重写的底层有关了。
虚函数重写,是基于虚函数表
的。虚函数表是一个用于存储虚函数指针的数组,其用于存储一个类中所有的虚函数指针,简称虚表
。
对于一般的类,如果没有虚函数,那么它的函数是不会存储在对象中的。但是虚函数不一样,为了保证可以在对象中确定这个对象对应的函数,我们要想办法在对象中标识出这个对象的虚函数。
于是含有虚函数的类,会多出一个指针,这个指针指向虚函数表,而虚函数表内部存储了这个类所有虚函数的地址。而这个指针叫虚函数表指针
,简称虚表指针
。
每个类都有自己独立的虚表,所以派生类和基类的虚表是独立的。
我们看看派生类的虚表是如何生成的:
- 先将基类的虚表拷贝一份到派生类的虚表中
- 如果派生类重写了虚函数,那么用重写的虚函数覆盖掉原先的虚函数
- 如果派生类自己还有额外的虚函数,依次添加到虚表的末尾
第二条至关重要,这是虚函数重写的底层原理:派生类重写了虚函数后,将虚表中相应的虚函数地址替换为重写后的地址。
当我们调用虚函数时,其会通过对象虚函数表指针找到虚函数表,再通过虚函数表定位函数。
将派生类的对象交给基类的 指针/引用 维护时,不会发生拷贝,而是进行一次切片,此时指针依然指向原先的对象,访问虚函数时,通过派生类对象的虚表来访问
当指针/引用指向基类对象:访问基类的虚表,调用重写前的虚函数
当指针/引用指向派生类对象:访问派生类的虚表,调用重写后的虚函数
此时不论是通过基类还是派生类的 指针/引用,都会通过对象本身对应的虚表来调用函数,这样就不会被 指针/引用 影响调用错误了。
那么为什么将派生类的对象切片为基类对象,不能调用到派生类的函数呢?
当我们将一个派生类的对象切片为基类对象,此时不是直接进行拷贝,基类在拷贝派生类中的基类成员时,不会拷贝派生类的虚表,而是用基类自己的虚表。
因此当我们将一个派生类对象切片为基类对象,由于虚表不是派生类的虚表,所以访问到的虚函数是基类的虚函数,无法构成多态。
虚表的特性:
虚表在编译阶段生成
虚表存储在代码段(常量区)中
只有虚函数才进虚表,普通函数不会进入虚表
虚表指针在构造函数的初始化列表中完成的初始化
多继承与多态
现在我们有以下继承关系:
cpp
class Base1 {
public:
virtual void func1()
{
cout << "Base1::func1" << endl;
}
virtual void func2()
{
cout << "Base1::func2" << endl;
}
private:
int b1;
};
class Base2
{
public:
virtual void func1()
{
cout << "Base2::func1" << endl;
}
virtual void func2()
{
cout << "Base2::func2" << endl;
}
private:
int b2;
};
class Derive : public Base1, public Base2
{
public:
virtual void func1()
{
cout << "Derive::func1" << endl;
}
virtual void func3()
{
cout << "Derive::func3" << endl;
}
private:
int d1;
};
Base1:有func1和func2两个虚函数
Base2:有func1和func2两个虚函数
Derive:继承了Base1和Base2,重写了func1,并增加了一个func3虚函数
多继承的情况下,我们的虚表是如何存放虚函数地址的?
我们看看两张虚表:
从Base1
继承的虚表:
cpp
Derive::func1
[0]:00007FF6750714E2
Base1::func2
[1]:00007FF675071343
Derive::func3
[2]:00007FF6750714C9
从Base2
继承的虚表:
cpp
Derive::func1
[0]:00007FF6750714DD
Base2::func2
[1]:00007FF6750710EB
第一个问题 :
派生类自己的虚函数func3
存储在哪一张虚表?
通过上面两张虚表可以看到,派生类的func3
出现在了第一张虚表中
结论:
- 多继承时,派生类会继承多张虚表,派生类自己增添的函数会放在第一张虚表中
第二个问题 :
按理来说,派生类对两个基类的func1
进行了重写,那么两张虚表都应该被重写,为什么两张虚表中func1
的地址不同?
原因:
对于第一张虚表,它会直接调用func1
函数。但是对于第二张虚表,它的指针并不在对象的头部,那么如果直接调用func1
函数,就会导致this
指针指向错误。所以第二张虚表调用函数时,会先跳转到其它地址,修正自己的this指针,让指针指向对象的开头,再去调用func1
函数。
结论:
- 如果多个基类中存在同名函数,且函数被派生类重写,此时这个虚函数会被存在多张虚表中,不同虚表会根据对应的地址,来修改自己的
this
指针,找到对象开头的指针,保证调用函数时this
指针正确
虚继承与多态
在虚继承中存在 虚基表/虚基表指针。而多态中存在 虚表/虚表指针,这是一对容易混淆的概念,接下来我们辨析一下:
对比
- virtual
- 虚函数中的
virtual
与 虚继承的virtual
两者只是共用一个关键字,没有太大关系- 指针
- 指向虚表的指针叫做虚表指针 / 虚函数表指针
- 指向虚基表的指针叫做虚基表指针
- 当一个类同时具有虚表指针与虚基表指针,虚表指针放在虚基表指针前面
- 表
- 虚函数通过虚表来找到函数地址
- 虚继承通过虚基表来找到被共享的间接基类
- 在虚基表中,第一个位置会空出来,存储一个虚基表指针与虚表指针的偏移量
比如以下结构的虚继承:
那么这个派生类D的对象结构视图如下:
有以下两个注意点:
- 当一个类同时具有虚表指针与虚基表指针,虚表指针放在虚基表指针前面
在左侧的对象模型中,对于从同一个类继承下来的指针,虚基表指针会在虚表指针前面。
- 在虚基表中,第一个位置会空出来,存储一个虚基表指针与虚表指针的偏移量
在右侧的绿色虚基表中,第一个位置存放的不是到达虚基类成员的偏移量,而是到虚表指针到虚基表指针的偏移量。