C 语言表达式和运算符
表达式和运算符是组成 C 语言的基本组成部分,本文主要针对表达式和运算符进行讲解,涉及到赋值运算符、算术运算符、比较运算符和逻辑运算符等运算符以及条件表达式、成员访问表达式和函数调用表达式等一系列表达式。
表达式
C 语言中定义一个表达式由一个操作符和零个或多个运算符组成。操作数是诸如常量、变量以及函数调用返回值的类型对象。例如:
1 | 4 |
括号可以用于构造子表达式,如 ( 2 * ( ( 3 + 10 ) - ( 2 * 6 ) ) )
。在由括号组成的表达式中,首先对最内层的表达式进行求值,即 3 + 10
和 2 * 6
分别求值为 13
和 12
;随后进行 13 - 12
的表达式求值,其结果为 1
;最后执行 2 * 1
的表达式求值,得出最后结果为 2
。从这里可以看出,C 语言的表达式求值顺序是由内到外进行的,而最外层的括号是可以省略的。
函数调用表达式
对任何具有返回值的函数的调用是一个表达式。例如:
1 | int function(void); |
成员访问表达式
您可以使用成员访问运算符 .
来访问联合或结构体的成员。您需要将结构体或联合的变量放在运算符的左边,成员名放在运算符的右边。例如:
1 | struct point |
当然,您也可以通过指针进行访问(使用间接运算符 ->
)。x->y
等同于 (*x)->y
。例如:
1 | struct fish |
条件表达式
您可以通过条件运算符来形成一个条件表达式,它将根据第一个操作数的真假来决定是计算第二操作数还是第三个操作数。例如:
1 | a ? b : c; |
如果表达式 a
为 true
,那么表达式 b
的结果将作为整个表达式的最终结果。否则表达式 c
的结果将作为整个表达式的最终结果。
表达式 b
和 c
必须兼容,即,它们必须是:
- 算术类型;
- 兼容的结构体或联合类型;
- 兼容的指针类型(其中一个可能为
NULL
)。
例如:
1 | a = (x == 5) ? y : z; |
如果 x
等于 5
,那么 a
将等于 y
的值;否则,a
将等于 z
的值。它可以被视为是一个简短的 if ... else ...
的写法。例如,下面的写法等同于上述写法:
1 | if (x == 5) { |
如果第一个操作数为 true
,那么第三个操作数将不会被执行;同样地,如果第一个操作数为 false
,那么第二个操作数将不会被执行。第一个操作数总是会被执行的。
表达式中的语句与声明
作为 GNU C 的扩展,它允许您在括号中构建复杂的语句。它让您可以在表达式中引入循环、switch
语句以及局部变量。
回想一下,复合语句(也称为块)是由大括号包围的语句序列。例如:
1 | ({ int y = function (); int z; |
这是一个求函数 function()
返回值的绝对值的有效表达式(虽然复杂了一点)。
复合语句中的最后一件事应该是一个后跟分号的表达式; 此子表达式的值用作整个构造的值。(如果你在大括号中最后使用了一些其他类型的语句,那么构造的类型为void,因此实际上没有值。)
此功能在使宏定义“安全”时非常有用(因此它们只能评估每个操作数一次)。例如,我们经常在 C 语言中看到如下定义:
1 |
但是上述定义中 a
或者 b
将被执行两次,这就可能导致副作用。在 GNU C 中,如果您知道操作数的类型,您可以定义更为安全的版本(以整型为例):
1 |
如果您不知道操作数的类型,您仍然可以这样做,但是您需要使用 typeof
表达式或者类型命名。
常量表达式中不允许嵌入语句,例如枚举常量的值,位字段的宽度或静态变量的初始值。
运算符
运算符指定了操作数上需要执行的操作,运算符可以有一个、两个或者三个操作数,这取决于运算符的类型。
赋值运算符
赋值运算符(Assignment Operator)将值存储在变量中。C 语言提供了多种赋值运算符的变体。
标准的赋值运算符(=
)仅仅是将右操作数的值存放到左操作数指定的变量中。与所有赋值运算符一样,左操作数(通常称为__左值 lvalue__)不能是字面值(literal)或常量值。
1 | int x = 10; |
不同于下面将要介绍的其它赋值运算符,普通的赋值运算符可以为结构体类型赋值。
复合赋值运算符执行的操作涉及到左操作数和右操作数,并将计算的结果存储到左操作数中。下表给出了复合赋值运算符以及其作用:
复合赋值运算符 | 作用 |
---|---|
+= |
将两个操作数进行加法运算,并将结果赋给左操作数 |
-= |
使用左左操作数减去右操作数,并将结果赋给左操作数 |
*= |
将两个操作数进行乘法运算,并将结果赋给左操作数 |
/= |
将左操作数除以右操作数,并将结果赋给左操作数 |
%= |
对两个操作数执行取余除法,并将除法结果赋给左操作数 |
<<= |
对左操作数执行左移操作,移位右操作数指定的位数,并将移位结果赋给左操作数 |
>>= |
对左操作数执行右移操作,移位右操作数指定的位数,并将移位结果赋给左操作数 |
&= |
对两个操作数执行按位与操作,并将操作结果分配给左操作数 |
|= |
对两个操作数执行按位或操作,并将操作结果赋给左操作数 |
^= |
对两个操作数执行按位异或操作,并将操作结果赋给左操作数 |
自增自减运算符
自增运算符 (++
) 是将操作数自加 1
的运算符。操作数必须是原始类型、指针或枚举类型变量之一。你可以在操作数之前或之后使用自增运算符。例如:
1 | char w = '1'; |
指针自增操作自有在指针所指向的值是有效的内存空间时才有意义。
前缀自增运算符在计算操作数之前加 1,后缀自增运算符在计算操作数之后加 1。在前面的示例中更换自增运算符的位置没有任何影响。但是,在下面的情况下就有所不同了:
1 | int x = 5; |
上面的示例输出结果为:
1 | 5 |
同样,您也可以使用自减运算符减去 1,其用法与自增运算符相同。
算术运算符
C 语言提供了标准的算术运算符,它包括加法、减法、乘法、除法、取模以及负号。这些算术运算符的使用相当简单,与我们平时接触到的算法大部分相同。例如:
1 | /* 加法 */ |
您可以在指针类型上使用加法和减法,但是不能使用乘法和除法。
1 | /* 乘法 */ |
正数的正数除法向零取整,例如 5/3
的值为 1。但是,如果操作数包含负数,那么取整的方向有实现决定。
您可以使用取模运算符 %
来获得两个操作数产生的余数。您通过将两个操作数分别放置在运算符的两端来进行取模运算。3 % 5
与 5 % 3
有不同的结果。取模运算符的操作数必须是原始数据类型。例如:
1 | /* 取模运算 */ |
负号运算符的使用如下所示:
1 | /* 负号 */ |
如果您在无符号数上使用负号运算符,其结果并不是其相反数,而是这个无符号数数据类型的最大值减去这个无符号数。
目前,大多数系统都使用二进制补码算法,因此在这类系统上,负数所能表示的范围总是比正数多。例如,在某些系统上,下面的程序:
1 |
|
其输出结果为:
1 | INT_MAX = 2147483647 |
当然,您还可以将正号应用于数值表达式,例如,int x = +42;
。除非显式为负数,否则假定数值为正,因此该运算符对程序操作没有影响。
比较运算符
您可以使用比较运算符来确定两个操作数彼此之间的关系:它们是否相等、一个比另一个大或一个比另一个小等。当您使用任何比较运算符时,其结果不是 1 就是 0,分别代表 true 和 false。
在下面的代码示例中,变量 x 和 y 代表算术类型或指针的任何两个表达式。
相等运算符 (==
) 用于测试两个操作数是否相等。如果它们相等,则返回 1;如果不想等,则返回 0。
1 | if (x == y) { |
不等号运算符 (!=
) 用于测试两个操作数是否不相等。如果它们相等,则返回 0;如果不想等,则返回 1。
1 | if (x != y) { |
浮点数的相等或不想等比较运算符可能产生意想不到的结果。我们在之前介绍数据类型的时候已经有所提及。
您可以比较函数指针是否相等或不等;测试的结果表示两个函数指针是否指向同一个函数。
除了相等和不想等之外,您还可以比较两个操作数之间的小于 (<
)、小于等于 (<=
)、大于 (>
) 和大于等于 (>=
) 关系。例如:
1 | if (x < y) |
逻辑运算符
逻辑运算符用于测试一组操作数的真假性。在 C 语言中,任何非零表达式都被视为真;而计算结果为零的表达式视为假。
逻辑与运算符 (&&
) 用于测试两个操作数是否均为真 (true)。如果第一个表达式计算结果为假 (false),则不会计算第二个表达式。
1 | if ((x == 5) && (y == 10)) |
逻辑或运算符 (||
) 用于测试两个操作数中是否有为真的。如果第一个表达式计算结果为真,则不会计算第二个表达式。
1 | if ((x == 5) || (y == 10)) |
逻辑非运算符 (!
) 用于反转操作数的真假性。
1 | if (!(x == 5)) |
由于逻辑运算符中的第二个表达式并不是必须被计算的,因此您可以用非常不直观的形式编写代码:
1 | if (foo && x++) |
如果 foo
的值为 0,那么不仅函数 bar()
不会被调用,x
的值也不会增加。如果您想要无论在什么情况下都增加 x
的值,您应该将其写在逻辑运算符之外来做这件事。
位移运算符
您可以使用左移运算符 (<<
) 来将第一个操作数向左移动指定的比特位。第二个操作数表示要移位的位数。从值左侧移除的位被丢弃,而右侧移入的位则填充为 0。例如:
1 | x = 47; /* 47 的二进制为 00101111 */ |
类似地,您可以使用右移运算符 (>>
) 来将第一个操作数向右移动指定的比特位。从值右侧移除的位被丢弃,而左侧移入的位通常也由 0 来填充,但如果第一个操作数是带符号的负值,则添加的位将为 0 或先前位于最左位位置的任何值。
1 | x = 47; /* 47 的二进制为 00101111 */ |
无论是左移还是右移运算符,如果第二个操作数大于第一个操作数的比特位时,或者第二个操作数是负数时,这都属于未定义的情况。
按位逻辑运算符
C 语言提供了按位与 (&
),按位或 (|
),按位取反 (~
) 以及按位异或 (^
) 运算符。(本节中的操作数均为二进制形式。)
按位与运算符将会检测两个操作数的每个位,仅当它们都为 1 时结果中的相应位才为 1,其它情况下均为 0。例如:
1 | 11001001 & 10011011 = 10001001 |
按位或则是在两个操作数中,相应位全为 0 时,其结果中对应的位才为 0,否则则为 1。例如:
1 | 11001001 ^ 10011011 = 01010010 |
按位取反则是将操作数中的每个比特位进行反转,例如:
1 | ~11001001 = 00110110 |
在 C 语言中,您只能将这些运算符与整数(或字符)类型的操作数一起使用,并且为了最大程度的可移植性,您应该只使用带有无符号整数类型的按位取反运算符。
指针运算符
您可以用取地址符 (&
) 来获取对象的内存地址。
1 | int x = 5; |
函数指针和数据指针是不兼容的,因此,您不能指望将函数的地址存储到数据指针中,然后将其复制到函数指针中并成功调用它。它可能适用于某些系统,但它并不具有可移植性。
作为 C89 的 GNU 扩展,您还可以使用标签地址运算符 (&&
) 获取标签的地址。结果是一个 void *
类型的指针,它可以与 goto
一起使用。
给定存储在指针中的内存地址,您可以使用间接运算符 *
来获取存储在地址中的值。例如:
1 | int x = 5; |
您需要避免在未初始化未确定的内存地址上使用解地址运算符。
sizeof
运算符
您可以使用 sizeof
运算符来获取数据类型的字节大小,其操作数可以是真实得到数据类型限定符(例如,int 和 float),也可以是有效的表达式。当操作数是数据类型时,它必须使用括号包裹起来。例如:
1 | size_t a = sizeof(int); |
sizeof
运算符的返回值类型为 size_t
,它定义在 <stddef.h>
头文件中。size_t
是无符号正数类型,可能为 unsigned int
或 unsigned long int
;在不同的系统上可能有所不同。
size_t
类型通常是循环索引的一种方便类型,因为它保证能够保存任何数组中的元素数量; 然而,int
则可能不满足这种情况。
sizeof
运算符可以用来自动的计算数组的元素个数。
1 |
|
有两种情况下可能不适用。第一种情况是在数组长度为 0 (GCC 的 GNU 扩展支持长度为 0 的数组);第二种情况则是在数组作为函数的参数(此时数组将退化为指针)。
逗号运算符
你可以使用逗号表达式来分割两个表达式。例如,第二个表达式可能会使用第一个表达式生成的值:
1 | x++, y = x * x; |
通常,逗号表达式经常用于 for
语句中:
1 | for (x = 1, y = 10; x <=10 && y >=1; x++, y--) |
这样做可是很方便的在 for
语句中设置、监控以及修改表达式的值。
注意,逗号也用于分割函数参数,但这时它并不属于逗号运算符。
如果您想再函数中使用逗号运算符,您需要使用括号将其包裹起来。例如:
1 | foo(x, y=47, x, z); |
上述示例将被解释为带有四个参数的函数调用。而下面的示例则表示三个参数的函数调用:
1 | foo(x, (y=47, x), z); |
其中,第二参数为 (y=47, x)
。
数组下标运算符
您可以使用下标运算符来获取数组元素。例如:
1 | my_array[0] = 5; |
数组的下标运算符等于指针操作,例如 A[i]
等同于 (*((A) + (i)))
。这就意味着大多数情况下使用数组名都等同于指针操作。这同样意味着您不能对使用 register
定义的数组使用下标运算符。
类型转换
您可以使用类型转换来将表达式转换为特定数据类型。类型转换由包裹在括号内的类型限定符以及后续的表达式组成。例如:
1 | float x; |
在上面的示例中,x
和 y
均为整数类型,随后执行除法操作,最后将其结果转换为 float
类型,其结果为 2
。我们显示地将除法的结果转换为 float
其实并没有任何作用,因为 y/z
的结果已经为 2
。
要解决此问题,您需要在除法运算发生之前将其中一个操作数转换为浮点类型。例如:
1 | float x; |
类型转换仅适用于标量类型(例如,整型、浮点类型以及指针类型)。因此,下面的转换将失败:
1 | struct fooTag { /* members ... */ }; |
运算符优先级
当表达式包含多个运算符(例如 a + b * f()
)时,将根据优先级规则对运算符进行分组。例如,该表达式的含义是调用不带参数的函数 f
,将结果乘以 b
,然后将结果添加到 a
。这就是运算符优先级的 C
规则为此表达式确定的内容。
以下是表达式类型的列表,首先按最高优先级顺序显示。有时两个或多个运营商具有相同的优先权;除非另有说明,否则所有这些操作员都是从左到右应用的。
- 函数调用、数组下标、成员访问运算符表达式。
- 一元运算符,包括逻辑非、按位取反、自增、自减、正负号、间接运算符、取地址运算符、类型转换、
sizeof
表达式。当连续包含多个一元运算符时,后面的运算符嵌套在前面的运算符中:例如!-x
表示!(-x)
。 - 乘法、除法以及取模表达式。
- 加法与减法表达式。
- 移位表达式。
- 大于、小于、大于等于、小于等于表达式。
- 等于、不等于表达式。
- 按位与表达式。
- 按位异或表达式。
- 按位或表达式。
- 逻辑与表达式。
- 逻辑或表达式。
- 条件表达式(使用
? :
)。当用作子表达式时,它们从右到左依次执行。 - 所有赋值表达式,包括复合赋值。当多个赋值语句在单个较大表达式中显示为子表达式时,它们将从右到左进行求值。
- 逗号运算符表达式。
上面的列表看起来比较枯燥,但它其实非常简单之间,不过它也隐藏这一些陷阱。例如:
1 | foo = *p++; |
这里 p
作为表达式的副作用递增,但是 foo
的取值为 *(p++)
而不是 (*p)++
,因为一元运算符从右到左绑定。还有其他潜在的意外隐藏在 C
优先级表背后的例子。因此,如果读者误解了程序的含义,那么您应该使用括号来明确您的意思。
求值顺序
在 C 语言中,您不能假设多个子表达式按照看似自然的顺序进行求值。例如,考虑表达式 ++a * f()
,请问 a
的自增发生在函数 f
调用之前还是之后呢?编译器可以按任意顺序执行,因此您无法进行假设。
实际的编译器在将源代码转换为实际计算机中的特定动作时,为了提高效率可以重新排序这些操作。您编写的程序与计算机实际执行的操作之间的对应关系是根据副作用和序列点来指定的。
副作用
副作用(_side effects_)包含一下几点:
- 访问
volatile
对象; - 修改一个对象;
- 修改文件;
- 调用一个执行了上述动作的函数。
这些本质上是运行程序的外部可见效果。它们被称为副作用,因为它们是表达式评估的影响,超出了表达式的实际结果值。
编译器允许以与程序源码所暗示不同的顺序执行程序的操作,前提是最终实际发生了所有必要的副作用。 编译器也允许完全省略一些操作; 例如,如果可以确定该值未被使用并且求值表达式的该部分不会产生任何所需的副作用,则允许跳过求值表达式的一部分。
序列点
编译器的另一个要求是副作用应该以正确的顺序发生。为了在不过度约束编译器的情况下提供此功能,C89 和 C90 标准指定了序列点列表。序列点(_sequence points_)是以下的其中之一:
- 函数的调用(在对函数参数求值完成之后);
- 与运算符
&&
的左侧操作数的结尾; - 或运算符
||
的左侧操作数的结尾; - 逗号运算符
,
的左侧操作数的结尾; - 三元运算符的第一个操作数结尾
a ? b : c
; - 完整的声明结尾;
- 初始化表达式的结尾;
- 表达式语句的结尾(即,表达式后的
;
); if
或switch
控制表达式的结尾;while
或do
控制表达式的结尾;for
语句的三个控制表达式中的任何一个的结尾;return
语句的表达式结尾;- 库函数返回之前;
- 在与格式化
I/O
项相关联的操作之后(例如,使用strftime
或printf
和scanf
函数); - 紧接在调用比较函数之前和之后(例如调用
qsort
函数)。
在序列点,先前表达求值的所有副作用必须完整,并且可能没有发生后续求值的副作用。
这可能看起来有点难以理解,但还有另一种方法可以考虑这一点。想象一下,您编写了一个库(其中一些函数是外部的,也许不是其他函数)并编译它,允许其他人从他们的代码中调用您的一个函数。上面的定义确保在它们调用函数时,它们传入的数据具有与抽象机器指定的行为一致的值,并且函数返回的任何数据都具有与抽象一致的状态机。这包括通过指针访问的数据(即不仅仅是函数参数和带有外部链接的标识符)。
以上略微简化,因为存在在链接时执行整个程序优化的编译器。但重要的是,虽然它们可能会执行优化,但程序的可见副作用必须与抽象机器生成的副作用相同。
序列点约束表达式
下面的代码
1 | i = i + 1 |
非常正常,且毫无疑问会在许多程序中出现。但是接下来的代码就有点难懂了
1 | i = ++i + 1; |
i
最终的值为多少呢?C 语言标准(C89 和 C99)都禁止在符合规划的程序中使用该结构。
在两个序列点之间,您只允许做以下两件事:
- 通过表达式的求值,对象可以将其存储值最多修改一次;
- 只读对象的先前值以确定要存储的值。
这两个条件中的第一个禁止表达式如 foo(x=2, ++x)
;第二个条件禁止像 a[i++] = i
这样的表达式。
1 | int x=0; bar(++x,++x) |
不符合规定的程序;在参数求值完成之前两次修改 x
的值。
1 | int x=0; bar((++x,++x)) |
允许;函数仅有一个参数(传入的值为 2
),逗号运算符有一个序列点。
1 | *p++ || *p++ |
允许;在 ||
处有一个序列点。
1 | int *p = malloc(sizeof(*p)), *q = p; *p = foo(); bar((*p)++, (*q)++); |
不允许;在对 bar
的参数的求值完成之前,p
对象被修改两次。事实上,这是通过 p
和 q
来完成的,这是无关紧要的,因为它们都指向同一个对象。
让我们回到我们用来介绍评估顺序问题的例子,++a * f()
。假设代码如下:
1 | static int a = 1; |
这个代码是否复合标准呢?尽管 foo
中的表达式两次修改 a
的值,但这不是问题。我们看看两种可能的情况:
- 右操作数
f()
先执行 - 由于f()
返回一个而不是void
,因此它必须包含一个return
语句。因此,在return
的末尾势必会有一个序列点,它介于f()
修改a
的值和左操作数修改a
值之间。 - 左操作数
++a
先执行 - 首先,a
自增,然后对f()
的参数(它们中有零)求值。在实际调用f()
之前势必有一个序列点。
因此,我们看到我们的程序复合标准。注意上述的讨论并不依赖于函数 f()
主体的具体细节,它仅取决于函数包含一个序列点,在我们的示例中它是一个 return
语句,但是表达式语句或完整的声明符也是可以的。
但是,上述代码的结果取决于运算符 **
的操作数的求值顺序(MacOS clang-1001.0.46.4 结果为 6
,即属于第二种情况)。如果首先计算左操作数,则 foo()
返回 6
,否则,返回 303
。C 标准没有规定操作数的求值顺序,也不需要实现给出文档说明或者按照某一特定顺序实现。这段代码是不确定的(unspecified),这就意味着它们可能是几种特定情况的一种,但是 C 标准并没有给出具体应该是那一种。
序列点和信号传递
当接收到信号时,这将在序列点之间发生。volatile
对象的副作用将先于序列点发生,而其它对象的更新可能不会发生(其实这个地方理解的不太清楚)。这甚至会出现在类似 x = 0;
这样的赋值语句中,因为代码生成器可能在为这条语句生成多个指令,这意味着它可以信号到来时被中途中断。
C 标准对信号处理程序中可能发生的数据访问非常严格。它们当然可以使用自动变量,但在读取或写入其他对象方面,它们必须是 volatile sig_atomic_t
类型。volatile
类型限定符确保对程序其他部分中的变量的访问不会跨越序列点,并且使用 sig_atomic_t
类型可确保变量的变化对于信号传递是原子的。
POSIX 标准还允许在信号处理程序调用少量库函数。这些功能称为异步信号安全(async-signal-safe)函数集。如果您的程序要在 POSIX 系统上运行而不在其他系统上运行,您也可以安全地在信号处理程序中调用它们。
参考
[1] https://www.gnu.org/software/gnu-c-manual/gnu-c-manual.html#Expressions-and-Operators