C现代方法(第18章)笔记——声明

第18章 声明

——定义变量很容易,但要时时刻刻控制它很难

声明在C语言编程中起着核心的作用。通过声明变量和函数,可以在两方面为编译器提供至关重要的信息:检查程序潜在的错误,以及把程序翻译成目标代码。

前面几章已经提供了声明的示例,但是没有完整地描述,本章将弥补这个缺憾。本章会探讨可以用于声明的复杂选项,并且显示变量声明和函数声明之间的几个共同点。此外,本章还为存储、作用域以及链接这些重要概念提供了坚实的基础。

18.1节介绍声明的一般语法。接下来的4节将集中讨论声明中出现的数据项:存储类型(18.2节)类型限定符(18.3节)声明符(18.4节)初始化器(18.5节)18.6节讨论了inline关键字,它可以用在C99函数声明中。


18.1 声明的语法

声明为编译器提供有关标识符含义的信息。当编写

int i;

时,是在告诉编译器:名字i表示当前作用域内数据类型为int的变量。声明

float f(float);

则是在告诉编译器:f是一个返回值为float型的函数,并且此函数有一个实际参数,此参数类型也为float型。

一般地,声明具有下列形式:

声明指定符 声明符;

声明指定符(declaration specifier)描述声明的变量或函数的性质。声明符(declarator)给出了它们的名字,并且可以提供关于其性质的额外信息。

声明指定符分为以下3类:

  • 存储类型。存储类型一共有4种:autostaticexternregister。在声明中最多可以出现一种存储类型。如果存储类型存在,则必须把它放置在最前面
  • 类型限定符C89只有两种类型限定符:constvolatile。从C99开始还有一个限定符restrict;从C11开始又新增了一个原子类型限定符_Atomic声明可以包含零个或多个限定符
  • 类型指定符。关键字voidcharshortintlongfloatdoublesignedunsigned都是类型指定符。这些单词可以组合使用,如第7章所述。这些单词出现顺序并不重要(int unsigned longlong 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;
存储类型类型指定符声明符
staticfloatx, y, *p

下列声明有类型限定符但是没有存储类型。此外,它还有初始化器:

const char month[] = "January";
类型限定符类型指定符声明符初始化器
constcharmonth[]“January”

下列声明既有存储类型也有类型限定符。此外,它还有3个类型指定符,当然它们的顺序并不重要:

extern const unsigned long int a[10];
存储类型类型限定符类型指定符声明符
externconstunsigned long inta[10]

和变量声明一样,函数声明也有存储类型、类型限定符和类型指定符。下列声明具有存储类型和类型指定符:

extern int square(int);
存储类型类型指定符声明符
externintsquare(int)

下面4节将详细介绍存储类型、类型限定符、声明符和初始化器。


18.2 存储类型

存储类型可以用于变量以及较小范围的函数和形式参数的说明。现在集中讨论变量的存储类型。

回顾一下10.3节的内容,术语块(block)表示函数体或者复合语句(可能包含声明)。从C99开始, 选择语句(if和switch)循环语句(while、do和for)以及它们所控制的“内部”语句也被视为,尽管本质上有一些差别。


18.2.1 变量的性质

C程序中的每个变量都具有以下3个性质:

  • 存储期。变量的存储期决定了为变量预留的内存被释放的时间。具有自动存储期的变量在所属块被执行时获得内存单元,并在块终止时释放内存单元,从而会导致变量失去值。具有静态存储期的变量在程序运行期间占有同一个存储单元,也就允许变量无限期地保留它所占用的空间。
  • 作用域。变量的作用域其实是变量名字的作用范围,是指可以通过名字引用变量的那部分程序文本。变量可以有块作用域(变量的名字从声明的地方一直到所在块的末尾都是可见的)或者文件作用域(变量的名字从声明的地方一直到所在文件的末尾都是可见的)。
  • 链接。实际上是指变量名字的链接属性,它确定了程序的不同部分可以通过变量名字共享此变量的范围。通过具有外部链接属性的名字,变量可以被程序中的几个(或许全部)文件共享。如果名字具有内部链接属性,变量只能属于单独一个文件,但是此文件中的函数可以共享这个变量。(如果具有相同名字的变量出现在另一个文件中,那么系统会把它作为不同的变量来处理。)名字属于无链接的变量属于单独一个函数,而且根本不能被共享。

默认的存储期、作用域和链接都依赖于变量声明的位置:

  • 在块(包括函数体)内部声明的变量通常具有自动存储期,它的名字具有块作用域,并且无链接。
  • 在程序的最外层(任意块外部)声明的变量具有静态存储期,它的名字具有文件作用域和外部链接。

下面的例子说明了变量i和变量j的默认性质:

int i; //静态存储期、文件作用域、外部链接

void f(void){
    int j;  //自动存储期、块作用域、无链接
}

对许多变量而言,默认的存储期、作用域和链接是符合要求的。当这些性质无法满足要求时,可以通过指定明确的存储类型(auto、static、extern和register)来改变变量的性质。


18.2.2 auto存储类型

auto存储类型只对属于块的变量有效。auto变量具有自动存储期(无须惊讶),它的名字具有块作用域,并且无链接。auto存储类型几乎从来不用显式地指明,因为对于在块内部声明的变量,它是默认的


18.2.3 static存储类型

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函数的速度。

18.2.4 extern存储类型

extern存储类型使几个源文件可以共享同一个变量。15.2节介绍了使用extern的基本概念,所以这里的讨论不会太多。回顾讲过的内容可以知道,下列声明给编译器提供的信息是,iint型变量:

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,那么它的名字具有内部链接;否则(通常情况下),具有外部链接。


18.2.5 register存储类型

声明变量具有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关键字与C99restrict关键字相关。


18.2.6 函数的存储类型

和变量声明一样,函数声明(和定义)也可以包括存储类型,但是选项只有 externstatic。在函数声明开始处的单词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


18.2.7 小结

目前已经介绍了各种存储类型,现在对已知内容进行一个总结。下面的代码片段说明了变量和形式参数声明中包含或者省略存储类型的所有可能的方法:

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自动

无法确定: 因为这里没有显示出变量bj的定义,所以无法确定它们的链接。在大多数情况下,变量会定义在另一个文件中,并且具有外部链接。

在这4种存储类型之中,最重要的是externstaticauto没有任何效果,而现代编译器已经使register变得不如以前重要了。从C11开始增加了_Thread_local存储类型和线程存储期,第28章中再来详细介绍。


18.3 类型限定符

早先在C语言中一共有两种类型限定符:constvolatile。[C99引入了第三种类型限定符,即restrict,它只用于指针受限指针17.8节);C11又引入了第4种类型限定符,即_Atomic,可用于除数组和函数之外的类型,将在第28章中介绍]。因为volatile只用在底层编程中,所以本书将对此限定符的讨论推迟到20.3节const用来声明一些类似变量的对象,但这些变量是“只读”的。程序可以访问const型对象的值,但是无法改变它的值。例如,下面这个声明创建了名为nconst型对象,且此对象的值为10

const int n = 10;

而下列声明产生了名为tax_bracketsconst型数组:

const int tax_brackets[] = {750, 2250, 3750, 5250, 7000};

把对象声明为const有以下几个好处:

  • const是文档格式:声明对象是const类型可以提示任何阅读程序的人,该对象的值不会改变。
  • 编译器可以检查程序没有特意地试图改变该对象的值。
  • 当为特定类型的应用(特别是嵌入式系统)编写程序时,编译器可以用单词const来识别需要存储到ROM(只读存储器)中的数据。

乍一看,const好像与前面章节中用于创建常量名的#define指令一样。然而,实际上#defineconst之间有明显的差异:

  • 可以用#define指令为数值、字符或字符串常量创建名字。const可用于产生任何类型的只读对象,包括数组、指针、结构和联合。

  • const对象遵循与变量相同的作用域规则,而用#define创建的常量不受这些规则的限制。特别是不能用#define创建具有块作用域的常量。

  • 和宏的值不同,const对象的值可以在调试器中看到。

  • 不同于宏,const对象不可以用于常量表达式。例如,因为数组边界必须是常量表达式,所以不能写成下列形式:

    const int n = 10; 
    int a[n];             /*** WRONG ***/
    

    (在C99中,如果a具有自动存储期,那么这个例子是合法的——它会被视为变长数组;但是如果a具有静态存储期,那么这个例子是不合法的。)

  • const对象应用取地址运算符(&)是合法的,因为它有地址。宏没有地址。

没有绝对的原则说明何时使用#define以及何时使用const这里建议对表示数或字符的常量使用#define。这样就可以把这些常量作为数组维数,并且在switch语句或其他要求常量表达式的地方使用它们。


18.4 声明符

声明符不等于标识符

声明符包含标识符(声明的变量或函数的名字),标识符的前边可能有符号*,后边可能有[]()。通过把*[]()组合在一起,可以创建复杂声明符

在了解较为复杂的声明符之前,先来复习一下前面讲过的声明符的知识。在最简单的情况下,声明符就是标识符,就如同下面例子中的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();
    

    最后这组声明指明了absswapfind_largest的返回类型,但是没有提供有关它们的实际参数的信息。圆括号内置为空不等同于把单词void放置在圆括号内,后者说明没有实际参数。圆括号内为空的这种函数声明风格正在迅速消失。它比C89的原型形式差,因为它不允许编译器检查函数调用是否有正确的实际参数。

如果所有的声明符都这样简单,那么C语言的编程将一蹴而就。可惜的是,实际程序中的声明符往往组合了符号*[]()。我们已经见过这类组合的示例了。我们知道下列语句声明了一个数组,此数组的元素是10个指向整数的指针:

int *ap[10];

我们还知道下列语句声明了一个函数,此函数有一个float型的实际参数,并且返回指向float型值的指针:

float *fp(float);

此外,我们在17.7节学过下面这条语句,它用来声明一个指向函数的指针,此函数有int型实际参数和void型返回值:

void (*pf)(int);

18.4.1 解释复杂声明

到目前为止,我们在声明符的理解方面还没有遇到太多的麻烦。但是,下面这个声明符是什么意思呢?

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节有一个这样的数组示例。)


18.4.2 使用类型定义来简化声明

一些程序员利用类型定义来简化复杂的声明。考虑一下前面检查过的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_arrayFcn_ptr值的数组,Fcn_ptr是指向Fcn类型的指针,而Fcn是不带实际参数且返回指向int型值的指针的函数。


18.5 初始化器

为了方便,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; 
    

    因为LASTFIRST都是宏,所以编译器可以计算出i的初始值(100-1+1=100)。如果LASTFIRST是变量,那么初始化器就是非法的。

  • 如果变量具有自动存储期,那么它的初始化器不必是常量:

    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结构类型的函数。


18.5.1 未初始化的变量

前面的章节中已经暗示,未初始化变量有未定义的值,但并不总是这样的,变量的初始化值依赖于变量的存储期。

  • 具有自动存储期的变量没有默认的初始值。不能预测自动变量的初始值,而且每次变量变为有效时,值可能不同。
  • 具有静态存储期的变量默认情况下的值为0。用calloc分配的内存是简单地给字节的位设为0,而静态变量不同于此,它是基于类型的正确初始化,即整型变量初始化为0,浮点变量初始化为0.0,指针初始化为空指针。

出于书写风格的考虑,最好为静态类型的变量提供初始化器,而不是依赖于它们一定为0的事实。如果程序访问了没有明确初始化的变量,那么以后阅读程序的人可能不容易确定变量是否为0,或者是否在程序中的某处通过赋值初始化。


18.6 内联函数(C99)

C99及之后的函数声明中有一个C89中不存在的选项:可以包含关键字inline。这个关键字是一个全新的声明指定符,不同于存储类型、类型限定符以及类型指定符。为了理解inline的作用,需要把C编译器在调用函数和从函数返回过程中产生的机器指令可视化。

在机器层面,调用函数之前可能需要预先执行一些指令。调用本身需要跳转到函数的第一条指令,函数本身可能也需要执行一些额外的指令来启动执行。如果函数有参数,参数需要被复制(因为C通过值传递参数)。从函数返回也需要被调用的函数和调用函数执行差不多的工作量。调用函数和从函数返回所需的工作量称为“额外开销”,因为我们并没有要求函数执行这些工作。尽管函数调用中的额外开销只是使程序稍许变慢,但在特定的情况下额外开销会产生累积效应。例如,在函数需要调用数百万次或数十亿次,使用老式的比较慢的处理器(例如在嵌套系统中),或者有着非常严格的时限要求(例如在实时系统中)时。

C89中,避免函数额外开销的唯一方式是使用带参数的宏(14.3节)。但带参数的宏也有一些缺点。C99提供了一种更好的解决方案:创建内联函数(inline function)“内联”表明编译器把函数的每一次调用都用函数的机器指令来代替。这种方法虽然会使被编译程序的大小增加一些,但可以避免函数调用的常见额外开销。

不过,把函数声明为inline并不是强制编译器将代码内联编译,而只是建议编译器应该使函数调用尽可能地快,也许在函数调用时才执行内联展开。编译器可以忽略这一建议。从这方面来说,inline类似于registerrestrict关键字,后两者也是用于提升程序性能的,但可以忽略。


18.6.1 内联定义

内联函数用关键字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.haverage的定义在average.c中被当作外部定义

C99中的一般法则是,如果特定文件中某个函数的所有顶层声明中都有inline但没有extern,则该函数定义在该文件中是内联的。如果在程序的其他地方使用该函数(包含其内联定义的文件也算在内),则需要在另一个文件中为其提供外部定义。调用函数时,编译器可以选择进行正常调用(使用函数的外部定义)或者执行内联展开(使用函数的内联定义)。我们没有办法知道编译器会怎样选择,所以一定要确保这两处定义一致。刚刚讨论过的方式(使用average.haverage.c)可以保证定义的一致性


18.6.2 对内联函数的限制

因为内联函数的实现方式和一般函数大不一样,所以需要一些不同的规则和限制。对于具有外部链接的内联函数来说,具有静态存储期的变量是一个特别的问题。因此,C99对具有外部链接的内联函数(未对具有内部链接的内联函数做约束)做了如下限制。

  • 函数中不能定义可改变的static变量。
  • 函数中不能引用具有内部链接的变量。

这样的函数可以定义同时为staticconst的变量,但每个内联定义都需要分别创建该变量的副本。


18.6.3 在GCC中使用内联函数

C99标准之前,一些编译器(包括GCC)已经可以支持内联函数了。因此,它们使用内联函数的规则可能与C99标准不一样。特别是前面描述的那种方案(使用average.haverage.c文件)在这些编译器中可能无效

不论GCC的版本如何,被同时定义为staticinline的函数都可以工作得很好。这样做在C99中也是合法的,所以是最安全的。static inline函数可以用于单个文件,也可以放在头文件中,然后在需要调用的源文件中包含进去。

还有一种方法可以在多个文件中共享内联函数。这种方法适用于旧版本的GCC,但与C99相冲突。具体做法是将函数的定义放入头文件中,指明其为externinline,然后在任何包含该函数调用的源文件中包含该头文件,并且在其中一个源文件中再次给出该函数的定义(不过这次没有externinline关键字)。这样即便编译器因为某种原因不能对函数进行“内联”,函数仍然有定义。

关于GCC,最后需要注意的是,仅当通过-O命令行选项请求进行优化时,才会对函数进行“内联”。


18.7 函数指定符_Noreturn和头<stdnoreturn.h>(C1X)

C语言里,有些函数是不返回的,比如longjmpexitabort。从C11开始引入了一个函数指定符,也就是关键字_Noreturn,意思是“不返回”。如果在一个函数的声明里有这个函数指定符,则意味着它不返回到调用者

C11新增了一个头<stdnoreturn.h>,它很简单,只有一个宏noreturn,被扩展为_Noreturn。如果在程序中包含了这个头,则可以直接使用noreturn来代替_Noreturn


18.8 静态断言(C1X)

函数assert在程序运行期间做诊断工作,从C11开始引入的静态断言_Static_assert可以把检查和诊断工作放在程序编译期间进行

_Static_assert(常量表达式, 字面串);

在这里,_Static_assertC11新增的关键字。“常量表达式”必须是一个整型常量表达式。如果它的值不为0,则没有什么效果;如果值为0,则违反约束条件,并且C实现应当产生一条诊断信息,在这条信息里应当包含“字面串”的内容,除非字面串的内容不是用基本源字符集编码的。

C标准规定unsigned int类型可表示的数值范围至少是-32767~32767,当然绝大多数平台支持比这个规定大得多的范围。为了保险起见,下面这个小程序要求unsigned int能够表示超出上述范围的数值,所以用静态断言来决定是否允许继续编译。

# include <limits.h> 

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/2m的值在函数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!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

New_Teen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值