| 目录 |

3 变量与语句

“世间正是有各种各样的人,才成其为世界,不是吗,神父?”
“正是如此,我的孩子,正是如此。”

——海盗船长托马斯·巴塞洛缪·雷德对神父,《海盗》

一个 C 程序里确实能塞下好多东西。

没错。

而且出于各种原因,如果我们把程序里能见到的各类东西分一分类,大家都会轻松一些,这样我们才能说清楚自己在谈什么。

3.1 变量

人们常说“变量保存值”。但另一种理解方式是:变量就是一个人类可读的名字,指向内存中的某些数据。

我们在这里先花点时间,往指针这个兔子洞里瞄一眼。别担心。

你可以把内存看作一个由字节组成的大数组33。数据就存放在这个“数组”里34。如果一个数比单个字节大,它就会存放在多个字节中。由于内存像数组,内存中的每个字节都可以通过它的索引来指称。这个内存索引也叫地址,或者位置,或者指针。

在 C 里有一个变量时,这个变量的值就在内存的某个地方,位于某个地址。当然了。毕竟还能在哪儿呢?但用数字地址来指称一个值很麻烦,于是我们给它起个名字,而这就是变量。

我提这些的原因有两方面:

  1. 这会让以后理解指针变量更容易——指针变量就是保存其他变量地址的变量!
  2. 而且,这也会让以后理解指针更容易。

所以,变量就是存放在内存中某个地址上的某些数据的名字。

3.1.1 变量名

变量名可以使用 0-9、A-Z、a-z 范围内的任何字符以及下划线,但要遵守以下规则:

至于 Unicode,直接试就行了。规范 §D.2 里有一些规则,说明了标识符的哪些部分允许使用哪些 Unicode 码点范围,但在这里写这些就太多了,而且这大概是你永远不必操心的事。

3.1.2 变量类型

取决于你掌握的语言,你可能熟悉类型这个概念,也可能不熟悉。但 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。

什么?你想往那些变量里存几个数?疯了吧!

那就来试试吧:

int main(void)
{
    int i;

    i = 2; // 把值 2 赋给变量 i

    printf("Hello, World!\n");
}

漂亮。我们存好了一个值。现在把它打印出来。

做法是给 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;

3.1.3 布尔类型

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,而如果你指望别的某个正值也为真,就可能对不上。例如:

printf("%d\n", true == 12);  // 打印 "0",为假!

3.2 运算符与表达式

C 的运算符对你来说应该不陌生,你在别的语言里见过。我们这里快速扫一遍其中的一些。

(细节比这多得多,但这一节我们讲到足以入门就够了。)

3.2.1 算术运算

希望这些你都熟悉:

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() 函数的各种变体之一。

我们来聊聊一些更古怪、你在别的语言里可能没有的东西!

3.2.2 三目运算符

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 语句那样是流程控制。它只是一个求值得到某个值的表达式。

3.2.3 前置与后置的自增和自减

现在,我们来摆弄另一个你可能没见过的东西。

这就是传说中的后置自增和后置自减运算符:

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);

不过这个后面再细说。

3.2.4 逗号运算符

这是一种不常用的分隔表达式的方式,这些表达式会从左到右执行:

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);

我们后面会再讲到。

3.2.5 条件运算符

对于布尔值,我们有一大堆标准运算符:

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");

3.2.6 布尔运算符

我们可以用表示与、或、非的布尔运算符把条件表达式串联起来或加以改变。

运算符 布尔含义
&& 与
|| 或
! 非

布尔“与”的一个例子:

// 如果 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");

但我需要这个例子!

3.2.7 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。表达式的结果完全在编译期确定,而不是在运行时。

我们后面会利用这一点。

3.3 流程控制

布尔值固然好,但如果我们控制不了程序流程,那当然什么也做不成。我们来看几种结构: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 后面的那一件东西。而那一件东西可以是一条语句,也可以是一个语句块。

3.3.1 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 级联则可以检查不等、范围、变量,或者任何你能在条件表达式里构造出来的东西。

3.3.2 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");
}

3.3.3 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 函数。

3.3.4 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");
}

3.3.5 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 还有一个你有时会看到的巧妙之处,相当有意思:贯穿(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 那一章里详细讲。


| 目录 |