【C语言必学知识点四】操作符

操作符详解

封面

前言

大家好,很高兴又和大家见面了!现在我们以及结束了数组与函数知识板块的学习,今天我们将进入下一个板块——操作符板块的学习,下面开始介绍我们今天的内容吧。

一、操作符分类

  • 算术操作符
  • 移位操作符
  • 位操作符
  • 赋值操作符
  • 单目操作符
  • 关系操作符
  • 逻辑操作符
  • 条件操作符
  • 逗号表达式
  • 下标引用、函数调用和结构体成员

二、算术操作符

1.成员

算术操作符的成员有:

‘+’——算术加,用于计算两数之和;

算术加

从测试中我们可以得知算术加法适用于三种情况:

  • 整数之间的相加;
  • 小数之间的相加;
  • 以及整数和小数之间的相加;

这里大家需要注意的点是以浮点型打印的时候,小数位数可以通过%和f之间的数字来控制,就比如图中,我打印%.1f就是打印一位小数,我打印%.2f就是打印两位小数;

‘-’——算术减,用于计算两数只差;

算术减

从测试中我们可以得知算术减法适用于三种情况:

  • 整数之间的相减;
  • 小数之间的相减;
  • 以及整数和小数之间的相减;

‘*’ ——算术乘,用于计算两数之积;

算术乘

从测试中我们可以得知算术乘法适用于三种情况:

  • 整数之间的相乘;
  • 小数之间的相乘;
  • 以及整数和小数之间的相乘;

‘/’——算术除,用于计算两数之商;

算术除

从测试结果中我们可以得知算术除法适用于三种情况:

  • 整数之间的相除,此时的结果取的是整数部分,
  • 小数之间的相除,此时结果取的是小数部分与整数部分组成的小数;
  • 整数和小数之间的相除,此时结果取得是小数部分与整数部分组成的小数;

‘%’——算术取模,用于计算两数之余。

算术取模

从图中可以看到,取模操作符并不能作用于浮点型,只能进行整型之间的取模;

算术取模2

从测试结果中,我们通过将此次的运算结果与算术除的对比可以发现:

  • 算术除的整数运算返回值为整数部分算术取模的整数运算返回值为余数部分

2.小结

  • 除了%——算术取模操作符之外,其它的几个操作符可以作用于整数和浮点数
  • 对于/——算术除法操作符,如果两个操作数都为整数执行整数除法,而只要有浮点数执行的就是浮点数除法
  • %——算术取模操作符的两个操作数必须为整数返回的是相除之后的余数

三、移位操作符

1.成员

‘<<’——左移操作符,尖尖朝向左边;
‘>>’——右移操作符,尖尖朝向右边;

注:移位操作符只能操作整数 注:移位操作符只能操作整数 注:移位操作符只能操作整数

2.移动内容

移位操作符,这里对我们来说还是比较陌生的,他这个移位是什么发生了移位呢?这个就是我们现在要探讨的问题。我们先来认识一下计算机的单位划分;

2.1 计算机中的单位

计算机的单位有以下几种单位:

bit——比特位,计算机中最小的单位,1个比特位只能存放一个“1”或一个“0”;
byte——字节,1字节=8比特位(1byte=8bits);
kb——千字节,1千字节=1024字节(1kb=1024byte);
mb——兆字节,1兆字节=1024千字节(1mb=1024kb);
gb——千兆字节,1千兆字节=1024兆字节(1gb=1024mb);
tb——万兆字节,1万兆字节=1024千兆字节(1tb=1024gb);
pb——十万兆字节,1十万兆字节=1024万兆字节(1pb=1024tb)

计算机的单位中除了bit、byte之间的转化为8外,其它单位之间的转化都是1024

在数组篇中我们提到过一个操作符——sizeof——计算操作数所占内存空间大小,这个操作符计算出来的数值的单位就是字节。下面我们来看一下常见的数据类型所占空间的大小;

2.2 常见数据类型所占空间大小

这里我们测试的是字符类型、四种整型、两种浮点型以及布尔类型:

数据类型所占空间大小
在测试结果中我们可以看到,int类型所占空间大小为4个字节,转化成比特位也就是32个比特位;
一个int类型能存放的数值是从 − 2 31 ~ 2 31 − 1 -2^{31}~2^{31}-1 2312311那我们用二进制序列表示的话就是:
1111 1111 1111 1111 1111 1111 1111 11110111 1111 1111 1111 1111 1111 1111 1111

(这里中间是没有空格的,我是为了方便大家阅读,所以4个为一组,加空格分开了)

现在我们知道了计算机中的单位和各个常见数据类型所占内存空间的大小,接下来我们就来对移位操作符进行测试;

2.3 移位内容测试

下面我们通过这个代码进行测试:

//移位操作符的移位对象
int main()
{
	int a = 1;
	a << 1;
	printf("a = %d\n", a);
	int b = a << 1;
	printf("b = %d\n", b);
	return 0;
}

在这个代码中,我们定义了一个整型变量a也就是说此时a的值是从 − 2 31 ~ 2 31 − 1 -2^{31}~2^{31}-1 2312311这个范围中的任意一个数;
随后我们将1赋值给了a,也就是说a此时的值为1,对应的二进制序列为0000 0000 0000 0000 0000 0000 0000 0001
之后我们将a进行了左移操作,左移的位数为1,然后打印了a的值;
紧接着我们又定义了一个新的变量b,并将a左移1的值赋值给了b,随后对b的值进行了打印;
接下来我们来看一下运行结果:

移位内容测试
从测试结果中我们可以得到以下信息:

  • a它本身在经过左移后它的值是不变的,也就是说它的二进制序列也不会发生改变;
  • 但是将它左移后的值赋值给b后,我们会发现这个值变成了2,对应的二进制序列为:
    0000 0000 0000 0000 0000 0000 0000 0010

大家现在能看出来什么变化吗?看不出来没关系,接下来我们继续测试:

移位内容测试2
从这次的测试结果中,我们可以得到以下信息:

  • a的值也就是1,经过左移一位后得到的新值为2,也就是1的两倍—— 2 1 2^1 21;
  • b的值也就是2,经过左移两位后得到的新值为8,也就是2的四倍—— 2 2 2^2 22
  • c的值也就是8,经过右移三位后得到的新值为1,也就是8的八分之一—— ( 1 / 2 ) 3 (1/2)^3 (1/2)3

从这些信息我们不难得到:

  • 进行左移n位操作时,操作数会增大 2 n 2^n 2n
  • 进行右移n位操作时,操作数会缩小 ( 1 / 2 ) n (1/2)^n (1/2)n

那现在是不是说明左移右移的操作对象为数值呢?我们先不着急下结论,下面我们来看看这些数值的二进制位:

//数值对应二进制位
1——0000 0000 0000 0000 0000 0000 0000 0001
2——0000 0000 0000 0000 0000 0000 0000 0010
8——0000 0000 0000 0000 0000 0000 0000 1000
1——0000 0000 0000 0000 0000 0000 0000 0001

我们现在再来分析一下:

  • 当1左移一位时,结果变成了2,我们可以看到对应的二进制位1的位置也发生了变化,向左移动了一位;
  • 当2左移两位时,结果变成了8,我们可以看到对应的二进制位1的位置也发生了变化,向左移动了两位;
  • 当8右移三位时,结果变成了1,我们可以看到对应的二进制位1的位置也发生了变化,向右移动了三位;

现在我们就很明确了,我们移动的数值与二进制位移动的数值是一一对应的,那是不是就说明移位操作符移动的其实是二进制位呢?下面我们来做个测试,将数值3分别移动1位、2位、3位,如果移动的是二进制位,那移动后的二进制位应该如下所示:

3——0000 0000 0000 0000 0000 0000 0000 0011
//3<<1
0000 0000 0000 0000 0000 0000 0000 0110——6
//3<<2
0000 0000 0000 0000 0000 0000 0000 1100——12
//3<<3
0000 0000 0000 0000 0000 0000 0001 1000——24

接下来我们通过代码来验证一下,如果结果与我们现在的猜想对的上,那就说明我们的想法是正确的的:

移位对象验证
从结果中可以看到,正如我们所想的这样,现在我们就能得到以下结论:

  • 移位操作符移动的是二进制位
  • 进行移位操作的对象本身不会被移位操作符改变,该对象在进行移位操作后会产生一个新的值;
  • 进行左移n位操作时,产生的新值为原先值的 2 n 2^n 2n倍;
  • 进行右移n位操作时,产生的新值为原先值的 ( 1 / 2 ) n (1/2)^n (1/2)n

现在我们已经明确了移位操作符的操作内容了,在进一步探讨移位操作符前,我们还需先了解一下原码、反码和补码的知识点;

3.原码、反码、补码

整数的二进制有三种表示形式:原码、反码、补码

3.1 原码

用机器数(二进制位)的最高位表示数的符号(正数符号位为0,负数符号位为1),其余各位表示数的绝对值

3.2 反码与补码

对于有符号数值来说,正整数与负整数的反码和补码是不相同的:

  • 正整数

原码=反码=补码=实际值

  • 负整数

原码数值部分=实际值
反码是除符号位外,原码的数值位按位取反
补码是除符号位外,原码的数值位按位取反后加1

下面那3于-3来举例,它们的原码、反码、补码如图所示:

原码、反码、补码
了解完原码、反码和补码后,现在我们就要开始介绍移位操作符的移位方式了;
对于移位操作符来说,它的移位方式有两种——算术移位和逻辑移位。下面我们就来详细介绍一下这两种移位方式;

4.算术移位

算术移位的对象是有符号数,在移位的过程中符号位保持不变

4.1 正整数移位

正整数的原码=反码=补码,所以在进行移位后移出的部分舍弃空余的部分补0

正整数移位

4.2 负整数移位

原码移位:负整数的原码数值部分与实际值相同,故在移位时只要使符号位不变,移出部分舍弃,空位补0

负整数原码移位

反码移位:负数的反码除符号位外,其余各位与原码相反,故移位时只要使符号位不变,空位与原码相反,即空位补1
负整数反码移位

补码移位:补码是由反码加1,当我们从补码的最低位向最高位找到第一个1时,在此1的左边的各位均与反码相同,而在此1的右边各位包括此1在内均与对应的原码相同。
故当负数的补码左移时,因空位出现在低位,也就是1的右边,所以补位的代码与原码相同,即空位补0
负数的补码右移时,因空位出现在高位,也就是1的左边,所以补位的代码与反码相同,即空位补1

负整数补码移位

5.逻辑移位

逻辑移位将操作数视为无符号数
移位规则逻辑左移时,高位移动完舍弃,低位补0逻辑右移时,低位移动完舍弃,高位补0

逻辑移位

6.整数的存储

一个整数不管是正整数还是负整数,它存放在计算机内存中都是以二进制补码的形式进行存放的。

7.移位方式的测试

在了解完上述内容后下面我们来对这些移位方式分别测试一下:

移位方式测试
从测试结果中我们可以得到以下信息;

  • 不管是逻辑左移还是算术左移,移动后的值都相同;
  • 但是在右移操作中,逻辑右移与算术右移的结果相差甚远。

下面我来测试一下如果我们移动负数位又会是什么结果:

移位方式测试
从结果中我们可以看到,不管是算术移位还是逻辑移位,系统都会报出警告计数为负,其行为未定义。

8.小结

经过上述的介绍与测试,我们可以对左移、右移操作符做一个总结:

  • 正整数移动:

正整数的移位规则为,二进制序列移动,空位补0;

  • 负整数的移动:

左移操作符的移位规则为,二进制序列往左移动空位补0
右移操作符在逻辑右移时,二进制序列往右移动,空位补0
右移操作符在算术右移时,二进制序列往右移动,空位补1

警告:对于移位运算符,不要移动负数位,这个是标准未定义的。 警告:对于移位运算符,不要移动负数位,这个是标准未定义的。 警告:对于移位运算符,不要移动负数位,这个是标准未定义的。

四、位操作符

1.成员

‘&’——按位与
‘|’——按位或
‘^’——按位异或

2.操作内容

位操作符操作内容与移位操作符相同,也是二进制位。下面我们就来介绍它的运算规则;

3.运算规则

‘&’——按位与操作符

  • 当两个数的二进制位都为1时,结果为1,否则为0;

按位与

从测试结果中我们可以看到,当两个数对应的二进制位都为1时,结果才为1,只要有对应的二进制位为0,则结果为0;

‘|’——按位或操作符

  • 当两个数的二进制位有1时,结果为1,否则为0;

按位或

从结果中我们可以看到,当两个数对应的二进制位只要有1,结果就为1,如果对应的二进制位都为0,结果才为0;

‘^’——按位异或操作符

  • 当两个数的二进制位不同时,结果为1,否则为0;

按位异或
从结果中我们可以看到,当两个数对应的二进制位不相同时,即一个为1,另一个为0,此时结果为1,如果同为1或者同为0,则结果为0;

4.小结

  • 位操作符的操作内容为操作数的二进制位;
  • &——按位与操作符:当两个数对应的二进制位同为1时,结果为1,否则为0;
  • |——按位或操作符:当两个数对应的二进制位有1时,结果为1,否则为0;
  • ^——按位异或操作符:当两个数对应的二进制位不同时,结果为1,否则为0;

五、赋值操作符

1.成员

  • 正常赋值操作符

‘=’——赋值操作符,将操作符右表达式的值赋值给可修改的左值;

  • 自算术赋值操作符:

‘-=’——复合算术减赋值操作符,给操作对象赋值自减后的值,如​​a = a - 1​​​可以写成​​a -= 1​​;
‘*=’——复合算术乘赋值操作符,给操作对象赋值自乘后的值,如​​a = a * 1​​​可以写成​​a *= 1​​;
‘/=’——复合算术除赋值操作符,给操作对象赋值自除后的值,如​​a = a / 1​​​可以写成​​a /= 1​​;
‘%=’——复合算术取模赋值操作符,给操作对象赋值自取模后的值,如​​a = a % 1​​​可以写成​​a %= 1​​;

  • 自移位赋值操作符

‘>>=’——复合右移赋值操作符,给操作对象赋值自右移后的值,如​​a = a >> 1​​​可以写成​​a >>= 1​​;
‘<<=’——复合左移赋值操作符,给操作对象赋值自左移后的值,如​​a = a << 1​​​可以写成​​a <<= 1​​;

  • 自位运算赋值操作符

‘&=’——复合按位与赋值操作符,给操作对象赋值自按位与后的值,如​​a = a & 1​​​可以写成​​a &= 1​​;
‘|=’——复合按位或赋值操作符,给操作对象赋值自按位或后的值,如​​a = a | 1​​​可以写成​​a |= 1​​;
‘^=’——复合按位异或赋值操作符,给操作对象赋值自按位异或后的值,如​​a = a ^ 1​​​可以写成​​a ^= 1​​;

2.赋值逻辑

赋值操作符有两个操作数,一个是可修改的左值和一个表达式右值,这里的表达式可以是常量、常量表达式、变量、变量表达式、变量与常量运算表达式如下所示:
赋值操作符
如果我们将赋值操作符左边的操作数换成常量的话,此时的程序就无法运行,如下所示:
赋值操作符左操作数
从错误列表中我们可以看到此时的报错内容就是表达式必须是可修改的左值,也就是赋值操作符的左操作数必须是可修改的对象才行,这个对象可以是变量、可以是数组元素、可以是指针,还可以是结构体成员;

3.自赋值操作符

自赋值操作符顾名思义就是自己给自己赋值,从前面的赋值操作符成员中我们知道这些自赋值操作符可以是自己给自己进行算术运算赋值,可以是移位运算赋值,还可以是位运算赋值。这里我们拿最简单的算术运算赋值来说明:

自赋值操作符

从这个例子中我们可以看到,这里的变量a通过自加赋值得到的值其实与b = b +12;得到的值是一样的。
这类自赋值运算其实就是赋值运算的一种简写,只不过此时赋值操作符的左右操作对象中都有同一个变量,但是并不是说只要有同一个变量就能像这样简写:

//自赋值操作符
int main()
{
	int a = 5;
	a = (a + 2) + a * 2 + (a - 2);
	printf("a = %d\n", a);
	return 0;
}

对于这个代码我们要进行计算的话应该是先计算a + 2 = 5 + 2 = 7; 之后再计算a - 2 = 5 - 2 = 3;最后计算a * 2 = 5 * 2 = 10;这个代码的结果应该是a = 7 + 10 + 3 = 20;

自赋值操作符2
在这种情况下,我就不能对右侧所有的对象进行简写,也就是我不能像这样a +*-= 2;C语言中没有这种写法;
但是我可以写成a += 2 + a * 2 + (a - 2);这种写法也是正确的。我们现在验算一下:

自赋值操作符3
但是有一个点需要注意,在这个式子中,我们只能这么简写成自赋值格式,有朋友可以就会说,我为什么不能把乘给提出来,下面我们来看一下运算结果:

自赋值操作符4
可以看到这里的结果与我们前面计算的结果是完全不符的,造成这个结果的原因是因为此时的运算逻辑发生了改变。

  • 这种写法的运算逻辑为:a = a * (2 + 5 + 2 + 5 - 2) = 5 * 12 = 60; 这个逻辑的意思就是变量a是与右操作数这个表达式的整体进行自乘然后再赋值给自己;

同理,这里的格式也不能写成a -= 2 + (a + 2) + a * 2; 此时的逻辑是变量a与右操作数这个整体进行自减,然后再赋值给自己也就是a = a -(2 + 5 +2 + 5 * 2) = 5 - 19 = -14;我们可以来验证一下:

自赋值操作符5
现在我们从这些测试结果中就能得到结论:

  • 自赋值操作符是将左操作数与右操作数这个整体进行运算之后再赋值给自己;

所以希望大家在使用自赋值操作符时一定要先判断此时的自赋值对象是不是需要与右操作数这个整体进行运算;
最后我们对赋值操作符做个小结;

4.小结

  • 赋值操作符是将右操作数赋值给左操作数;
  • 赋值操作符的左操作数必须是可修改的值;
  • 当左操作数需要先与右操作数这个整体进行运算,然后在赋值给自己时根据运算的方式简写成对应的自赋值操作符;
  • 自赋值操作符的书写格式为:运算方式 + 赋值操作符--> +=、-=、*=、/=……

六、单目操作符

在前面的介绍中我们可以看到每一个操作符的操作对象都是有左操作数和右操作数两个操作对象,所以它们又可以称为双目操作符。现在我们要介绍的单目操作符就是只有一个操作对象的操作符,下面我们就来看看都有哪些操作符;

1.成员

‘!’——逻辑反操作;
‘-’——负值;
‘+’——正值;
‘&’——取地址;
‘sizeof’——计算操作对象所占空间大小(以字节为单位);
‘~’——对一个数的二进制按位取反;
‘–’——前置、后置–;
‘++’——前置、后置++;
‘*’——间接访问操作符(解引用操作符);
‘(类型)’——强制类型转换;

看到这些成员,有朋友就会发现,前面我们不是介绍了‘+’‘-’‘&’‘*’这些操作符了吗?怎么现在又有它们了?

这里就是我要给大家说明的第一个点,有些操作符根据操作对象的数量不同,它们会起到不同的作用,这里我给这类操作符称为多种含义的操作符,也就是刚刚提到的这四种操作符。下面我们就来详细介绍一下这类操作符

2.多种含义的操作符——‘+’、‘-’、‘*’、‘&’

  • 作为双目操作符时,它们分别代表算术加、算术减、算术乘、按位与。它们的作用如下:

‘+’——将两个操作对象进行算术加,详细介绍请回看算术操作符部分;
‘-’——将两个操作对象进行算术减,详细介绍请回看算术操作符部分;
‘*’——将两个操作对象进行算术乘,详细介绍请回看算术操作符部分;
‘&’——将两个对象对应的二进制位进行按位与运算,详细介绍请回看位操作符部分;

  • 作为单目操作符时,它们分别代表负值、正值、解引用操作符、取地址操作符。作用分别是:

‘-’——负值,取操作对象的相反数;
‘+’——正值,取操作对象本身,一般会省略;
‘*’——解引用操作符,常用于指针,将指针进行解引用操作后,可以取出存放在地址中内容;
‘&’——取地址,将操作对象在内存中存储的地址提取出来,常用在指针中,将提取出来的地址存放进指针;

它们在作为单目操作符时又是如何使用的呢?现在我们就来介绍一下;

2.1 正值与负值——‘+’、‘-’

对于‘+’‘-’这两个操作符来说,它们作为单目操作符时的意义与数学的正负号是相同的,‘+’代表的是正号,‘-’代表负号,下面我们通过一个例子来说明这两个操作符:

正值与负值

从结果中我们可以看到,当给变量加上‘+’之后的打印结果就是变量本身的值,当给变量加上‘-’之后的打印结果是变量自身的相反数,这也符合数学中正负号的运算规则:
正正得正,正负得负,负负得正,负正得负 正正得正,正负得负,负负得正,负正得负 正正得正,正负得负,负负得正,负正得负
所以对于‘+’‘-’来说,它们的用法很简单:

  • 两个操作数时,就是算术加法和算术减法;
  • 一个操作数时,就是正号和负号;

在多种含义的操作符中取地址与解引用是我们要介绍的重点对象,因为我们在之后指针篇章的学习中,会经常遇到它们两个。
在数组篇章的介绍中,我们简单介绍了一下什么是地址:
内存被划分成了一个个小的内存单元,每个内存单元都有它相应的编号,这些编号我们称为内存单元的地址
现在我们要弄清楚的就是它这个地址是怎么来的;

2.2 地址的产生

在前面的学习中我们知道数据在内存中的存储是以二进制的形式进行存储的,所谓的二进制就是0/1,所以我们看到的二进制序列都是有0和1组成的一串数字,这里的0和1其实代表的是计算机中的电信号,0代表的是负电也即是低点位,1代表的是正电也就是高电位。

1946年世界上的第一台电子数字计算机(Electroninc Numerical Integrator And Coputer, ENIAC)问世了。

ENIAC 所采用的逻辑元件就是电子管,当时没有任何的编程语言,科学家们只能通过计算机能识别的电信号来进行编程,为了方便识别这些电信号,规定将正电视为1,负电视为0,于是就有了最开始的计算机语言——二进制。

在前面我们介绍过计算机中的单位:

bit——比特位,计算机中最小的单位,1个比特位只能存放一个“1”或一个“0”;

也就是说一个二进制位就是一个比特位。

随着时代的发展,计算机的逻辑元件经过不断的更新,最终变成了我们现在使用的微处理器——Intel 80386(32位)Pentium 4(64位)Core i7(64位)等。
这里的32位、64位指的是机器字长,也就是计算机进行一次整数运算能处理的二进制数据的位数。

这里我们可以简单的理解为,在32位系统中也就是32个比特位,这些比特位是从 2 0 到 2 31 2^0到2^{31} 20231,对应的二进制序列如下所示:

//地址的产生
2^0——0000 0000 0000 0000 0000 0000 0000 0001
2^1——0000 0000 0000 0000 0000 0000 0000 0010
2^2——0000 0000 0000 0000 0000 0000 0000 0100
2^3——0000 0000 0000 0000 0000 0000 0000 1000
……
2^28——0001 0000 0000 0000 0000 0000 0000 0000
2^29——0010 0000 0000 0000 0000 0000 0000 0000
2^30——0100 0000 0000 0000 0000 0000 0000 0000
2^31——1000 0000 0000 0000 0000 0000 0000 0000

这些比特位能存储的数值从 − 2 31 — 2 31 − 1 -2^{31}—2^{31}-1 2312311,每一个数值所对应的二进制序列就代表一个地址;

同理,在64位系统中则是64个比特位,从 2 0 到 2 63 2^0到2^{63} 20263,能存储的数值从 − 2 63 — 2 63 − 1 -2^{63}—2^{63}-1 2632631,每一个数值所对应的二进制序列就代表一个地址。

2.3 地址的作用

在【函数栈帧的创建与销毁】篇章中我们有提到过:

  • 寄存器的功能是存储二进制代码
  • 内存最基本的组成是由 MAR 、存储体和 MDR 组成
  • MAR存放的是地址信息、MDR暂存的是从存储器中读或写的数据信息;
  • 主存储器(内存)的工作方式是按存储单元的地址进行存取;

也就是说,地址的作用就是用来帮助内存进行数据的存取的。

下面大家可以思考一个问题,既然内存可以通过地址进行存取数据,那我们可不可以通过地址来存取数据呢?

答案是肯定的,程序猿需要通过地址来对数据进行存取时需要用到的操作符就是我们这里要介绍的——取地址‘&’和解引用‘*’操作符。

2.4 取地址与解引用操作符——‘*’、‘&’

取地址——顾名思义就是提取地址的意思,将操作对象的地址提取出来;
解引用——就是将地址中存储的数据给提取出来;

可以看到,这两兄弟有点意思,都是提取,但是一个提取的是地址一个提取的是数据

我们最开始接触取地址操作符时,是在第一次使用scanf函数时。下面我们就来介绍一下为什么这里要使用取地址操作符‘&’

在【函数栈帧的创建与销毁】的篇章中我们有对传值传参进行过介绍;

函数在传值传参时是先在函数栈帧的栈顶申请一块空间并通过数据寄存器eax和ecx来将参数从右往左依次进行压栈操作,这一过程就相当于是通过数据寄存器将函数实参的数据进行了拷贝,并将拷贝的内容存放进这块新的空间内。

现在我们来看一下如果是传址传参,计算机又是如何工作的:

//多种含义的操作符
//&——取地址操作符
void Change(int* x, int* y)
{
	int z = *x;
	*x = *y;
	*y = z;
}
//交换两个整型变量的值
int main()
{
	int a = 2;
	int b = 3;
	printf("交换前:a = %d, b = %d\n", a, b);
	Change(&a, &b);
	printf("交换后:a = %d, b = %d\n", a, b);
	return 0;
}

这里我们的步骤还是一样:
F 10 — > 调试— > 单击鼠标右键— > 转到反汇编 F10—>调试—>单击鼠标右键—>转到反汇编 F10—>调试>单击鼠标右键>转到反汇编
传址传参的过程
我们现在要重点关注的是Change函数的调用过程;

  • 原代码中我们使用的是取地址操作符‘&’,将实参a和b的地址传参给函数Change;
  • 程序接收到取地址的指令后,通过lea指令将实参b和a的地址分别拷贝到eax和ecx这两个寄存器中;
  • eax和ecx这两个寄存器再通过压栈操作依次压入main函数的函数栈顶;

传址传参过程

如果有朋友看到这两张图感觉比较陌生的话,建议抽空回看一下【函数栈帧的创建与销毁】在这个篇章内我有通过图文的方式将这里的每一步指令和对应的函数栈帧的图像都有详细介绍到。

下面我们来看一下Change函数的调用过程:

函数调用过程
我们要重点关注的过程是形参的调用过程,为了更好的给大家介绍这一过程,接下来我们将对这一过程进行一步一步的分析:

	z = *x;
003618FC  mov         eax,dword ptr [ebp+8]  
003618FF  mov         ecx,dword ptr [eax]  
00361901  mov         dword ptr [ebp-8],ecx  

在这个过程中原代码通过解引用操作符‘*’将存储在形参x地址中的内容给提取出来然后赋值给变量z,程序在接收到这一指令后,通过三个mov指令实现了解引用与赋值的过程。下面我们通过图来理解这一过程:

解引用

  • 第一个mov——将地址ebp+8中存储的值赋值给eax。

注意:此时地址ebp+8中存储的值是一个地址,所以eax接收到的同样也是地址,但是eax通过这个地址找到了变量a,也就是代表着此时的eax就是变量a;

  • 第二个mov——将eax的值赋值给ecx,这一过程就是解引用的过程,将地址中存放的值取出来并赋值给ecx。

注意:此时的eax代表的就是变量a,所以这一步就是将变量a存储的值取出来赋值给ecx,从图中我们可以看到经过这一步赋值后,ecx存储的值是变量a的值也就是2;

  • 第三个mov——将ecx的值赋值给地址ebp-8,这一过程就是正常的赋值过程。

接下来我们来看看第二步的两次解引用操作:

	*x = *y;
00361904  mov         eax,dword ptr [ebp+8]  
00361907  mov         ecx,dword ptr [ebp+0Ch]  
0036190A  mov         edx,dword ptr [ecx]  
0036190C  mov         dword ptr [eax],edx 

有了上一步的解引用操作,这一步理解起来就容易多了,这一过程通过四个mov完成了两次解引用和一次赋值的过程,我们同样还是通过图像来进行理解:

解引用2

  • 第一个mov——将地址ebp+8中存储的值赋值给eax。

注意:此时地址ebp+8中存储的值是一个地址,所以eax接收到的同样也是地址,但是eax通过这个地址找到了变量a,也就是代表着此时的eax就是变量a;

  • 第二个mov——将地址ebp+0Ch中存储的值赋值给ecx。

注意:此时地址ebp+0Ch中存储的值是一个地址,所以ecx接收到的同样也是地址,但是ecx通过这个地址找到了变量b,也就是代表着此时的ecx就是变量b;

  • 第三个mov——将ecx的值赋值给edx,这一过程就是第一次解引用,将地址中的值取出来存放进edx中。

注意:此时的ecx代表的就是变量b,所以这一步就是将变量b存储的值取出来赋值给edx,从图中我们可以看到经过这一步赋值后,edx存储的值是变量b的值也就是3;

  • 第四个mov——将edx的值赋值给eax的值中,这一过程就是第二次解引用,将edx中的值赋值给eax这个地址中。

注意:此时的eax代表的就是变量a,所以这一步就是将edx存储的值赋值给变量a,从图中我们可以看到经过这一步赋值后,变量a存储的值变成了3;

现在大家对取地址和解引用应该有一点感觉了吧,接下来我们来看最后一步:

	*y = z;
0036190E  mov         eax,dword ptr [ebp+0Ch]  
00361911  mov         ecx,dword ptr [ebp-8]  
00361914  mov         dword ptr [eax],ecx 

有了前面两步的介绍,这一步我们就直接进行分析了,这里和第一步一样通过三个mov完成了一次解引用和一次赋值操作,每一步解引用的作用如下:

  • 第一个mov——将地址ebp+0Ch中存储的值赋值给eax。

注意:此时地址ebp+0Ch中存储的值是一个地址,所以eax接收到的同样也是地址,但是eax通过这个地址找到了变量b,也就是代表着此时的eax就是变量b;

  • 第二个mov——将地址ebp-8中存储的值赋值个ecx。

注意:此时地址ebp-8是变量z的地址,变量z中存储的值是2,所以ecx接收到的值就是2;

  • 第三个mov——将ecx值赋值给eax的值中,这一过程就是解引用的过程,将ecx的值赋值给eax这个地址中。

此时的eax代表的就是变量b,所以此时就是将ecx存储的值2赋值给变量b,经过赋值后,变量b的值会变成2。

解引用3
经过上面的步骤,就成功完成了变量a和变量b中的值的交换。从这一过程中我们可以看到取地址操作符就相当于是一个门牌号,而解引用操作符就是门的钥匙,我们可以通过门牌号找到对应的房门,再通过钥匙打开房门对房间内的事物进行修改。
现在我们就来对多种含义的操作符做一个小结;

2.5 小结

  • ‘+’‘-’有两个操作数时,就是算术加法和算术减法;
  • ‘+’‘-’只有一个操作数时,就是正号和负号,满足正正得正,正负得负,负负得正,负正得负的运算规则;
  • 取地址操作符的作用就是帮助计算机通过地址找到对应的操作对象
  • 解引用操作符的作用就是帮助计算机直接对操作对象的值进行修改
  • 在函数传址传参中,解引用的形参就是对应的实参,我们能够通过改变形参来改变实参

取地址操作符和解引用操作符我就先介绍到这里了,接下来我们来介绍一下只有一种含义的单目操作符;

3.一种含义的单目操作符

3.1 ‘!’——逻辑反操作

逻辑反操作简单的理解就是真的变成假的,假的变成真的。常用于条件语句中:

逻辑反操作

在C语言中规定:

0为假,非0为真
真值为1,假值为0

从测试结果中我们可以看到不管是正数还是负数经过逻辑反操作后得到的值都为0,也就是假,对于分支语句和循环语句的来说,

  • 当条件语句为假时,不进入对应的语句块中,所以当我们输入非零的数时都只能将逻辑反操作后的a值打印出来;
  • 当输入0时,a经过逻辑反操作后变成真,其值为1,这时就能进入条件语句的语句块中打印“hehe”,此时也能进入循环语句的语句块中打印5次“hello”;

3.2 ‘sizeof’——计算操作对象所占空间大小(以字节为单位)

sizeof这个操作符对咱们来说并不陌生了,它的作用是操作数所占内存空间的大小。

这里的操作数可以是变量、指针变量也可以是数据类型还可以是数组名;

3.2.1 sizeof的用法

下面我们通过代码来进一步了解它的用法:
sizeof的用法

通过图中的结果可知,sizeof有以下几种使用方式:

  • sizeof可以计算变量、指针变量、数组以及数据类型所占空间大小;
  • sizeof计算变量、指针变量所占空间大小时,可以省略括号;
  • sizeof在计算数组时,可以通过数组名来计算,也可以通过数组的数据类型来计算,前者可以省略括号,后者不能省略;
  • 数组的空间大小 = 数组类型 * 数组大小;
  • 指针所占空间大小是一个定值,不会根据类型的不同而改变大小。

在这些使用方式中,我们可以看到此时sizeof的操作对象都是单一的对象,下面我们来看一个代码:

sizeof计算表达式

在这个代码中我们在定义完a、b两个局部变量后,将b+5的值赋值给了a,并通过sizeof计算了a的所占空间大小,此时是能够正常运行的,如果此时我们将a = b + 5;这个表达式放到sizeof的括号中,又会是怎样的结果呢?

3.2.2 sizeof计算表达式

下面我们就进行实操测试一下,代码如下:

//sizeof——计算操作对象所占空间大小
int main()
{
	short a = 0;
	int b = 10;
	printf("%d\n", sizeof(a = b + 5));
	printf("%d\n", a);
	return 0;
 }

现在sizeof的操作对象变成了一个表达式,我们来运行一下看看结果会不会发生变化:

sizeof计算表达式2

从结果中我们可以看到,此时是能够正常计算变量a的数据类型,但是赋值操作并没有执行,也就是说当sizeof的操作对象为一个表达式时,表达式并不会参与运算。

3.2.3 小结

介绍到这里,我们来对sizeof的用法做一个总结:

  1. sizeof可以计算变量、指针变量、数据类型、数组所占内存空间大小;
  2. 当sizeof的操作对象为表达式时,表达式不参与运算;
  3. 当sizeof的操作对象为变量、指针变量、数组名时,括号可以省略;

到这里sizeof的相关内容咱们就全部介绍完了,大家可以好好消化一下相关知识点。接下来我们继续介绍其它的单目操作符;

3.3 ‘~’——对一个数的二进制按位取反

经过前面对移位操作符和位操作符的介绍,想必大家对二进制位已经不陌生了。现在我们介绍的这个操作符它的操作对象也是操作数的二进制位,它这里的按位取反的意思就是对一个数的二进制位从1变成0,从0变成1,。下面我们来看一下按位取反操作符是如何运行的:

//单目操作符
//按位取反
int main()
{
	int a = 5;
	//补码——0000 0000 0000 0000 0000 0000 0000 0101
	// 按位取反
	//补码——1111 1111 1111 1111 1111 1111 1111 1010
	//反码——1111 1111 1111 1111 1111 1111 1111 1001
	//原码——1000 0000 0000 0000 0000 0000 0000 0110——-6
	printf("%d\n", ~a);
	return 0;
}

通过对二进制位的一系列转换,我们笔算得到了答案为-6,下面我们就来运行一下,验证我们的答案:

按位取反

可以看到运行结果的确如此,我们推算的过程来看,按位取反是符号位和数值位都进行取反,而我们从原码得到反码时是符号位不变,数值位按位取反,这个还是有些区别的,大家别弄混咯!

3.4 前置–、后置–与前置++、后置++

3.4.1 ++、–的作用

首先我们要知道这个++、–都是什么意思,下面我们来编码测试一下:

++、--的作用

从测试结果中我们可以看到,不管是前置还是后置,++的作用都是给操作对象+1,而–的作用都是给操作对象-1,所以++、–又叫做自增1与自减1;

3.4.2 前置、后置的区别

下面我们来看一下这个前置和后置究竟有什么不同:

//单目操作符
int main()
{
	int a = 2;
	//前置++
	printf("%d\n", ++a);
	printf("%d\n", a);
	//后置++
	printf("%d\n", a++);
	printf("%d\n", a);
	//前置--
	printf("%d\n", --a);
	printf("%d\n", a);
	//后置--
	printf("%d\n", a--);
	printf("%d\n", a);
	return 0;
 }

各位朋友你们在看结果之前,可以先自己将这个代码的运行结果写出来,然后再来对照结果看一下,结果跟你想的会不会有区别:

前置与后置

从结果中我们看到了,在操作符前置的时候,计算机是先完成的自增和自减的操作指令,所以在前置的结果中两次的打印结果相同;
但是在操作符后置的时候,计算机是先打印的操作对象,再对操作对象进行的++和–的操作指令,所以在后置的结果中,两次的打印结果不同;
由此我们可以得到结论:

  • '++‘与’–'操作符在前置时,计算机会先对操作对象进行自增与自减,再使用操作对象;
  • '++‘与’–'操作符在后置时,计算机会先使用操作对象,再对操作对象进行自增与自减;

这两个操作符我们只需要掌握它们在前置和后置的区别就行,下面我们来看看最后一个单目操作符;

3.5 ‘(类型)’——强制类型转换

强制类型转换字面意思理解就是强制性的将操作对象的类型进行转换。下面我们通过代码来认识一下这个操作符:

//单目操作符
int main()
{
	short a = 5;
	printf("%d\n", a);
	printf("%d\n", sizeof a);
	//强制类型转换
	printf("%.2f\n", (float)a);
	printf("%d\n", sizeof((float)a));
	return 0;
 }

大家可以尝试着先手写一下这个代码的运行结果,然后再来对照运行结果看看答案和自己的是不是能对上,通过这个方法来加深对这个操作符的理解:

强制类型转换

从结果中我们可以看到,通过强制类型转换我们将short类型的变量转变成了float类型,并且变量所占空间大小也变成了强制转换后的类型所占空间大小。

这个操作符我们目前见到的还不多,不知道大家对前面的游戏编写还有没有印象,我们在使用srand函数时就使用过这个操作符。
因为srand的参数是无符号整型的,我们在设置随机数起点时要先将有符号长整型的time进行强制类型转换成无符号整型才能正常使用,所以设置随机数起点的代码为​​srand((unsigned int)time(NULL))​​。

单目操作符的内容到这里咱们就全部介绍完了,接下来我们来看一下关系操作符;

七、关系操作符

1.成员

‘>’——大于操作符,用来比较两个操作对象的大小;
‘>=’——大于等于操作符,用来比较两个操作对象的大小;
‘<’——小于操作符,用来比较两个操作对象的大小;
‘<=’——小于等于操作符,用来比较两个操作对象的大小;
‘!=’——不等于操作符,用来判断两个操作对象不相等;
‘==’——恒等于操作符,用来判断两个操作对象相等;

2.用法介绍

这六种关系操作符理解起来还是比较简单的,它的用法与数学中的用法差不多,主要是用来判断两个操作对象的大小关系的。
使用关系操作符的关系表达式的结果只有0和1这两个结果,对应的情景如下:

  1. 当关系不成立时,关系表达式的结果为0;
  2. 当关系成立时,关系表达式的结果为1;

接下来我们来测试一下:

关系表达式的结果
从测试结果中可以看到,对于第一个关系表达式2 > 3 显然不成立,所以打印出来的值为0;
对于第二个表达式2 > 1 显然成立,所以打印出来的值为1;

根据关系表达式的结果,我们运用最多的地方就是在分支语句和循环语句中,通过判断两个操作对象的关系来决定接下来的程序执行内容;

  • 当关系表达式结果为0时,不执行分支语句与循环语句代码块的内容;
  • 当关系表达式结果为1时,执行分支语句与循环语句代码块的内容;

下面我们借助代码来进一步说明:

关系操作符

如上图所示,在这个例子中,代码运行的运行逻辑如下:
第一次循环:

在循环判断中,a=1,满足a>=0这个条件,关系表达式结果为真,进入循环语句;
在分支判断中,a=1,满足a==1这个条件,关系表达式结果为真,进入if分支;

第二次循环:

在循环判断中,a=0,满足a>=0这个条件,关系表达式结果为真,进入循环语句;
在分支判断中,a=0,不满足a==1这个条件,关系表达式结果为假,跳过if分支,满足a!=1这个条件,关系表达式结果为真,进入else分支;

第三次循环:

在循环判断中,a=-1,不满足a>=0这个条件,关系表达式结果为假,跳过循环语句;

3.注意事项

在使用关系操作符时,有几个点需要注意:

  1. 关系表达式不能连用

关系操作符是一个双目操作符,它的操作对象只有两个,所以在使用关系操作符时不宜连用;
需要进行多次判断时因借助逻辑操作符&&与||来配合使用;

3 > 2 > 1,在这种连用的情况下,关系操作符的执行顺序是

  • 先判断3 > 2,成立,关系表达式的值为1;
  • 再判断1 > 1,不成立,关系表达式的值为0;

如果我们改写成3 > 2 && 2 > 1,此时执行的逻辑是:

  • 先判断3 > 2成立,关系表达式值为1;
  • 再判断2 > 1成立,关系表达式值为1;
  • 最后运算1 && 1 = 1,表达式的值为1;

下面我们通过代码来验证一下:

关系操作符的错误使用

从测试结果中可以看到,此时代码的运行逻辑正如我们前面分析的一样,当连用时,表达式的结果为0,当用逻辑操作符连接时,表达式结果为1;

  1. 注意区分"==“与”="
  • 两个等号的是关系操作符,用来判断两个操作对象是否相等,表达式结果为0或者1;
  • 一个等号的是赋值操作符,用来将右边的操作对象的值赋值给左边的操作对象,表达式的值为右操作对象的值;

下面我们通过代码来测试一下这两个操作符:

区分==与=

可以看到,两个等号时,是在判断a与b是否相等,3与2不相等,表达式结果为假,值为0;
一个等号是将b的值2赋值给了a,此时打印的值是b的值;

4.小结

  1. 关系操作符可以用来判断操作对象之间的大小关系;
  2. 关系表达式的值为0/1:
  • 当关系不成立时,表达式结果为0;
  • 当关系成立时,表达式结果为1;
  1. 关系表达式不能连用,需要多次判断时,需要使用逻辑表达式连接;
  2. !!!注意区分’==‘和’='这两个操作符:
  • 两个等号的是关系操作符,用来判断两个操作对象是否相等,表达式的值为0或者1;
  • 一个等号的是赋值操作符,用来将右边的操作对象的值赋值给左边的操作对象,表达式的值为右操作对象的值;

关系操作符的内容不多,咱们先介绍到这里,下面我们继续来介绍后面的操作符;

八、逻辑操作符

1.成员

‘&&’——逻辑与——并且
‘||’——逻辑或——或者

2.操作内容

逻辑操作符的操作对象可以是单一的对象,也可以是表达式;

//使用格式:
exp1 && exp2;
exp1 || exp2;

3.运算规则

逻辑操作符的运算结果只有两种,真和假:

  1. 当结果为真时,值为1;
  2. 当结果为假时,值为零;

下面我们通过例子来看一下逻辑表达式的值:

逻辑表达式
从这个例子中我们可以得到以下结论:

  1. 逻辑与的运算规则是两个操作数都为真,结果为真,否则为假;
  2. 逻辑或的运算规则是两个操作数只要有一个为真,结果为真,否则为假;

我们通过代码来进一步介绍逻辑操作符,如下所示:

在这里插入图片描述

从测试结果中我们可以得到以下信息:
在第一个if语句的判断语句中会出现三种情况:

  1. 当a小于等于3时,表达式​​a<=3​​​成立,表达式结果为真,此时表达式​​a>=7​​不成立,表达式结果为假;
  2. 当a大于3时,表达式​​a<=3​​​不成立,表达式结果为假,此时a也小于7的话,表达式​​a>=7​​也不成立,表达式结果为假;
  3. 当a大于7时,表达式​​a>=7​​​成立,表达式结果为真,此时表达式​​a<=3​​不成立,表达式结果为假;

我们可以看到,在逻辑或的两个操作对象只要有一个操作对象结果为真,那整个表达式的结果就为真,if语句就能正常执行打印​​"%d不在集合(3,7)内"​​,
如果两个操作对象结果都为假,则整个表达式的结果就为假,if语句就不能执行;

在第二个if语句的判断语句中也会出现三种情况:

  1. 当a小于等于3时,表达式​​a<7​​​成立,表达式结果为真,此时表达式​​a>3​​不成立,表达式结果为假;
  2. 当a大于3时,表达式​​a>3​​​成立,表达式结果为真,此时a也小于7的话,表达式​​a<7​​也成立,表达式结果为真;
  3. 当a大于7时,表达式​​a>3​​​成立,表达式结果为真,此时表达式​​a<7​​不成立,表达式结果为假;

我们可以看到,在逻辑与的两个操作对象中,只要有一个操作对象结果为假,那整个表达式的结果就为假,if语句就不能执行,除非两个操作对象的结果都为真,则整个表达式的结果才为真,if语句才能执行打印​​"3<%d<7"​​;

从这些信息中,我们好像找到了一点熟悉的感觉。这个运算规则是不是和按位或和按位与有点相似啊,下面我们就来探讨一下这两类操作符;

4.与位操作符的异同点

4.1 相同点

运算规则相同:

  • 逻辑与和按位与都是两个对象都为真,结果才为真,否则为假;
  • 逻辑或和按位或都是两个对象都为假,结果才为假,否则为真;

表示符号相同:

  • 逻辑与和按位与的符号都是&;
  • 逻辑或和按位或的符号都是|;

4.2 不同点

操作对象不同:

  • 位操作符的操作对象是操作数的二进制位;
  • 逻辑操作符的操作对象是表达式;

符号数量不同:

  • 逻辑与有两个&,按位与只有一个&;
  • 逻辑或有两个|,按位或只有一个|;

现在我们介绍完逻辑操作符的运算规则,咱们也将其与位操作符进行了对比,接下来我们来介绍一下逻辑操作符的运算特点;

5.运算特点

C语言逻辑运算符在运算时有一个特点,它总是先对左侧的表达式求值,再对右边的表达式求值,这个顺序是保证的。
在这个特点下,逻辑运算符在运算的过程中会出现下面两种情况:

  • 在逻辑与——&&运算时,如果左边的表达式结果为假,则不再对右边的表达式求值;
  • 在逻辑或——||运算时,如果左边的表达式结果为真,则不再对右边的表达式求值;

这种情况称为“短路”。下面我们就通过具体的例子来说明:

逻辑操作符运算特点

在上述的例子中,根据这个运算特点,我们来分析一下对于1/2/3这三个数的判断逻辑:

  • 在第一个if逻辑或的判断中,已经满足了左边的表达式,结果为真,所以不需要再对右边的表达式继续进行判断;
  • 在第二个if逻辑与的判断中,已经不满足左边的表达式,结果为假,所以不需要再对右边的表达式继续进行判断;

6.总结

  1. 逻辑操作符有两个成员——逻辑与(&&)和逻辑或(||);
  2. 我们可以用集合的观点来理解这两个操作符:
  • 逻辑与就是求两个集合的交集,而逻辑或是求两个集合的并集;
  1. 逻辑操作符的操作对象是两个表达式;
  2. 逻辑操作符的运算结果只有两种情况——真和假:
  • 结果为真时值为1,结果为假时值为0;
  1. 逻辑操作符的运算规则:
  • 逻辑与——操作对象都为真,结果才为真,否则,结果为假;
  • 逻辑或——操作对象都为假,结果才为假,否则,结果为真;
  1. 逻辑操作符的运算特点:
  • 在逻辑与——&&运算时,如果左边的表达式结果为假,则不再对右边的表达式求值;
  • 在逻辑或——||运算时,如果左边的表达式结果为真,则不再对右边的表达式求值;

逻辑操作符到这里也介绍完了,接下来我们来看一下其它的操作符;

九、条件操作符

1.成员

“exp1?exp2:exp3”——条件操作符又称三目操作符;

  • 条件操作符是C语言里面位移一个三目操作符,操作符的操作对象有3个,操作符由"?“与”:"这两个符号构成。

2.运算规则

exp1!=0,exp1?exp2:exp3=exp2;
exp1==0,exp1?exp2:exp3=exp3;

这两个式子是表示的什么意思呢?
用文字表述出来就是:

  • 当表达式1的结果为真,也就是表达式的值不为0,则计算表达式2,整个表达式的结果为表达式2的结果;
  • 当表达式1的结果为假,也就是表达式的值为0,则计算表达式3,整个表达式的结果为表达式3的结果;

这个判断逻辑与if……else的判断逻辑类似,下面我们通过例子来进一步说明:

条件操作符

我们来分析一下这个例子的运行结果:

  • 在变量a,b,c的运算中

    • 第一步我们先通过判断a>b的结果,很显然2>3这个结果为假;
    • 第二步我们来计算表达式,按正常的从左到右计算的顺序,或者从右到左计算的顺序我们都能得到​​a=5,b=6​​这个结论;
    • 第三步进行结果打印,从打印的结果中我们可以看到,此时的a还是初始值2,c则与b的值相等为6;
  • 在变量x,y,z的运算中

    • 第一步我们先通过判断x<y的结果,很显然2<3这个结果为真;
    • 第二步我们来计算表达式,按正常的从左到右计算的顺序,或者从右到左计算的顺序我们都能得到​​x=5,y=6​​这个结论;
    • 第三步进行结果打印,从打印的结果中我们可以看到,此时的y还是初始值3,z则与x的值相等为5;

从这个例子中我们可以得到以下结论:

  1. 条件操作符的判断逻辑与if……else的判断逻辑类似,可以理解为条件操作符为双分支语句的一种简化形式;
  2. ​条件操作符语句整体表达式的值在表达式1的值为假时,表达式3的值将作为整个表达式的值,此时表达式2不参与运算;​
  3. ​条件操作符语句整体表达式的值在表达式1的值为真时,表达式2的值将作为整个表达式的值,此时表达式3不参与运算;

也就是说条件操作符的执行流程如下图所示:

条件操作符的执行流程

3.总结

  1. 条件操作符的操作对象有三个,是C语言中唯一一个三目操作符;
  2. 条件操作符是双分支语句的一种简化形式;
  3. 条件操作符的执行流程如下:
    • 判断表达式1的值为真,则运算表达式2,表达式3不参与运算表达式2的值为整个表达式的值
    • 判断表达式1的值为假,则运算表达式3,表达式2不参与运算表达式3的值为整个表达式的值

条件操作符的内容就全部介绍完了,下面我们继续介绍其它的操作符;

十、逗号表达式

1.成员

“exp1,exp2,exp3,……expn”——逗号表达式就是用逗号隔开的多个表达式,其中​​","​​就是逗号表达式的操作符。

2.用法介绍

2.1 隔开同类型的操作对象

这个常见的是用于定义变量上,如下所示:

逗号表达式

在这个例子中,我们需要定义3个整型变量,此时我们就可以用逗号表达式将这些变量隔开,在第一个表达式前标明定义的变量类型就可以了;

有细心的朋友会在这个例子中看到我们在printf中也有用到逗号表达式,此时为什么可以呢?

这里是因为printf作为一个库函数,它括号里的内容我们在函数篇章有提到过,叫做参数,不管是这里的由双引号引起的字符串也好,还是后面的a,b,c这些变量也好,都是printf函数的参数,所以从这个角度来看,它们也是属于同类型的操作对象;

逗号表达式的错误用法

当我们像这个例子中一样,在定义同类型的变量时,用逗号表达式隔开后第二个表达式继续带上数据类型,或者在定义不同类型时直接用逗号表达式隔开,系统都会报错,报错的错误提示我们可以看到一个是在15行有语法错误,一个是在16行有未声明的标识符;

这里我们就介绍完了逗号表达式的第一个用法,接下来我们来看逗号表达式的第二个用法;

2.2 隔开表达式

在这个用法中这表达式具体指的是什么呢?我们通过下面的例子来理解:

逗号表达式2

在这个例子中我们可以得到以下结论:

  • 所谓的表达式不一定是变量的赋值与运算,这个表达式还可以是函数、还可以是单一的变量
  • 这些表达式的类型可以不相同运算的方式也可以不相同执行的逻辑也可以不相同

所以我们可以把表达式理解操作对象,这个操作对象可以是单一的变量可以是计算式可以是函数但是不能为数据类型

那在通过逗号隔开表达式的时候这些表达式又是如何运算的呢?下面我们就来介绍一下逗号表达式的运算规则;

3.运算规则

逗号表达式的运算规则是表达式从左到右依次执行,整个表达式的结果是最后一个表达式的结果。
下面我们来看一个例子进一步理解逗号表达式的求值:

逗号表达式3

在这个例子中,我们的运算顺序有两种一个是从左到右依次计算a、b、c,一个是从右到左依次计算c、b、a;
下面我们就来分析一下这个逗号表达式的计算规则:

  • 从左到右计算
    • 表达式从左到右计算,我们可以得到的结果是​​a=5,b=7,c=35​​,最终将逗号表达式的值赋值给d;
  • 从右到左计算
    • 表达式从右到左计算,我们可以得到的结果是​​c=6,b=9,a=11​​,最终将逗号表达式的值赋值给d;

从最终的运行结果中我们可以看到,表达式的顺序是从左到右执行,且整个表达式的值为最后一个表达式的值;

4.总结

对于逗号表达式,咱们介绍了一下两点内容:

  • 逗号表达式可以用来隔开除了数据类型以外的操作对象,这个对象可以是单个变量,可以是计算式还可以是函数;
  • 逗号表达式的运算规则为:从左到右依次执行,最后一个表达式的值为整个表达式的值;

逗号表达式的内容也不多,比较容易理解,我们只要记住不要用逗号表达式隔开数据类型以及表达式的求值顺序就可以了。下面我们继续看其它的操作符;

十一、下标引用、函数调用、和结构成员

1.成员

“[]”——下标引用操作符
“()”——函数调用操作符
“.”——结构体成员操作符
“->”——结构体成员操作符

2.“[]”——下标引用操作符

下标引用操作符我们并不陌生了,在数组篇章中有介绍过,它的作用就是在数组中对数组元素下标进行引用,以此来访问数组元素。
下标引用操作符也是一个双目操作符,它的操作数有两个——数组名和索引值;
下面我们通过实例来说明它的两个操作数:

int arr[10] = { 0 };//定义数组
	//int——数组元素类型;
	// int [10]——数组类型;
	//arr——数组名;
	//10——数组大小;
	arr[0] = 0;
	//arr——数组名;
	//0——索引值;

在这个数组中,下标引用操作符的操作对象就是​​arr​​​和​​0​​;
在使用下标引用操作符时,有几点需要注意:

  • 当数组名前面有数据类型时,这时是在定义数组,并不是通过下标引用操作符来引用下标;
  • 当省略数组名,只有元素数据类型以及下标引用操作符和数组大小时,这是表示数组的数据类型;
  • 通过操作符来引用数组下标时,操作符内的索引值不会超过甚至是等于数组大小;

下标引用操作符到这里就介绍完了,下面我们来看看函数调用操作符;

3.“()”——函数调用操作符

函数调用操作符顾名思义,就是用来进行函数调用的。
我们在进行函数调用时,是通过函数名(参数)这种格式,这里的括号就是函数调用操作符。下面我们来介绍一下函数调用操作符的用法;

3.1 操作符的使用

函数调用操作符从操作对象性质的角度来理解,我们也可以认为它是一个双目操作符。
因为对于函数调用操作符的操作对象从性质上看是只有两个——函数名和参数;
但是实际上操作对象并不止两个,如下所示:

int a = 0, b = 1, c = 2;
	printf("%d,%d,%d\n", a, b, c);
	//printf——函数名;
	//"%d,%d,%d\n", a, b, c——参数;

在这个例子中我们可以看到对于函数调用操作符来说,它此时的操作对象是​​printf​​​这个函数名以及括号内的参数;
但是我们可以看到函数的参数此时有“%d,%d,%d\n”​​​、​​a​​​ 、​​b ​​ 、​​c ​​这四个参数;
所以我们从操作对象的性质上来看,它也是属于双目操作符,但是我们要清楚它的参数并不是只能有一个参数,它可以没有参数也可以有一个参数或者多个参数。

函数调用操作符我们需要掌握的是只要是在操作符内部的内容都是属于函数的参数,以及函数调用操作符的参数可以没有,可以有一个,也可以有多个;

现在我们来看看结构体成员操作符;

4.“.”/“->”——结构体成员操作符

结构体成员操作符我们现在还是比较陌生的,有几个问题摆在我们眼前——什么是结构体?为什么结构体成员操作符有两个?它们有什么区别?下面我们就来一一解答;

4.1 什么是结构体?

结构体简单的理解就是通过结构体关键字​​struct​​定义的一个新的数据类型,这个数据类型由同一类型或者不同类型的成员组成
结构体的作用是用来描述一个复杂对象的。如我现在要描述一本书,我就需要描述这本书的书名、价格,此时通过C语言提供的数据类型显然是办不到的,这时我就可以定义一个结构体,代码如下所示:

//结构体
struct book//结构体数据类型
{
	char name[20];//结构体成员1;
	int price;//结构体成员2;
};

在这个定义中我们可以看到对于​​struct book​​​这个结构体类型来说,它有两个成员​​name[20]​​​和​​price​​​,这两个成员的数据类型分别是​​char​​​和​​int​​两个不同的类型;
在简单了解了结构体之后,我们继续来解答下一个问题;

4.2 为什么结构体成员操作符有两个?它们有什么区别?

我们使用结构体类型的格式与其它数据类型的格式一样——​​结构体类型 变量名​​
对于这个变量我们也可以进行初始化,初始化的方式和变量初始化一样,就是给变量赋一个初始值,但是要注意这个初始值的顺序要和结构体成员的顺序一一对应,如图所示:

结构体初始化

可以看到,在结构体类型中我们是先定义的字符数组,所以初始化时,我们的第一个元素是一个字符串,第二个元素才是整型值。
在对结构体变量进行初始化后,我们想要调用这些值的话,这里就需要用到我们的结构体成员操作符了。

结构体成员操作符是用来访问结构体的成员的

  • 对于“​.”​​这个操作符来说,它的操作对象是变量名和成员名
  • 对于​​"->"​​这个操作符来说,它的操作对象是指针变量名和成员名

结构体成员操作符之所以有两个,是因为我们访问结构体成员的方式不同,一个是通过变量访问,一个是通过指针来访问。
下面我们就来一一介绍这两个操作符应该如何使用;

4.2 操作符的使用

  • “​.”​​是一个双目操作符,它的操作对象是变量名和成员名。这里的变量名指的是通过结构体类型定义的结构体变量;
  • ​​"->"是一个双目操作符,它的操作对象是指针变量名和成员名。这里的指针变量指的是通过结构体类型定义的结构体变量的指针;

这两个操作符的使用方式如下所示:

结构体成员操作符

这里我们可以看到,对于结构体变量a来说,它想访问结构体成员,就需要借助操作符​​ “​.”​​;
而对于结构体指针变量pa来说,它想访问结构体成员,就需要借助操作符​​"->"

5.总结

  • ​​"[]"​​——下标引用操作符常用在数组中,在需要借助下标来访问数组元素时使用,需要注意区分引用操作符与定义数组的区别;
  • ​​"()"​​——函数调用操作符常用在函数中,在需要进行函数调用时使用,我们要分清函数操作符的两个操作对象——函数名和参数;

其中参数这个操作对象可以没有可以是一个或者多个,只要是在操作符内部的所有对象都是函数的参数;

  • ​​".“/”->"​​——结构体成员操作符,在需要访问结构体成员时使用,根据访问的方式不同,所使用的操作符不同:
    • 通过结构体变量访问结构体成员时使用——​​"."​​;
    • 通过结构体指针访问结构体成员时使用——​​"->"​​;

ps:有朋友看到结构体和指针可能就慌了,这是啥呀?我咋看不懂呢?
别着急,目前结构体和指针的相关内容我们还未介绍,在后面的指针篇章与结构体篇章我会给大家详细介绍相关内容,大家记得关注哦!!!

操作符我们到这里就全部介绍完了,在前面我们经常提到表达式这个术语,并且每当介绍到一个操作符时,我们都会介绍一下对应表达式的值,那么这个表达式的值具体是如何求解的呢?下面我们就一起来探讨一下吧!

十二、表达式求值

对表达式的求值内容,我们分为两个区块介绍:

  • 一个是简单的表达式求值——如​​a + b​​​​​c ^ d​​这种只有一个操作符的表达式;
  • 一个是复杂的表达式求值——如​​++a * b ^ c >> 2​​这种有多个操作符的表达式;

对于简答的表达式,我们可能会遇到的问题就是不同类型的操作对象进行运算,如:

//表达式求值
int main()
{
	char a = 'a';
	short b = 1;
	int c = 2;
	float d = 3.14f;
	double e = 2.58;
	a + b + c + d + e;
	return 0;
}

在这种情况下,五个变量的类型都不相同,对于表达式​​a + b + c + d + e​​的值,我们又应该如何计算呢?
对于上述这种多类型的表达式求值,我们在对其求值的过程中需要将它们转化成其它的类型

说到转化类型,在前面我们有介绍过一种类型转换的方式,通过强制类型转换操作符进行的类型转换,接下来我们来介绍另一种转换方式——隐式类型转换;

1.隐式类型转换

在介绍隐式类型转换前,我们先要对这个转换有一个初步的理解才行。

那什么是隐式类型转换呢?

我的理解就是字面意思:隐——隐藏、隐蔽——偷偷摸摸的,不易察觉的。那隐式类型转换就是让人无法察觉的进行类型转换;

那什么情况下才会进行隐式类型转换呢?

这里有两种情况:

  • 一种是当操作数的类型所占空间大小小于一个整型所占空间大小时,会将操作数转换成整型后再进行运算,这种叫做整型提升
  • 另一种是当操作数在进行运算时,它们的类型都不相同,并且类型所占空间大小大于或等于一个整型所占空间大小时,其中的一个操作数将转化为另一个操作数的类型,这种叫做算术转换;

下面我们来一一介绍这两种转换的方式;

1.1 整型提升

1.1.1 什么是整型提升?

C语言的整型算术运算总是至少以缺省整型类型的精度来进行的。
为了获得这个精度,表达式中的字符和短整型操作数在使用之前被转换为普通整型,这种转换称为整型提升。

光看这两句话,我们都不太好理解什么是整型提升,下面我们来了解一下为什么要整型提升?整型提升的意义是什么?

1.1.2 整型提升的意义

表达式的整型运算要在CPU的相应运算器件内执行,CPU内整型运算器(ALU)的操作数的字节长度一般就是int的字节长度,同时也是CPU的通用寄存器的长度。
因此,即使两个char类型的相加,在CPU执行时实际上也要先转换为CPU内整型操作数的标准长度。
通用CPU(general-purpose CPU)是难以直接实现两个8比特字节直接相加运算(虽然机器指令中可能有这种字节相加指令)。
所以,表达式中各种长度可能小于int长度的整型值,都必须先转换为intunsigned int,然后才能送入CPU去执行运算。

简单来说就是在进行整型运算时,因为这个运算是在CPU内进行的,但是CPU内负责整型运算的运算器它的操作数的字节长度一般是一个​​int​​类型的字节长度
当操作数的字节长度小于一个​​int​​类型的字节长度时,这个整型运算器是无法正常工作的
为了让这个运算器正常工作,这时我们需要将​​char​​​和​​short​​​这两种数据类型的操作数先转化成​​int​​类型,再进行整型计算,这样这个运算器就能正常工作了。

现在我们再来理解什么是整型提升,所谓的整型提升其实就是将​​char​​​和​​short​​​这两种类型的操作数转换成​​int​​类型的过程
下面我们来通过例子进一步理解什么是整型提升:

//表达式求值
int main()
{
	char a = 1, b = 2, c = a + b;
	printf("%d\n", c);
	a = 1, b = 126, c = a + b;
	printf("%d\n", c);
	a = 1, b = 127, c = a + b;
	printf("%d\n", c);
	return 0;
}

我们来看一下这个代码,现在我们对字符类型的变量a、b分别进行了3次赋值——1,2、1,126、1,127,大家觉得如果我直接以整型打印,结果会是多少?
下面我们来看一下运行结果:

字符整型运算

这个结果跟各位的预期是一样的吗?下面我就来解释一下为什么会出现这个结果;

简单的理解就是,字符在进行整型运算时,只是将字节大小提升成了​​int​​​的字节大小后,再按正常的​​int​​​类型进行运算,所以我们可以看到当​​a=1,b=2​​​或者​​a=1,b=126​​时c的结果就是两数之和;

但是如果仅仅只是这种提升方式的话为什么在第三种情况下,结果会变成​​c=-128​​呢?接下来我们来学习一下这个整型提升是如何进行的;

1.2 如何进行整型提升?

  • 整型提升是按照变量的数据类型的符号位来提升的:
    • 负数的整型提升因为符号位为1,所以高位补充的也是1;
    • 正数的整型提升因为符号位为0,所以高位补充的也是0;

上面介绍的是整型提升的规则,简单的理解就是

  • 当符号位为1时,提升的比特位补充的内容都是1;
  • 当符号位为0时,提升的比特位补充的内容都是0;

经过前面的学习,我们知道对于有符号数来说,最高位就是它们的符号位,对于有符号的​​char​​​和​​short​​类型的数来说同样适用。
接下来根据这个提升规则我们来理解一下为什么在上述代码的第三种情况下结果会发生变化;
我们知道对于有符号的数字1来说,它的原码/反码/补码都为:

​​00000000000000000000000000000001​​

那对于有符号的数字127来说,它的原码/反码/补码又是多少呢?我们借助程序员计算机来查看一下,步骤如下所示;
1.大家可以使用快捷键​​win+r​​​来打开Windows的运行窗口,并在窗口中输入​​clac打开计算器:

打开运行窗口

2.进入计算器后将计算器调整成程序员模式:

计算器模式选择

3.在十进制模式下输入127来查看127的二进制形式:

进制转化

可以看到127的二进制形式为​​01111111​​,它实际在计算机内存中的二进制序列为:

​​00000000000000000000000001111111​​

这个二进制序列也就是有符号整数127的原码/反码/补码。现在我们已经知道了1和127的补码,那对于字符a和b来说,它们作为字符类型的变量是如何存储这个内容的呢?这就是我们要介绍的一个内容——截断

1.3 截断

对于截断我们可以简单的理解为就是将高字节位的内容存存储在低字节的变量中的一个转换过程

那它的截断规则又是什么呢?

  • 整型提升的规则,简单的概括出来就是在高位上补充缺少的字节;;
  • 截断刚好与整型提升相反,它是保留低位的字节去掉高位多出来的字节

对于数字1和127来说,它们的截断过程如下如所示:
截断过程

在截断完成后a的二进制序列为​​00000001​​​,b的二进制序列为​​01111111​​;

a和b的整型运算又应该如何完成呢?

  • 在前面的介绍中我们有提到,在进行整型运算时,当运算的操作数的字节位不足一个整型字节位时,我们需要进行整型提升,整型提升是按照操作数的符号位来提升的;

也就是说,此时的a和b需要进行整型提升,那它们提升的过程如下:

整型提升

对于提升后的a和b再进行相加我们就很熟悉了很容易的到结果为:

​​00000000000000000000000010000000​​

现在我们要将这个结果存放在字符c内,此时它需要发生截断,最后我们要将c以整型的形式打印出来,它需要进行整型提升,这个截断和提升的过程如图所示:

先截断后提升

此时我们可以看到通过这一系列操作后,c的二进制序列的符号位由0变为了1,也就是说此时的c是一个负数,那负数的原码我们需要进行补码->反码->原码的一系列转换,转换过程如下:

原反补转化

这样我们就得到了​​c=-128​​。

1.4 小结

当在进行整型运算时,如果操作数的字节长度不足一个整型的字节长度,那么在运算的过程中,我们需要完成一下步骤:

  1. 将整型数存放在变量中,这个过程会发生截断,将高位多出的字节去掉,低位保留相应的字节长度;
  2. 将变量进行整型运算,这个过程会发生整型提升,由当前二进制序列的最高位的数字来在高位补充缺失的字节:
    • 如果此时最高位为0,则高位补充字节内容为0;
    • 如果此时最高位为1,则高位补充字节内容为1;
  3. 将运算后的数存放在变量中,如果这个存放的变量字节长度不足整型长度,会发生截断;

对于​​char​​​类型来说,它能存储的整型值的范围是 − 2 7 ~ 2 7 − 1 ​​​ -2^7~2^7-1​​​ 27271​​​也就是​​ − 128 ~ 27 -128~27 12827​​,而且我们在手算时可以按照下面的规则进行运算:

  • 当正整数之和小于等于127时,运算结果为两数相加的值;
  • 当正整数之和大于127时,具体的值需要进行整型提升与截断才能最终确定其值;
  • 两数之和的值为一个以0-256为一个周期的周期函数,图像如下所示:

字符类型的函数图像

以上就是整型提升的全部内容,这是对于​​char​​​和​​short​​这个两个类型而言,接下来我们来介绍另一种转换方式;

1.5 算术转换

我们先想象一下一种情况——在某个操作符的各个操作数属于不同类型时,除非其中一个操作数转换为另一个操作数的类型,否则操作就无法进行。
在这个情况中的操作符可不是指算术操作符,还可能是其它的操作符,这里我们方便起见,以最开始的例子来说明:

//表达式求值
int main()
{
	char a = 'a';
	short b = 1;
	int c = 2;
	float d = 3.14f;
	double e = 2.58;
	a + b + c + d + e;
	return 0;
}

在这个例子中我们可以看到这里的五个变量的类型各不相同,对于a、b两个变量要进行运算时,我们知道了应该先进行整型提升,再进行整数运算,那对于c、d、e这个三个已经满足了一个整型字节大小的不同类型时,我们又应该如何执行呢?
在这种情况下,就需要用到我们现在要介绍的算术转换的相关知识了。

1.6 什么是算术转换?

所谓的算术转换我们可以简单的理解为是当我们对不同类型的操作数进行运算时,其中一个操作数会无条件转换成另一个操作数的类型的这个过程

进行算术转换的操作对象是字节大小满足一个整型字节大小的操作对象;
对于不满足整型字节大小的对象,需要先进行整型提升,再进行算术转换。

如何进行算术转换?
我们先来看一张数据类型的名次表:

名次数据类型
1long double
2double
3float
4unsigned long int
5long int
6unsigned int
7int

在表中我们可以看到,int的名次最低,long double的名次最高。这种数据类型的层次体系称为寻常算术转换

当我们的操作数的数据类型不同,且一个操作数的数据类型排名低于另一个操作数的数据类型排名时,类型排名较低的操作数会无条件转换为另一个操作数的类型,然后再执行运算。

  • 也就是说当​​int​​​与​​float​​​这两个数据类型的操作数进行运算时,​​int​​​类型的操作数会先无条件转换成​​float​​类型,然后再进行运算;
  • 同理​​float​​​与​​double​​​这两个数据类型也是一样的情况,​​float​​​会无条件转换成​​double​​类型,然后再进行运算;

在了解了这些知识点后,我们再来手算一下刚刚的例子;
由字符a的ASCII码值为97,这些值加起来为105.72,值的类型应该为double类型,下面我们来看一下运行结果:

多类型整型运算

从这个运算结果中我们可以看到,它这里的精度出现了点问题,但是大体上还是这个值。会出现这个情况是因为在进行隐式转换的时候整型数转换成浮点数时,会出现精度丢失的问题,解决也很简单,如下图所示:

精度丢失

  • 在运行时程序会提示我们像这样操作导致算术溢出了,如果要解决的话,需要在调用运算符前先将值强制转化成宽类型,这样就避免了溢出的问题。

    • 如上图所示,在第二次打印时我们就将不是double类型的对象都进行了强制转化,所以最终的结果为105.720000;
  • 还有一种解决办法就是,它既然丢失了精度,我们直接给它精度也就是通过​​%.*lf​​这种格式来打印,这里的*代表的是精度

    • 我们在赋值时给d和e赋值的是两位小数,所以这里我通过​​%.2lf​​这种格式来打印,也能得到正常的值105.72。

1.7 小结

  • 在进行运算时,两个操作对象中数据类型名次较低的操作对象会转换另一个操作对象的数据类型,再进行运算;
  • 在整型值转换成浮点型时,会出现精度丢失的问题,我们有两种解决方式:
    • 可以通过在打印时以​​%.lf​​的格式给结果相应的精度来进行打印;
    • 或者避开隐式类型转换,使用强制类型转换直接将整型强制转换成浮点型;

介绍完了隐式类型转换,我们会发现,刚刚我们遇到的问题都是操作符相同的情况下,如果在操作符不同的情况下我们又应该如何进行表达式求值呢?接下来我们就来介绍一下相关知识点;

2.操作符的属性

对于像最开始咱们举的例子​​++a*b^c>>2​​这种复杂的表达式来说,求值取决于三个因素:

  1. 操作符的优先级
  2. 操作符的结合性
  3. 是否控制求值顺序

两个相邻的操作符先执行哪个?取决于它们的优先级,如果优先级相同,则取决于它们的结合性。

2.1 优先级

操作符的优先级是指如果一个表达式包含多个运算符,哪个运算符应该优先执行。
下面我们来看一下各个操作符的优先级:

操作符的优先级

资料参考:​​https://zh.cppreference.com/w/c/language/operator_precedence
知道了操作符的优先级,那对于例子中的前置++、乘法、按位异或、和右移操作符的优先级我们可以对照上表进行排序:

优先级操作符
1前置++(++a)
2乘法((++a)*b)
3右移(c>>2)
4按位异或((++a)*b)^(c>>2)

现在我们就解决了当操作符优先级不同时的问题,那如果操作符优先级相同呢?这就是我们要介绍的另一个属性——结合性;

2.2 结合性

如果两个运算符优先级相同时,我们需要根据结合性来决定执行顺序。
所谓的结合性我们可以简单的理解为操作符的运算方向,操作符在运算时要么是从左到右运算,要么是从右到左运算;

  • 从左到右运算的操作符,我们称它的结合性为左结合;
  • 从右到左运算的操作符,我们称它的结合性为右结合;

大部分的操作符都是左结合,比如我们在介绍隐式类型转换时,用到的操作符是加法,查表可知它的结合性是左结合,所以我们在运算时是从左边往右边计算,这也符合我们数学中加法的运算顺序;
少部分的运算符是右结合,比如赋值语句​​a=2​​这时的运算是从右往左执行,所以它的执行逻辑是把2赋值给a。

2.3 小结

  1. 我们在进行表达式计算时,首先判断操作符的优先级在优先级相同的情况下,我们再判断操作符的结合性,以此来决定计算顺序;
  2. 对于三目操作符、逻辑或、逻辑与以及逗号这四个操作符来说,它们在进行运算时会控制求值顺序;
  • 如三目操作符会根据表达式1的值的不同而进行不同顺序的求值;
    • 逻辑或在左操左对象为真时,不再计算右操作对象;
    • 逻辑与在做操作对象为假时,不再计算右操作对象;
  • 逗号表达式的值是最右边表达式的值;

运算符的优先级顺序很多,下⾯是部分运算符的优先级顺序(按照优先级从⾼到低排列),建议⼤概
记住这些操作符的优先级就⾏,其他操作符在使⽤的时候查看上⾯表格就可以了。

  1. 圆括号( () )
  2. ⾃增运算符( ++ ),⾃减运算符( – )
  3. 单⽬运算符( + 和 - )
  4. 乘法( * ),除法( / )
  5. 加法( + ),减法( - )
  6. 关系运算符( < 、 > 等)
  7. 赋值运算符( = )

由于圆括号的优先级最⾼,可以使⽤它改变其他运算符的优先级。
介绍完这两个属性,我们来看看几个表达式;

3.问题表达式解析

3.1 表达式一——a * b + c * d + e * f

对于这个表达式,我们可以看到它是由两个操作符——​​+​​​和​​​​​组成的表达式,按优先级来说,计算机在计算时只能根据​​​​​的优先级比​​+​​高,从而保证乘法的计算比加法早,但是优先级并不能保证第三个乘法比第一个加法早执行,因此,计算机在计算这个表达式的时候可能的顺序是:

顺序1

计算步骤执行操作
1 a ∗ b a*b ab
2 c ∗ d c*d cd
3 a ∗ b + c ∗ d a*b+c*d ab+cd
4 e ∗ f e*f ef
5 a ∗ b + c ∗ d + e ∗ f a*b+c*d+e*f ab+cd+ef

顺序2

计算步骤执行操作
1 a ∗ b a*b ab
2 c ∗ d c*d cd
3 e ∗ f e*f ef
4 a ∗ b + c ∗ d a*b+c*d ab+cd
5 a ∗ b + c ∗ d + e ∗ f a*b+c*d+e*f ab+cd+ef

像这样的话对于有些表达式求值,在结果上就会产生出入;

3.2 表达式二——c + --c

这个表达式同上,我们只能根据操作符的优先级来确定前置–在+之前进行运算,但是无法确定+的左操作数的获取是在前置–之前还是之后,比如:

int c = 1;
	c = c + --c;

如果+的取值在前置–前,那么结果就为1,如果+的取值在前置–后,那么结果就为0;因此我们并不能准确判断表达是的结果,此时的表达式就是有歧义的表达式;

3.3 表达式三——i=i-- - --i * ( i = -3 ) * i++ + ++i

代码如下:

//问题表达式3
int main()
{
	int i = 10;
	i = i-- - --i * (i = -3) * i++ + ++i;
	printf("i = %d\n", i);
	return 0;
}

对于这个代码,在不同的编译器下的执行结果为:

不同编译器下的运行结果

像这种情况,我们又应该以哪个编译器的结果为准呢?所以对于这个表达式的结果也是有歧义的;

3.4 表达式四——answer = fun() - fun() * fun()

代码如下:

//问题表达式4
int fun()
{
	static int count = 1;
	return ++count;
}
int main()
{
	int answer;
	answer = fun() - fun() * fun();
	printf("%d\n", answer);//输出多少?
	return 0;
}

对于这个表达式,我们能确定的只有函数调用在乘法前面,乘法运算在减法的前面,但是我们并不能确定哪一个函数先进行调用,那就可能出现以下的几种情况:

计算步骤执行顺序
1 f u n ( ) = 2 fun()=2 fun()=2
2 f u n ( ) = 3 fun()=3 fun()=3
3 f u n ( ) = 4 fun()=4 fun()=4
4 f u n ( ) ∗ f u n ( ) fun()*fun() fun()fun()
5 f u n ( ) − f u n ( ) ∗ f u n ( ) fun()-fun()*fun() fun()fun()fun()
情况1
函数调用顺序表达式的值
从左到右依次调用 2 − 3 ∗ 4 = − 10 2-3*4=-10 234=10
情况2
函数调用顺序表达式的值
从乘法左边到右最后到减法左边依次调用 4 − 2 ∗ 3 = − 2 4-2*3=-2 423=2

像这种因为调用顺序不同导致值有歧义的表达式也是有问题的;

3.5 表达式五——ret = (++i) + (++i) + (++i)

代码如下:

//表达式5
#include <stdio.h>
int main()
{
 int i = 1;
 int ret = (++i) + (++i) + (++i);
 printf("%d\n", ret);
 printf("%d\n", i);
 return 0;
}

对于这个表达式的形式是不是与第一个表达式的形式类似啊,都是由两个优先级不同的操作符组成,而且都不能确定优先级高的第三个操作符和优先级低的第一个操作符的运算顺序,但是这里与第一个表达式不同的地方在于,前置++的结合性是从右到左进行,而加法的结合性是从左到右进行,此时就会出现一下几种情况:

顺序1

计算步骤执行操作实际运算过程
1++i=2++i + ++i + ++i
2++i=3++i + ++i + 2
3++i=4++i + 3 + 3
44+4=84 + 4 + 4
58+=128+4

顺序2

计算步骤执行操作实际运算过程
1++i=2++i + ++i + ++i
2++i=3++i + ++i + 2
33+3=6++i + 3 + 3
4++i=4++i + 6
54+6=104+6

由此我们可以判断,这个表达式也是一个问题表达式;

3.6 小结

  • 即使操作符有各自的优先级和结合性,如果我们不能通过这两个属性来使表达式具有唯一确定的计算途径,那这个表达式就是一个有风险的表达式,建议不要写出这种表达式;
  • 为了保证计算途径的唯一性,我们可以通过圆括号将先执行的表达式给括起来;
    • 拿表达式五来说,我们可以像这样处理:​​++i + (++i + ++i)​​
    • 在这种情况下我们就能根据优先级和结合性判断,先执行后面的两个前置++,再将经过两次自增后的结果相加,之后再进行前置++,最后将自增后的值与前一次的和相加,就能得到值为10,这样我们的表达式就没有歧义了。

结语

操作符的全部内容到此就全部介绍完了,在这个篇章中,我们不仅介绍了十种操作符,我们还介绍了如何通过操作符的优先级与结合性来对表达式进行求值。希望这篇内容对大家理解操作符与计算表达式能有帮助。最后感谢各位的翻阅,咱们下一篇再见!!!