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

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

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

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


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

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

Допустим, есть класс 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:
MyPredicate implements Predicate&ltString&gt
{
  @Implement(Predicate.class)
  boolean test(String t) {
    return false;
  }
}

При анализе метода в классе, тип параметра String, а в интерфейсе — T, и все попытки получить вместо него String не к чему не привели. В итоге я не придумал ничего лучше чем просто игнорировать параметры типа. Проверка будет пройдена при любых фактических параметрах типа, даже если они не совпадают. К счастью, компилятор сам выдаст ошибку, если метод не имеет реализации по умолчанию и не реализован в базовом классе. Но все же, если кто-нибудь знает как это обойти, буду крайне благодарен за подсказку.

Подключение к 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, когда имена методов плохо вписываются в назначение класса. И пока, у меня не возникло желания от нее избавится. В общем аннотацию я сделал для себя, а целью статьи было показать на какие грабли я при этом наступал. Надеюсь, у меня это получилось. Спасибо за внимание.

PS. Благодарю пользователей ohotNik_alex и Comdiv за помощь в исправлении ошибок.

Комментарии (11)


  1. akurilov
    20.06.2018 22:25

    Прекрасная замена @Overrides. Только я бы назвал анноташку "Implements", чтобы естественнее читалась


    1. akurilov
      21.06.2018 20:58
      +1

      Я наврал. Анноташка таки называется «Override», а не «Overrides»


      1. DoctorScript Автор
        21.06.2018 22:04

        Ага, написать Implement как съесть печеньку и не запить чайком, вроде и вкусно, но чего-то не хватает. Но и существующая аннотация Override и совпадение имени аннотации с ключевым слово языка немного режет глаз.


  1. ohotNik_alex
    21.06.2018 10:48

    RotateCounter не может знать о том, свободен ли выбранный id

    эммм… а можно поинтересоваться откуда столько смелости просто выкинуть принцип инкапсуляции и размазать знания о функционале по всему проекту?
    как раз класс, занимающийся идентификаторами и должен заниматься проверками их валидности.


    вообще статья мне понравилась, но работать с таким я откровенно не хотел бы)
    во-первых, использование SOF должно сводиться к минимуму. не потому что там легко найти ответы (чтение документации полезнее списывания), а потому что можно перетащить чужие ошибки.
    во-вторых, если я возьму этот проект и запущу его в другой IDE или даже просто другом eclipse — оно работать будет? не уверен… но если будет — это потрясающе.
    ну и чисто эстетическое замечание — reflection- методы являются проктологией в Java. обычно их приплетают либо если все совсем плохо(сторонняя библиотека с приватными доступами etc), либо где-то в архитектуре ошибка. с точки зрения практики — задача интересная. но для поддержки неприменимо.


    Как я понимаю изначально проблема пришла из невозможности понять откуда взялся метод.
    JavaDoc тогда уж в помощь. Ну или пользуйтесь горячими клавишами вроде "перейти к родителю".


    /**
    * {@link ru.ohotnik.lab.SomeClass#myMethod(String, String)}   - ссылка на метод. в IDEA  доступна для перехода по клику. другие IDE думаю не хуже
    * {@link ru.ohotNik.lab.SomeClass} - ссылка на класс ...
    */

    Дело в том, что я так и не смог найти способ получить фактические параметры типов которым для обобщенных интерфейсы.

    небольшая опечатка. не совсем понял что не получилось.


    1. akurilov
      21.06.2018 21:01
      +1

      Как я понимаю изначально проблема пришла из невозможности понять откуда взялся метод.

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


      1. DoctorScript Автор
        22.06.2018 00:06

        Родственная душа)


    1. DoctorScript Автор
      22.06.2018 00:05

      Спасибо за такой развернутый комментарий.

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

      Дальше сложнее. Честно говоря, я не знаю что такое SOF (и гугл выдает нечто вроде Special Operations Force).

      Eclipse-проект нельзя просто так взять и запустить в другой IDE. А в другом Eclipse без проблем. К тому же, в коде или сборке .jar нет ничего eclipse-специфического. Как будет время надо будет сделать через gradle.

      Про reflection тоже не понятно. Это же не рефлексия. Рефлексия это получение информации о типе на этапе выполнения. А это обработка на этапе компиляции.

      Про JavaDoc согласен. Это красиво и без танцев с бубном. Но огорчает, что в C# эта возможность встроена в язык, а в Java нет.

      За опечатку отдельное спасибо. Я отредактировал тот абзац и постарался пояснить подробнее. Если не трудно, глянь, пожалуйста, абзац, стало ли понятнее?


  1. Zolushok
    21.06.2018 16:07
    +1

    IMHO, в Java реализация объектом дополнительных интерфейсов, не определяющих его (объекта) основную функцию, является спорной практикой и сама по себе усложняет понимание структуры. Лучше действительно вынести реализации этих интерфейсов в функции-геттеры, пусть это и добавит пару строк кода.


    А в тех случаях, когда непосредственная реализация дополнительных интерфейсов оправдана, необходимый контекст лучше добавить в имя метода при описании интерфейса (естественно, речь о случае, когда Вы сами его описываете).


    К примеру, если у вас есть некий интерфейс Magic с функцией getValue(), который вы планируете реализовывать как добавочный, то функцию лучше назвать не getValue(), а getMagicValue(), чтобы не путаться при чтении и не бояться конфликта имён.


    1. DoctorScript Автор
      22.06.2018 00:23

      Дело в вкуса. Лично мне не нравится плодить дополнительные объекты, они меня запутывают еще больше.

      Про getMagicValue() согласен, раньше именно так и делал. Но наличие аннотации над методом уже говорит, что он не простой. Слово класс, уверен, подсвечивается всеми IDE как ключевое слово языка. Все это позволяет понять, что метод для реализации интерфейса даже не читая его названия!


  1. Cdracm
    22.06.2018 00:24
    +1

    дык и чего получилось? можно пример использования?


    1. DoctorScript Автор
      22.06.2018 00:28

      А пример в конце раздела «Постановка задачи» не подходит? Там в классе UnitManager есть метод isValueFree() помеченный @Implements. Или я неправильно понял о каком примере идет речь?