手写解析器的最佳方式是什么?

24
我们使用ANTLR创建了一个类SQL语法的解析器,虽然在大多数情况下结果是令人满意的,但有一些边缘情况需要修复;由于我们没有编写解析器,因此我们对其不够了解,无法做出明智的更改。
因此,我们想编写自己的解析器。手写解析器的最佳方法是什么?应该使用什么类型的解析器 - 递归下降已经被推荐;这样做正确吗?我们将用C#编写它,因此任何关于在该语言中编写解析器的教程都将不胜感激。
更新:我也对涉及F#的答案感兴趣 - 我一直在寻找在项目中使用它的理由。

Simon,我正在查看你的帖子,你提到你“决定手动完成”。你是想在这里进行一个练习来学习解析,还是想要一个语义正确、可维护、快速的解析器?如果是后者,我认为你的决定过早了。你会被解析逻辑所束缚,很快就会忘记你要纠正的“少数边缘情况”。 - Sam Harwell
后者。我们走了解析器生成器的路线,最终得到了一些我们不理解的东西,因此无法修复。我宁愿选择需要几个月工作时间但可以修复的东西,而不是快速完成但无法修复的东西。 - Simon
1
我有点困惑。你对解析器生成器的工作原理不够熟悉吗?如果你做得正确,就不应该再去玩弄生成的代码了。 - Eric
@Eric:也许吧。我相信我已经足够理解它,以确保输出是正确的,但是也许还需要一些技巧来使其更快。我所知道的就是我们现在的程序又难以理解又慢。 - Simon
1
我也使用了ANTLR,但现在考虑手写解析器,因为要解决这些“边缘情况”需要大量时间。 - schoetbi
16个回答

19
我强烈推荐F#语言作为.NET平台上解析的首选语言。它在ML语言家族中的根源意味着它对面向语言编程有很好的支持。
区分联合和模式匹配允许非常简洁和强大地指定AST。高阶函数允许定义解析操作及其组合。对于单子类型的一流支持,使状态管理可以隐式处理,从而极大地简化了解析器的组合。强大的类型推断极大地帮助了这些(复杂)类型的定义。所有这些都可以交互式地指定和执行,使您能够快速原型。
Stephan Tolksdorf通过他的解析器组合库FParsec将这个实践应用到了实践中。
从他的示例中,我们看到了如何自然地指定AST:
type expr =
    | Val of string
    | Int of int
    | Float of float
    | Decr of expr

type stmt =
    | Assign of string * expr
    | While of expr * stmt
    | Seq of stmt list
    | IfThen of expr * stmt
    | IfThenElse of expr * stmt * stmt
    | Print of expr

type prog = Prog of stmt list

解析器的实现(部分省略)同样简洁:

let stmt, stmtRef = createParserForwardedToRef()

let stmtList = sepBy1 stmt (ch ';')

let assign =
    pipe2 id (str ":=" >>. expr) (fun id e -> Assign(id, e))

let print = str "print" >>. expr |>> Print

let pwhile =
    pipe2 (str "while" >>. expr) (str "do" >>. stmt) (fun e s -> While(e, s))

let seq =
    str "begin" >>. stmtList .>> str "end" |>> Seq

let ifthen =
    pipe3 (str "if" >>. expr) (str "then" >>. stmt) (opt (str "else" >>. stmt))
          (fun e s1 optS2 ->
               match optS2 with
               | None    -> IfThen(e, s1)
               | Some s2 -> IfThenElse(e, s1, s2))

do stmtRef:= choice [ifthen; pwhile; seq; print; assign]


let prog =
    ws >>. stmtList .>> eof |>> Prog

在第二行,举个例子,stmtch是解析器(parsers),sepBy1是一个单态(monadic)解析器组合器,它接受两个简单的解析器并返回一个组合解析器。在这种情况下,sepBy1 p sep返回一个解析器,用于解析一个或多个由sep分隔的p的出现。因此,您可以看到如何从简单的解析器快速组合出强大的解析器。 F#对重载操作符的支持还允许使用简明的中缀符号表示,例如顺序组合器和选择组合器可以指定为>>.<|>
祝你好运,
丹尼

11

唯一可以由理智的人手写的解析器是递归下降法。话虽如此,手写自底向上的解析器仍然可行但非常不可取。

如果您打算使用递归下降法解析SQL语法,您必须验证SQL语法是否存在左递归(如有必要,则消除该递归),然后基本上为每个语法规则编写一个函数。请参见此处以获取更多参考信息。


8

递归下降法会给你最简单的方法,但我必须同意mouviciel的看法,学习flex和bison绝对值得。当你发现语法错误时,在flex / bison中修复语言定义要容易得多,而不是重写递归下降代码。

顺便提一下,C#解析器是使用递归下降法编写的,它往往非常强大。


3
“非常容易”这个说法有夸张的成分。在递归下降分析器中,方法就是语法(一旦识别出来,它就变得简单了)... - mike g
1
取决于您的错误在哪里。使用递归下降,您可能会在语法的整个部分中语义上改变其含义。在flex/bison定义中,这将导致1行更改。在递归下降中,您可能需要重写一半的代码来处理它。 - Spence

8

我支持使用递归下降(LL1)的方法来解析语法。它们简单快捷,而且在我看来,很容易维护。

不过,请仔细检查您的语言,确保它是LL1。如果您有像C语言那样的语法,例如((((type))foo)[ ]),需要多次遍历括号才能确定您是否正在查看类型、变量或表达式,则使用LL1会非常困难,这时候自底向上的方法效果更好。


3
改变语言为LL1是正确的做法。 - Stephan Eggermont
1
@Stephan:我倾向于同意,尽管有一两次我别无选择 - 我必须解析C语言。 - Mike Dunlavey
1
如果你的解析器中只有逻辑判断,那么可以这样写: if(type) ... else if(variable) ... - mike g

7

递归下降解析器确实是最好的、也可能是唯一能够手动构建的解析器。您仍然需要了解正式的无上下文语言是什么,并将您的语言放在正常形式中。我个人建议您删除左递归并将您的语言放在 Greibach Normal Form中。这样做后,解析器几乎就能自己编写。

例如,这个产生式:

A => aC 
A => bD
A => eF

变得简单,像这样:

int A() {
   chr = read();
   switch char
     case 'a': C();
     case 'b': D();
     case 'e': F();
     default: throw_syntax_error('A', chr);
}

这里没有更难的情况(更困难的是确保你的语法处于完全正确的形式,但这使你拥有了你提到的控制权)。

Anton's Link 似乎也很棒。


1
+1 对于 Greibach 正规式——让我回忆起了大学时光。 - pjp
我认为这假设语言是LL(1)。 - James Fremen

4

如果你想手写代码,递归下降是最明智的选择。

你也可以使用表格解析器,但这将非常难以维护。

例如:

Data = Object | Value;
Value = Ident, '=', Literal;
Object = '{', DataList, '}';
DataList = Data | DataList, Data;


ParseData {
  if PeekToken = '{' then 
    return ParseObject;
  if PeekToken = Ident then
    return ParseValue;
  return Error;
}

ParseValue {
  ident = TokenValue;
  if NextToken <> '=' then 
    return Error;
  if NextToken <> Literal then
    return Error;
  return(ident, TokenValue);
 }

ParseObject {
  AssertToken('{');
  temp = ParseDataList;
  AssertToken('}');
  return temp;
}

ParseDataList {
  data = ParseData;
  temp = []
  while Ok(data) {
    temp = temp + data;
    data = ParseData;
  }
}

3

没有“最好的”方法。根据您的需求,您可能需要自下而上(LALR1)或递归下降(LLk)方法。例如这篇文章给出了个人喜欢LALR(1)(自下而上)而不是LL(k)的原因。然而,每种类型的解析器都有其优点和缺点。通常情况下,由于生成查找表作为有限状态机,LALR将更快。

为了选择适合您的工具和技术,请检查您的情况;熟悉相关的工具和技术。从一些LALRLL维基百科文章开始并不是一个坏选择。在这两种情况下,您应该始终从指定BNFEBNF语法开始。我更喜欢 EBNF,因为它更简洁。
一旦您理解了您想要做什么以及如何将其表示为语法(BNF或EBNF),请尝试使用几种不同的工具,并运行它们在要解析的代表性文本样本上。 个人经验:

然而,我听说LL(k)更加灵活。我从未费心去自己了解。从我的一些解析器构建经验中,不管是LALR还是LL(k),选择最适合您需求的最好方法是先编写语法。我编写了自己的C++ EBNF RD解析器生成器模板库,使用了Lex/YACC并编写了一个小型R-D解析器。这跨越了15年左右的时间,我在其中最长的项目上花费的时间不超过2个月。


3
我建议您不要手写词法分析器,而是使用flex或类似的工具。手动识别标记的任务并不难,但我认为您不会获得太多好处。
正如其他人所说,递归下降解析器最容易手写。否则,您必须维护每个标记的状态转换表,这并不真正人类可读。
我相当确定ANTLR实现了递归下降解析器:在有关ANTLR 3.0的访谈中提到过它。
我还发现了一系列关于用C#编写解析器的博客文章。它似乎非常简单。

3
冒犯原帖作者的风险虽然存在,但是在有好的解析器生成工具(如ANTLR)可用的情况下,手动编写大型语言(例如某个特定供应商的SQL)的解析器实在是太疯狂了。你会花费更多时间来手动重写解析器,而不是使用解析器生成器修复“边缘案例”,并且随着SQL标准的发展或者你发现自己误解了其他东西,你必然需要返回并修改解析器。如果您不了解解析技术,那就投入时间去学习它。使用解析器生成器处理边缘案例不会花费你数月时间,而你已经承认你愿意花数月时间手动完成它。
话虽如此,如果你非要手动重做它,使用递归下降解析器是手动完成的最佳方式。
(注意:原帖作者在下面澄清他想要一个参考实现。在我看来,你永远不应该以过程方式编写一个语法的参考实现,因此递归下降已经被淘汰了。)

1
不会有任何冒犯。随着标准的变化重写解析器在这里并不是问题;这是我们自己的类SQL语法,解析器将成为参考实现。 - Simon
如果这是一个参考实现,那么请不要编写递归下降解析器。RD解析器都是过程式代码,比规范难以理解得多。您绝对需要一个干净、明确定义语言规则的规范,以便其他人(“参考”,记住?)可以清楚地看到指定的内容。ANTLR比RD更好。使用GLR选项的Bison比ANTLR更好,因为您可以毫不顾忌地编写无上下文语法规则。 - Ira Baxter
通过使用解析器生成器,您可以验证已发布的语法,并避免手写解析器未实际实现语法的问题。关于参考实现的观点加一分。 - Stephen C

1
在C/Unix中,传统的方法是使用lex和yacc。在GNU中,相应的工具是flex和bison。我不知道Windows/C#的情况。

1
我想手写它 - 不使用解析器生成器。 - Simon
Flex虽然是GPL许可的,但并不是GNU项目。 - Eric

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接