目录
1)基类中 virtual void Speak() = 0
十、虚函数表(vtable)+ 虚表指针(vptr)完整内存图
(2)virtual void computeControl() = 0;
(3)void run() { ctrl->computeControl(); }
封装继承多态
在之前文章中已经详细介绍了面向对象编程的三大特性:封装、继承和多态。下面是具体链接:
面向对象编程(OOP)三大特性全解析:封装 / 继承 / 多态(含现实类比与 C++ 代码)-CSDN博客
https://blog.csdn.net/m0_58954356/article/details/154478398?spm=1001.2014.3001.5502有很多小伙伴私信,封装和继承很好了解,多态怎么都学不明白。博主在此写一篇关于最近学习多态的一些自己的理解和认知,如有错误请批评指正!(我将继续用之前不同国家人说不同语言的例子循序渐进到最近的一些项目展开)
一、前言之为何学习多态
在 C++ 面向对象三大特性(封装、继承、多态)中,多态是最核心也最容易迷糊的一个。
多态解决的问题是:
同一个接口,不同对象可以给出不同的实现。
在机器人、游戏引擎、框架设计、驱动封装、策略模式等场景中,多态都是必不可少的机制。
二、多态是什么
多态 = 一个接口,多种实现。
当你用基类指针或引用指向不同的子类对象时,调用同一个方法可以产生不同的行为,这就是多态。
例如,“Person” 都会 “Speak”,但不同国家的人说不同语言:
-
Chinese → “你好!”
-
American → “Hello!”
-
Russian → “Привет!”
三、多态实现的三大条件
实现多态必须满足一下三个条件:
| 条件 | 含义 |
|---|---|
| 1. 基类函数必须是 virtual | 启用动态绑定 |
| 2. 子类必须重写父类虚函数 | 提供不同实现 |
| 3. 必须通过父类指针或引用调用 | 否则无法触发多态 |
四、完整代码示例
#include <iostream>
using namespace std;
class Person {
public:
virtual void Speak() = 0; // ★ 纯虚函数:实现多态的关键
};
class Chinese : public Person {
public:
void Speak() { cout << "你好!" << endl; }
};
class American : public Person {
public:
void Speak() { cout << "Hello!" << endl; }
};
class Russian : public Person {
public:
void Speak() { cout << "Привет!" << endl; }
};
void sayHello(Person* p) { // ★ 统一接口,面向抽象
p->Speak(); // ★ 多态调用点
}
int main() {
Chinese c;
American a;
Russian r;
sayHello(&c);
sayHello(&a);
sayHello(&r);
}
运行结果:
你好!
Hello!
Привет!
五、逐行深度解析
1)基类中 virtual void Speak() = 0
含义:
-
virtual:开启多态“动态绑定” -
=0:纯虚函数 → Person 变成“抽象类” -
Person 不能实例化,只能作为接口
这一行是多态得以存在的关键。没有 virtual,就没有多态!
2)子类重写(override)基类的虚函数
例如:
void Speak() { cout << "你好!" << endl; }
作用:
-
为自己提供独特实现
-
在虚函数表(vtable)中写入自己的函数入口
每个子类都会生成自己的 vtable(虚函数表)。
3)main() 中创建子类对象
Chinese c;
American a;
Russian r;
每个创建的子类对象的内部都产生一个隐藏指针:
对象内部:
[vptr] → 指向各自的 vtable
4)sayHello(&c):发生向上转型
sayHello(&c);
-
将
Chinese*隐式转换为Person* -
这是合法的,因为 Chinese 继承了 Person
此时形参 p:
静态类型:Person*
动态类型:Chinese*
这里就为多态埋下伏笔:“静态看父类,动态看子类”。
5)多态调用点(核心)
p->Speak();
这里发生的动作如下:
(1)运行时检查 → p 的 vptr
判断对象的实际类型是 Chinese / American / Russian
(2)查它的 vtable
vtable 内第一项就是 Speak 的入口
(3)跳转执行子类实现
例如:
-
Chinese → Chinese::Speak
-
American → American::Speak
这就是动态绑定(Dynamic Dispatch)。
六、必须用指针或引用而不能按值传递?
1)按值传递:
void sayHello(Person p) { p.Speak(); } // ❌
2)对象切片(Object Slicing):
会出现对象切片(Object Slicing):
-
子类对象传给父类按值时,会被“切掉”子类部分
-
变成一个纯粹的 Person
-
不再带有 vptr → 虚函数表变为父类的
-
多态完全消失
3)指针或引用方式:
所以必须用:
Person* p 或 Person& p
七、虚函数表(vtable)到底长什么样?
1)Chinese :
Chinese 对象:
┌───────────────┐
│ vptr ───────┼─→ [ Chinese::Speak ]
└───────────────┘
2)American:
American 对象:
┌───────────────┐
│ vptr ───────┼─→ [ American::Speak ]
└───────────────┘
3)调用 p->Speak() 时:
→ 找 vptr
→ 找到不同 vtable
→ 跳转到对应的 Speak
八、多态设计建议
| 建议 | 原因 |
|---|---|
| 给基类析构函数加 virtual | 否则 delete Person* 会导致内存泄漏 |
| 子类重写写 override | 编译器自动检查函数签名 |
| 若不允许子类再重写函数 → 用 final | 如:void Speak() override final; |
| 抽象类只负责接口,不负责实现 | 遵循面向抽象编程 |
九、多态优势总结
| 优势 | 说明 |
|---|---|
| 扩展性好 | 新增子类不需要修改 sayHello |
| 解耦 | 面向父类编程,不依赖具体实现 |
| 代码复用 | 一个接口,多种实现 |
十、虚函数表(vtable)+ 虚表指针(vptr)完整内存图
1)父类 Person 中的成员
class Person {
public:
virtual void Speak() = 0;
};
(1)编译器生成
编译器会自动为 Person 生成:
-
一个 虚函数表 vtable
-
表中存放:Person::Speak(纯虚:指向 0 或特殊入口)
(2)对象内部结构
抽象类无法实例化,但结构如下:
Person对象
┌───────────────┐
│ vptr │──→ vtable(Person)
└───────────────┘
vtable(Person):
vtable(Person):
┌──────────────────────┐
│ Person::Speak(=0) │
└──────────────────────┘
2)子类 Chinese 内存结构
class Chinese : public Person {
public:
void Speak() { cout << "你好!"; }
};
(1)对象内部:
Chinese 对象内存
┌───────────────┐
│ vptr ----------┼──→ vtable(Chinese)
└───────────────┘
(2)vtable(Chinese):
vtable(Chinese):
┌─────────────────────────────┐
│ &Chinese::Speak │
└─────────────────────────────┘
3)多态调用过程可视化
(1)调用:
Person* p = new Chinese();
p->Speak();
(2)执行流程:
p ---指向----> Chinese 对象
│
└→ vptr → vtable(Chinese)
│
└→ Chinese::Speak()
(3) C++ 多态本质
编译期不知道要调哪个函数(静态看 Person)
但运行时能根据 vptr 找到真正的实现(动态看 Chinese)。
十一、多态案例 —— 策略模式实现“不同导航算法”
1)场景:
同一辆 AGV 可以采用不同路径跟踪算法
如:Pure Pursuit、Stanley、LQR、MPC
2)代码含义:
这段代码在做两件事:
-
用多态 + 抽象接口 把“控制算法”抽象成一个统一接口
Controller -
用工厂模式 把“创建哪个控制器对象”的逻辑统一集中管理
这样:
-
导航器
Navigator只知道有个Controller,不关心具体是 MPC 还是 Stanley -
想换控制算法,只用换一个子类对象或改工厂的传参
3)抽象接口:class Controller
class Controller {
public:
virtual void computeControl() = 0;
virtual ~Controller() {}
};
逐句解释:
(1)class Controller { ... };
-
定义一个“控制器接口”
-
约定所有控制算法都必须提供同样的“对外功能”:
computeControl()
(2)virtual void computeControl() = 0;
-
virtual:虚函数 → 支持多态(运行时根据实际对象类型来调用) -
= 0:纯虚函数 → 这个类变成抽象类,不能直接实例化
任何继承
Controller的类 必须实现自己的computeControl(),否则也会变成抽象类。
(3)virtual ~Controller() {}
-
虚析构函数
-
作用:当你通过
Controller*删除子类对象时,可以正确调用子类 析构函数,防止内存泄漏
Controller* c = new MPC();
delete c; // 会先调用 MPC::~MPC(),再调 Controller::~Controller()
规范写法:
凡是有虚函数的基类,析构函数几乎都应该是虚的。
4)各种控制算法子类
(1)PurePursuit
class PurePursuit : public Controller {
public:
void computeControl() override {
cout << "使用 Pure Pursuit 控制" << endl;
}
};
-
: public Controller:公有继承 → PurePursuit 是一种 Controller -
void computeControl() override:-
覆盖(重写)父类的
computeControl() -
override告诉编译器:“我就是要重写父类虚函数,帮我检查签名是否一致”
-
输出语句只是示意:真实工程中我们会写轨迹跟踪控制律,比如计算转向角、速度等。
(2)Stanley
class Stanley : public Controller {
public:
void computeControl() override {
cout << "使用 Stanley 控制" << endl;
}
};
同样结构,只是内部实现不同。
理解为:同一个接口,不同控制策略。
5) Navigator:持有一个“控制策略”
class Navigator {
public:
Navigator(Controller* c) : ctrl(c) {}
void run() {
ctrl->computeControl(); // ★ 多态调用
}
private:
Controller* ctrl;
};
逐行解释:
(1)Controller* ctrl;(成员变量)
-
Navigator里面持有一个指向Controller的指针 -
这里不关心具体是哪种 Controller(MPC / LQR / …)
-
这就是面向抽象编程:只依赖接口,不依赖具体实现
(2)构造函数:Navigator(Controller* c) : ctrl(c) {}
-
使用构造函数初始化列表把传进来的
Controller*保存在成员变量ctrl中 -
也就是常说的依赖注入(Dependency Injection):
-
算法对象在外面创建
-
导航器只拿来用,不负责“选哪一种算法”
-
(3)void run() { ctrl->computeControl(); }
-
调用的是 基类指针
Controller*上的虚函数 -
因为前面
computeControl()是 virtual,所以这里会发生多态:-
如果 ctrl 指向 MPC → 调用的是
MPC::computeControl() -
如果 ctrl 指向 Stanley → 调用的是
Stanley::computeControl()
-
这一行就是整个系统多态的核心调用点。
6)main():如何切换算法?
int main() {
MPC m;
Navigator nav(&m); // 注入 MPC 算法
nav.run(); // 输出:使用 MPC 控制
}
逐行看:
(1)MPC m;
-
在栈上创建一个 MPC 控制器对象
(2)Navigator nav(&m);
-
把
&m(即MPC*)传给构造函数Navigator(Controller* c) -
这里发生向上转型(Upcasting):
-
子类指针
MPC*自动转换为父类指针Controller*
-
-
以后
Navigator内部就通过Controller*接口来使用这个具体的 MPC 算法
(3)nav.run();
-
内部是:
ctrl->computeControl(); -
ctrl 实际上指向的是 MPC 对象
-
因为是虚函数 → 动态绑定到
MPC::computeControl() -
所以输出:
使用 MPC 控制
(4)纯跟踪版本
PurePursuit pp;
Navigator nav(&pp);
nav.run(); // → 使用 Pure Pursuit 控制
同一份 Navigator 代码,完全不用改任何一行,控制算法已经换了。
这就是多态 + 策略模式带来的扩展性。
7)工程实践中的优化
-
用
std::unique_ptr<Controller>管理指针,避免手动delete -
工厂返回智能指针:
-
用
enum/ 配置结构体替代裸字符串
十二、C++ 多态八股文面试题(含答案)
(1)什么是多态?
一个接口,多种实现。
通过基类指针或引用指向子类对象,调用虚函数时根据对象实际类型执行不同版本。
(2)多态的触发条件?
-
父类函数必须加
virtual -
子类必须 override
-
必须通过父类指针/引用调用
(3)为什么按值传递不能多态?
因为会发生 对象切片(object slicing):
子类对象按值转换为父类对象,子类部分被切掉,vptr 被替换成父类,无法多态。
(4)什么是虚函数表(vtable)?
一个函数指针数组,存放类的虚函数入口
每个具有虚函数的类都有一张 vtable。
(5)虚表指针(vptr)是什么?
每个对象内部都有一个隐藏指针 vptr → 指向该对象所属类的 vtable。
调用虚函数时根据 vptr 动态跳转执行。
(6)什么是动态绑定?
函数执行的版本在运行时决定,而不是编译期。
(7)为什么基类要写虚析构函数?
为了通过
delete base_ptr正确调用子类析构,避免内存泄漏。
(8)override / final 的作用?
-
override:编译器检查正确重写。 -
final:阻止进一步重写。
十三、全文总结
多态 = virtual + override + 父类指针/引用 + 动态绑定(vtable)
是 C++ 最核心的面向对象能力,
多态让系统更灵活、更解耦、更容易扩展、更面向接口而不是实现。
没有 virtual → 不叫多态
按值传递 → 不叫多态
直接用子类调用 → 不叫多态
真正的多态:
Person* p = new Chinese();
p->Speak(); // 调子类实现
854

被折叠的 条评论
为什么被折叠?



