这次我们来看一个比较硬核的方向:在 C++ 里做编译期借用检查,让代码像 Rust 一样在编译阶段拦截内存安全问题,并且用 C++26 的反射能力来降低实现成本。这个主题来自 CppNow 会议的一个技术议题,核心思路不是把 C++ 改成另一种语言,而是借助编译期分析、模板元编程和反射,把指针、引用的生命周期以及可变别名这些容易出错的问题提前暴露在 build 阶段。
很多人听到“借用检查器”第一反应是 Rust 专属。确实,Rust 的所有权与借用体系是语言级设计,编译器对每一个借用都有完整追踪。但 C++ 社区并没有放弃,一直有实验项目尝试用静态分析、Clang 插件、模板约束甚至未来的反射能力,为 C++ 提供类似的安全网。这类工具的意义在于:存量 C++ 代码不可能一夜间重写成 Rust,如果能编译期自动抓出悬垂引用、迭代器失效和数据竞争,对底层库、游戏引擎、嵌入式以及安全关键系统都是巨大帮助。
这篇文章不是介绍一个可直接下载的一键包,而是把 C++ 编译期借用检查器背后的技术路线拆开:先对比 Rust 的做法,再讲 C++26 反射对这类工具的影响,然后给出一套可落地的实现思路、验证用例、构建集成和排错清单。如果你正在用 C++ 维护基础组件、引擎或者安全要求比较高的服务,可以先收藏这份材料,后面写代码时对照着做自查。
1. C++ 编译期借用检查器核心能力速览
在展开细节之前,先给出一份能力速览表。这个表描述的是“理想的编译期借用检查器”应该具备的能力,实际项目会根据实现路线有所取舍。
| 能力项 | 说明 |
|---|---|
| 项目类型 | 编译期静态分析工具 / 实验性 C++ 库 |
| 主要功能 | 所有权与借用规则检查、悬垂引用检测、可变别名检测、生命周期约束校验 |
| 核心思想 | 借鉴 Rust 所有权/借用模型,用 C++ 模板、Clang 静态分析、C++26 反射实现 |
| 编译要求 | 需要支持 C++20/23 或 C++26 实验特性的编译器;Clang/LLVM 更便于做深度分析 |
| 运行模式 | 构建期集成;可作 Clang 工具链插件,也可通过 CMake 自定义 target 运行 |
| 显存/GPU | 与显存无关;编译期工具主要消耗 CPU 和内存 |
| API 能力 | 不是 Web 服务;可提供命令行工具、编译器插件或构建系统集成接口 |
| 批量任务 | 适合对整仓代码批量静态检查,可接入 CI/CD |
| 适合场景 | 安全关键系统、底层库、游戏引擎、嵌入式软件,以及对内存安全要求较高的存量 C++ 项目 |
需要说明的是,目前 C++ 社区还没有一个类似 Rust 那样“统一语言级”的借用检查器。上面这些能力是多个实验方向的目标集合。如果你只是想要一个立刻能跑的检查工具,可以先看 clang-tidy 里的生命周期相关规则;如果你对原理和实现路线感兴趣,就继续往下读。
2. 适用场景与使用边界
编译期借用检查器最适合三类团队:第一类是正在维护老 C++ 项目,短期无法迁移到 Rust,但又想降低内存安全问题比例的团队;第二类是底层库、游戏引擎、嵌入式软件这类对运行性能敏感、不能接受额外运行时开销的团队;第三类是安全关键系统开发团队,希望在代码评审之外再加一道自动化的编译期约束。
它能解决的主要问题是“把一部分运行时才会暴露的问题提前到编译期”。比如悬垂引用、迭代器失效、指针别名等,传统做法是等 ASan、TSan 在测试环境抓到崩溃,或者在线上偶发故障后排查。借用检查器如果能在编译阶段把这类代码标记出来,修复成本会明显降低。
但它不是万能的。第一,它不适合替代 ASan、TSan、Valgrind 这类运行时工具,因为静态分析无法覆盖所有运行时状态和外部输入;第二,它不适合对第三方库做全量检查,除非你能拿到源码并且有权修改;第三,如果你需要依赖 C++26 反射,那么编译器版本和标准库支持度会是一个现实限制。实际使用时要结合运行时工具和编译期检查,形成多层防线。
还有一个必须强调的边界:任何静态分析工具都只能用于你自己有权修改、有权审查的代码库。如果把公司内部源码或第三方闭源代码丢进实验性工具做分析,要注意代码保密和许可证合规问题。特别是涉及商业产品、开源协议混合、以及 Jenkins 或 CI 服务器上的私有代码仓库时,应该先和法务或技术负责人确认授权范围。
3. 为什么需要在 C++ 里实现借用检查器
先看一个典型的 C++ 内存安全问题:vector 扩容导致引用失效。
#include <iostream> #include <vector> int main() { std::vector<int> v = {1, 2, 3}; int& ref = v[0]; v.push_back(4); // 可能触发 vector 扩容 std::cout << ref << std::endl; // 未定义行为 }这段代码在 Release 模式下可能直接崩溃,也可能输出错误结果,因为ref指向的对象在push_back之后可能已经被移动到了新的内存块。C++ 编译器默认不会拒绝这种写法,只有上了 ASan 之后才可能在运行时抓出 use-after-realloc。
如果用 Rust 写类似逻辑,情况完全不同。
fn main() { let mut v = vec![1, 2, 3]; let r = &v[0]; v.push(4); println!("{}", r); // 编译错误:不能同时存在不可变借用和可变借用 }Rust 的借用检查器在编译期就发现v.push(4)需要可变借用&mut v,而r仍然持有不可变借用&v,两者不能共存,于是直接拒绝编译。这就是“借用检查器”的核心价值:把内存安全问题从运行时错误变成编译错误。
C++ 实现借用检查器的难点在于:没有语言级的所有权转移规则,也没有自动的生命周期标注机制。编译器只能看到类型和声明,无法自动理解“谁拥有这块内存”“这个引用什么时候失效”。但我们可以借用 Rust 的思路,在 C++ 的编译期分析层做约束:一旦发现某个引用被传递出去,而原对象随后被修改或销毁,就产生诊断信息。
这种检查器的意义不只是抓 bug,还在于规范化团队的编码习惯。很多 C++ 项目的内存安全靠的是“代码评审时人工看”,如果能让机器在 CI 阶段强制检查,效率和标准都会提升。
4. C++26 反射会给借用检查带来什么
C++ 的反射能力一直被社区期待,因为它能解决元编程里“遍历类型成员”“判断成员类型”“自动生成代码”的问题。C++26 的反射提案如果落地,会让编译期拿到更多类型信息,比如某个类的成员变量列表、每个成员的类型、访问权限,甚至是成员函数签名。这对借用检查器来说意义很大。
举个例子,传统 C++ 模板只能根据类型特征做判断,没办法方便地遍历一个结构体的所有成员去检查有没有裸指针或引用。但有了反射能力,可以写出类似“遍历这个类的所有成员,看看是否有成员引用了外部对象”的编译期逻辑,进而自动生成生命周期检查代码。这样,借用检查器就不需要完全依赖 Clang 插件去解析 AST,而是可以在标准 C++ 的范围里做一部分自我检查。
不过要泼一盆冷水:反射不是银弹。它只是给编译期元编程提供了更丰富的输入,最终静态分析引擎如何识别借用关系、如何处理函数调用图、如何判断分支条件,仍然需要一套分析框架。反射更适合用来驱动规则生成和约束表达,而不是替代整个静态分析引擎。
从 C++26 的角度看,比较现实的路径是:用反射帮助处理“类型成员层面”的检查,例如自动识别包含裸指针或引用的结构体,再把这些信息交给 Clang 插件做更深层的跨函数分析。未来如果标准反射能力普及,很多原本需要大量模板黑魔法的代码可以变得直观。
5. 环境准备与前置条件
由于这不是一键启动的服务,环境准备实际上是“准备一个适合开发静态分析工具的工具链”。我用下面这张表总结一套通用检查清单。
| 检查项 | 推荐配置 | 说明 |
|---|---|---|
| 操作系统 | Linux / macOS / Windows | Clang 工具链跨平台支持较好 |
| 编译器 | Clang 15+ / GCC 12+ | Clang 更适合做 LibTooling 插件开发 |
| 语言标准 | C++20 / C++23 / C++26 实验特性 | 反射能力需要较新的编译器版本 |
| 构建工具 | CMake 3.20+ | 集成自定义 target 更方便 |
| 静态分析框架 | Clang LibTooling / clang-tidy / LLVM | 深度分析用户代码的关键 |
| 内存与磁盘 | 按项目规模准备 | 大规模静态分析会显著增加内存占用 |
具体到操作系统命令,在 Ubuntu 上可以安装 Clang 和 CMake:
sudo apt update sudo apt install clang llvm cmake ninja-buildmacOS 用户如果安装了 Homebrew:
brew install llvm cmake ninjaWindows 用户则可以通过 Visual Studio Installer 安装“用于 Windows 的 LLVM”工具链,或者使用 vcpkg 安装 LLVM。需要注意,不同版本的 LLVM API 差异较大,做 Clang 插件开发时一定要锁版本,避免编译错误。
如果你是第一次接触 Clang LibTooling,建议先跑通官方示例,再去看具体的 AST 节点类型。不要直接在一个大型代码库上验证,否则你会被海量编译错误和误报淹没。
6. 实现路线:C++ 编译期借用检查器的三种玩法
6.1 路线一:模板库 + 编译期断言
最轻量的路线是用模板包装指针和引用,在编译期用static_assert、concept或类型特征做检查。比如设计一个Borrow<T>类型,只在构造时允许从Owner<T>借出,并且在借出期间禁用原对象的可变操作。
template <typename T> class Owner { public: explicit Owner(T* p) : ptr_(p) {} // 这里只是一个示意,真正的检查需要配合借用状态机 T* get() { return ptr_; } const T* get() const { return ptr_; } private: T* ptr_; }; template <typename T> class Borrow { public: explicit Borrow(Owner<T>& owner) : ptr_(owner.get()) {} T& operator*() { return *ptr_; } private: T* ptr_; };这种方案的优点是实现成本低,但问题也很明显:它没法理解复杂的函数调用关系,也没法自动分析一个裸指针是否被传递给第三方库。它适合作为“编码规范约束层”,帮助团队在局部模块内规避风险,但距离真正的借用检查器还有很大距离。
6.2 路线二:Clang LibTooling 静态分析插件
更接近“编译器级借用检查”的路线是写 Clang 插件,直接遍历 AST,分析变量的声明、引用、赋值以及函数调用关系。这种方法可以理解用户的真实代码,而不是仅仅依赖类型包装。
下面是一个 LibTooling 工具的最小骨架,这段代码可以做自定义 AST 遍历,但具体分析逻辑需要自己填充。
#include "clang/AST/ASTConsumer.h" #include "clang/AST/ASTContext.h" #include "clang/AST/RecursiveASTVisitor.h" #include "clang/Frontend/CompilerInstance.h" #include "clang/Frontend/FrontendAction.h" #include "clang/Tooling/CommonOptionsParser.h" #include "clang/Tooling/Tooling.h" #include "llvm/Support/CommandLine.h" using namespace clang; using namespace clang::tooling; class BorrowCheckerVisitor : public RecursiveASTVisitor<BorrowCheckerVisitor> { public: explicit BorrowCheckerVisitor(ASTContext* ctx) : ctx_(ctx) {} bool VisitVarDecl(VarDecl* vd) { // 在这里分析引用变量和指针变量的生命周期 // 例如:检查一个引用变量是否在作用域内被无效化 return true; } private: ASTContext* ctx_; }; class BorrowCheckerConsumer : public ASTConsumer { public: explicit BorrowCheckerConsumer(ASTContext* ctx) : visitor_(ctx) {} void HandleTranslationUnit(ASTContext& ctx) override { visitor_.TraverseDecl(ctx.getTranslationUnitDecl()); } private: BorrowCheckerVisitor visitor_; }; class BorrowCheckerAction : public ASTFrontendAction { public: std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance& ci, StringRef) override { return std::make_unique<BorrowCheckerConsumer>(&ci.getASTContext()); } }; int main(int argc, const char** argv) { auto expectedParser = CommonOptionsParser::create(argc, argv, llvm::cl::GeneralCategory); if (!expectedParser) { llvm::errs() << expectedParser.takeError(); return 1; } auto& parser = expectedParser.get(); ClangTool tool(parser.getCompilations(), parser.getSourcePathList()); return tool.run(newFrontendActionFactory<BorrowCheckerAction>().get()); }这段代码的编译方式依赖于具体 LLVM 版本,你需要按照本机安装的 LLVM 配置 CMake 链接参数。它的优点是能拿到完整 AST,可以做跨函数分析,缺点是开发成本和维护成本都不低。
6.3 路线三:C++26 反射辅助生成检查
C++26 反射的设想是:在编译期获取类型成员信息,然后自动生成检查代码。下面这段代码只是概念示意,不能直接编译,因为当前可用的反射 API 形态还在发展。
// 概念性示例,基于 C++26 反射能力,实际 API 取决于标准实现 template <typename T> consteval void checkBorrowSafety() { for (std::meta::info member : std::meta::members_of(^^T)) { // 判断成员是否是裸指针、引用或容器类型 // 没通过检查则触发编译错误 } }反射的价值在于,它能把“手动给每个类写检查”变成“自动对所有类型做检查”。不过这需要编译器前端先完成符号解析,还要和静态分析结果配合。比较现实的工程路径是:先用模板和概念做局部约束,再用 Clang 插件做跨函数分析,最后用反射把检查规则自动化。
6.4 三种路线横向对比
| 实现路线 | 系统性强弱 | 误报率 | 上手成本 | 适用项目 |
|---|---|---|---|---|
| 模板库 + 编译期断言 | 弱 | 低 | 低 | 单模块、新代码 |
| Clang LibTooling 插件 | 强 | 中到高 | 高 | 存量代码、全仓分析 |
| C++26 反射辅助元编程 | 中 | 中 | 中 | 类型规整的新项目 |
实际上,一个完整可靠的编译期借用检查器大概率会混合使用这三种路线:用模板库约束接口层,用 Clang 插件分析函数调用图,用反射自动生成部分成员检查规则。
7. 集成到构建系统:编译期检查的启动方式
借用检查器不是 Web 服务,它的“启动方式”是构建期自动运行。最简单的方式是把 clang-tidy 挂到 CMake 里,让它执行自定义检查规则。
find_program(CLANG_TIDY clang-tidy) set(CMAKE_CXX_CLANG_TIDY ${CLANG_TIDY} --checks=-*,borrow-checker --header-filter=.* )如果你的工具是自定义 LibTooling 可执行文件,可以用add_custom_target把它挂到构建流程里。
add_executable(borrow_checker src/borrow_checker.cpp) add_custom_target(run_borrow_check COMMAND borrow_checker ${CMAKE_SOURCE_DIR}/src -- -std=c++20 -I ${CMAKE_SOURCE_DIR}/include DEPENDS borrow_checker )然后单独构建检查目标:
cmake -S . -B build cmake --build build --target run_borrow_check命令行方式也一样,如果工具编译好了,直接在源码根目录运行:
./build/borrow_checker src/main.cpp -- -std=c++20 -I include/这里需要注意,LibTooling 的命令行参数里,--之后的内容会传给 Clang 编译器驱动,用来解析编译选项。如果工具没有输出,先检查是不是参数位置写错了,或者是不是源码文件本身没有被纳入编译数据库。
如果你不想引入 Clang 插件,也可以先用 clang-tidy 的现有生命周期检查规则做初筛。clang-tidy 里有一些检查已经能识别局部引用失效的问题,只是覆盖范围不如一个真正的借用检查器那么完整。优先用现成规则,再逐步扩展自定义规则,是比较稳妥的路径。
8. 编译期借用检查器功能测试与效果验证
借用检查器的核心判断标准是“该报错的代码能不能在编译期报出来”。下面列三组典型测试。
8.1 悬垂引用测试
准备一段有悬垂引用风险的代码。
// test_dangling.cpp #include <vector> int main() { std::vector<int> v{1, 2, 3}; int& ref = v[0]; v.push_back(4); return ref; }在普通 C++ 编译器下,这段代码大概率能编译通过。如果借用检查器生效,应该给出类似下面的诊断信息:
error: reference 'ref' may dangle after call to 'push_back'这里注意,实际诊断文本取决于工具实现。判断成功的标准是:工具在编译阶段输出错误,并且构建流程被阻断。
8.2 可变别名测试
第二个典型问题是“一个对象同时被可变引用和不可变引用访问”。
// test_alias.cpp #include <vector> int main() { std::vector<int> v{1, 2, 3}; int& a = v[0]; const int& b = v[1]; a = 42; // 修改操作是否影响 b? return a + b; }借用检查器如果做得好,应该尝试判断a和b是否可能指向同一片内存区域。这比单纯检测 vector 扩容要复杂,因为它需要分析别名关系。很多静态分析工具会给出警告,而不是直接报错,这是合理的。
8.3 跨函数借用测试
更接近真实项目的测试是跨函数调用。
// test_cross_function.cpp void mutate(std::vector<int>& v) { v.push_back(10); } int main() { std::vector<int> v{1, 2, 3}; const int& ref = v[0]; mutate(v); return ref; }这里借用检查器需要知道mutate内部会修改v,而ref是对v内部元素的引用。跨函数分析的难度会显著上升,如果能抓住这个问题,说明工具的可复用性已经不错了。
8.4 测试流程建议
建议准备一个小型测试目录,里面分别放正常代码和违规代码,预期结果也写清楚。
| 测试用例 | 输入 | 预期结果 | 判断标准 |
|---|---|---|---|
| 悬垂引用 | vector 扩容后使用引用 | 编译失败 | 有报错,无运行崩溃 |
| 可变别名 | 同时存在可变/不可变引用 | 警告或失败 | 有诊断,未漏报 |
| 跨函数借用 | 子函数修改容器 | 警告或失败 | 有诊断,误报可控 |
| 正常代码 | 生命周期安全 | 编译通过 | 无误报 |
一开始不要追求覆盖所有场景,先保证正常代码不误报,再逐步增加复杂规则。
9. 接口 API 与 CI/CD 批量任务
借用检查器的“API”形态通常是命令行接口,而不是 HTTP 服务。它要能接收一批源码文件、编译参数和检查规则,然后输出诊断结果。下面是一个通用的命令行调用示例。
borrow_checker src/main.cpp src/util.cpp -- -std=c++20 -I include/如果你想把它接进 CI,可以让 CI 构建时运行检查目标。下面是一个 GitHub Actions 工作流片段,作用是先构建检查工具,再对源码目录运行检查。
name: borrow-check on: [push, pull_request] jobs: borrow-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install dependencies run: | sudo apt update sudo apt install clang llvm cmake ninja-build - name: Configure run: cmake -S . -B build -G Ninja - name: Build checker run: cmake --build build --target borrow_checker - name: Run borrow check run: cmake --build build --target run_borrow_check在批量任务层面有两个建议:第一,先把全仓源码文件列表固化,不要每次实时扫描整个文件系统;第二,在 CI 里把诊断结果输出成文件,记录到构建产物中,方便团队回溯。如果某个文件反复误报,可以设置临时白名单,但白名单要有截止时间,避免变成永久豁免。
10. 资源占用与性能观察
编译期借用检查器最明显的成本是编译时间。分析一个翻译单元需要构建 AST、遍历 AST、可能还要做跨函数数据流分析,这比普通编译慢得多。更稳妥的判断是,这类检查适合放在 CI 而不是每次本地增量编译都执行。
观察性能可以从三个角度入手。第一,用-ftime-report查看 Clang 前端各阶段耗时分布;第二,用/usr/bin/time -v 编译命令查看峰值内存;第三,在 CMake 构建时记录整个 target 的耗时,对比检查开关关闭前后的差距。
/usr/bin/time -v ./build/borrow_checker src/ -- -std=c++20 -I include/ 2>&1 | grep -E "Maximum resident|Elapsed"如果你的分析器会遍历大量 AST 节点,内存占用会比较明显。优化手段包括:不做无谓的深层模板实例分析、跳过系统头文件、只分析用户代码目录、利用编译数据库避免重复解析公共头文件。
对于规模较大的项目,更推荐先对改动文件做增量检查,再定期对全仓做全量检查。全量检查可以放在每天凌晨或每周一次的定时任务中。
11. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 检查工具无输出 | 编译数据库路径不对,或没有把源码文件传给工具 | 检查命令行参数和compile_commands.json | 用cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON生成编译数据库 |
| 误报太多 | 规则过于激进,未排除第三方代码 | 查看诊断信息是否集中在头文件或系统头文件 | 调整头文件过滤规则,限制检查范围 |
| 第三方库大量报错 | 第三方库没有适配借用规则 | 检查报错文件路径 | 对第三方目录设置白名单或跳过检查 |
| 反射特性不可用 | 编译器版本过低,或未开启实验特性 | 执行clang --version查看版本 | 升级编译器,或退回到模板/Clang插件方案 |
| 编译时间暴涨 | 全量分析所有文件 | 观察-ftime-report输出 | 改成增量检查,只分析变更文件 |
| 宏导致误报 | 宏展开后无法识别实际类型 | 在诊断日志里查看展开后代码 | 在宏处添加 suppression 注释,或调整规则 |
| 与 ASan/TSan 同时使用后行为不一致 | 静态分析和运行时工具的检测维度不同 | 对比报错场景 | 将两者结合,运行时工具作为编译期检查的补充 |
在维护检查器时,一定要建立一个误报登记表。每一条误报都是宝贵的样本,可以用它反向调整规则。完全杜绝误报不现实,但如果误报率太高,团队会直接放弃这个工具。
12. 最佳实践与使用建议
第一,渐进式启用检查。不要第一天就扫描整个仓库,而是先挑一两个核心模块跑通流程,确认误报率可控后,再逐步扩大范围。第二,把检查器和代码评审流程绑定。CI 阶段跑检查器,诊断结果作为一个自动评审意见,只有误报登记过并确认后,才允许合入代码。第三,对有问题的代码先做白名单处理,但白名单必须附带负责人和过期时间,否则就会变成永久豁免,失去约束力。
第四,把现有 Rust 代码里的经验整理成 C++ 侧规则。比如 Rust 禁止同时持有可变引用和不可变引用,C++ 侧可以写成“不允许在一个作用域内对同一容器同时存在operator[]返回的引用和push_back调用”。这些规则不用一开始做得很复杂,先覆盖高频问题,再扩展边界。
第五,注意合规边界。如果你要在公司代码库上做实验,先用自己的测试工程或开源授权兼容的工程跑通。不要直接把第三方商业源码丢进实验性工具,也不要绕过项目的代码保密要求。静态分析工具会读取完整源码,需要确保使用场景有合法授权。
第六,构建工具链版本要锁死。无论使用 Clang LibTooling 还是 C++26 反射,LLVM API 和标准库接口都在快速变化。建议在 CMake 里固定 LLVM 版本,或者在 CI 中使用容器镜像,避免因为版本升级导致检查器不可用。
第七,把已有的 clang-tidy 规则和 Sanitizer 跑起来,再考虑自定义借用检查器。很多基础问题其实可以用现成工具抓,只把自定义检查器用在 clang-tidy 覆盖不到的场景上。这样可以减少维护成本,也让团队更容易接受新工具。
13. 总结与下一步
C++ 编译期借用检查器最值得尝试的点,是它能把一批“事后崩溃”变成“事前编译错误”。虽然不可能一步到位达到 Rust 的完整效果,但可以先从一个极小的规则集开始,比如只检测 vector 扩容后的引用失效,然后在 Clang LibTooling 基础上逐步扩展。
最容易踩的坑是想一次性覆盖所有场景。借用检查的核心难点不在语法,而在函数调用关系、别名分析和分支条件,一次做全就会陷进误报和性能两座大山。建议先在小模块上用模板约束跑通概念,再切换到 Clang 插件做深度分析,最后等 C++26 反射能力成熟后把规则自动化。
后续可以继续探索的方向包括:跨翻译单元分析、基于反射的类型成员自动扫描、与 clang-tidy 规则生态融合,以及把检查结果接入 CI 的标准化报告格式。这套思路对底层基础设施团队很有价值,建议收藏备用。