如何避免使用 goto 并有效地中断嵌套循环



我想说的是,在C/C++编程时,使用goto被认为是一种不好的做法。

但是,给定以下代码

for (i = 0; i < N; ++i) 
{
for (j = 0; j < N; j++) 
{
for (k = 0; k < N; ++k) 
{
...
if (condition)
goto out;
...
}
}
}
out:
...

我想知道如何在不使用goto的情况下有效地实现相同的行为.我的意思是,例如,我们可以在每个循环结束时执行诸如检查condition之类的操作,但是AFAIK goto将仅生成一个汇编指令,该指令将是一个jmp。这是我能想到的最有效的方法。

还有其他被认为是良好做法吗?当我说使用 goto 被认为是一种不好的做法时,我错了吗?如果我是,这会是使用它的好情况之一吗?

谢谢

(imo) 最好的非goto版本看起来像这样:

void calculateStuff()
{
// Please use better names than this.
doSomeStuff();
doLoopyStuff();
doMoreStuff();
}
void doLoopyStuff()
{
for (i = 0; i < N; ++i) 
{
for (j = 0; j < N; j++) 
{
for (k = 0; k < N; ++k) 
{
/* do something */
if (/*condition*/)
return; // Intuitive control flow without goto
/* do something */
}
}
}
}

拆分它可能也是一个好主意,因为它可以帮助您保持函数简短,代码可读(如果您比我更好地命名函数)和低依赖项。

如果你有这样的深度嵌套循环并且你必须打破,我相信goto是最好的解决方案。 某些语言(不是 C)具有break(N)语句,该语句将脱离多个循环。 C 没有它的原因是它甚至比goto糟糕:你必须计算嵌套循环才能弄清楚它的作用,并且它很容易被后来有人出现并添加或删除嵌套级别,而没有注意到中断计数需要调整。

是的,gotos通常不受欢迎。 在这里使用goto不是一个好的解决方案;这只是几种邪恶中最小的一种。

在大多数情况下,你必须打破深度嵌套循环的原因是因为你正在寻找一些东西,并且你已经找到了它。 在这种情况下(以及其他一些评论和答案所建议的那样),我更喜欢将嵌套循环移动到它自己的函数中。 在这种情况下,内部循环外的return可以非常干净地完成您的任务。

(有些人说函数必须始终在末尾返回,而不是从中间返回。 这些人会说,因此,简单的突破功能解决方案是无效的,即使搜索被拆分为自己的功能,他们也会强制使用同样尴尬的突破内部循环技术。 就个人而言,我相信那些人错了,但你的里程可能会有所不同。

如果你坚持不使用goto,如果你坚持不使用具有早期回报的单独函数,那么是的,你可以做一些事情,比如维护额外的布尔控制变量,并在每个嵌套循环的控制条件下冗余地测试它们,但这只是一个麻烦和混乱。 (这是我说使用简单goto

小于的更大的弊端之一。

我认为goto在这里是一件非常理智的事情,并且根据C++核心指南,它是特殊用例之一。

但是,也许要考虑的另一种解决方案是IIFEλ。在我看来,这比声明一个单独的函数稍微优雅一些!

[&] {
for (int i = 0; i < N; ++i)
for (int j = 0; j < N; j++)
for (int k = 0; k < N; ++k)
if (condition)
return;
}();

感谢reddit上的JohnMcPineapple提供这个建议!

在这种情况下,您不想避免使用goto.

一般来说,应避免使用goto,但是此规则也有例外,您的案例就是其中之一的一个很好的例子。

让我们看一下替代方案:

for (i = 0; i < N; ++i) {
for (j = 0; j < N; j++) {
for (k = 0; k < N; ++k) {
...
if (condition)
break;
...
}
if (condition)
break;
}
if (condition)
break;
}

或:

int flag = 0
for (i = 0; (i < N) && !flag; ++i) {
for (j = 0; (j < N) && !flag; j++) {
for (k = 0; (k < N) && !flag; ++k) {
...
if (condition) {
flag = 1
break;
...
}
}
}

这些都不像goto版本那样简洁或可读。

在你只是向前跳(而不是向后)的情况下,使用goto被认为是可以接受的,这样做会使你的代码更具可读性和可理解性。

另一方面,如果您使用goto向两个方向跳转,或者跳入可能绕过变量初始化的范围,那就不好了。

这是一个不好的goto示例:

int x;
scanf("%d", &x);
if (x==4) goto bad_jump;
{
int y=9;
// jumping here skips the initialization of y
bad_jump:
printf("y=%dn", y);
}

C++编译器会在此处抛出错误,因为goto跳过y的初始化。 然而,C 编译器将对此进行编译,并且上面的代码将在尝试打印y时调用未定义的行为,如果发生goto,该行为将未初始化。

正确使用goto的另一个示例是错误处理:

void f()
{
char *p1 = malloc(10);
if (!p1) {
goto end1;
}
char *p2 = malloc(10);
if (!p2) {
goto end2;
}
char *p3 = malloc(10);
if (!p3) {
goto end3;
}
// do something with p1, p2, and p3
end3:
free(p3);
end2:
free(p2);
end1:
free(p1);
}

这将在函数结束时执行所有清理。 将此与替代方案进行比较:

void f()
{
char *p1 = malloc(10);
if (!p1) {
return;
}
char *p2 = malloc(10);
if (!p2) {
free(p1);
return;
}
char *p3 = malloc(10);
if (!p3) {
free(p2);
free(p1);
return;
}
// do something with p1, p2, and p3
free(p3);
free(p2);
free(p1);
}

在多个地方进行清理的地方。 如果以后添加更多需要清理的资源,则必须记住在所有这些位置添加清理以及之前获取的任何资源的清理。

上面的示例与 C 的关系比与 C C++更相关,因为在后一种情况下,您可以使用具有适当析构函数和智能指针的类来避免手动清理。

Lambda 允许您创建本地作用域:

[&]{
for (i = 0; i < N; ++i) 
{
for (j = 0; j < N; j++) 
{
for (k = 0; k < N; ++k) 
{
...
if (condition)
return;
...
}
}
}
}();

如果还希望能够返回超出该范围:

if (auto r = [&]()->boost::optional<RetType>{
for (i = 0; i < N; ++i) 
{
for (j = 0; j < N; j++) 
{
for (k = 0; k < N; ++k) 
{
...
if (condition)
return {};
...
}
}
}
}()) {
return *r;
}

其中返回{}boost::nullopt是"中断",返回值返回封闭范围内的值。

另一种方法是:

for( auto idx : cube( {0,N}, {0,N}, {0,N} ) {
auto i = std::get<0>(idx);
auto j = std::get<1>(idx);
auto k = std::get<2>(idx);
}

我们生成一个在所有 3 个维度上的可迭代对象,并使其成为 1 个深度嵌套循环。 现在break工作正常。 你必须写cube.

在 c++17 中,这变成了

for( auto[i,j,k] : cube( {0,N}, {0,N}, {0,N} ) ) {
}

这很好。

现在,在您应该响应的应用程序中,在初级控制流量级别上循环较大的三维范围通常是一个坏主意。 你可以把它拧掉,但即便如此,你最终也会遇到线程运行太长的问题。 我玩过的大多数三维大型迭代都可以从使用子任务线程本身中受益。

为此,您最终需要根据操作访问的数据类型对操作进行分类,然后将操作传递给为您计划迭代的内容。

auto work = do_per_voxel( volume,
[&]( auto&& voxel ) {
// do work on the voxel
if (condition)
return Worker::abort;
else
return Worker::success;
}
);

然后,所涉及的控制流进入do_per_voxel功能。

do_per_voxel不会是一个简单的裸循环,而是一个系统,用于将每个体素任务重写为每个扫描线任务(甚至是每个平面任务,具体取决于平面/扫描线在运行时的大小(!)),然后依次将它们调度到线程池托管任务调度程序,拼接生成的任务句柄,并返回一个类似于未来的work,可以等待或用作工作时的延续触发器完成了。

有时你只是使用 goto。 或者您手动分解子循环的函数。 或者您使用标志来突破深度递归。 或者您将整个 3 层循环放在它自己的函数中。 或者,您可以使用 monad 库编写循环运算符。 或者你可以抛出一个异常(!)并捕获它。

C++中几乎所有问题的答案都是"视情况而定"。 问题的范围和可用的技术数量很大,问题的细节会改变解决方案的细节。

替代 - 1

您可以执行以下操作:

  1. 在开头设置bool变量isOkay = true
  2. 所有for循环条件,添加一个额外的条件isOkay == true
  3. 当您的自定义条件满足/失败时,请设置isOkay = false

这将使您的循环停止。不过,额外的bool变量有时会很方便。

bool isOkay = true;
for (int i = 0; isOkay && i < N; ++i)
{
for (int j = 0; isOkay && j < N; j++)
{
for (int k = 0; isOkay && k < N; ++k)
{
// some code
if (/*your condition*/)
isOkay = false;
}
}
}

备选方案 - 2

其次,如果上述循环迭代在一个函数中,最好的选择是在满足自定义条件时return结果。

bool loop_fun(/* pass the array and other arguments */)
{
for (int i = 0; i < N ; ++i)
{
for (int j = 0; j < N ; j++)
{
for (int k = 0; k < N ; ++k)
{
// some code
if (/* your condition*/)
return false;
}
}
}
return true;
}

将 for 循环分解为函数。 它使事情更容易理解,因为现在您可以看到每个循环的实际操作。

bool doHerpDerp() {
for (i = 0; i < N; ++i) 
{
if (!doDerp())
return false;
}
return true;
}
bool doDerp() {
for (int i=0; i<X; ++i) {
if (!doHerp())
return false;
}
return true;
}
bool doHerp() {
if (shouldSkip)
return false;
return true;
}
还有其他被认为是

好的做法吗?我错了吗我说使用 goto 被认为是不好的做法?

goto可能会被滥用和过度使用,但我在您的示例中没有看到两者中的任何一个。打破深度嵌套的循环最清楚地表达为一个简单的goto label_out_of_the_loop;

使用许多跳转到不同标签的goto是一种不好的做法,但在这种情况下,并不是关键字goto本身会使您的代码变坏。事实上,你在代码中跳来跳去,使其难以理解,这使得它变得糟糕。但是,如果您需要从嵌套循环中跳出单次跳转,那么为什么不使用为此目的而制作的工具。

用一个凭空捏造的比喻:想象一下,你生活在一个过去把钉子钉在墙上很时髦的世界里。最近,使用螺丝刀将螺丝钻入墙壁变得更加流行,锤子完全过时了。现在考虑你必须(尽管有点老了)把钉子钉在墙上。你不应该避免使用锤子来做到这一点,但也许你应该问问自己,你是否真的需要在墙上钉钉子而不是螺丝钉。

(以防万一不清楚:锤子是goto的,墙上的钉子是从嵌套环中跳出来的,而墙上的螺丝将使用功能来避免深度嵌套;)

一种可能的方法是将布尔值分配给表示状态的变量。稍后可以使用"IF"条件语句在代码中测试此状态,以用于其他目的。

就您对效率的评论而言,在Visual Studio 2017的发布模式下编译这两个选项会产生完全相同的程序集。

for (int i = 0; i < 5; ++i)
{
for (int j = 0; j < 5; ++j)
{
for (int k = 0; k < 5; ++k)
{
if (i == 1 && j == 2 && k == 3) {
goto end;
}
}
}
}
end:;

并带有旗帜。

bool done = false;
for (int i = 0; i < 5; ++i)
{
for (int j = 0; j < 5; ++j)
{
for (int k = 0; k < 5; ++k)
{
if (i == 1 && j == 2 && k == 3) {
done = true;
break;
}
}
if (done) break;
}
if (done) break;
}

两者都生产..

xor         edx,edx  
xor         ecx,ecx  
xor         eax,eax  
cmp         edx,1  
jne         main+15h (0C11015h)  
cmp         ecx,2  
jne         main+15h (0C11015h)  
cmp         eax,3  
je          main+27h (0C11027h)  
inc         eax  
cmp         eax,5  
jl          main+6h (0C11006h)  
inc         ecx  
cmp         ecx,5  
jl          main+4h (0C11004h)  
inc         edx  
cmp         edx,5  
jl          main+2h (0C11002h)    

所以没有收获。如果您使用现代 c++ 编译器,另一种选择是将其包装在 lambda 中。

[](){
for (int i = 0; i < 5; ++i)
{
for (int j = 0; j < 5; ++j)
{
for (int k = 0; k < 5; ++k)
{
if (i == 1 && j == 2 && k == 3) {
return;
}
}
}
}
}();

同样,这将产生完全相同的程序集。我个人认为在您的示例中使用 goto 是完全可以接受的。很明显,其他人发生了什么,并使代码更加简洁。可以说,lambda同样简洁。

特定

IMO,在这个特定示例中,我认为重要的是要注意循环之间的共同功能。(现在我知道你的例子在这里不一定是字面意思,但请耐心等待我一秒钟)随着每个循环迭代N次,您可以像下面这样重组代码:

int max_iterations = N * N * N;
for (int i = 0; i < max_iterations; i++)
{
/* do stuff, like the following for example */
*(some_ptr + i) = 0; // as opposed to *(some_3D_ptr + i*X + j*Y + Z) = 0;
// some_arr[i] = 0; // as oppose to some_3D_arr[i][j][k] = 0;
}

现在,重要的是要记住,所有循环,无论是 for 还是其他,实际上都只是 if-goto 范式的合成糖。我同意其他人的观点,你应该让一个函数返回结果,但是我想展示一个像上面这样的例子,其中可能并非如此。当然,我会在代码审查中标记上述内容,但如果您将上述内容替换为goto,我会认为这是朝着错误方向迈出的一步。(注意 - 确保您可以可靠地将其放入所需的数据类型中)

常规

现在,作为一般答案,循环的退出条件可能每次都不相同(如有问题的帖子)。作为一般规则,尽可能多地从循环(乘法等)中提取不必要的操作,虽然编译器每天都变得越来越聪明,但编写高效和可读的代码是无可替代的。

/* matrix_length: int of m*n (row-major order) */
int num_squared = num * num;
for (int i = 0; i < matrix_length; i++)
{
some_matrix[i] *= num_squared; // some_matrix is a pointer to an array of ints of size matrix_length
}

与其编写*= num * num,我们不再需要依赖编译器来为我们优化它(尽管任何好的编译器都应该)。因此,执行上述功能的任何双重或三重嵌套循环不仅有利于您的代码,而且有利于 IMO 编写干净高效的代码。在第一个例子中,我们可以改用*(some_3D_ptr + i*X + j*Y + Z) = 0;!我们是否相信编译器会优化i*Xj*Y,这样它们就不会在每次迭代时都计算出来?

bool check_threshold(int *some_matrix, int max_value)
{
for (int i = 0; i < rows; i++)
{
int i_row = i*cols; // no longer computed inside j loop unnecessarily.
for (int j = 0; j < cols; j++)
{
if (some_matrix[i_row + j] > max_value) return true;
}
}
return false;
}

呸!为什么我们不使用 STL 或 Boost 等库提供的类?(我们必须在这里做一些低级/高性能的代码)。由于复杂性,我什至无法编写3D版本。即使我们已经手动优化了一些东西,如果你的编译器允许,使用 #pragma 展开或类似的预处理器提示甚至可能更好。

结论

通常,您可以使用的抽象级别越高越好,但是如果将整数的一维行主序矩阵别名化为二维数组会使您的代码流更难理解/扩展,值得吗?同样,这也可能是使某些东西成为其自身功能的指标。我希望,鉴于这些例子,你可以看到不同的地方需要不同的范式,作为程序员,你的工作就是弄清楚这一点。不要对上述内容发疯,但请确保您知道它们的含义,如何使用它们以及何时需要它们,最重要的是,确保使用您的代码库的其他人也知道它们是什么并且对此没有任何疑虑。祝你好运!

bool meetCondition = false;
for (i = 0; i < N && !meetCondition; ++i) 
{
for (j = 0; j < N && !meetCondition; j++) 
{
for (k = 0; k < N && !meetCondition; ++k) 
{
...
if (condition)
meetCondition = true;
...
}
}
}

已经有几个很好的答案告诉你如何重构你的代码,所以我不会重复它们。 不再需要以这种方式编码以提高效率;问题是它是否不优雅。(好的,我建议进行一个改进:如果你的帮助程序函数只打算在该函数的主体中使用,你可以通过将它们声明为static来帮助优化器,这样它就可以确定该函数没有外部链接,并且永远不会从任何其他模块调用, 暗示inline不会受到伤害。 但是,以前的答案表明,当您使用 lambda 时,现代编译器不需要任何此类提示。

我将稍微挑战一下这个问题的框架。 你是对的,大多数程序员都忌讳使用goto。 在我看来,这已经忘记了最初的目的。 当 Edsger Dijkstra 写下"Go To Statement 被认为是有害的"时,他这么认为有一个具体的原因:与递归函数调用(他更喜欢)或迭代循环(他接受)的控制流相比,go to的"肆无忌惮"的使用使得正式推理当前程序状态以及当前必须满足的条件太难了。 他总结道:

go to的陈述太原始了;它太邀请把一个人的程序弄得一团糟了。人们可以认为并理解那些被认为阻碍其使用的条款。我并不是说提到的条款是详尽无遗的,因为它们将满足所有需求,但无论建议什么条款(例如堕胎条款),它们都应该满足这样的要求,即可以维护一个独立于程序员的坐标系,以有用和可管理的方式描述过程。

许多类C编程语言,例如Rust和Java,确实有一个额外的"子句被认为是限制其使用"的,即标签break。 更受限制的语法可能是break 2 continue;脱离嵌套循环的两个级别,并在包含它们的循环的顶部恢复。 这并不比 C 风格的breakDijkstra 想要做的事情更成问题:定义程序状态的简洁描述,程序员可以在他们的头脑中跟踪,或者静态分析器会发现易于处理。

goto限制为这样的结构,使其只是对标签的重命名中断。 剩下的问题是编译器和程序员不一定知道你只会以这种方式使用它。

如果在循环之后有一个重要的后置条件,并且您对goto的关注与Dijkstra的关注相同,则可以考虑在简短的评论中说明它,例如// We have done foo to every element, or encountered condition and stopped.这将减轻人类的问题,静态分析器应该做得很好。

最好的解决方案是将循环放入一个函数中,然后从该函数return

这基本上与你的goto例子相同,但有很大的好处,你可以避免再次进行goto辩论。

简化的伪代码:

bool function (void)
{
bool result = something;

for (i = 0; i < N; ++i) 
for (j = 0; j < N; j++) 
for (k = 0; k < N; ++k) 
if (condition)
return something_else;
...
return result;
}

这里的另一个好处是,如果您遇到 2 种以上的场景,您可以从bool升级到enum。你不能真正以可读的方式用goto做到这一点。当您开始使用多个gotos和多个标签的那一刻,就是您接受意大利面编码的那一刻。是的,即使您只是向下分支 - 阅读和维护也不会很漂亮。

值得注意的是,如果你有 3 个嵌套的 for 循环,这可能表明你应该尝试将代码拆分为几个函数,然后整个讨论甚至可能无关紧要。