Cing
发布于 2022-04-16 / 321 阅读
0

关于计算精度

一、序

因为最近工作中遇到客户提出的 bug,所以有了这篇文章

具体的问题是:后端计算数值的精度问题

众所周知,计算机在计算时存在精度问题,对于金融等场景来说,如果丢失了精度将是一个非常严重的问题

我们从 Java 的角度来看看计算精度的问题

那背过八股文的我们都知道,Java 有 BigDecimal 来处理大数计算和保证精度问题,那为什么还有精度问题?难道我们的业务开发人员菜到连 BigDecimal 都没听说过吗?(手动狗头)

且听我细细道来

二、常见问题

1、溢出

溢出是最容易触发的问题

public class Sample {
    public static void main(String[] args) {
        int a = Integer.MAX_VALUE;
        System.out.println(a);
        System.out.println(a + 1);
    }
}

那么这段代码的输出是什么呢?

2147483647
-2147483648

为什么加了一反而变成了负数呢?

这和计算机使用补码表示负数有关,也和 int 所使用的内存地址范围有关

int 使用了 4 字节来存储数字,算一下最大可以表示 2 ^ 31 -1 那么超出这个区间的精度就丢失了

这个问题我们可以用 Java 提供的 BigDecimal 来解决,BigDecimal 提供了无限大的精度(相对而言,毕竟还有硬件的制约)

2、小数

ok,依然先看例子

public class Sample {
    public static void main(String[] args) {
        double a = 0.1, b = 0.2;
        System.out.println(a + b);
    }
}

很简单吧?学过小学数学的我们都知道答案是 0.3,那计算机总不能连这都算错吧?来看看结果

0.30000000000000004

惊喜吗?这是因为计算机使用二进制存储小数,十进制转二进制时出现了精度丢失

那解决方案同样很简单,使用 BigDecimal 来进行计算

public class Sample {
    public static void main(String[] args) {
        BigDecimal a = new BigDecimal("0.1").add(new BigDecimal("0.2"));
        System.out.println(a);
    }
}

那么到此为止我们已经解决了大数计算和小数精度问题

相信不少用 Java 朋友都看过阿里的开发手册,里面强调了金融计算要使用 BigDecimal,那我写这篇是为什么呢?

别急,我们继续看

3、乘除法顺序带来的精度问题

纳尼?乘除法顺序还有精度问题?

我们来看这个计算 6500 / 24 * 0.75 = 203.125

我们请出没有精度问题的 BigDecimal

public class Sample {
    public static void main(String[] args) {
        System.out.println(new BigDecimal("6500").divide(new BigDecimal("24"), 10, RoundingMode.HALF_UP).multiply(new BigDecimal("0.75")));
    }
}

我们看看计算结果

203.124999999975

什么???居然又算错了,接下来我们调整一下已计算顺序,先乘后除 6500 * 0.75 / 24 = 203.125

public class Sample {
    public static void main(String[] args) {
        System.out.println(new BigDecimal("6500").multiply(new BigDecimal("0.75")).divide(new BigDecimal("24")));
    }
}

再看看结果

203.125

没问题了,这是为什么呢???

原因是 6500 / 24 = 270.8333333333333... 无限循环,除法之后我们得到了一个无限循环小数,之后做乘法,自然而然我们又丢失了精度

若我们想保证结果的正确,最好将除法放在最后做,最终结果设定一个精度和舍入方式

三、关于调整计算顺序

刷过算法提的朋友或许了解如果想写一个简单的四则运算计算器,有一个方法叫做逆波兰表达式(后缀表达式),这种表达式非常适合栈的模式

我们通常写的计算式被称为中缀表达式,这个名字是怎么来的呢?因为算式在计算机中计算时会被解析成语法树,对这颗二叉树的遍历方式决定了名字,例如:中序遍历就叫做中缀表达式

我们可以调整同一层的乘除子树来调整计算顺序(未实践,感觉可行)

相关扩展:中缀转后缀算法(调度场算法) 通过调整调度场算法的乘除优先级应该可以调整计算顺序(未实践,感觉可行)

上面两种都是在查解决方案时搜集到的感觉可行但没有大量实践的方法,用在系统中令人不安

经过和同事们的讨论,获得了新的思路

四、解决方案

我们知道有理数是可以通过分数来表示的,例如 1 / 9 = 0.11111...,那我们只需要使用分数来计算,就可以在最后进行除法

恰好 Apache Commons Math 为我们提供了便利的分数计算工具

我们来看看新的代码

public class Sample {
    public static void main(String[] args) {
        BigFraction res = new BigFraction(6500).divide(new BigFraction(24)).multiply(new BigFraction(0.75));
        System.out.println(res);
        System.out.println(res.bigDecimalValue());
    }
}

看看结果

1625 / 8
203.125

成功将将除法放在最后做了

五、规则引擎解决方案

问题

有时候我们在业务中会使用规则引擎来辅助一些业务场景,例如:QLExpressaviatorscript

我们以阿里的 QLExpress 为例

public class Sample {
    public static void main(String[] args) throws Exception {
        ExpressRunner runner = new ExpressRunner();
        DefaultContext<String, Object> context = new DefaultContext<>();
        String express = "6500 / 24 * 0.75";
        Object r = runner.execute(express, context, null, true, false);
        System.out.println(r);
    }
}

看看结果

202.5

行叭,说好的不用担心精度问题呢?

细看文档

isPrecise

/**
 * 是否需要高精度计算
 */
private boolean isPrecise = false;

高精度计算在会计财务中非常重要,java的float、double、int、long存在很多隐式转换,做四则运算和比较的时候其实存在非常多的安全隐患。 所以类似汇金的系统中,会有很多BigDecimal转换代码。而使用QLExpress,你只要关注数学公式本身 订单总价 = 单价 * 数量 + 首重价格 + ( 总重量 - 首重) * 续重单价 ,然后设置这个属性即可,所有的中间运算过程都会保证不丢失精度。

原来要开启高精度计算呀,是我的问题,那我们浅开一下再试试吧

public class Sample {
    public static void main(String[] args) throws Exception {
        ExpressRunner runner = new ExpressRunner(true, false);
        DefaultContext<String, Object> context = new DefaultContext<>();
        String express = "6500 / 24 * 0.75";
        Object r = runner.execute(express, context, null, true, false);
        System.out.println(r);
    }
}

这样应该行了?

203.124999999975

哦漏,依然不行,其实深入源码能看到高精度计算就是在底层使用了 BigDecimal,根据前面的结论,计算过程中除法可能会丢失精度造成最终结果不正确

解决

我们前面看到可以用分数解决中间除法丢失精度的问题,那么再看看 QLExpress 的文档,其提供了运算符重载,那我们将运算符重载一下应该就可以了,尝试一下

public class Sample {

    static Object cal(Object[] list, BiFunction<Object, Object, Object> fun, BiFunction<Object, Object, Object> defFun) {
        Object op1 = list[0];
        Object op2 = list[1];
        if (op1 instanceof Number && op2 instanceof Number) {
            if (!(op1 instanceof BigFraction)) {
                op1 = new BigFraction(((Number) op1).doubleValue());
            }
            if (!(op2 instanceof BigFraction)) {
                op2 = new BigFraction(((Number) op2).doubleValue());
            }
            return fun.apply(op1, op2);
        } else {
            if (op1 instanceof BigFraction) {
                op1 = ((BigFraction) op1).bigDecimalValue();
            }
            if (op2 instanceof BigFraction) {
                op2 = ((BigFraction) op2).bigDecimalValue();
            }
        }
        return defFun.apply(op1, op2);
    }

    static class AddOp extends Operator {

        @Override
        public Object executeInner(Object[] list) {
            return cal(list, (op1, op2) -> ((BigFraction) op1).add((BigFraction) op2), (op1, op2) -> {
                try {
                    return OperatorOfNumber.add(op1, op2, this.isPrecise);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            });
        }
    }

    static class SubOp extends Operator {

        @Override
        public Object executeInner(Object[] list) {
            return cal(list, (op1, op2) -> ((BigFraction) op1).subtract((BigFraction) op2), (op1, op2) -> {
                try {
                    return OperatorOfNumber.subtract(op1, op2, this.isPrecise);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            });
        }
    }

    static class MultiOp extends Operator {

        @Override
        public Object executeInner(Object[] list) {
            return cal(list, (op1, op2) -> ((BigFraction) op1).multiply((BigFraction) op2), (op1, op2) -> {
                try {
                    return OperatorOfNumber.multiply(op1, op2, this.isPrecise);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            });
        }
    }

    static class DivideOp extends Operator {

        @Override
        public Object executeInner(Object[] list) {
            return cal(list, (op1, op2) -> ((BigFraction) op1).divide((BigFraction) op2), (op1, op2) -> {
                try {
                    return OperatorOfNumber.divide(op1, op2, this.isPrecise);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            });
        }
    }

    public static void main(String[] args) throws Exception {
        ExpressRunner runner = new ExpressRunner(true, false);

        runner.replaceOperator("+", new AddOp());
        runner.replaceOperator("-", new SubOp());
        runner.replaceOperator("*", new MultiOp());
        runner.replaceOperator("/", new DivideOp());

        DefaultContext<String, Object> context = new DefaultContext<>();
        String express = "6500 / 24 * 0.75";
        Object r = runner.execute(express, context, null, true, false);
        System.out.println(r);
    }
}

看看运行结果

1625 / 8
203.125

很好,符合我们的预期

六、一点题外话

为什么说金融计算要使用 BigDecimal 呢,不还是会有精度问题吗?我个人理解因为金融计算普遍是加减法,基本不会有乘除法,因此 BigDecimal 不会有精度问题

另外要注意前后端交互时数字(特别是小数)最好是用字符串来传输,数值传输到前端后可能又会由于 Js 的处理再次丢失精度