Хабрахабр

[Из песочницы] Аннотации времени компиляции на примере @Implement

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

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

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

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

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

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

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

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

IClient.isValueFree(int value) {
}

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

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

Пример кода UnitManager

public class Unit { private int id;
} public class UnitManager implements RotateCounter.IClient
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. Обработкой элементов на этапе компиляции занимаются специальные классы-процессоры. Processor). AbstractProcessor (можно просто реализовать интерфейс javax.annotation.processing. Самый важные метод в нем process. Больше про процессоры можно прочитать здесь и здесь. В котором мы можем получить список всех аннотированных элементов и провести необходимые проверки.

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

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

Element) — основной интерфейс для работы большинством структурных элементов языка. Element (javax.lang.model.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) — к этому классу советую присмотреться повнимательнее. Types (javax.lang.model.util. По сути, это набор утилит для работы с типами. Там можно найти много интересного. Например, он позволяет получить обратно 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) — перечисление, поваляет уточнить информацию об элементе, проверить является ли элемент пакетом (PACKAGE), классом (CLASS), методом(METHOD), интерфейсом(INTERFACE) и т.д. ElementKind (javax.lang.model.element.

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

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

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

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

Для начала нужно создать саму аннотацию. Про это уже и так довольно много написано (например здесь), поэтому не буду подробно на этом останавливаться. Скажу только, что для нашего примера нужно добавить две аннотации @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> вместо Predicate&ltString>.В итоге я не придумал ничего лучше чем просто игнорировать параметры типа. Проверка будет пройдена при любых фактических параметрах типа, даже если они не совпадают. К счастью, компилятор сам выдаст ошибку, если метод не имеет реализации по умолчанию и не реализован в базовом классе. Но все же, если кто-нибудь знает как это обойти, буду крайне благодарен за подсказку.

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»