首页 > 编程笔记

指针与数组的关系,C语言指针和数组的关系详解

指针和数组的关系是比较高级的内容。本节,我们主要讨论指针和一维数组的关系。二维数组本身用得就很少,指针和二维数组的关系用得就更少了。指针和二维数组的关系我们后面也会讲。

指针和一维数组的关系很重要。把这个问题弄清楚了,前面的很多问题就都明白了。比如数组为什么是连续的,为什么需要连续,数组的下标是什么意思,到底什么是一维数组等。

用指针引用数组元素

引用数组元素可以用“下标法”,这个在前面已经讲过,除了这种方法之外还可以用指针,即通过指向某个数组元素的指针变量来引用数组元素。

数组包含若干个元素,元素就是变量,变量都有地址。所以每一个数组元素在内存中都占有存储单元,都有相应的地址。指针变量既然可以指向变量,当然也就可以指向数组元素。同样,数组的类型和指针变量的基类型一定要相同。下面给大家写一个程序:
# include <stdio.h>
int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int *p = &a[0];
    int *q = a;
    printf("*p = %d, *q = %d\n", *p, *q);
    return 0;
}
输出结果是:
*p = 1, *q = 1

程序中定义了一个一维数组 a,它有 5 个元素,即 5 个变量,分别为 a[0]、a[1]、a[2]、a[3]、a[4]。所以 p=&a[0] 就表示将 a[0] 的地址放到指针变量 p 中,即指针变量 p 指向数组 a 的第一个元素 a[0]。而 C 语言中规定,“数组名”是一个指针“常量”,表示数组第一个元素的起始地址。所以 p=&a[0] 和 p=a 是等价的,所以程序输出的结果 *p 和 *q 是相等的,因为它们都指向 a[0],或者说它们都存放 a[0] 的地址。

那么 a[0] 的地址到底是哪个地址?“数组名表示的是数组第一个元素的起始地址”这句话是什么意思?“起始地址”表示的到底是哪个地址?

我们知道,系统给每个int型变量都分配了 4 字节的内存单元。数组 a 是 int 型的,所以数组 a 中每一个元素都占 4 字节的内存单元。而每字节都有一个地址,所以每个元素都有 4 个地址。那么 p=&a[0] 到底是把哪个地址放到了 p 中?

答案是把这 4 个地址中的第一个地址放到了 p 中。这就是“起始”的含义,即第一个元素的第一字节的地址。我们将“数组第一个元素的起始地址”称为“数组的首地址”。数组名表示的就是数组的首地址,即数组第一个元素的第一字节的地址。

注意,数组名不代表整个数组,q=a 表示的是“把数组 a 的第一个元素的起始地址赋给指针变量 q”,而不是“把数组 a 的各个元素的地址赋给指针变量 q”。

指针的移动

那么如何使指针变量指向一维数组中的其他元素呢?比如,如何使指针变量指向 a[3] 呢?

同样可以写成 p=&a[3]。但是除了这种方法之外,C 语言规定:如果指针变量 p 已经指向一维数组的第一个元素,那么 p+1 就表示指向该数组的第二个元素。

注意,p+1 不是指向下一个地址,而是指向下一个元素。“下一个地址”和“下一个元素”是不同的。比如 int 型数组中每个元素都占 4 字节,每字节都有一个地址,所以 int 型数组中每个元素都有 4 个地址。如果指针变量 p 指向该数组的首地址,那么“下一个地址”表示的是第一个元素的第二个地址,即 p 往后移一个地址。而“下一个元素”表示 p 往后移 4 个地址,即第二个元素的首地址。下面写一个程序验证一下:
# include <stdio.h>
int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int *p = a;
    printf("p = %d, p + 1 = %d\n", p, p+1);    //用十进制格式输出
    printf("p = %#X, p + 1 = %#X\n", p, p+1);  //也可以用十六进制格式输出
    printf("p = %p, p + 1 = %p\n", p, p+1);    //%p是专门输出地址的输出控制符
    return 0;
}
输出结果是:
p = 1638196, p + 1 = 1638200
p = 0X18FF34, p + 1 = 0X18FF38
p = 0018FF34, p + 1 = 0018FF38

我们看到,p+1 表示的是地址往后移 4 个。但并不是所有类型的数组 p+1 都表示往后移 4 个地址。p+1 的本质是移到数组下一个元素的地址,所以关键是看数组是什么类型的。比如数组是 char 型的,每个元素都占一字节,那么此时 p+1 就表示往后移一个地址。所以不同类型的数组,p+1 移动的地址的个数是不同的,但都是移向下一个元素。

知道元素的地址后引用元素就很简单了。如果 p 指向的是第一个元素的地址,那么 *p 表示的就是第一个元素的内容。同样,p+i 表示的是第 i+1 个元素的地址,那么 *(p+i) 就表示第 i+1 个元素的内容。即 p+i 就是指向元素 a[i] 的指针,*(p+i) 就等价于 a[i]。

下面写一个程序验证一下:
# include <stdio.h>
int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int *p, *q, *r;
    p = &a[3];  //第一种写法
    printf("*p = %d\n", *p);
    q = a;  //第二种写法
    q = q + 3;
    printf("*q = %d\n", *q);
    r = a;  //第三种写法
    printf("*(r+3) = %d\n", *(r+3));
    return 0;
}
输出结果是:
*p = 4
*q = 4
*(r+3) = 4

注意,*(q+i) 两边的括号不能省略。因为 *q+i 和 *(q+i)是不等价的。指针运算符“*”的优先级比加法运算符“+”的优先级高。所以 *q+i 就相当于 (*q)+i 了。

前面讲过,指针和指针只能进行相减运算,不能进行乘、除、加等运算。所以不能把两个地址加起来,这是没有意义的。所以指针变量只能加上一个常量,不能加上另一个指针变量。
那么现在有一个问题:“数组名 a 表示数组的首地址,a[i] 表示的是数组第 i+1 个元素。那么如果指针变量 p 也指向这个首地址,可以用 p[i] 表示数组的第 i 个元素吗?”可以。下面写一个程序看一下:
# include <stdio.h>
int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int *p = a;
    printf("p[0] = %d\n", p[0]);
    printf("p[1] = %d\n", p[1]);
    printf("p[2] = %d\n", p[2]);
    printf("p[3] = %d\n", p[3]);
    printf("p[4] = %d\n", p[4]);
    return 0;
}
输出结果是:
p[0] = 1
p[1] = 2
p[2] = 3
p[3] = 4
p[4] = 5

所以 p[i] 和 *(p+i) 是等价的,即“指向数组的”指针变量也可以写成“带下标”的形式。

那么反过来,因为数组名 a 表示的也是数组的首地址,那么元素 a[i] 的地址也可以用 a+i 表示吗?回答也是“可以的”。也就是说如果指针变量 p 指向数组 a 的首地址,那么 p+i 和 a+i 是等价的,它们可以互换。下面也写一个程序验证一下:
# include <stdio.h>
int main(void)
{
    int a[] = {2, 5, 8, 7, 4};
    int *p = a;
    printf("*(p+3) = %d, *(a+3) = %d\n", *(p+3), *(a+3));
    return 0;
}
输出结果是:
*(p+3) = 7, *(a+3) = 7

这时有人说,a 不是指针变量也可以写成“*”的形式吗?只要是地址,都可以用“*地址”表示该地址所指向的内存单元中的数据。而且也只有地址前面才能加“*”。因为指针变量里面存放的是地址,所以它前面才能加“*”。

实际上系统在编译时,数组元素 a[i] 就是按 *(a+i) 处理的。即首先通过数组名 a 找到数组的首地址,然后首地址再加上i就是元素 a[i] 的地址,然后通过该地址找到该单元中的内容。

所以 a[i] 写成 *(a+i) 的形式,程序的执行效率会更高、速度会更快。因为在执行程序的时候数组是先计算地址,然后转化成指针。而直接写成指针 *(a+i) 的形式就省去了这些重复计算地址的步骤。

指针变量的自增运算

前面说过,p+1 表示指向数组中的第二个元素。p=p+1 也可以写成 p++ 或 ++p,即指针变量也可以进行自增运算。当然,也可以进行自减运算。自增就是指针变量向后移,自减就是指针变量向前移。下面给大家写一个程序:
# include <stdio.h>
int main(void)
{
    int a[] = {2, 5, 8, 7, 4};
    int *p = a;
    printf("*p++ = %d, *++p = %d\n", *p++, *++p);
    return 0;
}
输出结果是:
*p++ = 5, *++p = 5

因为指针运算符“*”和自增运算符“++”的优先级相同,而它们的结合方向是从右往左,所以 *p++ 就相当于 *(p++),*++p 就相当于 *(++p)。但是为了提高程序的可读性,最好加上括号。

在程序中如果用循环语句有规律地执行 ++p,那么每个数组元素就都可以直接用指针指向了,这样读取每个数组元素时执行效率就大大提高了。为什么?比如:
int a[10] = {0};
int *p = a;

如果执行一次 ++p,那么此时 p 就直接指向元素 a[1]。而如果用 a[1] 引用该元素,那么就先要计算数组名 a 表示的首地址,然后再加 1 才能找到元素 a[1]。同样再执行一次 ++p,p 就直接指向元素 a[2]。而如果用 a[2] 引用该元素,那么还要先计算数组名 a 表示的首地址,然后再加 2 才能找到元素 a[2]……

所以,用数组的下标形式引用数组元素时,每次都要重新计算数组名 a 表示的首地址,然后再加上下标才能找到该元素。而有规律地使用 ++p,则每次 p 都是直接指向那个元素的,不用额外的计算,所以访问速度就大大提高了。数组长度越长这种差距越明显!所以当有大批量数据的时候,有规律地使用 ++p 可以大大提高执行效率。

在前面讲数组时写过这样一个程序:
# include <stdio.h>
int main(void)
{
    int a[5] = {1, 2, 3, 4, 5};
    int i;
    for (i=0; i<5; ++i)
    {
        printf("%d\n", a[i]);
    }
    return 0;
}
输出结果是:
1
2
3
4
5

用指针的方法实现一下:
# include <stdio.h>
int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int *p = NULL;  //先初始化, 好习惯
    for (p=a; p<(a+5); ++p)
    {
        printf("%d\n", *p);
    }
    return 0;
}
输出结果是:
1
2
3
4
5

指针还可以用关系运算符比较大小,使用关系运算符来比较两个指针的值的前提是两个指针具有相同的类型。

既然 p+i 和 a+i 等价,那么能不能写成 a++?答案是“不能”。在前面讲自增和自减的时候强调过,只有变量才能进行自增和自减,常量是不能进行自增和自减的。a 代表的是数组的首地址,是一个常量,所以不能进行自增,所以不能写成a++。

下面再来写一个程序,编程要求:实现从键盘输入 10 个整数,然后输出最大的偶数,要求使用指针访问数组。
# include <stdio.h>
int main(void)
{
    int a[10] = {0};
    int *p = a;
    int max;
    int i;  //循环变量
    int flag = 1;  //标志位
    printf("请输入十个整数:");
    for (i=0; i<10; ++i)
    {
        scanf("%d", p+i);  //不要用&p[i], 不要出现数组的影子
    }
    for (; p<a+10; ++p)
    {
        if (0 == *p%2)  
        {
            if (flag)  //将第一个偶数赋给max;
            {
                max = *p;
                flag = 0;
            }
            else if (*p > max) //寻找最大的偶数
            {
                max = *p;
            }
        }
    }
    if (!flag)  /*这句很经典, 如果flag一直都是1, 那么说明没有一个是偶数*/
    {
        printf("最大的偶数为:%d\n", max);
    }
    else
    {
        printf("未发现偶数\n");
    }
    return 0;
}
输出结果是:
请输入十个整数:-33 -26 15 38 74 59 31 -2 27 36
最大的偶数为:74

两个参数确定一个数组

前面讲过,在函数调用时如果要将一个数组从主调函数传到被调函数,只需要传递两个参数就能知道整个数组的信息。即一维数组的首地址(数组名)和数组元素的个数(数组长度)。

但是当时还没有讲指针,所以形参定义的是数组。本节我们再将这个问题复习一下,并将形参改用指针来接收数组名。因为指针变量中存放的是地址,而数组名表示的就是一个地址,所以在形参中可以直接定义一个指针变量来接收数组名。

下面来写一个程序,把“输出一维数组所有元素”的功能写成函数,然后在主函数中进行调用:
# include <stdio.h>
void Output(int *p, int cnt);  //声明一个输出数组的函数
int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int b[] = {-5, -9, -8, -7, -4};
    Output(a, 5);
    Output(b, 5);
    printf("\n");
    return 0;
}
/*定义一个输出数组的函数*/
void Output(int *p, int cnt)  /*p用来接收首地址, cnt用来接收数组元素的个数*/
{
    int *a = p;
    for (; p<(a+cnt); ++p)  //数组地址作为循环变量
    {
        printf("%d  ", *p);
    }
}
输出结果是:
1  2  3  4  5  -5  -9  -8  -7  -4

程序中,之所以要传递数组的长度是因为在 C 语言中没有一个特殊的标记可以作为数组的结束标记。因为数组是存储数据的,任何数据都可以存到数组中,所以数组里面任何一个值都是有效的值。不仅是C语言,任何一门语言都无法用某一个值作为数组结束的标记。所以“数组长度”这个参数是必需的。

上面这个程序是以“数组地址”作为被调函数的循环变量,也可以改成以“数组个数”作为被调函数的循环变量:
# include <stdio.h>
void Output(int *p, int cnt);  //声明一个输出数组的函数
int main(void)
{
    int a[] = {1, 2, 3, 4, 5};
    int b[] = {-5, -9, -8, -7, -4};
    Output(a, 5);
    Output(b, 5);
    printf("\n");
    return 0;
}
/*定义一个输出数组的函数*/
void Output(int *p, int cnt)  /*p用来接收首地址, cnt用来接收数组元素的个数*/
{
    int i;
    for (i=0; i<cnt; ++i)  //数组元素个数作为循环变量
    {
        printf("%d  ", *(p+i));
    }
}
输出结果是:
1  2  3  4  5  -5  -9  -8  -7  -4

指针变量占多少字节

我们讲过,指针变量根据“基类型”的不同有 int* 型、float* 型、double* 型、char* 型等。但是前面在讲数据类型的时候讲过,int 型变量占 4 字节,float 型变量占 4 字节、double 型变量占 8 字节、char 型变量占 1 字节。那么“指针型变量”占几字节?是不是基类型占几字节,该指针变量就占几字节?同样,用 sizeof 写一个程序看一下就知道了:
# include <stdio.h>
int main(void)
{
    int    *a = NULL;
    float  *b = NULL;
    double *c = NULL;
    char   *d = NULL;
    printf("%d %d %d %d\n", sizeof(a), sizeof(b), sizeof(c), sizeof(d));
    return 0;
}
输出结果是:
4 4 4 4

可见,不管是什么基类型,系统给指针变量分配的内存空间都是 4 字节。在 C 语言中,只要是指针变量,在内存中所占的字节数都是 4。指针变量的“基类型”仅用来指定该指针变量可以指向的变量类型,并没有其他意思。不管基类型是什么类型的指针变量,它仍然是指针变量,所以仍然占 4 字节。

我们前面讲过,32 位计算机有 32 根地址线,每根地址线要么是 0 要么是 1,只有这两种状态。内存单元中的每个地址都是由这 32 根地址线通过不同的状态组合而成的。而 4 字节正好是 32 位,正好能存储下所有内存单元的地址信息。少一字节可能就不够,多一字节就浪费,所以是 4 字节。

推荐阅读