C++笔记---多态

embedded/2024/11/13 15:39:23/

1. 多态的概念

多态(polymorphism)的概念:通俗来说,就是多种形态。

多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态,编译时多态(静态多态)和运行时多态(动态多态)。

编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们一般把编译时归为静态,运行时归为动态。

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是”(>^ω^<)喵“,传狗对象过去,就是"汪汪"。

2. 多态的定义及实现

具体来说,动态的多态发生在继承体系中,父类与子类或统一父类的各子类之间,是在调用函数时发生不同的行为的现象。

比如Student继承了Person。Person对象买票全价,Student对象优惠买票。

class Person
{
public:virtual void ByTicket(){cout << "全价买票" << endl;}
};class Student : public Person
{
public:virtual void ByTicket(){cout << "半价买票" << endl;}
};void func(Person* p)
{p->ByTicket();
}int main()
{Person p;Student s;func(&p);func(&s);return 0;
}

2.1 构成多态的条件

1. 必须用父类的指针或引用来调用函数(对象不行)

2. 被调用的必须是虚函数(被virtual关键字修饰的函数)

3. 对象构造完成

说明:要实现多态效果,第一必须是父类的指针或引用,因为只有父类的指针或引用才能既可指向子类对象,又可指向自身;第二子类必须对父类的虚函数重写/覆盖;第三在对象构造阶段多态不生效。

2.1.1 虚函数

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。

注意非成员函数以及静态成员函数都不能加virtual修饰。

class Person
{
public:virtual void ByTicket(){cout << "全价买票" << endl;}
};
2.1.2 虚函数的重写/覆盖

虚函数的重写/覆盖:子类中有一个跟父类完全相同的虚函数(即子类虚函数与父类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了父类的虚函数。

在这里,重写意味着重写父类虚函数的函数体如果父类和子类虚函数的参数带有不同的缺省值的话,构成多态时,以父类虚函数的缺省值为准

class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};int main(int argc, char* argv[])
{B* p = new B;p->test();return 0;
}

注意:在重写父类虚函数时,子类的虚函数在不加virtual关键字时,也可以构成重写(因为继承后父类的虚函数被继承下来了在子类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。

2.1.3 override和final关键字

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

例如下面这个函数名写错的情况:

// error C3668: “Benz::Drive”: 包含重写说明符“override”的方法没有重写任何基类方法
class Car {
public:virtual void Dirve(){}
};class Benz :public Car {
public:virtual void Drive() override { cout << "Benz-舒适" << endl; }
};int main()
{return 0;
}

如果我们不想让子类重写这个虚函数,那么可以用final去修饰:

// error C3248: “Car::Drive”: 声明为“final”的函数⽆法被“Benz::Drive”重写
class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl; }
};int main()
{return 0;
}
2.1.4 协变

即父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,也构成重写,称为协变。

协变的实际意义并不大,所以我们了解一下即可。

class A {};
class B : public A {};class Person {
public:virtual A* BuyTicket(){cout << "买票-全价" << endl;return nullptr;}
};class Student : public Person {
public:virtual B* BuyTicket(){cout << "买票-打折" << endl;return nullptr;}
};

这个语法的设计可能是为了在函数返回值上体现多态。

2.1.5 析构函数的重写

父类的析构函数为虚函数,此时子类析构函数只要定义,就与父类的析构函数构成重写。

虽然父类与子类析构函数名字不同看起来不符合重写的规则,但实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了vialtual修饰,子类的析构函数就构成重写。

由于析构函数的名称会统一被处理成destructor,所以在不加virtual关键字时,父类和子类的析构函数构成隐藏关系。

下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为~B()中在释放资源。

注意:这个问题问试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么父类中的析构函数建议设计为虚函数。

class A{
public:virtual ~A(){cout << "~A()" << endl;}
};class B : public A {
public:~B(){cout << "~B()->delete:" << _p << endl;delete _p;}
protected:int* _p = new int[10];
};// 只有派⽣类Student的析构函数重写了Person的析构函数
// 下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{A* p1 = new A;A* p2 = new B;delete p1;delete p2;return 0;
}
2.1.6 重载/隐藏/重写

 2.2 纯虚函数及抽象类

在虚函数的后面写上 "=0" ,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被子类重写,但是语法上可以实现),只要声明即可。

包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果子类继承后不重写纯虚函数,那么子类也是抽象类。

纯虚函数某种程度上强制了子类重写虚函数,因为不重写实例化不出对象。

纯虚函数及抽象类的存在是为了描述一些抽象的概念,如:动物,植物,玩具……

以动物为例,假设有个动物类,我们知道动物是可以叫的,那么这个抽象的“动物”怎么叫呢?我们无法明确,但是我们确实需要描述这一动作,这时就可以采用虚函数以及抽象类。

class Animal
{
public:virtual void talk() const = 0{}
};class Dog : public Animal
{
public:virtual void talk() const{std::cout << "汪汪" << std::endl;}
};class Cat : public Animal
{
public:virtual void talk() const{std::cout << "(>^ω^<)喵" << std::endl;}
};void letsHear(const Animal& animal)
{animal.talk();
} int main()
{Cat cat;Dog dog;letsHear(cat);letsHear(dog);return 0;
}

我们之前提到,父类和子类之间在类别上存在一种包含关系,上面的例子就是一种具体体现。

由此我们也可以看出,抽象类就是为了实现属于同一类别的事物之间的多态关系而存在的

3. 多态的原理

3.1 虚表指针

先来看一段代码:

class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
protected:int _b = 1;char _ch = 'x';
};int main()
{Base b;cout << sizeof(b) << endl;return 0;
}

这是怎么回事呢?

按照内存对齐的规则(C语言结构体的大小,结构体内存对齐_c语言 struct大小-CSDN博客), Base类的大小应该是8才对啊。

我们通过调试可以找到答案:

可以看到,在Base对象中多了一个成员变量"__vfptr",使其大小增加到了16个字节。

对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。

一个含有虚函数的类中都至少都有一个虚函数表指针,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。

父类的虚函数放到父类的虚表中;子类继承(拷贝)了父类的虚表,如果有新的虚函数则添加进去,如果重写了父类的虚函数则用子类虚函数的地址对其进行覆盖。

3.2 多态的实现

上面的例子中,func函数中的Ptr->ByTicket()是如何识别指向对象的类型并调用正确的函数的呢?

给每个对象做上一个标记似乎是个不错的选择,这样在访问相应的内存空间时就可以通过这个标记找到合适的函数了。

没错,这个标记就是虚表指针。

对于虚函数的调用,在编译时并不会通过符号表来确定其地址,而是通过对对象的函数虚表进行查找来调用,这样就实现了函数的地址是在运行时(接收到的对象不同,查找到的函数也不同)才被确定,也就是动态的多态。

• 对不满足多态条件(指针或者引用 + 调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定
• 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定

// ptr是指针 + BuyTicket是虚函数满足多态条件。
// 这⾥就是动态绑定,编译在运⾏时到ptr指向对象的虚函数表中确定调用函数地址
ptr->BuyTicket();
00EF2001 mov eax, dword ptr[ptr]
00EF2004 mov edx, dword ptr[eax]
00EF2006 mov esi, esp
00EF2008 mov ecx, dword ptr[ptr]
00EF200B mov eax, dword ptr[edx]
00EF200D call eax// BuyTicket不是虚函数,不满足多态条件。
// 这⾥就是静态绑定,编译器直接确定调用函数地址
ptr->BuyTicket();
00EA2C91 mov ecx, dword ptr[ptr]
00EA2C94 call Student::Student(0EA153Ch)

函数的调用与对象本身相绑定(动态绑定),而不与其符号表相绑定(静态绑定),这也就解释了为什么多态的构成需要用指针或引用来调用虚函数,而不能用对象(会发生拷贝,虚表会根据类型变化,从而有可能错调函数)。

3.3 虚函数表的具体说明

• 父类对象的虚函数表中存放父类所有虚函数的地址。

父类的虚函数放到父类的虚表中;子类继承(拷贝)了父类的虚表,如果有新的虚函数则添加进去,如果重写了父类的虚函数则用子类虚函数的地址对其进行覆盖。

• 多继承时,包含虚函数的父类有几个就会有几个虚表,子类新增的虚函数地址放到第一张虚表后面。
• 虚函数表本质是⼀个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会在后面放个0x00000000标记,g++系列编译不会放)。

• 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址存到了虚表中,虚表的指针又存到了对象中。

• 虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下的代码可以对比验证一下。vs下是存在代码段(常量区)

int main()
{int i = 0;static int j = 1;int* p1 = new int;const char* p2 = "xxxxxxxx";printf("栈:%p\n", &i);printf("静态区:%p\n", &j);printf("堆:%p\n", p1);printf("常量区:%p\n", p2);Base b;Derive d;Base* p3 = &b;Derive* p4 = &d;printf("Person虚表地址:%p\n", *(int*)p3);printf("Student虚表地址:%p\n", *(int*)p4);printf("虚函数地址:%p\n", &Base::func1);printf("普通函数地址:%p\n", &Base::func5);return 0;
} 运行结果:
栈 : 010FF954
静态区 : 0071D000
堆 : 0126D740
常量区 : 0071ABA4
Person虚表地址 : 0071AB44
Student虚表地址 : 0071AB84
虚函数地址 : 00711488
普通函数地址 : 007114BF


http://www.ppmy.cn/embedded/113074.html

相关文章

CPU 和 GPU:为什么GPU更适合深度学习?

目录 什么是 CPU &#xff1f; 什么是 GPU &#xff1f; GPU vs CPU 差异性对比分析 GPU 是如何工作的 &#xff1f; GPU 与 CPU 是如何协同工作的 &#xff1f; GPU vs CPU 类型解析 GPU 应用于深度学习 什么是 CPU &#xff1f; CPU&#xff08;中央处理器&#xff09;…

用于稀疏自适应深度细化的掩码空间传播网络 CVPR2024

目录 Masked Spatial Propagation Network for Sparsity-Adaptive Depth Refinement &#xff08;CVPR 2024&#xff09;用于稀疏自适应深度细化的掩码空间传播网络1 介绍2 算法流程2.1 问题建模2.2 Guidance Network2.3 MSPN 模块 3 实验结果3.1 稀疏度自适应深度细化对比试验…

分享JavaScript中直接调用CSS中的类名

分享JavaScript中直接调用CSS中的类名 在现代的 JavaScript 框架&#xff08;如 React、Vue&#xff09;中&#xff0c;使用 CSS 模块&#xff08;CSS Modules&#xff09;是一种非常流行的方式。.module.css 文件扩展名代表的是 CSS 模块&#xff0c;它与普通的 CSS 文件不同…

Java 之 IO流

一、IO流概述 在计算机编程中&#xff0c;IO流&#xff08;Input/Output Stream&#xff09;是处理设备间数据传输的关键技术。简单来说&#xff0c;IO流就是以流的方式进行输入输出&#xff0c;数据被当作无结构的字节序或字符序列来处理。在Java等编程语言中&#xff0c;IO流…

vscode从本地安装插件

1. 打开VSCode。 2. 点击左侧菜单中的“扩展”&#xff08;或按CtrlShiftX&#xff09;。 3. 点击“更多操作”&#xff08;三个点&#xff09;> “从VSIX安装”。 4. 选择下载的.vsix文件。 5. 点击“安装”即可安装插件。

redis简单使用与安装

redis redis 是什么 Redis 是一个开源的&#xff0c;使用 C 语言编写的,支持网络交互的,内存中的Key-Value 数据结构存储系统&#xff0c;支持多种语言,它可以用作数据库、缓存和消息中间件。 一、存储系统特性 内存存储与持久化 Redis 主要将数据存储在内存中&#xff0c;这…

IntelliJ IDEA 创建 Java 项目指南

IntelliJ IDEA 是一款功能强大的集成开发环境&#xff08;IDE&#xff09;&#xff0c;广泛用于 Java 开发。本文将介绍如何在 IntelliJ IDEA 中创建一个新的 Java 项目&#xff0c;包括环境的设置和基本配置。更多问题&#xff0c;请查阅 一、安装 IntelliJ IDEA 1. 下载 In…

Qt多元素控件——QListWidget

文章目录 Qt多元素控件QListWidget核心属性、方法和信号使用演示 Qt多元素控件 Qt中提供了一些多元素控件&#xff1a; xxxView和xxxWidget的关系&#xff1a; xxxView是更底层的实现xxxWidget是基于xxxView封装而来的 此处的xxxView是MVC结构的典型实现&#xff0c;MVC是软件…