1. 拆开揉碎:为什么这组表达式是C语言的一座分水岭
*p++、*++p、++*p、(*p)++这四个表达式,几乎是每个学C语言的人都会遇到的坎。你随便在搜索引擎里搜“C语言指针难题”“C语言必背100代码”“翁恺C语言练习题”,这组表达式一定会出现在结果里。我在带新人、帮同事Review代码、甚至自己在凌晨三点Debug的时候,都无数次和它们打过照面。可以说,能不能一次性把这四行表达式说清楚,基本就反映了你对“运算符优先级”“指针自增”“左值”这三个概念掌握到了什么程度。
为什么它这么经典?因为它在极短的代码里,集中了C语言最底层的两个核心机制:指针的移动(地址运算)和内存单元内容的修改(解引用后的操作)。很多同学背结论——“*p++是先取指再移动指针”——背得很熟,但换个形式,比如写成嵌套的函数指针调用,或者配合二级指针,立刻就懵了。这说明优先级这个东西,光靠背没有用,你得在语法树的层面上真正理解它。
这篇东西我打算从运算符规则讲起,把四个表达式的求值过程逐个拆开,画清楚每一步发生了什么,再补上一批能直接跑起来的验证代码和实战场景,最后聊聊我实际踩过的那些坑。如果你正在刷C语言基础题,或者准备计算机二级C语言考试,又或者在工作中写嵌入式、写算法题时被指针绕晕,这篇文章应该能帮你把这块地基彻底夯实。
很多初学者会问一个问题:题目里四个表达式,*和++都是单目运算符,它们谁先算谁后算,差别真的有那么大吗?答案是:差别大到你无法想象,一个措辞可能让程序从“正常遍历数组”变成“修改数组内容”甚至“段错误”。接下来的章节,我会一步步推导。
2. 优先级与结合性的底层规则:先把C语言运算规则这张表吃透
2.1 运算符优先级速查:单目运算符里藏着什么规律
C语言的运算符优先级表,是所有教材都会列的东西。初看很吓人,一大堆符号按级别排成十几行,但你真正需要重点记住的区间其实没那么多。我们关注的++、--、*、&,全都属于“单目运算符”这个级别。
我先把和本主题直接相关的规则拎出来:
- 后缀自增
p++、后缀自减p--:优先级最高,属于第二梯队(第一梯队是圆括号()、数组下标[]、成员选择.和->)。 - 前缀自增
++p、前缀自减--p、解引用*p、取地址&p、逻辑非!p等:位于同一个优先级层级,低于后缀自增,但高于乘除法、加减法和绝大多数双目运算符。 - 所有单目运算符的结合方向是自右向左。
这条规则是关键。*p++之所以等价于*(p++),不是因为“后缀++比*优先级高”这一句话就够了,还要结合“单目运算符右结合”这个性质。所谓右结合,就是在没有括号的情况下,从右往左依次找操作数。于是*p++先结合++,把p++看成一个整体,然后*再对这个整体解引用。
这里我补充一个很多人忽略的细节:优先级决定的是“谁的语法结合位置更紧”,而不是“谁先执行”。在实际运行时,p++这个整体已经包含了“用旧值生成副本”和“修改p本身”两个动作,而*作用于的是那个旧值副本。严格来说,C标准只规定了运算结果,并没有强制规定自增和解引用谁先落实到内存上,但理解成“先取旧值副本,再自增,再解引用副本”对日常编程是安全且准确的。
2.2 右结合到底怎么看出无括号版本的真实含义
我们来做一个规范的语法分析。表达式*++p中,*和++(前缀)在同一优先级层,因为没有显式括号,所以从右向左结合。哪个运算符离操作数p更“近”?当然是前缀++。它先和p绑定,形成++p这个整体,然后*再作用于它,最终解析为*(++p)。
同理,++*p的解析过程是:操作数首先是*p,前缀++的优先级和*相同,但从右向左结合,于是先看p前面的运算符,发现是*,先绑定*p,然后++再绑定(*p),最终结果是++(*p)。这里*p本身是一个左值(可修改的内存单元),所以++可以直接作用在它上面。
(*p)++则完全是另一条路:用括号强行把*p包起来,让后缀++作用在*p这个左值上。后缀自增的语义是“返回旧值,然后把变量本身加1”,所以(*p)++做的事情是“取出指针p指向的值作为表达式结果,然后将这个值自增1”。
把四个表达式放一起对比,你能看到一个很有意思的对称性。我整理成一张表:
| 表达式 | 等价写法 | 对指针p的影响 | 对指针所指向变量的影响 | 表达式返回的旧值 |
|---|---|---|---|---|
*p++ | *(p++) | p向后移动一个单位 | 无影响 | p移动前指向单元的值 |
*++p | *(++p) | p向前移动一个单位 | 无影响 | p移动后指向单元的值 |
++*p | ++(*p) | 无影响 | 所指向的变量自增1 | 自增后的值 |
(*p)++ | (*p)++ | 无影响 | 所指向的变量自增1 | 自增前的值 |
有了这张表,题目最核心的问题就已经解决了一半。但光看懂表格还不行,你得会在实际代码里驱动它们。
2.3 常见误区:别把“优先级”和“运算次序”混为一谈
这里必须单独说一个高频误区。很多教材把“优先级”描述成“谁先算”,导致很多新手觉得*p++是先执行p++再执行*p。这个说法在结果上有点偏,但在概念上非常误导人。
实际情况是,*p++被解析成*(p++)之后,p++这个表达式本身的求值会产生两个东西:一个是修改前的p值(旧地址),另一个副作用是把p本身加1。然后*作用在“旧地址”上,取的是旧地址处的值。所以从最终效果看,表达式等价于:
tmp = p; /* 先保存旧指针 */ p = p + 1; /* 指针自增,副作用 */ result = *tmp; /* 解引用旧指针 */看到没有,是先解引用旧地址,再修改指针。优先级只决定了“++绑定在p上,而不是绑定在*p上”,并没有直接规定内存写入的先后次序。以后在面试、笔试、考试里遇到这类题,不要在脑子里模拟“谁先执行”,而是直接做语法解析,把整个表达式拆成“哪部分是操作数、哪部分是运算符”,然后再按每种运算符的语义去求值。
3. 四种表达式逐个击破:语义、场景和容易混淆的边界
3.1*p++:最常用的“先取后移”,遍历数组的常青树
*p++是四个人里出场率最高的一个。C语言里最常见的字符串复制、数组遍历、内存拷贝,底层几乎都是它在驱动。它的语义是:取出当前指针指向的值,然后让指针后移一位。
为什么要强调“取出当前值”而不是“先让指针移动”?因为后缀自增的特性决定了p++这个表达式的结果永远是被修改前的p值。我在前面已经推导过了,这里不再重复。
看一个最典型的使用场景:用指针遍历数组。
#include <stdio.h> int main(void) { int arr[] = {10, 20, 30, 40, 50}; int *p = arr; int i; printf("使用 *p++ 遍历数组:\n"); for (i = 0; i < 5; i++) { printf("%d ", *p++); } printf("\n"); /* 此时p已经指向arr[4]的下一个位置,即arr+5 */ printf("遍历结束后p指向的位置是:%ld\n", (long)(p - arr)); return 0; }运行结果如下:
使用 *p++ 遍历数组: 10 20 30 40 50 遍历结束后p指向的位置是:5这个例子里,*p++循环了5次,每次循环先取当前位置的值打印,然后指针向后移动一个int,最后一次移动后指针指向数组越界位置arr+5。这在C语言里是允许的,只要你不解引用越界之后的位置就没事。
*p++之所以比*p; p++;两行写法更让人上瘾,是因为它在语义上是一个原子操作,既简洁又不容易遗漏。很多自研代码、开源项目、Linux内核代码里,你能看到大量*dst++ = *src++;这样的写法,它本质就是把复制和移动合并在一起,循环体只剩一个while (n-- > 0) *dst++ = *src++;。
3.2*++p:先移后取,用于“跳过第一个元素”或“从下一个位置开始处理”
*++p的语义是:先把指针向后移动一个单位,然后取新指向位置的值。它和*p++相比,就是把自增动作放到了取值之前。
这玩意儿最典型的应用场景是:当你想从第二个元素开始遍历,或者在某些算法里先“跳过”当前节点。比如写一个单链表相关的递归展开处理,熟悉指针的人就会写出first = *head++; second = *head++;这样的代码,如果你要跳过第一个元素,直接从第二个开始处理,*++p就派上用场。
来看验证示例:
#include <stdio.h> int main(void) { int arr[] = {10, 20, 30, 40, 50}; int *p = arr; printf("跳过第一个元素,从第二个开始取:%d\n", *++p); printf("此时p指向的元素下标:%ld\n", (long)(p - arr)); return 0; }运行输出:
跳过第一个元素,从第二个开始取:20 此时p指向的元素下标:1如果我把代码改成这样:
int *p = arr; printf("%d\n", *++p); /* 20 */ printf("%d\n", *p++); /* 20 */ printf("%d\n", *p); /* 30 */你会看到第一次*++p把p移到下标1处取了20,第二次*p++先取20,再把p移动到下标2,第三次*p取到的就是30。这个练习如果能在脑子里准确模拟出来,优先级这块就算过关了。
3.3++*p:修改目标值,这才是“自增指向对象”的正确姿势
前两个表达式都在移动指针,后两个表达式作用的对象完全不同。++*p的解析结果是++(*p),它的含义是:先取指针p指向的值,然后把这个值自增1。整个过程不影响p本身。
这个表达式经常会和*p++搞混,尤其是在面试题里,题干故意写得很像,让你误以为是在移动指针。实际上一句话就能区分:
*p++:改的是“针”(指针的位置)。++*p:改的是“线头”(指针所指的实体内容)。
写个例子验证:
#include <stdio.h> int main(void) { int x = 100; int *p = &x; printf("自增前 x = %d\n", x); ++*p; printf("自增后 x = %d\n", x); printf("p 本身的值(地址)没有变,p = %p\n", (void *)p); return 0; }运行结果:
自增前 x = 100 自增后 x = 101 p 本身的值(地址)没有变,p = 0x7ffc...注意,++*p写多了之后,有人会习惯性给它加个空格区分层次。比如++*p和++ *p在C语言里其实是同一个东西,但很多代码规范明确要求写++*p这种紧凑格式,因为 unary operator 紧跟操作数,可读性更好,也避免编译器和人类同一时期产生误会。
这里我额外提一句顶层指针和底层指针可以相互赋值吗这个相关的热词。其实这个概念在C语言语境下和const修饰有关。顶层指针指的是指针本身是const,底层指针指的是指针指向的对象是const。如果你在写++*p时,发现编译报错“increment of read-only location”,那就是指针指向的对象被const限定了。反过来,如果p本身是const指针,你写*p++就不行,因为不能修改p本身。这三个概念经常在选择题里组合出现,本质都是在考:你自增的到底是“指针”还是“指向的值”。
3.4(*p)++:后置自增对象值,括号在这里决定了成败
(*p)++是四个表达式里最能体现“括号重要性”的一个。它和前两个表达式最大的区别是:后缀++的优先级极高,如果不用括号把*p包起来,写成*p++,含义就会变成“移动指针”而不是“修改值”。一旦加了括号,就把“解引用”和“后置自增”这两个操作绑定在同一个对象上。
语义上,(*p)++返回的是自增前的值,然后才把指向的值加1。这个“返回旧值”的特征,让它在很多算法场景中非常有用。比如你想把数组里的某个值取出后自增,同时还需要旧值做后续判断:
#include <stdio.h> int main(void) { int x = 100; int *p = &x; int old = (*p)++; printf("(*p)++ 返回的旧值: %d\n", old); printf("自增后 x 的值: %d\n", x); return 0; }运行结果:
(*p)++ 返回的旧值: 100 自增后 x 的值: 101这恰好和++*p形成了一个对称关系:
++*p返回的是新值(自增后的值)。(*p)++返回的是旧值(自增前的值)。
这个区别在嵌入C代码的时候会有直接的体现。比如你在写一个环形缓冲区的写指针更新逻辑:写数据前你要用到旧写指针位置,然后把写指针前进一格。如果某一步你需要“从缓冲区中的某个值读取后原地自增”,那么选择(*p)++和++*p会导致完全不同的结果。
我遇到过一位同事,他写FIFO的状态计数时本来想这么写:
count = (*fifo->tail)++;结果漏了括号,变成:
count = *fifo->tail++;前者是先取回环缓冲区尾部元素的值,然后原地自增;后者是取完值后把tail指针本身后移,完全不是一回事。排查了很久才发现是这里逻辑变了,一个括号的位置,硬生生让问题定位多花了两个钟头。这种教训很贵。
4. 组合拳与真实场景:从字符串拷贝到栈操作,这套语法无处不在
4.1 字符串与内存拷贝:*dst++ = *src++背后的美学
如果你写过或者看过高性能版的字符串拷贝,一定见过这种循环:
void my_strcpy(char *dst, const char *src) { while ((*dst++ = *src++) != '\0') ; }你注意看这里,*dst++是“先取当前目标位置,把源字符赋过去,再把两个指针都后移”,赋值表达式的结果是源字符本身,当它是'\0'时循环停止。
这里面埋着一个很容易出错的点:循环体是空语句;,真正干活的是 while 条件里的赋值。如果这里用的是*++dst = *++src,那初始时指针就已经领先了一个位置,源字符串的首字符会拷贝到错误的位置,最终结果必错无疑。这个例子就是*p++语义最好的实战注解。
我还常和刚入门的人说,如果这段代码你能不看答案独立写出来,且正确说明为什么终止条件写!= '\0'而不是> 0,那你的指针基本功就过关了一半。
再发散一个点:如果用负数作为结束标志的“带长度前缀”的内存块拷贝,代码就成了:
n = src[0]; /* 长度存在首字节 */ src += 1; while (n-- > 0) { *dst++ = *src++; }这段代码里,n-- > 0这个条件同时完成了“判断旧值是否大于0”和“自减”两个任务。你能在这里看出后缀运算“返回旧值”的思想其实一脉相承。
4.2 顺序栈与队列的指针操作:后置自增是天然的生产者
在很多嵌入式实时系统或算法题里,顺序栈是用数组加栈顶指针top实现的。入栈操作如果写指针版,往往长这样:
int stack[MAX]; int *top = stack; void push(int val) { *top++ = val; } int pop(void) { return *--top; }*top++ = val做的事情是“把新元素写入当前栈顶位置,然后top指针上移一格”。而*--top在弹出时先把top下移一格,再取元素。你看这两个表达式,一个是*p++的写版本,一个是*++p的返回值版本,正好覆盖了栈的入栈和出栈两个方向。
我自己在笔试里出过这道变形题:上面这个栈如果入栈写成*++top = val,出栈写成*top--,会有什么效果?很多人的第一反应是“好像也能工作”,但仔细推演就会发现,如果初始top = stack,那么第一次*++top = val会写到stack[1],栈的第一个位置就被跳过了;而*top--取完值后还把top再往下移一格,指针边界会出问题。这种“看起来相似、实际上差之毫厘失之千里”的坑,正是当年我对这个知识点印象最深的原因。
4.3 链表的头插法到通用遍历:结合二级指针的进阶用法
除了基础的数据结构,这套东西还会在更复杂的场景里出现。比如头插法创建链表时,新人喜欢用一个临时指针在节点间穿梭:
Node *head = NULL; Node *cur; while (/* 有数据 */) { cur = malloc(sizeof(Node)); /* 填充cur的数据 */ cur->next = head; head = cur; }这个写法本身没毛病,但如果写成“用二级指针操控头结点”的高阶版,你会再次见到*pp和++的组合:
void push(Node **head, int data) { Node *node = malloc(sizeof(Node)); node->data = data; node->next = *head; *head = node; }再看一个把(*pp)和++结合用的例子:如果要把当前指针指向的链表节点的值都加1,并且移动指针,有人会写出:
++*p++; /* 等价于 ++(*(p++)),先取旧对象自增,再移动指针 */这种“自增对象值,再移动指针”的复合行为,其实可以拆成++(*p); p++;两行。我提倡在无人review且追求极限性能时用这种combo,但团队合作阶段绝不要这么写。一眼能看懂优先级的老手当然没事,可代码是给人读的,工作里“可读性优先”才是王道。
5. 验证与排错:利用编译器、调试器和阅读汇编来吃透优先级
5.1 断点验证:用GDB打印每一步的中间状态
我在实际带人的时候,最推荐的验证方法是直接把代码放进GDB里,观察每一步指针的值和地址的变化。下面是一段测试代码:
#include <stdio.h> int main(void) { int arr[] = {10, 20, 30, 40, 50}; int *p = arr; printf("*p++ = %d\n", *p++); /* 10 */ printf("*++p = %d\n", *++p); /* 30 */ printf("++*p = %d\n", ++*p); /* 31 */ printf("(*p)++ = %d\n", (*p)++); /* 31 */ printf("now x = %d\n", arr[2]); /* 32 */ return 0; }这段代码是很好的一道手算题,你先别急着跑结果,自己往下推一遍:
- 初始
p = &arr[0]。 - 第一行
*p++:取10,p移动到下标1。 - 第二行
*++p:p先移动到下标2,取值30。 - 第三行
++*p:对arr[2]进行自增,arr[2]从30变为31,表达式结果为31。 - 第四行
(*p)++:返回旧值31,然后arr[2]变为32。
最终输出:
*p++ = 10 *++p = 30 ++*p = 31 (*p)++ = 31 arr[2] = 32如果在GDB里跑,你在每个printf之前设置断点,打印p - arr和*p,就能直观看到指针漂移和值的变化。我的经验是,自己手算一遍之后再让机器打脸,比看十遍教程都管用。很多人在这一步发现自己根本没懂优先级,正好借此修正认知。
5.2 观察反汇编:编译器是怎么翻译这四条语句的
前几年我在调试一个实时性要求比较高的模块,为了抠执行周期,特意去看了几条语句的汇编输出。其实用objdump -d或者直接gcc -S就能看到*p++和*++p的区别。用一段最简单的代码来做反汇编实验:
int test1(int *p) { return *p++; } int test2(int *p) { return *++p; } int test3(int *p) { return ++*p; } int test4(int *p) { return (*p)++; }在x86-64平台上,用gcc -O2 -S编译后,你会发现几个函数生成的汇编指令数量不同,但核心就几条:
test1:先movl (%rdi), %eax,再把%rdi加4,返回旧值。test2:先把%rdi加4,再movl (%rdi), %eax,返回新地址处的值。test3:incl (%rdi)同时影响内存,然后movl (%rdi), %eax读取自增后的值。test4:movl (%rdi), %eax保存旧值,然后incl (%rdi),返回旧值。
反汇编一眼看出来,后缀返回旧值、前缀返回新值不是“概念上的约定”,而是编译器实实在在生成的机器指令序列。理解到这个层面,你对优先级的理解才算真正落地。
5.3 用静态分析工具和Clang-Tidy约束团队书写
前面说了,这种复合表达式一旦写错,排错成本极高。所以实际工程项目里,我会把两个工具开起来:
- 开启编译器警告
-Wall -Wextra,让-Wsequence-point之类的警告帮我们盯住副作用相关的未定义行为。 - 用
clang-tidy的cppcoreguidelines-pro-bounds-pointer-arithmetic等规则提醒团队注意指针算术。 - 在代码评审规范里明确规定:出现
*p++这种复合表达式时,必须有注释解释语义。尤其在涉及二级指针或者函数指针时,强制加括号,禁止挑战人类理解极限。
我见过很多新人写代码喜欢在网上找各种短小精悍的写法,比如一行遍历、一行拷贝。但我始终建议,在性能允许的前提下,优先考虑可读性。while (n-- > 0) *dst++ = *src++;这种经典写法可以保留,因为它足够通行;但再往前多跨一步,写出**++dp这种三重复合表达式,就是对队友审美和心智的考验了。
6. 优先级之外:那些容易被牵连的基础概念
6.1 左值、右值与副作用:为什么(*p)++必须要求*p是左值
后置自增++要求它的操作数必须是可修改的左值,也就是必须要有一个实实在在的内存地址可以被修改。在(*p)++中,*p就是左值,因为它解引用后对应着一个具体的对象。但在*p++中,*的操作数是p++的结果,这个结果是一个“右值”,是临时保存的旧地址,所以它只是被读取,不会被修改。
很多人搞不懂“左值右值”,我可以给一个粗浅但好用的理解方式:左值是能出现在赋值号左边的东西,右值是不能出现在左边的东西。*p能,所以它是左值;p++的结果不能,所以它是右值。++*p合法,是因为*p是左值,前缀++能在原处修改它;++(p++)非法,是因为p++这个右值无法被自增。
面试笔试里还有一种阴险考法:给你int *p;,然后问*p++ = 1合法吗?答案是合法,因为*p++解析为*(p++),最终得到的还是一个左值*p_old,赋值会作用在指针移动前的目标内存上。这种写法在旧式代码里有时会出现,但我个人不建议主动使用,因为逻辑太绕,维护成本极高。
6.2 顶层const、底层const与指针自增的兼容问题
前面提到过热词“顶层指针和底层指针可以相互赋值吗”,这里正式聊一下。C语言里虽然不叫顶层底层,但大家习惯借用C++的说法:
- 顶层const:指针本身不能改,如
int *const p;,此时需要对p自增的写法(如*p++里的指针自增)都非法。 - 底层const:指向的对象不能改,如
const int *p;,此时需要对对象自增的写法(如++*p、(*p)++)都非法。
这两种指针之间的赋值规则可以简化记忆:要么“权限从不收窄到收窄”,要么“权限不变”。把非const指针赋给const指针是允许的,因为你在限制自己;把const指针赋给非const指针则不行,因为这会绕过限制,编译器会直接拒绝。
回到本主题,它和优先级的关联是:++*p编译报 “increment of read-only location” 时,你得先怀疑是不是底层const;*p++报 “read-only variable is not assignable” 时,你得先怀疑是不是顶层const。能同时看懂这两种报错,说明你不是光记了优先级,而是把指针的整个类型系统串起来了。
6.3 序列点与未定义行为:不要在一条表达式里过度发挥
优先级能告诉你*p++是“先取旧值再移动指针”,但它不能告诉你多个副作用之间的先后顺序。C语言里有一个概念叫“序列点(sequence point)”,在同一个序列点之间对同一个变量做多次未排序的修改,属于未定义行为(undefined behavior)。
举例:
*p++ = *p; /* 危险:左右两侧都对p进行了读取,但自增与右侧读取的先后顺序未定义 */这种写法看起来想做什么?“把当前值copy到下一个位置?”但它到底是把p[0]复制到p[0](如果是先自增再取右侧)还是把p[0]复制到p[1](如果是先取右侧再自增),标准并没有保证。不同编译器不同优化级别下行为都可能不一样。
正确的做法是拆成两步:
*p = *p; p++;或者如果你是想把当前值赋给后一个位置,那就是:
p[1] = *p;这里我强烈建议各位读者建立一条铁律:一条语句里,不要对同一个变量做两次未排序的修改,也不要在一个副作用表达式里同时读取同一个变量的其他状态,除非你有十足的把握。靠脸debug是没有前途的。
7. 实战速查与记忆口诀:把四兄弟彻底吃透
讲到这里,核心内容已经差不多全了。最后给你一套我总结的记忆方式,方便你临考、刷题、coding时快速调用:
7.1 一眼定生死:看到表达式先找“被自增的对象”
面对任何复合表达式,第一步不是看运算优先级表,而是看“谁和谁先结合”。方法很简单:
- 如果没有括号,就按照“后缀++/--优先于前缀单目,单目运算符从右向左结合”的规则强行绑对。
- 绑对之后,看自增符号是贴在指针变量
p上、还是贴在解引用结果*p上。 - 如果自增贴在
p上,那就是指针在移动;如果自增贴在*p上,那就是指向的值在变化。 - 最后再看自增是前缀还是后缀,来决定返回值是新值还是旧值。
我把这四个表达式用一个表格做终极对比,你也可以把这个表收藏起来,面试前快速过一眼:
| 表达式 | 解析结果 | 操作对象 | 返回的值 | 口语化记忆 |
|---|---|---|---|---|
*p++ | *(p++) | 指针p | p旧位置的值 | 先取值,指针再走 |
*++p | *(++p) | 指针p | p新位置的值 | 指针先走,再取值 |
++*p | ++(*p) | 指向的对象 | 自增后的新值 | 给当前值加1,结果返回新值 |
(*p)++ | (*p)++ | 指向的对象 | 自增前的旧值 | 给当前值加1,但返回旧值 |
7.2 常考易错题十连测:你能答对几道
下面这些题目都来自我平时带人时整理的错题集,每道题目背后的考点都指向同一个优先级问题。你先自己做,然后看解析。
int a[] = {1,2,3,4}; int *p = a;问*p++ + *p++的值?
这题其实是未定义行为,但很多考试题的作者没意识到,会给出一个“预期答案”。严格来说,同一表达式内对p多次后缀自增且求值顺序未排序,属于未定义行为。如果非要按“从左到右”理解,结果是1 + 2 = 3,但我不建议在任何真实代码里这么写。接上题,如果写成
(*p++)++,请问它自增的是谁?
通过括号先解析:(* (p++) )++,即先取p旧位置的值,然后把那个值自增。整个表达式对a[0]自增,返回旧值1,最终a[0]变为2,p也移动到a[1]。char *s = "abc"; while (*s++) ;执行完,s指向哪里?
每次*s++取一个字符,直到取出末尾的'\0'(注意,'\0'的值是0,while结束)。此时s++仍然会再执行一次,所以s指向的是字符串末尾'\0'的下一个位置。很多人没注意“结束条件判断之后还多移动了一次”的细节。++*p++怎么解析?
后缀++优先级最高,先绑给p,得p++;然后*和前缀++同一优先级,从右向左,所以先*p++,再++作用于*p++的结果,最终等于++(*(p++))。效果是取旧地址处的值并自增,然后指针移动。这种题目在考试里很常见,专门考你优先级顺序的叠加能力。*p += 1和(*p)++有区别吗?
若只看最终效果,两者都让*p的值加1,结果都是新值(因为+=返回的是新值,而(*p)++返回旧值)。左值要求上,两者都要求*p是左值。区别在于返回值:*p += 1返回自增后的新值;(*p)++返回旧值。如果
p指向const限定的变量,++*p合法吗?
不合法。“increment of read-only location” 就是典型报错。此时需要转型或另想办法,但常规逻辑下不应该对const对象做修改。int *p = NULL; ++*p;会怎样?
编译能过,运行大概率崩溃。因为++*p会对NULL地址解引用并写内存。这类题考的是“语法合法不等于运行时安全”。(*p)++和++(*p)在表达式中作为整个右值使用时,区别是什么?
前者返回旧值,后者返回新值。例如a = (*p)++得到的是加1前的值;a = ++(*p)得到的是加1后的值。p是int *,p++和++p的区别?
表达式结果不同:p++产生旧地址,++p产生新地址。副作用都是一样的让p增加一个int的大小。C语言里单独写p++;和++p;作为语句,效果完全一样,没有区别。什么时候必须用括号?
但凡你想表达“让指针指向的对象自增”,都建议用括号明确写(*p)++或++(*p)。同理,任何“取指针指向的对象并指针自增”的歧义场景,写*(p++)也远比*p++对新手更友好。
7.3 我个人的习惯:写读自己时怎么用
说点私人经验。我写代码有一个不成文的习惯:单独语句里能拆就拆,复合表达式保留在热点循环里。比如*p++这种“遍历+移动”的二合一写法,在字符串拷贝、循环处理数组时写起来非常舒服,性能和可读性平衡得很好,我用得很顺手。但一旦表达式中再叠加第二个运算符,比如++*p++,我几乎一定会拆成两行:
++(*p); p++;有人会觉得这样多写了两行,不够“专业”。但我见过太多次因为一行过于聪明的代码导致的线上问题。代码首先是给人看的,其次才是让机器跑的。如果你在面试的时候写++*p++并且加了一行注释,面试官反而会觉得你理解得清楚;如果你写了不拆但说不清语义,那就是给自己挖坑。
最后再分享一个记忆技巧:你把这四个表达式想象成“取件”和“寻址”的游戏。*p++是“取出当前包裹,然后你移动到下一个货架”;*++p是“你先走到下一个货架,再拿包裹”;++*p是“把当前包裹里的东西加一个新物件,然后给你看加完后的结果”;(*p)++是“你先登记包裹现状,然后往里面塞东西,最后把登记表给你”。这个小类比帮我在最开始学指针的时候熬过了最难的一个星期,希望对你也有帮助。
指针这东西,你躲是躲不掉的。C语言里有一半以上的坑,要么和指针有关,要么和“运算符优先级导致你原本想操作A结果操作了B”有关。把*和++的关系在纸上推演一百遍,不如亲手写一遍、编译一遍、看一遍反汇编。真正理解这四个表达式之后,你再去看函数指针、二级指针、复杂声明解析,会觉得思路通畅很多。那些用C语言写的文件读写操作、虚拟存储器管理实验、数据结构课设,底层逻辑也就是这些基础语法的不断重复和组合。希望这篇拆解能帮你把那层窗户纸捅破。