(C语言)指针的初步了解与深入解析

本文详细解释了指针的概念,包括地址、不同类型指针的使用、指针与数组的联系(如整数加减、数组指针和指针数组),以及指针与函数的关联(传址调用、函数指针和函数指针数组)。还讨论了常见易错点,如野指针和强制转换的差异。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、什么是指针

        我们首先需要了解地址的概念。计算机的内存就像一个大酒店,里面的每一个房间都有特定的编号,相应的,内存由一个个存储单元组成,其中的每一个存储单元都有自己的地址,存储单元的大小一般是一个字节。计算机会为我们创建的每一个变量分配一块内存的空间用于存放变量的值,这些空间都由相邻的存储单元组成。变量的地址就是这块空间的第一个存储单元的地址。有了地址,计算机就能快速地找到对应的存储单元,以便于进行后续的操作。而指针变量就是用于存储这些地址的变量

        通过前面的讲解,我们知道每一个变量都有自己的地址。不同类型的地址需要用不同类型的指针来存储,例如整形变量的地址需要用整形指针存储,字符变量的地址需要用字符指针存储。一下便是整形指针存储整形的地址的代码,其中的&是取地址符号,故名思义,用于取出变量的地址。

89d78ffa22804bdd89f546ccd99764a5.png

其它类型的变量也是相似的原理,只需把上面的类型名更改一下即可。如果要通过指针操作相应的变量,要对指针使用解引用操作符 * 。如下。

1b882b31810a4110bc279e7cbf0afb97.png

       现在我们来探究不同类型的指针有什么区别。指针中存放的地址都是一样长的,但是不同类型的指针解引用后操作的存储单元个数(即字节数不同)。下面我用一个例子来进行说明,我们知道整形变量占四个字节,字符类型的变量占一个字节,如果要把0赋值给这两个变量,用整形指针可以将整形变量四个字节的内容都置为0,但是如果用字符指针操作整形变量,则只能将一个字节的内容置为0,代码如下:

dca76951d85e4c3cb6bc8535bc2fdf74.png

        我们打好断点开始调试,打开内存窗口并输入&a对变量a的变化进行监视,下图中的第一行即为变量a。每一行末尾的四个标点符号可以忽略,前面0x开头的16进制数字是变量a的地址,后面 f f 开头的是a的值,同样是16进制,由于是小端存储(即小的位数放前面),所以的16进制表示应为05 f5 e0 ff。每两格(如开头的 f f )占一个字节。

8ac3b98eb032401ca053b6aa731b6276.png

        当我们使用整形指针pa进行赋值操作时,我们可以看到a的每一位都被置为0,

94bd7a80ffcc452c807aa382d2b42b7f.png

        重新对a赋值并使用字符指针操作后,可以看到只有第一个存储单元的内容被置为0

ec3bad29348c44e4abb514ed0c845bfd.png

        不难发现不同类型指针操作的字节数是不同的,而操作的范围与该指针存储的地址指向的变量大小一致。

        在指针中有一类特殊的存在,那就是类型为 void* 的指针。这类指针可以存放指向任何类型的地址,但是使用时必须进行强制转换才能使用。这类指针一般用于一些函数的传参。

        前面提到,指针是存放地址的变量,所有的变量在内存中都有自己的空间,而每一块空间都有自己的地址。所以指针也有自己的地址。换言之,就像整形变量和浮点型变量等变量都有自己的地址一样,指针变量也有自己的地址,它同样可以存到其它指针变量中,得到二级指针。如下

其中ppa就是二级指针,对其二次解引用就可以得到变量a的值,如果只解引用一次,就可以得到变量pa的值,也就是a的地址。类似的,我们也可以定义三级指针,四级指针等。

二、指针与数组的联系

1.指针与整数的加减运算

        如果你已经掌握了数组和函数的知识,那么你肯定已经使用过指针了。在数组中,数组名即为数组首元素地址。例如下面这个简单的整形数组。

a45c4435fac0453794c0c67d754ebea7.png

        对于该数组而言,数组名 a 代表它的首元素地址,也就是存放元素 1 的地址。由于在内存中数组的相邻的元素的存储位置也是相邻的,而且地址的大小随着下标的增加而增大,所以我们可以通过首元素的地址访问在它之后的元素,只需给指针a加上要访问的元素的下标大小即可。例如以下代码

a5850eec03ec48a5944c66078006900d.png

        因为该数组是整形数组,数组名是指向首元素的整形指针,根据前面讲到的内容,每次操作访问四个存储单元(即4字节),而每个存储单元都有自己的地址,相邻的存储单元的地址大小的差为1,对数组名 a 加上1后,就得到一个比首元素地址多4的地址,这样就跳过了一个整形的大小,前面提到数组相邻的元素在内存中的存储也是相邻的,那么这个新的地址就是数组a第二个元素的地址,也就是元素 2 的地址,如果将数组名a加上2,那么将会跳过8个字节,以此类推。其它类型的数组是类似的道理。那么我们不难发现 *(a+1) 与 a[1] 是等价的。        

        指针可以与整数作加法,自然也可以作减法,我们用新的指针变量存放第二个元素的地址,减去1之后就得到了首元素的地址,如下:

f4cab73d0b384163b912d1050b932842.png

        我们可以看到”指针+整数=指针“,那么”指针-指针“自然就等于整数了,如下

a3f4643fb0bf418593f4b1b323be911f.png

        我们可以看到指针b指向数组a的第四个元素,减去数组名,也就是数组首元素的地址,就得到了指针的偏移量3,如果我们能拿到数组最后一个元素的地址,用它减去数组首元素的地址再加1就能得到数组的元素个数。此外要注意指针与指针的加法运算是没有意义的。

        指针加减整数有一个前提,那就是加减前后指针必须指向同一块空间,也就是像上面的例子那样,指针进行加减整数操作之后仍然指向同一个数组的某一元素,不能出现越界的情况,否则容易出现其它变量被意外修改之类的问题。

2.数组指针

        虽然说一般情况下数组名表示首元素地址,但有两种情况例外,一是mian函数里的sizeof(数组名),这里的sizeof()不会计算首元素地址的长度,而是计算整个数组的大小。第二种情况则是&数组名,它并不是取出首元素的的地址,而是取出整个数组的地址。用&数组名得到的地址可以赋值给数组指针,例如要用一个数组指针存放前面例子里的数组a,就可以像下面这样操作

e642c6257e834bfda8596815fe212792.png

其中 pa 是数值指针的变量名,int(* )[5]是它的类型名,表示这个指针指向一个整形数组,数组的元素个数为5,当然,元素个数可以省略。也可以对该类型名进行进一步的拆分,(*pa)表示这是一个名为pa的指针,而 int [5] 则是这个指针所指向的数据的类型,是一个元素个数为5的 int 类型的指针。

        现在我们研究数组指针加减整数,我们将数组指针 pa 加上1后强制转换为 int* 类型,再减去1,然后解引用后输出,可以看到输出的是数组a的最后一个元素5,这是为什么呢?

51a6a35915074c4ea24d376ef47df4b0.png

        首先我们要知道,数组指针pa中虽然存放的是整个数组的地址,但其本质仍然是数组首元素的地址,只是由于类型不同,操作的字节数不同。我们前面说过,指针进行操作时,操作的字节数与指针指向的类型的大小一致。这里的指针pa指向数组a,那么数组指针pa操作的大小与数组a的类型 int[5] 一致,那么进行加1操作后,一次跳过20个字节,自然就跳过了整个数组,从而指向数组之后的下一个存储单元(具体可参考下图,每一格表示四个字节的空间),那么被强制转换为 int*之后,操作个数变为4个字节,减1后就从数组之后的第一个存储空间转而指向该整形数组的最后一个元素。解引用之后就得到了5。

44c0c6fc653945dbbc0f2248a394080c.png

(注:这里的pa+1应该指向数字5右边的竖杠,因为无论指针指向的空间有多大,指针都只会存放第一个字节的地址。由于博主作图能力有限,重新作图较为繁琐,故只在此处提醒)

       

在二维数组中,数组名仍是其首元素地址,但二维数组的首元素是它里面的第一个数组,数组的地址自然就是数组指针。例如以下这段代码

f52225429aef4e2ebaba0361df14acdc.png

        对二维数组num的数组名加一后,得到的是数组num第二个元素的地址,也就是包含4,5,6的整个数组的地址,对其进行解引用,就得到了小数组的首元素4的地址,加2后就得到了元素6的地址。如果对数组指针解引用后是数组首元素地址感到不理解,可以用下面的方式理解:

对于变量a,我们进行取地址操作后再解引用,得到的仍是变量a。

那么对于数组名,它表示数组首元素地址,我们进行取地址操作后再解引用,得到的自然是数组首元素地址,我们知道取地址数组名得到的是数组指针,那么就可以得出这个结论:对数组指针解引用得到的是数组首元素地址。但要注意不能用二级指针存放数组指针。

3.指针数组

        指针是一种存放地址的变量,既然是变量,那就可以组成数组,由指针构成的数组就是指针数组(注意区分指针数组与数组指针,一个是数组,一个是指针)。不过同样只有同种类型的指针才能放在一个数组内,定义指针数组与定义其它数组只差了一个 * 号,如下

20a6019c221d4d9b86eebb13ad8ff46a.png

         定义其它类型的指针只需更换类型名即可,不过定义时要注意与数组指针区分开。这里的 * 号是与 int 结合的,表示数组中存放的是 int* 类型的元素,而数组指针中的 * 号是与指针名结合的,表示这是一个指针变量,具体可见前文。

       如果使用指针数组存放数组的地址,则可以得到一个类似二维数组的数组,如下

33eeeb605e5640d0adc9f053a7718ed9.png

我们将数组a,b,c的地址都放入该指针数组内,可以看到在调用时*(parr[1]+1)与parr[1][1]的效果是一样的,这个数组与二维数组有两个区别,一是二维数组的空间在内存中是连续的。假设有一个二维数组是这样定义的:int num [2][3]={{1,2,3},{4,5,6}}; 那么在内存中,元素 3 所在的空间后面紧接着就是元素 4 的空间。但在指针数组parr中,各个一维数组的空间就不一定是连续的了(如果这些一维数组的地址来自同一个二维数组的话,它们的空间就可以是连续的)。第二个区别是指针数组中各个数组的元素个数可以不同。例如数组a只有三个元素,而其它的有4个。

   上面的parr数组中的各个数组名前面加上 & 符号后不会影响输出结果,读者可以自行探求其原因,这里不再展开,注意数组指针进行加减整数操作时每次会跳过整个数组的大小。

4.const与指针

        通过对指针加上const关键字可以防止指针指向的内容被修改或防止指针本身被修改,具体跟const所处的位置是在 * 左边还是右边有关,具体如下。

int a=0,b=1;
const int* p = &a;    //不可通过p修改a,但可以修改p的指向
p=&b;

int* const pa = &a;    // pa不可修改,但可以通过pa修改a
*pa=2;

const int* const cpc= &a;  //都不能修改

三、指针与函数的联系

1.传址调用

   在C语言中,一个函数一次最多只能返回一个值,在函数中创建的所有局部变量都将被销毁。如果要在函数中一次修改多个主函数中的变量,除了使用数组之外,还可以用传址调用,也就是向函数传入变量的地址,通过地址修改变量的值,这样一来,函数结束后销毁的只有存放这些地址的指针。比如下面我们创建两个变量,用函数将它们的值同时改为10。

f8f7362472f444f3bce48f132b70e6d3.png

可以看到即使函数不返回任何值,一样能完成对主函数中的变量的赋值。

2.数组传参本质

        我们先看下面这段代码

a808609d5bc64c438528c084c0b0af9a.png

        看上去a 和b 的值都应该是数组a 的大小,但为什么一个是40一个是8呢?这就需要我们知道将数组传入函数的本质。函数在接收数据时,会开辟一块空间存放输入的数据,如果将每一个数据都接收会很浪费空间,所以函数在接收数组时,只会创建一个同类型的指针接收数组首元素地址。看似我们转入了一个数组,其实只是传了一个地址进去。在x86环境中,指针的大小是8个字节,所以在函数里面sizeof计算传入的数组大小得到的其实是指针的大小。因此,在数组传参中,我们也可以用指针来接收数组,如下

5bd557bc26454834833c00646e8262ac.png

        向函数传入二维数组时同样是传地址,但传的是数组指针,因为二维数组的首元素是一个数组,那么将二维数组的数组名传入函数,就相当于把数组的地址传入函数,如下

2a38a32b08034c3197b4d7b87bafd745.png

        也许你会觉得很奇怪,为什么传进去一个一维数组的指针,却能当二维数组使用?从简单的角度想,可以类比一维数组的传参,将一维数组传入函数只需要它所包含的元素的指针,比如int数组就只需传int*,而二维数组里面包含元素的是一维数组,将一维数组指针传入就能实现想函数传入二维数组。

        从严谨的角度想,要将一个指针 p 当作二维数组来使用,关键点在于p[ i ][ j ]要能访问到二维数组中第i+1行第j+1列的元素,换言之,就是要访问二维数组中  下标为 i 的一维数组里面  下标为 j 的元素。我们把p[ i ][ j ]看作(*(p+i) )[ j ],那么*( p+i )就必须是下标为 i 的一维数组,p是一个一维数组指针,p+i 就是跳过 i 个对应的一维数组的大小,指向其后面的空间,如果没有越界的话,其实就是二维数组里面下标为 i 的一维数组的指针,解引用后就是这个一维数组本身(两个都是一维数组首元素地址,只是操作的空间大小不同),这时再接上 [ j ]就是简单的一维数组访问了。 

3.函数指针

      除了变量有地址,函数也有地址,取出函数的地址可以用&函数名或者直接使用函数名,因为函数名表示的就是该函数的地址,但要注意取函数地址时函数名后面不能加括号,因为函数名后面的括号是函数调用操作符,调用函数时不能取它的地址,编译器会报错。如下,赋值符右边的部分出错,这里的 fun 函数即为上面我们讲二维数组传参时定义的函数。

635af196c0434af7a3705bacabc6c666.png

        函数指针的难点在于它的定义,从上图就可以看出函数指针的类型名非常复杂;我们逐步进行解析,首先我们先换个形参简单点的函数,如下

int Add(int a,int b){
    return a + b;
}
int main() {

    int(*p)(int,int) = &Add;

    int a = 0, b = 1;
    printf("%d",p(a, b));
    return 0;
}

        这样一来就能清晰地看到函数指针的各个部分,首先左起第一个int表示该指针指向的函数的返回类型是int,其次(*pf)表示这是一个名为pf的指针,最后的(int,int)表示该指针指向的函数接收的各个参数。其它类型的函数指针也是同理,只不过在返回类型和接收的参数个数和类型有所不同,我们只要将其像上面那样分成三段,就不难判断它的类型,如下,要注意第二行的第一个 * 在括号外面,它是与int 结合的,表示该指针指向的函数返回类型是int*。

c77795207182449886269165de25e5bb.png

        其中,函数指针pf2的参数部分就是函数指针pf1,这里我们也可以看到函数指针也可以传入函数,通过函数指针调用函数时可以不使用 * 符号,如果要加上的话会要将解引用符号与指针名用括号括起来,否则会将函数的返回值解引用。下面就是将函数指针解引用的方式

        下面这段程序的输出结果是0.400000,要解析这段代码需要将前面讲到的知识综合运用,留给读者作为练习

e9f3a2624cf84f0097f2f80720c1f66d.png

4.函数指针数组 与 函数指针数组指针

        前面我们提到函数也是有地址的,那么我们就可以将其存入指针变量,再将多个函数指针存入数组中,这样就构成了函数指针数组。我们来看下面的函数指针数组的定义

        上面定义的parr即为函数指针数组,其中 int 表示函数的返回类型,(void)表示函数接收的参数的类型,这里是void,也就是不接收, [2] 表示这是一个由两个元素构成的数组,(* parr )则表示数组的元素为指针。整体来看,就是一个存放了两个函数指针的数组,其中函数的返回值是int,接收的参数类型是viod。

         通过函数指针数组调用函数时只需在后面加上()并传入函数所需的参数即可,这里函数不接收参数,所以括号里面为空。

        如果数组中存放的函数指针的返回类型或接收的参数类型与数组定义时不同一般也不会报错(函数fun1 和fun2 的定义见本小节第一张图),但是不建议大家这样做,除了容易出错外,还影响代码的可读性。

        我们知道数组也是有地址的,虽然本质上仍是首元素的地址,但是操作的空间大小不同。我们可以取出函数指针数组,存入指针变量中,就得到了函数指针数组指针,它的定义较为复杂,不过我们可以回想一下其它的数组指针式怎么定义的,不难发现只需在变量名的位置多套一个(* )即可,如下

       指针是可以存入数组的,函数指针数组指针也是指针,同样可以存入数组中,那么既然是数组,那又可以将其存入一个指针中,这样的关系可以一直延伸下去, 当然,意义不大。

四、常见易错点

1.野指针

        通过指针我们可以修改变量,但如果指针指向了未知的空间,就会变成野指针。在程序运行的过程中,内存空间是动态分配的。以调用函数为例,在函数调用结束后,函数内部创建的所有局部变量的空间都会被回收,当程序需要时,内存会把这些空间分配出去,用于存储其它的变量或者调用函数等。但如果主函数中有指针指向了函数中的变量,当函数结束时,指针依然会指向同一块空间,这时该指针指向哪里我们无从得知,那么这个指针就变成了野指针,如果内存将这块空间分配给后面出现的变量,这个变量的值就很容易被野指针修改,导致出现意料之外的错误,并且这种bug是难以发现的,越是大型的程序,野指针就越危险。

        除了前面提到的指针指向函数中的局部变量,常见导致野指针出现的操作还有对数组越界访问和指向被 free 释放后的空间等,如果不知道要给指针赋值什么,可以先置空,也就是将NULL赋值给指针,表示指针不指向任何空间。

2.强制转换变量类型与强制转换指针类型的区别

观察下面这段代码

        变量b是将变量a强制转换成int类型然后赋值的,而变量c是将a的地址强制转换成int*类型然后解引用后赋值的。表面上看二者都是想将a转换成int类型以取出整数部分,但只有变量b成功了。原因在于浮点型数据和整形类型的数据存储方式和读取方式不同(感兴趣的读者可以自行搜索资料,这里不再展开),强制转换变量本身时,是将变量的值取出后进行转换,所有不会被存储方式影响,而强制转换指针时是将地址转换成所要的类型,这时原地址所指向的空间的数据会被当作新类型读取。

3.传址调用时改变形参中指针的指向

        我们知道函数的形参在函数调用结束后肯定是会被销毁的,它并不是调用函数时传入的参数本身,只是一份临时拷贝,也就是说是一个复制品。传址调用可以通过指针使函数修改主函数中的变量,因为它传入的是一个地址,但在函数结束后,用于接收地址的指针变量同样会被销毁,如果我们在函数中改变了形参中指针的指向,就会使传址调用失去意义。例如下面这段代码

        虽然将数组num的首元素地址传入了fun,但由于函数中的指针a的指向被改变,所以主函数中的数组并未更改,如果要在函数中更改主函数中指针的指向,可以使用二级指针。

       

对指针的讲解到此结束,感谢观看。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值