多态的概念
多态(Polymorphism)是面向对象编程中的一个重要概念,它允许同一类型的对象在不同的上下文中表现出不同的行为。多态性有两种主要形式:编译时多态(静态多态性)和运行时多态(动态多态性)。
编译时多态可以看成是函数重载和运算符重载,之前的文章已经涉及过,不再赘述;
所以,下面所提到的多态,都指的是运行时多态。
多态的定义及实现
多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。
Person对象买票全价,Student对象买票半价。
由此,在继承中构成多态需要两个条件:
- 必须通过基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
对于第一点,是实现多态的重要前提,即通过相同的函数名,在不同类中有着不同的响应,下面以代码实例演示:
#include <iostream>
class Base{
public:
virtual void someFunction(){
std::cout << "Base::someFunction()" << std::endl;
}
};
class Derived : public Base{
public:
void someFunction() override{
std::cout << "Derived::someFunction()" << std::endl;
}
};
void Func1(){
Base b1;
Derived d1;
b1.someFunction();
d1.someFunction();
Base* ptr1 = new Derived();
ptr1->someFunction();
Base* ptr2 = new Base();
ptr2->someFunction();
delete ptr1;
delete ptr2;
}
int main(void){
Func1();
return 0;
}
结果如下:
对于第18、19行,虽然调用了不同类的成员函数,但是并不符合多态的要求,而接下来的代码演示了多态的具体实现,通俗来说,就是不管你基类指针指向什么对象,但凡要调用虚函数时,必须使用基类的指针或引用,否则就不是多态。
虚函数
什么是虚函数
虚函数:即被virtual修饰的类成员函数称为虚函数。
class Person{
public:
virtual void BuyTicket() {
cout << "买票-全价" << endl;
}
};
这里的BuyTicket函数就是一个虚函数。
虚函数的重写(覆盖)
虚函数的重写(覆盖):
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
注意,这里重写的要求极为苛刻,函数名、返回值类型、参数列表均要求相同,才符合虚函数重写的要求。
前文已经演示了虚函数的重写代码,此处不再赘述;但注意,基类声明了虚函数,派生类重写时无需再次声明,尽管依然构成重写,但是不规范,不建议使用。
因为基类的虚函数继承下来,在派生类中依然保持着虚函数属性,所以无需再次声明。
虚函数重写时的两个例外:
- 协变(基类与派生类虚函数返回值类型不同)
从派生类指针到基类指针的转换称为协变。
协变的概念扩展到函数的返回类型,即如果一个函数的返回类型是某个类的指针或引用,那么它可以在子类中被重写为该子类的指针或引用。
#include <iostream>
class Base{
public:
virtual Base* func1(){
return this;
}
};
class Derived : public Base{
public:
Derived* func1() override {
return this;
}
};
如上,派生类重写时发生了协变,改变了基类虚函数的返回值;但请注意,协变不是一个强制要求,你当然可以按照基类虚函数的返回值进行重写,不进行协变操作。
- 析构函数的重写(基类和派生类的析构函数名不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的 析构函数构成重写,虽然基类与派生类析构函数名字不同。
虽然函数名不相同,看起来违背了重写的规 则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
代码演示如下:
#include <iostream>
class Person{
public:
Person(){
std::cout << "Person()" << std::endl;
}
virtual ~Person(){
std::cout << "~Person()" << std::endl;
}
};
class Student : public Person{
public:
Student(){
std::cout << "Student()" << std::endl;
}
~Student() override {
std::cout << "~Student()" << std::endl;
}
};
void Func1(){
Person* ptr1 = new Person();
Person* ptr2 = new Student();
delete ptr1;
delete ptr2;
}
int main(void){
Func1();
return 0;
}
结果如下:
可以看见,调用ptr2的析构函数时发生了多态,其使用的是派生类的析构函数而非子类,可见派生类的析构函数对基类的析构函数进行了重写。
关键字override、final
override
用于检查派生类的虚函数是否对基类虚函数进行了重写,如果没有发生,报出编译错误。
下面简单演示一下用法:
class Base{
public:
virtual void show(){
std::cout << "Hello World!" << std::endl;
}
};
class Derived : public Base{
public:
void show(){
std::cout << "Hello CPlusPlus World!" << std::endl;
}
};
final
用于修饰虚函数,表示该虚函数不会再被重写:
class Base{
public:
virtual void show() final{
std::cout << "Hello World!" << std::endl;
}
};
class Derived : public Base{
public:
// 编译出错,基类的虚函数已经被限制重写
void show(){
std::cout << "Hello CPlusPlus World!" << std::endl;
}
};
这里基类的虚函数重写已经被限制,所以当派生类对该函数进行重写时,编译出错。
小结
重载、重写(覆盖)、重定义(隐藏)概念辨析
抽象类
抽象类的概念及定义
抽象类(也称为接口类),即包含纯虚函数的类;
纯虚函数,在虚函数后写上=0,即构成了纯虚函数。
抽象类无法实例化出对象,且如果继承后的派生类未对纯虚函数进行重写,派生类依然无法实例化出对象,所以抽象类更像是一个接口的性质,故也称之为接口类。
class Person {
public:
virtual void show() = 0;
};
class Student :public Person {
public:
void show() {
std::cout << "Hello C++ World!" << std::endl;
}
};
void Func1() {
// Person p1;
Student s1;
}
注意,第14行想要去实例化一个抽象类,编译出错;
当我们在派生类中重写了纯虚函数,派生类就可以去实例化一个s1对象了。
接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现;
虚函数的 继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口;
所以如果不实现多态,不要把函数定义成虚函数。
多态的原理
虚函数表
这里我们先以一个简单的基类为例,看看它的组成结构:
#include <iostream>
class Base {
public:
virtual void show() {
std::cout << "Hello World!" << std::endl;
}
private:
int _base = 0;
};
void Func1() {
Base b1;
}
int main(void) {
Func1();
return 0;
}
我们调用监视窗口:
可以看到,除了成员变量_base外,还有一个指针类型_vfptr,而这个我们称之为虚函数表指针(virtual function pointer);
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,那么派生类中这个表放了些什么呢?
为了验证,我改写了实验用例:
#include <iostream>
class Base {
public:
virtual void Func1() {
std::cout << "Base::Func1()" << std::endl;
}
virtual void Func2() {
std::cout << "Base::Func2()" << std::endl;
}
private:
int _base = 0;
};
class Derived :public Base {
public:
void Func1() {
std::cout << "Derived::Func1()" << std::endl;
}
virtual void Func3() {
std::cout << "Derived::Func3()" << std::endl;
}
void Func4() {
std::cout << "Derived::Func4()" << std::endl;
}
private:
int _derived = 0;
};
void test1() {
Base b1;
Derived d1;
}
int main(void) {
test1();
return 0;
}
监视窗口结果:
代码中,基类的两个虚函数,在派生类中我只是对Func1进行了重写,观察它们的值,发现Func1在派生类和基类中的地址是不一样的,而没有实现重写的Func2地址与基类相同;
所以重写在原理层面就是将基类的虚函数覆盖了,当调用派生类对象的函数时,会直接调用覆盖后的函数,从而实现多态;而重写是语法层面的叫法,和原理没有什么关系;
派生类的虚函数和继承下来的基类的虚函数一样,放在同一张虚函数表里,如果是多继承语法,则放在第一个基类的虚表里(按照声明的顺序排序)。
单继承和多继承的虚函数表
单继承中的虚函数表
这里我们采用之前的代码样例:
#include <iostream>
class Base {
public:
virtual void Func1() {
std::cout << "Base::Func1()" << std::endl;
}
virtual void Func2() {
std::cout << "Base::Func2()" << std::endl;
}
private:
int _base = 0;
};
class Derived :public Base {
public:
void Func1() {
std::cout << "Derived::Func1()" << std::endl;
}
virtual void Func3() {
std::cout << "Derived::Func3()" << std::endl;
}
void Func4() {
std::cout << "Derived::Func4()" << std::endl;
}
private:
int _derived = 0;
};
void test1() {
Base b1;
Derived d1;
}
int main(void) {
test1();
return 0;
}
为了验证上面的说法,还要写一个验证程序打印出对象虚表的结构,因为对于监视窗口,我们无法看到完整的虚表结构,所以要通过一些手段:
using VFPTR = void(*)();
void Print_VTable(VFPTR vTable[]) {
std::cout << "虚表地址->" << vTable << std::endl;
for (int i = 0; vTable[i] != nullptr; ++i) {
std::cout << "第" << i << "个虚函数地址:" << vTable[i];
VFPTR f = vTable[i];
f();
}
std::cout << std::endl;
}
int main(void) {
Base b;
Derived d;
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
Print_VTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
Print_VTable(vTabled);
return 0;
}
这里的代码设计较为复杂,我将详细解释一下这样设计的原理:
首先是函数指针的声明,采用了C++11的新特性,使用了using,类似于typedef,所以这行代码也可以写成:
typedef void(*VFPTR)();
第一个括号表示是一个函数指针类型,其中VEPTR表示它的别名,第二个括号表示参数列表,void表示返回类型;
紧接着是打印函数的实现,其中参数列表表示一个由类似VFPTR函数组成的函数指针数组,随后遍历函数数组,打印其地址并对其进行调用,会执行相应的函数操作;
接下来的问题就是解决如何找到虚表的地址:
正如我们之前代码演示的那样,可以发现虚表一般都放在对象的前4个字节(因为指针是4个字节),而虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr,所以我们打印函数中参数是一个函数指针数组,它表示的其实就是一个虚表;
有了以上的思路,我们先是要获得对象的前4个字节:(int*)&b;
先对对象取地址,随后强转成(int*)类型,这里有人可能会疑惑:为什么一定要转成(int*),其他的指针不也是4个字节吗,这是因为我们还要对该内容进行解引用,以获取前4个字节,所以要转成(int*)类型;
((int)&b),而一旦解引用,表示所指向的内容就是一个虚表指针,指向虚函数表,接下来为了适应函数的参数列表,我们要对它进行再次的类型强转,(VFPTR*)((int*)&b),这样就满足了函数的参数类型要求;
下面就是实现的完整代码:
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
PrintVTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
PrintVTable(vTabled);
通过这一套完整的打印验证程序,最终就能验证说法的真伪;
#include <iostream>
class Base {
public:
virtual void Func1() {
std::cout << "Base::Func1()" << std::endl;
}
virtual void Func2() {
std::cout << "Base::Func2()" << std::endl;
}
private:
int _base = 0;
};
class Derived :public Base {
public:
void Func1() {
std::cout << "Derived::Func1()" << std::endl;
}
virtual void Func3() {
std::cout << "Derived::Func3()" << std::endl;
}
void Func4() {
std::cout << "Derived::Func4()" << std::endl;
}
private:
int _derived = 0;
};
using VFPTR = void(*)();
void Print_VTable(VFPTR vTable[]) {
std::cout << "虚表地址->" << vTable << std::endl;
for (int i = 0; vTable[i] != nullptr; ++i) {
std::cout << "第" << i << "个虚函数地址:" << vTable[i];
VFPTR f = vTable[i];
f();
}
std::cout << std::endl;
}
int main(void) {
Base b;
Derived d;
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
Print_VTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
Print_VTable(vTabled);
return 0;
}
结果如下(如果出现打印失败的情况,只需要清理一下解决方案即可)(因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题):
可以看到监视窗口看不到的完整虚函数表,可以发现其内部是否重写的具体情况;
多继承中的虚函数表
多继承的打印方法和单继承类似,原理都是通过对象的头4个字节找到对应的虚表地址,随后进行打印;
但是多继承不同于单继承,多继承对象不止有一个虚表,而且在之前的监视窗口中可以看到两个虚表地址相差的位置取决于继承的对象:
可以看到Base1和Base2相差4个字节,而这正是sizeof Base1的大小,所以我们要改变打印的具体实现:
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
std::cout << " 虚表地址>" << vTable << std::endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
std::cout << "第" << i << "个虚函数地址:" << vTable[i];
VFPTR f = vTable[i];
f();
}
std::cout << std::endl;
}
int main()
{
Derived d;
VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
PrintVTable(vTableb1);
VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d + sizeof(Base1)));
PrintVTable(vTableb2);
return 0;
}
打印函数不变,传入对象进行改变;
为什么第二十二行要强转成(char*)类型?这是因为字节相加,如果是int类型,会以4个字节相加减,导致未知错误,所以要强转成char类型;
结果如下: