Контрибутинг в Raku для самых маленьких | Raku contributing for the little ones
В последние пару недель я несколько раз натыкался на статьи и видео о том, как это здорово — контрибутить в открытое ПО. По этому поводу я вспомнил о другой старой статье «Raku это моя MMORPG». В ней говорится, что приносить пользу открытому ПО можно несколькими способами. Например, можно быть Воином и писать программы, основанные на каком-то открытом ПО. Можно быть Лучником — и писать блоги, твиты и подобное, возбуждая интерес с выбранному ПО. Ещё, можно быть Магом — реализовывать новые фичи и фиксить баги. Сегодня я возьму Лучника и расскажу, как можно стать Магом для языка программирования Raku.
Выбираем квест
Давайте выберем какой-нибудь баг компилятора и пофиксим его. Идём в трекер компилятора Rakudo и выбираем подходящую багу. Я полистал список меток и наткнулся на parsing — удача, я какое-то время назад разбирался в грамматике компилятора и штудировал отличную книжку по этой теме. Есть четыре задачки:
- С меткой LTA (Less Than Awesome — чуть менее, чем потрясающе — когда настоящее поведение отличается от интуитивно ожидаемого) — пока вычёркиваем;
- С меткой «нужен консенсус» — мы хотим просто пофиксить несложную багу — точно вычёркиваем;
- С меткой «grammar and actions» про возможно мёртвый код — хороший кандидат для первой задачки;
- Просто «parsing» и что-то про метаоператор R — идеально, берём.
С квестом определились, теперь нужно настроить рабочую среду. В 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
Прошло 117,144 теста в 1,346 файлах за 14 минут. Несколько тестов, относящихся к utf8 по какой-то причине не исполнились, всё остальное работает как надо. Мы готовы к работе!
Разбираемся в постановке задачи
В задаче написано, что какой-то метаоператор R что-то делает не так с colonpair. Открываю документацию и ищу по слову R — метаоператоров с таким именем в выпадающем списке нет. Пробую набрать metaop и вижу reverse metaoperator (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 можно написать функцию, перехватывающую все аргументы, которые в неё передали и проанализировать. Делается это с помощью вертикальной черты перед единственным параметром — Capture:
sub sub-with-capture(|foo) { # <- параметр Capture
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 показывает, что это не так. Собственно, в этом и заключается баг — при передаче в функцию colonpair, использующую метаоператор R, пара превращается её значение.
Нужен новый тест
Чтобы убедиться в том, что будущие изменения исправят неверное поведение, нужно написать новый тест. В тестовых файлах несложно найти тест (S03-metaops/reverse.t) метаоператора R. Добавлю внизу следующий тест:
# https://github.com/rakudo/rakudo/issues/1632
{
my $got;
sub with-named(:$value) { $got = $value };
lives-ok { with-named(:value(3 R- 2)) }, "call doesn't throw";
is $got, -1, "named is good";
}В тесте есть функция with-named c именованным параметром. Его значение сохраняется во временную переменную $got и в конце теста сравнивается с ожидаемым значением. Кроме того, проверяем, не падает ли вызов функции вообще. Запустить отдельный тест для только что собранного компилятора можно с помощью 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 вверху файла. Теперь тест падает, но на количество не ругается. Можно приступать к исправлениям.
Метод пристального взгляда
Сначала я доверился меткам на задачке — если это parsing, значит проблема где-то на стадии разбора исходного кода. Мои знания на данный момент следующие:
- Код основного парсера находится в файле
rakudo/src/Perl6/Grammar.nqp; - Этот парсер наследуется от базового парсера из файла
nqp/src/HLL/Grammar.nqp; - Метаоператоры парсятся и работают похожим образом и можно методом пристального взгляда найти различия.
В коде основного парсера я нашёл упоминания метаоператоров:
token infix_prefix_meta_operator:sym<R> {
<sym> <infixish('R')> {}
<.can_meta(lt;infixish>, "reverse the args of")>
<O=.revO(lt;infixish>)>
}
token infix_prefix_meta_operator:sym<Z> {
<sym> <infixish('Z')> {}
<.can_meta(lt;infixish>, "zip with")>
<O(|%list_infix)>
}Тут нужны некоторые знания по грамматикам в Raku. Из моих знаний выходило, что принципиальной разницы в парсинге этих двух метаоператоров нет. Через какое-то время, вдоволь накопавшись в исходных кодах парсера, я начал подозревать, что парсинг работает правильно. Мысль, что кодmy $r = :foo(3 R- 2); say $r; # Output: foo => -1
работает корректно подсказывала — проблема возникает именно при вызове функции. Видимо, я зря доверился метке на задачке.
Компилятор нам поможет
Довольно запоздало я вспомнил, что должен был сделать с самого начала. У компилятора Rakudo есть отладочный ключ --target. Он принимает название стадии работы компилятора, результат которой нужно вывести на консоль и выйти. Пробую посмотреть на --target=parse (так как, только о нём и знаю):
Я используюrakumo-mиз папки~/dev-rakudo/rakudo, чтобы не ждать, пока нужные файлы скопируются в~/dev-rakudo-installкомандойmake install. Простые скрипты можно запускать так. Более сложные — придётся запускать из-installпослеmake 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.
Это был не парсинг
Всё, что распарсилось, передаётся в так называемые Actions для превращения литералов в синтаксическое дерево. В нашем случае Actions лежат в файлах rakudo/src/Perl6/Actions.nqp и nqp/src/HLL/Actions.nqp. Тут немного проще разобраться — всё-таки это код, а не грамматика.
В основных Actions я нашёл следующих код:
[...]
elsif lt;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, у них будет ещё один аргумент — некая reduce функция. Все эти функции нашлись в 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просто указывает на то, что это не публичный код и является частью реализации компилятора; - У операции
-нет характеристикиthunky, значит функция&reduceне будет участвовать в вычислениях и результатомZявляетсяSeq.new(...); - Результатом
Rявляется вызов операции-с аргументами в обратном порядке.
В этот момент я вспомнил, что есть ещё один --target, а именно — ast. Он покажет результат работы Actions:
> ./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.
Это были не Actions
Тут мой взгляд падает на лог сборки компилятора. Там как раз перечислены какие-то Stage. Наобум пробую --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> [...]
Какая-то нечитаемая матрица. Между ast и mast есть Stage optimize:
> ./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: < - >). Значит, проблема где-то в оптимизаторе.
Упоминания о &METAOP_REVERSE есть только в методе optimize_nameless_call, в которую приходит QAST::Op+{QAST::SpecialArg}(call :named<foo>). Видимо, именно эта операция ответственна за формирование именованной пары — имя (параметр named) у неё уже есть, ей нужно вычислить значение. Поверхностно осмотрев пути исполнения метода optimize_nameless_call, можно сделать вывод, что нас интересует самый последний блок:
[...]
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
[...]То есть, optimize_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
[...]Параметр named вернулся на место. Тест тоже начал проходить:
> 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
На этом можно было бы остановиться, но это же код оптимизатора компилятора, а в итоге его работы получился вызов метода - с двумя целочисленными аргументами. Как-то это неоптимально, на мой взгляд. Если изменить возвращаемое выражение на return 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и убедиться, что ничего нового не падает; - Сделать fork репозиториев с компилятором
Rakudoи тестами наGitHub; - Добавить fork-репозитории как новые
git remote:cd ~/dev-rakudo/rakudo && git remote add fork <url-of-your-rakudo-fork> cd ~/dev-rakudo/rakudo/t/spec && git remote add fork <url-of-your-roast-fork> - ВАЖНО: Убедиться, что в обоих репозиториях правильно выставлены
user.nameиuser.emailвgit; - Сделать коммиты в оба репозитория с подробным описание зачем и какие были сделаны изменения, добавить ссылки на оригинальную задачку трекере;
- Запушить коммиты:
cd ~/dev-rakudo/rakudo && git push fork cd ~/dev-rakudo/rakudo/t/spec && git push fork - Сделать pull request в оба репозитория. В описаниях к ним лучше добавить перекрёстные ссылки друг на друга и на оригинальную задачку.
Заключение
Контрибутить в открытое ПО это:
- весело и интересно;
- даёт чувство, что ты делаешь что-то полезное, и это действительно так;
- позволяет познакомиться с новыми интересными и профессиональными людьми (на любые вопросы относительно
Rakuвам ответят в IRC канале#raku; - замечательный опыт решения нестандартных задач без стресса в виде дедлайна.
Выбирайте класс героя, который вам сейчас более близок и вперёд навстречу новым квестам!
English version