Clang 变换器教程

本教程介绍如何使用 Clang 变换器编写源代码到源代码的转换工具。

什么是 Clang 变换器?

Clang 变换器是一个用于编写 C++ 诊断和程序转换的框架。它基于 clang 工具链和 LibTooling 库,但旨在隐藏 clang 原生低级库的大部分复杂性。

Transformer 的核心抽象是重写规则,它指定如何将给定程序模式更改为新形式。以下是一些使用 Transformer 可以完成的任务示例

  • 警告使用 MkX 作为声明函数的名称,

  • MkX 更改为 MakeX,其中 MkX 是声明函数的名称,

  • s.size() 更改为 Size(s),其中 sstring

  • e.child().m() 折叠为 e.m(),对于任何表达式 e 和名为 m 的方法。

所有这些示例都具有一个共同的形式:它们识别要转换的目标模式,它们指定对模式标识的代码进行编辑,并且它们的模式和编辑引用共同变量,如 sem,它们跨越代码片段。我们的第一个和第二个示例还指定了仅从语法本身无法得知的模式约束,例如“s 是一个 string。” 即使是第一个示例(“警告...”)也共享此形式,即使它没有更改任何代码 - 它的“编辑”只是一个空操作。

Transformer 帮助用户简洁地指定这种类型的规则,并轻松地以本地方式在文件集合上执行它们,将它们应用于代码库的选定部分,甚至将它们捆绑为 clang-tidy 检查以进行持续应用。

Clang 变换器适合谁?

Clang 变换器适合想要编写 clang-tidy 检查或编写工具以(大致)以相同方式修改大量 C++ 文件的开发人员。什么算作“大量”实际上取决于更改的性质以及您对重复编辑的耐心程度。根据我们的经验,自动化解决方案在 100 到 500 个文件之间变得有价值。

入门

Transformer 中的模式是用 clang 的 AST 匹配器 表达的。匹配器是一种用于描述 clang 抽象语法树 (AST) 部分的组合器语言。由于 clang 的 AST 包含完整的类型信息(在单个 翻译单元 (TU) 的范围内),这些模式甚至可以对 AST 节点的类型属性进行丰富的约束编码。

在本教程中,我们假设您熟悉 clang AST 和相应的 AST 匹配器。不熟悉两者之一的用户建议从 相关阅读 中的推荐参考资料开始。

示例:样式检查名称

假设您有一个禁止函数命名为“MkX”的样式指南规则,并且您想编写一个检查来捕获任何违反此规则的行为。我们可以将其表达为 Transformer 重写规则

makeRule(functionDecl(hasName("MkX").bind("fun"),
         noopEdit(node("fun")),
         cat("The name ``MkX`` is not allowed for functions; please rename"));

makeRule 是我们用于生成重写规则的首选函数。它接受三个参数:模式、编辑和(可选)解释性说明。在我们的示例中,模式 (functionDecl(...)) 标识函数 MkX 的声明。由于我们只是诊断问题,而不是建议修复方法,因此我们的编辑是空操作。但是,它包含诊断消息的锚点node("fun") 表示将消息与绑定到“fun”的 AST 节点的源范围关联;在本例中,即命名错误的函数声明。最后,我们使用 cat 来构建解释更改的消息。关于名称 cat - 我们将在下面更详细地讨论它,但请记住它也可以接受多个参数并连接它们的结果。

请注意,makeRule 的结果是类型为 clang::transformer::RewriteRule 的值,但大多数用户不需要关心此类型的详细信息。

示例:重命名函数

现在,让我们将此示例扩展为转换;具体来说,是上面的第二个示例

makeRule(declRefExpr(to(functionDecl(hasName("MkX")))),
         changeTo(cat("MakeX")),
         cat("MkX has been renamed MakeX"));

在此示例中,模式 (declRefExpr(...)) 标识对函数 MkX 的任何引用,而不是像我们之前示例中那样标识其声明本身。我们的编辑 (changeTo(...)) 表示将模式匹配的代码更改为文本“MakeX”。最后,我们再次使用 cat 来构建解释更改的消息。

以下是一些此规则将进行的示例更改

原始

结果

X x = MkX(3);

X x = MakeX(3);

CallFactory(MkX, 3);

CallFactory(MakeX, 3);

auto f = MkX;

auto f = MakeX;

示例:方法转函数

接下来,让我们编写一个规则来用(自由)函数调用替换方法调用,该函数调用应用于原始方法调用的目标对象。具体来说,“将 s.size() 更改为 Size(s),其中 s 是一个 string。” 我们从忽略 s 类型的一个更简单的更改开始。也就是说,它将修改任何方法调用,其中方法名为“size”

llvm::StringRef s = "str";
makeRule(
  cxxMemberCallExpr(
    on(expr().bind(s)),
    callee(cxxMethodDecl(hasName("size")))),
  changeTo(cat("Size(", node(s), ")")),
  cat("Method ``size`` is deprecated in favor of free function ``Size``"));

我们用给定的 AST 匹配器表达模式,该匹配器将方法调用的目标绑定到 s [1]。对于编辑,我们再次使用 changeTo,但这次我们从多个部分构建该项,然后使用 cat 进行组合。我们的项的第二部分是 node(s),它选择与绑定到匹配找到的 AST 节点 s 对应的源代码。 node(s) 构建一个 RangeSelector,它在 cat 中使用时,指示应将选定的源插入输出中的该位置。

现在,我们可能不想重写所有“size”方法的调用,而只重写 std::string 上的那些调用。我们只需通过细化我们的匹配器来实现此更改。规则的其余部分保持不变

llvm::StringRef s = "str";
makeRule(
  cxxMemberCallExpr(
    on(expr(hasType(namedDecl(hasName("std::string"))))
      .bind(s)),
    callee(cxxMethodDecl(hasName("size")))),
  changeTo(cat("Size(", node(s), ")")),
  cat("Method ``size`` is deprecated in favor of free function ``Size``"));

示例:重写方法调用

在此示例中,我们删除了一系列调用中的“中间”方法调用。例如,如果您想将子结构折叠到其父结构中,则可能会出现这种情况。

llvm::StringRef e = "expr", m = "member";
auto child_call = cxxMemberCallExpr(on(expr().bind(e)),
                                    callee(cxxMethodDecl(hasName("child"))));
makeRule(cxxMemberCallExpr(on(child_call), callee(memberExpr().bind(m)),
         changeTo(cat(e, ".", member(m), "()"))),
         cat("``child`` accessor is being removed; call ",
             member(m), " directly on parent"));

此规则并不完全是我们想要的:它将重写 my_object.child().foo()my_object.foo(),但它也将重写 my_ptr->child().foo()my_ptr.foo(),这不是我们想要的。我们可以通过在 child_call 的定义中使用 not(isArrow()) 来限制模式。然而,我们确实想重写通过指针的调用。

为了捕获此习惯用法,我们提供了 access 组合器以智能地构建字段/方法访问。在我们的示例中,成员访问表示为

access(e, cat(member(m)))

第一个参数指定被访问的对象,第二个参数指定字段/方法名称的描述。在本例中,我们指定应从源代码中复制方法名称 - 具体来说,是 m 成员的源范围。要构建方法调用,我们将在 cat 中使用此表达式

cat(access(e, cat(member(m))), "()")

参考:范围、模板、编辑、规则

上面的示例仅展示了重写规则的基础知识。我们提到的每个元素都有更多可用的构造函数:范围选择器、模板、编辑和规则。在本节中,我们将简要回顾每个元素,并参考源代码头文件以获取最新信息。首先,我们阐明重写规则实际上正在重写什么。

将 AST 重写为... 文本?

细心的读者可能已经注意到,我们在解释重写规则实际重写的内容时有些含糊。我们提到了“代码”,但代码既可以表示为原始源代码文本,也可以表示为抽象语法树。那么,究竟是哪一种呢?

理想情况下,我们会将输入 AST 重写为一个新的 AST,但 clang 的 AST 不太适合这种类型的转换。所以,我们采取折衷方案:我们将模式及其绑定的名称以 AST 的形式表达,但将我们的更改以源代码文本的形式表达。我们设计了 Transformer 的语言来弥合两种表示之间的差距,以尽量减少用户需要考虑源代码位置和其他低级语法细节的需要。

范围选择器

Transformer 提供了一个小型 API 用于描述源代码范围:RangeSelector 组合器。这些范围最常用于指定受编辑影响的源代码,以及在构建新文本时提取源代码。

大致来说,范围组合器有两种:一种是根据 AST 选择源代码范围,另一种是将现有范围组合成新范围。例如,node 选择由特定 AST 节点跨越的源代码范围,正如我们所见,而 after 选择位于其参数范围之后的(空)范围。因此,after(node("id")) 是紧随绑定到 id 的 AST 节点之后的空范围。

有关 RangeSelector 的完整集合,请参阅头文件 clang/Tooling/Transformer/RangeSelector.h

模板

Transformer 提供了大量不断增长的组合器用于构建输出。在上面,我们演示了 cat,这是用于构建模板的核心函数。它接受一系列参数,这些参数可以是以下三种类型之一:

  1. 原始文本,直接复制到输出中。

  2. 选择器:用 RangeSelector 指定,指示要复制到输出的源代码范围。

  3. 生成器:一个操作,它从其参数构建代码片段。例如,我们上面看到的 access 函数。

这些不同类型的数据都用 Stencil (泛型)表示。cat 直接将文本和 RangeSelector 作为参数,而不是要求它们用生成器构建;其他生成器是显式构建的。

通常,Stencil 从匹配结果生成文本。因此,它们不仅限于生成源代码,还可以用于生成诊断消息,这些消息引用(命名)匹配代码的元素,就像我们在重写方法调用的示例中看到的那样。

有关 Stencil 类型的更多详细信息,请参阅头文件 clang/Tooling/Transformer/Stencil.h

编辑

Transformer 支持其他形式的编辑。首先,在 changeTo 中,我们可以使用之前看到的相同的 RangeSelector 指定要替换的代码的特定部分。例如,我们可以用以下代码更改函数声明中的函数名称:

makeRule(functionDecl(hasName("bad")).bind(f),
         changeTo(name(f), cat("good")),
         cat("bad is now good"));

我们还提供用于插入和删除的更简单的编辑原语:insertBeforeinsertAfterremove。这些都可以在头文件 clang/Tooling/Transformer/RewriteRule.h 中找到。

我们对每个找到的匹配项不限于一次编辑。某些情况下,需要对每个匹配项进行多次编辑。例如,假设我们想要交换函数调用的两个参数。

为此,我们提供了 makeRule 的重载,它接受一个编辑列表,而不是单个编辑。我们的示例可能看起来像这样:

makeRule(callExpr(...),
        {changeTo(node(arg0), cat(node(arg2))),
         changeTo(node(arg2), cat(node(arg0)))},
        cat("swap the first and third arguments of the call"));

EditGenerator(高级)

到目前为止,我们看到的特定编辑都是 ASTEdit 类的实例,或者是一个这样的列表。但是,并非所有编辑都可以表示为 ASTEdit。因此,我们还支持一个非常通用的编辑生成器签名:

using EditGenerator = MatchConsumer<llvm::SmallVector<Edit, 1>>;

也就是说,EditGenerator 是一个函数,它将 MatchResult 映射到一组编辑,或者失败。此签名支持对匹配结果进行非常通用的计算。Transformer 提供了许多用于处理 EditGenerator 的函数,最显着的是 flatten EditGenerator,例如列表扁平化。有关完整列表,请参阅头文件 clang/Tooling/Transformer/RewriteRule.h

规则

我们还可以使用 applyFirst 组合多个规则,而不仅仅是在规则内进行编辑:它将规则列表组合成一个有序的选择,Transformer 应用第一个匹配其模式的规则,忽略列表中其后的其他规则。如果匹配器是独立的,则顺序无关紧要。在这种情况下,applyFirst 只是将规则集合并为一个。

applyFirst 的好处是,对于某些问题,它允许用户更简洁地制定列表中的后续规则,因为它们的模式不需要显式排除列表中之前的模式。例如,考虑一组重写复合语句的规则,其中一个规则处理空复合语句的情况,另一个规则处理非空复合语句。使用 applyFirst,可以将这些规则简洁地表达为

applyFirst({
  makeRule(compoundStmt(statementCountIs(0)).bind("empty"), ...),
  makeRule(compoundStmt().bind("non-empty"),...)
})

第二个规则不需要显式指定复合语句是非空的——这从 applyFirst 中的规则位置可以得出。对于更复杂的示例,这可以导致代码的可读性大大提高。

有时,对代码的修改可能需要包含特定的头文件。为此,用户可以使用 addInclude 修改规则以指定包含指令。

有关这些函数的更多文档,请参阅头文件 clang/Tooling/Transformer/RewriteRule.h

将重写规则用作 clang-tidy 检查

Transformer 支持使用类 clang::tidy::utils::TransformerClangTidyCheck 将重写规则作为 clang-tidy 检查执行。它的设计目的是在定义中需要最少的代码。例如,给定一个规则 MyCheckAsRewriteRule,可以按如下方式定义一个 tidy 检查:

class MyCheck : public TransformerClangTidyCheck {
 public:
  MyCheck(StringRef Name, ClangTidyContext *Context)
      : TransformerClangTidyCheck(MyCheckAsRewriteRule, Name, Context) {}
};

TransformerClangTidyCheck 基于您的规则规范实现了虚拟 registerMatcherscheck 方法,因此您无需自己实现它们。如果需要根据语言选项和/或 clang-tidy 配置对规则进行配置,则可以将其表达为一个函数,该函数接受这些参数并(可选)返回一个 RewriteRule。这对于我们的方法重命名规则很有用,该规则由原始名称和目标参数化。有关详细信息,请参阅 clang-tools-extra/clang-tidy/utils/TransformerClangTidyCheck.h