🎄 12/25. 在 Perl 6 中, 0.1 + 0.2 背后的东西

欢迎来到今年 12/25 的 Perl 6 One-Liner Advent Calendar! 的第12天。今天,我们将研究一个计算零的单行程序。

1
say 0.1 + 0.2 - 0.3

如果您熟悉编程,那么您很清楚,只要开始使用浮点运算,就会失去精度,并且很快就会面对小错误。

您可能还看到了网站,0.30000000000000004.com,它有很多不同的编程语言列表,以及它们如何打印简单的表达式 0.1 + 0.2。在大多数情况下,您没有得到 0.3 这个准确值。通常当你得到零时,它实际上是在打印操作期间舍入的结果。

在 Perl 6 中,0.1 + 0.2正好是 0.3,今天的单行程序正好打印了一个零。

让我们稍微探讨一下 Perl 6 的内部结构,看看它是如何工作的。几天前,我们看到 Perl 6 的 grammar(在 Rakudo 编译器中实现)具有以下检测数字的片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
token numish {
[
| 'NaN' >>
| <integer>
| <dec_number>
| <rad_number>
| <rat_number>
| <complex_number>
| 'Inf' >>
| $<uinf>='∞'
| <unum=:No+:Nl>
]
}

假如你很熟悉 Perl 6,就会知道 Perl 6 使用有理数来存储浮点数(如0.1)这一事实可以解释上述行为。这是对的,但是看一下 grammar,你会发现这篇文章有点长。

Grammar 中所谓的 rat_number 是用尖括号写的数字:

1
2
3
4
5
token rat_number { '<' <bare_rat_number> '>' }
token bare_rat_number {
<?before <.[-−+0..9<>:boxd]>+? '/'>
<nu=.signed-integer> '/' <de=integer>
}

因此,如果您将程序更改为:

1
say <1/10> + <2/10> - <3/10>

那么你将立即操作有理数。以下是以此格式转换数字的相应action操作

1
2
3
4
5
6
7
8
9
10
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 标记:

1
2
3
4
5
6
7
8
9
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 操作方法相当复杂。让我告诉你。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 数。

现在我们跳过一些细节,并将看看另一个有理数的重要部分,你必须要了解它们。

在我们的示例中,整数和小数部分获得以下值:

1
2
3
$parti=1, $partf=10
$parti=2, $partf=10
$parti=3, $partf=10

如果您破解 Rakudo 文件的本地副本,您可以轻松地自己查看。

或者,在命令行中使用 --target=parse 选项:

1
$ perl6 --target=parse -e'say 0.1 + 0.2 - 0.3'

输出的一部分将包含我们想要查看的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- 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
2
> <1/10>.nude.say
(1 10)
1
2
> <2/10>.nude.say
(1 5)
1
2
3
4
> 0.2.nude.say
(1 5)
> 0.3.nude.say
(3 10)

如您所见,我们有 1/5,而不是 2/10,它代表相同的数字,具有相同的精度。使用数字时,您不必担心找到两个分数的公分母,例如:

1
2
> (0.1 + 0.2).nude.say
(3 10)

我希望今天在一个看似简单的单行程序背后没有太多的裸露。明天见到你与 Perl 6 有趣的另一部分!