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

How to use the Either type in C#?

提问人:SuperJMN 提问时间:8/3/2020 最后编辑:SuperJMN 更新时间:10/1/2022 访问量:15861

问:

Zoran Horvat 建议使用该类型来避免空检查,并且不要忘记在操作执行期间处理问题。 在函数式编程中很常见。EitherEither

为了说明它的用法,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);
}

如您所见,以后可用于形成单个值的返回值,而不会忘记处理操作失败的情况。请注意,如果存在多个故障,则所有故障都派生自该类。OperationEither<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 类和一个包含Successint value

额外

非常有趣的是,可以包含评价过程中可能发生的所有问题的摘要。此行为非常适合向调用方提供有关失败的详细信息。不仅是第一次失败的操作,还有随后的失败。我在语义分析中想到了编译器。我不希望舞台在检测到的第一个错误上得到救助,而是收集所有问题以获得更好的体验。Failure

C# .NET OOP 防御性编程

评论

5赞 asaf92 8/3/2020
看起来你正在为你的问题提出一个解决方案,并问你如何在不实际解释问题的情况下将这个解决方案应用于你的问题。我不确定你想实现什么,但这个类看起来非常复杂,看起来你想要的只是一个简单的错误管理,你可以通过异常处理或一个简单的响应对象来实现,该对象将具有一个可以检查错误的属性Either
5赞 asaf92 8/3/2020
此外,如果 null 检查是您的主要问题,那么 C# 有更好的工具,例如 null 合并运算符、null 条件运算符、可为 null 的值类型(如果您返回值类型)、可为 null 的引用类型(在 C# 8.0 中)等......还可以考虑使用 C# 元组 (C# 7.0)
0赞 Mert Akcakaya 8/3/2020
同意@asaf92。这与其说是问题,不如说是任务。这也很简单: 1. 使用 try-catch 知道操作是否成功。结果返回 Either<,>。
3赞 Lasse V. Karlsen 8/4/2020
我认为在这里发布代码的问题之一是这里的任何人都不清楚如何使用 Either/Left/Right 类。对我来说,它看起来像一个非常奇怪的 API,使用了未使用的 func 参数等等。我知道 Pluralsight 课程会解释这一点,但是将代码从该上下文中取出会使其更加难以理解,如果您无法理解如何使用它并且您已经看过该课程,我的建议是给它一个通行证。
1赞 SuperJMN 8/4/2020
这与防御性编程有关。基本上,在语法上不可能忘记处理失败,因为它迫使您将失败“映射”到有效结果(如字符串)。因此,所需的类的复杂实现(很难得到它)。很抱歉,我无法为您提供更多信息。我四处寻找更多的样本和见解,但恐怕只有一小部分人能回答这个问题。[交叉手指]EitherEither

答:

39赞 Zoran Horvat 8/6/2020 #1

任一类型基础知识

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

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

任一和例外

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

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

任一和副作用

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

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

功能任一类型实现

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

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

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

使用的所有方法的签名都是直截了当的,它们都不会收到 Either 类型。那些可以检测到错误的方法允许返回 Either。那些不这样做的人,只会返回一个普通的结果。

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

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

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

实践中的任何一种类型

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

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

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

实施者须知

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

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

评论

0赞 SuperJMN 8/7/2020
谢谢你的回答,佐兰!研究完这篇文章后,我有一些问题,1.我仍然对作用于 Either<L、R> 的不同方法感到困惑。例如,您使用 MapLeft、MapRight 和 Map、Reduce...因为实现本质上很复杂,而且名称非常通用,所以我不知道何时以及如何使用它们。
0赞 SuperJMN 8/7/2020
2. 您创建了 2 个类,Left 和 Right,它们似乎封装了 right 和 left(错误)值,但在上面的答案中您没有使用它们。但是,您调用 和 .我承认我对使用右/左值的不同方式感到迷茫。Either.Succes()Either.Fail()
0赞 SuperJMN 8/7/2020
3. 如果您提供一些工作示例以及您认为更适合初学者开始深入研究的类的实现,那就太棒了。是否有任何参考实现可用于遵循您的代码?此外,如果您能用完整的样本完成这篇文章,那么对于像我这样直率的人来说,开始并改进我们的设计将非常有用!Either
0赞 SuperJMN 8/7/2020
4. 您能否完成开箱即用的样品?这将真正帮助我们了解它在引擎盖下是如何工作的!非常感谢您的耐心和对软件工程的巨大贡献!
0赞 Kevin Krumwiede 12/1/2022
在这个范式中,是否应该抛出或返回类似(这是 Java 中的已检查异常)的东西?由于它源于运行时环境中的外部条件,因此它与域无关。但这也不是编程错误。IOExceptionEither
1赞 SuperJMN 6/19/2022 #2

对于那些仍然想知道的人,弗拉基米尔·霍里科夫 (Vladimir Khorikov) 的这个方便的库中有 和 (AKA) 类型。MaybeResultEither

https://github.com/vkhorikov/CSharpFunctionalExtensions

我发布这个是因为它随时可用、功能强大且设计精良。

评论

2赞 silkfire 6/19/2022
或者 Ultimate (github.com/silkfire/Ultimately),这是我几年前基于相同概念开发的 Optional 的一个分支。