找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3837

积分

0

好友

499

主题
发表于 1 小时前 | 查看: 3| 回复: 0

LLVM 代码库超过两百万行 C++,编译全程带 -fno-rtti。grep 整个代码库,没有一次 dynamic_cast 调用

这不是偶然。Chromium 也一样,它有自己的 base::DownCast;Unreal Engine 有 Cast<>;protobuf 有 DynamicCastToGenerated。这些项目体量从百万到千万行不等,无一使用标准的 dynamic_cast。理由只有一个:它们都发现 dynamic_cast 的代价,远比多数程序员想的要大。

读完这篇文章你带走三件东西:dynamic_cast 在运行时到底做了什么(不是“查一下 vtable”那么简单);三种替代方案(enum tag + classof、Visitor 双重派发、std::variant + std::visit)各自的安全保证和工程代价;以及根据你的具体场景,应该选哪个。

dynamic_cast 到底慢在哪

标准怎么说的:dynamic_cast<T*>(v) 要求 v 指向一个多态类型(至少有一个虚函数),运行时检查 v 指向的最派生对象是否包含一个 T 类型的子对象([expr.dynamic.cast]/2, /6)。找到了返回指向该子对象的指针,找不到返回 nullptr。引用版本失败则抛 std::bad_cast

一段最常见的用法:

struct Base { virtual ~Base() = default; };
struct Derived : Base { int value = 42; };

void process(Base* b) {
    if (auto* d = dynamic_cast<Derived*>(b)) {
        // 安全:d 确实指向 Derived
        use(d->value);
    }
}

这段代码的运行时路径,取决于你用的标准库。以 libcxxabi(Clang 默认的 C++ ABI 库)为例,编译器把 dynamic_cast 翻译成一次对 __dynamic_cast 函数的调用:

// libcxxabi src/private_typeinfo.cpp 简化
extern "C" void* __dynamic_cast(
    const void* src_ptr,              // 源对象指针
    const __class_type_info* src_type, // 源类型的 type_info
    const __class_type_info* dst_type, // 目标类型的 type_info
    ptrdiff_t src2dst_offset)          // 编译器提供的提示偏移
{
    // 第一步:从 vtable[-1] 取出最派生对象的 type_info
    // 第二步:从 vtable[-2] 取出到最派生对象的偏移
    // 第三步:对 type_info 继承图做深度优先搜索
    //         调用 search_below_dst / search_above_dst
    // ...
}

关键在第三步。__dynamic_cast 不是简单比较两个指针是否相等。它要从最派生对象的 type_info 开始,递归遍历整个继承图,搜索是否存在目标类型的基类子对象。这个搜索由两个递归函数 search_below_dstsearch_above_dst 完成,它们处理虚继承、多重继承、交叉转换(cross-cast)等各种复杂情况。

简单单继承下,搜索深度通常只有一两层,开销大约 15–30 纳秒。但在多重继承或虚继承场景下(这在大型框架里很常见),搜索要遍历多条继承路径,开销可以飙到 50–200 纳秒。对比之下,一次普通的虚函数调用大约 3–5 纳秒。

还有一个容易忽视的代价:RTTI 数据本身的空间开销。每个多态类型都会在可执行文件里生成 type_info 对象和类名字符串。在大型项目中,这些数据累积起来能占到二进制大小的 5%–10%。LLVM 早在 2003 年就因为这个原因全面禁用了 C++ RTTI。

这就是 -fno-rtti 存在的理由。一旦开了这个编译选项dynamic_cast 直接不可用——编译器会报错。那问题就来了:不用 dynamic_cast,安全的向下转换还能怎么做?

答案有三条路线,每条路线的安全保证和代价都不一样。

方案一:enum tag + classof——LLVM 的选择

原理很直接:在基类里存一个枚举值标记具体类型,派生类在构造时设定这个值。向下转换时,先检查 tag,再 static_cast

class Shape {
public:
    enum Kind { SK_Circle, SK_Square };
private:
    Kind kind_;
public:
    Shape(Kind k) : kind_(k) {}
    Kind getKind() const { return kind_; }
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double radius_;
public:
    Circle(double r) : Shape(SK_Circle), radius_(r) {}
    double getRadius() const { return radius_; }
    // LLVM 风格:每个类提供一个 static classof
    static bool classof(const Shape* s) {
        return s->getKind() == SK_Circle;
    }
};

class Square : public Shape {
    double side_;
public:
    Square(double s) : Shape(SK_Square), side_(s) {}
    double getSide() const { return side_; }
    static bool classof(const Shape* s) {
        return s->getKind() == SK_Square;
    }
};

在 LLVM 里,isa<>dyn_cast<> 就是对 classof 的包装:

// LLVM 的用法(llvm/Support/Casting.h)
if (auto* c = dyn_cast<Circle>(shape)) {
    // c 是 Circle*
    use(c->getRadius());
}

// 对比标准 dynamic_cast 的写法
if (auto* c = dynamic_cast<Circle*>(shape)) {
    use(c->getRadius());
}

语法几乎一样,但编译产物天差地别。dyn_cast<Circle>(shape) 展开后就是:

Circle::classof(shape) ? static_cast<Circle*>(shape) : nullptr

classof 的实现是 shape->getKind() == SK_Circle——一条 load 加一条 cmp,总共 1–2 纳秒。与 dynamic_cast 的 DFS 遍历相比,快了一到两个数量级。

LLVM 对这套机制还有更精细的设计。当继承层次有多级时(比如 Shape → Polygon → Square),classof 可以检查一个 enum 范围而非单个值:

class Polygon : public Shape {
public:
    // Polygon 的所有子类 Kind 落在 [SK_Square, SK_Triangle] 范围内
    static bool classof(const Shape* s) {
        return s->getKind() >= SK_Square
            && s->getKind() <= SK_Triangle;
    }
};

这依赖一个约定:枚举值按继承层次连续编排。LLVM 文档(llvm/docs/HowToSetUpLLVMStyleRTTI.rst)对此有明确要求,每个新子类的 enum 值必须落在父类的范围内。

安全边界在哪

这套方案的安全性完全建立在两个前提上:

  1. classof 的实现必须正确。如果 classof 返回了错误的结果,后续的 static_cast 就是未定义行为。没有任何运行时检查能兜底——这一点与 dynamic_cast 根本不同。
  2. 新增子类时必须同步更新 enum 和 classof。遗漏了,编译器不会报错(因为 static_cast 不做运行时检查),但程序会悄悄做出错误的类型判定。

对于第二个问题,-Wswitch 是最后一道防线。当你用 switch 对 enum 做分发时:

void dispatch(Shape* s) {
    switch (s->getKind()) {
    case Shape::SK_Circle: /* ... */ break;
    case Shape::SK_Square: /* ... */ break;
    // 如果将来新增了 SK_Triangle 但忘了在这里加 case——
    // -Wswitch 会警告 "enumeration value 'SK_Triangle' not handled"
    }
}

但这道防线有个开关:一旦你写了 default: 分支,-Wswitch 就不再警告未覆盖的枚举值。很多项目出于“处理异常情况”的习惯加了 default: assert(false);,结果把编译器的静态检查关掉了,新增类型时完全靠人记得改。

我的建议:不写 default。用 switch 后跟一行 __builtin_unreachable()(或 C++23 的 std::unreachable())来告诉编译器“这里不可达”,同时保留 -Wswitch 的穷举检查。

方案二:Visitor 双重派发——编译器帮你穷举

Visitor 模式 换了一个思路:不让调用者判断类型,让对象自己告诉你它是什么。

// 前向声明
class Circle;
class Square;

// Visitor 接口:每个子类对应一个 visit 方法
class ShapeVisitor {
public:
    virtual void visit(Circle& c) = 0;
    virtual void visit(Square& s) = 0;
    virtual ~ShapeVisitor() = default;
};

class Shape {
public:
    virtual void accept(ShapeVisitor& v) = 0;
    virtual ~Shape() = default;
};

class Circle : public Shape {
    double radius_;
public:
    Circle(double r) : radius_(r) {}
    double getRadius() const { return radius_; }
    void accept(ShapeVisitor& v) override { v.visit(*this); }
};

class Square : public Shape {
    double side_;
public:
    Square(double s) : side_(s) {}
    double getSide() const { return side_; }
    void accept(ShapeVisitor& v) override { v.visit(*this); }
};

使用时,每种“操作”定义一个 Visitor 实现:

class AreaCalculator : public ShapeVisitor {
    double result_ = 0;
public:
    void visit(Circle& c) override {
        result_ = 3.14159 * c.getRadius() * c.getRadius();
    }
    void visit(Square& s) override {
        result_ = s.getSide() * s.getSide();
    }
    double getResult() const { return result_; }
};

// 调用
AreaCalculator calc;
shape->accept(calc);  // 两次虚函数调用:accept + visit

注意这里的分发路径:shape->accept(calc) 是一次虚函数调用(确定具体类型),v.visit(*this) 又是一次虚函数调用(确定具体操作)。两次间接跳转,大约 6–10 纳秒,比 dynamic_cast 快,但比 enum tag 的 load+cmp 慢。

Visitor 真正的价值在于编译器的穷举保证。当你新增一个 Triangle 类时,必须在 ShapeVisitor 接口里加一个 visit(Triangle&) 纯虚函数。那么所有继承了 ShapeVisitor 的类——AreaCalculatorRendererSerializer——如果没实现这个新方法,编译直接报错。漏不掉。

但这枚硬币的另一面:每加一个新类型,所有已有的 Visitor 都要改。如果你的系统有 30 个 Visitor 实现、频繁新增节点类型(比如一个不断演化的 AST),每次加一个节点就要改 30 个文件。这个维护成本会把团队逼疯。

编译器社区对此有个术语——Expression Problem:如果你的扩展轴是“新增操作”,Visitor 是完美方案;如果扩展轴是“新增类型”,Visitor 就是反模式。选之前先想清楚你的系统沿哪个轴演化。

方案三:std::variant + std::visit——值语义下的编译期穷举

C++17 引入的 std::variant 从根本上换了一种思路:不用继承,不用指针,把“类型集合”直接声明在类型系统里。

#include <variant>
#include <cmath>

struct Circle { double radius; };
struct Square { double side; };

using Shape = std::variant<Circle, Square>;

double area(const Shape& s) {
    return std::visit([](const auto& shape) -> double {
        using T = std::decay_t<decltype(shape)>;
        if constexpr (std::is_same_v<T, Circle>)
            return 3.14159 * shape.radius * shape.radius;
        else
            return shape.side * shape.side;
    }, s);
}

更常见的写法是用 overloaded lambda 技巧(C++17 聚合 CTAD):

// 经典的 overloaded 辅助
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };

double area(const Shape& s) {
    return std::visit(overloaded{
        [](const Circle& c) { return 3.14159 * c.radius * c.radius; },
        [](const Square& s) { return s.side * s.side; }
    }, s);
}

这段代码的关键保证:如果你新增一个 TriangleShape 的类型列表里,但 std::visit 的 visitor 没有处理它,编译直接失败。这个穷举检查发生在编译期,和 Visitor 模式一样强。

std::visit 的编译产物

std::visit 在编译期为每个 variant alternative 生成一个包装函数,存在一张静态函数指针表里。运行时读取 variant 当前的 index,从表里取对应的函数指针,做一次间接调用。

libstdc++ 的实现(__detail::__gen_vtable)对少量 alternative(大约 4 个以内)优化为一条 switch 语句,编译器可以进一步优化为跳转表甚至内联。但当 alternative 数量多时,间接调用会阻止内联——在热路径上,手写 if constexpr 链或 std::get_if 的 if-else 可能更快。

如果你同时对多个 variant 调用 std::visit,函数指针表的大小是 N^k(N 个 alternative、k 个 variant),膨胀速度惊人。3 个 variant 各 5 个类型 = 125 个函数指针。编译时间和二进制大小都要付账。

内存布局的代价

variant 是值类型,它的大小等于最大 alternative 的大小加上 index 的空间:

sizeof(std::variant<Circle, Square>)
    = max(sizeof(Circle), sizeof(Square)) + alignof 对齐填充 + index
    // Circle: 8 字节 (double), Square: 8 字节 (double)
    // index 通常 1–4 字节
    // 实测(GCC 13, x86-64):sizeof = 16

对比继承体系,一个 Base* 只有 8 字节,对象在堆上分配。variant 把对象内联存储在栈上(或容器的连续内存里),对缓存友好。但如果你的类型大小差异很大(一个 8 字节、一个 256 字节),每个 variant 实例都按最大的那个来分配,内存会浪费。

variant 还有一个 std::monostate 可以表示“空状态”(类似 nullptr),以及 std::holds_alternative 做类型检查、std::get_if 做安全取值。异常安全方面,如果赋值时新类型的构造抛了异常,variant 会进入 valueless_by_exception 状态——这是它和手写 tagged union 相比唯一的“意外”。

选哪个:三条带边界的推荐

四种方案的核心权衡一张表说清:

维度 dynamic_cast enum tag + classof Visitor 双重派发 std::variant + visit
运行时开销 15–200ns (DFS) 1–2ns (load+cmp) 6–10ns (2×虚调用) 3–8ns (间接调用/switch)
安全保证 运行时检查,失败返回 nullptr classof 正确性靠人 编译期穷举(纯虚函数) 编译期穷举(模板实例化)
新增类型的代价 零(任意扩展) 改 enum,改各处 switch 改 Visitor 接口 + 所有实现 改 variant 定义 + 所有 visit
新增操作的代价 改所有 if-else 分发 改所有 switch 分发 加一个 Visitor 实现 加一个 visit 调用
需要 RTTI
对象语义 引用/指针(堆分配) 引用/指针(堆分配) 引用/指针(堆分配) 值(内联存储,缓存友好)
工业级用户 少数遗留代码 LLVM, Chromium, protobuf 编译器前端 AST 函数式风格库, 配置系统

三条选型建议:

类型集合封闭 + 值语义 → std::variant。你的类型数量有限(<10 个)、不需要通过基类指针做多态、对象可以拷贝/移动。这是现代 C++ 最推荐的路线。典型场景:配置项的值类型(string / int / double / bool)、解释器的 token 类型、JSON 节点。

类型集合封闭 + 引用语义 + 操作频繁变化 → Visitor。你有一棵对象树(AST、DOM、IR),类型集合基本稳定,但需要不断增加对这棵树的操作(打印、优化、序列化、代码生成)。每加一个操作只需一个新 Visitor 类,不动原有代码。编译器前端(Clang 的 RecursiveASTVisitor)就是典型。

类型集合开放 + 性能敏感 + 愿意承担手工维护 → enum tag + classof。你的继承层次可能跨模块扩展,不方便把所有类型塞进一个 variant;同时对类型判定的延迟有严格要求(比如每秒千万次的 IR 节点类型检查)。LLVM、Chromium 都走了这条路。代价是安全性靠人和 -Wswitch 兜底。

总结

在任何需要向下转换的场景里,先问自己两个问题:

第一问:类型集合是封闭的还是开放的? 封闭(编译时能列完所有类型)→ variant 或 Visitor 都能给你编译期穷举保证。开放(运行时可能出现新类型、跨 DLL 扩展)→ 只有 enum tag 或 dynamic_cast 能撑住。

第二问:扩展轴是类型还是操作? 如果你更常加新操作(遍历、序列化、优化 pass)→ Visitor。如果你更常加新类型(新的 AST 节点、新的消息类型)→ variant 或 enum tag。两个轴都在变→ 这是 Expression Problem,没有完美解;variant + overloaded 通常是务实的折中。

至于 dynamic_cast——如果你在写一个类型集合开放、对性能不敏感、不想维护任何手工机制的小项目,它仍然是合法选择。标准定义了它,标准库实现了它,它的安全性有运行时兜底。只是当你的项目长到 LLVM 那个体量时,你会理解为什么他们选择了另一条路。

声明:本文是经过严格查阅相关权威文献和资料,形成的专业的可靠的内容。全文数据都有据可依,可回溯。特别申明:数据和资料已获得授权。本文内容,不涉及任何偏颇观点,用中立态度客观事实描述事情本身。

MODERN C++ 官方标识图




上一篇:2026江苏招生计划PDF发布: AI驱动分数线查询工具开发实录
下一篇:Android IMU 调试全链路:从 Kernel IIO 到 SensorService 实战总结
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-6-25 03:40 , Processed in 0.600791 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表