APP下载

基于JavaCC的抽象语法树生成错误处理技术研究

2022-03-30王国隆金大海宫云战

计算机测量与控制 2022年2期
关键词:源代码区间语法

王国隆,金大海, 宫云战

(北京邮电大学 网络与交换技术国家重点实验室,北京 100876)

0 引言

为保障计算机软件质量,应尽早进行软件测试,而软件测试的重要手段之一就是静态测试[1-5]。随着C++语言标准11/14/17的不断演进,新标准对C++语法进行了诸多补充和优化,同时也带来了许多扩充的新特性[6]。随着C++新标准在市场上大面积的普及应用,对支持C++新标准的静态测试也显得尤为重要。

目前存在很多用于C++静态测试的工具,如Cpplint[7],PMD[8],Cppcheck[9]等。在这些静态测试工具中都必须对代码解析。抽象语法树是对代码的一种重要的中间表示形式,也是一个对代码进行静态测试的不可或缺的数据结构,在代码测试分析领域有着广泛的应用[10]。

目前存在的很多用于C++语言的词法语法分析工具,如JavaCC[11],ANTLR[12],LEX/YACC[13]等不能完全支持C++新标准的某些特性。而文中采用错误处理技术,较好地解决了这个问题,它可以确保对抽象语法树生成出现错误的地方采取对应的策略进行错误处理并完成抽象语法树的创建。在本文中,提出了一个针对抽象语法树生成的错误处理框架,用以解决抽象语法树生成错误问题。

1 相关工作

在词法语法分析工具方面,彭虎臣[14]以LEX作为词法分析器,读入字符串后根据词法规则,将单词或者字符转换为token;采用YACC作为语法分析器,通过在符合BNF范式的语法规则中嵌入语法动作,之后搭建抽象语法树提取网页中CSS部分。Liu等[15]采用用户自定义的语法规则及词法规则,利用ANTLR来生成相应语法分析器及词法分析器的代码。之后,首先把输入的字符流,通过词法分析器转变为由token组成的流,然后利用语法分析器的输出获得最后的抽象语法树进行Scratch语言的特征提取和检测。黄松等[10]使用纯Java代码编写的免费编译工具JavaCC,经过用户自定义的词法语法规则文件jjt生成抽象语法树。肖一飞[16]提出一种基于JavaCC的通用的缺陷检测预处理方法,通过修改jjt规则文件对不同类型的词法异常和语法异常进行支持解决。孟春辰[17]提出一种基于JavaCC的中间文件化简的方式,通过保留一些类型定义信息从而避免了对头文件中导致静态分析失败的复杂语法结构的分析。本文鉴于JavaCC的易用性以及平台无关性等优势继续使用JavaCC作为抽象语法树的生成工具。

但是,JavaCC也有缺点。JavaCC遇到语法错误或者不匹配的语法时,仅仅会报告第一处错误并停止分析。也就意味着,对于代码的抽象语法树生成错误且不完整,所以需要对此进行错误处理。

对于错误处理方面,罗海丽[18]提出抛弃输入记号直到某个定界符,JavaCC默认的错误处理就是使用了跳过字符到指定符号的方式,但是这种抛弃可能会引入更多的错误;郝丽波等[19]提出受到最大重复次数约束的可重试的错误处理策略,但是对于JavaCC来说不做出改动每次生成结果都是一样;Jia等[20]提出了可替换的对输入做局部修改的错误处理策略,但是很难猜测符合意愿的替代方式;曾祥文[21]提出了一种可以回退K个的分析器,使用两个分析栈,一个栈负责将新单词压入栈,另一个栈负责维护K个单词,相当于对K个单词的窗口进行修复。由于无法预测出错的常见形式和替换方式,本文使用跳过符号的方式进行错误处理。

现有的词法语法分析工具对C++新标准支持的不多,一些研究人员是面向C++98标准构造抽象语法树并进行分析,与他们工作不同的是,本文是面向C++新标准生成抽象语法树并对生成错误进行处理,此方法既可应对不支持或者不匹配的语法片段,也可应对C++日后的语法更新。

2 抽象语法树生成错误分析

2.1 抽象语法树生成错误原因

抽象语法树(AST,abstract syntax tree)是以树状的形式表达源代码语法结构的一种表现形式[17],用T=表示,其中:N为树的节点,表示代码中的一种语法结构;E为树的边,表示代码中的语法逻辑关系。

抽象语法树生成过程如图1所示。

图1 语法树生成过程示意图

源程序经过词法分析和语法分析生成抽象语法树。BNF(Backus-Naur Form)是描述编程语言的文法。词法分析和语法分析[22]根据对应的符合BNF范式的规则文件生成对应的语法树节点组成语法树。词法分析错误可以通过在规则文件中添加新token来支持,语法分析则需要针对语法逻辑对规则文件进行修改。现有的规则文件可以较好地支持C++98标准和C++14标准的绝大部分语法和特性,但是针对语法逻辑修改的规则文件不能做到对不断迭代的C++新标准的完全支持,进而就会产生语法树生成错误。

2.2 语法规则文件不能完全支持的原因分析

语法规则文件不能完全支持C++新标准的原因主要有以下3点:

2.2.1 C++新标准BNF范式无法获取

官方的C++新标准的BNF范式文档无法获取,并且网上存在的新标准BNF范式各有出入,无法确定是否和官方一致。如果范式文档选择出现问题,会对后续分析处理产生重大影响。

2.2.2 C++新标准语法结构变化大

C++新标准语法更改中,提出了新的语法结构,新的功能,在新关键字的配合之下或不需要新的关键字,在原有关键字的基础之上,提出新的结构,完成新的功能。这主要目的是为了更加方便地实现相应的功能。以语法树中statement节点的语法结构的变化为例,如图2所示。

图2 C++新标准语法结构变化示例

标准更新后出现更多的是语法结构的变化,有些结构是在原有结构的基础之上进行扩展,这种在构建抽象语法树的时候相对容易进行更改,但是很多结构的更改是在推翻原有结构的基础上进行重新架构(如图2),同时还会糅合其他新的结构进来,层层嵌套。这种在破坏原有结构的基础上进行的更改在构建抽象语法树时相对困难。

由于破坏原有的语法结构,所以在修改抽象语法树规则文件时,也需要对相应结构进行破坏,这样无法保证在破坏现有结构后仍然可以对原有结构进行支撑,这是较难的问题。对于这种问题,一方面对新的语法尽量修改规则文件进行支撑,无法支撑的语法需要通过错误处理技术进行处理。

2.2.3 C++新标准核心库变更大

C++新标准的库更乐于使用复杂的嵌套结构和模板类来对相关代码进行声明,所以结构变得相对复杂,而之前的C++98的库更多的是使用直接声明的方法。例如C++98和C++11核心库iostream的变化如图3所示。

图3 C++新标准核心库变化示例

可以看出,新标准的库文件结构变得异常复杂。因此,C++98的库文件内容更加容易被抽象语法树支撑。由于测试时,为了获取到更全面的分析数据,会将全部的头文件进行展开,以获取到更多的信息来分析,获取分析结果。但是目前来说,在极度复杂的库文件的情况之下,很难做到对头文件的完全支撑。相反地,用户所写源文件的结构相对简单,不会大量使用复杂的结构,更加容易进行语法的支撑。所以建立抽象语法树时,构建的主体应为源文件,要做到不丢失源码信息。

综上,在对C++新标准的语法逻辑做到最大可能的支撑的基础上,对于还不支持的会导致语法树生成错误的语法要进行错误处理。因为错误会导致语法树生成中断,错误点之后的源代码不能生成相关的语法树节点,导致对应源文件的语法树不完整,从而影响后续静态分析。所以本文重点讨论在语法树生成中跳过不支持或不匹配的语法片段的错误处理技术。

3 针对抽象语法树生成的错误处理框架

对于语法树生成失败的源文件,希望通过错误处理技术可以继续生成语法树并且保证语法树尽量完整,也由此本文提出了针对抽象语法树生成的错误处理框架,框架设计如图4所示。

图4 抽象语法树生成错误处理框架

首先,源代码经过预处理后生成中间文件。之后是一个迭代的过程,如果中间文件生成抽象语法树成功,直接结束;如果抽象语法树生成错误,则需要根据报错行数跳过附近语法片段以此继续生成抽象语法树直至成功。

3.1 预处理

中间文件(intermediate file)是在分析C++程序时使用编译器对源代码进行预处理后生成的文件,以方便进行后续分析。

先要对C++源文件进行预处理,预处理是通过编译器进行的,包括一些宏替换,条件编译以及头文件展开等操作。正是因为需要上述操作,所以不能使用.cpp文件直接进行处理,需要使用预处理后生成的中间文件.i进一步生成抽象语法树。

3.2 语法树生成错误处理

中间文件经过词法和语法规则文件生成抽象语法树,如果成功生成,当前文件分析完成;如果生成出错,那么接下来进入语法树生成错误处理流程。

α1α2…αn=w1αiw2

(1)

式中,w1为已经分析并且生成语法树节点的部分;αi为当前分析的token;w2为当前文件剩余的token流。

假定分析到αi生成语法树时发生错误,因为分析是自上而下的,那就意味着当前已经建立了部分语法树,并且已经生成的语法树已经涵盖了w1,但是语法树却无法继续生成以涵盖w2。此时,就必须应用错误处理技术来继续生成语法树。可采取的措施如下:

1)删除当前tokenαi并继续分析;

2)于w1和αi之间插进终结符号α,从而把式(1)改写成式(2):

α1α2…αn=w1ααiw2

(2)

然后再从αi开始分析;

Lakoff提出的意象图式(image schema)是人们理解和认知事物的基本结构和组织形式,也是概念范畴内的原型结构[4]。由于人体本身就属于空间概念范畴,所以基本的意象图式就是空间图式,凡是涉及到空间结构的概念都是以意象图式认知模式储存在大脑中。at表示空间概念范畴是基于路径意象图式基础的,如图1:

3)从w1的尾部删去若干个token后继续分析。

上述措施可以单独使用也可以结合使用,其中措施(2)直接添加终结符可能会导致程序不能正常编译。这里可以结合措施(1)和(3)进行错误处理,多次使用措施1并结合措施3进行处理相当于在式(1)中从w1的尾部删除若干个token并且在w2的首部删除若干个token,即把式(1)修改为式(3):

α1…αi-p…αi…αi+qα2…αn=w0Uw3

(3)

式中,

U=αi-p…αi…αi+q

(4)

如果这样的U存在,就删除或注释Uu后继续分析。

基于以上措施,对于每一个cpp文件,先不考虑源代码中引用的头文件,只对纯源代码部分生成抽象语法树。如果在这个过程中出现异常,解决措施是不考虑报错行数附近的函数区间,继续分析其他可以正常生成抽象语法树的部分。

解决办法是,从完整的中间文件中分离出源程序部分,根据这部分用户写的源代码生成抽象语法树,如果发生异常,通过报错行数和函数区间标识算法略过附近函数区间后重新生成语法树直至成功。

模块流程设计如图5所示。

如图5所示,新语法错误处理模块可以分为3个部分:1)源程序分离;2)文件内函数区间划分;3)注释报错区间迭代生成抽象语法树。

3.2.1 源程序分离

在完整的中间文件中,有用户写的源代码部分,也有头文件展开的部分,这里先不考虑头文件展开部分,只对源程序生成抽象语法树。所以,需要从完整的中间文件中得到源程序部分。

中间文件片段以及预期分离效果如图6所示。

图6 中间文件片段以及预期分离效果

孟春辰[17]根据“# 行号 文件存放路径”从后向前遍历,直到遇到文件后缀.h就结束遍历。但是对于图6代码,在源程序中还插入了一些扩展进来的头文件部分,之前的算法不能准确截取出源程序部分,所以提出了算法1所示的通用的源程序分离算法。

算法1:源程序分离算法

输入:源文件名fileName,完整中间文件content;

输出:只包含源程序部分的中间文件result。

index ← 0

isSourceLine ← false

rightPattern← "#s(d+)s”(.*)" + fileName + "”s(d+)"

falsePattern ←"#s(d+)s”(.*)”s(d+)"

while index < content.size() do

if line matches rightPattern do

index++

isSourceLine ←true

continue

end if

if line matches falsePattern do

isSourceLine ←false

end if

if isSourceLine do

result.add(line)

end if

index++

end while

return result

算法描述:算法从上向下扫描,通过两种正则表达式进行识别,一种表示含有源文件后缀的文件提示信息,另一种表示普通的文件提示信息,并且通过一个布尔值标识当前行是否加入结果集中,最后返回源程序部分代码。

3.2.2 文件内区间划分

对上述中间文件分离出的源程序部分生成语法树,如果发生错误,就需要根据报错行数注释相应的区间,所以接下来就需要对中间文件进行区间划分。

定义1:函数区间:函数区间是文件内用来标识一个函数名称和范围的结构,该结构由一个三元组表示,该结构描述记为I={Name,StartLine,EndLine},其中:

Name:表示该函数区间的函数名称;

StartLine:表示该函数区间的起始行数;

EndLine:表示该函数区间的终止行数。

文件内区间划分分为两部分:第一部分是函数区间的标识,第二部分是函数区间优化。

1)函数区间标识算法:函数区间标识算法通过状态机来实现,定义11种状态,利用互相的转化关系求出函数范围。状态机转化关系如表1所示。

表1 函数区间标识状态机转化关系

主要算法是在函数头结束状态中遇到{和}的处理逻辑,具体的算法如算法2所示。

算法2: 函数区间标识算法

输入:文件路径path;

输出:函数划分后起始行结束行的集合list。

switch state do

……

case 7 do

if 3=lastState and 6=lastState then

startLine ←line /*lastState表示当前状态的上一个状态*/

end if

if c ='{' then

bracket ←bracket + 1 /*bracket表示大括号的个数*/

end if

if c ='}' then

bracket← bracket - 1

end if

if 0 =bracket then

endLine← line + 1

list.add(startLine, endLine) /*list是保存起始行和结束行的集合*/

end if

……

end switch

算法描述:查看状态机中的状态,状态7表示函数体开始,如果从成员变量初始化列表状态(状态3)或者从函数头结束状态(状态6)转换而来,需要记录函数开始行数startLine.bracket表示括号个数,当前字符c如果是{符号需要增加括号个数,如果是}需要减少括号个数,如果括号个数减为0,则当前函数结束,记录相应的结束行数endLine,随后加入到函数划分的集合中。

2)函数区间优化算法:对于区间划分后的文件中,会存在部分没有在任何区间中的代码行需要进一步被划分到现有区间中,区间划分后剩余部分如图7所示。

图7 区间划分后剩余部分

可以看出,例如“public:”“