C#のクロージャと予想外の落とし穴

C#のクロージャと予想外の落とし穴

デリゲートまたはラムダ式を使用して、C#でもクロージャを使用できます。

最終更新 2022/06/15 22:49
老胡写代码
読了目安 3 分
カテゴリ
.NET
タグ
.NET C#

クロージャは主に関数型プログラミングの概念ですが、C#の主要な特徴はオブジェクト指向です。しかし、デリゲートやラムダ式を利用することで、C#でも関数型プログラミング風のコードを書くことができます。同様に、デリゲートやラムダ式を使用してC#でクロージャを利用することも可能です。

WIKIの定義によると、クロージャは構文クロージャまたは関数クロージャとも呼ばれ、関数型プログラミング言語において構文バインディングを実現する技術です。クロージャは実装上は構造体であり、関数(通常はそのエントリアドレス)と関連する環境(シンボル検索テーブルに相当)を格納します。また、クロージャは変数のライフサイクルを遅延させることもできます。

うーん…定義だけではピンとこないかもしれません。次の例を見てみましょう。

class Program
{
    static Action CreateGreeting(string message)
    {
        return () => { Console.WriteLine("Hello " + message); };
    }

    static void Main()
    {
        Action action = CreateGreeting("DeathArthas");
        action();
    }
}

この例は非常にシンプルで、ラムダ式を使ってActionオブジェクトを作成し、それを呼び出しています。

しかし注意深く観察すると、Actionオブジェクトが呼び出されるとき、CreateGreetingメソッドは既に戻っており、その引数であるmessageは破棄されているはずです。それなのに、Actionオブジェクトを呼び出したときに正しい結果が得られるのはなぜでしょうか?

その秘密は、ここでクロージャが形成されていることにあります。CreateGreetingは既に戻っていますが、そのローカル変数が返されたラムダ式にキャプチャされ、ライフサイクルが延長されています。どうでしょうか、この説明でクロージャの定義が少し明確になったのではないでしょうか?

クロージャはこれほど単純で、実は私たちは日常的に使用しているのに、気づいていないこともよくあります。例えば、次のようなコードを書いたことがあるはずです。

void AddControlClickLogger(Control control, string message)
{
	control.Click += delegate
	{
		Console.WriteLine("Control clicked: {0}", message);
	}
}

このコードもクロージャを利用しています。なぜなら、controlがクリックされたときには、messageはとっくにそのスコープを超えているからです。クロージャを適切に使用することで、空間的にも時間的にも疎結合なデリゲートを記述できます。

ただし、クロージャを使用する際には注意すべき落とし穴があります。クロージャはローカル変数のライフサイクルを遅延させるため、状況によってはプログラムの結果が予想と異なることがあります。次の例を見てみましょう。

class Program
{
    static List<Action> CreateActions()
    {
        var result = new List<Action>();
        for(int i = 0; i < 5; i++)
        {
            result.Add(() => Console.WriteLine(i));
        }
        return result;
    }

    static void Main()
    {
        var actions = CreateActions();
        for(int i = 0;i<actions.Count;i++)
        {
            actions[i]();
        }
    }
}

この例も非常にシンプルで、Actionのリストを作成し、順番に実行します。結果を見てみましょう。

5
5
5
5
5

多くの人がこの結果を見てこんな表情になるでしょう!!0、1、2、3、4になるはずでは?何が問題なのでしょうか?

原因を探ると、ここでもクロージャの本質が問題になっています。「クロージャが変数のライフサイクルを遅延させる」というコインの裏側は、変数が意図せず複数のクロージャから参照される可能性があることです。

この例では、ローカル変数iが5つのクロージャから同時に参照されています。これら5つのクロージャはiを共有しているため、最後に表示される値はすべて同じで、ループを抜けたときのiの値である5になります。

この問題を解決するには簡単で、別のローカル変数を宣言し、各クロージャがそれぞれのローカル変数を参照するようにすればよいです。

// 他の部分は以前と同じ
static List<Action> CreateActions()
{
    var result = new List<Action>();
    for (int i = 0; i < 5; i++)
    {
        int temp = i; // ローカル変数を追加
        result.Add(() => Console.WriteLine(temp));
    }
    return result;
}
0
1
2
3
4

これで各クロージャが異なるローカル変数を参照するようになり、問題は解決しました。

もう一つの修正方法として、クロージャを作成する際にforの代わりにforeachを使用する方法があります。少なくともC# 7.0のバージョンでは、この問題は既に認識されており、foreachを使用するとコンパイラが自動的にコードを生成してこのクロージャの落とし穴を回避します。

// これでも修正可能
static List<Action> CreateActions()
{
    var result = new List<Action>();
    foreach (var i in Enumerable.Range(0,5))
    {
        result.Add(() => Console.WriteLine(i));
    }
    return result;
}

以上が、C#におけるクロージャの使用法とその際の小さな落とし穴についてです。この記事を通じて皆さんがこの知識を理解し、開発の際に無駄な回り道をしないことを願っています!

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2026/04/22

各OSバージョンの.NETサポート状況(250707更新)

仮想マシンとテストマシンを使用して、各OSバージョンの.NETサポート状況を確認します。OSインストール後、対応するランタイムをインストールし、Stardustエージェントを実行できることを確認します(合格条件)。

続きを読む
同じカテゴリ / 同じタグ 2026/02/07

AOTの使用経験のまとめ

プロジェクト作成当初から、新機能を追加したり新しい構文を使用したりした場合には、すぐにAOT公開テストを実施するという良い習慣を身につけるべきです。

続きを読む