RewriteTokens Ver.2

RewriteTokens 演習

1 準備

演習を行うにあたり、TEBA を利用できる環境を用意する必要がある。 以下は、TEBA を使うための準備を説明する。

1.1 TEBA のインストール

TEBA は、以下の手順でインストールする。

  1. TEBA は下記の URL から最新版をダウンロードする。

http://tebasaki.jp/src/

  1. ダウンロードしたファイルを展開し、自分で管理しやすい場所に置く。 場所について迷うようなら、まず、ホームディレクトリに置く。

  2. 展開したディレクトリの名前にはバージョン番号が入っている。 そのままだと、今後、最新版に入れ替えるときに、後述する設定の更新も必要になるので、 バージョン番号のない名前でアクセスできるようにする。

バージョン番号のない名前でアクセスできるようにするには、 ディレクトリ名をバージョン名のない teba に変更するか、 次のようにシンボリックリンクを作成する。

ln -s teba-1.2.3 teba

これにより、teba という名前で元のディレクトリにアクセスできる。 なお、1.2.3 の部分は実際のディレクトリ名に合わせること。

TEBA はアップデートされることがあるが、その場合には、次のようにシンボリックリンク teba を削除して、再度、ダウンロードと配置を行えばよい。

rm teba

以降では、TEBA はホームディレクトリの直下に配置しteba でアクセスできるものとして説明する。 これ以外の方法で配置等をする場合は、各自の状況に合わせて読み替えること。

1.2 Perl のライブラリの準備

TEBA は、CPAN で提供される以下のライブラリを利用しており、 あらかじめインストールする必要がある。

なお、各OS のパッケージ管理システムを利用して、これらをインストールできる場合がある。 Ubuntu などで、パッケージ管理システムに apt を利用している場合は、次のパッケージ名で導入できる。

例えば、libjson-perl を apt を用いてインストールするときは、 apt を次のように実行する。

apt install libjson-perl

ただし、管理者権限が必要であるので、一般的には次のように sudo も用いて次のように実行する。

sudo apt install libjson-perl

CPAN から直接インストールするよりは、管理が簡単になるので、 OS のパッケージ管理システムを利用することを勧める。 macOS の場合も、MacPorts でインストール可能である。

1.3 TEBA のツールの実行準備

TEBA のツールを使うためには、ツールが入っているディレクトリが 実行パス(環境変数PATH)に含まれていることが必要である。 自分のホームディレクトリに存在する以下の設定ファイルに実行パスの設定を追加する。

もし emacs で .bashrc を編集をするなら、ホームディレクトリに移動して、次のように実行する。

emacs .bashrc

実行パスの設定はファイルの最後に次のように書いて追加する。 なお、$HOME はホームディレクトリのパスを持つ環境変数である。

PATH=$HOME/teba/bin:$PATH

設定を書き込んだらファイルを保存する。ただし、ファイルを保存しても設定は反映されない。 設定は保存した後、新しく起動したシェルに対して有効になる。

設定できたことを、新しい端末(ターミナル)で、次のコマンドを実行して確認する。

cparse.pl -h

コマンド cparse.pl のヘルプメッセージが表示されれば、 正しく PATH が設定できている。

すでに起動している端末で、設定を有効にしたい場合は、次のように .bashrc を読み込ませる。 最初のピリオドが設定の読み込みを指示する命令である。ファイル名の先頭もピリオドであるが、 それとは異なることに注意すること。

. .bashrc

なお、シェルの設定を書き間違えると、最悪、シェルが動かなくなる。 すでに起動している端末に設定を読み込ませるときは、 新しい端末を開いて動くことを確認した方がよい。

シェルが csh 系(tcsh など)の場合は設定が異なるが、 OS の標準設定とは異なるシェルを自ら選択していると想定されるので、 詳細は理解しているものとして、ここでは割愛する。 また、bash や zsh についても、詳細については man で確認すること。 man を知らない人は man で確認すること(man manと実行すればよい)。

1.4 TEBA のライブラリパスの設定

TEBAはプログラミング言語 Perl で実装されており、 Perl インタプリタが TEBA の Perl のモジュールを読み込めるよう設定が必要である。 具体的には、環境変数 PERLLIB に TEBA のライブラリの絶対パスを設定する。 具体的には、実行パスを設定したファイル(.bashrc)に、 PATH の定義の次の行に、次のように定義を追加する。

export PERLLIB=$HOME/teba/TEBA

正しく設定ができたかを確認するためには、実行パスと同様に新しい端末を開いて、 次のコマンドを実行する。

perl -e 'use RewriteTokens2;'

何も出力されない、すなわち、エラーが表示されなければ、正しく設定できている。

1.5 演習作業のディレクトリの準備

演習では複数のファイルを作成する。他の授業や演習等で作るファイルと混在しないよう、 この演習での作業用のディレクトリを自分のわかりやすい場所に用意すること。 以下に作成の例を示す。

mkdir teba-prac

なお、インストールした TEBA のディレクトリでは作業しないこと。 また、以降の作業で TEBA のファイルを編集してはいけない。

1.6 演習: 環境の準備

ここまでの説明に従い、TEBA を使用する環境を用意しなさい。

  1. TEBA のダウンロードと配置
  2. シンボリックリンクの作成
  3. 環境変数 PATH の設定の追加
  4. 環境変数 PERLLIB の設定の追加
  5. 演習作業用のディレクトリの作成

なお、以降の演習内容は、作成した演習作業用のディレクトリに移動してから実行すること。

2 RewriteTokens を用いた構文解析の実現

この演習では、簡易なプログラミング言語に対する構文解析を RewriteTokens Ver.2 で実現する方法を学ぶ。

2.1 対象プログラミング言語

対象となるプログラミング言語は、C言語のサブセットとし、次の制限を加える。

2.2 構文解析の手順

構文解析の手順は、おおむね次の通りである。

  1. ソースコードを読み込む。
  2. ソースコードを、字句解析によって、字句のリストに変換する。
  3. 字句列の書換えルールを繰り返し適用し、構文木の構造を含んだ字句のリストに変換する。
  4. 字句のリストを出力する。

まず、字句解析を実現し、次に構文解析を段階的に実現していく。

2.2.1 字句解析

構文解析を行うためには、まず、ソースコードを解析の基本単位となる字句に分解する。 この処理を字句解析と呼ぶ。 TEBAでは、字句を「属性付き字句」と呼ぶ形式で表現して扱う。 この形式では、字句の種類を表す「型」とその字句のテキストを組み合わせて表現する。 例えば、型や演算子、予約語は次のように表現する。

  • OP <+>
  • OP <*>
  • TYPE <int>
  • TYPE <char>
  • RE_IF <if>
  • RE_WHILE <while>

各行が属性付き字句である。各属性付き字句は次のような構成になっている。

  • 先頭の大文字の単語は、字句の種類を表す型名である。
  • 型名の後ろの <> の間は字句が表すテキストそのものである。
  • 型名と < の間には空白を入れる。ここには、後述する識別記号が挿入されることもある。

例えば、上記の1行目は二項演算子 + を表す属性付き字句である。 OP が演算子であることを意味し、<> の間には 字句のテキストとしての表現である + がある。 5行目の RE_IF <if>は、予約語 if を表す。 RE_IF の RE は英語の reserve の先頭の2文字が由来である。

型名については、この演習の中で指定するので、それに従うこと。 TEBA はC言語の構文解析器を持っているが、それが持つ字句の定義とは異なるので、 TEBA の構文解析器を参照しないこと。

演算子の型名については、当初、すべて OP とする。ただし、演算子には二項演算子と 単項演算子があり、構文解析のときは区別する必要がある。 しかし、字句解析の段階では区別がつかないので、演習の途中で区別する処理を追加する。

2.2.2 構文解析

構文解析によって、ソースコードの静的な構造を表す構文木を構成する。 ただし、木を表現するにあたり、木の節(ノード)は、 その節の始まりと終りを表わす字句で囲んで表現する。 考え方は、HTML や XML の開始タグと終了タグと同じである。

例えば、式 1+2 を考える。この式の構成要素である式 1 は次のように表現する。

B_P  #B0002 <>
NUM  <1>
E_P  #B0002 <>

これは、構文木をグラフで表現したときに、次のように単体の節点(ノード)を表す。

整数値 1 の字句は NUM <1> と表現され、値であることを意味する。 この字句を囲む B_PE_P という型の字句が、構文要素の「式」を表す。 B は begin を、E は end を意味する。 これらの字句は構文要素を表現するためだけの字句なので、字句としてのテキストは持たず、 <> の間には文字は入らない。 また、#B0002 のような識別記号が付与されている。 これは同じ記号を持つ字句は1つの構文要素の開始と終了の組となっていることを示している。 識別記号は同一のものかどうか区別できることに意味があり、その数字や文字自体に意味はない。 書換えの際には、このような組になっている字句を利用して、書換える箇所を探索する。

2 も同様に表現される。B_PE_P が持つ識別記号が 式 1 の場合とは異なることにも注意すること。

B_P  #B0003 <>
NUM  <1>
E_P  #B0003 <>

1+2は、これらの式を演算子+を間に入れて構成されるので、 次のように表現する。

B_P  #B0001 <>
B_P  #B0002 <>
NUM  <1>
E_P  #B0002 <>
OP  <+>
B_P  #B0003 <>
NUM  <2>
E_P  #B0003 <>
E_P  #B0001 <>

全体を囲む #B0001 の字句が全体の式を表す構文要素を表している。 また、演算子そのものは式ではないので、B_PE_P では囲まない。 B_PE_P の囲みと字句との包含関係を次に示す。

この包含関係は、次のグラフの木構造を表現しているものと解釈する。

すなわち、ある組の囲みが、別の組の囲みを含むとき、前者を親節点、後者を子節点として解釈する。 なお、組とならない字句、例えば、NUM <1> などは、 それを直接包含する節点に属するものとする。

TEBA は、ソースコードの断片も解析できるよう字句列を書換えていく。 書換えは、字句の並びの中から、構文要素の構成のパターンにあてはまる箇所を見つけると、 その箇所の前後に B_PE_P のような構文要素を表現する字句を挿入し、 節点を作るとともに、木構造を徐々に構成していく。 この仕組みを実現するために、構文木の葉から根にむかう順序で構文要素となる節点を構成する。

一般的に、構文解析では、LR構文解析や再帰下降構文解析が用いられる。 しかし、これらはソースコードが完全に揃っている状態を前提としている。 TEBA の場合は、構文解析ができない箇所が含まれていた場合は、そこを無視して書換えていくので、 不完全なソースコードも解析できる可能性がある。 なお、興味がある人は “island grammer” を調べるとよい。

2.3 演習: 解析ツールの作成

  1. まず、この演習で使うツールを作成する。字句解析と構文解析の機能を実装したものである。 次に示す Perl のコードをrw.pl という名前のファイルとして作成しなさい。 作成にあたっては、この資料のテキストをそのままコピーすればよい。

    このツールはファイル token.def に書かれた字句定義に従って字句解析を行い、 ファイル parser.rules の定義に従って字句列を書換え、構文解析を行う。 入力は、コマンド引数に指定したファイルまたは標準入力であり、出力は標準出力に書き出す。 なお、パッケージ RewriteTokens を使用するが、 旧バージョンと区別するために、導入(use)するときは “RewriteTokens2” という名前を用いる。

#!/usr/bin/env perl

use strict;
use warnings;

use Tokenizer;
use BracketsID;
use RewriteTokens2;

my $input = join("", <>);

my @tokens = Tokenizer->new->load("token.def")
    ->set_input($input)->tokens;

chomp @tokens;
BracketsID->new->conv(\@tokens);

RewriteTokens->new->syntax_check->rule(&file("parser.rules"))->apply(\@tokens);

print join("\n", @tokens), "\n";

sub file {
    my $f = shift;
    open (my $fh, '<', $f) || die "Can't open $f: ";
    my $text = join("", <$fh>);
    close($fh);
    return $text;
}
  1. 次の方法で、このファイルに実行許可を与えなさい。
chmod a+x rw.pl
  1. 次のようにツールを実行し、“can't load token definition: token.def” というエラーメッセージが表示されることを確認しなさい。表示されないときは、 正しく実行できる状態になっていない。
echo | ./rw.pl
  1. このツールは、カレントディレクトリにある token.defparser.rules を読み込んで動作する。次のようにコマンドを実行し、2つの空のファイルを作りなさい。 touch コマンドについては man で確認すること。
touch token.def parser.rules
  1. 再度、次のようにツールを実行しなさい。
echo | ./rw.pl

正しく空のファイルを作っていれば、次のように出力される。

UNIT_BEGIN  <>
UNKNOWN <\n>
UNIT_END    <>

3 字句解析

TEBA では、字句解析のためのモジュールとして Tokenizer が定義されている。 Tokenizer は、字句の定義を与えると、それに従って、 入力されたテキストを字句単位に切り出していき、属性付き字句のリストとして返す。

TEBA の字句解析では、コンパイラ等の一般的なツールで行う字句解析とは異なる点がある。 一般的なツールでは、字句として切り出すものは、英数字や記号で構成されたもので、 空白は字句とはならない。TEBA では空白も1つの字句として扱う。 これはソースコードを書換えるような操作をしたときに、元の空白を失なって、 スタイルが崩れることをできるだけ防ぐためである。 なお、コメントは、一般的な字句解析でも切り出されるがそのあとは削除される。 TEBA では、コメントも空白を表す字句として残す。

3.1 字句解析の定義

ツールでは、字句の定義はファイル token.def に書き込まれていることが前提となるので、 ファイル token.def に定義を記述する。定義の書式は次の通りである。

型名 /正規表現/

「型名」は、字句の種類を表す文字列である。空白や記号を含めてはいけない。 「正規表現」は型名に該当する字句に対するパターンである。

すべての字句の型について、定義を token.def に記述する。 字句の解析時には、上から下に順番に評価される。 すなわち、2つの正規表現に適合しうる字句(またはそれを構成する部分文字列)があった場合、 上に書いたものが適用される。

記述例を以下に示す。正規表現であるので、+* のように、 正規表現のメタ記号と一致する記号を指定するときは、そのまま記述できず、 直前にバックスラッシュを入れてエスケープする。 メタ記号ではない記号、例えば、- はそのまま記述できる。

OP  /\+/
OP  /\*/
OP  /-/
TYPE  /int/
TYPE  /char/

3.2 正規表現

正規表現は文字列に対するパターンを表現するもので、ソフトウェア開発において、 広く使われている。ここでは、最低限必要なものだけを解説する。詳細は各自で調べること。

アルファベット

パターン内に書いた文字は、そのままそのパターンで適合すべき文字となる。 ただし、以降で列挙するような特別な記号は除く。

ピリオド .

任意の1文字に適合する。

クエスチョン ?

直前の文字が存在しないか、1つ存在する。

アスタリスク *

直前の文字が 0 個以上存在する。

プラス +

直前の文字が 1 個以上存在する。

クラス [文字集合]

文字集合に書かれた文字のいずれか1個。 英字は A-Z のように省略して書ける。 例えば、変数名の先頭の文字は [a-zA-Z_] と表現される。 また、マイナス記号は英字の範囲を示す記号として使われるので、 もしマイナス記号を入れるときは [-0-9A-F] のように先頭に記述する必要がある。

クラスは指定する文字集合の先頭を ^ にすることで、補集合を指定できる。 例えば、[^abc]a, b, c の3つ以外の文字 (英字だけでなく、数字や各記号を含む)を意味する。

グループ (正規表現), (?:正規表現)

括弧内の正規表現で書かれたパターンを1つの塊として扱う。 例えば、(abc)*abc という文字の0回以上の連続を意味する。 なお、TEBA の Tokenizer では、内部処理の関係でエラーが起きないよう (?:正規表現) を使う方が安全である。

選択 |

複数の選択候補があるときに、その間を区切る。 例えば (a|b)+ というパターンは、 a または b が 1回以上連続していることを意味する。

特殊クラス \w, \d, \s, \b

\w[a-zA-Z0-9_] を、 \d[0-9] を意味する簡略表現である。 \s は任意の空白(半角の空白文字やタブ, 設定によっては改行)を意味する。 \b は文字そのものではなく、文字の種類の区切り(英字と記号の間など)を表す。

エスケープ \

正規表現の特殊な記号等そのものに適合させたいときは、 記号の前にバックスラッシュを付ける。\.\* など。 Tokenizer は正規表現をスラッシュ / で囲むので、 スラッシュも \/とエスケープする必要がある。

3.3 演習: 字句解析の定義の記述

  1. 必要な字句の定義をすべて記述しなさい。 ただし、演習を円滑に進めるために、型名については次に指定するものを使うこと。

    予約語, 記号等 型名
    if RE_IF
    else RE_ELSE
    while RE_WHILE
    for RE_FOR
    左波括弧 { C_L
    右波括弧 } C_R
    左丸括弧 ( P_L
    右波括弧 ) P_R
    演算子 OP
    識別子(変数) IDN
    数値(リテラル) NUM
    int, char, double TYPE
    セミコロン SC
    空白(タブ \t またはスペース)の連続 SP_B
    改行 \n SP_NL
    コメント // ... (ただし、行末の改行は含まない) SP_C

    以下補足です。

    • RE は予約語(reserved keywrod)を意味する。
    • 予約語 if に対して単に RE_IF /if/ と定義すると正しく処理されない。 例えば、iffy のような if で始まる名前の識別子(変数)を与えると、 まず、その先頭の if のみが切り取られ、fyと分断される。 単語としての if に正確に適合させるには、 if の後に文字が続かないことを表現するために、 単語の切れ目を表す \b を用いて RE_IF /if\b/ と定義する。
    • {} は「波括弧」と呼ぶ。数式では「中括弧」として使うことが多いが、 名称はあくまでも「波括弧」である。(JIS 規格でも決まっている)
    • この演習では、「識別子(identifier)」に相当するものは変数名である。 なお、実際のC言語では、識別子は変数名だけでなく、型名やタグ名なども相当する。
    • 数値については、整数と実数を区別しない。 また、負の数の場合、先頭のマイナス記号は数値の一部として扱う。 すなわち、-1-3.14 が1つの字句であり、マイナスは単項演算子ではない。 ただし、“- 1” や “- 3.14” のように、 マイナスの記号と数値の間に空白が入った場合は、それぞれ別の字句として扱い、 マイナス記号は単項演算子とする。
    • コメントは // から行末の改行の直前までを1つの字句として扱う。 この場合、「//で始まり、改行ではない文字の 0 個以上の連続」と考えればよい。
    • この表の順序と定義を書くべき順序は必ずしも一致しないので注意すること。
  2. 次のコマンドを実行して出力が、そのあとに示す出力例と合うか確認しなさい。 なお、if{ の間には空白文字が3つ存在していることに注意すること。

echo '-a_+bCd89e+0.92-(-3.2) if   {iff}int; //x x' | ./rw.pl

実行例は次のようになる。

UNIT_BEGIN  <>
OP  <->
IDN <a_>
OP  <+>
IDN <bCd89e>
OP  <+>
NUM <0.92>
OP  <->
P_L #B0001  <(>
NUM <-3.2>
P_R #B0001  <)>
SP_B    < >
RE_IF   <if>
SP_B    <   >
C_L #B0002  <{>
IDN <iff>
C_R #B0002  <}>
TYPE    <int>
SC  <;>
SP_B    < >
SP_C    <//x x>
SP_NL   <\n>
UNIT_END    <>
  1. 前のテストは十分ではない。すべての定義が正しいことを確認するのに十分な入力例を考え、 それを記述したファイルを作成しなさい。なお、構文解析は行わないので、 意味のあるプログラムになっている必要はなく、 テストできていない字句定義に合う字句、iffy のように紛らわしい字句を並べればよい。 また、空白に含まれるタブについてはテストできなくてもよい。

4 構文解析

TEBA では、属性付き字句列から、構文要素を構成する字句の並びを見つけ、 その前後に構文要素であることを示す字句を挿入していことで、 構文要素の識別を行う。 あるソースコードについて字句解析を行ったあと、この識別を繰り返し適用することで、 最終的に字句列全体で1つの構文木が構成される。 なお、この識別を行うために字句を置き換えたり、挿入・削除を行い、 以降では、このことを「字句列の書換え」と呼ぶ。

木の葉にあたる要素以外の構文要素は内部に構文要素を含む。 それらの構文要素を識別するためには、先にその内部の構文要素を識別しておく必要がある。 したがって、次の順に構文要素を識別していく。

  1. 式の終端要素: 構文木の葉となる構文要素 (変数と数値)
  2. 式の非終端要素: 内部に式を含む式 (ただし、優先順位が高い演算子で結合した式の識別が優先される)
  3. 文 (ただし、文は内部に文を含む入れ子になることがあり、その場合はより内側のものの識別が優先される)
  4. 宣言 (ここまでの順で識別すると、宣言は式文と誤った識別をされているので、修正の書換えを行う)

以降では、演習課題を通して、構文解析を徐々に実現していく。

4.1 変数とリテラルの識別

4.1.1 識別方法

最初に、構文木の葉(終端要素)になる要素を識別する。 具象構文木の場合は、予約語(if, while など)も構文木の葉になるが、 ここでは抽象構文木を構成し、 葉となる要素は単体の変数参照(識別子)およびリテラル(数値)で構成される式とする。

式を構成する字句列は字句 B_P <>E_P <> で囲むものとする。 直感的には次のような書換えルールを定義をしたい。 ただし、この定義ではあとで問題になるので、これを書いてはいけない!

{ $id:IDN } => { ('':B_P $id '':E_P) }

左辺( => の左側)は書換え対象としたい字句の並びであり、 このルールでは $id:IDN のみが書かれている。 この記述は、書換え対象の字句列の中の型が IDN の字句を表し、 右辺において、その字句を字句変数 $id で参照することを意味する。 変数名は自由に設定でき、ここでは識別子(identifier)なので $id と名付けている。 型 IDN は識別子の字句を表すが、リテラルを対象とするときは、 型名を NUM に変えたルールを用意する。

右辺(=>の右側)の波括弧内は書き換え後の字句の並びであり、 ('':B_P$id'':E_P) がそれぞれが字句を表す。 このうち $id は左辺の変数に適合した字句を表し、 ('':B_P'':E_P) は新しく挿入する字句である。 この右辺は、左辺で適合した識別子に対して、その前後に字句を1つずつ挿入することを意味する。

('':B_P'':E_P) については、まず、丸括弧を取り除いた '':B_P'':E_P から説明する。 '':B_P'':E_P は、それぞれ 型が B_PE_P の字句を意味する。 また、コロンの前のシングルクォートで囲まれた部分が、その字句のテキストである。 すわなち、それぞれ B_P <>E_P <> を表す。 なお、'text':T のようにシングルクォートの囲みに文字列を書けば、 字句 T <text> を表す。 構文解析ではテキストを持つ字句を挿入することはないので、 中にテキストを書かないシングルクォートの囲みを書くことは冗長ではあるが、 ソースコードの編集支援などで字句を追加したいときに必要となる。

('':B_P'':E_P) の丸括弧の意味について説明する。 丸括弧は開きとそれに対応する閉じの組によって、 これらの字句が1つの組を構成することを意味し、各字句に同じ識別記号を付与する。 すなわち、('':B_P'':E_P) と書いた場合は、 B_P #B0001 <>E_P #B0001 <> のように、 2つの字句に同じ識別記号(この場合は #B0001)が自動的に割り当てられる。

組になるので、入れ子の関係も表現できる。 例えば、('':B_P ('':B_P '':E_P) '':E_P) という入れ子構造に対しては 次のように、内側の組と外側の組に同じ識別記号が割り当てられる。

B_P #B0001  <>
B_P #B0002  <>
E_P #B0002  <>
E_P #B0001  <>

丸括弧の位置に注意が必要である。開き括弧は先頭に、閉じ括弧は最後につける。 また、開き括弧の後ろ、閉じ括弧の前に空白を入れてはいけない。 空白が入ると、括弧は単独の要素として扱われ、別の意味になる。 課題を行うにあたって、空白を入れる間違いを犯しやすいので注意すること!!

ルールは字句列を先頭から調べ、適用できるところをすべて書換えて終了する。 ただし、書き変わったところには適用されない。 したがって、このルールが意味するところは、字句列内のすべての IDN 型の字句について、 その前後を B_PE_P 型の字句で囲むということである。 次の入力と出力はルールによる書換えの例である。 なお、識別記号(#X0001 など)は実際のツールの出力とは異なる。

入力:

IDN <a>
OP  <*>
IDN <b>

出力:

B_P #X0001  <>
IDN <a>
E_P #X0001  <>
OP  <*>
B_P #X0002  <>
IDN <b>
E_P #X0002  <>

4.1.2 再帰的な構造の識別のための準備

この書換えルールは、非終端要素としての式、すなわち、 その内部に別の式を含む式を解析するときに問題が生じる。 ルールによる書換えは、字句列を先頭から最後尾まで1回走査して終了するので、 式のように再帰的な構造を持つものを解析するときは、 ルールを繰り返し適用する必要がある。 このとき、再帰的な構造の深さがわからないので、適用回数は固定できず、 書換えられる限り繰り返すという処理が必要になる。

構文解析における書換えでは、先に示した B_PE_P 型の字句のように、 基本的には構文要素となる字句の並びの前後に字句を挿入していく。 しかし、構文要素となる字句の並びそのものは変化していないので、 次に同じルールを適用すると、また、その字句の並びの前後に挿入することになる。 よって、書換えられる限り繰り返すという方針により、繰り返しは停止せず、 無限に書換え続けることになる。 現実には、最終的に構文解析器のメモリが尽きて、異常終了することになる。

この無限の繰り返しを回避するためには、構文要素として識別した字句の並びを、 次のルールの適用時に書換え対象とならないように、書換える必要がある。 そこで、最初に構文要素を識別するときは、一時的な型を持つ字句を挿入し、 その構文要素がそれを含む要素の識別に使われたら、正式な型の字句に置き換えるようにする。 また、一時的な型を持つ構文要素で構成された構文要素を識別するようにルールを書く。 この例は、式の構文解析のところで示す。

ここで重要なのは、「一時的な型を持つ構文要素で構成された構文要素を識別する」 という方針に合わせるため、 最初に識別する変数や数値(リテラル)単体で構成される式を識別するときは、 一時的な型の字句を挿入する必要があるということである。 よって、変数を持つ式の識別のルールを、次のように定義する。(これを書いてください!)

{ $id:IDN } => { ('':_B_X $id '':_E_X) }

ここでの型 _B_X_E_X が、B_PE_P の代わりに使う一時的な型である。 一時的な型名は、ルールを書くときの習慣としてアンダスコア _ から始める名前を付ける。 これは、書き換えが中途半端に終ったときに、そのことに気づきやすくなる利点がある。

以降の作業において、式の再帰的な構造を解析するときは、 _B_X_E_X 型の字句で表現される構文要素を子として持つ式の字句の並びを見つけ、 その全体を_B_X_E_X 型の字句で囲む。 それと同時に、_B_X_E_X 型の字句については、 型を B_PE_P に変更するか、それらの型の字句に置き換える。

4.1.3 演習: 変数と数値(リテラル)の識別

  1. 変数の参照する式を識別するルール(再帰的な構造の識別のための準備で示したもの)を parser.rules に記述しなさい。
  2. 変数と同様に、数値(リテラル)を参照する式を識別するルールをparser.rules に追記しなさい。
  3. abc + -1.234 - 567 * DEF を解析したとき、abc, -1.234, 567, DEF の 4 つの字句が型 _B_X_E_X の字句で囲まれることを確認しなさい。

なお、動作を確認するときは、次のようにするとよい。

  • 簡単な式を1行入力して確かめる場合は、echo コマンドとパイプを使って、 次のように実行する。 シェルに記号(この場合はアスタリスク*)をファイル名等に展開されないよう、 式はシングルクォートで囲む。
echo 'abc + -1.234 - 567 * DEF' | ./rw.pl

出力:

UNIT_BEGIN  <>
_B_X #X0001 <>
IDN <abc>
_E_X #X0001 <>
SP_B    < >
OP  <+>
SP_B    < >
_B_X #X0003 <>
NUM <-1.234>
_E_X #X0003 <>
SP_B    < >
OP  <->
SP_B    < >
_B_X #X0004 <>
NUM <567>
_E_X #X0004 <>
SP_B    < >
OP  <*>
SP_B    < >
_B_X #X0002 <>
IDN <DEF>
_E_X #X0002 <>
SP_NL   <\n>
UNIT_END    <>
  • 複雑な式や複数の式を入力させるときは、test.c などの適当なファイルを作り、 ツールの引数に指定する。
./rw.pl test.c
  • 出力される字句リストが長くて確認しにくいときは、 字句を型付の状態で結合させて表示させる方法もある。 結合するには、TEBA の join-token.pl を使用する。 “-d” が型付で表示させるオプションである。 これを省略すると、単純に字句を結合するので、 正しく解析できているかは確認できない。
./rw.pl test.c | join-token.pl -d
  • join-token.pl は見やすくするために、 B_PE_P は紫色の波括弧に省略して表記する。 もし正確に確認したいときは、オプションを -de にすればよい。

  • join-token.pl は色を付けて出力するために、ANSIの カラーエスケープシーケンス(color escape sequence)を使用している。 less などのページャーを使う場合には、 エスケープシーケンスをそのまま出力するオプションが必要になる。

    • less の場合は -r (または -R)を使用する。
    • lv の場合は -c を使用する。
./rw.pl test.c | join-token.pl -d | less -r
  • ルールを書くときに空白の存在を忘れがちなので、演算子の前後など、 空白が入りうる箇所には必ず空白を入れてテストすること。 例えば、式を a*b*c ではなく、a * b * c と書く。

4.2 左結合と右結合

4.2.1 左結合

複数の演算子で構成された式を考える。 例えば、a * b * c という式は、二項演算子 * で結合した式である。 「二項演算子」であるから、2つの「項」が存在する。 なお、演算子は、項の間に書くが、このような書き方を「中置記法(infix notation)」と言う。

2つの演算子 * ごとに、「項」とは何かを考える。式が単純に a * b であれば、 項は ab である。構文木としては、次のようになる。

式が a * b * c の場合を考える。 最初の演算子 * の左側の項は a であるが、 右側は b の場合と b * c の2種類の解釈の可能性がある。 すなわち、各項を括弧で囲んで表現するなら ((a) * (b)) * (c) の場合と、 ((a) * ((b) * (c)) の2種類である。 構文木で示すと、次の通りである。

どちらになるかは、2つの演算子のうち、どちらから先に項を決めるかによって決まる。 先の演算子について先に項を定めれば、右側の項は b となり、図の右側の構文木になる。 後の演算子について先に項を定めれば、最初の演算子の右側の項は b * c になり、 図の左側の構文木になる。 このような解釈の曖昧さをなくすために、一般的なプログラミング言語では、 演算子の結合の仕方のルールを定めている。

かけ算のような数値演算の演算子は左側に出てくる演算子から項を定める。 これを「左結合」と呼ぶ。 すなわち、結合を括弧で明示的に表現すれば ((a) * (b)) * (c) と解釈し、 抽象構文木は、図の右側になる。 さらに演算子が増えても、同じように ((((a) * (b)) * (c)) * (d)) * (e) と解釈する。 なお、数学的にはどう解釈しても同じと考えるが、プログラムの場合にはそうならない。 例えば、割り算において、double 型と int 型の変数や値が混在すると、 値の切り捨ての仕方が項の組み合わせによって異なり、 計算結果が異なる可能性が生じる。 C言語で、(9.0/3)/29.0/(3/2) の計算結果がどうなるか考えてみるとよい。

4.2.2 右結合

多くの演算子は左結合であるが、右結合になる演算子も存在する。 この演習では、代入演算子 = のみが該当する。 代入演算子は、右辺の値を左辺に代入し、代入された値を返す。右辺の値ではなく、 左辺の値であることに注意する必要がある。 例えば、C言語の次のプログラムを考える。

#include <stdio.h>

int main(void) {
  unsigned char c;
  int  x;
  x = c = 257;
  printf("%d\n", x);
  return 0;
}

このプログラムの出力は 257 ではなく、1 になる。unsigned char 型の変数は 8 ビット(最大値 255) の値しか扱えないので、上位のビットが削られ、 下位の8ビット(この場合 256 を引いた値)のみが残る。

このことを踏まえて考えると、式 x = c = 257 に結合を括弧で明示的に表現すると、 x = (c = 257) となる。すなわち、右結合となっており、右側から結合していく。 抽象構文木で表現すると次の通りである。

代入演算子を増やしても、同様に a = (b = (c = (d = e))) となる。 なお、代入演算子は、それ自身の意味から、左結合になる余地はない。 例えば、左結合にすると、(x = c) = 257 と計算する必要がある。 仮に c の値を 1 とすると、(x = 1) = 257 と等しくなり、 x の値は 1 になるので、1 = 257 という意味不明な代入を行うことになる。

4.3 左結合の演算子(かけ算)の識別

4.3.1 識別方法の検討

ここでは、まず、かけ算(演算子 *)のみの式について考える。 単純に考えると、書換えのルールは次のように考えられる。 ただし、この定義は期待通りには動かない(だから、書かないでください!)。

{ ($lb:_B_X $left:@ANY $le:_E_X) $sp1:@SP
        $op:OP/\*/  $sp2:@SP ($rb:_B_X $right:@ANY $re:_E_X) }
=> { ('':_B_X $lb:B_P $left $le:E_P $sp1
        $op  $sp2 $rb:B_P $right $re:E_P '':_E_X) }

この書き換えルールは、直感的には、 次ように演算子 * の前後の2つの式を組み合わせた新しい式を作るよう、 字句の挿入および型の変更を行う。

ここで、このルールでは @ANY@SP という特殊な型名(字句マクロ)を使用している。 それぞれ、任意の字句の連続と空白(改行を含む)の連続を意味する特殊な型である。 この型を使うには、この型を参照する書換えルールより前に、 次のように字句マクロとして定義しておく(これは書いてください!)。

@ANY => { [ $:/./ ]* }
@SP => { [ $:/SP_/ ]** }

なお、@SP には最長一致となる繰返しを表す ** を用いる。 このようにすると、任意の字句の連続 @ANY と併用したときに、 空白が任意の字句の連続の方に含まれることを回避でき、 書換えルールが簡素化される。

また、$op:OP/\*/$:/./ のように、 型名の後ろにスラッシュで囲んだ正規表現を記述できる。 この場合、字句のテキスト(<>の間)がこの正規表現に合致するもののみが、 変数に適合する。例えば、$op:OP/\*/ は、OP型の字句、すなわち、 すべての演算子の中でかけ算の記号である * に限定しており、 変数 $op にはかけ算の演算子の字句のみが適合する。 $:/./ は型名をスラッシュで囲んで表現している。この場合は型名が先頭から 正規表現の . 、すなわち、任意の1つの文字に適合することを意味する。 どのような型でも1文字以上で構成されるので、すべての型名が適合する。 同様に $:/SP_/SP_ で始まる型名を意味する。 なお、$:/./$:/SP_/ は変数名を省略している。 これはこの変数を参照することがないからである。

なぜ上記の書換えルールが期待通りにならないかは、a * b * c * d のような長い式に適用するとわかる。 対応関係をわかりやすくするために、_B_X_E_X を丸括弧で表現すると、 1回、ルールを適用すると、書換え後は次の結合になる。 すなわち、本来、結合しない c * d が結合し、左結合とは異なる結合になっている。

(a * b) * (c * d)

このような結果になる理由は、1回の書換えルールの適用では、一度、 書換えた箇所は書換えの対象にならないからである。 つまり、(a * b) の部分の書き換えをしたあと、 b の後ろの空白から書換え可能箇所を探し、c * dを見つけて書換えるからである。 この制約は不便であるが、これがないと、同じ位置での書換えが無限に行われることになり、 適切な書換えが実現できなくなる。

以上のように、単純に演算子の前後の式をまとめて1つの式にするという方法では、 左結合に対応しておらず、正しく構文解析を行えない。

4.3.2 左結合に基づく識別方法

左結合に基づくには、左側からしか結合できない状況を作り出す必要がある。 前の例で言えば、1回の書換えルールの適用では (a * b) * c * d と最初の式のみ識別するように制限をかける必要がある。

説明を簡潔にするために、以下では次のように表現する。なお、_B_OP_E_OP は左結合を実現するために導入する一時的な型である。

記号
_B_X 丸括弧 開き (
_E_X 丸括弧 閉じ )
B_P 波括弧 開き {
E_P 波括弧 閉じ {
_B_OP 角括弧 開き [
_E_OP 角括弧 閉じ ]

まず、a * b * c * d の終端要素のみ、すなわち、変数を参照する式を識別した状態を示す。

(a) * (b) * (c) * (d)

次に、一番左の項 (a) とそれ以外の各項が区別をつくように書換える。 この場合、(a) 以外は演算子 * の右側に存在するということに着目する。 演算子とその右側の式を仮に一つのまとまりと考え、 それらを _B_OP_E_OP (角括弧で表現) 型の字句で囲む。 このとき、その内部の _B_X_E_X (丸括弧で表現)は式の子要素になることが確定するので、合わせて B_PE_P (波括弧で表現)に変換する。 変換した結果は次の通りである。

(a) [* {b}] [* {c}] [* {d}]

結合すべき左側の部分を見ると、 一番左の項は _B_X_E_X (丸括弧)で囲まれ、 その右の項は _B_OP_E_OP (角括弧)で囲まれており、 この組み合わせは、左側の2つの項にしか存在しない。 そこで、この組み合わせを結合して、式を構成するよう書換える。 書換えた結果は次のようになる。

({a} * {b}) [* {c}] [* {d})]

一時的な型 _B_OP, _E_OP の字句は不要となるので書換え後には残さない。 また、左の項になる式の _B_X_E_X (丸括弧)を B_PE_P (波括弧)に置き換える。

ここまでが左側から1式を1つ構成したことになり、左結合を1回実現したことになる。 書換えたあとを見ると、一番左側の式 ({a} * {b}) と、 一時的な字句で囲まれた [* {c}] が並んでおり、 再帰的に適用できることがわかる。 繰返し適用していくと、次のように変化し、正しく左結合での構造が構成される。

({{a} * {b}} * {c}) [* {d}]
({{{a} * {b}} * {c}} * {d})

4.3.3 ルールの適用順序

式の構造を作っていくには、 まず最初に変数と数値(リテラル)を式として識別しておくことが必要である。 そのあと、前節の書換えを繰り返し適用していく。

ルールの適用の順序は、基本的には記述した順序、すなわち上から下に適用される。 よって、まず変数と数値(リテラル)を式として識別を記述し、 その下に式の構造を識別していくルールを書いていく。

4.3.4 再帰的な書換え

式の構造の解析は再帰的な書換え、すなわち、書換えルールを繰り返し適用が必要である。 ルールの適用を繰り返すには、次のように REPEAT { ... } を用いて書く。 REPEAT の中は、複数のルールを書くことができる。 その場合、そのルールは上から下に順次適用される。 また、REPEAT の中に REPEAT を書く、言わゆる多重ループも記述可能である。

REPEAT {

   { .... } => { .... }
   { .... } => { .... }
         .........
   { .... } => { .... }

}

4.3.5 演習: かけ算の識別

  1. 最初の _B_OP_E_OP で囲むルールは次のように書ける。 このルールを parser.rules に追加して、適切に書換えられることを確認しなさい。 なお、書換え後(=>の右側)の $b:B_P$e:E_P は、 字句 $b$e が参照する字句の型をそれぞれ B_PE_P に書き換えることを意味する。
{ $op:OP/\*/ $sp:@SP ($b:_B_X $right:@ANY $e:_E_X) }
=> { ('':_B_OP $op $sp $b:B_P $right $e:E_P '':_E_OP) }
  1. 一番左側の式の識別できるよう書換えルールを実現しなさい。

    • 一時的な字句である _B_OP_E_OP は、書換え後に不要になるので、 変数に名前を付ける必要はなく、$:_B_OP のように省略してよい。
    • 演算子と右の項の字句列は _B_OP_E_OP 組で囲まれていて、 書き変わらないので、@ANY を使うと簡単になる。 すなわち、書換え前(=>の左側)では、($:_B_OP $right:@ANY $:_E_OP) と書けば、書換え後(=>の右側)では、$right を参照するだけで済む。
    • 書換え後に、1つの演算子に対する式は _B_X_E_X で囲まれることになる。 囲むためのこれらの字句は、書換え後(=>の右側)で ('':_B_X'':_E_X) で表現する。
    • 空白が入りうる箇所には、必ず空白が存在すると考えて書換えルールを書くこと。
  2. 次の実行例と実行結果を参考に、正しく書き換えられていることを確認しなさい。 なお、ルールの書き方によっては、識別記号は異なる可能性があるので、 識別記号が異なる場合は、字句の組み合わせが一致しているかを確認すること。

    実行例:

echo 'xyz * abc * DEF' | ./rw.pl

実行結果:

UNIT_BEGIN  <>
_B_X #X0006 <>
B_P #X0001  <>
IDN <xyz>
E_P #X0001  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0002  <>
IDN <abc>
E_P #X0002  <>
_E_X #X0006 <>
SP_B    < >
_B_OP #X0005    <>
OP  <*>
SP_B    < >
B_P #X0003  <>
IDN <DEF>
E_P #X0003  <>
_E_OP #X0005    <>
SP_NL   <\n>
UNIT_END    <>
  1. 再帰的に書き変わるようにルールを修正しなさい。 具体的には、繰り返し適用すべきルールを REPEAT で囲めばよい。

  2. 次の実行例と実行結果を参考に、正しく書き換えられていることを確認しなさい。 なお、ルールの書き方によっては、識別記号は異なる可能性があるので、 識別記号が異なる場合は、字句の組み合わせが一致しているかを確認すること。

    実行例:

echo 'p1 * p2 * p3' | ./rw.pl

実行結果:

UNIT_BEGIN  <>
_B_X #X0007 <>
B_P #X0006  <>
B_P #X0001  <>
IDN <p1>
E_P #X0001  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0002  <>
IDN <p2>
E_P #X0002  <>
E_P #X0006  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0003  <>
IDN <p3>
E_P #X0003  <>
_E_X #X0007 <>
SP_NL   <\n>
UNIT_END    <>
  1. 演算子が 5 つの例を作り、正しく解析できることを確認しなさい。

4.4 右結合の演算子 (代入)

4.4.1 識別方法

右結合は、左結合を反転する形で行えばよい。 つまり、右側から式を構成していく。 式 a = b = c = d を例に、左結合と同じように表現すると、 次のように書換えることになる。

(a) = (b) = (c) = (d)
[{a} =] [{b} =] [{c} =] (d)
[{a} =] [{b} =] ({c} = {d})
[{a} =] ({b} = {{c} = {d}})
({a} = {{b} = {{c} = {d}}})

4.4.2 演習: 代入演算子の識別

  1. 左結合の書換えルールを参考に、代入演算子についての書換えルールを記述せよ。 かけ算のルールの後ろ(REPEAT { ... } の後)に書くこと。

  2. 次の実行例と実行結果を参考に、正しく解析できているかを確認せよ。

    実行例(1):

echo 'a1 = b2 = c3 = e4 = x * 4 * 5 * z' | ./rw.pl

実行結果(1):

UNIT_BEGIN  <>
_B_X #X0022 <>
B_P #X0001  <>
IDN <a1>
E_P #X0001  <>
SP_B    < >
OP  <=>
SP_B    < >
B_P #X0021  <>
B_P #X0002  <>
IDN <b2>
E_P #X0002  <>
SP_B    < >
OP  <=>
SP_B    < >
B_P #X0020  <>
B_P #X0003  <>
IDN <c3>
E_P #X0003  <>
SP_B    < >
OP  <=>
SP_B    < >
B_P #X0019  <>
B_P #X0004  <>
IDN <e4>
E_P #X0004  <>
SP_B    < >
OP  <=>
SP_B    < >
B_P #X0014  <>
B_P #X0013  <>
B_P #X0012  <>
B_P #X0005  <>
IDN <x>
E_P #X0005  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0007  <>
NUM <4>
E_P #X0007  <>
E_P #X0012  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0008  <>
NUM <5>
E_P #X0008  <>
E_P #X0013  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0006  <>
IDN <z>
E_P #X0006  <>
E_P #X0014  <>
E_P #X0019  <>
E_P #X0020  <>
E_P #X0021  <>
_E_X #X0022 <>
SP_NL   <\n>
UNIT_END    <>

実行例(2):

echo 'a1 = b2 = c3 = e4 = x * 4 * 5 * z' | ./rw.pl | join-token.pl -d

実行結果(2): [注意: 資料を見やすくするために、最後の = の後ろで改行している ]

:UNIT_BEGIN:_B_X#X0022{a1:IDN} =:OP {{b2:IDN} =:OP {{c3:IDN} =:OP {{e4:IDN} =:OP
{{{{x:IDN} *:OP {4:NUM}} *:OP {5:NUM}} *:OP {z:IDN}}}}}:_E_X#X0022
:UNIT_END
  1. 演算子の数を増やした例を作り、正しく解析できるか確認しなさい。

4.5 優先順位が異なる演算子

4.5.1 優先順位と解析順序

書換えルールを書く順序によって、結合の仕方が異なる。 例えば、a = b * c という式は、括弧で結合を表現すれば、次の2通りが考えられる。

  • a = (b * c)
  • (a = b) * c

我々は自然と前者と解釈をするが、暗黙的に、 かけ算の演算子 * と代入演算子 = では、かけ算の演算子 * の方が優先順位が高いことを理解しているからである。 TEBA の構文解析において、優先順位を反映するには、 優先順位が高い演算子の書換えルールから記述していけばよい。

演算子の優先順位は、我々が自然と解釈できるような順序になっているが、 厳密にはプログラミング言語によって異なる。 構文解析を実現するときは、プログラミング言語の仕様書で、 演算子の優先順位を正確に確認しておく必要がある。

4.5.2 演習: 足し算の演算子の追加

  1. C言語の演算子の優先順位がどのように定められているか調べなさい。

  2. 足し算の二項演算子 + の式を識別するための書換えルールを書きなさい。 parser.rules 内での記述する位置については、 演算子の優先順位に基づいて考えなさい。 なお、足し算は左結合なので、書換えルールの書き方は、かけ算を真似すればよい。

  3. 次の実行例と実行結果を参考に、正しく解析できているかを確認せよ。

    実行例(1):

echo 'a1 + b2 + 0.23 * x + 1.58 * y' | ./rw.pl

実行結果(1):

UNIT_BEGIN  <>
_B_X #X0016 <>
B_P #X0015  <>
B_P #X0014  <>
B_P #X0001  <>
IDN <a1>
E_P #X0001  <>
SP_B    < >
OP  <+>
SP_B    < >
B_P #X0002  <>
IDN <b2>
E_P #X0002  <>
E_P #X0014  <>
SP_B    < >
OP  <+>
SP_B    < >
B_P #X0009  <>
B_P #X0005  <>
NUM <0.23>
E_P #X0005  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0003  <>
IDN <x>
E_P #X0003  <>
E_P #X0009  <>
E_P #X0015  <>
SP_B    < >
OP  <+>
SP_B    < >
B_P #X0010  <>
B_P #X0006  <>
NUM <1.58>
E_P #X0006  <>
SP_B    < >
OP  <*>
SP_B    < >
B_P #X0004  <>
IDN <y>
E_P #X0004  <>
E_P #X0010  <>
_E_X #X0016 <>
SP_NL   <\n>
UNIT_END    <>

実行例(2):

echo 'a1 + b2 + 0.23 * x + 1.58 * y' | ./rw.pl | join-token.pl -d

実行結果(2): [注意: 資料を見やすくするために、式の途中で改行している ]

:UNIT_BEGIN:_B_X#X0016{{{a1:IDN} +:OP {b2:IDN}} +:OP {{0.23:NUM} *:OP {x:IDN}}} 
+:OP {{1.58:NUM} *:OP {y:IDN}}:_E_X#X0016
:UNIT_END
  1. 演算子の出現の仕方が異なる例を作り、正しく解析できるか確認しなさい。

4.6 優先順位が同一の演算子

4.6.1 解説

解析の順序を調整することで、演算子の優先順位を調整できる。 しかし、かけ算と割り算のように、同一の優先順位の演算子については、 どちらかを先に解析するということはできない。 例えば、a / b * c / d という式は、結合を括弧で表現すると次のようになる。

((a / b) * c) / d

しかし、例えば、かけ算から先に解析すると次のような結合になり、式の意味が変わる。

(a / (b * c)) / d

これを解決するためには、優先順位が同一の演算子については左側から先に出てくる方を優先する。 そのためには、演算子を扱う書換えルールにおいて、 優先順位が同一の演算子を区別せずに扱うようにすればよい。 例えば、すでにかけ算についての構文解析のための書換えルールを記述しているが、 そのルールが割り算の演算子も扱うよう拡張すればよい。 ルールを追加するのではないことに注意すること。 解析する過程を、これまでと同様に表現すると次のようになる。

(a) / (b) * (c) / (d)
(a) [/ {b}] [* {c}] [/ {d}]
({a} / {b}) [* {c}] [/ {d}]
({{a} / {b}} * {c}) [/ {d}]
({{{a} / {b}} * {c}} / {d})

4.6.2 演習: かけ算と割り算の解析

  1. かけ算の書換えルールを修正して、かけ算と割り算の両方に対応するようにせよ。
    • 簡単な方法は、 書換えルールの演算子の変数の定義 $op:OP/\*/ を正規表現のクラスを使って $op:OP/[\*\/]/ と書く。
    • パターンを書くときに、 スラッシュもバックスラッシュでエスケープする必要があることに注意せよ。
  2. かけ算と割り算が混合した式を用いて、正しく解析されることを確認せよ。 演算子の数や出現の仕方が異なる例を複数考えて試すこと。

4.6.3 演習: 足し算と引き算の解析

  1. 足し算の書換えルールを修正して、足し算と引き算を同時に解析できるようにせよ。
  2. 足し算と引き算が混合した式を用いて、正しく解析されることを確認せよ。 演算子の数や出現の仕方が異なる例を複数考えて試すこと。

4.7 括弧による優先順位の変更

4.7.1 解説

式を表現するときに、優先順位が低いものを先に結合させたいことがある。 例えば、変数 ab の平均値は2つの変数の和を2で割る必要がある。 これを a + b / 2 と書くと、b / 2 を先に計算するので間違いである。 足し算を優先する必要があるので、括弧を使って (a + b) / 2 と記述する。

式を括弧で囲んだとき、その全体は、その内部の式がどのようになっていても、 式として扱える。そこで、解析の開始段階で、変数や数値(リテラル)と同じく、 式として識別する。直感的には、次のように記述したい(実際には、これではうまくいかない)。

{ ($pl:P_L $any:@ANY $pr:P_R) } => { ('':_B_X $pl $any $pr '':_E_X) }

括弧は入れ子にできる。すなわち、( ((a) + (b)) * (c) ) のように、 括弧の式の中に括弧を記述できる。 書換えルールは、字句列の先頭から最後まで走査しながら書き換えるが、 書き換えた箇所についてはそれ以上の書換えを行わない。 よって、このルールでは、走査中に最初に出会う最も外側の括弧については書き換える (この場合は字句を挿入する)が、その中の括弧は書換えの対象外となる。

再帰的に書き換えるためには次のように REPEAT を用いて、書換えを繰り返す必要がある。

REPEAT {
  { ($pl:P_L $any:@ANY $pr:P_R) } => { ('':_B_X $pl $any $pr '':_E_X) }
}

しかし、上記の書換えルールを繰り返しても、正しく書換えられない。 括弧の字句(P_L, P_R 型の字句)は、書換え後も残っているので、 同じ括弧を見つけて挿入することを繰り返す。 すなわち、無限に書換えを続けて、最後は異常終了する。

無限の繰り返しを止めるには、書換え対象となった括弧を、 次は書換え対象とならないように制限する必要がある。 つまり、括弧は P_L, P_R 型の字句なので、 一時的に別の型に書換えればよい。 書換えた型名は、括弧の式の識別に関する一通りの書換えを終えたら、戻せばよい。 これを実現したもの次に示す。

REPEAT {
  { ($pl:P_L $any:@ANY $pr:P_R) }
       => { ('':_B_X   $pl:_XP_L $any $pr:_XP_R   '':_E_X) }
}

{ $pl:_XP_L } => { $pl:P_L }
{ $pr:_XP_R } => { $pr:P_R }

この書換えでは、括弧の字句の型を、一時的に _XP_L_XP_R に変更している。 さらに、再帰的な書換え、つまり、REPEAT が終了したあと、元に戻している。 元に戻すときは、繰り返しは不要である。1つの字句だけを対象に書換えるので、 1回の走査ですべて書換えることができる。

ここで採用した括弧を _B_X_E_X で囲むという方法は、少し乱暴である。 関数定義を考えたときに、定義する関数の名前の直後の仮引数を囲む括弧は式ではない。 型変換に用いるキャストは、型名を括弧で囲むが、これも演算子であって式ではない。 制御文の条件式を囲む丸括弧も式ではない(for 文を考えるとわかりやすい)。 つまり、丸括弧は様々な用途で用いられているので、それを区別する必要がある。 この問題を解決する方法としては、次の2通りが考えられるが、 ここではこの点については深入りしない。

  • 式を解析する前に、その使われ方(文脈)によって、丸括弧を別の型名に書換えておく
  • 一旦、式として解釈させておいて、あとで式ではない箇所を修正するように書換える

4.7.2 演習: 括弧の解析

  • 解説を読んでから、括弧に対する書換えルールを parser.rules に追加せよ。 追加すべき位置は解説を読んで考えること。
  • 複数の括弧を使った式を用いて、正しく解析されることを確認せよ。

4.8 単項演算子

4.8.1 解説

ここまでは、演算子は二項演算子のみを扱ってきた。しかし、例えば - a のように、 1つの項を右側に持つ演算子も存在する。 このような演算子を単項演算子と呼ぶ。 また、このような式の前(左)に演算子を書く書き方を「前置記法(prefix notation)」、 あるいは「ポーランド記法(polish notation)」と呼ぶ。

単項演算子は、一般的に、優先順位が最も高いので、 終端要素(ここでは、変数、数値、括弧)の次に解析すればよい。 しかし、この演習で扱う単項演算子 +- は、 二項演算子としても使われる記号であり、その区別が必要となる。

二項演算子と単項演算子の違いを考える。 二項演算子は演算子の左右に2つの項を持ち、 単項演算子は右側に1つの項を持つ。 右側に項を1つ持つことでは共通しているが、 左側は、二項演算子の場合は項(つまり式)が、 単項演算子の場合は式以外のものが存在する。

違いを理解するために、次の例を考える。

  • a + - b
  • - a + b
  • - a + - - b

この例では、演算子 + は二項演算子で、- は単項演算子である。 3つ目の例の - - b の ように、 単項演算子は単項演算子用いた式に対して重ねて使うこともできる。

演算子 - の左側に着目すると、演算子が存在するか、「何も存在しない」のいずれかである。 一方、+ の左側には a という式が存在する。 様々な例を考えると、単項演算子の左側に演算子を書くことはできるが、 二項演算子の左側に演算子を書くことはできない。 例えば、a + + ba - + b のような式はありえない。 二項演算子の左側には式しか書けず、また、必ず式が存在しなければならない。

これらのことから、次のように二項演算子を特定し、それ以外を単項演算子と扱えばよい。

  1. 左に式( _B_X_E_X の組)が存在する演算子は二項演算子と特定する。
  2. 二項演算子として特定されない演算子は単項演算子と特定する。

二項演算子と単項演算子を区別するために、これ以降、 単項演算子の型は OP から OP_U に変更するものとする。 ただし、単項演算子は「二項演算子として特定されない演算子」であるので、 先に二項演算子から特定する必要がある。具体的には次の手順で行う。

  1. まず二項演算子の字句の型を一時的な型である _OP_BIN に変更する。
  2. 次に、残っている演算子が単項演算子なので、それらの字句の型を OP_U に変更する。
  3. 最後に 二項演算子、すなわち _OP_BIN 型の字句の型を OP に戻す。

なお、二項演算子に対して、単項演算子は種類が少ないことに注意する必要がある。 例えば、かけ算の * や比較演算子の < は単項演算子にはならない。 よって、単項演算子になりうる演算子のみ対象として書換えルールを書けばよい。

4.8.2 演習: 単項演算子の識別

  1. 書換え規則をわかりやすくするために、まずマクロを定義する。 単項演算子の候補となる演算子を表す字句を字句マクロ @UNARY_OP として定義しなさい。 候補とは、すべての演算子のうち単項演算子になりうる演算子のことである。 定義は次のように書く。
@UNARY_OP => { $:OP/ここに字句パターンを書く/ }

このうち、スラッシュで囲まれた「ここに字句パターンを書く」の部分に、 候補となる演算子のいずれかに適合する文字列の正規表現を書く。

字句マクロ定義は、このマクロを使うルールより前に存在しなければならない。 すでに定義しているマクロ(@ANY など)と並べるとよい。

  1. 単項演算子の候補の演算子のうち、二項演算子の型を _OP_BIN に変更するルールを追加せよ。次の点に注意すること。

    • 単項演算子の候補の型は OP ではなく @UNARY_OP を用いる。 すなわち、変数は $op:@UNARY_OP のように書く。
    • 左側が式になっているとは、実質的に左隣りに _E_X 型の字句が1つ存在するということである。 _B_X 型の字句や その式を構成する字句の存在を考える必要はない。
    • 単項演算子とその左側の式の間には空白が入りうる。
    • ルールを書く位置は解説をよく読んで考えること。
  2. 二項演算子にならなかった単項演算子の候補の演算子の型を OP_U 型に変更するルールを追加しなさい。

  3. 二項演算子として識別子した _OP_BIN 型の字句を、 すべて OP 型に戻すルールを追加しなさい。

  4. 単項演算子(OP_U型の字句)で構成される式を識別するルールを追加しなさい。 次の点に注意すること。

    • 「式を識別する」とは、式全体を _B_X_E_X の字句で囲む処理である。
    • 識別した式の内部の式(単項演算子の右側)の _B_X_E_XB_PE_P に修正する。
    • 単項演算子を重ねて適用できるので、書換えを繰り返すこと。
  5. 次の実行例と実行結果を参考に、正しく解析できているかを確認しなさい。

実行例(1):

echo 'a - + - b' | ./rw.pl

実行結果(1):

UNIT_BEGIN  <>
_B_X #X0006 <>
B_P #X0001  <>
IDN <a>
E_P #X0001  <>
SP_B    < >
OP  <->
SP_B    < >
B_P #X0004  <>
OP_U    <+>
SP_B    < >
B_P #X0003  <>
OP_U    <->
SP_B    < >
B_P #X0002  <>
IDN <b>
E_P #X0002  <>
E_P #X0003  <>
E_P #X0004  <>
_E_X #X0006 <>
SP_NL   <\n>
UNIT_END    <>

実行例(2):

echo 'a - + - b' | ./rw.pl | join-token.pl -d

実行結果(2):

:UNIT_BEGIN:_B_X#X0006{a:IDN} -:OP {+:OP_U {-:OP_U {b:IDN}}}:_E_X#X0006
:UNIT_END
  1. 正しく単項演算子を識別できるか、適切なテストケースを考えて確認しなさい。

4.9 式の構文解析

4.9.1 式の構文解析の最終処理

ここまでの手順で、式の基本的な解析の手順ができあがっている。 残る作業は以下の通りである。

  • 取り扱ってこなかった演算子について、優先順位を考慮して、書換えルールを追加する。
  • 式全体が _B_X_E_X 型の字句で囲まれているので、 これらを B_PE_P 型に置き換える。
  • 括弧内の式も同様なので、それぞれ置き換える。

なお、最後の作業について注意すべきことがある。この置換えの直前では、 式全体と括弧内の式以外に、_B_X_E_X が残ることはありえない。 もし、そのような状態があれば、構文解析が適切に実現できていないことになる。 最後の作業を適用すると、その正しくない状態に気づけなくなるので、 最後の作業を実現する前に、十分にテストをして、正しく構文解析ができていることを確認すること。

4.9.2 関数呼び出し

この演習の対象プログラミング言語では、関数を扱っていないので、 関数呼び出しも存在しない。 しかし、現実的なプログラミング言語においては、関数は必須であり、 関数呼び出しを式の項として扱うことは不可欠である。 そこで、演習には含まれないが、関数呼出しの扱いについて説明する。

関数呼び出しを扱うためには、次のように識別する。

  • 式の右側に演算子が存在せず、代わりに括弧で囲まれた式が存在したら、 2つの式の組み合わせを関数呼び出しと識別する。
  • 右の括弧内(つまり、実引数)は、カンマで区切られた式が 0 個以上存在する。 これらの式は _B_X_E_X 型の字句で囲まれている。 関数呼び出しを識別したときに、それらを B_PE_P 型に置換えるか、 そのままにして、式の解析の終了時にまとめて置換える。 後者の方法は実装が簡単になるが、解析の誤りに気づきにくいので注意が必要である。

なお、関数呼び出しの左側の式は識別子とは限らない点に注意する。 C言語では、関数のポインタに関する演算を行う式(例えば (*func_p)(arg1, arg2)) になっている可能性がある。 また、オブジェクト指向言語では、インスタンスが持つメソッドを呼び出すので、 インスタンスからメソッドを参照する式になる。 さらに、C言語の場合、ユーザ定義型を用いたキャストは、 関数呼び出しと形が同じになるケース(例えば、(Student_t)(s+1))があり、 ユーザ定義型の情報なしには正しく解析できないことがある。

4.9.3 演習: 式の解析の完結

  1. すべての演算子について、書換えルールを parser.rules に追加せよ。 プログラミング言語の仕様は対象プログラミング言語 で確認すること。
  2. 式の解析を完了させるために、ここまでの式の解析で変換されずに残った _B_X_E_X をそれぞれ B_PE_P に置き換える書換えルールを parser.rules に追加せよ。
  3. すべての演算子について、適切に優先順位を取り扱いながら、式を解析できることを確認せよ。

補足: 複数の演算子をパターンとして指定するときには、次のように考えるとよい。

  • 単純に正規表現の「選択」を使う。パターンは長くなりがちであるが、確実である。
  • 文字クラス [ ] を使う。ただし、文字クラスは「文字」を指定するので、 <=== のような複数の記号で構成される演算子には使えない。 [(<=)] とグループを指定して書いても、(, <, =, ) と各文字に分解されて、そのいずれかに適合するパターンとなる。 よって、文字クラスと選択の組み合わせで表現する。
  • 一部に文字クラスを使って、部分的に共通する演算子をまとめる。 例えば、<, <=, >, >= は、 「先頭が < または > で、そのあとに = が 0個または1個続く」 と考えれば、[<>]=? と前半を文字クラスにした表現でまとめられる。

4.10 式文

4.10.1 解説

式のみで構成された文を「式文」と呼ぶ。 すなわち、式とその後に続くセミコロンで式文が構成される。 式はどのような式でも構わない。 一般的には代入式や関数呼び出し式を書くが、 a + b; のように代入も関数呼び出しも行わない式文もありえる。

式とセミコロンだけで式文を識別すると次の問題が生じる。

  • 宣言の一部が式文と誤って識別される。例えば、int a; という宣言に対し、 a; の部分が式文と識別される。
  • for文の条件部が式文と誤って識別される。例えば、for (i = 0; i < 0; i = i + 1) という頭部に対して、i = 0;i < 0; が式文と識別される。

これらの問題に対しては、(1) 誤って識別されたものを修正する書換えを行う、 または (2) 誤った識別が起こらないように事前に制限をかける書換えを行う、 のどちらで対処する。これらについては、宣言とfor文の識別のときに、 それぞれ対策を示す。ここでは、誤った識別が行われるが、対処は行わない。

4.10.2 文の識別の決めごと

文については、一律に B_STE_ST 型の字句で囲むものとする。 すなわち、式文や複合文といった種類は区別しない。 ただし、最初は一時的な字句として _B_ST_E_ST 型の字句で囲む (先頭のアンダスコアの有無に注意)。 式と同じく、文は、その内部に別の文を含む入れ子構造になりうる。 そこで、外側の文を識別したら、 内部の文は _B_ST_E_STB_STE_ST に置換えることにする。

なお、_B_STB_ST は見間違える可能性もあるので、 わかりにくければ、一時的な字句の型の方は、独自のものを導入してもよい。

4.10.3 名前付きグループ

式文を識別するときは、式とセミコロンの並びを _B_ST_E_ST 型の字句で囲む。 式文は入れ子にはならない、つまり、式文の中に式文が出現することはないので、 そのまま書換えルールを実現すればよい。直感的なイメージを次に示す。

 { 任意の式 空白 セミコロン } => { ('':_B_ST. 任意の式 空白 セミコロン '':_E_ST) }

ここで右辺では 任意の式 空白 セミコロン に対応する変数を参照する必要があり、 記述が長くなる。任意の式B_PE_P 型の字句に囲まれた任意の字句の並びであることにも注意する必要がある。 これらは変更されない字句なので、グループを用いてまとめて参照することで、 ルールを簡素化できる。グループを使うと次のようになる。

 { [est: 任意の式 空白 セミコロン ] } => { ('':_B_ST $est '':_E_ST) }

左辺の [est: から ] がグループであり、est はグループの名前である。 グループに含まれる字句は右辺において、そのグループ名の変数で参照できる。 この例の場合、右辺の $est が該当する。

この例のように、グループではグループ内の字句の並びまとめて1つの変数で参照するので、 左辺のグループ内の変数は右辺では参照されない。 よって、左辺では、例えば空白に対して $:@SP のように名前を省略できる。

グループを使うと、名前を考える必要性が減り、また、 右辺ではグループ名で参照することで、 変数の参照し忘れや順番の間違いといった誤りを犯す可能性も減る。 作業効率が高まるので、積極的に使用することを勧める。

4.10.4 演習: 式文の識別

  1. 式文を識別する書換えルールを追加せよ。ただし、名前付きグループを使用すること。 一時的な型名 _B_ST_E_ST を使うことに注意せよ。
  2. 複数の式文を書き、式文が正しく識別されることを確認せよ。
  3. 変数宣言に対して、変数とセミコロンを誤って式文と識別していることを確認せよ。

4.11 複合文

4.11.1 解説

複数の文を束ねたものを複合文と呼ぶ。 複合文の中には、複合文自体も含むことができる。

複合文は、複数の文を波括弧で囲んで表現する。 この演習のプログラミング言語では、波括弧は複合文にのみ使用するので、 波括弧の囲みを文として識別すればよい。 なお、この演習では扱わないが、 C言語の場合、波括弧は構造体の宣言や配列の初期値などに利用されるので、 どのような文脈の中に出現するかということを区別する必要がある。

4.11.2 演習: 複合文の識別

  1. 複合文を識別する書換えルールを追加せよ。追加にあたっては次のことに注意すること。

    • 識別した複合文は一時的な型名 _B_ST_E_ST を使う。
    • 再帰的な構造への対応には、「括弧による優先順位の変更 」 を参考にするとよい。
  2. 次の実行例と実行結果を参考に、正しく解析できているかを確認しなさい。

実行例:

echo '{{1; {2;}}}' | ./rw.pl

実行結果:

UNIT_BEGIN  <>
_B_ST #X0005    <>
C_L #B0001  <{>
_B_ST #X0006    <>
C_L #B0002  <{>
_B_ST #X0003    <>
B_P #X0001  <>
NUM <1>
E_P #X0001  <>
SC  <;>
_E_ST #X0003    <>
SP_B    < >
_B_ST #X0007    <>
C_L #B0003  <{>
_B_ST #X0004    <>
B_P #X0002  <>
NUM <2>
E_P #X0002  <>
SC  <;>
_E_ST #X0004    <>
C_R #B0003  <}>
_E_ST #X0007    <>
C_R #B0002  <}>
_E_ST #X0006    <>
C_R #B0001  <}>
_E_ST #X0005    <>
SP_NL   <\n>
UNIT_END

実行結果: [入れ子構造がわかりやすくなるように手作業でインデントをしたもの]

UNIT_BEGIN  <>
    _B_ST #X0005    <>
        C_L #B0001  <{>
            _B_ST #X0006    <>
                C_L #B0002  <{>
                    _B_ST #X0003    <>
                        B_P #X0001  <>
                            NUM <1>
                        E_P #X0001  <>
                        SC  <;>
                    _E_ST #X0003    <>
                    SP_B    < >
                    _B_ST #X0007    <>
                        C_L #B0003  <{>
                            _B_ST #X0004    <>
                                B_P #X0002  <>
                                    NUM <2>
                                E_P #X0002  <>
                                SC  <;>
                            _E_ST #X0004    <>
                        C_R #B0003  <}>
                    _E_ST #X0007    <>
                C_R #B0002  <}>
            _E_ST #X0006    <>
        C_R #B0001  <}>
    _E_ST #X0005    <>
    SP_NL   <\n>
UNIT_END
  1. 他の入力例を考えて、複合文が適切に識別されることを確認せよ。 複合文の内部には式文や複合文が含まれることを考慮すること。

4.12 制御文

この演習で扱う制御文には、if 文、while 文、for 文の3つがある。 この中で最も難しい if 文から取り組む。

4.12.1 if文

4.12.1.1 基本構造

まず、if 文の構文(つまり、構造)を確認する。 if 文は、次のように予約語 if の後ろに括弧で囲まれた式があり、そのあとに文が1つ続く。

 if ( 条件式 )

文は1つであり、if文そのものには波括弧は含まない。波括弧は、複合文の構成要素であり、 文を複合文にしたときに使用する。

条件式を囲む丸括弧は、式の構成要素ではない。 しかし、式の構文解析では一律に丸括弧を式として扱っているので、 ここまでの解析では ( 条件式 ) 全体が式となっている。 そこで、ここでは if文の構造を次のように捉える。

 if 式 文

条件式を囲む丸括弧は、後で式ではない形に補正する。 なお、この問題は他の制御文でも起こるので、 すべての制御文についてまとめて補正を行う。 なお、最初から式の構成要素ではない括弧として区別したい場合は、 式を解析する前に、この括弧を別の型名に置換えればよい。

if 文は 次のように予約語 else を用いて条件が偽のときに実行する文を記述できる。

 if 式 文1 else2

ここで、「節」という用語を次のように定義する。

  • 前半の if 式 文1 を「if 節」と呼ぶ。
  • 後半の else2 を「else 節」と呼ぶ。

if 文は、if 節のみのときと、else 節がつくときの2種類があるので、 構文解析は複雑になる。

4.12.1.2 else 節に関わる問題(1)

else 節はif文の構文に曖昧さをもたらす。 例えば、入れ子になっている次の if文を考える。

if (c1) if (c2) do1; else do2;

if 節は2つ、else 節は1つである。 この場合、else 節は 2 つの if 節のどちらに属すると解釈するかは、 この記述を見てもわからない。つまり、複合文を用いて表現したときに、 次の2通りが考えられる。

解釈1:

if (c1) {
    if (c2) do1; else do2;
}

解釈2:

if (c1) {
    if (c2) do1; 
} else {
    do2;
}

この曖昧さを解消するために、C言語では次のように解釈すると決まっている。

  • else 節は else 節を持たない直近の if 節と結合する

よって、正しい解釈は「解釈1」の方である。

4.12.1.3 else 節に関わる問題(2)

else 節の有無の違いがあることで、字句列の書換えによる構文解析では問題が生じる。 直感的には、次の順序の解析を繰り返したいと考える。

  1. else 節を持つ if 文を識別する。
  2. 残った if 節を if 文と識別する。

ところが、この方法では、次のような記述を正しく解析できない。

if (c1) do1(); else if (c2) do2();

else 節は予約語 else とそれに続く1つの文で構成されるので その予約語と文に着目すれば else 節を見つけることができる。 ところが、文が else 節を持たない if 文の場合、 上記のの順序で解析すると、else 節内の if 文が識別されないので、 else 節自体を見つけることができない。 結果として、if 節をすべて if文と識別し、else 節が if 文に含まれずに残る。 解析の順序を逆にしても解決にはならない。

これらの問題を解決するには、「else 節を持たない if 文」を識別する仕組みを持つ必要がある。 else 節を持たないということは、if 節の後ろに予約語 else が「存在しない」 ということを意味する。

全体としては、次の解析を繰り返すことで実現できる。

  1. if 節を識別する。
  2. else 節を識別する。
  3. if 節と else 節の並びを if 文と識別する。
  4. 後ろに else が存在しない if 節を if文と識別する

4.12.1.4 構文解析の手順

次の書換えを繰り返し適用することで解析を行う。

  1. if 式 文 の並びを _B_IF_E_IF 型の字句で囲み、 if 節として識別する。
  2. else 文 の並びを _B_EL_E_EL 型の字句で囲み、 else 節として識別する。
  3. 識別した if 節と else 節の並びを if 文として識別する。このとき、次の書換えを行う。
    • if 文の全体の範囲を _B_ST_E_ST 型の字句で囲む。
    • 節を囲む _B_IF_E_IF_B_EL_E_EL 型の字句は除去する。
    • 内部の文を囲む _B_ST_E_ST 型の字句は、 それぞれ B_STE_ST 型に変更する。
  4. if 節で、後ろに else が存在しない if 節を if 文として識別する。 このとき、次の書換えを行う。
    • if 文の全体の範囲を _B_ST_E_ST 型の字句で囲む。
    • 節を囲む _B_IF_E_IF 型の字句は除去する。
    • 内部の文を囲む _B_ST_E_ST 型の字句は、 それぞれ B_STE_ST 型に変更する。

ここで、「後ろに else が存在しない」 の書き方を説明する。 「存在しない」ことは否定を用いて、次のように表現する。

[end: [: $:RE_ELSE ]! | $:@ANY ]

部分的に説明していく。 まず [: ... ]! は、名前のないグループ [: ...] に否定演算子 ! をつけたものである。 これはグループ内の字句に適合したら、このルールの適用を中止することを意味する。 よって、この場合は $:RE_ELSE、すなわち、予約語 else があったらルールは適用されない。

次に [end: ... | ... ] は、end というグループの中に、 選択を表す演算子 | を記述している。 この演算子の前のパターンに適合しなければ、後ろのパターンでの適合を試みることを意味する。

$:@ANY は、すでに定義済みの型マクロ @ANY を用いており、 任意の1つの字句に適合する。

よって、全体を組み合わせると、次の意味になる。

  • 予約語 else に適合したら、ルールを適用を中止する。
  • そうでなければ、任意の字句と適合し、ルールの適用を継続する。

結果として、グループ end は「else ではない字句」を持つことになる。 必要なのは、「else が存在しない」であるが、それを表現できないので、 「else ではない字句」に適合しているという点に注意する必要がある。 つまり、書換え後の右辺では $end を入れて、適合した字句が消えないようにする必要がある。

4.12.2 演習: if文の識別

  1. if文を識別する書換えルールを書きなさい。
  • 制御文の入れ子に対応するために、全体は REPEAT {} で囲む必要がある。
  • ただし、最初は REPEAT なしで、単体の if 文の識別を実現し、 単体の if 文が識別できることが確認できたら、REPEAT で囲んで、 入れ子の if 文を識別できるようにすると、わかりやすい。
  • ここで導入する REPEAT の中には、 このあとに扱う他の制御文の解析処理も含める必要がある。 これは、他の制御文との組み合わせに対応するためである。
  1. 次の実行例と実行結果を参考に、正しく解析できているかを確認しなさい。

実行例:

echo 'if (c1) if (c2) do1; else do2; else if (c3) do3;' | ./rw.pl

実行結果:

UNIT_BEGIN  <>
_B_ST #X0020    <>
RE_IF   <if>
SP_B    < >
B_P #X0007  <>
P_L #B0001  <(>
B_P #X0001  <>
IDN <c1>
E_P #X0001  <>
P_R #B0001  <)>
E_P #X0007  <>
SP_B    < >
B_ST #X0016 <>
RE_IF   <if>
SP_B    < >
B_P #X0008  <>
P_L #B0002  <(>
B_P #X0002  <>
IDN <c2>
E_P #X0002  <>
P_R #B0002  <)>
E_P #X0008  <>
SP_B    < >
B_ST #X0010 <>
B_P #X0003  <>
IDN <do1>
E_P #X0003  <>
SC  <;>
E_ST #X0010 <>
SP_B    < >
RE_ELSE <else>
SP_B    < >
B_ST #X0011 <>
B_P #X0004  <>
IDN <do2>
E_P #X0004  <>
SC  <;>
E_ST #X0011 <>
E_ST #X0016 <>
SP_B    < >
RE_ELSE <else>
SP_B    < >
B_ST #X0017 <>
RE_IF   <if>
SP_B    < >
B_P #X0009  <>
P_L #B0003  <(>
B_P #X0005  <>
IDN <c3>
E_P #X0005  <>
P_R #B0003  <)>
E_P #X0009  <>
SP_B    < >
B_ST #X0012 <>
B_P #X0006  <>
IDN <do3>
E_P #X0006  <>
SC  <;>
E_ST #X0012 <>
E_ST #X0017 <>
_E_ST #X0020    <>
SP_NL   <\n>
UNIT_END    <>

実行結果: [入れ子構造がわかりやすくなるように手作業でインデント等をしたもの]

UNIT_BEGIN  <>
    _B_ST #X0020    <>

        RE_IF   <if>

        SP_B    < >
        B_P #X0007  <>
            P_L #B0001  <(>

                B_P #X0001  <>
                    IDN <c1>
                E_P #X0001  <>

            P_R #B0001  <)>
        E_P #X0007  <>

        SP_B    < >
        B_ST #X0016 <>

            RE_IF   <if>
            SP_B    < >

            B_P #X0008  <>
                P_L #B0002  <(>
                    B_P #X0002  <>
                        IDN <c2>
                    E_P #X0002  <>
                P_R #B0002  <)>
            E_P #X0008  <>

            SP_B    < >

            B_ST #X0010 <>
                B_P #X0003  <>
                    IDN <do1>
                E_P #X0003  <>
                SC  <;>
            E_ST #X0010 <>
            SP_B    < >

            RE_ELSE <else>

            SP_B    < >

            B_ST #X0011 <>
                B_P #X0004  <>
                    IDN <do2>
                E_P #X0004  <>
                SC  <;>
            E_ST #X0011 <>

        E_ST #X0016 <>
        SP_B    < >

        RE_ELSE <else>

        SP_B    < >
        B_ST #X0017 <>

            RE_IF   <if>

            SP_B    < >
            B_P #X0009  <>
                P_L #B0003  <(>
                    B_P #X0005  <>
                        IDN <c3>
                    E_P #X0005  <>
                P_R #B0003  <)>
            E_P #X0009  <>

            SP_B    < >

            B_ST #X0012 <>
                B_P #X0006  <>
                    IDN <do3>
                E_P #X0006  <>
                SC  <;>
            E_ST #X0012 <>

        E_ST #X0017 <>

    _E_ST #X0020    <>
    SP_NL   <\n>
UNIT_END    <>
  1. if文、複合文、式文を入れ子として含む例を複数作り、 正しく解析できることを確認せよ。

4.12.3 while文

まず、while 文の構文を確認する。while 文は次の構造をしている。

while ()

if 文の else がない状態と同じであり、if 文の解析の考え方を適用する。 条件式の扱いに注意する必要がある。if 文と同じく、丸括弧も式として識別されているので、 実際に書換えルールでとらえるべき構造は次の通りである。

while 式 文

4.12.4 演習: while文の識別

  1. while 文を識別する書換えルールを書きなさい。 if 文での識別で追加した REPEAT の中に書くこと。
  2. while文を含めた複数の種類の文を組み合わせた例を複数作り、 正しく解析できることを確認せよ。

4.12.5 for文

まず、for 文の構文を確認する。for 文は次のような構造をしている。

for (1 ;2 ;3)

構造としては、基本的には while 文と同じであるが、予約語が異なる。 さらに、括弧内には3つの式があり、セミコロンで区切られている。

これまで、すでに式文を識別しており、単純に式とセミコロンの組み合わせに基づいたので、 式1 ;式2 ; の部分は式文として識別され、 それぞれ _B_ST_E_ST 型の字句で囲まれている。 これは誤った解析であり、補正が必要となる。

for 文の条件に書かれるセミコロンは、式文のセミコロンとは役割が異なる。 そこで、二項演算子と単項演算子の区別と同じように、 for 文の条件内のセミコロンは別の型 FSC に変更する。 式文として識別されないためには、式文の識別より前に型の変更を行なう必要がある。

型変更の対象となるセミコロンは、for 文の条件の部分に存在するものだけである。 そこで、次の CONTEXT を用いてルールを記述する。

CONTEXT { 
  ... 文脈パターン ... 
}{ 
  ... 書換えルール ... 
}

この記述では、文脈パターンの部分には書換えルールを適用したい箇所に適合するパターンを書く。 ただし、適合箇所を絞り込むためには、正確に該当範囲だけを書くだけでは不十分で、 その前後の字句の並びもパターンに含める必要がある。 その代わり、書換えルールを適用したい範囲は単独の丸括弧で表現する。

ここで書くべき for 文の例を次に示す。

CONTEXT {

  $:RE_FOR $:@SP ($:B_P ( $:@ANY ) $:E_P) 

}{ 

  SC型 を FSC 型に変更するルール

}

文脈パターンとしては予約語からそのあとの条件部までを記述している。 この場合、$:@ANY には for 文の条件部の字句が適合する。 その $:@ANY の前後には空白を挟んで丸括弧が存在している。 この丸括弧は、ルールの適用範囲を意味する。 必ず空白を挟む必要があることに注意する必要がある。 すなわち、 ($:@ANY) とは記述できない。 この場合は、組となる字句を表すために付ける丸括弧に解釈されるからである。

式文を識別する前に、このセミコロンを FSC に変換するルールを適用しておけば、 while 文と同じ方法で for 文を識別できる。 パターンを工夫すれば、 while 文と for 文の識別は1つの書換えルールにまとめることも可能である。

4.12.6 演習: for文の解析

  1. 次の手順でfor 文のセミコロンを FSC 型に変更する書換えルールを追加しなさい。

    • 書換えルールを追加する前に、 for 文の2つの式とセミコロンが式文と識別されていることを確認しなさい。
    • 解説に従って書換えルールを追加しなさい。
    • for 文の2つの式とセミコロンが式文と識別されなくなったことを確認しなさい。
  2. for 文を識別する書換えルールを追加しなさい。

  3. 次の実行例と実行結果を参考に、正しく解析できているかを確認しなさい。

実行例:

echo 'for (i = 0; i < 10; i = i + 1) i;' | ./rw.pl

実行結果:

UNIT_BEGIN  <>
_B_ST #X0019    <>
RE_FOR  <for>
SP_B    < >
B_P #X0009  <>
P_L #B0001  <(>
B_P #X0016  <>
B_P #X0001  <>
IDN <i>
E_P #X0001  <>
SP_B    < >
OP  <=>
SP_B    < >
B_P #X0006  <>
NUM <0>
E_P #X0006  <>
E_P #X0016  <>
FSC <;>
SP_B    < >
B_P #X0013  <>
B_P #X0002  <>
IDN <i>
E_P #X0002  <>
SP_B    < >
OP  <<>
SP_B    < >
B_P #X0007  <>
NUM <10>
E_P #X0007  <>
E_P #X0013  <>
FSC <;>
SP_B    < >
B_P #X0017  <>
B_P #X0003  <>
IDN <i>
E_P #X0003  <>
SP_B    < >
OP  <=>
SP_B    < >
B_P #X0011  <>
B_P #X0004  <>
IDN <i>
E_P #X0004  <>
SP_B    < >
OP  <+>
SP_B    < >
B_P #X0008  <>
NUM <1>
E_P #X0008  <>
E_P #X0011  <>
E_P #X0017  <>
P_R #B0001  <)>
E_P #X0009  <>
SP_B    < >
B_ST #X0018 <>
B_P #X0005  <>
IDN <i>
E_P #X0005  <>
SC  <;>
E_ST #X0018 <>
_E_ST #X0019    <>
SP_NL   <\n>
UNIT_END    <>

実行結果: [入れ子構造がわかりやすくなるように手作業でインデント等をしたもの]

UNIT_BEGIN  <>
    _B_ST #X0019    <>
        RE_FOR  <for>
        SP_B    < >

        B_P #X0009  <>
            P_L #B0001  <(>

                B_P #X0016  <>
                    B_P #X0001  <>
                        IDN <i>
                    E_P #X0001  <>
                    SP_B    < >
                    OP  <=>
                    SP_B    < >
                    B_P #X0006  <>
                        NUM <0>
                    E_P #X0006  <>
                E_P #X0016  <>

                FSC <;>

                SP_B    < >
                B_P #X0013  <>
                    B_P #X0002  <>
                        IDN <i>
                    E_P #X0002  <>
                    SP_B    < >
                    OP  <<>
                    SP_B    < >
                    B_P #X0007  <>
                        NUM <10>
                    E_P #X0007  <>
                E_P #X0013  <>

                FSC <;>

                SP_B    < >
                B_P #X0017  <>
                    B_P #X0003  <>
                        IDN <i>
                    E_P #X0003  <>
                    SP_B    < >
                    OP  <=>
                    SP_B    < >
                    B_P #X0011  <>
                        B_P #X0004  <>
                            IDN <i>
                        E_P #X0004  <>
                        SP_B    < >
                        OP  <+>
                        SP_B    < >
                        B_P #X0008  <>
                            NUM <1>
                        E_P #X0008  <>
                    E_P #X0011  <>
                E_P #X0017  <>

            P_R #B0001  <)>
        E_P #X0009  <>

        SP_B    < >
        B_ST #X0018 <>
            B_P #X0005  <>
                IDN <i>
            E_P #X0005  <>
            SC  <;>
        E_ST #X0018 <>

    _E_ST #X0019    <>
    SP_NL   <\n>
UNIT_END    <>
  1. for 文を含めた複数の種類の文の組み合わでも正しく解析できることを確認せよ。

4.12.7 文の解析の完結

ここまでの処理を実現すると、解析結果は次のようになっている。

  • 文のうち、入れ子として他の文の内部に入っている文は B_STE_ST 型の字句で囲まれた状態になっている。
  • 入れ子関係における最外の文、または、内部に文を持たない単体の文は 一時的な型である _B_ST_E_ST 型の字句で囲まれた状態になる。
  • 制御文の条件式を囲む括弧は式と解析され、B_PE_P 型で囲まれている。

よって、式の解析と同じように、次の書換えを行う必要があり、 これにより文の完結する。

  • _B_ST_E_ST 型の字句を B_STE_ST 型に変換する。
  • 制御文の括弧を囲む B_PE_P 型の字句を削除する。

4.12.8 演習: 文の解析の完結

  1. _B_ST_E_ST 型の字句を B_STE_ST 型に変換する書換えルールを追加しなさい。

  2. 制御文の括弧を囲む B_PE_P 型の字句を削除する書換えルールを追加しなさい。 ただし、次のヒントを参考に、1つの書換えルールで記述しなさい。

    • いずれの制御文も 予約語 B_P 条件部 E_P 文 となっているので、 これを 予約語 条件部 文 となる書換えルールを記述する。
    • 空白が入る可能性がある位置に注意すること。
    • 3つの制御文の予約語は RE_IF, RE_WHILE, RE_FOR である。 いずれも先頭は予約語を意味する RE_ で始まるので、 これらをまとめた正規表現は RE_(IF|WHILE|FOR) となる。
    • 型の正規表現に適合する字句変数は $変数名:/正規表現/ と記述できる。
  3. 複数の種類の文の組み合わせた例を複数考え、正しく解析できることを確認しなさい。

4.13 変数宣言

4.13.1 解説

まず、変数宣言の構文を確認する。変数宣言は次のような構文を持つ。

型 変数 ;

ただし、ここまでの解析で、変数 ; の部分は式文と識別されている。 これは誤りであるので、式文にならないようにする必要がある。

誤った解析を修正するには、for 文のときと同じくセミコロンを別名にする方法もあるが、 ここでは別の方法を採用する。式文は単純に全体が B_STE_ST 型の字句で囲まれている。 よって、宣言を識別する前にこの字句を削除すればよい。

宣言の識別は次の字句の並びを見つけ、B_DEE_DE で囲む。

型 式 ;

最初の構文の説明と異なり、「変数」ではなく「式」と書いている点に注意する必要がある。 変数名は式の識別により、その変数を参照する式として識別されているからである。

式のままにしておくことは誤りと考えることもできるが、C言語を前提とすると、 実は式の方が正しい。例えば、次のようなポインタ型や配列型の変数の宣言は *[...] のような演算子(に相当するもの)がつく。 演算子を含めた構造があることを想定すると、式のままにしておく方が適切である。

int *ptr;
char str[128];

4.13.2 演習: 変数宣言の解析

  1. 適当な宣言を入力し、宣言の一部が式文になることを確認しなさい。

  2. 変数宣言を識別する書換えルールを追加しなさい。

  3. 次の実行例と実行結果を参考に、正しく解析できているかを確認しなさい。

実行例:

echo 'int i; char c; double d' | ./rw.pl

実行結果:

UNIT_BEGIN  <>
B_DE #X0006 <>
TYPE    <int>
SP_B    < >
B_P #X0001  <>
IDN <i>
E_P #X0001  <>
SC  <;>
E_DE #X0006 <>
SP_B    < >
B_DE #X0007 <>
TYPE    <char>
SP_B    < >
B_P #X0002  <>
IDN <c>
E_P #X0002  <>
SC  <;>
E_DE #X0007 <>
SP_B    < >
TYPE    <double>
SP_B    < >
B_P #X0003  <>
IDN <d>
E_P #X0003  <>
SP_NL   <\n>
UNIT_END    <>
  1. 文と宣言が混在したソースコードを正しく解析できることを確認せよ。

5 発展課題

5.1 演習: C言語のフルセットを目指す

この演習で作成した構文解析器を拡張して、 解析できるプログラミング言語をC言語のフルセットに近付けなさい。 拡張するべき主な構文要素は以下の通りである。

  • 空式
  • 関数定義と関数呼び出し
  • ポインタ型やポインタ型の変数宣言、ポインタ型の参照
  • 配列
  • 構造型, 共用型, 列挙型
  • キャスト
  • 複数の変数を含む宣言
  • ユーザ型定義(typedef) (難問)

5.2 演習: 前処理指令を含むソースコードの解析 (難問)

C言語は前処理を使用することが前提となっている。 一般に、前処理を行なったあとは構文解析可能であるが、 前処理前の状態では解析できない。 しかし、多少の不正確さを許容し、近似的な構文解析は可能である。

前処理指令を解析できるようにせよ。 また、前処理指令を空白の一種と考えて、 前処理指令が任意の位置(例えば、式の中)に出てきても解析できるようにせよ。

5.3 演習: ソースコードの変換

TEBA は、字句列を書換えるだけなので、ソースコードの変換も行える。 以下の変換を実現せよ。

  • for 文を while 文を用いた等価なコードに変換する。
  • 制御文が持つ文が複合文ではないときに、複合文にする。

その他、自分で変換を考えて実現せよ。

5.4 演習: JSON への変換

制御フロー解析やデータフロー解析をする場合、字句列のまま処理することは難しいので、 明示的に構文木を構成する方が望ましい。 Python や JavaScript など、他のスクリプト言語でも処理しやすいよう、 JSON の形式で構文木を出力する方法を考え、実装せよ。

文責: 吉田 敦
Contact: Yoshida Atsushi