[Из песочницы] Аннотации времени компиляции на примере @Implement
Все мы любим отлавливать ошибки на этапе компиляции, вместо исключений времени выполнения. Их устранить проще всего, компилятор сам показывает все места, нуждающиеся в исправлении. Хотя большинство проблем можно обнаружить только при запуске программы, все же мы стараемся сделать это как можно раньше. В блоках инициализации классов, в конструкторах объектов, при первом вызове метода и т.д. И иногда нам везет, и даже на этапе компиляции известно достаточно, чтобы проверить программу на наличие определенных ошибок.
В этой статье хочу поделиться опытом написания одной такой проверки. Если точнее то создание аннотации которая может выдавать ошибки, как это делает компилятор. Судя по тому, что в рунете информации на данную тему не так много, то описанные выше, счастливые ситуации бывают не часто.
Я опишу общий алгоритм проверки, а также все шаги и нюансы на которые я тратил время и нервные клетки.
Постановка задачи
В этом разделе я приведу пример использования этой аннотации. Если Вы уже знаете какую проверку хотите сделать можете смело его пропускать. Уверен, это никак не повлияет на полноту изложения.
Сейчас речь пойдет скорее о повышении читаемости кода нежели об устранении ошибок. Пример, можно сказать, из жизни, а точнее из моего хобби-проекта.
Допустим, есть класс 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
. Но это оказалось не так, и мне пришлось погрузиться в удивительный и пугающий мир проверок на этапе компиляции.
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<T>
вместо Predicate<String>
.В итоге я не придумал ничего лучше чем просто игнорировать параметры типа. Проверка будет пройдена при любых фактических параметрах типа, даже если они не совпадают. К счастью, компилятор сам выдаст ошибку, если метод не имеет реализации по умолчанию и не реализован в базовом классе. Но все же, если кто-нибудь знает как это обойти, буду крайне благодарен за подсказку.
Подключение к Eclipse
Лично я люблю Eclipce и в своей практике использовал только его. Поэтому опишу способы подключения процессора именно к этой IDE. Чтобы Eclipse увидел процессор нужно запаковать его в отдельный .JAR, в котором будет и сама аннотация. При этом в проекте нужно создать папку META-INF/services и там создать файл javax.annotation.processing.Processor и указать полное имя класса процессора: ds.magic.annotations.compileTime.ImplementProcessor
, в моем случае. На всякий случай приведу скриншот, а то когда у меня не ничего не работало, я чуть не начал грешить на структуру проекта.
Далее собираем .JAR и подключаем ее к своему проекту, сначала как обычную библиотеку, что бы видеть аннотация была видна в коде. Затем подключаем процессор (здесь подробнее). Для этого нужно открыть свойства проекта и выбрать:
- Java Compiler → Annotation Processing и поставить галочку в «Enable annotation processing».
- Java Compiler → Annotation Processing → Factory Path поставить галочку в «Enable project specific settings». Затем нажать Add JARs… и выбрать ранее созданный JAR-файл.
- Согласится на перестроение проекта.
Итог
Все вместе и в Eclipse-проекте можно увидеть на GitHub. На момент написания статьи там всего два класса, если аннотацию можно так назвать: Implement.java и ImplementProcessor.java. Думаю, об их назначении вы уже догадались.
Возможно, кому-то эта аннотация может показаться бесполезной. Возможно, так и есть. Но лично я сам ею пользуюсь вместо @Override
, когда имена методов плохо вписываются в назначение класса. И пока, у меня не возникло желания от нее избавится. В общем аннотацию я сделал для себя, а целью статьи было показать на какие грабли я при этом наступал. Надеюсь, у меня это получилось. Спасибо за внимание.