第 4 章 · Expressions and Operators

来源: Effective C (Seacord, 2020) — Chapter 4, pp. 57–80 笔记日期: 2026-08-27


一、章节概述

  1. lvalue vs rvalue 是这章的认知底层:lvalue = “locator value” = 指向可寻址对象的表达式(可放赋值左侧);rvalue = 一个(不可放赋值左侧)。i + 12 不是 lvalue,因为没有底层对象。
  2. 值计算 vs 副作用 是表达式求值模型:value computation = 算值;side effect = 改状态(写对象、访问 volatile、I/O、赋值、调用有副作用的函数)。
  3. i++ vs ++i 的真实语义:postfix i++ 返回旧值、副作用是递增;prefix ++i 返回新值。在表达式里这俩可能产生完全不同的结果(*p++ 输出 ‘x’ 但 p 已指向 ‘y’)。
  4. Precedence 和 Associativity 是两个独立维度:15 级优先级 + 左/右结合。a + b + c((a + b) + c)(左结合),a = b = c(a = (b = c))(右结合)。
  5. Order of evaluation 是 unspecified(除了 && || ?: ,):max(f(), g()) 调用顺序没有保证——同一表达式两次跑结果可能不同。这是 C 区别于 Python/Java 的根本点之一。
  6. Sequence Point 是副作用”墙”:进入/退出函数、完整表达式结尾、&&/||/?:/, 操作数之间。跨 sequence point 改同一 scalar 对象 = UB
  7. i++ * i++ 是 UB——两个 side effect unsequenced。正确范式:每个 side effect 拆成完整表达式。
  8. sizeof 不求值:返回 size_t(无符号),sizeof 内的表达式不执行。这是少数”安全”的运算符之一。CHAR_BIT * sizeof(int) 算位数。
  9. 位运算是 system programming 的灵魂:位图、协议打包、权限位。但只用在 unsigned 类型——signed 类型上的位操作容易踩 signed right shift 是 implementation-defined 的坑。
  10. ~ 在 unsigned char 上是补码陷阱unsigned char uc = 0xFF; int i = ~uc; 结果是 0xFFFFFF00(负数)。所有位运算都用足够宽的 unsigned 是铁律。
  11. 移位操作的 UB:负数位移、shift >= width、signed left shift 溢出——全是 UB。1UL << 32 在 32-bit unsigned long 上是 UB。
  12. 2 ^ 7 不是 128! ^ 是 XOR,不是幂。初学者最常踩的坑之一——幂运算用 pow(2, 7)
  13. &&|| 是唯一保证 left-to-right 求值的运算符(且 short-circuit)。isN(ptr, n) 风格 = ptr && *ptr == n 是防 null-deref 的经典写法。
  14. Casts 不只是”类型转换”:可以reinterpret bits(intptr_t)ptr)或change bits(int)float)。Cast 会 disable 警告——(char)fgetc() 静音 C4244 但没修问题。
  15. ?: 是唯一三目运算符,且能初始化 const 对象(if-else 不能)。第一个操作数有 sequence point。
  16. a < b < c 不是数学链式比较!是 (a < b) < c——比较结果是 0/1,再与 c 比。CERT 强烈推荐永远加括号 + 开 -Wparentheses
  17. 复合赋值 E1 op= E2E1 = E1 op (E2) 安全(E1 只 evaluate 一次)。没有 &&= ||=——作者特地点出。
  18. 逗号运算符 vs 逗号分隔符:逗号作运算符有 sequence point;逗号分隔函数参数/声明列表没有。f(a, (t=3, t+2), c) 中第二逗号是运算符。
  19. Pointer arithmetic 是 scaled:自动按 sizeof(T) 缩放,不按字节算。pi < &m[2] 中的 &m[2] 是”too-far pointer”——指向数组尾后一格,是合法的。
  20. 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 在 1860、温度在 -2080、数组下标在 0~N)。

Takeaway 4: 移位运算的三个 UB 触发点

  • 是什么:(a) shift count 为负;(b) shift count ≥ promoted left operand 宽度;(c) signed left shift 溢出。
  • 为什么重要1UL << 32 在 32-bit unsigned long 是 UB;1 << 31 在 32-bit int 是 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 + 先 check shift count
  • if ((reg & FLAG) == FLAG) 模式:比 if (reg & FLAG) 更精确——后者非零即真,无法区分 FLAGFLAG | 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 经常写错的(按频率):
    1. i++ * i++ 之类的副作用链
    2. max(f(), g()) 当 unspecified 当 specified
    3. a < b < c 当链式
    4. 位运算用 signed + 漏 shift check
    5. cast 静音 warning 替代真正修复
    6. &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——要理解硬件语义

工程师必须掌握的核心能力

  1. 能在 mental model 里跑 sequence point:一眼看出一个表达式里有没有 UB
  2. 精通移位运算的三个触发条件shift < 0shift >= width、signed overflow
  3. 理解 too-far pointer:所有 C 数组遍历的基石
  4. 能识别”cast 静音 warning”是 bug 模式getchar/fgetc 循环就是例子
  5. 会写 -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 而是 fh 调用顺序的结果。

行动 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->nextp 已被 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。控制流是”看起来对”的代码另一个高发地。