gotogoto 语句广受尊崇,在这里可以毫无争议地介绍。
开个玩笑!多年来,关于 goto 是否被认为有害175,一直有大量反反复复的争论(结论常常是:有害)。
在这位程序员看来,你应该使用能写出最好代码的那种结构,同时把可维护性和速度考虑在内。而有时候,那种结构可能就是 goto!
本章中,我们会看看 goto 在 C 中如何工作,然后考察一些常见的使用场景176。
在这个例子中,我们将用 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 的角度看,其他函数中的标签不在作用域内。它还意味着,你可以在两个函数中使用相同的标签名——只是不能在同一函数中使用相同的标签名。
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: ;
}我们在末尾放了一个 ;——这是因为标签不能直接指向复合语句的末尾(也不能放在变量声明之前)。
当你深陷层层嵌套的代码中间时,可以用 goto 脱身,这种方式往往比再嵌套更多 if、再使用标志变量更干净。
// 伪代码
for(...) {
for (...) {
while (...) {
do {
if (some_error_condition)
goto bail;
} while(...);
}
}
}
bail:
// 在这里清理不用 goto 的话,你就得在每一层循环里检查错误状态标志,才能一路退出来。
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");如果你调用多个函数来初始化多个系统,而其中一个失败了,那么你只应反初始化那些目前已经初始化过的系统。
我们来做一个人为的示例:开始初始化各个系统,并检查是否有调用返回错误(我们用 -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。
算是吧。仅限递归函数。
如果你不熟悉,尾调用优化(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));
}要实现这一点,可以把那次调用换成两步:
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。这很糟糕,因为递归调用时并不是这样运作的。使用临时变量则能避免这个问题,哪怕你并没有特意提防。而且编译器多半也会把它们优化掉。
这不在规范之内,但在类 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 标准之内。
话虽如此,但凡是需要重启某个函数的场合,都可以用类似的技巧。
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 干净利落地重试。
我们希望这个英勇的线程最终能同时拿到两把互斥量、拯救世界,同时避开邪恶的死锁。
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 之后它可能打印出任何值。
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");
}因为这样变长数组才能被正确地分配,并在它脱离作用域时被正常释放。