На прошлой неделе мы увидели, как создавать модели кода Java из исходных и JAR-файлов . Теперь мы хотим иметь возможность решать символы: на практике мы хотим связать ссылки на символы в нашем исходном коде с объявлениями символов либо в исходных файлах Java, либо в jar-файлах.
Ссылки на символы и объявления символов
Прежде всего мы должны учитывать, что один и тот же символ может указывать на разные объявления в разных контекстах. Рассмотрим этот абсурдный кусок кода:
package me.tomassetti;
public class Example {
private int a;
void foo(long a){
a += 1;
for (int i=0; i<10; i++){
new Object(){
char a;
void bar(long l){
long a = 0;
a += 2;
}
}.bar(a);
}
}
}
Он содержит несколько объявлений символа a :
- в строке 5 оно объявлено как поле типа int
- в строке 7 он объявлен как параметр типа long
- в строке 11 он объявлен как поле анонимного класса и имеет тип char
- в строке 14 он объявлен как локальная переменная типа long
У нас также есть две ссылки на символ а :
- в строке 8, когда мы увеличиваем на 1
- в строке 15, когда мы увеличиваем на 2
Теперь, чтобы решить символы, мы можем выяснить, какие ссылки на символы ссылаются на какие объявления символов. Так что мы можем делать такие вещи, как понимание типа символа, и какие операции могут быть выполнены над символом.
Общие правила
Принцип определения символа прост (но реализация может быть… хитрой): учитывая ссылку на символ, мы ищем ближайшую соответствующую декларацию. Пока мы не найдем его, мы продолжаем двигаться дальше от эталона, поднимаясь в AST.
В нашем примере мы сопоставили бы ссылку в строке 15 с объявлением в строке 14. Мы также сопоставили бы ссылку в строке 8 с объявлением в строке 7. Просто, а?
Рассмотрим другой пример:
package me.tomassetti;
class A {
public void foo(){
System.out.println("I am the external A");
}
}
public class Example {
A a;
class A {
public void foo(){
System.out.println("I am the internal A");
}
}
void foo(A a){
final int A = 10;
new A().foo();
}
}
Теперь у нас есть различные определения A , в некоторых случаях как Класс (строки 3 и 13), в других как переменные (строка 20). Ссылка в строке 21 ( new A (). Foo (); ) соответствует объявлению в строке 13, а не объявлению в строке 20, поскольку мы можем использовать только объявления типов для сопоставления ссылок на символы внутри оператора создания нового объекта. Вещи начинают становиться не так просто …
Есть и другие вещи, которые следует учитывать:
- операторы import: импорт com.foo.A разрешает ссылки на A с объявлениями класса A в пакете com.foo
- классы в одном пакете, на которые можно ссылаться простым именем, тогда как для других мы должны использовать полное имя
- классы в пакете по умолчанию нельзя ссылать вне пакета по умолчанию
- поля и методы должны быть унаследованы (для методов мы должны учитывать перегрузку и переопределение, не путая их)
- при сопоставлении методов мы должны учитывать совместимость параметров (совсем не просто)
- и тд и тп
Хотя принцип прост, существует множество правил, исключений и вещей, которые необходимо учитывать для правильного разрешения символов. Приятно то, что мы можем легко начать, а затем усовершенствовать решение.
Создай наш решатель символов
Одно замечание: в этом посте я объясню подход я в настоящее время используется , чтобы построить свой символ решатель для effectivejava . Я не говорю, что это идеальное решение, и я уверен, что со временем улучшу это решение. Однако этот подход, по-видимому, охватывает большое количество случаев, и я думаю, что это не ужасное решение.
Простая ссылка на имя (что бы оно ни указывало на переменную, параметр, поле и т. Д.) Представляется в виде NameExpr в AST, создаваемом JavaParser . Мы начнем с создания функции, которая принимает узел NameExpr и возвращает соответствующее объявление, если таковое может быть найдено. Функция solveNameExpr в основном просто вызывает функцию solveSymbol, передавая три параметра:
- сам узел AST: он представляет область, в которой нужно решить символ
- ноль: это представляет дополнительную контекстную информацию, в этом случае она не нужна
- имя должно быть решено: ну, это должно быть само за себя
(defn solveNameExpr
"given an instance of com.github.javaparser.ast.expr.NameExpr returns the declaration it refers to,
if it can be found, nil otherwise"
[nameExpr]
(let [name (.getName nameExpr)]
(solveSymbol nameExpr nil name)))
Мы начнем с объявления протокола (который чем-то похож на интерфейс Java) для концепции области видимости:
(defprotocol Scope ; for example in a BlockStmt containing statements [a b c d e], when solving symbols in the context of c ; it will contains only statements preceeding it [a b] (solveSymbol [this context nameToSolve]) ; solveClass solve on a subset of the elements of solveSymbol (solveClass [this context nameToSolve]))
Основная идея заключается в том, что в каждой области видимости мы пытаемся найти объявления, соответствующие символу, который нужно решить, если мы не найдем их, мы делегируем родительскую область видимости. Мы указываем реализацию по умолчанию для узла AST ( com.github.javaparser.ast.Node ), который просто делегирует родителю. Для некоторых типов узлов мы предоставляем конкретную реализацию.
(extend-protocol Scope
com.github.javaparser.ast.Node
(solveSymbol [this context nameToSolve]
(solveSymbol (.getParentNode this) this nameToSolve))
(solveClass [this context nameToSolve]
(solveClass (.getParentNode this) this nameToSolve)))
Для BlockStmt мы смотрим среди инструкций, предшествующих той, которая проверялась для объявлений переменных. Текущий оператор проверки передается как контекст . Если вы заинтересованы в функциях, используемых этим, просто посмотрите код эффективной Java: он находится на GitHub
(extend-protocol Scope
BlockStmt
(solveSymbol [this context nameToSolve]
(let [elementsToConsider (if (nil? context) (.getStmts this) (preceedingChildren (.getStmts this) context))
decls (map (partial declare-symbol? nameToSolve) elementsToConsider)]
(or (first decls) (solveSymbol (.getParentNode this) this nameToSolve)))))
Для MethodDeclaration мы смотрим среди параметров
(defn solve-among-parameters [method nameToSolve]
(let [parameters (.getParameters method)
matchingParameters (filter (fn [p] (= nameToSolve (.getName (.getId p)))) parameters)]
(first matchingParameters)))
(extend-protocol Scope
com.github.javaparser.ast.body.MethodDeclaration
(solveSymbol [this context nameToSolve]
(or (solve-among-parameters this nameToSolve)
(solveSymbol (.getParentNode this) nil nameToSolve)))
(solveClass [this context nameToSolve]
(solveClass (.getParentNode this) nil nameToSolve)))
Для ClassOrInterfaceDeclaration мы смотрим среди полей класса или интерфейса (у классов могут быть статические поля).
(defn solveAmongVariableDeclarator
[nameToSolve variableDeclarator]
(let [id (.getId variableDeclarator)]
(when (= nameToSolve (.getName id))
id)))
(defn- solveAmongFieldDeclaration
"Consider one single com.github.javaparser.ast.body.FieldDeclaration, which corresponds to possibly multiple fields"
[fieldDeclaration nameToSolve]
(let [variables (.getVariables fieldDeclaration)
solvedSymbols (map (partial solveAmongVariableDeclarator nameToSolve) variables)
solvedSymbols' (remove nil? solvedSymbols)]
(first solvedSymbols')))
(defn- solveAmongDeclaredFields [this nameToSolve]
(let [members (.getMembers this)
declaredFields (filter (partial instance? com.github.javaparser.ast.body.FieldDeclaration) members)
solvedSymbols (map (fn [c] (solveAmongFieldDeclaration c nameToSolve)) declaredFields)
solvedSymbols' (remove nil? solvedSymbols)]
(first solvedSymbols')))
(extend-protocol Scope
com.github.javaparser.ast.body.ClassOrInterfaceDeclaration
(solveSymbol [this context nameToSolve]
(let [amongDeclaredFields (solveAmongDeclaredFields this nameToSolve)]
(if (and (nil? amongDeclaredFields) (not (.isInterface this)) (not (empty? (.getExtends this))))
(let [superclass (first (.getExtends this))
superclassName (.getName superclass)
superclassDecl (solveClass this this superclassName)]
(if (nil? superclassDecl)
(throw (RuntimeException. (str "Superclass not solved: " superclassName)))
(let [inheritedFields (allFields superclassDecl)
solvedSymbols'' (filter (fn [f] (= nameToSolve (fieldName f))) inheritedFields)]
(first solvedSymbols''))))
amongDeclaredFields)))
(solveClass [this context nameToSolve]
(solveClass (.getParentNode this) nil nameToSolve)))
Для CompilationUnit мы ищем другие классы в том же пакете (оба используют их простые или квалифицированные имена), мы рассматриваем операторы импорта и ищем типы, объявленные в файле.
(defn qNameToSimpleName [qualifiedName]
(last (clojure.string/split qualifiedName #"\.")))
(defn importQName [importDecl]
(str (.getName importDecl)))
(defn isImportMatchingSimpleName? [simpleName importDecl]
(= simpleName (qNameToSimpleName (importQName importDecl))))
(defn solveImportedClass
"Try to solve the classname by looking among the imported classes"
[cu nameToSolve]
(let [imports (.getImports cu)
relevantImports (filter (partial isImportMatchingSimpleName? nameToSolve) imports)
importNames (map (fn [i] (.getName (.getName i))) imports)
correspondingClasses (map typeSolver importNames)]
(first correspondingClasses)))
(extend-protocol Scope
com.github.javaparser.ast.CompilationUnit
(solveClass [this context nameToSolve]
(let [typesInCu (topLevelTypes this)
; match types in cu using their simple name
compatibleTypes (filter (fn [t] (= nameToSolve (getName t))) typesInCu)
; match types in cu using their qualified name
compatibleTypes' (filter (fn [t] (= nameToSolve (getQName t))) typesInCu)]
(or (first compatibleTypes)
(first compatibleTypes')
(solveImportedClass this nameToSolve)
(solveClassInPackage (getClassPackage this) nameToSolve)
; we solve in nil context: it means look for absolute names
(solveClass nil nil nameToSolve)))))
Если нам не удается решить символ в области действия модуля компиляции, родительский элемент для делегирования отсутствует, поэтому мы используем нулевую область действия, которая представляет отсутствие области действия. В этом случае могут быть решены только абсолютные имена, такие как канонические имена классов. Мы делаем это с помощью функции typeSolver . TypeSolver необходимо знать , какой путь к классам , чтобы использовать и в основном смотрят на классы из исходных файлов каталогов и JAR. Также в этом случае не стесняйтесь копаться в коде Java.
(extend-protocol Scope nil (solveClass [this context nameToSolve] (typeSolver nameToSolve)))
Выводы
Я думаю, что Clojure отлично подходит для постройки решений постепенно: мы можем реализовывать различные методы протоколов по мере продвижения вперед. Создавайте простые тесты и улучшайте их по одному.
Мы создали это простое решение итеративно, и до сих пор оно отлично работает для наших целей. Мы будем продолжать писать больше тестовых случаев, и нам, возможно, придется реорганизовать одну или две вещи, но я думаю, что мы движемся в правильном общем направлении.
В будущем может быть полезно извлечь этот преобразователь символов в отдельную библиотеку, которая будет использоваться с JavaParser для выполнения статического анализа и рефакторинга.
