コンパイラやリンカを作るのは難しいですし、かといってオープンソースのスクリプト言語を読んで理解するのも量が膨大すぎてちょっと…、という私は、1年ほど前にネット上からシンプルなスクリプト言語のソースコードを探しました。でも、これがなかなか見つかりません。ただ、興味本位でスクリプト言語の大まかな動作が知りたかっただけなのですが、ちょっとした解説なども見つかりませんでした。というわけで、今回はシンプルなスクリプト言語を作ってみることにします。
あと、あらかじめ断っておきますが、私はこういうプログラミング言語作成に関しては、かなり無知なので、ご了承ください。言語処理系の基本なんて全然学んでいません。ごめんなさい。サンプルも、行き当たりばったりでなんとかかんとか作成したプログラムです。申し訳ありません。
なので、本格的にこういうものを学びたい方はそれなりの専門書を読むなりしてください。ネット上にはあまり情報がなくとも、書籍ではすばらしいものが多数出版されていますし、オープンソースのプログラミング言語も多数あるので、とりあえず学ぶには申し分ないと思います。少なくとも、このテキストでは、本格的なものは学べないので(^^;。
ただ、そこまで本格的にやりたいとは思ってないけれど、スクリプト言語ってなんかちょっと気になるなぁ、くらいの方はきっとタメになると思います(タメにならなかったらすみません)。
さて、では今回取り扱うプログラムは以下です。gcc、VS.NET、BCCでコンパイルを確認しています。以下、ソースコードを見ながら、読み進めてください。
まずは、スクリプト言語の本当に大まかな流れを説明します。まず、最初に「ソースコードが書かれてあるファイルを開き」続いて「ファイル内容を読み込み」そして「ソースコードを解釈、実行」して「ファイルを閉じ」ます。えっと、本当に大まかな流れでしたが(笑)、これはmain関数の処理です。
それで、問題は「ソースコードを解釈し、実行する」部分です。まず「解釈」という部分に焦点を当てます。大抵のプログラミング言語のソースコードは、アルファベットと数字といくつかの特殊文字によって記述されるため、それらを適切に解釈する必要があります。では、具体的に解釈とはいったいどういうことでしょうか。
-----
for(i=0; str[i] != '\0'; i++)
str[i] = (char)tolower((int)str[i]);
-----
例えば、プログラミング言語に使用される単語は、大きく分けて「変数名」「数字」「文字列」「特殊文字」「予約語」といった分別を行うことが可能です。上のプログラムを例にとると、「for」は予約語、「(」は特殊文字、「i」は変数名で「=」は特殊文字、そして「0」は数字で、「;」は特殊文字、となります。なので、解釈する側としては、まず、それらを正確に認識する必要があります。
----- test.bs num = 105 PRINT "num = ", num, "\n" -----
上のプログラムを実行すると、スクリプトが一番最初に読み込む文字は「n」となります。これはアルファベットであるため、変数名であるかまたは予約語となります。続いて「u」「m」が読み込まれ、そしてスペースが読み込まれます。このスペースが読み込まれた時点で、この単語が終了であることが分かります。そして、この単語が「num」であり、登録されているすべての予約語と一致しないため、変数名であると解釈できます。というわけで、とりあえず、変数名「num」というものを作成します。
再びソースコードを読み込むと、今度は「=」です。これは特殊文字で確定なので、そのまま特殊文字として解釈します。
次はスペースなので読み飛ばし、その次は「1」という数字となります。これは数値で確定なのですが、数値としてどれだけの値であるかが分からないので、数字以外の文字が出現するまで、次の文字を読んでいきます。まず「0」が読まれ、続いて「5」が読まれます。そして改行(数字以外)が読まれることになるので、結果、「105」という単語であることが分かります。さて、これまでの一連の流れから「num」「=」「105」という3つの単語が一列になっていることが分かります。
次に改行を挟んで、「P」という文字が読み込まれます。これだけでは変数名か予約語か分からないので、とりあえず次の文字、その次の文字へと、どんどん読み進めて行きます。すると、スペースを見つけることで単語の終端と分かり、最終的に「PRINT」という予約語であることが分かります。PRINT文であることが分かったら、次は、文字列、変数名、区切りのための特殊文字、のいずれかとなりますので、ダブルクォーテーション「"」ならば文字列とし、「,」なら区切り、それ以外ならば変数名として解釈します。
このような単語の解釈を行う処理は次の関数で行われています。
----- kbasic.c int getlex( void ); // 次の単語を取得 void ungetlex( void ); // 取得している単語を戻す char GetChar( void ); // 1文字取得 int lex_ident( void ); // 変数or予約語を取得 int lex_const( void ); // 数値を取得 int lex_string( void ); // 文字列を取得 -----
基本的にgetlexとungetlexを使って単語の取得や返却を行います。これらは、簡単に言うと、getc関数やungetc関数の単語版だと言えます。この関数を使うことで、好きな時に単語の取り出しや返却が可能になります。また、単語のデータは構造体として定義しており、次のようになっています。
----- kbasic.c
typedef struct
{
/* 上記に定義しているデータを格納する変数 */
int type;
/* 予約語以外のデータの場合は、そのデータを入れる変数群 */
union{
int num; /* 数字 */
char ident[MAX_IDENT_LEN]; /* 変数名 or ラベル名 */
char string[MAX_STRING_LEN]; /* 文字列 */
char symbol; /* 特殊文字 */
}detail;
} LEX_T;
-----
typeには「予約語」「変数名」「数値」「文字列」といったどのタイプの単語であるかを識別する識別子が入り、そのタイプによって、次のunionで定義されている変数にデータが格納されます。
プログラム内では、この構造体はグローバル変数として定義しています。よって、getlex関数を呼び出したらこの構造体に単語データが格納され、ungetlex関数を呼び出したら、この構造体にある単語データがファイルへ(正確にはファイルではないが)戻ります。
以後、getlexとungetlexを利用して様々な関数を作成していきます。
プログラミング言語である以上、最低限、数式を計算できなければなりません。括弧つきの整数の四則演算を数式どおりに計算するためには、再帰下降構文解析を利用します。再帰下降構文解析というとなにやら難しそうですが、実際には大したことはありません。まず、最初に次のような演算を行う関数をそれぞれ作成します。
----- int expression( void ); // 入口(入口としての機能しか果たしていない) int AndOr( void ); // かつ、もしくは int NotFunc( void ); // 否定 int Compare( void ); // 等号、不等号 int AddSub( void ); // 加算、減算 int MulDiv( void ); // 乗算、除算 int Factor( void ); // 変数名、数値、括弧() -----
expression関数はただの入口ですので、それ自体には意味はありません。AndOrは「&&」や「||」を計算する関数で、NotFuncは否定「!」、Compareは等号や不等号「=<>」を計算する関数です。そして、加算や減算を行うのがAddSub関数で、乗算や除算を行うのがMulDiv関数です。最後のFactorは、変数名をその変数の値に変換したり、数値そのものを扱ったり、また括弧「()」を処理する関数です。
----- keisan.bs num = 5 * 4 * (3 + 4 - 5) + 1 PRINT "num = ", num, "\n" -----
さて、問題はここからです。上のソースコードを見てください。まずnumという変数名が定義され、その次の単語が「=」であることを解釈したら、続いてexpression関数が呼び出されます。expression関数内ではいきなりAndOr関数が呼び出され、AndOr関数内ではいきなりNotFunc関数が呼び出されます。そしてNotFunc関数内ではCompare関数が呼び出され、Compare関数内ではAddSub関数が…、といった具合に、次から次へと下の関数が呼び出されていきます。それで、とりあえずその調子でFactor関数まで呼び出されます。そして、Factor関数は「数値」を処理するので、結果「5」という数値を認識します。
Factor関数の処理が終わると、呼び出しもとのMulDiv関数へと処理が移ります。MulDiv関数は乗算と除算を処理するため、乗算命令である「*」を発見します。すると、再びFactor関数を呼び出します。再度呼び出されたFactor関数は、今度は「4」という数値を認識することになり、そして、またMulDiv関数へ戻ります。これで「5」と「4」という数値を認識したことになるので、乗算を行い「20」という演算結果が算出されます。
そして、次の単語を調べると、再び「*」となります。
-----
num = 5 * 4 * (3 + 4 - 5) + 1
------>
ここまで完了
-----
よって、またFactor関数を呼び出します。Factor関数内では、今度は「(」であるため、expression関数を呼び出します。この辺りは少しややこしいかもしれませんので、図にします。
-----
int expression( void );
int AndOr( void );
int NotFunc( void );
int Compare( void );
int AddSub( void );
int MulDiv( void ); ---> int Factor( void ); ---> int expression( void );
int Factor( void ); int AndOr( void );
int NotFunc( void );
int Compare( void );
int AddSub( void );
int MulDiv( void );
int Factor( void );
-----
MulDiv関数は次の値が知りたいので、再度Factor関数を呼び出しましたが、次の単語は括弧であったため、Factor関数がexpression関数を呼び出しました。2度目のexpression関数が呼び出されたため、再度、処理はFactor関数まで進みます。ただ、これは括弧内の話です。つまりFactor関数は「3」を認識し、次は「+」なので、AddSub関数まで上ります。そしてAddSub関数でまたFactor関数が呼び出され、4が認識されます。これで「7」が得られます。また次は「-」なのでFactor関数が呼び出され、「5」が認識され、AddSub関数に戻ってきたときに「7 - 5」で「2」が得られます。そして、次は「)」しかないので、このまま2度目のexpression関数は処理を終えます。
----- int expression( void ); int AndOr( void ); int NotFunc( void ); int Compare( void ); int AddSub( void ); int MulDiv( void ); ---> int Factor( void ); -----
よって、最終的にFactor関数が得られたデータは「2」となります。これがそのままMulDiv関数へ渡ることになります。MulDiv関数はすでに「20」というデータを持っており、さらにFactor関数から受け取った「2」を乗算して「40」というデータを持つことになります。これで次の図までの計算が行われたことになります。
-----
num = 5 * 4 * (3 + 4 - 5) + 1
------------------->
ここまで完了
-----
そして、次の単語は「+」であるためAddSub関数へ処理が行きます。同じようにして、AddSub関数はFactor関数を呼び出すので、「1」を取得し、それを「40」に加算して、結果的に「41」というデータが取得できます。これで計算の処理は終わりなので、そのままexpression関数は「41」というデータを返却することになります。
再帰下降構文解析は、優先順位の低い演算処理関数から順番に呼び出していき、また、括弧が見つかるごとに新しい演算処理を生成しながら、最終的な計算結果を得るアルゴリズムです。このような方法で、四則演算を正確に計算することができるというわけです。ここはアルゴリズムに慣れてない方は、少し難しく感じるかもしれませんが、再帰処理を理解していれば、それほど難解なものではないと思います。
変数は、「識別子」と「データ」を、それぞれが対応した状態で保存しておく必要があります。よって、今回は二分探索木のアルゴリズムを利用して変数を管理しました。変数の管理については次の関数で行っています。
----- kbasic.c int ValLabel( char *ident, int data, int flag ); VAL_LABEL *MakeValLabelTree( char *ident, int data ); void AddValLabelTree( VAL_LABEL *t, VAL_LABEL *n ); int MakeAddValLabelTree( VAL_LABEL *t, char *ident, int data ); VAL_LABEL *SearchValLabelTree( VAL_LABEL *t, char *ident ); void DeleteValLabelTree( VAL_LABEL *t ); -----
変数名を識別子として、二分探索木を作成します。変数管理のためのメモリ領域は動的に確保します。二分探索木のアルゴリズムは、かなり有名なもので、ネットで調べればすぐに発見できるので、詳細な説明は割愛させていただきます(アルゴリズムとデータ構造編 第17章 二分探索木 二分探索木[1] ― 初期設定、追加、検索)。
実際の実行を担っているのはInterpreter関数です。次のそれぞれの予約語に対して、処理を実行しています。
----- PRINT データを標準出力へ出力する INPUT データを標準入力から受け取る IF 条件が真なら以下のプログラムを実行 LABEL GOTO文のあて先を設定する GOTO LABELで設定されたあて先へ飛ぶ -----
----- kbasic.c(PRINT)
case _PRINT:
do{
/* 出力すべきデータを読み込む */
getlex();
switch( lex.type )
{
case TYPE_STR:
/* "Hello! World" というような
ダブルクォーテーションで囲まれた文字列だったら */
printf( "%s", lex.detail.string );
break;
case TYPE_IDENT:
case TYPE_NUM:
/* 変数名もしくは数字だったら */
ungetlex();
printf( "%d", expression() );
break;
default:
Error("PRINT文が正しくありません");
}
getlex();
}while( lex.type == TYPE_SYM && lex.detail.symbol == ',' );
/* ','だったら続く文字列を連結 */
/* バッファを吐きだす */
fflush( stdout );
/* 先読みした状態なので戻す */
ungetlex();
break;
-----
PRINT命令は文字列と変数の値を出力するだけです。よってまずは文字列であるか、変数値であるかを調べ、それに対応した処理を行っています。また、連結を行う「,」が発見されたら、引き続き出力を行うようにしています。
----- kbasic.c(INPUT)
case _INPUT:
/* 標準入力からデータを受け取る変数名を読み込む */
getlex();
if( lex.type == TYPE_IDENT ){
int num;
char buf[1024];
fgets( buf, sizeof(buf), stdin);
num = atoi(buf);
ValLabel( lex.detail.ident, num, VAL_FLAG_SEARCH_W );
}
/* 変数名じゃなければエラー */
else
Error("INPUT文が正しくありません");
break;
-----
INPUT命令は標準入力から変数へデータを受け取る処理を行います。fgetsで文字列を受け取り、atoiで数値に変換して保存します。
----- kbasic.c(GOTO)
case _GOTO:
/* 飛ぶべきラベル名を読み込む */
getlex();
/* ラベル名なら読みこみ */
if( lex.type == TYPE_IDENT ){
/* 実行ポインタを変更 */
int point = ValLabel( lex.detail.ident, -1, VAL_FLAG_SEARCH_R );
if( point == -1 )
Error("指定されたラベルがみつかりません");
pg.pt = point;
}
/* ラベル名でなければエラー */
else
Error("GOTO文が正しくありません");
break;
-----
GOTO命令は指定されたラベルへ実行ポインタを移動させます。
----- kbasic.c(IF)
case _IF:
if( expression() ){
/* 条件式の解析「真」ならばTHENの位置へ */
do{
getlex();
}while( lex.type != _THEN && lex.type != _EOF );
if( lex.type == _EOF )
Error("IFに対するTHENがありません");
}
else{
/* 条件式の解析「偽」ならばENDIFの位置へ */
int if_flag = 1;
do{
getlex();
if( lex.type == _IF )
if_flag++;
if( lex.type == _ENDIF )
if_flag--;
}while( (if_flag != 0 || lex.type != _ENDIF) && lex.type != _EOF );
if( lex.type == _EOF )
Error("IFに対応するENDIFがありません");
}
break;
-----
IF命令は条件分岐を行います。expression関数を呼び出し、条件式の真偽を確かめて、プログラムを実行させるかどうかを決めます。真ならTHENの位置へ、偽ならENDIFの位置まで移動します。
----- kbasic.c(変数名)
case TYPE_IDENT:
/* 変数名もしくはラベル名だったならば */
strcpy( ident, lex.detail.ident );
getlex();
if( lex.type == _EOF )
Error("予期しない終了がみつかりました");
if( lex.detail.symbol != '=' )
Error("代入文が正しくありません");
ValLabel( ident, expression(), VAL_FLAG_SEARCH_W );
break;
-----
何の予約語もなく、いきなり変数名が現れたならば、それは代入式なので、expressionを呼び出します。これで、変数に適切な値が代入されます。
では、最後にスクリプト言語の動作を確認します。次のプログラムを見てください。
----- label.bs PRINT "Start Number: " INPUT x PRINT "End Number: " INPUT y PRINT "Ans: ", x, " + ... + ", y, " = " z = 0 LABEL LOOP IF x > y THEN GOTO END_OF_LOOP ENDIF z = z + x x = x + 1 GOTO LOOP LABEL END_OF_LOOP PRINT z, "\n" -----
2つの数値を入力させ、1番目の数値から2番目の数値までの数をすべて足し合わせます。そして、その合計値を出力します。実行例を次に示します。
----- 出力例 $ ./kbasic label.bs Start Number: 1 End Number: 100 Ans: 1 + ... + 100 = 5050 $ -----
うまく動作していることが分かります。ココには他のサンプルプログラムも置いていますので、ぜひ試してみてください。
さて、いかがだったでしょうか。今回はこれまでとは少し趣向を変えて、スクリプト言語を作ってみようと題してお送りしました。といっても、取り扱ったプログラム自体は1年くらい前に書いたもので、1年前から書こう書こうと思いながら結局こんなに経ってしまった代物です(汗)。というのも、なんかスクリプト言語の作成って説明が難しくて、なかなか進まなくて…(だとしても1年はあんまりだけど)。
実行方法や、実際に動作するプログラムについてはココを参照してください。こんなものをスクリプト言語だというと、もしかしたら怒られるかもしれませんが、スクリプト言語の大まかな動作アルゴリズムは理解できたと思います。これから学ぼうという人にとってみれば、膨大な量のソースコードというのはやる気を奪うので(笑)、1000行足らずのソースコードで動作するスクリプト言語を作成してみました。
さて、最後になりましたが、ここまで読んでくれて本当にありがとうございます。
では、また会う日まで...