《C语言实用之道》之微 妙 之 C

C语言包含的一些特性常常被误解,因而会引发一些问题或者意料之外的结果。本章讨论这些微妙之处。

2.1  变量的作用域和生命周期

变量的作用域(scope)定义了在哪里可以使用该变量;而变量的生命周期(life)则定义了什么时候可以使用该变量。这两个方面不是独立的。它们代表不同的方式,用来说明一个变量如何维护它的有效性。

广义而言,C语言支持两种类型的变量:局部变量和全局变量。

2.1.1  局部变量

局部变量是在一个函数或块语句的内部定义的。可以从它们被定义的那一行开始,直到该函数或块语句的结束花括号为止,使用局部变量。考虑代码清单2-1中展示的小函数(现在它并不精巧,以后会更加精巧)。

代码清单2-1  一个小函数

1. int multi_sum(int n1, int n2, int do_mult) {

2.   int retval = n1;

3.   if (do_mult) retval *= n2;

4.   else retval += n2;

5.   return retval;

6.   }

变量n1、n2、do_mult和retval都是局部变量,但三个形式参数(n1n2do-mult)在该函数内部的任何地方(即,从第2行到第5行)都是有效的,而retval变量只从第3行开始才有效。

为了存储这些动态变量的值而需要的内存,是在该函数被调用的时候,在程序的栈上分配的。然后,当函数返回的时候,栈指针被移回该函数调用之前的位置,这些变量都超出了它们的作用域。也就是说,实际上,它们都不再存在。

当该函数被调用的时候,对于每个参数,都从程序的栈上分配一个变量,对应的参数的值被拷贝到变量中。

例如,如果以如下方式调用该函数:

int n1 = 3;

int result = multi-sum(n1, 5, 1);

那么在函数中,n1局部变量包含3,n2包含5,do_mult包含1。

同样的名称n1既可以在函数外面使用,也可以在函数内部使用,这并不意味着该名称只代表一个变量:这两个3被存储在不同的位置。

这意味着可以重写该函数,如下所示:

int multi_sum(int n1, int n2, int do_mult) {

if (do_mult) n1 *= n2;

else n1 += n2;

return n1;

}

这不会影响函数之外的n1所存储的值。

如果一个函数返回一个指向某个局部变量的指针,那么这可能会引起严重的问题。编译器会检查你是否做了类似的事情,但是只会发出一条警告。例如,如果编译一个如下的函数:

int *ptr(int n) { return &n; }

编译器会发出如下警告:

warning: function returns address of local variable [-Wreturn-local-addr]

但是,可以忽略该警告(尽管你永远不应该忽略任何警告!)。在任何情况下,其实编写出不让编译器发出警告的代码也十分容易(就是不让函数返回的指针指向局部变量):

int *ptr(int n) {

int *p = &n;

return p;

}

如果用下面这样的方式来执行这个有点危险的函数ptr( ):

int main(void) {

int *nn = ptr(7);

printf("%d\n", *nn);

}

程序将会在控制台上打印出7,正如预期的那样。但是,定义一个无足轻重的、什么也不干的如下函数:

void nothing(int n1) { int n2 = n1; }

并且将它放在执行ptr()函数和打印nn的代码之间,如代码清单2-2所示,你将大吃一惊。该程序将会打印出10而不是7,尽管你根本没有去碰nn。

代码清单2-2  一个小程序

int main(void) {

int *nn = ptr(7);

nothing(10);

printf("%d\n", *nn);

}

这是因为,执行这个什么也不做的函数时,又重新使用了nn所指向的动态地址,因此改变了它的内容。

很显然,如果返回函数中任何一个局部变量的地址,而不管该局部变量是否如上例所示是输入参数,都会有同样的问题发生。例如,下面的代码也不工作:

int *ptr(int n) {

int val = n;

return &val;

}

但是,如果将局部变量变成静态的,如下所示:

int *ptr(int n) {

static int val;

val = n;

return &val;

}

那么代码清单2-2中的程序将打印出7。这是因为,将存储类static应用在一个局部变量上,使得编译器到编译时才在程序的数据段中为它分配空间。这样的变量随着程序的生命周期一直存在,因而能够保持住它的值。由于静态变量的存在,使得该函数不是可重入的(在后面的一章中,当讨论到并发性时将进一步讨论重入问题),但是它允许你通过返回其地址的方式来扩展它的作用域。

虽然这样使用静态局部变量的方式不会引发直接的问题,但带来的束缚是:代码更难理解和维护。这不是我愿意推荐的做法。更有甚者,它将允许你在一个函数之外修改该函数的一个局部变量的值。

静态局部变量通常被用于在一个函数的连续两次执行之间保持一个值不变,或者从另一个角度来描述,将一个函数一次执行后得到的值,传递给它的下一次执行。

需要记住的很重要的一点是:虽然编译器并不初始化动态局部变量,但是对于静态局部变量,它会清除它们的值——将数值类型设置为0,将字符类型设置为'\0',将指针设置为NULL。

也就是说,好的实践是:在任何情况下都要初始化所有变量。利用通用的初始化器{0},可以很容易地将任何变量初始化为零,如下所示:

anytype simple_var = {0];

anytype array[SIZE] = {0};

anytype multi_dim_array[SIZE1][SIZE2][SIZE3] = {0};

简而言之,局部变量默认仅在它们被定义的代码块中才有效,并且只有当代码块激活的时候它们才存在。如果它们被定义成静态的,那么它们存在于程序的执行过程中,并且可以在它们被定义的代码块之外访问它们,不过,在实践中这样的访问是不鼓励的。

2. 限制局部变量的作用域

所有的程序员都会犯错。错误被越早检测到,通常修复起来越容易。最好的做法是:应该尽量在编译时或者在程序运行之前,找到尽可能多的错误。其中一种做法是,将局部变量的作用域限制到最小。

这是因为,当在一个大的代码块中针对不同的用途而使用一个变量的时候,很有可能会忘记重新初始化该变量(本该重新初始化)。

针对不同的用途使用不同的变量,可以让你更加恰当地命名每个变量,因此提高代码的可读性和可维护性。

更进一步,如果在本地定义大的数组,它们可能会在程序栈中堆积起来,尽管在桌面系统或笔记本电脑上运行的程序的可用内存在不断增加,但是这些大数组可能会“暴跳起来(hit the ceiling)”,引发程序崩溃。

为了限制一个变量的作用域,你需要做的是,将它的定义和使用它的代码括在由花括号分隔的块语句中:

...

{ // here the block begins

double d_array[N];

...

} // here the block ends and the stack space used up by d_array is recovered

将一段代码用花括号括起来,这种做法也可以鼓励你将代码尽可能靠近它所用到的至少某些变量。

从C99开始,可以在for语句的内部定义for循环的控制变量:

for (int k = 0; k < 5; k++) {

...

}

需要使用-std=c99选项来编译本书所有的代码,因为我总是用到这一特性。也可以将for循环括在一个块语句中,从而限定for循环的控制变量,这样可以用任何版本的C语言,如下面的例子所示:

{

int k;

for (k = 0; k < 5; k++) {

...

}

}

这样做不会让你的代码变慢。

gcc支持C标准的几个版本(关于完整的列表,可以查看gcc.gnu.org/onlinedocs/ gcc/C-Dialect- Options.html),可以使用-std选项来选择C标准的版本。默认情况下,gcc认为C代码遵守ISO 9899标准的1990发行版(因而使用-std=c90选项实际上是不必要的)。ISO C标准最新支持的版本是2011(不过,在写作本书时,还不是100%支持),可以通过编译器的-std=c11选项来选择该版本。C标准的新发行版不仅增加编译器的特性,而且也往往废弃一些以前旧发行版的某些特性。

2.1.2  全局变量

全局变量是指那些定义在函数外面的变量。它们默认都是被导出的,之所以是全局的,是因为它们在程序中的任何地方都可以访问。在程序的整个生命周期中,它们都保持有效。

使用关键字extern通常会导致混淆。为了理解这一关键字,需要理解变量的定义和声明之间的区别。

变量的定义是指:指示编译器为一个变量分配内存。而变量的声明是指:告诉编译器,将要使用一个变量,而该变量已经被定义在其他某个地方。

例如:

int ijk[5] = {0};

上述定义告诉编译器,为一个包含五个整数的数组分配内存,并且为它的第一个位置赋予名称ijk。如果在任何函数的外面定义ijk,那么ijk是静态分配的变量,在整个程序中都可以使用。

如果该程序包含几个模块,其中某一个模块(不是定义ijk的那个模块)需要访问ijk,那么需要在这个模块内声明ijk:

extern int ijk[5];

注意,只能在定义变量的地方对变量进行初始化。虽然可以直接在需要访问变量的模块内部写上变量声明,但是一般推荐的做法是:把声明写在一个头文件中,文件名称类似于定义变量的那个C文件。例如,如果ijk定义在whatever.c文件中,那么可以把声明写在whatever.h中。然后,你所需要做的就是,在需要使用ijk的模块中#include "whatever.h"

所有的全局变量都是静态分配的,默认在整个程序中都可以使用。这使得可以为存储类static赋予不同的含义:可以阻止其他的模块引用一个变量。也就是说,如果在所有函数的外面使用存储类static定义一个变量,那么不能再用extern来引用它。

这一区别非常重要,接下来再用另一种方法来解释,你应该会完全明白:虽然在一个函数内部的变量定义前加上static,可以潜在地将该变量的作用域扩展到整个程序的范围,但是当为一个全局变量加上static时,这限定它的作用域仅在定义该变量的模块范围内。

2.1.3  函数

在C语言中,所有的函数都是全局的,因为不能在一个函数内部定义另一个函数。上一节中提到的关于全局变量的绝大多数内容也都适用于函数。尤其是,静态函数的作用域被限定在定义它们的模块中。

唯一明显的区别是,许多程序员(包括我)在声明函数的时候省略了关键字extern,但是在声明全局变量的时候不会省略。事实上,在声明变量的时候,也可以省略extern关键字,只需要在模块中对变量进行初始化即可(否则,编译器会报告同一个变量被多次定义的错误)。

换句话说,编译器会认为在变量被初始化的地方是定义,所有其他地方是声明。那么,如果变量在任何地方都未被初始化,该怎么办?这种情况下,编译器自行决定哪个是定义。由于编译器为全局变量在数据段中分配内存,严格来讲,它并不真的关心哪里是变量定义,哪里是声明,只要定义和声明能匹配就可以。但是,我发现这种情况多少有些“不让人愉快”(原谅我找不到更好的词来描述)。可能这与我已经编写了相当数量的Java代码有关系。无论如何,尽管其中的差别有些虚幻,但是你可以发现,在我的代码中,所有的全局变量,在与定义它们的源文件对应的头文件中,都会出现关键字extern。而且这样做往往也是多余的,因为它们都被初始化了。

 

节选自《C语言实用之道》第2章    C  1

C语言实用之道》试读电子书,免费提供,有需要的留下邮箱,一有空即发送给大家。邮箱留到微信回复更快(邮箱地址+试读书名),别忘啦顶哦!

微信:qinghuashuyou  

更多最新图书请点击查看哦(即日起-5月31日:关注@qinghuashuyou 发送:关注技术方向,即有机会参与图书抽奖,每周抽取十个幸运读者)



猜你喜欢

转载自blog.csdn.net/qinghuawenkang/article/details/80196580