首页 > 编程知识 正文

老师你不知道吗6(如果你不知道名言是谁说的)

时间:2023-05-06 00:47:22 阅读:104120 作者:4000

《为什么是设计》是一系列关于计算机领域编程决策的文章。在本系列的每篇文章中,我们将提出一个具体的问题,并从不同的角度讨论这种设计的优缺点及其对具体实现的影响。

0.1 0.2=0.3似乎是理所当然的事情。然而,上一篇文章分析了为什么0.1 0.2=0.30000004在大多数编程语言中不成立。标准浮点数可以通过32位单精度浮点数或64位双精度浮点数来保证有限的精度。所有正确实现浮点数的编程语言都会遇到以下“错误”:

0 . 10 . 20 . 30000000000000004浮点数是编程语言中不可或缺的概念,需要在性能和精度之间做出取舍。过高的精度需要更多的位数和计算量,过低的精度不能满足常见的计算要求。这一重要决定将影响上千千的应用和服务。然而,这个决定需要面对的问题和软件工程中需要解决的问题之间没有太大的区别——如何尽可能多地做。

图1-性能和精度之间的权衡

虽然浮点数提供了相对优秀的性能,但是在金融系统中使用精度较低的浮点数会产生非常严重的后果。假设我们使用一个64位双精度浮点数来存储交易所或银行的账户余额。这时就有被用户攻击的可能。用户可以使用双精度浮点数的精度限制进行更多的平衡:

图2-金融系统和浮点数

当用户将0.1单位和0.2单位的资产分别充值到账户中,用双精度浮点数计算时,会得到0.300000000000000000000000000000000000000000000000000000000000000000000000000000用户可以通过提取所有这些资产获得0.00000000004的意外之财[1]。如果用户重复足够多次,他可以让银行破产。大家加油。这是一个使用浮点数的段落。

var余额float 64=0 func main(){存款(. 1)存款(. 2)如果余额,ok :=取款(0.300000000000000000000000000000000000000000000000000000000000000000000好的{fmt。Println(balance)}}func存款(v float64) {balance=v}func取款(v float64) (float64,bool){如果v=balance {balance -=vreturn v,True }返回0,false}以上代码只是一种理想情况。如今成熟的金融体系不可能~~~(其实也不一定)~ ~犯这样的低级错误,但这种可能性在一些新兴交易所还是存在的,但要真正落实以上操作还是很难的。如果我们能控制的资源是无限的,自然可以达到无限精度小数。然而,资源总是有限的。一些编程语言或库会通过以下两种方法提供更高精度的小数,以保证建立0.10.2=0.3的方程:

使用128位高精度定点或无限精度定点;采用有理数式和评分制,保证计算的准确性;这两种方法可以实现更高精度的十进制,但它们的原理略有不同。接下来,我们将分析它们的设计原理。

十进制小数

在许多情况下,浮点数的精度损失是由不同十进制系统的数据相关转换引起的。正如我们在文章《为什么0.10.2=0.30000004》中提到的,在二进制位数有限的十进制中,我们无法准确表示0.1和0.2,这就导致了精度损失,而这些精度损失最终可能会累积成很大的误差:

图3-二进制和十进制精度的损失

如下图所示,因为0.25和0.5的十进制小数都可以用二进制浮点数精确表示,所以用浮点数计算0.250.5的结果必须准确[2]:

图4-0.25和0.5的浮点表示

为了解决浮点数的精度问题,一些编程语言引入了Decimal decimal。如果编程语言没有本地分支,十进制在不同的社区中非常常见

持 Decimal,我们在开源社区也一定能够找到使用特定语言实现的 Decimal 库。Java 通过 BigDecimal 提供了无限精度的小数,该类中包含三个关键的成员变量 intVal、scale 和precision[^3]:

public class BigDecimal extends Number implements Comparable<BigDecimal> { private BigInteger intVal; private int scale; private int precision = 0; ...}

当我们使用 BigDecimal 表示 1234.56 时,BigDecimal 中的三个字段会分别以下的内容:

intVal 中存储的是去掉小数点后的全部数字,即 123456;scale 中存储的是小数的位数,即 2;prevision 中存储的是全部的有效位数,小数点前 4 位,小数点后 2 位,即6;

图 5 - BigDecimal 实现

BigDecimal 这种使用多个整数的方法避开了二进制无法准确表示部分十进制小数的问题,因为 BigInteger 可以使用数组表示任意长度的整数,所以如果机器的内存资源是无限的,BigDecimal 在理论上也可以表示无限精度的小数。

虽然部分编程语言实现了理论上无限精度的 BigDecimal,但是在实际应用中我们大多不需要无限的精度保证,C# 等编程语言通过 16 字节的 Decimal 提供的 28 ~ 29 位的精度,而在金融系统中使用 16 字节的 Decimal 一般就可以保证数据计算的准确性了[^4]。

有理数

使用 Decimal 和 BigDecimal 虽然可以在很大程度上解决浮点数的精度问题,但是它们在遇到无限小数时仍然无能为力,使用十进制的小数永远无法准确地表示 1/3,无论使用多少位小数都无法避免精度的损失:

图 6 - 无限小数的精度问题

当我们遇到这种情况时,使用有理数(Rational)是解决类似问题的最好方法,部分编程语言因为科学计算的需求会将有理数作为标准库的一部分,例如:舒心的柚子[^5] 和 Haskell[^6]。分数是有理数的重要组成部分,使用分数可以准确的表示 1/10、1/5和 1/3,舒心的柚子 作为科学计算中的常用编程语言,我们可以使用如下所示的方式表示分数:

鲜艳的秋天> 1//31//3鲜艳的秋天> numerator(1//3)1鲜艳的秋天> denominator(1//3)3

这种解决精度问题的方法更接近原始的数学公式,分数的分子和分母是有理数结构体中的两个变量,多个分数的加减乘除操作与数学中对分数的计算没有任何区别,自然也就不会造成精度的损失,我们可以简单了解一下 Java 中有理数的实现[^7]:

public class Rational implements Comparable<Rational> { private int num; // the numerator private int den; // the denominator public double toDouble() { return (double) num / den; } ...}

上述类中的 num 和 den 分别表示分数的分子和分母,它提供的 toDouble 方法可以将当前有理数转换成浮点数,因为浮点数在软件工程中虽然更加常用,当我们需要严密的科学计算时,可以使用有理数完成绝大多数的计算,并在最后转换回浮点数以减少可能出现的误差。

然而需要注意的是,这种使用有理数计算的方式不仅在使用上相对比较麻烦,它在性能上也无法与浮点数进行比较,一次常见的加减法就需要使用几倍于浮点数操作的汇编指令,所以在非必要的场景中一定要尽量避免。

总结

想要保证 0.1 + 0.2 = 0.3 这个公式的成立并不是一件复杂的事情,作者相信除了文中介绍的这些方案之外,我们还会有其他的实现方式,但是文中介绍的方案是最为常见的两种,我们再来回顾一下如何使 0.1 + 0.2 = 0.3 这个公式成立:

使用十进制的两个整数 — 整数值和指数表示有限精度或者无限精度的小数,一些编程语言使用 128 位的 Decimal 表示具有 28 ~ 29 位精度的数字,而一些编程语言使用 BigDecimal 表示无限精度的数字;使用十进制的两个整数 — 分子和分母表示准确的分数,可以减少浮点数计算带来的精度损失;

有理数和小数是数学中的概念,数学是一门非常严谨和精确的学科,通过引入大量的概念和符号,数学中的计算可以实现绝对的准确;但是软件工程作为一门工程,它需要在复杂的物理世界,利用有限的资源解决有限的问题,所以我们需要在多个方案之间做出权衡和选择,数学中的有理数和无理数其实都可以在软件中实现,但是在使用时一定要想清楚 — 为了得到这些我们牺牲了什么?到最后,我们还是来看一些比较开放的相关问题,有兴趣的读者可以仔细思考一下下面的问题:

你最常用的编程语言中小数的结构体是什么样的,包含了哪些字段?浮点数、小数和有理数三种不同的策略在加减乘除四则运算上的性能如何?

版权声明:该文观点仅代表作者本人。处理文章:请发送邮件至 三1五14八八95#扣扣.com 举报,一经查实,本站将立刻删除。