トップ

Knuth's GPS に関すること

1. 名前呼びと Jensen's Device

はじめに

Algol 60 という初期のプログラミング言語に備わっていた言語機能で、そのややこしさから後のプログラミング言語にはほとんど引き継がれなかった機能に「名前呼び」(call by name)がある。ラムダ計算の簡約において最外戦略のことを指すそれと似たようなものではあるが、代入などの副作用がある一般的なプログラミング言語における名前呼びには独特のややこしさがある。

「引き継がれなかった」と書いたが、実際には参照呼び(call by reference)と第一級の手続き(first class procedure)にリファクタリングされて引き継がれたと見ることもできる。後述する。

名前呼びとはどんなものか

関数の呼び出しにおいて、実引数として書いた式が、常に呼び出し元の環境にあるかのごとく評価される、というのが名前呼びである。架空の言語による擬似コードで具体例を示す。

def hoge(by_name x:int, by_name cond:bool) {
    while cond {
        println(x)
        set x := x + 1
    }
}

def main() {
    set a := 5
    hoge(a, a < 10)
    println("---")
    println(a)
}

これを実行すると、次のように表示される。

5
6
7
8
9
---
10

マクロのようなものか、と思われるかもしれない。同じような動作をする、という意味では正しいが、あくまで実行される言語の機能であるという点が、実行前にプログラムを変形するものであるマクロとは異なる。例えば、自分自身を再帰的に展開するマクロは、展開を続ける条件が静的に決まらなければ展開が止まらず、実行前にエラーになるが、名前呼びの手続きは再帰していても問題ない。

Jensen's Device

この名前呼びの機能と副作用を利用したトリッキーなコードを Jensen's Device と言う。Wikipedia の記事( http://en.wikipedia.org/wiki/Jensen's_Device )などを参照のこと。名前はデンマーク人のプログラマ Jørn Jensen氏( http://en.wikipedia.org/wiki/Jørn_Jensen )にちなむ。

2. Knuth's GPS

Knuthらが名前呼びを利用してこんなことが可能である、というコードとして示したコードが、Knuth の GPS(General Problem Solver)である。

和田先生のブログ「パラメトロン計算機」に、どうやって動くのかの解説があるので参照されたい( http://parametron.blogspot.jp/2008/02/gps.html )。

以下、これを実際に動かしてみるまで、という説明をする。

まず、Algol 60 の処理系だが、OKIソフトウェア エンジニアリングソリューションセンタの資料室というウェブページ( http://www.oki-osk.jp/esc/whitepaper.html )に、「PLY (Python Lex-Yacc) で作る Algol 60 処理系」という資料があるので、そこで作成されている Algol 60 処理系を使わさせていただくことにする。

PLY を利用した言語の実装の解説が主眼のページであるので、Algol を使いたいだけの人に向けた説明はないが、現在のところ第四部のページ( http://www.oki-osk.jp/esc/ply-algol/part4.html )の最初のところにある algol60-0.7.tar.bz2 という tarball が最新のようなので、それをダウンロードする。

PLY というライブラリを使っているが、それも同梱されているので、OS には Python がインストールされていればよい( Windows では試していない)。システムにインストールすることもできるが、プログラムの動作を試すだけであれば、展開するだけでよい。

次に、実際に動かすプログラムのソースコードを示す。Algol には「reference representation」と「hardware representation」があり、書籍などで目にする Algol コードや、前出の和田先生のブログのソースコードは reference representation ベースであり、そのままでは動かすことができない。ここで利用する Algol 処理系の hardware representation に書き直す必要がある。以下のようになった。

begin
    integer procedure mod(x, m); value x, m; integer x, m;
        mod := x - x div m * m;

    integer procedure gps(i, n, z, v); integer i, n, z, v;
        begin for i := 1 step 1 until n do z := v; gps := 1 end;

    integer m, i, p, a, z;

    m := 46;

    i := gps(i, if i = 0 then -1 else i, p,
        if i = 1 then 1 else
        if gps(a, i, z,
            if a = 1 then 1 else
                if mod(i, a) = 0 and a < i
                    then 0 else z) = z
        then (if p < m then p + 1
              else i * gps(a, 1, i, -1))
        else p);

    outinteger(1, p)
end

ここで、hardware representation への書き換え以外にも、いくつか修正を加えているので、それについて説明する。

1. 実数型から整数型へ

元のコードでは引数が実数型になっており、GPS を呼び出す手続き中にも整数と 1.0 のような実数の記述が混在していたが、この、m 番目の素数を求める計算では全て整数で良いので整数にした(内積を求める場合などには実数型が要る場合もある)。

2. mod 関数のくくり出し

元のコードでは entier(a)×(entier(i)÷(entier(a)) = entier(i) という式で行っていた、i が a で割り切れるかどうかの計算は、剰余を求める mod という関数をくくり出して mod(i, a) = 0 という式とした。

3. 最後の gps の呼び出しの最後の引数を 0 から -1 に

元のコードでは gps(a, 1.0, i, 0.0) となっているのを gps(a, 1, i, -1) とした。こうしておかないと、ここで使用する Algol 処理系では停止しない。原因は for 文の解釈の違いによるもので、C言語の for 文と同じような順序、すなわち、ループの 1 回のイテレーションの後、先にループ変数の増分による更新があり、その後でループを抜けるか否かの判定が行われる場合は、ここが -1 になっていなければならない。ループ変数の増分による更新よりも先にループを抜けるか否かの判定がある場合には、ここが 0 となる。

他の文献(共立『アルゴリズム辞典』 p. 17)でも -1.0 としているものが見られる一方、確かに初出( CACM の記事(1961)。 http://cseweb.ucsd.edu/~goguen/courses/230w02/GPS.html に当該部分の引用がある)では 0.0 となっている。

"Revised Report on the Algorithmic Language Algol 60" (RRA60 / 1963) を見ると次のような解説があるので、それに従う限りでは 0 とするのはバグで -1 とするのが正しい。

Knuth先生によるバグという確率は限りなく0に近いだろうので、記事と仕様の初出の前後順などから考えて、仕様が曖昧か未定義であったか、変化したものと思われる。

インデントの付け方や前述の mod についても『アルゴリズム辞典』のリストを参考にした。

ここでは m に 46 を指定しているので、46 番目の素数である 199 が表示される。実際のコマンドラインでの例は次のようなものである。

$ ../algol60-0.7/bin/algol60 gps.algol
199

余談

このコードについて、Knuth先生は、チューリング賞講演 "Computer Programming as an Art" でも言及しており「私はかつて,内積をとる手続きを,きわめて普通でない方法で呼びだし,その結果その手続きは内積ではなくて,m 番目の素数を計算するという,ひとつの文だけからなる ALGOL プログラムを書いて,おおいに悦にいったことがあります」(有澤誠訳。『ACMチューリング賞講演集』 p. 57)と述べている。

GPS の普通の(?)使い方である内積をとるコードとは次のような感じである(工夫すれば z の初期化も別の gps の呼び出しにして、1 文にできるが、ここではやらない。結果は z に得られる)。

z := 0;
gps(i, n, z, z + a[i]*b[i])

原文献のスキャンを入手したので、GPSと素数探索のリストの部分をそのまま引用シテオク

-

(なお、最後のピリオドはプログラム自体のものではなく、文献中の文章としてのものであると思われる。)

3. 色々な言語での実現

こんにちそれなりに流行っているプログラミング言語で、Algol そのままの名前呼びがある言語はまずないと言ってよい(たとえば Scala には名前呼びがあるが、名前呼びの引数は代入のできない val の変数にしかできないので、GPS などをそのままもってくることはできない)。

しかし、前述の沖(略)の Algol 60 処理系の実装を解説したページを読まれた方は既にお分かりと思うが、こんにちのプログラミング言語の知識で考えるならば、名前呼びで式を渡すのはクロージャを渡しているものとして扱うことができる。また、GPS の引数 i や z は、単に代入されるためだけに変数を渡すもので、式を渡すことはないから名前渡しの必要はなく、参照呼び(あるいは Pascal の変数引数など)があれば用は足りる。

参照呼びは、1 要素の配列を使うなどしてシミュレート可能なので、こんにちの第一級の手続きがあるプログラミング言語であれば、GPS と、GPS を使って素数を求めるプログラムは書ける、ということになる。

具体的に書いてみた結果は別のページ( various.html )として置いてある。いろいろ書いてみた結果として、いくつかの言語の変わった制限や、元のプログラムの微妙な点があきらかになった。人によってはまさかと思われるかもしれないが、Haskell でも書いてある(もっとも普通に書いた Haskell とはちょっと言い難いが)。

微妙な点

式(文ではなく、式)と副作用とが渾然とした手続き型プログラミングで、評価順が問題となるのは次のような点である。

GPS を利用して m 番目の素数を求めるコードの場合、関数を呼び出す時点での引数の評価による副作用はないので、問題は演算子の左辺と右辺である。

私が確認した微妙な点は以下の通り。

1. 最後の i * gps(...)

                    then 0.0 else z) = z
        then (if p < m then p + 1
              else i * gps(a, 1, i, -1))
        else p);

和田先生の解説でも「問題は最後の乗算でiを先に評価すればよいが, gpsを先に評価すると大切なiの値が変わっていることだ. 」とあるように、ここでは gps の評価よりも前に、i の値を得ておかないといけない。逆だと p に結果を得ることができず、-1 になってしまう。

「まだ他にも微妙な点はあるが」と和田先生も書かれている通り、もう一つある。

2. 中央部の gps(...)z の比較

        if gps(a, i, z,
            if a = 1 then 1 else
                if mod(i, a) = 0 and a < i
                    then 0.0 else z) = z

ここでは、先に gps(...) の評価を行い、そのあとで z を評価して比較しないといけない。逆だと p に得られる結果が m 番目の素数 + 1 になってしまう。

個々の言語に付けた解説でも述べるが、C 言語のように規格に従うと未定義であるといった場合でも、実験に使用した処理系で正しい結果が得られていれば良しとした。

4. GPS が万能であることの説明

http://cseweb.ucsd.edu/~goguen/courses/230w02/GPS.html を読んだ限りでは、初出文献においても General Problem Solver という、代入文 1 文のループだけ、という手続きに付けるには少々大仰な名前の正当化については、別の文献へのポインタのみのようである。参照先の文献は Google Books でちょっとだけなら見ることができるのだが( http://books.google.co.jp/books?id=TMLgEIOGhBMC )、プログラマ向けではなく数学的な計算可能性の本のようである。また、サイモンとニューウェルによる AI プログラムの GPS(ウィキペディアの「General Problem Solver」)も意識しているかもしれない。

しかし、厳密な証明ならともかく、GPS が任意の計算を実行可能であるという直感的説明は難しくないと考える。以下のような説明で良いはずである。

まず、GPS の引数 i と z の型を任意長の整数とする(数学的な実数にしても良いでしょうが、悪趣味でしょう)。n と v を評価して返る値についても同様。

その上で(ここでは C言語風に書く)、

for (i := 1; i ≦ n(); ++i) {
    z := v();
}

というループをじっと眺めると、i を仮想計算機のプログラムカウンタ、z を仮想計算機のレジスタファイル(及び全メモリ)、v を仮想計算機の命令、として、ものすごく単純な仮想計算機のインタプリタであるように見えてこないだろうか(名前渡しのために、i や z に n や v からアクセスできることに留意)?

具体的に v に与えるべき関数は次のようなものである。

v() {
    switch (i) {
    case 1:
        return 次の z の値を計算する式その 1 ;
    case 2:
        return 次の z の値を計算する式その 2 ;
    case 3:
        return 次の z の値を計算する式その 3 ;
    case 4:
        return 次の z の値を計算する式その 4 ;
    case 5:
        return 次の z の値を計算する式その 5 ;
    case 6:
        return 次の z の値を計算する式その 6 ;
      :
      :
      :
    }
}

このようにして、任意のプログラム(たとえば、チューリングマシンのプログラム)を実行する計算機を、GPS でシミュレートが可能であるので、GPS は万能である。という説明で良いはずと思われる。

Knuth先生の「goto 文を用いた構造的プログラミング」の中で goto の除去について述べているところで、このように、1 個のループとその中の巨大な switch 文(書かれた時代には C 言語はまだ一般的でないので、実際には else if のカタマリで、詳細には少し形が異なる)に書き換えることにより、任意のプログラムを goto 文の無い形に書き換えることができることを論じている。もちろん、「これですべての goto 文を除去できたわけであるが,実際にはすべての構造を失ってしまっている.」(有澤誠訳『文芸的プログラミング』 p. 64)とあるように、かつてしばしば勘違いして語られた「全てのプログラムは構造化により goto 無しで書くことができる」という主張の際に、その裏付けということにされていた理論が、実際には意味のないものであることを示すために使われている。