0.8-0.7 != 0.7-0.6

456 阅读6分钟

在进行大数运算时,遇到了运算精度不够的问题,稍作了一下整理

0.8-0.7 != 0.7-0.6原因 二进制表示十进制数造成的精度缺失,从而导致计算出现误差;至于精度缺失的原因就要从二进制如何表示小数说起:

十进制小数转化二进制

乘2至无小数

举个例子

初始值(取整) 变换后(x2) 二进制部分
0.8 1.6 0.1
0.6 1.2 0.11
0.2 0.4 0.110
0.4 0.8 0.110
死循环

找到了原因,接下来该解决问题:

  • 首先想到的就是放大数据,用整数来表示小数,就不会有精度缺失了,
  • 实际上Java本身就提供了BigDecimal类来处理大数运算,我们可以将数值转换为BigDecimal对象,调用它的方法进行高精度运算

BigDecimal源码

  1. 首先看一下它存储了哪些数据,从中可以猜测该类也是通过,缩放数值来实现精确运算的;
 private final BigInteger intVal;// 未缩放的值(即真实值)
 private final int scale;// 缩放比例  真实值 = 整数化的值 / (10^scale)
 private transient int precision;//数值的位数
 private final transient long intCompact;//数值(如果数值大于Long.MAX_VALUE,值为Long.MIN_VALUE)
  1. 新建一个BigDecimal对象,构造方法可以传入多种类型的数据,一般建议传入string类型的值来防止数据精度的损失,尤其是public BigDecimal(double val)源码中有一段注释it is generally recommended that the {@linkplain #BigDecimal(String)<tt>String</tt> constructor} be used in preference to this one大致意思是建议使用string类型参数代替double类型参数,原因显然是double类型带来的精度丢失
  2. 接下来研究下构造函数,BigDecimal(String val)最终调用的方法是BigDecimal(str.toCharArray(),0,val.length(),MathContext.UNLIMITED),以下是我对改构造函数的简化
public void BigDecimal(char[] in, int offset, int len, MathContext mc) {
        int prec = 0;//数值位数(不计数值前面的0)
        int scl = 0;//放大比例  真实值 = 记录的数值
        long rs = 0;//数值
        BigInteger rb = null;//传入数值长度超过18,会使用改对象存储数据
        // 取符号位
        boolean isneg = false;          // assume positive
        if (in[offset] == '-') {
            isneg = true;               // leading minus means negative
            offset++;
            len--;
        } else if (in[offset] == '+') { // leading + allowed
            offset++;
            len--;
        }
        // 取数值
        boolean dot = false;//是否到小数部分
        long exp = 0;//指数
        char c;
        boolean isCompact = len <= 18;
        int idx = 0;
        if (isCompact) {// 有足够的空间计算
            for (; len > 0; offset++, len--) {
                c = in[offset];
                if ((c == '0')) {
                    if (prec == 0) prec = 1;
                    else if (rs != 0) {
                        rs *= 10;
                        ++prec;
                    }
                    if (dot) ++scl;//记录小数位数
                } else if (c >= '1' && c <= '9') {
                    int digit = c - '0';
                    if (prec != 1 || rs != 0) ++prec;
                    rs = rs * 10 + digit;
                    if (dot) ++scl;
                } else if (c == '.') {//之后是小数位
                    if (dot) throw new NumberFormatException();//存在两个及以上的小数点
                    dot = true;
                } else if (Character.isDigit(c)) {//判断char是否为数值
                    //尝试转换字符为数字
                } else if ((c == 'e') || (c == 'E')) {
                    exp = parseExp(in, offset, len);//指数计算
                    if ((int) exp != exp) // overflow
                        throw new NumberFormatException();
                    break;//指数计算完成,跳出循环
                } else {
                    throw new NumberFormatException();
                }
            }
            if (prec == 0)//无数值
                throw new NumberFormatException();
            if (exp != 0) scl = adjustScale(scl, exp);// scl和exp抵消
            rs = isneg ? -rs : rs;
            int mcp = mc.getPrecision();//获取精度位数
            int drop = prec - mcp;
            if (mcp > 0 && drop > 0) {
                while (drop > 0) {
                    // 检查缩放位数是否超出限制
                    scl = checkScaleNonZero((long) scl - drop);
                    // 三个参数一次为:数值,10^drop(表示精确到哪一位) , 舍入模式对应的常量,返回结果为舍入后的值
                    rs = divideAndRound(rs, LONG_TEN_POWERS_TABLE[drop], mc.roundingMode.oldMode);
                    // 计算舍入后数值的位数
                    prec = longDigitLength(rs);
                    //再次计算需要丢弃的位数,直至符合精度要求
                    drop = prec - mcp;
                }
            }else {
                // 省略,逻辑相似,但考虑到数值超int上限,使用char[]存储数值,
                // 完成后将char[]转为BigInteget类型
            }
        }
        // 赋值
        this.scale = scl;
        this.precision = prec;
        this.intCompact = rs;
        this.intVal = rb;
    }
  1. 刚才的构造函数中有一个参数MathContext没有解释,字面上看是指数学上下文,应该是存储些运算相关的环境变量,在BigDecimal中我们用到了两个,一个是精度precision(即参与运算的最大位数),一个是舍入模式roundingMode,BigDecimal默认的上下文是MathContext.UNLIMITED:银行家舍入,无限精度;简单介绍下Java中的舍入模式

    1. ROUND_UP

    向远离零的方向舍入。舍弃非零部分,并将非零舍弃部分相邻的一位数字加一。

    1. ROUND_DOWN

    向接近零的方向舍入。舍弃非零部分,同时不会非零舍弃部分相邻的一位数字加一,采取截取行为。

    1. ROUND_CEILING

    向正无穷的方向舍入。如果为正数,舍入结果同ROUND_UP一致;如果为负数,舍入结果同ROUND_DOWN一致。注意:此模式不会减少数值大小。

    1. ROUND_FLOOR

    向负无穷的方向舍入。如果为正数,舍入结果同ROUND_DOWN一致;如果为负数,舍入结果同ROUND_UP一致。注意:此模式不会增加数值大小。

    1. ROUND_HALF_UP

    向“最接近”的数字舍入,如果与两个相邻数字的距离相等,则为向上舍入的舍入模式。如果舍弃部分>= 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同。这种模式也就是我们常说的我们的“四舍五入”。

    1. ROUND_HALF_DOWN

    向“最接近”的数字舍入,如果与两个相邻数字的距离相等,则为向下舍入的舍入模式。如果舍弃部分> 0.5,则舍入行为与ROUND_UP相同;否则舍入行为与ROUND_DOWN相同。这种模式也就是我们常说的我们的“五舍六入”。

    1. ROUND_HALF_EVEN

    向“最接近”的数字舍入,如果与两个相邻数字的距离相等,则相邻的偶数舍入。如果舍弃部分左边的数字奇数,则舍入行为与 ROUND_HALF_UP 相同;如果为偶数,则舍入行为与 ROUND_HALF_DOWN 相同。注意:在重复进行一系列计算时,此舍入模式可以将累加错误减到最小。此舍入模式也称为“银行家舍入法”,主要在美国使用。四舍六入,五分两种情况,如果前一位为奇数,则入位,否则舍去。

    1. ROUND_UNNECESSARY

    断言请求的操作具有精确的结果,因此不需要舍入。如果对获得精确结果的操作指定此舍入模式,则抛出ArithmeticException

    PS: Java默认的Math.round()实现的是四舍五入,而.net,和python3中的运算包的round()方法默认实现的银行家舍入,使用新语言的时候感觉需要注意下他的舍入函数的舍入模式;避免采坑.

  2. 了解了BigDecimal的数据构造过程,如何运算就显而易见了, 详情自己看源码吧;

    1. 加减法,将两个数的scale放大至相同进行加减运算
    2. 乘除法,数值部分乘除运算,scale部分加减运算