Мне кажется, что людям очень не нравится 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
, что отчасти верно.
От IL до Ассемблера
JIT-компилятор .NET является SSA-компилятором. Я не буду вдаваться во все детали формы SSA и о том, как создать оптимизирующий компилятор, это слишком много, но может дать общее представление о том, что произойдет. Для более глубокого понимания лучше начать с чтения по оптимизации компиляторов (мне нравится эта книга для краткого введения: http://ssabook.gforge.inria.fr/latest/book.pdf ) и LLVM (llvm.org) ,
Каждый оптимизирующий компилятор опирается на тот факт, что код прост и следует предсказуемым шаблонам . В случае циклов FOR мы используем теорию графов для анализа ветвей, а затем оптимизируем такие вещи, как cycli в наших ветвях (например, ветвления в обратном направлении).
Однако теперь у нас есть прямые ветви для реализации наших циклов. Как вы могли догадаться, это на самом деле один из первых шагов, которые 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, что означает однократный выход.
И теперь мы подошли к реальному вопросу.
Представьте, что у вас есть что-то вроде этого:
// 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:
// ...
Мое личное мнение об этом довольно простое: если это то, что мы намеревались все время, давайте сделаем мир проще как для компилятора, так и для удобства чтения, и напишем это сразу.
ТЛ; др:
Нижняя граница:
- Если возможно, используйте простое условие в цикле for. Придерживайтесь языковых конструкций высокого уровня, которые есть в вашем распоряжении.
- Если ничего не получается и у вас остается либо,
goto
либо bool more
, предпочитайте первое.