非常基础的英语语法解析器

13
我正在编写一个非常基本的解析器(大多数是为了更好地理解它们的工作原理),它接收用户输入的少量单词,检测句子结构是否正确,并输出结果。语法如下:
句子: 名词 动词
冠词 句子
句子 连词 句子
连词: “和” “或” “但是”
名词: “鸟” “鱼” “C ++”
动词: “规则” “飞行” “游泳”
冠词: “the”
编写语法很简单。实现代码却给了我一些麻烦。我的伪代码如下:
main()
get user input (string words;)
while loop (cin >> words)
call sentence()
end main()

sentence()
call noun()
if noun() call verb() (if verb is true return "OK" ???)(else "not ok"???)
else if not noun() call article()
                if article() call sentence() (if sentence is true "OK"???)(else "not"?)
else if not noun() call conjunction()
                   if sentence() conjunction() sentence() - no idea how to implement
                                                             return "OK"
else "not ok"

这是我的极其粗糙的伪代码。我有几个关于实现它的问题。

  1. 对于单词功能(名词、动词等),我该如何检查它们是否为真?(即检查用户输入是否包含birds、fish、fly、swim等)

  2. 我应该如何处理连词调用和输出?

  3. 我应该从主函数还是调用函数处理输出?

  4. 如果我的伪代码完全错误,则上述所有问题都不重要。基础知识是否有问题?

另外,我正在完成《使用C++编程实践与原理》第6章练习,因此希望使用已经学过的语言语法,所以任何属于高级编程范畴的内容可能并不会很有帮助。(练习明确指出不要使用令牌,因此请将其排除在外。)

提前感谢您的帮助!

最后编辑:在书籍的公开群组中,我问了同样的问题,Bjarne Stroustrup回复说他将练习解决方案放在网上。他基本上将输入读入sentence函数,并使用if语句返回true或false。但是,他没有使用冠词,所以我的代码更加复杂。我想,如果从这个练习中学到了什么,那就是当处理大量用户输入时,分词是关键(根据我目前所知)。以下是我的代码。我可能以后会回来修改它,因为它仍然非常有错误,并且基本上只返回句子是否正确,并且无法处理例如(名词、连接词、句子)这样的内容,但是现在我要继续。

#include "std_lib_facilities.h"

bool article(string words)
{
               if (words == "the")
               return true;
               else return false;        
}

bool verb(string words)
{
               if (words == "rules" || words == "fly" || words == "swim")
               return true;
               else return false;                   
}

bool noun(string words)
{
               if (words == "birds" || words == "fish" || words == "c++")
               return true;
               else return false;                   
}

bool conjunction(string words)
{
              if (words == "and" || words == "but" || words == "or")
              return true;
              else return false;                  
}

bool sentence()
{
string w1;
string w2;
string w3;
string w4;

cin >> w1;
if (!noun(w1) && !article(w1)) return false; // grammar of IFS!

cin >> w2;
if (noun(w1) && !verb(w2)) return false;
if (article(w1) && !noun(w2)) return false;

cin >> w3;
if (noun(w1) && verb(w2) && (w3 == ".")) return true;
if (verb(w2) && !conjunction(w3)) return false;
if (noun(w2) && !verb(w3)) return false;
if (conjunction(w3)) return sentence();

cin >> w4;
if (article(w1) && noun(w2) && verb(w3) && (w4 == ".")) return true;
if (!conjunction(w4)) return false;
if (conjunction(w4)) return sentence();
}


int main()
{                                   
cout << "Enter sentence. Use space then period to end.\n";
            bool test = sentence();
            if (test)
               cout << "OK\n";
            else
               cout << "not OK\n";

保持窗口打开();


1
你想从头开始编写解析器吗?还是想知道如何使用解析器生成工具来构建一个可以自定义的解析器? - Martin York
我想从零开始编写它。 - Alex
请查看:http://en.wikipedia.org/wiki/Recursive_descent_parser#C_implementation - Jared Updike
多酷的讨论啊!我一直想了解语法和词法分析器等等。这是一个不错的入门介绍。谢谢。收藏了。 - Joel Berger
@Alex,你能发一下你用于getline的代码吗?我很好奇。 - Wolfpack'08
显示剩余5条评论
9个回答

10

好的,如果你真的想手动实现它 :-(

这个问题有两个部分:

  • 词法分析
  • 语法分析。
  • 我们可以忽略Symantic分析,因为这就是上面的原因。

首先,你需要将输入流令牌化为合理的令牌。单词可能是一个显而易见的选择,但这将使语法阶段的工作量很大。所以,我会将你的单词分成以下类型(连词、名词、动词、冠词),然后编写一个词法分析器来返回正确的词元。

Lexer.cpp
enum Lexeme { END,Conjunction,Noun,Verb,Article };
Lexem getNextLexme(std::istream in)
{
    std::string word;
    in >> word;

    if (!in) {return END;}

         if (word == "and")   return Conjunction;
    else if (word == "birds") return Noun;
    else if (word == "fly")   return Verb;
    else if (word == "the")   return Article;

    ... etc
}

现在,您可以根据简化的标记流编写语法解析器。

bool ParseSentence(std::istream in)
{
    Lexeme token = getNextLexme(in);
    switch(token)
    {
        case Noun:    if (!parseVerb(in))
                      {    return false;
                      }
                      return parseConjunctionOrEnd(in);
        case Article: return ParseSentence();
        case END:     return true;
    }
}
bool parseVerb(std::istream in)
{
    Lexeme token = getNextLexeme(in);
    if (token != Verb) { /*ERROR*/ return false;}
    return true;
 }
 // etc

如果使用句法分析,你可以选择构建状态表。但这需要手动分析语法并确定各个状态。只有最简单的语法才应该尝试,比你在这里看到的任何大型语法都应该交给能够自动生成状态表的工具。

因此,假设我在下面定义的语法:
希望我没有弄错,因为我不是一个充气工具 :-)

State 1:   Start <Nothing Happened>
               Article -> State 2
               Noun -> State 3
               Otherwise Error
State 2:   Seen Article.
               Noun -> State 3
               Otherwise Error
State 3:   Seen Noun  in Sentence.
               Verb -> State 4
               Otherwise Error
State 4:   Seen Noun Verb
               End -> State 5
               Conjunction -> State 1
State 5:   Finished:

State 0:   Error State.


int stateTable[][]    // CurrentState,CurrentObject
           = {/*State 0: Error State:*/{},
                                       // END,Conjunction,Noun,Verb,Article 
              /*State 1: Start*/       {  0,  0,          3,   0,   2},
              /*State 2: Article*/     {  0,  0,          3,   0,   0},
              /*State 3: Noun*/        {  0,  0,          0,   4,   0},
              /*State 4: Noun Verb*/   {  5,  1,          0,   0,   0},
              /*State 5: End*/         {}
             };

bool parseSentence(std::iostream& in)
{
    int currentState = 1;
    while((currentState != 0) && (currentState != 5))
    {
        int token = getNextLexme(in);
        currentState = stateTable[currentState][token];
    }
    return currentState == 5;
}

这个练习说不要费心处理令牌,只需读入字符串即可。然而,这确实让我走上了正确的轨道。如果我成功了,我会评论并编辑这篇文章。 - Alex
@Alex... 这是你在不写整个代码的情况下能得到的最好答案。将这个答案与Barry Brown的答案结合起来,你就有了一个相当完整的解决方案。Martin的答案为你提供了代码,并指导你向上递归语法的正确方向。像Martin所做的那样对输入流进行标记化处理肯定更好。然而,要像这样解析,你需要按照Barry的建议重新构造你的语法。确保它符合自顶向下解析的要求。 - Polaris878
这个练习明确说明不要使用令牌,所以在我采用那种方法之前,我会尝试一些更多的技巧。此外,我肯定会改进语法,但创建类似乎是不必要的(除非我使用令牌)。 - Alex
@Alex...你可以不使用令牌来完成这个练习,但它并不会使事情复杂化,而且肯定是更干净/更容易的方法。 - Polaris878
我知道我的代码已经很完美了,除了一个主要的缺陷。函数(function())需要能够读取每个输入的单词,而不仅仅是一次读取一个。难道分词是唯一的解决方法吗?如果是这样,该死的Stroustrup! - Alex
我正在使用getline函数来保存用户输入的所有单词。现在它只输出一个结果,但它总是输出“Not Ok”。不确定为什么... - Alex

6
我很感兴趣这个问题。我将帮助提问者Alex,但由于我不太懂C++,所以将使用伪代码。也不会使用lex/yacc,因为Alex想学习它们的制作过程。如果使用类似lex和yacc这样的工具,它们就变成了“黑匣子”。我现在没有时间把所有东西都整合起来,但我可以分几个小时逐步解决它。我只是想现在开始。
首先我们需要清理语法。你定义了一个句子:
sentence : noun verb
         | article sentence
         | sentence conjunction sentence

这个语法有缺陷。例如,“a the fish swim”这样的句子是合法的,因为句子是根据自身定义的。递归是语法的正常部分,但需要正确处理。我猜测你不希望两个或更多冠词连续出现。

一个更好的句子语法可能是:

sentence : clause conjunction clause
         | clause

clause : nounphrase verbphrase

nounphrase : noun
           | article noun

这将消除可能导致无限循环的不受约束的递归。
现在我们准备处理解析器本身。由于这是C++,我们可以把它变成面向对象的。我现在必须离开一会儿,但我给你一个提示:每个产生式规则都将有一个类。

既然这是 C++,我们不妨将其面向对象化。每个生成规则都会有一个类。这似乎反映了当今世界的许多问题。;-) - ShreevatsaR
1
感谢您改进了语法。如果我要对其进行标记化,我将不得不使用类,但肯定有一种不涉及标记的解决方案。我将尝试一些更多的技巧(也许制作一个字符串向量?),如果我无法弄清楚,我将采用标记化。 - Alex
是的,我要进行标记化。但现在已经很晚了,所以我明天早上再处理它。不过,我只需要一个令牌类来获取输入,不需要为每个规则都创建一个类,这一点我没有看到任何特别的原因。 - Alex

2
好的,那么...我没有你具体问题的答案,但是我想指出一些在处理这个问题时需要考虑的一般性思路。首先,解析很难。你有一个简单的语法,但它仍然可能很难。编译器前端负责解析...只是为了提供一些背景知识。
有两种基本类型的解析...自顶向下解析和自底向上解析。它们的名称取决于它们如何遍历语法树(考虑将为可能的结构创建哪种语法树)。自顶向下解析很容易,而且可能适用于你想要做的事情。最常见的自顶向下解析方法是递归下降解析:http://en.wikipedia.org/wiki/Recursive_descent_parser 但是,要使用递归下降解析,你的语法必须符合某种特定格式...对于某些语法来说,不可能进行递归下降解析。不过你应该能够改变你的语法以适应这个限制。
自顶向下解析器很容易编写...因为你只需要为一个小语言编写几个函数。
第二种解析方式是自底向上解析。这在编译器中常用,因为它没有自顶向下的限制。如果给定的字符串不符合语言,它也更容易进行错误报告。
自底向上解析器很难编写...大多数人使用解析器生成器来完成工作。我一直在使用YACC。你基本上输入语法(以及匹配该规则时要执行的操作),然后它会解析语法。
自底向上解析器使用一种称为移位-归约解析的方法。这是处理输入和匹配规则的方法。
再看一遍你的代码,我认为你可以使用自顶向下解析器。抱歉我不能给出具体的代码,但是搜索自顶向下解析器示例(或递归下降解析器示例)可能会找到你需要的代码。

1
本章的示例(计算器示例)使用自底向上分析法处理优先级语法。然而,这似乎与严格按升序或降序检查名词和动词不同,它实际上是在检查“如果x为真,则确保y也为真”。它基本上是自上而下工作,但有一些转折。接下来我会发布一些未完成的代码,以展示我的意思。 - Alex

1

大多数解析,如程序文本解析,都是使用正式语法解析器完成的。 英语和大多数口头语言不是正式语法,因此您将很难对它们进行解析。 这个问题困扰了 PHD 多年,但没有取得太多成功。


7
我觉得这个问题的重点不在于此。他已经想出了一种足够正式的简化版英语语法来解析。他的问题更多是关于如何创建一个通用的解析程序。 - Sean
是的,问题是:我该如何解析这个语法。 - Martin York

1

词类

要获取词类,您需要使用包含词类的字典列表。除了将单词映射到词类列表的哈希表之外,另一种可能检查词类的方法是将每个词类的单词集合加载到自己的Bloom过滤器中(这是一种从字符串到布尔值的压缩散列映射)。


1

自然语言语法的一个方面是它们经常是含糊不清的。例如,英语句子:

I once shot an elephant in my pajamas. How he got in my pajams I'll never know
-- Groucho Marx

短语“in my pajamas”模糊地描述了主语“I”或宾语“elephant”。如果没有语义上下文,将无法正确构建AST。

如果您希望避免这种情况,您可能需要一些有用的处理歧义的方法。一种策略是生成所有可能的歧义短语派生。使这成为可能的工具之一是Earley Parser。与其他类型的解析器(如递归下降解析器)不同,Earley解析器以解析器状态转换的形式生成所有派生,而不是简单的树形结构。实际上,这并不难处理。


很难毁掉一个美丽的海滩。 - Barry Brown
自然语言中的另一个歧义是可以是两种或更多类型的单词。例如,这些名词/动词:fly、call、stack、wreck。还有这个名词/动词/形容词:light。我认为你的工作很艰巨。 :-) - RobH

0
使用Flex和Bison:
在Bison中使用语法规则:
%%

English            :   SentenceList

SentenceList       :   Sentence
                   |   Article  Sentence
                   |   Sentence Conjunction Sentence

Sentence           :   Noun Verb


Conjunction        :   TOKEN_WordAnd
                   |   TOKEN_WordOr
                   |   TOKEN_WordBut


Noun               :   TOKEN_WORD_BIRDS
                   |   TOKEN_WORD_FISH
                   |   TOKEN_WORD_CPP

Verb:              :   TOKEN_WORD_RULES
                   |   TOKEN_WORD_FLY
                   |   TOKEN_WRD_SWIM

Article            :   TOKEN_WORD_THE
%%

2
为什么要点踩?我能想到一种更好的方式来解析一个良好定义的语法!对于任务,请使用正确的工具。 - Martin York
1
也许是因为提问者不是在寻找解析器,而是在寻求编写解析器的帮助... - DJ.
可能。但这是一篇写作的文章。 - Martin York
让工具接管你的语法并生成解析器,与编写解析器完全不同。 - McPherrinM
我认为是这样的:唯一的区别在于你不需要花10个小时用笔和纸生成状态表。难点在于定义一个有效的语法,而这是由你而非工具完成的。 - Martin York
我认为你们两个都有道理。一方面,OP想要从头开始编写解析器,而这不是这个项目的目的。这是别人的工作。另一方面,OP可以检查这个工作并基于这个工作构建自己的解析器。当然,他需要将代码从C#翻译成C++,这给问题增加了新的复杂度维度。 - Wolfpack'08

0
你可以去看一下Ubiquity,这是一个针对Firefox的插件,旨在使用自然语言执行常见的网络任务(它是用JavaScript编写的,但也许你可以得到一个通用算法,这将非常有用)。

-1
在你开始编写解析器之前,我建议你学习一下lex和yacc或者flex和bison这对工具。这些工具专门用于创建解析器和词法分析器。
它们会自动生成你的c/c++(也许还有其他)代码,因此你不必担心用户参数的各种边界情况。你可以在30分钟内完成上面的语法。
至于你的问题:
对于单词函数(名词、动词等),我应该如何检查它们是否正确?(即检查用户输入中是否有birds、fish、fly、swim等)
这里需要慷慨使用strcasecmp(),并进行各种错误检查。
我不太明白你在问什么,应该如何处理连接调用和输出?
如果有效,我会返回某种标志值。
我应该从主函数还是调用函数处理输出?
大多数情况下是从调用函数处理,因为这些函数具有你关心的个别功能。main()只是将它们粘合在一起的胶水。
如果我的伪代码完全错误,那么以上所有问题都无关紧要。基础部分有什么问题吗?
按照你的方式看起来可行,但是如果你切换到lex/yacc或flex/bison,你将避免很大的麻烦。

1
我猜他问这个是因为他想学习它们是如何工作的。我同意使用工具学习解析器会教你很多。然后再写自己的解析器。 - doug65536

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