OCaml 標準ライブラリ探訪 #2 List : スタックと計算量に注意

関連リンク:
OCaml 標準ライブラリ探訪 第0回
その他の回は第0回のトラックバックよりご覧ください。

やあこんにちは。ちょっとショッキングなことがあってブルーな筆者です。他にも 3kg は一瞬で減量できたんですがね、そこから先がなんともです。まーみなさんには関係有りませんね。

標準ライブラリ探訪第二回は List です。このモジュールは関数型言語で最も基本的な再帰データ型である list 型のデータを扱う関数群からなっています。

OCaml では list は predefined な型で、その定義は標準ライブラリにはなく、コンパイラにとって既知になっています。(詳しい内部定義は ${srcdir}/typing/predef.ml にあります。) が、あえて、list 型の定義を書いてみると、次の様な variant型として書く事が出来ます:

type 'a list = 
  | []                  (* nil *)
  | :: of 'a * 'a list  (* cons *)

list 型の入門解説で色々言われている list 型操作コストの深さに対する線形性はすべてこの型定義から出てきます。詳しくは list が何かは関数型言語の教科書を読めばまず説明がありますから、そちらを読んでください。そういう先人が何度も書いていることをここで説明しても、ね? とはいえ、List を征した者のみが OCaml を征する事が出来るといっても過言ではありませんから、重要なモジュールです。

あ、そうそう、もちろん上の式は [ ] という OCaml では variant constructor として許されない変な記号を使っているのでエラーになりますよ。

ちなみに [ ] はダメですが、なぜか :: はコンストラクタの名前として正しく認識されます。OCaml 3.11.1 でコンストラクタに使える変な名前は ::, true, false, () の四つ。使うと難読化になる、かもしれません、けど、一度定義すると以降の名前空間では二度と元のコンストラクターとしては使えませんので、しないほうが身のためです:

type t = :: | true of int | false of unit | () of t * t
let _ = true 1 :: false (prerr_endline "howdy") :: ();;

末尾再帰: non tail recursive v.s. tail recursive

List で一番注意しなければいけないこと、それは、一部の関数が tail recursive ではないことです。Non tail recursive な関数では関数の戻りアドレスをスタックに積むため、あまり多くの回数再帰するとスタックを使いきって、 Stack_overflow 例外が発生します:

let cntr = ref 0

let rec f () =
  incr cntr;
  f (); ()  (* 末尾再帰ではない *)
;;

let _ = try f () with _ -> Printf.eprintf "cntr=%d\n" !cntr;;

上の例では f () の再帰呼び出しの後に敢えて ; () を置いて non tail rec にしています。このプログラムを動かすと、スタックを使い切ってしまってエラーがでた際の呼び出しの深さを表示します。私の ocaml toplevel では 262045 でした。

この f (); () を f () にしてやると tail rec になります。Tail recursive だと戻りアドレスを覚える必要がなくなるので、スタックを消費しません。そのため、プログラムを実行しても無限ループになるだけで、Stack_overflow は発生しません。

え、tail recursion (末尾再帰) って何だって? それは多分検索すると山の様に出てきますから、そっちで勉強してくださいね。

OCaml は上の例でもわかるように non tail rec を tail rec に自動的に最適化するという気の利いたことをしてくれません。f (); () も f () に自動的に簡約してくれたら良さそうですが、それもしてくれません。とても実直なコンパイラですね。

ともかく、OCaml では再帰関数を tail rec で書くことが時に重要です。ちゃんと型付けされているのに、想定外に再帰回数が多くて Stack_overflow になるのは嫌でしょう?

List モジュールには non tail rec 関数がたくさんある!!

さて、tail rec が重要、ということを頭に入れて list.ml を読んでみると、、、

あれれれ、全然 tail rec じゃないよ!?

例えば、List.map を見てみましょう:

let rec map f = function
    [] -> []
  | a::l -> let r = f a in r :: map f l

最後が r :: map f l になっている。Tail rec ではありません。ということは、、、

# let rec list st  = function 0 -> st | n -> list (n :: st) (n-1);;
# let _ = List.map (fun x -> x) (list [] 300000);; (* 上の環境と同じなので、この回数でスタックを使い果たす *)
Stack overflow during evaluation (looping recursion?)

案の定 Stack overflow してしまいます。

List.map をはじめとして、List モジュールの多くの関数は tail rec ではありません。そのため、与えるリストの深さが深すぎて再帰の回数を多くなりすぎると Stack overflow します。

これはライブラリの欠陥ではなく、敢えて選んだ物だと OCaml コンパイラ開発プロジェクトの Xavier Leroy が次の様に説明しています:

Let me put this another way. There are some library functions for
which no "one size fits all" implementation exist: i.e. the
implementation favors one style of use over others. For List.map or
list concatenation, for instance, one has to choose between running at
full speed on small to medium-sized lists, or being tail-recursive and
handling arbitrarily large lists at a significant cost in speed and
clarity of implementation. I chose the former approach. So, if you
really need to handle large lists, you may have to write your own
(tail-rec and slow) map function. It's not really code duplication,
in that your map function will have quite different running behavior
than the one in the standard library.

http://groups.google.com/group/fa.caml/msg/01e80aadf16837d6?pli=1

Tail rec の map は stack overflow はしない代わりに、 non tail rec バージョンの map が使う二倍の量をヒープで使用するため、効率が悪い。そのため、 non tail rec のわかりやすく効率の良い実装の方を選んだとの事。小中サイズのリストでは実際 non tail rec バージョンの方が早いのです。大きいリストを扱いたくて tail rec 判が欲しかったら自分で書いておくれ、とも書いています。続きでは、そもそも List.map に 100000 depth 以上のリストを突っ込む時点で何かデータ型の使い方おかしいやろ、というツッコミも続きに書いてあります。

この話は List モジュールの他の non tail rec な関数にも当てはまります。Non tail rec はわかりやすい定義で、効率が良いが、 Stack_overflow する可能性がある。なんとかとハサミは使い様。この辺を理解して使ってください。

では、小中のリストでは non tail rec で効率よく、大きいリストでは tail rec を使って Stack_overflow しない、といういいとこ取りの map の実装は書けるでしょうか。 http://ocaml.janestreet.com/?q=node/71 をご覧ください。

関数紹介

length

実直にパターンマッチを使って長さを数えるので深さに比例した時間がかかります。それが嫌な場合は他のデータ型を使うか、自分で長さを別情報として持つリストを実装してください。

hd, tl

もちろん、head, tail の略です。[ ] が与えられると Failure 例外を発生しますので、明らかに引数が非 null リストであるとわかっている時くらいにしか使いません。普段はパターンマッチを使って [ ] の場合も押さえた書き方をした方がよいでしょう。

パターンマッチに不慣れで car, cdr が無いと心配でしょうがない lisper のための関数ですね。

rev

さすがに、 rev は tail rec で書いてあります。ナイーブに書くと Stack_overflow 以前に計算量が爆発しますから。(Tail rec バージョンの map の非効率性とは比べ物にならないほど爆発する。)

let rec dame_rev = function
  | [] -> []
  | x::xs -> dame_rev xs @ [x]   (* アッー何てことだ *)

どうダメかわからない方は是非自分で実験される事をおすすめします。なぜだかよく理解できなくても構いません。理解も出来ない上に試して実感もしない、という方は関数型言語は使わないで配列しかない言語を使った方がお互いのためだと思います。さようなら。

rev_append, rev_map

rev と同じく tail rec です。append や map と比べると効率がよいですが、結果の要素の順序が引数の物と逆になります。(これを逆にしないでやると tail rec バージョンの map の様に効率に問題が出てきます。)

  • 順序が逆でも構わないとき
  • 順序が逆だと却ってうれしいとき
  • 順序が逆になる関数を二回使うので、結果は元通りでうれしいとき

に使うと効果的です。

他の tail rec な関数、例えば、List.fold_left も内部でリストを構成していくと、逆順になりますね。

fold_left, fold_right

fold (折りたたみ)は accumulator とリスト要素を受け取って計算を行い、新しい accumulator の値を得て、次のリスト要素へと計算を進めていく関数です。fold の使い方はこれまたいろんな入門書に嫌というほど解説されてますから、そちらを読んでもらうことにします。

しかし、fold_left と fold_right の違いや型、リスト要素が関数に適用されていく順序が覚えにくいですね。OCaml の fold には良い覚えかたがあります。

fold_left/right では、 Accumulator はリスト要素の 左/右 に来る。リストの要素は 左/右 から関数に適用されていく。

val fold_left : ('acc -> 'l -> 'acc) -> 'acc -> 'l list -> 'acc
(* Tail rec。 fold_left は accumulator が left。リスト要素は left から関数に与えられていく。 *)

val fold_right : ('l -> 'acc -> 'acc) -> 'l list -> 'acc -> 'acc
(* Non tail rec。 fold_right は accumulator が right。リスト要素は right から関数に与えられていく。*)

リスト要素の適用順は関数中で副作用が起こるとき重要です。

Haskell の同等の関数 foldl, foldr は引数順が OCaml の List.fold_left, List.fold_right とは違います。このため両者を同時に使いこなすのは非常に難しい(笑)。

初心者の方は fold で大体つまづきます。踏ん張りどころです。頑張ってください。注意点を書きます:

fold で書けるなら fold を絶対使おう

fold に慣れていない人は、 fold を使いさえすればいい所なのに、自前の再帰関数を定義してしまうことがよくあります。これは止めましょう。fold は関数型言語プログラミングでの超基本語彙なので、fold は fold と書いた方が、他の人に解り易いし、後で自分がコードを読むときにもわざわざ再帰関数の定義をなぞらなくても済みます。強調します:

再帰関数を定義したら、それが fold で書き直せるかどうか検討せよ

ちょっと誇張が入ってるかもしれませんが、これくらい重要です。頑張っているうちに自然と fold が身につくはずです。

fold_left をなるべく使おう

fold_right は non tail rec なので。left で簡単に書けるものを right で書いてはいけません。政治信条はともかく、List.fold は left でお願います。

mem, assoc

リストが小さければ大丈夫ですが、あまりに大きくなるなら、Set や Hashtbl を使うことを検討しましょう。

stable_sort, sort, fast_sort

なんだか .mli に色々違いについて述べられていますが、、、実際の所、すべて同じです。(${srcdir}/list.ml 参照)。

終わり。次回は Printf

教科書っぽい所は飛ばしまくったので、わかりにくかった所もあると思いますが、OCaml の List で注意するべき点は大体挙げることが出来たのではないかと思います。まーとにかく、スタックと計算量のことをぼんやりとでもいいから意識して頂ければ。

次回は楽しく便利な Printf です。これまたいろいろと薀蓄がありましてな…