Wait the light to fall

🎄 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 有趣的另一部分!