《编译器设计》笔记2:词法分析器

·  阅读 355

以下是「第2章 词法分析器」的读书笔记。

关键词:有限自动机、正则表达式。

识别器

这一节用「状态转移图」来描述识别器。

识别单词

假设要识别 new 关键字,那么识别器中可能存在这样的代码:

c <- NextChar()
if c = 'n'
  c <- NextChar()
  if c = 'e'
    c <- NextChar()
    if c = 'w'
      return 'success'
    else
      throw 'error'
    end
  else
    throw 'error'
  end
else
  throw 'error'
end
复制代码

我们可以用 转移图 来表示这一逻辑:

image.png

图中的 S0、S1、S2 和 S3 表示计算过程中的一个抽象状态,S0 一般用于表示起始状态,有两层圆圈的 S3 表示接受状态。

那么识别 while 的转移图就是这样的:

image.png

识别 not 的转移图是这样:

image.png

因此,识别 new notwhile 的转移图就是这样的:

image.png

识别器的形式化

我们可以把识别 new notwhile 的有限自动机形式化为:

1b219ab58c3b28d0430d3b1b058acef.jpg

其中

  • SS 表示所有状态,其中 ses_e 是错误状态,转移图一般不画 ses_e
  • Σ\Sigma 是识别器使用的字母表
  • δ\delta 是识别器的所有转移函数
  • s0s_0 是初始状态
  • SAS_A 是接受状态的集合

任何形式上满足这 5 个条件(SSΣ\Sigmaδ\deltas0s_0SAS_A)的数学对象都叫做「有限状态机」,英文缩写为 FA。

识别数字

image.png

这个图有两个问题:

  1. 它无法终结,这违反了 SS 是有限集的定义。
  2. S2S_2 开始,所有的状态都是等价的,因为他们期待同样的数字切均为接受状态。

因此我们可以允许 FA 有环,这可以大大简化 FA:

image.png

识别标识符

image.png

不唯一

一个识别器对应的状态转移图很可能不唯一。

正则表达式

识别器除了用「状态转移图」来描述,还可以用「正则表达式」来描述。

但什么是正则表达式呢?

形式化定义

一个正则表达式由三个基本操作构建而成:

  1. 选择,语法为 RSR|S,定义为 xxRxS{x | x \in R 或 x \in S }

  2. 连接,语法为 RSRS,定义为 xyxRyS{xy | x \in R 且 y \in S }

  3. 闭包,语法为 RR*,定义为 ϕRRRRRRRRRR...{ \phi | R | RR | RRR | RRRR | ... }

    • 正闭包,语法为 R+R+,其本质是 RRRR*

举例,用上面定义的正则表达式来表示一个识别标识符的识别器是这样的:

[a-zA-Z]([a-zA-Z0-9])*
复制代码

可以用正则表达式定义的语言被称为「正则语言」。

从正则表达式到确定性有限状态机

这一小节一开始就给出了一个结论:从有限状态机可以自动推导出正则表达式,反之亦然。其中的构造法如下:

f91ac82d0cbc01afd8b982043870bb2.jpg

为了理解这些构造法,我们需要将 FA 区分为 DFA(确定性有限状态机)和 NFA(非确定性有限状态机)。

  1. NFA 允许在 ϵ\epsilon(空字符串)上进行转移。
  2. DFA 不允许。

我个人感觉这部分内容过于学术,没有必要理解。直接记住下面结论即可:

  1. RE => NFA:Thompson 构造法可以把正则表达式变成 NFA
  2. NFA => DFA:子集构造法可以把 NFA 变成 DFA
  3. DFA => min DFA:Hopcroft 算法或 Brzozowski 算法可以把 DFA 简化成最小 DFA(最小是指状态数最小)
  4. 推论:通过上面三个算法,我们可以把正则表达式变成最小 DFA
  5. DFA 变成识别器代码是很容易的事情(下节讲)
  6. 推论:只要用正则描述一门语言,就可以很容易的得到识别器的代码
  7. DFA => RE:Kleene 构造法可以把 DFA 变成正则表达式
  8. 推论:DFA 和正则表达式是等价的

从 DFA 到代码

将 DFA 转换为可执行的代码有三种策略:

  1. 表驱动
  2. 直接编码
  3. 手工编码

三种策略都是在模拟状态机的运转方式:不停地读取输入字符串中的下一个字符,并模拟状态转移。它们的不同之处在于对 DFA 转移结构的建模方式和模拟 DFA 操作的方式。

表驱动的词法分析器

假设某编程语言的标识符的正则表达式为 $[0-9]+,那么对应的 DFA 可能为:

image.png

我们需要把字符分类,列成一个表,我们将此表命名为「字符分类表」:

$0-9EOF其他
prefixnumberotherother

然后我们可以把所有的状态转移列成一个表,我们将此表命名为「转移表」:

prefixnumberother
s0s_0s1s_1serrors_{error}serrors_{error}
s1s_1serrors_{error}s2s_2serrors_{error}
s2s_2serrors_{error}s2s_2serrors_{error}
ses_eserrors_{error}serrors_{error}serrors_{error}

其中只有 s2s_2 是接受状态。

我们还可以将每种状态是否属于接受状态进行列表,此表名为「结果类型表」

s0s_0s1s_1s2s_2s3s_3
无效无效标识符无效

那么代码就很好写了:

function nextWord(){
  let state = 's_0'
  let lexeme = ''
  let stack = []
  stack.push('bad')
  while (state !== 's_error') {
    nextChar(char) // 此函数以后介绍
    lexeme += char
    if(['s_2'].include(state)){
      stack = []
    }
    stack.push(state)
    categoty = 字符分类表[char]
    state = 转移表[state][category]
  }
  // 如果最终 state 不是接受状态,则回滚到之前的接受状态(如果有)
  while (!['s_2'].include(state) && state !== 'bad'){
    state = stack.pop()
    truncate(lexeme)
    rollback() // 此函数以后介绍
  }
  
  if(['s_2'].include(state)){
    return 结果类型表[state]
  } else {
    return '无效'
  }
  
}
复制代码

表驱动法存在的一个明显问题是,它的回滚逻辑遇到比较长的失败匹配时,可能会非常低效 O(n^2),此时可以使用「最长适配」算法来解决这个问题。

直接编码的词法分析器

表驱动法有这样一段代码:

while (state !== 's_error') {
    nextChar(char) // 此函数以后介绍
    ...
    categoty = 字符分类表[char]
    state = 转移表[state][category]
  }
复制代码

这里有两次查表操作,虽然查表是常数次操作,但直接编码法可以进一步降低操作数,其原理是使用 goto 和标签来代替 while 和查表。

依然以 $[0-9]+ 为例,代码如下:

s_init: 
    lexeme = ''
    stack = []
    stack.push('bad')
    goto s_0
s_0:
    nextChar(char)
    lexeme += char
    if(['s_2'].include(state)){
      stack = []
    }
    stack.push(state)
    if(char === '$'){
      goto s_1
    }else{
      goto s_out
    }
s_1:
    nextChar(char)
    lexeme += char
    if(['s_2'].include(state)){
      stack = []
    }
    stack.push(state)
    if('0123456789'.include(char)){
      goto s_2
    }else{
      goto s_out
    }
s_2:
    nextChar(char)
    lexeme += char
    if(['s_2'].include(state)){
      stack = []
    }
    stack.push(state)
    if('0123456789'.include(char)){
      goto s_2
    }else{
      goto s_out
    }
s_out:
    white(!['s_2'].include(state) && state !== 'bad'){
        state = stack.pop()
        truncate lexeme
        rollback()
    }
  
    if(['s_2'].include(state)){
        return 结果类型表[state]
    } else {
        return '无效'
    }
复制代码

可以看到,while 变成了多个 goto,查表操作变成了不同的 label。

但这种方案有两个缺点:

  1. 违反了结构化编程原则,因为使用了 goto
  2. 不适合人类阅读

还在,这种方案的代码很多时候都是自动生成的,所以问题不大。

手工编码的词法分析器

前面两种方案中,每个字符需要被逐个分析,I/O开销还是过大,为什么不一次性读取多个字符呢?也就是引入 buffer。

buffer 越大,词法分析器读取输入字符串的次数就越少。当然 buffer 也不能无限大。

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改