Здравствуйте. Я учусь на последнем курсе бакалавриата и уже через месяц с небольшим буду защищать свою выпускную квалификационную работу (или же дипломную). Мне захотелось рассказать про неё здесь, чем я сейчас и займусь.
Меня давно интересуют инструменты для обеспечения работы языков программирования - лексеры, парсеры, интерпретаторы, компиляторы и всякое такое. Настолько интересуют, что уже в конце первого курса я решила, что в конце обучения буду защищать свой маленький язык программирования. Увы, создание собственного языка оказалось делом довольно сложным, из-за чего пришлось искать новую тему. Примерно на третьем курсе мы изучали в университете Kotlin, который быстро запал мне в душу и стал моим любимцем (после Lua). Мне захотелось написать дипломную именно на нём, поэтому я стала думать, что бы такое написать. Так как меня интересовал геймдев, я подумала: "Почему бы не создать свой движок для текстовых квестов как альтернативу Ren'Py?". Подумала и написала простой движок. Увы, в нём не было научной новизны, да и писать под него было неудобно, ну хоть получила опыт создания язычков, когда дала жизнь своему Av, про который писала в прошлой статье.
В университете мне сказали, что подобная тема совершенно не подходит для защиты, после чего я снова задумалась, что бы написать. Вспомнила, что довольно неплохо знакома с ANTLR, и решила написать DSL, который позволит превращать классы, которые сгенерировал ANTLR, в AST Kotlin. Эта мысль возникла довольно внезапно, но мне быстро стало интересно, во что она может превратиться. Как писать такой DSL, а главное, зачем? Как я объясняла нашей завкафедрой, подобный инструмент позволит автоматически переводить кодовые базы с разных языков на один - мой любимый Котлин. Кроме того, это поможет поддержать авторов новых языков программирования, поскольку он позволяет не писать свой рантайм, а положиться на уже существующий JVM, ведь Котлин исполняется на нём.
Официально тема моей дипломной работы называется так: "DSL ASTra для описания транспайлеров из классов, сгенерированных ANTLR, в AST Kotlin". Почему ASTra? Потому что [AS]T [Tra]nspiler. Теперь расскажу, как работает мой DSL и как можно его использовать, в качестве примера возьму транспайлер STLC => Kotlin.
Работа фактически состоит из двух частей:
Ядро, которое позволяет описывать транспайлеры
Около трёх сотен классов, которые описывают типизированное AST Kotlin версии 1.9.22
Ядро состоит из следующих классов:
TranspilationRule
RuleBuilder
RuleHolder
RuleHolderBuilder
RuleVisitor
DefaultRule
RuleLogger

TranspilationRule<P, A> - это класс, который позволяет описывать правила транспиляции. Мы сопоставляем семантику P:ParseTree и A:KotlinAst между собой, чтобы в дальнейшем RuleVisitor мог искать правила и преобразовывать дерево разбора в AST.
Как выглядит использование билдера TranspilationRule:
val rX = rule<XContext, KSimpleIdentifier>("x") { from(XContext::class) how {ctx: XContext -> ctx.ID().text.id } }
В данном случае можно опустить типовые параметры функции rule, поскольку они выведутся самостоятельно без всяких проблем, но я обычно указываю их везде. Функция rule вызывает type-safe builder, который позволяет поэтапно настраивать наше правило.
XContent - это класс, сгенерированный ANTLR, KSimpleIdentifier - класс, описывающий простые идентификаторы в AST Kotlin. Функция how описывает, как именно XContent становится KSimpleIdentifier. Существуют ещё howCtx, где ctx становится ресивером, и howFixed, где с помощью комбинатора фиксированной точки сама how становится рекурсивной, за счёт чего можно вызывать себя же без поиска среди правил. Можно также не задавать имя сразу, а вызвать name("name") внутри rule {}, но мне удобнее сразу задавать имя.
Пример посложнее:
val rConditional = rule("conditional") { from(ConditionalContext::class) how {ctx: ConditionalContext -> val tToExp = lookup<TContext, KExpression>() ?: error("ASTra doesn't know how to get KExpression from T") tToExp(ctx.pred).ifElse( tToExp(ctx.if_true).block, tToExp(ctx.if_false).block ) } }
Здесь уже можно увидеть вызов функции lookup. Это довольно интересная часть, поскольку мы просто ищем правило с такой сигнатурой, не интересуясь его именем и не привязываясь к нему хардкодно.
По факту весь DSL состоит из объявления правил, показывающих, как семантика некоего формального языка соответствует семантике Котлина. Есть, конечно, вспомогательные функции, но в целом это всё. Разве что стоит отметить возможность настройки логирования перед вызовом правила и после, а также существование defaultRule, которое будет выполняться, если для узла дерева разбора не будет найдено правило.
Вот пример defaultRule:
default { println("default for $it") "TODO".id() }
Пример транспайлера STLC => Kotlin целиком
val stlcRuleHolder=rules<StlcLexer,StlcParser> { default { println("default for ${it::class.simpleName}") "TODO".id() } val rX= rule("x") { from(XContext::class) how {ctx:XContext-> ctx.ID().text.id} } val rVariable= rule("variable") { from(VariableContext::class) how {ctx:VariableContext-> rX(ctx.x())} } val rTrue= rule("true") { from(Constant_trueContext::class) how {true.ast} } val rFalse= rule("false") { from(Constant_falseContext::class) how {false.ast} } val rParenthesis= rule("parenthesis") { from(ParenthesisContext::class) how {ctx:ParenthesisContext-> val tToExp=lookup<TContext,KExpression>() ?: error("ASTra doesn't know how to get KExpression from Parenthesis") tToExp(ctx.t()).paren } } val rConditional= rule("conditional") { from(ConditionalContext::class) how {ctx:ConditionalContext-> val tToExp=lookup<TContext,KExpression>() ?: error("ASTra doesn't know how to get KExpression from T") tToExp(ctx.pred).ifElse( tToExp(ctx.if_true).block, tToExp(ctx.if_false).block ) } } val rT= rule("t") { from(TContext::class) how {ctx:TContext-> when(ctx) { is VariableContext->rVariable(ctx) is AbstractionContext-> lookup<AbstractionContext,KFunctionLiteral>()?.invoke(ctx) ?: error("ASTra doesn't know how to get KLambdaLiteral from Abstraction") is ApplicationContext-> lookup<ApplicationContext,KExpression>()?.invoke(ctx) ?: error("ASTra doesn't know how to get KExpression from ApplicationContext") is Constant_trueContext->rTrue(ctx) is Constant_falseContext->rFalse(ctx) is ConditionalContext->rConditional(ctx) is ParenthesisContext->rParenthesis(ctx) else->error("ASTra doesn't know how to get KExpression from ${ctx::class.simpleName}") } } } val rApplication= rule<ApplicationContext,KExpression>("application") { from(ApplicationContext::class) how {ctx-> val exp=ctx.t(1) ctx.t(0).let(rT)(rT(exp)) } } val rType= rule("type") { from(TypeContext::class) howFixed {rType-> {ctx:TypeContext-> val boolify={name:String-> if(name=="Bool") "Boolean" else name } when(ctx) { is Flat_typeContext->boolify(ctx.ID().text).type is Abstraction_typeContext-> boolify(ctx.ID().text).type .functionTypeParameters .leadsTo(rType(ctx.type())) .type else->error("ASTra doesn't know how to get other KType from ${ctx::class.simpleName}") } } } } val rAbstraction= rule<AbstractionContext,KLambdaLiteral>("abstraction") { from(AbstractionContext::class) how {ctx-> rX(ctx.x()).variableDecl(rType(ctx.type())) .lambdaParams .literal(rT(ctx.t()).stat) } } }
Думаю, с первой частью всё более-менее понятно, перехожу ко второй. IDEA говорит, что у интерфейса KotlinAst 282 наследника. Я не буду считать вручную, сколько их на самом деле, а поверю ей.
Почему так много классов? Я следовала официальной грамматике и упрощала её там, где это было возможно, но в целом мне пришлось описать её целиком, кроме лишних частей.
Как обычно, начну с "Hello, world!":
"println".id("Hello, world!".ast)
Как можно заметить, здесь println является идентификатором, а "Hello, world!" - строкой в AST. Благодаря перегрузке функции-оператора invoke можно вызвать println.id как обычную функцию, что очень удобно.
val x = "x".id
Здесь мы просто сослались на некоторое свойство "x".
Пример посложнее:
val exp: KBinaryExpression = 1.ast + 2.ast
Здесь выражение имеет тип KBinaryExpression, объединяющий в себе KAdditiveExpression и KMultiplicativeExpression. Путём добавления постфикса ast к литералу целого числа мы превращаем его в KIntegerLiteral.
"obj"["prop".id]
Здесь мы берём свойство "prop" у объекта "obj".
val declaration = kval("x", 10.ast)
Здесь мы создаём объявление свойства. Как можно заметить, оно создаётся необычайно просто, при этом доступна более полная кастомизация:
val declaration1 = kval("x") { +Lateinit // import EMemberModifier.Lateinit +Override // import EMemberModifier.Override isVar +10.ast } // Объявления равнозначны val declaration2 = kval("x") { mods(Lateinit, Override) isVal(false) expr(10.ast) }
Но это ладно, всё выглядит достаточно просто. Для примера возьму прям большой кусок кода и покажу, как он выглядит на Котлине и моём DSL.
Оригинал
abstract class Character(val name: String, var health: Int) { abstract fun attack(target: Character) fun isAlive(): Boolean = health > 0 open fun takeDamage(amount: Int) { health -= amount println("$name получил $amount урона. Осталось $health HP."); if (health <= 0) println("$name пал в бою...") } } abstract class Hero( name: String, health: Int, val power: Int ):Character(name, health) { override fun attack(target: Character) { println("$name атакует ${target.name}!") target.takeDamage(power) } } class Monster( name: String, health: Int, val damage: Int ):Character(name, health) { override fun attack(target: Character) { println("$name кусает ${target.name}!") target.takeDamage(damage); } } fun main() { val hero = Hero("Алиса", 100, 20) val goblin = Monster("Гоблин", 100, 20) println("⚔️ Битва начинается!") while(hero.isAlive()&&goblin.isAlive()) { hero.attack(goblin) if(goblin.isAlive()) goblin.attack(hero) println() } println("🏁 Битва окончена!") }
На DSL
kotlinFile { +kclass("Character",EInheritanceModifier.Abstract) { primaryConstructor { "name" ofType "String" "health" init { isVar type("Int") } } classBody { function("attack") { +EInheritanceModifier.Abstract "target" ofType "Character" } function("isAlive") { type("Boolean".type) exprBody("health".id greater 0.ast) } function("takeDamage") { +EInheritanceModifier.Open "amount" ofType "Int" blockBody { +"health".id.subAssign("amount".id) +"println".id( stringLiteral { ref("name") text(" получил ") ref("amount") text(" урона. Осталось ") ref("health") text(" HP.") } ) +"health".id .lessEq(0.ast) .ifTrue("println".id("\$name пал в бою...".ast).stat) } } } } +kclass("Hero",EInheritanceModifier.Abstract) { primaryConstructor { "name" init { notMine // isn't val/var, just sends to parent constructor type("String") } "health" init { notMine type("Int") } "power" ofType "Int" } delegationSpecifiers { "Character".id.simpleUserType.userType constructorInvocation listOf("name".id.valueArg, "health".id.valueArg) } classBody { function("attack") { +Override "target" ofType "Character" blockBody { +"println".id( stringLiteral { ref("name") text(" атакует ") expr("target".id["name".id]) text("!") } ) +"target"["takeDamage".id]("power".id) } } } } +kclass("Monster") { primaryConstructor { "name" init { notMine type("String") } "health" init { notMine type("Int") } "damage" ofType "Int" } delegationSpecifiers { "Character".id.simpleUserType.userType constructorInvocation listOf("name".id.valueArg, "health".id.valueArg) } classBody { function("attack") { +Override "target" ofType "Character" blockBody { +"println".id( stringLiteral { ref("name") text(" кусает ") expr("target".id["name".id]) text("!") } ) +"target"["takeDamage".id]("damage".id) } } } } +kfun("main") { blockBody { +kval("hero", "Hero".id("Алиса".ast, 100.ast, 20.ast)) +kval("goblin", "Monster".id("Гоблин".ast, 100.ast, 20.ast)) +"println".id("⚔️ Битва начинается!".ast) +("hero"["isAlive".id]() and "goblin"["isAlive".id]()).kwhile { +"hero"["attack".id]("goblin".id) +"goblin"["isAlive".id]().ifTrue( "goblin"["attack".id]("hero".id).stat ) +"println".id() } +"println".id("🏁 Битва окончена!".ast) } } }
Что здесь происходит? Мы объявляем абстрактный класс Character, от которого наследуются классы Hero и Monster. Оба умеют драться и получать урон. Ниже в функции main мы загоняем их на арену и заставляем драться не на жизнь, а на смерть. Код полностью рабочий, если интересно, кто из них выживет, можете запустить в IDE или в онлайн-песочнице.
Как можно заметить, я активно использую type-safe builders и перегрузки функций-операторов, благодаря чему у меня получается относительно компактная и удобная запись.
Использование when тоже относительно удобно:
kwhen("x".id) { "Int".type caseIs "Int".ast "String".type caseIs "String".ast "Unknown".ast.stat.caseElse }
Стоит также отметить, что для преобразования моего AST в исходный код я создала интерфейс Emitter<E>, который может реализовать любой эмиттер, в том числе и KotlinEmitter, который есть в проекте.
Чем отличается от kastree, kotlinx.ast, kotlinpoet? Тем, что у меня именно DSL для генерации AST, он не работает в качестве шаблонизатора, как kotlinpoet, и позволяет описывать AST более лаконично и компактно за счёт удобных билдеров и свойств-расширений. Плюс он создан мной для дипломной работы, да.
Пожалуйста, если примеров не хватило, пишите в комментариях или смотрите в репозитории, там как раз ещё есть пример транспайлера Lisp => Kotlin, где игрушечный Лисп превращается в Котлин по щелчку пальца. Прошу прощения за небрежно оформленный код, я займусь им, когда будет время.
P. S. Av из моей статьи перебрался в репозиторий на Гитхабе, можете тоже глянуть.
