【C#】ラムダのローカル変数のキャプチャーを回避する

string.Create の実装を見て気が付いた内容です。

ラムダの式の変数キャプチャーを回避して実行時に生成されるデリゲートでヒープのメモリ確保を削減できるという内容になります。

ラムダ式の変数キャプチャーとは

ラムダ式内からローカル変数やフィールドを参照することを変数のキャプチャーと言います。

以下のようなコードを書くと変数がキャプチャーされます。

public void Capture()
{
    // ★キャプチャー対象のローカル変数
    int localValue1 = 20;
    int localValue2 = 40;
    
    Func<int> func = () => 
    {
        // ★外部の変数をラムダ内で参照する
        return localValue1 + localValue2 * 2;
    };
    func();
}

このように変数をキャプチャーすると毎回デリゲートが作成されヒープにメモリが確保されます(定数や不変の値を使用する場合は最適化されて確保されない場合もあります)

単発なら問題ないですが複数回実行するとヒープのメモリ確保時間だったり、後でGCが発生する時間などでパフォーマンスが低下します。

ここで以下のように少し工夫するとキャプチャーがなくなってヒープの確保がなくなります。

public void NoCapture()
{
    // ★キャプチャー対象のローカル変数
    int localValue1 = 20;
    int localValue2 = 40;
    
    // ★引数で受け取るように変更
    Func<int, int, int> func = (value1, value2) => 
    {
        // ★外部の変数をラムダ内で参照する
        return value1 + value2 * 2;
    };
    func(localValue1, localValue2);
}

上記処理を1000回繰り返したときの BenchmarkDotNet のパフォーマンス測定結果です。

| Method     | Mean       | Error      | StdDev    | Rank | Gen0    | Allocated |
|----------- |-----------:|-----------:|----------:|-----:|--------:|----------:|
| Capture    | 8,362.0 ns | 4,796.0 ns | 262.89 ns |    2 | 15.3046 |   96024 B |
| NoCapture  |   414.2 ns |   218.0 ns |  11.95 ns |    1 |       - |         - |

引数にすると Allcated が 0になり実行時間かなり高速化されました。

また、以下のようにローカル関数にすることもできます。

あとフィールド化もできます(が、それくらいならメソッドにしたほうがよさそうです)

public void NoCapture()
{
    int localValue1 = 20;
    int localValue2 = 40;

    // C#8.0からstaticなローカル関数として宣言することもできる
    // static → キャプチャーしないよの宣言
    static int func(int value1, int value2)
    {
        // ★外部の変数をラムダ内で参照する
        return value1 + value2 * 2;
    }
    func(localValue1, localValue2);
}


// クラスのローカル変数にもできる
// ** だが、これするくらいならstaticメソッド化したほうがよい
static readonly Func<int, int> _calc = value => value + value * 2;

この考え方はラムダを受け取るメソッドにも適用出来ます。

よほどのことが無い限りこんな書き方はしないですが、パフォーマンスが特に大切かつライブラリにしなきゃいけないというときに覚えておくとよいかもしれません。

public static class LambdaSample
{
    public static void Capture(Action action)
    {
        action();
    }
}

// 上記の実装に対し、以下のようにラムダを使用すると
// 変数キャプチャーが発生する

public void Capture()
{
    for (int i = 0; i < loops; i++)
    {
        int i2 = i * 2;
        LambdaSample.Capture(() =>
        {
            int ret = i + i2;
        });
    }
}

// - - - - - - - - -
// 以下のようにすると変数キャプチャーがなくなり
// ヒープにデリゲートを毎回取らないことで処理速度と実行効率が向上する

public static class LambdaSample
{
    // ラムダが使用する変数を引数で取得する
    public static void NonCapture<TParam>(TParam param, Action<TParam> action)
    {
        action(param);
    }
}

// このように宣言して以下のように使用する

public void NonCapture()
{
    for (int i = 0; i < loops; i++)
    {
        int i2 = i * 2;
        LambdaSample.NonCapture((i, i2), param =>
        {
            int ret = param.i + param.i2;
        });
    }
}

以上です。