Compiler Construction Lecture No.5

Menu Menu


部分評価としてのコンパイラ

しかし、このコンパイラ信じられないほど馬鹿である。1+1 の計算も、まじめに実行時に行う。このような計算は実は、コンパイル時におこなってしまって構わない。では、どこまでコンパイル時に計算できるのだろうか?

このような考え方は、部分評価(partial evaluation) と呼ばれる。これを、プログラムからプログラムを計算するプログラム変換、α(アルファ)を使って以下のように記述する。

        P'(Y)(X) = α(P(X,Y),X)   P(X,Y)のうち、Xの値を定数として可能な部分を先に計算する。

αはプログラムと入力変数の指定の2つの引き数を持つ。αは、Pと入力変数の値を受け取り、プログラムP'を出力する。P'は、Xの値を指定すると、それに対応するプログラムを返すプログラムである。P'を、入力の一部を前もって計算するので、部分計算と呼ぶ。

        P''(Y) = P'(Y)(5) = α(P(X,Y),X)(5) 

とすると、X=5を仮定して計算を進めたプログラムを意味する。このαを二村射影(Futamura projection)と呼んでいる。例えば、i(P)(X)で、入力Xを持つプログラムP(X)をインタプリタiが実行することを表すことにしよう。ここでPは、Xを入力として持つ任意のプログラムを表す変数である。

これに、部分計算αを使って、

        α(i(P),P)     入力X以外の部分Pを前もって実行する。

とする。c(X)(p)は、pの字句解析やら構文解析やらの部分を前もって計算したプログラムである。これをコンパイルと考えることもできる。つまりコンパイラc(P)は、
        c = α(i(P),P)

となる。また、
        y(P) = α(I(P),I)

は、コンパイラコンパイラを表す。あるインタプリタiが与えられると、y(P)(i) は、Pをコンパイルするコンパイラ c(P) = α(i(P),P)(P) を返す。


さらに良いコードを出すためには

ここでは、少しだけ部分計算をおこなう努力をしてみよう。この程度でも、今まで行ってきた方法で実現することは難しい。何故なら、今までは、構文解析を行ったその場でコード生成を行っていたからである。計算できることがわかった時には、既にコードを生成してしまっているので、手遅れなわけだ。

このためには、どうしても、一旦、プログラムを表す構造を作る必要がある。その構造を作る時、あるいは、作った後で解析して、より良いコード生成を行う。この構造は、もちろん、構文木である。コンパイラによって、単なる構文木ではなく、より複雑な構造を採用することもある。気にすると複雑になりやすいので、一旦、疑似コードを生成して、その後に、その最適化をおこなう方法もある。構文木自身も、post fix code (後置コード)と呼ばれる疑似コードとみなすこともできる。


中間木の生成

木を生成するのは、計算するのとほとんど同じで、下位の構文要素の木を下位の構文解析の関数から取りだし、上位に新しい木のノードをつけ加えて送れば良い。木のノードは例えば、

    #define NEW(type) ((type *)malloc(sizeof(type)))
    typedef struct node {
        struct node *left;
        struct node *right;
        int type;
        int value;
    } node, *node_ptr;

などのように表すことができる。これを生成するには、

    node_ptr
    new_node(int type,int value,node_ptr left,node_ptr right) 
    {
        node *d;
        // d =  (node *)malloc(sizeof(node));
	d = NEW(node);
        d->type = type;
        d->value = value;
        d->left = left;
        d->right = right;
        return d;
    }

などとすれば良い。ここでtypeとvalueは、字句解析が返した値をそのまま使う。例えば、aexpr では、以下のように木を生成する。

    node *
    aexpr()
    {
        node *d;
        d = mexpr();
        while(last_token!=EOF) {
            switch(last_token) {
            case '-': 
                d = new_node('-',0,d,mexpr());
                break;
            case '+': 
                d = new_node('+',0,d,mexpr());
                break;
            default:
                return d;
            }
        }
    }

この時、new_node()は、mexpr()が二つ呼ばれてから呼ばれることに注意しよう。インタプリタと違ってdは、値ではなく木である。

new_node() では、mexpr()の値が分かった段階で呼ばれるのだから、もし、計算可能ならば、その場で計算をおこなうことが望ましい。

    node *
    new_node(type,value,left,right) 
    int type;
    int value;
    node *left;
    node *right;
    {
        node *d;
        if ((left  &&  left-> type =='0') &&
            (right && right-> type =='0')) {
            switch(type) {
            case '>':
                right->value = (left->value > right->value); 
                free(left); return right;
                break;
            case '+':
                right->value = left->value + right->value; 
                free(left); return right;
                break;
           ...
            }
        }
        d =  (node *)malloc(sizeof(node));
        d->type = type;
        d->value = value;
        d->left = left;
        d->right = right;
        return d;
    }

などとすれば良い。 s-tree-compile.c しかし、これで十分だろうか?

実際には、式の計算順序は、+, * などによって変更することができる。この変更によって定数として計算できる範囲が広がることがある。これは、定数を木の葉の方に移動させることによって実現することができる。あるいは、木を使って、共通の式を2度計算しているかどうかを調べることができる。(問:このようなアルゴリズムを考察してみよ)

実際のコンパイラでは、浮動小数点(floating point)と整数(integer)が混在することもあり、安易に計算順序を変更すると、かえって浮動小数演算が増えたり、誤差が大きくなったりすることがある。気を付けて計算する必要がある。


中間木からのコード生成

一旦、中間木ができてしまえば、コード生成は容易である。また、引き算の場合の引き数の順序の制御も楽になる。

    code_generate(d)
    node *d;
    {
        int assign;
        switch(d->type) {
        case '0':
            emit_value(d->value);
            return;
        case 'v':
            emit_load(d->value);
            return;
            ....
        default:   /* calculation */
            code_generate(d->right);
            emit_push();
            code_generate(d->left);
            switch(d->type) {
            case '+': emit_calc(O_ADD); break;
            case '-': emit_calc(O_SUB_R); break;
            case '/': emit_calc(O_DIV_R); break;
            case '*': emit_calc(O_MUL); break;
            default:
                error("Internal Error, unknown opecode");
            }
            return;
        }
    }

となる。中間木を順序よくたどりながらコードを生成する。ただし、この方法では再帰呼び出しが多用されるので、それに対する工夫が必要となる場合もある。

実際のコンパイラでは、中間木は式だけなくて、手続き内のすべての文に対して生成することが多い。この時、case文のようなものは、超巨大になることがあるので、中間木も巨大になってしまう。この時に安易な木の扱いを行うと、非常に遅いコンパイラになってしまうことがある。


問題5.3

上の構文木の生成では、malloc/free を繰り返している。これは、一般的には効率が悪い方法とされている。前もって、いくつかまとめてnodeをmallocし、それを再利用する方法を考案し、実装せよ。

実際には、式の計算順序は、+, * などでは変更することができる。この変更によって定数として計算できる範囲が広がることがある。これは、定数を木の葉の方に移動させることによって実現することができる。あるいは、木を使って、共通の式を2度計算しているかどうかを調べることができる。このようなアルゴリズムを考察し、例題のコンパイラを変更して実装せよ。


問題5.4

s-code-intel-r.c を使った、s-intel について、論理演算 &,| を拡張して見よう。余裕があれば他の拡張にも挑戦して見よう。


課題

関数呼び出しの実現

変数のスコープ

関数呼び出しのコード生成

レジスタのセーブ

中間木を使った部分評価

バイトコードの設計

バイトコードの生成と実行

バイトコードからのコンパイル


Shinji KONO / Fri Oct 18 18:24:25 2013