第 4 章 · Expressions and Operators
来源: Effective C (Seacord, 2020) — Chapter 4, pp. 57–80 笔记日期: 2026-08-27
一、章节概述
- lvalue vs rvalue 是这章的认知底层:lvalue = “locator value” = 指向可寻址对象的表达式(可放赋值左侧);rvalue = 一个值(不可放赋值左侧)。
i + 12不是 lvalue,因为没有底层对象。 - 值计算 vs 副作用 是表达式求值模型:value computation = 算值;side effect = 改状态(写对象、访问 volatile、I/O、赋值、调用有副作用的函数)。
i++vs++i的真实语义:postfixi++返回旧值、副作用是递增;prefix++i返回新值。在表达式里这俩可能产生完全不同的结果(*p++输出 ‘x’ 但 p 已指向 ‘y’)。- Precedence 和 Associativity 是两个独立维度:15 级优先级 + 左/右结合。
a + b + c是((a + b) + c)(左结合),a = b = c是(a = (b = c))(右结合)。 - Order of evaluation 是 unspecified(除了
&&||?:,):max(f(), g())调用顺序没有保证——同一表达式两次跑结果可能不同。这是 C 区别于 Python/Java 的根本点之一。 - Sequence Point 是副作用”墙”:进入/退出函数、完整表达式结尾、
&&/||/?:/,操作数之间。跨 sequence point 改同一 scalar 对象 = UB。 i++ * i++是 UB——两个 side effect unsequenced。正确范式:每个 side effect 拆成完整表达式。sizeof不求值:返回size_t(无符号),sizeof内的表达式不执行。这是少数”安全”的运算符之一。CHAR_BIT * sizeof(int)算位数。- 位运算是 system programming 的灵魂:位图、协议打包、权限位。但只用在 unsigned 类型——signed 类型上的位操作容易踩
signed right shift是 implementation-defined 的坑。 ~在 unsigned char 上是补码陷阱:unsigned char uc = 0xFF; int i = ~uc;结果是0xFFFFFF00(负数)。所有位运算都用足够宽的 unsigned 是铁律。- 移位操作的 UB:负数位移、
shift >= width、signed left shift 溢出——全是 UB。1UL << 32在 32-bitunsigned long上是 UB。 2 ^ 7不是 128!^是 XOR,不是幂。初学者最常踩的坑之一——幂运算用pow(2, 7)。&&和||是唯一保证 left-to-right 求值的运算符(且 short-circuit)。isN(ptr, n)风格 =ptr && *ptr == n是防 null-deref 的经典写法。- Casts 不只是”类型转换”:可以reinterpret bits(
(intptr_t)ptr)或change bits((int)float)。Cast 会 disable 警告——(char)fgetc()静音 C4244 但没修问题。 ?:是唯一三目运算符,且能初始化const对象(if-else不能)。第一个操作数有 sequence point。a < b < c不是数学链式比较!是(a < b) < c——比较结果是 0/1,再与 c 比。CERT 强烈推荐永远加括号 + 开-Wparentheses。- 复合赋值
E1 op= E2比E1 = E1 op (E2)安全(E1 只 evaluate 一次)。没有&&=||=——作者特地点出。 - 逗号运算符 vs 逗号分隔符:逗号作运算符有 sequence point;逗号分隔函数参数/声明列表没有。
f(a, (t=3, t+2), c)中第二逗号是运算符。 - Pointer arithmetic 是 scaled:自动按
sizeof(T)缩放,不按字节算。pi < &m[2]中的&m[2]是”too-far pointer”——指向数组尾后一格,是合法的。 p + n越界 = UB,但指向同一数组或 too-far pointer 的减法 well-defined。
二、核心 Takeaways
Takeaway 1: i++ * i++ 是 UB——经典”看起来对、实际炸”
- 是什么:
int j = i++ * i++两个i++side effects unsequenced——C 标准直接判 UB。 - 为什么重要:人类直觉是
j = 5 * 6 = 30;实际可能 5 * 5、5 * 6、6 * 5、6 * 6——甚至根本不写j(编译器优化掉循环)。 - 解决什么问题:所有”一个表达式多个 side effect 改同一变量”的代码 = UB。
- 适用场景:循环自增、复杂赋值语句、宏展开(宏会重复求值)。
Takeaway 2: Order of evaluation = unspecified(除 && || ?: ,)
- 是什么:
max(f(), g())中f()和g()哪个先调用没有规定。 - 为什么重要:很多代码”碰巧能工作”是因为编译器当前选了这个顺序——GCC 升级就可能崩。
- 解决什么问题:避免一个表达式里两个操作都依赖共享状态——拆成完整表达式即可。
- 适用场景:所有含函数调用、共享全局变量的复合表达式。
Takeaway 3: a < b < c 不是链式比较
- 是什么:C 把
a < b < c解释为(a < b) < c——先算a < b得 0 或 1,再和 c 比。 - 为什么重要:数学家/初学者以为等价于
a < b && b < c——错。1 < 5永真 → 永远成立。 - 解决什么问题:永远写
(a < b) && (b < c);CI 加-Wparentheses。 - 适用场景:所有范围检查(age 在 18
60、温度在 -2080、数组下标在 0~N)。
Takeaway 4: 移位运算的三个 UB 触发点
- 是什么:(a) shift count 为负;(b) shift count ≥ promoted left operand 宽度;(c) signed left shift 溢出。
- 为什么重要:
1UL << 32在 32-bitunsigned long是 UB;1 << 31在 32-bitint是 UB(溢出到符号位)。 - 解决什么问题:所有位运算前必须先检查 shift count 在合法范围。
- 适用场景:协议字段打包、CRC 计算、hash 算法实现、权限位操作。
Takeaway 5: 位运算必须用 unsigned 类型
- 是什么:signed
>>在 negative value 上是 implementation-defined(arithmetic vs logical shift)。 - 为什么重要:ARM、x86 默认算术右移,但编译器可选逻辑右移;不同架构不同。
- 解决什么问题:避免”看似对的位运算在另一个架构错”。
- 适用场景:所有位图、mask、flag 操作、checksum、hash。
Takeaway 6: Too-far pointer 是 C 数组迭代的核心约定
- 是什么:
&arr[N](指向尾后一格)是合法指针——只要不解引用。 - 为什么重要:
for (pi = &m[0]; pi < &m[2]; ++pi)是 idiomatic C/C++ 写法——C++std::end()也是这个原理。 - 解决什么问题:避免计算
arr_size - 1这种 off-by-one。 - 适用场景:所有 C 风格数组迭代、内存遍历、自定义 iterator。
Takeaway 7: Cast 会 disable 警告
- 是什么:
(char)fgetc(in)让 MSVC 静默 C4244,但没修问题——fgetc返回int(含 EOF),强转 char 后永远不可能等于 EOF。 - 为什么重要:这正是 K&R
getchar循环的经典 bug 模式;正确写法是int c; while ((c = fgetc(in)) != EOF) { char_var = c; ... }。 - 解决什么问题:避免”用 cast 静音警告 → 隐藏 bug → 生产事故”。
- 适用场景:所有 I/O 循环、所有”编译器警告我但我加个 cast 就过了”的场景。
Takeaway 8: && short-circuit 是防 null-deref 的标准武器
- 是什么:
ptr && *ptr == n—— 若ptr == NULL,*ptr不求值。 - 为什么重要:比
if (ptr != NULL && *ptr == n)短,且更明确。 - 解决什么问题:避免 null-deref;同时避免不必要的内存读。
- 适用场景:所有 optional pointer 参数、所有
is_file_ready() || prepare_file()这种”lazy init”。
三、工程实践视角
嵌入式开发
- 位操作是嵌入式吃饭工具:GPIO、寄存器、中断标志、UART 帧、CRC8。所有位操作用
uint32_t+ 先 checkshift count。 if ((reg & FLAG) == FLAG)模式:比if (reg & FLAG)更精确——后者非零即真,无法区分FLAG和FLAG | OTHER。for (volatile uint32_t *p = ...; p < end; ++p)模式:遍历 MMIO 时volatile是必须的(防优化);遍历内部 SRAM 则不需要。shift count检查用< LOG2(width)而非< width——更紧凑。- MCU 无符号右移:永远 logical(无 signed);不用担心 implementation-defined。
Linux 系统开发
- 内核代码里
i++ * i++是 bug 高发地:list_for_each_entry_safe存在就是为了 free 链表时安全遍历。 -Wsequence-point是 GCC 早期警告(已合并到-Wall);现代 GCC 用-Wunsequenced。max(f(), g())模式:Linux kernel 里大量min/max宏——但 kernel 用 statement expression (({ ... })) 强制 left-to-right 求值,规避 unspecified 问题。container_of宏用了 cast + 算术——这是”合法 UB 边界”使用的范例。-Wparentheses+-Wswitch-enum是 CI 标配。
机器人软件
- ROS 消息是 bit-packed 协议:消息头 4-byte、CRC32 mask、type field。位运算 + big-endian 是家常便饭。
- EtherCAT / CANopen 状态机:状态切换用 switch + enum +
default: abort()——配合-Wswitch-enum防漏 case。 - point cloud 迭代:
for (float *p = cloud; p < cloud + n; p += 3)—— 3 是 stride(x/y/z)。这是 too-far pointer 的实际工程用法。 - 运动控制循环:每个控制周期
for (int i = 0; i < N_JOINTS; ++i)—— N_JOINTS 用const限定。
汽车电子软件
- AUTOSAR 禁用
goto(除特定 goto-chain 模式)——靠 MISRA 规则约束;本书推荐的”cleanup chain”用法在汽车代码里依然合法。 for(;;)死循环 + break 退出是 RTOS task main loop 的标准模式(for(;;) { msg = receive(); process(msg); })。switch (account)必加default: abort()——和 AUTOSAR SWS 规范一致。?:初始化const在汽车代码里被推荐——比if-else更紧凑且能用在 const 上下文。- 位操作安全性:AUTOSAR 要求所有位运算加注释说明每个字段的含义——因为 signed shift 是 implementation-defined。
safe division模板(Listing 5-2)= AUTOSAR SWS 数字运算函数的标配——检查 NULL、零除、INT_MIN/-1 溢出。
初级 vs 高级工程师的关注差
| 维度 | 初级 | 高级 |
|---|---|---|
i++ * i++ | 觉得是 30 | 立刻知道是 UB,拆成完整表达式 |
max(f(), g()) | 觉得 f 先 | 知道 unspecified,把结果存临时变量 |
a < b < c | 当作数学链式 | 永远 (a < b) && (b < c) + -Wparentheses |
| 位运算 | signed / unsigned 混用 | 强制 unsigned + check shift count |
&arr[N] | 觉得非法 | 知道是 too-far pointer,是 idiomatic |
| cast 静音警告 | 加了就好 | 理解它只是 hide 问题,真正修 |
if (reg & FLAG) | 觉得对 | 知道应该 == (flag) 防误判 |
四、AI 时代视角
这个知识今天是否仍然重要?
重要性级别:极高——这一章是”看起来对、实际 UB”代码的高发地。
- AI 经常写错的(按频率):
i++ * i++之类的副作用链max(f(), g())当 unspecified 当 specifieda < b < c当链式- 位运算用 signed + 漏 shift check
- cast 静音 warning 替代真正修复
&arr[N+1]当”非法”但其实是 too-far
AI 能帮助完成什么
- ✅ 帮你把
i++ * i++拆成顺序安全代码 - ✅ 把
(int)cast 转成int临时变量(保留 warning) - ✅ 写
-Wparentheses检查脚本扫旧代码 - ✅ 生成 wraparound-safe 的位运算 wrapper
- ✅ 把
&arr[i]改写成 too-far-safe 循环
AI 无法替代什么
- ❌ 判断函数调用顺序是否真的不重要——需要理解业务
- ❌ 决定 signed/unsigned shift 在你的 MCU 上是哪个——需要 MCU 手册
- ❌ 判断 cast 静音警告是否真的没问题——需要测运行时行为
- ❌ 写真正的位运算 wrapper——要理解硬件语义
工程师必须掌握的核心能力
- 能在 mental model 里跑 sequence point:一眼看出一个表达式里有没有 UB
- 精通移位运算的三个触发条件:
shift < 0、shift >= width、signed overflow - 理解 too-far pointer:所有 C 数组遍历的基石
- 能识别”cast 静音 warning”是 bug 模式:
getchar/fgetc循环就是例子 - 会写
-Wall -Wextra -Wpedantic -Wparentheses -Wswitch-enum的 CI 配置
五、实践行动项
行动 1: 演示 i++ * i++ 是 UB —— 不同编译器/优化等级结果不同
cat > /tmp/ub_pp.c <<'EOF'
#include <stdio.h>
int main(void) {
volatile int i = 5; // volatile 防优化掩盖
int j = i++ * i++;
printf("j = %d, i = %d\n", j, i);
return 0;
}
EOF
for opt in O0 O1 O2 O3; do
cc -std=c17 -$opt -o /tmp/ub_pp_$opt /tmp/ub_pp.c
printf " $opt: "; /tmp/ub_pp_$opt
done预期:不同优化级别结果不同——甚至有些版本 j 可能是垃圾值或循环被优化掉(如果你把它放进循环)。
行动 2: 演示 a < b < c 不是数学链式
cat > /tmp/chain.c <<'EOF'
#include <stdio.h>
#include <stdbool.h>
int main(void) {
int a = 1, b = 2, c = 5;
printf("1 < 2 < 5 ? %d (should be 1 if math; but C says ...)\n", a < b < c);
// a < b = 1; 1 < 5 = 1 -> "true"
// But this means "a<b is true and b<c"... which is the WRONG logic!
bool correct = (a < b) && (b < c);
printf("Correct math: %d\n", correct);
return 0;
}
EOF
cc -std=c17 -Wall -Wextra -Wparentheses -o /tmp/chain /tmp/chain.c && /tmp/chain预期:1 < 2 < 5 打印 1,但意思完全错。-Wparentheses 警告你写错了。
行动 3: 演示 order-of-evaluation unspecified
cat > /tmp/order.c <<'EOF'
#include <stdio.h>
int g_count = 0;
int f(void) { g_count = 100; return 1; }
int h(void) { g_count = 200; return 2; }
int main(void) {
g_count = 0;
int sum = f() + h(); // f(), h() 顺序未指定
printf("sum=%d, g_count=%d (count 取决于顺序)\n", sum, g_count);
return 0;
}
EOF
cc -std=c17 -O0 -o /tmp/order /tmp/order.c && /tmp/order
cc -std=c17 -O2 -o /tmp/order2 /tmp/order.c && /tmp/order2预期:不同优化级别 sum 可能不同——sum 不是 1+2=3 而是 f 和 h 调用顺序的结果。
行动 4: 演示位运算的 UB 触发点
cat > /tmp/shift.c <<'EOF'
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint32_t x = 1;
// 危险:32-bit unsigned long shift 32 是 UB
// uint32_t y = x << 32; // UB: shift count >= width
// 安全写法:
if (32 < sizeof(x) * 8) {
printf("(x << 32) on wider type = %u\n", ((uint64_t)x) << 32);
}
// signed left shift overflow
int8_t s = 64;
int t = s << 1; // UB: signed overflow
printf("64 << 1 = %d (UB territory)\n", t);
return 0;
}
EOF
cc -std=c17 -fsanitize=undefined -g -O1 -o /tmp/shift /tmp/shift.c && /tmp/shift预期:UBSan 报告 signed shift overflow。
行动 5: 演示 too-far pointer
cat > /tmp/toofar.c <<'EOF'
#include <stdio.h>
int arr[5] = {10, 20, 30, 40, 50};
int main(void) {
int sum = 0;
// &arr[5] 是合法的 too-far pointer
for (int *p = &arr[0]; p < &arr[5]; ++p) {
sum += *p;
}
printf("sum = %d\n", sum);
return 0;
}
EOF
cc -std=c17 -Wall -Wextra -o /tmp/toofar /tmp/toofar.c && /tmp/toofar预期:打印 150。这个循环是 C/C++ idiomatic 的核心。
六、值得深入思考的问题
Q1: 如果 i++ * i++ 是 UB,为什么 C 标准不直接禁止它(编译期报错)?
可能的解释:UB 给了编译器激进优化的权利。如果改成编译期检查,编译器就不能基于”UB 不发生”做假设。问题:这是 performance vs safety 的根本 trade-off;如果 GCC 选择 “compile error on i++ * i++”,它会失去哪些优化机会?
Q2: Order of evaluation unspecified 是 C 设计的根本决策。Rust 选择 left-to-right 求值,Python/Java 也明确。 这种差异反映了什么哲学?
提示:C 给编译器自由度;Java/Rust 给程序员确定性。问题:为什么 C 没有在 30 年后修正这个?是因为 ABI 兼容成本太高,还是另有原因?
Q3: &arr[N](too-far pointer)是 idiomatic 但违反”指针不能指向非法内存”的直觉。如果当年 C 标准化时禁掉它,今天的 C/C++ 标准库会怎样不同?
问题:too-far pointer 是 C 设计的”美丽漏洞”还是”历史包袱”?它对现代 C++ iterator 设计的影响是什么?
Q4: 移位运算的三个 UB 触发点都”看起来自然”(程序员不知道 1UL << 32 是 UB)。编译器为什么不在 shift count 越界时给个 warning?
提示:可能的原因——编译器没有数据流分析;编译期 sizeof 已知但 shift count 经常是运行时变量。问题:Clang -Wshift-overflow / -Wshift-negative 的检测准确率是多少? 在你的代码里开起来能抓多少?
Q5: for (p = head; p != NULL; p = p->next) free(p); 是经典 UB——读完 p->next 后 p 已被 free。为什么 C 标准不强制编译器检测 use-after-free?
提示:成本——运行时需要 memory access tracker;编译期又没法证明。问题:这是为什么 ASan/UBSan 存在的原因吗?为什么 Rust 能编译期检测 use-after-free,而 C 不能?
下一章预告: Chapter 5 — Control Flow —— if / switch 的隐藏陷阱(漏 break、漏 default、indent 错觉)、while / do-while / for 的微妙差异(for 的 expression3 实际是循环体”之后”执行)、goto cleanup chain(Linux kernel 17 labels 的真实案例)、continue / break / return。控制流是”看起来对”的代码另一个高发地。