Цикл статей о создании собственного парсера выражений: от токенизации до полноценного синтаксического разбора.
Оглавление цикла
- Часть 1. Токенизация
- Часть 2. Вычисление выражения в обратной польской записи
- Часть 3. Алгоритм сортировочной станции
- Часть 4. Парсер рекурсивного спуска
- Часть 5. Парсер Пратта
- Часть 6. Перевод из постфиксной формы в инфиксную (вы находитесь здесь)
В предыдущих частях мы построили полноценную систему разбора и вычисления математических выражений: реализовали токенизацию, синтаксический разбор с помощью трёх различных парсеров, поддержку унарных операторов, функций с переменным числом аргументов и проверку синтаксических ошибок. Всё это позволило получать корректные выражения в виде постфиксной записи (RPN) и эффективно их вычислять.
Однако постфиксная форма плохо подходит для отображения: её структура непрозрачна, а порядок выполнения операций неочевиден без подготовки. В этой части мы научимся преобразовывать выражение из RPN обратно в человекочитаемую инфиксную форму – с корректными скобками, приоритетами и унарными знаками.
Такой функционал необходим, если вы хотите визуализировать введённые выражения. Более того, это отличный способ убедиться, что ваша система разбора работает правильно: если инфиксное выражение, преобразованное в RPN и обратно, совпадает с исходным (с точностью до допустимой расстановки скобок), значит, всё идёт как надо.
Требования к преобразованию
Для восстановления инфиксной формы из RPN нам потребуется пройти по списку токенов и, как и при вычислении, использовать стек. Однако вместо чисел мы будем складывать текстовые подвыражения. Для каждого оператора (в том числе унарного) и функции нам нужно будет правильно:
- Выбрать нужное число аргументов из стека (так что без описаний функций не обойтись).
- Обернуть аргументы скобками, если это необходимо для сохранения приоритетов и ассоциативности.
- Вернуть результат обратно в стек.
Как и во всех прошлых частях, реализовывать будем в единственном файле expression_infix_builder.js с классом ExpressionInfixBuilder.
Интерфейс класса
Будем придерживаться привычной структуры. Создадим класс ExpressionInfixBuilder, у которого будет метод build(rpn), принимающий массив токенов (как и ранее во всех частях цикла). Каждый токен должен содержать хотя бы два свойства: тип (type) и значение (value). Тип определяет, что это за элемент (number, variable, constant, operator или function), а значение – конкретный символ.
Для корректного восстановления мы должны учитывать приоритеты операторов и их ассоциативность, чтобы обернуть подвыражения в скобки, если это необходимо. Также договоримся, что все операции за исключением возведения в степень (^) и унарного минуса (~) мы будем обрамлять пробелами для более красивого вида.
class ExpressionInfixBuilder {
constructor(functions) {
this.functions = functions
this.operators = {
"+": {args: 2, precedence: 1, associate: "left", value: " + "},
"-": {args: 2, precedence: 1, associate: "left", value: " - "},
"*": {args: 2, precedence: 2, associate: "left", value: " * "},
"/": {args: 2, precedence: 2, associate: "left", value: " / "},
"^": {args: 2, precedence: 3, associate: "right", value: "^"},
"~": {args: 1, precedence: 3, associate: "right", value: "-"}
}
this.constants = {
"pi": "π"
}
}
build(rpn) {
}
}
Переводим выражения
Как уже обсуждалось ранее, алгоритм преобразования выражения из постфиксной записи в инфиксную во многом повторяет алгоритм вычисления выражения. Однако вместо того чтобы выполнять операции и получать численные результаты, мы будем поэтапно строить строковое представление выражения. При этом возникает важная особенность: так как в постфиксной записи нет скобок, простая последовательная сборка выражения может привести к неверному порядку вычислений. Например, при прямом преобразовании выражения 1 2 3 + * получится 1 * 2 + 3 вместо ожидаемого 1 * (2 + 3). Чтобы восстановить правильную структуру, необходимо правильно расставлять скобки.
Самым простым решением было бы заключать каждый результат операции в скобки, но в этом случае выражение быстро становится сильно перегруженным и неудобным для восприятия. Вместо этого мы будем учитывать приоритеты операторов: если у текущего оператора приоритет выше, чем у аргумента, – аргумент берётся в скобки. Если приоритеты совпадают, тогда нужно учитывать ассоциативность оператора. Если ассоциативность не совпадает с позицией аргумента (левый или правый), то и в этом случае добавляются скобки. Такой подход позволяет восстанавливать выражения, близкие к оригинальным по форме, но с гарантией правильного порядка вычислений.
Заниматься определением, нужны ли скобки для оператора, будет метод addParenthesis, принимающий операнд, выполняемый оператор и сторону, с которой обрабатывается операнд. Для функций всё гораздо проще. Поскольку вызов функции всегда обрамляется скобками и разделителем, все аргументы можно просто добавлять как есть:
build(rpn) {
const stack = []
for (const token of rpn) {
if (token.type === "number" || token.type === "variable") {
stack.push({value: token.value, precedence: Infinity})
}
else if (token.type === "constant") {
stack.push({value: this.constants[token.value] ?? token.value, precedence: Infinity})
}
else if (token.type === "operator") {
const operator = this.operators[token.value]
const args = stack.splice(-operator.args)
if (args.length === 1) {
const arg = this.addParenthesis(args[0], operator, "right")
stack.push({value: `${operator.value}${arg}`, precedence: operator.precedence})
}
else {
const arg1 = this.addParenthesis(args[0], operator, "left")
const arg2 = this.addParenthesis(args[1], operator, "right")
stack.push({value: `${arg1}${operator.value}${arg2}`, precedence: operator.precedence})
}
}
else if (token.type === "function") {
const args = stack.splice(-this.functions[token.value].args).map(arg => arg.value)
stack.push({value: `${token.value}(${args.join(", ")})`, precedence: Infinity})
}
}
return stack.pop().value
}
addParenthesis(operand, operator, side) {
if (operand.precedence < operator.precedence || operand.precedence === operator.precedence && operator.associate !== side)
return `(${operand.value})`
return operand.value
}
Внимание, внутри метода build нет никаких проверок входного выражения на некорректность, так как мы ожидаем на входе исключительно правильное выражение (что в нашем случае гарантируется устройством парсера, осуществляющего перевод из инфиксной записи в обратную польскую).
Обновляем наш парсер
Давайте добавим созданный класс в наш парсер математических выражений и дополним его методом toInfix:
class ExpressionParser {
constructor(expression) {
...
this.infixBuilder = new ExpressionInfixBuilder(functions)
}
toInfix() {
return this.infixBuilder.build(this.rpn)
}
}
И теперь проверим наш класс в деле:
const expressions = [
"1 + 2 * 3",
"(1 + 2) * 3",
"(1 + 2) * (3 + 4)",
"2^2^3",
"-2^(2^3)",
"-(2^2)^3",
"-2^-2^-3",
"sin(2*pi*x)^2 + cos(-y)^2",
"-max(-cos(pi/2), (1+2)^2^3)"
]
for (const expression of expressions) {
const parser = new ExpressionParser(expression)
console.log(`${expression} -> ${parser.toInfix()}`)
}
/*
* 1 + 2 * 3 -> 1 + 2 * 3
* (1 + 2) * 3 -> (1 + 2) * 3
* (1 + 2) * (3 + 4) -> (1 + 2) * (3 + 4)
* 2^2^3 -> 2^2^3
* -2^(2^3) -> -2^2^3
* -(2^2)^3 -> -(2^2)^3
* -2^-2^-3 -> -2^-2^-3
* sin(2*pi*x)^2 + cos(-y)^2 -> sin(2 * π * x)^2 + cos(-y)^2
* -max(-cos(pi/2), (1+2)^2^3) -> -max(-cos(π / 2), (1 + 2)^2^3)
*/
Заключеине
В этой части мы всего за 57 строк ванильного Javascript кода научились восстанавливать инфиксную форму выражения по его постфиксной записи и ещё немного расширили наш парсер. Хотя на первый взгляд задача кажется простой – нужно просто заменить вычисления на сборку строк – на практике требуется аккуратно расставлять скобки, чтобы сохранить порядок операций. Для этого мы учли приоритеты операторов и их ассоциативность. Результатом стал класс, способный превращать любое корректное RPN-выражение в человекочитаемую форму, максимально близкую к оригиналу.
На этом цикл статей по разбору математических выражений завершается. Спасибо, что прочитали и, надеюсь, вам удалось узнать что-то новое!
Куда же без исходников?
class ExpressionInfixBuilder {
constructor(functions) {
this.functions = functions
this.operators = {
"+": {args: 2, precedence: 1, associate: "left", value: " + "},
"-": {args: 2, precedence: 1, associate: "left", value: " - "},
"*": {args: 2, precedence: 2, associate: "left", value: " * "},
"/": {args: 2, precedence: 2, associate: "left", value: " / "},
"^": {args: 2, precedence: 3, associate: "right", value: "^"},
"~": {args: 1, precedence: 3, associate: "right", value: "-"}
}
this.constants = {
"pi": "π"
}
}
build(rpn) {
const stack = []
for (const token of rpn) {
if (token.type === "number" || token.type === "variable") {
stack.push({value: token.value, precedence: Infinity})
}
else if (token.type === "constant") {
stack.push({value: this.constants[token.value] ?? token.value, precedence: Infinity})
}
else if (token.type === "operator") {
const operator = this.operators[token.value]
const args = stack.splice(-operator.args)
if (args.length === 1) {
const arg = this.addParenthesis(args[0], operator, "right")
stack.push({value: `${operator.value}${arg}`, precedence: operator.precedence})
}
else {
const arg1 = this.addParenthesis(args[0], operator, "left")
const arg2 = this.addParenthesis(args[1], operator, "right")
stack.push({value: `${arg1}${operator.value}${arg2}`, precedence: operator.precedence})
}
}
else if (token.type === "function") {
const args = stack.splice(-this.functions[token.value].args).map(arg => arg.value)
stack.push({value: `${token.value}(${args.join(", ")})`, precedence: Infinity})
}
}
return stack.pop().value
}
addParenthesis(operand, operator, side) {
if (operand.precedence < operator.precedence || operand.precedence === operator.precedence && operator.associate !== side)
return `(${operand.value})`
return operand.value
}
}
А так же expression_parser.js – парсер математических выражений, использующий созданный класс из этой части.
