慎用unsigned类型

1,529 阅读9分钟

这篇文章我会从计算机基础的角度向大家解释 unsigned 类型 和 signed 类型在内存中到底是如何存储的,以及为什么要慎用 unsigned 类型。

我是站在 iOS 开发角度写的这篇文章,所以会出现一些OC代码,如果你看不懂的话可以跳过这些部分。

在OC中我们一般会使用 NSUInteger 而非 unsigned 表示无符号整数,但其实它们是一样的,NSUInteger 只是 unsigned 的类型别名而已;NSObjCRuntime.h 文件中有关于 NSUInteger 的详细定义。

#if __LP64__ || TARGET_OS_WIN32 || NS_BUILD_32_LIKE_64
typedef unsigned long NSUInteger;
#else
typedef unsigned int NSUInteger;
#endif

正文

- (void)reversePrintObjectWithArray:(NSArray *)array {
    if (![array isKindOfClass:NSArray.class]) return;
    
    for (NSUInteger i = array.count - 1; i >= 0; i--) {
        NSLog(@"第%lu个元素: %@", i, array[i]);
    }
}

上面这个方法只是简单的倒序打印数组中的每个元素,而且在打印前也做了类型检查和判空操作。但是它还是有问题,你能看出来吗?

当你传递的参数是 nil、非 NSArray 类型,或者非空数组时,一切都正常;但如果你不小心传递了一个空数组,将会导致崩溃,崩溃原因是数组访问越界,越界的索引是 18446744073709551615(如果在32位环境下,会是4294967295)。你也可以把这段代码粘贴到你的项目中,测试一下看看结果是否和我说的一样。

???是不是觉得条件都不符合,循环应该一次都没执行,还有 18446744073709551615 是哪来的?

其实造成这一切的根源在于 计算机对于正数和负数的读写方式不一样


下面我将用C代码来帮助大家理解 计算机是如何读写正数和负数

short a = -18;
unsigned short b = 65518;

printf("a: %hd, %hu\n", a, a);
printf("b: %hd, %hu\n", b, b);

运行结果

计算机规定整数必须以补码形式进行存储,如果你想弄明白为什么,请阅读题外话(为什么要使用补码)

注意:只有负数需要计算补码,正数的反码、补码其实和原码一样。

变量a的补码计算过程如下:

-18
= 10010(18的二进制)
= 0000 0000 0001 0010(short 类型占用 2 * 8 = 16 个比特位,所以前面需要补0)
= 1000 0000 0001 0010(计算机规定,有符号数的第1个比特位用于存储符号位,1表示负
数,0表示正数;这就是-18的原码)
= 1111 1111 1110 1101(这是-18的反码,反码就是把除符号位之外的所有数据取反)
= 1111 1111 1110 1110(这是-18的补码,补码是在反码的基础上加1)

变量a实际存储在内存中的数据不是原码 1000 0000 0001 0010 而是补码 1111 1111 1110 1110;在使用 %hd 打印的时候,计算机会把内存中的数据当作补码处理,所以需要先将它还原成原码 1000 0000 0001 0010,然后把原码转为十进制,所以输出的是 -18。

当你使用 %hu 去打印变量a的时候,计算机会把这块内存的数据解释为正数,还记得之前提到的正数的补码和原码一样吗,计算机会把内存中的数据直接当作原码处理, 1111 1111 1110 1110 转为十进制正好就是 65518 。

看完变量a的讲解后,你是否能手动验证变量b的结果呢?

变量b的原码是 1111 1111 1110 1110,由于它是正数,所以补码就等于原码;在使用 %hu 打印的时候,计算机会把内存中的数据直接当成原码处理,所以会输出 65518。

如果使用 %hd 打印的话,计算机会把内存中的数据当做补码,补码转为原码是 1000 0000 0001 0010,发现了吗,这不就是 -18 的原码吗。

发现了吗,65518 的补码/原码正好和 -18 的补码一样(简单的说,在内存中 65518 和 -18 的数据是一样的);有时候现实中2个完全不同的数可能在内存中的表现会一模一样,这也是为什么大部分情况下二进制无法还原回高级语言的原因。

以变量a为例,不管它的类型是 short 还是 unsigned short,都不会影响 -18 这个值在内存中的存储方式,在读取变量a的值时,计算机首先找到变量a的地址,然后根据类型得到它的长度,short 的长度是 2 * 8 = 16 个比特位,从变量a的地址开始,往后16个比特位就是变量a在内存中存储的数据,使用不同的符号打印(%hu、%hd)会影响计算机对这块内存的解释,但不会影响内存中的数据。


回到开头那个数组越界的问题,18446744073709551615 其实就是把 -1 当作无符号数读取而来的。

由于 NSUInteger 实际是 long 类型,占用 8 * 8 = 64 个比特位,-1 的原码是 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001,补码是 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111,由于 NSUInteger 是无符号数,所以计算机会把补码当成原码使用,转为十进制正好就是 18446744073709551615 。

解决起来也很简单,把 NSUIntegerNSInteger 代替就行了。

总结

在项目开发过程中,除了返回值、参数等特殊地方,建议统一使用有符号数(NSInteger)代替无符号数(NSUInteger),这有如下好处:

  1. 提高代码的统一性和兼容性。
  2. 避免不同数字类型之间的转换问题,减少代码复杂度,降低代码BUG率。

如果必须要使用无符号数,例如返回值、参数等,切记一定要在使用前检查数据正确性,例如使用强转判断值是否正确。

// 无符号数作为方法参数
- (void)fun:(NSUInteger)index {
    if ((NSInteger)index < 0) return;
    
    ......
}

// 无符号数作为返回值
NSInteger _count;
- (NSUInteger)count {
    return _count < 0 ? 0 : (NSUInteger)_count;
}

像文章中的这种问题属于数据异常,大部分情况下数据异常远比崩溃还要严重。

数据异常可能会给用户或公司带来巨大损失;假设你开发一个计算器软件,把值算错了大概率会导致非常严重的后果和损失,如果让程序崩溃或死机,至少客户会选择其他计算器或计算方式而不会造成后面更严重的损失。

另外数据异常这种问题通常很难发现,很容易成为线上BUG导致糟糕的用户体验(建议大家平常使用单元测试来对代码进行定期体检,这样能大大降低类似的这种错误)。

在Swift中,如果你对一个无符号类型(UInt)变量不小心赋值了负数的话,将会得到一个运行时崩溃,说明数据异常确实比APP崩溃更严重。

题外话(为什么要使用补码)

由于加法和减法这种操作非常频繁,为了提高运算效率,所以它们一般都由硬件直接支持;如果直接使用原码存储的话,在计算类似 6 - 18 这样的表达式时将会得到错误的结果:

6 - 18 = 6 + (-18)
= 0000 0000 0000 0110(原) + 1000 0000 0001 0010(原)
= 1000 0000 0001 1000(原)
= -24

很明显计算结果是不对的,于是有些人设计出了反码,用反码计算确实解决了 6 - 18 的问题,但是类似 18 - 6 这样的计算又出错了:

18 - 6 = 18 + (-6)
= 0000 0000 0001 0010(原) + 0000 0000 0000 0110(原)
= 0000 0000 0001 0010(反) + 1111 1111 1111 1001(反)
= 1 0000 0000 0000 1011(反) 
= 0000 0000 0000 1011(反) // 最左边的1内存容纳不了,所以直接截掉。
= 0000 0000 0000 1011(原)
= 11

计算结果离正确值还差1,如果按照反码计算的话,小数减去大数会正确,但大数减去小数就始终会相差1,还是那群人,又绞尽脑汁的设计出了补码(该设计者因此获得了图灵奖)。

18 - 6 = 18 + (-6)
= 0000 0000 0001 0010(原) + 0000 0000 0000 0110(原)
= 0000 0000 0001 0010(反) + 1111 1111 1111 1001(反)
= 0000 0000 0001 0010(补) + 1111 1111 1111 1010(补)
= 1 0000 0000 0000 1100(补)
= 0000 0000 0000 1100(补) // 最左边的1内存容纳不了,所以直接截掉。
= 0000 0000 0000 1100(反)
= 0000 0000 0000 1100(原)
= 12

结果终于正确了,如果大家感兴趣的话可以手动计算一下 13 - 55 - 13 等等来巩固你学到的知识。

计算机的设计是一门艺术,很多实用的技术都是权衡和妥协的结果。

如果你还是不理解的话,建议你看看 整数在内存中是如何存储的,为什么它堪称天才般的设计(这是一篇付费文章,如果你不想付费的话请自行百度搜索关键字)。

注意事项

不要把无符号数、有符号数和正数、负数搞混,无符号数肯定是正数,但有符号数未必是负数。

计算机是对正数和负数的读写规则不一样,不是对无符号数和有符号数的读写规则不一样。