てくメモ

trivial な notes

【C#】デリゲートを取る引数に ラムダ式 / 各種メソッド / ローカル関数 を渡した際の違いを確認する

自分の認識と少し状況が変わっているという話を見聞きし、まとめて確認。

  • ラムダ式インスタンスメソッド、staticメソッド、拡張メソッド、ローカル関数を渡す
  • メンバーやローカル変数をキャプチャしないのを前提
  • .NET 8 / C# 12


ケースをフォーカスしてしまっているので、キャプチャが入る場合などを含めて解説されている参考文献を最初に示す。




TL;DR

  • 選べるならラムダ式
  • インスタンスメソッドはキャッシュされないため、毎回デリゲートがnewされる
  • staticメソッドやローカル関数はキャッシュされるが、ラムダ式で包むとお得

ベンチマーク

最初にベンチマークを示す。

ベンチマークコードもここで折りたたむが、適宜抜粋する。

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
public class DelegateBenchmark
{
    private object target = default!;

    [GlobalSetup]
    public void Setup()
    {
        target = new();
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private object Instance(object obj) => obj;
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static object Static(object obj) => obj;

    private Func<object, object>? cache;

    [Benchmark(Baseline = true, Description = "Non-delegate")]
    public object NonDelegate() => Instance(target);
    [Benchmark(Description = "Lambda")]
    public object Lambda() => target.Func(static obj => obj);
    [Benchmark(Description = "Instance method")]
    public object InstanceMethod() => target.Func(Instance);
    [Benchmark(Description = "Instance method with lambda")]
    public object InstanceMethodLambda() => target.Func(obj => Instance(obj));
    [Benchmark(Description = "Instance method with caching")]
    public object InstanceMethodWithCaching() => target.Func(cache ??= new(Instance));
    [Benchmark(Description = "Static method")]
    public object StaticMethod() => target.Func(Static);
    [Benchmark(Description = "Static method with lambda")]
    public object StaticMethodLambda() => target.Func(obj => Static(obj));
    [Benchmark(Description = "Extension method")]
    public object ExtensionMethod() => target.Func(this.Extension);
    [Benchmark(Description = "Extension method with lambda")]
    public object ExtensionMethodLambda() => target.Func(obj =>  this.Extension(obj));
    [Benchmark(Description = "Local function")]
    public object LocalFunction()
    {
        static object local(object obj) => obj;
        return target.Func(local);
    }
    [Benchmark(Description = "Local function with lambda")]
    public object LocalFunctionLambda()
    {
        static object local(object obj) => obj;
        return target.Func(obj => local(obj));
    }
}

file static class Ex
{
    public static T Func<T>(this T obj, Func<T, T> func) => func(obj);

    public static object Extension(this object _, object obj) => obj;
}

コンパイラ展開コード折りたたみ(確認用。長い。利用:SharpLab)

[NullableContext(1)]
[Nullable(0)]
public class DelegateBenchmark
{
    [CompilerGenerated]
    private static class <>O
    {
        [Nullable(0)]
        public static Func<object, object> <0>__Static;

        [Nullable(0)]
        public static Func<object, object> <1>__local;
    }


    [Serializable]
    [CompilerGenerated]
    private sealed class <>c
    {
        [Nullable(0)]
        public static readonly <>c <>9 = new <>c();

        [Nullable(0)]
        public static Func<object, object> <>9__6_0;

        [Nullable(0)]
        public static Func<object, object> <>9__11_0;

        [Nullable(0)]
        public static Func<object, object> <>9__15_1;

        [NullableContext(0)]
        internal object <Lambda>b__6_0(object obj)
        {
            return obj;
        }

        [NullableContext(0)]
        internal object <StaticMethodLambda>b__11_0(object obj)
        {
            return Static(obj);
        }

        [NullableContext(0)]
        internal object <LocalFunctionLambda>b__15_1(object obj)
        {
            return <LocalFunctionLambda>g__local|15_0(obj);
        }
    }

    private object target;

    [Nullable(new byte[] { 2, 1, 1 })]
    private Func<object, object> cache;

    public void Setup()
    {
        target = new object();
    }

    private object Instance(object obj)
    {
        return obj;
    }

    private static object Static(object obj)
    {
        return obj;
    }

    public object NonDelegate()
    {
        return Instance(target);
    }

    public object Lambda()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, <>c.<>9__6_0 ?? (<>c.<>9__6_0 = new Func<object, object>(<>c.<>9.<Lambda>b__6_0)));
    }

    public object InstanceMethod()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, new Func<object, object>(Instance));
    }

    public object InstanceMethodLambda()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, new Func<object, object>(<InstanceMethodLambda>b__8_0));
    }

    public object InstanceMethodWithCaching()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, cache ?? (cache = new Func<object, object>(Instance)));
    }

    public object StaticMethod()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, <>O.<0>__Static ?? (<>O.<0>__Static = new Func<object, object>(Static)));
    }

    public object StaticMethodLambda()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, <>c.<>9__11_0 ?? (<>c.<>9__11_0 = new Func<object, object>(<>c.<>9.<StaticMethodLambda>b__11_0)));
    }

    public unsafe object ExtensionMethod()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, new Func<object, object>(this, (nint)(delegate*<object, object, object>)(&<_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Extension)));
    }

    public object ExtensionMethodLambda()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, new Func<object, object>(<ExtensionMethodLambda>b__13_0));
    }

    public object LocalFunction()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, <>O.<1>__local ?? (<>O.<1>__local = new Func<object, object>(<LocalFunction>g__local|14_0)));
    }

    public object LocalFunctionLambda()
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Func(target, <>c.<>9__15_1 ?? (<>c.<>9__15_1 = new Func<object, object>(<>c.<>9.<LocalFunctionLambda>b__15_1)));
    }

    [NullableContext(0)]
    [CompilerGenerated]
    private object <InstanceMethodLambda>b__8_0(object obj)
    {
        return Instance(obj);
    }

    [NullableContext(0)]
    [CompilerGenerated]
    private object <ExtensionMethodLambda>b__13_0(object obj)
    {
        return <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex.Extension(this, obj);
    }

    [CompilerGenerated]
    internal static object <LocalFunction>g__local|14_0(object obj)
    {
        return obj;
    }

    [CompilerGenerated]
    internal static object <LocalFunctionLambda>g__local|15_0(object obj)
    {
        return obj;
    }
}

[NullableContext(1)]
[Nullable(0)]
[Extension]
internal static class <_>FD2E2ADF7177B7A8AFDDBC12D1634CF23EA1A71020F6A1308070A16400FB68FDE__Ex
{
    [Extension]
    public static T Func<[Nullable(2)] T>(T obj, Func<T, T> func)
    {
        return func(obj);
    }

    [Extension]
    public static object Extension(object _, object obj)
    {
        return obj;
    }
}

| Method                       | Mean     | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|------------------------------|---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Non-delegate                 | 1.978 ns | 0.7825 ns | 0.0429 ns |  1.00 |    0.00 |      - |         - |          NA |
| Lambda                       | 1.998 ns | 0.4824 ns | 0.0264 ns |  1.01 |    0.02 |      - |         - |          NA |
| Instance method              | 9.886 ns | 1.4419 ns | 0.0790 ns |  5.00 |    0.14 | 0.0204 |      64 B |          NA |
| Instance method with lambda  | 9.474 ns | 1.0506 ns | 0.0576 ns |  4.79 |    0.12 | 0.0204 |      64 B |          NA |
| Instance method with caching | 2.899 ns | 0.4433 ns | 0.0243 ns |  1.47 |    0.04 |      - |         - |          NA |
| Static method                | 3.719 ns | 0.3771 ns | 0.0207 ns |  1.88 |    0.05 |      - |         - |          NA |
| Static method with lambda    | 2.502 ns | 1.3206 ns | 0.0724 ns |  1.27 |    0.06 |      - |         - |          NA |
| Extension method             | 9.953 ns | 4.8201 ns | 0.2642 ns |  5.03 |    0.12 | 0.0204 |      64 B |          NA |
| Extension method with lambda | 9.380 ns | 6.6819 ns | 0.3663 ns |  4.75 |    0.25 | 0.0204 |      64 B |          NA |
| Local function               | 3.744 ns | 0.4856 ns | 0.0266 ns |  1.89 |    0.03 |      - |         - |          NA |
| Local function with lambda   | 2.205 ns | 3.9868 ns | 0.2185 ns |  1.12 |    0.13 |      - |         - |          NA |
  • 単純なラムダ式 (Lambda) は、メソッド直呼び(Non-delegate)に迫った
  • インスタンスメソッド (Instance method) は、キャッシュが行われない(毎回newされる)
    • それを防ぐには、自前でキャッシュ (with caching) する
  • staticメソッド (Static method) はキャッシュされるが、単純なラムダ式の方が速度面では優れる
    • ラムダ式で包むと (with lambda)、速度が向上する
  • ローカル関数 (Local function)は、staticメソッドと同様の性質を持っているよう

個別

以下のような拡張メソッドに素通しのラムダ式等を渡していく。

file static class Ex
{
    public static T Func<T>(this T obj, Func<T, T> func) => func(obj);
}

ラムダ式

// object target;
public object Lambda() => target.Func(static obj => obj);

ラムダ式の内容をインスタンスメソッドに持ち、それをキャッシュするフィールドを持つクラスを、コンパイラが生成する。

インスタンスメソッド

[MethodImpl(MethodImplOptions.NoInlining)]
private object Instance(object obj) => obj;

private Func<object, object>? cache;

public object InstanceMethod() => target.Func(Instance);
public object InstanceMethodLambda() => target.Func(obj => Instance(obj));
public object InstanceMethodWithCaching() => target.Func(cache ??= new(Instance));

メンバーやローカル変数をキャプチャしなくても毎回newされる。インスタンスメソッドなので実質thisがキャプチャされるようなものであり、致し方なし。

staticメソッド

[MethodImpl(MethodImplOptions.NoInlining)]
private static object Static(object obj) => obj;

public object StaticMethod() => target.Func(Static);
public object StaticMethodLambda() => target.Func(obj => Static(obj));

staticメソッドを単に渡す場合、コンパイラstaticクラスを生成し、そのフィールドにstaticメソッドをキャッシュする。

そしてstaticメソッドは、なんとデリゲートの仕様上の不利を背負っているとのこと。

なので、そのまま渡すのではなくラムダ式に包むと、ラムダ式の項で述べたようにインスタンスメソッドのデリゲートとなることで速度が向上する。

ある時期までのバージョンはそのまま渡すだけだとキャッシュされなかったため、ラムダ式にするというのが手筋になっていたはず。一方現在、キャッシュされるしIDEからラムダ式削除の推奨が出るが、ラムダ式がお得という何とも微妙な感じになってしまっている。

拡張メソッド

file static class Ex
{
    public static object Extension(this object _, object obj) => obj;
}
public object ExtensionMethod() => target.Func(this.Extension);
public object ExtensionMethodLambda() => target.Func(obj =>  this.Extension(obj));

今回のような場合、インスタンスメソッドと同様であった。

「拡張メソッドは」みたいな書き方をするならケース分けがあるべき(少なくとも拡張先がthisでない場合とか)だと思ったので、今回は触れる程度に。

ローカル関数

public object LocalFunction()
{
    static object local(object obj) => obj;
    return target.Func(local);
}

public object LocalFunctionLambda()
{
    static object local(object obj) => obj;
    return target.Func(obj => local(obj));
}

この場合、ローカル関数をそのまま渡すとstaticメソッドをそのまま渡したときと同じ扱いだった。

そのままでキャッシュされるしIDEからラムダ式消去の推奨が出るが、ラムダ式で包むと速度が向上するのも同様。