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

Перед тем, как мы начнем обсуждать создание строкового калькулятора, давайте определимся, что это такое. Строковый калькулятор (далее просто "калькулятор") - это алгоритм или программа, которая преобразует арифметическое выражение, представленное в виде строки (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, где продемонстрированы примеры как пользоваться калькулятором.