16-编写大型程序
编写大型程序
If a listener nods his head when you’re explaining your program, wake him up. [^1]
目录
[TOC]
编写大型程序
零 源文件
到现在为止一直假设 C 程序是由单独一个文件组成的。事实上,可以把程序分割为任意数量的源文件。根据惯例,源文件的扩展名为.c
。每个源文件包含程序的部分内容,主要是函数的定义和变量的定义。其中源文件必须包含一个 main
函数作为程序的起始点。
把程序分为多个源文件有许多优点:
- 把相关的函数和变量分组放在同一个文件中可以使程序的结构清晰
- 可以分别对每一个源文件进行编译
- 把函数分组放在不同的源文件中更利于复用
一 头文件
当把程序分割为几个源文件时,问题也随之产生了:某文件中的函数如何调用定义在其他文件中的函数呢?函数如何访问其他文件中的外部变量呢?两个文件如何共享一个宏定义或类型定义呢?答案就是使用 #include
指令
#include
指令告诉预处理器打开指定的文件,并把此文件的内容插入到当前文件中。按照此种方式包含的文件称为头文件(有时称为包含文件)。
注意:C 标准使用术语“源文件”来只是程序员编写的全部文件,包括.c
和.h
文件。前面说的源文件指.c
文件。
0. #include
指令
#include 指令主要有两种书写格式,这两种格式之间的差异在于编译器定位头文件的方式。
-
用于属于 C 语言自身库的头文件:
搜寻系统头文件所在的目录(或多个目录)。
-
用于所有其他头文件(包含自己编写的):
先搜寻当前目录,然后再搜寻系统头文件所在的目录。
在 #include 指令中的文件名可以含有帮助定位文件的信息,比如目录的路径或驱动器号:
注意:
- 预处理器不会将 #include 指令中的双引号部分当作字符串字面量来处理。(否则,上例中的
\v
和\m
会被当作转义序列处理) - 最好在 #include 指令中不包含路径和驱动器信息(相对路径要好于绝对路径),这样可以提高可移植性。
#include 指令还有一种格式:
应用场景:
2. 共享宏定义和类型定义
3. 共享函数原型
4. 共享变量声明
上面这三部分的内容本质上差不多,后面我们会用一个程序来向大家演示如何完成。
需要注意的是:含有函数和变量定义的.c
文件需要包含有相应声明的头文件,这样编译器可以检查声明与定义是否匹配。
在头文件中这样写:
int i; |
这样不仅声明 i 是 int 类型变量,而且也对 i 进行了定义,从而使编译器为 i 留出了空间。为了声明变量 i 而不是定义它,可以这样做:
extern int i; |
extern
告诉编译器,变量 i 是在程序中的其他位置定义的,因此不需要为 i 分配空间。
extern 可以用于所有类型变量的声明中。在数组的声明中使用 extern 时,可以省略数组长度:
extern int a[]; |
因为此刻编译器不用为数组分配空间,所以不需要知道数组长度。
说了这么多我是没看懂 extern 怎么用,反正用的不多,不懂没事。
5. 嵌套包含
6. 保护头文件
如果源文件包含同一个头文件两次,那么可能产生编译错误。
比如 file1.h 包含 file3.h ,file2.h 包含 file3.h 如果 foo.c 同时包含 file1.h 和 file2.h,那么 file3.h 会被该源文件包含两次。(头文件包含另一个头文件就是所谓的嵌套包含,简单吧。)
保护头文件的好处:
- 安全
- 减少重复,提高效率
比如:
|
在 boolean.h 中定义宏 BOOLEAN_H ,首次包含这个头文件时,该宏没有被定义。另外,这种情况下,这样定义宏是一个不错的选择。
7. 头文件中的 #error
指令
#error
指令经常放置在头文件中,用来检查不应该包含该头文件的条件。例如:如果一个头文件中用到了一个在最初的 C89 前不存在的特性,为了避免把头文件用于旧的非标准编译器,检查 __STDC__
宏是否存在:
二 把程序划分成多个文件
程序:文本格式化
输入未格式化的引语:来自 Dennis M. Ritchie 写的"The Development of the C programming language" 一文:
C is quirky, flawed, and an |
程序完成对这段文字的调整:
C is quirky, flawed, and an enormous success. Although |
程序分析:
完成这个程序需要两步:读入和输出。
读入我们选择按单词读入到当前行中,然后按当前行输出。注意输出的每一行最后“对”的很齐,我们 write_line 函数对这种格式做了特殊处理。
按单词读入我们创建 word.h 和 word.c
按行输出我们创建 line.h 和 line.c
最后用 justify.c 包含 main 函数
参考程序:
word.h
|
line.h
|
word.c
|
line.c
|
justify.c
|
三 构建多文件程序
- 编译 必须对程序中的每个源文件分别进行编译。(不需要编译头文件。编译包含头文件的源文件时会自动编译头文件的内容。)对于每个源文件,编译器会产生一个包含目标代码的文件。这些文件称为目标文件(object file),在 UNIX 中扩展名为
.o
,Windows 中为.obj
- 链接 连接器把上一步产生的目标文件和库函数的代码结合起来在一起生成可执行的程序。链接器的一个职责是解决编译器遗留的外部引用问题。(外部引用发生在一个文件中的函数调用另一个文件中定义的函数或访问另一个文件中定义的变量时。)
编译我们可以用命令:gcc -c 文件名
大多数编译器允许一部构建:gcc -o justify justify.c word.c line.c
选项 -o
表明我们希望的可执行文件名为: justify
0. makefile
makefile 过于复杂,以后可能会单独处一起教学。
1. 链接期间的错误
如果程序丢失了函数的定义或变量定义,那么链接器将无法解析外部引用,从而导致undefined symbol
或undefined referece
的消息。
下面是一些最常见的错误起因:
- 变量名或函数名拼写错误。
- 缺失文件 如果编译器不能找到 foo.c 中的函数,那么可能不知道此文件。需要检查是否列出了 foo.c 文件
- 缺失库 链接器不可能找到程序中用到的全部库函数。Linux/Unix 中使用头
<math.h>
可能需要在链接程序时指明选项-lm
,这会导致链接器去搜索一个包含<math.h>函数编译版本的系统文件。(命令为gcc -lm 文件名
)
2. 重新构建程序
程序开发期间,极少需要编译全部文件。为了节约时间,重新构建的过程应该只对那些可能受到上次修改影响的文件进行重新编译。
需要重新编译的文件有两种可能性:
- 源文件被改
- 源文件包含的头文件被改
比如我们需要对程序:文本格式化中的程序做出一些修改:
修改 word.c 中的 read_char 函数:
int read_char(){ |
为了避免在 justify.c 中使用 strlen ,我们可以修改 word.c 中的 read_word 函数的返回值:
int read_word(char* word, int len) { |
与此同时,我们需要改变 read_word 在 word.h 中的声明:
int read_word(char* word, int len); |
然后改变 justify.c 函数对 read_word 的调用:
int main(void) { |
如此一来,我们改变了 word.c , word.h 和 justify.c ,在重新构建可执行程序 justify 时,我们需要重新编译 word.c 和 justify.c 然后再重新链接。注意,我们不需要重新编译 line.c ,因为它没有被修改也没有包含 word.h 。所以,对于 GCC 编译器,可以使用下面的指令进行重构:gcc -o justify justify.c word.c line.o
3. 在程序外定义宏
命令:gcc -D
比如:
gcc -DDEBUG=1 foo.c |
其效果相当于在 foo.c 的开始处这样写:
如果 -D 选项没有指定值,那么这个值被设为 1
许多编译器也支持-U
选项,用于删除宏,效果相当于#undef
[^1]: 如果有人听你讲解程序时点头了,把他叫醒。 Epigrams on Programming 编程警句
参考资料:《C语言程序设计:现代方法》