系列導航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 類中。
下一篇將會介紹詞法分析中用到的正則表達式,以及如何解析正則表達式。