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

解析一切:高级 Parsley,第 2 部分

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.33/5 (6投票s)

2020 年 1 月 6 日

MIT

11分钟阅读

viewsIcon

8277

downloadIcon

217

使用 Parsley 将 C# 子集解析到 CodeDOM

引言

在我上一篇文章中,我介绍了如何使用 Parsley 的更高级功能来解析复杂的语法——在这种情况下是 Slang,一个我在代码生成项目中使用的 C# 子集。本文是其合乎逻辑的后续——将解析后的树转化为有用的东西。

背景

自然,这篇文章会引发一个问题:是手工编写还是生成解析器更好?您会发现,将解析树转化为有用东西的工作与解析本身非常相似,那么它又能在哪里节省精力呢?

这是一个好问题,值得回答。简而言之,它并不能。解析器生成器能在错误处理和解析方面提供一致性,从而减少错误并提高代码的健壮性。它并不一定能为您节省精力。获得的解析树(在很大程度上)已经过语法和结构的检查,因此您已经知道它看起来会是什么样子,这意味着在将解析树转换为更有趣的东西时,您不需要另一层错误处理。不过,您仍然必须编写代码来转换树。这正是我们在这里要做的事情。就我个人而言,我倾向于手工编写的方法,但 Slang 太大了,我无法处理所有错误处理并确保我掌握了语法。我认为在某个点之后,生成的解析器会非常有帮助。

概念化这个混乱的局面

在上一篇文章中,我们留下了一个尚可使用的 Slang 语法,需要进行测试,并暗示了我们下一步要做什么。我们需要将那个混乱的东西变成一个 CodeDOM 图。

通常,我们会使用 Parsley 的 => { ... } 语法引导操作功能来实现这一点,但在这里我没有这样做,主要是因为 /fast 选项不支持上述功能(操作总是会被解析,这会减慢解析器生成过程),而且我还不想让最终方法被命名为 Evaluate(),因为在这个例子中这个名字没有意义。

相反,我只是创建了一个 partial class 别名为 SlangParser,并自己添加了方法。我还添加了一些辅助扩展,主要是为了节省我的手指。不幸的是,最终的代码并不那么容易理解,因为它只是对解析树进行索引。我会打印出解析树,数一下找到索引,然后从那里编码。我没有索引的常量,因为这很快就会变得难以承受。根本有太多的索引了。不幸的是,没有神奇的方法可以使这更容易,尽管如果您将语法编码为使解析树始终具有相同的“形状”——也就是说,避免可选表达式等会改变子节点长度的元素——就可以更容易一些。在某些情况下,我已经这样做了。尤其是在我的虚拟节点中,我会注意使解析树的形状保持不变,无论它是如何填充的,因为我对此拥有完全的控制权。

为了让您对代码的外观有一个大致了解,以下是从解析树创建 CodeVariableDeclarationStatement 的代码。

static CodeStatement _BuildVariableDeclarationStatement(ParseNode node)
{
    CodeTypeReference vt = null;
    if (StatementParser.varType != node.C(0).SymbolId)
        vt = _BuildType(node.C(0));
    var vd = new CodeVariableDeclarationStatement().SetLoc(node);
    if (null != vt)
        vd.Type = vt;
    vd.Name = node.C(1).Value;
    if (3 < node.CL())
    {
        vd.InitExpression = _BuildExpression(node.C(3));
    }
    if (null != vd.InitExpression)
        vd.Mark(node, null == vd.Type);
    return vd;
}

C()CL() 只是 node.Childrennode.Children.Length 的别名。Mark()SetLoc() 使用我们将用于稍后各种信息的标记 UserData 来标记 CodeDOM 对象。Mark() 标记一个对象以供访问,并可能由 SlangPatcherCode DOM Go Kit 的一部分)进行解析,而 SetLoc() 只是将节点的位置信息复制到对象上,我们可以稍后用于行指示符和错误报告。整个过程有点像一开始手工解析代码,只是它是针对我们已经拥有基本形状的、经过语法检查的数据结构进行的,这得益于我们的语法。

编写这个混乱的程序

构建这个大杂烩

请记住,Parsley 是我 Build Pack 的一部分,您需要**在 Release 模式下**进行几次构建才能运行它。这是为了构建预构建步骤中使用的二进制文件。如果您不先执行此操作,您的项目将无法构建。第一次构建时您会收到错误。那些是由于项目中必要的循环依赖关系导致的文件锁定错误。只需再次构建,错误就会消失。

在编码过程中,自上一篇文章以来,我已更新了语法和代码库,并在遇到 bug 时进行了修复。我将概述编写此代码所采取的步骤,以便您了解为自己的语法进行此操作的过程。这不是我最喜欢的活儿,但它是解析过程的最后一步。

正如我之前所说,我查看解析树并计算节点数来找出是什么。您可以使用任何 Slang 可以解析的文件来转储解析树。

try
{
    stm = File.OpenRead("mycode.cs");
    var tokenizer = new SlangTokenizer(stm);
    var pn = SlangParser.Parse(tokenizer);
    Console.WriteLine(pn.ToString("t"));
}
finally
{
    if (null != stm)
        stm.Close();
}

我在这里粗体显示了主要感兴趣的部分,即 ParseNode 上的 ToString("t")。将 "t" 传递给 ToString() 将以树形结构为您提供节点。从这个版本开始,Parsley 生成的解析器就可以使用此功能了。

从文本字符串执行此操作甚至更容易

var text = "using System;" +
    "class Program {" +
        "static void Main() {" +
            "Console.WriteLine(\"Hello World!\");" +
        "}" +
    "}";

var tokenizer = new SlangTokenizer(text);
var pn = SlangParser.Parse(tokenizer);
Console.WriteLine(pn.ToString("t"));

这将在那里输出一个很长的解析树

+- CompileUnit
   +- UsingDirective
   |  +- usingKeyword using
   |  +- NamespaceName
   |  |  +- identifier System
   |  +- semi ;
   +- CustomAttributeGroups
   +- Class
      +- Comments
      +- CustomAttributeGroups
      +- TypeAttributes
      +- Partial
      +- classKeyword class
      +- identifier Program
      +- TypeParams
      +- BaseTypes
      +- WhereClauses
      +- Members
         +- Member
            +- Method
               +- Comments
               +- CustomAttributeGroups
               +- MemberAttributes
               |  +- staticKeyword static
               +- voidType void
               +- PrivateImplementationType
               +- identifier Main
               +- lparen (
               +- MethodParamList
               +- rparen )
               +- StatementBlock
                  +- lbrace {
                  +- Statement
                  |  +- Comments
                  |  +- ExpressionStatement
 ...

请注意,我已经省略了省略号下面的部分,因为它相当“宽”,并且会换行。

这里需要注意的一点是,我们有很多空节点,如 PartialPrivateImplementationType。它们的作用是,除非它们有子节点,否则它们不会被考虑,但它们本身始终存在于树中。我设计语法的原因是为了让您更容易找到树中的索引,因为树的“高度”不会因数据而改变。换句话说,这样从 ClassMembers 的节点数总是相同的。最好这样设计您的语法。这就是您应该做的方式,而不是这样

MyProduction= [ MyOptionalElement ] foo;
MyOptionalElement= bar;
foo="foo";
bar="bar";

这将导致 MyOptionalElement 不会出现在 MyProduction 下的解析树中,除非指定了它,您应该这样做

MyProduction= MyOptionalElement foo;
MyOptionalElement= bar|; // or MyOptionalElement= [ bar ];
foo="foo";
bar="bar";

请注意,我们如何将 MyOptionalElement 的可选性移到了生产本身,使其可以为空。这就是您应该做的方式。列表节点也一样。只需将 { bar }+ 替换为生产本身下的 { bar },就像上面一样,然后使生产本身在其父节点中非可选,就像上面一样。这有时可能会产生冲突,在这种情况下可能不起作用,您将不得不忍受可变长度的解析树,或者使用自动回溯来覆盖冲突(如果可能),尽管在这种情况下不推荐这种方法,因为它会占用性能但收益甚微,而且试错回溯总是比不回溯不可靠。

现在语法树已经精简到尽可能易于遍历,我们可以继续了。一般来说,您必须编写的代码的复杂性和大小会随着您的子树节点数呈线性增长,因此对于语法的基本元素,比如上面显示的变量声明代码,它非常简单;而对于类型声明等,即使您如何精简语法,事情也会很快变得复杂起来,尽管尽可能在多个地方重用语法元素意味着尽可能重用您必须编写的代码,这可以为您节省工作。精简、重用、回收!让我们看看我们更复杂的程序之一——上面提到的类型声明。

var result = new CodeTypeDeclaration().Mark(node);
var ca = _BuildCustomAttributeGroups(node.C(1));
_AddCustomAttributes(ca, "", result.CustomAttributes);
ca.Remove("");
// make sure there's no attributes that are targeted to something other than the default:
if (0 < ca.Count)
    throw new SyntaxException
          ("Invalid attribute target", node.Line, node.Column, node.Position);
result.Attributes = 0;
if (TypeDeclParser.MemberAttributes == node.C(2).SymbolId)
{
    result.Attributes = _BuildMemberAttributes(node.C(2));
    result.TypeAttributes = _BuildMemberTypeAttributes(node.C(2));
}
else
    result.TypeAttributes = _BuildTypeAttributes(node.C(2));
// only partial if Partial has child nodes
result.IsPartial = 0<node.C(3).CL();
var name = node.C(5).Value;
if (verbatimIdentifier == node.C(5).SymbolId)
    name = name.Substring(1);
result.Name = name;
switch (node.SymbolId)
{
    case TypeDeclParser.Enum:
        result.IsEnum = true;
        break;
    case TypeDeclParser.Class:
        result.IsClass = true;
        break;
    case TypeDeclParser.Struct:
        result.IsStruct = true;
        break;
    case TypeDeclParser.Interface:
        result.IsInterface = true;
        break;
}
result.TypeParameters.AddRange(_BuildTypeParams(node.C(6)));
result.BaseTypes.AddRange(_BuildBaseTypes(node.C(7)));
var wn = node.C(8);
for(var i = 0;i<wn.Children.Length-1;i++)
{
    ++i;
    CodeTypeParameter p = null;
    var tpn = wn.C(i).C(0).Value;
    if (verbatimIdentifier == wn.C(i).C(0).SymbolId)
        tpn = tpn.Substring(1);
    for (int jc= result.TypeParameters.Count,j = 0;j<jc;++j)
    {
        var tp = result.TypeParameters[j];
        if(0==string.Compare(tp.Name,tpn,StringComparison.InvariantCulture))
        {
            p = tp;
            break;
        }
    }
    if (null == p)
        throw new SyntaxException("Where clause constraint on unspecified type parameter", 
                                   wn.Line, wn.Column, wn.Position);
    var wcpn = wn.C(i).C(2);
    for(var j = 0;j<wcpn.Children.Length;j++)
    {
        var wcc = wcpn.C(j);
        if(TypeDeclParser.newKeyword==wcc.C(0).SymbolId)
            p.HasConstructorConstraint = true;
        else
            p.Constraints.Add(_BuildType(wcc.C(0)));
                    
        ++j;
    }
}
var mn = node.C(9);
for(var i = 0;i<mn.Children.Length;i++)
    result.Members.Add(_BuildMember(mn.C(i)));
return result;

解码“where”子句是此程序的主要部分。其余的只是对解析树的基本索引,然后委托给各种其他 _BuildXXXX() 方法,这可能是 _BuildXXXX() 方法代码的 80% 或更多。 “where”子句在语法上很复杂,并且由于 CodeDOM 的映射方式而进一步复杂化,这要求我们使用非索引搜索来查找每个约束的类型参数。幸运的是,对于除最复杂的泛型之外的大多数泛型,类型参数的数量很少超过十几个,唯一例外是 Tuple<> 泛型,因此这不会对性能造成太大影响,而且只有在出现“where”时才会应用。

关于语法设计与错误的注意事项

您可以看到这是乏味的,但请注意,我们没有在此例程中进行错误处理,否则它会更加乏味。您可以在这些例程中进行错误处理,但我并不一定推荐这样做,因为解析树已经被检查过,任何错误都应该在解析过程中向上报告,这正是生成解析器的目的。如果您在这里添加错误处理,您将需要编写近乎翻倍的代码,可能会讽刺地因此引入 bug,而且除此之外,您还将重复解析本身已经执行的工作。充其量,您可能想在公开这些例程的公共方法中进行错误处理,在将解析节点向下传递之前,但您只需要验证根节点,并且仅当它从外部源传递而不是在该例程中直接解析时才进行验证。我在这些例程中进行的任何错误处理只是为了验证语法本身无法验证的少数区域。尽量构建您的语法以进行尽可能多的验证。这可以减少 bug 和在此步骤中需要编写的代码。基本上,您希望使您的语法尽可能具体和精确。有时,正如我在 Slang 语法中的一些区域所做的那样,您可能不得不诉诸于 virtual 生产来接管解析并引入复杂的错误处理,就像我们在 _ParseMember() 中做的那样。在那里您不一定需要这样做,但如果您不这样做,您将在稍后,在这次解析树检查步骤中进行验证。这并不好,因为良好的错误处理意味着尽快报告。您将需要付出努力,以便在解析阶段尽可能多地报告错误。这完全取决于您如何设计语法,因此良好的设计是关键。像任何代码一样,它可能需要几次迭代才能正确。请记住,生成解析器的主要好处之一——如果不是主要好处——就是错误处理,所以我无法过度强调在此处进行仔细设计的[_意[_]。花时间并在开始时进行精炼。

即使进行了所有这些代码编写,我们仍然没有一个完全有效的 CodeDOM 树。原因是 CodeDOM 要求我们区分字段引用和属性引用,而两者在 C# 中看起来完全相同。此外,所有方法调用最初都在此处解析为委托调用表达式,因此 myObj.ToString() 将被视为调用 ToString“字段!”引用的委托。与上面一样,方法调用和委托调用在 C# 中看起来完全相同。还有许多其他需要解析的区域,例如 var 变量声明等。话虽如此,大多数代码实际上都可以从这样的 CodeDOM 树中正确地渲染为 C#(除了 var 变量),尽管它不正确,正是因为这些未解析的字段引用和委托调用在渲染为 C# 时看起来就像“正确”的代码。这不适用于其他语言。在那些情况下,我们必须修补回来的 CodeDOM。

一般来说,我们会在操作树之前对其进行修补,但这将留待未来的文章。由于这是一个不适用于大多数解析器的专用阶段,因此这里不进行介绍。相反,我们只专注于将解析树转换为抽象语法树,而 CodeDOM 就是抽象语法树。我们从解析树创建了代码树,并将结果 AST 树中的每个可能的节点标记了它来自源文档的源行、列和位置信息。这几乎是进行合理错误处理的必要条件,也是 Slang 当前版本的致命弱点——这也是新代码试图纠正的一部分。

关于设计最终数据结构的注意事项

也许最常见的情况是,在使用您自己的解析代码时,您不会将其放入像 CodeDOM 这样的第三方数据结构中,而是放入您自己的数据结构中。当您这样做时,请记住存储有关树来自的源文档的信息——例如行号和列号。如果您一开始没有为此进行设计,您会后悔的。幸运的是,CodeDOM 对象有一个用户定义的字典,我们可以在其中插入任意信息,但对于您自己的结构,您需要设计它们,使其拥有这些数据的成员字段。

最新的代码库比我们的文章稍有超前,因为我已经包含了一些修补代码。我们将在后续文章中介绍这些代码,但它需要进行优化并在此基础上构建一个合适的演示,我宁愿专门写一篇关于它的文章。

历史

  • 2020 年 1 月 6 日 - 初始提交
© . All rights reserved.