ノートの端の書き残し

UnityやらC#やら。設計が得意かもしれない。

ローカル関数とラムダ式

私は普段仕事でも趣味でもUnityを使っているので、基本的に用いる言語はC#です。

ゲームプログラミングで重要なのはやはり速度ですが、複数人で開発したり長期的に改修が続く場合は可読性も大切です。 Unityでは読みやすいC#でプログラミングして、それが最終的に速いC++に変換することで、可読性と速度をそれなりに担保している、と私は考えています。 ただ、その際に結構大胆な変換が行われていて、単にC#の文法を学ぶだけでは、実は速度的に不利な書き方をしている、ということがよくあります。

C#コンパイルされてIL(中間言語)になり、さらにIL2CPP(その名の通り、IL to C++ということ)でC++に変換されるわけですが、一つ目のILへの変換を簡単に見ることができるのがSharpLab(https://sharplab.io/)
ILへの変換でどのような変更が行われているのかを逆コンパイル結果として見せてくれます。
例えばC#7.0以降で使用できるローカル関数という機能があります。
これは、関数の中で関数を定義できるというもので、ラムダ式で対応していたであろうところをこれで置き換えられます。

public class C {
    public void M() {
        void localMethod() {    }
    }
}

こういう感じですね。
ラムダ式は必ずデリゲートが生成され、外の変数を用いる際にはクロージャとしてクラスインスタンスが生成されるのですが、ローカル関数の場合は不要であればデリゲートは生成されず、クロージャも可能な場合は構造体が使用されます。このため、ラムダ式をローカル関数で置き換えることでパフォーマンス改善が見込める場合があるわけです。

こういうことをどこで勉強するかというと結構難しいのですが、試しに上のコードをSharpLabに書いてみると、このような逆コンパイル結果が得られます。

public class C
{
    public void M()
    {
    }

    [CompilerGenerated]
    internal static void <M>g__localMethod|0_0()
    {
    }
}

ローカル関数として定義したはずのlocalMethodがクラス関数になっていますね。
さらになんかstaticにされています。 なんでだろう……

さて、ラムダ式と比較するためにローカル関数をActionとして渡してみましょう。
以下のコードをSharpLabに入力します。

public class C {
    public void M() {
        Hoge(localMethod);
        
        void localMethod() {
            Console.WriteLine("スマブラやりたい");
        }
    }
    
    public void Hoge(Action fuga) {
        fuga();
    }
}

するとこうなります。

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

        internal void <M>g__localMethod|0_0()
        {
            Console.WriteLine("スマブラやりたい");
        }
    }

    public void M()
    {
        Hoge(<>c.<>9.<M>g__localMethod|0_0);
    }

    public void Hoge(Action fuga)
    {
        fuga();
    }
}

次はラムダ式で同様のスクリプトを書いてみます。

public class C {
    public void M() 
    {
        Hoge(() => Console.WriteLine("スマブラやりたい"));
    }
    
    public void Hoge(Action fuga)
    {
        fuga();
    }
}

これを入力すると

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

        public static Action <>9__0_0;

        internal void <M>b__0_0()
        {
            Console.WriteLine("スマブラやりたい");
        }
    }

    public void M()
    {
        Hoge(<>c.<>9__0_0 ?? (<>c.<>9__0_0 = <>c.<>9.<M>b__0_0));
    }

    public void Hoge(Action fuga)
    {
        fuga();
    }
}

先ほどのローカル関数と少し違いがありますね。
public static Action <>9__0_0というデリゲートが生成されており、且つそれのnullチェックが入っています。
このように勝手にnewが追加されちゃうので、この場合はローカル関数を使いましょうとなるわけです。 SharpLab、便利ですね。本当はILに変換されたさらに後の最適化も大切ですが、この段階でも簡単に確認できるのはありがたいです。
最後に、スマブラやりたいという文字列をMの中で変数として定義し、ローカル関数、ラムダ式に渡してみたパターンを見てみましょう。 ローカル関数パターン

public class C {
    public void M() {
        string str = "スマブラやりたい";
        localMethod();
        
        void localMethod() {
            Console.WriteLine(str);
        }
    }
}

これを入力すると

public class C
{
    [StructLayout(LayoutKind.Auto)]
    [CompilerGenerated]
    private struct <>c__DisplayClass0_0
    {
        public string str;
    }

    public void M()
    {
        <>c__DisplayClass0_0 <>c__DisplayClass0_ = default(<>c__DisplayClass0_0);
        <>c__DisplayClass0_.str = "スマブラやりたい";
        <M>g__localMethod|0_0(ref <>c__DisplayClass0_);
    }

    public void Hoge(Action fuga)
    {
        fuga();
    }

    [CompilerGenerated]
    internal static void <M>g__localMethod|0_0(ref <>c__DisplayClass0_0 P_0)
    {
        Console.WriteLine(P_0.str);
    }
}

ラムダ式パターン

public class C {
    public void M() {
        string str = "スマブラやりたい";
        Hoge(() => Console.WriteLine(str));
    }
    
    public void Hoge(Action fuga) {
        fuga();
    }
}

これを入力すると

public class C
{
    [CompilerGenerated]
    private sealed class <>c__DisplayClass0_0
    {
        public string str;

        internal void <M>b__0()
        {
            Console.WriteLine(str);
        }
    }

    public void M()
    {
        <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
        <>c__DisplayClass0_.str = "スマブラやりたい";
        Hoge(<>c__DisplayClass0_.<M>b__0);
    }

    public void Hoge(Action fuga)
    {
        fuga();
    }
}

ローカル関数およびラムダ式に変数を渡すために用意された<>c__DisplayClass0_0というクロージャが、ローカル関数のときはstructに、ラムダ式のときはclassになっていますね。 当然パフォーマンス的に構造体の方がヒープ領域を使用しない分良く、やはりローカル関数を選ぶべきケースだとわかります。
しかし、ローカル関数の方もラムダ式の方と同じく、Hoge(Action fuga)に渡して実行されるようにするとクロージャはclassになってしまいます……
ローカル関数は可能であればメンバ関数にクラスチェンジできる、ここが、ラムダ式との大きな違いであるわけですね。
ちなみにですが、この場合は引数としてstrを渡してあげればクロージャを用意する必要も無いです(それはそう)。

このように、どっちでもいいじゃんと思ってしまいそうなところでも実はほんのちょっと書き方を変えるだけでパフォーマンスが良くなったり悪くなったりするので、文法を学ぶ際は実行時にできるだけ近い状態を調べることが大切です。

こういう便利なものが個人でも使えるんだからありがたいですよね。