C编程
程序流程控制
很少有程序严格按照一个控制路径执行,每条指令都明确列出。为了有效编程,必须了解如何根据用户输入或其他条件更改程序执行的步骤,如何用少量代码多次执行某些步骤,以及如何让程序看起来像是具备基本逻辑的能力。C语言中的条件语句和循环语句提供了这种控制能力。
从现在开始,理解“代码块”这一概念非常重要。代码块是指一组关联的代码语句,它们作为一个整体执行。在C语言中,代码块的开始由左大括号 {
表示,结束由右大括号 }
表示。代码块结束后不需要分号。代码块可以为空,如 {}
。代码块也可以是嵌套的,即一个代码块可以包含另一个代码块。
条件语句
几乎没有程序是没有基本决策能力的。实际上,可以说没有任何有意义的人类活动不涉及某种形式的决策——无论是本能的还是其他形式的决策。例如,当你开车接近交通信号灯时,你不会想着:“我会继续通过这个十字路口。”而是想着:“如果信号灯是红的,我会停车;如果是绿的,我会继续行驶;如果是黄灯,只有在一定速度和距离情况下,我才会继续行驶。”这些决策过程可以通过C语言的条件语句来模拟。
条件语句是指告诉计算机只有在满足特定条件时才执行某段代码或更改某些数据的语句。最常见的条件语句是 if-else
语句,而条件表达式和 switch-case
语句通常作为更简洁的实现方式。
在了解条件语句之前,首先需要了解C语言如何表示逻辑关系。C语言将逻辑视为算术运算。值 0
(零)表示“假”,而其他任何值都表示“真”。如果你选择某个特定的值(通常是1)来表示真,然后将其与其他值进行比较,你的代码很可能会出错。例如,许多不熟悉C语言的程序员会使用 #define
来定义一个“TRUE”值,这其实是一种不良的编程习惯。
由于C语言中的逻辑运算符与算术运算符相同,以下是一些常见的逻辑运算符:
关系运算符和等价运算符:
a < b
:当a
小于b
时返回 1,否则返回 0。a > b
:当a
大于b
时返回 1,否则返回 0。a <= b
:当a
小于或等于b
时返回 1,否则返回 0。a >= b
:当a
大于或等于b
时返回 1,否则返回 0。a == b
:当a
等于b
时返回 1,否则返回 0。a != b
:当a
不等于b
时返回 1,否则返回 0。
初学者需要特别注意,"等于"运算符是 ==
,而不是 =
。这是导致很多编程错误的原因,且这种错误常常难以发现,因为表达式 (a = b)
会将 b
赋值给 a
,然后返回 b
的值,而表达式 (a ==
b)
会检查 a
是否等于 b
。需要指出的是,如果你将 =
与 ==
混淆,编译器通常不会提示错误,语句如 if (c = 20)
会被视为有效,且总是将 20 赋给 c
并返回 true
。为了避免这种错误,可以将常量放在前面,例如:if (20 ==
c)
,这样如果不小心写错编译器会给出错误提示。
值得注意的是,C语言没有像许多其他语言那样专门的布尔类型,0 表示假,非零表示真。因此,以下两种写法是等价的:
if (foo()) {
// 执行某些操作
}
if (foo() != 0) {
// 执行某些操作
}
许多人使用 #define TRUE 1
和 #define FALSE 0
来弥补缺乏布尔类型的问题,但这种做法不推荐,因为它假设的条件并不总是成立。更好的做法是直接明确地表示期望从函数调用中得到的结果,依据不同的情况使用不同的错误指示方法。
逻辑表达式:
a || b
:当a
或b
中有一个为真(或两者都为真)时,结果为 1,否则为 0。a && b
:当a
和b
都为真时,结果为 1,否则为 0。!a
:当a
为真时,结果为 0;当a
为假时,结果为 1。
举个例子,以下是一个较大的逻辑表达式:
e = ((a && b) || (c > d));
如果 a
和 b
都为非零,或者 c
大于 d
,e
会被设置为 1,否则为 0。
C语言使用短路求值(short-circuit evaluation)进行逻辑表达式的求值。也就是说,一旦能够确定逻辑表达式的结果,就不再进行进一步的求值。这在以下情况中非常有用:
int myArray[12];
....
if (i < 12 && myArray[i] > 3) {
....
}
在上面的代码片段中,i < 12
的比较首先被计算。如果它的结果是 0(假),那么 i
就是无效的数组索引,程序不会再去访问 myArray[i]
,因为已知整个表达式的结果是假的。
If-Else语句
If-Else
提供了一种控制方式,只有当特定条件满足时才执行某段代码。If-Else
语句的语法如下:
if (/* 条件放这里 */) {
/* 如果条件不为零(真),则执行这段代码 */
} else {
/* 如果条件为零(假),则执行这段代码 */
}
如果条件为真(即非零),则执行 if
块的代码,否则执行 else
块的代码。
else
和随后的代码块是完全可选的。如果没有必要在条件为假时执行任何代码,可以省略它。
另外,需要注意的是,if
语句可以直接跟在 else
语句后面。虽然这种写法偶尔会有用,但如果 if-else
链接超过两到三个,通常被认为是一种不好的编程实践。在这种情况下,最好使用 switch-case
构造来替代。
示例:
以下代码将变量 c
设置为两个变量 a
和 b
中较大的一个,或者当 a
和 b
相等时设置为 0。
if (a > b) {
c = a;
} else if (b > a) {
c = b;
} else {
c = 0;
}
考虑这个问题:为什么不能直接省略 else
写成:
if (a > b) {
c = a;
}
if (a < b) {
c = b;
}
if (a == b) {
c = 0;
}
有几个原因解释为什么不能只用多个 if
语句来处理这种情况。最重要的是,如果条件语句不是互斥的,可能会导致两个情况同时执行,而不是仅执行其中一个。如果代码不同,而且在某个代码块中,a
或 b
的值发生了变化(例如:你将 a
和 b
中较小的值重置为0),那就有可能触发多个 if
语句,这显然不是你想要的行为。此外,评估条件语句会消耗处理器时间。如果你使用 else
来处理这种情况,在上述例子中,如果 (a > b)
为非零(即 true
),程序就会避免额外评估其他 if
语句。因此,总的来说,通常最好在所有条件不会评估为非零(true
)的情况下使用 else
语句。
条件表达式
条件表达式是一种更简洁的方式,通过它可以根据条件设置值,比 If-Else
更加简洁。语法如下:
(/* 逻辑表达式 */) ? (/* 如果为非零 (true) */) : (/* 如果为0 (false) */)
逻辑表达式会被求值。如果它的值为非零(true
),则整个条件表达式的值为 ?
和 :
之间的表达式,否则为 :
后面的表达式。因此,下面的例子(稍微改变其功能,使得当 a
和 b
相等时,c
被设置为 b
)变成:
c = (a > b) ? a : b;
条件表达式有时能帮助澄清代码的意图。通常应避免嵌套使用条件操作符。最好仅在 a
和 b
的表达式较简单时使用条件表达式。此外,与初学者常见的误解相反,条件表达式并不会使代码变得更快。尽管看起来减少代码行数可能会导致执行时间更快,但实际上并不存在这种相关性。
Switch-Case 语句
假设你写了一个程序,用户输入一个 1-5 之间的数字(对应学生成绩,A(1)到 F(5)),然后将其存储在 grade
变量中,程序根据该数字输出相应的字母成绩。如果你使用 If-Else
实现,代码可能如下:
if (grade == 1) {
printf("A\n");
} else if (grade == 2) {
printf("B\n");
} else if (/* 等等... */)
有很多 if-else-if-else-if-else
的链条不仅对程序员来说很麻烦,对阅读代码的人也是一种负担。幸运的是,有一种解决方法:Switch-Case
语句,基本的语法是:
switch (/* 整型或枚举类型变量 */) {
case /* 整型或枚举的潜在值 */:
/* 代码 */
case /* 另一个潜在值 */:
/* 不同的代码 */
/* 根据需要插入其他 case */
default:
/* 更多的代码 */
}
Switch-Case
语句接受一个变量,通常是整型(int
)或枚举类型(enum
),该变量放在 switch
后面,然后与 case
关键字后面的值进行比较。如果变量等于 case
后面指定的值,语句就“激活”,开始执行 case
后面的代码。一旦 switch-case
语句激活,就不会再评估其他的 case
。
Switch-Case
在语法上是“奇怪”的,因为与每个 case
关联的代码不需要使用大括号。
非常重要的一点是:通常每个 case
语句的最后一条语句是 break
。break
会使程序的执行跳转到 switch
语句后面的语句,这是我们通常希望发生的行为。但是如果省略 break
,程序会继续执行下一个 case
语句的第一行,这种现象被称为“穿透”(fall-through)。当程序员希望这种行为时,应该在代码块的末尾加上注释,表示希望发生穿透。否则,维护代码的其他程序员可能会认为缺少 break
是一个错误,并不小心“修复”这个问题。以下是一个例子:
switch (someVariable) {
case 1:
printf("This code handles case 1\n");
break;
case 2:
printf("This prints when someVariable is 2, along with...\n");
/* FALL THROUGH */
case 3:
printf("This prints when someVariable is either 2 or 3.\n" );
break;
}
如果指定了 default
语句,当没有其他 case
匹配时,会执行 default
语句。default
语句是可选的。下面是使用 Switch-Case
语句来实现之前的 if-else-if
的示例:
switch (grade) {
case 1:
printf("A\n");
break;
case 2:
printf("B\n");
break;
case 3:
printf("C\n");
break;
case 4:
printf("D\n");
break;
default:
printf("F\n");
break;
}
也可以将多个值与同一组语句绑定,如下所示:(此处不需要穿透注释,因为预期的行为很明显)
switch (something) {
case 2:
case 3:
case 4:
/* 对 2、3 或 4 执行一些语句 */
break;
case 1:
default:
/* 对 1 或其他非 2、3、4 的值执行语句 */
break;
}
Switch-Case
语句与用户定义的 enum
数据类型一起使用时特别有用。一些编译器能够警告未处理的 enum
值,这有助于避免出现错误。
循环
在计算机编程中,通常需要在一定次数内或直到满足特定条件时重复执行某个动作。单纯地重复输入某些语句会非常麻烦且不灵活,这样的方法不仅难以停止,也不直观。举个现实生活的例子,如果餐厅的洗碗工被问到他一晚上做了什么,他会回答:“我一晚上都在洗碗。”他不太可能回答:“我洗了一只碗,然后又洗了一只碗,然后又洗了一只碗,接着……”使计算机能够执行某些重复任务的构造被称为循环。
while 循环
while
循环是最基础的循环类型。只要条件为非零(true
),它就会一直运行。例如,以下代码会导致程序看似“卡住”,你必须手动关闭程序。一个永远无法退出循环的情况被称为“无限循环”:
int a = 1;
while (42) {
a = a * 2;
}
以下是另一个 while
循环的例子,它会打印出所有小于 100 的 2 的幂:
int a = 1;
while (a < 100) {
printf("a is %d \n", a);
a = a * 2;
}
所有循环的流程也可以通过 break
和 continue
语句来控制。break
语句会立即退出封闭的循环,而 continue
语句会跳过当前代码块的其余部分,重新开始判断控制条件。例如:
int a = 1;
while (42) { // 循环直到执行到 break 语句
printf("a is %d ", a);
a = a * 2;
if (a > 100) {
break;
} else if (a == 64) {
continue; // 立即跳过当前循环,重新判断 while 条件
}
printf("a is not 64\n");
}
在这个例子中,程序会像往常一样打印出 a
的值,并打印出 a is not 64
(除非由于 continue
语句被跳过)。
与 if
一样,当 while
循环的代码块只有一个语句时,可以省略大括号,例如:
int a = 1;
while (a < 100)
a = a * 2;
这只会将 a
一直增加,直到 a
不再小于 100。
当程序执行到 while
循环的末尾时,它总是回到循环顶部的 while
语句,在那里重新评估控制条件。如果此时控制条件为“真”——即使它在循环内部的某些语句中
常见的表达式是:
int i = 5;
while (i--) {
printf("java 和 c# 做不到这一点\n");
}
这段代码执行 while
循环 5 次,每次 i
的值从 4 降到 0(在循环内)。巧妙地,这些值正好是用来访问一个包含 5 个元素的数组的索引。
For 循环
For
循环通常像这样:
for (初始化; 测试; 增量) {
/* 代码 */
}
初始化语句只会执行一次——在第一次评估测试条件之前。通常,它用于给某个变量赋初值,虽然这并非强制要求。初始化语句也可以用于声明和初始化在循环中使用的变量。
测试表达式每次在循环执行之前都会被评估。如果该表达式的值为 0(假),则跳过循环,继续执行 for
循环后面的代码。如果表达式的值为非零(真),则执行循环体内的大括号中的代码。
每次循环后,增量语句都会执行。通常,它用于增加循环索引(即在初始化表达式中初始化并在测试表达式中测试的变量)。执行完增量语句后,控制会返回到循环顶部,在那里测试表达式再次被评估。如果 continue
语句在 for
循环内被执行,增量语句将是下一个被执行的语句。
for
语句的每个部分都是可选的,可以省略。由于 for
语句的自由格式特性,您可以在其中做一些比较复杂的事情。for
循环常用于循环遍历数组中的项,每次处理一个项。
int myArray[12];
int ix;
for (ix = 0; ix < 12; ix++) {
myArray[ix] = 5 * ix + 3;
}
上述 for
循环初始化了 myArray
中的每个元素。循环索引可以从任何值开始。在以下情况中,它从 1 开始:
for (ix = 1; ix <= 10; ix++) {
printf("%d ", ix);
}
这将输出:
1 2 3 4 5 6 7 8 9 10
最常见的是使用从 0 开始的循环索引,因为数组的索引从 0 开始,但有时也会使用其他值来初始化循环索引。
增量操作还可以做其他事情,比如递减。因此,像下面这样的循环也是常见的:
for (i = 5; i > 0; i--) {
printf("%d ", i);
}
输出结果为:
5 4 3 2 1
这里是一个例子,其中测试条件只是一个变量。如果该变量的值为 0 或 NULL
,循环退出,否则执行循环体内的语句:
for (t = list_head; t; t = NextItem(t)) {
/* 循环体 */
}
while
循环也可以做与 for
循环相同的事情,然而,for
循环是一种更简洁的方式,因为所有必要的信息都在一行语句中。
for
循环也可以不给条件,例如:
for (;;) {
/* 一组语句 */
}
这叫做无限循环,因为它会永远循环,除非 for
循环中的语句有 break
语句。空的测试条件有效地将其评估为真。
在 for
循环中常用逗号运算符来执行多个语句。
int i, j, n = 10;
for (i = 0, j = 0; i <= n; i++, j += 2) {
printf("i = %d , j = %d \n", i, j);
}
在设计或重构条件部分时,特别要小心使用 <
或 <=
,是否需要将起始和停止修正 1,以及前缀和后缀符号的情况。(例如,在一个100码长的走道上,每隔10码有一棵树,那么树的数量就是 11 棵。)
int i, n = 10;
for (i = 0; i < n; i++)
printf("%d ", i); // 处理了 n 次 => 0 1 2 3 ... (n-1)
printf("\n");
for (i = 0; i <= n; i++)
printf("%d ", i); // 处理了 (n+1) 次 => 0 1 2 3 ... n
printf("\n");
for (i = n; i--;)
printf("%d ", i); // 处理了 n 次 => (n-1) ... 3 2 1 0
printf("\n");
for (i = n; --i;)
printf("%d ", i); // 处理了 (n-1) 次 => (n-1) ... 4 3 2 1
printf("\n");
Do-While 循环
DO-WHILE
循环是一个后检查的 while
循环,这意味着它在每次执行后检查条件。因此,即使条件为零(假),它至少会执行一次。其形式如下:
do {
/* 做一些事情 */
} while (condition);
注意终止的分号。这是正确语法所必须的。由于这是 while
循环的一种形式,break
和 continue
语句在循环中的作用也相应地起作用。continue
语句会跳到条件的测试处,而 break
语句则退出循环。
值得注意的是,Do-While
和 While
在功能上几乎是相同的,唯一重要的区别是:Do-While
循环总是保证至少执行一次,而 While
循环如果第一次评估条件为 0(假),则不会执行。
最后:goto
goto
是一种非常简单且传统的控制机制。它是一个语句,用于立即无条件跳转到程序中的另一行代码。要使用 goto
,您必须在程序的某个位置放置一个标签。标签由一个名称和一个冒号(:
)组成,独立占据一行。然后,您可以在程序中的所需位置写上 goto label;
,代码将从标签处开始继续执行。如下所示:
MyLabel:
/* 一些代码 */
goto MyLabel;
goto
的控制流转移能力非常强大,以至于除了简单的 if
语句外,所有其他控制结构也可以使用 goto
来编写。在这里,我们可以让 S
和 T
代表任意语句:
if (''cond'') {
S;
} else {
T;
}
/* ... */
可以使用两个 goto
和两个标签来实现相同的效果:
if (''cond'') goto Label1;
T;
goto Label2;
Label1:
S;
Label2:
/* ... */
这里,第一个 goto
是条件性的,取决于 cond
的值。第二个 goto
是无条件的。我们还可以对循环执行相同的转换:
while (''cond1'') {
S;
if (''cond2'')
break;
T;
}
/* ... */
可以写成:
Start:
if (!''cond1'') goto End;
S;
if (''cond2'') goto End;
T;
goto Start;
End:
/* ... */
正如这些案例所示,通常您的程序结构可以在不使用 goto
的情况下表达出来。无节制地使用 goto
会导致代码难以阅读和维护,因为更多地使用像 if-else
或 for
循环这样的惯用方法,能够更好地表达程序结构。从理论上讲,goto
语句并不是必需的,但在某些情况下,它可以增加可读性,避免代码重复,或使控制变量不必要。你应该首先掌握惯用的解决方案,只有在必要时才使用 goto
。请记住,许多 C 风格指南严格禁止使用 goto
,常见的例外是以下几种情况。
goto
的一种用途是跳出深层嵌套的循环。由于 break
语句只能跳出一个循环,因此 goto
可以用来跳出整个循环。没有使用 goto
,跳出深层嵌套的循环是完全可能的,但通常需要创建和测试额外的变量,这可能使得代码的可读性比使用 goto
更差。使用 goto
使得有序地撤销操作变得容易,通常是为了避免释放已经分配的内
存。
另一个常见的用途是创建状态机。这个话题比较高级,不是经常需要使用的。
示例
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int years;
printf("Enter your age in years : ");
fflush(stdout);
errno = 0;
if (scanf("%d", &years) != 1 || errno)
return EXIT_FAILURE;
printf("Your age in days is %d\n", years * 365);
return 0;
}