在我看来,人们似乎非常不喜欢 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;
虽然编译器在一个步骤中完成了这些事情,但它可以让您深入了解该过程。从 C# 程序演变而来的 IL 代码是最后一个 C# 代码的直译。您可以在这里亲自查看:https://dotnetfiddle.net/QaiLRz(点击“查看 IL”)
现在,您在这里观察到的一件事是,在此过程中,代码变得更加复杂。观察这一点的最简单方法是我们需要越来越多的代码来完成同样的事情。你可能还会争辩说foreach、for、while 和break 实际上是goto 的简写,这在一定程度上是正确的。
从 IL 到汇编程序
.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 优化期间发挥作用。
但是,分支往往会破坏那些很好的可预测模式,因此优化人员不太喜欢这种情况。 Break、continue、goto——它们都打算打破这些可预测的模式——因此并不是真正的“好”。
此时您还应该意识到,一个简单的foreach 比一堆到处乱跑的goto 语句更容易预测。就(1)可读性和(2)从优化器的角度来看,它都是更好的解决方案。
另一件值得一提的是,它与优化编译器以将寄存器分配给变量非常相关(一个称为寄存器分配的过程)。您可能知道,您的 CPU 中只有有限数量的寄存器,它们是迄今为止硬件中最快的内存。在最内层循环中的代码中使用的变量更有可能被分配一个寄存器,而循环之外的变量则不太重要(因为此代码可能被命中较少)。
求助,太复杂了……我该怎么办?
最重要的是,您应该始终使用您可以使用的语言结构,这通常会(隐含地)为您的编译器构建可预测的模式。如果可能,尽量避免使用奇怪的分支(特别是: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 循环之外的单个分支。
换句话说,最好的情况是它最终会得到这样的结果:
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;博士:
底线:
- 如果可能,请在 for 循环中使用简单的条件。尽可能坚持使用您可以使用的高级语言结构。
- 如果一切都失败了,而您只剩下
goto 或bool more,请选择前者。