Синтаксический разбор арифметических выражений
Дерево выражения
Рассмотрим арифметические выражения с обычным приоритетом операций. Сложение (+) и вычитание (-) имеют равный приоритет и более низкий (выполняются позже), чем умножение (*) с делением (/). Кроме этого, пусть существует унарный минус: -5, или -(3+5), или 3+-5=3+(-5). Несмотря на привычную операционную запись арифметических выражений, соответствующие действия являются функциями. Эти функции или бинарны (имеют два аргумента): plus(x, y) это "x+y" или унарны (один аргумент): inv(x) - это -(x).
Арифметическое выражение представимо в виде дерева. Например, дерево выражения 1*2 + 3*4
в корне имеет операцию сложения "+" и затем две ветви, в каждой из которых происходит умножение "*".
В общем случае вызов вложенных друг в друга функций всегда образует дерево:
1*2 + 3*4 plus(mult(1,2), mult(3,4))Будем описывать арифметическое выражение (функцию) типа op(lf, rt) при помощи структуры вида {op, lf, rt}, где op - это тип операции, lf - первый аргумент, и rt - второй. Так, выражение 1*2+3*4 имеет следующее представление:
{ op: "+" , lf: { op: "*" , lf: { op: "N" , lf: 1}, rt: { op: "N" , lf: 2}}, rt: { op: "*" , lf: { op: "N" , lf: 3}, rt: { op: "N" , lf: 4}} } |
function Parser(){ } Parser.calc = function (expr) { switch (expr.op){ case "N" : // число (Number) return expr.lf; case "+" : // сложение return this .calc(expr.lf) + this .calc(expr.rt); case "-" : // вычитание return this .calc(expr.lf) - this .calc(expr.rt); case "*" : // умножение return this .calc(expr.lf) * this .calc(expr.rt); case "/" : // деление return this .calc(expr.lf) / this .calc(expr.rt); case "M" : // унарный минус (Minus) return - this .calc(expr.lf); } } |
Порождающая грамматика
Синтаксис арифметических выражений (с учетом приоритета операций) будем задавать следующей порождающей грамматикой:
E :- T1 + E | T1 - E | T1 T1 :- T2 * T1 | T2 / T1 | T2 T2 :- -T3 | T3 T3 :- N | (E)Грамматика имеет смысл правил, по которым можно делать замены символов E, T1, T2, T3. В процессе таких замен происходит создание (порождение) некоторого арифметического выражения. Дерево начинает строиться с терма E (expression). Его можно заменить на одно из трех выражений (разделённых вертикальной чертой) из первой строки, например на: T1 + E. Затем необходимо сделать две замены для T1 и E, например (T2*T1) + T1. Далее, (T3*T2) + T2 и т.д., пока не доберёмся до числа N, на котором порождение останавливается.
Правила первой строки могут порождать цепочки T1 + T1 + T1 или T1 + T1 - T1 произвольной длины. Повторение терма E в конце правила E :- T1 + E образует "хвостовую" рекурсию, которая и генерит эти цепочки. Аналогичная хвостовая рекурсия стоит в правилах второй строки, определяющей структуру каждого слагаемого. Это даёт выражения вида T2*T2/T2*T2. Будем считать, что операции в таких цепочках выполняются слева направо: T2*(T2/(T2*T2)).
Фактически три терма T1,T2,T3 соответствуют трём уровням приоритета для операций (+ -), (* /) и унарного минуса (-). Подстановка правила T3 :- (E) из последней строки порождает вложенные скобки (..(..)..) любой глубины.
Вещественное число NUM также можно описать при помощи порождающей грамматики:
N :- I.I | I I :- D I | D D :- 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9Впрочем, это делать не обязательно и стоит воспользоваться средствами JS для конверации строки в число.
Генерация случайных выражений
В качестве демонстрации порождения арифметических выражений при помощи грамматики, напишем четыре функции. Результаты 10 вызовов функции getRandE(5) приведены справа (их можно пересчитать, нажатием кнопки ):
Parser.getRandE = function (max) { switch (max-- > 0 ? MS.rand(3): 0){ case 0: return this .getRandT1(max); // T1 case 1: return this .getRandT1(max)+ "+" + this .getRandE(max); // T1+E case 2: return this .getRandT1(max)+ "-" + this .getRandE(max); // T1-E } } Parser.getRandT1 = function (max) { switch (max-- > 0 ? MS.rand(3): 0){ case 0: return this .getRandT2(max); // T2; case 1: return this .getRandT2(max)+ "*" + this .getRandT1(max); // T2*T1 case 2: return this .getRandT2(max)+ "/" + this .getRandT1(max); // T2/T1 } } Parser.getRandT2 = function (max) { if ( MS.rand(10) ) return this .getRandT3(max); // T3; return "-" + this .getRandT3(max); // -T3; } Parser.getRandT3 = function (max) { if (max-- > 0 ? MS.rand(10): 0) return "(" + this .getRandE(max)+ ")" ; // (E) return MS.rand(100); // N } |
Функция MS.rand(3) возвращает равновероятно 0,1 или 2, т.е. MS.rand(n) равна Math.floor(Math.random()*n). Параметр max устраняет очень длинные рекурсии. После каждого вызова он уменьшается и, когда достигает нуля, вызываются "нерекурсивные" грамматические правила. Функции getRandT2 и getRandT3 несколько отличаются и уменьшают вероятность унарного минуса и повышают вероятность появления скобок.
Символы вне грамматики
В арифметическом выражении будем допускать пробельные символы и комментарии:
// строчные (до конца строки) /* блочные - между этими символами */Как и всё остальное, их можно описать при помощи грамматики, но эффективнее игнорировать эти символы на этапе сканирования строки. Проще всего использовать регулярные выражения:
st = st.replace(/\/\/.*/g, " " ); // удаляем строчные комментарии (. - любой, кроме \n) st = st.replace(/\/\*[\s\S]*?\*\ //g, ""); // удаляем блочные комментарии (нет вложений!) st = st.replace(/\s+/g, "" ); // удаляем переносы строк и лишние пробелы |
Если комментариев нет - достаточно следующей функции:
function Parser() { this .n = 0; // текущее полжение при парсинге в строке } Parser.skip = function (st) { // пропускаем пробелы while ( this .n < st.length && " \t\n\r" .indexOf(st.charAt( this .n)) >= 0 ) this .n++; return ( this .n < st.length)? this .n: -1; } |
Если есть комментарии, то функцию необходимо усложнить:
Parser.skip = function (st) { while ( this .n < st.length && " \t\n\r" .indexOf(st.charAt( this .n)) >= 0 ) this .n++; // пропускаем пробелы if ( this .n >= st.length) return -1; // достигли конца строки if (st.charAt( this .n)=== "/" && this .n+1 < st.length){ // возможны комментарии: if (st.charAt( this .n+1)=== "/" ){ // строчный this .n += 2; while ( this .n < st.length && st.charAt( this .n)!== "\n" ) this .n++; return this .skip(st); // вские \r и пробелы в начале след. } if (st.charAt( this .n+1)=== "*" ){ // блочный this .n += 2; while ( this .n+1 < st.length && (st.charAt( this .n) !== "*" || st.charAt( this .n+1) !== "\/" )) this .n++; this .n += 2; return this .skip(st); // пробелы после комментариев } } return ( this .n < st.length)? this .n: -1; } |
var st1 = document.getElementById( 'skip_src_id' ).value; var st2 = "" ; Parser.n = 0; while ( Parser.skip(st1) >= 0 ) st2 += st1.charAt(Parser.n++); document.getElementById( 'skip_des_id' ).value = st2; |
Отметим, что функция Parser.skip не устраняет вложенные комментарии /* /* */ */. Для этого её необходимо модифицировать, подсчитывая число открывающихся (/*) и закрывающихся (*/) скобок. Впрочем, для языков, поддерживающих строки, необходимо учесть наличие таких последовательностей внутри строк, например: /* " /*" */. Поэтому ограничимся запретом вложенных комментариев.
Синтаксический разбор
Перейдём теперь к синтаксическому разбору строки, в результате которого получится дерево выражения. Стартом для парсинга будет служить функция:
Parser.parse = function (st) { this .n = 0; // текущее положение в строке this .err = "" ; // сообщения о синтаксических ошибках return this .parseE(st); // возвращает дерево выражения } |
Parser.parseE = function (st) { var t1 = this .parseT1(st); // вычитываем терм T1 if ( this .skip(st) < 0) // пропускаем пробелы и комметарии return t1; // E :- T1 (в конце строки) switch (st.charAt( this .n)){ case "+" : // E :- T1 + E this .n++; return {op: "+" , lf:t1, rt: this .parseE(st) } ; case "-" : // E :- T1 - E this .n++; return {op: "-" , lf:t1, rt: this .parseE(st) } ; } this .err += "\nn=" + this .n+ "> parseE: no + or - after term T1" ; return t1; // вернём undefined } |
Аналогично выглядит функция парсинга терма T1 :- T2 * T1 | T2 / T1 | T2:
Parser.parseT1 = function (st) { var t2 = this .parseT2(st); // вычитываем терм T2 if ( this .skip(st) < 0) // пропускаем пробелы и комметарии return t2; // T1 :- T2 (в конце строки) switch (st.charAt( this .n)){ case "*" : this .n++; // T1 :- T2 * T1 return {op: "*" , lf: t2, rt: this .parseT1(st) }; case "/" : // T1 :- T2 / T1 this .n++; return {op: "/" , lf: t2, rt: this .parseT1(st) }; } return t2; // T1 :- T2 (нет * или /) } |
Похожа и функция для терма Т2, которая выделяет унарный минус. В предыдущих функциях пропуск пробелов в самом начале не вызывался, так как предполагалось, что это сделает Parser.parseT2(st), которая не может применяться к пустому терму. В результате строки типа "2+" сгенерят ошибку.
Parser.parseT2 = function (st) { if ( this .skip(st) < 0){ this .err += "\nn=" + this .n+ "> parseT2: empty term" ; return ; } if (st.charAt( this .n) === "-" ){ this .n++; return {op: "M" , lf: this .parseT3(st) } ; // T2 :- -T3 } return this .parseT3(st); // T2 :- T3 } |
Parser.parseT3 = function (st) { if ( this .skip(st) < 0){ // нет терма T3 this .err += "\nn=" + this .n+ "> parseT3: empty after unary minus" ; return ; } if (st.charAt( this .n)=== "(" ){ // выражение в скобках this .n++; var expr = this .parseE(st); if ( this .skip(st)<0 || st.charAt( this .n)!== ")" ) this .err += "\nn=" + this .n+ "> parseT3: no close bracket )" ; else this .n++; return expr; } if ( this .isdigit(st, this .n) ){ // вычитываем число var n1= this .n; do this .n++; // целая часть while ( this .n < st.length && this .isdigit(st, this .n)) if ( this .n < st.length && st.charAt( this .n) === "." ){ this .n++; while ( this .n < st.length && this .isdigit(st, this .n)) this .n++; // дробная часть } return { op: "N" , lf: parseFloat(st.substring(n1, this .n)) }; } this .err += "\nn=" + this .n+ "> parseT3: unknown error" ; return ; } |
Результаты работы :
Более продвинутая грамматика
Аналогичным образом можно написать парсер Calc для более продвинутых арифметических выражений, содержащих переменные и функции. Пусть программа состоит из набора выражений, разделённых точеой с запятой, типа:
x=5; y=z=8; u = x+y; sin(z)*exp(cos(u)); sum(1,2,3,4,x);При этом некотрые функции, например, sin(x) имеют фиксированное число аргументов, а некторые sum, min - произвольное. Соответствующая грамматика имеет вид:
PRG :- E; PRG | E E :- V = E | T1 T1 :- T2 + T1 | T2 - T1 | T2 T2 :- T3 * T2 | T3 / T2 | T3 T3 :- -T4 | +T4 | T4 T4 :- NAME(LST) | T5 T5 :- (E) | N | V LST :- E,LST | E V :- NAMEТерминальными символами являются N (вещественное число) и V :- NAME (переменная). NAME (как для имени функции, так и для имени переменной) является набором букв, цифр и символов подчёркивания (с цифры начинаться не должен). После парсинга выражение представимо структурой: {op: lf, rt}, где op - тип операции (см. Calc.calc), lf - выражение для первого аргумента, rt - для второго Для функций (op="F") первый аргумент (lf) - это имя функции, а второй (rt) - массив выражений - фактических аргументов функции.
Результаты её работы :