系列导航html
1、输入缓冲
在介绍如何进行词法分析以前,先来讲说一个不怎么被说起的问题——怎么从源文件中读取字符流。为何这个问题这么重要呢?是由于在词法分析中,对字符流是有要求的,它必须可以支持回退操做(就是将多个字符放回到流中,之后会再次被读取)。git
先来解释下为何须要支持回退操做,举个简单的例子来讲,如今要对两个模式进行匹配:github
图 1 流的回退过程正则表达式
上面是一个简单的匹配过程,仅为了展现回退过程,在后面实现 DFA 模拟器时会详细解释是如何匹配词素的。数组
如今来看看 C# 中与输入相关的类,有 Stream,它支持流的查找,可是只能以字节方式访问;BinaryReader 和 TextReader 虽然支持读取字符,可是又不能支持回退。因此,就必须本身完成这个输入缓冲类了,大体思路就是以 TextReader 做为底层的字符输入,而后由本身的类完成对回退能力的支持。数据结构
《编译原理》上给出了一种缓冲区对的方法,简单的说就是开辟两个缓冲区,设缓冲区大小都是 N 个字符。每一次都将 N 个字符读入到缓冲区中,并在这个缓冲区上实现字符操做。若是当前缓冲区的数据已经处理完毕,就将 N 个新字符读入到另外一个缓冲区中,接下来就换作操做新的缓冲区。post
这样的数据结构效率很高,并且只要维护合适的指针,就能够很容易的实现回退功能。不过它的缓冲区大小是固定的,新读入的字符会覆盖旧的字符。若是须要回退的字符数量过多(好比在分析很长的字符串时),就容易出现错误。我经过使用多个缓冲区解决了旧字符被覆盖的问题——若是缓冲区不足了,就开辟新缓冲区,而不是覆盖旧数据。字体
若是仅仅是不断的添加缓冲区,那么占用的内存只会不断增长,这样是没有什么意义的,所以我定义了三个释放缓冲区的操做:Drop,Accept 和 AcceptToken。Drop 的做用是将当前位置以前的全部数据标记为无效(被抛弃),被标记无效的数据占用的缓冲区就被释放掉,能够拿来被重复利用了;Accept 则会将标记为无效的数据以字符串形式返回,而不只仅是简单的抛弃;相似的,AcceptToken 是以 Token 形式返回被无效化的数据,是为了方便进行词法分析。spa
这样的数据结构比较相似于 STL 中的 deque,不过这里不须要随机访问和插入、删除数据,仅会在数据的头、尾进行操做,所以我直接将多个缓冲区使用双向链表连成一个环,使用三个指针 current,first 和 last 指向链表中有数据的缓冲区,以下图所示:3d
图 2 多个缓冲区组成的链表,红色的部分表示有数据,白色的部分没有数据
其中,first 指向的是最先的数据缓冲区,last 指向的是最新的数据缓冲区,current 指向的是当前正在访问的数据缓冲区,current 老是在 [first, last] 范围以内。firstIndex 和 lastLen 之间红色的部分,就是包含有效数据的缓冲区,idx 表示当前正在访问的字符。白色的部分表示空缓冲区,或是缓冲区中的数据已无效。
当须要读取下一个字符时,就从 current 中依次读取数据,并将 idx 后移。若是 current 中的数据已经读取完毕,则将 current 移向 last(这里用移向,是由于 current 和 last 之间可能有多个缓冲区),同时 idx 也要相应的移动。
图 3 current 移向 last
若是须要继续读取字符,可是 current 中没有新数据了,而此时 current 已经与 last 相同,表示缓冲区中已经没有更新的数据,那么就须要从 TextReader 中读取数据,放到新的缓冲区中,同时后移 current 和 last(须要保证 last 老是指向最新的缓冲区)。
图 4 current 和 last 向后移
如今来看看回退操做。进行回退时,只须要将 current 向 first 的方向移动(一样,current 和 first 之间可能有多个缓冲区)。
图 5 回退操做
Drop 操做(Accept 和 AcceptToken 也同理)的实现也很简单,只须要将 first 移动到 current 位置,将 firstIndex 移动到 idx 便可,这就表示 idx 以前的数据都看做无效数据。
图 6 Drop 操做
这里须要注意的就是,Drop 操做完成后,被无效化的数据就有可能会被新数据覆盖,所以应该肯定数据再也不须要时再执行 Drop 操做。Drop 操做的效率很高(移动两个引用),基本不用担忧会影响效率。
使用这种环形数据结构的优势是除了将字符填充到缓冲区以外,彻底避免了数据的额外复制,不管是前进、回退仍是 Drop 操做都只有指针(引用)操做,效率很高。当 Drop 比较及时时,仅会使用两个缓冲区,不会额外的占用内存。当占用的缓冲区过多时,还可以实现主动释放多余的内存(这里如今没有考虑)。
缺点就是实现起来会复杂些,须要仔细处理好 first、current 和 last 的关系,以及 firstIndex、index 和 lastLen 范围限制,有时还会涉及到多个缓冲区的操做。
完整的代码可见 SourceReader.cs。
2、代码定位
在对源代码进行解析的时候,记录每一个 Token 对应的行号和列号显然是很必要的工做,没有人会喜欢面对一大堆 Error,并且还恰恰不告诉你究竟是哪错了……所以,我认为代码定位绝对是词法分析必备的功能,因此直接把这个功能内置到了 SourceReader 类中了。
下面来讲明如何实现代码定位。代码定位包含三维数据:索引、行号和列号。索引是从 0 开始的字符索引,主要是方便程序进行处理;行号和列号则都是从 1 开始的,主要是为了人去看。
行定位比较简单,Unix 的换行符是 '\n',Windows 的换行符是 "\r\n",因此直接统计 '\n' 的个数便可。
接下来是列定位。为了达到比较好的效果,须要考虑两个因素:全角、半角字符和 Tab 字符。
一个中文字符(即全角字符)对应的是两列,英文字符(半角字符)对应的则是一列,这样在等宽字体下,每一列都是上下对齐的。在计算列数的时候,天然也应当如此,使用 Encoding.Default.GetByteCount() 而不是字符串的长度。不过这里我发现了一个内存问题(详情参考这里),改用 Encoding.Default.GetEncoder() 的 GetByteCount 方法就能够了。
一个 Tab 字符的长度是不定的(通常是为 4 或 8,因人而异),因此定义了一个 TabSize 来表示 Tab 字符的宽度。那么,一个 Tab 字符就对应 TabSize 列么?并非这样的,虽然通常看来是这样,但事实上,Tab 字符是让下一字符对应的列老是为 TabSize 的整数倍再加 1。若是 TabSize = 4,那么它的行为以下图所示,其中 a 和 bcc 后面都是有两个 Tab 字符,bcccccc 和 bccccccc 后面都是有一个 Tab 字符,每一个 Tab 字符我都用灰色箭头标出来了。
图 7 Tab 字符实例
因此,实际的列号应当使用下面的公式计算,其中 currentCol 是 Tab 字符所在的列,nextCol 就是下一字符所在的列:
1
|
nextCol = tabSize * (1 + (currentCol - 1) / tabSize) + 1;
|
代码定位的计算方法有了,而后就是计算的时机。若是每次 Read 的时候都计算当前字符的位置,一是计算效率会略低,由于 GetByteCount 方法中,一次性计算较长一个字符数组的效率,差很少是屡次计算长度为 1 的字符数组的一倍。二是回退的时候应该怎么办?若是将以前的位置计算结果都保存起来,内存占用会是一个问题,若是不考虑的话,又没法根据当前字符的位置推算出前一个字符的位置(好比当前字符在第一列的话,前一个字符应该在第几列?)。
综合考虑以后,我决定将代码位置的计算放到 Drop 操做(Accept 和 AcceptToken 也同样)中,一个是向上面所说的,计算效率会略高,另外一个是通常仅当识别出了一个 Token 后才须要为它定位,此时刚好是 Drop 或 AcceptToken 的时机,识别 Token 的过程当中就是定位了也没有什么用处。
我将代码定位的功能单独封装到了 SourceLocator.cs 类中。
下一篇将会介绍词法分析中用到的正则表达式,以及如何解析正则表达式。