C# 詞法分析器(六)構造詞法分析器


系列導航

  1. (一)詞法分析介紹
  2. (二)輸入緩沖和代碼定位
  3. (三)正則表達式
  4. (四)構造 NFA
  5. (五)轉換 DFA
  6. (六)構造詞法分析器
  7. (七)總結

現在最核心的 DFA 已經成功構造出來了,最后一步就是根據 DFA 得到完整的詞法分析器。

由於目前還不能像 Flex 那樣支持詞法定義文件,所以仍然需要在程序中定義規則,而且也不能非常靈活的自定義詞法分析器,不過基本的東西完全夠用了。

一、詞法規則的定義

詞法分析器用到的所有規則都在 Grammar<T> 類中定義,這里的泛型參數 T 表示詞法分析器的標識符的類型(必須是一個枚舉類型)。定義規則方法包括:定義上下文的 DefineContext 方法、定義正則表達式的 DefineRegex 方法和定義終結符的 DefineSymbol 方法。

調用 DefineContext 方法定義的詞法分析器上下文,會使用 LexerContext 類的實例表示,它的基本定義如下所示:

// 當前上下文的索引。
int Index;
// 當前上下文的標簽。
string Label;
// 當前上下文的類型。
LexerContextType ContextType;

在詞法分析器中,僅可以通過標簽來切換上下文,因此 LexerContext 類本身被設置為 internal。

上下文的類型就是包含型或者排除型,等價於 Flex 中的 %s 和 %x 定義(可參見 Flex 的 Start Conditions)。這里簡單的解釋下,在進行詞法分析時,如果當前上下文是排除型的,那么僅在當前上下文中定義的規則會被激活,其它的(非當前上下文中定義的)規則都會失效。如果當前上下文是包含型的,那么沒有指定任何上下文的規則也會被激活。

默認上下文標簽為 "Initial"。

Grammar<T> 中定義正則表達式的 DefineRegex 方法,就等價於 Flex 中的定義段(Definitions Section),可以定義一些常見的正則表達式以簡化規則的定義,例如可以定義

grammar.DefineRegex("digit", "[0-9]");

在正則表達式的定義中,就可以直接使用 "{digit}" 來引用預先定義的正則表達式。

最后是定義終結符的 DefineSymbol 方法,就對應於 Flex 中的規則段(Rules Section),可以定義終結符的正則表達式和相應的動作。

終結符的動作使用 Action<ReaderController<T>> 來表示,由 ReaderController<T> 類來提供 Accept,Reject,More 等方法。

其中,Accept 方法會接受當前詞法單元,並返回 Token 對象。Reject 方法會拒絕當前匹配,轉而尋找次優的規則,這個操作會使詞法分析器的所有匹配變慢,需要謹慎使用。More 方法通知詞法分析器,下次匹配成功時,不替換當前的文本,而是把新的匹配追加在后面。

Accept 方法和 Reject 方法是相互沖突的,每次匹配成功只能調用其中的一個。如果兩個都未調用,那么詞法分析器會認為當前匹配是成功的,但不會返回 Token,而是繼續匹配下一個詞法單元。

二、詞法分析器的實現

2.1 基本的詞法分析器

由於多個規則間是可能產生沖突的,例如字符串可以與多個正則表達式匹配,因此在說明詞法分析器之前,首先需要定義一個解決沖突的規則。這里采用與 Flex 相同的規則:

  1. 總是選擇最長的匹配。
  2. 如果最長的匹配與多個正則表達式匹配,總是選擇先被定義的正則表達式。

基本的詞法分析器非常簡單,它只能實現最基礎的詞法分析器功能,不能支持向前看符號和 Reject 動作,但是大部分情況下,這就足夠了。

這樣的詞法分析器幾乎相當於一個 DFA 執行器,只要不斷從輸入流中讀取字符送入 DFA 引擎,並記錄下來最后一次出現的接受狀態就可以了。當 DFA 引擎到達了死狀態,找到的詞素就是最后一次出現的接受狀態對應的符號(這樣就能保證找到的詞素是最長的),對應多個符號的時候只取第一個(之前已經將符號索引從小到大進行了排序,因此第一個符號就是最先定義的符號)。

簡單的算法如下:

輸入:DFA $D$
$s = s_0$
while (c != eof) {
	$s = D[c]$
	if ($s \in FinalStates$) {
		$s_{last} = s$
	}
	c = nextChar();
	}
$s_{last}$ 即為匹配的詞素

實現該算法的代碼可見 SimpleReader<T> 類,核心代碼如下:

// 最后一次匹配的符號和文本索引。
int lastAccept = -1, lastIndex = Source.Index;
while (true) {
	int ch = Source.Read();
	if (ch == -1) {
		// End Of File。
		break;
	}
	state = base.LexerRule.Transitions[state, base.LexerRule.CharClass[ch]];
	if (state == LexerRule.DeadState) {
		// 沒有合適的轉移,退出。
		break;
	}
	int[] symbolIndex = base.LexerRule.SymbolIndex[state];
	if (symbolIndex.Length > 0) {
		lastAccept = symbolIndex[0];
		lastIndex = Source.Index;
	}
}
if (lastAccept >= 0) {
	// 將流調整到與接受狀態匹配的狀態。
	Source.Unget(Source.Index - lastIndex);
	DoAction(base.LexerRule.Actions[lastAccept], lastAccept, Source.Accept());
}

2.2 支持定長的向前看符號的詞法分析器

接下來,將上面的基本的詞法分析器進行擴展,讓它支持定長的向前看符號。

向前看符號的規則形式為 $r = s/t$,如果 $s$ 或 $t$ 可以匹配的字符串長度是固定的,就稱作定長的向前看符號;如果都不是固定的,則稱為變長的向前看符號。

例如正則表達式 abcd 或者 [a-z]{2},它們可以匹配的字符串長度是固定的,分別為 4 和 2;而正則表達式 [0-9]+ 可以匹配的字符串長度就是不固定的,只要是大於等於一都是可能的。

區分定長和變長的向前看符號,是因為定長的向前看符號匹配起來更容易。例如正則表達式 a\*/bcd,識別出該模式后,直接回退三個字符,就找到了 a* 的結束位置。

對於規則 abc/d*,識別出該模式后,直接回退到只剩下三個字符,就找到了 abc 的結束位置。

我將向前看符號可以匹配的字符串長度預先計算出來,存儲在 int?[] Trailing 數組中,其中 null 表示不是向前看符號,正數表示前面($s$)的長度固定,負數表示后面($t$)的長度固定,0 表示長度都不固定。

所以,只需要在正常的匹配之后,判斷 Trailing 的值。如果為 null,不是向前看符號,不用做任何操作;如果是正數 n,則把當前匹配的字符串的前 n 位取出來作為實際匹配的字符串;如果是負數 -n,則把后 n 位取出來作為實際匹配的字符串。實現的代碼可見 FixedTrailingReader<T> 類

2.3 支持變長的向前看符號的詞法分析器

對於變長的向前看符號,處理起來則要更復雜些。因為不能確定向前看的頭是在哪里(並沒有一個確定的長度),所以必須使用堆棧保存所有遇到的接受狀態,並沿着堆棧向下找,直到找到包含 int.MaxValue - symbolIndex 的狀態(我就是這么區分向前看的頭狀態的,可參見上一篇《C# 詞法分析器(五)轉換 DFA》的 2.4 節 DFA 狀態的符號索引)。

需要注意的是,變長的向前看符號是有限制的,例如正則表達式 ab\*/bcd\*,這時無法准確的找到 ab\* 的結束位置,而是會找到最后一個 b 的位置,導致最終匹配的詞素不是想要的那個。出現這種情況的原因是使用 DFA 進行字符串匹配的限制,只要是前一部分的結尾與后一部分的開頭匹配,就會出現這種問題,所以要盡量避免定義這樣的正則表達式。

實現的代碼可見 RejectableTrailingReader<T> 類,沿着狀態堆棧尋找目標向前看的頭狀態的代碼如下:

// stateStack 是狀態堆棧
int target = int.MaxValue - acceptState;
while (true) {
	astate = stateStack.Pop();
	if (ContainsTrailingHead(astate.SymbolIndex, target)) {
		// 找到了目標狀態。
		break;
	}
}
// ContainsTrailingHead 方法利用符號索引的有序,避免了很多不必要的比較。
bool ContainsTrailingHead(int[] symbolIndex, int target) {
	// 在當前狀態中查找,從后向前找。
	for (int i = symbolIndex.Length - 1; i >= 0; i--) {
		int idx = symbolIndex[i];
		// 前面的狀態已經不可能是向前看頭狀態了,所以直接退出。
		if (idx < base.LexerRule.SymbolCount) {
			break;
		}
		if (idx == target) {
			return true;
		}
	}
	return false;
}

在沿着堆棧尋找向前看頭狀態的時候,不必擔心找不到這樣的狀態,DFA 執行時,向前看的頭狀態一定會在向前看狀態之前出現。

2.4 支持 Reject 動過的詞法分析器

Reject 動作會指示詞法分析器跳過當前匹配規則,而去尋找同樣匹配輸入(或者是輸入的前綴)的次優規則。

比如下面的例子:

g.DefineSymbol("a", c => { Console.WriteLine(c.Text); c.Reject(); });
g.DefineSymbol("ab", c => { Console.WriteLine(c.Text); c.Reject(); });
g.DefineSymbol("abc", c => { Console.WriteLine(c.Text); c.Reject(); });
g.DefineSymbol("abcd", c => { Console.WriteLine(c.Text); c.Reject(); });
g.DefineSymbol("bcd", c => { Console.WriteLine(c.Text); });
g.DefineSymbol(".", c => { });

對字符串 "abcd" 進行匹配,最后輸出的結果是:

abcd
abc
ab
a
bcd

具體的匹配過程如下所示:

第一次匹配了第 4 個規則 "abcd",然后輸出字符串 "abcd",並 Reject。

所以詞法分析器會嘗試次優規則,即第 3 個規則 "abc",然后輸出字符串 "abc",並 Reject。

接下來繼續嘗試次優規則,即第 2 個規則 "ab",然后輸出字符串 "ab",並 Reject。

繼續嘗試次優規則,即第 1 個規則 "a",然后輸出字符串 "a",並 Reject。

然后,繼續嘗試次優規則,即第 6 個規則 ".",此時字符串 "a" 被成功匹配。

最后,剩下的字符串 "bcd" 恰好與規則 5 匹配,所以直接輸出 "bcd"。

在實現上,為了做到這一點,同樣需要使用堆棧來保存所有遇到的接受狀態,如果當前匹配被 Reject,就沿着堆棧找到次優的匹配。實現的代碼可見 RejectableReader<T> 類

上面這四個小節,說明了詞法分析器的基本結構,和一些功能的實現。實現了所有功能的詞法分析器實現可見 RejectableTrailingReader<T> 類

三、一些詞法分析的例子

接下來,我會給出一些詞法分析器的實際用法,可以作為參考。

3.1 計算器

我首先給出一個計算器詞法分析程序的完整代碼,之后的示例就只包含規則的定義了。

Grammar g = new Grammar();
g.DefineSymbol("[0-9]+");
g.DefineSymbol("\\+");
g.DefineSymbol("\\-");
g.DefineSymbol("\\*");
g.DefineSymbol("\\/");
g.DefineSymbol("\\^");
g.DefineSymbol("\\(");
g.DefineSymbol("\\)");
// 吃掉所有空白。
g.DefineSymbol("\\s", c => { });
LexerRule lexer = g.CreateLexer();
string text = "456 + (98 - 56) * 89 / -845 + 2^3";
TokenReader reader = lexer.GetReader(text);
while (true) {
	try {
		Token token = reader.ReadToken();
		if (token.Index == Token.EndOfFileIndex) {
			break;
		} else {
			Console.WriteLine(token);
		}
	}
	catch (SourceException se) {
		Console.WriteLine(se.Message);
	}
}
// 輸出為:
Token #0 456
Token #1 +
Token #6 (
Token #0 98
Token #2 -
Token #0 56
Token #7 )
Token #3 *
Token #0 89
Token #4 /
Token #2 -
Token #0 845
Token #1 +
Token #0 2
Token #5 ^
Token #0 3

3.2 字符串

下面的例子可以匹配任意的字符串,包括普通字符串和逐字字符串(@"" 這樣的字符串)。由於代碼中的字符串用的都是逐字字符串,所以雙引號比較多,一定要數清楚個數。

g.DefineRegex("regular_string_character", @"[^""\\\n\r\u0085\u2028\u2029]|(\\.)");
g.DefineRegex("regular_string_literal", @"\""{regular_string_character}*\""");
g.DefineRegex("verbatim_string_characters", @"[^""]|\""\""");
g.DefineRegex("verbatim_string_literal", @"@\""{verbatim_string_characters}*\""");
g.DefineSymbol("{regular_string_literal}|{verbatim_string_literal}");
string text = @"""abcd\n\r""""aabb\""ccd\u0045\x47""@""abcd\n\r""@""aabb\""""ccd\u0045\x47""";
// 輸出為:
Token #0 "abcd\n\r"
Token #0 "aabb\"ccd\u0045\x47"
Token #0 @"abcd\n\r"
Token #0 @"aabb\""ccd\u0045\x47"

3.3 轉義的字符串

下面的例子利用了上下文,不但可以匹配任意的字符串,同時還可以對字符串進行轉義。

g.DefineContext("str");
g.DefineContext("vstr");
g.DefineSymbol(@"\""", c => { c.PushContext("str"); textBuilder.Clear(); });
g.DefineSymbol(@"@\""", c => { c.PushContext("vstr"); textBuilder.Clear(); });
g.DefineSymbol(@"<str>\""", c => { c.PopContext(); c.Accept(0, textBuilder.ToString(), null); });
g.DefineSymbol(@"<str>\\u[0-9]{4}", c =>
	textBuilder.Append((char)int.Parse(c.Text.Substring(2), NumberStyles.HexNumber)));
g.DefineSymbol(@"<str>\\x[0-9]{2}", c =>
	textBuilder.Append((char)int.Parse(c.Text.Substring(2), NumberStyles.HexNumber)));
g.DefineSymbol(@"<str>\\n", c => textBuilder.Append('\n'));
g.DefineSymbol(@"<str>\\\""", c => textBuilder.Append('\"'));
g.DefineSymbol(@"<str>\\r", c => textBuilder.Append('\r'));
g.DefineSymbol(@"<str>.", c => textBuilder.Append(c.Text));
g.DefineSymbol(@"<vstr>\""", c => { c.PopContext(); c.Accept(0, textBuilder.ToString(), null); });
g.DefineSymbol(@"<vstr>\""\""", c => textBuilder.Append('"'));
g.DefineSymbol(@"<vstr>.", c => textBuilder.Append(c.Text));
string text = @"""abcd\n\r""""aabb\""ccd\u0045\x47""@""abcd\n\r""@""aabb\""""ccd\u0045\x47""";
// 輸出為:
Token #0 abcd

Token #0 aabb"ccdEG
Token #0 abcd\n\r
Token #0 aabb\"ccd\u0045\x47

可以看到,這里的輸出結果,恰好是 3.2 節的輸出結果轉義之后的結果。需要注意的是,這里利用 c.Accept() 方法修改了要返回的詞法單元,而且由於涉及到多重轉義,在設計規則的時候一定要注意雙引號和反斜杠的個數。

現在,完整的詞法分析器已經成功構造出來,本系列暫時就到這里了。相關代碼都可以在這里找到,一些基礎類(如輸入緩沖)則在這里


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM