《C++多态入门:轻松理解虚函数与多态编程》
////// 欢迎来到 aramae 的博客,愿 Bug 远离,好运常伴! //////
博主的Gitee地址:阿拉美 (aramae) - Gitee.com
![]()
时代不会辜负长期主义者,愿每一个努力的人都能达到理想的彼岸。
目录
多态的概念
多态的基础:
虚函数
虚函数的继承
虚类/虚基类
虚函数重写/覆盖
多态的定义及实现:
多态的条件:
其他的多态行为
多态中子类可以不写virtual
协变
继承遗留问题解决
析构函数
C++11 override和final
final
功能1:禁用继承
使用场景:
编辑
功能2:禁用重写
使用场景
override
场景:
描述:
用法:
重载、覆盖(重写)、隐藏(重定义)的对比
纯虚函数
概念
特点
纯虚函数 vs 普通虚函数
抽象类/纯虚类
概念
特点
接口继承和实现继承
多态原理
引入(多态的原理)
虚函数表指针(虚指针)
虚函数表/虚表
描述:
虚表的特性(单继承)
虚表的一般示例
对象中的虚表指针在构造函数中初始化
虚表的位置
引言: 本章节讲解C++中的多态(Polymorphism),这是一个非常重要的核心概念,也是面向对象编程的三大特性(封装、继承、多态)之一
多态的概念
- 多态的核心是:同一个接口,不同的实现。通过基类接口操作派生类对象,实现“一个接口,多种方法”。
多态的基础:
虚函数
- 在函数前面加上virtual就是虚函数
class A {
public:virtual void func() {};//这是一个虚函数
};
虚函数的继承
- 虚函数的继承体现了接口继承
- 继承了接口等于继承了函数的外壳,即 (返回值类型,函数名,参数列表,还有缺省参数)
- 只需要重写函数体(即具体的函数功能),覆盖/重写原来的接口
虚类/虚基类
- 虚类:含有虚函数的类是虚类
- 虚基类:虚拟继承体系中的基类
注意区分
虚函数重写/覆盖
条件:三同
---> 返回值类型,函数名,参数类型(这里和缺省参数的值无关,只和类型有关)
概念:虚函数重写是体现在继承/多态体系中,该函数是虚函数(其必须在父类中声明),同时在满足三同的条件下,子类的函数会替换掉继承下来的父类虚函数的函数体(重新实现)
- 体现接口继承
- 重写/覆盖只有虚函数才有,非虚函数可能是隐藏/重定义(注意区分)
- 重写/覆盖只修改函数体(其返回值类型,函数名,参数类型不变)
- 只要子类函数满足上述三同条件,即构成重写/覆盖,无论是否修改函数体
多态的定义及实现:
多态的条件:
继承中要构成多态有两个条件(缺一不可)
- 1.必须通过基类的指针或者引用调用虚函数
- 2.被调用的函数必须是虚函数,且子类必须对从父类继承来的虚函数进行重写
class Person {
public:virtual void BuyTicket() { cout << "成人 -> 全票" << endl; }; //是虚函数
};class Student :public Person{
public:virtual void BuyTicket() { cout << "学生 -> 半票 " << endl; };//对虚函数重写
};void func(Person& p) { //父类的指针或引用去调用p.BuyTicket();
}int main() {Person p;Student s;func(p);func(s);return 0;
}
其他的多态行为
多态中子类可以不写virtual
多态中子类可以不写virtual,而且只要父类是虚函数,之后继承的子孙类都是虚函数(待验证,是否位于虚表)
class Person {
public:virtual void BuyTicket() {std::cout << "全票" << std::endl;}
};class Student :public Person {
public:void BuyTicket() {std::cout << "半票" << std::endl;}
};class Children : public Person {
public:void BuyTicket(){std::cout << "三折票" << std::endl;}
};void func(Person& p){p.BuyTicket();
}int main()
{Person p; Student s; Children c;func(p); func(s); func(c);return 0;
}
-
说法1:体现接口继承:继承了接口==继承了函数的壳,只需要重写接口的实现(函数体),这样就是体现了接口继承
-
说法2: 可能存在父类,子类不是同一个人实现的情况.
假设子类必须是虚函数才能实现多态,如果父类是虚函数,而另外一个人写子类时忘记加上virtual,这是就有可能发生内存泄露问题,如切片后再析构的情况(只析构父类,不析构子类).
因此,父类是虚函数的情况下,子类不强制需要virtual才能发生多态这种行为,能有一定的安全作用.
缺点:没有统一规范. 最好还是全都加上virtual
协变
协变(Covariant) 是C++中虚函数返回类型的一个特殊特性,允许子类重写虚函数时返回更具体类型的指针或引用。协变场景下三同中返回值可以不同,且返回值必须是父类或子类关系的指针或引用
简单来讲就是,父类虚函数返回父类类型指针/引用,子类虚函数返回子类类型指针/引用;
各返回各类型的指针/引用
//返回对应类型引用
class Person {
public:virtual Person& BuyTicket() {std::cout << "全票" << std::endl;Person p;return p;}
};class Student :public Person {
public:Student& BuyTicket() {std::cout << "半票" << std::endl;Student s;return s;}
};void func(Person& p) { //父类的指针或引用去调用p.BuyTicket();
}
//返回对应类型指针
class Person {
public:virtual Person* BuyTicket() {std::cout << "全票" << std::endl;Person p;return nullptr;}
};class Student :public Person {
public:Student* BuyTicket() {std::cout << "半票" << std::endl;Student s;return nullptr;}
};
void func(Person& p) { //父类的指针或引用去调用p.BuyTicket();
}
总之,当虚函数返回值为基类类型的指针或引用时,编译器才会检查是否是协变类型.此时如果派生类虚函数返回值是基类或派生类的指针或引用,则判定为协变;否则不是协变
扩展:C++协变(covariant)-CSDN博客
继承遗留问题解决
析构函数
先来看一下继承关系中析构函数的实例:
//析构函数
class Person {
public:~Person() { std::cout << "~Person()" << "\n"; }
};class Student :public Person {
public:~Student() { std::cout << "~Student()" << "\n"; }
};int main() {Person per;Student stu;return 0;
}
可以看到这里的析构是没有问题的
再来看一下指针切片:
int main() {Person* ptr1 = new Person;Person* ptr2 = new Student;delete ptr1;delete ptr2;return 0;
}
显然,这里并没有正确的析构
- 结果说明对切片后的对象进行析构时,只会执行对应切片类型的析构函数.
继承体系中析构函数会被重命名成Destructor.
本意:根据指针(引用)指向的对象类型来选择对应的析构函数
结果:根据指针(引用)的类型的来选择对应的析构函数
虽然结果符合正常语法,但是我们在这种情况下并不希望是这样,我们希望它是根据指针(引用)指向的对象类型来选择对应的函数执行.
而根据指针(引用)指向的对象类型来选择对应的函数,这正好就是多态的理念.
因此,为了解决切片中这样的析构函数问题,我们选择将其转化成多态来解决.
此时我们已经满足多态构造的2个条件的其中之一:基类的指针或引用, 剩下的我们需要满足派生类的析构函数构成对基类析构函数的重写。而重写的条件是:返回值类型,函数名,参数列表都相同。对于析构函数,目前还缺的就是函数名相同,因此,析构函数的名称统一处理为destructor.(当然这个处理步骤并不需要我们来完成)
具体解决方式:
析构函数都成为虚函数
class Person {
public:virtual ~Person() { std::cout << "~Person()" << "\n"; }
};class Student :public Person {
public:virtual ~Student() { std::cout << "~Student()" << "\n"; }
};int main() {Person* ptr1 = new Person;Person* ptr2 = new Student;delete ptr1;delete ptr2;return 0;
}
C++11 override和final
final
功能1:禁用继承
C++11中允许将类标记为final,继承该类会导致编译错误.
用法:直接在类名后面使用关键字final
class A final
{};class B : public A //编译错误
{};
使用场景:
明确该类未来不会被继承时,可以使用final明确告知.
功能2:禁用重写
C++中还允许将函数标记为final,禁用子类中重写该方法
用法:在函数体前使用关键字final
class A {
public:virtual void func() final {}
};class B : public A {
public:void func() {} //编译错误
};
使用场景
一般情况下,只有最终实现的情况下会使用final: 当你在一个派生类中实现了某个虚函数,并且认为这是该函数的“最终”或“最完善”的实现,不希望后续的派生类再次改变其行为。使用final
关键字可以确保这一点,防止函数被进一步重写。
对虚函数使用final后,编译器可以做出一些优化,比如内联调用,因为它知道不会有其他版本的函数存在。
override
场景:
C++对函数重写的要求是比较严格的.如果某些情况因为疏忽而导致函数没有进行重写,这种情况在编译期间是不会报错的,只有程序运行时没有得到预期结果才可能意识到出现了问题,等到这时再debug已经得不偿失了.
因此,C++11提供了override关键字,可以帮助用户检测是否完成重写
描述:
override(覆盖)关键字用于检查派生类虚函数是否重写了基类的某个虚函数,如果没有则无法通过编译。
用法:
在需要进行重写的虚函数的函数体前或参数列表花括号后加上override
class A {
public:virtual void func() {}
};class B : public A {
public:void func(int i) override{ }
};
重载、覆盖(重写)、隐藏(重定义)的对比
纯虚函数
概念
在虚函数后面写上=0,这个函数就为纯虚函数.
virtual void fun() = 0;
纯虚函数只能写声明,不能写函数体.
特点
-
纯虚函数只能在基类中声明(使用
= 0
) -
派生类中重写纯虚函数时,不再是纯虚函数
-
派生类提供的是具体实现
纯虚函数 vs 普通虚函数
特性 | 纯虚函数 | 普通虚函数 |
---|---|---|
语法 | = 0 | 无特殊标记 |
实现要求 | 派生类必须实现 | 派生类可选实现 |
类性质 | 使类成为抽象类 | 不影响类性质 |
实例化 | 不能创建实例 | 可以创建实例 |
用途 | 定义接口规范 | 提供默认实现 |
抽象类/纯虚类
概念
含有纯虚函数的类是纯虚类,更多的是叫抽象类(也叫做接口类)
class A{virtual void func() = 0;
};
特点
-
抽象类不能实例化对象
-
抽象类的派生类如果不重写纯虚函数,则还是抽象类
-
纯虚函数规范了派生类必须重写,更体现接口继承
-
纯虚类可以有成员变量
接口继承和实现继承
从类中继承的函数包含两部分:一是"接口"(interface),二是 "实现" (implementation).
- 接口就是函数的"壳",是函数除了函数体外的所有组成
- 实现就是函数的函数体.
纯虚函数 => 继承的是:接口 (interface)
普通虚函数 => 继承的是:接口 + 缺省实现 (default implementation)
非虚成员函数 => 继承的是:接口 + 强制实现 (mandatory implementation)
- 普通函数的继承是一种实现继承,派生类继承了基类函数,继承的是函数的实现,目的是为了复用函数实现.
- 普通虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口+缺省实现,目的是为了重写,达成多态.
- 纯虚函数只继承了接口,要求用户必须要重写函数的实现.
如果不实现多态,不要把函数定义成虚函数。
多态原理
引入(多态的原理)
下面我们来计算一个虚类的大小:
//这里我们统一采用64位系统
#include<iostream>
using namespace std;class Base {
public:virtual void func() {}//->函数放在代码段
private:int _a; // -> 4字节char _b; // -> 1字节
};//->根据结构体内存对齐的大小计算规则 , 4 +1 +3填充 = 8int main(int argc, char* argv[])
{std::cout << sizeof(Base) << "\n";return 0;
}
如果是一般的类,那我们会认为是计算结构体对齐之后的大小,结果应当是8.
但计算结果发现,虚类的结果是16,说明虚类比普通类多了一些东西.
实例化对象Base b;
查看监视窗口
可以发现对象的头部多了一个指针_vfptr
;这个指针叫做虚函数表指针,它指向了虚函数表
虚函数表指针(虚指针)
指向虚表的指针,叫虚函数表指针,位于对象的头部.
定义:
如果在类中定义了虚函数,则对象中会增加一个隐藏的指针,叫虚函数表指针__vfptr,虚函数表指针在成员的前面,直接占了4/8字节.
那么,这里我们再重新计算一下, 8(虚指针大小)+ 4 + 1 +3 (填充) = 16;
虚函数表/虚表
描述:
虚函数表指针所指向的表,叫做虚函数表(virtual function table),也叫做虚表
虚函数表本质是一个虚函数指针数组.元素顺序取决于虚函数的声明顺序.大小由虚函数的数量决定.
虚表的特性(单继承)
-
虚表在编译期间生成.
虚表是由虚函数的地址组成,而编译期间虚函数的地址已经存在,因此能够在编译期间完成.
-
虚函数继承体系中,基类先生成一份虚表,之后派生类自己的虚表都是基于从父类继承下来的虚表.
-
特例,为了方便使用,VS在虚表数组最后面放了一个nullptr.(其他编译器不一定有)
- 子类会继承父类的虚函数表(开辟一个新的数组,浅拷贝)
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数,如果子类没有重写,则虚函数表和父类的虚函数表的元素完全一样
- 派生类自己新增加的虚函数,从继承的虚表的最后一个元素开始,按其在派生类中的声明次序增加到派生类虚表的最后。
- 派生类自己新增的虚函数放在继承的虚表的后面,如果是基类则是按顺序从头开始放,总而言之,自己新增的虚函数位置一定比继承的虚函数位置后
- 虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中.另外对象中存的不是虚表,存的是虚表指针
- 虚表是在编译阶段就完成了,在初始化列表完成的是虚表指针的初始化
- 同一类型直接定义的对象共享同一个虚表
- 子类对象直接赋值给父类对象后就变成了父类对象,只拷贝成员,不拷贝虚表,虚表还是父类的
虚表的一般示例
class Person {
public:virtual void BuyTicket(int val = 1) {std::cout << "全票" << ":" << val << "\n";}virtual void func(int val = 1) {std::cout << "全票" << ":" << val << "\n";}
};class Student :public Person {
public:void BuyTicket(int val = 0) { //覆盖std::cout << "半票" << "=" << val << "\n";}
};int main() {Person p;Student s;return 0;
}
对象中的虚表指针在构造函数中初始化
class Base {
public:Base() :_a(0){}virtual void func() {}private:int _a;
};class Driver :public Base {};int main(int argc, char* argv[])
{Base b;return 0;
}
注:虚表指针和成员谁先初始化由编译器决定
虚表的位置
虚表没有明确说必须在哪里,不过我们可以尝试对比各个区的地址,看虚表的大致位置
class Base{
public:virtual void func(){}
private:int _a;
};class Derive :public Base {
};int main()
{Base b;Derive d;int x = 0;int *y = new int;static int z = 1;const char * str = "hello world";printf("栈对象地址: %p\n",&x);printf("堆对象地址: %p\n",y);printf("静态区对象地址: %p\n",&z);printf("常量区对象地址: %p\n",str);printf("Base对象虚表指针: %p\n",*(int**)(&b)); //32位环境printf("Derive对象虚表指针:%p\n",*(int**)(&d)); return 0;
}
根据地址分析,虚表指针与常量区对象地址距离最近,因此可以推测虚表位于常量区.
另外,在监视窗口中观察虚表指针与虚函数地址也可以发现,虚表指针与虚函数地址也是比较接近,也可以大致推测在代码段中.(代码段常量区很贴近,比较ambiguous,模棱两可的)
从应用角度来说,虚表也应当位于常量区中,因为虚表在编译期间确定好后,不会再发生改变,在常量区也是比较合适的.
谈谈对象切片
我们可以使用子类对象给父类类型赋值,但要注意C++中不支持通过对象切片实现多态.
首先赋值过程会涉及大量拷贝.成本开销比较大.
其次,拷贝只拷贝成员,不会拷贝虚表.
因为子类中继承的自父类的虚表可能被子类覆盖过,如果切片给父类对象,那么父类对象的虚表中就会有子类重写的虚函数,显然不合理.
谈谈多态的原理
多态是怎么实现的,其实程序也不知道自己调用的是子类还是父类的,在它眼里都是一样的父类指针或引用.
如果是虚函数,则在调用时,会进入到"父类"中去,找到虚函数表中的函数去调用,是父类的就调用父类的,是子类就调用子类的.如果不是虚函数,则直接调用.
多态的实际原理也是传什么调什么,编译期间虚函数表已经确定好了
再看多态的两个条件
为什么需要虚函数重写,虚表中存的就是子类的虚函数,重写后就和父类不同了,也就能实现多态的效果.
为什么需要父类的指针或引用,就是因为指针或引用既能指向父类也能指向子类,能够实现切片,区分父类和子类
虚函数覆盖这个词的由来就是,子类重写的虚函数会覆盖父类的.
覆盖是原理层的叫法.重写是语法的叫法
虚表打印
例一:
class Person {
public:virtual void BuyTicket(int val = 1) {std::cout << "全票" << ":" << val << "\n";}virtual void func(int val = 1) {std::cout << "全票" << ":" << val << "\n";}
};class Student :public Person {
public:void BuyTicket(int val = 0) {std::cout << "半票" << "=" << val << "\n";}virtual void Add(){std::cout<<"Studetn"<<"\n";}
};class C : public Student {
public:virtual void Add(){std::cout<<"C"<<"\n";}int _c = 3;
};void fun(Student &s){s.Add();
}int main() {Person p;Student s;C c;fun(c);return 0;
}
对上例函数查看VS监视时,发现虚表不显示完全
需要在监视窗口中手动输入(void**)0x虚函数表指针,10
,表示以(void*)[10]
方式展开
此后就能全部显示虚表了
程序打印虚表
例二:源码
(仅适用VS,因为VS会将虚表末尾置空,如果是g++,则需要明确虚表有几个虚函数)
class A {
public:virtual void fun1(){std::cout<<"func1()"<<"\n";}virtual void fun2(){std::cout<<"func2()"<<"\n";}
};class B :public A {
public:virtual void fun3(){std::cout<<"func3()"<<"\n";}
};using VFPTR = void(*)(void);
void PrintVFTable(VFPTR table[])
{for (int i = 0; table[i]; i++){//1.打印虚类对象的虚表printf("%p",table[i]);//2.指针不够直观的情况下.可以执行函数指针得到更具象的结果VFPTR f = table[i];f(); //小细节:f()能够正常执行,说明这样的调用方式能够自动将虚表所在对象的this传参到虚函数中.}
}int main()
{A a;B b;PrintVFTable((VFPTR*)(*((VFPTR*)&a))); //方式1 (修改,VFPTR*比int*更通用)puts("");PrintVFTable(*(VFPTR**)&b); //方式2 ,在明确指向逻辑的情况下,二级指针更为简洁/* 代码理解:1.typedef和using语法层面功能都是将类型重命名,这个重命名会被认定成一个新类型,需要时再进行解释.2.int*在32位和64位下解引用都是4字节.而指针大小在32位下是4字节,64位下是8字节.在64位机器下使用int*解引用的话,就会得到错误的结果.因此int*不够普遍.3.VFPTR被当作一个新类型来看待.直接使用VFPTR时,编译器认为是非指针变量;使用VFPTR*时,编译器认为是一级指针变量.(VFPTR*)&a即为将a的地址转成类型为VFPTR的一级指针.之后,解引用则以VFPTR的大小为步长,取出相应的数据(虚表指针,也是虚表首地址).VFPTR实际类型为函数指针,32位下为4字节,64位下为8字节,因此解引用后能够取得正确的结果. */return 0;
}
多继承虚表
先看虚函数多继承体系下内存布局
class Base1 {
public:virtual void func1() { std::cout << "Base1::func1" <<std::endl; }virtual void func2() { std::cout << "Base1::func2" << std::endl; }
private:int b1 = 1;
};class Base2 {
public:virtual void func1() { std::cout << "Base2::func1" << std::endl; }virtual void func2() { std::cout << "Base2::func2" << std::endl; }
private:int b2 = 1;
};class Derive : public Base1, public Base2 {
public:
//子类重写func1virtual void func1() { std::cout << "Derive::func1" << std::endl; }
//子类新增func3virtual void func3() { std::cout << "Derive::func3" << std::endl; }
private:int d1 =2;
};int main()
{Derive d;return 0;
}
简单分析可知,虚函数多继承体系下派生类会根据声明顺序依次继承父类.继承方式类似于虚继承
多继承下子类自己新增的虚函数在哪?
我们知道,单继承中,子类自己新增的虚函数会尾插到虚表的末尾.
那么多继承呢?是每个父类都添加?还是只添加到其中一个?添加到一个的话添加到哪里?
要知道结果,必须要看一眼虚表的真实情况.因此我们打印所有虚表看看情况.
int main()
{Derive d;/*打印d中Base1的虚表*/std::cout<<"Base1的虚表"<<"\n";PrintVFTable(*(VFPTR**)(&d));puts("");/*打印d中Base2的虚表*/std::cout<<"Base2的虚表"<<"\n";//方法1,手动计算指针偏移//PrintVFTable((VFPTR*)*(VFPTR*)((char*)&d+sizeof(Base1)));//PrintVFTable(*(VFPTR**)((char*)&d+sizeof(Base1)));//方法2,切片,自动计算指针偏移 -- 推荐,不容易出错Base2 *b2 = &d;PrintVFTable(*(VFPTR**)b2);return 0;
}
结论与发现:
- 通过结果能证明,子类自己新增的虚函数只会添加进第一个继承的父类的虚表中,也就是尾插.
- 子类会继承所有父类的虚表,有多少个父类就有多少个虚表
-
结果也证明,子类重写会对所有父类的同名函数进行覆盖
-
观察结果还发现,两个func1的地址居然不一样.这其实涉及到C++this指针的原理问题->this指针修正.
要搞明白是什么情况,我们需要观察汇编代码,去看更深层次的逻辑.(这里涉及到汇编的就不往下深究了)
其他一些概念:
动态绑定和静态绑定
-
静态绑定又称为前期绑定(早绑定),在程序编译期间就确定了程序的行为,即编译时,也称为静态多态.
静态多态例子:函数重载,如
std::cout<<
的类型自动识别,原理就是函数名修饰规则将operator<<(不同的参数)在编译时生成多份(都是生成多份,C语言需要程序员手动,C++由编译器自动生成),使传的参数不同时能够对外表现出不同的行为.这种技术给开发者和用户都带来了使用上的便利. -
动态绑定也称为后期绑定(晚绑定),是在程序运行期间,即运行时,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态.虚函数多态就是动态多态.
内联函数inline 和 虚函数virtual
inline如果被编译器识别成内联函数,则该函数是没有地址的. 与虚表中存放虚函数的地址有冲突.
但事实上,inline 和 virtual 可以一起使用 :
-
这取决于使用该函数的场景:内联是一个建议性关键字,如果发生多态,则编译器会忽略内联.如果没有发生多态,才有可能成为内联函数
-
即:多态和内联可以一起使用,但同时只能有一个发生
静态函数static 与 虚函数
静态成员函数不能是虚函数,因为静态成员函数没有this指针,与多态发生条件矛盾
-
父类引用/指针去调用
-
static函数没有隐藏this参数.不满足虚函数重写条件"三同"
-
静态成员函数目的是给所有对象共享,不是为了实现多态
构造函数、拷贝构造函数、赋值运算符重载 与 虚函数
-
构造,拷贝构造不能是虚函数
- 构造函数需要帮助父类完成初始化,必须一起完成,不能像多态那样非父即子(父对象调父的,子对象调子的);
- 虚表指针初始化是在构造函数的初始化列表中完成的,要先执行完构造函数,才能有虚函数
- 构造函数多态没有意义
-
赋值运算符重载也和拷贝构造一样,不建议写成虚函数,虽然编译器不报错.
结语:感谢相遇
/// 高山仰止,景行行止。虽不能至,心向往之 ///