効率の追及


OCaml と関数型プログラミングに慣れてきたでしょうか。ここでは コードをより高速にするために注意すべき点を幾つか挙げます。

末尾再帰

私は末尾再帰じゃない再帰関数は極力 使用・作成しないようにしています。 でないと扱うデータがかなり大きいので、結局どこかで stack overflow を起してしまうからです。

まず再帰的な関数とは何だったかと言うと、 関数の中で、自分自身を呼び出しているような関数の事でした。末尾再帰な 関数呼び出しとはその中でも、自分自身を呼び出した結果がそのまま、関数の返り値に なるような再帰の仕方の事でした。

末尾再帰の何が嬉しいのでしょうか?末尾再帰で無い場合は、 自分自身を呼び出した後で、その結果が得られるまで、どこに 戻ってくるか等を覚えておき、結果を得られた後でなんらかの演算を行なって 最終的な結果を返す、と言う事が行なわれています。 しかし、末尾再帰な関数呼び出しの場合は自分自身を呼び出した結果がそのま ま最終的な結果になるので、戻ってくる必要が無く、場所等を覚えておく必要も無い、 と言う事にあります。 関数呼び出しは普通、どこに戻って来るかを覚えておくために stack を消費しますが、末尾再帰的な関数はコンパイラがそれを自動的に ループ (ジャンプ) に変換してくれるため、stack を消費しなくなります。

注意すべき関数

とても便利ですが気をつけないと 場合によってはかなり遅くなってしまうような、注意すべき関数を紹介します。

リストの結合 ((@): 'a list -> 'a list -> 'a list)

l1 @ l2List.append l1 l2と等価で、 二つのリスト l1l2 をつなぎあわせます。 でも、この関数を使うのは極力避けましょう (^^;。 理由は、末尾再帰でないので、長いリストに対して適用すると stack overflow をおこしてしまうと言う事です。 これと似た関数: List.rev_append がありますが、できれば こっちを使うようにしましょう。List.rev_append l1 l2 は は、最初のリスト l1 を逆順にしたものが、二番目のリスト l2 の前にくっついたような形になります。 List.append との違いは、末尾再帰な実装になった代償として、 リスト内の順番が変化してしまうと言う事です。

どちらの関数も基本的には l2 は変更せずに、その先頭に l1 の要素を一つずつくっつける、と言う操作になるため、 l1の長さに比例する時間がかかる事になります。 と言う事で、二つのリストの、短かい方を最初の引数に持ってくるよう に注意しましょう。 これは、次の文字列の結合と似たような理屈で、ループの中等で使う 場合に特に重要になってきます。

メイリングリストに流れましたが、 がんばれば末尾再帰な append が書けるらしいです。 OCaml に組込まれる日は来るのだろうか。

文字列の結合 ((^): string -> string -> string)

s1 ^ s2 で、文字列 s1s2 を結合して、新しい文字列を作る事ができます。

# "foo" ^ "bar";;
- : string = "foobar"
注意しなければならないのは、元の二つの文字列は変更されずに、 新しい string の値ができて、その 長さは二つの文字列の長さの和になり、値をコピーする事になります。 よって、この関数の実行にはその長さに比例する時間がかかる事になります。 この関数を一回だけ使う時は特に問題無いのですが、loop の中で使う時に 気をつける必要があります。 例えば、ループの中で、文字列を一文字ずつ伸ばしていった 場合にどのくらい時間がかかるかと言うと、最初は 1 文字からはじまり、 次は 2 文字になり、次は 3 文字に.... と言う事になり、 文字列の長さが最終的に 1000 になったとすると、 1+2+...+999+1000 = 500500 もの膨大な数に比例する時間かかってしまう 事になってしまいます。 問題になっているのは、毎回新しい string が生成されているため、 文字列の生成・コピーの回数が膨大になってしまう、と言う事です。 途中の文字列を他で使わない場合にはその手順が激しく無駄になります。 回避する方法としては、最初に長さ 1000 の string を生成して、 1文字づつ(副作用で)代入していく方法が考えられます。 最終的に得られる文字列の長さがわかっている場合はこれが一番良いですが、 わからない場合はどうするのが良いでしょう。文字列が後に伸びていくだけ なのであれば、Buffer モジュールを使えばこれを効率的に行なう事ができま す。

メモリ確保がいつ行なわれるかを理解する

OCaml でのメモリの確保はそれなりに速いそうですが いずれにせよ避けたほうが高速になるのは言うまでも無いでしょう。

"中間"のリストや配列をなるべく作らないようにする

例えば

Array.map g (Array.map f lst)
と関数 f と g でリスト lst の要素を二回変換したい時、 最初の fで変換した (Array.map f lst) で新しく生成された array はもうガベコレ されるのを待つだけです。と言うわけで、
Array.map (fun x -> g (f x)) lst
とした方が 無駄なメモリ確保が起きずに効率が良いと言えるでしょう。

定数変数はグローバルにする

let f x = 
  let arr = [|0;1;2|] in
     ...

のように書いた場合、この 配列 arr は関数 f が呼ばれる度に生成されてしまいます。 配列ではなく、immutable なデータ構造を用いているのであれば、arr が以降 どこにあらわれようとも同じ値になるので、コンパイラが自動的に置き換える 事もできるのですが、 配列は mutable で、後で値が変えられてしまっている可能性がある ために、最適化できません。毎回メモリ確保して欲しく無い場合は手動で 以下のように書換えましょう。

let arr = [|0;1;2|];;
let f x = 
     ...

その他

関数の引数

# let f (x,y) = ...  (* 定義 1 *)

# let f x y = ... (* 定義 2 *)

のどっちが良いのでしょう。前者は下手すると必要の無い tuple を生成してしまい、 後者は(関数の部分適用が無い場合)、下手すると必要の無い closure を作ってしまいます。 OCaml の byte code コンパイラは定義2 の方が最適化がかかり、効率が良いそうです。 native code コンパイラの場合はどちらも最適化されるようです。 というわけで迷ったら curry 化した方にしましょう。