Python与浮点数

在周六参加 TDD Workshop 的时候, 遇到一个问题就是因为涉及到浮点数运算导致单元测试迟迟通不过- -. 回来就在这方面查了查

简单的例子

1
2
3
4
5
In [1]: 0.1 + 0.2 == 0.3
Out[1]: False
In [4]: round(2.675, 2)
Out[4]: 2.67

简单说这个就是因为浮点数的问题引起的, 也导致我们浮点数的单元测试没有通过.

关于浮点数

不管是什么数, 在计算机中最终都会被转化为 0 和 1 进行存储, 所以我们需要先弄明白以下几点问题

  • 一个小数如何转化为二进制
  • 浮点数的二进制如何存储

浮点数的二进制表示

首先我们要了解浮点数二进制表示, 有以下两个原则:

  • 整数部分对 2 取余然后逆序排列
  • 小数部分乘 2 取整数部分, 然后顺序排列

2.25 的二进制表示是?

整数部分的二进制表示为 10, 小数部分我们逐步来算
0.25 * 2 = 0.5 整数部分取 0
0.5 * 2 = 1.0 整数部分取 1
所以 2.25 的二进制表示为 10.01

0.1 的表示是什么?

我们继续按照浮点数的二进制表示来计算
0.1 * 2 = 0.2 整数部分取 0
0.2 * 2 = 0.4 整数部分取 0
0.4 * 2 = 0.8 整数部分取 0
0.8 * 2 = 1.6 整数部分取 1
0.6 * 2 = 1.2 整数部分取 1
0.2 * 2 = 0.4 整数部分取 0

所以你会发现, 0.1 的二进制表示是 0.00011001100110011001100110011……0011
0011作为二进制小数的循环节不断的进行循环.

这就引出了一个问题, 你永远不能存下 0.1 的二进制, 即使你把全世界的硬盘都放在一起, 也存不下 0.1 的二进制小数.

浮点数的二进制存储

Python 和 C 一样, 采用 IEEE 754 规范来存储浮点数. IEEE 754 对双精度浮点数的存储规范将 64 bit 分为 3 部分.

  • 第 1 bit 位用来存储 符号, 决定这个数是正数还是负数
  • 然后使用 11 bit 来存储指数部分
  • 剩下的 52 bit 用来存储尾数
    Double-precision_floating-point_format

而且可以指出的是, double 能存储的数的个数是有限的, double 能代表的数必然不超过 2^64 个, 那么现实世界上有多少个小数呢? 无限个. 计算机能做的只能是一个接近这个小数的值, 是这个值在一定精度下与逻辑认为的值相等. 换句话说, 每个小数的存储(但是不是所有的), 都会伴有精度的丢失.

浮点数计算的问题

现在我们可以看一开始提到的例子

0.1 + 0.2 == 0.3


0.1 在 Python 中真正的数字是 0.1000000000000000055511151231257827021181583404541015625
0.2 在 Python 中真正的数字是 0.200000000000000011102230246251565404236316680908203125
0.3 在 Python 中真正的数字是 0.299999999999999988897769753748434595763683319091796875

这就是为什么 0.1 + 0.2 != 0.3 的原因

round(2.675, 2)

1
2
In [4]: round(2.675, 2)
Out[4]: 2.67

为什么 2.675 精确两位小数之后不是 2.68 呢, 因为 2.675 在计算机中真正的数字是 2.67499999999999982236431605997495353221893310546875

坑啊坑.

我是如何遇到了这个问题

简单地说是因为我理解错了 decimal 这个模块的用法.
我一开始的使用方式是

1
2
In [14]: Decimal(2.675) * Decimal(1.2)
Out[14]: Decimal('3.209999999999999668043315637')

因为没有仔细看库手册导致的错误使用. 正确的用法是:

1
2
In [15]: Decimal('2.675') * Decimal('1.2')
Out[15]: Decimal('3.2100')

将字符串传入 Decimal, 而将数字直接传入, 它的效果是查看该数字在计算机中实际存储的数字.

decimal是如何实现的计算精准

我粗略的过了一下 decimal 这个库的源代码, 这个根据 General Decimal Arithmetic Specification 来设计, 简单地说就是将传入的字符串记录符号, 记录一个大数(整数和小数部分直接拼接而成), 记录小数点位置, 然后重写这个类的 operation进行实现.

参考

using-decimal-in-python
PEP327 Decimal Data Type
代码之谜(五)- 浮点数(谁偷了你的精度?)
Double-precision floating-point format
Floating Point Arithmetic: Issues and Limitations
IEEE 754
Decimal Code
General Decimal Arithmetic
Specification

字符串在 Python 2.x 和 3.x 下的适配

RQ 提交了一个 Pull Request 来解决 issue#437 .

最开始的提交 我做了以下的工作:

  • 找到问题所在
  • 写新的单元测试
  • 将相关串做 decode 操作
  • 跑单元测试通过, 提交 Pull Request

然后….就遇到问题了, Travis 跑完发现 Python2.x 下都没问题, 但是 Python3.x 都跑不过, 原因很简单, python3.x 的时候已经不区分 string 和 unicode, 统一采用 unicode, 因此也取消了 decode 方法. 那么我们如何来同时适配 2.x 和 3.x 版本呢. 我想过很多方法, 但是都觉得不够优雅, 后来还是在 RQ 这个库本身里找到了答案.

在 RQ 中, 对字符串的获取都会经过一个 as_text 的函数处理, 该函数位于 compat/__init__.py, 就是为了同时适配 2.x 和 3.x 版本, 函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
if not PY2:
# Python 3.x and up
text_type = str
string_types = (str,)
def as_text(v):
if v is None:
return None
elif isinstance(v, bytes):
return v.decode('utf-8')
elif isinstance(v, str):
return v
else:
raise ValueError('Unknown type %r' % type(v))
def decode_redis_hash(h):
return dict((as_text(k), h[k]) for k in h)
else:
# Python 2.x
text_type = unicode
string_types = (str, unicode)
def as_text(v):
if v is None:
return None
return v.decode('utf-8')
def decode_redis_hash(h):
return h

首先通过 six 这个库来判断 Python 版本, 然后根据版本的不同, 声明 as_text 方法的具体实现, 这样在整个库在处理字符串时, 不需要考虑版本差异, 直接调用 as_text 进行处理即可.

用了pyenv-virtualenv, 天黑都不怕

之前就有听大妈推荐过 pyenv. 最近给一个项目这个库提交 Pull Request, 但 Python3.X 的单元测试没有跑过, 而我的机器上没有 Python3.X, 也不想把现有的 Python2.7 替换掉, 所以就用起了这个库.

简单的说, pyenv 是一个Python管理工具, 这个是和我们常用的 virtualenv 有所不通, 前者是对 Python 的版本进行管理, 实现不同版本的切换和使用. 后者测试创建一个虚拟环境, 与系统环境以及其他 Python 环境隔离, 避免干扰.

安装方法我就不做赘述了, pyenv readme 已经写的特别详尽

pyenv使用方法

简单的说一下使用方法

安装不同版本的 Python

1
2
pyenv install <version> #安装特定版本的 Python
pyenv install 3.3.0 #安装 Python 3.3.0

当我的系统 Python 版本是 2.7, 但是有个 叫做 py3-project 需要用 Python3 来运行的时候, 只需要这样做:

1
2
3
4
cd py3-project #进入项目目录
pyenv local 3.3.0 #将当前目录下的Python环境切换为3.3.0
pyenv version #运行显示通过pyenv设置之后的python版本, 得到结果是3.3.0
python --version #查看Python版本, 得到结果也是3.3.0

此时就可以通过 python3.3 来运行项目了, 才这个项目之外的目录运行 Python, 你会发现仍然是系统版本. 通过pyenv可以给不同的目录设置不同的 Python 版本, 还可以通过 pyenv global 这个命令切换整个全局的 Python版本. 赞爆了是不是.

告别virtualenv

接下来, 再介绍一个工具, 配合pyenv, 让我告别了用了很久了virtualenv.这个工具叫做 pyenv-virtualenv, 安装方法依然跳过, 至于使用, 你只需要记住三条命令:

1
2
3
pyenv virtualenv 3.3.0 env #创建一个 Python 版本为 3.3.0 的环境, 环境叫做 env
pyenv activate env_name #激活 env 这个环境, 此时 Python 版本自动变为 3.3.0, 且是独立环境
pyenv deactivate #离开已经激活的环境

嗯, 写完这篇继续去修复那段 Python3.X 下通不过单元测试的程序.

再见了, virtualenv.