Всем маткульт-привет! В этой статье мы продолжаем и заканчиваем написание консольного инженерного калькулятора. Для понимания происходящего настоятельно рекомендую сначала ознакомиться с первой частью.

Введение

В прошлой части мы научились разбивать исходное математическое выражение формата (log2(18)/3.14)*sqrt(0.11^(-3)/0.02)на токены. На выходе мы получаем массив токенов, каждый их которых содержит информацию о типе (оператор, скобка, число, ...) и об ассоциативности, если он таковую имеет.

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

Алгоритм сортировочной станции

Описание

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

Сложность O(n) , что очень и очень радует.

Алгоритм

  • Пока не все токены обработаны:

    • Прочитать токен.

      • Если токен — число, то добавить его в очередь вывода.

      • Если токен — функция, то поместить его в стек.

      • Если токен — разделитель аргументов функции (запятая):

        • Пока токен на вершине стека не открывающая скобка:

          • Переложить оператор из стека в очередь вывода.

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

      • Если токен — оператор 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. Обработка входного символа

    • Если на вход подан операнд, он помещается на вершину стека.

    • Если на вход подан знак операции, то соответствующая операция выполняется над требуемым количеством значений, извлечённых из стека, взятых в порядке добавления. Результат выполненной операции кладётся на вершину стека.

  2. Если входной набор символов обработан не полностью, перейти к шагу 1.

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

Реализация

Фукнция 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-калькулятора.

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


  1. forthuse
    30.11.2023 03:06
    +3

    К слову, на С/С++ есть и проекты готовых 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


    1. forthuse
      30.11.2023 03:06
      +2

      Дополню сообщение выше.

      Имеются и пара Телеграм каналов:

      Телеграм канал - МК61 МК52 MK85 Развиваем легендарные советские программируемые калькуляторы https://t.me/mk61s

      Телеграм канал по Форт https://t.me/ruforth
      (Конкатенативные языки программирования и тематические беседы)


      1. UmarNur Автор
        30.11.2023 03:06

        Спасибо! Не представлял, что тема калькуляторов так популярна и кто-то вообще ее развивает (казалось бы, куда)