Фаза 1 — расширения языка (#2)
This commit is contained in:
1 parent
730ba2da00
commit
dbd707afe4
4 files changed
+266
-33
No files matched your search
@@ -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)
|
||||
|
||||
## Структура
|
||||
|
||||
+143
-29
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user