查看原文
其他

【老万】我看编程(二):int有几个字节?

老万 老万故事会 2021-01-30

(图片来自chinaso.com)


程序员十有八九听过这个段子:一程序员夜里12点街头独行,被警察拦下盘问:“int 类型有几个字节?”程序员一惊:“4个!”警察:“你可以走了。”程序员好奇:“你为什么问我这个?”警察:“深夜流落街头,穿得又这么寒伧,不是小偷就是程序员!”

 

呜呜呜,我当个程序员碍着谁了,编段子这样黑我们?


(图片来自qhimg.com)


做为如假包换的程序员,我必须指出:这个段子有bug,还不止一个。所以,一点也不好笑!

 

首先,“int 有几个字节”这种问题本身就不成立。先得加个限定条件“在什么编程语言里面”,要知道不同的语言给 int 留的大小经常是不同的。即便是同一种语言,不同的硬件平台和编译器版本,这个尺寸都可能不一样。比如 Java 规定 int 是 4 个字节,而 C 和 C++ 语言里面,int 有 4 个字节的,有 2 个字节的,甚至不排除有 1 个或 8 个字节的,全看编译器的设计而定。因为,C 语言的设计宗旨不是可移植性第一,而是希望和机器的硬件高度契合,程序跑得快,所以通常 C 编译器都会把 int 的尺寸选得和机器的硬件字长一致。警察问出“int 类型有几个字节”这样的外行问题情有可原,毕竟抓坏蛋一般不需要会写代码,但是一个人回答“4个”,明显是假程序员。起码,是程序员中的次品。

 

第二个 bug,得从“不是小偷就是程序员”说起。把这句话换成计算机术语,就是“是小偷”和“是程序员”二者必有一个是真的,也就是说这两种情况是“或”的关系。那么问题来了,在这个前提下,如果“是程序员”的话,能得出“不是小偷”的结论吗?明显不能嘛,因为这两者不是互斥的:谁规定的程序员不能也是小偷?做为警察,逻辑糊涂至此,不知道会放走多少邪恶的犯罪分子。想到这里,你还笑得起来吗?


(图片来自sinaimg.cn)


我来把这两个 bugs 修好吧:一小偷夜里12点街头独行,被警察拦下盘问:“int 类型有几个字节?”小偷一听窃喜:这个段子我看过,难不倒我。“4个!”警察哈哈一笑:“小样!跟我走一趟吧!”小偷急了:“这不是标准答案吗?你到底上不上网!”警察:“就你这样还好意思说自己是程序员?告诉你,我看过‘老万故事会’公众号,你蒙不了我。int 类型有多大,跟‘美女的胸有多大’一样,完全因情况而异,一定不能搞一刀切。这些知识,都是老万告诉我的。”小偷:“唉,这年月,不看老万公众号连小偷都不好当啊!”警察:“那可不!你得与时俱进啊。”二人越感慨越投缘,不由得四目相接十指紧扣,齐声诵道:“老万故事会,好看又不贵,实为居家旅行,杀人越货之不二选择。你今天关注了吗?”

 

你看,这是不是科学多了,好笑多了?哦啊哈哈哈哈哈哈哈~~~!我好像听见图灵、冯诺伊曼、高德纳纷纷发出一阵阵\a似的笑声。

 

以上是引子。

 

—————— 分割线 ——————


今天我要讲的故事和 int 的尺寸还真有一定的关系。和上次一样,这又是一个悲伤的故事:

 

有一次我在工作中碰到了一个很奇怪的 bug:用优化模式(optimized mode)编译的程序,不时会莫名其妙地崩溃。因为在优化方式下程序的机器码和源码不是一一对应的,从系统堆栈看不出崩溃发生在源程序的哪一行,所以我试着用调试模式(debug mode)进行编译,这样错误信息会更有帮助。诡异的是,我这么一搞,程序又不崩溃了。为什么呢?根据我多年的经验,这多半是程序做了编译器不允许它做的事,产生了无定义的行为(undefined behavior),编译器双手一摊说你干了不该干的事,责任自负。

 

什么叫无定义的行为?就是说一个语言的设计者规定在某些情况下程序不能做某些事情,相应的,解释器或编译器可以假设这个程序不会做这些事。如果做了,编译器可以爱怎么处理就怎么处理,程序接下来的行为有可能像疯子一样不可理喻,这就叫无定义行为

 

举几个常见的例子。比如说在 C++ 里面访问数组元素的时候,如果元素的编号超过了数组的大小就是一种无定义行为,编译器有权假设这种情况不会发生。打个比方:一栋大楼有20层,每层10套房。修大楼的人为了省钱,决定不修过道尽头的那堵墙(坑爹啊),反正没有人应该到那去不是吗?有天你要送快递到20层,不幸地址写错了,7号写成11号。你想都不想就往前跑,超过10号也不停。结果会怎么样呢?跟昭仓和唐塔一样,掉下去摔死了呗。而且你还没地儿说理,因为你在进楼前就签了一个协议:如果在楼里去了不该去的地方,后果自负。在程序里也差不多,数组越界了,有可能导致程序跑飞做出匪夷所思的事情来。更恶心的是,这些后果可能不是马上就会发生,而是在程序已经执行了很久以后,在你想象不到的时间和情况下出现错误,这时候距离错误源头已经相隔十万八千条指令了。再要找到错误的原因,难上加难。

 

还有一种常见的无定义行为是悬挂指针访问(dangling pointer dereference),就是说一个指针以前指向的是一个合法的对象,但是后来这个对象已经不存在了,下次通过这个指针就会访问到另一个对象,甚至是未被分配的内存。再打个比方:你的对象李小花住在幸福路3号。这个地址就是一个指针,你经常到那里去找你的对象玩耍。然而有一天小花跟人跑了,3号搬进来另外一位姑娘叫郭爱爱。由于种种原因,你没有更新地址簿,还是认为幸福路3号住的是你的对象,所以你还是跑到那里去,要访问房间里的姑娘。运气好的话,你歪打正着跟郭姑娘好上了,但是也可能运气不好被她的男朋友抓了现行打成生活不能自理。到底会出现哪种情况,事先无法预料,因为你的错误导致了无定义的行为。

 

看到这里,你是不是在想这个 C++ 的设计者是不是吃饱了撑的,尽搞些 bullshit 给程序员下套?你还别说,他的名字就叫 B.S.(全称Bjarne Stroustrup)。不过,无定义行为的存在是有用的:它允许编译器从一些假设出发,做更多的优化,让程序跑得更快。代价吗,就是程序员的担子重了,必须小心不要掉进这些坑里。

 

作为程序语言的设计者,经常需要在程序的执行效率和安全性之间做取舍。拿数组越界做例子,如果我们每次访问之前都预先将地址检查一下,看它是不是太大或者太小,如果超出了范围就直接报错返回,不就可以避免错误扩大化了吗?很多语言就是这么干的。但是每次数组访问都这么检查一下,有些性能要求高的程序表示吃不消。有没有可能既安全又高效呢?有聪明人想到可以在编译程序的时候对代码作一些分析(因为这个分析是在程序跑起来之前做的,所以叫做“静态分析”),如果有足够的证据证明在某段代码里面数组的下标绝对不可能越界,那么这段代码里面的越界检查就可以取消了,而且不影响安全性。不过当程序逻辑非常复杂的时候,往往既不能证明它一定会越界,也不能证明它一定不会越界。所以静态分析不是万能的。

 

还有一种做法是“动态分析”,也是让编译器在程序中插入一些新的指令,在程序运行时做一些验证,可以抓住更多常见的错误。比如,要知道会不会访问到未分配的地址,只要在每次内存访问之前都到系统的内存分配表中查找一下这个地址就可以了。当然,这样的代价就是程序的执行效率下降,所以不太可能在真正上线的业务程序里面长期这样干。不过,当我们怀疑系统有错误的时候,可以暂时用这种方式去运行、测试系统,争取在系统作出不当行为的时候马上发现问题。有一些工具就是干这个的,比如 Valgrind 和我歌开源的 AddressSanitizer 等。

 

因为怀疑系统出现了无定义行为,我特地用一些动态分析工具(包括 AddressSanitizer)重新编译系统然后执行。结果和我用调试模式编译一摸一样:程序又决定不崩溃了。一旦回到正常的优化模式,系统马上又开始崩溃。这种 bug,我们程序员亲切地叫它做“海森 bug” (Heisenbug),因为它和物理学上的海森堡测不准原理类似:你一试着找这个 bug,它就藏起来了;你一不盯着它,它又冒出来了。这种海森 bug 是最让程序员崩溃的。


(图片来自chinaimg.cn)


再好的工具,也不能自动找到全部的错误。无奈之下我只好把可能有问题的代码全部读一遍,逐行扫描。最后,终于锁定了一段嫌疑代码。简化后是这样的:

 

int margin = …;

...

int value = …;

if (value + margin <= threshold) {

  SetNewValue(value);

}

 

如果 value + margin 不超过上限的话,就接受 value,否则就忽略它。这段代码很平常啊,你看出问题了吗?

 

原来,int 类型是有尺寸限制的,只能表示一定范围内的整数值。如果运算的结果超出了这个范围,我们就说它“溢出”了。比如,在 int 是4个字节的情况下,只能表示从 -2^31 到 2^31 - 1 这些整数。那么,如果 value 是 2^31 - 1 而 margin 是 1,value + margin 的结果是什么呢?

 

如果你抢答 -2^31 的话,恭喜你!你的二进制算术还是学得不错的。然而这个答案是错误的。

 

什么?!这怎么错了?2^31 - 1 就是 0x7FFFFFFF,再加 1 就是 0x80000000,不就是代表 -2^31 吗?


(图片来自ablwang.com)


C++ 的设计者不是这么想的。他们认为,如果要担心带符号的整数运算溢出的话,很多优化就没法做了。比如这段来自 wikipedia 的例子:

 

int Foo(unsigned char x) {

     int value = 2147483600;  // 假定int是4个字节

     value += x;

     if (value < 2147483600)

        Bar();

     return value;

}

 

如果不考虑溢出的话,编译器可以认为 value < 2147483600 这个条件永远不会成立,从而可以把代码简化为:

 

int Foo(unsigned char x) {

     int value = 2147483600;

     return value + x;

}

 

似不似很爽?为了让编译器有自由做这样的优化,他们干脆规定带符号的整数运算如果溢出就算无定义行为,是程序员的责任,编译器干啥都可以。看,自由也是有代价的。这就是为什么我换一种模式编译程序就不崩溃,返回到优化模式系统又崩溃:这段程序在运行时出现了溢出,编译器决定这时在不同的模式下去做不同的事情,导致它的行为不可捉摸。不幸的是,C++ 里面这样的坑太多了,一不留神就会掉进去,摔得很惨。

 

值得提出的是,C++ 规定无符号的整数运算如果溢出的话,其结果是有定义的,不会出现无定义行为。这是不是很让你抓狂呢?

 

下一次你的程序出现不可解释的神秘故障的时候,很可能是出现了无定义行为。在用动态分析工具无效的情况下,不妨想一想:int 类型是几个字节?溢出了吗?

 



往期文章推荐:


我看代码审查(三):实战的细节

我的科大(一):爱情

淘书记之汤姆索亚

微信礼仪之八荣八耻




长按 - 识别二维码关注“老万故事会”公众号:


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存