Мені здається, людям дуже не подобається goto
твердження, тому я відчув необхідність трохи виправити це.
Я вважаю, що "емоції" людей goto
зрештою зводяться до розуміння коду та (помилок) щодо можливих наслідків для продуктивності. Перш ніж відповісти на запитання, я спершу перейду до деяких деталей того, як воно складено.
Як ми всі знаємо, C # компілюється в IL, який потім компілюється в асемблер за допомогою компілятора SSA. Я дам трохи уявлення про те, як це все працює, а потім спробую відповісти на саме запитання.
Від C # до IL
Спочатку нам потрібен фрагмент коду C #. Почнемо з простого:
foreach (var item in array)
{
// ...
break;
// ...
}
Я буду робити цей крок за кроком, щоб дати вам гарне уявлення про те, що відбувається під кришкою.
Перший переклад: з foreach
еквівалентного for
циклу (Примітка. Тут я використовую масив, тому що я не хочу потрапляти в деталі IDisposable - в такому випадку мені також доведеться використовувати IEnumerable):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
Другий переклад: for
і break
перекладається в більш простий еквівалент:
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
І третій переклад (це еквівалент коду IL): ми змінюємо break
і while
в галузі:
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;
Хоча компілятор робить це за один крок, він дає вам уявлення про процес. Код IL, який розвивається з програми C #, - це буквальний переклад останнього коду C #. Переконатися в цьому можна тут: https://dotnetfiddle.net/QaiLRz (натисніть "переглянути IL")
Тепер одне, що ви тут помітили, - це те, що під час процесу код стає складнішим. Найпростіший спосіб це спостерігати за тим, що нам потрібно було все більше і більше коду, щоб прискорити те саме. Можна також стверджувати , що foreach
, for
, while
і break
насправді є короткими руками для goto
, що частково вірно.
Від ІЛ до Асемблера
Компілятор .NET JIT - компілятор SSA. Я не буду вникати в усі деталі форми SSA тут і як створити оптимізуючий компілятор, це занадто багато, але я можу дати базове розуміння того, що буде. Для глибшого розуміння найкраще почати читати про оптимізацію компіляторів (мені подобається ця книга для короткого вступу: http://ssabook.gforge.inria.fr/latest/book.pdf ) та LLVM (llvm.org) .
Кожен оптимізуючий компілятор покладається на те, що код простий і слід передбачуваних моделей . У випадку циклів FOR ми використовуємо теорію графів для аналізу гілок, а потім оптимізуємо такі речі, як цикл у наших гілках (наприклад, гілки назад).
Однак тепер у нас є передні гілки для реалізації наших циклів. Як ви могли здогадатися, це насправді один з перших кроків, який JIT збирається виправити, як це:
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:
// ...
Як бачите, зараз у нас є відстала гілка, яка є нашою маленькою петлею. Єдине, що тут все-таки противно - це галузь, з якою ми закінчилися завдяки своїй break
заяві. В деяких випадках ми можемо рухати це тим самим способом, але в інших - це залишитися.
То чому ж компілятор робить це? Ну, якщо ми зможемо розкрутити цикл, ми можемо його векторизувати. Ми навіть можемо довести, що додаються лише константи, а це означає, що вся наша петля може випасти на повітрі. Підводячи підсумок: зробивши шаблони передбачуваними (зробивши гілки передбачуваними), ми можемо довести, що в нашому циклі існують певні умови, а це означає, що ми можемо робити магію під час оптимізації JIT.
Однак гілки, як правило, ламають ті приємні передбачувані зразки, що є чимось оптимізатором. Перервайтеся, продовжуйте, переходьте - всі вони мають намір порушити ці передбачувані закономірності - і тому насправді не «приємні».
На цьому етапі ви також повинні усвідомити, що просте foreach
є більш передбачуваним, ніж купа goto
тверджень, які проходять повсюдно. Що стосується (1) читабельності та (2) з точки зору оптимізатора, це і краще рішення.
Ще одна річ, яку варто згадати, це те, що це дуже актуально для оптимізації компіляторів для призначення регістрів змінним (процес, який називається розподілом регістрів ). Як ви можете знати, у вашому процесорі є лише обмежена кількість реєстрів, і це далеко не найшвидший об'єм пам'яті у вашому обладнання. Змінні, що використовуються в коді, що знаходиться в самому внутрішньому циклі, швидше отримують присвоєний реєстр, тоді як змінні за межами вашого циклу мають менш важливе значення (тому що цей код, ймовірно, менший).
Допомога, занадто велика складність ... що мені робити?
Суть полягає в тому, що ви завжди повинні використовувати мовні конструкції, які у вас є, що зазвичай (неявно) будують передбачувані шаблони для вашого компілятора. Намагайтеся уникати дивних гілок , якщо це можливо ( в Зокрема: break
, continue
, goto
або return
в середині нічого).
Хороша новина тут полягає в тому, що ці передбачувані зразки є легкими для читання (для людей) та легкими для помічення (для компіляторів).
Один із таких шаблонів називається SESE, який означає "Single Entry Single Exit".
І тепер ми переходимо до реального питання.
Уявіть, що у вас є щось подібне:
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
Найпростіший спосіб зробити цю передбачувану схему - просто усунути if
повністю:
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
В інших випадках ви також можете розділити метод на 2 способи:
// 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;
}
Тимчасові змінні? Хороший, поганий чи потворний?
Ви навіть можете вирішити повернути булевий цикл із циклу (але я особисто віддаю перевагу формі SESE, тому що компілятор це побачить, і я думаю, що це чистіше читати).
Деякі люди вважають, що використовувати тимчасову змінну чистіше, і пропонують таке рішення:
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.
// ...
}
// ...
Я особисто проти такого підходу. Подивіться ще раз на те, як складається код. Тепер подумайте, що це буде робити з цими приємними передбачуваними зразками. Отримати картину?
Правильно, дозвольте мені прописати це. Що станеться, це те, що:
- Компілятор випише все як гілки.
- В якості кроку оптимізації компілятор зробить аналіз потоку даних, намагаючись видалити дивну
more
змінну, яка буває використана лише в керуючому потоці.
- У разі успіху змінна
more
буде видалена з програми, а залишаються лише гілки. Ці гілки будуть оптимізовані, тому ви отримаєте лише одну гілку із внутрішньої петлі.
- Якщо невдало, змінна
more
, безумовно, використовується в самому внутрішньому циклі, тому якщо компілятор не оптимізує її, вона має високі шанси бути віднесеною до реєстру (що з'їдає цінну пам'ять реєстру).
Отже, підсумовуючи: оптимізатор у вашому компіляторі потрапить у пекло багато проблем, щоб зрозуміти, що more
він використовується лише для керуючого потоку, і в кращому випадку сценарій переведе його на одну гілку за межами зовнішньої для петля.
Іншими словами, найкращим сценарієм є те, що він закінчиться еквівалентом цього:
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:
// ...
Моя особиста думка з цього приводу досить проста: якщо це те, що ми мали намір весь час, давайте полегшимо світ як для компілятора, так і для читання, і запишемо це відразу.
tl; dr:
Нижня лінія:
- Якщо можливо, використовуйте просту умову у своєму циклі. Дотримуйтесь якомога більше мовних конструкцій, які є у вас в розпорядженні.
- Якщо все виходить з ладу, і ви залишаєтесь з одним
goto
або bool more
, віддайте перевагу першому.