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

How to use the Either type in C#?

Zoran Horvat 提议使用 Either 类型来避免空值检查和 在操作执行期间不要忘记处理问题 Either 在函数式编程中很常见。

为了说明它的用法,Zoran 举了一个类似这样的例子:

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 returns Either<Failture, Resource> 稍后可用于形成单个值,而不会忘记处理操作失败的情况。请注意,所有失败都源自 Failure class,以防有多个失败。

这种方法的问题是消耗价值可能很困难。

我用一个简单的程序来展示复杂性:

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 class 和包含 int value

Success class 是有意义的

额外

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

任一类型基础知识

这两种类型都来自函数式语言,在这些语言中异常被(正确地)视为 side-effect,因此不适合传递 错误。注意不同类型错误之间的区别:有些属于领域,有些则不属于。例如。空引用异常或索引越界与域无关——它们更像是一个缺陷。

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

两者兼而有之

在异常表示对域很重要的事件的情况下,这两种类型都在替换异常。不过,它不会取代其他情况下的异常。

关于异常的故事很长,从不需要的 side-effect 到简单的漏洞抽象。顺便说一下,漏洞抽象是 throws 关键字在 Java 语言中随着时间的推移逐渐消失的原因。

其中之一和副作用

涉及到 side-effect 时同样有趣,尤其是与不可变类型结合使用时。在任何语言中,无论是函数式语言、OOP 语言还是混合语言(C#、Java、Python,包括在内),程序员在 知道 某种类型是不可变的时候都会有特定的行为。一方面,他们有时倾向于 cache 结果——完全正确! - 这有助于他们避免以后进行代价高昂的调用,例如涉及网络调用甚至数据库的操作。

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

功能性任一类型实现

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

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

使用 Either 最明显的好处是 return 函数会明确说明它们 return 结果的两个通道。而且,结果将变得稳定,这意味着我们可以在需要时自由缓存它们。另一方面,单独对 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

使用的所有方法的签名是 straight-forward,其中 none 将接收 Either 类型。那些 可以 检测到错误的方法,被允许 return 任一个。那些不这样做的,只会 return 一个简单的结果。

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

所有这些不同的方法都可以绑定到 Either,Either 将选择是有效地调用它们,还是继续使用它已经包含的内容。基本上Map调用Failed就通过,Success就调用

这就是让我们只编写快乐路径并在可能的时刻处理错误的原则。在大多数情况下,直到到达 top-most 层之前,不可能一直处理错误。应用程序通常会通过将错误转化为错误响应来“处理”错误。这种情况正是 Either 类型的优势所在,因为没有其他代码会注意到需要处理的错误。

实践中的任一类型

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

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

简单示例

考虑计算算术表达式的问题。节点由计算函数计算in-depth,returns Either<Value, ArithmeticError>。错误类似于上溢、下溢、被零除等 - 典型的域错误。然后实现计算器 straight-forward:定义节点,可以是普通值或操作,然后为每个节点实现一些 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);

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

复杂示例

在更复杂的算术求值器中,人们可能希望看到所有错误,而不仅仅是一个。该问题至少需要对两个帐户进行自定义:(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;
}

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

实施者须知

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

在这方面,Combine 方法可以是附加的扩展方法,例如,附加到 Either<TResult, List<TError>>Either<TReuslt, ImmutableList<TError>> 类型,以便它变得可用(不引人注目!)在错误 可以 合并的情况下。在所有其他情况下,当错误类型不是列表时,Combine 方法将不可用。