[隐藏]

一、简介

在C语言中,要使用变量必须先对其进行声明,声明为编译器提供标识符的含义信息,声明的基本形式:

声明说明符  声明符列表;

声明说明符(declaration specifier)描述声明的变量或函数的性质。声明符(declarator)列表就是要声明的标识符的列表,对于复杂的类型,声明符列表中的每个条目实际上都是一个表达式,说明被声明的名字的用途。

比如:

int flag;

它声明的变量flag是一个整数。也可以同时声明多个变量:

char ch, k;

在声明的同时也可以进行初始化:

int flag = 0;

声明说明符分为4大类:存储类型说明符(storage-class-specifier),类型限定符(type-qualifier),类型说明符(type-specifier),函数说明符(function-specifier)。其中,类型限定符和类型说明符必须紧跟在存储类型说明符的后面。但它们两者的顺序没有限制。

C语言中的类型说明符有以下几种,在一个声明中至少包含其中的一个:

void
char
short
int
long
float
double
signed
unsigned
_Bool
_Complex
_Imaginary
struct-or-union-specifier
enum-specifier
typedef-name

声明符包含标识符(变量或函数的名字),它的前面可以有符号*,说明声明符是一个指针;后面可以有[]或(),分别表明声明符是数组或函数:

int i;//i是一个整数
int *p;//p是一个指向一个整数的指针
int a[10];//a是一个数组,它有10个int型的元素
int abs(int i);//abs是一个函数,它的参数是一个整数,它返回一个int型整数

二、存储类型(storage-class)

C语言中的存储类型说明符(storage-class-specifier)有以下几种:

typedef
extern
static
auto
register

之所以把 typedef 归于存储类型,只是为了语法方便,这里不再讨论。

这些说明符涉及到变量的3个性质:作用域(scope.)链接(linkage)存储期限(storage  duration)

作用域

变量的作用域指明变量对哪部分程序是可见的(visible,即,可使用)。相同的名字可以指定不同的实体,只要它们的作用域或名字空间(name spaces)不同。

名字空间

在一个编译单元中,如果出现两个或两个以上的相同名字的标识符,对某一段程序都可见,它们可以通过名字空间分开而不产生混淆,C语言有4种名字空间:

  1. 标签名(label name)。用于goto语句的标签
  2. 标记名(tag)。struct,union,enum声明的标记
  3. 成员名(member)。结构体、联合内的成员名
  4. 普通标识符(ordinary identifier)。所有其他的标识符

从这里也可以看出,枚举常量标识符是属于普通标识符的,所以它不能和同一作用域内其他变量标识符相同

C语言中有4中作用域:函数作用域(function),文件作用域 (file),块作用域(block)和函数原型作用域(function prototype)。

函数原型作用域即在声明函数时形参(parameter)的作用域,它只在函数原型中可见。

具有文件作用域的变量从它声明的地方到文件结尾都是可见的,需要指出的是,用#include引入的文件中的具有文件作用域的声明并不结束于它所在的文件的结尾。

具有块作用域的变量只在它声明的地方到块结尾可见(术语块(block)表示函数体、复合语句、选择语句、循环语句以及它们所控制的内部语句)。

在函数体内声明的变量具有函数作用域。

如果两个相同名字的标识符出现在同一作用域,并且属于相同名字空间,即出现作用域的重叠(overlap),那么处于外部作用域(比较大的作用域)的标识符将被隐藏:

上面的程序不会出现任何问题。比如i = ‘C’是给声明5的i赋值,i = 0是给声明4的i赋值,i = 2是给声明1的i赋值。

链接

同一作用域相同名字标识符的声明可以指示不同的实体,也可以指示相同的实体,这就要利用链接的概念。变量的链接确定了可以共享此变量的范围。

具有外部链接(external linkage)的变量可以被程序中的多个文件共享。具有内部链接(internal linkage)的变量只能被它所在文件的函数共享。每一个无链接(no linkage)的变量只能指定不同的实体,也就是这一实体不能被共享。

作用域和链接的区别:

作用域是为编译器服务的,链接是为链接器服务的。编译器用标识符的作用域来确定在文件中某一位置访问标识符是否合法。当编译器把源文件翻译成目标代码时,它会把有外部链接的名字存储到目标文件的一个表中。因此,链接器可以访问到具有外部链接的名字,而内部链接或无连接的名字对链接器是不可见的。

存储期限

变量的存储期限决定了它的生命期(何时被分配内存,何时被回收)。C语言共有3种存储期限:静态(static)、自动(automatic)、分配(allocated)。

分配存储是指使用malloc等函数动态分配内存的变量,这里不做讨论。

具有自动存储期限的变量在所在块被执行时获得内存,在块结束时被释放。具有静态存储期限的变量在整个程序中一直存在。

变量默认的性质与它声明的位置有关:

auto存储类型

auto存储类型只对块内部变量有效,auto变量和在块内部声明变量的默认的性质相同,所以auto说明符是从来都不需要的。

static存储类型

static说明符可以作用于任何变量,它的效果同样和变量声明的位置有关:

在作用于块外部的声明时,static使变量具有内部链接,从而它只在它的文件中可见。它的这种特性常常用于避免命名冲突和信息隐藏。

两个相同名称的具有外部链接的变量实际上代表同一实体,即使我们的本意并非如此。如果要避免这种冲突,可以给变量加上static修饰符。

static也可修饰函数:

那么函数 f 就只能被这个文件内的其他函数调用。

在作用于块内的声明时,static使变量具有静态存储期限,从而使这个变量在整个程序运行期都会存在,即使程序离开它所在块,它的内存也不会被释放。

我们知道,每一个对象,系统只要给它分配了内存,它的地址就不会再改变。

auto变量和static变量的区别在于,程序每次进入一个块时,auto变量都会被重新初始化,这样它的地址就有可能和上次的位置不同,而static变量在程序开始之前就会被初始化,并且在程序进入这个块时不会被重新初始化,从而在整个程序运行期占据一个不变的位置。也正因为如此,当函数被递归调用时,每次它的auto变量的实体都是不同的,而static变量指定同一实体,从而实现共享,某些时候利用这一特性可以提高性能:

另一方面,不应该在函数内返回指向auto变量的指针,返回指向static变量的指针却是可以的。

extern存储类型

extern关键字用于改变变量的链接属性,extern声明的变量具有静态存储期限:

extern型变量的链接并不像static那么简单。先前提到过,作用域会出现重叠的情况,作用域较大的变量会被隐藏。但是如果在extern型之前出现过这个变量的声明,那么后面的声明不会改变它的链接属性,除非先前的声明是无连接的。

具有块作用域却有外部链接的标识符:

假设在一个文件中所有函数之外定义了一个具有外部链接的变量i:

int i;

在另一个文件中的一个函数需要访问这个变量:

那么在这个函数中i就有块作用域,如果它所在的文件的其他函数也需要访问i,它们就需要单独声明i。

register存储类型

声明register类型的变量就是建议编译器把变量存储在寄存器中,但这只是建议(suggestion),编译器完全可以忽略。

register存储类型只对声明在块内的变量有效。register变量具有和auto变量一样的存储期限、作用域和链接。但是,因为寄存器没有地址,所以不能对其进行取地址运算,即使编译器选择把变量存在内存中。

通常我们把需要频繁访问和更新的变量或指针声明为register:

现在许多编译器可以自己确定那些变量保存在寄存器中最好,使用register声明可能反而降低执行效率。

三、类型限定符(Type qualifier)

类型限定符有:

const

restrict

volatile

const

const限定符用来声明常量(constant),它们的值不允许修改:

const int i;

这样 i 的值就不允许修改。当然,类型限定符和类型说明符的顺序无关紧要,所以,也可以这样声明:

int const i;

但是,由于 i 的值不允许修改,所以,如果它具有的是自动存储期限,那么它的值就是垃圾(不确定)。我们需要在声明的时候进行初始化:

const int i = 10;

如果用const来修饰函数的形参,那么在函数调用时会得到实参的值,并且它的值在函数中不会被改变。

const也可用于函数的返回值。

最被人津津乐道的就是const作用于指针了:

int *p; //p是一个普通的指向整型的指针

int const *p;//p是一个指向整型常量的指针,可以修改指针的值,但不能修改它指向的值

int * const p;//p是一个指向整型的常量指针,可以修改它指向的值,但不能修改指针的值

int const * const p;//p是一个指向整型常量的常量指针,指针本身和它指向的值都不能修改

但是,用const声明的常量与#define定义的常量不同:

  1. const常量并不是“真正的常量”,准确来说是“只读”,所以不能用来声明的数组的长度;
  2. const常量具有数据类型,编译器可以对其进行安全检查,它的值也可以在调试器中看到;
  3. const可用于产生只读的数组、指针、结构、联合,而#define只能用于数值、字符、字符串;
  4. const不能用于常量表达式。

restrict

restrict只能用于限定指针,这种指针称为受限指针(restricted pointer)。

如果受限指针p所指向的对象需要修改,那么该对象不允许通过除指针p之外的任何方式访问。

例如,具有文件作用域的声明:

如果有一个对象被通过a、b、c中的任意一个访问并且修改,那么这个对象将不能被其他两个访问。

restrict也可用于函数形参:

这样,在函数执行期间,p和q所指向的对象将不能被其他任何形式访问。

memcpy在C99中的原型为:

void *memcpy(void * restrict s1, const void * restrict s2, size_t n);

它从一个对象向另一个对象复制字节。s2指向待复制的数据,s1指向目的地,n是要复制的字节数。s1和s2都使用restrict,说明它们的地址不应重叠。

而memmove的原型是:

void *memmove(void * s1, const void * s2, size_t n);

它做的工作和memcpy相同,只是,当s1和s2重叠时复制依然会进行。

restrict只是给编译器提供信息使之忽略对函数调用的检查而产生更有效的代码,所以,程序员要确保对这类函数的调用都是合法的。例如,下面的函数调用上面的函数 f :

但是,对一个没有被修改过的对象进行别名访问却是可以的。如果一个对象有多种访问方式,这些方式互为别名。例如:

特别的,如果数组a和b不相交,那么 h(100, a, b, b); 是被允许的,因为在函数h中b没有改变。

由受限指针创建别名也是合法的。同时,对受限指针的赋值限制并不区分函数调用和同等意义的嵌套块。但是,有一个例外,“由外向内”的赋值是允许的:

restrict和register类型,它对编译器的优化建议编译器完全可以忽略。即使编译器不进行优化,或程序员禁用了优化,也不会对程序的结果产生任何影响。

volatile

用volatile修饰的变量表示可以被某些编译器未知的因素更改,比如操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

假设指针p指向的内存空间用于存放用户通过键盘输入的最近的一个字符,这个内存的值是易变的。我们通过一个循环来获取这个内存的值并存在缓冲数组中:

编译器可能发现在循环即没有改变p,也没有改变*p,因此编译器对程序进行优化:

这并不是我们想要的结果,这时使用volatile限定符就能保证*p每次都必须从内存中重新获取。

这些限定符也可以组合起来使用:

extern const volatile int real_time_clock;

它表明外部变量real_time_clock可以被硬件修改,但是不允许对它进行赋值等修改操作。

四、声明和定义

先看一个例子:

extern int n;

这条语句只是变量n的声明而不是定义,它只是告诉编译器n在别处定义(可以稍后出现在同一文件中,但通常在另一文件),但我要在这里使用。变量在程序中可以有多次声明,定义有且只能有一次。

如果是:

int n = 10;

它不仅声明了n,还对其进行了定义。

简单来说,声明所说明的并非本身,而是描述其他地方创建的对象,所以不需要再为其分配内存;而定义为对象分配内存。所以

int a[100];//确定a的类型并分配内存,创建了新对象a

extern int b[];//描述b的类型,没有分配内存,用于指代在其他地方定义的b

如果一个外部变量在不同文件中定义了两次或更多会怎么样?

严格来说,每个外部变量只能定义一次,如果定义了多次并且指定了初始值,那么大多数系统不会接受这个程序。如果这些定义并没有给出初始值,那么有些系统可能会接受,另一些可能仍不接受。所以,避免这一问题的唯一方法就是每个外部变量只定义一次。

如果声明和定义的变量的类型不一致会怎么样?

这个问题比一个变量多次定义有趣的多。假设我们有一个程序由两个文件组成。一个文件中声明了外部变量n:

extern int n;

另一文件定义了n:

long n;

这显然是错误的程序,然而编译器可能并不能检测到这种错误。编译器对这两个文件分别处理,因此在处理一个文件时它并不知道另一个文件的内容。连接器对C语言一无所知,它也不知道程序中存在这种错误。

当程序运行时,如果编译器检测到了错误,它会给出诊断信息。否则将会发生不确定的结果。

特别的,如果一个变量p被声明为:

extern char *p;

而它的定义是:

char p[10];

当用p[i]这种形式提取这个声明的内容时,实际上得到的是一个字符,编译器却把它当成指针,把字符解释成地址显然是错误的。

五、复杂声明

我们已经知道,任何C语言变量的声明都有两部分组成:类型以及一组类似表达式的声明符。

一个像 int i; 这样的声明理解起来当然没有任何困难。但是,下面的声明的意思是什么呢?

char * const *(*next)();

这是一个在《C专家编程(Expert C Programming)》中进行详细讲解了的取自telnet程序的声明。

Peter Van Der Linden采用下面的规则进行了分析:

理解C语言声明的优先级规则

A      声明从它的名字开始读取,然后按照优先级顺序依次读取。

B      优先级从高到低依次是:

0000B.1    声明中被括号括起来的那部分

0000B.2    后缀操作符:

000000000000括号 ( ) 表示这是一个函数;

000000000000方括号 [ ] 表示这是一个数组。

0000B.3     前缀操作符:星号 * 表示  “指向…的指针”。

C      如果const和 (或)volatile关键字的后面紧跟类型说明符(如int,long等),那么它作用于类型说明符。在其他情况下,const和(或)volatile关键字作用于它左边紧邻的指针星号。

下面我们用优先级规则分析 char * const *(*next)();

适用规则 解释
 A  首先,看变量名“next”,并注意到它直接被括号括住
 B.1  所以先把括号里的东西作为一个整体,得出“next是一个指向…的指针”
 B  然后考虑括号外面的东西,在星号前缀和括号后缀之间作选择
 B.2  B.2规则告诉我们优先级较高的是右边的函数括号,所以得出“next是一个函数指针,指向一个返回…的函数”
 B.3  然后,处理前缀“*”,得出指针所指的内容
 C  最后,把“char * const”解释为指向字符的常量指针

概括上面的分析结果,得出这个声明表示“next是一个指针,它指向一个函数,该函数返回另一个指针,该指针指向一个类型为char的常量指针”。

也可以按照下图进行分析:

这本书也提供了一个把C语言的声明翻译成通俗语言的cdecl程序。
这个程序主要的数据结构是一个堆栈。从左到右读取声明语句,把各个标记依次压入堆栈,直到读到标识符为止。然后继续向右读入一个标记,也就是标识符后边的那个标记。接着,观察标识符左边的那个标记(需要从堆栈中弹出)。

还有一个用FSM(有限状态机)实现的版本