Под катом будет изложена реализация языка простых арифметических выражений с let конструкцией. Чтение будет интересно для людей, не знакомых с языком kotlin или только начинающих свой путь в функциональном программировании.

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)


  1. voddan
    19.09.2016 11:38

    Если цель «придерживаться принципов функционального программирования», то все сделано верно. Но учитывая что Kotlin в основном объектно-ориентированный язык, то конечно операции типа `solve` и `toString` лучше делать с помощью наследования и полиморфизма вместо `when`.


  1. voddan
    19.09.2016 11:48

    Про идеологические недостатки решения: было бы справедливо отметить что:


    • Использование исключений в данном случае лежит полностью на совести автора. При желании можно было бы организовать монаду и тогда было бы все "по функциональным канонам". Другое дело не понятно какой от этого выигрыш.


    • Ленивость вычислений в конкретно этой ситуации зависит не от языка, а от реализации. К текущему коду можно добавить небольшой кусок и выражения станут вычисляться лениво.


    1. punksta
      20.09.2016 09:41

      Спасибо за комментарий. Речь шла скорее про природу чистых функций, чем про выбор подхода. Согласен: стиль выбирает программист. В языках Java и Kotlin, вычисления в монадах осложняются, если из их «недр» летят исключения. Код начинает отбросать try-catch. Про ленивость согласен.


  1. 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)


    1. 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 вернуть.


      1. 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]


        1. xGromMx
          20.09.2016 15:02

          Это уже Scala


        1. punksta
          20.09.2016 15:56

          Думаю, что и на котлине вполне можно такое написать, тут никакой магии. Мне больше интересно самому проверку типов написать, в виде отдельной функции. Или же дополнить язык лямбдами и написать вывод типа, но вот реализацию последнего пока не представляю именно на kotlin.