“世间正是有各种各样的人,才成其为世界,不是吗,神父?”
“正是如此,我的孩子,正是如此。”——海盗船长托马斯·巴塞洛缪·雷德对神父,《海盗》
一个 C 程序里确实能塞下好多东西。
没错。
而且出于各种原因,如果我们把程序里能见到的各类东西分一分类,大家都会轻松一些,这样我们才能说清楚自己在谈什么。
人们常说“变量保存值”。但另一种理解方式是:变量就是一个人类可读的名字,指向内存中的某些数据。
我们在这里先花点时间,往指针这个兔子洞里瞄一眼。别担心。
你可以把内存看作一个由字节组成的大数组33。数据就存放在这个“数组”里34。如果一个数比单个字节大,它就会存放在多个字节中。由于内存像数组,内存中的每个字节都可以通过它的索引来指称。这个内存索引也叫地址,或者位置,或者指针。
在 C 里有一个变量时,这个变量的值就在内存的某个地方,位于某个地址。当然了。毕竟还能在哪儿呢?但用数字地址来指称一个值很麻烦,于是我们给它起个名字,而这就是变量。
我提这些的原因有两方面:
所以,变量就是存放在内存中某个地址上的某些数据的名字。
变量名可以使用 0-9、A-Z、a-z 范围内的任何字符以及下划线,但要遵守以下规则:
至于 Unicode,直接试就行了。规范 §D.2 里有一些规则,说明了标识符的哪些部分允许使用哪些 Unicode 码点范围,但在这里写这些就太多了,而且这大概是你永远不必操心的事。
取决于你掌握的语言,你可能熟悉类型这个概念,也可能不熟悉。但 C 对类型有点挑剔,所以我们该复习一下。
一些示例类型,都是最基本的那几种:
| 类型 | 示例 | C 类型 |
|---|---|---|
| 整数 | 3490 |
int |
| 浮点数 | 3.14159 |
float35 |
| 字符(单个) | 'c' |
char |
| 字符串 | "Hello, world!" |
char *36 |
当你要求时,C 会努力在大多数数值类型之间自动转换。但除此之外,所有转换都是手动的,尤其是字符串与数值之间的转换。
C 里几乎所有类型都是这些类型的变体。
在使用变量之前,你必须先声明这个变量,并告诉 C 这个变量保存什么类型。一旦声明,变量的类型在运行时就不能再更改。你把它设成什么,它就是什么,直到它脱离作用域、被宇宙重新吸收为止。
我们拿之前那段“Hello, world”代码,给它加上几个变量:
#include <stdio.h>
int main(void)
{
int i; // 存放有符号整数,例如 -3、-2、0、1、10
float f; // 存放有符号浮点数,例如 -3.1416
printf("Hello, World!\n"); // 啊,这熟悉的亲切感
}好了!我们声明了几个变量。我们还没用过它们,而且它们都未初始化。一个保存整数,另一个保存浮点数(如果你有数学背景,基本上就是实数)。
未初始化的变量具有不确定的值37。它们必须被初始化,否则你就必须假定它们含有某个无意义的数字。
这是 C 可能“坑到你”的地方之一。以我的经验,大多数时候这个不确定的值是零……但它可能每次运行都不一样!绝不要假定这个值是零,即使你看到它就是零。永远要在使用变量之前显式地把它们初始化成某个值38。
什么?你想往那些变量里存几个数?疯了吧!
那就来试试吧:
漂亮。我们存好了一个值。现在把它打印出来。
做法是给 printf() 函数传两个了不起的实参。第一个实参是一个字符串,描述要打印什么以及怎么打印(称为格式字符串),第二个是要打印的值,也就是变量 i 里的内容。
printf() 会在格式字符串中搜寻各种以百分号(%)开头的特殊序列,这些序列用来告诉它该打印什么。例如,如果它找到 %d,就会去看传入的下一个形参,并把它按整数打印出来。如果找到 %f,就把该值按 float 打印出来。如果找到 %s,就打印一个字符串。
于是,我们可以像这样打印出各种类型的值:
#include <stdio.h>
int main(void)
{
int i = 2;
float f = 3.14;
char *s = "Hello, world!"; // char *("char 指针")就是字符串类型
printf("%s i = %d and f = %f!\n", s, i, f);
}输出将是:
Hello, world! i = 2 and f = 3.14!这样看来,printf() 可能类似于你熟悉的其他语言里的各种格式字符串或参数化字符串。
注意: 在接下来的几节里,我默认你已经事先声明好了变量。如果示例用到整型 i 和 j,那就假定在示例上方某处我写了:
int i, j;C 有布尔类型,真的假的?
1!
历史上,C 没有布尔类型,而且有些人可能会说它现在仍然没有。
在 C 里,0 表示“假”,非零表示“真”。
所以 1 为真。-37 也为真。而 0 为假。
你完全可以把布尔类型声明成 int:
int x = 1;
if (x) {
printf("x is true!\n");
}到了 C23,你才有了真正的 bool、true 和 false。在那之前,如果你的 C 版本够新,可以 #include <stdbool.h> 来获得同样的东西。
#include <stdio.h>
#include <stdbool.h> // 在 C23 中不需要
int main(void) {
bool x = true;
if (x) {
printf("x is true!\n");
}
}虽然从技术上说,你应该把 bool 变量设成 true、false,或者某个求值为真或假的表达式的结果,但实际上你可以把各种各样的东西转换成 bool。这里有一些具体规则,但接近零的东西往往求值为 false,不接近零的东西则为真。
但如果你混着用就要小心,因为 true 的数值是 1,大概如此39,而如果你指望别的某个正值也为真,就可能对不上。例如:
C 的运算符对你来说应该不陌生,你在别的语言里见过。我们这里快速扫一遍其中的一些。
(细节比这多得多,但这一节我们讲到足以入门就够了。)
希望这些你都熟悉:
i = i + 3; // 加法(+)和赋值(=)运算符,给 i 加 3
i = i - 8; // 减法,从 i 中减去 8
i = i * 9; // 乘法
i = i / 2; // 除法
i = i % 5; // 取模(除法余数)上面这些全都有简写形式。那几行都可以更简洁地写成:
i += 3; // 等同于 "i = i + 3",给 i 加 3
i -= 8; // 等同于 "i = i - 8"
i *= 9; // 等同于 "i = i * 9"
i /= 2; // 等同于 "i = i / 2"
i %= 5; // 等同于 "i = i % 5"C 没有幂运算。你得用 math.h 里 pow() 函数的各种变体之一。
我们来聊聊一些更古怪、你在别的语言里可能没有的东西!
C 还有三目运算符。这是一个表达式,它的值取决于其中内嵌的条件判断的结果。
// 如果 x > 10,给 y 加 17。否则给 y 加 37。
y += x > 10? 17: 37;真是一团乱麻!你读得越多就越习惯。为了帮你一点忙,我用 if 语句把上面的表达式重写一遍:
// 这个表达式:
y += x > 10? 17: 37;
// 等价于这个非表达式:
if (x > 10)
y += 17;
else
y += 37;把这两者对照着看,直到你能看出三目运算符的每个组成部分。
或者,另一个例子:打印 x 里存的数是奇数还是偶数:
printf("The number %d is %s.\n", x, x % 2 == 0? "even": "odd");printf() 里的 %s 格式说明符表示打印一个字符串。如果表达式 x % 2 求值为 0,整个三目表达式就求值为字符串 "even"。否则它求值为字符串 "odd"。挺酷的!
重要的是要注意,三目运算符不像 if 语句那样是流程控制。它只是一个求值得到某个值的表达式。
现在,我们来摆弄另一个你可能没见过的东西。
这就是传说中的后置自增和后置自减运算符:
i++; // 给 i 加一(后自增)
i--; // 从 i 减一(后自减)很常见的情况是,它们只是下面这些写法的简写版:
i += 1; // 给 i 加一
i -= 1; // 从 i 减一但它们其实有更微妙的区别,这些狡猾的家伙。
我们来看看这种变体,也就是前置自增和前置自减:
++i; // 给 i 加一(前自增)
--i; // 从 i 减一(前自减)使用前置自增和前置自减时,变量的值在表达式求值之前就先自增或自减。然后表达式用新值求值。
使用后置自增和后置自减时,表达式先用原值计算,然后在表达式的值确定之后再对值进行自增或自减。
你其实可以把它们嵌进表达式里,像这样:
i = 10;
j = 5 + i++; // 计算 5 + i,_然后_再自增 i
printf("%d, %d\n", i, j); // 打印 11, 15我们把它和前置自增运算符比较一下:
i = 10;
j = 5 + ++i; // 先自增 i,_然后_计算 5 + i
printf("%d, %d\n", i, j); // 打印 11, 16这种技巧常用于数组和指针的访问与操作。它让你既能使用变量里的值,又能在使用它之前或之后对该值进行自增或自减。
但到目前为止,你见到它最多的地方是 for 循环:
for (i = 0; i < 10; i++)
printf("i is %d\n", i);不过这个后面再细说。
这是一种不常用的分隔表达式的方式,这些表达式会从左到右执行:
x = 10, y = 20; // 先把 10 赋给 x,再把 20 赋给 y看起来有点傻,因为你完全可以把逗号换成分号,对吧?
x = 10; y = 20; // 先把 10 赋给 x,再把 20 赋给 y但那有点不一样。后者是两个彼此独立的表达式,而前者是单个表达式!
使用逗号运算符时,逗号表达式的值就是最右边那个表达式的值:
x = (1, 2, 3);
printf("x is %d\n", x); // 打印 3,因为 3 在逗号列表中最右边但就连这个也相当刻意。逗号运算符的一个常见用途是在 for 循环里,在语句的各个部分做多件事:
for (i = 0, j = 10; i < 100; i++, j++)
printf("%d, %d\n", i, j);我们后面会再讲到。
对于布尔值,我们有一大堆标准运算符:
a == b; // 如果 a 等于 b 则为真
a != b; // 如果 a 不等于 b 则为真
a < b; // 如果 a 小于 b 则为真
a > b; // 如果 a 大于 b 则为真
a <= b; // 如果 a 小于或等于 b 则为真
a >= b; // 如果 a 大于或等于 b 则为真不要把赋值(=)和比较(==)搞混了!比较用两个等号,赋值用一个。
我们可以把比较表达式用在 if 语句里:
if (a <= 10)
printf("Success!\n");我们可以用表示与、或、非的布尔运算符把条件表达式串联起来或加以改变。
| 运算符 | 布尔含义 |
|---|---|
&& |
与 |
|| |
或 |
! |
非 |
布尔“与”的一个例子:
// 如果 x 小于 10 且 y 大于 20,就做点什么:
if (x < 10 && y > 20)
printf("Doing something!\n");布尔“非”的一个例子:
if (!(x < 12))
printf("x is not less than 12\n");! 的优先级高于其他布尔运算符,所以那种情况下必须加括号。
当然,这和下面这个是一样的:
if (x >= 12)
printf("x is not less than 12\n");但我需要这个例子!
sizeof 运算符这个运算符告诉你某个变量或数据类型在内存中占用的大小(以字节为单位)。
更确切地说,它告诉你某个表达式的类型(这个表达式可能只是一个变量)在内存中占用的大小(以字节为单位)。
在不同系统上这个值可能不同,但 char 及其变体除外(它们始终是 1 字节)。
现在这看起来可能没什么用,但我们后面会时不时提到它,所以值得讲一讲。
既然它计算的是存储一个类型所需的字节数,你可能会以为它返回 int。或者……既然大小不可能是负数,也许是 unsigned?
但事实证明,C 有一种特殊的类型来表示 sizeof 的返回值。它就是 size_t,读作“size tee”40。我们所知道的是,它是一种无符号整数类型,能容纳任何你传给 sizeof 的东西的字节大小。
size_t 经常出现在各种需要传入或返回数量的地方。你可以把它理解为一个表示计数的值。
你可以对变量或表达式求 sizeof:
int a = 999;
// %zu 是 size_t 类型的格式说明符
// 如果你的编译器不接受 "z" 这部分,就去掉它
printf("%zu\n", sizeof a); // 在我的系统上打印 4
printf("%zu\n", sizeof(2 + 7)); // 在我的系统上打印 4
printf("%zu\n", sizeof 3.14); // 在我的系统上打印 8
// 如果需要打印负的 size_t 值,请用 %zd记住:那是表达式类型的字节大小,而不是表达式本身的大小。这就是为什么 2+7 的大小和 a 一样——它们都是 int 类型。我们会在下一段代码里再提到这个数字 4……
……在那里我们会看到,你还可以对类型求 sizeof(注意:和表达式不同,类型名外面必须有圆括号):
printf("%zu\n", sizeof(int)); // 在我的系统上打印 4
printf("%zu\n", sizeof(char)); // 在所有系统上都打印 1重要的是要注意,sizeof 是编译期操作41。表达式的结果完全在编译期确定,而不是在运行时。
我们后面会利用这一点。
布尔值固然好,但如果我们控制不了程序流程,那当然什么也做不成。我们来看几种结构:if、for、while 和 do-while。
首先,由你身边友好的 C 开发者给你一条关于语句和语句块的前瞻性通用说明:
在 if 或 while 这类语句之后,你可以放一条要执行的语句,也可以放一个要依次执行的语句块。
我们从单条语句开始:
if (x == 10) printf("x is 10\n");有时也会把它写在单独一行上。(空白字符在 C 里基本无关紧要——它不像 Python。)
if (x == 10)
printf("x is 10\n");但如果你想让这个条件触发多件事呢?你可以用花括号标出一个块或复合语句。
if (x == 10) {
printf("x is 10\n");
printf("And also this happens when x is 10\n");
}一种非常常见的风格是,即使并不必要也总是使用花括号:
if (x == 10) {
printf("x is 10\n");
}有些开发者觉得这样代码更易读,也能避免这样的错误:视觉上看起来某些东西在 if 块里,实际上却不在。
// 糟糕的错误示例
if (x == 10)
printf("This happens if x is 10\n");
printf("This happens ALWAYS\n"); // 惊喜!!无条件执行!while 和 for 以及其他循环结构的工作方式与上面的示例相同。如果你想在循环里或在 if 之后做多件事,就把它们用花括号包起来。
换句话说,if 会运行 if 后面的那一件东西。而那一件东西可以是一条语句,也可以是一个语句块。
if-else 语句 我们在好几个例子里已经用过 if 了,因为你很可能在别的语言里见过它,但这里再来一个:
int i = 10;
if (i > 10) {
printf("Yes, i is greater than 10.\n");
printf("And this will also print if i is greater than 10.\n");
}
if (i <= 10) printf("i is less than or equal to 10.\n");在示例代码里,如果 i 大于 10,就会打印这条消息,否则执行继续到下一行。注意 if 语句后面的花括号;如果条件为真,那么要么执行紧跟在 if 之后的第一条语句或表达式,要么执行 if 后面花括号里的那一整段代码。这种代码块行为是所有语句共有的。
当然,C 在这方面就是这么有趣:你还可以给 if 加上 else 子句,在条件为假时做点什么:
int i = 99;
if (i == 10)
printf("i is 10!\n");
else {
printf("i is decidedly not 10.\n");
printf("Which irritates me a little, frankly.\n");
}你甚至可以把它们级联起来,测试各种条件,像这样:
int i = 99;
if (i == 10)
printf("i is 10!\n");
else if (i == 20)
printf("i is 20!\n");
else if (i == 99) {
printf("i is 99! My favorite\n");
printf("I can't tell you how happy I am.\n");
printf("Really.\n");
}
else
printf("i is some crazy number I've never heard of.\n");不过如果你打算走这条路,一定要看看 switch 语句,它可能是更好的方案。问题在于 switch 只能用于和常量数字做相等比较。上面的 if-else 级联则可以检查不等、范围、变量,或者任何你能在条件表达式里构造出来的东西。
while 语句 while 就是那种普普通通的循环结构。当条件表达式为真时做一件事。
我们来做一个吧!
// 打印以下输出:
//
// i 现在是 0!
// i 现在是 1!
// [ 2 到 7 之间还有更多同样的内容 ]
// i 现在是 8!
// i 现在是 9!
int i = 0;
while (i < 10) {
printf("i is now %d!\n", i);
i++;
}
printf("All done!\n");这样你就得到了一个基本循环。C 还有 for 循环,用在这个例子里会更干净。
while 的一个还算常见的用途是写无限循环,也就是在条件为真时不断重复:
while (1) {
printf("1 is always true, so this repeats forever.\n");
}do-while 语句 既然我们已经把 while 语句搞定了,那就来看看它关系很近的表亲 do-while 吧。
它们基本上一样,只不过如果第一轮循环条件就为假,do-while 会执行一次,而 while 完全不会执行。换句话说,用 do-while 时,判断是否执行代码块的测试发生在代码块的末尾;用 while 时则发生在代码块的开头。
我们来看个例子:
// 使用 while 语句:
i = 10;
// 这不会被执行,因为 i 不小于 10:
while(i < 10) {
printf("while: i is %d\n", i);
i++;
}
// 使用 do-while 语句:
i = 10;
// 这会执行一次,因为循环条件直到
// 循环体运行之后才会被检查:
do {
printf("do-while: i is %d\n", i);
i++;
} while (i < 10);
printf("All done!\n");注意,两种情况下循环条件一上来就为假。所以在 while 里,循环失败,后面的代码块永远不会执行。而在 do-while 里,条件是在代码块执行之后才检查的,所以它至少会执行一次。在这个例子里,它打印消息,递增 i,然后条件失败,接着输出“All done!”。
这个故事的寓意是:如果你想让循环无论循环条件如何都至少执行一次,就用 do-while。
这些例子或许用 for 循环来做会更好。我们来做点不那么确定的事——重复,直到某个特定的随机数出现!
#include <stdio.h> // 为了使用 printf
#include <stdlib.h> // 为了使用 rand
int main(void)
{
int r;
do {
r = rand() % 100; // 获取 0 到 99 之间的随机数
printf("%d\n", r);
} while (r != 37); // 重复直到出现 37
}旁注:你运行那个程序不止一次了吗?如果运行了,有没有注意到同样的数字序列又出现了。一次又一次,一次又一次?这是因为 rand() 是一个伪随机数生成器,必须用不同的数来播种,才能生成不同的序列。更多细节请查阅 srand()42 函数。
for 语句 欢迎来到世界上最流行的循环之一:for 循环!
如果你事先知道要循环多少次,这个循环非常合适。
只用 while 循环也能做同样的事,但 for 循环有助于让代码更整洁。
下面是两段等价的代码——注意 for 循环只是一种更紧凑的表达方式:
// 打印 0 到 9(含)之间的数字……
// 使用 while 语句:
i = 0;
while (i < 10) {
printf("i is %d\n", i);
i++;
}
// 用 for 循环做完全相同的事:
for (i = 0; i < 10; i++) {
printf("i is %d\n", i);
}没错,各位——它们做的事完全一样。但你能看出 for 语句更紧凑一些,也更赏心悦目。(JavaScript 用户此刻会充分体会到它的 C 语言血统。)
它分成三部分,用分号分隔。第一部分是初始化,第二部分是循环条件,第三部分是循环条件为真时在块末尾应执行的操作。这三部分都是可选的。
for (initialize things; loop if this is true; do this after each loop)注意,如果循环条件一开始就为假,循环体连一次都不会执行。
for循环的有趣小知识!你可以用逗号运算符在
for循环的每个子句中做多件事!for (i = 0, j = 999; i < 10; i++, j--) { printf("%d, %d\n", i, j); }
空的 for 会永远运行下去:
for(;;) { // "永远"
printf("I will print this again and again and again\n" );
printf("for all eternity until the heat-death of the universe.\n");
printf("Or until you hit CTRL-C.\n");
}switch 语句 取决于你来自哪种语言,你可能熟悉也可能不熟悉 switch,或者 C 的版本甚至比你习惯的更受限。这个语句让你可以根据一个整数表达式的值采取多种不同的动作。
基本上,它把一个表达式求值为整数值,然后跳到与该值对应的 case。执行从那里继续。如果遇到 break 语句,执行就跳出 switch。
下面这个例子根据给定的山羊数量,打印出对“这究竟是多少只山羊”的直观感受。
#include <stdio.h>
int main(void)
{
int goat_count = 2;
switch (goat_count) {
case 0:
printf("You have no goats.\n");
break;
case 1:
printf("You have a singular goat.\n");
break;
case 2:
printf("You have a brace of goats.\n");
break;
default:
printf("You have a bona fide plethora of goats!\n");
break;
}
}在那个例子中,switch 会跳到 case 2,并从那里开始执行。当它遇到(如果遇到)break 时,就跳出 switch。
另外,你可能注意到最下面那个 default 标签。当所有 case 都不匹配时,就会执行它。
每个 case,包括 default,都是可选的。它们可以以任意顺序出现,但 default(如果有的话)通常都放在最后。
所以整个东西的作用就像一串 if-else 级联:
if (goat_count == 0)
printf("You have no goats.\n");
else if (goat_count == 1)
printf("You have a singular goat.\n");
else if (goat_count == 2)
printf("You have a brace of goats.\n");
else
printf("You have a bona fide plethora of goats!\n");但有几个关键区别:
switch 通常能更快地跳到正确的代码处(不过规范并不做这样的保证)。if-else 能处理诸如 < 和 >= 这样的关系条件判断,也能处理浮点数和其他类型,而 switch 不行。switch 还有一个你有时会看到的巧妙之处,相当有意思:贯穿(fall through)。
还记得 break 是如何让我们跳出 switch 的吗?
那么,如果我们不 break 会怎样?
结果是我们会继续进入下一个 case!演示!
switch (x) {
case 1:
printf("1\n");
// 贯穿!
case 2:
printf("2\n");
break;
case 3:
printf("3\n");
break;
}如果 x == 1,这个 switch 会先命中 case 1,它会打印出 1,但接着它就继续执行下一行代码……而那一行打印的是 2!
然后,终于遇到一个 break,于是我们跳出 switch。
如果 x == 2,那么我们就只是命中 case 2,打印 2,然后照常 break。
不写 break 就叫做贯穿(fall through)。
如果你用的是 C23 之前的版本,务必在打算贯穿的地方写一条注释,就像我上面做的那样。这能免得其他程序员猜测你是不是有意为之。GCC 会识别这些注释并抑制警告。
或者你可以用一个非标准的 GCC 属性,我一直不喜欢它,因为它又丑又不标准,但 GCC 和 Clang 都识别它:
switch (x) {
case 1:
printf("1\n");
__attribute__((fallthrough));
case 2:
...但现在,终于,在 C23 中我们有了用属性做这件事的标准方式:
switch (x) {
case 1:
printf("1\n");
[[fallthrough]];
case 2:
...但请记住,这是 C 程序中常见的引入 bug 的地方之一:忘了在 case 里写 break。如果你不想直接滚进下一个 case43,就必须写。
前面我说过 switch 适用于整数类型——请保持这样。不要在里面用浮点或字符串类型。这里有个近似漏洞的地方:你可以用字符类型,因为它们本身就是伪装的整数。所以这完全可以接受:
char c = 'b';
switch (c) {
case 'a':
printf("It's 'a'!\n");
break;
case 'b':
printf("It's 'b'!\n");
break;
case 'c':
printf("It's 'c'!\n");
break;
}最后,你可以在 switch 中使用 enum,因为它们也是整数类型。不过这会在 enum 那一章里详细讲。