diff --git a/honkit/chapter1.md b/honkit/chapter1.md index 345864f..c9f91ab 100755 --- a/honkit/chapter1.md +++ b/honkit/chapter1.md @@ -108,4 +108,4 @@ x = 1 2025年xx月dd日、自室にて。水島宏太 -[^1] 「コンパイラ 第2版: 原理・技法・ツール」のこと。コンパイラ開発者にとってバイブルとも言われる本である +[^1] 「コンパイラ 第2版: 原理・技法・ツール」のこと。コンパイラ開発者にとってバイブルとも言われる本である \ No newline at end of file diff --git a/honkit/chapter2.md b/honkit/chapter2.md index ce2ddc8..3a042f2 100755 --- a/honkit/chapter2.md +++ b/honkit/chapter2.md @@ -1,135 +1,52 @@ -# 2. 身近な構文解析器 +# 2. 構文解析の基本 - この章から、構文解析についての説明を始めたいと思います。この本を読んでいる読者の方は何らかの形で「構文解析器」または「構文解析」という用語に触れたことがあるのではないかと思います。この用語が意味するものはいったんおいておくとして、この章では、JSON(JavaScript Object Notation)を例にして、典型的な構文解析について説明していきます。 +この章からいよいよ構文解析についての説明を始めたいと思います。とはいっても本書を手に取った皆様は構文解析についてまだ馴染みがないかと思います。そこで、まずは第1章でみたような算術式の構文解析を例にして、構文解析の基本について学ぶことにしましょう。 -## 2.1 JSON(JavaScript Object Notation)の定義 +## 2.1 算術式の文法 - JSONは、特に、WebサービスにアクセスするためのAPIで非常に一般的に使われているデータフォーマットです。また、企業内サービス間で連携するときにも非常によく使われます。皆さんは何らかの形でJSONに触れたことがあるのではないかと思います。 +ただ「算術式」といっただけだと人によってかなりイメージするものが異なります。本書では以下の条件を満たすものを算術式とします。 - JSONは元々は、JavaScriptのサブセットとして、オブジェクトに関する部分だけを切り出したものでしたが、現在はECMAで標準化されており、色々な言語でJSONを扱うライブラリがあります。また、JSONはデータ交換用フォーマットの中でも非常にシンプルであるという特徴があり、そのシンプルさ故か、同じ言語でもJSONを扱うライブラリが乱立する程です。 - - とにかく、まずは、簡単なJSONのサンプルを見てみましょう。 - -### 2.1.1 オブジェクト - - 以下は、二つの名前/値のペアからなる**オブジェクト**です。 - -```js -{ - "name":"Kota Mizushima", - "age":36 -} -``` - - このJSONは、`name`という名前と、`"Kota Mizushima"`という文字列の**ペア**と、`age`という名前と`36`という値のペアからなる**オブジェクト**であることを示しています。なお、用語については、ECMA-404の仕様書に記載されているものに準拠しています。名前/値のペアは、属性やプロパティを呼ばれることもあるので、適宜読み替えてください。 - - 日本語で表現すると、このオブジェクトは、名前が`Kota Mizushima`、年齢が`36`という人物一人分のデータを表していると考えることができます。オブジェクトは、`{}`で囲まれた、`name:value`の対が`,`を区切り文字として続く形になります。後述しますが、`name`の部分は**文字列**である必要があります。 - -### 2.1.2 配列 - - 別の例として、以下のJSONを見てみます。 - -```js -{ - "kind":"Rectangle", - "points": [ - {"x":0, "y":0}, - {"x":0, "y":100}, - {"x":100, "y":100}, - {"x":100, "y":0}, - ] -} -``` - - このJSONは、 - -- "kind":"Rectangle"のペア -- "points":`[...]`のペア - - からなるオブジェクトです。さらに、`"point"`に対応する値が**配列**になっていて、その要素は - -- 名前が`"x"`で値が`0`、名前が`"y"`で値が`0`のペアからなるオブジェクト -- 名前が`"x"`で値が`0`、名前が`"y"`で値が`100`のペアからなるオブジェクト -- 名前が`"x"`で値が`100`、名前が`"y"`で値が`100`のペアからなるオブジェクト -- 名前が`"x"`で値が`100`、名前が`"y"`で値が`0`のペアからなるオブジェクト - - 配列は、`[]`で囲まれた要素の並びで、区切り文字は`,`です。 +- 四則演算が使える + - 足し算は`x + y` + - 引き算は`x + y` + - 掛け算は`x * y` + - 割り算は`x / y` +- 優先順位は掛け算・割り算が高く、足し算・引き算が低い + - たとえば、`1 + 2 * 3`は`1 + (2 * 3)`と解釈される +- 同じ優先順位の演算子は左から右に結びつく + - たとえば、`1 + 2 - 3`は`(1 + 2) - 3`と解釈される +- 値は整数のみ + - たとえば、`1 + 2.0`のような式はエラーになる +- 式の優先順位を変えるために括弧を使うことができる + - たとえば、`(1 + 2) * 3`は`3 * 3`と解釈される +- 式の要素間にあるスペースは無視される + - たとえば、`1+2`は`1 + 2`と同じ - このオブジェクトは、種類が四角形で、それを構成する点が`(0, 0), (0, 100), (100, 100), (100, 0)`からなっているデータを表現しているとみることができます。 - -### 2.1.3 数値 - - これまで見てきたオブジェクトと配列は複合的なデータでしたが、既に出てきているように、JSONにはプリミティブな(これ以上分解できない)データもあります。たとえば、先ほどから出てきている数値もそうです。数値は、 - -```js -1 -10 -100 -1000 -1.0 -1.5 -``` - - のような形になっており、整数または小数です。JSONでの数値の解釈は特に規定されていない点に注意してください。たとえば、`0.1`は2進法での小数だと解釈しても良いですし、10進法での小数と解釈しても構いません。つまり、特に、IEEEの浮動小数点数である、といった規定はありません。 - -### 2.1.4 文字列 - - 先ほどから出てきていますが、JSONのデータには文字列もあります。 - -```js -"Hello, World" -"Kota Mizushima" -"hogehoge" -``` - - のように、`""`で囲まれたのものが文字列となります。オブジェクトのキーになれるのは文字列のみです。たとえば、以下は**JavaScriptの**オブジェクトとしては正しいですが、**JSON**のオブジェクトとしては正しくありません。 - -```js -{ - name:"Kota Mizushima", //nameが"で囲まれていない! - age:36 -} -``` +この定義に従う算術式には以下のようなものが含まれます。 - このような誤ったJSONは、他言語のJSONライブラリではエラーになりますが、JavaScriptの一部として、そのまま書いてもエラーにならないので、注意してください。 - -### 2.1.5 真偽値 - - JSONには、多くのプログラミング言語にある真偽値もあります。JSONの真偽値は以下のように、`true`または`false`の二通りです。 - -```js -true -false +```text +1 + 2 * 3 +(1 + 2) * 3 +3 * (1 + 2) +12 / 3 +1 + 3 * 4 / 2 ``` - 真偽値も特に解釈方法は定められていませんが、ほとんどのプログラミング言語で、該当するリテラル表現があるので、他の言語で取り扱う時は、おおむねそのよな真偽値リテラルにマッピングされます。 +普段、皆さんは何かしらのプログラミング言語を使ってプログラムを書いているはずですから、上のような算術式は馴染みが深いはずです。 -### 2.1.6 null +しかし、上のような日本語を使った定義だけでは算術式の文法を表現するのには不十分です。たとえば「式の要素間にあるスペースは無視される」という言葉だけでは、`10`と`1 0`を区別することができません。優先順位についても感覚的にはわかるものの、やはり定義としては曖昧です。式の優先順位を変えるために括弧を使えるという表現も、直感にはあっているものの現実の文法を表現するには不十分です。 - 多くのプログラミング言語にある要素ですが、JSONには`null`を含むことができます。多くのプログラミング言語のJSONライブラリでは、無効値に相当する値にマッピングされますが、JSONの仕様では`null`の解釈は定められていません。`null`に相当するリテラルがあればそれにマッピングされる事も多いですが、`Option`や`Maybe`といったデータ型によって`null`を表現する言語では、そのようなデータ型にマッピングされる事が多いようです。 +もちろん、日本語でより詳しく記述して曖昧さを少なくしていくこともできますが、いたずらに長くなるだけです。それよりも、文法を表現するための文法である形式文法を使って、算術式の文法を表現することが一般的です。形式文法というと何かしらとても難しいもののように感じられますが、実際には簡単なものです。次節では形式文法の一つであり、もっともメジャーな表記法であるBNF(Backus-Naur Form、バッカス・ナウア記法)を使って、算術式の文法を表現してみましょう。 -### 2.1.7 JSONの全体像 +## 2.2 算術式のBNF - ここまでで、JSONで現れる6つの要素について説明しましたが、JSONで使える要素は**これだけ**です。このシンプルさが、多くのプログラミング言語でJSONが使われる要因でもあるのでしょう。JSONで使える要素について改めて並べてみます。 +プログラミング言語の文法自体を表現する文法(メタ文法といいます)の一つに、BNFがあります。BNFは、プログラミング言語の文法をはじめ、インターネット上でのメッセージ交換フォーマットなど、様々な文法を表現するのに使われています。本書の読者の方にはBNFに馴染みのない方も多いと思うので、算術式のBNFの前にBNFについて説明します。本書ではISO/IEC 14977で仕様が策定されたEBNFの事を指してBNFと呼ぶことにします。BNFは歴史的に、かなり多くの方言があり、どの記法を使うか事前に説明しておかないと読みづらいためです。 -- オブジェクト -- 配列 -- 数値 -- 文字列 -- 真偽値 -- null +### 2.2.1 BNFの概要 - 次の節では、このJSONの**文法**が、どのような形で表現できるかについて見ていきます。 +BNFははFortranの開発者でもある、John Backus(ジョン・バッカス)らが開発した記法であるBNFです。BNFは「プログラミング言語」そのものの文法を記述するために開発されました。基本情報技術者試験でも出題されるので、ひょっとしたらそこで知った方もおられるかもしれません。 -## 2.2 JSONとBNは - - プログラミング言語の文法自体を表現する文法(メタ文法といいます)の一つに、BNFがあります。BNFは、プログラミング言語の文法をはじめ、インターネット上でのメッセージ交換フォーマットなど、様々な文法を表現するのに使われています。とはえ、本書の読者の方にはBNFに馴染みのない方もいると思うので、まず次の節で簡単にBNFについて説明した後、JSONを表現するBNFについて説明します。なお、本書ではISO/IEC 14977で仕様が策定されたEBNFの事を指してBNFと呼ぶことにします。BNFは歴史的に、かなり多くの方言があり、どの記法を使うか事前に説明しておかないと読みづらいためです。 - -### 2.2.1 BNF - -BNFは、正式にはBackus-Naur Form(バッカス・ナウア記法)と言います。Fortranの開発者でもある、John Backus(ジョン・バッカス)らが開発した記法であるBNFは、「プログラミング言語」そのものの文法を記述するために開発されました。基本情報技術者試験でも出題されるので、ひょっとしたらそこで知った方もおられるかもしれません。 - -とはいっても、これだけではチンプンカンプンかもしれませんので具体例を見て行きましょう。 +では、早速具体例を見て行きましょう。 たとえば、Java言語のローカル変数を宣言するプログラムの断片を考えてみると、以下のようになるでしょう。 @@ -164,1783 +81,154 @@ Double z2 = 1.0; ```bnf local_variable_declaration = type_name identifier ('=' expression)? ';' +``` このような、`=`で分かれた内の左側を規則名と呼び、右側を本体と呼びます。また、本体と非終端記号を合わせて規則と呼びます。本書では、BNFを多用していくので、慣れていくようにしてください。 -### 2.2.2 JSONのBNF +### 2.2.2 算術式のBNF -BNFについて説明し終わったところで、早速、JSONのBNFを見ていくことにしましょう。JSONのBNFによる定義は以下で全てです。 +BNFについて説明し終わったところで、早速、算術式のBNFを見ていくことにしましょう。2.1ででてきた算術式のBNFによる定義は以下で全てです。 ```bnf -json = object; -object = LBRACE RBRACE | LBRACE {pair {COMMA pair} RBRACE; -pair = STRING COLON value; -array = LBRACKET RBRACKET | LBRACKET {value {COMMA value}} RBRACKET ; -value = STRING | NUMBER | object | array | TRUE | FALSE | NULL ; - -STRING = ("\"\"" / "\"" CHAR+ "\"") S; -NUMBER = (INT FRAC EXP | INT EXP | INT FRAC | INT) S; -TRUE = "true" S ; -FALSE = "false" S; -NULL = "null" S; -COMMA = "," S; -COLON = ":" S; -LBRACE = "{" S; -RBRACE = "}" S; -LBRACKET = "[" S; -RBRACKET = "]" S; - -S = ( [ \f\t\r\n]+ - | "/*" (!"*/" _)* "*/" - / "//" (![\r\n] _)* [\r\n] - )* ; - -CHAR = (!(["\\]) _) | "\\" [\\"/bfnrt] | "u" HEX HEX HEX HEX ; -HEX = `[0-9a-fA-F]` ; -INT = ["-"] (`[1-9]` {`[0-9]`} / "0") ; -FRAC = "." [0-9]+ ; -EXP = e `[0-9]` {`[0-9]`} ; -E = "e+" | "e-" | "E+" | "E-" | "e" | "E" ; +expression = term { (PLUS | MINUS) term }; +term = factor { (MULTIPLY | DIVIDE) factor }; +factor = NUMBER | LPAREN expression RPAREN; + +PLUS = '+' SPACING; +MINUS = '-' SPACING; +MULTIPLY = '*' SPACING; +DIVIDE = '/' SPACING; +LPAREN = '(' SPACING; +RPAREN = ')' SPACING; +SPACING = (' ' | '\t' | '\n' | '\r')*; ``` - これまで説明したJSONの要素と比較して見慣れない記号が出てきましたが、一つ一つ見て行きましょう。 +たくさんの記号が出てきましたが、順番に見ていきましょう。 -### json +### 2.2.3 expression -まず、一番上から読んでいきます。先程も書きましたが、BNFでは、 +まず、一番上から読んでいきます。BNFでは、 ``` -json = object; +expression = term { (PLUS | MINUS) term }; ``` -のような、**規則**の集まりによって、文法を表現します。`=`の左側である`json`が**規則名**で、右側(ここでは `object`)が**本体**とになります。さらに、先程は説明していませんでしたが、本体の中に出てくる、他の規則を参照する部分(ここでは`object`)を非終端記号と呼びます。非終端記号は同じBNFで定義されている規則名と一致する必要があります。 - -この規則を日本語で表現すると、「`json`という名前の規則は、`object`という非終端記号を参照している」と読むことができます。また、`object`は、JSONのオブジェクトを表しているので、jsonという規則は全体で一つのオブジェクトを表しているということになります。 - -### object - -`object` はJSONのオブジェクトを表す規則で、定義は以下のようになっています。 - -``` -object = LBRACE RBRACE | LBRACE pair {COMMA pair} RBRACE; -``` - -EBNFにおいて`{}`で囲まれたものは、その中の要素が0回以上繰り返して出現することを示しています。また、`pair`の定義はのちほど出てきますので心配しないでください。 - -この規則によって`object`は - -- ブレースで囲まれたもの(`LBRACE RBRACE`)である - - `LBRACE`はLeft-Brace(開き波カッコ)の略で`{`を示しています - - `RBACE`はRight-Brace(閉じ波カッコ)の略で`}`を示しています -- `LBRACE`が来た後に、`pair`が1回出現して、さらにその後に、`COMMA`(カンマ)を区切り文字として `pair` が1回以上繰り返して出現した後、`RBRACE`が来る - -のどちらかであることを表しています。 +のような、**規則**の集まりによって、文法を表現します。`=`の左側である`expression`が**規則名**で、右側(ここでは `term { (PLUS | MINUS) term }`)が**本体**になります。先程は説明していませんでしたが、本体の中に出てくる、他の規則を参照する部分(ここでは`term`や`PLUS`、`MINUS`)を非終端記号と呼びます。非終端記号は同じBNF内で定義されている規則名と一致する必要があります。 -具体的なJSONを当てはめてみましょう。以下のJSONは、`LBRACE RBRACE`にマッチします。 - -```js -{} -``` - -以下のJSONは`LBRACE {pair {COMMA pair} RBRACE`にマッチします。 - -```js -{"x":1} -{"x":1,"y":2} -{"x":1,"y":2,"z":3} -``` - -しかし、以下のテキストは、`object`に当てはまらず、エラーになります。これは、規則の中を見ると、カンマ(`COMMA`)は区切り文字であるためです。 - -```js -{"x":1,} // ,で終わっている -``` +EBNFにおいて`{}`で囲まれたものは、その中の要素が0回以上繰り返して出現することを示しています。`term { (PLUS | MINUS) term }`は、`term`が`PLUS`または`MINUS`を挟んで1回以上繰り返して出現することを示しています。 + +この規則を日本語で表現すると「`expression`という名前の規則は、右辺`term { (PLUS | MINUS) term }`を参照している」と読むことができます。 -### pair +### 2.2.4 term -`pair`(ペア)は、JSONのオブジェクト内での`"x":1`に当たる部分を表現する規則です。`value`の定義については後述します。 +`term` は算術式の中で、掛け算や割り算を表す規則です。`factor`という規則を参照しています。 ```bnf -pair = STRING COLON value; -``` - -これによってペアは`:`(`COLON`)の前に文字列リテラル(`STRING`)が来て、その後にJSONの値(`value`)が来ることを表しています。`pair`にマッチするテキストとしては、 - -``` -"x":1 -"y":true -``` - -などがあります。一方で、以下のテキストは`pair`にマッチしません。JavaScriptのオブジェクトとJSONが違う点です。 - +term = factor { (MULTIPLY | DIVIDE) factor }; ``` -x:1 // 文字列リテラルでないといけない -``` - -### COMMA -`COMMA`は、カンマを表す規則です。カンマそのものを表すには、単に`","`と書けばいいのですが、任意個の空白文字が続くことを表現したいため、規則`S`(後述)を参照しています。 - -```bnf -COMMA = "," S; -``` +この規則によって`term`は、`factor`が`MULTIPLY`または`DIVDE`を挟んで1回以上繰り返して出現することを示しています -### array +### 2.2.5 factor -`array`は、JSONの値の配列を表す規則です。 +`factor`は算術式の中で、数値や括弧で囲まれた式を表す規則です。 ```bnf -array = LBRACKET RBRACKET | LBRACKET value {COMMA value} RBRACKET ; -``` - -`LBRACKET`は開き大カッコ(`[`)を、`RBRACKET`は閉じ大カッコ(`]`)を表しています。 - -これによって`array`は、 - -- 大カッコで囲まれたもの(`LBRACKET RBRACET`)である -- 開き大カッコ(`LBRACKET`)が来た後に、`value`が1回あらわれて、さらにその後に、`COMMA`を区切り文字として `value` が1回以上繰り返してあらわれた後、閉じ大カッコが来る(`RBRACKET`) - -のどちらかであることを表しています。よく見ると、先程の`object`と同様の構造を持っていることがわかります。 - -`array`についても具体的なJSONを当てはめてみましょう。以下のJSONは`LBRACKET RBRACKET`にマッチします。 - -```js -[] +factor = NUMBER | LPAREN expression RPAREN; ``` -また、以下のJSONは`LBRACKET value {COMMA value} RBRACKET`にマッチします。 - -```js -[1] -[1, 2] -[1, 2, 3] -["foo"] -``` - -しかし、以下のテキストは、`array`に当てはまらず、エラーになります。`{COMMA pair}`とあるように、カンマは必ず後ろにペアを必要とするからです。 - -```js -[1,] // ,で終わっている -``` +これによって`factor`は、`NUMBER`または`LPAREN`で始まって、`RPAREN`で終わることを示しています。`NUMBER`は数値を表す規則です。`LPAREN`は開き括弧(`(`)を表し、`RPAREN`は閉じ括弧(`)`)を表します。 -`value`の定義については次の項で説明します。 +### 2.2.5 PLUS -### value +`PLUS`は`+`記号を表す規則です。 ```bnf -value = STRING | NUMBER | object | array | TRUE | FALSE | NULL ; +PLUS = '+' SPACING; ``` - `value`はJSONの値を表現する規則です。これは、JSONの値は、 - -- 文字列(`STRING`) -- 数値(`NUMBER`) -- オブジェクト(`object`) -- 配列(`array`) -- 真(`TRUE`) -- 偽(`FALSE`) -- ヌル(`NULL`) +`+`のあとに`SPACING`が続くことを示しています。`SPACING`は空白文字の0回以上の繰り返しを表す規則です。`SPACING`については後述します。 - のいずれかでなければいけない事を示しています。JSONを普段使っている皆さんにはお馴染みでしょう。 +### 2.2.6 MINUS -### STRING - - `STRING`は文字列リテラルを表す規則です。 +`MINUS`は`-`記号を表す規則です。 ```bnf -STRING = ("\"\"" / "\"" CHAR+ "\"") S; +MINUS = '-' SPACING; ``` - `"`で始まって、文字が任意個続いて、 `"` で終わります。`COMMA`などと同じように、空白読み飛ばしのために`S`を付けています。`CHAR`の定義については省略します。 +`-`のあとに`SPACING`が続くことを示しています。 -### NUMBER +### 2.2.7 MULTIPLY - `NUMBER`は数値リテラルを表す規則です。 +`MULTIPLY`は`*`記号を表す規則です。 ```bnf -NUMBER = (INT FRAC EXP | INT EXP | INT FRAC | INT) S; +MULTIPLY = '*' SPACING; ``` -- 整数(`INT`)に続いて、小数部(`FRAC`)と指数部(`EXP`)が来る -- 整数(`INT`)に続いて、指数部(`EXP`)が来る -- 整数(`INT`) - - のいずれかであるのが`NUMBER`であるという事を表現しています。同様に、空白読み飛ばしのために、`S`を付けています。 +`*`のあとに`SPACING`が続くことを示しています。 -### TRUE +### 2.2.8 DIVIDE - `TRUE`は、真を表すリテラルを表す規則です。 +`DIVIDE`は`/`記号を表す規則です。 ```bnf -TRUE = "true" S ; +DIVIDE = '/' SPACING; ``` - 文字列 `true` が真を表すということでそのままですね。 +`/`のあとに`SPACING`が続くことを示しています。 -### FALSE +### 2.2.9 LPAREN - `FALSE`は、偽を表すリテラルを表す規則です。構造的には、`TRUE`と同じです。 +`LPAREN`は開き括弧(`(`)を表す規則です。 ```bnf -FALSE = "true" S ; +LPAREN = '(' SPACING; ``` - 文字列 `false` が偽を表すということでそのままです。 +`(`のあとに`SPACING`が続くことを示しています。 -### NULL +### 2.2.10 RPAREN - `NULL`は、ヌルリテラルを表す規則です。構造的には、`TRUE`や`FALSE`と同じです。 +`RPAREN`は閉じ括弧(`)`)を表す規則です。 ```bnf -NULL = "null" S; -``` - - `NULL`は、ヌル値があるプログラミング言語だと、その値にマッピングされますが、ここではあくまでヌル値は`null`で表されることしか言っておらず、**意味は特に規定していない**ことに注意してください。 - -### JSONのBNFまとめ - - このように、JSONのBNFは、非常に少数の規則だけで表現することができます。読者の中には、あまりにも簡潔過ぎて驚かれた方もいるのではないでしょうか。しかし、これだけ単純であるにも関わらず、JSONのBNFは**再帰的に定義されている**ため、非常に複雑な構造も表現することができます。たとえば、 - -- 配列の要素がオブジェクトであり、その中のキー`"a"`に対応する要素の中にさらに配列があって、その配列は空配列である - - といったことも、JSONのBNFでは表現することができます。この、再帰的な規則というのは、構文解析において非常に重要な要素なので、これから本書を読み進める上でも念頭に置いてください。 - -## 2.3 JSONの構文解析器 - -ここまでで、JSONの定義と、その文法について見てきました。この節では、BNFを元に、JSONを**構文解析**するプログラムを考えてみます。まだ、構文解析が何かも定義していないわけですが、とりあえずは、以下のようなインタフェース`JsonParser`インタフェースを実装したクラスを「JSONの構文解析器」と考えることにします。 - -```java -package parser; -interface JsonParser { - public ParseResult parse(String input); -} -``` - - なお、クラス`ParseResult`は以下のようなジェネリックなクラスになっています。`value`は解析結果の値です。これは任意の型をとり得るので、`T`としています。また、`input`は「構文解析の対象となる文字列」を表します。 - -```java -public class ParseResult { - public final T value; - public final String input; - public ParseResult(T value, String input) { - this.value = value; - this.input = input; - } -} -``` - - このインタフェース`JsonParser`は`parse()`メソッドだけを持ちます。`parse()`メソッドは、文字列`input`を受け取り、`ParseResult`型を返します。`JsonValue`は以下のように定義されます。GoF(Gang of Four)パターンで言うところの`Composite`パターンですが、Javaのようなオブジェクト指向言語で、再帰的な木構造を表す時には定番のパターンです。 - -```java -public interface JsonAst { - // value - interface JsonValue {} - - // NULL - class JsonNull implements JsonValue { - private JsonNull(){} - private static final JsonNull INSTANCE = new JsonNull(); - public static JsonNull getInstance() { - return INSTANCE; - } - - @Override - public String toString() { - return "null"; - } - } - - - // TRUE - class JsonTrue implements JsonValue { - private JsonTrue(){} - private static final JsonTrue INSTANCE = new JsonTrue(); - public static JsonTrue getInstance() { - return INSTANCE; - } - - @Override - public String toString() { - return "true"; - } - } - - // FALSE - class JsonFalse implements JsonValue { - private JsonFalse(){} - private static final JsonFalse INSTANCE = new JsonFalse(); - public static JsonFalse getInstance() { - return INSTANCE; - } - - @Override - public String toString() { - return "false"; - } - } - - // NUMBER - class JsonNumber implements JsonValue { - public final double value; - public JsonNumber(double value) { - this.value = value; - } - - @Override - public boolean equals(Object o) { - // 実装は省略 - } - - @Override - public int hashCode() { - // 実装は省略 - } - - @Override - public String toString() { - return "JsonNumber{" + - "value=" + value + - '}'; - } - } - - // STRING - class JsonString implements JsonValue { - public final String value; - public JsonString(String value) { - this.value = value; - } - - @Override - public boolean equals(Object o) { - // 実装は省略 - } - - @Override - public int hashCode() { - // 実装は省略 - } - - @Override - public String toString() { - return "JsonString{" + - "value='" + value + '\'' + - '}'; - } - } - - // object - class JsonObject implements JsonValue { - public final List> properties; - public JsonObject(List> properties) { - this.properties = properties; - } - - @Override - public boolean equals(Object o) { - // 実装は省略 - } - - @Override - public int hashCode() { - // 実装は省略 - } - - @Override - public String toString() { - return "JsonObject{" + - "properties=" + properties + - '}'; - } - } - - // array - class JsonArray implements JsonValue { - public final List elements; - public JsonArray(List elements) { - this.elements = elements; - } - - @Override - public boolean equals(Object o) { - // 実装は省略 - } - - @Override - public int hashCode() { - // 実装は省略 - } - - @Override - public String toString() { - return "JsonArray{" + - "elements=" + elements + - '}'; - } - } -} -``` - -各クラスが、JSONのBNFの規則名に対応しているのがわかるでしょうか。この節では、各規則に対応するメソッドを実装することを通して、実際にJSONの構文解析器を組み上げていきます。 - -### 構文解析器の全体像 - -これから、JSONの構文解析器、つまり、JSONを表す文字列を受け取って、それに対応する上記の`JsonAst.JsonValue`型の値を返すメソッドを実装していくわけですが、まず、構文解析器を表現するクラスの全体像を示しておきます。このクラスは次のようになります。 - -```java -package parser; - -import java.util.ArrayList; -import java.util.List; - -public class PegJsonParser implements JsonParser { - private int cursor; - private String input; - - private int progressiveCursor; - private ParseException progressiveException; - - private static class ParseException extends RuntimeException { - public ParseException(String message) { - super(message); - } - } - - public ParseResult parse(String input) { - this.input = input; - this.cursor = 0; - try { - var value = parseValue(); - return new ParseResult<>(value, input.substring(this.cursor)); - } catch (ParseException e) { - throw progressiveException; - } - } - - private void recognize(String literal) { - if(input.substring(cursor).startsWith(literal)) { - cursor += literal.length(); - } else { - String substring = input.substring(cursor); - int endIndex = cursor + (literal.length() > substring.length() ? substring.length() : literal.length()); - throwParseException("expected: " + literal + ", actual: " + input.substring(cursor, endIndex)); - } - } - - private boolean isHexChar(char ch) { - return ('0' <= ch && ch <= '9') || ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z'); - } - - private void skipWhitespace() { - OUTER: - while(cursor < input.length()) { - char currentCharacter = input.charAt(cursor); - switch (currentCharacter) { - case '\f': - case '\t': - case '\r': - case '\n': - case '\b': - case ' ': - cursor++; - continue OUTER; - default: - break OUTER; - } - } - } - - private JsonAst.JsonValue parseValue() { - int backup = cursor; - try { - return parseString(); - } catch (ParseException e) { - cursor = backup; - } - - try { - return parseNumber(); - } catch (ParseException e) { - cursor = backup; - } - - try { - return parseObject(); - } catch (ParseException e) { - cursor = backup; - } - - try { - return parseArray(); - } catch (ParseException e) { - cursor = backup; - } - - try { - return parseTrue(); - } catch (ParseException e) { - cursor = backup; - } - - try { - return parseFalse(); - } catch (ParseException e) { - cursor = backup; - } - - return parseNull(); - } - - private JsonAst.JsonTrue parseTrue() { - recognize("true"); - skipWhitespace(); - return JsonAst.JsonTrue.getInstance(); - } - - private JsonAst.JsonFalse parseFalse() { - recognize("false"); - skipWhitespace(); - return JsonAst.JsonFalse.getInstance(); - } - - private JsonAst.JsonNull parseNull() { - recognize("null"); - skipWhitespace(); - return JsonAst.JsonNull.getInstance(); - } - - private void parseLBrace() { - recognize("{"); - skipWhitespace(); - } - - private void parseRBrace() { - recognize("}"); - skipWhitespace(); - } - - private void parseLBracket() { - recognize("["); - skipWhitespace(); - } - - private void parseRBracket() { - recognize("]"); - skipWhitespace(); - } - - private void parseComma() { - recognize(","); - skipWhitespace(); - } - - private void parseColon() { - recognize(":"); - skipWhitespace(); - } - - - private JsonAst.JsonString parseString() { - if(cursor >= input.length()) { - throwParseException("expected: \"" + " actual: EOF"); - } - char ch = input.charAt(cursor); - if(ch != '"') { - throwParseException("expected: \"" + "actual: " + ch); - } - cursor++; - var builder = new StringBuilder(); - OUTER: - while(cursor < input.length()) { - ch = input.charAt(cursor); - switch(ch) { - case '\\': - cursor++; - if(cursor >= input.length()) break OUTER; - char nextCh = input.charAt(cursor); - cursor++; - switch (nextCh) { - case 'b': - builder.append('\b'); - break; - case 'f': - builder.append('\f'); - break; - case 'n': - builder.append('\n'); - break; - case 'r': - builder.append('\r'); - break; - case 't': - builder.append('\t'); - break; - case '\\': - builder.append('\\'); - break; - case '"': - builder.append('"'); - break; - case '/': - builder.append('/'); - break; - case 'u': - if(cursor + 4 <= input.length()) { - char[] characters = input.substring(cursor, cursor + 4).toCharArray(); - for(char character:characters) { - if(!isHexChar(character)) { - throwParseException("invalid unicode escape: " + character); - } - } - char result = (char)Integer.parseInt(new String(characters), 16); - builder.append(result); - cursor += 4; - } else { - throwParseException("invalid unicode escape: " + input.substring(cursor)); - } - break; - default: - throwParseException("expected: b|f|n|r|t|\"|\\|/ actual: " + nextCh); - } - break; - case '"': - cursor++; - break OUTER; - default: - builder.append(ch); - cursor++; - break; - } - } - - if(ch != '"') { - throwParseException("expected: " + "\"" + " actual: " + ch); - } else { - skipWhitespace(); - return new JsonAst.JsonString(builder.toString()); - } - throw new RuntimeException("never reach here"); - } - - private void throwParseException(String message) throws ParseException { - var exception = new ParseException(message); - if(progressiveCursor < cursor) { - progressiveCursor = cursor; - progressiveException = exception; - } - throw exception; - } - - private JsonAst.JsonNumber parseNumber() { - int start = cursor; - char ch = 0; - while(cursor < input.length()) { - ch = input.charAt(cursor); - if(!('0' <= ch && ch <= '9')) break; - cursor++; - } - if(start == cursor) { - throwParseException("expected: [0-9] actual: " + (ch != 0 ? ch : "EOF")); - } - return new JsonAst.JsonNumber(Integer.parseInt(input.substring(start, cursor))); - } - - private Pair parsePair() { - var key = parseString(); - parseColon(); - var value = parseValue(); - return new Pair<>(key, value); - } - - private JsonAst.JsonObject parseObject() { - int backup = cursor; - try { - parseLBrace(); - parseRBrace(); - return new JsonAst.JsonObject(new ArrayList<>()); - } catch (ParseException e) { - cursor = backup; - } - - parseLBrace(); - List> members = new ArrayList<>(); - var member = parsePair(); - members.add(member); - try { - while (true) { - parseComma(); - member = parsePair(); - members.add(member); - } - } catch (ParseException e) { - parseRBrace(); - return new JsonAst.JsonObject(members); - } - } - - public JsonAst.JsonArray parseArray() { - int backup = cursor; - try { - parseLBracket(); - parseRBracket(); - return new JsonAst.JsonArray(new ArrayList<>()); - } catch (ParseException e) { - cursor = backup; - } - - parseLBracket(); - List values = new ArrayList<>(); - var value = parseValue(); - values.add(value); - try { - while (true) { - parseComma(); - value = parseValue(); - values.add(value); - } - } catch (ParseException e) { - parseRBracket(); - return new JsonAst.JsonArray(values); - } - } -} -``` - -このクラス`PegJsonParser`で重要なことは、クラスがフィールドとして - -- 入力文字列 `input` -- 現在どこまで読み進めたかを表す整数 `cursor` - -をフィールドとして保持していることです。構文解析器を実装する方法としては、主として、同じ入力文字列を与えれば同じ解析結果が返ってくるような関数型の実装方法と、今回のように、現在どこまで読み進めたかによって解析結果が変わる手続き型の方法があるのですが、説明の都合上、手続き型の方がやりやすいので、本書では手続き型の実装方法を採用しています。 - -なお、`progressive`で始まるフィールドは主にエラーメッセージをわかりやすくするためのものなので、現時点では気にする必要はありません。 - -### nullの構文解析メソッド - -`null`の構文解析は、次のような `parseNull()` メソッドとして定義します。 - -```java -private JsonAst.JsonNull parseNull() { - recognize("null"); - skipWhitespace(); - return JsonAst.JsonNull.getInstance(); -} - -``` - - このメソッドで行っていることを見ていきましょう。このメソッドでは、入力である`input`の現在位置が`"null"`という文字列で始まっているかをチェックします。もしそうなら、**JSONのnull**をあらわす`JsonAst.JsonNull`のインスタンスを返します。もし、先頭が`"null"`でなければ、構文解析は失敗なので例外を発生させますが、これは`recognize()`メソッドの中で行われています。`recognize()`の内部では、入力の現在位置と与えられた文字列を照合して、マッチしない場合例外を投げます。 - - 次に、`skipWhitespace()`メソッドを呼び出して、「空白の読み飛ばし」を行っています。 - - `recognize()`も`skipWhitespace()`も構文解析中に頻出する処理であるため、今回はそれぞれをメソッドにくくりだして、各構文解析メソッドの中で呼び出せるようにしました。 - -### trueの構文解析メソッド - -`true`の構文解析は、次のような `parseTrue()` メソッドとして定義します。 - -```java -private JsonAst.JsonTrue parseTrue() { - recognize("true"); - skipWhitespace(); - return JsonAst.JsonTrue.getInstance(); -} -``` - - 見ればわかりますが、`parseNull()`とほぼ同じです。固定の文字列を解析するという点で両者はほぼ同じ処理であり、引数を除けば同じ処理になるのです。 - -### falseの構文解析メソッド - -`false`の構文解析は、次のシグニチャを持つ `parseFalse()` メソッドとして定義します。 - -```java -private JsonAst.JsonFalse parseFalse() { - recognize("false"); - skipWhitespace(); - return JsonAst.JsonFalse.getInstance(); -} -``` - - これも、`parseNull()`とほぼ同じですので、特に説明の必要はないでしょう。 - -### 数値の構文解析メソッド - -数値の構文解析は、次のシグニチャを持つ `parseNumber()` メソッドとして定義します。 - -```java - private JsonAst.JsonNumber parseNumber() { - int start = cursor; - char ch = 0; - while(cursor < input.length()) { - ch = input.charAt(cursor); - if(!('0' <= ch && ch <= '9')) break; - cursor++; - } - if(start == cursor) { - throwParseException("expected: [0-9] actual: " + (ch != 0 ? ch : "EOF")); - } - return new JsonAst.JsonNumber(Integer.parseInt(input.substring(start, cursor))); } -``` - -`parseNumber()` では、`while`文において、 - -- 0から9までの文字が出る間、入力を読む -- 1桁ずつ、数値に変換する - -という処理を行っています。本来なら、JSONの仕様では、小数も扱えるのですが、構文解析にとっては本質的ではないので本書では省略します。 - -### 文字列の構文解析メソッド - -文字列の構文解析は、次のシグニチャを持つ `parseString()` メソッドとして定義します。 - -```java - private JsonAst.JsonString parseString() { - if(cursor >= input.length()) { - throwParseException("expected: \"" + " actual: EOF"); - } - char ch = input.charAt(cursor); - if(ch != '"') { - throwParseException("expected: \"" + "actual: " + ch); - } - cursor++; - var builder = new StringBuilder(); - OUTER: - while(cursor < input.length()) { - ch = input.charAt(cursor); - switch(ch) { - case '\\': - cursor++; - if(cursor >= input.length()) break OUTER; - char nextCh = input.charAt(cursor); - cursor++; - switch (nextCh) { - case 'b': - builder.append('\b'); - break; - case 'f': - builder.append('\f'); - break; - case 'n': - builder.append('\n'); - break; - case 'r': - builder.append('\r'); - break; - case 't': - builder.append('\t'); - break; - case '\\': - builder.append('\\'); - break; - case '"': - builder.append('"'); - break; - case '/': - builder.append('/'); - break; - case 'u': - if(cursor + 4 <= input.length()) { - char[] characters = input.substring(cursor, cursor + 4).toCharArray(); - for(char character:characters) { - if(!isHexChar(character)) { - throwParseException("invalid unicode escape: " + character); - } - } - char result = (char)Integer.parseInt(new String(characters), 16); - builder.append(result); - cursor += 4; - } else { - throwParseException("invalid unicode escape: " + input.substring(cursor)); - } - break; - default: - throwParseException("expected: b|f|n|r|t|\"|\\|/ actual: " + nextCh); - } - break; - case '"': - cursor++; - break OUTER; - default: - builder.append(ch); - cursor++; - break; - } - } - - if(ch != '"') { - throwParseException("expected: " + "\"" + " actual: " + ch); - } else { - skipWhitespace(); - return new JsonAst.JsonString(builder.toString()); - } - throw new RuntimeException("never reach here"); - } -``` - -`while`文の中が若干複雑になっていますが、一つ一つ見ていきます。 - -まず、最初の部分では、 - -```java - if(cursor >= input.length()) { - throwParseException("expected: \"" + " actual: EOF"); - } - char ch = input.charAt(cursor); - if(ch != '"') { - throwParseException("expected: \"" + "actual: " + ch); - } - cursor++; +RPAREN = ')' SPACING; ``` -- 入力が終端に達していないこと -- 入力の最初が`"`であること +`)`のあとに`SPACING`が続くことを示しています。 -をチェックしています。文字列は当然ながら、ダブルクォートで始まりますし、文字列リテラルは、最低長さが2あるので、それらの条件が満たされなければ例外が投げられるわけです。 +### 2.2.11 SPACING -`while`文の中では、最初が - -- `\` であるか(エスケープシーケンスか) -- それ以外か - -を`switch`文で判定して分岐しています。JSONで使えるエスケープシーケンスは、 - -- `b`, `f`, `n`, `t`, `\`, `"`, `/` -- `uxxxx` (ユニコードエスケープ) - -のいずれかに分かれているので、意味を読み取るのは簡単でしょう。 - -そして、`while`文が終わったあとで、 - - -```java - if(ch != '"') { - throwParseException("expected: " + "\"" + " actual: " + ch); - } else { - skipWhitespace(); - return new JsonAst.JsonString(builder.toString()); - } - throw new RuntimeException("never reach here"); -``` - -というチェックを入れることによって、ダブルクォートで文字列が終端している事を確認した後、空白を読み飛ばしています。 - -メソッドの末尾で`RuntimeException`を`throw`しているのは、ここに到達することは、構文解析器にバグが無い限りはありえないことを示しています。 - -### 配列の構文解析メソッド - -配列の構文解析は、次のシグニチャを持つ `parseArray()` メソッドとして定義します。 - -```java - public JsonAst.JsonArray parseArray() { - int backup = cursor; - try { - parseLBracket(); - parseRBracket(); - return new JsonAst.JsonArray(new ArrayList<>()); - } catch (ParseException e) { - cursor = backup; - } - - parseLBracket(); - List values = new ArrayList<>(); - var value = parseValue(); - values.add(value); - try { - while (true) { - parseComma(); - value = parseValue(); - values.add(value); - } - } catch (ParseException e) { - parseRBracket(); - return new JsonAst.JsonArray(values); - } - } -``` - -この`parseArray()`は多少複雑になります。まず、先頭に`"["`が来るかチェックする必要があります。これをコードにすると、以下のようになります。 - -```java -parseLBracket(); -``` - -`parseLBracket()`は以下のように定義されています。 - -```java - private void parseLBracket() { - recognize("["); - skipWhitespace(); -} -``` - -`recognize()`で、現在の入力位置が`[`と一致しているかチェックをした後、空白を読み飛ばしています。 -  -この`recognize()`は、与えられた文字列リテラルが入力先頭とマッチするかをチェックし、マッチするなら入力を前に進めて、マッチしないなら例外を投げます。内部の実装は以下のようになります。 - -```java - private void recognize(String literal) { - if(input.substring(cursor).startsWith(literal)) { - cursor += literal.length(); - } else { - String substring = input.substring(cursor); - int endIndex = cursor + (literal.length() > substring.length() ? substring.length() : literal.length()); - throwParseException("expected: " + literal + ", actual: " + input.substring(cursor, endIndex)); - } - } -``` - - このようにすることで、マッチしない場合に例外を投げ、そうでなければ入力進めるという挙動を実装できます。`[`の次には任意の`JsonValue`または`"]"`が来る可能性があります。この時、まず最初に、`]`が来ると**仮定**するのがポイントです。 - -```java - int backup = cursor; - try { - parseLBracket(); - parseRBracket(); - return new JsonAst.JsonArray(new ArrayList<>()); - } catch (ParseException e) { - cursor = backup; - } -``` - - もし、仮定が成り立たなかった場合、`ParseException`がthrowされるはずですから、それをcatchして、バックアップした位置に巻き戻します。 - - そして、`]`が来るという仮定が成り立たなかった場合、再び最初に`[`が出現して、その次に来るのは任意の`JsonValue`ですから、以下のようなコードになります。 - -```java - parseLBracket(); - List values = new ArrayList<>(); - var value = parseValue(); - values.add(value); -``` - - ここで、ローカル変数`values`は、配列の要素を格納するためのものです。 - - 配列の中で、最初の要素が読み込まれた後、次に来るのは、`,`か`]`のどちらかですが、ひとまず、`,`が来ると仮定して`while`ループで - -```java -parseComma(); -value = parseValue(); -values.add(value); -``` - - を繰り返します。この繰り返しは、1回ごとに必ず入力を1以上進めるため、必ず失敗します。失敗した時は、テキストが正しいJSONなら、`]`が来るはずなので、 - -```java -parseRBracket(); -return new JsonAst.JsonArray(values); -``` - - とします。もし、テキストが正しいJSONでない場合、`parseRBracket()`から例外が投げられるはずですが、その例外は**より上位の層が適切にリカバーしてくれると期待して**放置します。JSONのような再帰的な構造を解析する時、このような、「自分の呼び出し元が適切にやってくれるはず」(何故なら、自分はその呼び出し元で適切にcatchしているのだから)という考え方が重要になります。 - - このように、多少複雑になりましたが、`parseArray()`の定義が、EBNFにおける表記 +`SPACING`は空白文字を表す規則です。 ```bnf -array = LBRACKET RBRACKET | LBRACKET {value {COMMA value}} RBRACKET ; -``` - - に対応していることがわかるでしょうか。読み方のポイントは、`|`の後を、例外をキャッチした後の処理ととらえることです。 - -### オブジェクトの構文解析メソッド - - -オブジェクトの構文解析は、次のシグニチャを持つ `parseObject()` メソッドとして定義します。 - -```java - private JsonAst.JsonObject parseObject() { - int backup = cursor; - try { - parseLBrace(); - parseRBrace(); - return new JsonAst.JsonObject(new ArrayList<>()); - } catch (ParseException e) { - cursor = backup; - } - - parseLBrace(); - List> members = new ArrayList<>(); - var member = parsePair(); - members.add(member); - try { - while (true) { - parseComma(); - member = parsePair(); - members.add(member); - } - } catch (ParseException e) { - parseRBrace(); - return new JsonAst.JsonObject(members); - } - } -``` - -この定義を見て、ひょっとしたら、 - -「あれ?これ、`parseArray()`とほとんど同じでは」 - -と気づかれた読者の方が居るかも知れません。実際、`parseObject()`がやっていることは非常に`parseArray()`と非常に類似しています。 - -まず、最初に、 - -```java -parseLBrace(); -parseRBrace(); -return new JsonAst.JsonObject(new ArrayList<>()); -``` - -としている箇所は、`{}`という形の空オブジェクトを読み取ろうとしていますが、これは、空配列`[]`を読み取るコードとほぼ同じです。 - -続くコードも、対応する記号が`{}`か`[]`の違いこそあるものの、基本的に同じです。唯一の違いは、オブジェクトの各要素は、`name:value`というペアなため、`parseValue()`の代わりに`parsePair()`を呼び出しているところくらいです。 - -そして、`parsePair()`は以下のように定義されています。 - -```java - private Pair parsePair() { - var key = parseString(); - parseColon(); - var value = parseValue(); - return new Pair<>(key, value); - } -``` - - これは、EBNFにおける以下の定義にそのまま対応しているのがわかるでしょう。 - -``` -pair = STRING COLON value; +SPACING = (' ' | '\t' | '\n' | '\r')*; ``` -### 構文解析における再帰 - - 配列やオブジェクトの構文解析メソッドを見るとわかりますが、 - -- `parseArray() -> parseValue() -> parseArray()` -- `parseArray() -> parseValue() -> parseObject()` -- `parseObject() -> parseValue() -> parseObject()` -- `parseObject() -> parseValue() -> parseArray()` - - のような再帰呼び出しが起こり得ることがわかります。このような再帰呼び出しでは、各ステップで必ず構文解析が1文字以上進むため、JSONがどれだけ深くなっても(スタックが溢れない限り)うまく構文解析ができるのです。 - -### 構文解析とPEG - -ここまででJSONの構文解析器を実装することが出来ましたが、実は、この節で紹介した技法は古典的な構文解析の技法では**ありません**。 - -この節で解説した技法は、Parsing Expression Grammar(PEG)と呼ばれる手法に基づいています。 +空白文字(スペース、タブ、改行、復帰)が0回以上繰り返して出現することを示しています。 -PEGは2004にBryan Ford(ブライアン・フォード)によって提案された形式文法であり、従来主流であったCFG(Context-Free Grammar)と少し違う特徴を持ちますが、プログラミング言語など曖昧性の無い言語の解析に使うのには便利であり、最近では色々な言語でPEGをベースにした構文解析器が実装されています。構文解析を理解するのに字句解析は本来的には余計なものとも言えるので、先にPEGを使った技法を学ぶことで、構文解析についてスムーズに理解してもらえたのではないかと思います。ただし、従来の構文解析手法(という言い方は不適切で、依然として従来の手法の方がよく使われています)を学ぶのも重要な事ですので、次の節では、字句解析という手法を用いた構文解析手法について解説します。 +### 2.2.12 まとめ -## 2.4 字句解析器を使った構文解析器 +このようにして、算術式の文法をBNFで表現することができました。重要なのはこのBNFの表現が、先ほどの日本語の説明よりも正確で、曖昧さがないことです。このため、BNFは、プログラミング言語の文法を表現するだけにとどまらず、曖昧さのない文法を表現するためのツールとして広く使われています。構文解析器を作る際にも、BNFを使って文法を表現することが一般的です。 -さて、前節では、再帰的な構文を取り扱う構文解析法の一種であるPEGを取り扱いましたが、通常の構文解析法では、まず、字句解析という前処理を行ってから構文解析を行います。字句解析の字句は英語ではトークン(`token`)と言われるものです。たとえば、以下の英文があったとします。 +## 2.3 抽象構文木 -``` -We are parsers. -``` - -我々は構文解析器であるというジョーク的な文ですが、それはさておき、この文は - -``` -[We, are, parsers] -``` +こうして算術式の文法を曖昧さのない形で表現することができました。この文法を使って、具体的な算術式を解析すると、その結果として**抽象構文木**(Abstract Syntax Tree、AST)というデータ構造が得られます。抽象構文木という考え方はプログラミング言語に留まらず、コンピュータ上の構造化されたデータを表現するのに非常に重要な概念です。この節では、抽象構文木について説明します。 -という三つのトークン(単語)に分解すると考えるのが字句解析の発想法です。 - -実は、古典的な構文解析の世界では、このような字句解析が必須とされていましたが、それは後の章で説明される構文解析アルゴリズムの都合に加えて、空白のスキップという処理を字句解析で行えるからでもあります。 - -実際、前節で出てきたJSONの構文解析器では`skipWhitespace()`の呼び出しが頻出していましたが、字句解析器で空白を読み飛ばす処理を先に行うことで、構文解析器で空白の読み飛ばしという作業をしなくてよくなります。 - -もちろん、この点はトレードオフがあって、たとえば、空白に関する規則がある言語の中でブレがある場合には、字句解析という前処理はかえってしない方が良いということすらあります。ともあれ、字句解析という前処理を通すことには一定のメリットがあるのは確かです。 - -### 2.4.1 字句解析器を使った構文解析器の全体像 - -この項では、字句解析器を使った構文解析器の全体像を示します。まず最初に、JSONの字句解析器は次のようになります。 - -```java -package parser; - -public class SimpleJsonTokenizer implements JsonTokenizer { - private final String input; - private int index; - private Token fetched; - - public SimpleJsonTokenizer(String input) { - this.input = input; - this.index = 0; - } - - public String rest() { - return input.substring(index); - } - - private static boolean isDigit(char ch) { - return '0' <= ch && ch <= '9'; - } - - private boolean tokenizeNumber(boolean positive) { - char firstChar = input.charAt(index); - if(!isDigit(firstChar)) return false; - int result = 0; - while(index < input.length()) { - char ch = input.charAt(index); - if(!isDigit(ch)) { - fetched = new Token(Token.Type.INTEGER, positive ? result : -result); - return true; - } - result = result * 10 + (ch - '0'); - index++; - } - fetched = new Token(Token.Type.INTEGER, positive ? result : -result); - return true; - } - - private boolean tokenizeStringLiteral() { - char firstChar = input.charAt(index); - int beginIndex = index; - if(firstChar != '"') return false; - index++; - var builder = new StringBuffer(); - while(index < input.length()) { - char ch = input.charAt(index); - if(ch == '"') { - fetched = new Token(Token.Type.STRING, builder.toString()); - index++; - return true; - } - if(ch == '\\') { - index++; - if(index >= input.length()) return false; - char nextCh = input.charAt(index); - switch(nextCh) { - case '\\': - builder.append('\\'); - break; - case '"': - builder.append('"'); - break; - case '/': - builder.append('/'); - break; - case 't': - builder.append('\t'); - break; - case 'f': - builder.append('\f'); - break; - case 'b': - builder.append('\b'); - break; - case 'r': - builder.append('\r'); - break; - case 'n': - builder.append('\n'); - break; - case 'u': - if((index + 1) + 4 >= input.length()) { - throw new TokenizerException("unicode escape ends with EOF: " + input.substring(index)); - } - var unicodeEscape= input.substring(index + 1, index + 1 + 4); - if(!unicodeEscape.matches("[0-9a-fA-F]{4}")) { - throw new TokenizerException("illegal unicode escape: \\u" + unicodeEscape); - } - builder.append((char)Integer.parseInt(unicodeEscape, 16)); - index += 4; - break; - } - } else { - builder.append(ch); - } - index++; - } - return false; - } - - private void accept(String literal, Token.Type type, Object value) { - String head = input.substring(index); - if(head.indexOf(literal) == 0) { - fetched = new Token(type, value); - index += literal.length(); - } else { - throw new TokenizerException("expected: " + literal + ", actual: " + head); - } - } - - @Override - public Token current() { - return fetched; - } - - @Override - public boolean moveNext() { - LOOP: - while(index < input.length()) { - char ch = input.charAt(index); - switch (ch) { - case '[': - accept("[", Token.Type.LBRACKET, "["); - return true; - case ']': - accept("]", Token.Type.RBRACKET, "]"); - return true; - case '{': - accept("{", Token.Type.LBRACE, "{"); - return true; - case '}': - accept("}", Token.Type.RBRACE, "}"); - return true; - case '(': - accept("(", Token.Type.LPAREN, "("); - return true; - case ')': - accept(")", Token.Type.RPAREN, ")"); - return true; - case ',': - accept(",", Token.Type.COMMA, ","); - return true; - case ':': - accept(":", Token.Type.COLON, ":"); - return true; - // true - case 't': - accept("true", Token.Type.TRUE, true); - return true; - // false - case 'f': - accept("false", Token.Type.FALSE, false); - return true; - case 'n': { - String actual; - if (index + 4 <= input.length()) { - actual = input.substring(index, index + 4); - if (actual.equals("null")) { - fetched = new Token(Token.Type.NULL, null); - index += 4; - return true; - } else { - throw new TokenizerException("expected: null, actual: " + actual); - } - } else { - actual = input.substring(index); - throw new TokenizerException("expected: null, actual: " + actual); - } - } - case '"': - return tokenizeStringLiteral(); - // whitespace - case ' ': - case '\t': - case '\n': - case '\r': - case '\b': - case '\f': - char next = 0; - do { - index++; - next = input.charAt(index); - } while (index < input.length() && Character.isWhitespace(next)); - continue LOOP; - default: - if('0' <= ch && ch <= '9') { - return tokenizeNumber(true); - } else if (ch == '+') { - index++; - return tokenizeNumber(true); - } else if (ch == '-') { - index++; - return tokenizeNumber(false); - } else { - throw new TokenizerException("unexpected character: " + ch); - } - } - } - return false; - } -} -``` +### 2.3.1 抽象構文木とは -次に、これを利用した構文解析器のコードを示します。 - -```java -package parser; - -import java.util.ArrayList; -import java.util.List; - -public class SimpleJsonParser implements JsonParser { - private SimpleJsonTokenizer tokenizer; - - public ParseResult parse(String input) { - tokenizer = new SimpleJsonTokenizer(input); - tokenizer.moveNext(); - var value = parseValue(); - return new ParseResult<>(value, tokenizer.rest()); - } - - private JsonAst.JsonValue parseValue() { - var token = tokenizer.current(); - switch(token.type) { - case INTEGER: - return parseNumber(); - case STRING: - return parseString(); - case TRUE: - return parseTrue(); - case FALSE: - return parseFalse(); - case NULL: - return parseNull(); - case LBRACKET: - return parseArray(); - case LBRACE: - return parseObject(); - } - throw new RuntimeException("cannot reach here"); - } - - private JsonAst.JsonTrue parseTrue() { - if(!tokenizer.current().equals(true)) { - return JsonAst.JsonTrue.getInstance(); - } - throw new parser.ParseException("expected: true, actual: " + tokenizer.current().value); - } - - private JsonAst.JsonFalse parseFalse() { - if(!tokenizer.current().equals(false)) { - return JsonAst.JsonFalse.getInstance(); - } - throw new parser.ParseException("expected: false, actual: " + tokenizer.current().value); - } - - private JsonAst.JsonNull parseNull() { - if(tokenizer.current().value == null) { - return JsonAst.JsonNull.getInstance(); - } - throw new parser.ParseException("expected: null, actual: " + tokenizer.current().value); - } - - private JsonAst.JsonString parseString() { - return new JsonAst.JsonString((String)tokenizer.current().value); - } - - private JsonAst.JsonNumber parseNumber() { - var value = (Integer)tokenizer.current().value; - return new JsonAst.JsonNumber(value); - } - - private Pair parsePair() { - var key = parseString(); - tokenizer.moveNext(); - if(tokenizer.current().type != Token.Type.COLON) { - throw new parser.ParseException("expected: `:`, actual: " + tokenizer.current().value); - } - tokenizer.moveNext(); - var value = parseValue(); - return new Pair<>(key, value); - } - - private JsonAst.JsonObject parseObject() { - if(tokenizer.current().type != Token.Type.LBRACE) { - throw new parser.ParseException("expected `{`, actual: " + tokenizer.current().value); - } - - tokenizer.moveNext(); - if(tokenizer.current().type == Token.Type.RBRACE) { - return new JsonAst.JsonObject(new ArrayList<>()); - } - - List> members = new ArrayList<>(); - var pair= parsePair(); - members.add(pair); - - while(tokenizer.moveNext()) { - if(tokenizer.current().type == Token.Type.RBRACE) { - return new JsonAst.JsonObject(members); - } - if(tokenizer.current().type != Token.Type.COMMA) { - throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); - } - tokenizer.moveNext(); - pair = parsePair(); - members.add(pair); - } - - throw new parser.ParseException("unexpected EOF"); - } - - private JsonAst.JsonArray parseArray() { - if(tokenizer.current().type != Token.Type.LBRACKET) { - throw new parser.ParseException("expected: `[`, actual: " + tokenizer.current().value); - } - - tokenizer.moveNext(); - if(tokenizer.current().type == Token.Type.RBRACKET) { - return new JsonAst.JsonArray(new ArrayList<>()); - } - - List values = new ArrayList<>(); - var value = parseValue(); - values.add(value); - - while(tokenizer.moveNext()) { - if(tokenizer.current().type == Token.Type.RBRACKET) { - return new JsonAst.JsonArray(values); - } - if(tokenizer.current().type != Token.Type.COMMA) { - throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); - } - tokenizer.moveNext(); - value = parseValue(); - values.add(value); - } - - throw new ParseException("unexpected EOF"); - } -} -``` +抽象構文木は、プログラムの構造を木構造で表現したものです。プログラムの構造を木構造で表現することで、プログラムの構造を効率的に解析することができます。抽象構文木は、プログラムの構造を表現するためのデータ構造であり、プログラムの構造を解析するためのデータ構造です。たとえば、`1 + 2 * 3`という算術式の抽象構文木は以下のようになります。 -構文解析器から呼び出されている`parserXXX()`メソッドを見るとわかりますが、字句解析器を導入したことによって、文字列の代わりにトークンの列を順によみ込んで、期待通りのトークンが現れたかを事前にチェックしています。また、この構文解析器には空白の読み飛ばしに関する処理が入っていないことに着目してください。 - -、PEG版と異なり、途中で失敗したら後戻り(バックトラック)するという処理も存在しません。後戻りによって、文法の柔軟性を増すというメリットがある一方、構文解析器の速度が落ちるというデメリットもあるため、字句解析器を用いた構文解析器は一般により高速に動作します(ただし、実装者の力量の影響も大きいです)。 - -### 2.4.2 JSONの字句解析器 - -さて、2.4.1で触れたJSONの字句解析器について、この項では、主要な部分に着目して説明します。 - -#### 2.4.2.1 ヘッダ部 - -まず、JSONの字句解析器を実装したクラスである`SimpleJsonTokenizer`の先頭(ヘッダ)部分を読んでみます。 - -```java -public class SimpleJsonTokenizer implements JsonTokenizer { - private final String input; - private int index; - private Token fetched; - - public SimpleJsonTokenizer(String input) { - this.input = input; - this.index = 0; - } -``` - -`String`型のフィールド`input`は、トークンに切り出す元となる文字列を表します。`int`型のフィールド`index`は、今、字句解析器が文字列の何番目を読んでいるかを表すフィールドで0オリジンです。`Token`型のフィールド`fetched`には、字句解析器が切り出したトークンが保存されます。 - -コンストラクタ中では、入力文字列`input`を受け取り、フィールドに格納および、`index`を0に初期化しています。 - -#### 2.4.2.2 本体部 - -`SimpleTokenizer`の主要なメソッドは、 - -- `tokenizeNumber()` -- `tokenizeStringLiteral()` -- `accept()` -- `moveNext()` -- `current()` - -#### 2.4.2.2.1 tokenizeNumber - -`tokenizeNumber()`メソッドは、文字列の現在位置から開始して、数値トークンを切り出すためのメソッドです。引数`positive`の値が`true`なら、正の整数を、`false`なら、負の整数をトークンとして切り出しています。返り値はトークンの切り出しに成功したか、失敗したかを表します。 - -#### 2.4.2.2.2 tokenizeStringLiteral - -`tokenizeStringLiteral()` メソッドは、文字列の現在位置から開始して、文字列リテラルトークンを切り出すためのメソッドです。返り値はトークンの切り出しに成功したか、失敗したかを表します。 - -#### 2.4.2.2.3 accept - -`accept()` メソッドは、文字列の現在位置から開始して、文字列`literal`にマッチしたら、種類`type`で値が`value`なトークンを生成するメソッドです。これは、`toknizeStringLiteral()`など他のメソッドから呼び出されます。 - -#### 2.4.2.2.4 moveNext - -`moveNext()` メソッドは、字句解析器の中核となるメソッドです。呼び出されると、次のトークンは発見するまで、文字列の位置を進め、トークンが発見されたら、トークンを`fetched`に格納して、`true`を返します。トークン列の終了位置に来たら`false`を返します。これは、`Iterator`パターンの一種とも言えますが、典型的な`Iterator`と異なり、`moveNext()`が副作用を持つ点がポイントでしょうか。この点は、.NETの`IEnumerator`のアプローチを参考にしました。 - -#### 2.4.2.2.5 current - -`current()`メソッドは、`moveNext()`メソッドが`true`を返したあとに呼び出すと、切り出せたトークンを取得することができます。`moveNext()`を次に呼び出すと、`current()`の値が変わってくる点に注意が必要です。 - -### 2.4.3 JSONの構文解析器 - -さて、JSONの字句解析器である`SimpleTokenizer`はこのようにして実装しましたが、JSONの構文解析器である`SimpleJSONParser`はどのように実装されているのでしょうか。このクラスは、主に - -- `parseTrue()`メソッド:規則`TRUE`に対応する構文解析メソッド -- `parseFalse()`メソッド:規則`FALSE`に対応する構文解析メソッド -- `parseNull()`メソッド:規則`NULL`に対応する構文解析メソッド -- `parseString()`メソッド:規則`STRING`に対応する構文解析メソッド -- `parseNumber()`メソッド:規則`NUMBER`に対応する構文解析メソッド -- `parseObject()`メソッド:規則`object`に対応する構文解析メソッド -- `parseArray()`メソッド:規則`array`に対応する構文解析メソッド -- `parseValue()`メソッド: 規則`value`に対応する構文解析メソッド - -というメソッドからなっており、それぞれが内部で`SimpleTokenizer`クラスのオブジェクトのメソッドを呼び出しています。では、これらのメソッドについて順番に見て行きましょう。 - -#### 2.4.3.1 parseTrue - -`parseTrue()`メソッドは、規則`TRUE`に対応するメソッドで、JSONの`true`に対応するものを解析するメソッドでもあります。実装は以下のようになります: - -```java - private JsonAst.JsonTrue parseTrue() { - if(!tokenizer.current().equals(true)) { - return JsonAst.JsonTrue.getInstance(); - } - throw new parser.ParseException("expected: true, actual: " + tokenizer.current().value); - } -``` - -見るとわかりますが、`tokenizer`が保持している次のトークンの値が`true`だったら、`JsonAst.JsonTrue`のインスタンスを返しているだけですね。ほぼ、字句解析器に処理を丸投げしているだけですから、詳しい説明は不要でしょう。 - -#### 2.4.3.2 parseFalse - -`parseTrue()`メソッドは、規則`FALSE`に対応するメソッドで、JSONの`false`に対応するものを解析するメソッドでもあります。実装は以下のようになります: - -```java - private JsonAst.JsonFalse parseFalse() { - if(!tokenizer.current().equals(false)) { - return JsonAst.JsonFalse.getInstance(); - } - throw new parser.ParseException("expected: false, actual: " + tokenizer.current().value); - } -``` - -実装については、`parseTrue()`とほぼ同様なので説明は省略します。 - -#### 2.4.3.3 parseNull - -`parseNull()`メソッドは、規則`NULL`に対応するメソッドで、JSONの`null`に対応するものを解析するメソッドでもあります。実装は以下のようになります: - -```java - private JsonAst.JsonNull parseNull() { - if(tokenizer.current().value == null) { - return JsonAst.JsonNull.getInstance(); - } - throw new parser.ParseException("expected: null, actual: " + tokenizer.current().value); - } -``` - -実装については、`parseTrue()`とほぼ同様なので説明は省略します。 - -#### 2.4.3.4 parseString - -`parseString()`メソッドは、規則`STRING`に対応するメソッドで、JSONの`"..."`に対応するものを解析するメソッドでもあります。実装は以下のようになります: - -```java - private JsonAst.JsonString parseString() { - return new JsonAst.JsonString((String)tokenizer.current().value); - } -``` - -実装については、`parseTrue()`とほぼ同様なので説明は省略します。 - -#### 2.4.3.5 parseNumber - -`parseString()`メソッドは、規則`NUMBER`に対応するメソッドで、JSONの`1, 2, 3, 4, ...`に対応するものを解析するメソッドでもあります。実装は以下のようになります: - -```java - private JsonAst.JsonNumber parseNumber() { - var value = (Integer)tokenizer.current().value; - return new JsonAst.JsonNumber(value); - } -``` - -実装については、`parseTrue()`とほぼ同様なので説明は省略します。 - -#### 2.4.3.6 parseObject - -`parseObject()`メソッドは、規則`object`に対応するメソッドで、JSONのオブジェクトリテラルに対応するものを解析するメソッドでもあります。実装は以下のようになります: - -```java - private JsonAst.JsonObject parseObject() { - if(tokenizer.current().type != Token.Type.LBRACE) { - throw new parser.ParseException("expected `{`, actual: " + tokenizer.current().value); - } - - tokenizer.moveNext(); - if(tokenizer.current().type == Token.Type.RBRACE) { - return new JsonAst.JsonObject(new ArrayList<>()); - } - - List> members = new ArrayList<>(); - var pair= parsePair(); - members.add(pair); - - while(tokenizer.moveNext()) { - if(tokenizer.current().type == Token.Type.RBRACE) { - return new JsonAst.JsonObject(members); - } - if(tokenizer.current().type != Token.Type.COMMA) { - throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); - } - tokenizer.moveNext(); - pair = parsePair(); - members.add(pair); - } - - throw new parser.ParseException("unexpected EOF"); - } +```text + + + / \ + 1 * + / \ + 2 3 ``` -まず、最初のif文で、次のトークンが`{`であることを確認した後に、 - -- その次のトークンが`}`であった場合:空オブジェクトを返す -- それ以外の場合: `parsePair()` を呼び出し、 `string:pair` のようなペアを解析した後、以下のループに突入: - - 次のトークンが`}`の場合、集めたペアのリストを引数として、`JsonAst.JsonObject()`オブジェクトを作って返す - - それ以外で、次のトークンが`,`でない場合、構文エラーを投げて終了 - - それ以外の場合:次のトークンをフェッチして来て、`parsePair()`を呼び出して、ペアを解析した後、リストにペアを追加 - -のような動作を行います。実際のコードと対応付けてみると、より理解が進むでしょう。 - -#### 2.4.3.7 parseArray - -`parseArray()`メソッドは、規則`array`に対応するメソッドで、JSONの配列リテラルに対応するものを解析するメソッドでもあります。実装は以下のようになります: - -```java - private JsonAst.JsonArray parseArray() { - if(tokenizer.current().type != Token.Type.LBRACKET) { - throw new parser.ParseException("expected: `[`, actual: " + tokenizer.current().value); - } - - tokenizer.moveNext(); - if(tokenizer.current().type == Token.Type.RBRACKET) { - return new JsonAst.JsonArray(new ArrayList<>()); - } - - List values = new ArrayList<>(); - var value = parseValue(); - values.add(value); - - while(tokenizer.moveNext()) { - if(tokenizer.current().type == Token.Type.RBRACKET) { - return new JsonAst.JsonArray(values); - } - if(tokenizer.current().type != Token.Type.COMMA) { - throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); - } - tokenizer.moveNext(); - value = parseValue(); - values.add(value); - } - - throw new ParseException("unexpected EOF"); - } - ``` - -まず、最初のif文で、次のトークンが`[`であることを確認した後に、 - -- その次のトークンが`]`であった場合:空オブジェクトを返す -- それ以外の場合: `parseValue()` を呼び出し、 `value`を解析した後、以下のループに突入: - - 次のトークンが`}`の場合、集めた`values`のリストを引数として、`JsonAst.JsonObject()`オブジェクトを作って返す - - それ以外で、次のトークンが`,`でない場合、構文エラーを投げて終了 - - それ以外の場合:次のトークンをフェッチして来て、`parsePair()`を呼び出して、`value`を解析した後、リストに`value`を追加 - -のような動作を行います。実際のコードと対応付けてみると、より理解が進むでしょう。 - -なお、`parseArray()`のコードを読めばわかるように、ほとんどのコードは、`parseObject()`と共通のものになっています。もしこれが気になるようであれば、共通部分をくくりだすことも出来ます。 - -### 2.5 まとめ - -この章では、JSONの構文解析や字句解析を実際に作ってみることを通して、構文解析の基礎について学んでもらいました。特に、 - -- 2.1 JSONの定義 -- 2.2 JSONのBNF -- 2.3 JSONの構文解析機(PEG版) -- 2.4 字句解析器を使った構文解析器 - -といった順番で、JSONの定義から入って、PEGによるJSONパーザ、字句解析器を使った構文解析器の作り方について学んでもらいました。この書籍中で使ったJSONはECMAScriptなどで定義されている正式なJSONに比べてサブセットになっており、たとえば、浮動小数点数が完全に扱えないという制限がありますが、構文解析器全体から見ればささいなことなので、この章を理解出来れば、JSONの構文解析についてはある程度理解出来たと思って構いません。 - -次の章では、文脈自由文法(Context-Free Grammar, CFG)の考え方について学んでもらいます。というのは、文脈自由文法は、現在使われているほとんどの構文解析アルゴリズム(もちろん、PEG等を除く)の基盤となっている概念であって、CFGの理解なくしては、その後の構文解析の理解もおぼつかないからです。 +ここで、抽象構文木の各ノードは、プログラムの構造を表現するためのデータ構造です。たとえば、`+`ノードは足し算を表し、`1`ノードは整数の`1`を表します。このように、抽象構文木はプログラムの構造を表現するためのデータ構造であり、プログラムの構造を解析するためのデータ構造です。 -逆に、CFGの考え方さえわかってしまえば、個別の構文解析アルゴリズム自体は、必ずしもそれほど難しいとは感じられなくなって来るかもしれません。 \ No newline at end of file +TBD \ No newline at end of file diff --git a/honkit/chapter3.md b/honkit/chapter3.md index 84b902f..422c2d7 100755 --- a/honkit/chapter3.md +++ b/honkit/chapter3.md @@ -1,418 +1,1818 @@ -# 第3章 文脈自由文法の世界 +# 3. JSONの構文解析 -第3章では、JSONの構文解析器を記述することを通して、基本的な構文解析の方法を学びました。また、構文解析器についても、PEG型の構文解析器および字句解析器を使った二通りを作ってみることで、構文解析器といっても色々な書き方があるのがわかってもらえたのではないかと思います。 +2章までで構文解析に必要な基本概念について学ぶことができました。この章ではJSONという実際に使われている言語を題材に、より実践的な構文解析のやり方を学んでいきます。 -この第4章では、現代の構文解析を語る上で必須である、文脈自由文法という概念について学ぶことにします。「文脈自由文法」というと、一見、堅くて難しそうな印象を持つ方も多いかもしれません。しかし、一言で言ってしまえば、BNFをよりシンプルに、数学的に厳密にしただけのものであって、厳しい言葉から漂う程難解な概念ではありません。一方で、「文脈自由文法」という概念を習得することによるメリットは計り知れないものがあります。たとえば、それによって、正規表現で記述出来ないが文脈自由言語(BNFと表現力では等価)で記述出来る「言語」を知ることが出来ますし、文脈自由言語では記述不可能な「言語」について知ることも出来ます。 +## 3.1 JSON(JavaScript Object Notation)の概要 -さて、文脈自由文法の世界に飛び込んで見ましょう。 +JSONは、WebサービスにアクセスするためのAPIで非常に一般的に使われているデータフォーマットです。また、企業内サービス間で連携するときにも非常によく使われます。皆さんは何らかの形でJSONに触れたことがあるのではないかと思います。 -## 3.1 BNFと文脈自由文法 +JSONは元々は、JavaScriptのサブセットとして、オブジェクトに関する部分だけを切り出したものでしたが、現在はECMAで標準化されており、色々な言語でJSONを扱うライブラリがあります。また、JSONはデータ交換用フォーマットの中でも非常にシンプルであるという特徴があり、そのシンプルさ故か、同じ言語でもJSONを扱うライブラリが乱立する程です。また、今のWebアプリケーション開発に携わる開発者にとってJSONは避けて通れないといってよいでしょう。 + + 以降では簡単なJSONのサンプルを通してJSONの概要を説明します。 -文脈自由文法の定義を大上段に示しても抽象的過ぎますので、皆様に馴染みがあるBNFを文脈自由文法を用いた記述に変換することで、文脈自由文法についての理解のとっかかりとしたいと思います。お題は、「カッコの釣り合いがとれた文字列が任意個続いたもの」です。 +### 3.1.1 オブジェクト -たとえば、 + 以下は、二つの名前/値のペアからなる**オブジェクト**です。 +```js +{ + "name" : "Kota Mizushima", + "age": 41 +} ``` -() -(()) -(()()) -()()() + +このJSONは、`name`という名前と、`"Kota Mizushima"`という文字列の**ペア**と、`age`という名前と`41`という値のペアからなる**オブジェクト**であることを示しています。なお、用語については、ECMA-404の仕様書に記載されているものに準拠しています。名前/値のペアは、属性やプロパティを呼ばれることもあるので、適宜読み替えてください。 + +日本語で表現すると、このオブジェクトは、名前が`Kota Mizushima`、年齢が`41`という人物一人分のデータを表していると考えることができます。オブジェクトは、`{}`で囲まれた、`name:value`の対が`,`を区切り文字として続く形になります。後述しますが、`name`の部分は**文字列**である必要があります。 + +### 3.1.2 配列 + +別の例として、以下のJSONを見てみます。 + +```js +{ + "kind":"Rectangle", + "points": [ + {"x":0, "y":0}, + {"x":0, "y":100}, + {"x":100, "y":100}, + {"x":100, "y":0}, + ] +} +``` + +このJSONは、 + +- "kind":"Rectangle"のペア +- "points":`[...]`のペア + +からなるオブジェクトです。さらに、`"point"`に対応する値が**配列**になっていて、その要素は + +- 名前が`"x"`で値が`0`、名前が`"y"`で値が`0`のペアからなるオブジェクト +- 名前が`"x"`で値が`0`、名前が`"y"`で値が`100`のペアからなるオブジェクト +- 名前が`"x"`で値が`100`、名前が`"y"`で値が`100`のペアからなるオブジェクト +- 名前が`"x"`で値が`100`、名前が`"y"`で値が`0`のペアからなるオブジェクト + +配列は、`[]`で囲まれた要素の並びで、区切り文字は`,`です。 + +このオブジェクトは、種類が四角形で、それを構成する点が`(0, 0), (0, 100), (100, 100), (100, 0)`からなっているデータを表現しているとみることができます。 + +### 3.1.3 数値 + +これまで見てきたオブジェクトと配列は複合的なデータでしたが、既に出てきているように、JSONにはプリミティブな(これ以上分解できない)データもあります。たとえば、先ほどから出てきている数値もそうです。数値は、 + +```js +1 +10 +100 +1000 +1.0 +1.5 +``` + +のような形になっており、整数または小数です。JSONでの数値の解釈は特に規定されていない点に注意してください。たとえば、`0.1`は2進法での小数だと解釈しても良いですし、10進法での小数と解釈しても構いません。つまり、特に、IEEEの浮動小数点数である、といった規定はありません。 + +### 3.1.4 文字列 + +先ほどから出てきていますが、JSONのデータには文字列もあります。 + +```js +"Hello, World" +"Kota Mizushima" +"hogehoge" ``` -は釣り合いの取れた文字列の例です。一方で、 +のように、`""`で囲まれたのものが文字列となります。オブジェクトのキーになれるのは文字列のみです。たとえば、以下は**JavaScriptの**オブジェクトとしては正しいですが、**JSON**のオブジェクトとしては正しくありません。 +```js +{ + name:"Kota Mizushima", //nameが"で囲まれていない! + age:36 +} ``` -)( -(() -()) + +このような誤ったJSONは、他言語のJSONライブラリではエラーになりますが、JavaScriptの一部として、そのまま書いてもエラーにならないので、注意してください。 + +### 3.1.5 真偽値 + +JSONには、多くのプログラミング言語にある真偽値もあります。JSONの真偽値は以下のように、`true`または`false`の二通りです。 + +```js +true +false ``` -は釣り合いの取れていない文字列の例です。このような言語をDyck(ディック)言語と呼び、文脈自由文法を特徴づける言語とされています。 +真偽値も解釈方法は定められていませんが、ほとんどのプログラミング言語で、該当するリテラル表現があるので、他の言語で取り扱う時は、おおむねそのよな真偽値リテラルにマッピングされます。 + +### 3.1.6 null + +多くのプログラミング言語にある要素ですが、JSONには`null`を含むことができます。多くのプログラミング言語のJSONライブラリでは、無効値に相当する値にマッピングされますが、JSONの仕様では`null`の解釈は定められていません。`null`に相当するリテラルがあればそれにマッピングされる事も多いですが、`Option`や`Maybe`といったデータ型によって`null`を表現する言語では、そのようなデータ型にマッピングされる事が多いようです。 + +### 3.1.7 JSONの全体像 -Dyck言語の文法を擬似BNFで記述してみると以下のようになります。 +ここまでで、JSONで現れる6つの要素について説明しましたが、JSONで使える要素は**これだけ**です。このシンプルさが、多くのプログラミング言語でJSONが使われる要因でもあるのでしょう。JSONで使える要素について改めて並べてみます。 -```text -D = P -P = "(" P ")" P | "" +- オブジェクト +- 配列 +- 数値 +- 文字列 +- 真偽値 +- null + +次の節では、このJSONの**文法**が、どのような形で表現できるかについて見ていきます。 + +## 3.2 JSONのBNF + +前の節でJSONの概要について説明し終わったところで、いよいよJSONの文法について見ていきます。JSONの文法はECMA-404の仕様書に記載されていますが、ここでは、BNFで表現されたJSONの文法を見ていきます。 + +JSONのBNFによる定義は以下で全てです。 + +```bnf +json = object; +object = LBRACE RBRACE | LBRACE {pair {COMMA pair} RBRACE; +pair = STRING COLON value; +array = LBRACKET RBRACKET | LBRACKET {value {COMMA value}} RBRACKET ; +value = STRING | NUMBER | object | array | TRUE | FALSE | NULL ; + +STRING = ("\"\"" / "\"" CHAR+ "\"") S; +NUMBER = (INT FRAC EXP | INT EXP | INT FRAC | INT) S; +TRUE = "true" S ; +FALSE = "false" S; +NULL = "null" S; +COMMA = "," S; +COLON = ":" S; +LBRACE = "{" S; +RBRACE = "}" S; +LBRACKET = "[" S; +RBRACKET = "]" S; + +S = ( [ \f\t\r\n]+ + | "/*" (!"*/" _)* "*/" + / "//" (![\r\n] _)* [\r\n] + )* ; + +CHAR = (!(["\\]) _) | "\\" [\\"/bfnrt] | "u" HEX HEX HEX HEX ; +HEX = `[0-9a-fA-F]` ; +INT = ["-"] (`[1-9]` {`[0-9]`} / "0") ; +FRAC = "." [0-9]+ ; +EXP = e `[0-9]` {`[0-9]`} ; +E = "e+" | "e-" | "E+" | "E-" | "e" | "E" ; ``` -`D`が構文解析の際に最初に参照される記号です。このBNFを文脈自由文法による記述になおしていきましょう。 +これまで説明したJSONの要素と比較して見慣れない記号が出てきましたが、一つ一つ見て行きましょう。 + +### 3.2.1 json + +一番上から読んでいきます。2章の復習になりますが、BNFでは、 + + ``` +json = object; + ``` + +のような**規則**の集まりによって、文法を表現します。`=`の左側である`json`が**規則名**で、右側(ここでは `object`)が**本体**とになります。さらに、本体の中に出てくる、他の規則を参照する部分(ここでは`object`)を非終端記号と呼びます。非終端記号は同じBNFで定義されている規則名と一致する必要があります。 + +この規則を日本語で表現すると、「`json`という名前の規則は、`object`という非終端記号を参照している」と読むことができます。また、`object`は、JSONのオブジェクトを表しているので、jsonという規則は全体で一つのオブジェクトを表しているということになります。 -最初に、外側にある"|"を消去します。結果として以下のようになります。 +### 3.2.2 object -```text -D = P -P = "(" P ")" P -P = "" +`object`はJSONのオブジェクトを表す規則で、定義は以下のようになっています。 + +``` +object = LBRACE RBRACE | LBRACE pair {COMMA pair} RBRACE; ``` -同じ名前`P`を持つ記号が二つ出てきてしまいましたね。文脈自由文法の標準的な表記法では、同じ名前の規則が複数出てきても構いません。その場合の解釈は、BNFで同名の規則を"|"でくくった場合とほぼ同じです。 +`pair`の定義はのちほど出てきますので心配しないでください。 + +この規則によって`object`は -次に"="を"→"に置き換えます。 +- ブレースで囲まれたもの(`LBRACE RBRACE`)である + - `LBRACE`はLeft-Brace(開き波カッコ)の略で`{`を示しています + - `RBACE`はRight-Brace(閉じ波カッコ)の略で`}`を示しています +- `LBRACE`が来た後に、`pair`が1回出現して、さらにその後に、`COMMA`(カンマ)を区切り文字として `pair` が1回以上繰り返して出現した後、`RBRACE`が来る -```text -D → P -P → "(" P ")" P -P → "" +のどちらかであることを表しています。 + +具体的なJSONを当てはめてみましょう。以下のJSONは`LBRACE RBRACE`にマッチします。 + +```js +{} ``` -空文字列は`ε`で表現されるのでこれも置き換えます。 +以下のJSONは`LBRACE {pair {COMMA pair} RBRACE`にマッチします。 -```text -D → P -P → "(" P ")" P -P → ε +```js +{"x":1} +{"x":1,"y":2} +{"x":1,"y":2,"z":3} ``` -文字を表すときの`"`も使わないのでこれも消去します。 +しかし、以下のテキストは、`object`に当てはまらず、エラーになります。これは、規則の中を見ると、カンマ(`COMMA`)は区切り文字であるためです。 + +```js +{"x":1,} // ,で終わっている +``` + +### 3.2.3 pair + +`pair`(ペア)は、JSONのオブジェクト内での`"x":1`に当たる部分を表現する規則です。`value`の定義については後述します。 + +```bnf +pair = STRING COLON value; +``` -$$ -\begin{align*} -D & → P \\ -P & → ( P ) P \\ -P & → ε \\ -\end{align*} -$$ +これによってペアは`:`(`COLON`)の前に文字列リテラル(`STRING`)が来て、その後にJSONの値(`value`)が来ることを表しています。`pair`にマッチするテキストとしては、 + +``` +"x":1 +"y":true +``` + +などがあります。一方で、以下のテキストは`pair`にマッチしません。JavaScriptのオブジェクトとJSONが違う点です。 + +``` +x:1 // 文字列リテラルでないといけない +``` + +### 3.2.4 COMMA + +`COMMA`は、カンマを表す規則です。カンマそのものを表すには、単に`","`と書けばいいのですが、任意個の空白文字が続くことを表現したいため、規則`S`(後述)を参照しています。 + +```bnf +COMMA = "," S; +``` + +### 3.2.5 array + +`array`は、JSONの値の配列を表す規則です。 + +```bnf +array = LBRACKET RBRACKET | LBRACKET value {COMMA value} RBRACKET ; +``` + +`LBRACKET`は開き大カッコ(`[`)を、`RBRACKET`は閉じ大カッコ(`]`)を表しています。`value`の定義については後述します。 + +これによって`array`は、 + +- 大カッコで囲まれたもの(`LBRACKET RBRACET`)である +- 開き大カッコ(`LBRACKET`)が来た後に、`value`が1回あらわれて、さらにその後に、`COMMA`を区切り文字として `value` が1回以上繰り返してあらわれた後、閉じ大カッコが来る(`RBRACKET`) + +のどちらかであることを表しています。よく見ると、先程の`object`と同様の構造を持っていることがわかります。 + +`array`についても具体的なJSONを当てはめてみましょう。以下のJSONは`LBRACKET RBRACKET`にマッチします。 + +```js +[] +``` + +また、以下のJSONは`LBRACKET value {COMMA value} RBRACKET`にマッチします。 + +```js +[1] +[1, 2] +[1, 2, 3] +["foo"] +``` + +しかし、以下のテキストは、`array`に当てはまらず、エラーになります。`{COMMA pair}`とあるように、カンマは必ず後ろにペアを必要とするからです。 + +```js +[1,] // ,で終わっている +``` + +### 3.2.6 value + +```bnf +value = STRING | NUMBER | object | array | TRUE | FALSE | NULL ; +``` + +`value`はJSONの値を表現する規則です。これは、JSONの値は、 + +- 文字列(`STRING`) +- 数値(`NUMBER`) +- オブジェクト(`object`) +- 配列(`array`) +- 真(`TRUE`) +- 偽(`FALSE`) +- ヌル(`NULL`) + + のいずれかでなければいけない事を示しています。JSONを普段使っている皆さんにはお馴染みでしょう。 + +### 3.2.7 STRING + +`STRING`は文字列リテラルを表す規則です。 + +```bnf +STRING = ("\"\"" / "\"" CHAR+ "\"") S; +``` + +`"`で始まって、文字が任意個続いて、 `"` で終わります。`COMMA`と同じように、空白読み飛ばしのために`S`を付けています。`CHAR`の定義は難しくないのですが、煩雑になるため省略します。 + +### 3.2.8 NUMBER + +`NUMBER`は、数値リテラルを表す規則です。 + +```bnf +NUMBER = (INT FRAC EXP | INT EXP | INT FRAC | INT) S; +``` -このようにして変換して出来た文脈自由文法ですが、この中で、 +- 整数(`INT`)に続いて、小数部(`FRAC`)と指数部(`EXP`)が来る +- 整数(`INT`)に続いて、指数部(`EXP`)が来る +- 整数(`INT`) -$$ -D \rightarrow P -$$ +のいずれかが`NUMBER`であるという事を表現しています。同様に、空白読み飛ばしのために、`S`を付けています。 -のような、$\rightarrow$で区切られた右と左をあわせたものを**生成規則**と呼びます。$D$や$P$を**非終端記号**と呼び、$($は**終端記号**と呼びます。このようにして見ていくと、文脈自由文法とは、 +### 3.2.9 TRUE -- 生成規則の1個以上の並び +`TRUE`は、真を表すリテラルを表す規則です。 -からなっており、生成規則は、 +```bnf +TRUE = "true" S ; +``` -- 左辺:1個の非終端記号 -- 右辺:1個以上の終端記号または非終端記号の並び +文字列 `true` が真を表すということでそのままですね。 -からなっていることがわかります。改めて、Dyck言語のBNFによる表現と文脈自由文法による表現を見てみることにします。 +### 3.2.10 FALSE -まず、BNFによる表現です。 +`FALSE`は、偽を表すリテラルを表す規則です。構造的には、`TRUE`と同じです。 -```text -D = P -P = "(" P ")" S | "" +```bnf +FALSE = "true" S ; ``` -次に、文脈自由文法で表現したものです。 +文字列 `false` が偽を表すということでそのままです。 + +### 3.2.11 NULL -$$ -\begin{align*} -D & \rightarrow P \\ -P & \rightarrow ( P ) P \\ -P & \rightarrow \epsilon -\end{align*} -$$ +`NULL`は、ヌルリテラルを表す規則です。構造的には、`TRUE`や`FALSE`と同じです。 -多少冗長になりましたが、大きくは変わらないことがわかると思います。実質的に、BNFは文脈自由文法の表記法の1つとも言えますから、本質的に両者の能力に差はありません。 +```bnf +NULL = "null" S; +``` -では何故、BNFという表記法でなく文脈自由文法の標準的な表記法に変換するかといえば、4章以降で示す種々の構文解析アルゴリズムの多くが、文脈自由文法をベースに構築されているからです。 +`NULL`は、ヌル値があるプログラミング言語だと、その値にマッピングされますが、ここではあくまでヌル値は`null`で表されることしか言っておらず、**意味は特に規定していない**ことに注意してください。 -構文解析を語る上で書かせない、構文木という概念を説明するためにも、文脈自由文法という概念は重要になります。以降の節では、Dyck言語を表現した文脈自由文法を元に、構文解析の基礎をなす様々な概念について説明していきます。 +### 3.2.12 JSONのBNFまとめ -## 3.2 文脈自由文法と言語 +JSONのBNFは、非常に少数の規則だけで表現することができます。読者の中には、あまりにも簡潔過ぎて驚かれた方もいるのではないでしょうか。しかし、これだけ単純であるにも関わらず、JSONのBNFは**再帰的に定義されている**ため、非常に複雑な構造も表現することができます。たとえば、 -前の節で定義したDyckの定義は以下のようなものでした。 +- 配列の要素がオブジェクトであり、その中のキー`"a"`に対応する要素の中にさらに配列があって、その配列は空配列である -$$ -\begin{align*} -D & \rightarrow P \\ -P & \rightarrow ( P ) P \\ -P & \rightarrow \epsilon -\end{align*} -$$ +といったことも、JSONのBNFでは表現することができます。この、再帰的な規則というのは、構文解析において非常に重要な要素なので、これから本書を読み進める上でも念頭に置いてください。 -これまでは「言語」という用語を明確な定義なしに使っていました。「言語」という言葉を一般的な文脈で使ったときに多くの人が思い浮かべるのは、日本語や英語、フランス語、などの**自然言語**でしょう。 +## 3.3 JSONの構文解析器 -しかし、この書籍で扱う**言語**は、プログラミング言語のような**曖昧さ**を持たないものです。たとえば、JavaやRuby、Pythonといったプログラミング言語の文法には曖昧さがなく、同じテキストは常に同じプログラムを意味します。 +JSONの定義と、文法について見てきました。この節では、BNFを元に、JSONを**構文解析**するプログラムを考えてみます。とりあえずは、以下のようなインタフェース`JsonParser`インタフェースを実装したクラスを「JSONの構文解析器」と考えることにします。 -では、たとえば、Java言語といったときに、**言語**が指すものは何なのでしょうか?文脈自由文法のような形式文法の世界では、**言語**を**文字列の集合**として取り扱います。 +```java +package parser; +interface JsonParser { + public ParseResult parse(String input); +} +``` -これでは抽象的ですね。たとえば、以下のHello, World!プログラムは、Java言語のプログラムですが、文字列として見ることもできます。 +クラス`ParseResult`は以下のようなジェネリックなクラスになっています。`value`は解析結果の値です。これは任意の型をとり得るので、`T`としています。また、`input`は「構文解析の対象となる文字列」を表します。 ```java -public class HW { - public static void main(String[] args) { - System.out.println("Hello, World!"); +public record ParseResult(T value, String input) {} +``` + +インタフェース`JsonParser`は`parse()`メソッドだけを持ちます。`parse()`メソッドは、文字列`input`を受け取り、`ParseResult`型を返します。デザインパターンの中でも`Composite`パターンを使ったものですが、オブジェクト指向言語で、再帰的な木構造を表す時には`Composite`は定番のパターンです。 + +```java +public interface JsonAst { + // value + sealed interface JsonValue permits + JsonNull, JsonTrue, JsonFalse, + JsonNumber, JsonString, + JsonObject, JsonArray {} + + // NULL + record JsonNull() implements JsonValue { + @Override + public String toString() { + return "null"; + } + } + + // TRUE + record JsonTrue() implements JsonValue { + @Override + public String toString() { + return "true"; + } + } + + // FALSE + record JsonFalse() implements JsonValue { + @Override + public String toString() { + return "false"; + } + } + + // NUMBER + record JsonNumber(double value) implements JsonValue { + @Override + public String toString() { + return "JsonNumber(" + value + '}'; + } + } + + // STRING + record JsonString(String value) implements JsonValue { + @Override + public String toString() { + return "JsonString(\"" + value + "\")"; + } + } + + // object + record JsonObject(List> properties) + implements JsonValue { + @Override + public String toString() { + return "JsonObject{" + properties + '}'; + } + } + + // array + record JsonArray(List elements) + implements JsonValue { + @Override + public String toString() { + return "JsonArray[" + elements + ']'; + } } } ``` -3を表示するだけのプログラムも考えてみます。一番単純な形は以下のようになるでしょう。 +各クラスがBNFの規則名に対応しているのがわかるでしょうか。次の節では、各規則に対応するメソッドを実装することを通して、実際にJSONの構文解析器を組み上げていきます。 + +### 3.3.1 構文解析器の全体像 + +これから、JSONの構文解析器、つまり、JSONを表す文字列を受け取って、それに対応する上記の`JsonAst.JsonValue`型の値を返すメソッドを実装していくわけですが、先に構文解析器を表現するクラスの全体像を示しておきます。 ```java -public class P3 { - public static void main(String[] args) { - System.out.println(3); +package parser; + +import java.util.ArrayList; +import java.util.List; + +public class PegJsonParser implements JsonParser { + private int cursor; + private String input; + + private int progressiveCursor; + private ParseException progressiveException; + + private static class ParseException extends RuntimeException { + public ParseException(String message) { + super(message); + } + } + + public ParseResult parse(String input) { + this.input = input; + this.cursor = 0; + try { + var value = parseValue(); + return new ParseResult<>(value, input.substring(this.cursor)); + } catch (ParseException e) { + throw progressiveException; + } + } + + private void recognize(String literal) { + if(input.substring(cursor).startsWith(literal)) { + cursor += literal.length(); + } else { + String substring = input.substring(cursor); + int endIndex = cursor + (literal.length() > substring.length() ? substring.length() : literal.length()); + throwParseException("expected: " + literal + ", actual: " + input.substring(cursor, endIndex)); + } + } + + private boolean isHexChar(char ch) { + return ('0' <= ch && ch <= '9') || ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z'); + } + + private void skipWhitespace() { + OUTER: + while(cursor < input.length()) { + char currentCharacter = input.charAt(cursor); + switch (currentCharacter) { + case '\f': + case '\t': + case '\r': + case '\n': + case '\b': + case ' ': + cursor++; + continue OUTER; + default: + break OUTER; + } + } + } + + private JsonAst.JsonValue parseValue() { + int backup = cursor; + try { + return parseString(); + } catch (ParseException e) { + cursor = backup; + } + + try { + return parseNumber(); + } catch (ParseException e) { + cursor = backup; + } + + try { + return parseObject(); + } catch (ParseException e) { + cursor = backup; + } + + try { + return parseArray(); + } catch (ParseException e) { + cursor = backup; + } + + try { + return parseTrue(); + } catch (ParseException e) { + cursor = backup; + } + + try { + return parseFalse(); + } catch (ParseException e) { + cursor = backup; + } + + return parseNull(); + } + + private JsonAst.JsonTrue parseTrue() { + recognize("true"); + skipWhitespace(); + return new JsonAst.JsonTrue(); + } + + private JsonAst.JsonFalse parseFalse() { + recognize("false"); + skipWhitespace(); + return new JsonAst.JsonFalse(); + } + + private JsonAst.JsonNull parseNull() { + recognize("null"); + skipWhitespace(); + return new JsonAst.JsonNull(); + } + + private void parseLBrace() { + recognize("{"); + skipWhitespace(); + } + + private void parseRBrace() { + recognize("}"); + skipWhitespace(); + } + + private void parseLBracket() { + recognize("["); + skipWhitespace(); + } + + private void parseRBracket() { + recognize("]"); + skipWhitespace(); + } + + private void parseComma() { + recognize(","); + skipWhitespace(); + } + + private void parseColon() { + recognize(":"); + skipWhitespace(); + } + + private JsonAst.JsonString parseString() { + if(cursor >= input.length()) { + throwParseException("expected: \"" + " actual: EOF"); + } + char ch = input.charAt(cursor); + if(ch != '"') { + throwParseException("expected: \"" + "actual: " + ch); + } + cursor++; + var builder = new StringBuilder(); + OUTER: + while(cursor < input.length()) { + ch = input.charAt(cursor); + switch(ch) { + case '\\': + cursor++; + if(cursor >= input.length()) break OUTER; + char nextCh = input.charAt(cursor); + cursor++; + switch (nextCh) { + case 'b': + builder.append('\b'); + break; + case 'f': + builder.append('\f'); + break; + case 'n': + builder.append('\n'); + break; + case 'r': + builder.append('\r'); + break; + case 't': + builder.append('\t'); + break; + case '\\': + builder.append('\\'); + break; + case '"': + builder.append('"'); + break; + case '/': + builder.append('/'); + break; + case 'u': + if(cursor + 4 <= input.length()) { + char[] characters = input.substring(cursor, cursor + 4).toCharArray(); + for(char character:characters) { + if(!isHexChar(character)) { + throwParseException("invalid unicode escape: " + character); + } + } + char result = (char)Integer.parseInt(new String(characters), 16); + builder.append(result); + cursor += 4; + } else { + throwParseException("invalid unicode escape: " + input.substring(cursor)); + } + break; + default: + throwParseException("expected: b|f|n|r|t|\"|\\|/ actual: " + nextCh); + } + break; + case '"': + cursor++; + break OUTER; + default: + builder.append(ch); + cursor++; + break; + } + } + + if(ch != '"') { + throwParseException("expected: " + "\"" + " actual: " + ch); + } else { + skipWhitespace(); + return new JsonAst.JsonString(builder.toString()); + } + throw new RuntimeException("never reach here"); + } + + private void throwParseException(String message) throws ParseException { + var exception = new ParseException(message); + if(progressiveCursor < cursor) { + progressiveCursor = cursor; + progressiveException = exception; + } + throw exception; + } + + private JsonAst.JsonNumber parseNumber() { + int start = cursor; + char ch = 0; + while(cursor < input.length()) { + ch = input.charAt(cursor); + if(!('0' <= ch && ch <= '9')) break; + cursor++; + } + if(start == cursor) { + throwParseException("expected: [0-9] actual: " + (ch != 0 ? ch : "EOF")); + } + return new JsonAst.JsonNumber(Integer.parseInt(input.substring(start, cursor))); + } + + private Pair parsePair() { + var key = parseString(); + parseColon(); + var value = parseValue(); + return new Pair<>(key, value); + } + + private JsonAst.JsonObject parseObject() { + int backup = cursor; + try { + parseLBrace(); + parseRBrace(); + return new JsonAst.JsonObject(new ArrayList<>()); + } catch (ParseException e) { + cursor = backup; + } + + parseLBrace(); + List> members = new ArrayList<>(); + var member = parsePair(); + members.add(member); + try { + while (true) { + parseComma(); + member = parsePair(); + members.add(member); + } + } catch (ParseException e) { + parseRBrace(); + return new JsonAst.JsonObject(members); + } + } + + public JsonAst.JsonArray parseArray() { + int backup = cursor; + try { + parseLBracket(); + parseRBracket(); + return new JsonAst.JsonArray(new ArrayList<>()); + } catch (ParseException e) { + cursor = backup; + } + + parseLBracket(); + List values = new ArrayList<>(); + var value = parseValue(); + values.add(value); + try { + while (true) { + parseComma(); + value = parseValue(); + values.add(value); + } + } catch (ParseException e) { + parseRBracket(); + return new JsonAst.JsonArray(values); + } } } ``` -`3+5`を表示するだけのプログラムは以下のようになるでしょう。 +このクラス`PegJsonParser`で重要なことは、クラスがフィールドとして以下を保持していることです。 ```java -public class P8 { - public static void main(String[] args) { - System.out.println(3 + 5); - } +public class PegJsonParser implements JsonParser { + private int cursor; + private String input; + private int progressiveCursor; + private ParseException progressiveException; + // ... } ``` -このようにJava言語のプログラムとして認められる文字列を列挙していくと、次のような**文字列の集合**`JP`になります。 +構文解析器を実装する方法としては、同じ入力文字列を与えれば同じ解析結果が返ってくるような関数型の実装方法と、今回のように、現在どこまで読み進めたかによって解析結果が変わる手続き型の方法があるのですが、手続き型の方が説明しやすいので、本書では手続き型の実装方法を採用しています。 + +`progressive`で始まるフィールドは主にエラーメッセージをわかりやすくするためのものなので、現時点では気にする必要はありません。 + +### 3.3.2 nullの構文解析メソッド -```text -JP = { - public class HW { public static void main(String[] args) { System.out.println("Hello, World!"); }}, - public class P3 { public static void main(String[] args) { System.out.println(3); }}, - public class p8 { public static void main(String[] args) { System.out.println(3 + 5); }}, - ... +`null`の構文解析は、次のような `parseNull()` メソッドとして定義します。 + +```java +private JsonAst.JsonNull parseNull() { + recognize("null"); + skipWhitespace(); + return JsonAst.JsonNull.getInstance(); } + ``` -Java言語のプログラムとして認められる文字列は無数にありますから、集合`JP`の要素は無限個あります。つまり、`JP`は**無限集合**になります。 + このメソッドで行っていることを見ていきましょう。このメソッドでは、入力である`input`の現在位置が`"null"`という文字列で始まっているかをチェックします。もしそうなら、**JSONのnull**をあらわす`JsonAst.JsonNull`のインスタンスを返します。もし、先頭が`"null"`でなければ、構文解析は失敗なので例外を発生させますが、これは`recognize()`メソッドの中で行われています。`recognize()`の内部では、入力の現在位置と与えられた文字列を照合して、マッチしない場合例外を投げます。 + + 次に、`skipWhitespace()`メソッドを呼び出して、「空白の読み飛ばし」を行っています。 + + `recognize()`も`skipWhitespace()`も構文解析中に頻出する処理であるため、今回はそれぞれをメソッドにくくりだして、各構文解析メソッドの中で呼び出せるようにしました。 + +### 3.3.3 trueの構文解析メソッド -同様にRubyを「言語」として見ると次のようになります。 +`true`の構文解析は、次のような `parseTrue()` メソッドとして定義します。 -```text -RB = { - puts 'Hello, World!', - puts 1, - puts 2, - ... +```java +private JsonAst.JsonTrue parseTrue() { + recognize("true"); + skipWhitespace(); + return JsonAst.JsonTrue.getInstance(); } ``` -Rubyプログラムとして認められる文字列は無数にあるので、`RB`もやはり無限集合となります。 + 見ればわかりますが、`parseNull()`とほぼ同じです。固定の文字列を解析するという点で両者はほぼ同じ処理であり、引数を除けば同じ処理になるのです。 + +### 3.3.4 falseの構文解析メソッド + +`false`の構文解析は、次のシグニチャを持つ `parseFalse()` メソッドとして定義します。 + +```java +private JsonAst.JsonFalse parseFalse() { + recognize("false"); + skipWhitespace(); + return JsonAst.JsonFalse.getInstance(); +} +``` + + これも、`parseNull()`とほぼ同じですので、特に説明の必要はないでしょう。 + +### 3.3.5 数値の構文解析メソッド + +数値の構文解析は、次のシグニチャを持つ `parseNumber()` メソッドとして定義します。 + +```java + private JsonAst.JsonNumber parseNumber() { + int start = cursor; + char ch = 0; + while(cursor < input.length()) { + ch = input.charAt(cursor); + if(!('0' <= ch && ch <= '9')) break; + cursor++; + } + if(start == cursor) { + throwParseException("expected: [0-9] actual: " + (ch != 0 ? ch : "EOF")); + } + return new JsonAst.JsonNumber(Integer.parseInt(input.substring(start, cursor))); } +``` + +`parseNumber()` では、`while`文において、 + +- 0から9までの文字が出る間、入力を読む +- 1桁ずつ、数値に変換する + +という処理を行っています。本来なら、JSONの仕様では、小数も扱えるのですが、構文解析にとっては本質的ではないので本書では省略します。 + +### 3.3.6 文字列の構文解析メソッド + +文字列の構文解析は、次のシグニチャを持つ `parseString()` メソッドとして定義します。 + +```java + private JsonAst.JsonString parseString() { + if(cursor >= input.length()) { + throwParseException("expected: \"" + " actual: EOF"); + } + char ch = input.charAt(cursor); + if(ch != '"') { + throwParseException("expected: \"" + "actual: " + ch); + } + cursor++; + var builder = new StringBuilder(); + OUTER: + while(cursor < input.length()) { + ch = input.charAt(cursor); + switch(ch) { + case '\\': + cursor++; + if(cursor >= input.length()) break OUTER; + char nextCh = input.charAt(cursor); + cursor++; + switch (nextCh) { + case 'b': + builder.append('\b'); + break; + case 'f': + builder.append('\f'); + break; + case 'n': + builder.append('\n'); + break; + case 'r': + builder.append('\r'); + break; + case 't': + builder.append('\t'); + break; + case '\\': + builder.append('\\'); + break; + case '"': + builder.append('"'); + break; + case '/': + builder.append('/'); + break; + case 'u': + if(cursor + 4 <= input.length()) { + char[] characters = input.substring(cursor, cursor + 4).toCharArray(); + for(char character:characters) { + if(!isHexChar(character)) { + throwParseException("invalid unicode escape: " + character); + } + } + char result = (char)Integer.parseInt(new String(characters), 16); + builder.append(result); + cursor += 4; + } else { + throwParseException("invalid unicode escape: " + input.substring(cursor)); + } + break; + default: + throwParseException("expected: b|f|n|r|t|\"|\\|/ actual: " + nextCh); + } + break; + case '"': + cursor++; + break OUTER; + default: + builder.append(ch); + cursor++; + break; + } + } + + if(ch != '"') { + throwParseException("expected: " + "\"" + " actual: " + ch); + } else { + skipWhitespace(); + return new JsonAst.JsonString(builder.toString()); + } + throw new RuntimeException("never reach here"); + } +``` + +`while`文の中が若干複雑になっていますが、一つ一つ見ていきます。 + +まず、最初の部分では、 + +```java + if(cursor >= input.length()) { + throwParseException("expected: \"" + " actual: EOF"); + } + char ch = input.charAt(cursor); + if(ch != '"') { + throwParseException("expected: \"" + "actual: " + ch); + } + cursor++; +``` + +- 入力が終端に達していないこと +- 入力の最初が`"`であること + +をチェックしています。文字列は当然ながら、ダブルクォートで始まりますし、文字列リテラルは、最低長さが2あるので、それらの条件が満たされなければ例外が投げられるわけです。 + +`while`文の中では、最初が + +- `\` であるか(エスケープシーケンスか) +- それ以外か + +を`switch`文で判定して分岐しています。JSONで使えるエスケープシーケンスは、 + +- `b`, `f`, `n`, `t`, `\`, `"`, `/` +- `uxxxx` (ユニコードエスケープ) + +のいずれかに分かれているので、意味を読み取るのは簡単でしょう。 + +そして、`while`文が終わったあとで、 + + +```java + if(ch != '"') { + throwParseException("expected: " + "\"" + " actual: " + ch); + } else { + skipWhitespace(); + return new JsonAst.JsonString(builder.toString()); + } + throw new RuntimeException("never reach here"); +``` + +というチェックを入れることによって、ダブルクォートで文字列が終端している事を確認した後、空白を読み飛ばしています。 + +メソッドの末尾で`RuntimeException`を`throw`しているのは、ここに到達することは、構文解析器にバグが無い限りはありえないことを示しています。 + +### 3.3.7 配列の構文解析メソッド + +配列の構文解析は、次のシグニチャを持つ `parseArray()` メソッドとして定義します。 + +```java + public JsonAst.JsonArray parseArray() { + int backup = cursor; + try { + parseLBracket(); + parseRBracket(); + return new JsonAst.JsonArray(new ArrayList<>()); + } catch (ParseException e) { + cursor = backup; + } + + parseLBracket(); + List values = new ArrayList<>(); + var value = parseValue(); + values.add(value); + try { + while (true) { + parseComma(); + value = parseValue(); + values.add(value); + } + } catch (ParseException e) { + parseRBracket(); + return new JsonAst.JsonArray(values); + } + } +``` + +この`parseArray()`は多少複雑になります。まず、先頭に`"["`が来るかチェックする必要があります。これをコードにすると、以下のようになります。 -このように、形式言語の世界では文字列の集合を言語としてとらえるわけです。 +```java +parseLBracket(); +``` -例をDyck言語に戻します。Dyck言語が表す文字列の集合が一体何なのかを考えてみます。Dyck言語とは「括弧の釣り合いが取れた文字列」を表すものでした。ということは、括弧の釣り合いが取れた文字列を要素に持つ集合を考えればいいことになります。 +`parseLBracket()`は以下のように定義されています。 -```text -DK = { - (), - (()), - ((())), - (()()), - ()(), - ()()(), - ... +```java + private void parseLBracket() { + recognize("["); + skipWhitespace(); } ``` -言語を文字列の集合として見ることについて、掴めて来たのではないかと思います。 +`recognize()`で、現在の入力位置が`[`と一致しているかチェックをした後、空白を読み飛ばしています。 +  +この`recognize()`は、与えられた文字列リテラルが入力先頭とマッチするかをチェックし、マッチするなら入力を前に進めて、マッチしないなら例外を投げます。内部の実装は以下のようになります。 -言語を文字列の集合として表現すると、**集合論**の立場で言語について論じられることが大きなメリットです。 +```java + private void recognize(String literal) { + if(input.substring(cursor).startsWith(literal)) { + cursor += literal.length(); + } else { + String substring = input.substring(cursor); + int endIndex = cursor + (literal.length() > substring.length() ? substring.length() : literal.length()); + throwParseException("expected: " + literal + ", actual: " + input.substring(cursor, endIndex)); + } + } +``` -たとえば、Dyck言語の条件を満たす文字列$()$について以下のように表記することが可能です。集合$DK$と$()$の関係について、 + このようにすることで、マッチしない場合に例外を投げ、そうでなければ入力進めるという挙動を実装できます。`[`の次には任意の`JsonValue`または`"]"`が来る可能性があります。この時、まず最初に、`]`が来ると**仮定**するのがポイントです。 -$$ -\verb|()| \in DK -$$ +```java + int backup = cursor; + try { + parseLBracket(); + parseRBracket(); + return new JsonAst.JsonArray(new ArrayList<>()); + } catch (ParseException e) { + cursor = backup; + } +``` -と表記する事が可能になります。 +もし、仮定が成り立たなかった場合、`ParseException`がthrowされるはずですから、それをcatchして、バックアップした位置に巻き戻します。 -一方で、Dyck言語でない文字列")("は以下のように表記することが可能になります。 +`]`が来るという仮定が成り立たなかった場合、再び最初に`[`が出現して、その次に来るのは任意の`JsonValue`ですから、以下のようなコードになります。 -$$ -\verb|)(| \notin DK -$$ +```java + parseLBracket(); + List values = new ArrayList<>(); + var value = parseValue(); + values.add(value); +``` -$DK$は単なる集合なので、皆さんが中学や高校で習ったように、和や積を考えることができます。 +ローカル変数`values`は、配列の要素を格納するためのものです。 -たとえば、Ruby言語を表す無限集合を$RB$と考えたとき、集合の和$RB \cup DK$を考えることが出きます。$RB \cup DK$は以下のようになります。 +配列の中で、最初の要素が読み込まれた後、次に来るのは、`,`か`]`のどちらかですが、ひとまず、`,`が来ると仮定して`while`ループで -$$ -\begin{array}{l} -RB \cup DK = \lbrace \\ - \ \ \verb|()|, \\ - \ \ \verb|puts 1|,\\ - \ \ \verb|(())|,\\ - \ \ \verb|puts 2|,\\ - \ \ ... \\ -\rbrace -\end{array} -$$ +```java +parseComma(); +value = parseValue(); +values.add(value); +``` + +を繰り返します。この繰り返しは、1回ごとに必ず入力を1以上進めるため、必ず失敗します。失敗した時は、テキストが正しいJSONなら、`]`が来るはずなので、 + +```java +parseRBracket(); +return new JsonAst.JsonArray(values); +``` + +とします。もし、テキストが正しいJSONでない場合、`parseRBracket()`から例外が投げられるはずですが、その例外は**より上位の層が適切にリカバーしてくれると期待して**放置します。JSONのような再帰的な構造を解析する時、このような、「自分の呼び出し元が適切にやってくれるはず」(何故なら、自分はその呼び出し元で適切にcatchしているのだから)という考え方が重要になります。 + +このように、多少複雑になりましたが、`parseArray()`の定義が、EBNFにおける表記 + +```bnf +array = LBRACKET RBRACKET | LBRACKET {value {COMMA value}} RBRACKET ; +``` + +に対応していることがわかるでしょうか。読み方のポイントは、`|`の後を、例外をキャッチした後の処理ととらえることです。 + +### 3.3.8 オブジェクトの構文解析メソッド -集合の積$RB \cap DK$を考えることもできます。Ruby言語のプログラムでかつDyck言語であるような文字列は存在しませんから$$RB \cap DK$$は**空集合**になります。つまり、$$RB \cap DK = \emptyset$$です。 +オブジェクトの構文解析は、次のシグニチャを持つ `parseObject()` メソッドとして定義します。 -集合論の道具を自由に使えるのが言語を文字列の集合としてとらえることのメリットです。 +```java + private JsonAst.JsonObject parseObject() { + int backup = cursor; + try { + parseLBrace(); + parseRBrace(); + return new JsonAst.JsonObject(new ArrayList<>()); + } catch (ParseException e) { + cursor = backup; + } + + parseLBrace(); + List> members = new ArrayList<>(); + var member = parsePair(); + members.add(member); + try { + while (true) { + parseComma(); + member = parsePair(); + members.add(member); + } + } catch (ParseException e) { + parseRBrace(); + return new JsonAst.JsonObject(members); + } + } +``` -別の例として、Java言語のプログラムの後方互換性を考えてみましょう。Java 5で書かれたプログラムはJava 8でも(基本的に)OKです。ここで、Java 5のプログラムを表す集合を`J5`、Java 8のプログラムを表す集合を`J8`とすると、以下のように表記できます。 +この定義を見て、ひょっとしたら、 -$$ -J5 \subset J8 -$$ +「あれ?これ、`parseArray()`とほとんど同じでは」 -Java 8はJava 5の後方互換であるという事実を、このように集合論の立場で言うことができるわけです。 +と気づかれた読者の方が居るかも知れません。実際、`parseObject()`がやっていることは非常に`parseArray()`と非常に類似しています。 -## 3.3 文脈自由言語と言語の階層 +まず、最初に、 -ここまで見てきたように**ある**文脈自由文法は、言語、つまり、文字列の集合を定義するのでした。ところで、**すべての**文脈自由文法、言い換えれば文脈自由文法自体の集合はどのようなものになるのでしょうか? +```java +parseLBrace(); +parseRBrace(); +return new JsonAst.JsonObject(new ArrayList<>()); +``` -**ある**文脈自由文法は文字列の集合を定義するわけですから、ここで考えているのは**文字列の集合の集合**がどのような構造を持つかということになります。このような言語の集合のことを言語クラスと呼びます。 +としている箇所は、`{}`という形の空オブジェクトを読み取ろうとしていますが、これは、空配列`[]`を読み取るコードとほぼ同じです。 -この問題を考えるためには、皆さんが普段駆使しておられる正規表現を思い浮かべてもらうのがわかりやすいと思います。正規表現に馴染のない方もいると思うので念のため解説します。正規表現は**文字列のパターン**を定義するための言語です。現代のプログラミング言語で使用されている正規表現はさまざまな拡張が入っているため複雑になっていますが、本来の正規表現にとって重要なパーツのみを扱います。 +続くコードも、対応する記号が`{}`か`[]`の違いこそあるものの、基本的に同じです。唯一の違いは、オブジェクトの各要素は、`name:value`というペアなため、`parseValue()`の代わりに`parsePair()`を呼び出しているところくらいです。 -以下が正規表現を構成する要素です。$e_1$や$e_2$、$e$はそれ自体正規表現を表していることに注意が必要です。$a$は任意の文字1文字を表します。 +そして、`parsePair()`は以下のように定義されています。 -$$ -\begin{array}{ll} -a &\ 文字 \\ -\epsilon &\ 空文字列 \\ -e_1 e_2 &\ 連接 \\ -e_2 | e_2 &\ 選択 \\ -e* &\ 繰り返し \\ -\end{array} -$$ +```java + private Pair parsePair() { + var key = parseString(); + parseColon(); + var value = parseValue(); + return new Pair<>(key, value); + } +``` -正規表現はシンプルな規則によって構成されますが、多様なパターンを表現できます。以下は自然数を表現する正規表現です。 + これは、EBNFにおける以下の定義にそのまま対応しているのがわかるでしょう。 ``` -0|(1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)* +pair = STRING COLON value; +``` + +### 3.3.9 構文解析における再帰 + +配列やオブジェクトの構文解析メソッドを見るとわかりますが、 + +- `parseArray() -> parseValue() -> parseArray()` +- `parseArray() -> parseValue() -> parseObject()` +- `parseObject() -> parseValue() -> parseObject()` +- `parseObject() -> parseValue() -> parseArray()` + +のような再帰呼び出しが起こり得ることがわかります。このような再帰呼び出しでは、各ステップで必ず構文解析が1文字以上進むため、JSONがどれだけ深くなっても(スタックが溢れない限り)うまく構文解析ができるのです。 + +### 3.3.10 構文解析とPEG + +ここまででJSONの構文解析器を実装することが出来ましたが、実は、この節で紹介した技法は古典的な構文解析の技法では**ありません**。 + +この節で解説した技法は、Parsing Expression Grammar(PEG)と呼ばれる手法に基づいています。 + +PEGは2004にBryan Ford(ブライアン・フォード)によって提案された形式文法であり、従来主流であったCFG(Context-Free Grammar)と違う特徴を持ちますが、プログラミング言語など曖昧性の無い言語の解析に使うのには便利であり、最近では色々な言語でPEGをベースにした構文解析器が実装されています。メジャーな言語だとPythonは3.9からPEGベースの構文解析器に内部実装を切り替えました。 + +PEGはとてもシンプルなので、先にPEGを使った技法を学ぶことで、構文解析についてスムーズに理解してもらえたのではないかと思います。ただし、従来の構文解析手法(という言い方は不適切で、依然として従来の手法の方がよく使われています)を学ぶのも重要な事ですので、次の節では、従来型の構文解析手法について解説します。 + +## 3.4 字句解析器を使った構文解析器 + +前節では、構文解析法の一種であるPEGを取り扱いましたが、通常の構文解析法では、字句解析という前処理を行ってから構文解析を行います。字句解析の字句は英語ではトークン(`token`)と言われるものです。たとえば、以下の英文があったとします。 + +``` +We are parsers. +``` + +我々は構文解析器であるというジョーク的な文ですが、それはさておき、この文は + +``` +[We, are, parsers] +``` + +という三つのトークン(単語)に分解すると考えるのが字句解析の発想法です。 + +古典的な構文解析の世界では、字句解析が必須とされていましたが、それは後の章で説明される構文解析アルゴリズムの都合に加えて、空白のスキップという処理を字句解析で行えるからでもあります。 + +前節で出てきたJSONの構文解析器では`skipWhitespace()`の呼び出しが頻出していましたが、字句解析器を使う場合、空白を読み飛ばす処理を先に行うことで、構文解析器では空白の読み飛ばしという作業をしなくてよくなります。 + +この点はトレードオフがあって、たとえば、空白に関する規則がある言語の中でブレがある場合には、字句解析という前処理はかえってしない方が良いということすらあります。ともあれ、字句解析という前処理を通すことには一定のメリットがあるのは確かです。 + +### 3.4.1 字句解析器を使った構文解析器の全体像 + +この項では、字句解析器を使った構文解析器の全体像を示します。まず最初に、JSONの字句解析器は次のようになります。 + +```java +package parser; + +public class SimpleJsonTokenizer implements JsonTokenizer { + private final String input; + private int index; + private Token fetched; + + public SimpleJsonTokenizer(String input) { + this.input = input; + this.index = 0; + } + + public String rest() { + return input.substring(index); + } + + private static boolean isDigit(char ch) { + return '0' <= ch && ch <= '9'; + } + + private boolean tokenizeNumber(boolean positive) { + char firstChar = input.charAt(index); + if(!isDigit(firstChar)) return false; + int result = 0; + while(index < input.length()) { + char ch = input.charAt(index); + if(!isDigit(ch)) { + fetched = new Token(Token.Type.INTEGER, positive ? result : -result); + return true; + } + result = result * 10 + (ch - '0'); + index++; + } + fetched = new Token(Token.Type.INTEGER, positive ? result : -result); + return true; + } + + private boolean tokenizeStringLiteral() { + char firstChar = input.charAt(index); + int beginIndex = index; + if(firstChar != '"') return false; + index++; + var builder = new StringBuffer(); + while(index < input.length()) { + char ch = input.charAt(index); + if(ch == '"') { + fetched = new Token(Token.Type.STRING, builder.toString()); + index++; + return true; + } + if(ch == '\\') { + index++; + if(index >= input.length()) return false; + char nextCh = input.charAt(index); + switch(nextCh) { + case '\\': + builder.append('\\'); + break; + case '"': + builder.append('"'); + break; + case '/': + builder.append('/'); + break; + case 't': + builder.append('\t'); + break; + case 'f': + builder.append('\f'); + break; + case 'b': + builder.append('\b'); + break; + case 'r': + builder.append('\r'); + break; + case 'n': + builder.append('\n'); + break; + case 'u': + if((index + 1) + 4 >= input.length()) { + throw new TokenizerException("unicode escape ends with EOF: " + input.substring(index)); + } + var unicodeEscape= input.substring(index + 1, index + 1 + 4); + if(!unicodeEscape.matches("[0-9a-fA-F]{4}")) { + throw new TokenizerException("illegal unicode escape: \\u" + unicodeEscape); + } + builder.append((char)Integer.parseInt(unicodeEscape, 16)); + index += 4; + break; + } + } else { + builder.append(ch); + } + index++; + } + return false; + } + + private void accept(String literal, Token.Type type, Object value) { + String head = input.substring(index); + if(head.indexOf(literal) == 0) { + fetched = new Token(type, value); + index += literal.length(); + } else { + throw new TokenizerException("expected: " + literal + ", actual: " + head); + } + } + + @Override + public Token current() { + return fetched; + } + + @Override + public boolean moveNext() { + LOOP: + while(index < input.length()) { + char ch = input.charAt(index); + switch (ch) { + case '[': + accept("[", Token.Type.LBRACKET, "["); + return true; + case ']': + accept("]", Token.Type.RBRACKET, "]"); + return true; + case '{': + accept("{", Token.Type.LBRACE, "{"); + return true; + case '}': + accept("}", Token.Type.RBRACE, "}"); + return true; + case '(': + accept("(", Token.Type.LPAREN, "("); + return true; + case ')': + accept(")", Token.Type.RPAREN, ")"); + return true; + case ',': + accept(",", Token.Type.COMMA, ","); + return true; + case ':': + accept(":", Token.Type.COLON, ":"); + return true; + // true + case 't': + accept("true", Token.Type.TRUE, true); + return true; + // false + case 'f': + accept("false", Token.Type.FALSE, false); + return true; + case 'n': { + String actual; + if (index + 4 <= input.length()) { + actual = input.substring(index, index + 4); + if (actual.equals("null")) { + fetched = new Token(Token.Type.NULL, null); + index += 4; + return true; + } else { + throw new TokenizerException("expected: null, actual: " + actual); + } + } else { + actual = input.substring(index); + throw new TokenizerException("expected: null, actual: " + actual); + } + } + case '"': + return tokenizeStringLiteral(); + // whitespace + case ' ': + case '\t': + case '\n': + case '\r': + case '\b': + case '\f': + char next = 0; + do { + index++; + next = input.charAt(index); + } while (index < input.length() && Character.isWhitespace(next)); + continue LOOP; + default: + if('0' <= ch && ch <= '9') { + return tokenizeNumber(true); + } else if (ch == '+') { + index++; + return tokenizeNumber(true); + } else if (ch == '-') { + index++; + return tokenizeNumber(false); + } else { + throw new TokenizerException("unexpected character: " + ch); + } + } + } + return false; + } +} ``` -通常の正規表現エンジンでは文字クラスと呼ばれる機能を使って`0|[1-9][0-9]*`のように書くことができますが意味は同じです。 +これを利用した構文解析器のコードを示します。 + +```java +package parser; + +import java.util.ArrayList; +import java.util.List; + +public class SimpleJsonParser implements JsonParser { + private SimpleJsonTokenizer tokenizer; + + public ParseResult parse(String input) { + tokenizer = new SimpleJsonTokenizer(input); + tokenizer.moveNext(); + var value = parseValue(); + return new ParseResult<>(value, tokenizer.rest()); + } + + private JsonAst.JsonValue parseValue() { + var token = tokenizer.current(); + switch(token.type) { + case INTEGER: + return parseNumber(); + case STRING: + return parseString(); + case TRUE: + return parseTrue(); + case FALSE: + return parseFalse(); + case NULL: + return parseNull(); + case LBRACKET: + return parseArray(); + case LBRACE: + return parseObject(); + } + throw new RuntimeException("cannot reach here"); + } + + private JsonAst.JsonTrue parseTrue() { + if(!tokenizer.current().equals(true)) { + return JsonAst.JsonTrue.getInstance(); + } + throw new parser.ParseException("expected: true, actual: " + tokenizer.current().value); + } + + private JsonAst.JsonFalse parseFalse() { + if(!tokenizer.current().equals(false)) { + return JsonAst.JsonFalse.getInstance(); + } + throw new parser.ParseException("expected: false, actual: " + tokenizer.current().value); + } -あるいは、7桁の郵便番号は次のような文字クラスを使って次のように表すことができます。文字クラスはシンタックスシュガーなので使わなくても同等の記述は可能ですが、説明を簡潔するために以降では文字クラスを使って表現します: + private JsonAst.JsonNull parseNull() { + if(tokenizer.current().value == null) { + return JsonAst.JsonNull.getInstance(); + } + throw new parser.ParseException("expected: null, actual: " + tokenizer.current().value); + } + + private JsonAst.JsonString parseString() { + return new JsonAst.JsonString((String)tokenizer.current().value); + } + private JsonAst.JsonNumber parseNumber() { + var value = (Integer)tokenizer.current().value; + return new JsonAst.JsonNumber(value); + } + + private Pair parsePair() { + var key = parseString(); + tokenizer.moveNext(); + if(tokenizer.current().type != Token.Type.COLON) { + throw new parser.ParseException("expected: `:`, actual: " + tokenizer.current().value); + } + tokenizer.moveNext(); + var value = parseValue(); + return new Pair<>(key, value); + } + + private JsonAst.JsonObject parseObject() { + if(tokenizer.current().type != Token.Type.LBRACE) { + throw new parser.ParseException("expected `{`, actual: " + tokenizer.current().value); + } + + tokenizer.moveNext(); + if(tokenizer.current().type == Token.Type.RBRACE) { + return new JsonAst.JsonObject(new ArrayList<>()); + } + + List> members = new ArrayList<>(); + var pair= parsePair(); + members.add(pair); + + while(tokenizer.moveNext()) { + if(tokenizer.current().type == Token.Type.RBRACE) { + return new JsonAst.JsonObject(members); + } + if(tokenizer.current().type != Token.Type.COMMA) { + throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); + } + tokenizer.moveNext(); + pair = parsePair(); + members.add(pair); + } + + throw new parser.ParseException("unexpected EOF"); + } + + private JsonAst.JsonArray parseArray() { + if(tokenizer.current().type != Token.Type.LBRACKET) { + throw new parser.ParseException("expected: `[`, actual: " + tokenizer.current().value); + } + + tokenizer.moveNext(); + if(tokenizer.current().type == Token.Type.RBRACKET) { + return new JsonAst.JsonArray(new ArrayList<>()); + } + + List values = new ArrayList<>(); + var value = parseValue(); + values.add(value); + + while(tokenizer.moveNext()) { + if(tokenizer.current().type == Token.Type.RBRACKET) { + return new JsonAst.JsonArray(values); + } + if(tokenizer.current().type != Token.Type.COMMA) { + throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); + } + tokenizer.moveNext(); + value = parseValue(); + values.add(value); + } + + throw new ParseException("unexpected EOF"); + } +} ``` -[0-9][0-9][0-9]-[0-9][0-9][0-9][0-9] + +構文解析器から呼び出されている`parserXXX()`メソッドを見るとわかりますが、字句解析器を導入したことによって、文字列の代わりにトークンの列を順によみ込んで、期待通りのトークンが現れたかを事前にチェックしています。また、この構文解析器には空白の読み飛ばしに関する処理が入っていないことに着目してください。 + +、PEG版と異なり、途中で失敗したら後戻り(バックトラック)するという処理も存在しません。後戻りによって、文法の柔軟性を増すというメリットがある一方、構文解析器の速度が落ちるというデメリットもあるため、字句解析器を用いた構文解析器は一般により高速に動作します(ただし、実装者の力量の影響も大きいです)。 + +### 3.4.2 JSONの字句解析器 + +3.4.1で触れたJSONの字句解析器について、この項では、主要な部分に着目して説明します。 + +#### 3.4.2.1 ヘッダ部 + +まず、JSONの字句解析器を実装したクラスである`SimpleJsonTokenizer`の先頭(ヘッダ)部分を読んでみます。 + +```java +public class SimpleJsonTokenizer implements JsonTokenizer { + private final String input; + private int index; + private Token fetched; + + public SimpleJsonTokenizer(String input) { + this.input = input; + this.index = 0; + } ``` -これも`[0-9]{3}-[0-9]{4}`のように書くことができますが、`e{n}`で`e`の`n`回の繰り返しを表現するのは、文字クラスと同様に単なるシンタックスシュガーです。 +`String`型のフィールド`input`は、トークンに切り出す元となる文字列を表します。`int`型のフィールド`index`は、今、字句解析器が文字列の何番目を読んでいるかを表すフィールドで0オリジンです。`Token`型のフィールド`fetched`には、字句解析器が切り出したトークンが保存されます。 + +コンストラクタ中では、入力文字列`input`を受け取り、フィールドに格納および、`index`を0に初期化しています。 + +#### 3.4.2.2 本体部 -正規表現は色々な分野で使われており、正規表現によって非常に幅広い範囲の文字列集合、つまり言語を表現できます。しかし、正規表現にも限界があります。 +`SimpleTokenizer`の主要なメソッドは、 -正規表現の集合で表現される言語クラスを本書では$RL$と表記します。$RL$で表せないことが証明されている典型的な言語の1つがDyck言語です。つまり、 +- `tokenizeNumber()` +- `tokenizeStringLiteral()` +- `accept()` +- `moveNext()` +- `current()` -$$ -DK \notin RL -$$ +#### 3.4.2.2.1 tokenizeNumber -ここでDKは言語であり、RLは言語クラス(言語の集合)であることに注意してください。 +`tokenizeNumber()`メソッドは、文字列の現在位置から開始して、数値トークンを切り出すためのメソッドです。引数`positive`の値が`true`なら、正の整数を、`false`なら、負の整数をトークンとして切り出しています。返り値はトークンの切り出しに成功したか、失敗したかを表します。 -さらに話を進めると、文脈自由文法が表す言語クラス(文脈自由言語と呼び、本書では$CFL$と表記します)と$RL$について次のような関係がなりたちます。 +#### 3.4.2.2.2 tokenizeStringLiteral -$$ -RL \subset CFL -$$ +`tokenizeStringLiteral()` メソッドは、文字列の現在位置から開始して、文字列リテラルトークンを切り出すためのメソッドです。返り値はトークンの切り出しに成功したか、失敗したかを表します。 -これは、文脈自由文法では正規表現で表現可能なあらゆる文字列を表現可能だが、逆は成り立たないということです。 +#### 3.4.2.2.3 accept -これは単に理論上の話ではなく実用上大きな問題として立ちはだかります。たとえば、プログラミング言語の構文解析ではDyck言語のような**括弧の対応がとれていなければエラー**という文法が頻繁に登場しますが、正規表現では書けないのです。 +`accept()` メソッドは、文字列の現在位置から開始して、文字列`literal`にマッチしたら、種類`type`で値が`value`なトークンを生成するメソッドです。これは、`toknizeStringLiteral()`など他のメソッドから呼び出されます。 -さて、Dyck言語に特徴づけられる**括弧の対応を計算できる**ことに文脈自由文法の利点があるわけですが、文脈自由文法だけであらゆる種類の文字列の集合を定義可能なのでしょうか? +#### 3.4.2.2.4 moveNext -これは自明ではありませんが、不可能であることが証明されています。たとえば、$a$を$n$回、$b$を$n$回、$c$を$n$回だけ($n \ge 0$)並べた文字列を表す言語$a^nb^nc^n$は、文脈自由文法で定義不可能です。 +`moveNext()` メソッドは、字句解析器の中核となるメソッドです。呼び出されると、次のトークンは発見するまで、文字列の位置を進め、トークンが発見されたら、トークンを`fetched`に格納して、`true`を返します。トークン列の終了位置に来たら`false`を返します。これは、`Iterator`パターンの一種とも言えますが、典型的な`Iterator`と異なり、`moveNext()`が副作用を持つ点がポイントでしょうか。この点は、.NETの`IEnumerator`のアプローチを参考にしました。 -一方でこの言語は文脈依存言語(本書では$CSL$と表記)という言語クラスで定義可能で、$CFL$は$CSL$の真部分集合です。この事実は次のように表すことができます。 +#### 3.4.2.2.5 current -$$ -CFL \subset CSL -$$ +`current()`メソッドは、`moveNext()`メソッドが`true`を返したあとに呼び出すと、切り出せたトークンを取得することができます。`moveNext()`を次に呼び出すと、`current()`の値が変わってくる点に注意が必要です。 -このように、言語クラスには階層があります。これまででてきた言語クラスを含めると、言語クラスの階層は次のようになります。 +### 3.4.3 JSONの構文解析器 -$$ -RL \subset CFL \subset CSL -$$ +JSONの字句解析器である`SimpleTokenizer`はこのようにして実装しましたが、JSONの構文解析器である`SimpleJSONParser`はどのように実装されているのでしょうか。このクラスは、主に -言語クラスとしては$RL$(正規言語)よりも$CFL$(文脈自由言語)の方が強力であり、$CFL$より$CSL$(文脈依存言語)方が強力ということですね。 +- `parseTrue()`メソッド:規則`TRUE`に対応する構文解析メソッド +- `parseFalse()`メソッド:規則`FALSE`に対応する構文解析メソッド +- `parseNull()`メソッド:規則`NULL`に対応する構文解析メソッド +- `parseString()`メソッド:規則`STRING`に対応する構文解析メソッド +- `parseNumber()`メソッド:規則`NUMBER`に対応する構文解析メソッド +- `parseObject()`メソッド:規則`object`に対応する構文解析メソッド +- `parseArray()`メソッド:規則`array`に対応する構文解析メソッド +- `parseValue()`メソッド: 規則`value`に対応する構文解析メソッド -わかりやすく図として表現すると以下のようになります。 +というメソッドからなっており、それぞれが内部で`SimpleTokenizer`クラスのオブジェクトのメソッドを呼び出しています。では、これらのメソッドについて順番に見て行きましょう。 -![言語クラスの階層](./img/chapter3/chomsky1.svg) +#### parseTrue -CSLよりさらに強力な言語クラスも存在しますが、実はもっとも強い言語クラスが存在しています。そのクラスは**帰納的加算言語**と呼ばれていて、現存する(ほぼ)すべてのプログラミング言語の能力と一致します。 +`parseTrue()`メソッドは、規則`TRUE`に対応するメソッドで、JSONの`true`に対応するものを解析するメソッドでもあります。実装は以下のようになります: -(ほぼ)すべてのプログラミング言語はチューリング完全であるという意味で能力的に等しいということを聞いたことがあるプログラマーの方も多いでしょう。 +```java + private JsonAst.JsonTrue parseTrue() { + if(!tokenizer.current().equals(true)) { + return JsonAst.JsonTrue.getInstance(); + } + throw new parser.ParseException("expected: true, actual: " + tokenizer.current().value); + } +``` -形式言語の用語で言い換えれば、任意のプログラミング言語で生成可能な言語(=文字列集合)の全体である言語クラスは帰納的加算言語とちょうど一致するということになります。 +見るとわかりますが、`tokenizer`が保持している次のトークンの値が`true`だったら、`JsonAst.JsonTrue`のインスタンスを返しているだけですね。ほぼ、字句解析器に処理を丸投げしているだけですから、詳しい説明は不要でしょう。 -# 3.4 生成規則と導出 +#### parseFalse -文脈自由文法は生成規則の集まりからできていることを学びました。ところで、生成規則とはどういう意味なのでしょうか。上のDyck言語を表す文脈自由文法をもう一度眺めてみましょう。 +`parseTrue()`メソッドは、規則`FALSE`に対応するメソッドで、JSONの`false`に対応するものを解析するメソッドでもあります。実装は以下のようになります: -$$ -\begin{array}{lll} -D & \rightarrow & P \\ -P & \rightarrow & ( P ) P \\ -P & \rightarrow & \epsilon -\end{array} -$$ +```java + private JsonAst.JsonFalse parseFalse() { + if(!tokenizer.current().equals(false)) { + return JsonAst.JsonFalse.getInstance(); + } + throw new parser.ParseException("expected: false, actual: " + tokenizer.current().value); + } +``` -非終端記号$D$はカッコの対応が取れた文字列の集合を表しています。ここで見方を変えると、$D$から$()$, $(())$, $((()))$, $()()$などの文字列を生成することができるということです。文法から文字列を生成することを導出と呼びます。 +実装については、`parseTrue()`とほぼ同様なので説明は省略します。 -この導出の仕方は大きく分けて +#### parseNull -- 最左導出(Leftmost Derivation) -- 最右導出(Rightmost Derivation) +`parseNull()`メソッドは、規則`NULL`に対応するメソッドで、JSONの`null`に対応するものを解析するメソッドでもあります。実装は以下のようになります: -の2つがあります。以降の節では、この導出について詳しく説明します。 +```java + private JsonAst.JsonNull parseNull() { + if(tokenizer.current().value == null) { + return JsonAst.JsonNull.getInstance(); + } + throw new parser.ParseException("expected: null, actual: " + tokenizer.current().value); + } +``` -## 3.4.1 最左導出 +実装については、`parseTrue()`とほぼ同様なので説明は省略します。 -最左導出は、生成規則を適用する際に常に一番左の非終端記号を展開する方法です。これにより導出過程が一意に決定されます。例として次の文脈自由文法を考えてみましょう。 +#### parseString -$$ -\begin{align*} -S & \rightarrow AB \\ -A & \rightarrow aA \\ -A & \rightarrow a \\ -B & \rightarrow bB \\ -B & \rightarrow b \\ -\end{align*} -$$ +`parseString()`メソッドは、規則`STRING`に対応するメソッドで、JSONの`"..."`に対応するものを解析するメソッドでもあります。実装は以下のようになります: -最左導出によって$S$から$aabb$を導出する過程を示します。 +```java + private JsonAst.JsonString parseString() { + return new JsonAst.JsonString((String)tokenizer.current().value); + } +``` -$$ -\begin{align*} -S & \Rightarrow AB & (S \rightarrow ABを適用) \\ -AB & \Rightarrow aAB & (A \rightarrow aAを適用) \\ -aAB & \Rightarrow aaB & (A \rightarrow a適用) \\ -aaB & \Rightarrow aabB & (B \rightarrow bBを適用) \\ -aabB & \Rightarrow aabb & (B \rightarrow bを適用) -\end{align*} -$$ +実装については、`parseTrue()`とほぼ同様なので説明は省略します。 + +#### parseNumber + +`parseString()`メソッドは、規則`NUMBER`に対応するメソッドで、JSONの`1, 2, 3, 4, ...`に対応するものを解析するメソッドでもあります。実装は以下のようになります: + +```java + private JsonAst.JsonNumber parseNumber() { + var value = (Integer)tokenizer.current().value; + return new JsonAst.JsonNumber(value); + } +``` + +実装については、`parseTrue()`とほぼ同様なので説明は省略します。 + +#### parseObject + +`parseObject()`メソッドは、規則`object`に対応するメソッドで、JSONのオブジェクトリテラルに対応するものを解析するメソッドでもあります。実装は以下のようになります: + +```java + private JsonAst.JsonObject parseObject() { + if(tokenizer.current().type != Token.Type.LBRACE) { + throw new parser.ParseException("expected `{`, actual: " + tokenizer.current().value); + } + + tokenizer.moveNext(); + if(tokenizer.current().type == Token.Type.RBRACE) { + return new JsonAst.JsonObject(new ArrayList<>()); + } + + List> members = new ArrayList<>(); + var pair= parsePair(); + members.add(pair); + + while(tokenizer.moveNext()) { + if(tokenizer.current().type == Token.Type.RBRACE) { + return new JsonAst.JsonObject(members); + } + if(tokenizer.current().type != Token.Type.COMMA) { + throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); + } + tokenizer.moveNext(); + pair = parsePair(); + members.add(pair); + } + + throw new parser.ParseException("unexpected EOF"); + } +``` + +まず、最初のif文で、次のトークンが`{`であることを確認した後に、 + +- その次のトークンが`}`であった場合:空オブジェクトを返す +- それ以外の場合: `parsePair()` を呼び出し、 `string:pair` のようなペアを解析した後、以下のループに突入: + - 次のトークンが`}`の場合、集めたペアのリストを引数として、`JsonAst.JsonObject()`オブジェクトを作って返す + - それ以外で、次のトークンが`,`でない場合、構文エラーを投げて終了 + - それ以外の場合:次のトークンをフェッチして来て、`parsePair()`を呼び出して、ペアを解析した後、リストにペアを追加 + +のような動作を行います。実際のコードと対応付けてみると、より理解が進むでしょう。 + +#### parseArray + +`parseArray()`メソッドは、規則`array`に対応するメソッドで、JSONの配列リテラルに対応するものを解析するメソッドでもあります。実装は以下のようになります: + +```java + private JsonAst.JsonArray parseArray() { + if(tokenizer.current().type != Token.Type.LBRACKET) { + throw new parser.ParseException("expected: `[`, actual: " + tokenizer.current().value); + } + + tokenizer.moveNext(); + if(tokenizer.current().type == Token.Type.RBRACKET) { + return new JsonAst.JsonArray(new ArrayList<>()); + } + + List values = new ArrayList<>(); + var value = parseValue(); + values.add(value); + + while(tokenizer.moveNext()) { + if(tokenizer.current().type == Token.Type.RBRACKET) { + return new JsonAst.JsonArray(values); + } + if(tokenizer.current().type != Token.Type.COMMA) { + throw new parser.ParseException("expected: `,`, actual: " + tokenizer.current().value); + } + tokenizer.moveNext(); + value = parseValue(); + values.add(value); + } + + throw new ParseException("unexpected EOF"); + } + ``` -最左導出は、これ以上適用できる規則がなくなるまで、常に一番左の非終端記号を展開していきます。 +まず、最初のif文で、次のトークンが`[`であることを確認した後に、 -## 3.4.2 最右導出 +- その次のトークンが`]`であった場合:空オブジェクトを返す +- それ以外の場合: `parseValue()` を呼び出し、 `value`を解析した後、以下のループに突入: + - 次のトークンが`}`の場合、集めた`values`のリストを引数として、`JsonAst.JsonObject()`オブジェクトを作って返す + - それ以外で、次のトークンが`,`でない場合、構文エラーを投げて終了 + - それ以外の場合:次のトークンをフェッチして来て、`parsePair()`を呼び出して、`value`を解析した後、リストに`value`を追加 -最右導出は、生成規則を適用する際に常に一番右の非終端記号を展開する方法です。最左導出と同様に導出過程が一意に決定されます。さきほどと同じ文脈自由文法を考えてみましょう。 +のような動作を行います。実際のコードと対応付けてみると、より理解が進むでしょう。 -$$ -\begin{align*} -S & \rightarrow AB \\ -A & \rightarrow aA \\ -A & \rightarrow a \\ -B & \rightarrow bB \\ -B & \rightarrow b \\ -\end{align*} -$$ +なお、`parseArray()`のコードを読めばわかるように、ほとんどのコードは、`parseObject()`と共通のものになっています。もしこれが気になるようであれば、共通部分をくくりだすことも出来ます。 -最右導出によって$S$から$aabb$を導出する過程を示します。 +### 3.5 まとめ -$$ -\begin{align*} -S & \Rightarrow AB & (S \rightarrow ABを適用) \\ -AB & \Rightarrow AbB & (B \rightarrow bBを適用) \\ -AbB & \Rightarrow Abb & (B \rightarrow bを適用) \\ -Abb & \Rightarrow aAbb & (A \rightarrow aAを適用) \\ -aAbb & \Rightarrow aabb & (A \rightarrow aを適用) -\end{align*} -$$ +この章では、JSONの構文解析や字句解析を実際に作ってみることを通して、構文解析の基礎について学んでもらいました。特に、 -最右導出は、これ以上適用できる規則がなくなるまで、常に一番右の非終端記号を展開します。 +- 3.1 JSONの定義 +- 3.2 JSONのBNF +- 3.3 JSONの構文解析機(PEG版) +- 3.4 字句解析器を使った構文解析器 -最左導出と最右導出では最終的に同じ文字列を導出することが可能です。しかし、導出過程が異なります。 +といった順番で、JSONの定義から入って、PEGによるJSONパーザ、字句解析器を使った構文解析器の作り方について学んでもらいました。この書籍中で使ったJSONはECMAScriptなどで定義されている正式なJSONに比べてサブセットになっており、たとえば、浮動小数点数が完全に扱えないという制限がありますが、構文解析器全体から見ればささいなことなので、この章を理解出来れば、JSONの構文解析についてはある程度理解出来たと思って構いません。 -先取りしておくと、形式言語における最左導出がいわゆる下向き構文解析に対応し、最右導出の逆操作が上向き構文解析に対応します。これらについては次の章で説明します。 +次の章では、文脈自由文法(Context-Free Grammar, CFG)の考え方について学んでもらいます。というのは、文脈自由文法は、現在使われているほとんどの構文解析アルゴリズム(もちろん、PEG等を除く)の基盤となっている概念であって、CFGの理解なくしては、その後の構文解析の理解もおぼつかないからです。 -生成規則から文字列を生成するために2つの導出方法を使い分けることができることを覚えておいてください。 +逆に、CFGの考え方さえわかってしまえば、個別の構文解析アルゴリズム自体は、それほど難しいとは感じられなくなって来るかもしれません。 \ No newline at end of file diff --git a/honkit/chapter4.md b/honkit/chapter4.md index 96aaec0..f384be8 100755 --- a/honkit/chapter4.md +++ b/honkit/chapter4.md @@ -1,1956 +1,418 @@ -# 4. 構文解析アルゴリズム古今東西 +# 第4章 文脈自由文法の世界 -第2章でJSONを例にしてPEGによる構文解析器と、単純な字句解析器を用いた構文解析器を実装しました。第3章では文脈自由文法の概念を紹介しました。これでようやく準備が整ったので、本書の本丸である構文解析アルゴリズムの話ができます。 +第3章では、JSONの構文解析器を記述することを通して、構文解析のやり方を学びました。構文解析器についても、PEG型の構文解析器および字句解析器を使った2通りを作ってみることで、構文解析器といっても色々な書き方があるのがわかってもらえたのではないかと思います。 -といっても、戸惑う読者の方が多いかもしれません。これまで「構文解析アルゴリズム」について具体的な話はまったくなかったのですから。しかし、皆さんは、第2章で二つの構文解析アルゴリズムを使ってJSONの構文解析器を**すでに**書いているのです。 +この第4章では、現代の構文解析を語る上で必須である、文脈自由文法という概念について学ぶことにします。「文脈自由文法」というと、一見、堅くて難しそうな印象を持つ方も多いかもしれません。しかし、一言で言ってしまえば、BNFをよりシンプルに、数学的に厳密にしただけのものであって、厳しい言葉から漂う程難解な概念ではありません。一方で、「文脈自由文法」という概念を習得することによるメリットは計り知れないものがあります。たとえば、それによって、正規表現で記述出来ないが文脈自由言語(BNFと表現力では等価)で記述出来る「言語」を知ることが出来ますし、文脈自由言語では記述不可能な「言語」について知ることも出来ます。 -用語として不正確ですが、第2章で最初に実装したのはPEGと呼ばれる手法の素朴な実装です。次に実装したのは*LL(1)*っぽい再帰下降構文解析器です。**再帰下降**という言葉は見慣れないものなので、疑問に思われる読者の方も多いと思います。その疑問は脇において、第2章での実装が理解できたのなら、皆さんはすでに直感的に構文解析アルゴリズムを理解していることになります。 +さて、文脈自由文法の世界に飛び込んで見ましょう。 -この章では、2024年までに発表された主要な構文解析アルゴリズムについて、筆者の独断と偏見を交えて解説します。この章で紹介する構文解析アルゴリズムのほとんどについて、その構文解析アルゴリズムを使った構文解析器を生成してくれる構文解析器生成系が存在します。構文解析機生成系については第5章で説明しますが、ここではさわりだけを紹介しておきます。 +## 4.1 BNFと文脈自由文法 -たとえば、構文解析アルゴリズムとして有名な*LALR(1)*は、もっともメジャーな構文解析器生成系でCコードを生成するyacc(GNUによる再実装であるbisonが主流)で採用されています。*LL(1)*はJava向けの構文解析器生成系としてメジャーなJavaCCで採用されている方式です。*ALL(*)*は、Javaをはじめとした多言語向け構文解析器生成系として有名なANTLRで採用されています。PEGを採用した構文解析器生成系も多数存在します。 +文脈自由文法の定義を大上段に示しても抽象的過ぎますので、皆様に馴染みがあるBNFを文脈自由文法を用いた記述に変換することで、文脈自由文法についての理解のとっかかりとしたいと思います。お題は、「カッコの釣り合いがとれた文字列が任意個続いたもの」です。 -このように、なにかの構文解析アルゴリズムがあれば、構文解析アルゴリズムに基づいた構文解析機生成系を作ることができます。世dんですが、筆者は大学院生時代にPEGおよびPackrat Parsingの研究をしており、その過程でPEGによる構文解析器生成系を作ったものです。 - -小難しいことばかり言うのは趣味ではないので、さっそく、構文解析アルゴリズムの世界を覗いてみましょう!  - -## 4.1 下向き構文解析と上向き構文解析 - -具体的な構文解析アルゴリズムの説明に入る前に、構文解析アルゴリズムは大別して、 - -- 上から下へ(下向き) -- 下から上へ(上向き) - -の二つのアプローチがあることを理解しておきましょう。下向き構文解析法と上向き構文解析法では真逆の発想で構文解析を行うからです。 - -## 4.2 下向き構文解析の概要 - -まずは下向き構文解析法です。下向き構文解析では予測型とバックトラック型で若干異なる方法で構文解析をしますが、ここでは予測型の下向き構文解析法を説明します。予測型の下向き構文解析法ではCFGの開始記号から構文解析を開始し、規則に従って再帰的に構文解析を行っていきます。 - -第3章で例に出てきたDyck言語を例にして、具体的な方法を説明します。Dyck言語の文法は以下のようなものでした。 - -$$ -\begin{array}{lll} -D & \rightarrow & \$ P \$ \\ -P & \rightarrow & ( P ) P \\ -P & \rightarrow & \epsilon \\ -\end{array} -$$ - -このCFGはカッコがネストした文字列の繰り返し(空文字列を含む)を過不足無く表現しているわけですが、`(())`という文字列がマッチするかを判定する問題を考えてみましょう。 - -まず最初に、開始記号が$`D`$で規則$`D \rightarrow $ P $ `$があるのでスタックに積み込みます。 - -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \uparrow \$ P \$ \rbrack -\end{align*} -$$ - -次に最初の1文字を入力文字列から読み込んで、先読み文字列に追加します。 - -$$ -\begin{align*} -先読み文字列:& \$ \\ -スタック:& \lbrack D \rightarrow \uparrow \$ P \$ \rbrack -\end{align*} -$$ - -入力文字列の先頭と現在の解析位置にある文字が`$`であるため、先読み文字列の先頭とマッチします。そこで、文字列を消費します。 - -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$ \rbrack -\end{align*} -$$ - -その次ですが、候補となる規則には$`P \rightarrow (P)P`$と$`P \rightarrow \epsilon`$があるものの、先読み文字列が空なので、次の文字を読み込みます。 - -$$ -\begin{align*} -先読み文字列:\verb|(| & \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$ \rbrack -\end{align*} -$$ +たとえば、 -規則が$`P \rightarrow (P)P`$に確定したので、スタックに規則を積みます。 +``` +() +(()) +(()()) +()()() +``` -$$ -\begin{align*} -先読み文字列:& \verb|(| \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow \uparrow (P) P\rbrack -\end{align*} -$$ +は釣り合いの取れた文字列の例です。一方で、 -次の文字は`(`であり、先読み文字列の先頭とマッチします。そこで、文字列を消費します。 +``` +)( +(() +()) +``` -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P\rbrack -\end{align*} -$$ +は釣り合いの取れていない文字列の例です。このような言語をDyck(ディック)言語と呼び、文脈自由文法を特徴づける言語とされています。 -先ほどと同様に先読み文字列に1文字追加します。 +Dyck言語の文法を擬似BNFで記述してみると以下のようになります。 -$$ -\begin{align*} -先読み文字列:& \verb|(| \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P\rbrack -\end{align*} -$$ +```text +D = P +P = "(" P ")" P | "" +``` -規則は$`P \rightarrow (P)P`$に確定します。そこで、スタックに確定した規則を積みます。 +`D`が構文解析の際に最初に参照される記号です。このBNFを文脈自由文法による記述になおしていきましょう。 -$$ -\begin{align*} -先読み文字列:& \verb|(| \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P, P \rightarrow \uparrow (P)P\rbrack -\end{align*} -$$ +最初に、外側にある"|"を消去します。結果として以下のようになります。 -先読み文字列の先頭と現在の解析位置にある文字がマッチするので、文字列を消費します。 +```text +D = P +P = "(" P ")" P +P = "" +``` -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P, P \rightarrow ( \uparrow P ) P\rbrack -\end{align*} -$$ +同じ名前`P`を持つ記号が二つ出てきてしまいましたね。文脈自由文法の標準的な表記法では、同じ名前の規則が複数出てきても構いません。その場合の解釈は、BNFで同名の規則を"|"でくくった場合とほぼ同じです。 -次が非終端記号$`P`$ですが、候補は$`P \rightarrow \epsilon`$だけです。本来ならスタックにこの規則を積んで下ろすという作業が必要ですが、省略して$`P`$の分を読み進めてしまいます。 +次に"="を"→"に置き換えます。 -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P \uparrow ) P\rbrack -\end{align*} -$$ +```text +D → P +P → "(" P ")" P +P → "" +``` -先読み文字列に1文字追加します。 +空文字列は`ε`で表現されるのでこれも置き換えます。 -$$ -\begin{align*} -先読み文字列:& \verb|)| \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P \uparrow ) P\rbrack -\end{align*} -$$ +```text +D → P +P → "(" P ")" P +P → ε +``` -先読み文字列の先頭と現在の解析位置にある文字がマッチするので、文字列を消費します。 +文字を表すときの`"`も使わないのでこれも消去します。 $$ \begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P ) \uparrow P\rbrack +D & → P \\ +P & → ( P ) P \\ +P & → ε \\ \end{align*} $$ -先ほどと同様、規則の候補は$`P \rightarrow \epsilon`$だけです。$`P`$の分を読み進めてしまいます。 +このようにして変換して出来た文脈自由文法ですが、この中で、 $$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P ) \uparrow \rbrack -\end{align*} +D \rightarrow P $$ -スタックトップの規則を解析し終えたので、スタックから取り除きます。 - -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P \uparrow ) P -\end{align*} -$$ +のような、$\rightarrow$で区切られた右と左をあわせたものを**生成規則**と呼びます。$D$や$P$を**非終端記号**と呼び、$($は**終端記号**と呼びます。このようにして見ていくと、文脈自由文法とは、 -さらに1文字読み込みます。 +- 生成規則の1個以上の並び -$$ -\begin{align*} -先読み文字列:& \verb|)| \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P \uparrow ) P -\end{align*} -$$ +からなっており、生成規則は、 -先読み文字列の先頭とマッチしますから、文字列を消費します。 +- 左辺:1個の非終端記号 +- 右辺:1個以上の終端記号または非終端記号の並び -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P ) \uparrow P \rbrack -\end{align*} -$$ +からなっていることがわかります。改めて、Dyck言語のBNFによる表現と文脈自由文法による表現を見てみることにします。 -先ほどと同様、規則の候補は$`P \rightarrow \epsilon`$だけです。$`P`$の分を読み進めてしまいます。 +まず、BNFによる表現です。 -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P ) P \uparrow \rbrack -\end{align*} -$$ +```text +D = P +P = "(" P ")" S | "" +``` -スタックトップの規則を解析し終えたので、スタックから取り除きます。 +次に、文脈自由文法で表現したものです。 $$ \begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ P \uparrow \$ \rbrack +D & \rightarrow P \\ +P & \rightarrow ( P ) P \\ +P & \rightarrow \epsilon \end{align*} $$ -文字列の末尾なので`$`を読み込みます。 +多少冗長になりましたが、大きくは変わらないことがわかると思います。実質的に、BNFは文脈自由文法の表記法の1つとも言えますから、本質的に両者の能力に差はありません。 -$$ -\begin{align*} -先読み文字列:\$ & \\ -スタック:& \lbrack D \rightarrow \$ P \uparrow \$ \rbrack -\end{align*} -$$ +何故、BNFという表記法でなく文脈自由文法の標準的な表記法に変換するかといえば、5章以降で示す種々の構文解析アルゴリズムの多くが、文脈自由文法をベースに構築されているからです。 -先読み文字列の先頭とマッチしますから、文字列を消費します。 +構文解析を語る上で書かせない、構文木という概念を説明するためにも、文脈自由文法という概念は重要になります。以降の節では、Dyck言語を表現した文脈自由文法を元に、構文解析の基礎をなす様々な概念について説明していきます。 -$$ -\begin{align*} -先読み文字列:& \\ -スタック:& \lbrack D \rightarrow \$ P \$ \uparrow \rbrack -\end{align*} -$$ +## 4.2 文脈自由文法と言語 -規則の最後に到達したので、スタックから要素を取り除きます。 +前の節で定義したDyckの定義は以下のようなものでした。 $$ \begin{align*} -先読み文字列:& \\ -スタック:& \lbrack \rbrack +D & \rightarrow P \\ +P & \rightarrow ( P ) P \\ +P & \rightarrow \epsilon \end{align*} $$ -入力文字列の終端に到達し、スタックが空になったので、入力文字列`(())`はDyck言語の文法に従っていることがわかりました。 - -予測型の下向き構文解析では以下の動作を繰り返します。 +これまでは「言語」という用語を明確な定義なしに使っていました。「言語」という言葉を一般的な文脈で使ったときに多くの人が思い浮かべるのは、日本語や英語、フランス語、などの**自然言語**でしょう。 -1. 残りの文字列から、1文字とってきて先読み文字列に追加する -2. 次が非終端記号で、先読み文字列から適用すべき規則が決定できる場合スタックにその規則を積む。規則が決定できない場合はエラー。次が終端記号であれば、先読み文字列の先頭とマッチするかを確認し、マッチすれば文字列を消費し、マッチしない場合はエラーを返す -3. 規則の最後に到達した場合は、スタックから要素を取り除く +しかし、この書籍で扱う**言語**は、プログラミング言語のような**曖昧さ**を持たないものです。たとえば、JavaやRuby、Pythonといったプログラミング言語の文法には曖昧さがなく、同じテキストは常に同じプログラムを意味します。 -## 4.3 下向き構文解析法のJavaによる実装 +では、たとえば、Java言語といったときに、**言語**が指すものは何なのでしょうか?文脈自由文法のような形式文法の世界では、**言語**を**文字列の集合**として取り扱います。 -このような動作をJavaコードで表現することを考えてみます。 +これでは抽象的ですね。たとえば、以下のHello, World!プログラムは、Java言語のプログラムですが、文字列として見ることもできます。 ```java -// D → P -// P → ( P ) P -// P → ε -public class Dyck { - private final String input; - private int position; - - public Dyc(String input) { - this.input = input; - this.position = 0; +public class HW { + public static void main(String[] args) { + System.out.println("Hello, World!"); } +} +``` - public boolean parse() { - boolean result = D(); - return result && position == input.length(); - } +3を表示するだけのプログラムも考えてみます。一番単純な形は以下のようになるでしょう。 - private boolean D() { - return P(); +```java +public class P3 { + public static void main(String[] args) { + System.out.println(3); } +} +``` - private boolean P() { - // P → ( P ) P - if (position < input.length() && input.charAt(position) == '(') { - position++; // '(' を読み進める - if (!P()) return false; - if (position < input.length() && input.charAt(position) == ')') { - position++; // ')' を読み進める - return P(); - } else { - return false; - } - // P → ε - } else { - // 空文字列にマッチ - return true; - } +`3+5`を表示するだけのプログラムは以下のようになるでしょう。 + +```java +public class P8 { + public static void main(String[] args) { + System.out.println(3 + 5); } } ``` -クラス`Dyck`は、Dyck言語を構文解析して、成功したら`true`、そうでなければ`false`を返すものです。BNFと比較すると、 +このようにJava言語のプログラムとして認められる文字列を列挙していくと、次のような**文字列の集合**`JP`になります。 -- 規則の名前と一対一になるメソッドが存在する -- 非終端記号の参照は規則の名前に対応するメソッドの再帰呼び出しとして実現されている - -のが特徴です。 +```text +JP = { + public class HW { public static void main(String[] args) { System.out.println("Hello, World!"); }}, + public class P3 { public static void main(String[] args) { System.out.println(3); }}, + public class p8 { public static void main(String[] args) { System.out.println(3 + 5); }}, + ... +} +``` -呼び出す規則を上、呼び出される規則を下とした時、上から下に再帰呼び出しが続いていくため、再帰下降構文解析と呼ばれます。このように「上から下に」構文解析を行っていくのが下向き構文解析法の特徴です。 +Java言語のプログラムとして認められる文字列は無数にありますから、集合`JP`の要素は無限個あります。つまり、`JP`は**無限集合**になります。 -注意しなければいけないのは、下向き構文解析の方法は多数あり、その1つに再帰下降構文解析があるということです。 +同様にRubyを「言語」として見ると次のようになります。 -実際、後述するLL(1)の実装のときは構文解析のための表を作り、関数の再帰呼び出しは行わないこともあります。 +```text +RB = { + puts 'Hello, World!', + puts 1, + puts 2, + ... +} +``` -## 4.4 上向き構文解析の概要 +Rubyプログラムとして認められる文字列は無数にあるので、`RB`もやはり無限集合となります。 -上向き構文解析は下向き構文解析とは真逆の発想で構文解析を行います。こちらの方法は下向き型より直感的に理解しづらいかもしれません。 +このように、形式言語の世界では文字列の集合を言語としてとらえるわけです。 -上向き構文解析ー正確にはシフト還元構文解析として知られているものーでは、文字列を左から右に読み込んでいき、順番にスタックにプッシュしていきます。これをシフト(shift)と呼びます。 +例をDyck言語に戻します。Dyck言語が表す文字列の集合が一体何なのかを考えてみます。Dyck言語とは「括弧の釣り合いが取れた文字列」を表すものでした。ということは、括弧の釣り合いが取れた文字列を要素に持つ集合を考えればいいことになります。 -シフト動作を続けていくうちに、規則の右辺の記号列とスタックトップにある記号列がマッチすれば、規則の左辺にマッチしたとして、スタックトップにある記号列を規則の左辺で置き換えます。これを還元(reduce)と呼びます。 +```text +DK = { + (), + (()), + ((())), + (()()), + ()(), + ()()(), + ... +} +``` -具体例を挙げてみます。以下のCFGがあったとしましょう。ただし、入力の先頭と末尾を表すために`$`を使うものとします。 +言語を文字列の集合として見ることについて、掴めて来たのではないかと思います。 -$$ -\begin{align*} -D & \rightarrow \$ P \$ \\ -P & \rightarrow ( P ) P \\ -P & \rightarrow \epsilon -\end{align*} -$$ +言語を文字列の集合として表現すると、**集合論**の立場で言語について論じられることが大きなメリットです。 -これは下向き構文解析で扱ったDyck言語の文法です。上向き構文解析の説明の都合上、上記と等価な以下の文法を考えます。 +たとえば、Dyck言語の条件を満たす文字列$()$について以下のように表記することが可能です。集合$DK$と$()$の関係について、 $$ -\begin{align*} -D & \rightarrow \$ P \$ \\ -D & \rightarrow \$ \epsilon \$ \\ -P & \rightarrow P\ X \\ -P & \rightarrow X \\ -X & \rightarrow ( X ) \\ -X & \rightarrow () -\end{align*} +\verb|()| \in DK $$ -このCFGに対して`(())`という文字列がマッチするかを判定する問題を考えてみましょう。上向き構文解析では、まず最初の「1文字」を左から右にシフトします。以下のようなイメージです。スタックに要素をプッシュすると右に要素が追加されていくものとします。 - -$$ -\begin{align*} -スタック: & \lbrack \$, (\ \rbrack -\end{align*} -$$ +と表記する事が可能になります。 -このスタックは$`P`$にも$`D`$にもマッチしません。そこで、もう1文字をシフトしてみます。 +一方で、Dyck言語でない文字列")("は以下のように表記することが可能になります。 $$ -\begin{align*} -スタック: & \lbrack \$, (, ( \rbrack -\end{align*} +\verb|)(| \notin DK $$ -まだマッチしませんね。さらにもう1文字シフトしてみます。 - -$$ -\begin{align*} -スタック: & \lbrack \$, (, (, \rbrack -\end{align*} -$$ +$DK$は単なる集合なので、皆さんが中学や高校で習ったように、和や積を考えることができます。 -さらにもう1文字シフトしてみます。 +たとえば、Ruby言語を表す無限集合を$RB$と考えたとき、集合の和$RB \cup DK$を考えることが出きます。$RB \cup DK$は以下のようになります。 $$ -\begin{align*} -スタック: & \lbrack \$, (, (, )\rbrack -\end{align*} +\begin{array}{l} +RB \cup DK = \lbrace \\ + \ \ \verb|()|, \\ + \ \ \verb|puts 1|,\\ + \ \ \verb|(())|,\\ + \ \ \verb|puts 2|,\\ + \ \ ... \\ +\rbrace +\end{array} $$ -規則$`X \rightarrow ()`$を使って還元を行います。 +集合の積$RB \cap DK$を考えることもできます。Ruby言語のプログラムでかつDyck言語であるような文字列は存在しませんから$$RB \cap DK$$は**空集合**になります。つまり、$$RB \cap DK = \emptyset$$です。 -$$ -\begin{align*} -スタック: & \lbrack \$, (, X\rbrack -\end{align*} -$$ +集合論の道具を自由に使えるのが言語を文字列の集合としてとらえることのメリットです。 -この状態でもう1文字シフトしてみます。 +別の例として、Java言語のプログラムの後方互換性を考えてみましょう。Java 5で書かれたプログラムはJava 8でも(基本的に)OKです。ここで、Java 5のプログラムを表す集合を`J5`、Java 8のプログラムを表す集合を`J8`とすると、以下のように表記できます。 $$ -\begin{align*} -スタック: & \lbrack \$, (, X, ) \rbrack -\end{align*} +J5 \subset J8 $$ -規則$`X \rightarrow (X)`$を使って還元を行います。 - -$$ -\begin{align*} -スタック: & \lbrack \$, X \rbrack -\end{align*} -$$ +Java 8はJava 5の後方互換であるという事実を、このように集合論の立場で言うことができるわけです。 -さらに規則$`P \rightarrow X`$を使って還元を行います。 +## 4.3 文脈自由言語と言語の階層 -$$ -\begin{align*} -スタック: & \lbrack \$, P \rbrack -\end{align*} -$$ +ここまで見てきたように**ある**文脈自由文法は、言語、つまり、文字列の集合を定義するのでした。ところで、**すべての**文脈自由文法、言い換えれば文脈自由文法自体の集合はどのようなものになるのでしょうか? -文字列の末尾にきたので、`$`をシフトします。 +**ある**文脈自由文法は文字列の集合を定義するわけですから、ここで考えているのは**文字列の集合の集合**がどのような構造を持つかということになります。このような言語の集合のことを言語クラスと呼びます。 -$$ -\begin{align*} -スタック: & \lbrack \$, P, \$ \rbrack -\end{align*} -$$ +この問題を考えるためには、皆さんが普段駆使しておられる正規表現を思い浮かべてもらうのがわかりやすいと思います。正規表現に馴染のない方もいると思うので念のため解説します。正規表現は**文字列のパターン**を定義するための言語です。現代のプログラミング言語で使用されている正規表現はさまざまな拡張が入っているため複雑になっていますが、本来の正規表現にとって重要なパーツのみを扱います。 -このスタックは規則$`D \rightarrow $ P $`$にマッチします。還元が行われ、最終的にスタックの状態は次のようになります。 +以下が正規表現を構成する要素です。$e_1$や$e_2$、$e$はそれ自体正規表現を表していることに注意が必要です。$a$は任意の文字1文字を表します。 $$ -\begin{align*} -スタック: & \lbrack D \rbrack -\end{align*} +\begin{array}{ll} +a &\ 文字 \\ +\epsilon &\ 空文字列 \\ +e_1 e_2 &\ 連接 \\ +e_2 | e_2 &\ 選択 \\ +e* &\ 繰り返し \\ +\end{array} $$ -めでたく`(())`が`D`とマッチすることがわかりました。上向き構文解析では以下の手順を繰り返していきます。 - -1. 残りの文字があれば、入力文字をシフトしてスタックにプッシュする(シフト) -2. スタックの記号列が規則の右辺にマッチすれば、左辺の非終端記号で置き換える(還元) - -3章の最後で少しだけ述べましたが、還元はちょうど、最右導出における導出の逆向きの操作になります。 - -## 4.5 上向き構文解析のJavaによる実装 - -このような動作をJavaコードで表現することを考えてみます。まず必要なのは、規則を表すクラス`Rule`です。問題を単純化するために、 - -1. 規則の名前(左辺)は1文字 -2. 規則の右辺は終端記号または非終端記号のリストである - -とします。このようなクラス`Rule`は以下のように表現できます。 - -```java -public record Rule(char lhs, List rhs) { - public Rule(char lhs, Element... rhs) { - this(lhs, List.of(rhs)); - } - public boolean matches(List stack) { - if (stack.size() < rhs.size()) return false; - for (int i = 0; i < rhs.size(); i++) { - Element elementInRule = rhs.get(rhs.size() - i - 1); - Element elementInStack = stack.get(stack.size() - i - 1); - if (!elementInRule.equals(elementInStack)) { - return false; - } - } - return true; - } -} -``` - -可変長引数を受け取るコンストラクタは規則を簡単に記述するためのものです。`matches`メソッドはスタックの状態と規則がマッチするかを判定します。 - -`Element`は終端記号や非終端記号を表すクラスで、以下のように定義されます。 +正規表現はシンプルな規則によって構成されますが、多様なパターンを表現できます。以下は自然数を表現する正規表現です。 -```java -public sealed interface Element - permits Element.Terminal, Element.NonTerminal { - public record Terminal(char symbol) implements Element {} - - public record NonTerminal(char name) implements Element {} -} ``` - -これらのクラスを使ってシフトと還元を行うクラス`Dyck`は次のように定義できます。 - -```java -public class Dyck { - private final String input; - private int position; - private final List rules; - - private final List stack = new ArrayList<>(); - - public Dyck(String input) { - this.input = input; - this.position = 0; - this.rules = List.of( - // D → $ P $ - new Rule('D', new Terminal('$'), new NonTerminal('P'), new Terminal('$')), - // D → $ ε $ - new Rule('D', new Terminal('$'), new Terminal('$')), - // P → P X - new Rule('P', new NonTerminal('P'), new NonTerminal('X')), - // P → X - new Rule('P', new NonTerminal('X')), - // X → ( X ) - new Rule('X', new Terminal('('), new NonTerminal('X'), new Terminal(')')), - // X → () - new Rule('X', new Terminal('('), new Terminal(')')) - ); - } - - public boolean parse() { - stack.add(new Terminal('$')); - while (true) { - if (!tryReduce()) { - if (position < input.length()) { - stack.add(new Terminal(input.charAt(position))); - position++; - } else { - break; - } - } - } - stack.add(new Terminal('$')); - while (tryReduce()) { - // 還元を試みる - } - return stack.size() == 1 && stack.get(0).equals(new NonTerminal('D')); - } - - private boolean tryReduce() { - for (Rule rule : rules) { - if (rule.matches(stack)) { - for (int i = 0; i < rule.rhs().size(); i++) { - stack.remove(stack.size() - 1); - } - stack.add(new NonTerminal(rule.lhs())); - return true; - } - } - return false; - } -} +0|(1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)* ``` -このプログラムでは、入力文字列を1文字ずつシフトしながら、還元を行っています。規則にマッチする場合はスタックから右辺の要素を取り除き、左辺の非終端記号をプッシュします。最終的にスタックに`NonTerminal('D')`だけが残れば、入力文字列が文法に従っていることが確認できます。 - -## 4.6 下向き構文解析と上向き構文解析の比較 - -下向き構文解析法と上向き構文解析法は得手不得手があります。 - -下向き型は規則と関数を対応付けるのが容易なので手書きの構文解析器を書くのに向いています。実際、驚くほど多くのプログラミング処理系の構文解析器は手書きの再帰下降構文解析で実装されています。 - -下向き型は、関数の引数として現在の情報を渡して、引数に応じて構文解析の結果を変化させることが比較的容易です。これは文脈に依存した文法を持った言語を解析するときに有利な性質です。しかし、下向き型は左再帰という形の文法をそのまま処理できないという欠点があります。 +通常の正規表現エンジンでは文字クラスと呼ばれる機能を使って`0|[1-9][0-9]*`のように書くことができますが意味は同じです。 -たとえば、以下のBNFは上向き型だと普通に解析できますが、工夫なしに下向き型で実装すると無限再帰に陥ってスタックオーバーフローします。 +あるいは、7桁の郵便番号は次のような文字クラスを使って次のように表すことができます。文字クラスはシンタックスシュガーなので使わなくても同等の記述は可能ですが、説明を簡潔するために以降では文字クラスを使って表現します: ``` -A = A "a" -A = ""; +[0-9][0-9][0-9]-[0-9][0-9][0-9][0-9] ``` -このような問題を下向き型で解決する方法も存在します。 +これも`[0-9]{3}-[0-9]{4}`のように書くことができますが、`e{n}`で`e`の`n`回の繰り返しを表現するのは、文字クラスと同様に単なるシンタックスシュガーです。 -たとえば、上の文法を以下のように書き換えれば下向き型でも問題なく解析できるようになります。このような処理を左再帰の除去と呼びます。 +正規表現は色々な分野で使われており、正規表現によって非常に幅広い範囲の文字列集合、つまり言語を表現できます。しかし、正規表現にも限界があります。 -通常のプログラミング言語でも直線的な再帰は常にループに変換可能ですが、原理的にはそれと似たようなものです。 +正規表現の集合で表現される言語クラスを本書では$RL$と表記します。$RL$で表せないことが証明されている典型的な言語の1つがDyck言語です。つまり、 -``` -A = "a" A - | ""; -``` - -さて、上向き型は左再帰を問題なく処理できるので、このような文法をそのまま解析できるわけです、では上向き型はすべての文法に対して有利なのでしょうか?ことはそう単純ではありません。 +$$ +DK \notin RL +$$ -たとえば、それまでの文脈に応じて構文解析のルールを切り替えたくなることがあります。最近の言語によく搭載されている文字列補間などはその最たる例です。 +ここでDKは言語であり、RLは言語クラス(言語の集合)であることに注意してください。 -`"`の中は文字列リテラルとして特別扱いされますが、その中で`#{`が出てきたら(Rubyの場合)、通常の式を構文解析するときのルールに戻る必要があります。 +さらに話を進めると、文脈自由文法が表す言語クラス(文脈自由言語と呼び、本書では$CFL$と表記します)と$RL$について次のような関係がなりたちます。 -このように、文脈に応じて適用するルールを切り替えるのは下向き型が得意です。もちろん、上向き型でも実現できないわけではありません。実際、Rubyの構文解析機はYaccの定義ファイルから生成されるようになっていますが、Yaccが採用しているのは代表的な上向き構文解析法である`LALR(1)`です。 +$$ +RL \subset CFL +$$ -ともあれ、下向きと上向きには異なる利点と欠点があります。 +これは、文脈自由文法では正規表現で表現可能なあらゆる文字列を表現可能だが、逆は成り立たないということです。 -次からは具体的なアルゴリズムの説明に移ります。 +これは単に理論上の話ではなく実用上大きな問題として立ちはだかります。たとえば、プログラミング言語の構文解析ではDyck言語のような**括弧の対応がとれていなければエラー**という文法が頻繁に登場しますが、正規表現では書けないのです。 -## 4.2 LL(1) - 代表的な下向き構文解析アルゴリズム +さて、Dyck言語に特徴づけられる**括弧の対応を計算できる**ことに文脈自由文法の利点があるわけですが、文脈自由文法だけであらゆる種類の文字列の集合を定義可能なのでしょうか? -下向き型構文解析法の中でおそらくもっとも古典的で、よく知られているのは`LL(1)`法です。`LL`は**Left-to-right, Leftmost derivation**の略で、左から右へ文字列をスキャンしながら**最左導出**を行うことを意味しています。最左導出は第3章で説明しましたね。 +これは自明ではありませんが、不可能であることが証明されています。たとえば、$a$を$n$回、$b$を$n$回、$c$を$n$回だけ($n \ge 0$)並べた文字列を表す言語$a^nb^nc^n$は、文脈自由文法で定義不可能です。 -`LL(1)`の`1`は「1トークン先読み」を意味しています。つまり、`LL(1)`法は、次の1トークンを見て、最左導出を行うような構文解析手法です。この手法は手書きの**再帰下降構文解析**によって簡単に実装できるため、構文解析手法の中でも単純なものと言えるでしょう。字面が一見小難しく見えますが、`LL(1)`のアイデアは意外に簡単なものです。 +一方でこの言語は文脈依存言語(本書では$CSL$と表記)という言語クラスで定義可能で、$CFL$は$CSL$の真部分集合です。この事実は次のように表すことができます。 -たとえば、以下のようなJava言語のif文があったとします。 +$$ +CFL \subset CSL +$$ -```java -if(age < 18) { - System.out.println("18歳未満です"); -} else { - System.out.println("18歳以上です"); -} -``` +このように、言語クラスには階層があります。これまででてきた言語クラスを含めると、言語クラスの階層は次のようになります。 -我々はどのようにしてこれを見て「if文がある」と認識するのでしょうか。もちろん「人それぞれ」なのですが、最初にキーワード`if`が現れたからif文だと考える人も多いのではないかと思います。 +$$ +RL \subset CFL \subset CSL +$$ -`LL(1)`構文解析アルゴリズムはまさにこのイメージを元にした手法です。プログラムをトークン列に区切った後に、「最初の1トークン」を見て、「これはif文だ」とか「これはwhile文だ」とか認識するようなものですね。 +言語クラスとしては$RL$(正規言語)よりも$CFL$(文脈自由言語)の方が強力であり、$CFL$より$CSL$(文脈依存言語)方が強力ということですね。 -イメージとしては簡単なのですが、アルゴリズムとして実行可能なようにするためには考えなければいけない論点がいくつかあります。以下では、`LL(1)`を実装するに当たって考えなければいけない課題について論じます。 +わかりやすく図として表現すると以下のようになります。 -### 課題1 - ある構文の最初のトークンが複数種類ある場合 +![言語クラスの階層](./img/chapter3/chomsky1.svg) -先程の例ではある構文、たとえばif文が始まるには`if`というキーワードが必須で、それ以外の方法でif文が始まることはありえませんでした。 +CSLよりさらに強力な言語クラスも存在しますが、実はもっとも強い言語クラスが存在しています。そのクラスは**帰納的加算言語**と呼ばれていて、現存する(ほぼ)すべてのプログラミング言語の能力と一致します。 -つまり、`if`というトークンが先頭に来たら、それはif文であると確定できるわけです。 +(ほぼ)すべてのプログラミング言語はチューリング完全であるという意味で能力的に等しいということを聞いたことがあるプログラマーの方も多いでしょう。 -しかし、問題はそう単純ではありません。if文の他に符号付き整数および加減乗除のみからなる算術式を構文解析をすることを考えてみましょう。 +形式言語の用語で言い換えれば、任意のプログラミング言語で生成可能な言語(=文字列集合)の全体である言語クラスは帰納的加算言語とちょうど一致するということになります。 -算術式は以下のいずれかで始まります。 +## 4.4 生成規則と導出 -- `(` -- `-` -- `+` -- 整数リテラル(``) +文脈自由文法は生成規則の集まりからできていることを学びました。ところで、生成規則とはどういう意味なのでしょうか。上のDyck言語を表す文脈自由文法をもう一度眺めてみましょう。 -つまり、算術式の始まりは複数のトークンで表されます。このような場合、最初のトークンとの一致比較だけでは「これは算術式だ」と確定できません。 +$$ +\begin{array}{lll} +D & \rightarrow & P \\ +P & \rightarrow & ( P ) P \\ +P & \rightarrow & \epsilon +\end{array} +$$ -あるトークンが算術式の始まりである事を確定するためには、トークンの集合という概念が必要になります。 +非終端記号$D$はカッコの対応が取れた文字列の集合を表しています。ここで見方を変えると、$D$から$()$, $(())$, $((()))$, $()()$などの文字列を生成することができるということです。文法から文字列を生成することを導出と呼びます。 -たとえば、算術式の始まりは以下のようなトークンの集合で表されます。 +この導出の仕方は大きく分けて -```text -{"(", "-", "+", } -``` +- 最左導出(Leftmost Derivation) +- 最右導出(Rightmost Derivation) -このように、ある構文が始まるかを決定するために必要なトークンの集合のことを**FIRST集合**と呼びます。 +の2つがあります。以降の節では、この導出について詳しく説明します。 -**FIRST集合**は非終端記号ごとに定義されます。以降では、非終端記号`N`に対して定義されるFIRST集合を`FIRST(N)`と表します。 +## 4.4.1 最左導出 -たとえば、規則$A$の右辺が複数あって以下のようになっているとします。 +最左導出は、生成規則を適用する際に常に一番左の非終端記号を展開する方法です。これにより導出過程が一意に決定されます。例として次の文脈自由文法を考えてみましょう。 $$ -\begin{array}{l} -A \rightarrow B \\ -A \rightarrow C -\end{array} +\begin{align*} +S & \rightarrow AB \\ +A & \rightarrow aA \\ +A & \rightarrow a \\ +B & \rightarrow bB \\ +B & \rightarrow b \\ +\end{align*} $$ -このとき、 +最左導出によって$S$から$aabb$を導出する過程を示します。 $$ -\begin{array}{l} -FIRST(B) \cap FIRST(C) = \emptyset -\end{array} +\begin{align*} +S & \Rightarrow AB & (S \rightarrow ABを適用) \\ +AB & \Rightarrow aAB & (A \rightarrow aAを適用) \\ +aAB & \Rightarrow aaB & (A \rightarrow a適用) \\ +aaB & \Rightarrow aabB & (B \rightarrow bBを適用) \\ +aabB & \Rightarrow aabb & (B \rightarrow bを適用) +\end{align*} $$ -が成り立てば、先頭1トークンだけを「先に見て」$B$を選ぶか$C$を選ぶかを安全に決定することができます。 - -この「先を見る」(Lookahead)という動作がLL(1)のキモです。 +最左導出は、これ以上適用できる規則がなくなるまで、常に一番左の非終端記号を展開していきます。 -バックトラックしない下向き型の場合「あ。間違ってたので別の選択肢をためそう」ということができませんから、必然的に一つ先を読んで分岐する必要があるのです。 +## 4.4.2 最右導出 -一つ先を読んで安全に分岐を選べるためには、分岐の先頭にあるトークンがお互いに重なっていないことが必要条件になります。この「お互いに重なっていない」というのが、 +最右導出は、生成規則を適用する際に常に一番右の非終端記号を展開する方法です。最左導出と同様に導出過程が一意に決定されます。さきほどと同じ文脈自由文法を考えてみましょう。 $$ -\begin{array}{l} -FIRST(B) \cap FIRST(C) = \emptyset -\end{array} +\begin{align*} +S & \rightarrow AB \\ +A & \rightarrow aA \\ +A & \rightarrow a \\ +B & \rightarrow bB \\ +B & \rightarrow b \\ +\end{align*} $$ -であらわされる条件です。 - -### 課題2 - 省略可能な要素の扱い - -if文であるかどうかは、先頭の1トークンを見ればわかります。しかし、if文であるとして、if-else文なのかelseがない単純なif文であるかどうかを判定するのはどうすればいいでしょうか。たとえば、以下の文は正当です。 - -```java -if (age < 18) { - System.out.println("18歳未満です"); -} -``` - -以下の文も正当です。 - -```java -if (age < 18) { - System.out.println("18歳未満です"); -} else { - System.out.println("18歳以上です"); -} -``` - -最初のif文の後に、 - -- 前者はelseが出現しない -- 後者はelseが出現する - -という違いがあります。 - -つまり、elseが出現するかどうかで、どの規則を適用するかを決定する必要があります。このような場合、次のトークンを見て、elseならif-else文、そうでなければ単純なif文と解釈します。 - -この判断を行うためには**FIRST集合**だけでは不十分です。elseが省略される可能性があるためです。elseが省略可能かどうかを示す情報が必要です。 - -ある要素が省略可能かどうかを示す情報を**nullable**と呼びます。**nullable**は非終端記号が空文字列を生成可能かどうかを示す情報です。たとえば、以下の規則があるとします。 - -$$ -A → a \\ -A → b \\ -A → ε \\ -$$ - -$A$は空文字列を生成可能です。このような場合、$A$はnullableであると言います。 - -さて、この言い方に従うと - -```java -else { - System.out.println("18歳以上です"); -} -``` - -この部分は**nullable**であると言えます。 - -このような**nullable**な要素の次に出現し得るトークンの集合を**FOLLOW集合**と呼びます。**FOLLOW集合**は非終端記号ごとに定義されます。以降では、非終端記号$N$に対して定義される**FOLLOW集合**を$FOLLOW(N)$と表します。 - -次の項では、**FIRST集合**と**FOLLOW集合**の概念についてより厳密に説明します。 - -### FIRST集合とFOLLOW集合の計算 - -LL(1)をアルゴリズムとしてきちんと定義しようとするなら、この二つの概念が必要であることはわかってもらえたのではないかと思います。しかし、この二つですが、プログラム上でどう計算すれば良いのでしょうか?この問いに答えることがLL(1)アルゴリズムをきちんと理解することであり、逆にきちんと理解できれば、自力でLL(1)アルゴリズムによるパーサを記述できるようになるでしょう。 - -まずはFIRST集合について考えてみます。単純化のために以下のような規則を仮定します。 - -$$ -A \rightarrow \alpha_1 \\ -A \rightarrow \alpha_2 \\ -... \\ -A \rightarrow \alpha_n \\ -$$ - -$\alpha_i$は終端記号や非終端記号の並びです。$FIRST(A)$を求めるには以下の手順を用います。 - -1. FIRST集合を空集合に初期化する。 -2. 各生成規則$`A \rightarrow \alpha_i`$について、次を行う: - - $\alpha_i$の最初の記号$X_1$が終端記号ならば、FIRST(A)に$X_1$を追加する。 - - $X_1$が非終端記号の場合、$FIRST(X1)$を計算し、$FIRST(A)$に$FIRST(X1)$を追加する。 - - $X_1$が$nullable$である場合、次の記号$X_2$について同様の処理を行う。 - -$nullable$については先程軽く述べましたが、ある非終端記号が空文字列を生成可能かどうかを示すものです。$nullable$の計算は以下の手順で行います。 - -1. 全ての非終端記号をfalse(nullableでない)に初期化する。 -2. 生成規則$`A \rightarrow \alpha`$について、$`\alpha`$が空(ε)なら$nullable(A)$をtrueに設定する。 -3. 生成規則$`A \rightarrow \alpha`$($\alpha = \beta_1 \beta_2 ... \beta_n$)について、$\beta_1 ... \beta_n`$が全て$nullable$なら$nullable(A)$をtrueに設定する。 -4. 値が変化しなくなるまで2と3を繰り返す。 - -次に**FOLLOW集合**の計算です。$FOLLOW(A)$は、非終端記号$A$の後に現れる可能性のある終端記号の集合です。計算手順は以下の通りです。 - -1. FOLLOW集合を空集合に初期化する。 -2. 開始記号のFOLLOW集合に入力の終端記号$を追加する。 -3. 各生成規則$`B \rightarrow \alpha A \beta`$について、以下を行う: - - $`FIRST(β)`$からεを除いたものを$FOLLOW(A)$に追加する。 - - $nullable(\beta)$ならば、$FOLLOW(B)$を$FOLLOW(A)$に追加する。 -4. 値が変化しなくなるまで3を繰り返す。 - -この$FIRST$と$FOLLOW$を用いて、LL(1)構文解析表を作成します。 - -## LL(1)構文解析表の作成 - -構文解析表は、非終端記号と入力の次のトークン(終端記号)の組み合わせで、次にどの生成規則を適用すべきかを示すものです。構文解析表の作成手順は以下の通りです。 - -1. 各生成規則$`A \rightarrow α`$について、次を行う: - - $`FIRST(α)`$からεを除いた全ての終端記号aに対して、表の項目$`[A, \alpha]`$に規則$`A \rightarrow \alpha`$を入れる。 - - もし$`ε \in FIRST(α)`$なら、$`FOLLOW(A)`$の全ての終端記号$\beta$に対して、表の項目$`[A, \beta]`$に規則$`A \rightarrow α`$を入れる。 -2. 構文解析表において、同じ項目に複数の規則が入る場合、その文法はLL(1)ではない - -つまり、LL(1)構文解析表が作成できるかどうかは、その文法がLL(1)であるかどうかの判定に等しいと言えます。 - -## LL(1)の問題点と限界 - -`LL(1)`は古典的でありかつそれなりに実用的でもありますが、アルゴリズムがシンプルである故の問題点や限界も存在します。この節では`LL(1)`の抱える問題点について述べます。 - -### 問題点1 - 最初の1トークンで構文要素を決められないことがある - -例えば、以下の文法を考えてみましょう。 - -$$ -\begin{array}{lll} -S & \rightarrow & a B \\ -S & \rightarrow & a C \\ -B & \rightarrow & b \\ -C & \rightarrow & c -\end{array} -$$ - -$S$の最初のトークンは常に$a$で、その次のトークンが$b$なら$B$を、$c$なら$C$を選択します。最初の1トークンだけではどちらの規則を適用すべきか決められません。 - -この問題は**左因子化**(left factoring)によって解決できます。左因子化では共通部分をくくり出すことで、LL(1)で解析可能な文法に変換します。 - -$$ -\begin{array}{lll} -S & \rightarrow & a S' \\ -S' & \rightarrow & A \\ -S' & \rightarrow & B \\ -B & \rightarrow & b \\ -C & \rightarrow & c -\end{array} -$$ - -しかし、左因子化は常に適用できるわけではありません。 - -### 問題点2 - 左再帰の問題 - -$LL(1)$では左再帰を含む文法を扱うことができません。例えば、以下の文法は左再帰を含んでいます。 - -$$ -\begin{array}{lll} -E & \rightarrow & E\ +\ T \\ -E & \rightarrow & T \\ -\end{array} -$$ - -この文法はEの定義の最初にE自身が出現しています。左再帰を除去することでLL(1)で扱えるように変換できます。 - -$$ -\begin{array}{lll} -E & \rightarrow T\ E'\\ -E' & \rightarrow +\ T\ E'\\ -E' & \rightarrow \epsilon \\ -\end{array} -$$ - -左再帰の除去は多くの場合に可能ですが、変換作業が煩雑になることも少なくありません。 - -## 4.3 LL(k) - LL(1)の拡張 - -LL(1)の限界を克服するために、LL(k)という概念が導入されました。kは先読みするトークン数を示します。kを増やすことで、より複雑な文法を扱えるようになりますが、解析表のサイズや計算量が増加します。 - -しかし、LL(k)でもすべての文脈自由言語を扱えるわけではありません。たとえば、文脈自由言語の例として以下のようなものがあります。 - -$$ -a^i b^j (i >= j >= 1) -$$ - -これは$a$が$i$回あらわれてその後に$b$が$j$回あらわれるような言語ですが、$LL(k)$言語ではありません。後述しますが、$LR(1)$言語にはなるので、純粋に言語としての表現能力を考えると$LL(k)$は$LR(1)$よりも弱い言語であると言えます。 - -## 4.5 SLR - 単純な上向き構文解析アルゴリズム - -SLR(Simple LR)は、LR法の中でも最も単純な手法です。LRは**L**eft-to-right(左から右への入力)と**R**ightmost derivation(最右導出)の略です。 Left-to-rightは「左から右に文字列を読み込む」を指します。これは直感的にわかりやすいでしょう。Rightmost derivationは「最右導出を行う」という意味です。 - -最右導出については第3章で説明しましたが、簡単に復習しておきましょう。最右導出は文法規則を適用していく際に、常に右側から展開していくことを指します。たとえば、以下の文脈自由文法を考えてみましょう: - -$$ -S -> aSSb | c -$$ -$$ -\begin{array}{lll} -S & \rightarrow aSSb\\ -S & \rightarrow c\\ -\end{array} -$$ - -この文法に対して、最右導出で`accb`という文字列を生成する過程は以下のようになります: +最右導出によって$S$から$aabb$を導出する過程を示します。 $$ \begin{align*} -S & \Rightarrow aSSb & (S \rightarrow aSSbを適用) \\ -aSSB & \Rightarrow aScb & (S \rightarrow cを適用) \\ -aScb & \Rightarrow accb & (S \rightarrow cを適用) +S & \Rightarrow AB & (S \rightarrow ABを適用) \\ +AB & \Rightarrow AbB & (B \rightarrow bBを適用) \\ +AbB & \Rightarrow Abb & (B \rightarrow bを適用) \\ +Abb & \Rightarrow aAbb & (A \rightarrow aAを適用) \\ +aAbb & \Rightarrow aabb & (A \rightarrow aを適用) \end{align*} $$ -第3章で説明したように、$S$を展開するときに、一番右の$S$を展開していますね。これが「最右導出」ということです。最右導出の過程を逆にたどることが `SLR`ひいては`LR`法の基本的な考え方です。 - -もちろん、これだけだとわけがわからないという方が大半ではないかと思います。しかし、読者の皆さんがこの章で既に見たシフト還元構文解析が理解できていれば、その入口に立ったようなものです。 - -なぜなら、シフト還元構文解析の「シフト」が上でいうLeft-to-right、「還元」がRightmost derivationに対応しているからです。シフトは左から右に文字列を読み込むことを意味し、還元は最右導出の逆を行うことを意味します。SLRはシフト還元構文解析の考え方をより厳密に定式化したものと言えるでしょう。 - -しかし、素朴なシフト還元構文解析とSLR法が大きく異なる点があります。 - -それは構文解析表と呼ばれる表を用いてシフトするか還元するかを決定する点です。素朴なシフト還元構文解析ではスタックを毎回スキャンしていましたが、これではスタックのサイズが大きくなってきたときに遅くなるのが想像できますね。 - -一方、SLRでは構文解析表を使ってシフトするか還元するかを決定するため、効率的に構文解析を行うことができます。 - -では、SLRではどのように構文解析表を作り出すのでしょうか?そのための道具立てを次節以降で説明します。 - -### LR(0)項 - -SLRでは、文法規則を`LR(0)項`(以下、単に項と書きます)という形に変換します。項は規則の右辺にドット(・)を挿入したものです。たとえば: - -``` -E -> E + T -``` - -この規則に対して、以下のような項を作ることができます: - -``` -E -> . E + T, -``` - -ドットは現在の解析位置を示します。1つの項は構文解析の途中経過を表すものと見ることができます。この項はEの右辺の最初の記号を読み込もうとしている状態です。 - -さらに、項集合という概念を導入します。規則 - -``` -E -> E + T -``` - -から得られる項の集まりは以下のようになります: - -``` -1. E -> . E + T -2. E -> E . + T -3. E -> E + . T -4. E -> E + T . -``` - -ある規則から得られる項集合は、その規則の左辺に対応する構文解析の途中状態の集合と見ることができます。この規則の右辺は3つの要素からなるため、4つの項が得られます。 - -よりわかりやすく言うなら、4つの項は以下のような状態を表しています: - -1. Eの右辺の最初の記号を読み込もうとしている -2. Eの右辺の最初の記号を読み込んだ後、+を読み込もうとしている -3. Eの右辺の最初の記号と+を読み込んだ後、Tを読み込もうとしている -4. Eの右辺の最初の記号と+とTを読み込んだ後。還元しようとしている - -### 状態の構築 - -SLRでは、項目の集合を「状態」として扱います。初期状態から始めて、遷移を繰り返すことで全ての状態を構築します。 -例として、以下の簡単な文法を考えてみましょう: - -```text -{ - S -> E, - E -> E + T | T, - T -> x -} -``` - -この文法に対して入力を解析するときの初期状態は以下のようになります: - -```text -{ - S -> . E, - E -> . E + T, - E -> . T, - T -> . x -} -``` - -この状態から、各記号(E, T, x)に対する遷移を考えます。例えば、xに対する遷移は以下のようになります: - -``` -{ - S -> . E, - E -> . E + T, - E -> . T, - T -> x . -} -``` - -遷移元の状態、つまり項集合と比較して、最後の項がxを読み込んでいる状態になっていることがわかります。このようにして、各記号に対する遷移を考えていくことで、全ての状態を構築していきます。 - -### 閉包(Closure) - -状態を構築する際、ある非終端記号に対する項目がある場合、その非終端記号から始まる全ての規則も状態に含める必要があります。これを閉包操作と呼びます。 - -閉包操作は以下の手順で行います: - -1. 現在の状態に含まれる全ての項について、ドットの直後にある非終端記号を取得 -2. その非終端記号に対応する全ての規則を取得 -3. それらの規則を新しい項として状態に追加 -4. 項集合が変化しなくなるまで、1から3を繰り返す - -### GOTO関数 - -SLR(Simple LR)法において、GOTO関数は構文解析器の中心的な役割を果たす重要な概念です。GOTO関数は、現在の状態と入力記号に基づいて次の状態を決定する関数です。具体的には、ある状態から特定の記号を「読む」(読むとはその記号に関連するシフトまたは非終端記号の展開を意味します)ことで遷移する先の状態を指し示します。 - -GOTO関数は次のように定義されます: - -```text -GOTO(I, X) = J -``` - -ここで、 - -- I は現在の状態(項目集合) -- X は遷移の基となる記号(終端記号または非終端記号)、 -- J は遷移後の状態(新しい項目集合) - -です。 - -#### GOTO関数の計算方法 - -GOTO関数を計算するためには、現在の状態Iに含まれる項目に対して、特定の記号Xがドット(・)の直後に現れるものを探し、そのドットを1つ右に移動させた新しい項目を生成します。これらの新しい項目の閉包を取り、その結果が遷移後の状態Jとなります。 - -具体的な手順は以下の通りです: - -1. 項目の選択: 現在の状態Iに含まれるすべての項目`A → α・Xβ`を選びます。Xは終端記号または非終端記号です。 -2. ドットの移動: 選択した各項目について、ドットを1つ右に移動させた項目`A → αX・β`を生成します。 -3. 閉包の計算: 生成した新しい項目集合に対して閉包操作を行います。これは、非終端記号 β が現れた場合、その非終端記号に関連するすべての規則を追加する操作です。 -4. 新しい状態の確定: 閉包操作後の項目集合が新しい状態Jとなります。 - -#### 例でみるGOTO関数の適用 - -具体的な文法と項目集合を用いてGOTO関数の動作を確認してみましょう。 - -##### 文法: - -```text -S -> E -E -> E + T -E -> T -T -> id -``` - -##### 項目集合の例: - -例えば、ある状態 I が以下の項目を含んでいるとします: - -```text -1. S -> . E -2. E -> . E + T -3. E -> . T -4. T -> . id -``` - -この状態Iに対して、記号Eに対するGOTO関数`GOTO(I, E)`を計算してみます。 - -1. 項目の選択: - -- `S -> . E` (ドットの直後が E) -- `E -> . E + T` (ドットの直後が E) - -2. ドットの移動: - -- `S -> E .` -- `E -> E . + T` - -3. 閉包の計算: - -- `S -> E .`はドットが右端にあるため、閉包には新たな項目は追加されません。 -- `E -> E . + T`はドットの直後が + なので、閉包には新たな項目は追加されません。 - -したがって、GOTO関数 GOTO(I, E) によって生成される新しい状態 J は以下の項目を含みます: - -```text -1. S → E . -2. E → E . + T -``` - -のように、GOTO関数は現在の状態と特定の記号に基づいて新しい状態を導出します。 - -### 構文解析表 - -SLR法における構文解析表は、構文解析器が入力を解析する際に必要なアクション(シフト、還元、受理、エラー)を決定するための表です。この表は、各状態と各入力記号の組み合わせに対して、どのアクションを取るべきかを示します。 - -構文解析表は主に2つの部分から構成されます: - -1. ACTION表:終端記号に対するアクションを示す部分。 -2. GOTO表:非終端記号に対する次の状態を示す部分。 - -#### 構文解析表の作成手順 - -構文解析表を作成する手順は以下の通りです: - -TBD - -### 構文解析の実行 - -構文解析表を使って、実際の入力文字列を解析します。スタックと入力バッファを用いて、以下の操作を繰り返します: - -- 現在の状態と次の入力記号に基づいて、構文解析表からアクションを決定 -- アクションにしたがって、シフトまたは還元を実行 -- 受理に到達したら解析成功、エラーが発生したら解析失敗 - -### 具体例 - -TBD - -このように、SLR(0)法では構文解析表を参照しながら、入力文字列を左から右に読み込み、適切なタイミングでシフトと還元を繰り返すことで構文解析を行います。 -SLR(0)法は比較的単純ですが、扱える文法に制限があります。次にSLR(0)を拡張して先読みを考慮したSLR(1)法について説明します。 - -## 4.6 SLR(1) - 先読みを考慮した上向き構文解析アルゴリズム - -SLR(1)(Simple LR(1))法は、SLR(0)法を先読み情報で強化した上向き構文解析アルゴリズムです。SLR(1)法では、LR(0)項目集合を用いて状態を構築し、さらに文法のFOLLOW集合を利用して解析表を作成します。これにより、LR(0)法で生じるシフト・還元(shift-reduce)コンフリクトや還元・還元(reduce-reduce)コンフリクトを解消することが可能になります。 - -### 構文解析表の作成方法 - -それでは、SLR(1)法における構文解析表の作成手順を詳しく説明していきましょう。 - -#### 手順1: LR(0)項目集合の構築 - -まず、与えられた文法についてLR(0)項目集合を構築します。LR(0)項目は、ドット(・)の位置で解析の進行状況を示すものでしたね。この項目集合と状態遷移を構築する際には、前節で説明した閉包とGOTO関数を用います。 - -#### 手順2: FOLLOW集合の計算 - -次に、各非終端記号のFOLLOW集合を計算します。FOLLOW集合とは、ある非終端記号の直後に現れうる可能性のある終端記号の集合です。計算手順は以下の通りです。 - -1. 各非終端記号のFOLLOW集合を空集合に初期化します。 -2. 開始記号のFOLLOW集合に入力の終端記号$を追加します。 -3. 各生成規則`A → αBβ`について、`FIRST(β)`からεを除いたものを`FOLLOW(B)`に追加します。 -4. もしβがε(空文字列)に導出可能であれば、`FOLLOW(A)`を`FOLLOW(B)`に追加します。 -5. 値が変化しなくなるまで、ステップ3と4を繰り返します。 - -#### 手順3: 構文解析表(ACTION表とGOTO表)の作成 - -ACTION表とGOTO表を以下の手順で作成します。 - -#### ACTION表の作成 - -1. シフト動作の設定 - -状態Iにおいて、項目`[A → α・aβ]`が含まれている場合、かつaが終端記号であるとき、`GOTO(I, a) = J`であれば、ACTION表の`(I, a)`にshift Jを設定します。 - -2. 還元動作の設定 - -状態Iにおいて、項目`[A → α・]`(ドットが右端にある項目)が含まれている場合、`A → α`の規則番号をrとします。このとき、`FOLLOW(A)`に含まれる全ての終端記号aに対して、ACTION表の`(I, a)`に`reduce r`を設定します。 - -3. 受理動作の設定 - -状態Iにおいて、項目`[S' → S・]`(S'は拡張した開始記号)が含まれている場合、ACTION表の`(I, $)`にacceptを設定します。 - -##### GOTO表の作成 - -状態Iにおいて、非終端記号Aに対して`GOTO(I, A) = J`が定義されている場合、GOTO表の`(I, A)`にJを設定します。 - -#### 手順4: コンフリクトの検出 - -構文解析表を作成した後、以下のコンフリクトがないか確認します。 - -- シフト・還元コンフリクト:同じセルにshiftとreduceが存在する場合。 -- 還元・還元コンフリクト:同じセルに複数のreduceが存在する場合。 - -これらのコンフリクトがなければ、その文法はSLR(1)文法であり、SLR(1)構文解析器を構築できます。コンフリクトがある場合は、文法を変更するか、より強力なLR(1)法やLALR(1)法を検討する必要があります。 - -### 具体例 - -具体的な例を用いて、SLR(1)法の構文解析表の作成手順を示します。 - -以下の文法を考えます。 - -``` -1. E → E + T -2. E → T -3. T → T * F -4. T → F -5. F → ( E ) -6. F → id -``` - -この文法は四則演算の式を表現しています。 - -#### ステップ1:LR(0)項目集合の構築 - -各項目集合(状態)を構築します。TBD - -#### ステップ2:FOLLOW集合の計算 - -各非終端記号のFOLLOW集合を計算します。 - -- FOLLOW(E) = `{ ), $ }` -- FOLLOW(T) = `{ +, ), $ }` -- FOLLOW(F) = `{ *, +, ), $ }` - -##### ステップ3:構文解析表の作成 - -各状態に対して、ACTION表とGOTO表を作成します。 - -- シフト動作:項目`[A → α・aβ]`に基づいて設定。 -- 還元動作:項目`[A → α・]`とFOLLOW(A)に基づいて設定。 - -##### ステップ4:コンフリクトの検出 - -作成した構文解析表を確認し、コンフリクトがないことを確認します。この文法では、SLR(1)法でコンフリクトなく解析可能です。 - -#### SLR(1)法の利点と限界 - -SLR(1)法は、SLR(0)法よりも多くの文法を扱える一方、LR(1)法よりは弱い解析能力しか持ちません。SLR(1)法でコンフリクトが発生する場合、LR(1)法やLALR(1)法を検討する必要があります。 - -## 4.7 LR(1) - 項目集合に先読みを追加したSLR(1)の拡張 - -LR(1)法は、SLR(1)法をさらに強化した上向き構文解析アルゴリズムです。LR(1)法では、各項目に**先読み記号(lookahead)**を付加します。これにより、解析中の文脈をより正確に把握し、コンフリクトを解消することが可能になります。 - -### LR(1)項目 - -LR(1)法では、項目は以下の形式で表されます。 - -```text -[A → α・β, a] -``` - -- A → αβ:文法規則 -- ・:ドット(現在の解析位置) -- a:先読み記号(終端記号または終端記号の集合) - -この項目は、「αを既に解析し、次にβを解析しようとしている。さらに、入力の先頭にaが現れることを期待する」という意味を持ちます。 - -### LR(1)項目集合の構築 - -LR(1)項目集合を構築するために、以下の手順を踏みます。 - -#### 手順1: 初期項目の作成 - -開始記号S'に対して、初期項目`[S' → ・S, $]`を作成します。ここで、$は入力の終端記号を表します。 - -#### 手順2: LR(1)閉包(closure)の計算 - -項目集合Iの閉包closure(I)を以下の手順で計算します。 - -1. `closure(I) = I`とする。 -2. `closure(I)`に新しい項目が追加されなくなるまで、以下を繰り返す。 - - `closure(I)`内の各項目`[A → α・Bβ, a]`について、Bが非終端記号であれば、Bのすべての規則`B → γ`に対して、`FIRST(βa)`に含まれる全ての終端記号bについて、項目`[B → ・γ, b]`を`closure(I)`に追加する。 - -#### 手順3: GOTO関数の計算 - -項目集合`I`と記号`X`に対して、`GOTO(I, X)`は以下の項目からなる集合の閉包です。 - -- I内の項目`[A → α・Xβ, a]`に対して、`[A → αX・β, a]`を集めた集合の閉包。 - -#### 手順4: LR(1)項目集合の全体構築 - -初期項目集合から出発し、GOTO関数を用いて新たな項目集合を構築します。この過程を新しい項目集合が生まれなくなるまで繰り返します。 - -### LR(1)構文解析表の作成 - -LR(1)項目集合を用いて、構文解析表(ACTION表とGOTO表)を作成します。 - -#### ACTION表の作成 - -1. シフト動作の設定 - -状態Iにおいて、項目`[A → α・aβ, b]`が含まれている場合、aが終端記号であれば、`GOTO(I, a) = J`として、ACTION表の`(I, a)`に`shift J`を設定します。 - -2. 還元動作の設定 - -状態Iにおいて、項目`[A → α・, a]`(ドットが右端にある項目)が含まれている場合、`A → α`の規則番号をrとして、ACTION表の`(I, a)`にreduce rを設定します。 - -3. 受理動作の設定 - -状態Iにおいて、項目`[S' → S・, $]`が含まれている場合、ACTION表の`(I, $)`に`accept`を設定します。 - -### GOTO表の作成 - -GOTO表の作成方法はSLR(1)法と同様です。 - -### LR(1)法の利点と欠点 - -#### 利点 - -- 強力な解析能力:LR(1)法は、すべてのLR(1)文法を解析可能であり、SLR(1)法やLALR(1)法で解析できない文法も扱えます。 -- コンフリクトの解消:先読み記号を明示的に扱うことで、コンフリクトを細かく解消できます。 - -#### 欠点 - -解析表のサイズが大きい:LR(1)項目集合は非常に大きくなる傾向があり、解析表も巨大になります。これにより、実用上のメモリ消費や処理時間が問題となる場合があります。 - -### 具体例 - -簡単な文法を用いて、LR(1)項目集合と解析表の作成を示します。 - -```text -1. S → L = R -2. S → R -3. L → * R -4. L → id -5. R → L -``` - -#### LR(1)項目集合の構築 - -初期項目:`[S' → ・S, $]` - -#### ステップ1:閉包の計算 - -初期項目の閉包を計算し、項目集合を構築します。 - -#### ステップ2:GOTO関数の計算 - -各項目集合に対して、GOTO関数を計算し、新たな項目集合を生成します。 - -#### ステップ3:全体の項目集合の構築 - -この過程を繰り返し、全ての項目集合を構築します。 - -#### 構文解析表の作成 - -構築したLR(1)項目集合を用いて、ACTION表とGOTO表を作成します。 - -#### コンフリクトの解消 - -この文法では、SLR(1)法では解消できないコンフリクトが発生しますが、LR(1)法では正しく解析できます。 - -#### LR(1)法の欠点 - -LR(1)法は強力ですが、解析表のサイズが大きくなるため、実用上はメモリや処理時間の問題があります。そこで、次節で説明するLALR(1)法がよく用いられます。LALR(1)法は、LR(1)法の解析能力を保ちつつ、解析表のサイズをSLR(1)法と同程度に抑えた手法です。 - -## 4.8 LALR(1) - 現実的に取り扱いやすい上向き構文解析アルゴリズム - -LR(1)法は強力な手法ですが、解析表が大きくなるという欠点があります。LALR(1)(Look-Ahead LR)は、LR(0)の状態を結合し、LR(1)の性能を持ちながら解析表のサイズを抑える手法です。 - -### LALR(1)解析表の作成 - -LR(1)アイテムの集合を構築し、同じLR(0)アイテムを持つ状態をマージします。その際、先読み記号を適切に管理します。 - -### 利点と欠点 - -LALR(1)は多くの実用的な文法を扱えるため、構文解析器生成器(例えばYacc)で広く使われています。しかし、まれにLALR(1)では解析できない文法も存在します。 - -## 4.7 - Parsing Expression Grammar(PEG) - 構文解析アルゴリズムのニューカマー - - 2000年代に入るまで、構文解析手法の主流はLR法の変種であり、上向き構文解析アルゴリズムでした。その理由の一つに、先読みを前提とする限り、従来のLL法はLR法より表現力が弱いという弱点がありました。下向き型でもバックトラックを用いれば幅広い言語を表現できることは比較的昔から知られていましたが、バックトラックによって解析時間が最悪で指数関数時間になるという弱点があります。そのため、コンパイラの教科書として有名な、いわゆるドラゴンブックでも現実的ではないといった記述がありました(初版にはあったはずだが、第二版にも該当記述があるかは要確認)。しかし、2004年にBryan Fordによって提案されたParsing Expression Grammar(PEG)はそのような状況を変えました。 - - PEGはおおざっぱに言ってしまえば、無制限な先読みとバックトラックを許す下向き型の構文解析手法の一つです。決定的文脈自由言語に加えて一部の文脈依存言語を取り扱うことができますし、Packrat Parsingという最適化手法によって線形時間で構文解析を行うことが保証されているというとても良い性質を持っています。さらに、LL法やLR法でほぼ必須であった字句解析器が要らず、アルゴリズムも非常にシンプルであるため、ここ十年くらいで解析表現手法をベースとした構文解析生成系が数多く登場しました。 - - 他人事のように書いていますが、筆者が大学院時代に専門分野として研究していたのがまさしくこのPEGでした。Python 3.9ではPEGベースの構文解析器が採用されるなど、PEGは近年採用されるケースが増えています。 - - 2章で既にPEGを用いた構文解析器を自作したのを覚えているでしょうか。たとえば、配列の文法を表現した以下のPEGがあるとします。 - -```text -array = LBRACKET RBRACKET | LBRACKET {value {COMMA value}} RBRACKET ; -``` - - このPEGに対応するJavaの構文解析器(メソッド)は以下のようになるのでした。 - -```java - public Ast.JsonArray parseArray() { - int backup = cursor; - try { - // LBRACKET RBRACKET - parseLBracket(); - parseRBracket(); - return new Ast.JsonArray(new ArrayList<>()); - } catch (ParseException e) { - cursor = backup; - } - - // LBRACKET - parseLBracket(); - List values = new ArrayList<>(); - // value - var value = parseValue(); - values.add(value); - try { - // {value {COMMA value}} - while (true) { - parseComma(); - value = parseValue(); - values.add(value); - } - } catch (ParseException e) { - // RBRACKET - parseRBracket(); - return new Ast.JsonArray(values); - } - } -``` - - PEGの特色は、 - -```java - int backup = cursor; -``` - - という行によって、解析を始める時点でのソースコード上の位置を保存しておき、もし解析に失敗したら以下のように「巻き戻す」ところにあります。「巻き戻した」位置から次の分岐を試そうとするのです。 - -```java - } catch (ParseException e) { - cursor = backup; - } - // LBRACKET - parseLBracket(); - // ... -``` - - なお、PEGの挙動を簡単に説明するために2章および本章では例外をスロー/キャッチするという実装にしていますが、現実にはこのような実装にするとオーバーヘッドが大きすぎるため、実用的なPEGパーザでは例外を使わないことが多いです。 - - 一般化すると、PEGの挙動は以下の8つの要素を使って説明することができます。 - -1. 空文字列: ε -2. 終端記号: t -3. 非終端記号: N -4. 連接: e1 e2 -5. 選択: e1 / e2 -6. 0回以上の繰り返し: e* -7. 肯定述語: &e -8. 否定述語: !e - - 次節以降では、この8つの要素がそれぞれどのような意味を持つかを説明していきます。説明のために - -```java -match(e, v) == Success(consumed, rest) -``` - -や - -```java -match(e, v) == Failure -``` - -というJava言語ライクな記法を使います。 - -たとえば、 - -```java -match("x", "xy") == Success("x", "y")` -``` - -は式`x`が文字列`"xy"`にマッチして残りの文字列が`"y"`であることを示します。また、 - -```java -match("xy", "x") == Failure -``` - -は式`"xy"`が文字列`"x"`にマッチしないことを表現します。 - -### 空文字列 - -空文字列εは0文字**以上**の文字列にマッチします。たとえば、 - -```java -match(ε, "") == Success("", "") -``` - -が成り立つだけでなく、 - -```java -match(ε, "x") == Success("", "x") -``` - -や - -```java -match(ε, "xyz") == Success("", "xyz") -``` - -も成り立ちます。εは**あらゆる文字列**にマッチすると言い換えることができます。 - -### 終端記号 - -終端記号`t`は1文字以上の長さで特定のアルファベットで**始まる**文字列にマッチします。たとえば、 - -```java -match(x, "x") == Success("x", "") -``` - -や - -```java -match(x, "xy") == Success("x", "y") -``` - -が成り立ちます。一方、 - -```java -match(x, "y") == Failure -``` - -ですし、 - -```java -match(x, "") == Failure -``` - -です。εの場合と同じく「残りの文字列」があってもマッチする点に注意してください。 - - - -### 選択 - -`e1`と`e2`は共に式であるものとします。このとき、 - - -```text -e1 / e2 -``` - -に対する`match(e1 / e2, s)`は以下のような動作を行います。 - -1. `match(e1, s)`を実行する -2. 1.が成功していれば、sのサフィックスを返し、成功する -3. 1.が失敗した場合、`match(e2, s)`を実行し、結果を返す - -### 選択 - -`e1`と`e2`は共に式であるものとします。このとき、 - -```text -e1 e2 -``` - - に対する`match(e1 e2, s)`は以下のような動作を行います。 - -1. `match(e1, s)`を実行する -2. 1.が成功したとき、結果を`Success(s1,s2)`とする。この時、`match(e2,s2)`を実行し、結果を返す -3. 1.が失敗した場合、その結果を返す - -### 非終端記号 - -あるPEGの規則Nがあったとします。 - -```text -N <- e -``` - -`match(N, s)`は以下のような動作を行います。 - -1. `N`に対応する規則を探索する(`N <- e`が該当) -2. `N`の呼び出しから戻って来たときのために、スタックに現在位置`p`を退避 -3. `match(e, s)`を実行する。結果を`M`とする。 -4. スタックに退避した`p`を戻す -5. `M`を全体の結果とする - -### 0回以上の繰り返し - -`e`は式であるものとします。 - -```text -e* -``` - -このとき、eと文字列sの照合を行うために以下のような動作を行います。 - -1. `match(e,s)`を実行する -2. 1.が成功したとき(`n`回目)、結果を`Success(s_n,s_(n+1))`とする。`s`を`s_(n+1)`に置き換えて、1.に戻る -3. 1.が失敗した場合(`n`回目)、結果を`Success(s_1...s_n, s[n...])`とする - -`e*`は「0回以上の繰り返し」を表現するため、一回も成功しない場合でも全体が成功するのがポイントです。なお、`e*`は規則 - -``` -H <- e H / ε -``` - -に対して`H`を呼び出すことの構文糖衣であり、全く同じ意味になります。 - -### 肯定述語 - -`e`は式であるものとします。このとき、 - -```text -&e -``` -  -は`match(&e,s)`を実行するために、以下のような動作を行います。 - -1. `match(e,s)`を実行する -2-1. 1.が成功したとき:結果を`Success("", s)`とする -2-2. 1.が失敗した場合:結果は`Failure()`とする - -肯定述語は成功したときにも「残り文字列」が変化しません。肯定述語`&e`は後述する否定述語`!!`を二重に重ねたものに等しいことが知られています。 - - -### 否定述語 - -`e`は式であるものとします。このとき、 - -```text -!e -``` -  -は`match(!e,s)`を実行するために以下のような動作を行います。 - -1. `match(e,s)`を実行する -2-1. 1.が成功したとき:結果を`Failure()`とする -2-2. 1.が失敗した場合:結果は`Success("", s)`とする - -否定述語も肯定述語同様、成功しても「残り文字列」が変化しません。 - -前述した`&e = !!e`は論理における二重否定の除去に類似するものということができます。 - -### PEGの操作的意味論 - -ここまでで、PEGを構成する8つの要素について説明してきましたが、実際のところは厳密さに欠けるものでした。より厳密に説明すると以下のようになります(Ford:04を元に改変)。先程までの説明では、`Success(s1, s2)`を使って、`s1`までは読んだことを、残り文字列が`s2`であることを表現してきました。ここではペア`(n, x)`で結果を表しており、`n`はステップ数を表すカウンタで`x`は残り文字列または`f`(失敗を表す値)となります。⇒を用いて、左の状態になったとき右の状態に遷移することを表現しています。 - -``` -1. 空文字列: - (ε,x) ⇒ (1,ε) (全ての x ∈ V ∗ Tに対して)。 -2. 終端記号(成功した場合): - (a,ax) ⇒ (1,a) (a ∈VT , x ∈V ∗ T である場合)。 -3. 終端記号(失敗した場合): - (a,bx) ⇒ (1, f) iff a ≠ b かつ (a,ε) ⇒ (1, f)。 -4. 非終端記号: - (A,x) ⇒ (n + 1,o) iff A ← e ∈ R かつ(e,x) ⇒ (n,o)。 -5. 連接(成功した場合): - (e1, x1 x2 y) ⇒ (n1,x1) かつ (e2, x2 y) ⇒ (n2, x2) のとき、 (e1 e2,x1 x2 y) ⇒ (n1 + n2 + 1, x1 x2)。 -6. 連接(失敗した場合1): - (e1, x) ⇒ (n1, f) ならば (e1 e2,x) ⇒ (n1 + 1, f). もし e1が失敗したならば、e1e2はe2を試すことなく失敗する。 -7. 連接(失敗した場合2): - (e1, x1 y) ⇒ (n1,x1) かつ (e2,y) ⇒ (n2, f) ならば (e1e2,x1y) ⇒ (n1 + n2 + 1, f)。 -8. 選択(場合1): - (e1, x y) ⇒ (n1,x) ならば (e1/e2,xy) ⇒ (n1 +1,x)。 -9. 選択(場合2): - (e1, x) ⇒ (n1, f) かつ (e2,x) ⇒ (n2,o) ならば (e1 / e2,x) ⇒ (n1 + n2 + 1,o)。 -10. 0回以上の繰り返し (繰り返しの場合): - (e, x1 x2 y) ⇒ (n1,x1) かつ (e∗,x2 y) ⇒ (n2, x2) ならば (e∗,x1 x2 y) ⇒ (n1 + n2 +1,x1 x2)。 -11. 0回以上の繰り返し (停止の場合): - (e,x) ⇒ (n1, f) ならば (e∗,x) ⇒ (n1 + 1, ε)。 -12. 否定述語(場合1): - (e,xy) ⇒ (n,x) ならば (!e,xy) ⇒ (n + 1, f)。 -13. 否定述語(場合2): - (e,x) ⇒ (n, f) ならば (!e,x) ⇒ (n + 1, ε)。 -``` - -## 4.9 - Packrat Parsing - -素のPEGは非常に単純でいて、とても幅広い範囲の言語を取り扱うことができます。しかし、PEGには一つ大きな弱点があります。最悪の場合、解析時間が指数関数時間になってしまうことです。現実的にはそのようなケースは稀であるという指摘ありますが(論文を引用)、原理的にはそのような弱点があります。Packrat Parsingはメモ化という技法を用いることでPEGで表現される言語を線形時間で解析可能にします。 - -メモ化という技法自体をご存じでない読者の方も多いかもしれないので、まずメモ化について説明します。 - -### 4.9.1 fibメソッド - - メモ化の例でよく出てくるのはN番目のフィボナッチ数を求める`fib`関数です。この書籍をお読みの皆様ならお馴染みかもしれませんが、N番目のフィボナッチ数F(n)は次のようにして定義されます: - -``` -F(0) = 1 -F(1) = 1 -F(n) = F(n - 1) + F(n - 2) -``` - - この再帰的定義を素朴にJavaのメソッドとして書き下したのが以下のfibメソッドになります。 - -```java -public class Main { - public static long fib(long n) { - if(n == 0 || n == 1) return 1L; - else return fib(n - 1) + fib(n - 2); - } - public static void main(String[] args) { - System.out.println(fib(5)); // 120 - } -} -``` - - このプログラムを実行すると、コメントにある通り120が出力されます。しかし、このfibメソッドには重大な欠点があります。それは、nが増えると計算量が指数関数的に増えてしまうことです。たとえば、上のfibメソッドを使うと`fib(30)`くらいまではすぐに計算することができます。しかし、`fib(50)`を求めようとすると皆さんのマシンではおそらく数十秒はかかるでしょう。 - - フィボナッチ数を求めたいだけなのに数十秒もかかってはたまったものではありません。 - -### 4.9.2 fib関数のメモ化 - - そこで出てくるのがメモ化というテクニックです。一言でいうと、メモ化とはある引数nに対して計算した結果f(n)をキャッシュしておき、もう一度同じnに対して呼び出されたときはキャッシュした結果を返すというものです。早速、fibメソッドをメモ化してみましょう。 - - メモ化されたfibメソッドは次のようになります。 - -```java -import java.util.*; -public class Main { - private static Map cache = new HashMap<>(); - public static long fib(long n) { - Long value = cache.get(n); - if(value != null) return value; - - long result; - if(n == 0 || n == 1) { - result = 1L; - } else { - result = fib(n - 1) + fib(n - 2); - } - cache.put(n, result); - return result; - } - public static void main(String[] args) { - System.out.println(fib(50)); // 20365011074 - } -} -``` - - `fib(50)`の結果はコメントにある通りですが、今度は一瞬で結果がかえってきたのがわかると思います。メモ化されたfibメソッドでは同じnに対する計算は二度以上行われないので、nが増えても実行時間は線形にしか増えません。つまり、fib(50)の実行時間は概ねfib(25)の二倍であるということです。 - - ただし、計算量に詳しい識者の方は「おいおい。整数同士の加算が定数時間で終わるという仮定はおかしいんじゃないかい?」なんてツッコミを入れてくださるかもしれませんが、そこを議論するとややこしくなるので整数同士の加算はたかだか定数時間で終わるということにします。 - - `fib`メソッドのメモ化でポイントとなるのは、記憶領域(`cache`に使われる領域)と引き換えに最悪計算量を指数関数時間から線形時間に減らせるということです。また、メモ化する対象となる関数は一般的には副作用がないものに限定されます。というのは、メモ化というテクニックは「同じ引数を渡せば同じ値が返ってくる」ことを暗黙の前提にしているからです。 - - 次の項ではPEGをナイーヴに実装した`parse`関数をまずお見せして、続いてそれをメモ化したバージョン(Packrat parsing)をお見せすることにします。`fib`メソッドのメモ化と同じようにPEGによる構文解析もメモ化できることがわかるでしょう。 - -### 4.9.3 parseメソッド - - ここからは簡単なPEGで記述された文法を元に構文解析器を組み立てていくわけですが、下記のような任意個の`()`で囲まれた`0`の構文解析器を作ります。 - -``` -A <- "(" A ")" - / "(" A A ")" - / "0" -``` - - 単純過ぎる例にも思えますが、メモ化の効果を体感するにはこれで十分です。早速構文解析器を書いていきましょう。 - -```java -sealed interface ParseResult permits ParseResult.Success, ParseResult.Failure { - public abstract String rest(); - record Success(String value, String rest) implements ParseResult {} - record Failure(String rest) implements ParseResult {} -} -class ParseError extends RuntimeException { - public final String rest; - public String rest() { - return rest; - } - ParseError(String rest) { - this.rest = rest; - } -} -public class Parser { - private static boolean isEnd(String string) { - return string.length() == 0; - } - public static ParseResult parse(String input) { - String start = input; - try { - // "(" A ")" - if(isEnd(input) || input.charAt(0) != '(') { - throw new ParseError(input); - } - - var result = parse(input.substring(1)); - if(!(result instanceof ParseResult.Success)) { - throw new ParseError(result.rest()); - } - - var success = (ParseResult.Success)result; - - input = success.rest(); - if(isEnd(input) || input.charAt(0) != ')') { - throw new ParseError(input); - } - - return new ParseResult.Success(success.value(), input.substring(1)); - } catch (ParseError error) { - input = start; - } - - try { - // "(" A A ")" - if((isEnd(input)) || input.charAt(0) != '(') { - throw new ParseError(input); - } - - var result = parse(input.substring(1)); - if(!(result instanceof ParseResult.Success)) { - throw new ParseError(result.rest()); - } - - var success = (ParseResult.Success)result; - input = success.rest(); - - result = parse(input); - - if(!(result instanceof ParseResult.Success)) { - throw new ParseError(result.rest()); - } - - success = (ParseResult.Success)result; - input = success.rest(); - - if(isEnd(input) || input.charAt(0) != ')') { - throw new ParseError(input); - } - - return new ParseResult.Success(success.value(), success.rest().substring(1)); - } catch (ParseError error) { - input = start; - } - - if(isEnd(input) || input.charAt(0) != '0') { - return new ParseResult.Failure(input); - } - - return new ParseResult.Success(input.substring(0, 1), input.substring(1)); - } -} -``` - -このプログラムを使うと以下のように構文解析を行うことが出来ます。 - -```java -jshell> Parser.parse("("); -$25 ==> Failure[rest=] -jshell> Parser.parse("()"); -$26 ==> $26 ==> Failure[rest=)] -jshell> Parser.parse("(0)"); -$27 ==> Success[value=), rest=] -``` -  -しかし、この構文解析器には弱点があります。`(((((((((((((((((((((((((((0)))`のようなカッコのネスト数が深いケースで急激に解析にかかる時間が増大してしまうのです。これはまさにPEGだからこそ起こる問題点だと言えます。 - -### 4.9.4 parseメソッドのメモ化 - Packrat Parsing - -4.9.3のコードをもとに`parse`メソッドをメモ化してみましょう。コードは以下のようになります。 - -```java -import java.util.*; -sealed interface ParseResult permits ParseResult.Success, ParseResult.Failure { - public abstract String rest(); - record Success(String value, String rest) implements ParseResult {} - record Failure(String rest) implements ParseResult {} -} -class ParseError extends RuntimeException { - public final String rest; - public String rest() { - return rest; - } - ParseError(String rest) { - this.rest = rest; - } -} -class PackratParser { - private Map cache = new HashMap<>(); - private boolean isEnd(String string) { - return string.length() == 0; - } - public ParseResult parse(String input) { - String start = input; - try { - // "(" A ")" - if(isEnd(input) || input.charAt(0) != '(') { - throw new ParseError(input); - } - - input = input.substring(1); - ParseResult result; - result = cache.get(input); - if(result == null) { - result = parse(input); - cache.put(input, result); - } - - if(!(result instanceof ParseResult.Success)) { - throw new ParseError(result.rest()); - } - - var success = (ParseResult.Success)result; - - input = success.rest(); - if(isEnd(input) || input.charAt(0) != ')') { - throw new ParseError(input); - } - - return new ParseResult.Success(success.value(), input.substring(1)); - } catch (ParseError error) { - input = start; - } - - try { - // "(" A A ")" - if((isEnd(input)) || input.charAt(0) != '(') { - throw new ParseError(input); - } - - input = input.substring(1); - ParseResult result; - result = cache.get(input); - if(result == null){ - result = parse(input); - cache.put(input, result); - } - - if(!(result instanceof ParseResult.Success)) { - throw new ParseError(result.rest()); - } - - var success = (ParseResult.Success)result; - input = success.rest(); - - result = cache.get(input); - if(result == null) { - result = parse(input); - cache.put(input,result); - } - - if(!(result instanceof ParseResult.Success)) { - throw new ParseError(result.rest()); - } - - success = (ParseResult.Success)result; - input = success.rest(); - - if(isEnd(input) || input.charAt(0) != ')') { - throw new ParseError(input); - } - - return new ParseResult.Success(success.value(), input.substring(1)); - } catch (ParseError error) { - input = start; - } - - if(isEnd(input) || input.charAt(0) != '0') { - return new ParseResult.Failure(input); - } - - return new ParseResult.Success(input.substring(0, 1), input.substring(1)); - } -} -``` - -```java - private Map cache = new HashMap<>(); -``` - -というフィールドが加わったことです。このフィールド`cache`がパーズの途中結果を保持してくれるために計算が高速化されるのです。結果として、PEGでは最悪指数関数時間かかっていたものがPackrat Parsingでは入力長に対してたかだか線形時間で解析できるようになりました。 - -PEGは非常に強力な能力を持っていますが、同時に線形時間で構文解析を完了できるわけで、これはとても良い性質です。そういった理由もあってか、PEGやPackrat Parsingを用いた構文解析器や構文解析器生成系はここ10年くらいで大幅に増えました。 - -## 4.10 - Generalized LR (GLR) Parsing - -GLR(Generalized LR)法は、Tomitaによって提案された手法で、曖昧な文法や非決定性を含む文法を扱うことができます。GLRは解析中に可能性のある複数の解析パスを同時に追跡し、すべての解釈を得ることができます。 - -### GLRの特徴 - -- 複数の解析スタックを同時に管理 -- シフト・還元動作を一般化 -- 木構造の共有によりメモリ効率を向上 - -GLRは曖昧さを扱えるため、自然言語処理や曖昧な構文を持つプログラミング言語の解析に適しています。 - -## 4.11 - Generalized LL (GLL) Parsing - -GLL(Generalized LL)法は、LL法を拡張して曖昧な文法を扱えるようにした手法で、Scottらによって2010年に提案されました(Scott:2010)。GLL法は再帰下降構文解析機を一般化したもので、非決定性を処理するために解析スタックと呼ばれるデータ構造を用います。 - -GLLの特徴は以下の通りです。 - -- 再帰下降構文解析機の拡張 -- 非決定性を処理できる -- 部分的なメモ化による効率化 - -## 4.12 - Parsing with Derivatives (PwD) - -Parsing with derivatives(PwD)はPwD(Parsing with Derivatives)は、正規表現の微分の概念を文脈自由文法に拡張した手法で、Mightらによって2011年に提案されました(Might:2011)。関数型のプログラムとして記述され、遅延評価や無限リストを活用します。 - -PwDの特徴は以下の通りです。 - -- 理論的にシンプル -- 遅延評価による効率化 -- 関数型プログラミング言語での実装が容易 - -## 4.13 - Tunnel Parsing - -[トンネル構文解析](https://dl.acm.org/doi/abs/10.2478/cait-2022-0021)は、曖昧性のある文法を効率的に解析するための新しい手法です。解析の過程で不要な部分をスキップ(トンネル)することで、効率的な解析を実現します。 - -トンネル構文解析の特徴は以下の通りです。 - -- 不要な解析パスを早期に除外 -- メモリ使用量の削減 -- 特定の問題領域での効率的な解析 - -## 4.14 - 構文解析アルゴリズムの計算量と表現力の限界 - - LL parsing、LR parsing、PEG、Packrat parsing、GLR parsing、GLL parsingについてこれまで書いてきましたが、計算量的な性質についてまとめておきましょう。なお、`n`は入力文字列長を表します。 - -| アルゴリズム | 時間計算量 | 空間計算量 | -| ------------------- | ------------- | ------------------------------- | -| LL(1) | O(n) | O(|N| * |T|) | -| LL(k) | O(n) | ??? | -| SLR(1) | O(n) | O(|N| * |P| * |T|) | -| LR(1) | O(n) | O(s * |T|) | -| LALR(1) | O(n) | O(s * |T|) | -| PEG | O(2^n) | O(n) | -| Packrat Parsing | O(n) | O(n * |P|) | -| GLR | O(n^3) | O(n^3) | -| GLL | O(n^3) | O(n^3) | -| PwD | O(n^3) | O(n^3) | -| トンネル構文解析 | 問題に依存 | 問題に依存 | - -nは全てのアルゴリズムで入力文字列の長さを表します。その他の記号については以下の通りです。 - -- LL(1) - - `|S|`: 開始記号列のサイズ - - `|T|`: は終端記号の数 -- SLR(1): - - `|N|`: 規則の右辺のサイズ - - `|P|`: 規則の数 - - `|T|`: 終端記号の数 -- LR(1): - - s: 状態数 - - `|T|`: 終端記号の数 -- LALR(1): - - s: 状態数 - - `|T|`: 終端記号の数 -- PEG or packrat parsing - - `|P|`: 規則の数 - -LL(1)やLR(1)は線形時間で解析を終えられますが、GLRやGLLは最悪の場合多項式時間を要します。PEGは指数時間がかかることがありますが、Packrat Parsingによって線形時間で解析できるようになります。 +最右導出は、これ以上適用できる規則がなくなるまで、常に一番右の非終端記号を展開します。 -## 4.15 - まとめ +最左導出と最右導出では最終的に同じ文字列を導出することが可能です。しかし、導出過程が異なります。 -この章では構文解析アルゴリズムの中で比較的メジャーな手法について、そのアイデアと概要を含めて説明しました。その他にも多数の手法がありますが、いずれにせよ、「上から下に」向かって解析する下向きの手法と「下から上に」向かって解析する上向きの手法のどちらかに分類できると言えます。 +先取りしておくと、形式言語における最左導出がいわゆる下向き構文解析に対応し、最右導出の逆操作が上向き構文解析に対応します。これらについては次の章で説明します。 -GLRやGLL、PwDについては普段触れる機会はそうそうありませんが、LLやLR、PEGのパーサジェネレータは多数存在するため、基本的な動作原理について押さえておいて損はありません。また、余裕があれば各構文解析手法を使って実際のパーサジェネレータを実装してみるのも良いでしょう。実際にパーサジェネレータを実装することで、より深く構文解析手法を理解することもできます。 \ No newline at end of file +生成規則から文字列を生成するために2つの導出方法を使い分けることができることを覚えておいてください。 diff --git a/honkit/chapter5.md b/honkit/chapter5.md index 78a518b..1f2f3da 100755 --- a/honkit/chapter5.md +++ b/honkit/chapter5.md @@ -1,939 +1,1954 @@ -# 5. 構文解析器生成系の世界 +# 5. 構文解析アルゴリズム古今東西 - 4章では現在知られている構文解析手法について、アイデアと提案手法の概要について説明しました。実は、構文解析の世界ではよく知られていることなのですが、4章で説明した各種構文解析手法は毎回プログラマが手で実装する必要はありません。 +第2章で算術式を例にしてBNFや抽象構文木といった基礎概念を説明しました。第3章ではJSONの構文解析器の実装を通してPEGによる構文解析と単純な字句解析を用いた構文解析のやり方を学びました。第4章では文脈自由文法と形式言語の階層の話をしました。これでようやく準備が整ったので、本書の本丸である構文解析アルゴリズムの話ができます。 - というのは、CFGやPEG(その類似表記も含む)によって記述された文法定義から特定の構文解析アルゴリズムを用いた構文解析器を生成する構文解析器生成系というソフトウェアがあるからです。もちろん、それぞれの構文解析アルゴリズムや生成する構文解析器の言語ごとに別のソフトウェアを書く必要がありますが、ひとたびある構文解析アルゴリズムのための構文解析器生成系を誰かが書けば、その構文解析アルゴリズムを知らないプログラマでもその恩恵にあずかることができるのです。 +といっても、戸惑う読者の方が多いかもしれません。これまで「構文解析アルゴリズム」について具体的な話はまったくなかったのですから。しかし、皆さんは、第3章で二つの構文解析アルゴリズムを使ってJSONの構文解析器を**すでに**書いているのです。 - 構文解析器生成系でもっとも代表的なものはyaccあるいはその互換実装であるGNU bisonでしょう。yaccはLALR(1)法を利用したCの構文解析器を生成してくれるソフトウェアであり、yaccを使えばプログラマはLALR(1)法の恩恵にあずかることができます。 +第3章で最初に実装したのはPEGと呼ばれる手法の素朴な実装です。次に実装したのは*LL(1)*っぽい再帰下降構文解析器です。**再帰下降**という言葉は見慣れないものなので、疑問に思われる読者の方も多いと思います。その疑問は脇において、第3章での実装が理解できたのなら、皆さんはすでに直感的に構文解析アルゴリズムを理解していることになります。 - この章では構文解析器生成系という種類のソフトウェアの背後にあるアイデアからはじまり、LL(1)、LALR(1)、PEGのための構文解析器を作る方法や多種多様な構文解析器生成系についての紹介などを行います。 +この章では、2024年までに発表された主要な構文解析アルゴリズムについて、筆者の独断と偏見を交えて解説します。この章で紹介する構文解析アルゴリズムのほとんどについて、その構文解析アルゴリズムを使った構文解析器を生成してくれる構文解析器生成系が存在します。構文解析機生成系については第5章で説明しますが、ここではさわりだけを紹介しておきます。 - また、この章の最後ではある意味構文解析生成系の一種とも言えるパーザコンビネータの実装方法について踏み込んで説明します。構文解析生成系はいったん対象となるプログラミング言語のソースコードを生成します。この時、対象言語のコードを部分的に埋め込む必要性が出てくるのですが、この「対象言語のコードを埋め込める必要がある」というのは結構曲者でして、実用上ほぼ必須だけど面倒くささと伴うので、構文解析系をお手軽に作るとは行かない部分があります。 +たとえば、構文解析アルゴリズムとして有名な*LALR(1)*は、もっともメジャーな構文解析器生成系でCコードを生成するyacc(GNUによる再実装であるbisonが主流)で採用されています。*LL(1)*はJava向けの構文解析器生成系としてメジャーなJavaCCで採用されている方式です。*ALL(*)*は、Javaをはじめとした多言語向け構文解析器生成系として有名なANTLRで採用されています。PEGを採用した構文解析器生成系も多数存在します。 - 一方、パーザコンビネータであれば、いわゆる「ラムダ式」を持つほとんどのプログラミング言語で比較的簡単に実装できます。本書で利用しているJava言語でも同様です。というわけで、本章を読めば皆さんもパーザコンビネータを明日から自前で実装できるようになります。 +このように、なにかの構文解析アルゴリズムがあれば、構文解析アルゴリズムに基づいた構文解析機生成系を作ることができます。世dんですが、筆者は大学院生時代にPEGおよびPackrat Parsingの研究をしており、その過程でPEGによる構文解析器生成系を作ったものです。 -## 5.1 Dyck言語の文法とPEGによる構文解析器生成 +小難しいことばかり言うのは趣味ではないので、さっそく、構文解析アルゴリズムの世界を覗いてみましょう!  -これまで何度も登場したDyck言語は明らかにLL(1)法でもLR(1)法でもPEGによっても解析可能な言語です。実際、4章ではDyck言語を解析する手書きのPEGパーザを書いたのでした。しかし、立ち戻ってよくよく考えてみると退屈な繰り返しコードが散見されたのに気づいた方も多いのではないでしょうか(4章に盛り込む予定)。 +## 5.1 下向き構文解析と上向き構文解析 -実際のところ、Dyck言語を表現する文法があって、構文解析アルゴリズムがPEGということまで分かれば対応するJavaコードを**機械的に生成する**ことも可能そうに見えます。特に、構文解析はコード量が多いわりには退屈な繰り返しコードが多いものですから、文法からJavaコードを生成できれば劇的に工数を削減できそうです。 +具体的な構文解析アルゴリズムの説明に入る前に、構文解析アルゴリズムは大別して、 -このように「文法と構文解析手法が決まれば、後のコードは自動的に決定可能なはずだから、機械に任せてしまおう」という考え方が構文解析器生成系というソフトウェアの背後にあるアイデアです。 +- 上から下へ(下向き) +- 下から上へ(上向き) -早速ですが、以下のようにDyck言語を表す文法が与えられたとして、PEGを使った構文解析器を生成する方法を考えてみましょう。 +の二つのアプローチがあることを理解しておきましょう。下向き構文解析法と上向き構文解析法では真逆の発想で構文解析を行うからです。 -```text -D <- P; -P <- "(" P ")" | "()"; -``` +## 5.2 下向き構文解析の概要 + +まずは下向き構文解析法です。下向き構文解析では予測型とバックトラック型で若干異なる方法で構文解析をしますが、ここでは予測型の下向き構文解析法を説明します。予測型の下向き構文解析法ではCFGの開始記号から構文解析を開始し、規則に従って再帰的に構文解析を行っていきます。 + +第4章で例に出てきたDyck言語を例にして、具体的な方法を説明します。Dyck言語の文法は以下のようなものでした。 + +$$ +\begin{array}{lll} +D & \rightarrow & \$ P \$ \\ +P & \rightarrow & ( P ) P \\ +P & \rightarrow & \epsilon \\ +\end{array} +$$ + +このCFGはカッコがネストした文字列の繰り返し(空文字列を含む)を過不足無く表現しているわけですが、`(())`という文字列がマッチするかを判定する問題を考えてみましょう。 + +まず最初に、開始記号が$`D`$で規則$`D \rightarrow $ P $ `$があるのでスタックに積み込みます。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \uparrow \$ P \$ \rbrack +\end{align*} +$$ + +次に最初の1文字を入力文字列から読み込んで、先読み文字列に追加します。 + +$$ +\begin{align*} +先読み文字列:& \$ \\ +スタック:& \lbrack D \rightarrow \uparrow \$ P \$ \rbrack +\end{align*} +$$ + +入力文字列の先頭と現在の解析位置にある文字が`$`であるため、先読み文字列の先頭とマッチします。そこで、文字列を消費します。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$ \rbrack +\end{align*} +$$ + +その次ですが、候補となる規則には$`P \rightarrow (P)P`$と$`P \rightarrow \epsilon`$があるものの、先読み文字列が空なので、次の文字を読み込みます。 + +$$ +\begin{align*} +先読み文字列:\verb|(| & \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$ \rbrack +\end{align*} +$$ + +規則が$`P \rightarrow (P)P`$に確定したので、スタックに規則を積みます。 + +$$ +\begin{align*} +先読み文字列:& \verb|(| \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow \uparrow (P) P\rbrack +\end{align*} +$$ -PEGでは非終端記号の呼び出しは関数呼び出しとみなすことができますから、まず次のようなコードになります。 +次の文字は`(`であり、先読み文字列の先頭とマッチします。そこで、文字列を消費します。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P\rbrack +\end{align*} +$$ + +先ほどと同様に先読み文字列に1文字追加します。 + +$$ +\begin{align*} +先読み文字列:& \verb|(| \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P\rbrack +\end{align*} +$$ + +規則は$`P \rightarrow (P)P`$に確定します。そこで、スタックに確定した規則を積みます。 + +$$ +\begin{align*} +先読み文字列:& \verb|(| \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P, P \rightarrow \uparrow (P)P\rbrack +\end{align*} +$$ + +先読み文字列の先頭と現在の解析位置にある文字がマッチするので、文字列を消費します。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P) P, P \rightarrow ( \uparrow P ) P\rbrack +\end{align*} +$$ + +次が非終端記号$`P`$ですが、候補は$`P \rightarrow \epsilon`$だけです。本来ならスタックにこの規則を積んで下ろすという作業が必要ですが、省略して$`P`$の分を読み進めてしまいます。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P \uparrow ) P\rbrack +\end{align*} +$$ + +先読み文字列に1文字追加します。 + +$$ +\begin{align*} +先読み文字列:& \verb|)| \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P \uparrow ) P\rbrack +\end{align*} +$$ + +先読み文字列の先頭と現在の解析位置にある文字がマッチするので、文字列を消費します。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P ) \uparrow P\rbrack +\end{align*} +$$ + +先ほどと同様、規則の候補は$`P \rightarrow \epsilon`$だけです。$`P`$の分を読み進めてしまいます。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( \uparrow P ) P, P \rightarrow ( P ) \uparrow \rbrack +\end{align*} +$$ + +スタックトップの規則を解析し終えたので、スタックから取り除きます。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P \uparrow ) P +\end{align*} +$$ + +さらに1文字読み込みます。 + +$$ +\begin{align*} +先読み文字列:& \verb|)| \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P \uparrow ) P +\end{align*} +$$ + +先読み文字列の先頭とマッチしますから、文字列を消費します。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P ) \uparrow P \rbrack +\end{align*} +$$ + +先ほどと同様、規則の候補は$`P \rightarrow \epsilon`$だけです。$`P`$の分を読み進めてしまいます。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ \uparrow P \$, P \rightarrow ( P ) P \uparrow \rbrack +\end{align*} +$$ + +スタックトップの規則を解析し終えたので、スタックから取り除きます。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ P \uparrow \$ \rbrack +\end{align*} +$$ + +文字列の末尾なので`$`を読み込みます。 + +$$ +\begin{align*} +先読み文字列:\$ & \\ +スタック:& \lbrack D \rightarrow \$ P \uparrow \$ \rbrack +\end{align*} +$$ + +先読み文字列の先頭とマッチしますから、文字列を消費します。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack D \rightarrow \$ P \$ \uparrow \rbrack +\end{align*} +$$ + +規則の最後に到達したので、スタックから要素を取り除きます。 + +$$ +\begin{align*} +先読み文字列:& \\ +スタック:& \lbrack \rbrack +\end{align*} +$$ + +入力文字列の終端に到達し、スタックが空になったので、入力文字列`(())`はDyck言語の文法に従っていることがわかりました。 + +予測型の下向き構文解析では以下の動作を繰り返します。 + +1. 残りの文字列から、1文字とってきて先読み文字列に追加する +2. 次が非終端記号で、先読み文字列から適用すべき規則が決定できる場合スタックにその規則を積む。規則が決定できない場合はエラー。次が終端記号であれば、先読み文字列の先頭とマッチするかを確認し、マッチすれば文字列を消費し、マッチしない場合はエラーを返す +3. 規則の最後に到達した場合は、スタックから要素を取り除く + +## 5.3 下向き構文解析法のJavaによる実装 + +このような動作をJavaコードで表現することを考えてみます。 ```java -public boolean parseD() { - return parseP(); -} -public boolean parseP() { - "(" P ")" | "()" +// D → P +// P → ( P ) P +// P → ε +public class Dyck { + private final String input; + private int position; + + public Dyc(String input) { + this.input = input; + this.position = 0; + } + + public boolean parse() { + boolean result = D(); + return result && position == input.length(); + } + + private boolean D() { + return P(); + } + + private boolean P() { + // P → ( P ) P + if (position < input.length() && input.charAt(position) == '(') { + position++; // '(' を読み進める + if (!P()) return false; + if (position < input.length() && input.charAt(position) == ')') { + position++; // ')' を読み進める + return P(); + } else { + return false; + } + // P → ε + } else { + // 空文字列にマッチ + return true; + } + } } ``` -## 5.1 JSONの構文解析器を生成する +クラス`Dyck`は、Dyck言語を構文解析して、成功したら`true`、そうでなければ`false`を返すものです。BNFと比較すると、 + +- 規則の名前と一対一になるメソッドが存在する +- 非終端記号の参照は規則の名前に対応するメソッドの再帰呼び出しとして実現されている + +のが特徴です。 + +呼び出す規則を上、呼び出される規則を下とした時、上から下に再帰呼び出しが続いていくため、再帰下降構文解析と呼ばれます。このように「上から下に」構文解析を行っていくのが下向き構文解析法の特徴です。 + +注意しなければいけないのは、下向き構文解析の方法は多数あり、その1つに再帰下降構文解析があるということです。 + +実際、後述するLL(1)の実装のときは構文解析のための表を作り、関数の再帰呼び出しは行わないこともあります。 + +## 5.4 上向き構文解析の概要 + +上向き構文解析は下向き構文解析とは真逆の発想で構文解析を行います。こちらの方法は下向き型より直感的に理解しづらいかもしれません。 + +上向き構文解析ー正確にはシフト還元構文解析として知られているものーでは、文字列を左から右に読み込んでいき、順番にスタックにプッシュしていきます。これをシフト(shift)と呼びます。 + +シフト動作を続けていくうちに、規則の右辺の記号列とスタックトップにある記号列がマッチすれば、規則の左辺にマッチしたとして、スタックトップにある記号列を規則の左辺で置き換えます。これを還元(reduce)と呼びます。 + +具体例を挙げてみます。以下のCFGがあったとしましょう。ただし、入力の先頭と末尾を表すために`$`を使うものとします。 + +$$ +\begin{align*} +D & \rightarrow \$ P \$ \\ +P & \rightarrow ( P ) P \\ +P & \rightarrow \epsilon +\end{align*} +$$ + +これは下向き構文解析で扱ったDyck言語の文法です。上向き構文解析の説明の都合上、上記と等価な以下の文法を考えます。 + +$$ +\begin{align*} +D & \rightarrow \$ P \$ \\ +D & \rightarrow \$ \epsilon \$ \\ +P & \rightarrow P\ X \\ +P & \rightarrow X \\ +X & \rightarrow ( X ) \\ +X & \rightarrow () +\end{align*} +$$ + +このCFGに対して`(())`という文字列がマッチするかを判定する問題を考えてみましょう。上向き構文解析では、まず最初の「1文字」を左から右にシフトします。以下のようなイメージです。スタックに要素をプッシュすると右に要素が追加されていくものとします。 + +$$ +\begin{align*} +スタック: & \lbrack \$, (\ \rbrack +\end{align*} +$$ + +このスタックは$`P`$にも$`D`$にもマッチしません。そこで、もう1文字をシフトしてみます。 + +$$ +\begin{align*} +スタック: & \lbrack \$, (, ( \rbrack +\end{align*} +$$ + +まだマッチしませんね。さらにもう1文字シフトしてみます。 + +$$ +\begin{align*} +スタック: & \lbrack \$, (, (, \rbrack +\end{align*} +$$ + +さらにもう1文字シフトしてみます。 + +$$ +\begin{align*} +スタック: & \lbrack \$, (, (, )\rbrack +\end{align*} +$$ + +規則$`X \rightarrow ()`$を使って還元を行います。 + +$$ +\begin{align*} +スタック: & \lbrack \$, (, X\rbrack +\end{align*} +$$ + +この状態でもう1文字シフトしてみます。 -LL(1)構文解析器生成系で、JSONのパーザが作れることを示す。これを通じて、構文解析器生成系が実用的に使えることを理解してもらう。 +$$ +\begin{align*} +スタック: & \lbrack \$, (, X, ) \rbrack +\end{align*} +$$ -## 5.2 構文解析器生成系の分類 +規則$`X \rightarrow (X)`$を使って還元を行います。 -構文解析器生成系は1970年代頃から研究の蓄積があり、数多くの構文解析生成系がこれまで開発されています。基本的には構文解析器生成系と採用しているアルゴリズムは対応するので、たとえば、JavaCCはLL(1)構文解析器を出力するため、LL(1)構文解析器生成系であると言ったりします。 +$$ +\begin{align*} +スタック: & \lbrack \$, X \rbrack +\end{align*} +$$ -同様に、yacc(bison)はLALR(1)構文解析器生成系を出力するので、LALR(1)構文解析器生成系であると言ったりもします。ただし、例外もあります。bisonはyaccと違って、LALR(1)より広いGLR構文解析器を生成できるので、GLR構文解析器生成系であるとも言えるのです。実際には、yaccを使う場合、ほとんどはLALR(1)構文解析器を出力するので、GLRについては言及されることは少ないですが、そのようなことは知っておいても損はないでしょう。 +さらに規則$`P \rightarrow X`$を使って還元を行います。 -より大きなくくりでみると、下向き構文解析(LL法やPEG)と上向き構文解析(LR法など)という観点から分類することもできますし、ともに文脈自由文法ベースであるLL法やLR法と、解析表現文法など他の形式言語を用いた構文解析法を対比してみせることもできます。 +$$ +\begin{align*} +スタック: & \lbrack \$, P \rbrack +\end{align*} +$$ -## 5.3 JavaCC:Javaの構文解析生成系の定番 +文字列の末尾にきたので、`$`をシフトします。 -1996年、Sun Microsystems(当時)は、Jackという構文解析器生成系をリリースしました。その後、Jackの作者が自らの会社を立ち上げ、JackはJavaCCに改名されて広く知られることとなりましたが、現在では紆余曲折の末、[javacc.github.io](https://javacc.github.io/javacc)の元で開発およびメンテナンスが行われています。現在のライセンスは3条項BSDライセンスです。 +$$ +\begin{align*} +スタック: & \lbrack \$, P, \$ \rbrack +\end{align*} +$$ -JavaCCはLL(1)法を元に作られており、構文定義ファイルからLL(1)パーザを生成します。以下は四則演算を含む数式を計算できる電卓をJavaCCで書いた場合の例です。 +このスタックは規則$`D \rightarrow $ P $`$にマッチします。還元が行われ、最終的にスタックの状態は次のようになります。 + +$$ +\begin{align*} +スタック: & \lbrack D \rbrack +\end{align*} +$$ + +めでたく`(())`が`D`とマッチすることがわかりました。上向き構文解析では以下の手順を繰り返していきます。 + +1. 残りの文字があれば、入力文字をシフトしてスタックにプッシュする(シフト) +2. スタックの記号列が規則の右辺にマッチすれば、左辺の非終端記号で置き換える(還元) + +4章の最後で少しだけ述べましたが、還元はちょうど、最右導出における導出の逆向きの操作になります。 + +## 5.5 上向き構文解析のJavaによる実装 + +このような動作をJavaコードで表現することを考えてみます。まず必要なのは、規則を表すクラス`Rule`です。問題を単純化するために、 + +1. 規則の名前(左辺)は1文字 +2. 規則の右辺は終端記号または非終端記号のリストである + +とします。このようなクラス`Rule`は以下のように表現できます。 ```java -options { - STATIC = false; - JDK_VERSION = "17"; +public record Rule(char lhs, List rhs) { + public Rule(char lhs, Element... rhs) { + this(lhs, List.of(rhs)); + } + public boolean matches(List stack) { + if (stack.size() < rhs.size()) return false; + for (int i = 0; i < rhs.size(); i++) { + Element elementInRule = rhs.get(rhs.size() - i - 1); + Element elementInStack = stack.get(stack.size() - i - 1); + if (!elementInRule.equals(elementInStack)) { + return false; + } + } + return true; + } } +``` -PARSER_BEGIN(Calculator) -package com.github.kmizu.calculator; -public class Calculator { - public static void main(String[] args) throws ParseException { - Calculator parser = new Calculator(System.in); - parser.start(); - } -} -PARSER_END(Calculator) - -SKIP : { " " | "\t" | "\r" | "\n" } -TOKEN : { - -| -| -| -| -| -| -} -o -public int expression() : -{int r = 0;} -{ - r=add() { return r; } +可変長引数を受け取るコンストラクタは規則を簡単に記述するためのものです。`matches`メソッドはスタックの状態と規則がマッチするかを判定します。 + +`Element`は終端記号や非終端記号を表すクラスで、以下のように定義されます。 + +```java +public sealed interface Element + permits Element.Terminal, Element.NonTerminal { + public record Terminal(char symbol) implements Element {} + + public record NonTerminal(char name) implements Element {} } +``` -public int add() : -{int r = 0; int v = 0;} -{ - r=mult() ( v=mult() { r += v; }| v=mult() { r -= v; })* { - return r; +これらのクラスを使ってシフトと還元を行うクラス`Dyck`は次のように定義できます。 + +```java +public class Dyck { + private final String input; + private int position; + private final List rules; + + private final List stack = new ArrayList<>(); + + public Dyck(String input) { + this.input = input; + this.position = 0; + this.rules = List.of( + // D → $ P $ + new Rule('D', new Terminal('$'), new NonTerminal('P'), new Terminal('$')), + // D → $ ε $ + new Rule('D', new Terminal('$'), new Terminal('$')), + // P → P X + new Rule('P', new NonTerminal('P'), new NonTerminal('X')), + // P → X + new Rule('P', new NonTerminal('X')), + // X → ( X ) + new Rule('X', new Terminal('('), new NonTerminal('X'), new Terminal(')')), + // X → () + new Rule('X', new Terminal('('), new Terminal(')')) + ); } -} + public boolean parse() { + stack.add(new Terminal('$')); + while (true) { + if (!tryReduce()) { + if (position < input.length()) { + stack.add(new Terminal(input.charAt(position))); + position++; + } else { + break; + } + } + } + stack.add(new Terminal('$')); + while (tryReduce()) { + // 還元を試みる + } + return stack.size() == 1 && stack.get(0).equals(new NonTerminal('D')); + } -public int mult() : -{int r = 0; int v = 0;} -{ - r=primary() ( v=primary() { r *= v; }| r=primary() { r /= v; })* { - return r; + private boolean tryReduce() { + for (Rule rule : rules) { + if (rule.matches(stack)) { + for (int i = 0; i < rule.rhs().size(); i++) { + stack.remove(stack.size() - 1); + } + stack.add(new NonTerminal(rule.lhs())); + return true; + } + } + return false; } } +``` -public int primary() : -{int r = 0; Token t = null;} -{ -( - r=expression() -| t= { r = Integer.parseInt(t.image); } -) { return r; } -} +このプログラムでは、入力文字列を1文字ずつシフトしながら、還元を行っています。規則にマッチする場合はスタックから右辺の要素を取り除き、左辺の非終端記号をプッシュします。最終的にスタックに`NonTerminal('D')`だけが残れば、入力文字列が文法に従っていることが確認できます。 + +## 5.6 下向き構文解析と上向き構文解析の比較 + +下向き構文解析法と上向き構文解析法は得手不得手があります。 + +下向き型は規則と関数を対応付けるのが容易なので手書きの構文解析器を書くのに向いています。実際、驚くほど多くのプログラミング処理系の構文解析器は手書きの再帰下降構文解析で実装されています。 + +下向き型は、関数の引数として現在の情報を渡して、引数に応じて構文解析の結果を変化させることが比較的容易です。これは文脈に依存した文法を持った言語を解析するときに有利な性質です。しかし、下向き型は左再帰という形の文法をそのまま処理できないという欠点があります。 + +たとえば、以下のBNFは上向き型だと普通に解析できますが、工夫なしに下向き型で実装すると無限再帰に陥ってスタックオーバーフローします。 +``` +A = A "a" +A = ""; ``` -の部分はトークン定義になります。ここでは、7つのトークンを定義しています。トークン定義の後が構文規則の定義になります。ここでは、 +このような問題を下向き型で解決する方法も存在します。 -- `expression()` -- `add()` -- `mult()` -- `primary()` +たとえば、上の文法を以下のように書き換えれば下向き型でも問題なく解析できるようになります。このような処理を左再帰の除去と呼びます。 -の4つの構文規則が定義されています。各構文規則はJavaのメソッドに酷似した形で記述されますが、実際、ここから生成される.javaファイルには同じ名前のメソッドが定義されます。`expression()`が`add()`を呼び出して、`add()`が`mult()`を呼び出して、`mult()`が`primary()`を呼び出すという構図は第2章で既にみた形ですが、第2章と違って単純に宣言的に各構文規則の関係を書けばそれでOKなのが構文解析器生成系の強みです。 +通常のプログラミング言語でも直線的な再帰は常にループに変換可能ですが、原理的にはそれと似たようなものです。 -このようにして定義した電卓プログラムは次のようにして利用することができます。 +``` +A = "a" A + | ""; +``` -```java -package com.github.kmizu.calculator; +さて、上向き型は左再帰を問題なく処理できるので、このような文法をそのまま解析できるわけです、では上向き型はすべての文法に対して有利なのでしょうか?ことはそう単純ではありません。 -import jdk.jfr.Description; -import org.junit.jupiter.api.Test; -import com.github.kmizu.calculator.Calculator; -import java.io.*; +たとえば、それまでの文脈に応じて構文解析のルールを切り替えたくなることがあります。最近の言語によく搭載されている文字列補間などはその最たる例です。 -import static org.junit.jupiter.api.Assertions.assertEquals; +`"`の中は文字列リテラルとして特別扱いされますが、その中で`#{`が出てきたら(Rubyの場合)、通常の式を構文解析するときのルールに戻る必要があります。 -public class CalculatorTest { - @Test - @Description("1 + 2 * 3 = 7") - public void test1() throws Exception { - Calculator calculator = new Calculator(new StringReader("1 + 2 * 3")); - assertEquals(7, calculator.expression()); - } +このように、文脈に応じて適用するルールを切り替えるのは下向き型が得意です。もちろん、上向き型でも実現できないわけではありません。実際、Rubyの構文解析機はYaccの定義ファイルから生成されるようになっていますが、Yaccが採用しているのは代表的な上向き構文解析法である`LALR(1)`です。 - @Test - @Description("(1 + 2) * 4 = 12") - public void test2() throws Exception { - Calculator calculator = new Calculator(new StringReader("(1 + 2) * 4")); - assertEquals(12, calculator.expression()); - } +ともあれ、下向きと上向きには異なる利点と欠点があります。 - @Test - @Description("(5 * 6) - (3 + 4) = 23") - public void test3() throws Exception { - Calculator calculator = new Calculator(new StringReader("(5 * 6) - (3 + 4)")); - assertEquals(23, calculator.expression()); - } +次からは具体的なアルゴリズムの説明に移ります。 + +## 5.7 LL(1) - 代表的な下向き構文解析アルゴリズム + +下向き型構文解析法の中でおそらくもっとも古典的で、よく知られているのは`LL(1)`法です。`LL`は**Left-to-right, Leftmost derivation**の略で、左から右へ文字列をスキャンしながら**最左導出**を行うことを意味しています。最左導出は第4章で説明しましたね。 + +`LL(1)`の`1`は「1トークン先読み」を意味しています。つまり、`LL(1)`法は、次の1トークンを見て、最左導出を行うような構文解析手法です。この手法は手書きの**再帰下降構文解析**によって簡単に実装できるため、構文解析手法の中でも単純なものと言えるでしょう。字面が一見小難しく見えますが、`LL(1)`のアイデアは意外に簡単なものです。 + +たとえば、以下のようなJava言語のif文があったとします。 + +```java +if(age < 18) { + System.out.println("18歳未満です"); +} else { + System.out.println("18歳以上です"); } ``` -この`CalculatorTest`クラスではJUnit5を使って、JavaCCで定義した`Calculator`クラスの挙動をテストしています。空白や括弧を含む数式を問題なく計算できているのがわかるでしょう。 +我々はどのようにしてこれを見て「if文がある」と認識するのでしょうか。もちろん「人それぞれ」なのですが、最初にキーワード`if`が現れたからif文だと考える人も多いのではないかと思います。 + +`LL(1)`構文解析アルゴリズムはまさにこのイメージを元にした手法です。プログラムをトークン列に区切った後に、「最初の1トークン」を見て、「これはif文だ」とか「これはwhile文だ」とか認識するようなものですね。 + +イメージとしては簡単なのですが、アルゴリズムとして実行可能なようにするためには考えなければいけない論点がいくつかあります。以下では、`LL(1)`を実装するに当たって考えなければいけない課題について論じます。 + +### 課題1 - ある構文の最初のトークンが複数種類ある場合 + +先程の例ではある構文、たとえばif文が始まるには`if`というキーワードが必須で、それ以外の方法でif文が始まることはありえませんでした。 + +つまり、`if`というトークンが先頭に来たら、それはif文であると確定できるわけです。 + +しかし、問題はそう単純ではありません。if文の他に符号付き整数および加減乗除のみからなる算術式を構文解析をすることを考えてみましょう。 + +算術式は以下のいずれかで始まります。 + +- `(` +- `-` +- `+` +- 整数リテラル(``) + +つまり、算術式の始まりは複数のトークンで表されます。このような場合、最初のトークンとの一致比較だけでは「これは算術式だ」と確定できません。 + +あるトークンが算術式の始まりである事を確定するためには、トークンの集合という概念が必要になります。 + +たとえば、算術式の始まりは以下のようなトークンの集合で表されます。 + +```text +{"(", "-", "+", } +``` + +このように、ある構文が始まるかを決定するために必要なトークンの集合のことを**FIRST集合**と呼びます。 + +**FIRST集合**は非終端記号ごとに定義されます。以降では、非終端記号`N`に対して定義されるFIRST集合を`FIRST(N)`と表します。 + +たとえば、規則$A$の右辺が複数あって以下のようになっているとします。 + +$$ +\begin{array}{l} +A \rightarrow B \\ +A \rightarrow C +\end{array} +$$ + +このとき、 + +$$ +\begin{array}{l} +FIRST(B) \cap FIRST(C) = \emptyset +\end{array} +$$ + +が成り立てば、先頭1トークンだけを「先に見て」$B$を選ぶか$C$を選ぶかを安全に決定することができます。 + +この「先を見る」(Lookahead)という動作がLL(1)のキモです。 + +バックトラックしない下向き型の場合「あ。間違ってたので別の選択肢をためそう」ということができませんから、必然的に一つ先を読んで分岐する必要があるのです。 -このようなケースでは先読みトークン数が1のため、JavaCCのデフォルトで構いませんが、定義したい構文によっては先読み数を2以上に増やさなければいけないこともあります。そのときは、以下のようにして先読み数を増やすことができます: +一つ先を読んで安全に分岐を選べるためには、分岐の先頭にあるトークンがお互いに重なっていないことが必要条件になります。この「お互いに重なっていない」というのが、 + +$$ +\begin{array}{l} +FIRST(B) \cap FIRST(C) = \emptyset +\end{array} +$$ + +であらわされる条件です。 + +### 課題2 - 省略可能な要素の扱い + +if文であるかどうかは、先頭の1トークンを見ればわかります。しかし、if文であるとして、if-else文なのかelseがない単純なif文であるかどうかを判定するのはどうすればいいでしょうか。たとえば、以下の文は正当です。 ```java -options { - STATIC = false; - JDK_VERSION = "17"; - LOOKAHEAD = 2 +if (age < 18) { + System.out.println("18歳未満です"); } ``` -ここでは、`LOOKAHEAD = 2`というオプションによって、先読みトークン数を2に増やしています。LOOKAHEADは固定されていれば任意の正の整数にできるので、JavaCCはデフォルトではLL(1)だが、オプションを設定することによってLL(k)になるともいえます。 +以下の文も正当です。 -また、JavaCCは構文定義ファイルの文法がかなりJavaに似ているため、生成されるコードの形を想像しやすいというメリットがあります。JavaCCはJavaの構文解析生成系の中では最古の部類の割に今でも現役で使われているのは、Javaユーザにとっての使いやすさが背景にあるように思います。 +```java +if (age < 18) { + System.out.println("18歳未満です"); +} else { + System.out.println("18歳以上です"); +} +``` +最初のif文の後に、 -## 5.4 Yacc (GNU Bison):構文解析器生成系の老舗 +- 前者はelseが出現しない +- 後者はelseが出現する -YaccはYet another compiler compilerの略で、日本語にすると「もう一つのコンパイラコンパイラ」といったところでしょうか。yaccができた当時は、コンパイラを作るためのコンパイラについての研究が盛んだった時期で、構文解析器生成系もそのための研究の副産物とも言えます。1970年代にAT&Tのベル研究所にいたStephen C. Johnsonによって作られたソフトウェアで、非常に歴史がある構文解析器生成系です。YaccはLALR(1)法をサポートし、lexという字句解析器生成系と連携することで構文解析器を生成することができます(もちろん、lexを使わない実装も可能)。Yacc自体は色々な構文解析器生成系に多大な影響を与えており、現在使われているGNU BisonはYaccのGNUによる再実装でもあります。 +という違いがあります。 -Yaccを使って、四則演算を行う電卓プログラムを作るにはまず字句解析器生成系であるflex用の定義ファイルを書く必要ががあります。その定義ファイル`token.l`は次のようになります: +つまり、elseが出現するかどうかで、どの規則を適用するかを決定する必要があります。このような場合、次のトークンを見て、elseならif-else文、そうでなければ単純なif文と解釈します。 -```text -%{ -#include "y.tab.h" -%} +この判断を行うためには**FIRST集合**だけでは不十分です。elseが省略される可能性があるためです。elseが省略可能かどうかを示す情報が必要です。 -%% -[0-9]+ { yylval = atoi(yytext); return NUM; } -[-+*/()] { return yytext[0]; } -[ \t] { /* ignore whitespce */ } -"\r\n" { return EOL; } -"\r" { return EOL; } -"\n" { return EOL; } -. { printf("Invalid character: %s\n", yytext); } -%% +ある要素が省略可能かどうかを示す情報を**nullable**と呼びます。**nullable**は非終端記号が空文字列を生成可能かどうかを示す情報です。たとえば、以下の規則があるとします。 + +$$ +A → a \\ +A → b \\ +A → ε \\ +$$ + +$A$は空文字列を生成可能です。このような場合、$A$はnullableであると言います。 + +さて、この言い方に従うと + +```java +else { + System.out.println("18歳以上です"); +} ``` -`%%`から`%%`までがトークンの定義です。これはそれぞれ次のように読むことができます。 +この部分は**nullable**であると言えます。 + +このような**nullable**な要素の次に出現し得るトークンの集合を**FOLLOW集合**と呼びます。**FOLLOW集合**は非終端記号ごとに定義されます。以降では、非終端記号$N$に対して定義される**FOLLOW集合**を$FOLLOW(N)$と表します。 + +次の項では、**FIRST集合**と**FOLLOW集合**の概念についてより厳密に説明します。 + +### FIRST集合とFOLLOW集合の計算 + +LL(1)をアルゴリズムとしてきちんと定義しようとするなら、この二つの概念が必要であることはわかってもらえたのではないかと思います。しかし、この二つですが、プログラム上でどう計算すれば良いのでしょうか?この問いに答えることがLL(1)アルゴリズムをきちんと理解することであり、逆にきちんと理解できれば、自力でLL(1)アルゴリズムによるパーサを記述できるようになるでしょう。 + +まずはFIRST集合について考えてみます。単純化のために以下のような規則を仮定します。 + +$$ +A \rightarrow \alpha_1 \\ +A \rightarrow \alpha_2 \\ +... \\ +A \rightarrow \alpha_n \\ +$$ + +$\alpha_i$は終端記号や非終端記号の並びです。$FIRST(A)$を求めるには以下の手順を用います。 + +1. FIRST集合を空集合に初期化する。 +2. 各生成規則$`A \rightarrow \alpha_i`$について、次を行う: + - $\alpha_i$の最初の記号$X_1$が終端記号ならば、FIRST(A)に$X_1$を追加する。 + - $X_1$が非終端記号の場合、$FIRST(X1)$を計算し、$FIRST(A)$に$FIRST(X1)$を追加する。 + - $X_1$が$nullable$である場合、次の記号$X_2$について同様の処理を行う。 + +$nullable$については先程軽く述べましたが、ある非終端記号が空文字列を生成可能かどうかを示すものです。$nullable$の計算は以下の手順で行います。 + +1. 全ての非終端記号をfalse(nullableでない)に初期化する。 +2. 生成規則$`A \rightarrow \alpha`$について、$`\alpha`$が空(ε)なら$nullable(A)$をtrueに設定する。 +3. 生成規則$`A \rightarrow \alpha`$($\alpha = \beta_1 \beta_2 ... \beta_n$)について、$\beta_1 ... \beta_n`$が全て$nullable$なら$nullable(A)$をtrueに設定する。 +4. 値が変化しなくなるまで2と3を繰り返す。 + +次に**FOLLOW集合**の計算です。$FOLLOW(A)$は、非終端記号$A$の後に現れる可能性のある終端記号の集合です。計算手順は以下の通りです。 + +1. FOLLOW集合を空集合に初期化する。 +2. 開始記号のFOLLOW集合に入力の終端記号$を追加する。 +3. 各生成規則$`B \rightarrow \alpha A \beta`$について、以下を行う: + - $`FIRST(β)`$からεを除いたものを$FOLLOW(A)$に追加する。 + - $nullable(\beta)$ならば、$FOLLOW(B)$を$FOLLOW(A)$に追加する。 +4. 値が変化しなくなるまで3を繰り返す。 + +この$FIRST$と$FOLLOW$を用いて、LL(1)構文解析表を作成します。 + +### LL(1)構文解析表の作成 + +構文解析表は、非終端記号と入力の次のトークン(終端記号)の組み合わせで、次にどの生成規則を適用すべきかを示すものです。構文解析表の作成手順は以下の通りです。 + +1. 各生成規則$`A \rightarrow α`$について、次を行う: + - $`FIRST(α)`$からεを除いた全ての終端記号aに対して、表の項目$`[A, \alpha]`$に規則$`A \rightarrow \alpha`$を入れる。 + - もし$`ε \in FIRST(α)`$なら、$`FOLLOW(A)`$の全ての終端記号$\beta$に対して、表の項目$`[A, \beta]`$に規則$`A \rightarrow α`$を入れる。 +2. 構文解析表において、同じ項目に複数の規則が入る場合、その文法はLL(1)ではない + +つまり、LL(1)構文解析表が作成できるかどうかは、その文法がLL(1)であるかどうかの判定に等しいと言えます。 + +### LL(1)の問題点と限界 + +`LL(1)`は古典的でありかつ実用的でもありますが、アルゴリズムがシンプルである故の問題点や限界も存在します。この節では`LL(1)`の抱える問題点について述べます。 + +### 問題点1 - 最初の1トークンで構文要素を決められないことがある + +例えば、以下の文法を考えてみましょう。 + +$$ +\begin{array}{lll} +S & \rightarrow & a B \\ +S & \rightarrow & a C \\ +B & \rightarrow & b \\ +C & \rightarrow & c +\end{array} +$$ + +$S$の最初のトークンは常に$a$で、その次のトークンが$b$なら$B$を、$c$なら$C$を選択します。最初の1トークンだけではどちらの規則を適用すべきか決められません。 + +この問題は**左因子化**(left factoring)によって解決できます。左因子化では共通部分をくくり出すことで、LL(1)で解析可能な文法に変換します。 + +$$ +\begin{array}{lll} +S & \rightarrow & a S' \\ +S' & \rightarrow & A \\ +S' & \rightarrow & B \\ +B & \rightarrow & b \\ +C & \rightarrow & c +\end{array} +$$ + +しかし、左因子化は常に適用できるわけではありません。 + +### 問題点2 - 左再帰の問題 + +$LL(1)$では左再帰を含む文法を扱うことができません。例えば、以下の文法は左再帰を含んでいます。 + +$$ +\begin{array}{lll} +E & \rightarrow & E\ +\ T \\ +E & \rightarrow & T \\ +\end{array} +$$ + +この文法はEの定義の最初にE自身が出現しています。左再帰を除去することでLL(1)で扱えるように変換できます。 + +$$ +\begin{array}{lll} +E & \rightarrow T\ E'\\ +E' & \rightarrow +\ T\ E'\\ +E' & \rightarrow \epsilon \\ +\end{array} +$$ -- `[0-9]+ { yylval = atoi(yytext); return NUM; }` +左再帰の除去は多くの場合に可能ですが、変換作業が煩雑になることも少なくありません。 - 0-9までの数字が1個以上あった場合はに数値として解釈し(`atoi(yytext)`)、トークン`NUM`として返します。 +## 5.8 LL(k) - LL(1)の拡張 -- `[-+*/()] { return yytext[0]; }` +LL(1)の限界を克服するために、LL(k)という概念が導入されました。kは先読みするトークン数を示します。kを増やすことで、より複雑な文法を扱えるようになりますが、解析表のサイズや計算量が増加します。 - `-`,`+`,`*`,`/`,`(`,`)`についてはそれぞれの文字をそのままトークンとして返します。 +しかし、LL(k)でもすべての文脈自由言語を扱えるわけではありません。たとえば、文脈自由言語の例として以下のようなものがあります。 -- `[ \t] { /* ignore whitespce */ }` +$$ +a^i b^j (i >= j >= 1) +$$ - タブおよびスペースは単純に無視します +これは$a$が$i$回あらわれてその後に$b$が$j$回あらわれるような言語ですが、$LL(k)$言語ではありません。後述しますが、$LR(1)$言語にはなるので、純粋に言語としての表現能力を考えると$LL(k)$は$LR(1)$よりも弱い言語であると言えます。 -- `"\r\n" { return EOL; }` +## 5.9 SLR - 単純な上向き構文解析アルゴリズム - 改行はEOLというトークンとして返します +SLR(Simple LR)は、LR法の中でも最も単純な手法です。LRは**L**eft-to-right(左から右への入力)と**R**ightmost derivation(最右導出)の略です。 Left-to-rightは「左から右に文字列を読み込む」を指します。これは直感的にわかりやすいでしょう。Rightmost derivationは「最右導出を行う」という意味です。 -- `"\r" { return EOL; }` +最右導出については第4章で説明しましたが、簡単に復習しておきましょう。最右導出は文法規則を適用していく際に、常に右側から展開していくことを指します。たとえば、以下の文脈自由文法を考えてみましょう: - 前に同じ +$$ +S -> aSSb | c +$$ +$$ +\begin{array}{lll} +S & \rightarrow aSSb\\ +S & \rightarrow c\\ +\end{array} +$$ -- `"\n" { return EOL; }` +この文法に対して、最右導出で`accb`という文字列を生成する過程は以下のようになります: - 前に同じ +$$ +\begin{align*} +S & \Rightarrow aSSb & (S \rightarrow aSSbを適用) \\ +aSSB & \Rightarrow aScb & (S \rightarrow cを適用) \\ +aScb & \Rightarrow accb & (S \rightarrow cを適用) +\end{align*} +$$ -- `. { printf("Invalid character: %s\n", yytext); }` +第4章で説明したように、$S$を展開するときに、一番右の$S$を展開していますね。これが「最右導出」ということです。最右導出の過程を逆にたどることが `SLR`ひいては`LR`法の基本的な考え方です。 - それ以外の文字が来たらエラーを吐きます +もちろん、これだけだとわけがわからないという方が大半ではないかと思います。しかし、読者の皆さんがこの章で既に見たシフト還元構文解析が理解できていれば、その入口に立ったようなものです。 -このトークン定義ファイルを入力として与えると、flexは`lex.yy.c`という形で字句解析器を出力します。 +なぜなら、シフト還元構文解析の「シフト」が上でいうLeft-to-right、「還元」がRightmost derivationに対応しているからです。シフトは左から右に文字列を読み込むことを意味し、還元は最右導出の逆を行うことを意味します。SLRはシフト還元構文解析の考え方をより厳密に定式化したものと言えるでしょう。 -次に、yaccの構文定義ファイルを書きます: +しかし、素朴なシフト還元構文解析とSLR法が大きく異なる点があります。 + +それは構文解析表と呼ばれる表を用いてシフトするか還元するかを決定する点です。素朴なシフト還元構文解析ではスタックを毎回スキャンしていましたが、これではスタックのサイズが大きくなってきたときに遅くなるのが想像できますね。 + +一方、SLRでは構文解析表を使ってシフトするか還元するかを決定するため、効率的に構文解析を行うことができます。 + +では、SLRではどのように構文解析表を作り出すのでしょうか?そのための道具立てを次節以降で説明します。 + +### LR(0)項 + +SLRでは、文法規則を`LR(0)項`(以下、単に項と書きます)という形に変換します。項は規則の右辺にドット(・)を挿入したものです。たとえば: + +``` +E -> E + T +``` + +この規則に対して、以下のような項を作ることができます: + +``` +E -> . E + T, +``` + +ドットは現在の解析位置を示します。1つの項は構文解析の途中経過を表すものと見ることができます。この項はEの右辺の最初の記号を読み込もうとしている状態です。 + +さらに、項集合という概念を導入します。規則 + +``` +E -> E + T +``` + +から得られる項の集まりは以下のようになります: + +``` +1. E -> . E + T +2. E -> E . + T +3. E -> E + . T +4. E -> E + T . +``` + +ある規則から得られる項集合は、その規則の左辺に対応する構文解析の途中状態の集合と見ることができます。この規則の右辺は3つの要素からなるため、4つの項が得られます。 + +よりわかりやすく言うなら、4つの項は以下のような状態を表しています: + +1. Eの右辺の最初の記号を読み込もうとしている +2. Eの右辺の最初の記号を読み込んだ後、+を読み込もうとしている +3. Eの右辺の最初の記号と+を読み込んだ後、Tを読み込もうとしている +4. Eの右辺の最初の記号と+とTを読み込んだ後。還元しようとしている + +### 状態の構築 + +SLRでは、項目の集合を「状態」として扱います。初期状態から始めて、遷移を繰り返すことで全ての状態を構築します。 +例として、以下の簡単な文法を考えてみましょう: ```text -%{ -#include -int yylex(void); -void yyerror(char const *s); -int yywrap(void) {return 1;} -extern int yylval; -%} - -%token NUM -%token EOL -%left '+' '-' -%left '*' '/' - -%% - -input : expr EOL - { printf("Result: %d\n", $1); } - ; - -expr : NUM - { $$ = $1; } - | expr '+' expr - { $$ = $1 + $3; } - | expr '-' expr - { $$ = $1 - $3; } - | expr '*' expr - { $$ = $1 * $3; } - | expr '/' expr - { - if ($3 == 0) { yyerror("Cannot divide by zero."); } - else { $$ = $1 / $3; } - } - | '(' expr ')' - { $$ = $2; } - ; - - -void yyerror(char const *s) { - fprintf(stderr, "Parse error: %s\n", s); + S -> E, + E -> E + T | T, + T -> x } +``` -int main() +この文法に対して入力を解析するときの初期状態は以下のようになります: + +```text { - yyparse(); + S -> . E, + E -> . E + T, + E -> . T, + T -> . x } ``` -flexの場合と同じく、`%%`から`%%`までが構文規則の定義の本体です。実行されるコードが入っているので読みづらくなっていますが、それを除くと以下のようになります: +この状態から、各記号(E, T, x)に対する遷移を考えます。例えば、xに対する遷移は以下のようになります: -```text -% { -%token NUM -%token EOL -%left '+' '-' -%left '*' '/' +``` +{ + S -> . E, + E -> . E + T, + E -> . T, + T -> x . } +``` -%% -input : expr EOL - { printf("Result: %d\n", $1); } - ; +遷移元の状態、つまり項集合と比較して、最後の項がxを読み込んでいる状態になっていることがわかります。このようにして、各記号に対する遷移を考えていくことで、全ての状態を構築していきます。 -expr : NUM - | expr '+' expr - | expr '-' expr - | expr '*' expr - | expr '/' expr - | '(' expr ')' - ; -%% -``` +### 閉包(Closure) -だいぶ見やすくなりましたね。入力を表す`input`規則は`expr EOL`からなります。`expr`は式を表す規則ですから、その後に改行が来れば`input`は終了となります。 +状態を構築する際、ある非終端記号に対する項目がある場合、その非終端記号から始まる全ての規則も状態に含める必要があります。これを閉包操作と呼びます。 -次に、`expr`規則ですが、ここではyaccの優先順位を表現するための機能である`%left`を使ったため、優先順位のためだけに規則を作る必要がなくなっており、定義が簡潔になっています。ともあれ、こうして定義された文法定義ファイルをyaccに与えると`y.tab.c`というファイルを出力します。 +閉包操作は以下の手順で行います: -flexとyaccが出力したファイルを結合して実行ファイルを作ると、次のような入力に対して: +1. 現在の状態に含まれる全ての項について、ドットの直後にある非終端記号を取得 +2. その非終端記号に対応する全ての規則を取得 +3. それらの規則を新しい項として状態に追加 +4. 項集合が変化しなくなるまで、1から3を繰り返す +### GOTO関数 + +SLR(Simple LR)法において、GOTO関数は構文解析器の中心的な役割を果たす重要な概念です。GOTO関数は、現在の状態と入力記号に基づいて次の状態を決定する関数です。具体的には、ある状態から特定の記号を「読む」(読むとはその記号に関連するシフトまたは非終端記号の展開を意味します)ことで遷移する先の状態を指し示します。 + +GOTO関数は次のように定義されます: + +```text +GOTO(I, X) = J ``` -1 + 2 * 3 -2 + 3 -6 / 2 -3 / 0 + +ここで、 + +- I は現在の状態(項目集合) +- X は遷移の基となる記号(終端記号または非終端記号)、 +- J は遷移後の状態(新しい項目集合) + +です。 + +#### GOTO関数の計算方法 + +GOTO関数を計算するためには、現在の状態Iに含まれる項目に対して、特定の記号Xがドット(・)の直後に現れるものを探し、そのドットを1つ右に移動させた新しい項目を生成します。これらの新しい項目の閉包を取り、その結果が遷移後の状態Jとなります。 + +具体的な手順は以下の通りです: + +1. 項目の選択: 現在の状態Iに含まれるすべての項目`A → α・Xβ`を選びます。Xは終端記号または非終端記号です。 +2. ドットの移動: 選択した各項目について、ドットを1つ右に移動させた項目`A → αX・β`を生成します。 +3. 閉包の計算: 生成した新しい項目集合に対して閉包操作を行います。これは、非終端記号 β が現れた場合、その非終端記号に関連するすべての規則を追加する操作です。 +4. 新しい状態の確定: 閉包操作後の項目集合が新しい状態Jとなります。 + +#### 例でみるGOTO関数の適用 + +具体的な文法と項目集合を用いてGOTO関数の動作を確認してみましょう。 + +##### 文法: + +```text +S -> E +E -> E + T +E -> T +T -> id ``` -それぞれ、次のような出力を返します。 +##### 項目集合の例: + +例えば、ある状態 I が以下の項目を含んでいるとします: +```text +1. S -> . E +2. E -> . E + T +3. E -> . T +4. T -> . id ``` -Result: 7 -Result: 5 -Result: 3 -Parse error: Cannot divide by zero. + +この状態Iに対して、記号Eに対するGOTO関数`GOTO(I, E)`を計算してみます。 + +1. 項目の選択: + +- `S -> . E` (ドットの直後が E) +- `E -> . E + T` (ドットの直後が E) + +2. ドットの移動: + +- `S -> E .` +- `E -> E . + T` + +3. 閉包の計算: + +- `S -> E .`はドットが右端にあるため、閉包には新たな項目は追加されません。 +- `E -> E . + T`はドットの直後が + なので、閉包には新たな項目は追加されません。 + +したがって、GOTO関数 GOTO(I, E) によって生成される新しい状態 J は以下の項目を含みます: + +```text +1. S → E . +2. E → E . + T ``` -yaccはとても古いソフトウェアの一つですが、Rubyの文法定義ファイルparse.yはyacc用ですし、未だに各種言語処理系では現役で使われてもいます。 +のように、GOTO関数は現在の状態と特定の記号に基づいて新しい状態を導出します。 -## 5.5 ANTLR:多言語対応の強力な下向き構文解析生成系 +### 構文解析表 -1989年にPurdue Compiler Construction set(PCCTS)というものがありました、ANTLRはその後継というべきもので、これまでに、LL(k) -> LL(*) -> ALL(*)と取り扱える文法の幅を広げつつアクティブに開発が続けられています。作者はTerence Parrという方ですが、構文解析器一筋(?)と言っていいくらい、ANTLRにこれまでの時間を費やしてきている人です。 +SLR法における構文解析表は、構文解析器が入力を解析する際に必要なアクション(シフト、還元、受理、エラー)を決定するための表です。この表は、各状態と各入力記号の組み合わせに対して、どのアクションを取るべきかを示します。 -それだけに、ANTLRの完成度は非常に高いものになっています。また、一時期はLR法に比べてLL法の評価は低いものでしたが、Terence ParrがLL(k)を改良していく過程で、LL(*)やALL(*)のようなLR法に比べてもなんら劣らない、しかも実用的にも使いやすい構文解析法の発明に貢献したということができます。 +構文解析表は主に2つの部分から構成されます: -ANTLRはJava、C++などいくつもの言語を扱うことができますが、特に安心して使えるのはJavaです。以下は先程と同様の、四則演算を解析できる数式パーザをANTLRで書いた場合の例です。 +1. ACTION表:終端記号に対するアクションを示す部分。 +2. GOTO表:非終端記号に対する次の状態を示す部分。 -ANTLRでは構文規則は、`規則名 : 本体 ;` という形で記述しますが、LLパーザ向けの構文定義を素直に書き下すだけでOKです。 +#### 構文解析表の作成手順 -```java -grammar Expression; +構文解析表を作成する手順は以下の通りです: -expression returns [int e] - : v=additive {$e = $v.e;} - ; +TBD -additive returns [int e = 0;] - : l=multitive {$e = $l.e;} ( - '+' r=multitive {$e = $e + $r.e;} - | '-' r=multitive {$e = $e - $r.e;} - )* - ; +### 構文解析の実行 -multitive returns [int e = 0;] - : l=primary {$e = $l.e;} ( - '*' r=primary {$e = $e * $r.e;} - | '/' r=primary {$e = $e / $r.e;} - )* - ; +構文解析表を使って、実際の入力文字列を解析します。スタックと入力バッファを用いて、以下の操作を繰り返します: -primary returns [int e] - : n=NUMBER {$e = Integer.parseInt($n.getText());} - | '(' x=expression ')' {$e = $x.e;} - ; +- 現在の状態と次の入力記号に基づいて、構文解析表からアクションを決定 +- アクションにしたがって、シフトまたは還元を実行 +- 受理に到達したら解析成功、エラーが発生したら解析失敗 -LP : '(' ; -RP : ')' ; -NUMBER : INT ; -fragment INT : '0' | [1-9] [0-9]* ; // no leading zeros -WS : [ \t\n\r]+ -> skip ; +### 具体例 -``` +TBD -規則`expression`が数式を表す規則です。そのあとに続く`returns [int e]`はこの規則を使って解析を行った場合に`int`型の値を返すことを意味しています。これまで見てきたように構文解析器をした後には抽象構文木をはじめとして何らかのデータ構造を返す必要があります。`returns ...`はそのために用意されている構文です。名前が全て大文字の規則はトークンを表しています。 +このように、SLR(0)法では構文解析表を参照しながら、入力文字列を左から右に読み込み、適切なタイミングでシフトと還元を繰り返すことで構文解析を行います。 +SLR(0)法は比較的単純ですが、扱える文法に制限があります。次にSLR(0)を拡張して先読みを考慮したSLR(1)法について説明します。 -数式を表す各規則についてはこれまで書いてきた構文解析器と同じ構造なので読むのに苦労はしないでしょう。 +## 5.10 SLR(1) - 先読みを考慮した上向き構文解析アルゴリズム -規則`WS`は空白文字を表すトークンですが、これは数式を解析する上では読み飛ばす必要があります。 `[ \t\n\r]+ -> skip`は +SLR(1)(Simple LR(1))法は、SLR(0)法を先読み情報で強化した上向き構文解析アルゴリズムです。SLR(1)法では、LR(0)項目集合を用いて状態を構築し、さらに文法のFOLLOW集合を利用して解析表を作成します。これにより、LR(0)法で生じるシフト・還元(shift-reduce)コンフリクトや還元・還元(reduce-reduce)コンフリクトを解消することが可能になります。 -- スペース -- タブ文字 -- 改行文字 +### 構文解析表の作成方法 -のいずれかが出現した場合は読み飛ばすということを表現しています。 +それでは、SLR(1)法における構文解析表の作成手順を詳しく説明していきましょう。 -ANTLRは下向き型の構文解析が苦手とする左再帰もある程度扱うことができます。先程の定義ファイルでは繰り返しを使っていましたが、これを左再帰に直した以下の定義ファイルも全く同じ挙動をします。 +#### 手順1: LR(0)項目集合の構築 -```java -grammar LRExpression; +まず、与えられた文法についてLR(0)項目集合を構築します。LR(0)項目は、ドット(・)の位置で解析の進行状況を示すものでしたね。この項目集合と状態遷移を構築する際には、前節で説明した閉包とGOTO関数を用います。 -expression returns [int e] - : v=additive {$e = $v.e;} - ; +#### 手順2: FOLLOW集合の計算 -additive returns [int e] - : l=additive op='+' r=multitive {$e = $l.e + $r.e;} - | l=additive op='-' r=multitive {$e = $l.e - $r.e;} - | v=multitive {$e = $v.e;} - ; +次に、各非終端記号のFOLLOW集合を計算します。FOLLOW集合とは、ある非終端記号の直後に現れうる可能性のある終端記号の集合です。計算手順は以下の通りです。 -multitive returns [int e] - : l=multitive op='*' r=primary {$e = $l.e * $r.e;} - | l=multitive op='/' r=primary {$e = $l.e / $r.e;} - | v=primary {$e = $v.e;} - ; +1. 各非終端記号のFOLLOW集合を空集合に初期化します。 +2. 開始記号のFOLLOW集合に入力の終端記号$を追加します。 +3. 各生成規則`A → αBβ`について、`FIRST(β)`からεを除いたものを`FOLLOW(B)`に追加します。 +4. もしβがε(空文字列)に導出可能であれば、`FOLLOW(A)`を`FOLLOW(B)`に追加します。 +5. 値が変化しなくなるまで、ステップ3と4を繰り返します。 -primary returns [int e] - : n=NUMBER {$e = Integer.parseInt($n.getText());} - | '(' x=expression ')' {$e = $x.e;} - ; +#### 手順3: 構文解析表(ACTION表とGOTO表)の作成 -LP : '(' ; -RP : ')' ; -NUMBER : INT ; -fragment INT : '0' | [1-9] [0-9]* ; // no leading zeros -WS : [ \t\n\r]+ -> skip ; -``` +ACTION表とGOTO表を以下の手順で作成します。 -左再帰を使うことでより簡単に文法を定義できることもあるので、あると嬉しい機能だと言えます。 +#### ACTION表の作成 -さらに、ANTLRは`ALL(*)`というアルゴリズムを採用しているため、通常のLLパーザでは扱えないような文法定義も取り扱うことができます。以下の「最小XML」文法定義を見てみましょう。 +1. シフト動作の設定 -```java -grammar PetitXML; -@parser::header { - import static com.github.asciidwango.parser_book.ch5.PetitXML.*; - import java.util.*; -} +状態Iにおいて、項目`[A → α・aβ]`が含まれている場合、かつaが終端記号であるとき、`GOTO(I, a) = J`であれば、ACTION表の`(I, a)`にshift Jを設定します。 -root returns [Element e] - : v=element {$e = $v.e;} - ; +2. 還元動作の設定 -element returns [Element e] - : ('<' begin=NAME '>' es=elements '' {$begin.text.equals($end.text)}? - {$e = new Element($begin.text, $es.es);}) - | ('<' name=NAME '/>' {$e = new Element($name.text);}) - ; +状態Iにおいて、項目`[A → α・]`(ドットが右端にある項目)が含まれている場合、`A → α`の規則番号をrとします。このとき、`FOLLOW(A)`に含まれる全ての終端記号aに対して、ACTION表の`(I, a)`に`reduce r`を設定します。 -elements returns [List es] - : { $es = new ArrayList<>();} (element {$es.add($element.e);})* - ; +3. 受理動作の設定 -LT: '<'; -GT: '>'; -SLASH: '/'; -NAME: [a-zA-Z_][a-zA-Z0-9]* ; +状態Iにおいて、項目`[S' → S・]`(S'は拡張した開始記号)が含まれている場合、ACTION表の`(I, $)`にacceptを設定します。 -WS : [ \t\n\r]+ -> skip ; -``` +##### GOTO表の作成 + +状態Iにおいて、非終端記号Aに対して`GOTO(I, A) = J`が定義されている場合、GOTO表の`(I, A)`にJを設定します。 + +#### 手順4: コンフリクトの検出 + +構文解析表を作成した後、以下のコンフリクトがないか確認します。 + +- シフト・還元コンフリクト:同じセルにshiftとreduceが存在する場合。 +- 還元・還元コンフリクト:同じセルに複数のreduceが存在する場合。 + +これらのコンフリクトがなければ、その文法はSLR(1)文法であり、SLR(1)構文解析器を構築できます。コンフリクトがある場合は、文法を変更するか、より強力なLR(1)法やLALR(1)法を検討する必要があります。 + +### 具体例 + +具体的な例を用いて、SLR(1)法の構文解析表の作成手順を示します。 -`PeitXML`の名の通り、属性やテキストなどは全く扱うことができず、``や``、``といった要素のみを扱うことができます。規則`element`が重要です。 +以下の文法を考えます。 ``` -element returns [Element e] - : ('<' begin=NAME '>' es=elements '' {$begin.text.equals($end.text)}? - {$e = new Element($begin.text, $es.es);}) - | ('<' name=NAME '/>' {$e = new Element($name.text);}) - ; +1. E → E + T +2. E → T +3. T → T * F +4. T → F +5. F → ( E ) +6. F → id ``` -ここで空要素(``など)に分岐するか、子要素を持つ要素(``など)に分岐するかを決定するには、`<`に加えて、任意の長さになり得るタグ名まだ先読みしなければいけません。通常のLLパーザでは何文字(何トークン)先読みしているのは予め決定されているのでこのような文法定義を取り扱うことはできません。しかし、ANTLRの`ALL(*)`アルゴリズムとその前身となる`LL(*)`アルゴリズムでは任意個の文字数を先読みして分岐を決定することができます。 +この文法は四則演算の式を表現しています。 -ANTLRでは通常のLLパーザで文法を記述する上での大きな制約がないわけで、これは非常に強力です。理論的な意味での記述能力でも`ALL(*)`アルゴリズムは任意の決定的な文脈自由言語を取り扱うことができます。 +#### ステップ1:LR(0)項目集合の構築 -また、`ALL(*)`アルゴリズム自体とは関係ありませんが、XMLのパーザを書くときには開きタグと閉じタグの名前が一致している必要があります。この条件を記述するために`PetitXML`では次のように記述されています。 +各項目集合(状態)を構築します。TBD -```java -'<' begin=NAME '>' es=elements '' {$begin.text.equals($end.text)}? -``` +#### ステップ2:FOLLOW集合の計算 -この中の`{$begin.text.equals($end.text)}?`という部分はsemantic predicateと呼ばれ、プログラムとして書かれた条件式が真になるときにだけマッチします。semantic predicateのような機能はプログラミング言語をそのまま埋め込むという意味で、正直「あまり綺麗ではない」と思わなくもないですが、実用上はsemantic predicateを使いたくなる場面にしばしば遭遇します。 +各非終端記号のFOLLOW集合を計算します。 -ANTLRはこういった実用上重要な痒いところにも手が届くように作られており、非常によくできた構文解析機生成系といえるでしょう。 +- FOLLOW(E) = `{ ), $ }` +- FOLLOW(T) = `{ +, ), $ }` +- FOLLOW(F) = `{ *, +, ), $ }` -## 5.6 SComb +##### ステップ3:構文解析表の作成 -手前味噌ですが、拙作のパーザコンビネータである[SComb](https://github.com/kmizu/scomb)も紹介しておきます。これまで紹介してきたものはすべて構文解析器生成系です。つまり、独自の言語を用いて作りたい言語の文法を記述し、そこから**対象言語**(CであったりJavaであったり様々ですが)で書かれた構文解析器を生成するものだったわけですが、パーザコンビネータは少々違います。 +各状態に対して、ACTION表とGOTO表を作成します。 -パーザコンビネータでは対象言語のメソッドや関数、オブジェクトとして構文解析器を定義し、演算子やメソッドによって構文解析器を組み合わせることで構文解析器を組み立てていきます。パーザコンビネータではメソッドや関数、オブジェクトとして規則自体を記述するため、特別にプラグインを作らなくてもIDEによる支援が受けられることや、対象言語が静的型システムを持っていた場合、型チェックによる支援を受けられることがメリットとして挙げられます。 +- シフト動作:項目`[A → α・aβ]`に基づいて設定。 +- 還元動作:項目`[A → α・]`とFOLLOW(A)に基づいて設定。 -SCombで四則演算を解析できるプログラムを書くと以下のようになります。先程述べたようにSCombはパーザコンビネータであり、これ自体がScalaのプログラム(`object`宣言)でもあります。 +##### ステップ4:コンフリクトの検出 -```scala -object Calculator extends SCombinator { - // root ::= E - def root: Parser[Int] = E +作成した構文解析表を確認し、コンフリクトがないことを確認します。この文法では、SLR(1)法でコンフリクトなく解析可能です。 - // E ::= A - def E: Parser[Int] = rule(A) +#### SLR(1)法の利点と限界 - // A ::= M ("+" M | "-" M)* - def A: Parser[Int] = rule(chainl(M) { - $("+").map { op => (lhs: Int, rhs: Int) => lhs + rhs } | - $("-").map { op => (lhs: Int, rhs: Int) => lhs - rhs } - }) +SLR(1)法は、SLR(0)法よりも多くの文法を扱える一方、LR(1)法よりは弱い解析能力しか持ちません。SLR(1)法でコンフリクトが発生する場合、LR(1)法やLALR(1)法を検討する必要があります。 - // M ::= P ("+" P | "-" P)* - def M: Parser[Int] = rule(chainl(P) { - $("*").map { op => (lhs: Int, rhs: Int) => lhs * rhs } | - $("/").map { op => (lhs: Int, rhs: Int) => lhs / rhs } - }) +## 5.11 LR(1) - 項目集合に先読みを追加したSLR(1)の拡張 - // P ::= "(" E ")" | N - def P: P[Int] = rule{ - (for { - _ <- string("("); e <- E; _ <- string(")")} yield e) | N - } - - // N ::= [0-9]+ - def N: P[Int] = rule(set('0'to'9').+.map{ digits => digits.mkString.toInt}) +LR(1)法は、SLR(1)法をさらに強化した上向き構文解析アルゴリズムです。LR(1)法では、各項目に**先読み記号(lookahead)**を付加します。これにより、解析中の文脈をより正確に把握し、コンフリクトを解消することが可能になります。 - def parse(input: String): Result[Int] = parse(root, input) -} -``` +### LR(1)項目 -各メソッドに対応するBNFによる規則をコメントとして付加してみましたが、BNFと比較しても簡潔に記述できているのがわかります。Scalaは記号をそのままメソッドとして記述できるなど、元々DSLに向いている特徴を持った言語なのですが、その特徴を活用しています。`chainl`というメソッドについてだけは見慣れない読者の方は多そうですが、これは +LR(1)法では、項目は以下の形式で表されます。 -``` -// M ::= P ("+" P | "-" P)* +```text +[A → α・β, a] ``` -のような二項演算を簡潔に記述するためのコンビネータ(メソッド)です。パーザコンビネータの別のメリットとして、BNF(あるいはPEG)に無いような演算子をこのように後付で導入できることも挙げられます。構文規則からの値(意味値)の取り出しもScalaのfor式を用いて簡潔に記述できています。 +- A → αβ:文法規則 +- ・:ドット(現在の解析位置) +- a:先読み記号(終端記号または終端記号の集合) -筆者は自作言語Klassicの処理系作成のためにSCombを使っていますが、かなり複雑な文法を記述できるにも関わらず、SCombのコア部分はわずか600行ほどです。それでいて高い拡張性や簡潔な記述が可能なのは、Scalaという言語の能力と、SCombがベースとして利用しているPEGという手法のシンプルさがあってのものだと言えるでしょう。 +この項目は、「αを既に解析し、次にβを解析しようとしている。さらに、入力の先頭にaが現れることを期待する」という意味を持ちます。 -## 5.7 パーザコンビネータJCombを自作しよう! +### LR(1)項目集合の構築 -コンパイラについて解説した本は数えきれないほどありますし、その中で構文解析アルゴリズムについて説明した本も少なからずあります。しかし、構文解析アルゴリズムについてのみフォーカスした本はParsing Techniquesほぼ一冊といえる現状です。その上でパーザコンビネータの自作まで踏み込んだ書籍はほぼ皆無と言っていいでしょう。読者の方には「さすがにちょっとパーザコンビネータの自作は無理があるのでは」と思われた方もいるのではないでしょうか。 +LR(1)項目集合を構築するために、以下の手順を踏みます。 -しかし、驚くべきことに、現代的な言語であればパーザコンビネータを自作するのは本当に簡単です。きっと、多くの読者の方々が拍子抜けしてしまうくらいに。この節ではJavaで書かれたパーザコンビネータJCombを自作する過程を通じて皆さんにパーザコンビネータとはどのようなものかを学んでいただきます。パーザコンビネータと構文解析器生成系は物凄く雑に言ってしまえば近縁種のようなものですし、パーザコンビネータの理解は構文解析器生成系の仕組みの理解にも役立つはずです。きっと。 +#### 手順1: 初期項目の作成 -まず復習になりますが、構文解析器というのは文字列を入力として受け取って、解析結果を返す関数(あるいはオブジェクト)とみなせるのでした。これはパーザコンビネータ、特にPEGを使ったパーザコンビネータを実装するときに有用な見方です。この「構文解析器はオブジェクトである」を文字通りとって、以下のようなジェネリックなインタフェース`JParser`を定義します。 +開始記号S'に対して、初期項目`[S' → ・S, $]`を作成します。ここで、$は入力の終端記号を表します。 -```java -interface JParser { - Result void parse(String input); -} +#### 手順2: LR(1)閉包(closure)の計算 + +項目集合Iの閉包closure(I)を以下の手順で計算します。 + +1. `closure(I) = I`とする。 +2. `closure(I)`に新しい項目が追加されなくなるまで、以下を繰り返す。 + - `closure(I)`内の各項目`[A → α・Bβ, a]`について、Bが非終端記号であれば、Bのすべての規則`B → γ`に対して、`FIRST(βa)`に含まれる全ての終端記号bについて、項目`[B → ・γ, b]`を`closure(I)`に追加する。 + +#### 手順3: GOTO関数の計算 + +項目集合`I`と記号`X`に対して、`GOTO(I, X)`は以下の項目からなる集合の閉包です。 + +- I内の項目`[A → α・Xβ, a]`に対して、`[A → αX・β, a]`を集めた集合の閉包。 + +#### 手順4: LR(1)項目集合の全体構築 + +初期項目集合から出発し、GOTO関数を用いて新たな項目集合を構築します。この過程を新しい項目集合が生まれなくなるまで繰り返します。 + +### LR(1)構文解析表の作成 + +LR(1)項目集合を用いて、構文解析表(ACTION表とGOTO表)を作成します。 + +#### ACTION表の作成 + +1. シフト動作の設定 + +状態Iにおいて、項目`[A → α・aβ, b]`が含まれている場合、aが終端記号であれば、`GOTO(I, a) = J`として、ACTION表の`(I, a)`に`shift J`を設定します。 + +2. 還元動作の設定 + +状態Iにおいて、項目`[A → α・, a]`(ドットが右端にある項目)が含まれている場合、`A → α`の規則番号をrとして、ACTION表の`(I, a)`にreduce rを設定します。 + +3. 受理動作の設定 + +状態Iにおいて、項目`[S' → S・, $]`が含まれている場合、ACTION表の`(I, $)`に`accept`を設定します。 + +### GOTO表の作成 + +GOTO表の作成方法はSLR(1)法と同様です。 + +### LR(1)法の利点と欠点 + +#### 利点 + +- 強力な解析能力:LR(1)法は、すべてのLR(1)文法を解析可能であり、SLR(1)法やLALR(1)法で解析できない文法も扱えます。 +- コンフリクトの解消:先読み記号を明示的に扱うことで、コンフリクトを細かく解消できます。 + +#### 欠点 + +解析表のサイズが大きい:LR(1)項目集合は非常に大きくなる傾向があり、解析表も巨大になります。これにより、実用上のメモリ消費や処理時間が問題となる場合があります。 + +### 具体例 + +簡単な文法を用いて、LR(1)項目集合と解析表の作成を示します。 + +```text +1. S → L = R +2. S → R +3. L → * R +4. L → id +5. R → L ``` -ここで構文解析器を表現するインタフェース`JParser`は型パラメータ`R`を受け取ることに注意してください。一般に構文解析の結果は抽象構文木になりますが、インタフェースを定義する時点では抽象構文木がどのような形になるかはわかりようがないので、型パラメータにしておくのです。`JParser`はたった一つのメソッド`parse()`を持ちます。`parse()`は入力文字列`input`を受け取り、解析結果を`Result`として返します。 +#### LR(1)項目集合の構築 + +初期項目:`[S' → ・S, $]` + +#### ステップ1:閉包の計算 + +初期項目の閉包を計算し、項目集合を構築します。 + +#### ステップ2:GOTO関数の計算 + +各項目集合に対して、GOTO関数を計算し、新たな項目集合を生成します。 + +#### ステップ3:全体の項目集合の構築 + +この過程を繰り返し、全ての項目集合を構築します。 + +#### 構文解析表の作成 + +構築したLR(1)項目集合を用いて、ACTION表とGOTO表を作成します。 + +#### コンフリクトの解消 -`JParser`の実装は一体全体どのようなものになるの?という疑問を脇に置いておけば理解は難しくないでしょう。次に解析結果`Result`をレコードとして定義します。 +この文法では、SLR(1)法では解消できないコンフリクトが発生しますが、LR(1)法では正しく解析できます。 + +#### LR(1)法の欠点 + +LR(1)法は強力ですが、解析表のサイズが大きくなるため、実用上はメモリや処理時間の問題があります。そこで、次節で説明するLALR(1)法がよく用いられます。LALR(1)法は、LR(1)法の解析能力を保ちつつ、解析表のサイズをSLR(1)法と同程度に抑えた手法です。 + +## 5.12 LALR(1) - 現実的に取り扱いやすい上向き構文解析アルゴリズム + +LR(1)法は強力な手法ですが、解析表が大きくなるという欠点があります。LALR(1)(Look-Ahead LR)は、LR(0)の状態を結合し、LR(1)の性能を持ちながら解析表のサイズを抑える手法です。 + +### LALR(1)解析表の作成 + +LR(1)アイテムの集合を構築し、同じLR(0)アイテムを持つ状態をマージします。その際、先読み記号を適切に管理します。 + +### 利点と欠点 + +LALR(1)は多くの実用的な文法を扱えるため、構文解析器生成器(例えばYacc)で広く使われています。しかし、まれにLALR(1)では解析できない文法も存在します。 + +## 5.13 - Parsing Expression Grammar(PEG) - 構文解析アルゴリズムのニューカマー + +2000年代に入るまで、構文解析手法の主流はLR法の変種であり、上向き構文解析アルゴリズムでした。その理由の一つに、先読みを前提とする限り、従来のLL法はLR法より表現力が弱いという弱点がありました。下向き型でもバックトラックを用いれば幅広い言語を表現できることは比較的昔から知られていましたが、バックトラックによって解析時間が最悪で指数関数時間になるという弱点があります。そのため、コンパイラの教科書として有名な、いわゆるドラゴンブックでも現実的ではないといった記述がありました(初版にはあったはずだが、第二版にも該当記述があるかは要確認)。しかし、2004年にBryan Fordによって提案されたParsing Expression Grammar(PEG)はそのような状況を変えました。 + +PEGはおおざっぱに言ってしまえば、無制限な先読みとバックトラックを許す下向き型の構文解析手法の一つです。決定的文脈自由言語に加えて一部の文脈依存言語を取り扱うことができますし、Packrat Parsingという最適化手法によって線形時間で構文解析を行うことが保証されているというとても良い性質を持っています。さらに、LL法やLR法でほぼ必須であった字句解析器が要らず、アルゴリズムも非常にシンプルであるため、ここ十年くらいで解析表現手法をベースとした構文解析生成系が数多く登場しました。 + +他人事のように書いていますが、筆者が大学院時代に専門分野として研究していたのがまさしくこのPEGでした。Python 3.9ではPEGベースの構文解析器が採用されるなど、PEGは近年採用されるケースが増えています。 + +3章で既にPEGを用いた構文解析器を自作したのを覚えているでしょうか。たとえば、配列の文法を表現した以下のPEGがあるとします。 + +```text +array = LBRACKET RBRACKET | LBRACKET {value {COMMA value}} RBRACKET ; +``` + + このPEGに対応するJavaの構文解析器(メソッド)は以下のようになるのでした。 ```java -record Result(V value, String rest){} + public Ast.JsonArray parseArray() { + int backup = cursor; + try { + // LBRACKET RBRACKET + parseLBracket(); + parseRBracket(); + return new Ast.JsonArray(new ArrayList<>()); + } catch (ParseException e) { + cursor = backup; + } + + // LBRACKET + parseLBracket(); + List values = new ArrayList<>(); + // value + var value = parseValue(); + values.add(value); + try { + // {value {COMMA value}} + while (true) { + parseComma(); + value = parseValue(); + values.add(value); + } + } catch (ParseException e) { + // RBRACKET + parseRBracket(); + return new Ast.JsonArray(values); + } + } ``` -レコード`Result`は解析結果を保持するクラスです。`value`は解析結果の値を表現し、`rest`は解析した結果「残った」文字列を表します。 +PEGの特色は、 -このインタフェース`JParser`は次のように使えると理想的です。 +```java + int backup = cursor; +``` + +という行によって、解析を始める時点でのソースコード上の位置を保存しておき、もし解析に失敗したら以下のように「巻き戻す」ところにあります。「巻き戻した」位置から次の分岐を試そうとするのです。 ```java -JParser calculator = ...; -Result result = calculator.parse("1+2*3"); -assert 7 == result.value(); + } catch (ParseException e) { + cursor = backup; + } + // LBRACKET + parseLBracket(); + // ... ``` -パーザコンビネータは、このようなどこか都合の良い`JParser`を、BNF(あるいはPEG)に近い文法規則を連ねていくのに近い使い勝手で構築するための技法です。前の節で紹介した`SComb`もパーザコンビネータでしたが基本的には同じようなものです。 +なお、PEGの挙動を簡単に説明するために3章および本章では例外をスロー/キャッチするという実装にしていますが、現実にはこのような実装にするとオーバーヘッドが大きすぎるため、実用的なPEGパーザでは例外を使わないことが多いです。 -この節では最終的に上のような式を解析できるパーザコンビネータを作るのが目標です。 +一般化すると、PEGの挙動は以下の8つの要素を使って説明することができます。 -### 5.7.1 プリミティブなパーザを作ろう! +1. 空文字列: ε +2. 終端記号: t +3. 非終端記号: N +4. 連接: e1 e2 +5. 選択: e1 / e2 +6. 0回以上の繰り返し: e* +7. 肯定述語: &e +8. 否定述語: !e + + 次節以降では、この8つの要素がそれぞれどのような意味を持つかを説明していきます。説明のために -これからパーザコンビネータを作っていくわけですが、パーザコンビネータの基本となる「部品」を作る必要があります。 +```java +match(e, v) == Success(consumed, rest) +``` -まず最初に、文字列リテラルを受け取ってそれを解析できる次のような`string()`メソッドは是非とも欲しいところです。 +や ```java -assert new Result("123", "").equals(string("123").parse("123")); +match(e, v) == Failure ``` -これはBNFで言えば文字列リテラルの表記に相当します。 +というJava言語ライクな記法を使います。 -次に、解析に成功したとしてその値を別の値に変換するための方法もほしいところです。たとえば、`123`という文字列を解析したとして、これは最終的に文字列ではなくintに変換したいところです。このようなメソッドは、ラムダ式で変換を定義できるように、次のような`map()`メソッドとして提供したいところです。 +たとえば、 ```java - JParser map(Parser parser, Function function); -assert (new Result(123, "")).equals(map(string("123"), v -> Integer.parseInt(v)).parse("123")); +match("x", "xy") == Success("x", "y")` ``` -これは構文解析器生成系でセマンティックアクションを書くのに相当すると言えるでしょう。 - -BNFで`a | b`、つまり選択を書くのに相当するメソッドも必要です。これは次のような`alt()`メソッドとして提供します。 +は式`x`が文字列`"xy"`にマッチして残りの文字列が`"y"`であることを示します。また、 ```java - JParser alt(Parser p1, Parser p1); -assert (new Result("bar", "")).equals(alt(string("foo"), string("bar"))); +match("xy", "x") == Failure ``` -同様に、BNFで`a b`、つまり連接」を書くのに相当するメソッドも必要ですが、これは次のような`seq()`メソッドとして提供します。 +は式`"xy"`が文字列`"x"`にマッチしないことを表現します。 + +### 空文字列 + +空文字列εは0文字**以上**の文字列にマッチします。たとえば、 ```java -record Pair(A a, B b){} - JParser> seq(Parser p1, Parser p2); -assert (new Result<>(new Pair("foo", "bar"), "")).equals(seq(string("foo"), string("bar"))) +match(ε, "") == Success("", "") ``` -最後に、BNFで`a*`、つまり0回以上の繰り返しに相当する`rep0()`メソッド +が成り立つだけでなく、 ```java - JParser> rep0(Parser p); -assert (new Result>(List.of(), "")).equals(rep0(string(""))) +match(ε, "x") == Success("", "x") ``` -や`a+`、つまり1回以上の繰り返しに相当する`rep1()`メソッドもほしいところです。 +や ```java - JParser> rep1(Parser p); -assert (new Result>(List.of("a", "a", "a"), "")).equals(rep1(string("aaa"))) +match(ε, "xyz") == Success("", "xyz") ``` -この節ではこれらのプリミティブなメソッドの実装方法について説明していきます。 +も成り立ちます。εは**あらゆる文字列**にマッチすると言い換えることができます。 -#### 5.7.1.1 `string()`メソッド +### 終端記号 -まず最初に`string(String literal)`メソッドで返す`JParser`の中身を作ってみましょう。`JParser`クラスはただ一つのメソッド`parser()`をもつので次のような実装になります。 +終端記号`t`は1文字以上の長さで特定のアルファベットで**始まる**文字列にマッチします。たとえば、 ```java -class JLiteralParser implements JParser { - private String literal; - public JLiteralParser(String literal) { - this.literal = literal; - - } - public Result parse(String input) { - if(input.startsWith(literal)) { - return new Result(literal, input.substring(literal.length())); - } else { - return null; - } - } -} +match(x, "x") == Success("x", "") ``` -このクラスは次のようにして使います。 +や ```java -assert new Result("foo", "").equals(new JLiteralParser("foo")); +match(x, "xy") == Success("x", "y") ``` -リテラルを表すフィールド`literal`が`input`の先頭とマッチした場合、`literal`と残りの文字列からなる`Result`を返します。そうでない場合は返すべき`Result`がないので`null`を返します。簡単ですね。あとはこのクラスのインスタンスを返す`string()`メソッドを作成するだけです。なお、使うときの利便性のため、以降では各種メソッドはクラス`JComb`のstaticメソッドとして実装していきます。 +が成り立ちます。一方、 ```java -public class JComb { - JParser string(String literal) { - return new JLiteralParser(literal); - } -} +match(x, "y") == Failure ``` -使う時は次のようになります。 +ですし、 ```java -JParser foo = string("foo"); -assert new Result("foo", "_bar").equals(foo.parse("foo_bar")); -assert null == foo.parse("baz"); +match(x, "") == Failure ``` -#### 5.7.1.2 `alt()`メソッド +です。εの場合と同じく「残りの文字列」があってもマッチする点に注意してください。 -次に二つのパーザを取って「選択」パーザを返すメソッド`alt()`を実装します。先程のようにクラスを実装してもいいですが、メソッドは一つだけなのでラムダ式にします。 +### 選択 -```java -public class JComb { - // p1 / p2 - public static JParser alt(JParser p1, JParser p2) { - return (input) -> { - var result = p1.parse(input);//(1) - if(result != null) return result;//(2) - return p2.parse(input);//(3) - }; - } -} +`e1`と`e2`は共に式であるものとします。このとき、 + + +```text +e1 / e2 ``` -ラムダ式について復習しておくと、これは実質的には以下のような匿名クラスを書いたのと同じになります。 +に対する`match(e1 / e2, s)`は以下のような動作を行います。 -```java -public class JComb { - public static JParser alt(JParser p1, JParser p2) { - return new JAltParser(p1, p2); - } -} +1. `match(e1, s)`を実行する +2. 1.が成功していれば、sのサフィックスを返し、成功する +3. 1.が失敗した場合、`match(e2, s)`を実行し、結果を返す -class JAltParser implements JParser { - private JParser p1, p2;; - public JAltParser(Parser p1, Parser p2) { - this.p1 = p1; - this.p2 = p2; - } - public Result parse(String input) { - var result = p1.parse(input); - if(result != null) return result; - return p2.parse(input); - } -} +### 選択 + +`e1`と`e2`は共に式であるものとします。このとき、 + +```text +e1 e2 ``` -この定義では、 + に対する`match(e1 e2, s)`は以下のような動作を行います。 + +1. `match(e1, s)`を実行する +2. 1.が成功したとき、結果を`Success(s1,s2)`とする。この時、`match(e2,s2)`を実行し、結果を返す +3. 1.が失敗した場合、その結果を返す -(1) まずパーザ`p1`を試しています -(2) `p1`が成功した場合は`p2`を試すことなく値をそのまま返します -(3) `p1`が失敗した場合、`p2`を試しその値を返します +### 非終端記号 -のような挙動をします。 +あるPEGの規則Nがあったとします。 -しかしこれはBNFというよりPEGの挙動です。そうです。実は今ここで作っているパーザコンビネータである`JComb`は(`SComb`)PEGをベースとしたパーザコンビネータだったのです。もちろん、PEGベースでないパーザコンビネータを作ることも出来るのですが実装がかなり複雑になってしまいます。PEGの挙動をそのままプログラミング言語に当てはめるのは非常に簡単であるため、今回はPEGを採用しましたが、もし興味があればBNFベース(文脈自由文法ベース)のパーザコンビネータも作ってみてください。 +```text +N <- e +``` -#### 5.7.1.3 `seq()`メソッド +`match(N, s)`は以下のような動作を行います。 -次に二つのパーザを取って「連接」パーザを返すメソッド`seq()`を実装します。先程と同じくラムダ式にしてみます。 +1. `N`に対応する規則を探索する(`N <- e`が該当) +2. `N`の呼び出しから戻って来たときのために、スタックに現在位置`p`を退避 +3. `match(e, s)`を実行する。結果を`M`とする。 +4. スタックに退避した`p`を戻す +5. `M`を全体の結果とする -```java -record Pair(A a, B b){} -// p1 p2 -public class JComb { - public static JParser> seq(JParser p1, JParser p2) { - return (input) -> { - var result1 = p1.parse(input); //(1-1) - if(result1 == null) return null; //(1-2) - var result2 = p2.parse(result1.rest()); //(2-1) - if(result2 == null) return null; //(2-2) - return new Result<>(new Pair(result1.value(), result2.value()), result2.rest());//(2-3) - }; - } -} +### 0回以上の繰り返し + +`e`は式であるものとします。 + +```text +e* ``` -先程の`alt()`メソッドと似通った実装ですが、p1が失敗したら全体が失敗する(nullを返す:(1-2))のがポイントです。p1とp2の両方が成功した場合は、二つの値のペアを返しています(2-3)。 +このとき、eと文字列sの照合を行うために以下のような動作を行います。 -#### 5.7.1.4 `rep0()`, `rep1()`メソッド +1. `match(e,s)`を実行する +2. 1.が成功したとき(`n`回目)、結果を`Success(s_n,s_(n+1))`とする。`s`を`s_(n+1)`に置き換えて、1.に戻る +3. 1.が失敗した場合(`n`回目)、結果を`Success(s_1...s_n, s[n...])`とする -残りは0回以上の繰り返し(`p*`)を表す`rep0()`と1回以上の繰り返し(`p+`)を表す`rep1()`メソッドです。 +`e*`は「0回以上の繰り返し」を表現するため、一回も成功しない場合でも全体が成功するのがポイントです。なお、`e*`は規則 -まず、`rep0()`メソッドは次のようになります。 +``` +H <- e H / ε +``` -```java -public class JComb { - public static JParser> rep0(JParser p) { - return (input) -> { - var result = p.parse(input); // (1) - if(result == null) return new Result<>(List.of(), input); // (2) - var value = result.value(); - var rest = result.rest(); - var result2 = rep0(p).parse(rest); //(3) - if(result2 == null) return new Result<>(List.of(value), rest); - List values = new ArrayList<>(); - values.add(value); - values.addAll(result2.value()); - return new Result<>(values, result2.rest()); - }; - } -} +に対して`H`を呼び出すことの構文糖衣であり、全く同じ意味になります。 + +### 肯定述語 + +`e`は式であるものとします。このとき、 + +```text +&e ``` +  +は`match(&e,s)`を実行するために、以下のような動作を行います。 -(1)でまずパーザ`p`を適用しています。ここで失敗した場合、0回の繰り返しにマッチしたことになるので、空リストからなる結果を返します((2))。そうでなければ、1回以上の繰り返しにマッチしたことになるので、繰り返し同じ処理をする必要がありますが、これは再帰呼出しによって簡単に実装できます((3))。シンプルな実装ですね。 +1. `match(e,s)`を実行する +2-1. 1.が成功したとき:結果を`Success("", s)`とする +2-2. 1.が失敗した場合:結果は`Failure()`とする +肯定述語は成功したときにも「残り文字列」が変化しません。肯定述語`&e`は後述する否定述語`!!`を二重に重ねたものに等しいことが知られています。 -`rep1(p)`は意味的には`seq(p, rep0(p))`なので、次のようにして実装を簡略化することができます。 +### 否定述語 -```java -public class JComb { - public static JParser> rep1(JParser p) { - JParser>> rep1Sugar = seq(p, rep0(p)); - return (input) -> { - var result = rep1Sugar.parse(input);//(1) - if(result == null) return null;//(2) - var values = new ArrayList<>(); - values.add(rep1Sugar.b()); - values.addAll(rep1Sugar.b()); - return new Result<>(values, result.rest()); //(3) - }; - } -} +`e`は式であるものとします。このとき、 + +```text +!e +``` +  +は`match(!e,s)`を実行するために以下のような動作を行います。 + +1. `match(e,s)`を実行する +2-1. 1.が成功したとき:結果を`Failure()`とする +2-2. 1.が失敗した場合:結果は`Success("", s)`とする + +否定述語も肯定述語同様、成功しても「残り文字列」が変化しません。 + +前述した`&e = !!e`は論理における二重否定の除去に類似するものということができます。 + +### PEGの操作的意味論 + +ここまでで、PEGを構成する8つの要素について説明してきましたが、実際のところは厳密さに欠けるものでした。より厳密に説明すると以下のようになります(Ford:04を元に改変)。先程までの説明では、`Success(s1, s2)`を使って、`s1`までは読んだことを、残り文字列が`s2`であることを表現してきました。ここではペア`(n, x)`で結果を表しており、`n`はステップ数を表すカウンタで`x`は残り文字列または`f`(失敗を表す値)となります。⇒を用いて、左の状態になったとき右の状態に遷移することを表現しています。 + +``` +1. 空文字列: + (ε,x) ⇒ (1,ε) (全ての x ∈ V ∗ Tに対して)。 +2. 終端記号(成功した場合): + (a,ax) ⇒ (1,a) (a ∈VT , x ∈V ∗ T である場合)。 +3. 終端記号(失敗した場合): + (a,bx) ⇒ (1, f) iff a ≠ b かつ (a,ε) ⇒ (1, f)。 +4. 非終端記号: + (A,x) ⇒ (n + 1,o) iff A ← e ∈ R かつ(e,x) ⇒ (n,o)。 +5. 連接(成功した場合): + (e1, x1 x2 y) ⇒ (n1,x1) かつ (e2, x2 y) ⇒ (n2, x2) のとき、 (e1 e2,x1 x2 y) ⇒ (n1 + n2 + 1, x1 x2)。 +6. 連接(失敗した場合1): + (e1, x) ⇒ (n1, f) ならば (e1 e2,x) ⇒ (n1 + 1, f). もし e1が失敗したならば、e1e2はe2を試すことなく失敗する。 +7. 連接(失敗した場合2): + (e1, x1 y) ⇒ (n1,x1) かつ (e2,y) ⇒ (n2, f) ならば (e1e2,x1y) ⇒ (n1 + n2 + 1, f)。 +8. 選択(場合1): + (e1, x y) ⇒ (n1,x) ならば (e1/e2,xy) ⇒ (n1 +1,x)。 +9. 選択(場合2): + (e1, x) ⇒ (n1, f) かつ (e2,x) ⇒ (n2,o) ならば (e1 / e2,x) ⇒ (n1 + n2 + 1,o)。 +10. 0回以上の繰り返し (繰り返しの場合): + (e, x1 x2 y) ⇒ (n1,x1) かつ (e∗,x2 y) ⇒ (n2, x2) ならば (e∗,x1 x2 y) ⇒ (n1 + n2 +1,x1 x2)。 +11. 0回以上の繰り返し (停止の場合): + (e,x) ⇒ (n1, f) ならば (e∗,x) ⇒ (n1 + 1, ε)。 +12. 否定述語(場合1): + (e,xy) ⇒ (n,x) ならば (!e,xy) ⇒ (n + 1, f)。 +13. 否定述語(場合2): + (e,x) ⇒ (n, f) ならば (!e,x) ⇒ (n + 1, ε)。 ``` -実質的な本体は(1)だけで、あとは結果の値が`Pair`なのを`List`に加工しているだけですね。 +## 5.14 - Packrat Parsing + +素のPEGは非常に単純でいて、とても幅広い範囲の言語を取り扱うことができます。しかし、PEGには一つ大きな弱点があります。最悪の場合、解析時間が指数関数時間になってしまうことです。現実的にはそのようなケースは稀であるという指摘ありますが(論文を引用)、原理的にはそのような弱点があります。Packrat Parsingはメモ化という技法を用いることでPEGで表現される言語を線形時間で解析可能にします。 + +メモ化という技法自体をご存じでない読者の方も多いかもしれないので、まずメモ化について説明します。 -既に作られたパーザを加工して別の値を生成するためのメソッド`map()`を`JParser`に実装してみましょう。`map()`は`JParser`のメソッドとして実装するとメソッドチェインが使えて便利なので、インタフェースの`default`メソッドとして実装します。 +### fibメソッド + メモ化の例でよく出てくるのはN番目のフィボナッチ数を求める`fib`関数です。この書籍をお読みの皆様ならお馴染みかもしれませんが、N番目のフィボナッチ数F(n)は次のようにして定義されます: + +``` +F(0) = 1 +F(1) = 1 +F(n) = F(n - 1) + F(n - 2) +``` + + この再帰的定義を素朴にJavaのメソッドとして書き下したのが以下のfibメソッドになります。 ```java -interface JParser { - Result parse(String input); - - default JParser map(Function f) { - return (input) -> { - var result = this.parse(input); - if (result == null) return null; - return new Result<>(f.apply(result.value()), result.rest()); (1) - }; +public class Main { + public static long fib(long n) { + if(n == 0 || n == 1) return 1L; + else return fib(n - 1) + fib(n - 2); + } + public static void main(String[] args) { + System.out.println(fib(5)); // 120 } } ``` -(1)で`f.apply(result.value())`として値を加工しているのがポイントでしょうか。 +このプログラムを実行すると、コメントにある通り120が出力されます。しかし、このfibメソッドには重大な欠点があります。それは、nが増えると計算量が指数関数的に増えてしまうことです。たとえば、上のfibメソッドを使うと`fib(30)`くらいまではすぐに計算することができます。しかし、`fib(50)`を求めようとすると皆さんのマシンではおそらく数十秒はかかるでしょう。 + + フィボナッチ数を求めたいだけなのに数十秒もかかってはたまったものではありません。 + +### fib関数のメモ化 + +そこで出てくるのがメモ化というテクニックです。一言でいうと、メモ化とはある引数nに対して計算した結果f(n)をキャッシュしておき、もう一度同じnに対して呼び出されたときはキャッシュした結果を返すというものです。早速、fibメソッドをメモ化してみましょう。 -パーザを遅延評価するためのメソッド`lazy()`も導入します。Javaはデフォルトでは遅延評価を採用しない言語なので、再帰的な規則を記述するときにこのようなメソッドがないと無限に再帰してスタックオーバーフローを起こしてしまいます。 +メモ化されたfibメソッドは次のようになります。 ```java -public class JComb { - public static JParser lazy(Supplier> supplier) { - return (input) -> supplier.get().parse(input); +import java.util.*; +public class Main { + private static Map cache = new HashMap<>(); + public static long fib(long n) { + Long value = cache.get(n); + if(value != null) return value; + + long result; + if(n == 0 || n == 1) { + result = 1L; + } else { + result = fib(n - 1) + fib(n - 2); + } + cache.put(n, result); + return result; + } + public static void main(String[] args) { + System.out.println(fib(50)); // 20365011074 } } ``` -これで最低限の部品は出揃ったのですが、せっかくなので正規表現を扱うメソッド`regex()`も導入してみましょう。 +`fib(50)`の結果はコメントにある通りですが、今度は一瞬で結果がかえってきたのがわかると思います。メモ化されたfibメソッドでは同じnに対する計算は二度以上行われないので、nが増えても実行時間は線形にしか増えません。つまり、fib(50)の実行時間は概ねfib(25)の二倍であるということです。 + +ただし、計算量に詳しい識者の方は「おいおい。整数同士の加算が定数時間で終わるという仮定はおかしいんじゃないかい?」なんてツッコミを入れてくださるかもしれませんが、そこを議論するとややこしくなるので整数同士の加算はたかだか定数時間で終わるということにします。 + +`fib`メソッドのメモ化でポイントとなるのは、記憶領域(`cache`に使われる領域)と引き換えに最悪計算量を指数関数時間から線形時間に減らせるということです。また、メモ化する対象となる関数は一般的には副作用がないものに限定されます。というのは、メモ化というテクニックは「同じ引数を渡せば同じ値が返ってくる」ことを暗黙の前提にしているからです。 + +次の項ではPEGをナイーヴに実装した`parse`関数をまずお見せして、続いてそれをメモ化したバージョン(Packrat parsing)をお見せすることにします。`fib`メソッドのメモ化と同じようにPEGによる構文解析もメモ化できることがわかるでしょう。 + +### parseメソッド + + ここからは簡単なPEGで記述された文法を元に構文解析器を組み立てていくわけですが、下記のような任意個の`()`で囲まれた`0`の構文解析器を作ります。 + +``` +A <- "(" A ")" + / "(" A A ")" + / "0" +``` + + 単純過ぎる例にも思えますが、メモ化の効果を体感するにはこれで十分です。早速構文解析器を書いていきましょう。 ```java -public class JComb { - public static JParser regex(String regex) { - return (input) -> { - var matcher = Pattern.compile(regex).matcher(input); - if(matcher.lookingAt()) { - return new Result<>(matcher.group(), input.substring(matcher.end())); - } else { - return null; +sealed interface ParseResult permits ParseResult.Success, ParseResult.Failure { + public abstract String rest(); + record Success(String value, String rest) implements ParseResult {} + record Failure(String rest) implements ParseResult {} +} +class ParseError extends RuntimeException { + public final String rest; + public String rest() { + return rest; + } + ParseError(String rest) { + this.rest = rest; + } +} +public class Parser { + private static boolean isEnd(String string) { + return string.length() == 0; + } + public static ParseResult parse(String input) { + String start = input; + try { + // "(" A ")" + if(isEnd(input) || input.charAt(0) != '(') { + throw new ParseError(input); + } + + var result = parse(input.substring(1)); + if(!(result instanceof ParseResult.Success)) { + throw new ParseError(result.rest()); + } + + var success = (ParseResult.Success)result; + + input = success.rest(); + if(isEnd(input) || input.charAt(0) != ')') { + throw new ParseError(input); + } + + return new ParseResult.Success(success.value(), input.substring(1)); + } catch (ParseError error) { + input = start; + } + + try { + // "(" A A ")" + if((isEnd(input)) || input.charAt(0) != '(') { + throw new ParseError(input); + } + + var result = parse(input.substring(1)); + if(!(result instanceof ParseResult.Success)) { + throw new ParseError(result.rest()); + } + + var success = (ParseResult.Success)result; + input = success.rest(); + + result = parse(input); + + if(!(result instanceof ParseResult.Success)) { + throw new ParseError(result.rest()); } - }; + + success = (ParseResult.Success)result; + input = success.rest(); + + if(isEnd(input) || input.charAt(0) != ')') { + throw new ParseError(input); + } + + return new ParseResult.Success(success.value(), success.rest().substring(1)); + } catch (ParseError error) { + input = start; + } + + if(isEnd(input) || input.charAt(0) != '0') { + return new ParseResult.Failure(input); + } + + return new ParseResult.Success(input.substring(0, 1), input.substring(1)); } } ``` -引数として与えられた文字列を`Pattern.compile()`で正規表現に変換して、マッチングを行うだけです。これは次のようにして使うことができます。 +このプログラムを使うと以下のように構文解析を行うことが出来ます。 ```java -var number = regex("[0-9]+").map(v -> Integer.parseInt(v)); -assert (new Result(10, "")).equals(number.parse("10")); +jshell> Parser.parse("("); +$25 ==> Failure[rest=] +jshell> Parser.parse("()"); +$26 ==> $26 ==> Failure[rest=)] +jshell> Parser.parse("(0)"); +$27 ==> Success[value=), rest=] ``` +  +しかし、この構文解析器には弱点があります。`(((((((((((((((((((((((((((0)))`のようなカッコのネスト数が深いケースで急激に解析にかかる時間が増大してしまうのです。これはまさにPEGだからこそ起こる問題点だと言えます。 -ここまでで作ったクラス`JComb`と`JParser`などを使っていよいよ簡単な算術式のインタプリタを書いてみましょう。仕様は次の通りです。 - -- 扱える数値は整数のみ -- 演算子は加減乗除(`+|-|*|/`)のみ -- `()`によるグルーピングができる +### parseメソッドのメモ化 - Packrat Parsing -実装だけを提示すると次のようになります。 +前のコードをもとに`parse`メソッドをメモ化してみましょう。コードは以下のようになります。 ```java -public class Calculator { - public static JParser expression() { - /* - * expression <- additive ( ("+" / "-") additive )* - */ - return seq( - lazy(() -> additive()), - rep0( - seq( - alt(string("+"), string("-")), - lazy(() -> additive()) - ) - ) - ).map(p -> { - var left = p.a(); - var rights = p.b(); - for (var right : rights) { - var op = right.a(); - var rightValue = right.b(); - if (op.equals("+")) { - left += rightValue; - } else { - left -= rightValue; - } - } - return left; - }); +import java.util.*; +sealed interface ParseResult permits ParseResult.Success, ParseResult.Failure { + public abstract String rest(); + record Success(String value, String rest) implements ParseResult {} + record Failure(String rest) implements ParseResult {} +} +class ParseError extends RuntimeException { + public final String rest; + public String rest() { + return rest; } + ParseError(String rest) { + this.rest = rest; + } +} +class PackratParser { + private Map cache = new HashMap<>(); + private boolean isEnd(String string) { + return string.length() == 0; + } + public ParseResult parse(String input) { + String start = input; + try { + // "(" A ")" + if(isEnd(input) || input.charAt(0) != '(') { + throw new ParseError(input); + } - public static JParser additive() { - /* - * additive <- primary ( ("*" / "/") primary )* - */ - return seq( - lazy(() -> primary()), - rep0( - seq( - alt(string("*"), string("/")), - lazy(() -> primary()) - ) - ) - ).map(p -> { - var left = p.a(); - var rights = p.b(); - for (var right : rights) { - var op = right.a(); - var rightValue = right.b(); - if (op.equals("*")) { - left *= rightValue; - } else { - left /= rightValue; - } + input = input.substring(1); + ParseResult result; + result = cache.get(input); + if(result == null) { + result = parse(input); + cache.put(input, result); } - return left; - }); - } - public static JParser primary() { - /* - * primary <- number / "(" expression ")" - */ - return alt( - number, - seq( - string("("), - seq( - lazy(() -> expression()), - string(")") - ) - ).map(p -> p.b().a()) - ); + if(!(result instanceof ParseResult.Success)) { + throw new ParseError(result.rest()); + } + + var success = (ParseResult.Success)result; + + input = success.rest(); + if(isEnd(input) || input.charAt(0) != ')') { + throw new ParseError(input); + } + + return new ParseResult.Success(success.value(), input.substring(1)); + } catch (ParseError error) { + input = start; + } + + try { + // "(" A A ")" + if((isEnd(input)) || input.charAt(0) != '(') { + throw new ParseError(input); + } + + input = input.substring(1); + ParseResult result; + result = cache.get(input); + if(result == null){ + result = parse(input); + cache.put(input, result); + } + + if(!(result instanceof ParseResult.Success)) { + throw new ParseError(result.rest()); + } + + var success = (ParseResult.Success)result; + input = success.rest(); + + result = cache.get(input); + if(result == null) { + result = parse(input); + cache.put(input,result); + } + + if(!(result instanceof ParseResult.Success)) { + throw new ParseError(result.rest()); + } + + success = (ParseResult.Success)result; + input = success.rest(); + + if(isEnd(input) || input.charAt(0) != ')') { + throw new ParseError(input); + } + + return new ParseResult.Success(success.value(), input.substring(1)); + } catch (ParseError error) { + input = start; + } + + if(isEnd(input) || input.charAt(0) != '0') { + return new ParseResult.Failure(input); + } + + return new ParseResult.Success(input.substring(0, 1), input.substring(1)); } - - // number <- [0-9]+ - private static JParser number = regex("[0-9]+").map(Integer::parseInt); } ``` -コメントに対応するPEGを付加してありますが、表記は冗長なもののほぼPEGに一対一に対応しているのがわかるのではないでしょうか? - -これに対してJUnitを使って以下のようなテストコードを記述してみます。無事、意図通りに解釈されていることがわかります。 - ```java -assertEquals(new Result<>(7, ""), expression().parse("1+2*3")); // テストをパス + private Map cache = new HashMap<>(); ``` -結果として見ると、さすがにDSLに向いたScalaに比べれば大幅に冗長になったものの、手書きで再帰下降パーザを組み立てるのに比べると大幅に簡潔な記述を実現することができました。しかも、JComb全体を通しても500行にすら満たないのは特筆すべきところです。Javaがユーザ定義の中置演算子をサポートしていればもっと簡潔にできたのですが、そこは向き不向きといったところでしょうか。 +というフィールドが加わったことです。このフィールド`cache`がパーズの途中結果を保持してくれるために計算が高速化されるのです。結果として、PEGでは最悪指数関数時間かかっていたものがPackrat Parsingでは入力長に対してたかだか線形時間で解析できるようになりました。 + +PEGは非常に強力な能力を持っていますが、同時に線形時間で構文解析を完了できるわけで、これはとても良い性質です。そういった理由もあってか、PEGやPackrat Parsingを用いた構文解析器や構文解析器生成系はここ10年くらいで大幅に増えました。 + +## 5.15 - Generalized LR (GLR) Parsing + +GLR(Generalized LR)法は、Tomitaによって提案された手法で、曖昧な文法や非決定性を含む文法を扱うことができます。GLRは解析中に可能性のある複数の解析パスを同時に追跡し、すべての解釈を得ることができます。 + +### GLRの特徴 + +- 複数の解析スタックを同時に管理 +- シフト・還元動作を一般化 +- 木構造の共有によりメモリ効率を向上 + +GLRは曖昧さを扱えるため、自然言語処理や曖昧な構文を持つプログラミング言語の解析に適しています。 + +## 5.16 - Generalized LL (GLL) Parsing + +GLL(Generalized LL)法は、LL法を拡張して曖昧な文法を扱えるようにした手法で、Scottらによって2010年に提案されました(Scott:2010)。GLL法は再帰下降構文解析機を一般化したもので、非決定性を処理するために解析スタックと呼ばれるデータ構造を用います。 + +GLLの特徴は以下の通りです。 + +- 再帰下降構文解析機の拡張 +- 非決定性を処理できる +- 部分的なメモ化による効率化 + +## 5.17 - Parsing with Derivatives (PwD) + +Parsing with derivatives(PwD)はPwD(Parsing with Derivatives)は、正規表現の微分の概念を文脈自由文法に拡張した手法で、Mightらによって2011年に提案されました(Might:2011)。関数型のプログラムとして記述され、遅延評価や無限リストを活用します。 + +PwDの特徴は以下の通りです。 + +- 理論的にシンプル +- 遅延評価による効率化 +- 関数型プログラミング言語での実装が容易 + +## 5.18 - Tunnel Parsing + +[トンネル構文解析](https://dl.acm.org/doi/abs/10.2478/cait-2022-0021)は、曖昧性のある文法を効率的に解析するための新しい手法です。解析の過程で不要な部分をスキップ(トンネル)することで、効率的な解析を実現します。 + +トンネル構文解析の特徴は以下の通りです。 + +- 不要な解析パスを早期に除外 +- メモリ使用量の削減 +- 特定の問題領域での効率的な解析 + +## 5.19 - 構文解析アルゴリズムの計算量と表現力の限界 + + LL parsing、LR parsing、PEG、Packrat parsing、GLR parsing、GLL parsingについてこれまで書いてきましたが、計算量的な性質についてまとめておきましょう。なお、`n`は入力文字列長を表します。 + +| アルゴリズム | 時間計算量 | 空間計算量 | +| ------------------- | ------------- | ------------------------------- | +| LL(1) | O(n) | O(|N| * |T|) | +| LL(k) | O(n) | ??? | +| SLR(1) | O(n) | O(|N| * |P| * |T|) | +| LR(1) | O(n) | O(s * |T|) | +| LALR(1) | O(n) | O(s * |T|) | +| PEG | O(2^n) | O(n) | +| Packrat Parsing | O(n) | O(n * |P|) | +| GLR | O(n^3) | O(n^3) | +| GLL | O(n^3) | O(n^3) | +| PwD | O(n^3) | O(n^3) | +| トンネル構文解析 | 問題に依存 | 問題に依存 | + +nは全てのアルゴリズムで入力文字列の長さを表します。その他の記号については以下の通りです。 + +- LL(1) + - `|S|`: 開始記号列のサイズ + - `|T|`: は終端記号の数 +- SLR(1): + - `|N|`: 規則の右辺のサイズ + - `|P|`: 規則の数 + - `|T|`: 終端記号の数 +- LR(1): + - s: 状態数 + - `|T|`: 終端記号の数 +- LALR(1): + - s: 状態数 + - `|T|`: 終端記号の数 +- PEG or packrat parsing + - `|P|`: 規則の数 + +LL(1)やLR(1)は線形時間で解析を終えられますが、GLRやGLLは最悪の場合多項式時間を要します。PEGは指数時間がかかることがありますが、Packrat Parsingによって線形時間で解析できるようになります。 + +## 5.20 - まとめ -このように、パーザコンビネータを使うと、手書きでパーザを書いたり、あるいは、対象言語に構文解析器生成系がないようなケースでも、比較的気軽にパーザを組み立てるためのDSL(Domain Specific Language)を定義できるのです。また、それだけでなく、特にJavaのような静的型付き言語を使った場合ですが、IDEによる支援も受けられますし、BNFやPEGにはない便利な演算子を自分で導入することもできます。 +この章では構文解析アルゴリズムの中で比較的メジャーな手法について、そのアイデアと概要を含めて説明しました。その他にも多数の手法がありますが、いずれにせよ、「上から下に」向かって解析する下向きの手法と「下から上に」向かって解析する上向きの手法のどちらかに分類できると言えます。 -パーザコンビネータはお手軽なだけあって各種プログラミング言語に実装されています。たとえば、Java用なら[jparsec](https://github.com/jparsec/jparsec)があります。しかし、筆者としては、パーザコンビネータが動作する仕組みを理解するために、是非とも**自分だけの**パーザコンビネータを実装してみてほしいと思います。 \ No newline at end of file +GLRやGLL、PwDについては普段触れる機会はそうそうありませんが、LLやLR、PEGのパーサジェネレータは多数存在するため、基本的な動作原理について押さえておいて損はありません。また、余裕があれば各構文解析手法を使って実際のパーサジェネレータを実装してみるのも良いでしょう。実際にパーサジェネレータを実装することで、より深く構文解析手法を理解することもできます。 \ No newline at end of file diff --git a/honkit/chapter6.md b/honkit/chapter6.md index 399e46a..a17eb00 100755 --- a/honkit/chapter6.md +++ b/honkit/chapter6.md @@ -1,454 +1,949 @@ -# 6. 現実の構文解析 +# 6. 構文解析器生成系の世界 -ここまでで、LL法やLR法、Packrat Parsingといった、これまでに知られているメジャーな構文解析アルゴリズムを一通り取り上げてきました。これらの構文解析アルゴリズムは概ね文脈自由言語あるいはそのサブセットを取り扱うことができ、一般的なプログラミング言語の構文解析を行うのに必要十分な能力を持っているように思えます。 + 4章では現在知られている構文解析手法について、アイデアと提案手法の概要について説明しました。実は、構文解析の世界ではよく知られていることなのですが、4章で説明した各種構文解析手法は毎回プログラマが手で実装する必要はありません。 -しかし、構文解析を専門としている人や実用的な構文解析器を書いている人は直感的に理解していることなのですが、実のところ、既存の構文解析アルゴリズムだけではうまく取り扱えない類の構文があります。一言でいうと、それらの構文は文脈自由言語から逸脱しているために、文脈自由言語を取り扱う既存の手法だけではうまくいかないのです。 + というのは、CFGやPEG(その類似表記も含む)によって記述された文法定義から特定の構文解析アルゴリズムを用いた構文解析器を生成する構文解析器生成系というソフトウェアがあるからです。もちろん、それぞれの構文解析アルゴリズムや生成する構文解析器の言語ごとに別のソフトウェアを書く必要がありますが、ひとたびある構文解析アルゴリズムのための構文解析器生成系を誰かが書けば、その構文解析アルゴリズムを知らないプログラマでもその恩恵にあずかることができるのです。 -このような、既存の構文解析アルゴリズムだけでは扱えない要素は多数あります。たとえば、Cのtypedefはその典型ですし、RubyやPerlのヒアドキュメントと呼ばれる構文もそうです。他には、Scalaのプレースホルダ構文やC++のテンプレート、Pythonのインデント文法など、文脈自由言語を逸脱しているがゆえに人間が特別に配慮しなければいけない構文は多く見かけられます。 + 構文解析器生成系でもっとも代表的なものはyaccあるいはその互換実装であるGNU bisonでしょう。yaccはLALR(1)法を利用したCの構文解析器を生成してくれるソフトウェアであり、yaccを使えばプログラマはLALR(1)法の恩恵にあずかることができます。 -また、これまでの章では、主に構文解析を行う手法を取り扱っていましたが、現実問題としては抽象構文木をうまく作る方法やエラーメッセージを適切に出す方法も重要になってきます。 + この章では構文解析器生成系という種類のソフトウェアの背後にあるアイデアからはじまり、LL(1)、LALR(1)、PEGのための構文解析器を作る方法や多種多様な構文解析器生成系についての紹介などを行います。 -この章では、巷の書籍ではあまり扱われない、しかし現実の構文解析では対処しなくてはならない構文や問題について取り上げます。皆さんが何かしらの構文解析器を作るとき、やはり理想どおりにはいかないことが多いと思います。この章がそのような現実の構文解析で遭遇する読者の方々の助けになれば幸いです。 + また、この章の最後ではある意味構文解析生成系の一種とも言えるパーザコンビネータの実装方法について踏み込んで説明します。構文解析生成系はいったん対象となるプログラミング言語のソースコードを生成します。この時、対象言語のコードを部分的に埋め込む必要性が出てくるのですが、この「対象言語のコードを埋め込める必要がある」というのは結構曲者でして、実用上ほぼ必須だけど面倒くささと伴うので、構文解析系をお手軽に作るとは行かない部分があります。 -## 6.1 字句要素が構文要素を含む文法 + 一方、パーザコンビネータであれば、いわゆる「ラムダ式」を持つほとんどのプログラミング言語で比較的簡単に実装できます。本書で利用しているJava言語でも同様です。というわけで、本章を読めば皆さんもパーザコンビネータを明日から自前で実装できるようになります。 -最近の多くの言語は文字列補間(String Interpolation)と呼ばれる機能を持っています。 +## 6.1 Dyck言語の文法とPEGによる構文解析器生成 -たとえば、Rubyでは以下の文字列を評価すると、`"x + y = x + y"`ではなく`"x + y = 3"`になります。 +これまで何度も登場したDyck言語は明らかにLL(1)法でもLR(1)法でもPEGによっても解析可能な言語です。実際、4章ではDyck言語を解析する手書きのPEGパーザを書いたのでした。しかし、立ち戻ってよくよく考えてみると退屈な繰り返しコードが散見されたのに気づいた方も多いのではないでしょうか(4章に盛り込む予定)。 -```ruby -x = 1; y = 2 -"x + y = #{x + y}" # "x + y = 3" -``` +実際のところ、Dyck言語を表現する文法があって、構文解析アルゴリズムがPEGということまで分かれば対応するJavaコードを**機械的に生成する**ことも可能そうに見えます。特に、構文解析はコード量が多いわりには退屈な繰り返しコードが多いものですから、文法からJavaコードを生成できれば劇的に工数を削減できそうです。 -つまり、`#{`と`}`で囲まれた範囲をRubyの式として評価した結果を文字列として埋め込んでくれるわけです。 +このように「文法と構文解析手法が決まれば、後のコードは自動的に決定可能なはずだから、機械に任せてしまおう」という考え方が構文解析器生成系というソフトウェアの背後にあるアイデアです。 -Scalaでも同じことを次のように書くことができます。 +早速ですが、以下のようにDyck言語を表す文法が与えられたとして、PEGを使った構文解析器を生成する方法を考えてみましょう。 -```scala -val x = 1; val y = 2 -s"x + y = #{x + y}" // "x + y = 3" +```text +D <- P; +P <- "(" P ")" | "()"; ``` -Swiftだと次のようになります。 +PEGでは非終端記号の呼び出しは関数呼び出しとみなすことができますから、まず次のようなコードになります。 -```swift -let x = 1 -let y = 2 -"x + y = \(x + y)" +```java +public boolean parseD() { + return parseP(); +} +public boolean parseP() { + "(" P ")" | "()" +} ``` +## 6.2 JSONの構文解析器を生成する -同様の機能はKotlin、Python(3.6以降)、JavaScript(TypeScriptも)など様々な言語に採用されています。比較的新しい言語や、既存言語の新機能として採用するのがすっかり普通になった機能と言えるでしょう。 +LL(1)構文解析器生成系で、JSONのパーザが作れることを示す。これを通じて、構文解析器生成系が実用的に使えることを理解してもらう。 -文字列補間はとても便利な機能ですが、構文解析という観点からは少々やっかいな存在です。文字列リテラルは従来はトークンとして扱われており、正規言語の範囲に収まるように設計されていたため、正規表現で取り扱えたのです。これは字句解析と構文解析を分離し、かつ、字句解析を可能な限り単純化するという観点で言えばある意味当然とも言えますが、文字列補間は従来は字句であり正規表現で表現できたものを文脈自由文法を取り扱わなければいけない存在にしてしまいました。 +## 6.3 構文解析器生成系の分類 -たとえば、少々極端な例ですが、Rubyでは以下のように`#{}`の中にさらに文字列リテラルを書くことができ、その中には`#{}`を……といった具合に無限にネストできるのです。これまでの章を振り返ればわかるようにこれは明らかに正規言語を逸脱しており文脈自由言語の扱う範疇です。 +構文解析器生成系は1970年代頃から研究の蓄積があり、数多くの構文解析生成系がこれまで開発されています。基本的には構文解析器生成系と採用しているアルゴリズムは対応するので、たとえば、JavaCCはLL(1)構文解析器を出力するため、LL(1)構文解析器生成系であると言ったりします。 -```ruby -x = 1; y = 2 -"expr1 (#{"expr2 (#{x + y})"})" # "expr1 (expr2 (3))" -``` +同様に、yacc(bison)はLALR(1)構文解析器生成系を出力するので、LALR(1)構文解析器生成系であると言ったりもします。ただし、例外もあります。bisonはyaccと違って、LALR(1)より広いGLR構文解析器を生成できるので、GLR構文解析器生成系であるとも言えるのです。実際には、yaccを使う場合、ほとんどはLALR(1)構文解析器を出力するので、GLRについては言及されることは少ないですが、そのようなことは知っておいても損はないでしょう。 -しかし、従来の手法では文字列リテラルは字句として取り扱わなければいけないため、各言語処理系の実装者はad hocな形で構文解析器に手を加えています。たとえば、Rubyの構文解析器はbisonを使って書かれていますが、字句解析器に状態を持たせることでこの問題に対処しています。文字列リテラル内に`#{"が出現したら状態を式モードに切り替えて、その中で文字列リテラルがあらわれたら文字列リテラルモードに切り替えるといった具合です。 +より大きなくくりでみると、下向き構文解析(LL法やPEG)と上向き構文解析(LR法など)という観点から分類することもできますし、ともに文脈自由文法ベースであるLL法やLR法と、解析表現文法など他の形式言語を用いた構文解析法を対比してみせることもできます。 -一方、PEGでは字句解析と構文解析が分離されていないため、特別な工夫をすることなく文字列補間を実装することができます。以下はRubyの文字列補間と同じようなものをPEGで記述する例です。 +## 6.4 JavaCC:Javaの構文解析生成系の定番 -```text -string <- "\"" ("#{" expression "}" / .)* "\"" -expression <- 式の定義 -``` +1996年、Sun Microsystems(当時)は、Jackという構文解析器生成系をリリースしました。その後、Jackの作者が自らの会社を立ち上げ、JackはJavaCCに改名されて広く知られることとなりましたが、現在では紆余曲折の末、[javacc.github.io](https://javacc.github.io/javacc)の元で開発およびメンテナンスが行われています。現在のライセンスは3条項BSDライセンスです。 -文字列補間を含む文字列リテラルは分解可能という意味で厳密な意味では字句と言えないわけですが、PEGは字句解析を分離しないおかげで文字列リテラルを殊更特別扱いする必要がないわけです。 +JavaCCはLL(1)法を元に作られており、構文定義ファイルからLL(1)パーザを生成します。以下は四則演算を含む数式を計算できる電卓をJavaCCで書いた場合の例です。 + +```java +options { + STATIC = false; + JDK_VERSION = "17"; +} -PEGの利用例が近年増えてきているのは、言語に対してこのようにアドホックに構文を追加したいというニーズがあるためではないかと筆者は考えています。 +PARSER_BEGIN(Calculator) +package com.github.kmizu.calculator; +public class Calculator { + public static void main(String[] args) throws ParseException { + Calculator parser = new Calculator(System.in); + parser.start(); + } +} +PARSER_END(Calculator) + +SKIP : { " " | "\t" | "\r" | "\n" } +TOKEN : { + +| +| +| +| +| +| +} +o +public int expression() : +{int r = 0;} +{ + r=add() { return r; } +} -## 6.2 インデント文法 +public int add() : +{int r = 0; int v = 0;} +{ + r=mult() ( v=mult() { r += v; }| v=mult() { r -= v; })* { + return r; + } +} -Pythonではインデントによってプログラムの構造を表現します。たとえば、次のPythonプログラムを考えます。 -```python -class Point: - def __init__(self, x, y): - self.x = x - self.y = y -``` +public int mult() : +{int r = 0; int v = 0;} +{ + r=primary() ( v=primary() { r *= v; }| r=primary() { r /= v; })* { + return r; + } +} -このPythonプログラムは次のような抽象構文木に変換されると考えられます。 +public int primary() : +{int r = 0; Token t = null;} +{ +( + r=expression() +| t= { r = Integer.parseInt(t.image); } +) { return r; } +} ``` -class -|-- name: Point -|-- def - | -- name: init - | -- arguments - | -- self - | -- x - | -- y - | -- body - | -- self.x = x - | -- self.y = y -``` -インデントによってプログラムの構造を表現するというアイデアは秀逸だと思いますが、一方で、インデントによる構造の表現は明らかに文脈自由言語の範囲を超えるものです。 +の部分はトークン定義になります。ここでは、7つのトークンを定義しています。トークン定義の後が構文規則の定義になります。ここでは、 -Pythonでは字句解析のときにインデントを``、インデントを「外す」のを``というトークンに変換することで構文解析のときに複雑さを持ち込まないようにしています。つまり、``と``というトークンによって挟まれた範囲がクラス定義の本体であったり、メソッド定義の本体であったりという形にして取り扱っているのです。これは括弧の対応をとる問題と同じため、字句解析後のPythonは文脈自由言語の範囲内で取り扱えます。 +- `expression()` +- `add()` +- `mult()` +- `primary()` -上の文だと字句解析後は次のようになります。 +の4つの構文規則が定義されています。各構文規則はJavaのメソッドに酷似した形で記述されますが、実際、ここから生成される.javaファイルには同じ名前のメソッドが定義されます。`expression()`が`add()`を呼び出して、`add()`が`mult()`を呼び出して、`mult()`が`primary()`を呼び出すという構図は第2章で既にみた形ですが、第2章と違って単純に宣言的に各構文規則の関係を書けばそれでOKなのが構文解析器生成系の強みです。 +このようにして定義した電卓プログラムは次のようにして利用することができます。 + +```java +package com.github.kmizu.calculator; + +import jdk.jfr.Description; +import org.junit.jupiter.api.Test; +import com.github.kmizu.calculator.Calculator; +import java.io.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class CalculatorTest { + @Test + @Description("1 + 2 * 3 = 7") + public void test1() throws Exception { + Calculator calculator = new Calculator(new StringReader("1 + 2 * 3")); + assertEquals(7, calculator.expression()); + } + + @Test + @Description("(1 + 2) * 4 = 12") + public void test2() throws Exception { + Calculator calculator = new Calculator(new StringReader("(1 + 2) * 4")); + assertEquals(12, calculator.expression()); + } + + @Test + @Description("(5 * 6) - (3 + 4) = 23") + public void test3() throws Exception { + Calculator calculator = new Calculator(new StringReader("(5 * 6) - (3 + 4)")); + assertEquals(23, calculator.expression()); + } +} ``` - .... + +この`CalculatorTest`クラスではJUnit5を使って、JavaCCで定義した`Calculator`クラスの挙動をテストしています。空白や括弧を含む数式を問題なく計算できているのがわかるでしょう。 + +このようなケースでは先読みトークン数が1のため、JavaCCのデフォルトで構いませんが、定義したい構文によっては先読み数を2以上に増やさなければいけないこともあります。そのときは、以下のようにして先読み数を増やすことができます: + +```java +options { + STATIC = false; + JDK_VERSION = "17"; + LOOKAHEAD = 2 +} ``` -``は`A`という名前のことを表すトークン、``は開き括弧を表すトークン、``はdefキーワードを表すトークンであるものとします。 +ここでは、`LOOKAHEAD = 2`というオプションによって、先読みトークン数を2に増やしています。LOOKAHEADは固定されていれば任意の正の整数にできるので、JavaCCはデフォルトではLL(1)だが、オプションを設定することによってLL(k)になるともいえます。 -しかし、よくよく考えればわかるのですが、``トークンと``トークンを切り出す処理が文脈自由ではありません。つまり、字句解析時に``と``トークンを切り出すために特殊な処理をしていることになります。``トークンは``トークンとスペースの数が同じでなければいけないため、切り出すためには正規表現でも文脈自由文法でも手に余ることは想像できるでしょう。 +また、JavaCCは構文定義ファイルの文法がかなりJavaに似ているため、生成されるコードの形を想像しやすいというメリットがあります。JavaCCはJavaの構文解析生成系の中では最古の部類の割に今でも現役で使われているのは、Javaユーザにとっての使いやすさが背景にあるように思います。 -## 6.3 ヒアドキュメント -ヒアドキュメントは複数行に渡る文字列を記述するための文法で、従来はbashなどのシェル言語で採用されていましたが、PerlやRubyもヒアドキュメントを採用しました。たとえば、RubyでHTMLの文字列をヒアドキュメントで以下のように書くことができます。 +## 6.5 Yacc (GNU Bison):構文解析器生成系の老舗 -```ruby -html = < - Title -

Hello

- -HTML +YaccはYet another compiler compilerの略で、日本語にすると「もう一つのコンパイラコンパイラ」といったところでしょうか。yaccができた当時は、コンパイラを作るためのコンパイラについての研究が盛んだった時期で、構文解析器生成系もそのための研究の副産物とも言えます。1970年代にAT&Tのベル研究所にいたStephen C. Johnsonによって作られたソフトウェアで、非常に歴史がある構文解析器生成系です。YaccはLALR(1)法をサポートし、lexという字句解析器生成系と連携することで構文解析器を生成することができます(もちろん、lexを使わない実装も可能)。Yacc自体は色々な構文解析器生成系に多大な影響を与えており、現在使われているGNU BisonはYaccのGNUによる再実装でもあります。 + +Yaccを使って、四則演算を行う電卓プログラムを作るにはまず字句解析器生成系であるflex用の定義ファイルを書く必要ががあります。その定義ファイル`token.l`は次のようになります: + +```text +%{ +#include "y.tab.h" +%} + +%% +[0-9]+ { yylval = atoi(yytext); return NUM; } +[-+*/()] { return yytext[0]; } +[ \t] { /* ignore whitespce */ } +"\r\n" { return EOL; } +"\r" { return EOL; } +"\n" { return EOL; } +. { printf("Invalid character: %s\n", yytext); } +%% ``` -特筆すべきは、`< +int yylex(void); +void yyerror(char const *s); +int yywrap(void) {return 1;} +extern int yylval; +%} + +%token NUM +%token EOL +%left '+' '-' +%left '*' '/' + +%% + +input : expr EOL + { printf("Result: %d\n", $1); } + ; + +expr : NUM + { $$ = $1; } + | expr '+' expr + { $$ = $1 + $3; } + | expr '-' expr + { $$ = $1 - $3; } + | expr '*' expr + { $$ = $1 * $3; } + | expr '/' expr + { + if ($3 == 0) { yyerror("Cannot divide by zero."); } + else { $$ = $1 / $3; } + } + | '(' expr ')' + { $$ = $2; } + ; + + +void yyerror(char const *s) +{ + fprintf(stderr, "Parse error: %s\n", s); +} + +int main() +{ + yyparse(); +} ``` -これは以下の内容の文字列として解釈されます。 +flexの場合と同じく、`%%`から`%%`までが構文規則の定義の本体です。実行されるコードが入っているので読みづらくなっていますが、それを除くと以下のようになります: + +```text +% { +%token NUM +%token EOL +%left '+' '-' +%left '*' '/' +} + +%% +input : expr EOL + { printf("Result: %d\n", $1); } + ; -```ruby -ここはE1です -ここはE2です +expr : NUM + | expr '+' expr + | expr '-' expr + | expr '*' expr + | expr '/' expr + | '(' expr ')' + ; +%% ``` -ヒアドキュメント内では文字列補間が使えるのでさらに複雑です。以下のようなヒアドキュメントもOKなのです。 +だいぶ見やすくなりましたね。入力を表す`input`規則は`expr EOL`からなります。`expr`は式を表す規則ですから、その後に改行が来れば`input`は終了となります。 + +次に、`expr`規則ですが、ここではyaccの優先順位を表現するための機能である`%left`を使ったため、優先順位のためだけに規則を作る必要がなくなっており、定義が簡潔になっています。ともあれ、こうして定義された文法定義ファイルをyaccに与えると`y.tab.c`というファイルを出力します。 -```ruby -a = 100 -b = 200 -here = <
LL(*) -> ALL(*)と取り扱える文法の幅を広げつつアクティブに開発が続けられています。作者はTerence Parrという方ですが、構文解析器一筋(?)と言っていいくらい、ANTLRにこれまでの時間を費やしてきている人です。 -詳細については、中田先生の[ruby_scalaリポジトリ](https://github.com/inakata/ruby_scala/blob/3f54cc6f80678e30a211fb1374280246f08182ed/src/main/scala/com/github/inakata/ruby_scala/Ruby.scala#L1383)を読んでもらえばわかりますがとても難解な処理になっています。 +それだけに、ANTLRの完成度は非常に高いものになっています。また、一時期はLR法に比べてLL法の評価は低いものでしたが、Terence ParrがLL(k)を改良していく過程で、LL(*)やALL(*)のようなLR法に比べてもなんら劣らない、しかも実用的にも使いやすい構文解析法の発明に貢献したということができます。 -このときはScalaのパーザコンビネータを使ってヒアドキュメントを再現したのですが、引数を取ってコンビネータを返すメソッドを定義することで問題を解決しました。形式言語の文脈でいうのなら、PEGの規則が引数を持てるように拡張することでヒアドキュメントを解釈できるようになったと言うことができます。 +ANTLRはJava、C++などいくつもの言語を扱うことができますが、特に安心して使えるのはJavaです。以下は先程と同様の、四則演算を解析できる数式パーザをANTLRで書いた場合の例です。 -PEGを拡張して規則が引数を持てるようにするという試みは複数ありますが、筆者もMacro PEGというPEGを拡張したものを提案しました。ヒアドキュメントという当たり前に使われている言語機能ですら、構文解析を正しく行うためには厄介な処理をする必要があるのです。 +ANTLRでは構文規則は、`規則名 : 本体 ;` という形で記述しますが、LLパーザ向けの構文定義を素直に書き下すだけでOKです。 -## 6.4 改行終端可能文法 +```java +grammar Expression; -C、C++、Java、C#などの言語では、解釈・実行の基本単位は**文**(Statement)と呼ばれるものになります。また、文はセミコロンなどの終端子と呼ばれるもので終わるか、区切り文字で区切られるのが一般的です。一方、セミコロンが文の区切りになるのがPascalなどの言語です。厳密には違いますが、関数型プログラミング言語Standard MLのセミコロンも似たような扱いです。 +expression returns [int e] + : v=additive {$e = $v.e;} + ; -Javaでは次のように書くことで、A、B、Cを順に出力することができます。 +additive returns [int e = 0;] + : l=multitive {$e = $l.e;} ( + '+' r=multitive {$e = $e + $r.e;} + | '-' r=multitive {$e = $e - $r.e;} + )* + ; + +multitive returns [int e = 0;] + : l=primary {$e = $l.e;} ( + '*' r=primary {$e = $e * $r.e;} + | '/' r=primary {$e = $e / $r.e;} + )* + ; + +primary returns [int e] + : n=NUMBER {$e = Integer.parseInt($n.getText());} + | '(' x=expression ')' {$e = $x.e;} + ; + +LP : '(' ; +RP : ')' ; +NUMBER : INT ; +fragment INT : '0' | [1-9] [0-9]* ; // no leading zeros +WS : [ \t\n\r]+ -> skip ; -```java -System.out.println("A"); -System.out.println("B"); -System.out.println("C"); ``` -このように文が終端子(Terminator)で終わる文法には、文の途中に改行が挟まっても単なるスペースと同様に取り扱えるという利点があります。先程のプログラムを次のように書き換えても意味は代わりません。 +規則`expression`が数式を表す規則です。そのあとに続く`returns [int e]`はこの規則を使って解析を行った場合に`int`型の値を返すことを意味しています。これまで見てきたように構文解析器をした後には抽象構文木をはじめとして何らかのデータ構造を返す必要があります。`returns ...`はそのために用意されている構文です。名前が全て大文字の規則はトークンを表しています。 + +数式を表す各規則についてはこれまで書いてきた構文解析器と同じ構造なので読むのに苦労はしないでしょう。 + +規則`WS`は空白文字を表すトークンですが、これは数式を解析する上では読み飛ばす必要があります。 `[ \t\n\r]+ -> skip`は + +- スペース +- タブ文字 +- 改行文字 + +のいずれかが出現した場合は読み飛ばすということを表現しています。 + +ANTLRは下向き型の構文解析が苦手とする左再帰もある程度扱うことができます。先程の定義ファイルでは繰り返しを使っていましたが、これを左再帰に直した以下の定義ファイルも全く同じ挙動をします。 ```java -System.out.println( - "A"); -System.out.println( - "B"); -System.out.println( - "C"); +grammar LRExpression; + +expression returns [int e] + : v=additive {$e = $v.e;} + ; + +additive returns [int e] + : l=additive op='+' r=multitive {$e = $l.e + $r.e;} + | l=additive op='-' r=multitive {$e = $l.e - $r.e;} + | v=multitive {$e = $v.e;} + ; + +multitive returns [int e] + : l=multitive op='*' r=primary {$e = $l.e * $r.e;} + | l=multitive op='/' r=primary {$e = $l.e / $r.e;} + | v=primary {$e = $v.e;} + ; + +primary returns [int e] + : n=NUMBER {$e = Integer.parseInt($n.getText());} + | '(' x=expression ')' {$e = $x.e;} + ; + +LP : '(' ; +RP : ')' ; +NUMBER : INT ; +fragment INT : '0' | [1-9] [0-9]* ; // no leading zeros +WS : [ \t\n\r]+ -> skip ; ``` -大抵の場合、文は一行で終わるのですから、毎回セミコロンをつけなければいけないのも面倒くさいものです。そういったニーズを反映してか、Scala、Kotlin、Swift、Goなどの比較的新しい言語では(Scalaの初期バージョン -が2003ですから、そこまで新しいのかという話もありますが)、文はセミコロンで終わることもできるが、改行でも終わることができます。より古い言語でもPython、Ruby、JavaScriptも改行で文が終わることができます。 +左再帰を使うことでより簡単に文法を定義できることもあるので、あると嬉しい機能だと言えます。 -たとえば、先程のJavaプログラムに相当するScalaプログラムは次のようになります。 +さらに、ANTLRは`ALL(*)`というアルゴリズムを採用しているため、通常のLLパーザでは扱えないような文法定義も取り扱うことができます。以下の「最小XML」文法定義を見てみましょう。 -```scala -println("A") -println("B") -println("C") +```java +grammar PetitXML; +@parser::header { + import static com.github.asciidwango.parser_book.ch5.PetitXML.*; + import java.util.*; +} + +root returns [Element e] + : v=element {$e = $v.e;} + ; + +element returns [Element e] + : ('<' begin=NAME '>' es=elements '' {$begin.text.equals($end.text)}? + {$e = new Element($begin.text, $es.es);}) + | ('<' name=NAME '/>' {$e = new Element($name.text);}) + ; + +elements returns [List es] + : { $es = new ArrayList<>();} (element {$es.add($element.e);})* + ; + +LT: '<'; +GT: '>'; +SLASH: '/'; +NAME: [a-zA-Z_][a-zA-Z0-9]* ; + +WS : [ \t\n\r]+ -> skip ; ``` -見た目にもすっきりしますし、改行を併用するコーディングスタイルが大半であることを考えても、無駄なタイピングが減るしでいいことずくめです。Scalaではそれでいて、次のように文の途中で改行が入っても問題なく解釈・実行することができます。 +`PeitXML`の名の通り、属性やテキストなどは全く扱うことができず、``や``、``といった要素のみを扱うことができます。規則`element`が重要です。 -```scala -println( - "A") -println( - "B") -println( - "C") +``` +element returns [Element e] + : ('<' begin=NAME '>' es=elements '' {$begin.text.equals($end.text)}? + {$e = new Element($begin.text, $es.es);}) + | ('<' name=NAME '/>' {$e = new Element($name.text);}) + ; ``` -Scalaでも一行の文字数が増えれば分割したくなりますから、このような機能があるのは自然でしょう。 +ここで空要素(``など)に分岐するか、子要素を持つ要素(``など)に分岐するかを決定するには、`<`に加えて、任意の長さになり得るタグ名まだ先読みしなければいけません。通常のLLパーザでは何文字(何トークン)先読みしているのは予め決定されているのでこのような文法定義を取り扱うことはできません。しかし、ANTLRの`ALL(*)`アルゴリズムとその前身となる`LL(*)`アルゴリズムでは任意個の文字数を先読みして分岐を決定することができます。 -ここで一つの疑問が湧きます。「文は改行で終わる」という規則なら改行が来たときに「文の終わり」とみなせばよいですし、「文はセミコロンで終わる」という規則なら、セミコロンが来たときに「文の終わり」とみなせば問題ありません。しかしながら、このような文法を実現するためには「セミコロンが来れば文が終わるが、改行で文が終わることもある」というややこしい規則に基づいて構文解析をしなければいけません。 +ANTLRでは通常のLLパーザで文法を記述する上での大きな制約がないわけで、これは非常に強力です。理論的な意味での記述能力でも`ALL(*)`アルゴリズムは任意の決定的な文脈自由言語を取り扱うことができます。 -このような文法を実現するのは案外ややこしいものです。Javaの`System.out.println("A");`という文は正確には「式文」と呼ばれますが、この式文は次のように定義されます。 +また、`ALL(*)`アルゴリズム自体とは関係ありませんが、XMLのパーザを書くときには開きタグと閉じタグの名前が一致している必要があります。この条件を記述するために`PetitXML`では次のように記述されています。 -```text -expression_statement ::= expression +```java +'<' begin=NAME '>' es=elements '' {$begin.text.equals($end.text)}? ``` -``はセミコロンを表すトークンです。では、Scala式の文法を「改行でもセミコロンでも終わることができる」と考えて次のように記述しても大丈夫でしょうか。 +この中の`{$begin.text.equals($end.text)}?`という部分はsemantic predicateと呼ばれ、プログラムとして書かれた条件式が真になるときにだけマッチします。semantic predicateのような機能はプログラミング言語をそのまま埋め込むという意味で、正直「あまり綺麗ではない」と思わなくもないですが、実用上はsemantic predicateを使いたくなる場面にしばしば遭遇します。 -```text -expression_statement ::= expression - | expression -``` +ANTLRはこういった実用上重要な痒いところにも手が届くように作られており、非常によくできた構文解析機生成系といえるでしょう。 + +## 6.7 SComb + +手前味噌ですが、拙作のパーザコンビネータである[SComb](https://github.com/kmizu/scomb)も紹介しておきます。これまで紹介してきたものはすべて構文解析器生成系です。つまり、独自の言語を用いて作りたい言語の文法を記述し、そこから**対象言語**(CであったりJavaであったり様々ですが)で書かれた構文解析器を生成するものだったわけですが、パーザコンビネータは少々違います。 +パーザコンビネータでは対象言語のメソッドや関数、オブジェクトとして構文解析器を定義し、演算子やメソッドによって構文解析器を組み合わせることで構文解析器を組み立てていきます。パーザコンビネータではメソッドや関数、オブジェクトとして規則自体を記述するため、特別にプラグインを作らなくてもIDEによる支援が受けられることや、対象言語が静的型システムを持っていた場合、型チェックによる支援を受けられることがメリットとして挙げられます。 -``は改行を表すトークンです。プラットフォームによって改行コードは異なるので、このように定義しておくと楽でしょう。このような規則でうまく先程の例全てをうまく取り扱えるかといえば、端的に言って無理です。 +SCombで四則演算を解析できるプログラムを書くと以下のようになります。先程述べたようにSCombはパーザコンビネータであり、これ自体がScalaのプログラム(`object`宣言)でもあります。 ```scala -println( - "A") +object Calculator extends SCombinator { + // root ::= E + def root: Parser[Int] = E + + // E ::= A + def E: Parser[Int] = rule(A) + + // A ::= M ("+" M | "-" M)* + def A: Parser[Int] = rule(chainl(M) { + $("+").map { op => (lhs: Int, rhs: Int) => lhs + rhs } | + $("-").map { op => (lhs: Int, rhs: Int) => lhs - rhs } + }) + + // M ::= P ("+" P | "-" P)* + def M: Parser[Int] = rule(chainl(P) { + $("*").map { op => (lhs: Int, rhs: Int) => lhs * rhs } | + $("/").map { op => (lhs: Int, rhs: Int) => lhs / rhs } + }) + + // P ::= "(" E ")" | N + def P: P[Int] = rule{ + (for { + _ <- string("("); e <- E; _ <- string(")")} yield e) | N + } + + // N ::= [0-9]+ + def N: P[Int] = rule(set('0'to'9').+.map{ digits => digits.mkString.toInt}) + + def parse(input: String): Result[Int] = parse(root, input) +} ``` -C系の言語では行コメントなどの例外を除き、字句解析時に改行もスペースも同じ扱いで処理しているので、このような「式の途中で改行が来る」ケースも特に工夫する必要がありませんでした。しかし、Scalaなどの言語における「改行」は式の途中では無視されるが文末では終端子にもなり得るという複雑な存在です。 +各メソッドに対応するBNFによる規則をコメントとして付加してみましたが、BNFと比較しても簡潔に記述できているのがわかります。Scalaは記号をそのままメソッドとして記述できるなど、元々DSLに向いている特徴を持った言語なのですが、その特徴を活用しています。`chainl`というメソッドについてだけは見慣れない読者の方は多そうですが、これは -言い換えると「文脈」を考慮して改行を取り扱う必要がでてきたのです。このような文法はどのようにすれば取り扱えるでしょうか。なかなか難しい問題ですが、大きく分けて二つの戦略があります。 +``` +// M ::= P ("+" P | "-" P)* +``` -一つ目は字句解析器に文脈情報を持たせる方法です。たとえば、「式」モードでは改行は無視されるが、「文」モードだと無視されないという風にした上で、式が終わったら「文」モードに切り替えを行い、式が開始したら「式」モードに切り替えを行います。この方式を採用している典型的な言語がRubyで、Cで書かれた字句解析器には実に多数の文脈情報を持たせています。 +のような二項演算を簡潔に記述するためのコンビネータ(メソッド)です。パーザコンビネータの別のメリットとして、BNF(あるいはPEG)に無いような演算子をこのように後付で導入できることも挙げられます。構文規則からの値(意味値)の取り出しもScalaのfor式を用いて簡潔に記述できています。 -```c -// https://github.com/ruby/ruby/blob/v3_2_0/parse.y#L161-L181 -/* examine combinations */ -enum lex_state_e { -#define DEF_EXPR(n) EXPR_##n = (1 << EXPR_##n##_bit) - DEF_EXPR(BEG), - DEF_EXPR(END), - DEF_EXPR(ENDARG), - DEF_EXPR(ENDFN), - DEF_EXPR(ARG), - DEF_EXPR(CMDARG), - DEF_EXPR(MID), - DEF_EXPR(FNAME), - DEF_EXPR(DOT), - DEF_EXPR(CLASS), - DEF_EXPR(LABEL), - DEF_EXPR(LABELED), - DEF_EXPR(FITEM), - EXPR_VALUE = EXPR_BEG, - EXPR_BEG_ANY = (EXPR_BEG | EXPR_MID | EXPR_CLASS), - EXPR_ARG_ANY = (EXPR_ARG | EXPR_CMDARG), - EXPR_END_ANY = (EXPR_END | EXPR_ENDARG | EXPR_ENDFN), - EXPR_NONE = 0 -}; -``` +筆者は自作言語Klassicの処理系作成のためにSCombを使っていますが、かなり複雑な文法を記述できるにも関わらず、SCombのコア部分はわずか600行ほどです。それでいて高い拡張性や簡潔な記述が可能なのは、Scalaという言語の能力と、SCombがベースとして利用しているPEGという手法のシンプルさがあってのものだと言えるでしょう。 -「改行で文が終わる」以外にもRubyはかなり複雑な構文解析を行っているため、このように多数の状態を字句解析器に持たせる必要があります。Rubyの文法は私が知る限り**もっとも複雑なものの一つ**なのでややこれは極端ですが、字句解析器に状態を持たせるアプローチは他の言語も採用していることが多いようです。 +## 6.8 パーザコンビネータJCombを自作しよう! -別のアプローチとして既出のPEGを使うという方法があります。PEGでは字句解析という概念自体がありませんから、式の途中に改行が入るというのも構文解析レベルで処理できます。Python 3.10以降、PEGを用いた構文解析器が採用されていると書きましたが、このアプローチが採用されているかは確認できています。 +コンパイラについて解説した本は数えきれないほどありますし、その中で構文解析アルゴリズムについて説明した本も少なからずあります。しかし、構文解析アルゴリズムについてのみフォーカスした本はParsing Techniquesほぼ一冊といえる現状です。その上でパーザコンビネータの自作まで踏み込んだ書籍はほぼ皆無と言っていいでしょう。読者の方には「さすがにちょっとパーザコンビネータの自作は無理があるのでは」と思われた方もいるのではないでしょうか。 -例として拙作のプログラミング言語Klassicでは次のようにして式の合間に改行を挟むことができるようにしています。 +しかし、驚くべきことに、現代的な言語であればパーザコンビネータを自作するのは本当に簡単です。きっと、多くの読者の方々が拍子抜けしてしまうくらいに。この節ではJavaで書かれたパーザコンビネータJCombを自作する過程を通じて皆さんにパーザコンビネータとはどのようなものかを学んでいただきます。パーザコンビネータと構文解析器生成系は物凄く雑に言ってしまえば近縁種のようなものですし、パーザコンビネータの理解は構文解析器生成系の仕組みの理解にも役立つはずです。きっと。 -```scala -//add ::= term {"+" term | "-" term} -lazy val add: Parser[AST] = rule{ - chainl(term)( - (%% << CL(PLUS)) ^^ { location => (left: AST, right: AST) => BinaryExpression(location, Operator.ADD, left, right) } | - (%% << CL(MINUS)) ^^ { location => (left: AST, right: AST) => BinaryExpression(location, Operator.SUBTRACT, left, right) } - ) +まず復習になりますが、構文解析器というのは文字列を入力として受け取って、解析結果を返す関数(あるいはオブジェクト)とみなせるのでした。これはパーザコンビネータ、特にPEGを使ったパーザコンビネータを実装するときに有用な見方です。この「構文解析器はオブジェクトである」を文字通りとって、以下のようなジェネリックなインタフェース`JParser`を定義します。 + +```java +interface JParser { + Result void parse(String input); } ``` -関数`CL()`は次のように定義されます。 +ここで構文解析器を表現するインタフェース`JParser`は型パラメータ`R`を受け取ることに注意してください。一般に構文解析の結果は抽象構文木になりますが、インタフェースを定義する時点では抽象構文木がどのような形になるかはわかりようがないので、型パラメータにしておくのです。`JParser`はたった一つのメソッド`parse()`を持ちます。`parse()`は入力文字列`input`を受け取り、解析結果を`Result`として返します。 -```scala - def CL[T](parser: Parser[T]): Parser[T] = parser << SPACING +`JParser`の実装は一体全体どのようなものになるの?という疑問を脇に置いておけば理解は難しくないでしょう。次に解析結果`Result`をレコードとして定義します。 + +```java +record Result(V value, String rest){} ``` -Klassicの構文解析器は自作のパーザコンビネータライブラリで構築されているので少々ややこしく見えますが、要約すると、`CL()`は引数に与えたものの後に任意個のスペース(改行)が来るという意味で、キーワードである`PLUS`や`MINUS`の後にこのような規則を差し込むことで「式の途中での改行は無視」が実現できています。 +レコード`Result`は解析結果を保持するクラスです。`value`は解析結果の値を表現し、`rest`は解析した結果「残った」文字列を表します。 + +このインタフェース`JParser`は次のように使えると理想的です。 -現在ある言語で採用されているかはわかりませんが、GLR法のようにスキャナレス構文解析と呼ばれる他の手法を使う方法もあります。スキャナレスということは字句解析が無いということですが、字句解析器を別に必要としない構文解析法の総称を指します。PEGも字句解析器を必要としませんから、PEGもスキャナレス構文解析の一種と言えます。 +```java +JParser calculator = ...; +Result result = calculator.parse("1+2*3"); +assert 7 == result.value(); +``` -ともあれ、私達が普通に使っている「改行で文が終わる」ようにできる処理一つとっても厄介な問題だということです。 +パーザコンビネータは、このようなどこか都合の良い`JParser`を、BNF(あるいはPEG)に近い文法規則を連ねていくのに近い使い勝手で構築するための技法です。前の節で紹介した`SComb`もパーザコンビネータでしたが基本的には同じようなものです。 -## 6.5 Cのtypedef +この節では最終的に上のような式を解析できるパーザコンビネータを作るのが目標です。 -C言語の`typedef`文は既存の型に別名をつける機能です。C言語をバリバリ書いているプログラマの型ならお馴染みの機能でしょう。Cのtypdefは +### 6.8.1 部品を考えよう -- 移植性を高める -- 関数ポインタを使ったよみづらい宣言を読みやすくする +これからパーザコンビネータを作っていくわけですが、パーザコンビネータの基本となる「部品」を作る必要があります。 -といった目的で使われますが、この`typedef`文が意外に曲者だったりします。以下は`i`をint型の別名として定義するものですが、同時にローカル変数`i`を`i`型として定義しています。 +まず最初に、文字列リテラルを受け取ってそれを解析できる次のような`string()`メソッドは是非とも欲しいところです。 -```c -typedef int i; -int main(void) { - i i = 100; // OK - int x = (i)'a'; // ERROR - return 0; -} +```java +assert new Result("123", "").equals(string("123").parse("123")); ``` -現実にこのようなコードの書き方をするかはともかく、`i i = 100;`は明らかにOKな表現として解析してあげなければいけません。一方で、`int x = (i)'a';`は構文解析エラーになります。`i i = 100;`という宣言がなければこの文も通るのですが、合わせて書けば構文解析エラーです。それ以前の文脈である識別子がtypedefされたかどうかで構文解析の結果が変わるのですからとてもややこしいです。 +これはBNFで言えば文字列リテラルの表記に相当します。 -C言語ではこのようなややこしい構文を解析するために、typdefした識別子を連想配列の形で持っておいて、構文解析時にそれを使うという手法を採用しています。 +次に、解析に成功したとしてその値を別の値に変換するための方法もほしいところです。たとえば、`123`という文字列を解析したとして、これは最終的に文字列ではなくintに変換したいところです。このようなメソッドは、ラムダ式で変換を定義できるように、次のような`map()`メソッドとして提供したいところです。 -## 6.6 Scalaでの「文頭に演算子が来る場合の処理」 +```java + JParser map(Parser parser, Function function); +assert (new Result(123, "")).equals(map(string("123"), v -> Integer.parseInt(v)).parse("123")); +``` -6.4 で改行で文を終端する文法について説明しましたが、Scalaはさらにややこしい入力を処理できなければいけません。たとえば、以下のような文を解釈できる必要があります。 +これは構文解析器生成系でセマンティックアクションを書くのに相当すると言えるでしょう。 -```scala -val x = 1 - + 2 -println(x) // 3 +BNFで`a | b`、つまり選択を書くのに相当するメソッドも必要です。これは次のような`alt()`メソッドとして提供します。 + +```java + JParser alt(Parser p1, Parser p1); +assert (new Result("bar", "")).equals(alt(string("foo"), string("bar"))); ``` -ここで、xを3とちゃんと解釈するには`val x = 1`が改行で終わったから「文が終わった」と解釈せず、次のトークンである`+`まで見てから文が終わるか判定する必要があります。これまで試した限り、同じことができるのはJavaScriptくらいで、Ruby、Python、Kotlin、Go、Swiftなどの言語ではエラーになるか、`x = 1`で文が終わったと解釈され、`+ 2`は別の文として解釈されるケースばかりでした。 +同様に、BNFで`a b`、つまり連接」を書くのに相当するメソッドも必要ですが、これは次のような`seq()`メソッドとして提供します。 -この処理について、Scala言語仕様内の[1.2 Newline Characters](https://scala-lang.org/files/archive/spec/2.13/01-lexical-syntax.html)に関連する記述があります。 +```java +record Pair(A a, B b){} + JParser> seq(Parser p1, Parser p2); +assert (new Result<>(new Pair("foo", "bar"), "")).equals(seq(string("foo"), string("bar"))) +``` -> Scala is a line-oriented language where statements may be terminated by semi-colons or newlines. A newline in a Scala source text is treated as the special token “nl” if the three following criteria are satisfied: -> 1. The token immediately preceding the newline can terminate a statement. -> 2. The token immediately following the newline can begin a statement. -> 3. The token appears in a region where newlines are enabled. -> The tokens that can terminate a statement are: literals, identifiers and the following delimiters and reserved words: +最後に、BNFで`a*`、つまり0回以上の繰り返しに相当する`rep0()`メソッド -これを意訳すると、通常の場合はScalaの文はセミコロンまたは改行で終わることができるが、次の三つの条件**全て**を満たしたときのみ、特別なトークン`nl`として扱われることになる、ということになります。 +```java + JParser> rep0(Parser p); +assert (new Result>(List.of(), "")).equals(rep0(string(""))) +``` -1. 改行の直前のトークンが「文を終わらせられる」ものである場合 -2. 改行の直後のトークンが「文を始められる」ものである場合 -3. 改行が「利用可能」になっている箇所にあらわれたものである場合 +や`a+`、つまり1回以上の繰り返しに相当する`rep1()`メソッドもほしいところです。 -たとえば、以下のScalaプログラムについていうと、最初の改行は`nl`トークンになりませんが、何故かというと条件1が満たされても条件2が満たされないからです。 +```java + JParser> rep1(Parser p); +assert (new Result>(List.of("a", "a", "a"), "")).equals(rep1(string("aaa"))) +``` -```scala -val x = 1 - + 2 +この節ではこれらのプリミティブなメソッドの実装方法について説明していきます。 + +### 6.8.2 `string()`メソッド + +まず最初に`string(String literal)`メソッドで返す`JParser`の中身を作ってみましょう。`JParser`クラスはただ一つのメソッド`parser()`をもつので次のような実装になります。 + +```java +class JLiteralParser implements JParser { + private String literal; + public JLiteralParser(String literal) { + this.literal = literal; + + } + public Result parse(String input) { + if(input.startsWith(literal)) { + return new Result(literal, input.substring(literal.length())); + } else { + return null; + } + } +} ``` -ちなみに、調査を開始する時点ではScalaの文法の基本文法を継承したKotlinでも同じようになっていると思っていたのですが、一行目で文が終わると解釈されてしまいました。 +このクラスは次のようにして使います。 -```kotlin -val x = 1 - + 2 // + 2は単独の式として解釈されてしまう -println(x) // 1 +```java +assert new Result("foo", "").equals(new JLiteralParser("foo")); ``` -## 6.7 プレースホルダー構文 +リテラルを表すフィールド`literal`が`input`の先頭とマッチした場合、`literal`と残りの文字列からなる`Result`を返します。そうでない場合は返すべき`Result`がないので`null`を返します。簡単ですね。あとはこのクラスのインスタンスを返す`string()`メソッドを作成するだけです。なお、使うときの利便性のため、以降では各種メソッドはクラス`JComb`のstaticメソッドとして実装していきます。 -Scalaにはプレースホルダー構文、正確にはPlaceholder Syntax for Anonymous Functionと呼ばれる構文があります。これは最近の言語ではすっかり普通に使えるようになったいわゆる**ラムダ式**を簡易表記するための構文です。 +```java +public class JComb { + JParser string(String literal) { + return new JLiteralParser(literal); + } +} +``` -たとえば、Scalaで`[1, 2, 3, 4]`というリストの要素全てを2倍するという処理はラムダ式(Scalaでは単純に無名関数という用語)を使って次のように書くことができます。 +使う時は次のようになります。 -```scala -List(1, 2, 3, 4).map(x => x + 1) +```java +JParser foo = string("foo"); +assert new Result("foo", "_bar").equals(foo.parse("foo_bar")); +assert null == foo.parse("baz"); ``` -ラムダ式を普段から使っておられる読者には大体雰囲気で伝わると思うのですが、`map`は多くの言語で採用されている高階関数です。`map`は引数で渡された無名関数をリストの各要素に適用して、その結果できた新しいリストを返します。たとえば、上のプログラムだと実行結果は次のようになります。 +### 6.8.3 `alt()`メソッド +次に二つのパーザを取って「選択」パーザを返すメソッド`alt()`を実装します。先程のようにクラスを実装してもいいですが、メソッドは一つだけなのでラムダ式にします。 -```scala -List(2, 4, 6, 8) +```java +public class JComb { + // p1 / p2 + public static JParser alt(JParser p1, JParser p2) { + return (input) -> { + var result = p1.parse(input);//(1) + if(result != null) return result;//(2) + return p2.parse(input);//(3) + }; + } +} ``` -しかし、`map()`に渡す無名関数を毎回`x => x + 1`のように書かないといけないのも冗長です。というわけで、Scalaでは次のように無名関数を簡易表記することができます。 +ラムダ式について復習しておくと、これは実質的には以下のような匿名クラスを書いたのと同じになります。 -```scala -List(1, 2, 3, 4).map(_ + 1) +```java +public class JComb { + public static JParser alt(JParser p1, JParser p2) { + return new JAltParser(p1, p2); + } +} + +class JAltParser implements JParser { + private JParser p1, p2;; + public JAltParser(Parser p1, Parser p2) { + this.p1 = p1; + this.p2 = p2; + } + public Result parse(String input) { + var result = p1.parse(input); + if(result != null) return result; + return p2.parse(input); + } +} ``` -これは構文解析のときに、先程の +この定義では、 -```scala -List(1, 2, 3, 4).map(x => x + 1) +(1) まずパーザ`p1`を試しています +(2) `p1`が成功した場合は`p2`を試すことなく値をそのまま返します +(3) `p1`が失敗した場合、`p2`を試しその値を返します + +のような挙動をします。 + +しかしこれはBNFというよりPEGの挙動です。そうです。実は今ここで作っているパーザコンビネータである`JComb`は(`SComb`)PEGをベースとしたパーザコンビネータだったのです。もちろん、PEGベースでないパーザコンビネータを作ることも出来るのですが実装がかなり複雑になってしまいます。PEGの挙動をそのままプログラミング言語に当てはめるのは非常に簡単であるため、今回はPEGを採用しましたが、もし興味があればBNFベース(文脈自由文法ベース)のパーザコンビネータも作ってみてください。 + +### 6.8.4 `seq()`メソッド + +次に二つのパーザを取って「連接」パーザを返すメソッド`seq()`を実装します。先程と同じくラムダ式にしてみます。 + +```java +record Pair(A a, B b){} +// p1 p2 +public class JComb { + public static JParser> seq(JParser p1, JParser p2) { + return (input) -> { + var result1 = p1.parse(input); //(1-1) + if(result1 == null) return null; //(1-2) + var result2 = p2.parse(result1.rest()); //(2-1) + if(result2 == null) return null; //(2-2) + return new Result<>(new Pair(result1.value(), result2.value()), result2.rest());//(2-3) + }; + } +} ``` -に展開されます。出てくる`_`のことをプレースホルダ(placeholder)と呼びます。例によってこの構文は非常に扱いが厄介です。何故かというと、`_`がどのような無名関数を表すかを構文解析時に決定するのはかなり困難なのです。すぐに思いつくのは、`_`はそれを囲む「最小の式」を無名関数に変換すると定義という方法です。しかし、これはプレースホルダが二つ以上出てくると破綻します。 +先程の`alt()`メソッドと似通った実装ですが、p1が失敗したら全体が失敗する(nullを返す:(1-2))のがポイントです。p1とp2の両方が成功した場合は、二つの値のペアを返しています(2-3)。 -たとえば、別の高階関数`foldLeft()`を使った例を見てみます。 +#### 6.8.5 `rep0()`, `rep1()`メソッド -```scala -List(1, 2, 3, 4).foldLeft(0)(_ + _) +残りは0回以上の繰り返し(`p*`)を表す`rep0()`と1回以上の繰り返し(`p+`)を表す`rep1()`メソッドです。 + +まず、`rep0()`メソッドは次のようになります。 + +```java +public class JComb { + public static JParser> rep0(JParser p) { + return (input) -> { + var result = p.parse(input); // (1) + if(result == null) return new Result<>(List.of(), input); // (2) + var value = result.value(); + var rest = result.rest(); + var result2 = rep0(p).parse(rest); //(3) + if(result2 == null) return new Result<>(List.of(value), rest); + List values = new ArrayList<>(); + values.add(value); + values.addAll(result2.value()); + return new Result<>(values, result2.rest()); + }; + } +} ``` -リスト`[1, 2, 3, 4]`の合計値である`10`を計算してくれます。このプレースホルダ構文は次のように変換されます。 +(1)でまずパーザ`p`を適用しています。ここで失敗した場合、0回の繰り返しにマッチしたことになるので、空リストからなる結果を返します((2))。そうでなければ、1回以上の繰り返しにマッチしたことになるので、繰り返し同じ処理をする必要がありますが、これは再帰呼出しによって簡単に実装できます((3))。シンプルな実装ですね。 -```scala -List(1, 2, 3, 4).foldLeft(0)((x, y) => x + y) + +`rep1(p)`は意味的には`seq(p, rep0(p))`なので、次のようにして実装を簡略化することができます。 + + +```java +public class JComb { + public static JParser> rep1(JParser p) { + JParser>> rep1Sugar = seq(p, rep0(p)); + return (input) -> { + var result = rep1Sugar.parse(input);//(1) + if(result == null) return null;//(2) + var values = new ArrayList<>(); + values.add(rep1Sugar.b()); + values.addAll(rep1Sugar.b()); + return new Result<>(values, result.rest()); //(3) + }; + } +} ``` -このケースでは、`(_ + _)`が`((x, y) => x + y)`という無名関数に変換されたわけですが、プレースホルダが複数出現するとややこしい問題になります。 +実質的な本体は(1)だけで、あとは結果の値が`Pair`なのを`List`に加工しているだけですね。 +### 6.8.6 `map()`メソッド -また、そもそもプレースホルダが単一であっても解釈が難しい問題もあります。たとえば、次の式は考えてみます。 +パーザを加工して別の値を生成するためのメソッド`map()`をに実装してみましょう。`map()`は`JParser`のメソッドとして実装するとメソッドチェインが使えて便利なので、インタフェースの`default`メソッドとして実装します。 -```scala -List(1, 2, 3, 4).map(_ * 2 + 3) + +```java +interface JParser { + Result parse(String input); + + default JParser map(Function f) { + return (input) -> { + var result = this.parse(input); + if (result == null) return null; + return new Result<>(f.apply(result.value()), result.rest()); (1) + }; + } +} ``` -これは以下のScalaプログラムに変換されます。 +(1)で`f.apply(result.value())`として値を加工しているのがポイントでしょうか。 -```scala -List(1, 2, 3, 4).map(x => x * 2 + 3) -```` +### 6.8.7 `lazy()`メソッド -もし、`_`を含む「最小の式」を無名関数にするという方式だと`(_ * 2)`が`(x => x * 2)`という無名関数に変換されても良さそうですがそうはなっていません。また、ユーザーのニーズを考えてもそうなっては欲しくありません。 +パーザを遅延評価するためのメソッド`lazy()`も導入します。Javaはデフォルトでは遅延評価を採用しない言語なので、再帰的な規則を記述するときにこのようなメソッドがないと無限に再帰してスタックオーバーフローを起こしてしまいます。 -Scalaではこのプレースホルダ構文をどう扱っているかというと非常に複雑で一言では説明しきれない部分があるのですが、あえておおざっぱに要約すると、次のようになります。 +```java +public class JComb { + public static JParser lazy(Supplier> supplier) { + return (input) -> supplier.get().parse(input); + } +} +``` -1. `_` をアンダースコアセクション(underscore section)と呼ぶ -2. 無名関数になる範囲の式`e`は構文カテゴリ(syntactic category)`Expr`に属しており、アンダースコアセクション`u`について次の条件を満たす必要がある: - 2-1. `e`は真に(property)`u`を含んでいる - 2-2. `e`の中に構文カテゴリ`Expr`に属する式は存在しない +### 6.8.8 `regex()`メソッド -といっても、これだけだとわかりませんよね。たとえば、 +せっかくなので正規表現を扱うメソッド`regex()`も導入してみましょう。 +```java +public class JComb { + public static JParser regex(String regex) { + return (input) -> { + var matcher = Pattern.compile(regex).matcher(input); + if(matcher.lookingAt()) { + return new Result<>(matcher.group(), input.substring(matcher.end())); + } else { + return null; + } + }; + } +} ``` -map(_ * 2 + 3) + +引数として与えられた文字列を`Pattern.compile()`で正規表現に変換して、マッチングを行うだけです。これは次のようにして使うことができます。 + +```java +var number = regex("[0-9]+").map(v -> Integer.parseInt(v)); +assert (new Result(10, "")).equals(number.parse("10")); ``` -では`_ * 2 + 3`までが「無名関数化」される範囲ですが、これをいったん括弧つきで表記すると`(_ * 2) + 3`となります。Scalaの構文解析上のルールでは、演算子を使った式は単独では構文カテゴリ`Expr`に属しません。メソッドの引数になっている`(_ * 2 + 3)`まで来て初めて、この式は構文カテゴリ`Expr`になります。 +### 6.8.9 算術式のインタプリタを書いてみる + +ここまでで作ったクラス`JComb`と`JParser`などを使っていよいよ簡単な算術式のインタプリタを書いてみましょう。仕様は次の通りです。 -端的に言って、この時点で既に目眩がするような内容です。というのは、構文カテゴリという情報自体が構文解析の途中でなければ取り出せない情報であり、つまり、Scalaでは構文解析の*途中*にうまくプレースホルダを処理する必要があるのです。このプレースホルダ構文をきっちり解説するのは本書の内容を超えますが、やはりこの問題も文脈自由言語の範囲で扱うことができません。 +- 扱える数値は整数のみ +- 演算子は加減乗除(`+|-|*|/`)のみ +- `()`によるグルーピングができる -Scala処理系内部でプレースホルダ構文がどのように実装されているかも読んだことがありますが、とてもややこしくいものでした。C言語のtypedefは構文解析の途中で連想配列に名前を登録すればいいだけまだマシですが、さらに厄介だというのが正直な印象です。 +実装だけを提示すると次のようになります。 -なお、プレースホルダ構文について「きちんとした定義」を参照されたい方は、[Scala Language Specification 6.23.2: Placeholder Syntax for Anonymous Function](https://www.scala-lang.org/files/archive/spec/2.13/06-expressions.html#placeholder-syntax-for-anonymous-functions)を読んでいただければと思います。 +```java +public class Calculator { + public static JParser expression() { + /* + * expression <- additive ( ("+" / "-") additive )* + */ + return seq( + lazy(() -> additive()), + rep0( + seq( + alt(string("+"), string("-")), + lazy(() -> additive()) + ) + ) + ).map(p -> { + var left = p.a(); + var rights = p.b(); + for (var right : rights) { + var op = right.a(); + var rightValue = right.b(); + if (op.equals("+")) { + left += rightValue; + } else { + left -= rightValue; + } + } + return left; + }); + } + + public static JParser additive() { + /* + * additive <- primary ( ("*" / "/") primary )* + */ + return seq( + lazy(() -> primary()), + rep0( + seq( + alt(string("*"), string("/")), + lazy(() -> primary()) + ) + ) + ).map(p -> { + var left = p.a(); + var rights = p.b(); + for (var right : rights) { + var op = right.a(); + var rightValue = right.b(); + if (op.equals("*")) { + left *= rightValue; + } else { + left /= rightValue; + } + } + return left; + }); + } + + public static JParser primary() { + /* + * primary <- number / "(" expression ")" + */ + return alt( + number, + seq( + string("("), + seq( + lazy(() -> expression()), + string(")") + ) + ).map(p -> p.b().a()) + ); + } + + // number <- [0-9]+ + private static JParser number = regex("[0-9]+").map(Integer::parseInt); +} +``` -## 6.7 エラーリカバリ +表記は冗長なもののほぼPEGに一対一に対応しているのがわかるのではないでしょうか? -構文解析の途中でエラーが起きることは(当然ながら)普通にあります。構文解析中のエラーリカバリについては多くの研究があるものの、コンパイラの教科書で構文解析アルゴリズでのエラーリカバリについて言及されることは稀です。推測ですが、構文解析において2つ目以降のエラーは大抵最初のエラーに誘発されて起こるということや、どうしても経験則に頼った記述になりがちなため、教科書で言及されることは少ないのでしょう。また、大抵の言語処理系で構文解析中のエラーリカバリについては大したことをしていなかったという歴史的事情もあるかもしれません。 +これに対してJUnitを使って以下のようなテストコードを記述してみます。無事、意図通りに解釈されていることがわかります。 -しかし、現在は別の観点から構文解析中のエラーリカバリが重要性を増してきています。それは、テキストエディタの拡張としてIDEのような「構文解析エラーになるが、それっぽくなんとか構文解析をしなければいけない」というニーズがあるからです。 +```java +assertEquals(new Result<>(7, ""), expression().parse("1+2*3")); // テストをパス +``` -TBD +DSLに向いたScalaに比べれば大幅に冗長になったものの、手書きで再帰下降パーザを組み立てるのに比べると大幅に簡潔な記述を実現することができました。しかも、JComb全体を通しても500行にすら満たないのは特筆すべきところです。Javaがユーザ定義の中置演算子をサポートしていればもっと簡潔にできたのですが、そこは向き不向きといったところでしょうか。 -## 6.8 まとめ +### 6.9 まとめ -6章では現実の構文解析で遭遇する問題について、いくつかの例を挙げて説明しました。筆者が大学院博士後期課程に進学した頃「構文解析は終わった問題」と言われたのを覚えていますが、実際にはその後もANTLRの`LL(*)`アルゴリズムのような革新が起きていますし、細かいところでは今回の例のように従来の構文解析法単体では取り扱えない部分をアドホックに各プログラミング言語が補っている部分があります。 +パーザコンビネータを使うと、手書きでパーザを書いたり、あるいは、対象言語に構文解析器生成系がないようなケースでも、比較的気軽にパーザを組み立てるためのDSL(Domain Specific Language)を定義できるのです。また、それだけでなく、特にJavaのような静的型付き言語を使った場合ですが、IDEによる支援も受けられますし、BNFやPEGにはない便利な演算子を自分で導入することもできます。 -このような問題が起きるのは結局のところ、当初の想定と違って「プログラミング言語は文脈自由言語として表せ」なかったという事です。より厳密には当然、文脈自由言語の範囲に納めることもできますが、便利な表記を許していくとどうしても文脈自由言語から「はみ出て」しまうということです。このような「現実のプログラミング言語の文脈依存性」については専門の研究者以外には案外知られていなかったりしますが、ともあれこのような問題があることを知っておくのは、既存言語の表記法を取り入れた新しい言語を設計するときにも有益でしょう。 \ No newline at end of file +パーザコンビネータはお手軽なだけあって各種プログラミング言語に実装されています。たとえば、Java用なら[jparsec](https://github.com/jparsec/jparsec)があります。しかし、筆者としては、パーザコンビネータが動作する仕組みを理解するために、是非とも**自分だけの**パーザコンビネータを実装してみてほしいと思います。 \ No newline at end of file diff --git a/honkit/chapter7.md b/honkit/chapter7.md index 2a2f96a..1ee4f00 100755 --- a/honkit/chapter7.md +++ b/honkit/chapter7.md @@ -1,9 +1,454 @@ -# 7. おわりに +# 7. 現実の構文解析 -ここまでで構文解析の世界を概観してみましたがいかがでしたか?構文解析、特に非自然言語の構文解析というのは地味なもので、パーザジェネレータの発展などもあり「構文解析はもう終わった問題だ」という人もいます。ただ、その一方で2000年代以降になってもPEGの発明(再発見)があり、Pythonの構文解析器に採用されるまでにつながりましたし、`LL(*)`や`ALL(*)`のような革新的なアルゴリズムが生み出されています。それも、どちらかといえば主流であった上向き型の構文解析でなく下向き型の構文解析で、です。 +ここまでで、LL法やLR法、Packrat Parsingといった、これまでに知られているメジャーな構文解析アルゴリズムを一通り取り上げてきました。これらの構文解析アルゴリズムは概ね文脈自由言語あるいはそのサブセットを取り扱うことができ、一般的なプログラミング言語の構文解析を行うのに必要十分な能力を持っているように思えます。 -とはいえやはり地味なものは地味であり、プログラミング言語処理系を構成するコンポーネントという観点から言っても「脇役」という印象は否めません。ただ、わたしたちはプログラミング言語を書いているときは、コンパイラの内部表現や抽象構文木と対話しているわけではありません。プログラマーが直接対話する相手はプログラミング言語の具象構文であり、具象構文はプログラミング言語の「UI」を担当する部分といえるでしょう。通常のアプリケーションでUIが軽視されるべきでないのと同様にやはり具象構文も軽視されるべきでないと私は思いますし、よりよい具象構文の設計には構文解析の知識が助けになると信じています。 +しかし、構文解析を専門としている人や実用的な構文解析器を書いている人は直感的に理解していることなのですが、実のところ、既存の構文解析アルゴリズムだけではうまく取り扱えない類の構文があります。一言でいうと、それらの構文は文脈自由言語から逸脱しているために、文脈自由言語を取り扱う既存の手法だけではうまくいかないのです。 -ところで、ここまで、構文解析の基盤を支える「形式言語」の世界についてはあえてはしょった説明に留めました。何故なら構文解析を学ぶという点からすると本筋から外れ過ぎてしまいますし、何より形式言語理論を学ぶのは骨が折れる作業でもあるからです。 +このような、既存の構文解析アルゴリズムだけでは扱えない要素は多数あります。たとえば、Cのtypedefはその典型ですし、RubyやPerlのヒアドキュメントと呼ばれる構文もそうです。他には、Scalaのプレースホルダ構文やC++のテンプレート、Pythonのインデント文法など、文脈自由言語を逸脱しているがゆえに人間が特別に配慮しなければいけない構文は多く見かけられます。 -とはいえ、せっかくなので、この章では形式言語理論のほんの導入だけでも紹介したいと思います。幸い、形式言語理論を学ぶための良質な教科書はいくつもありますから、この章を読み終えて形式言語理論に興味を持った方はぜひ、形式言語理論を学んでみてください。 \ No newline at end of file +また、これまでの章では、主に構文解析を行う手法を取り扱っていましたが、現実問題としては抽象構文木をうまく作る方法やエラーメッセージを適切に出す方法も重要になってきます。 + +この章では、巷の書籍ではあまり扱われない、しかし現実の構文解析では対処しなくてはならない構文や問題について取り上げます。皆さんが何かしらの構文解析器を作るとき、やはり理想どおりにはいかないことが多いと思います。この章がそのような現実の構文解析で遭遇する読者の方々の助けになれば幸いです。 + +## 6.1 字句要素が構文要素を含む文法 + +最近の多くの言語は文字列補間(String Interpolation)と呼ばれる機能を持っています。 + +たとえば、Rubyでは以下の文字列を評価すると、`"x + y = x + y"`ではなく`"x + y = 3"`になります。 + +```ruby +x = 1; y = 2 +"x + y = #{x + y}" # "x + y = 3" +``` + +つまり、`#{`と`}`で囲まれた範囲をRubyの式として評価した結果を文字列として埋め込んでくれるわけです。 + +Scalaでも同じことを次のように書くことができます。 + +```scala +val x = 1; val y = 2 +s"x + y = #{x + y}" // "x + y = 3" +``` + +Swiftだと次のようになります。 + +```swift +let x = 1 +let y = 2 +"x + y = \(x + y)" +``` + + +同様の機能はKotlin、Python(3.6以降)、JavaScript(TypeScriptも)など様々な言語に採用されています。比較的新しい言語や、既存言語の新機能として採用するのがすっかり普通になった機能と言えるでしょう。 + +文字列補間はとても便利な機能ですが、構文解析という観点からは少々やっかいな存在です。文字列リテラルは従来はトークンとして扱われており、正規言語の範囲に収まるように設計されていたため、正規表現で取り扱えたのです。これは字句解析と構文解析を分離し、かつ、字句解析を可能な限り単純化するという観点で言えばある意味当然とも言えますが、文字列補間は従来は字句であり正規表現で表現できたものを文脈自由文法を取り扱わなければいけない存在にしてしまいました。 + +たとえば、少々極端な例ですが、Rubyでは以下のように`#{}`の中にさらに文字列リテラルを書くことができ、その中には`#{}`を……といった具合に無限にネストできるのです。これまでの章を振り返ればわかるようにこれは明らかに正規言語を逸脱しており文脈自由言語の扱う範疇です。 + +```ruby +x = 1; y = 2 +"expr1 (#{"expr2 (#{x + y})"})" # "expr1 (expr2 (3))" +``` + +しかし、従来の手法では文字列リテラルは字句として取り扱わなければいけないため、各言語処理系の実装者はad hocな形で構文解析器に手を加えています。たとえば、Rubyの構文解析器はbisonを使って書かれていますが、字句解析器に状態を持たせることでこの問題に対処しています。文字列リテラル内に`#{"が出現したら状態を式モードに切り替えて、その中で文字列リテラルがあらわれたら文字列リテラルモードに切り替えるといった具合です。 + +一方、PEGでは字句解析と構文解析が分離されていないため、特別な工夫をすることなく文字列補間を実装することができます。以下はRubyの文字列補間と同じようなものをPEGで記述する例です。 + +```text +string <- "\"" ("#{" expression "}" / .)* "\"" +expression <- 式の定義 +``` + +文字列補間を含む文字列リテラルは分解可能という意味で厳密な意味では字句と言えないわけですが、PEGは字句解析を分離しないおかげで文字列リテラルを殊更特別扱いする必要がないわけです。 + +PEGの利用例が近年増えてきているのは、言語に対してこのようにアドホックに構文を追加したいというニーズがあるためではないかと筆者は考えています。 + +## 6.2 インデント文法 + +Pythonではインデントによってプログラムの構造を表現します。たとえば、次のPythonプログラムを考えます。 + +```python +class Point: + def __init__(self, x, y): + self.x = x + self.y = y +``` + +このPythonプログラムは次のような抽象構文木に変換されると考えられます。 + +``` +class +|-- name: Point +|-- def + | -- name: init + | -- arguments + | -- self + | -- x + | -- y + | -- body + | -- self.x = x + | -- self.y = y +``` + +インデントによってプログラムの構造を表現するというアイデアは秀逸だと思いますが、一方で、インデントによる構造の表現は明らかに文脈自由言語の範囲を超えるものです。 + +Pythonでは字句解析のときにインデントを``、インデントを「外す」のを``というトークンに変換することで構文解析のときに複雑さを持ち込まないようにしています。つまり、``と``というトークンによって挟まれた範囲がクラス定義の本体であったり、メソッド定義の本体であったりという形にして取り扱っているのです。これは括弧の対応をとる問題と同じため、字句解析後のPythonは文脈自由言語の範囲内で取り扱えます。 + +上の文だと字句解析後は次のようになります。 + +``` + .... +``` + +``は`A`という名前のことを表すトークン、``は開き括弧を表すトークン、``はdefキーワードを表すトークンであるものとします。 + +しかし、よくよく考えればわかるのですが、``トークンと``トークンを切り出す処理が文脈自由ではありません。つまり、字句解析時に``と``トークンを切り出すために特殊な処理をしていることになります。``トークンは``トークンとスペースの数が同じでなければいけないため、切り出すためには正規表現でも文脈自由文法でも手に余ることは想像できるでしょう。 + +## 6.3 ヒアドキュメント + +ヒアドキュメントは複数行に渡る文字列を記述するための文法で、従来はbashなどのシェル言語で採用されていましたが、PerlやRubyもヒアドキュメントを採用しました。たとえば、RubyでHTMLの文字列をヒアドキュメントで以下のように書くことができます。 + +```ruby +html = < + Title +

Hello

+ +HTML +``` + +特筆すべきは、`< +``` + +``はセミコロンを表すトークンです。では、Scala式の文法を「改行でもセミコロンでも終わることができる」と考えて次のように記述しても大丈夫でしょうか。 + +```text +expression_statement ::= expression + | expression +``` + + +``は改行を表すトークンです。プラットフォームによって改行コードは異なるので、このように定義しておくと楽でしょう。このような規則でうまく先程の例全てをうまく取り扱えるかといえば、端的に言って無理です。 + +```scala +println( + "A") +``` + +C系の言語では行コメントなどの例外を除き、字句解析時に改行もスペースも同じ扱いで処理しているので、このような「式の途中で改行が来る」ケースも特に工夫する必要がありませんでした。しかし、Scalaなどの言語における「改行」は式の途中では無視されるが文末では終端子にもなり得るという複雑な存在です。 + +言い換えると「文脈」を考慮して改行を取り扱う必要がでてきたのです。このような文法はどのようにすれば取り扱えるでしょうか。なかなか難しい問題ですが、大きく分けて二つの戦略があります。 + +一つ目は字句解析器に文脈情報を持たせる方法です。たとえば、「式」モードでは改行は無視されるが、「文」モードだと無視されないという風にした上で、式が終わったら「文」モードに切り替えを行い、式が開始したら「式」モードに切り替えを行います。この方式を採用している典型的な言語がRubyで、Cで書かれた字句解析器には実に多数の文脈情報を持たせています。 + +```c +// https://github.com/ruby/ruby/blob/v3_2_0/parse.y#L161-L181 +/* examine combinations */ +enum lex_state_e { +#define DEF_EXPR(n) EXPR_##n = (1 << EXPR_##n##_bit) + DEF_EXPR(BEG), + DEF_EXPR(END), + DEF_EXPR(ENDARG), + DEF_EXPR(ENDFN), + DEF_EXPR(ARG), + DEF_EXPR(CMDARG), + DEF_EXPR(MID), + DEF_EXPR(FNAME), + DEF_EXPR(DOT), + DEF_EXPR(CLASS), + DEF_EXPR(LABEL), + DEF_EXPR(LABELED), + DEF_EXPR(FITEM), + EXPR_VALUE = EXPR_BEG, + EXPR_BEG_ANY = (EXPR_BEG | EXPR_MID | EXPR_CLASS), + EXPR_ARG_ANY = (EXPR_ARG | EXPR_CMDARG), + EXPR_END_ANY = (EXPR_END | EXPR_ENDARG | EXPR_ENDFN), + EXPR_NONE = 0 +}; +``` + +「改行で文が終わる」以外にもRubyはかなり複雑な構文解析を行っているため、このように多数の状態を字句解析器に持たせる必要があります。Rubyの文法は私が知る限り**もっとも複雑なものの一つ**なのでややこれは極端ですが、字句解析器に状態を持たせるアプローチは他の言語も採用していることが多いようです。 + +別のアプローチとして既出のPEGを使うという方法があります。PEGでは字句解析という概念自体がありませんから、式の途中に改行が入るというのも構文解析レベルで処理できます。Python 3.10以降、PEGを用いた構文解析器が採用されていると書きましたが、このアプローチが採用されているかは確認できています。 + +例として拙作のプログラミング言語Klassicでは次のようにして式の合間に改行を挟むことができるようにしています。 + +```scala +//add ::= term {"+" term | "-" term} +lazy val add: Parser[AST] = rule{ + chainl(term)( + (%% << CL(PLUS)) ^^ { location => (left: AST, right: AST) => BinaryExpression(location, Operator.ADD, left, right) } | + (%% << CL(MINUS)) ^^ { location => (left: AST, right: AST) => BinaryExpression(location, Operator.SUBTRACT, left, right) } + ) +} +``` + +関数`CL()`は次のように定義されます。 + +```scala + def CL[T](parser: Parser[T]): Parser[T] = parser << SPACING +``` + +Klassicの構文解析器は自作のパーザコンビネータライブラリで構築されているので少々ややこしく見えますが、要約すると、`CL()`は引数に与えたものの後に任意個のスペース(改行)が来るという意味で、キーワードである`PLUS`や`MINUS`の後にこのような規則を差し込むことで「式の途中での改行は無視」が実現できています。 + +現在ある言語で採用されているかはわかりませんが、GLR法のようにスキャナレス構文解析と呼ばれる他の手法を使う方法もあります。スキャナレスということは字句解析が無いということですが、字句解析器を別に必要としない構文解析法の総称を指します。PEGも字句解析器を必要としませんから、PEGもスキャナレス構文解析の一種と言えます。 + +ともあれ、私達が普通に使っている「改行で文が終わる」ようにできる処理一つとっても厄介な問題だということです。 + +## 6.5 Cのtypedef + +C言語の`typedef`文は既存の型に別名をつける機能です。C言語をバリバリ書いているプログラマの型ならお馴染みの機能でしょう。Cのtypdefは + +- 移植性を高める +- 関数ポインタを使ったよみづらい宣言を読みやすくする + +といった目的で使われますが、この`typedef`文が意外に曲者だったりします。以下は`i`をint型の別名として定義するものですが、同時にローカル変数`i`を`i`型として定義しています。 + +```c +typedef int i; +int main(void) { + i i = 100; // OK + int x = (i)'a'; // ERROR + return 0; +} +``` + +現実にこのようなコードの書き方をするかはともかく、`i i = 100;`は明らかにOKな表現として解析してあげなければいけません。一方で、`int x = (i)'a';`は構文解析エラーになります。`i i = 100;`という宣言がなければこの文も通るのですが、合わせて書けば構文解析エラーです。それ以前の文脈である識別子がtypedefされたかどうかで構文解析の結果が変わるのですからとてもややこしいです。 + +C言語ではこのようなややこしい構文を解析するために、typdefした識別子を連想配列の形で持っておいて、構文解析時にそれを使うという手法を採用しています。 + +## 6.6 Scalaでの「文頭に演算子が来る場合の処理」 + +6.4 で改行で文を終端する文法について説明しましたが、Scalaはさらにややこしい入力を処理できなければいけません。たとえば、以下のような文を解釈できる必要があります。 + +```scala +val x = 1 + + 2 +println(x) // 3 +``` + +ここで、xを3とちゃんと解釈するには`val x = 1`が改行で終わったから「文が終わった」と解釈せず、次のトークンである`+`まで見てから文が終わるか判定する必要があります。これまで試した限り、同じことができるのはJavaScriptくらいで、Ruby、Python、Kotlin、Go、Swiftなどの言語ではエラーになるか、`x = 1`で文が終わったと解釈され、`+ 2`は別の文として解釈されるケースばかりでした。 + +この処理について、Scala言語仕様内の[1.2 Newline Characters](https://scala-lang.org/files/archive/spec/2.13/01-lexical-syntax.html)に関連する記述があります。 + +> Scala is a line-oriented language where statements may be terminated by semi-colons or newlines. A newline in a Scala source text is treated as the special token “nl” if the three following criteria are satisfied: +> 1. The token immediately preceding the newline can terminate a statement. +> 2. The token immediately following the newline can begin a statement. +> 3. The token appears in a region where newlines are enabled. +> The tokens that can terminate a statement are: literals, identifiers and the following delimiters and reserved words: + +これを意訳すると、通常の場合はScalaの文はセミコロンまたは改行で終わることができるが、次の三つの条件**全て**を満たしたときのみ、特別なトークン`nl`として扱われることになる、ということになります。 + +1. 改行の直前のトークンが「文を終わらせられる」ものである場合 +2. 改行の直後のトークンが「文を始められる」ものである場合 +3. 改行が「利用可能」になっている箇所にあらわれたものである場合 + +たとえば、以下のScalaプログラムについていうと、最初の改行は`nl`トークンになりませんが、何故かというと条件1が満たされても条件2が満たされないからです。 + +```scala +val x = 1 + + 2 +``` + +ちなみに、調査を開始する時点ではScalaの文法の基本文法を継承したKotlinでも同じようになっていると思っていたのですが、一行目で文が終わると解釈されてしまいました。 + +```kotlin +val x = 1 + + 2 // + 2は単独の式として解釈されてしまう +println(x) // 1 +``` + +## 6.7 プレースホルダー構文 + +Scalaにはプレースホルダー構文、正確にはPlaceholder Syntax for Anonymous Functionと呼ばれる構文があります。これは最近の言語ではすっかり普通に使えるようになったいわゆる**ラムダ式**を簡易表記するための構文です。 + +たとえば、Scalaで`[1, 2, 3, 4]`というリストの要素全てを2倍するという処理はラムダ式(Scalaでは単純に無名関数という用語)を使って次のように書くことができます。 + +```scala +List(1, 2, 3, 4).map(x => x + 1) +``` + +ラムダ式を普段から使っておられる読者には大体雰囲気で伝わると思うのですが、`map`は多くの言語で採用されている高階関数です。`map`は引数で渡された無名関数をリストの各要素に適用して、その結果できた新しいリストを返します。たとえば、上のプログラムだと実行結果は次のようになります。 + + +```scala +List(2, 4, 6, 8) +``` + +しかし、`map()`に渡す無名関数を毎回`x => x + 1`のように書かないといけないのも冗長です。というわけで、Scalaでは次のように無名関数を簡易表記することができます。 + +```scala +List(1, 2, 3, 4).map(_ + 1) +``` + +これは構文解析のときに、先程の + +```scala +List(1, 2, 3, 4).map(x => x + 1) +``` + +に展開されます。出てくる`_`のことをプレースホルダ(placeholder)と呼びます。例によってこの構文は非常に扱いが厄介です。何故かというと、`_`がどのような無名関数を表すかを構文解析時に決定するのはかなり困難なのです。すぐに思いつくのは、`_`はそれを囲む「最小の式」を無名関数に変換すると定義という方法です。しかし、これはプレースホルダが二つ以上出てくると破綻します。 + +たとえば、別の高階関数`foldLeft()`を使った例を見てみます。 + +```scala +List(1, 2, 3, 4).foldLeft(0)(_ + _) +``` + +リスト`[1, 2, 3, 4]`の合計値である`10`を計算してくれます。このプレースホルダ構文は次のように変換されます。 + +```scala +List(1, 2, 3, 4).foldLeft(0)((x, y) => x + y) +``` + +このケースでは、`(_ + _)`が`((x, y) => x + y)`という無名関数に変換されたわけですが、プレースホルダが複数出現するとややこしい問題になります。 + + +また、そもそもプレースホルダが単一であっても解釈が難しい問題もあります。たとえば、次の式は考えてみます。 + +```scala +List(1, 2, 3, 4).map(_ * 2 + 3) +``` + +これは以下のScalaプログラムに変換されます。 + +```scala +List(1, 2, 3, 4).map(x => x * 2 + 3) +```` + +もし、`_`を含む「最小の式」を無名関数にするという方式だと`(_ * 2)`が`(x => x * 2)`という無名関数に変換されても良さそうですがそうはなっていません。また、ユーザーのニーズを考えてもそうなっては欲しくありません。 + +Scalaではこのプレースホルダ構文をどう扱っているかというと非常に複雑で一言では説明しきれない部分があるのですが、あえておおざっぱに要約すると、次のようになります。 + +1. `_` をアンダースコアセクション(underscore section)と呼ぶ +2. 無名関数になる範囲の式`e`は構文カテゴリ(syntactic category)`Expr`に属しており、アンダースコアセクション`u`について次の条件を満たす必要がある: + 2-1. `e`は真に(property)`u`を含んでいる + 2-2. `e`の中に構文カテゴリ`Expr`に属する式は存在しない + +といっても、これだけだとわかりませんよね。たとえば、 + +``` +map(_ * 2 + 3) +``` + +では`_ * 2 + 3`までが「無名関数化」される範囲ですが、これをいったん括弧つきで表記すると`(_ * 2) + 3`となります。Scalaの構文解析上のルールでは、演算子を使った式は単独では構文カテゴリ`Expr`に属しません。メソッドの引数になっている`(_ * 2 + 3)`まで来て初めて、この式は構文カテゴリ`Expr`になります。 + +端的に言って、この時点で既に目眩がするような内容です。というのは、構文カテゴリという情報自体が構文解析の途中でなければ取り出せない情報であり、つまり、Scalaでは構文解析の*途中*にうまくプレースホルダを処理する必要があるのです。このプレースホルダ構文をきっちり解説するのは本書の内容を超えますが、やはりこの問題も文脈自由言語の範囲で扱うことができません。 + +Scala処理系内部でプレースホルダ構文がどのように実装されているかも読んだことがありますが、とてもややこしくいものでした。C言語のtypedefは構文解析の途中で連想配列に名前を登録すればいいだけまだマシですが、さらに厄介だというのが正直な印象です。 + +なお、プレースホルダ構文について「きちんとした定義」を参照されたい方は、[Scala Language Specification 6.23.2: Placeholder Syntax for Anonymous Function](https://www.scala-lang.org/files/archive/spec/2.13/06-expressions.html#placeholder-syntax-for-anonymous-functions)を読んでいただければと思います。 + +## 6.7 エラーリカバリ + +構文解析の途中でエラーが起きることは(当然ながら)普通にあります。構文解析中のエラーリカバリについては多くの研究があるものの、コンパイラの教科書で構文解析アルゴリズでのエラーリカバリについて言及されることは稀です。推測ですが、構文解析において2つ目以降のエラーは大抵最初のエラーに誘発されて起こるということや、どうしても経験則に頼った記述になりがちなため、教科書で言及されることは少ないのでしょう。また、大抵の言語処理系で構文解析中のエラーリカバリについては大したことをしていなかったという歴史的事情もあるかもしれません。 + +しかし、現在は別の観点から構文解析中のエラーリカバリが重要性を増してきています。それは、テキストエディタの拡張としてIDEのような「構文解析エラーになるが、それっぽくなんとか構文解析をしなければいけない」というニーズがあるからです。 + +TBD + +## 6.8 まとめ + +6章では現実の構文解析で遭遇する問題について、いくつかの例を挙げて説明しました。筆者が大学院博士後期課程に進学した頃「構文解析は終わった問題」と言われたのを覚えていますが、実際にはその後もANTLRの`LL(*)`アルゴリズムのような革新が起きていますし、細かいところでは今回の例のように従来の構文解析法単体では取り扱えない部分をアドホックに各プログラミング言語が補っている部分があります。 + +このような問題が起きるのは結局のところ、当初の想定と違って「プログラミング言語は文脈自由言語として表せ」なかったという事です。より厳密には当然、文脈自由言語の範囲に納めることもできますが、便利な表記を許していくとどうしても文脈自由言語から「はみ出て」しまうということです。このような「現実のプログラミング言語の文脈依存性」については専門の研究者以外には案外知られていなかったりしますが、ともあれこのような問題があることを知っておくのは、既存言語の表記法を取り入れた新しい言語を設計するときにも有益でしょう。 \ No newline at end of file diff --git a/honkit/chapter8.md b/honkit/chapter8.md new file mode 100755 index 0000000..2a2f96a --- /dev/null +++ b/honkit/chapter8.md @@ -0,0 +1,9 @@ +# 7. おわりに + +ここまでで構文解析の世界を概観してみましたがいかがでしたか?構文解析、特に非自然言語の構文解析というのは地味なもので、パーザジェネレータの発展などもあり「構文解析はもう終わった問題だ」という人もいます。ただ、その一方で2000年代以降になってもPEGの発明(再発見)があり、Pythonの構文解析器に採用されるまでにつながりましたし、`LL(*)`や`ALL(*)`のような革新的なアルゴリズムが生み出されています。それも、どちらかといえば主流であった上向き型の構文解析でなく下向き型の構文解析で、です。 + +とはいえやはり地味なものは地味であり、プログラミング言語処理系を構成するコンポーネントという観点から言っても「脇役」という印象は否めません。ただ、わたしたちはプログラミング言語を書いているときは、コンパイラの内部表現や抽象構文木と対話しているわけではありません。プログラマーが直接対話する相手はプログラミング言語の具象構文であり、具象構文はプログラミング言語の「UI」を担当する部分といえるでしょう。通常のアプリケーションでUIが軽視されるべきでないのと同様にやはり具象構文も軽視されるべきでないと私は思いますし、よりよい具象構文の設計には構文解析の知識が助けになると信じています。 + +ところで、ここまで、構文解析の基盤を支える「形式言語」の世界についてはあえてはしょった説明に留めました。何故なら構文解析を学ぶという点からすると本筋から外れ過ぎてしまいますし、何より形式言語理論を学ぶのは骨が折れる作業でもあるからです。 + +とはいえ、せっかくなので、この章では形式言語理論のほんの導入だけでも紹介したいと思います。幸い、形式言語理論を学ぶための良質な教科書はいくつもありますから、この章を読み終えて形式言語理論に興味を持った方はぜひ、形式言語理論を学んでみてください。 \ No newline at end of file