如何在C#中使用Either类型?

Sup*_*JMN 7 .net c# oop defensive-programming

Zoran Horvat建议使用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);
}
Run Code Online (Sandbox Code Playgroud)

如您所见,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);   
        }       
    }
}
Run Code Online (Sandbox Code Playgroud)

请注意,此示例根本不使用该Either类型,但作者本人告诉我可以这样做。

准确地说,我想将评估上面的示例转换为使用Either.

换句话说,我想将我的代码转换为使用任一种并正确使用它

笔记

有一个包含有关最终错误信息的失败类和一个Success包含错误信息的类是有意义的int value

额外的

非常有趣的是,aFailure可以包含评估期间可能发生的所有问题的摘要。这种行为非常棒,可以为调用者提供有关失败的更多信息。不仅是第一次失败的操作,还有后续的失败。我在语义分析期间想到了编译器。我不希望舞台在它检测到的第一个错误时退出,而是收集所有问题以获得更好的体验。

Zor*_*vat 20

任一类型基础知识

这两种类型都来自函数式语言,其中异常被(理所当然地)视为副作用,因此不适合传递错误。注意不同类型错误之间的区别:其中一些属于域,另一些不属于。例如,空引用异常或索引越界与域无关——它们表示存在缺陷。

两者都被定义为具有两个分支的泛型类型 - 成功和失败:Either<TResult, TError>. 它可以以两种形式出现,其中包含 的对象TResult,或包含 的对象TError。它不能同时出现在两个状态中,也不能同时出现在任何一个状态中。因此,如果拥有一个Either 实例,它要么包含一个成功生成的结果,要么包含一个错误对象。

要么和例外

在异常将代表对域很重要的事件的情况下,这两种类型都在替换异常。但是,它不会替换其他场景中的异常。

关于异常的故事很长,从不需要的副作用到简单的泄漏抽象。顺便说一句,泄漏的抽象是throwsJava 语言中关键字的使用随着时间的推移逐渐消失的原因。

两者兼而有之

当涉及到副作用时,它同样有趣,尤其是与不可变类型结合使用时。在任何语言、函数式、OOP 或混合语言(C#、Java、Python,包括)中,当程序员知道某种类型是不可变的时,他们的行为会特别明确。一方面,他们有时倾向于缓存结果 - 完全正确!- 这有助于他们避免以后昂贵的调用,例如涉及网络调用甚至数据库的操作。

缓存也可以是微妙的,比如在操作结束前多次使用内存中的对象。现在,如果一个不可变类型有一个单独的域错误结果通道,那么它们将违背缓存的目的。我们拥有的对象会有用几次,还是每次需要它的结果时都应该调用生成函数?这是一个棘手的问题,无知有时会导致代码缺陷。

功能性任一种类型的实现

这就是任一种类型可以提供帮助的地方。我们可以忽略它内部的复杂性,因为它是一个库类型,只关注它的API。最低 任一类型允许:

  • 将结果映射到不同的结果,或不同类型的结果 - 对链接快乐路径变换很有用
  • 处理错误,有效地将失败转化为成功 - 在顶层很有用,例如,当将成功和失败都表示为 HTTP 响应时
  • 将一个错误转换为另一个 - 在传递层边界时很有用(一层中的域错误集需要转换为另一层的域错误集)

使用Either 最明显的好处是返回它的函数将显式声明它们返回结果的两个通道。而且,结果将变得稳定,这意味着我们可以在需要时自由缓存它们。另一方面,单独对Either 类型的绑定操作有助于避免代码其余部分的污染。一方面,函数永远不会收到任何一个。它们将分为对常规对象进行操作的对象(包含在Either 的Success 变体中),或对域错误对象进行操作(包含在Either 的Failed 变体中)。选择哪个函数将被有效调用的是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
Run Code Online (Sandbox Code Playgroud)

所使用的所有方法的签名都是直截了当的,它们都不会收到任何类型。那些可以检测错误的方法被允许返回任一。那些没有的,只会返回一个简单的结果。

Either<User, Error> ReadUser(input);
Product FindProduct(User);
Either<ProductDetails, Error> ReadTechnicalDetails(Product);
HttpResponse View(Product);
HttpResponse ErrorView(Product);
Run Code Online (Sandbox Code Playgroud)

所有这些不同的方法都可以绑定到Either,这将选择是有效地调用它们,还是继续使用它已经包含的内容。基本上,如果在失败时调用 Map 操作将通过,并在成功时调用操作。

这就是让我们只编码快乐路径并在可能的时候处理错误的原则。在大多数情况下,在到达最顶层之前,不可能一直处理错误。应用程序通常会通过将错误转换为错误响应来“处理”错误。这种情况正是Either 类型发挥作用的地方,因为没有其他代码会注意到需要处理错误。

在实践中输入

在某些情况下,例如表单验证,需要沿路线收集多个错误。对于这种情况,Either 类型都将包含 List,而不仅仅是一个 Error。以前提出的Either.Map 函数在这种情况下也足够了,只需进行修改即可。CommonEither<Result, Error>.Map(f)不会f在失败状态下调用。但是Either<Result, List<Error>>.Map(f),在 f 返回的地方Either<Result, Error>仍然会选择调用f,只是为了查看它是否返回错误并将该错误附加到当前列表中。

经过这个分析,很明显,Either 类型代表的是一种编程原则,一种模式,如果你喜欢的话,而不是一种解决方案。如果任何应用程序有一些特定的需求,并且Either 满足这些需求,那么实现归结为选择合适的绑定,然后由Either 对象应用到目标对象。使用Either 编程变得声明式。调用者的职责是声明哪些函数适用于积极和消极的场景,并且Either 对象将在运行时决定是否调用以及调用哪个函数。

简单示例

考虑计算算术表达式的问题。节点由计算函数进行深入评估,该函数返回Either<Value, ArithmeticError>。错误就像上溢、下溢、被零除等 - 典型的域错误。实现计算器很简单:定义节点,可以是普通值或操作,然后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);
Run Code Online (Sandbox Code Playgroud)

这个例子演示了评估如何导致在任何时候出现算术错误,并且系统中的所有节点都会简单地忽略它。节点只会评估他们的快乐路径,或者自己产生错误。当需要向用户显示某些内容时,只会在 UI 上第一次考虑错误。

复杂的例子

在更复杂的算术计算器中,人们可能希望看到所有错误,而不仅仅是一个错误。该问题需要对至少两个帐户进行自定义:(1) 必须包含错误列表,以及 (2) 必须添加新 API 以组合两个任一实例。

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;
}
Run Code Online (Sandbox Code Playgroud)

在这个实现中,公共Combine方法是入口点,它可以连接来自两个任一实例的错误(如果两者都失败),保留一个错误列表(如果只有一个失败),或者调用映射函数(如果两者都成功) . 请注意,即使是最后一个场景,其中两个对象都为 Success,最终也会产生 Failed 结果!

给实施者的注意事项

需要注意的是,Combine方法是库代码。必须对消费代码隐藏神秘、复杂的转换,这是一条通用规则。消费者只会看到简单明了的 API。

在这方面,该Combine方法可以是附加到例如Either<TResult, List<TError>>orEither<TReuslt, ImmutableList<TError>>类型的扩展方法,以便在可以组合错误的情况下(不显眼!)可用。在所有其他情况下,当错误类型不是列表时,该Combine方法将不可用。