rakulang, dartlang, nimlang, golang, rustlang, lang lang no see

为最年轻的 Raku 贡献力量

焉知非鱼

Contributing to Raku

在过去的几周里,我看到了一些文章视频,讲述了在开源软件中构思是多么的伟大。这次我又想起了另一篇旧文Raku 是我的 MMORPG。它说,你可以从以下几个方面受益于开源软件。比如说,可以做一个大侠,基于一些开源软件来写软件。作为写手,可以写博客、微博等,对所选软件产生兴趣。或者你可以成为一个法师 - 实现新的功能和修复 bug。今天小编就带着弓箭手来告诉大家如何成为 Raku 编程语言的法师。

选择一个任务 #

让我们挑选一些编译器的 bug,并修复它。让我们去 Rakudo 编译器 issues中选择我们想要修复的 bug。我滚动了一下 bug 列表,遇到了解析 - 运气不错,我前段时间一直在研究编译器语法,看了一本这方面的好书。找到了四个问题。

img

  1. 标签为 LTA (Less Than Awesome - 当真实行为与直觉预期不同时)- 我们暂时把它划掉。
  2. 标签 “需要共识” - 我们只想修复一个不复杂的 bug - 肯定要划掉。
  3. 标签为 “grammar and actions” 的关于一个可能死的代码是一个很好的候选人的第一个任务。

任务确定后,现在我们需要配置工作环境。在 Windows、Linux 和 macOS 中,一切应该都差不多。我将通过 macOS 的例子来告诉你。

建立工作环境 #

为源码和我们建立的编译器建立文件夹。

mkdir ~/dev-rakudo && mkdir ~/dev-rakudo-install

Rakudo 编译器由三部分组成。

  1. 虚拟机。现在有三种 - JVM、JS 和 MoarVM。我们以 MoarVM 为最稳定的一个。
  2. NQP(Not Quite Perl),是一种低级(中级)语言的实现,它是 Raku 的一个 “子集”。虚拟机可以执行用 NQP 编写的代码。
  3. Rakudo 编译器本身,用 NQP 和 Raku 编写。

下载并编译这三个组件。我分别花了一分半钟、半分钟和两分半钟才编好。

cd ~/dev-rakudo && git clone git@github.com:MoarVM/MoarVM.git && cd MoarVM
perl Configure.pl --prefix ~/dev-rakudo-install && make -j 4 && make install

cd ~/dev-rakudo && git clone git@github.com:Raku/nqp.git && cd nqp
perl Configure.pl --backend=moar --prefix ~/dev-rakudo-install && make -j 4 && make install

cd ~/dev-rakudo && git clone git@github.com:rakudo/rakudo.git && cd rakudo
perl Configure.pl --backend=moar --prefix ~/dev-rakudo-install && make -j 4 && make install

注意参数。--prefix 显示了 make install 命令后可执行文件的复制位置,--backend=moar 表示正在使用的虚拟机,而 -j 4 则要求跨多线程并行化(以防加快进度)。现在我们已经建立了 Rakudo 编译器 ~/dev-rakudo-install/bin/raku。我们还需要官方的编译器测试套件。你应该把它和它的代码一起放在文件夹里。

cd ~/dev-rakudo/rakudo && git clone https://github.com/Raku/roast.git t/spec

我们先进行测试。这种情况很常见,有些测试甚至在新的变化之前就失败了。我们需要辨别出来,这样以后就不会害怕这些变化破坏了一些不必要的东西。

这里和下面我将在 ~/dev-rakudo/rakudo 文件夹中工作,除非另有说明。

> make spectest
[...]
Test Summary Report
-------------------
t/spec/S32-str/utf8-c8.t    (Wstat: 65280 Tests: 54 Failed: 0)
  Non-zero exit status: 255
  Parse errors: Bad plan.  You planned 66 tests but ran 54.
Files=1346, Tests=117144, 829 wallclock secs (27.70 usr  6.04 sys + 2638.79 cusr 210.98 csys = 2883.51 CPU)
Result: FAIL
make: *** [m-spectest5] Error 1

14分钟内共运行了1,346个文件中的117,144次测试。一些与utf8相关的测试由于某种原因失败了,其他的都能正常工作。我们已经准备好去工作了!

让我们来看看问题的陈述 #

问题陈述说,某个元运算符 R 在 colonpair 上出了问题。我打开文档,搜索 R 这个词,但下拉列表中没有这个名字的元运算符。我试着输入 metaop,看到的是反向元操作符(R)。原来,如果你想把二元运算的操作数按相反的顺序写出来,你可以在其符号前使用前缀 R。

say 3 R- 2 == -1 # Output: True

Colonpair 是命名对的语法。它看起来就像名字前面有一个冒号,前面有一个括号,有一个值。例如 :foo(42) 是一个名称为 foo、值为 42 的对儿。这个语法通常用于在调用函数时,向函数传递一个命名参数中的值。

sub sub-with-named-parameter(:$foo) {
    say $foo;
}

sub-with-named-parameter(:foo(42)); # Output: 42

如果一个函数参数不是命名的,而是位置的,那么在用命名对调用时,就会出现编译错误。

sub sub-without-named-parameter($foo) { # <- 没有冒号
  say $foo;
}

sub-without-named-parameter(:foo(42)); # Unexpected named argument 'foo' passed

如果你在调用这样的函数时用括号包围一个参数,整个参数对将被传递到位置参数。

sub sub-without-named-parameter($foo) {
  say $foo;
}

sub-without-named-parameter((:foo(42))); # Output: foo => 42

在 Raku 中,你可以写一个函数来捕获所有传递给它的参数并分析它们。这是在单个参数 - 捕获前用竖线完成的。

sub sub-with-capture(|foo) { # <- 参数捕获
  say foo;
}

sub-with-capture(:foo(42));     # Output: \(:foo(42))
sub-with-capture(42);           # Output: \(42)
sub-with-capture(:foo(3 Z- 2)); # Output: \(:foo((1,).Seq))
sub-with-capture(:foo(3 R- 2)); # Output: \(-1)

倒数第二行使用了 Z 元操作符 - zip 操作符。它将左右两部分作为一个列表,按顺序每次从它们中抽取一个元素,并进行操作,从而形成一个序列。

在最后一行,只用了我们需要的 R 元操作符。在这种情况下,它不是一个对,而是一个常量,它被传递到函数中。我们可以假设这是元运算符工作方式的一些特殊性,但用 Z 的例子表明并非如此。其实这是一个 bug - 当一个对被传递到一个使用 R 元运算符的函数中时,它的值会被转换。

我们需要一个新的测试 #

为了确保未来的变化能够修复错误的行为,我们需要写一个新的测试。在测试文件中不难找到 R 元操作符的测试(S03-metops/reverse.t)。下面我将补充以下测试。

# https://github.com/rakudo/rakudo/issues/1632
{
  sub subroutine($expected, |actual) {
    is actual.gist, $expected, "Сolonpair exists"
  }

  subroutine('\(:foo(-1))', :foo(3 R- 2));
}

该测试有一个功能,有两个参数 - 正常和捕获。在函数体中,第一个参数和传递的 Capture 的字符串表示进行比较。你可以使用 make 对新构建的编译器进行单独测试。

> make t/spec/S03-metaops/reverse.t
[...]
ok 69 - [R~]=
not ok 70 - Colonpair exists
# Failed test 'Colonpair exists'
# at t/spec/S03-metaops/reverse.t line 191
# expected: '\(:foo(-1))'
#      got: '\(-1)'
# You planned 69 tests, but ran 70
# You failed 1 test of 70

你可以看到,测试失败了(如预期)。还有一个单独的说明,系统预计69次测试,但收到70次。这是基于 TAP 的测试系统的特点 - 必须在文件的顶部修正传递给 plan 函数的数字。现在测试崩溃了,但编号没有受到影响。你可以开始修复它。

凝视法 #

一开始我很相信任务上的标签 - 如果是解析的话,一定是源码解析阶段的某个地方出现了问题。目前我的认识如下:

  1. 基础解析器代码在文件 rakudo/src/Perl6/Grammar.nqp 中。
  2. 这个解析器是从 nqp/src/HLL/Grammar.nqp 文件中的基础解析器继承的。
  3. 元操作符的解析和工作方式都差不多,你可以通过仔细观察来发现不同之处。

我在基础解析器代码中找到了对元操作符的引用。

token infix_prefix_meta_operator:sym<R> {
  <sym> <infixish('R')> {}
  <.can_meta($<infixish>, "reverse the args of")>
  <O=.revO($<infixish>)>
}

token infix_prefix_meta_operator:sym<Z> {
  <sym> <infixish('Z')> {}
  <.can_meta($<infixish>, "zip with")>
  <O(|%list_infix)>
}

这需要对 Raku grammar 有一定的了解。据我所知,原来这两个元运算符在解析上并没有根本的区别。一段时间后,在解析器的源代码中挖得够多了,我开始怀疑解析工作是否正确。认为代码 my $r = :foo(3 R- 2); say $r; # Output: foo => -1 正确工作的建议 - 问题恰恰发生在调用函数时。显然,我白白相信了任务栏上的标签。

编译器将帮助我们 #

颇为迟钝的我想起了我从一开始就应该做的事情。Rakudo 编译器有 --target 调试开关。它取编译器阶段的名称,你想将其结果输出到控制台并退出。我想看看 --target=parse(因为我只知道这一个)。

我从 ~/dev-rakudo/rakudo 文件夹中使用 rakumo-m,这样我就不必等待通过 make install 命令将所需文件复制到 ~/dev-rakudo-install。简单的脚本可以这样运行。更复杂的脚本必须在 make install 之后从 -install 中运行。

> cat ~/test.raku
sub s(|c) { say c }
s(:foo(3 R- 2));
s(:foo(3 Z- 2));

> ./rakudo-m --target=parse ~/test.raku
[...]
- args: (:foo(3 R- 2))
  - semiarglist: :foo(3 R- 2)
    - arglist: 1 matches
      - EXPR: :foo(3 R- 2)
        - colonpair: :foo(3 R- 2)
          - identifier: foo
          - coloncircumfix: (3 R- 2)
            - circumfix: (3 R- 2)
              - semilist: 3 R- 2
                - statement: 1 matches
                  - EXPR: R- 2
[...]
- args: (:foo(3 Z- 2))
  - semiarglist: :foo(3 Z- 2)
    - arglist: 1 matches
      - EXPR: :foo(3 Z- 2)
        - colonpair: :foo(3 Z- 2)
          - identifier: foo
          - coloncircumfix: (3 Z- 2)
            - circumfix: (3 Z- 2)
              - semilist: 3 Z- 2
                - statement: 1 matches
                  - EXPR: Z- 2
[...]

结论:R 和 Z 的解析是一样的。

这不是解析 #

所有被解析的东西都会被传递给所谓的 Action,把字词变成一棵语法树。在我们的例子中,Actions 位于文件 rakudo/src/Perl6/Actions.nqpnqp/src/HLL/Actions.nqp 中。这里就比较容易搞清楚了,毕竟是代码,是 grammar。

我在主 Actions 中找到了以下代码。

[...]
elsif $<infix_prefix_meta_operator> {
[...]
  if    $metasym eq 'R' { $helper := '&METAOP_REVERSE'; $t := nqp::flip($t) if $t; }
  elsif $metasym eq 'X' { $helper := '&METAOP_CROSS'; $t := nqp::uc($t); }
  elsif $metasym eq 'Z' { $helper := '&METAOP_ZIP'; $t := nqp::uc($t); }
  
  my $metapast := QAST::Op.new( :op<call>, :name($helper), WANTED($basepast,'infixish') );
  $metapast.push(QAST::Var.new(:name(baseop_reduce($base<OPER><O>.made)), :scope<lexical>))
    if $metasym eq 'X' || $metasym eq 'Z';
[...]

它说,如果在代码中解析了元操作符 RZX,就应该在语法树中添加一些 METAOP_ 函数调用。在 ZX 的情况下,它会多一个参数,即某种还原函数。所有这些功能都可以在 rakudo/src/core.c/metaops.pm6 中找到。

sub METAOP_REVERSE(\op) is implementation-detail {
  -> |args { op.(|args.reverse) }
}

sub METAOP_ZIP(\op, &reduce) is implementation-detail {
 nqp::if(op.prec('thunky').starts-with('.'),
  -> +lol {
    my $arity = lol.elems;
    [...]
  },
  -> +lol {
    Seq.new(Rakudo::Iterator.ZipIterablesOp(lol,op))
  }
  )
}

给你:

  1. \op 是由我们的元操作符,即-,在前面的操作。
  2. Trait implementation-detail 只是表明这不是公共代码,是编译器实现的一部分。
  3. 由于-操作没有笨重的特性,所以 &reduce 函数不会参与计算,Z 的结果是 Seq.new(...)
  4. R 的结果是一个操作调用 - 参数顺序相反。

这时我想起还有一个 - 目标,即星。它将显示行动的结果。

> ./rakudo-m --target=ast ~/test.raku
[...]
- QAST::Op(call &s) <sunk> :statement_id<4> s(:foo(3 R- 2))
  - QAST::Op+{QAST::SpecialArg}(call :named<foo>) <wanted> :statement_id<5> :before_promotion<?> R-
    - QAST::Op(call &METAOP_REVERSE) <wanted> :is_pure<?>
      - QAST::Var(lexical &infix:<->) <wanted>
    - QAST::Want <wanted> 3
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(3)  3
    - QAST::Want <wanted> 2
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(2)  2
[...]
- QAST::Op(call &s) <sunk> :statement_id<7> s(:foo(3 Z- 2))
  - QAST::Op+{QAST::SpecialArg}(:named<foo>) <wanted> :statement_id<8> :before_promotion<?> Z-
    - QAST::Op(call &METAOP_ZIP) <wanted> :is_pure<?>
      - QAST::Var(lexical &infix:<->) <wanted>
      - QAST::Var(lexical &METAOP_REDUCE_LEFT)
    - QAST::Want <wanted> 3
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(3)  3
    - QAST::Want <wanted> 2
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(2)  2
[...]

一如所料。除了调用不同的 METAOP_ 函数外,所有的东西几乎都是一样的。从它们的代码中我们可以知道,原则上这些函数的不同之处在于返回值的类型 - 分别是 IntSeq。众所周知,Raku 对不同类型的对象的上下文相当敏感……我想,它关注的可能是返回值。我试着用下面的方式修改代码。

sub METAOP_REVERSE(\op) is implementation-detail {
  -> |args { Seq.new(op.(|args.reverse)) }
}

编译、运行。

> make
[...]
Stage start      :   0.000
Stage parse      :  61.026
Stage syntaxcheck:   0.000
Stage ast        :   0.000
Stage optimize   :   7.076
Stage mast       :  14.120
Stage mbc        :   3.941
[...]
> ./rakudo-m ~/test.raku
\(-1)
\(:foo((1,).Seq))

一切都没有改变。所以,不是返回值……想了想,不知道为什么结果又是 -1 而不是 (-1,).Seq。而且,从代码来看,Seq 根本就没有一个合适的构造函数。下一次,作为一些疯狂的事情,我尝试调用 METAOP_REVERSE 结果只是为了崩溃。

sub METAOP_REVERSE(\op) is implementation-detail {
  -> |args { die }
}

编译、运行。

> make
[...]
> ./rakudo-m ~/test.raku
\(-1)
\(:foo((1,).Seq))

怎么会呢?语法树中包含了对 METAOP_REVERSE 的调用,它的代码应该是折叠的,但计算仍然进行,我们得到 -1

这些都不是《行动》。

这里我的目光落在编译器的构建日志上。它是一些阶段被列在那里。我随机试了 --target=mast

> ./rakudo-m --target=mast ~/test.raku
[...]
MAST::Frame name<s>, cuuid<1>
  Local types: 0<obj>, 1<obj>, 2<obj>, 3<obj>, 4<int>, 5<str>, 6<obj>, 7<obj>, 8<obj>,
  Lexical types: 0<obj>, 1<obj>, 2<obj>, 3<obj>, 4<obj>,
  Lexical names: 0<c>, 1<$¢>, 2<$!>, 3<$/>, 4<$*DISPATCHER>,
  Lexical map: $!<2>, c<0>, $*DISPATCHER<4>, $¢<1>, $/<3>,
  Outer: name<<unit>>, cuuid<2>
[...]

某种不可读的矩阵。星号和桅杆之间有一个阶段性的优化。

> ./rakudo-m --target=optimize ~/test.raku
[...]
- QAST::Op(callstatic &s) <sunk> :statement_id<4> s(:foo(3 R- 2))
  - QAST::Op(call &infix:<->)  :METAOP_opt_result<?>
    - QAST::Want <wanted> 2
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(2)  2
    - QAST::Want <wanted> 3
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(3)  3
[...]
- QAST::Op(callstatic &s) <sunk> :statement_id<7> s(:foo(3 Z- 2))
  - QAST::Op+{QAST::SpecialArg}(call :named<foo>) <wanted> :statement_id<8> :before_promotion<?> Z-
    - QAST::Op(callstatic &METAOP_ZIP) <wanted> :is_pure<?>
      - QAST::Var(lexical &infix:<->) <wanted>
      - QAST::Var(lexical &METAOP_REDUCE_LEFT)
    - QAST::Want <wanted> 3
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(3)  3
    - QAST::Want <wanted> 2
      - QAST::WVal(Int)
      - Ii
      - QAST::IVal(2)  2
[...]

哈,就是这样。在优化阶段后,行将失踪。

  • QAST::Op+{QAST::SpecialArg}(call :named<foo>) <wanted> :statement_id<5> :before_promotion<?> R-. 并将整个 METAOP_REVERSE 调用替换为通常的操作 (&infix:<->)。所以问题一定在优化器的某个地方。

只有在 optim_nameless_call 方法中才会提到 &METAOP_ASSIGN,其中 QAST::Op+{QAST::SpecialArg}(call :named<foo>)。显然,这个操作负责生成一个命名对 - 它已经有了一个名字(命名参数),它需要计算值。从优化 _ 无名方法的执行路径来看,我们可以得出结论,我们对最后一个块感兴趣。

[...]
  elsif self.op_eq_core($metaop, '&METAOP_REVERSE') {
    return NQPMu unless nqp::istype($metaop[0], QAST::Var)
      && nqp::elems($op) == 3;
    return QAST::Op.new(:op<call>, :name($metaop[0].name),
      $op[2], $op[1]).annotate_self: 'METAOP_opt_result', 1;
  }
[...]

让我提醒你,优化前的树是这样的。

[...]
- QAST::Op(call &s) <sunk> :statement_id<4> s(:foo(3 R- 2))
  - QAST::Op+{QAST::SpecialArg}(call :named<foo>) <wanted> :statement_id<5> :before_promotion<?> R-
    - QAST::Op(call &METAOP_REVERSE) <wanted> :is_pure<?>
      - QAST::Var(lexical &infix:<->) <wanted>
    - QAST::Want <wanted> 3
    - QAST::Want <wanted> 2
[...]

而精简之后,是这样的。

[...]
- QAST::Op(callstatic &s) <sunk> :statement_id<4> s(:foo(3 R- 2))
  - QAST::Op(call &infix:<->)  :METAOP_opt_result<?>
    - QAST::Want <wanted> 2
    - QAST::Want <wanted> 3
[...]

也就是说,优化 _nameless_call 做了以下工作。

如果我们的 QAST::Op+{QAST::SpecialArg} 操作没有三个参数,如果 METAOP_REVERSE 调用没有一个正确的类型,我们就返回空。这不是我们的情况。 否则,我们将返回一个新的操作,代替我们的 QAST::Op+{QAST::SpecialArg} 操作,以相反的顺序调用 &infix:<-> 参数。就是说,把结果打包成一对就没了。

在摸索了一下如何解决这个问题,并阅读了 QAST::SpecialArgQAST::Node 的实现后,我想到了下面的代码。

[...]
  elsif self.op_eq_core($metaop, '&METAOP_REVERSE') {
    return NQPMu unless nqp::istype($metaop[0], QAST::Var)
      && nqp::elems($op) == 3;
    my $opt_result := QAST::Op.new(:op<call>, :name($metaop[0].name),
      $op[2], $op[1]).annotate_self: 'METAOP_opt_result', 1;
    if $op.named { $opt_result.named($op.named) } # 添加选项 named 
    if $op.flat { $opt_result.flat($op.flat) }    # 添加选项 flat
    return $opt_result;
  }
[...]

还有木头。

[...]
- QAST::Op(callstatic &s) <sunk> :statement_id<4> s(:foo(3 R- 2))
  - QAST::Op+{QAST::SpecialArg}(call &infix:<-> :named<foo>)  :METAOP_opt_result<?>
    - QAST::Want <wanted> 2
    - QAST::Want <wanted> 3
[...]

命名的参数返回到它的位置。测试也开始通过。

> make t/spec/S03-metaops/reverse.t
[...]
All tests successful.
Files=1, Tests=70,  3 wallclock secs ( 0.03 usr  0.01 sys +  3.61 cusr  0.17 csys =  3.82 CPU)
Result: PASS

我们本可以就此打住,但这是编译器优化器的代码,它的结果是一个有两个整数参数的方法调用。我认为这在某种程度上是次优的。如果我们将返回表达式改为返回 self.visit_op: $opt_result;,对产生的非优化操作调用优化器,那么产生的树就会像这样。

[...]
- QAST::Op(callstatic &s) <sunk> :statement_id<4> s(:foo(3 R- 2))
  - QAST::Want+{QAST::SpecialArg}(:named<foo>)
    - QAST::WVal+{QAST::SpecialArg}(Int :named<foo>)
    - QAST::IVal(-1)
[...]

现在一切都很理想。

分享成果 #

我们已经到了终点线。现在我们只需要分享我们的经验。

  1. 重要的是:运行所有的 make spectest 测试,确保没有新的东西被破坏。
  2. 在 GitHub 上使用 Rakudo 编译器和测试制作 fork 仓库。
  3. 将 fork 仓库添加为新的 git 远程仓库。
  4. cd ~/dev-rakudo/rakudo && git remote add fork
  5. cd ~/dev-rakudo/t/spec && git remote add fork

重要:确保两个仓库在 git 中都有正确的用户名和用户邮箱。

提交到两个版本库,详细说明你为什么做了哪些改动,并添加对原始问题跟踪器的引用。

运行提交。

cd ~/dev-rakudo/rakudo && git push fork
cd ~/dev-rakudo/t/spec && git push fork

向两个仓库提出拉取请求。在他们的描述中,最好是相互参照和原任务。

结论 #

对开源软件的贡献是:

  1. 趣味性和趣味性。
  2. 给你的感觉是,你正在做一些有用的事情,你真的是。
  3. 让你认识新的有趣和专业的人(任何关于 Raku 的问题都会在 #raku IRC 频道中得到回答)。
  4. 解决非标准任务,没有截止日期的压力,是一种很好的体验。

选择你觉得最舒服的角色等级,去做新的任务吧!