December 5, 2021

Java аннотации в Raku | Java Annotations in Raku

Сегодня немного про то, что новое лучше усваивается через уже известное. Так сложилось, что на $dayjob я пишу на Java, по-этому зайду именно с его стороны. В Java 1.5 появилась интересная синтаксическая форма — аннотации. Выглядит это как-то так:

/**
 * @deprecated use #getId() method instead
 */
@Override
public String getName() {
  return "stub"
}

В примере показана аннотация @Deprecated, которая заставляет среду выполнения выводить предупреждение в консоль каждый раз, когда используется метод getName. Кроме того, в Javadoc добавлена поясняющая информация.

Вообще, аннотации в Java это механизм добавления некоторых метаданных в классы, объекты, типы и прочее, которые можно использовать в последствии на этапе компиляции, исполнения или статического анализа кода. С помощью них, например, можно реализовать стратегию разделения (decouple) кода — чтобы одни компоненты программы работали совместно с другими, не имея жёсткой связи. На этой стратегии построена идея Инверсии Управления и ядро библиотеки Spring.

Но хватит про Java. Что есть в Raku похожего на механизм аннотаций? В Raku есть Traits — синтаксис, которым можно пометить классы и объекты. Эти метки обрабатываются во время компиляции программы. В зависимости от желания программиста, эффект такой обработки может оказывать влияние и на ход выполнения программы.

Например, рассмотрим аналогичную аннотации @Deprecated конструкцию из спецификации Raku:

sub get-name(--> Str) is DEPRECATED('get-id() method') {
  'stub'
}

is DEPRECATED и есть trait. В аргументе можно сообщить альтернативу устаревшему коду. После завершения программы, во время выполнения которой вызывалась функция get-name(), будет выведено сообщение о том, где и сколько раз выполнялся устаревший код:

Saw 1 occurrence of deprecated code.
======================================================================
Sub get-name (from GLOBAL) seen at:
  ~/advent.raku, line 13
Please use get-id() method instead.
----------------------------------------------------------------------
Please contact the author to have these occurrences of deprecated code
adapted, so that this message will disappear!

Устареваем

is DEPRECATED это trait из стандартной библиотеки. Чтобы разобраться в том, как он работает, попробуем написать свой аналог под названием obsolete. Сначала определимся с хранилищем собираемой информации — класс, который хранит и обновляет количество вызовов функции и умеет вывести отчёт:

class ObsoletTraitData {
  has $.routine-name is required;
  has $.user-hint;
  has $!execution-amount = 0;
  method executed() { $!execution-amount++ }
  method report() {
    return unless $!execution-amount;
    note "Obsolete routine $!routine-name is execcuted $!execution-amount times.";
    note $_ with $!user-hint;
  }
}

Теперь объявляем тестовый trait — это обычная multi функция с именем trait_mod:<is> и двумя аргументами: первый — к чему будет применяться trait (в нашем случае это функция Routine), второй — имя:

say 'run-time';
multi trait_mod:<is>(Routine $r, :$obsolete!) {
  say 'compile-time'
}
sub get-name(--> Str) is obsolete {
  'stub'
}
say get-name;
# Output: compile-time
#         run-time 
#         stub

Самое важное, что нужно понять о traits — их функции исполняются во время компиляции, а не выполнения программы. Это наглядно видно из вывода кода выше. Вспомним, чего мы хотим добиться — отчета об исполнении устаревшего кода перед завершением работы программы. Эту информацию мы можем получить только во время исполнения. Чтобы повлиять на исполнение из стадии компиляции, trait должен как-то модифицировать функцию. В нашем случае можно добавить в функцию phaser ENTER. Это специальный блок, который выполняется перед выполнением первой инструкции функции. То есть, мы ходим, чтобы функция get-name выглядела как-то так:

sub get-name(--> Str) {
  ENTER { $obsolet-trait-data.executed }
  'stub'
}

Код самой функции мы трогать не можем, но можем сделать необходимые манипуляции во время компиляции. Берём имя функции, возможную подсказку для пользователя, заводим новый объект типа ObsoletTraitData, кладём его в локальную ассоциативную переменную %obsolet-trait-data и добавляем необходимый phaser:

my ObsoletTraitData %obsolet-trait-data;

multi trait_mod:<is>(Routine $r, :$obsolete!) {
  my $routine-name = $r.name;
  my $user-hint = $obsolete ~~ Str ?? $obsolete !! Any;
  %obsolet-trait-data{$routine-name} =
    ObsoletTraitData.new(:$routine-name, :$user-hint);
  $r.add_phaser('ENTER', -> {
    %obsolet-trait-data{$routine-name}.executed;
  });
}

Теперь, при выполнении функции get-name, объект ObsoletTraitData будет обновлять своё состояние. Таким образом, мы повлияли на ход выполнения программы во время её компиляции. Осталось только вывести отчёт. Для этого мы добавим ещё один phaser END в основной код. Его блок исполняется перед самым завершением программы. Таким образом получим следующую картину:

class ObsoletTraitData { ... }

my ObsoletTraitData %obsolet-trait-data;

END { .report for %obsolet-trait-data.values }

multi trait_mod:<is>(Routine $r, :$obsolete!) { ... }

sub get-name(--> Str) is obsolete('Please use get-id() instead.') {
  'stub'
}
sub anouther-obsolet() is obsolete {}

get-name();
anouther-obsolet();
get-name();
# Output:
# Obsolete routine get-name is execcuted 2 times.
# Please use get-id() instead.
# Obsolete routine anouther-obsolet is execcuted 1 times.

Переопределяем

С @Deprecated закончили. Другая часто используемая аннотация в Java это @Override - ей помечется метод класса. Случай, когда он не переопределяет метод супер-класса, считается ошибкой компиляции. Сделать аналогичный trait будет несложно - нам не придёт выходить за пределы стадии компиляции. Объявляем trait с именем override, применимый только к методам:

multi trait_mod:<is>(Method $m, :$override!) {

Проверяем, что метод является членом класса, иначе заканчиваем работу:

return unless $m.package.HOW ~~ Metamodel::ClassHOW;

Проверяем, что у класса обладателя метода есть родители. Для этого воспользуемся мета-методом ^mro, который отдаст список всех родительских классов, включая его самого, Any и Mu (их мы из рассмотрения отфильтровываем):

my $class = $m.package;
my $method-point = $class.^name ~ '::' ~ $m.name;
my @parents = $class.^mro[1 ..^ *-2];
die "is override trait cannot be used without parent class $method-point." unless @parents;

Проходим по всем родителям и их методам в поисках одного, который совпадает по имени и сигнатуре. Сравнения сигнатур методов на одинаковость это не очень тривиальная задача, и здесь мы скроем её реализацию за функциейcheck-signature-eq:

for @parents -> $parent {
  for $parent.^methods -> $parent-method {
    return if $parent-method.name eq $m.name &&
      check-signature-eq($parent-method.signature, $m.signature)
  }
}

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

die "$method-point does not override any parent methods.";

В результате получаем следующее:

multi trait_mod:<is>(Method $m, :$override!) { ... }

class A {
  method from-a(:$r) {}
}

class B is A {
  method from-a($r) is override {
    say 'from-b'
  }
}
# Output: B::from-a does not override any parent methods.
# Exit code: 1

Подавляем

Нам уже удалось реализовать логику работы Java аннотаций @Deprecated и @Override. Попробуем реализовать логику @SuppressWarnings. Эта аннотация применяется к функции и подавляет её предупредительные сообщения. Так же, можно указать какие именно предупреждения будут подавляться.

В Raku предупреждения можно вывести с помощью функции warn. Она порождает специальное исключение, которое выводится в поток ошибок, а процесс выполнения возобновляется с прежнего места. Перехватить такое исключение можно с помощью специального phaser CONTROL. То есть, как и в случае с @Deprecated, нам нужно модифицировать функцию, добавив нужный phaser. Давайте попробуем что-то новое и вместо add_phaser используем обёртку функции. Как это работает? Мы заменяем одну функцию другой, которая может по своему усмотрению вызвать оригинал (методом callsame). Внутрь этой функции мы и вставим phaser CONTROL, который будет имитировать стандартное поведение, но только не для подавляемых предупреждений:

multi trait_mod:<is>(Routine $b, :$suppress-warnings) {
  my $regex = $suppress-warnings ~~ Str ?? / <$suppress-warnings> / !! Any;
  $b.wrap(sub with-control(|c) {
    callsame;
    CONTROL {
      when CX::Warn {
        .note if $regex.defined && $_.message !~~ $regex;
        .resume
      }
    }
  });
}

sub work-in-progress() is suppress-warnings('todo') {
  warn 'important warn';
  warn 'todo warn';
  say 'WIP';
}

work-in-progress()
# Output:
# important warn
#  in sub work-in-progress at ~/trait-supress.raku line 15
# WIP

Сериализуем

Осталось только обсудить аннотации, определяемые пользователем. Как я уже сказал выше, аннотации Java это способ прикрепить некоторую мета-информацию к классу или объекту. После этого, во время компиляции или чаще всего исполнения, аннотированные объекты проверяются на предмет обладания нужной информацией. В Raku для этого отлично подойдут роли. Рассмотрим задачу добавления в класс простейшей системы сериализации. Напишем класс и разметим нашим будущим trait:

class Person is serialize-name('Passport') {
  has $.first;
  has $.second is serialize-name('Second name');
  has $.third is serialize-name('Honorific');
}

Видно, что trait serialize-name применяется и к самому классу и к его атрибутам.

Trait для атрибута выглядит так:

role SerializableAttribute {
  has $.serialize-name;
}

multi trait_mod:<is>(Attribute $a, :$serialize-name) {
  $a does SerializableAttribute(:$serialize-name);
}

Тут trait добавляет в атрибут новую роль SerializableAttribute. Эта роль сама привносит новый атрибут в атрибут :) Значение нового атрибута trait передаёт через свой аргумент.

Trait для класса выглядит следующим образом:

role SerializableClass[$name] {
  method serialize() {
    say $name, ' | ', self.^name;
    say .serialize-name, ' <- ', .get_value(self)
      for self.^attributes(:local).grep(*.^can('serialize-name'));
  }
}

multi trait_mod:<is>(Mu:U $c, :$serialize-name!) {
  return unless $c.HOW ~~ Metamodel::ClassHOW;
  $c.^add_role(SerializableClass[$serialize-name]);
}

Тут trait проверяет, что применяется именно к классу и добавляет специальную роль SerializableClass. Эта роль добавляет в класс новый метод serialize, который реализует всю логику по сериализации. В частности, он фильтрует список всех атрибутов класса по признаку наличия метода serialize-name.

Если всё это запустить, то получим:

Person.new(:first<John>, :second<Hancock>, :third<Mr>).serialize();
# Output:
# Passport | Person
# second-name <- Hancock
# honorific <- Mr

Заключение

Как мы видим, traits это довольно мощный инструмент, но, как и всё в мире Raku, его можно использовать очень по-разному. Например, в Java, при объявлении своей аннотации, программист должен указать, до какой стадии распространяется её действие (только на уровне кода, до конца компиляции или до завершения приложения). Ещё можно указать, будет ли аннотация наследоваться дочерними классами, и можно ли её указывать несколько раз. С другой стороны, traits в Raku предоставляют программисту полную свободу действий. Теперь вы обладаете знаниями, достаточными, чтобы написать свою IoC/DI систему наподобие Java Spring Core с помощью Raku traits.

English version