前言
在前不久,我们计算机导论的课程讲到了进制转化,原反补三码。但是我周围有同学提出疑问,学习这三个码有什么用啊?
我在c语言中也学习过不少相关知识,他们都是我在学习位操作符时第一次接触到,所以今天我写一篇博客,来解释位操作符。C语言是一门非常灵活的语言,这得益于它丰富的操作符,甚至某些操作符可以精确到二进制位,也就是计算机最小的存储单元。那我们开始吧!
进制介绍
其实我们经常能听到2进制、8进制、10进制、16进制这样的讲法,那是什么意思呢?其实2进制、8进制、10进制、16进制是数值的不同表示形式而已。
比如13的不同形式有:
进制 | 表达 |
二进制 | 1101 |
八进制 | 15 |
十进制 | 13 |
十六进制 | d |
十进制是我们从小到大学习的,我们知晓十进制有以下两大特点:
满10进位
所有数字都由0-9组成
二进制也是如此
满2进位
所有数字都由0-11组成
对于16进制来说,由于10~15这些数字,无法用一位数字表达,于是就引入了字母abcdef来对应。
满16进位
所有数字都由0-9,a-f组成
那这些数字为啥在不同进制下表达形式不同?这就涉及到位权了:
进制的位权
不同进制的最大区别就是每一位的位权不同,从小数点左侧第一位开始,n进制的第x位的位权就是nx-1。想要知道一个数字多大,就用相应的位上的数字乘相应的位权,最后加和。比如二进制的1011,根据上图加和表达的数字就是11。
C语言常见的进制转化
在C语言中,由于数据是以二进制存储,所以二进制可以说的C语言进制转化的核心。在此我们主要说明十转二,二和八互转,二和十六互转。
十进制转二进制:
我们以125为例:
我们只需要每次把数字除2,把余数保留,把商留下来,然后用商继续除2,直到商为0。然后把余数从后到前排列,就得到相应的二进制了。故125的二进制就是1111101。
二进制与八进制和十六进制的互相转化
8进制的数字每一位是0-7的,0-7的数字,各自写成2进制,最多有3个2进制位就足够了,比如7的二进制是111,所以在2进制转8进制数的时候,从2进制序列中右边低位开始向左每3个2进制位会换算一位八进制位,当剩余的数字不足三个直接换算。
十六进制与其同理,16进制的数字每一位是0-9,a -f 的,0-9,a -f的数字,各自写成2进制,最多有4个2进制位就足够了,比如 f 的二进制是1111,所以在2进制转16进制数的时候,从2进制序列中右边低位开始向左每4个2进制位会换算一个16进制位,剩余不够4个二进制位的直接换算。
对于c语言来说,编译器在处理的时候,会把0开头的数字判定为8进制,0x开头的数字判定为16进制。比如下面的代码,两个数字都是17,在加上不同的前缀后,打印结果也就不同了。
而我们在给内存编址的时候,地址也是16进制的形式,它也带有一个0x的前缀。
原码、反码、补码
整数的2进制表示方法有三种,即原码、反码和补码
三种表示方法均有符号位和数值位两部分,符号位都是用0表示“正”,用1表示“负”,最高位的一位是被当做符号位,剩余的都是数值位。
转换规则
正整数的原、反、补码都相同。
负整数的三种表示方法各不相同。
原码:直接将数值按照正负数的形式翻译成二进制得到的就是原码。
反码:将原码的符号位不变,其他位依次按位取反就可以得到反码。
补码:反码+1就得到补码。
对于int(整形),计算机会给内存开辟4个字节即32个比特来存放a。由于在此a是正数,第一位符号位为0,数值为5,转化为二进制就是101,存在最后。正数的原反补三码相同。
由于在此a是负数,在原码中,第一位是符号位,存放1。
反码:符号位不变,保持为1。其余位按位取反,即0变1,1变0.
补码:在反码的情况下加1。
而补码想要变回原码,也是相同的步骤,即先取反后加一。
数据与内存的关系
首先,我们在内存中存储的数据是以补码的形式存储的。我们用代码定义a,b为5和-5,然后观察其在内存中的值:
由于二进制实在难于分辨,所以编译器在向程序员呈现计算机存储的值的时候,会转为16进制。我们从上图中可见,a的存储是可以理解的,可b的值却不是-00000005。这个fffffffb其实就是-5的补码的16进制形式,由此可以证明,内存存储数据就是以补码的形式。
那为什么内存要存补码?
- 可以把符号与数值统一处理,把数字的正负放在码值中,不用额外区分。
- 可以使加法减法统一处理(CPU只有加法计算器)。
第一点其实是容易理解的,那为什么用补码可以统一加减法呢?我们以下面的代码为例:
可见,虽然代码是减法,但是在计算机处理的时候,只做了加法运算。这样可以减少计算机硬件的消耗,只需要在CPU内部做好加法的硬件即可。
移位操作符
移位操作符是针对补码的操作符,他们都直接作用于补码。使用语法如下:
a << x;
a >> x;
前者是左移,后者是右移,a是被移动的值,x表示移动的位数,这里表示的就是把a的补码左移(右移)x位。
左移操作符<<
对于左移操作符,就是补码右侧补0,左侧丢弃。之后a的补码就会发生改变。第三行的两种形式都可以改变a的值,当然也可以用其它变量来接收a左移后的返回值,那这样a本身就不会发生改变。比如
b = a << 1;
右移操作符>>
右移操作符和左移操作符略有不同,右移操作符的效果有两种,分别是算数右移和逻辑右移。
逻辑右移:左边用0填充,右边丢弃
算术右移:左边用原值的符号位填充,右边丢弃
在此,具体实现哪一种是由编译器决定的,但是绝大部分编译器是算数右移。
注意:
移位操作符不可以移动负数位,这个标准是没有定义的,会报错。
int num = 10; num>>-1;//error
位操作符:&, |, ^, ~
操作符 | 名称 | 功能 |
& | 按位与 | 两数补码按位相比,有0则为0,同1才为1 |
1 | 按位或 | 两数补码按位相比,有1则为1,同0才为0 |
^ | 按位异或 | 两数补码按位相比,相同为0,相异为1 |
~ | 按位取反 | 对一个补码,把0变1,把1变0 |
具体如下:
一个小小的面试题
不能创建临时变量(第三个变量),实现两个数的交换。
对于两数交换,不少人会想到创建第三变量,但是这道面试题直接断了这条思路。
题解1:
这种思维模式,把a和b的值加和,然后分别用b先后储存的值,来反向输出。但是它有一种问题就是,当数字过大时,a+b的值有可能超出int类型的储存范围。在此我稍微讲解一下int类型的存储范围:
int类型的存储范围
int(整型)分为两种类型,分别是int(有符号整型)和unsigned int(无符号整型)。它们的区别就是第一位是符号位还是数值位。
对于无符号整型,它没有符号位,这32位全是数值位,当数字最大的时候就是32位数值位全为1,计算得到4294967295。所以无符号整形的存储范围是0到4294967295
对于有符号整型,它的第一位是符号位,那数值位只有31位,也就是说,当数字最大的时候就是这31位全是1,符号位为0;当数字最小的时候,这31位全是1,符号位为1。计算得到int的存储范围是-2147483674到2147483674
题解2:
在此我给大家解释说明一下:
首先引入两个公式
a ^ a = 0
a ^ 0 = a
这用到了异或的思路,相同为0,相异为1。那么a和a的补码完全相同,异或后就得到0。0与a异或得到a也很好理解。
在上述代码中,a被赋值为a ^ b。之后b = a ^ b就相当于 b = (a ^ b) ^ b。异或是支持交换律的,那这就相当于b = ( b ^ b ) ^ a = 0 ^ a = a。至此我们就完成了把a的值赋给a。
在第三句代码中,a = a ^ b = ( a ^ b) ^ a = b。因为在这里,b已经是a的值了。
由上述可知,在我们得到a ^ b的时候,只要再 ^ a就得到了b,再 ^b就得到a。
这种代码的好处就是不会进位,防止数值过大出现精度丢失的情况。当然,平常写代码还是用创建第三个变量的方法好,这个代码有很多局限性
- 只能作用于整数
- 代码的可读性低
- 代码执行效率也低于三个变量的方法
在此只是为了解决这个面试题而用的这个方式。
逗号表达式
基本语法:
exp1, exp2, exp3, …expN
逗号表达式,就是用逗号隔开的多个表达式。
逗号表达式,从左向右依次执行。整个表达式的结果是最后一个表达式的结果。
我们用几个例子来了解这个表达式。
int a = 1; int b = 2; int c = (a>b, a=b+10, a, b=a+1);//逗号表达式
这个表达式从左到右一次执行
第一步a>b是不成立的,直接忽略。也就是说,当逗号表达式中的式子不成立,它就直接不执行,没有意义。
第二步给a赋值,a就变成了12。
第三步给b赋值,由于第二步a的值被改变了,所以这里b=12+1=13。也就是说,前面的表达式改变,是会影响后面的表达式的。
if (a =b + 1, c=a / 2, a > 0, c > 5)
相信不少人第一次在if语句中见到三个表达式。在这个if语句中,第一步和第二步会改变a和c的值,会影响后面if的判断。第三步,不论a是否满足该条件,都完全不影响if语句的判断,这条if语句最终是否执行取决于第四个表达式c > 5是否成立。也就是上面提到的:“整个表达式的结果是最后一个表达式的结果。”
对我来说,这个逗号表达式最大的作用就是简化while以及do-while循环。比如:
int a = 3; int b = 5; a -= 1; b += 1; while (a > 0) { a -= 1; b += 1; }
这个代码在进入while循环前需要预处理一下,而这个预处理刚好和while循环的内部处理相同,我们就可以写成:
int main() { int a = 3; int b = 5; while (a -= 1,b +=1,a > 0) { } }
或者
int main() { int a = 3; int b = 5; do { } while (a -= 1, b += 1, a > 0); }
看吧,代码是不是简化了很多?
下标访问[]、函数调用()
想不到吧?你每天都在用的数组访问和调用函数居然也是通过操作符!接下来我们浅浅讲述一下它们的操作数与操作符的关系。
操作数
操作数就是指一个操作符的操作对象,比如3+5,那么+操作符的操作数就是3和5。
下标引用操作符[]
int arr[10];//创建数组 arr[9] = 10;//实用下标引用操作符。 [ ]的两个操作数是arr和9。
也就是说,对于下标引用操作符,它的操作数是括号内的索引值以及数组名。
函数调用操作符()
函数调用操作符的操作数也包含函数名,但是函数是可以传多个参数的,每个参数都是一个操作数。
也就是说,函数调用操作符接受一个或者多个操作数:第一个操作数是函数名,剩余的操作数就是传递给函数的参数。
操作符的属性:优先级、结合性
C语言的操作符有2个重要属性:优先级、结合性,这两个属性决定了表达式求值的计算顺序。
优先级
优先级指的是,如果一个表达式包含多个运算符,哪个运算符应该优先执行。各种运算符的优先级是不一样的。
3 + 4 * 5
比如这个代码,*操作符的优先级就比较高,会先执行4×5,后执行+3。
结合性
如果两个运算符优先级相同,优先级没办法确定先计算哪个了,这时候就看结合性了,则根据运算符是左结合,还是右结合,决定执行顺序。
左结合就是指对于同级操作符,从左到右执行,比如加减法:
3 + 5 - 1
它是左结合的,故先3+5,再-1。
大部分运算符是左结合(从左到右执行),少数运算符是右结合(从右到左执行),比如赋值运算符( = )。
a = 3 + 5
就会先执行右边的3+5,再赋值给a。
而在所有操作符中,圆括号()的优先级是最高的,可以用来改变其它操作符的先后顺序,比如:
(3 + 5) * 5
圆括号的优先级高于*,就会先执行3+5。
操作符的优先级与结合性表格在下面的链接中,但我不建议大家记忆,通过平常的代码练习即可。
整型提升与算术转换
我们知道,计算机在执行计算的时候,是在cpu中执行计算的。而CPU在计算时至少接受一个int(整型)的长度的数据,然后再计算。当一个数据长度小于int(整型)时,就会把这个数据变长为int(整型)的长度,这个过程叫做整形提升。而当一个数据长度大于int(整型)时,会根据计算的值的数据类型,把那个数据长度较短的数据增长,这个过程叫做算术转换。
整型提升
如图,对于一个char a = 1;
在进入CPU进行计算的时候,就会在前面补0,直到补到32个bit,即一个整型长度的时候,才开始计算。这样可以有效防止进位导致的数据丢失,在上面也讲过,数据的存储是有范围的,char也有相应的范围,当我们计算结果超出这个范围,就会丢失数据。
负数的整型提升
char c1 = -1;
变量c1的二进制位(补码)中只有8个比特位:
1111111
因为 char 为有符号的 char
所以整型提升的时候,高位补充符号位,即为1
提升之后的结果是:
11111111111111111111111111111111
正数的整型提升
char c2 = 1;
变量c2的二进制位(补码)中只有8个比特位:
00000001
因为 char 为有符号的 char
所以整形提升的时候,高位补充符号位,即为0
提升之后的结果是:
00000000000000000000000000000001
算术转换
如果某个操作符的各个操作数属于不同的类型,那么除非其中一个操作数的转换为另一个操作数的类型,否则操作就无法进行。
long double
double
float
unsigned long int
long int
unsigned int
int
如果某个操作数的类型在上面这个列表中排名靠后,那么首先要转换为另外一个操作数的类型后执行运算。
结语
今天虽然标题是位操作符,但是我依然从内存的角度解释了很多问题,我认为c语言的学习是离不开对内存的理解的,更不要说以后在学习指针的时候。相信现在大家对开始的问题也有些答案了
为什么要学进制转换?
因为地址是以16进制形式编码,内存是以2进制储存。
为什么要学三码?
因为内存中存储的是补码,计算时也是以补码形式。
那这篇博客就到此结束啦,下次见各位!