C语言中仅有四种数据类型,分别为整型、浮点型、指针型和聚合类型(包括数组和结构体),剩下的类型都是从这四种类型派生或组合而来
例如字符型char其实就是一个短整型,而字符串是用字符数组来保存和模拟。本章主要说说整型和浮点型相关问题。字符串、指针、数组和结构等主题,之后介绍
整型家族还分为有符号(signed)和无符号(unsigned)两种。整型数无论是否有符号,在计算机内部都是用补码来表示的。理解补码的表示方式有助于我们对整型数溢出的理解,所以先来介绍整型数的补码表示
介绍补码之前,先简单介绍一下计算机内部使用的二进制。理解了计算机内部使用的二进制,下面来看看原码、反码和补码的官方定义
这样的定义绝对正确,但是也会让你一头雾水,其实我们可以通过画几个图把这些概念简单阐述清楚。为了方便说明问题,我们假定用4位二进制数表示一个整数(一般32位电脑上主流C语言编译器用32位表示一个整数,不过原理都是一致的)
下图说明了无符号数的原码表示方法。其中内圈的数字为二进制数,外圈的数字为内圈二进制数所对应的十进制数

图3-2:无符号数的原码
顾名思义,无符号数不能表示负数。为了解决这个问题,我们把最高位定义为符号位。如果最高位为1就代表是一个负数,其他三位表示对应的十进制数

图3-3:有符号数的原码
用原码来表示一个有符号数会带来两个问题:
可见,原码不适合用来表示有符号数
为了保证正负相加等于0,我们采用反码的表示方法,反码的表示如图3-4所示。如果把二进制数1000想象成12点,把二进制数0000想象成6点,原码就是从12点开始顺时针排列-1到-7,而反码就是从6点开始逆时针排列-1到-7。这样做的好处就在于现在正负数相加等于0了。例如1+(-1),就是0001+1110=1111,用反码表示的话就是(-0)了

图3-4:有符号数的反码
第一个问题解决了,但是第二个问题没有解决。在用反码表示有符号数的图3-4中,依然有两个零存在,分别为0000和1111。补码就是为了解决这个问题而发明的
按照补码定义,-1的反码为1110,不过现在必须在末位加1,那现在-1就是1111了。以此类推,补码的表示如图3-5。与反码表示不同的是,补码在负数上从-1表示到-8,-0不再存在

图3-5:有符号数的补码
虽然第二个问题解决了,但用补码表示时,正负相加还等于0吗?可以验证一下,如果丢弃最高位的进位,结果满足正负相加等于0。至此,找到了终极解决方案,那就是用补码来表示一个有符号的整数。注意,对于无符号数,原码、反码、补码都是一致的。所以我们得到的结论是:整型数在计算机中,使用补码表示
如果第一节你已经完全明白,那么下面我们开始讨论在实际编写程序中非常危险的一个bug,那就是C语言的整型数溢出问题。有了前一节基础,下面讨论的溢出问题会比较容易理解
首先,我们知道,有符号数在计算机内部都通过补码来表示。其次,在图3-5中,如果加上x,就代表着顺时针走x格;如果减去x,就代表这逆时针走x格。有了这些知识,让我们用实例说话。在图3-5中,如果3+1,那就是从3顺时针走一格,等于4,每一任何问题。但是如果是7+1呢?顺时针走1格后,变成-8了。如果7+7呢,顺时针走7格,等于-2了。这就是整型数发生了溢出
所谓的溢出,就是因为4位二进制数,用补码表示一个整数的时候,所能表示的最大正整数就是(41)2- -1=7,如果在7的基础上再加1,就会发生“轻微溢出”,变为了最小的那个负数。如果两个最大的正整数相加,就会发生“严重溢出”,结果等于-2。这里注意,所谓的“轻微溢出”和“严重溢出”都是从溢出的角度去定义的。事实上,它们都是非常严重的bug,在实际编程中并不是说“严重溢出”就比“轻微溢出”更严重
那么,C语言中int所表示的“最大正整数”到底是多少呢?不同的平台,不同编译器,会有不同定义。为此,C语言在头文件limits.h中给出了相关的宏定义,如表3-1所示
表3-1 整型数的极限值宏定义

有了这些宏定义,可以编写出程序3-1,程序中分别演示了加法带来的溢出和减法带来的溢出
// 程序3-1 溢出的实例
printf("INT_MAX = %d\n", INT_MAX);
printf("INT_MAX + 1 = %d\n", INIT_MAX + 1);
printf("INT_MAX + INT_MAX = %d\n", INT_MAX + INT_MAX);
printf("--------------------------------------\n");
printf("INT_MIN = %d\n", INT_MIN);
printf("INT_MIN - 1 = %d\n", INT_MIN - 1);
printf("INT_MIN + INT_MIN = %d\n", INT_MIN + INT_MIN);程序最后的运行结果如图3-6所示,最大的正整数为

图3-6 程序运行结果
通过第二节,我们已经初步知道了造成溢出的主要原因,但是只知道原因不够。下面我们对溢出的现象进行深入分析。本节主要说明4个子问题:溢出的定义,溢出发生的边界,溢出的危害以及如何避免溢出
关于有符号整型数的溢出,程序3-1已经阐述得很清楚了。但是对于无符号数,《C陷阱与缺陷》在“整数溢出”一节中支出,在无符号算术运算中,没有所谓“溢出”一说。我想Koenig的思路可能是这样,当下午1点时,没有任何人会说:“现在是12点溢出了。”因为,我们已经常识性地知道,我们的中标上是没有13这个数字的。但是在C语言中,你能常识性地马上知道UINT_MAX是多少吗?下面我们看程序3-2。程序3-2中a是一个无符号整型数,运行这个程序后,会打印出“0”
// 程序3-2 无符号整型数溢出的例子
unsigned a = UINT_MAX;
printf("%u", a + 1);假设现在你每个月赚UINT_MAX,由于你的工作出色,老板决定下一个月给你涨一块钱。根据这段程序,你下一个月工资将会是0元。这个时候,如果老板说无符号算术运算中没有“溢出”的话,你会同意吗?所以我决定不再讨论“溢出”是否有符号,而是从工程的角度来看,如果经过程序运算和经过纸和笔运算的结果不一样,那么我们就认为整型数运算发生了溢出。除了运算的溢出,C语言中还有很多其他类型的溢出
图3-7和图3-8分别演示了有符号数和无符号数中的两种溢出,分别为上溢出和下溢出。上溢出是由于顺时针方向旋转(加法)造成的。下溢出是由于逆时针方向旋转(减法)造成的。对于无符号数,溢出发生的地方在6点钟方向,如图3-7所示;而对于有符号数,溢出的边界在12点方向,如图3-8所示

图3-7 无符号数的溢出边界

图3-8 有符号数的溢出边界
溢出最令人沮丧的地方在于,C语言不通过运行时检查来避免溢出,所以程序3-2会正常运行,没有任何提示和运行错误
当我学习C语言这门课时,邻座一美女同学给我递纸条,纸条上写着程序3-3。我看了一眼对她说:“你没有考虑溢出啊!如果love溢出,会变成一个最小的负数。”然后我把纸条还给美女同学。从此,她再也没有联系过我。直到现在,我依然后悔只是关注了love的溢出,而没有关注time的溢出
// 程序3-3 程序员的求爱信
while (time++)
{
love++;
}溢出的危害如此严重,那为什么C语言不去避免这种错误呢?这符合C语言的两个风格:信任程序员、只要快。因为C语言信任你,所以编译器认为你写出UINT_MAX + 1是对的,没有运行时检查是为了保证速度。
实际上我根本没学过C语言课,完全自学成才。学成后写了一个十万行的程序,送给我暗恋的女孩。实际编写工程的时候,溢出发生的概率和美女递纸条概率一样,都不高。避免溢出的一个最根本原则就是了解你要处理问题的规模。如果要处理一个班的学生,你完全可以忽略溢出问题;但如果要处理全世界人口,那就要注意了
一个比较简单的避免溢出的方法就是利用double数据类型。一般情况下,让double溢出还真是件比较困难的事。如果要求一定要用整型数来完成任务,那么首先应该利用表3-1中的宏定义,了解一下C语言在你所用平台上的极限值,并且利用这些宏定义和程序3-4中列出的判断表达式来避免溢出发生。请注意,程序3-4中的判断表达式有的时候代表溢出会发生,有的时候代表溢出已经发生
// 程序3-4 避免溢出的技巧
if ((unsigned)(a) > INT_MAX) // 有符号正数a上溢出了
if ((unsigned)(a) <= INT_MAX) // 有符号负数a下溢出了
if (a > INT_MAX - b) // 有符号数加法a + b会发生上溢出
if (a < INT_MIN - b) // 有符号数减法a - b会发生下溢出
if (a + b < a) // 无符号数加法已经发生上溢出
if (a < b) // 无符号数减法a - b会发生下溢出前面已经介绍了整数的二进制表示方法,对于n位二进制数,也就只能表示个整数。一个顺其自然的想法就是用这个整数的一半来表示正数,另外一半表示负数。这种表示方法就是有符号数
但是在实际应用中,有很多情况不会出现负数。比如年龄、一个班级的课程数等。如果用有符号数来保存这些值,那么永远不会用到表示负数的那一半范围,这样就被白白浪费,而且还使得正数的表示范围被占用了一半
针对这个问题,C语言中引入了无符号数的概念。无符号数的源码、反码、补码都一致,具体表示方法第一节中介绍了。对于n位二进制数,表示0到个范围内的整数
注意,无符号这个特性,只适用于整型数,而不适用于浮点数。同时我们一定要注意在表达式中混用有符号和无符号数的情况。这是因为C语言的表达式中会发生一种比较神奇的隐式数据类型转换,这种隐式的数据转换会带来一些隐含错误,如程序3-5中所示
// 程序3-5 无符号整型数溢出实例
int Sum(int a[], unsigned length)
{
int i = 0;
int sum = 0;
for (i = 0; i <= length - 1; ++i)
{
sum += a[i];
}
return sum;
}
if (-1 > 0U)
{
printf("???");
}程序3-5中的Sum函数是一段非常中规中矩的程序,每个细节都考虑得很好。数组的长度length不可能是负的,所以声明为unsigned。在程序3-5的第4行表达式i <= length - 1中,由于length是一个无符号整数,整个表达式length - 1最后的结果也被隐式转换为无符号类型。这样,当length = 0时,整个表达式变为0U - 1U,在图3-7中,下溢出正好对应这种情况。最后结果是当length = 0时,表达式length - 1值是最大的无符号数UINT_MAX,这样for循环就会执行UINT_MAX次,因为它会因为执行非法的内存访问而被操作系统一脚踢出
与此类似的一个情况如程序3-5中的第9~11行所示,这段程序会打印出“???”。因为在表达式中会发生隐式类型转换,所以-1被转换成了无符号类型。别忘了,-1的二进制表示(全部二进制位都是1)在无符号整数中被定义为UINT_MAX
无符号数据类型另外一个主要隐含错误来源于sizeof,在第九节会给出另外一个实例。本来无符号数是为了能扩大其表示的范围,但却带来了很多的隐含错误,有点得不偿失。所以,尽量不要在你的程序中使用无符号类型,以免增加不必要的复杂性,尤其不要仅仅因为无符号类型不存在负数而使用它来表示一个数值。随着计算机机器字长从32位过渡到64位,一个有符号数据类型int所表示的范围已经很大了,另外还有long long类型。用int数据类型好处在于,当设计混合类型操作(比如比较有符号数和无符号数)的时候,我们不必担心上面描述的边界溢出情况(比如-1会被提升为一个非常大的正数)。我的建议是:避免在一个表达式中混合使用无符号数和有符号数
如果你一定要用,最好在表达式中使用强制类型转换,使它们同时为有符号数或无符号数,精确告诉编译器你想干什么,别让编译器隐式地替你拿主意
目前,无符号数多用在位段以及位操作上。在位操作中,有时需要逻辑移位,而不是数学移位,这时必须用无符号数
整数类型有long,在本书发表时期,大部分个人计算机都是32位。在这样的计算机上,long的长度为32位,short是16位。如果你觉得位数比较少,你可以使用一个long long类型,这个类型是64位。C语言默认有一个short short类型(8位),那就是char
千言万语一句话,那就是char就是一个8位整数。如果你还是纠结于char这个名字,就干脆把它想成short short吧!那char这个名字从何而来?其实这要拜ACSII码所赐。ACSII码规定用8位二进制数对256个字符进行编码。所以,这种8位二进制数的整型数据类型就叫作char了。从这个意义上说,程序3-6中的前3行正确且合理,就是看起来有点别扭罢了。我们可以把char赋值给一个int,这是安全的;反之,就要冒数据被丢失的风险。毕竟要老鼠去住大象的房间是安全的,但是如果要把大象塞到冰箱里,却不太容易
// 程序3-6 char与int互换
int i = 'a';
char c = 97;
i = c; // 安全
c = i; // 不安全,数据可能丢失如果short short类型存在,那么它一定有符号,除非你用unsigned来修饰。但是char到底有符号还是无符号呢?答案是有时候有符号,有时候无符号。别忘了,C语言有很多编译器,每种编译器对char的符号都有自己的定义。任何先入为主的假设都有风险
当要在不同平台运行程序时,字符是否有符号的这种歧义性质会带来麻烦。如果移植性的要求很高,那么你就需要确保你的字符变量中保存的值的范围在0~127之间。这样无论字符类型是否有符号,都可以正确地表示这个范围之内的值
再看一种提高移植性的方法,先查看一下函数getchar()的参考文档,官方的定义:int getchar()。有些人会疑惑,getchar()明明返回一个字符,为什么用一个int来收呢。因为getchar()在读到文件末尾或错误时,会返回EOF,EOF在stdio.h中被定义为-1。如果你的平台上char恰好无符号,程序3-7将永远不会停止。如果getchar()返回一个int型,同时我们在程序3-7中将第1行修改为int c;,这样,无论char是否有符号,程序3-7都可以通过判断c是否等于EOF来终止了
// 程序3-7 getchar的返回值
char c; // danger
while((c = getchar()) != EOF)
{
...
}有没有一个办法确定,在我自己的计算机上,char到底有符号还是无符号呢?表3-1中有两个宏定义,分别为CHAR_MIN和CHAR_MAX
如果printf("%d", CHAR_MIN);输出的是-128,那么说明,在你的本地计算机上,char是有符号的;如果printf("%d", CHAR_MIN);输出的是0,那么说明,在你的本地计算机上,char无符号
赵岩.C语言点滴[M].2013/10