深入理解 PHP 中的浮点型

我们都知道 PHP 是一个弱类型的动态脚本语言,所谓弱类型是指不仅在声明变量时不需要指定它的类型,且运算时也不做类型验证。比如可以将一个整型和浮点数类型做加法操作。

<?php
$x = 1; // $x 是整型
$y = 1.1; // $y 浮点数
var_dump($x);
var_dump($y);
$z = $x + $y; // $z 是浮点数
var_dump($z);
// output
int(1)
float(1.1)
float(2.1)

并且上面的代码也可以注意到,在变量声明时也无需指定变量的类型,说明赋值是最简单的隐式的类型转换。然后把整型和浮点数相加得到了另一个浮点数。

在 PHP 中,对于浮点数 float 同样也代表 double 双精度和 real 实数类型,在 php.net 上有如下关于浮点数精度的说明。

浮点数的精度问题

浮点数的精度有限。尽管取决于系统,PHP 通常使用 IEEE 754 双精度格式,则由于取整而导致的最大相对误差为 1.11e-16。非基本数学运算可能会给出更大误差,并且要考虑到进行复合运算时的误差传递。
此外,以十进制能够精确表示的有理数如 0.1 或 0.7,无论有多少尾数都不能被内部所使用的二进制精确表示,因此不能在不丢失一点点精度的情况下转换为二进制的格式。这就会造成混乱的结果:例如,floor((0.1+0.7)*10) 通常会返回 7 而不是预期中的 8,因为该结果内部的表示其实是类似 7.9999999999999991118…。

那么 0.1 和 0.7 为什么转换为二进制时会产生误差呢?我们先只看 0.1 转换成二进制就好。十进制数值转换为二进制数值可以通过下面的方法:

  • 整数部分:连续用该整数除以2,取余数,然后商再除以2,直到商等于0为止。然后把得到的各个余数按相反的顺序排列 - 简称”除2取余法”。
  • 小数部分:十进制小数转换为二进制小数,采用”乘2取整,顺序排列”法。用2乘以十进制小数,将得到的整数部分取出,再用2乘余下的小数部分,然后再将积的整数部分取出,如此进行,直到积中的小数部分为 0 或者达到所要求的精度为止。然后把取出的整数部分按顺序排列起来,即先取出的整数部分作为二进制小数的高位,后取出的整数部分作为低位有效位 - 简称”乘2取整法”。
  • 含有小数的十进制数转换成二进制,整数、小数部分分别进行转换,然后相加。

0.1 没有整数部分,所以只需要对小数部分做乘2取整。

运算 乘积 是否有整数
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.4 * 2 0.8 0
0.8 * 2 1.6 1
0.6 * 2 1.2 1
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.4 * 2 0.8 0

我们能看到最后永远重复 0011 的循环……

IEEE 754

PHP 浮点数使用 IEEE 754 二进制浮点数算术标准来存储。

sign:符号位(sign bit);
exponent:“指数部分”,即次高有效的e个比特,存储指数部分;
fraction:最后剩下的f个低有效位的比特,存储“有效数”(significand)的小数部分。

32位单精度

单精度二进制小数,使用32个比特存储。

1 8 23
S Exp Fraction
31 30至23 偏正值(实际的指数大小+127) 22至0位编号(从右边开始为0)

S为符号位,Exp为指数字,Fraction为有效数字。 指数部分即使用所谓的偏正值形式表示,偏正值为实际的指数大小与一个固定值(32位的情况是127)的和。采用这种方式表示的目的是简化比较。因为,指数的值可能为正也可能为负,如果采用补码表示的话,全体符号位S和Exp自身的符号位将导致不能简单的进行大小比较。正因为如此,指数部分通常采用一个无符号的正数值存储。单精度的指数部分是−126~+127加上偏移值127,指数值的大小从1~254(0和255是特殊值)。浮点小数计算时,指数值减去偏正值将是实际的指数大小。

64位双精度

双精度二进制小数,使用64个比特存储。

1 8 23
S Exp Fraction
63 62至52偏正值(实际的指数大小+1023) 51至0位编号(从右边开始为0)

S为符号位,Exp为指数字,Fraction为有效数字。指数部分即使用所谓的偏正值形式表示,偏正值为实际的指数大小与一个固定值(64位的情况是1023)的和。采用这种方式表示的目的是简化比较。因为,指数的值可能为正也可能为负,如果采用补码表示的话,全体符号位S和Exp自身的符号位将导致不能简单的进行大小比较。正因为如此,指数部分通常采用一个无符号的正数值存储。双精度的指数部分是−1022~+1023加上1023,指数值的大小从1~2046(0(2进位全为0)和2047(2进位全为1)是特殊值)。浮点小数计算时,指数值减去偏正值将是实际的指数大小。

参考IEEE 754 知道,对于单精度存储小数位至多保留23位。即 0.1 转换为二进制之后 0011 的无限循环只能保留23位,即使是双精度也只能保留52位,其余均要舍去。所以浮点数会出现精度的问题,转换结果可以根据浮点数转换工具查看。

十进制 => 二进制 => 十六进制 => 转换后浮点数
0.1 => 00111101110011001100110011001101 => 0x3dcccccd => 0.10000000149011612
0.7 => 00111111001100110011001100110011 => 0x3f333333 => 0.699999988079071

浮点数的范围和精度

浮点数计算公式如下:

sign * 2exponent * fraction

能看出浮点数的范围是由指数的位数来决定的。单精度的浮点数指数有8位,双精度有11位。于是,单精度的指数范围为-127~+128,而双精度的指数范围为-1023~+1024,并且指数位是按补码的形式来划分的。其中负指数决定了浮点数所能表达的绝对值最小的非零数;而正指数决定了浮点数所能表达的绝对值最大的数,也即决定了浮点数的取值范围。单精度的范围为-2128 ~ +2128,也即-3.40E+38 ~ +3.40E+38;双精度的范围为-21024 ~ +21024,也即-1.79E+308 ~ +1.79E+308。

浮点数的精度是由尾数的位数来决定的,且尾数是按科学计数法来存储的,如 9.2233720368548E+18。

  • 单精度:223 = 8388608,一共7位,这意味着最多能有7位有效数字,但绝对能保证的为6位,也即 float 的精度为6~7位有效数字;
  • 双精度:252 = 4503599627370496,一共16位,所以精度为15~16位。

整型溢出

如果给定的一个数超出了 integer 的范围,将会被解释为 float。同样如果执行的运算结果超出了 integer 范围,也会返回 float。

64 位系统下的整数溢出:

<?php
$large_number = 9223372036854775807;
var_dump($large_number); // int(9223372036854775807)
$large_number = 9223372036854775808;
var_dump($large_number); // float(9.2233720368548E+18)
$million = 1000000;
$large_number = 50000000000000 * $million;
var_dump($large_number); // float(5.0E+19)

整型转换为浮点数

如果整型数字的精度可以保留,则结果值是最接近原值的一个近似值,否则这个值不确定。下面代码即是精度不能保留的例子,能够看到最后的结果值是用科学计数法表示,且保留的精度是14位。

// 64位系统,PHP 5.6 / PHP 7
$int = 9223372036854775807;
$float = (float)$int;
var_dump($int);
var_dump($float);
output:
int(9223372036854775807)
float(9.2233720368548E+18) // 能够注意到保留的精度是 14 位

浮点数转换为整型

从浮点数转换成整数时,将向下取整。

向上取整:不管四舍五入的规则 只要后面有小数前面的整数就加1
向下取整:不管四舍五入的规则 只要后面有小数忽略小数

如果浮点数超出了整数范围(32 位平台下通常为 +/- 2.15e+9 = 231,64 位平台下通常为 +/- 9.22e+18 = 263),则结果为未定义,因为没有足够的精度给出一个确切的整数结果。在此情况下没有警告,甚至没有任何通知!

Warning : 决不要将未知的分数强制转换为 integer,这样有时会导致不可预料的结果。

<?php
echo (int) ( (0.1+0.7) * 10 ); // 显示 7!