如何避免使用 goto 并有效地打破嵌套循环

How to avoid use of goto and break nested loops efficiently

我想说的是,在 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 被认为是一种不好的做法时,我错了吗?如果我是,这会是使用它的好处之一吗?

谢谢

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

备选 - 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;
}

如果你有这样的深层嵌套循环并且你必须突破,我相信goto是最佳解决方案。某些语言(不是 C)有一个 break(N) 语句可以跳出多个循环。 C 没有它的原因是它甚至 goto 更差:你必须计算嵌套循环才能弄清楚它做了什么,而且它容易受到某些人的攻击稍后出现并添加或删除嵌套级别,而没有注意到需要调整中断计数。

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

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

(有些人说函数必须始终 return 在最后,而不是从中间开始。这些人会说简单的 break-it-out-a-function 解决方案因此无效,并且他们会强制使用相同的笨拙的内循环突破技术,即使搜索被拆分为它自己的功能。就我个人而言,我相信那些人是错误的,但是您的里程可能会有所不同。)

如果你坚持不使用 goto,并且如果你坚持不使用带有早期 return 的单独函数,那么是的,你可以做一些事情,比如维护额外的布尔控制变量并在每个嵌套循环的控制条件下冗余地测试它们,但这只是一件麻烦事和一团糟。 (这是我用简单的 goto 表示小于的最大弊端之一。)

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;
            ...
        }
    }
}

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

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 */
            }
        }
    }
}

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

将 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;
}

Is there any other that is considered a good practice? Am I wrong when I say it is considered a bad practice to use goto?

goto 可能被误用和过度使用,但我在您的示例中没有看到这两者中的任何一个。跳出深层嵌套循环最清楚地用简单的 goto label_out_of_the_loop; 表示。

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

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

(以防万一不清楚:锤子是 goto 墙上的钉子是跳出嵌套循环,而墙上的螺丝会使用函数来避免深度嵌套一起 ;)

最好的解决方案是将循环放在一个函数中,然后从该函数中 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 以可读的方式真正做到这一点。你开始使用多个 goto 和多个标签的那一刻,就是你拥抱意大利面条编码的那一刻。是的,即使你只是向下分支——阅读和维护也不会很好。

值得注意的是,如果您有 3 个嵌套的 for 循环,这可能表明您应该尝试将代码拆分为多个函数,这样整个讨论甚至可能都不相关。

在这种情况下,您不会不想避免使用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 双向跳转,或者跳转到可能绕过变量初始化的范围,that 会很糟糕.

这是 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=%d\n", y);
}

C++ 编译器会在此处抛出错误,因为 goto 跳过 y 的初始化。然而,C 编译器将编译它,并且上面的代码将在尝试打印 y 时调用 undefined behavior,如果 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++,因为在后一种情况下,您可以将 类 与适当的析构函数和智能指针一起使用,以避免手动清理。

就您对在 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 同样简洁。

我认为 goto 在这里做是一件非常明智的事情,并且是 C++ Core Guidelines.

的特殊用例之一

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

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

感谢 JohnMcPineapple 在 reddit 上提出此建议!

Lambdas 让您创建本地作用域:

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

如果您还想要 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;
}

其中 returning {}boost::nullopt 是一个 "break",并且 returning 一个值 returns 一个来自封闭的值范围。

另一种方法是:

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.

中变为

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

这很好。

现在,在您应该具有响应能力的应用程序中,在主要控制流级别上遍历较大的 3 维范围通常不是一个好主意。您可以将其线程化,但即便如此,您最终也会遇到线程运行时间过长的问题。我玩过的大多数 3 维大型迭代都可以从使用子任务线程本身中获益。

为此,您最终会希望根据操作访问的数据类型对您的操作进行分类,然后将您的操作传递给为您安排迭代的东西 .

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 不会是一个简单的裸循环,而是一个将每个体素任务重写为每个扫描线任务(甚至是每个平面任务,具体取决于planes/scanlines 在运行时 (!)) 然后依次将它们分派给线程池管理的任务调度程序,拼接生成的任务句柄,以及 return 可以等待的类似未来的 work打开或用作工作完成时的继续触发器。

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

中几乎 每个 问题的答案都是 "it depends"。问题的范围和可用的技术数量很大,问题的细节改变了解决方案的细节。

具体

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 范式的语法糖。我同意其他人的意见,你应该有一个函数 return 结果,但是我想展示一个像上面这样的例子,在这个例子中可能不是这样。当然,我会在代码审查中标记上面的内容,但如果你用 goto 替换上面的内容,我会认为这是朝着错误方向迈出的一步。 (注意——确保您可以可靠地将其适合您所需的数据类型)

一般

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

例子

/* 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 这样的库提供的 类? (我们必须在这里做一些低 level/high 性能代码)。由于复杂性,我什至无法编写 3D 版本。尽管我们已经手动优化了一些东西,但如果您的编译器允许,使用 #pragma unroll 或类似的预处理器提示可能会更好。

结论

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

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

我要挑战一下问题的框架。你是对的,大多数程序员都有使用 goto 的禁忌。在我看来,这已经失去了最初的目的。当 Edsger Dijkstra 写道,“Go To Statement Considered Harmful”时,他这样认为有一个特定的原因:go to 的“肆无忌惮”使用使得对当前程序状态的正式推理变得太难了,什么条件必须与来自递归函数调用(他更喜欢)或迭代循环(他接受)的控制流相比,目前是正确的。他总结道:

The go to statement as it stands is just too primitive; it is too much an invitation to make a mess of one's program. One can regard and appreciate the clauses considered as bridling its use. I do not claim that the clauses mentioned are exhaustive in the sense that they will satisfy all needs, but whatever clauses are suggested (e.g. abortion clauses) they should satisfy the requirement that a programmer independent coordinate system can be maintained to describe the process in a helpful and manageable way.

许多类 C 的编程语言,例如 Rust 和 Java,确实有一个额外的“被认为限制其使用的子句”,即标签的 break。更受限制的语法可能类似于 break 2 continue; ,用于跳出嵌套循环的两层并在包含它们的循环顶部继续。对于 Dijkstra 想要做的事情来说,这与 C 风格 break 相比没有什么问题:定义程序状态的简明描述,程序员可以在头脑中跟踪,或者静态分析器会发现易于处理。

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

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