2. 定义和声明

优质
小牛编辑
132浏览
2023-12-01

2. 定义和声明

2.1. externstatic关键字

在上一节我们把两个程序文件放在一起编译链接,main.c用到的函数pushpopis_emptystack.c提供,其实有一点小问题,我们用-Wall选项编译main.c可以看到:

$ gcc -c main.c -Wall
main.c: In function ‘main’:
main.c:8: warning: implicit declaration of function ‘push’
main.c:12: warning: implicit declaration of function ‘is_empty’
main.c:13: warning: implicit declaration of function ‘pop’

这个问题我们在第 2 节 “自定义函数”讨论过,由于编译器在处理函数调用代码时没有找到函数原型,只好根据函数调用代码做隐式声明,把这三个函数声明为:

int push(char);
int pop(void);
int is_empty(void);

现在你应该比学第 2 节 “自定义函数”的时候更容易理解这条规则了。为什么编译器在处理函数调用代码时需要有函数原型?因为必须知道参数的类型和个数以及返回值的类型才知道生成什么样的指令。为什么隐式声明靠不住呢?因为隐式声明是从函数调用代码推导而来的,而事实上函数定义的形参类型可能跟函数调用代码传的实参类型并不一致,如果函数定义带有可变参数(例如printf),那么从函数调用代码也看不出来这个函数带有可变参数,另外,从函数调用代码也看不出来返回值应该是什么类型,所以隐式声明只能规定返回值都是int型的。既然隐式声明靠不住,那编译器为什么不自己去找函数定义,而非要让我们在调用之前写函数原型呢?因为编译器往往不知道去哪里找函数定义,像上面的例子,我让编译器编译main.c,而这几个函数的定义却在stack.c里,编译器又怎么会知道呢?所以编译器只能通过隐式声明来猜测函数原型,这种猜测往往会出错,但在比较简单的情况下还算可用,比如上一节的例子这么编译过去了也能得到正确结果。

现在我们在main.c中声明这几个函数的原型:

/* main.c */
#include <stdio.h>

extern void push(char);
extern char pop(void);
extern int is_empty(void);

int main(void)
{
	push('a');
	push('b');
	push('c');
	
	while(!is_empty())
		putchar(pop());
	putchar('\n');

	return 0;
}

这样编译器就不会报警告了。在这里extern关键字表示这个标识符具有External Linkage。External Linkage的定义在上一章讲过,但现在应该更容易理解了,push这个标识符具有External Linkage指的是:如果把main.cstack.c链接在一起,如果pushmain.cstack.c中都有声明(在stack.c中的声明同时也是定义),那么这些声明指的是同一个函数,链接之后是同一个GLOBAL符号,代表同一个地址。函数声明中的extern也可以省略不写,不写extern的函数声明也表示这个函数具有External Linkage。

如果用static关键字修饰一个函数声明,则表示该标识符具有Internal Linkage,例如有以下两个程序文件:

/* foo.c */
static void foo(void) {}
/* main.c */
void foo(void);
int main(void) { foo(); return 0; }

编译链接在一起会出错:

$ gcc foo.c main.c
/tmp/ccRC2Yjn.o: In function `main':
main.c:(.text+0x12): undefined reference to `foo'
collect2: ld returned 1 exit status

虽然在foo.c中定义了函数foo,但这个函数只具有Internal Linkage,只有在foo.c中多次声明才表示同一个函数,而在main.c中声明就不表示它了。如果把foo.c编译成目标文件,函数名foo在其中是一个LOCAL的符号,不参与链接过程,所以在链接时,main.c中用到一个External Linkage的foo函数,链接器却找不到它的定义在哪儿,无法确定它的地址,也就无法做符号解析,只好报错。凡是被多次声明的变量或函数,必须有且只有一个声明是定义,如果有多个定义,或者一个定义都没有,链接器就无法完成链接。

以上讲了用staticextern修饰函数声明的情况。现在来看用它们修饰变量声明的情况。仍然用stack.cmain.c的例子,如果我想在main.c中直接访问stack.c中定义的变量top,则可以用extern声明它:

/* main.c */
#include <stdio.h>

void push(char);
char pop(void);
int is_empty(void);
extern int top;

int main(void)
{
	push('a');
	push('b');
	push('c');
	printf("%d\n", top);
	
	while(!is_empty())
		putchar(pop());
	putchar('\n');
	printf("%d\n", top);

	return 0;
}

变量top具有External Linkage,它的存储空间是在stack.c中分配的,所以main.c中的变量声明extern int top;不是变量定义,因为它不分配存储空间。以上函数和变量声明也可以写在main函数体里面,使所声明的标识符具有块作用域:

int main(void)
{
	void push(char);
	char pop(void);
	int is_empty(void);
	extern int top;

	push('a');
	push('b');
	push('c');
	printf("%d\n", top);
	
	while(!is_empty())
		putchar(pop());
	putchar('\n');
	printf("%d\n", top);

	return 0;
}

注意,变量声明和函数声明有一点不同,函数声明的extern可写可不写,而变量声明如果不写extern意思就完全变了,如果上面的例子不写extern就表示在main函数中定义一个局部变量top。另外要注意,stack.c中的定义是int top = -1;,而main.c中的声明不能加Initializer,如果上面的例子写成extern int top = -1;则编译器会报错。

main.c中可以通过变量声明来访问stack.c中的变量top,但是从实现stack.c这个模块的角度来看,top这个变量是不希望被外界访问到的,变量topstack都属于这个模块的内部状态,外界应该只允许通过pushpop函数来改变模块的内部状态,这样才能保证堆栈的LIFO特性,如果外界可以随机访问stack或者随便修改top,那么堆栈的状态就乱了。那怎么才能阻止外界访问topstack呢?答案就是用static关键字把它们声明为Internal Linkage的:

/* stack.c */
static char stack[512];
static int top = -1;

void push(char c)
{
	stack[++top] = c;
}

char pop(void)
{
	return stack[top--];
}

int is_empty(void)
{
	return top == -1;
}

这样,即使在main.c中用extern声明也访问不到stack.c的变量topstack。从而保护了stack.c模块的内部状态,这也是一种封装(Encapsulation)的思想。

static关键字声明具有Internal Linkage的函数也是出于这个目的。在一个模块中,有些函数是提供给外界使用的,也称为导出(Export)给外界使用,这些函数声明为External Linkage的。有些函数只在模块内部使用而不希望被外界访问到,则声明为Internal Linkage的。

2.2. 头文件

我们继续前面关于stack.cmain.c的讨论。stack.c这个模块封装了topstack两个变量,导出了pushpopis_empty三个函数接口,已经设计得比较完善了。但是使用这个模块的每个程序文件都要写三个函数声明也是很麻烦的,假设又有一个foo.c也使用这个模块,main.cfoo.c中各自要写三个函数声明。重复的代码总是应该尽量避免的,以前我们通过各种办法把重复的代码提取出来,比如在第 2 节 “数组应用实例:统计随机数”讲过用宏定义避免硬编码的问题,这次有什么办法呢?答案就是可以自己写一个头文件stack.h

/* stack.h */
#ifndef STACK_H
#define STACK_H
extern void push(char);
extern char pop(void);
extern int is_empty(void);
#endif

这样在main.c中只需包含这个头文件就可以了,而不需要写三个函数声明:

/* main.c */
#include <stdio.h>
#include "stack.h"

int main(void)
{
	push('a');
	push('b');
	push('c');
	
	while(!is_empty())
		putchar(pop());
	putchar('\n');

	return 0;
}

首先说为什么#include <stdio.h>用角括号,而#include "stack.h"用引号。对于用角括号包含的头文件,gcc首先查找-I选项指定的目录,然后查找系统的头文件目录(通常是/usr/include,在我的系统上还包括/usr/lib/gcc/i486-linux-gnu/4.3.2/include);而对于用引号包含的头文件,gcc首先查找包含头文件的.c文件所在的目录,然后查找-I选项指定的目录,然后查找系统的头文件目录。

假如三个代码文件都放在当前目录下:

$ tree
.
|-- main.c
|-- stack.c
`-- stack.h

0 directories, 3 files

则可以用gcc -c main.c编译,gcc会自动在main.c所在的目录中找到stack.h。假如把stack.h移到一个子目录下:

$ tree
.
|-- main.c
`-- stack
    |-- stack.c
    `-- stack.h

1 directory, 3 files

则需要用gcc -c main.c -Istack编译。用-I选项告诉gcc头文件要到子目录stack里找。

#include预处理指示中可以使用相对路径,例如把上面的代码改成#include "stack/stack.h",那么编译时就不需要加-Istack选项了,因为gcc会自动在main.c所在的目录中查找,而头文件相对于main.c所在目录的相对路径正是stack/stack.h

stack.h中我们又看到两个新的预处理指示#ifndef STACK_H#endif,意思是说,如果STACK_H这个宏没有定义过,那么从#ifndef#endif之间的代码就包含在预处理的输出结果中,否则这一段代码就不出现在预处理的输出结果中。stack.h这个头文件的内容整个被#ifndef#endif括起来了,如果在包含这个头文件时STACK_H这个宏已经定义过了,则相当于这个头文件里什么都没有,包含了一个空文件。这有什么用呢?假如main.c包含了两次stack.h

...
#include "stack.h"
#include "stack.h"

int main(void)
{
...

则第一次包含stack.h时并没有定义STACK_H这个宏,因此头文件的内容包含在预处理的输出结果中:

...
#define STACK_H
extern void push(char);
extern char pop(void);
extern int is_empty(void);
#include "stack.h"

int main(void)
{
...

其中已经定义了STACK_H这个宏,因此第二次再包含stack.h就相当于包含了一个空文件,这就避免了头文件的内容被重复包含。这种保护头文件的写法称为Header Guard,以后我们每写一个头文件都要加上Header Guard,宏定义名就用头文件名的大写形式,这是规范的做法。

那为什么需要防止重复包含呢?谁会把一个头文件包含两次呢?像上面那么明显的错误没人会犯,但有时候重复包含的错误并不是那么明显的。比如:

#include "stack.h"
#include "foo.h"

然而foo.h里又包含了bar.hbar.h里又包含了stack.h。在规模较大的项目中头文件包含头文件的情况很常见,经常会包含四五层,这时候重复包含的问题就很难发现了。比如在我的系统头文件目录/usr/include中,errno.h包含了bits/errno.h,后者又包含了linux/errno.h,后者又包含了asm/errno.h,后者又包含了asm-generic/errno.h

另外一个问题是,就算我是重复包含了头文件,那有什么危害么?像上面的三个函数声明,在程序中声明两次也没有问题,对于具有External Linkage的函数,声明任意多次也都代表同一个函数。重复包含头文件有以下问题:

  1. 一是使预处理的速度变慢了,要处理很多本来不需要处理的头文件。

  2. 二是如果有foo.h包含bar.hbar.h又包含foo.h的情况,预处理器就陷入死循环了(其实编译器都会规定一个包含层数的上限)。

  3. 三是头文件里有些代码不允许重复出现,虽然变量和函数允许多次声明(只要不是多次定义就行),但头文件里有些代码是不允许多次出现的,比如typedef类型定义和结构体Tag定义等,在一个程序文件中只允许出现一次。

还有一个问题,既然要#include头文件,那我不如直接在main.c#include "stack.c"得了。这样把stack.cmain.c合并为同一个程序文件,相当于又回到最初的例 12.1 “用堆栈实现倒序打印”了。当然这样也能编译通过,但是在一个规模较大的项目中不能这么做,假如又有一个foo.c也要使用stack.c这个模块怎么办呢?如果在foo.c里面也#include "stack.c",就相当于pushpopis_empty这三个函数在main.cfoo.c中都有定义,那么main.cfoo.c就不能链接在一起了。如果采用包含头文件的办法,那么这三个函数只在stack.c中定义了一次,最后可以把main.cstack.cfoo.c链接在一起。如下图所示:

图 20.2. 为什么要包含头文件而不是.c文件

为什么要包含头文件而不是.c文件

同样道理,头文件中的变量和函数声明一定不能是定义。如果头文件中出现变量或函数定义,这个头文件又被多个.c文件包含,那么这些.c文件就不能链接在一起了。

2.3. 定义和声明的详细规则

以上两节关于定义和声明只介绍了最基本的规则,在写代码时掌握这些基本规则就够用了,但其实C语言关于定义和声明还有很多复杂的规则,在分析错误原因或者维护规模较大的项目时需要了解这些规则。本节的两个表格出自[Standard C]。

首先看关于函数声明的规则。

表 20.1. Storage Class关键字对函数声明的作用

Storage ClassFile Scope DeclarationBlock Scope Declaration
none

previous linkage
can define

previous linkage
cannot define

extern

previous linkage
can define

previous linkage
cannot define

static

internal linkage
can define

N/A

以前我们说“extern关键字表示这个标识符具有External Linkage”其实是不准确的,准确地说应该是Previous Linkage。Previous Linkage的定义是:这次声明的标识符具有什么样的Linkage取决于前一次声明,这前一次声明具有相同的标识符名,而且必须是文件作用域的声明,如果在程序文件中找不到前一次声明(这次声明是第一次声明),那么这个标识符具有External Linkage。例如在一个程序文件中在文件作用域两次声明同一个函数:

static int f(void); /* internal linkage */
extern int f(void); /* previous linkage */

则这里的extern修饰的标识符具有Interanl Linkage而不是External Linkage。从上表的前两行可以总结出我们先前所说的规则“函数声明加不加extern关键字都一样”。上表也说明了在文件作用域允许定义函数,在块作用域不允许定义函数,或者说函数定义不能嵌套。另外,在块作用域中不允许用static关键字声明函数。

关于变量声明的规则要复杂一些:

表 20.2. Storage Class关键字对变量声明的作用

Storage ClassFile Scope DeclarationBlock Scope Declaration
none

external linkage
static duration
static initializer
tentative definition

no linkage
automatic duration
dynamic initializer
definition

extern

previous linkage
static duration
no initializer[*]
not a definition

previous linkage
static duration
no initializer
not a definition

static

internal linkage
static duration
static initializer
tentative definition

no linkage
static duration
static initializer
definition


上表的每个单元格里分成四行,分别描述变量的链接属性、生存期,以及这种变量如何初始化,是否算变量定义。链接属性有External Linkage、Internal Linkage、No Linkage和Previous Linkage四种情况,生存期有Static Duration和Automatic Duration两种情况,请参考本章和上一章的定义。初始化有Static Initializer和Dynamic Initializer两种情况,前者表示Initializer中只能使用常量表达式,表达式的值必须在编译时就能确定,后者表示Initializer中可以使用任意的右值表达式,表达式的值可以在运行时计算。是否算变量定义有三种情况,Definition(算变量定义)、Not a Definition(不算变量定义)和Tentative Definition(暂定的变量定义)。什么叫“暂定的变量定义”呢?一个变量声明具有文件作用域,没有Storage Class关键字修饰,或者用static关键字修饰,那么如果它有Initializer则编译器认为它就是一个变量定义,如果它没有Initializer则编译器暂定它是变量定义,如果程序文件中有这个变量的明确定义就用明确定义,如果程序文件没有这个变量的明确定义,就用这个暂定的变量定义[32],这种情况下变量以0初始化。在[C99]中有一个例子:

int i1 = 1; // definition, external linkage
static int i2 = 2; // definition, internal linkage
extern int i3 = 3; // definition, external linkage
int i4; // tentative definition, external linkage
static int i5; // tentative definition, internal linkage
int i1; // valid tentative definition, refers to previous
int i2; // 6.2.2 renders undefined, linkage disagreement
int i3; // valid tentative definition, refers to previous
int i4; // valid tentative definition, refers to previous
int i5; // 6.2.2 renders undefined, linkage disagreement
extern int i1; // refers to previous, whose linkage is external
extern int i2; // refers to previous, whose linkage is internal
extern int i3; // refers to previous, whose linkage is external
extern int i4; // refers to previous, whose linkage is external
extern int i5; // refers to previous, whose linkage is internal

变量i2i5第一次声明为Internal Linkage,第二次又声明为External Linkage,这是不允许的,编译器会报错。注意上表中标有[*]的单元格,对于文件作用域的extern变量声明,C99是允许带Initializer的,并且认为它是一个定义,但是gcc对于这种写法会报警告,为了兼容性应避免这种写法。



[32] 由于本书没有提及将不完全类型进行组合的问题,所以这条规则被我简化了,真正的规则还要复杂一些。读者可以参考C99中有关Incomplete Type和Composite Type的条款。Tentative Definition的完整定义在C99的6.9.2节条款2。