学习 Python 源码(六) 解析器续

语法器生成

继续上篇的, 已知第4步 生成了 NFA.

第5步, NFA->DFA.

// pgen.c -- 函数名字很糟糕.
grammar *maketables(nfagrammar *gr) {
  grammar *g = new grammar();  // 创建 DFA 语法对象.
  
  for_each (nfa in gr->nfas[]) {
    DFA *dfa = make_dfa(gr, nfa);
    g->dfas[] += dfa;  // 伪代码: 添加到 g 中.
  }
}

// 根据 NFA 创建出 DFA 的算法, 可参见编译原理.
DFA *make_dfa(NFA *nfa) {
  // ...计算 epsilon_closure() ...
  // ...计算 nfa 的子集 ...
  // ...合并子集, 成为 DFA 的状态 ...
  ...
  // 通过减少状态, 优化 DFA.
  simplify(dfa);
  // ...
  convert(dfa);
  return dfa;
}

看源代码, 是实现了子集构造法生成 DFA 的整个过程. 看了下, 代码效率有点糟糕... 幸亏也就使用一次生成...

第6步, 第7步, 第8步 相对比较单一, 不细究了吧.

状态机的执行

今生成 grammar 之后, 产生的输出文件 graminit.c/h 即是主要给出了 grammar 结构中的所有 DFA 数据,
labels[] 数据, state, arc 等数据的集合, 模拟执行该 DFA 的程序称之为 driver. 位于文件 parser.c 中.

为调用此 driver, 调用者需要负责将 lexer, parser 粘和起来, 粘和程序大致样子是:

void driver() {
  while (! end_of_input()) {
    tok = tokenizer.get();  // 从词法器中读取下一个符号.
    parser.add_token(tok);  // 送入解析器.
    // 检查结果等略.
  }
}

Python 的这种 lexer/parser 模型有自己的特色, 它每解析一个 token 就"送入" 解析器去驱动 DFA, 然后
立刻返回一个结果. 这和 yacc/bison 等类似工具生成的 parser 相比, 后者直接"迷失" 在机器生成的
迷宫一般的 parser 函数中了. (故而说: 有特点). 下面研究 parser.add_token():

// parser.c -- 本质是驱动 DFA.
int PyParser_AddToken(parser *ps, int tok, char *str, ...) {
  // 根据输入的 tok,str 翻译为 DFA 中的 label 编号.
  int label = classify(tok, str);
  if (label < 0) return SYNTAX_ERROR;  // 不识别此 tok.

  // 主循环: 循环直到 tok 被移入(shift, 即被某个产生式消费掉), 或发生错误.
  while (1) {
    DFA *dfa = top_stack->dfa;   // 当前的栈顶的 DFA.
    state *s = dfa->state[top_stack->state];  // 当前 DFA 状态.

    // 所谓 DFA 状态转换, 即是根据输入 label(tok), 当前状态 s 转换到新状态.
    // 伪代码: 查找转换表(由 accel[] 提供)
    state *ns = 转换表[label];
    if (没有此 ns, 即不识别此输入) {
      if (s->accept) {  // 当前状态是一个 DFA 接受态, 则现在接受它.
        stack.pop();    // 下推栈 pop() 一个, 回到前一个状态.
        continue;       // 继续下一个状态, 看是否识别输入.
      }
      // 否则, 未见到识别的输入, 不能识别当前的输入, 因此是一个语法错误.
      return SYNTAX_ERROR;
    }

    // 现在转换到新 DFA 的状态 ns.
    if (ns 转移到 `非终结符') {
      push(); continue;  // 进入到该 '非终结符' 的状态机 DFA.
    }

    // 现在 ns 是 `终结符'
    shift();  // 移入, 即消费掉此 `终结符'.
    // 判定 shift() 之后是否到了一个 accept 态, 且不需要更多输入了.
    while (accept && 不需要更多输入) 
      pop();   // 接受此 DFA 态, 弹出即返回到前一个状态.
  } // end of while(1)
}


大致可理解为是一组 DFA 的模拟执行. 通过使用栈记录 DFA 状态,以及解析树节点 node. 在 shift(), push()
的辅助过程中, 向父节点 node 添加子节点, 最终 DFA 解析完成后, 栈底的 node 就是解析出来的整颗解析树
的根. 原程序有很多调试 print 语句, 可以清楚地看到解析器执行的 DFA 转换路径, 如:

// 解析 Grammar 时 DFA 运行的调试输出.
Token NAME/'single_input' ... It's a token we know
 DFA 'MSTART', state 0: Push .      (压栈, 进入 rule 子状态机)
 DFA 'RULE', state 0: Shift.        (移入 single_input 符号)
Token COLON/':' ... It's a token we know
 DFA 'RULE', state 1: Shift.        (移入 ':' 符号)
Token NAME/'NEWLINE' ... It's a token we know
 DFA 'RULE', state 2: Push .
 DFA 'RHS', state 0: Push .
 ...


解析树的特点之一是没有子类型, 每种节点都无区分的当做一个子 node 添加到父 node 的子节点数组中.
同时, 解析树一般(远比)抽象语法树(AST) 冗余, 例如上面移入的 ':' 符号在 AST 中根本是不需要的, 一些
中间节点如 rhs, item, atom 在 AST 中通常也会被消除.

因此, 尽管 python 的解析器有自己的特色, 但是其使用解析树而非 AST 树的选择, 带来额外的内存消耗,
以及冗余的处理成本.

元语法器的执行和生成的 Python 语法器使用的是同一个 driver, 所以以上介绍对两种语法器都适用.

代码生成

在解析源文件或控制台输入的 python 源文本后, 我们得到一颗解析树, 该解析树结构即是描述在
语法文件 Grammar 中的产生式结构. 此后在 compile.c 中通过主要编译函数 compile_node()
将其编译为字节码. 其中还有一些额外处理, 如 init, optimize 等"非主线任务" 就暂时先忽略.

// c 可看做是 compiling *this, n 即为前面得到的语法解析树.
void compile_node 或 com_node (struct compiling *c, node *n) {
  switch (TYPE(n)) {   // 即 n 对应的 non-terminal(非终结符)类型.
    // 以几个 non-terminal 为例.
    case single_input:
      // single_input : NEWLINE | simple_stmt | compund_stmt NEWLINE
      if (n[0] == NEWLINE) return;  // 第一种 alt 忽略即可
       com_node(c, n[0]);  // 递归编译子节点 simple_stmt 或 compund_stmt.
      ...
    case expr_stmt: 
      com_expr_stmt(n); break;  // 编译表达式.
    case if_stmt:
      com_if_stmt(n); break;
    case for, while, try, ... ...  // 其它类似.
  }
}

 

我们再看几个典型的产生式对应的代码生成子函数即可:

// 产生式 arith_expr: term ('+'|'-' term)*
void com_arith_expr(n) {
  com_term(n[0]);  // 第一个 child 一定是 term.
  for (int i = 2; i < n->nchlidren; i += 2) {
    // 因此 n[i-1], n[i] 分别对应运算符 '+'|'-' 及 term.
    com_term(n[i]);
    switch (n[i-1]) {
      case '+': gen_code(BINARY_ADD); // 生成 ADD 指令.
      case '-': gen_code(BINARY_SUBTRACT);  // 生成 SUB 指令.
    } 
  }
}

例如表达式 1+2 生成的解析树在这里是 (1 '+' 2), 然后生成的指令大致为:

  PUSH   1   # 由 com_term(n[0]) 句生成此代码.
  PUSH   2   # 由 com_term(n[i-1]) 句生成.
  ADD        # 由 gen_code(BINARY_ADD) 句生成此代码.

 

再例如 if_stmt:

// compile.c (python 1.5) 后续版本可能有一定结构变化或优化.
void com_if_stmt(node *n) {
  // child索引:  0    1    2   3     (4      5    6   7)*n
  // if_stmt:   'if' test ':' suite ('elif' test ':' suite)* 
  //      8+     9+  10+    +4*n
  //     ['else' ':' suite]
  for (int i = 0; i+3 < nchildren; i += 4) {
    com_node(n[i+1]);   // 编译 test 测试语句.
    com_addfwref(JUMP_IF_FALSE, &a);  // 跳转到下一个 elif 判断部分, 地址会回填到 a.
    gen_code(POP_TOP);  // 弹出 test 计算出的测试值.
    com_node(n[i+3]);   // 编译 suite 部分.
    com_addfwref(JMP_FORWARD, &anchor);  // 跳转到 if 结束, 后面回填.
    com_backpatch(c, a);  // 前一个 if 测试失败跳转到当前位置, 即开始下一个 elif 判断.
  }
  // 如果有 else 部分, 则:
   if (i+2 < nchildren)
    com_node(n[i+2]);   // 编译 else 中的 suite.
  // 回填 if 结束位置, 前面所有 if/elif 体部分执行完之后跳转到 anchor.
  com_backpatch(anchor);
}

if 语句编译要求使用向前跳转, 并在后面得到地址之后回填. for, while 语句等类似.

有时间也可细看一下 try 语句的代码生成, 不过可直接按照书上说的显示出字节码, 一边输入 python 语句,
一边看到其产生的字节码输出, 这样对照看更容易理解. 其实现在已经没有太多迷雾了, 因为整个从解析
到代码生成, 到执行的整个流程都已经见到整体了, 至于细节需要时再去看吧.  (END)

你可能感兴趣的:(学习 Python 源码(六) 解析器续)