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