——定义变量很容易,但要时时刻刻控制它很难。
声明在C
语言编程中起着核心的作用。通过声明变量和函数,可以在两方面为编译器提供至关重要的信息:检查程序潜在的错误,以及把程序翻译成目标代码。
前面几章已经提供了声明的示例,但是没有完整地描述,本章将弥补这个缺憾。本章会探讨可以用于声明的复杂选项,并且显示变量声明和函数声明之间的几个共同点。此外,本章还为存储、作用域以及链接这些重要概念提供了坚实的基础。
18.1节
介绍声明的一般语法
。接下来的4节
将集中讨论声明中出现的数据项:存储类型(18.2节)
、类型限定符(18.3节)
、声明符(18.4节)
和初始化器(18.5节)
。18.6节
讨论了inline
关键字,它可以用在C99
函数声明中。
声明为编译器提供有关标识符含义的信息。当编写
int i;
时,是在告诉编译器:名字i
表示当前作用域内数据类型为int
的变量。声明
float f(float);
则是在告诉编译器:f
是一个返回值为float
型的函数,并且此函数有一个实际参数,此参数类型也为float
型。
一般地,声明具有下列形式:
声明指定符 声明符;
声明指定符(declaration specifier)
描述声明的变量或函数的性质。声明符(declarator)
给出了它们的名字,并且可以提供关于其性质的额外信息。
声明指定符分为以下
3
类:
4
种:auto
、static
、extern
和register
。在声明中最多可以出现一种存储类型。如果存储类型存在,则必须把它放置在最前面。C89
只有两种类型限定符:const
和volatile
。从C99
开始还有一个限定符restrict
;从C11
开始又新增了一个原子类型限定符_Atomic
。声明可以包含零个或多个限定符。void
、char
、short
、int
、long
、float
、double
、signed
和unsigned
都是类型指定符。这些单词可以组合使用,如第7章
所述。这些单词出现顺序并不重要(int unsigned long
和long unsigned int
完全一样)。类型指定符也包括结构、联合和枚举的说明(例如,struct point{int x, y;}
、struct {int x, y;}
或者struct point
)。用typedef
创建的类型名也是类型指定符。(从C99
开始还有第4
种声明指定符,即函数指定符
,它只用于函数声明。这一类指定符包括从C99
开始引入的关键字inline
和从C11
开始引入的_Noreturn
。)类型限定符和类型指定符必须跟随在存储类型的后边,但是两者的顺序没有限制。出于书写风格的考虑,这里会将类型限定符放置在类型指定符的前面。
声明符可以只是一个标识符(简单变量的名字),也可能
是标识符
和[]
以及*
的各种组合,用来表示指针、数组或者函数。声明符之间用逗号
分隔。表示变量的声明符后边可以跟随初始化器。一起看一些说明这些规则的例子。下面是一个带有存储类型和
3
个声明符的声明:
static float x, y, *p;
存储类型 | 类型指定符 | 声明符 |
---|---|---|
static | float | x, y, *p |
下列声明有类型限定符但是没有存储类型。此外,它还有初始化器:
const char month[] = "January";
类型限定符 | 类型指定符 | 声明符 | 初始化器 |
---|---|---|---|
const | char | month[] | “January” |
下列声明既有存储类型也有类型限定符。此外,它还有
3
个类型指定符,当然它们的顺序并不重要:
extern const unsigned long int a[10];
存储类型 | 类型限定符 | 类型指定符 | 声明符 |
---|---|---|---|
extern | const | unsigned long int | a[10] |
和变量声明一样,函数声明也有存储类型、类型限定符和类型指定符。下列声明具有存储类型和类型指定符:
extern int square(int);
存储类型 | 类型指定符 | 声明符 |
---|---|---|
extern | int | square(int) |
下面4
节将详细介绍存储类型、类型限定符、声明符和初始化器。
存储类型可以用于变量以及较小范围的函数和形式参数的说明。现在集中讨论变量的存储类型。
回顾一下10.3节
的内容,术语块(block)
表示函数体或者复合语句(可能包含声明)。从C99
开始, 选择语句(if和switch)
、循环语句(while、do和for)
以及它们所控制的“内部”语句也被视为块
,尽管本质上有一些差别。
C
程序中的每个变量都具有以下3
个性质:
自动存储期
的变量在所属块被执行时获得内存单元,并在块终止时释放内存单元,从而会导致变量失去值。具有静态存储期
的变量在程序运行期间占有同一个存储单元,也就允许变量无限期地保留它所占用的空间。块作用域
(变量的名字从声明的地方一直到所在块的末尾都是可见的)或者文件作用域
(变量的名字从声明的地方一直到所在文件的末尾都是可见的)。默认的存储期、作用域和链接都依赖于变量声明的位置:
下面的例子说明了变量i
和变量j
的默认性质:
int i; //静态存储期、文件作用域、外部链接
void f(void){
int j; //自动存储期、块作用域、无链接
}
对许多变量而言,默认的存储期、作用域和链接是符合要求的。当这些性质无法满足要求时,可以通过指定明确的存储类型(auto、static、extern和register)
来改变变量的性质。
auto
存储类型只对属于块的变量有效。auto
变量具有自动存储期(无须惊讶),它的名字具有块作用域,并且无链接。auto
存储类型几乎从来不用显式地指明,因为对于在块内部声明的变量,它是默认的。
static
存储类型可以用于全部变量,而无须考虑变量声明的位置。但是,作用于块外部声明的变量和块内部声明的变量时会有不同的效果。当用在块外部时,单词static
说明变量的名字具有内部链接
。当用在块内部时,static
把变量的存储期从自动的变成了静态的。下面的代码说明把变量i
和变量j
声明为static
所产生的效果:
static int i; //静态存储期、文件作用域、内部链接
void f(void){
static int j; //静态存储期、块作用域、无链接
}
在用于块外部的声明时,static
本质上使变量只在声明它的文件内可见。只有出现在同一文件中的函数可以看到此变量。在下面的例子中,函数f1
和函数f2
都可以访问变量i
,但是其他文件中的函数不可以:
static int i;
void f1(void)
{
/* has access to i */
}
void f2(void)
{
/* has access to i */
}
static
的此种用法可以用来实现一种称为信息隐藏(19.2节)
的技术。
块内声明的
static
变量在程序执行期间驻留在同一存储单元内。和每次程序离开所在块就会丢失值的自动变量不同,static
变量会无限期地保留值。static
变量具有以下一些有趣的性质:
static
变量只在程序执行前进行一次初始化,而auto
变量则会在每次出现时进行初始化(当然,需假设它有初始化器)。auto
变量。但是,如果函数含有static
变量,那么此函数的全部调用都可以共享这个static
变量。auto
变量的指针,但是函数返回指向static
变量的指针是没有错误的。声明函数中的一个变量为
static
,这样做允许函数在“隐藏”区域内的调用之间保留信息。隐藏区域是程序其他部分无法访问到的地方。然而,更通常的做法是用static
来使程序更加有效。思考下列函数:
char digit_to_hex_char(int digit)
{
const char hex_chars[16] = "0123456789ABCDEF";
return hex_chars[digit];
}
每次调用digit_to_hex_char
函数时,都会把字符0123456789ABCDEF
复制给数组hex_chars
来对其进行初始化。现在,把数组设为static
的:
char digit_to_hex_char(int digit)
{
static const char hex_chars[16] = "0123456789ABCDEF";
return hex_chars[digit];
}
//由于static型变量只进行一次初始化,这样做就提升了digit_to_hex_char函数的速度。
extern
存储类型使几个源文件可以共享同一个变量。15.2节
介绍了使用extern
的基本概念,所以这里的讨论不会太多。回顾讲过的内容可以知道,下列声明给编译器提供的信息是,i
是int
型变量:
extern int i;
但是这样不会导致编译器为变量i
分配存储单元。用C
语言的术语来说,上述声明不是变量i
的定义,它只是提示编译器需要访问定义在别处的变量(可能稍后在同一文件中,更常见的是在另一个文件中)。变量在程序中可以有多次声明,但只能有一次定义。
变量的extern
声明不是定义,这一规则有一个例外。对变量进行初始化的extern
声明是变量的定义。例如,声明
extern int i = 0;
//上述语句等效于
int i = 0;
这条规则可以防止多个extern
声明用不同方法对变量进行初始化。
extern
声明中的变量始终具有静态存储期。变量的作用域依赖于声明的位置。如果声明在块内部,那么它的名字具有块作用域;否则,具有文件作用域:
extern int i; //静态存储期、文件作用域、什么链接?
void f(void){
extern int j; //静态存储期、块作用域、什么链接?
}
确定extern
型变量的链接有一定难度。如果变量在文件中较早的位置(任何函数定义的外部)声明为static
,那么它的名字具有内部链接;否则(通常情况下),具有外部链接。
声明变量具有
register
存储类型就要求编译器把变量存储在寄存器中,而不是像其他变量一样保留在内存中。(寄存器是驻留在计算机CPU
中的存储单元。存储在寄存器中的数据会比存储在普通内存中的数据访问和更新的速度更快。)指明变量的存储类型是register
是一种请求,而不是命令。编译器可以选择把register
型变量存储在内存中。
register
存储类型只对声明在块内的变量有效。register
变量具有和auto
变量一样的存储期、名字的作用域和链接。但是,register
变量缺乏auto
变量所具有的一种性质:因为寄存器没有地址,所以对register
变量使用取地址运算符&
是非法的。即使编译器选择把变量存储在内存中,这一限制仍适用。
register
存储类型最好用于需要频繁进行访问或更新的变量。例如,在for
语句中的循环控制变量就比较适合声明为register
:
int sum_array(int a[], int n)
{
register int i;
int sum = 0;
for (i = 0; i < n; i++)
sum += a[i];
return sum;
}
现在register
存储类型已经不像以前那样在C
程序员中流行了。当今的编译器比早期的C
语言编译器复杂多了,许多编译器可以自动确定哪些变量保留在寄存器中可以获得最大的好处。不过,使用register
仍然可以为编译器优化程序性能提供有用的信息。特别地,编译器知道不能对register
变量取地址,因而不能用指针对其进行修改。在这一方面,register
关键字与C99
的restrict
关键字相关。
和变量声明一样,函数声明(和定义)也可以包括存储类型,但是选项只有
extern
和static
。在函数声明开始处的单词extern
说明函数的名字具有外部链接,也就是允许其他文件调用此函数;static
说明是内部链接,也就是说只能在定义函数的文件内部调用此函数。如果不指明函数的存储类型,那么会假设函数具有外部链接。
思考下面的函数声明:
extern int f(int i);
static int g(int i);
int h(int i);
函数f
具有外部链接,函数g
具有内部链接,而函数h
(默认情况下)具有外部链接。因为g
具有内部链接,所以在定义它的文件之外不能直接调用它。(把g
声明为static
不能完全阻止在别的文件中对它进行调用,通过函数指针进行间接调用仍然是可能的。)
声明函数是extern
的就如同声明变量是auto
的一样,两者都没有作用。基于这个原因,本书不在函数声明中使用extern
。然而你需要知道,一些程序员广泛地使用extern
也是无害的。
另外,声明函数是
static
的十分有用。事实上,当声明不打算被其他文件调用的任意函数时,建议使用static
存储类型。这样做的好处包括以下2
点:
f
声明为static
存储类型,能保证在函数定义出现的文件之外函数f
都是不可见的。因此,以后修改程序的人可以知道对函数f
的修改不会影响其他文件中的函数。(一个例外是,另一个文件中的函数如果传入了指向函数f
的指针,它可能会受到函数f
变化的影响。幸运的是,这种问题很容易通过检查定义函数f
的文件来发现,因为传递f
的函数一定也定义在此文件中。)static
的函数具有内部链接,所以可以在其他文件中重新使用这些函数的名字。虽然我们不太可能会为一些其他目的故意重新使用函数名字,但是在大规模程序中这种现象是很难避免的。带有外部链接的大量函数名可能导致C
程序员所说的“名字空间污染”,即不同文件中的名字意外地发生了冲突。使用static
存储类型可以有效地预防此类问题。函数的形式参数具有和
auto
变量相同的性质:自动存储期、块作用域和无链接。唯一能用于形式参数的存储类型是register
。
目前已经介绍了各种存储类型,现在对已知内容进行一个总结。下面的代码片段说明了变量和形式参数声明中包含或者省略存储类型的所有可能的方法:
int a;
extern int b;
static int c;
void f(int d, register int e)
{
auto int g;
int h;
static int i;
extern int j;
register int k;
}
表18-1说明了上述例子中每个变量和形式参数的性质:
表18-1 变量和形式参数的性质
名字 | 存储期 | 作用域 | 链接 |
---|---|---|---|
a | 静态 | 文件 | 外部 |
b | 静态 | 文件 | 无法确定 |
c | 静态 | 文件 | 内部 |
d | 自动 | 块 | 无 |
e | 自动 | 块 | 无 |
g | 自动 | 块 | 无 |
h | 自动 | 块 | 无 |
i | 静态 | 块 | 无 |
j | 静态 | 块 | 无法确定 |
k | 自动 | 块 | 无 |
无法确定: 因为这里没有显示出变量
b
和j
的定义,所以无法确定它们的链接。在大多数情况下,变量会定义在另一个文件中,并且具有外部链接。
在这4
种存储类型之中,最重要的是extern
和static
。auto
没有任何效果,而现代编译器已经使register
变得不如以前重要了。从C11
开始增加了_Thread_local
存储类型和线程存储期,第28章
中再来详细介绍。
早先在
C
语言中一共有两种类型限定符:const
和volatile
。[C99
引入了第三种类型限定符,即restrict
,它只用于指针(受限指针17.8节
);C11
又引入了第4种
类型限定符,即_Atomic
,可用于除数组和函数之外的类型,将在第28章
中介绍]。因为volatile
只用在底层编程中,所以本书将对此限定符的讨论推迟到20.3节
。const
用来声明一些类似变量的对象,但这些变量是“只读”
的。程序可以访问const
型对象的值,但是无法改变它的值。例如,下面这个声明创建了名为n
的const
型对象,且此对象的值为10
:
const int n = 10;
而下列声明产生了名为tax_brackets
的const
型数组:
const int tax_brackets[] = {750, 2250, 3750, 5250, 7000};
把对象声明为
const
有以下几个好处:
const
是文档格式:声明对象是const
类型可以提示任何阅读程序的人,该对象的值不会改变。const
来识别需要存储到ROM
(只读存储器)中的数据。乍一看,
const
好像与前面章节中用于创建常量名的#define
指令一样。然而,实际上#define
和const
之间有明显的差异:
可以用#define
指令为数值、字符或字符串常量创建名字。const
可用于产生任何类型的只读对象,包括数组、指针、结构和联合。
const
对象遵循与变量相同的作用域规则,而用#define
创建的常量不受这些规则的限制。特别是不能用#define
创建具有块作用域的常量。
和宏的值不同,const
对象的值可以在调试器中看到。
不同于宏,const
对象不可以用于常量表达式。例如,因为数组边界必须是常量表达式,所以不能写成下列形式:
const int n = 10;
int a[n]; /*** WRONG ***/
(在C99
中,如果a
具有自动存储期,那么这个例子是合法的——它会被视为变长数组;但是如果a
具有静态存储期,那么这个例子是不合法的。)
对const
对象应用取地址运算符(&)
是合法的,因为它有地址。宏没有地址。
没有绝对的原则说明何时使用
#define
以及何时使用const
。这里建议对表示数或字符的常量使用#define
。这样就可以把这些常量作为数组维数,并且在switch
语句或其他要求常量表达式的地方使用它们。
声明符不等于标识符。
声明符包含标识符(声明的变量或函数的名字),标识符的前边可能有符号
*
,后边可能有[]
或()
。通过把*
、[]
和()
组合在一起,可以创建复杂声明符
。在了解较为复杂的声明符之前,先来复习一下前面讲过的声明符的知识。在最简单的情况下,声明符就是标识符,就如同下面例子中的
i
:
int i;
声明符还可以包含符号*
、[]
和()
。
用*
开头的声明符表示指针:
int *p;
用[]
结尾的声明符表示数组:
int a[10];
如果数组是形式参数,或者数组有初始化器,再或者数组的存储类型为extern
,那么方括号内可以为空:
extern int a[];
因为a
是在程序的别处定义的,所以这里编译器不需要知道数组的长度。(在多维数组中,只有第一维的方括号可以为空。) 从C99
开始为数组形式参数声明中方括号内的内容提供了两种额外的选项。一个是关键字static
,后面跟着的表达式指明数组的最小长度;另一个是符号*
,它可以用在函数原型中以指示变长数组参数。9.3节
讨论了这两种新特性。
用()
结尾的声明符表示函数:
int abs(int i);
void swap(int *a, int *b);
int find_largest(int a[], int n);
C
语言允许在函数声明中省略形式参数的名字:
int abs(int);
void swap(int *, int *);
int find_largest(int [], int);
甚至圆括号内可以为空:
int abs();
void swap();
int find_largest();
最后这组声明指明了abs
、swap
和find_largest
的返回类型,但是没有提供有关它们的实际参数的信息。圆括号内置为空不等同于把单词void
放置在圆括号内,后者说明没有实际参数。圆括号内为空的这种函数声明风格正在迅速消失。它比C89
的原型形式差,因为它不允许编译器检查函数调用是否有正确的实际参数。
如果所有的声明符都这样简单,那么
C
语言的编程将一蹴而就。可惜的是,实际程序中的声明符往往组合了符号*
、[]
和()
。我们已经见过这类组合的示例了。我们知道下列语句声明了一个数组,此数组的元素是10个
指向整数的指针:
int *ap[10];
我们还知道下列语句声明了一个函数,此函数有一个float
型的实际参数,并且返回指向float
型值的指针:
float *fp(float);
此外,我们在17.7节
学过下面这条语句,它用来声明一个指向函数的指针,此函数有int
型实际参数和void
型返回值:
void (*pf)(int);
到目前为止,我们在声明符的理解方面还没有遇到太多的麻烦。但是,下面这个声明符是什么意思呢?
int *(*x[10])(void);
这个声明符组合了*
、[]
和()
,所以x
是指针、数组还是函数并不明显。
幸运的是,无论多么费解,都可以根据下面两条简单的规则来理解任何声明:
[]
和()
优先于*
。如果*
在标识符的前面,而标识符后边跟着[]
,那么标识符表示数组而不是指针。同样地,如果*
在标识符的前面,而标识符后边跟着()
,那么标识符表示函数而不是指针。(当然,可以使用圆括号来使[]
和()
相对于*
的优先级无效。)首先把这些规则应用于简单的示例。在声明
int *ap[10];
中,ap
是标识符。因为*
在ap
的前面,并且后边跟着[]
,而[]
优先级高,所以ap
是指针数组。在下列声明中,
float *fp(float);
fp
是标识符。因为*
在标识符的前面,并且后边跟着()
,而()
优先级高,所以fp
是返回指针的函数。
下列声明是一个小陷阱:
void (*pf)(int);
因为*pf
包含在圆括号内,所以pf
一定是指针。但是(*pf)
后边跟着(int)
,所以pf
必须指向函数,且此函数带有int
型的实际参数。单词void
表明了此函数的返回类型。
正如最后那个例子所示,理解复杂的声明符经常需要从标识符的一边折返到另一边:
pf的类型:
(*pf) | (int) | void |
---|---|---|
指针指向 | 具有与int型实际参数的函数 | 返回void型值 |
下面用这种折返方法来解释先前给出的声明:
int *(*x[10])(void);
首先,定位声明的标识符(x)
。在x
前有*
,而后边又跟着[]
。因为[]
优先级高于*
,所以取右侧(x
是数组)。接下来,从左侧找到数组中元素的类型(指针)。再接下来,到右侧找到指针所指向的数据类型(不带实际参数的函数)。最后,回到左侧看每个函数返回的内容(指向int
型的指针)。
要想熟练掌握
C
语言的声明,需要花些时间,并且要多练习。唯一的好消息是在C
语言中有不能声明的特定内容。函数不能返回数组:
int f(int)[]; /*** WRONG ***/
函数不能返回函数:
int g(int)(int); /*** WRONG ***/
返回函数型的数组也是不可能的:
int a[10](int); /*** WRONG ***/
在上述情形中,可以用指针来获得所需的效果。函数不能返回数组,但可以返回指向数组的指针;函数不能返回函数,但可以返回指向函数的指针;函数型的数组不合法,但是数组可以包含指向函数的指针。(17.7节
有一个这样的数组示例。)
一些程序员利用类型定义来简化复杂的声明。考虑一下前面检查过的
x
的声明:
int *(*x[10])(void);
为了使x
的类型更容易理解,可以使用下面一系列的类型定义:
typedef int *Fcn(void);
typedef Fcn *Fcn_ptr;
typedef Fcn_ptr Fcn_ptr_array[10];
Fcn_ptr_array x;
反向阅读可以发现,x
具有Fcn_ptr_array
类型,Fcn_ptr_array
是Fcn_ptr
值的数组,Fcn_ptr
是指向Fcn
类型的指针,而Fcn
是不带实际参数且返回指向int
型值的指针的函数。
为了方便,
C
语言允许在声明变量时为它们指定初始值。为了初始化变量,可以在声明符的后边书写符号=
,然后在其后加上初始化器。(不要把声明中的符号=
和赋值运算符相混淆,初始化和赋值不一样。)
在前面章节中已经见过各种各样的初始化器了。简单变量的初始化器就是一个与变量类型一样的表达式:
int i = 5 / 2 ; /* i is initially 2 */
如果类型不匹配,C
语言会用和赋值运算相同的规则对初始化器进行类型转换( 7.4节)
:
int j = 5.5; /* converted to 5 */
指针变量的初始化器必须是具有和变量相同类型或void*
类型的指针表达式:
int *p = &i;
数组、结构或联合的初始化器通常是带有花括号
的一串值:
int a[5] = {1, 2, 3, 4, 5};
从
C99
开始,由于指示器(8.1节、16.1节)
的存在,初始化器可以有其他形式。为了全面覆盖声明的范围,现在来看看一些控制初始化器的额外规则:
具有静态存储期的变量的初始化器必须是常量:
#define FIRST 1
#define LAST 100
static int i = LAST – FIRST + 1;
因为LAST
和FIRST
都是宏,所以编译器可以计算出i
的初始值(100-1+1=100
)。如果LAST
和FIRST
是变量,那么初始化器就是非法的。
如果变量具有自动存储期,那么它的初始化器不必是常量:
int f(int n)
{
int last = n – 1;
...
}
包含在花括号中的数组、结构或联合的初始化器必须只包含常量表达式,不允许有变量或函数调用:
#define N 2
int powers[5] = {1, N, N * N, N * N * N, N * N * N * N};
因为N
是常量,所以powers
的初始化器是合法的。如果N
是变量,那么程序将无法通过编译。在C99
中,仅当变量具有静态存储期时,这一限制才生效。
自动类型的结构或联合的初始化器可以是另外一个结构或联合:
void g(struct part part1)
{
struct part part2 = part1;
...
}
虽然初始化器应该是具有适当类型的表达式,但它们不必是变量或形式参数名。例如,part2
的初始化器可以是*p
,其中p
具有struct part *
类型;也可以是f(part1)
,其中f
是返回part
结构类型的函数。
前面的章节中已经暗示,未初始化变量有未定义的值,但并不总是这样的,变量的初始化值依赖于变量的存储期。
0
。用calloc
分配的内存是简单地给字节的位设为0
,而静态变量不同于此,它是基于类型的正确初始化,即整型变量初始化为0
,浮点变量初始化为0.0
,指针初始化为空指针。出于书写风格的考虑,最好为静态类型的变量提供初始化器,而不是依赖于它们一定为0
的事实。如果程序访问了没有明确初始化的变量,那么以后阅读程序的人可能不容易确定变量是否为0
,或者是否在程序中的某处通过赋值初始化。
C99
及之后的函数声明中有一个C89
中不存在的选项:可以包含关键字inline
。这个关键字是一个全新的声明指定符,不同于存储类型、类型限定符以及类型指定符。为了理解inline
的作用,需要把C
编译器在调用函数和从函数返回过程中产生的机器指令可视化。在机器层面,调用函数之前可能需要预先执行一些指令。调用本身需要跳转到函数的第一条指令,函数本身可能也需要执行一些额外的指令来启动执行。如果函数有参数,参数需要被复制(因为
C
通过值传递参数)。从函数返回也需要被调用的函数和调用函数执行差不多的工作量。调用函数和从函数返回所需的工作量称为“额外开销”,因为我们并没有要求函数执行这些工作。尽管函数调用中的额外开销只是使程序稍许变慢,但在特定的情况下额外开销会产生累积效应。例如,在函数需要调用数百万次或数十亿次,使用老式的比较慢的处理器(例如在嵌套系统中),或者有着非常严格的时限要求(例如在实时系统中)时。在
C89
中,避免函数额外开销的唯一方式是使用带参数的宏(14.3节)
。但带参数的宏也有一些缺点。C99
提供了一种更好的解决方案:创建内联函数(inline function)
。“内联”表明编译器把函数的每一次调用都用函数的机器指令来代替。这种方法虽然会使被编译程序的大小增加一些,但可以避免函数调用的常见额外开销。不过,把函数声明为
inline
并不是强制编译器将代码内联编译,而只是建议编译器应该使函数调用尽可能地快,也许在函数调用时才执行内联展开。编译器可以忽略这一建议。从这方面来说,inline
类似于register
和restrict
关键字,后两者也是用于提升程序性能的,但可以忽略。
内联函数用关键字inline
作为一个声明指定符:
inline double average(double a, double b)
{
return (a + b) / 2;
}
下面考虑复杂一点的情形。average
有外部链接,所以在其他源文件中也可以调用average
。但编译器并没有考虑average
的定义是外部定义(因其是内联定义),所以试图在别的文件中调用average
将被当作错误。(这句话第一时间可能看不懂,请仔细阅读下面的解释)
有两种方法可以避免这一错误。一种方法是在函数定义中增加单词
static
:
static inline double average(double a, double b)
{
return (a + b) / 2;
}
现在average
具有内部链接了,所以其他文件不能调用它。其他文件可以定义自己的average
函数,可以与这里的定义相同,也可以不同。
另一种方法是为
average
提供外部定义,从而可以在其他文件中调用。一种实现方式是将该函数重新写一遍(不使用inline
),并将这一函数定义放在另一个源文件中。这样做是合法的,但为同一个函数提供两个版本不太可取,因为我们不能保证对程序进行修改时它们仍然一致。更好的实现方式是,首先将
average
的内联定义放入头文件(命名为average.h
)中:
#ifndef AVERAGE_H
#define AVERAGE_H
inline double average(double a, double b)
{
return (a + b) / 2;
}
//上面是average函数的内联定义,与一般函数不同,
//函数的内联定义通常是放在放在头文件中的
#endif
小插曲:假设你只创建了上述
average.h
(仅包含average
函数的内联定义),并在另一个main.c
文件中的main
函数里调用了average
函数(main.c
包含了#include "average.h"
头),当编译main.c
文件时,会产生类似“undefined reference to average'”
的报错信息,原因是average
没有外部定义!
接下来再创建与之匹配的源文件average.c
:
#include "average.h"
extern double average(double a, double b);
//使用extern关键字,使得average函数的内联定义
//被二次利用,有效且一致地实现了average函数的外部定义。
现在,任何一个需要调用average
函数的文件只需要简单地包含average.h
就行了,该头文件包含了average
的内联定义。average.c
文件包含了average
的原型。由于使用了extern
关键字,因此average.h
中average
的定义在average.c
中被当作外部定义。
C99
中的一般法则是,如果特定文件中某个函数的所有顶层声明中都有inline
但没有extern
,则该函数定义在该文件中是内联的。如果在程序的其他地方使用该函数(包含其内联定义的文件也算在内),则需要在另一个文件中为其提供外部定义。调用函数时,编译器可以选择进行正常调用(使用函数的外部定义)或者执行内联展开(使用函数的内联定义)。我们没有办法知道编译器会怎样选择,所以一定要确保这两处定义一致。刚刚讨论过的方式(使用average.h
和average.c
)可以保证定义的一致性。
因为内联函数的实现方式和一般函数大不一样,所以需要一些不同的规则和限制。对于具有外部链接的内联函数来说,具有静态存储期的变量是一个特别的问题。因此,
C99
对具有外部链接的内联函数(未对具有内部链接的内联函数做约束)做了如下限制。
static
变量。这样的函数可以定义同时为static
和const
的变量,但每个内联定义都需要分别创建该变量的副本。
在
C99
标准之前,一些编译器(包括GCC
)已经可以支持内联函数了。因此,它们使用内联函数的规则可能与C99
标准不一样。特别是前面描述的那种方案(使用average.h
和average.c
文件)在这些编译器中可能无效。
不论GCC
的版本如何,被同时定义为static
和inline
的函数都可以工作得很好。这样做在C99
中也是合法的,所以是最安全的。static inline
函数可以用于单个文件,也可以放在头文件中,然后在需要调用的源文件中包含进去。
还有一种方法可以在多个文件中共享内联函数。这种方法适用于旧版本的GCC
,但与C99
相冲突。具体做法是将函数的定义放入头文件中,指明其为extern
和inline
,然后在任何包含该函数调用的源文件中包含该头文件,并且在其中一个源文件中再次给出该函数的定义(不过这次没有extern
和inline
关键字)。这样即便编译器因为某种原因不能对函数进行“内联”,函数仍然有定义。
关于GCC
,最后需要注意的是,仅当通过-O
命令行选项请求进行优化时,才会对函数进行“内联”。
在
C
语言里,有些函数是不返回的,比如longjmp
、exit
和abort
。从C11
开始引入了一个函数指定符,也就是关键字_Noreturn
,意思是“不返回”。如果在一个函数的声明里有这个函数指定符,则意味着它不返回到调用者。
C11
新增了一个头
,它很简单,只有一个宏noreturn
,被扩展为_Noreturn
。如果在程序中包含了这个头,则可以直接使用noreturn
来代替_Noreturn
。
函数
assert
在程序运行期间做诊断工作,从C11
开始引入的静态断言_Static_assert
可以把检查和诊断工作放在程序编译期间进行。
_Static_assert(常量表达式, 字面串);
在这里,_Static_assert
是C11
新增的关键字。“常量表达式”必须是一个整型常量表达式。如果它的值不为0
,则没有什么效果;如果值为0
,则违反约束条件,并且C
实现应当产生一条诊断信息,在这条信息里应当包含“字面串”的内容,除非字面串的内容不是用基本源字符集
编码的。
C
标准规定unsigned int
类型可表示的数值范围至少是-32767~32767
,当然绝大多数平台支持比这个规定大得多的范围。为了保险起见,下面这个小程序要求unsigned int
能够表示超出上述范围的数值,所以用静态断言来决定是否允许继续编译。
# include
int main(void)
{
_Static_assert(UINT_MAX >= 32767, "Not support this platform.");
// 其他代码
return 0;
}
基本源字符集
:C
语言使用的字符集包括基本源字符集和扩展字符集,前者包括26
个(大小写)英文字母、数字以及标点符号等;后者由你所在地区的文字符号组成。
如果unsigned int
的最大值大于32767
,那么这个常量表达式的值为1
,这个静态断言什么也不做;否则编译不能继续进行,并显示第5
行出现错误,错误的原因是静态断言失败。在C11
中,静态断言是作为声明出现的。
在引入静态断言之前,我们通常是在预处理阶段用#if
和#error
等预处理指令做一些诊断工作,但是预处理器并不认识C
的语法元素,这就限制了它的功能和应用范围,而引入静态断言则可以解决这个问题。
问1:从
C99
开始,为什么把选择语句和重复语句(以及它们的“内部”语句)视为块?
答:这条奇怪的规则源于把复合字面量(9.3节、16.2节)
用于选择语句和重复语句时出现的一个问题。该问题与复合字面量的存储期有关,所以我们先花点时间讨论一下这个问题。
C99
及之后的标准指出,如果复合字面量出现在函数体之外,那么复合字面量所表示的对象具有静态存储期。否则,它具有自动存储期,因而对象所占有的内存会在复合字面量所在块的末尾释放。考虑下面的函数,该函数返回使用复合字面量创建的point
结构:
struct point create_point(int x, int y)
{
return (struct point) {x, y};
}
这个函数可以正确地工作,因为复合字面量创建的对象会在函数返回时被复制。原始的对象将不复存在,但副本会保留。现在假设我们对函数进行微小的改动:
struct point *create_point(int x, int y)
{
return &(struct point) {x, y};
}
这一版本的create_point
函数会导致未定义的行为,因为它返回的指针所指向的对象具有自动存储期,函数返回后该对象就不复存在。
现在回到开始时提到的问题:为什么把选择语句和重复语句视为块
?考虑下面的示例1
:
/* Example 1 - if statement without braces */
double *coefficients, value;
if (polynomial_selected == 1)
coefficients = (double[3]) {1.5, -3.0, 6.0};
else
coefficients = (double[3]) {4.5, 1.0, -3.5};
value = evaluate_polynomial(coefficients);
这个程序片段显然能按需要的方式工作(但是请继续阅读)。coefficients
将指向由复合字面量创建的两个对象之一,并且该对象在调用evaluate_polynomial
时仍然存在。现在考虑一下示例2
,如果在内部语句(if
语句控制的语句)两边加上花括号
,会有什么不同:
/* Example 2 - if statement with braces */
double *coefficients, value;
if (polynomial_selected == 1) {
coefficients = (double[3]) {1.5, -3.0, 6.0};
} else {
coefficients = (double[3]) {4.5, 1.0, -3.5};
}
value = evaluate_polynomial(coefficients);
现在我们遇到问题了。每个复合字面量会创建一个对象,但该对象只存在于包含相应语句的花括号所形成的块内。调用evaluate_polynomial
时,coefficients
指向一个不存在的对象,从而导致未定义的行为。
C99
的创立者对这种现象很不满意,因为程序员不可能预料到,在if
语句中简单地增加花括号就会导致未定义的行为。为了避免这一问题,他们决定始终把内部语句视为块。这样一来,示例1
和示例2
就等价了,都会导致未定义的行为。
当复合字面量是选择语句或重复语句的控制表达式的一部分时,类似的问题也会发生。因此,我们把整个选择语句和重复语句也都看作块(就好像有一对不可见的花括号包裹在整个语句外面一样)。因此,带有else
子句的if
语句包含三个块:两个内部语句分别是一个块,整个if
语句又是一个块。
问2:你曾说过,具有自动存储期的变量在所在块开始执行时分配内存空间。这对于
C99
及之后的变长数组是否也成立?
答:不成立。变长数组的空间不会在所在块开始执行时就分配,因为那时候还不知道数组的长度。事实上,在块的执行到达变长数组声明时才会为其分配空间。从这一方面说,变长数组不同于其他所有的自动变量。
问3:
“作用域”
和“链接”
之间的区别到底是什么?
答:作用域是为编译器服务的,链接是为链接器服务的。编译器用标识符的作用域来确定在文件的给定位置访问标识符是否合法。当编译器把源文件翻译成目标代码时,它会注意到具有外部链接的名字,并最终把这些名字存储到目标文件内的一个表中。因此,链接器可以访问到具有外部链接的名字,而内部链接的名字或无链接的名字对链接器而言是不可见的。
问4:我无法理解一个名字具有块作用域但又有着外部链接。可否详细解释一下?
答:当然可以。假设某个源文件定义了变量i
:
int i
假设变量i
的定义放在了任意函数之外,所以默认情况下它具有外部链接。在另一个文件中,有一个函数f
需要访问变量i
,所以f
的函数体把i
声明为extern
:
void f(void)
{
extern int i;
...
}
在第一个文件中,变量i
具有文件作用域。但在函数f
内,i
具有块作用域。如果除函数f
以外的其他函数需要访问变量i
,那么它们将需要单独声明i
。(或者简单地把变量i
的声明移到函数f
外,从而使其具有文件作用域。)在整个过程中会混淆的就是,每次声明或定义i
都会建立不同的作用域,有时是文件作用域,有时是块作用域。
问5:为什么不能把
const
对象用在常量表达式中呢?“constant”
不就是常量吗?
答:在C
语言中,const
表示“只读”而不是“常量”。下面用几个例子说明为什么const
对象不能用于常量表达式。
首先,const
对象只在它的生命期内为常量,而不是在程序的整个执行期内。假设在函数体内声明了一个const
对象:
void f(int n)
{
const int m = n / 2;
...
}
当调用函数f
时,m
将被初始化为n/2
,m
的值在函数f
返回之前都保持不变。当再次调用函数f
时,m
可能会得到不同的值。这就是问题出现的地方。假设m
出现在switch
语句中:
void f(int n)
{
const int m = n / 2;
...
switch (...) {
...
case m: ... /*** WRONG ***/
...
}
...
}
那么直到函数f
调用之前m
的值都是未知的,这违反了C
语言的规则——分支标号的值必须是常量表达式。
接下来看看声明在块外部的const
对象。这些对象具有外部链接,并且可以在文件之间共享。如果C
语言允许在常量表达式中使用const
对象,就很容易遇到下列情况:
extern const int n;
int a[n]; /*** WRONG ***/
n
可能在其他文件中定义,这使编译器无法确定数组a
的长度。(假设a
是外部变量,所以它不可能是变长数组。)
如果这样还不能让你信服,考虑下面的情况:如果一个const
对象也用volatile
类型限定符(20.3节
)声明,它的值可能在程序执行过程中的任何时间发生改变。下面是C
标准中的一个例子:
extern const volatile int real_time_clock;
程序可能不会改变变量real_time_clock
的值(因为其声明为const
),但可以通过其他的某种机制修改它的值(因其被声明为volatile
)。
问6:为什么声明符的语法如此古怪?
答:声明试图进行模拟使用。指针声明符的格式为*p
,这种格式和稍后将用于p
的间接寻址运算符方式相匹配。数组声明符的格式为a[...]
,这种格式和数组稍后的取下标方式相匹配。函数声明符的格式为f(...)
,这种格式和函数调用的语法相匹配。这种推理甚至可以扩展到最复杂的声明符上。请思考一下17.7节
中的数组file_cmd
,此数组的元素都是指向函数的指针。数组file_cmd
的声明符格式为
(*file_cmd[])(void)
而这些函数的调用格式为
(*file_cmd[n])();
其中圆括号、方括号和*
的位置都一样。
本文是博主阅读《C语言程序设计:现代方法(第2版·修订版)》时所作笔记,日后会持续更新后续章节笔记。欢迎各位大佬阅读学习,如有疑问请及时联系指正,希望对各位有所帮助,Thank you very much!