那天上
Python的第三节课,讲的是数据类型。为了活跃气氛,我让学生们在交互式命令行里随便算点东西。
没过两分钟,后排一个男生举手了,语气里带着一种发现了新大陆的兴奋:“老师,Python算错了!”
我走过去,他的屏幕上是这样一行:
>>> 0.1 + 0.20.30000000000000004
全班一下子炸了锅。有人不信,自己敲了一遍,结果一模一样;有人开始怀疑人生:“连电脑都不会算小学数学?”还有人当场下结论:“Python有bug,我们换C语言吧。”
我笑着让大家安静下来。我说,这不是Python的bug,换成C、Java、JavaScript,结果都差不多。这节课,我们就来当一回侦探,看看这个多出来的0.00000000000000004,到底是从哪儿冒出来的。
我在黑板上写了一个分数:1/3。
“谁能把它写成小数?”
“0.333333……”
“写完了吗?”
“写不完,无限循环。”
“如果只给你8位,你会写成多少?”
“0.33333333。”
“那三个0.33333333加起来,等于1吗?”
学生们算了算:0.99999999。差了一点点。
我说,好,记住这个感觉。在十进制里,1/3是一个“写不完”的数,只能截断,截断就有误差。电脑遇到的,是同一个问题,只不过它用的是二进制。
十进制里,只有分母是2和5的组合的分数,才能写成有限小数,比如1/2=0.5,1/4=0.25,1/5=0.2。
二进制里,规则更严格:只有分母是2的幂的分数,才能写成有限小数,比如1/2、1/4、1/8。
那0.1呢?0.1=1/10,分母里有个5,在二进制里就是一个无限循环小数:
0.0001100110011001100110011……
同样,0.2也是。
电脑存储小数,普遍采用一个叫IEEE 754的标准。Python里的float是“双精度”,总共64位,其中真正用来存有效数字的只有52位(加上隐含的一位,相当于53位)。无限循环的二进制小数,到了第53位就得“四舍五入”截断。
于是,电脑里存的0.1,其实不是0.1,而是一个非常非常接近0.1的数。
我让学生们用下面这行代码,把它的“真面目”打印出来:
>>> from decimal import Decimal>>> Decimal(0.1)Decimal('0.1000000000000000055511151231257827021181583404541015625')
屏幕上跳出一长串数字,教室里一片“哇”。原来,我们平时看到的0.1,只是Python在显示时帮我们“美化”过的结果。
两个都带着一点误差的数加在一起,误差也加在一起,结果就成了0.30000000000000004。而电脑里存的“0.3”,又是另一个带着误差的近似值,两者恰好不相等。所以:
>>> 0.1 + 0.2 == 0.3False
侦探破了案,接下来就是“怎么办”。我给学生们介绍了三种办法。
第一种,比较的时候别用“==”,而是看两个数是不是“足够接近”。Python的math模块里有现成的函数:
>>> import math>>> math.isclose(0.1 + 0.2, 0.3)True
第二种,需要精确十进制计算的场合,比如算钱,用decimal模块,并且用字符串来创建:
>>> Decimal('0.1') + Decimal('0.2')Decimal('0.3')
有个学生眼尖,问:“为什么前面Decimal(0.1)那么长,这里Decimal('0.1')就是准的?”
好问题。Decimal(0.1)是把一个已经带着误差的float原封不动地“翻译”过来;Decimal('0.1')是直接按十进制的“0.1”来存,从一开始就没有误差。差别就在那一对引号上。
第三种,干脆避开小数。比如金额,很多系统都是以“分”为单位,用整数来存,1.23元存成123分,整数运算没有这个烦恼。
下课前,我让大家写一句话,总结这节课学到了什么。
大部分同学写的是“浮点数有精度误差”“比较小数要用isclose”。
那个最先举手的男生,写的是:“电脑不是不会算,是它看世界的方式跟我们不一样。”
我把这句话念给全班听。教室里安静了一会儿,然后响起了掌声。
后来我常常想,这大概就是编程课最有意思的地方:一个看似“算错了”的结果,背后藏着计算机最底层的设计;而弄懂它的过程,也是在学着理解一种跟自己不一样的思维方式。
下次,如果你的程序里也冒出了一个奇怪的0.30000000000000004,别急着骂它。它只是在用二进制,诚实地告诉你:我已经尽力了。
苏嘉健,华南师范大学教育科学学院
声明:本文为 脚本之家专栏作者 投稿,未经允许请勿转载。