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으로 출력합니다.

하나의 캡처 변수를 참조하는 모든 조치 때문인 것으로 보입니다. 결과적으로, 호출 될 때 모두 동일한 출력을 갖습니다.

각 조치 인스턴스에 자체 캡처 변수가 있도록이 한계를 극복 할 수있는 방법이 있습니까?


15
주제에 관한 Eric Lippert의 블로그 시리즈를 참조하십시오 : 유해한 것으로 간주되는 루프 변수 닫기
Brian

10
또한 이들은 foreach 내에서 예상대로 작동하도록 C # 5를 변경하고 있습니다. (속보 변경)
Neal Tibrewala


3
@Neal이 예는 아직도 여전히 다섯 개 10 초 출력으로, C # 5에서 제대로 작동하지 않지만
이안 오크스을

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

답변:


196

예-루프 내부에서 변수의 사본을 가져옵니다.

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

C # 컴파일러가 변수 선언에 도달 할 때마다 "새"로컬 변수를 만드는 것처럼 생각할 수 있습니다. 실제로 적절한 새 클로저 객체를 만들고 여러 범위의 변수를 참조하면 구현 측면에서 복잡해 지지만 작동합니다. :)

이 문제의 일반적인 발생이 사용하고있는 주 for또는 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
존의 책은 또한 (!, 겸손 존을 정지)이에 아주 좋은 장을 가지고
마크 Gravell

35
내가 다른 사람들이 그것을 꽂게하면 더 좋아 보인다;) (나는 그것을 추천하는 답변을 투표하는 경향이 있다고 고백한다.)
Jon Skeet

2
이제까지로, skeet@pobox.com에 피드백 : 평가 될 것입니다
존 소총

7
C를 들어 # 5.0 동작은 다른 (합리적) 존 소총으로 새로운 대답을 참조 - stackoverflow.com/questions/16264289/...
알렉세이 Levenkov

1
@Florimond : C #에서 클로저가 작동하는 방식이 아닙니다. 값이 아닌 변수를 캡처 합니다 . (이것은 루프에 관계없이 사실이며 변수를 캡처하고 실행될 때마다 현재 값을 인쇄하는 람다로 쉽게 시연됩니다.)
Jon Skeet


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

@tjlevine : 대단히 감사합니다. 내 답변에 그것에 대한 참조를 추가 할 것입니다. 잊어 버렸습니다!
Jon Skeet

또한 Jon, 다양한 Java 7 마감 제안에 대한 귀하의 의견을 읽고 싶습니다. 나는 당신이 당신이 하나를 쓰고 싶다고 언급 한 것을 보았지만 그것을 보지 못했습니다.
tjlevine

1
@tjlevine : 좋아, 나는 올해 말까지 작성하려고 약속 :)
Jon Skeet

6

이것은 루프와 관련이 없습니다.

() => variable * 2외부 스코프 variable가 람다의 내부 범위에 실제로 정의되지 않은 람다 식을 사용하기 때문에이 동작이 트리거 됩니다.

C # 2의 익명 메소드뿐만 아니라 C # 3 +의 Lambda 표현식은 여전히 ​​실제 메소드를 작성합니다. 이러한 메소드에 변수를 전달하는 것은 약간의 딜레마를 포함합니다 (값으로 전달? 참조로 전달? C #은 참조로 진행하지만 참조가 실제 변수보다 오래 지속될 수있는 또 다른 문제가 열립니다). 이러한 모든 딜레마를 해결하기 위해 C #이하는 것은 람다 식에 사용 된 지역 변수에 해당하는 필드와 실제 람다 메서드에 해당하는 메서드를 사용하여 새 도우미 클래스 ( "클로저")를 만드는 것입니다. variable코드의 변경 사항 은 실제로 해당 변경 사항으로 변경됩니다.ClosureClass.variable

따라서 while 루프 ClosureClass.variable는 10에 도달 할 때까지를 계속 업데이트 한 다음 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;
}

어쨌든 "Closures"는 루프와 관련된 개념이 아니라 로컬 범위 변수의 익명 메소드 / 람다 식 사용 과 관련이 있습니다. 루프를 잘못 사용하면 클로저 트랩이 나타납니다.


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

0

여기에 아무도 ECMA-334를 직접 인용하지 않았기 때문에 :

10.4.4.10 진술 문

양식의 진술에 대한 명확한 할당 검사 :

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

진술이 작성된 것처럼 수행됩니다.

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

또한 사양에서

12.16.6.3 지역 변수의 인스턴스화

로컬 변수는 실행이 변수 범위에 들어가면 인스턴스화되는 것으로 간주됩니다.

[예 : 예를 들어, 다음 메소드가 호출 x되면 루프가 반복 될 때마다 로컬 변수 가 세 번 인스턴스화되고 초기화됩니다.

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

그러나 x루프 외부 에서 선언을 이동하면 단일 인스턴스화가 발생합니다 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

컴파일러는 세 인스턴스화를 단일 델리게이트 인스턴스 (§11.7.2)로 최적화 할 수 있지만 필수는 아닙니다.

for-loop가 반복 변수를 선언하면 해당 변수 자체는 루프 외부에서 선언 된 것으로 간주됩니다. [예 : 따라서 반복 변수 자체를 캡처하도록 예를 변경 한 경우 :

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

반복 변수의 한 인스턴스 만 캡처되어 출력을 생성합니다.

3
3
3

최종 예]

오 예, C ++에서는 변수가 값 또는 참조로 캡처되는지 선택할 수 있기 때문에이 문제가 발생하지 않는다고 언급해야한다고 생각합니다 ( Lambda capture 참조 ).


-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
귀하의 답변이 위의 누군가가 제공 한 답변과 다른 점은 무엇입니까?
Thangadurai
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.