2016-08-05 55 views
2

Ich arbeitete an einem Parser-Projekt und ich implementierte es mit recursive descent parser. Das Problem könnte jedoch leicht Stapelüberlauf verursachen. Was sind die Techniken, um mit dieser Art von Problem umzugehen?Vermeiden Sie stackoverflow im rekursiven Algorithmus in rekursiven Abstieg Parser

Zur Veranschaulichung, hier ist einfache mathematische Ausdruck Parser, mit Addition, Subtraktion, Multiplikation und Division zu unterstützen. Gruppierungsklammern könnten verwendet werden, und sie lösen offensichtlich eine Rekursion aus.

hier ist voll Code:

#include <string> 
#include <list> 
#include <iostream> 

using namespace std; 

struct term_t; 
typedef list<term_t> prod_t; 
typedef list<prod_t> expr_t; 
struct term_t 
{ 
    bool div; 
    double value; 
    expr_t expr; 
}; 

double eval(const expr_t &expr); 
double eval(const term_t &term) 
{ 
    return !term.expr.empty() ? eval(term.expr) : term.value; 
} 
double eval(const prod_t &terms) 
{ 
    double ret = 1; 
    for (const auto &term : terms) 
    { 
     double x = eval(term); 
     if (term.div) 
      ret /= x; 
     else 
      ret *= x; 
    } 
    return ret; 
} 
double eval(const expr_t &expr) 
{ 
    double ret = 0; 
    for (const auto &prod : expr) 
     ret += eval(prod); 
    return ret; 
} 

class expression 
{ 
public: 
    expression(const char *expr) : p(expr) 
    { 
     prod(); 
     for (;;) 
     { 
      ws(); 
      if (!next('+') && *p != '-') // treat (a-b) as (a+-b) 
       break; 
      prod(); 
     } 
    } 
    operator const expr_t&() const 
    { 
     return expr; 
    } 

private: 
    void term() 
    { 
     expr.back().resize(expr.back().size() + 1); 
     term_t &t = expr.back().back(); 
     ws(); 
     if (next('(')) 
     { 
      expression parser(p); // recursion 
      p = parser.p; 
      t.expr.swap(parser.expr); 
      ws(); 
      if (!next(')')) 
       throw "expected ')'"; 
     } 
     else 
      num(t.value); 
    } 
    void num(double &f) 
    { 
     int n; 
     if (sscanf(p, "%lf%n", &f, &n) < 1) 
      throw "cannot parse number"; 
     p += n; 
    } 
    void prod() 
    { 
     expr.resize(expr.size() + 1); 
     term(); 
     for (;;) 
     { 
      ws(); 
      if (!next('/') && !next('*')) 
       break; 
      term(); 
     } 
    } 
    void ws() 
    { 
     while (*p == ' ' || *p == '\t') 
      ++p; 
    } 
    bool next(char c) 
    { 
     if (*p != c) 
      return false; 
     ++p; 
     return true; 
    } 

    const char *p; 
    expr_t expr; 
}; 

int main() 
{ 
    string expr; 
    while (getline(cin, expr)) 
     cout << "= " << eval(expression(expr.c_str())) << endl; 
} 

, wenn Sie ausführen, können Sie einfache mathematische Ausdrücke wie 1+2*3+4*(5+6*7) Typ und wird 195 korrekt berechnen. Ich habe auch einfache Ausdruck Auswertung hinzugefügt, es verursacht auch Rekursion und Ursachen Stapelüberlauf noch einfacher als das Parsen. Wie auch immer, das Parsen selbst ist einfach und offensichtlich, wie kann ich es umschreiben, ohne große Änderungen im Code vorzunehmen und die Rekursion vollständig zu vermeiden? In meinem Fall verwende ich einen ähnlichen Ausdruck wie diese (((((1))))), um Rekursion zu verursachen, und wenn ich nur ein paar hundert Klammern habe, bekomme ich Stapelüberlauf. Wenn ich mit Debugger (in Visual Studio) die Rekursionsbaumstruktur durchtrete, wenn nur drei Funktionen: [term ->] expression ctor -> ->term und aus der Registerprüfung nehmen diese drei Funktionen 700-1000 Bytes Stapelspeicherplatz. Mit den Optimierungseinstellungen und dem Fiddling-Code kann ich etwas weniger machen, und mit den Compiler-Einstellungen kann ich den Stack-Speicherplatz erhöhen, oder in diesem Fall könnte ich auch Dijksta's shunting-yard algorithm verwenden, aber das ist nicht der Punkt der Frage: Ich möchte wissen, wie man umschreibt um Rekursion zu vermeiden und gleichzeitig, wenn möglich, ohne den Parsing-Code komplett neu zu schreiben.

+2

siehe "Tail-Rekursion-Optimierung". Die Grundidee besteht darin, Teilergebnisse in eine rekursive Funktion zu überführen. Dann wird der Compiler den gleichen Frame für jeden rekursiven Aufruf verwenden. – DanielS

+0

* wenn ich nur ein paar hundert Klammern habe * - Erwarten Sie in einem realen Szenario wirklich mehr als 200 verschachtelte Klammern? – PaulMcKenzie

+0

@PaulMcKenzie im wirklichen Leben Ich hatte dieses Problem nicht, aber ich wollte herausfinden, wie man mit so etwas umgeht. – Pavel

Antwort

3

Ein rekursiv -descent Parser ist notwendigerweise rekursiv; Der Name ist nicht launisch.

Wenn eine Produktion rechtsrekursiv ist, ist ihre entsprechende rekursive Abstiegsaktion tailrekursiv. Mit einer geeigneten Grammatik könnten Sie also einen tail-rekursiven Parser erzeugen, aber die Produktion für Klammern in Klammern ist schwierig. (Und siehe unten.)

Sie können die Rekursion simulieren, indem Sie einen simulierten Call-Stack beibehalten, aber die Stack-Manipulation wird wahrscheinlich die Einfachheit des rekursiven Descent-Parsers überfordern. In jedem Fall gibt es einfachere iterative Algorithmen, die einen expliziten Syntaxanalyse-Stapel verwenden, daher könnte es sinnvoller sein, einen von ihnen zu verwenden. Aber das würde die Frage nicht beantworten.

HINWEIS: Wenn Sie C++ verwenden, müssen Sie durch einige Reifen springen, um einen Endkontext zu erstellen. Insbesondere wenn Sie ein Objekt mit einem nicht-trivialen Destruktor (wie beispielsweise std :: list) zuweisen, tritt der automatische Destruktoraufruf im Endkontext auf, und der letzte explizite Funktionsaufruf ist kein Endaufruf.

+1

bevor ich die Frage stelle, habe ich tatsächlich mit der Tail-Rekursion experimentiert, und es scheint in C++ problematisch zu sein, auch wenn Sie nicht-triviale Objekte nicht explizit im Stack zuweisen. Ich musste auf alle Arten von Macken zurückgreifen, um den Stackverbrauch zu reduzieren. – Pavel

+0

Auch habe ich diesen einfachen Parser ohne Rekursion implementiert, aber andere rekursive Funktionen würden stackoverflow auslösen. Es gibt zwei offensichtlich rekursive Funktionen: Parsing und 'eval', selbst nachdem ich sie geändert hatte, würde der Destruktor Stack-Overflow auslösen :) und es ist offensichtlich, dass es dies tun sollte, wenn Sie' expr_t' Struktur überprüfen – Pavel

+0

@Pavel: Sie können sicherlich ohne analysieren Rekursion. Aber es erfordert das Umschreiben des Parsers, von dem Sie sagen, dass es außerhalb der Grenzen liegt.Persönlich würde ich vorschlagen, einen binären Ausdrucksbaum anstelle von Listen von Listen von Listen zu verwenden; Die Listen haben mehr Overhead als Sie wahrscheinlich wollen und kaufen Sie nicht viel, wenn überhaupt, in Bezug auf Code-Einfachheit. – rici

1

Zum Analysieren von Ausdrücken sehen Sie sich die Analyse der Operatorpriorität an, z. B. http://epaperpress.com/oper/download/OperatorPrecedenceParsing.pdf. Es analysiert Ausdrücke in einer einfachen Schleife mit einem Datenstapel. Der einzige Platz, der für 200 verschachtelte Klammern benötigt wird, ist 200 Einträge in einem Datenstapel.

Es gibt Sprachen, in denen zur Laufzeit neue Operatoren hinzugefügt werden können, wobei das kompilierte Programm Assoziativität und Vorrang dieser Operatoren angibt, etwas, das nicht mit einem rekursiven anständigen Parser gehandhabt werden konnte.

+0

aber Rangier-yeard tut genau das. Beschreibt dieses Papier das Algo? – Pavel

+0

das sieht aus wie Rangierbahnhofsalgorithmus, aber es erwähnt es nicht. – Pavel

+0

Operatorpriorität ist relativ einfach als Teil des rekursiven Descent-Parsers zu implementieren. Ein Beispiel ist ein Teil von llvm tutorial http://llvm.org/docs/tutorial/LangImpl02.html –

2

Die gängige Praxis für rekursive Descent-Parser besteht darin, in Teilausdrücke, Nicht-Terminale oder verschachtelte Konstrukte zu recursen, aber Rekursion nicht zu verwenden, um das Parsen auf derselben Ebene fortzusetzen. Dies macht die Stapelgröße zu einem Limit für die maximale "Tiefe" der Zeichenfolge, die Sie analysieren können, jedoch nicht auf deren Länge.

Es sieht aus wie Sie diesen Teil richtig gemacht haben, können so sehen die typischen Zahlen ...

Wegen der Stack-basierten Beschränkung werden die rekursiven Analyse Funktionen in der Regel so geschrieben, dass sie nicht ein verwenden viel Stapel - 128 Bytes oder so wäre ein hoher Durchschnitt.

Also, wenn Sie sagen, 128K Stapelplatz (was normalerweise bedeutet, dass Ihr Stapel bereits 90% voll ist), dann sollten Sie in der Lage sein, 1000 Ebenen zu erhalten, und das ist viel für die reale Welt Texte, die Programmierer tatsächlich eingeben.

In Ihrem Fall erhalten Sie nur 200 Ebenen auf dem Stapel. Dies ist wahrscheinlich auch für das wirkliche Leben in Ordnung, aber wenn Sie nicht in einer sehr eingeschränkten Hardware-Umgebung arbeiten, zeigt dies an, dass Sie nur zu viel Stapelspeicherplatz in Ihren rekursiven Funktionen verwenden.

Ich weiß nicht die Größe der ganzen Klasse, aber ich würde vermuten, dass das Hauptproblem in term() ist, wo Sie eine ganz neue expression auf den Stapel mit der expression parser(p); Deklaration legen. Das ist sehr ungewöhnlich und sieht so aus, als könnte es viel Platz beanspruchen. Sie sollten wahrscheinlich vermeiden, dieses ganze neue Objekt zu machen.

Drucken Sie sizeof(expression) aus, um zu sehen, wie groß das wirklich ist.

+0

in meinem tatsächlichen Code verwendete ich unitque_ptr, weil der Ausdruck größer war. In diesem Beispielcode sollte es klein sein: Es ist ein Zeiger und eine Liste; Liste ist wahrscheinlich Zeiger und Größe intern. Also, 12-16 Bytes im 32-Bit-Modus – Pavel