Как использовать тип Either в C#? - PullRequest
6 голосов
/ 03 августа 2020

Зоран Хорват предложил использовать тип Either, чтобы избежать нулевых проверок, и , чтобы не забывать обрабатывать проблемы во время выполнения операции. Either распространено в функциональном программировании.

Чтобы проиллюстрировать его использование, Зоран показывает пример, похожий на этот:

void Main()
{
    var result = Operation();
    
    var str = result
        .MapLeft(failure => $"An error has ocurred {failure}")
        .Reduce(resource => resource.Data);
        
    Console.WriteLine(str);
}

Either<Failed, Resource> Operation()
{
    return new Right<Failed, Resource>(new Resource("Success"));
}

class Failed { }

class NotFound : Failed { }

class Resource
{
    public string Data { get; }

    public Resource(string data)
    {
        this.Data = data;
    }
}

public abstract class Either<TLeft, TRight>
{
    public abstract Either<TNewLeft, TRight>
        MapLeft<TNewLeft>(Func<TLeft, TNewLeft> mapping);

    public abstract Either<TLeft, TNewRight>
        MapRight<TNewRight>(Func<TRight, TNewRight> mapping);

    public abstract TLeft Reduce(Func<TRight, TLeft> mapping);
}

public class Left<TLeft, TRight> : Either<TLeft, TRight>
{
    TLeft Value { get; }

    public Left(TLeft value)
    {
        this.Value = value;
    }

    public override Either<TNewLeft, TRight> MapLeft<TNewLeft>(
        Func<TLeft, TNewLeft> mapping) =>
        new Left<TNewLeft, TRight>(mapping(this.Value));

    public override Either<TLeft, TNewRight> MapRight<TNewRight>(
        Func<TRight, TNewRight> mapping) =>
        new Left<TLeft, TNewRight>(this.Value);

    public override TLeft Reduce(Func<TRight, TLeft> mapping) =>
        this.Value;
}

public class Right<TLeft, TRight> : Either<TLeft, TRight>
{
    TRight Value { get; }

    public Right(TRight value)
    {
        this.Value = value;
    }

    public override Either<TNewLeft, TRight> MapLeft<TNewLeft>(
        Func<TLeft, TNewLeft> mapping) =>
        new Right<TNewLeft, TRight>(this.Value);

    public override Either<TLeft, TNewRight> MapRight<TNewRight>(
        Func<TRight, TNewRight> mapping) =>
        new Right<TLeft, TNewRight>(mapping(this.Value));

    public override TLeft Reduce(Func<TRight, TLeft> mapping) =>
        mapping(this.Value);
}

Как видите, Operation возвращает Either<Failture, Resource>, что позже можно использовать для формирования единого значения, не забывая обрабатывать случай, когда операция не удалась. Обратите внимание, что все сбои происходят от класса Failure, если их несколько.

Проблема с этим подходом заключается в том, что использование значения может быть затруднительным.

Я демонстрируя сложность с помощью простой программы:

void Main()
{
    var result = Evaluate();
    
    Console.WriteLine(result);
}

int Evaluate()
{
    var result = Op1() + Op2();
    
    return result;
}

int Op1()
{
    Throw.ExceptionRandomly("Op1 failed");
    
    return 1;
}


int Op2()
{
    Throw.ExceptionRandomly("Op2 failed");
    
    return 2;
}

class Throw
{
    static Random random = new Random();
    
    public static void ExceptionRandomly(string message)
    {
        if (random.Next(0, 3) == 0)
        {
            throw new InvalidOperationException(message);   
        }       
    }
}

Обратите внимание, что в этом примере тип Either не используется вообще, но автор сам сказал мне, что это возможно.

Точнее, я хотел бы преобразовать образец выше Evaluation для использования Either.

Другими словами, я хочу преобразовать свой код для использования Either и используйте его правильно

ПРИМЕЧАНИЕ

Имеет смысл иметь класс Failure, содержащий информацию о возможной ошибке, и класс Success, содержащий int value

Extra

Было бы очень интересно, если бы Failure мог содержать сводку всех проблем, которые могли возникнуть во время оценки. Такое поведение было бы замечательно, чтобы предоставить вызывающему абоненту дополнительную информацию о сбое. Не только первая неудачная операция, но и последующие отказы. Я думаю о компиляторах во время анализа semanti c. Я бы не хотел, чтобы сцена сработала при первой обнаруженной ошибке, но чтобы собрать все проблемы для лучшего восприятия.

1 Ответ

15 голосов
/ 06 августа 2020

Основы любого типа

Любой тип исходит из функциональных языков, где исключения (по праву) считаются побочным эффектом и поэтому не подходят для передачи ошибок domain . Обратите внимание на разницу между различными типами ошибок: некоторые из них относятся к домену, другие - нет. Например, исключение нулевой ссылки или выход индекса за границы не связаны с доменом - они скорее указывают на дефект.

Любой из них определяется как общий c тип с двумя ветвями - успех и неудача: Either<TResult, TError>. Он может появляться в двух формах, где он содержит объект TResult или где он содержит объект TError. Он не может появляться в обоих состояниях одновременно или ни в одном из них. Следовательно, если у кого-то есть экземпляр Either, он либо содержит успешно созданный результат, либо содержит объект ошибки.

Either и Exceptions

Любой тип заменяет исключения в тех сценариях ios, где исключение будет представлять событие, важное для домена. Однако он не заменяет исключения в других сценариях ios.

Рассказ об исключениях длинный, от нежелательных побочных эффектов до простых дырявых абстракций. Между прочим, дырявые абстракции являются причиной того, что использование ключевого слова throws со временем исчезло в языке Java.

Либо и побочные эффекты

Не менее интересно, когда оно доходит до побочных эффектов, особенно в сочетании с неизменяемыми типами. На любом языке, функциональном, OOP или смешанном (C#, Java, Python, в том числе), программисты ведут себя определенным образом, когда знают определенный тип неизменяемым. Во-первых, они иногда стремятся к кешировать результатов - с полным правом! - что помогает им избежать дорогостоящих вызовов позже, таких как операции, связанные с сетевыми вызовами или даже с базой данных.

Кэширование также может быть тонким, например, использование объекта в памяти несколько раз до завершения операции. Теперь, если у неизменяемого типа есть отдельный канал для результатов ошибок домена, они не будут выполнять кэширование. Будет ли объект, который у нас есть, быть полезным несколько раз, или нам следует вызывать генерирующую функцию каждый раз, когда нам нужен ее результат? Это сложный вопрос, когда незнание иногда приводит к дефектам кода.

Функциональная реализация любого типа

Вот где на помощь приходит любой тип. Мы можем не обращать внимания на его внутреннюю сложность, потому что это тип библиотеки, и сосредоточиться только на его API. Минимум Любой тип позволяет:

  • Сопоставить результат с другим результатом или результатом другого типа - полезно для цепочки преобразований счастливого пути
  • Обработка ошибки, эффективно превращая неудачу в успех - полезно на верхнем уровне, например, при представлении как успеха, так и неудачи в виде ответа HTTP
  • Преобразование одной ошибки в другую - полезно при переходе границ уровня (набор ошибок домена на одном уровне необходимо преобразовать в набор доменных ошибок другого уровня)

Наиболее очевидное преимущество использования Either состоит в том, что функции, возвращающие его, будут явно указывать оба канала, по которым они возвращают результат. И результаты станут стабильными, а это значит, что мы можем свободно кэшировать их, если нам это нужно. С другой стороны, операции привязки только к типу Either помогают избежать загрязнения остальной части кода. Во-первых, функции никогда не получат Either. Они будут разделены на те, которые работают с обычным объектом (содержатся в варианте Success для Either), и на те, которые работают с объектами ошибок домена (содержатся в варианте Failed для Either). Это операция привязки к Either, которая выбирает, какая из функций будет эффективно вызываться. Рассмотрим пример:

var response = ReadUser(input) // returns Either<User, Error>
  .Map(FindProduct)            // returns Either<Product, Error>
  .Map(ReadTechnicalDetails)   // returns Either<ProductDetails, Error>
  .Map(View)                   // returns Either<HttpResponse, Error>
  .Handle(ErrorView);          // returns HttpResponse in either case

Подписи всех используемых методов просты, и ни один из них не получит тип Either. Те методы, которые могут обнаружить ошибку, могут возвращать Either. Те, которые этого не делают, просто вернут простой результат.

Either<User, Error> ReadUser(input);
Product FindProduct(User);
Either<ProductDetails, Error> ReadTechnicalDetails(Product);
HttpResponse View(Product);
HttpResponse ErrorView(Product);

Все эти разрозненные методы могут быть связаны с Either, который выберет, вызывать ли их эффективно или продолжать работу с тем, что он уже содержит. По сути, операция Map проходит, если вызывается для Failed, и вызывает операцию для Success.

Это принцип, который позволяет нам кодировать только счастливый путь и обрабатывать ошибку в тот момент, когда это становится возможным. В большинстве случаев невозможно полностью обработать ошибку, пока не будет достигнут самый верхний уровень. Приложение обычно «обрабатывает» ошибку, превращая ее в ответ об ошибке. Именно в этом сценарии лучше всего проявляется любой тип, потому что никакой другой код никогда не заметит, что ошибки необходимо обрабатывать.

Практически любой тип

Есть сценарии ios, такие как проверка формы, где необходимо собрать несколько ошибок по маршруту. Для этого сценария любой тип будет содержать список, а не только ошибку. Предложенная ранее функция Either.Map подойдет и в этом сценарии, только с модификацией. Обычный Either<Result, Error>.Map(f) не вызывает f в состоянии сбоя. Но Either<Result, List<Error>>.Map(f), где f возвращает Either<Result, Error>, все равно выберет вызов f только для того, чтобы увидеть, вернул ли он ошибку, и добавить эту ошибку в текущий список.

После этого анализа это Очевидно, что любой тип представляет собой принцип программирования, шаблон, если хотите, а не решение. Если какое-либо приложение имеет определенные c потребности, и Either соответствует этим потребностям, то реализация сводится к выбору соответствующих привязок, которые затем будут применены объектом Either к целевым объектам. Программирование с помощью Either становится декларативным. Обязанность вызывающего объекта - объявить , какие функции применяются к положительному и отрицательному сценариям, и объект Either решит, какую функцию вызывать во время выполнения.

Простой пример

Рассмотрим задачу вычисления арифметического c выражения. Узлы подробно оцениваются функцией вычисления, которая возвращает Either<Value, ArithmeticError>. Ошибки такие как переполнение, потеря значимости, деление на ноль и т. Д. c. - типичные доменные ошибки. Тогда реализация калькулятора проста: определите узлы, которые могут быть простыми значениями или операциями, а затем реализовать некоторую Evaluate функцию для каждого из них.

// Plain value node
class Value : Node
{
    private int content;
    ...
    Either<int, Error> Evaluate() => this.content;
}

// Division node
class Division : Node
{
    private Node left;
    private Node right;
    ...
    public Either<Value, ArithmeticError> Evaluate() =>
        this.left.Map(value => this.Evaluate(value));

    private Either<Value, ArithmeticError> Evaluate(int leftValue) =>
        this.right.Map(rightValue => rightValue == 0 
            ? Either.Fail(new DivideByZero())
            : Either.Success(new Value(leftValue / rightValue));
}
...
// Consuming code
Node expression = ...;
string report = expression.Evaluate()
    .Map(result => $"Result = {result}")
    .Handle(error => $"ERROR: {error}");
Console.WriteLine(report);

Этот пример демонстрирует, как оценка может вызывать в любой момент может появиться арифметическая c ошибка, и все узлы в системе просто проигнорируют ее. Узлы будут только оценивать свой счастливый путь или сами генерировать ошибку. Впервые ошибка будет учитываться только в пользовательском интерфейсе, когда пользователю необходимо отобразить что-то .

Сложный пример

В более сложной арифметике c оценщик, может потребоваться увидеть все ошибки, а не только одну. Эта проблема требует настройки как минимум двух учетных записей: (1) Either должен содержать список ошибок и (2) должен быть добавлен новый API для объединения двух экземпляров Either.

public Either<int, ArithErrorList> Combine(
    Either<int, ArithErrorList> a,
    Either<int, ArithErrorList> b,
    Func<int, int, Either<int, ArithErrorList>> map) =>
    a.Map(aValue => Combine(aValue, b, map);

private Either<int, ArithErrorList> Combine(
    int aValue,
    Either<int, ArithErrorList> b,
    Func<int, int, Either<int, ArithErrorList>> map) =>
    b.Map(bValue => map(aValue, bValue));  // retains b error list otherwise

private Either<int, ArithErrorList> Combine(
    ArithErrorList aError,
    Either<int, ArithErrorList> b,
    Func<int, int, Either<int, ArithErrorList>> map) =>
    b.MapError(bError => aError.Concat(bError))
        .Map(_ => bError);    // Either concatenate both errors, or just keep b error
...
// Operation implementation
class Divide : Node
{
    private Node left;
    private Node right;
    ...
    public Either<int, AirthErrorList> Evaluate() =>
        helper.Combine(left.Evaluate(), right.Evaluate(), this.Evaluate);

    private Either<int, ArithErrorList> Evaluate(int a, int b) =>
        b == 0 ? (ArithErrorList)new DivideByZero() : a / b;
}

В этой реализации Метод publi c Combine - это точка входа, которая может объединять ошибки из двух экземпляров Either (если оба экземпляра Failed), сохранять один список ошибок (если только один - Failed) или вызывать функцию сопоставления (если оба экземпляра Success ). Обратите внимание, что даже последний сценарий, когда оба объекта Either являются успехом, может в конечном итоге привести к результату Failed!

Примечание для разработчиков

Важно отметить, что Combine методы являются библиотечным кодом. Это общее правило, что crypti c, сложные преобразования должны быть скрыты от потребляющего кода. Это только простой и понятный API, который когда-либо увидит потребитель.

В этом отношении метод Combine может быть методом расширения, присоединенным, например, к типу Either<TResult, List<TError>> или Either<TReuslt, ImmutableList<TError>> , так что он становится доступным (ненавязчиво!) в тех случаях, когда ошибки могут быть объединены с . Во всех остальных случаях, когда тип ошибки не является списком, метод Combine будет недоступен.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...