PHP 是一种弱类型语言,这样的特性,必然要求有无缝透明的隐式类型转换,PHP 内部使用 zval 来保存任意类型的数值,zval 的结构如下(5.2为例):
struct _zval_struct {
/* Variable information */
zvalue_value value; /* value */
zend_unit refcount;
zend_uchar type;
zend_uchar is_ref;
};
上面的结构中,实际保存数值本身的是 zvalue_value 联合体:
typedef union _zvalue_value {
long lval; /* long value */
double dval; /* double value */
struct {
char *val;
int len;
} str;
HashTable *ht; /* hash table value */
zend_object_value obj;
} zvalue_value;
今天的话题,我们只关注其中的两个成员,lval 和 dval。我们要意识到,long lval 是随着编译器,OS 的字长不同而不定长的,它有可能是 32bits 或者 64bits,而 double dval(双精度)由 IEEE 754 规定,是定长的,一定是 64bits。
请记住这一点,造就了 PHP 的一些代码的「非平台无关性」。我们接下来的讨论,除了特别指明,都假设 long 为 64bits。
IEEE 754 的浮点计数法,这里就不引用了,大家有兴趣可以自己查看。关键一点是,double 的尾数采用 52 位 bit 来保存,算上隐藏的 1 位有效位,一共是 53bits。
在这里,引出一个很有意思的问题,我们用 C 代码来举例(假设 long 为 64bits):
long a = x;
assert(a == (long)(double)a);
请问,a 的取值在什么范围内的时候,上面的代码可以断言成功?(留在文章最后解答)
现在我们回归正题,PHP 在执行一个脚本之前,首先要读入脚本,分析脚本,这个过程也包含着,对脚本中的字面量进行 zval 化,比如对于如下脚本:
$a = 9223372036854775807; // 64位有符号数最大值
$b = 9223372036854775808; // 最大值 +1
var_dump($a);
var_dump($b);
输出:
int(9223372036854775807)
float(9.22337203685E+18)
也就是说,PHP 在词法分析阶段,对于一个字面量的数值,会去判断。如果超出了当前系统的 long 的表值范围,就用 dval 来表示,zval 为 IS_FLOAT;否则用 lval 来表示,zval 为 IS_LONG。
凡是对于最大的整数值的数值,我们都要小心,因为它有可能会有精度损失:
$a = 9223372036854775807;
$b = 9223372036854775808;
var_dump($a === ($b - 1));
输出是 false。
现在接上开头的讨论,之前说过,PHP 的整数,可能是 32 位,也可能是 64 位,这就决定了,一些在 64 位上可以正常运行的代码,可能会因为隐式的类型转换,发生精度丢失,从而造成代码不能正常的运行在 32 位系统上。
所以,我们一定要警惕这个临界值,好在 PHP 中已经定义了这个临界值:
echo PHP_INT_MAX;
当然,为了保险起见,我们应该使用字符串来保存大整数,并且采用比如 bcmath 这样的数学函数库来进行计算。
另外,还有一个关键的配置,会让我们产生迷惑。这个配置就是 php.precision,它决定了 PHP 在输出一个 float 值的时候,输出多少有效位。
最后,我们再来回头看看上面提出的问题,也就是一个 long 的整数,最大的值是多少,才能保证转到 float 以后再转回 long 不会发生精度丢失?
比如,对于整数,我们知道它的二进制表示是 101,现在,让我们右移两位,变成 1.01,舍去高位的隐含的有效位,我们得到在 double 中存储 5 的二进制数值为:
0/*符号位*/ 10000000001/*指数位*/ 0100000000000000000000000000000000000000000000000000
5 的二进制表示,丝毫未损的保存在了尾数部分,在这个情况下,从 double 转回 long,不会发生精度丢失。
我们知道 double 用 52 位表示尾数,算上隐含的首位 1,一共是 53 位精度。那么也就可以得出结论,如果一个 long 的整数,值小于:
2^53 - 1 == 9007199254740991; //牢记, 我们现在假设是64bits的long
在进行 long -> double -> long 的数值转换时,不会发生精度损失。
接下来我们来看另外一个问题:
$f = 0.58;
var_dump(intval($f * 100)); // 为啥输出是 57
相信很多同学有过这样的疑问。为什么输出是 57 啊?PHP 的 bug 么?
要搞明白这个原因,还是要先知道浮点数的表示,上面我们介绍了。
浮点数,以 64 位的长度(双精度)为例,会采用 1 位符号位(E),11 位指数位(Q),52 位尾数(M)表示,一共 64 位。
符号位:最高位表示数据的正负,0 表示正数,1 表示负数。
指数位:表示数据以 2 为底的幂,指数采用偏移码表示。
尾数:表示数据小数点后的有效数字。
这里的关键点就在于,小数的二进制表示。关于小数如何用二进制表示,大家可以百度下,这里不再赘述。我们只要了解,0.58 对于二进制来说,是无限长的(下面的数字省略掉了隐含的 1)。
0.58 的二进制表示基本上(52位)是: 0010100011110101110000101000111101011100001010001111
0.57 的二进制表示基本上(52位)是: 0010001111010111000010100011110101110000101000111101
而两者的二进制,如果只是通过这 52 位计算的话,分别是:
0.58 -> 0.57999999999999996
0.57 -> 0.56999999999999995
至于 0.58*100 的具体浮点数乘算,我们不考虑那么细,有兴趣的可以看 Floating point,我们就模糊的以心算来看,0.58*100=57.999999999,那你 intval 一下,自然就是 57 了。
可见,这个问题的关键点就是:「你看似有穷的小数,在计算机的二进制表示里却是无穷的」。
类似的,floor((0.1+0.7)*10) 通常会返回 7 而不是预期中的 8 ,因为该结果内部的表示其实是类似 7.9999999999999991118... 。
所以永远不要相信浮点数结果精确到了最后一位,也永远不要比较两个浮点数是否相等。如果确实需要更高的精度,应该使用 任意精度数学函数 或者 gmp函数。