4. 表达计算
本章涵盖:
- 进行算术运算
- 修改对象
- 使用布尔值
- 三元运算符
- 确定求值顺序
前面已经使用过一些简单的表达式示例。表达式是根据其他值计算出一个值的代码片段。其中最简单的是算术表达式,与学校里学过的表达式相似。但还有其他种类,尤其是前面见过的 == 和 != 等比较运算符。
4.1 操作数与运算符
这里用于计算的值和对象,大多采用已经见过的 size_t 类型。这类值对应“大小”,因此是不能为负的数,其可能值域从 0 开始。我们希望表示所有非负整数;在数学中,这些数通常记作 SIZE_MAX 是一个很大的上界,限定了 size_t 能够表示的最大值。
要点 4.1 #1
size_t 类型表示范围 [0, SIZE_MAX] 内的值。
SIZE_MAX 的值相当大。根据平台不同,它是下面三个值之一:
第一个值是最低要求;如今,这么小的值只会出现在某些嵌入式平台上。另外两个值目前常见得多:第二个仍见于一些个人计算机和笔记本电脑,绝大多数较新的平台则采用第三个。对于不太复杂的计算,这样选取的值已经足够大。标准头文件 <stdint.h> 提供 SIZE_MAX,因此无须自行推算该值,也无须据此对程序作专门适配。
前面对 size_t 所说的“不能为负的数”,对应 C 所称的无符号整数类型。+、!= 这样的符号或符号组合称为运算符,运算符所作用的事物称为操作数。因此,在 a + b 这样的表达式中,+ 是运算符,a 和 b 是它的操作数。
下面几张表概览了 C 的全部运算符:
- 表 4.1 列出作用于值的运算符。
- 表 4.2 列出作用于对象的运算符。
- 表 4.3 列出作用于类型的运算符。
使用这些表时,可能需要在表间来回跳转。例如,如果要分析 a + 5 这样的表达式,其中 a 是某个 unsigned 类型的对象,就得先查表 4.2 第三行,得知 a 会被求值;随后再查表 4.1 第三行,推断 a 的值与 5 通过算术运算 a + 组合。即使无法理解表中的全部内容,也不必沮丧。这里提到的许多概念尚未介绍;把它们列在这里,是为了形成贯穿全书的参考资料。
表 4.1 值运算符。 “形式”列给出运算的句法形式,其中 @ 表示运算符,a 以及可能出现的 b 表示充当操作数的值。对于算术运算和位运算,结果类型是协调 a 与 b 类型后得到的类型。对于部分运算符,“别名”列给出运算符的替代形式,或者列出具有特殊含义的运算符组合。大多数运算符和术语会在后文讨论。
| 运算符 | 别名 | 形式 | a 的类型限制 | b 的类型限制 | 结果 | 类别 |
|---|---|---|---|---|---|---|
a | 窄类型 | 宽类型 | 提升 | |||
+ - | a@b | 指针 | 整数 | 指针 | 算术 | |
+ - * / | a@b | 算术 | 算术 | 算术 | 算术 | |
+ - | @a | 算术 | 算术 | 算术 | ||
% | a@b | 整数 | 整数 | 整数 | 算术 | |
~ | compl | @a | 整数 | 整数 | 位 | |
& | bitand | a@b | 整数 | 整数 | 整数 | 位 |
| | bitor | a@b | 整数 | 整数 | 整数 | 位 |
^ | xor | a@b | 整数 | 整数 | 整数 | 位 |
<< >> | a@b | 整数 | 正值 | 整数 | 位 | |
== < > <= >= | a@b | 标量 | 标量 | 0, 1 | 比较 | |
!= | not_eq | a@b | 标量 | 标量 | 0, 1 | 比较 |
!!a | a | 标量 | 0, 1 | 逻辑 | ||
! | not | @a | 标量 | 0, 1 | 逻辑 | |
&& || | and or | a@b | 标量 | 标量 | 0, 1 | 逻辑 |
. | a@m | struct | 值 | 成员 | ||
* | @a | 指针 | 对象 | 引用 | ||
[] | a[b] | 指针 | 整数 | 对象 | 成员 | |
-> | a@m | struct 指针 | 对象 | 成员 | ||
() | a(b ...) | 函数指针 | 值 | 调用 | ||
sizeof | @ a | 无 | size_t | 大小、ICE | ||
alignof | _Alignof | @(a) | 无 | size_t | 对齐、ICE |
表 4.2 对象运算符。 “形式”列给出运算的句法形式,其中 @ 表示运算符,o 表示对象,a 表示充当操作数的适当附加值(如果存在)。“类型”列中的附加 * 要求对象 o 可取地址。
| 运算符 | 别名 | 形式 | 类型 | 结果 | 类别 |
|---|---|---|---|---|---|
o | 数组* | 指针 | 数组退化 | ||
o | 函数 | 指针 | 函数退化 | ||
o | 其他 | 值 | 求值 | ||
= | o@a | 非数组 | 值 | 赋值 | |
+= -= *= /= | o@a | 算术 | 值 | 算术 | |
+= -= | o@a | 指针 | 值 | 算术 | |
%= | o@a | 整数 | 值 | 算术 | |
++ -- | @o、o@ | 算术或指针 | 值 | 算术 | |
&= | and_eq | o@a | 整数 | 值 | 位 |
|= | or_eq | o@a | 整数 | 值 | 位 |
^= | xor_eq | o@a | 整数 | 值 | 位 |
<<= >>= | o@a | 整数 | 值 | 位 | |
. | o@m | struct | 对象 | 成员 | |
[] | o[a] | 数组* | 对象 | 成员 | |
& | @o | 任意* | 指针 | 地址 | |
sizeof | @ o | 数据对象、非 VLA | size_t | 大小、ICE | |
sizeof | @ o | VLA | size_t | 大小 | |
alignof | _Alignof | @(o) | 非函数 | size_t | 对齐、ICE |
表 4.3 类型运算符。 这些运算符返回 size_t 类型的整数常量(ICE)。它们采用类似函数的句法,操作数放在圆括号中。
| 运算符 | 别名 | 形式 | T 的类型 | 结果 |
|---|---|---|---|---|
sizeof | sizeof(T) | 任意 | 大小 | |
alignof | _Alignof | alignof(T) | 任意 | 对齐 |
offsetof | offsetof(T,m) | struct | 成员偏移量 |
4.2 算术
算术运算符是表 4.1 中第一组作用于值的运算符。
4.2.1 +、- 和 *
算术运算符 +、- 和 * 的工作方式大体符合预期,分别计算两个值的和、差与积:
size_t a = 45;
size_t b = 7;
size_t c = (a - b)*2;
size_t d = a - b*2;2
3
4
这里,c 必须等于 76,d 必须等于 31。从这个小示例中可以看到,子表达式可以用圆括号组合起来,强制运算符按期望方式结合。
此外,运算符 + 和 - 还有一元形式。-b 给出 b 的负值,即满足 b + a 为 0 的值 a。+a 只提供 a 的值。下面的代码同样得到 76:
size_t c = (+a + -b)*2;即使计算采用无符号类型,通过运算符 - 进行的取负与求差仍然是良好定义的。也就是说,无论为这种减法提供什么值,计算总会得到有效结果。事实上,size_t 的神奇性质之一,就是 +、-、* 算术只要可能就总能正常工作。只要最终的数学结果位于 [0, SIZE_MAX] 范围内,表达式的值就是这个结果。
要点 4.2.1 #1
无符号算术始终是良好定义的。
要点 4.2.1 #2
如果数学上的正确结果可以表示为 size_t,那么 size_t 上的 +、- 和 * 运算就会给出该结果。
如果结果不在该范围内,因而无法表示为 size_t 值,就称发生了算术溢出。例如,把两个非常大的值相乘,数学上的乘积大于 SIZE_MAX,就会发生溢出。下一节会考察 C 如何处理溢出。
4.2.2 除法与余数
运算符 / 和 % 稍微复杂一些,因为它们分别对应整数除法与求余运算。与另外三个算术运算符相比,你也许没有那么熟悉它们。a/b 求值得到 b 可以完整放入 a 多少次;从 a 中移去最大数量的 b 后,a%b 就是剩余值。运算符 / 和 % 成对出现:如果有 z = a / b,余数 a % b 就可以按 a - z*b 计算。
要点 4.2.2 #1
对于无符号值,a == (a/b) * b + (a%b)。
时钟上的小时数是 % 运算符的一个熟悉示例。假设有一个 12 小时制时钟:从 8:00 再过 6 小时就是 2:00。大多数人都能在 12 小时制或 24 小时制时钟上计算时间差。这种计算对应 a % 12;在我们的示例中,(8 + 6) % 12 == 2。[1] % 的另一种类似用途,是按每小时的分钟数进行计算,形式为 a % 60。
这两种运算只有一个不允许的值:0。禁止除以零。
要点 4.2.2 #2
只有第二个操作数不为 0 时,无符号 / 和 % 才是良好定义的。
运算符 % 还可以帮助进一步解释无符号类型的加法和乘法算术。如前所述,如果赋给无符号类型的值超出其范围,就称它溢出。此时,结果会像使用 % 运算符那样缩减。所得值会在类型的范围内“回绕”。对于 size_t,这个范围是 0 到 SIZE_MAX。
要点 4.2.2 #3
size_t 算术会隐式地以 SIZE_MAX + 1 为模进行计算。
要点 4.2.2 #4
发生溢出时,无符号算术会回绕。
这意味着,对于 size_t 值,SIZE_MAX + 1 等于 0,而 0 - 1 等于 SIZE_MAX。
正是这种“回绕”魔法,让 - 运算符可以用于无符号类型。例如,把值 -1 解释为 size_t 时,它等于 SIZE_MAX;因此,给值 a 加上 -1,就是对 a + SIZE_MAX 求值,回绕后得到:
运算符 / 和 % 有一项很好的性质:其结果始终不大于操作数。
要点 4.2.2 #5
无符号 / 和 % 的结果始终不大于操作数。
要点 4.2.2 #6
无符号 / 和 % 不会溢出。
4.3 修改对象的运算符
前面已经见过另一项重要运算——赋值:a = 42。从示例可以看出,这个运算符并不对称;右侧是一个值,左侧是一个对象。C 行话以一种古怪的方式滥用了语言,把右侧称为右值(right value),把左侧对象称为左值(left value)。我们会尽量避免这种词汇;说“值”和“对象”就足够了。
C 还有其他赋值运算符。对于任意二元运算符 @,已经见过的五种运算都采用下面的句法:
an_object @= some_expression;它们只是把算术运算符 @ 与赋值结合起来的方便缩写;参见表 4.2。基本等价的形式是:
an_object = (an_object @ (some_expression));换言之,存在运算符 +=、-=、*=、/= 和 %=。例如,在 for 循环中可以使用 +=:
for (size_t i = 0; i < 25; i += 7) {
...
}2
3
这些运算符的句法有些挑剔。不同字符之间不能留有空白;例如,写成 i + = 7 而不是 i += 7 会造成句法错误。
要点 4.3 #1
一个运算符的所有字符必须彼此直接相连。
前面还见过另外两个修改对象的运算符:递增运算符 ++ 和递减运算符 --:
++i等价于i += 1。--i等价于i -= 1。
所有这些赋值运算符都是真正的运算符。它们返回对象修改后的值,但不返回对象本身。如果你足够疯狂,甚至可以写出:
a = b = c += ++d;
a = (b = (c += (++d))); // Same2
不过,在一步中组合对多个对象的修改,通常会遭到反对。除非想故意混淆代码,否则不要这样做。在表达式中对所涉对象作出的这种改变称为副作用。
要点 4.3 #2
值表达式中的副作用是邪恶的。
要点 4.3 #3
绝不要在一个语句中修改一个以上的对象。
递增和递减运算符各自还有另一种形式:后缀递增与后缀递减。它们与前面见过的形式在向外围表达式提供结果时有所不同。前缀形式(++a 和 --a)先修改对象的值,然后返回修改后的结果,这与相应的赋值操作(a+=1 和 a-=1)非常相似;而后缀形式则先返回对象被修改前的值,随后再执行修改操作。不过,无论采用哪种形式,对象本身受到的影响是相同的:它的值最终都会递增或递减。
这一切表明,对带副作用的表达式求值,过程可能很难追踪。不要这样做。
4.4 布尔上下文
有些运算符会根据某项条件是否成立,产生值 0 或 1;参见表 4.1。它们可以分成两类:比较和逻辑求值。
4.4.1 比较
示例中已经见过比较运算符 ==、!=、< 和 >。后两个运算符在操作数之间进行严格比较;运算符 <= 和 >= 则分别进行“小于或等于”与“大于或等于”比较。前面已经看到,所有这些运算符都可以用于控制语句;不过,它们实际上比这更强大。
要点 4.4.1 #1
比较运算符返回值 false 或 true。
请记住,false 和 true 不过是 0 和 1 的花哨名称。因此,它们可以用于算术运算或数组索引。下面的代码中,c 始终为 1;如果 a 和 b 相等,d 就为 1,否则为 0:
size_t c = (a < b) + (a == b) + (a > b);
size_t d = (a <= b) + (a >= b) - 1;2
下一个示例中,N 是一个很大的数;数组元素 sign[false] 保存 largeA 中大于或等于 1.0 的值的数量,sign[true] 则保存严格小于 1.0 的值的数量:
double largeA[N] = { };
...
/* Fill largeA somehow */
size_t sign[2] = { 0, 0 };
for (size_t i = 0; i < N; ++i) {
sign[(largeA[i] < 1.0)] += 1;
}2
3
4
5
6
7
| 数组元素 | 类型 | 含义 |
|---|---|---|
sign[false] | size_t | 大于或等于 1.0 的值的数量 |
sign[true] | size_t | 严格小于 1.0 的值的数量 |
最后,还有一个标识符 not_eq 可以代替 !=。这项功能很少使用,它源自某些字符无法在所有计算机平台上正确提供的年代。要使用它,必须包含文件 <iso646.h>。
4.4.2 逻辑
逻辑运算符作用于本来就应当表示 false 或 true 的值。如果操作数并非如此,就先应用条件执行一节中介绍的规则(要点 3.1 #1)。运算符 !(not)对操作数作逻辑否定;运算符 &&(and)是逻辑与,||(or)是逻辑或。表 4.4 汇总了这些运算符的结果。
表 4.4 逻辑运算符
a | not a |
|---|---|
false | true |
true | false |
a and b | b = false | b = true |
|---|---|---|
a = false | false | false |
a = true | false | true |
a or b | b = false | b = true |
|---|---|---|
a = false | false | true |
a = true | true | true |
与比较运算符类似,逻辑运算符返回真值。
要点 4.4.2 #1
逻辑运算符返回值 false 或 true。
还是要记住,这些值不过是 0 和 1,因而可以用作索引:
double largeA[N] = { };
...
/* Fill largeA somehow */
size_t isset[2] = { 0, 0 };
for (size_t i = 0; i < N; ++i) {
isset[!!largeA[i]] += 1;
}2
3
4
5
6
7
这里,表达式 !!largeA[i] 两次应用 ! 运算符,从而只是确保把 largeA[i] 作为真值求值(要点 3.1 #4)。因此,数组元素 isset[0] 和 isset[1] 会分别保存等于 0.0 和不等于 0.0 的值的数量:
| 数组元素 | 类型 | 含义 |
|---|---|---|
isset[false] | size_t | 等于 0.0 的值的数量 |
isset[true] | size_t | 不等于 0.0 的值的数量 |
运算符 && 和 || 有一项称为短路求值的特殊性质。这个生硬的术语表示:如果不需要第二个操作数就能确定运算结果,就会省略对第二个操作数的求值:
// This never divides by 0.
if (b != 0 && ((a/b) > 1)) {
++x;
}2
3
4
这里,程序执行时会有条件地省略对 a/b 的求值,从而绝不会发生除以零。等价代码如下:
if (b) {
// This never divides by 0.
if (a/b > 1) {
++x;
}
}2
3
4
5
6
4.5 三元运算符或条件运算符
三元运算符与 if 语句相似,但它是表达式,会返回所选分支的值:
size_t size_min(size_t a, size_t b) {
return (a < b) ? a : b;
}2
3
与运算符 && 和 || 类似,第二个和第三个操作数只有真正需要时才会求值。来自 <tgmath.h> 的宏 sqrt 计算非负值的平方根;用负值调用它会引发范围错误:
#include <tgmath.h>
#ifdef __STDC_NO_COMPLEX__
# error "we need complex arithmetic"
#endif
double complex sqrt_real(double x) {
return (x < 0) ? CMPLX(0, sqrt(-x)) : CMPLX(sqrt(x), 0);
}2
3
4
5
6
7
在这个函数中,sqrt 只调用一次,而且该次调用的实参绝不会为负。因此,sqrt_real 的行为始终良好;传给 sqrt 的值绝不会不合要求。
复数算术及其工具需要头文件 <complex.h>,而 <tgmath.h> 会间接包含它们。第 5.7.8 节将介绍这些内容。
前一个示例还展示了由预处理指令实现的条件编译。#ifdef 构造确保:只有定义了宏 __STDC_NO_COMPLEX__,程序才会遇到 #error 条件。
4.6 求值顺序
对于目前见过的运算符,已经知道 &&、|| 和 ?: 会限制部分操作数是否求值。这尤其意味着,这些运算符的操作数存在求值顺序:第一个操作数为其余操作数提供条件,因而总是首先求值。
要点 4.6 #1
&&、||、?: 和 , 首先对第一个操作数求值。
逗号(,)是唯一尚未介绍的运算符。它按顺序对操作数求值,结果为右操作数的值。例如,(f(a), f(b)) 先对 f(a) 求值,再对 f(b) 求值;结果是 f(b) 的值。
请注意,逗号字符在 C 中还扮演其他句法角色,而那些角色并不采用相同的求值约定。例如,分隔初始化项的逗号与分隔函数实参的逗号不具备同样的性质。
逗号运算符在整洁代码中很少有用,而且是初学者的陷阱:A[i, j] 并不是矩阵 A 的二维索引,而会得到 A[j]。
要点 4.6 #2
不要使用 , 运算符。
其他运算符没有求值顺序限制。例如,在 f(a)+g(b) 这样的表达式中,并不存在预先确定的顺序来规定先计算 f(a) 还是先计算 g(b)。如果函数 f 或 g 中任意一个带有副作用(例如 f 在幕后修改 b),表达式的结果就会取决于所选择的顺序。
要点 4.6 #3
大多数运算符不会为其操作数定序。
这个顺序可能取决于编译器、编译器的特定版本、编译期选项,甚至仅仅取决于表达式周围的代码。不要依赖任何特定的定序方式,否则它早晚会反咬一口。
函数实参也是如此。在下面这样的代码中:
printf("%g and %g\n", f(a), f(b));我们无法知道最后两个实参中哪一个先求值。
要点 4.6 #4
函数调用不会为其实参表达式定序。
要想可靠地避免依赖算术表达式的求值顺序,唯一办法就是禁用副作用。
要点 4.6 #5
表达式中的函数调用不应带有副作用。
挑战 4:并查集(Union-Find)
并查集问题处理的是底集合上划分的表示。我们用数字 0, 1, ... 标识底集合中的元素,并用森林数据结构表示划分;在这种结构中,每个元素都知道一个“父元素”,后者是同一划分块中的另一个元素。划分中的每个集合都由一个指定元素标识,称为该集合的根。
我们希望执行两项主要操作:
Find操作接收底集合中的一个元素,返回相应集合的根。Union操作接收两个元素,把这两个元素所属的集合合并为一个集合。[2]
你能否使用一个名为 parent、基类型为 size_t 的索引表来实现森林数据结构?在这张表中,值 SIZE_MAX 表示相应位置是某棵树的根;其他数字则表示相应树节点的父元素位置。
实现开始阶段的一项重要功能,是编写初始化函数,把 parent 设为单元素划分,也就是让每个元素都成为自己独立集合之根的划分。
有了这张索引表,你能否实现一个 Find 函数,为给定索引寻找所在树的根?
你能否实现一个 FindReplace 函数,把通往根的路径上所有 parent 表项(包括根)改为一个指定值?
你能否实现一个 FindCompress 函数,把所有相关 parent 表项改为已经找到的根?
你能否实现一个 Union 函数,把给定两个元素的树合并为一棵树?请在一侧使用 FindCompress,在另一侧使用 FindReplace。
小结
- 算术运算符进行数学计算。它们作用于值。
- 赋值运算符修改对象。
- 比较运算符比较值,并返回
0或1。 - 函数调用和大多数运算符以非特定顺序对操作数求值。只有
&&、||和?:会对操作数的求值顺序施加规定。