字句解析の基礎:正規表現によるパターンマッチ

 字句解析とは、文字列として入力されるプログラムをtokenの列に分解するフェーズである。前回のプログラムにおいて、字句解析を行う関数getTokenは、文字列を数字(NUM)、演算子(PLUS_OP,MINUS_OP)などのtokenに分けて、返す関数である。

 どのような文字列がどのようなtokenになるかについては、正規表現(regular expression)で定義することができる。アルファベットA上の正規表現とは、

  1. e (空列記号)は正規表現である。
  2. aの要素aは正規表現である。
  3. RSが正規表現であれば、M|NMN, M* は正規表現である。M|Nとは、MもしくはNMNMの次にNがくる列、M*とは、M0回以上繰り返しを意味する。

なお、(S)は、Sと同等であることを意味する。

 例えば、正規表現とは、a(b|c)*は、最初にaがあり、bまたはcが続く文字列を表現する。abbcabcbccも、abccもこの正規表現で表現される文字列である。

 正規表現は、図のような規則で非決定性有限オートマトン(NDA: nondeterministic finite automatonに変換できる。有限オートマトン(finite automaton)とは、有限の内部状態を持ち、与えられてた記号列を読みこみながら状態遷移し、その記号列があるパターンをもつ列であるかを決定するものである。非決定性有限オートマトンとは、入力によらない状態遷移(空列記号に対する状態遷移)をもち、それは非決定的に遷移してもしなくてもよいと状態をもつものである。図にそれぞれの規則に対応するオートマトンを図示する。

先にあげたa(b|c)*について、この規則で、NDAに変換した結果も示す。

NDAでは、空の状態遷移に対して、状態遷移するかしないかの両方の可能性をしらべなくてはならないので、実際にそのまま実装すると効率がわるい。そのため、非決定的な遷移を取り除き、決定性有限オートマトン(DFA: deterministic finite automaton)に変換する。DFAとは、以下の条件を満たす有限オートマトンである。

(1)e による遷移がない。

(2)一つの状態から同じ記号による異なった状態への遷移はない。

変換には次の規則を適用し、上の条件にあったオートマトンに変換する。

  1. 初期状態から、e による遷移を一まとめにした集合を初期状態とする。
  2. 状態の集合からの遷移は、その集合からの遷移の集合の合併とする。つまり、状態の集合D={x1,x2,...}からのaによる遷移の行き先は、x1からaで遷移した状態yもしくは、yからe で遷移する状態の集合になる。
  3. 2を繰り返し新しい遷移が得られなくなるまで繰り返す。

このアルゴリズムで得られるFDAは必ずしも、最小のオートマトンとはならない。最小にするには、同じ遷移が2つあった場合には、冗長なので1つにまとめることができ、これを繰り返すことにより、最小化ができる。

自動字句解析生成プログラム:lex

 正規表現が与えられた時に、その言語(文字のパターン)を認識するDFAを機械的に作り出すことができる。そのアルゴリズムをプログラムにしたものが字句解析器生成系( lexical analyzer generator)である。このプログラムとして、有名なのが、lexである。

lexでは、定義ファイルは以下の形式でかく。

%{

任意のCプログラム。定数やCのマクロの宣言をここにかく。

%}

下の定義で使うlexのマクロの定義

%%

正規表現による入力パターンの定義

正規表現パターン アクション という形で書く

%%

任意のCプログラム

例えば、a(b|c)*の正規表現を認識するじく解析は、

  %{

%}

%%

a(b|c)* { printf("OK\n"); } /*このパターンを入力したらOKを出力する*/

.  {printf("NG\n");} /*.は以上以外のパターンの場合のアクションを指定 */

%%

  /*  なにもなし */

でよい。これをコンパイルするには、

  % lex test.l

% lex lex.yy.c -ll

とする。これでできたa.outを実行すると、abcにたいしてはOK, xxxにはNGと出力される。Linuxflexのときには、-llの代わりに、-lflをつかうこと。

前回示した数式の字句解析の部分は以下のように書ける。

%{

#include "expr.h"

%}

digit [0-9] /* マクロの定義,digit0-9の数字の集合と定義する */

%%

{digit}+ return NUM; /*0-9の1回以上の繰り返しは、NUMと認識する */

"+" return PLUS_OP; /*+PLUS_OP +は繰り返しの意味なので、""で囲む*/

"-" return MINUS_OP, /* -MINUS_OP */

[ \t]  /* 空白は無視 */

. printf("error?\n"); /* error */

%%

main(){

yystdin = stdout;

....

r = yylex(); /* tokenの列はyytextに入る */

printf(" token is %d,'%s'\n",r,yytext); }

lexは、字句解析ルーチンとして、yylexを生成する。このルーチンは、actionで指定されたreturn分で返された値を返す。

lexの使い方については、

% man lex

として、マニュアルを参照のこと。

数式の構文解析:top-down parserの作り方

前回、簡単な数式の処理系を解説した。構文は、以下のようなBNFによる構文規則で記述されることを述べた。

  足し算の式 := 式 +の演算子 式

  引き算の式 := 式 −の演算子 式

  式 := 数字 | 足し算の式 | 引き算の式

 ここで、構文の最終的な要素に現れるものを終端記号(terminal symbol)、それ以外のほかの構文規則によって定義される記号を非終端記号(non-terminal symbol)と呼ぶ。ここでは、非終端記号を<>で囲んで表すことにする。構文規則は、

  1. 構文規則は, 非終端記号<T>に対して、<T> := e (eは構文規則)で、表現される。これは、非終端記号<T>は、構文規則eによって置き換えられることを意味する。
  2. eは空でもよい。
  3. eは、非終端記号、終端記号、もしくはe1 | e2e1 e2e1* のいずれか。e1 | e2は、e1もしくはe2であることを意味し、e1 e2 はe1の次にe2が現れることを意味し、e1*は、e10個以上の繰り返しを意味する。

(...)は、構文規則のまとまりを示す。e1|e2|e3は、((e1|e2)|e3)を、e1 e2 e3 は ((e1 e2) e3)と同じである。正規表現と似ていることを注意しよう。

 ここでは、構文規則の左辺が、一つの終端記号<T>だけという文法を考える。これはすなわち、どのような場合でも<T> := eの規則を使って、右辺に置き換えることができることを意味し、このような文法を文脈自由文法とよぶ。この制限を取り払って、例えば、

  e1 <T> e2 := ...

というような、e1 e2の間に構文要素<T>が現れたときだけ、置き換えることができるというような文法が考えられるが、このような文法を文脈依存文法と呼ぶ。

 構文規則に対し、構文解析を作る方法を紹介しよう。例えば、

  1. := a <B> c

という構文規則があったとすれば、<A>のための構文解析関数readAは以下のように作ることができる。

readA() {

aを読み込む;

readB(); /* Bを読み込むための関数を呼ぶ*/

bを読み込む;

これは、これから読み込むものの形を先に仮定してしまってから、それに合致するかを調べていく構文解析法である。このような構文解析法を再帰的下向き構文解析(recursive decent parsing)あるいはtop-down parserと呼ぶ。前回で解説した数式の構文解析もこの方法によるものである。

 さて、前回の数式を再度考え、括弧や乗算をいれて考えてみることにする。構文規則を再度定義してみると、

 <expression> := <expression> <expr_op> <term>

<term> := <term> <term_op> <factor>

<factor> := number | '(' <expression> ')'

<expr_op> := '+' | '-'

<term_op> := '*'

ここの定義で、加減算の優先順位を考慮して、生成される構文木を反映して構文規則が作られていることに注目。

しかし、これを上の方法で書くと

 readExpr() {

readExpr();

readExprOp();

readTerm();

}

となってしまって、readExprが再帰的に呼ばれて,うまく行かない。この問題を、top-down parserの 左再帰性の問題と呼ばれている。すなわち、最終的に

<T> := <T> e

となる、文法規則ではうまくいかないのである。このために、前回のプログラムでは、

 readExpr() {

readTerm();

while(readExprOp() is OK)

readTerm();

}

とした。これは、

  <expresssion> := <term> <expression1>

<expression1> := <expr_op> <term> <expression1> | e

と書き換えたのと同等である。e は空を示す。

 このほかに、

  <T> := b ( c| e )

はうまく行かない。(c|e )は、cを読むか、それともなにもしないかという意味であるが、この場合は、<T>の次に何がくるかによって、cを読むかどうかが決まるので、top-down parser では、処理ができないことになってしまう。

 

演習課題2

(1)と(2)のどちらかのプログラムを書きなさい。

  1. C言語のコメントは、/* で始まり、任意の文字列があり */ で終わる。コメントを含むCのプログラムを標準入力として、コメントを除いたプログラムを標準出力に出力するプログラムを作りなさい。
  2. 標準入力から、文字列を入力し、浮動少数点数を入力した場合、YES、それ以外の場合、NOを標準出力するプログラムを作りなさい。lexを使って作ってもよい。浮動少数点の正規表現は、

        浮動少数点 := 少数点数(e |指数部) | 数字列 指数部

        少数点数 := (e | 数字列) . 数字列 | 数字列 .

指数部 := E (e | 符号) 数字列

        数字列 := 数字 | 数字列 数字

         符号 := - | +

   なお、数字は0から9までの数字、浮動少数点数の符号は考えない。