Аннотации времени компиляции на примере @Implement

в 15:35, , рубрики: eclipse, java

Аннотации времени компиляции на примере @Implement - 1

Все мы любим отлавливать ошибки на этапе компиляции, вместо исключений времени выполнения. Их устранить проще всего, компилятор сам показывает все места, нуждающиеся в исправлении. Хотя большинство проблем можно обнаружить только при запуске программы, все же мы стараемся сделать это как можно раньше. В блоках инициализации классов, в конструкторах объектов, при первом вызове метода и т.д. И иногда нам везет, и даже на этапе компиляции известно достаточно, чтобы проверить программу на наличие определенных ошибок.

В этой статье хочу поделиться опытом написания одной такой проверки. Если точнее то создание аннотации которая может выдавать ошибки, как это делает компилятор. Судя по тому, что в рунете информации на данную тему не так много, то описанные выше, счастливые ситуации бывают не часто.

Я опишу общий алгоритм проверки, а также все шаги и нюансы на которые я тратил время и нервные клетки.

Постановка задачи

В этом разделе я приведу пример использования этой аннотации. Если Вы уже знаете какую проверку хотите сделать можете смело его пропускать. Уверен, это никак не повлияет на полноту изложения.

Сейчас речь пойдет скорее о повышении читаемости кода нежели об устранении ошибок. Пример, можно сказать, из жизни, а точнее из моего хобби-проекта.

Допустим, есть класс UnitManager, который, по сути, является коллекцией юнитов. В нем есть методы для добавления, удаления, получения юнита и т.д. При добавлении нового юнита менеджер присваивает ему id. Генерация id делегирована классу RotateCounter, который, возвращает число в заданном диапазоне. И тут есть крошечная проблема, RotateCounter не может знать о том, свободен ли выбранный id. Согласно принципу инвертирования зависимостей, можно создать интерфейс, в моем случае это RotateCounter.IClient, у которого есть единственный метод isValueFree(), который получает id и возвращает true, если id свободен. А UnitManager реализует этот интерфейс, создаст экземпляр RotateCounter и передаст ему себя в качестве клиента.

Я так и сделал. Но, открыв исходник UnitManagerа через несколько дней после написания, я вошел в легкий ступор увидев метод isValueFree(), который не очень то подходил по логике для UnitManagerа. Было бы намного проще, если бы была возможность указать какой интерфейс реализует этот метод. Например, в языке C#, из которого я пришел в Java, с этой проблемой помогает справиться явная реализация интерфейса. В этом случае, во-первых, вызвать метод можно только при явном касте к интерфейсу. Во-вторых, что более важно в данном случае, в сигнатуре метода явно указывается имя интерфейса (и без модификатора доступа), например:

IClient.isValueFree(int value) {
}

Один из вариантов решения – добавление аннотации, с именем интерфейса который реализует этот метод. Нечто вроде @Override, только с указанием интерфейса. Согласен, можно использовать анонимный внутренний класс. В этом случае, так же как и в C#, метод нельзя просто так вызвать у объекта, да и сразу видно какой интерфейс он реализует. Но, это увеличит объем кода, следовательно, ухудшить читаемость. Да и его нужно как-то получить из класса – создать геттер или публичное поле (ведь перегрузки операторов каста в Java тоже нет). Неплохой вариант, но мне не нравится.

По началу, я думал, что в Java, как и в C# аннотации являются полноценными классами и от них можно наследоваться. В этом случае нужно было бы просто создать аннотацию, которая наследуется от @Override. Но это оказалось не так, и мне пришлось погрузиться в удивительный и пугающий мир проверок на этапе компиляции.

Пример кода UnitManager

public class Unit {
  private int id;
}

public class UnitManager implements RotateCounter.IClient
{
  private final Unit[] units;
  private final RotateCounter idGenerator;
  
  public UnitManager(int size)
  {
    units = new Unit[size];
    idGenerator = new RotateCounter(0, size, this);
  }
  
  public void addUnit(Unit unit)
  {
    int id = idGenerator.findFree();
    units[id] = unit;
  }

  @Implement(RotateCounter.IClient.class)
  public boolean isValueFree(int value) {
    return units[value] == null;
  }
  
  public void removeUnit(int id) {
    units[id] = null;
  }
}

public class RotateCounter
{
  private final IClient client;
  
  private int next;
  private int minValue;
  private int maxValue;
  
  public RotateCounter(int minValue, int maxValue, IClient client)
  {
    this.client = client;
    this.minValue = minValue;
    this.maxValue = maxValue;
    next = minValue;
  }
  
  public int incrementAndGet()
  {
    int current = next;
    if (next >= maxValue) {
      next = minValue;
      return current;
    }
    next++;
    return current;
  }
  
  public int range() {
    return maxValue - minValue + 1;
  }
  
  public int findFree()
  {
    int range = range();
    int trysCounter = 0;
    
    int id;
    do
    {
      if (++trysCounter > range) {
        throw new IllegalStateException("No free values.");
      }
      id = incrementAndGet();
    }
    while (!client.isValueFree(id));
    return id;
  }
  
  public static interface IClient {
    boolean isValueFree(int value);
  }
}

Немного теории

Сразу оговорюсь, все приведенные методы являются экземплярными, по этому, для краткости имена методов буду указывать с именем типа и без параметров: <имя_типа>.<имя_метода>().

Обработкой элементов на этапе компиляции занимаются специальные классы-процессоры. Это классы которые наследуются от javax.annotation.processing.AbstractProcessor (можно просто реализовать интерфейс javax.annotation.processing.Processor). Больше про процессоры можно прочитать здесь и здесь. Самый важные метод в нем process. В котором мы можем получить список всех аннотированных элементов и провести необходимые проверки.

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {
  return false;
}

С начала, по наивности душевной, я думал, что работа с типами на этапе компиляции осуществляется в терминах рефлексии, но… нет. Там все основано на элементах.

Element (javax.lang.model.element.Element) — основной интерфейс для работы большинством структурных элементов языка. У элемента есть наследники, которые точнее определяют свойства конкретного элемента (за подробностями можно заглянуть сюда):

package ds.magic.example.implement; // PackageElement  

public class Unit // TypeElement
{
  private int id; // VariableElement
  
  public void setId(int id) { // ExecutableElement
    this.id = id;
  }
}

TypeMirror (javax.lang.model.type.TypeMirror) — нечто вроде Class<?>, возвращаемый методом getClass(). Например, их можно сравнивать чтобы узнать совпадают ли типы элементов. Получить его можно при помощи метода Element.asType(). Также это тип возвращают некоторые операции с типами, такие как TypeElement.getSuperclass() или TypeElement.getInterfaces().

Types (javax.lang.model.util.Types) — к этому классу советую присмотреться повнимательнее. Там можно найти много интересного. По сути, это набор утилит для работы с типами. Например, он позволяет получить обратно TypeElement из TypeMirror.

private TypeElement asTypeElement(TypeMirror typeMirror) {
  return (TypeElement)processingEnv.getTypeUtils().asElement(typeMirror);
}

TypeKind (javax.lang.model.type.TypeKind) — перечисление, позволяет уточнить информацию о типе, проверить является ли тип массивом (ARRAY), пользовательским типом (DECLARED), переменной типа (TYPEVAR) и т.д. Получить можно через TypeMirror.getKind()

ElementKind (javax.lang.model.element.ElementKind) — перечисление, поваляет уточнить информацию об элементе, проверить является ли элемент пакетом (PACKAGE), классом (CLASS), методом(METHOD), интерфейсом(INTERFACE) и т.д.

Name (javax.lang.model.element.Name) — интерфейс для работы с именем элемента, можно получить через Element.getSimpleName().

В основном, этих типов мне было достаточно для написания алгоритма проверки.

Хочу заметить еще одну интересную особенность. Реализации интерфейсов Element в Eclipse лежат в пакетах org.eclipse..., например элементы, которые представляю методы имеют тип org.eclipse.jdt.internal.compiler.apt.model.ExecutableElementImpl. Это натолкнуло меня на мысль, что эти интерфейсы реализуются каждой IDE самостоятельно.

Алгоритм проверки

Для начала нужно создать саму аннотацию. Про это уже и так довольно много написано (например здесь), поэтому не буду подробно на этом останавливаться. Скажу только, что для нашего примера нужно добавить две аннотации @Target и @Retention. Первая указывает, что нашу аннотацию можно применять только к методу, а вторая – что аннотация будет существовать только в исходном коде.

Аннотации нужно указать, какой именно интерфейс реализовывает аннотированный метод (тот метод к которому применена аннотация). Это можно сделать двумя способами: либо указать полное имя интерфейса строкой, например @Implement("com.ds.IInterface"), либо передать непосредственно класс интерфейса: @Implement(IInterface.class). Второй способ явно лучше. В этом случае за правильностью указанного имени интерфейса будет следить сам компилятор. Кстати, если назвать это член value() то при добавлении аннотации к методу не нужно будет явно указывать имя этого параметра.

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.SOURCE)
public @interface Implement
{
  Class<?> value();
}

Дальше начинается самое интересное — создание процессора. В методе process получаем список всех аннотированных элементов. За тем получаем саму аннотацию и ее значение — указанный интерфейс. В общем, каркас класса-процессора выглядит так:

@SupportedAnnotationTypes({"ds.magic.annotations.compileTime.Implement"})
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class ImplementProcessor extends AbstractProcessor
{
  private Types typeUtils;
  
  @Override
  public void init(ProcessingEnvironment procEnv)
  {
    super.init(procEnv);
    typeUtils = this.processingEnv.getTypeUtils();
  }

  @Override
  public boolean process(Set<? extends TypeElement> annos, RoundEnvironment env)
  {
    Set<? extends Element> annotatedElements = 
      env.getElementsAnnotatedWith(Implement.class);
      
    for(Element annotated : annotatedElements)
    {
      Implement annotation = annotatedElement.getAnnotation(Implement.class);
      TypeMirror interfaceMirror = getValueMirror(annotation);
      TypeElement interfaceType = asTypeElement(interfaceMirror);

      //...
    }

    return false;
  }

  private TypeElement asTypeElement(TypeMirror typeMirror) {
    return (TypeElement)typeUtils.asElement(typeMirror);
  }
}

Хочу заметить, что просто так взять и получить value аннотации. При попытке вызвать annotation.value() будет брошено исключение MirroredTypeException, а вот из него можно получить TypeMirror. Этот читерский способ, а также правильное получение value было я нашел тут:

private TypeMirror getValueMirror(Implement annotation)
{
  try {
    annotation.value();
  } catch(MirroredTypeException e) {
    return e.getTypeMirror();
  }
  return null;
}

Сама проверка состоит из трех частей, если хоть одна из них не пройдена, то нужно вывести сообщение об ошибке и переходить к следующей аннотации. Кстати, вывести сообщение об ошибке можно при помощи следующего метода:

private void printError(String message, Element annotatedElement)
{
  Messager messager = processingEnv.getMessager();
  messager.printMessage(Kind.ERROR, message, annotatedElement);
}

Первым делом нужно проверить, является ли value аннотации интерфейсом. Тут все просто:

if (interfaceType.getKind() != ElementKind.INTERFACE)
{
  String name = Implement.class.getSimpleName();
  printError("Value of @" + name + " must be an interface", annotated);
  continue;
}

Далее, необходимо проверить действительно ли класс, в котором находится аннотированный метод, реализует указанный интерфейс. Сначала я по глупости реализовал эту проверку руками. Но потом воспользовавшись хорошим советом, присмотрелся к Types и нашел там метод Types.isSubtype(), который проверит все дерево наследования и вернет true если указанный интерфейс там есть. Что немаловажно, умеет работать с обобщенными (generic) типами, в отличие от первого варианта.

TypeElement enclosingType = (TypeElement)annotatedElement.getEnclosingElement();
if (!typeUtils.isSubtype(enclosingType.asType(), interfaceMirror))
{
  Name className = enclosingType.getSimpleName();
  Name interfaceName = interfaceType.getSimpleName();
  printError(className + " must implemet " + interfaceName, annotated);
  continue;
}

И наконец, нужно удостоверится, что в интерфейсе есть метод с такой же сигнатурой что и аннотированный. Хотелось бы воспользоваться методом Types.isSubsignature(), но, к сожалению, он не правильно работает если у метода есть параметрами типа. А значит закатываем рукава и пишем все проверки руками. А их у нас снова три. Ну, точнее сигнатура метода состоит из трех частей: имени метода, типа возвращаемого значения и списка параметров. Нужно пройтись по всем методам интерфейса и найти тот который прошел все три проверки. Хорошо бы не забыть, что метод может быть унаследован от другого интерфейса и рекурсивно выполнить те же проверки для базовых интерфейсов.

Вызов нужно поместить в конец цикла в методе process, вот так:

if (!haveMethod(interfaceType, (ExecutableElement)annotatedElement))
{
  Name name = interfaceType.getSimpleName();
  printError(name + " don't have "" + annotated + "" method", annotated);
  continue;
}

А сам метод haveMethod() выглядит следующим образом:

private boolean haveMethod(TypeElement interfaceType, ExecutableElement method)
{
  Name methodName = method.getSimpleName();
  for (Element interfaceElement : interfaceType.getEnclosedElements())
  {
    if (interfaceElement instanceof ExecutableElement)
    {
      ExecutableElement interfaceMethod = (ExecutableElement)interfaceElement;
      
      // Is names match?
      if (!interfaceMethod.getSimpleName().equals(methodName)) {
        continue;
      }
      
      // Is return types match (ignore type variable)?
      TypeMirror returnType = method.getReturnType();
      TypeMirror interfaceReturnType = method.getReturnType();
      if (!isTypeVariable(interfaceReturnType)
          && !returnType.equals(interfaceReturnType))
      {
        continue;
      }
      
      // Is parameters match?
      if (!isParametersEquals(method.getParameters(),
          interfaceMethod.getParameters()))
      {
        continue;
      }
      return true;
    }
  }
  
  // Recursive search
  for (TypeMirror baseMirror : interfaceType.getInterfaces())
  {
    TypeElement base = asTypeElement(baseMirror);
    if (haveMethod(base, method)) {
      return true;
    }
  }
  
  return false;
}

private boolean isParametersEquals(List<? extends VariableElement> methodParameters, List<? extends VariableElement> interfaceParameters)
{
  if (methodParameters.size() != interfaceParameters.size()) {
    return false;
  }
  
  for (int i = 0; i < methodParameters.size(); i++)
  {
    TypeMirror interfaceParameterMirror = interfaceParameters.get(i).asType();
    if (isTypeVariable(interfaceParameterMirror)) {
      continue;
    }
    
    if (!methodParameters.get(i).asType().equals(interfaceParameterMirror)) {
      return false;
    }
  }
  return true;
}

private boolean isTypeVariable(TypeMirror type) {
  return type.getKind() == TypeKind.TYPEVAR;
}

Видите проблему? Нет? А она там есть. Дело в том, что я так и не смог найти способ получить фактические параметры типов которым для обобщенных интерфейсы. Все попытки получить таковые выдают максимум нечто вроде Predicate&ltT&gt вместо Predicate&ltString&gt.В итоге я не придумал ничего лучше чем просто игнорировать параметры типа. Проверка будет пройдена при любых фактических параметрах типа, даже если они не совпадают. К счастью, компилятор сам выдаст ошибку, если метод не имеет реализации по умолчанию и не реализован в базовом классе. Но все же, если кто-нибудь знает как это обойти, буду крайне благодарен за подсказку.

Подключение к Eclipse

Лично я люблю Eclipce и в своей практике использовал только его. Поэтому опишу способы подключения процессора именно к этой IDE. Чтобы Eclipse увидел процессор нужно запаковать его в отдельный .JAR, в котором будет и сама аннотация. При этом в проекте нужно создать папку META-INF/services и там создать файл javax.annotation.processing.Processor и указать полное имя класса процессора: ds.magic.annotations.compileTime.ImplementProcessor, в моем случае. На всякий случай приведу скриншот, а то когда у меня не ничего не работало, я чуть не начал грешить на структуру проекта.

image

Далее собираем .JAR и подключаем ее к своему проекту, сначала как обычную библиотеку, что бы видеть аннотация была видна в коде. Затем подключаем процессор (здесь подробнее). Для этого нужно открыть свойства проекта и выбрать:

  1. Java Compiler -> Annotation Processing и поставить галочку в «Enable annotation processing».
  2. Java Compiler -> Annotation Processing -> Factory Path поставить галочку в «Enable project specific settings». Затем нажать Add JARs… и выбрать ранее созданный JAR-файл.
  3. Согласится на перестроение проекта.

Итог

Все вместе и в Eclipse-проекте можно увидеть на GitHub. На момент написания статьи там всего два класса, если аннотацию можно так назвать: Implement.java и ImplementProcessor.java. Думаю, об их назначении вы уже догадались.

Возможно, кому-то эта аннотация может показаться бесполезной. Возможно, так и есть. Но лично я сам ею пользуюсь вместо @Override, когда имена методов плохо вписываются в назначение класса. И пока, у меня не возникло желания от нее избавится. В общем аннотацию я сделал для себя, а целью статьи было показать на какие грабли я при этом наступал. Надеюсь, у меня это получилось. Спасибо за внимание.

Автор: DoctorScript

Источник


* - обязательные к заполнению поля