Всем маткульт-привет! В этой статье мы продолжаем и заканчиваем написание консольного инженерного калькулятора. Для понимания происходящего настоятельно рекомендую сначала ознакомиться с первой частью.
Введение
В прошлой части мы научились разбивать исходное математическое выражение формата (log2(18)/3.14)*sqrt(0.11^(-3)/0.02)
на токены. На выходе мы получаем массив токенов, каждый их которых содержит информацию о типе (оператор, скобка, число, ...) и об ассоциативности, если он таковую имеет.
Теперь мы хотим привести выражение к виду обратной польской записи (RPN), чтобы затем удобно его посчитать. Это нам позволяет сделать изобретенный Эдсгером Дейкстрой алгоритм сортировочной станции.
Алгоритм сортировочной станции
Описание
Не мудрствуя лукаво и не изобретая велосипед, скажу, что подробное описание работы алгоритма на русском и на английском есть на Википедии. В англиской версии также наглядно показано, почему алгоритм так называется - его работа напоминает работу железнодорожной сортировочной станции.
Сложность , что очень и очень радует.
Алгоритм
-
Пока не все токены обработаны:
-
Прочитать токен.
Если токен — число, то добавить его в очередь вывода.
Если токен — функция, то поместить его в стек.
-
Если токен — разделитель аргументов функции (запятая):
-
Пока токен на вершине стека не открывающая скобка:
Переложить оператор из стека в очередь вывода.
Если стек закончился до того, как был встречен токен открывающая скобка, то в выражении пропущен разделитель аргументов функции (запятая), либо пропущена открывающая скобка.
-
-
Если токен — оператор op1, то:
-
Пока присутствует на вершине стека токен оператор op2, чей приоритет выше или равен приоритету op1, и при равенстве приоритетов op1 является левоассоциативным:
Переложить op2 из стека в очередь вывода;
Положить op1 в стек.
-
Если токен — открывающая скобка, то положить его в стек.
-
Если токен — закрывающая скобка:
-
Пока токен на вершине стека не открывающая скобка:
Переложить оператор из стека в очередь вывода.
Если стек закончился до того, как был встречен токен открывающая скобка, то в выражении пропущена скобка.
Выкинуть открывающую скобку из стека, но не добавлять в очередь вывода.
-
Если токен на вершине стека — функция, переложить её в очередь вывода.
-
-
Если больше не осталось токенов на входе:
-
Пока есть токены операторы в стеке:
Если токен оператор на вершине стека — открывающая скобка, то в выражении пропущена скобка.
Переложить оператор из стека в очередь вывода.
-
Конец.
Приступим к реализации. Напомню, что токенизатор и класс Token
- в первой части.
Реализация
Фунция shuntingYard
принимает на вход вектор токенов; на выходе выдает тоже вектор токенов, так называемую очередь вывода, но уже в RPN,
void shuntingYard(const std::vector<Token> &expr, std::vector<Token> &outQueue)
{ // ...
Основа работы алгоритма - стек, в который помещаются операторы, функции, скобки и разделители, а затем переносятся в очередь вывода. Эта операция выполняется часто, поэтому для удобства используем лямбду, которая перемещает токен с вершины стека в очередь вывода.
// ...
std::stack<Token> stack;
auto fromStackToQueue = [&]() { outQueue.push_back(stack.top()); stack.pop(); };
// ...
Теперь перебираем все токены. Если токен - числовой литерал, сразу переносим его в очередь вывода; если функция или закрывающая скобка - на стек.
// ...
for(const auto& token : expr)
{
switch(token.getType())
{
case Token::INT_LITERAL:
case Token::FLOAT_LITERAL:
outQueue.push_back(token);
break;
case Token::L_PARANTHESIS:
case Token::FUNCTION:
stack.push(token);
break;
// ...
Если токен - оператор:
// ...
case Token::OPERATOR:
if(!stack.empty())
{
/* Пока на вершине стека присутствует правоассоциативный оператор с приоритетом выше,
или левоассоциативный с равным приоритетом, перекладываем его в очередь вывода */
while(stack.top().getType() == Token::OPERATOR && ((stack.top().getPrecendance() > token.getPrecendance())
|| (stack.top().getPrecendance() == token.getPrecendance() && token.getAsc() == Token::LEFT)))
{
fromStackToQueue();
if(stack.empty())
break;
}
}
// Перекладываем текущий токен на вершину стека
stack.push(token);
break;
// ...
Если токен - закрывающая скобка:
// ...
case Token::R_PARANTHESIS:
// Если стек уже пуст, даже не начинаем цикл
if(stack.empty())
throw Error("Non-balanced on paranthesis expression!", Error::Syntax);
// Пока не встретили открывающую скобку, перекладываем токен с вершины стека в очередь вывода
while (stack.top().getType() != Token::L_PARANTHESIS)
{
fromStackToQueue();
// Если стек кончился, а открывающую скобку не встретили - в исходном выражении не хватает скобок
if (stack.empty())
throw Error("Non-balanced on paranthesis expression!", Error::Syntax);
}
// Удаляем открывающую скобку со стека, не перенося её в очередь вывода
stack.pop();
// Если на вершине оказалась функция, перекладываем её а стек
if(!stack.empty() && stack.top().getType() == Token::FUNCTION)
fromStackToQueue();
break;
// ...
Если токен - разделитель аргументов функции (он же запятая):
// ...
case Token::SEPARATOR:
// Если стек уже пуст, даже не начинаем цикл
if(stack.empty())
throw Error("Paranthesis or separator missed!", Error::Syntax);
// Пока не наткнулись на открывающую скобку, перекладываем токены со стека в очередь вывода
while(stack.top().getType() != Token::L_PARANTHESIS)
{
fromStackToQueue();
// Если так и не встретили закрывающую скобку - в исходном выражении не хватает скобки или разделителя
if(stack.empty())
throw Error("Paranthesis or separator missed!", Error::Syntax);
}
break;
} // конец цикла
// ...
Теперь перекладываем опереторы, оставшиеся на стеке, в очередь вывода:
// ...
while(!stack.empty())
{
if(stack.top().getType() == Token::L_PARANTHESIS)
throw Error("Paranthesis-unbalanced expression!", Error::Syntax);
else
fromStackToQueue();
} // конец функции shuntingYard
Отлично, мы преобразовали выражение.
Например, из(log2(18)/3.14)*sqrt(0.11^(-3)/0.02)
мы получим последовательность23 log2 2 - 3.14 / 0.1 10 3 - ^ * 0.02 / sqrt * +
Обсчет RPN
Теперь то, зачем мы все здесь собрались - посчитать. Как подробно описано здесь, весь смысл RPN в заключается в том, что нам больше не надо заботиться о приоритете операций - все операнды и операторы уже расположены в нужном порядке. Так что нам лишь остается последовательно пройтись по операторам, записывая промежуточные вычисления на стек.
Алгоритм
-
Обработка входного символа
Если на вход подан операнд, он помещается на вершину стека.
Если на вход подан знак операции, то соответствующая операция выполняется над требуемым количеством значений, извлечённых из стека, взятых в порядке добавления. Результат выполненной операции кладётся на вершину стека.
Если входной набор символов обработан не полностью, перейти к шагу 1.
После полной обработки входного набора символов результат вычисления выражения лежит на вершине стека.
Реализация
Фукнция countRPN
будем принимать на вход вектор токенов в RPN (да ладно!) и возвращать результат вычисления.
double countRPN(const std::vector<Token> &expr)
{ // ...
Теперь нам понадобится стек, куда мы будем класть операнды и результаты промежуточных вычислений:
// ...
std::stack<double> stack;
// ...
Для удобства введем лямбды, которые будут брать один или два аргумента со стека:
// ...
auto getOneToken = [&]()
{
if(stack.empty()) throw Error("Not enough arguments in function!", Error::Syntax);
double x = stack.top();
stack.pop();
return x;
};
auto getTwoTokens = [&]()
{ double x = getOneToken(), y = getOneToken(); return std::tuple{y,x}; };
// ...
Теперь, собственно, посчитайка:
// ...
double res;
for (auto &token : expr)
{
const std::string &str = token.getStr();
switch(token.getType())
{
// Если токен - числовой литерал, он же операнд, кладем на стек
case Token::INT_LITERAL:
stack.push(std::stof(str));
break;
case Token::FLOAT_LITERAL:
stack.push(std::stof(str));
break;
// ...
Теперь операторы:
// ...
case Token::OPERATOR:
switch(token.getAsc())
{
// Левоассоциативные операторы
case Token::LEFT:
{
auto [a,b] = getTwoTokens();
if (str == "+") res = a + b;
else if (str == "-") res = a - b;
else if (str == "*") res = a * b;
else if (str == "/") res = checkedDivision(a, b);
else if (str == "^") res = std::pow(a,b);
else throw Error("Unknown operator!", Error::Syntax);
break;
}
// Правоассоциативные операторы
case Token::RIGHT:
{
auto a = getOneToken();
if (str == "-") res = -a;
else throw Error("Unknown operator!", Error::Syntax);
break;
}
}
stack.push(res);
break;
// ...
И функции:
// ...
case Token::FUNCTION:
if(str == "log")
{
auto [a,b] = getTwoTokens();
if(a <= 0.f || a == 1.0f) throw Error(std::format("log(a,x): not defined for a = {}", a), Error::Math);
if(b <= 0.f) throw Error("log(a,x): out of function's domain", Error::Math);
res = std::log(b) / std::log(a);
}
else if
// сколько хотите, столько и добавляйте
else
throw Error("Unknown function!", Error::Syntax);
stack.push(res);
break;
}
} // конец цикла
// ...
Результатом будет единственный оставшийся элемент на стеке:
// ...
return stack.top();
} // конец функции countRPN
Заключение
Весь код проекта лежит на github. Надеюсь, что было интересно.
UPD
Как было замечено в комментарии к первой части, существует куда более простой способ сделать то, что было описано в статье - генератор синтаксических анализаторов GNU Bison, разработанный аж в 1985 году. В официальной документации есть пример создания RPN-калькулятора.
forthuse
К слову, на С/С++ есть и проекты готовых RPN калькуляторов (HP48,HP50G)
x49gp - c эмулированием ARM процессора на Qemu (Samsung's s3c2410)
emu48 - обычный эмулятор HP38/39/40/48/49
free42 - симулятор HP-42S
...
P.S. А, следующим шагом будет написание на С/С++ проекта реализации очередного Форт (Forth)? :)
Или даже, к примеру, чтобы не скучно, проект аппаратного Forth калькулятора как их разные прдставлены в профиле пользователя https://github.com/zooxo?tab=repositories
Этот проект уместился в 8Кб контроллера ATtiny85 (Arduino)
https://github.com/zooxo/ivt
Интересно, что в продаже программируемые калькуляторы в России фактически не представлены, т.е. они не стали элементом части "массовой" IT культуры. (вероятно в силу ряда своих исторических факторов повлиявших на это).
Хотя есть и такая форумная площадка по обсуждению тематики калькуляторов http://www.leningrad.su/calc/index.php
forthuse
Дополню сообщение выше.
Имеются и пара Телеграм каналов:
Телеграм канал - МК61 МК52 MK85 Развиваем легендарные советские программируемые калькуляторы https://t.me/mk61s
Телеграм канал по Форт https://t.me/ruforth
(Конкатенативные языки программирования и тематические беседы)
UmarNur Автор
Спасибо! Не представлял, что тема калькуляторов так популярна и кто-то вообще ее развивает (казалось бы, куда)