C语言梦开始的地方9:初识指针 文章目录C语言梦开始的地方9初识指针内存和地址指针变量和地址取地址定义指针变量通过指针访问数据指针变量的大小指针变量类型的意义指针的解引用指针 - 整数void* 指针const修饰指针const 修饰变量const 修饰指针变量指针运算指针 - 指针指针的关系运算野指针野指针成因规避野指针assert断言指针的使用和传址调用传值调用和传址调用C语言梦开始的地方9初识指针上一章我们了解了 static 和 extern知道了如何控制变量的可见范围和生命周期。从本章开始我们将正式踏入C语言最核心、最强大、也最让初学者头疼的领域——指针。有人说指针是C语言的灵魂也有人说指针是C语言最难啃的骨头。但无论如何指针就是你通往C语言高手之路的必经关卡。内存和地址在了解指针之前我们先要了解内存和地址这样有助于我们对指针的了解。内存是什么地址又是什么内存是计算机中用于存储程序和数据的硬件可以看作一个巨大的字节数组。每个字节都有一个唯一的编号这个编号就是该字节的内存地址。地址从0开始按字节递增32位系统地址范围0x00000000 ~ 0xFFFFFFFF64位更大。每个变量在编译后都会分配一块内存空间其首字节的地址被称为该变量的地址。通过地址CPU可以找到并操作内存中的具体数据。类比内存就像一条街道每个门牌号地址对应一个住户字节而变量可能占用连续几个门牌号如int占4个字节。补充一个计算机的小知识bit - 比特位 1Byte 8bit Byte - 字节 1KB 1024Byte KB 1MB 1024KB MB 1GB 1024MB GB 1TB 1024GB TB 1PB 1024TB PBbit是计算机最小单位Byte是计算机基本单位 其中每个内存单元相当于一个学生宿舍一个字节空间里面能放8个比特位就好比同学们住的八人间每个人是一个比特位。每个内存单元也都有一个编号这个编号就相当于宿舍房间的门牌号有了这个内存单元的编号CPU就可以快速找到一个内存空间。生活中我们把门牌号也叫地址在计算机中我们把内存单元的编号也称为地址。C语言中给地址起了新的名字叫指针所以我们可以理解为内存单元的编号 地址 指针指针变量和地址取地址上面我们说到内存单元的编号 地址 指针。那么问题来了指针既然是一个编号地址我们怎么把它存起来答案是用指针变量来存。指针变量就是用来存放地址指针的变量。它本身也是一个变量只不过它存的值比较特殊——存的是另一个变量的地址。那么C语言中如何去地址呢C语言中使用运算符取地址运算符来获取一个变量地址。#includestdio.hintmain(){inta10;// 打印变量 a 的值printf(a 的值%d\n,a);// 打印变量 a 的地址printf(a 的地址%p\n,a);return0;}每次运行时这个地址编号可能不同因为操作系统分配的内存位置不固定但有一点是确定的变量 a 确实有一个唯一的地址编号。定义指针变量既然 a 拿到了 a 的地址我们得找个变量把它存起来这就是指针变量。定义指针变量的语法格式类型* 指针变量名;比如我们要存 int 类型变量的地址就定义一个 int* 类型的指针变量intmain(){inta10;int*pa;printf(a 的地址%p\n,a);printf(p 中存的值: %p\n,p);return0;}程序运行后我们发现p存储的值和a的地址是一样的注意int* p 中的 * 表示 p 是一个指针变量int 表示 p 指向的是一个 int 类型的变量。后面我们会详细讲为什么指针还要分类型。通过指针访问数据单纯拿到地址还不够我们还得能通过地址找到那个变量拿到里面的值。这就要用到*运算符解引用运算符也叫间接访问运算符。#includestdio.hintmain(){inta10;int*pa;// 通过 p 拿到 a 的值printf(%d\n,*p);// 通过 p 修改 a 的值*p20;printf(%d\n,a);// 输出 20return0;}这里*p的意思找到p所指向的地址中对应的那个变量就相当于变相的操作那个变量。需注意的是如果指针指向的是常量就不能去随意操作因为常量本身不能修改能修改的就不叫常量。指针变量的大小指针变量存的是地址那它占多少字节呢这完全取决你的电脑是多少位一般电脑是32位和64位64位兼容32位32位不兼容64位系统地址总线位数地址范围指针大小32位系统32位0 ~ 2³²-14 个字节64位系统640 ~ 2⁶⁴-18个字节为什么因为指针存的就是地址编号地址编号的范围决定了需要用多少位来表示。32位系统地址编号最大到 2³²-1需要 32 个 bit 来存也就是 4 个字节。#includestdio.hintmain(){printf(%zd\n,sizeof(char*));printf(%zd\n,sizeof(short*));printf(%zd\n,sizeof(int*));printf(%zd\n,sizeof(double*));return0;}注意指针变量的大小和类型是无关的只要指针类型的变量在相同的平台下大小都是相同的32位平台就是4字节64位平台就是8字节指针变量类型的意义指针变量的大小和类型无关只要是指针变量在同一个平台下大小都是一样的为什么还要有各种各样的指针类型呢其实指针类型是有特殊意义的我们继续了解指针变量类型的意义指针的解引用我们先看下面两个代码:调试我们可以看到代码1会将a的4个字节全部改为0但是代码2只是将a的第一个字节改为0。所以指针的类型决定了对指针解引用的时候有多大的权限一次能操作几个字节。比如char* 的指针解引用就只能访问一个字节而 int* 的指针的解引用就能访问四个字节。指针 - 整数指针的 - 整数当同指针类型时/- 1时等价于 一维数组的/- 1当非同类型即强制类型转换时比如原本是int类型的指针变量 强制转换成char类型的指针变量就会从原来的1跳过4个字节变成跳过1个字节。#includestdio.hintmain(){intarr[5]{1,2,3,4,5};int*parr;// 在C语言中数组名在表达式中通常被隐式转换为指向其首元素的指针。printf(*(p1):%d\n,*(p1));char*c(char*)arr;printf(*(c1):%d\n,*(c1));return0;}*(p 1) 是等价于数组的–的所以打印结果为2c 指向 arr 的首地址即第一个字节。假设系统为小端序x86 常用arr[0] 1 在内存中存储为 01 00 00 00低字节在前。c1 指向第二个字节该字节值为 0。解引用得到 char 类型的 0打印为 %d 时提升为 int所以输出 0。大端是 00 00 00 01所以那怕是大端序也是这样的。那么这里又衍生出了一个新的知识点小端和大端序。什么是小端序什么是大端序呢小端和大端序单纯是计算机一种读取内存的方式小端低地址存 小低字节大端低地址存 大高字节。那么怎么区分当前系统是大端还是小端呢很简单就像上一个例子一样我们给一个int num 1; 强制转换成char* 打印出1就是小端0就是大端。注意指针加减整数常用于数组元素的遍历注意不要越界。#includestdio.hintmain(){intnum1;char*pc(char*)num;printf(%d\n,*pc);return0;}void* 指针void我们知道是无返回值的而void* 是一种特殊的指针类型称为“无类型指针”或“泛型指针”。它可以接收任意类型的地址但不能直接解引用也不能直接进行加减整数运算因为不知道步长。void* 我们可以认为是一种垃圾桶不管什么类型的指针变量它都可以接受。这也就是为什么很多C语言标准库中的函数返回值是void的原因了当你不知道或者你要这个能接受大部分类型时就可以给void#includestdio.hintmain(){inta10;doubleb3.14;void*vpa;// 合法vpb;// 同样合法// printf(%d\n, *vp); // 错误void* 不能直接解引用// vp vp 1; // 错误void* 不能直接加减整数// 必须强制转换为具体类型后再使用int*ip(int*)vp;printf(%d\n,*ip);// 若 vp 实际指向 double强制转为 int* 再解引用是未定义行为应确保类型匹配。return0;}const修饰指针const 修饰的变量其值不能被程序直接修改。它更像一个“契约”告诉编译器和程序员这个变量是只读的如果代码试图去修改它编译器会报错。const 修饰变量const 修饰的变量称为常变量其值不能被修改但可以通过指针等方式间接尝试修改这是未定义行为。intmain(){constintnum10;num20;// 错误num 是只读的return0;}注意const修饰的变量是常变量或者说是只读变量不是常量不能用于数组上。但是有程序可以通过是因为c99有一个新的语法叫变长数组但是这样num就失去了初始化只能通过数组的输入来赋值。const 修饰指针变量指向常量的指针const int *p;或int const *p;指针指向的数据是只读的不能通过*p修改它。指针本身可以指向其他变量。常量指针int * const p;指针本身的值地址是只读的不能再指向其他地方。可以通过*p修改指向的数据。指向常量的常量指针const int * const p;指针指向的数据和指针本身都不可修改。怎么了解呢你就认为 *p 和 p是前后桌关系const就是老师老师在p后面时p敢和明目张胆的搞小动作吗很明显不敢那么这也是为什么 *p 可以修改因为不在我后面我肯定敢搞小动作同理当const *p在 *p后面 *p敢搞小动作吗那也肯定不敢啊。p同上。那么两个const不就是两个老师分别看着 *p 和 p吗#includestdio.hintmain(){inta10,b20;constint*p1a;// *p1 30; // 错误不能通过 p1 修改 ap1b;// 正确int*constp2a;*p230;// 正确// p2 b; // 错误p2 不能指向别处constint*constp3a;// *p3 30; // 错误// p3 b; // 错误}指针运算指针 - 指针当两个指针指向同一数组或同一块连续内存时指针相减的结果是两个地址之间元素的个数类型由指针类型决定其类型为 ptrdiff_t定义在 stddef.h 中通常为有符号整数。#includestdio.hintmain(){intarr[5]{1,2,3,4,5};int*parr[1];int*qarr[4];// 类似sizeof计算数组大小intdiffq-p;// diff 3因为 arr[4] 与 arr[1] 相隔 3 个 intprintf(元素个数差%d\n,diff);return0;}若两个指针指向不同数组或不同 malloc 块相减是未定义行为即使它们地址数值上的差值有意义也不应依赖。指针的关系运算指针可以进行关系运算, , , , , !但仅当两个指针指向同一数组或同一块连续内存的元素或其末尾之后的一个位置时比较结果才有意义。#includestdio.hintmain(){intarr[5]{1,2,3,4,5};int*parr;int*qarr[4];while(pq){// 合法且安全因为 p 和 q 指向同一数组printf(%d ,*p);p;}}那么我们再看下面代码#includestdio.hintmain(){charstr[]hello;// 唯一的数组char*p1str;// 第一个指针指向该数组char*p2str;// 第二个指针也指向该数组同一个地址if(p1p2){printf(p1 和 p2 指向同一个地址条件成立\n);printf(p1 %p, p2 %p\n,p1,p2);}else{printf(条件不成立\n);}// 为了对照再回顾一下两个不同数组的情况charstr3[]hello;charstr4[]hello;if(str3str4){printf(str3 和 str4 地址相同\n);}else{printf(str3 和 str4 是两个不同的数组地址不同: %p vs %p\n,str3,str4);}return0;}这里p1 和 p2相等因为指向同一块内存str3 和 str4不相等因为指向不同的内存。我们可以认为p1 和 p2 是克隆关系他们别无二致str3 和 str4是双胞胎单纯只是长大一样但是他们的习惯和性格基本是不一样的。野指针野指针就是指针指向的位置是不可知的随机的、不正确的、没有明确限制的。就像路边的野狗野猫被咬了只能自认倒霉但是如果是有主人的就可以通过法律维权获取赔偿野指针成因未初始化局部指针变量未赋初值其值是随机的。越界访问指针操作超出了合法内存范围如数组越界。释放/销毁后未置空free§ 或函数返回局部变量的地址后指针仍然指向已失效的内存。int*p;// 未初始化野指针*p10;// 危险intarr[3];int*qarr3;// 指向末尾之后可以比较但不能解引用*q100;// 危险越界访问int*func(){intx10;returnx;// 返回局部变量地址函数返回后 x 失效}int*rfunc();*r20;// 危险悬挂指针规避野指针声明指针时初始化为 NULL。遍历结束后指针越界立即置 NULL 避免悬空确保指针指向有效内存后再使用。free( p ) 后立即将 p NULL;。不要返回局部变量的地址。对数组下标进行边界检查。使用静态/动态分析工具如 Valgrind、AddressSanitizer检测野指针。#includestdio.hintmain(){intarr[10]{1,2,3,4,5,6,7,8,9,10};int*parr[0];inti0;for(i0;i10;i){*(p)i;}// 此时p已经越界了可以把p置为NULLpNULL;// 下次使用的时候判断p不为NULL的时候再使用parr[0];// 重新让p获得地址if(p!NULL)// 判断{//...}return0;}assert断言assert 是 assert.h 提供的宏用于在运行时检查条件是否为真。若条件为假程序终止并输出错误信息文件名、行号、表达式。细心的就会发现assert和if是一样的那么为什么C语言要提供assert呢assert是直接中止程序并且告诉程序员在那个文件的哪一行好查找if是报错加程序员自己给的exit信息。它两最大的区别就是assert是一种更严厉的父母当孩子犯错后就是通过严厉的惩罚让孩子记住这个错误if是一种温柔的父母当孩子犯错了只会跟孩子说哪里出错了不会惩罚但是有的人就说了有的时候使用if程序不也退出了。这是因为if本身不能终止程序终止程序的是程序员的后续操作与if操作无关。#includeassert.h#includestdio.hintdivide(inta,intb){assert(b!0);// 若 b0程序崩溃returna/b;}注意事项assert 适用于调试阶段检查程序内部逻辑错误如指针不应为 NULL。不要将带有副作用的表达式放入 assert因为定义了 NDEBUG 宏后assert 会被展开为空副作用将消失。发布版本release可通过 #define NDEBUG 关闭所有 assert。指针的使用和传址调用传值调用和传址调用C语言函数参数默认是传值调用即函数内操作的是实参的副本修改形参不会影响实参。voidswap_fail(intx,inty){inttmpx;xy;ytmp;}intmain(){inta3,b5;swap_fail(a,b);printf(%d %d\n,a,b);// 仍然输出 3 5}总结实参传递给形参的时候形参会单独创建一份临时空间来接收实参对形参的修改不影响实参那么传址调用又是什么呢传址调用通过传递指针函数内解引用修改实参的值从而实现“改变外部变量”。voidswap_success(int*px,int*py){inttmp*px;*px*py;*pytmp;}intmain(){inta3,b5;swap_success(a,b);printf(%d %d\n,a,b);// 输出 5 3}传址调用的本质也是传值——传递的是指针变量的值地址只不过通过地址间接操作了外部变量。传址调用等价于int a 10; int *p a; *p 20; 这种在同一个作用域操作而传址调用就是不在同一个作用域里面操作传址调用可以让函数和主调函数之间建立真正的联系在函数内部可以修改主调函数中的变量所以未来函数中只是需要主调函数中的变量值来实现计算就可以采用传值调用。如果函数内部要修改主调函数中的变量的值就需要传址调用。完