読者です 読者をやめる 読者になる 読者になる

xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP for .NET な管理人の技術ブログです。

ローカル関数

C#

コードを書いていると、稀に関数の中に「そこでしか使わない関数」を作りたくなる場合があります。例えば再帰処理などはその最たるものかと思います。C# 6.0 まではデリゲートを駆使して以下のように書いていました。

static void Main()
{
    //--- 再帰処理したい場合は一旦変数を切る
    //--- 変数をキャプチャするためのテクニック
    Func<int, int> fibonacci = null;
    fibonacci = x =>
    {
        if (x <= 1)
            return x;
        return fibonacci(x - 1) + fibonacci(x - 2);
    }
    var result = fibonacci(7);
}

書けますし問題なく動作しますが、いちいちデリゲートのインスタンスを作らなければなりません。呼び出し以外のコスト (= インスタンス生成コスト) がかかっていることが分かります。C# 7 からはこれらデリゲートで実現することで起こる諸所の問題を解決し、用途に特化した「ローカル関数」という機能が追加される見込みで、以下のような感じで書けるようになります。

static void Main()
{
    //--- ローカル関数を利用した再帰呼び出し
    int fibonacci(int x)
    {
        if (x <= 1)
            return x;
        return fibonacci(x - 1) + fibonacci(x - 2);
    }
    var r1 = fibonacci(7);

    //--- もちろん Expression-Bodied な書き方も OK
    string asExpressionBodied(int x) => (x * x).ToString();
    var r2 = asExpressionBodied(456);
}

検証内容

ローカル関数は基本的にローカル変数と同様のルールに則った挙動をします。以降、できること/できないことをアレコレ検証してみたので紹介していきます。

アクセシビリティ

関数内でしか利用できないものなので (そもそも) アクセシビリティの設定はできません。

void Foo()
{
    //--- コンパイルエラー!
    //--- public とか private みたいな設定はできない
    public int bar() => 123;
}

静的関数

static キーワードを付けたローカル関数は作れません。なので以下はコンパイルエラーになります。

void Foo()
{
    //--- これもできない
    static int bar() => 123;
}

非同期メソッド (async/await)

async/await の構文は問題なく利用できます。

void Foo()
{
    async Task<int> bar()
    {
        await Task.Delay(1000);
        return 123;
    }
    var result = bar().Result;
}

ジェネリック

Func<T>Action<T> 関連のデリゲートを使用していた場合、ジェネリック関数にするためには親となる関数自体もジェネリックにして型引数を利用する必要がありました。例えば以下のような感じです。

//--- こうやって書いても結局使い物にならない
void Foo<T>()
    where T : struct
{
    Func<T, string> bar = x => x.ToString();

  //var result = bar(123);   //--- int    から T に変換できないのでコンパイルエラー
  //var result = bar(12.3);  //--- double から T に変換できないのでコンパイルエラー
    var result = bar(default(T));  //--- これなら OK
}

関数 Foo をジェネリックにしたいわけではなくてもそうせざるを得ませんでした。しかし、ローカル関数を利用すれば親の関数をジェネリック化することなくジェネリック関数を作ることができるようになります。これは嬉しい!

//--- 親の関数をジェネリック化する必要がない
void Foo()
{
    //--- 型制約も当然使える
    string bar<T>(T x)
        where T : struct
        => x.ToString();

    var r1 = bar(123);   //--- T を int    として解釈させる
    var r2 = bar(12.3);  //--- T を double として解釈させる
}

イテレータ (yield)

デリゲート内では yield が使えない制約があり、yield を使いたい場合はクラス/構造体のメンバーとして private メソッドを作成する必要がありました。しかし、ローカル関数ではそんな制約もなくなります

void Foo()
{
    IEnumerable<string> getFruits()
    {
        yield return "Apple";
        yield return "Orange";
        yield return "Banana";
    }

    foreach (var x in getFruits())
        Console.WriteLine(x);
}

スコープ

冒頭でも説明しましたが、ローカル関数はローカル変数と同じようなルールが適用されます。なので、呼び出しのスコープも以下のようになります。

static void Main()
{
    string foo(object x) => x.ToString();

    {
        string foo(object x) => x.ToString();  //--- 親階層に同名のローカル関数があるからダメ
        string bar(object x) => x.ToString();
        var r1 = foo(123);  //--- OK
        var r2 = bar(123);  //--- OK
    }

    var r3 = foo(123);  //--- OK
    var r4 = bar(123);  //--- スコープ範囲外によりコンパイルエラー!
}

ネスト

デリゲートの中でデリゲートを作れるように、ローカル関数もネストすることができます

static void Main()
{
    //--- ローカル関数
    string foo()
    {
        //--- 入れ子になったローカル関数
        int bar(int x) => x * x;
        return bar(3).ToString();
    }
    var result = foo();
}

変数キャプチャ (= クロージャ)

ローカル関数にはクロージャの機能があり、関数定義よりも先にある変数をキャプチャすることができます。

static void Main()
{
    var age = 31;
    void foo(string name)
    {
        Console.WriteLine(name);  //--- xin9le
        Console.WriteLine(age);   //--- 31
    }
    foo("xin9le");
}

逆コンパイル

では最後に、ローカル関数がどのように実現されているのかコンパイル結果を覗いてみましょう!利用するコードは上記 (変数キャプチャ) の節でも利用した以下のコード。

using System;

namespace LocalFunctions
{
    class Program
    {
        static void Main()
        {
            var age = 31;
            void foo(string name)
            {
                Console.WriteLine(name);  //--- xin9le
                Console.WriteLine(age);   //--- 31
            }
            var dummy = 123;
            foo("xin9le");
        }
    }
}

コンパイル結果を ILSpy.NET Reflector で覗いてみると以下のように展開されていることが分かります。

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace LocalFunctions
{
    internal class Program
    {
        [CompilerGenerated]
        [StructLayout(LayoutKind.Auto)]
        private static struct <>c__DisplayClass0_0
        {
            public int age;
        }

        [CompilerGenerated]
        internal static void <Main>g__foo0_0(string name, ref Program.<>c__DisplayClass0_0 ptr)
        {
            Console.WriteLine(name);
            Console.WriteLine(ptr.age);
        }

        private static void Main()
        {
            Program.<>c__DisplayClass0_0 <>c__DisplayClass0_ = default(Program.<>c__DisplayClass0_0);
            <>c__DisplayClass0_.age = 31;
            int dummy = 123;
            Program.<Main>g__foo0_0("xin9le", ref <>c__DisplayClass0_);
            Console.WriteLine(dummy);
        }
    }
}

非常に読みにくい上にコンパイルすら通らないので、同じような意味になるように書き換えてみました。

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace LocalFunctions
{
    internal class Program
    {
        [CompilerGenerated]
        [StructLayout(LayoutKind.Auto)]
        private struct ClosureHelper
        {
            public int age;
        }

        [CompilerGenerated]
        private static void foo(string name, ref ClosureHelper obj)
        {
            Console.WriteLine(name);
            Console.WriteLine(obj.age);
        }

        private static void Main()
        {
            var obj = default(ClosureHelper);
            obj.age = 31;
            int dummy = 123;
            foo("xin9le", ref obj);
            Console.WriteLine(dummy);
        }
    }
}

ここからローカル関数は以下のように実現されていることが読み取れます。コンパイラさん、だいぶ頑張ってるw

  • ローカル関数はメンバー関数に昇格する (= 普通の関数になる)
  • ローカル関数名は他とは重複しない形で生成される
  • メンバー関数として生成されるのでデリゲートは生成されない
  • ローカル関数にキャプチャされているローカル変数を持つ構造体が生成される
  • 構造体のインスタンスは ref 引数の形で渡される (= 参照渡し)
  • ref 引数はローカル関数の引数の末尾に自動で追加される
  • ローカル関数にキャプチャされていない変数は構造体に含まれない

これまでのデリゲートの形ではクラスとして展開されていたため、ヒープ領域でのメモリ確保が必ず行われていました。ローカル関数の場合、ローカル変数が構造体の参照渡しになってヒープ領域へのメモリアロケーションがなくなる (= スタック領域しか使わない) ため、パフォーマンスの向上が見込めます

名前の通り、ローカル変数と規約と関数としての動きをそのままミックスした感じですね :)