InquirerUrdaneta gets new leaders after dismissal of mayor, vice mayorRTP Desporto12h30 Samu muito elogiado e os pequenos que foram grandes na TaçaESPNBears CB Kyler Gordon returns with vengeance, answers criticsPunchUNILORIN graduates 16,152 students at 41st convocation ceremonyESPN DeportesDestacan audiencia y asistencia de WNBAThe Jerusalem PostHamas planned to detonate bombs at Gaza refugee camps to cover terrorists' escape, IDF revealsBollywood HungamaTiger Shroff Firing Incident: Three accused arrested by Kota police while travelling on Pune-Jaipur expressMintFSSAI proposes ban on added sugars in infant foods: Why it wants to act and how sucrose, fructose can harm babiesLa TerceraUcrania afirma que una reunión de Putin con Xi y Trump solo servirá para continuar la guerraRTL BoulevardVan Douzen Kroes tot Imaan Hammam: deze 5 Nederlanders schitteren bij Victoria's Secret Fashion ShowZDF heuteEntdecken Sie das ZDF-NachrichtenstudioThe South AfricanGovernment pays tribute to media on ‘Black Wednesday’
The Daily Newsstand · Free, Always
Monday, October 19, 2026

Реализация MVP DSL движка с AST, кодогенерацией и динамической компиляцией

Translate

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

Целеполагание:

В данной статье я демонстрирую свой фреймворк для динамически компилируемых функций по принципу задания некоторой формулы для вычислений с параметрами: "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 - параметр, передаваемый функции cos

  • log - имя функции

  • 2 - числовой литерал, передаваемый функции log

  • pi - имя константы, передаваемой функции 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 есть аккаунт в инстаграмм

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.