Как стать автором
Обновить

Как я писала DSL

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров6K

Здравствуйте. Я учусь на последнем курсе бакалавриата и уже через месяц с небольшим буду защищать свою выпускную квалификационную работу (или же дипломную). Мне захотелось рассказать про неё здесь, чем я сейчас и займусь.

Меня давно интересуют инструменты для обеспечения работы языков программирования - лексеры, парсеры, интерпретаторы, компиляторы и всякое такое. Настолько интересуют, что уже в конце первого курса я решила, что в конце обучения буду защищать свой маленький язык программирования. Увы, создание собственного языка оказалось делом довольно сложным, из-за чего пришлось искать новую тему. Примерно на третьем курсе мы изучали в университете 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

Диаграмма классов ядра DSL
Диаграмма классов ядра DSL

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 из моей статьи перебрался в репозиторий на Гитхабе, можете тоже глянуть.

Теги:
Хабы:
+24
Комментарии15

Публикации

Ближайшие события