1. 引言
研究背景:现代生产代码库极其复杂并且不断更新。静态分析器可以帮助开发人员发现代码中的潜在问题(在本文的其余部分中称为错误),这对于在这些大型代码库中保持高代码质量是必要的。虽然通过静态分析尽早发现错误是有帮助的,但修复这些错误的问题在实践中仍然主要是手动任务,阻碍了静态分析工具的采用。
现存问题:大多数静态分析器都会查找常见错误类别的实例,例如潜在的空取消引用、流行 API 的错误使用或特定语言结构的误用。研究团队观察到,针对特定错误类别的修复通常彼此相似:它们有一个模式。也就是说,过去人类对同一错误类别的修复可能会提供有关如何修复该错误类别的未来实例的见解。鉴于这一观察,是否可以通过学习过去的修复来自动修复发现的错误?
研究内容:论文通过学习过去的修复来解决自动修复常见错误类别实例的问题。论文假设两个输入:(1)修复特定类型错误的一组更改,例如来自代码库的版本历史记录。这些变化可以作为训练数据来学习修复模式。(2) 一段带有我们要修复的静态分析警告的代码。仅给出这两个输入,问题是预测解决方案,以类似于或等于人类开发人员所做的方式解决静态分析警告。通过自动生成修复程序并仅将是否应用修复程序的最终决定留给人类,可以大大减少解决静态分析器指出的错误所花费的总体工作量。
论文专注于那些具有不平凡但重复性修复的错误。一方面,有些错误类别通常意味着特定的修复。例如,对于建议某个字段为最终结果的警告,实施自动修复建议非常简单。这种自动修复可以由该规则的作者在静态分析器中定义,而不需要知道应用该规则的特定上下文;事实上,有些容易出错规则带有自动修复功能。另一方面,一些错误需要复杂的、特定于应用程序的修复,例如用户进行一系列特定交互后 UI 选项卡不显示的问题。在这里,论文的目标是介于这两个极端之间的错误类别,其中找到修复程序并非易事,但典型的修复程序属于一组重复出现的修复模式。对于此类错误类别,通常存在不止一种方法来解决问题,并且解决错误类别的特定实例的正确方法取决于上下文,例如,静态分析警告周围的代码。
作为本工作中针对的错误类别的一个示例,请考虑 NullPointerExceptions 仍然是 Java 和其他语言中最普遍的错误之一。如果静态分析器警告潜在的空取消引用,开发人员可以通过各种方式解决该问题。上图显示了空取消引用错误修复的三个匿名示例,这些示例分别向现有 if 条件添加了一个连接、用三元运算替换了调用以及添加了提前返回。虽然所有这些修复都引入了某种空检查,但确切的修复在很大程度上取决于现有的代码。除了这些示例之外,还有更多方法可以修复空取消引用错误,例如,通过添加新的 if 语句或以分离方式扩展现有的 if 条件。学习所有这些修复模式并决定将哪一种模式应用于给定的错误代码是一个不小的问题。论文的工作旨在自动修复大规模工业软件开发中的错误。 这种设置会带来一些有趣的挑战:
- 为了减少修复错误所花费的人力时间,该方法可能只提出少量潜在的修复方案,最好只提出一个修复方案。
- 为了使此修复为开发人员所接受,建议的修复应该类似于人类:与人类开发人员将实现的修复非常相似或完全相同。
- 为了快速提出修复建议,以及将查找修复所需的计算资源保持在一定范围内,该方法无法探索大量候选修复并根据测试套件或任何其他昂贵的验证例程验证每个修复。
为了解决这些挑战,论文推出了 Getafix,这是一种自动化技术,可以学习静态分析警告的重复修复模式,并针对未来出现的同一错误类别提出修复建议。Getafix生成类似于人类的修复,并且速度足够快(通常在 10 秒内),可以在与人类开发人员等待静态分析结果的时间大致相同的时间内提供修复建议。简而言之,该方法包括三个主要步骤。首先,它将一组给定的示例修复拆分为 AST 级别的编辑步骤。其次,它基于一种新颖的分层聚合聚类技术,从这些编辑步骤中学习重复出现的修复模式,该技术生成修复模式的层次结构,范围从非常一般的修复到非常具体的修复。第三,如果需要修复以前未见过的错误,Getafix会找到合适的修复模式,对所有候选修复进行排名,并向开发人员建议最重要的修复。作为第三步的检查,Getafix会根据静态分析器验证每个建议的修复,以确保修复消除警告。请注意,针对静态分析器的验证是每次修复的一次性工作,从而将计算资源保持在合理的范围内。
实验结果:论文通过两种方式评估 Getafix。论文评估的一部分将该方法应用于针对两个公开可用且广泛使用的 Java 静态分析器报告的六种警告的总共 1,268 个错误修复。错误类别包括潜在的 null 取消引用、Java 引用相等性的错误使用以及常见的 API 误用。在学习了数十到数百个示例的修复模式后,Getafix 准确预测人工修复是所有修复中 12% 到 91% 的最重要建议,具体取决于错误类别。在开发人员愿意检查最多 5 个修复建议的情况下,正确预测修复的百分比甚至在 19% 到 92% 之间,其中包含 1,268 个错误中的 526 个修复。因为这些结果表明预测的修复与人类修复完全匹配的频率,而不是产生任何人类可接受的修复,所以这些结果提供了 Getafix 有效性的下限。
论文评估的另一部分将 Getafix 部署到 Facebook 的生产环境中,它现在为数十亿人使用的应用程序的稳定性做出了贡献。在 Facebook,Getafix 目前建议修复 Infer发现的错误,一种静态分析工具,可识别 Android 和 Java 代码中的问题,例如空取消引用。例如,上图中的修复已由 Getafix 建议并被 Facebook 的开发人员接受。论文发现,开发人员接受了 Getafix 建议的所有修复中大约42%,只需单击一下即可解决错误,有助于节省开发人员宝贵的时间。
2. 文章概述
Getafix 由三个主要组件组成,分为学习阶段和预测阶段。在下文中,论文将在高层次上描述它们的功能和挑战,然后在后面的部分中进行更详细的描述。上图概述了该方法。在学习阶段,一组错误及其修复对作为训练数据提供给 Getafix。因为训练数据可以服务于与特定信号相关的过去人类代码更改的任何集合,例如静态分析警告、类型错误、lint 消息,或者只是在人类代码审查期间建议更改的事实。论文的评估侧重于将静态分析警告作为信号,即所有错误和修复均已被静态分析器检测为特定错误类别的实例,例如潜在的空取消引用。在预测阶段,Getafix会采用以前未见过的代码(这些代码与训练示例具有相同的信号)并生成错误修复。
Tree Differencer:学习阶段的第一步,也是 Getafix 三个主要组件中的第一个,是树差异器,它识别 AST 级别的变化。它生成具体的编辑,这些编辑是原始 AST 之前和之后的子 AST 对,表示可以通过将 AST 之前的编辑实例替换为 AST 之后的编辑实例在不同代码上重播的特定更改。
漏洞模式学习:为了从特定的修复示例中概括,修复模式具有“洞”,即模式变量,可以匹配特定的子树。一项关键的技术贡献是一种新颖的分层、凝聚聚类技术,它将固定模式组织成分层结构。该方法派生出修复模式的不同变体,范围从匹配少数具体编辑的非常具体的模式到匹配许多具体编辑的非常一般的修复模式。另一个关键贡献是修复模式中不仅包含代码更改本身,还包含一些周围的上下文,如上图所示。此上下文对于决定将多种可能的修复模式中的哪一种应用于给定的代码段至关重要。
漏洞修复:在学习修复模式(每个错误类别一次)之后,Getafix 的最后一个组件是预测阶段,该阶段将模式应用于以前未见过的错误代码以生成合适的修复。由于 Getafix 的目标是预测修复,而无需对许多候选修复进行昂贵的验证,因此它在内部对候选修复进行排名。论文提出了一种简单而有效的统计排名技术,该技术使用提取的附加上下文以及修复模式。
在向开发人员建议修复之前,Getafix 会针对为错误类别提供信号的同一工具(例如静态分析、类型检查器或 linter)验证预测的修复。Getafix 与所使用的信号无关,因此我们假设可能是昂贵的黑匣子组件。
3. Tree Differencer
Getafix 的第一步采用一组示例错误修复,并将每个修复分解为细粒度的编辑。这些编辑为后续步骤中学习和应用修复模式提供了基本要素。例如,上图显示了代码更改以及Getafix提取的三个细粒度编辑:(i) 如果任务为null,则插入提前返回(以绿色显示);(ii) 将 public 改为 private(以红色显示);(iii) 移动doWork方法(以蓝色显示)。
3.1 Trees
Getafix 根据 AST 提取细粒度的编辑。 出于论文的目的,AST 中的节点具有:(1)标签,例如 "BinEx" (二进制表达式)、 "Literal" 以及 "Name";(2)可能为空的值,例如 +、42 以及 foo;(3)子节点,每个子节点都有一个位置来描述它们与父节点的关系,例如,“左”和“右”来寻址二进制表达式的左/右子表达式。更正式地,论文将树集定义如下:
其中 Label、Location 和 Value 是字符串集。为了便于阅读,论文使用术语符号而不是元组来表示 AST。 例如,解析 x = y + 2 会产生 AST Assign(x : Name, + : BinEx(y : Name,2 : Literal)) 。 子节点列在其父节点后面的括号中。如果不存在子节点,则省略括号。 如果值不为空,则使用从类型判断借用的语法将值添加到标签前面。
3.2 Tree Edits
给定两棵树,论文定义编辑如下:
编辑是包含前后 AST 的三元组,后跟一组映射。 映射是引用前一个节点和后一个 AST 节点的三元组,以及指示这对子树是否是修改的一部分的标志(mod 表示该节点是修改的一部分,unmod 表示映射的 子树未修改)。 TreeRef 是唯一标识 AST 中节点的引用集。论文在源代码中编写编辑内容的方式如下:之前的代码 ↣ 之后的代码,例如,x = y + 2 ↣ x = 3 + y。
将此表示法与上面的 AST 表示法相结合,论文像 AST 之前 ↣ 之后 AST 一样编写树编辑,其中论文将修改后的子树写成粗体(只要区别相关),并通过映射匹配索引连接给定节点。 例如,针对上述代码更改发出的两个具体编辑是 + : BinEx0(y : Name1, 2 : Literal) ↣ + : BinEx0(3 : Literal, y : Name1) ,即
基于树的编辑推理的替代方法是使用基于行的比较工具。 然而,基于行的差异在更粗粒度的级别上解释代码,忽略了 AST 提供的结构信息。 例如,考虑到上图中的更改,基于行的比较会将两种方法标记为完全删除和插入,而基于树的编辑可以对移动进行编码,因此还将移动方法中的插入检测为具体编辑。
3.3 Extracting Concrete Edits
为了从给定的 AST 对中提取编辑,Getafix 基于 GumTree 算法,一种基于树的技术,用于计算从一个 AST 到另一个 AST 的细粒度编辑步骤。该方法提取四种编辑步骤:删除、插入、移动以及更新。前树或后树中未映射的节点分别被视为删除或插入。父节点未相互映射的映射节点对被视为一次移动。如果它们的父级已映射,则该对也被视为移动,但节点在这些父级中具有不同的子树位置(例如参数交换)。具有不同值的映射节点对被视为更新,并且可能同时是移动的一部分。上述操作之一涉及的任何节点对都被视为已修改,而所有其他映射节点对则被视为未修改。如果整个子树的所有节点均未修改,则认为整个子树未修改。
对于上图中的示例,if 语句的插入是添加,从 public 更改为 private 是更新,并且方法 doWork 已被移动。表示调用 task.makeProgress() 的子树未修改。 相反,由于插入了 if 语句,代表该调用周围的块的子树被修改。
正如上图中所展示的,修改可以被嵌套并与移动结合起来。对于编辑的具体粒度,没有明确的标准。也许插入本身就是解决方案,也许移动很关键。由于论文的算法与语言无关,没有领域知识,将修改分组为具体的编辑也没有可靠的策略。论文通过依据 GumTree 报告的修改来提取整个具体编辑范围来解决这一挑战:如果具体编辑至少包含一个修改,则映射节点将成为新具体编辑的根。因此,在上图的示例中,论文提取了植根于 doWork 主体(包含插入)、doWork 方法声明(也包含插入)和 getRuntime 方法声明(含更新)的具体编辑,同时也包括类主体级别(包含所有修改)和其祖先节点。相反,论文不会创建以 getRuntime 主体为基础的具体编辑,因为它没有修改。除了以映射节点为根创建具体编辑外,论文还为前后树中修改节点的每个连接组件的父级创建具体编辑根。这样做是为了确保论文仍然能够从附近发生的其他更改学习模式。
该方法旨在提取过多而不是过少的具体编辑。 其基本原理是,Getafix 的聚类步骤(将在下面详细解释)会自动以最佳粒度级别对模式进行优先级排序,因为将存在多个相似的具体编辑。 例如,当将上图发出的具体编辑与空取消引用的错误修复中的进一步具体编辑相结合时,if 语句的插入可能比移动或更新更频繁地出现。 相比之下,包含进一步修改的具体编辑(例如,在 AST 中进一步扎根)可能会作为噪音被丢弃,因为没有类似的具体编辑来创建聚类。
4. Learning Fix Pattern
给定上一节中描述的树差异器提取的细粒度树级编辑集,下一步是学习修复模式,即在特定类型错误的修复中观察到的重复编辑模式。 直观上,编辑模式可以被认为是多次编辑的概括。 为了抽象出具体编辑的细节,编辑模式可能有“洞”或模式变量,以表示树中具体编辑不同的部分。 Getafix 的一个关键贡献是一种新颖的算法,该算法派生出编辑模式的层次结构,其中包含不同通用级别的编辑模式,范围从叶的具体编辑到根的抽象编辑模式。
导出编辑模式层次结构的算法基于对编辑模式的泛化操作,这是论文通过反统一获得的。论文采用了一种现有的方法,用于将不同符号表达之间的泛化。首先介绍了泛化操作,然后展示了 Getafix 如何利用这一操作来引导分层聚类算法。最后,详细描述了 Getafix 如何利用上下文信息增强编辑模式,以帮助确定何时将学习到的编辑模式应用到新代码。