] > Parse and Syntax Tree(2)

第 12 回 構文解析木(2)

本日の内容


このドキュメントは http://edu.net.c.dendai.ac.jp/ 上で公開されています。

12-1. 構文解析

今回はコンパイラコンパイラなしで、文法からそれを解釈するプログラムを作 る手法を学びます。 なお、構文解析のことをparse、構文解析をするプログラムなどを parserといいます。

素朴な解析法

バッカス・ナウア記法の素朴な解析法として次のようなものがあります。 例えば、 A→bCD で A,C,D が非終端記号、 b が終端記号だった時、関数 A を次のように作ります。


void A(){
   記号 b を読む;
   C();
   D();
}

そして非終端記号 C, D に対しても同様に関数を作ります。 このようにすると構文木通りに関数が呼び出されて、与えられた記号列を処理 できる場合があります。 しかし、実際は一つの非終端記号から複数の導出規則が存在します。 例えば、下の例では状態 S で文字 a を読み込んだ時、 A か B のどちらを導 出するかはすぐには決められません。

S→A|B
A→ab
B→ac

素朴な方法としては、適当に導出規則を選び、途中で失敗したら別の導出規則 を選ぶ方法が考えられます。これをバックトラックと言います。 確かにこの方法でも構文解析できますが、しかし、キューを実装しているよう に、途中の入力を全て覚える必要があります。 また、考え得る全ての構文木を一つ一つ生成しては比較するようなものなので、 効率が悪いです。

バックトラックの例


#include <stdio.h>
#define SIZE 100
int queue[SIZE];
int r=0,g=0;
int getletter(){
  if(r==g){
    queue[(g=(g+1)% SIZE)]=getchar();
  }
  r=(r+1)% SIZE;
  return queue[r];
}
ungetletter(int c){
  queue[r]=c;
  r=(r+SIZE -1)%SIZE;
}
int S(){
  if(A() || B()){ /* A がだめなら B */
   return 1;
  }else{
    return 0;
  }
}
int A(){
  int c1,c2;
  c1=getletter();
  if(c1!='a'){
    ungetletter(c1);
    return 0;
  }
  c2=getletter();
  if(c2!='b'){
    ungetletter(c2);
    ungetletter(c1);
    return 0; /* A が適合しないなら読んだ文字をすべて返す */
  }
  return 1;
}
int B(){
  int c1,c2;
  c1=getletter();
  if(c1!='a'){
    ungetletter(c1);
    return 0;
  }
  c2=getletter();
  if(c2!='c'){
    ungetletter(c2);
    ungetletter(c1);
    return 0;
  }
  return 1;
}
main(){
  if(S()){
    printf("Ok\n");
  }else{
    printf("NG\n");
  }
}

バックトラックを避け、効率良く構文解析するためにはどのようなことが考え られるでしょうか? 一つの考え方として、次に読み込む記号から必ず導出規則が一つ決定できるよ うな文法であれば、文字を読み込みながら if 文でどちらの導出規則を選択で きるため、バックトラックを行わずに済みます。 そのような、どんな非終端記号に対しても、次の先頭の文字を読むだけで一意 に導出が可能な文法を LL(1)文法 と言います。

左再帰性の除去

さてここでは前回取り上げた足し算の文法を取り上げ、コンパイラコンパイラ 無しで、文法をプログラムで表現する手法を考えます。 まず、前回の足し算の文法は以下の通りです。

文法 G1

=({和},{数, +}, P1, 和)

+

この文法はそのままでは上で述べたように、文字を読んでも次の導出規則を決 めることはできません。 入力列に対して + というルールを適応するには、その先頭部分が「和」の形になってい るかどうかを調べる必要があります。そして、そのためにはその先頭部分が 「和」の形になっているかどうかを調べる必要があります。 このように左辺と同じ非終端記号が右辺の先頭に来ていると、入力 列を順に読む構文解析ができなくなります。 これを左再帰性と言います。 この左再帰性は次のようにすれば除去できます。 次のような左再帰性を持つ生成規則があったとします。

AAα|β

A は非終端記号で、α と β は非終端記号、終端記号からなる列を 表し、β は A で始まらないとします。 この時、次のように書き直すと左再帰性がなくなります。

AβA' A' αA' | ε

上の足し算を解釈する文法G1の左再帰性を除去すると次のように なります。

文法 G2

=({和, 和'},{数, +}, P2, 和)

' '+ '|ε

再帰的下向き構文解析法

文法に対して、左辺から右辺への導出が、次の一文字を読むだけで決定できれ ば、その文法をLL(1) 文法と言います。 次の一文字を読むだけで決定できるかどうかは次の計算を行うことで判定でき ます。なお次の一文字を読んでプログラムの次の動きを決めますので、入力文 字が終ったことを示す特殊な文字を考えることがあります。

LL(1)文法は次のように形式化できます。 α は非終端記号、終端記号からなる列とします。 また A は非終端記号とします。 この時 First(α), Follow(A), Director(A,α) を次のように定義 します。

First(α):
α から導出可能な列の先頭になりうる終端記号の集合。 もし、α から ε へ導出可能なら、ε も含めます。
Follow(A):
開始記号から導出をした時、 A の直後になりうる終端記号の集合。
Director(A,α):
First(α) 但し、 α から ε へ導出可能なら Follow(A) も含めます。

文法 G2についてこれらを計算すると次のようになります。 なお、数式の最後として = があることとし、Director は生成規則に関するも のだけ求めます。

First' = + = First = Follow = = Follow' = = Director ' = Director ' +' = + Director ' ε = =

このようにすべての非終端記号に対して、その生成規則を示す Director 同士 に共通部分がない文法を LL(1) 文法と言います。 G2 は 計算した Director に共通部分がないので LL(1) 文法です。

なお G1 についても同様に計算すると次のようになります。

First = = Follow = + = Director = = Director + = =

このように Director(和, ・) に重複がありますので、 LL(1)文法ではありま せん。

LL(1) 文法は、先に示した素朴な関数呼出による構文解析に対して、次に来る ものを判断して構文規則を変えることで構文解析ができるようになります。

  1. 各非終端記号に対応した関数を作ります。ここでは A とし、作る関数を A() とします。
  2. Director(A,・)に応じてプログラムを書きます。 Director(A, α)={x}, Director(A, β)={y} の時、次のようになります。
    
    A(){
      if(文字 =='x'){
         if(αの処理){
           return Ok;
         }
      }else if(文字=='y'){
         if(βの処理){
           return Ok;
         }
      }
      return NG;
    }
    

上の G2 も同様に Director により次のように計算できます(わ かりやすいように日本語を使ってますので、このままでは動きません)。


void 和(){
   if(次==数){
      次を読む;
      if(和'()){
        return Ok;
      }
   }
   return NG;
}
void 和'(){
   if(次=='='){ /* Director(和',ε) */
     return Ok;
   }else if(次=='+'){ /* Director(和',+数和') */
     次を読む;
     if(次==数){
       次を読む;
       if(和'()){
         return Ok;
       }
     }
   }
   return NG;
}  
main(){
  次を読む;
  if(和()){
    return Ok;
  }
  return NG;
}

なお Pascal というプログラミング言語は LL(1) 言語です。

構文解析の意味付け

構文解析の手続きにおいて、出力を考えます。 これは構文に意味を与えることになります。

ここでは、数式から構文解析木を作り、実際に数式の値を計算することを考え ます。 始めに G1 を考えます。 各ルールで、次のように木が作られます。

ルール 機械的な構文解析木 数式の構造
+ ルール1の導出木 ルール1での計算
ルール2の導出木 ルール2の導出木

和の部分を考えると、導出により木の下の方に移動していくので、上から下へ 木を作っていく 一方 G2 は 次のように木が作られます。

ルール 機械的な構文解析木 数式の構造
' ルール1の導出木 ルール1の計算
' + ' ルール2の導出木 ルール2の計算
'ε そのまま

この場合、下から上へ木を作ることになります。 これを上に示したプログラムを利用して、次のように生成させるます。


typedef struct tr {
  char *item ;
  struct tr *left,*right;
} TREE;
TREE *root=NULL;
void 和(){
   数を読み込む;
   root=(TREE *)malloc(sizeof(TREE));
   root->item=数;
   root->left=NULL;
   root->right=NULL;
   次を読む;
   和'();
}
void 和'(){
   TREE *op,*num;
   if(次=='='){ /* Director(和',ε) */
     return;
   }else if(次=='+'){ /* Director(和',+数和') */
     +を読み込む;
     op=(TREE *)malloc(sizeof(TREE));
     op->item="+";
     op->left=root;
     次を読む;
     数を読み込む;
     num=(TREE *)malloc(sizeof(TREE));
     num->item=数;
     num->left=NULL;
     num->right=NULL;
     op->right=num;
     root=op;
     次を読む;
     和'();
   }
}  
main(){
  次を読む;
  和();
}

12-2. 木の探索と表示

木の内部を検索、表示する際に、順序を複数決めることができます。 以前は「木の左側を処理」、「注目頂点を表示」、「木の右側を処理」 と、もっとも深い頂点から順に表示される表示法を紹介しました。 これを 深さ優先探索 と言います。 一方、同じ深さごとに探索、表示することもあります。これを 幅優先探 索と言います。

また、深さ優先探索に関しても「木の左側を処理」、「注目頂点を表示」、 「木の右側を処理」の他にも、これらの出力の順番を変える方法が考えられま す。 ここで、例えば、次のような構文解析木が得られたとします。

構文解析木

その時「木の左側を処理」、「注目頂点を表示」、 「木の右側を処理」とす れば中置記法の表示と一致します。一方 「木の左側を処理」、 「木の右側を処理」、「注目頂点を表示」とすると後 置記法(逆ポーランド記法)と一致します。

演習12-1

以上の議論により、数式(足し算のみ)を逆ポーランド記法に変換するプログラ ムを作りなさい。

完全な数式処理

ここまでは足し算のみの数式を使って、文法、構文解析、構文解析による計算 の手法を紹介しました。 ここでは、完全な数式の文法を与えます。

まず、かけ算を考えます。かけ算だけの式の場合は計算の順序などは足し算と 同様です。したがって、かけ算だけのルールを考えると次のようになります。

*

ここで、積と和の優先順位を考えます。 積の方が優先されますので、積を計算した後、和を計算します。 これを実現させるには、和の計算で「数」という終端記号を計算の対象にしま したが、積を計算の対象に変更します。 変更した後のルールは次のようになります。

+ *

さらに括弧の処理を考えます。 括弧の中にはどんな式も入ります。一方括弧はその中の値を計算したら、計算 の対象となる値として数と同じように扱われます。 今まで「数」という終端記号を使ってきましたが、ここで「値」 という非終端記号を導入します。 すると、値に関するルールは次のようになります。

()

これらをすべてまとめると完全な数式処理の文法 G3を定義できま す。

文法 G3

=({和, 積, 値},{数, +, *, (, )}, P3, 和)

+ * ()

演習12-2

次の数式の構文解析木を書きなさい。

  1. 1 + 2 + 3
  2. 1 * 2 + 3
  3. 1 + 2 * 3
  4. (1 + 2) * 3
  5. 1 * (2 + 3)

演習12-3

G3 を拡張バッカス・ナウア記法で書きなさい

演習12-4

G3 の左再帰性を取り除いた文法 G4 を求めなさい。

演習12-5

G4 の各導出規則に対して Director を求め、 LL(1)文法であるこ とを確かめなさい。

演習12-6

中置記法で書かれた数式を逆ポーランド記法に変換するプログラムを書きなさ い。

演習12-7

数式を計算するプログラムを書きなさい。

拡張バッカス・ナウア記法

拡張バッカス・ナウア記法は、バッカス・ナウア記法に中括弧による繰返しの 記法を付け加えたものです。 また、丸括弧の中に | を使うことで、括弧内を選択できます。 例えば、拡張バッカス・ナウア記法を使うと、上の足し算は「+ 数」を任意の 回数繰返したものとして次のように書くことができます。

{+}

坂本直志 <sakamoto@c.dendai.ac.jp>
東京電機大学工学部情報通信工学科