構文解析の基礎

  これまで、式のインタプリター、前回のtiny Cなどではtopdown parserを使って解説した。top down parserは再帰的下方構文解析の代表的な手法であり、次に何が来るのかを推定しながら構文解析を進めていく方法である。比較的構成がわかりやすく、人手で書いていく場合などには適した方法とされている。

 このほかにも構文解析には、上方構文解析法(bottomup parser、上昇型ともいう)という方法がある。この方法は人手で直接実現するには向かない方法であるが、理論的に構成されており、構文解析のプログラムを自動的に生成するためには重要な方法になっている。今回は、この下向き構文解析法についてみていく。

 簡単に説明するためにいつもの式の構文解析を考えてみる。文法は以下のものを考える

  1. E := E + T
  2. E:=T
  3. T := T * F
  4. T := F
  5. F := ( E )
  6. F := id

F,T,Eは非終端記号であり、idは変数のようなシンボルを仮定する。

上向き構文解析と還元

 構文解析の重要な役割は、入力がこの文法にあっているかどうかを認識することである。下方構文解析では、まず、Eであることを仮定して解析をはじめ、それぞれの非終端記号に対応する関数を呼び出し、最終的に必要な終端記号列になっているかを認識する方法であった。つまり、構文木という観点からみれば、構文木の根から葉に向かって解析を進めていく。(ここで、この文法は左再帰で書いてあるため、そのままではtop-down parserができないことは前回説明したとおり)

 これに対し、上方構文解析では葉すなわち終端記号から、根すなわち非終端記号へ向かって文法を適用して、最終的にEになっているかを認識する。例えば,a+c*dを考えてみる。これをtokenの列にしてみると、

id + id*id

さらに、(6)の文法を適用して、

 F+F*F 

(3)(4)の文法を適用して

 F+T*F ⇒ T + T*F ⇒ T + T

さらに、(1)(2) を適用して

 E + T ⇒ E

となって、認識される。この適用して、非終端記号に置き換えていくことを還元(reduction)と呼ぶ。上方構文解析で、構文木を構成する過程は、文字列を非終端記号に還元していく過程である。この例では、順番を考えずにできるところから還元していったが、これをするためには入力を全部みてからやることになるため、あまり現実的ではない。

 上向き構文解析では、入力を左から右に見ながら(つまり、一文字づつ入力しながら)還元していく。入力の右側から適用できる構文規則を逆順にたどって最終的に最後の構文規則まで還元できる部分列をhandle(把手)という。上方構文解析はhandleを見つけて還元する過程とみなすことができる。

  右文形式    handle 還元につかった規則

  a + b * c a (6) (4) (2)

E + b * c b (6) (4)

E + T * c c (6)

E + T * F T*F (3)

E + T E+T (1)

E

 このような構文解析を(自動的に)構成するために、現在の構文解析の状態を記憶するためのスタックと入力の動作として以下のものを考える。

  1. 移動(shift):次の入力記号をスタックの上段に移動する。
  2. 還元(reduce)handleの右の記号がスタックの一番上にあり、適用できる構文規則をみつけて、その非終端記号に置き換える。
  3. 受理(accept):構文解析が終了
  4. エラー:適用できる構文規則がみつからず、誤りを発見。

これを図示すると以下のようになる。

 スタックの状態       入力      動作

$ a + b * c$ shift

$a + b * c $ (6)(4)(2)によるreduce

$E + b * c $ shift

$E + b * c $ shift

$E + b * c $ (6)(4)によるreduce

$E + T * c $ shift

$E + T* c $ shift

$E + T*c $ (6)によるreduce

$E + T*F $ (3)によるreduce

$E + T $ (1)によるreduce

$E $ accept

演算子順位構文解析法

 さて、上方構文解析のためのアルゴリズムについて、考えていくことにしよう。演算子順位構文解析法は、演算子順位文法(operator precedence grammar)に対する簡単な上方構文解析法である。演算子文法とは、どの構文生成規則の右辺も空ではなく、しかも、隣接する2つの非終端記号を持たないという文法である。つまり、算術式E := E + Tのように、必ず非終端記号の間には演算子(終端記号)が入るものである。演算子順位文法とは、 終端記号について、優先度> < =が定義されている文法のことである。

  1. A:= ...st...または、A:= ...sBt..なる構文規則があれば、s=t
  2. A:= ...sB..なる構文規則があり、さらに、BtまたはBCtなる規則が導きられるならば、s < t
  3. A:= ...Bt...なる構文規則があり、さらにB...sまたはB...sCなる規則が導かれるならば、s > t

例についていえば括弧(,)に関しては=E:= E+TT:=T*Fにより、+<*である。つまり、数式の直感的な優先度に対応している文法とおもえばよい。このような関係にしたがって、構文規則より、演算子順位行列を作ることができる。

+ * ( ) id

+ | > < < > <

* | > > < > <

( | < < < = <

) | > > >

id| > > >

これを使って、以下のアルゴリズムをつかえばよい。

  1. スタックに空記号$をつんでおく
  2. 入力記号aをよむ
  3. スタック上の演算子sに対し、s > aであるかぎリ、還元する
  4. そうでなければ、aをスタック上につみ、2へ
  5. 全部認識されたら終わり。

最後のreductionのために、便宜的に優先度が一番低い最後の記号を導入する必要がある。

LR構文解析法

 演算子文法に関しては比較的簡単なアルゴリズムで構成することができたが、一般の文法には使えない。ここで説明する方法は入力を左から右へ走査し、最右の規則を導くので、LR(left-to-right scanning Right most derivation)構文解析法と呼ばれるものである。

LR構文解析は入力とスタック、構文解析表からなる。入力は1記号ずつ左から右に読む。スタックには、

 s0 X1 s1 X2 s2 X3 s3.... Xm sm

という記号列を積む。sは状態に対応した記号である。Xは文法記号で、実際は必要ないが説明のために加えてある。構文解析表は構文解析動作関数ACTIONと行き先関数GOTOの2つの部分からなる。LR構文解析のプログラムは現在のスタックの最上段の状態smと入力記号aiをもちいて、ACTION[sm, ai]を引いて、以下の動作のどれかをとる。

  1. shift s: 入力記号aiGOTO[sm,ai]で求めた次の状態sをスタックに積む。次の入力に進む。
  2. reduce A := b: 文法規則A:=bで還元する。すなわち、最上段にあるXの列がbであるはずなので、これに対応するXsのペアをスタックから取り除き、最後の状態smAで、GOTO[sm,A]=sを次の状態とし、Asをスタックに積む。還元の動作は現在の入力記号は変わらない。
  3. accept
  4. error

例に挙げた数式の文法について番号をつける。

構文解析表は以下のようになる。

ACTIOn GOTO(非終端記号)

 state id + * ( ) $ E T F

0 s5 s4 1 2 3

1 s6 acc

2 r2 s7 r2 r2

3 r4 r4 r4 r4

4 s5 s4 8 2 3

5 r6 r6 r6 r6

6 s5 s4 9 3

7 s5 s4 10

8 s6 s11

9 r1 s7 r1 r1

10 r3 r3 r3 r3

11 r5 r5 r5 r5

ここで、sishiftで状態iをスタックに積む動作を意味する。また、rjは文法jによるreduce動作を意味する。

ここで、a*b+c$を入力として考えてみよ。(紙面の都合上、講義で解説する)

 このような表を作ることによって、LR構文解析ができる。この表の作りかたについてはこの講義では説明しないが、重要な点は字句解析のところでオートマトンから字句解析を自動的に生成できると同様に、この表を自動的に作る方法があり、構文解析ルーチンを自動的に生成できることである。

構文解析生成プログラムyacc

 LR構文解析ルーチンを自動生成するプログラムの一つがyaccである。実際、構文解析ルーチンはtop-down parserで書くことがあるが、複雑になると手に負えなくなるため、yaccのような自動生成プログラムを使う。(linuxのフリーな構文解析は実際bisonというプログラムであるが、yaccというコマンドになっている)実際の場面ではyaccの使い方を習得しておくことが重要になる。

 yaccは、LR構文解析に一文字の先読み機能を付け加えたLALR(Look-ahead LR)という文法のクラスを扱うことができる。yaccの入力(文法の定義)は、例として以下のように書く。

%token NUM /* yylexから返すtokenの定義、文字を直接返してもよい*/

%token SYM

%token STRING

%{

#include <stdio.h> /*Cのプログラムのヘッダー、なんでもかける*/

%}

%start expression /* yyparseで何の認識をするかの指定*/

%% /* 文法の定義の始まり*/

expression: term

| expression '+' term

;

term: factor

| term '*' factor

;

factor: NUM | SYM ;

%% /* 文法の定義の終わり*/

#include "lex.c" /* ここからは何のCのプログラムをかいてもいい*/

tokenで定義されているものは、defineされるので、lex.cのなかでそのまま使うことができる。lex.cでは、これまででやった字句解析のルーチンが定義してある。構文解析から呼び出される字句解析のルーチンは、yylexというなまえで、lexを使っても生成できる。これをexpression.yとすると、

   yacc expression.y

で、構文解析ルーチンyyparseを含むルーチンがy.tab.cである。ここで、mainプログラムを以下のようにして、リンクすればよい。

main(){yyparse(); printf("ok\n");}

void yyerror(){ printf("syntax error!\n"); exit(1); }

yyerrorは構文解析でエラーになったときに呼び出される関数でユーザが与える。

 

演習課題5:

 上記のyaccのプログラムを拡張し、演習問題1でやった括弧をいれた式を構文解析するyaccのプログラムを作りなさい。コンパイル、実行し、(a + b)*c + 1が認識できることを確かめなさい。