C语言教程-9-运算符及其优先级和求值顺序
注意,本章讲解的优先级,求值顺序,副作用极其重要,甚至一些十分熟悉C语言的老手也可能会犯相关的错误!
运算符
什么是运算符
我们在前面讲解过表达式,语句的概念,也讲解并使用了一些基本的运算符,例如四则运算,赋值运算符等.
这一部分我们将会详细扩充一些运算符,并仔细讲解运算符与表达式求值的一些重要细节.
运算符和表达式不可分割,我们再来看一下标准中对表达式的描述:

再看一下Microsoft文档中关于操作数的描述:
也就是说,运算符及其操作数的序列组成一个表达式.单一的运算符无法发挥作用,必须至少有一个对应的操作数才能构成一个表达式.至于操作数的概念很好理解,简单来说就是参与运算的各种值.
下面来详细细分一下C语言中的各种运算符.
基本运算符
我们之前已经大量的使用了这些运算符.
赋值运算符: =
C语言中,一个单独的=并不意味着"等于",这和Visual Baisc等语言并不相同,他是一个赋值运算符.例如:
a = 3;
用于将=右边的值3赋给左边的变量a.也就是说,=左边是一个变量名,右侧是将要赋给该变量的值.
赋值行为是从右向左的.
进一步注意,我们需要区分变量名和变量值的区别,尽管他们的区别可能微乎其微,但是如果我们有:
i=i+1;
显然这个合法的语句在数学上行不通,但是,C语言中,这代表着把变量i的值加上1,然后将新值重新赋值给i变量.
另一方面,类似这样的语句是错误的:
3 = a;
因为3是一个常量,你不能对其进行修改---无论是从语法上还是从逻辑上都不正确.很显然,3就是3,我们当然不能把3"赋值"为4.实际上,我们判断这样的语句是否合法,看的是=左边是否为一个左值,更准确的说,是可修改的左值.
数据对象,左值,右值
这部分可参考C Primer Plus第六版的对应内容!
赋值表达式的实际效果是将某个值存储到某个指定的内存位置上,这一段指定的数据存储空间称之为数据对象,也许有些朋友了解过面向对象和面向过程的区别,请不要混淆,这里的"对象"指的是操作的焦点.C标准只有在这时才会使用对象这个术语.
C语言中,所谓左值就是用于标识特定数据对象的名称或表达式,所以,对象指的是实际的数据存储,而左值实际上是一个用于标识或定位存储位置的标签.例如a=3;中a就是标识了变量a的位置,编译器根据这个标签找到变量a的存储位置,最后把值3存储进去.
此外,上面还提到了表达式,而不仅仅只是一个简单名称,这意味着,只要能够正确标识一个位置,就是一个左值.关于这部分内容,我们需要学习到后面才能知道,例如可以使用数组下标运算符指定数组元素等,我们先不急.
我们还提到了可修改的左值,之所以说这个,是因为C标准中新增了(2023年早就已经不是新增了)const限定符,用const创建的变量的值不可变(我们之前讲解使用常量时提到过).那么,用const修饰过的变量自然不能作为=运算符的左操作数.
而至于a=3;中的3,相应的,是一个右值.右值指的是能赋给可修改左值的量,且本身不是左值.
总的来说,使用一个=运算符需要两个操作数,左侧(左操作数)必须为一个可修改的左值,而右侧(右操作数)既可以是左值也可以是一个右值,当然,一个不可修改的左值也可以作为右操作数.
加法运算符: +
+的使用非常显而易见,但是我们需要注意的是,+的左右两个操作数无论是否为右值,最后加法运算的结果(也就是这个表达式的值)一定是一个右值.
例如:
a+b中,a和b都是左值,但是a+b计算出来的值,也就是表达式a+b是一个右值.
减法运算符: -
同理,它用于减法运算,和+一样,需要两个操作数.
+和-都需要两个操作数,所以他们都是二元运算符.
符号运算符: +和-
这里和加法,减法运算符使用相同的符号,但是一定注意,他们是不同的!
因为我们可以有这样的一个表达式:
+a
或者
-a
这意味这此处的+或-仅仅需要一个操作数,所以他们都是一元运算符,其作用也很简单---取相反数.
不过在过去,+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个操作数都是常数,也就不存在副作用的影响,最终的结果显然不变.
其他算数运算符
学习这节之前,要学会进制转换,并尽量了解原码,补码和反码.
求模运算符: %
%运算符用于求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乘以2k.例如3<<2的结果就是3*22,也就是12
如果不能理解,尝试假设十进制移位,将m进行十进制左移k位相当于乘以10k,例如3<<(base10)2的结果就是3*102,也就是300
关于负数,左移会影响其符号位.
右移运算符: >>
这里要注意,尽量对正数进行右移,而不要对负数进行右移.
原因是:由于整数在计算机中以补码存储,最高位为符号位,那么就会有两种不同的右移---算数右移和逻辑右移.
算数右移:
右移k位时,高位空出来的k位以原操作数的符号位填充,以保持结果的符号不变.
逻辑右移:
右移k位时,高位空出来的k位以0填充.
C语言的实现
C语言中,右移取决于具体实现,尽管大部分实现(编译器)为算数右移,但是不能保证所有的机器/编译器都是这样.
也就是说,C语言中,对于有符号数的右移操作,这是一个未定义行为.我们尽量避免对有符号数(负数)进行右移操作.
移位运算符的问题
关于左移和右移的另一个问题是,如果我们指定移动的位数为负数(例如<< -3),或者大于等于左操作数原本的二进制位数(例如,int为32位,但是我们<< 33)
那么该行为未定义,具体请查阅文档.例如,某些实现中,对int值进行<< -3被处理为<< 32 + (-3)也就是<< 29
逻辑运算符
与&&,或||,非!
注意位运算的&和|是单独的一个&和|,与逻辑运算符没有任何关系
在讲解循环的时候,已经讲解了逻辑运算符,已经基本包含所有问题,同时讲解了短路效应,短路效应可能引发的问题会在后面副作用的讲解中描述.
比较(关系)运算符
==,<=,>=,<,>,!=
同样在前面已经讲解.
需要注意的是,比较运算符常常和逻辑运算符搭配,例如:
if(a >= 0 && a <= 100)
这里仍需要注意优先级的问题,逻辑运算符的优先级整体低于比较运算符(除了非!),所以,先判断a的两个范围,即是否大于等于0和是否小于等于100,最后取并集,也就是是否在0~100内.
实际上也就是if( ( a >= 0 ) && ( a <= 100 ) )
一般情况下,()的优先级全场最高(虽然标准中并未将其定义为运算符,但是一般将其称为"括号运算符"),我们可以使用()来改变优先级.
赋值运算符
不仅仅有=运算符这个最基本的赋值运算符,为了简化代码,C语言还有其他的几种赋值运算符,我们将其称为复合赋值运算符.我们直接来看标准中的描述:
就是这些,写代码时如果有类似的赋值,直接使用复合赋值运算符即可.
自增自减运算符
这两个运算符本来是用于简化形如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
也就是说,前缀和后缀都会将操作数自增,但是这个表达式作为一个整体,返回的值是不一样的.
有的人可能会这么理解:"++a是先自增,再返回值;a++是先返回值,再自增"
从初学的角度和前后缀写法表现出的结果来看,这么理解情有可原,但是!这样的理解绝对错误!
错误的原因在于认为返回值这个操作先于自增运算.
事实上,任意的运算符,都会返回一个值,也就是运算结果,例如1+1返回一个2作为运算结果,而任何运算符,都是要先将其运算彻底完成,最终将特定的某一个值作为这个表达式的值返回.
有关这个问题,在后面的指针自增运算和解引用还会进行讲解!
为了讲明前缀和后缀的运算过程,我们直接写出其(类似,这里相当于模拟了一个函数)等价的代码:
//++a类似等价于下面的几行代码:
a = a + 1;
return a;
//a++类似等价于下面的几行代码:
temp = a;
a = a + 1;
return temp;
我们实际上把++a或a++这两个表达式的值使用return语句来表示,相信各位能够理解我这里的意思,而不是去错误的疯狂思考为什么和return是"等价的".
显然,我们可以看出:
1.对于++a,我们单纯的将其值加1,然后将a返回即可,所以,++a的值为a的新值.
2.对于a++,我们创建了一个副本(也就是temp,由于演示,a不一定是int)来保存a原来的值,然后a的值加1,最后返回temp,也就是a原来的值.此时,a已经是新值了,但是++a的值为a的旧值.
以上拿++来讲解,--是完全同理的.
上面已经讲解的十分明白了,如果各位感兴趣,可以去看《C和指针》这本书中相关的讲解.
尽管++和--的主要目的是将操作数的值加1或减1,但是我个人仍然愿意将其归为这个运算符的副作用,毕竟,其要组成表达式,表达式的值通常是重点关注的对象.但是,巧就巧在这里我们既要关心自增,又要使用其返回的值.
那么,我个人倾向于将++a或--a等视作"有副作用的表达式",也就是说,我更关心这个表达式最终的值,而这几个表达式的主要作用---将a自增或自减1---我认为是副作用,因为对这个表达式进行求值是不可逆的(当然可以再减回去或加回去,你知道我不是这个意思),它们让a的值发生了变化!
这里再次出现了副作用的概念,虽然大部分资料均为有这个名词的描述,可能甚至根本不关心,但是由于初学者的许多代码(无论是他们自己写的,或者是某些烂书/烂资料/烂题中出现的)常常会纠结表达式的副作用及引出的相关一些未定义行为,从而导致理解和使用的错误,本教程要对这个问题进行详细讨论!
其他运算符
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的写法根本无法通过编译!
其他
其他的运算符暂时不予讲解,学了后面的知识才能进行讲解.
例如成员访问运算符等.
本章进行了运算符,优先级,求值顺序的讲解.同时在关键的前缀/后缀++或--的讲解中描述了什么是副作用,由于在许多方面都会有体现,并且碍于目前讲到的知识不足,这里不方便展开讲解,所以在后面的各个知识点的讲解中会穿插进行讲解.
---WAHAHA 2023.10.4
上一篇:C语言教程-8-跳转控制和嵌套
下一篇:C语言教程-10-数组

浙公网安备 33010602011771号