#函数式编程 Functional Programming in C# [30]

6.5 Either主题的变化

  Either使我们在函数式错误处理方面有了长足的进步。 异常会导致程序 "跳出 "正常的执行流程,进入堆栈中某个任意函数的异常处理块,与此相反,Either保持了正常的程序执行流程,而是返回一个结果的表示。
  因此,Either 有很多值得喜欢的地方。还有一些可能的反对意见:

  • Left 的类型总是保持不变,那么你如何组成函数,以不同的 Left 类型返回一个Either?
  • 总是要指定两个通用参数使得代码过于冗长。
  • “Either”、“Left”和“Right”这些名字太神秘了。我们不能有更用户友好的东西吗?

  在这一节中,我将讨论这些问题,并看看如何通过对Either模式的变化来缓解这些问题。

6.5.1 在不同的错误表述之间进行转换

  正如你看到的,Map和Bind允许你改变R类型,但不允许改变L类型。尽管对错误有一个同质的表示是比较好的,但这并不总是可行的。如果你写了一个L类型总是Error的库,而其他人写了一个总是string的库,那该怎么办?你怎么能把这两者结合起来呢?
  事实证明,这可以通过Map的一个重载来解决,该重载允许你将一个函数应用于左边的值和右边的值。 这个重载接收一个Either<L,R>,然后不是一个而是两个函数:一个类型为(L -> LL)的函数,它将应用于左边的值(如果存在),另一个类型为(R -> RR)的函数将应用于右边的值:

public static Either < LL, RR > Map < L, LL, R, RR > 
	(this Either < L, R > either, Func < L, LL > left, Func < R, RR > right)
	 => either.Match < Either < LL, RR >> (
	 	l => Left(left(l)), 
	 	r => Right(right(r)));

  Map的这种变化允许你任意改变两种类型,这样你就可以在L类型不同的函数之间进行互操作。

Either < Error, int > Run(double x, double y) 
	=> Calc(x, y).Map(
		left: msg => Error(msg),
		right: d => d).Bind(ToIntIfWhole);
Either < string, double > Calc(double x, double y) //...
Either<Error, int> ToIntIfWhole(double d) //...

  最好避免噪音并坚持错误的一致表示,但不同的表示不是绊脚石。

6.5.2 特殊版本的Either

  让我们看看在 C# 中使用 Either 的其他缺点。
  首先,有两个通用参数会增加代码的噪音。例如,想象一下你想捕获多个验证错误,为此你选择IEnumerable< Error >作为你的Left类型。你最终会得到这样的签名:

public Either<IEnumerable<Error>, Rates> RefreshRates(string id) //...

  现在你必须读完三样东西(Either、IEnumerable和Error),然后才能看到最有意义的部分,即期望的返回类型率。与我们在第三章中讨论的对失败一无所知的特征相比,我们似乎已经陷入了一个相反的极端。
  第二,Either、Left和Right这些名字都太抽象了。软件开发已经够复杂了,所以我们应该尽可能选择最直观的名字。
  这两个问题都可以通过使用更专业的Either版本来解决,它有一个固定的类型来表示失败(因此有一个单一的通用参数),以及更方便用户的名称。请注意,Either的这种变化很常见,但不是标准化的。 你会发现有许多不同的库和教程,它们在术语和行为上都有各自的小变化。
  出于这个原因,我认为最好先让你彻底了解Either,它在文献中无处不在,而且很成熟,会让你掌握你可能遇到的任何变化。(然后你可以选择最适合你的表示方法,如果你愿意的话,甚至可以实现你自己的类型来表示结果)
  LaYumba.Functional 包括以下两种用于表示结果的变体:

  • Validation< T > – 你可以把它看成是一个被具体化为IEnumerable< Error >的Either:
    Validation< T > = Invalid(IEnumerable< Error >) | Valid(T)
    Validation就像一个Either,失败的情况被固定为IEnumerable< Error >,使得捕捉多个验证错误成为可能。
  • Exceptional< T > - 这里,失败被固定为System.Exception:
    Exceptional< T > = Exception | Success(T)
    Exceptional 可以作为基于异常的API和函数式错误处理之间的桥梁,你将在下一个例子中看到。

  表 6.2 并排显示了这些变化。

表 6.2 一些特殊的版本和它们的状态名称

在这里插入图片描述
  这些新的类型有比Either更友好、更直观的名字,接下来你会看到一个使用它们的例子。

6.5.3 重构到 Validation 和 Exceptional

  让我们回到用户为未来执行而预订转账的场景。以前我们用Either来模拟简单的工作流,其中包括验证和持久化 – 两者都可能失败。现在让我们看看使用更具体的Validation和Exceptional来代替执行会有什么变化。
  一个执行验证的函数自然应该产生一个Validation。在我们的情况下,它的类型将是

Validate : BookTransfer -> Validation< BookTransfer >

  因为Validation和Either一样,都是针对Error类型的,所以验证函数的实现和之前基于Either的实现是一样的,只是在签名上有所改变。下面是一个例子。

DateTime now;
Validation < BookTransfer > ValidateDate(BookTransfer cmd) {
    if (cmd.Date.Date <= now.Date) 
    	return Invalid(Errors.TransferDateIsPast); // 在无效状态下的验证中包涵一个错误
    else return Valid(cmd); // 在有效状态下将命令包裹在一个验证中。
}

  像往常一样,定义了隐式转换,所以在这个例子中你可以省略对Valid和Invalid的调用。

桥接基于异常的 API 和函数式的错误处理

  接下来,让我们看看持久化。与验证不同,此处的失败表示基础架构或配置中的故障,或其他技术错误。我们认为此类错误是例外的,因此我们可以使用 Exceptional 对其进行建模:

Save : BookTransfer -> Exceptional< Unit >

  Save 的实现可能如下所示。

清单 6.12 将基于异常的 API 转换为 Exceptional

string connString;
Exceptional < Unit > Save (BookTransfer transfer) { //返回类型确认有可能发生异常。
    try {
    	//对引发异常的第三方 API 的调用包含在 try 中。
        ConnectionHelper.Connect(connString, c => c.Execute("INSERT ...", transfer));
    } catch (Exception ex) {
        return ex; //异常被包裹在异常状态下的一个异常中。
    }
    return Unit(); //所产生的 Unit 在成功状态下被包裹在Exceptional中。
}

  请注意,try/catch的范围尽可能的小:我们想捕捉连接到数据库时可能引发的任何异常,并立即转换为函数式,将结果包裹在一个异常中。 像往常一样,隐式转换将创建一个适当的初始化的异常值。
  注意这个模式是如何让我们从一个抛出异常的第三方API变成一个函数式 API的,其中错误被当作有效载荷来处理,错误的可能性反映在返回类型中。

失败的验证(Validation)和技术错误(Exception)应该以不同的方式处理
  使用 Validation 和 Exceptional 的好处在于它们具有不同的语义内涵:

  • Validation 表明违反了某些业务规则。
  • Exception 表示一个意外的技术错误。

  我们现在要看一下,使用这些不同的表示方法如何让我们适当地处理每个案例。我们仍然需要结合验证和持久化;这在Handle中完成:

public class BookTransferController: Controller {
    Validation < Exceptional < Unit >> Handle(BookTransfer cmd) 
    	=> Validate(cmd) // 结合验证和持久性
    		.Map(Save);
    Validation < BookTransfer > Validate(BookTransfer cmd) 
    	=> ValidateBic(cmd) //顶层验证功能,结合各种验证
    		.Bind(ValidateDate);
    Validation < BookTransfer > ValidateBic(BookTransfer cmd) // ...   
    Validation <BookTransfer> ValidateDate(BookTransfer cmd) // ...   
    Exceptional <Unit> Save(BookTransfer cmd) // ...
}

  因为Validate返回一个Validation,而Save返回一个Exceptional,我们不能用Bind来组成这些类型。但是没关系:我们可以用Map代替,最后得到Validation<Exceptional< Unit >>这个返回类型。这是一个嵌套的类型,表达了我们将验证的效果(也就是说,我们可能会得到验证错误而不是所需的返回值)与处理异常的效果(也就是说,即使验证通过后,我们可能会得到一个异常而不是返回值)结合起来。
  因此,Handle通过 "堆叠 "两个单体效应,承认操作可能因业务原因和技术原因而失败。图6.5说明了在这两种情况下,我们是如何通过把错误作为payload的一部分来表达的。
在这里插入图片描述
  为了完成端到端的方案,我们只需要添加入口点。在这里,控制器接收来自客户端的BookTransfer命令,调用之前定义的Handle,并将结果Validation<Exceptional< Unit >>转换为一个结果送回给客户端(见清单6.13)。

清单 6.13 对验证错误和异常错误的不同处理方法

public class BookTransferController: Controller {
    ILogger < BookTransferController > logger;
    [HttpPost, Route("api/transfers/book")]
    public IActionResult BookTransfer([FromBody] BookTransfer cmd) //解开验证中的值
    	=> Handle(cmd).Match(
    		Invalid: BadRequest,  //如果验证失败,则发送 400
    		Valid: result => result.Match( //解开 Exceptional 中的值
    			Exception: OnFaulted, //如果持久化失败,则发送 500
    			Success: _ => Ok()));
    			
    IActionResult OnFaulted(Exception ex) {
        logger.LogError(ex.Message);
        return StatusCode(500, Errors.UnexpectedError);
    }
    Validation < Exceptional < Unit >> Handle(BookTransfer cmd) //...
}

  这里我们使用两个嵌套的Match调用,首先解开Validation里面的值,然后解开Exceptional里面的值。

  • 如果验证失败,我们将发送一个400,其中将包括验证错误的全部细节,以便用户可以解决这些问题。
  • 另一方面,如果持久化失败,我们不想把细节发送给用户。取而代之的是,我们返回一个带有更多通用错误类型的500;这也是一个记录异常的好地方。

  正如你所看到的,每个相关函数的明确返回类型允许你明确区分和定制你如何处理与业务规则相关的故障和与技术问题相关的故障。
  总之,Either为你提供了一种显式的、函数式的方法来处理错误,而不会产生副作用(与抛出/捕获异常不同)。但正如我们相对简单的银行方案所说明的那样,使用Either的专门版本,如Validation和Exceptional,可以带来更有表现力和可读的实现。

6.5.4 抛开异常?

  在本章中,你已经对函数式错误处理背后的思想有了扎实的了解。你可能觉得这与基于异常的方法有很大的不同,事实上也是如此。
  我提到,抛出异常会扰乱正常的程序流程,引入副作用。更实际的是,它使你的代码更难维护和推理:如果一个函数抛出了一个异常,分析它对应用程序的影响的唯一方法就是沿着所有可能的代码路径进入该函数,然后在堆栈中寻找第一个异常处理程序。在函数式错误处理中,错误只是函数返回类型的一部分,所以你仍然可以孤立地推理函数的情况。
  意识到使用异常的不利影响后,Go、Elixir 和 Elm 等几种较年轻的编程语言已经接受了错误应该简单地被视为值的想法,因此与 throw 和 try/catch 语句的等价物只在非常少的情况下使用(Elixir),或者完全没有出现在语言中 (Go, Elm)。 C# 包含异常的事实并不意味着您需要使用它们来处理错误;相反,您可以在应用程序中使用函数式错误处理,并使用适配器函数将调用结果转换为基于异常的 API 之类的东西,如前面所示。
  有没有例外仍然有用的情况?我相信是这样:

  • 开发者的错误 —— 例如,如果你试图从一个空列表中删除一个项目,或者如果你把一个空值传递给一个需要该值的函数,那么该函数或列表的实现抛出一个异常就很正常。异常从来不是为了在调用代码中被捕获和处理;它们表明应用逻辑是错误的。
  • 配置错误 —— 例如,如果一个应用程序依靠消息总线与其他系统连接,并且在没有连接的情况下不能有效地执行任何有用的任务,那么在启动时未能连接到总线应该导致一个异常。同样的情况也适用于配置的关键部分,如数据库连接,被遗漏。 这些异常应该只在初始化时抛出,而且不应该被捕获(除了可能在最外层的应用程序范围的处理程序中),但应该正确地导致应用程序崩溃。

总结

  • 使用Either来表示一个有两种不同可能结果的操作的结果,通常是成功或失败。一个Either可以处于两种状态之一:

    • Left 表示失败,包含不成功的操作的错误信息。
    • Right 表示成功并包含成功操作的结果
  • 使用与 Option 已经看到的核心功能的等效项进行交互:

    • Map 和 Bind 应用映射/绑定函数,如果 Either 处于正确状态;否则他们只会传递 Left 值。
    • Match 的工作方式与 Option 的工作方式类似,允许您以不同的方式处理 Right 和 Left 情况。
    • Where 不太适用,因此应使用 Bind 代替它进行过滤,同时提供合适的 Left 值。
  • Either 对于将多个验证函数与 Bind 组合起来特别有用,或者更一般地说,对于组合多个操作(每个操作都可能失败)特别有用。

  • 因为 Either 相当抽象,而且由于它的两个泛型参数的语法开销,在实践中最好使用Either 的特定版本,例如Validation 和Exceptional。

  • 使用 functors 和 monad 时,更喜欢使用抽象中的函数,例如 Map 和 Bind。尽可能少或尽可能晚地使用向下交叉的匹配函数。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值