很多人初学 C 时把 a += b 理解成“就是 a = a + b 的缩写”。这句话只说对了一半:它们在结果值上等价,但在求值次数与类型转换上有关键差异。吃透这些差异,能避免一堆阴沟翻船的 bug,尤其是涉及小整型、浮点与位移的边界场景。
一、复合赋值的“等价展开”,但左值只求一次
语义模板(概念化理解):
E1 op= E2
// 等价于(仅作说明用):
{
T tmp = E1; // 概念中的临时值,不重复读写 E1
tmp = (tmp) op (E2); // 先按规则做运算
E1 = tmp; // 再写回,E1 只求值一次
}
要点:
- E1 只求值一次。a[i++] += 1; 中 i++ 只发生一次;而你手写 a[i++] = a[i++] + 1; 会发生两次。
- 写回发生在运算之后,且结果会转换成 E1 的类型再存入。
支持的复合赋值(C 语言内置): += -= *= /= %= <<= >>= &= ^= |= 此外还有指针与整数的 +=、-=(指针算术)。
二、类型转换的真相:三层“过滤器”
理解复合赋值时,基本按这三步走:
- 整型提升 / 通常算术转换(UAC)
- 小整型(char/short/枚举等)先做整型提升为 int 或 unsigned int(能装下其所有值者)。
- 对算术二元运算,随后执行通常算术转换:例如 int 与 double 运算会在 double 中进行;float 会提升为 double;有符号/无符号混算遵循“能容纳范围”的共同类型规则。
- 在共同运算类型中完成 op
- 如 a += b,a 与 b 会在共同类型中做 a + b。
- 将结果转换为 E1 的类型并写回
- 即便运算在更大/更宽的类型中进行,落盘回 E1 时会“缩回去”。
小结:E1 op= E2 ≈ “在更宽的安全类型里算完,再变回 E1 的类型存入,并且 E1 只读一次只写一次”。
三、不同运算符下的特殊规则
1)算术类:+= -= *= /= %=
- 在共同运算类型里计算,再转回 E1 的类型。
- 溢出风险:
- 有符号整数溢出:未定义行为(UB)。例如 int a = INT_MAX; a += 1;,结果不可预期。
- 无符号整数溢出:按 2 模回绕,可预期。
- 窄化转换:若写回到更小的类型,超范围时:
- 转换为无符号小类型:按模回绕。
- 转换为有符号小类型:结果是实现定义(不同编译器/平台可能不同),但不属于 UB。
示例
unsigned char c = 250;
c += 10; // 运算在 int 中得到 260,写回 unsigned char:260 mod 256 = 4,c == 4
signed char s = 120;
s += 20; // 运算在 int 中为 140,写回 signed char 超范围 → 实现定义
2)位移类:<<= >>=
- 左右两侧都先整型提升;位移在提升后的类型里完成,再写回 E1 的类型。
- 位移计数要求:非负且小于位宽,否则 UB。
- 右移有符号负值是否算算术右移?实现定义(但多数编译器做算术右移)。
示例
unsigned char c = 0xF0; // 240
c <<= 1; // 提升到 int 做位移,结果 480,写回 unsigned char = 480 mod 256 = 224 (0xE0)
3)按位类:&= ^= |=
- 整型参与,先整型提升/通常算术转换,按位运算后再写回。
4)指针算术:p += n、p -= n
- 仅当 p 为指向数组元素或后一位置(one-past)的指针且 n 为整数时合法。
- 实质:p = p + n * sizeof(*p)。编译器会自动乘元素大小。
- 指针与指针之间不存在 +=(p += q 非法),-= 只能用整数偏移。
示例
int a[10], *p = a;
p += 3; // 指向 a[3]
p -= 2; // 回到 a[1]
5)浮点与“单精度陷阱”
- float 参与二元运算时通常会提升为 double。 因此 float f; f += 1.0; 的加法在 double 中完成,再舍入回 float。
- 与 f = f + 1.0 在现代 C 的数值与舍入行为上等价(两次转换点位相同)。
四、“只求值一次”的威力与坑
1)避免重复副作用
int i = 0;
int a[3] = {0};
a[i++] += 1; // i 只自增一次,安全;等价于 { a[i] = a[i] + 1; i++; } 的效果
如果你手写:
a[i++] = a[i++] + 1; // i 自增两次,逻辑跑偏
2)与函数/volatile 的组合
volatile int reg = 0x10;
reg <<= 1; // 只读取一次寄存器值,再写回。若写成 reg = reg << 1; 一般结果一样,
// 但复合赋值更清晰地表达“只读一次再写”的意图。
3)未定义行为警告:同时修改同一标识符
int x = 1;
x += x++; // UB:在一个完整表达式内对 x 进行了两次修改(x++ 与赋值),且无序列化关系
再如:
int i = 0, a[2] = {0};
a[i] += i++; // 同样 UB:对 i 的修改与使用未明确序列关系
原则:一个完整表达式里,不要既读又改同一标识符,除非标准保证了求值次序。
五、常见“类型差一阶”的坑位清单
- char/short 与字面量混算
- 小整型先提升为 int,得到的中间结果可能远超小类型范围;写回时要么模回绕(无符号),要么实现定义(有符号)。
- 有符号与无符号混算
- UAC 可能把 int 提升到 unsigned int,导致“负数变巨正”。
- int a = -1; unsigned b = 1; a += b; → 先在 unsigned 中做 UINT_MAX 级别的运算,再写回 int,结果实现定义/未定义?(取决于中间值能否表示;一般危险,避免。)
- 位移位数超限
- x <<= 32 在 32 位整型上是 UB;位移数来自外部变量时务必做范围校验。
- 浮点累加至整型目标
- int a; a += 0.1; 先在 double 中相加,再转回 int,发生截断,很可能啥也没变。
- 取模 %= 与负数
- C99 起规定余数与左操作数同号;但写回窄类型仍可能触发实现定义的窄化行为。
六、每一种复合赋值的“口袋展开卡”
记忆口诀:先“升格”算,再“缩格”存,左值只读一次。
- E1 += E2 → 在 UAC 类型里做 +,写回 E1 类型
- E1 -= E2 → 同上
- E1 *= E2 → 同上
- E1 /= E2 → 同上(整数除法截断,除以 0 是 UB)
- E1 %= E2 → 同上(除以 0 是 UB)
- E1 <<= E2 → 两侧整型提升后位移,再写回
- E1 >>= E2 → 同上
- E1 &= E2、E1 ^= E2、E1 |= E2 → 整型提升/UAC 后按位运算,再写回
- 指针:p += n / p -= n → 指针算术(步长 sizeof(*p)),n 经整型提升;结果仍是 typeof(p)
七、与“手写展开”的真实差异:两个实战片段
片段 1:性能敏感的寄存器镜像
volatile uint32_t STAT;
void ack_bit_3(void) {
STAT &= ~(1u << 3); // 只读一次 STAT,再写回;避免“读-改-读-写”序列的竞态窗口
}
若写成:
STAT = STAT & ~(1u << 3);
在很多平台上仍只读一次,但从可读性与意图上,复合赋值版本更清晰地表达“读一次改一次写一次”。
片段 2:浮点累计到单精度累加器
float acc = 0.0f;
double x = 1e-8;
for (int i = 0; i < 1e7; ++i) {
acc += x; // 在 double 中相加,再舍入回 float;长期累计会有可见的舍入台阶
}
想提升精度?把累加器也升格到 double 再在末尾一次性转换。
八、自测 5 题(看懂就及格)
- unsigned char c = 250; c += 10; → c == ?
- signed char s = 120; s += 20; → 实现定义 / 不可移植
- int x = INT_MAX; x += 1; → UB
- int i = 0, a[2] = {0}; a[i] += i++; → UB
- float f = 1.0f; f += 1e-8; → 在 double 中算,再转回 float;可能无可见变化
答案速记:1) 4;2) 看实现;3) 未定义;4) 未定义;5) 变化可能被舍入吞掉。
九、工程实践建议
- 对窄类型目标用显式铸型表达意图: u8 += (uint8_t)delta; 明示你接受回绕。
- 混合符号运算前先统一符号: int 与 unsigned 混用前,先转同一有符号或无符号类型。
- 位移前做范围校验: shift &= (WIDTH-1); 或运行时断言。
- 避免在一个表达式里既读又改同一变量: 拆成两句,让次序一目了然。
- 浮点累计用更宽精度,最后一步再窄化。
把“只求值一次 + 先升格算再缩格存”这两条塞进肌肉记忆后,复合赋值的所有细节都会自洽:你能写出更安全的代码,也能在读别人的表达式时立刻嗅出潜在的未定义行为与移植性雷点。这就是它们存在的真正价值。