C语言的指针与数组

发布于 2022-09-12  36 次阅读


指针这一部分在传统C语言中应该算是比较难理解的一部分了,这篇文章就来讨论一下C语言中指针的用法,顺便再说说C++中对指针的扩展的特性。

什么是指针?

在介绍指针之前,先推荐一个网站:cppreference,在这个网站里面能找到C和C++的所有标准和特性。不过我给的连接是英文的,在网站下方可以找到中文站的入口。不过阅读这些材料还是推荐阅读英文原版。

在本篇内容当中,所有C和C++一致的内容,均以C语言为例。

首先介绍一下指针,简单来说,指针就是保存的一个内存地址。我们都知道,C/C++的变量在运行时需要保存到栈内存中,如果我们创建一个变量b,来保存a这个变量的值所在的内存地址,那么我们就可以说b是a的指针。

指针怎么用?

在这里,我们需要介绍两个运算符,其分别是&*。&符号在C语言中一般使用在变量名前面,其作用是取内存地址。也就是说,假如我们创建了一个变量int a=5,那么我们可以知道,a的值为5。如果我们在a前面加个&,则表示取a(也就是5这个值)在内存中的地址。也就是说,&a是一段内存地址,同样,我们也可以将其称为指针。

当然,我们可以把&a这个"内存地址"保存到一个变量中,这个变量我们称之为指针变量。指针变量的申请方式需要用到*符号,具体如:int *b,我们在申请完b这个指针变量时,就可以用它来保存&a了。下面用实际代码来进行说明:

1
2
3
4
5
6
7
8
#include <stdio.h>
int main() {
    int a = 5;      // 申请一个变量a
    int *b;         // 申请一个int型指针变量
   
    b = &amp;a;         // 用b来保存a值所在地址
    printf("%p\n", b);      // 看看b是多少
}

上面这段代码我们得到的运行结果为0x7fff535d85c4,顺便说一下,这段程序我是在菜鸟工具的在线C语言编辑器(下同)中运行的,使用的C11标准。

如果我们有一个指针变量(比如上面的b),想使用其值(对应的a的5),那么我们只需要使用*b即可。尝试运行下面这一段代码:

1
2
3
4
5
6
7
8
#include <stdio.h>
int main() {
    int a = 5;      // 申请一个变量a
    int *b;         // 申请一个int型指针变量
   
    b = &a;         // 用b来保存a值所在地址
    printf("%d\n", *b);     // 输出指针变量b指向的值
}

运行结果为5。

指针的类型

在上面的示例代码中,申请指针变量时我提到了int型指针这个概念。

实际上,指针变量也是变量,它和普通变量类似,也有自己的类型。不过有的同学可能会想,指针变量里面保存的不都是"指针"吗?指针不应该都是一个64位地址(在64位平台及操作系统下)吗?没错,指针确实都是一个64位地址,所以实际上,在申请指针变量时所描述的类型并非指针变量值的类型,而是指针变量里面保存的地址的数据的类型。

这么说可能会有一些抽象,我们还是以上面那段代码为例进行说明。了解C语言变量类型的同学都知道,int类型的范围是0x00000000到0xffffffff(32位,也就是-2的31次方到2的31次方,或是无符号的0到2的32次方)。而我们上面给出的例子,在最后输出的结果为0x7fff535d85c4,直接给干到了48位,远远超过了int的范围。这是因为在申请指针变量b时的int描述的并非b的类型,因为b一定是一个地址(指针),其描述的实际上是b地址的值的类型,在该例中也就是a(值为5)的类型。

在使用指针变量时,如果我们不指定数据类型,那么系统也不会知道这个指针的地址应该是int还是char还是long。因为在内存中,数据都是连续的,所以如果类型出现错误,刚好遇上两个变量在内存中的地址相邻,就很可能覆盖带下一个变量的地址。所以我们在申请指针变量时指定了所在内存位置的值的类型,才能避免上述情况的发生。

同样的道理,再举个例子。long number = 1234;那么如果我们要为这个变量申请一个指针变量,并将number在内存中的地址的值赋给它,我们需要这样创建:long *ptr_number = &number;如果我们创建的指针变量是int *ptr_number = &number;那么我们在使用这个指针时,就会把number当作一个int型的变量来使用,在内存中就只会操作其中32位。

有一种比较特殊的指针类型:无类型指针(void *),因为这个类型的用法比较特殊,所以我们放在后面单独用一节来讲。

NULL指针

如果在创建指针变量时,没有值可以赋给它,那么我们可以将其赋值为NULL,也就是int *ptr = NULL;赋值为NULL后,其地址为0。一般情况下来说,操作系统会认为指针为0的变量是不指向任何值的指针,所以我们也可以在一个指针使用完毕后将其赋值为NULL。

void指针

在C语言(以及其他很多语言)中,void表示无类型,比如在声明函数时使用void fun();则表示该函数无返回值类型,同样,在指针中void *表示无类型指针。

在上面我们提到了指针是又类型的(比如int,char之类的,但实际上也存在一种没有类型的指针,也就是void指针。void指针可以接受任何类型的指针。比如:

1
2
3
4
5
int a = 5;
void *b = &amp;a;

char c = 15;
b = &amp;c;

而在使用void指针时,需要先转换成相应的指针类型类型,比如:

1
2
3
4
5
int d = 10;

int *e;
void *f = &amp;d;
e = (int *)f;

C语言标准库中的内存分配函数malloc的返回值就是(void *),所以在调用这个函数时需要先转换成有类型的指针变量。

由于其特殊性,在对其进行使用时要特别小心,防止因类型错误出现问题。

指针运算操作

指针的内容本质上也是一个值(也可以理解成数字),所以,我们可以使用运算符对其进行运算操作。指针有四种运算符,分别为+、-、++、--。

这些符号与普通的数值操作的意思差不多,不过对于不同类型的指针来说,其运算有所不同。在普通的数值运算中,++和--表示自增和自减,在指针变量中也是类似,不过不同的是,指针变量的增减与其类型有关,每次增减的值是其类型的字节数。举个例子,有一个int类型的指针ptr,其初始值为1000,在执行完一次ptr++后,其值将变为1004。同样,如果ptr的类型是long *,则运行一次ptr++后将变成1008。其余的运算符也是同样的道理。这一点我们还会在介绍数组时再次介绍。

同样,指针还支持使用关系运算符(==、>、<)进行比较操作,这一点与数值运算相同,不再加以阐述。

值得注意的是,根据ANSI标准,void类型的指针无法进行运算操作。

数组

把数组和指针一起讲是因为这两者有些地方比较相似。

首先来复习一下数组的用法。首先是数组的声明方式,一般是按照这种规则:
类型 变量名[数组空间大小];
比如我需要申请一个能保存4个int类型数字的数组,就是如下语句:

1
int arr[4];

或者也可以直接在声明变量时赋值(也就是初始化),此时就不再需要单独写数组空间大小,如下:

1
int arr[] = {1, 2, 3, 4};

数组的访问党法为变量名[下标索引]。 不过需要注意的是,与大多数语言相同,C语言数组的下标索引也是从0开始。

以我们上面初始化的数组的代码为例,其数据在内存中的状态如下:

数据在内存中对应的位置

为了证实,我们可以尝试运行以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdlib.h>
 
int main() {
    int arr[] = {1, 2, 3, 4};
   
    printf("%p\n", &arr);
    printf("%p\n", &arr[0]);
    printf("%p\n", &arr[1]);
    printf("%p\n", &arr[2]);
    printf("%p\n", &arr[3]);
    return(0);
}

得到结果如下:

0x7ffd647e38f0
0x7ffd647e38f0
0x7ffd647e38f4
0x7ffd647e38f8
0x7ffd647e38fc

可以看到,实际上数组中每个元素占其类型相应大小的空间,比如int类型为4字节,所以每个元素占4字节空间。并且值得注意的是,直接对数组变量名取地址跟对下标为0的元素取地址得到的值是一样的,所以实际上0号元素所在位置就是该数组的起始位置。

按照上面那个例子,我们可以大胆假设数组的变量名看作该数组的"指针"。实际上,它也有很多地方跟指针类似,比如我们可以尝试运行下面这段代码(注意,下面这段代码开始到后面的内容可能会有点绕哦)

1
2
3
4
5
6
7
8
9
#include <stdio.h>
 
int main() {
    int arr[] = {1, 2, 3, 4};
   
    printf("%d\n", *arr);
   
    return(0);
}

运行这段代码,我们可以直接得到数组中的第一个元素1,这一点更是印证了我们之前的假设。

我们之前讲过指针变量的运算操作,那么我们可以尝试在数组中使用指针变量运算,根据上图的数据中对应内存位置,我们通过代码来尝试实现通过对arr运算索引出3,具体代码如下:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
 
int main() {
    int arr[] = {1, 2, 3, 4};
   
    printf("%d\n", *arr+2);
   
    return(0);
}

我们通过对arr变量加2,成功索引到了3。不过这里还需要注意一个优先级的问题,这里程序在运行时,是先对arr进行+2的操作,然后再取其值,用括号来表示优先级是这样的:*(arr+2),当然,在这段代码里面如果把*arr+2改成(*arr)+2同样能得到3,但是这次的3是1(通过arr索引得到)+2得到的3,大家可以尝试将数组中的3改成其他值再试试。

在数组中的变量运算与指针变量运算相同,以上面的代码为例,其+2并非真正加2,而是增加了两个元素的大小,其中每个元素(int类型)四字节,加2就是加了8字节,这一点与指针变量运算相同,大家可以尝试运行以下代码来验证。

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
 
int main()
{
    int arr[] = {1, 2, 5, 4};
   
    printf("%p\n", arr);
    printf("%p\n", arr+2);
   
    return(0);
}

得到结果如下:

0x7ffefef0b330
0x7ffefef0b338

可以看到,加2以后比之前正好多8。

所以实际上,数组和指针有很多相似的地方。

指针数组与多重指针

上面我们简单地介绍了一下指针和数组。了解了指针与数组后,我们可以稍加思考:如果一个数组里面存的都是指针会怎样?

这一节,我们就来讨论一下这个问题。

指针数组

实际上,我们可以直接声明一个指针类型的数组,声明方法与常规数组一样:int *ptr_arr[10];这样我们就申请了一个10个元素大小的数组空间,其中每个元素就是一个指针,也就是说,这个数组占总内存空间 10×8字节(64位地址)=80字节。

我们可以利用这个特点,来创建一个多维数组,也就是说,一个数组里的指针指向的是一另个数组,以下面代码为例:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int main() {
    int a1[] = {9, 8, 7};
    int a2[] = {4, 5, 6};
    int a3[] = {1, 2, 3};
   
    int *arr[] = {a1, a2, a3};
   
    printf("%d",arr[1][1]);
}

通过这段代码,我们可以将a2中的第二个元素(其下标为1)给索引出来。

同样,根据我们前面提到的指针变量运算,来尝试一些比较奇怪的写法,将上面的代码中的arr[1][1]修改为如下代码,可以得到同样的效果:

1
*arr[1]+1

大家可以自己去尝试一下。不过我们还可以基于这一段代码进一步魔改,如下:

1
*(*(arr+1)+1)

到这一步,我们整个语句中已经不存在方括号了,而出现了两个*,接下面我们就来研究研究这个包含两个*的语句。

多重指针

实际上,我们可以让一个指针指向另一个指针,我们一般将其称为多重指针
其形式如 int **ptr;

其实在刚才所说的指针数组从某种意义上来说就是一种多重指针。根据我们之前在数组一节的实验来看,数组几乎可以直接当作指针来使用,而上面的例子中,数组保存的都是指向其他地址的指针,所以刚才我们可以使用形如*(*(arr+1)+1)的语句来索引多维数组(指针数组)中的某一个值。

这个图来自菜鸟教程,我懒得做图了