运算符基础:优先级、自增自减与短路求值
引言:C 的运算符一箩筐,坑就在细节里
C 的运算符多得出名——算术、关系、逻辑、位、赋值、自增自减、条件、逗号、sizeof、取地址/解引用……它们凑在一起,靠优先级和结合性决定求值顺序。这一章我们过一遍最常用的几类(位运算留给第 6 章单讲),重点讲三个最容易让你「我以为是这样、其实不是」的地方:i++ vs ++i、逻辑运算符的短路、还有**「同一个表达式多次改一个变量」的 UB 陷阱**。每一步真跑。
算术与关系运算符:基础但有几个老坑
算术运算符 + - * / % 里,/ 和 % 有几个规则要记牢:两个整数相除(/)做的是整数除法,结果向零取整(7 / 2 == 3、-7 / 2 == -3);%(取余)的两个操作数必须都是整数(7.5 % 2 直接编不过)。这里那个最经典的坑第 4 章讲过——== != < > <= >= 没什么花哨的,它们的结果是 int:真为 1、假为 0(所以 x = (a > b) 这种把比较结果存下来是完全合法的,x 会是 0 或 1)。
逻辑运算符与「短路求值」
逻辑运算符 &&(与)、||(或)、!(非)操作的是「真假」(C 里 0 是假、任何非 0 是真),结果也是 int 的 0 或 1。它们最关键的特性是短路求值(short-circuit,ISO/IEC 9899 §6.5.13 / §6.5.14):&& 的左边如果是假(0),整个 && 必为假,右边就根本不求值;|| 的左边如果是真(非 0),整个 || 必为真,右边也不求值。 我们写个带副作用的 side_effect 函数来验证:
#include <stdio.h>
int side_effect(const char* who) {
printf(" → %s 被求值了\n", who);
return 1;
}
int main(void) {
printf("0 && side_effect:\n");
int r1 = (0 && side_effect("&& 右侧"));
printf("1 || side_effect:\n");
int r2 = (1 || side_effect("|| 右侧"));
printf("结果 r1=%d r2=%d(两处 side_effect 都没被调用 → 短路!)\n", r1, r2);
return 0;
}$ gcc -std=c11 -Wall shortcircuit.c -o sc && ./sc
0 && side_effect:
1 || side_effect:
结果 r1=0 r2=1(两处 side_effect 都没被调用 → 短路!)看输出——两处 side_effect 一次都没被调用(没有那行 → ... 被求值了),结果 r1=0(因为 0 && ... 必为 0)、r2=1(因为 1 || ... 必为 1)。这就是短路:右边被直接跳过了。
短路是个极其有用的特性。最经典的写法是「防 null 再解引用」:if (p != NULL && p->value > 0)——如果 p 是空,&& 左边为假,右边的 p->value 就不会被求值,于是你不会去解引用空指针;要是没有短路,这条语句对空指针就是个段错误。再比如「先检查下标再访问」:if (i < n && arr[i] == target)。这些都是靠短路保平安。但短路也有反面:如果你的某个副作用(比如 ++count)写在了 &&/|| 右边,而它被短路跳过了,那这个副作用就不会发生——你以为「这行执行了 count 就加 1」,结果它被短路了、count 没动,bug 就藏进去了。所以记住:别把「必须发生的副作用」放在可能被短路的位置。
自增自减:i++ 和 ++i 不一样
++ 和 -- 各有前置(++i)和后置(i++)两种,它们都会把 i 加 1/减 1,但返回值不同(§6.5.2.4 后置、§6.5.3.1 前置):
#include <stdio.h>
int main(void) {
int i = 5;
int a = i++; /* 后置++:先返回旧值给 a,再自增 → a=5 */
int b = ++i; /* 前置++:先自增,再返回新值给 b → b=7 */
printf("a = %d (i++ 的返回值是旧值)\n", a);
printf("b = %d (++i 的返回值是新值)\n", b);
printf("i = %d\n", i);
return 0;
}$ gcc -std=c11 -Wall incdec.c -o id && ./id
a = 5 (i++ 的返回值是旧值)
b = 7 (++i 的返回值是新值)
i = 7i++(后置)先返回 i 的旧值、再自增,所以 a = i++ 拿到的是 5(旧值),但之后 i 变成了 6;++i(前置)先自增、再返回新值,所以 b = ++i 拿到的是 7(新值),i 此时是 7。一句话记法:「后置」的返回值是「操作前」,「前置」的返回值是「操作后」。如果只是单独一句 i++;(不用它的返回值),前置后置效果一样;但用在表达式里(a = i++),区别就出来了。对于基本类型,i++ 和 ++i 性能没差别;但 C++ 里对迭代器,前置 ++it 通常更快(后置要拷贝一份旧值),所以很多人养成「能用 ++i 就不用 i++」的习惯——C 里无所谓,但用了不亏。
赋值运算符:= 是有返回值的表达式
赋值在 C 里不是一个「语句」、是一个有返回值的表达式:a = 5 把 5 赋给 a,整个表达式的值就是 5。所以你能写链式赋值 a = b = c = 0(从右往左结合,等价 a = (b = (c = 0)))。还有复合赋值 += -= *= /= %= 等,a += 5 等价 a = a + 5(但它只求值 a 一次,对有副作用的左值有用)。= 的结合性是从右到左(所以链式赋值成立),优先级很低(比大部分运算符都低,所以 x = a + b 是先算 a + b 再赋值)。
这里有个笔误陷阱:如果你本想写 if (a == 5) 却写成了 if (a = 5),因为 = 是赋值、结果是 5(非 0 = 真),这个 if 永远为真、还顺手把 a 改成了 5——阶段 0 第 9 章讲 -Wall 时演示过,gcc 的 -Wparentheses 会提醒你「条件里写了赋值,是不是想写 ==」。
优先级、结合性,以及求值顺序的 UB
C 有十几级运算符优先级(§6.5 的语法隐含了它们),背那个大表是反人类的——记不住就用括号。你只要记住几条最常用的:* / 比 + - 高(所以 < 等)比相等(==)高;逻辑运算符里 ! 比 && 高、&& 比 || 高;赋值 = 优先级几乎最低。剩下拿不准的,写括号——
最后是一个必须知道的 UB 陷阱:在同一个表达式里、对一个变量修改两次(或既修改又使用、中间没有「序列点」),是未定义行为(§6.5 第 2 段)。最经典的例子是 a[i] = i++;——这里既读了 i(算 a[i] 的下标)、又改了 i(i++),而它们之间没有序列点,编译器先算哪个完全说不准,结果就是 UB。类似的 i = i++、j = ++i + ++i 都是 UB。遇到「一行里多次动同一个变量」要警觉,拆成两行写最稳。&&、||、逗号运算符、三目 ?: 的 ? 和 : 之间是有序列点的(所以 ++i, ++i 用逗号运算符是合法的、不 UB),这是少数几个例外。
小结
C 的运算符里,算术 / 是整数除法(5/2 == 2,要 % 两边都得是整数;关系运算符返回 int 的 0/1。逻辑运算符 &&/|| 有短路求值(§6.5.13/§6.5.14):左边能定结果就跳过右边,所以 p != NULL && p->value 这种「防 null」写法才安全(真跑验证 0 && side_effect()、1 || side_effect() 里 side_effect 一次都没调用),但别把必须发生的副作用放在可能被短路的位置。自增自增 i++(后置,返回旧值)和 ++i(前置,返回新值)不一样(真跑 a=i++ 得 5、b=++i 得 7),单独写没区别、用在表达式里看返回值。赋值 = 是有返回值的表达式(所以 a = b = c 链式成立),但写成 if (a = 5) 是经典笔误(-Wparentheses 会抓)。优先级记不住就用括号(比背表强),而 a[i] = i++ 这种「一行里多次改一个变量」是 UB(§6.5 第 2 段,没有序列点就说不准求值顺序),拆成两行最稳。下一章我们看另一大类运算符——位运算和移位,它们和这一章的提升、溢出规则会再次交汇。
参考资源
- ISO/IEC 9899:2011 §6.5(表达式与各运算符,优先级由语法隐含)、§6.5.13(
&&,含短路/序列点)、§6.5.14(||)、§6.5.2.4(后置++/--)、§6.5.3.1(前置++/--)、§6.5.16(赋值)、§6.5 第 2 段(同一表达式多次修改同一对象为 UB) - 第 4 章:浮点、字符、常量与隐式转换(整数除法
5/2的坑) - 阶段 0 · 第 9 章:警告旗标(
-Wparentheses抓if (a = 5)) - 第 6 章:位运算与移位(
&|^~<<>>)