Цикл статей о создании собственного парсера выражений: от токенизации до полноценного синтаксического разбора.
Оглавление цикла
- Часть 1. Токенизация
- Часть 2. Вычисление выражения в обратной польской записи
- Часть 3. Алгоритм сортировочной станции (вы находитесь здесь)
- Часть 4. Парсер рекурсивного спуска
- Часть 5. Парсер Пратта
- Часть 6. Перевод из постфиксной формы в инфиксную
В предыдущей части мы реализовали калькулятор выражений, записанных в обратной польской записи – структуру, идеально подходящую для быстрого и однозначного вычисления выражений. Однако остаётся вопрос: как получить RPN из привычной инфиксной записи (с операциями, скобками, переменными и функциями)?
Один из классических ответов на этот вопрос – алгоритм сортировочной станции (Shunting Yard Algorithm), предложенный Дейкстрой. Он позволяет преобразовать выражение вида:
3 + 4 * 8 / (5 - 3)^2^3
в обратную польскую запись:
3 4 8 * 5 3 - 2 3 ^ ^ / +
Именно этот алгоритм мы реализуем в данной части. При этом мы:
- будем парсить выражения, содержащие числа, константы, функции и переменные;
- научимся отличать вычитание от унарного минуса;
- будем проверять синтаксическую корректность выражения и выдавать соответствующие сообщения об ошибках.
Алгоритм сортировочной станции
В целом отличное описание алгоритма есть на википедии, но в нём нет двух важных деталей: обработки унарного минуса и проверки ошибок. В этой статье мы исправим это ужасное недоразумение, но давайте для начала разберёмся с базовым алгоритмом:
- Создаём пустой стек
stack
и пустой выходной массивrpn
; - Идём по списку токенов от первого к последнему;
- если токен является числом, константой или переменной, записываем его в
rpn
; - если токен является функцией, кладём его в стек;
- если токен является разделителем аргументов (
,
), то перекладываем токены из стека вrpn
, пока на вершине стека не окажется(
(если стек закончился раньше, в выражении пропущена открывающая скобка); - если токен является операцией
o1
, то:- перекладываем из стека в выходной массив операции
o2
, пока их приоритет выше приоритетаo1
(или приоритеты равны, еслиo1
является левоассоциативной); - после чего кладём операцию
o1
в стек;
- перекладываем из стека в выходной массив операции
- если токен является открывающей скобкой
(
, то кладём его в стек; - если токен является закрывающей скобкой
)
, то:- перекладываем из стека в выходной массив токены, пока на вершине стека не окажется открывающая скобка
(
(если стек закончился раньше, в выражении пропущена открывающая скобка); - выбрасываем открывающую скобку из стека;
- если на вершине стека оказалась функция, то перекладываем её из стека в выходной массив;
- перекладываем из стека в выходной массив токены, пока на вершине стека не окажется открывающая скобка
- перекладываем оставшиеся в стеке токены в выходной массив (если встретится
(
, то в выражении пропущена закрывающая скобка).
Знаю, знаю, знаю. Алгоритм звучит не так уж и просто, поэтому давайте рассмотрим на конкретном примере:
3 + 4 * 8 / (5 - 3)^2
шаг | токен | действие | стек | rpn |
---|---|---|---|---|
1 | 3 |
кладём 3 в rpn |
[] |
[3] |
2 | + |
кладём + в стек |
[+] |
[3] |
3 | 4 |
кладём 4 в rpn |
[+] |
[3, 4] |
4 | * |
кладём * в стек |
[+, *] |
[3, 4] |
5 | 8 |
кладём 8 в rpn |
[+, *] |
[3, 4, 8] |
6 | / |
перекладываем * из стека в rpn |
[+] |
[3, 4, 8, *] |
кладём / в стек |
[+, /] |
[3, 4, 8, *] |
||
7 | ( |
кладём ( в стек |
[+, /, (] |
[3, 4, 8, *] |
8 | 5 |
кладём 5 в rpn |
[+, /, (] |
[3, 4, 8, *, 5] |
9 | - |
кладём - в стек |
[+, /, (, -] |
[3, 4, 8, *, 5] |
10 | 3 |
кладём 3 в rpn |
[+, /, (, -] |
[3, 4, 8, *, 5, 3] |
11 | ) |
перекладываем - из стека в rpn |
[+, /, (] |
[3, 4, 8, *, 5, 3, -] |
выкидываем ( из стека |
[+, /] |
[3, 4, 8, *, 5, 3, -] |
||
12 | ^ |
кладём ^ в стек |
[+, /, ^] |
[3, 4, 8, *, 5, 3, -] |
13 | 2 |
кладём 2 в rpn |
[+, /, ^] |
[3, 4, 8, *, 5, 3, -, 2] |
14 | перекладываем ^ из стека в rpn |
[+, /] |
[3, 4, 8, *, 5, 3, -, 2, ^] |
|
перекладываем / из стека в rpn |
[+] |
[3, 4, 8, *, 5, 3, -, 2, ^, /] |
||
перекладываем + из стека в rpn |
[] |
[3, 4, 8, *, 5, 3, -, 2, ^, /, +] |
Слишком простой пример? Давайте рассмотрим кое-что по-настоящему забористое:
2 * 9 / 2.5 + cos(pi) * max(3^2 * (7 - 1), x)
шаг | токен | действие | стек | rpn |
---|---|---|---|---|
1 | 2 |
кладём 2 в rpn |
[] |
[2] |
2 | * |
кладём * в стек |
[*] |
[2] |
3 | 9 |
кладём 9 в rpn |
[*] |
[2, 9] |
4 | / |
перекладываем * из стека в rpn |
[] |
[2, 9, *] |
кладём / в стек |
[/] |
[2, 9, *] |
||
5 | 2.5 |
кладём 2.5 в rpn |
[/] |
[2, 9, *, 2.5] |
6 | + |
перекладываем / из стека в rpn |
[] |
[2, 9, *, 2.5, /] |
кладём + в стек |
[+] |
[2, 9, *, 2.5, /] |
||
7 | cos |
кладём cos в стек |
[+, cos] |
[2, 9, *, 2.5, /] |
8 | ( |
кладём ( в стек |
[+, cos, (] |
[2, 9, *, 2.5, /] |
9 | pi |
кладём pi в rpn |
[+, cos, (] |
[2, 9, *, 2.5, /, pi] |
10 | ) |
выкидываем ( из стека |
[+, cos] |
[2, 9, *, 2.5, /, pi] |
перекладываем cos из стека в rpn |
[+] |
[2, 9, *, 2.5, /, pi, cos] |
||
11 | * |
кладём * в стек |
[+, *] |
[2, 9, *, 2.5, /, pi, cos] |
12 | max |
кладём max в стек |
[+, *, max] |
[2, 9, *, 2.5, /, pi, cos] |
13 | ( |
кладём ( в стек |
[+, *, max, (] |
[2, 9, *, 2.5, /, pi, cos] |
14 | 3 |
кладём 3 в rpn |
[+, *, max, (] |
[2, 9, *, 2.5, /, pi, cos, 3] |
15 | ^ |
кладём ^ в стек |
[+, *, max, (, ^] |
[2, 9, *, 2.5, /, pi, cos, 3] |
16 | 2 |
кладём 2 в rpn |
[+, *, max, (, ^] |
[2, 9, *, 2.5, /, pi, cos, 3, 2] |
17 | * |
перекладываем ^ из стека в rpn |
[+, *, max, (] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^] |
кладём * в стек |
[+, *, max, (, *] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^] |
||
18 | ( |
кладём ( в стек |
[+, *, max, (, *, (] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^] |
19 | 7 |
кладём 7 в rpn |
[+, *, max, (, *, (] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7] |
20 | - |
кладём - в стек |
[+, *, max, (, *, (, -] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7] |
21 | 1 |
кладём 1 в rpn |
[+, *, max, (, *, (, -] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1] |
22 | ) |
перекладываем - из стека в rpn |
[+, *, max, (, *, (] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -] |
выкидываем ( из стека |
[+, *, max, (, *] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -] |
||
23 | , |
перекладываем * из стека в rpn |
[+, *, max, (] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -, *] |
24 | x |
кладём x в rpn |
[+, *, max, (] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -, *, x] |
25 | ) |
выкидываем ( из стека |
[+, *, max] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -, *, x] |
перекладываем max из стека в rpn |
[+, *] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -, *, x, max] |
||
26 | перекладываем * из стека в rpn |
[+] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -, *, x, max, *] |
|
перекладываем + из стека в rpn |
[] |
[2, 9, *, 2.5, /, pi, cos, 3, 2, ^, 7, 1, -, *, x, max, *, +] |
Как видите, алгоритм не настолько и ужасен. Давайте теперь реализуем его!
Алгоритм сортировочной станции: базовая реализация
Создадим файл shunting_yard_parser.js
, содержащий класс ShuntingYardParser
. Определим в конструкторе приоритеты и ассоциативность обрабатываемых операций:
class ShuntingYardParser { constructor() { this.operators = { "+": {precedence: 1, associative: "left"}, "-": {precedence: 1, associative: "left"}, "*": {precedence: 2, associative: "left"}, "/": {precedence: 2, associative: "left"}, "^": {precedence: 3, associative: "right"}, "~": {precedence: 3, associative: "right"} } } }
Сам алгоритм мы реализуем внутри метода parse(tokens)
:
parse(tokens) { const stack = [] const rpn = [] for (const token of tokens) { if (token.type === "number" || token.type === "constant" || token.type === "variable") { rpn.push(token) } else if (token.type === "function") { stack.push(token) } else if (token.type === "delimeter") { while (stack.length && stack[stack.length - 1].type !== "left_parenthesis") rpn.push(stack.pop()) if (stack.length === 0) throw new Error(`"${token.value}" outside a function or without "(" (${token.start}:${token.end})`) } else if (token.type === "operator") { while (stack.length && this.isMorePrecedence(stack[stack.length - 1], this.operators[token.value])) rpn.push(stack.pop()) stack.push(token) } else if (token.type === "left_parenthesis") { stack.push(token) } else if (token.type === "right_parenthesis") { while (stack.length && stack[stack.length - 1].type !== "left_parenthesis") rpn.push(stack.pop()) if (stack.length === 0) throw new Error(`"(" missing before "${token.value}" (${token.start}:${token.end})`) stack.pop() if (stack.length && stack[stack.length - 1].type === "function") rpn.push(stack.pop()) } prev = token } while (stack.length) { const token = stack.pop() if (token.type === "left_parenthesis") throw new Error(`the brackets are disbalanced (${token.start}:${token.end})`) rpn.push(token) } return rpn } isMorePrecedence(top, operator) { if (top.type != "operator") return false if (operator.associative === "right") return this.operators[top.value].precedence > operator.precedence return this.operators[top.value].precedence >= operator.precedence }
Не так уж и сложно, правда? Давайте проверим, что наш парсер работает верно:
const tokenizer = new ExpressionTokenizer({ functions: ["sin", "cos", "tan", "max"], constants: ["pi", "e"] }) const parser = new ShuntingYardParser() parser.parse(tokenizer.tokenize("3 + 4")) // 3 4 + parser.parse(tokenizer.tokenize("1 + 2 * 3")) // 1 2 3 * + parser.parse(tokenizer.tokenize("3 + 4 * 8 / (5 - 3)^2")) // 3 4 8 * 5 3 - 2 ^ / + parser.parse(tokenizer.tokenize("max(sin(x), cos(y))")) // x sin y cos max
Добавляем поддержку унарного минуса
Пришло время разобраться, как отличить, когда -
является вычитанием, а когда должен быть унарным минусом. Рассмотрим возможные варианты появления унарного минуса в выражении:
-5
,-(1+4)
– с него может начинаться выражение;2 + -1
,x^-3
– может идти после бинарной операции;max(-4, x)
,2 + (-4 + 8)
– может идти после открывающей скобки;max(4, -6)
– может идти после разделителя аргументов.
Во всех остальных (корректных) случаях -
является вычитанием. Получается, нам достаточно проверить, какой токен был перед -
, и при выполнении описанных выше условий заменить -
на ~
.
Заведём перед циклом переменную prev
, изначально равную null
, что будет означать, что обрабатывается первый токен выражения. Добавим метод проверки токена на унарность isUnary
и испольуем его при обработке оператора:
// может ли токен быть унарным isUnary(prev) { return prev === null || prev.type === "operator" || prev.type === "delimeter" || prev.type === "left_parenthesis" } parse(tokens) { const stack = [] const rpn = [] let prev = null // предыдущий токен for (token of tokens) { ... else if (token.type === "operator") { // если токен - является унарным, заменяем его на ~ if (token.value === "-" && this.isUnary(prev)) token.value = "~" ... } prev = token // запоминаем обработанный токен } ... }
Что и всё? Да! Вот так просто добавляется обработка унарного минуса. Проверим в работе:
parser.parse(tokenizer.tokenize("-1")) // 1 ~ parser.parse(tokenizer.tokenize("-(1+4)")) // 1 4 + ~ parser.parse(tokenizer.tokenize("2 - -3")) // 2 3 ~ - parser.parse(tokenizer.tokenize("-----5")) // 5 ~ ~ ~ ~ ~ parser.parse(tokenizer.tokenize("max(-4, -5)")) // 4 ~ 5 ~ max parser.parse(tokenizer.tokenize("-4^-2^-3")) // 4 2 3 ~ ^ ~ ^ ~
Теперь у нас есть парсер, использующий алгоритм сортировочной станции, способный перевести математическое выражение из инфиксной формы в постфиксную. Но что произойдёт, если подать ему на вход такие выражения:
parser.parse(tokenizer.tokenize("1 2 +")) // 1 2 + parser.parse(tokenizer.tokenize("5 + + 7")) // 5 + 7 + parser.parse(tokenizer.tokenize("1 2 3 + (,) - * / 4 5 6 (^)")) // 1 2 3 + * 4 5 6 ^ / - parser.parse(tokenizer.tokenize("sin cos 2 max 7")) // 2 7 max cos sin parser.parse(tokenizer.tokenize("max(,)")) // max parser.parse(tokenizer.tokenize("5 + 6 +")) // 5 6 + +
Очевидно, что текущая версия алгоритма не способна отличать корректные выражения от некорректных, а потому она нуждается в доработке.
Проверяем выражение на корректность
На интуитивном уровне понятно, почему приведённые выше выражения не являются корректными. После чисел, констант и переменных с функциями (или проще говоря операндов) должна идти операция, а не ещё один операнд. При этом аргументы функции должны быть обёрнуты в круглые скобки. Мы можем эффективно отлавливать синтаксические ошибки, если введём ожидаемое состояние – expect
, которое будет проверяться в каждом из условий. Изначально мы ожидаем операнд (так как выражения начинаются не с операций, а унарный минус тоже будем считать операндом).
Проведём также небольшой рефакторинг кода: вынесем обработку различных типов токенов в соответствующие методы, после чего добавим проверку на синтаксические ошибки:
parse(tokens) { const stack = [] const rpn = [] let prev = null let expect = "operand" for (const token of tokens) { if (token.type === "number" || token.type === "constant" || token.type === "variable") { expect = this.parseOperand(token, rpn, expect) } else if (token.type === "function") { expect = this.parseFunction(token, stack, expect) } else if (token.type === "delimeter") { expect = this.parseDelimeter(token, stack, rpn, expect) } else if (token.type === "operator" && token.value === "-" && this.isUnary(prev)) { expect = this.parseUnary(token, stack, expect) } else if (token.type === "operator") { expect = this.parseOperator(token, stack, rpn, expect) } else if (token.type === "left_parenthesis") { expect = this.parseLeftParenthesis(token, stack, expect) } else if (token.type === "right_parenthesis") { expect = this.parseRightParenthesis(token, stack, rpn, expect) } else throw new Error(`got invalid token: "${token.value}" (${token.start}:${token.end})`) prev = token } this.parseLastOperators(stack, rpn, expect) return rpn } // обработка операции parseOperand(token, rpn, expect) { if (expect !== "operand") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) rpn.push(token) return "operator" } // обработка функции parseFunction(token, stack, expect) { if (expect !== "operand") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) stack.push(token) return "(" } // обработка разделителя аргументов parseDelimeter(token, stack, rpn, expect) { if (expect !== "operator") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) while (stack.length && stack[stack.length - 1].type !== "left_parenthesis") rpn.push(stack.pop()) if (stack.length === 0) throw new Error(`"${token.value}" outside a function or without "(" (${token.start}:${token.end})`) return "operand" } // обработка унарного минуса parseUnary(token, stack, expect) { if (expect !== "operand") throw new Error(`expected ${expect}, but got unary "${token.value}" (${token.start}:${token.end})`) token.value = "~" stack.push(token) return "operand" } // обработка операции parseOperator(token, stack, rpn, expect) { if (expect !== "operator") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) while (stack.length && this.isMorePrecedence(stack[stack.length - 1], this.operators[token.value])) rpn.push(stack.pop()) stack.push(token) return "operand" } // обработка открывающей скобки parseLeftParenthesis(token, stack, expect) { if (expect === "operator") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) stack.push(token) return "operand" } // обработка закрывающей скобки parseRightParenthesis(token, stack, rpn, expect) { if (expect !== "operator") throw new Error(`expect ${expect}, but got ")" (${token.start}:${token.end})`) while (stack.length && stack[stack.length - 1].type !== "left_parenthesis") rpn.push(stack.pop()) if (stack.length === 0) throw new Error(`"(" missing before "${token.value}" (${token.start}:${token.end})`) stack.pop() if (stack.length && stack[stack.length - 1].type === "function") rpn.push(stack.pop()) return "operator" } // обработка оставшихся в стеке операций parseLastOperators(stack, rpn, expect) { while (stack.length) { const token = stack.pop() if (expect != "operator") throw new Error(`expect ${expect}, but got "${token.value}" (${token.start}:${token.end})`) if (token.type === "left_parenthesis") throw new Error(`the brackets are disbalanced (${token.start}:${token.end})`) rpn.push(token) } }
Да, цена проверки синтаксиса в алгоритме сортировочной станции – много дополнительных строк кода. Именно из-за этой особенности его редко используют в задачах, где проверка корректности важна. В будущих статьях мы реализуем гораздо более элегантные парсеры, в которых проверка синтаксиса будет осуществляться гораздо проще. Зато посмотрите, что будет на тех же выражениях теперь:
parser.parse(tokenizer.tokenize("1 2 +")) // Error: expected "operator", but got "2" (2:3) parser.parse(tokenizer.tokenize("5 + + 7")) // Error: expected operand, but got "+" (4:5) parser.parse(tokenizer.tokenize("1 2 3 + (,) - * / 4 5 6 (^)")) // expected "operator", but got "2" (2:3) parser.parse(tokenizer.tokenize("sin cos 2 max 7")) // Error: "(", but got "cos" (4:7) parser.parse(tokenizer.tokenize("max(,)")) // Error: expected operand, but got "," (4:5) parser.parse(tokenizer.tokenize("5 + 6 +")) // Error: expect operand, but got "+" (6:7) parser.parse(tokenizer.tokenize("1 + (2")) // Error: the brackets are disbalanced (4:5) parser.parse(tokenizer.tokenize("sin(7))")) // Error: "(" missing before ")" (6:7) // а вот тут не всё в порядке parser.parse(tokenizer.tokenize("sin(1, 2, 3, 4)")) // 1 2 3 4 sin parser.parse(tokenizer.tokenize("max(sin(1, 2))")) // 1 2 sin max parser.parse(tokenizer.tokenize("max(1)")) // 1 max
Отлично! Теперь абсолютное большинство синтаксических ошибок успешно отлавливаются парсером.
Большинство, но не все. Сейчас всё ещё можно написать выражение sin(1, 2, 3, 4)
и оно будет успешно разобрано. Большинство ошибок такого рода будут успешно обнаружены на этапе вычисления выражения, однако даже там выражение max(sin(1, 2))
будет успешно вычислено. Поэтому нам никак нельзя оставить это без внимания.
Чтобы отлавливать такие ошибки в этом парсере, нам нужно завести дополнительный стек argsCount
для хранения количества обработанных аргументов функции:
- Когда будет парситься функция, мы будет добавлять в этот стек
1
; - Когда будет парситься разделитель аргументов, будем увеличивать значение на вершине этого стека на 1.
- А когда будем выталкивать из основного стека функцию, будем также выталкивать количество аргументов из
argsCount
и сравнивать с ожидаемым (да, для этого нам придётся передать в парсер словарь с функциями).
Реализуем же это:
constructor(functions) { this.functions = functions ... } parse(tokens) { ... const argsCount = [] for (const token of tokens) { ... else if (token.type === "function") { expect = this.parseFunction(token, stack, argsCount, expect) } else if (token.type === "delimeter") { expect = this.parseDelimeter(token, stack, rpn, argsCount, expect) } else if (token.type === "right_parenthesis") { expect = this.parseRightParenthesis(token, stack, rpn, argsCount, expect) } ... } ... } parseFunction(token, stack, argsCount, expect) { ... argsCount.push(1) return "(" } parseDelimeter(token, stack, rpn, argsCount, expect) { ... if (argsCount.length === 0) throw new Error(`"${token.value}" outside a function (${token.start}:${token.end})`) argsCount[argsCount.length - 1]++ return "operand" } parseRightParenthesis(token, stack, rpn, argsCount, expect) { ... if (stack.length && stack[stack.length - 1].type === "function") { const func = this.functions[stack[stack.length - 1]] const args = argsCount.pop() if (args != func.args) throw new Error(`invalid "${func.value}" arguments count: expected ${func.args}, got ${args} "${token.value}" (${token.start}:${token.end})`) rpn.push(stack.pop()) } return "operator" }
parser.parse(tokenizer.tokenize("sin(1, 2, 3, 4)")) // Error: invalid "sin" arguments count: expected 1, got 4 ")" (14:15) parser.parse(tokenizer.tokenize("max(sin(1, 2))")) // Error: invalid "sin" arguments count: expected 1, got 2 ")" (12:13) parser.parse(tokenizer.tokenize("max(1)")) // Error: invalid "max" arguments count: expected 2, got 1 ")" (5:6)
Что ж, вот наконец-то и готов наш парсер. Теперь ни одна синтаксическая ошибка не проскочит мимо него, но как же много в нём строчек кода (целых 167)! Это даже больше, чем токенизатор и калькулятор вместе взятые. Но такова цена использования надёжной версии алгоритма сортировочной станции.
Собираем всё вместе
На этом этапе у нас наконец есть все слагаемые полноценного парсера математических выражениий: токенизатор, синтаксический анализатор и калькулятор. Давайте создадим файл expression_parser.js
с классом ExpressionParser
, внутри которого соберём все элементы воедино. Чтобы можно было вычислять выражения одним лишь вызовом этого класса, а не описывать каждый раз функции, константы, операции и т.д.
Что нам потребуется?
- Описание всех функций, констант и операций, с которыми должен работать наш парсер;
- Словарь
variables
, в котором будут храниться значения переменных. - Метод
setVariable(name, value)
, с помощью которого можно будет задавать значения переменным; - Собственно токенизатор, парсер и калькулятор, созданные нами ранее.
class ExpressionParser { constructor(expression) { const functions = this.initFunctions() const constants = this.initConstans() const operators = this.initOperators() const tokenizer = new ExpressionTokenizer({ functions: Object.keys(functions), constants: Object.keys(constants) }) const parser = new ShuntingYardParser(functions) this.evaluator = new ExpressionEvaluator({constants, functions, operators}) this.rpn = parser.parse(tokenizer.tokenize(expression)) this.variables = {} } setVariable(name, value) { this.variables[name] = value } evaluate() { return this.evaluator.evaluate({rpn: this.rpn, variables: this.variables}) } initFunctions() { return { "sin": {args: 1, evaluate: Math.sin}, "cos": {args: 1, evaluate: Math.cos}, "tan": {args: 1, evaluate: Math.tan}, "max": {args: 2, evaluate: Math.max}, // и многие многие другие } } initConstans() { return { "pi": Math.PI, "e": Math.E } } initOperators() { return { "+": {args: 2, evaluate: (arg1, arg2) => arg1 + arg2}, "-": {args: 2, evaluate: (arg1, arg2) => arg1 - arg2}, "*": {args: 2, evaluate: (arg1, arg2) => arg1 * arg2}, "/": {args: 2, evaluate: (arg1, arg2) => arg1 / arg2}, "^": {args: 2, evaluate: (arg1, arg2) => arg1 ** arg2}, "~": {args: 1, evaluate: (arg) => -arg} } } }
Теперь вычислять выражения станет гораздо проще. Давайте напишем небольшую функцию, позволяющую проверить, что наш парсер работает ожидаемым образом:
function TestParser(expression, expected, variables = {}, eps = 1e-15) { try { const parser = new ExpressionParser(expression) for (const [variable, value] of Object.entries(variables)) parser.setVariable(variable, value) const result = parser.evaluate() if (Math.abs(result - expected) < eps) console.log(`%c${expression} = ${result}`, "color: green") else console.log(`%c${expression} = ${result}, but expected ${expected}`, "color: red") } catch (error) { console.log(`%c"${expression}" is invalid: ${error.message}`, expected === null ? "color: green" : "color: red") } } // проверяем корректные выражения TestParser("1", 1) // 1 = 1 TestParser("1 + 2 * 3", 7) // 1 + 2 * 3 = 7 TestParser("-(1 + 2) * 3 - 4", -13) // -(1 + 2) * 3 - 4 = -13 TestParser("-2^2", -4) // -2^2 = -4 TestParser("(-2)^2", 4) // (-2)^2 = 4 TestParser("-2^-2", -0.25) // -2^-2 = -0.25 TestParser("(-2)^-2", 0.25) // (-2)^-2 = 0.25 TestParser("max(5 + 2^3, -7 * -9)", 63) // max(5 + 2^3, -7 * -9) = 63 TestParser("cos(7 - 5)^2 + sin(4^0.5)^2", 1) // cos(7 - 5)^2 + sin(4^0.5)^2 = 1 TestParser("sin(x) * (pi/-x - 5)^2", -9, {"x": -Math.PI / 2}) // sin(x) * (pi/-x - 5)^2 = -9 // проверяем некорректные выражения TestParser("()", null) // "()" is invalid: expect operand, but got ")" (1:2) TestParser("max(1)", null) // "max(1)" is invalid: invalid "max" arguments count: expected 2, got 1 ")" (5:6) TestParser("sin(1, 5)", null) // "sin(1, 5)" is invalid: invalid "sin" arguments count: expected 1, got 2 ")" (8:9) TestParser("sin cos 2 max 7", null) // "sin cos 2 max 7" is invalid: expected (, but got "cos" (4:7)
Заключение
Вот оно! Наконец-то! Наш парсер совсем готов! Это стоило нам 220 строк ванильного Javascript кода. Но оно ведь того правда стоило, не так ли? Теперь мы можем обрабатывать любые математические выражения и вычислять их многократно (что имеет смысл только, если выражение содержит переменные).
Итоговый алгоритм
В процессе поиска информации были просмотрены десятки различных статей, посвящённых алгоритму сортировочной станции. И подавляющее большинство из них ограничиваются базовыми выражениями без функций, переменных, унарных операторов и даже операции возведения в степень. А уж алгоритма, проверяющего корректность разбираемого выражения, нет и подавно. Поэтому исправим это недоразумение:
rpn = [] // выражение в обратной польской записи stack = [] // стек args = [] // стек количества аргументов функций expect = "operand" // ожидаемое состояние prev = null // прошлый токен for token in tokens: if token is a constant, number or variable: assert expect is "operand" rpn.push(token) expect = "operator" elif token is a function: assert expect is "operand" stack.push(token) args.push(1) expect = "(" elif token is delimeter: assert expect is "operator" while stack.top is not "(": rpn.push(stack.pop()) assert stack is not empty and args is not empty args.top++ // увеличиваем количество аргументов функции expect = "operand" elif token is "-" operator and prev is null, operator, delimeter or "(": assert expect is "operand" stack.push("~") // меняем токен на унарный expect = "operand" elif token is operator: assert expect is "operator" while stack.top is operator and is_more_precedence(stack.top, token): rpn.push(stack.pop()) stack.push(token) expect = "operand" elif token is "(": assert expect is "(" or "operand" stack.push(token) expect = "operand" elif token is ")": assert expect is "operator" while stack.top is not "(": rpn.push(stack.pop()) assert stack is not empty stack.pop() if stack.top is function: assert args.pop() is equal to function arguments count // проверяем совпадение количества аргументов rpn.push(stack.pop()) expect = "operator" prev = token while stack is not empty: assert expect is operator and not "(" rpn.push(stack.pop())
Был бы этот алгоритм в википедии, может и не было бы этой статьи вовсе!
Куда же без исходников?
class ShuntingYardParser { constructor(functions) { this.functions = functions this.operators = { "+": {precedence: 1, associative: "left"}, "-": {precedence: 1, associative: "left"}, "*": {precedence: 2, associative: "left"}, "/": {precedence: 2, associative: "left"}, "^": {precedence: 3, associative: "right"}, "~": {precedence: 3, associative: "right"} } } parse(tokens) { const stack = [] const argsCount = [] const rpn = [] let prev = null let expect = "operand" for (const token of tokens) { if (token.type === "number" || token.type === "constant" || token.type === "variable") { expect = this.parseOperand(token, rpn, expect) } else if (token.type === "function") { expect = this.parseFunction(token, stack, argsCount, expect) } else if (token.type === "delimeter") { expect = this.parseDelimeter(token, stack, rpn, argsCount, expect) } else if (token.type === "operator" && token.value === "-" && this.isUnary(prev)) { expect = this.parseUnary(token, stack, expect) } else if (token.type === "operator") { expect = this.parseOperator(token, stack, rpn, expect) } else if (token.type === "left_parenthesis") { expect = this.parseLeftParenthesis(token, stack, expect) } else if (token.type === "right_parenthesis") { expect = this.parseRightParenthesis(token, stack, rpn, argsCount, expect) } else throw new Error(`got invalid token: "${token.value}" (${token.start}:${token.end})`) prev = token } this.parseLastOperators(stack, rpn, expect) return rpn } isUnary(prev) { return prev === null || prev.type === "operator" || prev.type === "delimeter" || prev.type === "left_parenthesis" } isMorePrecedence(top, operator) { if (top.type !== "operator") return false if (operator.associative === "right") return this.operators[top.value].precedence > operator.precedence return this.operators[top.value].precedence >= operator.precedence } parseOperand(token, rpn, expect) { if (expect !== "operand") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) rpn.push(token) return "operator" } parseFunction(token, stack, argsCount, expect) { if (expect !== "operand") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) stack.push(token) argsCount.push(1) return "(" } parseDelimeter(token, stack, rpn, argsCount, expect) { if (expect !== "operator") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) while (stack.length && stack[stack.length - 1].type !== "left_parenthesis") rpn.push(stack.pop()) if (stack.length === 0) throw new Error(`"${token.value}" outside a function or without "(" (${token.start}:${token.end})`) if (argsCount.length === 0) throw new Error(`"${token.value}" outside a function (${token.start}:${token.end})`) argsCount[argsCount.length - 1]++ return "operand" } parseUnary(token, stack, expect) { if (expect !== "operand") throw new Error(`expected ${expect}, but got unary "${token.value}" (${token.start}:${token.end})`) token.value = "~" stack.push(token) return "operand" } parseOperator(token, stack, rpn, expect) { if (expect !== "operator") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) while (stack.length && this.isMorePrecedence(stack[stack.length - 1], this.operators[token.value])) rpn.push(stack.pop()) stack.push(token) return "operand" } parseLeftParenthesis(token, stack, expect) { if (expect === "operator") throw new Error(`expected ${expect}, but got "${token.value}" (${token.start}:${token.end})`) stack.push(token) return "operand" } parseRightParenthesis(token, stack, rpn, argsCount, expect) { if (expect !== "operator") throw new Error(`expect ${expect}, but got ")" (${token.start}:${token.end})`) while (stack.length && stack[stack.length - 1].type !== "left_parenthesis") rpn.push(stack.pop()) if (stack.length === 0) throw new Error(`"(" missing before "${token.value}" (${token.start}:${token.end})`) stack.pop() if (stack.length && stack[stack.length - 1].type === "function") { const func = this.functions[stack[stack.length - 1].value] const args = argsCount.pop() if (args != func.args) throw new Error(`invalid "${stack[stack.length - 1].value}" arguments count: expected ${func.args}, got ${args} "${token.value}" (${token.start}:${token.end})`) rpn.push(stack.pop()) } return "operator" } parseLastOperators(stack, rpn, expect) { while (stack.length) { const token = stack.pop() if (expect != "operator") throw new Error(`expect ${expect}, but got "${token.value}" (${token.start}:${token.end})`) if (token.type === "left_parenthesis") throw new Error(`the brackets are disbalanced (${token.start}:${token.end})`) rpn.push(token) } } }
А так же
- expression_tokenizer.js – токенизатор из первой части;
- expression_evaluator.js – калькулятор выражений, записаных в обратной польской записи из второй части;
- expression_parser.js – парсер математических выражений, использующий алгоритм сортировочной станции из этой части.
В следующих статьях мы напишем два других парсера: алгоритм рекурсивного спуска и парсер Пратта. И всё, что нам потребуется сделать, это заменить создание parser
в конструкторе ExpressionParser
.