Парсим математические выражения на чистом Javascript. Часть 3: алгоритм сортировочной станции

javascript, math expression parsing, shunting yard
Парсим математические выражения на чистом Javascript. Часть 3: алгоритм сортировочной станции

Цикл статей о создании собственного парсера выражений: от токенизации до полноценного синтаксического разбора.

Оглавление цикла

В предыдущей части мы реализовали калькулятор выражений, записанных в обратной польской записи – структуру, идеально подходящую для быстрого и однозначного вычисления выражений. Однако остаётся вопрос: как получить RPN из привычной инфиксной записи (с операциями, скобками, переменными и функциями)?

Один из классических ответов на этот вопрос – алгоритм сортировочной станции (Shunting Yard Algorithm), предложенный Дейкстрой. Он позволяет преобразовать выражение вида:

3 + 4 * 8 / (5 - 3)^2^3

в обратную польскую запись:

3 4 8 * 5 3 - 2 3 ^ ^ / +

Именно этот алгоритм мы реализуем в данной части. При этом мы:

  • будем парсить выражения, содержащие числа, константы, функции и переменные;
  • научимся отличать вычитание от унарного минуса;
  • будем проверять синтаксическую корректность выражения и выдавать соответствующие сообщения об ошибках.

Алгоритм сортировочной станции

В целом отличное описание алгоритма есть на википедии, но в нём нет двух важных деталей: обработки унарного минуса и проверки ошибок. В этой статье мы исправим это ужасное недоразумение, но давайте для начала разберёмся с базовым алгоритмом:

  1. Создаём пустой стек stack и пустой выходной массив rpn;
  2. Идём по списку токенов от первого к последнему;
    • если токен является числом, константой или переменной, записываем его в rpn;
    • если токен является функцией, кладём его в стек;
    • если токен является разделителем аргументов (,), то перекладываем токены из стека в rpn, пока на вершине стека не окажется ( (если стек закончился раньше, в выражении пропущена открывающая скобка);
    • если токен является операцией o1, то:
      • перекладываем из стека в выходной массив операции o2, пока их приоритет выше приоритета o1 (или приоритеты равны, если o1 является левоассоциативной);
      • после чего кладём операцию o1 в стек;
    • если токен является открывающей скобкой (, то кладём его в стек;
    • если токен является закрывающей скобкой ), то:
      • перекладываем из стека в выходной массив токены, пока на вершине стека не окажется открывающая скобка( (если стек закончился раньше, в выражении пропущена открывающая скобка);
      • выбрасываем открывающую скобку из стека;
      • если на вершине стека оказалась функция, то перекладываем её из стека в выходной массив;
  3. перекладываем оставшиеся в стеке токены в выходной массив (если встретится (, то в выражении пропущена закрывающая скобка).

Знаю, знаю, знаю. Алгоритм звучит не так уж и просто, поэтому давайте рассмотрим на конкретном примере:

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())

Был бы этот алгоритм в википедии, может и не было бы этой статьи вовсе!

Куда же без исходников?

Итоговый файл shunting_yard_parser.js
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)
        }
    }
}

А так же

В следующих статьях мы напишем два других парсера: алгоритм рекурсивного спуска и парсер Пратта. И всё, что нам потребуется сделать, это заменить создание parser в конструкторе ExpressionParser.