多态(Polymorphism)
多态是面向对象的核心机制,它允许"同一个接口,根据对象的实际类型表现不同的行为"。在 C++ 中,多态依赖虚函数(virtual function)实现。
1. 概念——为什么需要多态
1.1 静态绑定的局限
在继承体系中,如果没有虚函数,通过父类指针调用函数时,永远调用父类的版本:
class Filter {
public:
float update(float input) { return input; } // 非虚函数——默认行为:直通
};
class KalmanFilter : public Filter {
public:
float update(float input) { /* 卡尔曼滤波逻辑 */ return m_state; }
private:
float m_state = 0.0f;
};
class MovingAverage : public Filter {
public:
float update(float input) { /* 滑动平均逻辑 */ return m_avg; }
private:
float m_avg = 0.0f;
};
// 使用场景:用一个数组存放不同类型的滤波器
Filter* filters[2];
filters[0] = new KalmanFilter();
filters[1] = new MovingAverage();
filters[0]->update(10.0f); // 调用 Filter::update,而非 KalmanFilter::update!
filters[1]->update(10.0f); // 同样调用 Filter::update
原因: 编译器在编译期根据指针的静态类型(Filter*)决定了调用哪个函数,这就是静态绑定(Static Binding)。它完全不关心指针实际指向的对象是什么类型。
1.2 多态的解决方案
加上 virtual 关键字后,函数调用在运行期根据对象的真实类型决定:
class Filter {
public:
virtual float update(float input) { return input; } // 虚函数
virtual ~Filter() = default; // 虚析构函数(见第 7 节)
};
此后:
filters[0]->update(10.0f); // 运行期发现是 KalmanFilter → 调用 KalmanFilter::update
filters[1]->update(10.0f); // 运行期发现是 MovingAverage → 调用 MovingAverage::update
结论: 没有 virtual → 静态绑定(看指针类型);有 virtual → 动态绑定(看对象类型)。这就是多态的开关。
2. 虚函数(Virtual Function)
2.1 基本语法
class BTNode {
public:
virtual ~BTNode() = default; // 虚析构函数
virtual BTStatus tick() = 0; // 纯虚函数(见第 5 节)
virtual void reset() {} // 普通虚函数(有默认实现)
};
2.2 override 关键字
在子类中重写虚函数时,建议加上 override:
class SequenceNode : public BTNode {
public:
BTStatus tick() override { // 明确标注"这是重写"
for (auto& child : m_children) {
if (child->tick() == BTStatus::FAILURE)
return BTStatus::FAILURE;
}
return BTStatus::SUCCESS;
}
};
override 的作用:
- 向读者明确表达"这个函数重写了基类的虚函数"
- 让编译器检查函数签名是否与基类完全一致(如果不一致会报编译错误,而不是静默地变成一个新的函数)
2.3 final 关键字
如果某个虚函数不希望被进一步重写,可以用 final:
class CriticalNode : public BTNode {
public:
BTStatus tick() override final { /* ... */ } // 子类不能再重写 tick()
};
也可以用于整个类:
class FinalFilter final : public Filter {
// 此类不能被继承
};
3. 多态的本质——虚函数表(vtable)
多态不是魔法,它由编译器通过虚函数表(vtable)实现。
Filter 类(有虚函数):
┌─────────────────┐
│ vtable 指针 │ ──→ ┌──────────────────────┐
│ (隐藏成员) │ │ &Filter::update │
├─────────────────┤ │ &Filter::~Filter │
│ 其它成员变量 │ └──────────────────────┘
└─────────────────┘
KalmanFilter 类(重写了 update):
┌─────────────────┐
│ vtable 指针 │ ──→ ┌───────────────────────────┐
│ (隐藏成员) │ │ &KalmanFilter::update │ ← 指向子类版本
├─────────────────┤ │ &KalmanFilter::~KalmanFilter│
│ m_state │ └───────────────────────────┘
│ m_covariance │
│ ... │
└─────────────────┘
每个含有虚函数的类都有一个隐藏的虚函数表指针(vptr),指向一张函数指针表(vtable)。当通过基类指针调用虚函数时:
- 通过对象找到 vptr
- 通过 vptr 找到 vtable
- 在 vtable 中查找对应的函数指针
- 调用该函数
这个过程发生在运行期,因此有轻微的性能开销(一次额外的指针解引用)。在嵌入式实时系统中,这个开销通常可以接受,但需要意识到它的存在。
4. 重载、隐藏与重写——三个易混淆的概念
这三个概念名称相似,但本质完全不同。按"是否发生在继承中"划分:
4.1 重载(Overloading)——不涉及继承
重载发生在同一个类内部:函数名相同,但参数列表不同。
class Logger {
public:
void log(const char* msg); // 版本 1
void log(const char* msg, int id); // 版本 2:参数不同 → 重载
};
Logger logger;
logger.log("error"); // 调用版本 1
logger.log("error", 404); // 调用版本 2
| 特征 | 说明 |
|---|---|
| 发生位置 | 同一个类内部 |
| 是否需要 virtual | 否 |
| 是否需要继承 | 否 |
| 绑定方式 | 编译期(静态绑定) |
| 与多态的关系 | 无关 |
4.2 隐藏(Hiding)——继承中名字遮挡
隐藏发生在继承关系中:子类定义了一个与父类同名的函数(无论参数是否相同、无论是否为虚函数),父类的同名函数就被遮挡。
class Base {
public:
void show(int x) { /* ... */ }
};
class Derived : public Base {
public:
void show() { /* ... */ } // 名字相同 → 隐藏了 Base::show(int)
};
Derived d;
d.show(); // 调用 Derived::show
// d.show(10); // ❌ 编译错误:Base::show(int) 被隐藏了
解决方法: 使用 using 声明将父类的同名函数引入子类作用域:
class Derived : public Base {
public:
using Base::show; // 将 Base 中所有名为 show 的函数引入
void show() { /* ... */ } // 子类版本
};
Derived d;
d.show(); // 调用 Derived::show
d.show(10); // 调用 Base::show(int) —— 不再被隐藏
| 特征 | 说明 |
|---|---|
| 发生位置 | 继承关系中 |
| 触发条件 | 子类出现与父类同名的函数 |
| 是否需要 virtual | 否 |
| 绑定方式 | 编译期(静态绑定) |
| 与多态的关系 | 无关 |
4.3 重写(Override)——多态的核心
重写发生在继承关系中,子类重新实现父类的虚函数。这是实现多态的机制。
class Filter {
public:
virtual float update(float input) { return input; }
};
class KalmanFilter : public Filter {
public:
float update(float input) override { /* 子类实现 */ return 0.0f; }
};
Filter* f = new KalmanFilter();
f->update(10.0f); // 动态绑定 → 调用 KalmanFilter::update
| 特征 | 说明 |
|---|---|
| 发生位置 | 继承关系中 |
| 是否需要 virtual | 是(父类函数必须是虚函数) |
| 函数签名 | 必须与父类完全一致 |
| 绑定方式 | 运行期(动态绑定) |
| 与多态的关系 | 多态的实现方式 |
5. 纯虚函数与抽象类
5.1 纯虚函数
如果基类无法提供有意义的默认实现,可以声明为纯虚函数:
class BTNode {
public:
virtual BTStatus tick() = 0; // = 0 表示纯虚函数——没有实现,子类必须重写
};
5.2 抽象类
包含纯虚函数的类称为抽象类(Abstract Class)。抽象类不能直接实例化:
BTNode node; // ❌ 编译错误:BTNode 是抽象类,不能实例化
class SequenceNode : public BTNode {
public:
BTStatus tick() override { /* ... */ } // 必须实现
};
SequenceNode seq; // ✅ 可以实例化——它实现了所有纯虚函数
抽象类的作用是定义接口规范:告诉所有派生类"你必须实现这些函数",但不规定具体如何实现。这在 GSRL 的行为树、滤波器、控制器体系中大量使用。
6. 类型转换:static_cast 与 dynamic_cast
在多态体系中,经常需要将父类指针转换为子类指针。
6.1 static_cast——编译期转换
Filter* f = new KalmanFilter();
KalmanFilter* kf = static_cast<KalmanFilter*>(f); // 直接转换,不检查
- 快,但不安全——如果
f实际不是KalmanFilter,结果是未定义行为 - 不依赖虚函数,编译期完成
- 适用于确定类型正确的场景
6.2 dynamic_cast——运行期检查
Filter* f = getSomeFilter(); // 不确定具体类型
KalmanFilter* kf = dynamic_cast<KalmanFilter*>(f);
if (kf) {
// 转换成功,可以安全使用 KalmanFilter 特有的成员
kf->setProcessNoise(0.1f);
} else {
// 转换失败,f 不是 KalmanFilter
}
MovingAverage* ma = dynamic_cast<MovingAverage*>(f);
if (ma) {
ma->setWindowSize(10);
}
- 安全:转换失败时返回
nullptr - 依赖虚函数表(vtable),要求基类有虚函数
- 有运行期开销(类型检查)
| 特性 | static_cast |
dynamic_cast |
|---|---|---|
| 安全性 | 不安全 | 安全(失败返回 nullptr) |
| 速度 | 快 | 较慢(运行期类型检查) |
| 依赖虚函数 | 否 | 是 |
| 使用场景 | 类型确定 | 类型不确定,需要安全转换 |
GSRL 中偏好
static_cast,因为嵌入式环境追求性能且类型关系通常在编译期已明确。行为树节点的类型转换会借助static_cast完成。
7. 虚析构函数——容易被忽视的关键点
当通过基类指针删除派生类对象时,如果析构函数不是虚函数,只会调用基类的析构函数,导致派生类的资源泄漏:
class Filter {
public:
~Filter() { /* 释放 Filter 的资源 */ } // 非虚析构函数——危险!
};
class KalmanFilter : public Filter {
public:
~KalmanFilter() { /* 释放矩阵内存 */ } // 这个析构函数不会被调用!
private:
float* m_buffer = new float[100]; // 这块内存泄漏了
};
Filter* f = new KalmanFilter();
delete f; // 只调用 Filter::~Filter(),不调用 KalmanFilter::~KalmanFilter()
解决方案: 将基类的析构函数声明为 virtual:
class Filter {
public:
virtual ~Filter() = default; // 虚析构函数——正确!
};
现在 delete f 会通过虚函数表找到 KalmanFilter::~KalmanFilter(),先调用子类析构,再调用父类析构,资源被正确释放。
原则: 如果一个类被设计为基类(会被继承),它的析构函数应该声明为 virtual,即使函数体为空。
8. 与 GSRL 的关系
多态在 GSRL 中无处不在:
- 行为树节点:
BTNode是抽象基类,定义纯虚函数tick()。BTSequence、BTSelector、BTCondition等子类各自重写tick(),实现不同的行为逻辑。运行时通过基类指针数组遍历所有节点,多态机制确保每个节点执行自己的逻辑。 - 控制器:
Controller是抽象基类,SimplePID通过重写controllerCalculate()实现具体控制算法。上层代码只依赖Controller*接口,不关心具体是哪种控制器。 - 滤波器:
Filter<T>定义滤波接口,KalmanFilter、MovingAverage等各自实现。控制器中持有一个Filter<fp32>*成员,通过多态可以在不修改控制器代码的情况下切换滤波器。
在嵌入式约束下,GSRL 使用多态时遵循以下原则:
- 虚函数数量尽量少,减少 vtable 体积
- 避免在中断服务例程(ISR)中进行 dynamic_cast 等带运行期开销的操作
- 接口类(如 BTNode)保持简洁,纯虚函数数量控制在个位数
9. 总结
| 概念 | 核心要点 |
|---|---|
| 静态绑定 | 编译期根据指针类型决定调用哪个函数(无 virtual) |
| 动态绑定 | 运行期根据对象实际类型决定调用哪个函数(有 virtual) |
| 虚函数(virtual) | 启用动态绑定的语法机制 |
| 虚函数表(vtable) | 虚函数运行期分发的底层实现——每个类一张函数指针表 |
override |
显式标注重写,编译器检查签名一致性 |
final |
禁止进一步重写(函数级或类级) |
纯虚函数(= 0) |
基类不提供实现,子类必须重写 |
| 抽象类 | 包含纯虚函数的类,不能直接实例化 |
| 重载(Overload) | 同一类内同名不同参,编译期决议,与多态无关 |
| 隐藏(Hiding) | 继承中子类同名函数遮挡父类版本,编译期决议 |
| 重写(Override) | 子类重写父类虚函数,运行期决议,多态的实现方式 |
static_cast |
编译期类型转换,快但不安全 |
dynamic_cast |
运行期类型转换,安全(依赖虚函数表) |
| 虚析构函数 | 基类析构必须为 virtual,确保通过基类指针删除时正确释放资源 |
多态的前提是继承(详见《面向对象编程.md》)。而多态体系中常见的"只复制指针、不复制数据"的语义,则由移动语义来高效实现(详见《移动语义.md》)。
作者: Qing | 修改日期: 2026-07-18