欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 新闻 > 资讯 > C++之多态

C++之多态

2025/5/18 9:39:25 来源:https://blog.csdn.net/2402_83250773/article/details/147984004  浏览:    关键词:C++之多态

开始新的征程啦———多态,它也是C++的三大特性之一。

在这里插入图片描述

文章目录

  • 一、多态的概念
  • 二、多态的定义和实现
    • 2.1多态的定义
    • 2.2 实现动态多态所需要的条件(2个)
    • 2.3 虚函数的定义
    • 2.4 虚函数的重写/覆盖
    • 2.5 虚函数重写中的问题
      • 2.5.1 协变
      • 2.5.2 析构函数的重写
      • 2.6 override和final关键字
    • 2.6 重载/重写/隐藏
  • 三、纯虚函数和抽象类
  • 四、多态的原理
    • 4.1 虚函数表指针
    • 4.2 父子类的虚函数表指针的区分
    • 4.3 多态的原理
    • 4.4 动态绑定与静态绑定
    • 4.4 虚函数表

一、多态的概念

通俗来讲的话,就是多种形态。多态性(Polymorphism) 指的是 “同一接口,不同实现” 的能力

多态分为编译时多态(静态多态)和运行时多态(动态多态),什么属于静态多态呢?像之前学习的函数重载以及模板(函数模板+类模板)就属于静态多态,为什么叫编译时多态?因为实参传给形参的匹配是在编译时完成的(传不同的参数就可以调用不用的函数,通过参数的不同达到多种形态);什么是运行时多态呢?即:去完成某个行为(函数),传不同的对象,来完成不同的行为,就会达到多种形态。(多态举例:去同一个买票的窗口,普通人去是全价,但是学生是半价,不用的对象,不同的行为)

我们这节讲述的都是动态的多态。

二、多态的定义和实现

2.1多态的定义

动态多态是指在运行时根据对象的实际类型来决定调用哪个函数,与【指针 / 引用的静态类型】无关,不是由这个决定的。它通过虚函数(Virtual Functions) 和继承机制实现,核心是 运行时类型识别 和 虚函数表(VTABLE) 的动态调度。

简单理解就是:一个继承关系下的类型的对象,去调用同一函数,产生了不同的行为。

2.2 实现动态多态所需要的条件(2个)

非常重要,这个是前提条件

  1. 被调用的函数必须是虚函数
    1.2想让函数有多种形态,首先需要让基类中的那个成员函数成为虚函数

  2. 必须是基类(父类)指针 / 引用去调用虚函数
    【注意注意:
    (1)基类基类基类,派生类是没法调用虚函数的哈。而且父类的指针/引用,既可以指向父类对象,又可以指向子类对象(ptr是person(父)类类型的指针,不管你传什么样的派生类过来,他在这里都会进行切割,因此ptr它始终都指向父类的那一部分);
    (2)指针或引用,如果是person p;是调用不了的哈,它只是一个单纯的对象,不是指针也不是引用】

为什么必须是基类的指针/引用呢?因为只有基类的指针或引用才能既指向派生类对象,又能指向基类对象。

2.3 虚函数的定义

上述所需的第一个条件是虚函数。那什么是虚函数呢?

在 C++ 中,虚函数是实现动态多态的核心机制。它允许通过 [基类的指针或引用] 调用 [派生类的特定函数] ,而具体调用哪个函数在 运行时 根据对象的实际类型确定,而非编译时.

虚函数的定义:虚函数是在基类中用 virtual 关键字声明的(成员函数),派生类可以 重写(Override) 该函数以提供自己的实现。【非成员函数不能加virtual来修饰,也就是说这个函数是在类里面的】

virtual这个关键字加在成员函数的返回值的前面

class person
{
public:virtual void Buy(){cout << "普通身份--全价" << endl;}
};

2.4 虚函数的重写/覆盖

派生类中有一个跟基类完全相同的虚函数,则称为是派生类的虚函数重写了基类的虚函数)。【虚函数的完全相同代表什么呢?代表着3同:派生类和基类中的虚函数,它们的函数名字,参数列表类型,返回值类型】【还需要注意的是,它们两个是在不同的作用域,一个在基类里,一个在派生类里】

回忆一下函数重载:两个函数必须同一个作用域;它们的名字和返回值类型相同,但是参数不同。
对于这个虚函数的重写,我们这里还需要特别来强调一下,就是这个虚函数的重写在这里确实是将基类中的虚函数给重写了,只不过这里的重写只是将函数的实现给重写了,换句话说,其实就是在多态的场景下,基类虚函数的实现过程被替换成了派生类虚函数的实现过程,在编译时就被替换好了。

在派生类中重写了基类的虚函数:只是将基类虚函数的实现(这是个名词,实现过程的意思)给重写了。【只有虚函数才能进行重写】【实际上就是:在多态的场景下,在编译的时候,基类虚函数的实现过程被替换成了派生类虚函数的实现过程】

class A
{
public:virtual void func(int val = 0)  //A中的val时0,B中的是1{std::cout << "class A->" << val << std::endl;}
};
class B : public A  //满足继承关系:A父
{
public:void func(int val = 1){std::cout << "class B->" << val << std::endl;}
};
int main()
{   //先在堆上创建一个 B 类的对象实例,然后将地址赋给A类指针。再用A类指针(基类指针)调用虚函数//new B                          赋值给A* pA* p = new B; //在堆上分配的内存大小为sizeof(B),包含(子类)B自身的成员和从A继承的成员p->func();   //p是基类。条件之一:用基类的指针去调用虚函数//输出结果是class B->0return 0;
}

为什么输出结果是class B->0而不是class B->1呢?(派生类)B类中的func()函数也就是将(基类)A类的func()函数的实现进行重写,然后将这个重写后的函数当作B类中的func()函数。(记得之前说过,重写,只是将基类A的函数实现过程进行了重写,其余的没有改变。)

再理解就是,直接使用了A类的func()函数的返回值类型,函数名字,参数(包括它的缺省值),然后再使用了不一样的实现
可以将B类的func()函数想成这个样子

virtual void func(int val = 0)  //这一行都是A类func函数的
{std::cout << "class B->" << val << std::endl;
}

我们还需要注意一个知识点:在重写基类的虚函数时,没有在派生类的虚函数前加virtual这个关键字时,也可以构成重写(因为继承后基类的虚函数被继承下来了,在派生类中依旧保持虚函数的属性),也就是说,如果有一个虚函数构成重写的话,那么派生类中的那个虚函数实际上就是基类中的那个虚函数,实现部分的代码给换成了派生类中的这个虚函数的实现部分的代码(在上面的例子,基类中的func函数前面没有加virtual这个关键字,func函数还是形成了重写,就是因为派生类中的那个func函数其实就是基类中的那个func函数,只是将实现部分的代码替换成了派生类中的那个func函数的实现部分的代码),但是该种写法并不是很规范,不建议我们这样去使用,最好还是在派生类虚函数前加上virtual,不过在考试的选择题中,会让你判断是否构成多态,因此我们这里需要注意一下。

class person
{
public:virtual void BuyTicket(){cout << "买票——全折" << endl;}
};
class student :public person
{virtual void BuyTicket(){cout << "买票——半折" << endl;}
};
//BuyTicket构成虚函数的重写,接下来可以利用这两个继承关系的类来实现一个多态。
int main()
{person* p1 = new person;  p1->BuyTicket();//买票——全折;person& p2 = *p1;  //引用p2.BuyTicket();//买票——全折;person* s1 = new student;  //这里会发生切割关系s1->BuyTicket();//买票——半折;这里调用的是派生类中的BuyTicket函数。person& s2 = *s1;s2.BuyTicket();//买票——半折;这里调用的其实也是派生类中的BuyTicket函数。return 0;
}

代码讲解:
person* s1 = new student; s1->BuyTicket();
重点1:public继承的派生类对象(这里指的是new student是实例化对象),可以赋值给基类的指针/引用(指的是s1)。可以叫做切片/切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。

重点2:在之前普通情况下(不构成多态) 去调用函数的话,我们调用的这个函数所在的作用域是由指针/引用它自己本身的类型去决定的

如果:基类的指针/引用,去调用的这个函数构成多态,那么它所调用的函数所在的作用域(意思就是那个函数是哪个类中的)取决于这个指针/引用指向的那个对象,并不取决于指针/引用它本身自己的类型。这里,父类指针s1指向的是子类对象student,取决于子类对象,而不是父类指针。

所以,所调用的函数的作用域是(new student实例化对象)所在的那个类,即student类。所以调用的是student类里的函数,所以是半价。

2.5 虚函数重写中的问题

2.5.1 协变

基类虚函数返回基类对象的指针或引用,派生类虚函数返回派生类对象的指针或者引用时,称之为是协变。

派生类重写基类的虚函数时,与基类虚函数的返回值不同(打破了 " 三同 " 条件)。(这里注意一下,就是基类虚函数返回基类对象的指针或引用,不一定是返回当前基类对象的指针或引用,当然也可以是其它基类对象的指针或引用)协变的实际意义其实并不大,所以我们这里只需了解一下即可。

2.5.2 析构函数的重写

将基类的析构函设置为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写(虽然基类与派生类析构函数的名字不同,不符合重写的规则,但是实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称会统一处理成destructor,所以基类的析构函数加上virtual修饰,派生类的析构函数就会构成重写)下面,我们就通过代码来看一看为什么析构函数一定要构成重写?

先看一下普通情况:A是父类,B是子类

A* a1 = new A;
A* a2 = new B;//这里采用了"切割"方面的知识。
delete p1;
delete p2;

这种情况,编译器会报错,出现了内存泄露的问题(在delete p2;出现的错误)。在这里调用析构函数,它只会调用A的析构函数,因为p2的类型是A类类型,所以它不会用B的析构函数去释放arr这块连续的空间。

那如何才能让它同时也调用B的析构函数呢?换句话说,也就是在调用析构函数的时候,如何才能让它根据指针指向的那个对象的类型去调用相应的析构函数,而不是根据指针的类型。这里我们就可以使用虚函数的重写。只要构成重写,我们就可以通过使用多态来解决,多态它是根据指针指向的那个对象的的类型去调用相应的函数的(为了构成重写,必须保证函数名要相同,因此编译器才会对析构函数的函数名做了特殊处理。)

所以,析构函数一定要构成重写

class A
{
public:virtual ~A(){cout << "~A" << endl;}
};
class B : public A
{
public:virtual ~B(){cout << "~B" << endl;}
protected:int* arr=new int[10];
};
int main()
{A* a1 = new A;A* a2 = new B;delete a1;delete a2;return 0;
}

2.6 override和final关键字

从上面我们可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如说函数名写错、参数写错等等导致无法构成重写,而这种错误在编译时期是不会报出的,只有在程序运行时且没有得到预期结果才来找debug会得不偿失,因此C++11提供了override这个关键字,可以帮助用户检测是否构成了重写。

override写在派生类那个函数的参数后边
如果我们不想让派生类重写这个虚函数的话,那么就可以用final这个关键字去修饰一下就可以了。
在这里插入图片描述

class car
{
public:virtual void print() final { }//表明print这个虚函数不能构成重写操作
};

这样书写,则表明print()这个虚函数不可以被重写

2.6 重载/重写/隐藏

重写的条件更苛刻一点。
在这里插入图片描述

三、纯虚函数和抽象类

纯虚函数是在C++中用于定义抽象类的一种特殊虚函数。纯虚函数在基类中声明但不提供实现(实现没啥意义,因为要被派生类重写) ,间接要求派生类必须重写该函数。纯虚函数的主要作用是定义一个接口,强制派生类实现该接口。纯虚函数的声明通过在函数声明的末尾添加 = 0 来实现。

包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数的话,那么派生类也是抽象类,纯虚函数,在某种程度上强制了派生类重写虚函数,因为不重写的话就实例化不出对象来。

class car
{
public:virtual void drive() = 0; //drive成为了纯虚函数//所以car类成为了抽象类
};
class xiaomi:public car
{//没有对纯虚函数进行重写,所以xiaomi类也是抽象类
};
class xinjie :public car
{
public:virtual void drive(){cout << "xinjie" << endl;}//xinjie类继承了car类,也就继承了对drive这个纯虚函数的声明//它对纯虚函数进行了重写(可以当作在父类中声明,子类中实现),所以这个类不包含纯虚函数//这个类就不是抽象类,可以实例化出对象
};
int main()
{car* a1 = new car;car* a2 = new xiaomi;car* a3 = new xinjie;return 0;
}

在这里插入图片描述

四、多态的原理

4.1 虚函数表指针

在这里还是要再说一次:只有虚函数的重写才能完成多态

虚函数的重写是为了让父类和子类对象的虚函数表中,分别存父类和子类中的虚函数的地址(虚函数表的本质是“函数指针数组”)

接下来了解“虚函数表”和虚函数表指针“这两个概念。

虚函数表是用来存储类里面所有的的虚函数地址的数组(它的本质是函数指针数组)(每个包含虚函数的类,它的对象中会有一个指针,指针指向一个表,表里面存储着虚函数的地址)。

虚函数表指针是指向虚函数表的指针,每个(包含虚函数的类的)对象在内存中都有一个隐藏的成员变量,即虚函数表指针。这个指针指向该对象所属类的虚函数表。当调用虚函数时,程序会通过虚函数表指针找到对应的虚函数表,然后从表中获取正确的函数地址进行调用。
在这里插入图片描述
需要注意的是:如果在后来加上去的虚函数,vs是看不见的新的虚函数的。编译器的监视窗口故意隐藏了这两个函数(一个小bug),后期学习如何打印虚表。

4.2 父子类的虚函数表指针的区分

我们要进行区分:父类中的虚函数表指针的地址,派生类中,父类的虚函数指针的地址,不是同一个。父类的虚表里面是虚函数地址,派生类里的是重写的虚函数的地址。是不一样的

4.3 多态的原理

在完成多态调用的时候,如何做到指针指向哪个对象,就调用哪个对象作用域的虚函数呢?(指向谁,调用谁对应的虚函数)

我们再次说一下静态和动态:

  1. 静态多态:函数重载的模板都是在编译的时候实例化的。

  2. 动态多态:并不是在编译的时候选择调用谁,而是在运行的时候,去(指向的对象的虚表中)找到对应的虚函数,进行调用(指针指向的是父类的对象,那就会调用父类的虚函数。指向子类的对象,就会去找到父类的那一部分呢,切出来。)

我们实现虚函数的重写其实就是将(虚函数表指针)进行重写。使他指向不同的对象,我们无论传入的派生类是怎么样的,传入Func函数都会进行截断,使它只出现父类,然后通过虚函数表指针调用函数,这就形成了多态。
在这里插入图片描述

4.4 动态绑定与静态绑定

   1>.对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。2>.满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调⽤函数的地址,也就做动态绑定。(这两种绑定方式所生成的编译指令是完全不一样的,可以通过调用去看看汇编窗口)

4.4 虚函数表

  1. 基类对象的虚函数表中存放基类所有虚函数的地址。
  2. 同类型的对象共用同一张虚表(比如A a;A a2a和a2共用一张虚表),不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表
  3. 派生类由两部分构成,继承下来的基类和自己的成员。如果继承下来的基类有虚函数表指针,那派生列就不会再去生成虚函数表指针。但要注意:(继承下来的基类部分虚函数表指针)和(基类对象的虚函数表指针)不是同一个,就像基类对象的成员和派生类对象中的基类对象成员也独立的
  4. 派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,(3)派生类自己的虚函数地址三个部分。【派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。】
  5. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)
  6. 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com