Фаза 1 — расширения языка (#2)

This commit is contained in:
admin committed 2026-11-05 08:46:44 +10:00
1 parent 730ba2da00
commit dbd707afe4
4 files changed
+266 -33

No files matched your search

+7 -3
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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):
+115
View File
@@ -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()