Трансформации AST — Первый шаг к тяжёлым веществам

    А давайте сделаем magic с вашим Java кодом. Вот такой:


    Берем это:
    import groovy.transform.Canonical
    import groovy.transform.TupleConstructor
    
    @Canonical
    @TupleConstructor
    class Person {
        int id
        String firstName
        String lastName
        Date birthdate
    }
    

    Компилируем, и в байткоде получаем аналог вот этого:
    Адский бойлерплейт на Джаве на 100 с лишним строк
    import java.util.Date;
    import java.util.Map;
    
    public class Person {
        private int id;
        private String firstName;
        private String lastName;
        private Date birthdate;
    
        //Эта штука добавлена @TupleConstructor-ом
        public Person(Map parameters){
            this.id = (int) parameters.get("id");
            this.firstName = (String) parameters.get("firstName");
            this.lastName = (String) parameters.get("lastName");
            this.birthdate = (Date) parameters.get("birthdate");
        }
    
        public Person(int id, String firstName, String lastName, Date birthdate) {
            this.id = id;
            this.firstName = firstName;
            this.lastName = lastName;
            this.birthdate =birthdate;
        }
    
        public Person(int id, String firstName, String lastName) {
            this(id, firstName, lastName, null);
        }
    
        public Person(int id, String firstName) {
            this(id, firstName, null, null);
        }
    
        public Person(int id) {
            this(id, null, null, null);
        }
    
        public Person() {
            this(0, null, null, null);
        }
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
    
            Person person = (Person) o;
    
            if (id != person.id) return false;
            if (birthdate != null ? !birthdate.equals(person.birthdate) : person.birthdate != null) return false;
            if (firstName != null ? !firstName.equals(person.firstName) : person.firstName != null) return false;
            if (lastName != null ? !lastName.equals(person.lastName) : person.lastName != null) return false;
    
            return true;
        }
    
        @Override
        public int hashCode() {
            int result = id;
            result = 31 * result + (firstName != null ? firstName.hashCode() : 0);
            result = 31 * result + (lastName != null ? lastName.hashCode() : 0);
            result = 31 * result + (birthdate != null ? birthdate.hashCode() : 0);
            return result;
        }
    
        @Override
        public String toString() {
            return "Person{" +
                    "id=" + id +
                    ", firstName='" + firstName + '\'' +
                    ", lastName='" + lastName + '\'' +
                    ", birthdate=" + birthdate +
                    '}';
        }
    
        public int getId() {
            return this.id;
        }
    
        public void setId(int paramInt) {
            this.id = paramInt;
        }
    
        public String getFirstName() {
            return this.firstName;
        }
    
        public void setFirstName(String paramString) {
            this.firstName = paramString;
        }
    
        public String getLastName() {
            return this.lastName;
        }
    
        public void setLastName(String paramString) {
            this.lastName = paramString;
        }
    
        public Date getBirthdate() {
            return this.birthdate;
        }
    
        public void setBirthdate(Date paramDate) {
            this.birthdate = paramDate;
        }
    }
    



    Ну, как-бы да, приятно. Но ничего уникального, вот-же есть Lombok, не говоря уже о способности любого хорошего IDE сначала генерить, а потом прятать всесь этот бойлерплейт.

    Так зачем именно Groovy, почему AST transformations?
    В этой статье я попробую вкраце обосновать, зачем пользоваться Groovy AST transformations в Java проектах, и (опять-же вкраце) рассказать какие AST transfromations есть в Groovy сегодня. Если вы уже знаете зачем, и хотите только «как и что», смело листайте к «Введение в AST transformations».

    Итак, почему AST transformations а не Lombok?


    Начнем с того, что для того чтобы пользоваться AST transformations вам не нужно ни знать Groovy, ни писать на Groovy, ни запускать Groovy в рантайме. Трансформация происходит во время компиляции сорцов и Groovy добавляется одним jar-ом в список зависимостей. Всё.
    Таким образом, AST transformations являются прекрасным способом «протащить» Groovy в ваш проект: «Смотри, босс, это ничего страшного, это просто ещё одна библиотека для борьбы с бойлерплетом!». А потом, уже, конечно, шаг за шагом, тестом на Споке, за билдом на Грейдле, в вашем коде появится настоящий Groovy — динамический, фунциональный и элегантный. AST transformations это только первый шаг.
    Кроме того, AST transformations намного более расширяемы, мощны и универсальны, чем Ломбок.
    Последнее, но не менее важное — AST transformations прекрасно поддерживаются в любом IDE с поддержкой Groovy, а не только в Эклипсе.
    Фронтальное сравнение Ломбока с AST transformations явно выходит за рамки этой статьи, так что на этом остановимся.

    Мне кажется само собой разумеющимся, что генерация байткода имеет огромное преимущество над генерацией (а потом «схлопыванием» в редакторе чтобы не мозолило глаза) исходного кода — генерация происходит во время компилирования, её не нужно «поддерживать». Один пример — IntelliJ IDEA прекрасно генерирует hashcode и equals. При добавлении нового поля, я ручками стираю эти 2 метода и генерю их заново. Фу.

    Можно ещё многое сказать о преимуществах AST transformations для разработчиков как Java, так и Groovy, но, я надеюсь, идея ясна. Пора переходить к практике.

    Введение в AST transformations


    Одна из самых главных плюшек в Groovy, это, конечно метапрограммирование. Оно бывает двух типов — во время компиляции и во время исполнения.
    Метапрограммирование во время исполнения это примерно «ах, вы вызвали метод, которого не существует? Не страшно, мы сейчас чего-нить придумаем, на основе того, что вы имели ввиду, когда вызывали этот метод». Примеров такого масса — практически любая библиотека Groovy основана на таких штуках, будь то билдеры, сларперы, Grails, Ratpack, Gradle, и все остальное. Но, сейчас не об этом (если хотите об этом, смотрите пункт 1 наглого пиара в конце поста).

    Сейчас мы поговорим о метапрограммировании во время компиляции, а именно о том, как просто написав в коде одно, в байткоде получить другое (ну, или дополнительное).

    Начнем мы с трансформации, которая прошита прямо в самом Groovy, без всяких аннотаций и прочих добавок.
    Пишем:
    class Person {
        String name
    }
    

    На выходе получаем байткод, в котором все поля — private (в данном случае — name), и прописаны все getters и setters (ну, в данном случае только getName() и setName(String name), но идея ясна).

    Эта прекрасная мелочь является полноценным примером метапрограммирования во время компиляции.

    Посмотрев на это небольшое избавление от бойлерплейта, чудесный человек Danno Ferrin сказал себе: «Но ведь есть ещё много бойлерплейта, кроме геттеров и сеттеров, и не у всех они одинаковые! Давайте-ка придумаем чего-нить подключаемое и расширяемое!» И так родились AST Transformations (первая, как ни странно, была @Bindable. Хотя, если посмотреть, сколько кода она выкидывает, может и не странно).

    AST transformations это набор аннотаций, которые меняют абстрактное синтаксическое дерево налету во время компиляции Groovy. Можно сказать, что добавление getters и setters это встроенная трансформация AST, которая работает всегда, без добавления аннотаций. Остальные-же включаются только по требованию.

    Давайте посмотрим что у нас есть:
    • Итак, аннотация-пионер @Bindable и её подруга @Vetoable превращают геттеры и сеттеры в настоящие properties, с возможностью нацеплять на них listener-ы и слушать, регаривать и запрещать изменения.
    • Очень модные нынче словечки @Category и @Mixin добавляют природу одного класса в другой класс. Ну, примеси!
    • @Delegate добавляет все методы, которые существуют у делегата, имплементируя их, натурально, делегацией, да?
      Пишем:
      class Event {
          String name
          @Delegate Date date
      }
      

      Получаем готовую делегацию
      import java.util.Date;
      
      public class Event {
          private String name;
          private Date date;
      
          public String getName() {
              return this.name;
          }
      
          public void setName(String name) {
              this.name = name;
          }
      
          public Date getDate() {
              return this.date;
          }
      
          public void setDate(Date paramDate) {
              this.date = paramDate;
          }
      
      
          public boolean after(Date otherDate) {
              return date.after(otherDate);
          }
      
          public boolean before(Date otherDate) {
              return date.before(otherDate);
          }
      
          public long getTime() {
              return date.getTime();
          }
      
          public void setTime(long timestamp) {
              date.setTime(timestamp);
          }
      }
      


    • @Immutable делает класс неизменяемым, а конкретно:
      1. сеттеры кидают ReadOnlyPropertyException
      2. класс становится final
      3. поля становятся private и final
      4. появляются конструкторы со всеми полями: как просто в параметрах, так и в мапе (как в первом примере)
      5. появляется код, который создает резервные копии для изменяемых компонентов
      6. появляются equals, hashcode и toString

    • Вот ещё пачка подобных борцов с бойлерплейтом: @InheritConstructors добавляет все конструкторы из супер-класса, @TupleConstructor добавляет конструктор map, в котором ключи — названия полей, а значения — значения (см. первый пример этой стати), @AutoClone и @AutoExternalize добавляет соответствующие методы, а @Canonical делает «правильный Джава класс» — с конструктором без параметров, конструкторами, которые все параметры принимают (как подряд, так и мапой), и equals, hashCode и toString-ом. Ну, как @Immutable, только mutable — его мы тоже видели в первом примере.
    • Ещё один модный термин! @Lazy создаст лениво-инициализруемое поле (по первому требованию), опционально, обвернутое в soft-reference
    • @Newify позволяет создавать объекты с помощью метода new вместо названия конструктора (как в Руби), или, наоборот, только по названию конструктора, без new (как в Пайтоне). Тут, пожалуй, не помешает пример:
      @Newify rubyLikeNew() {
          assert Integer.new(42) == 42
      }
      

      или даже
      @Newify([Tree, Leaf]) buildTree() {
          Tree(Tree(Leaf(1), Leaf(2)), Leaf(3))
      }
      

      В последнем примере мы создаем Tree и Leaf без использования new. Сравните с аналогом на Java:
      public Tree buildTree() {
          return new Tree(new Tree(new Leaf(1), new Leaf(2)), new Leaf(3));
      }
      

    • А вот вам исправление давней несправедливости: в Груви по умолчанию все поля public. A как сделать package? Через @PackageScope трансформацию!
    • Вне зависимости от того, считаете ли вы Singleton паттерном, или анти-паттерном, иногда приходится его писать. Ну, или просто поставить @Singleton над классом, и лениво-инициализируемый синглтон с двойной проверкой локинга готов.
    • Наш, #razborpoletov-ный Андрей написал чудесную, вошедшую в Груви 2.2 @Memoized, которая запоминает результат работы метода, и если он вызывается ещё раз, отдает результат немедленно (и да, параметры имеют значение)
    • И напоследок — аннотация-анекдот @NotYetImplemented — она переворачивает результаты JUnit тестов: те, которые должны падать, проходят, и наоборот. Кроме того, что это напоминает def true=false //happy debugging, эта штука полезна для TDD — накидываем тесты для всех методов, включая те, которые ещё не прописаны, и заставляем тесты, которые ещё не должны и не могут работать, проходить с помощью @NotYetImplemented. Таким образом, падения этих тестов не будут мешать нам тестировать остальные.

    И это ещё не всё! Есть ещё архи-важный @CompileStatic, @Field, и целый набор аннотаций для облечения страданий по concurency, но это, всё-же, в другой раз (ну, или, смотрите пункт 1 наглого пиара в конце поста).

    P.S. Теперь, когда вы знаете о чем речь, вот вам две интересные хабра-статьи, о том как и чем писать новые AST трансформации. Об этом же смотрите ниже, в пункте 2 наглого пиара.

    А теперь наглый пиар из 2 пунктов:
    1. Кому нужно Грувей с нуля и до достаточно продвинутого упора, айда на мои трейнинги, 17-го апреля в Москве и 15-го апреля в Казани (стучать alexbel)
    2. Кому расчленёнки абстрактного синтаксического дерева и написания собственных AST трансформаций для борьбы с вашими собственными тараканами бойлерплейтами, айда на мои доклады на JPoint 18-го апреля и на JavaDay Kazan 16-го апреля (стучать опять alexbel)
    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 35

      +1
      Таки решился:)

      От себя скажу, что AST трансформации — определённо киллер фича Groovy. Если мы раньше использовали AOP, и это казалось чем-то крутым и решающим 100% наших проблем, то Groovy расширил эти 100% в разы, показав нам насколько мы ошибались.

      На правах рекламы хочу поделиться примером активного использования ASTT — compile-time генератор конверторов объектов для JVM (Прощай Dozer):

      github.com/bsideup/GOM/blob/master/src/test/groovy/ru/trylogic/gom/tests/TestConfigBuilder.groovy
      github.com/bsideup/GOM/blob/master/src/test/groovy/ru/trylogic/gom/tests/SimpleTest.groovy

      Причем сам код конвертации конкретных значений не такой уж и сложный в итоге:
      github.com/bsideup/GOM/tree/master/src/main/groovy/ru/trylogic/gom/converters

      Спасибо за развитие интереса к ASTT и упоминание моей статьи про MacroGroovy;) Будем совместными усилиями развивать тему.
        +2
        Про MacroGroovy не только тут упомянул, но и добавил в доклады на JPoint и JavaDay. Офигительная штука.
          +1
          Cédric Champeau обещал посмотреть через неделю внимательней, вроде как его тоже заинтересовал. Постараюсь продвинуть его в groovy-core, где ему и место имхо.

          К слову, саму идею я взял из Haxe-a: haxe.org/manual/macros
            +1
            Раз так, я Седрику тоже тогда на мозг капну при случае, ибо сейчас почти каждый день общаемся (они релизы на Бинтрей переводят).
              +1
              Буду благодарен, форс от такого большого дядьки мира Groovy ускорил бы процесс в разы.
        +2
        Не, выглядит круто, вопрос в том, что как с этим в IDE дела обстоят? Тот же ломбок весь проект красным красит, если открывать его в Идее — плагины для него это буэ и непереносимость сред. Хотелось бы видеть все это уже bundled.
          +2
          Практически для всех аннотаций, перечисленных в статье, есть отличная поддержка IDEA, она понимает, что как генерирует и позволяет с этим работать с блекджеком и автокомплитом
          +2
          Барух, я не понял — я могу эти трансформации на Ява классы навешивать? Кто в рантайме это все применять будет?
            +3
            Можно аннотировать Java-классы, если используется Groovy-компилятор, а не стандартный javac. Все AST трансформации происходят на этапе компиляции и меняют результатирующий байт-код

            P.S. больная тема, поэтому суюсь везде с ответом:) Сори, если ожидался ответ именно от Баруха
              +1
              Да без разницы, я бы написал тоже самое (ибо ФАКТ).
                0
                1. как это выглядит в грейдле?
                2. как скажется на скорости компиляции?
                3. Что скажет дебагер, когда я начну заходить в метод, которого нет?
                  +1
                  1. Кладешь java классы в директорию src/main/groovy
                  2. Медленно.
                  3. Идея знает про ASTT, так что зайти туда не получится.
                    0
                    Т.е. по сути это будут груви классы? Или по расширению оно будет знать что это Ява?
                    Я смогу в них писать def?
                      +4
                      Если расширение .java, то синтаксис Джавы, если .groovy, то Груви. Нахождение в src/main/whatever означает, каким компилятором компилировать, а не какой синтакс в файлах. Это от типа файла зависит.

                      Например, если ты положишь Груви файл в src/main/resources, то он будет переписан в classes не скомпилированным вообще.
                        +2
                        Да… классно, спасибо!
                          +1
                          my pleasure!
                      0
                      А надо как-то в градле включать процессинг груви-аннотаций для java-классов? java-классы в src/main/groovy компилятся без применения этих аннотаций. Документация говорит, что есть инкубационный флажок, но вот так не помогло:

                      tasks.withType(GroovyCompile) {groovyOptions.javaAnnotationProcessing = true}

                      при этом в груви-классах ast выполняются.
                +1
                Еще вопрос — есть ли на английском -то то ( я не могу найти) про применение груви трансформаций для Ява классов? Мне бы товарищам показать…
                  +1
                  Я не встречал. Если в гугле не находится, просто попробуй, не?
                  +3
                  Астрологи объявили неделю AST трансформаций, количество проектов увеличилось вдвое.

                  Для тех, кто хочет поиграться с ASTT, я создал проект, позволяющий писать трансформации Inline. Встречайте — @InlineTransform :)

                  github.com/bsideup/GroovyInlineTransform/blob/master/src/test/groovy/ru/trylogic/groovy/transform/inline/tests/BasicTest.groovy

                  Залил в Maven central ( search.maven.org/#search%7Cga%7C1%7Cgroovy-inline-transform ), ждёт синхронизации, могу ещё послать в jCenter :)
                    +2
                    Ух ты, жесть какая! Круто, правда совершенно не-читаемо :)

                    А на счет central/jcenter — Ну, во первых да, всё крутое в Груви уже давно в jcenter. А во-вторых, если уже на бинтрее, то в central можно попасть значительно проще оттуда — blog.bintray.com/2014/02/11/bintray-as-pain-free-gateway-to-maven-central/

                    пы.сы. www.trylogic.ru/ дает forbidden.
                      0
                      да, всё никак руки не дойдут перейти полностью на bintray, выходил в централ ещё старыми sonatype способами:)
                        +1
                        внезапно(!) в разговоре с Седриком обнаружилась уже готовая аннотация в groovy-core (since 2.0.0):
                        groovy.codehaus.org/gapi/groovy/transform/ASTTest.html

                        правда создана была для другого:)
                      +2
                      @Immutable делает класс неизменяемым, а конкретно:
                      1. сеттеры кидают ReadOnlyPropertyException
                      WAT?! Зачем вообще сеттеры Immutable-классу?
                        –1
                        Любопытно! А есть ли что-нибудь готовое для тех, кто не хочет каждый раз писать:

                        container.getItems().add(item);
                        item.setContainer(container);
                        

                        но чтобы было достаточно любой из этих срок, для установления двусторонней связи между container и item?
                          0
                          Вообще-то для этого не требуются AST трансформации.
                          class Container {
                          
                            Items items;
                            
                            Constructor() {
                              items = new Items(this);
                            }
                          
                          }
                          
                          class Items {
                            WeakReference<Container> container;
                            
                            protected List<Item> internalItems = [];
                            
                            Items(Container container) {
                              this.container = new WeakReference<Container>(container);
                            }
                            
                            Item addItem(Item item) {
                              internalItems.add(item);
                              item.container = this.container.get();
                            }
                          }
                          
                          
                          
                          class Item {
                            WeakReference<Container> container;
                            
                            void setContainer(Container container) {
                              container.items.addItem(this)
                            }
                          }
                          
                          
                          container.items.addItem(new Item())
                          
                            0
                            > Вообще-то для этого не требуются AST трансформации
                            AST трансформации, формально, вообще нигде не требуются. Но они уменьшают количество boilerplate-кода, и это, согласитесь, здорово.

                            Ваше решение, как и многие другие не избавлено от этого недостатка. Приходится создавать вспомогательные методы или классы.

                            Скорее всего я найду свободное время чтобы расчехлить AST и «спрятать» весь boilerplate в моей текущей реализации bidirectional assocations с глаз долой. Первым комментарием хотел лишь внести для себя определенность, что не изобрету тем самым велосипед.
                          0
                          jbaruch: вопрос о @ Delegate:
                          обычно хочется (требуется) в классе A не только переадресовать все вызовы экземпляру класса B, содержащемуся в классе A в кач-ве поля,
                          но и реализовать в классе A некий суперинтерфейс B,
                          ибо говорить о duck typing применительно к Java бессмысленно,
                          а @ Delegate реализует именно duck typing.

                          Это как-то можно сделать?
                            +1
                            Я не пробовал (может сегодня будет время, попробую), но логика подсказывает, что можно поставить implements. Поскольку класс компилируется groovy компилятором, должно сработать.
                              0
                              Спасибо.
                                0
                                @ Delegate позволяет добавить подпись интерфейсов к классу, в котором он объявлен, никакого Duck Typing, всё статично.
                            0
                            Очень хорошо.

                            В том же ламбоке можно было указать, что, например, при генерации equals/hashCode/toString нужно исключать какие-то поля.
                            Типичный пример — ссылка на родительский элемент, тогда как родительский элемент содержит список детей. В таком случае свалится с SO можно запросто.

                            Еще один момент — можно ли как-то влиять на поведение генерируемого кода — т.н. setter производит какую-то базовую проверку аргумента, например, недопустимо передавать null?
                              +1
                              Большинство этих аннотаций принимают параметры, которые конфигурируют генерируемый код. Например @Canonical принимает слисок полей для исключения.

                              На поведение генерируемого кода повлиять нельзя, можно просто методы, которые должны делать что-то отличное, от бойлерплейта, прописать вручную.
                              0
                              Помогите пожалуста, я не совсем понял как это можно использовать сильно не уходя от явы — «способом протащить Groovy в ваш проект». Попытался скомпилировать пример:

                              image

                              Метод 'setFirstName' сгенерировался, а вот использование конструктора с map выдаёт ошибку.
                                0
                                A у вас groovy в runtime classpath остался?

                              Only users with full accounts can post comments. Log in, please.