Академический Документы
Профессиональный Документы
Культура Документы
Sprache.calc - Наследование Грамматик в Sprache
Sprache.calc - Наследование Грамматик в Sprache
Tutorial
Статья демонстрирует технику создания парсеров с использованием наследования грамматик. Наследование позволяет
описывать новые грамматики на основе уже существующих путем добавления новых правил или переопределения
унаследованных, что существенно упрощает реализацию новых парсеров. Изменения в базовой грамматике автоматически
становятся доступными во всех порожденных грамматиках. Основная область применения такой техники — поддержка
нескольких диалектов или версий языков.
Поддержка наследования грамматик есть в некоторых генераторах парсеров (например, в ANTLR, Nitra), и автоматически
доступна в инструментах, использующих объектно-ориентированные языки в качестве DSL-языков описания грамматик
(например, Sprache и Irony).
В качестве примера приложения для статьи взята настраиваемая библиотека-калькулятор выражений с поддержкой
пользовательских функций и переменных. Калькулятор компилирует строки в LINQ-выражения, которые легко преобразуются в
строго типизированные делегаты. В отличие от интерпретирующих калькуляторов вроде NCalc, скомпилированные выражения
по скорости работы никак не отличаются от методов, написанных на C#. Пример использования готового калькулятора:
// выражение с переменными
var expr = calc.ParseExpression("Sin(y/x)", x => 2, y => System.Math.PI);
var func = expr.Compile();
Console.WriteLine("Result = {0}", func());
// пользовательские функции
calc.RegisterFunction("Mul", (a, b, c) => a * b * c);
expr = calc.ParseExpression("2 ^ Mul(PI, a, b)", a => 2, b => 10);
Console.WriteLine("Result = {0}", func.Compile()());
Я бы сказал, что Sprache — превосходный инструмент, отлично подходящий для достаточно широкого круга задач и
обладающий особой притягательностью, поскольку поощряет пошаговую разработку грамматик и TDD. Конечно, у
комбинаторных парсеров есть определенные недостатки (например, сложности с диагностикой и восстановлением после
ошибок), однако подобные детали несущественны для темы этой статьи.
Парсер в Sprache — это функция, которая трансформирует входную строку в какой-нибудь другой объект. В отличие от
большинства инструментов построения компиляторов, в Sprache не используется генерация кода. Парсеры определяются
прямо в тексте программы, и их сразу же можно использовать для разбора текста. Это позволяет параллельно с описанием
парсеров писать для них юнит-тесты, что очень удобно. Вот пример простого парсера, который принимает строчку из
повторяющихся букв A:
var parseA = Parse.Char('A').AtLeastOnce();
Простые парсеры комбинируются в более сложные парсеры. Для комбинации парсеров в Sprache определена масса extension-
методов (например, Or, And, Many и так далее), однако особенно впечатляет определение парсеров как LINQ-запросов:
Parser<string> identifier =
from leading in Parse.WhiteSpace.Many()
from first in Parse.Letter.Once()
from rest in Parse.LetterOrDigit.Many()
from trailing in Parse.WhiteSpace.Many()
select new string(first.Concat(rest).ToArray());
Совокупность всех правил, или грамматика языка, в Sprache обычно выглядит как статический класс с полями-парсерами. Более
подробно о Sprache можно почитать в обзорной статье, для которой на хабре имеется перевод:
Устройство калькулятора
Наш калькулятор может работать в трех режимах: простой, научный и настраиваемый.
Простой калькулятор поддерживает обычные арифметические операции над действительными числами с плавающей запятой,
унарный минус и скобки. Научный режим добавляет поддержку двоичных и шестнадцатеричных чисел, экспоненциальную
запись и вызовы любых функций из класса System.Math, а в настраиваемом режиме можно использовать параметры и
регистрировать свои собственные функции (с возможностью перегрузки).
Каждый следующий режим поддерживает все возможности предыдущих режимов и добавляет новые. Точно так же будет
устроена иерархия классов грамматик, описывающих входные языки выражений калькулятора. Парсер калькулятора
представляет собой функцию, преобразовывающую входную строку в LINQ-выражение, которое можно скомпилировать в
делегат и вызвать, как обычную функцию:
Простой калькулятор
В качестве основы для простого калькулятора взят пример из поставки Sprache — сверхкомпактный LinqyCalculator. Грамматика
разбита на правила так, чтобы максимально упростить создание LINQ-выражений во время компиляции:
Обычно парсеры Sprache объявляются как статические лямбда-функции. Нам это не подходит, потому что их нельзя
переопределять в классах-потомках, так что правила будем объявлять как виртуальные свойства.
// Было:
public static readonly Parser<Expression> ExpressionInParentheses =
from lparen in Parse.Char('(')
from expr in Expr
from rparen in Parse.Char(')')
select expr;
// Стало:
protected virtual Parser<Expression> ExpressionInParentheses
{
get
{
return
from lparen in Parse.Char('(')
from expr in Expr
from rparen in Parse.Char(')')
select expr;
}
}
После такой переделки грамматика немного увеличивается в размерах, зато теперь любые правила можно переопределять в
классах-потомках. Чтобы можно писать юнит-тесты для каждого правила, придется объявлять свойства-парсеры как public или
protected internal.
Я не буду приводить полный текст грамматики простого калькулятора, его можно посмотреть на гитхабе. В своей
содержательной части он практически повторяет стандартный пример LinqyCalculator из поставки Sprache.
Научный калькулятор
Поскольку научный калькулятор умеет как минимум все то же, что и обычный, его класс наследуется от грамматики простого
калькулятора. Для поддержки двоичных и шестнадцатеричных чисел добавляем новые правила:
Определить новые правила недостаточно, потому что базовая грамматика не знает, в какой момент их можно применять.
Поскольку двоичные и шестнадцатеричные числа — это разновидность констант, добавим их в парсер Constant.
Обратите внимание: парсеры Binary и Hexadecimal возвращают string, а парсер Constant — LINQ-выражение. Понадобятся
вспомогательные методы, которые преобразуют строки в Expression.Constant(double). Готовый парсер Constant с поддержкой
десятичных, двоичных и шестнадцатеричных чисел примет такой вид:
Вспомогательный метод CallFunction просто формирует LINQ-выражение для вызова статического из класса System.Math с
указанным именем:
Поскольку базовая грамматика ничего не знает о новых правилах, нужно их подключить в какое-нибудь правило базовой
грамматики. Здесь подобрать подходящее правило не так легко, как в случае с константами. Подходящее правило будет
определяться приоритетом операции вызова функции.
Нетрудно заметить, что этот приоритет должен быть самый высокий — такой же, как операции взятия в скобки. Например, при
вычислении выражения Sin(2) ^ Cos(3) сначала нужно вычислить значения функций, а затем выполнить операцию возведения в
степень.
В базовой грамматике взятие в скобки фигурирует в правиле Factor, поэтому его нам и нужно переопределить:
Любую пользовательскую функцию для калькулятора можно представить в виде делегата Func<double[], double>. Именованные
функции удобно хранить в словаре: Dictionary<string, Func<double[], double>>. Чтобы разрешить перегрузку функций,
достаточно к имени прицепить число параметров:
Определенную сложность представляет выражение Expression.Call, которое нужно сгенерировать для вызова пользовательской
функции. Дело в том, что Expression.Call может вызывать только существующие методы, в число которых пользовательские
функции, очевидно, не входят. Чтобы выкрутиться в этой ситуации, достаточно определить в классе калькулятора такой метод:
Этот метод и будет вызывать выражение Expression.Call, которое мы сформируем при компиляции. Нам останется только
преобразовать список параметров в один параметр-массив:
Добавление параметров
Для поддержки параметров потребуется доработка грамматики: новое правило и обновление старых правил. Параметр — это
просто идентификатор, который может встретиться там же, где константа или вызов функции:
Здесь мы впервые встречаем конфликт. Дело в том, что в правиле Factor теперь есть две альтернативы, которые обе начинаются
с идентификатора: параметр и функция. Если парсер встретил идентификатор, он не может определить, параметр перед ним или
функция, пока не заглянет вперед. Если за идентификатором идет скобка "(", значит это функция, в противном случае —
параметр.
Насколько мне известно, Sprache никак не помогает в поиске таких конфликтов. Обнаружить их можно лишь путем
пристального взгляда. Вы добавляете правила, пишете для них юнит-тесты и в один прекрасный момент обнаруживаете, что
некоторые тесты не проходят, сообщая об ошибках разбора. Случай с параметрами и функциями вполне тривиален, однако
чаще поиск и устранение конфликтов — серьезная задача, отнимающая много времени и сил.
Чтобы разрешить конфликт между параметрами и функциями, мы можем определить параметр как «Идентификатор, за
которым не следует скобка». Такое правило не будет приводить к конфликту, поскольку оно устраняет неоднозначность.
Выглядит оно так:
protected virtual Parser<Expression> Parameter
{
get
{
// identifier not followed by a '(' is a parameter reference
return
from id in Identifier
from n in Parse.Not(Parse.Char('('))
select GetParameterExpression(id);
}
}
Парсер Parse.Not подобен отрицательному предпросмотру (negative lookahead) в регулярных выражениях: он не меняет
указатель текущего символа и срабатывает успешно, если переданный ему парсер, в данном случае Parse.Char('('), терпит
неудачу.
Как и в случае с вызовом функций, нам нужно как-то сгенерировать выражение, возвращающее значение параметра. Настало
время принять решение о том, как в калькулятор будут передаваться параметры. На первый взгляд, мы можем поступить с
параметрами так же как с пользовательскими функциями: регистрировать их в специальном словаре Dictionary<string, double>,
который хранится в калькуляторе:
calc.Parameters["MyPI"] = 355/113d;
calc.Parameters["MyE"] = 2.718d;
Компиляция обращения к такому параметру будет устроена аналогично вызову пользовательской функции. Калькулятор
сгенерирует вызов метода GetParameterExpression, передав ему имя параметра. Если параметр не определена, можно
попытаться найти его среди констант класса System.Math:
Попытавшись воспользоваться таким калькулятором, мы сразу обнаружим неудобство такого хранилища параметров.
Выражений калькулятор может скомпилировать много, а хранилище параметров у него одно. Все выражения будут
использовать один и тот же пул параметров, привязанный к экземпляру калькулятора.
calc.Parameters["P"] = 3.14d;
calc.Parameters["R"] = 10;
var func1 = calc.ParseExpression("2*P*R").Compile();
var result1 = func1();
Общий пул параметров приводит к тому, что выражениями невозможно пользоваться в многопоточной программе. Один поток
установит одно значение параметра, другой поток — другое, и результат вычислений станет неопределенным. Очевидно, для
передачи параметров нужно придумать механизм понадежнее.
Для такой схемы потребуется не такая уж большая переделка. Вместо вызова this.GetParameterValue нужно всего лишь
сгенерировать обращение к словарю параметров: parameters[name]. Индексатор в C# компилируется в вызов метода get_Item,
поэтому доступ к параметру будет выглядеть так:
Чтобы не усложнять выражение, мы не будем проверять, есть ли в словаре параметр с указанным именем. Если параметра нет,
класс Dictionary и сам пожалуется на это. Вот полный метод для компиляции параметров:
Такой синтаксис читается значительно лучше, чем создание и заполнение Dictionary<string, double>. Его удобно использовать,
когда список допустимых параметров выражения фиксирован. Хотя это не имеет отношения к собственно разбору выражений,
поясню, как устроен этот метод:
Наследование грамматик взваливает на юнит-тесты дополнительную ответственность: для каждого класса нужно убедиться, что
все унаследованные правила продолжают работать и нормально взаимодействуют с правилами, переопределенными в классах-
потомках. Для этого можно использовать вспомогательный метод ForEachCalculator, который прогоняет тесты на всех вариантах
калькулятора:
[Fact]
public void ExprCombinesTermsWithAddSubOperators()
{
ForEachCalculator(calc =>
{
Assert.Equal(4d, calc.Expr.Parse("2+2").Execute());
Assert.Equal(2d, calc.Expr.Parse("2*3-4*1").Execute());
Assert.Throws<ParseException>(() => calc.Expr.Parse("+"));
});
}
Однако более изящное решение, которое и используется в юнит-тестах калькулятора, состоит в наследовании тестов. В базовом
классе тестов определен виртуальный метод CreateCalculator, который создает калькулятор для тестирования. Базовый класс
тестов создает и тестирует SimpleCalculator, его потомок создает тестирует ScientificCalculator, наследуя все тесты базового
класса, но выполняя их уже для калькулятора-потомка, и так далее.