c++多态与虚函数从根上解决

发布于:2022-12-17 ⋅ 阅读:(246) ⋅ 点赞:(0)


前言

本文内容尽力包括c++多态与虚函数的所有内容,对c++这部分内容彻底掌握


一、c++虚函数解决了什么问题

在向底层了解c++虚函数时,我们应该首先明白对虚函数的使用,在实际工程中使用虚函数解决了什么问题
首选看下面一段代码:

class Person
{
	protected:
		char *name;
	public:
		Person(char *myname):name(myname)
		{}
		void display()
		{
			cout<<"我是"<<name<<",我是Preson"<<endl;
		}	
}; 

class Teacher:public Person
{
	public:
		Teacher(char *name):Person(name)
		{}
		void display()	
		{
			cout<<"我是"<<name<<",我是Teacher"<<endl; 
		}
};

int main()
{
	Person *p=new Person("李明");
	p->display();
	p=new Teacher("小红");
	p->display();
}

运行结果如下:
在这里插入图片描述
在代码运行之前,我们会认为如果指针指向了派生类对象,那么就应该使用派生类的成员变量和成员函数。但是本例的运行结果却告诉我们,当基类指针 p 指向派生类 Teacher 的对象时,虽然使用了 Teacher 的成员变量,但是却没有使用它的成员函数,导致输出结果不伦不类(不符合我们的预期。
换句话说,通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。
为了消除这种尴尬,让基类指针能够访问派生类的成员函数,C++ 增加了虚函数(Virtual Function)。使用虚函数非常简单,只需要在函数声明前面增加 virtual 关键字
如下更改版的代码:

class Person
{
	protected:
		char *name;
	public:
		Person(char *myname):name(myname)
		{}
		virtual void display()
		{
			cout<<"我是"<<name<<",我是Preson"<<endl;
		}	
}; 

class Teacher:public Person
{
	public:
		Teacher(char *name):Person(name)
		{}
		void display()	
		{
			cout<<"我是"<<name<<",我是Teacher"<<endl; 
		}
};

int main()
{
	Person *p=new Person("李明");
	p->display();
	p=new Teacher("小红");
	p->display();
}

在这里插入图片描述
仅仅是在基类display() 函数声明前加了一个virtual关键字,将成员函数声明为了虚函数(Virtual Function),这样就可以通过 p 指针调用 Teacher 类的成员函数了,运行结果也证明了这一点。

有了虚函数,基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量),指向派生类对象时就使用派生类的成员。换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态。
C++提供多态的目的是: 可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。
C++中虚函数的唯一用处就是构成多态。

二、虚函数的使用

由上面,我们可以知道C++ 虚函数对于多态具有决定性的作用,有虚函数才能构成多态。
为此我们要实现多态,必须使用虚函数。但是,在使用虚函数的时候有以下注意事项:
(1)只需要在虚函数的声明处加上 virtual 关键字,函数定义处可以加也可以不加。
(2)为了方便,可以只将基类中的函数声明为虚函数,这样所有派生类中具有遮蔽关系的同名函数都将自动成为虚函数
(3)当在基类中定义了虚函数时,如果派生类没有定义新的函数来遮蔽此函数,那么将使用基类的虚函数
(4)只有派生类的虚函数覆盖基类的虚函数(函数原型相同)才能构成多态(通过基类指针访问派生类函数)。例如基类虚函数的原型为virtual void func();,派生类虚函数的原型为virtual void func(int);,那么当基类指针 p 指向派生类对象时,语句p -> func(100);将会出错,而语句p -> func();将调用基类的函数。
(5)构造函数不能是虚函数。
(6) 析构函数可以声明为虚函数,而且有时候必须要声明为虚函数
(7)内联函数不能是虚函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
(8)静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
这里我们重点解释下第5条和第6条,通过看面经,可知哈哈哈,这两条是经常会被问到。

构造函数不能是虚函数

原因:
(1)从使用的角度看:
虚函数是通过指向派生类的基类指针或引用,访问派生类中同名覆盖成员函数。,但是构造函数是通过创建对象时自动调用的,不可能通过父类的指针或者引用去调用,所以规定构造函数不能是虚函数.
(2)从内存的角度看:
C++ 中的构造函数用于在创建对象时进行初始化工作,在执行构造函数之前对象尚未创建完成,虚函数表尚不存在,也没有指向虚函数表的指针,所以此时无法查询虚函数表,也就不知道要调用哪一个构造函数。

为什么要把析构函数声明成虚函数

一个派生类的指针可以安全地转化为一个基类的指针。这样删除一个基类的指针的时候,C++不管这个指针指向一个基类对象还是一个派生类的对象,调用的都是基类的析构函数而不是派生类的。如果你依赖于基类的析构函数的代码来释放资源,而没有重载析构函数,那么会有资源泄漏。
看如下代码:

//基类
class Base{
public:
    Base();
    ~Base();
protected:
    char *str;
};
Base::Base(){
    str = new char[100];
    cout<<"Base constructor"<<endl;
}
Base::~Base(){
    delete[] str;
    cout<<"Base destructor"<<endl;
}

//派生类
class Derived: public Base{
public:
    Derived();
    ~Derived();
private:
    char *name;
};
Derived::Derived(){
    name = new char[100];
    cout<<"Derived constructor"<<endl;
}
Derived::~Derived(){
    delete[] name;
    cout<<"Derived destructor"<<endl;
}

int main(){
   Base *pb = new Derived();
   delete pb;

   cout<<"-------------------"<<endl;

   Derived *pd = new Derived();
   delete pd;

   return 0;
}

在这里插入图片描述
从运行结果可以看出,语句delete pb;只调用了基类的析构函数,没有调用派生类的析构函数;而语句delete pd;同时调用了派生类和基类的析构函数。在本例中,不调用派生类的析构函数会导致 name 指向的 100 个 char 类型的内存空间得不到释放;除非程序运行结束由操作系统回收,否则就再也没有机会释放这些内存。造成内存泄露
为了避免这个问题,我们只能把基类的析构函数声明成虚函数,此时派生类的析构函数也会自动成为虚函数。这个时候编译器会忽略指针的类型,而根据指针的指向来选择函数;也就是说,指针指向哪个类的对象就调用哪个类的函数。pb、pd 都指向了派生类的对象,所以会调用派生类的析构函数,继而再调用基类的析构函数。如此一来也就解决了内存泄露的问题。

注意:C++不把虚析构函数直接作为默认值的原因是虚函数表的开销以及和C语言的类型的兼容性。有虚函数的对象总是在开始的位置包含一个隐含的虚函数表指针成员。

由上,我们知道了虚函数使用中,其实就是看的声明的基类指针的指向。看过之前的博客
从外到内理解c++引用知道在底层,引用变量由指针按照指针常量的方式实现,那么引用能不能代替指针来实现虚函数指向呢。
答案是肯定的能,只不过引用只能在定义的同时初始化,并且以后也要从一而终,不能再引用其他数据。引用不像指针灵活,指针可以随时改变指向,而引用只能指代固定的对象,在多态性方面缺乏表现力,因此还是用指针比较方便

在知道了虚函数的基本使用后,我们再来学习下虚函数的特殊使用

纯虚函数

可以将虚函数声明为纯虚函数,语法格式为:

virtual 返回值类型 函数名 (函数参数) = 0;

包含纯虚函数的类称为抽象类(Abstract Class)。它无法实例化,也就是无法创建对象。原因很明显,纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。

在实际开发中,你可以定义一个抽象基类,只完成部分功能,未完成的功能交给派生类去实现(谁派生谁实现)。这部分未完成的功能,往往是基类不需要的,或者在基类中无法实现的。虽然抽象基类没有完成,但是却强制要求派生类完成,这就是抽象基类的“霸王条款”。注意:在派生类中必须完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。

明白了虚函数的应用场景以及虚函数的使用,接下来我们就要往底层了解,揭开虚函数的神秘的面纱,看看虚函数是如何实现的

三、虚函数的实现

从上面我们知道了将基类中的函数声明称虚函数,并且派生类有同名的函数遮蔽它,那么编译器会根据基类指针的指向找到对应类的对应函数。此时我们不仅心里产生疑问,这个操作编译器是怎么实现的呢啊?
编译器之所以能通过指针指向的对象找到虚函数,是因为在创建对象时额外地增加了虚函数表。
如果一个类包含了虚函数,那么在创建该类的对象时就会额外地增加一个数组数组中的每一个元素都是虚函数的入口地址。不过数组和对象是分开存储的,为了将对象和数组关联起来,编译器还要在对象中安插一个指针,指向数组的起始位置。这里的数组就是虚函数表(Virtual function table),简写为vtable。

看如下代码:

//People类
class People{
public:
    People(string name, int age);
public:
    virtual void display();
    virtual void eating();
protected:
    string m_name;
    int m_age;
};
People::People(string name, int age): m_name(name), m_age(age){ }
void People::display(){
    cout<<"Class People:"<<m_name<<"今年"<<m_age<<"岁了。"<<endl;
}
void People::eating(){
    cout<<"Class People:我正在吃饭,请不要跟我说话..."<<endl;
}

//Student类
class Student: public People{
public:
    Student(string name, int age, float score);
public:
    virtual void display();
    virtual void examing();
protected:
    float m_score;
};
Student::Student(string name, int age, float score):
    People(name, age), m_score(score){ }
void Student::display(){
    cout<<"Class Student:"<<m_name<<"今年"<<m_age<<"岁了,考了"<<m_score<<"分。"<<endl;
}
void Student::examing(){
    cout<<"Class Student:"<<m_name<<"正在考试,请不要打扰T啊!"<<endl;
}

//Senior类
class Senior: public Student{
public:
    Senior(string name, int age, float score, bool hasJob);
public:
    virtual void display();
    virtual void partying();
private:
    bool m_hasJob;
};
Senior::Senior(string name, int age, float score, bool hasJob):
    Student(name, age, score), m_hasJob(hasJob){ }
void Senior::display(){
    if(m_hasJob){
        cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,并且顺利找到了工作,Ta今年"<<m_age<<"岁。"<<endl;
    }else{
        cout<<"Class Senior:"<<m_name<<"以"<<m_score<<"的成绩从大学毕业了,不过找工作不顺利,Ta今年"<<m_age<<"岁。"<<endl;
    }
}
void Senior::partying(){
    cout<<"Class Senior:快毕业了,大家都在吃散伙饭..."<<endl;
}
int main(){
    People *p = new People("赵红", 29);
    p -> display();

    p = new Student("王刚", 16, 84.5);
    p -> display();

    p = new Senior("李智", 22, 92.0, true);
    p -> display();

    return 0;
}

各个类的对象内存模型如下所示:
在这里插入图片描述
图中左半部分是对象占用的内存,右半部分是虚函数表 vtable。在对象的开头位置有一个指针 vfptr,指向虚函数表,并且这个指针始终位于对象的开头位置。
观察虚函数表,可以发现基类的虚函数在 vtable 中的索引(下标)是固定的,不会随着继承层次的增加而改变,派生类新增的虚函数放在 vtable 的最后。如果派生类有同名的虚函数遮蔽(覆盖)了基类的虚函数,那么将使用派生类的虚函数替换基类的虚函数,这样具有遮蔽关系的虚函数在 vtable 中只会出现一次
当通过指针调用虚函数时,先根据指针找到 vfptr,再根据 vfptr 找到虚函数的入口地址。

了解了虚函数的实现,我们知道基类指针的类型确定是在运行时确定的,编译阶段是无法确定的。
那么运行阶段是怎么确定的呢,此时就不得不引出RTTI机制

RTTI机制

RTTI即通过运行时类型识别,程序能够使用基类的指针或引用来检查着这些指针或引用所指的对象的实际派生类型
C++是一种静态类型语言,其数据类型是在编译期就确定的,不能在运行时更改。然而由于面向对象程序设计中多态性的要求,C++中的指针或引用(Reference)本身的类型,可能与它实际代表(指向或引用)的类型并不一致。有时我们需要将一个多态指针转换为其实际指向对象的类型,就需要知道运行时的类型信息,这就产生了运行时类型识别的要求。C++要想获得运行时类型信息,只能通过RTTI机制,并且C++最终生成的代码是直接与机器相关的。

明白了RTTI机制,那么RTTI机制在c++中实际是怎么实现的呢?
其实,如果类包含了虚函数,那么该类的对象内存中还会额外增加类型信息,也即 type_info 对象。以上面的代码为例,Base 和 Derived 的对象内存模型如下图所示:

编译器会在虚函数表 vftable 的开头插入一个指针,指向当前类对应的 type_info 对象。当程序在运行阶段获取类型信息时,可以通过对象指针 p 找到虚函数表指针 vfptr,再通过 vfptr 找到 type_info 对象的指针,进而取得类型信息。

动态绑定与静态绑定

静态绑定与动态绑定
静态类型:对象在声明时采用的类型,在编译期既已确定
动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
非虚函数一般都是静态绑定,而虚函数都是动态绑定。
区别:
静态绑定发生在编译期,动态绑定发生在运行期;
对象的动态类型可以更改,但是静态类型无法更改;
要想实现动态,必须使用动态绑定;
在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

至此,c++虚函数基本已经搞定,如果有没学到的,等遇到了再回来补充。

推荐一个零声学院免费公开课程,个人觉得老师讲得不错,
分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,
fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,
TCP/IP,协程,DPDK等技术内容,点击立即学习:服务器课程


网站公告

今日签到

点亮在社区的每一天
去签到