Sepertinya saya suka orang sangat tidak menyukai goto
pernyataan, jadi saya merasa perlu untuk meluruskan hal ini sedikit.
Saya percaya 'emosi' yang dimiliki orang pada goto
akhirnya bermuara pada pemahaman kode dan (kesalahpahaman) tentang kemungkinan implikasi kinerja. Sebelum menjawab pertanyaan, karena itu saya akan terlebih dahulu masuk ke beberapa detail tentang bagaimana itu dikompilasi.
Seperti yang kita semua tahu, C # dikompilasi ke IL, yang kemudian dikompilasi ke assembler menggunakan kompiler SSA. Saya akan memberikan sedikit wawasan tentang bagaimana semua ini bekerja, dan kemudian mencoba menjawab pertanyaan itu sendiri.
Dari C # ke IL
Pertama kita membutuhkan sepotong kode C #. Mari kita mulai dari yang sederhana:
foreach (var item in array)
{
// ...
break;
// ...
}
Saya akan melakukan langkah demi langkah untuk memberi Anda ide bagus tentang apa yang terjadi di bawah tenda.
Terjemahan pertama: dari foreach
ke for
loop setara (Catatan: Saya menggunakan array di sini, karena saya tidak ingin masuk ke rincian IDisposable - dalam hal ini saya juga harus menggunakan IEnumerable):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
Terjemahan kedua: for
dan break
diterjemahkan ke dalam padanan yang lebih mudah:
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
Dan terjemahan ketiga (ini setara dengan kode IL): kami mengubah break
dan while
menjadi cabang:
int i=0; // for initialization
startLoop:
if (i >= array.Length) // for condition
{
goto exitLoop;
}
var item = array[i];
// ...
goto exitLoop; // break
// ...
++i; // for post-expression
goto startLoop;
Sementara kompiler melakukan hal-hal ini dalam satu langkah, itu memberi Anda wawasan tentang proses. Kode IL yang berevolusi dari program C # adalah terjemahan literal dari kode C # terakhir. Anda dapat melihat sendiri di sini: https://dotnetfiddle.net/QaiLRz (klik 'lihat IL')
Sekarang, satu hal yang Anda amati di sini adalah bahwa selama proses, kode menjadi lebih kompleks. Cara termudah untuk mengamati ini adalah dengan fakta bahwa kami membutuhkan lebih banyak kode untuk menyelesaikan hal yang sama. Anda juga mungkin berpendapat bahwa foreach
, for
, while
dan break
sebenarnya pendek tangan untuk goto
, yang sebagian benar.
Dari IL ke Assembler
Kompiler. NET JIT adalah kompiler SSA. Saya tidak akan membahas semua detail formulir SSA di sini dan cara membuat kompiler yang mengoptimalkan, terlalu banyak, tetapi dapat memberikan pemahaman dasar tentang apa yang akan terjadi. Untuk pemahaman yang lebih dalam, yang terbaik adalah mulai membaca tentang mengoptimalkan kompiler (saya suka buku ini untuk pengantar singkat: http://ssabook.gforge.inria.fr/latest/book.pdf ) dan LLVM (llvm.org) .
Setiap kompiler yang mengoptimalkan bergantung pada fakta bahwa kode itu mudah dan mengikuti pola yang dapat diprediksi . Dalam kasus loop FOR, kami menggunakan teori grafik untuk menganalisis cabang, dan kemudian mengoptimalkan hal-hal seperti cycli di cabang kami (mis. Cabang mundur).
Namun, kami sekarang memiliki cabang ke depan untuk mengimplementasikan loop kami. Seperti yang mungkin sudah Anda duga, ini sebenarnya salah satu langkah pertama yang akan diperbaiki JIT, seperti ini:
int i=0; // for initialization
if (i >= array.Length) // for condition
{
goto endOfLoop;
}
startLoop:
var item = array[i];
// ...
goto endOfLoop; // break
// ...
++i; // for post-expression
if (i >= array.Length) // for condition
{
goto startLoop;
}
endOfLoop:
// ...
Seperti yang Anda lihat, kita sekarang memiliki cabang terbelakang, yang merupakan lingkaran kecil kami. Satu-satunya hal yang masih jahat di sini adalah cabang yang kami dapatkan karena break
pernyataan kami . Dalam beberapa kasus, kita dapat memindahkan ini dengan cara yang sama, tetapi dalam kasus lain tetap ada.
Jadi mengapa kompiler melakukan ini? Nah, jika kita bisa membuka gulungannya, kita mungkin bisa mengubahnya. Kita bahkan mungkin dapat membuktikan bahwa hanya ada konstanta yang ditambahkan, yang berarti seluruh loop kita bisa menghilang ke udara tipis. Untuk meringkas: dengan membuat pola-pola yang dapat diprediksi (dengan membuat cabang-cabang dapat diprediksi), kita dapat membuktikan bahwa kondisi-kondisi tertentu bertahan dalam loop kita, yang berarti kita dapat melakukan sihir selama optimasi JIT.
Namun, cabang-cabang cenderung mematahkan pola-pola bagus yang dapat diprediksi itu, yang oleh karenanya merupakan sesuatu yang optimis. Hancurkan, lanjutkan, kebagian - mereka semua berniat untuk menghancurkan pola-pola yang dapat diprediksi ini - dan karenanya tidak benar-benar 'baik'.
Anda juga harus menyadari pada titik ini bahwa yang sederhana foreach
lebih dapat diprediksi daripada sekelompok goto
pernyataan yang tersebar di semua tempat. Dalam hal (1) keterbacaan dan (2) dari perspektif pengoptimal, keduanya merupakan solusi yang lebih baik.
Hal lain yang patut disebutkan adalah sangat relevan untuk mengoptimalkan kompiler untuk menetapkan register ke variabel (proses yang disebut alokasi register ). Seperti yang mungkin Anda ketahui, hanya ada sejumlah register yang terbatas di CPU Anda dan mereka adalah bagian memori tercepat di perangkat keras Anda. Variabel yang digunakan dalam kode yang berada di loop paling dalam, lebih mungkin untuk mendapatkan register yang ditugaskan, sedangkan variabel di luar loop Anda kurang penting (karena kode ini mungkin lebih sedikit hit).
Bantuan, terlalu banyak kerumitan ... apa yang harus saya lakukan?
Intinya adalah bahwa Anda harus selalu menggunakan konstruksi bahasa yang Anda miliki, yang biasanya (secara implisit) membangun pola yang dapat diprediksi untuk kompiler Anda. Cobalah untuk menghindari cabang aneh jika mungkin (khusus: break
, continue
, goto
atau return
di tengah-tengah tidak ada).
Kabar baiknya di sini adalah bahwa pola yang dapat diprediksi ini mudah dibaca (untuk manusia) dan mudah dikenali (untuk penyusun).
Salah satu pola itu disebut SESE, yang merupakan kependekan dari Single Entry Single Exit.
Dan sekarang kita sampai pada pertanyaan sebenarnya.
Bayangkan Anda memiliki sesuatu seperti ini:
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
Cara termudah untuk membuat ini menjadi pola yang dapat diprediksi adalah dengan hanya menghilangkan if
sepenuhnya:
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
Dalam kasus lain, Anda juga dapat membagi metode menjadi 2 metode:
// Outer loop in method 1:
for (i=0; i<100 && processInner(i); ++i)
{
}
private bool processInner(int i)
{
int j;
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
return i*j<=a;
}
Variabel sementara? Baik, buruk, atau jelek?
Anda bahkan mungkin memutuskan untuk mengembalikan boolean dari dalam loop (tapi saya pribadi lebih suka bentuk SESE karena itulah bagaimana kompiler akan melihatnya dan saya pikir lebih bersih untuk membaca).
Beberapa orang berpikir itu lebih bersih untuk menggunakan variabel sementara, dan mengusulkan solusi seperti ini:
bool more = true;
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { more = false; break; } // yuck.
// ...
}
if (!more) { break; } // yuck.
// ...
}
// ...
Saya pribadi menentang pendekatan ini. Lihat lagi bagaimana kode dikompilasi. Sekarang pikirkan apa yang akan dilakukan dengan pola yang bagus dan dapat diprediksi ini. Dapatkan fotonya?
Benar, izinkan saya mengejanya. Apa yang akan terjadi adalah:
- Kompiler akan menuliskan semuanya sebagai cabang.
- Sebagai langkah optimasi, kompiler akan melakukan analisis aliran data dalam upaya untuk menghapus
more
variabel aneh yang hanya digunakan dalam aliran kontrol.
- Jika berhasil, variabel
more
akan dihilangkan dari program, dan hanya cabang yang tersisa. Cabang-cabang ini akan dioptimalkan, sehingga Anda hanya akan mendapatkan satu cabang saja dari loop dalam.
- Jika tidak berhasil, variabel
more
pasti digunakan dalam loop paling dalam, jadi jika kompiler tidak akan mengoptimalkannya, ia memiliki peluang besar untuk dialokasikan ke register (yang memakan memori register yang berharga).
Jadi, untuk meringkas: pengoptimal dalam kompiler Anda akan mengalami banyak kesulitan untuk mencari tahu yang more
hanya digunakan untuk aliran kontrol, dan dalam skenario kasus terbaik akan menerjemahkannya ke cabang tunggal di luar luar untuk lingkaran.
Dengan kata lain, skenario kasus terbaik adalah bahwa skenario itu akan berakhir dengan yang setara dengan ini:
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { goto exitLoop; } // perhaps add a comment
// ...
}
// ...
}
exitLoop:
// ...
Pendapat pribadi saya tentang ini cukup sederhana: jika ini yang kami maksudkan selama ini, mari kita buat dunia lebih mudah untuk kompiler dan keterbacaan, dan segera tulis itu.
tl; dr:
Intinya:
- Gunakan kondisi sederhana di loop for Anda jika memungkinkan. Tetap berpegang pada konstruksi tingkat tinggi bahasa yang Anda miliki sebanyak mungkin.
- Jika semuanya gagal dan Anda salah
goto
atau bool more
, lebih suka yang pertama.