Что такое строковый калькулятор?

Перед тем, как мы начнем обсуждать создание строкового калькулятора, давайте определимся, что это такое. Строковый калькулятор (далее просто "калькулятор") - это алгоритм или программа, которая преобразует арифметическое выражение, представленное в виде строки (String), в числовое значение (Double).

Приведу конкретные примеры:
"cos(-pi/)^2 + sin(-pi/2)^2" => 1
"tanh(-2*pi)-(e^(-2*pi)-1)/(e^(-2*pi)+1)" => 0

Мотивация

Почему я решилa создать строковый калькулятор?

Недавно я столкнулась со статьей, в которой описывался алгоритм оценки параметров системы дифференциальных уравнений по неточным наблюдениям. Для реализации этого алгоритма мне потребовался класс калькулятора. Я выдвигаю следующие требования к своему проекту:

  1. Пользователь должен иметь возможность работать с различными операциями
    (бинарными операторами и унарными функциями), которые он определяет
    самостоятельно.

  2. Калькулятор должен поддерживать работу с константами (например, число π,
    число Эйлера и т.д.), которые также задает пользователь.

  3. При вычислении выражения сначала должны выполняться операции, заключенные в круглые скобки.

  4. Должен соблюдаться приоритет операций, например, сначала должны выполняться операции умножения, а затем сложения.

  5. Калькулятор должен корректно обрабатывать ситуации, когда унарная
    функция используется для обозначения обратного элемента (например, "-1")
    относительно некоторой операции.

Создание такого строкового калькулятора позволит мне эффективно реализовать алгоритм оценки параметров системы дифференциальных уравнений и обеспечит гибкость для пользователей в работе с выражениями и операциями.

Определим сущности

В коде мы начнем с определения классов для объектов, таких как бинарные операторы, унарные функции и константы. Для этого воспользуемся конструкциями языка Scala.

//бинарная операция
trait BinaryOperation {

  def getDesignation: String // обозначение самой операции например +,-,* 
  def getPriority: Int // приоритет операции
  def getSingleElement: Double // единичный элемент относительно данной операции

  def calc(left: Double, right: Double): Double // что делает операция

  //переопределяем метод equals
  override def equals(obj: Any): Boolean = {
    obj match {
      case str: String => getDesignation == str
      case _ => if (obj == null || getClass != obj.getClass) false
      else getDesignation == obj.asInstanceOf[BinaryOperation].getDesignation
    }
  }

}

// унарная функция
trait UnaryFunction {

  def getDesignation: String // обозначение самой операции например ln,cos,exp 
  def calc(number: Double): Double // что делает операция

  //переопределяем метод equals
  override def equals(obj: Any): Boolean = {
    obj match {
      case str: String => getDesignation == str
      case _ => if (obj == null || getClass != obj.getClass) false
      else getDesignation == obj.asInstanceOf[UnaryFunction].getDesignation
    }
  }

}

// сconstanta
trait Constant {

  def getDesignation: String // обозначение самой операции например pi,e
  def getValue: Double // значение 

  //переопределяем метод equals
  override def equals(obj: Any): Boolean = {
    obj match {
      case str: String => getDesignation == str
      case _ => if (obj == null || getClass != obj.getClass) false
      else getDesignation == obj.asInstanceOf[Constant].getDesignation
    }
  }

}

Чтобы прояснить синтаксис, уточню, что здесь я использую трейты (traits) в качестве интерфейсов, подобных тем, что есть в языках Java или C#. Помимо классов для объектов, я также создала трейты-фабрики для порождения анонимных объектов:

сущность для создания обьектов унаследованных от binaryOperation
trait BinaryOperationFabric {

  private val binaryOperationPool: ListBuffer[BinaryOperation] = new ListBuffer

  def createBinary(designation: String, priority: Int, fun: (Double, Double) => Double, singleElement: Double): BinaryOperation = {

    if(binaryOperationPool.contains(designation)) {
      binaryOperationPool.filter(_.getDesignation == designation).head
    } else {

      val binary = new BinaryOperation {
        override def getDesignation: String = designation

        override def getPriority: Int = priority

        override def calc(left: Double, right: Double): Double = fun(left, right)

        override def getSingleElement: Double = singleElement
      }

      binaryOperationPool.append(binary)
      binary

    }

  }

  // для унарных функций и констант есть аналогичных трейты

Конечной сущностью, которую мы определим, будет класс самого калькулятора. Этот класс будет отвечать за преобразование арифметического выражения, представленного в виде строки, в числовое значение.

trait StringCalculator {

  //колекции сущностей
  private val binaryOperations: ListBuffer[BinaryOperation] = new ListBuffer[BinaryOperation]
  private val unaryFunctions: ListBuffer[UnaryFunction] = new ListBuffer[UnaryFunction]
  private val constants: ListBuffer[Constant] = new ListBuffer[Constant]
  private val separator: String = "†" // сепаратор вспомогательный инструмент

  def calculate(expression: String): Double = {
    // ...
  }

  // методы для добавления новых сущьностей
  def addBinaryOperation(binary: BinaryOperation): Unit = binaryOperations += binary
  def addUnaryFunction(unary: UnaryFunction): Unit = unaryFunctions += unary
  def addConstant(const: Constant): Unit = constants += const
 
}

Первый этап: операции без приоритета:


Разработку алгоритма начнем с малого, нужно чтобы метод справляться с выражениями по типу "1+2-3*8/8 4+1". получить их в виде списка List

val reducedExpression: String = binaryOperations.map(binary => binary.getDesignation)
  .foldLeft(expression)((str, designation) => str.replace(designation.toString, s"$separator$designation$separator"))

val tokens: List[String] = expression.split(separator).toList

Для начала нужно в выражении отделить операции от чисел и получить их в виде списка Listобычный сплит по всем операциям тут не подойдет, поскольку он удалит сами операции и вместо этого я беру список всех операций и binaryOperations.map(binary => binary.getDesignation) и делаю замену всех операций на те же операции окружённые сепараторами т.е: "+" => "†+†". И таким образом для строки мы получим
"1+2-38/8*4+1" => "1†+†2†-†38†/†8†*†4†+†1" и уже split-уя мы получаем list:
List(1,+,2,, -3,*,8,/,8,*,4,+,1)

Таким образом, мы успешно разделили числа и операции друг от друга.

val numbers: List[Double] = finalTokens.filter(token => isNumeric(token)).map(token => token.toDouble)
val operations: List[String] = finalTokens.filter(token => !isNumeric(token))

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

  @tailrec
  private def performCalculations(numbers: List[Double], operations: List[String]): Double = {

    val right: Double = numbers.last

    if (operations.nonEmpty) {
      operations.last match {
        case x if (binaryOperations.contains(x)) =>
          val res = binaryOperations.filter(op => op.equals(x)).head.calc(numbers(numbers.size - 2), right)
          if (numbers.init.init.isEmpty && operations.init.isEmpty) res else performCalculations(numbers.init.init :+ res, operations.init)
        case token => throw new Exception(s"$token - неизвестный токен")
      }
    } else {
      numbers.last
    }

  }


Пример процесса вычисления: "1+1+1+1" => "1+1+2" => "1+3" => "4" => 4

Такой подход позволяет обрабатывать выражения справа налево, выполняя операции между последними двумя числами и обновляя список чисел после каждой операции.

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

Второй этап: обработка констант

Для обработки констант в выражении мы можем просто пройтись по всем токенам и заменить константы на их значения. После замены констант, выражение будет содержать только числа и операции.

val tokenWithConstant = constants.foldLeft(tokens)((acc, const) =>
  acc.foldLeft(List[String]())((acc, token) => {
  if (token == const.getDesignation) acc :+ const.getValue.toString else acc :+ token
}))

Пример процесса обработки констант:
"e+1+pi" => "2.71+1+3.14" => "2.71+4.14" => "6.85" => 6.85

Таким образом, мы заменяем константы, такие как "e" (число Эйлера) и "pi" (число пи), на соответствующие числовые значения. Это позволяет проводить дальнейшие вычисления с использованием чисел вместо символических обозначений констант.

Третий этап: выражения в скобках

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

Пример процесса обработки выражений в скобках:
"(e+pi+(12))^3" => "(e+pi+(1*2))^3" => "(e+pi+2)^3" => "(2.71+3.14+2)^3" =>
"7.85^3" => 483.736625

Для начала напишем метод, который будет выделять выражение во внешних скобках. Здесь я не буду останавливаться на деталях реализации этого метода, так как это может занять много времени. Важно заметить, что строка "separator{calculate(m.group(1))}separator" заменяет подстроку на вычисленное число.

   def replaceExpressionsWithExclamation(str: String): String = {

    (separator + "(.*?)" + separator).r.replaceAllIn(str.foldLeft(("", 0)) { (acc, char) =>
      val (output, bracketDepth) = acc
      if (char == '(') (output + (if (bracketDepth > 0) char else separator), bracketDepth + 1)
      else if (char == ')') (output + (if (bracketDepth > 1) char else separator), bracketDepth - 1)
      else (output + char, bracketDepth)
    }._1 , { m =>
      $separator${calculate(m.group(1))}$separator
    })

  }

Продолжив работу над алгоритмом, мы сможем обрабатывать выражения в скобках, рекурсивно применяя алгоритм к подвыражениям. Замена подвыражений на их вычисленные значения позволит продолжить вычисления в оставшейся части выражения.

Если у вас возникнут вопросы или потребуется дальнейшая помощь, пожалуйста, сообщите мне.

Четвертый этап: обработка унарных функций

В контексте обработки унарных функций подразумевается функции от одной переменной, такие как cos, sin, ln, sqrt и т.д. Синтаксис таких функций выглядит, например, как "fun(...)". Важно отметить, что выражение находится внутри скобок, что означает, что оно будет заменено на число (это было реализовано на предыдущих этапах с обработкой скобок). Таким образом, замена будет выглядеть приблизительно так: "fun(...)" => "fun num".

Стратегия обработки унарных функций состоит в том, что количество унарных функций в выражении не влияет на соотношение бинарных операций и чисел (число всегда на одно больше). Поэтому мы можем просто пройтись по всем токенам и, когда встречается унарная функция, взять число справа, выполнить операцию над этим числом, удалить унарную функцию с числом и поместить результат операции справа.

val tokensUnWithBinary = tokens.reverse.foldLeft(List[String] {
  tokens.last
})((acc, token) => if (unaryFunctions.contains(token)) {
  val num = acc.last.toDouble
  acc.init :+ unaryFunctions.filter(_.getDesignation == token).head.calc(num).toString
} else acc :+ token
).reverse.init.filter(_.nonEmpty)

Важно заметить, что в данном случае мы инвертируем порядок токенов и идем справа налево, чтобы обрабатывать случаи, когда унарные операции идут подряд, например, "ln(ln(ln(ln(...".

Пример процесса обработки унарных функций:

"ln(ln(1+e))+1" => "ln(ln3.71)+1" => "ln1.31+1" => "0.27 + 1" => "1.27" => 1.27

Таким образом, мы обрабатываем унарные функции, выполняя операции с числами и заменяя унарные функции на результаты операций.

Пятый этап: приоритеты

Одна из наиболее важных частей - это соблюдение приоритетов операций. Процесс довольно прост.

val minPriority = binaryOperations.map(_.getPriority).min
  implicit val ordering: Ordering[BinaryOperation] = Ordering.by(1.0 / _.getPriority)
  val orderedOperations = binaryOperations.toList.filter(_.getPriority != minPriority).sorted

  val finalTokens = orderedOperations.foldLeft(tokensUnWithBinary)((acc, binary) => {

    val indexes = tokens.zipWithIndex.filter(_._1 == binary.getDesignation).map(_._2)

    indexes.sorted.foldRight(acc)((index, acc) => {
       val left = tokensUnWithBinary(index - 1).toDouble
       val right = tokensUnWithBinary(index + 1).toDouble
       (acc.take(index - 1) :+ binary.calc(left, right).toString) ++ acc.takeRight(acc.size - index - 2)
     })

})

Первое, что мы делаем, это сортируем операции в порядке убывания приоритетов. Мы создаем список orderedOperations, в который входят операции, отсортированные по приоритету. Важно отметить, что в orderedOperations не включаются операции с самым низким приоритетом, так как они уже были обработаны на предыдущих этапах.

Далее, проходя справа налево по всем токенам, мы выполняем замену в соответствии с принципом "num1*num2" => "num3". Например, путь строки "2*2^2" => "2*4" => "8" => 8.

Таким образом, мы обрабатываем операции с учетом их приоритетов и выполняем соответствующие замены, чтобы получить окончательный результат вычисления.

Завершающий этап:


В выражении могут случиться ситуации когда например "-e+" в данном случае минус тут это не сколько операция, сколько обозначение обратного элемента, отрицательного числа и с такими ситуациями так же надо справляться. Для этого в классе операции мы определили единичный элемент, то есть элемент операция с которым даст нам тот же элемент. Так
для - такой элемент - это ноль. Вся суть состоит в том что бы отыскать все бинарные операции в в унарной форме и конкатенировать с лева с их единичным элементом например: "-e" => "0-e"тогда мы все сводим к ситуации с которой уже наш калькулятор справлялся и до этого.

val fin = binaryOperations.foldLeft(tokenWithConstant)((acc, binary) => {

  val indexing = acc.zipWithIndex.foldLeft(List[Int]())((acc, tokenIndex) => {
    if (tokenIndex._1 == binary.getDesignation) acc :+ tokenIndex._2 else acc
    })

  indexing.sorted.reverse.foldLeft(acc)((acc, index) => {
    if (!isNumeric(acc(index - 1))) (acc.take(index) :+ calculate(s"${binary.getSingleElement}${binary.getDesignation}" + acc(index + 1)).toString) ++ acc.takeRight(acc.size - index - 2) else acc
  })

})

Заключение

Теперь калькулятор действительно обладает большой функциональностью. Он способен обрабатывать различные операции, унарные функции, константы, а также выражения в скобках с соблюдением приоритетов. Главным преимуществом моей реализации я считаю возможность пользователей настраивать функциональность калькулятора под свои потребности, добавляя необходимые им сущности. Именно потребность в такой гибкости вдохновила меня на создание данного проекта.

  printResalt("cos(-pi/2)^2 + sin(-pi/2)^2")
  printResalt("lg(1 - 1/2)+lg(1 - 1/3)+lg(1 - 1/4)+lg(1 - 1/5)+lg(1 - 1/6)+lg(1 - 1/7)+lg(1 - 1/8)+lg(1 - 1/9)+lg(1 - 1/10)")
  printResalt("round((tanh(-2*pi)-(e^(-2*pi)-1)/(e^(-2*pi)+1))*100)")
  printResalt("round(fi^20)")

  //функцияя для удобства
  def printResalt(expression: String): Unit = {
    println(s"$expression = ${calculate(expression)}")
  }
результат
результат

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

P.S. Вы можете ознакомиться с проектом на GitHub

https://github.com/PicoPicoRobotWoman/string_calculator

P.P.S.

в проекте в src директории находится папка example, где продемонстрированы примеры как пользоваться калькулятором.

Комментарии (10)


  1. neit_kas
    13.07.2023 20:01
    +3

    Много операций со строками. Выглядит не особо быстрым. Когда-то делал такое, но на стеке: объединил алгоритм вычисления обратной польской нотации и алгоритм преобразования из строки в обратную польскую нотацию, причём так, чтобы всё это было однопроходным.

    А так, что бы ещё добавил,так это функции от нескольких аргументов. Там у вас по сути небольшая доработка будет: указываем у функции, сколько аргументов она хочет, далее из списка забираем их.


    1. EvilMan
      13.07.2023 20:01
      +1

      На первом курсе тоже делал нечто подобное. Для построения обратной польской нотации из инфиксной нотации использовал Алгоритм Сортировочной Станции (Shunting Yard Algorithm) от Дейкстры.


      1. PicoPicoRobotWoman Автор
        13.07.2023 20:01

        я так же делала только на C#, в этот раз мне хотелась как то иначе, ну и цели в этот раз иные были и мне отчасти хотялась поупряжнятся еще на Scala писать, мой основной ЯП большую часть времени был Java


  1. Alexandroppolus
    13.07.2023 20:01

    Тоже делал подобную штуку в студенческие годы. По строке строилось дерево выражения, которое потом вычислялось (помимо представленных тут BinaryOperation/UnaryFunction/Constant, была ещё сущность Variable, которая по сути переменная и брала своё значение по имени из коллекции переданных параметров). Так же ради интереса добавил символьное дифференцирование и тривиальные упрощения.


    1. PicoPicoRobotWoman Автор
      13.07.2023 20:01

      в моем вариант можно дифференцирование легко добавить)
      addUnaryFunction(createUnary("d", num => (num+0.00000001)/0.00000001))


  1. agalakhov
    13.07.2023 20:01

    Эм... Ну как бы подобные задачи обычно решают иначе, например вот так. На строках неудобно, длинно и чревато ошибками, а тут конечный автомат и стек.


  1. belch84
    13.07.2023 20:01

    По строке строилось дерево выражения, которое потом вычислялось (помимо представленных тут BinaryOperation/UnaryFunction/Constant, была ещё сущность Variable, которая по сути переменная и брала своё значение по имени из коллекции переданных параметров
    Это правильный подход, поскольку для решения практических задач приходится достаточно часто вычислять значение одного и того же выражения множество раз (например, при построении графиков функций, вычислении определенных интегралов, численном решении дифференциальных уравнений)
    в моем вариант можно дифференцирование легко добавить)
    addUnaryFunction(createUnary(«d», num => (num+0.00000001)/0.00000001))
    Не очень понял, как это вы добавляете дифференцирование, похоже, вы пытаетесь вычислять производную численно, да еще и прямо по определению (это не слишком эффективно). Конечно же, в нормальном калькуляторе, если уж он умеет вычислять значения выражений, должно (ладно, не должно, но не помешает) присутствовать аналитическое дифференцирование, т.е. построение по заданному выражению выражения для его производной по заданной переменной (эту операцию лучше применять сразу к синтаксическому дереву выражения)


    1. Alexandroppolus
      13.07.2023 20:01

      построение по заданному выражению выражения для его производной по заданной переменной (эту операцию лучше применять сразу к синтаксическому дереву выражения)

      Именно. По правилам дифференцирования. Плюс уборка всякого "мусора" (вроде умножения на 1 или 0, сложения с нулем, и т.д.), который остается от чисто механического применения этих правил. А в идеале, как следует сокращать выражение, это уже куда более хитрая задача (например, выражение (2x + sin(x)^2 - x + cos(x)^2 - x) сокращается до 1, но слагаемые могут быть "неудачно" раскиданы по узлам BinaryOperation, и надо их как-то "сводить" друг с другом).


  1. belch84
    13.07.2023 20:01

    Плюс уборка всякого «мусора» (вроде умножения на 1 или 0, сложения с нулем, и т.д.), который остается от чисто механического применения этих правил

    Упрощение промежуточных выражений при аналитическом дифференцировании
    image


  1. kmatveev
    13.07.2023 20:01

    Зашёл почитать про лексеры и парсеры, и, хоть я и не Scala-разработчик, имею много замечаний по прочитанному коду.

    Первое - в BinaryOperation/UnaryFunction не надо делать equals(), который принимает String. Гораздо лучше будет для хранения операций использовать не List, а Map (она же есть в Scala, я верю в это), тем более что вы потом по содержимому List-а делаете операции map()/filter().

    Во-вторых, и бинарные операции, и унарные, и константы имеют очень много общего: они имеют обозначение (ваше getDesignation), и могут быть вычислены (ваше calc()/getValue), поэтому вместо того, чтобы объявлять эти методы в трейтах по-отдельности, вполне подошёл бы родительский трейт (я верю, что такое есть в Scala). При этом сильно убавится кода, который делает одно и то же для разных трейтов (например, токенизация).

    В-третьих, нафига BinaryOperactionFabric? Вместо того, чтобы императивно клеить символ к функции и кешировать, проще в коде декларативно наобъявлять статических экземпляров трейта, а "фабрика" по-английски будет "Factory".

    Тут я, наконец, добрался до токенизации. Ну, это где символы обрамляются сепараторами через string.replace(), и потом всё нарезается через string.split(). Мощно. Я долго искал себе оправдание, почему я не додумался до такого? Потом придумал искусственный случай, когда это сломается (если обозначение операции является подстрокой обозначения другой операции), и успокоился.

    А где же парсер? А его тут нет. Тут сразу считалка, которая выполняет операции в том порядке, в котором они объявлены. Прикольно, если бы токенизатор понимал пробел как разделитель, то считалка понимала бы сразу и префиксную нотацию, и инфиксную, и постфиксную! (И любую кашу из них).

    Когда я дошёл до того места, где обработка скобок происходит способом "когда нашли открывающую скобку, то сканируем строку дальше, считая открывающие и закрывающие скобки, чтобы найти нужную закрывающую скобку, и скармливаем внутрискобочное выражение в вычислитель", я понял, что парсера тут не будет, а логика разбора и вычисления перемешана.

    Я вообще не хотел насмехаться ни над кодом, ни над автором (хотя скорее всего звучит именно так), я хотел мозг свой размять.