如何在 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
方法将不可用。
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
方法将不可用。