1. 入门
本章涵盖:
- 命令式编程简介
- 编译并运行代码
本章将通过一个简单程序,向你介绍 C 语言中的许多构造。如果已经有编程经验,你可能会觉得部分讨论只是无谓的重复;如果没有这样的经验,一连串的新术语和新概念又可能令你应接不暇。
无论遇到哪种情况,都请保持耐心。具备编程经验的读者很可能会发现一些未曾察觉的细节,或者发现自己过去对这门语言的某些认知有误。对于第一次接触编程的读者,也请放心:大约读完前 10 页之后,你的认知就会大幅加深,你会更清楚地理解编程到底是怎么一回事。
无论是对普通的编程学习,还是对阅读本书而言,有一条重要的智慧凝聚在 Douglas Adams 的《银河系漫游指南》(1986)中的一句话里:
要点 1 #2
不要恐慌。(译者注:原文:“Don't panic.”,出自《银河系漫游指南》)
不必为此焦虑。正文中有许多交叉引用、链接和旁支信息,书末还有索引。遇到疑问时,可以查阅;也可以干脆休息一下。
C 编程就是让计算机完成某些特定任务。C 程序通过向计算机发送指令来做到这一点,就像许多自然语言会用祈使句表达命令一样;这种组织计算机程序的方式因此称为命令式编程。为了开始实践并理解这句话的含义,请看清单 1.1 中的第一个程序,它对应源码目录中的 getting-started.c 源文件。
1.1 命令式编程
你大概看得出,这段代码构成了某种语言。它包含 main、include、for 等古怪单词,这些单词以特别的方式排列、着色,还和大量奇异的字符、数字以及似乎是英语的文本(“Doing some work”)混在一起。它的设计目的是在人类程序员与计算机之间建立联系,让我们能告诉计算机该做什么——也就是向它“发出命令”。
要点 1.1 #1
C 是一种命令式编程语言。
本书不仅会涉及 C 编程语言,还会涉及一种英语方言中的词汇——C 行话,也就是帮助我们谈论 C 的语言。每个术语第一次出现时,不可能都立即得到解释;不过,我会在适当的时候逐一说明。它们也全都编入了索引,因此你可以轻松“作弊”,自行跳转到解释更充分的正文——风险自负。[1]
从第一个示例中大概已经可以看出,C 程序由不同部分构成,这些部分形成了若干彼此交织的层次。我们试着由内向外理解它。这个程序运行后可见的结果,是在计算机的命令终端上输出五行文本。在我的计算机上,使用这个程序时大致如下。
清单 1.1 第一个 C 程序示例
/* This may look like nonsense, but really is -*- mode: C -*- */
#include <stdlib.h>
#include <stdio.h>
/* The main thing that this program does. */
int main (int argc, [[maybe_unused]] char* argv[argc+1]) {
// Declarations
double A[5] = {
[0] = 9.0,
[1] = 2.9,
[4] = 3.E+25,
[3] = .00007,
};
// Doing some work
for (size_t i = 0; i < 5; ++i) {
printf("element %zu is %g, \tits square is %g\n",
i,
A[i],
A[i]*A[i]);
}
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
> ./getting-started
element 0 is 9, its square is 81
element 1 is 2.9, its square is 8.41
element 2 is 0, its square is 0
element 3 is 7e-05, its square is 4.9e-09
element 4 is 3e+25, its square is 9e+502
3
4
5
6
我们很容易找出 getting-started.c 中由这个程序输出(用 C 行话说,就是“打印”)的那部分文本:第 17 行引号之间的内容。真正的动作发生在第 17 行到第 20 行之间。C 把这一段称为语句,但这个名称不算十分贴切。其他语言会使用“指令”一词,它更准确地描述了其用途。这个特定语句调用了一个名为 printf 的函数:
printf("element %zu is %g, \tits square is %g\n",
i,
A[i],
A[i]*A[i]);2
3
4
这里,printf 函数接收四个实参,它们括在一对圆括号 ( ... ) 中:
- 那段奇怪的文本(位于引号之间)是字符串字面量,用作输出的格式。文本中有三个标记(格式说明符),指出输出中应当插入数字的位置。这些标记以百分号字符(
%)开头。格式中还含有一些以反斜杠开头的特殊转义字符:\t和\n。 - 逗号之后是字母
i。i所代表的内容会打印在第一个格式说明符%zu的位置。 - 再一个逗号分隔出下一个实参
A[i]。它所代表的内容会打印在第二个格式说明符,也就是第一个%g的位置。 - 最后,同样由逗号分隔的是
A[i]*A[i],它对应最后一个%g。
稍后会解释所有这些实参的含义。现在只需记住:我们已经找到了程序的主要目的——在终端上打印若干行文本,也了解到程序会“命令”printf 函数来实现这个目的。其余部分只是一些语法糖,用于指定要打印哪些数字以及打印多少个数字。
1.2 编译和运行
如上一节所示,源代码表达了我们希望计算机做什么。但它本身只是一段存放在硬盘上的文本,计算机无法直接理解。我们需要一种名为“编译器”的专门程序,把 C 源代码翻译成机器能理解的形式,即二进制代码或可执行文件。至于编译后的程序长什么样、具体的翻译过程又是怎样的,这些内容非常复杂,远超本书的讨论范围。[2] 如果要详细解释,还需要一整本书的篇幅。不过,你目前不需要深入了解这些原理,因为现成的工具已经能帮我们完成所有的翻译工作。
要点 1.2 #1
C 是一种编译型编程语言。
编译器的名称及其命令行实参,在很大程度上取决于程序运行的平台。原因很简单:目标二进制代码依赖平台,也就是说,其形式和细节取决于运行它的计算机。个人计算机与手机的需求不同,冰箱所说的“语言”也不同于机顶盒。事实上,这正是 C 存在的原因之一:C 为各式各样、针对具体机器的语言(通常称为汇编语言)提供了一个抽象层。
要点 1.2 #2
正确的 C 程序可以在不同平台之间移植。
本书会投入大量篇幅来编写“正确”的 C 程序,以确保可移植性。遗憾的是,有些平台自称支持 C,却不符合最新标准;另一些符合标准的平台会接受错误程序,或者提供无法广泛移植的 C 标准扩展。因此,只在一个平台上运行和测试程序,并不总能保证可移植性。
编译器的职责是确保前面那个小程序(getting-started.c)在针对相应平台完成翻译之后,可以在个人计算机、手机、机顶盒,甚至也许是冰箱上正确运行。
话虽如此,如果你使用 POSIX 系统(例如 Linux 或 macOS),系统中很可能存在名为 c99 或 c17 的程序,而且它确实就是 C 编译器。可以尝试用下面的命令编译示例程序:
> c17 -Wall -o getting-started getting-started.c -lm编译器应当顺利完成工作,并在当前目录中输出一个名为 getting-started 的可执行文件。[3] 在这行示例命令中:
c17是编译器程序。-Wall要求编译器对它发现的一切异常之处发出警告。-o getting-started要求编译器把编译器输出存入名为getting-started的文件。getting-started.c指定源文件的名称,其中包含我们编写的 C 代码。请注意,文件名末尾的.c扩展名表示 C 编程语言。-lm要求编译器在必要时加入一些标准数值函数;稍后会用到它们。
现在可以执行刚刚创建的可执行文件。输入:
> ./getting-started你应当会看到与前面完全相同的输出。这就是“可移植”的含义:无论在哪里运行这个程序,它的行为都应当相同。
如果运气不好,编译命令不生效,就必须在系统文档中查找编译器的名称。如果系统中没有编译器,甚至还得安装一个。[4] 编译器的名称各不相同。下面是一些可能奏效的常见替代方案:
> clang -std=c2x -Wall -lm -o getting-started getting-started.c
> gcc -std=c2x -Wall -lm -o getting-started getting-started.c
> icc -std=c2x -Wall -lm -o getting-started getting-started.c2
3
选项 -std=c2x 指定名为“C2x”的标准版本;在我们还不知道标准会于 2023 年完成时,C23 就采用这个名称。(译者注:当今的新版编译器应写 -std=c23)
即使计算机上已经装有其中某些编译器,它们也可能无法顺利编译这个程序。[5]
清单 1.1 向我们展示了一个理想世界:程序可以正常工作,并且在所有平台上产生相同结果。遗憾的是,实际编程时经常会遇到无法完全正常工作的程序,可能产生错误或不可靠的结果。因此,请看清单 1.2 中的程序。它看起来与前一个清单十分相似。
如果让编译器处理这个程序,它应当给出类似下面的诊断信息。
清单 1.2 存在缺陷的 C 程序示例
/* This may look like nonsense, but really is -*- mode: C -*- */
/* The main thing that this program does. */
void main () {
// Declarations
int i;
double A[5] = {
9.0,
2.9,
3.E+25,
.00007,
};
// Doing some work
for (i = 0; i < 5; ++i) {
printf("element %d is %g, \tits square is %g\n",
i,
A[i],
A[i]*A[i]);
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
> gcc -std=c2x -Wall -o bad bad.c
bad.c:4:6: warning: return type of 'main' is not 'int' [-Wmain]
4 | void main() {
| ^~~~
bad.c: In function 'main':
bad.c:16:6: warning: implicit declaration of function 'printf' [-Wimplicit-function-declaration]
16 | printf("element %d is %g, \tits square is %g\n",
| ^~~~~~
bad.c:1:1: note: include '<stdio.h>' or provide a declaration of 'printf'
+++ |+#include <stdio.h>
1 | /* This may look like nonsense, but really is -*- mode: C -*- */
bad.c:16:6: warning: incompatible implicit declaration of built-in function 'printf'
16 | printf("element %d is %g, \tits square is %g\n",
| ^~~~~~
bad.c:16:6: note: include '<stdio.h>' or provide a declaration of 'printf'
bad.c:22:10: warning: 'return' with a value, in function returning void [-Wreturn-type]
22 | return 0;
| ^
bad.c:4:6: note: declared here
4 | void main() {
| ^~~~2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这里出现了许多很长的“warning”行,长到无法完整装进终端屏幕。编译器最终还是生成了可执行文件;遗憾的是,运行程序时得到的输出却不同。这说明我们必须谨慎,并且留意细节。
Clang 甚至比 GCC 更挑剔,会给出相似的诊断:
> clang -std=c2x -Wall -o bad bad.c
bad.c:4:1: error: 'main' must return 'int'
4 | void main() {
| ^~~~
| int
bad.c:16:6: error: call to undeclared library function 'printf' with type 'int (const char *, ...)'
16 | printf("element %d is %g, \tits square is %g\n",
| ^
bad.c:16:6: note: include the header <stdio.h> or explicitly provide a declaration for 'printf'
bad.c:22:3: error: void function 'main' should not return a value [-Wreturn-type]
22 | return 0;
| ^ ~
3 errors generated.2
3
4
5
6
7
8
9
10
11
12
13
请注意,与 GCC 不同,Clang 没有生成可执行文件。它认为检测到的三个问题全是致命错误,因而拒绝继续。请把这一点视为优点。
两个编译器都给出了三项诊断:它们认为 main 应当采用另一种返回类型;它们期待我们像清单 1.1 的第 3 行那样,写一行代码说明 printf 函数的来源;它们还检测到,第 22 行的 return 与这里给出的 main 说明不相符。
根据所用平台,可以强制编译器拒绝为会产生这类诊断的程序生成可执行文件。对于 GCC,相应的命令行选项是 -Werror;使用它之后,GCC 就会表现得与刚才看到的 Clang 一样。
因此,清单 1.1 与清单 1.2 之间的两处差异,把一个良好、符合标准且可移植的程序变成了糟糕的程序。我们也看到,编译器会帮助我们:它把问题精确定位到了引发故障的代码所在的行。积累一些经验之后,你就能理解它告诉你的内容。[6] [7]
要点 1.2 #3
C 程序应当干净利落地完成编译,不产生警告。
小结
- C 旨在向计算机发出命令,它在人类(程序员)与计算机之间充当媒介。
- C 必须经过编译才能执行。编译器负责在我们能够理解的语言(C)与特定平台的具体需求之间完成翻译。
- C 提供了实现可移植性的抽象层。同一个 C 程序可以在许多不同的计算机体系结构上使用。
- C 编译器是来帮助你的。如果它对程序中的某些内容发出警告,请认真听取。
C 行话中的特殊术语在本网站中以粗体标出。 ↩︎
事实上,翻译本身分为若干步骤,从文本替换一直延伸到真正的编译与链接。不过,把这些步骤统合在一起的工具历来称为“编译器”,而不称为技术上更准确的“翻译器”。 ↩︎
练习 3 在终端中尝试这条编译命令。 ↩︎
如果系统采用 Windows 操作系统,就必须安装编译器。Windows 自带的编译器如今正在赶上 C17,但我不了解其 C23 计划。本书讨论的许多功能可能无法使用。关于在 Windows 上使用其他开发环境的讨论,Chris Wellons 的博客文章 Four Ways to Compile C for Windows 也许仍值得一读。 ↩︎
练习 5 撰写一份文本报告,记录你在阅读本书时所做的测试。记下哪条命令在你的环境中能够工作。 ↩︎
练习 6 逐步改正清单 1.2。从第一行诊断开始,修正其中提到的代码,再重新编译;如此继续,直到得到毫无瑕疵的程序。 ↩︎
练习 7 两个程序之间还有第三处尚未提及的差异。请把它找出来。 ↩︎