Parser Combinatorを使用する場合 パーサージェネレーターを使用する場合


59

最近、パーサーの世界を深く掘り下げて、独自のプログラミング言語を作成したいと考えています。

しかし、パーサーを作成するには、パーサージェネレーターとパーサーコンビネーターという2つの異なるアプローチが存在することがわかりました。

興味深いことに、どのケースでどのアプローチが優れているかを説明したリソースを見つけることができませんでした。むしろ、資源(および人)の多くは、私は、他のアプローチを知っているだけで説明していない主題について質問彼らのようなアプローチをアプローチし、他のすべてのに言及していません。

  • 有名なドラゴンブックは字句/スキャンに入ると、(f)LEXに言及しているが、すべてのパーサコンビネータを言及していません。
  • 言語実装パターンは、Javaで構築されたANTLR Parser Generatorに大きく依存しており、Parser Combinatorsについては一切言及していません。
  • HaskellのParser CombinatorであるParsecのParsec入門チュートリアルでは、Parser Generatorsについてはまったく言及していません。
  • Boost :: spirit、最も有名なC ++ Parser Combinatorは、Parser Generatorsについてまったく言及していません。
  • パーサーコンビネーターを発明した可能性のある優れた説明ブログ投稿では、パーサージェネレーターについては一切言及していません。

簡単な概要:

パーサージェネレーター

パーサージェネレーターは、Extended Backus-Naur形式の方言であるDSLで記述されたファイルを受け取り、それを(コンパイル時に)このDSLで記述された入力言語のパーサーにできるソースコードに変換します。

これは、コンパイルプロセスが2つの別々のステップで実行されることを意味します。興味深いことに、パーサージェネレーター自体もコンパイラーです(そしてそれらの多くは実際に自己ホスト型です)。

パーサーコンビネーター

パーサーコンビネーターは、すべてがパラメーターとして入力を受け取るパーサーと呼ばれる単純な関数を記述し、一致する場合、この入力の最初の文字を抜き取ります。パーサーがこの入力から何も解析できなかった場合は、タプルを返します。タプル(result, rest_of_input)result空(nilまたはNothing)の場合があります。例はdigitパーサーです。もちろん、他のパーサーは、パーサーを最初の引数(最後の引数は入力文字列のまま)としてそれらを結合many1できます。

言うまでもなく、新しいパーサーを作成するために、もちろん(compose)digitとを組み合わせることができmany1ますinteger

また、choiceパーサーのリストを取得し、それぞれを順に試す、より高レベルのパーサーを作成することもできます。

このようにして、非常に複雑なレクサー/パーサーを構築できます。演算子のオーバーロードをサポートする言語では、ターゲット言語で直接記述されていても、EBNFに非常によく似ています(ターゲット言語のすべての機能を使用できます)。

単純な違い

言語:

  • パーサージェネレーターは、EBNFのようなDSLと、これらのステートメントが一致したときに生成するコードの組み合わせで記述されます。
  • パーサーコンビネーターは、ターゲット言語で直接記述されています。

字句解析/解析:

  • パーサージェネレーターは、「レクサー」(文字列をタグ付けされたトークンに分割して、処理している値の種類を示す)と「パーサー」(レクサーからトークンの出力リストを取得する)そしてそれらを組み合わせて、抽象構文ツリーを形成しようとします)。
  • パーサーコンビネーターには、この区別はありません/必要ありません。通常、単純なパーサーは「レクサー」の作業を実行し、より高レベルのパーサーはこれらの単純なパーサーを呼び出して、作成するASTノードの種類を決定します。

質問

しかし、これらの違いを考えると(そしてこれはおそらく完全なものではありません!)、いつどれを使用するかについて知識のある選択をすることはできません。これらの違いの意味/結果が何であるかはわかりません。

パーサージェネレーターを使用すると、問題をより適切に解決できることを示す問題のプロパティは何ですか?Parser Combinatorを使用して問題を解決する方が適切であることを示す問題のプロパティは何ですか?


4
言及していないパーサーを実装する方法は、少なくとも2つあります。パーサーインタープリター(パーサー言語をCやJavaにコンパイルする代わりに、パーサー言語を直接実行する以外は、パーサージェネレーターに似ています)手でパーサー。パーサーを手動で記述することは、多くの最新の生産対応の産業用言語実装(GCC、Clang javac、Scalaなど)の実装の好ましい形式です。それはあなたに良いエラーメッセージを生成するのに役立ちます内部パーサ状態、(近年では...で最も制御できます
イェルクWミッターク

3
…は、言語の実装者にとって非常に高い優先度になりました)。また、多くの既存のパーサージェネレーター/インタープリター/コンビネーターは、現代の言語実装が満たさなければならない多種多様な要求に対応するように実際に設計されていません。たとえば、多くの最新の言語実装では、バッチコンパイル、IDEバックグラウンドコンパイル、構文強調表示、自動リファクタリング、インテリジェントコード補完、自動ドキュメント生成、自動ダイアグラム作成などに同じコードを使用します。Scalaは、ランタイムリフレクションとそのマクロシステムにもコンパイラを使用。多くの既存のパーサ...
イェルクWミッターク

1
…フレームワークは、それに対処するのに十分な柔軟性がありません。また、EBNFに基づいていないパーサーフレームワークがあることに注意してください。例えば、式文法の構文解析のためのpackratパーサー
ヨルグWミットタグ

2
コンパイルしようとしている言語に大きく依存すると思います。どのタイプですか(LR、...)?
qwerty_so 16

1
上記の仮定は、通常はレクサー/ LRパーサーの組み合わせでコンパイルされたBNFに基づいています。しかし、言語は必ずしもLR文法に基づいているわけではありません。コンパイルを計画しているのはどちらですか?
qwerty_so

回答:


59

これらの別々の技術が存在する理由と、それらの長所と短所が何であるかをよりよく理解するために、私はこの数日、多くの研究を行いました。

すでに存在する回答のいくつかはそれらの違いの一部を示唆していましたが、完全な状況を示しておらず、やや意見が多いように見えたため、この回答が書かれました。

この説明は長いですが、重要です。我慢してください(または焦りを感じている場合は、最後までスクロールしてフローチャートを表示してください)。


パーサーコンビネーターとパーサージェネレーターの違いを理解するには、まず、存在するさまざまな種類の解析の違いを理解する必要があります。

解析

構文解析は、正式な文法に従って一連の記号を分析するプロセスです。(コンピューティングサイエンスでは)解析を使用して、コンピューターに言語で書かれたテキストを理解させることができます。通常、書かれたテキストを表す解析ツリーを作成し、ツリーの各ノードのさまざまな書かれた部分の意味を保存します。この解析ツリーは、さまざまな目的に使用できます(多くのコンパイラで使用される)別の言語への翻訳、何らかの方法(SQL、HTML)で書かれた命令の直接解釈、Lintersなどのツールでの 作業などなど。解析ツリーは明示的に生成されますが、ツリー内の各タイプのノードで実行されるアクションが直接実行されます。これにより効率が向上しますが、水中では暗黙的な解析ツリーが依然として存在します。

解析は計算上困難な問題です。このテーマに関する研究は50年以上ありましたが、まだ学ぶべきことがたくさんあります。

大まかに言えば、コンピューターが入力を解析できるようにする4つの一般的なアルゴリズムがあります。

  • LL解析。(コンテキストフリーのトップダウン解析。)
  • LR解析。(コンテキストフリーのボトムアップ解析。)
  • PEG + Packrat解析。
  • アーリー解析。

これらのタイプの解析は、非常に一般的な理論的な説明であることに注意してください。これらの各アルゴリズムを物理マシンに実装するには、トレードオフが異なる複数の方法があります。

LLとLRは、Context-Free文法のみを見ることができます(つまり、書き込まれたトークンの周囲のコンテキストは、それらの使用方法を理解するために重要ではありません)。

PEG / Packrat構文解析とEarley構文解析はあまり使用されません。Earley構文解析は、より多くの文法(必ずしもContext-Freeである必要のない文法を含む)を処理できるという点で優れていますが、(ドラゴンが主張するように)本(セクション4.1.1);これらの主張がまだ正確かどうかはわかりません)。 式の構文解析+ Packrat構文解析は、比較的効率的で、LLとLRの両方よりも多くの文法を処理できる方法ですが、以下で簡単に説明するように、あいまいさを隠します。

LL(左から右、左端の派生)

これは、おそらく解析について考える最も自然な方法です。入力文字列の次のトークンを見て、複数の可能な再帰呼び出しのどれをツリー構造を生成するために取るかを決定するという考え方です。

このツリーは「トップダウン」で構築されます。つまり、ツリーのルートから開始し、入力文字列を移動するのと同じ方法で文法規則を移動します。また、読み取られている「infix」トークンストリームに相当する「postfix」を構築していると見なすこともできます。

LLスタイルの解析を実行するパーサーは、指定された元の文法に非常によく似たように記述できます。これにより、比較的簡単に理解、デバッグ、および強化できます。クラシックパーサーコンビネーターは、LLスタイルパーサーを構築するためにまとめることができる「レゴピース」にすぎません。

LR(左から右、右端の派生)

LR解析は逆の方法でボトムアップで行われます。各ステップで、スタックの最上位の要素が文法のリストと比較され、それらが 文法の上位レベルのルールに縮小できるかどうかが確認れます。そうでない場合、入力ストリームからの次のトークンはシフトされ、スタックの一番上に配置されます。

最後に、文法の開始規則を表す単一のノードがスタック上にある場合、プログラムは正しいです。

先のことを考える

これら2つのシステムのいずれかでは、選択を決定する前に、入力からさらにトークンを覗く必要がある場合があります。これは(0)(1)(k)または(*)次のようなこれら二つの一般的なアルゴリズムの名前の後に表示さ-syntax LR(1)LL(k)k通常は「あなたの文法が必要とするだけ」を*表し、通常「このパーサーはバックトラッキングを実行します」を表します。直線的に。

LRスタイルのパーサーは、「先読み」することを決定するかもしれないときに、スタック上にすでに多くのトークンを持っているので、ディスパッチする情報が既にあることに注意してください。これは、同じ文法の場合、LLスタイルのパーサーよりも「先読み」の必要性が少ないことを意味します。

LL vs. LR:アンビゲティ

上記の2つの説明を読むと、LLスタイルの構文解析がより自然に思えるので、なぜLRスタイルの構文解析が存在するのか疑問に思うかもしれません。

ただし、LLスタイルの解析には問題があります:Left Recursion

次のような文法を書くことは非常に自然です。

expr ::= expr '+' expr | term term ::= integer | float

ただし、LLスタイルのパーサーは、この文法を解析するときに無限再帰ループにexpr陥ります。ルールの左端の可能性を試すと、入力を消費せずにこのルールに再帰します。

この問題を解決する方法があります。最も簡単なのは、この種の再帰が発生しないように文法を書き直すことです。

expr ::= term expr_rest expr_rest ::= '+' expr | ϵ term ::= integer | float (ここで、εは、「空の文字列」の略)

この文法は今や再帰的です。すぐに読むのがずっと難しくなることに注意してください。

実際には、左再帰は他の多くのステップを介して間接的に発生する可能性があります。これは、注意するのが難しい問題です。しかし、それを解決しようとすると、文法が読みにくくなります。

Dragon Bookのセクション2.5には次のように記載されています。

矛盾があるように見えます。一方で翻訳を容易にする文法が必要であり、他方で構文解析を容易にする著しく異なる文法が必要です。解決策は、簡単に翻訳できるように文法から始めて、構文解析を容易にするために慎重に変換することです。左再帰を排除することにより、予測再帰下降トランスレーターでの使用に適した文法を取得できます。

LRスタイルのパーサーは、ツリーをボトムアップで構築するため、この左再帰の問題はありません。 しかし、(多くの場合として実装されているLRスタイルのパーサに上記のような文法の精神的な翻訳有限状態オートマトンは
+(およびエラーが発生しやすいが)のように、多くの場合、状態の数百または数千がありますが、やることは非常に困難です考慮すべき状態遷移。これが、LRスタイルのパーサーが通常「コンパイラコンパイラ」とも呼ばれるパーサージェネレータによって生成される理由です。

あいまいさを解決する方法

上記の左再帰のあいまいさを解決する2つの方法を見ました:1)構文を書き直します2)LRパーサーを使用します。

しかし、解決が難しい他の種類のあいまいさがあります。2つの異なるルールが同時に等しく適用できる場合はどうでしょうか。

一般的な例を次に示します。

LLスタイルとLRスタイルの両方のパーサーには、これらの問題があります。算術式の解析に関する問題は、演算子の優先順位を導入することで解決できます。同様に、Dangling Elseのような他の問題は、1つの優先動作を選択し、それに固執することで解決できます。(たとえば、C / C ++では、宙ぶらりんのelseは常に最も近い 'if'に属します)。

これに対する別の「解決策」は、Parser Expression Grammar(PEG)を使用することです。これは、上記で使用したBNF文法に似ていますが、あいまいな場合は、常に「最初を選択」します。もちろん、これは実際に問題を「解決」するのではなく、むしろ曖昧さが実際に存在することを隠しています。

文法にあいまいさがないかどうかを一般に知ることが不可能である理由や、この意味合いが素晴らしいブログ記事LLとLRのコンテキストであるなど、この投稿よりもはるかに詳細な詳細情報ツールは難しいです。強くお勧めできます。今話しているすべてのことを理解するのに大いに役立ちました。

50年の研究

それでも人生は続く。有限状態オートマトンとして実装された「通常の」LRスタイルのパーサーは、数千の状態と遷移を必要とすることが多く、これはプログラムサイズの問題でした。そのため、Simple LR(SLR)やLALR(Look-ahead LR)などのバリアントが記述され、他の手法を組み合わせてオートマトンを小さくし、パーサープログラムのディスクとメモリのフットプリントを削減しました。

また、上記のあいまいさを解決する別の方法は、あいまいさの場合、両方の可能性を保持および解析する一般化された手法を使用することです:どちらかが行の解析に失敗する可能性があります'正しい')、および両方が正しい場合に両方を返す(この方法であいまいさが存在することを示す)。

興味深いことに、一般化LRアルゴリズムについて説明した後、同様のアプローチを使用して一般化LLパーサーを実装できることがわかりました。これは、同様に高速です(曖昧な文法の$ O(n ^ 3)$時間の複雑さ、$ O(n)単純な(LA)LRパーサーよりもブックキーピングは多いものの、完全に明確な文法のための$。これは、より高い定数係数を意味しますが、より自然な再帰降下(トップダウン)スタイルでパーサーを記述できるようにします。記述してデバッグします。

パーサーコンビネーター、パーサージェネレーター

したがって、この長い博覧会で、私たちは今、問題の核心に到達しています:

パーサーコンビネーターとパーサージェネレーターの違いは何ですか?

彼らは本当に異なる種類の獣です:

パーサーコンビネーターが作成されたのは、人々がトップダウンパーサーを書いていて、これらの多くに共通点が多いことに気づいたからです。

パーサージェネレーターが作成されたのは、LLスタイルのパーサーが抱えていた問題のないパーサー(つまり、LRスタイルのパーサー)を構築しようとしていたためです。一般的なものには、(LA)LRを実装するYacc / Bisonが含まれます。

おもしろいことに、今日では景色が多少混乱しています。

  • GLLアルゴリズム動作するパーサーコンビネーターを記述して、古典的なLLスタイルパーサーが抱えていた曖昧さの問題を解決しながら、あらゆる種類のトップダウンパーシングと同じように読み取り/理解することができます。

  • パーサージェネレーターは、LLスタイルのパーサー用に作成することもできます。ANTLRはそれを正確に行い、他のヒューリスティック(Adaptive LL(*))を使用して、古典的なLLスタイルのパーサーが持つあいまいさを解決します。

一般に、LRパーサージェネレーターを作成し、グラマーで実行されている(LA)LRスタイルのパーサージェネレーターの出力をデバッグすることは、元のグラマーを「inside-out」LRフォームに変換するため、困難です。一方、Yacc / Bisonのようなツールは長年にわたって最適化されており多くの人が実際に使用されています。つまり、多くの人が構文解析を行う方法と見なしており新しいアプローチに懐疑的です。

どちらを使用すべきかは、文法の難易度と、パーサーの速度に依存します。文法に応じて、これらの手法の1つ(さまざまな手法の実装)は、他の手法よりも高速であるか、メモリフットプリントが小さいか、ディスクフットプリントが小さいか、拡張性が高いか、デバッグが容易です。マイレージは異なる場合があります。

サイドノート:字句解析について。

字句解析は、パーサーコンビネーターとパーサージェネレーターの両方に使用できます。アイデアは、実装が非常に簡単(したがって高速)の「ダム」パーサーを使用して、ソースコードの最初のパスを実行し、たとえば繰り返しの空白、コメントなどを削除し、場合によっては「トークン化」することですあなたの言語を構成するさまざまな要素の大まかな方法​​。

主な利点は、この最初のステップにより、実際のパーサーが非常に単純になることです(そのため、おそらくより高速になります)。主な欠点は、別の変換手順があることです。たとえば、空白の削除により、行番号と列番号のエラー報告が難しくなります。

最後のレクサーは「単なる」別のパーサーであり、上記の手法のいずれかを使用して実装できます。その単純さのため、多くの場合、メインパーサー以外の他の手法が使用され、たとえば追加の「レクサージェネレーター」が存在します。


Tl; Dr:

ほとんどの場合に適用できるフローチャートを次に示します。 ここに画像の説明を入力してください


@Sjoerdそれは非常に難しい問題であることが判明したため、実に多くのテキストです。最後の段落をより明確にする方法を知っているなら、私はすべて耳にします:「どちらを使用すべきかは、文法の難易度と、パーサーの必要性の速さに依存します。文法によっては、これらの手法の1つ(/異なる手法の実装)は、他の手法よりも高速であるか、メモリフットプリントが小さいか、ディスクフットプリントが小さいか、拡張性が高いか、デバッグが容易です。
Qqwy 16

1
他の回答は、はるかに短く、より明確であり、回答の際により良い仕事をします。
シェード16

1
@Sjoerd私がこの答えを書いた理由は、他の答えが問題を単純化しすぎているか、部分的な答えを完全な答えとして提示している、および/または逸話の誤 trapに陥ったためです。上記の答えは、JörgW Mittag、Thomas Killian、および私が彼らが話していることを理解し、事前の知識を仮定せずにそれを提示したに質問のコメントにあった議論の具体化です。
Qqwy 16

いずれにせよ、私はtl; drフローチャートを質問に追加しました。@Sjoerd、それで満足ですか?
Qqwy 16

2
パーサーコンビネーターは、実際に使用しないと問題を解決できません。単なるコンビネータよりも多くのコンビネータがあり、それが|ポイントです。の正しい書き直しexprはさらに簡潔ですexpr = term 'sepBy' "+"(ここでは、一重引用符は、バックマークの代わりに関数中置記号を使用しています。これは、ミニマークダウンに文字エスケープがないためです)。より一般的な場合には、chainByコンビネータもあります。私は、PCにあまり適していない例として単純な解析タスクを見つけることは難しいことを理解していますが、それは本当に彼らにとって有利な強力な議論です。
スティーブンアームストロング

8

構文エラーがないことが保証されている入力の場合、または構文の正確性に関する全体的な合格/不合格が許容される場合、パーサーコンビネーターは、特に関数型プログラミング言語での作業がはるかに簡単です。これらは、プログラミングパズル、データファイルの読み取りなどの状況です。

パーサージェネレーターの複雑さを追加したい機能は、エラーメッセージです。ユーザーが行と列を指すエラーメッセージが必要であり、できれば人間にも理解できることを願っています。それを適切に行うには多くのコードが必要であり、antlrなどのより優れたパーサージェネレーターがそれを支援します。

ただし、自動生成ではこれまでのところしか取得できず、ほとんどの商用で長寿命のオープンソースコンパイラは、パーサーを手動で作成することになります。もしあなたがこれをやる気があれば、この質問をしたことはないだろうと思うので、パーサージェネレーターを使うことをお勧めします。


2
ご回答ありがとうございます!パーサーコンビネーターよりもパーサージェネレーターを使用して読み取り可能なエラーメッセージを作成する方が簡単なのはなぜですか?(具体的には、どの実装について話しているかに関係なく)たとえば、ParsecとSpiritの両方に、行と列の情報を含むエラーメッセージを出力する機能が含まれていることを知っているので、Parser Combinatorsでも同様にこれを行うことができます。
Qqwy 16

パーサーコンビネーターでエラーメッセージを出力できないということではなく、エラーメッセージをミックスに投げ込んだときに、それらの利点があまりはっきりしないということです。両方の方法を使用して比較的複雑な文法を実行すると、私が何を意味するかがわかります。
カールビーレフェルト

パーサーコンビネーターを使用すると、定義により、エラー状態になるのは「この時点から開始し、正当な入力が見つかりませんでした」だけです。これは実際に何が間違っていたかを教えてくれません。理論的には、その時点で呼び出された個々のパーサーは、それが何を期待し、見つけられなかったかをあなたに伝えることができますが、あなたができることはすべてそれを出力することです。
ジョンR.ストローム

1
正直に言うと、パーサージェネレーターは、適切なエラーメッセージでも正確には知られていません。
マイルルーティング

デフォルトではありませんが、良いエラーメッセージを追加するためのより便利なフックがあります。
カールビーレフェルト

4

ANTLRパーサージェネレーターのメンテナーの1人であるサムハーウェルは最近、次のように書いています。

[コンビネータ]が私のニーズを満たしていないことがわかりました。

  1. ANTLRは、あいまいさなどを管理するためのツールを提供してくれます。開発中に、曖昧な解析結果を表示できるツールがあり、文法のあいまいさを取り除くことができます。実行時に、IDEの不完全な入力に起因するあいまいさを活用して、コード補完などの機能でより正確な結果を生成できます。
  2. 実際には、パーサーコンビネーターはパフォーマンス目標を達成するのに適していないことがわかりました。これの一部は戻る
  3. アウトライン、コード補完、スマートインデントなどの機能に解析結果が使用される場合、文法の微妙な変更がこれらの結果の精度に影響を与えるのは簡単です。ANTLRは、これらの不一致をコンパイルエラーに変換するツールを提供します。そうしないと、型がコンパイルされてしまう場合でもです。IDEを構成するすべての追加コードが最初から新機能の完全なエクスペリエンスを提供することを知っているため、文法に影響する新しい言語機能を自信を持ってプロトタイプ化できます。ANTLR 4のフォーク(C#ターゲットのベース)は、この機能を提供しようとすることさえ知っている唯一のツールです。

基本的に、パーサーコンビネータは遊ぶのに便利なおもちゃですが、真面目な作業を行うために単純に切り取られているわけではありません。


3

Karlが言及しているように、パーサージェネレーターの方がエラー報告が優れている傾向があります。加えて:

  • 生成されたコードは構文に特化し、先読み用のジャンプテーブルを生成できるため、より高速になる傾向があります。
  • あいまいな構文を識別したり、左再帰を削除したり、エラー分岐を埋めたりするために、より優れたツールを使用する傾向があります。
  • 再帰的な定義をより適切に処理する傾向があります。
  • 発電機はより長く使用され、ボイラープレートをより多く処理するため、それらがより堅牢になる傾向があります。

一方、コンビネーターには次のような独自の利点があります。

  • これらはコード内にあるため、実行時に構文が異なる場合は、より簡単に変更できます。
  • それらは結びつきやすく、実際に消費しやすい傾向があります(パーサージェネレーターの出力は非常に汎用的で使いにくい傾向があります)。
  • これらはコード内にあるため、文法が期待どおりに動作しない場合、デバッグが少し簡単になる傾向があります。
  • 他のコードと同様に機能するため、学習曲線が浅くなる傾向があります。パーサージェネレーターは、何かを動作させる方法を学ぶために独自の癖を持っている傾向があります。

パーサージェネレーターは、現実の世界で使用される手書きのLL再帰下降パーサーと比較して、恐ろしいエラー報告を持っている傾向があります。パーサージェネレーターは、優れた診断を追加するために必要な状態テーブル遷移フックをほとんど提供しません。これが、ほぼすべての実際のコンパイラーがパーサーコンビネーターまたはパーサージェネレーターを使用しない理由です。LL再帰的パーサーの構築は簡単ですが、「クリーン」なPC / PGとしてではありませんが、より便利です。
dhchdhd
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.