使用 LibClang 为 C++ 枚举和结构体自动生成 operator
C++ 缺乏静态反射。每当你想按名字打印一个枚举值,或把结构体的字段输出到日志流时,就得为每种类型手写 operator<<。在一个有几百种类型的代码库里,这是机械劳动,并在字段被改名或重排时不断积累维护债。
本文展示如何用基于 LibClang(LLVM 的 C API,用于访问 Clang 编译器)及其 C++ AST 匹配器 DSL 的一个小型源到源工具来把这项工作自动化。
具体的问题
给定:
enum class foo { a, b };
struct bar {
foo x;
int y;
};
期望的输出是:
std::ostream &operator<<(std::ostream &os, foo v) {
switch (v) {
case foo::a: os << "a"; break;
case foo::b: os << "b"; break;
}
return os;
}
std::ostream &operator<<(std::ostream &os, const bar &v) {
os << "bar(";
os << "x=" << v.x;
os << ", y=" << v.y;
os << ")";
return os;
}
工具要解析头文件、遍历 AST、输出这段文本。以后给 bar 加一个新字段,只需重新运行生成器。
工具结构
工具使用 LibTooling(构建在 LibClang 之上的 C++ 层)和 ASTMatchers 库。需要三个部分:匹配器识别感兴趣的 AST 节点、回调在匹配触发时输出代码、一个 main 把它们串起来。
第 1 步:定义 AST 匹配器
auto EnumMatcher =
enumDecl(isExpansionInMainFile()).bind("enum");
auto RecordMatcher =
recordDecl(isExpansionInMainFile(), unless(isImplicit())).bind("record");
isExpansionInMainFile() 把匹配限制在命令行显式传入的那个文件中,忽略被包含的库头。unless(isImplicit()) 过滤掉编译器合成的 record,比如 lambda 闭包类型。
第 2 步:实现匹配回调
class Printer : public MatchFinder::MatchCallback {
public:
void run(const MatchFinder::MatchResult &Result) override {
if (const auto *Enum = Result.Nodes.getNodeAs<EnumDecl>("enum")) {
llvm::outs() << "std::ostream &operator<<(std::ostream &os, "
<< Enum->getName() << " v) {\n"
<< " switch (v) {\n";
for (const auto *EC : Enum->enumerators()) {
llvm::outs() << " case " << EC->getQualifiedNameAsString()
<< ": os << \"" << EC->getName() << "\"; break;\n";
}
llvm::outs() << " }\n return os;\n}\n\n";
}
if (const auto *Record = Result.Nodes.getNodeAs<RecordDecl>("record")) {
llvm::outs() << "std::ostream &operator<<(std::ostream &os, const "
<< Record->getName() << " &v) {\n"
<< " os << \"" << Record->getName() << "(\";\n";
bool first = true;
for (const auto *Field : Record->fields()) {
if (!first) llvm::outs() << " os << \", \";\n";
llvm::outs() << " os << \"" << Field->getName()
<< "=\" << v." << Field->getName() << ";\n";
first = false;
}
llvm::outs() << " os << \")\";\n return os;\n}\n\n";
}
}
};
回调收到一个 MatchResult,里面包含”按绑定名字可取出的匹配节点”。EnumDecl::enumerators() 按声明顺序遍历常量;RecordDecl::fields() 遍历非静态数据成员。
这个实现已简化。生产代码应处理命名空间(对 record 调用 getQualifiedNameAsString())、私有字段、模板特化和前置声明。
第 3 步:main 函数
static llvm::cl::OptionCategory GenOstreamCategory("genostream options");
int main(int argc, const char **argv) {
auto OptionsParser =
clang::tooling::CommonOptionsParser::create(argc, argv, GenOstreamCategory);
if (!OptionsParser) {
llvm::errs() << toString(OptionsParser.takeError()) << "\n";
return 1;
}
clang::tooling::ClangTool Tool(
OptionsParser->getCompilations(),
OptionsParser->getSourcePathList());
Printer Callback;
clang::ast_matchers::MatchFinder Finder;
Finder.addMatcher(EnumMatcher, &Callback);
Finder.addMatcher(RecordMatcher, &Callback);
return Tool.run(clang::tooling::newFrontendActionFactory(&Finder).get());
}
CommonOptionsParser 解析标准的 LibTooling 参数,包括指定编译数据库的 -p。
构建工具
在安装了 clang-devel 和 llvm-devel 的 Fedora 上:
g++ -std=c++17 -Wall genostream.cpp -o genostream -lclang-cpp -lLLVM
对需要跨 LLVM 版本移植的项目,使用 CMake 并显式链接 clangTooling 和 clangASTMatchers。
运行
从你的 CMake 构建生成 compile_commands.json:
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -B build
然后运行工具:
./genostream -p build/ src/types.h
生成的代码写到 stdout。重定向到 .cpp 文件,并作为生成源文件加入构建:
./genostream -p build/ src/types.h > src/types_operators.cpp
如果工具找不到 stddef.h 等系统头文件,把它指向 Clang 的资源目录:
./genostream -p build/ src/types.h \
--extra-arg="-resource-dir /usr/lib64/clang/10.0.1/"
不止于 operator<<
同样的模式适用于任何”结构由类型字段或枚举值规律决定”的操作符或函数:
- JSON 序列化(nlohmann/json 的
to_json/from_json) - 二进制序列化(Cereal、Boost.Serialization)
- 相等和比较运算符(
operator==、operator<=>) - 哈希特化(
std::hash<T>) - Protocol Buffers 或 FlatBuffers 适配器
用 libclang 的 Python 绑定写等价工具更容易,但无法表达 AST 匹配器谓词的全部能力。对于任何非平凡的匹配逻辑,C++ API 更合适。
参考
- LLVM LibTooling 文档:https://clang.llvm.org/docs/LibTooling.html
- AST Matchers 参考:https://clang.llvm.org/docs/LibASTMatchersReference.html
- Eli Bendersky,“Parsing C++ in Python with Clang”:https://eli.thegreenplace.net/2011/07/03/parsing-c-in-python-with-clang