C++多态
Posted 山舟
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++多态相关的知识,希望对你有一定的参考价值。
文章目录
声明:本文中的代码及运行结果均是在32位平台下、VS2022中的测试结果。
概念
通俗来说就是多种形态,具体一些就是对于某个行为,当不同的对象去完成时会产生出不同的状态。
再进一步到C++中,就是函数调用的多种形态,这一特性可以让我们在调用函数时更加灵活。
多态又分为静态多态和动态多态:
- 静态多态:主要指函数重载。
- 动态多态:主要指父类的指针或引用调用、重写虚函数。如果父类的指针或引用指向父类,就调用父类的虚函数;如果父类的指针或引用指向某个子类,就调用那个子类的虚函数。
静态多条在前面讲过,本篇主要讲动态多态,从其定义中就可看出,这里和继承关系非常密切,所以如果对继承不太熟的读者,可以到【C++】继承 了解一下。
一、多态的定义及实现
1.多态的构成条件
多态是指不同继承关系的类对象,调用同一函数时产生了不同的行为。比如Student(学生)继承了Person(承认),Person对象在买票这一行为上需要全价,而Student对象买票半价。
在继承中要构成多态还有两个条件:
- 必须通过父类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,且派生类必须对父类的虚函数进行重写
2.virtual关键字和虚函数
在成员函数前加virtual关键字后,该函数就变为虚函数(注意一般的函数前不可以加virtual)。
示例代码如下:
class Person
public:
virtual void BuyTickets()
cout << "全价票" << endl;
;
virtual void func()
编译结果如下:
注意:
- 只有类的非静态成员函数才可以成为虚函数。
- 虚函数和虚继承都用到了virtual关键字,但二者之间没有任何关系。虚函数使用virtual是为了实现多态,而虚继承是为了解决菱形继承中产生的数据冗余和二义性问题,它们之间没有关联。
3.虚函数重写
派生类中有一个跟基类完全相同的虚函数,即派生类虚函数与基类虚函数的返回值类型、函数名、参数列表完全相同,这时称子类的虚函数重写了基类的虚函数。
示例代码如下:
class Person //父类
public:
virtual void BuyTickets()
cout << "全价票" << endl;
;
class Student : public Person //派生类继承父类
public:
virtual void BuyTickets() //虚函数重写
cout << "半价票" << endl;
;
void func(Person& p) //代码以引用为例,指针同理
p.BuyTickets(); // 调用"同一个"成员函数(实际不是同一个)
int main()
Person p;
Student s;
//父类的引用接收不同类型的对象,实现了多态
func(p);
func(s);
return 0;
运行结果如下:
上面代码中func函数的参数p是父类的引用,接收不同类型的对象时调用虚函数产生了不同的结果,如果将参数改为父类的指针也可以实现同样的效果,但是如果是父类的对象则不可以。
下面的代码中,func1以父类的引用为参数,func2以父类的指针为参数,func3以父类的对象为参数。
示例代码如下:
class Person //父类
public:
virtual void BuyTickets()
cout << "全价票" << endl;
;
class Student : public Person //派生类继承父类
public:
virtual void BuyTickets() //虚函数重写
cout << "半价票" << endl;
;
void func1(Person& p) //父类引用
p.BuyTickets(); // 调用"同一个"成员函数(实际不是同一个)
void func2(Person* p) //父类指针
p->BuyTickets();
void func3(Person p) //父类对象
p.BuyTickets();
int main()
Person p;
Student s;
//父类的引用接收不同类型的对象,实现了多态
func1(p);
func1(s);
cout << endl;
//父类的指针接收不同类型的对象,实现了多态
func2(&p);
func2(&s);
cout << endl;
//父类的对象接收不同类型的对象,无法实现多态
func3(p);
func3(s);
return 0;
很显然以父类的对象为参数无法实现多态,这也印证了最开始提到的,只有父类的引用或指针才可以实现多态。
运行结果如下:
而如果父类的函数都不是virtual修饰的虚函数,那么就更无法实现多态了,很容易理解,这里就不做演示了。
4.虚函数重写的三个例外
(1)协变
子类重写父类虚函数时,与父类虚函数仅有返回值类型不同,且父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,称为协变。
示例代码如下:
class Person //父类
public:
virtual Person* pointer() //返回值为Person*
cout << "Person* pointer()" << endl;
return new Person;
;
class Student : public Person //派生类继承父类
public:
virtual Student* pointer() //返回值为Student*
cout << "Student* pointer()" << endl;
return new Student;
;
int main()
Person p;
Student s;
Person* ptr;
ptr = &p;
ptr->pointer(); //调用父类的
ptr = &s;
ptr->pointer(); //调用子类的
return 0;
运行结果如下,可以看到虽然pointer()函数的返回值不同,但依然实现了多态。
(2)析构函数重写
由于析构函数的函数名是有要求的,所以从代码角度看,子类和父类析构函数的函数名不同,破坏了多态的要求。但实际上,在【C++】继承 提到过,编译器对所有析构函数的函数名都做了特殊处理,编译后析构函数的名称统一处理成destructor。
但是析构函数重写是必要的吗?
下面列举两种情况:
①不需要重写虚构函数
示例代码如下:
class Person //父类
public:
~Person()
cout << "~Person()" << endl;
;
class Student : public Person //派生类继承父类
public:
~Student()
cout << "~Student()" << endl;
;
int main()
Person p;
Student s;
return 0;
运行结果如下:
这时两个类中都没有什么需要在析构函数中处理的东西,所以不重写析构函数没有什么影响。
这里再解释一下打印的结果:临时变量储存在栈中,符合后进先出的规则,所以先析构s,后析构p。而析构s时根据继承的规则,先调用子类的析构函数、再调用父类的析构函数,这时s的析构结束了。之后再析构p。
②需要重写析构函数
示例代码如下:
class Person //父类
public:
~Person()
cout << "~Person()" << endl;
;
class Student : public Person //派生类继承父类
public:
~Student()
cout << "~Student()" << endl;
;
int main()
Person* p = new Person;
Person* s = new Student;
delete p;
delete s;
return 0;
运行结果如下:
从打印结果来看,s指针在delete时仅调用了父类的析构函数,而他在new时申请的是Student的空间,这样就可能造成内存泄漏。这种场景下就需要对Student类的析构函数进行重写。
下面代码将两个类的析构函数定义为虚函数,这样就可以达到析构的目的。
示例代码如下:
class Person //父类
public:
virtual ~Person()
cout << "~Person()" << endl;
;
class Student : public Person //派生类继承父类
public:
virtual ~Student()
cout << "~Student()" << endl;
;
int main()
Person* p = new Person;
Person* s = new Student;
delete p;
delete s;
return 0;
运行结果如下,通过重写析构函数,指针在delete时就可以释放对应的资源,不会出现内存泄漏的问题。
(3)子类的虚函数可以不用virtual修饰
如果父类的某一个函数已经定义为虚函数,那么子类与之相同函数不需要用virtual修饰,也认为是完成了重写。但实际上这也是C++的一个小坑,为了方便代码的维护,建议还是只要需要用到虚函数的位置全部都用virtual修饰。
5.final
修饰父类的虚函数,使该虚函数无法被重写。
示例代码如下:
class Person //父类
public:
virtual void BuyTickets() final
cout << "全价票" << endl;
;
class Student : public Person //派生类继承父类
public:
virtual void BuyTickets() //虚函数重写
cout << "半价票" << endl;
;
可以看到,在编译阶段就报错,子类中无法对父类的BuyTickets()进行重写。
编译结果如下:
6.override
修饰子类的虚函数,检查该虚函数是否重写,若没有重写则编译报错。
示例代码如下:
class Person //父类
public:
virtual void BuyTickets()
cout << "全价票" << endl;
;
class Student : public Person //派生类继承父类
public:
virtual void BuyTickets(int i) override//虚函数重写
cout << "半价票" << endl;
;
代码中子类的BuyTickets函数与父类的参数列表不同,没有构成重写,override直接在编译阶段就报错。
编译结果如下:
二、抽象类
1.概念
在虚函数的后面写上 “= 0” ,则这个函数为纯虚函数,包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
下图中用抽象类创建对象失败,证明抽象类无法实例化出对象。
2.接口继承和实现继承
普通函数的继承是一种实现继承,子类继承了父类函数,继承的是函数的实现。而虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。尤其是抽象类的产生,更是强制子类重写父类(否则子类也无法实例化出对象,类的功能大打折扣)。
三、多态的原理
1.引出虚函数表指针
定义一个如下的类,类成员有一个int类型的变量和一个char类型的变量,成员函数有一个虚函数,计算它的大小。
class A
public:
virtual void Func()
cout << "virtual void Func()" << endl;
private:
int _b = 0;
char _ch = '\\0';
;
int main()
A a;
cout << sizeof(a) << endl;
return 0;
根据自定义类型(一):结构体 中内存对齐的规则可得该类定义出的对象的大小为8字节,但下面的运行结果是12字节(运行环境是32位平台、VS2022,后文中不再解释)。
通过调试来看对象a中具体有什么,为什么它的大小会是12字节?
通过调试看到对象a中不仅仅有类中定义的两个变量,还有一个void类型的指针,这个指针是虚函数表指针**,对象a大小为12字节正是因为多了这一个指针变量。
只要类中有虚函数,就一定会存在虚函数表指针,它就是用来实现多态的。
2.虚函数表
一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表(本质是一个数组)中,虚表指针指向的就是这个数组的起始地址。
虚函数表也简称虚表,虚函数表指针简称为虚表指针。
如下代码的类中定义了两个虚函数,一个普通函数,观察通过它定义的对象中虚表指针的情况。
class A
public:
virtual void Func() //虚函数
cout << "virtual void Func()" << endl;
virtual void Func2() //虚函数
void Func3() //普通函数
private:
int _b = 0;
char _ch = '\\0';
;
int main()
A a;
cout << sizeof(a) << endl;
return 0;
可以看到,类中定义了两个虚函数,则对象中含有虚表指针指向的数组中有两个元素,而普通函数则没有对应的虚表指针。
再次提醒:多态和虚拟继承都用到了virtual关键字,但二者毫无关联。具体到这里,多态中的virtual定义虚函数,由此引出了虚函数表和虚函数指针;而虚拟继承中的虚基表存放的是虚基类的偏移量,二者同样无关,注意不要混淆。
3.多态的原理
再回到前面的代码,通过解释其逻辑来说明多态的原理:
class Person //父类
public:
virtual void BuyTickets()
cout << "全价票" << endl;
;
class Student : public Person //派生类继承父类
public:
virtual void BuyTickets()//虚函数重写
cout << "半价票" << endl;
;
int main()
Person p;
p.BuyTickets();
Student s;
s.BuyTickets();
return 0;
通过调试可以看到,p和s中的虚表指针指向的地址不同,也就是说两个类中定义的BuyTickets()不是同一个,所以在调用时调用各自定义的函数,实现了多态。
满足多态条件、构成多态后,父类的指针或引用调用哪个虚函数,不是在编译时确定的,而是运行到指定位置、需要从指针或引用指向的对象的虚表中找虚函数地址时才确定的。所以如果指向的是父类对象,则调用父类的虚函数;指向的是子类对象,则调用子类的虚函数。
但如果不满足多态条件,那么调用哪个函数是在编译是就可以确定的,调用函数的对象是什么类型,就调用哪个类型定义的虚函数,与传什么类型的参数无关。
四、静态绑定和动态绑定
- 静态绑定又称为前期绑定(早绑定),即在程序编译期间就确定了程序的行为,也称为静态多态,比如本文最开始提到的函数重载。
- 动态绑定又称后期绑定(晚绑定),即在程序运行期间根据具体的类型来确定程序的具体行为,并确定具体调用哪个函数,也称为动态多态。
感谢阅读,如有错误请批评指正
以上是关于C++多态的主要内容,如果未能解决你的问题,请参考以下文章