编译原理:剖析python编译阶段

Python编译器

GDB跟踪python编译器的执行过程,在tokenizer.c的tok_get()函数中打一个断点,通过GDB查看python的运行,使用bt命令打印输出,结果如下图所示
image
image
image
image

整理后可得到:
image

该过程就是运行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)

有限自动机它的状态数量是有限的,当它受到一个新字符的时候,会导致状态的转移。
比如:下面的状态机能够区分标识符和数字字面量:
image
在这样一个状态机里,用单线圆圈表示临时状态,双线圆圈表示接受状态。接受状态就是一个合格的 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关键字和标识符的有限自动机图示:
image

从正则表达式生成有限自动机

上面已经了解了如何构造有限自动机以及如何处理词法规则的冲突,这样就可以实现手写词法分析器。但手写词法分析器的步骤太过繁琐,可只写出词法规则,自动生成对应的有限自动机

词法分析器生成工具lex(及GNU版本的flex)也能够基于规则自动生成词法分析器。

具体的实现思路如下:
把一个正则表达式翻译成NFA,然后把NFA转换成DFA

  • DFA: Deterministic Finite Automation,即确定的有限自动机,特点就是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换
  • NFA: Nondeterministic Finite Automaton, 即不确定的有限自动机,特点就是:该状态机中存在某写状态,针对某些输入,不能做一个去诶的能够的转换
    这就有可以细分成两种情况:
  1. 对于一个输入,它有来能够个状态可以转换

2.存在ε转换的情况,也就是没有任何字符输入的情况下,NFA也可以从一个状态迁移到另一个状态
如:"a[a-zA-Z0-9]bc" 这个正则表达式,对字符串的要求是以a开头,以bc结尾,a和bc之间可以有任意多个字母或数字,如下图所示,状态1的节点输入b时,有两条路经可以选择:一条是迁移到状态2,另一条仍然保持在状态1,因此这一个有限自动机是一个NFA
一个NFA例子--识别"a[a-zA-Z0-9]
bc" 的自动机
image
NFA引入\(\varepsilon\)转换的画法如下图所示:
另一个 NFA 的例子,同样能识别“a[a-zA-Z0-9]*bc”,其中有ε转换
image

无论是NFA还是DFA都等价于正则表达式,即所有的正则表达式都能转换成NFA或DFA;而所有的NFA或DFA,也都能转换成正则表达式

一个正则表达式可以翻译成一个NFA,它的翻译方法如下:

  • 识别字符i的NFA

当接受字符 i 的时候,引发一个转换,状态图的边上标注 i。其中,第一个状态(i,initial)是初始状态,第二个状态 (f,final) 是接受状态。
image

  • 转换“s|t”这样的正则表达式
    它这个意思是或是s,或是t,两者二选一。s和t本身是两个自表达式,可以增加两个新的状态:开始状态和接受状态,然后,用\(\varepsilon\)转换分别连接代表s和t的子图。
    如下图所示--识别s|t的NFA
    image

  • 转换“st”这样的正则表达式
    s 之后接着出现 t,转换规则是把 s 的开始状态变成 st 整体的开始状态,把 t 的结束状态变成 st 整体的结束状态,并且把 s 的结束状态和 t 的开始状态合二为一。这样就把两个子图衔接了起来,走完 s 接着走 t。
    识别st的NFA如下图所示:
    image

  • 对于“?”“”和“+”这样的符号,它们的意思是可以重复 0 次、0 到多次、1 到多次,转换时要增加额外的状态和边
    识别"s
    "的NFA图:
    image
    可看到从i直接到f,也就是对s匹配0次,也可以在s的起止节点上循环多次。

"s+",那就是s至少经过一次:
image

通过这样的转换,所有的正则表达式都可以转换为一个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

可以图示的方式展示输出的结果:
image

有了NFA后,就要利用它来识别某个字符串,在做正则表达式的匹配过程中,存在这大量的回溯操作,效率比较低,那能否将NFA转成DFA,让字符串的匹配过程更简单呢? 这样整个过程就是一个自动化的过程,从正则表达式到NFA,再到DFA
这个方法就是子集构造法

image

python的词法分析功能

了解了词法分析的实现的实现原理,来看着python的词法分析是如何做的
在查阅了tokenizer.c的tok_get()函数后,它也是通过有限自动机将字符串变成Token

python源码词法分析功能剖析

python词法分析的实现在Parser目录下的tokenizer.h和tokenizer.c。python的其他部分会直接调用tokenizer.h中定义的函数,如下:
image

这些函数均以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本身的状态,也就是内部的私有成员集合,定义如下:
image
其中最重要的是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词法分析直观展示

image
其中第二列是Token的类型,第三列是Token对应的字符串。各种Token类型的定义可以在 Grammar/Tokens 文件中找到
image



语法分析

语法分析方法

语法分析的核心知识点:两个基本功和两种算法思路

  • 两个基本功:第一,必须能够阅读和书写语法规则,也就是掌握上下文无关文法;第二,必须掌握递归下降算法
  • 两种算法思路:一种是自定向下的语法分析,另一种是自地向上的语法分析

上下文无关文法(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是终结符
正则文法是上下文无关文法的子集:
image

上下文相关文法:存在上下文相关的,比如,在高级语言中,本地变量必须先声明,才能在后面使用,这种制约关系就是上下文相关的

在语法分析阶段,并不关注上下文之间的依赖关系,这样使得语法分析的任务更加简单。至于上下文相关的情况,就交给语义分析阶段去处理了

接下来,就要根据语法规则,编写语法分析程序,把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:
image

总结起来,递归下降算法的特点如下:

  • 对于一个非终结符,要从左到右依次匹配其产生式中的每个项,包括非终结符和终结符。

  • 在匹配产生式右边的非终结符时,要下降一层,继续匹配该非终结符的产生式

  • 如果一个语法规则有多个可选的产生式,那么只要有一个产生式匹配成功就行。如果一个产生式匹配不成功,那就回退回来,尝试另一个产生式。这种回退过程,叫做回溯(Backtracking)

递归下降非常容易理解,能够有效处理多种语法规则,但它有两个缺点:

  • 第一个缺点:就是著名的左递归(Left Recursion)问题。

什么是左递归问题呢?
比如,在匹配算术表达式时,产生式的第一项是就是非终结符add,那按照算法,要下降一层,继续匹配add.这个过程会一直持续下去,无限递归。
image
所以递归下降算法是无法处理左递归问题的。那改成有递归,也就是把add这个递归项放在右边 -- 虽然规避了左递归的问题,但同时又导致了结合性的问题
那怎么办呢?
把递归调用转换成循环

在EBNF格式中,允许使用"" 号和"+"号表示重复:
image
对于('+'mul)
这部分,可以写成一个循环,在这个循环里,可以根据结合性的要求,手工生成正确的AST,它的伪代码如下:

左子节点 = 匹配一个mul
while(下一个Token是+){
  消化掉+
  右子节点 = 匹配一个mul
  用左、右子节点创建一个add节点
  左子节点 = 该add节点
}

创建正确的AST如下图所示:
image

  • 第二个缺点:就是当产生式匹配失败的时候,必须要“回溯”,这就可能导致浪费

LL算法:计算First和Follow集合

LL算法家族可自动计算出选择不同产生式的依据

LL算法的要点:计算First和Follow集合
First集合是每个产生式开头可能出现的Token集合,像stmt有三个产生式,它的First集合如下:
image
而 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,所以,其中的关键在于如何"拼凑"

假设采用下面的上下文无关文法,推演一个实例,具体语法规则如下:
image
如果用来解析"2+3*5",最终会形成下面的AST:
image

那算法是如何从底部拼凑出来这棵AST呢?
LR 算法和 LL 算法一样,也是从左到右地消化掉 Token。

在第 1 步,它会取出“2”这个 Token,放到一个栈里,这个栈是用来组装 AST 的工作区。同时,它还会预读下一个 Token,也就是“+”号,用来帮助算法做判断。

在下面的示意图里,画了一条橙色竖线,竖线的左边是栈,右边是预读到的一个 Token。在做语法解析的过程中,竖线会不断地往右移动,把 Token 放到栈里,这个过程叫做“移进”(Shift)
第一步,移进一个Token:
image
注意:在上图中使用虚线推测了AST的其他部分。也就是如果第一个Token遇到的是整型字面量,而后面跟着一个+号,那么这两个Token就决定了它们必然是这棵推测出来的AST的一部分。而图中右边就是它的推导过程,其中的每个步骤,都是用了一个产生式加了一个点(如".add"),这个点相当于图中左边的橙色竖线

可根据这棵假想的AST,即依据推想的推导过程,给它反推回去 ,把Int还原成pri,这个还原过程就叫做"规约(Reduce)"。工作区里的元素也随之更新成pri

第2步:Int规约为pri
image

按照这样的思路,不断地移进和规约,这棵 AST 中推测出来的节点会不断地被证实。而随着读入的 Token 越来越多,这棵 AST 也会长得越来越高,整棵树变得更大。下图是推导过程中间的一个步骤。
移进和规约过程中的一个步骤:
image

最后,整个AST构造完毕,而工作区里也就只剩下一个Start节点。
最后一步,add规约为start
image

2+3*5”最右推导的过程写在了下面,而如果你从最后一行往前一步步地看,它恰好就是规约的过程。
image

如果你见到 LR(k),那它的意思就是会预读 k 个 Token,我们在示例中采用的是 LR(1)

注:

相对于 LL 算法,LR 算法的优点是能够处理左递归文法。但它也有缺点,比如不利于输出全面的编译错误信息。因为在没有解析完毕之前,算法并不知道最后的 AST 是什么样子,所以也不清楚当前的语法错误在整体 AST 中的位置。

image

Python的语法分析功能

继续使用GDB跟踪执行过程,会在parser.c中找到语法分析的相关逻辑:
image

先来看下,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文件:
image

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:
image

mul: pri ('' pri)对应的DFA:
image

pri: IntLiteral | '(' add ')'对应的DFA:
image

parser.c 用了一个通用的函数去解析所有的语法规则,它所依据的就是为每个规则所生成的 DFA。
主要的实现逻辑是在** parser.c ** 的PyParser_AddToken()函数里

为了便于理解,模仿 Python 编译器,用上面的文法规则解析了一下“2+3*4+5”,并把整个解析过程画成图。

在解析的过程,用了一个栈作为一个工作区,来保存当前解析过程中使用的 DFA。

第1步,匹配add规则。 把add对应的DFA压到栈里,此刻DFA处于状态0,这时候预读了一个Token,是字面量2
image

第2步,根据add的DFA,走mul-1这条边,去匹配mul规则。 这时把mul对应的DFA入栈。在示意图中,栈从上往下延伸的。
image

第3步,根据mul的DFA,走pri-1这条边,去匹配pri规则。 这时把pri对应的DFA入栈。
image

第4步,根据pri的DFA,因为预读的Token是字面量2,所以移进这个字面量,并迁移到状态3。同时,为字面量2建立解析树的节点。 这个时候,又会预读下一个Token,'+'号
image

第 5 步,从栈里弹出 pri 的 DFA,并建立 pri 节点。 因为成功匹配了一个 pri,所以 mul 的 DFA 迁移到状态 1。
image

第 6 步,因为目前预读的 Token 是'+'号,所以 mul 规则匹配完毕,把它的 DFA 也从栈里弹出。 而 add 对应的 DFA 也迁移到了状态 1。
image

第 7 步,移进'+'号,把 add 的 DFA 迁移到状态 2,预读了下一个 Token:字面量 3。 这个 Token 是在 mul 的 First 集合中的,所以就走 mul-2 边,去匹配一个 mul。
image

按照这个思路继续做解析,直到最后,可以得到完整的解析树:
image

总结起来,Python 编译器采用了一个通用的语法分析程序,以一个作为辅助的数据结构,来完成各个语法规则的解析工作。当前正在解析的语法规则对应的 DFA,位于栈顶。一旦当前的语法规则匹配完毕,那语法分析程序就可以把这个 DFA 弹出,退回到上一级的语法规则。

经过上面的语法分析,形成的结果叫做解析树(Parse Tree), 又可叫做CST(Concrete Syntax Tree,具体语法树),和AST(抽象语法树)是相对的:一个具体,一个抽象
它们两个的区别在于:CST精确地反映了语法规则的推导过程,而AST则更准确地表达了程序的结构。如果说CST是"形似",那AST就是"神似"。

前面例子形成的CST的特点:
image
首先,加法是个二元运算符,但在这里 add 节点下面对应了两个加法运算符,跟原来加法的语义不符。第二,很多节点都只有一个父节点,这个其实可以省略,让树结构更简洁。

所期待的AST是这个样子的:
image

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;

image

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_NewPyNode_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;

image

状态栈的定义如下:

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;

image

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不会直接调用PyParserPyTokenizer的函数,而是直接调用下面的这些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
    image

从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。
    image
    假设当前我们处于(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和状态
image

在这种情况下会跳转到另外一个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树,试举例如下:
image
这颗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;

sizePy_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的转化

posted @ 2022-09-15 21:41  牛犁heart  阅读(505)  评论(0编辑  收藏  举报