Variável capturada em um loop em C #

216

Eu conheci uma questão interessante sobre c #. Eu tenho código como abaixo.

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());
}

Espero que ele produza 0, 2, 4, 6, 8. No entanto, ele realmente produz cinco 10s.

Parece que isso se deve a todas as ações referentes a uma variável capturada. Como resultado, quando são chamados, todos têm a mesma saída.

Existe uma maneira de contornar esse limite para que cada instância de ação tenha sua própria variável capturada?

Morgan Cheng
fonte
15
Veja também a série de blogs de Eric Lippert sobre o assunto: Fechando sobre o loop Variável considerada prejudicial
Brian
10
Além disso, eles estão mudando o C # 5 para funcionar conforme o esperado em um foreach. (alteração de quebra)
Neal Tibrewala
3
@Neal: embora este exemplo ainda não funcionar corretamente em C # 5, uma vez que ainda gera cinco 10s
Ian Oakes
6
Verificou que ele produz cinco 10s até hoje no C # 6.0 (VS 2015). Duvido que esse comportamento das variáveis ​​de fechamento seja candidato à mudança. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

Respostas:

196

Sim - faça uma cópia da variável dentro do loop:

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

Você pode pensar nisso como se o compilador C # cria uma variável local "nova" toda vez que atinge a declaração da variável. De fato, ele criará novos objetos de fechamento apropriados e ficará complicado (em termos de implementação) se você se referir a variáveis ​​em vários escopos, mas funciona :)

Observe que uma ocorrência mais comum desse problema está usando forou foreach:

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

Consulte a seção 7.14.4.2 da especificação do C # 3.0 para obter mais detalhes, e meu artigo sobre encerramentos também tem mais exemplos.

Observe que, a partir do compilador C # 5 e além (mesmo ao especificar uma versão anterior do C #), o comportamento foi foreachalterado para que você não precise mais fazer uma cópia local. Veja esta resposta para mais detalhes.

Jon Skeet
fonte
32
O livro de Jon também tem um bom capítulo sobre isso (deixar de ser humilde, Jon!)
Marc Gravell
35
Parece melhor se eu deixar que outras pessoas ligá-lo;) (confesso que eu tendem a votar-se respostas recomendá-lo embora).
Jon Skeet
2
Como sempre, o feedback para [email protected] seria apreciada :)
Jon Skeet
7
Para C # 5.0 comportamento é diferente (mais razoável) ver a resposta mais recente por Jon Skeet - stackoverflow.com/questions/16264289/...
Alexei Levenkov
1
@ Florimond: Não é assim que os fechamentos funcionam em C #. Eles capturam variáveis , não valores . (Isso é verdade independentemente de loops, e é facilmente demonstrada com um lambda que captura uma variável, e apenas imprime o valor atual sempre que é executado.)
Jon Skeet
23

Acredito que você esteja enfrentando algo conhecido como Closure http://en.wikipedia.org/wiki/Closure_(computer_science) . Seu lamba tem uma referência a uma variável com escopo fora da própria função. Seu lamba não é interpretado até que você o invoque e, uma vez obtido, obterá o valor que a variável possui no tempo de execução.

TheCodeJunkie
fonte
11

Nos bastidores, o compilador está gerando uma classe que representa o fechamento da sua chamada de método. Ele usa essa instância única da classe de fechamento para cada iteração do loop. O código se parece com isso, o que facilita a compreensão do motivo pelo qual o bug ocorre:

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;
    }
}

Na verdade, esse não é o código compilado do seu exemplo, mas examinei meu próprio código e parece muito com o que o compilador realmente geraria.

gerrard00
fonte
8

A maneira de contornar isso é armazenar o valor necessário em uma variável proxy e fazer com que essa variável seja capturada.

IE

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}
tjlevine
fonte
Veja a explicação na minha resposta editada. Estou encontrando a parte relevante das especificações agora.
Jon Skeet
Haha jon, na verdade, acabei de ler seu artigo: csharpindepth.com/Articles/Chapter5/Closures.aspx Você faz um bom trabalho, meu amigo.
tjlevine
@ tjlevine: Muito obrigado. Vou adicionar uma referência a isso na minha resposta. Eu tinha esquecido disso!
Jon Skeet
Jon, também gostaria de ler sobre seus pensamentos sobre as várias propostas de fechamento do Java 7. Já vi você mencionar que queria escrever um, mas ainda não o vi.
tjlevine
1
@tjlevine: Ok, eu prometo tentar escrevê-lo até o final do ano :)
Jon Skeet
6

Isso não tem nada a ver com loops.

Esse comportamento é acionado porque você usa uma expressão lambda em () => variable * 2que o escopo externo variablenão é realmente definido no escopo interno do lambda.

Expressões lambda (em C # 3 +, bem como métodos anônimos em C # 2) ainda criam métodos reais. A passagem de variáveis ​​para esses métodos envolve alguns dilemas (passar por valor? Passar por referência? C # é seguido por referência - mas isso abre outro problema em que a referência pode sobreviver à variável real). O que o C # faz para resolver todos esses dilemas é criar uma nova classe auxiliar ("fechamento") com campos correspondentes às variáveis ​​locais usadas nas expressões lambda e métodos correspondentes aos métodos lambda reais. Quaisquer alterações variableno seu código são realmente traduzidas para alterações nesse código.ClosureClass.variable

Portanto, seu loop while continua atualizando o ClosureClass.variableaté chegar a 10, e você executa loops para todos os loops ClosureClass.variable.

Para obter o resultado esperado, você precisa criar uma separação entre a variável de loop e a variável que está sendo fechada. Você pode fazer isso introduzindo outra variável, ou seja:

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());
}

Você também pode mover o fechamento para outro método para criar esta separação:

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());
}

Você pode implementar Mult como uma expressão lambda (fechamento implícito)

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

ou com uma classe auxiliar real:

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;
}

De qualquer forma, "Closures" NÃO é um conceito relacionado a loops , mas sim a métodos anônimos / expressões lambda, uso de variáveis ​​de escopo local - embora algum uso incauto de loops demonstre armadilhas de fechamento.

David Refaeli
fonte
5

Sim, você precisa escopo variabledentro do loop e passá-lo para o lambda dessa maneira:

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();
cfeduke
fonte
5

A mesma situação está acontecendo no multithread (C #, .NET 4.0].

Veja o seguinte código:

O objetivo é imprimir 1,2,3,4,5 em ordem.

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

A saída é interessante! (Pode ser como 21334 ...)

A única solução é usar variáveis ​​locais.

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}
Sunil
fonte
Isso não parece me ajudar. Ainda não determinístico.
Mladen Mihajlovic
0

Como ninguém aqui citou diretamente o ECMA-334 :

10.4.4.10 Para declarações

Verificação de atribuição definida para uma declaração for do formulário:

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

é feito como se a declaração estivesse escrita:

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

Mais adiante na especificação,

12.16.6.3 Instanciação de variáveis ​​locais

Uma variável local é considerada instanciada quando a execução entra no escopo da variável.

[Exemplo: por exemplo, quando o método a seguir é chamado, a variável local xé instanciada e inicializada três vezes - uma vez para cada iteração do loop.

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

No entanto, mover a declaração de xfora do loop resulta em uma única instanciação de x:

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

exemplo final]

Quando não capturada, não há como observar exatamente com que frequência uma variável local é instanciada - como as vidas úteis das instanciações são disjuntas, é possível para cada instanciação simplesmente usar o mesmo local de armazenamento. No entanto, quando uma função anônima captura uma variável local, os efeitos da instanciação se tornam aparentes.

[Exemplo: o exemplo

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();
  }
}

produz a saída:

1
3
5

No entanto, quando a declaração de xé movida para fora do loop:

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;
}

a saída é:

5
5
5

Observe que o compilador tem permissão (mas não é obrigatório) para otimizar as três instanciações em uma única instância de delegação (§11.7.2).

Se um loop for declarar uma variável de iteração, essa variável em si será considerada declarada fora do loop. [Exemplo: portanto, se o exemplo for alterado para capturar a própria variável de iteração:

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

apenas uma instância da variável de iteração é capturada, o que produz a saída:

3
3
3

exemplo final]

Sim, acho que deve ser mencionado que em C ++ esse problema não ocorre porque você pode escolher se a variável é capturada por valor ou por referência (consulte: Captura Lambda ).

Nathan Chappell
fonte
-1

É chamado de problema de fechamento, basta usar uma variável de cópia e pronto.

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());
}
Juned Khan Momin
fonte
4
De que maneira sua resposta é diferente da resposta fornecida por alguém acima?
Thangadurai 30/01/19