C#のループでキャプチャされた変数


216

C#に関する興味深い問題に遭遇しました。以下のようなコードがあります。

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    actions.Add(() => variable * 2);
    ++ variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

0、2、4、6、8が出力されると思いますが、実際には5つの10が出力されます。

これは、1つのキャプチャされた変数を参照するすべてのアクションが原因であると思われます。その結果、それらが呼び出されると、すべて同じ出力になります。

この制限を回避して、各アクションインスタンスに独自のキャプチャされた変数を持たせる方法はありますか?


15
次の件に関するEric Lippertのブログシリーズも参照してください:ループ変数を閉じることは有害と見なされる
Brian

10
また、foreach内で期待どおりに機能するようにC#5を変更しています。(
重大


3
@ニール:この例は5#10を出力するため、C#5ではまだ適切に機能しません
Ian Oakes

6
C#6.0(VS 2015)では、今日まで5の10を出力することを確認しました。クロージャー変数のこの振る舞いが変更の候補であるとは思えません。Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured
RBT 2017

回答:


196

はい-ループ内で変数のコピーを取ります:

while (variable < 5)
{
    int copy = variable;
    actions.Add(() => copy * 2);
    ++ variable;
}

C#コンパイラは、変数宣言にヒットするたびに「新しい」ローカル変数を作成するかのように考えることができます。実際、適切な新しいクロージャーオブジェクトが作成され、複数のスコープで変数を参照すると(実装の点で)複雑になりますが、機能します:)

この問題のより一般的な発生はfororの使用foreachです。

for (int i=0; i < 10; i++) // Just one variable
foreach (string x in foo) // And again, despite how it reads out loud

これの詳細については、C#3.0仕様のセクション7.14.4.2を参照してください。また、クロージャーに関する私の記事にも多くの例があります。

C#5コンパイラ以降では(以前のバージョンのC#を指定した場合でも)、動作がforeach変更されたため、ローカルコピーを作成する必要がなくなりました。詳細については、この回答を参照してください。


32
ジョンの本にも、これに関する非常に優れた章があります(謙虚であることをやめなさい、ジョン!)
マークグラベル

35
他の人にプラグインさせると、見栄えがよくなります;)(私はそれを推奨する回答に投票する傾向があることを認めます。)
Jon Skeet

2
相変わらず、skeet @ pobox.comへのフィードバックを歓迎します:)
Jon Skeet

7
C#の場合は5.0の動作が異なります(より合理的な)ジョンスキートことにより、新しい答えを参照- stackoverflow.com/questions/16264289/...を
アレクセイLevenkov

1
@Florimond:これは、C#でクロージャーが機能する方法ではありません。ではなく変数をキャプチャします。(これはループに関係なく当てはまり、変数をキャプチャするラムダで簡単に示され、実行されるたびに現在の値を出力するだけです。)
Jon Skeet

23

あなたが経験しているのはClosure http://en.wikipedia.org/wiki/Closure_(computer_science)として知られているものだと思います。ランバには、関数自体のスコープ外の変数への参照があります。ランバは、それを呼び出すまで解釈されず、呼び出されると、変数が実行時に持っている値を取得します。


11

舞台裏で、コンパイラーはメソッド呼び出しのクロージャーを表すクラスを生成しています。ループの反復ごとに、クロージャークラスのその単一のインスタンスを使用します。コードは次のようになり、バグが発生する理由を簡単に確認できます。

void Main()
{
    List<Func<int>> actions = new List<Func<int>>();

    int variable = 0;

    var closure = new CompilerGeneratedClosure();

    Func<int> anonymousMethodAction = null;

    while (closure.variable < 5)
    {
        if(anonymousMethodAction == null)
            anonymousMethodAction = new Func<int>(closure.YourAnonymousMethod);

        //we're re-adding the same function 
        actions.Add(anonymousMethodAction);

        ++closure.variable;
    }

    foreach (var act in actions)
    {
        Console.WriteLine(act.Invoke());
    }
}

class CompilerGeneratedClosure
{
    public int variable;

    public int YourAnonymousMethod()
    {
        return this.variable * 2;
    }
}

これは実際にはサンプルのコンパイル済みコードではありませんが、私は自分のコードを調べましたが、これはコンパイラーが実際に生成するコードと非常によく似ています。


8

これを回避する方法は、必要な値をプロキシ変数に格納し、その変数を取得することです。

IE

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}

編集した回答の説明を参照してください。現在、仕様の関連する部分を見つけています。
Jon Skeet、

ははジョン、私は実際にあなたの記事を読んだだけ です:csharpindepth.com/Articles/Chapter5/Closures.aspx私の友人は良い仕事をしています。
tjlevine 2008年

@tjlevine:どうもありがとう。それへの参照を私の回答に追加します。忘れちゃった!
Jon Skeet、

また、ジョン、Java 7のさまざまなクロージャーの提案についてのあなたの考えについても読んでみたいと思います。私はあなたがあなたがそれを書きたかったと言っているのを見たが、私はそれを見たことがない。
tjlevine 2008年

1
@tjlevine:さて、私は年末までにそれを書くことを試みることを約束します:)
Jon Skeet

6

これはループとは関係ありません。

この動作は() => variable * 2、外側のスコープvariableが実際にはラムダの内側のスコープで定義されていないラムダ式を使用するためにトリガーされます。

ラムダ式(C#3 +およびC#2の無名メソッド)でも、実際のメソッドが作成されます。これらのメソッドに変数を渡すと、いくつかのジレンマが生じます(値渡し?参照渡し?C#は参照渡しになりますが、これにより、参照が実際の変数より長く存続するという別の問題が発生します)。これらすべてのジレンマを解決するためにC#が行うことは、ラムダ式で使用されるローカル変数に対応するフィールドと実際のラムダメソッドに対応するメソッドを持つ新しいヘルパークラス(「クロージャー」)を作成することです。variableコードの変更は実際にはその変更に変換されますClosureClass.variable

そのため、whileループはClosureClass.variable10に達するまでを更新し続け、その後、forループはすべて同じで動作するアクションを実行しますClosureClass.variable

期待どおりの結果を得るには、ループ変数と、閉じられる変数を分離する必要があります。これを行うには、別の変数を導入します。つまり、

List<Func<int>> actions = new List<Func<int>>();
int variable = 0;
while (variable < 5)
{
    var t = variable; // now t will be closured (i.e. replaced by a field in the new class)
    actions.Add(() => t * 2);
    ++variable; // changing variable won't affect the closured variable t
}
foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

クロージャーを別のメソッドに移動して、この分離を作成することもできます。

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    actions.Add(Mult(variable));
    ++variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

Multをラムダ式として実装できます(暗黙のクロージャー)。

static Func<int> Mult(int i)
{
    return () => i * 2;
}

または実際のヘルパークラスで:

public class Helper
{
    public int _i;
    public Helper(int i)
    {
        _i = i;
    }
    public int Method()
    {
        return _i * 2;
    }
}

static Func<int> Mult(int i)
{
    Helper help = new Helper(i);
    return help.Method;
}

いずれの場合でも、「クロージャ」はループ関連する概念ではなく、匿名メソッド/ラムダ式のローカルスコープ変数の使用に関係します。ただし、ループの慎重な使用によってクロージャトラップが示される場合もあります。


5

はいvariable、ループ内でスコープを設定し、その方法でラムダに渡す必要があります。

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    int variable1 = variable;
    actions.Add(() => variable1 * 2);
    ++variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

Console.ReadLine();

5

マルチスレッド(C#、. NET 4.0)でも同じ状況が発生します

次のコードを参照してください。

目的は、1、2、3、4、5を順番に印刷することです。

for (int counter = 1; counter <= 5; counter++)
{
    new Thread (() => Console.Write (counter)).Start();
}

出力はおもしろい!(それは21334のようかもしれません...)

唯一の解決策は、ローカル変数を使用することです。

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}

これは私には役に立たないようです。まだ非決定的です。
Mladen Mihajlovic 2014年

0

誰もここで直接引用して以来ECMA-334

10.4.4.10ステートメント

フォームのforステートメントの明確な割り当てチェック:

for (for-initializer; for-condition; for-iterator) embedded-statement

ステートメントが作成されたかのように実行されます。

{
    for-initializer;
    while (for-condition) {
        embedded-statement;
    LLoop: for-iterator;
    }
}

さらに仕様では、

12.16.6.3ローカル変数のインスタンス化

ローカル変数は、実行が変数のスコープに入ったときにインスタンス化されたと見なされます。

[例:たとえば、次のメソッドが呼び出されると、ローカル変数xはインスタンス化され、3回(ループの反復ごとに1回)初期化されます。

static void F() {
  for (int i = 0; i < 3; i++) {
    int x = i * 2 + 1;
    ...
  }
}

ただし、宣言をxループの外に移動すると、のインスタンスが1つになりますx

static void F() {
  int x;
  for (int i = 0; i < 3; i++) {
    x = i * 2 + 1;
    ...
  }
}

終了例]

キャプチャされない場合、ローカル変数がインスタンス化される頻度を正確に観察する方法はありません。インスタンス化のライフタイムは互いに素であるので、インスタンス化ごとに同じ格納場所を単純に使用することが可能です。ただし、無名関数がローカル変数をキャプチャすると、インスタンス化の影響が明らかになります。

[例:例

using System;

delegate void D();

class Test{
  static D[] F() {
    D[] result = new D[3];
    for (int i = 0; i < 3; i++) {
      int x = i * 2 + 1;
      result[i] = () => { Console.WriteLine(x); };
    }
  return result;
  }
  static void Main() {
    foreach (D d in F()) d();
  }
}

出力を生成します:

1
3
5

ただし、宣言がxループの外に移動された場合:

static D[] F() {
  D[] result = new D[3];
  int x;
  for (int i = 0; i < 3; i++) {
    x = i * 2 + 1;
    result[i] = () => { Console.WriteLine(x); };
  }
  return result;
}

出力は次のとおりです。

5
5
5

コンパイラは、3つのインスタンス化を単一のデリゲートインスタンス(§11.7.2)に最適化することが許可されています(必須ではありません)。

forループが反復変数を宣言する場合、その変数自体はループの外側で宣言されていると見なされます。[例:したがって、反復変数自体をキャプチャするように例が変更された場合:

static D[] F() {
  D[] result = new D[3];
  for (int i = 0; i < 3; i++) {
    result[i] = () => { Console.WriteLine(i); };
  }
  return result;
}

反復変数の1つのインスタンスのみがキャプチャされ、出力が生成されます。

3
3
3

終了例]

ああ、そうですか、C ++では、変数を値でキャプチャするか参照でキャプチャするかを選択できるため、この問題は発生しないことを言及する必要があります(参照:Lambdaキャプチャ)。


-1

これはクロージャ問題と呼ばれ、単にコピー変数を使用するだけで完了します。

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    int i = variable;
    actions.Add(() => i * 2);
    ++ variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

4
あなたの回答は、上記の誰かが提供した回答とどのように異なりますか?
タンガドゥライ
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.