3. 一切都与控制有关
本章涵盖:
- 使用
if进行条件执行 - 在范围中迭代
- 进行多路选择
在入门示例清单 1.1 中,我们看到了两种控制程序执行流的构造:函数和 for 迭代。函数用于无条件转移控制。函数调用无条件地把控制转移给被调函数,return 语句则无条件地把控制转移回调用方。第 7 章还会回到函数。
for 语句有所不同:它有一个控制条件(示例中是 i < 5),用来规定内层块({ printf(...) })是否执行,以及在何时执行。C 有五种条件控制语句:if、for、do、while 和 switch。本章将考察这些语句:if 根据布尔表达式引入条件执行;for、do 和 while 是不同形式的迭代;switch 则根据整数值进行多路选择。
C 还有一些将在后面讨论的条件构造:形式为 cond ? A : B 的表达式所表示的三元运算符(第 4.5 节);编译期预处理条件构造 #if、#ifdef、#ifndef、#elif、#elifdef、#elifndef、#else、#endif(第 8.1.5 节);以及用关键字 _Generic 表示的类型泛型表达式(第 18 章)。
3.1 条件执行
首先考察由关键字 if 指定的构造。它看起来像这样:
if (i > 25) {
j = i - 25;
}2
3
这里把 i 与值 25 作比较。如果 i 大于 25,就把 j 设为值 i - 25。在这个示例中,i > 25 称为控制表达式,{ ... } 中的部分称为内层块。
从表面上看,这种形式的 if 语句与已经见过的 for 语句相似,但工作方式不同:圆括号中只有一个部分,它决定内层块执行一次,还是完全不执行。
if 构造还有一种更一般的形式:
if (i > 25) {
j = i - 25;
} else {
j = i;
}2
3
4
5
它又增加了一个内层块;控制条件不满足时,就执行这个内层块。在句法上,这是通过引入另一个关键字 else 来完成的,else 将两个内层块分开。
if (...) ... else ... 是选择语句。它根据 ( ... ) 中的内容,从两条可能的代码路径中选出一条。一般形式如下:
if (condition) secondary-block0
else secondary-block12
condition(控制表达式)有许多种可能形式,从本例中的简单比较,到非常复杂的嵌套表达式都可以。第 4.4.2 节将介绍所有可用的基本结构。
在 if 语句中,最简单的一种 condition 类似下方示例;它是清单 1.1 中 for 循环的一种变体:
for (size_t i = 0; i < 5; ++i) {
if (i) {
printf("element %zu is %g, \tits square is %g\n",
i,
A[i],
A[i]*A[i]);
}
}2
3
4
5
6
7
8
这里,决定是否执行 printf 的条件就是 i:一个数值本身就可以解释为条件。只有 i 的值不是 0 时,才会打印文本。[1]
数值 condition 的求值遵循两条简单规则:
要点 3.1 #1
值 0 表示逻辑假。
要点 3.1 #2
任何不等于 0 的值都表示逻辑真。
运算符 == 和 != 分别用于检验相等与不等。如果 a 的值等于 b 的值,a == b 就为真,否则为假;如果 a 等于 b,a != b 就为假,否则为真。知道了如何把数值作为条件求值,就可以避免冗余。例如,可以把:
if (i != 0) {
...
}2
3
改写为:
if (i) {
...
}2
3
哪个版本可读性更好,属于编码风格问题,可能引发无谓的争论。第一个版本也许更便于偶尔阅读 C 代码的人理解;而在假定读者对 C 类型系统有所了解的项目中,往往更偏爱后一个版本。
应当使用 bool 类型存储真值。它的值是 false 和 true。从技术上说,false 只是 bool 类型的值 0,true 则是值 1。务必使用 false 和 true(而非数字)来强调“值需要解释为条件”。第 5.7.4 节会进一步介绍 bool 类型。
请注意,在 C23 之前,要使用 bool 及其字面量 false 和 true,必须使用 <stdbool.h> 头文件。现在这已经过时;只有怀疑代码可能在旧平台上编译时,才应使用它。
冗余比较难以阅读,会使代码杂乱不堪。如果条件依赖某个真值,就直接用这个真值作为条件。可以通过改写来避免冗余,例如把:
bool b = ...;
...
if ((b != false) == true) {
...
}2
3
4
5
改写为:
bool b = ...;
...
if (b) {
...
}2
3
4
5
要点 3.1 #3
不要与 0、false 或 true 作比较。
直接使用真值(如前面的 i),或使用其否定(C 中写作 !i),可以让代码更清楚,也体现了 C 语言的一项基本概念。
要点 3.1 #4
所有标量都有真值。
这里,标量类型包括已经见过的所有数值类型,例如 size_t、bool 和 int,也包括指针类型。表 3.1 列出了本书经常使用的类型。第 6.2 节还会回到它们。
表 3.1 本书使用的标量类型
| 层级 | 名称 | 其他名称 | 类别 | 所在位置 | printf 格式 |
|---|---|---|---|---|---|
| 0 | size_t | 无符号 | <stddef.h> | "%zu"、"%zx" | |
| 0 | double | 浮点 | 内建 | "%e"、"%f"、"%g"、"%a" | |
| 0 | signed int | 有符号 | 内建 | "%d" | |
| 0 | unsigned | 无符号 | 内建 | "%b"、"%o"、"%u"、"%x" | |
| 0 | bool | _Bool | 无符号 | 内建(自 C23 起) | 以 "%d" 输出为 0 或 1 |
| 1 | ptrdiff_t | 有符号 | <stddef.h> | "%td" | |
| 1 | char const* | 字符串 | 内建 | "%s" | |
| 1 | char | 字符 | 内建 | "%c" | |
| 1 | void* | 指针 | 内建 | "%p" | |
| 2 | unsigned char | 无符号 | 内建 | "%hhu"、"%02hhx" |
3.2 迭代
前面已经遇到过用于在范围上迭代的 for 语句。在入门示例中,它声明了对象 i,并依次将其设为值 0、1、2、3 和 4。该语句的一般形式是:
for (clause1; condition2; expression3) secondary-block这个语句非常通用。通常,clause1 是赋值表达式或对象定义,用来说明迭代范围的初始值。condition2 检验迭代是否应当继续。随后,expression3 更新 clause1 中使用的迭代对象;它在每次迭代结束时执行。下面有两条建议:
- 我们希望迭代对象仅在
for循环的上下文中得到定义(参见要点 2.4.1 #2),因此大多数情况下,clause1应当是对象定义。 for含有四个不同部分,结构相对复杂,视觉上也不容易一眼把握,因此内层块通常应当是由{ ... }标明的复合语句。
再看几个示例:
for (size_t i = 10; i; --i) {
something(i);
}
for (size_t i = 0, stop = upper_bound(); i < stop; ++i) {
something_else(i);
}
for (size_t i = 9; i <= 9; --i) {
something_else(i);
}2
3
4
5
6
7
8
9
10
11
第一个 for 让 i 从 10 倒数到 1,包含两端。这里的条件仍然只是对对象 i 求值,无须冗余地与值 0 比较。当 i 变为 0 时,它求值为假,循环随即停止。第二个 for 声明两个对象 i 和 stop。与前面一样,i 是循环对象,条件中用它与 stop 比较;当 i 大于或等于 stop 时,循环终止。
第三个 for 看起来像是会永远进行下去,实际上却从 9 倒数到 0。下一章会看到,C 中的“大小”(类型为 size_t 的数)永远不会是负数。[2]
请注意,三个 for 语句都声明了名为 i 的对象。只要作用域互不重叠,这三个同名对象就可以相安无事地并存。
C 还有另外两种迭代语句:while 和 do:
while (condition) secondary-block
do secondary-block while(condition);2
下面的示例展示了 while 的一种典型用法。它实现 Heron 近似,用于计算数 a 的乘法逆元
(译者注:这个程序有 bug。下方讲解 Heron 近似的折叠块内会说明 bug 出现的原因,更下方的清单 3.1 给出了更完整、无 bug 的程序)
#include <tgmath.h>
constexpr double 𝜀 = 1E-9; // Desired precision
...
double const a = 34.0;
double x = 0.5;
while (fabs(1.0 - a*x) >= 𝜀) { // Iterates until close
x *= (2.0 - a*x); // Heron approximation
}2
3
4
5
6
7
8
译者注:Heron 近似
代码里面算了一个数列:
如果这个数列收敛,假设极限为
通过这种方式,不需要使用高代价的除法运算符 /,仅靠乘法和减法就能求出
然而,初始值
只要给定条件求值为真(这里只要 1.0 与所计算乘积之差的绝对值不小于 𝜀),迭代就会继续。𝜀 表示计算所需的最低精度。[3] do 循环与它非常相似,只是会在内层块之后检查条件:
do { // Iterates
x *= (2.0 - a*x); // Heron approximation
} while (fabs(1.0 - a*x) >= 𝜀); // Iterates until close2
3
这意味着,如果条件立即求值为假,while 循环完全不会运行内层块;do 循环则无条件地至少运行内层块一次,之后才第一次检查条件。与 for 语句一样,对 do 和 while 也建议采用 { ... } 形式。两者之间还有一个细微的句法差异:do 的 while (condition) 后面始终需要分号来终止语句。后面会看到,这项句法特征在多个嵌套语句的上下文中相当有用;参见第 10.2.1 节。
使用 break 和 continue 语句,可以让三种迭代语句都变得更加灵活。break 语句会停止循环,既不重新求值终止条件,也不执行内层块中 break 之后的部分:
while (true) {
double prod = a*x;
if (fabs(1.0 - prod) < 𝜀) { // Stops if close enough
break;
}
x *= (2.0 - prod); // Heron approximation
}2
3
4
5
6
7
这样就可以把乘积 a*x 的计算、停止条件的求值以及 x 的更新彼此分开,而 while 的条件也随之变得十分简单。用 for 可以做同样的事;C 程序员历来常把它写成下面这样:
for (;;) {
double prod = a*x;
if (fabs(1.0 - prod) < 𝜀) { // Stops if close enough
break;
}
x *= (2.0 - prod); // Heron approximation
}2
3
4
5
6
7
这里的 for(;;) 等价于 while(true)。for 的控制表达式(两个 ; 之间的中间部分)可以省略,并解释为“始终为真”;这只是 C 规则中的历史遗留,并无其他特殊用途。
continue 语句用得较少。与 break 一样,它会跳过内层块其余部分的执行,因此当前迭代不会执行复合语句中位于 continue 之后的所有语句。不过,随后它会重新求值条件;如果条件为真,就从内层块开头继续:
for (size_t i = 0; i < max_iterations; ++i) {
if (x > 1.0) { // Checks if we are on the correct side of 1
x = 1.0/x;
continue;
}
double prod = a*x;
if (fabs(1.0 - prod) < 𝜀) { // Stops if close enough
break;
}
x *= (2.0 - prod); // Heron approximation
}2
3
4
5
6
7
8
9
10
11
这些示例使用了 <tgmath.h> 头文件中的标准宏 fabs。[4] 它用于计算 double 值的绝对值。清单 3.1 展示了实现该算法的完整程序,不同之处在于它没有使用 fabs,而是显式地与几个固定数值进行比较。例如,eps1m24 被定义为 eps1p24 被定义为 0x1P-24(代表数值 constexpr 构造(第 5.6.5 节)结合使用。
清单 3.1 计算数的乘法逆元
#include <stdlib.h>
#include <stdio.h>
/* lower and upper iteration limits centered around 1.0 */
constexpr double eps1m01 = 1.0 - 0x1P-01;
constexpr double eps1p01 = 1.0 + 0x1P-01;
constexpr double eps1m24 = 1.0 - 0x1P-24;
constexpr double eps1p24 = 1.0 + 0x1P-24;
int main (int argc, char* argv[argc+1]) {
for (int i = 1; i < argc; ++i) { // process args
double const a = strtod(argv[i], nullptr); // arg -> double
double x = 1.0;
for (;;) { // by powers of 2
double prod = a*x;
if (prod < eps1m01) {
x *= 2.0;
} else if (eps1p01 < prod) {
x *= 0.5;
} else {
break;
}
}
for (;;) { // Heron approximation
double prod = a*x;
if ((prod < eps1m24) || (eps1p24 < prod)) {
x *= (2.0 - prod);
} else {
break;
}
}
printf("heron: a=%.5e,\tx=%.5e,\ta*x=%.12f\n",
a, x, a*x);
}
return EXIT_SUCCESS;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
第一阶段把当前考察的数 a 与当前估计值 x 的乘积同 1.5 和 0.5 比较,然后让 x 乘以 0.5 或 2,直到乘积接近 1。第二阶段再使用代码所示的 Heron 近似不断逼近,以很高的精度计算乘法逆元。
程序的总体任务,是计算命令行所提供的所有数的逆元。一次程序执行可能如下:
> ./heron 0.07 5 6E+23
heron: a=7.00000e-02, x=1.42857e+01, a*x=0.999999999996
heron: a=5.00000e+00, x=2.00000e-01, a*x=0.999999999767
heron: a=6.00000e+23, x=1.66667e-24, a*x=0.9999999970282
3
4
为了处理命令行中的数,程序还使用了 <stdlib.h> 中的库函数 strtod。[5] [6] [7]
挑战 1:顺序排序算法
你能对任意选择的排序键数组(例如 double 或字符串)完成下面两种排序吗?
- 归并排序(使用递归)
- 快速排序(使用递归)
如果不知道程序是否正确,完成它们也不会带来任何收获。因此,你能否提供一个简单的测试例程,检查所得数组是否确实有序?
这个测试例程应当只扫描数组一次,而且应当比排序算法快得多、快得多。
3.3 多路选择
C 提供的最后一种控制语句是 switch,它也是一种选择语句。它主要用于 if-else 构造级联起来过于繁琐的情况:
if (arg == 'm') {
puts("this is a magpie");
} else if (arg == 'r') {
puts("this is a raven");
} else if (arg == 'j') {
puts("this is a jay");
} else if (arg == 'c') {
puts("this is a chough");
} else {
puts("this is an unknown corvid");
}2
3
4
5
6
7
8
9
10
11
这里面对的是比真假判断更复杂、可能产生多种结果的选择。可以把它简化成:
switch (arg) {
case 'm': puts("this is a magpie");
break;
case 'r': puts("this is a raven");
break;
case 'j': puts("this is a jay");
break;
case 'c': puts("this is a chough");
break;
default: puts("this is an unknown corvid");
}2
3
4
5
6
7
8
9
10
11
这里根据对象 arg 的值选择一次 puts 调用。与 printf 一样,函数 puts 由 <stdio.h> 提供。它输出一行作为实参传入的字符串。我们为字符 'm'、'r'、'j'、'c' 提供了特定分支,还提供了一个标为 default 的特殊分支。如果 arg 与任何 case 值都不匹配,就会触发 default 分支。[8]
从句法上说,switch 很简单:
switch (expression) secondary-block它的语义也相当直接:case 和 default 标签充当跳转目标。控制根据表达式的值,从带有相应标签的语句处继续。如果遇到 break 语句,其所在的整个 switch 语句就会终止,程序控制随即转移到 switch 之后的下一个语句。依照这项规定,switch 语句的用途可以比逐层嵌套的 if-else 构造广泛得多:
switch (count) {
default: puts("++++ ..... +++");
case 4: puts("++++");
case 3: puts("+++");
case 2: puts("++");
case 1: puts("+");
case 0: // prior to C23 this needed an extra ";"
}2
3
4
5
6
7
8
一旦跳入内层块,程序就会继续执行,直到遇到 break 或到达块末尾。这里没有 break 语句,因此最终会运行后面的所有 puts 语句。例如,count 的值为 3 时,输出是一个三行三角形:
+++
++
+2
3
switch 的结构可以比 if-else 更灵活,但在其他方面受到限制。
要点 3.3 #1
case 值必须是整数常量表达式。
要点 3.3 #2
每个 switch 语句中的 case 值必须唯一。
第 5.6.2 节会详细说明这些表达式是什么。现在只需知道,它们必须是直接写入源代码的固定值,例如前一个示例中的 4、3、2、1、0。尤其要注意,count 这样的对象只能出现在 switch 部分,不能出现在各个 case 中。
switch 语句灵活性更高,却也为此付出了代价:它更容易出错。特别是,我们可能会意外跳过对象定义:
switch (x) {
unsigned tmp = 45;
...
case 0: printf("the temp is %u\n", tmp); // tmp may be uninitialized
}2
3
4
5
这类对象本身有效,但其初始化器可能从未执行;例如 x 为零时就是如此。因此在这个示例中,如果某次程序执行时 x 恰好为零,执行就会出错。第 5.5 节将更详细地讨论初始化以及缺少初始化的情形。
要点 3.3 #3
case 标签不得跳过对象定义。
在 C23 以前,case 标签的位置限制甚至更加严格。它们必须标记一个语句:标签不能放在声明之前,也不能直接放在复合语句的右花括号 } 之前。
挑战 2:数值导数
本书会大量涉及数值算法这一概念。先动手尝试一下:你能否实现函数 double F(double x) 的数值导数 double f(double x)?
请为本练习所用的函数提供一个示例 F。最好先选择你已知其导数的函数,例如 sin、cos 或 sqrt,这样便可以检验结果是否正确。
挑战 3:π
计算 π 的前 N 位小数。
小结
- 数值可以直接作为
if语句的条件;0表示“假”,所有其他值都表示“真”。 - 有三种不同的迭代语句:
for、do和while。for是进行范围迭代的首选工具。 switch语句执行多路选择。如果一个case没有以break终止,它就会贯穿到下一个case。
练习 1 把
if (i)条件加入程序,并将输出与之前的输出比较。 ↩︎练习 2 试着设想:当
i的值为0,又通过运算符--递减时,会发生什么。 ↩︎第 5.6.5 节会介绍 C23 的
constexpr构造细节。 ↩︎“tgmath”是“类型泛型数学”(type generic mathematics)的缩写,它提供数值函数的接口。 ↩︎
练习 5 加入若干
printf调用,输出x的中间值,以此分析清单 3.1。 ↩︎练习 6 说明清单 3.1 中形参
argc和argv的用途。 ↩︎练习 7 输出
eps1m01的值,并稍微改变这些值,观察程序输出。 ↩︎练习 8 在程序中测试示例
switch语句。省略其中一些break语句,看看会发生什么。 ↩︎