title: C语言教程-9-运算符及其优先级和求值顺序
tags: [C]
categories: C语言教程
description: 进一步扩充常用运算符,并讨论优先级和求值顺序
注意,本章讲解的优先级
,求值顺序
,副作用
极其重要,甚至一些十分熟悉C语言的老手也可能会犯相关的错误!
运算符
什么是运算符
我们在前面讲解过表达式
,语句
的概念,也讲解并使用了一些基本的运算符
,例如四则运算,赋值运算符等.
这一部分我们将会详细扩充一些运算符,并仔细讲解运算符与表达式求值的一些重要细节.
运算符和表达式不可分割,我们再来看一下标准中对表达式
的描述:
再看一下Microsoft文档中关于操作数的描述:
也就是说,运算符
及其操作数
的序列组成一个表达式.单一的运算符无法发挥作用,必须至少有一个对应的操作数才能构成一个表达式.至于操作数
的概念很好理解,简单来说就是参与运算的各种值.
下面来详细细分一下C语言中的各种常用运算符.
赋值运算符
=运算符
=运算符
是最简单的赋值运算符,它用于将=右边表达式的值赋值给左边的变量.
C语言中,一个单独的=
并不意味着"等于",这和Visual Baisc
等语言并不相同,他是一个赋值运算符.例如:
a = 3;
用于将=右边的值3
赋给左边的变量a
.也就是说,=左边是一个变量,右侧是将要赋给该变量的值.
赋值行为是从右向左的.
进一步注意,我们需要区分数学表达式和C表达式的区别,假如我们有:
i=i+1;
显然这个合法的语句在数学上行不通,但是,C语言中,这代表着把变量i的值加上1,然后将新值重新赋值给i变量.
这条语句中,左边的i指示一个变量,我们将要向i中写入数据;右边的i代表当前i中存储的值,我们取出该值并用于运算.
另一方面,类似这样的语句是错误的:
3 = a;
因为3是一个常量,你不能对其进行修改—无论是从语法上还是从逻辑上都不正确.很显然,3就是3,我们当然不能把3"赋值"为4.实际上,我们判断这样的语句是否合法,看的是=
左边是否为一个左值
,更准确的说,是可修改的左值
.这个问题请见本章下面的部分.
复合赋值元素符
不仅仅有=运算符
这个最基本的赋值运算符,为了简化代码,C语言还有其他的几种赋值运算符,我们将其称为复合赋值运算符
.我们直接来看标准
中的描述:
就是这些,写代码时如果有类似的赋值,直接使用复合赋值运算符即可.
算术运算符
加法运算符: +
+的使用非常显而易见,但是我们需要注意的是,+的左右两个操作数无论是否为右值,最后加法运算的结果(也就是这个表达式的值)一定是一个右值.
例如:
a+b
中,a和b都是左值,但是a+b计算出来的值,也就是表达式a+b是一个右值.
减法运算符: -
同理,它用于减法运算,和+一样,需要两个操作数.
+和-都需要两个操作数,所以他们都是二元运算符
.
符号运算符: +和-
这里和加法,减法运算符使用相同的符号,但是一定注意,他们是不同的!
因为我们可以有这样的一个表达式:
+a
或者
-a
这意味这此处的+或-仅仅需要一个操作数,所以他们都是一元运算符
,其作用也很简单—取相反数.
不过在过去,+a
是不被允许的.
乘法和除法运算符: *和/
关于这两个表达式之前就说过了,他们也是二元运算符
.需要注意的是,别忘了除法运算符有截断
这个特性(整数除法结果的小数部分被丢弃).
求模运算符: %
%运算符用于求a除以b的余数
,该运算符要求左右两个操作数必须
均为整数.
关于正数,没有任何问题.
对于负数而言,例如-8%3
,其结果要多注意一下.
我们有公式:A % B = A - A / B * B
或者可以简单记忆规律:
取模运算结果的正负是由左操作数的正负决定的.如果%左操作数是正数,那么取模运算的结果是非负数;如果%左操作数是负数,那么取模运算的结果是负数或0
位运算符 &,|,^,<<,>>
注:学习这节之前,最好尽量了解原码,补码和反码.
位运算符用于按位逻辑对操作数进行运算.
位运算的位,指的是二进制位,也就是说,位运算直接以二进制来处理操作数.
按位与: &
二者皆为1,结果才为1,否则为0
例如 3 & 1
的结果为1
即二进制011和001按位与
运算,结果为001,也就是十进制1
按位或: |
二者皆为0,结果才为0,否则为1
例如 3 | 2
的结果为3
即二进制011和010按位或
运算,结果为011,也就是十进制3
按位异或: ^
二者相同为0,不同为1
例如 4 ^ 2
的结果为6
即二进制100和010按位异或
运算,结果为110,也就是十进制6
左移运算符: <<
该运算符将操作数(以二进制处理)每一位向左移动(即向高位移动)k位,右边空出来的k位(即低位)用0填充,高位溢出的k位丢弃
例如 3 << 2
的结果为12
这里以一个字节的移位来举例
即3的二进制00000011向左移动2位,结果为00001100,其中最左边的2个0丢弃,最右边填充2个0,也就是十进制12
实际上,由于是对二进制移位,对m左移k位相当于m乘以 2 k 2^k 2k.例如3<<2的结果就是 3 ∗ 2 2 3*2^2 3∗22,也就是12
如果不能理解,尝试假设十进制移位,将m进行十进制左移k位相当于乘以 1 0 k 10^k 10k,例如3<<(base10)2的结果就是 3 ∗ 1 0 2 3*10^2 3∗102,也就是300
关于负数,左移会影响其符号位.
右移运算符: >>
这里要注意,尽量对正数进行右移,而不要对负数进行右移.
原因是:由于整数在计算机中以补码存储,最高位为符号位,那么就会有两种不同的右移—算数右移
和逻辑右移
.
算数右移:
右移k位时,高位空出来的k位以原操作数的符号位填充,以保持结果的符号不变.
逻辑右移:
右移k位时,高位空出来的k位以0填充.
C语言的实现
C语言中,右移取决于具体实现,尽管大部分实现(编译器)为算数右移
,但是不能保证所有的机器/编译器都是这样.
也就是说,C语言中,对于有符号数的右移操作,这是一个未定义行为
.我们尽量避免对有符号数(负数)进行右移操作.
移位运算符的问题
关于左移和右移的另一个问题是,如果我们指定移动的位数为负数(例如<< -3
),或者大于等于左操作数原本的二进制位数(例如,int为32位,但是我们<< 33
)
那么该行为未定义
,具体请查阅文档.例如,某些实现中,对int值进行<< -3
被处理为<< 32 + (-3)
也就是<< 29
自增自减运算符
这两个运算符本来是用于简化形如a=a+1
和a=a-1
这样的表达式的,但是实际上,有些其他语言的开发者甚至认为C语言的这两个运算符的特性带来的弊大于利.
++和–运算符
正如上面所写,用于简化形如a=a+1
和a=a-1
这样的表达式.
a=a+1
就等价于单独的++a
或者a++
a=a-1
就等价于单独的--a
或者a--
++和–有一个操作数,并允许该操作数放在左边(前缀自增/自减,例如a++)或放在右边(后缀自增/自减,例如–a)
前缀和后缀的重要区别与副作用
前缀和后缀两种写法在单独使用时没有任何区别(一般编译器都会进行优化).
但是如果将其放在表达式中,就会出现区别.考虑下面两个语句:
int a=3,b=3,c,d;
// 第一条语句
c = ++a;
// 第二条语句
d = b++;
读者认为执行完后a,b,c,d的值各自是什么?
答案是: a为4,b为4,c为4,d为3
是否出乎你的预料?
实际上是这样的,从结果上来看:
1.前缀++a
返回的结果为a自增后的值,也就是4,将其赋值给c,a最终为4
2.后缀b++
返回的结果为b自增前的值,也就是3,将其赋值给d,b最终也为4
也就是说,前缀和后缀都会将操作数自增,但是这个表达式作为一个整体,返回的值是不一样的.
总结来说:
如果有如下表达式:
++表达式1
表达式2++
第一个式子:前缀自增运算符的结果是将值1
加到表达式1
的值.
第二个式子:后缀自增运算符的结果是原来表达式的值,也就是表达式2
的值.
尽管++和–的主要目的是将操作数的值加1或减1,但是我个人仍然愿意将其归为这个运算符的副作用
,毕竟,其要组成表达式,表达式的值通常
是重点关注的对象.但是,巧就巧在这里我们既要关心自增,又要使用其返回的值.
那么,我个人倾向于将++a或–a等视作"有副作用的表达式",也就是说,现在我更关心这个表达式最终的值
,而这几个表达式的主要作用—将a自增或自减1—我认为是副作用
,因为对这个表达式进行求值是不可逆的(当然可以再减回去或加回去,你知道我不是这个意思),它们让a的值发生了变化
!
这里再次出现了副作用
的概念,虽然大部分资料均为有这个名词的描述,可能甚至根本不关心,但是由于初学者的许多代码(无论是他们自己写的,或者是某些烂书/烂资料/烂题中出现的)常常会纠结表达式的副作用
及引出的相关一些未定义行为
,从而导致理解和使用的错误,本教程要对这个问题进行详细讨论!
逻辑运算符
与&&,或||,非!
注意位运算的&和|是单独的一个&和|,与逻辑运算符没有任何关系
在讲解循环的时候,已经讲解了逻辑运算符,已经基本包含所有问题,同时讲解了短路效应
,短路效应可能引发的问题会在后面副作用
的讲解中描述.
比较(关系)运算符
==,<=,>=,<,>,!=
同样在前面已经讲解.
需要注意的是,比较运算符常常和逻辑运算符搭配,例如:
if(a >= 0 && a <= 100)
这里仍需要注意优先级
的问题,逻辑运算符的优先级整体低于比较运算符(除了非!
),所以,先判断a的两个范围,即是否大于等于0
和是否小于等于100
,最后取并集,也就是是否在0~100内.
实际上也就是if( ( a >= 0 ) && ( a <= 100 ) )
一般情况下,()
的优先级全场最高,我们可以使用()
来改变优先级.
成员访问运算符
包括有:
下标运算符[]
,指针解引用运算符*
,取地址运算符&
,成员访问运算符.
,指向结构体成员运算符->
这些运算符的讲解融入到后面的各章节中.
其他运算符
这里仅说明几个常用的运算符.
三目条件运算符
唯一的三目运算符,形式为:
条件表达式 ? 表达式1 : 表达式2
当条件表达式
为真,则对表达式1求值;
当条件表达式
为假,则对表达式2
求值.
例如求2个数中的较大值:
#include <stdio.h>
int main() {
// 使用三目运算符来比较两个数的大小
int a = 10, b = 20, c;
c = (a > b) ? a : b;
printf("c = %d\n", c); // c = 20
return 0;
}
sizeof运算符
是的,这是一个运算符,可能许多朋友认为他是一个函数,但是他确确实实是一个运算符.
sizeof运算符用于求运算对象的大小,结果以字节为单位.
运算对象可以是类型
或表达式
.
例如:
sizeof(int)
在32位机器下的结果为4
—大多数情况下int占用4字节
sizeof(char)
的结果为1
—char类型占用1字节
若有char a=2;
那么sizeof(a)
的结果为1—char类型的变量占用1字节
之所以说sizeof不是函数,是因为我们可以这样写:
若有char a=2;
那么可以写sizeof a
,省略了小括号()
但是需要注意,sizeof作用于类型的时候,必须加上小括号()
:
上图可以看出编译器报错了.
注意,sizeof运算符返回的结果并不是int
,而是size_t
:
我们只要记住他是一个无符号的整数即可,而且通常printf时最好使用%llu
来输出:
上图为CLion的截图,CLion对这里的%d做出了警告,当然,因为sizeof(int)和sizeof(char)的值太小了,实际上用%d也无妨,但是,最好还是规范代码.
逗号运算符: ,
逗号运算符,
用于将多个表达式连接起来,构成一个更大的表达式—可以叫它逗号表达式
.
需要注意的两点是,逗号运算符是全局优先级最低的运算符,并且其结合律为从左向右
.
另外重要的一点是,最后一个子表达式的值作为整个逗号表达式的值来返回
.
使用示例1:
我们可以利用逗号运算符将不相关的,功能相似的几步操作放在一起.例如:
#include <stdio.h>
int main() {
int a, b, c;
a = b = c = 4; // 由于各个赋值运算符的优先级相同,且结合律为从右向左,所以先执行c=4,然后b=c=4,最后a=b=4
a++, b++, c++; // 逗号表达式的优先级最低,且结合律为从左向右,所以先执行a++,然后b++,最后c++
printf("%d %d %d\n", a, b, c); // 5 5 5
return 0;
}
上面这段代码举了一个最简单的例子,我们想要把a,c,c的值都自增1,并且互不影响,就可以这么写在用一条语句中,使用逗号运算符进行连接.
使用示例2:
必须指出的是,一定要注意互不影响
这个问题,如果各个表达式的求值之间有影响,那么就需要慎重考虑,甚至运行结果可能不是我们想要的.例如举一个没有什么实际意义的例子:
#include <stdio.h>
int main() {
int a = 3, b = 4;
a = b + 1, printf("%d", a); // 输出结果为5
return 0;
}
尽管这段代码没有什么实际意义,但是足以说明问题.
前面已说明,逗号运算符的优先级全场最低,所以第5行的语句中有两个被逗号运算符连接起来的表达式:
a = b + 1
和printf("%d",a)
第二个表达式调用了printf()函数,它叫做函数调用表达式
,这里的小括号()
前面加上一个函数标识符(中间可能有参数)代表一个函数调用
.
前面同样已说明,逗号运算符的结合性是从左向右,那么我们应该先计算a = b + 1
,让a的值变为5
,然后在调用printf()函数将a的值输出,所以最终的输出结果是5
.
这里同样可以认为对a赋值实际上产生了一个副作用,然后这个副作用影响到了后面的表达式的继续求值—对printf的调用仍然认为是对表达式求值,只是这里的表达式是一个函数调用表达式
.导致输出的a不再是原来的3
.
使用示例3:
如果你还是对这里的副作用的影响没有什么重视的话,下面的代码可能让你重新思考:
#include <stdio.h>
int main() {
int a = 3, b;
b = (++a, a);
printf("%d", b); // 输出结果为4
return 0;
}
我知道很多人可能会骂我,说我用一个很不好的(甚至是极差的)代码作为例子来讲解,但是,为了说明轻视副作用可能导致的危害,我还是要以一些不良的代码作为反面教材.
我们前面的示例1和示例2都在主要关注由逗号运算符连接起来的两个子表达式,在示例3中,我们的代码的关注点是逗号表达式
整体的值!
显然,(++a,a)的两个子表达式之间的副作用有互相影响—即++a执行后,对a的求值结果将会是一个自增后的新值.
前面已经说明:最后一个子表达式的值作为整个逗号表达式的值来返回
.那么,++a,a
这个表达式的最终的值就是最后一个表达式a
的值,由于++a
使a变为4,则表达式a
的值为4
,进而最终赋值给b的值为4
.
所以,最后输出的结果为4
.
这个示例示范了如何求整个逗号表达式的值,并进一步的说明了副作用的问题.如果++a
不是我们的本意,那么就很可能存在一个难以察觉的bug.
我们要万分小心,仅仅是初学到现在,我们就已经遇到了好几种运算符的副作用可能引发的潜在问题!---即使你自己完全没有意识到!
进一步拓展-低级错误引发的bug
注:上面的讲解可能有点牵强,实际上,更常见的出乎我们本意的代码是这样:
将 a == 3
错误地写成 a = 3
,原来的表达式用于检验a的值是否为3—根据实际情况返回1或者0.
但是 a = 3
却是直接将a的值覆盖为3,然后这个表达式返回=
右边的值,也就是3,C语言中,3为非0值,意味着这个表达式的永远为真!这才算得上是一个非常容易犯的低级错误—导致了一个可能很难察觉的bug—也许大多数情况下他本来就是真
,所以短时间很难发觉这个bug!
所以,很多人愿意将上面的表达式这样写: 3 == a
,因为 3 = a
的写法根本无法通过编译!
重点:运算符优先级和求值顺序
我们现在仅讲解了基本运算符,我们拿这些简单的运算符进行举例.
优先级
优先级和数学上运算符优先级的意义是类似的,与数学相似,无论是加减乘除,还是赋值等运算符都有不同的优先级,如果一个表达式有多个运算符,我们首先根据优先级来确定表达式的运算顺序.
考虑下面的代码:
sum = 12.0 + 40.0 * n / part;
假设n的值为2,part的值为4.
这条语句中的赋值运算符右面有加法,乘法和除法运算符,先算哪一个?这里无需废话,和数学一样,先算乘除法,再算加减法,但是这是我们一眼看出来的,如果是C编译器来处理这段代码,则必须有提前规定好的运算顺序,也就是先算乘除法,再算加减法
.
C语言中对此问题有着明确的规定,为每一个运算符都规定了各自的优先级
,优先级高的运算符(乘除法)先执行运算,然后返回的结果再继续和优先级低的运算符(加减法)结合执行运算,这样,上面的代码如何运算就非常明确.
如果两个运算符的优先级相同怎么办?如果他们处理同一个运算符对象,则根据他们在语句中出现的顺序而言,大多数运算符都是从左向右依次运算(=运算符除外)
.
如此,上面的语句是如此执行:
40.0 * n 首先计算*或/,发现他们处理同一个操作数n,则根据从左向右结合的顺序,先计算*,结果是80.0
80.0 / part 然后计算/,结果为40.0
12.0 + 40.0 最终结果为52.0
到目前为止,我们学习过的运算符的优先级:
注意对于C语言而言,符号运算符和加减法运算符是不同的,首先他们的操作数的数量就不同.
求值顺序
为了解决运算顺序,C语言明确规定了运算符的优先级,但是这并没有规定所有的顺序,来看下面的代码:
y = 6 * 12 + 5 * 20;
当运算符共享一个运算对象时,优先级确定了求值顺序,再进一步,如果优先级相同(例如乘除),那么结合性进一步确定求值顺序.
但是,上面这个语句中,有两个乘法运算.显然这两个乘法比加法先进行运算,但是问题来了:这两个乘法先算哪个.
实际上,C语言并未规定
这两个乘法先计算哪一个,这取决于具体实现—意味着不同的电脑(计算机),甚至是一台电脑上不同的编译器运行出来的结果也不相同—有可能先算前者的实现在A硬件上效率更高,在B硬件上反而更适合先算后者.这种未明确规定的行为叫做未定义行为
,这里就是一个关于求值顺序
的未定义行为,他们十分重要!
许多朋友可能认为这并不是一个问题,事实上非常重要,不清晰的代码甚至可能引发严重的问题(我们会在后面介绍到其他运算符后并重新讲解副作用
时进行举例).
不过,就上面的这样代码而言,先算后算并没有影响,因为4个操作数都是常数,也就不存在副作用的影响,最终的结果显然不变.
数据对象,左值,右值
这里简要说明一些概念,具体详细的描述另见标准文档
数据对象
赋值表达式的实际效果是将某个值存储到某个指定的位置上,这一段指定的数据存储空间称之为数据对象
,也许有些朋友了解过面向对象和面向过程的区别,请不要混淆,这里的"对象"指的是操作的焦点.C标准只有在这时才会使用对象
这个术语.
左值
C语言中,所谓左值
就是除类型void之外的任何对象类型,且隐含地指代一个对象的表达式.对左值表达式求值得到对象标识.
左值可用于如下左值语境
:
- 作为取值运算符的操作数(除了指代位域和register的左值)
- 作为前/后自增/减运算符的操作数
- 作为成员访问运算符(.)的左操作数
- 作为赋值及复合赋值运算符的左操作数
满足下面之一的表达式是左值:
- 标识符,含具名函数形参,只要声明为指代对象(而非函数或枚举常量)
- 字符串字面量
- 复合字面量(C99)
- 括号表达式,若去掉括号后为左值(也就是加了括号的左值)
- 成员访问运算符(.)的结果,若其左参数是左值(也就是结构体成员)
- 指针访问运算符(->)的结果(也就是结构体指针指向的结构体的成员)
- 对指向对象指针运用解引用(*)运算符的结果(即指针指向的对象)
- 下标运算符([])的结果(即数组元素)
可修改左值
一个可修改左值
是任何完整的非数组类型的,非const限定的左值表达式(结构体/联合体则没有任何成员为const限定)
右值
C语言的右值
即为非左值对象表达式
,它不指代对象,而是指代一个值,不能对右值取址.
整数,字符,浮点数常量以及所有不返回左值的运算符构成的表达式为非左值对象表达式
.
本章进行了运算符,优先级,求值顺序的讲解.同时在关键的前缀/后缀++或–的讲解中描述了什么是副作用
,由于在许多方面都会有体现,并且碍于目前讲到的知识不足,这里不方便展开讲解,所以在后面的各个知识点的讲解中会穿插进行讲解.
---WAHAHA 2023.10.4
注:文章原文在本人博客https://gngtwhh.github.io/上发布。