运算符与表达式:从优先级到未定义行为
以 i = i++ 的事故现场开场:整数除法向零截断与 % 的符号、整型提升与寻常算术转换的完整阶梯、短路求值实验、a < b < c 陷阱、函数参数求值顺序实验、优先级速查表与三条记忆法,-Wall 与 UBSan 抓表达式错误的调试实录。
前置知识
分工说明:类型转换在 数据类型详解 只做概览——那里给出「转换存在、可能丢精度」的一句话,并把读者引到本篇;完整的整型提升、寻常算术转换阶梯表与配套实验都安家在本篇第 2 节。位运算同理:本篇第 4 节只留一段概览,完整讲解在 位运算与位域。
学习目标
读完本文你将能够:
- 说清整数除法自 C99 起向零截断、% 的结果符号跟随左操作数,知道浮点取余要用 fmod;
- 默写整型提升与寻常算术转换的阶梯表,预测 char + char 与 int + unsigned 的结果类型;
- 用短路求值写出判空惯用法,识别 a < b < c 比较链的陷阱;
- 解释 i = i++ 为什么是未定义行为,为什么函数参数的求值顺序不可依赖;
- 用 -Wall、-Wparentheses 与 UBSan 当场抓出 = 误写 ==、有符号溢出、移位越界类表达式错误。
预计 60 到 80 分钟,含 4 组动手实验与 3 道练习。
1. 问题引入:能跑的代码不等于对的代码
/* accident.c */
#include <stdio.h>
int main(void) {
int i = 10;
i = i++; /* 想法:先取原值,自增,再赋回去。结果该是多少? */
printf("i = %d\n", i);
return 0;
}这段代码在多数编译器上编译无错、运行不崩,答案却花样百出:有人打印 10,有人打印 11,同一个编译器加不加 -O2 结果还可能不同。这不是编译器的 bug,是标准把结果留白了——这样的表达式叫未定义行为(undefined behavior,UB):标准不规定结果,编译器想怎么生成代码都合法。第 5 节会拿标准原文给这句代码「判刑」,现在先把每一类运算符「有定义」的部分练扎实。
2. 算术运算符:除法、取余与类型阶梯
2.1 五个基本运算符
以 int a = 10, b = 3; 为例:
| 运算符 | 含义 | 表达式 | 结果 |
|---|---|---|---|
+ | 加法 | a + b | 13 |
- | 减法 | a - b | 7 |
* | 乘法 | a * b | 30 |
/ | 除法 | a / b | 3(小数部分丢弃) |
% | 取余 | a % b | 1 |
2.2 整数除法与取余:向零截断,符号跟左
/* divmod.c:除法与取余的符号行为 */
#include <stdio.h>
int main(void) {
printf("10 / 3 = %d\n", 10 / 3); /* 3:小数部分直接丢弃 */
printf("-10 / 3 = %d\n", -10 / 3); /* -3:向零截断,不是 -4 */
printf("10 / -3 = %d\n", 10 / -3); /* -3 */
printf("-7 %% 3 = %d\n", -7 % 3); /* -1:余数符号跟随左操作数 */
printf("7 %% -3 = %d\n", 7 % -3); /* 1 */
printf("10.0 / 3 = %f\n", 10.0 / 3); /* 3.333333:有浮点数参与才是浮点除法 */
return 0;
}三条规则(依据 cppreference 算术运算符页):
- 两个整数相除,结果是丢掉小数部分的代数商。C99 起明确规定向零截断,所以
-10 / 3是-3而不是-4(C99 之前截断方向由实现定义,这是老代码跨平台翻车的著名来源); %只能用于整数,结果满足恒等式(a / b) * b + a % b == a。由它推出:余数的符号跟随左操作数(被除数)。判奇偶时写n % 2 != 0而不是n % 2 == 1,后者对负奇数判断错误;- 除数为 0 是未定义行为;商在结果类型里放不下同样是 UB,最著名的是
INT_MIN / -1与INT_MIN % -1。
浮点没有 %:fmod(x, y) 计算浮点余数,公式 x - n*y(n 为 x/y 向零截断),结果符号同样跟随 x。fmod(-5.1, 3.0) 是 -2.1。
2.3 整型提升与寻常算术转换:类型相撞的裁决阶梯
char + char、int + unsigned 这类「类型不同」的表达式,C 按两步裁决:
第一步:整型提升。char、short 以及位段等小于 int 的操作数先提升为 int(int 装不下时提升为 unsigned int)——CPU 运算单元天然按 int 宽度干活,窄类型进寄存器前必须补齐。
第二步:寻常算术转换。提升后类型仍不同,按下面的阶梯从高到低找第一个「在场」的类型作为公共类型:
| 层级 | 类型 |
|---|---|
| 1 | long double |
| 2 | double |
| 3 | float |
| 4 | unsigned long long |
| 5 | long long |
| 6 | unsigned long |
| 7 | long |
| 8 | unsigned int |
| 9 | int |
细则:同符号就取层级高的;有符号与无符号相遇,只要无符号一方的层级不低于有符号一方,就把有符号的一方转成无符号——这条细则是下一组实验的主角。
2.4 实验一:char + char 的 sizeof
/* promote.c:用 sizeof 观察提升 */
#include <stdio.h>
int main(void) {
char a = 'A', b = 'B';
printf("sizeof(a) = %zu\n", sizeof(a)); /* 1 */
printf("sizeof(a + b) = %zu\n", sizeof(a + b)); /* 4:先提升成 int 再相加 */
printf("sizeof('A') = %zu\n", sizeof('A')); /* 4:字符字面量的类型本来就是 int */
printf("sizeof(1 + 2L) = %zu\n", sizeof(1 + 2L)); /* 8:long 在场,int 升 long */
return 0;
}一次典型输出(按 int 为 4 字节、long 为 8 字节的常见平台,如 Linux/macOS;Windows 上 long 是 4 字节,最后一行打 4):
sizeof(a) = 1
sizeof(a + b) = 4
sizeof('A') = 4
sizeof(1 + 2L) = 8a 和 b 各占 1 字节,但 a + b 占 4 字节——加法发生的舞台不是 char 而是 int。这条规则有个深远后果:读文件要用 int ch 接 fgetc 的返回值(文件 I/O 第 4 节会再遇到它)。
2.5 实验二:-1 < 1u 为什么是假
/* unsigned_trap.c:有符号与无符号混算的经典坑 */
#include <stdio.h>
int main(void) {
int i = -1;
unsigned u = 1u;
printf("-1 < 1u 的结果:%d\n", i < u);
printf("(unsigned)i = %u\n", (unsigned)i);
return 0;
}预期输出:
-1 < 1u 的结果:0
(unsigned)i = 4294967295int 与 unsigned int 同层级,按细则转成 unsigned int 比较。-1 转成无符号不是 1,而是绕回最大值 4294967295(32 位 int 时),于是「-1 小于 1」为假。规避法:让比较双方的符号一致,size_t 与 int 混用是此坑的现代高频版本(练习题里亲手修一次)。
3. 关系与逻辑:答案只有 0 和 1
3.1 六个关系运算符与两个逻辑运算符
| 运算符 | 含义 | a=10, b=3 时的结果 |
|---|---|---|
== != | 等于、不等于 | a == b 为 0,a != b 为 1 |
> < | 大于、小于 | a > b 为 1,a < b 为 0 |
>= <= | 不小于、不大于 | a >= b 为 1,a <= b 为 0 |
两个要点:
- 比较的结果是
int的 0 或 1,C 语言里条件真就是 1、假就是 0; - 区分
!(逻辑非,一元)与!=(不等于,二元):!x等价于x == 0,优先级还比算术高(第 7 节记忆法一)。!(a > 0)与a <= 0等价。
3.2 短路求值:右边可能根本不执行
&& 左边为假时右边不算,|| 左边为真时右边不算。这是标准保证的行为,不是优化技巧——cppreference 求值顺序页明确:&& 与 || 的第一个操作数求值之后有顺序点,然后才轮到第二个。
/* shortcircuit.c:用副作用验证短路 */
#include <stdio.h>
int main(void) {
int x = 5, y = 5;
printf("%d\n", (x == 0) && ++y); /* 0:左边已定胜负,++y 没执行 */
printf("y = %d\n", y); /* 5,不是 6 */
printf("%d\n", (x != 0) || ++y); /* 1:++y 又没执行 */
printf("y = %d\n", y); /* 仍然是 5 */
return 0;
}预期输出:
0
y = 5
1
y = 5最重要的正面惯用法是先判空再解引用:if (p != NULL && p->len > 0),指针为空时后半句绝不执行(-> 的细节见 指针深度解析)。反面的写法是把有副作用的调用(打日志、计数、释放资源)塞在 && 右边「顺便」执行——条件一旦短路它就悄悄不跑,这种 bug 最难查。需要必执行的语句,就老老实实分开写。
3.3 比较链陷阱:a < b < c 不是数学
/* chain.c */
#include <stdio.h>
int main(void) {
int a = 5, b = 3, c = 1;
printf("%d\n", a > b > c); /* 0:(a > b) 得 1,1 > 1 为假;数学上 5>3>1 应为真 */
printf("%d\n", 3 < 2 < 1); /* 1:(3 < 2) 得 0,0 < 1 为真;数学上应为假 */
return 0;
}预期输出:
0
1< 从左到右结合,a > b > c 实际是 (a > b) > c:左边先算成 0 或 1,再拿这个 0/1 与 c 比较。数学区间写法在 C 里几乎全错,正确写法是 a > b && b > c。
4. 位运算概览:六个运算符速写
以 unsigned a = 6(二进制 0110)、b = 3(0011)为例:
| 运算符 | 含义 | 表达式 | 结果 |
|---|---|---|---|
& | 按位与 | a & b | 2(0010) |
| | 按位或 | a | b | 7(0111) |
^ | 按位异或 | a ^ b | 5(0101) |
~ | 按位取反 | ~a | 9(32 位下即 0xFFFFFFF9) |
<< | 左移 | a << 1 | 12(1100) |
>> | 右移 | a >> 1 | 3(0011) |
典型用途一句话:& 配掩码「取字段」、| 配标志「开开关」、^ 做「翻转与校验」、移位做「倍乘与打包」。检查、设置、清除、翻转某一位的四个惯用宏,以及移位的符号位陷阱与位段,全部在 位运算与位域 展开——本篇只提醒一件事:<< 与 >> 的优先级(第 7 节第 5 档)夹在算术与关系之间,且对有符号负数右移结果由实现定义,位运算前先想清楚操作数该不该是无符号。
5. 赋值、自增自减与求值顺序
5.1 赋值与复合赋值
= 把右操作数的值存进左操作数,整个赋值表达式的值就是存进去的值,于是有右结合的连续赋值:a = b = c 即 a = (b = c)。十个复合赋值 += -= *= /= %= <<= >>= &= ^= |= 都是 a op= b 与 a = a op b 的简写,差别只有一处:前者左操作数只求值一次——左值本身带副作用时这个差别有意义。
5.2 i++ 与 ++i:一个用旧值,一个用新值
int c = 5;
printf("%d\n", c++); /* 5:先取旧值,再自增 */
printf("%d\n", c); /* 6 */
printf("%d\n", ++c); /* 7:先自增,再用新值 */
printf("%d\n", c); /* 7 */单独成句时 i++ 与 ++i 等价,团队惯例是循环里统一用 i++。要警惕的是它们出现在别的表达式里的时候——下一节解释为什么。
5.3 序列点:一条语句至多修改一个对象一次
把第 1 节的 i = i++ 送审。cppreference 求值顺序页转述的标准规则:
若对一个标量对象的副作用与同一标量对象的另一个副作用之间没有先后关系,行为未定义;若对一个标量对象的副作用与使用其值的值计算之间没有先后关系,行为同样未定义。
i = i++ 里 = 和 ++ 是同一对象的两个副作用,谁先谁后标准不排,于是整个表达式是 UB;同罪的还有 i = ++i + i++、f(++i, ++i)、f(i, i++)、a[i] = i++。而 &&、||、?:、逗号是少数给操作数排了顺序的运算符(3.2 节的短路实验正是靠这个才可复现)。
写法纪律一句话:一条语句里,一个变量至多被修改一次;想把「用值」和「改值」都表达清楚,就拆成两行——拆开永远不会错。
5.4 实验三:函数参数的求值顺序不可依赖
优先级决定表达式「怎么分组」,不决定「谁先算」。函数实参的求值顺序标准没规定:
/* argorder.c */
#include <stdio.h>
int trace(int v, const char *who) {
printf("%s 收到 %d\n", who, v);
return v;
}
int add(int a, int b) {
return a + b;
}
int main(void) {
int i = 1;
printf("sum = %d\n", add(trace(i + 1, "左"), trace(i + 2, "右")));
return 0;
}一次典型输出(gcc 常先算右参数;clang 常先算左):
右 收到 3
左 收到 2
sum = 5两种顺序都符合标准——实参求值顺序是「未指定」:编译器可自选、可再选、参数的求值还可以交错。标准只保证一点:所有实参求值完成后才进入函数体。所以依赖参数求值顺序的代码(比如 add(trace1(), trace2()) 之间有共享状态)本身就是错的;而 f(++i, ++i) 连「顺序未指定」都轮不到——直接 UB。看到这里,再回看第 1 节的 accident.c:i = i++ 的「不同编译器不同结果」不再神秘。
6. 三目、逗号与 sizeof
6.1 条件运算符 ?:
int a = 10, b = 3;
int max = (a > b) ? a : b; /* 10 */
int parity = (a % 2 == 0) ? 1 : 0; /* 偶数为 1 */先算条件,真取第二操作数,假取第三操作数,只算其中之一。它是唯一的三元运算符,右结合,嵌套时必须加括号。与短路一样,?: 在条件与被选分支之间也有顺序点,所以 max = (a > b) ? ++a : ++b; 是安全的——但为了可读性,超过一行的分支请用 if 语句(控制流)。
6.2 逗号运算符:只在两处出现
逗号从左到右依次求值,整个表达式的值是最后一个:
int a, b, c;
c = (a = 5, b = 10, a + b); /* 15:前两个是副作用,值被丢弃 */
for (int i = 0, j = 10; i < j; i++, j--) {
printf("i=%d j=%d\n", i, j);
}注意 int a, b, c 声明里的逗号不是逗号运算符;给变量逐个赋初值时更要小心(那是声明语法)。日常代码里逗号运算符只在 for 的两段与宏里常见,其余场合出现就该警惕。
6.3 sizeof:编译期的一行问卷
/* sizeofexp.c:sizeof 的操作数不求值 */
#include <stdio.h>
int main(void) {
int n = 3;
printf("sizeof(n++) = %zu\n", sizeof(n++)); /* 4 */
printf("n = %d\n", n); /* 3:n++ 根本没执行 */
int arr[10];
printf("元素个数 = %zu\n", sizeof arr / sizeof arr[0]); /* 10 */
return 0;
}预期输出:
sizeof(n++) = 4
n = 3
元素个数 = 10三条规则:除变长数组(VLA)外,sizeof 的操作数不求值,大小在编译期算出;结果是 size_t 类型,用 %zu 打印;sizeof(char) 恒为 1,其他类型以此为单位。sizeof arr / sizeof arr[0] 只对「真数组」有效——数组一旦退化成指针,得到的就不是元素个数了(详见 数组详解)。
7. 优先级与结合性:速查表与三条记忆法
从高到低(同级同行,结合性决定同级的运算方向):
| 档 | 运算符 | 结合性 |
|---|---|---|
| 1 | () [] -> . 后置 ++ -- | 从左到右 |
| 2 | 前置 ++ -- ! ~ 一元 + - * & (type) sizeof | 从右到左 |
| 3 | * / % | 从左到右 |
| 4 | + - | 从左到右 |
| 5 | << >> | 从左到右 |
| 6 | < <= > >= | 从左到右 |
| 7 | == != | 从左到右 |
| 8 | &(按位与) | 从左到右 |
| 9 | ^ | 从左到右 |
| 10 | |(按位或) | 从左到右 |
| 11 | && | 从左到右 |
| 12 | || | 从左到右 |
| 13 | ? : | 从右到左 |
| 14 | = 及全部复合赋值 | 从右到左 |
| 15 | , | 从左到右 |
整表背下来不现实,三条记忆法覆盖日常九成场景:
- 单目最凶:
!~++--高于全部算术运算符。所以-a * b是(-a) * b,!x + 1是(!x) + 1; - 算术高于关系高于逻辑:乘除 → 加减 → 移位 → 比大小 → 判等 →
&^|→&&→||,与数学直觉同向;唯一反直觉的是「判等居然高于按位与」,见下面的真实事故; - 赋值几乎垫底:只比逗号高、右结合。
x = a == b是x = (a == b),a = b = c是a = (b = c)。
第四条不是记忆法,是纪律:记不清就加括号。一个真实事故样本(源自老代码里的奇偶判断宏):
#define IS_EVEN(x) ((x) & 1 == 0) /* 错:== 高于 &,实际是 (x) & (1 == 0),恒为 0 */
#define IS_EVEN_OK(x) (((x) & 1) == 0) /* 对 */IS_EVEN 对任何输入都返回 0——它悄悄宣称所有数都是奇数。gcc 的 -Wparentheses 能抓到它(第 8 节当场演示)。
8. 常见错误与调试实录
8.1 -Wall 抓 = 误写 ==
/* eqbug.c */
#include <stdio.h>
int main(void) {
int limit = 0;
if (limit = 10) { /* 手滑:赋值当比较 */
printf("reached\n");
}
printf("limit = %d\n", limit);
return 0;
}gcc -Wall -Wextra eqbug.c -o eqbug预期输出(关键行):
eqbug.c: In function 'main':
eqbug.c:6:9: warning: suggest parentheses around assignment used as truth value [-Wparentheses]if (limit = 10) 永远为真,还顺手改写了 limit。修复就是写成 ==;确有「赋值兼判断」需求时用双层括号 if ((p = malloc(n)) != NULL) 明示意图。提醒:-Wall 不是银弹,y = x 这类恰好合法的表达式它不响——工具降低概率,纪律(第 5.3 节的一条一句)兜底。
8.2 UBSan 抓溢出与移位
/* ubsanexp.c */
#include <stdio.h>
#include <limits.h>
int main(void) {
int big = INT_MAX;
int overflow = big + 1; /* 有符号溢出:UB */
printf("overflow = %d\n", overflow);
int shift = 1 << 40; /* 移位数超过 int 的 32 位宽度:UB */
printf("shift = %d\n", shift);
return 0;
}gcc -Wall -Wextra -g -fsanitize=undefined ubsanexp.c -o ubsanexp
./ubsanexp预期输出(关键行,行号以实际文件为准):
ubsanexp.c:6:20: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
ubsanexp.c:8:18: runtime error: shift exponent 40 is too large for 32-bit type 'int'未定义行为检测器(UBSan)在事故点当场打印。注意有符号溢出是 UB 而不是「绕回负数」——绕回只是常见实现的巧合;无符号运算才有标准保证的模 2 的 n 次方环绕。表达式类的 UB 远不止这两种,工具全家桶见 静态分析与 Sanitizers。
8.3 -Wparentheses 提示位运算与比较混写
把第 7 节的事故宏放进源文件编译,gcc 追加一条:
warning: suggest parentheses around comparison in operand of '&' [-Wparentheses]这条警告正是记忆法二「判等高于按位与」的自动化:编译器发现你写了 x & 1 == 0,怀疑你以为它是 (x & 1) == 0。遇到就照它说的补括号,顺便检查语义。
9. 实际项目中的使用场景
- 判空与边界惯用法:
p != NULL && p->len > 0、i >= 0 && i < n,短路保证安全; - 计数与取模:环形缓冲下标
i % capacity、奇偶与分组n % 2、时间换算s / 60, s % 60; - 无符号与 size_t:数组下标、循环变量尽量与长度类型符号一致,避免实验二的绕回;
- 位标志见 位运算与位域;三目选默认值、逗号推进双游标的 for 循环见本文第 6 节。
10. 小练习
预测题(5 分钟):先写答案再运行验证:-7 % 3、7 % -3、-7 / 3 各是多少?依据是哪两条规则?
参考答案(先写再看):-1、1、-3。余数符号跟随左操作数,商向零截断,且 (a/b)*b + a%b == a 恒成立:-7/3 得 -2,-2*3 + (-1) 恰好回到 -7。
修改题(15 分钟):下面的倒序循环想打印 4 到 0,实际会无限循环。用第 2.5 节的知识解释原因并修复:
size_t n = 5;
for (size_t i = n - 1; i >= 0; i--) { /* size_t 永远 >= 0 */
printf("%zu\n", i);
}参考要点:i >= 0 对无符号恒真,i-- 减到 0 后绕回最大值。修法至少两种:改用有符号 for (int i = (int)n - 1; i >= 0; i--);或用 for (size_t i = n; i-- > 0;) 让判断先行、自减藏在条件里。验收:打印 4 3 2 1 0。
挑战题(半小时,不看答案先动手):把第 1 节的 accident.c 分别用 gcc -O0、gcc -O2 编译运行,再换 clang 试试,记录所有不同输出;最后用 -fsanitize=undefined 编译一遍,看 UBSan 怎么报警。
提示(思路方向):重点不是统计谁打 10 谁打 11,而是想清楚第 5.3 节的标准规则为什么让一切结果都合法。
展开(关键 API):UBSan 对这类 UB 不一定每次都报(它检测的是可静态识别的模式);结合 -Wall 看有没有警告。验收清单:至少观察到两种不同结果或一条警告;能用「两个副作用没有先后关系」完整复述判刑理由。
11. 与之前和之后的知识的关系
- 往前:变量与常量 的变量与字面量是本篇所有表达式的操作数;数据类型详解 的类型表在第 2 节完成「相遇听谁的」的闭环;
- 旁支:位运算的完整展开在 位运算与位域;这些表达式最主要的消费场景是 if 与循环,见 控制流;
*与&在表达式里还是解引用与取地址,见 指针深度解析; - 往后:读文件时
int ch = fgetc(fp)与 EOF 的比较用到第 2 节的整型提升,见 文件 I/O;UBSan 与编译警告的系统用法见 静态分析与 Sanitizers。
12. 官方文档
- 算术运算符(除法截断、% 符号规则、除零与 INT_MIN/-1 的 UB):https://en.cppreference.com/w/c/language/operator_arithmetic.html
- 类型转换与寻常算术转换阶梯:https://en.cppreference.com/w/c/language/conversion.html
- 求值顺序(未定义行为清单与顺序点):https://en.cppreference.com/c/language/eval_order
- 运算符优先级总表:https://en.cppreference.com/w/c/language/operator_precedence.html
- sizeof(操作数不求值与 size_t):https://en.cppreference.com/w/c/language/sizeof.html
- fmod(浮点取余):https://en.cppreference.com/w/c/numeric/math/fmod.html
13. 自我检查
- 能说出
10 / 3、-10 / 3、-7 % 3的值与依据的两条规则; - 能默写转换阶梯的前后各三层,并解释
sizeof('A')为什么是 4; - 能向同事讲清
a < b < c错在哪、i = i++为什么编译器想怎么算都合法、怎么改一定对; - 能用
-Wall与-fsanitize=undefined抓出本文演示的两类事故,并说出-Wparentheses提示的是哪条优先级规则。
本章总结
表达式三章合一:算术章里整数除法自 C99 起向零截断、余数符号跟随左操作数,浮点取余交给 fmod;类型相撞先整型提升再查寻常算术转换阶梯,有符号与无符号相遇时向无符号看齐,-1 < 1u 为假由此而来。逻辑章里比较只产 0 和 1,短路是标准保证的顺序,比较链 (a>b)>c 是数学写法的陷阱。求值章里一条语句至多修改一个对象一次,i = i++ 与实参求值顺序同属「标准不排顺序」的地界,拆开写是唯一的万能解。最后收进工具箱:-Wall 抓 = 当 ==,UBSan 抓溢出与移位,-Wparentheses 抓优先级误信——而比它们都可靠的是那句纪律:记不清就加括号。
下一步
进入 位运算与位域:第 4 节只给了六个位运算符的一张速写表,下一篇把它们展开成完整阶梯——掩码、四个位操作宏、移位的符号位陷阱与直接按位分配成员的位段。