yuqi-zheng

使用 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-develllvm-devel 的 Fedora 上:

g++ -std=c++17 -Wall genostream.cpp -o genostream -lclang-cpp -lLLVM

对需要跨 LLVM 版本移植的项目,使用 CMake 并显式链接 clangToolingclangASTMatchers


运行

从你的 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 更合适。


参考