Ремарка:
В данной статье описывается подход автора к решению конкретной технической задачи. Автор не утверждает, что предложенное решение является единственно возможным, оптимальным или наиболее элегантным. Это лишь один из вариантов реализации, представленный в ознакомительных целях, а не руководство к обязательному применению.
Целеполагание:
В данной статье я демонстрирую свой фреймворк для динамически компилируемых функций по принципу задания некоторой формулы для вычислений с параметрами: "x + (cos(y) + log(2, pi))" и преобразования выражений в лямбда функцию: Map[String, Any] => { ... } или (x: Any, y: Any) => { ... }.
Хеш‑таблица (далее — Map) задана для доступа к переменным по имени в качестве удобного универсального интерфейса. Все функции, литералы и константы, с которыми работает двигло, задаются пользователем. Реализация осуществляется посредством кодогенерации.
Таким образом, весь цикл от некого выражения до готовой лямбды будет следующим: expression -> AST -> ScalaCode → Class Loading -> lambda.
Expressionесмь некоторое арифметическое выражение.
(требования к нему рассмотрим далее)ASTесмь структура абстрактного синтаксического дерева.
(требования к нему рассмотрим далее)ScalaCodeесмь непосредственно исходный код на языке Scala, сгенерированный на основеAST.Class Loadingесмь загрузка скомпилированного кода вjvmи создание обьектов функций.Lambdaесмь готовая к использованию объект‑функция, полученная в результате компиляции сгенерированного кода.
Требования к выражениям:
Выражение есмь не что иное, как строка String, подчиняющаяся определённым правилам. Рассмотрим произвольное выражение, подобное тому, что было приведено выше:
x + (cos(y) + log(2, pi)).
Как и любая другая строка, оно состоит из символов, но сами по себе эти символы не несут смысловой нагрузки и не являются логическими единицами языка выражений. Поэтому далее будем рассматривать строку не как совокупность символов, а как совокупность токенов — лексических единиц, несущих определённую смысловую и структурную нагрузку.
Приведённое выражение состоит из следующих токенов:
x— параметр выражения+— оператор сложения(и)— группирующие символы, задающие порядок вычисленийcos— имя функцииy— параметр, передаваемый функцииcoslog— имя функции2— числовой литерал, передаваемый функцииlogpi— имя константы, передаваемой функцииlog,— разделитель аргументов функции
Токены разделим на несколько видов: параметры (в примере x, y), функции и бинарные функции (+, cos, log), литералы (2), константы (pi), разделители (,) и скобки (()).
На данном этапе, и в следующей моей статье про выражения, нам нужно всё перечисленное. Парсинг выражений в AST древе будет рассмотрен в последующей статье (или статьях).
Т.к. литералы, функции и константы задаёт пользователь, определим API для него в виде классов и классов аннотаций.
//Java // Способ задать имя для спецификаций (каждый токен имеет имя) @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface Name { String value(); } //scala // Спецификация для литерала trait LiteralSpec { // Поскольку токен-литерал, тоесмь строка "2.2", "true", "hello world", // необходимо сопоставлять токен с реальным обьектом scala, // что и отражает данная спецификация. def get(): String => Any } trait FunctionSpec { // Логика аналогичная, только функция теперь работает с набором переменных. def get(): List[Any] => Any } trait ConstantSpec { // По анологии. def get(): () => Any } // Примеры задания собственных спецификаций: @Name(name = "Double") final class DoubleLiteral extends LiteralSpec { override def get(): String => Any = _.toDouble } @LiteralMeta(name = "Text") final class TextLiteral extends LiteralSpec { override def get(): String => String = identity } @Name("E") class EulerConstant extends ConstantSpec { def get(): () => Any = () => Math.E } @Name("+") final class SumarFuncion extends FunctionSpec { // Пока что "движок" не типизируемый, и потому без возможности перегрузок // это временная реализация override def get(): List[Any] => Any = args => args(0) match { case a: Int => args(1) match { case b: Int => a + b case _ => throw new IllegalArgumentException("Se esperaba un Int") } case _ => throw new IllegalArgumentException("Se esperaba un Int") } }
Таким образом, спецификации отделяют описание конкретных компонентов от механики обработки выражений. Движок может найти компонент по имени и получить его реализацию, но не обязан заранее знать внутреннюю логику каждого литерала, функции или константы. Благодаря этому набор поддерживаемых компонентов можно расширять, добавляя новые спецификации.
Примечание: представленный код находится в модуле api, а реализации — в модулях constants, literals, functions соответственно.
Про требования к AST:
AST состоит из узлов, каждый из которых соотносится с токенами выражений.
Токены () и , определяют порядок операций, и потому для них не требуется отдельных типов узлов, так как порядок задаётся иерархичной структурой дерева.
Определим абстракции для описания AST:
package pico.calc.framework.core sealed trait AstNode // Тут name это имя, с которым сопоставляется аннотация @Name // В ParamNode name - имя переменной. final case class LiteralNode(name: String, value: String) extends AstNode final case class FunctionNode(name: String, ijos: List[AstNode]) extends AstNode final case class ParamNode(name: String) extends AstNode final case class ConstantNode(name: String) extends AstNode
«Компилятор»:
Движку, во время выполнения программы, необходимо динамически компилировать сгенерированный ScalaCode в исполняемые объекты Scala.
Для этого используется компилятор dotty, который позволяет преобразовать исходный код в байткод JVM. Компиляция выполняется непосредственно в рантайме, когда исходное выражение уже преобразовано в код Scala.
Результатом является скомпилированный класс, который затем загружается в JVM и может быть использован для создания исполняемого объекта. В нашем случае этот объект представляет собой функцию, принимающую параметры выражения и возвращающую результат вычисления.
Код компилятора находится в модуле compiler, и для его работы необходимо подключить библиотеку с dotty.
lazy val compilerModule = project .in(file("compiler")) .settings( commonSettings, name := "compiler", libraryDependencies += "org.scala-lang" %% "scala3-compiler" % scalaVersion.value )
Расмотрим компонет InMemoryScalaDriver для выполнения компиляции сгенерированного Scala-кода в RAM. Он настраивает компилятор, передаёт ему исходный код и сохраняет результат компиляции только в VirtualDirectory.
package pico.calc.framework.compiler import dotty.tools.dotc.Driver import dotty.tools.io.{VirtualDirectory, VirtualFile} import java.nio.charset.StandardCharsets // Драйвер компилятора dotty, выполняющий компиляцию в памяти. // Позволяет компилировать сгенерированный код без записи исходников и // результатов компиляции на диск, устраняя необходимость в правах на запись // в файловую систему для хранения этих данных. private[compiler] object InMemoryScalaDriver extends Driver { // Разрешаем компиляцию без передачи исходников через стандартный CLI. override protected def sourcesRequired: Boolean = false // Компилируем исходный код с заданным classpath, возвращается каталог с байткодом. def compile( source: String, classpath: String ): VirtualDirectory = // Аргументы запуска компилятора dotty. val args = Array("-classpath", classpath) // Инициализируем контекст компилятора с указанными настройками. setup(args, initCtx) match case None => throw CompilationError( "Could not initialize Scala compiler" ) case Some((_, initialContext)) => // Создаём виртуальный каталог для результата, не записывая классы на диск. val output = new VirtualDirectory("pico-memory-output") val context = initialContext.fresh context.setSetting(context.settings.outputDir, output) // Представляем исходный код как виртуальный файл в кодировке UTF-8. val sourceFile = new VirtualFile( "GeneratedSource.scala", source.getBytes(StandardCharsets.UTF_8) ) // Запускаем компиляцию исходного файла. val reporter = doCompile( newCompiler(using context), List(sourceFile) )(using context) // Прерываем процесс, если компилятор обнаружил ошибки. if reporter.hasErrors then throw CompilationError( "Scala compilation failed; see compiler diagnostics above" ) // Возвращаем каталог с результатами для последующей загрузки классов. output }
Компонент ниже отвечает за загрузку скомпилированного класса в JVM:
Получает исходный код и полное имя класса
Передаёт код компилятору
Извлекает байт‑код из
VirtualDirectoryСоздаёт собственный
ClassLoader, который определяет класс непосредственно из массива байтов
Результат — объект Class[?].
Поиск .class виртуальных файлов выполняется рекурсивно, в виртуальной директории.
package pico.calc.framework.compiler import dotty.tools.io.{AbstractFile, VirtualDirectory} object ScalaCompiler { def compile(source: String, className: String): Class[?] = { // Проверяем, что исходный код и имя класса не пусты. require(source.trim.nonEmpty, "Source code must not be empty") require(className.trim.nonEmpty, "Class name must not be empty") // Компилируем исходный код в памяти используя текущий classpath. val output = InMemoryScalaDriver.compile( source = source, classpath = System.getProperty("java.class.path") ) // Определяем родительский загрузчик классов. val parent = Option(Thread.currentThread().getContextClassLoader) .getOrElse(getClass.getClassLoader) // Находим скомпилированный файл класса в результате компиляции. val classFile = findClassFile(output) .getOrElse { throw new CompilationError("No compiled class found in memory") } // Читаем байт-код класса и закрываем поток после чтения. val input = classFile.input val bytecode = try input.readAllBytes() finally input.close() // Создаём загрузчик определяющий класс из полученного байт-кода. val classLoader = new ClassLoader(parent) { override protected def findClass(name: String): Class[?] = { // Разрешаем загрузку только запрошенного класса. if (name != className) { throw new ClassNotFoundException(name) } // Регистрируем класс в JVM на основе массива байтов. defineClass(name, bytecode, 0, bytecode.length) } } // Загружаем класс и возвращаем объект Class. classLoader.loadClass(className) } // Рекурсивно ищем первый скомпилированный файл .class, // в том числе во вложенных директориях пакетов. private def findClassFile(directory: VirtualDirectory): Option[AbstractFile] = directory.iterator .map { case subdirectory: VirtualDirectory => findClassFile(subdirectory) case file if file.name.endsWith(".class") => Some(file) case _ => None } .collectFirst { case Some(file) => file } }
Примечание: в этом проекте, все, что связанно с компиляцией, лежит в модуле compiler.
Кодогенерация:
Для кодогенерации необходимо реализовать диспетчеры спецификаций.
Пока их реализация будет достаточно простой, но в дальнейшем она будет расширяться по мере усложнения логики. Диспaтчеры предназначены для работы со спецификациями, определяемыми пользователем.
// пока что диспатчерезации происходит только по имени @Name // остальные диспатчеры полностью аналогичны object FunctionDispatcher { def apply(specs: Set[FunctionSpec]): String => Option[FunctionSpec] = { val specMap: Map[String, FunctionSpec] = specs .map { spec => val meta = spec.getClass.getAnnotation(classOf[Name]) meta.value() -> spec } .toMap name => specMap.get(name) } }
Опишем архитектуру через разделение ответственности: диспатчеры разрешают имена пользовательских спецификаций, а CodeGenHelper предоставляет кодогенератору единый интерфейс для их получения и анализа AST.
// вспомогательный класс для кодогенерации object CodeGenHelper: // набор подготовленных вспомогательных с замкнутыми диспатчерами final case class Helpers( resolveLiteral: String => LiteralSpec, resolveFunction: String => FunctionSpec, resolveConstant: String => ConstantSpec, getImports: AstNode => Set[Class[?]], getAllLiteralSpecs: AstNode => Set[LiteralSpec], getAllFunctionSpecs: AstNode => Set[FunctionSpec], getAllConstantSpecs: AstNode => Set[ConstantSpec] ) def apply( literalDispatcher: String => Option[LiteralSpec], functionDispatcher: String => Option[FunctionSpec], constantDispatcher: String => Option[ConstantSpec] ): Helpers = // Функции для резолвинга спецификаций def resolveLiteral(name: String): LiteralSpec = ... def resolveFunction(name: String): FunctionSpec = ... def resolveConstant(name: String): ConstantSpec = ... // Получение всех импортов и спецификаций def getImports(ast: AstNode): Set[Class[?]] = ... def getAllLiteralSpecs(ast: AstNode): Set[LiteralSpec] = ... def getAllFunctionSpecs(ast: AstNode): Set[FunctionSpec] = ... def getAllConstantSpecs(ast: AstNode): Set[ConstantSpec] = ... Helpers( resolveLiteral = resolveLiteral, resolveFunction = resolveFunction, resolveConstant = resolveConstant, getImports = getImports, getAllLiteralSpecs = getAllLiteralSpecs, getAllFunctionSpecs = getAllFunctionSpecs, getAllConstantSpecs = getAllConstantSpecs )
В следующем блоке кода будет реализован генератор исходного кода Scala, который преобразует AST в класс Formula, способный вычислять выражение по переданным параметрам.
Архитектура разделяет анализ AST и генерацию кода: CodeGenHelper собирает необходимые спецификации и зависимости, а ScalaCodeGenerator использует полученные данные для построения исходного кода.
Пока рассмотрим работу на примере литералов:
Сбор спецификаций.
getAllLiteralSpecs(ast)находит все спецификации литералов, используемые в выражении.Подготовка зависимостей.
getImports(ast)собирает импорты, необходимые для генерации исходного кода.Генерация преобразователей. Для каждой спецификации создаётся поле с функцией
String=>Any. Эта функция получается вызовомget()у экземпляра спецификации и преобразует текстовое представление литерала в соответствующее значение.Собранные поля и сгенерированное выражение помещаются в класс
Formula, реализующий функциюMap[String, Any] => Any. Методapplyпринимает параметры и вычисляет выражение.
Таким образом, генератор не обязан знать внутреннюю логику каждого типа литерала: он использует спецификации, предоставляющие необходимое преобразование. При этом создание преобразователей вынесено в поля класса, поэтому они инициализируются при создании Formula, а не генерируются заново при каждом вызове apply.
package pico.calc.framework.core // Class генерирующий код на scala на основе ast object ScalaCodeGenerator: def apply( helpers: CodeGenHelper.Helpers ): AstNode => String = // Генерирует код самих узлов из AST для тела функции apply def generateExpression(ast: AstNode): String = ast match case LiteralNode(name, value) => val specValName = toValName( helpers.resolveLiteral(name).getClass.getSimpleName ) s"""$specValName.apply("${value}")""" case FunctionNode(name, args) => val specValName = toValName(helpers.resolveFunction(name).getClass.getSimpleName) val generatedArgs = args.map(generateExpression).mkString(", ") s"$specValName.apply(List[Any]($generatedArgs))" case ConstantNode(name) => val specValName = toValName(helpers.resolveConstant(name).getClass.getSimpleName) s"$specValName.apply()" case ParamNode(name) => f"params(\"${name}\")" ast => // Формирует все необходимые импорты val imports = helpers.getImports(ast) .map(cls => s"import ${cls.getCanonicalName}") .mkString("\n") // Формирует Литералы для использования в узлах val literalVals = helpers.getAllLiteralSpecs(ast) .map { spec => val className = spec.getClass.getSimpleName val valName = toValName(className) s"val $valName: String => Any = new $className().get()" } .mkString("\n") // Формирует функции для использования в узлах val functionVals = helpers.getAllFunctionSpecs(ast) .map { spec => val className = spec.getClass.getSimpleName val valName = toValName(className) s"val $valName: List[Any] => Any = new $className().get()" } .mkString("\n") // Формирует Константы для использования в узлах val constantVals = helpers.getAllConstantSpecs(ast) .map { spec => val className = spec.getClass.getSimpleName val valName = toValName(className) s"val $valName: () => Any = new $className().get()" } .mkString("\n") // Код тела функции apply val body = generateExpression(ast) // Возвращем весь код по шаблону s"""package pico.calc.framework.generated |$imports | |class Formula extends (Map[String, Any] => Any): | $literalVals | $functionVals | $constantVals | | override def apply(params: Map[String, Any]): Any = | $body |""".stripMargin // Получает имя переменной в camalCase private def toValName(className: String): String = ...
Под конец остается только связать все воедино:
// Компилирует код в Map[String, Any] => Any object Compiler { def apply(code: String): Map[String, Any] => Any = { val clazz = ScalaCompiler.compile( source = code, className = "pico.calc.framework.generated.Formula" ) clazz .getDeclaredConstructor() .newInstance() .asInstanceOf[Map[String, Any] => Any] } } // собирает код и компилит object ExpressionCompiler { def apply( literals: Set[LiteralSpec], functions: Set[FunctionSpec], constants: Set[ConstantSpec] )(ast: AstNode): Map[String, Any] => Any = { val literalDispatcher = LiteralDispatcher(literals) val functionDispatcher = FunctionDispatcher(functions) val constantDispatcher = ConstantDispatcher(constants) val helpers = CodeGenHelper(literalDispatcher, functionDispatcher, constantDispatcher) val source = ScalaCodeGenerator(helpers)(ast) Compiler(source) } }
Заключение:
на данном этапе все устроено так, что перед методом apply() генерируются все необходимые функции из спецификаций, и через них же происходит вызов.
Это не самый оптимальный путь, но это MVP и оно работает.
В последующих статьях будет разобрано как делать более сложную и оптимальную кодогенерацию, как протипизировать спецификации и как усложнить диспатчерезацию для перегрузки методов.
P. S. код можно посмотреть тут версия 1.0.0
P.P. S. у меня есть аккаунт на Хабр Карьере
P.P.P.S есть аккаунт в инстаграмм