PHP浮点数运算
在涉及到浮点数运算的时候,我们通常不会去深究细节,而是把它同整数运算做相同处理,认为它们和整数的区别只是多了个小数点而已。比如0.1+0.7等于0.8,我们理所当然地认为这样的运算结果是正确的。
然鹅,看似有穷的小数, 在计算机的二进制表示里却是无穷的。这就会导致浮点数的运算结果往往与我们的预期不符。失之毫厘,谬以千里!细节往往决定成败。下面我们来看看浮点数运算中可能会有那些问题以及产生这些问题背后的原因。
1、浮点数运算惹的祸
1 <?php 2 3 // 加 4 $a = 0.1; 5 $b = 0.7; 6 $c = intval(($a + $b) * 10); 7 echo $c."<br>"; 8 // 输出:7 9 10 // 减 11 $a = 100; 12 $b = 99.98; 13 $c = $a - $b; 14 echo $c."<br>"; 15 // 输出:0.019999999999996 16 17 // 乘 18 $a = 0.58; 19 $b = 100; 20 $c = intval($a * $b); 21 echo $c."<br>"; 22 //输出:57 23 24 // 除 25 $a = 0.7; 26 $b = 0.1; 27 $c = intval($a / $b); 28 echo $c."<br>"; 29 // 输出:6
其实这些结果都并非语言的 bug,但和语言的实现原理有关, js 所有数字统一为 Number, 包括整形实际上全都是双精度(double)类型。而PHP会区分 int 还是 float。不管什么语言,只要涉及浮点运算,都是存在类似的问题,使用时一定要注意。
要弄清楚浮点数运算为什么会出现这样的结果,首先我们要知道浮点数的表示(IEEE 754)。
2、 IEEE754标准
IEEE754是20世纪80年代以来最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用。这个标准定义了表示浮点数的格式。
我们现在在用的计算机基本上都是基于这个标准来表示浮点数的,包括我们熟悉的短浮点数(float)、长浮点数(double),它们俩的表示方法相同,区别仅仅是阶码E和位数M的位数不同。
1)浮点数的基本定义
浮点数的形式有点像我们熟悉的科学计数法,譬如12.34这个数,可以写成下面几种形式:
后面这三种形式都能表示12.34这个数字,尽管它们的小数点位置各不相同,但因为后面乘了不同的10的幂次方,因此最终结果一致。
浮点数IEEE 754的标准形式:
这个式子和各个字母的含义已经非常清晰了,直接对照上面12.34这个例子看就好。当然了,12.34这个例子举的是我们最熟悉的十进制,我们计算机中使用的当然是二进制。
其中,M为尾数,B为基数,E为阶码。B为阶码的底,E、M都用二进制数表示,M表示N的全部有效数字,E 指明小数点的位置。
如下图:
下面逐个来看:
a、第一个位置是数符,就是表整个数字正负的符号,即0和1
b、接着是阶码E,这里的阶码也有正负,并且不用真值来表示,通常会用阶码的真值加上一个偏移量,作为实际存储的偏移值。如在短浮点数float中,这个偏移量为127,即$2^7-1=1111111_{(2)}$
c、最后是尾数,这个部分为了提高精度,规定将原数尾数转化为1.xxxx的形式,以1为默认最高位,然后储存的时候并不储存最高位1,视其为隐藏的,只存储小数点后面的部分,这样可以使尾数表示的精度达到最高,即存储位数最多,比实际位数多一位。
我们再来看一下短浮点数(float)和长浮点数(double)在IEEE 754中各个部分的位数:
3、浮点数无法精确到最后一位
PHP 官方手册解释如下:
浮点数的精度有限。尽管取决于系统,PHP 通常使用 IEEE 754 双精度格式,则由于取整而导致的最大相对误差为 1.11e-16。非基本数学运算可能会给出更大误差,并且要考虑到进行复合运算时的误差传递。永远不要相信浮点数结果精确到了最后一位,也永远不要比较两个浮点数是否相等。如果确实需要更高的精度,应该使用任意精度数学函数或者 gmp 函数。
这里的关键在于,浮点数的小数用二进制的表示,转换过程如下:
a. 将小数乘以2,取整数部分表示第一位;
b. 将小数部分乘以2,取整数部分表示第二位;
c. 再将小数部分乘以2,取整数部分表示第三位;
... 依次类推,直到小数部分为0;
例:0.58
0.58 * 2 = 1.16 ---> 1
0.16 * 2 = 0.32 ---> 0
0.32 * 2 = 0.64 ---> 0
0.64 * 2 = 1.28 ---> 1
0.28 * 2 = 0.56 ---> 0
0.56 * 2 = 1.12 ---> 1
0.12 * 2 = 0.24 ---> 0
0.24 * 2 = 0.48 ---> 0
0.48 * 2 = 0.96 ---> 0
0.96 * 2 = 1.92 ---> 1
我们会得到一个无限循环的二进制小数:
0.1001010001...
小数部分出现循环,有限的二进制位无法准确的表示一个小数,这也就是小数运算出现误差的原因。
1 // 0.58 对于二进制表示来说, 是无限长的值(下面的数字省掉了隐含的1).. 2 3 // 0.58的二进制表示基本上(53位)是: 4 0010100011110101110000101000111101011100001010001111 5 // 0.57的二进制表示基本上(53位)是: 6 0010001111010111000010100011110101110000101000111101 7 8 // 而两者的二进制, 如果只是通过这53位计算的话,分别是: 9 10 0.58 -> 0.57999999999999996 11 0.57 -> 0.56999999999999995
我们就模糊的以心算来看... 0.58 * 100 = 57.999999999
再intval一下, 自然就是57了....
4、防坑攻略
1) 通过乘100的方式转化为整数加减,然后在除以100转化回来……
2) 使用number_format转化成字符串,然后在使用(float)强转回来……
3) php提供了高精度计算的函数库,实际上就是为了解决这个浮点数计算问题而生的。
4、任意精度数学函数
对于任意精度的数学,PHP 提供了支持用字符串表示的任意大小和精度的数字的二进制计算。
BCMath:BC 是 Binary Calculator 的缩写。
官方手册:http://php.net/manual/zh/book.bc.php
大家在使用前,请先确认是否已安装 bcmath。
1 <?php 2 3 // 加 4 $a = 0.1; 5 $b = 0.7; 6 $c = intval(bcadd($a, $b, 1) * 10); 7 echo $c."<br>"; 8 // 输出:8 9 10 // 减 11 $a = 100; 12 $b = 99.98; 13 $c = bcsub($a, $b, 2); 14 echo $c."<br>"; 15 // 输出:0.02 16 17 // 乘 18 $a = 0.58; 19 $b = 100; 20 $c = intval(bcmul($a, $b)); 21 echo $c."<br>"; 22 // 输出:58 23 24 // 除 25 $a = 0.7; 26 $b = 0.1; 27 $c = intval(bcdiv($a, $b)); 28 echo $c."<br>"; 29 // 输出:7
除了加减乘除,bcmath 还提供了以下方法:
- bccomp 比较两个任意精度的数字
- bcmod 对一个任意精度数字取模
- bcpow 任意精度数字的乘方
- bcpowmod 高精度数字乘方求模
- bcscale 设置所有bc数学函数的默认小数点保留位数
- bcsqrt 任意精度数字的二次方根
下面把 常用的BC函数封装下:
1 <?php 2 3 /** 4 * BC Math 函数示例 5 * Class BCCalculate 6 */ 7 class BCCalculate 8 { 9 private $leftNumber;// 左操作数 10 private $rightNumber;// 右操作数 11 12 public function __construct($leftNumber, $rightNumber) 13 { 14 $this->leftNumber = $leftNumber; 15 $this->rightNumber = $rightNumber; 16 $this->setScale(); 17 } 18 19 /** 20 * 设置数字 21 * @param $name 22 * @param $value 23 * @return null 24 */ 25 public function __set($name, $value) 26 { 27 if (!isset($this->$name)) { 28 return null; 29 } 30 31 $this->$name = $value; 32 } 33 34 /** 35 * 获取数字 36 * @param $name 37 * @return null 38 */ 39 public function __get($name) 40 { 41 if (isset($this->$name)) { 42 return $this->$name; 43 } else { 44 return null; 45 } 46 } 47 48 /** 49 * 执行方法 50 * @param $functionName 51 * @param string $arguments 52 * @return null 53 */ 54 public function __call($functionName, $arguments) 55 { 56 if (!method_exists($this, $functionName)) { 57 return null; 58 } 59 60 // 设置小数点位数需要参数,其他不需要 61 if (isset($arguments[0])) { 62 return $this->$functionName($arguments[0]); 63 } 64 65 return $this->$functionName(); 66 } 67 68 /** 69 * 设置所有bc数学函数的默认小数点保留位数 70 * http://php.net/manual/zh/function.bcscale.php 71 * @param int $scale 72 */ 73 private function setScale($scale = 2) 74 { 75 bcscale($scale); 76 } 77 78 79 /** 80 * 2个任意精度数字的加法计算 81 * http://php.net/manual/zh/function.bcadd.php 82 * @return string 83 */ 84 private function add() 85 { 86 return bcadd($this->leftNumber, $this->rightNumber); 87 } 88 89 /** 90 * 2个任意精度数字的减法 91 * http://php.net/manual/zh/function.bcsub.php 92 * @return string 93 */ 94 private function sub() 95 { 96 return bcsub($this->leftNumber, $this->rightNumber); 97 } 98 99 /** 100 * 2个任意精度数字乘法计算 101 * http://php.net/manual/zh/function.bcmul.php 102 * @return string 103 */ 104 private function mul() 105 { 106 return bcmul($this->leftNumber, $this->rightNumber); 107 } 108 109 /** 110 * 2个任意精度的数字除法计算 111 * http://php.net/manual/zh/function.bcdiv.php 112 * @return string 113 */ 114 private function div() 115 { 116 return bcdiv($this->leftNumber, $this->rightNumber); 117 } 118 119 /** 120 * 比较两个任意精度的数字 121 * 相等返回 0 ;左大于右返回 1 ;右大于左返回 -1 122 * http://php.net/manual/zh/function.bccomp.php 123 * @return int 124 */ 125 private function comp() 126 { 127 return bccomp($this->leftNumber, $this->rightNumber); 128 } 129 130 /** 131 * 对一个任意精度数字取模 132 * http://php.net/manual/zh/function.bcmod.php 133 * @return string 134 */ 135 private function mod() 136 { 137 return bcmod($this->leftNumber, $this->rightNumber); 138 } 139 140 /** 141 * 任意精度数字的乘方 142 * http://php.net/manual/zh/function.bcpow.php 143 * @return string 144 */ 145 private function pow() 146 { 147 return bcpow($this->leftNumber, $this->rightNumber); 148 } 149 150 /** 151 * 任意精度数字的二次方根 152 * http://php.net/manual/zh/function.bcsqrt.php 153 * @return string 154 */ 155 private function sqrt() 156 { 157 return bcsqrt($this->leftNumber); 158 } 159 } 160 161 $bc = new BCCalculate(3.45, 5.61); 162 163 var_dump($bc->leftNumber);// 获取数字 float(3.45) 164 echo '<br />'; 165 $bc->leftNumber = 24.08; 166 var_dump($bc->leftNumber);// 修改数字 float(24.08) 167 echo '<br />'; 168 var_dump($bc->add());// 注意返回值是字符串 string(5) "29.69" 169 echo '<br />'; 170 $bc->setScale(3);// 修改小数点后位数 171 var_dump($bc->sub());// string(6) "18.470" 172 echo '<br />'; 173 var_dump($bc->mul());// string(7) "135.088" 174 echo '<br />'; 175 var_dump($bc->div());// string(5) "4.292" 176 echo '<br />'; 177 var_dump($bc->comp());// int(1) 178 echo '<br />'; 179 $bc->leftNumber = 10; 180 $bc->rightNumber = 4; 181 var_dump($bc->mod());// string(1) "2" 182 echo '<br />'; 183 var_dump($bc->pow());// string(5) "10000" 184 echo '<br />'; 185 $bc->leftNumber = 16; 186 var_dump($bc->sqrt());// string(5) "4.000" 187 echo '<br />';
5、扩展-MySQL 浮点型字段
在 MySQL 中,创建表字段时也有浮点数类型。
浮点数类型包括单精度浮点数(float)和双精度浮点数(double)。
同理,不建议使用浮点数类型!!!
浮点数存在误差,当我们使用精度敏感的数据时,应该使用定点数(decimal)进行存储。
参考链接:
https://segmentfault.com/a/1190000024485146
https://cloud.tencent.com/developer/article/1437501