Плагин javac или добавляем Extension Methods в Java
от aNNiMON
В Java 8 появилась возможность писать плагины к компилятору javac. С их помощью можно получать управление на нужном этапе компиляции и производить дополнительные проверки или изменения. Каждый плагин имеет название и может принимать аргументы для настройки своей работы.
Если сравнивать с процессорами аннотаций, то плагины к компилятору более гибкие и простые в использовании. Они не вызывают перекомпиляцию, если был сгенерирован какой-то класс, получить управление можно практически на любом этапе компиляции, вплоть до кодогенерации.
Настройка
Для реализации плагина нужно добавить в зависимости tools.jar, который находится в составе JDK.
- dependencies {
- compile files("${System.env.JAVA_HOME}/lib/tools.jar")
- }
После этого создаём класс плагина com.example.javacplugin.helloworld.HelloWorldPlugin, который реализует интерфейс com.sun.source.util.Plugin. В методе getName задаём удобное имя, а метод init будет стартовой точкой входа нашего плагина.
- package com.example.javacplugin.helloworld;
- import com.sun.source.util.JavacTask;
- import com.sun.source.util.Plugin;
- public class HelloWorldPlugin implements Plugin {
- @Override
- public String getName() {
- return "Hello";
- }
- @Override
- public void init(JavacTask task, String... args) {
- System.out.println("Hello, world!");
- }
- }
Далее, регистрируем плагин для загрузки ServiceLoader'ом, добавив в файл resources/META-INF/services/com.sun.source.util.Plugin строку: com.example.javacplugin.helloworld.HelloWorldPlugin.
Собираем проект, запускаем компилятор с параметрами -processorpath - путь к jar-файлу плагина и -Xplugin - имя плагина, которое мы задали в getName:
javac -processorpath plugin.jar -Xplugin:Hello Main.java
В результате получаем заветную строчку Hello, world!
Плагину можно передавать аргументы: -Xplugin:"Hello arg1 arg2", это может пригодиться, например, для передачи некоторого числа методов, превысив которые будет выдана ошибка компиляции.
Стадии компиляции
При компиляции java-исходника javac проходит следующие стадии:
1. Parse. Читаются исходные файлы, затем токены преобразуются в элементы абстрактного синтаксического дерева.
2. Enter. Строится таблица символов путём обхода дерева.
3. Process (annotations). Если в компиляции участвуют процессоры аннотаций, то происходит обработка аннотаций, затем, если сгенерировались новые классы, компилятор вновь переходит к стадии Parse.
4. Attribute. Синтаксические деревья помечаются атрибутами. На этом этапе происходит разрешение имён, проверка типов, и оптимизация свёртывания констант.
5. Flow. Выполняется анализ потока данных. Проверяется правильность присвоений, цепочек вызовов, а также достижимость кода.
6. Desugar. Происходит преобразование АСД с целью убрать синтаксический сахар. На этой стадии перечисления преобразуются в специальный класс, а вместо лямбд и ссылок на методы подставляются фабрики и прочие классы из пакета java.lang.invoke.
7. Generate. Генерируются class-файлы.
При написании плагина мы можем добавить обработчик перед началом и после стадий: Parse, Enter, Process, Flow, Generate.
Пример 1. Плагин подсчёта символов в исходнике
- public class CharactersCounterPlugin implements Plugin {
- @Override
- public String getName() {
- return "CharStat";
- }
- @Override
- public void init(JavacTask task, String... args) {
- final CharactersCounterTask counterTask = new CharactersCounterTask(new ConcurrentHashMap<>());
- task.addTaskListener(counterTask);
- }
- }
- public class CharactersCounterTask implements TaskListener {
- private final Map<String, Integer> stat;
- public CharactersCounterTask(Map<String, Integer> stat) {
- this.stat = stat;
- }
- @Override
- public void started(TaskEvent event) {
- if (event.getKind() == TaskEvent.Kind.PARSE) {
- final JavaFileObject source = event.getSourceFile();
- final String name = source.getName();
- try {
- stat.put(name, source.getCharContent(true).length());
- System.out.println(name + " processed");
- } catch (IOException ex) {
- System.out.println(name + " fail");
- }
- }
- }
- @Override
- public void finished(TaskEvent event) {
- }
- }
Плагин вызывается перед началом стадии Parse, считает количество символов в исходнике и добавляет эту информацию в глобальное хранилище, чтобы потом по окончанию компиляции вывести статистику. Хранилищем выступает обычный Map, а из объекта TaskEvent мы можем получить объект SourceFile, в котором уже будет путь к файлу и всё его содержимое. Остатся только положить данные в Map:
- String name = source.getName();
- stat.put(name, source.getCharContent(true).length());
Статистику покажем в самом конце, после того как будет выполнена последняя стадия Generate:
- public class CharactersCounterTask implements TaskListener {
- private Consumer<Map<String, Integer>> consumer;
- public void onComplete(Consumer<Map<String, Integer>> consumer) {
- this.consumer = consumer;
- }
- //...
- @Override
- public void finished(TaskEvent event) {
- if (event.getKind() == TaskEvent.Kind.GENERATE) {
- if (consumer != null) {
- consumer.accept(stat);
- consumer = null;
- }
- }
- }
- }
- // CharactersCounterPlugin init
- counterTask.onComplete(classCharsCount -> {
- System.out.println("Characters count statistics: ");
- System.out.format("\t processed %d files%n", classCharsCount.size());
- System.out.format("\t summary %d chars%n", classCharsCount.values().stream()
- .mapToInt(Integer::intValue)
- .sum());
- });
Выполнив компиляцию на каком-нибудь тестовом примере, получим подобный результат:
- $ ./gradlew :testcharstat:build
- ...
- :testcharstat:compileJava
- ../charstat/Main.java processed
- ../charstat/Test.java processed
- Characters count statistics:
- processed 2 files
- summary 239 chars
- :testcharstat:processResources UP-TO-DATE
- :testcharstat:classes
- ...
Пример 2. Плагин подсчёта количества методов
Плагин подсчитывает количество методов в каждом из исходников, а потом выводит общее число скомпилированных методов и названия классов в порядке убывания числа методов.
- public class MethodsCounterTask implements TaskListener {
- @Override
- public void started(TaskEvent taskEvent) { }
- @Override
- public void finished(TaskEvent event) {
- if (event.getKind() == TaskEvent.Kind.PARSE) {
- CompilationUnitTree compilationUnit = event.getCompilationUnit();
- MutableInteger counter = new MutableInteger();
- compilationUnit.accept(new MethodsVisitor(), counter);
- stat.put(event.getSourceFile().getName(), counter.get());
- }
- }
- }
- public class MethodsVisitor extends TreeScanner<Void, MutableInteger> {
- @Override
- public Void visitMethod(MethodTree methodTree, MutableInteger input) {
- input.increment();
- return super.visitMethod(methodTree, input);
- }
- }
Обработка происходит после завершения стадии Parse, когда у нас уже есть готовое АСД. Запускается MethodVisitor, который увеличивает значение счётчика каждый раз при посещении метода. Затем, как и в предыдущем примере, статистика помещается в Map.
На тестовом примере получаем следующий вывод:
- $ ./gradlew :testmethodscount:build
- ...
- :testmethodscount:compileJava
- Methods count statistics:
- processed 2 files
- summary 15 methods
- ../methodscount/Test.java: 10
- ../methodscount/Main.java: 5
- :testmethodscount:processResources UP-TO-DATE
- ,,,
Пример 3. Операторы доступа к Map как к массиву
Плагин добавляет возможность индексации Map по стороковому ключу, как будто это массив:
- Map<String, String> map = new HashMap<>();
- map["key"] = "ten"; // map.put("key", "ten")
- System.out.println(map["key"]); // map.get("key")
- public class MapAccessOperatorTask implements TaskListener {
- private final MapAccessReplacer replacer;
- public MapAccessOperatorTask(JavacTask task) {
- this.replacer = new MapAccessReplacer(task);
- }
- @Override
- public void started(TaskEvent taskEvent) { }
- @Override
- public void finished(TaskEvent event) {
- if (event.getKind() == TaskEvent.Kind.PARSE) {
- CompilationUnitTree compilationUnit = event.getCompilationUnit();
- ((JCTree.JCCompilationUnit) compilationUnit).accept(replacer);
- }
- }
- }
- public class MapAccessReplacer extends TreeTranslator {
- @Override
- public void visitAssign(JCAssign tree) {
- if (tree.getVariable().getKind() == Tree.Kind.ARRAY_ACCESS) {
- JCArrayAccess arrayAccess = (JCArrayAccess) tree.getVariable();
- if (arrayAccess.getIndex().getKind() == Tree.Kind.STRING_LITERAL) {
- JCExpression methodSelect = make.Select(arrayAccess.getExpression(), names.fromString("put"));
- result = make.Apply(List.nil(), methodSelect, List.of(arrayAccess.getIndex(), tree.getExpression()));
- return;
- }
- }
- super.visitAssign(tree);
- }
- @Override
- public void visitIndexed(JCArrayAccess tree) {
- if (tree.getIndex().getKind() == Tree.Kind.STRING_LITERAL) {
- JCExpression methodSelect = make.Select(tree.getExpression(), names.fromString("get"));
- result = make.Apply(List.nil(), methodSelect, List.of(tree.getIndex()));
- return;
- }
- super.visitIndexed(tree);
- }
- }
Здесь используется обход дерева с модификацией.
В первом случае, если в выражении присваивания JCAssign используется присваивание некоторого результата элементу массива, например x["key"] = ..., и ключом выступает строковый литерал, то мы преобразуем дерево так, чтобы на переменной x вызывался метод put с ключом key и значением, равным выражению, которое было после знака равно.
- // Было
- x["key"] = expr;
- // Стало
- x.put("key", expr)
Во втором случае, если массив индексируется строковым литералом, к примеру s = x["key"], заменяем это выражение на вызов метода get.
- // Было
- s = x["key"];
- // Стало
- s = x.get("key")
Кстати, плагин не ограничивает нас только классами Map, можно использовать любые объекты, у которых есть методы put или get.
- class Test {
- public int get(String s) {
- System.out.println("get " + s);
- return 10;
- }
- }
- Test test = new Test();
- int x = test["5"];
Пример 4. Перегрузка операторов
Плагин позволяет вместо методов add, subtract, multiply и divide использовать операторы +, -, *, / соответственно.
- public final class OperatorOverloadingTask implements TaskListener {
- private final OperatorOverloadingVisitor visitor;
- public OperatorOverloadingTask(JavacTask task) {
- this.visitor = new OperatorOverloadingVisitor(task);
- }
- @Override
- public void started(TaskEvent event) {
- if (event.getKind() == TaskEvent.Kind.ANALYZE) {
- CompilationUnitTree compilationUnit = event.getCompilationUnit();
- ((JCTree.JCCompilationUnit) compilationUnit).accept(visitor);
- }
- }
- @Override
- public void finished(TaskEvent event) { }
- }
- public final class OperatorOverloadingVisitor extends TreeTranslator {
- public OperatorOverloadingVisitor(JavacTask task) {
- operators = new EnumMap<>(Tree.Kind.class);
- operators.put(Tree.Kind.PLUS, names.fromString("add"));
- operators.put(Tree.Kind.MINUS, names.fromString("subtract"));
- operators.put(Tree.Kind.MULTIPLY, names.fromString("multiply"));
- operators.put(Tree.Kind.DIVIDE, names.fromString("divide"));
- }
- @Override
- public void visitBinary(JCTree.JCBinary bt) {
- super.visitBinary(bt);
- if (!operators.containsKey(bt.getKind())) return;
- JCExpression methodCall;
- final JCExpression left = bt.getLeftOperand();
- switch (left.getKind()) {
- case IDENTIFIER:
- JCIdent lIdent = (JCIdent) left;
- methodCall = treeMaker.Ident(lIdent.getName());
- break;
- case METHOD_INVOCATION:
- methodCall = left;
- break;
- default:
- return;
- }
- methodCall = treeMaker.Select(methodCall, operators.get(bt.getKind()));
- final JCMethodInvocation app = treeMaker.Apply(
- List.nil(), methodCall, List.of(bt.getRightOperand())
- );
- result = app;
- }
- }
Здесь для бинарной операции производится замена поддерживаемых операторов на вызов метода:
- // Было
- t = x + y * z()
- // Стало
- t = x.add(y.multiply(z()))
Отличной демонстрацией может послужить класс BigInteger:
- BigInteger x1 = BigInteger.TEN;
- BigInteger x2 = BigInteger.valueOf(120);
- BigInteger x3 = x1 + x2;
- // BigInteger x3 = x1.add(x2);
- BigInteger x4 = BigInteger.valueOf(2) * x3;
- // BigInteger x4 = BigInteger.valueOf(2).multiply(x3);
- BigInteger x5 = x4 - x2 / x1 + x3;
- // BigInteger x5 = x4.subtract(x2.divide(x1)).add(x3);
Однако стоит обратить внимание, что проверка типов не делается, так что на более серьёзном коде плагин может заменить не то, что нужно.
Пример 5. Поддержка методов-расширений
Наконец, последний пример - плагин, добавляющий поддержку extension-методов. С его помощью можно добавить метод в любой класс так, как будто он там действительно есть:
- "string".reverse();
- random.nextInt(0, 100);
- stream.sortBy(Person::name).forEachIndexed((i, person) -> ...);
Сперва обусловимся, что искать методы-расширения будем в том же классе, в котором использовать их. Если метод имеет модификаторы public static и хотя бы один аргумент, то он становится кандидатом в метод-расширение.
Модификация AST будет при всё том же обходе дерева. Для начала соберём всех кандидатов при посещении класса:
- @Override
- public Void visitClass(ClassTree ct, Void p) {
- final TypeElement currentClass = (TypeElement) TreeInfo.symbolFor((JCTree) ct);
- extensionMethods = Optional.ofNullable(currentClass)
- .map(TypeElement::getEnclosedElements)
- .map(enclosedElements -> enclosedElements
- .stream()
- .filter(e -> e.getModifiers().containsAll(PUBLIC_STATIC))
- .filter(e -> e.getKind() == ElementKind.METHOD)
- .map(ExecutableElement.class::cast)
- .filter(e -> !e.getParameters().isEmpty())
- .map(ExtensionMethod::new)
- .collect(Collectors.toCollection(ExtensionMethods::new))
- )
- .orElseGet(ExtensionMethods::new);
- return super.visitClass(ct, p);
- }
Берём все элементы, которые находятся в классе, затем:
- фильтруем сначала те, которые имеют модификатор public static
- затем, фильтруем сами методы
- каждый оставшийся элемент, это гарантированно элемент-метод, преобразуем к типу ExecutableElement
- если метод не принимает параметров - он нам не подходит,
- оборачиваем элемент в собственную обёртку - класс ExtensionMethod
- наконец, собираем все оставшиеся методы в список.
- public final class ExtensionMethods extends ArrayList<ExtensionMethod> {
- public Stream<String> names() {
- return stream().map(ExtensionMethod::getName);
- }
- }
Следующий этап, обработать вызов метода:
- @Override
- public Void visitMethodInvocation(MethodInvocationTree tree, Void p) {
- super.visitMethodInvocation(tree, p);
- if (!extensionMethods.isEmpty()) {
- processMethodInvocation((JCMethodInvocation) tree);
- }
- return p;
- }
- private void processMethodInvocation(JCMethodInvocation tree) {
- if (tree == null) return;
- if (tree.meth.getKind() != Tree.Kind.MEMBER_SELECT) return;
- final String methodName = ((JCFieldAccess) tree.meth).name.toString();
- if (extensionMethods.names().noneMatch(m -> m.equals(methodName))) return;
- final JCExpression receiver = ((JCFieldAccess) tree.meth).getExpression();
- extensionMethods.stream()
- .filter(m -> methodName.equals(m.getName()))
- .filter(m -> m.getParametersCount() == tree.getArguments().length() + 1)
- .filter(m -> checkTypes(m, receiver))
- .findAny()
- .ifPresent(me -> {
- tree.args = tree.args.prepend(receiver);
- Symbol symbol = (Symbol) me.getElement();
- tree.meth = make.Ident(symbol);
- System.out.println(methodName + " processed");
- });
- }
- private boolean checkTypes(ExtensionMethod m, JCExpression receiver) {
- attr.attribExpr(receiver, methodEnv);
- TypeMirror type = receiver.type;
- TypeMirror param = typeUtils.erasure(m.getElement().getParameters().get(0).asType());
- if (type == null || param == null) return false;
- return typeUtils.isAssignable(param, type);
- }
Если мы встретили вызов метода, проверяем, есть ли extension-метод с таким именем. Если есть, проверяем, соответствует ли он текущему вызову. Для этого у метода должны совпадать количество аргументов и их типы. Типы сравниваются по стёртому типу (type erasure) и присваиваемости (isAssignable), а значит не обязательно должны строго совпадать. Если такой метод найден, проводим замену:
- // Было
- "string".method(1, 2, x.ext())
- // Стало
- method("string", 1, 2, ext(x))
И теперь можно встраивать нужные методы. Например, добавить в Stream API sortBy и filterNot:
- public static IntStream filterNot(IntStream stream, IntPredicate predicate) {
- return stream.filter(predicate.negate());
- }
- public static <T, R extends Comparable<? super R>> Stream<T> sortBy(Stream<T> stream, Function<? super T, ? extends R> f) {
- return stream.sorted((o1, o2) -> f.apply(o1).compareTo(f.apply(o2)));
- }
- apps.stream()
- .filterNot(Application::isInstalled)
- .sortBy(Application::getDownloadingTime())
Вот так, с помощью нехитрых модификаций АСД, Java можно превратить в Kotlin... Но зачем?!
Исходный код: GitHub