C现代方法(第15章)笔记——编写大型程序

文章目录

  • 第15章 编写大型程序
    • 15.1 源文件
    • 15.2 头文件
      • 15.2.1 #include指令
      • 15.2.2 共享宏定义和类型定义
      • 15.2.3 共享函数原型
      • 15.2.4 共享变量声明
      • 15.2.5 嵌套包含
      • 15.2.6 保护头文件
      • 15.2.7 头文件中的#error指令
    • 15.3 把程序划分成多个文件
    • 15.4 构建多文件程序
      • 15.4.1 makefile
      • 15.4.2 链接期间的错误
      • 15.4.3 重新构建程序
      • 15.4.4 在程序外定义宏
    • 问与答
    • 写在最后

第15章 编写大型程序

——计算机领域的进步是很难找到恰当的时间单位来衡量的。有些大教堂用了一个世纪才建成。你能想象耗时如此之久的程序该有多么庞大、多么壮观吗?

虽然某些C程序小得足够放入一个单独的文件中,但是大多数程序不是这样的。程序由多个文件构成的原则更容易让人接受。

你将在本章看到,常见的程序由多个源文件(source file)组成,通常还有一些头文件(header file)。源文件包含函数的定义和外部变量,而头文件包含可以在源文件之间共享的信息。15.1节讨论源文件,15.2节详细地介绍头文件,15.3节描述把程序分割成源文件和头文件的方法,15.4节说明如何“构建”(即编译和链接)由多个文件组成的程序,以及在改变程序的部分内容后如何“重新构建”。

15.1 源文件

到现在为止一直假设C程序是由单独一个文件组成的。事实上,可以把程序分割成任意数量的源文件。根据惯例,源文件的扩展名为.c。每个源文件包含程序的部分内容,主要是函数和变量的定义。其中一个源文件必须包含一个名为main的函数,此函数作为程序的起始点。

例如,假设打算编写一个简单计算器程序,用来计算按照逆波兰表示法(reverse polish notation, RPN)输入的整数表达式,在逆波兰表示法中运算符都跟在操作数的后边。如果用户输入表达式30 5 - 7 *,利用栈(10.2节)记录中间结果,那么计算逆波兰表达式的值是很容易的。如果程序读取的是数,就把此数压入栈。如果程序读取的是运算符,则从栈顶弹出两个数进行相应的运算,然后把结果压入栈。当程序执行到用户输入的末尾时,表达式的值将在栈中。

例如,程序将按照下列方式计算表达式30 5 – 7 *的值:

  1. 30压入栈。
  2. 5压入栈。
  3. 从栈顶弹出两个数,用30减去5,结果为25,然后把此结果压回到栈中。
  4. 7压入栈。
  5. 从栈顶弹出两个数,将它们相乘,然后把结果压回到栈中。

完成这些步骤后,栈将包含表达式的值(即175)。程序的main函数将用循环来执行下列动作:

  • 读取“记号”(数或运算符)。
  • 如果记号是数,那么把它压入栈。
  • 如果记号是运算符,那么从栈顶弹出它的操作数进行运算,然后把结果压入栈中。

当像这样把程序分割成文件时,将相关的函数和变量放入同一文件中是很有意义的。可以把读取记号的函数和任何需要用到记号的函数一起放到某个源文件(比如说token.c)中。push、pop、make_empty、is_emptyis_full这些与栈相关的函数可以放到另一个文件stack.c中。表示栈的变量也可以放入stack.c文件,而main函数则可以在另一个文件calc.c中。

把程序分成多个源文件有许多显著的优点:

  • 把相关的函数和变量分组放在同一个文件中可以使程序的结构清晰。
  • 可以分别对每一个源文件进行编译。如果程序规模很大而且需要频繁改变(这一点在程序开发过程中是非常普遍的)的话,这种方法可以极大地节约时间。
  • 把函数分组放在不同的源文件中更利于复用。在示例中,把stack.ctoken.cmain函数中分离出来使得今后更容易复用栈函数和记号函数。

15.2 头文件

当把程序分割为几个源文件时,问题也随之产生了:某文件中的函数如何调用定义在其他文件中的函数呢?函数如何访问其他文件中的外部变量呢?两个文件如何共享同一个宏定义或类型定义呢?答案取决于#include指令,此指令使得在任意数量的源文件中共享信息成为可能,这些信息可以是函数原型、宏定义、类型定义等

#include指令告诉预处理器打开指定的文件,并且把此文件的内容插入当前文件中。因此,如果想让几个源文件可以访问相同的信息,可以把此信息放入一个文件中,然后利用#include指令把该文件的内容带进每个源文件中。按照此种方式包含的文件称为头文件(有时称为包含文件)。本节后面将更详细地讨论头文件。根据惯例,头文件的扩展名为.h

注意!! C标准使用术语“源文件”来指代程序员编写的全部文件,包括.c文件和.h文件。本书中的“源文件”仅指.c文件

15.2.1 #include指令

#include指令主要有2种书写格式。第一种格式用于属于C语言自身库的头文件

#include <文件名>

第二种格式用于所有其他头文件,也包含任何自己编写的文件:

#include "文件名"

这两种格式间的细微差异在于编译器定位头文件的方式。下面是大多数编译器遵循的规则:

  • #include <文件名>:搜寻系统头文件所在的目录(或多个目录)。(例如,在UNIX系统中,通常把系统头文件保存在目录/usr/include 中。)
  • #include "文件名":先搜寻当前目录,然后搜寻系统头文件所在的目录(或多个目录)。

通常可以改变搜寻头文件的位置,这种改变经常利用如-I路径 这样的命令行选项来实现。

请注意!!不要在包含自己编写的头文件时使用尖括号:

#include   /*** WRONG ***/

这是因为预处理器可能在保存系统头文件的地方寻找myheader.h(但显然是找不到的)。


#include指令中的文件名可以含有帮助定位文件的信息,比如目录的路径或驱动器号:

#include "c:\cprogs\utils.h" /* Windows path */ 
#include "/cprogs/utils.h"   /* UNIX path */ 

虽然#include指令中的双引号使得文件名看起来像字面串,但是预处理器不会把它们作为字面串来处理。(这是幸运的,因为在上面的Windows例子中,字面串中出现的\c\u会被作为转义序列处理。)

可移植性技巧:通常最好的做法是在#include指令中不包含路径或驱动器的信息。当把程序转移到其他机器上,或者更糟的情况是转移到其他操作系统上时,这类信息会使编译变得很困难。

例如,下面的这些#include指令指定了驱动器或路径信息,而这些信息不可能一直是有效的:

#include "d:utils.h" 
#include "\cprogs\include\utils.h" 
#include "d:\cprogs\include\utils.h" 

下列这些指令相对好一些。它们没有指定驱动器,而且使用的是相对路径而不是绝对路径:

#include "utils.h" 
#include "..\include\utils.h" 

#include指令还有一种不太常用的格式:

#include 记号

其中记号是任意预处理记号序列。预处理器会扫描这些记号,并替换遇到的宏。宏替换完成以后,#include指令的格式一定与前面两种之一相匹配。第三种#include指令的优点是可以用宏来定义文件名,而不需要把文件名“硬编码”到指令里面去,如下所示:

#if defined(IA32) 
    #define CPU_FILE "ia32.h" 
#elif defined(IA64) 
    #define CPU_FILE "ia64.h" 
#elif defined(AMD64) 
    #define CPU_FILE "amd64.h" 
#endif 

#include CPU_FILE

15.2.2 共享宏定义和类型定义

大多数大型程序包含需要由几个源文件(或者,最极端的情况是用于全部源文件)共享的宏定义和类型定义。这些定义应该放在头文件中。

例如,假设正在编写的程序使用名为BOOL、TRUEFALSE的宏。(C99中不需要这么做,因为头中定义了类似的宏。)我们把这些定义放在一个名为boolean.h的头文件中,这样做比在每个需要的源文件中重复定义这些宏更有意义:

#define BOOL int 
#define TRUE 1 
#define FALSE 0

任何需要这些宏的源文件只需简单地包含下面这一行:

#include "boolean.h" 

类型定义在头文件中也是很普遍的。例如,不用定义BOOL宏,而是可以用typedef创建一个Bool类型。如果这样做,boolean.h文件将有下列显示:

#define TRUE 1 
#define FALSE 0 
typedef int Bool;

把宏定义和类型定义放在头文件中有许多显而易见的好处。首先,不把定义复制到需要它们的源文件中可以节约时间。其次,程序变得更加容易修改。改变宏定义或类型定义只需要编辑单独的头文件,而不需要修改使用宏或类型的诸多源文件。最后,不需要担心由于源文件包含相同宏或类型的不同定义而导致的矛盾。

15.2.3 共享函数原型

假设源文件包含函数f的调用,而函数f是定义在另一个文件foo.c中的。调用没有声明的函数f是非常危险的。如果没有函数原型可依赖,编译器会假定函数f的返回类型是int类型的,并假定形式参数的数量和函数f的调用中的实际参数的数量是匹配的。通过默认实参提升(9.3节),实际参数自身自动转换为“标准格式”。编译器的假定很可能是错误的,但是,因为一次只能编译一个文件,所以是没有办法进行检查的。如果这些假定是错误的,那么程序很可能无法工作,而且没有线索可以用来查找原因。(基于这个原因,C99禁止在编译器看到函数声明或定义之前对函数进行调用。)

请注意!!当调用在其他文件中定义的函数f时,要始终确保编译器在调用之前已看到函数f的原型。

我们的第一个想法是在调用函数f的文件中声明它。这样可以解决问题,但是可能产生维护方面的“噩梦”。假设有50个源文件要调用函数f,如何能确保函数f的原型在所有文件中都一样呢?如何能保证这些原型和foo.c文件中函数f的定义相匹配呢?如果以后函数f发生了改变,如何能找到所有用到此函数的文件呢?

解决办法是显而易见的:把函数f的原型放进一个头文件中,然后在所有调用函数f的地方包含这个头文件。既然在文件foo.c中定义了函数f,我们把头文件命名为foo.h除了在调用函数f的源文件中包含foo.h,还需要在foo.c中包含它,从而使编译器可以验证foo.h中函数f的原型和foo.cf的函数定义相匹配

请注意!!在含有函数f定义的源文件中始终包含声明函数f的头文件。如果不这样做,则可能导致难以发现的错误,因为在程序别处对函数f的调用可能会和函数f的定义不匹配。

如果文件foo.c包含其他函数,大多数函数应该在包含函数f的声明的那个头文件中声明。毕竟,文件foo.c中的其他函数大概会与函数f有关。任何含有函数f调用的文件都可能会需要文件foo.c中的其他一些函数。然而,仅用于文件foo.c的函数不需要在头文件中声明,如果声明则容易造成误解。


为了说明头文件中函数原型的使用,一起回到15.1节的RPN计算器示例。文件stack.c包含函数make_empty、is_empty、is_full、pushpop的定义。这些函数的原型应该放在头文件stack.h中:

void make_empty(void); 
int is_empty(void); 
int is_full(void); 
void push(int i); 
int pop(void);

(为了避免使示例复杂化,函数is_empty和函数is_full将不再返回Boolean类型值而返回int类型值。)文件calc.c中将包含stack.h以便编译器检查在后面的文件中出现的栈函数的任何调用。文件stack.c中也将包含stack.h以便编译器验证stack.h中的函数原型是否与stack.c中的定义相匹配。

15.2.4 共享变量声明

外部变量(10.2节)在文件中共享的方式与函数的共享很类似。为了共享函数,要把函数的定义放在一个源文件中,然后在需要调用此函数的其他文件中放置声明。共享外部变量的方法和此方式非常类似。

目前不需要区别变量的声明和它的定义。为了声明变量i,可以这样写:

int i;         /* declares i and defines it as well */ 

这样不仅声明iint类型的变量,而且也对i进行了定义,从而使编译器为i留出了空间。为了声明变量i而不是定义它,需要在变量声明的开始处放置extern关键字(18.2节):

extern int i;  /* declares i without defining it */ 

extern告诉编译器,变量i是在程序中的其他位置定义的(很可能是在不同的源文件中),因此不需要为i分配空间。

顺便说一句,extern可以用于所有类型的变量。在数组的声明中使用extern时,可以省略数组的长度:

extern int a[]; 

因为此刻编译器不用为数组a分配空间,所以也就不需要知道数组a的长度了。


为了在几个源文件中共享变量i,首先把变量i的定义放置在一个文件中:

int i;

如果需要对变量i初始化,可以把初始化器放在这里。在编译这个文件时,编译器会为变量i分配内存空间,而其他文件将包含变量i的声明:

extern int i; 

通过在每个文件中声明变量i,使得在这些文件中可以访问或修改变量i。然而,由于关键字extern的存在,编译器不会在每次编译这些文件时都为变量i分配额外的内存空间。

当在文件中共享变量时,会面临和共享函数时相似的挑战:确保变量的所有声明和变量的定义一致。

请注意!!当同一个变量的声明出现在不同文件中时,编译器无法检查声明是否和变量定义相匹配。例如,一个文件可以包含定义

int i;

同时另一个文件包含声明

extern long i;

这类错误可能导致程序的行为异常。

为了避免声明和变量的定义不一致,通常把共享变量的声明放置在头文件中。需要访问特定变量的源文件可以包含相应的头文件。此外,含有变量定义的源文件需要包含含有相应变量声明的头文件,这样编译器就可以检查声明与定义是否匹配。

虽然在文件中共享变量是C语言界中的长期惯例,但是它有重大的缺陷。在19.2节中你将看到存在的问题,并且学习如何设计不需要共享变量的程序。

15.2.5 嵌套包含

头文件自身也可以包含#include指令。虽然这种做法可能看上去有点奇怪,但实际上是十分有用的。思考含有下列原型的stack.h文件:

int is_empty(void); 
int is_full(void);

由于这些函数只能返回01,将它们的返回类型声明为Bool类型而不是int类型是一个很好的主意:

Bool is_empty(void); 
Bool is_full(void);

当然,我们需要在stack.h中包含文件boolean.h以便在编译stack.h时可以使用Bool的定义。(在C99中应包含而不是boolean.h,并把这两个函数的返回类型声明为bool而不是Bool。)

传统上,C程序员避免使用嵌套包含。(C语言的早期版本根本不允许嵌套包含。)但是,这种对嵌套包含的偏见正在逐渐减弱,一个原因就是嵌套包含在C++语言中很普遍。

15.2.6 保护头文件

如果源文件包含同一个头文件两次,那么可能产生编译错误。当头文件包含其他头文件时,这种问题十分普遍。例如,假设file1.h包含file3.hfile2.h包含file3.h,而prog.c同时包含file1.hfile2.h,那么在编译prog.c时,file3.h就会被编译2次。

两次包含同一个头文件不总是会导致编译错误。如果文件只包含宏定义、函数原型和/或变量声明,那么不会有任何困难。然而,如果文件包含类型定义,则会导致编译错误

安全起见,保护全部头文件避免多次包含可能是个好主意,那样的话可以在稍后添加类型定义,不用冒因忘记保护文件而可能产生的风险。此外,在程序开发期间,避免同一个头文件的不必要重复编译可以节省一些时间。

为了防止头文件多次包含,用#ifndef#endif指令来封闭文件的内容。例如,可以用如下方式保护文件boolean.h

#ifndef BOOLEAN_H 
#define BOOLEAN_H 

#define TRUE 1 
#define FALSE 0 
typedef int Bool; 

#endif

在首次包含这个文件时,没有定义宏BOOLEAN_H,所以预处理器允许保留#ifndef#endif之间的多行内容。但是如果再次包含此文件,那么预处理器将把#ifndef#endif之间的多行内容删除。

宏的名字(BOOLEAN_H)并不重要,但是,给它取类似于头文件名的名字是避免和其他的宏冲突的好方法。由于不能把宏命名为BOOLEAN.H(标识符不能含有句点),像BOOLEAN_H这样的名字是个很好的选择。

15.2.7 头文件中的#error指令

#error指令(14.5节)经常放置在头文件中,用来检查不应该包含头文件的条件。例如,如果头文件中用到了一个在最初的C89标准之前不存在的特性,为了避免把头文件用于旧的非标准编译器,可以在头文件中包含#ifdef指令来检查__STDC__宏(14.3节)是否存在:

#ifndef __STDC__ 
#error This header requires a Standard C compiler 
#endif

15.3 把程序划分成多个文件

现在应用我们已经知道的关于头文件和源文件的知识来开发一种把一个程序划分成多个文件的简单方法。这里将集中讨论函数,但是同样的规则也适用于外部变量。假设已经设计好程序,换句话说,已经决定程序需要什么函数以及如何把函数分为逻辑相关的组。(第19章将讨论程序设计。)

下面是处理的方法。把每个函数集合放入一个不同的源文件中(比如用名字foo.c来表示一个这样的文件)。另外,创建和源文件同名的头文件,只是扩展名为.h(在此例中,头文件是foo.h)。在foo.h文件中放置foo.c中定义的函数的函数原型。(在foo.h文件中不需要也不应该声明只在foo.c内部使用的函数。下面的read_char函数就是一个这样的例子。)每个需要调用定义在foo.c文件中的函数的源文件都应包含foo.h文件。此外,foo.c文件也应包含foo.h文件,这是为了编译器可以检查foo.h文件中的函数原型是否与foo.c文件中的函数定义相一致。

main函数将出现在某个文件中,这个文件的名字与程序的名字相匹配。如果希望称程序为bar,那么main函数就应该在文件bar.c中。main函数所在的文件中也可以有其他函数,前提是程序中的其他文件不会调用这些函数。


为了说明刚刚论述的方法,现在把它用于一个小型的文本格式化程序justify。我们用一个名为quote的文件作为justify的输入样例,quote文件包含下列(未格式化的)引语,这些引语来自Dennis M. Ritchie写的“The Development of the C programming language”一文(参见History of Programming Language II一书,由T. J. Bergin, Jr.R. G. Gibson, Jr.编写,第671~687页):

C     is quirky,  flawed,    and  an 
enormous   success.      Although accidents of    history 
 surely  helped,   it evidently    satisfied   a    need 
 
    for  a   system  implementation     language     efficient 
  enough    to  displace            assembly    language, 
    yet sufficiently     abstract     and fluent     to describe 
   algorithms   and       interactions      in a    wide    variety 
of    environments. 
                        --      Dennis       M.         Ritchie

为了在UNIXWindows的命令行环境下运行这个程序,输入命令

justify <quote

# 请注意!! windows系统下使用cmd执行上述命令,powershell会报错

符号<告诉操作系统,程序justify将从文件quote而不是从键盘读取输入。由UNIX、Windows和其他操作系统支持的这种特性称为输入重定向(input redirection,22.1节。当用给定的文件quote作为输入时,程序justify将产生下列输出:

C is quirky,  flawed,  and  an  enormous  success.   Although 
accidents of history surely helped,  it evidently satisfied a 
need for a system implementation language  efficient   enough 
to displace assembly language, yet sufficiently abstract  and 
fluent to describe algorithms and  interactions   in  a  wide 
variety of environments.  -- Dennis M. Ritchie 

程序justify的输出通常显示在屏幕上,但是也可以利用输出重定向output redirection,22.1节)把结果保存到文件中:

justify <quote >newquote

程序justify的输出将放入到文件newquote中。

通常情况下,justify的输出应该和输入一样,区别仅在于删除了额外的空格和空行,并对代码行做了填充和调整。“填充”行意味着添加单词直到再多加一个单词就会导致行溢出时才停止,“调整”行意味着在单词间添加额外的空格以便于每行有完全相同的长度(60个字符)。必须进行调整,只有这样一行内单词间的间隔才是相等的(或者几乎是相等的)。对输出的最后一行不进行调整

假设没有单词的长度超过20个字符。(把与单词相邻的标点符号看作单词的一部分。)当然,这样是做了一些限制,不过一旦完成了程序的编写和调试,我们就可以很容易地把这个长度上限增加到一个事实上不可能超越的值。如果程序遇到较长的单词,它需要忽略前20个字符后的所有字符,用一个星号替换它们。例如,单词

antidisestablishmentarianism 

将显示成

antidisestablishment* 

现在明白了程序应该完成的内容,接下来该考虑如何设计了。首先发现程序不能像读单词一样一个一个地写单词,而必须把单词存储在一个“行缓冲区”中,直到足够填满一行。在进一步思考之后,我们决定程序的核心将是如下所示的循环:

for (;;) { 
    读单词;
    if (不能读单词) { 
        输出行缓冲区的内容,不进行调整; 
        终止程序; 
    } 
    
    if (行缓冲区已经填满){ 
        输出行缓冲区的内容,进行调整; 
        清除行缓冲区; 
    } 
    往行缓冲区中添加单词; 
}

因为我们需要函数处理单词,并且还需要函数处理行缓冲区,所以把程序划分为3个源文件。把所有和单词相关的函数放在一个文件(word.c)中,把所有和行缓冲区相关的函数放在另一个文件(line.c)中,第3个文件(fmt.c)将包含main函数。除了上述这些文件,还需要两个头文件word.hline.h。头文件word.h将包含word.c文件中函数的原型,而头文件line.h将包含line.c文件中函数的原型。

通过检查主循环可以发现,我们只需要一个和单词相关的函数——read_word。(如果read_word函数因为到了输入文件末尾而不能读入单词,那么将通过假装读取“空”单词的方法通知主循环。)因此,文件word.h是一个短小的文件:

/*
word.h
*/
#ifndef WORD_H 
#define WORD_H 

/************************************************************ 
 * read_word: Reads the next word from the input and        *
 *            stores it in word. Makes word empty if no     *
 *            word could be read because of end-of-file.    *
 *            Truncates the word if its length exceeds      *
 *            len.                                          *
 ************************************************************/ 
void read_word(char *word, int len); 

#endif 

注意宏WORD_H是如何防止多次包含word.h文件的。虽然word.h文件不是真的需要它,但是以这种方式保护所有头文件是一个很好的习惯。

文件line.h不会像word.h那样短小。主循环的轮廓显示了需要执行下列操作的函数:

  • 输出行缓冲区的内容,不进行调整。
  • 检查行缓冲区中还剩多少字符。
  • 输出行缓冲区的内容,进行调整。
  • 清除行缓冲区。
  • 往行缓冲区中添加单词。

我们将要调用下面这些函数:flush_linespace_remainingwrite_lineclear_lineadd_word。下面是头文件line.h的内容:

/*
line.h
*/ 
#ifndef LINE_H 
#define LINE_H

/********************************************************** 
 * clear_line: Clears the current line.                   * 
 **********************************************************/ 
void clear_line(void); 

/********************************************************** 
 * add_word: Adds word to the end of the current line.    * 
 *           If this is not the first word on the line,   * 
 *           puts one space before word.                  * 
 **********************************************************/ 
void add_word(const char *word); 

/********************************************************** 
 * space_remaining: Returns the number of characters left * 
 *                  in the current line.                  * 
 **********************************************************/ 
int space_remaining(void); 

/********************************************************** 
 * write_line: Writes the current line with               * 
 *             justification.                             * 
 **********************************************************/ 
void write_line(void); 

/********************************************************** 
 * flush_line: Writes the current line without            * 
 *             justification. If the line is empty, does  * 
 *             nothing.                                   * 
 **********************************************************/ 
void flush_line(void); 

#endif

在编写文件word.c和文件line.c之前,可以用在头文件word.h和头文件line.h中声明的函数来编写主程序justify.c。编写这个文件的主要工作是把原始的循环设计翻译成C语言:

/*
justify.c
--Formats a file of text 
*/
#include  
#include "line.h" 
#include "word.h" 

#define MAX_WORD_LEN 20 

int main(void) 
{ 
    char word[MAX_WORD_LEN+2]; 
    int word_len; 
    
    clear_line(); 
    for (;;) { 
        read_word(word, MAX_WORD_LEN+1); 
        word_len = strlen(word); 

        if (word_len == 0) { 
            flush_line(); 
            return 0; 
        } 

        if (word_len > MAX_WORD_LEN) 
            word[MAX_WORD_LEN] = '*'; 

        if (word_len + 1 > space_remaining()) { 
            write_line(); 
            clear_line(); 
        } 
        add_word(word); 
    } 
}

包含line.hword.h可以使编译器在编译justify.c时能够访问到这两个文件中的函数原型。

main函数用了一个技巧来处理超过20个字符的单词。在调用read_word函数时,main函数告诉read_word截短任何超过21个字符的单词。当read_word函数返回后,main函数检查word包含的字符串长度是否超过20个字符。如果超过了,那么读入的单词必须至少是21个字符长(在截短前),所以main函数会用星号来替换第21个字符。


现在开始编写word.c程序。虽然头文件word.h只有一个read_word函数的原型,但是如果需要,我们可以在word.c中放置更多的函数。不难看出,如果添加一个小的“辅助”函数read_char,函数read_word的编写就容易一些了。read_char函数的任务就是读取一个字符,如果是换行符或制表符则将其转换为空格。在read_word函数中调用read_char函数而不是getchar函数,就解决了把换行符和制表符视为空格的问题。下面是文件word.c

/*
word.c
*/
#include  
#include "word.h" 

int read_char(void) 
{ 
    int ch = getchar(); 
    if (ch == '\n' || ch == '\t') 
        return ' '; 
    return ch; 
} 

void read_word(char *word, int len) 
{ 
    int ch, pos = 0; 
    
    while ((ch = read_char()) == ' ') 
        ; 
    while (ch != ' ' && ch != EOF) { 
        if (pos < len) 
            word[pos++] = ch; 
        ch = read_char(); 
    } 
    word[pos] = '\0'; 
}

在讨论read_word函数之前,先对read_char函数中的getchar函数的使用讲两点:

  • 第一,getchar函数实际上返回的是int类型值而不是char类型值,因此read_char函数中把变量ch声明为int类型,而且read_char函数的返回类型也是int
  • 第二,当不能继续读入时(通常因为读到了输入文件的末尾),getchar的返回值为EOF(22.4节)

read_word函数由两个循环构成。第一个循环跳过空格,在遇到第一个非空白字符时停止。(EOF不是空白,所以循环在到达输入文件的末尾时停止。)第二个循环读字符直到遇到空格或EOF时停止。循环体把字符存储到word中直到达到len的限制时停止。在这之后,循环继续读入字符,但是不再存储这些字符。read_word函数中的最后一个语句以空字符结束单词,从而构成字符串。如果read_word在找到非空白字符前遇到EOFpos将为0,从而使得word为空字符串。


唯一剩下的文件是line.c。这个文件提供在文件line.h中声明的函数的定义。line.c文件也会需要变量来跟踪行缓冲区的状态。一个变量line将存储当前行的字符。严格地讲,line是我们需要的唯一变量。然而,出于对速度和便利性的考虑,还将用到另外两个变量:line_len(当前行的字符数量)和num_words(当前行的单词数量)。下面是文件line.c

/*
line.c
*/
#include  
#include  
#include "line.h" 

#define MAX_LINE_LEN 60 

char line[MAX_LINE_LEN+1]; 
int line_len = 0; 
int num_words = 0; 

void clear_line(void) 
{ 
    line[0] = '\0'; 
    line_len = 0; 
    num_words = 0; 
} 

void add_word(const char *word) 
{ 
    if (num_words > 0) { 
        line[line_len] = ' '; 
        line[line_len+1] = '\0'; 
        line_len++; 
    } 
    strcat(line, word); 
    line_len += strlen(word); 
    num_words++; 
} 

int space_remaining(void) 
{ 
    return MAX_LINE_LEN - line_len; 
} 

void write_line(void) 
{ 
    int extra_spaces, spaces_to_insert, i, j; 
    
    extra_spaces = MAX_LINE_LEN - line_len; 
    for (i = 0; i < line_len; i++) { 
        if (line[i] != ' ') 
            putchar(line[i]); 
        else { 
            spaces_to_insert = extra_spaces / (num_words - 1); 
            for (j = 1; j <= spaces_to_insert + 1; j++) 
                putchar(' '); 
            extra_spaces -= spaces_to_insert; 
            num_words--; 
        } 
    } 
    putchar('\n'); 
} 

void flush_line(void) 
{ 
    if (line_len > 0) 
        puts(line); 
}

文件line.c中大多数函数很容易编写,唯一需要技巧的函数是write_line。这个函数用来输出一行内容并进行调整。函数write_lineline中一个一个地写字符,如果需要添加额外的空格,那么就在每对单词之间停顿。额外空格的数量存储在变量spaces_to_insert中,这个变量的值由extra_spaces / (num_words -1)确定,其中extra_spaces初始值是最大行长度和当前行长度的差。因为在打印每个单词之后extra_spacesnum_words都发生变化,所以spcaes_to_insert也将变化。如果extra_spaces初始值为10,并且num_words初始值为5,那么第1个单词之后将有2个额外的空格,第2个单词之后将有2个额外的空格,第3个单词之后将有3个额外的空格,第4个单词之后将有3个额外的空格。

15.4 构建多文件程序

2.1节中,我们研究了对单个文件的程序进行编译和链接的过程。现在将把这种讨论推广到由多个文件构成的程序中。构建大型程序和构建小程序所需的基本步骤相同:

  • 编译。必须对程序中的每个源文件分别进行编译。(不需要编译头文件。编译包含头文件的源文件时会自动编译头文件的内容。)对于每个源文件,编译器会产生一个包含目标代码的文件。这些文件称为目标文件(object file),在UNIX系统中的扩展名为.o,在Windows系统中的扩展名为.obj
  • 链接。链接器把上一步产生的目标文件和库函数的代码结合在一起生成可执行的程序。链接器的一个职责是解决编译器遗留的外部引用问题。(外部引用发生在一个文件中的函数调用另一个文件中定义的函数或者访问另一个文件中定义的变量时。)

大多数编译器允许一步构建程序。例如,对于GCC来说,可以使用下列命令行来构建15.3节中的justify程序:

gcc –o justify justify.c line.c word.c

首先把三个源文件编译成目标代码,然后自动把这些目标文件传递给链接器,链接器会把它们结合成一个文件。选项-o表明我们希望可执行文件的名字是justify

15.4.1 makefile

把所有源文件的名字放在命令行中很快变得枯燥乏味。更糟糕的是,如果重新编译所有源文件而不仅仅是最近修改过的源文件,重新构建程序的过程中可能会浪费大量的时间。

为了更易于构建大型程序,UNIX系统发明了makefile的概念,这个文件包含构建程序的必要信息。makefile不仅列出了作为程序的一部分的那些文件,而且还描述了文件之间的依赖性。假设文件foo.c包含文件bar.h,那么就说foo.c“依赖于”bar.h,因为修改bar.h之后将需要重新编译foo.c

下面是针对程序justify而设的UNIX系统的makefile,它用GCC进行编译和链接:

justify: justify.o word.o line.o 
        gcc -o justify justify.o word.o line.o 

justify.o: justify.c word.h line.h 
        gcc -c justify.c 

word.o: word.c word.h 
        gcc -c word.c 

line.o: line.c line.h 
        gcc -c line.c 

这里有4组代码行,每组称为一条规则。每条规则的第一行给出了目标文件,跟在后边的是它所依赖的文件。第二行是待执行的命令(当目标文件所依赖的文件发生改变时,需要重新构建目标文件,此时执行第二行的命令)。下面看一下前两条规则,后两条类似。

在第一条规则中,justify(可执行程序)是目标文件:

justify: justify.o word.o line.o 
        gcc -o justify justify.o word.o line.o 

第一行说明justify依赖于justify.oword.oline.o这三个文件。在程序的上一次构建完成之后,只要这三个文件中有一个发生改变,justify都需要重新构建。下一行信息说明如何重新构建justify(通过使用gcc命令链接三个目标文件)。

在第二条规则中,justify.o是目标文件:

justify.o: justify.c word.h line.h 
        gcc -c justify.c 

第一行说明,如果justify.cword.hline.h文件发生改变,那么justify.o需要重新构建。(提及word.hline.h的理由是,justify.c包含这两个文件,它们的改变都可能会对justify.c产生影响。)下一行信息说明如何更新justify.o(通过重新编译justify.c)。选项-c通知编译器把justify.c编译为目标文件,但是不要试图链接它。

一旦为程序创造了makefile,就能使用make实用程序来构建(或重新构建)该程序了。通过检查与程序中每个文件相关的时间和日期,make可以确定哪个文件是过期的。然后,它会调用必要的命令来重新构建程序。

如果你想试试make,下面是一些需要了解的细节:

  • makefile中的每个命令前面都必须有一个制表符,不是一串空格。(在我们的例子中,命令看似缩进了8个空格,但实际上是一个制表符。)

  • makefile通常存储在一个名为Makefile(或makefile)的文件中。使用make实用程序时,它会自动在当前目录下搜索具有这些名字的文件。

  • 用下面的命令调用make

    make 目标
    

    其中目标是列在makefile中的目标文件之一。为了用我们的makefile构建justify可执行程序,可以使用命令

    make justify
    
  • 如果在调用make时没有指定目标文件,则将构建第一条规则中的目标文件。例如,命令

    make
    

    将构建justify可执行程序,因为justify是我们的makefile中的第一个目标文件。除了第一条规则的这一特殊性质外,makefile中规则的顺序是任意的。

make非常复杂,复杂到可以用整本书来介绍,所以这里不打算深入研究它的复杂性。真正的makefile通常不像我们的示例那样容易理解。有很多方法可以减少makefile中的冗余,使它们更容易修改。但是,这些技术同时也极大地降低了它们的可读性。

顺便说一句,不是每个人都用makefile的。其他一些程序维护工具也很流行,包括一些集成开发环境支持的“工程文件”。

15.4.2 链接期间的错误

一些在编译期间无法发现的错误会在链接期间被发现。尤其是如果程序中丢失了函数定义或变量定义,那么链接器将无法解析外部引用,从而导致出现类似“undefined symbol”“undefined reference”的消息。

链接器检查到的错误通常很容易修改。下面是一些最常见的错误起因:

  • 拼写错误。如果变量名或函数名拼写错误,那么链接器将进行缺失报告。例如,如果在程序中定义了函数read_char,但调用时把它写为read_cahr,那么链接器将报告说缺失read_cahr函数。
  • 缺失文件。如果链接器不能找到文件foo.c中的函数,那么它可能不知道存在此文件。这时就要检查makefile或工程文件来确保foo.c文件是列出了的。
  • 缺失库。链接器不可能找到程序中用到的全部库函数。UNIX系统中有一个使用了的经典例子。在程序中简单地包含该头可能是不够的,很多UNIX版本要求在链接程序时指明选项-lm,这会导致链接器去搜索一个包含函数的编译版本的系统文件。不使用这个选项可能会在链接时导致出现“undefined reference”消息。

15.4.3 重新构建程序

在程序开发期间,极少需要编译全部文件。大多数时候,我们会测试程序,进行修改,然后再次构建程序。为了节约时间,重新构建的过程应该只对那些可能受到上一次修改影响的文件进行重新编译。

假设按照15.3节的框架方法设计了程序,并对每一个源文件都使用了头文件。为了判断修改后需要重新编译的文件的数量,我们需要考虑2种可能性:

  • 第一种可能性是修改只影响一个源文件。这种情况下,只有此文件需要重新编译。(当然,在此之后整个程序将需要重新链接。)思考程序justify。假设要精简word.c中的函数read_char:

    int read_char(void) 
    { 
        int ch = getchar(); 
        return (ch == '\n' || ch == '\t') ? ' ' : ch; 
    } 
    

    这种改变没有影响word.h,所以只需要重新编译word.c并且重新链接程序就行了。

  • 第二种可能性是修改会影响头文件。这种情况下,应该重新编译包含此头文件的所有文件,因为它们都可能潜在地受到这种修改的影响。(有些文件可能不会受到影响,但是保守一点是值得的。)

作为示例,思考一下程序justify中的函数read_word。注意,为了确定刚读入的单词的长度,main函数在调用read_word函数后立刻调用strlen。因为read_word函数已经知道了单词的长度(read_word函数的变量pos负责跟踪长度),所以使用strlen就显得多余了。修改read_word函数来返回单词的长度是很容易的。首先,改变word.h文件中read_word函数的原型:

/*********************************************************** 
 * read_word: Reads the next word from the input and       * 
 *            stores it in word. Makes word empty if no    * 
 *            word could be read because of end-of-file.   * 
 *            Truncates the word if its length exceeds     * 
 *            len. Returns the number of characters        * 
 *            stored.                                      * 
 ***********************************************************/
int read_word(char *word, int len);

当然,要仔细修改read_word函数的注释。接下来,修改word.c文件中read_word函数的定义:

int read_word(char *word, int len) 
{                               
    int ch, pos = 0; 
    
    while ((ch = read_char()) == ' ') 
        ; 
    while (ch != ' ' && ch != EOF)  { 
        if (pos < len) 
            word[pos++] = ch; 
        ch = read_char(); 
    } 
    word[pos] = '\0'; 
    return pos; 
}

最后,再来修改justify.c,方法是删除对的包含,并按如下方式修改main函数:

int main(void) 
{ 
    char word[MAX_WORD_LEN+2]; 
    int word_len; 
    
    clear_line(); 
    for (;;) { 
        word_len = read_word(word, MAX_WORD_LEN+1); 

        if (word_len == 0) { 
            flush_line(); 
            return 0; 
        } 
        
        if (word_len > MAX_WORD_LEN) 
            word[MAX WORD LEN] = '*'; 

        if (word_len + 1 > space_remaining()) { 
            write_line(); 
            clear_line(); 
        } 
        add_word(word); 
    } 
}

一旦做了上述这些修改,就需要重新构建程序justify,方法是重新编译word.cjustify.c,然后再重新链接。不需要重新编译line.c,因为它不包含word.h,所以也就不会受到word.h改变的影响。对于GCC,可以使用下列命令来重新构建程序:

gcc –o justify justify.c word.c line.o 

注意,这里用的是line.o而不是line.c

使用makefile的好处之一就是可以自动重新构建。通过检查每个文件的日期,make实用程序可以确定程序上一次构建之后哪些文件发生了改变。然后,它会把那些改变的文件和直接或间接依赖于它们的全部文件一起重新编译。例如,如果我们对word.hword.cjustify.c进行了修改,并重新构建了justify程序,那么make将执行如下操作。

  1. 编译justify.c以构建justify.o(因为修改了justify.cword.c)。
  2. 编译word.c以构建word.o(因为修改了word.cword.h)。
  3. 链接justify.oword.oline.o以构建justify(因为修改了justify.oword.o)。

15.4.4 在程序外定义宏

在编译程序时,C语言编译器通常会提供一种指定宏的值的方法。这种能力使我们很容易对宏的值进行修改,而不需要编辑程序的任何文件。当利用makefile自动构建程序时这种能力尤其有价值。

大多数编译器(包括GCC)支持-D选项,此选项允许用命令行来指定宏的值:

gcc –DDEBUG=1 foo.c 

在这个例子中,定义宏DEBUG在程序foo.c中的值为1,其效果相当于在foo.c的开始处这样写:

#define DEBUG 1

如果-D选项命名的宏没有指定值,那么这个值被设为1

许多编译器也支持-U选项,这个选项用于删除宏的定义,效果相当于#undef。我们可以使用-U选项来删除预定义宏(14.3节)或之前在命令行方式下用-D选项定义的宏的定义。


问与答

问1:这里没有任何例子是使用#include指令来包含源文件的。如果这样做了会发生什么?

答:这是合法的,但不是个好习惯。这里给出一个出问题的例子。假设 foo.c中定义了一个在bar.cbaz.c中需要用到的函数f,我们在bar.cbaz.c中都加上了如下指令:

#include "foo.c"

这些文件都会很好地被编译。但当链接器发现函数f的目标代码有两个副本时,问题就出现了。当然,如果只是bar.c包含此函数,而baz.c没有,那么将没有问题。为了避免出现问题,最好只用#include包含头文件而非源文件

问2:针对#include指令的精确搜索规则是什么?

答:这与所使用的编译器有关。C标准在#include的表述中故意模糊不清。如果文件名用尖括号围起来,那么预处理器会到一些“由实现定义的地方”搜索。如果文件名用双引号围起来,那么就“以实现定义的方式搜索”文件,如果没有找到,再按前一种方式搜索。原因很简单:不是所有操作系统都有分层的(树形的)文件系统。

更加有趣的是,标准根本不要求尖括号内的名字是文件名,因此使用<>#include指令有可能完全在编译器内部处理。

问3:我不理解为什么每个源文件都需要它自己的头文件。为什么没有一个大的头文件包含宏定义、类型定义和函数原型呢?通过包含这个文件,每个源文件都可以访问所需要的全部共享信息。

答:只用“一个大的头文件”确实可行,许多程序员使用这种方法。而且,这种方法有一个好处:因为只有一个头文件,所以要管理的文件较少。然而,对于大型程序来说,这种方法的坏处大于它的好处

只使用一个头文件不能为以后阅读程序的人提供有用的信息。如果有多个头文件,读者可以迅速了解到特定的源文件需要使用程序的其他哪些部分。

此外,因为每个源文件都依赖于这个大的头文件,所以改变它会导致要对全部源文件重新编译,这是大型程序中的一个显著缺陷。更糟的是,因为包含了大量信息,所以头文件可能会频繁地改变。

问4:本章说到共享数组应该按照下列方式声明:

extern int a[];

既然数组和指针关系密切,那么用下列写法代替是否合法呢?

extern int *a;

答:不合法。在用于表达式时,数组“衰退”成指针。(当数组名用作函数调用中的实际参数时,我们已经注意到这种行为。)但在变量声明中,数组和指针是截然不同的两种类型。

问5:如果源文件包含了不是真正需要的头,会有损害吗?

答:不会,除非头中的声明或定义与源文件中的冲突。否则,可能发生的最坏情况就是在编译源文件时时间会有少量增加。

问6:我需要调用文件foo.c中的函数,所以包含了匹配的头文件foo.h。程序可以通过编译,但是不能通过链接。为什么?

答:在C语言中编译和链接是完全独立的。头文件的存在是为了给编译器而不是给链接器提供信息。如果希望调用文件foo.c中的函数,那么需要确保对foo.c进行了编译,还要确保链接器知道必须在foo.c的目标文件中搜索该函数。通常情况下,这就意味着在程序的makefile或工程文件中命名foo.c

问7:如果程序调用中的函数,这是否意味着中的所有函数都将和程序链接呢?

答:不是的。包含(或者任何其他头)对链接没有任何影响。在任何情况下,大多数链接器只会链接程序实际需要的函数

问8:从哪里可以得到make实用程序?

答:make是标准的UNIX实用程序。GNU的版本称为GNU Make,包含在大多数Linux发行版中,也可以从自由软件基金会的网站上直接获取。


写在最后

本文是博主阅读《C语言程序设计:现代方法(第2版·修订版)》时所作笔记,日后会持续更新后续章节笔记。欢迎各位大佬阅读学习,如有疑问请及时联系指正,希望对各位有所帮助,Thank you very much!

你可能感兴趣的:(C语言,c语言,笔记,开发语言,1024程序员节)