I.5.Напишем первый интерпретатор - madwareru/drafting-interpreters GitHub Wiki
Возвращаясь к идее рецепта интерпретатора, подумаем какие данные у нас имеются, каков их смысл, и что с ними предстоит сделать.
У нас есть набор операций и стек. Стек это тоже данные нашей программы, можно считать его "почтовым ящиком" для общения между операциями. Все операции кладут значения на вершину стека, а какие-то ещё и снимают значения для своих нужд. Операции выполняются последовательно, при этом у каждой операции есть так же своё описанное поведение, отвечающее на вопрос "как операция трактует данные и что она с ними делает". Операции производят свою работу и результатом работы программы является значение, лежащее на вершине стека.
Напишем в статическом классе Operation
новый метод, который дословно повторяет это поведение:
public static class Operation
{
// ...
public static double Eval(params IOperation[] operations)
{
var stack = new Stack<double>();
foreach (var op in operations)
op.Eval(stack);
return stack.Pop();
}
}
В такой метод можно будет передать как заготовленную последовательность команд, так и вызывать его с набором команд передающимся как аргументы:
// Эти вызовы эквивалентны
var testProgram = new[] { Operation.Put(2), Operation.Put(2), Operation.Add };
var a = Operation.Eval(testProgram);
var b = Operation.Eval(Operation.Put(2), Operation.Put(2), Operation.Add);
Проанализируем поведение нашего метода. В каких случаях может возникнуть исключительная ситуация? Скорей всего таких случаев будет четыре:
- В качестве какой либо из операций пользователь передал
null
. В таком случае нас ждёт встреча с NullReferenceException - Сам массив был
null
, результат аналогичный предыдущему пункту - Выполнение какой-то из операций выбросило исключение
- Массив операций был пуст, что привело к пустому стеку в конце выполнения метода Eval, а судя по документации метода Pop это приводит к InvalidOperationException
Заметим, что пока что наш код компилироваться не будет, так как у интерфейса IOperation нет метода Eval. Добавим его:
public interface IOperation
{
public void Eval(Stack<double> stack);
// ...
}
Метод принимает стек по ссылке для дальнейших манипуляций с ним. Так как сам стек является ссылочным типом, нужды явно прописывать ref
в сигнатуре у нас нет. Теперь нужно реализовать этот метод для всех операций.
public class Put : IOperation
{
// ...
public void Eval(Stack<double> stack) => stack.Push(Number);
}
Проанализируем поведение операции. Тело метода говорит нам дословно, что всё, что делает операция это кладёт на стек очередное число, о значении которого мы знали из тела операции. Почитаем документацию метода Push, упоминаний исключений не встречаем, стало быть, операция всегда отрабатывает успешно, и не приводит к исключительным ситуациям в обычных ситуациях. Но при null
в stack
мы всё таки получим NullReferenceException
.
public class Add : IOperation
{
// ...
public void Eval(Stack<double> stack)
{
var rhs = stack.Pop();
var lhs = stack.Pop();
stack.Push(lhs + rhs);
}
}
Данная операция так же как и предыдущая упадёт с NullReferenceException
если в stack
будет null
, так же она снимает со стека значения, и если на стеке не было достаточно операндов, пользователь получит InvalidOperationException
. В противном случае мы считаем вершину стека правым операндом (rhs), следующий за ней элемент считаем левым операндом(lhs), из них получается сумма, которая кладётся на вершину стека.
public class Div : IOperation
{
// ...
public void Eval(Stack<double> stack)
{
var rhs = stack.Pop();
var lhs = stack.Pop();
stack.Push(Math.Abs(rhs) < double.Epsilon ? throw new DivideByZeroException() : lhs / rhs);
}
}
Тут мы добавляем немного собственной семантики. В случае с C# при делении вещественных значений на ноль исключение не падает, вместо этого значение становится PositiveInfinity или NegativeInfinity в зависимости от знака в числителе. Нас это не устраивает в нашем калькуляторе, поэтому мы в такой ситуации форсируем выбрасывание исключения DivideByZeroException. В остальном исключительные ситуации всё те же что и в случае с операцией Add
, как и правила по выбору левого и правого операндов.
public class Sqrt : IOperation
{
// ...
public void Eval(Stack<double> stack)
{
var operand = stack.Pop();
stack.Push(operand < 0.0 ? throw new ArgumentOutOfRangeException() : Math.Sqrt(operand));
}
}
Так же как и в случае с Div
, мы добавляем немного своего поведения. В случае C# взятие квадратного корня приводит к получению NaN, мы же вместо этого бросаем исключение ArgumentOutOfRangeException. В остальном исключительные ситуации всё те же, что и в случае с предыдущими двумя операциями.
Интерпретатор готов, самое время проверить его работу
Упражнение I.5.1 Модифицируйте файл
Program.cs
для вычисления и печати результата какой-нибудь простой программы для калькулятора обратной польской нотации
Упражнение I.5.2 Напишите юнит тесты для всех команд интерпретатора. Учтите так же все исключительные ситуации, упомянутые в данном разделе
Упражнение I.5.3 (Опционально) Добавьте новые операции по вашему вкусу, реализуйте их поведение
Упражнение I.5.4 Попробуйте ответить на вопрос, можем ли мы добавить в наш интерпретатор возможность сохранять значение из стека в именованных переменных и далее использовать его. Какие модификации потребуется проделать над методами Operation.Eval и IOperation.Eval для добавления таких возможностей и какие новые операции будут необходимы?
Посмотреть на пример реализации актуальный для данной части туториала можно тут