C语言学习笔记——运算符与表达式

数据类型那篇里, 我们让 5/2 得出了 0.000000 这个莫名其妙的结果, 当时说”格式符和类型不匹配”. 但还有个更基础的问题, 考虑如下代码:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main() {
int a = 5, b = 2;
double c = a/b;

printf("%lf", c); // 输出 2
}

上面这段代码里, c 是 double, printf 也用 %f 输出, 按理说该打印 2.5. 可实际结果是 2.000000 —— 问题出在 5/2 这一步, 它根本没算出 2.5

这就轮到运算符了

1 C 的运算符

打开任何一本教材的运算符表, 密密麻麻几十行, 第一反应是这牛魔要背到什么时候. 其实它们分成几类, 而且大部分早就会用, 只是不知道名字:

类别 运算符 干什么
算术 + - * / % 加减乘除取余
关系 > < >= <= == != 比大小, 结果是”真/假”
逻辑 && \|\| ! 把几个条件连起来
赋值 = 以及 += -= *= /= 等 往变量里放值
自增自减 ++ -- 加一、减一
逗号 , 把几件事串成一串
条件 ?: 二选一
位运算 & \| ^ ~ << >> 直接操作二进制位
其他 sizeof () [] . -> & * 取大小、取地址等

这一篇只讲前面七类, 位运算留到 预处理与位运算 那篇, 因为它需要先熟练运用二进制

*注意, sizeof 是运算符, 不是函数. 原因是它可以不加括号 —— sizeof x 合法, 而函数调用 f x 一定报错. 并且它不需要 #include 任何头文件. 这也是为什么 C89 的 32 个关键字里没有 sizeof(它是运算符, 关键字是另一回事) 却又能当关键字用的原因

一般而言, 在Dev-C++里以粗体显示的都是关键字

2 算术运算符

+ - * 三个没什么好说的, 和小学学的一样. 但 / 不一样:

1
2
printf("%d\n", 5 / 2);      // 2, 不是 2.5
printf("%f\n", 5 / 2.0); // 2.500000

两个整数相除, 结果一定是整数, 无论是否用实型存读, 小数部分会被直接扔掉. 这就是整数除法. 想要小数, 就得让其中一个变成小数:

1
2
3
4
5 / 2         // 2     两个都是 int
5 / 2.0 // 2.5 2.0 是 double, 5 被提升
5.0 / 2 // 2.5 同理
(double)5 / 2 // 2.5 强转

这就是数据类型那篇里 %f 打出 0.000000 的根源: 先按整数除法得 2, 再被当成浮点数读出来

“扔掉”的具体规则是朝 0 截断, 即朝着绝对值减小的方向截断, 用人话说就是闭着眼睛抹掉小数点后面的部分

1
2
printf("%d\n", 7 / 2);     //  3
printf("%d\n", -7 / 2); // -3, 不是 -4

-3.5 朝零截断得 -3. 这一点常考

2.1 % 取余

% 求的是余数, 只能用于整数:

1
printf("%d\n", 7 % 3);     // 1

写成 7.0 % 3 会直接编译不过:

1
error: invalid operands to binary % (have 'double' and 'int')

负数的 % 是另一个坑. 规则是: 结果的符号跟”被除数”一致:

1
2
3
printf("%d\n", -5 % 3);    // -2
printf("%d\n", 5 % -3); // 2
printf("%d\n", -5 % -3); // -2

注意第二行, 被除数是 5(正), 所以结果是正的 2 —— 哪怕除数是负数. 这条规则背下来就行

% 在考试里最常见的用途有两个: 判断整除(n % 2 == 0)和取出各位数字(n % 10 得个位, 再来一次得百位)

3 关系运算符: 结果是个整数

> < >= <= == != 用来比较, 但它们的结果不是”真/假”两个字, 而是整数 1 和 0:

1
2
3
printf("%d\n", 3 > 2);     // 1
printf("%d\n", 3 < 2); // 0
printf("%d\n", 3 == 3); // 1

在 C 里, 0 表示假, 非 0 表示真. 关系运算只会产出 1 或 0, 但判断的时候”任何非 0 值”都算真

举个能体现这一点的例子:

1
2
if (5) printf("真");       // 5 是非0, 所以为真, 会打印
if (0) printf("真"); // 0 是假, 不打印

3.1 两个经典错误

第一个: 把 == 写成 =

1
2
if (a == 5)     // 判断 a 是不是 5
if (a = 5) // 把 5 赋给 a, 然后判断 a 的值(非0) -> 永远为真!

= 是赋值, == 才是比较. 写错了编译器多半不报错(只可能有 warning), 但逻辑全错

第二个: 连写比较

数学上可以写 , 但 C 里这么写达不到预期的效果, 其会将左边的不等式算出结果再进行右边的不等式

1
2
printf("%d\n", 1 < 2 < 3);   // 1
printf("%d\n", 3 > 2 > 1); // 0 <- 反直觉!

看第二行: 3 > 2 先算出 1, 然后变成 1 > 1, 结果是 0. 明明 3 既大于 2 也大于 1, 结果却是假

正确写法必须用逻辑运算连起来:

1
if (1 < x && x < 10)    // 对

4 逻辑运算符与短路求值(重点)

&&(与)、||(或)、!(非). 三个都要注意

4.1 结果也是 1 和 0

1
2
3
4
printf("%d\n", 3 && 5);    // 1, 不是 5!
printf("%d\n", 3 || 0); // 1
printf("%d\n", !5); // 0
printf("%d\n", !0); // 1

! 有个有意思的性质: 双重否定还原成 1, 不是原值:

1
printf("%d\n", !!5);       // 1, 不是 5

4.2 短路求值

这是重点, 也是考试爱考的地方. && 左边的值如果是假, 右边根本不执行; || 左边如果是真, 右边也不执行.

看个例子:

1
2
3
int i = 0;
int r = (0 && (i = 99));
printf("r=%d, i=%d\n", r, i); // r=0, i=0

i 还是 0 —— 因为 0 && ... 左边已经是假, 整个式子必然为假, 右边那个 i = 99 压根没被执行

同理:

1
2
3
int i = 0;
int r = (1 || (i = 99));
printf("r=%d, i=%d\n", r, i); // r=1, i=0

左边已经是真, 右边不执行

这个特性的实际价值在于: 它让 if (p != NULL && *p > 0) 这种写法变得安全 —— 如果 p 是空指针, 左边为假, 右边 *p 就不会被求值, 不会崩溃. 短路求值是在”有意利用”的, 不是巧合, 指针相关的会在后续章节中具体展开

这类题几乎必考, 问法一般是”执行后 i 的值是多少”, 或者”printf 会不会执行”. 判断方法就是从左往右看, 一旦能定下整体真假, 后面的全部跳过

5 赋值运算符

= 是赋值. 有个性质容易忽略: 赋值表达式本身也有值, 值就是赋进去的那个东西:

1
2
3
int m, n;
m = n = 5; // 从右往左: n=5 的值是5, 再赋给 m
printf("%d\n", (m = 3)); // 3

所以 m = n = 5 能工作, 它是 m = (n = 5), 右侧先算

5.1 复合赋值

+= -= *= /= %= 这五个是简写:

简写 等价于
a += 5 a = a + 5
a -= 5 a = a - 5
a *= 5 a = a * 5
a /= 5 a = a / 5
a %= 5 a = a % 5

还有个容易搞混的地方: a /= 5 是否丢小数取决于 a 的类型:

1
2
int    b = 7; b /= 2;    // b 变成 3, 整数除法丢小数
double d = 7; d /= 2; // d 变成 3.5, 正常除

复合赋值不改变类型, 只是简写. 该丢的还是丢

注意: 如果 = 漏掉写成 m + 3 不会报错, 但是由于这仅仅是个算式, 没有任何赋值操作, 导致变量的值不会改变

6 自增自减: 坑最多的一节

++ 是加一, -- 是减一. 单独用时颠过来倒过去随便用, 均会改变变量的值:

1
2
c++;    // c 加 1
--c; // c 减 1

但一旦混进表达式里, 前后缀的区别就出来了:

1
2
int a = 5;
printf("%d\n", a++); // 打印 5, 之后 a 变成 6
1
2
int a = 5;
printf("%d\n", ++a); // 打印 6, a 也变成 6
  • 后缀 a++, 加在后: 先用再加. 表达式取的是”加之前”的值
  • 前缀 ++a, 加在前: 先加再用. 表达式取的是”加之后”的值

两者结束时 a 都是 6, 区别只在”表达式的值”上

6.1 未定式

tmd高数还在追我

下面这种写法结果是不确定的, 别记答案:

1
2
int i = 5;
int r = i++ + i++; // 我这台机器上得 11, i 变 7

看起来像 5 + 6 = 11, 但语言标准根本没规定先算哪一边. 换个编译器、换个优化等级, 结果就可能不一样. 这在标准里叫未定义行为(undefined behavior)

原因: C 标准只规定”表达式整体的值”, 不规定”各部分的求值顺序”. 一个变量在同一条表达式里被改两次, 就是踩了这条规则的线

类似的还有 f(i++, i++), a[i] = i++ 等. 考试基本不考这种题, 因为它没有唯一答案; 真考了, 那就是出题人自己的问题真考到就按直觉来吧, 毕竟出题人估计也没动啥脑子

7 逗号运算符

逗号能把好几个表达式串成一串, 整个式子的值等于最后一个表达式的值:

1
2
int c = (1, 2, 3);
printf("%d\n", c); // 3

前面两个算了但白算, 只取最后那个

它能做点有用的事, 比如在一个表达式里干几件事:

1
2
3
int x, y;
int t = (x = 10, y = 20, x + y);
printf("%d\n", t); // 30

注意别和函数参数里的逗号搞混: printf("%d", a, b) 里的逗号是参数分隔符, 不是逗号运算符. 只有当逗号出现在”表达式”的位置上, 它才是运算符

逗号运算符的优先级是最低的, 比赋值还低. 所以 int c = 1, 2, 3; 和你想的完全不同 —— 它等价于 (int c = 1), 2, 3;, c 就是 1. 想要 3 必须加括号

8 条件运算符(三目)

?: 是 C 里唯一的三目运算符, 形式是:

1
条件 ? 成立时的值 : 不成立时的值

记法: 条件(成立吗)?成立就直接上:不成立再说

用法直白:

1
2
printf("%d\n", 3 > 2 ? 100 : 200);    // 100
printf("%d\n", 3 < 2 ? 100 : 200); // 200

最经典的用法是求最大值:

1
2
int m = 5, n = 9;
printf("%d\n", m > n ? m : n); // 9

有一个隐藏特点: 两个分支的类型不同时, 会发生类型提升:

1
2
printf("%f\n", 1 ? 2 : 3.5);            // 2.000000
printf("%d\n", (int)sizeof(1 ? 2 : 3.5)); // 8

2 本来是 int, 但因为另一个分支是 double(3.5), 整个表达式的类型被提升成 double. 所以 sizeof 得 8 而不是 4

*9 位运算

位运算直接操作二进制位, 和前面那些”算数值”的运算符不是一个思路. 它本身属于[预处理与位运算]那一篇, 但优先级表里绕不开它, 所以先认识一下

只要知道每个位运算符怎么算即可, 具体应用场景不涉及考查

一共 6 个: & | ^ ~ << >>

& 按位与: 两位都是 1 才得 1

1
2
3
4
  6 = 00000110
3 = 00000011
--------------- 按位与
00000010 = 2

| 按位或: 有一位是 1 就得 1

1
2
3
4
  6 = 00000110
3 = 00000011
--------------- 按位或
00000111 = 7

^ 按位异或: 两位不同才得 1

1
2
3
4
  6 = 00000110
3 = 00000011
--------------- 按位异或
00000101 = 5

~ 按位取反: 0 变 1, 1 变 0. 注意它作用在补码上, 结果是负数

1
printf("%d\n", ~6);     // -7

<< 左移、>> 右移: 整体挪动若干位, 若空出位置则用0补全

1
2
printf("%d\n", 1 << 3);    // 8,  相当于 1 * 2^3
printf("%d\n", 16 >> 2); // 4, 相当于 16 / 2^2

实用场景. 位运算在考试和实际代码里主要有这么几种用法:

1
2
3
4
5
n & 1            // 判断奇偶: 得 0 是偶数, 得 1 是奇数
(x >> i) & 1 // 取第 i 位
n & 0x0F // 取低 4 位
n | (1 << i) // 把第 i 位置 1
n ^ (1 << i) // 把第 i 位取反

异或还能不借助临时变量交换两个数:

1
2
int p = 5, q = 9;
p ^= q; q ^= p; p ^= q; // 交换后 p=9, q=5

两个坑: 1. ~6 等于 -7 不是 249 —— 因为 int 是有符号的, 取反后按补码解释就是负数. 想得到无符号的结果要用 unsigned 2. 右移负数的结果由实现决定 —— -8 >> 1 在我的编译器上得 -4, 但标准没规定补位补 0 还是补 1. 别写这种代码

10 优先级与结合性

到这儿所有常用运算符都认识了, 现在才能谈优先级, 否则一上来就背表没有意义

优先级: 谁先算; 结合性: 优先级相同时从左还是从右算

先看优先级(从高到低). ★ 是考试常考的, 其余了解即可:

级别 运算符 说明 重要度
1 () [] -> . 括号、下标、成员 ★
2 ! ~ ++ -- +(正) -(负) *(解引用) &(取址) sizeof (类型) 单目运算 ★
3 * / % 乘除余 ★
4 + - 加减 ★
5 << >> 移位 ★
6 < <= > >= 关系 ★
7 == != 相等性 ★
8 & 按位与
9 ^ 按位异或
10 \| 按位或
11 && 逻辑与 ★
12 \|\| 逻辑或 ★
13 ?: 三目 ★
14 = 及复合赋值 赋值 ★
15 , 逗号 ★

结合性只有两个方向:

  • 从右到左: 单目运算符(级别2)、三目(13)、赋值(14)
  • 从左到右: 其余全部

单目运算符的”右结合”是什么意思

这里最容易绕晕, 因为单目运算符只有一个操作数, 那”左右”从何谈起?

关键前提: 结合性只在”同一个运算符连着出现好几次”时才有意义. 比如 10 - 3 - 2 里有两个 -, 先算哪个就影响结果, 这才需要规则

单目运算符天生不存在这种歧义. -a 只有一个操作数 a, 没得选. 就算连着写, 比如 !!a, 也只能是 !(!a) —— 因为 (!a)! 根本不是合法的 C 语法. 所以标准里给单目标”从右到左”, 其实是因为只需要从右往左逐层剥开, 并没有真正的”选择”

拿 - -a 看(中间加空格, 免得被当成 --):

1
printf("%d\n", - -a);     // a=5 时得 5

解析成 -(-a): 先算最里层的 -a, 再套外层的负号. 这就是”从右到左”的含义 —— 从右往左一层层套, 而不是”右边的先算完”

所以你别被下面这种例子误导:

1
printf("%d\n", -a - b);     // -8, a=5 b=3

这里的两个减号根本不是同一个运算符! 第一个是单目负号(级别 2), 第二个是减法(级别 4). 它们优先级不同, 按优先级就定死了是 (-a) - b, 压根轮不到结合性出场

你看到”像是左结合”, 其实那是优先级在起作用. 结合性只解决”同级运算符连着出现”的问题

对比一下双目的 -(级别 4), 那个才有真正的结合性:

1
printf("%d\n", 10 - 3 - 2);      // 5, 不是 9

两个 - 同级, 这时才靠结合性决定: 左结合, 所以是 (10-3)-2 = 5. 要是右结合就成了 10-(3-2) = 9

赋值是从右到左的, 所以 m = n = 5 才能work:

1
m = n = 5      // 右结合 -> m = (n = 5)

如果是左结合, 会变成 (m = n) = 5, 也就是”给 m=n 这个结果赋值”, 那是非法的

一句话总结: 优先级管”不同级谁先算”, 结合性管”同级连着写谁先算”, 单目的”右结合”只是”从右往左剥”的描述

10.1 几个容易记错的地方

1.&& 比 || 优先级高

1
printf("%d\n", 1 || 0 && 0);      // 1

先算 0 && 0 得 0, 再算 1 || 0 得 1

2.! 优先级很高, 别漏括号

! 是级别 2, 比所有双目运算符都高. 拿 a = 6 试:

1
2
printf("%d\n", !(a & 1));    // 1  先算 a&1 得 0, 再取非得 1
printf("%d\n", !a & 1); // 0 先算 !a 得 0, 再 0&1 得 0

同一个式子的两行结果完全不同 —— 加不加括号, 算的是两码事

顺便注意, !(3 > 2) 和 !3 > 2 这两个碰巧都是 0(前者 !1=0, 后者 0>2=0), 用它俩举例是看不出差别的. 所以别拿它记规则, 老老实实加括号最省事

*3.位运算的优先级低于关系运算

== 是级别 7, & 是级别 8, 所以 & 反而后算:

1
2
if (a & 1 == 0)      // 陷阱! 先算 1 == 0 得 0, 整个变成 a & 0, 恒为假
if ((a & 1) == 0) // 这才是想要的: 判断 a 是偶数

拿 a = 6 看: 前者先算 1 == 0 得 0, 再算 6 & 0 得 0(假), 于是永远进不去 if; 后者算 6 & 1 得 0, 再算 0 == 0 得 1(真), 正确判定为偶数

*4.优先级和求值顺序是两件事

优先级决定”谁跟谁结合”, 但不决定”谁先算完”. f() + g() 里哪个先执行是未指定的. 这个区别在考试里偶尔以概念题出现

*11 表达式的类型与隐式转换

一个表达式算出来是什么类型, 由参与运算的类型决定:

1
2
3
1 / 2         // int   -> 0
1 / 2.0 // double -> 0.5
1.0 / 2 // double -> 0.5

规则是向”精度高”的方向靠: int 和 double 混算, 结果一定是 double. 这叫类型提升

常见的高低顺序:

1
char -> short -> int -> unsigned -> long -> float -> double -> long double

其实本质上就两条: 纯整数运算就是整数运算, 该丢小数就丢; 一旦混入浮点, 结果就是浮点

12 一个完整的例子: 简易计算器

把这篇的东西串起来:

写一个能算加减乘除和取余的计算器. 要求:

  • 一次读入一个算式, 形如 8*3, 也就是两个整数中间夹一个运算符
  • 支持 + - * / % 五种
  • 除法要处理三件事: 除数为 0 时报错; 能整除就输出整数; 不能整除要输出小数(不是截断成整数)
  • 遇到不认识的运算符要提示

示例输入输出见下

1
示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <stdio.h>

int main(void) {
int a, b;
char op;

printf("输入算式(如 8*3): ");
scanf("%d%c%d", &a, &op, &b);

if (op == '+') {
printf("%d + %d = %d\n", a, b, a + b);
} else if (op == '-') {
printf("%d - %d = %d\n", a, b, a - b);
} else if (op == '*') {
printf("%d * %d = %d\n", a, b, a * b);
} else if (op == '/') {
/* 这里必须判断: 除数不能为0, 而且要处理整数除法 */
if (b == 0) {
printf("除数不能为 0\n");
} else if (a % b == 0) {
printf("%d / %d = %d\n", a, b, a / b);
} else {
printf("%d / %d = %.4f (整数除法会丢小数, 这里转成浮点)\n",
a, b, (double)a / b);
}
} else if (op == '%') {
if (b == 0) {
printf("除数不能为 0\n");
} else {
printf("%d %% %d = %d\n", a, b, a % b);
}
} else {
printf("不认识的运算符: %c\n", op);
}

return 0;
}

这个程序里用到了本篇的不少东西:

  • % 判断能否整除(a % b == 0), 决定输出整数还是小数
  • (double)a / b 强转, 避免整数除法丢小数
  • && 虽然没直接出现, 但 else if 的链式判断是同一个思路
  • op == '+' 用的是 == 不是 =
  • %% 是 printf 里输出百分号的办法

注意 scanf("%d%c%d", &a, &op, &b) 这个写法. %c 会读入任何字符(包括空格), 所以输入必须是 8*3 这样紧挨着的. 如果写成 8 * 3, %c 读到的是空格而不是 *, 就会走进最后的 else. 这是 %c 的老毛病, 想跳过空白得写 " %c"(前面加个空格)

优先级那张表不用背, 但加减乘除、关系、逻辑、赋值的相对高低要有印象: 算术 > 关系 > 逻辑 > 赋值 > 逗号(这条链也不用背, 多看看有感觉就行). 实在记不清就加括号