| 目录 |

31 goto

goto 语句广受尊崇,在这里可以毫无争议地介绍。

开个玩笑!多年来,关于 goto 是否被认为有害175,一直有大量反反复复的争论(结论常常是:有害)。

在这位程序员看来,你应该使用能写出最好代码的那种结构,同时把可维护性和速度考虑在内。而有时候,那种结构可能就是 goto!

本章中,我们会看看 goto 在 C 中如何工作,然后考察一些常见的使用场景176。

31.1 一个简单的示例

在这个例子中,我们将用 goto 跳过一行代码,跳到一个标签。标签是一个标识符,可以作为 goto 的目标——它以冒号(:)结尾。

#include <stdio.h>

int main(void)
{
    printf("One\n");
    printf("Two\n");

    goto skip_3;

    printf("Three\n");

skip_3:

    printf("Five!\n");
}

输出为:

One
Two
Five!

goto 让执行直接跳到指定的标签,跳过中间的所有内容。

用 goto 既能向前跳,也能向后跳。

infinite_loop:
    print("Hello, world!\n");
    goto infinite_loop;

执行过程中,标签会被跳过。下面的代码会按顺序打印全部三个数字,就好像标签不存在一样:

    printf("Zero\n");
label_1:
label_2:
    printf("One\n");
label_3:
    printf("Two\n");
label_4:
    printf("Three\n");

正如你所注意到的,按惯例通常把标签一直左对齐到最左边。这能提高可读性,因为读者可以快速扫视,找到目标位置。

标签具有函数作用域。也就是说,无论它们出现在嵌套多少层的块里,你仍然可以从函数中的任何位置 goto 到它们。

这也意味着,你只能 goto 与 goto 自身位于同一函数的标签。从 goto 的角度看,其他函数中的标签不在作用域内。它还意味着,你可以在两个函数中使用相同的标签名——只是不能在同一函数中使用相同的标签名。

31.2 带标签的 continue

在某些语言中,你其实可以为 continue 语句指定一个标签。C 不允许这样做,但你可以轻松改用 goto。

为了说明问题,看看这个嵌套循环里的 continue:

for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
        printf("%d, %d\n", i, j);
        continue;   // 总是跳到下一个 j
    }
}

如我们所见,那个 continue 和所有 continues 一样,会进入最近的外层循环的下一次迭代。如果我们想在外一层循环(带 i 的那个)里 continue,该怎么办?

那么,我们可以用 break 回到外层循环,对吧?

for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
        printf("%d, %d\n", i, j);
        break;     // 让我们进入 i 的下一次迭代
    }
}

这样我们能跳出两层嵌套循环。但如果再嵌套一层,我们就无计可施了。那下面这种情况呢?我们没有任何语句能进入 i 的下一次迭代。

for (int i = 0; i < 3; i++) {
    for (int j = 0; j < 3; j++) {
        for (int k = 0; k < 3; k++) {
            printf("%d, %d, %d\n", i, j, k);

            continue;  // 让我们进入 k 的下一次迭代
            break;     // 让我们进入 j 的下一次迭代
            ????;      // 让我们进入 i 的下一次迭代???

        }
    }
}

goto 语句给我们提供了一条路!

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            for (int k = 0; k < 3; k++) {
                printf("%d, %d, %d\n", i, j, k);

                goto continue_i;   // 现在继续 i 循环!!
            }
        }
continue_i: ;
    }

我们在末尾放了一个 ;——这是因为标签不能直接指向复合语句的末尾(也不能放在变量声明之前)。

31.3 跳出重围

当你深陷层层嵌套的代码中间时,可以用 goto 脱身,这种方式往往比再嵌套更多 if、再使用标志变量更干净。

    // 伪代码

    for(...) {
        for (...) {
            while (...) {
                do {
                    if (some_error_condition)
                        goto bail;

                } while(...);
            }
        }
    }

bail:
    // 在这里清理

不用 goto 的话,你就得在每一层循环里检查错误状态标志,才能一路退出来。

31.4 带标签的 break

这种情况与 continue 只继续最内层循环非常相似。break 也只会跳出最内层循环。

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            printf("%d, %d\n", i, j);
            break;   // 只跳出 j 循环
        }
    }

    printf("Done!\n");

但我们可以用 goto 跳得更远:

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            printf("%d, %d\n", i, j);
            goto break_i;   // 现在跳出 i 循环!
        }
    }

break_i:

    printf("Done!\n");

31.5 多级清理

如果你调用多个函数来初始化多个系统,而其中一个失败了,那么你只应反初始化那些目前已经初始化过的系统。

我们来做一个人为的示例:开始初始化各个系统,并检查是否有调用返回错误(我们用 -1 表示错误)。如果其中一个出错,我们就只关闭目前已初始化的那些系统。

    if (init_system_1() == -1)
        goto shutdown;

    if (init_system_2() == -1)
        goto shutdown_1;

    if (init_system_3() == -1)
        goto shutdown_2;

    if (init_system_4() == -1)
        goto shutdown_3;

    do_main_thing();   // 运行我们的程序

    shutdown_system4();

shutdown_3:
    shutdown_system3();

shutdown_2:
    shutdown_system2();

shutdown_1:
    shutdown_system1();

shutdown:
    print("All subsystems shut down.\n");

注意,我们按与初始化子系统相反的顺序来关闭它们。所以如果子系统 4 启动失败,就会依次关闭 3、2,然后是 1。

31.6 尾调用优化

算是吧。仅限递归函数。

如果你不熟悉,尾调用优化(TCO)177 是一种在非常特定的情况下调用其他函数时避免浪费栈空间的方法。遗憾的是,其中的细节超出了本指南的范围。

但如果你有一个已知可以按这种方式优化的递归函数,就可以利用这个技巧。(注意,由于标签具有函数作用域,你无法尾调用其他函数。)

我们来看一个直白的例子:阶乘。

下面是一个递归版本,它不是 TCO,但可以改成 TCO!

#include <stdio.h>
#include <complex.h>

int factorial(int n, int a)
{
    if (n == 0)
        return a;

    return factorial(n - 1, a * n);
}

int main(void)
{
    for (int i = 0; i < 8; i++)
        printf("%d! == %ld\n", i, factorial(i, 1));
}

要实现这一点,可以把那次调用换成两步:

  1. 把各个形参的值设为下一次调用时它们应有的值。
  2. 用 goto 跳到函数第一行的标签。

我们来试试:

#include <stdio.h>

int factorial(int n, int a)
{
tco:  // 加上这个

    if (n == 0)
        return a;

    // 用设置新形参值的方式取代 return,并
    // goto 到函数的开头

    // 已注释掉:return factorial(n - 1, a * n);

    int next_n = n - 1;  // 看看这些如何与
    int next_a = a * n;  // 上面的递归实参对应起来?

    n = next_n;   // 把形参设为新的值
    a = next_a;

    goto tco;   // 然后重复!
}

int main(void)
{
    for (int i = 0; i < 8; i++)
        printf("%d! == %d\n", i, factorial(i, 1));
}

我在上面用了临时变量,在跳到函数开头之前设置好形参的下一轮取值。看出它们如何对应递归调用中原有的那些递归实参了吗?

那么,为什么要用临时变量呢?我本可以改成这样:

    a *= n;
    n -= 1;

    goto tco;

这样其实完全能正常工作。但如果我粗心地把这两行代码的顺序颠倒过来:

    n -= 1;  // 坏消息
    a *= n;

——这下就麻烦了。我们在用它去修改 a 之前,就先改掉了 n。这很糟糕,因为递归调用时并不是这样运作的。使用临时变量则能避免这个问题,哪怕你并没有特意提防。而且编译器多半也会把它们优化掉。

31.7 重启被中断的系统调用

这不在规范之内,但在类 Unix 系统中很常见。

某些长时间运行的系统调用如果被信号打断,可能会返回错误,此时 errno 会被设为 EINTR,表示该系统调用本身一切正常,只是被打断了。

在这种情况下,程序员往往很想重新发起这次调用、再试一遍。

retry:
    byte_count = read(0, buf, sizeof(buf) - 1);  // Unix read() 系统调用

    if (byte_count == -1) {            // 发生了一个错误……
        if (errno == EINTR) {          // 但它只是被中断了
            printf("Restarting...\n");
            goto retry;
        }

许多类 Unix 系统都有 SA_RESTART 标志,可以传给 sigaction(),请求操作系统自动重启所有慢速系统调用,而不是让它们以 EINTR 失败。

再说一次,这是 Unix 专有的做法,不在 C 标准之内。

话虽如此,但凡是需要重启某个函数的场合,都可以用类似的技巧。

31.8 goto 与线程抢占

这个例子直接取自《操作系统:三个简单部分》,那是另一位志同道合的作者写的又一本好书,他也认为好书应当可以免费下载。倒不是说我这人爱固执己见。

retry:

    pthread_mutex_lock(L1);

    if (pthread_mutex_trylock(L2) != 0) {
        pthread_mutex_unlock(L1);
        goto retry;
    }

    save_the_day();

    pthread_mutex_unlock(L2);
    pthread_mutex_unlock(L1);

在那里,线程愉快地获取了互斥量 L1,但随后可能拿不到由互斥量 L2 保护的第二份资源(比方说,被另一个不配合的线程占着)。如果我们的线程拿不到 L2 锁,它就解锁 L1,然后用 goto 干净利落地重试。

我们希望这个英勇的线程最终能同时拿到两把互斥量、拯救世界,同时避开邪恶的死锁。

31.9 goto 与变量作用域

我们已经看到,标号具有函数作用域,但如果我们跳过某些变量的初始化,就会发生奇怪的事情。

看看这个例子:我们从变量 x 不在作用域内的位置,跳进了它作用域的中段(也就是那个代码块里)。

    goto label;

    {
        int x = 12345;

label:
        printf("%d\n", x);
    }

这段代码能编译、能运行,但会给我一个警告:

warning: ‘x’ is used uninitialized in this function

然后运行时它打印出 0(你的结果可能不同)。

基本上,发生的事情是:我们跳进了 x 的作用域(所以在 printf() 里引用它是没问题的),但我们跳过了真正把它初始化为 12345 的那一行。所以这个值是不确定的。

当然,解决办法就是想方设法把初始化放到标号之后。

    goto label;

    {
        int x;

label:
        x = 12345;
        printf("%d\n", x);
    }

我们再看一个例子。

    {
        int x = 10;

label:

        printf("%d\n", x);
    }

    goto label;

这里会发生什么?

第一次进入这个代码块时,一切正常。x 是 10,打印出来的也是这个值。

但在 goto 之后,我们是跳进了 x 的作用域,却越过了它的初始化。这意味着我们仍然可以打印它,但值是不确定的(因为它没有被重新初始化)。

在我的机器上,它又一次打印出 10(如此无限循环下去),但这只是运气好。由于 x 未初始化,goto 之后它可能打印出任何值。

31.10 goto 与变长数组

说到变长数组和 goto,只有一条规则:不能从某个变长数组的作用域之外跳进该变长数组的作用域。

如果我这样做:

    int x = 10;

    goto label;

    {
        int v[x];

label:

        printf("Hi!\n");
    }

就会报错:

error: jump into scope of identifier with variably modified type

你可以跳到变长数组声明之前的位置,就像这样:

    int x = 10;

    goto label;

    {
label:  ;
        int v[x];

        printf("Hi!\n");
    }

因为这样变长数组才能被正确地分配,并在它脱离作用域时被正常释放。


| 目录 |