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

Разработка расширяемого алгоритма строкового калькулятора

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров2.9K

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

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

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии11

Публикации

Истории

Работа

Scala разработчик
20 вакансий

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

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
24 – 25 октября
One Day Offer для AQA Engineer и Developers
Онлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
26 октября
ProIT Network Fest
Санкт-Петербург
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань