I.6. Знакомимся с IResult - madwareru/drafting-interpreters GitHub Wiki

Настало время немного улучшить код интерпретатора. Новый тип Result

В прошлом разделе мы написали наш первый интерпретатор, но у него есть пара недостатков, которые можно было бы улучшить:

  1. Мы использовали системные исключения языка C# для того, чтобы выяснить, какие исключительные ситуации могут возникнуть. Это привело к тому, что мы в том числе учитывали ошибки времени выполнения, не связанные с нашим языком самим по себе, а вызванные внешними факторами. Хотелось бы разделить эти два типа ошибок
  2. Сигнатура метода Eval не говорит нам ничего о том, что может произойти во время работы очередной операции
  3. Исключения имеют неявную, скрытую природу, и при дизайне системы достаточно сложно выяснить, какой набор исключительных ситуаций может возникнуть

Часть проблем можно было бы решить путём введения нашего собственного типа для исключений. Другую часть проблем они, к сожалению, не решают. В качестве альтернативы мы введём новый специальный тип данных Result. К тому же, данный тип нам понадобится в дальнейшем ещё не раз для целей парсинга, и много чего ещё, так что имеет смысл добавить его в наш арсенал уже сейчас и поместить в разделяемую библиотеку. Создадим новый проект разделяемой библиотеки. Так же создадим проект для юнит тестирования этой библиотеки. В проекте библиотеки добавим новый файл IResult.cs со следующим содержимым:

public interface IResult<T, TError>
{
    public class Ok : IResult<T, TError>
    {
        public readonly T Value;
        public Ok(T value) => Value = value;
    }
    public class Err : IResult<T, TError>
    {
        public readonly TError Error;
        public Err(TError error) => Error = error;
    }
}

public static class Result
{
    public static IResult<T, TError> Ok<T, TError>(T value) => new IResult<T, TError>.Ok(value);
    public static IResult<T, TError> Err<T, TError>(TError error) => new IResult<T, TError>.Err(error);
}

Как видим, наш новый тип очень похож по своему содержимому на тип операций языка для обратной польской записи, является интерфейсом с двумя наследниками. При этом смысл нашего типа такой: "результат может быть либо верным со значением типа T, либо ошибочным со значением типа TError". Дальнейшая работа с этим типом ведётся через оператор switch в таком ключе:

switch(myResult)
{
    case IResult<int, MyError>.Ok ok:
        // делаем что-нибудь с ok.Value
        break;
    case IResult<int, MyEroor>.Err err:
        // делаем что-нибудь с err.Error
        break;
    default: 
        // Имеет смысл писать это для определения ситуации, 
        // где кто-то по ошибке решил реализовать интерфейс IResult для чего-то чуждого 
        throw new ArgumentOutOfRangeException();
}

Упражнение I.6.1 Перегрузите метод ToString для наследников интерфейса IResult для возможности красиво печатать содержимое данного типа

Теперь подумаем, как надо поменять сигнатуру нашего метода Eval для его использования:

  1. Вместо того, чтобы получать ссылку на стек и делать с ним что-то в режиме чёрного ящика, мы будем получать на вход стек и возвращать результат, который либо является "новым" стеком, либо является результатом с некой ошибкой
  2. Нам нужно ввести новый тип для ошибки

Тип Error

Наш новый тип для ошибок будет учитывать следующие ситуации:

  1. На стеке недостаточно данных (требовалось X элементов, но по факту там их Y)
  2. Произошла попытка деления на ноль
  3. Произошла попытка взять квадратный корень от отрицательного числа.
  4. Имела место попытка получить значение результата из пустого стека (случай когда массив операций пуст)

Создадим в проекте нашего интерпретатора новый файл IError.cs и оформим это там следующим образом:

public interface IError
{
    public class NotEnoughOperandsOnAStack : IError
    {
        public readonly int Expected;
        public readonly int Fact;
        public NotEnoughOperandsOnAStack(int expected, int fact)
        {
            Expected = expected;
            Fact = fact;
        }
    }
    public class DivisionByZero : IError {}
    public class SqrtOfANegativeNumber : IError {}
    public class FailedToGetResultFromAStack : IError {}
}

public static class Error
{
    public static IError NotEnoughOperandsOnAStack(int expected, int fact)
        => new IError.NotEnoughOperandsOnAStack(expected, fact);
    public static readonly IError DivisionByZero = new IError.DivisionByZero();
    public static readonly IError SqrtOfANegativeNumber = new IError.SqrtOfANegativeNumber();
    public static readonly IError FailedToGetResultFromAStack = new IError.FailedToGetResultFromAStack();
}

Упражнение I.6.2 Перегрузите метод ToString для наследников интерфейса IError для возможности красиво печатать содержимое данного типа

Немного сахара для лучших результатов

Перед тем как менять код интерпретации, введём ещё несколько улучшений для нашего типа. Напишем пару методов-расширений в статическом классе Result:

public static class Result
{
    // ...
    public static IResult<T1, TError> Map<T0, T1, TError>(
        this IResult<T0, TError> result, 
        Func<T0, T1> mapping
    ) => result switch
    {
        IResult<T0, TError>.Ok ok => Ok<T1, TError>(mapping(ok.Value)),
        IResult<T0, TError>.Err err => Err<T1, TError>(err.Error),
        _ => throw new ArgumentOutOfRangeException()
    };
    
    public static IResult<T1, TError> FlatMap<T0, T1, TError>(
        this IResult<T0, TError> result, 
        Func<T0, IResult<T1, TError>> mapping
    ) => result switch
    {
        IResult<T0, TError>.Ok ok => mapping(ok.Value),
        IResult<T0, TError>.Err err => Err<T1, TError>(err.Error),
        _ => throw new ArgumentOutOfRangeException()
    };
}

Эти два метода позволят нам работать с нашим типом данных на манер Linq. Метод Map аналогичен методу Select, он в случае если в результате лежит Ok, берёт его значение типа T0 и проецирует его на значение типа T1. Метод же FlatMap позволяет выстраивать цепочки вычислений, которые прерываются на первом вхождении ошибочного результата. Сейчас посмотрим как эти методы работают на практике.

Модифицируем метод Eval

Изменим сигнатуру нашего метода-расширения для массива операций в статическом классе Operation для возврата IResult<double, IError> и перепишем его:

public static class Operation
{
    // ...
    public static IResult<double, IError> GetResult(this Stack<double> stack) =>
        stack.Count >= 1
            ? Result.Ok<double, IError>(stack.Pop())
            : Result.Err<double, IError>(Error.FailedToGetResultFromAStack);

    public static IResult<double, IError> Eval(params IOperation[] operations)
    {
        var stack = Result.Ok<Stack<double>, IError>(new());
        foreach (var op in operations) 
            stack = stack.FlatMap(st => op.Eval(st));

        return stack.GetResult();
    }
}

Как можно заметить, объём самого метода не вырос, но понадобилось ввести вспомогательный метод-расширение. Тем не менее, наш код стал от этого строже и понятнее в смысле своего поведения. Можно так же прислушаться к подсказкам IDE и превратить наш метод в one liner в expression стиле (выбирайте что вам больше по душе на ваш вкус):

public static class Operation
{
    // ...
    public static IResult<double, IError> Eval(params IOperation[] operations) =>
        operations
            .Aggregate(Result.Ok<Stack<double>, IError>(new()), (acc, op) => acc.FlatMap(op.Eval))
            .FlatMap(GetResult);
}

Упражнение I.6.3 У реализации нашего метода есть небольшой изъян в плане производительности. В чём он заключается? Перепишите метод так, чтобы этот изъян пропал

Теперь, чтобы код компилировался, нам нужно написать новый вариант метода Eval для интерфейса IOperation. В отличие от предыдущей реализации, потренируемся ещё немного в написании кода с паттерн матчингом, удалим метод у интерфейса и введём вместо него метод-расширение:

public static class Operation
{
    // ...
    private static IResult<Stack<double>, IError> SizeAtLeast(this Stack<double> stack, int expectedCount) => 
        stack.Count < expectedCount
            ? Result.Err<Stack<double>, IError>(Error.NotEnoughOperandsOnAStack(expectedCount, stack.Count))
            : Result.Ok<Stack<double>, IError>(stack);

    private static IResult<Stack<double>, IError> PutNumber(this Stack<double> st, double number)
    {
        st.Push(number);
        return Result.Ok<Stack<double>, IError>(st);
    }

    public static IResult<Stack<double>, IError> Eval(this IOperation operation, Stack<double> stack) =>
        operation switch
        {
            IOperation.Put put => stack.PutNumber(put.Number),
            IOperation.Add => stack.SizeAtLeast(2)
                .FlatMap(st =>
                {
                    var rhs = st.Pop();
                    var lhs = st.Pop();
                    return st.PutNumber(lhs + rhs);
                }),
            IOperation.Div => stack.SizeAtLeast(2)
                .FlatMap(st =>
                {
                    var rhs = st.Pop();
                    var lhs = st.Pop();
                    return Math.Abs(rhs) < double.Epsilon 
                        ? Result.Err<Stack<double>, IError>(Error.DivisionByZero) 
                        : st.PutNumber(lhs / rhs);
                }),
            IOperation.Sqrt => stack.SizeAtLeast(1)
                .FlatMap(st =>
                {
                    var operand = st.Pop();
                    return operand < 0.0 
                        ? Result.Err<Stack<double>, IError>(Error.SqrtOfANegativeNumber)
                        : st.PutNumber(Math.Sqrt(operand));
                }),
            _ => throw new ArgumentOutOfRangeException(nameof(operation))
        };
}

Отметим забавный факт, что теперь мы, помимо прочего, можем писать наши программы для обратной польской записи в таком виде:

var testResult = Result.Ok<Stack<double>, IError>(new())
    .FlatMap(st => Operation.Put(256).Eval(st))
    .FlatMap(st => Operation.Put(7).Eval(st))
    .FlatMap(st => Operation.Put(9).Eval(st))
    .FlatMap(st => Operation.Add.Eval(st))
    .FlatMap(st => Operation.Div.Eval(st))
    .FlatMap(st => Operation.Put(-7).Eval(st))
    .FlatMap(st => Operation.Add.Eval(st))
    .FlatMap(st => Operation.Sqrt.Eval(st))
    .FlatMap(st => st.GetResult());

Тестирование интерпретатора

Упражнение I.6.4 Поэкспериментируйте с новым интерпретатором, позапускайте программу с разными входными данными и печатью результатов вычисления. Постарайтесь создавать исключительные ситуации

Упражнение I.6.5 Обновите набор тестов для работы с новой версией интерпретатора

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