Answers:
确实有三个选择,这三个选择在不同情况下更可取。
假设您现在被要求为某些古老的数据格式构建解析器。或者您需要解析器要快。或者,您需要解析器易于维护。
在这些情况下,最好使用解析器生成器。您不必费心处理所有细节,也不必获取大量复杂的代码即可正常工作,只需编写输入将遵循的语法,编写一些处理代码并保存:即时解析器。
优点很明显:
使用解析器生成器时,需要注意一件事:有时可能会拒绝您的语法。有关各种类型的解析器以及它们如何咬您的概述,您可能要从这里开始。在这里,您可以找到许多实现的概述以及它们接受的语法类型。
解析器生成器很好,但是它们对用户(最终用户而不是您)不是很友好。通常,您不能提供良好的错误消息,也不能提供错误恢复功能。也许您的语言很奇怪,解析器拒绝了您的语法,或者您需要的控制权超出了生成器所能提供的范围。
在这些情况下,最好使用手写递归下降解析器。虽然可能会很复杂,但是您可以完全控制解析器,因此您可以执行解析器生成器无法完成的各种出色工作,例如错误消息甚至错误恢复(尝试从C#文件中删除所有分号) :C#编译器会抱怨,但无论分号是否存在,无论如何都会检测到其他大多数错误)。
假设解析器的质量足够高,手写解析器通常也比生成的解析器性能更好。另一方面,如果您通常由于缺乏经验,知识或设计而导致编写失败的解析器(通常是由于这些的结合),则性能通常会变慢。但是对于词法分析器而言,情况恰恰相反:通常生成的词法分析器使用表查找,从而使它们比(大多数)手写查询更快。
在教育方面,编写自己的解析器将比使用生成器教给您更多的知识。毕竟,您必须编写越来越复杂的代码,此外,您还必须确切地了解如何解析语言。另一方面,如果您想学习如何创建自己的语言(因此,要获得语言设计经验),则最好选择选项1或选项3:如果要开发一种语言,它可能会发生很大变化,而选项1和3则使您的工作更轻松。
这是我当前要走的路:您编写自己的解析器生成器。虽然非常简单,但是这样做可能会教给您最多的知识。
为了给您一个想法,进行这样的项目会涉及到我自己的进度。
词法生成器
我首先创建了自己的词法生成器。我通常从开始使用代码开始设计软件,所以我考虑了如何使用我的代码并编写了这段代码(在C#中):
Lexer<CalculatorToken> calculatorLexer = new Lexer<CalculatorToken>(
new List<StringTokenPair>()
{ // This is just like a lex specification:
// regex token
new StringTokenPair("\\+", CalculatorToken.Plus),
new StringTokenPair("\\*", CalculatorToken.Times),
new StringTokenPair("(", CalculatorToken.LeftParenthesis),
new StringTokenPair(")", CalculatorToken.RightParenthesis),
new StringTokenPair("\\d+", CalculatorToken.Number),
});
foreach (CalculatorToken token in
calculatorLexer.GetLexer(new StringReader("15+4*10")))
{ // This will iterate over all tokens in the string.
Console.WriteLine(token.Value);
}
// Prints:
// 15
// +
// 4
// *
// 10
使用算术堆栈的思想,将输入的字符串-令牌对转换为相应的递归结构,以描述它们表示的正则表达式。然后将其转换为NFA(不确定性有限自动机),然后将其转换为DFA(确定性有限自动机)。然后,您可以将字符串与DFA进行匹配。
这样,您就可以很好地了解词法分析器的工作原理。另外,如果以正确的方式进行操作,则词法分析器生成器的结果可以与专业实现大致一样快。与选项2相比,您也不会失去任何表现力,与选项1相比,您不会失去太多表现力。
我用1600多行代码实现了lexer生成器。这段代码可以完成上述工作,但是每次启动程序时它仍会即时生成词法分析器:在某些时候,我将添加代码以将其写入磁盘。
如果您想知道如何编写自己的词法分析器,那么这是一个不错的起点。
解析器生成器
然后,您编写解析器生成器。我再次在这里参考有关各种解析器的概述-根据经验,解析器解析的越多,速度就越慢。
速度对我而言不是问题,我选择实现Earley解析器。事实证明,Earley解析器的高级实现速度大约是其他解析器类型的两倍。
作为这种快速打击的回报,您可以解析任何语法,甚至是模棱两可的语法。这意味着您不必担心解析器中是否存在任何左递归,或者移位-减少冲突是什么。如果结果的语法树无关紧要,也可以使用歧义语法更容易地定义语法,例如,将1 + 2 + 3解析为(1 + 2)+3还是1都无关紧要+(2 + 3)。
这是使用我的解析器生成器的一段代码如下所示:
Lexer<CalculatorToken> calculatorLexer = new Lexer<CalculatorToken>(
new List<StringTokenPair>()
{
new StringTokenPair("\\+", CalculatorToken.Plus),
new StringTokenPair("\\*", CalculatorToken.Times),
new StringTokenPair("(", CalculatorToken.LeftParenthesis),
new StringTokenPair(")", CalculatorToken.RightParenthesis),
new StringTokenPair("\\d+", CalculatorToken.Number),
});
Grammar<IntWrapper, CalculatorToken> calculator
= new Grammar<IntWrapper, CalculatorToken>(calculatorLexer);
// Declaring the nonterminals.
INonTerminal<IntWrapper> expr = calculator.AddNonTerminal<IntWrapper>();
INonTerminal<IntWrapper> term = calculator.AddNonTerminal<IntWrapper>();
INonTerminal<IntWrapper> factor = calculator.AddNonTerminal<IntWrapper>();
// expr will be our head nonterminal.
calculator.SetAsMainNonTerminal(expr);
// expr: term | expr Plus term;
calculator.AddProduction(expr, term.GetDefault());
calculator.AddProduction(expr,
expr.GetDefault(),
CalculatorToken.Plus.GetDefault(),
term.AddCode(
(x, r) => { x.Result.Value += r.Value; return x; }
));
// term: factor | term Times factor;
calculator.AddProduction(term, factor.GetDefault());
calculator.AddProduction(term,
term.GetDefault(),
CalculatorToken.Times.GetDefault(),
factor.AddCode
(
(x, r) => { x.Result.Value *= r.Value; return x; }
));
// factor: LeftParenthesis expr RightParenthesis
// | Number;
calculator.AddProduction(factor,
CalculatorToken.LeftParenthesis.GetDefault(),
expr.GetDefault(),
CalculatorToken.RightParenthesis.GetDefault());
calculator.AddProduction(factor,
CalculatorToken.Number.AddCode
(
(x, s) => { x.Result = new IntWrapper(int.Parse(s));
return x; }
));
IntWrapper result = calculator.Parse("15+4*10");
// result == 55
(请注意,IntWrapper只是一个Int32,除了C#要求它是一个类,因此我不得不引入包装器类)
我希望您看到上面的代码非常强大:可以解析出您能想到的任何语法。您可以在语法中添加任意代码位以执行许多任务。如果您设法使所有这些工作正常进行,则可以非常重用所生成的代码来轻松完成许多任务:想象一下使用这段代码构建命令行解释器。
如果您从未写过解析器,我建议您这样做。这很有趣,您可以了解事物的工作方式,还可以欣赏解析器和词法分析器生成器为您在下次需要解析器时所节省的精力。
我还建议您尝试阅读http://compilers.iecc.com/crenshaw/,因为它对如何执行操作具有扎实的态度。
编写自己的递归下降解析器的优点是,您可以针对语法错误生成高质量的错误消息。使用解析器生成器,可以产生错误并在某些点添加自定义错误消息,但是解析器生成器完全无法完全控制解析。
编写自己的脚本的另一个好处是,它更容易解析为一个与语法没有一对一对应关系的简单表示形式。
如果语法是固定的,并且错误消息很重要,请考虑自己滚动,或者至少使用可以为您提供所需错误消息的解析器生成器。如果语法在不断变化,则应考虑使用解析器生成器。
Bjarne Stroustrup讨论了他如何使用YACC进行C ++的第一个实现(请参阅《 C ++ 的设计和演变》)。在第一种情况下,他希望自己编写自己的递归下降解析器!
选项3:都不选择(滚动您自己的解析器生成器)
仅仅因为有一个理由不使用ANTLR,野牛,可可/ R,Grammatica,JavaCC中,柠檬,蒸谷米,SableCC,Quex,等等 -这并不意味着你应该立即推出自己的解析器+词法分析器。
确定为什么所有这些工具都不够好-为什么它们不能让您实现目标?
除非您确定要处理的语法中的奇数是唯一的,否则您不应该只为此创建一个自定义解析器+词法分析器。相反,创建一个可以创建所需内容的工具,但该工具也可以用于满足将来的需求,然后将其作为免费软件发布,以防止其他人遇到与您相同的问题。
滚动自己的解析器会迫使您直接考虑语言的复杂性。如果该语言难以解析,则可能很难理解。
早期,由于高度复杂(有些人会说“折磨”)的语言语法,解析器生成器引起了人们的极大兴趣。JOVIAL是一个特别糟糕的示例:它需要提前两个符号,而其他所有内容最多都需要一个符号。这使得为JOVIAL编译器生成解析器比预期的要困难得多(因为General Dynamics / Fort Worth Division在为F-16程序购买JOVIAL编译器时学到了很难的方法)。
如今,递归下降是普遍首选的方法,因为它对编译器编写者来说更容易。递归下降编译器极大地奖励了简单,简洁的语言设计,因为为一种简单,干净的语言编写一个递归下降解析器要比为那些复杂的,混乱的语言编写代码容易得多。
最后:您是否考虑过将语言嵌入LISP中,并让LISP解释器为您完成繁重的工作?AutoCAD做到了这一点,发现这使他们的生活变得更加轻松。有很多轻量级的LISP解释器,有些是可嵌入的。
我曾经为商业应用编写过一个解析器,并且使用了yacc。有一个竞争的原型,其中开发人员使用C ++手工编写了整个程序,并且工作速度慢了大约五倍。
至于该解析器的词法分析器,我完全是手工编写的。抱歉-差不多是10年前了,所以我记不清了-在C中大约有1000行。
我之所以手动编写词法分析器的原因是解析器的输入语法。这是我的解析器实现必须遵守的要求,而不是我设计的要求。(当然,我会对其进行不同的设计。更好!!)语法在很大程度上依赖于上下文,甚至词法依赖于某些地方的语义。例如,分号可以在一处成为令牌的一部分,而在另一处成为分隔符-基于对以前解析出的某个元素的语义解释。因此,我在手写词法分析器中“掩埋”了这种语义依赖性,这给了我一个相当简单的BNF,可以轻松地在yacc中实现。
ADDED响应麦克尼尔:YACC提供了非常强大的抽象,让程序员认为终端,非终端,生产和类似的东西的条款。另外,在实现yylex()
函数时,它帮助我专注于返回当前令牌,而不必担心它之前或之后的情况。C ++程序员在字符级别上工作,没有这种抽象的好处,最终创建了一个更复杂,效率更低的算法。我们得出的结论是,较慢的速度与C ++本身或任何库无关。我们使用加载到内存中的文件测量了纯解析速度;如果我们遇到文件缓冲问题,则yacc并不是解决问题的首选工具。
还想添加:一般而言,这并不是编写解析器的诀窍,而只是它在特定情况下如何工作的一个示例。
这完全取决于您需要解析的内容。您能否以自己的速度超过词法学习器的学习曲线?被解析的内容是否足够静态,以至于您以后不会后悔该决定?您是否发现现有的实施方案过于复杂?如果是这样,那就滚滚自己的乐子吧,但前提是您不要回避学习曲线。
最近,我真的很喜欢柠檬解析器,它可以说是我使用过的最简单,最简单的工具。为了使事情易于维护,我只将其用于大多数需求。SQLite使用它以及其他一些著名的项目。
但是,我对词法分析器一点都不感兴趣,除此之外,当我需要使用一个词法分析器(因此,柠檬)时,它们不会妨碍我。你可能是,如果是这样,为什么不做一个?我有一种感觉,您会回到使用现有的那种方法,但是如果必须的话,请挠痒痒:)
这取决于您的目标是什么。
您是否要学习解析器/编译器的工作方式?然后从头开始写自己的。那是您真正学会欣赏他们正在做的所有事情的唯一方法。在过去的几个月中,我一直在写作,这是一次有趣而宝贵的经历,尤其是“啊,所以这就是为什么X语言做到这一点……”的时刻。
您是否需要在截止日期前快速将某些东西放在一起以进行应用?然后也许使用解析器工具。
您是否需要在未来10年,20年甚至30年中扩展的东西?自己写,慢慢来。这将是非常值得的。
您是否考虑过Martin Fowlers语言工作台方法?引用本文
语言工作台对方程式所做的最明显改变是创建外部DSL的简便性。您不再需要编写解析器。您必须定义抽象语法-但这实际上是一个非常简单的数据建模步骤。另外,您的DSL获得了功能强大的IDE-尽管您确实需要花费一些时间来定义该编辑器。生成器仍然是您必须要做的事情,我的感觉是,它并没有比以前容易得多。但是,为这个简单而良好的DSL构建一个生成器是该练习最容易的部分之一。
读完这些,我会说编写自己的解析器的日子已经过去,最好使用可用的一种库。掌握了库之后,以后创建的所有DSL都将从该知识中受益。另外,其他人也不必学习您的解析方法。
编辑以涵盖评论(和修订的问题)
自己滚动的优点
简而言之,当您想真正深入解决这个严重困难的问题时,应该动手动手,以至于强烈地希望去掌握。
使用别人的图书馆的好处
因此,如果您想获得快速的最终结果,请使用其他人的资料库。
总体而言,这取决于您要解决问题的数量以及解决方案的选择。如果您想要全部,请自己动手。
编写自己的书的最大好处是,您将知道如何编写自己的书。使用yacc之类的工具的最大好处是,您将知道如何使用该工具。我是树梢的爱好者,适合进行初步探索。
为什么不派生一个开放源代码的解析器生成器并使其成为自己的呢?如果不使用解析器生成器,则如果对语言的语法进行了较大的更改,则代码将很难维护。
在解析器中,我使用正则表达式(我的意思是Perl风格)来标记化,并使用一些便利函数来提高代码的可读性。但是,解析器生成的代码可以通过制作状态表和long switch
- case
s 来更快,除非您使用.gitignore
它们,否则可能会增加源代码的大小。
这是我的自定义解析器的两个示例:
https://github.com/SHiNKiROU/DesignScript-一种基本的方言,因为我懒得用数组表示法来写超前行,所以我牺牲了错误消息的质量 https://github.com/SHiNKiROU/ExprParser-一个公式计算器。注意奇怪的元编程技巧
“我应该使用这个久经考验的'轮子'还是重新发明它?”