На прошлой неделе мы увидели, как создавать модели кода 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 для выполнения статического анализа и рефакторинга.