这篇文章是一系列常规文章的一部分,其中C++产品团队和其他来宾回答了我们从客户那里收到的问题。这些问题可以是任何与C++相关的:MSVC工具集、标准语言和库、C++标准委员会、ISOCPP.ORG、CppCon等。
今天的帖子由客座作者Stephen Kelly撰写,他是Havok的开发人员,Qt和CMake的贡献者 博主 . 这篇文章是一个系列文章的一部分,他在这个系列文章中分享了他在当前团队中使用叮当工具的经验。
在 最后一篇文章 ,我们创建了一个新的 叮当声 检查以下记录的步骤,并遇到我们自己知识中的第一个限制-我们如何更改声明和表达式(如函数调用)?
为了创建一个有效的重构工具,我们需要理解 新建u check.py 编写脚本并学习如何扩展它。
C++代码的C++代码探索
当Clang处理C++时,它创建了一个 抽象语法树 表示代码。AST需要能够表示所有可能出现在C++代码中的复杂性——可变模板、lambdas、运算符重载、各种声明等。如果我们可以使用席中代码的AST表示,我们就不会丢弃代码中的任何含义,就像我们只处理文本一样。
我们的目标是利用AST的复杂性,以便我们能够描述其中的模式,然后用新文本替换这些模式。叮当声 AST匹配器API 和 固定API 分别满足这些要求。
AST的复杂程度意味着需要详细的知识才能理解它。即使对于经验丰富的C++开发人员,类的数量以及它们之间如何相互关联也会令人畏惧。幸运的是,这一切都有节奏。我们可以识别模式,使用工具来发现构成C++代码的CLAN模型,并得到一个关于如何创建一个直觉的观点。 叮当声 快检查。
探索叮当声
让我们深入研究并创建一段简单的测试代码,以便我们可以检查它的Clang AST:
int addTwo(int num) { return num + 2; } int main(int, char**) { return addTwo(3); }
有多种方法可以检查铿锵的AST,但是在创建基于AST匹配器的重构工具时最有用的方法是 叮当声 . 我们需要建立我们的知识,AST匹配器和AST本身在同一时间通过 叮当声 .
那么,让我们回到上一篇文章中创建的MyFirstCheck.cpp。这个 MyFirstCheckCheck::寄存器匹配器 方法包含以下行:
Finder->addMatcher(functionDecl().bind("x"), this);
第一个论点 地址匹配器 是一种AST matcher,一种嵌入式领域特定语言。这是一种谓词语言 叮当声 用于遍历AST并创建一组结果“绑定节点”。在上面的例子中,一个名为 十 为AST中的每个函数声明创建。 叮当声 以后的电话 MyFirstCheckCheck::检查 对于结果中的每一组绑定节点。
我们开始吧 叮当声 将我们的测试文件作为参数传递,并在后面加上两个破折号。类似于 叮当声 在里面 第1部分 ,这允许我们指定编译选项并避免有关缺少编译数据库的警告。
这个命令将我们放入一个交互式解释器中,我们可以用它来查询AST:
$ clang-query.exe testfile.cpp -- clang-query>
类型 帮助 对于解释器中可用的完整命令集。我们可以检查的第一个命令是 比赛 ,我们可以缩写为 米 . 让我们从中粘贴匹配器 我的第一个支票.cpp :
clang-query> match functionDecl().bind("x") Match #1: testfile.cpp:1:1: note: "root" binds here int addTwo(int num) ^~~~~~~~~~~~~~~~~~~ testfile.cpp:1:1: note: "x" binds here int addTwo(int num) ^~~~~~~~~~~~~~~~~~~ Match #2: testfile.cpp:6:1: note: "root" binds here int main(int, char**) ^~~~~~~~~~~~~~~~~~~~~ testfile.cpp:6:1: note: "x" binds here int main(int, char**) ^~~~~~~~~~~~~~~~~~~~~ 2 matches.
叮当声 自动为匹配器中的根元素创建绑定。在尝试匹配特定的内容时,这会产生噪音,因此在定义自定义绑定名称时关闭它是有意义的:
clang-query> set bind-root false clang-query> m functionDecl().bind("x") Match #1: testfile.cpp:1:1: note: "x" binds here int addtwo(int num) ^~~~~~~~~~~~~~~~~~~ Match #2: testfile.cpp:6:1: note: "x" binds here int main(int, char**) ^~~~~~~~~~~~~~~~~~~~~ 2 matches.
因此,我们可以看到,对于翻译单元中出现的每个函数声明,我们得到一个结果匹配。 叮当声 稍后将在中一次使用一个匹配项 检查 中的方法 我的第一个支票.cpp 完成重构。
使用 退出 退出 叮当声 翻译。每次更改C++代码时,必须重新启动解释器,以便匹配新内容。
嵌套匹配器
AST匹配器形成一种“谓词语言”,其中词汇表中的每个匹配器本身就是一个谓词,并且这些谓词可以嵌套。匹配器可分为三大类,如 AST匹配器参考 .
函数decl() 是为源代码中的每个函数声明调用的AST Matcher。在普通的源代码中,对于这样一个简单的匹配器,会有成百上千个来自外部头的结果。
让我们只匹配具有特定名称的函数:
clang-query> m functionDecl(hasName("addTwo")) Match #1: testfile.cpp:1:1: note: "root" binds here int addTwo(int num) ^~~~~~~~~~~~~~~~~~~ 1 match.
此匹配器仅在名为“”的函数声明上触发 添加两个 “. 文档的中间一列表示每个匹配器的名称,第一列表示可以嵌套在其中的匹配器的类型。这个 hasName公司 文档未列为可用于 匹配器
在这里,没有使用clangast的经验的开发人员需要了解 函数DECL AST类 继承自 名称DECL AST类(以及 声明人 , 增值税 和 十二月 ). 记录为可用于其中每个类的匹配器也可以用于 函数decl() 匹配器。熟悉ClangAST类的继承结构对于熟练使用AST匹配器至关重要。Clang AST中的类名通过使第一个字母小写来对应于“node matcher”名称。对于带有缩写前缀的类名 CXX公司 例如 CXX成员调用器 ,整个前缀被小写以生成匹配器名称 CXX成员调用器 .
因此,我们可以匹配源代码中所有命名的声明,而不是匹配函数声明。忽略输出中的一些噪声,我们得到每个函数声明和每个参数变量声明的结果:
clang-query> m namedDecl() ... Match #8: testfile.cpp:1:1: note: "root" binds here int addTwo(int num) ^~~~~~~~~~~~~~~~~~~ Match #9: testfile.cpp:1:12: note: "root" binds here int addTwo(int num) ^~~~~~~ Match #10: testfile.cpp:6:1: note: "root" binds here int main(int, char**) ^~~~~~~~~~~~~~~~~~~~~ Match #11: testfile.cpp:6:10: note: "root" binds here int main(int, char**) ^~~ Match #12: testfile.cpp:6:15: note: "root" binds here int main(int, char**) ^~~~~~
参数声明出现在匹配结果中,因为它们由 帕姆瓦德科 班 ,也继承了 名称DECL . 我们只能通过使用相应的AST节点匹配器来匹配参数变量声明:
clang-query> m parmVarDecl() Match #1: testfile.cpp:1:12: note: "root" binds here int addTwo(int num) ^~~~~~~ Match #2: testfile.cpp:6:10: note: "root" binds here int main(int, char**) ^~~ Match #3: testfile.cpp:6:15: note: "root" binds here int main(int, char**) ^~~~~~
叮当声 有一个代码完成功能,通过按TAB键触发,显示可以在任何特定上下文中使用的匹配器。但是,Windows上未启用此功能。
通过叮当作响的AST转储发现
叮当声 在深入探索AST和转储中间节点时,作为发现工具最有用。
让我们询问一下 测试文件.cpp 再说一次,这次是 输出 设置为 倾倒 :
clang-query> set output dump clang-query> m functionDecl(hasName(“addTwo”)) Match #1: Binding for "root": FunctionDecl 0x17a193726b8 <testfile.cpp:1:1, line:4:1> line:1:5 used addTwo 'int (int)' |-ParmVarDecl 0x17a193725f0 <col:12, col:16> col:16 used num 'int' `-CompoundStmt 0x17a19372840 <line:2:1, line:4:1> `-ReturnStmt 0x17a19372828 <line:3:5, col:18> `-BinaryOperator 0x17a19372800 <col:12, col:18> 'int' '+' |-ImplicitCastExpr 0x17a193727e8 <col:12> 'int' <LValueToRValue> | `-DeclRefExpr 0x17a19372798 <col:12> 'int' lvalue ParmVar 0x17a193725f0 'num' 'int' `-IntegerLiteral 0x17a193727c0 <col:18> 'int' 2
这里有很多东西需要接受,还有很多噪音与我们制作匹配器的兴趣无关,比如指针地址,单词 习惯于 莫名其妙的出现和其他结构不明显的内容。为了这篇博文的简洁,我将在AST内容的进一步列表中删除这些内容。
报告的匹配具有 函数DECL 在树的顶端。下面,我们可以看到 帕姆瓦德科 我们之前匹配的节点,以及其他节点,例如 返回stmt . 每一个都对应于clangast中的一个类名,因此查找它们以查看它们继承了什么,并知道哪些匹配器与它们的使用相关是很有用的。
AST还包含源位置和源范围信息,后者用尖括号表示。虽然这个详细的输出对于探索AST很有用,但是对于探索源代码却没有那么有用。可以使用重新进入诊断模式 设置输出诊断 用于源代码探索。不幸的是,这两个输出( 倾倒 和 诊断 )当前无法立即启用,因此需要在它们之间切换。
树遍历
我们可以使用 有() 匹配器:
clang-query> m functionDecl(has(compoundStmt(has(returnStmt(has(callExpr())))))) Match #1: Binding for "root": FunctionDecl <testfile.cpp:6:1, line:9:1> line:6:5 main 'int (int, char **)' |-ParmVarDecl <col:10> col:13 'int' |-ParmVarDecl <col:15, col:20> col:21 'char **' `-CompoundStmt <line:7:1, line:9:1> `-ReturnStmt <line:8:5, col:20> `-CallExpr <col:12, col:20> 'int' |-ImplicitCastExpr <col:12> 'int (*)(int)' | `-DeclRefExpr <col:12> 'int (int)' 'addTwo' `-IntegerLiteral <col:19> 'int' 3
删除一些分散注意力的内容后,我们可以看到AST dump包含一些源范围和源位置。范围用尖括号表示,尖括号有起始位置,也可能有结束位置。避免重复文件名和关键字 线 和 列 ,只打印与以前打印的源位置的差异。例如, <测试文件。cpp:6:1, line:9:1> 描述中第6行第1列的范围 测试文件.cpp 第9行第1列 测试文件.cpp . 范围
因为每个嵌套谓词都匹配,所以顶层 函数decl() 匹配,我们得到结果的绑定。我们还可以使用嵌套 绑定() 调用将节点添加到结果集:
clang-query> m functionDecl(has(compoundStmt(has(returnStmt(has(callExpr().bind("functionCall"))))))) Match #1: Binding for "functionCall": CallExpr <testfile.cpp:8:12, col:20> 'int' |-ImplicitCastExpr <col:12> 'int (*)(int)' | `-DeclRefExpr <col:12> 'int (int)' 'addTwo' `-IntegerLiteral <col:19> 'int' 3 Binding for "root": FunctionDecl <testfile.cpp:6:1, line:9:1> line:6:5 main 'int (int, char **)' |-ParmVarDecl <col:10> col:13 'int' |-ParmVarDecl <col:15, col:20> col:21 'char **' `-CompoundStmt <line:7:1, line:9:1> `-ReturnStmt <line:8:5, col:20> `-CallExpr <col:12, col:20> 'int' |-ImplicitCastExpr <col:12> 'int (*)(int)' | `-DeclRefExpr <col:12> 'int (int)' 'addTwo' `-IntegerLiteral <col:19> 'int' 3
这个 hasDescendant() 在这种情况下,matcher可用于匹配与上述相同的节点:
clang-query> m functionDecl(hasDescendant(callExpr().bind("functionCall")))
注意过度使用 有() 和 hasDescendant() 匹配者-及其补充 hasParent() 和 祖先() –通常是反模式,可能会导致意外结果,尤其是在匹配嵌套时 出口 源代码中的子类。通常,应该使用更高级别的匹配器。例如,while 有() 可用于匹配所需的 整体式 参数在上述情况下,无法指定在具有多个参数的函数中要匹配的参数。这个 hasArgument() 匹配器应用于 callExpr() 要解决此问题,因为它可以指定在存在多个参数时应匹配哪个参数:
clang-query> m callExpr(hasArgument(0, integerLiteral()))
上面的匹配器将匹配第0个参数为整数文本的每个函数调用。
通常我们希望使用更窄的标准来只匹配特定类别的匹配。大多数匹配器都接受多个参数,并且表现得好像它们有一个隐式的 分配() 在他们内部。所以,我们可以写:
clang-query> m callExpr(hasArgument(0, integerLiteral()), callee(functionDecl(hasName("addTwo"))))
仅当被调用的函数名为 添加两个 “.
matcher表达式有时很容易阅读和理解,但很难编写或发现。可以通过检查的输出来发现可以匹配的特定节点类型 叮当声 . 然而 被叫方() 这里的matcher可能很难独立发现,因为它似乎没有在来自的AST转储中被引用 叮当声 它只是参考文档中长长的列表中的一个匹配器。现有系统的代码 叮当声 检查对于发现通常一起使用的匹配器和查找应该使用特定匹配器的上下文都是有教育意义的。
嵌套匹配器在clang查询中创建绑定是另一种重要的发现技术。如果我们有如下源代码:
int add(int num1, int num2) { return num1 + num2; } int add(int num1, int num2, int num3) { return num1 + num2 + num3; } int main(int argc, char**) { int i = 42; return add(argc, add(42, i), 4 * 7); }
我们打算引入一个 安全接口 要使用的类型,而不是 内景 签字人 添加 . 所有现有的 添加 必须移植到一些新的代码模式。
基本工作流程 叮当声 我们必须首先确定源代码,它是我们想要移植的示例,然后确定它在clangast中的表示方式。我们需要确定 添加 函数及其AST类型作为第一步。
让我们从 callExpr() 再一次:
clang-query> m callExpr() Match #1: testfile.cpp:15:10: note: "root" binds here return add(argc, add(42, i), 4 * 7); ^~~~~~~~~~~~~~~~~~~~~~~~~~~~ Match #2: testfile.cpp:15:20: note: "root" binds here return add(argc, add(42, i), 4 * 7); ^~~~~~~~~~
此示例使用各种不同的参数 添加 函数:第一个参数是来自不同函数的参数,然后是另一个调用的返回值,然后是内联乘法。 叮当声 可以帮助我们发现如何匹配这些结构。使用 hasArgument() matcher我们可以绑定到三个参数中的每一个,并使用 绑定根false 为简洁起见:
clang-query> set bind-root false clang-query> m callExpr(hasArgument(0, expr().bind("a1")), hasArgument(1, expr().bind("a2")), hasArgument(2, expr().bind("a3"))) Match #1: testfile.cpp:15:14: note: "a1" binds here return add(argc, add(42, i), 4 * 7); ^~~~ testfile.cpp:15:20: note: "a2" binds here return add(argc, add(42, i), 4 * 7); ^~~~~~~~~~ testfile.cpp:15:32: note: "a3" binds here return add(argc, add(42, i), 4 * 7); ^~~~~
将输出更改为 倾倒 重新运行同一个匹配器:
clang-query> set output dump clang-query> m callExpr(hasArgument(0, expr().bind("a1")), hasArgument(1, expr().bind("a2")), hasArgument(2, expr().bind("a3"))) Match #1: Binding for "a1": DeclRefExpr <testfile.cpp:15:14> 'int' 'argc' Binding for "a2": CallExpr <testfile.cpp:15:20, col:29> 'int' |-ImplicitCastExpr <col:20> 'int (*)(int, int)' | `-DeclRefExpr <col:20> 'int (int, int)' 'add' |-IntegerLiteral <col:24> 'int' 42 `-ImplicitCastExpr <col:28> 'int' `-DeclRefExpr <col:28> 'int' 'i' Binding for "a3": BinaryOperator <testfile.cpp:15:32, col:36> 'int' '*' |-IntegerLiteral <col:32> 'int' 4 `-IntegerLiteral <col:36> 'int' 7
我们可以看到参数的顶级AST节点是 DeclRefExpr公司 , 呼叫者 和 二进制运算符 分别。在实现重构工具时,我们可能希望包装 argc公司 作为 安全接口(argc) ,忽略嵌套 添加() 调用,因为其返回类型将更改为 安全接口 ,并更改 二进制运算符 安全操作。
当我们了解正在检查的AST时,我们还可以替换 表达式() 有更具体的东西要进一步探索。因为我们现在知道第二个论点是 呼叫者 ,我们可以使用 callExpr() matcher检查被叫方。这个 被叫方() 只有在我们指定 callExpr() 而不是 表达式() :
clang-query> m callExpr(hasArgument(1, callExpr(callee(functionDecl().bind("func"))).bind("a2"))) Match #1: Binding for "a2": CallExpr <testfile.cpp:15:20, col:29> 'int' |-ImplicitCastExpr <col:20> 'int (*)(int, int)' | `-DeclRefExpr <col:20> 'int (int, int)' 'add' |-IntegerLiteral <col:24> 'int' 42 `-ImplicitCastExpr <col:28> 'int' `-DeclRefExpr <col:28> 'int' 'i' Binding for "func": FunctionDecl <testfile.cpp:1:1, line:4:1> line:1:5 add 'int (int, int)' ... etc 1 match. clang-query> set output diag clang-query> m callExpr(hasArgument(1, callExpr(callee(functionDecl().bind("func"))).bind("a2"))) Match #1: testfile.cpp:15:20: note: "a2" binds here return add(argc, add(42, i), 4 * 7); ^~~~~~~~~~ testfile.cpp:1:1: note: "func" binds here int add(int num1, int num2) ^~~~~~~~~~~~~~~~~~~~~~~~~~~
避开消防水龙带
通常,当您需要检查AST时,在真正的源代码上运行clangquery而不是单个文件演示是有意义的。从一个 callExpr() matcher将导致一个firehose问题-将有成千上万的结果,您将无法确定如何使您的matcher更具体的源代码行您感兴趣。在这种情况下,有几个技巧可以帮助你。
首先,你可以使用 ISExpansionMainFile() 将匹配项仅限于主文件,不包括头中的所有结果。那个匹配器可以和 出口 是的, Stmt公司 s和 十二月 s、 因此,它对您可能希望开始匹配的所有内容都很有用。
第二,如果你仍然从你的匹配者那里得到太多的结果 有祖先 matcher可以用来进一步限制结果。
第三,变量的特定名称通常可以将您的匹配锚定到感兴趣的特定代码段。
探索代码的AST,例如
void myFuncName() { int i = someFunc() + Point(4, 5).translateX(9); }
可以从一个匹配器开始,该匹配器锚定到变量的名称、它所在的函数以及主文件中的位置:
varDecl(isExpansionInMainFile(), hasAncestor(functionDecl(hasName("myFuncName"))), hasName("i"))
这个起点将使我们有可能探索在AST中如何表示行的其余部分,而不会淹没在噪声中。
结论
叮当声 是使用AST Matchers开发重构工具时的一项重要资产。它是一个原型和发现工具,可以将其输入粘贴到新应用程序的实现中 叮当声 检查。
在这篇博文中,我们探讨了 叮当声 工具-嵌套匹配器并绑定它们的结果-以及输出如何与AST匹配器引用相对应。我们还了解了如何限制匹配的范围,以便在实际代码中轻松创建匹配器。
在下一篇博文中,我们将探讨AST matcher结果的相应消费者。这将是与我们确定为重构目标的模式对应的源代码的实际重写。
您认为哪种AST匹配器在您的代码中最有用?请在下面的评论中告诉我们,或通过电子邮件直接联系作者 stkelly@microsoft.com ,或在Twitter上 @斯蒂维尔 .
我将展示更多的新的和未来的发展 叮当声 在 代码::潜水 十一月。如果你要参加,一定要把它放在你的日历上!