NOTE: この記事は、当初、ココログの「いげ太のブログ」で公開していたものです。
よく訓練された C# 使いならばご存じの通り、C# に末尾最適化はない。より正確に言い換えるなら、C# 4.0 コンパイラは 'tail.'プリフィックスを付与しない。このことによって、C# プログラミングにおいては、再帰はおよそ避けるべきものとして認識されている。しかし。
しかしポインタと再帰の明らかな重要性以上に重要なのは、これらの学習から得られる精神的な柔軟さと、これらを教えている授業からふるい落とされないために必要な精神的態度が、大きなシステムを構築する上で欠かせないということだ。ポインタと再帰には、ある種の推論力、抽象的思考力、そして何よりも問題を同時に複数の抽象レベルで見るという能力が要求される。そしてポインタと再帰を理解できる能力は、優れたプログラマになるための能力と直接的に相関している。
Javaスクールの危険 - The Joel on Software Translation Project
ならば。もしあなたが再帰の得意でない C#er であるならば。末尾最適化を備えた .NET 言語である F# との対比によって再帰に入門してみるってのはどうか。
はじめに
この記事では、末尾再帰とそうでないふつうの再帰の違いについて述べません。while ループを末尾再帰に書き換えるだけのことを説明します。
while
ありがちな学習用コードを示そう。1 から引数 n までの整数の総和をとる関数 Sum だ。ここでは、ごく簡単な例として示すために for や foreach なんてな高級なループ構文を使わずに、while で書く。
int Sum(int n)
{
var ret = 0;
var i = 1;
while (i <= n) {
ret = ret + i;
i = i + 1;
}
return ret;
}
// Console.WriteLine( Sum(10) ); // 55
対応するように F# コードも示そう。上の C# コードと下の F# コードに、たいした違いがないとわかるだろう。
let sum n =
let mutable ret = 0
let mutable i = 1
while i <= n do
ret <- ret + i
i <- i + 1
ret
// printfn "%d" (sum 10) // 55
変数を減らす工夫
さて上記のつくりでは、1、2、3、4と、数値を1づつ加算してループを行うために、ループ カウンターの i と、どこまで加算するかの終了条件となる n を、それぞれ個別の変数として持っている。
しかしこれは、n、n-1、n-2、n-3と、数値を1づつ減算していって最後は1で終わるようにすれば、引数の n 自体をループ カウンターとして使うことができるので、変数 i をなくしてしまえる。
int Sum(int n)
{
var ret = 0;
while (n > 0) {
ret = ret + n;
n = n - 1;
}
return ret;
}
同じように F# コードも書き直そう。ただしちょっとした問題がある。F# の引数には基本的に再代入ができない。よってここでは、関数内で、引数と同じ名前の再代入可能な変数を定義することでやってみよう。結局、実質の変数は減ってないじゃんって話だけど、気分はいくぶん楽になるだろう。
let sum n =
let mutable n = n
let mutable ret = 0
while n > 0 do
ret <- ret + n
n <- n - 1
ret
変数でなく、引数として
さて、いま引数の n と変数の n は同質化してしまったってわけ。でもどうせなら、こんなまがいもんの同質化じゃなくて、ほんとうに同じものって呼べるようにしてしまいたい。もちろん、F# では引数への再代入ができないから、そう単純にはいかないわけだけど。
// こうしたい
let sum n =
let mutable ret = 0
while n > 0 do
ret <- ret + n
n <- n - 1
ret
てゆうか、ついでだから ret もなくしてしまおうか。いま関数内の n をなくそうとしているわけだけど、これはつまり、変数の n を引数の n だけでまかなってしまおうってことで、すなわち、関数内にある変数を引数に追い出してしまえば万事オーケーって寸法。なら ret も引数に追い出せばいいんじゃん。
// むしろこうしたい
let sum ret n =
while n > 0 do
ret <- ret + n
n <- n - 1
ret
// 呼ぶときはこんな感じで
// printfn "%d" (sum 0 10) // 55
じゃあやってみよう。でもどうすれば。再代入をなくせばいい。なぜいま再代入が必要。ループがあるから。ループの代わりを探せばいい。そう、それが、つまり、再帰だ。
繰り返しの条件
でも while は条件判断をもやっている。while ループを使わずに、でも条件判断は行いたい。よろしい、ならば if だ。while の中に隠れている if を引きずり出せ。白日の下に。コードの上に。
let sum ret n =
if n > 0 then
ret <- ret + n
n <- n - 1
ret
としたところで一つ触れておきたいのは、実のところ F# の if は、C# でいうところの条件演算子(3項演算子)の方に近いってこと。cond ? exp1 : exp2 の形で、値を返すアレ。つまり、上記のコードじゃあ exp2 がなくて変だ。ここでは、なにもしないことをあらわす () という値を当て込んで、ひとまずの体面を整えておきたい。
let sum ret n =
if n > 0 then
ret <- ret + n
n <- n - 1
else
()
ret
sumthing else
さて、肝心の再帰呼び出しをまだ書き入れていない。関数の内側からその関数自身を呼ぶんだ、再代入していた ret + n と n - 1 を引数に渡して。
let sum ret n =
if n > 0 then
sum (ret + n) (n - 1)
else
()
ret
しかしこれはおかしなコードだ。なにか変だ。そんな匂いがする。無理やりに突っ込まれた () がアンバランスさを教えている。これは C# で書き直すならこんなコードだ。
int Sum(int ret, int n)
{
(n > 0) ? Sum(ret + n, n - 1) : null;
return ret;
}
() は null ではない。() を null で置き換えるのは無理やりにすぎたのだとしたら、あるいはこんなコードと見立てよう。
int Sum(int ret, int n)
{
if (n > 0)
Sum(ret + n, n - 1);
else
;
return ret;
}
なにか嫌な予感がしてこないか。
再帰
問題の本質はこうだ。while 版の Sum が呼ばれるとき、定義中の return ret が評価されるのは一度っきりだ。しかし再帰版の Sum では、再帰によって Sum が呼ばれる度に return ret が評価される。これはイケナイ。
ということは、return ret が評価されるのを一度っきりにすればいいのであり、つまり、再帰時には return ret を通らないようにする。そんな場所に return ret を置くのだ。そんな場所はどこ。else 節だ。
int Sum(int ret, int n)
{
if (n > 0)
Sum(ret + n, n - 1);
else
return ret;
}
オーケー。まだバランスが悪い。分岐の片側でしか return されていない。再帰呼び出しの Sum の返り値が捨てられている。きっとこうだ。
int Sum(int ret, int n)
{
if (n > 0)
return Sum(ret + n, n - 1);
else
return ret;
}
「きっと」って、アバウトすぎる。なら声に出して読んでみよう。指差し確認、声出し確認。
「n > 0 のときは、ret + n を ret に、n - 1 を n に渡して再度 Sum を呼んでその結果を、そうでないときは ret の値を返す」
ほら、なにも問題ない。
なにも再帰だけ難しく考えすぎる必要はないんだ。ループを使う時に「この条件が成立する間はループの中身を実行して、成立しなくなったらそこから抜けて変数の値を返す」ということだけを意識すればよかったように、再帰だって前述のように読めばよいだけ、「この条件が成立するときは再帰して結果を返す、成立しなかったら引数の値を返す」と認識すればよいだけだ。
重要なのは、どんな条件で再帰して、そして再帰の果てでなにを返すのか。
再帰によって、いずれその果てで得られる最終の結果を返すためには、再帰呼び出し部分にも return 付けておかなければいけないのであり、再帰呼び出し先に結果の算出をお任せしておいてそいつを捨てちゃあ元も子もない。
それ一行で
F# の if は C# の条件演算子により近しいと言った。なら条件演算子を使おう。
int Sum(int ret, int n)
{
return (n > 0) ? Sum(ret + n, n - 1) : ret;
}
そして F# に戻ろう。こうなる。
let sum ret n =
if n > 0 then sum (ret + n) (n - 1) else ret
寄せ
実はまだ上記の F# コードは動かない。再帰関数には rec キーワードを付ける必要がある。
F# では、コードは上から順に定義され、未定義のモノを使用することはできない。また、定義中のモノは未定義状態とみなすのがデフォルトの解釈になるのだけれど、これを定義済とみなすべく rec を付与する。つまり、rec の存在が定義中を未定義とみなすか定義済とみなすか制御可能にするのである。と、込み入った説明をするとそういうことだ。
let rec sum ret n =
if n > 0 then sum (ret + n) (n - 1) else ret
さあ、これで動くコードになった。けどもうちょっとイイ感じに仕上げよう。かっこよく洗練された再帰関数にするのだ。
まずは ret 引数だ。ret じゃよくわからない。この場合は accumulation から acc と名付けるのがよいだろう。そして、このような再帰の間中引き回すためだけの取って付けたような引数は、引数リストの最後に鎮座するのがお定まりだ。
let rec sum n acc =
if n > 0 then sum (n - 1) (acc + n) else acc
次に、再帰呼び出しの位置。別に現状でもなんら問題はないが、せっかく末尾再帰と呼ばれるものを書いたのだから、コード上でも後ろの方にあった方が“この例の場合は”それっぽく見えていいと思える。if の条件をうまくひっくり返して、then 節と else 節を入れ替えよう。
let rec sum n acc =
if n <= 0 then acc else sum (n - 1) (acc + n)
最後に、sum の呼び出し側を考える。いま sum 関数の呼び出しは、たとえば10までの総和であれば、sum 10 0 のように行う必要がある。そのくせ第2引数は常に0でよい。常に0でよいのなら、常に0を与えるような関数でラップして、sum 10 で呼び出せるようにするべきだ。
let sum n =
let rec f n acc = if n<=0 then acc else f (n-1) (acc+n)
f n 0
おつかれさま。これでできあがり!