提问人:SuperJMN 提问时间:8/3/2020 最后编辑:SuperJMN 更新时间:10/1/2022 访问量:15861
如何在 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
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 类和一个包含Success
int value
额外
非常有趣的是,可以包含评价过程中可能发生的所有问题的摘要。此行为非常适合向调用方提供有关失败的详细信息。不仅是第一次失败的操作,还有随后的失败。我在语义分析中想到了编译器。我不希望舞台在检测到的第一个错误上得到救助,而是收集所有问题以获得更好的体验。Failure
答:
任一类型基础知识
这两种类型都来自函数式语言,其中异常(理所当然地)被视为副作用,因此不适合传递域错误。请注意不同类型错误之间的区别:其中一些错误属于域,而另一些则不属于域。例如,空引用异常或索引越界与域无关 - 它们表示存在缺陷。
两者都定义为具有两个分支的泛型类型 - 成功和失败: 。它可以以两种形式出现,其中它包含 的对象 ,或者它包含 的对象。它不能同时出现在两种状态中,也不能同时出现在任何一种状态中。因此,如果拥有 Either 实例,则它要么包含成功生成的结果,要么包含错误对象。Either<TResult, TError>
TResult
TError
任一和例外
在异常表示对域很重要的事件的情况下,这两种类型都会替换异常。但是,它不会替换其他方案中的异常。
关于异常的故事很长,从不需要的副作用到简单的漏洞抽象。顺便说一句,泄漏的抽象是 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)
f
Either<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
在这方面,该方法可以是附加到或类型的扩展方法,以便在可以组合错误的情况下(不显眼地!)。在所有其他情况下,当错误类型不是列表时,该方法将不可用。Combine
Either<TResult, List<TError>>
Either<TReuslt, ImmutableList<TError>>
Combine
评论
Either.Succes()
Either.Fail()
Either
IOException
Either
对于那些仍然想知道的人,弗拉基米尔·霍里科夫 (Vladimir Khorikov) 的这个方便的库中有 和 (AKA) 类型。Maybe
Result
Either
https://github.com/vkhorikov/CSharpFunctionalExtensions。
我发布这个是因为它随时可用、功能强大且设计精良。
评论
Either
Either
Either