Консольный калькулятор на C++17: интерактивный цикл «прочитал строку → вычислил → вывел результат». Поддерживает арифметику с приоритетами и скобками, переменные и константы, тригонометрию с градусами/радианами и пакетное вычисление выражений из текстового файла.
Главная особенность реализации — собственный разбор выражений методом рекурсивного спуска: грамматика описана в виде набора взаимно вызывающих друг друга функций (statement, expression, term, primary), а вычисление происходит прямо во время разбора, без построения синтаксического дерева. Подробно грамматика описана в разделе «Грамматика».
- Возможности
- Сборка и запуск
- Структура репозитория
- Как устроена обработка ввода
- Грамматика
- Тесты
- Известные особенности реализации
- Операции
+,-,*,/, скобки, унарные+и-. - Дробные числа (
.5,3.25, запись с экспонентой). - Переменные:
let имя = выражение. - Константы:
let const имя = выражение, снятие константности черезlet unconst. - Функции
sin,cos,tgи суффиксы единиц углаdeg/rad. - Несколько выражений в одной строке.
- Команда
calculate "путь"— вычисление выражений из файла. - Команды
helpиquit. - Сообщения об ошибках и восстановление после них без аварийного выхода.
Нужен компилятор с поддержкой C++17 (используется std::filesystem) и CMake ≥ 3.10.
cmake -S . -B build
cmake --build build
./build/calculator # в Windows: build\calculator.exeПосле запуска появляется приглашение >. Каждая введённая строка обрабатывается отдельно, выход — командой quit (или концом ввода).
.
├── CMakeLists.txt
├── src/
│ ├── main.cpp # цикл чтения строк (REPL)
│ ├── calculator.cpp # верхний уровень: разбор команд, calculate_file
│ ├── grammar.cpp # рекурсивный спуск: statement / expression / term / primary
│ ├── token.cpp # конструкторы и сравнение Token
│ ├── token_stream.cpp # лексер: get(), ignore(), get_file_path()
│ ├── variable.cpp # таблица переменных и разбор объявления let
│ ├── utility.cpp # error(), clean_up_mess(), write_input_text(), help()
│ └── internal/
│ ├── calculator.h
│ ├── calculator_context.h # общее состояние сессии
│ ├── grammar.h
│ ├── token.h # enum Kind и struct Token
│ ├── token_stream.h # таблицы операторов и ключевых слов, класс Token_stream
│ ├── utility.h
│ └── variable.h # struct Variable, класс Symbol_table
└── tests/
├── integration/ # data/input.txt, data/expected.txt, run_tests.ps1
└── unit/ # заготовки test_grammar / test_token / test_variable
| Модуль | Роль |
|---|---|
main.cpp |
Печатает приглашение, читает строку, передаёт её в calculator(), печатает результат. Завершает работу по quit. |
calculator.cpp |
Верхнеуровневый разбор: пропуск ;, команды quit / help / calculate, вызов statement(), перехват ошибок. |
grammar.cpp |
Синтаксический анализатор и вычислитель в одном лице. |
token_stream.cpp |
Превращает поток символов в токены; хранит буфер на один токен. |
variable.cpp |
Symbol_table (вектор Variable, линейный поиск) и разбор формы let …. |
utility.cpp |
Выброс ошибок, пропуск входа после ошибки, запись «эха» выражения, текст справки. |
struct Calculator_context {
Token_stream token_stream; // источник токенов
Symbol_table symbol_table; // переменные и константы
std::string text_input; // нормализованная запись разобранного выражения
std::stack<std::string> file_stack; // файлы, которые сейчас вычисляются (защита от рекурсии)
};Контекст передаётся по ссылке во все функции грамматики. Один контекст живёт всю интерактивную сессию, поэтому переменные сохраняются между строками.
main.cpp:run_calculator()
└─ std::getline → calculator(строка, context)
├─ строка оборачивается в std::istringstream, привязывается к token_stream
└─ цикл по элементам верхнего уровня:
• ';' → пропускается
• конец строки → выход из цикла
• quit → throw std::string("quit") → завершение программы
• help → добавить текст справки
• calculate → прочитать путь в кавычках, вычислить файл
• иначе → statement(context) и вывод «эхо = значение»
Важные следствия:
- Строка — единица ввода.
istringstreamсоздаётся на каждую строку, поэтому одно выражение не может продолжаться на следующей строке. - Разбор и вычисление совмещены. Функции грамматики возвращают
doubleсразу, дерева нет. - Один токен предпросмотра.
Token_stream::unget()хранит ровно один токен; этого достаточно для всей грамматики.
Грамматика реализована рекурсивным спуском: каждому нетерминалу соответствует функция, которая читает токены и возвращает значение.
| Нетерминал | Функция | Файл |
|---|---|---|
statement |
statement() |
grammar.cpp |
declaration |
declaration() → Symbol_table::define_name() |
grammar.cpp, variable.cpp |
expression |
expression() |
grammar.cpp |
term |
term() |
grammar.cpp |
primary |
primary() |
grammar.cpp |
элемент верхнего уровня (;, команды) |
calculator() |
calculator.cpp |
Грамматика типа LL(1): решение о том, какое правило применять, принимается по одному очередному токену. Единственное «заглядывание» с возвратом — проверка ( после sin / cos / tg.
Лексер — метод Token_stream::get(). Он пропускает пробельные символы и по первому символу выбирает тип токена.
| Символ | Kind |
Используется грамматикой |
|---|---|---|
( |
left_parenthesis |
да |
) |
right_parenthesis |
да |
+ |
plus |
да |
- |
minus |
да |
* |
multiply |
да |
/ |
divide |
да |
= |
assign |
да (только в let) |
; |
semicolon |
да (разделитель, граница восстановления после ошибки) |
% |
remainder |
нет — токен существует, но правила для него нет |
- Токен начинается с символа из набора
.0123456789. - Дальше значение читается стандартным
operator>>(double), поэтому допустимы дробная часть и, по правилам C++, запись с экспонентой. - Знак не входит в число:
-и+всегда отдельные токены, унарность определяется грамматикой. - Если разобрать число не удалось (например, одиночная точка) — ошибка
Invalid number.
identifier = буква { буква | цифра | "_" }
- Начинается только с буквы (не с цифры и не с
_). - Регистр учитывается.
- Если лексема совпала с ключевым словом, токен становится ключевым словом, иначе —
identifier.
| Слово | Kind |
Назначение |
|---|---|---|
let |
let |
начало объявления / изменения переменной |
const |
constant |
модификатор в let |
unconst |
unconst |
модификатор в let |
sin, cos, tg |
sine, cosine, tangent |
функции |
deg, rad |
degrees, radians |
суффиксы единицы угла |
help |
help |
команда |
quit |
quit |
команда |
calculate |
calculate |
команда |
global, local |
global, local |
зарезервированы, не используются |
Ключевые слова нельзя использовать как имена переменных.
- Любой другой символ (
^,,,!,"внеcalculateи т. д.) → ошибкаBad token. - Конец строки → токен
end_of_input.
Ниже грамматика, восстановленная из исходного кода. Обозначения: [ … ] — необязательно, { … } — повторение ноль и более раз, | — альтернатива, "…" — токен.
(* --- верхний уровень: calculator.cpp --- *)
input = { ";" | command | statement } ;
command = "help"
| "quit"
| "calculate" path ;
path = '"' символ { символ } '"' ; (* любые символы, кроме '"'; не пусто *)
(* --- инструкции: grammar.cpp, variable.cpp --- *)
statement = "let" declaration
| expression ;
declaration = [ "const" | "unconst" ] identifier [ "=" expression ] ;
(* --- выражения: grammar.cpp --- *)
expression = term { ( "+" | "-" ) term } ;
term = primary { ( "*" | "/" ) primary } ;
primary = number [ "deg" | "rad" ]
| identifier
| "(" expression ")"
| sign { sign } primary
| function "(" expression ")" ;
sign = "+" | "-" ;
function = "sin" | "cos" | "tg" ;Замечания к записи:
commandраспознаётся только как первый токен элемента верхнего уровня. Внутри выраженияhelp,quitиcalculateнедопустимы.;вinput— необязательный разделитель; об этом подробнее в п. 7.statement→"let" declaration— это единственное место, где допустимоlet. Правая часть объявления — обычноеexpression, вложенныеletневозможны.
Это прямое следствие структуры функций, поэтому достаточно разобрать общий случай.
Приоритет задаётся вложенностью. Операнды + и - — это term, а не primary. Пока term() разбирает цепочку primary { ("*" | "/") primary }, он «забирает» все подряд идущие * и /. Управление возвращается в expression() только тогда, когда следующий токен не * и не /. Поэтому * и / всегда связываются крепче + и -, а primary (скобки, функции, унарные знаки, литералы) — крепче всех.
Левая ассоциативность задаётся циклом. Правило вида
A = B { op B }
реализуется так:
double left = B();
for (;;) {
Token t = get();
if (t — это op) left = left op B();
else { unget(t); return left; }
}На каждом шаге накопленное значение становится левым операндом следующей операции. Для цепочки b1 op b2 op b3 … это даёт порядок ((b1 op b2) op b3) … — то есть вычисление слева направо. Это относится и к -, и к /.
Итоговая таблица (от высшего приоритета к низшему):
| Уровень | Конструкции | Ассоциативность | Функция |
|---|---|---|---|
| 1 | литералы, идентификаторы, ( … ), sin/cos/tg( … ), унарные + - |
— | primary() |
| 2 | *, / |
левая | term() |
| 3 | +, - |
левая | expression() |
Операций возведения в степень и остатка от деления в грамматике нет.
Ветка sign { sign } primary читает подряд идущие + и -, меняет знак при каждом - и затем применяет итоговый знак к следующему primary. Из этого следует:
- любое количество знаков подряд допустимо (
- - x,+ - x); - унарный знак стоит на уровне
primary, то есть связывается крепче*и/: запись-a * bчитается как(-a) * b; - знак допустим справа от бинарного оператора, потому что правый операнд — тоже
primary:a / -b,a + -b; - знак перед скобками и перед функцией работает так же:
-(…),-sin(…).
function "(" expression ")"
После имени функции обязательно должна идти (, иначе ошибка '(' expected after sin (соответственно cos, tg). Аргумент — полное выражение в скобках. Результат функции — обычный primary, поэтому к нему применимы *, /, унарные знаки и т. д.
- Аргумент трактуется как угол в радианах.
tgдополнительно проверяет, что|cos(аргумент)| ≥ 1e-12; иначе ошибкаtg is undefined for this value.
number [ "deg" | "rad" ]
<число> degпереводит градусы в радианы: значение умножается на π и делится на 180.<число> radоставляет значение без изменений (радианы — единица по умолчанию).- Суффикс допустим только сразу после числового литерала. После идентификатора, скобки или функции он не разбирается:
x degи(…) degне являются корректными записями (токенdegостанется необработанным и вызовет ошибкуprimary expectedна следующем шаге).
Подставляется текущее значение переменной. Если переменной нет — ошибка get: undefined name <имя>. Предопределённых констант (pi, e и т. п.) нет.
declaration = [ "const" | "unconst" ] identifier [ "=" expression ]
Разбор выполняет Symbol_table::define_name(). Семантика всех форм:
| Форма | Действие |
|---|---|
let x = e |
Создаёт переменную или присваивает новое значение. Если x — константа, ошибка x is a constant. |
let x |
Выводит текущее значение. Если x не объявлена, ошибка find: undefined name x. |
let const x = e |
Создаёт или присваивает, затем делает x константой. Если x уже константа, присваивание запрещено. |
let const x |
Делает существующую переменную константой. |
let unconst x = e |
Снимает константность, присваивает, переменная остаётся неконстантной. Это способ изменить константу. |
let unconst x |
Снимает константность с существующей переменной. |
Дополнительно:
- Присваивание возможно только через
let. Записьx = eбезletразбирается как выражениеxи завершается ошибкой, так как=не является оператором выражения. - Значение инструкции
let …— присвоенное (или текущее) значение переменной, оно выводится как результат. - Таблица переменных —
std::vector<Variable>с линейным поиском; в режимеcalculateу файла собственная таблица (см. ниже).
Верхний уровень (input) устроен как цикл «взять элемент → выполнить», а ; — всего лишь элемент, который пропускается. Конец expression определяется не ;, а тем, что следующий токен не +, -, * и /. Из этого следуют два важных свойства:
;необязательна. Разделитель нужен только для читаемости и как граница восстановления после ошибки.- Пропущенный оператор не является синтаксической ошибкой. Если после завершённого выражения идёт токен, который не может продолжить выражение, он начинает новую инструкцию. Поэтому
a b— это две инструкцииaиb,2a— число и затем идентификатор, аlet x = e1 let y = e2— два объявления подряд.
Побочный эффект: лишняя закрывающая скобка или % в конце выражения не обрывают разбор в момент встречи, а начинают новую инструкцию, которая завершается ошибкой primary expected.
| Команда | Поведение |
|---|---|
help |
Добавляет в вывод текст справки. |
quit |
Завершает программу. Работает и внутри вычисляемого файла — завершается вся программа. |
calculate "путь" |
Вычисляет файл построчно. |
Формат пути — в двойных кавычках, пробелы внутри пути допустимы. Завершающая ; не требуется (если она есть, будет пропущена как обычный разделитель). Ошибки пути: file path expected (нет открывающей кавычки), Closing quotation mark expected, File path cannot be empty, Cannot open file: ….
Особенности calculate:
- Выводится строка
Called file: <полный путь>, далее результаты построчно. - Файл вычисляется в новом контексте: переменные вызывающей сессии ему недоступны, а переменные, объявленные в файле, после его завершения не сохраняются.
- Ошибки внутри файла не прерывают вычисление: они печатаются и обработка продолжается со следующей инструкции.
- Рекурсивный вызов (файл прямо или косвенно вызывает сам себя) обнаруживается через
file_stackи приводит к ошибкеRecursive file calculation: <путь>. - Как и в консоли, каждая строка файла разбирается независимо.
Для каждой успешно разобранной инструкции печатается:
<нормализованная запись> = <значение>
Нормализованная запись («эхо») собирается самой грамматикой через write_input_text() — каждый разобранный токен дописывается в context.text_input и отделяется пробелом:
| Токен | Что попадает в эхо |
|---|---|
| число | число, до 15 значащих цифр |
| идентификатор | имя |
+ - * / ( ) = |
сам символ |
sin, cos, tg |
sin, cos, tg |
deg, rad |
deg, rad |
let, const, unconst |
не выводятся |
Значение печатается стандартным operator<< для double, то есть с точностью по умолчанию — 6 значащих цифр.
Поскольку форма let x = e тоже печатает эхо, её вывод имеет вид x = <эхо e> = <значение>.
Все ошибки — исключения std::runtime_error, выбрасываемые через error(). Верхний уровень перехватывает их, добавляет в вывод Error: <сообщение> и вызывает clean_up_mess(), которая пропускает остаток строки до ближайшей ; (либо до конца строки, если ; нет). Незавершённое эхо при этом отбрасывается.
| Сообщение | Причина |
|---|---|
Bad token |
недопустимый символ |
Invalid number |
число не удалось разобрать |
primary expected |
на месте операнда стоит токен, не начинающий primary |
')' expected |
не закрыта скобка |
'(' expected after sin / cos / tg |
у функции нет скобки |
tg is undefined for this value |
|cos| < 1e-12 |
divide by zero |
делитель в точности равен нулю |
name expected in declaration |
после let (и const / unconst) нет идентификатора |
get: undefined name X |
использование необъявленной переменной |
find: undefined name X |
let X или let const X без = для необъявленной X |
X is a constant |
попытка присвоить константе |
file path expected, Closing quotation mark expected, File path cannot be empty, Cannot open file: …, Recursive file calculation: … |
ошибки команды calculate |
- Оператор
%распознаётся лексером (Kind::remainder), но вterm()для него нет ветки, поэтому его появление приводит к ошибкеprimary expected. - Ключевые слова
globalиlocalраспознаются (и поэтому недоступны как имена), но в грамматике не используются.
Интеграционные (tests/integration/):
data/input.txtподаётся на стандартный вход программы: арифметика, приоритеты, скобки, унарные знаки, дробные числа, переменные, константы, несколько инструкций в строке и т. д.data/expected.txtсодержит ожидаемые значения (по одному на строку).run_tests.ps1(PowerShell, Windows) запускаетbuild\calculator.exe, из каждой строки вывода берёт значение после последнего=, сравнивает с ожидаемым и записываетresults/actual.txtи, при расхождениях,results/diff.txt.
Модульные (tests/unit/): файлы test_grammar.cpp, test_token.cpp, test_variable.cpp и CMakeLists.txt пока пусты; корневой CMakeLists.txt подкаталог tests не подключает.