Daniel's Blog

我最喜欢的 C++ 模式: X Macros

C++ Chapel Compilers 发布于 2023 年 10 月 14 日。

目录

当我刚加入 Chapel 团队时,其基于 C++ 的编译器中使用的一种模式给我留下了深刻的印象。 从那以后,我多次使用这种模式,并且对结果非常满意。 然而,感觉这种模式相对不为人所知,所以我想展示一下它,以及它在 Chapel compiler 中的一些应用。 为了简化演示,我对本文中直接展示的许多代码片段进行了稍微调整;如果您想查看完整版本,我包含了指向原始代码(可在 GitHub 上获得)的链接。

广义上讲,“X Macros”模式是关于生成代码的。 如果您需要编写_大量_重复代码(声明许多变量或类,执行许多非常相似的操作等),此模式可以节省大量时间,生成更易于维护的代码,并减少添加_更多_代码所需的工作量。

我将以最简单的形式介绍该模式,并以我的第一个例子:interning strings

应用 1: String Interning

Chapel 编译器会 intern 大量的字符串。 这样,它可以减少将标识符保存在内存中的内存占用(每个字符串 "x" 实际上是_同一个_字符串),并使相等比较更快(您可以只执行指针比较!)。 通常,使用 Context 类来管理 interning 状态。 可以使用上下文对象以以下方式构造新的 interned string:

UniqueString::get(ctxPtr, "the string");

实际上,这会搜索当前存在的 unique strings。 如果不存在具有该内容(在本例中为 "the string")的字符串,则会创建该字符串并在 Context 中注册。 否则,将返回现有字符串。 然而,有些字符串在编译器中出现了很多次,以至于每次都执行整个“查找或创建”操作效率很低。 其中一个例子是 "this" 字符串,它是一种标识符,在该语言中具有许多特殊行为(很像 Java 等语言中的 this)。 为了支持这种经常使用的字符串,编译器会初始化它们一次,并为每个字符串创建一个变量,可以访问该变量以获取该字符串的值。

这就是重复代码。 为每个字符串定义一个全新的变量,在编写本文时大约有 100 个字符串,这需要编写大量的样板代码。 此外,至少有两个地方需要添加代码:一次在变量的声明中,一次在初始化变量的代码中。 [note: 在编译器中的第三个用途实际上是一个在字符数组上定义的 variadic template。 该 template 的定义和专用化方式允许您通过其字符串内容引用变量(即,您可以编写 USTR("the string") 而不是 theStringVariable)。 ] 很容易意外地修改前者而不是后者,特别是对于不熟悉这些“常用字符串”如何实现的开发人员而言。

这就是 X Macros 的用武之地。 如果您查看编译器源代码,会发现一个头文件,其内容如下:

来自 all-global-strings.h,第 31 行左右

X(align     , "align")
X(atomic     , "atomic")
X(bool_     , "bool")
X(borrow     , "borrow")
X(borrowed    , "borrowed")
X(by       , "by")
X(bytes     , "bytes")
// 还有很多...

这个 X 是什么? 就在那里,它是该模式的本质:宏 X 未在头文件中定义! 实际上,all-global-strings.h 只是一个列表,我们可以“迭代”该列表,为它的每个元素生成一些代码,在任意多个位置。 我的意思是,我们可以编写如下代码:

来自 global-strings.h,第 76 行左右

  struct GlobalStrings {
#define X(field, str) UniqueString field;
#include "all-global-strings.h"#undef X
  };

在这种情况下,我们定义宏 X 来忽略字符串的值(我们只是在这里声明它),并创建一个新的 UniqueString 变量声明。 由于该声明位于 GlobalStrings 结构体内部,因此最终会创建一个字段。 就像这样,我们声明了一个包含 100 多个字段的类。 初始化同样简单:

来自 Context.cpp,第 49 行左右

  GlobalStrings globalStrings;
  Context rootContext;
  static void initGlobalStrings() {
#define X(field, str) globalStrings.field = UniqueString::get(&rootContext, str);
#include "chpl/framework/all-global-strings.h"#undef X
  }

有了这个,我们就完全自动化了声明和初始化所有 100 个 unique strings 的代码。 添加新字符串不需要开发人员知道实现此操作的所有位置:只需使用对 X 的新调用修改 all-global-strings.h 头文件,他们就可以添加一个新变量和用于初始化它的代码。 非常健壮!

应用 2: AST 类层级

虽然 interned strings 是一个极好的第一个例子,但它并不是我在 Chapel 编译器中遇到的 X Macros 的第一个用法。 除了字符串之外,编译器还使用 X Macros 来表示它使用的 abstract syntax tree (AST) 节点的整个类层级。 在这里,代码实际上更复杂一些;类层级不像字符串那样是_列表_;它本身就是一个树。 为了表示这样的结构,我们需要不止一个 X 宏;编译器选择了 AST_NODEAST_BEGIN_SUBCLASSESAST_END_SUBCLASSES。 如下所示:

来自 uast-classes-list.h,第 96 行左右

 // 上面还有其他 AST 节点...
 AST_BEGIN_SUBCLASSES(Loop)
   AST_NODE(DoWhile)
   AST_NODE(While)
  AST_BEGIN_SUBCLASSES(IndexableLoop)
   AST_NODE(BracketLoop)
   AST_NODE(Coforall)
   AST_NODE(For)
   AST_NODE(Forall)
   AST_NODE(Foreach)
  AST_END_SUBCLASSES(IndexableLoop)
 AST_END_SUBCLASSES(Loop)
 // 下面还有其他 AST 节点...

在此头文件(称为 uast-classes-list.h)中定义的类层级用于很多事情,无论是在编译器本身中还是在使用编译器的某些库中。 我将依次介绍用例。

Tags and Dynamic Casting

首先,为了处理 RTTI 的普遍缺失,层级头文件用于声明“tag”枚举。 每个 AST 节点都有一个与其类匹配的 tag;这允许我们检查 AST 并执行类似于 dynamic_cast 的安全强制转换。 请注意,对于父类(通过 BEGIN_SUBCLASSES 定义),我们实际上最终会创建_两个_ tag:一个 START_... 和一个 END_...。 这样做的原因稍后会清楚。

来自 AstTag.h,第 36 行左右

enum AstTag {
#define AST_NODE(NAME) NAME ,
#define AST_BEGIN_SUBCLASSES(NAME) START_##NAME ,
#define AST_END_SUBCLASSES(NAME) END_##NAME ,
#include "chpl/uast/uast-classes-list.h"#undef AST_NODE
#undef AST_BEGIN_SUBCLASSES
#undef AST_END_SUBCLASSES
 NUM_AST_TAGS,
 AST_TAG_UNKNOWN
};

上面的代码段使 AstTag 包含诸如 DoWhileWhileSTART_LoopEND_Loop 之类的元素。 为方便起见,我们还添加了其他几个元素:NUM_AST_TAGS,它会自动分配给我们生成的 tag 的数量,[note: 这是因为 C++ 依次将整数值分配给枚举元素,从零开始。 ] 以及一个通用的“未知 tag”值。

通过这种方式生成枚举元素后,我们可以编写查询函数。 这样,API 使用者可以编写 isLoop(tag) 而不是手动执行比较。 此处的代码生成实际上分为两种不同的“is bla”方法形式:用于具体 AST 节点(DoWhileWhile)的方法和用于抽象基类(Loop)的方法。 这样做的原因很简单:只有 AstTag::DoWhile 表示 do-while 循环,但 DoWhileWhile 都是 Loop 的实例。 因此,isLoop 应该对两者都返回 true。

这就是 START_...END_... 枚举元素发挥作用的地方。 从上到下读取头文件,我们首先生成 START_Loop,然后生成 DoWhileWhile,然后生成 END_Loop。 由于 C++ 依次将整数值分配给枚举,因此要检查 tag 是否“扩展”了基类,只需检查其值是否大于 START 标记,且小于 END 标记 – 这意味着它是在匹配的 BEGIN_SUBCLASSESEND_SUBCLASES 对中声明的。

来自 AstTag.h,第 59 行左右

// 为叶节点和常规节点定义 is___
// (尚未为抽象父类定义)
#define AST_NODE(NAME) \
 static inline bool is##NAME(AstTag tag) { \
  return tag == NAME; \
 }
#define AST_BEGIN_SUBCLASSES(NAME)
#define AST_END_SUBCLASSES(NAME)
// 将上面的宏应用于 uast-classes-list.h
#include "chpl/uast/uast-classes-list.h"// 清除宏
#undef AST_NODE
#undef AST_BEGIN_SUBCLASSES
#undef AST_END_SUBCLASSES
// 为抽象父类定义 is___
#define AST_NODE(NAME)
#define AST_BEGIN_SUBCLASSES(NAME) \
 static inline bool is##NAME(AstTag tag) { \
  return START_##NAME < tag && tag < END_##NAME; \
 }
#define AST_END_SUBCLASSES(NAME)
// 将上面的宏应用于 uast-classes-list.h
#include "chpl/uast/uast-classes-list.h"// 清除宏
#undef AST_NODE
#undef AST_BEGIN_SUBCLASSES
#undef AST_END_SUBCLASSES

这些帮助程序非常方便。 以下是我们最终得到的一些示例:

isFor(AstTag::For)       // 返回 true;'for' 循环确实是 'for' 循环。
isIndexableLoop(AstTag::For)  // 返回 true;'for' 循环是“可索引的”('for i in ...')
isLoop(AstTag::For)      // 返回 true;'for' 循环是一个循环。
isFor(AstTag::While)      // 返回 false;'while' 循环不是 'for' 循环。
isIndexableLoop(AstTag::While) // 返回 false;'while' 循环使用布尔条件,而不是索引
isLoop(AstTag::While)     // 返回 true;'while' 循环是一个循环。

在顶级 AST 节点类上,我们为每个 AST 子类生成 isWhateverNodetoWhateverNode。 因此,用户代码能够使用普通方法检查 AST 并执行(已检查的)强制转换。 为了简洁起见,我省略了此处的 isWhateverNode(其定义非常简单),并且仅包含 toWhateverNode

来自 AstNode.h,第 313 行左右

 #define AST_TO(NAME) \
  const NAME * to##NAME() const { \
   return this->is##NAME() ? (const NAME *)this : nullptr; \
  } \
  NAME * to##NAME() { \
   return this->is##NAME() ? (NAME *)this : nullptr; \
  }
 #define AST_NODE(NAME) AST_TO(NAME)
 #define AST_LEAF(NAME) AST_TO(NAME)
 #define AST_BEGIN_SUBCLASSES(NAME) AST_TO(NAME)
 #define AST_END_SUBCLASSES(NAME)
 // 将上面的宏应用于 uast-classes-list.h
 #include "chpl/uast/uast-classes-list.h" // 清除宏
 #undef AST_NODE
 #undef AST_LEAF
 #undef AST_BEGIN_SUBCLASSES
 #undef AST_END_SUBCLASSES
 #undef AST_TO

这些方法在编译器中被大量使用。 例如,这是我完全随机提取的一段代码:

来自 Resolver.cpp,第 1161 行左右

 if (auto var = decl->toVarLikeDecl()) {
  // 根据以下内容确定变量类型:
  // * 变量声明中的类型
  // * 变量声明中的初始化表达式
  // * 来自 split-init 的初始化表达式
  auto typeExpr = var->typeExpression();
  auto initExpr = var->initExpression();
  if (auto var = decl->toVariable())
   if (var->isField())
    isField = true;

因此,添加新 AST 节点的开发人员无需手动实现 isWhatevertoWhatever 和其他函数。 此功能和相当多的其他 AST 功能(我将在下一小节中介绍)是使用 X Macros 自动生成的。

您实际上并没有展示如何声明 AST 节点类,而只展示了 tag。 似乎不太可能使用相同的策略生成它们 – 每个 AST 节点不是都有自己不同的方法和实现代码吗? 您是对的。 AST 节点类是“照常”定义的,它们的构造函数必须显式地将其 tag 字段设置为相应的 AstTag 值。 定义新类的开发人员还必须扩展他们承诺在 uast-classes-list.h 中扩展的节点。 这似乎是出现 bug 的机会。 没有什么可以阻止开发人员返回错误的 tag,这会破坏自动强制转换行为。 是的,它不是万无一失的。 不久前,一个团队成员发现 一个 bug,其中一个节点被列为继承自 AstNode,但实际上继承自 NamedDecl。 即使它继承自该类,toNamedDecl 方法也不会在该节点上起作用。 尽管如此,此模式为 Chapel 编译器提供了很多价值; 我将在下一小节中展示更多用例,就像承诺的那样。

The Visitor Pattern without Double Dispatch

访问者模式通常非常重要,但对于我们编译器开发人员来说,它无处不在。 它有助于避免在 AST 节点类中膨胀执行各种操作所需的的方法和状态。 它通常还可以使我们免于编写 AST 遍历代码。

从本质上讲,与其将每个新操作(例如,转换为字符串、计算类型、分配 ID)作为每个 AST 节点类上的方法添加,不如我们将此代码提取到每个操作的_访问者_中。 此访问者是一个类,它具有在 AST 节点上实现自定义行为的方法。 visit(WhileLoop*) 方法可以用于对“while”循环执行操作,而 visit(ForLoop*) 可以对“for”循环执行相同的操作。 AST 节点本身只有 traverse 方法,该方法接受访问者(无论它是什么)并调用相应的 visit 方法。 这样,AST 节点实现保持简单且相对稳定。

作为一个非常简单的例子,假设您想计算程序中使用的循环数量(出于未指明的原因)。 您可以添加一个 countLoops 方法,但是您已经为 AST 节点 API 引入了一个方法,用于可能是一次性的、可丢弃的操作。 使用访问者模式,您不需要这样做; 您只需创建一个新类:

struct MyVisitor {
  int count = 0;
  void visit(const Loop*) { count += 1; }
  void visit(const AstNode*) { /* 对其他节点不执行任何操作 */ }
}
int countLoops(const AstNode* root) {
  MyVisitor visitor;
  root->traverse(visitor);
  return visitor.count;
}

traverse 方法是一个很好的 API,不是吗? 在不修改语法树的情况下,可以很容易地添加对语法树进行操作的操作。 但是,仍然有一个重要的开放问题:traverse 如何知道调用正确的 visit 函数?

如果 traverse 仅在 AstNode* 上定义,并且它只是调用 visit(this),那么我们始终最终调用 visit 函数的 AstNode 版本。 这是因为 C++ 不会根据方法参数的类型进行动态分派。 [note: 显然,C++ 能够根据_接收者_的运行时类型选择正确的方法:这只是 virtual 函数和 vtable。 ] 从静态的角度来看,调用显然接受 AstNode,没有更具体的类型。 因此,编译器选择该版本的 visit 方法。

在像 C++ 或 Java 这样的语言中解决此问题的“传统”方法称为_双重分派_。 使用我们的示例作为参考,这涉及使_每个_ AST 节点类都有自己的 traverse 方法。 这样,对 visit(this) 的调用具有更具体的类型信息,并且会被解析为适当的重载。 但这需要更多的样板代码:每个新的 AST 节点都需要有一个 virtual traverse 方法,该方法如下所示:

void MyNode::traverse(Visitor& v) {
 v.visit(this);
}

它还需要所有访问者都从 Visitor 扩展。 所以现在你有:

因此,有相当多的繁琐样板代码,并且在添加 AST 节点时需要手动修改更多代码:您必须去用新的 visit 存根调整 Visitor 类。

所有这些都是必要的,因为每个人(包括我自己)通常都认为像以下这样的代码通常是一个坏主意:

struct AstNode {
 void traverse(Visitor& visitor) {
  if (auto forLoop = toForLoop()) {
   visitor.visit(forLoop);
  } else if (auto whileLoop = toWhileLoop()) {
   visitor.visit(whileLoop);
  } else {
   // 还有 100 行像这样...
  }
 }
}

毕竟,当您添加新的 AST 节点时会发生什么? 您仍然需要修改此列表,并且由于一切仍然扩展 Visitor,因此您仍然需要在此处添加新的 visit 存根。 但是如果没有基类怎么办? 相反,如果 traverse 是一个 template 怎么办?

struct AstNode {
 template <typename VisitorType>
 void traverse(VisitorType& visitor) {
  if (auto forLoop = toForLoop()) {
   visitor.visit(forLoop);
  } else if (auto whileLoop = toWhileLoop()) {
   visitor.visit(whileLoop);
  } else {
   // 还有 100 行像这样...
  }
 }
}

请注意,如果在 C++ 中 visit 是 virtual 方法,则无法编写此代码; 您听说过 virtual template 吗? 对于这样的代码,VisitorType 不需要定义_每个_重载,只要它有 AstNode 的版本即可。 此外,如果不存在 DoWhile 的更具体的版本,C++ 的常规重载解析规则将负责调用 Loop 重载。

剩下的唯一问题是有 100 行的 if-else(可能是 switch,几乎没有美学上的好处)。 但这正是 X Macro 模式再次发挥作用的地方! 我们已经有所有 AST 节点类的列表,并且用于调用它们的代码几乎相同。 因此,Chapel 编译器有一个 doDispatch 函数(由 traverse 使用),如下所示:

来自 AstNode.h,第 377 行左右

  static void doDispatch(const AstNode* ast, Visitor& v) {
   switch (ast->tag()) {
    #define CONVERT(NAME) \
     case chpl::uast::asttags::NAME: \
     { \
      v.visit((const chpl::uast::NAME*) ast); \
      return; \
     }
    #define IGNORE(NAME) \
     case chpl::uast::asttags::NAME: \
     { \
      CHPL_ASSERT(false && "这段代码不应该运行"); \
     }
    #define AST_NODE(NAME) CONVERT(NAME)
    #define AST_BEGIN_SUBCLASSES(NAME) IGNORE(START_##NAME)
    #define AST_END_SUBCLASSES(NAME) IGNORE(END_##NAME)
    #include "chpl/uast/uast-classes-list.h"    IGNORE(NUM_AST_TAGS)
    IGNORE(AST_TAG_UNKNOWN)
    #undef AST_NODE
    #undef AST_BEGIN_SUBCLASSES
    #undef AST_END_SUBCLASSES
    #undef CONVERT
    #undef IGNORE
   }
   CHPL_ASSERT(false && "这段代码不应该运行");
  }

就是这样。 我们已经自动生成了遍历代码,允许我们以我认为非常优雅的方式使用访问者模式。 假设添加新 AST 节点的开发人员更新了 uast-classes-list.h 头文件,遍历逻辑将自动修改为正确处理新节点。

生成 Python 类层级

这很有趣。 有一段时间,在我的业余时间里,我一直在开发 Chapel 的 Python bindings。 这些 bindings 面向开发语言工具:用 Python 而不是 C++ 编写语言 linter、自动格式化程序,甚至可能是语言服务器感觉容易得多。 使用 Python 开发处理 Chapel 程序的 throwaway 脚本肯定更容易,这是 Chapel 团队的开发人员经常做的事情。

我决定让 Python AST 节点类层级与 C++ 版本匹配。 这在很多方面都很方便,包括能够包装父 AST 节点上的方法并使它们可以通过子 AST 节点使用,并且让 isinstance 正常工作。 从概念简单性的角度来看,它也很有利。 但是,我非常不想编写 CPython API 代码来定义 Chapel 语言中提供的许多 AST 节点类。

再次,uast-classes-list.h 头文件在这里发挥了作用。 我只需稍作努力,即可为类层级中的每个 AST 节点自动生成 PyTypeObject

来自 chapel.cpp,第 563 行左右

#define DEFINE_PY_TYPE_FOR(NAME, TAG, FLAGS)\
 PyTypeObject NAME##Type = { \
  PyVarObject_HEAD_INIT(NULL, 0) \
  .tp_name = #NAME, \
  .tp_basicsize = sizeof(NAME##Object), \
  .tp_itemsize = 0, \
  .tp_flags = FLAGS, \
  .tp_doc = PyDoc_STR("A Chapel " #NAME " AST node"), \
  .tp_methods = (PyMethodDef*) PerNodeInfo<TAG>::methods, \
  .tp_base = parentTypeFor(TAG), \
  .tp_init = (initproc) NAME##Object_init, \
  .tp_new = PyType_GenericNew, \
 };
#define AST_NODE(NAME) DEFINE_PY_TYPE_FOR(NAME, chpl::uast::asttags::NAME, Py_TPFLAGS_DEFAULT)
#define AST_BEGIN_SUBCLASSES(NAME) DEFINE_PY_TYPE_FOR(NAME, chpl::uast::asttags::START_##NAME, Py_TPFLAGS_BASETYPE)
#define AST_END_SUBCLASSES(NAME)
#include "chpl/uast/uast-classes-list.h"#undef AST_NODE
#undef AST_BEGIN_SUBCLASSES
#undef AST_END_SUBCLASSES

您可能已经注意到我在上面的代码中偷偷地使用了 template。 这样做的动机是避免为每个 AST 节点写出(通常为空)的 Python 方法表。 特别是,我有一个 template,默认情况下,它提供一个空方法表,可以针对每个节点进行专用化,以便在必要时添加方法。 此细节对于下面的应用 3 很有用,但对于理解此处 X Macros 的使用不是必需的。

我使用了相同的 <> 技巧来为每个 tag 生成 parentTypeFor

来自 chapel.cpp,第 157 行左右

static PyTypeObject* parentTypeFor(chpl::uast::asttags::AstTag tag) {
#define AST_NODE(NAME)
#define AST_LEAF(NAME)
#define AST_BEGIN_SUBCLASSES(NAME)
#define AST_END_SUBCLASSES(NAME) \
 if (tag > chpl::uast::asttags::START_##NAME && tag < chpl::uast::asttags::END_##NAME) { \
  return &NAME##Type; \
 }
#include "chpl/uast/uast-classes-list.h"#include "chpl/uast/uast-classes-list.h"#undef AST_NODE
#undef AST_LEAF
#undef AST_BEGIN_SUBCLASSES
#undef AST_END_SUBCLASSES
 return &AstNodeType;
}

再次调用 uast-classes-list.h 宏几次,我就有了一个正常工作的类层级。 我没有显式地提及任何 AST 节点; 一切都源自 Chapel 编译器头文件。 这也意味着随着语言的变化和 AST 类层级的开发,Python bindings 的代码不需要更新。 只要使用最新版本的头文件进行编译,该层级就会与语言中存在的层级匹配。

这允许在 Python 中编写如下代码:

def print_decls(mod):
  """
  打印此 Chapel 模块中声明的所有内容。
  """
  for child in mod:
    if isinstance(child, NamedDecl):
      print(child.name())

应用 3: CPython 方法表和 Getters

Chapel Python bindings 再次使用了 X Macro 模式。 就像我之前提到的那样,我使用 template specialization 来减少声明 Python 对象所需的样板代码量。 特别是,有一个通用的方法表声明如下:

来自 chapel.cpp,第 541 行左右

template <chpl::uast::asttags::AstTag tag>
struct PerNodeInfo {
 static constexpr PyMethodDef methods[] = {
  {NULL, NULL, 0, NULL} /* 哨兵 */
 };
};

然后,当我需要添加方法时,我通过编写如下内容来使用 template specialization:

template <>
struct PerNodeInfo<TheAstTag> {
 static constexpr PyMethodDef methods[] = {
  {"method_name", TheNode_method_name, METH_NOARGS, "文档字符串"},
  // ... 更多类似以上内容 ...
  {NULL, NULL, 0, NULL} /* 哨兵 */
 };
};

在查看一个 PR 时,该 PR 将更多方法添加到 Python bindings(通过定义新的 TheNode_methodname 函数,然后将它们包含在方法表中),我注意到在该 PR 中,开发人员添加了一些方法,但忘记将它们放入各自的表中,导致 Python 客户端代码无法使用它们。 这伴随着另一个观察结果,即在声明 C++ 函数,然后将它们列在表格中时,存在适度的重复。 该名称(代码中的 method_name)出现了多次