从上下文无关文法直接到编译器

前言:自学最重要的事情之一就是要快速看到效果

本文给出了一个跳过语法分析树,直接生成汇编代码的方法。

文法的介绍

  • 文法是构成一个句子的规则,文法差不多就是语法,英文翻译都是grammar,但是文法的定义明显更加的“公式化”。

  • 比如有语法:

    句子 = 主语 + 谓语 + 宾语,“主语”、“谓语”和“宾语”可以替换为名词,就成了“你 是 读者”。

    好,接下来把“句子 = 主语 + 谓语 + 宾语”这个这个形式变一下:

    成分 替换为
    句子 S
    = ->
    + 直接去掉
    主语 M
    谓语 V
    宾语 C

    结果就是S -> M V C,然后给出能替换主语、谓语和宾语的词(因为太多了就列一小部分)。

    成分 可以替换为
    M 蜀黍、鼠鼠
    V 喜欢
    C

    那么也可以类似地给出

    M -> 鼠鼠
    M -> 蜀黍
    V -> 喜欢
    C -> 钱
    

    好,那么我们就可以从S开始,得到这两个字符串:

    S -> M V C -> 鼠鼠 V C -> 鼠鼠 喜欢 C -> 鼠鼠 喜欢 钱
    S -> M V C -> 蜀黍 V C -> 蜀黍 喜欢 C -> 蜀黍 喜欢 钱
    

    其中鼠鼠、蜀黍、喜欢、钱,这四个就是所谓“终结符”,而S、M、V、C就是“非终结符”。

    往往非终结符用的是大写字母。

    当然文法也能够产生长度为0的字符串,长度为0的字符串,叫做空串,一般写作‘ε’。

正则文法、正则表达式和有限状态自动机

  • 正则文法的定义:

    仅有“非终结符->终结符 非终结符”和“非终结符->非终结符 终结符”两者之一类型的产生式,以及“非终结符->终结符”组成的文法就是正则文法。比如

    S -> aS, S -> b
    

    就可以产生一个最右边为b的,左边全部是a的字符串。

    再比如

    S -> Sa, S -> b
    

    就可以产生一个最左边为b的,右边全部是a的字符串。

    当然有些不是这个样子的也算。

    S -> Sabc, S -> b
    

    因为我们可以把它改成这个形式:

    S -> Bc, B -> Ab, B -> Sa, S -> b
    

    当然这种形式就不能算了:

    S -> Bc, B -> aS, S -> c
    

    正则表达式和有限状态自动机,就是识别这种文法产生的字符串的工具。

    但是正则文法不能产生所有的数学表达式,哪怕匹配的扩号串也不能。

    所以,正则文法的能力是有限度的,它现在就要和有限状态自动机一起超越正则。

上下文无关文法和下推自动机

  • 上下文无关文法,可以产生数学表达式。

    比如:

    E -> E + E
    E -> E * E
    E -> ( E )
    E -> 数字
    

    这样就可以产生1 + 2 * 3

    但是有一个问题,产生它的方式有两种:

    E -> E * E -> E + E * E -> 1 + 2 * 3
    E -> E + E -> E + E * E -> 1 + 2 * 3
    

    这样子就有两个意思了。所以似乎“歧义”和“有两个方式产生一个句子”是一个意思。

    如果能够成功避免“歧义”,那么数学表达式就可以没有歧义了。

    但是仔细想想,数学表达式,为什么没有那么多歧义?

    很容易发现,加法、乘法放一起,先算乘法。

    OK,那么可以改上面那个文法:

    E -> E + T
    T -> T * F
    F -> ( E )
    F -> 数字
    

    这样就可以了。

    但是有一个问题,怎么样才能够知道,这个文法是没有歧义的?

    对于任何一个上下文无关文法,没有通用的办法去判断它有没有歧义,但是有些,却可以。这里就是接下来的主角了:LL(k)、LALR(k)、LR(k)文法。这三个稍后再提 。

  • 下推自动机

    下推自动机,就是一个识别上下文无关文法的自动机,不管这个文法有没有歧义。但是程序语言必须没有歧义,虽然没有歧义,但是有时候也避免不了递归、回溯,所以有了一种特殊的下推自动机——确定状态的下推自动机。

LL(k)文法

  • LL(k)的意思是“识别它的自动机,要从左往右走,同时构造最左派生,至少需要提前看k个字符才能够确定状态”的文法。

    比如对于文法:

    E -> (E)
    E -> ()
    

    而言,要确定((()))的最左派生,考虑第一个字符,它有可能是E->(E)产生的,也可能是E->()产生的,这个时候必须要读第二个字符才能确定它是那个,第二个是(,就可以排除E->()的情况。

    像这样,读入一个字符后必须要看看下一个字符是什么才能确定最左派生的文法,叫做LL(1)文法,也就是提前看1个字符的LL(k)文法。

LR(k)文法

  • 此坑待填

LALR(k)文法

  • 此坑待填

使用LL(1)写一个编译器

  • 要使用LL(1)文法写一个编译器,那么我们首先需要给出一个编译器的文法。

    为了保持简单,这里使用的文法,是一个简单的算数表达式的文法。

    E -> E + T
    E -> T
    T -> T * F
    T -> F
    F -> m
    F -> (E)
    

    首先介绍一下LL(1)文法解析器,怎么识别这个语言的。

    1. 需要改变这个文法的形式,让它没有左递归,得到一个新的文法。比如上面这个可以等价的改为:

      E  -> T E'
      E' -> + T E'
      E' -> ε
      T  -> F T'
      T' -> * T'
      T' -> ε
      F -> m
      F -> (E)
      
    2. 修改之后的文法,必须要有一个特征:任何一个非终结符,在遇到了非终结符,可以唯一确定一个产生式。比如

      K -> m W
      K -> S W t
      S -> m
      W -> i
      

      那么K能够这么产生两个串:

      K -> m W -> m i
      K -> S W t -> m W t -> m i t
      

      想一想,如果我读入一个m,我能够不用递归去确定是哪个产生式产生了整个串?

      现在动手,根据产生式,很容易得出下面这一部分产生式能够产生的第一个字符。

      首先给出一部分容易得出的。

      E' -> + T E'	对于这个产生式,能产生的第一个字符就是+
      T' -> * T'		对于这个产生式,能产生的第一个字符就是*
      F -> m			对于这个产生式,能产生的第一个字符就是m
      F -> (E)		对于这个产生式,能产生的第一个字符就是(
      

      这里的意思是,在E'状态下,读入一个+,就可以推断要用E' -> + T E'这个产生式。

      然后给出一些稍微推断一下就能够得出的。

      E  -> T E'		对于这个产生式,根据第一个非终结符T,得出能产生的第一个字符就是m或(
      T  -> F T'		对于这个产生式,根据第一个非终结符F,得出能产生的第一个字符就是m或(
      

      接下来就有一个问题了,E' -> ε和T' -> ε的还没确定,因为它们没法产生一个字符,但是除了确定的读入*和+分别使用T' -> * T'和E' -> + T E'之外,什么时候用这两个?

      这就得回到产生T'和E'的产生式里看看了。

      尝试看看T',可以找到这两个产生式T -> F T'T' -> * T',这里T'都在结尾。

      对于T -> F T',T后面接的字符有哪些?可以找到产生T的产生式:E -> T E'E' -> + T E',这里两个T后面接的都是E',所以推断,遇到E'产生的第一个字符,那么T'就得产生空串。

      对于T' -> * T',也是要找产生T'的产生式,结果找到了T -> F T',再去找产生T的产生式,结果还是得弄清楚E'产生的第一个字符。

      好,对E'下手,产生E'的产生式有E -> T E'E' -> + T E',那么,又遇到一个问题,E后面不可能接任何东西了,怎么办?

      可以加一个不在终结符、非终结符中出现的字符,就用$吧,把E -> T E'改为E -> T E' $,那么就可以得到E后面可以接$,那么得到E'产生空串的条件之一是遇到了$,再来看看还有没有其他条件。

      可以找到F -> (E),那么可以得到E后面还能够接')',那么另一个条件就是遇到了')'

      于是就可以得到T'的了。由于有T -> F T',而E -> T E',根据T后面可以接的第一个字符,可以是+、$、), 那么,在T'遇到它们的时候,就得产生空串。于是现在就可以进行第三步了。

    3. 根据新产生式和上面的推断,我们可以列出下面的表格。这个表格叫做分析表

      m ( ) + * $
      E E -> T E' E -> T E'
      E' E' -> ε E' -> + T E' E' -> ε
      T T -> F T' T -> F T'
      T' T' -> ε T' -> ε T' -> * T' T' -> ε
      F F -> m F -> m

      到现在,就差不多可以写一个解析它的解析器了。

      这里table就是上面的表格。

      def Parser(s):
          s += '$'
          stack = Stack()
          i = 0
          while i < len(s):
              c = s[i]
              if c == stack.top:
                  stack.pop()
                  i += 1
              elif stack.top 不是非终结符:
                  raise '该字符串不是该文法能够产生的字符串'
              if table[stack.top][c]为空:
                  raise '该字符串不是该文法能够产生的字符串'
              p = table[stack.top][c]
              # p是table中存的产生式,有两个属性:left, right.
              # 如果p存的是T -> F T', 那么p.left = T, p.right = F T'
              for c in reversed(p.right):
                  stack.push(c)
          return 'Accept'
      

    但是现在遇到的问题是,产生这个表格有点麻烦,手动写这表格,简直是生怕写错。

    前人也怕,所以他们设计了算法来专门解决这个问题。

    回想一下,前面产生这个表格时,做了什么事情。首先写出了一部分产生式能够产生的第一个字符,对于能产生空串的产生式,后面还用到了它后边可以接的字符。

    还真别说,这个算法就用了这两个东西。一个叫做First集,一个叫做Follow集。当然,为了方便,还用了一个表明非终结符能否产生空串的集合——Nullable

  • Nullable产生算法

    Nullable好像容易求,就对它下手吧。
    比如:

    S -> A B C
    A -> B C
    B -> ε
    C -> ε
    

    首先确定直接产生空串的,那么B、C就是了。

    还有一些,是不直接的,比如A和S。

    这个步骤,看起来像是在做BFS。实际上就是做BFS,但是,仔细想想,这样的话,好像还要构建反图,太麻烦了,而且也确实没那么多非终结符。那么可以把没有在Nullable里的非终结符的产生式的右边中,在Nullable里的非终结符替换为空串,这样这就可以完成了。

    上面这个例子,大概是这样的:

    Nullable = {B, C}
    替换 A -> B C中的B、C
    得到 A -> ε,那么Nullable = {A, B, C}
    ...
    
  • First产生算法

    好,接下来求First集合

    右边第一个字符是终结符的,可以直接确定。

    比如

    S -> A A | B %
    A -> B +
    A -> -
    A -> ε
    B -> /
    

    可以直接得到:

    A: -
    B: /
    

    接下来,试着给出S的。

    S -> A A | B %
    直接得出,S的Frist一定包含了A的Frist。
    由于第一个A可空,那么,现在就把这产生式当成
    S -> A | B %
    一样的做法。
    S -> | B %
    接下来就是一个'|'了,S产生的第一个字符可能有它,那么得到S的First有'|'。
    到了S -> B %
    B不可空,S的Frist一定包含了B的Frist。
    到此,S的Frist一定包含了A、B的Frist。
    停止。
    

    这样,可以确定S、A和B的First的关系。

    S的First一定包含了A、B的First
    A的First一定包含了B的First
    B的First和S、A没有关系
    

    于是就可以根据已有的First集合

    A: -
    B: /
    S: |
    

    来确定First集合的其他内容。

    做法很简单,不断更新First集合,直到所有的集合都没有更新为止。

    第一轮
    S的First一定包含了A、B的First,那么S的First有:- / |
    A的First一定包含了B的First,那么A的First有:/ -
    B的First和S、A没有关系,不变,还是 /
    这一轮更新了,所以以防万一,进行下一轮
    第二轮
    S的First一定包含了A、B的First,那么S的First有:- / |
    A的First一定包含了B的First,那么A的First有:/ -
    B的First和S、A没有关系,不变,还是 /
    没变化,结束。
    

    当然,之前那个,也可以给出First集合。

    {T': {*}, E': {+}, E: {(, m}, F: {(, m}, T: {(, m}}

  • Follow产生算法

    由于Follow要利用First和Nullable的信息,所以它放在最后面。

    还是这个产生式集合。

    E  -> T E'
    E' -> + T E'
    E' -> ε
    T  -> F T'
    T' -> * F T'
    T' -> ε
    F -> m
    F -> (E)
    

    现在有First:{T': {*}, E': {+}, E: {(, m}, F: {(, m}, T: {(, m}}

    Nullable: {E': True, F: False, T': True, T: False, E: False}

    Follow集合,表明的是非终结符后面可以接的终结符。

    那么,容易发现,对于F -> (E)而言,E后面明显可以接')'

    其他的?那就得根据First来弄了。

    E -> T E'中,可以发现T后面接了E',那么T后面可以出现E'的First中的终结符。

    T -> F T'中,类似,F后面可接T'的First。

    以此类推。

    但是有时候会有麻烦事情,比如有产生式S -> A B C D ,现在要确定A的Follow,而B、C可能为空,这个时候,上面的做法就不太适合了。如果给一个BCD,能够给出它们的First就好了。不妨设计个函数。这个函数,给的就是一个字符串的First

    def First(s):
        r = set()
        for i in s:
            if i 是终结符:
            	i加入到r
                break
            elif i 是非终结符:
                i的First加入到r
            if i 不可空:
                break
        return r
    

    当然,有特殊情况。

    E -> T E'中,可以发现E'后面没有东西了,咋办?

    那就得找产生E的产生式了。就一个:F -> (E)。所以据可以这样来解决这个问题。

    可是还有问题,E'又可空,T的Follow也是E的Follow,这又该咋办?

    不妨再给个类似于First函数的Nullable函数

    def Nullable(s):
        for i in s:
            if i 是终结符:
                return False
            if i 不可空:
                return False
        return True
    

    那么就可以给出求Follow的算法了。

    1. 对于所有产生式的右侧,遍历的时候,如果遇到了非终结符,求它右侧的字符串的First。
      比如E' -> + T E',第一个是‘+’,跳过。第二个是T,求E'的First,加入到T的Follow集合。
    2. 遍历时,如果后面是可空的,那么就要用上产生式左边的非终结符的Follow了。
      比如E -> T E',对于T而言,E'可空,那么T的Follow就得加上E的Follow
    3. 对于最后一个,也要求产生式左边的非终结符的Follow。
      比如E -> T E',对于E'的Follow,得加上E的Follow。这一步其实上面一步可以解决了,但是我觉得得强调一下。
    4. 如果没有集合被更新,停止循环。

    这样,可以求出所有非终结符的Follow:

    {E': {$}, T': {$, +}, F: {*, +, $}, E: {)}, T: {$, +}}

  • 求LL(1)分析表。
    现在已经有了First、Nullable函数以及Follow集合,根据之前的想法,可以很容易给出算法。

    def LLParserTable(productions):
    	for p in productions:
    		for first in First(p.right):
    			table[p.left][first] |= {p}
                if Nullable(p.right) == False:
                    continue
                for follow in Follow(p.left):
                    table[p.left][follow] |= {p}
    	return table
    

    好了,现在已经有了分析表了。

  • 直接生成汇编代码

    本文的目标是“直接”生成汇编代码,所以省略了生成语法分析树这一部分。

    那么该怎么生成?不妨给上面每个文法一些描述。这些描述,表明了这个产生式,在什么时候,生成什么样的汇编码。

    (笔者考虑过很多方法,但是最终发现还是这样的描述最为适合。)

    E  -> T E'
    E' -> + T E'
    # 1 add
    E' -> ε
    T  -> F T'
    T' -> * F T'
    # 1 mult
    T' -> ε
    F -> m
    # 0 push %s
    F -> (E)
    

    描述的格式是这样的:n 命令 操作对象描述。n的意思是,从右往左第n个终结符或非终结符弹出时,弹出这个命令,如果有操作对象描述,就替换它。比如这里%s的意思是,替换为待读入字符,那么1 push %s会被替换为1 push m

    那么解析(m + m) * m + m,由于用了$作为结束字符,所以实际上输入是(m + m) * m + m $,会这样生成下面的汇编代码:

    待读入符号 使用产生式 用于识别的栈的情况 用于暂时存储命令的栈的情况 该步输出的命令
    开始 E
    ( E -> T E' E' T
    ( T -> F T' E' T' F
    ( F -> (E) E' T' ) E (
    ( 无,这一步在读入 E' T' ) E
    m E -> T E' E' T' ) E' T
    m T -> F T' E' T' ) E' T' F
    m F -> m E' T' ) E' T' m 0 push m,意味着用于识别的栈的那个m所在的位置一旦被弹出,这个命令就被弹出
    m 无,这一步在读入 E' T' ) E' T' 0 push m被弹出,栈为空 push m
    + T' -> ε E' T' ) E'
    + E' -> + T E' E' T' ) E' T + 1 add,意味着用于识别的栈的那个T所在的位置一旦被弹出,这个命令就被弹出
    + 无,这一步在读入 E' T' ) E' T 1 add
    m T -> F T' E' T' ) E' T' F 1 add
    m F -> m E' T' ) E' T' m 1 add, 0 push m
    m 无,这一步在读入 E' T' ) E' T' 1 add push m
    ) T' -> ε E' T' ) E' 1 add 因为T'所在的位置空了,所以就弹出了。现在,它空了。 add
    ) E' -> ε E' T' )
    ) 无,这一步在读入 E' T'
    * T' -> * F T' E' T' F * 1 mult,意味着F所在的位置空了,它就弹出了。
    * 无,这一步在读入 E' T' F 1 mult
    m F -> m E' T' m 1 mult,0 push m,在m所在位置空的时候弹出
    m 无,这一步在读入 E' T' 1 mult,0 push m,F所在的位置空了,弹出。 push m, mult
    + T' -> ε E'
    + E' -> + T E' E' T + 1 add,意味着T所在位置空了,就弹出。
    + 无,这一步在读入 E' T 1 add
    m T -> F T' E' T' F
    m F -> m E' T' m 1 add, 0 push m, 弹出 0 push m push m
    m 无,这一步在读入 E' T' 1 add
    $ T' -> ε E' 1 add弹出 add
    $ E' -> ε
    结束

    于是得到输出命令:

    push m
    push m
    add
    push m
    mult
    push m
    add
    

    有没有觉得它很眼熟?对,就是逆波兰表达式
    然后现在自己去设计一个编译器吧!