C语言指针进阶:数组指针、指针数组与字符指针区别实战
发布时间:2026/9/29 11:39:06来源:尧图网络
数组指针、指针数组、字符指针这三个词我见过太多人念串了包括当年刚学 C 的我自己。明明代码都能跑一到写函数参数或者看别人的库源码就开始犯迷糊int *p[10]和int (*p)[10]到底哪个是哪个为什么char *s hello能编译过但一改就崩这些问题的答案其实都藏在同一件事上——声明符的阅读顺序以及数组名在表达式里到底变成了什么。这篇内容我打算把指针进阶里最容易打结的这几块一次讲透数组指针和指针数组的区别到底从哪来字符指针为什么看着最简单却坑最深以及这三样东西怎么在实际代码里配合使用。写给我自己带过的实习生看也写给每一个能写出能跑的 C 代码、但还没完全建立内存图像的朋友。不需要你有多深的底子会写int a 1;和int *p a;就能跟上如果你已经能熟练用指针做链表那就当是回头补一节地基课顺带看看那些平时没人跟你讲的实操细节。1. 别急着背口诀先把两个名字拆开看1.1 名字本身就是答案从中文语序倒推声明形式中文里数组指针和指针数组这两个词其实已经把答案写在脸上了。汉语的修饰语在前、中心语在后数组指针的中心语是指针数组只是用来修饰它的定语意思是指向数组的指针本质是指针。指针数组的中心语是数组指针是定语意思是装着指针的数组本质是数组。所以你在判断类型的时候第一件事不是去看声明长什么样而是先问自己这个名字的中心词是什么那它就是什么。把这个逻辑映射到 C 的声明上就变成了一个非常机械的动作看变量名先跟谁结合。int *p[10]里p的右边是[]方括号的优先级比左边的*高所以p先和[]结合成为数组数组里放的是int *这就是指针数组。int (*p)[10]里加了一对括号强行让p先和*结合成为指针然后再和[10]结合指向一个长度为 10 的int数组这就是数组指针。我当年学这块的时候最大的误区是试图靠记形状来区分有括号的是数组指针没括号的是指针数组。这个记法本身没错但它只覆盖了最简单的一层。真实代码里你还会碰到int *(*p)[10]、char *(*p)[5]、int (*f)(int, int)这种更绕的组合光靠背形状会立刻失效。所以更可靠的做法是理解优先级规则本身括号 后缀运算符[]、() 前缀运算符*。这条规则一旦吃透上面那些绕的声明都能一层层剥开。1.2 右左法则不是玄学是编译器读声明的方式右左法则这个词很多人听过但大多当成一句口诀背下来没想过它为什么成立。其实它的原理非常朴素C 的声明符语法本身就是按优先级组织的一棵小树编译器解析的时候是从变量名出发先往右看能结合就结合掉结合不动了再往左看绕完一圈再继续往外走。你按同样的顺序读读出来的结果就和编译器理解的一致自然不会出错。拿int *p[10]走一遍从p出发往右看到[10]结合得到p 是一个长度为 10 的数组继续往右没有了往左看到*结合得到数组的元素是指针再往左看到int得到指针指向 int。连起来就是p是一个长度为 10 的数组元素是int *。再拿int (*p)[10]走一遍从p出发往右看到)被括号挡住回头往左看*结合得到p 是一个指针跳出括号往右看[10]结合得到指向长度为 10 的数组再往左看到int得到数组元素是 int。连起来就是p是一个指针指向长度为 10 的int数组。这两个读法的差别只有一对括号但语义差了十万八千里。我建议你拿张纸把int *p[10]、int (*p)[10]、char **p、int (*f)(int)、int *f(int)这几个都亲手走一遍右左法则走完你对声明的恐惧基本就消掉一半了。1.3 一张表先建立直觉在深入细节之前先给几个典型声明的对照表遇到不确定的时候可以随时翻回来。声明类型名称本质sizeof64 位int *p[10];指针数组数组10 个元素每个是int *80int (*p)[10];数组指针指针指向int[10]8char *s;字符指针指针指向char8char s[10];字符数组数组10 个char10char *argv[];指针数组数组元素是char *取决于元素个数int **p;指向指针的指针指针指向int *8注意最后一列的数值只在 64 位平台上成立32 位平台上指针是 4 字节。跨平台代码里千万不要把sizeof(void *)硬编码成 8用sizeof或标准类型去算。这张表里最容易让人愣一下的是第一行和第二行的 sizeof 差异同样是10 个 int的字眼一个占 80 字节一个占 8 字节。原因就在于一个是数组本体一个只是一个地址。理解了这个差异后面所有关于拷贝、传参、性能的问题都能自己推出来。2. 指针数组一排专门用来装地址的格子2.1 声明、初始化与内存布局int *p[10];声明出来的是一个数组这个数组占了 80 字节64 位下但注意这 80 字节里存的不是 int 值而是 10 个地址。每个元素都是一个独立的int *变量初始值是未定义的直接用就是野指针。真正的 int 数据在哪在别的地方你得另外分配或者指向已存在的变量。这块我特别喜欢用小区信箱来类比指针数组本身是楼下那一排信箱80 字节是这一排信箱占的地面面积每个信箱里放的是一张写有某户人家具体地址的纸条。你拿到 3 号信箱里的纸条顺着地址走才能找到真正的住户也就是那块 int 内存。信箱里没放纸条的时候你按着信箱编号去找人等于按着一个随机地址去找人结果就是崩溃。初始化可以一步到位比如int a 1, b 2, c 3; int *p[3] { a, b, c }; for (int i 0; i 3; i) { printf(%d\n, *p[i]); /* 注意是 *p[i]不是 (*p)[i] */ }这里有个非常关键的下标优先级细节*p[i]等价于*(p[i])因为[]优先级高于*。先取数组第 i 个元素一个地址再解引用得到 int 值这才是你要的结果。如果你写成(*p)[i]那就是先把p当成数组指针解引用而它根本不是数组指针行为就完全不同了。这个错我在 code review 里见过不下十次。2.2 指针数组存放字符串最常见的用法也最容易翻车指针数组最经典的用法就是存字符串表。char *names[] {Tom, Jerry, Spike};这一行声明的是一个数组里面放了 3 个char *每个指针指向一个字符串字面量。注意字符串字面量本身不在这个数组里它们被编译器放在了只读数据段数组里只是三个指路牌。正因为指路牌和房子是分开的所以数组本身占的空间是 3 × 8 24 字节而三个字符串各自占 4、6、6 字节含结尾的\0。总内存比用二维数组省得多因为每个字符串的长度可以不同不会为了对齐最短长度而浪费。这也是为什么处理命令行参数、配置项名称、错误信息表这类长度参差的场景大家都用指针数组而不是二维数组。但翻车点也在这里字符串字面量是只读的你通过指针去改它就是未定义行为。下面这段代码在不少平台上会直接段错误char *s hello; s[0] H; /* 未定义行为大概率崩溃 */很多人会疑惑我明明声明的是char *不是const char *为什么不能改 因为char *只说明我指向一个 char并不代表这个 char 属于我、我可以写。字面量所在的内存页被标记为只读你写它硬件层面就会拦下来。这个坑我在带新人的时候一定会专门讲一遍因为它太容易被语法上能编译过给骗过去。2.3 指针数组与二维字符数组到底该选哪个同样是存几个字符串char names[][8] {Tom, Jerry, Spike};也能干活。那什么时候用二维数组什么时候用指针数组我一般按两条标准判断字符串长度是否整齐以及是否需要修改内容。对比项char names[][8]char *names[]内存布局内容全部在数组里连续存放数组里只有指针内容在只读段空间开销每行固定 8 字节短字符串浪费按实际长度分配无浪费能否修改内容可以是普通数组不能只读段能否重指向不能行是固定的可以指针能重新赋值排序成本需要移动整个字符串只交换指针排序这一条特别值得说如果用二维数组排字符串交换两个元素要拷贝整个 8 字节的行用指针数组排序只是交换两个 8 字节的地址字符串本体一动不动。数据量大的时候这个差距非常明显。所以只要不需要修改字符串内容指针数组基本是更优选择。反过来如果你需要对每个字符串做就地编辑比如统一转大写那就老老实实用二维数组或者动态分配的内存不要图省事指向字面量。2.4 指针的指针指针数组在函数参数里的退化形态这里要引出一个绕不开的话题数组名做函数参数时会退化。int arr[10]作为参数等价于int *arrint *p[10]作为参数等价于int **p。很多人第一次看到int **就头大其实它往往就是指针数组退化了的意思并不神秘。int **argv就是最标准的例子main函数的char *argv[]在参数位置写char **argv完全等价。它表达的是argv 是一个指针指向一个char *元素的数组你可以写argv[0]、argv[1]来拿每一个字符串也可以写*argv拿第一个字符串还可以写**argv拿到第一个字符串的第一个字符。这三层下标的解引用本质就是沿着数组 → 指针 → 字符一路走下来。不过要注意int **p和int (*p)[10]完全不是一回事。前者是指向指针的指针后者是指向数组的指针。它们长相接近、sizeof 都是 8但步长天差地别p 1对前者是跳过 8 字节一个指针对后者是跳过 40 字节10 个 int。这个差异在处理二维数据时是致命的下面讲数组指针时会展开说。3. 数组指针指向整行的那个指针3.1 声明、初始化与那个惊人的步长int (*p)[10];声明了一个指针它的指向类型是int[10]也就是一个长度为 10 的整型数组。这句话里最重要的一点是p 1不是加 4 字节而是加 40 字节。因为指针的加法是按指向类型的大小来算的而它指向的对象整整有 10 个 int。这个特性第一次见会觉得很反直觉。我常用的类比是普通int *是一双正常尺码的鞋迈一步走 4 字节int (*p)[10]是一双整排货架尺码的鞋迈一步直接跨过整排货架40 字节。你要用它在二维数组里按行走走一步正好到下一行开头。int arr[3][4] { { 1, 2, 3, 4}, { 5, 6, 7, 8}, { 9, 10, 11, 12} }; int (*p)[4] arr; /* 指向第一行指向类型是 int[4] */ printf(%zu\n, sizeof(*p)); /* 16一整行 4 个 int */ printf(%d\n, (*p)[2]); /* 3第一行第 3 个元素 */ p; /* 步长 16 字节跳到第二行 */ printf(%d\n, (*p)[2]); /* 7第二行第 3 个元素 */注意int (*p)[4] arr;这里的赋值arr是二维数组名它在表达式里退化成指向第一行的指针类型正好是int (*)[4]。所以这个赋值是完全类型匹配的不需要任何强制转换。很多教材写int (*p)[4] (int (*)[4])arr;加了转换反而让人以为必须要转其实是多余的。3.2 用数组指针遍历二维数组两种写法对比遍历二维数组有两种主流写法我一般两种都写一遍给新人看让他们自己感受差异。第一种是下标法最直观for (int i 0; i 3; i) { for (int j 0; j 4; j) { printf(%d , p[i][j]); } printf(\n); }第二种是纯指针法能更清楚地暴露内存是连续的这件事int *flat (int *)p; /* 拍平成一维视角 */ for (int k 0; k 12; k) { printf(%d , flat[k]); }第二种写法能跑通本身就说明了一个重要事实二维数组在内存里是彻底连续的一维布局行与行之间没有间隙。p[i][j]最终会被编译器算成*(*(p i) j)也就是先按行跨 i 次再按元素跨 j 次。理解了这一点你就能理解为什么数组指针能替代二维数组做参数也能理解为什么把二维数组当一维数组用是可行的虽然可读性差不建议滥用。注意(int *)p这种拍平写法严格来说是可移植的但如果你把p定义成int **再做这种转换那就完全错了因为int **指向的是不连续的一堆指针拍平之后得到的是指针数组的内容不是数据内容。这是两种完全不同的内存结构别混。3.3 arr、arr[0]、arr 三个东西的区别这是我觉得最值得单独讲一节的知识点。对int arr[3][4];来说下面三个表达式的值也就是地址完全一样但类型完全不同表达式类型步长含义arrint (*)[4]16 字节指向第一行arr[0]int *4 字节指向第一个元素arr[0]int (*)[4]16 字节指向第一行arrint (*)[3][4]48 字节指向整个二维数组看出问题了吗arr和arr[0]的值和类型都一样但arr的类型完全不同它的步长是 48 字节一次跨过整个数组。很多人第一次看到arr 1会以为越界实际上它是合法地指向数组结尾之后那个位置只是你不能解引用。printf(%p\n, (void *)arr); /* 假设是 0x1000 */ printf(%p\n, (void *)(arr 1)); /* 0x1010加了 16 */ printf(%p\n, (void *)(arr 1)); /* 0x1030加了 48 */这个区别在写求数组长度宏的时候会要命。sizeof(arr) / sizeof(arr[0])得到 3这个大家都会但如果你在函数里写同样的宏因为arr已经退化成指针得到的就是 264 位下 8/4完全错误。这也是为什么二维数组传参时行数必须另外传一个参数进去没有别的办法。3.4 数组指针在函数传参中的正确写法二维数组传参是新手最容易卡住的地方我见过太多人写成void f(int **p)然后调用f(arr)编译报错还看不懂为什么。原因很简单arr退化成int (*)[4]和int **是两种完全不兼容的类型。正确的写法有三种效果完全一样void f1(int (*p)[4], int rows); void f2(int p[][4], int rows); void f3(int p[3][4]);第一种是原形最能体现数组指针本质第二种是语法糖编译器会自动补上列数第三种只能用于列数完全确定的场合灵活度最低。我一般推荐团队里统一用第二种因为可读性最好同时保留列数信息。但面试的时候你要能一眼认出第一种并说清楚它和int **的区别。注意void f(int (*p)[4])里的 4 必须写死不能省略也不能用变量。如果列数在运行期才确定那就不能用数组指针得改用一维数组 手动计算下标或者用一个指向指针的指针配合动态分配的指针数组。这是 C 语言数组类型的硬性限制。4. 字符指针看着最简单坑最深4.1 字符指针与字符数组的本质区别char *s和char s[10]这两个声明放在一起看很多人觉得差不多实际上内存层面的差别是根本性的。字符数组是实体它在栈上局部变量时实实在在占了 10 个字节你可以随便读写字符指针是标签它本身占 8 字节里面存的是一个地址那个地址指向的内存属于谁、能不能写完全取决于它指向哪里。char a[] hello; /* 栈上 6 字节内容可改 */ char *b hello; /* b 在栈上 8 字节字符串在只读段 */这两行最经典的验证方式是打印地址和大小printf(%zu %zu\n, sizeof(a), sizeof(b)); /* 6 8 */ printf(%zu %zu\n, strlen(a), strlen(b)); /* 5 5 */sizeof 一个 6 一个 8一眼看出本质不同。但 strlen 都是 5这正是让人放松警惕的地方——常用的字符串函数行为一致导致很多人以为二者可以互换。直到某天写b[0] H崩了才发现事情不对。我在团队里推的一条规矩是只要一个字符指针指向字面量声明时必须写const char *。这个习惯要付出零成本但能挡掉一整类运行期崩溃。4.2 字符串字面量藏在哪只读段与改写事故具体来说hello这个字面量存在哪里在典型的可执行文件布局里它被放在只读数据段有些平台叫.rodata。这个段在进程加载时被映射成只读页任何写入都会触发硬件异常操作系统收到后转成信号发给你程序就崩了。所以s[0] H不是改错了值而是试图往只读内存里写性质完全不同。更麻烦的是这个行为在标准里是未定义行为意味着不同编译器可以给出不同结果。我实测过某些平台上这么写会静默通过字符串真的被改了程序一切正常换个编译器或者换个优化等级同样的代码直接段错误。这种环境相关的 bug 最耗时间因为你本地跑得好好的一到别人的机器上就炸。还有一类相关的写法是返回局部数组的指针char *bad(void) { char buf[32] hello; return buf; /* 返回栈上地址函数一结束就失效 */ }这个函数返回的地址是有效的类型也匹配但指向的内存已经随着栈帧回收而失效了。调用方拿到手再用就是访问已经不属于你的栈空间值可能还没被覆盖也可能已经被别的函数改花了。这类 bug 的典型症状是有时候对有时候错特别难查。我一般建议把这类地址返回全部换成由调用方传入缓冲区的形式谁分配谁管理责任清晰。4.3 const 放三个不同位置分别代表什么const和字符指针的组合是另一个高频混淆点。const在声明中的位置不同锁定的东西完全不同我用一句话总结const 修饰它右边最近的那个东西右边没有就看左边。const char *p1 abc; /* 指向常量的指针*p1 不可改p1 可改 */ char * const p2 buf; /* 常量指针p2 不可改*p2 可改 */ const char * const p3 abc; /* 都不可改 */声明p本身能否改*p内容能否改常见用途const char *p可以能指向别处不可以只读入参、字符串表char * const p不可以绑定后就固定可以固定缓冲区句柄const char * const p不可以不可以全局只读常量记忆方法把声明从右往左读const char *p读成p 是个指针指向 const char所以锁的是内容char * const p读成p 是个 const 指针指向 char所以锁的是指针本身。这两种写法只在const的位置上差一个字含义完全相反写库接口的时候写错了调用方要么处处报错要么完全失去保护。4.4 顶层 const 与底层 const 的赋值规则顺着 const 往下走就必然会碰到顶层 const 和底层 const 能不能互相赋值这个问题。这两个概念在 C 里是正式术语在 C 里虽然没有明确命名但规则本质上是一致的理解它对写接口和排查类型报错都很有帮助。所谓顶层 const指的是指针本身是常量底层 const指的是指针指向的对象是常量。当你在做赋值时允许把带底层 const的往不带底层 const的方向看是禁止的反过来允许左值类型右值类型能否赋值原因int *int *const可以右值的顶层 const 被忽略const int *int *可以增加限制是安全的int *const int *不可以会丢失底层 const有写入风险const int *const int *const可以顶层 const 被忽略最后一行和第一行体现的都是同一条规则顶层 const 在拷贝时会被丢弃这不会带来任何安全问题因为你拷贝的是值地址不是权限。而底层 const 一旦被丢弃就相当于给了别人写入只读内存的能力编译器必须拦下来。int i 0; int *const p1 i; /* 顶层 const */ const int *p2 i; /* 底层 const */ p2 p1; /* 可以p1 的顶层 const 被忽略 */ /* p1 p2; */ /* 不行p1 本身不可改 */ /* int *p3 p2; */ /* 不行会丢掉底层 const */搞清这条规则你在看 C 标准库的函数签名时会顺畅很多比如那些接收const char *的接口为什么能接受char *而反过来就不行。5. 把三种指针拧成一股绳三个能直接抄的实战案例5.1 案例一函数怎么接收二维数组场景很常见一个显示矩阵的函数。假设矩阵是 3 行 4 列怎么写最合适void print_matrix(int (*m)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%4d, m[i][j]); } printf(\n); } } int main(void) { int m[3][4] {{1,2,3,4},{5,6,7,8},{9,10,11,12}}; print_matrix(m, 3); /* m 退化成 int (*)[4]类型匹配 */ return 0; }这里m[i][j]会被编译器展开成*(*(m i) j)先按 16 字节跨 i 行再按 4 字节跨 j 列。整个过程没有任何额外的间接寻址性能和一维数组遍历一样。这就是数组指针的实用价值——它把二维这件事编译期就解析掉了。注意如果列数在运行期才知道比如从配置文件读进来的矩阵那这条路线走不通。可行方案是用int *m加一个cols参数手动算下标m[i * cols j]。这个写法虽然土但在需要动态尺寸的场合是唯一解而且内存是连续的缓存友好。5.2 案例二用指针数组搭一张命令分发表指针数组最实用的场景之一是名字到行为的映射。用一个const char *数组存名字配合函数指针数组存对应的处理函数就能实现一个极简的命令分发器。这套结构在嵌入式设备、小型工具程序里非常常见。#include stdio.h #include string.h static void cmd_help(void) { printf(help: 显示帮助\n); } static void cmd_ver(void) { printf(ver: 版本 1.0\n); } static void cmd_exit(void) { printf(exit: 退出\n); } typedef struct { const char *name; void (*handler)(void); } cmd_entry_t; static const cmd_entry_t table[] { { help, cmd_help }, { ver, cmd_ver }, { exit, cmd_exit } }; int main(void) { const char *input ver; size_t n sizeof(table) / sizeof(table[0]); for (size_t i 0; i n; i) { if (strcmp(table[i].name, input) 0) { table[i].handler(); break; } } return 0; }这个结构有几个好处。第一新增命令只需要在表里加一行分发逻辑一行不用改符合开闭原则的朴素版本。第二name用const char *指向字面量不占额外空间而且天然只读防止误改。第三sizeof(table) / sizeof(table[0])这种算法只在表和数组在同一个作用域时有效一旦传进函数就退化成指针必须把长度当参数传进去或者用一个哨兵元素结尾。注意如果表特别大比如几千条线性查找会变慢。这时候可以考虑把表按名字排序配上一套序列号或者哈希表来索引。但在几百条以内线性查找的实际开销完全可以忽略别过早优化。5.3 案例三字符串表排序与原地处理前面讲过用指针数组排字符串非常省事因为只交换指针。写出来大概是这样#include stdio.h #include string.h static void sort_names(const char *arr[], int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (strcmp(arr[j], arr[j 1]) 0) { const char *tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } } int main(void) { const char *names[] { pear, apple, banana, cherry }; int n (int)(sizeof(names) / sizeof(names[0])); sort_names(names, n); for (int i 0; i n; i) printf(%s\n, names[i]); return 0; }这里注意参数写的是const char *arr[]退化后是const char **。为什么不能直接用char **因为names数组的元素类型是const char *把const char **传给char **会丢掉底层 const编译器会警告。这是前面讲的那条赋值规则在起作用看懂了就不觉得奇怪了。那如果确实需要原地修改字符串内容呢比如统一转大写。这时候就不能用指针数组指向字面量了得用二维字符数组#include ctype.h int main(void) { char words[][8] { pear, apple, banana }; int n (int)(sizeof(words) / sizeof(words[0])); for (int i 0; i n; i) { for (int j 0; words[i][j] ! \0; j) { words[i][j] (char)toupper((unsigned char)words[i][j]); } } return 0; }每种方案都有它擅长的场合只读 排序用指针数组需要改写内容用二维数组或者动态分配。把这两个场景分清楚你选型时就不会纠结。5.4 用 sizeof 给内存布局做一次体检我有个习惯写完涉及指针的代码会用一小段代码把所有相关的 sizeof 打一遍确认自己脑子里的内存图像和实际一致。这个习惯帮我抓到过不少我以为是这样的错误。#include stdio.h int main(void) { int arr[3][4]; int *parr[3]; int (*prow)[4] arr; char s[] hello; const char *ps hello; printf(sizeof(arr) %zu\n, sizeof(arr)); /* 48 */ printf(sizeof(arr[0]) %zu\n, sizeof(arr[0])); /* 16 */ printf(sizeof(arr[0][0]) %zu\n, sizeof(arr[0][0])); /* 4 */ printf(sizeof(parr) %zu\n, sizeof(parr)); /* 24 */ printf(sizeof(prow) %zu\n, sizeof(prow)); /* 8 */ printf(sizeof(*prow) %zu\n, sizeof(*prow)); /* 16 */ printf(sizeof(s) %zu\n, sizeof(s)); /* 6 */ printf(sizeof(ps) %zu\n, sizeof(ps)); /* 8 */ printf(arr %p\n, (void *)arr); printf(arr1 %p\n, (void *)(arr 1)); /* 16 */ printf(arr1 %p\n, (void *)(arr 1)); /* 48 */ return 0; }把这段跑一遍对着输出逐个解释清楚指针这几块基本就通了。我特别推荐arr1和arr1这两个它们的差值是理解步长由类型决定最直观的证据。6. 常见问题与排查技巧实录6.1 段错误速查表指针相关的崩溃八九不离十是下面几种。我把它们整理成一张表出问题的时候按顺序过一遍往往几分钟就能定位。现象大概率原因定位方法一运行就崩位置固定解引用了 NULL 或未初始化指针在解引用前打印指针值和地址改字符串时崩写到只读段检查声明是否为const char *并指向字面量函数返回后崩返回了局部数组或局部变量地址检查返回值的生命周期归属有时候崩有时候不崩访问已释放内存或越界写用地址检查工具跑一遍崩在字符串函数里字符串没有\0结尾检查缓冲区长度和终止符循环里越跑越崩指针自增越过了边界打印每轮指针与边界值的差补一条经验printf打印指针时一定要加(void *)转换因为%p严格来说只接受void *。这在大多数平台上不转也没事但换个架构可能出问题。养成这个习惯成本是零。6.2 sizeof 和 strlen 的分工这两个东西经常被混用其实它们解决的完全是两个问题。sizeof是编译期运算符它算的是这个类型或对象占多少字节对数组来说就是整个数组的长度strlen是运行期函数它从给定地址开始数直到遇到\0为止算的是字符串的可见长度。char s[] hello; const char *p hello; printf(%zu\n, sizeof(s)); /* 6含结尾 \0 */ printf(%zu\n, strlen(s)); /* 5 */ printf(%zu\n, sizeof(p)); /* 8指针本身大小 */ printf(%zu\n, strlen(p)); /* 5从 p 开始数到 \0 */一个容易踩的坑是sizeof用在已经退化成指针的数组上会失效。函数参数里的char s[]实际是char *ssizeof(s)得到的是 8 而不是数组长度。所以标准做法是凡是需要数组长度的地方要么用宏在数组还在作用域内时算好要么把长度当参数传。靠sizeof在函数内部猜长度早晚出事。6.3 编译器警告其实是在给你送答案我见过很多人编译时开-w把所有警告关掉理由是太多了看着烦。这在我看来是把最有价值的免费顾问请出门了。涉及指针的警告尤其值得看因为几乎每一条都对应一个真实隐患。建议至少开到-Wall -Wextra。常见的几条警告翻译成人话大概是这个意思警告信息关键词它在告诉你什么incompatible pointer type指针类型不匹配多半是int **和int (*)[N]搞混了discards const qualifier你把底层 const 丢了有写入只读内存的风险assignment makes pointer from integer少写了一个或者少写了一个*comparison between pointer and integer拿指针和 0 之外的字面量比了或者漏了 NULL的括号uninitialized use指针没初始化就用了最容易变成野指针format %p expects void *打印指针没有转换类型最后一条特别想多说一句printf(%p, p)这种漏写(void *)的情况现在的编译器基本都会提示。这不是编译器啰嗦是它真的在保护你。注意把警告全部消除不代表代码一定正确但带着一堆警告跑通的代码一定藏着定时炸弹。我给自己定的规矩是提交代码前警告必须清零这条规矩帮我拦下了不少线上问题。7. 顺带把几个容易混的兄弟概念分清7.1 函数指针与指针函数这两个名字和数组指针 / 指针数组是一模一样的构词法判断方法也一样看中心词。函数指针中心词是指针它本质是个指针指向函数指针函数中心词是函数它本质是个函数返回值是指针。int (*fp)(int, int); /* 函数指针fp 是指针指向 int(int,int) 的函数 */ int *f(int, int); /* 指针函数f 是函数返回 int * */区分它俩的意义在于函数指针可以被赋值、被传递、被放进数组是回调机制的基础指针函数只是一个返回地址的普通函数它的难点不在声明而在返回值的生命周期管理我们前面已经踩过这个坑了。函数指针数组的经典形态是int (*ops[4])(int, int);按右左法则读一遍ops先跟[4]结合成为数组元素是int (*)(int, int)也就是函数指针。这就是 5.2 节那张命令表的底层结构只是那里我们把它包在了结构体里可读性更好。7.2 万能指针 void* 与指针的指针void *是 C 里的通用指针它能接收任何对象指针的赋值不需要强制转换但反过来从void *转成具体类型必须显式转换。它的用途主要是我不知道指向什么类型的场景比如内存分配函数返回的就是void *因为分配器不关心你拿它干什么。void *有两个使用限制值得记住一是不能对它做算术运算因为编译器不知道步长是多少二是不能直接解引用原因同上。要操作就必须先转成具体类型。int a 42; void *v a; /* 可以直接赋值 */ int *ip (int *)v; /* 取出来必须显式转换 */ printf(%d\n, *ip); /* 42 */注意void *和void **差别很大后者不是万能指针的指针而是一个具体的、指向void *的指针。在需要传递指针到函数里让它改指向的时候你会用到指向指针的指针这时候千万别写成void *然后强行转换类型对不上会导致内存解释错误。指针的指针T **本身在函数参数里非常常见典型场景是函数内部分配内存并通过参数把地址带出来int alloc_buf(char **out, size_t n) { char *buf (char *)malloc(n); if (buf NULL) return -1; *out buf; return 0; }调用方传ptr进去函数里通过*out buf把新地址写回调用方的变量。这个模式是 C 里实现输出参数的标准做法理解了之后int **带来的那种陌生感就会消失。7.3 从 C 的指针到 C 的智能指针如果你从 C 跨到 C最先感受到的变化就是裸指针不再是唯一选择。C 提供了unique_ptr、shared_ptr这类智能指针它们在对象析构时自动释放所管理的内存解决的正是 C 里最容易出问题的那部分——忘记释放和提前释放。用起来大概是这样#include memory std::unique_ptrint p(new int(42)); // 不需要手动 deletep 离开作用域时自动释放 std::shared_ptrint sp std::make_sharedint(7); // 引用计数归零时自动释放unique_ptr表达独占所有权不能拷贝只能移动shared_ptr表达共享所有权靠引用计数决定何时释放。它们背后的核心思想其实和 C 里谁分配谁释放、责任清晰是一脉相承的只是把这件事从靠纪律变成了靠类型系统强制。反过来说如果 C 代码里把所有权关系写得足够清楚——比如每个函数明确说明是否接管传入的指针——那它同样能活得很好。我的建议是写 C 就把所有权规则当纪律严格执行写 C 就优先用智能指针别在 C 工程里大量散布裸new和delete。这不是风格问题是维护成本问题。7.4 指针思想跨语言快慢指针原地去重前面讲的全是 C 的内存层面最后想聊一点跨语言的东西。指针的核心价值其实有两个层面一是内存寻址二是用两个下标在同一块数据上跑。第二个层面完全脱离了 C 的语境在别的语言里一样成立最典型的就是快慢指针。说到这个就顺便提一句 JavaScript 里常见的场景有序数组原地去重。用快慢双下标只需要一趟扫描额外空间是常数级别。function dedupSorted(nums) { if (nums.length 0) return 0; let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; // 新长度 }这段代码里slow和fast在 C 里就是两个int *在 JS 里则是两个下标变量但思路完全一样慢指针维护已经处理好的区间边界快指针负责探索未处理区域发现新值就把它搬到边界后面。判断链表是否有环、找链表中点、找倒数第 k 个节点用的都是同一套思维。所以我的看法是学指针别只盯着内存地址那一层。先把 C 里数组指针、指针数组、字符指针这些内存层面的东西搞透你会得到一种看得见内存的能力再把这套双下标思维带到别的语言里你会发现很多看起来需要额外空间的算法其实原地就能解决。最后分享一个我自己一直在用的小办法。学这几个概念的时候我给自己准备了一张纸把所有相关的声明抄下来然后对每个声明做三件事读一遍右左法则、写出它的类型别名、猜一遍 sizeof 和步长。猜完再跑代码验证。抄了大概二十几个声明之后我发现自己再看到int *(*p)[10]这种组合脑子里能直接出画面了。这个方法笨但它确实有效因为它逼着你在我以为我懂了和我真的懂了之间做一次对账。指针这块没有捷径但有对账的工具把工具用起来比反复看教程快得多。
网站建设高端定制企业官网