🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位 :接续上篇的位运算,读懂逗号表达式、
[]、函数调用()、结构成员访问.与->,再解决更核心的问题:表达式被怎样分组,并不等于各部分按什么时间顺序执行。💡 学习目标:会计算逗号表达式;能正确访问数组与结构体成员;理解优先级、结合性、整数提升和通常算术转换;识别未定义行为与未指定求值顺序,并把复杂表达式改成可预测的代码。
📌 阅读说明:根据课程 PDF《第10讲:操作符详解》第 6~11 节整理。上篇已解释进制、补码、移位与位运算。本篇按 C11 讨论规则;对讲义中的示例做了必要的语法修正和边界说明。
文章目录
- 一、单目操作符:一个操作数,多种角色
- 二、逗号表达式:明确规定的从左到右
- 三、下标访问与函数调用:符号相近,规则不同
- [四、结构成员访问:
.与->怎么选](#四、结构成员访问:. 与 -> 怎么选) - 五、优先级与结合性:解决的是分组
- 六、整数提升与通常算术转换
- 七、表达式求值:分组之外还要看求值顺序
- 八、把风险表达式改写成可预测的代码
- 九、高频问答与练习
- 总结
一、单目操作符:一个操作数,多种角色
"单目"只表示它处理一个操作数 ,并不表示只有一种用途。课程列出 !、++、--、&、*、一元 +、一元 -、~、sizeof 和类型转换 (类型) 等。
| 写法 | 含义 | 例子 |
|---|---|---|
!x |
逻辑非,零变真、非零变假 | !0 为 1 |
++x / x++ |
增加 1,返回值时机不同 | 前置返回更新后的值;后置返回原值 |
--x / x-- |
减少 1,返回值时机不同 | 同上 |
&x |
获取对象地址 | int *p = &x; |
*p |
通过指针访问对象 | *p = 10; |
~x |
按位取反 | ~0U |
sizeof x |
求类型或表达式结果类型的大小 | 结果类型为 size_t |
(double)x |
显式转换类型 | 让除法按浮点数计算 |
例如:
c
int x = 3;
int a = ++x; /* x=4,a=4 */
int b = x++; /* b=4,之后 x=5 */
这里的前置和后置区别只说明表达式的值 。不要据此推断它与同一个大表达式里其他子表达式的执行先后,更不要把 ++x + ++x 当成普通算术题。
sizeof 也有一个常见陷阱:它通常不会求值其操作数表达式(可变长数组的相关情况是例外),但会根据类型 决定字节数。sizeof array 对真实数组得到整个数组大小;数组作为函数形参时会调整为指针类型,不能再用 sizeof 参数 得到原数组长度。

二、逗号表达式:明确规定的从左到右
逗号操作符把两个表达式连接起来:先求左边并完成其副作用,再求右边;整个逗号表达式的值是右边表达式的值。多个逗号连续出现时,按左结合分组,可以按从左到右跟踪。
c
int a = 1;
int b = 2;
int c = (a > b, a = b + 10, a, b = a + 1);
逐项计算:
| 步骤 | 表达式 | 状态或结果 |
|---|---|---|
| 1 | a > b |
1 > 2 为假;该结果被丢弃 |
| 2 | a = b + 10 |
a = 12 |
| 3 | a |
值为 12,仍被丢弃 |
| 4 | b = a + 1 |
b = 13,也是整个表达式的值 |
因此最后 a == 12、b == 13、c == 13。括号不可随意去掉,因为逗号操作符的优先级很低。

2.1 有逗号,不一定就是逗号操作符
c
printf("%d %d\n", a, b);
int numbers[] = {1, 2, 3};
函数实参之间、初始化列表项之间的逗号是分隔符 。它们不享有逗号操作符"左边完成后才求右边"的规则。尤其是函数 f(left(), right()):两个实参表达式的求值先后没有由书写顺序指定。
若确实需要强制先后,写成独立语句更直接:
c
int left_value = left();
int right_value = right();
f(left_value, right_value);
讲义还把读取、计数与判断压进一个 while 条件里。语法上可用逗号表达式实现,但把副作用拆开,通常更容易调试:
c
for (;;)
{
int value = get_val();
count_val(value);
if (value <= 0)
break;
/* 处理 value */
}
这段写法仍需按实际业务定义处理结束标记或错误返回值,但每步执行时机一眼可见。
三、下标访问与函数调用:符号相近,规则不同
3.1 []:从起点找到一个元素
c
int arr[10] = {0};
arr[9] = 10;
printf("%d\n", arr[9]);
下标表达式 arr[9] 可按 *(arr + 9) 理解:从 arr 指向的首元素位置移动 9 个元素,再访问它。合法下标是 0 到 9。越界不是"读到下一个格子也许能用",而是未定义行为。
c
int arr[10] = {0};
/* arr[10] = 1; 错误:最后一个合法元素是 arr[9] */
下标里也可以是计算表达式,如 arr[i + 1],但要确保计算出的最终下标仍在有效范围内。
3.2 ():调用函数,实参个数可为零
c
#include <stdio.h>
void greet(void)
{
puts("hello");
}
void print_number(int value)
{
printf("%d\n", value);
}
int main(void)
{
greet();
print_number(10);
return 0;
}
greet() 的实参个数是 0 ,这也是合法的函数调用。函数调用前会先准备实参,然后进入函数体;但多个实参之间的求值顺序没有由它们在括号中的左右位置规定。需要先后关系时,分别算好再传入。

四、结构成员访问:. 与 -> 怎么选
一个 int 不够描述一名学生:还可能需要姓名、年龄、学号等。结构体把这些不同类型的成员组织成一个新类型。
c
struct Student
{
char name[20];
int age;
unsigned int id;
}; /* 结构类型声明末尾的分号不能漏 */
声明并初始化一个变量:
c
struct Student student = {"Zhang", 20, 1001U};
printf("%s %d %u\n", student.name, student.age, student.id);
4.1 有结构体对象,用 .
student.age 访问的是 student 这个结构体对象 里的成员 age。C11 还支持指定初始化器:
c
struct Student student = {.id = 1001U, .age = 20, .name = "Zhang"};
这样初始化时可不按声明顺序书写,未明确初始化的成员会按初始化规则处理。指定初始化器是 C99 起的标准 C 语法。
4.2 有结构体指针,用 ->
c
struct Student student = {"Zhang", 20, 1001U};
struct Student *ptr = &student;
ptr->age = 21;
printf("%d\n", (*ptr).age); /* 21 */
ptr->age 等价于 (*ptr).age。后者的括号非常重要:. 的优先级高于一元 *;若写成 *ptr.age,会先尝试访问 ptr.age,但 ptr 是指针,不是结构体对象。

4.3 把结构体传入函数:值与地址不同
c
#include <stdio.h>
struct Point
{
int x;
int y;
};
void print_point(struct Point point)
{
printf("(%d, %d)\n", point.x, point.y);
}
void move_right(struct Point *point)
{
if (point != NULL)
point->x += 1;
}
int main(void)
{
struct Point p = {3, 4};
print_point(p);
move_right(&p);
print_point(p);
return 0;
}
print_point(p) 接收一份结构体值;move_right(&p) 接收原对象的地址,可以修改原对象。指针解引用之前先确认它满足函数约定,这里用 NULL 检查展示基本防护。
五、优先级与结合性:解决的是分组
优先级与结合性回答的是:一个表达式按什么结构解释。例如:
c
3 + 4 * 5 /* 解释为 3 + (4 * 5),结果 23 */
5 * 6 / 2 /* 解释为 (5 * 6) / 2,结果 15 */
a = b = 7 /* 解释为 a = (b = 7) */
*、/ 优先级高于 +;同一级的 *、/ 左结合;赋值操作符右结合。因此这些表达式如何分组可以确定。
不过,"(5 * 6) / 2 左结合"并不是一条"编译器必须先读变量 5 再读 6"的求值时间表。操作数若带函数调用或 ++ 等副作用,就必须另外检查求值顺序规则。
常见的部分层级如下,越往下优先级越低;这张表只用于阅读,不建议靠背完整表写出复杂表达式。
| 层级 | 操作符 | 结合方向 |
|---|---|---|
| 1 | 后缀 ++ --、调用 ()、下标 []、.、-> |
左到右 |
| 2 | 前缀 ++ --、一元 + -、! ~、取地址 &、间接访问 *、sizeof、类型转换 |
右到左 |
| 3 | * / % |
左到右 |
| 4 | + - |
左到右 |
| 5 | << >> |
左到右 |
| 6 | < <= > >= |
左到右 |
| 7 | == != |
左到右 |
| 8 | 按位与 & |
左到右 |
| 9 | 按位异或 ^ |
左到右 |
| 10 | 按位或 ` | ` |
| 11 | 逻辑与 && |
左到右 |
| 12 | 逻辑或 ` | |
| 13 | 条件 ?: |
右到左 |
| 14 | = 及 +=、-= 等复合赋值 |
右到左 |
| 15 | 逗号操作符 , |
左到右 |
括号是给读者的路标:当分组不明显时,主动加括号。它能改变或明确分组,但不能给普通算术操作数凭空增加求值顺序保证。

六、整数提升与通常算术转换
写 char + char,结果不一定还是 char。C 的整数提升会先把较窄的整数类型提升到至少 int 或 unsigned int 再参与运算。
6.1 整数提升:小类型先"长大"
c
signed char left = 100;
signed char right = 20;
int sum = left + right; /* 两个操作数先提升为 int,结果为 120 */
对 char、signed char、unsigned char、short 等,若 int 能表示该类型的所有值 ,就提升为 int;否则提升为 unsigned int。因此不能简单背成"有符号补符号位、无符号补零"并认为那就是完整语言规则。
而且,普通 char 的有符号性由实现决定。若示例需要明确讨论负值,写 signed char;需要明确讨论字节中的非负值,写 unsigned char。
c
signed char a = 100;
signed char b = 20;
signed char result = (signed char)(a + b);
上面的加法先在提升后的类型中计算,赋回小类型时再发生转换。若结果超出目标有符号类型可表示范围,转换结果由实现定义,不能通用地说成"只截掉高位"。这里 120 在常见 8 位 signed char 范围内,但示例是否可移植仍应根据实现的类型范围判断。
6.2 通常算术转换:两边类型不同怎么办
当算术或比较操作需要共同类型时,会按一组规则转换操作数。浮点和整数相遇时通常向浮点类型转换;两个整数相遇时还要看整数转换等级 与有符号/无符号范围,不能只把类型名排成一条"谁排得高就转成谁"的链。
一个很典型的例子:
c
int negative = -1;
unsigned int positive = 1U;
if (negative < positive)
puts("negative is smaller");
在这里两者是同等级的 int 与 unsigned int,negative 会转换成 unsigned int;-1 转换后的值是 UINT_MAX,所以比较结果为假。这常让只按数学整数直觉阅读的人吃惊。

需要表达"负数一定小于非负数"时,可以先显式处理负值,再在适当范围内比较:
c
if (negative < 0 || (unsigned int)negative < positive)
{
/* 条件成立 */
}
|| 的短路保证:当 negative < 0 为真时,右边不求值。若为假,negative 已是非负,转换为 unsigned int 不会改变其数学值。
七、表达式求值:分组之外还要看求值顺序
课程最后一节的真正难点,不是记住每种操作符优先级,而是区分下面三件事:
- 分组:表达式按什么结构解释,由语法、优先级和结合性决定。
- 求值顺序:多个子表达式按什么时间先后计算,必须由专门的规则决定。
- 副作用是否冲突:若对同一对象的修改与另一次修改或读取没有必要的先后约束,可能是未定义行为。

7.1 a*b + c*d + e*f:结构确定,时间顺序未全定
分组相当于 (a*b + c*d) + e*f。每个 * 的结果要用于相应的 +,但这不意味着编译器必须在第一次加法前把三个乘法全做完。若 a~f 都是普通稳定数值,这通常不影响最终数学结果;若换成带副作用的函数调用,就应仔细检查。
7.2 c + --c:未定义行为,不是"结果不稳定"
c
int c = 5;
int result = c + --c; /* 不要这样写 */
+ 的左右操作数没有规定先后,表达式一边读取 c,另一边又修改 c,两者没有必要的顺序关系。在 C11 中属于未定义行为。未定义行为不能靠"我的编译器输出 9"或"加一对括号"变成合法题目。
c
int c = 5;
--c;
int result = c + c; /* 定义明确,result = 8 */
课程里的 (++i) + (++i) + (++i),以及同一大表达式里多次 i--、--i、i++、++i,也有同类问题。不同编译器出现不同输出只是症状,不能把某一个输出当成标准答案。
7.3 多次调用会修改状态的函数:先后未指定,结果也可能变
把课程示例里的头文件拼写修正为 <stdio.h> 后,来看:
c
#include <stdio.h>
int next_value(void)
{
static int count = 1;
return ++count;
}
int main(void)
{
int answer = next_value() - next_value() * next_value();
printf("%d\n", answer);
return 0;
}
这里调用各自完整执行,但三个调用谁先执行没有指定 。它们依次可能返回 2、3、4,而这三个返回值分配给减法左操作数、乘法两操作数的方式可能不同;乘法分组明确,不等于调用次序明确。此例适合归为未指定的求值顺序导致结果可能不同 ,不要与 c + --c 的未定义行为混成同一类。
想要确定的业务含义,就写清调用顺序:
c
int first = next_value();
int second = next_value();
int third = next_value();
int answer = first - second * third; /* 2 - 3 * 4 = -10 */
7.4 哪些操作符给出了先后保证
| 形式 | 保证 |
|---|---|
left, right(逗号操作符) |
左操作数完成后才求右操作数 |
left && right |
左边先求;若为假,右边不求 |
| `left | |
condition ? yes : no |
先求条件,只求选中的分支 |
| 独立的两个完整语句 | 前一语句完成,再执行后一语句 |
注意函数实参列表里的逗号不属于表中的逗号操作符。普通 +、*、- 与括号也没有提供"从左往右逐项执行"的承诺。
八、把风险表达式改写成可预测的代码
分析一个复杂表达式,可以按照这四步走:
- 画出分组:先根据语法、优先级和结合性加上帮助阅读的括号。
- 标记每个读取与修改 :尤其找
++、--、赋值、函数调用、volatile访问等。 - 寻找明确的先后关系:逗号操作符、逻辑短路、条件操作符、语句边界等能否约束这些副作用?
- 若业务需要唯一顺序,拆成语句:让编译器和读者都无须猜测。

例如想"读取旧值,再递增,最后与另一个数相加":
c
int old_value = counter;
counter += 1;
int result = old_value + bonus;
这比把读取、修改和求和挤进一行更稳妥。代码行数多一点,检查成本却低很多。
九、高频问答与练习
Q1:a = b = 7 为什么能连续赋值?
赋值操作符右结合,分组为 a = (b = 7)。先把 7 赋给 b,赋值表达式再产生相应值供外层赋值使用。这里同一个对象没有冲突修改。
Q2:加括号能修好 i++ + ++i 吗?
不能。(i++) + (++i) 只是更明确地写出分组,没有给两次修改建立顺序。拆成独立语句。
Q3:ptr->x 与 (*ptr).x 是否完全一样?
就成员访问而言,两者等价。前提是 ptr 指向有效的相应结构体对象;空指针或失效指针不能因为用了 -> 就安全。
Q4:sizeof 的单位是什么?
结果以 字节 为单位;C 的"字节"是 char 的大小,sizeof(char) == 1。一个字节包含多少位可查 CHAR_BIT,不能对所有实现都默认 8 位。
动手练习
- 判断
int x = 1, y = 2; int z = (x += 3, y += x, y);执行后的三个值。 int arr[4] = {10, 20, 30, 40};,arr[3]与arr[4]哪个有效?- 对
struct Point p和struct Point *q = &p,分别写出访问x的表达式。 - 解释为什么
f(g(), h())不保证先调用g,并改写成保证g先执行的代码。 int x = 4; int y = x + ++x;能否推算y?为什么?
参考答案 :第 1 题 x=4、y=6、z=6;第 2 题只有 arr[3] 有效;第 3 题 p.x 与 q->x;第 4 题先执行 int gv = g();,再执行 int hv = h();,最后 f(gv, hv);;第 5 题不能,读取与修改同一对象缺少必要顺序,行为未定义。
总结
操作符学习可以分两层:先知道每个符号做什么,再知道类型转换、分组和求值顺序 如何共同影响结果。[]、()、.、-> 解决访问与调用;逗号操作符提供真正的先后;整数提升和通常算术转换改变运算类型;优先级与结合性只解决表达式的结构。遇到多个副作用时,拆成清楚的语句,通常就是最好的答案。
资料 :课程 PDF《第10讲:操作符详解》;ISO C11 工作草案 N1570 的表达式、整数提升与通常算术转换条款。本文示例和示意图为重新整理与绘制。