初学者在学习C语言,谈到不同数据类型时,一般都能理解 unsigned 和 signed 的区别,无非就是有无符号而已。但是对于 signed 数据类型的数据范围,初学者却常常会感到迷惑。
问题
例如提到 char 型能够表示的数据范围时,unsigned char 类型很好理解,所有的 8 位全部用于表示数值,因此 unsigned char 类型能够表示 2^8 = 256 个数,例如 0 到 255。
对于 signed char 类型,最高一位表示符号(+/-),能够用于表示数值的是低 7 位。因此按理说,signed char 类型能够表示的数值范围为 -0b111111到 +0b1111111,也即 -127 到 127。
但是很多教科书上却说 signed char 类型能够表示的数值范围为 -128 到 127,类似的还有 signed short 类型能够表示的数值范围为 -32768 到 32767,signed int 类型能够表示的数值范围为 -2147483648 到 2147483647,为什么有符号的整数类型,负数部分总是比整数部分多出一个数呢?
这里假设 short 类型占用 2 字节内存空间,int 类型占用 4 字节内存空间。
初步分析
在C语言中,char 型变量占用 8 个位,对于 signed char 类型,最高位表示符号位,此时有 7 个位用于表示数值。按照数学中的排列组合,7 个位能够表示 2^7 也即 128 个不同的数,若考虑正负号,signed char 类型最多也能表示 2*128=256
个不同的数。
但是,如果 signed char 类型能够表示的数值范围是 -0b111111到 +0b1111111(-127 到 127),那么能够表示的只有 255 个不同的数字了,与理论最大能够表示的不同数字数 256 相比,少了一个,这是因为 -0 和 +0 其实是同一个数字,也即 0b10000000 和 0b00000000 是同一个数字 0。
这对于计算机来说很不友好,同样的一个数字有两种二进制码,在处理时会显得很麻烦。这种麻烦对于 CPU 的电路设计,同样如此。
谈到 CPU 的电路设计,我们还应该明白,固定的面积上能够容纳的电路单元数是固定的。如果 CPU 只需处理加法运算,那么设计师就只需设计加法电路,这样既简单,又能最大程度的保障计算力。
可是在实际应用中,不可避免的需要减法运算,再考虑到 +/- 0 导致的数字二进制码重复问题,计算机中“补码”的概念被提出了。
补码
关于“补码”的定义就不写了,感兴趣的读者可以自行百科。这里仅简要的说下C语言中补码是如何获得的,其实很简单:正数和 0 的补码等于自身,负数的补码则是将其对应正数按位取反再加1。
例如正数 0b00000011(3) 的补码等于其自身,仍然为 0b00000011,而 0b10000001(-1)的补码等于 0b11111110 + 1 也即 0b11111111(0xFF)。
补码的最大优点是可以在加法或减法处理中,不需因为数字的正负而使用不同的计算方式。只要一种加法电路就可以处理各种有符号数加法(减法可以用一个数加上另一个数的补码来表示),因此只要有加法电路及补码电路即可完成各种有符号数加法及减法,在电路设计上相当方便。
另外,补码下的 0 就只有一个表示方式,因此在判断数字是否为 0 时,只要比较一次即可。
简单来说,数字 a(正负数皆可)的补码即为 -a。
为什么 signed char 型能够表示的数值范围为 -128~127
因为 -0 和 +0 其实是同一个数字,因此原码中 0b10000000 和 0b00000000 都表示数字 0。现在补码下的 0 只有一个表示方式:0b00000000,二进制码 0b10000000 就多余出来了。
浪费是可耻的,多出的二进制码 0b10000000 不能白白丢弃。若考虑数字 0 的二进制码 0b00000000,从它的符号位来看,计算机应该是将其当做“正数”的,0~127 是 128 个“正数”。
现在考察多出的二进制码 0b10000000,从它的符号位来看,把它当做负数是合情合理的,事实上在C语言中,它表示-128,从 -128 到 -1,恰好是 128 个“负数”。
在C语言中,signed char 型二进制码 0b10000000 的补码仍然为 0b10000000,因此它是“数字a的补码为 -a”原则的例外。
下表是一些 char 类型整数的补码,可表示的范围为 -128 到 127,总共 256 个不同整数。
延伸:为什么补码能这么巧妙实现了正负数的加减运算?
答案是:指定 n 位字长,就有 2^n 个可能的值,加减法运算都存在上溢出与下溢出的情况,实际上都等价于模 2^n 的加减法运算。这对于 n 位无符号整数类型或是 n 位有符号整数类型都同样适用。
例如,8 位无符号整数的值的范围是 0 到 255。因此 4+254 将上溢出,结果是 2,即
4+254 = 258 = 2 (mod 256)
8 位有符号整数的值的范围,如果规定为−128到127,则 126+125 将上溢出,结果是−5,即
126+125 = 251 = -5(mod 256)
对于 8 位字长的有符号整数类型,以 2^8 即 256 为模,则
-128 = 128 (mod 256)
-127 = 129 (mod 256)
...
-2 = 254 (mod 256)
-1 = 255 (mod 256)
所以模 256 下的加减法,用 0, 1, 2,…, 254,255 表示其值,或者用 −128, −127,…, −1, 0, 1, 2,…,127 是完全等价的。−128与128,−127与129,…,−2与254,−1与255 可以互换而加减法的结果不变,需要的 CPU 加法运算器的电路实现与 8 位无符号整数并无不同。
实际上对于 8 位的存储单元,把它的取值 [00000000,…, 11111111] 解释为 [0, 255],或者 [-1, 254],或者[-2, 253],或者[-128, 127],或者[-200, 55],甚至[500, 755],对于加法硬件实现并无不同,都是一样的。
小结
本节主要讨论了C语言中 signed char 型变量能够表示的数值范围,一般认为其能够表示 -128~127 的整数,而不是 0b11111111(-127)到 0b01111111(127),这也是初学者常常感到迷惑的地方。其实简单来看,计算机的设计讨厌一切“浪费”,数字 0 用两个二进制码(+/-0)表示就是一种浪费,处理起来也比较麻烦。倒不如只为 0 保留一种二进制码 0b00000000,而将多出的二进制码 0b10000000 用于表示 -128 方便了。