Инженерный калькулятор на C++. Часть 1: Токенизатор математических выражений
Всем привет! Сегодня хочу поделиться опытом написания консольного инженерного калькулятора, который может посчитать что-то вроде (log2(18)/3.14)*sqrt(0.1*10^(-3)/0.02)
Почему именно калькулятор (ну камон, их же и так тьма тьмущая)? Все потому, что в школе дали задание написать калькулятор на Qt; мне это показалось скучным, и я решил поэкспериментировать.
Введение
Статья разделена на 2 части:
Токенизатор математических выражений
Обратная польская запись (Алгоритм сортировочной станции)
В этой части мы рассмотрим создание простейшего парсера (токенизатора) на базе конечного автомата, который будет разделять исходное выражение на части: числовые литералы, операторы, функции и т.п.
Зачем это нужно? Обработка математического выражения в том виде, в котором оно есть, весьма неудобно — ведь операторы имеют приоритет, скобки этот приоритет меняют, а есть еще и фукнции, а унарное отрицание — так вообще ужас… Получается неудобно. Чтобы эту проблему решить, были придуманы Обратная польская запись (RPN) и Алгоритм сортировочной станции, позволяющий обычную запись выражения (ее также называют инфиксной) преобразовать в RPN (она является постфиксной). О них речь пойдет в следующей части.
Пока важно лишь то, что для преобразования инфиксного выражения в RPN его сначала необходимо токенизировать.
Конечный автомат
Если вы заглянете на эту статью в Википедии, то увидите, что парсеры для языков с формальной грамматикой (нам такое подходит) построены на конечных автоматах. О том, что такое конечный автомат, вы можете почитать здесь.
В первую очередь, определим набор состояний. По задумке, токенизатор умеет распознавать: числовые литералы (целые и floating-point) , операторы (лево- и правоаcсоциативные), функции от фиксированного числа аргументов, а также символ-разделитель аргументов функции (запятая).
S0 | Стартовое |
S1 | Токенизация скобки/оператора |
S2 | Запись целого числа в буфер |
S3 | Запись floating-point числа в буфер |
S4 | Запись функции в буфер |
S5 | Токенизация записанного числа/функции из буфера: |
Теперь переходы между состояниями (в виде графа и таблицы).
Легенда: Оп = Оператор (+ — ^ * /) , Бук = Буква, Циф = Цифра. Отдельные символы перечислены через пробел.
Рис 1. Переходы между состояниями
Рис 2. Переходы между состояниями (таблица)
Краткое пояснение работы автомата:
На вход последовательно поступают символы строки-выражения
Если текущий обрабатываемый символ:
Оператор или скобка: просто токенизируем (S1)
Цифра или буква: записываем в буфер, т.к. все число или называние функции может состоять из нескольких символов (S2, S3, S4).
Запись в буфер прерывается, как только на вход поступает оператор, скобка или разделитель (S5). Тогда мы сохраняем содержимое буфера в один токен, а оператор/скобку/разделитель — в другой.
Если обрабатываемый символ не соответствует текущему состоянию (например, в исходном выражении встречается
).
), значит выражение содержит синтаксическую ошибку. Таким образом мы можем поймать часть синтаксических ошибок еще на этапе токенизации; остальные будем ловить в Алгоритме сортировочной станции, но уже в следующей части.
Но это все слова. Давайте воплотим это в код.
Класс токена
Мы хотим, чтобы класс токена хранил информацию о типе токена и его ассоциативности, а также сам токен в виде строки:
class Token
{
public:
// Тип
enum Type
{
OPERATOR, // унарный/бинарный оператор
L_PARANTHESIS, // открывающая скобка
R_PARANTHESIS, // закрывающая скобка
INT_LITERAL, // целое число
FLOAT_LITERAL, // число с плавающей точкой
FUNCTION, // функция
SEPARATOR // разделитель аргументов функции
};
// Ассоциативность
enum OperatorAssociativity
{
NONE, // токен - не оператор и не открыващая скобка
RIGHT, // правоассоциативный
LEFT // левоассоциативный
};
// Конструктор
Token(std::string token, Type type, OperatorAssociativity asc = NONE);
// Приоритет
int getPrecendance() const;
// Геттеры ( ну у нас же ООП :D )
const Type& getType() const { return type; }
const OperatorAssociativity& getAsc() const { return opAsc; }
const std::string& getStr() const { return str; }
private:
Type type;
OperatorAssociativity opAsc;
std::string str;
};
Конструктор:
Token::Token(std::string token, Type type, OperatorAssociativity asc)
: type{type}
, str{token}
{
// если токен - оператор, но ассоциативность не задана - ошибка
if(type == OPERATOR && asc == NONE)
throw SyntaxError("Associativity required!");
// если токен - НЕ оператор, но ассоциативность задана - ошибка
else if(type != OPERATOR && asc != NONE)
throw SyntaxError("Non-operator token can't have an associativity!");
opAsc = asc;
}
Функция getPrecedance()
вовращает приоритет оператора в виде целого числа. Приоритет тем меньше, чем меньше число. Если токен — не оператор, генерирует исключение.
int Token::getPrecendance() const
{
static std::map op_leftassociative =
{
{"+", 2},
{"-", 2},
{"/", 3},
{"*", 3},
{"^", 5}
};
static std::map op_rightassociative =
{
{"-", 4} // унарное отрицание
};
switch(opAsc)
{
case LEFT:
if(op_leftassociative.contains(str)) return op_leftassociative[str];
else throw SyntaxError("Unknown Operator!");
break;
case RIGHT:
if(op_rightassociative.contains(str)) return op_rightassociative[str];
else throw SyntaxError("Unknown Operator!");
break;
case NONE:
throw SyntaxError("Token is not an operator, impossible!");
break;
}
}
Токенизатор-автомат
Во первых, состояния:
enum State
{
S0, // Стартовое
S1, // Токенизация скобки/оператора
S2, // Запись целого числа в буфер
S3, // Запись floating-point числа в буфер
S4, // Запись функции в буфер
S5 // Токенизация записанного числа/функции из буфера
};
Теперь конечный автомат. Реализуем его функцией, принимающей на вход сроку-выражение, а на выходе дающую вектор токенов.
void tokenize(const std::string &expr, std::vector &tokens)
{
// ...
Объявим необходимые переменные:
// ...
State state = S0; // Устанавливаем состояние в начальное
std::string validOperators = "+-*^/"; // Допустимые операторы
// каким типом является текущий символ?
bool isDigit, isLetter, isOp, isParanth, isPoint, isSep, isLParanth, isRParanth;
std::string buffer; // Буфер
Token::Type bufferTokenType = Token::INT_LITERAL; // Тип токена, записаного в буфере
// ...
Цикл:
// ...
for(auto& s : expr)
{
// Определяем тип символа
isDigit = std::isdigit(s);
isLetter = std::isalpha(s);
isLParanth = s == '(';
isRParanth = s == ')';
isParanth = isLParanth || isRParanth;
isPoint = s == '.';
isSep = s == ',';
isOp = validOperators.find(s) != validOperators.npos;
// Если тип символа неопределен, значит ошибка в синтаксисе
if(!(isDigit || isLetter || isParanth || isPoint || isSep || isOp))
throw SyntaxError(std::format("Unknown symbol: {}", s));
// Смена состояния
switch(state)
{
case S0:
if (isOp || isParanth)
state = S1;
else if (isDigit)
state = S2;
else if (isLetter)
state = S4;
else if (isPoint || isSep)
throw SyntaxError(std::format("Unexpected symbol: {}", s));
break;
case S1:
if (isDigit)
state = S2;
else if (isLetter)
state = S4;
else if (isPoint || isSep)
throw SyntaxError(std::format("Unexpected symbol: {}", s));
break;
case S2:
bufferTokenType = Token::INT_LITERAL;
if (isPoint)
state = S3;
else if (isParanth || isOp || isSep)
state = S5;
else if (isLetter)
throw SyntaxError(std::format("Unexpected symbol: {}", s));
break;
// и так далее для состояний S3, S4, S5. Полный код можно посмотреть в конце статьи
default:
break;
}
// ...
Далее — соответствующее действия на каждое из состояний:
// ...
// Токенизирует оператор/скобку/разделитель
auto tokenize_Op_Paranth_Sep = [&]() { /* ... */ };
// Действия
switch(state)
{
case S1:
tokenize_Op_Paranth_Sep();
break;
case S2: case S3: case S4: // Записываем цифру/букву в буфер
buffer.push_back(s);
break;
case S5:
// Токенизируем содержимое буфера
tokens.push_back({buffer, bufferTokenType});
// Освобождаем буфер
buffer.clear();
// Токенизируем текущий символ
tokenize_Op_Paranth_Sep();
break;
}
} // конец цикла
// ...
Если после обработки всех символов в буфере все еще что-то осталось, токенизируем:
// ...
if(!buffer.empty())
tokens.push_back({buffer, bufferTokenType});
} // конец функции tokenize
С токенизатором на этом, собственно, все. Код целиком приведен ниже.
Код
#pragma once
#include
class SyntaxError
{
public:
SyntaxError(std::string msg)
{
_msg = "Syntax Error: " + msg;
}
std::string what() { return _msg; }
private:
std::string _msg;
};
#pragma once
#include
class Token
{
public:
// Тип
enum Type
{
OPERATOR, // унарный/бинарный оператор
L_PARANTHESIS, // открывающая скобка
R_PARANTHESIS, // закрывающая скобка
INT_LITERAL, // целое число
FLOAT_LITERAL, // число с плавающей точкой
FUNCTION, // функция
SEPARATOR // разделитель аргументов функции
};
// Ассоциативность
enum OperatorAssociativity
{
NONE, // токен - не оператор
RIGHT, // правоассоциативный
LEFT // левоассоциативный
};
Token(std::string token, Type type, OperatorAssociativity asc = NONE);
// Приоритет
int getPrecendance() const;
const Type& getType() const { return type; }
const OperatorAssociativity& getAsc() const { return opAsc; }
const std::string& getStr() const { return str; }
private:
Type type;
OperatorAssociativity opAsc;
std::string str;
};
#pragma once
#include
#include "Token.hpp"
enum State
{
S0, // Стартовое
S1, // Токенизация скобки/оператора
S2, // Запись целого числа в буфер
S3, // Запись floating-point числа в буфер
S4, // Запись функции в буфер
S5 // Токенизация записанного числа/функции из буфера
};
void tokenize(const std::string &expr, std::vector &tokens);
#include "Tokenizer.hpp"
#include
#include
#include
#include "Tokenizer.hpp"
#include
int main()
{
std::vector tokensInfix, tokensRPN;
std::string expr = "(log2(18)/3.14)*sqrt(0.1*10^(-3)/0.02)";
std::cout << "Expression: " << expr << std::endl;
try
{
tokenize(expr, tokensInfix);
for(auto& i : tokensInfix)
std::cout << i.getStr() << ", type = " << i.getType() <<
", associative = " << i.getAsc() << "\n";
}
catch(SyntaxError &e)
{
std::cerr << e.what() << "\n";
exit(-1);
}
catch(const std::exception& e)
{
std::cerr << e.what() << '\n';
exit(-1);
}
return 0;
}
Заключение
Надеюсь, было интересно. В следующей статье, как было обещанно, будет моя реализация Алгоритма сортировочной станции, и калькулятор (мы же все здесь за этим собрались?) будет доделан до конца.