Skip to content

多态(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)。当通过基类指针调用虚函数时:

  1. 通过对象找到 vptr
  2. 通过 vptr 找到 vtable
  3. 在 vtable 中查找对应的函数指针
  4. 调用该函数

这个过程发生在运行期,因此有轻微的性能开销(一次额外的指针解引用)。在嵌入式实时系统中,这个开销通常可以接受,但需要意识到它的存在。


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_castdynamic_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()BTSequenceBTSelectorBTCondition 等子类各自重写 tick(),实现不同的行为逻辑。运行时通过基类指针数组遍历所有节点,多态机制确保每个节点执行自己的逻辑。
  • 控制器Controller 是抽象基类,SimplePID 通过重写 controllerCalculate() 实现具体控制算法。上层代码只依赖 Controller* 接口,不关心具体是哪种控制器。
  • 滤波器Filter<T> 定义滤波接口,KalmanFilterMovingAverage 等各自实现。控制器中持有一个 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