Контрибутинг в 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