👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
目录
- 一、 多态的概念
- 二、多态的定义及实现
- 2.1 虚函数
- 2.2 虚函数的重写(覆盖)
- 2.3 多态的构成条件(重点)
- 2.4 多态构成条件的两个例外
- 2.5 析构函数的重写(面试常考)
- 三、override和final(支持C++11)
- 3.1 override
- 3.2 final
- 四、重载、覆盖(重写)、隐藏(重定义)的对比
- 五、多态原理
- 5.1 虚函数表
一、 多态的概念
多态是面向对象三大基本特征中的最后一个。概念:通俗来说,就是多种形态,具体点就是当不同的对象去完成某个行为,就会产生出不同的状态。
比如在购买高铁票时,成人原价,学生半价,而军人可以优先购票,对于购票这一相同的动作,需要根据不同的对象提供不同的方法
#include <iostream>
using namespace std;class Adult // 成人
{
public:virtual void Buyticket(){cout << "成人-原价" << endl;}
};class Student : public Adult
{
public:virtual void Buyticket(){cout << "学生-半价" << endl;}
};class Soldier : public Adult
{
public:virtual void Buyticket(){cout << "军人-优先" << endl;}
};void Buyticket(Adult& At)
{At.Buyticket();
}int main()
{Adult at;Student s;Soldier sd;Buyticket(at); // 成人Buyticket(s); // 学生Buyticket(sd); // 军人return 0;
}
【输出结果】
可以看到,不同对象调用同一函数,执行结果是不同
二、多态的定义及实现
2.1 虚函数
虚函数:即被virtual
修饰的类成员函数称为虚函数。
2.2 虚函数的重写(覆盖)
虚函数的重写(覆盖):子类中有一个跟父类完全相同的虚函数,即子类虚函数与父类虚函数的返回值类型、函数名字、参数列表完全相同,称子类的虚函数重写了父类的虚函数。
// 父类
class Adult // 成人
{
public:virtual void Buyticket(){cout << "成人-原价" << endl;}
};// 子类
class Student : public Adult
{
public:virtual void Buyticket(){cout << "学生-半价" << endl;}
};
2.3 多态的构成条件(重点)
在继承中要构成多态还有两个条件:
- 必须通过父类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且子类必须对父类的虚函数进行重写(返回值类型、函数名字、参数列表的类型完全相同)
注意:上述两个构成多态的条件缺一不可!缺少其中任意一个条件,都不构成多态!
// 父类
class Adult // 成人
{
public:virtual void Buyticket(){cout << "成人-原价" << endl;}
};// 子类
class Student : public Adult
{
public:// 子类必须对父类的虚函数进行重写virtual void Buyticket(){cout << "学生-半价" << endl;}
};// 1. 通过父类的指针或者引用调用虚函数
// void Buyticket(Adult* At) - 指针
void Buyticket(Adult& At) // 引用
{// 2. 被调用的函数必须是虚函数At.Buyticket();
}
2.4 多态构成条件的两个例外
- 例外一:子类虚函数可以不使用
virtual
修饰
虽然这个例外在语法上是支持的,但是建议不要省略,因为会破坏代码的可阅读性,可能无法让别人一眼看出多态。
- 例外二:协变(父类与子类虚函数返回值类型可以不同)
子类重写基类虚函数时,与父类虚函数返回值类型不同。即 父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,称为协变。
第一种:返回各对象的指针
class Adult // 成人
{
public:// 父类虚函数返回父类对象的指针virtual Adult* Buyticket(){cout << "成人-原价" << endl;return 0;}
};
// 子类
class Student : public Adult
{
public:virtual Student* Buyticket(){cout << "学生-半价" << endl;return 0;}
};
第二种:返回各对象的引用
class Adult // 成人
{
public:// 父类虚函数返回父类对象的引用virtual const Adult& Buyticket(){cout << "成人-原价" << endl;return Adult(); // 返回匿名对象 }
};
// 子类
class Student : public Adult
{
public:virtual const Student& Buyticket(){cout << "学生-半价" << endl;return Student();}
};
注意:父子类关系的指针/引用,不是必须是自己的,也可以是其他类的,但是要对应匹配子类和父类。
class A // 父类
{};class B : public A // 子类
{};class Adult // 成人
{
public:// 父类虚函数返回父类对象的引用virtual const A& Buyticket(){cout << "成人-原价" << endl;return A(); // 返回匿名对象 }
};
// 子类
class Student : public Adult
{
public:virtual const B& Buyticket(){cout << "学生-半价" << endl;return B();}
};
还有一点要注意的是,不可以一个是指针,一个是引用,必须同时是指针,或者同时是引用
2.5 析构函数的重写(面试常考)
有个问题:析构函数加上virtual
是不是虚函数重写?
答案:是。虽然父类与子类析构函数名字不同(不满足三重),看起来违背了重写的规,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
#include <iostream>
using namespace std;class A
{
public:virtual ~A(){cout << "~A()" << endl;}
};class B : public A
{
public:virtual ~B(){cout << "~B()" << endl;}
};int main()
{A a;B b;return 0;
}
【输出结果】
接下来就是面试官的连续“攻击”:为什么要这样处理呢?— 因为要构成重写
那么为什么要让它们构成重写呢?其实不加virtual
关键字也是可以的
但如果不对析构重写的话,那么下面有一个场景是过不了的(记住此场景)
#include <iostream>
using namespace std;class A // 父类
{
public:~A(){cout << "~A()" << endl;}
};class B : public A // 子类
{
public:~B(){cout << "~B()" << endl;delete[] ptr;}
protected:int* ptr = new int[3];
};int main()
{A* a = new A;delete a;a = new B;delete a;return 0;
}
【输出结果】
我们发现,不加virtual
没有调用子类的析构函数,发生了内存泄漏。那为什么没有调到子类的析构呢?第一次释放了a
指向的空间,然后又改变了指向
在前面说过,类的析构函数都被处理成了destructor
这个函数。而delete
对于自定义类型的原理是:
- 在空间上执行析构函数,完成对象中资源的清理工作
- 调用
operator delete
函数释放对象的空间(operator delete
本质就是调用free
函数)
即对于delete a
,先调用了析构函数a->destructor()
,然后再调用operator delete
函数释放对象的空间。
但由于编译器将析构函数名处理成一样的函数名destructor
,因此构成了隐藏/重定义了。而且a
刚好是A
类型的指针,是一个普通的调用,不是多态调用。对于普通调用,看的是当前者的类型。因此delete a
就会再次调用A
类的析构函数。
但我们想的是指向什么类型,就去调用对应的析构函数,因此这是就得用到多态了。多态调用:看的是其指向的类型,指向什么类型,调用什么类型。
- 因此,为什么要在 父类/基类 的析构函数中加上
virtual
修饰?
答案:为了构成多态,确保不同对象的析构函数能被成功调用,避免内存泄漏。
三、override和final(支持C++11)
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重写,而这种错误在编译期间是不会报错的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override
和final
两个关键字,可以帮助用户检测是否重写。
3.1 override
作用:修饰子类的虚函数,检查是否构成重写(是否满足重写的必要条件),若不满足,则报错
先以正确代码为例,override
要写在子类函数括号的后面
#include <iostream>
using namespace std;class A
{
public:virtual void Print(){cout << "class A" << endl;}
};class B : public A
{
public:virtual void Print() override{cout << "class B : public A" << endl; }
};void Print(A& a)
{a.Print();
}int main()
{A a;B b;Print(a);Print(b);return 0;
}
【输出结果】
以下故意在子类的虚函数加个参数(不构成三重:子类虚函数与父类虚函数的返回值类型、函数名字、参数列表的类型完全相同),看看是否会报错
3.2 final
作用:修饰父类的虚函数,不让子类的虚函数与其构成重写,即不构成多态
对父类的虚函数加上final
:无法构成重写
除此之外,final
在某些场景下很实用:final
还可以修饰父类,修饰后,父类不可被继承。
注:final
可以修饰子类的虚函数,因为子类也有可能成为父类;但override
无法修饰父类的虚函数,因为父类之上没有父类了,自然无法构成重写。
四、重载、覆盖(重写)、隐藏(重定义)的对比
面试题中也喜欢考这三者的区别
-
重载:即函数重载。在同一个作用域中,通过参数的类型、个数或顺序不同来定义多个具有相同函数名但不同参数列表的方法。重载方法在编译时根据调用的参数匹配最合适的方法。
-
重写(覆盖):发生在类中,当出现虚函数且符合重写的三同原则(函数名、参数列表的类型和返回类型必须与父类中的方法一致)时,则会发生重写(覆盖)行为
-
重定义(隐藏):发生在类中,当子类中的函数名与父类中的函数名起冲突时,会隐藏父类同名函数,默认调用子类的函数。如果想使用父类的同名成员,可以通过
::
指定调用。
重写和重定义比较容易记混,简言之 先看看是否为虚函数,如果是虚函数且三同,则为重写;若不是虚函数且函数名相同,则为重定义。注:在类中,仅仅是函数名相同(未构成重写的情况下),就能触发 重定义(隐藏)
五、多态原理
5.1 虚函数表
多态究竟是如何实现的?先来看一段简单的代码,同时也是一道笔试题。
#include <iostream>
using namespace std;class A
{
public:virtual void Test(){cout << "class A" << endl;}
};int main()
{A a; cout << "A sizeof:" << sizeof(a) << endl;return 0;
}
【输出结果】
上述代码仅仅只是一个空类,只有一个虚函数,而我们知道对象是不存储函数的。而这里连成员变量也没有,结果为什么是4呢?当把环境改为64位平台(x64),输出结果却是8
这样下来,大小随平台而变的只能是指针了,因此可以推测当前类中藏着一个虚表指针
如上图所示,对象中的这个指针我们叫做虚函数表指针(v
代表virtual
,f
代表function
)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
另外,从这里我们也可以知道:一般不使用多态的话,最好还是不要加上virtual
,因为是有开销的。
那么派生类中这个表放了些什么呢?我们接着往下分析
针对上面的代码我们做出以下改造
class A
{
public:virtual void Func1(){cout << "A::Func1()" << endl;}virtual void Func2(){cout << "A::Func2()" << endl;}void Func3(){cout << "A::Func3()" << endl;}protected:int _a = 1;
};class B : public A
{
public:virtual void Func1() override{cout << "B::Func1()" << endl;}
};int main()
{A a;B b;return 0;
}
在vs
的监视窗口中,可以看到只要涉及虚函数类的对象中都有属性__vfptr(虚表指针)
,可以通过虚表指针所指向的地址,找到对应的虚表。
- 子类对象
b
中也有一个虚表指针,b
对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。 - 父类
a
对象和子类b
对象虚表是不一样的,这里我们发现Test
完成了重写,所以b
的虚表中存的是重写的B::Test
,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。 - 另外
Func
继承下来后是虚函数,所以放进了虚表,Print
也继承下来了,但是不是虚函数,所以不会放进虚表。 - 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个
nullptr
。 - 总结一下子类的虚表生成:
①先将父类中的虚表内容拷贝一份到子类虚表中
②如果派生类重写了父类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数。
③子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。
这里还有很容易混淆的问题:虚函数存在哪的?虚表存在哪的?
答:很多人都认为虚函数存在虚表,虚表存在对象中。但是这是错的。
注意:虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是它的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。
那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的。