delegateに代入された特定条件を満たしたラムダに関する一考察。

ある方に相談したら、何となくわかってきたので、後でまた忘れたときのために。
で、満たすべき条件とは

  • 閉包*1を使ってない

と言う点、この条件が意味するところは、ラムダ式をStatic methodとしてコンパイル可能であるという点。逆に、閉包を使ってないのなら、ブロックにしてしまって、複数行にわたって書いても構わないみたい。*2

この条件を満たしたサンプルは以下の通り

using System;

namespace ConsoleApplication1
{
	internal class Program
	{
		private static void Main(string[] args)
		{
			Func<int, int, int> add = (x, y) => x + y;
			Action<int> twiceAndPrint = _ => Console.WriteLine(_*2);

			var result = add(10, 20);
			twiceAndPrint(result);
		}

	}
}

さて、こいつをコンパイルしたとき、cscはどのようなILを吐くのか調べてみたところ、C#で、再構築してみると概ね以下のような形になった。

using System;

namespace ConsoleApplication1
{
	internal class Program
	{
		private static Func<int, int, int> CachedAnonymousMehod0;
		private static Action<int> CachedAnonymousMehod1;

		private static void Main(string[] args)
		{
			//隠しキャッシュがnullだったら、登録する。
			if (CachedAnonymousMehod0 == null)
			{
				CachedAnonymousMehod0 = new Func<int, int, int>(AddImpl);
			}

			if (CachedAnonymousMehod1 == null)
			{
				CachedAnonymousMehod1 = new Action<int>(TwiceAndPrintImpl);
			}

			//Func<int, int, int> add = (x, y) => x + y;
			Func<int, int, int> add = CachedAnonymousMehod0;

			//Action<int> twiceAndPrint = _ => Console.WriteLine(_*2);
			Action<int> twiceAndPrint = CachedAnonymousMehod1;

			//var result = add(10, 20);
			int result = add(10, 20);

			//twiceAndPrint(result);
			twiceAndPrint(result);
		}


		//(x, y) => x + y
		private static int AddImpl(int x, int y)
		{
			return x + y;
		}

		//_=>Console.WriteLine(_*2)
		private static void TwiceAndPrintImpl(int _)
		{
			Console.WriteLine(_*2);
		}

	}
}

実際には、ラムダ式の部分は、そのクラスのprivate staticなメソッドに展開され、デリゲートのキャッシュを持つことになる。
じっさいここまでは、以前から調べは付いていたけど、何故またキャッシュするのか?と言う点に関して、尤もらしい理由を付けることが出来なかった*3
で、先に戻って、ある方に相談してその会話の中でふと思いついたのが、

  • 実装の単純性

って点だった。これは何かというと、恐らく、コンパイラがキャッシュを生成してデリゲートの構築コストを省こうとするコンパイル結果を生成する理由として、参照先のラムダ式は概ね単純だろうと言う仮定に基づいているのでは無いかなと。
思い返すと、Funcに代表されるようなPredicate系でそう複雑なコトさせることはあんまり無いし、だいたいワンライナーで記述できる場合が多い*4
この場合、もしかしたら

  • デリゲート経由で実装を呼び出して結果を返す時間的コスト<デリゲートを1回1回キャッシュ無しで生成する時間的コスト

となり得ることが多いと仮定して、もしかしたら繰り返しエンクロージャ側のメソッドが呼ばれるコトを想定して、キャッシュしてるのでは無いかなと*5
逆に、一般的なメソッドを参照させるときは、別のメソッドとして存在してるくらいだから、それなりに中でやってることも複雑だろう、時間がかかるだろうと言うことと、恐らく、静的メソッドと、インスタンスメソッドを分けて考えるようなことはしないために、キャッシングしないのじゃ無いかと思う。

と言うことで、備忘録的なメモ。

*1:クロージャと書いてしまうと、プログラミング言語Clojureと判別が付かないので、最近は、閉包とかClosureと書いてる。

*2:逆にClosureを使うと、コンパイル時に利用している変数をパブリックなメンバ変数とした、隠しクラスが出来て、λ自体はそのクラスのインスタンスメソッド扱いになる。

*3:ちなみに、ラムダを使わない普通のデリゲート構築の際は、上記のようなキャッシュを作ることはしない。

*4:あくまで、個人の経験則では、ですが。

*5:さらに言うなら、キャッシュが強い参照となって効いてくるので、GCから見てもコレクトする必要が無くなる。良くも悪くも。