在 C 语言中,指针和数组有着非常强的关联,强到应当把两者同时拿出来讨论。任何可以通过数组下标来做到的操作,也都能用指针来做到。而指针的版本通常会更快,但至少对初学者来说会更难理解。
如下声明
int a[10];
定义了一个大小为 10 的数组 a,即由10个名为 a[0], a[1], ... a[9] 的连续对象所组成的块。
用 a[i] 来表示数组的第 i 个元素。如果 pa 是指向整数的指针,其声明为
int *pa;
则赋值语句
pa = &a[0];
使 pa 指向 a 的第 0 个元素;也就是说, pa 包含了 a[0] 的地址。
现在如果再赋值
x = *pa;
会将 a[0] 的内容拷贝给 x。
如果 pa 指向数组的某个特定元素,则根据定义,pa + 1 会指向下一个元素,pa + i 指向 pa 之后的第 i 个元素, pa - i 指向 pa 之前的第 i 个元素。因此,如果 pa 指向 a[0],则
*(pa+1)
表示 a[1] 的内容,而 pa + i 表示 a[i] 的地址,而 *(pa+i) 是 a[i] 的内容。
其实,不管数组 a 里面的变量是什么类型,占据多大空间,上述说法都是正确的。“将指针加1” 的含义是?pa + 1 指向下一个对象,由此扩展到所有指针运算,可得?pa + i 指向 pa 之后的第 i 个对象。
下标和指针运算之间有非常紧密的关联。根据定义,类型为数组的变量或表达式,其值为数组第 0 个元素的地址。因此,经过如下赋值之后
pa = &a[0];
pa 和 a 有相同的值。由于数组的名称就是其首个元素位置的同义词,赋值?pa = &a[0] 也能够写成
pa = a;
而更令人惊奇(至少在首次看到时)的事实是,a[i] 也能够写成 *(a+i)。在计算 a[i] 时,C会立即将其转换为 *(a+i);两种形式是等价的。将操作符 & 分别应用到两者,就能得到 &a[i] 和 a+i 也是等价的:a+i 是 a 之后第 i 个元素的地址。从另一个角度看,如果 pa 是指针,可以对它加下标来使用;pa[i] 等价于 *(pa+i)。简而言之,?数组+下标的表达式,等价于指针+偏移的表达式。
数组名称和指针有一个区别必须牢记。指针是一个变量,因此 pa=a 和 pa++ 都是合法的。但数组名不是变量;像 a=pa 和 a++ 这样的结构是非法的。
当数组名称被传递给函数时,传递的是数组首元素的位置。在被调函数中,该参数是一个局部变量,因此数组名参数是一个指针,即一个包含地址的变量。我们可以利用这个事实来写另一个版本的 strlen,计算字符串的长度:
/* strlen: 返回字符串s的长度 */
int strlen(char *s)
{
int n;
for (n = 0; *s != '\0'; s++)
n++;
return n;
}
由于 s 是一个指针,对其递增是完全合法的;s++ 对调用 strlen 的函数里面的字符串不起任何效果,它仅仅是对该指针在 strlen 中的私有拷贝进行递增。这意味着如下调用:
strlen("hello, world"); /* 字符串常量 */
strlen(array); /* char array[100]; */
strlen(ptr); /* char *ptr; */
都是可行的。
作为函数定义中的形参
char s[];
和
char *s;
是等价的;我们更偏向后者,因为它更显式地表明该参数是指针。当数组名称被传递给函数时,函数可以根据自己的意愿来认定它处理的是数组还是指针,并进行对应的操作。如果能够让代码看起来更恰当、更清晰,甚至可以使用两种表示法。
通过传递指向子数组开头的指针,可以将数组的一部分传递给函数。例如,如果 a 是数组,则
f(&a[2])
和
f(a+2)
都是把从 a[2] 开头的子数组地址传给函数 f 。在函数 f 中,参数声明可以写为
f(int arr[]) { ... }
或是
f(int *arr) { ... }
从 f 的角度而言,参数指向的是大数组的一部分还是数组真实的首地址,都无关紧要。
如果能保证元素的存在,可以将数组向前索引;p[-1] 和 p[-2] 等在语法上都是合法的,它们都指向 紧挨着 p[0] 的之前的元素。当然,引用数组边界之外的元素是非法的。
如果 p 是指向数组中某个元素的指针,则 p++ 将 p 递增以指向下一个元素,而 p+=i 将 p 递增 i 以指向当前元素后的第 i 个元素。这些及其类似的结构,是指针或地址运算的最简单形式。
C 语言地址运算的方式是一致而且有规律的;对指针,数组和地址运算的集成是 C 语言的优势之一。我们写一个简单原始的内存管理器来说明。有两个例程。第一个是 alloc(n),返回一个指针 p, 指向 n 个连续字符的位置,alloc 的调用者可以用它来保存字符。第二个是 afree(p),释放通过alloc 获取到的内存,使这块内存后续能被重用。说它们是“简单原始”的,因为必须以 alloc 相反的顺序来调用?afree 。也就是说,alloc 和 afree 管理的内存是一个栈,或者叫后进先出队列。标准库提供的类似函数叫做 malloc 和 free ,没有这个限制;在8.7节会展示如何来实现它们。
最简单的实现是让 alloc 交出一个我们称之为 allocbuf 的大字符数组中的一小部分。这个数组是 alloc 和 free 私有的。因为它们使用指针而不是下标来处理,其他例程不需要知道数组的名字,因此在包含 alloc 和 free 的源文件中,该数组可以声明为 static,使之对外部不可见。在实际的内存管理器中,数组甚至都不需要有名字;它可能是通过调用 malloc 或请求操作系统,从而获取到的一个指向未命名内存块的指针。
另一个所需的信息是 allocbuf 用了多少。我们使用一个指针 allocp 来指向下一个空闲的元素。当 alloc 被要求 n?个字符时,它检查在 allocbuf 中是否存在足够的空间。如果是,它返回当前的 allocp 值(即空闲块的开头),并将其递增 n,以指向下一个空闲区域。如果没有足够空间,alloc 返回零。afree(p) 仅仅是将 allocp 设为 p,如果 p 在 allocbuf 内部的话。
#define ALLOCSIZE 10000 /* 可用空间 */
static char allocbuf[ALLOCSIZE]; /* alloc所用空间 */
static char *allocp = allocbuf; /* 下一个空闲位置 */
char *alloc(int n) /* 返回指向n个字符的指针 */
{
if (allocbuf + ALLOCSIZE - allocp >= n) { /* 空间足够 */
allocp += n;
return allocp - n; /* 旧的指针 */
} else { /* 空间不足 */
return 0;
}
}
void afree(char *p) /* 释放p指向的空间 */
{
if (p >= allocbuf && p < allocbuf + ALLOCSIZE)
allocp = p;
}
通常,指针可以像其他变量一样初始化,不过正常情况下,有意义的初始值只有零,或者是包含之前定义过且类型匹配的地址的表达式。如下声明
static char *allocp = allocbuf;
将 allocp 定义为字符串指针,并将其初始化为指向 allocbuf 的开头,即程序启动时的下一个空闲位置。这也可以写成
static char *allocp = &allocbuf[0];
因为数组名称正是其第0个元素的地址。
如下判断
if (allocbuf + ALLOCSIZE - allocp >= n) { /* 足够 */
用来检查是否有足够的空间可满足分配 n 个字符的请求。如果有,则 allocp 的新值最多能到达的位置比 allocbuf 的末尾元素还超过一个【注意这个地址已经不属于allocbuf了,只可用来比较,不能分配】。如果请求能够满足,alloc 返回指向一块字符的起始位置的指针(注意alloc函数的声明)。如果不能,alloc 必须能够返回指示空间不足的信号。C 语言保证 0 永远不会是数据的合法地址,因此返回值零用来指示不正常的事件,此时为空间不足。
指针和整数是不可以相互交换使用的。零是唯一的例外:常量零可以被赋给指针,且指针可以与常量零比较。通常用符号常量 NULL 作为助记符来代替零,以更清晰地表示这是指针的特殊值。NULL 在 <stdio.h> 中定义。此后我们都将使用 NULL。
如下判断
if (allocbuf + ALLOCSIZE - allocp >= n) { /* 足够 */
以及
if (p >= allocbuf && p < allocbuf + ALLOCSIZE)
显示了指针运算的一些重要方面。首先,指针在某些环境下可以进行比较。如果 p 和 q 都指向同一数组的元素,则关系操作符如 ==,!=,<,>= 等等,都能正常使用。例如若要
p < q
为真,则 p 指向的元素在 q 指向的元素之前。任何指针都能与零进行相等或不等的比较,这是有意义的。但如果在不指向相同数组的指针之间进行运算或比较,其行为是未定义的。(有一个例外,数组末尾之后的第一个元素可以用于指针运算)
第二,我们已经观察到,指针和整数可以相加或相减。如下结构
p + n
表示 p 当前所指地址之后的第 n 个对象的地址。不管 p 指向何种类型的对象,这个说法总是正确的;n 会根据 p 指向的对象的大小进行放大,对象大小是由 p 的声明所决定的。例如,如果 int 占四个字节,则 int 会乘以四。【即如果p指向整数,则C编译器在计算 p + n 时会把n乘以4,例如 p=12345678 ,n=1,则 p+n = 12345678 + 1*4】
指针减法也是合法的:如果 p 和 q 指向同一数组内的元素,且 p<q,则 q-p+1 是 p 和 q 之间的元素个数(包含两端)。可用利用这个事实再写出另一个版本的 strlen:
/* strlen: 计算字符串s的长度 */
int strlen(char *s)
{
char *p = s;
while(*p != '\0')
p++;
return p - s;
}
在声明中,p 被初始化为 s,即字符串的首个字符。在 while 循环中,挨个检查每个字符,直到发现末尾的 '\0'。由于 p 指向的是字符,p++ 每次都将 p 移到下一个字符,而 p - s 表示移动过的字符数,即字符串的长度。(字符串中的字符数量可能太大,int 保存不下。头文件<stddef.h> 定义了一个类型 ptrdiff_t,它足够大,可以用来保存两个指针之间的有符号差值。然而,如果我们更仔细的话,会使用 size_t 来做 strlen 的返回值,以与标准库的版本相匹配。size_t 是 sizeof 操作符返回的有符号整型。)
指针运算是一致的:如果我们要处理比 char 占内存更多的 float,而 p 是指向 float 的指针,则 p++ 会指向下一个 float。这样,仅仅需要把 alloc 和 afree 中的所有 char 替换成 float,我们就能写出 alloc 的 float 版本 。所有的指针操作都会自动地考虑到所指向对象的大小。
合法的指针操作有:将指针赋给相同类型,一个指针与一个整数的加减,指向相同数组的两个指针的减法或比较,以及与零的赋值和比较。其他所有指针运算都是非法的。对两个指针相加是非法的,非法的还有相乘或相除,移位或者掩码,以及将指针与 float 或 double 相加,甚至,在没有强制类型转换的情况下,将一个类型的指针赋给另一个类型的指针。最后一种情况对 void * 是特例,它是可以不用强制类型转换。