Variabel yang diambil dalam satu lingkaran dalam C #

216

Saya bertemu masalah menarik tentang C #. Saya punya kode seperti di bawah ini.

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

Saya berharap untuk menampilkan 0, 2, 4, 6, 8. Namun, sebenarnya output lima 10s.

Tampaknya karena semua tindakan mengacu pada satu variabel yang ditangkap. Akibatnya, ketika mereka dipanggil, mereka semua memiliki output yang sama.

Apakah ada cara untuk mengatasi batasan ini agar setiap instance tindakan memiliki variabel yang ditangkap sendiri?

Morgan Cheng
sumber
15
Lihat juga seri Blog Eric Lippert pada subjek: Menutup Variabel yang Dianggap Berbahaya
Brian
10
Juga, mereka mengubah C # 5 agar berfungsi seperti yang Anda harapkan dalam foreach. (melanggar perubahan)
Neal Tibrewala
3
@Neal: walaupun contoh ini masih tidak berfungsi dengan baik di C # 5, karena masih menghasilkan lima 10
Ian Oakes
6
Itu memverifikasi bahwa itu output lima 10s sampai hari ini di C # 6.0 (VS 2015). Saya ragu bahwa perilaku variabel penutupan ini adalah kandidat untuk perubahan. Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.
RBT

Jawaban:

196

Ya - ambil salinan variabel di dalam loop:

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

Anda dapat menganggapnya seolah-olah kompiler C # membuat variabel lokal "baru" setiap kali hits deklarasi variabel. Bahkan itu akan membuat objek penutupan baru yang sesuai, dan itu menjadi rumit (dalam hal implementasi) jika Anda merujuk ke variabel dalam beberapa lingkup, tetapi ia bekerja :)

Perhatikan bahwa kejadian yang lebih umum dari masalah ini adalah menggunakan foratau foreach:

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

Lihat bagian 7.14.4.2 dari spesifikasi C # 3.0 untuk detail lebih lanjut tentang ini, dan artikel saya tentang penutupan memiliki lebih banyak contoh juga.

Perhatikan bahwa pada kompiler C # 5 dan seterusnya (bahkan ketika menentukan versi C # sebelumnya), perilaku foreachberubah sehingga Anda tidak perlu lagi membuat salinan lokal. Lihat jawaban ini untuk lebih jelasnya.

Jon Skeet
sumber
32
Buku Jon juga memiliki bab yang sangat bagus tentang hal ini (berhenti bersikap rendah hati, Jon!)
Marc Gravell
35
Kelihatannya lebih baik jika saya membiarkan orang lain memasukkannya;) (Saya akui bahwa saya cenderung memilih jawaban yang merekomendasikannya.)
Jon Skeet
2
Seperti biasa, umpan balik ke [email protected] akan dihargai :)
Jon Skeet
7
Untuk perilaku C # 5.0 berbeda (lebih masuk akal) lihat jawaban yang lebih baru oleh Jon Skeet - stackoverflow.com/questions/16264289/…
Alexei Levenkov
1
@Florimond: Bukan hanya itu cara penutupan bekerja di C #. Mereka menangkap variabel , bukan nilai . (Itu benar terlepas dari loop, dan mudah ditunjukkan dengan lambda yang menangkap variabel, dan hanya mencetak nilai saat ini setiap kali dieksekusi.)
Jon Skeet
23

Saya percaya apa yang Anda alami adalah sesuatu yang dikenal sebagai Penutupan http://en.wikipedia.org/wiki/Closure_(computer_science) . Lamba Anda memiliki referensi ke variabel yang dicakup di luar fungsi itu sendiri. Lamba Anda tidak ditafsirkan sampai Anda memintanya dan setelah itu akan mendapatkan nilai variabel pada waktu eksekusi.

TheCodeJunkie
sumber
11

Di belakang layar, kompiler menghasilkan kelas yang mewakili penutupan untuk pemanggilan metode Anda. Ia menggunakan instance tunggal dari kelas penutupan untuk setiap iterasi dari loop. Kode terlihat seperti ini, yang membuatnya lebih mudah untuk melihat mengapa bug terjadi:

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

Ini sebenarnya bukan kode yang dikompilasi dari sampel Anda, tetapi saya telah memeriksa kode saya sendiri dan ini sangat mirip dengan apa yang sebenarnya dihasilkan oleh kompiler.

gerrard00
sumber
8

Cara mengatasinya adalah dengan menyimpan nilai yang Anda butuhkan dalam variabel proxy, dan membuat variabel itu ditangkap.

YAITU

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}
Tjlevine
sumber
Lihat penjelasan dalam jawaban saya yang diedit. Saya menemukan sedikit spesifikasi yang relevan sekarang.
Jon Skeet
Haha jon, saya sebenarnya baru saja membaca artikel Anda: csharpindepth.com/Articles/Chapter5/Closures.aspx Anda melakukan pekerjaan yang baik teman saya.
tjlevine
@tjlevine: Terima kasih banyak. Saya akan menambahkan referensi untuk itu dalam jawaban saya. Saya sudah lupa tentang itu!
Jon Skeet
Juga, Jon, saya ingin membaca tentang pemikiran Anda tentang berbagai proposal penutupan Java 7. Saya pernah melihat Anda menyebutkan bahwa Anda ingin menulis satu, tetapi saya belum melihatnya.
tjlevine
1
@tjlevine: Oke, saya berjanji untuk mencoba menuliskannya pada akhir tahun :)
Jon Skeet
6

Ini tidak ada hubungannya dengan loop.

Perilaku ini dipicu karena Anda menggunakan ekspresi lambda di () => variable * 2mana variablecakupan luar tidak benar-benar didefinisikan dalam cakupan dalam lambda.

Ekspresi Lambda (dalam C # 3 +, serta metode anonim dalam C # 2) masih membuat metode yang sebenarnya. Melewati variabel ke metode ini melibatkan beberapa dilema (lulus dengan nilai? Lulus dengan referensi? C # berjalan dengan referensi - tetapi ini membuka masalah lain di mana referensi dapat bertahan lebih lama dari variabel yang sebenarnya). Apa yang dilakukan C # untuk menyelesaikan semua dilema ini adalah membuat kelas pembantu baru ("closure") dengan bidang yang sesuai dengan variabel lokal yang digunakan dalam ekspresi lambda, dan metode yang sesuai dengan metode lambda yang sebenarnya. Setiap perubahan variabledalam kode Anda sebenarnya diterjemahkan untuk mengubahnyaClosureClass.variable

Jadi loop sementara Anda terus memperbarui ClosureClass.variablehingga mencapai 10, maka Anda untuk loop menjalankan tindakan, yang semuanya beroperasi pada saat yang sama ClosureClass.variable.

Untuk mendapatkan hasil yang diharapkan, Anda perlu membuat pemisahan antara variabel loop, dan variabel yang sedang ditutup. Anda dapat melakukan ini dengan memperkenalkan variabel lain, yaitu:

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

Anda juga bisa memindahkan penutupan ke metode lain untuk membuat pemisahan ini:

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

Anda dapat menerapkan Mult sebagai ekspresi lambda (penutupan implisit)

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

atau dengan kelas pembantu sebenarnya:

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

Dalam setiap kasus, "Penutupan" BUKAN sebuah konsep yang terkait dengan loop , tetapi lebih kepada metode anonim / ekspresi lambda yang menggunakan variabel lingkup lokal - meskipun beberapa penggunaan loop yang tidak hati-hati menunjukkan perangkap penutup.

David Refaeli
sumber
5

Ya, Anda perlu lingkup variabledalam loop dan meneruskannya ke lambda seperti itu:

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
sumber
5

Situasi yang sama terjadi di multi-threading (C #, .NET 4.0].

Lihat kode berikut:

Tujuannya adalah untuk mencetak 1,2,3,4,5 secara berurutan.

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

Outputnya menarik! (Mungkin seperti 21334 ...)

Satu-satunya solusi adalah menggunakan variabel lokal.

for (int counter = 1; counter <= 5; counter++)
{
    int localVar= counter;
    new Thread (() => Console.Write (localVar)).Start();
}
Sunil
sumber
Ini sepertinya tidak membantu saya. Masih non-deterministik.
Mladen Mihajlovic
0

Karena tidak ada seorang pun di sini yang langsung mengutip ECMA-334 :

10.4.4.10 Untuk pernyataan

Tugas yang pasti memeriksa untuk-pernyataan dari formulir:

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

dilakukan seolah-olah pernyataan itu ditulis:

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

Lebih lanjut dalam spesifikasi,

12.16.6.3 Instansiasi variabel lokal

Variabel lokal dianggap instantiated ketika eksekusi memasuki ruang lingkup variabel.

[Contoh: Sebagai contoh, ketika metode berikut dipanggil, variabel lokal xinstantiated dan diinisialisasi tiga kali — satu kali untuk setiap iterasi loop.

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

Namun, memindahkan deklarasi di xluar loop menghasilkan instantiasi tunggal x:

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

contoh akhir]

Ketika tidak ditangkap, tidak ada cara untuk mengamati dengan tepat seberapa sering variabel lokal dipakai - karena masa hidup instantiasi terputus-putus, dimungkinkan untuk setiap instan untuk hanya menggunakan lokasi penyimpanan yang sama. Namun, ketika fungsi anonim menangkap variabel lokal, efek instantiasi menjadi jelas.

[Contoh: Contoh

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

menghasilkan output:

1
3
5

Namun, ketika deklarasi xdipindahkan di luar 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;
}

outputnya adalah:

5
5
5

Perhatikan bahwa kompiler diizinkan (tetapi tidak diharuskan) untuk mengoptimalkan ketiga instantiasi menjadi instance delegasi tunggal (§11.7.2).

Jika for-loop mendeklarasikan variabel iterasi, variabel itu sendiri dianggap dideklarasikan di luar loop. [Contoh: Jadi, jika contoh diubah untuk menangkap variabel iterasi itu sendiri:

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

hanya satu instance dari variabel iterasi yang ditangkap, yang menghasilkan output:

3
3
3

contoh akhir]

Oh ya, saya kira harus disebutkan bahwa dalam C ++ masalah ini tidak terjadi karena Anda dapat memilih apakah variabel ditangkap oleh nilai atau dengan referensi (lihat: Lambda capture ).

Nathan Chappell
sumber
-1

Ini disebut masalah penutupan, cukup gunakan variabel salin, dan selesai.

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
sumber
4
Dengan cara apa jawaban Anda berbeda dari jawaban yang diberikan oleh seseorang di atas?
Thangadurai