C++ 虚函数与多态性
虚函数
虚函数的唯一作用:实现类的 多态性
C++虚函数是定义在基类中的函数,子类必须对其进行覆盖,在类中声明虚函数的格式如下:
virtual void display();
C++提供多态的目的是:可以通过 基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,基类只能访问派生类的成员变量
多态性
面向对象程序设计中的多态性是指向不同的对象发送同一个消息,不同对象对应同一消息产生不同行为,多态性是指用一个名字定义不同的函数,这些函数执行不同但又类似的操作,这样就可以用同一个函数名调用不同内容的函数,也就是说,可以用同样的接口访问功能不同的函数,从而实现"一个接口,多种方法"的目的
事实上,在程序设计中经常会使用到多态性。最简单的例子就是运算符了,例如我们使用运算符+,就可以实现整型数、浮点数、双精度类型之间的加法运算,这三种类型的加法操作其实是互不相同的,是由不同内容的函数实现的。这个例子就是使用了多态的特征
在C++中,多态性的实现和 联编 (也称绑定)这一概念有关。一个源程序经过编译、链接,成为可执行文件的过程是把可执行代码联编(或称装配)在一起的过程
其中在运行之前就完成的联编成为 静态联编(前期联编)
而在程序运行之时才完成的联编叫 动态联编(后期联编)
两种多态的关系
由静态联编支持的多态性称为编译时多态性(静态多态性),在C++中,编译时多态性是通过函数重载和模板实现,利用函数重载机制,在调用同名函数的同时,编译系统会根据实参的具体情况确定其所要调用的函数是哪个
由动态联编支持的多态性称为运行时多态(动态多态),在C++中,运行时的多态性是通过虚函数来实现的
多态的定义和实现
多态定义的构成条件
举一个通俗易懂的例子:比如买票这个行为,普通人买是全价,学生买是半价票
实现多态的目的是在不同继承关系的类对象里,调用同一函数,能产生不同的行为,比如有一个Student派生类
继承了Person基类
,Person基类
的买票函数就是全价,而Student派生类
买票函数就是半价(即买票这个行为会根据买票对象的不同而不同)
如果需要使上方的要求成立,还需要两个条件,也就是在想x在继承中构成多态还需要两个条件:
1.调用函数的对象必须是指针或是引用(即必须以指针或引用的形式调用虚函数)
2.被调用的函数必须是虚函数(且必须已完成虚函数的重写)
虚函数
虚函数:在类(通常是最高基类)的成员函数前加上了virtual关键字的函数被称之为虚函数
class Person//基类函数
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
虚函数的重写: 派生类中有一个跟基类完全相同的虚函数,我们就称之为子类的虚函数重写了基类的虚函数。完全相同指定是:函数名 传入参数 返回值 都相同。另外,虚函数的重写也叫做虚函数的覆盖
为什么需要虚函数
当未声明函数为虚函数时,基类指针指向了派生类对象:
在堆中创建一个派生类(Cat类)的对象cat 并把该派生类对象赋值给基类(Pet)指针,可通过指针直接访问基类中的函数
void Pet::eat()
{
cout << name << "正在吃东西\n";
}
void Cat::play()
{
Pet::play();//todo如果需要调用基类中的play()函数 在原本的play()函数的基础上加上覆盖上的子类play()函数
cout << name << "正在追老鼠!\n";
}
void main()
{
Pet* cat = new Cat("猫");//指针cat的类型是Pet*(基类) (2)指针cat指向的对象的类型是Cat
cat->play();//结果为 "正在吃东西" 结果错误!
}
基类指针指向派生类对象:
Pet* cat
:建立一个名为cat的基类指针,其指针类型为Pet基类类型
new Cat
:申请一段动态内存,其内存类型为Cat派生类类型,此时便建立了一个Cat派生类类型的派生类对象,且此对象被基类指针绑定
我们直观上认为,如果基类指针指向了派生类对象,那么调用基类和子类中的同名函数时就应该使用派生类的成员变量和成员函数,但是拿此句[Pet* cat = new Cat("猫")
] 为例 此时调用的play()
函数是父类Pet的,还是子类Cat的?结果是 虽然指针指向的是派生类对象,但是程序并未调用派生类Cat的play()函数而是基类Pet的play()函数
也就是,当重写的函数未声明为虚函数时,基类类型的指针(*cat)指向派生类型的对象(new Cat并不是对象实例,但是也具有自己的内存地址,也是Cat类的对象)时, 通过基类指针只能访问派生类对象的成员变量,但是不能访问派生类的成员函数
为了消除这种尴尬,让基类指针能够访问派生类的成员函数,C++ 增加了 虚 函 数(Virtual Function)
示例代码:
案例一.动物和猫(使用指针的方式调用虚函数)
class Animal
{
public:
virtual void eat()
{
cout << "我是动物 我在吃东西" << endl;
}
};
class Cat:public Animal
{
public:
void eat()//同名函数
{
cout << "我是小猫 我在吃鱼" << endl;
}
};
void func(Animal* x)//todo此函数func以基类对象指针为形参 让程序根据传入的不同指针类型指向的不同类对象进行不同同名函数的调用
{
x->eat();//使用类对象指针调用eat()函数
}
void main()
{
Animal* animal = new Animal;//建立类型为Animal的类对象(new Animal) animal, 指向Animal类的指针(Animal*)(也就是 基类对象指向基类指针)
Cat* cat=new Cat;//建立类型为Cat的类对象 指向Cat类的指针(也就是 派生类对象指向派生类指针)
//todo Animal* animal Animal类对象的指针地址 new Animal动态分配的类对象内存空间大小为Animal
animal->eat();//运行结果为 "我是动物 我在吃东西" 正确
cat->eat();//运行结果为 "我是小猫 我在吃鱼" 正确
cout << "\n使用fun函数以基类对象指针为形参 :" << endl;
//todo未声明同名函数eat()为virtual虚函数前
func(animal);//运行结果为 "我是动物 我在吃东西" 正确
func(cat);//运行结果为 "我是动物 我在吃东西" 错误
//todo在基类中声明同名函数eat()为virtual虚函数前后
func(animal);//运行结果为 "我是动物 我在吃东西" 正确
func(cat);//运行结果为 "我是小猫 我在吃鱼" 正确
delete animal;
delete cat;
}
上述代码中,同样是x->eat();
这条语句,当x
这个基类指针指向不同的对象时,它执行的操作是不一样的,因此,当同一条语句可以执行不同的操作,看起来有不同表现方式时,这就是多态
使用引用也可以实现多态(以基类引用的形式调虚函数)
案例二.成人和学生买票(使用引用的方式调用虚函数)
#include <iostream>
#include <stdlib.h>
using namespace std;
class Person
{
public:
virtual void BuyTicket()//基类中的虚函数(Person.BuyTicket())
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()//子类中的虚函数的重写(Student.BuyTicket())
{
cout << "买票-半价" << endl;
}
};
void Func(Person& p)//根据调用(传入)的对象的引用别名的不同 调用不同对象的虚函数(指向基类person的 类对象指针名为p的指针)
{
p.BuyTicket();//调用基类指针指向的某对象指针的虚函数BuyTicket();
}
int main()
{
Person ps;
Student st;
不同引用调用不同虚函数
方法一:
Person &per=ps;
Person &stu=sd;
per.BuyTicket();结果:"买票-全价" 正确
stu.BuyTicket();结果:"买票-半价" 正确
或
方法二:
Func(ps);
Func(st);
return 0;
}
因为引用在本质上是通过指针的方式实现的,既然借助指针可以实现多态,那么我们就有理由推断:借助引用也可以实现多态
由于引用类似于常量,只能在定义的同时初始化,并且以后也要从一而终,不能再引用其他数据,所以本例中必须要定义两个引用变量,一个用来引用基类对象ps,一个用来引用派生类对象sd。从运行结果可以看出,当基类的引用指代基类对象时,调用的是基类的成员,而指代派生类对象时,调用的是派生类的成员
不过引用不像指针灵活,指针可以随时改变指向,而引用只能指代固定的对象,在多态性方面缺乏表现力,所以以后我们再谈及多态时一般是说指针,本例的主要目的是让读者知道,除了指针,引用也可以实现多态
总之: 引用构成的多态没有指针好!
★如果使用了virtual关键字,程序将根据引用或指针指向的 对 象 类 型 来选择方法,否则使用引用类型或指针类型来选择方法 (这里的引用或指针指向的对象的类型和引用的类型与指针的类型是两种概念!!!)
注意:🎯
1.在派生类中重写的成员函数可以不加virtual关键字
2.基类中的析构函数如果是虚函数,那么派生类的析构函数就重写了基类的析构函数,虽然两者的析构函数名不同,但其实编译后析构函数的名称都被处理为 destructor 这说明基类的析构函数最好写成虚函数
为什么析构函数需要写成虚函数 https://blog.csdn.net/komtao520/article/details/82424468
接口继承与实现继承
普通函数的继承方式是一种 实现继承,即派生类继承了基类的函数,可以使用实现继承的是函数的实现
虚函数的继承方式是一种 接口继承,派生类只基础 基类虚函数的接口,也就是只继承函数的声明,目的是实现重写,达成多态性。因为继承的是接口。所以如果不需要实现多态性,不需要把函数定义成虚函数(即使用类对象调用函数)
有了虚函数, 基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量), 基类指针指向派生类对象时就使用派生类的成员.换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)
多态的原理
虚函数表
#include<iostream>
#include <stdlib.h>
using namespace std;
class Base
{
public:
virtual void Fun1() {
cout << "Func1()" << endl;
}
private:
int a = 1;
};
int main()
{
Base b;
cout << "sizeof(b):" << sizeof(b) << endl;//结果为 "sizeof(b):16"
system("pause");
return 0;
}
C++的虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。 在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了 这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数
使用VS的监视器查看b
这个对象的虚函数表
通过测试我们发现sizeof(Base)
大小为8字节 ,Base
类中除a
成员外,还多了个名为_vfptr
的二级指针(存放指针变量地址的指针)放在对象的前面(有些平台将_vfptr放在对象后面),对象中的这个指针我们称它为虚函数列表指针,一个类中只要含有虚函数,则这个类至少有一个虚函数列表指针,因为虚函数的地址需要被放到虚函数列表(虚表)中
虚函数表代码演示
通过以下代码来说明虚函数表
1.此代码增加一个派生类Derive
去继承基类Base
2.在派生类Derive
中重写虚函数Func1
3.Base
基类中再增加一个虚函数Func2
和一个普通函数Func3
#include<iostream>
#include <stdlib.h>
using namespace std;
class Base
{
virtual void Func1()//基类中的虚函数Func1
{
cout << "Base::Func1()" << endl;
}
virtual void Func2() //基类中的虚函数Func2
{
cout << "Base::Func2()" << endl;
}
void Func3()//基类中的 普通函数 Func3
{
cout << "Base::Func3()" << endl;
}
private:
int ba = 1;//基类中的私有成员 ba
};
class Derive : public Base //公开继承Base基类的派生类Derive
{
public:
virtual void Func1() //派生类重写基类的虚函数 Func1
{
cout << "Derive::Func1()" << endl;
}
private:
int de = 2; //派生类的私有成员 de
};
int main()
{
Base base; //建立Base类型的对象 base
Derive derive; //建立Derive类型的对象 derive
system("pause");//暂停黑窗口命令 用于程序暂停
return 0;
}
对基类对象Base
进行监视后显示的内部信息
对派生类对象derive
进行监视后显示的内部信息
1.由此看出,派生类对象derive也有一个虚表指针,derive
对象由两部分构成,一部分时从父类继承下来的成员,另一部分是自己的成员
2.基类base对象和派生类derive对象的虚表中的虚函数指针地址是不一样的,发现Func1
的虚函数指针地址发生了变化,而Func2
的地址未发生变化这是因为Func1
和Func2
两个虚函数,我们在派生类中只重写了Func1
这个虚函数,Func1
在派生类中完成了重写,所以derive这个对象的虚函数表存的虚函数Func1
的指针是Derive::Func1
的指针而非Base::Func1
,所以虚函数的重写也叫覆盖。(简单的来说派生类继承了基类的虚函数表并将在派生类中进行重写过的虚函数指针与继承来的虚函数表中的相对的虚函数指针进行了替换覆盖更新,而未进行重写的虚函数指针在虚函数表内的指针不变)
3.Func1
,Func2
继承下来后是虚函数,所以放进了虚表,Func3
也继承下来了,但不是虚函数,所以不放在虚表中
总结:派生类的虚表生成与构成
生成:
1.先将基类中的虚表内容拷贝一份到派生类虚表中
2.如果派生类重写了基类中的某个虚函数,则用派生类的虚函数覆盖虚表中对应的基类的虚函数
3.派生类自己新增的虚函数按其在派生类的中的声明次序增加到派生类的虚表的最下方
构成:
1.基类中继承下但未重写的虚函数指针(表中此指针与基类中一致)
2.基类中继承下但被重写的虚函数指针(表中此指针覆盖基类中同名指针)
3.派生类自己新增的虚函数的指针(表中此指针放入派生类虚表的最下端)
多态实现的原理:
实现原理: 虚函数表+虚表指针
编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向虚函数地址数组的指针,称为虚表指针(vptr),这种数组称为虚函数表(虚表),虚表存放了指向虚函数表的地址,虚表存储了为类对象进行声明的虚函数地址,即每个类使用一个虚函数表,每个类对象用一个指向虚表地址的虚表指针,如果派生类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址,如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址(基类)
比如基类对象包含一个指针,该指针指向基类所有虚函数的地址表,派生类对象将包含一个指向独立地址表的指针
代码演示
依旧拿上边“买票”为例,进行代码演示:
代码目的
Func函数中如果
传入Person对象
调用的Person::BuyTicket
函数
传入Student对象
调用的是Student::BuyTicket
函数
#include<iostream>
#include <stdlib.h>
using namespace std;
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 ps;
Func(ps);//运行结果:"买票-全价"
Student sd;
Func(sd);//运行结果:"买票-半价"
system("pause");
return 0;
}
这样就实现了 不同对象去完成同一行为时,展现出不同的形态的目的
这得益于 虚函数和程序的动态绑定
动态绑定与静态绑定:
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序行为,也称为静态多态,例如函数重载
动态绑定又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也成为动态多态
使用虚函数后的变化:
1.对象将增加一个存储地址(虚函数指针地址)的空间(32位系统为4字节,64位为8字节)
2.每个类编译器都创建一个虚函数地址表
3.对每个函数的调用都需要增加在表中查找地址的操作
总结:
1.构造函数不能为虚函数
2.基类的析构函数应该为虚函数
3.友元函数不能为虚,因为友元函数不是类成员,只有类成员才能是虚函数。如果派生类没有重定义函数,则会使用基类版本
3.基类方法中声明了方法为虚后,该方法在基类派生类中是虚的
4.如果不使用多态,那么就需要定义多个指针变量,很容易造成混乱,而有了多态,只需要一个基类的指针变量xxx就可以调用所有派生类的虚函数