65.9K
CodeProject 正在变化。 阅读更多。
Home

如何在 C# 中构建分词器/词法分析器生成器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (12投票s)

2019 年 11 月 27 日

MIT

15分钟阅读

viewsIcon

29566

downloadIcon

597

构建一个功能齐全的 C# 分词器生成器

引言

这是对 《如何在 C# 中构建正则表达式引擎》 的续篇。我们将利用已开发的内容,并在此基础上进行扩展,创建一个功能完备的词法分析器生成器。

首先,什么是词法分析器?简而言之,词法分析器对解析器很有用。解析器利用它们将输入文本流分解为带有符号标记的词素,以便识别特定文本块的“类型”。如果您还不了解,请参阅上面的前一篇文章,因为它解释了词法分析/分词。无论如何,最好从那里开始。此外,您还可以回顾其中一些很棒的代码。正如我所说,我们将在那里已有的基础上进行构建。我已将源代码包含在本文档中。

分词器/词法分析器几乎总是用于解析器,但它们不一定如此。只要需要将文本分解为符号碎片,就可以使用它们。

背景

我们将构建 Rolex,这个“镀金”的词法分析器。它具有一些独特的特性,因此得名“镀金”。

首先,它可以创建没有任何外部依赖项的词法分析器,这在 .NET 领域有限的词法分析器生成器中是罕见甚至闻所未闻的。它可以生成其所有依赖代码作为源代码,并以 CodeDOM 合理支持的任何语言生成,因此不需要外部库。但是,您可以像引用任何程序集一样在项目中引用 Rolex.exe,并且您的分词器代码可以选择使用它作为外部库。

其次,它有一个名为“块结束”的功能,可以轻松匹配具有多字符结束条件的项,例如 C 语言块注释、标记注释和 CDATA 部分。

它还可以隐藏令牌,例如注释和空格。

它通过一种带属性的规范格式公开了所有这些内容。

Digits='[0-9]+'
Word='[A-Za-z]+'
Whitespace='\s'

这是我们从前一篇文章中使用的词法分析器,作为 Rolex 规范文件 (demo.rl)。

为了好玩和演示的目的,我们将在此添加一个 C 风格的块注释。与其使用复杂的正则表达式,不如直接编辑文件,使其如下所示:

Digits='[0-9]+'
Word='[A-Za-z]+'
Whitespace='\s'
Comment<blockEnd="*/",hidden>="/*"

请注意,上面的文本中同时存在单引号和双引号。单引号表示正则表达式,而双引号表示字面量。块结束必须是字面量,但其开始表达式不一定。请记住根据需要转义您的正则表达式和字符串。

还请注意注释是如何用尖括号声明的。其中的内容是属性,它们可以为任何词法分析器规则指定修饰符。我们为 Comment 指定了两个修饰符:blockEndhidden。这些分别指定了终止字符串,并从分词器输出中隐藏了该规则。

我们还可以使用属性 id 来显式指定符号的数字 id,该属性接受数字字面量(无引号),但不要太过分使用巨大的数字,因为每个数字都是一个数组索引,所以如果一个数字是 5000,它将创建一个包含 5000 个元素的数组。

这些是我们目前支持的唯一三个属性,但它们应该足以满足大多数需求。如果不够,希望深入研究这里能帮助您进行扩展。

我们广泛使用了 CodeDomUtility,所以最好快速阅读一下 这篇 Code Project 文章 - 它很短。

编写这个混乱的程序

我们的命令行处理和核心应用程序的大部分内容都在 Program.cs 中。

string inputfile = null;
string outputfile = null;
string name = null;
var codelanguage = "cs";
string codenamespace = null;
bool noshared = false;
bool lib = false;

这些是我们从命令行读取的应用程序参数。大多数参数都是不言自明的,除了 namenosharedlib。它们指定了生成的类的名称,以及是否生成依赖代码 - 指定 nosharedlib 会阻止生成依赖代码。前者在同一项目中生成多个分词器时很有用。您会生成第一个不带 noshared 的分词器,然后为后续的分词器加上 noshared,这样就只有一份基类和其他结构的副本。后者在我们想将 Rolex.exe 用作外部引用的程序集而不是将共享代码直接放入项目中时很有用。它会使生成的代码使用外部库。

我们在循环中使用 switch 来解析第一个参数之后的每个参数,使用一种额外的简单方式来指定命令行参数值 - 我们只需使用 args[] 数组中紧随其后的参数。因此,如果我们看到 "/namespace",下一个参数就是我们填充 codenamespace 变量的值。

for(var i = 1;i<args.Length;++i)
{
    switch(args[i])
    {
        case "/output":
            if (args.Length - 1 == i) // check if we're at the end
                throw new ArgumentException(string.Format
                ("The parameter \"{0}\" is missing an argument", args[i].Substring(1)));
            ++i; // advance 
            outputfile = args[i];
            break;
        case "/name":
            if (args.Length - 1 == i) // check if we're at the end
                throw new ArgumentException(string.Format
                ("The parameter \"{0}\" is missing an argument", args[i].Substring(1)));
            ++i; // advance 
            name = args[i];
            break;
        case "/language":
            if (args.Length - 1 == i) // check if we're at the end
                throw new ArgumentException(string.Format
                ("The parameter \"{0}\" is missing an argument", args[i].Substring(1)));
            ++i; // advance 
            codelanguage = args[i];
            break;
        case "/namespace":
            if (args.Length - 1 == i) // check if we're at the end
                throw new ArgumentException(string.Format
                ("The parameter \"{0}\" is missing an argument", args[i].Substring(1)));
            ++i; // advance 
            codenamespace = args[i];
            break;
        case "/noshared":
            noshared = true;
            break;
        case "/lib":
            lib = true;
            break;
        default:
            throw new ArgumentException(string.Format("Unknown switch {0}", args[i]));
    }
}

请注意,我们在这里抛出了异常,并且(上面未显示)我们已将 try/catch 块包装在 DEBUG 条件中。这样,在调试版本中,我们将获得异常而不是被抑制并被发送到带有错误消息的用法屏幕 - 这是在发布版本中发生的情况。我们通过抛出异常来报告所有错误。这使事情变得更清晰。

我们本可以在上面为错误消息使用 const,但在这个演示中我只是复制粘贴了。在实际应用中,我们可能更倾向于将消息保存在资源字符串表中。

接下来我们关心的代码行是:

var rules = _ParseRules(input);
_FillRuleIds(rules);

这些代码解析规则,并填充规则 id,但我们稍后会讲到。首先,什么是规则?每条规则是我们在前面看过的规范文件中的一行,共有四条规则。代码中的结构如下:

class _LexRule
{
    public int Id;
    public string Symbol;
    public KeyValuePair<string, object>[] Attributes;
    public RegexExpression Expression;
    public int Line;
    public int Column;
    public long Position;
}

您可以看到每条规则都有很多属性。最后三个字段不作为词法分析的一部分使用,它们只是告诉我们在文档中的哪个位置找到了规则,用于错误报告。Symbol 是等号左侧的标识符,Attributes<> 之间的命名值,Expression 是表示规则右侧的正则表达式 DOM/AST。有关如何使用该 DOM,请参阅前一篇文章。实际上我们只会使用它的一个方法。

解析例程使用了 Regex 库中最新但尚未发布的 ParseContext 版本。它在功能上与 本文中的版本 相同,但有一些小的 bug 修复(主要是命名)和更好的代码结构。它采用了一种巧妙但便捷的方法,即将属性值视为 JSON 并像解析 JSON 一样解析它们,因此 JSON 字符串转义以及所有 JSON 字面量等在此处都是有效的。总而言之,整个例程只是使用递归下降解析与解析上下文来将输入文件读入一个词法规则列表。一旦您熟悉了 ParseContext 的工作原理,该例程中唯一真正奇怪的部分就是我们解析正则表达式的时候。

if ('\'' == pc.Current)
{
    pc.Advance();
    pc.ClearCapture();
    pc.TryReadUntil('\'', '\\', false);
    pc.Expecting('\'');
    var pc2 = ParseContext.Create(pc.GetCapture());
    pc2.EnsureStarted();
    pc2.SetLocation(pc.Line, pc.Column, pc.Position);
    var rx = RegexExpression.Parse(pc2);
    pc.Advance();
    rule.Expression = rx;
}

我们在捕获主 ParseContext 的时候创建了一个新的 ParseContext。这样做是为了确保我们的新解析上下文只能“看到”单引号之间的文本。我们已经有效地扫描并捕获了引号之间的内容,将其视为自己的字符串,然后像通常那样在其上创建一个解析上下文。我们更新第二个解析上下文的位置信息,以便它能正确报告错误位置。然后,我们将解析上下文传递给 RegexExpression.Parse() 方法。这就是我们告诉该类何时停止解析的方法,因为它不了解我们的文件格式。它只看到正则表达式。

继续讲 _FillRuleIds():我们必须填充我们每条规则的 id。有些可能已经通过 id 属性在输入文件中填写了。我们必须保留这些,并按顺序“围绕它们”填充 id,这样,如果我们指定了一个 id 为 5 的规则,我们必须创建新的规则而不重复使用 5 这个 id。我们还必须向上编号。我们这样做的方式是移动,使得我们看到的最后一个 id 成为我们的新起点,然后我们为每条规则递增一,跳过任何已声明的规则,并检查重复项,这是不允许的。描述起来有点复杂,但使用起来很直观,而且很容易编写。

static void _FillRuleIds(IList<_LexRule> rules)
{
    var ids = new HashSet<int>();
    for (int ic = rules.Count, i = 0; i < ic; ++i)
    {
        var rule = rules[i];
        if (int.MinValue!=rule.Id && !ids.Add(rule.Id))
            throw new InvalidOperationException(string.Format
            ("The input file has a rule with a duplicate id at line {0}, 
            column {1}, position {2}", rule.Line, rule.Column, rule.Position));
    }
    var lastId = 0;
    for (int ic = rules.Count, i = 0; i < ic; ++i)
    {
        var rule = rules[i];
        if(int.MinValue==rule.Id)
        {
            rule.Id = lastId;
            ids.Add(lastId);
            while(ids.Contains(lastId))
                ++lastId;
        } else
        {
            lastId = rule.Id;
            while (ids.Contains(lastId))
                ++lastId;
        }
    }
}

我们还有一个重要辅助方法需要介绍。

static CharFA<string> _BuildLexer(IList<_LexRule> rules)
{
    var exprs = new CharFA<string>[rules.Count];
    for(var i = 0;i<exprs.Length;++i)
    {
        var rule = rules[i];
        exprs[i]=rule.Expression.ToFA(rule.Symbol);
    }
    return CharFA<string>.ToLexer(exprs);
}

请注意,这使用了 Regex 中的 CharFA<TAccept> 类。它的作用是获取每条规则的正则表达式,然后指示 Regex 将其转换为一个词法分析器。同样,有关这方面的工作原理,请参阅前一篇文章。这很重要,但在这里篇幅太长无法详述。其他 _BuildXXXX() 方法也从规则中获取信息并从中构建数据供我们稍后使用。

既然我们已经处理了命令行并从输入文件中读取了规则,现在让我们继续处理 Program.cs 的核心部分。

var ccu = new CodeCompileUnit();
var cns = new CodeNamespace();
if (!string.IsNullOrEmpty(codenamespace))
    cns.Name = codenamespace;
ccu.Namespaces.Add(cns);
var fa = _BuildLexer(rules);
var symbolTable = _BuildSymbolTable(rules);
var blockEnds = _BuildBlockEnds(rules);
var nodeFlags = _BuildNodeFlags(rules);
var dfaTable = fa.ToDfaStateTable(symbolTable);
if (!noshared && !lib)
{
    cns.Types.Add(CodeGenerator.GenerateTokenStruct());
    cns.Types.Add(CodeGenerator.GenerateTableTokenizerBase());
    cns.Types.Add(CodeGenerator.GenerateTableTokenizerEnumerator());
    cns.Types.Add(CodeGenerator.GenerateDfaEntry());
    cns.Types.Add(CodeGenerator.GenerateDfaTransitionEntry());
}
cns.Types.Add(CodeGenerator.GenerateTableTokenizer
             (name,dfaTable,symbolTable,blockEnds,nodeFlags));
if (lib)
    cns.Imports.Add(new CodeNamespaceImport("Rolex"));
var prov = CodeDomProvider.CreateProvider(codelanguage);
var opts = new CodeGeneratorOptions();
opts.BlankLinesBetweenMembers = false;
opts.VerbatimOrder = true;
prov.GenerateCodeFromCompileUnit(ccu, output, opts);

这里,我们主要是在构建所有数据,指示 Regex 创建 DFA 状态表,可选地生成共享源代码基类,然后将我们刚才创建的所有内容传递给 CodeGenerator.cs 中的 GenerateTableTokenizer(),我们稍后会介绍它。我们获取构建好的内容,然后将其添加到 CodeCompileUnit 中,从中生成输出。这涵盖了 Program 的所有重要方面。

CodeGenerator.cs 文件较大,但 如果没有 CodeDomUtility,它会更大。在此文件中,CodeDomUtility 被别名为 CD。读取调用 CodeDomUtility 的代码起初可能会有些困难,但随着您逐渐适应,会变得容易得多。这些例程中的大多数都生成静态代码,这些代码只是 Tokenizer.cs 中引用和库代码的生成版本。我们生成它的原因是因为它可以采用 .NET 语言(只要有兼容的 CodeDOM 提供程序)编写。

生成动态代码的主要公共例程是 GenerateTableTokenizer(),它的作用是将传入的数组序列化为新类的静态字段,该类继承自 TableTokenizer,并简单地将构造函数参数传递给基类的构造函数。基类要么存在于源代码中,要么作为引用的程序集 Rolex.exe 的一部分包含在内,如果您是以这种方式使用的话。同时,每个静态生成方法在 C# 的 Tokenizer.cs 中都有等效的实现,所以让我们来探讨一下。

public class TableTokenizer : IEnumerable<Token>
{
    // our state table
    DfaEntry[] _dfaTable;
    // our block ends (specified like comment<blockEnd="*/">="/*" in a rolex spec file)
    string[] _blockEnds;
    // our node flags. Currently only used for the hidden attribute
    int[] _nodeFlags;
    // the input cursor. We can get this from a string, a char array, or some other source.
    IEnumerable<char> _input;
    /// <summary>
    /// Retrieves an enumerator that can be used to iterate over the tokens
    /// </summary>
    /// <returns>An enumerator that can be used to iterate over the tokens</returns>
    public IEnumerator<Token> GetEnumerator()
    {
        // just create our table tokenizer's enumerator, passing all of the relevant stuff
        // it's the real workhorse.
        return new TableTokenizerEnumerator
               (_dfaTable, _blockEnds, _nodeFlags, _input.GetEnumerator());
    }
    // legacy collection support (required)
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
        => GetEnumerator();
    /// <summary>
    /// Constructs a new instance
    /// </summary>
    /// <param name="dfaTable">The DFA state table to use</param>
    /// <param name="blockEnds">The block ends table</param>
    /// <param name="nodeFlags">The node flags table</param>
    /// <param name="input">The input character sequence</param>
    public TableTokenizer(DfaEntry[] dfaTable,
           string[] blockEnds,int[] nodeFlags,IEnumerable<char> input)
    {
        if (null == dfaTable)
            throw new ArgumentNullException(nameof(dfaTable));
        if (null == blockEnds)
            throw new ArgumentNullException(nameof(blockEnds));
        if (null == nodeFlags)
            throw new ArgumentNullException(nameof(nodeFlags));
        if (null == input)
            throw new ArgumentNullException(nameof(input));
        _dfaTable = dfaTable;
        _blockEnds = blockEnds;
        _nodeFlags = nodeFlags;
        _input = input;
    }
}

这是什么?去掉注释后,几乎没有什么东西,只有一些成员字段和一个构造函数。但是请注意,GetEnumerator() 方法返回一个 TableTokenizerEnumerator,它将所有成员数据传递给它。如果您曾经编写过集合类,那么您已经知道这个方法使您能够编写 foreach(var token in myTokenizer) 来分词输入,我们稍后会讲到,但在此之前,让我们看看这个庞然大物,我们的令牌枚举器。

class TableTokenizerEnumerator : IEnumerator<Token>
{
    // our error symbol. Always -1
    public const int ErrorSymbol= -1;
    // our end of stream symbol - returned by _Lex() and used internally but not reported
    const int _EosSymbol = -2;
    // our disposed state indicator
    const int _Disposed = -4;
    // the state indicates the cursor is before the beginning (initial state)
    const int _BeforeBegin = -3;
    // the state indicates the cursor is after the end
    const int _AfterEnd = -2;
    // the state indicates that the inner input enumeration has finished 
    // (we still have one more token to report)
    const int _InnerFinished = -1;
    // indicates we're currently enumerating. 
    // We spend most of our time and effort in this state
    const int _Enumerating = 0;
    // indicates the tab width, used for updating the Column property when we encounter a tab
    const int _TabWidth = 4;
    // the DFA state table to use.
    DfaEntry[] _dfaTable;
    // the blockEnds to use
    string[] _blockEnds;
    // the nodeFlags to use
    int[] _nodeFlags;
    // the input cursor
    IEnumerator<char> _input;
    // our state 
    int _state;
    // the current token
    Token _current;
    // a buffer used primarily by _Lex() to capture matched input
    StringBuilder _buffer;
    // the one based line
    int _line;
    // the one based column
    int _column;
    // the zero based position
    long _position;
...

真是个大家伙!在这里,我们有几个常量、DFA 表、块结束符和节点标志、输入光标、状态指示器、当前 Token、字符串缓冲区以及一些文本位置信息。是的,Ramona,我们都需要。

与大多数枚举器类一样,我们的核心是 MoveNext(),它只需读取下一个令牌。

public bool MoveNext()
{
    // if we're not enumerating
    if(_Enumerating>_state)
    {
        if (_Disposed == _state)
            _ThrowDisposed();
        if (_AfterEnd == _state)
            return false;
        // we're okay if we got here
    }
    _current = default(Token);
    _current.Line = _line;
    _current.Column = _column;
    _current.Position = _position;
    _buffer.Clear();
    // lex the next input
    _current.SymbolId = _Lex();
    // now look for hiddens and block ends
    var done = false;
    while (!done)
    {
        done = true;
        // if we're on a valid symbol
        if (ErrorSymbol < _current.SymbolId)
        {
            // get the block end for our symbol
            var be = _blockEnds[_current.SymbolId];
            // if it's valid
            if (!string.IsNullOrEmpty(be))
            {
                // read until we find it or end of input
                if (!_TryReadUntilBlockEnd(be))
                    _current.SymbolId = ErrorSymbol;
            } 
            // node is hidden?
            if (ErrorSymbol<_current.SymbolId && 0 != (_nodeFlags[_current.SymbolId] & 1))
            { 
                // update the cursor position and lex the next input, skipping this one
                done = false;
                _current.Line = _line;
                _current.Column = _column;
                _current.Position = _position;
                _buffer.Clear();
                _current.SymbolId = _Lex();
            }
        }    
    }
    // get what we captured
    _current.Value = _buffer.ToString();
    // update our state if we hit the end
    if (_EosSymbol == _current.SymbolId)
        _state = _AfterEnd;
    // return true if there's more to report
    return _AfterEnd!=_state;
}

这里的绝大部分复杂性都涉及处理块结束符和隐藏令牌(在例程的末尾)。确实,除了这些以及一些簿记工作外,没什么特别的——哦,还有对 _Lex() 的调用,这是我们分词器/词法分析器的核心和灵魂。这个例程只是使用 DFA 表扫描输入,并在每次调用时报告它找到的内容,并移动光标。它在 _buffer_line_column_position 中报告文本和行信息,并返回匹配的符号 id - 出错时返回 -1 (_ErrorSymbol)。

// lex the next token
public int _Lex()
{
    // our accepting symbol id
    int acceptSymbolId;
    // the DFA state we're currently in (start at zero)
    var dfaState = 0;
    // corner case for beginning
    if (_BeforeBegin == _state)
    {
        if (!_MoveNextInput()) // empty input.
        {
            // if we're on an accepting state, return that
            // otherwise, error
            acceptSymbolId = _dfaTable[dfaState].AcceptSymbolId;
            if (-1 != acceptSymbolId)
                return acceptSymbolId;
            else
                return ErrorSymbol;
        }
        // we're enumerating now
        _state = _Enumerating;
    }
    else if (_InnerFinished == _state || _AfterEnd == _state)
    {
        // if we're at the end just return the end symbol
        return _EosSymbol;
    }
    // Here's where we run most of the match. we run one iteration of the DFA state machine.
    // We match until we can't match anymore (greedy matching) 
    // and then report the symbol of the last 
    // match we found, or an error ("#ERROR") if we couldn't find one.
    var done = false;
    while (!done)
    {
        var nextDfaState = -1;
        // go through all the transitions
        for (var i = 0; i < _dfaTable[dfaState].Transitions.Length; i++)
        {
            var entry = _dfaTable[dfaState].Transitions[i];
            var found = false;
            // go through all the ranges to see if we matched anything.
            for (var j = 0; j < entry.PackedRanges.Length; j++)
            {
                var ch = _input.Current;
                // grab our range from the packed ranges into first and last
                var first = entry.PackedRanges[j];
                ++j;
                var last = entry.PackedRanges[j];
                // do a quick search through our ranges
                if ( ch <= last)
                {
                    if (first <= ch) 
                        found = true;
                    j = int.MaxValue - 1; // break
                }
            }
            if (found)
            {
                // set the transition destination
                nextDfaState = entry.Destination;
                i = int.MaxValue - 1; // break
            }
        }

        if (-1 != nextDfaState) // found a valid transition
        {
            // capture our character
            _buffer.Append(_input.Current);
            // and iterate to our next state
            dfaState = nextDfaState;
            if (!_MoveNextInput())
            {
                // end of stream, if we're on an accepting state,
                // return that, just like we do on empty string
                // if we're not, then we error, just like before
                acceptSymbolId = _dfaTable[dfaState].AcceptSymbolId;
                if (-1 != acceptSymbolId) // do we accept?
                    return acceptSymbolId;
                else
                    return ErrorSymbol;
            }
        }
        else
            done = true; // no valid transition, we can exit the loop
    }
    // once again, if the state we're on is accepting, return that
    // otherwise, error, almost as before with one minor exception
    acceptSymbolId = _dfaTable[dfaState].AcceptSymbolId;
    if(-1!=acceptSymbolId)
    {
        return acceptSymbolId;
    }
    else
    {
        // handle the error condition
        // we have to capture the input 
        // here and then advance or the 
        // machine will never halt
        _buffer.Append(_input.Current);
        _MoveNextInput();
        return ErrorSymbol;
    }
}

它看起来在做很多事情,但实际上并没有,也不应该这样做,因为代码的速度至关重要。如果用于解析器,这是内部循环的内部循环的内部循环。它必须很快。

它的工作原理基于我们在上一篇文章中看到的那些图。这些图被烘焙到我们的状态表中,dfaTable[]。表中的每个条目都包含 Transitions 和一个 AcceptSymbolId。每个转换都包含一组打包的字符范围(存储为字符数组中的相邻字符对)和一个目标状态索引。在内部的 for 循环中,我们遍历转换的打包范围,以查看字符是否落在其中任何一个范围内。如果找到一个,我们将 dfaState 变量设置为 next 状态并继续。我们这样做直到我们无法匹配任何转换或耗尽输入。一旦发生这种情况,如果我们在一个接受状态(_dfaTable[dfaState].AcceptSymbolId 不等于 -1),我们就报告该符号。如果不是,我们就报告 _ErrorSymbol (-1)。遍历这些状态实际上很简单。诀窍在于首先生成那些表的数据,但 Regex 为我们完成了所有工作,通过我们之前看到的 ToLexer() 创建了它们,而 ToLexer() 又在 _ParseRules() 期间通过那些 _LexRule 上的 RegularExpression Expression 字段创建的。真是“杰克建造的房子”!尽管如此,我们终于完成了。

该参考实现中的其余代码是支持代码,例如 _MoveNextInput(),它将 _inner 的光标位置向前移动一位,并跟踪文本位置、行和列,或者 _TryReadUntilBlockEnd(),它的作用如其名称所示 - 它尝试读取直到匹配指定的块结束符(参见前面)。

最后,让我们深入了解 CodeGenerator.cs 的更多内容。

static CodeMemberMethod _GenerateLexMethod()
{
    var state = CD.FieldRef(CD.This, "_state");
    var input = CD.FieldRef(CD.This, "_input");
    var inputCurrent = CD.PropRef(input, "Current");
    var dfaTable = CD.FieldRef(CD.This, "_dfaTable");
    var dfaState = CD.VarRef("dfaState");
    var acc = CD.VarRef("acc");
    var done = CD.VarRef("done");
    var currentDfa = CD.ArrIndexer(dfaTable, dfaState);
    var invMoveNextInput = CD.Invoke(CD.This, "_MoveNextInput");
    var result = CD.Method(typeof(int), "_Lex");
    result.Statements.AddRange(new CodeStatement[] {
        CD.Var(typeof(int),"acc"),
        CD.Var(typeof(int),"dfaState",CD.Zero),
        CD.IfElse(CD.Eq(CD.Literal(_BeforeBegin),state),new CodeStatement[] {
            CD.If(CD.Not(invMoveNextInput),
                CD.Let(acc,CD.FieldRef(currentDfa,"AcceptSymbolId")),
                CD.IfElse(CD.NotEq(CD.NegOne,acc),new CodeStatement[] {
                    CD.Return(acc)
                },
                    CD.Return(CD.Literal(_ErrorSymbol))
                )
            ),
            CD.Let(state,CD.Literal(_Enumerating))
        },
            CD.If(CD.Or(CD.Eq(CD.Literal(_InnerFinished),state),
                  CD.Eq(CD.Literal(_AfterEnd),state)),
                CD.Return(CD.Literal(_EosSymbol))
            )
        ),
        CD.Var(typeof(bool),"done",CD.False),
        CD.While(CD.Not(done),
            CD.Var(typeof(int),"next",CD.NegOne),
            CD.For(CD.Var(typeof(int),"i",CD.Zero),CD.Lt(CD.VarRef("i"),
            CD.PropRef(CD.FieldRef(currentDfa,"Transitions"),"Length")),
            CD.Let(CD.VarRef("i"),CD.Add(CD.VarRef("i"),CD.One)),
                CD.Var("DfaTransitionEntry","entry",
                CD.ArrIndexer(CD.FieldRef(currentDfa,"Transitions"),CD.VarRef("i"))),
                CD.Var(typeof(bool),"found",CD.False),
                CD.For(CD.Var(typeof(int),"j",CD.Zero),CD.Lt(CD.VarRef("j"),
                CD.PropRef(CD.FieldRef(CD.VarRef("entry"),"PackedRanges"),"Length")),
                CD.Let(CD.VarRef("j"),CD.Add(CD.VarRef("j"),CD.One)),
                    CD.Var(typeof(char),"ch",inputCurrent),
                    CD.Var(typeof(char),"first",CD.ArrIndexer(CD.FieldRef(CD.VarRef("entry"),
                    "PackedRanges"),CD.VarRef("j"))),
                    CD.Let(CD.VarRef("j"),CD.Add(CD.VarRef("j"),CD.One)),
                    CD.Var(typeof(char),"last",CD.ArrIndexer(CD.FieldRef(CD.VarRef("entry"),
                    "PackedRanges"),CD.VarRef("j"))),
                    CD.If(CD.Lte(CD.VarRef("ch"),CD.VarRef("last")),
                        CD.If(CD.Lte(CD.VarRef("first"),CD.VarRef("ch")),
                            CD.Let(CD.VarRef("found"),CD.True)
                        ),
                        CD.Let(CD.VarRef("j"),CD.Literal(int.MaxValue-1))
                    )
                ),
                CD.If(CD.Eq(CD.VarRef("found"),CD.True),
                    CD.Let(CD.VarRef("next"),
                    CD.FieldRef(CD.VarRef("entry"),"Destination")),
                    CD.Let(CD.VarRef("i"),CD.Literal(int.MaxValue-1))
                )
            ),
            CD.IfElse(CD.NotEq(CD.VarRef("next"),CD.NegOne),new CodeStatement[] {
                CD.Call(CD.FieldRef(CD.This,"_buffer"),"Append",inputCurrent),
                CD.Let(dfaState,CD.VarRef("next")),
                CD.If(CD.Not(invMoveNextInput),
                    CD.Let(acc,CD.FieldRef(currentDfa,"AcceptSymbolId")),
                    CD.IfElse(CD.NotEq(acc,CD.NegOne), new CodeStatement[] {
                        CD.Return(acc)
                    },
                        CD.Return(CD.Literal(_ErrorSymbol))
                    )
                )
            },
                CD.Let(done,CD.True)
            )
        ),
        CD.Let(acc,CD.FieldRef(currentDfa,"AcceptSymbolId")),
        CD.IfElse(CD.NotEq(acc,CD.NegOne), new CodeStatement[] {
            CD.Return(acc)
        },
            CD.Call(CD.FieldRef(CD.This,"_buffer"),"Append",inputCurrent),
            CD.Call(CD.This,"_MoveNextInput"),
            CD.Return(CD.Literal(_ErrorSymbol))
        )
    });
    return result;
}

哇,真是个晦涩难懂的噩梦。等等。戴上你的 X 射线眼镜,将其与它生成的代码(我在前面的代码段中提供)进行比较。你会看到这样的内容:

CD.Call(CD.FieldRef(CD.This,"_buffer"),"Append",inputCurrent),
CD.Let(dfaState,CD.VarRef("next")),

这(至少在 C# 中)字面上翻译为:

_buffer.Append(input.Current);
dfaState = next;

我们稍微作弊了一下,预先声明了 nextinputCurrent,但代码的结构就在那里。整个例程就是这样布局的。熟悉 CodeDOM 会有帮助。整个类都有文档记录,所以您应该会看到每个方法的作用的工具提示。如果您将其与参考代码进行比较,您会发现整体结构是相同的。它有点冗长,但远不如 CodeDOM 那么糟糕。您会看到我习惯于内联声明,即使在必须内联数组的情况下也是如此。这样布局可以让我直接镜像参考代码的结构,并且使创建和维护它更容易,因为参考源代码现在是有效的“主文档”。我希望这有道理。通常,使用 CodeDOM 时,您的代码生成例程与它应该生成的代码一点也不像。这旨在纠正其中的一些问题。现在,生成的代码在结构上看起来与它应该生成的代码非常相似。所有的 if 和其他声明都内联在它们应该在生成的源代码中的位置。

看到了吗?曾经像黑魔法一样的东西,只是一个让维护负担减轻一点的魔法。

有点好笑的是,我们已经走到了这一步,但还没有介绍如何使用这个工具。

rolex.exe usage screen

  • inputfile - 必需,指定前面描述的输入词法分析器规范。
  • outputfile - 可选,要生成的输出文件 - 默认为 stdout。不建议使用 stdout,因为控制台可能会“烘烤”特殊字符(如 Unicode 字符),导致它们乱码,从而破坏 DFA 表。这不总是发生,但一旦发生,就会很麻烦,而且很难追踪。
  • name - 可选,这是要生成的类的名称。如果未指定,则可以从文件名中获取。
  • codelanguage - 可选,要生成的代码的语言。大多数系统支持 VB 和 CS (C#),但有些系统可能有其他语言。您的体验可能会有所不同,因为这应该适用于许多(如果不是大多数)语言,但事先很难知道它会与哪些语言一起工作。
  • codenamepace - 可选,如果指定,则表示将在其下生成代码的 .NET 命名空间。
  • noshared - 可选,如果指定,则表示生成的代码中不包含共享代码。当一个项目中有多个分词器时,这可能很有用。第一个分词器源文件可以不带此开关生成,然后后续的分词器源文件应带此开关生成,这样就只有一份共享代码。如果指定了 lib,则永远不会生成共享代码。
  • lib - 可选,如果指定,则表示将 rolex.exe 用作项目中的程序集引用。这不会添加程序集引用,但会设置代码以便能够使用它。使用 rolex.exe 最终分发版可能还需要 Regex.dll,但我尚未完全确认。我实际上不推荐这样做,因为这只是一个易于生成的共享代码的额外负担,您可以直接将其包含在您的项目中。这就是为什么默认情况下此选项未开启的原因。您实际上可以创建自己的共享库,只需将 rolex 规范输出到“Rolex”命名空间并删除非共享代码,然后进行编译。这样做不需要上述程序集,并且仍然允许您在此新构建的库中使用 lib 选项。

好了,既然我们已经介绍了如何从命令行生成,让我们来探索如何使用代码 - 我们从上面的词法分析器规范生成了它,但删除了“hidden”属性,以便注释显示出来。

// for display in the console - your code doesn't need this
// as you can get to the constants from demo.Digits/demo.Word, etc.
var syms = new string[] { "Digits", "Word", "Whitespace", "Comment" };
// a test string with a deliberate error at the end 
// (just for completeness)
var test = "baz123/***foo/***/ /**/bar1foo/*/";

// create our tokenizer over the test string
var tokenizer = new demo(test);
// enumerate the tokens and dump them to the console
foreach (var token in tokenizer)
    Console.WriteLine(
        "{0}: \"{1}\" at line {3}, column {4}, position {2}", 
        token.SymbolId != -1 ? syms[token.SymbolId] : "#ERROR", 
        token.Value, 
        token.Position, 
        token.Line, 
        token.Column);

这将产生以下输出:

Word: "baz" at line 1, column 1, position 0
Digits: "123" at line 1, column 4, position 3
Comment: "/***foo/***/" at line 1, column 7, position 6
Whitespace: " " at line 1, column 19, position 18
Comment: "/**/" at line 1, column 20, position 19
Word: "bar" at line 1, column 24, position 23
Digits: "1" at line 1, column 27, position 26
Word: "foo" at line 1, column 28, position 27
#ERROR: "/*/" at line 1, column 31, position 30

这很简单,但是,在实际的解析器中,您可能不会使用 foreach。相反,您将手动使用 IEnumerator<Token>,就像我们的分词器手动使用 IEnumerator<char> 一样。不过,只有几个方法和一个属性很重要,所以问题不大。

我们将在未来的关于如何构建解析器(再次)的文章中介绍这一点。

我希望这至少能让 lex/flex 和 bison 等工具不再神秘。它们本质上做的是同样的事情。此外,也许现在您了解了这个词法分析器是如何工作的,您就会自己做出一个更好的词法分析器。

历史

  • 2019 年 11 月 27 日 - 初始提交
© . All rights reserved.