<プログラミング特論 for 慶應義塾大学理工学研究科開放環境科学専攻 M1 in 2002>

lecture 3

命令型 (imperative) 言語と作用型 (applicative) 言語

von Neumann 型コンピュータが、計算機の基本アーキテクチャとして かくも長い間主流であり続けたことは一つの不思議である。 多くの計算機アーキテクチャ研究者は、1970年代、80年代の計算機普及拡大期に そう考えた。そしていくつもの新しいアーキテクチャを提案し、実装してきた。 例えば、関数型言語を効率的に実装するために考えられた、データフロー型計算機は代表的である。画像処理を高速で行うためのデータフーロー型 mpu も作成された。 ICOT では論理型言語の計算機を作成した。 しかし、様々な理由により主流にはなりえなかった (どころか商用化されたものは殆どなかった)。 最大の理由は、経済原理である。

  • von Neumann 型自体も様々な変化を遂げ、結局、安価かつ高速な cpu となった。そのため、関数型や論理型でさえエミュレーションすれば、十分な速度で動くようになった。また、コンパイラ技術も発達して効率のよいオブジェクトが生成できるようになった。
  • OS をも含め、全てのシステムを (例えば) 関数型言語で開発することは、 結局できなかった (ICOTを除く。全体をとにかく論理型言語で作成し、OS、window システムからアプリケーションまで作成してしまったこのプロジェクトは、従って、 極めて稀有な成果を残したことになる)。
  • 基本原理は、von Neumann 計算機アーキテクチャと命令型言語が理解しやすい。
  • 注: von Neumann 型コンピュータ
    現在の計算機アーキテクチャの原型は、数学者 Jon von Neumann によって考えられたもので、演算装置と記憶装置という二つの主要部分からなる。 記憶装置は (今では byte であるが、昔は) word と呼ぶ単位から構成され、 その一つ一つに順番に番地が振られる。 記憶装置には、演算装置内で実行される様々な動作 (四則演算 等) の記述、 演算の対象となるデータの番地、データそのもの等が記憶されている。 最も基本的な演算装置の動作は、(1) program counter の値を記憶装置の番地と考えて、 その内容を取り出し、命令を解読し、program counter を適宜増加し、 (2) その命令を実行する、ことである。 命令の中には、次のようなものがある。

    • ある番地の内容を取り出し、演算装置内のレジスタに記憶し、または、 レジスタ内の値と演算を行う。
    • レジスタ間の演算を行う。
    • レジスタの値を記憶装置のある番地に格納する。
    • レジスタの状態に従って、program counter の値を書き変える(条件付き又は無条件ジャンプ)

    すなわち、計算機による計算は、記憶装置 (と演算装置内の記憶装置) に記憶されて いデータが構成する ``状態'' を少しづつ変化させるという、基本操作 (``命令'') の繰り返しによって実行される。 このような計算機のモデルが von Neumann 型計算機であり、 このような計算のモデルに従う言語を命令型言語 (imperative language) と呼ぶ。
    計算の過程を、計算機内の状態と変化と捕える点において、このモデルは、 Turing machine に最も近い。

    注: 作用型言語
    「計算」を定義する代表的な3種の方法は、Turing machine, recursive function, そして lambda calculus である。後2者の理論的枠組に基づき、 実用的な ``言語'' としたものが作用型言語 (又は関数型言語) である。 すなわち、関数の定義とその引数への作用 (application, 通常は、適用と訳す ことの方が多い) に基づく言語である。 作用型言語 (関数型言語) においては、

    • 高階 (higher order) 関数、すなわち、関数自体を引数とする関数が 自然に扱える。
    • 関数の評価中に副作用がない。この結果、並列化が容易になる。
    • データの構造に関わらず、データ全体を一つの値として扱える。 命令型言語では、構造を持ったデータに対しては、よくその先頭への reference を 値として扱う。
    • 数学的取り扱いが可能である

    といった特徴がある。 しかし、抽象的な思考能力を要求されるため、学習の最初のバリアが高い、 業務プログラムやGUI まで含めた開発環境のサポートがない、 等の欠点があり、なかなか一般には普及しないのが現状である。

    注: Turing machine
    「計算」を定義する代表的な方法の一つ。数学者である Alan Turing が1936年に発表した 「抽象計算機械」を以来彼の名を冠して、こう呼ぶようになった。 それは informal には次の様に定義される。 Turing machine は
    • 左右両方向に無限に伸びているテープと、制御部とからなり、
    • テープは、桝目に分けられていて、各桝目には、一つだけの記号を書くことができ、
    • 制御部には、桝目の記号を読み取ったり書き換えたりするためのヘッドがついていて、
    • ヘッドは、更に、一時点においては一つの桝目だけを眺めるkとができ、かつ、 眺めている桝目の一を一つだけ左か右に移動させることができる
    ものであり、制御部の状態と、ヘッドが現在眺めている記号とに応じて、 三つの行動
    • ヘッドが眺めている桝目に、ある記号を書き込む(元の記号と同じでもよい)
    • 制御部の状態を新しい状態にする(元の状態と同じでもよい)
    • ヘッドを一桝だけ左または右に動かす
    を、一つのステップとして実行することができるものである。

    注: 万能 Turing machine
    上記の Turing machine の定義を文字通り読むと、制御部がちょうどその昔の ENIAC の本体であり、プラグインボード部がその状態遷移図に相当すると考えられてします。 しかし、この定義はそれほど非力なものではない。次の性質を持つ万能 Turing machine とよばれるものを作ることが出来る。
    • 任意の Turing machine が与えられた時、 それをある予め定められた方法で記号にしてテープ上にかけば、
    • その Turing machine に対する任意の開始テープ状態に対する動作と全く同一の動作 をする(停止する/停止しない、最後のテープ状態が同一)
    即ち、テープは現在の計算機の記憶装置を抽象化したものと、制御部は現在の計算機のCPUを抽象化したものと 考えることができる。

    注: 帰納的部分関数 (partial recursive function)
    計算のもう一つの定義方法は、帰納的部分関数によるものである。 帰納的部分関数とは、次の様にして定義されるものである。
    • 関数 f(x1,...,xn)=k は帰納的部分関数である。 但し、k はある自然数である。
    • 関数 f(x)=S(x) は帰納的部分関数である。 但し、 S(x) は自然数 x の次の自然数を指定する関数である。 S(x)=x+1 と考えてよい。
    • Uni(x1,...,xn)=xi は帰納的部分関数である。
    • g(x1,...,xm), h1(x1,...,xn),..., hm(x1,...,xn) がすべて帰納的部分関数であれば、 g(h1(x1,...,xn),...,hm(x1,...,xn)) も帰納的部分関数である。
    • h(x1,x2) が帰納的部分関数で、k が自然数の時、
      • f(0)=k
      • f(S(x))=h(f(x),x)
      によって定義される関数 f(x) も帰納的部分関数である。
    • g(x1,...,xn), h(x1,...,xn+2) が帰納的部分関数であれば、
      • f(0,x1,...,xn)=g(x1,...,xn)
      • f(S(x),x1,...,xn)=h(f(x,x1,...,xn),x,x1,...,xn)
      によって定義される関数 f(x,x1,...,xn) も帰納的部分関数である。
    • 述語 P(x1,...,xn,y) は最小化演算子μを 施すことができる形の述語であって、 P を構成している全ての関数が帰納的部分関数であれば、
      • f(x,x1,...,xn) = μ y (P(x1,...,xn,y))
      は帰納的部分関数である。なお、最小化演算子 μ は述語に対して適用され、 例えば、 μ y (P(x1,...,xn,y)) は、 もし、 P(x1,...,xn,y) を成立させるような y があればそのような最小の y を値とし、 P を成立させるような y が存在しなければ、未定義とするようなものである。
    • 以上で帰納的部分関数であると判定できるもののみが帰納的部分関数である。

    注: λ計算(λ-calculus)
    λ-calculus は Church が提唱したもう一つの計算の定義である。λ式とその簡約規則からなる。 λ式はある時点でのプログラムとデータの組を表す。β変換が計算の一ステップである。 β変換がこれ以上できない状態が計算の終了状態である。
    λ式
    変数を表す記号が可算無限個与えられているものとする。この時λ式とは、 変数記号を最小構成要素に補助記号「.」と「(」と「)」とから次の様に生成される。
    • 任意の変数はλ式である
    • M N とをλ式とするとき (MN) もλ式である
    • M をλ式、 x を変数とする時 (λx.M) もλ式である
    束縛された出現と自由な出現
    変数がλ式に出現する仕方は二通りある。 z の出現が E の部分式で (λz.M) なる形のものに含まれる時、その出現を z E における束縛された出現と呼び、束縛されていない出現を自由な出現と呼ぶ。
    代入
    λ式 M N 、変数 x に対して、 λ式 M[x←N] M で自由な x の出現を 全て N で置き換えて得られるλ式である。
    α変換
    λ式 E=(λx.Q) において、 変数 y Q には部分式としても束縛変数としても現れないものとする。 λ式 F=(λy. Q[x←y] ) であるとき、 E →α F と表し、 E F へα変換可能という。
    β変換
    λ式 E=((λx.Q)R) かつ F=Q[x←R] である時、 E →β F と表し、 E F へβ変換可能であるという。
    計算可能性の同値性
    実は上記3種の定義は同値であることが知られている。その他、これまでに定義された 計算可能性の定義は全て同値であることが知られている。

    注: 副作用
    通常は(薬の副作用と同じで)意図しない、又は目的外の作用・効果を言う。プログラム言語で登場するのは、 関数やサブルーチンの中で行った動作の直接の影響が、その関数・サブルーチン内のに止まらず、呼び出し元の ルーチンや更には殆ど関係のないと思われるルーチンの変数に迄及ぶことを言う。

    命令型言語の最大の弱みは、

    という二つの性質に依存していることである。 命令型言語の代入文は、 結局のところ、cpu の主記憶装置からの情報の読み出し動作と、 主記憶装置への情報の書き込み動作とを真似しているに過ぎない。

    もう一つの弱みは制御構造にある。 たとえ、様々なエレガントな制御構造 (while, for, case, etc.) を導入したにせよ、 基本は、von Neumann 型計算機の持つ、線型実行 (sequential) と 原始的な jump 命令とに依存している点である。

    Backus は、Turing 賞受賞講演において、「代入文は命令型言語の von Neumann ボトルネックである」と述べている。Backus が上げた例は次のプログラムである。

                    c:=0 ;
                    for i:=1 step 1 until n do
                            c:= c + a[i] * b[i] ;
    

    さてこれは何を計算するプログラム (部分) であろうか? 配列で表現された、二本のベクトルの内積を計算するプログラムである。

    では何が欠点であろうか?

  • このプログラムを理解するためには、頭の中で実行してみないといけない
  • 内積を計算する上では、積の順序、和の順序は影響しない。にも関わらず、 こうした命令型の言語で表現するには何ならかの順序を (多くの場合恣意的に 設定しないといけない)
  • プログラムの中で、ベクトルの長さ (配列の長さ) を明示しないといけない
  • 関数型言語の創始である LISP では、どう書けるであろうか。LISP になじみの ない人のために、LISP-like な形で記そう。
    ベクトル X = (x_1,...,x_n) に対して、First (X) = x_1, Rest(X) = (x_2,...x_n), という関数と、X が 0次元ベクトルの時のみ Null(X) = true となる boolean 値関数 とを考える。そうすると、X と Y の内積は、

            if Null(X) then 0
                    else First(X) * First(Y) +InnerProd(Rest(X), Rest(Y))
    

    と書ける。 これにより、ベクトルの長さというパラメータは記述する必要がなくなった。 for loop の代りに recursvie call が導入され、 内積の主たる演算である、加算と乗算が強調されることになった。 しかし、まだ、順序性は色濃く残っている。
    APL では、内積 (の拡張) がほぼ built-in 関数で定義されているため、

            X +.* Y
    

    と書ける。なお、これは、もっと基本的なオペレータを用いて、

            + / X * Y
    

    とも書ける。 Backus が提案する関数型言語を用いると、次のように書ける。

            (Insert +) □ (Applytoall *) □ Transpose
    

    1次元ベクトルは行ベクトルであると考える。行列は行ベクトルの組合せで表す。 例えば、((1,2,3),(4,5,6)) は2行3列の行列であるとする。 さて、□ は関数の合成を、Transpose は引数として渡された行列の転置を、 Applytoall は、第一引数で渡される関数を第二引数で渡される行列の 全ての行に適用することを、Insert は第二引数で渡されるベクトルの各要素間に 第一要素で渡される関数を挿入することを表わす。

    さて、この結果、確かに、「ベクトルの長さを明示する必要はなくなった」し、 「加算、乗算の順序にし意的なものを導入する必要はなくなった」。

    しかし、やはり、頭の中での実行は必要である (少なくとも我々凡人にとっては)。