関数引数の遅延評価
遅延評価とは、ある式を、その結果が本当に必要になる時点までは 評価しないでおくテクニックです。 論理演算子 &&, || や三項演算子 ?: は、 従来からある遅延評価を行う手法です:
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 記憶域クラスを仮引数に指定する形式に変更されました。) これを使うと先ほどの関数は:
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 から始まるスレッドなど) が、大きな助けとなっています。