関数引数の遅延評価
遅延評価とは、ある式を、その結果が本当に必要になる時点までは 評価しないでおくテクニックです。 論理演算子 &&, || や三項演算子 ?: は、 従来からある遅延評価を行う手法です:
void test(int* p) { if (p && p[0]) ... }
二番目の式 p[0] は、p が null でないときに限り評価されます。 もし仮に二番目の式が遅延評価されないとすると、 p が null のときには実行時エラーとなってしまうでしょう。
遅延評価演算子は実に有益なものではありますが、同時に、無視できない制限も存在します。 ログ取り関数を考えてみましょう。メッセージのログをとるもので、 グローバルな設定値によって実行時に ON/OFFを切り替えられるものとします:
void log(char[] message) { if (logging) fwritefln(logfile, message); }
メッセージ文字列が実行時に作られることはよくあります:
void foo(int i) { log("Entering foo() with i set to " ~ toString(i)); }
これは問題なく動作しますが、ログ取り機能のON/OFFにかかわらず メッセージ文字列の構築が行われてしまうのが問題です。 ログ機能を頻繁に使うアプリケーションでは、 これはパフォーマンス上の重大な問題となるかのうせいがあります。
解決策のひとつは、遅延評価をつかうことでしょう。
void foo(int i) { if (logging) log("Entering foo() with i set to " ~ toString(i)); }
しかし、これはログ取り機能の詳細をユーザーにさらすことになり、 カプセル化の原則に反します。C言語では、この手の問題はよく マクロを使って対処します:
#define LOG(string) (logging && log(string))
しかしこれも本質的な解決にはなっていません。プリプロセッサマクロには、 ご存じの通りいくつもの欠点があります:
- 変数 logging がユーザーの名前空間に公開される
- マクロはデバッガに認識されない
- マクロはグローバルにしか使えず、スコープを切れない
- マクロはクラスのメンバにできない
- マクロはそのアドレスを取得することができず、 関数のように間接的に持ち回ることができない
堅固な解決方法は、 関数引数の遅延評価を用いるものです。 D言語では、引数をdelegateにすることでこれが実現できます:
void log(char[] delegate() dg) { if (logging) fwritefln(logfile, dg()); } void foo(int i) { log( { return "Entering foo() with i set to " ~ toString(i); }); }
こうすると、文字列構築の式はloggingがtrueの時のみ実行されますし、 カプセル化も保たれています。唯一の問題点は、 式を { return exp; } で囲むのがいまいち好ましくないというところです。
そこで D では、(Andrei Alexandrescu の提案による) 小さいけれど不可欠な一歩を進めました。 任意の式は、 void ないしはその式の型を返す delegate へと 暗黙変換できるようになっています。 (Tomasz Stachowiak の提案により、このdelegate宣言は lazy 記憶域クラスを仮引数に指定する形式に変更されました。) これを使うと foo 関数は:
void log(lazy char[] dg) { if (logging) fwritefln(logfile, dg()); } void foo(int i) { log("Entering foo() with i set to " ~ toString(i)); }
元々の形と全く同じになりました。しかし今度は、 メッセージ文字列はログ取り機能がONの時のみ構築されます。
さて、似たようなパターンがコード中に繰り返されることはよくあります。 そのようなパターンを抽象化してカプセル化すれば、 コードの複雑性、ひいてはバグを減らすことにつながります。 このような抽象化のもっとも一般的な例は、 関数そのものです。 遅延評価は、他のパターンをカプセル化するための土台としても有効です。
簡単な例として、ある式が count 回実行される、というものを考えてみましょう。パターンは:
for (int i = 0; i < count; i++) exp;
です。このパターンは、遅延評価を使うことで関数にカプセル化できます:
void dotimes(int count, lazy void exp) { for (int i = 0; i < count; i++) exp(); }
これは以下のように使用します:
void foo() { int x = 0; dotimes(10, writef(x++)); }
出力結果はこうなります:
0123456789
もっと複雑なユーザー定義の制御構造を定義することも可能です: 以下は、switchに似た構造を作る例です:
bool scase(bool b, lazy void dg) { if (b) dg(); return b; } /* 可変個引数の場合は特殊ケースとして、 delegateでない実引数もdelegateに暗黙変換されます。 */ void cond(bool delegate()[] cases ...) { foreach (c; cases) { if (c()) break; } }
使用法は:
void foo() { int v = 2; cond ( scase(v == 1, writefln("it is 1")), scase(v == 2, writefln("it is 2")), scase(v == 3, writefln("it is 3")), scase(true, writefln("it is the default")) ); }
出力結果は:
it is 2
プログラミング言語 Lisp になじみのある方は、 Lisp のマクロとの類似に気づかれるかもしれません。
最後の例として、よくあるパターンを取り上げてみます:
Abc p; p = foo(); if (!p) throw new Exception("foo() failed"); p.bar(); // ここで p を使う
throw は文であって式ではないため、この一連の処理を行う式を書こうとしても、 どうしても複数の文に分けて 余分な変数を導入する必要がでてきてしまいます。 (この問題についての綿密な考察が、Andrei Alexandrescu と Petru Marginean の論文 Enforcements で為されています)。 遅延評価を使うと、これは全てひとつの関数に カプセル化することができます:
Abc Enforce(Abc p, lazy char[] msg) { if (!p) throw new Exception(msg()); return p; }
先ほどの例は簡単に:
Enforce(foo(), "foo() failed").bar();
と、5行から1行に減りました。Enforce は、テンプレート関数にすると さらに有用です:
T Enforce(T)(T p, lazy char[] msg) { if (!p) throw new Exception(msg()); return p; }
まとめ
関数引数の遅延評価は、関数の表現力を劇的に拡張します。 さまざまな頻出コードパターンやイディオムを、 かつては綺麗にカプセル化できなかったものでも、 うまくまとめあげることができます。
謝辞
Andrei Alexandrescu, Bartosz Milewski, David Held のひらめきと力添えに、厚く感謝いたします。 また、D言語コミュニティからの建設的な批判 (例えば Tomasz Stachowiak の投稿 D/41633 から始まるスレッドなど) が、大きな助けとなっています。