指针和数组
- 1. 指针的算术运算
- 1.1 指针加上整数
- 1.2 指针减去整数
- 1.3 两个指针相减
- 1.4 指针比较
- 1.5 指向复合常量的指针
- 2. 指针用于数组处理
- 3. 用数组名作为指针
- 3.1 数组型实际参数(改进版)
- 3.2 用指针作为数组名
- 4. 指针和多维数组
- 4.1 处理多维数组的元素
- 4.2 处理多维数组的行
- 4.3 处理多维数组的列
- 4.4 用多维数组名作为指针
- 5. C99 中的指针和变长数组
本专题介绍 C 语言中指针指向数组元素时的算术运算、关系运算和判等运算,通过示范指针处理数组元素的方法,揭示指针和数组之间的联系。
参考资料:《C 语言程序设计 · 现代方法 第 2 2 2 版》
1. 指针的算术运算
在专题十中我们知道,指针可以指向数组元素。例如,假设已经声明 a
和 p
如下:
int a[10], *p;
通过下列写法可以使 p
指向 a[0]
:
p = &a[0];
其结果可以用图形方式表示为:
现在可以通过 p
访问 a[0]
。例如,可以通过下列写法把值 5 5 5 存入 a[0]
中:
*p = 5;
下面显示的是现在的情况:
通过在 p
上执行指针算术运算(或者地址算术运算)可以访问数组 a
的其他所有元素。C 语言支持 3 3 3 种(而且只有 3 3 3 种)格式的指针算术运算:
- 指针加上整数
- 指针减去整数
- 两个指针相减
下面仔细研究一下每种运算。下面的所有例子都假设有如下声明:
int a[10], *p, *q, i;
1.1 指针加上整数
指针 p
加上整数 j
产生指向特定元素的指针,这个特定元素是 p
原先指向的元素后的 j
个位置。更确切地说,如果 p
指向数组元素 a[i]
,那么 p + j
指向 a[i + j]
(当然,前提是 a[i + j]
必须存在)。
下面的示例说明指针的加法运算,插图说明计算中 p
和 q
在不同点的值。
1.2 指针减去整数
如果 p
指向数组元素 a[i]
,那么 p - j
指向 a[i - j]
。例如:
1.3 两个指针相减
当两个指针相减时,结果为指针之间的距离(用数组元素的个数来度量)。因此,如果 p
指向 a[i]
且 q
指向 a[j]
,那么 p - q
就等于 i - j
。例如:
在一个不指向任何数组元素的指针上执行算术运算会导致未定义的行为。此外,只有在两个指针指向同一个数组时,把它们相减才有意义。
1.4 指针比较
可以用关系运算符(<
、<=
、>
和 >=
)和判等运算符(==
和 !=
)进行指针比较。只有在两个指针指向同一数组时,用关系运算符进行的指针比较才有意义。比较的结果依赖于数组中两个元素的相对位置。例如,在下面的赋值后 p <= q
的值是 0 0 0,而 p >= q
的值是 1 1 1。
p = &a[5];
q = &a[1];
1.5 指向复合常量的指针
指针指向由复合字面量(见专题八)创建的数组中的某个元素是合法的。例如:
int *p = (int []){3, 0, 3, 4, 1};
p
指向一个五元数组的第一个元素,这个数组包括 5 5 5 个整数 3 3 3, 0 0 0, 3 3 3, 4 4 4 和 1 1 1。使用复合字面量可以减少一些麻烦,我们不再需要先声明一个数组变量,然后用指针 p
指向数组的第一个元素。
2. 指针用于数组处理
指针的算术运算允许通过对指针变量进行重复自增来访问数组的元素。下面这个对数组 a
中元素求和的程序段说明了这种方法。在这个示例中,指针变量 p
初始指向 a[0]
,每次执行循环时对 p
进行自增;因此 p
先指向 a[1]
,然后指向 a[2]
,依此类推。在 p
指向数组 a
的最后一个元素后循环终止。
#define N 10
...
int a[N], sum, *p;
...
sum = 0;
for (p = &a[0]; p < &a[N]; p++)sum += *p;
下图说明了前 3 3 3 次循环迭代结束时(即 p
自增操作前)a
、sum
和 p
的内容。
for
语句中的条件 p < &a[N]
值得特别说明一下。尽管元素 a[N]
不存在,但是对它使用取地址运算符是合法的。因为循环不会尝试检查 a[N]
的值,所以在上述方式下使用 a[N]
是非常安全的。执行循环体时 p
依次等于 &a[0], &a[1], ..., &a[N - 1]
,但是当 p
等于 &a[N]
时,循环终止。
当然,改用下标可以很容易地写出不使用指针的循环。支持采用指针算术运算的最常见论调是,这样做可以节省执行时间。但是,这依赖于具体的实现,对有些编译器来说,实际上依靠下标的循环会产生更好的代码。
*
运算符和 ++
运算符的组合
C 程序员经常在处理数组元素的语句中组合 *
(间接寻址)运算符和 ++
运算符。例如,把值存入一个数组元素中,然后前进到下一个元素。利用数组下标可以写成 a[i++] = j;
。如果 p
指向数组元素,那么相应的语句将会是 *p++ = j;
。因为后缀 ++
的优先级高于 *
,所以编译器把上述语句看成是 *(p++) = j;
。p++
的值是 p
,因此 *(p++)
的值将是 *p
,即 p
当前指向的对象。
当然,*p++
不是唯一合法的 *
和 ++
的组合。例如,可以编写 (*p)++
,这个表达式返回 p
指向的对象的值,然后将对象进行自增(p
本身是不变化的)。
表达式 | 含义 |
---|---|
*p++ 或 *(p++) | 自增前表达式的值是 *p ,以后再自增 p |
(*p)++ | 自增前表达式的值是 *p ,以后再自增 *p |
*++p 或 *(++p) | 先自增 p ,自增后表达式的值是 *p |
++*p 或 ++(*p) | 先自增 *p ,自增后表达式的值是 *p |
这几种组合中,最频繁见到的就是 *p++
,它在循环中是很方便的。对数组 a
的元素求和时,可以把
for (p = &a[0]; p < &a[N]; p++)sum += *p;
改写成
p = &a[0];
while (p < &a[N])sum += *p++;
*
运算符和 --
运算符有类似的组合方法。在专题九中我们实现了一个栈,原始版本的栈依靠名为 top
的整型变量来记录 contents
数组中 “栈顶” 的位置。现在用一个指针变量来替换 top
,这个指针变量初始指向 contents
数组的第 0 0 0 个元素。
int *top_ptr = &contents[0];
下面是新的 push
函数和 pop
函数:
void push(int i)
{if (is_full())stack_overflow();else*top_ptr++ = i;
}int pop(void)
{if (is_empty())stack_underflow();elsereturn *--top_ptr;
}
3. 用数组名作为指针
指针的算术运算是数组和指针之间相互关联的一种方法,但这不是两者之间唯一的联系。下面是另一种关键的关系:可以用数组的名字作为指向数组第一个元素的指针。这种关系简化了指针的算术运算,而且使数组和指针更加通用。
例如,假设用如下形式声明 a
:
int a[10];
用 a
作为指向数组第一个元素的指针,可以修改 a[0]
:
*a = 7; /* stores 7 in a[0] */
可以通过指针 a + 1
来修改 a[1]
:
*(a + 1) = 12; /* stores 12 in a[1] */
通常情况下,a + i
等同于 &a[i]
,两者都表示指向数组 a
中元素 i
的指针,并且 *(a + i)
等价于 a[i]
,两者都表示元素 i
本身。换句话说,可以把数组的取下标操作看成是指针算术运算的一种形式。
数组名可以用作指针这一事实使得编写遍历数组的循环更加容易。例如下面这个对数组元素求和的循环
for (p = &a[0]; p < &a[N]; p++)sum += *p;
可以用 a
替换 &a[0]
,同时用 a + N
替换 &a[N]
:
for (p = a; p < a + N; p++)sum += *p;
虽然可以把数组名用作指针,但是不能给数组名赋新的值。试图使数组名指向其他地方是错误的:
while (*a != 0)a++; /*** WRONG ***/
这一限制不会对我们造成什么损失:我们可以把 a
复制给一个指针变量,然后修改该指针变量:
p = a;
while (*p != 0)p++;
程序
reverse3.c
:数列反向(改进版)
/* Reverses a series of numbers (pointer version) */#include <stdio.h>#define N 10int main(void)
{int a[N], *p;printf("Enter %d numbers: ", N);for (p = a; p < a + N; p++)scanf("%d", p);printf("In reverse order:");for (p = a + N - 1; p >= a; p--)printf(" %d", *p);printf("\n");return 0;
}
注意,scanf
函数的第二个实际参数是 p
,不是 &p
。因为 p
指向数组的元素,所以它是满足 scanf
函数要求的参数;而 &p
则是指向指向数组元素的指针的指针。
3.1 数组型实际参数(改进版)
数组名在传递给函数时,总是被视为指针。思考下面的函数,这个函数会返回整型数组中最大的元素:
int find_largest(int a[], int n)
{int i, max;max = a[0];for (i = 1; i < n; i++)if (a[i] > max)max = a[i];return max;
}
假设调用 find_largest
函数如下:
largest = find_largest(b, N);
这个调用会把指向数组 b
第一个元素的指针赋值给 a
,数组本身并没有被复制。把数组型形式参数看作是指针会产生许多重要的结果。
- 在给函数传递普通变量时,变量的值会被复制,任何对相应的形式参数的改变都不会影响到变量。反之,因为没有对数组本身进行复制,所以作为实际参数的数组是可能被改变的。为了指明数组型形式参数不会被改变,可以在其声明中包含单词
const
。 - 给函数传递数组所需的时间与数组的大小无关。因为没有对数组进行复制,所以传递大数组不会产生不利的结果。
- 如果需要,可以把数组型形式参数声明为指针,声明
a
是指针就相当于声明它是数组,编译器把这两类声明看作是完全一样的。例如,可以按如下形式定义find_largest
函数:
对于形式参数而言,声明为数组跟声明为指针是一样的;但是对变量而言,声明为数组跟声明为指针是不同的。声明int find_largest(int *a, int n) {... }
int a[10];
会导致编译器预留 10 10 10 个整数的空间,但声明int *a;
只会导致编译器为一个指针变量分配空间。在后一种情况下,a
不是数组,试图把它当作数组来使用可能会导致极糟的后果。例如,赋值*a = 0;
将在a
指向的地方存储 0 0 0,由于我们不知道a
指向哪里,所以对程序的影响是无法预料的。 - 可以给形式参数为数组的函数传递数组的 “片断”,所谓片断是指连续的数组元素组成的序列。假设希望用
find_largest
函数来定位数组b
中某一部分的最大元素,比如说元素b[5], ..., b[14]
。调用find_largest
函数时,将传递b[5]
的地址和数 10 10 10,表明希望find_largest
函数从b[5]
开始检查 10 10 10 个数组元素:largest = find_largest(&b[5], 10);
3.2 用指针作为数组名
C 语言也允许把指针看作数组名进行取下标操作:
#define N 100
...
int a[N], i, sum = 0, *p = a;
...
for (i = 0; i < N; i++)sum += p[i];
编译器把 p[i]
看作 *(p + i)
,这是指针算术运算非常正规的用法。
4. 指针和多维数组
4.1 处理多维数组的元素
从专题七可知,C 语言按行主序存储二维数组;换句话说,先是 0 0 0 行的元素,接着是 1 1 1 行的,依此类推。 r r r 行的数组可表示如下:
使用指针时可以利用这一布局特点。如果使指针 p
指向二维数组中的第一个元素(即 0 0 0 行 0 0 0 列的元素),就可以通过重复自增 p
的方法访问数组中的每一个元素。
作为示例,一起来看看把二维数组的所有元素初始化为 0 0 0 的问题。假设数组的声明如下:
int a[NUM_ROWS][NUM_COLS];
显而易见的方法是用嵌套的 for
循环:
int row, col;
...
for (row = 0; row < NUM_ROWS; row++)for (col = 0; col < NUM_COLS; col++)a[row][col] = 0;
如果把 a
看成是一维的整型数组,那么就可以把上述两个循环改成一个循环了:
int *p;
...
for (p = &a[0][0]; p <= &a[NUM_ROWS - 1][NUM_COLS - 1]; p++)*p = 0;
循环开始时 p
指向 a[0][0]
。对 p
连续自增可以使指针 p
指向 a[0][1]
、a[0][2]
、a[0][3]
等。当 p
达到 a[0][NUM_COLS - 1]
(即第 0 0 0 行的最后一个元素)时,再次对 p
自增将使它指向 a[1][0]
,也就是第 1 1 1 行的第一个元素。这一过程持续进行,直到 p
越过 a[NUM_ROWS - 1][NUM_COLS - 1]
(数组中的最后一个元素)为止。
虽然把二维数组当成一维数组来处理看上去像在搞欺骗,但是对大多数 C 语言编译器而言这样做都是合法的。但这样做是否是个好主意则要另当别论。这类方法明显破坏了程序的可读性,但是至少对一些老的编译器来说这种方法在效率方面进行了补偿。不过,对许多现代的编译器来说,这样所获得的速度优势往往极少甚至完全没有。
4.2 处理多维数组的行
为了访问到第 i
行的元素,需要初始化指针变量 p
使其指向数组 a
中第 i
行的元素 0 0 0:p = &a[i][0];
。对于任意的二维数组 a
来说,由于表达式 a[i]
是指向第 i
行中第一个元素(元素 0 0 0)的指针,前面的语句可以简写为 p = a[i];
。
为了了解原理,我们先回顾一下把数组取下标和指针算术运算关联起来的那个神奇公式:对于任意数组 a
来说,表达式 a[i]
等价于 *(a + i)
。因此 &a[i][0]
等同于 &(*(a[i] + 0))
,而后者等价于 &*a[i]
,又因为 &
和 *
运算符可以抵消,也就等同于 a[i]
。
下面的循环对数组 a
的第 i
行清零,其中用到了这一简化:
int a[NUM_ROWS][NUM_COLS], *p, i;
...
for (p = a[i]; p < a[i] + NUM_COLS; p++)*p = 0;
因为 a[i]
是指向数组 a
的第 i
行的指针,所以可以把 a[i]
传递给需要用一维数组作为实际参数的函数。换句话说,使用一维数组的函数也可以使用二维数组中的一行。因此,诸如 find_largest
和 store_zeros
这类函数比我们预期的更加通用。最初设计用来找到一维数组中最大元素的 find_largest
函数现在同样可以用它来确定二维数组 a
中第 i
行的最大元素:
largest = find_largest(a[i], NUM_COLS);
4.3 处理多维数组的列
处理二维数组的一列中的元素就没那么容易了,因为数组是按行而不是按列存储的。下面的循环对数组 a
的第 i
列清零:
int a[NUM_ROWS][NUM_COLS], (*p)[NUM_COLS], i;
...
for (p = &a[0]; p < &a[NUM_ROWS]; p++)(*p)[i] = 0;
这里把 p
声明为指向长度为 NUM_COLS
的整型数组的指针。在 (*p)[NUM_COLS]
中,*p
是需要使用括号的;如果没有括号,编译器将认为 p
是指针数组(存放指针的数组),而不是数组指针(指向数组的指针)。表达式 p++
把 p
移到下一行的开始位置。在表达式 (*p)[i]
中,*p
代表 a
的一整行,因此 (*p)[i]
选中了该行第 i
列的那个元素,(*p)[i]
中的括号是必要的,因为编译器会将 *p[i]
解释为 *(p[i])
。
4.4 用多维数组名作为指针
就像一维数组的名字可以用作指针一样,无论数组的维数是多少都可以采用任意数组的名字作为指针。思考数组 int a[NUM_ROWS][NUM_COLS];
,a
不是指向 a[0][0]
的指针,而是指向 a[0]
的指针。从 C 语言的观点来看,这样是有意义的。C 语言认为 a
不是二维数组而是一维数组,且这个一维数组的每个元素又是一维数组。用作指针时,a
的类型是 int (*)[NUM_COLS]
(指向长度为 NUM_COLS
的整型数组的指针)。
了解 a
指向的是 a[0][0]
有助于简化处理二维数组元素的循环。例如,为了把数组 a
的第 i
列清零,可以用
for (p = &a[0]; p < &a[NUM_ROWS]; p++)(*p)[i] = 0;
取代
for (p = a; p < a + NUM_ROWS; p++)(*p)[i] = 0;
另一种应用是巧妙地让函数把多维数组看成是一维数组。例如,思考如何使用 find_largest
函数找到二维数组 a
中的最大元素。我们把 a
(数组的地址)作为 find_largest
函数的第一个实际参数,NUM_ROWS * NUM_COLS
(数组 a
中的元素总数量)作为第二个实际参数:
largest = find_largest(a, NUM_ROWS * NUM_COLS); /*** WRONG ***/
这条语句不能通过编译,因为 a
的类型为 int (*)[NUM_COLS]
而 find_largest
函数期望的实际参数类型是 int *
。正确的调用是:
largest = find_largest(a[0], NUM_ROWS * NUM_COLS);
a[0]
指向第 0 0 0 行的元素 0 0 0,类型为 int *
(编译器转换以后),所以这一次调用将正确地执行。
5. C99 中的指针和变长数组
指针可以指向变长数组中的元素,变长数组是 C99 的一个特性。普通的指针变量可以用于指向一维变长数组的元素:
void f(int n)
{int a[n], *p;p = a;...
}
如果变长数组是多维的,指针的类型取决于除第一维外每一维的长度。下面是二维的情况:
void f(int m, int n)
{int a[m][n], (*p)[n];p = a;...
}
因为 p
的类型依赖于 n
,而 n
不是常量,所以说 p
具有可改变类型。需要注意的是,编译器并非总能确定 p = a
这样的赋值语句的合法性。例如,下面的代码可以通过编译,但只有当 m = n m = n m=n 时才是正确的:
int a[m][n], (*p)[m];
p = a;
如果 m ≠ n m \ne n m=n,后续对 p
的使用都将导致未定义的行为。
与变长数组一样,可改变类型也具有特定的限制,其中最重要的限制是,可改变类型的声明必须出现在函数体内部或者在函数原型中。
变长数组中的指针算术运算和一般数组中的指针算术运算一样。还是那个对二维数组 a
的一列进行清零操作的例子,这次将二维数组 a
声明为变长数组:int a[m][n];
,指向数组 a
中某行的指针可以声明为:int (*p)[n];
,把第 i
列清零的循环如下:
for (p = a; p < a + m; p++)(*p)[i] = 0;