🎄 12/25. 在 Raku 中, 0.1 + 0.2 背后的东西
— 焉知非鱼欢迎来到今年 12/25 的 Raku One-Liner Advent Calendar! 的第12天。今天,我们将研究一个计算零的单行程序。
say 0.1 + 0.2 - 0.3
如果您熟悉编程,那么您很清楚,只要开始使用浮点运算,就会失去精度,并且很快就会面对小错误。
您可能还看到了网站,0.30000000000000004.com,它有很多不同的编程语言列表,以及它们如何打印简单的表达式 0.1 + 0.2
。在大多数情况下,您没有得到 0.3
这个准确值。通常当你得到零时,它实际上是在打印操作期间舍入的结果。
在 Raku 中,0.1 + 0.2
正好是 0.3
,今天的单行程序正好打印了一个零。
让我们稍微探讨一下 Raku 的内部结构,看看它是如何工作的。几天前,我们看到 Raku 的 grammar(在 Rakudo 编译器中实现)具有以下检测数字的片段:
token numish {
[
| 'NaN' >>
| <integer>
| <dec_number>
| <rad_number>
| <rat_number>
| <complex_number>
| 'Inf' >>
| $<uinf>='∞'
| <unum=:No+:Nl>
]
}
假如你很熟悉 Raku,就会知道 Raku 使用有理数来存储浮点数(如0.1)这一事实可以解释上述行为。这是对的,但是看一下 grammar,你会发现这篇文章有点长。
Grammar 中所谓的 rat_number
是用尖括号写的数字:
token rat_number { '<' <bare_rat_number> '>' }
token bare_rat_number {
<?before <.[-−+0..9<>:boxd]>+? '/'>
<nu=.signed-integer> '/' <de=integer>
}
因此,如果您将程序更改为:
say <1/10> + <2/10> - <3/10>
那么你将立即操作有理数。以下是以此格式转换数字的相应action操作:
method rat_number($/) { make $<bare_rat_number>.ast }
method bare_rat_number($/) {
my $nu := $<nu>.ast.compile_time_value;
my $de := $<de>.ast;
my $ast := $*W.add_constant(
'Rat', 'type_new', $nu, $de, :nocache(1));
$ast.node($/);
make $ast;
}
在某些时候,抽象语法树获得一个包含 Rat
类型常量的节点,其中 $nu
和 $de
部分为分子和分母。
在我们的示例中,使用以 0.1 的形式写入的数字,它们首先传递 dec_number
标记:
token dec_number {
:dba('decimal number')
[
| $<coeff> = [ '.' <frac=.decint> ] <escale>?
| $<coeff> = [ <int=.decint> '.' <frac=.decint> ]
<escale>?
| $<coeff> = [ <int=.decint> ] <escale>
]
}
数字的整数和小数部分进入最终 Match 对象的 <int>
和 <frac>
键。此 grammar 标记的 action 操作方法相当复杂。让我告诉你。
method dec_number($/) {
if $<escale> { # wants a Num
make $*W.add_numeric_constant: $/, 'Num', ~$/;
} else { # wants a Rat
my $Int := $*W.find_symbol(['Int']);
my $parti;
my $partf;
# we build up the number in parts
if nqp::chars($<int>) {
$parti := $<int>.ast;
} else {
$parti := nqp::box_i(0, $Int);
}
if nqp::chars($<frac>) {
$partf := nqp::radix_I(
10, $<frac>.Str, 0, 4, $Int);
$parti := nqp::mul_I($parti, $partf[1], $Int);
$parti := nqp::add_I($parti, $partf[0], $Int);
$partf := $partf[1];
} else {
$partf := nqp::box_i(1, $Int);
}
my $ast := $*W.add_constant(
'Rat', 'type_new', $parti, $partf,
:nocache(1));
$ast.node($/);
make $ast;
}
}
对于每个数字 0.1,0.2 和 0.3,上面的代码接收它们的整数和小数部分,准备两个整数,$parti
和 $partf
,并将它们传递给我们在 rat_number
action 中看到的新常量的相同构造函数。然后你得到一个 Rat
数。
现在我们跳过一些细节,并将看看另一个有理数的重要部分,你必须要了解它们。
在我们的示例中,整数和小数部分获得以下值:
$parti=1, $partf=10
$parti=2, $partf=10
$parti=3, $partf=10
如果您破解 Rakudo 文件的本地副本,您可以轻松地自己查看。
或者,在命令行中使用 --target=parse
选项:
$ raku --target=parse -e'say 0.1 + 0.2 - 0.3'
输出的一部分将包含我们想要查看的数据:
- 0: 0.1
- value: 0.1
- number: 0.1
- numish: 0.1
- dec_number: 0.1
- frac: 1
- int: 0
- coeff: 0.1
- 1: 0.2
- value: 0.2
- number: 0.2
- numish: 0.2
- dec_number: 0.2
- coeff: 0.2
- frac: 2
- int: 0
将数字表示为分数,很容易进行精确计算,这就是我们在输出中看到纯零的原因。
回到我们的分数。如果您打印分子和分母(例如,使用 nude
方法),您将看到如果可能,该分数被标准化:
> <1/10>.nude.say
(1 10)
> <2/10>.nude.say
(1 5)
> 0.2.nude.say
(1 5)
> 0.3.nude.say
(3 10)
如您所见,我们有 1/5,而不是 2/10,它代表相同的数字,具有相同的精度。使用数字时,您不必担心找到两个分数的公分母,例如:
> (0.1 + 0.2).nude.say
(3 10)
我希望今天在一个看似简单的单行程序背后没有太多的裸露。明天见到你与 Raku 有趣的另一部分!