目录
前言
终于来到了C++面向对象三大特性的最后一个——多态,多态是C++最重要的内容,它的内部包含十分多的细节,笔试面试也是十分容易被问到。
一、多态是什么
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时就会产生出不同的状态。
例如同样是买票,不同的人可能具有不同的行为,普通人是正常买票,学生是半价买票,军人是优先买票。
二、定义及实现
1、多态构成的条件
2、虚函数
虚函数就是使用virtual关键字所修饰的函数
并且只有成员函数才能够被声明为虚函数
多态是在继承的基础上所做的进一步的实现,所以要构成多态首先要有继承关系
我们还是以买票为例
class Person
{
public:
virtual void BuyTicket() {
cout << "全价买票" << endl;
}
};
class Student :public Person
{
public:
virtual void BuyTicket() {
cout << "半价买票" << endl;
}
};
class Soldier :public Person
{
public:
virtual void BuyTicket() {
cout << "优先买票" << endl;
}
};
这就是虚函数的重写
3、虚函数重写的条件
虚函数重写又叫做虚函数的覆盖
虚函数重写/覆盖条件:1、有虚函数,2、三同(函数名,参数,返回值)
我们会发现虚函数重写的条件与函数的隐藏关系十分的相似,所以隐藏的定义就是不符合重写的,就是隐藏关系(函数名相同)
上面的买票的例子就是构成了虚函数的重写,Student类和Solider这两个类分别重写了Person类的BuyTicket函数
但是C++又给虚函数重写添加了特例
特例1、子类虚函数可以不加virtual关键字,依旧构成重写,但是建议加上
特例2、重写的协变,父子返回值可以不同但是要求父子的返回值是表示父子关系的指针或者引用,这里的父子关系指针和引用,必须对应上,父类返回表示父类关系的指针或引用,子类返回表示子类的指针或引用
我们先来看一下特例1
没有报错
接下来看特例2
发现也没有报错
那么我们返回不同类的表示父子关系的指针或者引用呢?
class A
{};
class B : public A
{};
这里我们简单定义了两个类
这样发现没有报错,但是我们将父子返回值类型互换呢?
答案是不可以
4、多态的条件
我们前面说了那么多的虚函数重写的条件,接下来我们来说明一下构成多态的条件
1、虚函数的重写
2、父类指针或者引用去调用虚函数
我们先来看一下例子
class Person
{
public:
virtual void BuyTicket() {
cout << "全价买票" << endl;
}
};
class Student :public Person
{
public:
virtual void BuyTicket() {
cout << "半价买票" << endl;
}
};
class Soldier :public Person
{
public:
virtual void BuyTicket() {
cout << "优先买票" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
void test1()
{
Person ps;
Student st;
Soldier sd;
Func(ps);
Func(st);
Func(sd);
}
int main()
{
test1();
return 0;
}
先看结果
与我们的预期是一致的
接下来我们依次破坏多态的条件,分别观察现象
1、不是父类的指针或者引用
void Func(Person p)
{
p.BuyTicket();
}
多态失效了,出现这种现象的原因是因为是用子类对象去赋值父类对象,还记得我们前面所说的切片吗?在st对象和sd对象中内部都有一份Person类,切片的时候将st和sd对象中Person类的那部分赋值给p,所以调用的是父类的那个BuyTicket函数
2、不符合重写
(1)父类没有加virtual关键字
class Person
{
public:
void BuyTicket() {
cout << "全价买票" << endl;
}
};
(2)参数不同
class Person
{
public:
void BuyTicket() {
cout << "全价买票" << endl;
}
};
class Student :public Person
{
public:
virtual void BuyTicket(int) {
cout << "半价买票" << endl;
}
};
class Soldier :public Person
{
public:
virtual void BuyTicket(char) {
cout << "优先买票" << endl;
}
};
既然我们已经了解了构成多态的基本条件,那么我们看两道题
class A
{
public:
virtual void func(int val = 1){ std::cout<<"A->"<< val <<std::endl;}
virtual void test(){ func();}
};
class B : public A
{
public:
void func(int val=0){ std::cout<<"B->"<< val <<std::endl; }
};
int main(int argc ,char* argv[])
{
B*p = new B;
p->test();
return 0;
}
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
这道题的答案是B
首先new出来了一个B对象,用指针p来指向,然后调用test函数因为test函数在A对象内部,所以test函数在调用func函数时是父类的指针去调用虚函数,这里是符合多态调用的,又因为该对象类型是B,所以应该调用的是B对象内部的func函数,所以答案被缩小到B和D选项了
很多人都会选D,但是答案是B,并且很多人选完了并不能说明为什么是B
这里引入一个概念,虚函数重写是接口继承,普通函数继承是实现继承
什么意思呢?
就是说虚函数重写时,子类会直接将父类的虚函数的声明拿下来,在子类内部实现虚函数内容的重写,而普通函数是会将父类函数的名字拿下来,接口和实现都是子类重新写的
所以选择B
三、多态原理
首先说明一下当前测试平台是visual studio2022 32位平台下测试
我们先看简单的情况只有一个类,这个类没有被继承,并且定义一个虚函数
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
void test2()
{
cout << sizeof(Base) << endl;
}
我们看看这个类的大小是多少?
答案是8,很多同学会说不对呀,Base中只有一个_b,大小怎么可能是8呢?
所以base中一定还会有其它东西,我们借助调试看一看
我们发现Base中还多了一个指针,这个指针就是虚函数表指针
我们可以通过内存窗口进一步查看这个指针所指向的内容
在内存窗口中我们发现编译器后面标出了Func,所以我们也就成功验证了虚函数表
我们要明确一点,对象中是没有虚表的,它的内部是有虚表指针,而虚函数会进入虚函数表
接下来我们让一个类去继承Base类,并且分别查看虚函数表
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
private:
int _d = 2;
};
void test2()
{
Base b;
Derive d;
}
我们发现父类和子类的虚表是不同的,而虚表中的内容是一样的,都是Func1的地址,这是因为我们的子类并没有 重写父类的虚函数,所以父类和子类的Func1都是同一个
接下来我们重写子类的Func1函数
我们发现虚表中的内容不同了,这个过程就是子类把父类的虚表继承下来,记住是继承下来,因为子类是继承父类,子类中是有一份父类的,父类中有虚表,子类直接继承下来,重写时将相应的虚函数的地址覆盖父类的虚函数地址就完成了重写
在调用时在虚函数表中拿到调用的虚函数的地址,然后调用就构成了多态
在底层看来都是一样的,利用父类的指针或者引用去调用时,发生切片,只保留了父类的那一部分,而虚函数表就在父类的那一部分,所以底层看来每个对象都只能看到虚函数表以及父类的成员变量,调用都是相同的,看似是调用同一个函数,实际上却是调用了不同的函数
原理总结
多态的本质原理,符合多态的两个条件
那么调用时,会到指定对象的虚表中找到对应的虚函数地址,进行调用
多态调用,程序运行时去指向对象的虚表中找到函数的地址,进行调用
不符合虚函数的条件,该函数就不会进入虚函数表,就是正常调用
我们看一下多态调用的汇编代码
运行时去指向对象的虚表中找到函数地址,进行调用
普通调用 ,编译链接时,确认函数地址,运行时直接调用
虚函数被编译完是存在公共代码段,并不是存储在虚表中
虚函数表中存的是函数地址,虚函数表本质是指针数组,它存储在常量区
父类对象和子类对象所指向的虚表不是同一个,如果没有完成虚函数的重写,那么父类对象和子类对象的虚表中存的是同一个虚函数的地址
构成多态的调用又称为运行时决议
运行时在虚函数表中寻找函数地址
不构成多态的普通调用称为编译时决议
在编译时每个函数的地址就确定了,运行时直接调用
因此不要随便定义虚函数,它会白白浪费空间,对象中会多出一个虚表指针,并且调用时变得更加复杂了
四、动态绑定与静态绑定
1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
五、接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实 现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成 多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
六、类中函数是否可以定义为虚函数问题
我们知道一个类中有很多函数,那么这些函数都是可以被定义为虚函数吗?
1、inline函数可以被定义为虚函
我们先要想一下inline函数的特点:在调用的地方展开,经常将较短调用频繁的函数声明为inline函数,inline函数是没有地址的,我们可能会这样想,inline函数都没有地址,它怎么放入到虚表中呢?这么想也有一定的道理,但是inline关键字是建议性关键字,编译器是可以不听取我们的建议的
没有报错
2、静态成员可以是虚函数吗?
答案是不可以,因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚表中。没有this指针,直接可以使用类型::成员函数
虚函数是为了实现多态,多态都是运行时去虚表中找,static成员函数都是在编译时决议,加virtual是没有价值的
3、构造函数可以是虚函数吗?
不可以,virtual函数是为了实现多态,运行时去虚表中找对应虚函数进行调用,对象中虚表指针都是构造函数初始化列表阶段才初始化的,构造函数虚函数是没有意义的
4、析构函数可以是虚函数吗?
可以并且建议基类的析构函数定义成虚函数
我们再举一个例子
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
}
};
我们先定义一个Person和Student对象
Person p;
Student s;
结果是没有问题的,但是我们如果这样做呢?
Person* ptr1 = new Person;
delete ptr1;
Person* ptr2 = new Student;
delete ptr2;
我们会发现析构函数的调用是错误的,第一个析构函数调用,清理Person类对象的,没有问题
但是第二个我们想要调用的是Student类的析构函数。
而这就与多态的思想不谋而合,但是这里有一个问题,析构函数没有参数和返回值,但是不同类的
析构函数的函数名不同?
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。
5、拷贝构造函数和operator=可以是虚函数吗?
拷贝构造是不可以的,因为拷贝构造函数也是构造函数,原因就与构造函数的原因相同
而operator=可以是虚函数,但是设定成为虚函数并没有什么意义
我们知道虚函数重写要满足三同,而operator=的返回值可以是协变,但是参数并不符合条件,除非我们让父类引用去赋值给子类,这样又会有其它的问题。
所以operator可以是虚函数但是没有什么意义。
七、C++11 override 和 fifinal
1、final关键字
我们先来说一下final关键字,final关键字在我们的继承篇章中说过,可以用来修饰类,使该类不能被继承,在这里final是用来修饰函数,定义不能被重写的函数
这个关键字说实话用处并不大,我们定义一个虚函数,就是要实现多态的,要不我们定义虚函数干什么?
2、override关键字
override关键字是加在子类虚函数的,是用来检查子类虚函数是否完成重写,没有完成重写就会报错。
class Car
{
public:
virtual void Drive() {}
};
class Benz : public Car
{
public:
virtual void Drive() override
{
cout << "舒适" << endl;
}
};
八、重载,重写(覆盖), 重定义(隐藏)对比
重载:两个函数在同一作用域,函数名相同,参数不同(类型,顺序,个数)
重写(覆盖):两个函数分别在基类和派生类的作用域,函数名/参数/返回值都必须相同(协变例外)
并且两个函数必须是虚函数
重定义(隐藏):两个函数分别在基类和派生类的作用域,函数名相同,两个基类和派生类的同名函数不构成重写就是重定义
九、抽象类
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口 类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生 类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承 。
简单来说抽象类就是包含纯虚函数的类,又叫它接口类
抽象类是不能实例化出对象的
class Car
{
public:
virtual void Drive() = 0;
};
抽象类一般是现实世界中没有对应的实体,才会定义
例如 植物,在现实世界中没有具体的实体。
为什么只有派生类重写纯虚函数才能实例化对象?
因为子类继承父类的纯虚函数,子类没有重写纯虚函数,子类中也包含着纯虚函数,有纯虚函数就不能实例化对象。抽象类也间接强制我们重写虚函数。
class Benz : public Car
{
public:
virtual void Drive()
{
cout << "豪华" << endl;
}
};
class BMW : public Car
{
public:
virtual void Drive()
{
cout << "舒适" << endl;
}
};
这时我们可以这样实例化对象
int main()
{
Car* ptr = new Benz;
ptr->Drive();
ptr = new BMW;
ptr->Drive();
return 0;
}
这样也就强制我们多态调用了
十、补充内容——更深层次理解多态原理
1、单继承下的多态
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价买票" << endl;
}
};
int main()
{
Person p1;
Person p2;
return 0;
}
我们可以看看同类型对象的虚表
我们发现同类型对象的虚表地址是相同的,也就是同一个类型的对象共用同一个虚表(虚表地址和内容相同)
接下来我们再定义一个Student类
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价买票" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "半价买票" << endl;
}
};
int main()
{
Person p1;
Person p2;
Student s1;
Student s2;
return 0;
}
然后观察p1和s1的虚表
不同类型对象的虚表不同,但是虚表中的内容可能相同,因为子类可能没有重写父类虚函数
class Student : public Person
{
public:
};
接下来我们去掉对父类虚函数的重写
我们发现虚表指针不同,但是虚表内容相同
不管是否完成重写,子类虚表和父类虚表都不是同一个虚表
接下来我们还是重写父类的BuyTicket函数,但是再给父类增加新的虚函数,再次观察虚表
class Person
{
public:
virtual void BuyTicket()
{
cout << "全价买票" << endl;
}
virtual void Func1()
{
cout << "Func1()" << endl;
}
};
这时我们发现虚函数都要进入虚表,子类中虚表存在两个部分,父类虚函数和子类虚函数
我们再添加子类虚函数
class Person
{
public:
virtual void BuyTicket()
{
cout << "Person::BuyTicket()" << endl;
}
virtual void Func1()
{
cout << "Person::Func1()" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "Student::BuyTicket()" << endl;
}
virtual void Func2()
{
cout << "Student::Func2" << endl;
}
};
我们发现在子类的虚表中,我们并没有发现新增的Func2,那么Func2真的没有进入虚表吗?
我们通过内存窗口进一步查看
我们进入到子类的虚表中,上下对比,发现内存窗口中第一行0x00ca1550就是BuyTicket函数
第二行0x00ca1546就是Func2函数,那么第三行0x00ca154b就是Func3函数了?
这只是一个地址我们并不能确定他就是Func3,我们只是猜想,
那么我们可以拿到虚函数表的地址,自己手动打印虚表,并且调用成员函数
因为我们定义的函数返回值都是void 并且是没有参数的,那么我们可以声明函数指针,来绕过类域直接调用。
typedef void (*VFPTR)();
这里声明是为了下面打印虚函数表做准备,因为虚函数表是函数指针数组,把这么多的东西混在一起并不好理解,这样声明之后就跟普通的数组一样了。
void PrintVFTable(VFPTR* table)
{
}
我们接下来就利用vs的特性,它会将虚表外的地址初始化为0,我们就利用这个特性来了遍历
void PrintVFTable(VFPTR* table)
{
for (size_t i = 0; table[i] != nullptr; i++)
{
printf("vft[%d]: %p->", i, table[i]);
table[i]();
}
printf("\n");
}
我们打印完地址,就直接调用函数,因为是函数指针,它的使用方法与函数名类似,并且不需要解引用。
接下来就是如何获取虚函数表的地址了,我们知道现在是32位操作系统,对象的前4个字节存放的虚表指针,我们就直接取对象地址的前4个字节就可以了,因为要取前4个字节我们可以让其转换为int*类型指针,解引用之后不就取到对象的前4个字节了吗?
PrintVFTable((VFPTR*)(*(int*)(&s1)));
我来说明一下这段代码,首先取到s1的地址,将其转化为int*再解引用就取到了虚表指针,但是我们当前的类型是int,而我们的PrintVFTable函数的参数是VFPTR*类型,所以需要我们最后强制类型转换一下。
接下来看结果
我们发现Func2果然被放到了s1的虚表中
2、多继承下的多态
我们先来一段代码
class Base1
{
public:
virtual void func1()
{
cout << "Base1::func1" << endl;
}
virtual void func2()
{
cout << "Base1::func2" << endl;
}
private:
int _b1 = 1;
};
class Base2
{
public:
virtual void func1()
{
cout << "Base2::func1" << endl;
}
virtual void func2()
{
cout << "Base2::func2" << endl;
}
private:
int _b2 = 2;
};
class Derive : public Base1, public Base2
{
virtual void func1()
{
cout << "Derive::func1" << endl;
}
virtual void func3()
{
cout << "Derive::func3" << endl;
}
private:
int _d = 3;
};
我们定义了三个类,Base1,Base2 , Derive,Derive分别继承于Base1和Base2,并且Base1和Base2分别有一份func1和func2,然后Derive重写了func1,并且自己新增了func3
首先我们计算一下Derive的大小
答案是20,我们先来计算Base1的大小,Base1中有_b1是四字节,有一个虚表指针是4字节,加到一块一共是8字节,Base2同理也是8字节,Derive = Base1 + Base2 + d
正好等于20,说明Derive中只有两个虚表指针,一个继承自Base1,一个继承自Base2
那么Derive自己的虚函数放到哪一个虚表中了呢?
我们还可以打印一下虚表,我们先打印Base1那一部分的虚表,这个比较简单,Base1是先继承的,所以它的虚表在Derive对象的前4个字节
PrintVFTable((VFPTR*)(*(int*)&d));
我们发现func3存放的第一个继承的虚表中了,所以当子类具有不止一个虚表时,它会将它自己的虚函数放到最先继承的类的虚表中
但是我们就想要看一看Base2的虚表的内容,怎么办呢?
有两种方法
1、跟前面差不多,我们先取到对象的地址,然后加上sizeof(Base1)个字节,再转换,跟前面差不多
PrintVFTable((VFPTR*)(*(int*)((char*)&d + sizeof(Base1))));
第二种方法
利用切片就直接到Base2的起始地址了,剩下的就跟取Base1的虚表一样了
Base2* ptr = &d;
PrintVFTable((VFPTR*)(*(int*)(ptr)));
然后我们同时打印Base1的虚表和Base2的虚表
发现了一个诡异的事情,这两个函数是同一个函数为什么它们的地址不同呢?
我们再同时直接取func1的地址,然后查看
这时更加奇怪的现象出现了,它们三个的地址都不相同。
接下来我们通过反汇编来查看到底发生了什么,其实它们调用的都是同一个函数,不过是多加了几层包装 而已
我们通过内存窗口,分别查看直接调用,通过Base1类型指针和Base2类型指针调用出现的结果
发现,它们最后都是要到直接调用的那个地址。
十一、菱形继承、菱形虚拟继承
实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的 模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看 了,一般我们也不需要研究清楚,因为实际中很少用。
我们简单的看一个例子
class A
{
public:
virtual void func1()
{
cout << "A::func1()" << endl;
}
public:
int _a;
};
class B : virtual public A
{
public:
virtual void func1()
{
cout << "B::func1()" << endl;
}
virtual void func2()
{
cout << "B::func2()" << endl;
}
public:
int _b;
};
class C : virtual public A
{
public:
virtual void func1()
{
cout << "C::func1()" << endl;
}
virtual void func2()
{
cout << "C::func2()" << endl;
}
public:
int _c;
};
class D : public B, public C
{
public:
virtual void func1()
{
cout << "C::func1()" << endl;
}
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
这是继承那篇文章写过的菱形虚拟继承的例子
我们还是通过调试窗口看看,它到底有多么复杂
这时我们会发现它有三张虚函数表,并且它还会有虚基表来存储A类中它们所共有的成员
成员变量前面的是虚基表,用来计算,当前部分距离公共成员的偏移量
最后存放的是公共的a,倒数第二行存放的是虚函数表
总结
以上就是今天要讲的内容,本文仅仅简单介绍了多态的用法和原理。