跳转至

继承与多态深入

难度:⭐⭐~⭐⭐⭐⭐ | 建议用时:2周 | 前置要求:现代类设计与特殊成员函数、RAII与智能指针 RAII 与智能指针、移动语义与完美转发


前置自测

📋 答不出 >= 2 题时,先回顾 现代类设计与特殊成员函数-移动语义与完美转发 的对应内容,再进入本章。

  1. 现代类设计与特殊成员函数 中的 Rule of Zero、Rule of Five 分别适合什么类?一个管理资源的类为什么通常要同时考虑析构、拷贝和移动?
  2. RAII与智能指针 中 std::unique_ptr<T> 表达什么所有权?std::shared_ptr<T> 又表达什么所有权?两者在生命周期上有什么根本差异?
  3. 移动语义与完美转发 中 std::move 的本质是什么?它会不会真的搬动内存中的对象?
  4. 如果一个函数参数写成 Base b,而你传入 Derived 对象,会发生什么?
  5. 一个机器人系统如果想通过配置文件切换 ICP、GICP、NDT 三种配准算法,C++ 类型系统需要提供什么能力?

本章目标

学完本章,你将能够:

  • 判断什么时候该使用继承,什么时候该使用组合、模板或函数对象,避免把继承当成代码复用工具。
  • 解释派生对象中的基类子对象、对象切片、向上转型和动态派发的内存模型。
  • 理解虚函数表和虚函数指针在主流 ABI 中的实现方式,同时知道这些布局细节不是 C++ 标准承诺。
  • 正确设计抽象基类:纯虚函数、虚析构函数、overridefinal、复制/移动策略和 clone() 模式。
  • 识别多态代码中的高风险点:非虚析构、对象切片、构造/析构期间虚调用、默认参数静态绑定、同名函数隐藏。
  • 在 g2o、PCL、OpenVINS、ROS2 pluginlib 等机器人项目中读懂运行时多态接口。
  • 为 类型转换 的 dynamic_cast、Lambda与STL算法 的回调与函数对象、变参模板折叠表达式与CRTP 的 CRTP、Concepts与Policy 的 Policy-based Design 建立清晰边界。

本章在课程中的位置:现代类设计与特殊成员函数 解决”一个类该如何管理自己的资源和特殊成员函数”,RAII与智能指针 解决”资源生命周期如何自动释放”,移动语义与完美转发 解决”大对象所有权如何高效转移”。本章把这些能力放进真实机器人系统的接口设计中:当一个系统需要在运行时切换算法模块时,C++ 需要一种”只依赖共同接口,却能调用具体实现”的机制,这就是继承与多态要解决的问题。

知识树

继承与多态深入
├── 为什么需要继承 ⭐⭐ ← 可替换性,不是代码复用
│   └── LSP(Liskov 替换原则)
├── 派生对象与对象切片 ⭐⭐⭐ ← 为什么多态必须用指针/引用
│   └── clone() 模式
├── 虚函数与 vtable ⭐⭐⭐ ← 动态派发的内存实现
│   ├── vptr 在构造过程中的变化
│   ├── 虚函数性能:直接开销 vs 间接开销
│   └── final 与去虚化
├── 抽象类与纯虚函数 ⭐⭐⭐ ← 接口契约
│   ├── 模板方法模式
│   └── g2o 风格:虚接口 + 模板参数混合
├── 虚析构函数 ⭐⭐⭐ ← 多态所有权安全
│   └── unique_ptr vs shared_ptr 的删除差异
├── override / final 与签名陷阱 ⭐⭐⭐
│   ├── 协变返回
│   ├── 默认参数静态绑定
│   └── 同名函数隐藏
├── 多继承与虚继承 ⭐⭐⭐⭐ ← 接口组合
│   └── 菱形问题与虚基类
├── 构造/析构期虚调用 ⭐⭐⭐ ← 多态边界
│   └── 两阶段初始化模式
└── 运行时多态 vs 静态多态选型 ⭐⭐⭐⭐
    ├── 架构层 vs 数值层分层
    └── pluginlib 模式

6.1 为什么需要继承:可替换性,而不是代码复用 ⭐⭐

动机:配置文件切换算法时,调用端不该跟着变

本节从一个具体的机器人工程需求出发——"在运行时切换算法实现"——引出继承与多态的核心价值。理解这个需求是理解后续所有内容(虚函数、vtable、抽象类、override)的动机基础。

考虑一个 LiDAR SLAM 前端。系统启动时读取配置:

registration:
  type: GICP
  max_iterations: 30
  voxel_resolution: 0.5

调用端只想写:

RegistrationResult result = registration->align(source, target);

它不想关心当前用的是 ICP、GICP 还是 NDT。因为调用端真正需要的是“配准模块能把 source 对齐到 target”,而不是“某个具体类的内部算法细节”。

如果不用多态,最直接的写法是到处写 switch

RegistrationResult runRegistration(
    RegistrationType type,
    const PointCloud& source,
    const PointCloud& target) {
    switch (type) {
        case RegistrationType::Icp:
            return runIcp(source, target);
        case RegistrationType::Gicp:
            return runGicp(source, target);
        case RegistrationType::Ndt:
            return runNdt(source, target);
    }
    throw std::runtime_error("unknown registration type");
}

这个函数看起来简单,但它会迅速扩散。前端初始化、参数加载、调试日志、性能统计、可视化、单元测试都可能需要同样的分支。新增一个 VGICP 后,修改点不是一个,而是一串。

设计方式 调用端知道什么 新增算法时要改哪里 典型后果
到处 switch 知道所有具体算法 多个调用点 分支扩散,模块边界模糊
继承 + 虚函数 只知道共同接口 工厂和新类 调用端稳定,新增算法局部化
模板参数 编译期知道具体类型 编译期组合处 高性能,但运行时切换不自然
std::variant 知道一组有限类型 variant 类型和访问器 适合封闭集合,不适合插件扩展

继承的价值不是“少写几行重复代码”,而是让调用端面向稳定接口编程。这个思想在机器人系统中很常见:PCL 的配准基类、OpenVINS 的特征跟踪基类、ROS2 的控制器接口、MoveIt2 的运动规划插件,本质上都在解决“调用端稳定、实现端可替换”的问题。

历史背景:从函数指针到虚函数

C 语言也能做运行时切换:把函数指针放进结构体即可。

struct RegistrationVTable {
    RegistrationResult (*align)(void* self,
                                const PointCloud& source,
                                const PointCloud& target);
    void (*destroy)(void* self);
};

struct RegistrationHandle {
    void* self;
    RegistrationVTable table;
};

这种写法揭示了多态的底层直觉:对象携带数据,接口携带函数入口。C++ 的虚函数把这套模式纳入类型系统,让编译器检查函数签名、调用约定和对象生命周期。

C 风格手写表 C++ 虚函数
void* 保存对象,类型安全靠约定 基类指针保存对象,类型关系由编译器检查
函数指针表手工维护 虚函数表由编译器生成
析构函数需要手动放进表 虚析构函数纳入语言规则
错误通常运行时暴露 大量签名错误可编译期暴露

继承不是现代 C++ 中唯一的抽象方式,但它仍然是运行时可替换模块最直接的语言工具。

类比:虚函数像是"通用遥控器上的按钮"——每个品牌的电视机接收到同一个"开机"信号后执行自己的开机流程。调用端只需要知道"按下开机键",不需要知道三星、LG、索尼各自的内部启动序列。继承体系定义了"遥控器有哪些按钮"(基类接口),每个品牌的电视机实现了"按下按钮后做什么"(派生类覆盖)。C 语言的函数指针表也能做到类似效果,但那相当于"自己焊遥控器电路"——你必须手动管理函数指针的初始化、调用约定和生命周期,编译器不会帮你检查任何错误。

理论:public 继承表达 is-a

在 C++ 中,class Derived : public Base 的核心含义是:任何需要 Base 的地方,都可以接受 Derived,并且语义仍然正确。这常被称为 Liskov 替换原则(LSP),由 Barbara Liskov 在 1987 年提出。

理解 LSP 的关键:替换不只是"编译能通过",而是"行为仍然正确"。考虑一个经典的反例——Square 继承 Rectangle。数学上正方形确实是一种矩形,但在代码中 Rectangle 可能有 setWidth()setHeight() 两个独立操作,而 Square 要求宽高始终相等。如果调用者写了 rect.setWidth(5); rect.setHeight(10); assert(rect.area() == 50);——对 Rectangle 正确,但对 Square 失败(面积变成 100)。编译能通过,但语义被破坏了。这就是为什么"is-a"不是几何分类关系,而是**行为替换关系**——派生类必须能在所有使用场景中正确替换基类。

为什么继承在机器人系统中如此重要? 机器人软件的一个核心需求是**运行时可替换性**。同一个导航框架可能需要在不同场景下使用不同的配准算法(ICP、NDT、GICP)、不同的传感器驱动(Livox、Velodyne、Ouster)、不同的规划器(RRT、PRM、lattice planner)。这些组件的接口相同但实现不同——这正是继承 + 虚函数的经典应用场景。ROS2 的 pluginlib 就是建立在这个模式之上的:定义抽象基类接口,插件提供具体实现,运行时根据配置文件动态加载。

class Registration {
public:
    virtual ~Registration() = default;

    virtual RegistrationResult align(const PointCloud& source,
                                     const PointCloud& target) = 0;
};

class IcpRegistration final : public Registration {
public:
    RegistrationResult align(const PointCloud& source,
                             const PointCloud& target) override {
        return runIcp(source, target);
    }
};

class GicpRegistration final : public Registration {
public:
    RegistrationResult align(const PointCloud& source,
                             const PointCloud& target) override {
        return runGicp(source, target);
    }
};

IcpRegistration 是一种 Registration,因为它确实能履行“对齐两个点云”这个契约。反过来,RegistrationConfig 就不应该继承 Registration,因为配置不是配准算法,只是配准算法使用的数据。

关系 C++ 表达 例子 判断标准
is-a public 继承 GicpRegistration 是一种 Registration 派生类能替代基类
has-a 成员变量 Registration 拥有 RegistrationConfig 对象包含另一个对象
uses-a 函数参数/局部变量 Registration 使用 KdTree 只在某个操作中依赖
behaves-like 模板/Concept 类型满足某组表达式 编译期约束行为

本质洞察:继承不是“把公共代码放到父类里”,而是“承诺派生类可以替换基类”。如果一个基类的主要价值只是存放几个工具函数,组合或自由函数通常更清晰。

反事实:把继承当代码复用会怎样

假设为了复用日志功能,让所有模块继承 Logger

class Logger {
public:
    void info(const std::string& message);
    void warn(const std::string& message);
};

class VoxelMap : public Logger {
public:
    void insert(const Point& p);
};

这段代码能工作,但语义不对。VoxelMap 不是一种 Logger,它只是需要日志功能。错误的 public 继承会把不该暴露的接口暴露给外部:

void useLogger(Logger& logger);

VoxelMap map;
useLogger(map);  // 类型上可行,语义上奇怪:体素地图被当成日志器

更合理的写法是组合:

class VoxelMap {
public:
    explicit VoxelMap(Logger& logger) : logger_(logger) {}

    void insert(const Point& p) {
        logger_.info("insert point");
        // ...
    }

private:
    Logger& logger_;
};

机器人项目中的模块边界很复杂。如果继承关系表达不清,后续的工厂、插件、测试替身都会变得混乱。

真实机器人项目中的多态接口实例

OpenVINS 的 TrackBase:OpenVINS 定义了 TrackBase 作为特征跟踪器的抽象基类,派生出 TrackKLT(KLT 光流跟踪)、TrackDescriptor(基于描述子的匹配跟踪)和 TrackAruco(ArUco 标记跟踪)。调用端只依赖 TrackBase::feed_new_camera() 接口,不关心具体使用哪种跟踪方法。配置文件中的 use_arucouse_klt 字段决定实例化哪个派生类。这正是 is-a 关系的标准应用:每种跟踪器都"是一种"特征跟踪器,都能提供特征轨迹。

PCL 的 Registration<PointSource, PointTarget>:PCL 的配准框架是继承 + 模板的混合设计——基类是模板类(用模板参数固定点类型),派生类实现具体的配准算法(ICP、NDT、GICP)。这种设计同时获得了运行时可替换性(通过虚函数 computeTransformation())和编译期点类型安全性(通过模板参数 PointSource)。

本质洞察:继承和模板不是竞争关系——它们工作在不同的维度上。继承处理"运行时在多种实现中选择"的问题,模板处理"编译期在多种数据类型上复用"的问题。两者经常在同一个框架中同时出现。

工程判断:三问决定是否使用继承

问题 如果答案是“是” 如果答案是“否”
调用端是否需要用同一接口操作不同实现? 可以考虑运行时多态 优先普通类或组合
具体实现是否需要运行时选择? 可以考虑虚函数 + 工厂 可以考虑模板或直接类型
基类指针删除派生对象是否可能发生? 基类析构必须 virtual 可考虑非多态类型

对 SLAM、导航、控制这类系统,继承常出现在架构层:算法插件、传感器驱动、优化因子、控制器接口。它较少出现在数值热路径:残差逐点计算、矩阵表达式、李群小量更新更适合模板和内联。

练习

  1. 设计判断题CameraModelPinholeCameraImageFrameFeatureTrackerLogger 五个概念中,哪些关系适合 public 继承,哪些适合组合?说明理由。
  2. 反事实分析题:如果一个 SLAM 系统不用统一 Registration 接口,而是在每个调用点写 switch(type),新增 VGICP 时至少会影响哪些模块?
  3. 代码题:写一个 Registration 抽象基类,派生出 IcpRegistrationNdtRegistration。再写一个 makeRegistration(std::string_view type) 工厂函数返回 std::unique_ptr<Registration>

上一节解决了”为什么需要继承”这个架构问题——继承的价值是可替换性,不是代码复用。但在动手写多态代码之前,还有一个更底层的问题必须搞清楚:派生对象在内存中到底是什么样子?这个问题直接决定了为什么多态对象必须通过指针或引用使用,为什么按值传递会丢失派生信息。如果跳过这个问题直接写代码,你会被”对象切片”这个隐蔽的 bug 反复困扰——代码编译通过,运行时行为却完全偏离预期。


6.2 派生对象、基类子对象与对象切片 ⭐⭐⭐

动机:std::vector<Base> 为什么装不下多态对象

很多初学者会写出这样的容器:

std::vector<Registration> registrations;
registrations.push_back(IcpRegistration{});
registrations.push_back(GicpRegistration{});

这段代码通常无法编译,因为 Registration 是抽象类。即使基类不是抽象类,写成 std::vector<Base> 也会发生对象切片:容器中每个元素的静态类型都是 Base,派生类新增的数据和虚函数覆盖关系会在按值拷贝时丢失。

要理解这个问题,需要先看派生对象的组成。

一个 Derived 对象可以理解为:

┌──────────────────────────┐
│ Base 子对象               │  ← Base& / Base* 指向这里
│  - Base 数据成员          │
│  - 多态实现所需的隐藏信息 │
├──────────────────────────┤
│ Derived 自己的数据成员    │
└──────────────────────────┘

Derived* 转换为 Base* 时,指针指向的是派生对象内部的基类子对象。这种向上转型是安全的,因为每个 Derived 对象都包含一个 Base 子对象。

对象切片:派生部分被按值丢弃

先看一个最小例子:

#include <iostream>
#include <string>

class Sensor {
public:
    explicit Sensor(std::string name) : name_(std::move(name)) {}

    virtual ~Sensor() = default;

    virtual void print() const {
        std::cout << "Sensor: " << name_ << "\n";
    }

private:
    std::string name_;
};

class CameraSensor : public Sensor {
public:
    CameraSensor(std::string name, int width, int height)
        : Sensor(std::move(name)), width_(width), height_(height) {}

    void print() const override {
        std::cout << "Camera: " << width_ << "x" << height_ << "\n";
    }

private:
    int width_;
    int height_;
};

void printByValue(Sensor sensor) {
    sensor.print();
}

void printByReference(const Sensor& sensor) {
    sensor.print();
}

int main() {
    CameraSensor camera("front", 1280, 720);
    printByValue(camera);      // 发生对象切片
    printByReference(camera);  // 保留动态类型
}

printByValue(camera) 会构造一个新的 Sensor 对象,只拷贝 CameraSensor 中的 Sensor 子对象。这个新对象已经不是 CameraSensor,所以 print() 调用基类版本。printByReference(camera) 没有创建新对象,引用仍然绑定到原来的派生对象,所以虚函数能动态派发到 CameraSensor::print()

传递方式 是否复制对象 是否保留派生部分 是否保留多态行为
Base b
Base& b
const Base& b
Base* b
std::unique_ptr<Base> 移动指针
std::shared_ptr<Base> 共享控制块

回顾 移动语义与完美转发:移动语义转移的是对象资源或所有权,不会改变对象的静态类型。如果你把 Derived 移动进 Base,仍然会按 Base 构造目标对象,派生部分仍然被切掉。多态对象的转移应该转移指针所有权,而不是按值移动基类对象。

正确容器:存放指针,而不是存放基类值

多态对象的常见容器写法如下:

#include <memory>
#include <vector>

std::vector<std::unique_ptr<Registration>> registrations;

registrations.push_back(std::make_unique<IcpRegistration>());
registrations.push_back(std::make_unique<GicpRegistration>());

for (const auto& registration : registrations) {
    RegistrationResult result = registration->align(source, target);
    // ...
}

这里 std::vector 存放的是 unique_ptr<Registration>。指针对象本身大小固定,可以放进同一个容器;堆上的真实对象仍然是 IcpRegistrationGicpRegistration

容器写法 适用性 原因
std::vector<Base> 不适合多态 会切片,抽象基类还无法实例化
std::vector<Base*> 只适合非拥有引用 生命周期不清晰,容易悬空
std::vector<std::unique_ptr<Base>> 常用 独占拥有,多态删除清晰
std::vector<std::shared_ptr<Base>> 共享图结构常用 多处共享所有权,但成本更高
std::vector<std::reference_wrapper<Base>> 非拥有引用集合 被引用对象必须在外部存活

机器人代码中,模块工厂通常返回 std::unique_ptr<Base>,因为插件对象通常只有一个管理者。因子图、地图点、关键帧这类共享结构才更常使用 std::shared_ptr 或项目自定义句柄。

保护多态基类:禁止按值复制

如果一个类被设计成多态基类,通常不希望它被按值复制。可以显式删除拷贝和移动,强迫调用端通过引用或指针使用它:

class Registration {
public:
    Registration() = default;
    virtual ~Registration() = default;

    Registration(const Registration&) = delete;
    Registration& operator=(const Registration&) = delete;
    Registration(Registration&&) = delete;
    Registration& operator=(Registration&&) = delete;

    virtual RegistrationResult align(const PointCloud& source,
                                     const PointCloud& target) = 0;
};

这延续了 现代类设计与特殊成员函数 的思想:特殊成员函数不是机械生成的清单,而是类语义的表达。多态基类的语义是“通过接口使用”,不是“作为值复制”。

如果确实需要复制多态对象,常见做法是提供 clone()

class Registration {
public:
    virtual ~Registration() = default;

    virtual std::unique_ptr<Registration> clone() const = 0;

    virtual RegistrationResult align(const PointCloud& source,
                                     const PointCloud& target) = 0;
};

class IcpRegistration final : public Registration {
public:
    std::unique_ptr<Registration> clone() const override {
        return std::make_unique<IcpRegistration>(*this);
    }

    RegistrationResult align(const PointCloud& source,
                             const PointCloud& target) override {
        return runIcp(source, target);
    }
};

clone() 把”复制一个动态类型对象”变成虚函数,由派生类自己决定如何复制真实对象。

clone() 模式与 std::unique_ptr 的协作clone() 返回 std::unique_ptr<Base> 是现代 C++ 的推荐做法。返回智能指针而非裸指针有两个优势:(1) 所有权语义清晰——调用者接管了克隆对象的生命周期;(2) 异常安全——即使调用者忘记手动 delete,对象也会在 unique_ptr 析构时被正确释放。

为什么不直接让基类支持拷贝构造? 因为基类的拷贝构造函数只能拷贝基类部分——派生类的数据会被”切掉”。clone() 通过虚函数机制让每个派生类在自己的实现中调用自己的拷贝构造函数(std::make_unique<IcpRegistration>(*this)),从而保留完整的动态类型。这是一个经典的”用运行时多态解决编译期类型系统无法表达的问题”的案例。

⚠️ 常见陷阱

⚠️ 编程陷阱:函数参数不小心写成基类按值传递

错误做法void solve(Optimizer optimizer);

现象:如果传入 GicpOptimizer,按值传递会构造一个 Optimizer 对象,派生类特有的数据和虚函数覆盖关系丢失。程序可能编译通过但运行时调用基类版本的函数。

根本原因:按值传递触发拷贝构造函数,目标类型是 Optimizer(基类),派生类部分被"切掉"。

正确做法:多态对象使用 Optimizer&const Optimizer&Optimizer*std::unique_ptr<Optimizer>。选择哪一种取决于函数是否拥有对象。

💡 概念误区:以为 std::move 可以避免对象切片

新手想法:"std::move(derived) 应该能把派生类'完整移动'到基类变量里。"

实际上std::move(derived) 只把表达式转成右值引用。目标类型如果仍然是 Base,构造出的仍然是 Base 对象——移动构造函数的参数类型是 Base&&,它只从 derived 的基类子对象中窃取资源,派生部分仍然被切掉。移动语义解决资源转移效率,不解决动态类型保存。

正确做法:多态对象的"转移"应该转移指针所有权——std::unique_ptr<Base> p = std::move(unique_ptr_to_derived)——而不是按值移动基类对象。

🧠 思维陷阱:认为对象切片总是编译器能检测的

新手想法:"如果有切片风险,编译器应该报错。"

实际上:只有基类是抽象类时,按值传递才会导致编译错误(因为抽象类不能实例化)。如果基类不是抽象类,按值传递完全合法——编译器不会警告,因为从 C++ 类型系统的角度看,Base b = derived 是一个合法的拷贝构造。对象切片的检测主要靠代码审查和设计约定(如 6.2.3 中删除基类的拷贝/移动操作)。

正确做法:多态基类应该删除拷贝和移动操作,或使用 clang-tidy 的 slicing 检查。

练习

  1. 运行验证题:编译上面的 Sensor 示例,观察 printByValueprintByReference 的输出差异。然后在 Sensor 中增加一个数据成员,观察切片后哪些数据保留。
  2. 设计题:为 FeatureTracker 设计一个不可复制、不可移动的多态基类。说明为什么删除移动构造函数不是性能倒退。
  3. 综合题:结合 RAII与智能指针 的智能指针和 移动语义与完美转发 的移动语义,写一个 std::vector<std::unique_ptr<Sensor>>,放入 CameraSensorImuSensor,并通过基类接口统一调用 read()

对象切片解释了“多态对象为什么要通过引用或指针使用”。下一步要解释:为什么通过同一个 Base* 调用函数时,C++ 能找到派生类版本。


6.3 虚函数、虚函数表与动态派发 ⭐⭐⭐

动机:同一个指针,为什么能调用不同函数

下面的代码中,registration 的静态类型是 const Registration&,但调用的函数由真实对象决定:

void run(const Registration& registration,
         const PointCloud& source,
         const PointCloud& target) {
    RegistrationResult result = registration.align(source, target);
}

如果传入 IcpRegistration,调用 ICP;如果传入 GicpRegistration,调用 GICP。这个能力叫运行时多态,也叫动态派发。

动态派发需要解决一个问题:编译器在编译 run() 时只知道参数类型是 Registration&,并不知道运行时会传入哪个派生类。它必须把“具体调用哪个函数”的决定推迟到运行时。

主流实现:vptr 和 vtable——从零构建理解

C++ 标准规定的是语义:虚函数调用应按对象的动态类型派发。标准不强制具体内存布局。但理解主流编译器如何实现这个语义,对于阅读调试器输出、理解对象大小、排查多态相关 bug 至关重要。我们从一个没有虚函数的对象开始,一步步看加入虚函数后发生了什么变化。

第一步:没有虚函数的对象长什么样

struct Plain {
    int id;          // 4 字节
    double value;    // 8 字节
};
// sizeof(Plain) = 16(含对齐填充)

Plain 对象内存布局:
┌──────────┬──────────┐
│ id (4B)  │ pad (4B) │
├──────────┴──────────┤
│    value (8B)       │
└─────────────────────┘

这个对象只包含数据成员,没有任何隐藏开销。编译器在编译期就知道每个成员函数的地址——调用 plain.func() 时直接跳转到 Plain::func 的代码,和调用一个普通函数没有区别。

第二步:加入一个虚函数后发生了什么

struct Polymorphic {
    virtual void run() {}
    int id;
    double value;
};
// sizeof(Polymorphic) = 24(多了 8 字节!)

Polymorphic 对象内存布局:
┌─────────────────────┐
│ vptr (8B)           │  ← 编译器悄悄插入的隐藏指针
├──────────┬──────────┤
│ id (4B)  │ pad (4B) │
├──────────┴──────────┤
│    value (8B)       │
└─────────────────────┘
        │ vptr 指向...
虚函数表(vtable)——每个类有一份,全局共享
┌──────────────────────────────┐
│ &Polymorphic::run             │  slot 0
└──────────────────────────────┘

关键变化:编译器在对象的最开头(在大多数 ABI 中)插入了一个隐藏的指针 vptr。这个指针指向该类的虚函数表(vtable)——一个全局的函数指针数组,每个槽位存储一个虚函数的地址。

第三步:派生类覆盖虚函数后,vtable 如何变化

struct Derived : Polymorphic {
    void run() override { /* 不同实现 */ }
    int extra;
};

Derived 对象内存布局:
┌─────────────────────┐
│ vptr (8B)           │  ← 指向 Derived 的 vtable(不是 Polymorphic 的!)
├──────────┬──────────┤
│ id (4B)  │ pad (4B) │
├──────────┴──────────┤
│    value (8B)       │
├──────────┬──────────┤
│ extra(4B)│ pad (4B) │
└──────────┴──────────┘
        │ vptr 指向...
Derived 的 vtable(和 Polymorphic 的 vtable 是不同的表!)
┌──────────────────────────────┐
│ &Derived::run                 │  slot 0(覆盖了基类版本)
└──────────────────────────────┘

构造 Derived 对象时,编译器把 vptr 设置为指向 Derived 的 vtable。这就是运行时多态的关键——同样的内存位置(vptr),在 Polymorphic 对象中指向 Polymorphic::vtable,在 Derived 对象中指向 Derived::vtable

读到这里你可能会问:vptr 是什么时候被设置的?答案是在构造函数中。当构造 Derived 对象时,先执行 Polymorphic 的构造函数——此时 vptr 被设为指向 Polymorphic::vtable。然后执行 Derived 的构造函数——此时 vptr 被**改写**为指向 Derived::vtable。这就是 6.8 节中"构造期间虚调用不会进入派生类版本"的底层原因——在基类构造函数执行时,vptr 还指向基类的 vtable。

第四步:通过基类引用调用虚函数时发生了什么

通过基类引用调用虚函数时,典型过程是:

  1. 从对象中取出虚函数指针(vptr 在对象的固定偏移处)。
  2. 找到该对象动态类型对应的虚函数表。
  3. 根据虚函数在表中的槽位取出函数地址。
  4. 间接调用该函数。

这和 6.1 节中 C 风格函数指针表很像,只是表的生成、维护和调用由编译器完成。回顾那个 RegistrationVTable 结构——vtable 本质上就是编译器替你维护的那个函数指针表,vptr 就是 void* self + 表指针的组合。

为什么 C++ 选择 vtable 而非其他动态派发方案? vtable 不是唯一的实现方式——Smalltalk 和 Objective-C 使用消息传递(message passing),每次调用都在运行时按方法名查找。JavaScript 使用隐藏类和内联缓存。但 vtable 有一个关键优势:调用开销是 O(1) 且可预测的。不管继承层次多深、有多少虚函数,一次虚函数调用永远是"取指针 → 查表 → 间接调用"三步。消息传递的查找时间取决于方法名长度和继承深度,在最坏情况下可能退化为线性搜索。对于需要确定性执行时间的机器人控制系统,vtable 的可预测性是一个重要的工程优势。

vtable 的代价:每个多态类有一个全局的 vtable(大小 = 虚函数数量 * 指针大小),每个多态对象有一个隐藏的 vptr(通常 8 字节在 64 位系统上)。对于包含百万个小型多态对象的场景(如点云中的每个点都是多态类型),8 字节/对象的额外开销会变得显著——这也是为什么点云数据结构通常不使用虚函数,而是用模板或 variant 来实现类型分派。

虚析构函数为什么必须存在? 如果通过基类指针 delete 一个派生类对象,编译器需要知道实际应该调用哪个析构函数。如果基类析构函数不是虚的,编译器只会调用基类的析构函数——派生类特有的资源(如 std::vector 成员的堆内存)不会被释放,造成内存泄漏。虚析构函数让 delete basePtr 能通过 vtable 找到正确的派生类析构函数。C++ Core Guidelines 的建议是:如果一个类有任何虚函数,它的析构函数就应该是虚的——这几乎没有例外。

sizeof 观察对象大小

下面的代码可以观察一个常见现象:带虚函数的类对象通常会多出一个隐藏指针大小。

#include <iostream>

struct Plain {
    int value;
};

struct Polymorphic {
    virtual ~Polymorphic() = default;
    virtual void run() {}
    int value;
};

int main() {
    std::cout << "sizeof(Plain) = " << sizeof(Plain) << "\n";
    std::cout << "sizeof(Polymorphic) = " << sizeof(Polymorphic) << "\n";
}

在常见 64 位平台上,输出可能类似:

sizeof(Plain) = 4
sizeof(Polymorphic) = 16

Polymorphic 不是简单的 8 + 4 = 12,因为对象对齐通常会把大小补齐到 16。不同 ABI、编译器和平台可能有不同结果,所以这个实验用于理解机制,不用于写可移植的内存布局假设。

现象 工程含义
多态对象通常多一个隐藏指针 大量小对象使用虚函数会增加内存占用
虚函数调用是间接调用 可能影响内联、分支预测和优化
具体布局不由标准承诺 不能依赖偏移写二进制序列化
final 可能帮助去虚化 提供优化机会,不等于强制优化

性能:真正的问题不是”几纳秒”,而是优化边界

关于虚函数性能,新手最常犯的错误是把它简化为一个绝对的判断——“虚函数慢,不要用”或”虚函数只多几纳秒,随便用”。两种极端都是错误的。正确的思考方式是把开销分为**直接开销**和**间接开销**两层来分析。

直接开销是虚函数调用本身比普通函数调用多出的时间——取 vptr、查表、间接跳转。在现代 CPU 上,这通常是纳秒级的(具体数字取决于 CPU 微架构、缓存命中率、分支预测器的状态)。对于一个每帧执行一次的 Registration::align() 调用,这几纳秒完全可以忽略——算法内部的 ICP 迭代需要数百微秒到数毫秒,虚调用的开销淹没在其中。

但间接开销往往比直接开销严重得多。当编译器不知道虚函数的最终调用目标时,它无法进行一系列关键优化:不能内联函数体(因为不知道函数体在哪里)、不能跨函数传播常量(因为不知道函数的具体实现)、不能展开循环(因为循环体中调用了未知函数)、不能进行向量化(因为 SIMD 指令需要知道完整的计算流程)。这些优化在数值密集型计算中的加速比可以达到 5-20 倍。

场景 虚函数是否合适 原因
配置阶段选择配准算法 合适 调用频率低,灵活性重要
每帧调用一次 align() 合适 算法内部成本远高于一次派发
每个点调用一次残差函数,百万点循环 谨慎 派发阻碍内联,成本会累积
Eigen 矩阵表达式逐元素运算 不合适 需要编译期展开和内联

这就是机器人 C++ 中常见的分层:架构层用虚函数,数值热路径用模板、CRTP 或普通内联函数。

本质洞察:虚函数不是“慢”,而是“把具体类型推迟到运行时”。这个推迟换来了插件化和可替换性,也让编译器少了一些编译期信息。性能判断应看调用频率和优化边界,而不是孤立地记一个时间数字。

反事实:如果 C++ 不用 vtable 会怎样

理解 vtable 的设计价值,最好的方式是看"如果不这样做会怎样"。

如果 C++ 选择 Objective-C 的消息传递模型——每次虚函数调用都按方法名字符串在运行时查找——调用开销会从 O(1) 变成 O(k)(k 是方法名长度和继承深度的函数)。对于配准算法每帧调用一次的 align(),这个开销不明显。但对于 Ceres 的残差函数在优化循环中被调用数万次的场景,字符串查找的累积开销会变得不可接受。

如果 C++ 完全不支持动态派发——像 Rust 的默认行为那样,所有方法都是静态分派,只在需要时通过 trait object(dyn Trait)引入虚函数表——程序员会在每个需要运行时选择的地方手动管理函数指针表,回到 6.1 节开头的 C 风格 RegistrationVTable 模式。代码更底层、更灵活,但也更容易出错。

C++ 的设计选择是:让编译器自动管理 vtable,程序员只需要写 virtual。这是一个"合理默认 + 可选逃逸"的设计——如果你不需要虚函数的开销,用 final 关键字或模板/CRTP 可以在编译期消除它。

final:表达继承边界,也提供去虚化机会

final 可以修饰类或虚函数:

class IcpRegistration final : public Registration {
public:
    RegistrationResult align(const PointCloud& source,
                             const PointCloud& target) override;
};

final 的第一层含义是设计语义:IcpRegistration 已经是叶子类,不希望继续派生。第二层含义是优化机会:编译器看到某个动态类型没有进一步派生时,可能把虚调用变成直接调用。

写法 含义
class A final 禁止继承整个类
void f() final 禁止派生类继续覆盖该虚函数
override final 先确认覆盖,再关闭后续覆盖

需要注意的是,final 提供优化条件,不保证编译器在所有场景都去虚化。是否优化取决于调用上下文和编译器策略。

⚠️ 常见陷阱

⚠️ 编程陷阱:在热循环里用虚函数表达每个点的运算

for (const auto& point : cloud) residual->evaluate(point); 如果 evaluate() 是虚函数,百万点循环会产生百万次间接调用,并且阻止很多内联优化。更好的做法通常是把算法类型固定在循环外,在循环内使用模板、函数对象或普通内联函数。

💡 概念误区:vtable 布局可以拿来做序列化

虚函数表、虚函数指针的位置和内容属于实现细节。序列化多态对象时,应序列化稳定字段和类型标签,而不是直接写对象内存。

练习

  1. 实验题:运行 sizeof 示例,在 GCC、Clang 或不同编译选项下比较结果。解释为什么 Polymorphic 的大小通常不是简单相加。
  2. 性能题:写一个虚函数版本和模板版本的百万次标量残差计算,用 -O2 计时。观察虚函数在热循环中的影响,并用汇编或编译器报告判断是否内联。
  3. 设计题:把一个点云滤波模块拆成两层:外层运行时选择滤波器,内层逐点运算避免虚函数。画出接口边界。

虚函数解释了”如何调用具体实现”。但仅有虚函数还不够——如果基类的虚函数有默认实现,派生类可能”忘记”覆盖它,导致在运行时执行一个毫无意义的默认行为。机器人项目通常需要先定义一套稳定契约,**强制**派生类必须实现关键操作。这就是抽象类和纯虚函数的角色。从 vtable 的角度看,纯虚函数在基类的 vtable 中对应的槽位通常存放一个特殊的地址(很多编译器放的是 __cxa_pure_virtual),如果不小心被调用会直接终止程序——这是编译器对”你忘了实现这个函数”的最后一道防线。


6.4 抽象类、纯虚函数与接口契约 ⭐⭐⭐

动机:调用端需要的是契约,不是默认实现

如果一个基类的某个操作没有合理默认行为,就应该把它声明为纯虚函数:

class FeatureTracker {
public:
    virtual ~FeatureTracker() = default;

    virtual void feedImage(const Image& image, double timestamp) = 0;
    virtual std::vector<FeatureTrack> tracks() const = 0;
};

FeatureTracker 本身不能实例化,因为“一个抽象特征跟踪器”不知道应该用 KLT、描述子匹配还是 AprilTag。它只定义契约:任何派生类都必须能接收图像并返回跟踪结果。

概念 C++ 写法 含义
虚函数 virtual void f(); 可以被派生类覆盖
纯虚函数 virtual void f() = 0; 派生类必须实现,含纯虚函数的基类不能实例化
抽象类 含至少一个纯虚函数的类 定义接口契约
具体类 实现所有纯虚函数的类 可以创建对象

接口契约要小而稳定

一个糟糕的接口会把所有内部细节都暴露出去:

class BadTracker {
public:
    virtual void setPyramidLevels(int levels) = 0;
    virtual void setDescriptorDistance(double distance) = 0;
    virtual void setArucoDictionary(int id) = 0;
    virtual void feedImage(const Image& image) = 0;
};

这个接口同时泄漏了 KLT、描述子和 ArUco 的参数。任何实现都被迫携带不相关函数。

更合理的做法是把共同契约保持在最小集合,把算法特有参数放进构造配置或派生类自己的配置结构:

class FeatureTracker {
public:
    virtual ~FeatureTracker() = default;

    virtual void feedImage(const Image& image, double timestamp) = 0;
    virtual std::span<const FeatureTrack> tracks() const = 0;
};

struct KltTrackerConfig {
    int pyramid_levels = 4;
    int max_features = 300;
};

class KltTracker final : public FeatureTracker {
public:
    explicit KltTracker(KltTrackerConfig config);

    void feedImage(const Image& image, double timestamp) override;
    std::span<const FeatureTrack> tracks() const override;
};

接口越小,派生类越容易正确实现,调用端越不容易依赖具体实现细节。

NVI(Non-Virtual Interface)模式:公有非虚 + 私有虚

Herb Sutter 在 Exceptional C++ Style 中推广了一种接口设计模式——非虚接口(NVI, Non-Virtual Interface)。核心思想是:公有接口函数是非虚的,它调用私有的虚函数来执行具体操作。这样基类可以在公有函数中添加通用的前置/后置逻辑(日志、计时、参数验证),而派生类只覆盖私有的虚函数。

class FeatureTracker {
public:
    virtual ~FeatureTracker() = default;

    // 公有非虚函数——调用端的唯一入口
    void feedImage(const Image& image, double timestamp) {
        validateImage(image);
        doFeedImage(image, timestamp);  // 委托给私有虚函数
        ++frame_count_;
    }

private:
    // 私有虚函数——派生类覆盖的扩展点
    virtual void doFeedImage(const Image& image, double timestamp) = 0;

    void validateImage(const Image& image) {
        if (image.empty()) {
            throw std::invalid_argument(empty image);
        }
    }

    int frame_count_ = 0;
};

NVI 的价值在于**把”接口契约”和”实现扩展点”分开**。调用端看到的是稳定的非虚公有接口;派生类看到的是需要实现的私有虚函数。基类可以在不破坏派生类的情况下,在公有函数中添加新的通用逻辑(如性能计时、参数合法性检查)。

设计方式 调用端看到 派生类实现 基类能添加通用逻辑?
公有虚函数 虚函数就是接口 直接覆盖公有函数 不方便——派生类可能忘记调用 Base::f()
NVI 非虚公有函数 覆盖私有虚函数 在公有函数中自然添加

NVI 不是强制要求——简单的接口(只有一两个纯虚函数)直接用公有虚函数也完全合理。NVI 的价值在需要”基类统一管理前置/后置逻辑”的场景中才体现出来。

模板方法:PCL Registration 的典型结构

运行时多态不只用于”每个函数都让派生类实现”。一种常见模式是模板方法(与 C++ 模板无关——这里的”模板”是设计模式中的术语):基类提供稳定流程,派生类只覆盖关键步骤。

简化后可以写成:

class Registration {
public:
    virtual ~Registration() = default;

    RegistrationResult align(const PointCloud& source,
                             const PointCloud& target) {
        validateInput(source, target);
        prepareData(source, target);
        return computeTransformation();
    }

private:
    void validateInput(const PointCloud& source,
                       const PointCloud& target) {
        if (source.empty() || target.empty()) {
            throw std::invalid_argument("empty point cloud");
        }
    }

    void prepareData(const PointCloud& source,
                     const PointCloud& target) {
        source_ = &source;
        target_ = &target;
    }

    virtual RegistrationResult computeTransformation() = 0;

    const PointCloud* source_ = nullptr;
    const PointCloud* target_ = nullptr;
};

调用端只调用 align(),流程中通用的输入检查和数据准备由基类完成,具体配准算法由派生类的 computeTransformation() 完成。PCL 的配准体系就具有这种结构:公共流程稳定,ICP、GICP、NDT 在核心求解步骤上不同。

设计点 基类负责 派生类负责
输入合法性 检查空点云、维度、参数范围 不重复写通用检查
生命周期 保存输入、输出、状态 不破坏基类约束
核心算法 定义调用时机 实现具体迭代
结果格式 统一返回类型 填充算法结果

g2o 风格:虚接口和模板参数混合

图优化库需要在同一个图中存放不同类型的顶点和边:位姿顶点、路标点顶点、IMU bias 顶点,一元边、二元边、多元边。单靠模板无法把不同类型自然放进同一个运行时容器;单靠虚函数又难以表达维度和测量类型。

典型设计会混合两种抽象:

class Vertex {
public:
    virtual ~Vertex() = default;

    virtual int dimension() const = 0;
    virtual void setToOrigin() = 0;
    virtual void oplus(const double* update) = 0;
};

template <int Dimension, typename Estimate>
class BaseVertex : public Vertex {
public:
    int dimension() const override {
        return Dimension;
    }

    void setToOrigin() override {
        setToOriginImpl();
    }

    void oplus(const double* update) override {
        oplusImpl(update);
    }

protected:
    virtual void setToOriginImpl() = 0;
    virtual void oplusImpl(const double* update) = 0;

    Estimate estimate_;
};

class VertexPose final : public BaseVertex<6, SE3> {
protected:
    void setToOriginImpl() override {
        estimate_ = SE3::Identity();
    }

    void oplusImpl(const double* update) override {
        estimate_ = SE3::exp(update) * estimate_;
    }
};

这里 Vertex 提供运行时统一接口,BaseVertex<6, SE3> 用模板参数固定维度和估计类型,VertexPose 实现具体的李群更新。这个设计说明一个重要事实:运行时多态和模板不是对立关系,很多高质量 C++ 库会在不同层次同时使用二者。

接口设计的反面:OpenVINS 早期版本的教训

OpenVINS 的早期版本中,TrackBase 的接口包含了大量与具体跟踪方法相关的方法——例如 get_aruco_id() 被放在了基类中。这意味着即使你使用的是 KLT 光流跟踪器(TrackKLT),基类接口也暴露了 ArUco 相关的方法。后续重构中,ArUco 特有的方法被移到了 TrackAruco 派生类中,基类只保留了真正所有跟踪器都需要的方法(feed_new_camera()display_active()、获取跟踪结果等)。

这个重构体现了一个反复出现的接口设计原则:接口的稳定性与其大小成反比。 接口越大,越容易因为某个具体实现的需求变化而被迫修改,进而影响所有实现者。在机器人系统中,传感器硬件更新、算法替换、参数调整是常态——保持接口小而稳定,可以让这些变化被隔离在具体实现内部。

反事实推理:如果 OpenVINS 不把 ArUco 方法从基类中移出会怎样?每当 ArUco 的参数或行为发生变化,TrackKLTTrackDescriptor 也需要重新编译(因为它们继承了基类的修改),即使这些变化与它们完全无关。在大型项目中,这种不必要的编译依赖会显著增加构建时间,也增加了引入 bug 的风险。

纯虚析构函数也需要定义

有时希望基类保持抽象,但又没有其他纯虚函数。可以把析构函数声明为纯虚:

class Module {
public:
    virtual ~Module() = 0;
};

Module::~Module() = default;

即使析构函数是纯虚函数,也必须提供定义。原因是派生对象析构时会按“派生类析构函数 -> 基类析构函数”的顺序执行,基类析构函数最终仍然会被调用。如果只有声明没有定义,会在链接阶段报错。

⚠️ 常见陷阱

⚠️ 编程陷阱:接口过大,派生类被迫实现无意义函数

一个接口如果同时包含 KLT、描述子、AprilTag、光流金字塔和词袋参数,就已经不再是共同契约。应拆分接口或把特定配置放到具体类中。

⚠️ 链接陷阱:纯虚析构函数没有定义

virtual ~Base() = 0; 只说明类是抽象的,不代表析构函数体不存在。必须在某个 .cpp 或头文件内联处提供 Base::~Base() = default;

练习

  1. 接口设计题:为 LoopDetector 设计一个抽象基类。哪些函数属于共同契约?哪些参数应该留在派生类配置里?
  2. 模板方法题:实现一个 DataLoader::load(),基类负责打开文件、计时、异常包装,派生类只实现 parse()
  3. 综合题:模仿 g2o 结构,写 BaseVertex<Dim, Estimate>,并实现 VertexPoint3DVertexPose2D 两个派生类。

抽象类定义了“派生类必须做什么”。但只要通过基类指针拥有派生对象,就会遇到更严肃的生命周期问题:删除对象时,析构函数必须按动态类型执行。


6.5 虚析构函数与多态所有权 ⭐⭐⭐

动机:通过基类指针删除派生对象

回顾 RAII与智能指针:智能指针的核心价值是表达所有权并自动释放资源。多态对象最常见的拥有方式是:

std::unique_ptr<Registration> registration =
    std::make_unique<IcpRegistration>();

registration 离开作用域时,它会执行 delete。问题是:unique_ptr 的静态类型是 Registration,真实对象是 IcpRegistration。如果 Registration 的析构函数不是 virtual,通过基类指针删除派生对象会触发未定义行为。

错误示例:

#include <iostream>
#include <memory>

class Base {
public:
    ~Base() {
        std::cout << "~Base\n";
    }
};

class Derived : public Base {
public:
    ~Derived() {
        std::cout << "~Derived\n";
    }
};

int main() {
    std::unique_ptr<Base> p = std::make_unique<Derived>();
}

这不是“只会泄漏派生类资源”这么简单。标准层面这是未定义行为,程序可能看起来只调用 ~Base(),也可能在优化后产生更难排查的问题。

正确写法:

class Base {
public:
    virtual ~Base() = default;
};

析构顺序:先派生,后基类

当基类析构函数是 virtual 时,通过基类指针删除派生对象会按正确顺序执行:

delete Base* 指向 Derived 对象

1. 调用 Derived::~Derived()
   - 释放 Derived 自己拥有的资源
2. 调用 Base::~Base()
   - 释放 Base 子对象拥有的资源
3. 释放整块对象内存

这个顺序和构造顺序相反。构造时先构造基类子对象,再构造派生类成员;析构时先析构派生类,再析构基类。

基类用途 析构函数建议
多态基类,可能通过基类指针删除 virtual ~Base() = default;
只作值类型,不用于多态 普通析构或 Rule of Zero
不允许外部通过基类删除 protected 非虚析构可作为特殊设计
接口类无资源 仍然写 virtual 析构

unique_ptrshared_ptr 的差异边界

std::unique_ptr<Base> 的默认删除器会执行 delete Base*,因此基类析构函数必须是 virtual。

std::shared_ptr 的情况更微妙。如果你这样构造:

std::shared_ptr<Base> p = std::make_shared<Derived>();

控制块中保存了构造时的真实删除逻辑,这一路径能正确销毁 Derived。但这不应该成为省略虚析构的理由,原因有三点:

  1. 代码后来可能改成 std::unique_ptr<Base>
  2. 对象可能先退化成 Base*,再构造 shared_ptr<Base>,真实删除器信息已经丢失。
  3. 接口契约应该从类型定义上表达,而不是依赖某个智能指针构造路径。

高质量多态基类仍然应该写虚析构函数。

自定义删除器也能让某些 std::unique_ptr<Base, Deleter> 正确销毁派生对象,但那会把多态销毁契约藏进某个对象值里。教学和工程默认做法仍然是:只要基类打算被多态删除,就在类型定义上写出虚析构。

多态基类的特殊成员函数策略

多态基类通常采用下面的组合:

class Module {
public:
    Module() = default;
    virtual ~Module() = default;

    Module(const Module&) = delete;
    Module& operator=(const Module&) = delete;
    Module(Module&&) = delete;
    Module& operator=(Module&&) = delete;

    virtual void configure(const Config& config) = 0;
    virtual void runOnce() = 0;
};

删除拷贝和移动不是因为“对象不能被移动”,而是因为按基类移动没有清晰语义。真实对象可以由 std::unique_ptr<Module> 移动所有权,也可以通过 clone() 显式复制动态类型。

操作 多态基类直接支持吗 推荐表达
调用接口 Base& / Base*
独占拥有 std::unique_ptr<Base>
共享拥有 视情况 std::shared_ptr<Base>
复制动态对象 不直接支持 virtual clone()
按值移动基类 不推荐 移动智能指针

protected 非虚析构:少见但有用

如果一个类允许被继承,但不允许通过基类指针删除,可以把析构函数设为 protected 且非虚:

class NonOwningInterface {
public:
    virtual void observe() = 0;

protected:
    ~NonOwningInterface() = default;
};

外部无法写 delete NonOwningInterface*,因为析构函数不可访问。这种模式适合“只借用接口,不拥有对象”的特殊场景。机器人项目中的常规插件接口不应使用这种模式,因为插件管理器通常需要通过基类指针销毁对象。

⚠️ 常见陷阱

⚠️ 编程陷阱:接口类没有虚析构

只要类中有虚函数,并且可能通过基类指针拥有派生对象,就写 virtual ~Base() = default;。这条规则简单、清晰,能避免大量生命周期问题。

💡 概念误区:shared_ptr 能正确删除,所以基类析构不用 virtual

shared_ptr 的安全性依赖构造路径。接口类应该表达自身的删除契约,而不是把正确性寄托在调用端的某种写法上。

练习

  1. 实验题:分别用非虚析构和虚析构运行 unique_ptr<Base> = make_unique<Derived>() 示例,观察析构输出。再开启 AddressSanitizer,观察诊断差异。
  2. 设计题:为 SensorDriver 接口写出完整特殊成员函数策略,并说明为什么删除拷贝和移动。
  3. 分析题:什么时候可以使用 protected 非虚析构?为什么机器人插件接口通常不适合这种设计?

析构函数解决了”对象如何正确结束生命”——确保通过基类指针删除派生对象时,派生类的资源不会泄漏。但生命周期安全只是多态正确性的一半;另一半是**接口正确性**——你覆盖的函数签名必须和基类完全匹配,否则你以为在覆盖,实际上在创建一个无关的新函数。C++ 的函数签名匹配规则非常严格——一个多余的 const、一个不同的参数类型、甚至一个微妙的引用限定差异都会让覆盖静默失败。override 关键字正是为了把这种”肉眼难以发现”的错误交给编译器自动检查。


6.6 overridefinal 与签名陷阱 ⭐⭐⭐

动机:一个 const 就能让覆盖失效

假设基类接口是:

class CostFunction {
public:
    virtual ~CostFunction() = default;

    virtual double evaluate(const State& state) const = 0;
};

派生类不小心写成:

class ReprojectionCost : public CostFunction {
public:
    double evaluate(const State& state) {
        return computeError(state);
    }
};

这个函数少了末尾的 const。它不是覆盖,而是在派生类中声明了一个新的同名函数。如果基类函数不是纯虚,程序可能还能编译,但通过 CostFunction& 调用时会进入基类版本。即使基类函数是纯虚,错误信息也会绕到“派生类仍然是抽象类”,不直接指出签名差异。

正确写法是:

class ReprojectionCost : public CostFunction {
public:
    double evaluate(const State& state) const override {
        return computeError(state);
    }
};

override 让编译器检查这件事:该函数必须覆盖某个基类虚函数,否则报错。

覆盖签名包含哪些部分

判断一个成员函数是否覆盖基类虚函数,需要匹配:

签名部分 是否影响覆盖 示例
函数名 evaluate
参数类型和顺序 const State& vs State&
const 限定 f() const vs f()
引用限定 f() & vs f() &&
noexcept 约束 可能影响 派生类不能放宽异常规格
返回类型 有限制 允许协变返回指针/引用
默认参数 不参与动态派发 按静态类型绑定

下面的示例展示几个常见错误:

class Base {
public:
    virtual ~Base() = default;

    virtual void update(double dt) const;
    virtual Base* clone() const;
    virtual void reset(int level = 0);
};

class Derived : public Base {
public:
    void update(double dt) override;        // 错:少了 const
    Derived* clone() const override;        // 对:协变返回
    void reset(int level = 1) override;     // 覆盖成功,但默认参数静态绑定
};

clone() 返回 Derived* 可以覆盖返回 Base* 的虚函数,因为这是协变返回,调用端通过基类接口仍然能把结果当作 Base* 使用。

默认参数不是动态派发

虚函数的函数体动态绑定,但默认参数按静态类型绑定:

#include <iostream>

class Base {
public:
    virtual ~Base() = default;
    virtual void run(int level = 0) const {
        std::cout << "Base level " << level << "\n";
    }
};

class Derived : public Base {
public:
    void run(int level = 10) const override {
        std::cout << "Derived level " << level << "\n";
    }
};

int main() {
    Derived d;
    Base& b = d;
    b.run();  // 调用 Derived::run,但参数是 0
}

输出是:

Derived level 0

这条规则很容易造成接口误解。虚函数接口中尽量避免给派生类重新声明不同默认参数;默认值应放在基类,并保持一致。

同名函数隐藏:重载集被派生类遮蔽

派生类声明同名函数会隐藏基类的整个重载集:

class Base {
public:
    virtual ~Base() = default;
    virtual void setParam(double value);
    virtual void setParam(const std::string& value);
};

class Derived : public Base {
public:
    void setParam(double value) override;
};

此时通过 Derived 对象直接调用 setParam(std::string) 可能找不到基类重载。可以用 using 把基类重载集引入:

class Derived : public Base {
public:
    using Base::setParam;

    void setParam(double value) override;
};

这不是多态机制本身的问题,而是 C++ 名称查找规则。接口设计中如果重载过多,应特别小心派生类隐藏。

为什么 C++ 要隐藏基类的同名函数? 这个设计看起来很反直觉——明明基类有一个完全匹配的 setParam(const std::string&) 重载,为什么派生类声明了 setParam(double) 后就找不到了?

原因是 C++ 的名称查找规则是**分层的**:编译器从最近的作用域开始查找名称,一旦在某个作用域找到了至少一个匹配的名称,就**停止向外层查找**。当 Derived 声明了自己的 setParam(double) 时,编译器在 Derived 的作用域中找到了 setParam 这个名称,于是不再去 Base 的作用域中查找——即使 Base 中有更好的匹配。

这个设计的动机是**防止脆弱的基类问题(fragile base class problem)**:如果基类添加了一个新的重载函数,不应该悄悄改变派生类的调用语义。想象你的 Derived 类调用 setParam("hello") 本来匹配的是 Derived::setParam(const char*)(假设存在这样一个派生类特有的重载),如果 C++ 不做名称隐藏,基类添加了 Base::setParam(const std::string&) 后,setParam("hello") 可能会被重定向到基类版本——因为 const std::string&const char* 在某些情况下是更好的匹配。名称隐藏规则避免了这种"基类添加方法导致派生类行为改变"的远距离影响。

final 的设计含义

final 常用于两类场景:

  1. 叶子类:具体算法类不希望继续派生。
  2. 稳定函数:某个覆盖实现不希望再被派生类改变。
class GicpRegistration final : public Registration {
public:
    RegistrationResult align(const PointCloud& source,
                             const PointCloud& target) override;
};

class BaseTracker {
public:
    virtual ~BaseTracker() = default;

    virtual void feedImage(const Image& image) final {
        preprocess(image);
        track(image);
    }

private:
    virtual void track(const Image& image) = 0;
};

第二种写法要谨慎。把 feedImage() 设为 final 表示流程不允许派生类修改,扩展点只留在 track()。这适合模板方法模式,但如果未来需要改流程,final 会成为约束。

⚠️ 常见陷阱

⚠️ 编程陷阱:只在派生类写 virtual,不写 override

virtual 只能说明这个函数可以参与虚调用,不能保证它覆盖了基类函数。派生类覆盖函数应默认写 override

⚠️ 接口陷阱:虚函数默认参数在派生类中改值

默认参数按静态类型绑定。通过基类引用调用时,派生类的默认值不会生效。接口中应避免让派生类声明不同默认参数。

练习

  1. 编译题:写出三个错误覆盖:少 const、参数类型不同、异常规格放宽。逐个加上 override,观察编译器报错。
  2. 分析题:为什么“派生类函数名相同”不等于“覆盖基类虚函数”?结合名称查找和函数签名解释。
  3. 工程题:为一个 CostFunction 接口写 clone(),使用协变返回或 std::unique_ptr<Base> 两种版本,比较所有权表达差异。

前面几节建立了单继承体系的完整图景:基类定义接口(6.1)→ 对象内存模型与切片防范(6.2)→ vtable 实现动态派发(6.3)→ 纯虚函数定义契约(6.4)→ 虚析构确保安全删除(6.5)→ override 保证签名匹配(6.6)。但真实的机器人系统中,一个类可能需要同时满足多个不同的接口契约。这就引出了多继承——C++ 中最受争议的特性之一。多继承本身不是"高级特性"或"坏特性"——它是解决特定问题(一个对象需要同时扮演多个角色)的工具,关键在于理解它何时安全、何时危险。


6.7 多继承、虚继承与接口组合 ⭐⭐⭐⭐

动机:一个类可能同时扮演多个角色

一个 ROS2 控制器可能既是生命周期节点的一部分,又暴露控制器接口,还需要接受参数更新回调。一个 SLAM 模块可能既是可配置模块,又是可观测状态发布者。

如果这些角色都通过接口表达,就会出现多继承:

class Configurable {
public:
    virtual ~Configurable() = default;
    virtual void configure(const Config& config) = 0;
};

class Resettable {
public:
    virtual ~Resettable() = default;
    virtual void reset() = 0;
};

class Tracker final : public Configurable, public Resettable {
public:
    void configure(const Config& config) override;
    void reset() override;
};

这种“多个纯接口”的继承相对安全,因为每个基类只表达一组行为,不携带复杂状态。

实现多继承的风险

如果多个基类都带数据成员,派生对象中会包含多个基类子对象:

Tracker 对象

┌────────────────────┐
│ Configurable 子对象 │
├────────────────────┤
│ Resettable 子对象   │
├────────────────────┤
│ Tracker 自己的数据  │
└────────────────────┘

这本身不是错误,但会带来名称二义性、指针调整和生命周期复杂度。

为什么多继承在工程中需要谨慎? Java 和 C# 做了一个激进的设计决策——禁止多继承(只允许实现多个接口)。这是因为多继承带来的复杂性(菱形问题、构造顺序、指针调整、内存布局不透明)在大型项目中很容易产生难以排查的 bug。C++ 保留了多继承的能力,但业界共识是:多个纯接口的继承是安全的(类似 Java 的接口实现),多个带状态基类的继承需要非常谨慎。在机器人项目中,ORB-SLAM3 和 g2o 几乎不使用多继承(除了纯接口);ROS2 的 pluginlib 插件体系也只使用单继承。如果你发现自己需要多继承带状态的基类,通常意味着设计可以通过组合(has-a)来替代。

更麻烦的是菱形继承。理解这个问题的最好方式是先看**不用虚继承会怎样**——让问题自然暴露出来,然后再理解虚继承为什么是必要的解决方案。

如果不用虚继承——菱形问题的完整暴露

class Module {
public:
    std::string name;
};

class ConfigurableModule : public Module {};
class RunnableModule : public Module {};

class SlamNode : public ConfigurableModule, public RunnableModule {};

这段代码能编译,但 SlamNode 的内存布局暴露了问题:

SlamNode 对象(不用虚继承)

┌──────────────────────────┐
│ ConfigurableModule 子对象 │
│  └─ Module 子对象 #1     │  ← 第一份 Module,含 name #1
├──────────────────────────┤
│ RunnableModule 子对象     │
│  └─ Module 子对象 #2     │  ← 第二份 Module,含 name #2
├──────────────────────────┤
│ SlamNode 自己的数据       │
└──────────────────────────┘

SlamNode 中有**两份** Module 子对象,各自拥有独立的 name。这导致三个具体问题:

第一,访问 name 时编译器报二义性错误——"你说的是哪个 name?"你必须写 node.ConfigurableModule::namenode.RunnableModule::name 来消歧义,这让代码变得丑陋。

第二,如果你在 ConfigurableModule 的代码中设置了 name = "slam_node",然后在 RunnableModule 的代码中读取 name——读到的是空字符串,因为它读的是另一份 Module 子对象的 name。两个子对象看起来是"同一个 Module",实际上是两个完全独立的实体。

第三,如果 Module 有一个注册到全局系统的构造函数(比如向模块管理器注册自己),同一个 SlamNode 会被注册两次——因为有两个 Module 子对象被构造了。

虚继承:共享同一个虚基类子对象

虚继承正是为了解决这些问题——让菱形顶端只保留一份:

class Module {
public:
    std::string name;
};

class ConfigurableModule : virtual public Module {};
class RunnableModule : virtual public Module {};

class SlamNode : public ConfigurableModule, public RunnableModule {};
SlamNode 对象(使用虚继承)

┌──────────────────────────┐
│ ConfigurableModule 子对象 │  ← 不再直接包含 Module,而是通过指针/偏移引用
├──────────────────────────┤
│ RunnableModule 子对象     │  ← 同样通过指针/偏移引用共享的 Module
├──────────────────────────┤
│ SlamNode 自己的数据       │
├──────────────────────────┤
│ Module 子对象(唯一一份)│  ← 只有一份,被所有路径共享
└──────────────────────────┘

虚继承解决了重复基类子对象问题——只有一份 Module,只有一个 name,只构造一次。但这个方案引入了新的复杂性:虚基类子对象的位置在对象布局中不再固定(它被放到了对象的末尾或其他位置),中间类通过隐藏的偏移量或指针来找到它。虚基类通常由最派生类(SlamNode)负责构造——因为只有最派生类知道虚基类子对象的最终位置。指针调整也更复杂——从 ConfigurableModule* 转换到 Module* 可能需要运行时计算偏移。

设计 优点 代价 建议
单继承 + 组合 简单清晰 角色表达可能稍啰嗦 默认优先
多个纯接口继承 清晰表达多角色 接口间可能冲突 可以使用
带状态多继承 复用实现 布局和生命周期复杂 谨慎
虚继承 解决菱形共享 对象模型复杂 少量底层框架使用

接口组合比实现复用更稳

如果只是想复用一段实现,组合通常比多继承更稳:

class ParameterStore {
public:
    void set(std::string key, double value);
    double get(std::string_view key) const;
};

class Tracker : public Configurable {
public:
    void configure(const Config& config) override {
        params_.set("max_features", config.max_features);
    }

private:
    ParameterStore params_;
};

Tracker 不是一种 ParameterStore,只是拥有参数存储能力。组合保持了对象语义,也避免了把内部工具接口暴露给外部。

多继承的指针调整:一个容易被忽视的细节

多继承中有一个微妙的底层行为——当基类指针转换为派生类指针(或反过来)时,指针的数值可能发生变化。这和单继承不同——在单继承中,基类子对象通常位于派生对象的开头,所以 Base*Derived* 的数值相同。但在多继承中,第二个及后续基类的子对象不在对象开头,因此 SecondBase* 的值需要加上一个偏移量。

struct A { int a; };
struct B { int b; };
struct C : A, B { int c; };

C obj;
A* pa = &obj;  // 指向对象开头
B* pb = &obj;  // 指向 B 子对象,地址通常 = &obj + sizeof(A)
// pa 和 pb 的值不同!

这个细节在 dynamic_castreinterpret_cast 中变得很重要。dynamic_cast 会自动计算正确的指针偏移;reinterpret_cast 不会——它只改变指针类型,不调整数值。这是在多继承体系中绝对不能用 reinterpret_cast 替代 dynamic_cast 的又一个原因。

enable_shared_from_this 是继承,但不是业务多态

std::enable_shared_from_this<T> 常让类继承一个标准库模板:

class KeyFrame : public std::enable_shared_from_this<KeyFrame> {
public:
    std::shared_ptr<KeyFrame> ptr() {
        return shared_from_this();
    }
};

这不是为了把 KeyFrame 当成 enable_shared_from_this<KeyFrame> 使用,而是让标准库在对象内部放置弱引用状态,以便从成员函数安全得到 shared_ptr。这是“实现机制继承”,不是业务上的 is-a。理解这一点能避免把所有继承都解释成同一种语义。

⚠️ 常见陷阱

⚠️ 设计陷阱:用多继承复用带状态实现

多继承最适合组合多个小接口。用它复用多个带状态基类时,构造顺序、析构顺序、二义名称和指针调整都会增加理解成本。

💡 概念误区:虚继承是多继承的默认解法

虚继承是解决菱形共享的特定工具,不是“更高级的继承”。大多数业务代码应先考虑拆接口或组合。

练习

  1. 画图题:画出非虚菱形继承和虚菱形继承的对象布局,标出 Module 子对象数量。
  2. 重构题:把一个 class Tracker : public Logger, public ParameterStore 改成组合版本,说明接口暴露有什么变化。
  3. 分析题:为什么多个纯接口继承相对安全?它仍然可能带来哪些问题?

多继承解决了”一个对象有多个接口”的表达问题。接下来我们回到一个在 6.3 节的 vtable 构建过程中埋下的伏笔:构造 Derived 对象时,vptr 先被设为指向基类的 vtable,然后才被改写为指向派生类的 vtable。这个”vptr 在构造过程中逐步变化”的事实,直接导致了一个令很多程序员困惑的行为——在基类构造函数中调用虚函数,不会派发到派生类版本。


6.8 构造、析构期间的虚调用与多态边界 ⭐⭐⭐

动机:基类构造函数想调用派生类初始化

下面的写法很诱人:

class Module {
public:
    Module() {
        initialize();
    }

    virtual ~Module() = default;

    virtual void initialize() {
        std::cout << "Module initialize\n";
    }
};

class CameraModule : public Module {
public:
    void initialize() override {
        std::cout << "Camera initialize\n";
    }
};

创建 CameraModule 时,很多人期待输出 Camera initialize。实际在 Module 构造函数中,虚调用不会派发到 CameraModule::initialize(),因为此时派生类部分还没有构造完成。

构造顺序决定了动态类型边界

构造 CameraModule 的顺序是:

1. 构造 Module 基类子对象
   - 在这个阶段,对象还只是 Module 子对象
   - CameraModule 的成员尚未构造
2. 构造 CameraModule 的成员
3. 执行 CameraModule 构造函数体

如果基类构造函数能调用派生类虚函数,派生类函数可能访问尚未构造的成员。C++ 选择更保守的规则:构造和析构期间,虚调用只在当前构造/析构层级内派发。

析构期间也是类似道理。执行基类析构函数时,派生类部分已经析构完毕,派生类成员不再可用,所以虚调用不会派发到派生类版本。

阶段 对象可安全使用的部分 虚调用派发到哪里
基类构造函数 基类子对象 基类版本
派生类构造函数体 基类 + 派生类已构造成员 派生类版本
派生类析构函数体 派生类成员仍可用 派生类版本
基类析构函数 派生类部分已销毁 基类版本

正确模式:两阶段初始化或工厂函数

如果初始化需要调用虚函数,常见做法是把构造和启动分开:

class Module {
public:
    virtual ~Module() = default;

    void start() {
        onStart();
    }

private:
    virtual void onStart() = 0;
};

class CameraModule final : public Module {
private:
    void onStart() override {
        openCamera();
    }
};

对象完全构造后,再由外部调用 start()。此时虚函数可以派发到派生类。

也可以用工厂函数保证创建后立即完成启动:

std::unique_ptr<Module> makeCameraModule(CameraConfig config) {
    auto module = std::make_unique<CameraModule>(std::move(config));
    module->start();
    return module;
}

这和 ROS2 lifecycle 的思想接近:构造对象只建立基本状态,配置、激活、停用由显式生命周期函数完成。构造函数越轻,异常和资源回滚越容易控制。

为什么 C++ 选择"构造期间不派发到派生类"而不是"构造期间也派发到派生类"? 这是一个深思熟虑的设计决策。如果构造期间允许派发到派生类版本,派生类的虚函数可能会访问尚未初始化的成员变量——在 Module 的构造函数执行时,CameraModule 特有的成员(如 camera_device_frame_buffer_)还没有被构造。访问未初始化的成员是未定义行为,在机器人系统中可能导致硬件驱动异常、图像数据损坏或段错误。C++ 通过"在构造期间把动态类型限制为当前构造层级"来从根源上阻止这种错误。Java 做了相反的选择——构造函数中的虚函数调用会派发到派生类版本,这虽然更"直觉",但经常导致"在构造期间访问未初始化字段"的 bug,是 Effective Java 中被反复警告的陷阱(Item 19)。

这个设计差异可以用一个类比理解:C++ 的构造过程像盖楼——先建一楼(基类),再建二楼(派生类)。在建一楼时,二楼的电梯按钮(派生类虚函数)不应该可用,因为二楼还不存在。Java 的做法更像一栋框架已搭好但内装未完的楼——电梯按钮已经连线,但如果你按了二楼的按钮,电梯门打开看到的是未装修的毛坯(未初始化的成员)。

默认参数静态绑定也是多态边界

6.6 节已经展示过默认参数问题。这里把它放进多态边界再看一次:虚函数只动态绑定函数体,不动态绑定默认参数。默认参数由调用表达式的静态类型决定。

class Planner {
public:
    virtual ~Planner() = default;
    virtual void plan(int attempts = 1) = 0;
};

class SamplingPlanner final : public Planner {
public:
    void plan(int attempts = 10) override;
};

void run(Planner& planner) {
    planner.plan();  // attempts = 1
}

接口中如果允许派生类改默认参数,调用端会得到不一致行为。更稳的写法是把默认值放进配置结构:

struct PlanOptions {
    int attempts = 10;
};

class Planner {
public:
    virtual ~Planner() = default;
    virtual void plan(const PlanOptions& options) = 0;
};

⚠️ 常见陷阱

⚠️ 生命周期陷阱:构造函数中调用虚函数

基类构造函数中的虚调用不会进入派生类版本。初始化派生类资源应放在派生类构造函数、显式 start()、工厂函数或 lifecycle 回调中。

⚠️ 接口陷阱:派生类修改虚函数默认参数

默认参数静态绑定。通过基类引用调用时,会使用基类默认值。用配置结构代替默认参数更稳。

练习

  1. 实验题:写一个基类构造函数调用虚函数的例子,观察输出。再把调用移到 start(),比较差异。
  2. 设计题:为一个 SensorDriver 设计构造、configure、activate、deactivate 四阶段生命周期。哪些阶段可以安全调用虚函数?
  3. 排错题:一个派生类把 plan(int attempts = 20) 的默认值改了,但通过基类调用仍然只尝试 1 次。解释原因并修复。

到这里,运行时多态的语言边界已经清楚——我们知道虚函数如何工作(6.3)、何时安全(6.5-6.6)、在构造析构期间有什么限制(6.8)。最后需要回答一个工程层面的问题:虚函数是不是所有"可替换"需求的最佳答案?答案是否定的——C++ 提供了多种抽象机制,每种适合不同的场景。本节把所有这些机制放到一起对比,建立一个"何时用什么"的决策框架。这个框架在机器人 C++ 开发中尤其重要,因为机器人系统同时包含"需要运行时灵活性"的架构层和"需要极致性能"的数值层——两层对抽象机制的需求截然不同。


6.9 运行时多态、静态多态与机器人 C++ 分层 ⭐⭐⭐⭐

动机:为什么 Eigen 不把矩阵表达式做成虚函数

如果虚函数能表达可替换性,为什么 Eigen、Sophus、Ceres Jet 这类数值库大量使用模板,而不是给每个矩阵表达式定义虚基类?

原因是调用频率和优化需求完全不同。配准算法对象每帧调用一次 align(),虚函数开销几乎淹没在 ICP 迭代里。矩阵表达式中的一次加法、一次乘法、一次李群小量更新可能在内层循环中被调用千万次,编译器需要看到完整类型才能内联、展开和消除临时对象。

抽象方式 决策时机 典型用途 优点 代价
虚函数 运行时 插件、驱动、算法模块 灵活,二进制边界清晰 间接调用,少一些编译期信息
模板 编译期 数值内核、容器适配 易内联,类型信息完整 编译时间长,错误信息复杂
CRTP 编译期 Eigen/Sophus 风格基类复用 静态派发,零运行期开销 语法复杂,运行时异构不自然
std::function 运行时 回调、策略函数 使用方便,可捕获状态 可能有类型擦除开销
std::variant 编译期封闭集合 有限算法集合 无虚析构,值语义 新增类型要改 variant

Eigen 不用虚函数的深层原因

Eigen 的所有矩阵表达式类型(MatrixMapBlockCwiseBinaryOp 等)都继承自 MatrixBase<Derived>——这是 CRTP,不是虚函数。为什么 Eigen 选择了这条路?

第一个原因是**表达式模板需要编译期类型信息**。A + B * C 这个表达式在 Eigen 中会生成类型 CwiseBinaryOp<Sum, Matrix3d, CwiseBinaryOp<Product, Matrix3d, Matrix3d>>——一棵编译期的表达式树。编译器在赋值时一次性遍历这棵树,生成最优的循环代码(向量化、展开、消除临时矩阵)。如果使用虚函数,表达式树的每个节点都是基类指针,编译器无法在编译期看到完整的表达式结构,就无法做表达式融合和 SIMD 优化。

第二个原因是**逐元素操作的调用频率**。一个 1000x1000 的矩阵加法需要执行 100 万次逐元素加法。如果每次加法都通过虚函数间接调用,就是 100 万次 vptr 查表 + 间接跳转。即使每次只多几纳秒,累积起来也是毫秒级的开销——在 1kHz 的控制循环中不可接受。

第三个原因是**代码体积和缓存友好性**。CRTP 的代码在编译期对每种具体类型生成特化版本,这些特化版本可以被编译器充分优化(常量传播、循环展开、寄存器分配)。虚函数的函数体在运行时才确定,编译器只能生成通用版本,优化空间更小。

但 Eigen 的设计也付出了代价:编译时间长(每种表达式组合都生成新的模板实例化)、错误信息晦涩(嵌套模板类型的报错极其难读)、无法在运行时切换矩阵表达式类型。这些代价在数值计算库中是可接受的,在应用架构层则不适合。

机器人项目中的分层原则

一个稳定的经验是:

架构层:运行时多态
  - 传感器驱动接口
  - 配准算法接口
  - 优化后端接口
  - ROS2/pluginlib 控制器插件

算法层:模板 + 普通类
  - 点云类型
  - 李群类型
  - 残差块
  - 矩阵表达式

热路径:内联函数 + CRTP + 数据布局优化
  - 每点残差计算
  - 最近邻距离内核
  - 雅可比组装
  - 小矩阵运算

本质洞察:机器人 C++ 的核心不是“虚函数和模板谁更先进”,而是把灵活性放在变化边界,把性能放在内层循环。架构层需要运行时替换,数值层需要编译期展开。

pluginlib:虚函数 + 工厂 + 动态库发现

ROS2 的插件体系可以理解为本章内容的工程化版本:

  1. 定义一个 C++ 抽象基类。
  2. 插件类继承该基类并实现虚函数。
  3. 用宏导出插件类。
  4. 用 XML 描述插件类型和动态库。
  5. 运行时根据字符串加载动态库并创建对象。

简化后的接口可能是:

class Planner {
public:
    virtual ~Planner() = default;

    virtual void configure(const PlannerConfig& config) = 0;
    virtual Trajectory plan(const PlanningScene& scene,
                            const Pose& goal) = 0;
};

调用端拿到的是 std::shared_ptr<Planner>std::unique_ptr<Planner>,不需要链接具体插件类。这里的本质仍然是运行时多态,只是对象创建被工厂和动态库系统接管。

pluginlib 的底层机制:pluginlib 使用 dlopen/dlsym(Linux)或 LoadLibrary/GetProcAddress(Windows)在运行时加载动态库。插件类通过 PLUGINLIB_EXPORT_CLASS 宏在动态库中注册一个工厂函数,pluginlib 的 ClassLoader 在运行时通过 XML 配置找到对应的动态库,加载它,调用工厂函数创建对象,返回基类智能指针。整个过程中,调用端的代码与插件的代码分别编译成不同的二进制——这就是为什么虚函数在这里不可替代:二进制边界之间的函数调用只能通过 ABI 层面的约定(如 vtable 布局)完成,模板和内联函数无法跨越动态库边界。

这也解释了为什么 ROS2 的控制器、规划器、传感器驱动几乎都使用虚函数接口:这些组件经常以独立包的形式分发,用户在不修改框架代码的情况下通过配置文件加载自己的实现。虚函数 + 动态库是 C++ 中唯一能在二进制级别实现"编译时未知、运行时加载"的标准化机制。

为什么不用 std::variant 替代 pluginlib? 因为 variant 要求在编译期列出所有可能的类型。如果框架在编译时不知道用户会编写哪些插件,就无法把它们放进 variant 的类型列表。variant 适合框架内部已知的封闭类型集合(如"ICP/NDT/GICP 三种配准算法"),不适合开放的插件系统。

真实项目中的分层实例

下面这个表格列出了几个机器人项目中架构层和数值层的抽象方式选择,作为上述原则的具体案例参考:

项目 架构层抽象 数值层抽象 原因
KISS-ICP VoxelHashMap 没有虚函数 模板化的点类型 整个管线是一条固定路径,不需要运行时切换
g2o Vertex/Edge 虚基类 + 工厂 BaseVertex<D,E> 模板参数固定维度 图中需要混合不同顶点/边类型
OpenVINS TrackBase 虚接口 + 配置选择 Eigen 矩阵运算无虚函数 特征跟踪器运行时可切换,矩阵运算需要内联
MoveIt2 PlannerPlugin pluginlib 加载 moveit::core::RobotState 无虚函数 规划器作为插件分发,状态计算在内循环
Ceres CostFunction 虚基类 Jet<T> 自动微分模板 用户自定义残差块,标量类型编译期确定

决策流程

选择抽象方式时,可以按下面的顺序判断:

1. 是否需要运行时从配置文件、插件或用户输入选择实现?
   ├─ 是 → 优先虚函数接口 + 工厂
   └─ 否 → 继续

2. 是否处在高频内层循环,且类型集合在编译期已知?
   ├─ 是 → 优先模板、CRTP、普通内联函数
   └─ 否 → 继续

3. 是否只是传入一个可调用策略?
   ├─ 是 → Lambda / 函数对象 / std::function
   └─ 否 → 继续

4. 类型集合是否很小且封闭?
   ├─ 是 → 可以考虑 std::variant
   └─ 否 → 回到虚函数或重新拆分模块边界

C++23/26 对虚函数机制的影响

C++ 的演进并没有抛弃虚函数,而是在不同层次提供更精细的工具:

C++23 deducing this(P0847):显式对象参数让成员函数可以根据 *this 的值类别和 cv 限定进行模板推导。这部分替代了 CRTP 的典型用法——不再需要通过 static_cast<Derived&>(*this) 来恢复派生类类型。但 deducing this 是编译期机制,不能替代运行时虚函数——它解决的是"编译期知道类型但需要通用代码"的问题,虚函数解决的是"运行时才知道类型"的问题。

编译器的去虚化优化(devirtualization):现代编译器(GCC 10+、Clang 10+)在能推断出对象动态类型的情况下,会自动把虚函数调用优化成直接调用。final 关键字是最直接的去虚化提示。当编译器看到 final 类的虚函数调用时,它知道不可能有进一步的派生类覆盖,可以安全地内联函数体。但去虚化不限于 final——如果编译器能通过数据流分析确定对象的真实类型(例如 auto obj = DerivedClass{}; obj.virtualFunc();),即使没有 final 也能去虚化。

std::variant + std::visit 作为虚函数的替代:对于封闭的类型集合(在编译期已知所有可能的类型),std::variant 提供了值语义的多态——不需要堆分配、不需要虚函数表、不需要指针间接。C++26 进一步改进了 std::visit 的编译期优化能力。但 variant 的局限性也很明显:新增一个类型需要修改 variant 的类型列表和所有 visit 调用点——这与虚函数的"新增派生类不影响调用端"的优势形成对比。

机制 适合场景 新增类型的影响
虚函数 开放集合,运行时选择 不影响调用端
std::variant 封闭集合,值语义 需要修改 variant 和所有 visit
CRTP / deducing this 编译期多态,零开销 编译期确定,运行时不可切换
std::function 轻量回调 不涉及类型层次

常见误解

💡 概念误区:现代 C++ 应该避免所有虚函数

模板和 CRTP 不能自然解决运行时插件加载、动态算法切换和跨动态库接口。虚函数仍然是架构层的重要工具。

💡 概念误区:所有策略都应该做成虚基类

如果策略只在一个函数内部使用,Lambda 或函数对象更轻。把每个小策略都做成类层次,会让代码变重。

练习

  1. 选型题:对以下场景选择抽象方式并说明理由:ROS2 控制器插件、Ceres 残差中的标量类型、点云滤波阈值函数、三种固定后端求解器。
  2. 重构题:把一个虚函数逐点残差计算改成“外层虚接口选择算法,内层模板函数处理点”的两层结构。
  3. 跨章综合题:结合 现代类设计与特殊成员函数-移动语义与完美转发,设计一个 RegistrationFactory:工厂返回 std::unique_ptr<Registration>Registration 禁止拷贝和移动;每个派生类用 override 实现 align();工厂用移动语义返回所有权。

本章小结

本章从“运行时切换算法”这个机器人系统需求出发,建立了继承与多态的完整工程图景:

主题 核心结论
继承语义 public 继承表达可替换性,不是代码复用优先
对象模型 派生对象包含基类子对象;按值传递基类会对象切片
虚函数 动态派发把具体调用目标推迟到运行时
vtable/vptr 主流 ABI 常用实现细节,不是可移植布局契约
抽象类 纯虚函数定义接口契约,接口应小而稳定
虚析构 多态基类通过基类指针删除派生对象时必须有虚析构
override 把覆盖关系交给编译器检查,不靠肉眼维护
final 表达继承边界,也可能提供去虚化机会
多继承 多个纯接口相对安全,带状态多继承要谨慎
构造/析构期虚调用 不会派发到尚未构造或已经销毁的派生部分
抽象方式选型 架构层用运行时多态,热路径用模板/CRTP/内联

本章最重要的不是记住”虚函数表里有函数指针”,而是建立一个工程判断:当系统需要在运行时替换模块时,用虚接口把变化限制在边界;当代码处在数值热路径时,把类型信息留给编译器。

一条贯穿本章的原则:继承是”承诺派生类可以替代基类”(is-a),不是”从基类借一些函数来用”(代码复用)。如果你发现自己在考虑继承主要是为了复用几个函数,组合(has-a)几乎总是更好的选择。继承创建的是类型层级关系——一旦建立,就成为整个系统的架构约束。组合只是对象之间的持有关系——修改或替换某个组件不会影响类型层级。在机器人系统的长期维护中,这种灵活性差异会持续产生影响。


累积项目:本章新增模块

本章把前几章的类型、类设计、RAII 和移动语义连接成一个可运行的小型模块系统。

项目目标

实现一个简化版配准模块框架:

mini_registration/
├── include/
│   ├── point_cloud.hpp
│   ├── registration.hpp
│   ├── icp_registration.hpp
│   └── registration_factory.hpp
└── src/
    └── main.cpp

核心要求:

  1. Registration 是抽象基类,包含虚析构、删除拷贝/移动、纯虚 align()
  2. IcpRegistrationNdtRegistration 使用 override 实现 align()
  3. makeRegistration() 返回 std::unique_ptr<Registration>
  4. 调用端只依赖 Registration&,不出现具体派生类判断。
  5. 增加一个 clone() 版本,比较“移动所有权”和“复制动态对象”的差异。

参考接口

#pragma once

#include <memory>
#include <string_view>
#include <vector>

struct Point3D {
    double x = 0.0;
    double y = 0.0;
    double z = 0.0;
};

using PointCloud = std::vector<Point3D>;

struct RegistrationResult {
    bool converged = false;
    int iterations = 0;
    double fitness_score = 0.0;
};

class Registration {
public:
    Registration() = default;
    virtual ~Registration() = default;

    Registration(const Registration&) = delete;
    Registration& operator=(const Registration&) = delete;
    Registration(Registration&&) = delete;
    Registration& operator=(Registration&&) = delete;

    virtual std::unique_ptr<Registration> clone() const = 0;

    virtual RegistrationResult align(const PointCloud& source,
                                     const PointCloud& target) = 0;
};

std::unique_ptr<Registration> makeRegistration(std::string_view type);

验收标准

检查项 合格标准
对象切片 项目中不出现 std::vector<Registration>Registration 按值参数
析构安全 Registration 析构函数为 virtual
覆盖检查 派生类覆盖函数全部写 override
所有权 工厂返回 std::unique_ptr<Registration>
扩展性 新增 GicpRegistration 时调用端不需要修改
诊断 故意去掉一个 const 后,override 能产生编译错误
NVI 模式 align() 是公有非虚函数,内部调用私有虚 computeTransformation()
多态与继承对比 写出"为什么不用模板/variant 做配准接口"的分析文档(3-5 句)

延伸阅读

资源 内容 难度
Bjarne Stroustrup, The C++ Programming Language 第 20-22 章 类层次、虚函数和对象模型的权威讲解 ⭐⭐
Scott Meyers, Effective C++ Item 7, 32-40 多态基类析构、接口设计、继承与组合的经典条款 ⭐⭐
Herb Sutter, GotW 系列 虚函数、异常安全和多态所有权相关讨论 ⭐⭐⭐
Itanium C++ ABI 文档 主流平台 vtable/vptr 布局的参考材料。阅读时要记住它是 ABI 文档,不是 C++ 标准 ⭐⭐⭐⭐
g2o、PCL、OpenVINS、ROS2 pluginlib 源码 把本章的接口设计映射到真实机器人项目 ⭐⭐⭐
P0847R7 (deducing this) 提案 C++23 显式对象参数的设计动机和语义定义 ⭐⭐⭐⭐
C++ Core Guidelines C.120-C.140 官方编码指南中关于类层次设计的条目 ⭐⭐
Arthur O'Dwyer, "Back to Basics: Virtual Dispatch" (CppCon 2019) 虚函数表的底层实现讲解,配有图示 ⭐⭐⭐

🔧 故障排查手册

现象 常见原因 检查方式 修复方式
通过基类指针删除后资源没释放或程序异常 基类析构函数不是 virtual 检查基类是否有虚函数但析构非虚 virtual ~Base() = default;
派生类函数没有被调用 函数签名不匹配或对象切片 给派生类函数加 override,检查参数是否按值传递 改成引用/指针传递,修正签名
std::vector<Base> 中多态失效 容器按值存储基类,发生切片 搜索 vector<Base>Base 按值参数 改为 vector<unique_ptr<Base>> 或引用集合
构造函数里调用虚函数没进派生类 构造期间动态类型还不是派生类 检查基类构造函数和析构函数中的虚调用 改成显式 start() 或工厂初始化
默认参数值和派生类声明不一致 默认参数静态绑定 检查虚函数默认参数是否在派生类改值 把默认值放进基类或配置结构
dynamic_cast 返回空指针 对象动态类型不是目标类型,或基类非多态 检查基类是否有虚函数,检查真实对象类型 先设计清晰接口,必要时在 类型转换 中用安全转换
多继承访问成员二义 两个基类都有同名成员或重复祖先 查看继承图和编译器二义性错误 使用限定名、拆接口、组合或必要时虚继承
虚函数热路径性能差 高频循环中间接调用阻碍内联 profile,观察调用次数和内联情况 外层虚接口选择算法,内层模板/函数对象计算

认知工具索引

本章使用的认知工具一览,方便回顾检索:

认知工具 位置 内容
类比 6.1 C 语言的函数指针表 vs C++ 的 vtable:手工管理 vs 编译器管理
类比 6.3 vtable 选择像"查电话簿"——不管认识多少人,查一个号码的时间固定
类比 6.8 C++ 构造过程像"盖楼"——一楼没建好,二楼的电梯按钮不应可用
反事实 6.1 如果不用多态,每个调用点写 switch——新增算法导致修改点扩散
反事实 6.3 如果 C++ 不用 vtable 而用消息传递——调用开销从 O(1) 变成 O(k)
反事实 6.4 如果接口不从基类移出特有方法——不相关的实现也被迫重新编译
本质洞察 6.1 继承不是"把公共代码放到父类里",而是"承诺派生类可以替换基类"
本质洞察 6.3 虚函数不是"慢",而是"把具体类型推迟到运行时"
本质洞察 6.9 机器人 C++ 的核心是把灵活性放在变化边界,把性能放在内层循环
本质洞察 6.1 继承和模板工作在不同维度——继承处理运行时选择,模板处理编译期复用


多态设计的性能量化:虚函数调用的真实开销

关于虚函数性能的讨论常常陷入两个极端:要么认为"虚函数很慢,尽量避免",要么认为"现代 CPU 分支预测很好,虚函数没有开销"。准确的理解需要区分两个维度——单次调用开销**和**优化阻碍

单次虚函数调用的直接开销非常小:一次内存加载(读取 vptr)、一次间接跳转(通过 vtable 读取函数地址)。在现代 CPU 上,这大约是 2-5 纳秒。对于 SLAM 系统中以 10-30 Hz 运行的回环检测或位姿图优化,虚函数的开销完全可以忽略。

真正的性能影响来自优化阻碍:编译器无法内联虚函数调用,因此也无法做跨函数的常量传播、循环向量化和死代码消除。在高频内层循环中,这种优化损失可能远大于间接调用本身。

// 场景 A:外层选择——虚函数合适
// 每帧调用一次,选择不同的配准算法
registration->align(source, target);  // 虚调用,每帧 1 次,开销可忽略

// 场景 B:内层计算——虚函数不合适
// 对每个点调用距离函数,百万次/帧
for (const auto& point : cloud) {
    double d = metric->distance(point, target);  // 虚调用,百万次/帧
    // 编译器无法内联 distance(),无法向量化这个循环
}

本质洞察:虚函数的性能边界不是"调用慢",而是"阻止编译器看到函数体"。 在调用频率低于每帧几千次的场景中,虚函数是零成本的抽象。 在每帧百万次的内层循环中,应当用模板或 std::function + std::visit 替代,让编译器有机会内联。

这个判断标准可以量化:如果 profile 显示虚调用点不在热点函数中,保留虚函数以获得接口灵活性。如果虚调用点位于占据 >10% CPU 时间的热点循环中,考虑用 CRTP 静态多态或模板策略模式替代。两种模式的选择不是非黑即白——同一个系统中可以在不同层级使用不同的多态方式。

虚函数与分支预测器的交互

现代 CPU 的间接分支预测器(BTB,Branch Target Buffer)能够记住近期执行过的虚调用目标。如果虚函数调用点上总是调用同一个派生类的实现(单态调用点,monomorphic call site),BTB 的预测准确率接近 100%,性能接近直接调用。如果调用点交替调用两三个不同实现(多态调用点),BTB 仍能较好地预测。但如果每次调用的目标都随机变化(真正的多态热点),BTB 预测失败率升高,流水线冲刷导致明显的性能损失。

在机器人系统中,大多数虚调用点都是单态或少态的——配准算法在运行期间通常不会频繁切换。因此实践中虚函数的性能问题比理论分析中更少见。

final 关键字的去虚化(devirtualization)效果

final 标记告诉编译器"这个类不会被进一步派生"或"这个虚函数不会被进一步覆盖"。这让编译器可以在编译期确定虚调用的目标,进行去虚化(devirtualization)——将间接调用转换为直接调用,从而允许内联。

class IcpRegistration final : public Registration {
    // 编译器知道 IcpRegistration 没有子类
    // 对 IcpRegistration* 的虚调用可以去虚化
    RegistrationResult align(const PointCloud& source,
                             const PointCloud& target) override;
};

void process(IcpRegistration* reg, const PointCloud& src, const PointCloud& tgt) {
    // 编译器可以将此虚调用内联——因为 reg 的动态类型确定是 IcpRegistration
    auto result = reg->align(src, tgt);
}

去虚化在机器人控制的高频路径中特别有价值。如果在外层用虚函数选择算法,在内层通过 final 派生类指针调用,编译器可以对内层调用做内联和向量化——兼顾了接口灵活性和内层性能。

在设计类层次时,对于确定不会被继承的叶子类(leaf class),应当习惯性地标记 final。这不仅是性能优化,也是设计意图的文档——告诉代码阅读者"这个类是继承体系的终点"。

在 SLAM 和机器人控制代码中,final 的典型使用场景包括:具体的配准算法实现(IcpRegistration final)、具体的传感器驱动(VelodyneLidar final)、具体的控制器实现(PdController final)。这些类实现了接口但不打算被进一步继承——标记 final 让编译器和代码阅读者都能明确这一点。

需要注意的是,final 是一个不可逆的设计决策——一旦标记了 final,后续如果需要从这个类派生就必须去掉 final,这可能影响依赖去虚化优化的代码路径。因此 final 最适合用于已经稳定、不太可能需要扩展的叶子类。对于框架和库中的基类,即使当前没有子类,也不应过早地标记 final——保留扩展性比微小的性能收益更有价值。

在 PCL 和 g2o 等开源库中,基类(如 pcl::Registrationg2o::BaseVertex)都没有标记 final,因为它们的设计目的就是被用户扩展。


下一章将进入类型转换。回顾本章:运行时多态让 Base* 能指向不同派生对象,但有时我们确实需要在安全边界内恢复具体类型。类型转换 会系统区分 static_castdynamic_castconst_castreinterpret_cast 和隐式转换,解释哪些转换是类型系统允许的,哪些转换只是把风险推迟到运行时。