Hello大家好!很高兴我们又见面啦!给生活添点passion,开始今天的编程之路!
我的博客: <但凡.
我的专栏: 《编程之路》、《数据结构与算法之美》、《题海拾贝》、《C++修炼之路》
目录
C++三大特性:封装,继承,多态。今天我们来看第三个,多态。
1、多态
1.1、多态引入
什么是多态?简单来讲就是多种形态。 我们调用一个函数,但是产生了不同的结果,这就是多态。多态分为静态多态和动态多态。静态多态是编译时多态,我们之前的函数重载和函数模板都是静态多态。而今天我们主要来看动态多态,也叫运行时多态(以下简称多态)。
多态必须通过继承来实现,并且必须有两个重要条件(前提):
1、必须是基类的指针或者引用调用虚函数。
2、被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
下面我们来开一个具体的使用场景:
cpp
#include <iostream>
using namespace std;
// 基类 Shape
class Shape {
public:
// 虚函数 - 用于实现多态
virtual void draw(){
cout << "Drawing a generic shape" << endl;
}
};
// 派生类 Circle
class Circle : public Shape {
public:
virtual void draw(){ // 重写基类虚函数
cout << "Drawing a circle" << endl;
}
};
// 派生类 Square
class Square : public Shape {
public:
virtual void draw(){ // 重写基类虚函数
cout << "Drawing a square" << endl;
}
};
int main() {
// 创建不同形状的对象
Circle circle;
Square square;
Shape& c = circle;
Shape& s= square;
c.draw();
s.draw();
return 0;
}
以上代码可以体现出动态多态。我们在调用c.draw的时候,实际上走的不是基类的draw,而是派生类circle的draw,调用s.draw同理。为什么会调到这两个函数呢?因为我们满足多态的两个必要条件,就构成了多态。
首先基类和派生类的draw都进行了虚函数重写(都加了virtual关键字),并且我们访问函数的时候使用基类的引用访问的。 注意在这千万不要想着基类引用这不是切片吗?切片不应该调用派生类中基类那一部分吗?注意我们进行了虚函数重写,那么和之前普通函数走的就不是一个逻辑了。
在这里如果只有基类的draw函数加了virtual关键字,但是派生类没有加这个关键字,实际上也构成虚函数重写。但是这样写是不规范的,所以说我们尽量都加上virtual关键字。
1.2、虚函数
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。
虚函数重写/覆盖的条件是:派生类中必须有根基类完全相同的虚函数(即派生类虚函数类型,返回值,函数名字,参数列表完全相同),我们就称派生类重写了基类的虚函数。
现在我们看一道多态场景的选择题:
以下程序输出结果是什么()
A:A->0 B:B->1 C:A->1 D:B->0 E: 编译出错 F:以上都不正确
cppclass 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; }
老规矩先说结果,这道题选B。这道题其实还是有点复杂的,但是没关系我们一点一点来分析。
**首先这两个类中的虚函数构成重写。**因为我上面提到过就算派生类中的虚函数不写virtual关键字也是构成重写的。并且虚函数的重写并没有说参数的缺省值一定要相同。
然后,我们p调用test函数,test函数要通过this指针调用func函数, 问题就来了,调用的是哪个func函数?我们明确一点,B继承A并不是说A中的成员拷贝到了B中。所以说我们访问B中A的那一部分其实并不是用B*的this指针,而是A*的this指针。如果是A*this指针,但是这个指针指向的不是一个A类对象啊,而是一个B类指针。又因为这里的两个虚函数重写,涉及到多态而不是切片,满足多态的两个必要条件,所以调用的是B中的func。
接下来我们想为什么B中的func选项不选D而选B呢?这里我也不卖关子了,因为这就是语法固定想也想不到。**在多态中我们构成重写的两个虚函数,实际上是用的基类虚函数的声明,加上派生类虚函数的定义。**而我们val的值是跟着声明走的,所以说打印出来val的值应该是声明的值,也就是1.
1.3、协变
这是一种特殊情况,当我们派生类的虚函数返回值为基类对象的指针或引用,而派生类返回派生类对象的指针或引用(其他参数相同),此时两虚函数也构成重写,我们称之为协变。
两个返回值不一定是该基类或者该派生类的指针或引用,也可以是其他两个继承关系的基类和派生类的指针或引用。
cpp
#include <iostream>
using namespace std;
class A {};
class B : public A {};
class Person {
public:
virtual A* BuyTicket()
{
cout << "买票-全价" << endl;
return nullptr;
}
};
class Student : public Person {
public:
virtual B* BuyTicket()
{
cout << "买票-打折" << endl;
return nullptr;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(&ps);
Func(&st);
}
1.4、析构函数的重写
还记得上一篇我们说过,**析构函数在编译时底层都会把他处理成destructor,所以说这两个析构实际上是隐藏关系的。**当然隐藏是一个坑,但其实也没事,因为隐藏是基类的析构函数对于派生类对象隐藏了,我们派生类对象调不到父类的析构函数,不是特殊场景我们派生类对象也不会调到父类的析构函数。
但是下面这个情景是经常出现的,并且在面试题中也经常出现:
cpp
#include<iostream>
using namespace std;
class A
{
public:
~A()
{
cout << "~A()" << endl;
}
};
class B : public A {
public:
~B()
{
cout << "~B()->delete:" << _p << endl;
delete _p;
}
protected:
int* _p = new int[10];
};
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
现在以上情况是会造成内存泄露的问题的。 我们来分析一下,首先p1正常释放,没什么好说的,p2就不能正常释放了。因为p2是派生类B中基类A的那一部分的切片,他其实是调用不到B的析构的。
那怎么解决呢**?我们只需要在基类的析构函数前加上virtual关键字,让这两个关键字构成重写。这样的话我们就能通过多态解决这个问题。**
修改后:
cpp
#include<iostream>
using namespace std;
class A
{
public:
virtual ~A()
{
cout << "~A()" << endl;
}
};
class B : public A {
public:
~B()
{
cout << "~B()->delete:" << _p << endl;
delete _p;
}
protected:
int* _p = new int[10];
};
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
1.5、override和final
这两个关键字是C++11新增的。 override可以显式标记派生类中重写的虚函数,帮助编译器检查是否正确地重写了基类的虚函数。并且也可以提高可读性,让别人看代码的时候一眼就知道这是个重写基类的虚函数。
**final可以标记这个虚函数不要被重写。**其中override标记在派生类中,final标记在基类中。
cpp
class Base {
public:
virtual void foo() final; // 此函数不能被子类重写
};
class Derived : public Base {
public:
virtual void foo() override; //报错:无法重写final函数
};
void Base::foo()
{
cout << 1 << endl;
}
void Derived::foo()
{
cout << 2 << endl;
}
1.6、重写、重写、隐藏的对比
**重载必须要求两个函数在同一个作用域。这是和重写,隐藏根本上就不同的。**重载要求函数名相同,参数不同,参数的类型或者个数不同,返回值可相同可不同。
**重写/覆盖要求两个函数必须在不用的作用域,必须在继承体系的基类和派生类两个作用域中。**函数名,参数,返回值都必须相同(协变除外),缺省值可以不同。两个函数都必须是虚函数。
**隐藏也是必须在继承体系的基类和派生类两个作用域中。**要求函数名相同。并且隐藏不只是函数,成员变量名称相同也构成隐藏。
2、纯虚函数和抽象类
在虚函数后面写上=0,这个虚函数就是纯虚函数了。 纯虚函数不需要定义,因为定义了也没用。包含纯虚函数的类叫抽象类,抽象类不可以实例化出对象。
我们可以这样理解,纯虚函数和抽象类就是为多态而生的。他就是为了方便后面重写他的派生类们。
cpp
#include<iostream>
using namespace std;
class Base {
public:
virtual void foo()=0;
};
class Derived : public Base {
public:
virtual void foo() override;
};
int main()
{
Base b;//报错
return 0;
}
编译报错:

3、多态的原理
3.1、虚函数表指针
想想这个问题,一个包含虚函数的类的大小是多少呢?
cpp
#include<iostream>
using namespace std;
class A
{
public:
virtual void func();//4
private:
int _a;//4
char _b;//1
//12
};
void A::func()
{
cout << 1 << endl;
}
int main()
{
cout << sizeof A << endl;
return 0;
}
**执行以上代码,输出结果为12。因为对齐规则的原因,输出结果必须为4的整数倍。**所以说这串代码可能不能说明一个虚函数占多少个字节。那我们再测试一下:
cpp
#include<iostream>
using namespace std;
class A
{
public:
virtual void func();//4
private:
};
void A::func()
{
cout << 1 << endl;
}
int main()
{
cout << sizeof A << endl;
return 0;
}
输出结果为4。以上代码都是在32为系统下测试的,如果是64为系统虚函数大小为8。
不知道看到这大家有没有什么想法,4和8恰好是32位系统和64位系统下一个地址所占的字节大小。
不卖关子了直接告诉大家,实际上包含虚函数的基类(注意是基类)无论这个基类中有多少个虚函数,这些虚函数只额外多占4个字节。 因为这些虚函数其实都存在虚函数表中(又叫虚表),而虚表放在我们的基类中。对于这个基类来说,再多几个虚函数也是把这几个虚函数放到虚表中,类中只记录一个虚表的地址(__vfptr)。每个含有虚函数的类都至少有一个虚函数指针。
3.2、多态是如何实现的
**首先我们了解一个概念,动态绑定和静态绑定。静态绑定就是在编译时就确定了运行时需要调用的参数的地址,但是动态绑定是运行时才能确定需要调用的函数的地址,**在这里虚函数属于动态绑定。
那到底是什么流程实现的多态呢?
cpp
#include <iostream>
using namespace std;
// 基类 Shape
class Shape {
public:
// 虚函数 - 用于实现多态
virtual void draw(){
cout << "Drawing a generic shape" << endl;
}
};
// 派生类 Circle
class Circle : public Shape {
public:
virtual void draw(){ // 重写基类虚函数
cout << "Drawing a circle" << endl;
}
};
// 派生类 Square
class Square : public Shape {
public:
virtual void draw(){ // 重写基类虚函数
cout << "Drawing a square" << endl;
}
};
int main() {
// 创建不同形状的对象
Circle circle;
Square square;
Shape& c = circle;
Shape& s= square;
c.draw();
s.draw();
return 0;
}
在运行后,我们的c调用draw函数,由于c是Shape类,所以先去Shape中找到draw函数,检测到这个函数为虚函数,接着就会根据引用的对象circle去派生类中的虚表,查找draw函数。同理在s调用draw函数时,也是根据s引用的对象square到派生类中查找draw函数。
虽然都是一个Shape类型的变量去调用函数,但是根这个Shape类型的变量没什么关系,而是有这个变量引用的对象决定的调哪个函数。
3.3、虚函数表
虚函数表到底是个啥?虚函数是存在哪的?虚表是存在哪的?一个类有几个虚函数表?带着这些问题我们继续往下看。
虚函数表就是个函数指针数组。 既然他是个数组,那我们存的他的地址也就是4字节(32位)。他存放的每个函数指针指向每个虚函数。**一般情况下编译器会在这个数组最后放一个0x00000000标记。**但是根据编译器来定,vs系列编译器是有这个标记的,g++系列编译不会放。
接下来我们先解决一个类有几个虚函数表的问题。**首先如果是单继承(只继承一个基类)的情况下,基类有一个虚函数表,派生类有一个虚函数表。**派生类中的虚函数表存放的是派生类自己的虚函数,重写之后的基类的虚函数的地址,还有基类最原始的虚函数地址这三部分。而基类中的虚函数表存放的是最原始的虚函数地址。
如果是多继承的情况,大概率会继承几个基类,就有几个虚函数表。因为对于某个基类的虚函数是得单独维护的。而派生类自己的虚函数存放在其中一个虚表中。
虚函数是存在哪的呢?虚函数和普通函数一样,编译好是一段指令,存放在代码段(常量区)。注意普通函数不是存放在栈区的,而函数中定义的变量才是存放在栈区的。
虚函数表 是存放在哪里的呢?这个根据编译器来定,vs中是存放在代码段(常量区)的。
好了,今天的内容就分享到这,我们下期再见!