C# 词法分析器(六)构造词法分析器 update 2022.09.12

系列导航

  1. (一)词法分析介绍
  2. (二)输入缓冲和代码定位
  3. (三)正则表达式
  4. (四)构造 NFA
  5. (五)转换 DFA
  6. (六)构造词法分析器
  7. (七)总结

最核心的 DFA 已经成功构造出来了,最后一步就是根据 DFA 得到完整的词法分析器。

现在支持在运行时通过词法规则生成可以运行的词法分析器,也能够在设计时通过 T4 模板生成词法分析器,只需要依赖一个较小的运行时 Cyjb.Compilers.Runtime

一、运行时词法规则的定义

词法分析器用到的所有规则都在 Lexer<T, TController> 类中定义,这里的泛型参数 T 表示词法分析器的标识符的类型(一般是一个枚举类型),TController 表示词法分析器的控制器,可以使用默认实现 LexerController<T>。定义规则方法包括:定义上下文的 DefineContext 方法、定义正则表达式的 DefineRegex 方法和定义终结符的 DefineSymbol 方法。

调用 DefineContext 方法定义的词法分析器上下文,会使用 LexerContext 类的实例表示,它的基本定义如下所示:

// 当前上下文的索引。
int Index;
// 当前上下文的标签。
string Label;
// 当前上下文的类型。
LexerContextType ContextType;

在词法分析器中,仅可以通过标签来切换上下文,因此 LexerContext 类本身是 internal 的。

上下文的类型就是包含型或者排除型,等价于 Flex 中的 %s 和 %x 定义(可参见 Flex 的 Start Conditions)。这里简单的解释下,在进行词法分析时,如果当前上下文是排除型的,那么仅在当前上下文中定义的规则会被激活,其它的(非当前上下文中定义的)规则都会失效。如果当前上下文是包含型的,那么没有指定任何上下文的规则也会被激活。

默认上下文标签为 "Initial"。

Lexer<T, TController> 中定义正则表达式的 DefineRegex 方法,就等价于 Flex 中的定义段(Definitions Section),可以定义一些常见的正则表达式以简化规则的定义,例如可以定义
lexer.DefineRegex("digit", "[0-9]");

在正则表达式的定义中,就可以直接使用 "{digit}" 来引用预先定义的正则表达式。

最后是定义终结符的 DefineSymbol 方法,就对应于 Flex 中的规则段(Rules Section),可以定义终结符的正则表达式和相应的动作。

终结符的动作使用 Action<TController> 来表示,由 LexerController<T> 类来提供 AcceptRejectMore 等方法。

其中,Accept 方法会接受当前词法单元,并返回 Token 对象。Reject 方法会拒绝当前匹配,转而寻找次优的规则,这个操作会使词法分析器的所有匹配变慢,需要谨慎使用。More 方法通知词法分析器,下次匹配成功时,不替换当前的文本,而是把新的匹配追加在后面。

Accept 方法和 Reject 方法是相互冲突的,每次匹配成功只能调用其中的一个。如果两个都未调用,那么词法分析器会认为当前匹配是成功的,但不会返回 Token,而是继续匹配下一个词法单元。

二、设计时词法规则的定义

为了简化设计时词法规则的定义和实现,选择使用 C# Attribute 来指定相关规则,而非词法规则文件。为了使用 T4 模板,需引入一些必备依赖:

1. 通过 nuget 依赖运行时 Cyjb.Compilers.Runtime

2. 通过 nuget 依赖生成器 Cyjb.Compilers.Design,注意请如下指定引用配置,可以正常编译项目并避免产生运行时引用。

<ItemGroup>
	<PackageReference Include="Cyjb.Compilers.Design" Version="1.0.4">
		<GeneratePathProperty>True</GeneratePathProperty>
		<PrivateAssets>all</PrivateAssets>
		<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
	</PackageReference>
</ItemGroup>

之后就是定义词法规则了。首先要求提供一个继承自 LexerController<T> 的部分类,通过在这个类上定义 Attribute 完成词法规则的定义。

public partial class CalcController : LexerController<Calc> {
}

使用 LexerContextAttribute 和 LexerInclusiveContextAttribute 声明词法分析器的上下文。例如:

// 声明上下文
[LexerContext("TestContext")]
// 声明排除型上下文
[LexerInclusiveContext("TestContext2")]
public partial class CalcController : LexerController<Calc> {
}

使用 LexerRegexAttribute 声明正则表达式。例如:

// 声明正则表达式
[LexerRegex("digit", "[0-9]+")]
public partial class CalcController : LexerController<Calc> {
}

使用 LexerSymbolAttribute 声明终结符。可以声明在类上,表示没有相关动作,也可以声明在方法上,并将此方法作为相关动作。例如:

// 声明终结符
[LexerSymbol("\\+", Kind = Calc.Add)]
public partial class CalcController : LexerController<Calc> {
	/// <summary>
	/// 数字的终结符定义。
	/// </summary>
	[LexerSymbol("[0-9]+", Kind = Calc.Id)]
	public void DigitAction()
	{
		Value = int.Parse(Text);
		Accept();
	}
}

最后是添加与词法分析器同名的 tt 文件,内容如下:

<#@ include file="$(PkgCyjb_Compilers_Design)\content\CompilerTemplate.t4" #>

运行 T4 模板后即可生成同名的 .lexer.cs 文件,包含了词法分析器的实现。下图是一个简单的示例:

图 1 设计时词法分析器示例

三、词法分析器的实现

3.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}$ 即为匹配的词素

实现该算法的代码可见 TokenizerSimple<T> 类,核心代码如下:

// 最后一次匹配的符号和文本索引。
int lastAccept = -1, lastIndex = Source.Index;
while (true) {
	state = NextState(state);
	if (state == -1) {
		// 没有合适的转移,退出。
		break;
	}
	int[] symbols = Data.States[state].Symbols;
	if (symbols.Length > 0) {
		lastAccept = symbols[0];
		lastIndex = Source.Index;
	}
}
if (lastAccept >= 0) {
	// 将流调整到与接受状态匹配的状态。
	Source.Index = lastIndex;
	TerminalData<T> terminal = Data.Terminals[lastAccept];
	Controller.DoAction(Start, terminal.Kind, terminal.Action);
}

3.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$ 位取出来作为实际匹配的字符串。实现的代码可见 TokenizerFixedTrailing<T> 类

3.3 支持变长的向前看符号的词法分析器

对于变长的向前看符号,处理起来则要更复杂些。因为不能确定向前看的头是在哪里(并没有一个确定的长度),所以必须使用堆栈保存所有遇到的接受状态,并沿着堆栈向下找,直到找到包含 -symbolIndex 的状态(我就是这么区分向前看的头状态的,可参见上一篇《C# 词法分析器(五)转换 DFA》的 2.4 节 DFA 状态的符号索引)。

需要注意的是,变长的向前看符号是有限制的,例如正则表达式 ab\*/bcd\*,这时无法准确的找到 ab\* 的结束位置,而是会找到最后一个 b 的位置,导致最终匹配的词素不是想要的那个。出现这种情况的原因是使用 DFA 进行字符串匹配的限制,只要是前一部分的结尾与后一部分的开头匹配,就会出现这种问题,所以要尽量避免定义这样的正则表达式。

实现的代码可见 TokenizerRejectableTrailing<T> 类,沿着状态堆栈寻找目标向前看的头状态的代码如下:

// stateStack 是状态堆栈
int target = -acceptState;
while (true) {
	astate = stateStack.Pop();
	if (ContainsTrailingHead(astate.Symbols, target)) {
		// 找到了目标状态。
		break;
	}
}
// ContainsTrailingHead 方法利用符号索引的有序,避免了很多不必要的比较。
bool ContainsTrailingHead(int[] symbolInsymbolsdex, int target) {
	// 在当前状态中查找,从后向前找。
	for (int i = symbols.Length - 1; i >= 0; i--) {
		int idx = symbols[i];
		if (idx >= 0)
		{
			// 前面的状态已经不可能是向前看头状态了,所以直接退出。
			break;
		}
		if (idx == target) {
			return true;
		}
	}
	return false;
}

在沿着堆栈寻找向前看头状态的时候,不必担心找不到这样的状态,DFA 执行时,向前看的头状态一定会在向前看状态之前出现。

3.4 支持 Reject 动过的词法分析器

Reject 动作会指示词法分析器跳过当前匹配规则,而去寻找同样匹配输入(或者是输入的前缀)的次优规则。

比如下面的例子:

lexer.DefineSymbol("a").Action(c => { Console.WriteLine(c.Text); c.Reject(); });
lexer.DefineSymbol("ab").Action(c => { Console.WriteLine(c.Text); c.Reject(); });
lexer.DefineSymbol("abc").Action(c => { Console.WriteLine(c.Text); c.Reject(); });
lexer.DefineSymbol("abcd").Action(c => { Console.WriteLine(c.Text); c.Reject(); });
lexer.DefineSymbol("bcd").Action(c => { Console.WriteLine(c.Text); });
lexer.DefineSymbol(".").Action(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,就沿着堆栈找到次优的匹配。实现的代码可见 TokenizerRejectable<T> 类

上面这四个小节,说明了词法分析器的基本结构,和一些功能的实现。实现了所有功能的词法分析器实现可见 TokenizerRejectableTrailing<T> 类

四、一些词法分析的例子

接下来,我会给出一些词法分析器的实际用法,可以作为参考。

4.1 计算器

下面的例子是一个简单的计算器词法分析器:

enum Calc { Id, Add, Sub, Mul, Div, Pow, LBrace, RBrace }

Lexer<Calc> lexer = new();
lexer.DefineSymbol("[0-9]+").Kind(Calc.Id).Action(c =>
{
	c.Value = int.Parse(c.Text);
	c.Accept();
});
lexer.DefineSymbol("\\+").Kind(Calc.Add);
lexer.DefineSymbol("\\-").Kind(Calc.Sub);
lexer.DefineSymbol("\\*").Kind(Calc.Mul);
lexer.DefineSymbol("\\/").Kind(Calc.Div);
lexer.DefineSymbol("\\^").Kind(Calc.Pow);
lexer.DefineSymbol("\\(").Kind(Calc.LBrace);
lexer.DefineSymbol("\\)").Kind(Calc.RBrace);
// 吃掉所有空白。
lexer.DefineSymbol("\\s");

ILexerFactory<Calc> factory = lexer.GetFactory();
string text = "456 + (98 - 56) * 89 / -845 + 2^3";
Tokenizer<Calc> tokenizer = factory.CreateTokenizer(text);
foreach(Token<Calc> token in tokenizer) {
	Console.WriteLine(token);
}

// 输出为:
// Id "456" at [0..3)
// Add "+" at [4..5)
// LBrace "(" at [6..7)
// Id "98" at [7..9)
// Sub "-" at [10..11)
// Id "56" at [12..14)
// RBrace ")" at [14..15)
// Mul "*" at [16..17)
// Id "89" at [18..20)
// Div "/" at [21..22)
// Sub "-" at [23..24)
// Id "845" at [24..27)
// Add "+" at [28..29)
// Id "2" at [30..31)
// Pow "^" at [31..32)
// Id "3" at [32..33)
// <<EOF>> at 33

相应的设计时词法分析器定义为:

[LexerSymbol("\\+", Kind = Calc.Add)]
[LexerSymbol("\\-", Kind = Calc.Sub)]
[LexerSymbol("\\*", Kind = Calc.Mul)]
[LexerSymbol("\\/", Kind = Calc.Div)]
[LexerSymbol("\\^", Kind = Calc.Pow)]
[LexerSymbol("\\(", Kind = Calc.LBrace)]
[LexerSymbol("\\)", Kind = Calc.RBrace)]
[LexerSymbol("\\s")]
partial class CalcController : LexerController<Calc>
{
	/// <summary>
	/// 数字的终结符定义。
	/// </summary>
	[LexerSymbol("[0-9]+", Kind = Calc.Id)]
	public void DigitAction()
	{
		Value = int.Parse(Text);
		Accept();
	}
}

4.2 字符串

下面的例子可以匹配任意的字符串,包括普通字符串和逐字字符串(@"" 这样的字符串)。由于代码中的字符串用的都是逐字字符串,所以双引号比较多,一定要数清楚个数。

enum Str { Str }

Lexer<Str> lexer = new();
lexer.DefineRegex("regular_char", @"[^""\\\n\r\u0085\u2028\u2029]|(\\.)");
lexer.DefineRegex("regular_literal", @"\""{regular_char}*\""");
lexer.DefineRegex("verbatim_char", @"[^""]|\""\""");
lexer.DefineRegex("verbatim_literal", @"@\""{verbatim_char}*\""");
lexer.DefineSymbol("{regular_literal}|{verbatim_literal}").Kind(Str.Str);

ILexerFactory<Str> factory = lexer.GetFactory();
string text = @"""abcd\n\r""""aabb\""ccd\u0045\x47""@""abcd\n\r""@""aabb\""""ccd\u0045\x47""";
Tokenizer<Str> tokenizer = factory.CreateTokenizer(text);
foreach (Token<Str> token in tokenizer)
{
	Console.WriteLine(token);
}
// 输出为:
// Str ""abcd\n\r"" at [0..10)
// Str ""aabb\"ccd\u0045\x47"" at [10..31)
// Str "@"abcd\n\r"" at [31..42)
// Str "@"aabb\""ccd\u0045\x47"" at [42..65)
// <<EOF>> at 65

相应的设计时词法分析器定义为:

[LexerRegex("regular_char", @"[^""\\\n\r\u0085\u2028\u2029]|(\\.)")]
[LexerRegex("regular_literal", @"\""{regular_char}*\""")]
[LexerRegex("verbatim_char", @"[^""]|\""\""")]
[LexerRegex("verbatim_literal", @"@\""{verbatim_char}*\""")]
[LexerSymbol("{regular_literal}|{verbatim_literal}", Kind = Str.Str)]
partial class TestStrController : LexerController<Str>
{ }

4.3 转义的字符串

下面的例子利用了上下文,不但可以匹配任意的字符串,同时还可以对字符串进行转义。

class EscapeStrController : LexerController<Str>
{
	public int CurStart;
	public StringBuilder DecodedText = new();
}

Lexer<Str, EscapeStrController> lexer = new();
const string ctxStr = "str";
const string ctxVstr = "vstr";
lexer.DefineContext(ctxStr);
lexer.DefineContext(ctxVstr);
// 终结符的定义。
lexer.DefineSymbol(@"\""").Action(c =>
{
	c.PushContext(ctxStr);
	c.CurStart = c.Start;
	c.DecodedText.Clear();
});
lexer.DefineSymbol(@"@\""").Action(c =>
{
	c.PushContext(ctxVstr);
	c.CurStart = c.Start;
	c.DecodedText.Clear();
});
lexer.DefineSymbol(@"\""").Context(ctxStr).Action(c =>
{
	c.PopContext();
	c.Start = c.CurStart;
	c.Text = c.DecodedText.ToString();
	c.Accept(Str.Str);
});
lexer.DefineSymbol(@"\\u[0-9]{4}").Context(ctxStr).Action(c =>
{
	c.DecodedText.Append((char)int.Parse(c.Text.AsSpan()[2..], NumberStyles.HexNumber));
});
lexer.DefineSymbol(@"\\x[0-9]{2}").Context(ctxStr).Action(c =>
{
	c.DecodedText.Append((char)int.Parse(c.Text.AsSpan()[2..], NumberStyles.HexNumber));
});
lexer.DefineSymbol(@"\\n").Context(ctxStr).Action(c =>
{
	c.DecodedText.Append('\n');
});
lexer.DefineSymbol(@"\\\""").Context(ctxStr).Action(c =>
{
	c.DecodedText.Append('\"');
});
lexer.DefineSymbol(@"\\r").Context(ctxStr).Action(c =>
{
	c.DecodedText.Append('\r');
});
lexer.DefineSymbol(@".").Context(ctxStr).Action(c =>
{
	c.DecodedText.Append(c.Text);
});
lexer.DefineSymbol(@"\""").Context(ctxVstr).Action(c =>
{
	c.PopContext();
	c.Start = c.CurStart;
	c.Text = c.DecodedText.ToString();
	c.Accept(Str.Str);
});
lexer.DefineSymbol(@"\""\""").Context(ctxVstr).Action(c =>
{
	c.DecodedText.Append('"');
});
lexer.DefineSymbol(@".").Context(ctxVstr).Action(c =>
{
	c.DecodedText.Append(c.Text);
});

ILexerFactory<Str> factory = lexer.GetFactory();
string text = @"""abcd\n\r""""aabb\""ccd\u0045\x47""@""abcd\n\r""@""aabb\""""ccd\u0045\x47""";
Tokenizer<Str> tokenizer = factory.CreateTokenizer(text);
foreach (Token<Str> token in tokenizer)
{
	Console.WriteLine(token);
}
// 输出为:
// Str "abcd
// " at [0..10)
// Str "aabb"ccdEG" at [10..31)
// Str "abcd\n\r" at [31..42)
// Str "aabb\"ccd\u0045\x47" at [42..65)
// <<EOF>> at 65

这里利用自定义词法分析控制器类型支持词法分析器的复用,避免在多次调用中并发访问同一闭包中的变量。这里的输出结果,恰好是 4.2 节的输出结果转义之后的结果。需要注意的是,这里利用 c.Accept() 方法修改了要返回的词法单元,而且由于涉及到多重转义,在设计规则的时候一定要注意双引号和反斜杠的个数。

相应的设计时词法分析器定义为:

[LexerContext("str")]
[LexerContext("vstr")]
partial class TestEscapeStrController : LexerController<Str>
{
	private const string CtxStr = "str";
	private const string CtxVstr = "vstr";

	private int curStart;
	private readonly StringBuilder decodedText = new();

	[LexerSymbol(@"\""")]
	public void BeginStrAction()
	{
		PushContext(CtxStr);
		curStart = Start;
		decodedText.Clear();
	}

	[LexerSymbol(@"@\""")]
	public void BeginVstrAction()
	{
		PushContext(CtxVstr);
		curStart = Start;
		decodedText.Clear();
	}

	[LexerSymbol(@"<str, vstr>\""", Kind = Str.Str)]
	public void EndAction()
	{
		PopContext();
		Start = curStart;
		Text = decodedText.ToString();
	}

	[LexerSymbol(@"<str>\\u[0-9]{4}")]
	[LexerSymbol(@"<str>\\x[0-9]{2}")]
	public void HexEscapeAction()
	{
		decodedText.Append((char)int.Parse(Text.AsSpan()[2..], NumberStyles.HexNumber));
	}

	[LexerSymbol(@"<str>\\n")]
	public void EscapeLFAction()
	{
		decodedText.Append('\n');
	}

	[LexerSymbol(@"<str>\\\""")]
	public void EscapeQuoteAction()
	{
		decodedText.Append('\"');
	}

	[LexerSymbol(@"<str>\\r")]
	public void EscapeCRAction()
	{
		decodedText.Append('\r');
	}

	[LexerSymbol(@"<*>.")]
	public void CopyAction()
	{
		decodedText.Append(Text);
	}

	[LexerSymbol(@"<vstr>\""\""")]
	public void VstrQuoteAction()
	{
		decodedText.Append('"');
	}
}

现在,完整的词法分析器已经成功构造出来,相关代码都可以在这里找到。

posted @ 2013-05-07 01:01  CYJB  阅读(7813)  评论(2编辑  收藏  举报
Fork me on GitHub