Apakah ada alasan untuk penggunaan ulang C # dari variabel dalam foreach?

1685

Saat menggunakan ekspresi lambda atau metode anonim di C #, kita harus waspada terhadap akses ke perangkap penutupan yang dimodifikasi . Sebagai contoh:

foreach (var s in strings)
{
   query = query.Where(i => i.Prop == s); // access to modified closure
   ...
}

Karena penutupan yang dimodifikasi, kode di atas akan menyebabkan semua Whereklausa pada kueri didasarkan pada nilai akhir dari s.

Seperti yang dijelaskan di sini , ini terjadi karena svariabel yang dideklarasikan dalam foreachloop di atas diterjemahkan seperti ini di kompiler:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}

bukannya seperti ini:

while (enumerator.MoveNext())
{
   string s;
   s = enumerator.Current;
   ...
}

Seperti yang ditunjukkan di sini , tidak ada keuntungan kinerja untuk mendeklarasikan variabel di luar loop, dan dalam keadaan normal satu-satunya alasan yang dapat saya pikirkan untuk melakukan ini adalah jika Anda berencana untuk menggunakan variabel di luar lingkup loop:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}
var finalString = s;

Namun variabel yang didefinisikan dalam satu foreachloop tidak dapat digunakan di luar loop:

foreach(string s in strings)
{
}
var finalString = s; // won't work: you're outside the scope.

Jadi kompilator mendeklarasikan variabel dengan cara yang membuatnya sangat rentan terhadap kesalahan yang seringkali sulit ditemukan dan didebug, sementara tidak menghasilkan manfaat yang dapat dilihat.

Apakah ada sesuatu yang dapat Anda lakukan dengan foreachloop dengan cara ini yang Anda tidak bisa jika mereka dikompilasi dengan variabel cakupan dalam, atau apakah ini hanya pilihan sewenang-wenang yang dibuat sebelum metode anonim dan ekspresi lambda tersedia atau umum, dan yang tidak memiliki sudah direvisi sejak saat itu?

StriplingWarrior
sumber
4
Ada apa dengan ini String s; foreach (s in strings) { ... }?
Brad Christie
5
@BradChristie OP tidak benar-benar berbicara tentang foreachtetapi tentang ekspresi lamda menghasilkan kode yang sama seperti yang ditunjukkan oleh OP ...
Yahia
22
@BradChristie: Apakah itu dikompilasi? ( Kesalahan: Jenis dan pengidentifikasi keduanya diperlukan dalam pernyataan foreach bagi saya)
Austin Salonen
32
@JakobBotschNielsen: Ini adalah lokal luar tertutup dari lambda; mengapa Anda berasumsi bahwa itu akan berada di tumpukan sama sekali? Seumur hidup lebih lama dari tumpukan bingkai !
Eric Lippert
3
@EricLippert: Saya bingung. Saya mengerti bahwa lambda menangkap referensi ke variabel foreach (yang dinyatakan secara internal di luar loop) dan karena itu Anda akhirnya membandingkan dengan nilai akhirnya; yang saya dapatkan. Yang tidak saya mengerti adalah bagaimana mendeklarasikan variabel di dalam loop akan membuat perbedaan sama sekali. Dari sudut pandang kompiler-penulis saya hanya mengalokasikan satu referensi string (var 's') pada stack terlepas dari apakah deklarasi berada di dalam atau di luar loop; Saya tentu tidak ingin mendorong referensi baru ke tumpukan setiap iterasi!
Anthony

Jawaban:

1407

Compiler mendeklarasikan variabel dengan cara yang membuatnya sangat rentan terhadap kesalahan yang seringkali sulit ditemukan dan didebug, sementara tidak menghasilkan manfaat yang dapat dilihat.

Kritik Anda sepenuhnya dibenarkan.

Saya membahas masalah ini secara rinci di sini:

Menutup variabel loop dianggap berbahaya

Apakah ada sesuatu yang dapat Anda lakukan dengan loop foreach dengan cara ini bahwa Anda tidak bisa jika mereka dikompilasi dengan variabel cakupan-dalam? atau apakah ini hanya pilihan sewenang-wenang yang dibuat sebelum metode anonim dan ekspresi lambda tersedia atau umum, dan yang belum direvisi sejak saat itu?

Yang terakhir. Spesifikasi C # 1.0 sebenarnya tidak mengatakan apakah variabel loop berada di dalam atau di luar tubuh loop, karena tidak membuat perbedaan yang dapat diamati. Ketika semantik penutupan diperkenalkan di C # 2.0, pilihan dibuat untuk meletakkan variabel loop di luar loop, konsisten dengan loop "for".

Saya pikir itu adil untuk mengatakan bahwa semua menyesali keputusan itu. Ini adalah salah satu "gotcha" terburuk di C #, dan kami akan mengambil perubahan untuk memperbaikinya. Dalam C # 5 variabel loop foreach akan secara logis di dalam tubuh loop, dan karena itu penutupan akan mendapatkan salinan baru setiap kali.

The forLoop tidak akan berubah, dan perubahan tidak akan "kembali porting" dengan versi sebelumnya dari C #. Karena itu Anda harus terus berhati-hati saat menggunakan idiom ini.

Eric Lippert
sumber
177
Kami memang mendorong balik perubahan C # 3 dan C # 4. Ketika kami merancang C # 3 kami menyadari bahwa masalah (yang sudah ada di C # 2) akan semakin buruk karena akan ada begitu banyak lambdas (dan permintaan pemahaman, yang lambdas dalam penyamaran) di loop foreach berkat LINQ. Saya menyesal bahwa kami menunggu masalah menjadi cukup buruk untuk menjamin memperbaikinya begitu terlambat, daripada memperbaikinya dalam C # 3.
Eric Lippert
75
Dan sekarang kita harus ingat foreach'aman' tapi fortidak.
leppie
22
@michielvoo: Perubahan melanggar dalam arti bahwa itu tidak kompatibel ke belakang. Kode baru tidak akan berjalan dengan benar saat menggunakan kompiler yang lebih lama.
leppie
41
@Benjol: Tidak, itulah sebabnya kami bersedia menerimanya. Jon Skeet menunjukkan kepada saya skenario perubahan penting yang melanggar, yaitu seseorang menulis kode dalam C # 5, mengujinya, dan kemudian membagikannya kepada orang-orang yang masih menggunakan C # 4, yang kemudian secara naif meyakini bahwa itu benar. Semoga jumlah orang yang terkena dampak skenario seperti ini kecil.
Eric Lippert
29
Selain itu, ReSharper selalu menangkap ini dan melaporkannya sebagai "akses ke penutupan yang dimodifikasi". Kemudian, dengan menekan Alt + Enter, apakah itu akan secara otomatis memperbaiki kode Anda untuk Anda. jetbrains.com/resharper
Mike Chamberlain
191

Apa yang Anda tanyakan sepenuhnya dibahas oleh Eric Lippert dalam posting blognya. Menutup variabel loop yang dianggap berbahaya dan sekuelnya.

Bagi saya, argumen yang paling meyakinkan adalah bahwa memiliki variabel baru di setiap iterasi tidak akan konsisten dengan for(;;)loop gaya. Apakah Anda berharap memiliki yang baru int idi setiap iterasi for (int i = 0; i < 10; i++)?

Masalah yang paling umum dengan perilaku ini adalah membuat penutupan atas variabel iterasi dan memiliki solusi yang mudah:

foreach (var s in strings)
{
    var s_for_closure = s;
    query = query.Where(i => i.Prop == s_for_closure); // access to modified closure

Posting blog saya tentang masalah ini: Penutupan atas variabel foreach dalam C # .

Krizz
sumber
18
Pada akhirnya, apa yang sebenarnya diinginkan orang ketika mereka menulis ini bukan untuk memiliki banyak variabel, itu untuk menutup nilainya . Dan sulit untuk memikirkan sintaks yang dapat digunakan untuk itu dalam kasus umum.
Random832
1
Ya, tidak mungkin untuk menutup dengan nilai, namun ada solusi yang sangat mudah saya baru saja mengedit jawaban saya untuk dimasukkan.
Krizz
6
Terlalu buruk penutupan C # close over reference. Jika mereka menutup nilai secara default, kita dapat dengan mudah menentukan variabel penutupan dengan ref.
Sean U
2
@ Kirzz, ini adalah kasus di mana konsistensi paksa lebih berbahaya daripada tidak konsisten. Seharusnya "hanya berfungsi" seperti yang diharapkan orang, dan jelas orang mengharapkan sesuatu yang berbeda ketika menggunakan foreach sebagai lawan dari for for, mengingat jumlah orang yang telah mencapai masalah sebelum kita tahu tentang akses ke masalah penutupan modifikasi (seperti saya) .
Andy
2
@ Random832 tidak tahu tentang C # tetapi dalam Common LISP ada sintaks untuk itu, dan angka itu menunjukkan bahwa bahasa apa pun dengan variabel dan penutupan yang dapat berubah (nay, harus ) memilikinya juga. Kami lebih dekat merujuk ke tempat yang berubah, atau nilai yang dimilikinya pada saat tertentu (penciptaan penutupan). Ini membahas hal-hal serupa dalam Python dan Skema ( cutuntuk referensi / vars dan cuteuntuk menjaga nilai yang dievaluasi dalam penutupan yang dievaluasi sebagian).
Will Ness
103

Setelah digigit oleh ini, saya memiliki kebiasaan memasukkan variabel yang didefinisikan secara lokal dalam lingkup terdalam yang saya gunakan untuk mentransfer ke penutupan apa pun. Dalam contoh Anda:

foreach (var s in strings)
    query = query.Where(i => i.Prop == s); // access to modified closure

Saya lakukan:

foreach (var s in strings)
{
    string search = s;
    query = query.Where(i => i.Prop == search); // New definition ensures unique per iteration.
}        

Setelah Anda memiliki kebiasaan itu, Anda dapat menghindarinya dalam kasus yang sangat jarang Anda maksudkan untuk mengikat ke luar lingkup. Sejujurnya, saya tidak berpikir saya pernah melakukannya.

Godeke
sumber
24
Itu adalah solusi khas Terima kasih atas kontribusinya. Resharper cukup pintar untuk mengenali pola ini dan membawanya ke perhatian Anda juga, yang bagus. Saya belum menggigit pola ini dalam beberapa saat, tetapi karena itu, dalam kata-kata Eric Lippert, "satu-satunya laporan bug yang paling umum kita dapatkan," Saya ingin tahu mengapa lebih dari bagaimana menghindarinya .
StriplingWarrior
62

Di C # 5.0, masalah ini diperbaiki dan Anda bisa menutup variabel loop dan mendapatkan hasil yang Anda harapkan.

Spesifikasi bahasa mengatakan:

8.8.4 Pernyataan foreach

(...)

Pernyataan formulir di muka

foreach (V v in x) embedded-statement

kemudian diperluas ke:

{
  E e = ((C)(x)).GetEnumerator();
  try {
      while (e.MoveNext()) {
          V v = (V)(T)e.Current;
          embedded-statement
      }
  }
  finally {
       // Dispose e
  }
}

(...)

Penempatan vdi dalam loop sementara penting untuk bagaimana itu ditangkap oleh fungsi anonim yang terjadi dalam pernyataan-tertanam. Sebagai contoh:

int[] values = { 7, 9, 13 };
Action f = null;
foreach (var value in values)
{
    if (f == null) f = () => Console.WriteLine("First value: " + value);
}
f();

Jika vdideklarasikan di luar loop sementara, itu akan dibagikan di antara semua iterasi, dan nilainya setelah loop untuk akan menjadi nilai akhir 13,, yang merupakan doa yang fakan dicetak. Alih-alih, karena setiap iterasi memiliki variabelnya sendiri v, variabel yang ditangkap oleh fiterasi pertama akan terus memiliki nilai 7, yang akan dicetak. ( Catatan: versi C # sebelumnya dinyatakan di vluar loop sementara. )

Paolo Moretti
sumber
1
Mengapa versi awal C # ini dinyatakan dalam loop sementara? msdn.microsoft.com/en-GB/library/aa664754.aspx
colinfang
4
@colinfang Pastikan untuk membaca jawaban Eric : Spesifikasi C # 1.0 ( dalam tautan Anda yang kita bicarakan VS 2003, yaitu C # 1.2 ) sebenarnya tidak mengatakan apakah variabel loop berada di dalam atau di luar loop body, karena tidak ada perbedaan yang dapat diamati . Ketika semantik penutupan diperkenalkan di C # 2.0, pilihan dibuat untuk meletakkan variabel loop di luar loop, konsisten dengan loop "for".
Paolo Moretti
1
Jadi Anda mengatakan bahwa contoh-contoh dalam tautan itu bukan spec definitif pada waktu itu?
colinfang
4
@colinfang Mereka adalah spesifikasi yang pasti. Masalahnya adalah bahwa kita berbicara tentang fitur (yaitu penutupan fungsi) yang diperkenalkan kemudian (dengan C # 2.0). Ketika C # 2.0 muncul, mereka memutuskan untuk meletakkan variabel loop di luar loop. Dan kemudian mereka berubah pikiran lagi dengan C # 5.0 :)
Paolo Moretti