diff --git a/README.md b/README.md index 5d406f1..eef6360 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ ## Возможности -- Операции: `+ - * /` и скобки `()` +- Операции: `+ - * / //` (целочисленное), скобки `()` - Приоритет операций (скобки меняют порядок) - Унарный минус/плюс: `-5`, `+3` -- Проценты: `50 %` → `0.5` -- Возведение в степень: `2 ** 10` +- Проценты: `50 %` → `0.5`; `7 % 3` → `1` (бинарный остаток; `%` без правого операнда = процент) +- Возведение в степень: `2 ** 10` (правостороннее) +- Экспоненциальная запись: `1e3`, `2.5E-4`; разделители разрядов: `1_000_000` +- Константы: `pi`, `e`, `tau`, `inf` (регистр не важен) +- Функции: `sqrt`, `abs`, `exp`, `ln`, `log`, `log2`, `log10`, `round`, `floor`, `ceil`, + `sin`, `cos`, `tan`, `asin`, `acos`, `atan` (тригонометрия — в радианах) - Деление на ноль обрабатывается красиво (без падения с exception) ## Структура diff --git a/calculator.py b/calculator.py index a2ad643..2594f43 100644 --- a/calculator.py +++ b/calculator.py @@ -1,24 +1,33 @@ """Парсер и вычислитель математических выражений. Поддерживает: - - операции: + - * / и скобки; + - операции: + - * / // %(мод) и скобки; + - возведение в степень ** (правостороннее); - унарный минус и плюс (-5, +3); - проценты (50% => 0.5); - - чистые и отрицательные числа. + - экспоненциальную запись (1e3, 2.5E-4) и разделители разрядов (1_000_000); + - константы: pi, e, tau, inf; + - функции: sqrt, abs, exp, ln, log, log2, log10, round, floor, ceil, + sin, cos, tan, asin, acos, atan (аргументы тригонометрии — в радианах). + +Правило для '%': если после % идёт число, константа, функция или '(' — это +бинарный остаток от деления (7 % 3 = 1); иначе — постфиксный процент (2 * 50 % = 1). Реализован как рекурсивный спуск с приоритетами: expr := term (('+' | '-') term)* - term := factor (('*' | '/') factor)* + term := factor (('*' | '/' | '//' | '%') factor | postfix-percent)* factor := ('+' | '-') factor | power power := postfix ('**') factor # правосторонний - postfix := atom ('%')* # постфиксные проценты - atom := NUMBER | '(' expr ')' + postfix := atom ('%')* # постфиксные проценты + atom := NUMBER | ID | ID '(' args ')' | '(' expr ')' """ from __future__ import annotations +import math +import re from dataclasses import dataclass from enum import Enum, auto -from typing import Tuple +from typing import Callable class CalcError(Exception): @@ -39,16 +48,19 @@ class _TokenType(Enum): MINUS = auto() STAR = auto() SLASH = auto() + FLOORDIV = auto() POW = auto() PERCENT = auto() LPAREN = auto() RPAREN = auto() + COMMA = auto() + ID = auto() @dataclass class Token: type: _TokenType - value: float | None = None + value: float | str | None = None _TOKEN_MAP = { @@ -56,14 +68,60 @@ _TOKEN_MAP = { "-": _TokenType.MINUS, "*": _TokenType.STAR, "/": _TokenType.SLASH, - "**": _TokenType.POW, "(": _TokenType.LPAREN, ")": _TokenType.RPAREN, "%": _TokenType.PERCENT, + ",": _TokenType.COMMA, } +_TWO_CHAR_OPS = {"**": _TokenType.POW, "//": _TokenType.FLOORDIV} + +# число: целая/дробная часть, необязательный разделитель '_' внутри, +# опциональная экспонента (только в виде eE/+-/цифры — одиночная 'e' +# трактуется как константа) +_NUM_RE = re.compile( + r"[0-9][0-9_]*(?:\.[0-9_]*)?(?:[eE][+-]?[0-9]+)?" + r"|\.[0-9][0-9_]*(?:[eE][+-]?[0-9]+)?" +) +_ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + _KEYWORDS = {"выход", "exit"} +_CONSTANTS: dict[str, float] = { + "pi": math.pi, + "e": math.e, + "tau": math.tau, + "inf": math.inf, +} + +def _round2(x: float, ndigits: float | None = None) -> float: + # наш калькулятор всё считает во float; Python round() хочет int для ndigits + return float(round(x, int(ndigits)) if ndigits is not None else round(x)) + + +# имя -> (реализация, допустимые арности) +_FUNCTIONS: dict[str, tuple[Callable[..., float], tuple[int, ...]]] = { + "sqrt": (math.sqrt, (1,)), + "abs": (abs, (1,)), + "exp": (math.exp, (1,)), + "ln": (math.log, (1,)), + "log": (math.log, (1, 2)), + "log2": (math.log2, (1,)), + "log10": (math.log10, (1,)), + "round": (_round2, (1, 2)), + "floor": (math.floor, (1,)), + "ceil": (math.ceil, (1,)), + "sin": (math.sin, (1,)), + "cos": (math.cos, (1,)), + "tan": (math.tan, (1,)), + "asin": (math.asin, (1,)), + "acos": (math.acos, (1,)), + "atan": (math.atan, (1,)), +} + +# токены, которые могут начинать правый операнд бинарного '%' +_OPERAND_START = frozenset({_TokenType.NUMBER, _TokenType.LPAREN, _TokenType.ID}) + def _tokenize(text: str) -> list[Token]: tokens: list[Token] = [] @@ -74,27 +132,26 @@ def _tokenize(text: str) -> list[Token]: if ch.isspace(): i += 1 continue - if ch.isdigit() or ch == ".": - j = i - while j < n and (text[j].isdigit() or text[j] == "."): - j += 1 - num_str = text[i:j] - # несколько точек — нечисло - if num_str.count(".") > 1: - raise SyntaxError_(f"Некорректное число: {num_str!r}") + m = _NUM_RE.match(text, i) + if m: + num_str = m.group() try: tokens.append(Token(_TokenType.NUMBER, float(num_str))) except ValueError: - raise SyntaxError_(f"Некорректное число: {num_str!r}") - i = j + raise SyntaxError_(f"Некорректное число: {num_str!r}") from None + i = m.end() + continue + m = _ID_RE.match(text, i) + if m: + tokens.append(Token(_TokenType.ID, m.group().lower())) + i = m.end() + continue + two = text[i:i + 2] + if two in _TWO_CHAR_OPS: + tokens.append(Token(_TWO_CHAR_OPS[two])) + i += 2 continue if ch in _TOKEN_MAP: - # двухсимвольные операторы: ** - two = text[i:i + 2] - if two == "**": - tokens.append(Token(_TokenType.POW)) - i += 2 - continue tokens.append(Token(_TOKEN_MAP[ch])) i += 1 continue @@ -110,6 +167,11 @@ class _Parser: def _peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else None + def _next_starts_operand(self) -> bool: + """Может ли токен после текуго начинать операнд (решает % как mod vs процент).""" + nxt = self.tokens[self.pos + 1] if self.pos + 1 < len(self.tokens) else None + return nxt is not None and nxt.type in _OPERAND_START + def _advance(self): tok = self._peek() self.pos += 1 @@ -138,15 +200,26 @@ class _Parser: value = self._factor() while True: tok = self._peek() - if tok and tok.type in (_TokenType.STAR, _TokenType.SLASH): + if tok and tok.type in (_TokenType.STAR, _TokenType.SLASH, _TokenType.FLOORDIV): self._advance() right = self._factor() if tok.type == _TokenType.SLASH: if right == 0: raise DivisionByZeroError("Деление на ноль") value = value / right + elif tok.type == _TokenType.FLOORDIV: + if right == 0: + raise DivisionByZeroError("Целочисленное деление на ноль") + value = value // right else: value = value * right + elif tok and tok.type == _TokenType.PERCENT and self._next_starts_operand(): + # бинарный остаток: 7 % 3 + self._advance() + right = self._factor() + if right == 0: + raise DivisionByZeroError("Остаток от деления на ноль") + value = value % right else: return value @@ -164,7 +237,8 @@ class _Parser: value = self._atom() while True: tok = self._peek() - if tok and tok.type == _TokenType.PERCENT: + if tok and tok.type == _TokenType.PERCENT and not self._next_starts_operand(): + # постфиксный процент: 50% => 0.5 self._advance() value = value / 100.0 else: @@ -178,8 +252,8 @@ class _Parser: exponent = self._factor() try: value = base ** exponent - except OverflowError as exc: - raise CalcError(f"Переполнение при возведении в степень: {exc}") from exc + except (OverflowError, ValueError) as exc: + raise CalcError(f"Ошибка возведения в степень: {exc}") from exc if isinstance(value, complex): raise CalcError("Комплексный результат не поддерживается") return value @@ -194,6 +268,13 @@ class _Parser: if tok.value is None: raise SyntaxError_("Пустое число") return tok.value + if tok.type == _TokenType.ID: + self._advance() + name = tok.value or "" + nxt = self._peek() + if name in _FUNCTIONS and nxt and nxt.type == _TokenType.LPAREN: + return self._call_function(name) + return self._resolve_id(name) if tok.type == _TokenType.LPAREN: self._advance() value = self._expr() @@ -202,7 +283,40 @@ class _Parser: raise SyntaxError_("Не хватает закрывающей скобки ')'") self._advance() return value - raise SyntaxError_("Ожидалось число или скобку") + raise SyntaxError_("Ожидалось число, константа, функция или скобку") + + def _resolve_id(self, name: str) -> float: + if name in _CONSTANTS: + return _CONSTANTS[name] + if name in _FUNCTIONS: + raise SyntaxError_(f"Функция '{name}' требует скобок: {name}(...)") + raise SyntaxError_(f"Неизвестный идентификатор: {name!r}") + + def _call_function(self, name: str) -> float: + func, arities = _FUNCTIONS[name] + self._advance() # '(' + args: list[float] = [] + if self._peek() and self._peek().type == _TokenType.RPAREN: + self._advance() + raise SyntaxError_(f"'{name}' требует аргументов") + while True: + args.append(self._expr()) + tok = self._peek() + if tok and tok.type == _TokenType.COMMA: + self._advance() + continue + break + close = self._peek() + if not close or close.type != _TokenType.RPAREN: + raise SyntaxError_(f"Не хватает закрывающей скобки ')' в '{name}'") + self._advance() + if len(args) not in arities: + expected = "/".join(str(a) for a in arities) + raise SyntaxError_(f"'{name}': ожидалось аргументов {expected}, получено {len(args)}") + try: + return float(func(*args)) + except (ValueError, OverflowError) as exc: + raise CalcError(f"Ошибка в '{name}': {exc}") from exc def parse_and_eval(expression: str) -> float: diff --git a/cli.py b/cli.py index b548e43..c12a35c 100644 --- a/cli.py +++ b/cli.py @@ -15,7 +15,7 @@ from calculator import is_exit_command, try_evaluate def repl(exprs: list[str] | None) -> int: """Запускает цикл ввода/вычисления.""" - if exprs is not None: + if exprs: # режим «пакетного» запуска (аргументы строки выражений) for expr in exprs: if is_exit_command(expr): diff --git a/test_calculator.py b/test_calculator.py index d7a53bc..0c4b0a6 100644 --- a/test_calculator.py +++ b/test_calculator.py @@ -156,5 +156,120 @@ class TestPhase0Regressions(unittest.TestCase): self.assertIn("Ошибка", try_evaluate("2 ** 100000")) +class TestPhase1Exponents(unittest.TestCase): + def test_e_notation(self): + self.assertAlmostEqual(parse_and_eval("1e3"), 1000) + self.assertAlmostEqual(parse_and_eval("2.5E-4"), 0.00025) + self.assertAlmostEqual(parse_and_eval("1e3 + 1"), 1001) + + def test_euler_constant(self): + self.assertAlmostEqual(parse_and_eval("e"), math.e) + self.assertAlmostEqual(parse_and_eval("2 * e"), 2 * math.e) + + def test_digit_separators(self): + self.assertAlmostEqual(parse_and_eval("1_000_000"), 1_000_000) + self.assertAlmostEqual(parse_and_eval("1_0.5_5"), 10.55) + + +class TestPhase1Constants(unittest.TestCase): + def test_constants(self): + self.assertAlmostEqual(parse_and_eval("pi"), math.pi) + self.assertAlmostEqual(parse_and_eval("tau"), math.tau) + self.assertAlmostEqual(parse_and_eval("inf * 2"), math.inf) + + def test_case_insensitive(self): + self.assertAlmostEqual(parse_and_eval("PI"), math.pi) + self.assertAlmostEqual(parse_and_eval("Sqrt(4)"), 2) + + def test_unknown_identifier(self): + with self.assertRaises(SyntaxError_): + parse_and_eval("foo + 1") + + +class TestPhase1Functions(unittest.TestCase): + def test_basic(self): + self.assertAlmostEqual(parse_and_eval("sqrt(16)"), 4) + self.assertAlmostEqual(parse_and_eval("abs(-3)"), 3) + self.assertAlmostEqual(parse_and_eval("floor(2.7)"), 2) + self.assertAlmostEqual(parse_and_eval("ceil(2.1)"), 3) + self.assertAlmostEqual(parse_and_eval("round(2.567, 2)"), 2.57) + + def test_logs_exp(self): + self.assertAlmostEqual(parse_and_eval("ln(e)"), 1) + self.assertAlmostEqual(parse_and_eval("log2(8)"), 3) + self.assertAlmostEqual(parse_and_eval("log10(1000)"), 3) + self.assertAlmostEqual(parse_and_eval("log(8, 2)"), 3) + self.assertAlmostEqual(parse_and_eval("exp(0)"), 1) + + def test_trig(self): + self.assertAlmostEqual(parse_and_eval("sin(pi / 2)"), 1) + self.assertAlmostEqual(parse_and_eval("cos(0)"), 1) + self.assertAlmostEqual(parse_and_eval("atan(1) * 4"), math.pi) + + def test_composition_precedence(self): + self.assertAlmostEqual(parse_and_eval("sqrt(16) + 1"), 5) + self.assertAlmostEqual(parse_and_eval("2 * sqrt(9)"), 6) + self.assertAlmostEqual(parse_and_eval("sqrt(2) ** 2"), 2) + self.assertAlmostEqual(parse_and_eval("-sqrt(4)"), -2) + + def test_domain_error_is_calcerror(self): + with self.assertRaises(CalcError): + parse_and_eval("sqrt(-1)") + with self.assertRaises(CalcError): + parse_and_eval("log(0)") + + def test_arity_error(self): + with self.assertRaises(SyntaxError_): + parse_and_eval("sqrt(1, 2)") + with self.assertRaises(SyntaxError_): + parse_and_eval("sqrt()") + + def test_missing_parens_message(self): + with self.assertRaises(SyntaxError_) as ctx: + parse_and_eval("sqrt 4") + self.assertIn("sqrt", str(ctx.exception)) + + def test_unbalanced_call(self): + with self.assertRaises(SyntaxError_): + parse_and_eval("sqrt(16") + + +class TestPhase1FloorDivMod(unittest.TestCase): + def test_floordiv(self): + self.assertAlmostEqual(parse_and_eval("7 // 2"), 3) + self.assertAlmostEqual(parse_and_eval("-7 // 2"), -4) + + def test_binary_mod(self): + self.assertAlmostEqual(parse_and_eval("7 % 3"), 1) + self.assertAlmostEqual(parse_and_eval("10 % 2.5"), 0) + + def test_mod_vs_percent_rule(self): + # постфиксный процент без правого операнда + self.assertAlmostEqual(parse_and_eval("50 %"), 0.5) + self.assertAlmostEqual(parse_and_eval("2 * 50 %"), 1.0) + self.assertAlmostEqual(parse_and_eval("1 + 10 %"), 1.1) + + def test_mod_with_paren_rhs(self): + self.assertAlmostEqual(parse_and_eval("7 % (3 + 1)"), 3) + + def test_div_zero_variants(self): + with self.assertRaises(DivisionByZeroError): + parse_and_eval("5 // 0") + with self.assertRaises(DivisionByZeroError): + parse_and_eval("5 % 0") + + +class TestPhase1Integration(unittest.TestCase): + def test_combined_expression(self): + self.assertAlmostEqual(parse_and_eval("sqrt(3 ** 2 + 4 ** 2)"), 5) + self.assertAlmostEqual(parse_and_eval("1e3 * 10 %"), 100) + self.assertAlmostEqual(parse_and_eval("round(pi, 3)"), 3.142) + + def test_try_evaluate_formats(self): + self.assertEqual(try_evaluate("2 ** 1e2"), "1.26765e+30") # огромно, но конечно + self.assertEqual(try_evaluate("sqrt(9)"), "3") + self.assertIn("Ошибка", try_evaluate("unknownvar")) + + if __name__ == "__main__": unittest.main()