variable("hello") + variable("habr") * 2016 where ("hello" to 1) and ("habr" to 2)
В статье читатель познакомится с такими конструкциями Kotlin, как extensions function, pattern matching, operator overriding, infix functions и некоторыми принципами функционального программирования. Написание парсера не входит в тему статьи, поэтому наш язык будем реализовывать внутри языка kotlin, подобно языкам скриптов систем сборки по отношению к скриптовым языкам, на которые первые написаны (grodle: groovy). Материал подается достаточно подробно. Желательно знание Java.
Постановка задачи
- определить синтаксис языка арифметических выражений;
- реализовать вычисление;
- придерживаться принципов функционального программирования
В нашем языке должны быть целочисленные константы, именованные выражения (let, where), операции сложения и умножения (для простоты).
Синтаксис
-
const(2)
— константа; -
variable("x")
— именованное выражение. «x» не является переменной с точки зрения операции присвоения значения. Это лишь имя некого выражения, определенного до или после текущей выражения; -
const(2) + const(2)
— пример операции сложения; -
variable("y") * 2
— пример операции умножения; -
variable("y") * const(2)
— пример операции умножения; -
"x" let (const(2)) inExr (variable("x") * 4)
— пример операции именования переменной в выражении; -
(variable("x") * 4) where ("x" to 1)
— пример операции именования переменной в выражении.
Реализация
Для формализации синтаксиса языка удобно представлять выражения в форме Бэкуса — Наура. В данной схеме number — это целое число, string — строка из стандартной библиотеки:
<expr> ::= (<expr>) | <const> | <var> | <let> | <operator>
<const> := const(<number>)
<var> := variable(<string>)
<let> := <var> let <number> inExpr (<expr>) | <var> let <expr> inExpr (<expr>) | <let where>
<let where> := (<expr>) where (<let where pair> ) | <let where> and (<let where pair> )
<let where pair> ::= <var> to <const> | <var> to <number> | <var> to (<expr>)
<operator> := <expr> * <expr> | <expr> + <expr> | <expr> * <number> | <expr> + <number>
Там, где это возможно, const заменено на number для лаконичности синтаксиса. Вопросы его реализации опишем в конце статьи. Сейчас нас будет интересовать вычисление.
Вычисления
Опишем структуру термов в виде классов. Мы будет использовать sealed class, так как далее нам будет удобно использовать его как образец. В котлине механизм паттерн матчинга является синтаксических сахаром над конструкциями switch case, isInstace из java, но не полноценным сопоставлением с образцом из мира функциональных языков.
sealed class Expr {
//константа лишь хранит свое значение
class Const(val c: Int): Expr()
//переменная хранит имя выражения
class Var(val name : String) : Expr()
//let конструкция хранит имя выражения и связанное выражение и выражение, где используется данное имя
//мы будем использовать один класс для и для where конструкций
class Let(val name: String, val value: Expr, val expr: Expr) : Expr()
//сумма хранит 2 выражения
class Sum(val left : Expr, val right: Expr) : Expr()
//произведение хранит 2 выражения
class Sum(val left : Expr, val right: Expr) : Expr()
override fun toString(): String = when(this) {
is Const -> "$c"
is Sum -> "($left + $right)"
is Mult -> "($left * $right)"
is Var -> name
is Let -> "($expr, where $name = $value)"
}
}
В зависимости от типа expr, нам становятся доступные поля, определенные в его потомках. Это реализуется с помощью smart-casts: неявного приведения типа внутри тела подобных
if (obj is Type)
конструкций. В аналогичном коде на языке java приходилось бы приводить типы вручную.Теперь мы можем описывать выражения вывозом конструкторов классов-наследников Expr, и пока нам этого хватит. В разделе синтаксис мы опишем функции, позволяющие писать выражения более лаконично.
val example = Expr.Sum(Expr.Const(1), Expr.Const(2))
Вычислять выражения мы будем с помощью рекурсивной функции последовательно «раскрывая» выражения. Тут пора вспомнить про именования. Для реализации let конструкции нам понадобится где-то хранить именованные выражения. Введем понятие контекст вычисления: список пар имя -> выражение
context: Map<String, Expr?>
. Если вы встретим переменную по ходу вычисления, мы должны получить выражение из контекста.fun solve(expr: Expr, context: Map<String, Expr? >? = null) : Expr.Const = when (expr) {
//в случае константы просто вернем ее, вычисление завершено
is Expr.Const -> expr
//вернем перемноженные результаты левого и правого выражения
is Expr.Mult -> Expr.Const(solve(expr.left, context).c * solve(expr.right, context).c)
//вернем сложенные результаты левого и правого выражения
is Expr.Sum -> Expr.Const(solve(expr.left, context).c + solve(expr.right, context).c)
//в текущий контекст добавим новую пару name -> value и вернем результат expr в новом контексте
is Expr.Let -> {
val newContext = context.orEmpty() + Pair(expr.name, expr.value)
solve(expr.expr, newContext)
}
//если в контексте есть имя expr.name, вернем результат выражения, иначе остановим вычисления исключением
is Expr.Var -> {
val exprFormContext : Expr? = context?.get(expr.name)
if (exprFormContext == null) {
throw IllegalArgumentException("undefined var ${expr.name}")
} else {
solve(exprFormContext, context!!.filter { it.key != expr.name })
}
}
}
Несколько слов о коде:
- Для контекста используется immutable объекты. Это значит, что добавляя в контекст новую пару, мы получаем новый контекст. Это важно для последующих вычислений: функции, вызванные из данной, не должны изменять текущий контекст. Это достаточно распространенный подход в функциональном программировании
- С точки зрения чистых функций и их вычислений исключения недопустимы. Функция должна определять отображение одного множества на другое. Можно решить проблему ошибки во время вычисления введя тип для результата вычисления:
sealed class Result { class Some(val t: Expr) : Result() class Error(val message: String, val where: Expr) : Result() }
- С помощью функций из стандартной библиотеки можно несколько сократить код, это будет сделано в конце статьи
Бывают случаи, когда мы можем предсказать результат вычисления еще до его завершения. К примеру, при умножении на 0 результат будет 0. Введя функцию-расширение
fun Expr.isNull() = if (this is Expr.Const) c == 0 else false
умножение примет следующий вид:is Expr.Mult -> when {
p1.left.isNull() or p1.right.isNull() -> const(0)
else -> const(solve(p1.left, context).c * solve(p1.right, context).c)
}
К аналогичному подходу можно прибегать при реализации других операций. К примеру для деления потребуется проверка деления на 0
Синтаксис
Для реализации синтаксиса используются extension-functions и operator-overloading. Выглядит это весьма наглядно.
fun variable(name: String) = Expr.Var(name)
fun const(c : Int) = Expr.Const(c)
//const(1) * const(2) == const(1).times(const(2))
infix operator fun Expr.times(expr: Expr): Expr = Expr.Mult(this, expr)
infix operator fun Expr.times(expr: Int): Expr = Expr.Mult(this, const(expr))
infix operator fun Expr.times(expr: String) : Expr = Expr.Mult(this, Expr.Var(expr))
//аналогично для плюс
infix operator fun Expr.plus(expr: Expr): Expr = Expr.Sum(this, expr)
infix operator fun Expr.plus(expr: Int): Expr = Expr.Sum(this, const(expr))
infix operator fun Expr.plus(expr: String) : Expr = Expr.Sum(this, Expr.Var(expr))
//where
infix fun Expr.where(pair: Pair<String, Expr>) = Expr.Let(pair.first, pair.second, this)
@JvmName("whereInt")
infix fun Expr.where(pair: Pair<String, Int>) = Expr.Let(pair.first, const(pair.second), this)
@JvmName("whereString")
infix fun Expr.where(pair: Pair<String, String>) = Expr.Let(pair.first, variable(pair.second), this)
//let and
infix fun Expr.and(pair: Pair<String, Int>) = Expr.Let(pair.first, const(pair.second), this)
@JvmName("andExr")
infix fun Expr.and(pair: Pair<String, Expr>) = Expr.Let(pair.first, pair.second, this)
//let реализуется через вспомогательный класс:
// ("s".let(1)).inExr(variable("s"))
class ExprBuilder(val name: String, val value: Expr) {
infix fun inExr(expr: Expr): Expr
= Expr.Let(name, value, expr)
}
infix fun String.let(expr: Expr) = ExprBuilder(this, expr)
infix fun String.let(constInt: Int) = ExprBuilder(this, const(constInt))
Примеры
fun testSolve(expr: Expr, shouldEquals: Expr.Const) {
val c = solve(expr)
println("$expr = $c, correct: ${shouldEquals.c == c.c}")
}
fun main(args: Array<String>) {
val helloHabr = variable("hello") * variable("habr") * 3 where ("hello" to 1) and ("habr" to 2)
testSolve(helloHabr, const(1*2*3))
val e = (const(1) + const(2)) * const(3)
testSolve(e, const((1 + 2) *3))
val e2 = "x".let(10) inExr ("y".let(100) inExr (variable("x") + variable("y")))
testSolve(e2, const(110))
val e3 = (variable("x") * variable("x") * 2) where ("x" to 2)
testSolve(e3, const(2*2*2))
val e4 = "x" let (1) inExr (variable("x") + (variable("x") where ("x" to 2)))
testSolve(e4, const(3))
val e5 = "x" let (0) inExr (variable("x") * 1000 + variable("x"))
testSolve(e5, const(0))
}
((((hello * habr) * 3), where hello = 1), where habr = 2) = 6, correct: true
((1 + 2) * 3) = 9, correct: true
(((x + y), where y = 100), where x = 10) = 110, correct: true
(((x * x) * 2), where x = 2) = 8, correct: true
((x + (x, where x = 2)), where x = 1) = 3, correct: true
(((x * 1000) + x), where x = 0) = 0, correct: true
Недостатки решения
Постановка задачи и решение являются учебными. В решении можно выделить следующие недостатки:
Прагматические:
- Неэффективность методов вычисления и представления термов;
- Отсутствие описанной грамматики и приоритета операций;
- Ограниченный синтаксис (несмотря на выразительность, kotlin накладывает ограничения);
- Отсутсвие других типов, кроме целочисленного.
Идеологические
- Исключения рушат красоту чистых функций;
- Отсутсвие ленивости вычисления, присущие некоторым функциональным языкам.
P.S.
Приветствуется критика к изложению и коду.
Исходный код с добавлением деления, вещественных чисел и выражения If
Комментарии (8)
voddan
19.09.2016 11:48Про идеологические недостатки решения: было бы справедливо отметить что:
Использование исключений в данном случае лежит полностью на совести автора. При желании можно было бы организовать монаду и тогда было бы все "по функциональным канонам". Другое дело не понятно какой от этого выигрыш.
- Ленивость вычислений в конкретно этой ситуации зависит не от языка, а от реализации. К текущему коду можно добавить небольшой кусок и выражения станут вычисляться лениво.
punksta
20.09.2016 09:41Спасибо за комментарий. Речь шла скорее про природу чистых функций, чем про выбор подхода. Согласен: стиль выбирает программист. В языках Java и Kotlin, вычисления в монадах осложняются, если из их «недр» летят исключения. Код начинает отбросать try-catch. Про ленивость согласен.
Akon32
19.09.2016 19:19Язык арифметических выражений придуман не очень хороший, отсюда "прагматические недостатки" 2-4.
Синтаксисы let и where как-то дублируют друг друга.
Кажется, из-за вложенности классов допустимо что-то вроде Result.Some.Some.Error("...") (не уверен). Это было бы странно для DSL.
Без приоритета операций совсем никуда — нехорошо, если выражение 2 + 1 * 3 внезапно равно 9.
Нагляднее было бы представлять выражения в виде деревьев, тогда могло бы появиться "описание грамматики" (в виде case-классов, как и у вас), приоритет операций стал бы не нужен, и наверно можно было бы ввести какую-то типизацию.
Пример:val expr = If( Less( Var("x"), Const(3)), Mul(Var("y"),Const(4)), Mul( Const(5), Add( Const(1), Const(2)))) where ( "x" eq 43, "y" eq 3)
Здесь синтаксический сахар языка Scala использован не в полной мере, т.к. я не знаю, что из сахара есть в Kotlin. Это — минималистичный по использованию сахара вариант. Можно писать и короче, типа Mul (var"x", var"y", 5, 6)
punksta
20.09.2016 10:02Спасибо за ответ.
ПроResult.Some.Some.Error("...")
sealed класс транслируются в abstact class, а каждый наследник в inner class(в скале ведь так-же case классы работают). По этому описанной ситуации быть не может.
Просто захотелось иметь возможность описывать связывания до и после выражения. Вы правы, семантика одна.
2 + 1 * 3 будет вычислено еще на этапе записи выражения в рантайме, так-как это перегруженные операторы.
Уже сейчас есть возможность описывать выражения через деревья с помощью конструкторов). Могу написать эквивалент вашему. Инфиксные функции я добавил для демонстрации возможностей dsl. Согласен, что с ними я получил кучу проблем.
Написал If Else, почти как у вас и функции к нему:
val example= ifE(const(1), BoolKey.Less, const(2)) { variable("helloHabr") } ifFalse { const(2016.0) } where ("helloHabr" to 2016)
Теперь точно нужна проверка типов. Легко можно разные типы в then else вернуть.Akon32
20.09.2016 14:53Проверку типов реализовать несложно:
abstract sealed class Expr[+T] object EmptyExpr extends Expr[Unit] case class If[+R,R1:R,R2:R](test: Expr[Boolean],ifTrue: Expr[R],ifFalse: Expr[R] = EmptyExpr) extends Expr[R]
punksta
20.09.2016 15:56Думаю, что и на котлине вполне можно такое написать, тут никакой магии. Мне больше интересно самому проверку типов написать, в виде отдельной функции. Или же дополнить язык лямбдами и написать вывод типа, но вот реализацию последнего пока не представляю именно на kotlin.
voddan
Если цель «придерживаться принципов функционального программирования», то все сделано верно. Но учитывая что Kotlin в основном объектно-ориентированный язык, то конечно операции типа `solve` и `toString` лучше делать с помощью наследования и полиморфизма вместо `when`.