C++第14课:C++类的多态性(c++的多态性是指)
liuian 2025-07-28 18:13 9 浏览
1.概念与分类
C++ 类的多态性实现了 “一个接口,多种方法”的开发模式。同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。就像一个遥控器(接口),可以控制不同品牌的电视(对象),虽然操作方式一样(按下电源键),但每台电视的响应(开机画面、启动速度等)可能不同。在 C++ 中,多态性主要分为编译时多态和运行时多态。
1.1.编译时多态
编译时多态也叫静态多态,是在编译阶段就确定函数调用的版本。这种多态性主要通过函数重载和模板来实现。函数重载是指在同一个类中,多个函数可以具有相同的函数名,但参数列表(参数类型、个数或顺序)不同。如定义一个print函数,既可以打印整数,也可以打印字符串:
// 打印整数
void print(int num) {
std::cout << "打印整数: " << num << std::endl;
}
// 打印字符串
void print(const char* str) {
std::cout << "打印字符串: " << str << std::endl;
}
int main() {
print(10); // 调用打印整数的版本
print("Hello, C++"); // 调用打印字符串的版本
return 0;
}
在这个例子中,编译器会根据传入参数的类型来决定调用哪个print函数,这就是编译时多态的体现。模板则允许我们编写泛型代码,在编译时根据具体的类型实例化相应的函数或类,进一步增强了代码的复用性和灵活性。
编译时多态的优点是效率高,因为函数调用的版本在编译阶段就已经确定,不需要在运行时进行额外的开销。但是,它的灵活性相对较低,一旦编译完成,函数调用的版本就固定下来了。
1.2.运行时多态
运行时多态也叫动态多态,是在程序运行时根据对象的实际类型来决定函数调用的版本。它主要通过虚函数和继承来实现。当基类中定义了虚函数,派生类可以重写(override)这个虚函数。在运行时,通过基类指针或引用调用虚函数时,会根据指针或引用实际指向的对象类型,来决定调用哪个类的虚函数版本 。例如:
public:
// 虚函数
virtual void speak() const {
std::cout << "动物发出声音" << std::endl;
}
};
class Dog : public Animal {
public:
// 重写虚函数
void speak() const override {
std::cout << "狗汪汪叫" << std::endl;
}
};
class Cat : public Animal {
public:
// 重写虚函数
void speak() const override {
std::cout << "猫喵喵叫" << std::endl;
}
};
int main() {
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
animal1->speak(); // 输出 "狗汪汪叫"
animal2->speak(); // 输出 "猫喵喵叫"
delete animal1;
delete animal2;
return 0;
}
在这个例子中,Animal类中的speak函数被声明为虚函数,Dog类和Cat类重写了speak函数。通过Animal类型的指针调用speak函数时,实际调用的是指针所指向对象的具体类型(Dog或Cat)的speak函数版本,这就是运行时多态的体现。
2.函数重载与模版
2.1.函数重载
函数重载是指在同一个作用域内,定义多个同名函数,但这些函数的参数列表(参数个数、类型或顺序)不同。编译器会根据调用函数时传入的实际参数,在编译阶段选择合适的函数版本进行调用。例如:
void print(int num) {
std::cout << "打印整数: " << num << std::endl;
}
void print(double num) {
std::cout << "打印双精度浮点数: " << num << std::endl;
}
在上述代码中,我们定义了两个名为print的函数,一个接受int类型参数,另一个接受double类型参数。当我们调用print(5)时,编译器会根据参数类型选择第一个print函数;当调用print(3.14)时,编译器会选择第二个print函数。
2.2.模版
模板包括函数模板和类模板,它允许开发者编写通用的代码,根据不同的类型参数生成不同的函数或类。例如,下面是一个简单的函数模板:
template<typename T>
T add(T a, T b) {
return a + b;
}
这个函数模板可以用于不同类型的加法运算。当我们调用add(3, 5)时,编译器会根据传入的参数类型int,生成一个专门处理int类型的add函数;当调用add(3.14, 2.71)时,编译器会生成处理double类型的add函数。
3.虚函数
虚函数是 C++ 运行时多态的核心机制。简单来说,虚函数是在基类中使用virtual关键字声明的成员函数,它可以在派生类中被重新定义。当通过基类指针或引用调用虚函数时,程序会在运行时根据指针或引用实际指向的对象类型,来决定调用哪个类的虚函数版本,这就是所谓的动态绑定。
举个例子,假设有一个图形类Shape,它有一个绘制函数draw,而Circle和Rectangle是从Shape派生出来的具体图形类,它们都有自己独特的绘制方式 。代码如下:
class Shape {
public:
// 虚函数
virtual void draw() const {
std::cout << "绘制一个形状" << std::endl;
}
};
class Circle : public Shape {
public:
// 重写虚函数
void draw() const override {
std::cout << "绘制一个圆形" << std::endl;
}
};
class Rectangle : public Shape {
public:
// 重写虚函数
void draw() const override {
std::cout << "绘制一个矩形" << std::endl;
}
};
void drawShape(const Shape& shape) {
shape.draw();
}
int main() {
Circle circle;
Rectangle rectangle;
drawShape(circle); // 输出 "绘制一个圆形"
drawShape(rectangle); // 输出 "绘制一个矩形"
return 0;
}
在这个例子中,Shape类中的draw函数被声明为虚函数。Circle类和Rectangle类重写了draw函数,提供了各自的绘制逻辑 。在main函数中,我们定义了Circle和Rectangle的对象,并将它们传递给drawShape函数。drawShape函数接受一个Shape类型的引用,通过这个引用调用draw函数时,实际调用的是传入对象的具体类型(Circle或Rectangle)的draw函数版本,这就是虚函数实现运行时多态的过程。
对比一下普通函数和虚函数的调用。如果Shape类中的draw函数不是虚函数,那么通过Shape类型的指针或引用调用draw函数时,无论指针或引用实际指向的是哪个派生类对象,都会调用Shape类中的draw函数版本 ,这就无法实现多态效果。例如:
class Shape {
public:
// 普通函数
void draw() const {
std::cout << "绘制一个形状" << std::endl;
}
};
class Circle : public Shape {
public:
// 这个函数与基类的draw函数不是重写关系,而是隐藏
void draw() const {
std::cout << "绘制一个圆形" << std::endl;
}
};
class Rectangle : public Shape {
public:
// 这个函数与基类的draw函数不是重写关系,而是隐藏
void draw() const {
std::cout << "绘制一个矩形" << std::endl;
}
};
void drawShape(const Shape& shape) {
shape.draw();
}
int main() {
Circle circle;
Rectangle rectangle;
drawShape(circle); // 输出 "绘制一个形状"
drawShape(rectangle); // 输出 "绘制一个形状"
return 0;
}
3.1.虚函数重写规则
在 C++ 中,虚函数的重写需要遵循一定的规则,以确保多态行为的正确实现:
(1)函数签名一致
派生类中重写的虚函数必须与基类中的虚函数具有相同的函数名、参数列表(参数类型、个数和顺序)以及返回值类型 (除了协变返回类型的特殊情况,稍后会介绍)。例如:
class Base {
public:
virtual void func(int a) {
std::cout << "Base::func(int)" << std::endl;
}
};
class Derived : public Base {
public:
// 正确重写,函数名、参数列表相同
void func(int a) override {
std::cout << "Derived::func(int)" << std::endl;
}
};
在这个例子中,Derived类中的func函数与Base类中的func函数具有相同的函数名和参数列表,因此是正确的重写。
(2)协变返回类型
在 C++11 及以后的标准中,如果派生类重写的虚函数返回的是基类中虚函数返回类型的派生类类型,这种情况是允许的,称为协变返回类型 。例如:
class BaseClass {
public:
virtual ~BaseClass() {}
};
class DerivedClass : public BaseClass {
public:
virtual ~DerivedClass() {}
};
class Base {
public:
virtual BaseClass* clone() {
return new Base();
}
};
class Derived : public Base {
public:
// 协变返回类型,返回DerivedClass* 是BaseClass* 的派生类类型
DerivedClass* clone() override {
return new Derived();
}
};
在这个例子中,Base类的clone函数返回BaseClass*,Derived类重写的clone函数返回DerivedClass*,DerivedClass是BaseClass的派生类,这种重写是合法的。
(3)析构函数的重写
当基类的析构函数被声明为虚函数时,派生类的析构函数会自动重写基类的析构函数,即使它们的函数名不同 (因为析构函数的名字是固定的,与类名相关)。这一点非常重要,因为当通过基类指针删除派生类对象时,如果基类析构函数不是虚函数,可能会导致派生类的析构函数无法被正确调用,从而产生内存泄漏等问题 。例如:
class Base {
public:
// 虚析构函数
virtual ~Base() {
std::cout << "Base析构函数" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() override {
std::cout << "Derived析构函数" << std::endl;
}
};
int main() {
Base* basePtr = new Derived();
delete basePtr; // 会先调用Derived的析构函数,再调用Base的析构函数
return 0;
}
在这个例子中,Base类的析构函数被声明为虚函数,当通过Base指针删除Derived对象时,会先调用Derived的析构函数,再调用Base的析构函数,确保资源的正确释放。如果Base的析构函数不是虚函数,那么delete basePtr时只会调用Base的析构函数,Derived的析构函数不会被调用,可能会导致Derived对象中分配的资源无法释放。
3.2.虚函数表与虚指针
为了实现虚函数的动态绑定,C++ 引入了虚函数表(vtable)和虚指针(vptr)的概念 。
虚函数表:每个包含虚函数的类都有一个虚函数表,它是一个函数指针数组,存储了该类中所有虚函数的地址 。虚函数表是在编译时生成的,并且对于每个类来说是唯一的。当一个类从另一个包含虚函数的类继承时,它会继承基类的虚函数表,并根据需要更新虚函数表中的条目(如果派生类重写了基类的虚函数) 。
虚指针:每个包含虚函数的类的对象都有一个虚指针,它指向该类的虚函数表 。虚指针通常位于对象内存布局的开头(在不同编译器实现中可能略有不同,但一般都在对象头部),它是对象的一部分,用于在运行时找到对象所属类的虚函数表,从而实现虚函数的动态调用 。
下面通过一个示例来更直观地理解虚函数表和虚指针的工作原理 :
class Base {
public:
virtual void func1() {
std::cout << "Base::func1" << std::endl;
}
virtual void func2() {
std::cout << "Base::func2" << std::endl;
}
};
class Derived : public Base {
public:
void func1() override {
std::cout << "Derived::func1" << std::endl;
}
};
int main() {
Base* basePtr = new Derived();
// 获取对象的虚指针,假设虚指针位于对象内存布局的开头
auto vptr = *(reinterpret_cast<void**>(basePtr));
// 获取虚函数表中func1函数的指针
auto func1Ptr = *reinterpret_cast<void***>(vptr);
// 调用func1函数
auto func1 = reinterpret_cast<void(*)()>(func1Ptr);
func1();
delete basePtr;
return 0;
}
Base类包含两个虚函数func1和func2,编译器会为Base类生成一个虚函数表,并在Base类的对象中添加一个虚指针,指向这个虚函数表。
当Derived类继承Base类并重写func1函数时,Derived类会继承Base类的虚函数表,并将虚函数表中func1函数的指针更新为指向Derived::func1的地址 。
在main函数中,创建了一个Derived类的对象,并通过Base指针指向它。通过获取对象的虚指针,进而获取虚函数表中func1函数的指针,最后调用func1函数 。由于Derived类重写了func1函数,所以实际调用的是Derived::func1,这就展示了虚函数表和虚指针如何在运行时实现多态调用 。
需要注意的是,上述通过指针直接操作虚函数表和虚指针的代码只是为了演示原理,在实际编程中,我们通常不会这样做,而是直接使用虚函数的语法来实现多态,这样更安全、更易读 。虚函数表和虚指针是 C++ 编译器内部实现多态的机制,了解它们的工作原理有助于我们更好地理解运行时多态的实现过程,但在日常开发中,我们更多地是利用虚函数带来的多态特性来编写灵活、可扩展的代码 。
4.多态性练习—图形绘制系统
在图形绘制系统中,多态性有着广泛的应用。我们可以定义一个抽象的图形基类Shape,并在其中声明一个纯虚函数draw 。这个纯虚函数为所有派生的具体图形类提供了一个统一的绘制接口。例如:
class Shape {
public:
// 纯虚函数,没有具体实现,要求派生类必须重写
virtual void draw() const = 0;
};
然后,从Shape类派生出不同的具体图形类,如Circle(圆形)、Rectangle(矩形)等,并在各自的派生类中实现draw函数 ,来提供具体的绘制逻辑 。
class Circle : public Shape {
public:
void draw() const override {
std::cout << "绘制一个圆形" << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() const override {
std::cout << "绘制一个矩形" << std::endl;
}
};
在实际绘制图形时,我们可以使用Shape类型的指针或引用来存储不同类型的图形对象 。当调用draw函数时,根据对象的实际类型,会自动调用相应派生类中的draw函数实现 。这样,我们就可以通过一个统一的接口来绘制各种不同的图形,而不需要为每种图形编写单独的绘制函数 。例如:
void drawShapes(const Shape* shapes[], int count) {
for (int i = 0; i < count; ++i) {
shapes[i]->draw();
}
}
int main() {
Shape* shapes[2];
shapes[0] = new Circle();
shapes[1] = new Rectangle();
drawShapes(shapes, 2);
// 释放内存
for (int i = 0; i < 2; ++i) {
delete shapes[i];
}
return 0;
}
在这个例子中,drawShapes函数接受一个Shape指针数组和数组的大小,通过循环调用每个Shape对象的draw函数,实现了对不同类型图形的统一绘制 。当需要添加新的图形类型时,只需要从Shape基类派生新的类,并实现draw函数,而不需要修改现有的绘制代码,这大大提高了系统的可扩展性和灵活性 。
5.多态性练习—游戏开发
在游戏开发领域,多态性同样发挥着重要作用。以角色扮演游戏(RPG)为例,游戏中有各种不同类型的角色,如战士(Warrior)、法师(Mage)、弓箭手(Archer)等 ,每个角色都有自己独特的行为和属性 。我们可以定义一个抽象的角色基类Character,并在其中声明一些虚函数,如attack(攻击)、move(移动)等 ,来描述角色的基本行为。
class Character {
public:
virtual void attack() const = 0;
virtual void move() const = 0;
};
然后,从Character类派生出不同的具体角色类,并在每个派生类中实现这些虚函数,以体现不同角色的行为差异 。
class Warrior : public Character {
public:
void attack() const override {
std::cout << "战士挥舞大剑进行攻击" << std::endl;
}
void move() const override {
std::cout << "战士快速冲向敌人" << std::endl;
}
};
class Mage : public Character {
public:
void attack() const override {
std::cout << "法师释放魔法进行攻击" << std::endl;
}
void move() const override {
std::cout << "法师瞬移到安全位置" << std::endl;
}
};
class Archer : public Character {
public:
void attack() const override {
std::cout << "弓箭手射出利箭进行攻击" << std::endl;
}
在游戏循环中,我们可以使用Character类型的指针或引用来操作不同类型的角色 。当角色执行攻击或移动等操作时,会根据角色的实际类型调用相应派生类中的函数实现 。这样,游戏引擎就可以通过一个统一的接口来管理和控制各种不同类型的角色,使得代码更加简洁、灵活,易于维护和扩展 。例如:
void playGame(Character* characters[], int count) {
for (int i = 0; i < count; ++i) {
characters[i]->attack();
characters[i]->move();
}
}
int main() {
Character* characters[3];
characters[0] = new Warrior();
characters[1] = new Mage();
characters[2] = new Archer();
playGame(characters, 3);
// 释放内存
for (int i = 0; i < 3; ++i) {
delete characters[i];
}
return 0;
}
在这个例子中,playGame函数模拟了游戏的运行过程,通过调用每个Character对象的attack和move函数,实现了不同角色的攻击和移动行为 。如果后续需要添加新的角色类型,只需要从Character基类派生新的类,并实现相应的虚函数,而游戏的核心逻辑(如游戏循环、角色管理等)无需大幅修改,这充分展示了多态性在游戏开发中的优势 。
- 上一篇:必须掌握的数据核对技巧,收藏备用
- 下一篇:检查文本是否相同EXACT函数应用技巧
相关推荐
- Javascript中的字符串拼接(js字符串拼接效率)
-
字符串拼接是所有程序设计语言都需要的操作。当拼接结果较长时,如何保证效率就成为一个很重要的问题。本文介绍的是Javascript中的字符串拼接,希望对你有帮助,一起来看。consticon=...
- C#-字符串常用方法(2) 104(c#字符串处理函数)
-
IndexOf()查找某字符或字符串在要查找字符串中第一次出现的下标,与数组相同从零开始,如果没有找到对应的数据,则返回-1LastIndexOf()查找某字符或字符串在要查找字符串中最后一次出现...
- C#拼接字符串及简单性能比较(c# 如何高效拼接字符串)
-
在C#编程中拼接字符串应该是最常见的场景之一,假如现在有几个变量需要转换成字符串并按格式拼接,常用的几种方法:inta=1;charb='c';doublec=1...
- C# 字符串连接方式有哪些?各自有什么特点?
-
在C#中,有几种方式可以连接字符串,每种方式都有其特点和适用场景。以下是常见的几种方式:使用连接符"+":使用加号"+"可以简单地将两个字符串连接起来。这种方式简...
- 字符串也可以比较大小?C语言的strcmp函数
-
在C语言中,因为字符串没有被封装成对象,所以直接用==、!=比较。C语言提供了一个用于字符串比较的函数类型strcmp。注意,字符串和字符完全不同,因为不同的编译器实现问题,返回值的情况也有所变化...
- JavaScript 常用功能总结(javascript的功能)
-
编吐血整理加上翻译,太辛苦了~求赞!本文主要总结了JavaScript常用功能总结,如一些常用的额JS对象,基本数据结构,功能函数等,还有一些常用的设计模式。目录:众所周知,JavaScript是...
- C++20 新特性(16):三路比较运算符()一统浆糊
-
C++语言的比较操作符有很多,包括:==、!=、>、>=、<、<=等,如果要实现对象的大小比较功能,需要实现这些函数,比较繁琐,而且如果实现不好,这些...
- C++中重载运算符的使用(c++重载操作符)
-
C++中重载运算符的使用:用户定义的类型,如:字符串,日期,复数,联合体以及文件常常重载二元+操作符以实现对象的连接,附加或合并机制。但是要正确实现+操作符会给设计,实现和性能带来一定的挑战。...
- 《学习C++》基本概念之标识符(c++标识符的定义)
-
标识符(identifier)是C++基本词法单元(即令牌token)之一,由字母(letter)、数字(digit)、下划线(underscore)组成,但是首字符必须为字母或下换线。标识符的用途主...
- Java判断Integer相等-应该这么这样用
-
先看下这段代码,然后猜下结果:Integeri1=50;Integeri2=50;Integeri3=128;Integeri4=128;System.out.prin...
- C语言模拟实现字符串操作函数(c语言模拟实现字符串操作函数是什么)
-
1.strlenstrlen是用来求字符串长度的函数,字符串长度就是字符串中包含的字符的个数,但是不包含字符串结尾的‘\0’实现strlen有三种方法:(1)定义一个计数器size_t ...
- C++ 知识小结(c++知识总结)
-
C语言typedef与#define比较https://www.runoob.com/note/24230野指针和悬空指针区别?野指针:指的是没有被初始化过的指针。解决方法:指针变量未及时...
- Python字符串比较的隐藏法则:Unicode对决、内存地址暗战!
-
字符串比较的底层规则核心原理:字符逐个对比,基于Unicode值一决胜负!#规则演示:从首字符开始逐位比较print("apple">"app")...
- 在C++中,如何避免出现Bug?(c++防止闪退)
-
C++中的主要问题之一是存在大量行为未定义或对程序员来说意外的构造。我们在使用静态分析器检查各种项目时经常会遇到这些问题。但正如我们所知,最佳做法是在编译阶段尽早检测错误。让我们来看看现代C++中的一...
- harmony-utils之StrUtil,字符串工具类
-
harmony-utils简介与说明harmony-utils一款功能丰富且极易上手的HarmonyOS工具库,借助众多实用工具类,致力于助力开发者迅速构建鸿蒙应用。其封装的工具涵盖了APP、设备...
- 一周热门
-
-
Python实现人事自动打卡,再也不会被批评
-
【验证码逆向专栏】vaptcha 手势验证码逆向分析
-
Psutil + Flask + Pyecharts + Bootstrap 开发动态可视化系统监控
-
一个解决支持HTML/CSS/JS网页转PDF(高质量)的终极解决方案
-
再见Swagger UI 国人开源了一款超好用的 API 文档生成框架,真香
-
网页转成pdf文件的经验分享 网页转成pdf文件的经验分享怎么弄
-
C++ std::vector 简介
-
系统C盘清理:微信PC端文件清理,扩大C盘可用空间步骤
-
飞牛OS入门安装遇到问题,如何解决?
-
10款高性能NAS丨双十一必看,轻松搞定虚拟机、Docker、软路由
-
- 最近发表
- 标签列表
-
- python判断字典是否为空 (50)
- crontab每周一执行 (48)
- aes和des区别 (43)
- bash脚本和shell脚本的区别 (35)
- canvas库 (33)
- dataframe筛选满足条件的行 (35)
- gitlab日志 (33)
- lua xpcall (36)
- blob转json (33)
- python判断是否在列表中 (34)
- python html转pdf (36)
- 安装指定版本npm (37)
- idea搜索jar包内容 (33)
- css鼠标悬停出现隐藏的文字 (34)
- linux nacos启动命令 (33)
- gitlab 日志 (36)
- adb pull (37)
- table.render (33)
- python判断元素在不在列表里 (34)
- python 字典删除元素 (34)
- vscode切换git分支 (35)
- python bytes转16进制 (35)
- grep前后几行 (34)
- hashmap转list (35)
- c++ 字符串查找 (35)