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

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

  1. В качестве какой либо из операций пользователь передал null. В таком случае нас ждёт встреча с NullReferenceException
  2. Сам массив был null, результат аналогичный предыдущему пункту
  3. Выполнение какой-то из операций выбросило исключение
  4. Массив операций был пуст, что привело к пустому стеку в конце выполнения метода Eval, а судя по документации метода Pop это приводит к InvalidOperationException

Заметим, что пока что наш код компилироваться не будет, так как у интерфейса IOperation нет метода Eval. Добавим его:

public interface IOperation
{
    public void Eval(Stack<double> stack);
    // ...
}

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

Реализация операции Put

public class Put : IOperation
{
    // ...
    public void Eval(Stack<double> stack) => stack.Push(Number);
}

Проанализируем поведение операции. Тело метода говорит нам дословно, что всё, что делает операция это кладёт на стек очередное число, о значении которого мы знали из тела операции. Почитаем документацию метода Push, упоминаний исключений не встречаем, стало быть, операция всегда отрабатывает успешно, и не приводит к исключительным ситуациям в обычных ситуациях. Но при null в stack мы всё таки получим NullReferenceException.

Реализация операции Add

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), из них получается сумма, которая кладётся на вершину стека.

Реализация операции Div

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, как и правила по выбору левого и правого операндов.

Реализация операции Sqrt

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 для добавления таких возможностей и какие новые операции будут необходимы?

Посмотреть на пример реализации актуальный для данной части туториала можно тут

⚠️ **GitHub.com Fallback** ⚠️