November 27, 2021

Контрибутинг в Raku для самых маленьких | Raku contributing for the little ones

В последние пару недель я несколько раз натыкался на статьи и видео о том, как это здорово — контрибутить в открытое ПО. По этому поводу я вспомнил о другой старой статье «Raku это моя MMORPG». В ней говорится, что приносить пользу открытому ПО можно несколькими способами. Например, можно быть Воином и писать программы, основанные на каком-то открытом ПО. Можно быть Лучником — и писать блоги, твиты и подобное, возбуждая интерес с выбранному ПО. Ещё, можно быть Магом — реализовывать новые фичи и фиксить баги. Сегодня я возьму Лучника и расскажу, как можно стать Магом для языка программирования Raku.

Выбираем квест

Давайте выберем какой-нибудь баг компилятора и пофиксим его. Идём в трекер компилятора Rakudo и выбираем подходящую багу. Я полистал список меток и наткнулся на parsing — удача, я какое-то время назад разбирался в грамматике компилятора и штудировал отличную книжку по этой теме. Есть четыре задачки:

Список открытых задач с меткой parsing
  1. С меткой LTA (Less Than Awesome — чуть менее, чем потрясающе — когда настоящее поведение отличается от интуитивно ожидаемого) — пока вычёркиваем;
  2. С меткой «нужен консенсус» — мы хотим просто пофиксить несложную багу — точно вычёркиваем;
  3. С меткой «grammar and actions» про возможно мёртвый код — хороший кандидат для первой задачки;
  4. Просто «parsing» и что-то про метаоператор R — идеально, берём.

С квестом определились, теперь нужно настроить рабочую среду. В 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

Прошло 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, значит проблема где-то на стадии разбора исходного кода. Мои знания на данный момент следующие:

  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(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))
  }
  )
}

Здесь:

  1. \op это операция, перед которой стоит наш метаоператор, то есть -;
  2. Trait implementation-detail просто указывает на то, что это не публичный код и является частью реализации компилятора;
  3. У операции - нет характеристики thunky, значит функция &reduce не будет участвовать в вычислениях и результатом Z является Seq.new(...);
  4. Результатом 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 делает следующее:

  1. Если у нашей операции QAST::Op+{QAST::SpecialArg} не три аргумента, а у вызова METAOP_REVERSE — первый не нужного типа, то возвращаем пусто. Это не наш случай;
  2. Иначе, вместо нашей операции 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)
[...]

Теперь всё оптимально.

Делимся результатами

Мы на финишной прямой. Теперь дело за малым — поделиться наработками:

  1. ВАЖНО: Прогнать все тесты make spectest и убедиться, что ничего нового не падает;
  2. Сделать fork репозиториев с компилятором Rakudo и тестами на GitHub;
  3. Добавить 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>
  4. ВАЖНО: Убедиться, что в обоих репозиториях правильно выставлены user.name и user.email в git;
  5. Сделать коммиты в оба репозитория с подробным описание зачем и какие были сделаны изменения, добавить ссылки на оригинальную задачку трекере;
  6. Запушить коммиты:
    cd ~/dev-rakudo/rakudo && git push fork cd ~/dev-rakudo/rakudo/t/spec && git push fork
  7. Сделать pull request в оба репозитория. В описаниях к ним лучше добавить перекрёстные ссылки друг на друга и на оригинальную задачку.

Заключение

Контрибутить в открытое ПО это:

  • весело и интересно;
  • даёт чувство, что ты делаешь что-то полезное, и это действительно так;
  • позволяет познакомиться с новыми интересными и профессиональными людьми (на любые вопросы относительно Raku вам ответят в IRC канале #raku;
  • замечательный опыт решения нестандартных задач без стресса в виде дедлайна.

Выбирайте класс героя, который вам сейчас более близок и вперёд навстречу новым квестам!

English version