...

пятница, 10 июля 2020 г.

IntelliJ IDEA: Structural Search & Replace

Современные IDE — очень мощные инструменты, способные помогать разработчику в самых разных ситуациях. Но обратной стороной этой мощности является то, что большинство функций находится в тени — об их наличии мало кто подозревает.


Простой пример одной такой функции

А вы знаете, что, если в IDEA нажать F2, курсор перескочит к ближайшей ошибке в файле? А если нет ошибки, то к замечанию? Как-то так получается, что об этом знают далеко не все.

Одной такой функцией является Structural Search & Replace (SSR). Она может быть невероятно полезна в тех ситуациях, когда пасует всё богатое разнообразие других функций.

Несколько таких примеров я и приведу в данной статье. И чтобы не ограничиваться суррогатными случаями, буду демонстрировать эти примеры на реальном коде из двух проектов:


  1. 3D-движка для создания игр jMonkeyEngine, как пример большого проекта, в котором всегда можно найти что-то интересненькое.
  2. моего собственного проекта plantuml-native-image, в котором я провожу эксперименты по компиляции PlantUML в нативный исполняемый код с помощью GraalVM native-image.

Собственно, случай во втором проекте и побудил меня к написанию статьи. Но обо всём по порядку...


Простая задача для препарирования

Прежде чем приступить к изучению структурного поиска как такового, давайте определимся с какой-либо простой задачей, в решении которой этот поиск будет полезен. Тут я хочу рассмотреть пример, который не так давно пригодился мне лично в одном из рабочих проектов (с тем лишь исключением, что вместо закрытого рабочего кода я буду демонстрировать пример на конкретной ревизии проекта jMonkeyEngine): поиск открытых объектов блокировок с использованием ключевого слова synchronized (см. раздел "Item 82 — Document thread safety" главы "11 Concurrency" в книге Joshua Bloch "Effective Java").

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

Тут надо понимать, что ключевое слово synchronized имеет два варианта использования:

в качестве модификатора метода:

class ClassA {
    public synchronized void someMethod() {
        // ...
    }
}

и в качестве внутренней конструкции метода:

class ClassA {
    public void someMethod() {
        synchronized(this) {
            // ...
        }
    }
}

По сути, оба приведённых выше примера являются примерами синхронизации на открытом объекте. Правильным было бы написание такого кода:

class ClassA {
    private final Object sync = new Object();
    public void someMethod() {
        synchronized(sync) {
            // ...
        }
    }
}

В таком примере никакой сторонний код не сможет вмешаться в синхронизацию.

Но как понять, есть ли в коде проекта такой паттерн?

Простейший подход — поиск по тексту ключевого слова synchronized и внимательный разбор каждого вхождения. Но такой подход годится только для совсем маленьких проектов. Если мы попытаемся поискать такое слово в проекте jMonkeyEngine, то найдём там аж 117 вхождений, причём не только в качестве искомой конструкции, но и в комментариях и просто строках. И разбираться в таком количестве вхождений уже далеко не так весело.

Так что же делать? Вот тут нам на помощь и приходит уникальная функция IntelliJ IDEA: Structural Search & Replace.


Structural Search. Основы

Для начала разберёмся с интерфейсом структурного поиска в IDEA.

Откроем проект jMonkeyEngine, и вызовем окно структурного поиска (Edit -> Find -> Search Structurally...) с двумя областями ввода:


  1. областью задания шаблона поиска;
  2. областью задания фильтров.

Чем же оно отличается от обычного поиска, и что мы можем с этим делать?

В отличие от обычного поиска, в ходе которого мы просто ищем вхождение некой подстроки (напрямую или через регулярное выражение), в данном поиске мы ищем не строку, а некоторый структурный шаблон на каком-либо языке программирования или разметки (вплоть до HTML).

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


В результате откроется окно с богатым выбором готовых шаблонов:


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

Начнём со случая, когда ключевое слово synchronized используется в качестве модификатора метода, т.к. в этом случае синхронизация получается по открытому методу просто by design. Для этого введём в левое поле следующую странную конструкцию:

synchronized $type$ $method$ ($ptype$ $param$) {
    $statement$;
}

Тут мы определяем некоторый паттерн поиска. Не точное соответствие, а только набор синтаксических конструкций, который должен попадать под критерий.

В этом шаблоне можно увидеть большое количество лексем, ограниченных спереди и сзади символами $. Это так называемые переменные шаблона поиска. Если лексема задана без символов $ (как, например synchronized), то она должна присутствовать в найденном фрагменте кода, как есть. А если лексема находится внутри "долларов", то в этом месте по умолчанию может находиться что угодно. Просто тут должно находиться что-то, не важно, что.

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

Т.о., если мы взглянем на шаблон абстрактно, то можем понять, что тут производится поиск определения произвольного метода, с каким-то возвращаемым типом (включая void), с каким-то параметром и строкой в теле метода. А главное, этот метод является синхронизируемым.

Но погодите, мы же хотим найти все синхронизируемые методы, с произвольным количеством параметров (включая методы без параметров) и с произвольно сложным телом. Как быть?

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

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

Т.е. если мы слева выделим переменную $param$, то справа можно задать для неё, например, критерий количества вхождений параметров в объявлении функции. Для этого достаточно щёлкнуть на гиперссылку Add Filter и выбрать второй пункт меню Count.


В появившейся строке фильтра отметим, что параметров может быть от 0 до бесконечности. Для этого второе поле оставим пустым:


Теперь шаблон будет искать все синхронизируемые методы с произвольным количеством параметров… Но только с одной строкой кода в теле. Чтобы это исправить, зададим точно такое же ограничение и для переменной $statement$.

Всё, теперь можно нажать кнопку Find и увидеть все синхронизируемые методы в проекте, которых всего 18 штук:


Отличное начало!


Спускаемся в кроличью нору. Script Filter

Ну а что с синхронизацией на произвольном объекте? Казалось бы, тут всё даже проще:

synchronized($Obj$) {
    $statement$;
}

Но вот беда, как определить, что переменная $Obj$ — приватная? Можно попробовать отталкиваться от паттерна из книги, что это должна быть переменная типа Object. Тогда мы можем добавить фильтр Type и задать имя типа класса Object. Причём соответствие должно быть строгим, без учёта иерархии. Т.е. галки под фильтром должны быть убраны:


Тогда мы с некоторой вероятностью найдём все места, где синхронизация использует закрытые объекты (да и то не все!). Что как бы даст нам результат, противоположный требуемому… В частных случаях это может быть полезно, если нам надо найти все синхронизации по объектам определённого типа и даже по наследникам этого типа (если отметить пункт with type hierarchy под фильтром).

Но в целом-таки хотелось бы найти именно те случаи, где используется не приватный объект. Любого типа.

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

Дело в том, что в качестве ограничения можно написать произвольный код на Groovy, возвращающий true или false. А в качестве параметров будут переменные из паттерна поиска, которые определены в символах $. Плюс ещё пара служебных переменных __context__ и __log__.

И вот тут находится самый расстраивающий меня момент. Данные переменные — объекты кусков Psi дерева синтаксического разбора. При этом:


  • поле ввода скрипта (внезапно) не предоставляет никакого code IntelliSense. Никаких подсказок;
  • угадать какой именно элемент Psi дерева окажется в качестве переменной практически нереально;
  • никакой справки по структуре Psi дерева. Только исходники;
  • общее юзабилити ввода фильтра ужасно. Необходимо смириться с его схлопываниями при потере фокуса ввода и необходимостью каждый раз расхлопывать поле и изменять размеры элементов, чтобы увидеть скрипт полностью, если он большой и многострочный.

Так что же делать? Будем разбираться. Прежде всего надо понять что же попадает в переменную $Obj$. Мне не пришло в голову ничего лучше идеи воспользоваться штатной функцией из Groovy: println. Добавляем к переменной $Obj$ фильтр типа Script и вводим следующий текст:

println(Obj)
return true

Как видим, в скрипте можно использовать имена переменных, только без "долларов". При выполнении этого поиска мы увидим все вхождения кода с synchronized, но нас интересует не это, а то, куда же напечатались логи с помощью println?

А напечатались они в лог самой IDEA. Найти их можно через меню: Help->Show Log in.... Т.к. у меня KDE, то пункт меню полностью называется Show Log in Dolphin. В результате откроется системный диспетчер файлов с указанием на актуальный файл лога. Вот в него-то и нужно заглянуть, чтобы увидеть информацию об интересующем нас объекте. В данном случае можно найти следующие строки:

2020-07-05 15:03:00,998 [14151177] INFO - STDOUT - PsiReferenceExpression:pending
2020-07-05 15:03:01,199 [14151378] INFO - STDOUT - PsiReferenceExpression:source
2020-07-05 15:03:01,216 [14151395] INFO - STDOUT - PsiThisExpression:this
2020-07-05 15:03:01,219 [14151398] INFO - STDOUT - PsiReferenceExpression:receiveObjectLock
2020-07-05 15:03:01,222 [14151401] INFO - STDOUT - PsiReferenceExpression:invoke
2020-07-05 15:03:01,226 [14151405] INFO - STDOUT - PsiReferenceExpression:chatServer
2020-07-05 15:03:01,231 [14151410] INFO - STDOUT - PsiReferenceExpression:obj
2020-07-05 15:03:01,236 [14151415] INFO - STDOUT - PsiReferenceExpression:sync
2020-07-05 15:03:01,242 [14151421] INFO - STDOUT - PsiReferenceExpression:image
2020-07-05 15:03:01,377 [14151556] INFO - STDOUT - PsiClassObjectAccessExpression:TerrainExecutorService.class
2020-07-05 15:03:01,409 [14151588] INFO - STDOUT - PsiReferenceExpression:byteBuffer
2020-07-05 15:03:01,429 [14151608] INFO - STDOUT - PsiReferenceExpression:lock
2020-07-05 15:03:01,432 [14151611] INFO - STDOUT - PsiReferenceExpression:eventQueue
2020-07-05 15:03:01,456 [14151635] INFO - STDOUT - PsiReferenceExpression:sensorData.valuesLock
2020-07-05 15:03:01,593 [14151772] INFO - STDOUT - PsiReferenceExpression:createdLock
2020-07-05 15:03:01,614 [14151793] INFO - STDOUT - PsiReferenceExpression:taskLock
2020-07-05 15:03:01,757 [14151936] INFO - STDOUT - PsiReferenceExpression:loaders
2020-07-05 15:03:01,765 [14151944] INFO - STDOUT - PsiReferenceExpression:threadLock

Т.о., мы видим, что в качестве значения Obj могут выступать объекты как минимум трёх видов:


  • PsiThisExpression — лексема this;
  • PsiClassObjectAccessExpression — синхронизация по объекту типа Class (synchronized (TerrainExecutorService.class) {...});
  • PsiReferenceExpression — некоторое выражение, результат вычисления которого используется как объект синхронизации.

Первые два типа можно автоматически рассматривать как синхронизацию по открытому объекту. Т.е. если у нас Obj является объектом типа PsiThisExpression или PsiClassObjectAccessExpression, то надо вернуть true.

Но что делать с типом PsiReferenceExpression? Точнее, даже не так. Что с ним вообще можно сделать?

К сожалению, единственный способ поиска ответа на этот вопрос, найденный мной — обратиться к исходникам. Т.к. Java парсер от JetBrains является открытым и лежит на GitHub в составе исходников IDEA, то ничто не мешает заглянуть в него. Интересующий нас класс находится тут.

Не буду утомлять подробностями ковыряния исходников Psi. Просто приведу получившийся результирующий скрипт:

if (Obj instanceof com.intellij.psi.PsiThisExpression) return true
if (Obj instanceof com.intellij.psi.PsiClassObjectAccessExpression) return true
if (Obj instanceof com.intellij.psi.PsiReferenceExpression) {
    def var = Obj.advancedResolve(false).element
    if (var instanceof com.intellij.psi.PsiParameter) return true
    if (var instanceof com.intellij.psi.PsiLocalVariable) {
        return !(var.initializer instanceof com.intellij.psi.PsiNewExpression)
    }
    if (var instanceof com.intellij.psi.PsiField) {
        return !var.hasModifier(com.intellij.lang.jvm.JvmModifier.PRIVATE) &&
               !var.hasModifier(com.intellij.lang.jvm.JvmModifier.PROTECTED)
    }
}
return true

В таком виде поиск находит уже только 12 вхождений. Полученный результат не идеален, т.к. некоторые из этих вхождений ложноположительные. Но думаю, что для данного примера этого достаточно. Тем не менее видно, что структура Psi весьма мощная и позволяет оперировать структурой кода на весьма высоком уровне абстракции.


Ваша первая в жизни статическая инспекция кода

Как мы увидели чуть ранее, поиск некоторых вещей может оказаться довольно непростой задачей. И будет обидно, если столько усилий пропадёт зря. Да и вообще, хотелось бы, чтобы IDE сразу подсказывала, что ты пишешь что-то не то.

И тут IDEA тоже может помочь. Дело в том, что в ней есть огромное количество всяких инспекций, которые помогают разработчику писать более правильный код, подсвечивая некорректные места и объясняя, что в них не так. И среди них есть одна замечательная инспекция, которая выключена по умолчанию. Это: Structural search inspection.

Найти эту инспекцию можно в окне настройки (File->Settings...->Editor->Inspections):


Включаем эту инспекцию и нажимаем плюсик, чтобы добавить шаблон структурного поиска. По умолчанию окно поиска автоматически заполнится тем, что искалось в последний раз. Нажимаем Ok и вводим имя шаблона поиска. Например, Open object sync. Но на деле лучше придумать что-то более развёрнутое, чтобы было понятно, что тут имеется в виду.

Всё, теперь IDEA начнёт автоматически подсвечивать все места в коде, которые подпадают под этот поиск:


Ву-а-ля!!! Вы создали первую в своей жизни инспекцию кода! Поздравляю! Остаётся только закоммитить её вместе с проектом, чтобы другие тоже могли ею воспользоваться. Для этого достаточно добавить в версионное хранилище каталог .idea/inspectionProfiles, где сохранились эти настройки.


Structural Replace

Как и в Structural Search, в IDEA есть и функция структурной замены — Structural Replace
(Edit -> Find -> Replace Structurally...):


В отличие от окна Search Structurally, в данном окне появляется ещё одна область. Она предназначена для задания замены найденному шаблону. Так же, как и в шаблоне, в подстановке могут содержаться переменные, ограниченные с двух сторон символами $. И точно так же эти переменные могут настраиваться в области ввода фильтров. Только в этом случае это будут не условия фильтрации, а исключительно Groovy скрипты для вычисления текста подстановки вместо переменной.

Тут тоже хочу привести пример из жизни. Мне надо было в одном огромном классе заменить все вызовы функций вида:

classInitializationSupport.initializeAtRunTime(WindowPropertyGetter.class, AWT_SUPPORT);

на вызовы вида:

classInitializationSupport.initializeAtRunTime("sun.awt.X11.WindowPropertyGetter", AWT_SUPPORT);

Т.е. надо было уйти от явного использования классов в коде. И далее сделать то же самое в отношении нескольких видов функций.

Проделывать всё это вручную мне очень не хотелось. Вместо этого я использовал Replace Structurally. Для начала задал шаблон поиска:

classInitializationSupport.initializeAtRunTime($Clazz$.class, AWT_SUPPORT)

На переменную $Class$ не навешивал никаких ограничений, т.к. мне всё равно какой класс туда попадёт.

Далее задал подстановку:

classInitializationSupport.initializeAtRunTime("$FullClass$", AWT_SUPPORT)

И вот тут-то мне надо вычислить новое значение переменной $FullClass$. Для чего выделяю её курсором и в области ввода ограничений задаю скрипт вида:

Clazz.type.canonicalText

Т.е. берём тип, попадающий в переменную из шаблона поиска $Clazz$, получаем полное имя этого типа и подставляем в параметр метода в виде строки.

В конечном итоге окно структурной замены получило следующий вид:


Далее нажимаем Find и получаем список возможных замен:


Тут мы можем посмотреть каждое вхождение, и во что оно превратится (кнопка Preview Replacement). Так же можно исключить какие-либо вхождения (из контекстного меню) или сделать все преобразования разом (кнопка Replace All).

В целом, если освоиться с Psi, то это уже не так и сложно, не правда ли?


Structural Replace as Intention

Тут пришла пора упомянуть ещё один мощнейший механизм IDEA — Intentions. Интеншены позволяют ковать железо, не отходя от кассы, т.е. применять какие-либо преобразования кода прямо по месту нахождения курсора.

Например, если вы напишете такой код:

public static void main(String[] args) {
    List<Integer> list = Arrays.asList(0, 10, 20, 30);
    for (int i = 0; i < list.size(); i++) {
        System.out.println(list.get(i));
    }
}

а потом встанете курсором на for и нажмёте на Alt+Enter, то вам будет выдано предложение переделать этот цикл в несколько вариантов другого представления того же самого. Например, в for-each:

public static void main(String[] args) {
    List<Integer> list = Arrays.asList(0, 10, 20, 30);
    for (Integer integer : list) {
        System.out.println(integer);
    }
}

Так вот, к чему это я? А к тому, что с помощью Structural Replace можно сделать такой же intention. Для этого достаточно дополнить ту же самую инспекцию, которую мы задавали для Structural Search. Только при добавлении нового пункта нужно указать, что это будет не поиск, а замена:


Теперь в коде соответствующие места станут подсвеченными, и там будет предлагаться автозамена:


Поздравляю ещё раз! Вы сделали первый в своей жизни Intention!


Заключение

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

Я не преследовал своей целью описать эти функции полностью и исчерпывающе. Очень многие аспекты остались за рамками статьи. Но я надеюсь, что этого будет достаточно для большинства, чтобы преодолеть барьер входа и разобраться со всем остальным самостоятельно.

Дерзайте. Удачи. И всем Java!

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий