编译原理:剖析python编译阶段
Python编译器
GDB跟踪python编译器的执行过程,在tokenizer.c的tok_get()函数中打一个断点,通过GDB查看python的运行,使用bt命令打印输出,结果如下图所示
整理后可得到:
该过程就是运行python并执行到词法分析环节的一个执行路径:
-
1.首先是 python.c,这个文件很短,只是提供了一个 main() 函数。你运行 python 命令的时候,就会先进入这里。
-
2.接着进入 Modules/main.c 文件,这个文件里提供了运行环境的初始化等功能,它能执行一个 python 文件,也能启动 REPL 提供一个交互式界面。
-
3.之后是 Python/pythonrun.c 文件,这是 Python 的解释器,它调用词法分析器、语法分析器和字节码生成功能,最后解释执行。
-
4.再之后来到 Parser 目录的 parsetok.c 文件,这个文件会调度词法分析器和语法分析器,完成语法分析过程,最后生成 AST。
-
5.最后是 toknizer.c,它是词法分析器的具体实现。
REPL为Read-Evaluate-Print-Loop的所写,即通过一个交互界面接受输入并回显结果
词法分析
词法分析方法
词法分析的任务就是:输入字符串,输出Token串,词法分析在英文中一般叫做Tokenizer
具体实现:有个计算模型,叫做有限自动机(Finite-state Automaton,FSA),或者叫做有限状态自动机(Finite-state Machine,FSM)
有限自动机它的状态数量是有限的,当它受到一个新字符的时候,会导致状态的转移。
比如:下面的状态机能够区分标识符和数字字面量:
在这样一个状态机里,用单线圆圈表示临时状态,双线圆圈表示接受状态。接受状态就是一个合格的 Token,比如上图中的状态 1(数字字面量)和状态 2(标识符)。当这两个状态遇到空白字符的时候,就可以记下一个 Token,并回到初始态(状态 0),开始识别其他 Token。
可看到,词法分析的过程,就是对一个字符串进行模式匹配的过程
字符串模式匹配的工具 -- 正则表达式工具
如:
ps -ef | grep 's[a-h]'
用s[a-h]
用来描述匹配规则,实现匹配所有包含"sa","sb",...,"sh"的字符串
相同原理,正则表达式也可以用来描述词法规则,这种描述方法叫做:正则文法(Regular Grammar)
比如:数字字面量和标识符的正则文法描述如下:
IntLiteral : [0-9]+; //至少有一个数字
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
可看到正则文法的格式为:Token类型:正则表达式,每个词法规则都采用这种格式,用于匹配一种Token
词法规则里面要有优先级,比如排在前面的词法规则优先级更高。
一个能够识别int关键字和标识符的有限自动机图示:
从正则表达式生成有限自动机
上面已经了解了如何构造有限自动机以及如何处理词法规则的冲突,这样就可以实现手写词法分析器。但手写词法分析器的步骤太过繁琐,可只写出词法规则,自动生成对应的有限自动机
词法分析器生成工具lex(及GNU版本的flex)也能够基于规则自动生成词法分析器。
具体的实现思路如下:
把一个正则表达式翻译成NFA,然后把NFA转换成DFA
- DFA: Deterministic Finite Automation,即确定的有限自动机,特点就是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换
- NFA: Nondeterministic Finite Automaton, 即不确定的有限自动机,特点就是:该状态机中存在某写状态,针对某些输入,不能做一个去诶的能够的转换
这就有可以细分成两种情况:
- 对于一个输入,它有来能够个状态可以转换
2.存在ε转换的情况,也就是没有任何字符输入的情况下,NFA也可以从一个状态迁移到另一个状态
如:"a[a-zA-Z0-9]bc" 这个正则表达式,对字符串的要求是以a开头,以bc结尾,a和bc之间可以有任意多个字母或数字,如下图所示,状态1的节点输入b时,有两条路经可以选择:一条是迁移到状态2,另一条仍然保持在状态1,因此这一个有限自动机是一个NFA
一个NFA例子--识别"a[a-zA-Z0-9]bc" 的自动机
NFA引入\(\varepsilon\)转换的画法如下图所示:
另一个 NFA 的例子,同样能识别“a[a-zA-Z0-9]*bc”,其中有ε转换
无论是NFA还是DFA都等价于正则表达式,即所有的正则表达式都能转换成NFA或DFA;而所有的NFA或DFA,也都能转换成正则表达式
一个正则表达式可以翻译成一个NFA,它的翻译方法如下:
- 识别字符i的NFA
当接受字符 i 的时候,引发一个转换,状态图的边上标注 i。其中,第一个状态(i,initial)是初始状态,第二个状态 (f,final) 是接受状态。
-
转换“s|t”这样的正则表达式
它这个意思是或是s,或是t,两者二选一。s和t本身是两个自表达式,可以增加两个新的状态:开始状态和接受状态,然后,用\(\varepsilon\)转换分别连接代表s和t的子图。
如下图所示--识别s|t的NFA
-
转换“st”这样的正则表达式
s 之后接着出现 t,转换规则是把 s 的开始状态变成 st 整体的开始状态,把 t 的结束状态变成 st 整体的结束状态,并且把 s 的结束状态和 t 的开始状态合二为一。这样就把两个子图衔接了起来,走完 s 接着走 t。
识别st的NFA如下图所示:
-
对于“?”“”和“+”这样的符号,它们的意思是可以重复 0 次、0 到多次、1 到多次,转换时要增加额外的状态和边
识别"s"的NFA图:
可看到从i直接到f,也就是对s匹配0次,也可以在s的起止节点上循环多次。
"s+",那就是s至少经过一次:
通过这样的转换,所有的正则表达式都可以转换为一个NFA
基于NFA可以写一个正则表达式公式:
举例:能够识别int关键字/标识符和数字字面量的正则表达式,这个表达式首先被表示为一个内部的树状数据结构,然后可以转换为NFA
int | [a-zA-Z][a-zA-Z0-9]* | [0-9]*
下面的输出结果中列出了所有的状态,以及每个状态到其他状态的转换,比如“0 ε -> 2”的意思是从状态 0 通过 ε 转换,到达状态 2 :
NFA states:
0 ε -> 2
ε -> 8
ε -> 14
2 i -> 3
3 n -> 5
5 t -> 7
7 ε -> 1
1 (end)
acceptable
8 [a-z]|[A-Z] -> 9
9 ε -> 10
ε -> 13
10 [0-9]|[a-z]|[A-Z] -> 11
11 ε -> 10
ε -> 13
13 ε -> 1
14 [0-9] -> 15
15 ε -> 14
ε -> 1
可以图示的方式展示输出的结果:
有了NFA后,就要利用它来识别某个字符串,在做正则表达式的匹配过程中,存在这大量的回溯操作,效率比较低,那能否将NFA转成DFA,让字符串的匹配过程更简单呢? 这样整个过程就是一个自动化的过程,从正则表达式到NFA,再到DFA
这个方法就是子集构造法
python的词法分析功能
了解了词法分析的实现的实现原理,来看着python的词法分析是如何做的
在查阅了tokenizer.c的tok_get()函数后,它也是通过有限自动机将字符串变成Token
python源码词法分析功能剖析
python词法分析的实现在Parser目录下的tokenizer.h和tokenizer.c。python的其他部分会直接调用tokenizer.h中定义的函数,如下:
这些函数均以PyTokenizer开头。这是Python源代码中的一个约定。虽然Python是用C语言实现的,其实现方式借鉴了很多面对对象的思想。拿词法分析来说,这四个函数均可以看作PyTokenizer的成员函数。头两个函数PyTokenizer_FromXXXX可以看作是构造函数,返回PyTokenizer的instance。PyTokenizer对象内部状态,也就是成员变量,储存在tok_state之中。PyTokenizer_Free可以看作是析构函数,负责释放PyTokenizer,也就是tok_state所占用的内存。PyTokenizer_Get则是PyTokenizer的一个成员函数,负责取得在字符流中下一个Token。这两个函数均需要传入tok_state的指针,和C++中需要隐含传入this指针给成员函数的道理是一致的。可以看到,OO的思想其实是和语言无关的,即使是C这样的结构化的语言,也可以写出面对对象的程序。
tok_state
tok_state
等价于PyTokenizer这个class本身的状态,也就是内部的私有成员集合,定义如下:
其中最重要的是buf,cur,inp,end,start,这些字段决定了缓冲区的内容
- buf:缓冲区的开始。假如PyTokenizer处于字符串模式,那么buf指向字符串本身,否则,指向文件读入的缓冲区。
- cur:指向缓冲区中下一个字符。
- inp:指向缓冲区中有效数据的结束位置。PyTokenizer是以行为单位进行处理的,每一行的内容存入从buf到inp之间,包括/n。一般情况下 ,PyTokenizer会直接从缓冲区中取下一个字符,一旦到达inp所指向的位置,就会准备取下一行。当PyTokenizer处于不同模式下面,具体的行为会稍有不同。
- end:缓冲区的结束,在字符串模式下没有用到。
- start:指向当前token的开始位置,如果现在还没有开始分析token,start为NULL。
PyTokenizer_FromFile & PyTokenizer_FromString & PyTokenizer_FromUTF8
这三种的实现大致相同,以PyTokenizer_FromString为例:
/* Set up tokenizer for string */
struct tok_state *
PyTokenizer_FromString(const char *str, int exec_input)
{
struct tok_state *tok = tok_new();
if (tok == NULL)
return NULL;
str = decode_str(str, exec_input, tok);
if (str == NULL) {
PyTokenizer_Free(tok);
return NULL;
}
/* XXX: constify members. */
tok->buf = tok->cur = tok->end = tok->inp = (char*)str;
return tok;
}
直接调用tok_new()返回一个tok_state类型的instance,后面的decode_str负责对str进行解码,然后赋给tok->buf/cur/end/inp。
PyTokenizer_Get
PyTokenizer_Get函数的实现:
int
PyTokenizer_Get(struct tok_state *tok, char **p_start, char **p_end)
{
int result = tok_get(tok, p_start, p_end);
if (tok->decoding_erred) {
result = ERRORTOKEN;
tok->done = E_DECODE;
}
return result;
}
PyTokenizer_Get
返回值的int便是token的类型
两个参数char **p_start, char **p_end
为输出参数,指向token在pyTokenizer内部缓冲区的位置。
这里采用返回一个p_start和p_end的意图是避免构造一份token内容的copy,而是直接给出token在缓冲区中的开始和结束的位置,以提高效率
PyTokenizer_Get
该函数的作用是在PyTokenizer所绑定的字符流(可以是字符串也可以而是文件)中取出一个token,比如sum=0,取到sum
,那下一个取到的就是=
。
一个返回的token由两部分参数描述:
- 1.表示token类型的int
- 2.btoken的具体内容,就是一个字符串
python会把不同的token分成若干种类型,该类型定义在include/token.h中以宏的形式存在,如NAME,NUMBER,STRING等
举例:
"sum"这个token可以表示出(NAME,"sum")
NAME是类型,表示sum是一个名称(注意和字符串区分开)
此时python并不会判定该名称是关键字还是标识符,统一称为NAME
tok_get函数
在PyTokenizer_Get
函数实现中,核心代码就是tok_get
函数
tok_get
函数主要负责做了以下几件事:
- 1.处理缩进
缩进的处理只在一行开始的时候。如果tok_state::atbol(at beginning of line)非0,说明当前处于一行的开始,否则不做处理。
/* Get indentation level */
if (tok->atbol) {
int col = 0;
int altcol = 0;
tok->atbol = 0;
for (;;) {
c = tok_nextc(tok);
if (c == ' ') {
col++, altcol++;
}
else if (c == '\t') {
col = (col / tok->tabsize + 1) * tok->tabsize;
altcol = (altcol / ALTTABSIZE + 1) * ALTTABSIZE;
}
else if (c == '\014') {/* Control-L (formfeed) */
col = altcol = 0; /* For Emacs users */
}
else {
break;
}
}
tok_backup(tok, c);
上面的代码负责计算缩进了多少列。由于tab键可能有多种设定,PyTokenizer对tab键有两套处理方案:tok->tabsize保存着"标准"的tab的大小,缺省为8(一般不要修改此值)。Tok->alttabsize保存着另外的tab大小,缺省在tok_new中初始化为1。col和altcol保存着在两种不同tab设置之下的列数,遇到空格+1,遇到/t则跳到下一个tabstop,直到遇到其他字符为止。
接下来,如果遇到了注释或者是空行,则不加以处理,直接跳过,这样做是避免影响缩进。唯一的例外是在交互模式下的完全的空行(只有一个换行符)需要被处理,因为在交互模式下空行意味着一组语句将要结束,而在非交互模式下完全的空行是要被直接忽略掉的。
if (c == '#' || c == '\n') {
/* Lines with only whitespace and/or comments
shouldn't affect the indentation and are
not passed to the parser as NEWLINE tokens,
except *totally* empty lines in interactive
mode, which signal the end of a command group. */
if (col == 0 && c == '\n' && tok->prompt != NULL) {
blankline = 0; /* Let it through */
}
else if (tok->prompt != NULL && tok->lineno == 1) {
/* In interactive mode, if the first line contains
only spaces and/or a comment, let it through. */
blankline = 0;
col = altcol = 0;
}
else {
blankline = 1; /* Ignore completely */
}
/* We can't jump back right here since we still
may need to skip to the end of a comment */
}
最后,根据col和当前indstack的栈顶(也就是当前缩进的位置),确定是哪一种情况,具体请参看上面的代码。上面的代码有所删减,去掉了一些错误处理,加上了一点注释。
注: PyTokenizer维护两个栈indstack
& altindstack
,分别对应col和altcol,保存着缩进的位置,而tok->indent
保存着栈顶。
if (!blankline && tok->level == 0) {
if (col == tok->indstack[tok->indent]) {
// 情况1:col=当前缩进,不变
}
else if (col > tok->indstack[tok->indent]) {
// 情况2:col>当前缩进,进栈
tok->pendin++;
tok->indstack[++tok->indent] = col;
tok->altindstack[tok->indent] = altcol;
}
else /* col < tok->indstack[tok->indent] */ {
// 情况3:col<当前缩进,退栈
while (tok->indent > 0 &&
col < tok->indstack[tok->indent]) {
tok->pendin--;
tok->indent--;
}
}
}
确定token
反复调用tok_nextc,获得下一个字符,依据字符内容判定是何种token,然后加以返回。
/* Identifier (most frequent token!) */
nonascii = 0;
if (is_potential_identifier_start(c)) {
/* Process the various legal combinations of b"", r"", u"", and f"". */
int saw_b = 0, saw_r = 0, saw_u = 0, saw_f = 0;
while (1) {
if (!(saw_b || saw_u || saw_f) && (c == 'b' || c == 'B'))
saw_b = 1;
/* Since this is a backwards compatibility support literal we don't
want to support it in arbitrary order like byte literals. */
else if (!(saw_b || saw_u || saw_r || saw_f)
&& (c == 'u'|| c == 'U')) {
saw_u = 1;
}
/* ur"" and ru"" are not supported */
else if (!(saw_r || saw_u) && (c == 'r' || c == 'R')) {
saw_r = 1;
}
else if (!(saw_f || saw_b || saw_u) && (c == 'f' || c == 'F')) {
saw_f = 1;
}
else {
break;
}
c = tok_nextc(tok);
if (c == '"' || c == '\'') {
goto letter_quote;
}
}
......
*p_start = tok->start;
*p_end = tok->cur;
......
return NAME;
}
saw_x标识x是否出现过,用来支持这种场景: ur"" and ru"" are not supported
假如当前字符是字母或者是下划线,则开始当作标示符进行分析,否则,继续执行下面的语句,处理其他的可能性。
python中的字符串可以是用r/u/f开头,如r"string", u"string",f"string",r
代表raw string, u
代表unicode string,f
代表格式化字符串常量
一旦遇到了r或者u的情况下,直接跳转到letter_quote标号处,开始作为字符串进行分析。
由于最后一次拿到的字符不属于当前标示符,应该被放到下一次进行分析,因此调用tok_backup把字符c回送到缓冲区中,类似ungetch()。最后,设置好p_start & p_end,返回NAME。这样,返回的结果表明下一个token是NAME,开始于p_start,结束于p_end。
tok_nextc
tok_nextc
负责从缓冲区中取出下一个字符,可以说是整个PyTokenizer的最核心的部分。
/* Get next char, updating state; error code goes into tok->done */
static int
tok_nextc(register struct tok_state *tok)
{
for (;;) {
if (tok->cur != tok->inp) {
// cur没有移动到inp,直接返回*tok->cur++
return Py_CHARMASK(*tok->cur++); /* Fast path */
}
if (tok->fp == NULL) {
// 字符串模式
}
if (tok->prompt != NULL) {
// 交互模式
}
else {
// 磁盘文件模式
}
}
}
大部分情况,tok_nextc会直接返回*tok->cur++,直到tok->cur移动到达tok->inp。一旦tok->cur==tok->inp,tok_nextc会读入下一行。根据PyTokenizer处于模式的不同,处理方式会不太一样
python词法分析直观展示
其中第二列是Token的类型,第三列是Token对应的字符串。各种Token类型的定义可以在 Grammar/Tokens 文件中找到
语法分析
语法分析方法
语法分析的核心知识点:两个基本功和两种算法思路
- 两个基本功:第一,必须能够阅读和书写语法规则,也就是掌握上下文无关文法;第二,必须掌握递归下降算法
- 两种算法思路:一种是自定向下的语法分析,另一种是自地向上的语法分析
上下文无关文法(Contex-Free Grammar)
在开始语法分析之前,需要解决的第一个问题:如何表达语法规则?
以下面程序为例,里面用到了变量声明语句,加法表达式,看下语法规则怎么写。
int a = 2;
int b = a + 3;
return b;
- 第一种写法如下,跟词法规则差不多,左边表示规则名称,右边是正则表达式
start:blockStmts ; //起始
block : '{' blockStmts '}' ; //语句块
blockStmts : stmt* ; //语句块中的语句
stmt = varDecl | expStmt | returnStmt | block; //语句
varDecl : type Id varInitializer? ';' ; //变量声明
type : Int | Long ; //类型
varInitializer : '=' exp ; //变量初始化
expStmt : exp ';' ; //表达式语句
returnStmt : Return exp ';' ; //return语句
exp : add ; //表达式
add : add '+' mul | mul; //加法表达式
mul : mul '*' pri | pri; //乘法表达式
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
在语法规则中,冒号左边的叫做非终结符(Non-terminal),又叫变元(Variable)。 非终结符可以按照右边的正则表达式来逐步展开,直到最后都变成了标识符,字面量,运算符等这些不可展开的符号,就是终结符(Terminal),终结符其实就是词法分析中形成的Token
像这样左边是非终结符,右边是正则表达式的书写语法规则的方式,就叫做扩展巴科斯范式(EBNF)。
- 第二种写法,产生式(Production Relu),又叫做替换规则(Substitution Rule)
产生式的左边是非终结符(变元),可以用右边的部分替代,中间通常用箭头链接
add -> add + mul
add -> mul
mul -> mul * pri
mul -> pri
有个偷懒的写法,就是把同一个变元的多个产生式写在一起,用竖线分割(但此时,如果产生式里原本就要用到"|"总结符,那就要用引号来及你想那个区分)
add -> add + mul | mul
mul -> mul * pri | pri
在产生式中,不用"" 和 "+" 来表示重复,而是引入" ε"(空字符串),所以“blockStmts : stmt”可以写成下面这个样子:
blockStmts -> stmt blockStmts | ε
总结起来,语法规则是由4个组成部分组成:
-
一个有穷的非终结符(或变元)的集合;
-
一个有穷的终结符的集合;
-
一个有穷的产生式集合;
-
一个起始非终结符(变元)。
那么符合这四个特点的文法规则,就叫做上下文无关文法(Context-Free Grammar,CFG)。
上下文无关文法和词法分析中用到的正则文法是否有一定的关系?
答案是有的,正则文法是上下文无关文法的一个子集。其实,这个正则文法也可以写成产生式的格式。比如,数字字面量(正则表达式为:"[0-9]+")可以写成:
IntLiteral -> Digit IntLiteral1
IntLiteral1 -> Digit IntLiteral1
IntLiteral1 -> ε
Digit -> [0-9]
但在上下文无关文法中,产生式的右边可以放置任意的终结符和非终结符,而正则文法只是其中的一个子集,叫做线性文法(Linear Grammar)。它的特点就是产生式的右边部分最多 只有一个非终结符,比如X->aYb,其中a和b是终结符
正则文法是上下文无关文法的子集:
上下文相关文法:存在上下文相关的,比如,在高级语言中,本地变量必须先声明,才能在后面使用,这种制约关系就是上下文相关的
在语法分析阶段,并不关注上下文之间的依赖关系,这样使得语法分析的任务更加简单。至于上下文相关的情况,就交给语义分析阶段去处理了
接下来,就要根据语法规则,编写语法分析程序,把Token串转化为AST
梯度下降算法(Recursive Descent Parsing)
基本思路就是按照语法规则去匹配Token串
举例:
变量声明语句的规则如下:
varDecl : types Id varInitializer? ';' ; //变量声明
varInitializer : '=' exp ; //变量初始化
exp : add ; //表达式
add : add '+' mul | mul; //加法表达式
mul : mul '*' pri | pri; //乘法表达式
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
写成产生式格式,如下:
varDecl -> types Id varInitializer ';'
varInitializer -> '=' exp
varInitializer -> ε
exp -> add
add -> add + mul
add -> mul
mul -> mul * pri
mul -> pri
pri -> IntLiteral
pri -> Id
pri -> ( exp )
基于这个规则做解析的算法如下:
匹配一个数据类型(types)
匹配一个标识符(Id),作为变量名称
匹配初始化部分(varInitializer),而这会导致下降一层,使用一个新的语法规则:
匹配一个等号
匹配一个表达式(在这个步骤会导致多层下降:exp->add->mul->pri->IntLiteral)
创建一个varInitializer对应的AST节点并返回
如果没有成功地匹配初始化部分,则回溯,匹配ε,也就是没有初始化部分。
匹配一个分号
创建一个varDecl对应的AST节点并返回
用上述算法解析"int a = 2",就会生成下面的AST:
总结起来,递归下降算法的特点如下:
-
对于一个非终结符,要从左到右依次匹配其产生式中的每个项,包括非终结符和终结符。
-
在匹配产生式右边的非终结符时,要下降一层,继续匹配该非终结符的产生式
-
如果一个语法规则有多个可选的产生式,那么只要有一个产生式匹配成功就行。如果一个产生式匹配不成功,那就回退回来,尝试另一个产生式。这种回退过程,叫做回溯(Backtracking)。
递归下降非常容易理解,能够有效处理多种语法规则,但它有两个缺点:
- 第一个缺点:就是著名的左递归(Left Recursion)问题。
什么是左递归问题呢?
比如,在匹配算术表达式时,产生式的第一项是就是非终结符add,那按照算法,要下降一层,继续匹配add.这个过程会一直持续下去,无限递归。
所以递归下降算法是无法处理左递归问题的。那改成有递归,也就是把add这个递归项放在右边 -- 虽然规避了左递归的问题,但同时又导致了结合性的问题
那怎么办呢?
把递归调用转换成循环在EBNF格式中,允许使用"" 号和"+"号表示重复:
对于('+'mul)这部分,可以写成一个循环,在这个循环里,可以根据结合性的要求,手工生成正确的AST,它的伪代码如下:
左子节点 = 匹配一个mul
while(下一个Token是+){
消化掉+
右子节点 = 匹配一个mul
用左、右子节点创建一个add节点
左子节点 = 该add节点
}
创建正确的AST如下图所示:
- 第二个缺点:就是当产生式匹配失败的时候,必须要“回溯”,这就可能导致浪费。
LL算法:计算First和Follow集合
LL算法家族可自动计算出选择不同产生式的依据
LL算法的要点:计算First和Follow集合
First集合是每个产生式开头可能出现的Token集合,像stmt有三个产生式,它的First集合如下:
而 stmt 的 First 集合,就是三个产生式的 First 集合的并集,也是 Int Long IntLiteral Id ( Return。
总体来说,针对对非终结符x,它的First集合的计算规则如下:
- 如果产生式以终结符开头,那么把这个终结符加入 First(x);
- 如果产生式以非终结符 y 开头,那么把 First(y) 加入 First(x);
- 如果 First(y) 包含ε,那要把下一个项的 First 集合也加入进来,以此类推;
- 如果 x 有多个产生式,那么 First(x) 是每个产生式的并集。
在计算First集合时,具体采用"不动点法" ,可参考实例程序FirstFollowSet类的 CalcFirstSets() 方法,运行示例程序能打印各个非终结符的 First 集合。
有种特殊的情况需要考虑,那就是对于某个非终结符,它自身会产生的ε情况
比如,示例文法中的blockStmts,它是可能产生ε的,也就是块中一个语句都没有。
block : '{' blockStmts '}' ; //语句块
blockStmts : stmt* ; //语句块中的语句
stmt = varDecl | expStmt | returnStmt; //语句
语法解析器在这个时候预读的下一个 Token 是什么呢?是右花括号。这证明 blockStmts 产生了ε,所以才读到了后续跟着的花括号。
对于某个非终结符后面可能跟着的 Token 的集合,我们叫做 Follow 集合。如果预读到的 Token 在 Follow 中,那么我们就可以判断当前正在匹配的这个非终结符,产生了ε。
Follow的算法规则如下:(以非终结符 x为例)
-
扫描语法规则,看看 x 后面都可能跟着哪些符号;
-
对于后面跟着的终结符,都加到 Follow(x) 集合中去;
-
如果后面是非终结符 y,就把 First(y) 加 Follow(x) 集合中去;
-
最后,如果 First(y) 中包含ε,就继续往后找;
-
如果 x 可能出现在程序结尾,那么要把程序的终结符 $ 加入到 Follow(x) 中去。
这样在计算了 First 和 Follow 集合之后,你就可以通过预读一个 Token,来完全确定采用哪个产生式。这种算法,就叫做 LL(1) 算法。
LL(1) 中的第一个 L,是 Left-to-right 的缩写,代表从左向右处理 Token 串。第二个 L,是 Leftmost 的缩写,意思是最左推导, LL(1)中的1,指的是预读一个Token
最左推导:就是它总是先把产生式中最左侧的非终结符展开完毕后,再展开下一个,相当于对AST从左子节点开始的深度优先遍历
LR算法:移进和规约
前面讲的递归下降和LL算法,都是自顶向下的算法,自底向上的其中代表就是LR算法
LR含义解读: L 还是代表从左到右读入 Token,而 R 是最右推导(Rightmost)的意思
自顶向下的算法,是从根节点逐层往下分解,形成最后的AST;而LR算法的原理则是从底下先拼凑出AST的一些局部拼图,并逐步组装成一颗完整的AST,所以,其中的关键在于如何"拼凑"
假设采用下面的上下文无关文法,推演一个实例,具体语法规则如下:
如果用来解析"2+3*5",最终会形成下面的AST:
那算法是如何从底部拼凑出来这棵AST呢?
LR 算法和 LL 算法一样,也是从左到右地消化掉 Token。
在第 1 步,它会取出“2”这个 Token,放到一个栈里,这个栈是用来组装 AST 的工作区。同时,它还会预读下一个 Token,也就是“+”号,用来帮助算法做判断。
在下面的示意图里,画了一条橙色竖线,竖线的左边是栈,右边是预读到的一个 Token。在做语法解析的过程中,竖线会不断地往右移动,把 Token 放到栈里,这个过程叫做“移进”(Shift)。
第一步,移进一个Token:
注意:在上图中使用虚线推测了AST的其他部分。也就是如果第一个Token遇到的是整型字面量,而后面跟着一个+号,那么这两个Token就决定了它们必然是这棵推测出来的AST的一部分。而图中右边就是它的推导过程,其中的每个步骤,都是用了一个产生式加了一个点(如".add"),这个点相当于图中左边的橙色竖线
可根据这棵假想的AST,即依据推想的推导过程,给它反推回去 ,把Int还原成pri,这个还原过程就叫做"规约(Reduce)"。工作区里的元素也随之更新成pri
第2步:Int规约为pri
按照这样的思路,不断地移进和规约,这棵 AST 中推测出来的节点会不断地被证实。而随着读入的 Token 越来越多,这棵 AST 也会长得越来越高,整棵树变得更大。下图是推导过程中间的一个步骤。
移进和规约过程中的一个步骤:
最后,整个AST构造完毕,而工作区里也就只剩下一个Start节点。
最后一步,add规约为start
2+3*5”最右推导的过程写在了下面,而如果你从最后一行往前一步步地看,它恰好就是规约的过程。
如果你见到 LR(k),那它的意思就是会预读 k 个 Token,我们在示例中采用的是 LR(1)
注:
相对于 LL 算法,LR 算法的优点是能够处理左递归文法。但它也有缺点,比如不利于输出全面的编译错误信息。因为在没有解析完毕之前,算法并不知道最后的 AST 是什么样子,所以也不清楚当前的语法错误在整体 AST 中的位置。
Python的语法分析功能
继续使用GDB跟踪执行过程,会在parser.c中找到语法分析的相关逻辑:
先来看下,Grammar文件,这个是用EBNF语法编写的Python语法规则文件。
//声明函数
funcdef: 'def' NAME parameters ['->' test] ':' [TYPE_COMMENT] func_body_suite
//语句
simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt |
import_stmt | global_stmt | nonlocal_stmt | assert_stmt)
该规则文件,实际上python的编译器本身并不使用它,它是给一个pgen的工具程序(Parser/pgen)使用的,
这个程序能够基于语法规则生成解析表(Parse Table),供语法分析程序使用。
有了 pgen 这个工具,你就可以通过修改规则文件来修改 Python 语言的语法,比如,你可以把函数声明中的关键字“def”换成“function”,这样你就可以用新的语法来声明函数。
在Makefile.pre.in文件中可找到该工具的编译命令,最后生成:Lib/keyword.py文件:
pgen能给生成新的语法解析器(在parser.c的注释中讲解了它的工作原理):它是把EBNF转化为一个NFA,然后再把这个NFA转化为DFA,基于这个DFA,在读取Token的时候,编译器就知道如何做状态迁移,并生成解析树
Python 用的是 LL(1) 算法。LL(1) 算法的特点:针对每条语法规则,最多预读一个 Token,编译器就可以知道该选择哪个产生式。这其实就是一个 DFA,从一条语法规则,根据读入的 Token,迁移到下一条语法规则。
通过一个例子,来看下一个python的语法分析特点,语法规则如下:
add: mul ('+' mul)*
mul: pri ('*' pri)*
pri: IntLiteral | '(' add ')'
其对应DFA如下:
add: mul ('+' mul)*对应的DFA:
mul: pri ('' pri)对应的DFA:
pri: IntLiteral | '(' add ')'对应的DFA:
parser.c 用了一个通用的函数去解析所有的语法规则,它所依据的就是为每个规则所生成的 DFA。
主要的实现逻辑是在** parser.c ** 的PyParser_AddToken()
函数里
为了便于理解,模仿 Python 编译器,用上面的文法规则解析了一下“2+3*4+5”,并把整个解析过程画成图。
在解析的过程,用了一个栈作为一个工作区,来保存当前解析过程中使用的 DFA。
第1步,匹配add规则。 把add对应的DFA压到栈里,此刻DFA处于状态0,这时候预读了一个Token,是字面量2
第2步,根据add的DFA,走mul-1这条边,去匹配mul规则。 这时把mul对应的DFA入栈。在示意图中,栈从上往下延伸的。
第3步,根据mul的DFA,走pri-1这条边,去匹配pri规则。 这时把pri对应的DFA入栈。
第4步,根据pri的DFA,因为预读的Token是字面量2,所以移进这个字面量,并迁移到状态3。同时,为字面量2建立解析树的节点。 这个时候,又会预读下一个Token,'+'号
第 5 步,从栈里弹出 pri 的 DFA,并建立 pri 节点。 因为成功匹配了一个 pri,所以 mul 的 DFA 迁移到状态 1。
第 6 步,因为目前预读的 Token 是'+'号,所以 mul 规则匹配完毕,把它的 DFA 也从栈里弹出。 而 add 对应的 DFA 也迁移到了状态 1。
第 7 步,移进'+'号,把 add 的 DFA 迁移到状态 2,预读了下一个 Token:字面量 3。 这个 Token 是在 mul 的 First 集合中的,所以就走 mul-2 边,去匹配一个 mul。
按照这个思路继续做解析,直到最后,可以得到完整的解析树:
总结起来,Python 编译器采用了一个通用的语法分析程序,以一个栈作为辅助的数据结构,来完成各个语法规则的解析工作。当前正在解析的语法规则对应的 DFA,位于栈顶。一旦当前的语法规则匹配完毕,那语法分析程序就可以把这个 DFA 弹出,退回到上一级的语法规则。
经过上面的语法分析,形成的结果叫做解析树(Parse Tree), 又可叫做CST(Concrete Syntax Tree,具体语法树),和AST(抽象语法树)是相对的:一个具体,一个抽象
它们两个的区别在于:CST精确地反映了语法规则的推导过程,而AST则更准确地表达了程序的结构。如果说CST是"形似",那AST就是"神似"。
前面例子形成的CST的特点:
首先,加法是个二元运算符,但在这里 add 节点下面对应了两个加法运算符,跟原来加法的语义不符。第二,很多节点都只有一个父节点,这个其实可以省略,让树结构更简洁。
所期待的AST是这个样子的:
python的语法分析生成的是CST, 而不是AST,之后Python会调用PyAst_FromNode将CST转换成AST.
CST结构与方法
CST的结点成为Node,其结构定义在node.h中:
typedef struct _node {
short n_type;
char *n_str;
int n_lineno;
int n_col_offset;
int n_nchildren;
struct _node *n_child;
int n_end_lineno;
int n_end_col_offset;
} node;
Python提供了下面的函数/宏来操作CST,同样定义在node.h中
PyAPI_FUNC(node *) PyNode_New(int type);
PyAPI_FUNC(int) PyNode_AddChild(node *n, int type,
char *str, int lineno, int col_offset,
int end_lineno, int end_col_offset);
PyAPI_FUNC(void) PyNode_Free(node *n);
#ifndef Py_LIMITED_API
PyAPI_FUNC(Py_ssize_t) _PyNode_SizeOf(node *n);
#endif
/* Node access functions */
#define NCH(n) ((n)->n_nchildren)
#define CHILD(n, i) (&(n)->n_child[i])
#define RCHILD(n, i) (CHILD(n, NCH(n) + i))
#define TYPE(n) ((n)->n_type)
#define STR(n) ((n)->n_str)
#define LINENO(n) ((n)->n_lineno)
/* Assert that the type of a node is what we expect */
#define REQ(n, type) assert(TYPE(n) == (type))
PyAPI_FUNC(void) PyNode_ListTree(node *);
void _PyNode_FinalizeEndPos(node *n); // helper also used in parsetok.c
PyNode_New
和PyNode_Free
负责创建和释放Node
结构:
node *
PyNode_New(int type)
{
node *n = (node *) PyObject_MALLOC(1 * sizeof(node));
if (n == NULL)
return NULL;
n->n_type = type;
n->n_str = NULL;
n->n_lineno = 0;
n->n_end_lineno = 0;
n->n_end_col_offset = -1;
n->n_nchildren = 0;
n->n_child = NULL;
return n;
}
void
PyNode_Free(node *n)
{
if (n != NULL) {
freechildren(n);
PyObject_FREE(n);
}
}
static void
freechildren(node *n)
{
int i;
for (i = NCH(n); --i >= 0; )
freechildren(CHILD(n, i));
if (n->n_child != NULL)
PyObject_FREE(n->n_child);
if (STR(n) != NULL)
PyObject_FREE(STR(n));
}
NCH/CHILD/RCHILD/TYPE/STR是用来封装对node的成员的访问的。需要提一下的是,CHILD(n, i)
是从左边开始算,传入i的是正数,而RCHILD(n, i)
则是从右边往左,传入的参数i是负数。
PyNode_AddChild
将一个新的子结点加入到子结点数组中。由于结点数量是动态变化的,因此在当前分配的结点数组大小不够的时候,Python会调用realloc重新分配内存。内存分配是一个非常耗时的动作,因此Python在PyNode_AddChild之中用到了和std::vector类似的技巧来尽量减少内存分配的次数,每次增长的时候都会根据某个规则进行RoundUp,而不是需要多少就分配多少。
XXXROUNDUP函数
负责进行此运算。n<=1时, 返回n。1<n<=128的时候,会RoundUp到4的倍数。n>128, 会调用fancy_roundup来RoundUp到2的幂。
#define XXXROUNDUP(n) ((n) <= 1 ? (n) : \
(n) <= 128 ? (int)_Py_SIZE_ROUND_UP((n), 4) : \
fancy_roundup(n))
/* Round up size "n" to be a multiple of "a". */
#define _Py_SIZE_ROUND_UP(n, a) (((size_t)(n) + \
(size_t)((a) - 1)) & ~(size_t)((a) - 1))
使用XXXROUNDUP
函数计算出来当前的最大容量
分配完空间后,就可以开始赋值了
n = &n1->n_child[n1->n_nchildren++];
n->n_type = type;
n->n_str = str;
n->n_lineno = lineno;
n->n_col_offset = col_offset;
n->n_end_lineno = end_lineno; // this and below will be updates after all children are added.
n->n_end_col_offset = end_col_offset;
n->n_nchildren = 0;
n->n_child = NULL;
PyParser
PyParser
的主要任务就是要根据Token生成CST
整个树的生成过程就是一个遍历语法图的过程。
语法图是由多个DFA组成,而输入的token和当前所处的状态结点可以决定下一个状态结点。由于PyParser是在多个DFA中遍历,因此当结束了某个DFA的遍历需要回到上一个DFA,这些信息都是由一个专门的栈保存着
PyParser
所对应的结构是parser_state
,这个结构保存着PyParser的内部状态,如下:
typedef struct {
stack p_stack; /* Stack of parser states */
grammar *p_grammar; /* Grammar to use */
node *p_tree; /* Top of parse tree */
#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD
unsigned long p_flags; /* see co_flags in Include/code.h */
#endif
} parser_state;
状态栈的定义如下:
typedef struct {
stackentry *s_top; /* Top entry */
stackentry s_base[MAXSTACK];/* Array of stack entries */
/* NB The stack grows down */
} stack;
状态栈中的状态如下:
typedef struct {
int s_state; /* State in current DFA */
const dfa *s_dfa; /* Current DFA */
struct _node *s_parent; /* Where to add next node */
} stackentry;
PyParser可调用这些函数来操作栈:
/* STACK DATA TYPE */
static void s_reset(stack *);
#define s_empty(s) ((s)->s_top == &(s)->s_base[MAXSTACK])
static int
s_push(stack *s, const dfa *d, node *parent);
#define s_pop(s) (s)->s_top++
PyParser所支持的"成员"函数如下:
parser_state *PyParser_New(grammar *g, int start);
void PyParser_Delete(parser_state *ps);
int PyParser_AddToken(parser_state *ps, int type, char *str,
int lineno, int col_offset,
int end_lineno, int end_col_offset,
int *expected_ret);
void PyGrammar_AddAccelerators(grammar *g);
PyParser_New
& PyParser_Delete
显然是用于创建和销毁PyParser的实例的,和PyTokenizer一致。
PyGrammar_AddAccelerators
主要用于处理python的Grammar数据生成Accelerator加快语法分析错误。
其中最核心的是PyParser_AddToken
,这个函数的作用是根据PyTokenizer所获得的token和当前所处的状态/DFA,跳转到下一个状态,并添加到CST中。在parsetok函数中,有如下的代码(省略了大部分):
parser_state *ps;
ps = PyParser_New(g, start);
for (;;) {
char *a, *b;
int type;
type = PyTokenizer_Get(tok, &a, &b);
PyParser_AddToken(ps, (int)type, str, tok->lineno, col_offset, &(err_ret->expected));
}
Parsetok的作用是分析某段代码。可以看到,parsetok会反复调用PyTokenizer_Get获得下一个token,然后将反复将获得的token传给PyParser_AddToken来逐步构造整个CST,当所有token都处理过了之后,整棵树也就建立完毕了。
PyParser API
Python不会直接调用PyParser
和PyTokenizer
的函数,而是直接调用下面的这些Python API:
PyAPI_FUNC(node *) PyParser_ParseString(const char *, grammar *, int,
perrdetail *);
PyAPI_FUNC(node *) PyParser_ParseFile (FILE *, const char *, grammar *, int,
char *, char *, perrdetail *);
PyAPI_FUNC(node *) PyParser_ParseStringFlags(const char *, grammar *, int,
perrdetail *, int);
PyAPI_FUNC(node *) PyParser_ParseFileFlags(FILE *, const char *, grammar *,
int, char *, char *,
perrdetail *, int);
PyAPI_FUNC(node *) PyParser_ParseStringFlagsFilename(const char *,
const char *,
grammar *, int,
perrdetail *, int);
/* Note that he following function is defined in pythonrun.c not parsetok.c. */
PyAPI_FUNC(void) PyParser_SetError(perrdetail *);
PyAPI_FUNC宏
是用于定义公用的Python API,表明这些函数可以被外界调用。在Windows上面Python Core被编译成一个DLL,因此PyAPI_FUNC等价于大家常用的__declspec(dllexport)/__declspec(dllimport)
。
这些函数把PyParser和PyTokenizer对象的接口和细节包装起来,使用者可以直接调用PyParser_ParseXXXX函数来使用PyParser和PyTokenizer的功能而无需知道PyPaser/PyTokenizer的工作方式,这可以看作是一个典型的Façade模式
PyParser_ParseFile为例,该函数分析传入的FILE返回生成的CST。其他的函数与此类似,只是分析的对象不同和传入参数的不同。
PyParser_AddToken implementation
PyParser_AddToken会调用3个内部函数来做处理:classify, push, shift
Classify
根据type和str,确定对应的Label,实现如下:
static int
classify(parser_state *ps, int type, const char *str)
{
grammar *g = ps->p_grammar;
int n = g->g_ll.ll_nlabels;
if (type == NAME) {
const label *l = g->g_ll.ll_label;
int i;
for (i = n; i > 0; i--, l++) {
if (l->lb_type != NAME || l->lb_str == NULL ||
l->lb_str[0] != str[0] ||
strcmp(l->lb_str, str) != 0)
continue;
#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD
#if 0
/* Leaving this in as an example */
if (!(ps->p_flags & CO_FUTURE_WITH_STATEMENT)) {
if (str[0] == 'w' && strcmp(str, "with") == 0)
break; /* not a keyword yet */
else if (str[0] == 'a' && strcmp(str, "as") == 0)
break; /* not a keyword yet */
}
#endif
#endif
D(printf("It's a keyword\n"));
return n - i;
}
}
{
const label *l = g->g_ll.ll_label;
int i;
for (i = n; i > 0; i--, l++) {
if (l->lb_type == type && l->lb_str == NULL) {
D(printf("It's a token we know\n"));
return n - i;
}
}
}
D(printf("Illegal token\n"));
return -1;
}
classify作了一些针对NAME的特殊处理。有两种NAME,一种是标示符,一种是关键字。
Shift
改变当前栈顶的状态(注意并不跳转到另外的DFA,这个改变只限于单个DFA中,跳转到另外一个DFA需要调用push压栈),并把当前的type/str作为一个新的子结点加入到栈顶的s_parent结点,通常s_parent
结点对应着当前的DFA的结点。
假设我们在DFA0中,当前栈顶为(DFA0, s1),type=NAME
从s1跳转到s2不会离开DFA0,不用进栈,只需改变当前(DFA0, s1)到(DFA0, s2)即可。
static int
shift(stack *s, int type, char *str, int newstate, int lineno, int col_offset,
int end_lineno, int end_col_offset)
{
int err;
assert(!s_empty(s));
err = PyNode_AddChild(s->s_top->s_parent, type, str, lineno, col_offset,
end_lineno, end_col_offset);
if (err)
return err;
s->s_top->s_state = newstate;
return 0;
}
Push
同样也会把type/str作为新的子结点n加入到当前s_parent结点并改变当前栈顶的状态为newstate。但是newstate并非是下一个状态,而是当新的DFA遍历完毕之后退栈才会到。然后,把目标DFA压栈。新生成的子结点n作为新的s_parent。
假设当前我们处于(DFA0, s1), type/str告诉我们下一个状态为s2,label是非终结符DFA1,对应着DFA1,因此我们会把当前栈顶(DFA0, s1)修改为(DFA0, s2),然后跳转到DFA1中进行匹配,同时(DFA1, s0)作为新的栈顶压栈,当(DFA1,s0)退栈之后,说明DFA1匹配完毕,回到(DFA0, s2)。
static int
push(stack *s, int type, const dfa *d, int newstate, int lineno, int col_offset,
int end_lineno, int end_col_offset)
{
int err;
node *n;
n = s->s_top->s_parent;
assert(!s_empty(s));
err = PyNode_AddChild(n, type, (char *)NULL, lineno, col_offset,
end_lineno, end_col_offset);
if (err)
return err;
s->s_top->s_state = newstate;
return s_push(s, d, CHILD(n, NCH(n)-1));
}
接下来来看下:PyParser_AddToken
函数的实现
int
PyParser_AddToken(parser_state *ps, int type, char *str,
int lineno, int col_offset,
int end_lineno, int end_col_offset,
int *expected_ret)
{
int ilabel;
int err;
D(printf("Token %s/'%s' ... ", _PyParser_TokenNames[type], str));
/* Find out which label this token is */
ilabel = classify(ps, type, str);
if (ilabel < 0)
return E_SYNTAX;
PyParser_AddToken第一步是根据type和str,调用classify获得对应的label。
PyParser_AddToken获得了对应的label之后,进入一个for loop:
/* Loop until the token is shifted or an error occurred */
for (;;) {
/* Fetch the current dfa and state */
const dfa *d = ps->p_stack.s_top->s_dfa;
state *s = &d->d_state[ps->p_stack.s_top->s_state];
D(printf(" DFA '%s', state %d:",
d->d_name, ps->p_stack.s_top->s_state));
这个for loop反复拿到当前栈顶,也就是DFA和DFA状态,然后根据当前的状态和Label决定下一步的动作。基本的规则如下:
/* Check accelerator */
if (s->s_lower <= ilabel && ilabel < s->s_upper) {
int x = s->s_accel[ilabel - s->s_lower];
有对应的Accelerator,为x
X第8位为1,说明x对应着一个非终结符,记录着目标状态的DFA ID和状态
在这种情况下会跳转到另外一个DFA,把目标DFA+状态压栈。
if (x != -1) {
if (x & (1<<7)) {
/* Push non-terminal */
int nt = (x >> 8) + NT_OFFSET;
int arrow = x & ((1<<7)-1);
if (nt == func_body_suite && !(ps->p_flags & PyCF_TYPE_COMMENTS)) {
/* When parsing type comments is not requested,
we can provide better errors about bad indentation
by using 'suite' for the body of a funcdef */
D(printf(" [switch func_body_suite to suite]"));
nt = suite;
}
const dfa *d1 = PyGrammar_FindDFA(
ps->p_grammar, nt);
if ((err = push(&ps->p_stack, nt, d1,
arrow, lineno, col_offset,
end_lineno, end_col_offset)) > 0) {
D(printf(" MemError: push\n"));
return err;
}
D(printf(" Push '%s'\n", d1->d_name));
continue;
}
否则,x对应终结符,调用Shift来改变栈顶的状态并把结点添加到CST中。如果栈顶对应着一个Accept状态的话,说明当前DFA已经匹配完毕,反复退栈直到当前状态不为Accept状态为止。
/* Shift the token */
if ((err = shift(&ps->p_stack, type, str,
x, lineno, col_offset,
end_lineno, end_col_offset)) > 0) {
D(printf(" MemError: shift.\n"));
return err;
}
D(printf(" Shift.\n"));
/* Pop while we are in an accept-only state */
while (s = &d->d_state
[ps->p_stack.s_top->s_state],
s->s_accept && s->s_narcs == 1) {
D(printf(" DFA '%s', state %d: "
"Direct pop.\n",
d->d_name,
ps->p_stack.s_top->s_state));
#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD
#if 0
if (d->d_name[0] == 'i' &&
strcmp(d->d_name,
"import_stmt") == 0)
future_hack(ps);
#endif
#endif
s_pop(&ps->p_stack);
if (s_empty(&ps->p_stack)) {
D(printf(" ACCEPT.\n"));
return E_DONE;
}
d = ps->p_stack.s_top->s_dfa;
}
return E_OK;
如果没有Acclerator,有可能该结点已经是Accept状态,同样退栈:
if (s->s_accept) {
#ifdef PY_PARSER_REQUIRES_FUTURE_KEYWORD
#if 0
if (d->d_name[0] == 'i' &&
strcmp(d->d_name, "import_stmt") == 0)
future_hack(ps);
#endif
#endif
/* Pop this dfa and try again */
s_pop(&ps->p_stack);
D(printf(" Pop ...\n"));
if (s_empty(&ps->p_stack)) {
D(printf(" Error: bottom of stack.\n"));
return E_SYNTAX;
}
continue;
}
否则,语法错误,假如可能遇到的token只有一种可能的话,设置expected_ret为当前我们所期望看到的token,这样Python才可以显示语法错误信息。
/* Stuck, report syntax error */
D(printf(" Error.\n"));
if (expected_ret) {
if (s->s_lower == s->s_upper - 1) {
/* Only one possible expected token */
*expected_ret = ps->p_grammar->
g_ll.ll_label[s->s_lower].lb_type;
}
else
*expected_ret = -1;
}
return E_SYNTAX;
至此,PyParser的语法分析就结束了,接下来就是要将CST转换成AST以及字节码
那么,Python 是如何把 CST 转换成 AST 的呢?这个过程分为两步。
- 首先,Python 采用了一种叫做 ASDL 的语言,来定义了 AST 的结构。ASDL是“抽象语法定义语言(Abstract Syntax Definition Language)”的缩写,它可以用于描述编译器中的 IR 以及其他树状的数据结构。
这个定义文件是 Parser/Python.asdl。CPython 编译器中包含了两个程序(Parser/asdl.py 和 Parser/asdl_c.py)来解析 ASDL 文件,并生成 AST 的数据结构。最后的结果在 Include/Python-ast.h 文件中。
在Makefile.pre.in文件中可看到,Include/Python-ast.h
正是通过 Parser/asdl_c.py -h
解析生成的
.PHONY=regen-ast
regen-ast:
# Regenerate Include/Python-ast.h using Parser/asdl_c.py -h
$(MKDIR_P) $(srcdir)/Include
$(PYTHON_FOR_REGEN) $(srcdir)/Parser/asdl_c.py \
-h $(srcdir)/Include/Python-ast.h.new \
$(srcdir)/Parser/Python.asdl
$(UPDATE_FILE) $(srcdir)/Include/Python-ast.h $(srcdir)/Include/Python-ast.h.new
# Regenerate Python/Python-ast.c using Parser/asdl_c.py -c
$(MKDIR_P) $(srcdir)/Python
$(PYTHON_FOR_REGEN) $(srcdir)/Parser/asdl_c.py \
-c $(srcdir)/Python/Python-ast.c.new \
$(srcdir)/Parser/Python.asdl
$(UPDATE_FILE) $(srcdir)/Python/Python-ast.c $(srcdir)/Python/Python-ast.c.new
在Include/Python-ast.h
定义了AST所用到的类型,以stmt_ty类型为例:
enum _stmt_kind {FunctionDef_kind=1, AsyncFunctionDef_kind=2, ClassDef_kind=3,
Return_kind=4, Delete_kind=5, Assign_kind=6,
AugAssign_kind=7, AnnAssign_kind=8, For_kind=9,
AsyncFor_kind=10, While_kind=11, If_kind=12, With_kind=13,
AsyncWith_kind=14, Raise_kind=15, Try_kind=16,
Assert_kind=17, Import_kind=18, ImportFrom_kind=19,
Global_kind=20, Nonlocal_kind=21, Expr_kind=22, Pass_kind=23,
Break_kind=24, Continue_kind=25};
struct _stmt {
enum _stmt_kind kind;
union {
struct {
identifier name;
arguments_ty args;
asdl_seq *body;
asdl_seq *decorator_list;
expr_ty returns;
string type_comment;
} FunctionDef;
struct {
identifier name;
arguments_ty args;
asdl_seq *body;
asdl_seq *decorator_list;
expr_ty returns;
string type_comment;
} AsyncFunctionDef;
struct {
identifier name;
asdl_seq *bases;
asdl_seq *keywords;
asdl_seq *body;
asdl_seq *decorator_list;
} ClassDef;
// . . . 过长,省略
} v;
int lineno;
int col_offset;
int end_lineno;
int end_col_offset;
};
typedef struct _stmt *stmt_ty;
stmt_ty
是语句结点类型,实际上就是_stmt
结构的指针,_stmt结构比较长,但有着很清晰的Pattern:
- 第一个Field为kind,代表语句的类型,_stmt_kind定义了_stmt的所有可能的语句类型,从函数定义语句,类定义语句直到Continue语句共有25种类型。
- 第二个Field为union v,每个成员都是struct,分别对应
_stmt_kind
中的一种类型,如_stmt.v.FunctionDef
对应了_stmt_kind
枚举中的FunctionDef_Kind,也就是说,当_stmt.kind == FunctionDef_Kind时,_stmt.v.FunctionDef中保存的就是对应的函数定义语句的具体内容。- 剩下的就是其他数据,如lineno,co_offset等
大部分的AST结点类型都是按照类似的pattern来定义的。除此之外,还有一个种比较简单的AST类型如operator_ty,expr_context_ty等,由于这些类型仍以_ty结尾,因此也可以认为是AST的结点,但实际上,这些类型只是简单的枚举类型,并非指针。因此在以后的文章中,并不把此类AST类型作为结点看待,而是作为简单的枚举处理。
由于每个AST类型会在union中引用其他的AST,这样层层引用,最后便形成了一颗AST树,试举例如下:
这颗AST树代表的是单条语句a+1。
与AST类型对应,在python_ast.h/c中定义了大量用于创建AST结点的函数,可以看作是AST结点的构造函数。以BinOp为例
expr_ty
BinOp(expr_ty left, operator_ty op, expr_ty right, int lineno, int col_offset,
int end_lineno, int end_col_offset, PyArena *arena)
{
expr_ty p;
if (!left) {
PyErr_SetString(PyExc_ValueError,
"field left is required for BinOp");
return NULL;
}
if (!op) {
PyErr_SetString(PyExc_ValueError,
"field op is required for BinOp");
return NULL;
}
if (!right) {
PyErr_SetString(PyExc_ValueError,
"field right is required for BinOp");
return NULL;
}
p = (expr_ty)PyArena_Malloc(arena, sizeof(*p));
if (!p)
return NULL;
p->kind = BinOp_kind;
p->v.BinOp.left = left;
p->v.BinOp.op = op;
p->v.BinOp.right = right;
p->lineno = lineno;
p->col_offset = col_offset;
p->end_lineno = end_lineno;
p->end_col_offset = end_col_offset;
return p;
}
此函数只是根据传入的参数做一些简单的错误检查,分配内存,初始化对应的expr_ty类型,并返回指针。
asdl_seq & asdl_int_seq
在stmt_ty
定义中,可以发现其中大量的用到了adsl_seq类型。
adsl_seq
& adsl_int_seq
简单来说就是一个动态构造出来的定长数组。
adsl_seq
是void *的数组:
typedef struct {
Py_ssize_t size;
void *elements[1];
} asdl_seq;
而asdl_int_seq
是int类型的数组:
typedef struct {
Py_ssize_t size;
int elements[1];
} asdl_int_seq;
size
为Py_ssize_t
类型的,其实它就是一个long int 类型的变量,elements
为数组的元素。定义elements数组长度为1,而在动态分配内存时则是按照实际长度sizeof(adsl_seq)+sizeof(void *) * (size - 1)
来分配的:
asdl_seq *
_Py_asdl_seq_new(Py_ssize_t size, PyArena *arena)
{
asdl_seq *seq = NULL;
size_t n;
/* check size is sane */
if (size < 0 ||
(size && (((size_t)size - 1) > (SIZE_MAX / sizeof(void *))))) {
PyErr_NoMemory();
return NULL;
}
n = (size ? (sizeof(void *) * (size - 1)) : 0);
/* check if size can be added safely */
if (n > SIZE_MAX - sizeof(asdl_seq)) {
PyErr_NoMemory();
return NULL;
}
n += sizeof(asdl_seq);
seq = (asdl_seq *)PyArena_Malloc(arena, n);
if (!seq) {
PyErr_NoMemory();
return NULL;
}
memset(seq, 0, n);
seq->size = size;
return seq;
}
这样既可以动态分配数组元素,也可以很方便的用elements来访问数组元素。
用以下的宏和函数可以操作adsl_seq / adsl_int_seq
:
asdl_seq *_Py_asdl_seq_new(Py_ssize_t size, PyArena *arena);
asdl_int_seq *_Py_asdl_int_seq_new(Py_ssize_t size, PyArena *arena);
#define asdl_seq_GET(S, I) (S)->elements[(I)]
#define asdl_seq_LEN(S) ((S) == NULL ? 0 : (S)->size)
#ifdef Py_DEBUG
#define asdl_seq_SET(S, I, V) \
do { \
Py_ssize_t _asdl_i = (I); \
assert((S) != NULL); \
assert(0 <= _asdl_i && _asdl_i < (S)->size); \
(S)->elements[_asdl_i] = (V); \
} while (0)
#else
#define asdl_seq_SET(S, I, V) (S)->elements[I] = (V)
#endif
注:
adsl_seq / adsl_int_seq均是从PyArena中分配出
- 在有了 AST 的数据结构以后,第二步,是把 CST 转换成 AST,这个工作是在 Python/ast.c 中实现的,入口函数是 PyAST_FromNode()。
PyAST_FromNode函数的大致代码如下:
此函数会深度遍历整棵CST,过滤掉CST中的多余信息,只是将有意义的CST子树转换成AST结点构造出AST树。
mod_ty
PyAST_FromNodeObject(const node *n, PyCompilerFlags *flags,
PyObject *filename, PyArena *arena)
{
int i, j, k, num;
asdl_seq *stmts = NULL;
asdl_seq *type_ignores = NULL;
stmt_ty s;
node *ch;
struct compiling c;
mod_ty res = NULL;
asdl_seq *argtypes = NULL;
expr_ty ret, arg;
c.c_arena = arena;
/* borrowed reference */
c.c_filename = filename;
c.c_normalize = NULL;
c.c_feature_version = flags ? flags->cf_feature_version : PY_MINOR_VERSION;
if (TYPE(n) == encoding_decl)
n = CHILD(n, 0);
k = 0;
switch (TYPE(n)) {
case file_input:
stmts = _Py_asdl_seq_new(num_stmts(n), arena);
if (!stmts)
goto out;
for (i = 0; i < NCH(n) - 1; i++) {
ch = CHILD(n, i);
if (TYPE(ch) == NEWLINE)
continue;
REQ(ch, stmt);
num = num_stmts(ch);
if (num == 1) {
s = ast_for_stmt(&c, ch);
if (!s)
goto out;
asdl_seq_SET(stmts, k++, s);
}
else {
ch = CHILD(ch, 0);
REQ(ch, simple_stmt);
for (j = 0; j < num; j++) {
s = ast_for_stmt(&c, CHILD(ch, j * 2));
if (!s)
goto out;
asdl_seq_SET(stmts, k++, s);
}
}
}
/* Type ignores are stored under the ENDMARKER in file_input. */
ch = CHILD(n, NCH(n) - 1);
REQ(ch, ENDMARKER);
num = NCH(ch);
type_ignores = _Py_asdl_seq_new(num, arena);
if (!type_ignores)
goto out;
for (i = 0; i < num; i++) {
string type_comment = new_type_comment(STR(CHILD(ch, i)), &c);
if (!type_comment)
goto out;
type_ignore_ty ti = TypeIgnore(LINENO(CHILD(ch, i)), type_comment, arena);
if (!ti)
goto out;
asdl_seq_SET(type_ignores, i, ti);
}
res = Module(stmts, type_ignores, arena);
break;
case eval_input: {
...
case single_input:
...
break;
case func_type_input:
...
break;
default:
PyErr_Format(PyExc_SystemError,
"invalid node %d for PyAST_FromNode", TYPE(n));
goto out;
}
out:
if (c.c_normalize) {
Py_DECREF(c.c_normalize);
}
return res;
}
mod_ty
PyAST_FromNode(const node *n, PyCompilerFlags *flags, const char *filename_str,
PyArena *arena)
{
mod_ty mod;
PyObject *filename;
filename = PyUnicode_DecodeFSDefault(filename_str);
if (filename == NULL)
return NULL;
mod = PyAST_FromNodeObject(n, flags, filename, arena);
Py_DECREF(filename);
return mod;
}
PyAst_FromNode根据N的类型作了不同处理,以file_input为例,file_input的产生式(在Grammar文件中定义)如下:
file_input: (NEWLINE | stmt)* ENDMARKER
对应的PyAST_FromNode
的代码做了如下事情:
num_stmts(n)
计算出所有顶层语句的个数,并创建出合适大小的adsl_seq结构以存放这些语句- 对file_input结点的所有子节点做如下处理:
- 忽略掉NEW_LINE,换行无需处理
REQ(ch, stmt);
断言ch的类型必定为stmt,从产生式可以得此结论- 计算子结点stmt 的语句条数num:
num == 1,说明stmt对应单条语句调用ast_for_stmt遍历stmt对应得CST子树,生成对应的AST子树,并调用adsl_seq_SET设置到数组之中。这样AST的根结点mod_ty便可以知道有哪些顶层的语句(stmt),这些语句结点便是根结点mod_ty的子结点。
num != 1,说明stmt对应多条语句。根据Grammar文件中定义的如下产生式可以推知此时ch的子结点必然为simple_stmt。
stmt: simple_stmt | compound_stmt
simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt |
import_stmt | global_stmt | nonlocal_stmt | assert_stmt)
expr_stmt: testlist_star_expr (annassign | augassign (yield_expr|testlist) |
[('=' (yield_expr|testlist_star_expr))+ [TYPE_COMMENT]] )
annassign: ':' test ['=' (yield_expr|testlist_star_expr)]
由于simple_stmt的定义中small_stmt和’;’总是成对出现,因此index为偶数的CST结点便是所需的单条顶层语句的结点,对于每个这样的结点调用adsl_seq_SET设置到数组之中
- 最后,调用Module函数从stmts数组生成mod_ty结点,也就是AST的根结点
上面的过程中用到了两个关键函数:num_stmts
和ast_for_stmt
。
- 先来看
num_stmts
函数
static int
num_stmts(const node *n)
{
int i, l;
node *ch;
switch (TYPE(n)) {
case single_input:
if (TYPE(CHILD(n, 0)) == NEWLINE)
return 0;
else
return num_stmts(CHILD(n, 0));
case file_input:
l = 0;
for (i = 0; i < NCH(n); i++) {
ch = CHILD(n, i);
if (TYPE(ch) == stmt)
l += num_stmts(ch);
}
return l;
case stmt:
return num_stmts(CHILD(n, 0));
case compound_stmt:
return 1;
case simple_stmt:
return NCH(n) / 2; /* Divide by 2 to remove count of semi-colons */
case suite:
case func_body_suite:
/* func_body_suite: simple_stmt | NEWLINE [TYPE_COMMENT NEWLINE] INDENT stmt+ DEDENT */
/* suite: simple_stmt | NEWLINE INDENT stmt+ DEDENT */
if (NCH(n) == 1)
return num_stmts(CHILD(n, 0));
else {
i = 2;
l = 0;
if (TYPE(CHILD(n, 1)) == TYPE_COMMENT)
i += 2;
for (; i < (NCH(n) - 1); i++)
l += num_stmts(CHILD(n, i));
return l;
}
default: {
char buf[128];
sprintf(buf, "Non-statement found: %d %d",
TYPE(n), NCH(n));
Py_FatalError(buf);
}
}
Py_UNREACHABLE();
}
此函数比较简单,根据结点类型和产生式递归计算顶层语句的个数。所谓顶层语句,也就是把复合语句(compound_stmt)看作单条语句,复合语句中的内部的语句不做计算,当然普通的简单语句(small_stmt) 也是算1条语句。下面根据不同结点类型分析此函数:
- Single_input
代表单条交互语句,对应的产生式:single_input: NEWLINE | simple_stmt | compound_stmt NEWLINE
如果single_input的第一个子结点为NEW_LINE,说明无语句,返回0,否则说明是simple_stmt或者compound_stmt NEWLINE,可以直接递归调用num_stmts处理
- File_input
代表整个代码文件,对应的产生式:file_input: (NEWLINE | stmt)* ENDMARKER
只需要反复对每个子结点调用num_stmts既可。
- Compound_stmt
代表复合语句,对应的产生式:compound_stmt: if_stmt | while_stmt | for_stmt | try_stmt | with_stmt | funcdef | classdef
compound_stmt只可能有单个子结点,而且必然代表单条顶层的语句,因此无需继续遍历,直接返回1既可。
- Simple_stmt
代表简单语句(非复合语句)的集合,对应的产生式:
simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
可以看到顶层语句数=子结点数/2 (去掉多余的分号和NEWLINE)
- Suite
代表复合语句中的语句块,也就是冒号之后的部分(如:classdef: 'class' NAME ['(' [testlist] ')'] ':' suite),类似于C/C++大括号中的内容,对应的产生式如下:suite: simple_stmt | NEWLINE INDENT stmt+ DEDENT
子结点数为1,说明必然是simple_stmt,可以直接调用num_stmts处理,否则,说明是多个stmt的集合,遍历所有子结点调用num_stmts并累加既可
可以看到,num_stmts基本上是和语句有关的产生式是一一对应的。
接下来分析ast_for_stmts的内容:
static stmt_ty
ast_for_stmt(struct compiling *c, const node *n)
{
if (TYPE(n) == stmt) {
assert(NCH(n) == 1);
n = CHILD(n, 0);
}
if (TYPE(n) == simple_stmt) {
assert(num_stmts(n) == 1);
n = CHILD(n, 0);
}
if (TYPE(n) == small_stmt) {
n = CHILD(n, 0);
/* small_stmt: expr_stmt | del_stmt | pass_stmt | flow_stmt
| import_stmt | global_stmt | nonlocal_stmt | assert_stmt
*/
switch (TYPE(n)) {
case expr_stmt:
return ast_for_expr_stmt(c, n);
case del_stmt:
return ast_for_del_stmt(c, n);
case pass_stmt:
return Pass(LINENO(n), n->n_col_offset,
n->n_end_lineno, n->n_end_col_offset, c->c_arena);
case flow_stmt:
return ast_for_flow_stmt(c, n);
case import_stmt:
return ast_for_import_stmt(c, n);
case global_stmt:
return ast_for_global_stmt(c, n);
case nonlocal_stmt:
return ast_for_nonlocal_stmt(c, n);
case assert_stmt:
return ast_for_assert_stmt(c, n);
default:
PyErr_Format(PyExc_SystemError,
"unhandled small_stmt: TYPE=%d NCH=%d\n",
TYPE(n), NCH(n));
return NULL;
}
}
else {
/* compound_stmt: if_stmt | while_stmt | for_stmt | try_stmt
| funcdef | classdef | decorated | async_stmt
*/
node *ch = CHILD(n, 0);
REQ(n, compound_stmt);
switch (TYPE(ch)) {
case if_stmt:
return ast_for_if_stmt(c, ch);
case while_stmt:
return ast_for_while_stmt(c, ch);
case for_stmt:
return ast_for_for_stmt(c, ch, 0);
case try_stmt:
return ast_for_try_stmt(c, ch);
case with_stmt:
return ast_for_with_stmt(c, ch, 0);
case funcdef:
return ast_for_funcdef(c, ch, NULL);
case classdef:
return ast_for_classdef(c, ch, NULL);
case decorated:
return ast_for_decorated(c, ch);
case async_stmt:
return ast_for_async_stmt(c, ch);
default:
PyErr_Format(PyExc_SystemError,
"unhandled compound_stmt: TYPE=%d NCH=%d\n",
TYPE(n), NCH(n));
return NULL;
}
}
}
可以看到,ast_for_stmt
基本上是根据stmt的产生式
来遍历CST的,stmt的产生式为stmt:
simple_stmt | compound_stmt
,对应了if语句的两条分支。之后,根据子结点simple_stmt或者compound_stmt的具体type,调用不同的ast_for_xxx函数来遍历CST,生成对应的AST结点。
这整个是一个递归下降的遍历分析的过程。其实很多编译器的语法分析是直接用递归下降生成AST实现的,而Python则稍有不同,先是用生成的代码生成CST,然后再用手写的递归下降分析法遍历CST生成AST,本质一样,不过Python的做法可以减少手写的工作量,只需分析CST,无需考虑词法分析的内容,当然增加的工作量是构造一个生成器从Grammar生成对应的分析代码。总的来说,还是有一定好处的,维护的代码会简单一些。
在递归下降遍历的过程中,一旦遇到的CST可以生成对应的AST,则会调用对应的AST类型的创建函数来返回对应的AST。这个过程在下面的ast_for_factor
中可以看到(优化代码为了清晰起见已去掉):
static expr_ty
ast_for_factor(struct compiling *c, const node *n)
{
expr_ty expression;
expression = ast_for_expr(c, CHILD(n, 1));
if (!expression)
return NULL;
switch (TYPE(CHILD(n, 0))) {
case PLUS:
return UnaryOp(UAdd, expression, LINENO(n), n->n_col_offset,
n->n_end_lineno, n->n_end_col_offset,
c->c_arena);
case MINUS:
return UnaryOp(USub, expression, LINENO(n), n->n_col_offset,
n->n_end_lineno, n->n_end_col_offset,
c->c_arena);
case TILDE:
return UnaryOp(Invert, expression, LINENO(n), n->n_col_offset,
n->n_end_lineno, n->n_end_col_offset,
c->c_arena);
}
PyErr_Format(PyExc_SystemError, "unhandled factor: %d",
TYPE(CHILD(n, 0)));
return NULL;
}
Factor对应的产生式如下:**factor: ('+'|'-'|'~') factor | power **
因此,对应的ast_for_factor的代码也遵循产生式的定义,先调用ast_for_expr分析factor/power对应的CST子树,再根据第一个子结点是+-~分别调用UnaryOp使用不同参数生成对应的AST子树。注意分析factor / power的时候用的是ast_for_expr,一是因为factor可能有左递归,而ast_for_expr会在case factor的时候处理左递归,二是因为ast_for_expr已经可以处理factor和power了,无需多写代码。
参考:
词法分析:用两种方式构造有限自动机
语法分析:两个基本功和两种算法思路
Python编译器(一):如何用工具生成编译器?
Python源码分析3 – 词法分析器PyTokenizer
Python源码分析5 – 语法分析器PyParser
Python源码分析6 – 从CST到AST的转化