再论C语言数组
C语言处理数组的方式是它广受欢迎的原因之一。C语言对数组的处理是非常有效的,其原因有以下三点:第一,除少数翻译器出于谨慎会作一些繁琐的规定外,C语言的数组下标是在一个很低的层次上处理的。但这个优点也有一个反作用,即在程序运行时你无法知道一个数组到底有多大,或者一个数组下标是否有效。ANSI/ISOC标准没有对使用越界下标的行为作出定义,因此,一个越界下标有可能导致这样几种后果:
(1) 程序仍能正确运行;
(2) 程序会异常终止或崩溃;
(3) 程序能继续运行,但无法得出正确的结果;
(4) 其它情况。
换句话说,你不知道程序此后会做出什么反应,这会带来很大的麻烦。有些人就是抓住这一点来批评C语言的,认为C语言只不过是一种高级的汇编语言。然而,尽管C程序出错时的表现有些可怕,但谁也不能否认一个经过仔细编写和调试的C程序运行起来是非常快的。
第二,数组和指针能非常和谐地在一起工作。当数组出现在一个表达式中时,它和指向数组中第一个元素的指针是等价的,因此数组和指针几乎可以互换使用。此外,使用指针要比使用数组下标快两倍。
第三,将数组作为参数传递给函数和将指向数组中第一个元素的指针传递给函数是完全等价的。将数组作为参数传递给函数时可以采用值传递和地址传递两种方式,前者需要完整地拷贝初始数组,但比较安全;后者的速度要快得多,但编写程序时要多加小心。C++和ANSIC中都有const关键字,利用它可以使地址传递方式和值传递方式一样安全。
数组和指针之间的这种联系会引起一些混乱,例如以下两种定义是完全相同的:
void f(chara[MAX])
{
/*... */
}
void f(char *a)
{ ·
/*... */
}
注意:MAX是一个编译时可知的值,例如用#define预处理指令定义的值。
这种情况正是前文中提到的第三个优点,也是大多数C程序员所熟知的。这也是唯一一种数组和指针完全相同的情况,在其它情况下,数组和指针并不完全相同。例如,当作如下定义 (可以出现在函数说明以外的任何地方)时:
char a[MAX];
系统将分配MAX个字符的内存空间。当作如下说明时:
char *a;
系统将分配一个字符指针所需的内存空间,可能只能容纳2个或4个字符。如果你在源文件中作如下定义:
char a[MAX];
但在头文件作如下说明;
extern char *a;
就会导致可怕的后果。为了避免出现这种情况,最好的办法是保证上述说明和定义的一致性,例如,如果在源文件中作如下定义:
char a[MAX];
那么在相应的头文件中就作如下说明,
externchar a[];
上述说明告诉头文件a是一个数组,不是一个指针,但它并不指示数组a中有多少个元素,这样说明的类型称为不完整类型。在程序中适当地说明一些不完整类型是很常见的,也是一种很好的编程习惯。是的,对数组a[MAX](MAX是一个编译时可知的值)来说,它的第一个和最后一个元素分别是a[o]和aLMAX-1)。在其它一些语言中,情况可能有所不同,例如在BASIC语言中数组a[MAX]的元素是从a[1]到a[MAX],在Pascal语言中则两种方式都可行。
注意:a[MAX]是一个有效的地址,但该地址中的值并不是数组a的一个元素。
上述这种差别有时会引起混乱,因为当你说“数组中的第一个元素”时,实际上是指“数组中下标为。的元素”,这里的“第一个”的意思和“最后一个”相反。
尽管你可以假造一个下标从1开始的数组,但在实际编程中不应该这样做。下文将介绍这种技巧,并说明为什么不应该这样做的原因。
因为指针和数组几乎是相同的,因此你可以定义一个指针,使它可以象一个数组一样引用另一个数组中的所有元素,但引用时前者的下标是从1开始的:
/*don't do this!!*/
int a0[MAX],
int *a1=a0-1; /*&a0[-1)*/
现在,a0[0]和a1[1)是相同的,而a0[MAX-1]和a1[MAX]是相同的。然而,在实际编程中不应该这样做,其原因有以下两点:
第一,这种方法可能行不通。这种行为是ANSI/ISOC标准所没有定义的(并且是应该避免的),而&a0[-1)完全有可能不是一个有效的地址(见9.3)。对于某些编译程序,你的程序可能根本不会出问题;在有些情况下,对于任何编译程序,你的程序可能都不会出问题;但是,谁能保证你的程序永远不会出问题呢?
第二,这种方式背离了C语言的常规风格。人们已经习惯了C语言中数组下标的工作方式,如果你的程序使用了另外一种方式,别人就很难读懂你的程序,而经过一段时间以后,连你自己都可能很难读懂这个程序了。
你可以使用数组后面第一个元素的地址,但你不可以查看该地址中的值。对大多数编译程序来说,如果你写如下语句:
int i,a[MAX],j;
那么i和j都有可能存放在数组a最后一个元素后面的地址中。为了判断跟在数组a后面的是i还是j,你可以把i或j的地址和数组a后面第一个元素的地址进行比较,即判断"&i==&a[MAX]"或"&j==&a[MAX]"是否为真。这种方法通常可行,但不能保证。
问题的关键是:如果你将某些数据存入a[MAX]中,往往就会破坏原来紧跟在数组a后面的数据。即使查看a[MAX]的值也是应该避免的,尽管这样做一般不会引出什么问题。
为什么在C程序中有时要用到&a[MAX]呢?因为很多C程序员习惯通过指针遍历一个数组中的所有元素,即用
for(i=0;i<MAX;++i)
{
/*do something*/
}
代替
for(p=a; p<&a[MAX];++p)
{
/*do something*/
}
这种方式在已有的C程序中是随处可见的,因此ANSIC标准规定这种方式是可行的。
如果你的程序是在理想的计算机上运行,即它的取址范围是从00000000到FFFFFFFF,那么你大可以放心,但是,实际情况往往不会这么简单。
在有些计算机上,地址是由两部分组成的,第一部分是一个指向某一块内存的起始点的指,针(即基地址),第二部分是相对于这块内存的起始点的地址偏移量。这种地址结构被称为段地址结构,子程序调用通常就是通过在栈指针上加上一个地址偏移量来实现的。采用段地址结构的最典型的例子是基于Intel 8086的计算机,所有的MS-DOS程序都在这种计算机上运行(在基于Pentium芯片的计算机上,大多数MS-DOS程序也在与8086兼容的模式下运行)。即使是性能优越的具有线性地址空间的RISC芯片,也提供了寄存器变址寻址方式,即用一个寄存器保存指向某一块内存的起始点的指针,用另一个寄存器保存地址偏移量。
如果你的程序使用段地址结构,而在基地址处刚好存放着数组a0(即基地址指针和&a0[0]相同),这会引出什么问题呢?既然基地址无法(有效地)改变,而偏移量也不可能是负值,因此“位于a0[0]前面的元素”这种说法就没有意义了,ANSIC标准明确规定引用这个元素的行为是没有定义的,这也就是9.1中所提到的方法可能行不通的原因。
同样,如果数组a(其元素个数为MAX)刚好存放在某段内存的尾部,那么地址&a[MAX]就是没有意义的,如果你的程序中使用了&a[MAX],而编译程序又要检查&a[MAX]是否有效,那么编译程序必然就会报告没有足够的内存来存放数组a。
尽管在编写基于Windows,UNIX或Macintosh的程序时不会遇到上述问题,但是C语言不仅仅是为这几种情况设计的,C语言必须适应各种各样的环境,例如用微处理器控制的烤面包炉,防抱死刹车系统,MS-DOS,等等。严格按C语言标准编写的程序能被顺利地编译并能服务于任何目的,但是,有时程序员也可以适度地背离C语言的标准,这要视程序员、编译程序和程序用户三者的具体要求而定。
不可以。当把数组作为函数的参数时,你无法在程序运行时通过数组参数本身告诉函数该数组的大小,因为函数的数组参数相当于指向该数组第一个元素的指针。这意味着把数组传递给函数的效率非常高,也意味着程序员必须通过某种机制告诉函数数组参数的大小。
为了告诉函数数组参数的大小,人们通常采用以下两种方法:
第一种方法是将数组和表示数组大小的值一起传递给函数,例如memcpy()函数就是这样做的:
char source[MAX],dest[MAX];
/*... */
memcpy(dest,source,MAX);
第二种方法是引入某种规则来结束一个数组,例如在C语言中字符串总是以ASCII字符NUL('\0')结束,而一个指针数组总是以空指针结束。请看下述函数,它的参数是一个以空指针结束的字符指针数组,这个空指针告诉该函数什么时候停止工作:
void printMany(char *strings口)
{
int i;
i=0;
while(strings[i]!=NULL)
{
puts(strings[i]);
++i;
}
}
正象9.5中所说的那样,C程序员经常用指针来代替数组下标,因此大多数C程序员通常会将上述函数编写得更隐蔽一些:
void printMany(char *strings[])
{
while(*strings)
{
puts(*strings++);
}
}
尽管你不能改变一个数组名的值,但是strings是一个数组参数,相当于一个指针,因此可以对它进行自增运算,并且可以在调用puts()函数时对strings进行自增运算。在上例中,while(*strings)
就相当于
while(*strings !=NULL)
在写函数文档(例如在函数前面加上注释,或者写一份备忘录,或者写一份设计文档)时,写进函数是如何知道数组参数的大小是非常重要的,例如,你可以非常简略地写上“以空指针结束”或“数组elephants中有numElephants个元素”(如果你在程序中用数字13表示数组的大小,你可以写进“数组arr中有13个元素”这样的描述,然而用确切的数字表示数组的大小不是一种好的编程习惯)。
与使用下标相比,使用指针能使C编译程序更容易地产生优质的代码。假设你的程序中有这样一段代码:
/* X la some type */
X a[MAX];
X *p; /*pointer*/
X x; /*element*/
int i; /*index*/
为了历数组a中的所有元素,你可以采用这样一种循环方式(方式a)
/*version (a)*/
for (i = 0; i<MAX; ++i)
{
x=a[i];
/* do something with x * /
}
你也可以采用这样一种循环方式(方式b)
/*veraion(b)*/
for (p = a; p<&a[MAX]; ++p )
{
x=*p;
/* do aomething with x * /
}
这两种方式有什么区别呢?两种方式中的初始情况和递增运算是相同的,作为循环条件的比较表达式也是相同的(下文中将进一步讨论这一点)。区别在于“x=a[]”和“x=*p”,前者要确定a[i]的地址,因此需要将i和类型x的大小相乘后再与数组a中第一个元素的地址相加;
后者只需间接引用指针p。间接引用是快速的,而乘法运算却比较慢。
这是一种“微效率”现象,它可能对程序的总体效率有影响,也可能没有影响。对方式a来说,如果循环体中的操作是将数组中的元素相加,或者只是移动数组中的元素,那么每次循环中大部分时间就消耗在使用数组下标上;如果循环体中的操作是某种I/O操作,或者是函数调用,那么使用数组下标所消耗的时间是微不足道的。
在有些情况下,乘法运算的开销会降低。例如,当类型x的大小为1时,经过优化就可以将乘法运算省去(一个值乘以1仍然等于这个值);当类型x的大小是2的幂时(此时类型x通常是系统固有类型),乘法运算就可以被优化为左移位运算(就象一个十进制的数乘以10一样)。
在方式b中,每次循环都要计算&a[MAX],这需要多大代价呢?这和每次计算a[i]的代价相同吗?答案是不同,因为在循环过程中&a[MAX]是不变的。任何一种合格的编译程序都只会在循环开始时计算一次&a[MAX],而在以后的每次循环中重复使用这次计算所得的值。
在编译程序确认在循环过程中a和MAX都不变的前提下,方式b和以下代码的效果是相同的:
/* how the compiler implements version (b) */
X *temp = &a[MAX]; /* optimization */
for (p = a; p< temp; ++p )
{
x =*p;
/*do something with x * /
}
遍历数组元素还可以有另外两种方式,即以递减而不是递增的顺序遍历数组元素。对按顺序打印数组元素这样的任务来说,后两种方式没有什么优势,但是对数组元素相加这样的任务来说,后两种方式比前两种方式更好。通过下标并且以递减顺序遍历数组元素的方式(方式c)如下所示(人们通常认为将一个值和。比较的代价要比将一个值和一个非零值比较的代价小:
/* version (c) */
for (i = MAX - 1; i>=0; --i)
{
x=a[i];
/* do aomcthing with x * /
}
通过指针并以递减顺序遍历数组元素的方式(方式d)如下所示,其中作为循环条件的比较表达式显得很简洁:
/* version (d) */
for (p = &a[MAX - 1]; p>=a; --p )
{
x =*P;
/*do something with x * /
}
与方式d类似的代码是很常见的,但不是绝对正确的,因为循环结束的条件是p小于a,而这有时是不可能的(见9.3)。
通常人们会认为“任何合格的能优化代码的编译程序都会为这4种方式产生相同的代码”,但实际上许多编译程序都没能做到这一点。笔者曾编写过一个测试程序(其中类型x的大小不是2的幂,循环体中的操作是一些无关紧要的操作),并用4种差别很大的编译程序编译这个程序,结果发现方式b总是比方式a快得多,有时要快两倍,可见使用指针和使用下标的效果是有很大差别的(有一点是一致的,即4种编译程序都对&a[MAX]进行了前文提到过的优化)。
那么在遍历数组元素时,以递减顺序进行和以递增顺序进行有什么不同呢?对于其中的两种编译程序,方式c和方式d的速度基本上和方式a相同,而方式b明显是最快的(可能是因为其比较操作的代价较小,但是否可以认为以递减顺序进行要比以递增顺序进行慢一些呢?);对于其中的另外两种编译程序,方式c的速度和方式a基本相同(使用下标要慢一些),但方式d的速度比方式b要稍快一些。
总而言之,在编写一个可移植性好、效率高的程序时,为了遍历数组元素,使用指针比使用下标能使程序获得更快的速度;在使用指针时,应该采用方式b,尽管方式d一般也能工作,但编译程序为方式d产生的代码可能会慢一些。
需要补充的是,上述技巧只是一种细微的优化,因为通常都是循环体中的操作消耗了大部分运行时间,许多C程序员往往会舍本求末,忽视这种实际情况,希望你不要犯相同的错误。不可以,尽管在一个很常见的特例中好象可以这样做。
数组名不能被放在赋值运算符的左边(它不是一个左值,更不是一个可修改的左值)。一个数组是一个对象,而它的数组名就是指向这个对象的第一个元素的指针。
如果一个数组是用extern或static说明-的,则它的数组名是在连接时可知的一个常量,你不能修改这样一个数组名的值,就象你不能修改7的值一样。
给数组名赋值是毫无根据的。一个指针的含义是“这里有一个元素,它的前后可能还有其它元素”,一个数组名的含义是“这里是一个数组中的第一个元素,它的前面没有数组元素,并且只有通过数组下标才能引用它后面的数组元素”。因此,如果需要使用指针,就应该使用指针。
有一个很常见的特例,在这个特例中,好象可以修改一个数组名的值:
void f(chara[12])
{
++a; /*legal!*/
}
秘密在于函数的数组参数并不是真正的数组,而是实实在在的指针,因此,上例和下例是等价的:
void f(char *a)
{
++a; /*certainlylegal*/
}
如果你希望上述函数中的数组名不能被修改,你可以将上述函数写成下面这样,但为此你必须使用指针句法:
void{(char *const a)
{
++a; /*illegal*/
}
在上例中,参数a是一个左值,但它前面的const关键字说明了它是不能被修改的。
前者是指向数组中第一个元素的指针,后者是指向整个数组的指针。
注意;笔者建议读者读到这里时暂时放下本书,写一下指向一个含MAX个元素的字符数组的指针变量的说明。提示:使用括号。希望你不要敷衍了事,因为只有这样你才能真正了解C语言表示复杂指针的句法的奥秘。下文将介绍如何获得指向整个数组的指针。
数组是一种类型,它有三个要素,即基本类型(数组元素的类型),大小(当数组被说明为不完整类型时除外),数组的值(整个数组的值)。你可以用一个指针指向整个数组的值:
char a[MAX]; /*arrayOfMAXcharacters*/
char *p; /*pointer to one character*/
/*pa is declared below*/
pa=&al
p=a; /* =&a[0] */
在运行了上述这段代码后,你就会发现p和pa的打印结果是一个相同的值,即p和pa指向同一个地址。但是,p和pa指向的对象是不同的。
以下这种定义并不能获得一个指向整个数组的值的指针:
char *(ap[MAX]);
上述定义和以下定义是相同的,它们的含义都是“ap是一个含MAX个字符指针的数组”;
char *ap[MAX];
并不是所有的常量都可以用来定义一个数组的初始大小,在C程序中,只有C语言的常量表达式才能用来定义一个数组的初始大小。然而,在C++中,情况有所不同。
一个常量表达式的值在程序运行期间是不变的,并且是编译程序能计算出来的一个值。在定义数组的大小时,你必须使用常量表达式,例如,你可以使用数字:
char a[512];
或者使用一个预定义的常量标识符:
#define MAX 512
/*... */
char a[MAX];
或者使用一个sizeof表达式:
char a[sizeof(structcacheObject)];
或者使用一个由常量表达式组成的表达式:
char buf[sizeof(struct cacheObject) *MAX];
或者使用枚举常量。
在C中,一个初始化了的constint变量并不是一个常量表达式:
int max=512; /* not a constant expression in C */
char buffer[max]; /* notvalid C */
然而,在C++中,用const int变量定义数组的大小是完全合法的,并且是C++所推荐的。尽管这会增加C++编译程序的负担(即跟踪const int变量的值),而C编译程序没有这种负担,但这也使C++程序摆脱了对C预处理程序的依赖。
数组的元素可以是任意一种类型,而字符串是一种特殊的数组,它使用了一种众所周知的确定其长度的规则。
有两种类型的语言,一种简单地将字符串看作是一个字符数组,另一种将字符串看作是一种特殊的类型。C属于前一种,但有一点补充,即C字符串是以一个NUL字符结束的。数组的值和数组中第一个元素的地址(或指向该元素的指针)是相同的,因此通常一个C字符串和一个字符指针是等价的。
一个数组的长度可以是任意的。当数组名用作函数的参数时,函数无法通过数组名本身知道数组的大小,因此必须引入某种规则。对字符串来说,这种规则就是字符串的最后一个字符是ASCII字符NUL('\0')。
在C中,int类型值的字面值可以是42这样的值,字符的字面值可以是‘*’这样的值,浮点型值的字面值可以是4.2el这样的单精度值或双精度值。
注意:实际上,一个char类型字面值是一个int类型字面值的另一种表示方式,只不过使用了一种有趣的句法,例如当42和'*'都表示char类型的值时,它们是两个完全相同的值。然而,在C++中情况有所不同,C++有真正的char类型字面值和char类型函数参数,并且通常会更仔细地区分char类型和int类型,整数数组和字符数组没有字面值。然而,如果没有字符串字面值,程序编写起来就会很困难,因此C提供了字符串字面值。需要注意的是,按照惯例C字符串总是以NUL字符结束,因此C字符串的字面值也以NUL字符结束,例如,“six times nine”的长度是15个字符(包括NUL终止符),而不是你看得见的14个字符。
关于字符串字面值还有一条鲜为人知但非常有用的规则,如果程序中有两条紧挨着的字符串字面值,编译程序会将它们当作一条长的字符串字面值来对待,并且只使用一个NUL终止符。也就是说,“Hello,”world”和“Hello,world”是相同的,而以下这段代码中的几条字符串字面值也可以任意分割组合:
char message[]=
”This is an extremely long prompt\n”
”How long is it?\n”
”It's so long,\n”
”It wouldn't fit On one line\n”;
在定义一个字符串变量时,你需要有一个足以容纳该字符串的数组或者指针,并且要保证为NUL终止符留出空间,例如,以下这段代码中就有一个问题:
char greeting[12];
strcpy(greeting,”Hello,world”); /*trouble*/
在上例中,greeting只有容纳12个字符的空间,而“Hello,world”的长度为13个字符(包括NUL终止符),因此NUL字符会被拷贝到greeting以外的某个位置,这可能会毁掉greetlng附近内存空间中的某些数据。再请看下例:
char greeting[12]=”Hello,world”;/*notastring*/
上例是没有问题的,但此时greeting是一个字符数组,而不是一个字符串。因为上例没有为NUL终止符留出空间,所以greeting不包含NUL字符。更好一些的方法是这样写:
char greeting[]=”Hello,world”;
这样编译程序就会计算出需要多少空间来容纳所有内容,包括NUL字符。
字符串字面值是字符(char类型)数组,而不是字符常量(const char类型)数组。尽管ANSIC委员会可以将字符串字面值重新定义为字符常量数组,但这会使已有的数百万行代码突然无法通过编译,从而引起巨大的混乱。如果你试图修改字符串字面值中的内容,编译程序是
不会阻止你的,但你不应该这样做。编译程序可能会选择禁止修改的内存区域来存放字符串字面值,例如ROM或者由内存映射寄存器禁止写操作的内存区域。但是,即使字符串字面值被存放在允许修改的内存区域中,编译程序还可能会使它们被共享。例如,如果你写了以下代码(并且字符串字面值是允许修改的):
char *p="message";
char *q="message";
p[4]='\0'; /* p now points to”mess”*/
编译程序就会作出两种可能的反应,一种是为p和q创建两个独立的字符串,在这种情况下,q仍然是“message”;一种是只创建一个字符串(p和q都指向它),在这种情况下,q将变成“mess”。
注意:有人称这种现象为“C的幽默”,正是因为这种幽默,绝大多数C程序员才会整天被自己编写的程序所困扰,难得忙里偷闲一次。