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

12

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

如您所见,操作返回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类型,但作者告诉我可以这样做。
确切地说,我想将上面的示例转换为使用Either
换句话说,我想将我的代码转换为使用Either并正确使用它。
注:有一个包含最终错误信息的Failure类和一个包含int值的Success类是有意义的。
额外说明:如果Failure能够包含可能在评估期间发生的所有问题的摘要,那将非常有趣。这种行为能够向调用方提供更多关于失败的信息,不仅是第一个失败的操作,还有随后的失败。我考虑编译器在语义分析期间的情况。我不希望该阶段在检测到第一个错误时退出,而是要收集所有问题以获得更好的体验。

5
似乎你提出了解决方法,询问如何将此解决方法应用于问题,但没有详细说明问题。我不确定你想要实现什么,但这个Either类似乎非常复杂,看起来你只是想要一个简单的错误管理,可以通过异常处理或具有属性以供检查错误的简单响应对象来实现。 - asaf92
5
另外,如果你的主要问题是空值检查,那么C#具有更好的工具来解决这个问题,例如null合并运算符,null条件运算符,可空值类型(如果你返回值类型),可空引用类型(在C#8.0中)等等……此外,考虑使用C#元组(C#7.0)。 - asaf92
同意@asaf92的观点。这更像是一个任务,而不是一个问题。此外,它非常简单: 1.使用try-catch来确定操作是否成功。 2.作为结果返回Either<,>。 - Mert Akcakaya
3
我觉得在这里发布代码的一个问题是,对于任何人来说,如何使用 Either/Left/Right 类并不清楚。对我来说,它看起来像是一个非常奇怪的 API,使用了未被使用等等的函数参数。我知道 Pluralsight 课程会解释这个,但把代码从那个上下文中拿出来,就使它更难理解了。如果你看了该课程仍无法理解如何使用它,我的建议是放弃它。 - Lasse V. Karlsen
1
这与防御性编程有关。基本上,Either使忘记处理失败在语法上不可能,因为它强制你将失败“映射”到有效结果(如字符串)。因此,Either所需的类的实现非常复杂(很难理解)。很抱歉我无法提供更多信息。我已经寻找了更多示例和见解,但恐怕只有少数人能回答这个问题。【祈求好运】 - SuperJMN
显示剩余2条评论
2个回答

39

Either类型基础知识

Either类型源自函数式编程语言,其中异常通常被视为副作用,因此不适合传递领域错误。注意区分不同类型的错误:它们中有些属于领域,而其他则不是。例如,空引用异常或索引越界异常与领域无关-它们更可能表示缺陷。

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

Either和异常

Either类型在那些异常代表领域事件的情况下替换异常。但并不替换其他情况下的异常。

关于异常的故事很长,从副作用到明显的漏斗抽象。顺便说一句,漏斗抽象是Java语言中使用throws关键字逐渐减少的原因。

Either和副作用

当与不可变类型结合使用时,它同样具有趣味性。在任何语言(包括函数式、面向对象或混合的C#、Java、Python等)中,当程序员知道某个类型是不可变的时,他们会表现出特定的行为。例如,他们有时倾向于缓存结果-完全正确!-这有助于避免后续成本高昂的调用,例如涉及网络调用甚至数据库的操作。

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

函数式Either类型实现

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

  • 将结果映射到不同类型的不同结果-有用于链式转换
  • 处理错误,将失败转化为成功-在顶层非常有用,例如表示成功和失败作为HTTP响应
  • 将一个错误转换为另一个错误-在传递层边界时很有用(一层中的领域错误集需要翻译成另一层的领域错误集)

使用Either的明显好处是返回它的函数将明确声明它们返回结果的两个通道。结果将变得稳定,这意味着我们可以自由地对其进行缓存。另一方面,仅在Either类型上绑定操作可帮助避免代码中的污染。例如,函数永远不会接收Either。它们将分为在正常对象上操作(包含在Either的Success变体中)或

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中,它会选择是有效地调用它们还是继续使用它已经包含的内容。基本上,如果在Failed状态下调用Map操作,则会跳过并在Success状态下调用该操作。

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

实际中的Either类型

有些场景,例如表单验证,需要在路线上收集多个错误。对于这种情况,Either类型将包含List而不仅仅是一个Error。先前提出的Either.Map函数在这种情况下也足够了,但需要进行修改。普通的Either<Result, Error>.Map(f)不会在Failed状态下调用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);

这个示例演示了评估可能导致算术错误在任何时候弹出,系统中的所有节点都将简单地忽略它。节点只会评估其快乐路径,或者生成自己的错误。仅在需要向用户显示某些内容时,错误才会首次被考虑到UI中。

复杂示例

在更复杂的算术求值器中,可能希望查看所有错误,而不仅仅是一个。这个问题需要至少两个方面的定制:(1)必须包含错误列表,以及(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;
}

在这个实现中,公共的Combine方法是入口点,可以连接两个Either实例的错误(如果两者都失败),保留一个错误列表(如果只有一个失败),或调用映射函数(如果两者都成功)。请注意,即使在最后一种情况下,两个Either对象都成功,仍然可能产生失败的结果!

对于实现者的注意事项

重要的是要注意,Combine方法是库代码。加密复杂的转换必须从使用代码中隐藏的是一般规则。消费者所看到的仅仅是简单明了的API。

在这方面,Combine方法可以是附加到Either<TResult, List<TError>>Either<TReuslt, ImmutableList<TError>>类型的扩展方法,以便在那些错误可以组合的情况下(不会打扰程序!)变得可用。在所有其他情况下,当错误类型不是列表时,Combine方法将不可用。


谢谢你的回答,Zoran! 在研究了这篇文章之后,我有一些问题:
  1. 我仍然对作用于Either<L, R>的不同方法感到困惑。例如,您使用MapLeft、MapRight和Map、Reduce等方法,因为实现本质上很复杂,名称也相当通用,我不知道何时以及如何使用它们。
- SuperJMN
  1. 你能否完成样例,使其可以直接运行?这将有助于我们更好地了解其内部工作原理!非常感谢您的耐心和对软件工程的巨大贡献!
- SuperJMN
在这种范式中,像Java中的已检查异常IOException这样的异常应该被抛出还是在Either中返回?由于它起源于运行时环境中的外部条件,因此它并不严格属于领域相关。但它也不是编程错误。 - Kevin Krumwiede
@KevinKrumwiede 外部错误应该保留为异常,因为你无法处理它们。它们在模块/系统的边界处通过重试操作、报告、退出等方式进行处理。 - Zoran Horvat
@ZoranHorvat 这触及到我对你关于异常分类的困惑。潜在的短暂外部异常可能会在应用程序的业务层中透明地重试,因此它们似乎非常适合使用 Either。编程错误和领域错误,例如“未经授权”,“无效状态”或“未找到”,不是短暂的(至少对于给定的输入),将由更高层处理和记录。 - Kevin Krumwiede
显示剩余2条评论

1
对于那些仍在疑惑的人,这个方便的库由 Vladimir Khorikov 提供了 MaybeResult(又名 Either)类型。

https://github.com/vkhorikov/CSharpFunctionalExtensions

我发布这篇文章是因为它已经准备好使用,功能强大且设计良好。


2
最终,(https://github.com/silkfire/Ultimately)是我几年前基于相同概念开发的Optional分支。 - silkfire

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接