为最年轻的 Raku 贡献力量
— 焉知非鱼Contributing to Raku
在过去的几周里,我看到了一些文章和视频,讲述了在开源软件中构思是多么的伟大。这次我又想起了另一篇旧文Raku 是我的 MMORPG。它说,你可以从以下几个方面受益于开源软件。比如说,可以做一个大侠,基于一些开源软件来写软件。作为写手,可以写博客、微博等,对所选软件产生兴趣。或者你可以成为一个法师 - 实现新的功能和修复 bug。今天小编就带着弓箭手来告诉大家如何成为 Raku 编程语言的法师。
选择一个任务 #
让我们挑选一些编译器的 bug,并修复它。让我们去 Rakudo 编译器 issues中选择我们想要修复的 bug。我滚动了一下 bug 列表,遇到了解析 - 运气不错,我前段时间一直在研究编译器语法,看了一本这方面的好书。找到了四个问题。
- 标签为 LTA (Less Than Awesome - 当真实行为与直觉预期不同时)- 我们暂时把它划掉。
- 标签 “需要共识” - 我们只想修复一个不复杂的 bug - 肯定要划掉。
- 标签为 “grammar and actions” 的关于一个可能死的代码是一个很好的候选人的第一个任务。
任务确定后,现在我们需要配置工作环境。在 Windows、Linux 和 macOS 中,一切应该都差不多。我将通过 macOS 的例子来告诉你。
建立工作环境 #
为源码和我们建立的编译器建立文件夹。
mkdir ~/dev-rakudo && mkdir ~/dev-rakudo-install
Rakudo 编译器由三部分组成。
- 虚拟机。现在有三种 - JVM、JS 和 MoarVM。我们以 MoarVM 为最稳定的一个。
- NQP(Not Quite Perl),是一种低级(中级)语言的实现,它是 Raku 的一个 “子集”。虚拟机可以执行用 NQP 编写的代码。
- 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
函数的数字。现在测试崩溃了,但编号没有受到影响。你可以开始修复它。
凝视法 #
一开始我很相信任务上的标签 - 如果是解析的话,一定是源码解析阶段的某个地方出现了问题。目前我的认识如下:
- 基础解析器代码在文件
rakudo/src/Perl6/Grammar.nqp
中。 - 这个解析器是从
nqp/src/HLL/Grammar.nqp
文件中的基础解析器继承的。 - 元操作符的解析和工作方式都差不多,你可以通过仔细观察来发现不同之处。
我在基础解析器代码中找到了对元操作符的引用。
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.nqp
和 nqp/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';
[...]
它说,如果在代码中解析了元操作符 R
、Z
或 X
,就应该在语法树中添加一些 METAOP_
函数调用。在 Z
和 X
的情况下,它会多一个参数,即某种还原函数。所有这些功能都可以在 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))
}
)
}
给你:
\op
是由我们的元操作符,即-,在前面的操作。- Trait
implementation-detail
只是表明这不是公共代码,是编译器实现的一部分。 - 由于-操作没有笨重的特性,所以
&reduce
函数不会参与计算,Z
的结果是Seq.new(...)
。 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_
函数外,所有的东西几乎都是一样的。从它们的代码中我们可以知道,原则上这些函数的不同之处在于返回值的类型 - 分别是 Int
和 Seq
。众所周知,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::SpecialArg
和 QAST::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)
[...]
现在一切都很理想。
分享成果 #
我们已经到了终点线。现在我们只需要分享我们的经验。
- 重要的是:运行所有的
make spectest
测试,确保没有新的东西被破坏。 - 在 GitHub 上使用 Rakudo 编译器和测试制作 fork 仓库。
- 将 fork 仓库添加为新的 git 远程仓库。
- cd ~/dev-rakudo/rakudo && git remote add fork 。
- 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
向两个仓库提出拉取请求。在他们的描述中,最好是相互参照和原任务。
结论 #
对开源软件的贡献是:
- 趣味性和趣味性。
- 给你的感觉是,你正在做一些有用的事情,你真的是。
- 让你认识新的有趣和专业的人(任何关于 Raku 的问题都会在
#raku IRC
频道中得到回答)。 - 解决非标准任务,没有截止日期的压力,是一种很好的体验。
选择你觉得最舒服的角色等级,去做新的任务吧!