第11章
介绍了指针,并且说明了如何把指针用作函数的实际参数和函数的返回值。本章介绍指针的另一种应用。当指针指向数组元素时,C
语言允许对指针进行算术运算
(加法和减法),通过这种运算我们可以用指针代替数组下标对数组进行处理。
正如本章将介绍的那样,C
语言中指针和数组的关系是非常紧密的。后面的第13章
(字符串)和第17章
(指针的高级应用)将利用这种关系。理解指针和数组之间的关系对于熟练掌握C语言非常关键:它能使我们深入了解C
语言的设计过程,并且能够帮助我们理解现有的程序。然而,需要知道的是,用指针处理数组的主要原因是效率,但是这里的效率提升已经不再像当初那么重要了,这主要归功于编译器的改进。
12.1节
讨论指针的算术运算,并且说明如何使用关系运算符和判等运算符进行指针的比较;12.2节
示范如何用指针处理数组元素;12.3节
揭示了一个关于数组的重要事实(即可以用数组的名字作为指向数组中第一个元素的指针),并且利用这个事实说明了数组型实际参数的真实工作机制;12.4节
讲解前3
节的主题对于多维数组的应用;最后的12.5节
介绍指针和变长数组之间的关系(C99
的特性)。
由11.5节
可知,指针可以指向数组元素。例如,假设已经声明a
和p
如下:
int a[10], *p;
通过下列写法可以使p
指向a[0]
:
p = &a[0];
现在可以通过p
访问a[0]
。例如,可以通过下列写法把值5
存入a[0]
中:
*p = 5;
把指针p
指向数组a
的元素不是特别令人激动。但是,通过在p
上执行指针算术运算(或者地址算术运算)可以访问数组a
的其他所有元素。C
语言支持3
种(而且只有3
种)格式的指针算术运算
:
接下来仔细研究一下每种运算。下面的所有例子都假设有如下声明:
int a[10], *p, *q, i;
指针
p
加上整数j
产生指向特定元素的指针,这个特定元素是p
原先指向的元素后的j
个位置。更确切地说,如果p
指向数组元素a[i]
,那么p + j
指向a[i + j]
(当然,前提是a[i + j]
必须存在)。下面的示例说明指针的加法运算:
p = &a[2]; //p指向a数组的第3个元素
q = p + 3; //q指向a数组的第6个元素
p += 6; //p指向a数组的第9个元素
如果
p
指向数组元素a[i]
,那么p - j
指向a[i - j]
。例如:
p = &a[8]; //p指向a数组第9个元素
q = p - 3; //q指向a数组第6个元素
p -= 6; //p指向a数组第3个元素
当两个指针相减时,结果为指针之间的距离(用数组元素的个数来度量)。因此,如果
p
指向a[i]
且q
指向a[j]
,那么p - q
就等于i - j
。例如:
p = &a[5];
q = &a[1];
i = p - q; /* i is 4 */
i = q - p; /* i is -4 */
请注意!!在一个不指向任何数组元素的指针上执行算术运算会导致未定义的行为。此外,只有在两个指针指向同一个数组时,把它们相减才有意义。
可以用关系运算符(<、<=、>和>=)
和判等运算符(==和!=)
进行指针比较
。只有在两个指针指向同一数组时,用关系运算符进行的指针比较才有意义。比较的结果依赖于数组中两个元素的相对位置。例如,在下面的赋值后p <= q
的值是0
,而p >= q
的值是1
。
p = &a[5];
q = &a[1];
指针指向由
复合字面量(9.3节)
创建的数组中的某个元素是合法的。回顾一下,复合字面量是C99
的一个特性,可以用于创建没有名称的数组。考虑下面的例子:
int *p = (int []){3, 0, 3, 4, 1};
p
指向一个5
元素数组的第一个元素,这个数组包括5
个整数:3、0、3、4和1
。使用复合字面量可以减少一些麻烦,我们不再需要先声明一个数组变量,然后用指针p
指向数组的第一个元素:
int a[] = {3, 0, 3, 4, 1};
int *p = &a[0];
指针的算术运算允许通过对指针变量进行重复自增来访问数组的元素。下面这个对数组
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;
for
语句中的条件p < &a[N]
值得特别说明一下。尽管元素a[N]
不存在(数组a
的下标为0~N-1
),但是对它使用取地址运算符是合法的。因为循环不会尝试检查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 |
这4
种组合都可以出现在程序中,但有些组合比其他组合要常见得多。最频繁见到的就是*p++
(或者*(p++)
),它在循环中是很方便的。对数组a
的元素求和时,可以这样做:
for (p = &a[0]; p < &a[N]; p++)
sum += *p;
//改写成
p = &a[0];
while (p < &a[N])
sum += *p++;
*运算符
和--运算符
的组合方法类似于*
和++
的组合。为了应用
*
和--
的组合,一起回到10.2节
的栈
的例子。原始版本的栈依靠名为top
的整型变量来记录contents
数组中“栈顶”
的位置。现在用一个指针变量来替换top
,这个指针变量初始指向contents
数组的第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();
else
return *--top_ptr;
}
注意!!因为希望pop
函数在取回top_ptr
指向的值之前对top_ptr
进行自减,所以要写成*--top_ptr
,而不是*top_ptr--
。
指针的算术运算是数组和指针之间相互关联的一种方法,但这不是两者之间唯一的联系。下面是另一种关键的关系:可以用数组的名字作为指向数组第一个元素的指针。这种关系简化了指针的算术运算,而且使数组和指针更加通用。
例如,假设用如下形式声明a
:
int a[10];
用a
作为指向数组第一个元素的指针,可以修改a[0]
:
*a = 7; /* stores 7 in a[0] */
可以通过指针a + 1
来修改a[1]
:
*(a+1) = 12; /* store 12 in a[1] */
通常情况下,a + i
等同于&a[i]
(两者都表示指向数组a
中元素i
的指针),并且*(a+i)
等价于a[i]
(两者都表示元素i
本身)。换句话说,可以把数组的取下标操作看作指针算术运算的一种形式。
数组名可以用作指针这一事实使得编写遍历数组的循环更加容易。思考下面这个来自12.2节
的循环:
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++;
8.1节
的程序reverse.c
读入10
个数,然后逆序输出这些数。程序读取数时会把这些数存入数组。一旦所有的数都读入了,程序就会反向遍历数组并打印出这些数。
原来的程序利用下标来访问数组中的元素。下面是改进后的程序,我们用指针的算术运算取代了数组的取下标操作。
/*
reverse3.c
--Reverses a series of numbers (pointer version)
*/
#include
#define N 10
int 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;
}
在原先的程序中,整型变量
i
用来记录数组内的当前位置。新版程序用指针变量p
替换了i
。读入的数仍然存储在数组中,只是换了一种方法来记录数组中的位置。注意,
scanf
函数的第二个实际参数是p
,不是&p
。因为p
指向数组的元素,所以它是满足scanf
函数要求的参数;而&p
则是指向“指向数组元素的指针”的指针。
数组名在传递给函数时,总是被视为指针
。思考下面的函数,这个函数会返回整型数组中最大的元素:
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
,数组本身并没有被复制。
把数组型形式参数看作指针会产生许多重要的结果。
在给函数传递普通变量时,变量的值会被复制。任何对相应的形式参数的改变都不会影响到变量。反之,因为没有对数组本身进行复制,所以作为实际参数的数组是可能被改变的。例如,下列函数(
9.3节
见过)可以通过在数组的每个元素中存储零来修改数组:void store_zeros(int a[], int n) { int i; for (i = 0; i < n; i++) a[i] = 0; }
为了指明数组型形式参数不会被改变,可以在其声明中包含单词
const
:int find_largest(const int a[], int n) { ... }
如果参数中有
const
,编译器会核实find_largest
函数体中确实没有对a
中元素的赋值。
给函数传递数组所需的时间与数组的大小无关。因为没有对数组进行复制,所以传递大数组不会产生不利的结果。
如果需要,可以把数组型形式参数声明为指针。例如,可以按如下形式定义find_largest
函数:
int find_largest(int *a, int n)
{
...
}
声明a
是指针就相当于声明它是数组。编译器把这两类声明看作完全一样的。
请注意!!
对于形式参数而言,声明为数组跟声明为指针是一样的
;但是对变量而言,声明为数组跟声明为指针是不同的。声明int a[10];
会导致编译器预留
10
个整数的空间,但声明int *a;
只会导致编译器为一个指针变量分配空间。在后一种情况下,a不是数组,试图把它当作数组来使用可能会导致极糟的后果。例如,赋值
*a = 0; /* wrong */
将在
a
指向的地方存储0
。因为我们不知道a
指向哪里,所以对程序的影响是无法预料的。
find_largest
函数来定位数组b
中某一部分的最大元素,比如元素b[5]
,…,b[14]
。调用find_largest
函数时,将传递b[5]
的地址和数10
,表明希望find_largest
函数从b[5]
开始检查10
个数组元素:largest = find_largest(&b[5], 10);
既然可以用数组名作为指针,C
语言是否允许把指针看作数组名进行取下标操作呢?现在,你可能猜出答案是肯定的,你是对的。下面是一个例子:
#define N 10
...
int a[N], i, sum = 0, *p = a;
...
for (i = 0; i < N; i++)
sum += p[i];
编译器把p[i]
看作*(p+i)
,这是指针算术运算非常正规的用法。目前我们对能够对指针取下标还仅限于好奇,但17.3节
会看到它实际上非常有用。
就像指针可以指向一维数组的元素一样,指针还可以指向多维数组的元素。本节将探讨用指针处理多维数组元素的常用方法。简单起见,这里
只讨论二维数组
,但所有内容都可以应用于更高维的数组。
从8.2节
可知,C语言按行主序存储二维数组
;换句话说,先是第0
行的元素,接着是第1
行的,依此类推。
使用指针时可以利用这一布局特点。如果使指针p
指向二维数组中的第一个元素(即第0
行第0
列的元素),就可以通过重复自增p
的方法访问数组中的每一个元素。
作为示例,一起来看看把
二维数组
的所有元素初始化为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
行的最后一个元素)时,再次对p
自增将使它指向a[1][0]
,也就是第1
行的第一个元素。这一过程持续进行,直到p
越过a[NUM_ROWS-1][NUM_COLS-1]
(数组中的最后一个元素)为止。
虽然把二维数组当成一维数组来处理看上去像在搞欺骗,但是对大多数
C
语言编译器而言这样做是合法的。这样做是否是个好主意则要另当别论。这类方法明显破坏了程序的可读性
,但是至少对一些老的编译器来说,这种方法在效率方面进行了补偿。不过,对许多现代的编译器来说,这样所获得的速度优势往往极少,甚至完全没有。
处理二维数组的一行中的元素,该怎么办呢?再次选择使用指针变量p
。为了访问到第i
行的元素,需要初始化p
使其指向数组a
中第i
行的元素0
:
p = &a[i][0];
对于任意的二维数组a
来说,由于表达式a[i]
是指向第i
行中第一个元素(元素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);
处理二维数组的一列中的元素就没那么容易了,因为数组是按行而不是按列存储的。下面的循环对数组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])
。
就像一维数组的名字可以用作指针一样,无论数组的维数是多少都可以采用任意数组的名字作为指针。但是,需要特别小心。思考下列数组:
int a[NUM_ROWS][NUM_COLS];
a
不是指向a[0][0]
的指针,而是指向a[0]
的指针。从C
语言的角度来看,这样做是有意义的。C
语言认为a
不是二维数组而是一维数组,并且这个一维数组的每个元素又是一维数组。用作指针时,a
的类型是int (*)[NUM_COLS]
(指向长度为NUM_COLS
的整型数组的指针)。
了解
a
指向的是a[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,类型为int *(编译器转换以后),所以这一次调用将正确地执行。
指针可以指向变长数组(8.3节)
中的元素,变长数组是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
时才是正确的:
int a[m][n], (*p)[m];
p = a;
如果m≠n
,后续对p
的使用都将导致未定义的行为。
与变长数组一样,变量修改类型也具有特定的限制,其中最重要的限制是,变量修改类型的声明必须出现在函数体内部或者在函数原型中。
变长数组中的指针算术运算和一般数组中的指针算术运算一样。回到12.4节
中那个对二维数组a
的一列进行清零操作的例子,这次将二维数组a
声明为变长数组:
int a[m][n];
指向数组a
中某行的指针可以声明为:
int (*p)[n];
把第i
列清零的循环几乎跟12.4节
中的完全一样:
for (p = a; p < a + m; p++)
(*p)[i] = 0;
问1:我不理解指针的算术运算。如果指针是地址,那么这是否意味着
p + j
这样的表达式是把j
加到存储在p
中的地址上呢?
答:不是的
。用于指针算术运算的整数需要根据指针的类型进行缩放。例如,如果p
的类型是int *
,那么p + j
通常给p
加上4 × j
(假定int
类型的值要用4
字节存储)。但是,如果p
的类型为double *
,那么p + j
可能给p
加上8 × j
,因为double
类型的值通常是8
字节长。
问2:编写处理数组的循环时,数组取下标和指针算术运算哪种更好一些呢?
答:这个问题不容易回答,因为答案与所使用的机器和编译器有关。对于早期PDP-11
机器上的C
语言,指针算术运算能生成更快的程序。如果在现在的机器上采用现在的编译器,数组取下标方法常常跟指针算术运算差不多,而且有时甚至会更好。底线是学习这两种方法,然后采用对你正在编写的程序更自然的方法。
问3:我在某些地方看到
i[a]
和a[i]
是一样的,这是真的吗?
答:是的,这是真的,确实很奇怪。对于编译器而言i[a]
等同于*(i + a)
,也就是*(a + i)
(像普通加法一样,指针加法也是可交换的)。而*(a + i)
也就是a[i]
。但是请不要在程序中使用i[a]
,除非你正计划参加下一届C
语言混乱代码大赛。
问4:为什么在形式参数的声明中
*a
和a[]
是一样的?
答:上述这两种形式都说明我们期望实际参数是指针。在这两种情况下,对a
可进行的运算是相同的(特别是指针算术运算和数组取下标运算)。而且,在这两种情况下,可以在函数内给a
本身赋予新的值。(C
语言要求数组变量的名字只能用作“常量指针”,但对于数组型形式参数的名字没有这一限制。)
问5:把数组型形式参数声明为
*a
和a[]
哪种风格更好呢?
答:这个问题很棘手。一种观点认为,因为*a
是不明确的(函数到底需要多对象的数组还是指向单个对象的指针?),所以a[]
更好是显而易见的。但是,许多程序员认为把形式参数声明为*a
更准确,因为它会提醒我们传递的仅仅是指针而不是数组的副本。有些人则根据具体情况在两种风格之间切换,切换的依据是函数是使用指针算术运算还是使用取下标运算来访问数组的元素的。(本书也采用这种方法。)在实践中,*a
比a[]
更常用,所以最好习惯于前者。不知道是真是假,听说现在Dennis Ritchie
把a[]
标记称为“活化石”,因为它“在使学习者困惑方面起的作用与在提醒程序阅读者方面所起的作用是相同的”。
问6:我们已经看到
C
语言中数组和指针之间的紧密联系。称它们是可互换的是否准确?
答:不准确
。数组型形式参数和指针形式参数是可以互换的,但是数组型变量不同于指针变量。从技术上说,数组的名字不是指针,C语言编译器会在需要时把数组的名字转换为指针。为了更清楚地看出两者的区别,思考对数组a
使用sizeof
运算符时会发生什么。sizeof(a)
的值是数组中字节的总数,即每个元素的大小乘以元素的数量。但是,如果p
是指针变量,那么sizeof(p)
的值则是用来存储指针值所需的字节数量。
问7:书上说把二维数组视为一维数组对“大多数”编译器而言是合法的。难道不是对所有编译器都合法吗?
答:不能说对所有编译器都合法
。一些现代的“越界检查”编译器不仅记录指针的类型,还会在指针指向数组时记录数组的长度。例如,假设给p
赋一个指向a[0][0]
的指针。从技术上讲,p
指向的是一维数组a[0]
的第一个元素。如果在遍历a
的所有元素的过程中反复对p
进行自增操作,当p
越过a[0]
的最后一个元素时我们就越界了。执行越界检查的编译器会插入代码,验证p
只能用于访问a[0]
指向的数组中的元素。一旦越过这个数组的边界,再对p
进行自增就会导致编译器报错。
问8:如果
a
是二维数组,为什么可以给find_largest
函数传递a[0]
而不是数组a
本身呢?a
和a[0]
不是都指向同一位置(数组开始的位置)吗?
答:它们确实指向同一位置,两者都指向元素a[0][0]
。问题是a的类型不对
。用作实际参数时,a
是一个指向数组的指针,但find_largest
函数需要指向整数的指针作为参数。a[0]
的类型为int *
,因此它可以作为find_largest
函数的实际参数。关于类型的这种考虑实际上是很好的,如果C
语言没这么挑剔,我们可能会犯各种各样编译器注意不到的指针错误。
本文是博主阅读《C语言程序设计:现代方法(第2版·修订版)》时所作笔记,日后会持续更新后续章节笔记。欢迎各位大佬阅读学习,如有疑问请及时联系指正,希望对各位有所帮助,Thank you very much!