15. 関数型プログラミング言語

関数の値は引数の値によってのみ決まる。→equational reasoning可能

equational reasoningの例: a = f(x) ⇒ g(f(x),f(x)) ≡ g(a,a)

純粋なPure関数型言語: 数学における関数と同様にequational reasoningが可能

命令型Imperative)言語:関数型言語と同様の構文は有るが…

…ので関数の評価値が呼び出し毎に異なることがあり得る。

高階関数: 関数型言語の特徴。

また、そのようなことを行う関数を高階関数と呼ぶ。

という特徴の下で高階関数が用いられると特に興味深い。

Tigerにおけるlexicalスコープ: 入れ子の内側で定義された関数が外側の関数の変数や引数を参照できる。

高階関数型言語: 入れ子の関数と高階関数を備えた関数型言語。

この章では…

Fun-Tiger: 高階関数を含むよう拡張したTiger言語。副作用が許されていてequational reasoning不可。Scheme、ML、Smalltalkなどのような純粋でない高階関数型言語impure, higher-order functional language)。

PureFun-Tiger: 高階関数の追加に加えて関数で副作用を認めないよう変更されたTiger言語。厳密で純粋な関数型言語strict, pure functional language)の本質を理解するための言語。(純粋な関数型に制限したMLのサブセットに似ている。)

Lazy-Tiger: Haskellのような遅延評価Lazy Evaluation)を備えた厳密でなく純粋な関数型言語non-strict pure functional language)。equational reasoningを良く支援する。

というような関数型言語における三つの異なった趣向について考える。

備考: SISALのように純粋な1階関数型言語はequational reasoningをサポートするが高階関数は受け付けない。

15.1 単純な関数型言語

/* 高階関数型言語 Fun-Tigerの実現その1 */

関数型の追加:

tyty -> ty

→ ( ty { , ty} ) -> ty

→ () -> ty

関数型の例:

int->string

整数1個を引数にとって文字列を返す関数の型。

(int, string)->intarray

整数と文字列を1つづつ引数にとってintarrayを返す関数の型。

()->string

getchar関数のように引数なしで文字列を返す関数の型。

(int->int)->int->int

->が右結合なので((int->int)->(int->int))とグループ化して考えて(こうは書けないが…)、int->int型の関数を引数にとってint->int型の関数を値として返す関数の型。

関数呼び出し式の変更: (呼び出し式で関数名に限られていた部分に任意の式を認める。)

expexp ( exp { , exp} )

expexp ( )

関数型の使用例: (プログラム例15.1)

addFiveとaddSevenが呼び出したintfun型のaddはそれぞれ引数nを5、7として関数hを返すのでnはhの各インスタンスについて非局所的に記憶しておく必要がある。これが後述する閉包closure)実装の動機となる。同様にtwiceは関数fをgの各インスタンスについて記憶する必要がある。

15.2 閉包

/* 高階関数型言語 Fun-Tigerの実現その2 */

実行時における関数の表現:

<入れ子の関数定義を持たない(Cのような)言語>

→ 変数のように(関数の機械語コードを指す)番地で表現可能。

→ 変数と同様に格納、引数渡しでき、呼び出し時にはレジスタ番地を使った関数呼出し命令で呼び出せる。

→実装はラベルと変数用のテンポラリを利用できる。(P.312の例)

<入れ子になった関数を許す言語>

→ 外側のフレームの変数をアクセスするための情報が必要なので関数の番地だけでは不十分。(P.311のプログラム例でhがn、gがfを参照する。)

→ 関数の番地及び、非局所変数にアクセスするための情報をまとめた閉包closure)として関数型変数を表現、記録する。

→ (閉包の簡単な実現)関数の番地とスタティック・リンクの対。スタティック・リンクをたどって非局所変数にアクセスできる。

備考: 閉包が変数へのアクセスを提供する部分に注目する場合、環境enviroment)と呼ばれる。

閉包の実現について:

閉包はスタティック・リンクに基づく必要はない。むしろスタティック・リンクによる閉包の実現には

という不利な点もある。本章では簡単のためにスタティック・リンクを用いる。

ヒープに割り付けられたActivation Record

P.311の例から: add終了後もaddはhに環境を提供する。 →addのフレームはaddの終了時に破棄できずスタックに置けない。 →フレームをヒープにおいて回収はGCに任せる。

改良(テキストの図15.2): 抜け出しているescape)変数だけ抜け出し変数レコードescaping variable record)にまとめてヒープに置く。それを指すEPという局所変数を用意する。スタティック・リンクは抜け出し変数レコード間で張る。

Tigerコンパイラの修正

Frameモジュールを修正する。

15.3 変更不能な変数

/* 厳密に純粋な関数型言語PureFun-Tigerの実現 */

equational reasoningを可能にするために世界(world)を変更するような副作用を禁止する。

厳しすぎるように見えるが…

例: 15.3のプログラム(テキスト108ページの二分探索の実装)のように既存の木を更新するのではなくコピーして変更箇所を追加したものを結果とすれば動作できる。

t2 := enter(t1, "mouse", 4)後にt1もt2も利用でき、t1を放棄後はGCによって不要になったノードが回収される。

Continuationに基づく入出力

continuationに基づく入出力continuation-based I/O): 「代入なし」に従うため既存のデータ構造の更新によらず新たなデータ構造を生成する場合の入出力。

type answer

プログラム全体が返す「結果」を示す型。exit関数の戻り値の型。

type stringConsumer = string -> answer

入力後の動作を示す関数の型。この型の関数は一般に入力関数(ここではgetchar)から渡された値を処理する関数。この型の関数を入力関数に入力の受取り手として渡す。この関数の「結果」は入力関数の「結果」となる。

type cont = () -> answer

出力後の動作を示す関数の型。この型の関数を出力値と共に出力関数(ここではprintやflush)に渡す。この関数の「結果」は出力関数の「結果」となる。

この変更によって入出力はequational reasoningと共存できる。

加えてI/Oを行う関数の返値は全てanswer型を持つので型検査時から識別できる。

言語の変更

プログラム例15.5: これは整数の読みこみ、読みこんだ整数の階乗の出力をループによって12より大きい数が入力されるまで繰り返し行うPureFun-Tigerのサンプルプログラム。このプログラム内の呼び出し関係を下に図示する。最後のexit()が呼び出されるまで再帰的な呼び出しが続き、factrialの呼び出しを除いて、途中では復帰していないことがわかる。

純粋な関数型言語の最適化

PureFun-TigerはFun-Tigerから(既定義の型を変更した以外)機能を削減しただけ →Fun-TigerコンパイラはPureFun-Tigerプログラムをコンパイルできる。

一般に関数型の言語は命令型言語の最適化(インライン展開、命令選択、ループ不変式解析、グラフ・カラーリングに基づくレジスタ割り当て、コピー伝播など)を全て利用することができる。

制御フローも計算できるが、

のために若干難しくなる。

PureFun-Tigerでは以上に加えてequational reasoningが可能であることを生かしていくつかの最適化ができる。

type record = { a : , b : }

var a1 := 5

var b1 := 7

var r := { a := a1, b := b1 }

var x := f(r)

var y := r.a + r.b

というコード片について、f に副作用がない。 → yの計算の際もr.aとr.bが各々a1=5、b1=7のままであり、y=12もこの後yのスコープ内では成り立っている。 →それぞれ定数で置き換えて良い。(同様の置換は命令型でも可能だが、副作用のため保守的に行わざるを得ず、殆ど置換できない。17.5節のalias解析参照)

備考: MLには更新不能な純粋関数型のレコードと更新可能なレコードがあり、前者については定数への置換による変換が可能である。

15.4 インライン展開

/* 関数型言語Fun-Tiger & PureFun-Tigerの最適化その1 */

関数型のプログラムでは多くの小さな関数を使う。 & 特に関数を引数に渡して使う。 →素朴にコンパイルすると命令型プログラムより関数呼び出しが増える。 →関数のインライン展開inline expansion): 関数呼び出しを関数本体のコピーで置きかえる。

例: 

15.6 :汎用のリスト遷移関数doListの使用。

15.7(a) :リスト遷移にprintDoubleという特定の動作が組み込まれて柔軟性が減っている。(一般のTigerでは関数を引数に渡せない。)

15.7(b) :インライン展開と15.6節の末尾再帰の最適化で15.6のプログラムはこれと等価に最適化できる。

最適化は、特殊な目的の関数を書いたとき並に効率の良い関数を汎用性を失わない形で書けるようにすること。

変数の捕獲(variable capture)の回避

重複する名前の局所変数の宣言で外側で宣言された変数名が「隠されて」いる場合に単純な展開を行うと関数の意味が正しくなくなる場合がある。

例:仮引数によってfに導入された局所的なxfで展開されるgの参照する外側の変数xと重複している。

対策:

インライン展開の規則

アルゴリズム15.8

命令型プログラムにも適用可能。

(a) 実パラメータが単純な変数

関数本体中の仮引数を実引数の変数で置換

(b) 実パラメータが式

重複しない局所変数を実パラメータで初期化して宣言。その局所変数で関数本体中の仮引数を実引数の変数で置換。(equational reasoningが可能な場合は効率のため、そうでない場合は副作用の重複を避けるため。例はp.323。)

利用されていない関数の除去(Dead function elimination

全ての呼び出しがインライン展開された関数、そして引数に渡される他のどんな方法でも参照されていない関数は取り除くことができる。

再帰的な関数のインライン展開

再帰呼び出しをしている関数をインライン展開するとループ中の最初の反復だけonly the first iteration of the loop)が展開され展開結果に展開した関数の呼び出しが残る。

例: printTable内のdoListを展開すると結果にリストの尾部に対するdoListの呼び出しが残っている。

対策: loop-preheader変換。再帰呼び出しをしている関数を分割する。

序(prelude): 外部から呼び出せる関数。例中のdoList

ループ先頭(loop-header): 序からだけ内部的に再帰呼び出しされる関数。例中のdoListX

このようにしてから序の方だけが展開され、それに伴ってループ先頭は定義が局所的な関数としてコピーされる。

ループ不変(loop-invariant)な引数

ループ先頭(loop-header)の中で再帰の際にそのまま渡される引数がある場合に、その変数はループについて不変であると考えられる。これらは引数として渡さずとも、外側の定義を参照すればよい。(ループ不変変数の巻き上げloop-invariant hoisting)変換。アルゴリズム15.10。)

例: 書き換え後、doListXfcはそれぞれdoListXの引数から外れ、doListXは代わりにdoListfxcxを直接参照する(p.325上段の書き換え)。この書き換え後printTabledoListを展開したものがp.325中段。

連鎖的(cascading)インライン展開

引数として渡された関数を連鎖的に展開する。

例: printTableprintDoubledoListに単に渡すのではなく、doListが展開された際、それがdoList内で呼ばれているところで展開する。(p.325の下段)

コードの爆発的増殖(code explosion)の回避

インライン展開は関数本体の複製を伴う。 →一般にプログラムが大きくなる。 →無差別な展開はプログラムの大きさを爆発的に増加させる。(実際、1つの関数展開が展開可能な関数のインスタンスを作り出して無限に増加させる例が作れる。)

爆発的増殖を抑止するHeuristicな規則: 

letの入れ子の展開(unnesting

Tiger言語では

let dec1 in let dec2 in exp end

let dec1 dec2 in exp end

と等価。

例: 書き換え後はプログラム15.11

15.5 閉包変換

/* 関数型言語Fun-Tiger & PureFun-Tigerの最適化その2 */

関数型言語の閉包変換closure conversion)フェーズはバックエンドを簡単化するために、自由変数(フレームと切り離して格納してある抜け出している変数)へのアクセスは仮引数を通じて明示的に行うようにプログラムを変換する。

具体的には:

例: 15.11を閉包変換して、15.12を得る。

15.6 効率的な末尾再帰

/* 関数型言語Fun-Tiger & PureFun-Tigerの最適化その3 */

関数プログラムでは制御フローは関数呼び出しで表わされる。

例: 15.7bwhileループに対応するのが15.12ではdoListXの呼び出し。15.7bで単に2度呼び出されているputInt関数はcontinuation関数に変換されている。Fun-Tiger関数はこれらdoListXdoRestagainを効率良くコンパイルする必要がある。

関数の呼び出しが末尾にある場合、その関数の返値は呼び出し元の返値でもあるから(p.329下段の例のf(x))、その関数呼び出しをジャンプで実装して直接大元の呼出し元に復帰すればよい。もしスタック・フレームの中身がなくて準備する必要がなければ大変効率が良くなる。

「末尾」の形式的定義: Biは末尾。Ciはそうではない。

  1. let var x := C1 in B1 end
  2. C1 (C2)
  3. if C1 then B1 else B2
  4. C1 + C2

関数呼び出しf(x)が式の末尾にあり、その式がそれを含む式の末尾にあり、そのようにして関数定義の末尾にあれば、末尾呼び出しだと言える。

末尾呼出し手順の実際: 

  1. 実パラメータを引数レジスタに移動する。
  2. 呼び出され側保存レジスタを復帰する。
  3. もしあれば、呼び出す関数のフレームをpopする。
  4. 呼び出したい関数にジャンプする。

1は多くの場合コピー伝播融合coalescing)フェーズで除去される。

23は呼び出す関数にスタック・フレームがなければ除去できる。(呼び出し元保存レジスタだけで作業ができればスタック・フレームは不必要。)

例:

ヒープ上の自由変数確保にかかる手間の削減:

15.7 遅延評価(Lazy evaluation

/* 関数型言語Lazy-Tigerの実現 */

equational reasoningの主要な法則

β-置換(β-substitution): 

f(x) = B ⇒ f(E) ≡ B[ x | E]

例(p.331): 右側は左側のf(loop(y))をβ-置換(Bをfの本体としてEloop(y)として)して展開したもの。

2つのプログラムはequational reasoningの下で同値だが動作は異なる。(y=0で左は無限ループに陥り、右は0を返す。) →これは明らかに不都合。

PureFun-TigerではABを置換して作ったプログラムであるとき、双方とも停止すれば両者は同じ結果を与えるが、同じ入力集合について一方が停止しないことがあり得る。

この部分的な不都合を改善するために不精な評価lazy evaluation)を導入する。

 

Lazy-Tigerの文法はPureFun-Tigerと同じ、意味も殆ど同じだが、Lazy evaluationされるところだけが異なる。

 

特徴

言語の例

Lazy言語

他の部分で値が必要とされている式しか評価されない。

MirandaHaskellLazy-Tiger

Strict言語

制御の流れが到達する式は全て評価される。

MLJavaCTigerPureFun-Tiger

 

 

名前による呼び出し評価(call-by-name evaluation

Call-by-value

関数の引数には値が渡される。f(g(x))の評価の例ではg(x)の結果がfで使われようが使われまいがg(x)が計算されて結果の値がfに渡される。(PascalMLJavaCTigerPureFun-Tigerの場合)

Call-by-name

必要な値だけを計算するために変数宣言は関数宣言(サンクthunk)と呼ばれる。)に、参照はその関数の呼び出しにそれぞれ変換される。

例えばint型の値が宣言されると()->intという関数型の値が作られ、int型の値が参照されるところではその()->int型の関数が呼び出される。(変数宣言は仮パラメータでも行われるので、関数呼び出しについて各実引数ごとに小さな関数宣言が必要となる。)

15.14: 15.3alookについて上述の変換を行ったもの。

Call-by-nameの問題点:

サンクが大量に作られ呼び出される点。それらは同じ値を計算する場合も何度も呼び出される。15.14の例ではlook(t1, k)が呼び出される度にt1()も呼び出されて全く同一の木を何度も構築する。

必要時呼び出し(call-by-need

Lazy evaluation必要時呼び出しcall-by-need)とも呼ばれる。 →名前による呼び出しを修正して同じサンクが2度実行されないようにする。

実現方法の概要:

  1. サンクにメモ・スロットmemo-slot)を追加してサンクの呼び出し毎にそこを調べる。
  2. 1度目の呼び出しならばメモ・スロットは空で、値を求めるサンク関数を呼び出してメモ・スロットに蓄え、その値を返す。
  3. 2度目以降の呼び出しではメモ・スロットに蓄えた値を返す。

実現の詳細:

  1. サンク関数へのポインタとメモ・スロットからなるレコードでサンクを表現する。サンク関数へのポインタはunevaluatedサンク関数を指し、メモ・スロットは必要なスタティック・リンクを持つように初期化しておく。
  2. 1度目の呼び出しの際はメモ・スロットに記録しておいたスタティック・リンクを伴ってunevaluatedサンクが呼び出される。unevaluatedサンクは値をサンク関数へのポインタをevaluatedサンク関数へのポインタに差し換えて、値を計算してメモ・スロットに格納し、その値を返す。
  3. 2度目以降の呼び出しではevaluatedサンク関数が呼ばれる。evaluatedサンク関数は単にメモリ・スロットに蓄えた値を返すだけである。

例: プログラム15.1var twenty := addFive(15)のコンパイル後擬似コード

Lazyプログラムの評価

例:

15.3benter関数を使って

three |→ 3!

minusOne |→ (-1)!

という写像のための木を作ることを考える。

ここでfact(-1)の結果は未定義である。

PureFun-Tiger

木を作るためにfact(-1)を計算しようとして無限ループに陥る。

Lazy-Tiger

3!の値を出力する。

↑の内訳:

  1. 変数t1が定義されるが実際にはenterは呼び出されないでt1のためのサンクが作られる。
  2. 変数t2が定義されるが実際にはenterは呼び出されないでt2のためにlook(t2, "three")を呼び出すためのサンクが作られる。(ただし直接そこで呼び出すわけではない。)
  3. 最後にanswerを返すputInt(…,exit)という式のためのサンクが定義される。ただし実際にanswerが計算されるのは最外側のサンクが呼び出されたとき。
  4. putIntの実行が始まるとその最初の引数の値が必要になってlook(t2, "three")のためのサンクが実行される。
  5. lookは引数tkのためのサンクを実行する。
  6. tが返したレコードのt.keyというフィールドもサンクなので実際には(t().key).()という関数によってキーとなる整数が得られる。(この場合それはfact(3)の値)
  7. t.key()"minouseOne"ならばlook(t().right, k)が呼び出されるが、この場合はminusOneに対応するノードのbindingサンクは呼び出されないので、fact(-1)は計算されず、無限ループに陥ることもない。

Lazy関数型プログラムの最適化

遅延関数型言語でも厳密な関数型言語や命令型言語と同様の数多くの最適化が可能。

ループは認識され(単純な末尾再帰の場合)、induction変数は識別され、共通部分式は削除される、など。

加えて、遅延評価関数ならではのequational reasoningを生かした最適化が可能。

不変変量の巻き上げ(invariant hoisting

例:

h(i)の計算はg()の実行とは独立して計算可能。→別に計算して変数に格納することで計算が削減できる(g()が複数回呼び出される場合)。

しかし厳密な言語ではこの変換ができない。

var a := f(8)

というコードがあって結果の関数aが全く呼ばれていなくて、かつh(8)が無限ループに陥るような場合、変換前は停止するが、変換後は無限ループに陥る。(この変換は非関数型言語では副作用の回数が変わるので論外。)

実行されないコード(dead-code)の除去

例:

dが使われていないのでg(x)の呼び出しも削減可能。

しかし:

deforestation

多くの言語では、あるモジュールがデータ構造を生成して、別のモジュールがそれを処理することが一般的。

例(プログラム15.15):

この際、関数間で引き渡される中間結果も含めて3つのリストが作られるがそれは無駄。deforestして1つの関数に融合できる。(P.337真中の例)

非純粋関数型言語ではこの変換は一般に正しくない。(incmuladdが自分の結果を逐次出力した場合を考えると評価順が変わる。p.337の出力例は変換前でp.338の出力例は変換後。)

この変換は純粋な関数型言語では正しい。

厳密性解析(strictness analysis

サンクの生成と評価のオーバーヘッドを減らす最適化が必要。これができないと他の最適化を利用してもなお遅くなると思われる。 →必要なサンクだけを生成する。

例: f(x)は引数xを評価することが明らかならば、サンクを渡さずに値を渡せば良い。

厳密性の定義

関数f(x)が厳密(strictness)であるとはf(x)に渡される実引数aの評価が停止せず失敗するならばf(a)の評価も停止せず失敗することである。

多引数版:

関数f(x1,…,xn)xiについて厳密であるとは実引数aの評価が停止しないならf(b1,…,bi-1,a,bi+1,…bn)の評価も停止しないこと。

例:

f:xyの双方に付いて厳密。

g:xについてのみ厳密。yは評価されない場合がある。

h:どの引数についても厳密でない。どの引数も関数内で評価はされていない。

j:xを評価していないにも関わらず定義からxについて厳密。(「ならば」の性質から。この定義をした目的は引数を評価してから渡す際の安全性をみることだからどのみち無限ループに陥る関数はどうせどっちでもいい。)

strictness解析の結果の利用

例(15.16): 厳密性解析の結果を利用した15.3alook関数のcall-by-nameへの変換結果。

15.14の全面的にcall-by-needに変換した場合と似ているが…。

looktkeyに関しては厳密(k>t.key式があるから)である。 →tkeyについては触れるtouch)必要がない。

しかしt.keyt.leftt.rightについては厳密でなくサンクが必要。

引数が厳密なので呼び出し側はサンクを渡す必要はない(再帰呼び出しの部分を参照)。

近似strictness解析(Approximate strictness analysis

上記の例のfghの場合のように簡単(最適化コンパイラが見つけられる程度に)なこともあるが、厳密性を正確に調べることは一般には計算可能ではない(p.218の動的生存解析やその他のデータフロー問題と同様)。 →保守的に近似を行う。

保守的な近似:

ある引数について正確に厳密性を決定できない場合は厳密でないと仮定してサンクが生成される。 →遅くなるが、停止するプログラムを停止しないように変換することはなくなる。

厳密性計算アルゴリズム(15.17):

このアルゴリズムは高階関数を取り扱えないのでLazy-Tigerでは上手く働かない。高階関数を扱うにはより強力なアルゴリズムが必要。