C--函数式编程第二版-全-

C# 函数式编程第二版(全)

原文:Functional Programming in C# 2e

译者:飞龙

协议:CC BY-NC-SA 4.0

前置材料

前言

函数式编程(FP)已成为主流编程中一个重要且令人兴奋的部分。在 2010 年代创建的大多数新语言和框架都是函数式的,导致一些人预测编程的未来将是函数式的。与此同时,像 C#和 Java 这样的流行面向对象(OO)语言在每次新版本中都引入了更多函数式特性,使得多范式编程风格成为可能。然而,在 C#社区中的采用速度却很慢。为什么是这样呢?我认为其中一个原因是缺乏优秀的文献:

  • 大多数函数式编程文献是用函数式语言编写的,尤其是 Haskell。对于有面向对象(OOP)背景的开发者来说,这构成了学习函数式编程概念的语言障碍。尽管许多概念适用于像 C#这样的多范式语言,但一次学习一个新的范式和一种新语言是一项艰巨的任务。

  • 更重要的是,文献中的大多数书籍倾向于用数学或计算机科学领域的例子来说明函数式技术和概念。对于大多数日复一日从事业务线(LOB)应用开发的程序员来说,这造成了领域差距,并让他们怀疑这些技术对于现实世界应用的相关性。

这些缺点在我的函数式编程学习之路上设置了巨大的障碍。在翻阅了第 n 本解释所谓的柯里化(通过展示如何使用数字 3 对add函数进行柯里化,创建一个可以将 3 加到任何数字上的函数——你能想到任何这种应用可能有一点点用处的场景吗?)的书之后,我决定走自己的研究之路。这包括学习半打函数式语言,并尝试找出哪些函数式编程的概念可以有效地应用于 C#以及大多数开发者被支付编写的那种应用。我的研究最终导致了这本书的撰写。

本书通过展示如何在 C#语言中利用函数式技术,为 C#开发者架起了语言差距的桥梁。它还通过展示如何将函数式技术应用于典型的商业场景,弥合了领域差距。我采取了一种实用主义的方法,并涵盖了在典型的 LOB 应用场景中有用的函数式技术,摒弃了大多数函数式编程背后的理论。最终,你应该关注函数式编程,因为它为你提供了以下:

  • 力量——这仅仅意味着你可以用更少的代码完成更多的工作。函数式编程提高了抽象级别,允许你编写高级代码,同时让你摆脱那些增加复杂性但无价值的底层技术细节。

  • 安全性——函数式编程不倾向于状态突变。这意味着以函数式风格编写的程序不太可能进入无效状态。此外,对状态突变采取保守的方法在处理并发时极为有益。以命令式风格编写的程序在单线程实现中可能运行良好,但在并发出现时可能会引发各种错误。函数式代码在并发场景中提供了更好的保证,因此在多核处理器时代,我们对函数式编程的兴趣激增是自然而然的事情。

  • 清晰度——我们花费更多的时间维护和消费现有代码,而不是编写新代码,因此我们的代码必须是清晰且意图明显的。随着您学习以函数式方式思考,实现这种清晰度会变得更加自然。

如果您已经以面向对象的方式编程了一段时间,那么在本书中的概念得以实现之前,可能需要一些努力和实验的意愿。为了确保学习函数式编程是一个愉快且有益的过程,我有两个建议:

  • 耐心——您可能需要多次阅读某些部分。您可能放下这本书几周,然后发现再次拿起它时,之前看似晦涩的内容突然开始变得有意义。

  • 在代码中进行实验。除非您亲自动手,否则您不会学到东西。本书提供了许多示例和练习,许多代码片段可以在 REPL 中进行测试。

您的同事可能不如您那样渴望探索。预期他们会抗议您采用这种新风格,并困惑地看着您的代码,说出诸如,“为什么不直接做x?”(其中x是无聊的、过时的,通常是有害的)之类的话。不要讨论。只需坐下来观察他们最终会转而使用您的技术来解决他们反复遇到的问题。

致谢

我想感谢保罗·劳斯,他不仅通过他的语言扩展库(我从其中借鉴了许多好想法)提供了灵感,而且在各个阶段慷慨地审阅了本书。

曼宁出版社的严谨编辑流程确保了本书的质量远远优于如果让我自行处理的情况。为此,我想感谢参与本书的团队,包括迈克·斯蒂普斯,发展编辑玛琳娜·迈克尔斯,技术编辑雷内·范登伯格,项目经理迪尔德丽·希姆,以及校对员梅洛迪·多拉布。

特别感谢丹尼尔·马巴赫和塔米尔·德谢舍尔对技术洞察的贡献,以及所有参与同行评审的人,包括大卫·帕库德、福斯特·海恩斯、乔治·奥诺弗雷、戈特·赫尔勒、奥利弗·弗拉尔、杰里米·凯尼、肯特·斯皮尔纳、马特·范·温克尔、耶迪吉亚·博里索、马克·埃尔斯顿、纳吉布·阿里夫、奥利弗·科滕和罗伯特·威尔克。

感谢斯科特·沃尔申(Scott Wlaschin)在fsharpforfunandprofit.com分享他的文章,以及许多其他 FP 社区的成员,他们通过文章、博客和开源项目分享他们的知识和热情。

关于本书

本书旨在展示如何利用 C#中的函数式技术编写简洁、优雅、健壮和可维护的代码。

谁应该阅读这本书?

这本书是为一群有雄心的开发者编写的。你了解.NET 和 C#或类似的语言,如 Java、Swift 或 Kotlin。你有开发现实世界应用的经验,并且熟悉 OOP 概念、模式和最佳实践。但是,你希望通过学习函数式技术来扩展你的工具箱,以便充分利用 C#作为多范式语言。

如果你正在尝试或计划学习一种函数式语言,这本书也将非常有价值,因为你在熟悉的语言中学习如何以函数式的方式思考。改变你的思维方式是难点;一旦实现,学习任何特定语言的语法就相对容易了。

本书是如何组织的:路线图

本书由 19 章组成,分为 4 部分:

  • 第一部分涵盖了函数式编程的基本原理。我们将从了解什么是函数式编程以及 C#如何支持函数式编程风格开始。然后,我们将探讨高阶函数的力量和纯函数的重要性。到第一部分结束时,你将具备概念和实践工具,以便开始学习更具体的函数式技术。

  • 第二部分展示了函数式技术的实际应用:如何设计类型和函数签名,以及简单函数如何组合成复杂程序。到第二部分结束时,你将很好地了解以函数式风格编写的程序看起来是什么样子,以及这种风格所能带来的好处。

  • 在掌握了这些基本概念之后,我们将在第三部分加快速度,转向更广泛的问题,如函数式错误处理、模块化和组合应用程序,以及理解状态和表示变化的函数式方法。到第三部分结束时,你将掌握一套工具,使你能够有效地使用函数式方法处理许多编程任务。

  • 第四部分探讨了更高级的主题,包括惰性求值、有状态计算、异步、数据流和并发。第四部分的每一章都介绍了可能完全改变你编写和思考软件方式的关键技术。

你可以在封面的前部分找到每个章节的更详细的主题分解和表示,以及阅读任何特定章节之前需要阅读的章节。

为现实世界应用编码

本书旨在贴近现实场景。为此,许多例子涉及实际任务,如读取配置、连接数据库、验证 HTTP 请求等——这些可能是你已经知道如何做的,但你会从函数式思维的新视角看到它们。

在整本书中,我使用一个长期运行的例子来说明函数式编程(FP)在编写 LOB 应用程序时如何提供帮助。为此,我选择了虚构的 Codeland 银行的在线银行应用程序(BOC)——我知道这听起来很俗气,但至少它有一个必要的三个字母的缩写。因为大多数人都能访问在线银行服务,所以应该很容易想象所需的功能,并看到讨论的问题如何与实际应用相关。

我使用几个其他场景来说明如何以函数式风格解决典型的编程问题。希望这种在实用示例和 FP 概念之间的不断往返,能够帮助弥合理论与实践之间的差距,这是我发现在现有的文献中有所欠缺的。

利用函数式库

类似于 C#这样的语言可能具有函数式特性,但要充分利用这些特性,你通常会使用库来简化常见任务。在这本书中,你将了解

  • System.Linq——是的,如果你不知道的话,它是一个函数式库!鉴于它是.NET 如此重要的一个部分,我假设你熟悉它。

  • System.Collections.Immutable——这是一个不可变集合库,我们将在第十一章开始使用。

  • System.InteractiveSystem.Reactive——这些库(你可能知道它们是.NET 的交互式扩展响应式扩展)允许你处理数据流,我们将在第十六章和第十八章中讨论。

这就排除了许多其他重要的类型和函数,它们是 FP 的基石。因此,一些独立开发者编写了库来填补这些空白。到目前为止,最完整的是由 Paul Louth 编写的 language-ext 库,它旨在改善 C#开发者以函数式编程方式编码时的体验。¹

在本书中,我不直接使用 language-ext;相反,我会向你展示我是如何开发自己的函数式实用程序库的,这个库叫做LaYumba.Functional,尽管它与 language-ext 有很大重叠。这有几个教学上的原因更有用:

  • 代码在本书出版后将会保持稳定。

  • 你有机会深入了解并看到那些强大的函数式结构定义起来竟然如此简单。

  • 你可以专注于本质。我会以最纯粹的形式向你展示这些结构,这样你就不会被一个完整库处理的细节和边缘情况所分散注意力。

关于代码

《C#函数式编程》的第二版使用 C# 10 和.NET 6。² 许多(如果不是全部)技术可以应用于语言的前版本,尽管这样做通常需要额外的输入。附录具体说明了如何在早期版本的 C#中使用不可变数据和模式匹配,这些早期版本的 C#不将这些作为语言特性。

您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,请访问livebook.manning.com/book/functional-programming-in-c-sharp-second-edition。您可以在 REPL 中执行许多较短的代码片段,从而获得即时的实践反馈。更长的示例可以在github.com/la-yumba/functional-csharp-code-2下载,包括练习的设置和解决方案。您还可以从www.manning.com/books/functional-programming-in-c-sharp-second-edition下载本书的源代码。

书中的代码列表专注于讨论的主题,因此可能会省略using语句、命名空间声明、平凡的构造函数或之前列表中出现的且未更改的代码部分。如果您想看到列表的完整、可编译版本,您可以在代码仓库中找到它。

liveBook 讨论论坛

购买《C#函数式编程 第二版》包括对 Manning 的在线阅读平台 liveBook 的免费访问。使用 liveBook 的独特讨论功能,您可以在全球范围内或针对特定章节或段落添加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/functional-programming-in-c-sharp-second-edition/welcome/v-7/。您还可以在livebook.manning.com/#!/discussion了解更多关于 Manning 论坛和行为准则的信息。

Manning 对我们读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

Enrico Buonanno 于 2001 年从哥伦比亚大学获得计算机科学硕士学位,自那时起一直担任软件开发人员和架构师。他为金融科技领域的知名公司(包括国际清算银行、巴克莱银行和瑞士信贷)以及其他技术驱动型业务处理了关键任务项目。


¹ language-ext 是开源的,可在 GitHub 和 NuGet 上获取:github.com/louthy/language-ext

C# 10 和 .NET 6 在撰写本文时仍处于预览阶段,因此可能存在一些差异。这些差异将在本书的相关部分中指出。

第一部分. 入门

在这部分,我将向你介绍函数式编程的基本技术和原则。

第一章首先探讨什么是函数式编程以及 C#如何支持以函数式风格进行编程。

第二章展示了函数在 C#中的表示方式,然后深入探讨了高阶函数,这是函数式编程的基本技术。

第三章解释了什么是纯函数,为什么纯度对函数的可测试性有重要影响,以及为什么纯函数非常适合并行化和其他优化。

在这些入门章节中,我试图利用你可能已有的现有知识(尤其是关于 LINQ 和单元测试的知识)来展示函数式编程原则的实际应用。

到第一部分的结尾,你将清楚地了解什么是函数式编程,以及 C#的哪些特性使你能够以函数式风格进行编码,你还将开始看到函数式编程所能带来的好处。

1 介绍函数式编程

本章涵盖

  • 函数式编程的好处和原则

  • C# 语言的函数式特性

  • 使用记录和模式匹配进行类型驱动程序

函数式编程(FP)是一种编程范式:一种不同于你可能习惯的主流命令式范式的思考程序的方式。因此,学习以函数式思考是具有挑战性的,但也是非常有益的。我的目标是,在阅读这本书之后,你将永远以不同的眼光看待代码!

学习过程可能是一段颠簸的旅程。你可能会从对看似晦涩或无用的概念感到沮丧,到当你脑海中有所领悟,能够用几行优雅的函数式代码替换一大堆命令式代码时的兴奋。本章将解答你在开始这段旅程时可能有的疑问。函数式编程究竟是什么?我为什么要关心?我能在 C#中进行函数式编程吗?这值得付出努力吗?

1.1 这个东西叫什么函数式编程?

函数式编程(FP)究竟是什么?从高层次来看,它是一种强调函数同时避免状态变化的编程风格。这个定义已经包含两个基本概念:

  • 函数作为一等值

  • 避免状态变化

让我们看看这些意味着什么。

在 REPL 中运行代码片段

随着你阅读本章和书中的代码片段,我鼓励你在 REPL 中输入它们。REPL(读取-评估-打印-循环)是一个命令行界面,允许你通过输入语句来实验语言,并立即获得反馈。你可能想尝试我展示的示例的一些变体;通过实际代码的实验来弄脏你的手将让你更快地学习。

如果你使用 Visual Studio,可以通过转到视图 > 其他窗口 > C#交互式来启动 REPL。或者,你可以使用 LINQPad。不幸的是,在撰写本文时,这些选项仅在 Windows 上可用。在其他操作系统上,你可以使用csi命令,尽管它不是功能最丰富的。

1.1.1 函数作为一等值

在一个将函数作为一等值的语言中,你可以将它们用作其他函数的输入或输出,你可以将它们赋值给变量,并将它们存储在集合中。换句话说,你可以对函数执行所有可以对任何其他类型的值执行的操作。

例如,将以下列表的内容输入到 REPL 中。

列表 1.1 使用函数作为一等值的简单示例

var triple = (int x) => x * 3;             ❶

var range = Enumerable.Range(1, 3);        ❷

var triples = range.Select(triple);        ❸

triples // => [3, 6, 9]

❶ 定义一个返回给定整数三倍的函数

❷ 创建一个包含值 [1, 2, 3] 的列表

❸ 将 triple 应用到范围内的所有值

在这个例子中,你调用SelectIEnumerable上的扩展方法),给它传递范围和triple函数作为参数。这创建了一个新的IEnumerable,它包含通过将triple函数应用于输入范围中的每个元素而获得的所有元素。

注意,在 C# 10 之前,你需要像这样显式声明triple的委托类型:

[source,csharp]

Func<int, int> triple = x => x * 3;

列表 1.1 中的代码演示了函数在 C#中确实是第一等值,因为你可以将乘以 3 的函数分配给变量triple,并将其作为Select的参数。在整个书中,您将看到将函数视为值可以使您编写强大而简洁的代码。

1.1.2 避免状态突变

如果我们遵循函数式范式,我们应该完全避免状态突变:一旦创建,对象永远不会改变,变量也不应该被重新分配(因此,实际上,它们在名称上只是变量)。术语突变表示值在原地被更改,更新存储在内存中的某个位置的价值。例如,以下代码创建并填充了一个数组,然后它原地更新了数组中的一个值:

int[] nums = { 1, 2, 3 };
nums[0] = 7;

nums // => [7, 2, 3]

这种更新也被称为破坏性更新,因为更新之前存储的值被破坏。在函数式编程中,应该始终避免这种更新。(纯函数式语言根本不允许原地更新。)

依据这个原则,排序或过滤列表不应该原地修改列表,而应该创建一个新的、适当过滤或排序的列表,而不影响原始列表。将以下列表中的代码输入到 REPL 中,以查看使用 LINQ 的WhereOrderBy函数排序或过滤列表时会发生什么。

列表 1.2 函数式方法:WhereOrderBy创建新的列表

var isOdd = (int x) => x % 2 == 1;
int[] original = { 7, 6, 1 };

var sorted = original.OrderBy(x => x);
var filtered = original.Where(isOdd);

original // => [7, 6, 1]      ❶
sorted   // => [1, 6, 7]      ❷
filtered // => [7, 1]         ❷

❶ 原始列表不受影响。

❷ 排序和过滤产生了新的列表。

如您所见,原始列表不受排序或过滤操作的影响,这些操作产生新的IEnumerables。让我们看看以下列表中的反例。如果你有一个数组,你可以通过调用它的Sort方法原地对其进行排序。

列表 1.3 非函数式方法:List<T>.Sort原地排序列表

int[] original = { 5, 7, 1 };
Array.Sort(original);

original // => [1, 5, 7]

在这种情况下,排序后,原始顺序被破坏。我们将在下面看到这是为什么有问题。

注意:你在.NET 库中看到函数式和非函数式方法的原因是历史性的:Array.Sort早于 LINQ,LINQ 标志着向函数式方向的一个决定性转变。

1.1.3 编写具有强保证的程序

在我们刚刚讨论的两个概念中,函数作为一等值最初似乎更有趣,我们将在第二章集中讨论它。但在我们继续之前,我想简要说明避免状态突变为什么也非常有益——它消除了由可变状态引起的许多复杂性。

让我们来看一个例子。(我们将在更详细地回顾这些主题,所以如果你现在对某些内容不清楚,请不要担心。)将以下列表中的代码输入到 REPL 中。

列表 1.4 从并发过程中修改状态

using static System.Linq.Enumerable;                 ❶
using static System.Console;                         ❶

var nums = Range(-10000, 20001).Reverse().ToArray();
// => [10000, 9999, ... , -9999, -10000]

var task1 = () => WriteLine(nums.Sum());
var task2 = () => { Array.Sort(nums); WriteLine(nums.Sum()); };

Parallel.Invoke(task1, task2);                       ❷
// prints: 5004 (or another unpredictable value)
//         0

❶ 允许你无需完全限定即可调用 RangeWriteLine

❷ 并行执行两个任务

在这里,你定义 nums 为介于 10,000 和 -10,000 之间的所有整数的数组;它们的总和显然应该是 0。然后你创建两个任务:

  • task1 计算并打印总和。

  • task2 首先对数组进行排序,然后计算并打印总和。

如果独立运行,每个任务都能正确地计算总和。但是,当你并行运行这两个任务时,task1 得出的结果是不正确且不可预测的。这很容易理解。当 task1 读取数组中的数字以计算总和时,task2 正在重新排列数组中的元素。这有点像在别人翻页的同时阅读一本书:你会读到一些混乱的句子!这在图 1.1 中有图形表示。

图 1.1 就地修改数据可能会给并发线程提供一个错误的数据视图。

如果我们使用 LINQ 的 OrderBy 方法,而不是就地排序列表,会怎样呢?让我们来看一个例子。

var task3 = () => WriteLine(nums.OrderBy(x => x).Sum());
Parallel.Invoke(task1, task3);

// prints: 0
//         0

如你所见,使用 LINQ 的函数式实现即使在并行执行任务时也能给出可预测的结果。这是因为 task3 并没有修改原始数组,而是创建了一个完全新的数据视图,该视图已排序:task1task3 并发地从原始数组中读取,但并发读取不会导致任何不一致,如图 1.2 所示。

图 1.2 函数式方法:创建原始结构的新的修改版本

这个简单的例子说明了更广泛的真理:当开发者在命令式风格(显式地修改程序状态)编写应用程序,后来引入并发(由于新需求或提高性能的需要)时,他们不可避免地会面临大量工作,并可能遇到一些难以解决的错误。如果一个程序从一开始就是以函数式风格编写的,那么并发通常可以免费添加或以大大减少的努力来实现。我们将在第三章和第十一章中更详细地讨论状态修改和并发。现在,让我们回到我们对函数式编程(FP)的概述。

尽管大多数人都会同意将函数视为一等值并避免状态修改是函数式编程(FP)的基本原则,但它们的运用引发了一系列实践和技术,因此关于哪些技术应该被认为是基本并包含在本书中的讨论是有争议的。我鼓励你对此问题采取实用主义的方法,并尝试将 FP 理解为 一套工具,你可以使用这些工具来解决你的编程任务。随着你学习这些技术,你将开始从不同的角度看待问题:你将开始以函数式的方式思考。

现在我们已经有了函数式编程的工作定义,让我们来看看 C#语言本身及其对函数式编程技术的支持。

函数式与面向对象?

我经常被要求比较和对比函数式编程与面向对象编程(OOP)。这并不简单,主要是因为对 OOP 应该是什么样子的假设存在冲突。

理论上,面向对象编程(OOP)的基本原则(封装、数据抽象等)与函数式编程的原则是正交的,因此没有理由认为这两种范式不能结合。

然而,在实践中,大多数面向对象(OO)开发者在其方法实现中严重依赖命令式风格,就地修改状态并使用显式控制流;他们在大型设计中使用面向对象设计,在小型设计中使用命令式编程。真正的问题是命令式与函数式编程之间的对比。

另一个有趣的问题是函数式编程(FP)与面向对象编程(OOP)在构建大型、复杂应用程序方面的区别。构建复杂应用程序的困难艺术依赖于以下原则。它们通常是有效的,无论所讨论的组件是函数、类还是应用程序:

  • 模块化——软件应由离散、可重用的组件组成。

  • 关注点分离——每个组件应该只做一件事。

  • 分层——高级组件可以依赖于低级组件,但反之则不然。

  • 松耦合——一个组件不应该了解它所依赖的组件的内部细节;因此,对组件的更改不应影响依赖于它的组件。

这些原则也绝不是面向对象的特定,因此可以使用相同的原理来构建函数式风格的应用程序。区别将在于组件是什么以及它们公开的 API。在实践中,对纯函数(我们将在第三章中讨论)和可组合性(第七章)的函数式强调使实现某些设计目标变得容易得多.^a

^a 关于为什么命令式风格的面向对象编程是(而不是解决)程序复杂性的原因的更深入讨论,请参阅本·莫斯利和彼得·马克斯(2006 年 11 月)撰写的文章“Out of the Tar Pit”,网址为mng.bz/xXK7

1.2 C#语言有多函数式?

函数确实是 C#中的一等值,如前文示例所示。实际上,C#从语言的最早期版本开始就支持函数作为一等值,通过Delegate类型,随后引入的 lambda 表达式使语法支持更加完善。我们将在第二章中回顾这些语言特性。

在类型推断方面,有一些怪癖和限制,我们将在第十章中讨论,但总的来说,对函数作为一等值的支持相当不错。

至于支持避免原地更新的编程模型,这个领域的根本要求是语言必须具备垃圾回收功能。因为你创建了现有数据结构的修改版本,而不是在原地更新它们的值,所以你希望旧版本在需要时被垃圾回收。同样,C# 满足了这一要求。

理想情况下,语言还应该阻止原地更新。长期以来,这是 C# 最大的不足:默认情况下所有内容都是可变的,而且没有简单的方法来定义不可变类型,这在函数式编程风格中是一个障碍。但随着 C# 9 中记录的引入,这一切都发生了改变。正如你在 1.2.3 节中将会看到的,记录允许你定义自定义的不可变类型,而不需要任何样板代码;实际上,定义记录比定义“普通”类还要简单。

由于随着时间的推移添加了这些特性,C# 9 为许多函数式技术提供了良好的语言支持。在这本书中,你将学习如何利用这些特性,并解决任何不足。接下来,我们将回顾一些与 FP 特别相关的 C# 语言特性。

1.2.1 LINQ 的函数式特性

当 C# 3 发布时,伴随着 .NET Framework 3.5 版本的推出,它包含了一系列受函数式语言启发的特性,包括 LINQ 库 (System.Linq) 以及一些新的语言特性,这些特性使得你可以使用 LINQ 做更多的事情。这些特性包括扩展方法、lambda 表达式和表达式树。

LINQ 确实是一个函数式库(正如你可能注意到的,我之前使用 LINQ 来说明 FP 的两个原则)。随着你在这本书中的进展,LINQ 的函数式特性将变得更加明显。

LINQ 为列表(或更普遍地,对“序列”,因为 IEnumerable 的实例在技术上应该被称为)上的许多常见操作提供了实现,其中最常见的是映射、排序和过滤,请参阅“序列的常见操作”侧边栏。以下是一个结合所有三个操作的示例:

Enumerable.Range(1, 100).
   Where(i => i % 20 == 0).
   OrderBy(i => -i).
   Select(i => $"{i}%")
// => ["100%", "80%", "60%", "40%", "20%"]

注意到 WhereOrderBySelect 都接受函数作为参数,并且不会修改给定的 IEnumerable,而是返回一个新的 IEnumerable。这说明了你之前看到的 FP 的两个原则。

LINQ 不仅简化了内存中对象的查询(LINQ to Objects),还简化了各种其他数据源,如 SQL 表和 XML 数据。C# 程序员已经将 LINQ 作为处理列表和关系数据的标准工具集(这占典型代码库中相当大的比例)。从积极的一面来看,这意味着你将已经对函数式库的 API 有了一定的了解。

另一方面,当与其他类型一起工作时,C# 程序员通常坚持使用流程控制语句的命令式风格来表示程序的预期行为。因此,我所看到的多数 C# 代码库都是函数式风格(当处理 IEnumerableIQueryable 时)和命令式风格(当处理其他所有内容时)的混合体。

这意味着,尽管 C# 程序员了解使用 LINQ 等函数式库的好处,但他们并没有足够接触 LINQ 的设计原则,以在他们的设计中利用这些技术。这正是本书旨在解决的问题。

序列上的常见操作

LINQ 库包含许多执行常见序列操作的方法,如下所示:

  • 映射——给定一个序列和一个函数,映射会产生一个新的序列,其元素是通过将给定的函数应用于原序列中的每个元素获得的(在 LINQ 中,这是通过 Select 方法实现的):

    Enumerable.Range(1, 3).Select(i => i * 3) // => [3, 6, 9]
    
  • 过滤——给定一个序列和一个谓词,过滤会产生一个新的序列,包含所有满足谓词的原序列元素(在 LINQ 中,这是通过 Where 实现的):

    Enumerable.Range(1, 10).Where(i => i % 3 == 0) // => [3, 6, 9]
    
  • 排序——给定一个序列和一个键选择函数,排序会产生一个序列,其中原序列的元素按键排序(在 LINQ 中,这是通过 OrderByOrderByDescending 实现的):

    Enumerable.Range(1, 5).OrderBy(i => -i) // => [5, 4, 3, 2, 1]
    

1.2.2 编程函数式的简写语法

C# 6、C# 7 和 C# 10 并不是革命性的发布,但它们包含了许多较小的语言特性,这些特性结合起来提供了更符合语法的语法,从而为函数式编程提供了更好的体验。以下列表展示了这些特性的一些示例。

列表 1.5 与函数式编程相关的 C# 习惯用法

using static System.Math;                       ❶

public record Circle(double Radius)
{
   public double Circumference                  ❷
      => PI * 2 * Radius;                       ❷

   public double Area
   {
      get
      {
         double Square(double d) => Pow(d, 2);  ❸
         return PI * Square(Radius);
      }
   }
}

❶ 使用 using static 允许无限定访问 System.Math 的静态成员,如 PIPow

❷ 表达式属性

❸ 局部函数是在另一个方法中声明的函数。

使用 using static 指令导入静态成员

C# 6 中引入的 using static 指令允许你导入一个类的静态成员(在列表 1.5 中,是 System.Math 类)。因此,在这个例子中,你可以直接调用 MathPIPow 成员,无需进一步限定:

using static System.Math;

public double Circumference
   => PI * 2 * Radius;

这为什么重要呢?在函数式编程(FP)中,我们更喜欢那些行为仅依赖于输入参数的函数,因为我们可以独立地推理和测试这些函数(与实现通常与实例变量交互的实例方法形成对比)。这些函数在 C# 中实现为静态方法,因此 C# 中的函数式库主要由静态方法组成。

using static 允许你更轻松地使用此类库。在 C# 10 中,这一点尤为明显,其中 global using static 允许你在整个项目中提供函数。尽管过度使用这些指令可能导致命名空间污染,但合理使用可以使代码更清晰、易读。

使用表达式体成员的更简洁的函数

我们使用=>引入的表达式体来声明Circumference属性,而不是使用通常的由花括号包围的语句体

public double Circumference
   => PI * 2 * Radius;

注意这与列表 1.5 中的Area属性相比要简洁得多!

在 FP 中,我们倾向于编写许多简单的函数,其中许多是一行代码,然后将这些函数组合成更复杂的流程。表达式体方法允许你以最小的语法噪声来完成这项工作。当你想要编写一个返回函数的函数时,这一点尤为明显——你将在本书中看到很多这种情况。

表达式体语法是在 C# 6 中为方法和属性获取器引入的。在 C# 7 中,它被推广到也适用于构造函数、析构函数、获取器和设置器。

在函数内声明函数

编写许多简单的函数意味着许多函数只从一个位置调用。C# 允许你通过在另一个函数的作用域内声明一个函数来明确这一点。实际上有两种方法可以做到这一点;我偏爱的方法是使用委托:

[source,csharp]

get
{
   var square = (double d) => Pow(d, 2);
   return PI * square(Radius);
}

此代码使用 lambda 表达式来表示函数,并将其分配给square变量。(在 C# 10 中,编译器推断square的类型为Func<double, double>,因此你可以使用var关键字来声明它。)我们将在第二章中更深入地探讨 lambda 表达式和委托。

另一种可能性是使用局部函数,这实际上是在方法内部声明的函数——这是一个在 C# 7 中引入的特性。

get
{
   double Square(double d) => Pow(d, 2);
   return PI * Square(Radius);
}

由于这个原因,lambda 表达式和局部函数都可以引用封装作用域内的变量,编译器实际上为每个局部函数生成一个类。为了减轻可能的影响,如果局部函数不需要访问封装作用域中的变量,就像在这个例子中一样,C# 8 允许你将局部函数声明为static,如下所示:

static double Square(double d) => Pow(d, 2);

如果你从标记为static的局部函数中引用封装作用域中的变量,你会得到一个编译器错误。

1.2.3 对元组的语言支持

C# 7 引入了创建和消费元组的新轻量级语法,类似于许多其他语言中的语法。这是 C# 7 中引入的最重要特性。¹

在实践中元组有何用途,为什么它们与 FP 相关?在 FP 中,我们倾向于将任务分解成小的函数。你可能会得到一个数据类型,它的唯一目的是捕获一个函数返回的信息,并且另一个函数期望作为输入。为这样的结构定义专用类型是不切实际的,这些结构并不对应于有意义的领域抽象。这就是元组发挥作用的地方。

让我们来看一个例子。假设你有一个货币对标识符,例如 EURUSD,它标识了欧元/美元的汇率,你希望将其拆分为两部分:

  • 基础货币(EUR)

  • 报价货币(USD)

为了做到这一点,你可以定义一个通用函数,该函数在给定的索引处拆分字符串。以下示例展示了此操作:

public static (string, string)                    ❶
   SplitAt(this string s, int at)
   => (s.Substring(0, at), s.Substring(at));      ❷

var (baseCcy, quoteCcy) = "EURUSD".SplitAt(3);    ❸
baseCcy  // => "EUR"
quoteCcy // => "USD"

❶ 将元组声明为方法的返回类型

❷ 构建一个元组

❸ 解构元组

此外,你还可以为元组的元素分配有意义的名称。这允许你像属性一样查询它们:

public static (string Base, string Quote)    ❶
   AsPair(this string ccyPair)
   => ccyPair.SplitAt(3);

var pair = "EURUSD".AsPair();
pair.Base  // => "EUR"                       ❷
pair.Quote // => "USD"                       ❷

❶ 为返回的元组元素分配名称

❷ 通过名称访问元素

让我们看看另一个例子。你知道你可以使用Where与谓词一起过滤列表中的值:

var nums = Enumerable.Range(0, 10);
var even = nums.Where(i => i % 2 == 0);

even // => [0, 2, 4, 6, 8]

如果你想知道满足谓词的元素以及那些不满足的元素,以便分别处理它们,我可以定义一个名为Partition的方法,该方法返回包含两个列表的元组:

var (even, odd) = nums.Partition(i => i % 2 == 0);

even // => [0, 2, 4, 6, 8]
odd  // => [1, 3, 5, 7, 9]

正如这些示例所示,元组语法允许你优雅地编写和消费需要返回多个值的函数。没有必要定义一个专门的数据类型来组合这些值。

1.2.4 模式匹配和记录类型

在本书第一版出版后出现的 C# 8 和 9 版本,为我们带来了两个直接受函数式语言启发的功能:

  • 模式匹配—允许你使用switch关键字来匹配不仅特定的值,还包括数据的形状,最重要的是其类型

  • 记录—无需样板代码的不可变类型,具有内置的创建修改版本的支持

提示:附录展示了如果你在使用遗留代码并且被限制在较旧的 C# 版本中,如何使用模式匹配和不可变类型。

我将通过一个实际示例来展示如何使用这些功能。如果你曾经从事过电子商务,你可能遇到过评估客户购买时需要支付的增值税(VAT)的需求。²

假设你被要求编写一个函数来估算客户在订单上需要支付的增值税。增值税的逻辑和金额取决于物品寄送的国家,当然,还有购买金额。因此,我们正在寻找实现一个名为Vat的函数,该函数将根据一个Order和买家的Address计算一个decimal(税额)。假设以下要求:

  • 对于运往意大利和日本的商品,将分别收取 22% 和 8% 的固定税率增值税。

  • 德国对食品产品收取 8% 的增值税,对所有其他产品收取 20% 的增值税。

  • 美国对所有产品收取固定的税率,但每个州的税率各不相同。

在继续阅读之前,你可能想花一点时间思考一下你将如何着手处理这个任务。

以下列表展示了如何使用记录类型来模拟一个Order。为了简化问题,我假设一个Order不能包含不同类型的Product

列表 1.6 位置记录

record Product(string Name, decimal Price, bool IsFood);    ❶

record Order(Product Product, int Quantity)                 ❷
{
   public decimal NetPrice => Product.Price * Quantity;
}

❶ 一个没有主体的记录以分号结尾。

❷ 记录可以有带有附加成员的主体。

注意,您只需一行就可以定义Product类型!编译器为您生成构造函数、属性获取器和几个便利方法,例如EqualsGetHashCodeToString

注意:C# 9 中的记录是引用类型,但 C# 10 允许您通过简单地写入record struct而不是仅record来使用记录语法定义值类型。有些令人惊讶的是,记录结构是可变的,如果您想使其不可变,必须将您的结构声明为readonly record struct

以下列表显示了如何实现第一个业务规则,该规则适用于具有固定增值税率的意大利和日本等国家。

列表 1.7 在值上执行模式匹配

static decimal Vat(Address address, Order order)
   => Vat(RateByCountry(address.Country), order);

static decimal RateByCountry(string country)
   => country switch
   {
      "it" => 0.22m,
      "jp" => 0.08m,
      _ => throw new ArgumentException($"Missing rate for {country}")
   };

static decimal Vat(decimal rate, Order order)
   => order.NetPrice * rate;

在这里,我定义了RateByCountry来映射国家代码到它们各自的增值税率。注意与传统的使用casebreakreturn的笨拙的switch语句相比,switch表达式的简洁语法。这里我们只是匹配country

还要注意,列表 1.7 中的代码假设存在一个具有Country属性的Address类型。它可以定义如下:

record Address(string Country);

那么,组成地址的其他字段,如街道、邮政编码等呢?不,我没有忘记或为了简单起见而省略它们。因为我们只需要这个计算的国家信息,所以拥有只封装我们在这个上下文中需要的信息的Address类型是合法的。您可以在不同的组件中有一个不同的、更丰富的Address定义,并在需要时定义两者之间的转换。

让我们继续并添加将货物运往德国的实现。作为提醒,德国对食品产品征收 8%的税,对所有其他产品征收 20%的税。以下列表中的代码显示了如何添加此规则。

列表 1.8 在模式匹配表达式中解构记录

static decimal Vat(Address address, Order order)
   => address switch
   {
      Address("de") => DeVat(order),
      Address(var country) => Vat(RateByCountry(country), order),
   };

static decimal DeVat(Order order)
   => order.NetPrice * (order.Product.IsFood ? 0.08m : 0.2m);

我们现在在Vat函数中添加了一个switch表达式。在每种情况下,给定的Address都会被解构,使我们能够根据其Country的值进行匹配。在第一种情况下,我们将其与字面值"de"进行匹配;如果匹配成功,我们调用德国的增值税计算,DeVat。在第二种情况下,值被分配给country变量,我们按照之前的方法检索该国的税率。注意,可以将switch表达式的子句简化如下:

static decimal Vat(Address address, Order order)
   => address switch
   {
      ("de") _ => DeVat(order),
      (var country) _ => Vat(RateByCountry(country), order),
   };

因为address的类型已知为Address,所以可以省略类型。在这个例子中,必须为匹配表达式包含一个变量名;这里我们使用一个丢弃的变量,即下划线字符。如果正在解构的对象至少有两个字段,则不需要这样做。³

属性模式

之前的列表展示了如何通过解构地址来匹配字段的值,这被称为位置模式。现在,假设您的Address类型更复杂,包括大约六个字段。在这种情况下,位置模式会变得嘈杂,因为您需要为每个字段包含一个变量名(至少是一个丢弃变量)。

这正是属性模式更适合的地方。以下代码展示了如何通过匹配属性值来进行匹配:

static decimal Vat(Address address, Order order) 
   => address switch 
   { 
      { Country: "de" } => DeVat(order), 
      { Country: var c } => Vat(RateByCountry(c), order), 
   }; 

这种语法的好处是,如果您后来向Address添加了额外的字段,您不需要做任何改变。一般来说,属性模式最适合您的典型面向对象实体,而位置模式最适合非常简单的对象,其定义不太可能改变(如 2D 点),或者与特定模式匹配场景建模的对象,如当前示例中的简化Address类型。

现在是关于美国的。在这里,我们也需要知道订单将发送到哪个州,因为不同的州有不同的税率。您可以按照以下方式建模:

record Address(string Country);
record UsAddress(string State) : Address("us");

也就是说,我们创建了一个专门的数据类型来表示美国地址。这扩展了Address,因为它有额外的数据。(在我看来,这比在Address中添加一个State属性并将其设置为null对于大多数国家来说更好。)现在,我们可以像以下列表所示那样完成我们的需求。

列表 1.9 通过类型进行模式匹配

static decimal Vat(Address address, Order order)
   => address switch
   {
      UsAddress(var state) => Vat(RateByState(state), order),
      ("de") _ => DeVat(order),
      (var country) _ => Vat(RateByCountry(country), order),
   };

static decimal RateByState(string state)
   => state switch
   {
      "ca" => 0.1m,
      "ma" => 0.0625m,
      "ny" => 0.085m,
      _ => throw new ArgumentException($"Missing rate for {state}")
   };

RateByState的实现与RateByCountry类似。更有趣的是Vat中的模式匹配。我们现在可以匹配UsAddress类型,提取出该州的税率。

TIP 本节说明了 C#支持的最常见(也是最有用)的模式。此外,您可以使用关系模式来匹配,例如,匹配所有大于 100 的值,或者使用逻辑模式来组合多个其他模式。有关完整规范,请访问docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns

我们已经完成了!整个代码只有 40 多行,大多数函数都是单行代码,我们需求中的三个情况在顶层switch表达式的相应情况中得到了清晰的表达。我们还没有必要对函数进行复杂的操作(目前还没有)。我们也不需要像面向对象程序员那样创建一个具有多个实现的接口(将这个问题视为策略模式的完美候选),可能的话。相反,我们只是使用了一种类型驱动的方法,这种方法展示了如何在静态类型函数式语言中使用记录和模式匹配。

生成的代码不仅简洁,而且可读性和可扩展性都很强。您可以看到,任何程序员都很容易添加针对其他国家的规则或根据需要修改现有规则。

1.3 你将在本书中学到什么

在本章中,你已经看到了 FP 和 C# 的基本概念,以及允许你在函数式风格中编程的特性。本书不假设你对函数式编程有任何先前的了解。它假设你非常了解 .NET 和 C#(或者,作为替代,类似 Java、Swift 或 Kotlin 这样的语言)。本书是关于函数式编程的,而不是关于 C# 的。阅读完这本书后,你将能够

  • 使用高阶函数以更少的代码实现更多功能,并减少重复

  • 使用纯函数编写易于测试和优化的代码

  • 编写易于消费且准确描述程序行为的 API

  • 使用专用类型以优雅和可预测的方式处理可空性、系统错误和验证规则

  • 编写可测试、模块化的代码,无需 IoC 容器的开销即可组合

  • 以函数式风格编写 Web API

  • 使用简单、声明性的代码编写复杂的程序,利用高级函数处理序列或值流中的元素

  • 阅读和理解为函数式语言编写的文献

摘要

  • 函数式编程(FP)是一种强大的范式,可以帮助你使代码更加简洁、可维护、表达性强、健壮、可测试,并且对并发友好。

  • FP 与面向对象编程(OOP)不同,它侧重于函数而不是对象,侧重于数据转换而不是状态突变。

  • FP 可以被视为一系列基于两个基本原则的技术集合:

    • 函数是一等值。

    • 应避免就地更新。

  • C# 是一种多范式语言,它稳步地融合了函数式特性,让你能够享受到在函数式风格编程中的好处。


¹ C# 7 的元组取代了笨拙的 C# 4 前辈,后者在性能上不佳,在语法上不吸引人,其元素通过名为 Item1Item2 等的属性访问。除了新的语法外,元组的底层实现也发生了变化。旧的元组由 System.Tuple 类支持,它们是不变引用类型。新的元组由 System.ValueTuple 结构支持。作为结构,它们在函数间传递时会被复制,但它们是可变的,因此你可以在方法内更新它们的成员,这是元组预期的不可变性和性能考虑之间的折衷。

² 增值税(VAT)也被称为 销售税消费税,具体取决于你所在的国家。

³ 问题在于在 C# 中 ("de")"de" 是相同的,所以编译器会认为你是在匹配一个 string,而不是具有单个字符串字段的对象。

2 以函数思维

本章涵盖

  • 数学与编程中的函数

  • 在 C#中表示函数

  • 利用高阶函数

在第一章中,你看到了将函数视为一等值是函数式编程(FP)的一个原则。这允许程序员提高标准,编写由其他函数参数化或创建的函数。这些被称为高阶函数(HOFs);它们确实提高了我们程序中的抽象级别,使我们能够用更少的代码做更多的事情。

但在我们深入探讨高阶函数(HOFs)之前,让我们退一步,看看我们所说的函数是什么意思:在数学和编程术语中它们是什么。然后我们将查看 C#提供的各种结构来表示函数。

2.1 函数究竟是什么?

在本节中,我将阐明我所说的函数的意思。我将从数学上对这个词的使用开始,然后转向 C#提供的各种表示函数的语言结构。这将为你提供一些基本的概念性和实用性工具,以便你可以开始以函数式的方式编码。

2.1.1 函数作为映射

在数学中,函数是两个集合之间的映射,分别称为定义域值域。也就是说,给定定义域中的一个元素,函数会从值域中产生一个元素。这就是全部。无论映射是基于某种公式还是完全任意,都没有关系。

在这种意义上,函数是一个完全抽象的数学对象,函数产生的值完全由其输入决定。你会发现,在编程中的函数并不总是这样。

例如,想象一个函数将小写字母映射到它们的大写对应字母,如图 2.1 所示。在这种情况下,定义域是集合{a, b, c, ...},值域是集合{A, B, C, ...}。(当然,也存在定义域和值域是同一集合的函数;你能想到一个例子吗?)

图片

图 2.1 数学函数简单来说就是一个映射。它将一个集合(定义域)的元素映射到另一个集合(值域)的元素。

这与编程函数有何关联?在静态类型语言如 C#中,类型代表集合(定义域和值域)。例如,如果你在图 2.1 中编写函数,你可以使用char来表示定义域和值域。那么你的函数类型可以写成

char → char

函数将char映射到char,或者等价地,给定一个char,它产生一个char

定义域和值域的类型构成了函数的接口,也称为其类型签名。你可以将其视为一份合同:函数签名声明,给定定义域中的一个元素,它将产生值域中的一个元素。¹ 这听起来可能非常明显,但你将在第四章中看到,在现实中,违反签名合同的例子比比皆是。

接下来,让我们看看 C# 语言特性,这些特性使我们能够表示函数。这里的意思是,不仅限于数学函数,还包括我们在日常编程中提到的函数。

2.1.2 在 C# 中表示函数

C# 中有几个语言结构可以用来表示函数:

  • 方法(包括局部方法)

  • 代理

  • Lambda 表达式

  • 字典

如果您对这些内容非常熟悉,请跳到第 2.2 节;否则,这里有一个快速回顾。

方法

方法是 C# 中函数最常见和惯用的表示形式。例如,System.Math 类包含表示许多常见数学函数的方法。当以函数式编程时,我们倾向于几乎只使用仅依赖于其输入参数的静态方法——它们不引用封装的静态类中的任何字段或属性,因此,您可以将其视为独立存在,就像处理数学函数一样。

我们在第 1.2.2 节中讨论了局部函数。它们实际上是方法内部声明的方法。如果您有一个执行特定任务的功能,并且您只需要在一个地方调用它,它可能是一个局部函数的候选。真正使您能够以函数式风格编程的结构是代理和 lambda 表达式,所以让我们继续讨论这些。

代理

代理是类型安全的函数指针。这里的 类型安全 意味着代理是强类型的。函数的输入和输出值的类型在编译时已知,并且一致性由编译器强制执行。

创建代理是一个两步过程:首先声明代理类型,然后提供实现。(这与编写 interface 然后实例化实现该接口的 class 类似。)

第一步是通过使用 delegate 关键字并提供代理的签名来完成的。例如,以下列表显示了包含在 .NET 基类库中的 Comparison<T> 代理的定义。

列表 2.1 声明代理

namespace System
{
   public delegate int Comparison<in T>(T x, T y);
}

如您所见,Comparison<T> 接收两个 T。然后它产生一个 int,表示哪个更大。

一旦您有了代理类型,您可以通过提供实现来实例化它。以下列表显示了这种方法。

列表 2.2 实例化和使用代理

var list = Enumerable.Range(1, 10).Select(i => i * 3).ToList();
list // => [3, 6, 9, 12, 15, 18, 21, 24, 27, 30]

Comparison<int> alphabetically = (l, r)                ❶
   => l.ToString().CompareTo(r.ToString());            ❶

list.Sort(alphabetically);                             ❷
list // => [12, 15, 18, 21, 24, 27, 3, 30, 6, 9]

❶ 提供 Comparison 的实现

❷ 将 Comparison 代理作为 Sort 的参数使用

如您所见,代理只是一个 对象(在技术意义上)表示一个操作;在这种情况下,是一个比较操作。像任何其他对象一样,您可以将代理用作另一个方法的参数,如列表 2.2 所示。代理是使函数在 C# 中成为一等值的语言特性。

Func 和 Action 代理

.NET 包含几个可以表示几乎所有函数类型的代理家族:

  • Func<R>——表示一个不接受任何参数并返回类型为 R 的结果的函数

  • Func<T1, R>—表示一个接受类型为 T1 的参数并返回类型为 R 的结果的函数

  • Func<T1, T2, R>—表示一个接受 T1T2 并返回 R 的函数

等等。这些委托代表各种 arity(参见“函数 arity”侧边栏)的函数。

自从引入 Func 以来,使用自定义委托的情况变得很少。例如,而不是这样声明一个自定义委托

delegate Greeting Greeter(Person p);

你可以直接使用

Func<Person, Greeting>

在这个示例中定义的 Greeter 类型与 Func<Person, Greeting> 相当或兼容。在两种情况下,它都是一个接受 Person 并返回 Greeting 的函数。在实践中,这意味着你可以定义一个 Greeter 并将其传递给期望 Func<Person, Greeting> 的方法,反之亦然,而编译器不会有任何抱怨。

有一个类似的委托家族来表示 actions,即没有返回值的函数,例如 void 方法:

  • Action—表示没有输入参数的操作

  • Action<T1>—表示具有类型为 T1 的输入参数的操作

  • Action<T1, T2> 等等—表示具有多个输入参数的操作

.NET 的发展趋势是远离自定义委托,转而使用更通用的 FuncAction 委托。例如,考虑谓词的表示方式:²

  • 在 .NET 2 中,引入了 Predicate<T> 委托。例如,FindAll 方法用于过滤 List<T>,期望一个 Predicate<T>

  • 在 .NET 3 中,Where 方法,也用于过滤,但定义为更通用的 IEnumerable<T>,不接收 Predicate<T>,而只接收 Func<T, bool>

这两种函数类型是等效的。推荐使用 Func 以避免出现代表相同函数签名的委托类型过多的情况,但仍然可以说自定义委托的表达能力更强。在我看来,Predicate<T> 比起 Func<T, bool> 更能传达意图,并且更接近于口语。

函数 arity

Arity 是一个有趣的词,指的是一个函数接受的参数数量。例如

  • 一个 零元 函数不接受任何参数。

  • 一个 一元 函数接受一个参数。

  • 一个 二进制 函数接受两个参数。

  • 一个 三元 函数接受三个参数。

等等。实际上,所有函数都可以被视为一元函数,因为传递 n 个参数相当于传递一个 n 元组作为唯一的参数。例如,加法(就像任何其他二元算术运算)是一个其定义域为所有数字对的函数。

Lambda 表达式

Lambda 表达式,简称 lambdas,用于声明内联函数。以下列表演示了使用 lambda 对数字列表进行字母排序。

列表 2.3 使用 lambda 表达式声明内联函数

var list = Enumerable.Range(1, 10).Select(i => i * 3).ToList();
list // => [3, 6, 9, 12, 15, 18, 21, 24, 27, 30]

list.Sort((l, r) => l.ToString().CompareTo(r.ToString()));
list // => [12, 15, 18, 21, 24, 27, 3, 30, 6, 9]

如果你的函数很短,并且你不需要在其他地方重用它,lambda 表达式提供了最吸引人的表示法。此外,请注意,在列表 2.3 中,编译器不仅推断 lrint 类型,而且还根据提供的 lambda 与 Sort 方法期望的 Comparison<int> 委托类型兼容,将 lambda 转换为该类型。

与方法一样,委托和 lambda 表达式可以访问它们声明的范围内的变量。这在利用 闭包 时尤其有用,如下面的列表所示。³

列表 2.4 一个访问封装作用域中变量的 lambda 表达式

var days = Enum.GetValues(typeof(DayOfWeek)).Cast<DayOfWeek>();
// => [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]

IEnumerable<DayOfWeek> daysStartingWith(string s)
   => days.Where(d => d.ToString().StartsWith(s));     ❶

daysStartingWith("S") // => [Sunday, Saturday]

❶ 在 lambda 中引用 s 变量,从而将其捕获在闭包中

在这个例子中,Where 期望一个函数,该函数接受一个 DayOfWeek 并返回一个 bool。实际上,由 lambda 表达式表示的函数也使用了 pattern 的值,该值通过闭包捕获来计算其结果。

这很有趣。如果你用更数学的眼光看待 lambda 表达式表示的函数,你可能会说它实际上是一个二元函数,它接受一个 DayOfWeek 一个 string(模式)作为输入,并产生一个 bool。然而,作为程序员,我们通常主要关注函数签名,所以你可能会更倾向于将其视为一个从 DayOfWeekbool 的单参数函数。两种观点都是有效的:函数必须符合其单参数签名,但它依赖于两个值来完成其工作。

匿名方法

为了完整性,我应该提到,C# 2 引入了一个名为 匿名方法 的功能。这允许你创建一个类似于这样的委托:

Comparison<int> alphabetically = delegate (int l, int r)
{
   return l.ToString().CompareTo(r.ToString());
};

当 lambda 表达式提供了更简洁的语法来执行相同操作时,C# 3 取代了匿名方法。匿名方法作为语言的遗迹特征幸存下来,但它们的用法是不被鼓励的。在 C# 功能的上下文中,术语 匿名函数 既指匿名方法也指 lambda 表达式。

字典

字典也适当地被称为映射(或哈希表)。它们是提供函数直接表示的数据结构。它们实际上包含了对 (来自定义域的元素)到 (对应于值域的元素)的关联。

我们通常将字典视为数据,所以改变一下视角,将它们视为函数是有益的。字典适合表示完全任意的函数,其中映射无法计算但 必须 完全存储。例如,以下列表显示了如何将布尔值映射到法语中的名称。

列表 2.5 完全表示函数的字典

var frenchFor = new Dictionary<bool, string>
{
   [true] = "Vrai",
   [false] = "Faux",
};

frenchFor[true]   ❶
// => "Vrai"

❶ 函数应用是通过查找来执行的。

函数可以用字典表示的事实也使得通过在字典中存储计算结果而不是每次都重新计算来优化计算密集型函数成为可能。这种技术称为 记忆化

为了方便起见,在本书的其余部分,我将使用术语 函数 来指代函数的 C# 表示之一。请记住,这并不完全符合该术语的数学定义。你将在第三章中了解更多关于数学函数和编程函数之间差异的信息。

2.2 高阶函数(HOFs)

现在你已经了解了什么是 FP,我们也回顾了该语言的函数特性,是时候开始探索一些具体的函数技术了。我们将从函数作为一等值的最重要好处开始——它们让你能够定义高阶函数(HOFs)。

HOFs 是接受其他函数作为输入或返回函数作为输出或两者都有的函数。我将假设你已经在使用 HOFs 方面有所了解,例如使用 LINQ。在这本书中,我们将大量使用 HOFs,所以本节应该作为一个复习,并可能介绍一些你可能不太熟悉的 HOFs 用例。

高阶函数(HOFs)很有趣,本节中的大多数示例都可以在 REPL 中运行。确保你在这个过程中尝试一些自己的变体。

2.2.1 依赖于其他函数的函数

一些 HOFs 将其他函数作为参数并按顺序调用它们以完成工作,这有点像一家公司可能将一些工作分包给另一家公司。你已经在本章前面看到了一些这样的 HOFs 的例子,包括 SortList 上的一个实例方法)和 WhereIEnumerable 上的一个扩展方法)。

当使用 Comparison 委托调用 List.Sort 时,这是一个表示“好吧,我会自己排序,只要你告诉我如何比较我所包含的任意两个元素”的方法。Sort 执行排序任务,但调用者可以决定使用什么逻辑进行比较。

同样,Where 执行过滤任务,调用者决定什么逻辑决定一个元素是否应该被包含。你可以将 Where 的类型用如图 2.2 所示的图形表示。

图 2.2 Where 是一个接受谓词函数作为输入的高阶函数。它使用谓词来决定哪些元素应包含在返回的列表中。

以下列表显示了 Where 的理想化实现。⁴

列表 2.6 Where,一个迭代应用给定谓词的高阶函数

public static IEnumerable<T> Where<T>
   (this IEnumerable<T> ts, Func<T, bool> predicate)
{

   foreach (T t in ts)       ❶
      if (predicate(t))      ❷
         yield return t;
}

❶ 遍历列表是 Where 的一个实现细节。

❷ 决定哪些项目被包含的标准由调用者决定。

Where 方法负责过滤逻辑。调用者提供 谓词,这是基于该谓词对 IEnumerable 进行过滤的标准。

如您所见,HOFs 可以帮助在逻辑难以轻易分离的情况下分离关注点。WhereSort是迭代应用的例子——HOF 对集合中的每个元素重复应用给定的函数。

粗略地看,这可以理解为您传递了一个函数作为参数,其代码最终将在 HOF 体内的循环体中执行。这是您仅通过传递静态数据无法做到的。一般方案如图 2.3 所示。

图片

图 2.3 重复应用作为参数提供的函数的 HOF

可选执行是 HOF 的另一个很好的候选。这在您只想在特定条件下调用给定的函数时很有用,如图 2.4 所示。

图片

图 2.4 条件应用作为参数提供的函数的 HOF

例如,想象一种从缓存中查找元素的方法。可以提供一个委托,并在缓存未命中时调用它。以下列表显示了如何实现这一点。

列表 2.7 可选调用的 HOF

class Cache<T> where T : class
{
   public T Get(Guid id) => //...
   public T Get(Guid id, Func<T> onMiss)
      => Get(id) ?? onMiss();
}

onMiss中的逻辑可能涉及昂贵的操作,如数据库调用。您不希望这种操作被不必要地执行。

前面的例子说明了接受函数作为输入并使用它来执行任务或计算值的 HOF。这可能是最常见的 HOF 模式,有时也被称为控制反转,其中 HOF 的调用者通过提供函数来决定做什么,而函数通过调用给定的函数来决定何时做。让我们看看 HOF 在其他哪些场景中很有用。

2.2.2 适配器函数

一些 HOF 根本不应用给定的函数,而是返回一个与作为参数提供的函数以某种方式相关的新的函数。例如,假设您有一个执行整数除法的函数:

var divide = (int x, int y) => x / y;
divide(10, 2) // => 5

你想改变参数的顺序,使除数排在第一位。这可以看作是一个更一般问题的特例:改变参数的顺序。你可以编写一个通用的 HOF,通过交换其参数的顺序来修改任何二元函数:

static Func<T2, T1, R> SwapArgs<T1, T2, R>(this Func<T1, T2, R> f)
   => (t2, t1) => f(t1, t2);

从技术上讲,更准确的说法是SwapArgs返回一个新的函数,该函数以相反的顺序调用给定的函数。但在直观层面上,我发现认为我得到了原始函数的修改版本更容易。现在你可以通过应用SwapArgs来修改原始的除法函数:

var divideBy = divide.SwapArgs();
divideBy(2, 10) // => 5

玩这种类型的 HOF 会导致一个有趣的想法,即函数不是一成不变的:如果您不喜欢函数的接口,您可以通过另一个提供更适合您需求的接口的函数来调用它。这就是为什么我称这些为适配器函数。⁵

2.2.3 创建其他函数的函数

有时你会编写主要目的是创建其他函数的函数。你可以把它们看作 函数工厂。以下示例使用 lambda 过滤一个数字序列,只保留能被 2 整除的数字:

var range = Enumerable.Range(1, 20);

range.Where(i => i % 2 == 0)
// => [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

如果你想要更通用的东西,比如能够过滤出能被任何数字 n 整除的数字,你可以定义一个函数,它接受 n 并生成一个合适的谓词,用于评估任何给定的数字是否可以被 n 整除:

Func<int, bool> isMod(int n) => i => i % n == 0;

我们之前还没有看过这样的 HOF。它接受一些静态数据并返回一个函数。让我们看看如何使用它:

using static System.Linq.Enumerable;

Range(1, 20).Where(isMod(2)) // => [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
Range(1, 20).Where(isMod(3)) // => [3, 6, 9, 12, 15, 18]

注意,你不仅获得了通用性,还提高了可读性!在这个例子中,你使用 isMod HOF 生成一个函数,然后将它作为输入传递给另一个 HOF,Where,如图 2.5 所示。

图 2.5 在这里,我们将 IsMod 定义为一个 HOF,它返回一个函数,告诉我们一个数字是否可以被某个给定的值整除。然后我们使用生成的谓词作为 Where 的输入。

你将在本书中看到更多关于 HOF 的用法。最终,你会把它们当作普通函数来看待,忘记它们是高阶的。现在让我们看看这些如何在更接近日常开发的场景中使用。

2.3 使用 HOF 避免重复

HOF 的另一个常见用途是封装设置和清理操作。例如,与数据库交互需要一些设置来获取和打开连接,并在交互后进行清理以关闭连接并将其返回到底层连接池。以下列表展示了这在代码中的样子。

列表 2.8 带有设置和清理的数据库连接

string connString = "myDatabase";

var conn = new SqlConnection(connString));    ❶
conn.Open();                                  ❶

// interact with the database...

conn.Close();                                 ❷
conn.Dispose();                               ❷

❶ 设置:获取并打开连接

❷ 清理:关闭并释放连接

设置和清理总是相同的,无论你是读取还是写入数据库,或者执行一个或多个操作。前面的代码通常使用如下 using 块编写:

using (var conn = new SqlConnection(connString))
{
   conn.Open();
   // interact with the database...
}

这既简短又好,但本质上仍然是相同的。⁶ 考虑以下列表,展示了一个简单的 DbLogger 类,它包含一些与数据库交互的方法。Log 方法插入给定的日志消息,而 GetLogs 方法检索自给定日期以来的所有日志。

列表 2.9 设置/清理逻辑的重复

using Dapper;                                               ❶

public class DbLogger
{
   string connString;                                       ❷

   public void Log(LogMessage msg)
   {
      using (var conn = new SqlConnection(connString))      ❸
      {
         conn.Execute("sp_create_log", msg                  ❹
            , commandType: CommandType.StoredProcedure);    ❹
      }                                                     ❺
   }

   public IEnumerable<LogMessage> GetLogs(DateTime since)
   {
      var sql = "SELECT * FROM [Logs] WHERE [Timestamp] > @since";
      using (var conn = new SqlConnection(connString))      ❻
      {
         return conn.Query<LogMessage>(sql                  ❼
            , new {since = since});                         ❼
      }                                                     ❽
   }
}

❶ 将 ExecuteQuery 作为扩展方法暴露在连接上

❷ 假设这是在构造函数中设置的。

❸ 设置

❹ 将 LogMessage 持久化到数据库

❺ 作为 Dispose 部分执行清理

❻ 设置

❼ 查询数据库并反序列化结果

❽ 清理

详细理解代码不是必需的。该代码使用 Dapper (github.com/StackExchange/dapper-dot-net),这是 ADO.NET 之上的一个薄层,允许您通过简单的 API 与数据库交互:

  • Query——查询数据库并返回反序列化的 LogMessages

  • Execute——运行存储过程并返回受影响的行数,我们忽略它

重要的是要注意,这两种方法有一些重复;即设置和拆除逻辑。我们能消除这种重复吗?

注意:在实际场景中,我建议你始终异步执行 I/O 操作。在这个例子中,GetLogs 应该真正调用 QueryAsync 并返回 Task<IEnumerable<LogMessage>>)。但是异步增加了复杂性,这在尝试学习 FP 的挑战性概念时并不 helpful。出于教学目的,我将在第十六章讨论异步。

如你所见,Dapper 提供了一个令人愉悦的 API,它甚至会在必要时打开连接。但你仍然需要创建连接,一旦完成,你应该尽快将其释放。因此,数据库调用的“肉”最终被设置和拆除的相同代码片段所包围。让我们看看如何通过将设置和拆除逻辑提取到 HOF 中来避免这种重复。

你正在编写一个执行设置和拆除的函数,并且参数化中间要执行的操作。这是一个完美的 HOF 场景,因为你可以用一个函数来表示中间的逻辑。⁷ 从图形上看,它看起来像图 2.6。

图片

图 2.6 一个封装在设置和拆除逻辑之间的给定函数的 HOF

因为连接设置和拆除比 DbLogger 更通用,所以它们可以被提取到一个新的 ConnectionHelper 类中。下面的列表提供了一个例子。

列表 2.10 将数据库连接的设置和拆除封装到 HOF 中

using System.Data;
using System.Data.SqlClient;

public static class ConnectionHelper
{
   public static R Connect<R>
      (string connString, Func<IDbConnection, R> f)
   {
      using (var conn = new SqlConnection(connString))   ❶
      {
         conn.Open();                                    ❶
         return f(conn);                                 ❷
      }                                                  ❸
   }
}

❶ 设置

❷ 中间发生的事情现在是参数化的。

❸ 拆除

Connect 函数执行设置和拆除,并且它通过中间要执行的操作进行参数化。函数体的签名很有趣;它接受一个 IDbConnection(通过它将与数据库交互)并返回一个泛型对象 R。在我们看到的用例中,R 在查询的情况下将是 IEnumerable<LogMessage>,在插入的情况下将是 int

你现在可以在 DbLogger 中使用 Connect 函数,如下所示:

using Dapper;
using static ConnectionHelper;

public class DbLogger
{
   string connString;

   public void Log(LogMessage message)
      => Connect(connString, c => c.Execute("sp_create_log"
         , message, commandType: CommandType.StoredProcedure));

   string sql = @"SELECT * FROM [Logs] WHERE [Timestamp] > @since";

   public IEnumerable<LogMessage> GetLogs(DateTime since)
      => Connect(connString
         , c => c.Query<LogMessage>(sql, new {since = since}));
}

你已经消除了 DbLogger 中的重复,因此 DbLogger 就不再需要知道创建、打开或释放连接的细节。到现在为止,我希望你同意 HOF 是强大的工具,尽管过度使用可能会使代码难以理解。在适当的时候使用 HOF,但要注意可读性:使用短的 lambda 表达式、清晰的命名和有意义的缩进。

练习

我建议你花时间做练习,并在过程中想出一些自己的。GitHub 上的代码示例存储库(mng.bz/10Pj)包括占位符,这样你就可以用最少的设置工作编写、编译和运行你的代码。它还包括你可以对照检查结果的解决方案:

  1. 浏览 System.Linq.Enumerable 的方法(mng.bz/PX6n)。哪些是高阶函数(HOFs)?你认为哪些暗示了给定函数的迭代应用?

  2. 编写一个否定给定谓词的函数:当给定的谓词评估为 true 时,结果函数评估为 false,反之亦然。

  3. 编写一个使用快速排序对 List<int> 进行排序的方法,返回一个新的列表而不是就地排序。

  4. 将之前的实现泛化以接受 List<T>Comparison<T> 委托。

摘要

  • 数学函数简单地定义了两个集合之间的映射。

  • 你可以使用方法、委托、lambda 表达式和字典在 C# 中表示函数。

  • 函数式编程(FP)利用高阶函数(HOFs),这些函数接受其他函数作为输入或输出;因此,语言需要将函数作为一等值。


¹ 在面向对象(OO)的层面上,接口是这一想法的扩展:一组具有相应输入和输出类型或更精确地说,方法(本质上就是函数)的方法,这些方法将 this(当前实例)作为隐式参数。

² 一个 谓词 是一个函数,给定一个值(比如说,一个整数),告诉你它是否满足某些条件(比如说,它是否是偶数)。

³ 一个 闭包 是 lambda 表达式本身及其声明的上下文的组合(lambda 出现的作用域中所有可用的变量)。

⁴ 此实现功能上是正确的,但它缺少 LINQ 实现中的错误检查和优化。

⁵ 在面向对象(OOP)中,著名的适配器模式可以看作是将适配函数的思想应用于对象的接口。

⁶ 它更短,因为 Dispose 在退出 using 块时被调用,这将反过来调用 Close。它更好,因为交互被包裹在一个 try/finally 块中,所以即使在 using 块的主体中抛出异常,连接也会被释放。

⁷ 因此,你可能会听到这种模式被不优雅地称为“中间的洞”。

3 为什么函数纯净性很重要

本章涵盖

  • 什么使一个函数纯净或不纯净

  • 为什么在并发场景中纯净性很重要

  • 纯净性与可测试性的关系

  • 减少代码的不纯净痕迹

这章最初的名称是“纯净的不可抗拒魅力。”但如果它真的如此不可抗拒,我们就会有更多的函数式程序员,对吧?你看,函数式程序员是纯净函数的忠实粉丝:没有副作用的功能。在本章中,你将看到这究竟意味着什么,以及为什么纯净函数具有一些非常令人向往的特性。

不幸的是,这种对纯净函数的迷恋部分原因是为什么函数式编程作为一门学科已经与行业脱节。很快你就会意识到,在大多数现实世界的应用中,几乎没有纯净性。然而,纯净性在现实世界中仍然相关,正如我希望在本章中展示的那样。

我们将从探讨什么使一个函数纯净(或不纯净)开始,然后你将看到纯净性如何影响程序的可测试性和甚至正确性,尤其是在并发场景中。我希望到本章结束时,你会发现纯净性如果不是不可抗拒,至少也是绝对值得记住的。

3.1 什么是函数纯净性?

在第二章中,你看到数学函数是完全抽象的实体。尽管一些编程函数是数学函数的近似表示,但这通常并非如此。你经常希望一个函数在屏幕上打印某些内容,处理一个文件,或者与另一个系统交互。简而言之,你经常希望函数一些事情,产生副作用。数学函数根本不做这样的事情;它们只返回一个值。

第二个重要的区别是:数学函数存在于真空中,因此它们的结果严格由它们的参数决定。我们用来表示函数的编程结构,另一方面,都可以访问一个上下文:一个实例方法可以访问实例字段,一个 lambda 可以访问封闭作用域中的变量,许多函数可以访问完全超出程序范围的事物,例如系统时钟、数据库或远程服务等。

这种上下文的存在,其界限并不总是清晰界定,以及它可能包含程序控制之外变化的事物,意味着编程中函数的行为比数学中的函数更复杂,难以分析。这导致了纯净不纯净函数之间的区别。

3.1.1 纯净性与副作用

纯净函数与数学函数非常相似:它们除了根据输入值计算输出值之外,不做任何事情。表 3.1 对比了纯净和不纯净函数。

表 3.1 纯净函数的要求

纯净函数 不纯净函数
输出完全取决于输入参数。 除了输入参数之外的其他因素可能影响输出。
无副作用 可有副作用

为了阐明这个定义,我们必须精确地定义什么是副作用。如果一个函数执行以下任何一项操作,那么它被认为是有副作用的:

  • 修改全局状态—这里的全局意味着任何在函数作用域之外可见的状态。例如,一个私有实例字段被认为是全局的,因为它可以从类中的所有方法中看到。

  • 修改其输入参数—调用者传递的参数实际上是一种函数与其调用者共享的状态。如果一个函数修改了它的任何一个参数,那么这就是对调用者可见的副作用。

  • 抛出异常—你可以独立地推理纯函数;然而,如果一个函数抛出异常,那么调用它的结果取决于上下文。也就是说,它取决于函数是在try-catch块中调用。

  • 执行任何 I/O 操作—这包括程序与外部世界的任何交互,包括从控制台、文件系统或数据库中读取或写入,以及与应用程序边界之外的任何进程交互。

总结来说,纯函数没有副作用,它们的输出完全由它们的输入决定。请注意,这两个条件都必须满足:

  • 没有副作用的函数仍然可能是非纯的。 也就是说,从全局可变状态读取的函数很可能会产生依赖于其输入之外因素的输出。

  • 一个输出完全取决于其输入的函数也可以是非纯的。 它仍然可能具有副作用,例如更新全局可变状态。

纯函数的确定性特性(对于相同的输入总是返回相同的输出)有一些有趣的后果。纯函数易于测试和推理。¹

此外,输出仅取决于输入的事实意味着求值的顺序并不重要。无论你现在还是以后计算函数的结果,结果都不会改变。这意味着你的程序中完全由纯函数组成的部分可以通过多种方式优化:

  • 并行化—不同的线程并行执行任务。

  • 惰性求值—仅在需要时计算值。

  • 记忆化—缓存函数的结果,使其只计算一次。

另一方面,使用这些技术与非纯函数一起可能会导致相当棘手的错误。出于这些原因,函数式编程倡导者建议尽可能优先使用纯函数。

3.1.2 管理副作用的方法

好的,让我们尽可能使用纯函数。但是,这总是可能的吗?是否有可能?嗯,如果你看一下被认为是副作用的事项列表,它是一个相当混杂的集合,因此管理副作用的方法取决于所讨论的副作用类型。

修改输入参数是最容易的。这种副作用总是可以避免的,我将在下面演示这一点。总是避免抛出异常也是可能的。我们将在第八章和第十四章中查看不抛出异常的错误处理。

编写程序(甚至是具有状态的程序)而不进行状态修改也是可能的。你可以编写任何程序而不必修改状态。²这对于一个面向对象程序员来说可能是一个令人惊讶的认识,需要真正的思维转变。在第 3.2 节中,我将向你展示一个简单的例子,说明避免状态修改如何使你能够轻松并行化一个函数。在后面的章节中,你将学习各种技术来处理更复杂的任务,而不依赖于状态修改。

最后,我将在第 3.3 节中讨论如何管理 I/O。通过学习这些技术,你将能够隔离或避免副作用,从而利用纯函数的优势。

3.1.3 避免修改参数

你可以将函数签名视为一个合同:函数接收一些输入并返回一些输出。当函数修改其参数时,这会混淆水,因为调用者依赖于这个副作用发生,尽管这并没有在函数签名中声明。因此,我认为在任何编程范式中修改参数都是一个坏主意。尽管如此,我反复遇到一些实现,它们做的是类似的事情:

decimal RecomputeTotal(Order order, List<OrderLine> linesToDelete)
{
   var result = 0m;
   foreach (var line in order.OrderLines)
      if (line.Quantity == 0) linesToDelete.Add(line);
      else result += line.Product.Price * line.Quantity;
   return result;
}

RecomputeTotal方法旨在在修改订单中项目数量时被调用。它重新计算订单的总价值,并且作为副作用,将数量变为零的订单行添加到给定的linesToDelete列表中。这如图 3.1 所示。

图片

图 3.1 RecomputeTotal修改了其输入,并且调用者依赖于这个副作用。

这之所以是一个糟糕的想法,是因为该方法的行为现在与调用者的行为紧密耦合:调用者依赖于该方法执行其副作用,而调用者依赖于调用者初始化列表。因此,每个方法都必须了解另一个方法的实现细节,这使得无法独立地推理代码。

警告:使用会修改其参数的方法存在另一个问题,如果你将参数的类型从class改为struct,你会得到截然不同的行为,因为当在函数间传递时,结构体会被复制。

你可以通过将所有计算出的信息返回给调用者来轻松避免这种副作用。重要的是要认识到,该方法实际上正在计算两份数据:订单的新总计和可以删除的行列表。你可以通过返回一个元组来使这一点明确。重构后的代码如下:

(decimal NewTotal, IEnumerable<OrderLine> LinesToDelete)
   RecomputeTotal(Order order)
   => (order.OrderLines.Sum(l => l.Product.Price * l.Quantity)
     , order.OrderLines.Where(l => l.Quantity == 0));

图 3.2 表示这个重构版本,看起来更简单。毕竟,现在它只是一个普通的函数,接受一些输入并返回输出。

图像

图 3.2 RecomputeTotal重构为显式返回其计算的所有信息

依据这个原则,你总是可以以这种方式构建你的代码,使得函数永远不会改变它们的输入参数。实际上,通过始终使用不可变对象——一旦创建就不能更改的对象——来强制执行这一点将是理想的。我们将在第十一章中详细讨论这一点。

3.2 通过避免状态突变来启用并行化

在本节中,我将向你展示一个简单场景,说明为什么纯函数始终可以并行化,而不纯函数则不能。想象一下,你想将字符串列表格式化为编号列表:

  • 标准化大小写。

  • 每个项目前应有一个计数器。

要做到这一点,你可以定义一个ListFormatter类,以下是其用法:

var shoppingList = new List<string>
{
   "coffee beans",
   "BANANAS",
   "Dates"
};

new ListFormatter()
   .Format(shoppingList)
   .ForEach(WriteLine);

// prints: 1\. Coffee beans
//         2\. Bananas
//         3\. Dates

以下列表显示了一个可能的ListFormatter实现。

列表 3.1 结合纯函数和不纯函数的列表格式化器

static class StringExt
{
   public static string ToSentenceCase(this string s)       ❶
      => s == string.Empty
         ? string.Empty
         : char.ToUpperInvariant(s[0]) + s.ToLower()[1..];
}

class ListFormatter
{
   int counter;

   string PrependCounter(string s) => $"{++counter}. {s}";  ❷

   public List<string> Format(List<string> list)
      => list
         .Select(StringExt.ToSentenceCase)                  ❸
         .Select(PrependCounter)                            ❸
         .ToList();
}

❶ 纯函数

❷ 不纯函数(它会改变全局状态)。

❸ 纯函数和不纯函数可以类似地应用。

关于纯度,有几个要点需要注意:

  • ToSentenceCase是纯函数(其输出严格由输入决定)。因为其计算只依赖于输入参数,所以可以无问题地将其定义为静态的。³

  • PrependCounter会增加计数器,所以它是不纯的。因为它依赖于实例成员(计数器),所以你不能将其定义为静态的。

  • Format方法中,你使用Select将两个函数应用到列表中的项上,不考虑其纯度。这并不理想,正如你很快就会学到的。事实上,理想情况下应该有一个规则,即Select应该仅与纯函数一起使用。

如果你正在格式化的列表足够大,并行执行字符串操作是否有意义?运行时是否会决定这样做以进行优化?我们将在下一节中解决这些问题。

3.2.1 纯函数并行化良好

给定足够大的数据集进行处理,通常并行处理是有利的,尤其是在处理是 CPU 密集型且数据片段可以独立处理时。纯函数可以很好地并行化,并且通常对使并发困难的问题免疫。(关于并发和并行性的复习,请参阅“并发和并行性的意义和类型”侧边栏)。

我将通过尝试使用ListFormatter并行化我们的列表格式化函数来阐述这一点。比较以下两个表达式:

list.Select(ToSentenceCase).ToList()
list.AsParallel().Select(ToSentenceCase).ToList()

第一个表达式使用System.Linq.Enumerable中定义的Select方法将纯函数ToSentenceCase应用于列表中的每个元素。第二个表达式类似,但它使用 Parallel LINQ (PLINQ)提供的方法。⁴ AsParallel将列表转换为ParallelQuery。因此,Select解析为ParallelEnumerable中定义的实现,它将ToSentenceCase应用于列表中的每个项目,但现在是在并行执行的。

列表被分成块,然后启动几个线程来处理每个块。当调用ToList时,结果被收集到一个单独的列表中。图 3.3 展示了这个过程。

图 3.3 列表中的数据并行处理

如您所预期,这两个表达式会产生相同的结果,但一个按顺序执行,另一个则并行执行。这很好。只需调用一次AsParallel,您就可以几乎免费地获得并行化。

为什么几乎免费?为什么您必须明确指示程序并行化操作?为什么运行时不应该像确定垃圾回收的最佳时机一样,确定并行化操作是一个好主意?

答案是运行时不了解函数的足够信息,无法做出是否并行化可能会改变程序流程的明智决定。由于它们的属性,纯函数始终可以并行应用,但运行时不了解正在应用的函数是否是纯函数。

并发的意义和类型

并发是同时进行几件事情的一般概念。更正式地说,并发是指程序在另一个任务完成之前启动一个任务,以便不同的任务在重叠的时间窗口内执行。在以下几种情况下可能会发生并发:

  • 异步——您的程序执行非阻塞操作。例如,它可以通过 HTTP 发起对远程资源的请求,然后在等待响应的同时继续执行其他任务。这有点像您发送电子邮件后继续生活而不等待回复。

  • 并行性——您的程序通过将工作分解成任务,每个任务在单独的核心上执行,利用多核机器的硬件来同时执行任务。这有点像在淋浴时唱歌:您实际上同时做了两件事。

  • 多线程——一种软件实现,允许不同的线程并发执行。即使在一个单核机器上运行,多线程程序看起来也能同时做几件事情。这有点像通过各种即时通讯窗口与不同的人聊天,尽管您实际上是在来回切换。最终结果是您同时进行多个对话。

同时做几件事情可以提高性能。这也意味着执行顺序没有保证,因此并发可能是困难的根源,尤其是在多个任务同时尝试更新某些共享可变状态时。(在后面的章节中,你将看到 FP 如何通过完全避免共享可变状态来解决这个问题。)

3.2.2 并行化不纯函数

你已经看到你可以成功地将纯函数ToSentenceCase并行化。让我们看看如果你天真地并行化不纯的PrependCounter函数会发生什么:

list.AsParallel().Select(PrependCounter).ToList()

如果你现在创建一个包含一百万个项目的列表,并使用天真地并行化的格式化程序进行格式化,你会发现列表中的最后一个项目不是以 1,000,000 开头,而是以一个较小的数字开头。如果你已经下载了代码示例,你可以通过运行以下命令来亲自尝试:

cd Examples
dotnet build
dotnet run NaivePar

输出将以类似以下内容结束

932335\. Item999998
932336\. Item999999
932337\. Item1000000

因为PrependCounter增加了counter变量,并行版本将会有多个线程读取和更新计数器,如图 3.4 所示。众所周知,++不是一个原子操作,因为没有锁定机制,我们将丢失一些更新,并最终得到一个错误的结果。

图 3.4 当并行处理列表时,多个线程并发访问计数器。

如果你有一些多线程经验,这听起来可能很熟悉。因为多个进程同时读取和写入计数器,一些更新会丢失。当然,你可以在增加计数器时使用锁或Interlocked类来修复这个问题。但这样做会降低性能,抵消了并行计算带来的部分收益。此外,锁定是一个我们希望在函数式编程中避免的命令式构造。

让我们总结一下。与默认可以并行化的纯函数不同,不纯函数不是直接可以并行化的。而且由于并行执行是非确定性的,你可能会遇到一些情况,其中你的结果是正确的,而另一些情况则不是(你不想遇到的错误类型)。

了解你的函数是否是纯的可以帮助你理解这些问题。此外,如果你在开发时考虑到纯度,那么如果你决定并行化执行,这将更容易。

3.2.3 避免状态突变

避免并发更新的潜在问题的一个可能方法是在源头上解决问题;一开始就不使用共享状态。如何做到这一点因场景而异,但我会向你展示一个解决方案,它使我们能够并行格式化列表。

让我们回到起点,看看是否有一个不涉及修改的顺序解决方案。如果,不是更新一个运行计数器,而是生成所有需要的计数器值的列表,然后从给定的列表和计数器列表中配对项目,会怎样?对于整数列表,你可以使用 Enumerable 上的便利方法 Range,如下所示。

列表 3.2 生成整数范围

Enumerable.Range(1, 3)
// => [1, 2, 3]

在函数式编程(FP)中,对两个并行列表进行配对操作很常见。这被称为 ZipZip 接受两个列表进行配对,并应用到一个配对上的函数。以下列表显示了一个示例。

列表 3.3 使用 Zip 结合并行列表中的元素

Enumerable.Zip(
   new[] {1, 2, 3},
   new[] {"ichi", "ni", "san"},
   (number, name) => $"In Japanese, {number} is: {name}")

// => ["In Japanese, 1 is: ichi",
//     "In Japanese, 2 is: ni",
//     "In Japanese, 3 is: san"]

你可以使用 RangeZip 重新编写列表格式化程序,如下所示。

列表 3.4 重构为仅使用纯函数的列表格式化程序

using static System.Linq.Enumerable;

static class ListFormatter
{
   public static List<string> Format(List<string> list)
   {
      var left = list.Select(StringExt.ToSentenceCase);
      var right = Range(1, list.Count);
      var zipped = Zip(left, right, (s, i) => $"{i}. {s}");
      return zipped.ToList();
   }
}

在这里,你使用应用了 ToSentenceCase 的列表作为 Zip 的左侧。右侧是用 Range 构建的。Zip 的第三个参数是配对函数:如何处理每一对项目。因为 Zip 可以用作扩展方法,你可以使用更流畅的语法编写 Format 方法:

public static List<string> Format(List<string> list)
   => list
      .Select(StringExt.ToSentenceCase)
      .Zip(Range(1, list.Count), (s, i) => $"{i}. {s}")
      .ToList();

在这次重构之后,Format 是纯函数,可以安全地将其定义为静态。但使其并行化怎么样?这很简单,因为 PLINQ 提供了一个与并行查询一起工作的 Zip 实现。以下列表提供了一个列表格式化的并行实现。

列表 3.5 一个并行执行的纯实现

using static System.Linq.ParallelEnumerable;                  ❶

static class ListFormatter
{
   public static List<string> Format(List<string> list)
      => list.AsParallel()                                    ❷
         .Select(StringExt.ToSentenceCase)
         .Zip(Range(1, list.Count), (s, i) => $"{i}. {s}")
         .ToList();
}

❶ 使用 ParallelEnumerable 暴露的 Range

❷ 将原始数据源转换为并行查询

这几乎与顺序版本相同;只有两个区别。首先,使用AsParallel将给定的列表转换为ParallelQuery,这样之后的所有操作都是并行执行的。其次,using static的变化使得Range现在指的是ParallelEnumerable中定义的实现(这返回一个ParallelQuery,这是Zip并行版本所期望的)。其余部分与顺序版本相同,Format的并行版本仍然是一个纯函数。

在这种情况下,通过完全删除状态更新,可以启用并行执行,但这并不总是这种情况,也并不总是这么容易。但到目前为止你看到的想法让你在处理与并行性相关的问题时处于更好的位置,更普遍地说,是并发问题。

静态方法的理由

当方法内部所需的所有变量都作为输入提供(或静态可用)时,你可以将方法定义为静态。本章包含将实例方法重构为静态方法的几个示例。

你可能会对此感到不安,尤其是如果你(像我一样)看到程序因为过度使用静态类而变得难以测试和维护。如果静态方法执行以下任一操作,它们可能会引起问题:

  • 修改静态字段—这些实际上是最全局的变量。它们可以从任何可以看到静态类的代码中更新,导致耦合和不可预测的行为。

  • 执行 I/O—在这种情况下,受威胁的是可测试性。如果方法A依赖于静态方法B的 I/O 行为,那么无法对A进行单元测试。

注意,这两种情况都意味着一个不纯的函数。另一方面,当一个函数是纯函数时,它可以安全地被标记为静态。作为一个一般准则

  • 将纯函数设为静态

  • 避免可变静态字段

  • 避免直接调用执行 I/O 的静态方法

随着你更多地以函数式的方式编码,你的更多函数将是纯函数。这意味着你的更多代码可能会在静态类中,而不会引起与滥用静态类相关的问题。

3.3 纯度与可测试性

在上一节中,你看到了纯度在并发场景中的相关性。因为副作用与状态突变有关,我们可以移除突变,结果得到的纯函数可以无问题地并行运行。

现在我们将探讨执行 I/O 操作的函数以及纯度与单元测试的相关性。单元测试必须是可重复的(如果测试通过,它应该在任何时间、任何机器、是否有连接等情况下都通过)。这与我们要求纯函数必须是确定性的要求密切相关。

我的目标是利用你对单元测试的知识来帮助你理解纯度的相关性,并消除纯度只具有理论兴趣的观念。你的经理可能不在乎你是否编写纯函数,但他们可能非常关注良好的测试覆盖率。

3.3.1 隔离 I/O 效果

与突变不同,你无法避免与 I/O 相关的副作用。虽然突变是实现细节,但 I/O 通常是必需的。以下是一些有助于阐明为什么执行 I/O 的函数永远不能是纯函数的例子:

  • 一个接受 URL 并返回该 URL 资源的函数,在远程资源发生变化时会产生不同的结果,或者如果连接不可用,可能会抛出错误。

  • 一个接受文件路径和要写入文件的内容的函数,如果目录不存在或程序所在进程没有写权限,可能会抛出错误。

  • 一个从系统时钟返回当前时间的函数在任何时刻都会返回不同的结果。

正如你所见,任何对外部世界的依赖都会阻碍函数的纯度,因为世界状态会影响函数的返回值。另一方面,如果你的程序要做任何有用的东西,那么不可避免地需要一些 I/O。即使是仅仅执行计算的纯数学程序,也必须执行一些 I/O 来传达其结果。你的某些代码将不得不是不纯的。

如何在满足执行 I/O 的要求的同时,获得纯净性的好处?你 隔离 程序中纯净的计算部分与 I/O。这样,你最小化了 I/O 的足迹,并为程序的纯净部分获得了纯净性的好处。例如,考虑以下代码:

using static System.Console;

WriteLine("Enter your name:");
var name = ReadLine();
WriteLine($"Hello {name}");

这个简单的程序将 I/O 与可以捕获在纯函数中的逻辑混合在一起,如下所示:

static string GreetingFor(string name) => $"Hello {name}";

在一些现实世界的程序中,将逻辑与 I/O 分离相对简单。例如,以 Pandoc 这样的文档格式转换器为例,它可以用来将文件从 Markdown 转换为 PDF。当你执行 Pandoc 时,它执行图 3.5 中显示的步骤。

图 3.5 一个易于隔离 I/O 的程序。执行格式转换的核心逻辑可以保持纯净。

程序的计算部分,执行格式转换,可以完全由纯函数组成。执行 I/O 的不纯函数可以调用执行转换的纯函数,但执行转换的函数不能调用任何执行 I/O 的函数,否则它们也会变得不纯。

大多数业务线 (LOB) 应用在 I/O 方面结构更为复杂,因此将程序的纯净计算部分与 I/O 分离是一个相当大的挑战。接下来,我将介绍本书中我们将使用的业务场景,我们将看到我们如何测试一些执行 I/O 的验证。

3.3.2 一个业务验证场景

想象你正在为在线银行应用程序编写代码。你的客户端是 Codeland 银行 (BOC);BOC 的客户可以使用网页或移动设备进行货币转账。在预订转账之前,服务器必须验证请求,如图 3.6 所示。

图 3.6 业务场景:验证转账请求

假设用户发起转账请求是通过一个 MakeTransfer 命令来表示的。一个 命令 是一个简单的数据传输对象 (DTO),客户端将其发送给服务器,封装了它想要执行的操作的详细信息。以下列表显示了我们对 MakeTransfer 的调用。

列表 3.6 代表发起货币转账请求的 DTO

public abstract record Command(DateTime Timestamp);

public record MakeTransfer
(
   Guid DebitedAccountId,               ❶

   string Beneficiary,                  ❷
   string Iban,                         ❷
   string Bic,                          ❷

   DateTime Date,                       ❸
   decimal Amount,                      ❸
   string Reference,                    ❸
   DateTime Timestamp = default
)
   : Command(Timestamp)
{
   internal static MakeTransfer Dummy   ❹
      => new(default, default, default
         , default, default, default, default);
}

❶ 识别发送者的账户

❷ 受益人账户的详细信息

❸ 转账的详细信息

❹ 当你不需要填充所有属性时,我们将使用这个来测试。

MakeTransfer 的属性通过反序列化客户端的请求来填充,除了 Timestamp,它需要由服务器设置。因此,声明了一个初始的 default 值。在单元测试时,我们必须手动填充对象,因此有一个 Dummy 实例允许你只填充与测试相关的属性,正如你将看到的。

在这种场景中的验证可能相当复杂。为了解释的目的,我们只查看以下验证:

  • 表示应执行转账的日期的字段 Date 不应该是过去的。

  • 受益人银行的标准化标识符 BIC 代码应该是有效的。

我们将从面向对象设计开始。(在第九章中,我展示了针对此场景的更彻底的函数式方法。)遵循单一职责原则,我们将为每个特定的验证编写一个类。让我们草拟一个所有这些验证器类都将实现的简单接口:

public interface IValidator<T>
{
   bool IsValid(T t);
}

现在我们已经建立了我们的领域特定抽象,让我们从基本实现开始。接下来的列表展示了这是如何完成的。

列表 3.7 实现验证规则

using System.Text.RegularExpressions;

public class BicFormatValidator : IValidator<MakeTransfer>
{
   static readonly Regex regex = new Regex("^[A-Z]{6}[A-Z1-9]{5}$");

   public bool IsValid(MakeTransfer transfer)
      => regex.IsMatch(transfer.Bic);
}
public class DateNotPastValidator : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => (DateTime.UtcNow.Date <= transfer.Date.Date);
}

这相当简单。BicFormatValidator 中的逻辑是纯的?是的,因为没有副作用,IsValid 的结果是确定的。那么 DateNotPastValidator 呢?在这种情况下,IsValid 的结果取决于当前日期,所以显然答案是:不是!我们面临什么样的副作用?是 I/O:DateTime.UtcNow 查询系统时钟,这超出了程序的上下文。

执行 I/O 的函数很难测试。例如,考虑以下测试:

[Test]
public void WhenTransferDateIsFuture_ThenValidationPasses()
{
   var sut = new DateNotPastValidator();     ❶
   var transfer = MakeTransfer.Dummy with
   {
      Date = new DateTime(2021, 3, 12)       ❷
   };

   var actual = sut.IsValid(transfer);
   Assert.AreEqual(true, actual);
}

sut 代表“待测试结构”。

❷ 这个日期曾经是未来的!

这个测试创建了一个 MakeTransfer 命令,在 2021-03-12 进行转账。(如果你不熟悉示例中使用的 with 表达式语法,我将在第 11.3 节中讨论这个问题。)然后断言该命令应该通过日期非过去的验证。

当我编写这个测试时,它通过了,但当你阅读它时,它将失败,除非你在设置时钟早于 2021-03-12 的机器上运行它。因为实现依赖于系统时钟,所以这个测试是不可重复的。

让我们退一步,看看为什么测试纯函数比测试不纯函数基本更容易。然后,在第 3.4 节中,我们将回到这个例子,看看我们如何将 DateNotPastValidator 纳入测试。

3.3.3 为什么测试不纯函数很难

当你编写单元测试时,你在测试什么?当然是一个单元,但一个单元究竟是什么呢?无论你测试的是哪个单元,它都是一个函数或者可以被视为一个函数

单元测试需要是隔离的(无 I/O)和可重复的(给定相同的输入,你总是得到相同的结果)。当你使用纯函数时,这些属性是保证的。当你测试一个纯函数时,测试很容易:你只需给它一个输入,并验证输出是否符合预期(如图 3.7 所示)。

图 3.7 测试纯函数很容易:你只需提供输入并验证输出是否符合预期。

如果你使用标准的安排-行动-断言 (AAA) 模式进行单元测试,并且你正在测试的单元是一个纯函数,那么安排步骤包括定义输入值,行动步骤是函数调用,断言步骤包括检查输出是否符合预期。⁵ 如果你为代表性的一组输入值这样做,你可以确信函数按预期工作。

另一方面,如果你正在测试的单元是一个 不纯 函数,其行为不仅取决于其输入,还可能取决于程序的状态(任何不是被测试函数局部可变的可变状态)和世界的状态(任何超出程序上下文的内容)。此外,函数的副作用可能导致程序和世界的新状态:例如,

  • 日期验证器依赖于现实世界的状态,特别是当前时间。

  • 返回 void 的方法发送电子邮件没有明确的输出可以断言,但它会导致世界的新状态。

  • 设置非局部变量的方法会导致程序的新状态。

因此,你可以将不纯函数视为一个纯函数,它接受其参数、程序和世界的当前状态作为输入,并返回其输出,以及程序和世界的新状态。图 3.8 展示了这一过程。

图片

图 3.8 测试不纯函数。你需要设置和断言的不仅仅是函数的输入和输出。

另一种看待这个问题的方式是,不纯函数除了其参数之外还有隐式输入,或者除了其返回值之外还有隐式输出,或者两者都有。

这如何影响测试?嗯,对于不纯函数,安排阶段不仅必须提供函数的显式输入,还必须设置程序和世界的状态表示。同样,断言阶段不仅必须检查结果,还必须检查预期的变化已发生在程序和世界的状态中。这总结在表 3.2 中。

表 3.2 从功能视角进行单元测试

AAA 模式 功能视角
安排 设置被测试函数的(显式和隐式)输入
行动 评估被测试函数
断言 验证(显式和隐式)输出的正确性

再次,我们应该在测试方面区分不同类型的副作用:

  • 设置程序状态并检查其更新会导致脆弱的测试并破坏封装性。

  • 可以使用存根来表示世界状态,这些存根创建一个测试运行的人工世界。

这是一项艰巨的工作,但技术已被充分理解。我们将在下一节探讨这一点。

3.4 测试执行 I/O 的代码

在本节中,你将了解我们如何将依赖于 I/O 操作的代码置于测试之下。我将向你展示不同的依赖注入方法,对比主流的面向对象方法与更函数式的方法。

为了演示这一点,让我们回到 DateNotPastValidator 中的不纯验证,看看我们如何重构代码以使其可测试。以下是对代码的提醒:

public class DateNotPastValidator : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => (DateTime.UtcNow.Date <= transfer.Date.Date);
}

问题在于,因为 DateTime.UtcNow 访问系统时钟,所以无法编写保证行为一致的测试。⁶ 让我们看看我们如何解决这个问题。

3.4.1 面向对象的依赖注入

测试依赖于 I/O 操作的代码的主流技术是在接口中抽象这些操作,并在测试中使用确定性实现。如果你已经熟悉这种方法,请跳到 3.4.2 节。

这种基于接口的依赖注入方法被认为是最佳实践,但我已经认为它是一个反模式。这是因为它涉及大量的模板代码。它包括以下步骤,我们将在接下来的章节中更详细地探讨:

  1. 定义一个接口,该接口抽象了你想要测试的代码执行的 I/O 操作,并将不纯实现放在实现该接口的类中。

  2. 在测试类中,构造函数中要求接口,将其存储在字段中,并按需使用它。

  3. 创建并注入一个用于单元测试的存根实现。

  4. 引入一些引导逻辑,以便在测试类实例化时在运行时提供不纯实现。

使用接口抽象 I/O

而不是直接调用 DateTime.UtcNow,你抽象了对系统时钟的访问。也就是说,你定义了一个接口和一个执行所需 I/O 的实现,如下所示:

public interface IDateTimeService
{
   DateTime UtcNow { get; }                     ❶
}

public class DefaultDateTimeService : IDateTimeService
{
   public DateTime UtcNow => DateTime.UtcNow;   ❷
}

❶ 在接口中封装不纯行为

❷ 提供默认实现

消费接口

然后你将日期验证器重构为使用此接口而不是直接访问系统时钟。验证器的行为现在取决于应该注入的接口实例(通常在构造函数中)。以下列表显示了如何做到这一点。

列表 3.8 重构一个类以使用接口

public class DateNotPastValidator : IValidator<MakeTransfer>
{
   private readonly IDateTimeService dateService;

   public DateNotPastValidator
      (IDateTimeService dateService)                     ❶
   {
      this.dateService = dateService;
   }

   public bool IsValid(MakeTransfer transfer)
      => dateService.UtcNow.Date <= transfer.Date.Date;  ❷

}

❶ 在构造函数中注入接口

❷ 验证现在依赖于接口。

让我们看看重构后的 IsValid 方法:它是一个纯函数吗?嗯,答案是,它取决于!当然,这取决于注入的 IDateTimeService 的实现:

  • 当正常运行时,你会组合你的对象,以便得到检查系统时钟的真正不纯实现。

  • 当运行单元测试时,你会注入一个假的实现,该实现执行某些可预测的操作,例如总是返回相同的 DateTime,这样你就可以编写可重复的测试。

测试时注入存根

以下列表展示了如何使用这种方法编写测试。

列表 3.9 通过注入可预测的实现进行测试

public class DateNotPastValidatorTest
{
   static DateTime presentDate = new DateTime(2021, 3, 12);

   private class FakeDateTimeService : IDateTimeService     ❶
   {
      public DateTime UtcNow => presentDate;
   }

   [Test]
   public void WhenTransferDateIsPast_ThenValidationFails()
   {
      var svc = new FakeDateTimeService();
      var sut = new DateNotPastValidator(svc);              ❷
      var transfer = MakeTransfer.Dummy with
      {
         Date = presentDate.AddDays(-1)
      };
      Assert.AreEqual(false, sut.IsValid(transfer));
   }
}

❶ 提供一个纯的、假的实现

❷ 注入假的

即,我们创建了一个存根,一个假的实现,与真实实现不同,它有一个确定的结果。

设置依赖项

我们还没有完成,因为我们需要在运行时为DateNotPastValidator提供它所依赖的IDateTimeService。这可以通过多种方式完成,无论是手动还是借助框架,具体取决于你程序的复杂性和选择的技术。⁷ 在一个 ASP.NET 应用程序中,它可能看起来像这样:

public void ConfigureServices(IServiceCollection services)
{
   services.AddTransient<IDateTimeService, DefaultDateTimeService>();
   services.AddTransient<DateNotPastValidator>();
}

此代码注册了真实的、不纯的实现DefaultDateTimeService,将其与IDateTimeService接口关联。因此,当需要DateNotPastValidator时,ASP.NET 看到它需要在构造函数中需要一个IDateTimeService,并提供了一个DefaultDateTimeService的实例。

基于接口方法的陷阱

单元测试非常有价值,以至于开发者愿意承受所有这些努力,即使是为了像DateTime.UtcNow这样简单的事情。使用基于接口的方法系统地使用的一个最不希望看到的效果是接口数量的激增,因为你必须为每个具有 I/O 元素的组件定义一个接口。

大多数应用程序都是用每个服务的一个接口来开发的,即使只设想了一个具体的实现。这些被称为头接口,它们并不是接口最初设计的目的(与几个不同实现的一个通用合同),但它们被广泛使用。你最终会有更多的文件、更多的间接引用、更多的程序集,以及难以导航的代码。

避免平凡的构造函数

将一个类重构为消费接口(如列表 3.8 所示)的一个问题是需要定义一个平凡的构造函数。这个构造函数所做的只是将输入参数存储在类字段中。在一个足够复杂的应用程序中,这会创建大量的样板代码。

许多语言通过拥有主构造函数来节省这种仪式。这个特性对于类来说不可用,但自从 C# 9 以来,你可以使用记录。列表 3.8 中的代码可以重构如下:

public record DateNotPastValidator(IDateTimeService DateService)
   : IValidator<MakeTransfer>
{
   private IDateTimeService DateService { get; } = DateService;

   public bool IsValid(MakeTransfer request)
      => DateService.UtcNow.Date <= request.Date.Date;
}

位置记录语法自动生成一个构造函数,你可以将其注入所需的IDateTimeService和一个名为DateService的公共属性。如果你觉得生成的属性污染了类的公共 API,你可以明确指定该属性应该是私有的。前面的代码展示了如何做到这一点。

3.4.2 减少样板代码的测试性

我已经讨论了基于接口的依赖注入方法的陷阱。在本小节中,我将向你展示一些更简单的替代方案。具体来说,测试代码不是消费一个接口,而是消费一个函数,有时甚至只是一个值。

将纯净边界向外扩展

我们能否消除整个问题并使一切纯净?不,我们需要检查当前日期。这是一个具有非确定性结果的操作。但有时,我们可以扩展纯净代码的边界。例如,如果你像以下列表中那样重写日期验证器呢?

列表 3.10 注入一个特定的值,而不是接口,使IsValid纯净

public record DateNotPastValidator(DateTime Today)
   : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => Today <= transfer.Date.Date;
}

我们不是注入一个接口,而是暴露一个可以调用的方法,而是注入一个。现在IsValid的实现是纯净的!你实际上已经将读取当前日期的副作用推到了创建验证器的代码中。为了设置创建此验证器的设置,你可能需要使用一些像这样的代码:

public void ConfigureServices(IServiceCollection services)
{
   services.AddTransient<DateNotPastValidator>
      (_ => new DateNotPastValidator(DateTime.UtcNow.Date));
}

不深入细节,此代码定义了一个函数,每当需要DateNotPastValidator时就会调用该函数,在这个函数内部,当前日期创建了一个新实例。请注意,这要求DateNotPastValidator是瞬时的;当需要验证传入的请求时,我们会创建一个新的实例。在这种情况下,这是一种合理的行为。

消费一个执行 I/O 的方法而不是一个值,这是一个简单的胜利,可以使你的代码更加纯净,从而更容易测试。当你的逻辑依赖于存储在文件中或特定环境设置中的配置时,这种方法效果很好。但事情并不总是这么简单,所以让我们继续探讨一个更通用的解决方案。

将函数作为依赖项注入

想象一下,当收到MakeTransfer请求时,会创建一个包含几个验证器的列表,每个验证器强制执行不同的规则。如果其中一个验证失败,请求就会失败,后续的验证器将不会被调用。

此外,假设查询系统时钟是昂贵的(实际上并不昂贵,但大多数 I/O 操作都是)。你不想每次创建验证器时都这样做,而只想在实际使用时才这样做。你可以通过注入一个函数,而不是一个值,让验证器按需调用这个函数来实现这一点:

public record DateNotPastValidator(Func<DateTime> Clock)
   : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => Clock().Date <= transfer.Date.Date;
}

我把注入的函数叫做Clock,因为如果不是一个可以调用以获取当前时间的函数,那什么是时钟呢?IsValid的实现现在不执行除了Clock执行的任何副作用,因此可以很容易地通过注入一个“损坏的时钟”来测试:

readonly DateTime today = new(2021, 3, 12);

[Test]
public void WhenTransferDateIsToday_ThenValidatorPasses()
{
   var sut = new DateNotPastValidator(() => today);
   var transfer = MakeTransfer.Dummy with { Date = today };

   Assert.AreEqual(true, sut.IsValid(transfer));
}

另一方面,在创建验证器时,你需要传递一个实际查询系统时钟的函数,如下所示:

public void ConfigureServices(IServiceCollection services)
{
   services.AddSingleton<DateNotPastValidator>
      (_ => new DateNotPastValidator(() => DateTime.UtcNow.Date));
}

注意,因为返回当前日期的函数现在是由验证器调用的,所以不再需要验证器是短暂的。你可以像前面代码片段中展示的那样将其用作单例。

这个解决方案满足了所有条件:验证器现在可以确定性测试,除非需要,否则不会执行任何 I/O,我们也不需要定义任何不必要的接口或简单的类。我们将在第九章中进一步探讨这种方法。

注入一个代表以增加清晰度

如果你选择注入一个函数,你可以考虑走得更远。你可以定义一个委托而不是简单地使用 Func

public delegate DateTime Clock();

public record DateNotPastValidator(Clock Clock)
   : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => Clock().Date <= transfer.Date.Date;
}

测试代码保持不变;在设置中,你可以在注册一个 Clock 的同时提高清晰度。一旦完成,框架就知道在创建需要 Clock 的验证器时使用它:

public void ConfigureServices(IServiceCollection services)
{
   services.AddTransient<Clock>(_ => () => DateTime.UtcNow);
   services.AddTransient<DateNotPastValidator>();
}

参数化单元测试

无论你使用什么方法来对 DateNotPastValidator 进行测试,你都可以使用参数化单元测试。参数化测试允许你使用各种输入值来测试你的代码。它们往往更具有功能性,因为它们让你从输入和输出的角度思考。例如,以下展示了如何测试日期非过去验证在各种情况下的工作情况:

[TestCase(+1, ExpectedResult = true)]
[TestCase( 0, ExpectedResult = true)]
[TestCase(-1, ExpectedResult = false)]
public bool WhenTransferDateIsPast_ThenValidatorFails(int offset)
{
   var sut = new DateNotPastValidator(() => presentDate);
   var transfer = MakeTransfer.Dummy with
   {
      Date = presentDate.AddDays(offset)
   };
   return sut.IsValid(transfer);
}

此代码使用 NUnit 的 TestCase 属性有效地运行了三个测试:今天(相对于硬编码的日期)请求转账、昨天和明天。XUnit 测试框架有 TheoryInlineData 属性,允许你做同样的事情,而在 MSTest 中,它被称为 DataRow

参数化测试的优势在于,你只需调整参数值就可以测试各种场景。客户是否能够请求一年以后的转账?如果不能,你可以添加一条测试来验证这一点,只需一行代码即可:

[TestCase(+366, ExpectedResult = false)]

注意,现在的测试方法本身就是一个函数:它将给定的参数值映射到 NUnit 可以检查的输出。实际上,它是一个纯函数,因为断言(会抛出异常)已经被推离了测试方法,并由测试框架执行。

参数化测试本质上只是对被测试函数的一个适配器。在这个例子中,测试创建了一个具有硬编码当前日期的人工世界状态。然后,它将测试的输入参数(当前日期与请求转账日期之间的偏移量)映射到一个适当填充的 MakeTransfer 对象,该对象作为输入传递给被测试的函数。

3.5 纯度与计算的发展

我希望这一章使函数纯度的概念不再神秘,并展示了为什么扩展纯代码的足迹是一个值得追求的目标。这提高了代码的可维护性、性能和可测试性。

软件和硬件的发展也对我们的纯度思考方式产生了重要影响。我们的系统越来越分布式,因此程序的 I/O 部分变得越来越重要。随着微服务架构的普及,我们的程序越来越少地涉及计算,更多地是将计算委托给其他服务,它们通过 I/O 与这些服务进行通信。

这种 I/O 需求的增加意味着纯度更难实现。但它也意味着对异步 I/O 的要求增加。正如你所看到的,纯度有助于你处理并发场景,包括处理异步消息。

硬件进化也很重要:CPU 的速度增长速度不如以前,因此硬件制造商正在转向结合多个处理器和核心。并行化正成为提高计算速度的主要途径,因此需要编写可以很好地并行化的程序。确实,向多核机器的转变是我们目前重新对函数式编程产生兴趣的主要原因之一。

练习

编写一个控制台应用程序,计算用户的身体质量指数(BMI):

  1. 提示用户输入他们的身高(以米为单位)和体重(以千克为单位)。

  2. 计算 BMI 为体重除以身高²。

  3. 输出一条消息:体重过轻(BMI < 18.5)、超重(BMI >= 25)或健康。

  4. 将你的代码结构化,使纯部分和不纯部分分离。

  5. 对纯部分进行单元测试。

  6. 使用基于函数的方法来抽象从控制台读取和写入的操作,对整体工作流程进行单元测试。

因为本章的大部分内容都是关于在实践中看到纯度的概念,我鼓励你调查,将我们讨论的技术应用于你目前正在工作的代码中。你可以在赚钱的同时学到新东西!

  1. 找到一个基于列表进行一些非平凡操作的地方(例如搜索foreach)。看看这个操作是否可以并行化;如果不能,看看是否可以提取操作的纯部分并并行化这部分。

  2. 在你的代码库中搜索 DateTime.NowDateTime.UtcNow 的用法。如果该区域没有经过测试,请使用本章中描述的基于接口的方法和基于函数的方法将其纳入测试。

  3. 在你的代码的其他部分寻找你依赖于没有传递依赖的不纯依赖项的地方。明显的候选者是跨越应用程序边界的静态类,如ConfigurationManagerEnvironment。尝试应用基于函数的测试模式。

摘要

  • 与数学函数相比,编程函数更难推理,因为它们的输出可能取决于除了输入参数之外的变量。

  • 副作用包括状态突变、抛出异常和 I/O。

  • 没有副作用的函数被称为 纯函数。这些函数除了返回一个仅取决于其输入参数的值之外,不做任何其他事情。

  • 纯函数比不纯函数更容易优化和测试,并且在并发场景中更可靠。在可能的情况下,你应该优先选择纯函数。

  • 与其他副作用不同,I/O 无法避免,但你仍然可以隔离应用程序中执行 I/O 的部分,以减少不纯代码的影响。


¹ 一些更倾向于理论化的作者展示了如何可以通过代数推理纯函数来证明程序的正确性;例如,参见 Graham Hutton 的 Programming in Haskell,第 2 版,剑桥,英国:剑桥大学出版社,2016 年。

² 我应该指出,完全避免状态变化并不总是容易的,也不总是实用的。但大多数时候避免状态变化是可行的,这是你应该努力实现的目标。

³ 在许多语言中,你会有这样的独立函数,但在 C# 中,方法需要位于类内部。将静态函数放在哪里主要是一个品味问题。

⁴ PLINQ 是 LINQ 的并行实现。

⁵ AAA 是在单元测试中组织代码的通用模式。根据这个模式,一个测试由三个步骤组成:arrange 准备任何先决条件,act 执行被测试的操作,assert 对获得的结果运行断言。

⁶ 你可以尝试编写一个测试,在填充输入 MakeTransfer 时从系统时钟读取。这在大多数情况下可能有效,但在午夜前后有一个小的时间窗口,在这个时间窗口内,当安排测试的输入时,日期与调用 IsValid 时的日期不同。实际上,你并不能保证始终一致。此外,我们需要一个可以与任何 I/O 操作一起工作的方法,而不仅仅是访问时钟。

⁷ 在复杂应用程序中手动组合所有类可能变得相当繁琐。为了减轻这种情况,一些框架允许你声明所需接口的实现。这些被称为 IoC 容器,其中 IoC 代表 控制反转

第二部分. 核心技术

在这部分,我们将探讨 FP 中最常用的技术。您将看到简单的构造和技巧如何使您能够用简洁、优雅和易读的代码解决问题。

第四章处理设计类型和函数签名的基本原则——您认为您已经知道的事情,但您从函数式视角看时,会发现它们以新的方式呈现。

第五章向您展示了Option类型的理由和实现,这是 FP 的一个基本要素,它不仅本身有用,而且将指导您理解本书中许多后续概念。

第六章介绍了 FP 的一些核心函数:MapBindForEachWhere。这些函数为与 FP 中最常见的数据结构进行交互提供了基本工具。

第七章展示了如何将函数链式连接成管道,以捕获程序的工作流程。然后它将范围扩大到以函数式风格开发整个用例。

到第二部分的结尾,您将很好地了解用函数式风格编写的程序看起来是什么样子。您还将能够将 FP 思想和结构应用到自己的代码中——在小规模上——在 OO 代码的海洋中创建函数代码的岛屿。

4 设计函数签名和类型

本章涵盖

  • 设计良好的函数签名

  • 对函数输入的精细控制

  • 使用 Unit 作为 void 的更灵活的替代品

我们到目前为止所讨论的原则定义了泛型 FP,无论你是在使用 C# 这样的静态类型语言编程,还是在使用 JavaScript 这样的动态类型语言编程。在本章中,你将学习一些特定于静态类型语言的函数技术。函数及其参数的类型化打开了一系列有趣的考虑。

函数是函数程序的基本构建块,因此正确地获取函数签名至关重要。由于函数签名是在其输入和输出的类型的基础上定义的,因此正确地获取这些类型同样重要。类型设计和函数签名设计实际上是同一枚硬币的两个面。

你可能会认为经过多年的定义类和接口,你知道如何设计你的类型和函数。但结果是,FP 带来了一系列有趣的概念,可以帮助你提高程序的健壮性和 API 的可用性。

4.1 设计函数签名

函数的签名告诉了你其输入和输出的类型;如果函数有名称,它还包括函数的名称。随着你更多地以函数方式编程,你会发现你更频繁地查看函数签名。定义函数签名是你在开发过程中的一个重要步骤,通常是你接近问题时做的第一件事。

我将首先介绍一个在 FP 社区中标准的函数签名符号。我们将全书使用它。

4.1.1 使用箭头符号编写函数签名

在 FP 中,函数签名通常使用 箭头符号 来表达。学习它有很大的好处,因为你会发现它在 FP 的书籍、文章和博客中:它是不同语言的功能程序员使用的通用语言。

假设我们有一个从 intstring 的函数 f;它接受一个 int 作为输入并返回一个 string 作为输出。我们将这样表示其签名:

f : int → string

在英语中,你会读到 f 的类型是 intstring,或者 f 接受一个 int 并返回一个 string。在 C# 中,具有此签名的函数可以赋值给 Func<int, string>

你可能会同意,箭头符号比 C# 类型更易读,这就是为什么我们在讨论签名时会使用它。当我们没有输入或输出(voidUnit)时,我们将使用 () 来表示。

让我们看看一些例子。表 4.1 显示了使用箭头符号表达的函数类型与相应的 C# 委托类型以及具有给定签名的函数的 lambda 表达式示例并排展示。

表 4.1 使用箭头符号表达函数签名

函数签名 C# 类型 示例
int string Func<int, string> (int i) => i.ToString()
() string Func<string> () => "hello"
int () Action<int> (int i) => WriteLine($"gimme {i}")
() () Action () => WriteLine("Hello World!")
(int, int) int Func<int, int, int> (int a, int b) => a + b

表格 4.1 的最后一个例子显示了多个输入参数:我们只需用括号将它们分组。括号表示元组,因此实际上我们是在将二元函数表示为一元函数,其输入参数是一个二元元组。

现在,让我们转向更复杂的签名,即那些高阶函数(HOFs)的签名。让我们从以下方法开始,该方法接受一个 string 和一个从 IDbConnectionR 的函数,并返回一个 R

static R Connect<R>(string connStr, Func<IDbConnection, R> func)
   => // ...

你会如何表示这个签名?第二个参数本身就是一个函数,因此它可以表示为 IDbConnection → R。HOF 的签名如下所示:

(string, (IDbConnection → R)) → R

这是相应的 C# 类型:

Func<string, Func<IDbConnection, R>, R>

箭头语法稍微轻量一些,并且更易于阅读,尤其是在签名的复杂性增加时。

4.1.2 签名有多大的信息量?

一些函数签名比其他签名更具表达性,我的意思是它们给我们提供了更多关于函数做什么、允许哪些输入以及我们可以期望什么输出的信息。例如,签名 () () 完全没有提供任何信息:它可能打印一些文本,增加一个计数器,发射一艘宇宙飞船……谁知道呢?另一方面,考虑以下签名:

(IEnumerable<T>, (T → bool)) → IEnumerable<T>

花一分钟时间看看你是否能猜出具有此签名的函数做什么。当然,没有看到实际的实现,你无法确切知道,但你可以做出一个有根据的猜测。该函数返回一个 T 的列表,并接受一个 T 的列表,以及第二个参数,它是一个从 Tbool 的函数,即 T 的一个 断言

合理地假设该函数使用 T 上的断言来过滤列表中的元素。简而言之,它是一个过滤函数。实际上,这正是 Enumerable.Where 的签名。让我们看看另一个例子:

(IEnumerable<A>, IEnumerable<B>, ((A, B) → C)) → IEnumerable<C>

你能猜出这个函数的作用吗?它返回一个 C 的序列,并接受一个 A 的序列,一个 B 的序列,以及一个从 AB 计算出 C 的函数。合理地假设这个函数将计算应用于两个输入序列的元素,并返回一个包含计算结果的第三个序列。这个函数可能是我们第 3.2.3 节讨论过的 Enumerable.Zip 函数。

这两个最后的签名如此具有表达性,以至于你可以对实现做出很好的猜测,这是一个可取的特性。当你编写 API 时,你希望它清晰,如果签名与良好的命名一起表达函数的意图,那就更好了。

当然,函数签名所能表达的内容是有限的。例如,Enumerable.TakeWhile是一个遍历给定序列的函数,只要给定的谓词评估为真,就会产生所有元素,它与Enumerable.Where具有相同的签名。这很有道理,因为TakeWhile也可以被视为一个过滤函数,但它的工作方式与Where不同。

总结来说,一些签名比其他签名更具表现力。随着你开发你的 API,尽量让你的签名尽可能具有表现力——这有助于你的 API 被消费,并为你的程序增加鲁棒性。

4.2 使用数据对象捕获数据

函数和数据就像硬币的两面:函数消耗数据并产生数据。一个好的 API 需要具有清晰签名的函数和精心设计的用于表示这些函数输入和输出的数据类型。在 FP(与 OOP 不同),在逻辑和数据之间进行区分是自然的:

  • 逻辑被编码在函数中。

  • 数据通过数据对象被捕获,这些数据对象被用作这些函数的输入和输出。

在本节中,我们将探讨一些设计数据对象的基本思想。然后,我们将继续探讨表示数据缺失(第 4.3 节)或可能的数据缺失(第五章)的相对抽象的概念。

假设你从事人寿保险业务。你需要编写一个函数,根据客户的年龄计算其风险配置文件。风险配置文件将通过一个enum来捕获:

enum Risk { Low, Medium, High }

你和 David 一起结对编程,David 是一名来自动态类型语言的实习生,他尝试实现这个函数。他在 REPL 中用几个输入运行它,以确认它按预期工作:

Risk CalculateRiskProfile(dynamic age)
   => (age < 60) ? Risk.Low : Risk.Medium;

CalculateRiskProfile(30) // => Low
CalculateRiskProfile(70) // => Medium

虽然在给定的合理输入下,实现似乎可以工作,但你还是对 David 选择dynamic作为参数类型感到惊讶。你向他展示他的实现允许客户端代码用string调用该函数,导致运行时错误:

CalculateRiskProfile("Hello")
// => runtime error: Operator '<' cannot be applied to operands➥
 of type 'string' and 'int'

你向 David 解释说,你可以告诉编译器你的函数期望什么类型的输入,这样就可以排除无效的输入。你重写了函数,将输入参数的类型改为int

Risk CalculateRiskProfile(int age)
   => (age < 60) ? Risk.Low : Risk.Medium;

CalculateRiskProfile("Hello")
// => compiler error: cannot convert from 'string' to 'int'

是否还有改进的空间?

4.2.1 原始类型通常不够具体

当你继续测试你的函数时,你会发现实现仍然允许无效的输入:

CalculateRiskProfile(-1000) // => Low
CalculateRiskProfile(10000) // => Medium

显然,这些不是客户年龄的有效值。那么,什么才是有效的年龄呢?你和业务方进行了讨论,以澄清这一点,他们指出合理的年龄值必须是正数且小于 120。你的第一反应是在你的函数中添加一些验证——如果给定的年龄超出了有效范围,则抛出异常:

Risk CalculateRiskProfile(int age)
{
   if (age < 0 || 120 <= age)
      throw new ArgumentException($"{age} is not a valid age");

   return (age < 60) ? Risk.Low : Risk.Medium;
}

CalculateRiskProfile(10000)
// => runtime error: 10000 is not a valid age

当你输入这些内容时,你可能会想这相当烦人:

  • 你将不得不为验证失败的案例编写额外的单元测试。

  • 应用程序的其他区域也需要年龄,因此您可能需要在那些地方也需要进行验证。这可能会导致一些代码重复。

重复 通常意味着关注点的分离已被破坏:CalculateRiskProfile 函数,它只应关注计算,现在也关注验证。有没有更好的方法?

4.2.2 使用自定义类型约束输入

同时,您的同事弗里达,她来自静态类型函数式语言,加入了会议。她看了看您到目前为止的代码,发现问题是您使用 int 来表示年龄。她评论说:“您可以让编译器知道您的函数期望什么类型的输入,这样就可以排除无效的输入。”

戴维惊讶地听着,因为这些正是您几分钟前用来夸奖他的话。您不确定她确切的意思,于是她开始实现 Age,作为一个只能表示有效年龄值的自定义类型,如下所示。

列表 4.1 仅能使用有效值实例化的自定义类型

public struct Age
{
   public int Value { get; }

   public Age(int value)
   {
      if (!IsValid(value))
         throw new ArgumentException($"{value} is not a valid age");

      Value = value;
   }

   private static bool IsValid(int age)
      => 0 <= age && age < 120;
}

此实现仍然使用 int 作为年龄的底层表示,但构造函数确保 Age 类型只能用有效值实例化。

这是一个功能思维的很好例子:Age 类型正是为了表示 CalculateRiskProfile 函数的域而创建的。现在可以重写如下:

Risk CalculateRiskProfile(Age age)
   => (age.Value < 60) ? Risk.Low : Risk.Medium;

这种新的实现有几个优点:

  • 您确保只能提供有效的值。

  • CalculateRiskProfile 不再导致运行时错误。

  • 验证年龄值的担忧被 Age 类型的构造函数所捕捉,从而消除了在处理年龄时重复验证的需要。

您仍然在 Age 构造函数中抛出异常,但我们在第 5.4.3 节中会解决这个问题。然而,仍有改进的空间。

在先前的实现中,我们使用了 Value 来提取年龄的底层值,所以我们仍然是在比较两个整数。这有几个问题:

  • 读取 Value 属性不仅会带来一些噪音,还意味着客户端代码知道了 Age 的内部表示,您可能希望在将来更改它。

  • 因为您正在进行整数比较,所以如果有人不小心将 60 的阈值值更改为 600(这是一个有效的 int 但不是有效的 Age),您将不会受到保护。

您可以通过修改 Age 的定义来解决这些问题,如下所示。

列表 4.2 封装年龄的内部表示

public class Age
{
   private int Value { get; }                      ❶

   public static bool operator <(Age l, Age r)     ❷
      => l.Value < r.Value;
   public static bool operator >(Age l, Age r)
      => l.Value > r.Value;

   public static bool operator <(Age l, int r)     ❸
      => l < new Age(r);
   public static bool operator >(Age l, int r)
      => l > new Age(r);
}

❶ 保持内部表示私有

❷ 比较一个 Age 与另一个 Age 的逻辑

❸ 为了便于使用,使得可以将一个 Age 与一个 int 进行比较;首先将 int 转换为 Age

现在年龄的内部表示被封装起来,比较逻辑在 Age 类中。你可以将你的函数重写如下:

Risk CalculateRiskProfile(Age age)
   => (age < 60) ? Risk.Low : Risk.Medium;

现在发生的情况是,从值 60 构造一个新的 Age,以便应用常规验证。(如果这抛出一个运行时错误,那没关系,因为这表明开发者错误;关于这一点,请参阅第八章。)当输入年龄进行比较时,这个比较是在 Age 类中进行的,使用你定义的比较运算符。总的来说,代码与之前一样易于阅读,但更健壮。

总结来说,原始类型通常被过于自由地使用(这已经成为 原始类型迷恋 的代名词)。如果你需要限制你函数的输入,通常更好的做法是定义一个自定义类型。这遵循了使无效状态不可表示的想法。在前面的例子中,你不能表示超出有效范围的年龄。

CalculateRiskProfile 的新实现与其原始实现相同,除了输入类型现在是 Age,这确保了数据的有效性并使函数签名更加明确。一个函数式程序员可能会说,现在函数是 诚实的。这是什么意思?

4.2.3 编写诚实的函数

你可能会听到函数式程序员谈论 诚实不诚实 的函数。一个诚实的函数简单地说就是它所说的那样:它始终遵守其签名——总是。例如,考虑我们在 4.2.2 节中最终得到的函数:

Risk CalculateRiskProfile(Age age)
   => (age < 60) => Risk.Low : Risk.Medium;

其签名是 Age Risk,声明,“给我一个 Age,我会给你一个 Risk。”确实,没有其他可能的输出。¹ 这个函数的行为就像一个数学函数,将域中的每个元素映射到陪域中的一个元素,如图 4.1 所示。

图 4.1 一个诚实的函数正好做其签名所说的:它将输入类型(的)所有可能值映射到输出类型的有效值。这使得你函数的行为可预测,并且你的程序更健壮。

将此与之前的实现进行比较,其看起来如下:

Risk CalculateRiskProfile(int age)
{
   if (age < 0 || 120 <= age)
      throw new ArgumentException($"{age} is not a valid age");

   return (age < 60) ? Risk.Low : Risk.Medium;
}

记住,签名是一个合同。签名 int Risk 表示,“给我一个 intint 的 232 个可能值中的任何一个),我会返回一个 Risk。”但实现并没有遵守其签名,对于它认为无效的输入抛出一个 ArgumentException(见图 4.2)。

图 4.2 一个不诚实的函数可能会有在签名中没有考虑到的结果。

这意味着这个函数是 不诚实 的——它 真正 应该说的是“给我一个 int,我 可能 返回一个 Risk,或者我可能抛出一个异常。”有时有合法的理由导致计算失败,但在这个例子中,限制函数输入,使函数始终返回一个有效值是一个更干净的解决方案。

总结来说,如果一个函数的行为可以通过其签名预测,那么这个函数就是诚实的:

  • 它返回声明类型的价值。

  • 它不会抛出异常。

  • 它永远不会返回null

注意,这些要求比函数纯度要求宽松且正式性较低。值得注意的是,一个执行 I/O 操作的函数仍然可以是诚实的。在这种情况下,其返回类型通常应该传达该函数可能会失败或需要很长时间(例如,通过返回一个包裹在ExceptionalTask中的结果,我将在第八章和第十六章分别讨论这些内容。)

4.2.4 将值组合成复杂的数据对象

你可能需要更多数据来微调你的健康风险计算实现。例如,从统计上看,女性的寿命通常比男性长,因此你可能想要考虑这一点:

enum Gender { Female, Male }

Risk CalculateRiskProfile(Age age, Gender gender)
{
   var threshold = (gender == Gender.Female) ? 62 : 60;
   return (age < threshold) ? Risk.Low : Risk.Medium;
}

因此,该函数的签名如下:

(Age, Gender) → Risk

有多少可能的输入值?在这个显然是简化的模型中,Gender有 2 个可能的值,Age有 120 个值,所以总共有 2 × 120 = 240 个可能的输入。注意,如果你定义一个包含AgeGender的元组,可能存在 240 个元组。同样,如果你定义一个类型来存储相同的数据,情况也是如此:

readonly record struct HealthData
{
   Age Age;
   Gender Gender;
};

无论你是调用接受AgeGender的二元函数,还是调用接受HealthData的单元函数,都有 240 种不同的输入可能。它们只是包装得略有不同。

之前我说过,类型代表集合,所以Age类型代表一个包含 120 个元素的集合,而Gender代表一个包含 2 个元素的集合。那么,更复杂的类型,如HealthData,它是在前两种类型的基础上定义的,又该如何呢?

实质上,创建一个HealthData实例相当于从两个集合AgeGender的所有可能组合(笛卡尔积)中选择一个元素。更普遍地说,每次你向类型(或元组)添加一个字段,你就在创建一个笛卡尔积,并为对象可能值的范围增加一个维度,如图 4.3 所示。

图 4.3 对象或元组可以被视为笛卡尔积。

因此,在类型理论中,通过聚合其他类型(无论是在元组、记录、结构或类中)定义的类型被称为积类型。相比之下,你有和类型。例如,如果类型ABC的两个唯一具体实现,那么

|C| = |A| + |B|

可能的C的数量是所有可能的A和所有可能的B的总和。(求和类型也被称为联合类型区分联合以及许多其他名称。)

这就结束了我们对数据对象设计的简要探索。主要的收获是,你应该以一种方式来建模你的数据对象,这样你就可以精细控制你的函数需要处理的数据范围。计算可能的实例数量可以带来清晰度。一旦你控制了这些简单类型,就很容易将它们聚合为更复杂的数据对象。现在,让我们继续探讨所有类型中最简单的一种:空元组或Unit

4.3 使用Unit建模数据缺失

我们已经讨论了如何表示数据;那么当没有数据可以表示时怎么办?许多函数被调用是为了它们的副作用,并返回void。但是void与许多函数式技术不兼容,所以在本节中,我将介绍Unit,这是一种我们可以用来表示数据缺失的类型,而不会遇到void的问题。

4.3.1 为什么void不是理想的选择

让我先说明一下为什么void不是理想的选择。在第 2.1.2 节中,我们介绍了通用的FuncAction委托家族。如果它们是通用的,为什么我们需要两个?为什么我们不能使用Func<Void>来表示一个不返回任何值的函数,就像我们使用Func<string>来表示一个返回string的函数一样?问题在于,尽管框架有System.Void类型和void关键字来表示没有返回值,但Void在编译器中受到特殊处理,因此不能用作返回类型。(实际上,它根本不能从 C#代码中使用。)

让我们看看为什么这在实践中可能成为一个问题。假设你需要了解某些操作所需的时间,为此,你编写了一个高阶函数(HOF),它启动计时器,运行给定的函数,然后停止计时器,并打印出一些诊断信息。这是我在第 2.3 节中演示的设置/拆除场景的典型例子。以下是实现代码:

public static class Instrumentation
{
   public static T Time<T>(string op, Func<T> f)
   {
      var sw = new Stopwatch();
      sw.Start();

      T t = f();

      sw.Stop();
      Console.WriteLine($"{op} took {sw.ElapsedMilliseconds}ms");
      return t;
   }
}

如果你想要读取文件的内容并记录操作所需的时间,你可以使用这个函数:

var contents = Instrumentation.Time("reading from file.txt"
   , () => File.ReadAllText("file.txt"));

很自然地,你可能会想要使用这个与返回void的函数一起。例如,你可能想要测量写入文件所需的时间,所以你希望编写如下代码:

Instrumentation.Time("writing to file.txt"
   , () => File.AppendAllText("file.txt", "New content", Encoding.UTF8));

问题在于AppendAllText返回void,因此它不能表示为Func。为了使前面的代码正常工作,你需要添加一个Instrumentation.Time的重载,它接受一个Action

public static void Time(string op, Action act)
{
   var sw = new Stopwatch();
   sw.Start();

   act();

   sw.Stop();

   Console.WriteLine($"{op} took {sw.ElapsedMilliseconds}ms");
}

这太糟糕了!你不得不因为FuncAction委托之间的不兼容性而复制整个实现。(在异步操作的世界中,TaskTask<T>之间也存在类似的二分法。)你该如何避免这种情况?

4.3.2 在ActionFunc之间架起桥梁

如果你打算使用函数式编程,有一个表示无返回值的不同表示形式是有用的。我们不会使用特殊的语言构造void,而是使用一个特殊值,空元组(也称为Unit)。空元组没有成员,因此它只有一个可能的值。因为它不包含任何信息,所以这相当于没有值。

空元组在System命名空间中可用。不引人注目的是,它被称为ValueTuple,但我会遵循 FP 惯例,称它为Unit

using Unit = System.ValueTuple;

技术上,voidUnit不同之处在于

  • void是一个表示空集合的类型;因此,无法创建其实例。

  • Unit代表一个包含单个值的集合;因此,任何Unit的实例都与任何其他实例等效,并且因此不携带任何信息。

如果你有一个接受Func的 HOF,但你想用它与Action一起使用,你该如何操作?在第二章中,我介绍了你可以编写适配器函数来修改现有函数以适应你的需求的想法。在这种情况下,你想要一种轻松地将Action转换为Func<Unit>的方法。接下来的列表提供了ToFunc函数的定义,它正是这样做的。它包含在我的函数式库LaYumba .Functional中,我开发这个库是为了支持本书的教学。

列表 4.3 将Action转换为Func<Unit>

using Unit = System.ValueTuple;             ❶

namespace LaYumba.Functional;               ❷

public static class ActionExt
{
   public static Func<Unit> ToFunc          ❸
       (this Action action)
       => () => { action(); return default; };

   public static Func<T, Unit> ToFunc<T>    ❸
       (this Action<T> action)
       => (t) => { action(t); return default; };

   // more overloads for Action's with more arguments...
}

❶ 将空元组别名为Unit

❷ 此文件作用域命名空间包括以下所有代码。

❸ 将Action转换为返回UnitFunc的适配器函数

当你用给定的Action调用ToFunc时,你得到一个Func<Unit>。这是一个当被调用时运行Action并返回Unit的函数。

TIP 此列表包含一个文件作用域命名空间,这是 C# 10 中引入的一个特性,用于减少缩进。声明的命名空间适用于文件的内容。

在此基础上,你可以扩展Instrumentation类,添加一个接受Action、将其转换为Func<Unit>并调用与任何Func<T>一起工作的现有重载的方法。接下来的列表展示了这种方法。

列表 4.4 不重复的接受FuncAction的 HOF

using LaYumba.Functional;
using Unit = System.ValueTuple;

public static class Instrumentation
{
   public static void Time(string op, Action act)    ❶
      => Time<Unit>(op, act.ToFunc());               ❷

   public static T Time<T>(string op, Func<T> f)
      => // same as before...
}

❶ 包含一个接受Action的重载

❷ 将Action转换为Func<Unit>并将其传递给接受Func<T>的重载

正如你所见,这使你能够在Time的实现中避免重复任何逻辑。你仍然必须公开接受Action的重载。但考虑到语言的限制,这是处理ActionFunc的最佳折衷方案。

尽管仅凭这个例子你可能不会完全接受Unit,但在这本书中你将看到更多需要UnitToFunc来利用函数式技术的例子。总的来说,

  • 使用void来表示数据的缺失,这意味着你的函数仅用于副作用调用,不返回任何信息。

  • 在需要保持FuncAction处理一致性时,使用Unit作为替代,更灵活的表现形式。

注意:C# 7 引入了元组表示法,允许你编写,例如,(1, "hello")来表示一个二元元组,所以从逻辑上讲,你可能会期望能够编写(1)来表示一元元组,以及()来表示零元元组。不幸的是,由于 C#语法中括号的工作方式,这是不可能的:只有包含两个或更多元素的元组才能使用括号编写。因此,在我们的 C#代码中我们将坚持使用Unit,而在使用箭头表示法时使用()。例如,我将一个Func<int, Unit>表示为int ()

摘要

  • 尽可能使你的函数签名尽可能具体。这使得它们更容易消费且更不容易出错。

  • 使你的函数诚实。诚实的函数总是做其签名所说的,并且给定预期的输入类型,它产生预期的输出类型——没有Exceptions,没有nulls。

  • 使用自定义类型而不是临时的验证代码来约束函数的输入值。

  • 当你需要为不返回数据的函数提供一个更灵活的表现形式时,使用Unit作为void的替代。


¹ 然而,也存在硬件故障、程序耗尽内存等情况的可能性,但这些不是函数实现的内在属性。

² 直到最近,函数库倾向于定义自己的Unit类型,作为一个没有成员的结构体。明显的缺点是这些自定义实现不兼容,因此我呼吁库开发者采用零参数的ValueTuple作为Unit的标准表示形式。

5 模拟数据可能不存在的情况

本章涵盖了

  • 使用Option来表示数据的可能不存在

  • 理解为什么null是一个糟糕的想法

  • 你是否应该使用 C# 8 可空引用类型

在第四章中,我向你介绍了这样一个观点:类型应该精确地表示它们封装的数据,以便编写表达式的函数签名。一个特别棘手的问题是表示可能不可用数据的问题。例如,当你在一个网站上注册时,你通常需要提供你的电子邮件地址,但其他细节,如你的年龄和性别是可选的。网站所有者可能希望处理和分析这些数据如果它们可用

“等等,”你可能正在想,“我们不是用null来做这个的吗?”我将在第 5.5 节中讨论null,但本章的前一部分,你可以假装null不存在,我们必须想出一种方法来表示数据的可能不存在。

当以函数式方式编码时,你永远不使用null——永远不。相反,FP 使用Option类型来表示可选性。我希望向你展示Option提供了一种更健壮和更具表达性的表示。如果你以前从未听说过Option,我请你暂时放下判断,因为Option的附加价值可能直到你看到它在下一两章中使用时才变得明显。

5.1 你每天使用的糟糕 API

在.NET 库中,处理表示数据可能不存在的问题并不优雅。想象一下,你去参加一个工作面试,并被要求进行以下测验:

问题:这个程序会打印什么?

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using static System.Console;

class IndexerIdiosyncracy
{
   public static void Main()
   {
      try
      {
         var empty = new NameValueCollection();
         var green = empty["green"];             ❶
         WriteLine("green!");

         var alsoEmpty = new Dictionary<string, string>();
         var blue = alsoEmpty["blue"];           ❷
         WriteLine("blue!");
      }
      catch (Exception ex)
      {
         WriteLine(ex.GetType().Name);
      }
   }
}

抽空阅读一下代码。注意NameValueCollection只是一个从stringstring的映射。¹ 然后,写下你认为程序会打印的内容(确保没有人看着)。现在,你愿意下多少赌注你得到了正确答案?如果你像我一样,有一种挥之不去的感觉,认为作为一个程序员,你应该关心的事情不仅仅是这些烦人的细节,那么本节的其余部分将帮助你看到问题实际上出在 API 本身,而不是你知识不足。

代码使用索引器从两个空集合中检索项目,所以这两个操作都失败了。索引器当然只是普通函数——[]语法只是糖衣——所以这两个索引器都是类型为string string的函数,并且都是不诚实的。我为什么说不诚实

NameValueCollection索引器❶如果键不存在则返回null。关于null实际上是否是string,有些争议,但我倾向于说不。² 你给索引器一个完全有效的输入string,它返回一个无用的null值——而不是签名所声称的。

Dictionary索引器❷抛出KeyNotFoundException,所以它是一个函数,说“给我一个string,我会返回你一个string”,而实际上它应该说,“给我一个string,我可能返回你一个string,或者我可能抛出一个异常。”

更糟糕的是,这两个索引器以不一致的方式不诚实。现在你知道了这一点,很容易看出程序打印的内容:

green!
KeyNotFoundException

.NET 中两个不同的关联集合暴露的接口不一致。谁能想到呢?唯一的办法就是查看文档(无聊)或者偶然发现一个错误(更糟糕)。让我们看看表示数据可能缺失的功能方法。

5.2 介绍Option类型

Option本质上是一个包含值...或者没有值的容器。它就像一个可能包含东西的盒子,或者它可能是空的。Option的符号定义如下:

Option<T> = None | Some(T)

让我们看看这意味着什么。T是一个类型参数(内部值的类型),所以Option<int>可以包含一个int|符号表示“或”,所以定义说明Option<T>可以是两种情况之一:

  • None—表示值缺失的特殊值。如果Option没有内部值,我们说the OptionNone

  • Some(T)—一个包装类型为T的值的容器。如果Option有内部值,我们说the OptionSome

(如果你想知道,在Option<T>中,我使用尖括号来表示T是一个类型参数;在Some(T)中,我使用圆括号来表示Some是一个函数,它接受一个T并返回一个Option<T>,包装给定的值。)

从集合的角度来看,Option<T>是集合Some(T)与单元素集合None的并集(见图 5.1)。Option是求和类型的一个好例子,我们已经在第 4.2.4 节讨论过。

图 5.1 Option<T>是集合Some<T>与单元素集合None的并集。

如果bool有两个可能的值,那么Some<bool>也有两个可能的值,但Option<bool>有三个可能的值,因为它还包括None。同样,Option<DayOfWeek>有八个可能的值,等等。

我们将在下一小节中查看实现Option,但首先,让我们看看它的基本用法,这样你就熟悉 API 了。我建议你在 REPL 中跟随,但你需要做一些设置,这将在以下侧边栏中描述。

在 REPL 中使用LaYumba.Functional

我开发了自己的功能库LaYumba.Functional,以支持本书中许多技术的教学。在 REPL 中尝试LaYumba.Functional中包含的构造将很有用。这需要你在 REPL 中导入它:

  1. 如果你还没有这样做,请下载并编译来自github.com/la-yumba/functional-csharp-code-2的代码示例。

  2. 在你的 REPL 中引用 LaYumba.Functional 库。具体如何操作取决于你的设置。在我的系统中(使用 Visual Studio 中的 C# Interactive 窗口,并且代码示例解决方案已打开),我可以通过输入以下内容来实现:

    #r "functional-csharp-code-2\LaYumba.Functional\bin\Debug\net6.0\
    ➥ LaYumba.Functional.dll"
    
  3. 在 REPL 中输入以下导入语句:

    using LaYumba.Functional;
    using static LaYumba.Functional.F;
    

设置好之后,你可以创建一些 Option

Option<string> _ = None;               ❶

Option<string> john = Some("John");    ❷

❶ 创建一个 None

❷ 创建一个 Some

这很简单!现在你知道了如何创建 Option,那么你如何与它们交互呢?在最基本的情况下,你可以通过 Match 方法来实现,这是一个执行模式匹配的方法。简单来说,它允许你根据 OptionNone 还是 Some 来运行不同的代码。

例如,如果你有一个可选的名字,你可以编写一个函数,为该名字返回一个问候语,如果没有提供名字,则返回一个通用消息。在 REPL 中输入以下内容:

string Greet(Option<string> greetee)
   => greetee.Match(
      None: () => "Sorry, who?",            ❶
      Some: (name) => $"Hello, {name}");    ❷

Greet(Some("John")) // => "Hello, John"

Greet(None) // => "Sorry, who?"

❶ 如果 greeteeNone,则 Match 评估此函数。

❷ 如果 greeteeSome,则 Match 评估此函数,并将 greetee 的内部值传递给它。

正如你所见,Match 接受两个函数:第一个函数定义在 None 的情况下要做什么;第二个函数定义在 Some 的情况下要做什么。在 Some 的情况下,函数会接收到 Option 的内部值。

在前面的 Match 调用中,使用了命名参数 None:Some: 以增加清晰度。可以省略这些参数:

string greet(Option<string> greetee)
   => greetee.Match
   (
      () => "Sorry, who?",
      (name) => $"Hello, {name}"
   );

通常我会省略它们,因为第一个 lambda 中的空括号 () 已经暗示了一个空的容器(即 None 状态下的 Option),而括号内有参数的括号 (name) 则暗示了一个包含值的容器。 (在 Some 的情况下,括号是可选的,就像任何一元 lambda 一样,但我保留它们以保持这种图形类比。)

如果现在这一切都有些令人困惑,不要担心;随着我们的深入,一切都会变得清晰。现在,这些是需要记住的事情:

  1. 使用 Some(value) 将一个值包装到 Option 中。

  2. 使用 None 创建一个空的 Option

  3. 使用 Match 根据选项的状态执行一些代码。

目前,你可以将 None 视为 null 的替代品,将 Match 视为 null 检查的替代品。你将在后续章节中看到为什么使用 Option 实际上比使用 null 更可取,以及为什么最终你不太需要经常使用 Match

5.3 实现 Option

随意跳到第 5.4 节或首次阅读时浏览这一节。首先,重要的是你要理解足够的内容,以便能够 使用 Option。但如果你想看看内部结构,在这一节中,我将向你展示我在 LaYumba.Functional 中实现 Option 时使用的技巧。这样做是为了向你展示其中几乎没有魔法,以及如何绕过 C# 类型系统的一些限制。你可能想在你跟随的同时将此代码输入到一个空项目中。

5.3.1 选项的理想化实现

在许多类型化的函数式编程语言中,Option 可以用一行代码定义如下:

type Option t = None | Some t

在 C# 中最接近的等效方法是以下内容:

interface Option<T> { }
record None : Option<T>;
record Some<T>(T Value) : Option<T>;

即,我们定义 Option<T> 为一个标记接口,然后为 NoneSome<T> 提供最小实现,表示它们都是有效的 Option<T>Some<T> 包含一个 T,而 None 不包含任何内容。

在这里,我们已经遇到了一个问题:因为 None 实际上不包含一个 T,我们希望无论 T 最终解析为哪种类型,None 都是一个有效的 Option<T>。不幸的是,C# 编译器不允许这样做,因此为了使代码能够编译,我们需要为 None 也提供一个泛型参数。

record None<T> : Option<T>;

我们现在有一个基本的、可工作的实现。

5.3.2 消费一个 Option

接下来,我们想要编写使用模式匹配来消费 Option 的代码。理想情况下,我希望它看起来像这样:

string Greet(Option<string> greetee)
   => greetee switch
   {
      None => "Sorry, who?",
      Some(name) => $"Hello, {name}"
   };

不幸的是,这不能编译。如果我们想要满足 C# 中模式匹配的语法,我们需要将代码重写如下:

string Greet(Option<string> greetee)
   => greetee switch
   {
      None<string> => "Sorry, who?",
      Some<string>(var name) => $"Hello, {name}"
   };

这肯定没有这么优雅(想象一下,如果你有一个比 string 更长的类型名),但至少它可以编译。然而,它确实会生成一个编译器警告,指出“switch 表达式没有处理其输入类型的所有可能值。”这是因为,从理论上讲,可能存在其他实现 Option<string> 的方法,而我们示例中的 switch 表达式并没有考虑到这一点。不幸的是,没有方法可以告诉 C# 我们永远不希望除了 SomeNone 之外的其他东西实现 Option

我们可以通过定义自己的适配器函数 Match 并包含一个丢弃模式来减轻这两个问题。这允许我们进行穷举模式匹配,并给我们一个易于消费的接口:

static R Match<T, R>(this Option<T> opt, Func<R> None, Func<T, R> Some)
   => opt switch
   {
      None<T> => None(),
      Some<T>(var t) => Some(t),
      _ => throw new ArgumentException("Option must be None or Some")
   };

然后,我们可以像这样消费一个 Option

string Greet(Option<string> greetee)
   => greetee.Match
   (
      None: () => "Sorry, who?",
      Some: (name) => $"Hello, {name}"
   );

现在我们有一个优雅、简洁的方式来消费一个 Option。(注意,我们还需要一个接受两个操作的 Match 重载,这样我们就可以根据 Option 的状态来执行某些操作。这可以通过第 4.3.2 节中描述的方法轻松实现。)

5.3.3 创建一个 None

让我们继续创建 Option。为了显式创建一个 None——比如说,为了测试 Greet 是否与 None 一起工作——我们必须编写以下内容:

var greeting = Greet(new None<string>());

这并不好。我特别不喜欢我们必须指定 string 参数:当我们调用一个方法时,我们希望类型推断来解决我们的泛型参数。理想情况下,我们需要一个可以转换为 None<T> 的值,无论 T 的类型如何。

虽然你不能用继承来实现这一点,但结果证明你可以用类型转换来实现。为了实现这一点,我们需要定义一个专用、非泛型类型,NoneType

struct NoneType { }

接下来,我们将 Option<T> 改为包括从 NoneTypeNone<T> 的隐式转换:

abstract record Option<T>
{
   public static implicit operator Option<T>(NoneType _)
      => new None<T>();
}

这实际上告诉运行时可以在期望 Option<T> 的地方使用 NoneType 的实例,并指示运行时将 NoneType 转换为 None<T>。最后,我们包括一个名为 None 的便利字段,它存储一个 NoneType

public static readonly NoneType None = default;

你现在可以通过简单地输入None来创建一个None<T>

Greet(None) // => "Sorry, who?"

这好多了!请注意,这假设None字段在作用域内,这可以通过using static实现。

在前面的代码片段中,None返回一个NoneType。由于Greet期望一个Option<string>,运行时会调用我们在Option<T>中定义的隐式转换,这会产生一个None<string>。所有这些完成后,你可以忘记NoneType的存在,只需编写知道None会为期望的T返回一个None<T>的代码。

5.3.4 创建一个 Some

现在来创建一个Some。首先,由于Some表示值的存在,不应该可能将null包装到Some中。为了做到这一点,我们不会依赖于编译器为记录生成的自动方法,而是会显式定义构造函数:

record Some<T> : Option<T>
{
   private T Value { get; }

   public Some(T value)
      => Value = value ?? throw new ArgumentNullException();

   public void Deconstruct(out T value)
      => value = Value;
}

在这里,我也将Option的内部值设置为private,这样它只能在模式匹配中解构Option时访问。然后我们可以定义一个便利函数Some,它将给定的值包装成一个Some

public static Option<T> Some<T>(T t) => new Some<T>(t);

有了这个,我们可以这样创建一个Some

Greet(Some("John")) // => "Hello, John"

现在我们有了创建NoneSome的简洁语法。为了锦上添花,我还会定义一个从TOption<T>的隐式转换:

abstract record Option<T>
{
   public static implicit operator Option<T>(T value)
      => value is null ? new None<T>() : new Some<T>(value);
}

这意味着T可以在期望Option<T>的地方使用,并且会自动被包装成一个Some<T>——除非它是null,在这种情况下它将是一个None<T>。这个代码片段让我们免去了显式调用Some的需要:

Greet(None)   // => "Sorry, who?"
Greet("John") // => "Hello, John"

它还允许我们轻松地将返回null的函数转换为返回Option的函数:

var empty = new NameValueCollection();
Option<string> green = empty["green"];

green // => None

5.3.5 优化Option实现

由于许多原因,在我的LaYumba.Functional库中,我选择使用稍微不同的方法,并定义Option如下所示。

列表 5.1 Option的 C#优化实现

public struct Option<T>
{
   readonly T? value;                                        ❶
   readonly bool isSome;                                     ❷

   internal Option(T value)                                  ❸
   {
      this.value = value ?? throw new ArgumentNullException();
      this.isSome = true;
   }

   public static implicit operator Option<T>(NoneType _)
      => default;                                            ❹

   public static implicit operator Option<T>(T value)
      => value is null ? None : Some(value);

   public R Match<R>(Func<R> None, Func<T, R> Some)          ❺
       => isSome ? Some(value!) : None();                    ❺
}

❶ 被 Some 包裹的值

❷ 表示OptionSome还是None

❸ 构建一个处于Some状态的Option

❹ 构建一个处于None状态的Option

❺ 一旦构建了Option,与之交互的唯一方式是通过Match

在这个实现中,我并不是使用不同的类型,而是使用状态(即isSome标志)来表示OptionSome还是None。我提供了一个单构造函数,它创建一个处于Some状态的Option。这是因为我已经将Option定义为结构体,结构体有一个隐式的无参数构造函数,它将所有字段初始化为其默认值。在这种情况下,isSome标志被初始化为false,表示OptionNone。这个实现有几个优点:

  • 性能更好,因为结构体是在栈上分配的。

  • 由于是结构体,Option不能是null

  • Optiondefault值是None(在记录中是null)。

其他所有内容(NoneType、隐式转换和Match的接口)与之前讨论的相同。最后,我在F类中定义了Some函数和None值,这使得你可以轻松创建Option

namespace LaYumba.Functional;

public static partial class F
{
   public static Option<T> Some<T>(T value) => new Option<T>(value);
   public static NoneType None => default;
}

现在你已经看到了拼图的每一块,再看看我之前展示的例子。现在应该更清晰了:

using LaYumba.Functional;
using static LaYumba.Functional.F;

string Greet(Option<string> greetee)
   => greetee.Match
   (
      None: () => "Sorry, who?",
      Some: (name) => $"Hello, {name}"
   );

Greet(Some("John")) // => "Hello, John"

Greet(None) // => "Sorry, who?"

正如你所见,有几种不同的方法可以在 C#中实现Option。我选择了这种特定的实现,因为它从客户端代码的角度提供了最干净的 API。但Option是一个概念,而不是特定的实现,所以如果你在另一个库或教程中看到不同的实现,请不要惊慌。³ 它仍然具有Option的标志性特征:

  • 一个表示值不存在的None

  • 一个Some函数,它封装一个值,表示存在一个值

  • 根据值是否存在来执行代码的方式(在我们的案例中,是Match

Option也被称为Maybe

不同的函数式框架使用不同的术语来表达类似的概念。Option的一个常见同义词是Maybe,其中SomeNone状态分别称为JustNothing

这种命名不一致性在函数式编程(FP)中很常见,这并不利于学习过程。在这本书中,我将尝试展示每个模式或技术最常见的同义词,然后坚持使用一个名称。从现在开始,我将坚持使用Option。只需知道,如果你遇到Maybe(比如在 JavaScript 或 Haskell 库中),它具有相同的概念。

让我们现在看看一些实际场景,在这些场景中你可以使用Option

5.4 作为部分函数的自然结果类型的Option

我们讨论了函数如何将一个集合的元素映射到另一个集合,以及类型如何表示这些集合。在总函数和部分函数之间有一个重要的区别:

  • 总函数—对定义域中的每个元素都进行映射

  • 部分函数—只对定义域中的某些元素而不是所有元素进行映射

部分函数有问题是,当给定的输入无法计算结果时,不清楚函数应该做什么。Option类型提供了一个完美的解决方案来模拟这种情况:如果函数为给定的输入定义了,它返回一个封装结果的Some;否则,它返回None。让我们看看一些常见的使用场景,我们可以使用这种方法。

5.4.1 解析字符串

想象一个解析整数字符串表示的函数。你可以将其建模为一个类型为string int的函数。这显然是一个部分函数,因为并非所有字符串都是有效的整数表示。事实上,有无限多个字符串无法映射到int

你可以通过让解析函数返回Option<int>来提供一个更安全的解析表示。如果给定的string无法解析,它将返回None,如图 5.2 所示。

图 5.2 使用Option传达解析是一个部分函数。对于提供有效整数表示的输入字符串,解析函数将解析的int包装到Some中。否则,它返回None

签名为string int的解析函数是部分函数,从签名中不清楚如果你提供一个无法转换为intstring会发生什么。另一方面,签名为string Option<int>的解析函数是全函数,因为对于任何给定的字符串,它返回一个有效的Option<int>。以下是一个使用 BCL 方法执行繁重工作但公开基于Option的 API 的实现:

public static class Int
{
   public static Option<int> Parse(string s)
      => int.TryParse(s, out int result)
         ? Some(result) : None;
}

本小节中的辅助函数包含在LaYumba.Functional中。你可以在 REPL 中尝试它们:

Int.Parse("10")    // => Some(10)
Int.Parse("hello") // => None

定义了类似的方法来解析字符串到其他常用类型,如双精度浮点数和日期,以及更一般地,将一种形式的数据转换为另一种更严格的形式。

5.4.2 在集合中查找数据

在 5.1 节中,我向你展示了某些集合公开的 API 在表示数据缺失方面既不诚实也不一致。要点如下:

new NameValueCollection()["green"]
// => null

new Dictionary<string, string>()["blue"]
// => runtime error: KeyNotFoundException

基本问题是以下内容。关联集合将键映射到值,因此可以被视为类型TKey TValue的函数。但是,没有保证集合包含每个可能的键的值,因此查找值是一个部分函数。

通过返回Option来建模值的检索是一个更好、更明确的方法。可以编写适配器函数来公开基于Option的 API,我通常将这些返回Option的函数命名为Lookup

Lookup : (NameValueCollection, string) → Option<string>

Lookup接受一个NameValueCollection和一个string(键)并返回一个包含值的Some,如果键存在,否则返回None。以下列表显示了实现。

列表 5.2 将返回null的函数改为返回Option

public static Option<string> Lookup
   (this NameValueCollection collection, string key)
   => collection[key];

就这样!表达式collection[key]string类型,而声明的返回值是Option<string>,因此string值将被隐式转换为Option<string>null被替换为None。我们付出了最小的努力,就从基于null的 API 转换到了基于Option的 API。

这里是一个Lookup的重载版本,它接受一个IDictionary。签名类似:

Lookup : (IDictionary<K, T>, K) → Option<T>

我们可以将Lookup函数实现如下:

public static Option<T> Lookup<K, T>(this IDictionary<K, T> dict, K key)
   => dict.TryGetValue(key, out T value) ? Some(value) : None;

现在我们有一个诚实、清晰且一致的 API 来查询这两个集合:

new NameValueCollection().Lookup("green")
// => None

new Dictionary<string, string>().Lookup("blue")
// => None

由于你请求的键不在集合中,不再有KeyNotFoundExceptionNullReferenceException。我们可以将相同的方法应用于查询其他数据结构。

5.4.3 智能构造器模式

在 4.2.2 节中,我们定义了Age类型,这是一个比int更严格的类型,因为并非所有的int都代表有效的年龄。你同样可以用Option来建模,如图 5.3 所示。

图 5.3 将int转换为Age也可以用Option来建模。

如果你需要从一个int创建一个Age,而不是调用必须抛出异常以创建有效实例的构造函数,你可以定义一个返回SomeNone的函数来指示Age创建成功。这被称为智能构造函数:它之所以智能,是因为它了解一些规则,可以防止创建无效的对象。以下列表展示了这种方法。

列表 5.3 实现Age的智能构造函数

public struct Age
{
   private int Value { get; }

   public static Option<Age> Create(int age)        ❶
      => IsValid(age) ? Some(new Age(age)) : None;

   private Age(int value)                           ❷
      => Value = value;

   private static bool IsValid(int age)
      => 0 <= age && age < 120;
}

❶ 一个智能构造函数返回Option

❷ 构造函数现在应该标记为private

如果你现在需要从一个int获取Age,你会得到Option<Age>,这迫使你必须考虑失败的情况。

5.5 处理null

在本章的开头,我要求你假装 C#中没有null,我们必须想出一种表示可选值的方法。真正函数式语言没有null,而是用Option类型来表示可选值。然而,一些最受欢迎的编程语言,包括 C#,不仅允许null的存在,而且将其用作所有引用类型的默认值。在本节中,我将向你展示为什么这是一个问题以及如何解决这个问题。

5.5.1 为什么null是一个如此糟糕的想法

让我们看看为什么null会导致这么多问题。

不严谨的数据建模

在 4.2.4 节中,你看到元组(Age, Gender)有(120 × 2) = 240 个可能的值。如果你将这两个值存储在结构体中,情况也是一样的。现在,如果你定义一个类或记录来保存这些值,如下所示

record HealthData(Age age, Gender Gender);

那么实际上有 241 个可能的值,因为引用类型可以是null。如果你将Age重构为类,你现在有 121 个可能的Age值和 243 个可能的HealthData值!null不仅污染了数据的数学表示,而且我们还得编写代码来处理所有这些可能的值。

模糊的函数签名

你可能听说过NullReferenceException是单个最常见的错误来源。但为什么它如此普遍?我相信答案在于一个基本的不确定性:

  • 因为引用类型默认是null,你的程序可能会因为编程错误而遇到null,其中所需值根本未初始化。

  • 有时,null被认为是一个合法的值;例如,NameValueCollection的作者决定通过返回null来表示键不存在是可以的。

由于无法声明null值是故意的还是编程错误的后果(至少在 C# 8 的可空引用类型之前是这样,我将在 5.5.3 节中讨论),你经常对如何处理null值感到怀疑。你应该允许null吗?你应该抛出ArgumentNullException?你应该让NullReferenceException向上冒泡吗?本质上,每个接受或返回引用类型的函数都是模糊的,因为不清楚null值是合法的输入还是输出。

防御性 null 检查

合法null和无意null之间的歧义不仅会导致错误。它还有一个可能更严重的影响:它会导致防御性编程。为了防止潜在的NullReferenceException,开发者会在代码中充斥着null检查和对null参数的断言。虽然使用这些断言有合理的理由(见 5.5.4 节),但如果在整个代码库中使用,它们会制造很多噪音。

5.5.2 通过使用 Option 代替 null 来提高健壮性

解决这些问题的主要步骤是永远不要使用null作为合法值。相反,使用Option来表示可选值。这样,任何null的出现都是编程错误的后果。(这意味着你永远不需要检查null;只需让NullReferenceException向上冒泡。)让我们看看一个例子。

想象你网站上有一个表单,允许人们订阅时事通讯。用户输入他的名字和电子邮件,这会导致Subscriber的实例化,然后持久化到数据库中。Subscriber定义如下:

public record Subscriber
(
   string Name,
   string Email
);

当是时候发送时事通讯时,为订阅者计算一个自定义问候语,并将其添加到时事通讯的主体之前:

public string GreetingFor(Subscriber subscriber)
   => $"Dear {subscriber.Name.ToUpper()},";

所有这些都工作得很好。Name不能为null,因为它是在注册表单中的必填字段,并且在数据库中不可为空。

几个月后,新订阅者的注册率下降,因此公司决定降低进入门槛,不再要求新订阅者输入他们的名字。姓名字段从表单中删除,数据库也相应进行了修改。

这应该被视为一个破坏性变更,因为不再可能对数据进行相同的假设。然而,代码仍然可以顺利编译。当是时候发送时事通讯时,GreetingFor在接收到没有NameSubscriber时会抛出异常。

到这时,负责在数据库中使姓名可选的人可能和负责维护发送时事通讯代码的人不在同一个团队。代码可能位于不同的存储库中。简而言之,查找Name的所有用法可能并不简单。因此,最好明确指出Name现在是可选的。也就是说,应该将Subscriber更改为

public record Subscriber
(
   Option<string> Name,    ❶
   string Email
);

Name现在被明确标记为可选。

这不仅清楚地传达了Name的值可能不可用的信息,还导致GreetingFor无法编译。GreetingFor以及任何其他访问Name属性的代码将需要修改,以考虑值可能不存在的情况。例如,你可能修改如下:

public string GreetingFor(Subscriber subscriber)
   => subscriber.Name.Match
   (
      () => "Dear Subscriber,",
      (name) => $"Dear {name.ToUpper()},"
   );

通过使用Option,你迫使 API 的用户处理没有数据可用的情况。这给客户端代码带来了更大的压力,但有效地消除了NullReferenceException发生的可能性。

string更改为Option<string>是一个破坏性变更:这样,你是在用编译时错误交换运行时错误,从而使编译的应用程序更加健壮。

5.5.3 非空引用类型?

广泛接受的观点是,在语言设计中存在可空类型是一个缺陷。这一点在一定程度上得到了证实,因为 C#的许多版本都引入了处理null的新语法,逐渐使语言更加复杂,但从未从根本上解决问题。

C# 8 通过引入一个名为可空引用类型(NRT)的功能,对解决这个问题做出了最激进的尝试。考虑到在 C#中引用类型始终是可空的,这个名字可能看起来有些奇怪;其目的是,该功能允许你标记你打算设置为可空的类型,并且编译器会跟踪你如何访问这些类型的实例。例如,NRT 允许你编写

#nullable enable           ❶

public record Subscriber
(
   string? Name,           ❷
   string Email            ❸
);

❶ 启用后续代码中的 NRT 功能

❷ 一个可空字段

❸ 一个非空字段

这允许你在声明中明确指定哪些值可以是null。此外,如果你在未进行null检查的情况下取消引用Name,你将收到编译器警告,告诉你Name可能为null

#nullable enable

public string GreetingFor(Subscriber subscriber)
   => $"Dear {subscriber.Name.ToUpper()},";

// => CS8602 Dereference of a possibly null reference

表面上看,你可能会认为这个特性取代了Option,在某种程度上,它确实如此。然而,当你深入探究时,你会发现一些问题:

  • 你需要通过将Nullable元素添加到你的项目文件中(或如前所述,在你的文件中添加#nullable指令)来显式选择此功能。

  • 即使你在项目级别选择了 NRT,仍然可以通过使用#nullable disable指令在文件中覆盖此设置。这意味着你不能孤立地推理代码:你现在需要查看不同的地方以确定一个string是否可以为空。

  • 只有当可空值声明和引用该值的代码都在 NRT 启用上下文中时,编译器警告才会出现,这再次使得孤立地推理代码变得困难。

  • 除非你将警告视为错误,否则在将string更改为string?等更改后,你的代码仍然可以编译,这因此不是破坏性变更,并且在一个有很多警告的代码库中会被忽略。

  • 编译器并不能总是跟踪你沿途所做的null检查,例如,

    public string GreetingFor(Subscriber subscriber)
       => IsValid(subscriber)                        ❶
          ? $"Dear {subscriber.Name.ToUpper()},"     ❷
          : "Dear Subscriber";
    

    ❶ 检查 subscriber.Name 是否不是 null

    ❷ 仍然警告你可能正在解引用一个 null

    即使 IsValid 检查表明 Name 不是 null,也会导致编译器警告。为了解决这个问题,你必须学习一组不为人知的属性,以防止编译器警告这些 假阳性。⁴

  • 未标记为可空的字段仍然可能成为 null(例如,在反序列化对象时):

    #nullable enable
    
    var json = @"{""Name"":""Enrico"", ""Email"":null}";
    var subscriber = JsonSerializer.Deserialize<Subscriber>(json);
    
    if (subscriber is not null)
       WriteLine(subscriber.Email.ToLower());
    // => throws NullReferenceException
    
  • 该特性不允许你在值类型和引用类型之间以统一的方式处理可选性。尽管 int?string? 等之间的语法相似,但它们是完全不同的:int?Nullable<int> 的缩写,所以我们有一个包裹 int 的结构,与 Option 有点相似。另一方面,string? 是一个注释,告诉编译器该值可能是 null

注意,当使用 Option 类型时,这些限制都不适用。总的来说,尽管我在 NRT 开发初期感到兴奋,但现在我倾向于认为它来得太晚了,太少了。似乎语言团队为这个特性设定了一个大胆的计划,但后来将其稀释,以便用户可以不费太多力气就将现有的代码库迁移到 C# 8。

如果你在一个接受 NRT 并决定在所有地方使用它的团队中工作,或者如果在几年后采用变得普遍,那么 NRT 确实会带来价值。但在写作的时候,如果你在处理各种项目并使用各种库,其中并非所有都使用 NRT,我看不出 NRT 会带来真正的益处。

5.5.4 防止 NullReferenceException

根据我们之前讨论的所有内容,在我看来,防止 null 值造成破坏的最稳健的方法如下。首先

  • 如果你使用 C# 8,启用 NRT。这有助于确保必需值始终被初始化。更重要的是,它向你的代码的消费者传达了意图,这些消费者也启用了 NRT。

  • 对于可选值,使用 Option<T> 而不是 T?

这意味着,在你的代码的边界内,你可以确信没有任何值是 null。你不应该进行任何 null 检查,也不应该抛出任何 ArgumentNullException

其次,确定你的代码的边界。这包括

  • 你打算发布或跨项目共享的库公开的方法

  • Web API

  • 监听来自消息代理或持久队列的消息

在这些边界中,防止 null 值渗透进来

  • 对于必需值

    • 抛出 ArgumentNullException

    • 返回一个状态码为 400(请求错误)的响应。

    • 拒绝消息。

  • 对于可选值,将 null 值转换为 Option

    • 在 C# 中,这可以通过隐式转换轻易完成。

    • 如果你的边界涉及反序列化以其他格式发送的数据,你可以将转换逻辑添加到你的格式化器中。

第三,当你使用 .NET 或第三方库时,你还需要防止 null 漏入。你在列表 5.2 中看到了如何做到这一点的一个例子,我们在其中定义了 NameValueCollection 上的返回 OptionLookup 方法。

将 JSON null 转换为 C# Option

为了方便起见,我的 LaYumba.Functional 库包含一个与 .NET 的 System.Text.Json 兼容的格式化工具,展示了如何将 JSON 对象中的 null 转换为 C# 的 Option 类型,并将其转换回。以下是如何使用它的一个示例:

using System.Text.Json;
using LaYumba.Functional.Serialization.Json;

record Person
(
   string FirstName,
   Option<string> MiddleName,
   string LastName
);

JsonSerializerOptions ops = new()
{
   Converters = { new OptionConverter() }
};

var json = @"{""FirstName"":""Virginia"",
   ""MiddleName"":null, ""LastName"":""Woolf""}";
var deserialized = JsonSerializer.Deserialize<Person>(json, ops);

deserialized.MiddleName // => None

json = @"{""FirstName"":""Edgar"",
   ""MiddleName"":""Allan"", ""LastName"":""Poe""}";
deserialized = JsonSerializer.Deserialize<Person>(json, ops);

deserialized.MiddleName // => Some("Allan")

总结来说,当表示一个“可选”的值时,Option 应该是你的默认选择。在你的数据对象中使用它来表示属性可能未设置的事实,并在你的函数中用来表示可能不会返回合适值的可能性。除了减少 NullReferenceException 的可能性之外,这还将丰富你的模型并使你的代码更具自文档性。在你的函数签名中使用 Option 是实现第四章总体建议的一种方式:设计诚实且高度描述性的函数签名,以便调用者可以期待。

在接下来的章节中,我们将探讨如何有效地使用 Option。虽然 Match 是与 Option 交互的基本方式,但我们将从下一章开始构建一个丰富的高级 API。Option 将成为你的朋友,不仅当你将其用于程序中时,而且作为一个简单的结构,通过它我将展示许多函数式编程(FP)概念。

练习

  1. 编写一个泛型 Parse 函数,它接受一个字符串并将其解析为 enum 的值。它应该可以像以下这样使用:

    Enum.Parse<DayOfWeek>("Friday")  // => Some(DayOfWeek.Friday)
    
    Enum.Parse<DayOfWeek>("Freeday") // => None
    
  2. 编写一个 Lookup 函数,它接受一个 IEnumerable 和一个谓词,并返回 IEnumerable 中与谓词匹配的第一个元素或 None,如果没有找到匹配的元素。用箭头符号写出其签名:

    bool isOdd(int i) => i % 2 == 1;
    
    new List<int>().Lookup(isOdd)     // => None
    new List<int> { 1 }.Lookup(isOdd) // => Some(1)
    
  3. 编写一个 Email 类型,它包装一个底层的字符串,并强制执行其格式有效。确保包括以下内容:

    • 智能构造函数

    • 隐式转换为字符串,以便可以轻松地与发送电子邮件的典型 API 一起使用

  4. 查看在 System.LINQ.Enumerable 中定义在 IEnumerable 上的扩展方法。⁵ 哪些可能返回空值或抛出某种未找到异常,因此是返回 Option<T> 的良好候选?

摘要

  • 使用 Option 类型来表示值的可能缺失。Option 可以处于两种状态之一:

    • None,表示值的缺失

    • Some,一个包装非 null 值的简单容器

  • 要根据 Option 的状态有条件地执行代码,使用 Match 与你想要在 NoneSome 情况下评估的函数。

  • 当一个函数无法保证对所有可能的输入都返回有效输出时,使用 Option 作为返回值

    • 在集合中查找值

    • 创建需要验证的对象(智能构造函数)

  • 确定你代码的边界,并防止任何null值渗透进来:

    • 强制必要的值。

    • 将可选值转换为Option


¹ 在.NET 的早期阶段,NameValueCollection被频繁使用,因为通常使用ConfigurationManager.AppSettings.config文件中获取配置设置。这已被更近期的配置提供者所取代,因此你可能不会经常遇到NameValueCollection,尽管它仍然是.NET 的一部分。

² 事实上,语言规范本身也这么说:如果你将null赋值给变量,如string s = null;,那么s is string评估为false

³ 例如,流行的模拟框架 NSubstitute 包括Option的实现。

⁴ 更多详情,请参阅mng.bz/10XQ

⁵ 请参阅 Microsoft 关于可枚举方法的文档:mng.bz/PXd8

6 函数式编程中的模式

本章涵盖

  • 核心函数:ReturnMapBindWhereForEach

  • 介绍函子和单子

  • 在不同的抽象级别上工作

模式是一种可以应用于解决各种问题的解决方案。在本章中我们将讨论的模式仅仅是函数:在函数式编程中,这些函数非常普遍,以至于它们可以被视为 FP 的核心函数

你可能熟悉其中的一些函数,如WhereSelect(它等同于Map),因为你在使用IEnumerable时使用过它们。但你会看到相同的操作可以应用于其他结构,从而建立一种模式。我将在本章中用Option来说明这一点;其他结构将在接下来的章节中介绍。

如往常一样,我建议你在 REPL 中输入代码并查看这些核心函数如何使用。(你需要导入第五章侧边栏中显示的LaYumba.Functional库。)

6.1 将函数应用于结构内部值

我们将首先查看的核心函数是Map。它接受一个结构和函数,并将函数应用于结构的内部值。¹ 让我们从熟悉的案例开始,其中涉及的结构是一个IEnumerable

6.1.1 将函数映射到序列

IEnumerableMap实现可以写成如下。

列表 6.1 将函数应用于给定的IEnumerable中的每个元素

public static IEnumerable<R> Map<T, R>
   (this IEnumerable<T> ts, Func<T, R> f)
{
   foreach (var t in ts)
      yield return f(t);
}

Map通过将函数T R应用于源列表中的每个元素,将T类型的列表映射到R类型的列表。请注意,在这个实现中,结果是通过使用yield return语句包装到IEnumerable中的。

备注:在 FP 中,使用像t这样的变量名来表示类型T的值,ts来表示T的集合,以及fgh等)来表示函数是很常见的。在为更具体的场景编码时,你可以使用更具描述性的名称,但当函数像Map这样非常通用时,你实际上对值t或函数f一无所知,变量名就相应地更通用。

图形上,Map可以如图 6.1 所示。

图 6.1 在IEnumerable上映射一个函数。这个操作产生一个包含将给定函数应用于源列表中每个项的结果的列表。

让我们看看一个简单的用法:

using static System.Linq.Enumerable;
using LaYumba.Functional;

var triple = (int x) => x * 3;

Range(1, 3).Map(triple);
// => [3, 6, 9]

也许你已经意识到,这正是当你调用 LINQ 的Select方法时得到的行为。确实,Map可以用Select来定义:

public static IEnumerable<R> Map<T, R>
   (this IEnumerable<T> ts, Func<T, R> f)
   => ts.Select(f);

这可能更有效,因为 LINQ 的Select实现针对某些特定的IEnumerable实现进行了优化。重点是,我将使用Map这个名字而不是Select,因为Map是 FP 中的标准术语,但你应该将MapSelect视为同义词。

6.1.2 将函数映射到 Option

现在,让我们看看如何为不同的结构定义MapOption。就像将函数映射到列表抽象化了列表的结构或实现方式、包含的项目数量以及函数如何应用到每个元素上一样,对于Option,我们希望将函数应用到其内部值上,而不需要了解Option的状态或实现细节。IEnumerableMap签名是

(IEnumerable<T>, (T → R)) → IEnumerable<R>

要获取OptionMap签名,我们只需遵循模式,将IEnumerable替换为Option

(Option<T>, (T → R)) → Option<R>

这个签名表明Map是给定的

  • 一个可能包含TOption

  • 一个从TR的函数

它必须返回一个可能包含ROption。你能想到一个实现吗?让我们看看:

  • 如果给定的OptionNone,那么就没有T可用,也无法计算出一个R:你能做的只是返回None

  • 另一方面,如果OptionSome,那么它的内部值是一个T,因此你可以将给定的函数应用到它上面,得到一个R,然后你可以将其包裹在一个Some中。

因此,我们可以定义Map,如下列所示。这也在图 6.2 中表示。

列表 6.2 OptionMap定义

public static Option<R> Map<T, R>
(
   this Option<T> optT, 
   Func<T, R> f
)
=> optT.Match
(
   () => None,
   (t) => Some(f(t))
);

图 6.2 在Option上映射函数

直观地讲,将Option视为一种特殊的列表,它可以是空的(None)或者恰好包含一个值(Some)。如果你从这个角度来看,就会变得很明显,OptionIEnumerableMap实现是一致的:给定的函数会被应用到结构的所有内部值上。让我们看看一个简单的例子:

var greet = (string name) => $"hello, {name}";

Option<string> empty   = None;
Option<string> optJohn = Some("John");

empty.Map(greet);   // => None
optJohn.Map(greet); // => Some("hello, John")

这里有一个现实生活中的类比:你有一个可爱的老阿姨,她擅长做苹果派(图 6.3)。她讨厌购物,但天哪,她喜欢做派(单一职责原则)。

图 6.3 你阿姨烤派,但只有当篮子里有苹果时!

你经常在上班的路上在她家门口放下一个装满苹果的篮子,晚上你会发现一个装满新鲜派饼的篮子!你的阿姨也很有幽默感,所以如果你机灵地在她家门口留一个空篮子,你也会得到一个空篮子作为回报。

在这个类比中,篮子代表Option。苹果是其内部值,你阿姨的烹饪技巧是应用到苹果上的函数。Map是将苹果取出,交给阿姨处理,然后重新装进烤好的派的过程。以下是这个例子在代码中的实现方式:

record Apples();
record ApplePie(Apples Apples);

var makePie = (Apples apples) => new ApplePie(apples);

Option<Apples> full  = Some(new Apples());
Option<Apples> empty = None;

full.Map(makePie)  // => Some(ApplePie)
empty.Map(makePie) // => None

6.1.3 选项如何提高抽象级别

一个非常重要的事情是要意识到Option抽象了值是否存在的问题。如果你直接对一个值应用一个函数,你必须确保该值可用。相反,如果你将那个函数MapOption上,你实际上并不关心值是否存在——Map根据情况应用函数或不应用。

在这一点上可能还不完全清楚,但随着你继续阅读本书,它将变得清晰。现在,让我们看看我能否说明这个想法。在第四章中,我们定义了一个函数,该函数根据Age来计算Risk,如下所示:

Risk CalculateRiskProfile(Age age)
   => (age < 60) ? Risk.Low : Risk.Medium;

现在,假设你正在进行一项调查,人们自愿提供一些个人信息并接收一些统计数据。调查参与者由以下定义的Subject类来模拟:

record Subject
(
   Option<Age> Age,
   Option<Gender> Gender,
   // many more fields...
);

一些字段如Age被建模为可选的,因为调查参与者可以选择是否披露此信息。这就是你计算特定SubjectRisk的方式:

Option<Risk> RiskOf(Subject subject)
   => subject.Age.Map(CalculateRiskProfile);

因为Risk是基于主题的年龄,而年龄是可选的,所以计算出的Risk也是可选的。你不必担心Age是否存在;相反,你可以映射计算风险的函数,并通过返回结果作为Option来允许可选性传播。接下来,让我们更一般地看看Map

6.1.4 函子的介绍

你已经看到Map是一个遵循精确模式的函数,并且它用于将函数应用到结构如IEnumerableOption的内部值上。它可以定义许多其他数据结构,包括集合、字典、树等。

让我们推广这个模式。让C<T>表示一个泛型容器,它封装了类型为T的一些内部值。然后Map的签名可以推广如下:

Map : (C<T>, (T → R)) → C<R>

Map可以被定义为一个函数,它接受一个容器C<T>和一个类型为(T → R)的函数f。它返回一个容器C<R>,该容器封装了将f应用到容器内部值的结果。

在函数式编程中,为这种Map函数定义类型的类型被称为函子。² IEnumerableOption是函子,正如你所看到的,你将在书中遇到更多。

对于实际应用来说,我们可以认为任何具有合理Map实现的类型都是一种函子。但什么是合理的实现呢?本质上,Map应该将一个函数应用到容器内部的值上,并且同样重要的是,它不应该做其他任何事情——Map不应该有副作用。³

为什么函子不是一个接口?

如果OptionIEnumerable都支持Map操作,为什么我们不通过接口来捕获这一点?确实,这样做会很不错,但不幸的是,在 C#中这是不可能的。为了说明原因,让我们尝试定义这样一个接口:

interface Functor<F<>, T>
{
   F<R> Map<R>(Func<T, R> f);
}

public struct Option<T> : Functor<Option, T>
{
   public Option<R> Map<R>(Func<T, R> f) => // ...
}

这无法编译:我们无法使用 F<> 作为类型变量,因为与 T 不同,它不表示一个类型,而是一个 类型类别:一个反过来参数化泛型类型的类型。而且,Map 只需返回一个 Functor 是不够的。它必须返回与当前实例相同类别的函子。

其他语言(包括 Haskell 和 Scala)支持所谓的 高阶类型类别,因此可以使用 类型类 来表示这些更通用的接口,但在 C#(甚至 F#)中,我们必须满足于较低层次的抽象,并遵循基于模式的途径.^a

^a 有可能在 C# 类型系统中创造性地使用,并找到类似于类型类的表示,但生成的代码相当复杂,因此不适合本书的意图。

6.2 使用 ForEach 执行副作用

在第四章中,我们讨论了 FuncAction 之间的二分法。我们再次遇到这个问题,与 Map 相关:Map 接受一个 Func,如果我们想在给定的结构中为每个值执行一个 Action,我们该怎么办?你可能知道 List<T> 有一个 ForEach 方法,它接受一个 Action<T> 并为列表中的每个项目调用它:

using static System.Console;

new List<int> { 1, 2, 3 }.ForEach(Write);
// prints: 123

这基本上就是我们想要的。让我们将其推广,以便我们可以在任何 IEnumerable 上调用 ForEach

using System.Collections.Immutable;
using Unit = System.ValueTuple;

public static IEnumerable<Unit> ForEach<T>
   (this IEnumerable<T> ts, Action<T> action)
   => ts.Map(action.ToFunc()).ToImmutableList();

代码将 Action 转换为返回 Unit 的函数,然后依赖于 Map 的实现。这仅创建了一个 Unit 的惰性评估序列。实际上,我们希望执行副作用;因此,调用了 ToImmutableList。使用方法出人意料地简单:

Enumerable.Range(1, 5).ForEach(Write);
// prints: 12345

现在,让我们看看 OptionForEach 定义。这是在 Map 的基础上定义的,使用将 Action 转换为 FuncToFunc 函数:⁴

public static Option<Unit> ForEach<T>
   (this Option<T> opt, Action<T> action)
   => Map(opt, action.ToFunc());

ForEach 的名称可能有些令人费解——记住,Option 至多有一个内部值,所以给定的操作恰好调用一次(如果 OptionSome),或者永远不会调用(如果它是 None)。以下是一个使用 ForEachAction 应用到 Option 的示例:

var opt = Some("John");

opt.ForEach(name => WriteLine($"Hello {name}"));
// prints: Hello John

然而,记住第三章的内容,我们应该旨在将纯逻辑与副作用分离。我们应该使用 Map 进行逻辑,使用 ForEach 进行副作用,因此最好将前面的代码重写如下:

opt.Map(name => $"Hello {name}")
   .ForEach(WriteLine);

TIP 尽量使使用 ForEach 应用的 Action 的作用域尽可能小:使用 Map 进行数据转换,使用 ForEach 进行副作用。这遵循了通用 FP 理念,即尽可能避免副作用,否则将其隔离。

在 REPL 中花点时间进行实验,看看 MapForEach 可以与 IEnumerableOption 一起使用。以下是一个示例:

using static System.Console;
using String = LaYumba.Functional.String;

Option<string> name = Some("Enrico");

name
   .Map(String.ToUpper)
   .ForEach(WriteLine);
// prints: ENRICO

IEnumerable<string> names = new[] { "Constance", "Albert" };

names
   .Map(String.ToUpper)
   .ForEach(WriteLine);

// prints: CONSTANCE
//         ALBERT

注意,无论你是处理Option还是IEnumerable,都可以使用相同的模式。这不是很好吗?现在你可以将OptionIEnumerable视为特殊容器,并且有一组核心函数允许你与之交互。如果你遇到一种新的容器类型,并且定义了MapForEach,你可能会对它们的作用有一个很好的了解,因为你可以识别出这种模式。

注意:在前面的代码中,我使用了LaYumba.Functional.String类,它通过静态方法公开了一些常用的System.String功能。这允许我将String.ToUpper作为一个函数来引用,而无需指定ToUpper实例方法所作用的实例,如下所示:s => s.ToUpper()

总结来说,ForEachMap类似,但它接受一个Action而不是函数,因此它用于执行副作用。让我们继续介绍下一个核心函数。

6.3 使用Bind链式调用函数

Bind是另一个重要的函数,类似于Map,但稍微复杂一些。我将通过一个例子来介绍Bind的需求。

假设你想要一个简单的程序,该程序从控制台读取用户的年龄并打印出一些相关消息。你还需要错误处理:年龄应该是有效的!

记住,在上一章中,我们定义了Int.Parse来解析字符串为int。我们还定义了Age.Create,一个智能构造函数,它可以从给定的int创建一个Age实例。这两个函数都返回Option

Int.Parse : string → Option<int>
Age.Create : int → Option<Age>

让我们看看如果我们用Map组合它们会发生什么:

string input = Prompt("Please enter your age:");

Option<int> optI = Int.Parse(input);
Option<Option<Age>> ageOpt = optI.Map(i => Age.Create(i));

如你所见,我们遇到了一个问题!我们最终得到了一个嵌套的值:OptionOptionAge……我们该如何处理这个嵌套值呢?

6.3.1 组合返回Option的函数

在这种情况下,Bind非常有用。对于Option,这是Bind的签名:

Option.Bind : (Option<T>, (T → Option<R>)) → Option<R>

Bind接受一个Option和一个返回Option的函数,并将该函数应用于Option的内部值。下面的列表显示了实现。

列表 6.3 OptionBindMap实现

public static Option<R> Bind<T, R>
(
   this Option<T> optT,
   Func<T, Option<R>> f      ❶
) 
=> optT.Match
(
   () => None,
   (t) => f(t)
);

public static Option<R> Map<T, R>
(
   this Option<T> optT,
   Func<T, R> f              ❷
) 
=> optT.Match
(
   () => None,
   (t) => Some(f(t))
);

Bind接受一个返回Option的函数。

Map接受一个普通函数。

前面的列表复制了Map的定义,以便你可以看到它们是多么相似。简单来说,None情况总是返回None,这样就不会应用给定的函数。Some情况确实应用了函数;然而,与Map不同,没有必要将结果打包进一个Option,因为f已经返回了一个Option

现在我们来看看如何在解析表示一个人年龄的字符串的例子中应用Bind。下面的列表显示了这种表示。

列表 6.4 使用Bind组合返回Option的两个函数

Func<string, Option<Age>> parseAge = s
   => Int.Parse(s).Bind(Age.Create);

parseAge("26");        // => Some(26)
parseAge("notAnAge");  // => None
parseAge("180");       // => None

函数 parseAge 使用 Bind 来组合 Int.Parse(它返回一个 Option<int>)和 Age.Create(它返回一个 Option<Age>)。因此,parseAge 结合了检查字符串是否表示有效整数的检查,以及检查整数是否是有效年龄值的检查。

现在我们来看一个简单的程序,它从控制台读取年龄并打印出相关消息的上下文:

WriteLine($"Only {ReadAge()}! That's young!");

static Age ReadAge()
   => ParseAge(Prompt("Please enter your age")).Match
   ( () => ReadAge(), (age) => age );        ❶

static Option<Age> ParseAge(string s)
   => Int.Parse(s).Bind(Age.Create);         ❷

static string Prompt(string msg)
{
   WriteLine(msg);
   return ReadLine();
}

❶ 递归调用自身,直到解析 age 失败

❷ 将解析 stringint 和从 int 创建 Age 结合起来

这里是这个程序的一个示例交互(用户输入以粗体显示):

Please enter your age
> hello
Please enter your age
> 500
Please enter your age
> 45
Only 45! That's young!

现在我们来看看 BindIEnumerable 的结合方式。

6.3.2 使用 Bind 平铺嵌套列表

你刚刚看到了如何使用 Bind 来避免嵌套 Option。同样的想法也适用于列表。但嵌套列表是什么?二维列表!我们需要一个函数,它将对列表应用返回列表的函数。但与其返回一个二维列表,它应该将结果平铺成一个一维列表。

记住 Map 会遍历给定的 IEnumerable 并对每个元素应用一个函数。Bind 类似于 Map,但有一个嵌套循环,因为应用 绑定 函数也会产生一个 IEnumerable。结果列表被平铺成一个一维列表。这可能在代码中更容易理解:

public static IEnumerable<R> Bind<T, R>
   (this IEnumerable<T> ts, Func<T, IEnumerable<R>> f)
{
   foreach (T t in ts)
      foreach (R r in f(t))
         yield return r;
}

如果你非常熟悉 LINQ,你会认识到这个实现几乎与 SelectMany 相同。对于 IEnumerableBindSelectMany 是相同的。再次强调,在这本书中,我将使用 Bind 这个名字,因为在函数式编程中它是标准的。

让我们用一个例子来看看它的实际应用。假设你有一个邻居列表,每个邻居都有一个宠物列表。你想要一个包含社区中所有宠物的列表:

using Pet = System.String;

record Neighbor(string Name, IEnumerable<Pet> Pets);

var neighbors = new Neighbor[]
{
   new (Name: "John", Pets: new Pet[] {"Fluffy", "Thor"}),
   new (Name: "Tim",  Pets: new Pet[] {}),
   new (Name: "Carl", Pets: new Pet[] {"Sybil"}),
};

IEnumerable<IEnumerable<Pet>> nested = neighbors.Map(n => n.Pets);
// => [["Fluffy", "Thor"], [], ["Sybil"]]

IEnumerable<Pet> flat = neighbors.Bind(n => n.Pets);
// => ["Fluffy", "Thor", "Sybil"]

注意到使用 Map 会产生嵌套的 IEnumerable,而 Bind 则产生平铺的 IEnumerable。(也请注意,无论从哪个角度看前面示例的结果,Bind 并不一定比 Map 产生更多的项目,这确实使得选择 SelectMany 这个名字看起来相当奇怪。)图 6.4 展示了 BindIEnumerable 上的图形表示,特别针对邻域示例中的类型和数据。

图 6.4 Bind 接受一个源列表和一个为源列表的每个项目返回列表的函数。它返回一个平铺的列表,而 Map 返回一个列表的列表。

如你所见,每次函数应用都会产生一个 IEnumerable,然后所有应用的结果都被平铺成一个单一的 IEnumerable

6.3.3 实际上,它被称为单子

现在我们来泛化 Bind 的模式。如果我们用 C<T> 来表示包含类型 T 的值的一些结构,那么 Bind 就会接受容器的一个实例和一个具有签名 (T → C<R>) 的函数,并返回一个 C<R>Bind 的签名始终是这个形式:

Bind : (C<T>, (T → C<R>)) → C<R>

你看到,在所有实际用途中,函子是定义了合适的 Map 函数的类型,使你能够将函数应用于函子的内部值。同样,单子 是定义了 Bind 函数的类型,使你能够有效地组合两个(或更多)返回单子的函数,而不会得到嵌套结构。有时你会听到人们谈论 单子绑定 来澄清他们不仅仅是在谈论某个名为 Bind 的函数,而是在谈论 那个 允许类型被视为单子的 Bind 函数。

6.3.4 Return 函数

除了 Bind 函数外,单子还必须有一个 Return 函数,该函数可以将普通值 T 提升或包装成单子值 C<T>。有些令人困惑的是,Return 函数通常不被称为Return,而是根据所讨论的结构采用不同的名称。对于 Option,这就是我们在第五章中定义的 Some 函数。

IEnumerableReturn 函数是什么?因为有许多 IEnumerable 的实现,所以有创建 IEnumerable 的许多可能方法。在我的函数式库中,我有一个适合 IEnumerableReturn 函数,称为 List。为了坚持函数式原则,List 返回一个不可变列表:

using System.Collections.Immutable;

public static IEnumerable<T> List<T>(params T[] items)
   => items.ToImmutableList();

List 函数不仅满足 Return 函数的要求,允许我们将简单的 T 提升到 IEnumerable<T>。此外,多亏了 params 参数,它还为我们提供了初始化列表的简洁语法:

using static F;

var empty  = List<string>();            // => []
var single = List("Andrej");            // => ["Andrej"]
var many   = List("Karina", "Natasha"); // => ["Karina", "Natasha"]

总结来说,单子是一个类型 M<T>,对于该类型,定义了以下函数:

Return : T → M<T>

Bind   : (M<T>, (T → M<R>)) → M<R>

对于类型被视为“正确”的单子,BindReturn 必须遵守某些属性;这些被称为 单子定律。为了避免在本章中过度加载理论,我将把对单子定律的讨论推迟到第 10.3 节。

简而言之,Return 应仅执行将 T 提升到 M<T> 所需的最小工作量,而无需做其他事情。它应该尽可能简单。

6.3.5 函子和单子之间的关系

我说过,函子是定义了 Map 的类型,而单子是定义了 ReturnBind 的类型。你也看到了,OptionIEnumerable 都是函子 单子,因为这些函数都定义了。

自然会产生两个问题:每个单子也是函子吗?每个函子也是单子吗?为了回答第一个问题,让我们再次看看我们迄今为止看到的核心函数的签名:

Map    : (C<T>, (T → R)) → C<R>

Bind   : (C<T>, (T → C<R>)) → C<R>

Return : T → C<T>

如果你有一个 BindReturn 的实现,你可以用这些实现来编写 MapMap 作为输入接受的函数 T R 可以通过与 Return 组合变成一个类型为 T C<R> 的函数。这个函数反过来可以给 Bind 作为输入。

为了让您确信这一点,我在练习中建议您用 BindReturn 来实现 Map。尽管实现是正确的,但它可能不是最优的,所以通常,Map会有自己的实现,不依赖于Bind。然而,这意味着每个单子也是一个函子。

关于第二个问题,答案是:并非每个函子都是单子。Bind不能在Map的术语下定义,因此拥有Map的实现并不能保证可以定义Bind函数。例如,某些类型的树支持Map但不支持Bind。另一方面,对于本书中我们将讨论的大多数类型,MapBind都可以定义。

6.4 使用 Where 过滤值

在第二章中,您看到了使用Where来过滤IEnumerable值的几个示例。实际上,Where也可以为Option定义,如下所示。

列表 6.5 过滤 Option 的内部值

public static Option<T> Where<T>
(
   this Option<T> optT, 
   Func<T, bool> pred
)
=> optT.Match
(
   () => None,
   (t) => pred(t) ? optT : None
);

给定一个Option和一个谓词,如果给定的OptionSome,并且其内部值满足给定的谓词,则返回Some;否则,返回None。再次强调,如果您将Option视为最多只有一个元素的列表,这就有意义了。以下是一个简单的用法:

bool IsNatural(int i) => i >= 0;
Option<int> ToNatural(string s) => Int.Parse(s).Where(IsNatural);

ToNatural("2")     // => Some(2)
ToNatural("-2")    // => None
ToNatural("hello") // => None

在这里,我们使用Int.Parse(在 5.4.1 节中定义),它返回一个Option,表示字符串是否已正确解析为int。然后我们使用Where来额外确保值是正数。

这就结束了我们对核心函数的初步探索。在您继续阅读本书的过程中,您将看到更多,但到目前为止描述的函数可以带您走很长的路,您将在下一章中看到这一点。

核心函数的许多名称

学习函数式编程的一个难点是,相同的结构在不同的语言或库中具有不同的名称。这一点对于核心函数来说也是正确的,因此我包括以下表格,以帮助您在其他地方遇到这些同义词时理解它们。

LaYumba.Functional LINQ 常见同义词
Map Select fMap, Project, Lift
Bind SelectMany FlatMap, Chain, Collect, Then
Where Where Filter
ForEach n/a Iter
Return n/a Pure

在编写本书和LaYumba.Functional库时,我必须决定为这些函数选择哪些名称,这些选择必然是有些任意的。ForEachWhere是好的名称,并且在.NET 中是标准的,但如果用于IEnumerable之外的函子或单子,SelectSelectMany将是不好的名称。我选择使用MapBind,它们更通用、更简短,并且在函数式编程文献中是标准的。

6.5 使用 Bind 结合 Option 和 IEnumerable

我提到过,看待 Option 的方法之一是将其视为一个特殊的列表,可以是空的 (None) 或恰好包含一个值 (Some)。你可以通过以下方式在代码中表达这一点,使得可以将 Option 转换为 IEnumerable

public struct Option<T>
{
   public IEnumerable<T> AsEnumerable()
   {
      if (isSome) yield return value!;
   }
}

如果 OptionSome,则 resulting IEnumerable 产生一个项目;如果它是 None,则不产生任何项目。在函子之间映射的函数,如 AsEnumerable,被称为 自然变换,在实践上非常有用。

IEnumerable 通常用于存储数据,而 Option 在值不存在时跳过计算,因此它们的意图通常不同。尽管如此,在某些情况下,将它们结合起来是有用的。在某些场景中,你最终会得到一个 IEnumerable<Option<T>>(或反之,Option<IEnumerable<T>>),并希望将其展平为 IEnumerable<T>

例如,让我们回到调查的例子,其中每个参与者都被模拟为 Subject。由于参与者披露年龄是可选的,我们模型 Subject.AgeOption<Age>

record Subject(Option<Age> Age);

IEnumerable<Subject> Population => new[]
{
   new Subject(Age.Create(33)),
   new Subject(None),             ❶
   new Subject(Age.Create(37)),
};

❶ 这个人没有披露他们的年龄。

你存储了参与者的详细信息在一个 IEnumerable<Subject> 中。现在假设你需要计算参与者的平均年龄。你该如何着手呢?你可以从选择所有 Age 的值开始:

IEnumerable<Option<Age>> optionalAges = Population.Map(p => p.Age);
// => [Some(Age(33)), None, Some(Age(37))]

如果你使用 Map 来选择调查者的年龄,你会得到一个选项列表。由于一个 Option 可以被视为一个列表,因此 optionalAges 可以被视为列表的列表。为了将这种直觉转化为代码,让我们给 Bind 添加一些重载,将 Option 转换为 IEnumerable,这样 Bind 就可以像我们在展平嵌套的 IEnumerable 一样应用:

public static IEnumerable<R> Bind<T, R>
   (this IEnumerable<T> list, Func<T, Option<R>> func)
   => list.Bind(t => func(t).AsEnumerable());

public static IEnumerable<R> Bind<T, R>
   (this Option<T> opt, Func<T, IEnumerable<R>> func)
   => opt.AsEnumerable().Bind(func);

尽管根据 FP 理论 Bind 应该只作用于一种容器类型,但 Option 总是可以 提升 到更通用的 IEnumerable,这使得这些重载在实践上是有效且非常有用的:

  • 我们可以使用第一个重载来获取 IEnumerable<T>,其中 Map 会给我们一个 IEnumerable<Option<T>>,就像当前的调查示例一样。

  • 我们可以使用第二个重载来获取 IEnumerable<T>,其中 Map 会给我们一个 Option<IEnumerable<T>>

在调查场景中,我们现在可以使用 Bind 过滤掉所有的 None 并获取所有实际给出的年龄列表:

var optionalAges = Population.Map(p => p.Age);
// => [Some(Age(33)), None, Some(Age(37))]

var statedAges = Population.Bind(p => p.Age);
// => [Age(33), Age(37)]

var averageAge = statedAges.Map(age => age.Value).Average();
// => 35

这允许我们利用 Bind 的展平特性来过滤掉所有的 None 情况。前面的输出显示了调用 MapBind 的结果,以便你可以比较结果。

6.6 在不同抽象级别上进行编码

抽象(在英语中,不是在面向对象编程中)意味着为了使一个普遍的、共同的特征出现,需要移除不同具体事物的特定特征:一个概念。例如,当你说你“会看到一排房子”或“把你的鸭子排成一排”时,这个概念移除了使鸭子与房子不同的任何东西,只捕捉了它们的空間排列。

IEnumerableOption这样的类型在其核心具有这样的概念抽象:它们内部值的所有特定特征都被抽象掉了。这些类型只捕获了枚举值或值可能不存在的能力。对于大多数泛型类型来说,情况也是如此。让我们尝试概括这一点,以便你所学的 Option 可以帮助你理解书中(以及在其他库中)稍后看到的其他构造。

6.6.1 常规与提升价值

当你处理像 IEnumerable<int>Option<Employee> 这样的类型时,你编写的代码比处理像 intEmployee 这样的非泛型类型时处于更高的抽象层次。让我们将我们处理的价值世界分为两类:

  • 常规值,我们将称之为 TStringintNeighborDayOfWeek 都是常规值的例子。

  • 提升的价值,我们将称之为 A<T> Option<int>IEnumerable<string>Func<Neighbor>Task<bool> 都是提升价值的例子。

在这里,提升价值意味着对相应常规类型的抽象。⁵ 这些抽象是使我们能够更好地处理和表示底层类型上的操作的构造。更技术地说,一个 抽象 是向底层类型添加效果的一种方式。⁶ 让我们看看一些例子:

  • Option 添加了 可选项 的效果,这不仅仅是一个 T,而是 T可能性

  • IEnumerable 添加了 聚合 的效果,这不仅仅是一个 T 或两个,而是一个 T序列

  • Func 添加了 惰性 的效果,这不仅仅是一个 T,而是一个可以评估以获得 T计算

  • Task 添加了 异步性 的效果,这不仅仅是一个 T,而是一个 承诺,在某个时刻你会得到一个 T

如前所述的例子所示,本质上非常不同的东西可以被认为是抽象,所以试图将这个概念放入一个框中是没有什么意义的。更有趣的是看看这些抽象如何被运用。

回到常规与提升价值,你可以将这些不同类型的值可视化如图 6.5 所示。这个图显示了常规类型 int 的一个示例,以及一些样本值和相应的抽象 A<int>,其中 A 可以是任意抽象。将常规值取走并包裹在相应的 A 中的箭头代表 Return 函数。

图 6.5 Return 将常规值提升为提升价值。

6.6.2 跨越抽象层次

现在我们有了这种类型的分类,我们可以相应地分类函数。我们有保持在同一抽象层次上的函数,也有跨越抽象层次之间的函数,如图 6.6 所示。⁷

图 6.6 按抽象层次分类的函数

让我们看看几个例子。函数 (int i) => i.ToString() 的签名是 int string,因此它将一个常规类型映射到另一个类型,显然属于第一种类型。

我们一直在使用的 Int.Parse 函数的类型是 string Option<int>,因此它是一个向上交叉函数——第三种类型。斯科特·沃尔申将这些称为 世界交叉 函数,因为它们从 世界 中的正常值 T 转到提升值 E<T> 的世界(外星?)。⁸

Return 函数,对于任何抽象 A,其类型为 T A<T>,是一个特殊的向上交叉函数,除了向上交叉之外不做任何事情。这就是为什么我将 Return 显示为垂直向上箭头,而任何其他向上交叉函数都显示为斜线箭头。

第二种类型的函数保持在抽象内部。例如,以下函数是一个明显的匹配:

(IEnumerable<int> ints) => ints.OrderBy(i => i)

它的签名形式是 A<T> A<R>。但我们也应该将任何以 A<T> 开始,有额外的参数,并最终以 A<R> 结束的函数包含在这个类别中。也就是说,任何应用保持在我们抽象内部的函数;它的签名将是 (A<T>, ...) A<R>。这包括我们查看过的许多 HOF(高阶函数),如 MapBindWhereOrderBy 等。

最后,向下交叉函数,其中我们从一个提升值开始,最终得到一个常规值,包括 IEnumerableAverageSumCount,以及 OptionMatch。请注意,给定一个抽象 A,并不总是可能定义一个与 Return 相对应的向下函数;通常没有垂直向下箭头。你总是可以将 int 提升到 Option<int>,但你不能 降低 Option<int>int。如果它是 None 呢?同样,你可以将单个 Employee 包装到 IEnumerable<Employee> 中,但没有任何明显的方法可以将 IEnumerable<Employee> 减少到单个 Employee

6.6.3 Map vs. Bind,再次回顾

让我们看看我们如何使用这种分类来更好地理解 MapBind 之间的区别。Map 接受一个提升值 A<T> 和一个 常规 函数 T R,并返回一个类型为 A<R> 的提升值,如图 6.7 所示。

图 6.7 以常规值和提升值来解释 Map

Bind 也接受一个提升值 A<T>,然后是一个类型为 T A<R>向上交叉 函数,并返回一个类型为 A<R> 的提升值,如图 6.8 所示。

图 6.8 以常规值和提升值来解释 Bind

主要区别在于 Map 接受一个常规函数,而 Bind 接受一个向上交叉函数。如果你使用类型为 T A<R> 的向上交叉函数与 Map 一起使用,你最终会得到一个嵌套的 A<A<R>> 类型的值。这通常不是期望的效果,你可能应该使用 Bind。请注意,MapBind 本身都是操作提升值的函数,因为它们都接受一个 A<T> 并产生一个 A<R>

6.6.4 在正确的抽象级别上工作

在不同抽象级别上工作的这个想法很重要。如果你总是处理常规值,你可能会陷入低级操作,如 for 循环、null 检查等。在如此低的抽象级别上工作既低效又容易出错。在同一个抽象级别内工作确实有一个明确的最佳点,如下面的代码片段(来自第一章)所示:

Enumerable.Range(1, 100).
   Where(i => i % 20 == 0).
   OrderBy(i => -i).
   Select(i => $"{i}%")
 // => ["100%", "80%", "60%", "40%", "20%"]

一旦你使用 Range 将常规值转换为 IEnumerable<int>,所有后续的计算都保持在 IEnumerable 抽象内部。也就是说,保持在同一个抽象级别可以让你很好地组合多个操作——我们将在下一章深入探讨这一点。

如果你处理的是形式为 A<B<C<D<T>>>> 的值,其中每个级别都添加了一个抽象,并且难以处理深深埋藏的 T,这也存在风险。这一点我将在第十七章中讨论。

在本章中,你看到了一些用于处理 OptionIEnumerable 的核心函数的实现。尽管实现很简单,但你已经看到了这如何为我们提供了一个丰富的 API 来处理 Option,就像你习惯于处理 IEnumerable 一样。可以为 OptionIEnumerable 定义一些常见操作——适用于不同类型结构的模式。有了这个 API 和对 FP 核心函数的更好理解,你就可以处理更复杂的情况了。

练习

  1. 实现 ISet<T>IDictionary<K, T>Map。(提示:首先用箭头符号写下签名。)

  2. 根据 BindReturn 实现 OptionIEnumerableMap

  3. 使用 Bind 和第 5.4.2 节中定义的返回 OptionLookup 函数来实现 GetWorkPermit(如下代码片段所示)。然后丰富实现,使 GetWorkPermit 在工作许可证过期时返回 None

  4. 使用 Bind 来实现 AverageYearsWorkedAtTheCompany(如下代码片段所示)。只应包括已离职的员工:

    Option<WorkPermit> GetWorkPermit(Dictionary<string, Employee> employees
       , string employeeId) => // your implementation here...
    
    double AverageYearsWorkedAtTheCompany(List<Employee> employees)
       => // your implementation here...
    
    public record Employee
    (
       string Id,
       Option<WorkPermit> WorkPermit,
       DateTime JoinedOn,
       Option<DateTime> LeftOn
    );
    
    public record WorkPermit
    (
       string Number,
       DateTime Expiry
    );
    

摘要

  • 类似于 Option<T>IEnumerable<T> 的结构可以被视为容器或抽象,使你能够更有效地处理类型 T 的底层值。

  • 你可以区分常规值(例如,T)和提升值(如 Option<T>IEnumerable<T>)。

  • 允许你有效地处理提升值的 FP 核心函数包括

    • Return,它将常规值提升到高值

    • Map,将函数应用于结构内的值并返回一个新结构,该结构包装了结果

    • ForEach,是Map的副作用变体,它接受一个动作,并为容器内的每个内部值执行该动作

    • Bind,将返回Option的函数映射到Option上,并将结果扁平化以避免产生嵌套的Option,类似地对于IEnumerable和其他结构

    • Where,根据给定的谓词过滤结构内的值

  • 对于Map有定义的类型称为functor。对于ReturnBind有定义的类型称为monad


¹ 内部值也被称为绑定值。

² 不幸的是,术语“functor”在不同的语境中有不同的含义。在数学中,它标识了被映射的函数;在编程中,它是你可以映射函数的容器。

³ 这不是官方定义,但本质上等价。

⁴ 你可能会问自己,“为什么不直接为Map添加一个接受Action的重载?”问题在于在这种情况下,当我们调用Map而不指定其泛型参数时,编译器无法解析到正确的重载。原因相当技术性:重载解析不考虑输出参数,因此在重载解析时无法区分Func<T, R>Action<T>。这种重载的代价是调用Map时必须始终指定泛型参数,这又会引起噪音。简而言之,最好的解决方案是引入一个专门的ForEach方法。

⁵ 其他作者将高值称为包裹值、放大值等。

⁶ 在这个语境中,“效果”有完全不同的含义,不应与“副作用”混淆。这是不幸的,但却是标准的术语。

⁷ 这种分类并不全面,因为你可以设想更多类别,在这些类别中,应用函数会导致你跳过几个抽象级别,或者从一个抽象类型跳到另一个抽象类型。但这些都可能是你遇到的最常见的函数类型,所以这种分类仍然是有用的。

⁸ 请参阅斯科特的“理解 map 和 apply”文章,链接在此:fsharpforfunandprofit.com/posts/elevated-world

7 使用函数组合设计程序

本章涵盖

  • 使用函数组合和方法链定义工作流程

  • 编写易于组合的函数

  • 处理服务器请求的端到端示例

函数组合不仅强大且表达能力强,而且易于使用。它在任何编程风格中都有一定程度的运用,但在函数式编程中,它被广泛使用。例如,你有没有注意到,当你使用 LINQ 处理列表时,你只需几行代码就能完成很多事情?这是因为 LINQ 是一个以组合为设计理念的函数式 API。

在本章中,我们将介绍函数组合的基本概念和技术,并使用 LINQ 来展示其用法。我们还将实现一个端到端的服务器端工作流程,在这个工作流程中,我们将使用第六章中引入的Option API。这个示例展示了函数方法的一些思想和好处,因此我们将以对这些内容的讨论结束本章。

7.1 函数组合

让我们先回顾一下函数组合及其与方法链的关系。函数组合是任何程序员隐性知识的一部分。它是你在学校学到的数学概念,然后每天都在不经意间使用。让我们快速复习一下定义。

7.1.1 快速复习函数组合

给定两个函数fg,你可以定义一个函数h,它是这两个函数的组合,表示如下:

h = f · g

h应用于值x等同于先对x应用g,然后对结果应用f

h(x) = (f · g)(x) = f(g(x))

例如,假设你想为在 Manning 工作的人获取电子邮件地址。你可以有一个函数计算本地部分(标识人员),另一个则添加域名:

record Person(string FirstName, string LastName);

static string AbbreviateName(Person p)
   => Abbreviate(p.FirstName) + Abbreviate(p.LastName);

static string AppendDomain(string localPart)
   => $"{localPart}@manning.com";

static string Abbreviate(string s)
   => s.Substring(0, Math.Min(2, s.Length)).ToLower();

AbbreviateNameAppendDomain是两个你可以组合以得到一个新的函数,该函数可以生成我假设的合作伙伴的 Manning 电子邮件。请看以下列表。

列表 7.1 定义一个函数为两个现有函数的组合

Func<Person, string> emailFor =
   p => AppendDomain(AbbreviateName(p));    ❶

var joe = new Person("Joe", "Bloggs");
var email = emailFor(joe);

email // => jobl@manning.com

emailForAppendDomainAbbreviateName组合。

有几点值得注意。首先,你只能组合具有匹配类型的函数:如果你正在组合(f · g),那么g的输出必须可以赋值给f的输入类型。

第二,在函数组合中,函数的顺序与它们执行顺序相反,所以f · g有时读作“fg之后。”例如,在AppendDomain(AbbreviateName(p))中,你首先执行最右侧的函数,然后是左侧的函数。这不利于可读性,尤其是当你想组合多个函数时。

C#没有为函数组合提供任何特殊的语法支持,尽管你可以定义一个高阶函数Compose来组合两个或多个函数,但这并不会提高可读性。这就是为什么在 C#中,最好求助于方法链。

7.1.2 方法链

方法链式语法(即使用 . 运算符链式调用多个方法的调用)提供了在 C#中实现函数组合的更可读的方式。给定一个表达式,你可以将其链式调用到任何在表达式的类型上定义的实例或扩展方法。例如,前面的例子需要按以下方式修改:

static string AbbreviateName(this Person p)           ❶
   => Abbreviate(p.FirstName) + Abbreviate(p.LastName);

static string AppendDomain(this string localPart)     ❶
    => $"{localPart}@manning.com";

this 关键字使这成为一个扩展方法

你现在可以将这些方法链式调用以获取某人的电子邮件。以下列表显示了这种方法。

列表 7.2 使用方法链式语法组合函数

var joe = new Person("Joe", "Bloggs");
var email = joe.AbbreviateName().AppendDomain();

email // => jobl@manning.com

注意现在扩展方法出现的顺序是它们将被执行的顺序。这显著提高了可读性,尤其是在工作流程复杂性增加时(更长的方法名、额外的参数、更多要链式调用的方法),这也是为什么方法链式调用是 C#中实现函数组合的首选方式。

关于扩展方法的常见误解

扩展方法使用 . 运算符调用,就像实例方法一样,但其语义与实例方法不同。例如,假设你定义了一个类型 Circle 如下:

record Circle(Point Center, double Radius);
record Point(double X, double Y);

如果你现在将 MoveScale 定义为 Circle 上的实例方法,这意味着 Circle 知道 如何移动/缩放自己或 负责 移动自己。这是面向对象看待事物的方式。

在函数式编程(FP)中,另一方面,我们会将这个逻辑放入与它们作用的数据分离的函数中(更多内容请见第 11.4 节)。例如,看看以下内容:

static class Geometry                                         ❶
{
   static Circle Move(this Circle c, double x, double y) => new
   (
      Center: new Point(c.Center.X + x, c.Center.Y + y),      ❷
      Radius: c.Radius
   );

   static Circle Scale(this Circle c, double factor) => new
   (
      Center: c.Center,
      Radius: c.Radius * factor                               ❸
   );
}

❶ 一个用于处理圆的函数模块

❷ 返回一个被移动的圆

❸ 返回一个被缩放的圆

我们将 MoveScale 定义为扩展方法的事实使我们能够像这样调用它们:

Circle Modify(this Circle c)
   => c
      .Move(10, 10)
      .Scale(2)

这与没有扩展方法语法的相应调用等效,但更易读。

Circle Modify(this Circle c)
   => Scale(Move(c, 10, 10), 2)

深受面向对象编程(OOP)影响的开发者往往将扩展方法视为实例方法;例如,仅仅因为 Move 使用了 this 修饰符标记了给定的圆,他们倾向于认为 Move 属于 Circle 或因此 Circle 知道负责 移动自己。

这是一个你应该摒弃的误解。在当前示例中,你应该将 MoveScale 简单地视为处理给定数据的函数;我们使用它们作为扩展方法纯粹是为了可读性。

7.1.3 高级世界中的组合

函数组合非常重要,它也应该在高级值的世界中成立。让我们继续当前示例,确定某人的电子邮件地址,但现在我们有一个 Option<Person> 作为起始值。你会假设以下内容成立:

Func<Person, string> emailFor =
   p => AppendDomain(AbbreviateName(p));       ❶

var opt = Some(new Person("Joe", "Bloggs"));

var a = opt.Map(emailFor);                     ❷

var b = opt.Map(AbbreviateName)                ❸
           .Map(AppendDomain);                 ❸

a.Equals(b) // => true

emailForAppend-DomainAbbreviateName 组成。

❷ 映射组合函数

❸ 分步骤映射 AbbreviateNameAppendDomain

无论你是分步映射 AbbreviateNameAppendDomain,还是将它们的组合 emailFor 在一个步骤中映射,结果都不应该改变。你应该能够安全地在这两种形式之间重构。

更普遍地,如果 h = f · g,那么将 h 映射到函子应该等同于先映射 g 到该函子,然后映射 f 到结果。这应该适用于任何函子和任何一对函数——这是函子定律之一,因此任何 Map 的实现都应该遵守它。¹

如果这听起来很复杂,那可能是因为它描述的是你直觉上认为应该始终明显成立的事情。确实,打破这条定律并不容易,但你可能会想出一个恶作剧般的函子,比如,它保留一个内部计数器,记录 Map 被应用了多少次(或者每次调用 Map 时以其他方式改变其状态),然后前面的情况就不会成立,因为 b 的内部计数会比 a 更大。

简而言之,Map 应该将函数应用于函子的内部值(或值),而不再做其他任何事情,以确保在处理函子时函数组合保持不变,就像处理正常值时一样。这种方法的优点在于,你可以在任何编程语言中使用任何函数库,并且可以自信地使用任何函子,因为像在前面代码片段中从 ab 的重构这样的重构将是安全的。

7.2 从数据流的角度思考

你可以用函数组合编写整个程序。每个函数以某种方式处理其输入,输出成为下一个函数的输入。当你这样做时,你开始从数据流的角度看待你的程序:程序只是一组函数,数据通过一个函数流到下一个函数。图 7.1 展示了一个线性流——最简单且最有用的类型。

图片

图 7.1 数据通过一系列函数流动

7.2.1 使用 LINQ 的可组合 API

在前面的例子中,我们通过使 AbbreviateNameAppendDomain 方法成为扩展方法,使它们可链式调用。这也是 LINQ 设计中采取的方法,如果你查看 System.Linq.Enumerable,你会看到它包含了许多用于处理 IEnumerable 的扩展方法。让我们看看使用 LINQ 组合函数的一个例子。

假设给定一个群体,你想找到最富有的四分位数(即目标群体中最富有的 25% 的人)的平均收入。你可以编写如下所示的内容。

列表 7.3 通过链式方法在 Linq.Enumerable 中定义查询

record Person(decimal Earnings);

static decimal AverageEarningsOfRichestQuartile(List<Person> population)
   => population
      .OrderByDescending(p => p.Earnings)
      .Take(population.Count / 4)
      .Select(p => p.Earnings)
      .Average();

注意你可以如何干净地使用 LINQ 编写这个查询(与使用控制流语句强制性地编写相同的查询相比)。你可能有一种感觉,内部代码将遍历列表,并且 Take 将有一个 if 检查来只返回请求的项目数,但你并不真的关心。相反,你可以以扁平工作流的形式安排你的函数调用——一系列指令的线性序列:

  1. 对人口进行排序(最富有的人排在顶部)。

  2. 只取前 25%。

  3. 取每个人的收入。

  4. 计算它们的平均值。

注意代码与工作流描述的相似性。让我们从数据流的角度来看它:你可以将 AverageEarningsOfRichestQuartile 函数视为一个简单的程序。它的输入是 List<Person>,输出是 decimal

此外,AverageEarningsOfRichestQuartile 实际上是四个函数的组合,因此输入数据通过四个转换步骤,如图 7.2 所示,逐步转换为输出值。

图 7.2 AverageEarningsOfRichestQuartile 函数中的数据流

第一个函数 OrderByDescending 保留了数据的类型,并按收入对人口进行排序。第二步也保留了数据的类型,但改变了基数:如果输入的人口由 n 个人组成,Take 现在只返回 n/4 个人。Select 保留了基数,但将类型改为 decimal 的列表,而 Average 再次将类型改为返回单个 decimal 值。²

让我们尝试将数据流的概念推广,使其不仅适用于对 IEnumerable 的查询,而且适用于一般数据。当你的程序中发生某些有趣的事情(一个请求、鼠标点击,或者简单地程序启动),你可以将这个“某些事情”视为 输入。这个输入,即数据,然后通过一系列转换,作为数据通过程序中一系列函数的流动。

7.2.2 编写可组合的函数

列表 7.3 中所示的简单 AverageEarningsOfRichestQuartile 函数展示了 LINQ 库的设计如何允许你将通用函数组合成特定查询。有些属性使得某些函数比其他函数更易于组合:³

  • 纯函数——如果你的函数有副作用,它的可重用性就较低。

  • 可链式——this 参数(实例方法中隐式存在,扩展方法中显式存在)使得通过链式组合成为可能。

  • 通用——函数越具体,可用于组合的情况就越少。

  • 形状保留——函数保留结构的形状,因此如果它接受 IEnumerable,则返回 IEnumerable,依此类推。

自然地,函数比操作更易于组合。因为 Action 没有输出值,它是一个死胡同,所以它只能位于管道的末尾。

注意,我们使用的 LINQ 函数根据这些标准都得了 100%,除了Average,它不具备形状保持性。此外,请注意我们在Option API 中定义的核心函数表现良好。

AverageEarningsOfRichestQuartile的可组合性如何?嗯,大约 40%:它是纯函数,并且有一个输出值,但它不是一个扩展方法,而且它非常具体。为了演示这一点,看看以下作为单元测试一部分消耗该函数的代码:

[TestCase(ExpectedResult = 75000)]
public decimal AverageEarningsOfRichestQuartile()
{
   var population = Range(1, 8)
      .Select(i => new Person(Earnings: i * 10000))
      .ToList();

   return PopulationStatistics
      .AverageEarningsOfRichestQuartile(population);
}

测试通过了,但代码也显示AverageEarningsOfRichestQuartile不具备其组合的 LINQ 方法的特性:它不可链式调用,而且它非常具体,你几乎不希望重用它。让我们改变这一点:

  1. 将其拆分为两个更通用的函数:AverageEarnings(这样你可以查询任何人口段的平均收入)和RichestQuartile(毕竟,你可能对最富有的四分之一的许多其他属性感兴趣)。

  2. 将它们做成扩展方法,以便它们可以链式调用:

static decimal AverageEarnings(this IEnumerable<Person> pop)
   => pop.Average(p => p.Earnings);

static IEnumerable<Person> RichestQuartile(this IEnumerable<Person> pop)
   => pop.OrderByDescending(p => p.Earnings)
      .Take(pop.Count / 4);

注意进行这种重构是多么容易!这是因为我们重构的函数的组合性质:新函数只是组合了更少的原始构建块。(如果你有一个使用forif语句的逻辑实现,重构可能不会这么容易。)你现在可以按如下方式重写测试:

[TestCase(ExpectedResult = 75000)]
public decimal AverageEarningsOfRichestQuartile()
   => SamplePopulation
      .RichestQuartile()
      .AverageEarnings();

List<Person> SamplePopulation
   => Range(1, 8)
      .Select(i => new Person(Earnings: i * 10000))
      .ToList();

你现在可以看到测试的阅读性有多好了。通过重构为更小的函数和扩展方法语法,你创建了更多可组合的函数和更易读的接口。

提示:如果你组合两个纯函数,结果函数也是纯函数,这为你提供了第三章中讨论的所有好处。因此,主要由纯、可组合函数组成的库(如 LINQ)往往功能强大且易于使用。

在本节中,你看到了 LINQ 如何提供(许多其他事物中)一套易于组合的函数,这些函数与IEnumerable一起工作非常有效。接下来,我们将看到如何在使用Option时使用声明性、扁平的工作流程。让我们首先明确我们所说的“工作流程”是什么,以及为什么它很重要。

7.3 编程工作流程

工作流程是理解和表达应用程序需求的一种强大方式。一个工作流程是一系列有意义的操作序列,旨在达到一个期望的结果。例如,烹饪食谱描述了准备菜肴的工作流程。

工作流程可以通过函数组合有效地建模。工作流程中的每个操作都可以由一个函数执行,这些函数可以组合成函数管道,执行工作流程,就像你在上一个例子中看到的那样,涉及数据在 LINQ 查询中通过不同的转换流动。

现在我们将查看服务器处理命令的更复杂的工作流程。场景是用户通过 Codeland 银行(BOC)的在线银行应用程序请求进行货币转账。我们只关注服务器端,因此工作流程在服务器接收到转账请求时启动。我们可以为工作流程编写以下规范:

  1. 验证请求的转移。

  2. 加载账户。

  3. 如果账户有足够的资金,则从账户中扣除金额。

  4. 将更改持久化到账户中。

  5. 通过 SWIFT 网络转账资金。⁴

7.3.1 验证的一个简单工作流程

整个货币转账工作流程相当复杂,为了让我们开始,让我们将其简化如下:

  1. 验证请求的转移。

  2. 预订转移(所有后续步骤)。

假设验证之后的所有步骤都是实际预订转移的子工作流程的一部分,这个子工作流程只有在验证通过的情况下才会被触发(见图 7.3)。

图 7.3 示例工作流程:在处理之前验证请求

让我们尝试实现这个高级工作流程。假设服务器使用 ASP.NET Core 公开 HTTP API,并且已经设置好,以便请求被身份验证并路由到适当的 MVC 控制器(在 9.5.3 节中,我将向您展示如何构建不需要控制器的 Web API),使其成为实现工作流程的入口点:

using Microsoft.AspNetCore.Mvc;

public class MakeTransferController : ControllerBase
{
   IValidator<MakeTransfer> validator;

   [HttpPost, Route("api/MakeTransfer")]     ❶
   public void MakeTransfer
      ([FromBody] MakeTransfer transfer)     ❷
   {
      if (validator.IsValid(transfer))
         Book(transfer);
   }

   void Book(MakeTransfer transfer)
      => // actually book the transfer...
}

❶ 向此路由的POST请求被路由到该方法。

❷ 将请求体反序列化为MakeTransfer

请求转移的详细信息被捕获在一个MakeTransfer类型中,该类型包含在用户请求的主体中。验证委托给控制器所依赖的服务,该服务实现了此接口:

public interface IValidator<T>
{
   bool IsValid(T t);
}

现在是更有趣的部分,工作流程本身:

public void MakeTransfer([FromBody] MakeTransfer transfer)
{
   if (validator.IsValid(transfer))
      Book(transfer);
}

void Book(MakeTransfer transfer)
   => // actually book the transfer...

这就是显式控制流的命令式方法。我总是对使用if持谨慎态度:一个单独的if可能看起来无害,但如果你开始允许一个if,随着额外要求的增加,没有什么可以阻止你拥有数十个嵌套的if,随之而来的复杂性使得应用程序易于出错且难以推理。接下来,我们将看看如何使用函数组合来解决这个问题。

7.3.2 考虑数据流进行重构

记住我们关于数据通过各种函数流动的想法?让我们尝试将转移请求视为数据通过验证并流入执行转账的Book方法。图 7.4 显示了这将如何看起来。

图 7.4 将验证视为数据流中的一个步骤

在类型上有点问题:IsValid返回一个布尔值,而Book需要一个MakeTransfer对象,因此这两个函数不能组合,如图 7.5 所示。

图 7.5 类型不匹配阻止函数组合

此外,我们还需要确保只有当请求数据通过验证后,它才会流入Book。这就是Option能帮到我们的地方:我们可以使用None来表示无效的传输请求,而使用Some<MakeTransfer>来表示有效的请求。

注意,在这个过程中,我们正在扩展我们对Option赋予的意义。我们不仅将Some解释为表示数据的存在,而且表示有效数据的存在,就像我们在智能构造器模式中所做的那样。现在,我们可以像以下列表所示那样重写控制器方法。

列表 7.4 使用Option表示通过/失败验证

public void MakeTransfer([FromBody] MakeTransfer transfer)
   => Some(transfer)
      .Where(validator.IsValid)
      .ForEach(Book);

void Book(MakeTransfer transfer)
   => // actually book the transfer...

我们将传输数据提升到Option中,并使用Where应用IsValid谓词;如果验证失败,则返回None,在这种情况下,Book不会被调用。在这个例子中,Where是一个高度可组合的函数,它允许我们将所有东西粘合在一起。这种风格可能不熟悉,但实际上非常易于阅读:“如果传输有效,则保留传输。”

7.3.3 组合带来更大的灵活性

一旦你有一个工作流程,就很容易进行更改,比如向工作流程中添加一个步骤。假设你希望在验证之前规范化请求,以便像空白和大小写这样的问题不会导致验证失败。

你会如何做?你需要定义一个执行新步骤的函数,然后将其集成到你的工作流程中。以下列表显示了如何做到这一点。

列表 7.5 向现有工作流程添加新步骤

public void MakeTransfer([FromBody] MakeTransfer transfer)
   => Some(transfer)
      .Map(Normalize)             ❶
      .Where(validator.IsValid)
      .ForEach(Book);

MakeTransfer Normalize(MakeTransfer request) => // ...

❶ 将新步骤插入到工作流程中

更普遍地说,如果你有一个业务工作流程,你应该旨在通过组合一组函数来表示它,其中每个函数代表工作流程中的一步,它们的组合代表工作流程本身。图 7.6 显示了从工作流程步骤到管道函数的一对一转换。

图 7.6 使用函数组合建模线性工作流程

要精确地说,在这种情况下,我们并不是直接组合这些函数——正如你所看到的,签名不允许这样做——而是将它们作为定义在Option中的 HOFs(高阶函数)的参数,如图 7.7 所示。

图 7.7 Option API 帮助我们组合现有函数。

接下来,让我们看看我们如何实现工作流程的其余部分。

7.4 函数域建模简介

域建模意味着为特定业务领域的实体和行为创建表示。在这种情况下,我们需要一个表示将从其中扣除转账资金的银行账户的表示。我们将在第十一章中更详细地探讨域建模,但在当前场景中了解基础知识是很好的。

让我们从一个非常简单的银行账户表示开始,它只捕获账户余额。这足以说明面向对象和函数式方法之间的基本区别。以下列表展示了面向对象实现可能的样子。

列表 7.6 在面向对象编程中,对象同时捕获数据和行为

public class Account
{
   public decimal Balance { get; private set; }

   public Account(decimal balance) { Balance = balance; }

   public void Debit(decimal amount)
   {
      if (Balance < amount)
         throw new InvalidOperationException("Insufficient funds");

      Balance -= amount;
   }
}

在面向对象编程中,数据和操作都存在于同一个对象中,对象中的方法通常可以修改对象的状态。相比之下,在函数式编程中,数据通过“愚蠢”的数据对象捕获,而行为则编码在函数中,因此我们将它们分开。我们将使用一个只包含状态的 AccountState 对象和一个包含与账户交互的函数的静态 Account 类。

更重要的是,注意前面 Debit 的实现充满了副作用:如果业务验证失败会抛出异常,并且会改变状态。相反,我们将使 Debit 成为一个纯函数。而不是修改现有的实例,我们将返回一个新的 AccountState 对象,其中包含新的余额。

如果账户上的资金不足,如何避免借记操作?嗯,到现在你应该已经学会了这个技巧!使用 None 来表示无效状态并跳过以下计算!以下列表提供了列表 7.6 中代码的函数式对应版本。

列表 7.7 函数式编程分离数据和行为

public record AccountState(decimal Balance);                   ❶

public static class Account                                    ❷
{
   public static Option<AccountState> Debit
      (this AccountState current, decimal amount)
      => (current.Balance < amount)
         ? None                                                ❸
         : Some(new AccountState(current.Balance - amount));   ❹

}

❶ 一个不可变的记录,只包含数据

❷ 只包含纯逻辑

None 在这里表示借记操作失败。

Some 将操作结果作为新账户状态包装。

注意列表 7.6 中的面向对象 Debit 实现不是可组合的:它有副作用并返回 void。列表 7.7 中的函数式对应版本则完全不同:它是一个纯函数并返回一个值,这个值可以用作链中下一个函数的输入。接下来,我们将将其集成到端到端工作流程中。

7.5 端到端服务器端工作流程

现在我们已经有了主要工作流程骨架和简单的领域模型,我们准备完成端到端工作流程。我们仍然需要实现 Book 函数,它应该执行以下操作:

  • 加载账户。

  • 如果账户有足够的资金,则从账户中扣除金额。

  • 将更改持久化到账户中。

  • 通过 SWIFT 网络转账资金。

让我们定义两个服务来捕获数据库和 SWIFT 访问:

public interface IRepository<T>
{
   Option<T> Get(Guid id);
   void Save(Guid id, T t);
}

interface ISwiftService
{
   void Wire(MakeTransfer transfer, AccountState account);
}

使用这些接口仍然是一个面向对象的模式,但现在让我们坚持使用它(你将在第九章中看到如何只使用函数)。注意 IRepository.Get 返回一个 Option,以承认对于任何给定的 Guid,没有保证找到项目。以下列表显示了完全实现的控制器,包括之前缺失的 Book 方法。

列表 7.8 控制器中端到端工作流程的实现

public class MakeTransferController : ControllerBase
{
   IValidator<MakeTransfer> validator;
   IRepository<AccountState> accounts;
   ISwiftService swift;

   public void MakeTransfer([FromBody] MakeTransfer transfer)
      => Some(transfer)
         .Map(Normalize)
         .Where(validator.IsValid)
         .ForEach(Book);

   void Book(MakeTransfer transfer)
      => accounts.Get(transfer.DebitedAccountId)
         .Bind(account => account.Debit(transfer.Amount))
         .ForEach(account =>
            {
               accounts.Save(transfer.DebitedAccountId, account);
               swift.Wire(transfer, account);
            });
}

让我们看看新添加的 Book 方法。注意,accounts.Get 返回一个 Option(如果找不到具有给定 ID 的账户),Debit 也返回一个 Option(如果资金不足)。因此,我们使用 Bind 组合这两个操作。最后,我们使用 ForEach 来执行所需的副作用:保存具有新余额的账户并将资金汇入 SWIFT。

整体解决方案中存在一些明显的不足。首先,我们实际上使用 Option 来在过程中出现错误时停止计算,但我们没有向用户提供任何反馈,说明请求是否成功或失败的原因。在第八章中,您将看到如何使用 Either 和相关结构来解决这个问题;这允许您捕获错误详情,而不会从根本上改变这里展示的方法。

另一个问题是在保存账户和汇款资金时应该原子化操作:如果过程在中间失败,我们可能会扣除资金而未将其发送到 SWIFT。解决这个问题的方法通常是特定于基础设施的,并不特定于函数式编程。⁵ 既然我已经坦白地说明了缺少什么,让我们来讨论一下好的方面。

7.5.1 表达式与语句

当您查看第 7.8 节中的控制器时,应该注意到其中没有 if 语句,没有 for 语句,等等。事实上,几乎没有任何语句!

函数式风格和命令式风格之间的一个基本区别是,命令式代码依赖于语句;函数式代码依赖于表达式。(关于这些差异的复习,请参阅“表达式、语句、声明”侧边栏。)本质上,表达式具有值;语句没有。虽然函数调用等表达式 可以 有副作用,但语句 只有 有副作用,因此它们不能组合。

如果您像我们这样通过组合函数来创建工作流程,副作用自然会聚集在工作流程的末尾:例如,ForEach 函数没有有用的返回值,所以那里就是管道的终点。这有助于隔离副作用,甚至从视觉上也是如此。

最初,不使用语句进行编程的想法可能相当陌生,但正如本章节和前几章节中的代码所展示的,在 C# 中这是完全可行的。请注意,唯一的语句是最后 ForEach 中的两个。这是可以的,因为我们想要有两个副作用——隐藏这一点是没有意义的。

我建议您尝试仅使用表达式进行编码。这并不能保证良好的设计,但它确实促进了更好的设计。

表达式、语句、声明

表达式 包括产生值的所有内容,例如这些:

  • 123"something" 这样的字面量

  • 如 x 这样的变量

  • a || bb ? x : ynew object() 这样的运算符和操作数

表达式可以在任何需要值的地方使用;例如,作为函数调用的参数或作为函数的返回值。

语句是程序的指令,例如赋值、条件(if/else)、循环等。

调用被认为是表达式,如果它们产生一个值,例如 "hello".ToUpper()Math.Sqrt(Math.Abs(n) + m)。如果它们不产生值;也就是说,如果被调用的方法返回 void,则被认为是语句。

声明(类、方法、字段等)通常被认为是语句,但为了讨论的目的,最好将其视为一个独立的类别。无论你更喜欢语句还是表达式,声明都是同样必要的,因此最好将它们排除在语句与表达式之争之外。

7.5.2 声明式与命令式

当我们更喜欢表达式而不是语句时,我们的代码变得更加声明式。它声明了正在计算的内容,而不是指示计算机执行哪些特定操作。换句话说,它是更高层次的,更接近我们与其他人类交流的方式。例如,我们控制器中的顶层工作流程如下所示:

=> Some(transfer)
   .Map(Normalize)
   .Where(validator.IsValid)
   .ForEach(Book);

折扣掉像 MapWhere 这样的东西,它们本质上在操作之间充当粘合剂,这样读起来就像工作流程的口头、要点定义。这意味着代码更接近于口语,因此更容易理解和维护。让我们在表 7.1 中对比命令式和声明式风格。

表 7.1 比较命令式和声明式风格

命令式 声明式
告诉计算机做什么(例如,“将此项目添加到此列表”)。 告诉计算机你想要什么(例如,“给我所有符合条件的项目”)。
主要依赖于语句。 主要依赖于表达式。
副作用无处不在。 副作用自然地倾向于表达式评估的末尾。^a
语句可以轻松地翻译成机器指令。 在将表达式翻译成机器指令的过程中存在更多的间接引用(因此,可能存在更多的优化)。

^a 这是因为副作用函数通常不会返回一个可以在进一步评估中使用的值。

另一个值得指出的是,由于声明式代码是更高层次的,很难在没有单元测试的信心下查看实现并看到它是否工作。这实际上是一件好事:通过单元测试来让自己信服比依赖查看代码并看到它看起来像是在做正确的事情的虚假信心要好得多。

7.5.3 层次化的函数式方法

我们所探讨的实现为我们展示了以函数组合结构化应用程序的自然方式。在任何合理复杂的应用程序中,我们往往会引入某种形式的分层,区分从高级到低级组件的层次结构,其中最高级组件是应用程序的入口点(在我们的例子中是控制器),而最低级的是出口点(在我们的例子中是存储库和 SWIFT 服务)。

不幸的是,我参与过许多项目,其中分层更像是一种诅咒而不是祝福,因为任何操作都需要跨越几个层次。这是因为存在在层之间结构调用调用的倾向,如图 7.8 所示。

图片

图 7.8 层之间交互的无用结构

在这种方法中,存在一个隐含的假设,即层应该只调用相邻的层。这使得架构变得僵化。此外,这意味着整个实现将是非纯的:因为最低级组件有副作用(它们通常访问数据库或外部 API),所以其上的一切也是非纯的——调用非纯函数的函数本身也是非纯的。

在本章展示的方法中,层之间的交互看起来更像图 7.9。

图片

图 7.9 顶层工作流程组合了低级组件公开的函数

高级组件可以依赖于任何低级组件,但反之则不然。这是一种更灵活、更有效的分层方法。在我们的例子中,有一个顶层工作流程,它组合了低级组件公开的函数。这里有几个优点:

  • 您可以在顶级组件中获得清晰的综合工作流程概述。这并不妨碍您在低级组件中定义子工作流程。

  • 中级组件可以是纯函数的。在我们的例子中,组件之间的交互看起来像图 7.10。

图片

图 7.10 中级组件可以是纯函数的。

如您所见,领域表示可以(并且应该!)仅由纯函数组成,因为没有与低级组件的交互;它只是基于输入计算结果。其他功能(如验证,取决于验证的内容)也可能如此。因此,这种方法有助于您隔离副作用并便于测试。由于领域模型和其他中级组件是纯函数,因此它们可以很容易地测试,无需模拟。

练习

  1. 不看任何代码或文档,写出OrderByTakeAverage函数的类型,这些函数被用来实现AverageEarningsOfRichestQuartile

  2. 使用 MSDN 文档检查您的答案:mng.bz/MvwDAverageAverageEarningsOfRichestQuartile有何不同?

  3. 实现一个通用Compose函数,它接受两个一元函数并返回这两个函数的组合。

摘要

  • 函数组合意味着将两个或更多函数组合成一个新的函数,这在函数式编程(FP)中得到了广泛应用。

  • 在 C#中,扩展方法语法允许你通过链式调用方法来使用函数组合。

  • 如果函数是纯的、可链式调用的且保持形状不变,它们就适合进行组合。

  • 工作流是一系列操作,可以通过函数管道在你的程序中有效地表达:每个工作流步骤一个函数,每个函数的输出被传递到下一个函数。

  • LINQ 库提供了一套丰富的易于组合的函数,用于处理IEnumerable,你可以从中获得灵感来编写自己的 API。

  • 函数式代码偏好表达式而非语句,这与命令式代码不同。

  • 依赖于表达式会导致你的代码变得更加声明式,因此更易于阅读。


¹ 存在第二个甚至更简单的函子法则:如果你在函子fMap一个恒等函数(x x),得到的函子与f相同。简单来说,恒等函数应该在函子的提升世界中成立。

² Average也会导致整个方法链被评估,因为它是链中唯一的“贪婪”方法。

³ 这些只是一般性指南。总是有可能组合不具有这些特性的函数,但在实践中,这些特性是判断组合这些函数的容易程度和有用性的良好指标。

⁴ SWIFT 是一个银行间网络;就我们而言,它只是一个我们需要与之通信的第三方应用程序。

⁵ 这个问题在分布式架构中很常见且难度较大。如果你在数据库中存储账户,可能会倾向于打开一个数据库事务,在事务中保存账户,转账,并在完成后提交。但如果在转账后但在提交事务之前进程死亡,这仍然不能保护你。一个彻底的解决方案是原子性地创建一个单一的任务,代表这两个操作,并有一个执行这两个操作并在两者都成功执行后删除该任务的进程。这意味着任何操作都可能被多次执行,因此需要为操作提供幂等性。关于这类问题和解决方案的参考文献是 Gregor Hohpe 和 Bobby Woolf 合著的《企业集成模式》(Addison-Wesley,2004 年)。

第三部分:函数式设计

在本部分,我们将扩大我们的关注范围,以函数式方法来设计整个应用程序或解决跨领域问题。

第八章讨论了函数式方法在验证和错误处理方面的应用。

第九章展示了如何仅使用函数来模块化和组合应用程序,使用部分应用和强大的Aggregate函数(折叠)等技术。

第十章讨论了另一个核心功能,Apply。它还教你如何实现 LINQ 查询模式,并比较了一些函数式模式,如应用和单子。第十章还介绍了一种称为基于属性的测试的技术,通过向代码中抛入随机数据来验证代码是否遵循某些属性。

第十一章讨论了通过不可变数据对象来表示状态、身份和变化的功能方法,而第十二章讨论了不可变数据结构。这些原则不仅适用于内存中的数据,也适用于数据库级别,这在第十三章中有所体现。

到了第三部分的结尾,你将掌握一套工具,使你能够有效地使用端到端的功能方法来应对许多常见场景。

8 函数式错误处理

本章涵盖

  • 使用 Either 表示替代结果

  • 连接可能失败的操作

  • 区分业务验证和技术错误

错误处理是我们应用程序的重要部分。它也是函数式和命令式编程风格差异显著的一个方面:

  • 命令式编程使用特殊的语句如 throw try-catch,这会打断正常的程序流程。 这引入了在第 3.1.1 节中讨论的副作用。

  • 函数式编程力求最小化副作用,因此通常避免抛出异常。 相反,如果一个操作可能失败,它应该返回其结果的表示,包括成功或失败的指示,以及(如果成功)其结果或一些错误数据。换句话说,FP 中的错误只是 负载

基于命令式的异常处理方法存在许多问题。有人说过 throwgoto 具有相似的语义,这引发了一个问题:为什么命令式程序员禁止使用 goto 但没有禁止 throw。¹ 关于何时使用异常和何时使用其他错误处理技术也存在很多混淆。² 我认为函数式方法为复杂的错误处理领域带来了更多的清晰度,我希望通过本章的示例来说服你。

我们将探讨如何将函数式方法付诸实践,以及如何使用函数签名来声明函数可能失败——即通过返回一个在其负载中包含错误信息的类型。然后,错误可以在调用函数中像任何其他值一样被消费。

8.1 一种更安全的表示结果的方法

在前面的章节中,你看到可以使用 Option 不仅表示值的缺失,也表示有效值的缺失。你可以使用 Some 来表示一切顺利,使用 None 来表示出错。换句话说,函数式错误处理有时可以通过使用 Option 类型来满意地实现。以下是一些示例:

  • 将字符串解析为数字——返回 None 以指示给定的字符串不是数字的有效表示。

  • 从集合中检索一个项目——返回 None 以指示没有找到合适的项。

在这些场景中,函数无法计算有效结果的方式只有一个,即用 None 表示。返回 Option<T> 而不是 T 的函数在其签名中承认操作可能失败。一种看待它的方法是,除了结果 T 之外,它们还返回一些额外的负载(即我们 Option 实现中的 isSome 标志),这表示成功或失败。

如果一个操作有几种可能失败的方式呢?比如,如果 BOC 应用程序收到一个复杂的请求,比如一个转账请求,用户当然需要知道转账是否成功预订,而且在失败的情况下,还需要知道失败的原因。

在这种情况下,Option太有限了,因为它不传达任何关于操作失败原因的详细信息。因此,我们需要一种更丰富的方式来表示结果——它包括关于到底出了什么问题的信息。

8.1.1 使用Either捕获错误细节

对于这个问题的一个经典函数式方法是使用Either类型,在操作有两个可能结果的情况下,它捕获了已发生的结果的详细信息。按照惯例,两个可能的结果用LeftRight表示(如图 8.1 所示),将Either生成操作比作一个分叉点:事情可以朝一个方向或另一个方向发展。

图片

图 8.1 Either表示两种可能的结果之一。它表明计算可能产生LeftRight。图像描述了一个例子,其中选择了左侧分支。

虽然LeftRight可以从一个中性的角度来看,但到目前为止,Either最常见的使用是表示可能失败的操作的结果,在这种情况下,Left用来表示失败,Right用来表示成功。所以,记住这个:

  • Right = 没问题

  • Left = 出了问题

在这种有偏见的接受中,Either就像是一个增加了错误信息的OptionOption可以是NoneSome状态,而Either也可以类似地处于LeftRight状态,如表 8.1 所示。

表 8.1 OptionEither都可以表示可能的失败。

失败 成功
Option<T> None Some<T>
Either<L, R> Left<L> Right<R>

如果Option可以象征性地定义为

Option<T> = None | Some(T)

那么Either可以类似地这样定义:

Either<L, R> = Left(L) | Right(R)

注意,Either有两个泛型参数。它可以处于两种状态之一:

  • Left(L)封装了一个类型为L的值,捕获了关于错误的详细信息。

  • Right(R)封装了一个类型为R的值,代表一个成功的结果。

让我们看看基于Option的接口如何与基于Either的接口不同。想象一下,你正在做一些 DIY,去商店买你需要的一个工具。如果这个商品没有货,一个基于Option的店主可能会说,“抱歉,这个商品没有货”——然后就没有然后了。一个基于Either的店主会给你更多信息,比如,“我们下周才有货,”或者“这个产品已经停产”;然后你可以根据这些信息做出进一步的决策。

那么一个欺骗性的店主,在库存耗尽后,会卖给你一个看起来和你想要的完全一样的产品,但当你使用它时会在你面前爆炸?这就是图 8.2 描述的异常抛出接口。

图 8.2 你作为客户会喜欢哪个商店?

因为 Either 的定义与 Option 非常相似,所以可以使用相同的技巧来实现。在我的 LaYumba.Functional 库中,我定义了一个类型 Left<L>,它可以包装一个 L 并可以隐式转换为任何类型 REither<L, R>。同样对于 Right<R>。为了方便,类型 LR 的值也可以隐式转换为 Either<L, R>

你可以在代码示例中看到完整的实现,但在这里我不会包括它,因为与第 5.3 节中讨论的 Option 的实现相比,没有什么是新的。相反,让我们在 REPL 中玩一玩 Either。像往常一样,你需要首先引用 LaYumba.Functional

#r "functional-csharp-code-2\LaYumba.Functional\bin\Debug\net6.0\
➥ LaYumba.Functional.dll"

using LaYumba.Functional;
using static LaYumba.Functional.F;

现在创建一些 Either

Right(12)            ❶
// => Right(12)

Left("oops")         ❷
// => Left("oops")

❶ 创建一个处于 Right 状态的 Either

❷ 创建一个处于 Left 状态的 Either

这很简单!你使用 Right 函数来包装,比如一个 intRight<int>,它可以隐式转换为任何 LEither<L, int>(这类似于我如何使用 NoneTypeNone 状态下创建 Option),同样对于 Left 也是如此。现在,让我们编写一个使用 Match 来根据 Either 的状态计算不同值的函数:

string Render(Either<string, double> val)
   => val.Match
   (
      Left: l => $"Invalid value: {l}",
      Right: r => $"The result is: {r}"
   );

Render(Right(12d))
// => "The result is: 12"

Render(Left("oops"))
// => "Invalid value: oops"

现在你已经知道了如何创建和消费一个 Either,让我们看看一个稍微更有趣的例子。想象一个执行简单计算的函数:

f(x, y) → sqrt(x / y)

为了正确执行计算,我们需要确保 y 不为零,并且 x/y 的比率是非负的。如果这些条件中有一个不满足,我们想知道是哪一个。所以计算返回,比如说,一个 double 在快乐路径上,否则返回一个带有错误消息的 string。这意味着这个函数的返回类型应该是 Either<string, double>——记住,成功的类型是右侧的类型。下面的列表显示了实现。

列表 8.1 使用 Either 捕获错误详情

using static System.Math;

Either<string, double> Calc(double x, double y)
{
   if (y == 0) return "y cannot be 0";

   if (x != 0 && Sign(x) != Sign(y))
      return "x / y cannot be negative";

   return Sqrt(x / y);
}

Calc 的签名清楚地声明它将返回一个结构,该结构可以是字符串或双精度浮点数。确实,实现返回的是字符串(错误消息)或双精度浮点数(计算结果)。在任何情况下,返回的值都隐式转换为适当填充的 Either。让我们在 REPL 中测试一下:

Calc(3, 0)   // => Left("y cannot be 0")
Calc(-3, 3)  // => Left("x / y cannot be negative")
Calc(-3, -3) // => Right(1)

因为 EitherOption 非常相似,你可能猜到你在与 Option 相关的核心函数将会有 Either 的对应函数。让我们找出答案。

8.1.2 用于处理 Either 的核心函数

Option一样,我们可以用Match来定义MapForEachBind。因为Left情况用于表示失败,所以在Left情况下会跳过计算:

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

public static Either<L, Unit> ForEach<L, R>
   (this Either<L, R> either, Action<R> act)
   => Map(either, act.ToFunc());

public static Either<L, RR> Bind<L, R, RR>
(
   this Either<L, R> either,
   Func<R, Either<L, RR>> f
)
=> either.Match
(
   l => Left(l),                  ❶
   r => f(r)
);

❶ 在Left情况下,计算被跳过,并将Left值传递下去。

这里有一些需要注意的事情。在所有情况下,函数只会在EitherRight时应用。³ 这意味着如果我们把Either看作一个分叉点,那么当我们选择左边的路径时,就会错过所有后续的计算。

还要注意的是,当你使用MapBind时,R类型会改变:正如Option<T>T上的函子一样,Either<L, R>R上的函子,这意味着你可以使用Map来对R应用函数。另一方面,L类型保持不变。

关于Where?记住,你可以用谓词调用Where,并在它不满足谓词时过滤出Option的内部值:

Option<int> three = Some(3);

three.Where(i => i % 2 == 0) // => None
three.Where(i => i % 2 != 0) // => Some(3)

使用Either,你不能这样做。如果条件不满足,应该返回一个Left,但由于Where需要一个谓词,而谓词只返回布尔值,所以如果谓词失败,就没有可用的L类型值。如果你尝试为Either实现Where,这可能是最容易理解的。

public static Either<L, R> Where<L, R>
(
  this Either<L, R> either,
  Func<R, bool> predicate
)
=> either.Match
(
   l => Left(l),
   r => predicate(r)
      : Right(r)
      ? Left(/* now what? I don't have an L */)
);

如你所见,如果EitherRight但它的内部值不满足谓词,你应该返回一个Left。然而,没有可用的L类型值可以填充一个Left

你刚刚了解到Where不如MapBind通用:它只能定义在存在零值的结构中(例如IEnumerable的空序列或OptionNone)。对于Either<L, R>来说,没有零值,因为L是一个任意类型。你只能通过显式创建一个Left或通过调用可能返回适当L值的函数的Bind来使Either失败。你将在下一个示例中看到这一点,我将展示一个基于Option的实现和一个基于Either的实现并排。

8.1.3 比较OptionEither

想象我们正在模拟一个招聘流程。我们将从一个基于Option的实现开始,其中Some(Candidate)代表已经通过面试流程的候选人,而None代表拒绝。下面的列表显示了这种实现。

列表 8.2 基于Option的招聘流程实现

Func<Candidate, bool> IsEligible;
Func<Candidate, Option<Candidate>> TechTest;
Func<Candidate, Option<Candidate>> Interview;

Option<Candidate> Recruit(Candidate c)
   => Some(c)
      .Where(IsEligible)
      .Bind(TechTest)
      .Bind(Interview);

招聘流程首先是一个技术测试,然后是面试。如果测试失败,面试就不会进行。但在测试之前,我们会检查候选人是否有资格。使用Option,我们可以用Where应用IsEligible谓词,这样如果候选人没有资格,后续步骤就不会进行。

现在想象一下,人力资源部门不仅想知道候选人是否通过,还想知道拒绝的原因,因为这项信息允许他们改进招聘流程。我们可以重构为基于Either的实现,使用Rejection对象捕获拒绝的原因,如下面的列表所示。Right类型与之前一样是Candidate,而Left类型是Rejection

列表 8.3 等价的基于Either的实现

Func<Candidate, bool> IsEligible;
Func<Candidate, Either<Rejection, Candidate>> TechTest;
Func<Candidate, Either<Rejection, Candidate>> Interview;

Either<Rejection, Candidate> CheckEligibility(Candidate c)   ❶
{
   if (IsEligible(c)) return c;
   else return new Rejection("Not eligible");
}

Either<Rejection, Candidate> Recruit(Candidate c)
   => Right(c)
      .Bind(CheckEligibility)                                ❷
      .Bind(TechTest)
      .Bind(Interview);

❶ 将谓词转换为返回Either的函数

❷ 使用Bind应用CheckEligibility

现在,我们需要更明确地表达IsEligible测试失败的情况,因此我们将这个谓词转换为一个返回Either的函数,即CheckEligibility。这为谓词未通过时提供了一个合适的Left值(即Rejection)。现在我们可以使用BindCheckEligibility组合到工作流程中。

注意到基于Either的实现更为冗长。这很合理,因为我们选择Either是为了明确地表达失败条件。

8.2 连接可能失败的运算

Either特别适合表示可能引起从快乐路径偏离的操作链。例如,偶尔你会为你的男朋友或女朋友准备他们最喜欢的菜肴。工作流程可能看起来像这样:

  o WakeUpEarly
 / \
L   R ShopForIngredients
   / \
  L   R CookRecipe
     / \
    L   R EnjoyTogether

在每一步中,都可能出错:你可能睡过头,你可能醒来时遇到暴风雨,这阻止了你去商店,你可能分心,让一切都烧毁了……简而言之,只有当一切顺利时,你才能一起享受快乐的餐点(见图 8.3)。

图 8.3 如果一切按计划进行……

使用Either,我们可以模拟前面的工作流程。下面的列表展示了如何实现这一点。

列表 8.4 使用Bind连接多个返回Either的函数

Func<Either<Reason, Unit>> WakeUpEarly;
Func<Either<Reason, Ingredients>> ShopForIngredients;
Func<Ingredients, Either<Reason, Food>> CookRecipe;

Action<Food> EnjoyTogether;
Action<Reason> ComplainAbout;
Action OrderPizza;

WakeUpEarly()
   .Bind(_ => ShopForIngredients())
   .Bind(CookRecipe)
   .Match
   (
      Right: dish => EnjoyTogether(dish),
      Left: reason =>
      {
         ComplainAbout(reason);
         OrderPizza();
      }
   );

记住从Bind的定义中,如果状态是Left,则Left值只是被传递下去。在先前的列表中,当我们说ComplainAbout(reason)时,reason是之前任何步骤中失败的原因:如果我们没有醒来,ComplainAbout会接收到那个原因;同样,如果我们没有去购物,等等。

之前的树形图是工作流程的正确、逻辑表示。图 8.4 展示了另一种看待它的方法,这更接近实现细节。

图 8.4 连接返回Either的函数

每个函数都返回一个两部分的结构,即Either,并通过Bind与下一个函数连接。F# 拥护者 Scott Wlaschin 将通过连接多个返回Either的函数获得的工作流程比作一个双轨系统:⁴

  • 有一个主轨道(即快乐路径),从R1Rn

  • Left侧有一个辅助的、并行轨道

  • 如果你处于 Right 轨道,每次函数应用时,你将要么沿着 Right 轨道继续前进,要么被引导到 Left 轨道。

  • 一旦你处于 Left 轨道,你将一直保持在轨道上直到路的尽头。

  • Match 是路的尽头,在这里并行轨道的分离发生。

虽然这个“最喜欢的菜肴”例子相当轻率,但它代表了众多编程场景。例如,想象一个无状态服务器,在收到请求后,必须执行以下步骤:

  1. 验证请求

  2. 从数据库加载模型

  3. 对模型进行更改

  4. 持久化更改

这些操作中的任何一个都可能失败,并且任何步骤的失败都应阻止工作流程继续。此外,响应可能包含有关请求操作成功或失败详情的信息,并适当地提醒客户端。接下来,我们将看看在这样场景中使用 Either

8.3 验证:Either 的完美用例

让我们重新审视请求货币转账的场景,但在这个情况下,我们将处理一个简化的场景,其中客户端明确请求在未来某一天执行转账。应用程序应该执行以下操作:

  1. 验证请求

  2. 存储转账详情以供将来执行

  3. 返回一个表示成功或任何失败详情的响应

我们可以用 Either 来模拟操作可能失败的事实。如果转账请求成功存储,就没有有意义的数据返回给客户端,所以 Right 类型参数将是 Unit。那么 Left 类型应该是什么?

8.3.1 选择合适的错误表示

让我们看看一些你可以用来捕获错误详情的类型。当你通过 MapBind 将函数应用于 Either 时,Right 类型会改变,但 Left 类型保持不变。所以一旦你为 Left 选择了一个类型,这个类型在整个工作流程中保持不变。

我在之前的例子中使用了一些 string,但这似乎限制性太强;你可能想添加更多关于错误的详细结构化信息。关于 Exception 怎么样?它是一个可以扩展为任意丰富子类型的基类。然而,这里的语义是错误的:Exception 表示发生了异常。相反,这里我们正在为“常规业务”中的错误进行编码。

我在下一个列表中包含了一个简单的基 Error 类型,仅暴露一个 Message 属性。你可以为特定错误扩展这个类型。

列表 8.5 表示失败的基础类

namespace LaYumba.Functional;

public record Error(string Message);

虽然严格来说,Error 的表示是领域的一部分,但这是一个足够通用的要求,我已经将这个类型添加到了我的功能库中。我的推荐方法是为每种错误类型创建一个类型。例如,下面的列表提供了一些我们需要用来表示一些验证失败情况的错误类型。

列表 8.6 独特类型捕获特定错误的详细信息

namespace Boc.Domain;

public sealed record InvalidBicError()
   : Error("The beneficiary's BIC/SWIFT code is invalid");

public sealed record TransferDateIsPastError()
   : Error("Transfer date cannot be in the past");

为了方便起见,我们将添加一个静态类,Errors。它包含用于创建特定 Error 的工厂函数:

public static class Errors
{
   public static Error InvalidBic
      => new InvalidBicError();

   public static Error TransferDateIsPast
      => new TransferDateIsPastError();
}

这是一个技巧,将有助于我们保持业务决策处的代码更干净,正如你一会儿会看到的。它还提供了良好的文档:Errors 有效地为你提供了所有为该领域定义的特定错误的概述。

8.3.2 定义基于 Either 的 API

假设关于转账请求的详细信息被捕获在一个类型为 MakeTransfer 的 DTO 中(见列表 3.6):这是我们从客户端接收到的,也是我们工作流程的输入数据。我们还可以确定,当没有数据(在成功的情况下)或带有详细信息的错误(在失败的情况下)时,工作流程应返回 Either<Error, Unit>。这意味着我们需要实现的主要函数来执行此工作流程具有以下签名:

MakeTransfer → Either<Error, Unit>

现在我们已经准备好介绍实现的大致框架:

public class MakeTransferController : ControllerBase
{
   [HttpPost, Route("transfers/book")]
   public void MakeTransfer([FromBody] MakeTransfer request)
      => Handle(request);

   Either<Error, Unit> Handle(MakeTransfer cmd)
      => Validate(cmd)
         .Bind(Save);                                       ❶

   Either<Error, MakeTransfer> Validate(MakeTransfer cmd)   ❷
      => // TODO: add validation...

   Either<Error, Unit> Save(MakeTransfer cmd)               ❸
      => // TODO: save the request...
}

❶ 使用 Bind 来链式执行两个可能失败的运算

❷ 使用 Either 来承认验证可能会失败

❸ 使用 Either 来承认持久化请求可能会失败

Handle 方法定义了高级工作流程:首先验证,然后持久化。ValidateSave 都返回 Either 来承认运算可能会失败。注意,Validate 的返回类型是 Either<Error, MakeTransfer>。这意味着我们需要在右侧的 MakeTransfer 命令,以便转账数据可用,并可以传递给 Save。接下来,让我们添加一些验证。

8.3.3 添加验证逻辑

让我们先验证请求的一些简单条件:

  • 转账的日期应该在将来。

  • 提供的 BIC 代码应该符合正确的格式。⁵

你已经在 3.3.2 节中看到了这个验证的逻辑。然而,当时我们编写了返回布尔值的函数,表示 MakeTransfer 是否有效。现在,我们想要返回一个 Either 来捕获验证失败的具体细节。

我们可以让一个函数执行每个验证。典型的方案如下:

Regex bicRegex = new Regex("[A-Z]{11}");

Either<Error, MakeTransfer> ValidateBic(MakeTransfer transfer)
   => bicRegex.IsMatch(transfer.Bic)
      ? transfer                       ❶
      : Errors.InvalidBic;             ❷

❶ 成功:原始请求被封装在 EitherRight 状态中。

❷ 失败:错误被封装在 EitherLeft 状态中。

即,每个验证函数都接受一个请求作为输入,并返回 either(要么)验证后的命令 or 相应的错误。

每个验证函数都是一个跨界函数(从一个普通值 MakeTransfer 到一个提升值 Either<Error, MakeTransfer>),因此我们可以使用 Bind 组合这些函数中的几个。以下列表显示了如何做到这一点。

列表 8.7 使用 Bind 链式连接多个验证函数

DateTime now;
Regex bicRegex = new Regex("[A-Z]{11}");

Either<Error, Unit> Handle(MakeTransfer transfer)
   => Right(transfer)                             ❶
      .Bind(ValidateBic)                          ❷
      .Bind(ValidateDate)                         ❷
      .Bind(Save);                                ❷

Either<Error, MakeTransfer> ValidateBic(MakeTransfer transfer)
   => bicRegex.IsMatch(transfer.Bic)
      ? transfer
      : Errors.InvalidBic;

Either<Error, MakeTransfer> ValidateDate(MakeTransfer transfer)
   => transfer.Date.Date > now.Date
      ? transfer
      : Errors.TransferDateIsPast;

Either<Error, Unit> Save(MakeTransfer cmd) => //...

❶ 将命令提升到 Either

❷ 使用 Bind 应用所有后续可能失败的运算

总结来说,使用 Either 来承认 一个 运算可能会失败,并使用 Bind 来链式执行 多个 可能失败的运算。

现在我们的工作流程已经很好地用Either捕获了失败的可能性,我们如何将此信息传达给请求的 HTTP 客户端?我们将在下一节探讨这个问题。

8.4 向客户端应用程序表示结果

你现在已经看到了OptionEither的许多用例。这两种类型都可以看作是表示结果:在Option的情况下,None可以表示失败;在Either的情况下,它是Left。我们已经将OptionEither定义为 C#类型,但在本节中,你将看到如何将它们转换为外部世界。

尽管我们已经为这两种类型定义了Match,但我们使用它相当少,而是依赖于MapBindWhere来定义工作流程。记住,这里的关键区别在于后者在抽象内部工作(你从Option<T>开始,并以Option<R>结束)。另一方面,Match允许你“离开抽象”(你从Option<T>开始,并以R结束)。图 8.5 展示了这个过程。

图 8.5 使用OptionEitherMatch用于离开抽象。

作为一条一般规则,一旦你引入了像Option这样的抽象,最好尽可能长时间地坚持使用它。尽可能长时间是什么意思?理想情况下,这意味着当你跨越应用边界时,你会离开抽象世界。

设计应用程序时,在应用核心(包含服务和领域逻辑)和外部层(包含一组适配器,通过这些适配器你的应用程序与外部世界交互)之间保持一些分离是良好的实践。你可以将你的应用程序看作是一个橙子,其中皮肤由一层适配器组成,如图 8.6 所示。

图 8.6 应用程序的外部层由适配器组成。

OptionEither这样的抽象在应用核心中很有用,但它们可能无法很好地映射到交互应用期望的消息合约。因此,外部层是你需要离开抽象并转换为客户端应用程序期望的表示的地方。

8.4.1 暴露类似Option的接口

想象一个 API,给定一个股票代码(股票或其他金融工具的标识符,例如 AAPL、GOOG 或 MSFT),返回有关请求的金融工具的详细信息。在应用核心中,你已经实现了一个执行此操作的功能;其签名是

GetDetails : string → Option<InstrumentDetails>

你无法知道给定的股票代码字符串是否确实标识了一个现有的工具,因此你使用了Option来建模这种情况。接下来,让我们将此数据暴露给外部世界。你可以通过在 ASP.NET MVC 控制器上定义一个端点来实现这一点。

API 返回,比如说,通过 HTTP 的 JSON(一种不处理 Option 的格式和协议),因此控制器需要充当一个适配器,可以将 Option 转换为该协议支持的内容。也就是说,如果给定的股票代码没有对应的工具,我们将返回一个状态码为 404(未找到)的 HTTP 响应,如下列表所示。

列表 8.8 将 None 映射到状态码 404

using Microsoft.AspNet.Mvc;

public class InstrumentsController : ControllerBase
{
   [HttpGet, Route("api/instruments/{ticker}/details")]
   public IActionResult GetInstrumentDetails(string ticker)
      => GetDetails(ticker)
         .Match<IActionResult>
         (
            () => NotFound(),         ❶
            (result) => Ok(result)    ❷
          );

   Option<InstrumentDetails> GetDetails(string ticker) => // ...
}

❶ 将 None 映射到 404

❷ 将 Some 映射到 200

注意,由于 NotFoundOk(都继承自 ControllerBase)返回不同的 IActionResult 实现,我们必须显式声明 IActionResult 作为 Match 的类型参数。

无参数风格

GetInstrumentDetails 的主体可以写得更加简洁:

=> getInstrumentDetails(ticker)
   .Match<IActionResult>
   (
      None: NotFound,
      Some: Ok
   );

(或者甚至可以更简洁地不带参数名称。)

这种省略显式参数的风格有时被称为无参数风格,因为省略了数据点。一开始可能会有些令人畏惧,但一旦习惯了,就会更加简洁。

你现在已经看到了如何使用基于 Option 的接口来建模工作流程,并通过 HTTP API 暴露它。接下来,让我们看看基于 Either 的接口。

8.4.2 暴露类似 Either 的接口

就像 Option 一样,一旦你的值提升到 Either 的更高层次,最好一直保持在那里,直到工作流程结束。但所有美好的事物都有尽头,所以你最终需要离开你的应用程序领域,并将你的 Either 的表示暴露给外部世界。

让我们回到本章中讨论的银行场景——即客户请求在未来日期进行转账的场景。我们的核心功能返回一个 Either<Error, Unit>,我们必须将其转换为,比如说,通过 HTTP 的 JSON。

一种方法与我们刚才查看的 Option 类似:我们可以使用 HTTP 状态码 400 来表示我们收到了一个无效请求。以下列表展示了这种方法。

列表 8.9 将 Left 映射到状态码 400

public class MakeTransferController : ControllerBase
{
   [HttpPost, Route("api/transfers/future")]
   public IActionResult MakeTransfer([FromBody] MakeTransfer transfer)
      => Handle(transfer).Match<IActionResult>
      (
         Left: BadRequest,
         Right: _ => Ok()
      );

   Either<Error, Unit> Handle(MakeTransfer transfer) => // ...
}

这有效。唯一的缺点是关于业务验证如何与 HTTP 错误码相关联的约定并不稳固。有些人会争论说 400 表示一个语法上错误的请求,而不是像这里这样的语义上错误的请求。

在并发情况下,一个在发送时有效的请求在服务器收到时可能不再有效(例如,账户余额可能已经下降)。400 状态码是否传达了这一点?

而不是试图确定哪个 HTTP 状态码最适合特定的错误场景,另一种方法是返回响应中的结果表示。我们将在下一节中探讨这个选项。

8.4.3 返回结果 DTO

这种方法涉及始终返回成功的状态码(因为,在底层,响应已被正确接收和处理),以及响应体中任意丰富的结果表示。以下列表显示了一个简单的数据传输对象(DTO),它表示处理请求的结果,包括其左右组件。

列表 8.10 表示响应中序列化结果的 DTO

public record ResultDto<T>
{
   public bool Succeeded { get; }
   public bool Failed => !Succeeded;

   public T Data { get; }
   public Error Error { get; }

   internal ResultDto(T data) => (Succeeded, Data) = (true, data);
   internal ResultDto(Error error) => Error = error;
}

这个 ResultDtoEither 类似。但与 Either 不同,其内部值只能通过高阶函数访问,而 DTO 则将其暴露出来,以便于客户端的序列化和访问。然后我们可以定义一个实用函数,将 Either 转换为 ResultDto

public static ResultDto<T> ToResult<T>(this Either<Error, T> either)
   => either.Match
   (
      Left: error => new ResultDto<T>(error),
      Right: data => new ResultDto<T>(data)
   );

现在,我们只需在我们的 API 方法中公开 Result 即可。接下来的列表显示了如何做到这一点。

列表 8.11 将错误详情作为成功响应负载的一部分返回

public class MakeTransferController : ControllerBase
{
   Func<MakeTransfer, Either<Error, Unit>> makeTransfer;

   [HttpPost, Route("api/transfers/future")]
   public ResultDto<Unit> MakeTransfer([FromBody] MakeTransfer transfer)
      => makeTransfer(transfer).ToResult();
}

总体来说,这种方法意味着你的端点代码更少。更重要的是,这意味着你不需要依赖 HTTP 协议的怪癖来表示结果,而是可以创建最适合你的结构来表示你选择看到的 Left

最后,两种方法都是可行的,并且在实际的 API 中都被使用。你选择哪种方法更多与 API 设计有关,而不是与函数式编程(FP)有关。关键是,当你向客户端应用程序公开可以用 Either 在你的应用程序中建模的结果时,你通常必须做出一些选择。

我通过 HTTP API 的例子说明了“降低”抽象中的值,因为这是一个非常常见的需求,但如果你暴露另一种类型的端点,这些概念并不会改变。总的来说,如果你处于橙子的皮层,使用 Match;在橙子的核心中保持多汁的抽象。

8.5 基于 Either 的变体

Either 将我们引向功能错误处理的深处。与导致程序“跳”出其正常执行流程并进入堆栈中某处的异常处理块的异常不同,Either 维持正常的程序执行流程,并返回结果的表现形式。

Either 有很多值得喜欢的地方。也有一些可能的反对意见:

  • Bind 不改变 Left 类型,那么你如何组合返回不同 Left 类型的 Either 函数?

  • 总是必须指定两个泛型参数使得代码过于冗长。

  • EitherLeftRight 的名字太晦涩了。我们能不能有更用户友好的名字?

在本节中,我将解决这些担忧。你会看到如何通过 Either 主题的一些变体来减轻这些问题。

8.5.1 在不同的错误表示之间切换

正如你所见,MapBind 允许你更改 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 => F.Left(Left(l)),
   r => F.Right(Right(r))
);

这种 Map 的变体允许你任意更改两种类型,这样你就可以在 L 类型不同的函数之间进行互操作。⁶ 以下是一个示例:

Either<string, double> Calc(double x, double y) //...   ❶

Either<Error, int> ToIntIfWhole(double d) //...         ❷

Either<Error, int> Run(double x, double y)
   => Calc(x, y)
      .Map
      (
         Left: msg => Error(msg),                       ❸
         Right: d => d
      )
      .Bind(ToIntIfWhole);

L 表示 string

L 表示 Error

❸ 从 string 转换为 Error

如果可能的话,最好避免噪音并坚持一致的错误表示。不同的表示方式,尽管如此,并不是一个障碍。

8.5.2 Either 的专用版本

让我们来看看在 C# 中使用 Either 的其他缺点。

首先,有两个泛型参数会增加代码的噪音。⁷ 例如,想象你想捕获多个验证错误,为此你选择 IEnumerable<Error> 作为你的 Left 类型。你最终会得到这样的签名:

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

现在,你必须阅读三个东西(EitherIEnumerableError),才能到达最有意义的部分——所需的返回类型 Rates。与我们在 4.2.3 节中讨论的没有任何关于失败信息的签名相比,这似乎是我们陷入了对立面。

第二,EitherLeftRight 的名称过于抽象。软件开发本身就足够复杂,因此我们应该选择最直观的名称。

可以通过使用具有固定类型来表示失败(因此,单个泛型参数)和更友好的名称的更专用版本的 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 和函数式错误处理之间的桥梁。

表 8.2 展示了这些变化并排。

表 8.2 Either的一些特定版本及其状态名称

类型 成功情况 失败情况 失败类型
Either<L, R> Right Left L
Validation<T> Valid Invalid IEnumerable<Error>
Exceptional<T> Success Exception Exception

这些新类型比Either有更友好、更直观的名称。而且因为Left类型是静态的,它不会使你的方法签名变得杂乱。你将在下一个示例中看到它们的使用。

8.5.3 重构为验证和异常处理

让我们回到用户为未来执行预订货币转账的场景。之前,我们用Either模拟了包含验证和持久化(两者都可能失败)的简单工作流程。现在,让我们看看使用更具体的ValidationExceptional如何改变实现。

执行验证的功能函数应该自然地产生一个Validation。在我们的场景中,其类型会是

Validate : MakeTransfer → Validation<MakeTransfer>

因为Validation就像Either一样,特定于Error类型,验证函数的实现将与之前的基于Either的实现相同,除了签名的变化。以下是一个示例:

DateTime now;

Validation<MakeTransfer> ValidateDate(MakeTransfer transfer)
   => transfer.Date.Date > now.Date
      ? transfer                      ❶
      : Errors.TransferDateIsPast;    ❷

❶ 在“有效”状态下将命令封装在Validation

❷ 在“无效”状态下将一个Error封装在Validation

与之前的实现一致,我定义了Valid为一个函数,它接受一个T并将其提升到Validation<T>的“有效”状态,同样对于Invalid,它接受一个或多个Error。隐式转换也被定义,所以在前面的例子中,你可以省略对ValidInvalid的调用。

在基于异常的 API 和函数式错误处理之间架桥

接下来,让我们看看持久化。与验证不同,这里的失败将表明基础设施或配置或另一个技术错误。我们把这些错误视为异常,因此我们可以用Exceptional来模拟这种情况:⁸

Save : MakeTransfer → Exceptional<Unit>

以下列表展示了Save实现的可能形式。

列表 8.12 将基于异常的 API 转换为Exceptional

string connString;

Exceptional<Unit> Save(MakeTransfer transfer)           ❶
{
   try
   {
      ConnectionHelper.Connect(connString               ❷
         , c => c.Execute("INSERT ...", transfer));     ❷
   }

   catch (Exception ex) { return ex; }                  ❸

   return Unit();                                       ❹
}

❶ 返回类型承认异常的可能性。

❷ 将抛出异常的第三方 API 调用封装在try中。

❸ 异常将在“异常”状态下隐式转换为并封装在Exceptional中。

❹ 返回的Unit将在“成功”状态下转换为Exceptional

注意到try-catch的范围尽可能小:我们希望捕获在连接数据库时可能抛出的任何异常,并立即将其转换为函数式风格,将结果包裹在Exceptional中。像往常一样,隐式转换创建了一个适当初始化的Exceptional。注意这种模式如何使我们能够从第三方抛出异常的 API 转换到函数式 API,其中错误被处理为负载,错误的可能性反映在返回类型中。

验证失败与技术错误

验证失败和技术错误应该被不同地处理。使用ValidationExceptional的好处是它们具有不同的语义含义:

  • Validation表示可能违反的业务规则。

  • Exceptional表示可能出现的意外技术错误。

我们现在将探讨如何使用这些不同的表示方法来适当地处理每个案例。我们仍然需要将验证和持久性结合起来,这在这里通过Handle完成:

public class MakeTransferController : ControllerBase
{
   Validation<Exceptional<Unit>> Handle(MakeTransfer transfer)
      => Validate(transfer)                                     ❶
         .Map(Save);                                            ❶

   Validation<MakeTransfer> Validate(MakeTransfer transfer)     ❷
      => ValidateBic(transfer) 
         .Bind(ValidateDate); 

   Validation<MakeTransfer> ValidateBic(MakeTransfer transfer) // ...
   Validation<MakeTransfer> ValidateDate(MakeTransfer transfer) // ...

   Exceptional<Unit> Save(MakeTransfer transfer) // ...
}

❶ 结合验证和持久性

❷ 结合各种验证规则的顶级验证函数

因为Validate返回一个Validation,而Save返回一个Exceptional,所以我们不能使用Bind来组合这些类型。但这是可以的:我们可以使用Map,最终得到返回类型Validation<Exceptional<Unit>>。这是一个嵌套类型,表示我们正在结合验证的效果(我们可能会得到验证错误而不是期望的返回值)和异常处理的效果(即使验证通过,我们可能仍然会得到异常而不是返回值)。⁹

因此,Handle通过“堆叠”两个单子效果来承认操作可能因为业务原因或技术原因而失败。图 8.7 说明了在两种情况下我们如何通过将错误包含在负载中来表达错误。

图 8.7 在功能错误处理中,错误被视为返回负载的一部分。

要完成端到端场景,我们只需要添加入口点。这是控制器从客户端接收MakeTransfer命令的地方,调用之前定义的Handle,并将结果Validation<Exceptional-<Unit>>转换为发送回客户端的结果。这将在下面的列表中展示。

列表 8.13 验证和异常错误的处理方式

public class MakeTransferController : ControllerBase
{
   ILogger<MakeTransferController> logger;

   [HttpPost, Route("api/transfers/book")]
   public IActionResult MakeTransfer([FromBody] MakeTransfer transfer)
      => Handle(transfer).Match                   ❶
      (
         Invalid: BadRequest,                     ❷
         Valid: result => result.Match            ❸
         (
            Exception: OnFaulted,                 ❹
            Success: _ => Ok()
         )
      );

   IActionResult OnFaulted(Exception ex)
   {
      logger.LogError(ex.Message);
      return StatusCode(500, Errors.UnexpectedError);
   }

   Validation<Exceptional<Unit>> Handle(MakeTransfer transfer) //...
}

❶ 解包Validation内部的值

❷ 如果验证失败,发送 400 状态码

❸ 解包Exceptional内部的值

❹ 如果持久性失败,发送 500 状态码

在这里,我们使用两个嵌套的Match调用,首先解包Validation内部的值,然后是Exceptional内部的值:

  • 如果验证失败,我们发送一个包含验证错误全部详情的 400 状态码,以便用户可以处理这些问题。

  • 另一方面,如果持久化失败,我们不想将详细信息发送给用户。相反,我们返回一个带有更通用错误类型的 500 状态码(这也是记录异常的好地方)。

正如你所看到的,每个相关函数的显式返回类型允许你清楚地区分和定制你如何处理与业务规则相关的失败与与技术问题相关的失败。

总结来说,Either 提供了一种明确的功能方式来处理错误,而不引入副作用,这与抛出/捕获异常不同。但正如我们相对简单的银行场景所说明的,使用 Either 的专用版本,如 ValidationExceptional,会导致更具有表达性和可读性的实现。

8.5.4 留下异常?

在本章中,你已经对功能错误处理的理念有了坚实的理解。¹⁰ 你可能会觉得这与基于异常的方法有很大的不同,确实如此!

我提到抛出异常会中断正常的程序流程,引入副作用。更实际地说,它使得你的代码更难以维护和推理:如果一个函数抛出异常,分析这种异常对应用程序的影响的唯一方法就是跟踪所有可能的代码路径进入该函数,然后查找堆栈中的第一个异常处理器。在功能错误处理中,错误只是函数返回类型的一部分,因此你仍然可以独立地推理函数。

已经意识到使用异常的负面影响后,一些较新的编程语言,如 Go、Elixir 和 Elm,都接受了这样的观点:错误应该简单地被视为值,因此 throwtry-catch 语句的等价物只被很少使用(Elixir)或者根本不包含在语言中(Go、Elm)。C# 包含异常并不意味着你需要使用它们来处理错误;相反,你可以在应用程序中使用功能错误处理,并使用适配器函数将基于异常的 API 调用的结果转换为类似于前面所示 Exceptional 的东西。

是否有任何情况下异常仍然有用?我相信是这样的:

  • 开发者错误——例如,如果你试图从一个空列表中删除一个项目,或者如果你将 null 值传递给需要一个值的函数,该函数或列表实现抛出异常是可以接受的。这种异常永远不会被调用代码捕获和处理;它们表明应用程序逻辑有误。

  • 配置错误——例如,如果一个应用程序依赖于消息总线来连接到其他系统,并且除非连接,否则无法有效地执行任何有用的操作,那么在启动时无法连接到总线应该导致异常。如果缺少像数据库连接字符串这样的关键配置项,也适用同样的情况。这些异常应该在初始化时抛出,并且不打算被捕获(除了可能在最外层的、应用程序范围内的处理程序中),但应该正确地导致应用程序崩溃。

练习

  1. 编写一个ToOption扩展方法,将Either转换为Option;如果存在,左值将被丢弃。然后编写一个ToEither方法,将Option转换为具有适当参数的Either,如果OptionNone,则可以调用该参数以获取适当的Left值。(提示:首先用箭头符号编写函数签名。)

  2. 考虑一个工作流程,其中两个或多个返回Option的函数使用Bind进行链式调用。然后将第一个函数改为返回Either。这应该会导致编译失败。Either可以转换为Option(如你在上一个练习中看到的),因此为Bind编写扩展重载。这样,返回EitherOption的函数可以使用Bind进行链式调用,产生一个Option

  3. 编写一个具有以下签名的函数

    TryRun : (() → T) → Exceptional<T>
    

    该函数在try-catch块中运行给定的函数,返回一个适当填充的Exceptional

  4. 编写一个具有以下签名的函数

    Safely : ((() → R), (Exception → L)) → Either<L, R>
    

    该函数在try-catch块中运行给定的函数,返回一个适当填充的Either

摘要

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

    • Left表示失败,并包含不成功操作的错误信息。

    • Right表示成功,并包含成功操作的结果。

  • 使用与Option中已看到的核心函数等效的函数与Either进行交互:

    • MapBind仅在Either处于Right状态时应用映射/绑定函数;否则,它们只是传递Left值。

    • Match允许你分别处理RightLeft情况。

    • Where不适用;如果你要根据谓词过滤出某些Right值,请使用Bind代替,提供一个在谓词失败时产生适当Left值的函数。

  • Either特别适用于将多个验证函数与Bind结合使用,或者更一般地,用于将多个可能失败的操作组合在一起。

  • 由于Either相当抽象,并且由于其两个泛型参数的语法开销,在实践中,最好使用Either的特定版本,例如ValidationExceptional

  • 当与函子和单子一起工作时,最好使用保持在抽象内部的函数,例如MapBind。尽可能少或尽可能晚地使用向下交叉的Match函数。


¹ 事实上,我认为throwgoto更糟糕。后者至少跳到了一个定义良好的位置;而throw,除非你探索了throw发生的代码中所有可能的路径,否则你真的不知道接下来会执行什么代码。

² 这些包括返回一个特殊值来指示错误或返回表示操作结果的物体,这是我在本章中要采取的方法。

³ 这被称为Either有偏实现。还有不同的、无偏Either实现,它们不用于表示错误/成功分离,而是表示两个同样有效的路径。在实践中,有偏的实现被广泛使用。

⁴ 我鼓励您查看他网站上关于“面向铁路的编程”的文章和视频会议,网址为fsharpforfunandprofit.com/rop/

⁵ BIC 代码,也称为 SWIFT 代码,是银行分支的标准标识符。

⁶ 由于函数式编程(FP)中的术语并不匮乏,因此定义了这种形式的Map的函子被称为双函子。其理念是函子有一个内部值,而双函子有两个(或两个中的一个)内部值。双函子可以有RightMap(与Map相同)、LeftMap(将函数映射到左值)和BiMap(与刚刚展示的Map的重载相同)这样的函数。

⁷ 你可以将其视为Either或 C#类型系统的不足。Either在可以(几乎)总是推断类型的 ML 语言中成功使用,因此即使复杂的泛型类型也不会给代码带来任何噪音。这是一个经典例子,说明尽管函数式编程的原则是语言无关的,但它们需要根据每种特定语言的优势和劣势进行调整。

⁸ 在这个上下文中,异常不一定意味着很少发生;它表示技术错误,而不是从业务逻辑的角度来看的错误。

⁹ 记住,这些是单子效应而不是副作用,但在 FP 的术语中,它们简单地被称为效应

¹⁰ 我们将在第三部分中回顾错误处理,背景是惰性和异步,但本章已经涵盖了所有基本方面。

9 使用函数构建应用程序的结构

本章涵盖

  • 部分应用和柯里化

  • 克服方法类型推断的限制

  • 模块化和组合应用程序

  • 将列表简化为单个值

构建复杂、现实世界的应用程序并非易事。关于这个主题已经写下了整本书,所以这一章绝对不是旨在提供一个全面的视角。我们将关注你可以用来模块化和组合完全由函数组成的应用程序的技术,以及这些技术与通常在面向对象编程中如何实现的结果相比。

我们将逐步实现。首先,你需要了解一个经典但相对低级的函数技术,称为部分应用。这项技术允许你编写高度通用的函数,其行为由参数化,然后提供这些参数,获得具有给定参数的更专业化的函数,这些参数已经“固化”在函数中。

然后,我们将探讨如何在实际中应用部分应用来首先指定在启动时可用和纯运行时参数,稍后随着它们的接收而提供。最后,我们将探讨如何进一步采取这种方法,并使用部分应用进行依赖注入,以至于可以完全由函数组成一个应用程序,而不会失去在用对象组合时预期的任何粒度或解耦。

9.1 部分应用:分阶段提供参数

想象一下你正在翻新你的房子。你的室内设计师艾达,打电话给弗雷德,她信任的油漆供应商,并告诉她她打算订购的油漆的详细信息,然后派布鲁诺,装饰师,去取所需的油漆量。图 9.1 说明了这个场景。

图片

图 9.1 弗雷德在提供产品之前需要几条信息。这些信息可以由艾达和布鲁诺在不同时间提供。

显然,商店需要知道客户想要购买什么以及需要多少,以便满足请求,在这种情况下,信息是在不同时间点给出的。为什么?好吧,这是 Ada 的责任来选择颜色和品牌(她不会信任布鲁诺记住她确切的选择)。另一方面,布鲁诺的任务是测量表面并计算所需的油漆量。在这个时候,所有必要的信息都可用,布鲁诺可以从供应商那里取走油漆。

我刚才描述的是部分应用的现实生活类比。在编程中,这意味着分阶段向函数提供输入参数。正如我的现实生活例子一样,这与关注点分离有关:最好在不同的应用程序生命周期阶段和不同的组件中提供函数所需的参数。

让我们看看代码示例。这里的想法是,你有一个需要几块信息来完成其工作的函数(类似于弗雷德,油漆供应商)。例如,在下面的列表中,我们有 greet 函数,它接受一个通用问候语和一个名字,并为给定名字生成个性化的问候语。

列表 9.1 在列表上映射的二进制函数

using Name = System.String;
using Greeting = System.String;
using PersonalizedGreeting = System.String;

var greet = (Greeting gr, Name name) => $"{gr}, {name}";

Name[] names = { "Tristan", "Ivan" };

names.Map(n => greet("Hello", n)).ForEach(WriteLine);
// prints: Hello, Tristan
//         Hello, Ivan

提示:如果你以前从未使用过部分应用,那么将本节中的示例输入到 REPL 中进行操作,以获得对它如何工作的实际感受是很重要的。

列表 9.1 顶部的 using 语句使我们能够给 string 类型的特定使用赋予一些语义意义,从而使得函数签名更有意义。你可以更进一步,定义专用类型(如第四章所述),从而确保例如 PersonalizedGreeting 不可能意外地作为 greet 函数的输入。但就目前的讨论而言,我并不太担心强制执行业务规则——只是关于拥有有意义的、明确的签名,因为我们将会查看很多签名。这是 greet 函数的签名:

(Greeting, Name) → PersonalizedGreeting

然后,我们有一个名字列表,并将 greet 映射到列表上,以获得列表中每个名字的问候语。请注意,greet 函数始终以“Hello”作为其第一个参数,而第二个参数则随着列表中的每个名字而变化。

这感觉有点奇怪。我们有一个通用的问候语和 n 个不同的名字,我们重复了那个问候语 n 次。似乎我们是在重复自己。难道不是更好将问候语固定为“Hello”在 Map 的作用域之外?这将表达这样一个事实,即决定使用“Hello”作为所有名字的问候语是一个更通用的决策,并且可以首先进行。传递给 Map 的函数将只消耗名字。我们如何实现这一点?

在列表 9.1 中,我们无法这样做,因为 greet 需要两个参数,而我们使用的是“正常”的函数应用;也就是说,我们以 greet 所期望的两个参数来调用 greet。(它被称为 application,因为我们正在将函数 greet 应用到其参数上。)

我们可以通过部分应用来解决此问题。想法是允许一些代码决定通用的问候语,并将其作为第一个参数(就像 Ada 决定颜色那样)传递给 greet。这将生成一个新的函数,其中已经内置了“Hello”作为要使用的问候语。然后,其他代码可以调用这个函数,以问候人的名字。

有几种方法可以使这成为可能。你将首先看到如何以一种支持部分应用的方式编写一个特定的函数,然后是如何定义一个通用的 Apply 函数,它可以为任何给定的函数启用部分应用。

9.1.1 手动启用部分应用

独立提供参数的一种方法是将 greet 函数重写如下:

var greetWith = (Greeting gr) => (Name name) => $"{gr}, {name}";

这个新的函数 greetWith 接受一个参数,即通用问候语,并返回一个类型为 Name PersonalizedGreeting 的新函数。注意,当函数使用其第一个参数 gr 被调用时,它被捕获在闭包中,因此“记住”直到返回的函数使用第二个参数 name 被调用。你可以这样使用它:

var greetFormally = greetWith("Good evening");
names.Map(greetFormally).ForEach(WriteLine);
// prints: Good evening, Tristan
//         Good evening, Ivan

我们已经达到了在 Map 范围之外修复问候语的目标。注意 greetgreetWith 依赖于相同的实现,但它们的签名不同。让我们比较一下:

greet     : (Greeting, Name) → PersonalizedGreeting
greetWith : Greeting → (Name → PersonalizedGreeting)

greet 接受两个参数并返回一个值。相比之下,greetWith 接受一个参数,即 Greeting,并返回一个函数,该函数反过来接受一个 Name 并返回一个 PersonalizedGreeting

实际上,箭头符号是 右结合的:箭头右侧的所有内容都被分组。因此,greetWith 签名中的括号是多余的,greetWith 的类型通常可以写成以下形式:

greetWith : Greeting → Name → PersonalizedGreeting

greetWith 被称为是 柯里化 形式:所有参数都是通过函数调用逐个提供的。

再次强调,greetgreetWith 依赖于相同的实现。变化的是签名以及参数是独立提供的并被捕获在闭包中。这是一个很好的迹象,表明我们应该能够机械地进行部分应用,而无需重写函数。让我们看看如何实现这一点。

9.1.2 部分应用的一般化

作为对 greetWith 方法所展示方法的更一般替代,我们可以定义一个适配函数,允许你向一个多参数函数提供一个参数,从而生成一个等待接收剩余参数的函数。以下代码片段展示了通用 Apply 函数的定义,它将给定值作为第一个参数传递给指定的二元函数:

public static Func<T2, R> Apply<T1, T2, R>
(
   this Func<T1, T2, R> f,     ❶
   T1 t1                       ❷
)
=> t2 => f(t1, t2);            ❸

❶ 一个二元函数

❷ 第一个参数的值

❸ 返回一个接受原始函数第二个参数的一元函数

Apply 接受一个二元函数,对给定的参数进行部分应用,并返回一个接受第二个参数的一元函数。提供的输入参数 t1 被捕获在闭包中,产生一个新的函数,当第二个参数提供时,它会调用原始函数 f

我们可以类似地定义 Apply 用于更高阶的函数。例如,下面是 Apply 对三元函数的定义:

public static Func<T2, T3, R> Apply<T1, T2, T3, R>
(
   this Func<T1, T2, T3, R> f,
   T1 t1
)
=> (t2, t3) => f(t1, t2, t3);

这个重载接受一个三元函数和一个用作第一个参数的值。它产生一个二元函数,等待剩余的两个参数。可以定义类似的重载用于更高阶的函数,并包含在 LaYumba.Functional 中。

注意表达式主体方法和 lambda 表示法如何为我们提供良好的语法支持来定义这种函数转换。这种 Apply 的一般定义意味着你不需要手动创建一个像 greetWith 这样的函数。相反,你只需使用 Apply 给原始的 greet 函数提供其第一个参数:

var greetInformally = greet.Apply("Hey");
names.Map(greetInformally).ForEach(WriteLine);
// prints: Hey, Tristan
//         Hey, Ivan

无论你是使用手动方法还是通用的 Apply 函数,你应该开始看到一种模式:我们从一个通用函数(如 greet)开始,使用部分应用来创建这个函数的专用版本(如 greetInformally)。现在这是一个一元函数,可以传递,使用它的代码甚至不需要意识到这个新函数已经被部分应用。图 9.2 图形总结了我们迄今为止所涵盖的步骤。

图 9.2 比较正常函数应用与部分应用。部分应用允许你分步骤提供参数,以获得内置这些参数并等待后续参数的函数。部分应用可以通过手动操作或使用通用的 Apply 函数来启用。

总结来说,部分应用始终是从一般到具体的。它允许你定义通用函数,然后通过提供参数来微调它们的行为。最终,编写这样的通用函数提高了抽象级别,并可能允许更大的代码重用。

9.1.3 参数顺序的重要性

greet 函数展示了通常良好的参数顺序:更通用的参数,这些参数可能在应用程序的生命周期早期被应用,应该放在前面,然后是更具体的参数。我们在早年就学会了说“你好”,但我们会一直遇到和问候新的人直到变老。

作为一项经验法则,如果你将函数视为一个操作,它的参数通常包括以下内容:

  • 该操作将影响的数据。这通常会在后期提供,应该放在最后。

  • 一些确定函数将如何操作或函数执行工作所需的依赖项的选项。这些可能需要在早期确定,并应该放在前面。

警告:这种参数顺序有时与我们的扩展方法使用愿望相冲突:不幸的是,我们只能用 this 修饰符标记方法参数中的第一个,尽管它可能不是最通用的参数。在这种情况下,你必须选择扩展方法语法或部分应用对你预期的用途是否更可取。

当然,确定参数的最佳顺序并不总是容易。你很快就会看到,即使参数的顺序不符合你的预期用途,你仍然可以使用部分应用。

总结来说,每当您有一个多参数函数,并且希望分离提供不同参数的责任时,您就有很好的理由使用部分应用。然而,在继续使用部分应用的实际用途之前,我们应先解决一个难题。这个问题与类型推断有关,我们将在下一节中解决它。

9.2 克服方法解析的怪癖

到目前为止,我们自由地使用了方法、lambda 和委托来表示函数。然而,对于编译器来说,这些都是不同的事物,并且方法的类型推断并不像我们希望的那样好。让我们首先看看事情顺利时会发生什么,比如当我们使用 Option.Map 时:

Some(9.0).Map(Math.Sqrt) // => 3.0

注意,Map 有两个类型参数。如果编译器无法推断它们的类型,我们就必须像这样编写前面的代码片段:

Some(9.0).Map<double, double>(Math.Sqrt)

在这里,名称 Math.Sqrt 识别了一个方法,而 Map 期望一个类型为 Func<T, R> 的委托。更技术地说,Math.Sqrt 识别了一个方法组。由于方法重载,可能有多个具有相同名称的方法。编译器足够智能,不仅可以选择正确的重载(在这种情况下,只有一个),还可以推断 Map 的类型参数。

这一切都很完美。它使我们不必在方法(或,作为替代,lambda)和委托之间进行转换,也不必指定泛型类型,因为这些可以从方法签名中推断出来。不幸的是,对于需要两个或更多参数的方法,所有这些优点都消失了。

让我们看看如果我们尝试将 greet 函数重写为方法会发生什么。在下面的列表中,它被称为 GreeterMethod。这是我们想要编写的代码。

列表 9.2 多参数方法类型推断失败

PersonalizedGreeting GreeterMethod(Greeting gr, Name name)     ❶
   => $"{gr}, {name}";

Func<Name, PersonalizedGreeting> GreetWith(Greeting greeting)
   => GreeterMethod.Apply(greeting);                           ❷

❶ 如果我们将问候函数编写为一个方法...

❷ ...然后这个表达式无法编译。

在这里,我们已经将问候函数编写为一个方法,现在我们想要一个 GreetWith 方法来部分应用它到一个特定的问候。不幸的是,这段代码无法编译,因为名称 GreeterMethod 识别了一个 MethodGroup,而 Apply 期望一个 Func,编译器并没有为我们进行推断。

局部函数中的类型推断

C# 7 引入了 局部函数(在方法作用域内声明的函数),但实际上它们应该被称为 局部方法。内部实现上,它们作为方法实现,尽管这没有任何好处(您不能重载它们),所以在类型推断方面,它们与普通方法具有相同的特性。

如果您想使用泛型 Apply 为方法提供参数,您必须使用以下列表中的某种形式。您会看到,将多参数方法作为 HOF(高阶函数)的参数使用时,需要复杂的语法。

列表 9.3 将多参数方法作为 HOF 的参数

PersonalizedGreeting GreeterMethod(Greeting gr, Name name)
   => $"{gr}, {name}";

Func<Name, PersonalizedGreeting> GreetWith_1(Greeting greeting)
   => FuncExt.Apply<Greeting, Name, PersonalizedGreeting>            ❶
         (GreeterMethod, greeting);

Func<Name, PersonalizedGreeting> GreetWith_2(Greeting greeting)
   => new Func<Greeting, Name, PersonalizedGreeting>(GreeterMethod)  ❷
      .Apply(greeting);

❶ 放弃扩展方法语法,并显式提供所有泛型参数

❷ 在调用Apply之前,显式地将方法转换为Func

我个人认为这两种情况中的语法噪声都是不可接受的。请注意,这些问题是特定于方法解析的。如果你使用委托(例如Func),这些问题就会消失。以下列表展示了创建委托的不同方式,然后你可以使用Apply

列表 9.4 获取委托实例的不同方式

public class TypeInference_Delegate
{
   readonly string separator = ", ";

   // 1\. field
   readonly Func<Greeting, Name, PersonalizedGreeting> GreeterField
      = (gr, name) => $"{gr}, {name}";                              ❶

   // 2\. property
   Func<Greeting, Name, PersonalizedGreeting> GreeterProperty
      => (gr, name) => $"{gr}{separator}{name}";                    ❷

   // 3\. factory
   Func<Greeting, T, PersonalizedGreeting> GreeterFactory<T>()      ❸
      => (gr, t) => $"{gr}{separator}{t}";
}

❶ 委托字段的声明和初始化;注意你在这里不能引用separator

❷ 只有 getters 的属性通过=>引入其主体。

❸ 作为函数工厂的方法可以有泛型参数。

让我们简要讨论这些选项。声明委托字段似乎是最自然的选择。不幸的是,它并不强大。例如,如果你像列表 9.4 中所示的那样结合声明和初始化,你无法在委托主体中引用任何实例变量,如separator。此外,由于字段可以被重新赋值(我们当然不希望在这种情况下这样做),你应该将委托标记为readonly

或者,你可以通过属性公开委托。在公开委托的类中,这相当于只是将=替换为=>来声明一个只有 getters 的属性。从调用代码的角度来看,这种变化是完全透明的。

但最强大的方式是有一个工厂方法:一个仅用于创建你想要的委托的方法。这里的主要区别是,你还可以有泛型参数,这是字段或属性所不可能的。

无论你以何种方式获取委托实例,类型解析都将正常工作,因此,在所有情况下,你可以像这样提供第一个参数:

GreeterField.Apply("Hi");
GreeterProperty.Apply("Hi");
GreeterFactory<Name>().Apply("Hi");

本节的要点是,如果你想使用接受多参数函数作为参数的高阶函数(HOFs),有时最好放弃使用方法,转而使用Func(或者返回Func的方法)。虽然Func不如方法那样符合惯例,但它们可以节省你显式指定类型参数的语法开销,使代码更易于阅读。

既然你已经了解了部分应用,让我们继续讨论一个相关概念:currying。这是一种假设并可能简化部分应用的技术。

9.3 Curried functions: Optimized for partial application

以数学家 Haskell Curry 命名,currying是将一个n-元函数f,它接受参数t1t2,...,tn,转换为一个一元函数,它接受t1并产生一个新的函数,该函数接受t2,依此类推,最终在所有参数都给出后返回与f相同的结果。换句话说,具有此签名的n-元函数

(T1, T2, ..., Tn) → R

当进行 curry 操作时,具有以下特征:

T1 → T2 → ... → Tn → R

你在本章的第一节中已经看到了这个例子。这里是一个提醒:

var greet = (Greeting gr, Name name) => $"{gr}, {name}";

var greetWith = (Greeting gr) => (Name name) => $"{gr}, {name}";

我提到greetWith类似于greet,但以 curry 形式。确实,比较它们的签名:

greet     : (Greeting, Name) → PersonalizedGreeting
greetWith : Greeting → Name → PersonalizedGreeting

这意味着你可以像这样调用柯里化的 greetWith 函数:

greetWith("hello")("world") // => "hello, world"

这实际上是两次函数调用,并且它有效地等同于用两个参数调用 greet。当然,如果你打算同时传递所有参数,这就没有意义了。但是,当你对部分应用感兴趣时,它就变得很有用。如果一个函数是柯里化的,那么通过调用函数就可以简单地实现部分应用。

var greetFormally = greetWith("Good evening");
names.Map(greetFormally).ForEach(WriteLine);
// prints: Good evening, Tristan
//         Good evening, Ivan

函数可以像这里的 greetWith 一样写成柯里形式;这被称为手动柯里化。或者,也可以定义通用的函数,这些函数将接受一个n-元函数并将其柯里化。对于二元和三元函数,Curry 看起来是这样的:

public static Func<T1, Func<T2, R>> Curry<T1, T2, R>
   (this Func<T1, T2, R> f)
    => t1 => t2 => f(t1, t2);

public static Func<T1, Func<T2, Func<T3, R>>> Curry<T1, T2, T3, R>
   (this Func<T1, T2, T3, R> f)
    => t1 => t2 => t3 => f(t1, t2, t3);

可以为其他元数的函数定义类似的重载。作为一个练习,请用箭头符号写出前面函数的签名。让我们看看我们如何使用这样一个通用的 Curry 函数来柯里化 greet 函数:

var greetWith = greet.Curry();
var greetNostalgically = greetWith("Arrivederci");

names.Map(greetNostalgically).ForEach(WriteLine);
// prints: Arrivederci, Tristan
//         Arrivederci, Ivan

当然,如果你想使用通用的 Curry 函数,与 Apply 一样,方法解析的注意事项同样适用。

部分应用和柯里化是密切相关的概念,但又是不同的,当你第一次接触它们时,这通常很令人困惑。让我们明确一下这些区别:

  • 部分应用——你给函数提供的参数少于函数期望的参数,得到一个使用到目前为止提供的参数值特定化的函数。

  • 柯里化——你没有任何参数;你只是将一个n-元函数转换成一个一元函数,可以依次给出参数,最终得到与原始函数相同的结果。

如你所见,柯里化实际上并没有任何事情;相反,它优化了函数以进行部分应用。你可以像我们在本章前面使用通用 Apply 函数那样,不进行柯里化就进行部分应用。另一方面,仅仅柯里化本身是没有意义的:你柯里化一个函数(或以柯里形式编写一个函数)是为了更容易地使用部分应用。

部分应用在 FP 中非常常见,以至于在许多函数式语言中,所有函数默认都是柯里化的。因此,在 FP 文献中,箭头符号中的函数签名都是以柯里形式给出的,如下所示:

T1 → T2 → ... → Tn → R

重要提示:在本书的其余部分,我总是使用柯里符号,即使对于实际上不是柯里化的函数也是如此。

尽管在 C# 中函数默认不是柯里化的,但你仍然可以利用部分应用,通过参数化其行为来编写高度通用且因此广泛可重用的函数。然后你可以使用部分应用来创建你偶尔需要的更具体的函数。如你所见,你可以用不同的方式实现这一点:

  • 通过将函数写成柯里形式

  • 通过使用 Curry 柯里化函数,然后使用后续参数调用柯里化函数

  • 通过逐个提供参数使用 Apply

你使用哪种技术是个人口味的问题,尽管我个人认为使用 Apply 是最直观的。

9.4 创建部分应用友好的 API

既然你已经看到了部分应用的基本机制以及如何通过使用 Func 而不是方法来绕过类型推断不足,我们可以继续到一个更复杂的场景,在这个场景中我们将使用第三方库和现实世界的需求。

部分应用的一个好场景是当一个函数需要一些在启动时可用且不改变的配置,以及每次调用时都变化的临时参数。在这种情况下,一个引导组件可以提供配置参数,从而获得一个只期望调用特定参数的专用函数。然后,这可以提供给功能最终消费者,从而使其无需了解任何关于配置的信息。

在本节中,我们将探讨这样一个例子:访问 SQL 数据库。想象一个应用程序,像大多数应用程序一样,需要对数据库执行多个查询。让我们从部分应用的角度来考虑这个问题。想象一个用于检索数据的一般函数:

  • 它可以被具体化为查询特定的数据库。

  • 它可以被进一步具体化为检索特定类型的对象。

  • 它可以通过给定的查询和参数进一步具体化。

让我们通过一个简单的例子来探索这个问题。假设我们想要通过 ID 加载一个 Employee 或通过姓氏搜索 Employee。这些操作可以通过以下签名的函数来捕捉:

lookupEmployee          : Guid → Option<Employee>
findEmployeesByLastName : string → IEnumerable<Employee>

实现这些函数是我们的高级目标。在低级上,我们将使用 Dapper 库来查询 SQL Server 数据库。¹ 对于检索数据,Dapper 提供了具有以下签名的 Query 方法:

public static IEnumerable<T> Query<T>
(
   this IDbConnection conn,
   string sqlQuery,
   object param = null,
   SqlTransaction tran = null,
   bool buffered = true
)

表 9.1 描述了在调用 Query 时我们需要提供的参数,包括类型参数 T。在这个例子中,我们将忽略最后两个参数,它们是可选的。

表 9.1 Dapper 的 Query 方法参数

T 应从查询返回的数据中填充的类型。在我们的例子中,这将是一个 Employee(Dapper 自动将列映射到字段)。
conn 数据库的连接(注意 Query 是连接上的扩展方法,但这与部分应用无关)。
sqlQuery 这是你要执行的 SQL 查询的模板,例如 "SELECT * FROM EMPLOYEES WHERE ID = @Id"(注意 @Id 占位符)。
param 一个其属性用于填充 sqlQuery 中占位符的对象。例如,前面的查询需要提供的对象包含一个名为 Id 的字段,其值将在 sqlQuery 中被评估并渲染,而不是 @Id

这是一个关于参数顺序的绝佳例子,因为连接和 SQL 查询可以作为应用程序设置的一部分应用,而 param 对象将针对 Query 的每次调用而特定。对吗?

噢,实际上,错了!SQL 连接是轻量级对象,应该在查询执行时获取和释放。实际上,正如你可能从第二章中记得的,Dapper 的 API 的标准使用遵循此模式:

const string sql = "SELECT 1";

using (var conn = new SqlConnection(connString))
{
   conn.Open();
   var result = conn.Query(sql);
}

这意味着我们的第一个参数(连接)不如第二个参数(SQL 模板)通用。但并非一切都已失去。记住,如果你不喜欢你拥有的 API,你可以改变它!这就是适配器函数的作用。²

在本节的其余部分,我们将编写一个更好的支持部分应用的 API,以便创建检索我们感兴趣的数据的专用函数。

9.4.1 类型作为文档

虽然 DB 连接必须是短暂的,但用于创建连接的连接字符串在应用程序的生命周期内通常不会改变。它可以在应用程序启动时从配置中读取,之后永远不会改变。因此,连接字符串将是函数检索数据时使用的最一般参数。

让我们应用第 4.2 节中介绍的一个想法(即我们可以使用类型来使我们的代码更具表达性),并为连接字符串创建一个专用类型。以下列表展示了这种方法。

列表 9.5 用于连接字符串的自定义类型

public record ConnectionString(string Value)
{
   public static implicit operator string(ConnectionString c) => c.Value;
   public static implicit operator ConnectionString(string s) => new (s);
}

当一个字符串不仅仅是字符串,而是数据库连接字符串时,我们将它包装在 ConnectionString 中。这可以通过隐式转换轻易完成。例如,在启动时,我们可以从配置中填充它,如下所示:

ConnectionString connString = configuration
   .GetSection("ConnectionString").Value;

同样的思考方式也适用于 SQL 模板,因此我也定义了一个 SqlTemplate 类型,遵循同样的原则。大多数静态类型函数式语言允许你使用一行代码定义自定义类型,基于内置类型,如下所示:

type ConnectionString = string
type SqlTemplate = string

在 C# 中,这要费点劲,但仍然值得。首先,它使你的函数签名更具意图性:你正在使用类型来记录你的函数做什么。例如,一个函数可以声明它依赖于连接字符串,如下所示。

列表 9.6 使用自定义类型使函数签名更明确

public Option<Employee> lookupEmployee
   (ConnectionString conn, Guid id) => //...

这比依赖于 string 要明确得多。第二个好处是,你现在可以定义 ConnectionString 的扩展方法,这在 string 上是没有意义的。你将在下一部分看到这一点。

9.4.2 数据访问函数的特定化

现在我们已经研究了表示和获取连接字符串的方法,让我们看看执行 DB 查询所需的其他数据,从一般到具体:

  • 我们想要检索的数据类型(例如 Employee

  • SQL 查询模板(例如 "SELECT * FROM EMPLOYEES WHERE ID = @Id")

  • 我们将使用param对象来渲染 SQL 模板(例如new { Id = "123" }

现在是解决方案的核心。我们可以在ConnectionString上定义一个扩展方法,它接受我们需要的参数,如下所示。

列表 9.7 一个更适合部分应用的适配函数

using Dapper;
using static ConnectionHelper;

public static class ConnectionStringExt
{
   public static Func<object, IEnumerable<T>> Retrieve<T>
   (
      this ConnectionString connStr,                        ❶
      SqlTemplate sql                                       ❶
   )
   => param                                                 ❷
   => Connect(connStr, conn => conn.Query<T>(sql, param));
}

❶ 这些值在应用程序启动时可用。

❷ 这个值随着每个查询而变化。

注意,我们依赖于ConnectionHelper.Connect,我们在第 2.3 节中实现了它,并且它内部负责打开和释放连接。如果你不记得实现细节没关系;只需注意,在这里,最通用的参数(在整个应用程序生命周期中不会改变的连接字符串)作为第一个参数,而数据库连接本身是短暂的,并且每次查询都会由Connect创建一个新的实例。这是先前方法的签名:

Retrieve<T> : (ConnectionString, SqlTemplate) → object → IEnumerable<T>

函数接收一个连接字符串和一个 SQL 模板。这些值在应用程序启动时已知,因此可以由一个在启动时读取配置以检索的组件提供。结果是仍然等待接收一个最终参数:一个包含 SQL 查询参数的对象。例如,这样的函数可以被处理客户端发起的请求的组件使用。希望你现在看到了部分应用与关注点分离之间的关系。

下面的列表显示了如何具体化Retrieve以实现我们需要的函数。

列表 9.8 向获取所需签名的函数提供参数

ConnectionString conn = configuration
   .GetSection("ConnectionString").Value;

SqlTemplate sel = "SELECT * FROM EMPLOYEES"
   , sqlById = $"{sel} WHERE ID = @Id"
   , sqlByName = $"{sel} WHERE LASTNAME = @LastName";

// queryById : object → IEnumerable<Employee>
var queryById = conn.Retrieve<Employee>(sqlById);                 ❶

// queryByLastName : object → IEnumerable<Employee>
var queryByLastName = conn.Retrieve<Employee>(sqlByName);         ❷

// lookupEmployee : Guid → Option<Employee>
Option<Employee> lookupEmployee(Guid id)                          ❸
   => queryById(new { Id = id }).SingleOrDefault();

// findEmployeesByLastName : string → IEnumerable<Employee>
IEnumerable<Employee> findEmployeesByLastName(string lastName)    ❸
   => queryByLastName(new { LastName = lastName });

❶ 连接字符串和 SQL 查询是固定的。

❷ 连接字符串和 SQL 查询是固定的。

❸ 我们打算实现的函数

在这里,我们通过具体化先前定义的Retrieve方法来定义queryByIdqueryByLastName。现在我们有两个接受param对象的单参数函数,它封装了用于替换SqlTemplate中占位符的值。

剩下的工作就是定义lookupEmployeefindEmployeesByLastName,它们具有我们在本节开头设定的签名。这些函数作为适配函数,将它们的输入参数转换为适当填充的param对象。

备注:能够给Retrieve提供连接字符串并获取一个可以由返回的数据类型参数化的函数将是非常好的。毕竟,我们将使用相同的连接字符串来检索Employee和其他实体。不幸的是,C#不允许我们延迟泛型类型参数的解析。

在这个例子中,你看到了我们从一个针对任何 SQL 数据库运行任何查询的极其通用的函数开始,最终得到了高度专业化的函数。请注意,我们没有明确使用CurryApply;相反,Retrieve被定义为可以分步骤提供参数。

9.5 模块化和组合应用程序

随着应用程序的增长,我们需要对它们进行模块化,并将它们分解成组件。例如,在第八章中,你看到了处理预订转账请求的端到端示例。我们将所有代码放在控制器中,到结束时,控制器的成员列表看起来如下面的列表所示。

列表 9.9 过于负责的控制器?

public class MakeTransferController : ControllerBase
{
   DateTime now;
   static readonly Regex regex = new Regex("^[A-Z]{6}[A-Z1-9]{5}$");
   string connString;
   ILogger<MakeTransferController> logger;

   public IActionResult MakeTransfer([FromBody] MakeTransfer request)

   IActionResult OnFaulted(Exception ex)

   Validation<Exceptional<Unit>> Handle(MakeTransfer request)

   Validation<MakeTransfer> Validate(MakeTransfer cmd)
   Validation<MakeTransfer> ValidateBic(MakeTransfer cmd)
   Validation<MakeTransfer> ValidateDate(MakeTransfer cmd)

   Exceptional<Unit> Save(MakeTransfer transfer)
}

如果这是一个现实世界的银行应用程序,你将不仅仅有两个规则来检查转账请求的有效性,而是会有几十个规则。你还会处理身份验证和会话管理、仪表化等功能。简而言之,控制器会迅速变得太大,你需要将其分解成具有更多离散责任的不同组件。这使得你的代码更加模块化和易于管理。

模块化的另一个主要动力是代码重用:例如,会话管理或授权的逻辑可能需要由几个控制器使用,因此应该放置在单独的组件中。一旦将应用程序分解成组件,你需要将其重新组合,以便所有必需的组件可以在运行时协作。

在本节中,我们将探讨如何处理模块化以及面向对象和函数式方法在这方面的差异。我们将通过重构 MakeTransferController 来说明这一点。

9.5.1 面向对象编程中的模块化

在面向对象编程(OOP)中,模块性通常是通过将责任分配给不同的类,并通过接口捕获这些责任来获得的。例如,你可能定义一个 IValidator 接口用于验证,以及一个 IRepository 接口用于持久化,如下面的列表所示。

列表 9.10 面向对象编程中的接口捕获组件的责任

public interface IValidator<T>
{
   Validation<T> Validate(T request);
}

public interface IRepository<T>
{
   Option<T> Lookup(Guid id);
   Exceptional<Unit> Save(T entity);
}

控制器将依赖于这些接口来完成其工作,如图 9.3 所示。

图 9.3 在面向对象设计中,高级组件(如 Controller)通过接口消费低级组件(如 Repository)。

这遵循一种称为 依赖反转 的模式,根据该模式,高级组件(如控制器)不是直接消费低级组件,而是通过抽象来消费,这通常被理解为低级组件(如验证器和存储库)实现的接口。³ 这种方法有几个好处:

  • 解耦—你可以更换存储库实现,将其从写入数据库更改为写入队列,而这不会影响控制器。你只需要更改两者之间的连接方式。

  • 可测试性—你可以通过注入一个假的 IRepository 来对处理器进行单元测试,而无需访问数据库。

与依赖反转相关的高成本也是相当高的:

  • 接口的数量激增,增加了样板代码,并使代码难以导航。

  • 组合应用程序的启动逻辑通常远非简单。

  • 构建用于可测试性的模拟实现可能很复杂。

为了管理这种额外的复杂性,通常会使用第三方框架;即,IoC 容器和模拟框架。如果我们遵循这种方法,控制器的实现最终看起来就像下面列表中所示的那样。

列表 9.11 小规模函数式和大规模面向对象

public class MakeTransferController : ControllerBase
{
   IValidator<MakeTransfer> validator;       ❶
   IRepository<MakeTransfer> repository;     ❶

   public MakeTransferController(IValidator<MakeTransfer> validator
      , IRepository<MakeTransfer> repository)
   {
      this.validator = validator;            ❷
      this.repository = repository;          ❷
   }

   [HttpPost, Route("api/transfers/book")]
   public IActionResult TransferOn([FromBody] MakeTransfer transfer)
      => validator.Validate(transfer)        ❸
         .Map(repository.Save)               ❸
         .Match
         (
            Invalid: BadRequest,
            Valid: result => result.Match<IActionResult>
            (
               Exception: _ => StatusCode(500, Errors.UnexpectedError),
               Success: _ => Ok()
            )
         );
}

❶ 依赖项是对象。

❷ 依赖项在构造函数中注入。

❸ 消耗依赖项

你可以说,前面的实现在小规模上是功能性的,在大规模上是面向对象的。主要组件(控制器、验证器、存储库)确实是对象,程序行为编码在这些对象的方法上。另一方面,许多功能性概念随后被用于方法的实现以及它们的签名定义中。

在整体面向对象软件架构中使用功能性技术的方法是整合函数式编程(FP)与面向对象编程(OOP)的一种完全有效的方式。也有可能将函数式方法推进到所有行为都被函数捕获的程度。你将在下一部分看到这一点。

9.5.2 FP 中的模块化

如果面向对象编程的基本单元是对象,那么在函数式编程中它们就是函数。函数式编程中的模块化是通过将责任分配给函数,然后通过函数组合来实现的。在函数式方法中,我们不需要定义接口,因为函数签名已经提供了我们需要的所有接口。

例如,在第三章中,你看到需要一个知道当前时间的验证器类不需要依赖于服务,而只需依赖于一个返回当前时间的函数。下面的列表提供了一个提醒。

列表 9.12 将函数作为依赖项注入

public record DateNotPastValidator(Func<DateTime> Clock)
   : IValidator<MakeTransfer>
{
   public Validation<MakeTransfer> Validate(MakeTransfer transfer)
      => transfer.Date.Date < Clock().Date
         ? Errors.TransferDateIsPast
         : Valid(transfer);
}

总的来说,如果时钟不是一个可以调用来获取当前时间的函数,那它是什么呢?但让我们更进一步:为什么你甚至需要IValidator接口呢?毕竟,验证器如果不是一个可以用来确定给定对象是否有效的函数,那又是什么呢?让我们改用一个委托来表示验证:

// Validator<T> : T → Validation<T>
public delegate Validation<T> Validator<T>(T t);

如果我们遵循这种方法,MakeTransferController不是依赖于一个IValidator对象,而是依赖于一个Validator函数。要实现一个Validator,你不需要有一个存储依赖项的字段的对象;相反,依赖项可以作为函数参数传递,如下一个列表所示。

列表 9.13 将依赖项作为函数参数传递

public static Validator<MakeTransfer> DateNotPast(Func<DateTime> clock)
   => transfer
   => transfer.Date.Date < clock().Date
      ? Errors.TransferDateIsPast
      : Valid(transfer);

在这里,DateNotPast是一个高阶函数(HOF),它接受一个函数clock(它需要知道当前日期的依赖项)并返回一个类型为Validator的函数。注意这种方法如何让你免于创建接口、在构造函数中注入它们以及将它们存储在字段中的整个仪式。

让我们看看如何创建一个Validator。在启动应用程序时,你会给DateNotPast一个从系统时钟读取的函数:

Validator<MakeTransfer> val = DateNotPast(() => DateTime.UtcNow());

然而,出于测试目的,你可以提供一个返回固定日期的clock

var uut = DateNotPast(() => new DateTime(2020, 20, 10));

注意,这实际上是一种部分应用:DateNotPast是一个二元函数(以柯里化形式),它需要一个clock和一个MakeTransfer来计算其结果。你在组合应用(或单元测试的arrange阶段)时提供第一个参数,在处理传入请求(或单元测试的act阶段)时提供第二个参数。

除了验证器之外,MakeTransferController还需要一个依赖项来持久化MakeTransfer请求数据。如果我们打算使用函数,我们可以用以下签名来表示这一点:

MakeTransfer → Exceptional<Unit>

再次,我们可以从一个通用的函数开始,该函数使用此签名写入数据库来创建这样一个函数:

TryExecute : ConnectionString → SqlTemplate → object → Exceptional<Unit>

我们可以用配置中的连接字符串和要执行的命令的 SQL 模板来参数化它。这与你在 9.3 节中看到的Retrieve函数类似,所以我在这里省略了全部细节。我们的控制器实现现在将看起来像这样:

public class MakeTransferController : ControllerBase
{
   Validator<MakeTransfer> validate;
   Func<MakeTransfer, Exceptional<Unit>> save;

   [HttpPost, Route("api/transfers/book")]
   public IActionResult MakeTransfer([FromBody] MakeTransfer cmd)
      => validate(cmd).Map(save).Match( //...
}

如果我们将这种方法推向逻辑结论,我们应该质疑为什么我们还需要控制器,因为所有我们使用的逻辑都可以被捕获在类型为的函数中:

MakeTransfer → IResult

这是一个接受从 HTTP 请求体反序列化的MakeTransfer命令的函数,并返回一个IResult,ASP.NET 使用它来适当地填充 HTTP 响应。更确切地说,该函数还需要接受它所依赖的validatesave函数。以下列表显示了这种方法。

列表 9.14 我们用例的最高级函数

using static Microsoft.AspNetCore.Http.Results;    ❶

static Func<MakeTransfer, IResult> HandleSaveTransfer
(
   Validator<MakeTransfer> validate,               ❷
   Func<MakeTransfer, Exceptional<Unit>> save      ❷
)
=> transfer                                        ❸
=> validate(transfer).Map(save).Match
   (
      Invalid: err => BadRequest(err),
      Valid: result => result.Match
      (
         Exception: _ => StatusCode(StatusCodes.Status500InternalServerError),
         Success: _ => Ok()
      )
   );

❶ 定义了OkBadRequest等函数,这些函数用于填充IResult

❷ 处理命令所需的依赖项

❸ API 接收到的命令

这基本上与我们的MakeTransferController方法相同,只是有几个不同之处:

  • *依赖项不是存储在字段中,而是作为参数传递的函数。我们期望在应用程序启动时提供validatesave,从而得到一个接受MakeTransfer的函数,该函数与每个传入请求一起调用。

  • *代码使用像OKBadRequest这样的函数来填充IResult。这些作为静态方法在Microsoft.AspNetCore.Http.Results中公开。相比之下,之前的实现(列表 9.11)使用了从ControllerBase继承的同等名称的方法。

现在,我们需要注册这个函数,以便当客户端向相应的路由发送 HTTP 请求时实际被调用。让我们接下来这么做。

9.5.3 将函数映射到 API 端点

你可以将 Web API 视为一个函数,它接受 HTTP 请求作为输入,并产生 HTTP 响应作为输出。这样的函数会如何工作?它会查看请求的路由,并将请求传递给相应的函数,因此,一组函数,每个 API 端点一个。从概念上讲,将 API 视为一组函数很容易理解。然而,长期以来,使用 ASP.NET 将这个想法转化为实践并不实用,因为 ASP.NET 优先考虑 MVC 控制器作为创建 Web API 的方式。

这在 .NET 6 中发生了彻底的变化,它包括 最小 API,这是一个允许你简单地将函数映射到 API 端点的特性。这代表了一个巨大的转变!多年来,我们在 C# 中有函数式特性,但受限于面向对象的框架,包括 ASP.NET。最小 API 允许你从头开始以函数式风格构建。

提示:如果你不能使用 .NET 6,你仍然可以通过使用名为 Feather HTTP 的包以函数式风格构建 Web API。Feather HTTP 是后来成为 .NET 6 最小 API 的第一个版本。有关如何引用 Feather HTTP 的最新说明,请参阅 github.com/featherhttp/framework

使用最小 API,你可以用几行代码就配置一个 Web API。

列表 9.15 配置最小 Web API

using Microsoft.AspNetCore.Builder;

var app = WebApplication.Create();          ❶

app.MapGet("/", () => "Hello World!");      ❷

await app.RunAsync();                       ❸

❶ 创建一个 Web 应用程序

❷ 配置端点

❸ 开始监听请求

如你所见,你只需创建一个 WebApplication,然后使用 MapGetMapPost 等方法,提供一个路由以及处理该路由请求的函数。这与在其他语言中非常流行的微 Web 框架相一致。

顶级语句

之前的列表使用了 顶级语句,这是 C# 9 中引入的一个特性。你可以有一个包含松散语句的单个文件,这将作为你应用程序的入口点(之前这些语句会被包裹在 Program.Main 中)。

如果你正在编写最小 API,将你的 API 配置放在入口点文件中是有意义的,可能使用顶级语句。在这个文件中,你可以将你应用程序的所有端点映射到项目中其他文件中定义的函数。

下面的列表展示了另一个稍微复杂一些的端点。这个端点接收一个 Todo 对象并将其保存到数据库中。

列表 9.16 配置 POST 请求

app.MapPost("/todos", async
   (
      [FromServices] TodoDbContext db,    ❶
      Todo todo                           ❷
   ) =>
   {
       await db.Todos.AddAsync(todo);     ❸
       await db.SaveChangesAsync();       ❸

       return new StatusCodeResult(204);  ❹
   });

❶ 此依赖项必须注册。

❷ 反序列化请求体

❸ 写入数据库

❹ 填充响应

如你所见,最小 API 提供了 MVC 控制器中可用的所有优点(依赖注入、反序列化、处理异步处理等),但形式更为简洁。同样,在我们的 BOC 应用程序中,我们可以简单地将列表 9.14 中定义的处理程序插入到 WebApplication 中。然后,应用程序的入口点将看起来像这样:

var app = WebApplication.Create();
var handleSaveTransfer = ConfigureSaveTransferHandler(app.Configuration);

app.MapPost("/Transfer/Future", handleSaveTransfer);

await app.RunAsync();

剩下的只是实现ConfigureSaveTransferHandler,在那里我们设置将在我们的MakeTransfer处理程序中使用的依赖项。以下列表显示了此设置。

列表 9.17 为用例连接所需函数

static Func<MakeTransfer, IResult>
   ConfigureSaveTransferHandler(IConfiguration config)
{
   ConnectionString connString
      = config.GetSection("ConnectionString").Value;
   SqlTemplate InsertTransferSql = "INSERT ...";

   var save = connString.TryExecute(InsertTransferSql);   ❶

   var validate = DateNotPast(() => DateTime.UtcNow);     ❷

   return HandleSaveTransfer(validate, save);             ❸
}

❶ 设置持久性

❷ 设置验证

❸ 将两个合并到主工作流程中

在这里,我们连接了各个部分:我们给我们的通用TryExecute提供所有它需要的参数,以便在需要时将MakeTransfer保存到数据库中;我们给DateNotPast一个时钟;最后,我们将这两个结果函数都传递给我们的逻辑中的主函数(列表 9.14)。

那就结束了!你可以看到我们如何仅使用函数构建整个用例:没有接口,没有存储库——只是函数(一个用于保存数据的函数,一个用于验证的函数,一个用于在处理请求时结合两者的函数)。当你看的时候,它实际上非常干净和简单。

我们仍然只是简单地应用一条验证规则,但在第 9.6 节中,我们将为几个规则提供服务。首先,让我们讨论一下在这个例子中 OO 和功能方法是如何排列的。

9.5.4 比较两种方法

在我刚刚展示的功能方法中,所有依赖都作为函数注入。请注意,使用这种方法,你仍然享有与依赖倒置相关的优势:

  • 解耦—一个函数对其所消费的函数的实现细节一无所知。

  • 可测试性—当测试这些函数中的任何一个时,你可以简单地传递给它返回可预测结果的函数。

你也减轻了与其面向对象版本中依赖倒置相关的一些问题:

  • 你不需要定义任何接口。

  • 这使得测试更容易,因为你不需要设置模拟对象。

例如,以下列表展示了本节中开发的用例的测试。注意,当依赖是函数时,可以编写不使用模拟对象的单元测试。

列表 9.18 无模拟对象的单元测试

[Test]
public void WhenValid_AndSaveSucceeds_ThenResponseIsOk()
{
   var handler = HandleSaveTransfer
   (
      validate: transfer => Valid(transfer),    ❶
      save: _ => Exceptional(Unit())            ❶
   );

   var result = controller.MakeTransfer(MakeTransfer.Dummy);

   Assert.AreEqual(typeof(OkResult), result.GetType());
}

❶ 注入返回可预测结果的函数

到目前为止,功能方法似乎更可取。还有另一个差异需要指出。在 OO 实现(列表 9.10)中,控制器依赖于以下定义的IRepository接口:

public interface IRepository<T>
{
   Option<T> Lookup(Guid id);
   Exceptional<Unit> Save(T entity);
}

但请注意,控制器只使用了Save方法。这违反了接口隔离原则(ISP),该原则指出,客户端不应该依赖于它们不使用的方法。想法是,仅仅因为你在信任你的 15 岁孩子保管你的房子钥匙,并不意味着他们应该有你的车钥匙。IRepository接口实际上应该被拆分为两个单方法接口,控制器应该依赖于一个更小的接口,如下所示:

public interface ISaveToRepository<T>
{
   Exceptional<Unit> Save(T entity);
}

这进一步增加了应用程序中的接口数量。如果你足够坚持 ISP(接口隔离原则),最终你会得到大量只包含一个方法的接口,这些接口传达的信息与函数签名相同,最终使得仅仅注入函数的方法更简单。

(当然,如果控制器确实需要读取和写入两个函数,那么在函数式风格中,我们就必须注入两个函数,从而增加依赖项的数量。通常,函数式风格更为明确。)

为了完成这个用例的实现,我们需要考虑的不是一条,而是多条验证规则。在面向对象编程(OOP)中,你可以使用一个实现 IValidator 的复合验证器,并在内部使用一系列特定的 IValidator。但我们要以函数式风格来完成这个任务,并有一个 Validator 函数,它内部组合多个 Validator 的规则。我们将在下一节中探讨这个问题,但为了做到这一点,我们必须首先退一步,看看将值列表缩减为一个单一值的一般模式。

9.6 将列表缩减为一个单一值

将值列表缩减为一个单一值是一个常见的操作,但我们之前还没有讨论过。在函数式编程(FP)的术语中,这个操作被称为 折叠缩减,这些是在大多数语言或库以及 FP 文献中会遇到的名字。典型地,LINQ 使用不同的名字:Aggregate。如果你已经熟悉 Aggregate,可以跳过下一个小节。

9.6.1 LINQ 的 Aggregate 方法

注意,我们之前使用的大多数与 IEnumerable 相关的函数也返回一个 IEnumerable。例如,Map 接受一个包含 n 个项目的列表,并返回另一个包含 n 个项目的列表,可能类型不同。WhereBind 也保持在抽象内部;它们接受一个 IEnumerable 并返回一个 IEnumerable,尽管元素的数量和类型可能会改变。

Aggregate 与这些函数不同,因为它接受一个包含 n 个项目的列表,并返回恰好一个项目(就像你可能熟悉的 SQL 聚合函数 COUNTSUMAVERAGE 一样)。

给定一个 IEnumerable<T>Aggregate 接受一个初始值,称为累加器(或种子),以及一个累加函数(一个接受累加器和列表中的元素并返回累加器新值的二元函数)。然后 Aggregate 遍历列表,将函数应用于累加器的当前值和列表中的每个元素。Aggregate 的签名是

IEnumerable<T> → Acc → (Acc → T → Acc) → Acc

例如,你可以有一个柠檬的列表,并将其汇总成一杯柠檬汁。累加器是一个空杯子,如果柠檬列表为空,这就是你得到的结果。累加函数接受一个杯子和一个单独的柠檬,并返回一个挤过柠檬的杯子。给定这些参数,Aggregate 遍历列表,将每个柠檬挤入杯子中,最后返回包含所有柠檬汁的杯子。

图 9.4 以图形方式展示了它。如果列表为空,Aggregate 返回给定的累加器,acc。如果它包含一个项目,t[0],它返回将 f 应用到 acct[0] 的结果;让我们称这个值为 acc[1]。如果它包含更多项目,它将计算 acc[1],然后应用 facc[1] 和 t[1] 以获得 acc[2],依此类推,最终返回 acc[N] 作为结果:acc 可以看作是一个初始值,在它之上应用列表中的所有值使用给定的函数。

图 9.4 使用 Aggregate 将列表缩减为一个单一值

Sum 函数(在 LINQ 中可以独立使用)是 Aggregate 的一个特例。一个空列表中所有数字的总和是多少?当然是 0!这就是我们的累加器值。二进制函数仅仅是加法,所以我们可以像下面列表所示那样表达 Sum

列表 9.19 Sum 作为 Aggregate 的特例

Range(1, 5).Aggregate(0, (acc, i) => acc + i) // => 15

注意,这会展开成以下内容:

((((0 + 1) + 2) + 3) + 4) + 5

更一般地,ts.Aggregate(acc, f) 展开成

f(f(f(f(acc, t0), t1), t2), ... tn)

Count 也可以看作是 Aggregate 的一个特例:

Range(1, 5).Aggregate(0, (count, _) => count + 1) // => 5

注意,累加器的类型不一定是列表项的类型。例如,假设我们有一个项目列表,我们想要将它们添加到一个树中。我们列表中的类型可能是 T,而累加器的类型将是 Tree<T>。以下列表显示了我们可以从一个空的树作为累加器开始,并在遍历列表时添加每个项目。

列表 9.20 使用 Aggregate 创建列表中所有项目的树

Range(1, 5).Aggregate(Tree<int>.Empty, (tree, i) => tree.Insert(i))

在这个例子中,我假设 tree.Insert(i) 返回一个包含新插入值的树。

Aggregate 是一个如此强大的方法,以至于我们可以用 Aggregate 来实现 MapWhereBind(我建议作为练习来做)。还有一个不那么通用的重载版本,它不接收累加器参数,而是使用列表的第一个元素作为累加器。这个重载版本的签名是

IEnumerable<T> → (T → T → T) → T

当使用这个重载版本时,结果类型与列表中元素的类型相同,并且列表不能为空。

9.6.2 聚合验证结果

现在你已经知道了如何将值列表缩减为一个单一值,让我们应用这个知识,看看我们如何将验证器列表缩减为一个单一验证器。为了做到这一点,我们需要实现一个具有以下类型的函数

IEnumerable<Validator<T>> → Validator<T>

注意,因为 Validator 本身就是一个函数类型,所以前面的类型展开成这样:

IEnumerable<T → Validation<T>> → T → Validation<T>

首先,我们需要决定我们希望组合验证如何工作:

  • 快速失败——如果验证应该优化效率,则组合验证应该在任何一个验证器失败时立即失败,从而最小化资源的使用。如果你从应用程序中以编程方式验证请求,这是一个很好的方法。

  • 收集错误——您可能希望识别所有被违反的规则,以便在再次请求之前进行修复。当通过表单验证用户请求时,这是一个更好的方法。

快速失败策略更容易实现:每个验证器都返回一个 ValidationValidation 暴露了一个 Bind 函数,该函数仅在状态为 Valid 时应用绑定函数(就像 OptionEither 一样),因此我们可以使用 Aggregate 遍历验证器列表并将每个验证器绑定到运行结果。以下列表显示了这种方法。

列表 9.21 使用 Aggregate 应用列表中的所有验证器

public static Validator<T> FailFast<T>
   (IEnumerable<Validator<T>> validators)
   => t
   => validators.Aggregate(Valid(t)
      , (acc, validator) => acc.Bind(_ => validator(t)));

注意,FailFast 函数接受一个 Validator 列表并返回一个 Validator:一个期望类型为 T 的对象进行验证的函数。在接收到有效的 t 后,它使用 Valid(t) 作为累加器遍历验证器列表(如果验证器列表为空,则 t 是有效的)并将列表中的每个验证器应用到累加器上。从概念上讲,对 Aggregate 的调用如下所示:

Valid(t)
   .Bind(validators[0]))
   .Bind(validators[1]))
   ...
   .Bind(validators[n - 1]));

由于Validation中对Bind的定义,当验证器失败时,后续的验证器将被跳过,整个验证失败。

并非所有验证的成本都相同。例如,使用正则表达式验证 BIC 代码是否格式正确(如列表 8.7 所示)是便宜的。假设您还需要确保给定的 BIC 代码识别一个现有的银行分支。这可能涉及数据库查找或远程调用一个包含有效代码列表的服务,这显然更昂贵。

为了确保整体验证效率,您需要相应地排序验证者列表。在这种情况下,您需要首先应用(便宜)的正则表达式验证,然后才是(昂贵)的远程查找。

9.6.3 收集验证错误

相反的方法是优先考虑完整性,包括所有失败的验证的细节。在这种情况下,您不希望失败阻止进一步的计算;相反,您希望确保所有验证器都运行,并且如果有的话,所有错误都被收集。如果您正在验证一个包含许多字段的表单,并且希望用户看到他们需要修复的所有内容以便进行有效提交,这很有用。以下列表显示了我们可以如何重写结合不同验证器的函数。

收集所有失败验证器的错误

public static Validator<T> HarvestErrors<T>
   (IEnumerable<Validator<T>> validators)
   => t =>
{
   var errors = validators
      .Map(validate => validate(t))     ❶
      .Bind(v => v.Match(
         Invalid: errs => Some(errs),   ❷
         Valid: _ => None))             ❸
      .ToList();

   return errors.Count == 0             ❹
      ? Valid(t)
      : Invalid(errors.Flatten());
};

❶ 独立运行所有验证器

❷ 收集验证错误

❸ 忽略通过验证

❹ 如果没有错误,整体验证通过。

在这里,我们不是使用 Aggregate,而是使用 Map 将验证器列表映射到对要验证的对象运行验证器的结果。这确保了所有验证器都是独立调用的,我们最终得到一个 IEnumerableValidation

然后,我们感兴趣的是收集所有错误。为此,我们使用Option。将Invalid映射到包含错误的Some包装,将Valid映射到None。记住,从第六章中,Bind可以用来从Option列表中过滤None,这正是我们在下面所做的事情,以获得所有错误的列表。因为每个Invalid都包含一个错误列表,所以errors实际上是一个列表的列表。在失败的情况下,我们需要将其展平成一个一维列表,并使用它来填充一个Invalid。如果没有错误,我们返回正在验证的对象,并用Valid包装。⁴

练习

  1. 二元算术函数的偏应用:

    • 编写一个名为Remainder的函数,该函数计算整数除法的余数(适用于负输入值!)。注意参数的预期顺序并不是偏应用最可能需要的顺序(你更有可能偏应用除数)。

    • 编写一个ApplyR函数,该函数将给定二进制函数的最右侧参数传递给它。(尽量在不查看Apply实现的情况下完成。)以箭头符号表示ApplyR的签名,包括柯里化和非柯里化形式。

    • 使用ApplyR创建一个函数,该函数返回任何数字除以 5 的余数。

    • 编写一个ApplyR的重载版本,将三元函数的最右侧参数传递给它。

  2. 三元函数:

    • 定义一个包含三个字段的PhoneNumber类:号码类型(家庭、手机等)、国家代码('it'、'uk'等)和号码。CountryCode应该是一个自定义类型,具有到和从string的隐式转换。

    • 定义一个三元函数,根据这些字段的值创建一个新的数字。你的工厂函数的签名是什么?

    • 使用偏应用创建一个二元函数,该函数创建一个英国号码,然后再创建一个一元函数,该函数创建一个英国手机号码。

  3. 到处都是函数:你可能仍然觉得对象最终比函数更强大。当然,一个日志对象应该公开DebugInfoError等相关操作的方法?为了证明这并不一定如此,挑战自己编写一个简单的日志机制(将日志记录到控制台即可),该机制不需要任何类或结构体。你仍然应该能够将一个Log值注入到消费者类或函数中,公开DebugInfoError操作,如下所示:

    void ConsumeLog(Log log)
       => log.Info("look! no classes!");
    
  4. 开放练习:在你的日常编码中,开始更加关注你编写和使用的函数的签名。参数的顺序是否合理;它们是否从一般到具体?是否有某个参数你总是用相同的值调用,以便可以偏应用它?你是否有时编写相同代码的相似变体,并且可以将这些代码泛化成参数化函数?

  5. Aggregate为依据实现IEnumerableMapWhereBind

摘要

  • 部分应用意味着逐步提供函数的参数,实际上是在每次提供参数时创建一个更专业的函数。

  • 柯里化意味着改变函数的签名,使其一次接受一个参数。

  • 部分应用允许你通过参数化函数的行为,然后提供参数以获得越来越专业的函数,从而编写高度通用的函数。

  • 参数的顺序很重要:你首先给出最左边的参数,这样函数应该从一般到具体声明其参数。

  • 当在 C#中处理多参数函数时,方法解析可能会出现问题,并导致语法开销。这可以通过依赖于Func而不是方法来克服。

  • 你可以通过将它们声明为参数来注入函数所需的依赖项。这允许你完全由函数组成你的应用程序,而不会牺牲关注点的分离、解耦和可测试性。


¹ Dapper 是一个轻量级的 ORM,因其速度快、使用简单而受到很多欢迎;我们首次在第二章中使用它。你可以在 GitHub 上找到它,网址为github.com/StackExchange/dapper-dot-net,你可以在那里找到更多文档。

² 我们在第二章中讨论了适配器函数:如果你不喜欢函数的签名,你可以通过定义一个调用另一个函数并公开更适合你需求的接口的函数来改变它。

³ 注意,依赖注入和依赖反转之间有一个区别。依赖注入要通用得多:这意味着你正在注入一个类、方法或函数所需的某物。例如,如果你注入一个具体实现、原始值或配置对象,你正在使用依赖注入,但不是依赖反转。依赖反转依赖于依赖注入,但反之则不然。

⁴ 实际上,使用我们尚未介绍的应用程序和遍历工具,有更简单的方法来完成这项任务。你将在第十五章中看到这一点。

10 高效使用多参数函数

本章涵盖

  • 使用提升类型与多参数函数一起使用

  • 使用 LINQ 语法与任何单调类型

  • 基于属性的测试基础

本章的主要目标是教会你如何在 effectful 类型世界中使用多参数函数,因此标题中的“effectively”也是一个双关语!记得在 6.6.1 节中,effectful 类型包括 Option(添加了可选性效果)、Exceptional(异常处理)、IEnumerable(聚合)等。在第三部分中,你将看到更多与状态、惰性和异步相关的效果。

随着你更多地使用函数式编程,你将严重依赖这些效果。你可能已经大量使用了 IEnumerable。如果你接受 Option 和一些 Either 变体类型可以为你的程序增加鲁棒性的事实,你很快就会在大部分代码中使用提升类型。

尽管你已经看到了像 MapBind 这样的核心函数的强大功能,但你还没有看到一项重要的技术:如何在你的工作流程中集成多参数函数,因为 MapBind 都接受单元函数。

结果表明,有两种可能的方法:applicative 和 monadic 方法。我们首先将查看 applicative 方法,它使用 Apply 函数(你还没有见过的核心函数)。然后我们将重新审视 monads,你将看到如何使用 Bind 与多参数函数,以及 LINQ 语法如何有助于这个领域。然后我们将比较两种方法,并了解为什么在不同的场景中两者都可以很有用。在这个过程中,我还会介绍与 monads 和 applicatives 相关的一些理论,并介绍一种称为 基于属性的测试 的单元测试技术。

10.1 提升世界中的函数应用

在本节中,我将介绍 applicative 方法,该方法依赖于一个新函数 Apply 的定义,该函数在提升的世界中执行函数应用。Apply,就像 MapBind 一样,是 FP 中的核心函数之一。

为了热身,启动 REPL 并像往常一样导入 LaYumba.Functional 库。然后,输入以下内容:

var doubl = (int i) => i * 2;

Some(3).Map(doubl) // => Some(6)

到目前为止,没有什么新东西:你有一个被 Option 包裹的数字,你可以使用 Map 将单元函数 doubl 应用到它上面。现在,假设你有一个 二元 函数,比如乘法,并且你有两个数字,每个数字都被 Option 包裹。你如何将函数应用到其参数上?

这里是关键概念:柯里化(在第九章中已介绍)允许你将任何 n-元函数转换为接受其参数时返回 (n–1)-元函数的单元函数。这意味着只要你将函数柯里化,你就可以使用 Map 与任何函数一起使用!让我们通过以下列表来实际看看。

列表 10.1 将柯里化函数映射到 Option

var multiply = (int x) => (int y) => x * y;

var multBy3 = Some(3).Map(multiply);
// => Some(y => 3 * y))

记住,当你将一个函数映射到Option上时,Map会提取Option中的值并将其应用于该函数。在上面的列表中,MapOption中提取了值3,并将其传递给multiply函数:3替换了变量x,得到函数y => 3 * y。让我们看看类型:

multiply              : int → int → int
Some(3)               : Option<int>
Some(3).Map(multiply) : Option<int → int>

当你映射一个多参数函数时,函数被部分应用于Option包裹的参数。让我们从更一般的角度来看这个问题。这是Map对于函子F的签名:

Map : F<T> → (T → R) → F<R>

现在想象一下,R的类型恰好是T1 T2,因此R实际上是一个函数。在这种情况下,签名扩展为

F<T> → (T → T1 → T2) → F<T1 → T2>

但是看看第二个参数:T T1 T2。这是一个柯里化的二元函数。这意味着你可以使用Map与任何可变元的函数!为了使调用者免于必须柯里化函数,我的函数库包括接受各种可变元函数的重载的Map,并负责柯里化:例如,

public static Option<Func<T2, R>> Map<T1, T2, R>
   (this Option<T1> opt, Func<T1, T2, R> func)
   => opt.Map(func.Curry());

因此,以下列表中的代码也有效。

列表 10.2:将二元函数映射到Option

var multiply = (int x, int y) => x * y;

var multBy3 = Some(3).Map(multiply);
multBy3 // => Some(y => 3 * y))

现在你已经知道你可以有效地使用Map与多参数函数,让我们看看结果值。这是你以前没有见过的东西:一个升高的函数,它是一个被升高类型包裹的函数,如图 10.1 所示。

图 10.1:将二元函数映射到Option得到一个被Option包裹的单元函数。

升高函数并没有什么特殊之处。函数是值,所以它只是被通常的容器包裹的另一个值。

然而,如何处理一个作为函数的升高的值?现在你有一个被Option包裹的单元函数,你如何提供它的第二个参数?如果第二个参数也被Option包裹呢?一个粗略的方法是显式地解包两个值,然后将函数应用于参数,如下所示:

var multiply = (int x, int y) => x * y;

Option<int> optX = Some(3)
          , optY = Some(4);

var result = optX.Map(multiply).Match
(
   () => None,
   (f) => optY.Match
   (
      () => None,
      (y) => Some(f(y))
   )
);

result // => Some(12)

这段代码并不优雅。它将Option的升高世界留给函数应用,然后将结果再次提升到Option中。是否可以抽象化这一点,并在不离开升高世界的情况下集成多参数函数?这正是Apply函数所做的,我们将在下一节中探讨它。

10.1.1 理解应用函子

在我们查看为升高值定义Apply之前,让我们简要回顾一下在第九章中定义的Apply函数,它在常规值的世界中执行部分应用。我们为Apply定义了各种重载,它接受一个n-元函数和一个参数,并返回将函数应用于参数的结果。签名形式如下

Apply : (T → R) → T → R
Apply : (T1 → T2 → R) → T1 → (T2 → R)
Apply : (T1 → T2 → T3 → R) → T1 → (T2 → T3 → R)

这些签名表示,“给我一个函数和一个值,我会给你应用函数到值的结果,”无论是函数的返回值还是部分应用的函数。

在提升的世界中,我们需要定义Apply的重载,其中输入和输出值被包裹在提升类型中。一般来说,对于任何可以定义Apply的函子AApply的签名将采用以下形式

Apply : A<T → R> → A<T> → A<R>
Apply : A<T1 → T2 → R> → A<T1> → A<T2 → R>
Apply : A<T1 → T2 → T3 → R> → A<T1> → A<T2 → T3 → R>

这就像常规的Apply一样,但在提升世界中,它说,“给我一个包裹在A中的函数和一个包裹在A中的值,我会给你应用函数到值的结果,当然也是包裹在A中的。”这在图 10.2 中得到了说明。

图 10.2 Apply在提升世界中执行函数应用。

Apply的实现必须解包函数,解包值,将函数应用于值,并将结果重新包裹。当我们为函子A定义一个合适的Apply实现时,这被称为应用函子(或简称为应用)。以下列表显示了如何为Option定义Apply,从而使Option成为一个应用。

列表 10.3 OptionApply实现

public static Option<R> Apply<T, R>
(
   this Option<Func<T, R>> optF,
   Option<T> optT
)
=> optF.Match
(
   () => None,
   (f) => optT.Match
   (
      () => None,
      (t) => Some(f(t))                ❶
   )
);

public static Option<Func<T2, R>> Apply<T1, T2, R>
(
   this Option<Func<T1, T2, R>> optF,
   Option<T1> optT
)
=> Apply(optF.Map(F.Curry), optT);     ❷

❶ 只有当两个Option都是Some时才将包裹的函数应用于包裹的值

❷ 将包裹的函数柯里化并调用接受一个包裹一元函数的Option的重载

第一个重载是重要的。它接受一个包裹在Option中的一元函数及其参数,该参数也包裹在Option中。实现仅在两个输入都是Some时才将包裹的函数应用于包裹的值,在其他所有情况下返回None

如同往常,需要为包裹函数的各种可变元定义重载。我们可以根据一元版本定义这些重载,就像第二个重载所展示的那样。

现在封装和解封装的低级细节已经处理完毕,让我们看看如何使用二进制函数与Apply

var multiply = (int x, int y) => x * y;

Some(3).Map(multiply).Apply(Some(4));
// => Some(12)

Some(3).Map(multiply).Apply(None);
// => None

简而言之,如果您有一个包裹在容器中的函数,Apply允许您向它提供参数。让我们进一步探讨这个想法。

10.1.2 提升函数

在之前的例子中,您已经看到通过将多参数函数映射到提升值(如下所示)将函数提升到容器中:

Some(3).Map(multiply)

或者,您也可以通过简单地使用容器的Return函数将函数提升到容器中,就像使用任何其他值一样。毕竟,包裹的函数并不关心它是如何到达那里的,所以您可以写出这个:

Some(multiply)       ❶
   .Apply(Some(3))   ❷
   .Apply(Some(4))   ❷

// => Some(12)

❶ 将函数提升到Option

❷ 使用Apply提供参数

这可以推广到任何可变元的函数。而且,像往常一样,您得到Option的安全性,这样如果沿途的任何值是None,最终结果也是None

Some(multiply)
   .Apply(None)
   .Apply(Some(4))
// => None

如您所见,在提升世界中评估二元函数有两种截然不同但等效的方法。您可以在表 10.1 中并排看到这些。

表 10.1 提升世界中实现函数应用的两个等效方法

将函数Map,然后Apply 将函数提升,然后Apply

|

Some(3)
   .Map(multiply)
   .Apply(Some(4))

|

Some(multiply)
   .Apply(Some(3))
   .Apply(Some(4))

|

第二种方式(首先使用 Return 提升函数,然后应用参数)更易读、更直观,因为它与常规值世界中的部分应用类似,如表 10.2 所示。

表 10.2 常规值和提升值世界中的部分应用

部分应用与常规值 部分应用与提升值

|

multiply
   .Apply(3)
   .Apply(4)

   // => 12

|

Some(multiply)
   .Apply(Some(3))
   .Apply(Some(4))

   // => Some(12)

|

无论你是通过使用 Map 还是使用 Return 提升函数,从结果函子的角度来看都没有关系。这是一个要求,如果适用性被正确实现,它将成立。有时被称为适用性法则。¹

10.1.3 基于属性的测试简介

我们能否编写一些单元测试来证明我们用来处理 Option 的函数满足适用性法则?这种测试(测试实现是否满足某些法则或属性)有特定的技术。它被称为基于属性的测试,并且有一个名为 FsCheck 的支持框架可用于在 .NET 中进行基于属性的测试。²

基于属性的测试是参数化单元测试,其断言应适用于任何可能的参数值。你编写一个参数化测试,然后让一个框架,例如 FsCheck,重复使用大量随机生成的参数值运行测试。

通过一个例子最容易理解这一点。以下列表展示了适用性法则的属性测试可能的样子。

列表 10.4 展示适用性法则的基于属性的测试

using FsCheck.Xunit;
using Xunit;

Func<int, int, int> multiply = (i, j) => i * j;

[Property]                                 ❶
void ApplicativeLawHolds(int a, int b)     ❷
{
   var first = Some(multiply)
       .Apply(Some(a))
       .Apply(Some(b));
   var second = Some(a)
       .Map(multiply)
       .Apply(Some(b));

   Assert.Equal(first, second);
}

❶ 标记基于属性的测试

❷ FsCheck 随机生成大量输入值以运行测试。

如果你查看测试方法的签名,你会看到它使用两个 int 值进行参数化。但与第三章侧边栏“参数化单元测试”中讨论的参数化测试不同,这里我们没有为参数提供任何值。相反,我们只是用 FsCheck.Xunit 中定义的 Property 属性装饰测试方法。³ 当你运行测试时,FsCheck 会随机生成大量输入值并使用这些值运行测试。⁴ 这让你免于需要想出样本输入,并给你更大的信心,确保边缘情况被覆盖。

这个测试通过了,但我们正在使用 int 作为参数并将它们提升到 Option 中,所以它只说明了 Some 状态下的行为。我们还应该测试 None 发生了什么。我们测试方法的签名应该是

void ApplicativeLawHolds(Option<int> a, Option<int> b)

我们还希望 FsCheck 能够随机生成 OptionSomeNone 状态,并将它们提供给测试。

如果我们尝试运行这个,FsCheck 将会抱怨它不知道如何随机生成 Option<int>。幸运的是,我们可以像以下列表所展示的那样教 FsCheck 如何做到这一点。

列表 10.5 教 FsCheck 创建任意的 Option

static class ArbitraryOption
{
   public static Arbitrary<Option<T>> Option<T>()
   {
      var gen = from isSome in Arb.Generate<bool>()
                from val in Arb.Generate<T>()
                select isSome && val != null ? Some(val) : None;
      return gen.ToArbitrary();
   }
}

FsCheck 知道如何生成原始类型,如 boolint,因此生成 Option<int> 应该很容易:生成一个随机的 bool,然后是一个随机的 int;如果 bool 为假,则返回 None;否则,将生成的 int 包装到 Some 中。这就是前面代码的基本含义——在这个阶段不必担心确切的细节。

现在我们只需要指导 FsCheck 在需要随机 Option<T> 时查看 ArbitraryOption 类。下面的列表展示了如何做到这一点。

列表 10.6 使用任意 Option 参数化的属性测试

[Property(Arbitrary = new[] { typeof(ArbitraryOption) })]
void ApplicativeLawHolds(Option<int> a, Option<int> b)
   => Assert.Equal
   (
      Some(multiply).Apply(a).Apply(b),
      a.Map(multiply).Apply(b)
   );

当然,FsCheck 现在能够随机生成这个测试的输入,这个测试通过了,并且完美地展示了 applicative 法则。这证明我们的实现始终满足 applicative 法则吗?并不完全,因为它只测试了属性对于 multiply 函数是成立的,而法则应该对任何函数都成立。不幸的是,与数字和其他值不同,随机生成一组有意义的函数是不可能的。但这类基于属性的测试仍然给我们带来了良好的信心——当然比单元测试,即使是参数化的单元测试要好。

基于现实世界的属性测试

基于属性的测试不仅适用于理论内容,还可以有效地应用于业务线(LOB)应用程序。当你有一个不变量时,你可以编写属性测试来捕捉它。

这里有一个非常简单的例子:如果你有一个随机填充的购物车,并且从中随机移除一定数量的商品,修改后的购物车总额必须始终小于或等于原始购物车的总额。你可以从这样的显然微不足道的属性开始,并继续添加属性,直到它们捕捉到你的模型本质。

这在 Scott Wlaschin 的“为属性测试选择属性”文章中得到了很好的展示,该文章可在 mng.bz/Zx0A 找到。

现在我们已经涵盖了 Apply 函数的机制,让我们比较一下之前讨论过的其他模式。完成这些后,我们将通过一个更具体的例子来查看 applicatives 的实际应用,以及它们与 monads 的比较。

10.2 Functors、applicatives 和 monads

让我们回顾一下你迄今为止看到的三种重要模式:functors、applicatives 和 monads。⁵ 记住,functors 是通过 Map 的实现定义的,monads 是通过 BindReturn 的实现定义的,而 applicatives 是通过 ApplyReturn 的实现定义的,如表 10.3 所示。

表 10.3 针对 functors、applicatives 和 monads 的核心函数总结

模式 必需函数 签名
Functor Map F<T> (T R) F<R>
Applicative Return T A<T>
Apply A<(T R)> A<T> A<R>
单子 Return T M<T>
Bind M<T> (T M<R>) M<R>

首先,为什么Return是单子和应用函子的要求,而不是函子的要求?你需要一种方法将值T放入函子F<T>中;否则,你无法创建任何可以对其Map函数的东西。实际上,问题在于函子定律(Map应该观察的性质)并不依赖于Return的定义,而单子和应用函子定律则依赖于。这主要是一个技术问题。

更有趣的是,你可能想知道这三个模式之间的关系。在第七章中,你看到单子比函子更强大。应用函子也比函子更强大,因为你可以用ReturnApply来定义MapMap接受提升的值和常规函数,因此你可以使用Return提升函数,然后使用Apply将其应用于提升的值。对于Option,它看起来像这样:

public static Option<R> Map<T, R>
   (this Option<T> opt, Func<T, R> f)
   => Some(f).Apply(opt);

任何其他应用函子的实现都将相同,使用相关的Return函数而不是Some

最后,单子比应用函子更强大,因为你可以像这样用BindReturn来定义Apply

public static Option<R> Apply<T, R>
(
   this Option<Func<T, R>> optF,
   Option<T> optT
)
=> optT.Bind(t => optF.Bind(f => Some(f(t))));

这使我们能够建立一个层次结构,其中函子是最一般的模式,应用函子位于函子和单子之间。图 10.3 显示了这些关系。

你可以将这看作是一个类图:如果函子是一个接口,应用函子将扩展它。此外,在第九章中,我讨论了fold函数,或者如 LINQ 中称为Aggregate的函数,这是其中最强大的,因为你可以用它来定义BindFoldables(可以定义fold的东西)比单子更强大。

图片

图 10.3 函子、应用函子和单子之间的关系

应用函子不像函子和单子那样常用,那么为什么还要费心呢?结果证明,尽管Apply可以用Bind来定义,但它通常有自己的实现,这既是为了效率,也是因为Apply可以包含在用Bind定义Apply时丢失的有趣行为。在这本书中,我将展示两个单子,它们的Apply实现具有这种有趣的行为:Validation(在本章后面)和Task(第十六章)。

接下来,让我们回到单子的主题,看看你如何使用多参数函数的Bind

10.3 单子定律

现在我将讨论单子定律,这是在第六章中首次介绍术语单子时承诺的。如果你对理论不感兴趣,请跳到 10.3.4 节。

记住,单子是一个类型M,对于以下函数有定义:

  • Return——接受类型T的常规值并将其提升为类型M<T>的单子值

  • Bind——接受一个单子值m和一个跨世界函数f;从m中提取其内部值t并将f应用于它

ReturnBind应该具有以下三个属性:

  1. 右单位性

  2. 左单位性

  3. 结合律

对于当前的讨论,我们主要对第三条法则,即结合律感兴趣,但前两条法则足够简单,我们也可以涵盖它们。

10.3.1 右侧单位元

右侧单位元的性质表明,如果你将Return函数绑定到单子值m上,你最终会得到m。换句话说,以下应该成立:

m == m.Bind(Return)

如果你看看前面的等式,在右侧,Bind解包了m内部的值并应用Return,将其重新提升。结果是零并不令人惊讶。下一个列表显示了一个测试,证明了Option类型的右侧单位元成立。

列表 10.7 右侧单位元的属性测试

[Property(Arbitrary = new[] { typeof(ArbitraryOption) })]
void RightIdentityHolds(Option<object> m)
   => Assert.Equal
   (
      m,
      m.Bind(Some)
   );

10.3.2 左侧单位元

左侧单位元的性质表明,如果你首先使用Return提升t,然后Bind一个函数f到结果,那么这应该等同于将f应用到t上:

Return(t).Bind(f) == f(t)

如果你看看这个等式,在左侧你使用Return提升t,然后Bind在将其提供给f之前提取它。这个法则表明,这种提升和提取应该没有副作用,并且也不应以任何方式影响t。下一个列表显示了一个测试,证明了IEnumerable的左侧单位元成立。

列表 10.8 左侧单位元的属性测试

Func<int, IEnumerable<int>> f = i => Range(0, i);

[Property] void LeftIdentityHolds(int t)
   => Assert.Equal
   (
      f(t),
      List(t).Bind(f)
   );

一起来看,左侧和右侧单位元确保在Return中执行的提升操作和作为Bind一部分发生的解包是中性操作,它们没有副作用,并且不会扭曲t的值或f的行为,无论这种包装和解包是在将值提升到单子之前(左侧)还是之后(右侧)发生。我们可以编写一个单子,比如,在内部保持Bind被调用的次数计数,或者包含其他副作用。这将违反这个性质。

用更简单的话说,Return应该尽可能“愚蠢”:没有副作用,没有条件逻辑,不对给定的t进行操作;只需完成满足签名T C<T>的最小工作。

让我们来看一个反例。以下基于属性的测试据说说明了Option的左侧单位元:

Func<string, Option<string>> f = s => Some($"Hello {s}");

[Property] void LeftIdentityHolds(string t)
   => Assert.Equal
   (
      f(t),
      Some(t).Bind(f)
   );

结果表明,当t的值为null时,前面的性质失败了。这是因为我们的Some实现过于智能,如果给定null会抛出异常,而此特定函数fnull容忍的,并产生Some("Hello ")

如果你希望左侧单位元对任何值都成立,包括null,你需要更改Some的实现,将null提升到Some中。但这不是一个好主意,因为这样Some会表示存在数据,而实际上并没有。这是一个实用性胜过理论的例子。⁶

10.3.3 结合律

现在让我们继续到第三条定律,这是对我们当前讨论最有意义的。我会先提醒一下结合性对加法意味着什么:如果您需要添加两个以上的数字,您如何分组并不重要。也就是说,对于任何数字 abc,以下都是正确的:

(a + b) + c  ==  a + (b + c)

Bind 也可以被视为一个二元运算符,可以用符号 >>= 表示,这样您就可以用符号 m >>= f 来代替 m.Bind(f),其中 m 表示一个单调值,而 f 是一个跨越世界的函数。符号 >>=Bind 的相当标准的表示法,它应该能够图形化地反映 Bind 所做的操作:提取左操作数的内部值并将其馈送到作为右操作数的函数。

结果表明,Bind 在某种意义上也是结合的。您应该能够写出以下等式:

(m >>= f) >>= g == m >>= (f >>= g)

让我们看看左侧。在这里,您计算第一个 Bind 操作,然后使用得到的单调值作为下一个 Bind 操作的输入。这将展开为 m.Bind(f).Bind(g),这是我们通常使用 Bind 的方式。

现在让我们看看右侧。按照现在的写法,它是语法错误的:(f >>= g) 不起作用,因为 >>= 期望左操作数是一个单调值,而 f 是一个函数。但请注意,f 可以展开为其 lambda 形式,x ⇒ f(x),因此您可以按如下方式重写右侧:

m >>= (x => f(x) >>= g)

Bind 的结合性可以用以下等式总结:

(m >>= f) >>= g  ==  m >>= (x => f(x) >>= g)

或者,如果您愿意,可以写成以下形式:

m.Bind(f).Bind(g)  ==  m.Bind(x => f(x).Bind(g))

下面的列表展示了如何将此转换为代码。它展示了一个基于属性的测试,说明 Option 的实现具有结合性质。

列表 10.9 展示 Bind 结合性质对于 Option 的基于属性的测试

using Double = LaYumba.Functional.Double;      ❶

Func<double, Option<double>> safeSqrt = d
   => d < 0 ? None : Some(Math.Sqrt(d));

[Property(Arbitrary = new[] { typeof(ArbitraryOption) })]
void AssociativityHolds(Option<string> m)
   => Assert.Equal
   (
      m.Bind(Double.Parse).Bind(safeSqrt),
      m.Bind(x => Double.Parse(x).Bind(safeSqrt))
   );

❶ 暴露一个返回 OptionParse 函数

当我们将左侧与 m.Bind(f).Bind(g) 结合时,这给出了更易读的语法(我们之前使用的语法)。但如果我们将右侧与 g 结合并展开为 lambda 形式,我们得到的是这个:

m.Bind(x => f(x).Bind(y => g(y)))

有趣的是,在这里 g 不仅可以看到 y,还可以看到 x。这正是您能够在单调流(我指的是通过 Bind 连接几个操作的流程)中集成多参数函数的原因。我们接下来会看看这一点。

10.3.4 使用多参数函数与 Bind 结合

让我们看看在之前的 Bind 调用内部调用 Bind 如何允许您集成多参数函数。例如,想象一个乘法,其中两个参数都被包裹在一个 Option 中,因为它们必须从字符串中解析出来。在这个例子中,Int.Parse 接受一个字符串并返回一个 Option<int>

static Option<int> MultiplicationWithBind(string strX, string strY)
   => Int.Parse(strX)
      .Bind(x => Int.Parse(strY)
         .Bind<int, int>(y => multiply(x, y)));

这虽然可行,但可读性极差。想象一下,如果你有一个接受三个或更多参数的函数!嵌套调用 Bind 使得代码难以阅读,因此你肯定不希望编写或维护这样的代码。你在 10.1.2 节中看到的适用性语法要清晰得多。实际上,有一种更好的语法可以用来编写嵌套的 Bind 应用。这种语法被称为 LINQ

10.4 使用 LINQ 提高任何单子的可读性

根据上下文,LINQ 的名称用来表示不同的事物:

  • 它可以简单地引用 System.Linq 库。

  • 它可以指一种特殊的类似 SQL 的语法,可以用来表达对各种类型数据的查询。实际上,LINQ 代表 Language-Integrated Query

自然地,这两个是相关的,并且它们都是在 C# 3 中同时引入的。到目前为止,你在本书中看到的 LINQ 库的所有用法都使用了正常方法调用,但有时使用 LINQ 语法可以产生更可读的查询。例如,将表 10.4 中的两个表达式输入到 REPL 中,以查看它们是等价的。

表 10.4 LINQ 是一种用于表达查询的专用语法。

正常方法调用 LINQ 表达式

|

Enumerable.Range(1, 100).
   Where(i => i % 20 == 0).
   OrderBy(i => -i).
   Select(i => $"{i}%")

|

from i in Enumerable.Range(1, 100)
where i % 20 == 0
orderby -i
select $"{i}%"

|

这两个表达式不仅在产生相同结果的意义上是等价的;实际上,它们编译成相同的代码。当 C# 编译器发现 LINQ 表达式时,它会以基于模式的方式将其子句转换为方法调用——你将在下一刻更详细地了解这一点。

这意味着你可以为你的自定义类型实现查询模式,并使用 LINQ 语法与它们一起工作,这可以显著提高可读性。接下来,我们将探讨为 Option 实现查询模式。

10.4.1 使用 LINQ 与任意函子

最简单的 LINQ 查询具有单个 fromselect 子句,它们解析为 Select 方法。例如,这里是一个使用范围作为数据源的简单查询:

using System.Linq;
using static System.Linq.Enumerable;

from x in Range(1, 4)
select x * 2;
// => [2, 4, 6, 8]

Range(1, 4) 返回一个包含值 [1, 2, 3, 4] 的序列,这是 LINQ 表达式的数据源。然后我们通过将数据源中的每个项目 x 映射到 x * 2 来创建一个 投影,以生成结果。底层发生了什么?

给定一个如前所述的 LINQ 表达式,编译器会查看数据源的类型(在这种情况下,Range(1, 4) 的类型为 RangeIterator),然后寻找一个名为 Select 的实例或扩展方法。编译器使用其正常的方法解析策略,优先考虑作用域中最具体的匹配项,在这种情况下是定义在 IEnumerable 上的扩展方法 Enumerable.Select

在表 10.5 中,你可以看到 LINQ 表达式及其翻译并排。注意 Select 给出的 lambda 表达式如何结合 from 子句中的标识符 xselect 子句中的选择表达式 x * 2

表 10.5 带有一个 from 子句的 LINQ 表达式及其解释

|

from x in Range(1, 4)
select x * 2

|

Range(1, 4).
   Select(x => x * 2)

|

记得在第六章中,Select 是 LINQ 对 FP 中更常见的操作 Map 的等价操作。LINQ 的基于模式的处理方法意味着你可以为任何类型定义 Select,编译器会在找到该类型作为 LINQ 查询的数据源时使用它。让我们为 Option 做这件事:

public static Option<R> Select<T, R>
   (this Option<T> opt, Func<T, R> f)
   => opt.Map(f);

前面的代码实际上只是将 MapSelect 别名,这是编译器寻找的名称。这就是你能够在简单的 LINQ 表达式中使用 Option 所需要的一切!以下是一些示例:

from x in Some(12)
select x * 2
// => Some(24)

from x in (Option<int>)None
select x * 2
// => None

(from x in Some(1) select x * 2) == Some(1).Map(x => x * 2)
// => true

总结来说,你可以通过提供一个合适的 Select 方法,使用任何函子(functor)与单个 from 子句进行 LINQ 查询。当然,对于如此简单的查询,LINQ 的表示法实际上并不带来太多好处;标准方法调用甚至可以节省你几个按键。让我们看看更复杂的查询会发生什么。

10.4.2 使用任意单子(monads)的 LINQ

让我们看看具有多个 from 子句的查询——这些查询结合了多个数据源的数据。以下是一个示例:

var chars = new[] { 'a', 'b', 'c' };
var ints = new [] { 2, 3 };

from c in chars
from i in ints
select (c, i)
// => [(a, 2), (a, 3), (b, 2), (b, 3), (c, 2), (c, 3)]

如你所见,这有点类似于对两个数据源进行嵌套循环,我们在第 6.3.2 节讨论 Bind 时已经提到。实际上,你可以使用 MapBind 编写一个等效的表达式,如下所示:

chars
   .Bind(c => ints
      .Map(i => (c, i)));

或者,等价地,使用标准的 LINQ 方法名(Select 代替 MapSelectMany 代替 Bind):

chars
   .SelectMany(c => ints
      .Select(i => (c, i)));

注意,你可以构造一个包含来自两个数据源的数据的结果,因为你封闭了变量 c

你可能会猜测,当查询中存在多个 from 子句时,它们会与 SelectMany 的相应调用一起解释。你的猜测是正确的,但有一个转折。出于性能原因,编译器不会执行前面的转换,而是转换为具有不同签名的 SelectMany 重载:

public static IEnumerable<RR> 
SelectMany<T, R, RR>
(
   this IEnumerable<T> source,
   Func<T, IEnumerable<R>> bind,
   Func<T, R, RR> project
)
{
   foreach (T t in source)
      foreach (R r in bind(t))
         yield return project(t, r);
}

这意味着这个 LINQ 查询

from c in chars
from i in ints
select (c, i)

实际上会被翻译为

chars.SelectMany(c => ints, (c, i) => (c, i))

下面的列表显示了 SelectMany 的普通实现(其签名与 Bind 相同)和扩展重载(当查询包含两个 from 子句并转换为方法调用时将使用)。

列表 10.10 SelectMany 的两个重载,LINQ 所需

public static IEnumerable<R> SelectMany<T, R>          ❶
(
   this IEnumerable<T> source,
   Func<T, IEnumerable<R>> func
)
{
   foreach (T t in source)
      foreach (R r in func(t))
         yield return r;
}

public static IEnumerable<RR> SelectMany<T, R, RR>     ❷
(
   this IEnumerable<T> source,
   Func<T, IEnumerable<R>> bind,
   Func<T, R, RR> project
)
{
   foreach (T t in source)
      foreach (R r in bind(t))
         yield return project(t, r);
}

❶ 普通版本的 SelectMany,相当于 Bind

❷ 当翻译包含两个 from 子句的查询时使用的扩展重载 SelectMany

比较签名。你会看到第二个重载是通过将普通 SelectMany 与一个选择函数的调用“压缩”得到的;不是一个常规的选择函数 T R,而是一个接受两个输入参数(每个数据源一个)的选择函数。

优点是,使用 SelectMany 的这个更复杂的重载,不再需要嵌套一个 lambda 表达式在另一个 lambda 表达式内部,从而提高了性能。⁷

扩展的 SelectMany 比我们用单子 Bind 识别的纯版本更复杂,但它仍然在功能上等同于 BindSelect 的组合。这意味着我们可以为任何单子定义一个合理的 LINQ 风格的 SelectMany 实现。让我们看看 Option 的例子:

public static Option<RR> SelectMany<T, R, RR>
(
   this Option<T> opt,
   Func<T, Option<R>> bind,
   Func<T, R, RR> project
)
=> opt.Match
(
   () => None,
   (t) => bind(t).Match
   (
      () => None,
      (r) => Some(project(t, r))
   )
);

如果你编写了一个包含三个或更多 from 子句的表达式,编译器还要求使用纯版本的 SelectMany——与 Bind 具有相同签名的版本。因此,需要定义 SelectMany 的两个重载来满足 LINQ 查询模式。

你现在可以在具有多个 from 子句的 Option 上编写 LINQ 查询。例如,以下是一个简单的程序,提示用户输入两个整数并计算它们的和,使用返回 Option 的函数 Int.Parse 来验证输入是否为有效的整数:

WriteLine("Enter first addend:");
var s1 = ReadLine();

WriteLine("Enter second addend:");
var s2 = ReadLine();

var result = from a in Int.Parse(s1)
             from b in Int.Parse(s2)
             select a + b;

WriteLine(result.Match
(
   None: () => "Please enter 2 valid integers",
   Some: (r) => $"{s1} + {s2} = {r}"
));

以下列表展示了如何将前面示例中的 LINQ 查询与编写相同表达式的替代方法进行比较。

列表 10.11 添加两个可选整数的不同方法

// 1\. using LINQ query
from a in Int.Parse(s1)
from b in Int.Parse(s2)
select a + b

// 2\. normal method invocation
Int.Parse(s1)
   .Bind(a => Int.Parse(s2)
      .Map(b => a + b))

// 3\. the method invocation that the LINQ query will be converted to
Int.Parse(s1)
   .SelectMany(a => Int.Parse(s2)
      , (a, b) => a + b)

// 4\. using Apply
Some(new Func<int, int, int>((a, b) => a + b))
   .Apply(Int.Parse(s1)
   .Apply(Int.Parse(s2))

毫无疑问,LINQ 在此场景中提供了最易读的语法。Apply 与之相比尤其糟糕,因为你必须指定你想要你的投影函数作为 Func 使用。⁸ 你可能会觉得使用类似 SQL 的 LINQ 语法来做与查询数据源无关的事情是不熟悉的,但这种用法是完全合法的。LINQ 表达式只是提供了一个方便的语法来处理单子,并且它们是在函数语言中类似构造的基础上构建的。⁹

10.4.3 LINQ 子句 let、where 以及其他

除了你迄今为止看到的 fromselect 子句之外,LINQ 还提供了一些其他子句。let 子句对于存储中间计算结果非常有用。例如,让我们看看以下列表中的程序,它计算直角三角形的斜边长度,并提示用户输入两边的长度。

列表 10.12 使用 let 子句与 Option

using Double = LaYumba.Functional.Double;    ❶

string s1 = Prompt("First leg:")             ❷
     , s2 = Prompt("Second leg:");

var result = from a in Double.Parse(s1)
             let aa = a * a                  ❸
             from b in Double.Parse(s2)
             let bb = b * b                  ❸
             select Math.Sqrt(aa + bb);

WriteLine(result.Match
(
   () => "Please enter two valid numbers",
   (h) => $"The hypotenuse is {h}"
));

❶ 提供了一个返回 OptionParse 函数

❷ 假设 Prompt 是一个便利函数,用于从控制台读取用户输入

let 子句允许你存储中间结果。

let 子句允许你在 LINQ 表达式的范围内定义一个新的变量,例如本例中的 aa。为此,它依赖于 Select,因此不需要额外的工作来启用 let 的使用。¹⁰

你还可以与 Option 一起使用的另一个子句是 where 子句。这解析为我们已经定义的 Where 方法,因此在这种情况下不需要额外的工作。例如,对于斜边长度的计算,你应该检查用户输入不仅是否为有效数字,而且是否为正数。以下列表展示了如何进行此操作。

列表 10.13 使用 where 子句与 Option

string s1 = Prompt("First leg:")
     , s2 = Prompt("Second leg:");

var result = from a in Double.Parse(s1)
             where a >= 0
             let aa = a * a

             from b in Double.Parse(s2)
             where b >= 0
             let bb = b * b
             select Math.Sqrt(aa + bb);

WriteLine(result.Match
(
   () => "Please enter two valid, positive numbers",
   (h) => $"The hypotenuse is {h}"
));

如这些示例所示,LINQ 语法允许你简洁地编写查询,这些查询作为对应MapBindWhere函数调用的组合将是繁琐的。LINQ 还包含各种其他子句,如orderby,你已在之前的示例中看到。这些子句对于集合是有意义的,但在OptionEither之类的结构中没有对应项。

总结来说,对于任何单子,你可以通过提供SelectMap)、SelectManyBind)以及SelectMany的三元重载的实现来实施 LINQ 查询模式。某些结构可能具有其他可以包含在查询模式中的操作,例如在Option的情况下使用Where

现在你已经看到 LINQ 如何为使用多参数函数的Bind提供轻量级语法,让我们回到比较BindApply,不仅基于可读性,还基于实际功能。

10.5 当使用 Bind 与 Apply

LINQ 提供了使用Bind的良好语法,即使对于多参数函数也是如此——甚至比使用Apply进行常规方法调用还要好。我们是否仍然应该关心Apply?实际上,在某些情况下,Apply可以表现出有趣的行为。其中一种情况是验证;让我们看看原因。

10.5.1 使用智能构造函数进行验证

考虑以下PhoneNumber类的实现。你能看出其中有什么问题吗?

public record PhoneNumber
(
   string Type,
   string Country,
   long Nr
);

答案应该就在你的眼前:类型是错误的!这个类允许你创建一个PhoneNumber,例如,Type等于“绿色”,Country等于“幻想国”,Nr等于“•10”。

你在第四章中看到,定义自定义类型可以确保无效数据不会悄悄进入你的系统。以下是一个遵循这一理念的PhoneNumber类的定义:

public record PhoneNumber
{
   public NumberType Type { get; }
   public CountryCode Country { get; }
   public Number Nr { get; }

   public enum NumberType { Mobile, Home, Office }
   public struct Number { /* ... */ }
}

public class CountryCode { /* ... */ }

现在的PhoneNumber的三个字段都具有特定的类型,这应该确保只能表示有效值。CountryCode可能在应用程序的其他地方使用,但剩余的两个类型是特定于电话号码的,因此它们在PhoneNumber类内部定义。

我们仍然需要提供一种构建PhoneNumber的方法。为此,我们可以定义一个私有构造函数和一个公共工厂函数Create

public record PhoneNumber
{
   public static Func<NumberType, CountryCode, Number, PhoneNumber>
   Create = (type, country, number)
      => new(type, country, number);

   PhoneNumber(NumberType type, CountryCode country, Number number)
   {
      Type = type;
      Country = country;
      Nr = number;
   }
}

注意,我将Create定义为Func而不是使用构造函数或方法来帮助类型推断。这已在第 9.2 节中讨论过。

现在假设我们得到了三个原始输入字符串,并且基于它们,我们需要创建一个PhoneNumber。每个属性可以独立验证,因此我们可以定义三个具有以下签名的智能构造函数:

validCountryCode : string → Validation<CountryCode>
validNumberType  : string → Validation<PhoneNumber.NumberType>
validNumber      : string → Validation<PhoneNumber.Number>

这些函数的实现细节并不重要(如果你想知道更多,请参阅代码示例)。关键是validCountryCode接受一个string并仅在给定的字符串表示有效的CountryCode时返回Valid状态的Validation。其他两个函数类似。

10.5.2 使用应用流收集错误

给定三个输入字符串,我们可以在创建PhoneNumber的过程中将这些三个函数组合起来,如下所示列表所示。使用应用流,我们可以将PhoneNumber的工厂函数提升到Valid并应用其三个参数。

列表 10.14 使用应用流的Validation

Validation<PhoneNumber> CreatePhoneNumber
   (string type, string countryCode, string number)
   => Valid(PhoneNumber.Create)                        ❶
      .Apply(validNumberType(type))                    ❷
      .Apply(validCountryCode(countryCode))            ❷
      .Apply(validNumber(number));                     ❷

❶ 将工厂函数提升到Validation

❷ 提供参数,每个参数也被包装在一个Validation

如果我们使用的任何函数在验证单个字段时返回Invalid,则此函数将返回Invalid。让我们看看它在各种不同输入下的行为:

CreatePhoneNumber("Mobile", "ch", "123456")
// => Valid(Mobile: (ch) 123456)

CreatePhoneNumber("Mobile", "xx", "123456")
// => Invalid([xx is not a valid country code])

CreatePhoneNumber("Mobile", "xx", "1")
// => Invalid([xx is not a valid country code, 1 is not a valid number])

第一个表达式显示了成功创建PhoneNumber。在第二个中,我们传递一个无效的国家代码,并得到预期的失败。在第三种情况下,国家和号码都无效,我们得到一个包含两个错误的验证(记住,ValidationInvalid情况包含一个IEnumerable<Error>,正好用于捕获多个错误)。

但两个错误是如何在最终结果中收集的?这是由于ApplyValidation的实现。查看以下列表。

列表 10.15 ValidationApply实现

public static Validation<R> Apply<T, R>
(
   this Validation<Func<T, R>> valF,
   Validation<T> valT
)
=> valF.Match
(
   Valid: (f) => valT.Match
   (
      Valid: (t) => Valid(f(t)),                      ❶
      Invalid: (err) => Invalid(err)
   ),
   Invalid: (errF) => valT.Match
   (
      Valid: (_) => Invalid(errF),
      Invalid: (errT) => Invalid(errF.Concat(errT))   ❷
   )
);

❶ 如果两个输入都是有效的,则将包装的函数应用于包装的参数,并将结果提升到Valid状态的Validation中。

❷ 如果两个输入都存在错误,将返回一个处于Invalid状态的Validation,该状态收集来自valFvalT的错误。

如我们所预期,Apply仅在两个参数都有效时才将包装的函数应用于包装的参数。但,有趣的是,如果两个参数都无效,它将返回一个结合两个参数错误的Invalid

10.5.3 使用单子流快速失败

以下列表演示了如何使用 LINQ 创建PhoneNumber

列表 10.16 使用单子流的Validation

Validation<PhoneNumber> CreatePhoneNumberM
   (string typeStr, string countryStr, string numberStr)
   => from type    in validNumberType(typeStr)
      from country in validCountryCode(countryStr)
      from number  in validNumber(numberStr)
      select PhoneNumber.Create(type, country, number);

让我们使用之前相同的测试值运行这个新版本:

CreatePhoneNumberM("Mobile", "ch", "123456")
// => Valid(Mobile: (ch) 123456)

CreatePhoneNumberM("Mobile", "xx", "123456")
// => Invalid([xx is not a valid country code])

CreatePhoneNumberM("Mobile", "xx", "1")
// => Invalid([xx is not a valid country code])

前两种情况与之前相同,但第三种情况不同:只有第一个验证错误出现。为了了解原因,让我们看看下一个列表中Bind的定义(LINQ 查询实际上调用SelectMany,但这是以Bind为术语实现的)。

列表 10.17 ValidationBind实现

public static Validation<R> Bind<T, R>
(
   this Validation<T> val,
   Func<T, Validation<R>> f
)
=> val.Match
(
   Invalid: (err) => Invalid(err),
   Valid: (t) => f(t)
);

如果给定的单子值是Invalid,则不会评估给定的函数。在这个列表中,validCountryCode返回Invalid,因此validNumber永远不会被调用。因此,在单子版本中,我们永远不会有机会累积错误,因为任何错误都会导致后续函数被绕过。如果您想更清楚地理解差异,我们可以比较ApplyBind的签名:

Apply : Validation<(T  → R)>  → Validation<T>  → Validation<R>
Bind  : Validation<T>  → (T  → Validation<R>)  → Validation<R>

使用Apply,两个参数都是Validation类型;在调用Apply之前,Validation及其可能包含的错误已经独立评估。因为两个参数的错误都存在,所以在结果值中收集它们是有意义的。

使用Bind时,只有第一个参数是Validation类型。第二个参数是一个产生Validation的函数,但这个函数尚未被评估,所以如果第一个参数是InvalidBind的实现可以完全避免调用该函数。¹¹

因此,Apply是关于组合两个独立计算的高级值,而Bind是关于序列化产生高级值的计算。因此,单子流允许短路:如果在过程中某个操作失败,后续的操作将被跳过。

我认为Validation的情况表明,尽管函数模式和它们的定律看起来很严谨,但仍然有空间以适合特定应用特定需求的方式来设计高级类型。鉴于我的Validation实现和当前创建有效PhoneNumber的场景,你会使用单子流快速失败,但使用适用性流来收集错误。

总结来说,你已经在高级世界中看到了使用多参数函数的三种方式:好的、坏的、和丑陋的。嵌套调用Bind无疑是丑陋的,最好避免。另外两种方式哪一种好或坏取决于你的需求。如果你有一个具有一些期望行为的Apply实现,就像你在Validation中看到的那样,使用适用性流;否则,使用 LINQ 的单子流。

练习

  1. EitherExceptional实现Apply

  2. 实现EitherExceptional的查询模式。尝试在不查看任何示例的情况下写下SelectSelectMany的签名。对于实现,只需遵循类型——如果类型检查通过,那么它很可能是正确的!

  3. 想出一个场景,其中各种返回Either的操作通过Bind链式连接。 (如果你缺乏想法,可以使用第八章中的“最喜欢的菜肴”示例。)用 LINQ 表达式重写代码。

总结

  • 你可以使用Apply函数在高级世界中执行函数应用,例如Option的世界。

  • 多参数函数可以用Return提升到高级世界;然后你可以用Apply提供参数。

  • 可以定义Apply的类型被称为适用性。适用性比函子更强大,但比单子弱。

  • 因为单子更强大,你也可以使用嵌套调用Bind来在高级世界中执行函数应用。

  • LINQ 提供了一种轻量级的语法来处理单子,其可读性优于嵌套调用Bind

  • 要使用自定义类型与 LINQ 一起使用,你必须实现 LINQ 查询模式,特别是提供具有适当签名的SelectSelectMany的实现。

  • 对于几个 monads,Bind具有短路行为(在某些情况下不会应用给定的函数),但Apply没有(它不是给定一个函数,而是一个提升的值)。因此,你有时可以将期望的行为嵌入到 applicatives 中,例如在Validation的情况下收集验证错误。

  • FsCheck 是一个基于属性的测试框架。它允许你运行一个测试,该测试使用大量随机生成的输入,从而可以高度确信测试的断言对任何输入都成立。


¹ 在现实中,正确的ApplyReturn实现必须满足四个定律;这些定律本质上意味着恒等函数、函数组合和函数应用在应用世界中与在正常世界中一样工作。我在文本中提到的应用定律是这些定律的结果,并且在重构和实际应用方面比这四个基本定律更重要。在这里我不会详细讨论这四个定律,但如果你想要了解更多,可以查看 Haskell 中应用模块的文档,网址为mng.bz/AOBx。此外,你可以在代码示例 LaYumba.Functional.Tests/Option/ApplicativeLaws.cs 中查看说明应用定律的基于属性的测试。

² FsCheck 是用 F#编写的,并且可以免费使用(github.com/fscheck/FsCheck)。像为其他语言编写的许多类似框架一样,它是从 Haskell 的 QuickCheck 移植过来的。

³ 这也有将基于属性的测试与你的测试框架集成的效果:当你使用dotnet test运行测试时,所有基于属性的测试都会运行,以及常规的单元测试。也存在一个FsCheck.NUnit包,它公开了 NUnit 的Property属性。

⁴ 默认情况下,FsCheck 生成 100 个值,但你也可以自定义输入值的数量和范围。如果你开始认真使用基于属性的测试,能够精细调整生成值的参数变得相当重要。

⁵ 如第六章中“为什么 functor 不是一个接口?”的侧边栏所指出的,一些语言如 Haskell 允许你使用类型类来捕获这些模式,类型类类似于接口但更强大。C#的类型系统不支持这些泛型抽象,因此你无法在接口中以惯用方式捕获MapBind

⁶ 当然,在函数式语言中,你一开始就不会有null,所以你不会陷入这个困境。

⁷ LINQ 的设计者注意到,当查询中使用了多个from子句时,性能会迅速下降。

⁸ 这是因为 lambda 表达式可以用来表示Expression以及Func

⁹ 例如,Haskell 中的do块或 Scala 中的for推导式。

¹⁰ let将新计算的结果存储在元组中,与之前的结果一起。

(11) 当然,你可以提供一个 Bind 的实现,它不会执行任何这样的短路操作,而是始终执行绑定的函数并收集任何错误。这是可能的,但它不符合直觉,因为它打破了我们从类似类型如 OptionEither 预期的行为。

11 表示状态和变化

本章涵盖

  • 状态变化的陷阱

  • 无变化表示

  • 强制不可变性

  • 分离数据和逻辑

希腊哲学家赫拉克利特说,我们无法两次踏入同一条河流;河流不断变化,所以刚才还在那里的河流已经不再了。许多程序员会不同意,认为这是同一条河流,但其 状态 已经改变了。函数式程序员试图忠实于赫拉克利特的思考,并且会随着每一次观察创造一条新的河流。

大多数程序都是用来表示现实世界中的事物和过程的,因为世界不断变化,程序必须以某种方式表示这种变化。问题是 如何 表示变化。以命令式风格编写的商业应用程序在其核心具有状态变化:对象代表业务域中的实体,世界的变化通过改变这些对象的状态来建模。

我们将首先研究我们在使用变化时在程序中引入的弱点。然后我们将看到我们如何通过不使用变化来表示变化,以及更实际地,如何在 C# 中强制不可变性。最后,因为我们的程序的大部分数据都存储在数据结构中,我们将介绍函数数据结构背后的概念和技术,这些数据结构也是不可变的。

11.1 状态变化的陷阱

状态变化 是指在原地更新内存,与之相关的一个重要问题是,对共享可变状态的并发访问是不安全的。你已经在第一章和第三章中看到了由于并发更新导致信息丢失的例子;现在让我们看看一个更面向对象的场景。想象一个具有 Inventory 字段的 Product 类,表示库存中的单位数量:

public class Product
{
   public int Inventory { get; private set; }
   public void ReplenishInventory(int units) => Inventory += units;
   public void ProcessSale(int units) => Inventory -= units;
}

如果 Inventory 如此示例所示是可变的,并且你有并发线程更新其值,这可能导致 竞争条件,结果可能是不可预测的。想象一下,你有一个线程正在补充库存,而另一个线程同时处理一个销售,如图 11.1 所示,减少库存。如果两个线程同时读取值,并且销售线程有最后的更新,你最终会看到库存总体减少。

图片

图 11.1 并发更新导致的数据丢失。两个线程都导致 Inventory 值被并发更新,结果导致其中一个更新丢失。

不仅补充库存的更新已经丢失,而且第一个线程现在可能面临一个完全无效的状态:刚刚补充的产品库存为零。

如果你做过一些基本的并发编程,你可能正在想,“简单!你只需要使用lock语句将Inventory的更新包装在临界区中。”实际上,这个解决方案在这个简单情况下是有效的,但随着系统复杂性的增加,它可能成为一些难以解决的错误的原因。(销售不仅影响库存,还影响销售订单、公司资产负债表等等。)

如果在设置单个变量时可能会出现故障,那么想象一下当一个实体的更新涉及到更新多个字段时会发生什么。例如,想象一下当你更新库存时,你也会设置一个标志来指示产品库存是否不足,如下面的列表所示。

列表 11.1 非原子更新导致的暂时不一致性

class Product
{
   int inventory;

   public bool IsLowOnInventory { get; private set; }

   public int Inventory
   {
      get => inventory;
      private set
      {
         inventory = value;
                                      ❶
         IsLowOnInventory = inventory <= 5;
      }
   }
}

❶ 此时,从任何读取其属性的线程的角度来看,对象可能处于无效状态。

这段代码定义了一个不变量:当inventory为 5 或更少时,IsLowOnInventory必须为真。

在单线程设置中,前面的代码没有问题。但在多线程设置中,一个线程可能在另一个线程在Inventory已更新但IsLowOnInventory尚未更新的窗口期间读取该对象的状态。请注意,如果计算IsLowOnInventory的逻辑变得更加昂贵,这个窗口会变宽。在这个窗口期间,不变量可能会被破坏,因此第一个线程会看到对象似乎处于无效状态。这当然会非常罕见,并且几乎不可能重现。这也是竞态条件引起的错误如此难以诊断的部分原因。

事实上,竞态条件已知已导致软件行业中最引人注目的失败之一。如果你有一个具有并发性和状态变化的系统,那么你无法证明该系统没有竞态条件。¹换句话说,如果你想实现并发(鉴于今天对多核处理器和分布式计算的倾向,你几乎没有选择)并且对正确性有强保证,那么你不得不放弃状态变化。

缺乏安全的并发访问可能是共享可变状态的最大陷阱,但并非唯一。另一个问题是引入耦合的风险——系统不同部分之间的高度相互依赖。在图 11.1 中,Inventory封装的,这意味着它只能从类内部设置,根据面向对象理论,这应该给你一种舒适感。但是Product类中有多少方法可以设置库存值?有多少代码路径通向这些方法,从而最终影响Inventory的值?应用程序的哪些部分可以获取相同的Product实例并依赖于Inventory的值,以及如果你引入一个导致Inventory发生变化的新组件,会有多少部分受到影响?

对于一个非平凡的应用程序,很难完全回答这些问题。这就是为什么即使inventory是一个私有字段,只能通过私有设置器来设置,它也符合全局可变状态的定义;据我们所知,它可以通过封装类中的公共方法被程序的任何部分修改。因此,可变状态将读取或更新该状态的各个组件的行为耦合在一起,使得推理整个系统的行为变得困难。

最后,共享可变状态意味着纯度丧失。正如第三章所述,修改全局状态(记住,这指的是所有不属于函数本地的状态,包括私有变量)构成了副作用。如果你通过修改系统中的对象来表示世界的变化,你将失去函数纯度的优势。因此,函数式范式总体上不鼓励状态修改。

注意:在本章中,你将学习如何处理不可变数据对象。这是一个重要的技术,但请记住,它并不总是足以表示随时间变化的事物。不可变数据对象可以表示实体在任何给定时间点的状态,有点像电影中的一个镜头,但要表示实体本身,要获得完整的动态画面,你需要进一步抽象,将这些连续状态联系起来。我们将在第十三章、第十五章、第十八章和第十九章中讨论实现这一目标的技巧。

局部修改是可以接受的

并非所有的状态修改都是同样糟糕的。修改局部状态(仅在函数作用域内可见的状态)是不优雅的,但却是良性的。例如,想象以下函数:

int Sum(int[] ints)
{
   var result = 0;
   foreach (int i in ints) result += i;
   return result;
}

虽然我们在更新result,但这从函数的作用域之外是不可见的。因此,这个Sum的实现实际上是一个纯函数:它对调用函数来说没有可观察的副作用。

自然地,这段代码也是低级的。你通常可以使用内置函数如SumAggregate等来实现你想要的功能。在实践中,你很少会找到一个合理的理由来修改局部变量。

11.2 理解状态、身份和变化

让我们更仔细地看看变化和修改。² 我所说的变化是指现实世界中的变化,例如当 50 个单位的股票可供出售时。修改意味着数据在原地更新;正如你在Product类中看到的,当Inventory值被更新时,Inventory的旧值就会丢失。

在函数式编程(FP)中,我们不通过修改来表示变化:值不会在原地更新。相反,我们创建新的实例来表示具有所需变化的数据,如图 11.2 所示。当前库存水平为 53 并不抹去它之前是 3 的事实。

图 11.2 在函数式编程中,可以通过创建数据的新版本来表示变化。

在函数式编程(FP)中,我们处理的是不可变值:一旦一个值被初始化,它就永远不会被更新。

理解不可变对象

如果你一直用修改来表示变化,当对象的属性更新时创建对象的副本可能会显得反直觉。例如,考虑以下代码:

record Product(int Inventory);

static Product ReplenishInventory(Guid id, int units)
{
   Product original = RetrieveProduct(id);
   Product updated = new Product(original.Inventory + units);
   return updated;
}

在这段代码中,Product是不可变的,所以我们通过创建一个新的Product实例来表示新库存的可用性。你可能会觉得这样做有些尴尬,因为现在内存中有两个相互竞争的Product实例,其中只有一个准确地代表了现实世界的产品。

注意,在这个例子中,返回的是updated实例,而original实例超出作用域,因此将被垃圾回收。在许多情况下,过时的实例只是被“遗忘”而不是被覆盖。

但有些情况下,你可能确实希望一个实体的多个视图共存。例如,假设你的雇主为超过 40 美元的订单提供免费运输。你可能希望在用户删除商品之前和之后查看订单,以警告他们是否刚刚失去了免费运输的权利。或者,更新可能是一个内存事务的一部分,如果事务失败,你可能希望将实体回滚到之前的状态。

只有最新或当前的数据视图是有价值的这种观点只是源于主流实践的一种偏见。当你放弃这种观点时,许多新的可能性就会出现。

要细化或重新定义你对变化和修改的直觉,区分变化的事物和不变化的事物是有用的。

11.2.1 一些东西永远不会改变

有些东西我们认为是固有的不可变的。例如,你的年龄可能会从 30 岁变成 31 岁,但数字 30 仍然是数字 30,31 仍然是 31。

这在基类库(BCL)中得到了体现,即所有原始类型都是不可变的。那么更复杂类型呢?日期是一个很好的例子。3 月 3 日仍然是 3 月 3 日,即使你可能将你的日历中的预约从 3 月 3 日改为 3 月 4 日。这也在 BCL 中得到了反映,即用于表示日期的类型,如DateTime是不可变的。³ 你可以通过在 REPL 中输入以下内容来自行验证(如果你没有.NET 6,请使用DateTime而不是DateOnly):

var momsBirthday = new DateOnly(1966, 12, 13);
var johnsBirthday = momsBirthday;               ❶

// some time goes by...

johnsBirthday = johnsBirthday.AddDays(1);       ❷

johnsBirthday // => 14/12/1966
momsBirthday  // => 13/12/1966                  ❸

❶ 约翰和母亲的生日相同。

❷ 你意识到约翰的生日实际上晚了一天。

❸ 母亲的生日没有受到影响。

在前面的例子中,我们首先说母亲和约翰有相同的生日,所以我们给momsBirthdayjohnsBirthday赋相同的值。当我们使用AddDays创建一个较晚的日期并将其赋值给johnsBirthday时,这不会影响momsBirthday。在这个例子中,我们双重保护了日期不被修改:

  • 因为System.DateOnly是一个结构体,它在赋值时会进行复制,所以momsBirthdayjohnsBirthday是不同的实例。

  • 即使DateOnly是一个类,并且momsBirthdayjohnsBirthday指向同一个实例,行为仍然相同,因为AddDays创建了一个新的实例,而不会影响底层实例。

相反,如果DateOnly是一个可变类,并且AddDays变异了其实例的天数,那么momsBirthday的值将因为更新johnsBirthday而更新——或者更准确地说,作为更新johnsBirthday的副作用。(想象一下向妈妈解释这是你迟到生日祝福的原因。)

.NET 框架中的不可变类型

这里是.NET 基类库中最常用的不可变类型:

  • DateTimeTimeSpanDateTimeOffsetDateOnlyTimeOnly

  • Delegate

  • Guid

  • Nullable<T>

  • String

  • Tuple<T1>Tuple<T1, T2>、...

  • Uri

  • Version

此外,所有原始类型都是不可变的。

现在让我们定义一个自定义的不可变类型。比如说我们这样表示一个Circle

readonly record struct Circle(Point Center, double Radius);

你可能会同意,一个圆应该永远增长或缩小是没有意义的,因为它是一个完全抽象的几何实体。前面的实现通过将结构体声明为readonly来反映这一点,这使得它是不可变的。这意味着将无法更新RadiusCenter的值;一旦创建,圆的状态将永远无法改变。⁴

结构体应该是不可变的

注意,我已经将Circle定义为值类型。因为值类型在函数之间传递时会被复制,所以结构体应该是不可变的。这不是由编译器强制执行的,所以你可以创建一个可变结构体。事实上,如果你声明一个没有readonly修饰符的record结构体,你将得到一个可变结构体。

与类不同,你对可变结构体所做的任何更改都会向下传播,但不会向上调用栈传播,这可能导致意外的行为。因此,我建议你始终坚持使用不可变结构体,唯一的例外是有证明的性能需求。

如果你有一个圆,并且想要一个大小是原来两倍的圆,你可以定义函数来根据现有的圆创建一个新的圆。以下是一个例子:

static Circle Scale(this Circle c, double factor)
   => c with { Radius = c.Radius * factor };

好的,到目前为止,我们还没有使用过变异,这些例子相当直观。数字、日期和几何实体有什么共同之处?它们的值捕捉了它们的身份:它们是值对象。如果你改变日期的值……嗯,它标识了一个不同的日期!当我们考虑值和身份不同的事物时,问题就开始了。我们将在下一节探讨这个问题。

11.2.2 不使用变异表示变化

许多现实世界的实体会随时间变化:你的银行账户、你的日历、你的联系人列表——所有这些事物都有随时间变化的状态。图 11.3 说明了这个概念。

图 11.3 随时间变化状态的实体

对于这样的实体,它们的身份不是由它们的值来确定的,因为它们的身份保持不变,而它们的值随着时间的推移而变化。相反,它们的身份与不同时间点的不同状态相关联。你的年龄可能会改变,或者你的薪水,但你的身份不会。为了表示这样的实体,程序必须不仅模拟一个实体的状态(这是容易的部分),还要模拟从一个状态到另一个状态的转换,以及通常将身份与实体的当前状态关联起来。

我们讨论了一些为什么突变提供的是一个不完美的状态转换机制的原因。在函数式编程(FP)中,状态不会被突变;它们是快照,就像电影帧一样,代表着一个不断发展的现实,但本身是静态的。

11.3 使用记录来捕获领域实体的状态

为了说明 C# 中的不可变数据对象,让我们从 AccountState 开始工作,我们将使用它来表示 BOC 应用程序中银行账户的状态。以下列表显示了我们的模型。

列表 11.2 银行账户状态的简单模型

public enum AccountStatus
{ Requested, Active, Frozen, Dormant, Closed }

public record AccountState
(
   CurrencyCode Currency,
   AccountStatus Status = AccountStatus.Requested,
   decimal AllowedOverdraft = 0m,
   IEnumerable<Transaction> TransactionHistory = null
);

public record Transaction
(
   decimal Amount,
   string Description,
   DateTime Date
);

为了简洁起见,我省略了 CurrencyCode 的定义,它只是像我们在 9.4.1 节中看到的 ConnectionStringSqlTemplate 类型一样包装一个字符串值,如 EUR 或 USD。

因为 AccountState 有几个字段,并且并不是所有字段在所有时间都有意义,所以我为除了货币字段以外的所有字段提供了合理的默认值。要创建一个 AccountState,你实际上只需要它的货币:

var newAccount = new AccountState(Currency: "EUR");

这将创建一个默认状态为 RequestedAccountState。当你准备好激活账户时,你可以通过使用 with 表达式来完成:

public static AccountState Activate(this AccountState original)
   => original with { Status = AccountStatus.Active };

这将创建一个新的 AccountState 实例,包含原始对象的所有值,除了 Status,它被设置为新的值。原始对象仍然完好无损:

var original = new AccountState(Currency: "EUR");
var activated = original.Activate();

original.Status    // Requested
original.Currency  // "EUR"

activated.Status   // Active
activated.Currency // "EUR"

注意,你可以使用设置多个属性的 with 表达式:

public static AccountState RedFlag(this AccountState original)
   => original with
   {
      Status = AccountStatus.Frozen,
      AllowedOverdraft = 0m
   };

使用不可变对象的影响

与不可变对象一起工作意味着每次你的数据需要更改时,你都会创建一个新的、修改后的实例,而不是在原地突变对象。“但这不是非常低效吗?”你可能正在想。

确实,创建修改后的副本以及创建最终需要垃圾回收的更多对象确实会有轻微的性能损失。这也是为什么在缺乏自动内存管理的语言中,函数式编程(FP)不实用的原因。

但是性能影响比你想象的要小,因为修改后的实例是原始对象的浅拷贝。也就是说,原始对象引用的对象没有被复制;只有引用被复制。除了正在更新的字段外,新对象是原始对象的位复制。

图片

例如,当您使用更新的状态创建新的AccountState时,交易列表不会被复制。相反,新对象引用原始的交易列表。(这也应该是不可变的,因此不同的实例共享它是可以的。)

with 表达式是快速的。当然,就地更新更快,因此在性能和安全之间有一个权衡。创建浅拷贝的性能惩罚在大多数情况下可能可以忽略不计。我的建议是首先考虑安全,然后在需要时进行优化。

接下来,让我们看看我们如何进一步改进这个模型。

11.3.1 对记录初始化的细粒度控制

再看看提出的AccountState定义(在下面的片段中复制),看看您是否能发现任何潜在的问题:

public record AccountState
(
   CurrencyCode Currency,
   AccountStatus Status = AccountStatus.Requested,
   decimal AllowedOverdraft = 0m,
   IEnumerable<Transaction> TransactionHistory = null
);

实际上这里有几个问题。一个立即引起注意的事情是交易列表的默认值是null。提供默认值的原因是,当创建新账户时,它将没有之前的交易,因此将其作为可选参数是有意义的。但我们也不希望null可能引起NullReferenceException。其次,这个记录定义允许您通过更改现有账户的货币来创建账户,如下所示:

var usdAccount = newAccount with { Currency = "USD" };

这没有意义。尽管账户的状态可能从“请求”变为“活动”,一旦以特定货币开设了账户,这种状态就不应该改变。我们希望我们的模型能够表示这一点。让我们看看我们如何解决这两个问题,从后者开始。

只读属性与初始化属性

当您使用位置记录时,编译器会为声明的每个参数创建一个只读的自动属性。这是一个具有getinit方法的属性;后者是一个 setter,只能在记录实例初始化时调用。如果我们显式地将Currency属性声明为公共只读自动属性,就像编译器会生成的那样,它看起来会是这样:

public record AccountState
(
   CurrencyCode Currency,
   AccountStatus Status = AccountStatus.Requested,
   decimal AllowedOverdraft = 0m,
   IEnumerable<Transaction> TransactionHistory = null
)
{
   public CurrencyCode Currency { get; init; } = Currency;
}

下面的列表将这一点分解,以便您可以看到每个位的意义。

列表 11.3 在位置记录定义中显式定义属性

public record AccountState(CurrencyCode Currency /*...*/)
{

   public CurrencyCode Currency     ❶
   {
      get;                          ❷
      init;                         ❸
   }
   =                                ❹
   Currency;                        ❺

}

❶ 在这里,Currency指的是属性的名称。

❷ 获取属性的值

❸ 仅在记录初始化时设置值

❹ 介绍了属性初始化器

❺ 在这里,Currency指的是构造函数参数;这意味着在初始化时,Currency属性被设置为Currency构造函数参数提供的值。

当你使用 with 表达式创建记录的修改版本时,运行时会创建原始记录的副本,然后调用任何你提供新值的属性的 init 方法。现在,显式编写属性允许我们覆盖编译器的默认设置;在这种情况下,我们想要通过删除 init 方法将 Currency 属性定义为只读自动属性:

public CurrencyCode Currency { get; } = Currency;

然后,尝试创建具有不同货币的账户的修改版本的 with 表达式将无法编译,因为没有为副本设置 Currencyinit 方法。

不可变对象永远不会改变,因此不可变对象的属性必须是只读或只初始化:

  • 如果在创建副本时给属性赋予一个更新值是有意义的,则使用只初始化属性。

  • 否则使用只读属性。

正如你所见,编译器生成的位置记录属性是只初始化的,所以如果你想它们是只读的,你需要显式声明它们。

将可选列表初始化为空

现在,让我们回到 TransactionHistory 的问题,它在将 AccountState 的构造函数参数未传递值时初始化为 null。我们真正想要的是有一个空列表作为默认值,因此理想情况下我们想要编写

public record AccountState
(
   // ...
   IEnumerable<Transaction> TransactionHistory
      = Enumerable.Empty<Transaction>()
);

但这无法编译,因为可选参数的默认值必须是编译时常量。最简洁的解决方案是显式定义 Transaction-History 属性并使用属性初始化器,如下所示。

列表 11.4 使用空列表初始化记录

public record AccountState
(
   CurrencyCode Currency,
   AccountStatus Status = AccountStatus.Requested,
   decimal AllowedOverdraft = 0m,
   IEnumerable<Transaction> TransactionHistory = null
)
{
   public IEnumerable<Transaction>
      TransactionHistory { get; init; }
      = TransactionHistory                      ❶
         ?? Enumerable.Empty<Transaction>();    ❷
 }

❶ 指的是构造函数参数

如果构造函数被给定 null,则使用一个空列表

虽然方法参数的默认值必须是编译时常量,但属性初始化器没有这个限制。因此,我们可以在属性初始化器中包含一些逻辑。前面的代码用显式声明替换了自动生成的 TransactionHistory 属性;这本质上是在说,“当创建一个新的 AccountState 时,使用可选 TransactionHistory 构造函数参数提供的值来填充 TransactionHistory 属性,但如果它是 null,则使用空列表。”

有其他可能的方案:你可以显式定义一个构造函数并在其中包含此逻辑,或者定义一个带有后置字段的完整属性并在属性的 init 方法中包含此逻辑。

11.3.2 一直不可变

还有一个小调整。为了使一个对象不可变,它的所有成员都必须是不可变的。如果你查看 AccountState 的定义,会发现一个陷阱。TransactionHistory 被定义为 IEnumerable<Transaction>,虽然 Transaction 是不可变的,但有许多可变列表实现了 IEnumerable。例如,考虑以下代码:

var mutableList = new List<Transaction>();

var account = new AccountState
(
   Currency: "EUR",
   TransactionHistory: mutableList
);

account.TransactionHistory.Count() // => 0

mutableList.Add(new(-1000, "Create trouble", DateTime.Now));

account.TransactionHistory.Count() // => 1

此代码创建了一个具有可变列表的 AccountState;然后它保留对该列表的引用,以便列表仍然可以被修改。因此,我们无法说我们对 AccountState 的定义真正不可变。

有两种可能的解决方案。你可以更改类型定义,将 TransactionHistory 声明为 ImmutableList 而不是 IEnumerable。或者,你可以将属性重写为以下列表所示。

列表 11.5 即使给定可变列表,使记录不可变

using System.Collections.Immutable;

public record AccountState // ...
{
   public CurrencyCode Currency { get; } = Currency;

   public IEnumerable<Transaction> TransactionHistory { get; init; }
      = ImmutableList.CreateRange
         (TransactionHistory ?? Enumerable.Empty<Transaction>());
}

此代码从给定的 IEnumerable 创建一个 ImmutableList,从而使 AccountState 真正不可变。

提示:如果给定一个 ImmutableListCreateRange 将直接返回它,这样你就不必使用这种方法承担任何开销。否则,它将创建一个防御性副本,确保对给定列表的任何后续修改都不会影响 AccountState

如果一个账户有一个不可变的交易列表,你如何向列表中添加交易?你不需要。你创建一个新的列表,其中包含新的交易以及所有现有的交易,这将成为新的 AccountState 的一部分。以下列表显示了向不可变对象添加子对象涉及创建新的父对象。

列表 11.6 向不可变对象添加子对象

using LaYumba.Functional;                              ❶

public static AccountState Add
   (this AccountState account, Transaction trans)
   => account with
   {
      TransactionHistory
         = account.TransactionHistory.Prepend(trans)   ❷
   };

❶ 将 Prepend 作为 IEnumerable 的扩展方法

❷ 一个新的 IEnumerable,包括现有值和正在添加的值

注意,在这个特定情况下,我们是在列表中 * prepend* 交易。这是特定领域的;在大多数情况下,你感兴趣的是最新的交易,因此将最新的交易保持在列表的前端是高效的。

每次添加或删除单个元素时都复制列表可能听起来非常低效,但这并不一定是这种情况。我们将在第十二章中讨论原因。

使用 C# 记录的障碍

在本节中,你看到了我们如何有效地使用记录来定义自定义不可变数据类型。然而,记录是 C# 中的新特性,所以当你尝试采用记录时可能会遇到一些障碍。

特别是,如果你使用对象关系映射器(包括 Entity Framework),它使用变更跟踪来查看哪些对象已更改并需要在数据库中更新,或者依赖于空构造函数和可设置属性来填充对象,你可能无法使用记录。另一个障碍可能是序列化。虽然 System.Text.Json 支持将记录序列化为 JSON 并从 JSON 反序列化,但其他序列化程序可能还不支持记录。在这种情况下,考虑使用约定不变性(在第九章附录中讨论)。我预计随着时间的推移,记录将变得流行,并最终被所有主要库支持。

11.4 分离数据和逻辑

函数式编程(FP)通过将数据与逻辑分离来减少应用程序中的耦合,从而使其更简单、更容易维护。这是我们前面章节中一直遵循的方法:

  • 我们在列表 11.2 中定义的AccountState只包含数据。

  • 如激活账户或添加交易等业务逻辑,是通过函数来模拟的。

我们可以将所有这些函数组合成一个静态的Account类,包括创建和更新AccountState新版本的逻辑,如下所示。

列表 11.7 包含特定业务逻辑的静态类

public static class Account
{
   public static AccountState Create(CurrencyCode ccy) => new(ccy);

   public static AccountState Activate(this AccountState account)
      => account with { Status = AccountStatus.Active };

   public static AccountState Add
      (this AccountState account, Transaction trans)
      => account with
      {
         TransactionHistory
            = account.TransactionHistory.Prepend(trans)
      };
}

Account是一个静态类,用于表示账户的变化,包括一个工厂函数。而AccountState表示在特定时间点的账户状态,Account中的函数则表示状态转换。这如图 11.4 所示。

图 11.4 表示实体状态和与逻辑相关的关注点是分开的。在这个例子中,AccountState捕获表示账户的数据,而Account是一组模拟账户变化的函数。

当我们在高级别编写逻辑时,我们只依赖于Account:例如,

var onDayOne = Account.Create("USD");
var onDayTwo = Account.Activate(onDayOne);

这意味着函数式编程(FP)允许你将表示状态和表示状态转换视为不同的关注点。此外,业务逻辑与数据(Account依赖于较低级别的AccountState)相比处于更高层次。

命名约定

如果你遵循将逻辑与数据分离的方法,你必须选择一个命名约定来区分数据对象和包含逻辑的类。在这里,我使用了包含逻辑的类名(Account)作为类名;这是因为我喜欢在引用无参数函数时保持最佳的可读性:例如,Account.Activate

Option<AccountState> Activate(Guid id)
   => GetAccount(id).Map(Account.Activate);

另一方面,更冗长的AccountState通常可以通过使用var来省略。当然,其他命名约定也是可能的。选择最符合逻辑的,并在你的应用程序中保持一致性。

Account是一个类,因为 C#语法要求它(除了顶层语句外,你无法在类外声明方法或委托),但从概念上讲,它只是相关函数的集合。这可以被称为模块。这些函数不依赖于封装类中的任何状态,因此你可以将它们视为独立的函数,并将类名视为命名空间的一部分。

这种将数据(无生命的)和函数(执行数据转换的函数)之间的分离是函数式编程(FP)的典型特征。这与面向对象编程(OOP)形成鲜明对比,在 OOP 中,对象既包含数据也包含修改这些数据的方法。

将数据与逻辑分离会导致系统更简单、耦合更少,因此更容易理解和维护。在编程分布式系统时,这也是一个合理的选择,因为数据结构需要易于序列化和在应用程序之间传递,而逻辑则位于这些应用程序中。

数据导向编程(DOP)

本章中我讨论的几个想法与 DOP 相关,DOP 是一种倡导通过将逻辑与数据分离来降低应用程序复杂性的范式。FP 和 DOP 是不同的,但它们之间有一些重叠。DOP 的原则是

  1. 将逻辑与数据实体分离。

  2. 使用不可变数据。

  3. 使用泛型结构来表示数据实体。

函数式编程(FP)也提倡使用不可变数据,使用不可变数据和纯函数自然地导致将逻辑与数据实体分离,正如我在本节中演示的那样。FP 和 DOP 之间肯定有一些重叠。

至于第三个原则,DOP 提倡使用泛型结构来表示数据;例如,你不会定义一个具有Currency属性的AccountState类型,而是会使用一个字典,将账户货币的值映射到Currency键,以及其他字段的类似映射.^a 结果表明,你可以仅使用列表、字典和原始数据类型来表示任何形状的数据。

使用泛型结构来表示数据的主要好处是你可以以相应通用的方式处理数据;例如,给定任何形状的两个数据快照,你可以比较它们并查看哪些位发生了变化。你可以合并更改集并查看并发更新是否导致冲突。这非常强大。

显而易见的缺点是你会失去类型安全,因此对于习惯于在静态类型语言(如 C#)中工作的程序员来说,这有点难以推销。

如果你想了解更多关于 DOP 的信息,了解如何通过分离逻辑和数据简化生活,以及为什么使用泛型结构来表示数据实体是有价值的,请参阅 Yehonathan Sharvit 的《数据导向编程》(Manning, 2021)。

^a 如果你想在 C#中采用这种方法,你可能会使用dynamic类型来对底层字典进行糖包装。这允许你使用点符号访问字段值。

摘要

  • FP 反对状态突变,防止与状态突变相关的一些缺点,如线程不安全、耦合和不纯:

    • 不变的事物用不可变对象来表示。

    • 变化的事物也用不可变对象来表示;这些不可变快照代表了实体在某个特定时刻的状态。变化通过创建一个新的快照并包含所需的变化来表示。

  • 使用记录来定义自定义不可变数据类型。

  • 要使一个类型不可变,它所有的子类型,包括列表和其他数据结构,也必须是不可变的。

  • 通过将数据与逻辑分离,你可以简化你的应用程序并促进松耦合:

    • 使用数据对象(通常是记录)来封装数据。

    • 使用函数(作为无状态静态类中的静态方法实现)来表示业务逻辑。


¹ 前面的例子提到了多线程,但如果并发源是异步或并行(这些术语在第三章的“并发意义和类型”侧边栏中进行了描述),同样的问题也可能出现。

² 我在本节中讨论的基本技术是函数式编程(FP)中普遍存在的,但我用来解释这些技术的概念和隐喻主要受到了 Clojure 编程语言创造者 Rich Hickey 的启发。

.NET 的创造者从 Java 中汲取了灵感,但在这个案例中,他们还从 Java 的错误中吸取了教训(Java 直到 Java 8 才有可变的日期)。

⁴ 实际上,你仍然可以通过反射来修改只读变量。但将字段设置为只读是一个明确的信号,表明该字段不应该被修改。

12 函数式数据结构简介

本章涵盖

  • 函数式数据结构

  • 链表

  • 二叉树

在第十一章中,你看到了如何创建不可变对象。特别是,第 11.1 节展示了在涉及并发时状态变更的陷阱。当处理集合时,这些陷阱变得更加明显。因为处理大型集合比更新单个对象需要更长的时间,所以发生竞争条件的机会更大(我们在第 1.1.3 节中看到了这个例子)。

现在你已经了解了不可变对象,让我们来看看不可变数据结构设计背后的某些原则。请注意,文献中使用的术语函数式数据结构和不可变数据结构是可以互换的。¹ 你会发现原则是相同的:毕竟,对象只是临时的数据结构。

如果你只承诺使用不可变数据,所有数据结构也应该是不可变的。例如,你不应该通过改变其结构来向列表中添加元素,而应该创建一个新的列表,包含所需的变化。

这可能最初会让你皱起眉头:“为了向列表中添加一个项目,我需要将所有现有元素复制到一个新的列表中,再加上额外的项目?这是多么低效啊?”

为了让你了解这并不一定是不高效的,让我们看看一些简单的函数式数据结构。你会发现向集合中添加新元素确实会产生一个新的集合,但这并不涉及复制原始集合中的每个项目。

注意:本章中展示的实现是原始的。它们有助于理解基本概念,但不适用于生产环境。对于实际应用,请使用经过验证的库,如System.Collections.Immutable

12.1 经典的函数式链表

我们将从经典的函数式链表开始。虽然表面上很简单,但这通常是大多数函数式语言核心库中的基本列表。我们可以用以下方式符号化地描述它:

List<T> = Empty | Cons(T, List<T>)

换句话说,一个TList可以是以下两种情况之一:

  • Empty——表示空列表的特殊值

  • Cons——由两个值cons-tructed 的非空列表:

    • 一个单独的T,称为,表示列表中的第一个元素

    • 另一个名为T列表,表示所有其他元素

尾可以是EmptyCons等。因此,List是一个递归类型的例子:一个用自身定义的类型。这就是我们如何只用两种情况就能应对任何长度的列表。例如,包含["a", "b", "c"]的列表的结构如下:

Cons("a", Cons("b", Cons("c", Empty)))

图 12.1 包含值["a", "b", "c"]的链表

它可以用图 12.1 中的图形表示,其中每个Cons用一个包含值(头)和指向列表其余部分的指针(尾)的框表示。

让我们看看我们如何在 C# 中实现这一点。这些实现包含在源存储库中的 LaYumba.Functional.Data 项目中。以下是我用来模拟列表的类型:

namespace LaYumba.Functional.Data.LinkedList;

public abstract record List<T>;
internal sealed record Empty<T> : List<T>;
internal sealed record Cons<T>(T Head, List<T> Tail) : List<T>;

注意,只有 List 类型是公开的。为了与 List 交互,我定义了一个 Match 方法,它提供了用于模式匹配的优美语法:

public static R Match<T, R>
(
   this List<T> list,
   Func<R> Empty,
   Func<T, List<T>, R> Cons
)
=> list switch
{
   Empty<T> => Empty(),
   Cons<T>(var t, var ts) => Cons(t, ts),
   _ => throw new ArgumentException("List can only be Empty or Cons")
};

这与我在第 5.3 节中展示的 Option 理想化实现非常相似:Empty 没有成员(就像 None 一样),而 Cons 存储列表的元素。Match 方法允许你处理这两种情况,从而减少了 switch 表达式的语法噪音。

结果表明,你可以用 Match 来定义所有常用的列表操作。例如,如果你想知道列表的长度(如下面的列表所示),你会使用 Match——空列表显然长度为 0,而非空列表的长度是其尾部长度加 1。

列表 12.1 计算列表的长度

public static int Length<T>(this List<T> list)
   => list.Match
   (
      () => 0,                        ❶
      (_, tail) => 1 + tail.Length()  ❷
    );

❶ 给 Match 的第一个函数处理空列表的情况。

❷ 如果列表不为空,第二个函数将获得列表的头部和尾部。

注意在第一个函数给 Match 的函数中,空括号图形上暗示了一个空列表。在第二个函数中,参数包括 Cons 的头部和尾部。在大多数情况下,我们在这里会处理头部,然后依赖于列表的递归定义来递归处理尾部。

最后,我提供了一些函数来创建空列表和填充列表。使用 new Cons("a", new Cons("b", ...) 明确创建整个结构将非常繁琐,所以我定义了一些用于初始化空列表或填充列表的函数。这些函数如下所示。

列表 12.2 初始化列表的函数

public static class LinkedList
{
   public static List<T> List<T>()                 ❶
      => new Empty<T>();                           ❶

   public static List<T> List<T>(T h, List<T> t)   ❷
      => new Cons<T>(h, t);                        ❷

   public static List<T> List<T>(params T[] items) ❸
      => items.Reverse().Aggregate(List<T>()       ❸
         , (tail, head) => List(head, tail));      ❸
}

❶ 创建一个空列表

❷ 从头部和尾部创建非空列表

❸ 用于创建具有几个硬编码元素的列表的便利方法

前两个函数只是分别调用 EmptyCons 的构造函数。列表中的下一个函数是一个便利的列表初始化器。params 关键字已经将所有参数收集到一个数组中,所以我们只需要将数组转换成 EmptyCons 的合适组合。这是通过 Aggregate 实现的,使用 Empty 作为累加器,并在减少函数中创建一个 Cons。因为 List 将项目添加到列表的开头,所以我们必须首先反转参数列表。

现在你已经看到了所有构建块,让我们在 REPL 中玩一玩 List。你需要导入 LaYumba.Functional.Data 集合:

#r "functional-csharp-code-2\LaYumba.Functional\bin\Debug\net6.0\
➥ LaYumba.Functional.Data.dll"

这里有一些示例,说明你如何在 REPL 中创建列表:

using LaYumba.Functional.Data.LinkedList;
using static LaYumba.Functional.Data.LinkedList.LinkedList;

var empty = List<string>();
// => []

var letters = List("a", "b");
// => [a, b]

var taxi = List("c", letters);
// => [c, a, b]

这段代码演示了如何创建一个列表,无论是空的还是预先填充的,以及如何通过向现有列表添加单个项目来创建一个 Cons

12.1.1 常见列表操作

现在我们来看看我们如何使用这个列表执行一些常见的操作,就像我们习惯于使用 IEnumerable 一样。例如,这是 Map

public static List<R> Map<T, R>
(
   this List<T> list,
   Func<T, R> f
)
=> list.Match
(
   () => List<R>(),
   (t, ts) => List(f(t), ts.Map(f))
);

Map接受一个列表和一个要映射到列表上的函数。然后它使用模式匹配。如果列表为空,它返回一个空列表;否则,它将函数应用于头部并递归地将函数映射到尾部,然后返回这两个的Cons

在这里,你可以看到一个常见的命名约定;当一个Cons被解构时,它的元素通常被称为t(单数,表示头部)和ts(复数,表示尾部),因为它们都是类型T。(在不需要命名泛型类型的语言中,你会看到xxs。)

如果我们有一个整数列表并且想要求和,我们可以按照同样的方式实现:

public static int Sum(this List<int> list)
   => list.Match
   (
      () => 0,
      (head, tail) => head + tail.Sum()
   );

如你在 9.6 节所知,SumAggregate的一个特例。让我们看看我们如何为List实现更通用的Aggregate

public static Acc Aggregate<T, Acc>
(
   this List<T> list,
   Acc acc,
   Func<Acc, T, Acc> f
)
=> list.Match
(
   () => acc,
   (t, ts) => Aggregate(ts, f(acc, t), f)
);

再次,我们进行模式匹配,并在Cons情况下,将归约函数f应用于累加器和头部。然后我们使用新的累加器和列表的尾部递归调用Aggregate

警告 这里展示的实现不是栈安全的。如果列表足够长,它们将导致StackOverflowException

现在我们已经了解了如何处理链表,让我们看看如何进行修改列表的操作。

12.1.2 修改不可变列表

假设我们想要向现有的列表中添加一个项目(通过这种方式,我自然是指获得一个包含额外项目的新的列表)。对于单链表,自然的方法是在前面添加项目:

public static List<T> Add<T>(this List<T> list, T value)
   => List(value, list);

给定一个现有的列表和一个新值,我们构建一个新的列表,其头部是一个包含新值和指向原始列表头部指针的列表节点。就是这样!不需要复制所有元素,因此我们可以以常数时间添加一个元素,只创建一个新对象。以下是我们不可变链表添加元素的示例:

var fruit = List("pineapple", "banana");
// => ["pineapple", "banana"]

var tropicalMix = fruit.Add("kiwi");
// => ["kiwi", "pineapple", "banana"]

var yellowFruit = fruit.Add("lemon");
// => ["lemon", "pineapple", "banana"]

fruit列表以两个项目初始化。然后我们添加第三个水果以获得一个新的列表tropicalMix。因为列表是不可变的,我们的原始fruit列表没有改变,仍然包含两个项目。这很明显,因为我们可以用它来创建一个新的、只包含黄色水果的列表的新版本。

图 12.2 展示了前述代码中创建的对象的图形表示,并显示在创建带有新项目的新的列表时,原始的fruit列表没有被修改(也不需要复制其元素)。

图 12.2 向列表添加项目不会影响原始列表。

想想这在解耦方面的意义:当你有一个不可变列表(以及更一般地,不可变对象)时,你可以暴露它而无需担心其他组件会对数据进行什么操作。他们根本无法对数据进行任何操作!

那么移除一个项目呢?单链表倾向于很好地处理第一个项目,所以我们将移除第一个项目(头部)并返回剩余的列表(尾部):

public static List<T> Tail<T>(this List<T> list)
   => list.Match
   (
      () => throw new IndexOutOfRangeException(),
      (_, tail) => tail
   );

再次,我们可以在常数时间内从列表中移除第一个元素,而不改变原始列表。(注意,这是非常少数几个可以合理抛出异常的地方之一,因为在对空列表调用Tail是开发者的错误。如果有可能列表为空,正确的实现应该使用Match而不是调用Tail。)

你可能会发现这些示例相当有限,因为我们只与列表的第一个元素进行了交互。但在实践中,这可以用来覆盖相当多的用例。例如,如果你需要一个栈,这是一个完美的起点。常见的操作如MapWhere对于长度为n的列表来说,时间复杂度都是O(n),就像任何其他列表一样。

你可以定义函数在索引m处插入或删除一个元素,这些操作的时间复杂度为O(m),因为它们需要遍历m个元素并创建m个新的Cons对象。如果你经常需要从长列表的末尾添加或删除元素(例如,如果你需要实现一个队列),你会使用不同的数据结构——你习惯的所有数据结构都有不可变的对立面。

12.1.3 解构任何IEnumerable

注意我们如何能够定义许多有用的操作在我们的简单链表类型中,这些操作都是基于模式匹配的。这是因为通常我们希望空列表的行为与非空列表不同。注意非空情况将列表解构为其头部和尾部。

可以定义具有相同语义的Match方法,使其适用于任何IEnumerable,可以定义如下:

public static Option<T> Head<T>(this IEnumerable<T> list)    ❶
{
   var enumerator = list.GetEnumerator();
   return enumerator.MoveNext()
      ? Some(enumerator.Current)
      : None;
}

public static R Match<T, R>
(
   this IEnumerable<T> list,
   Func<R> Empty,
   Func<T, IEnumerable<T>, R> Otherwise
)
=> list.Head().Match
(
   None: () => Empty(),                                      ❷
   Some: (head) => Otherwise(head, list.Skip(1))             ❸
);

❶ 如果列表为空,Head返回None;否则,列表的头部被包裹在一个Some中。

❷ 如果列表为空,调用Empty处理程序

❸ 如果列表不为空,调用Otherwise处理程序,并传递列表的头部和尾部

这个Match的实现包含在LaYumba.Functional中。你将在第十三章中看到它在实践中是如何有用的。

12.2 二叉树

树也是常见的数据结构。除了链表之外,大多数列表实现都使用树作为其底层表示,因为这允许某些操作更有效地执行。我们只需看看一个基本的二叉树,如下定义:²

Tree<T> = Leaf(T) | Branch(Tree<T>, Tree<T>)

根据这个定义,树可以是一个Leaf,它是一个终端节点并包含一个T,或者它可以是一个Branch,它是一个非终端节点,包含两个子节点或子树。这些可以进一步是叶子或分支,等等递归。就像List一样,我将用不同的类型来表示每个情况:

public abstract record Tree<T>;
internal record Leaf<T>(T Value) : Tree<T>;
internal record Branch<T>(Tree<T> Left, Tree<T> Right) : Tree<T>;

当你遍历树时,你将想要为分支和叶子执行不同的代码,并访问叶子的内部值。我将定义一个Match方法,提供一个愉快的 API 来执行模式匹配:

public static R Match<T, R>
(
   this Tree<T> tree,
   Func<T, R> Leaf,
   Func<Tree<T>, Tree<T>, R> Branch
)
=> tree switch
{
   Leaf<T>(T val) => Leaf(val),
   Branch<T>(var l, var r) => Branch(l, r),
   _ => throw new ArgumentException("{tree} is not a valid tree")
};

你现在可以像往常一样调用Match

myTree.Match
(
   Leaf: t => $"It's a leaf containing '{t}'",
   Branch: (left, right) => "It's a branch"
);

我还有典型的工厂函数LeafBranch,允许你在 REPL 中创建一个树,如下所示:

using static LaYumba.Functional.Data.BinaryTree.Tree;

Branch(
   Branch(Leaf(1), Leaf(2)),
   Leaf(3)
)

这将创建一个如下所示的树:

12.2.1 常见树操作

现在让我们看看一些常见的操作。与列表一样,我们可以用模式匹配来定义大多数操作。例如,计算树中值的数量可以这样做:

public static int Count<T>(this Tree<T> tree)
   => tree.Match
   (
      Leaf: _ => 1,
      Branch: (l, r) => l.Count() + r.Count()
   );

树也有深度(你需要遍历多少个节点才能从根节点到达最远的叶子节点),同样,你可以使用模式匹配来计算深度:

public static int Depth<T>(this Tree<T> tree)
   => tree.Match
   (
      Leaf: _ => 0,
      Branch: (l, r) => 1 + Math.Max(l.Depth(), r.Depth())
   );

那么Map呢?Map应该产生一个与原始树同构的新树,将映射函数应用于原始树中的每个值,如图 12.3 所示。

图 12.3 二叉树的Map函数

尽量不要看下面的实现,写下你认为Map可能如何工作的方法:

public static Tree<R> Map<T, R>
(
   this Tree<T> tree,
   Func<T, R> f
)
=> tree.Match
(
   Leaf: t => Leaf(f(t)),
   Branch: (left, right) => Branch
   (
      Left: left.Map(f),
      Right: right.Map(f)
   )
);

在树上实现 Map,你进行模式匹配:

  • 如果你有一个叶子节点,那么你提取它的值,将函数应用于叶子节点,并将其包裹在一个新的叶子节点中。

  • 否则,你创建一个新的分支,其左右子树是将函数映射到原始子树的结果。

定义一个将树中所有值减少到单个值的 Aggregate 函数也是合理的:

public static Acc Aggregate<T, Acc>
(
   this Tree<T> tree,
   Acc acc,
   Func<Acc, T, Acc> f
)
=> tree.Match
(
   Leaf: t => f(acc, t),
   Branch: (l, r) =>
   {
      var leftAcc = l.Aggregate(acc, f);
      return r.Aggregate(leftAcc, f);
   }
);

12.2.2 结构共享

更有趣的是,让我们看看一个改变树结构的操作,比如插入一个元素。这可以简单地完成,如下面的列表所示。

列表 12.3 向不可变树添加值

public static Tree<T> Insert<T>
(
   this Tree<T> tree, 
   T value
)
=> tree.Match
(
   Leaf: _ => Branch(tree, Leaf(value)),
   Branch: (l, r) => Branch(l, r.Insert(value))
);

如同往常,代码使用模式匹配。如果树是叶子节点,它创建一个分支,其两个子节点是叶子节点本身和带有插入值的新的叶子节点。如果它是分支,它将新值插入到右子树中。

例如,如果你从一个包含 {1, 2, 3, 7} 的树开始,并插入值 9,结果将如图 12.4 所示。正如你所看到的,新树与原始树共享其大部分结构。这是一个更普遍的结构共享思想的例子;更新的集合尽可能多地与原始集合共享其结构。

图 12.4 增值后的树与原始树共享部分结构。

插入一个元素到树中需要创建多少新项?与到达叶子节点所需的一样多。如果你从一个包含 n 个元素的平衡树开始,³ 插入涉及到创建 log n + 2 个对象,这是合理的。⁴

当然,列表 12.3 中的实现最终会导致一个非常不平衡的树,因为它总是向右添加元素。为了保证高效的插入,我们需要改进树表示以包括自我平衡机制。这当然可能,但超出了本介绍的范畴。

12.3 结论

开发高效的函数式数据结构是一个庞大而迷人的主题,我们只是触及了皮毛。关于这个主题的参考书是 Chris Okasaki(剑桥大学出版社,1999 年)的《Purely Functional Data Structures》。不幸的是,代码示例是 Standard ML。尽管如此,在本节中,你已经对函数数据结构的内部工作原理和结构共享的概念有所了解,这允许不可变数据结构既安全又高效。

函数式程序在复制数据而不是就地更新数据时可能会遭受一些性能损失,但命令式程序可能必须引入锁定和防御性复制以确保正确性。因此,函数式程序在许多场景中往往表现更好。然而,对于大多数实际应用来说,性能并不是关键问题,而是通过拥抱不可变性而获得的更高可靠性。

练习

列表:

  1. 实现以下函数以使用本章中定义的单链表List

    • InsertAt在给定索引处插入一个项。

    • RemoveAt移除给定索引处的项。

    • TakeWhile接受一个谓词并遍历列表,直到找到一个不满足谓词的项,然后输出所有项。

    • DropWhile的工作方式类似,但排除了列表前面的所有项。

  2. 这四个函数的复杂度是多少?创建新列表需要多少新对象?

  3. TakeWhileDropWhile在处理已排序的列表并且希望获取所有大于或小于某个值的项时非常有用。编写实现时,请使用IEnumerable而不是List

树:

  1. 在本章中展示的二叉树实现中定义Bind是否可能?如果是,实现Bind;如果不是,解释为什么不可能。(提示:首先编写签名,然后绘制一个二叉树以及如何将返回树的函数应用于树中的每个值。)

  2. 实现一个LabelTree类型,其中每个节点都有一个类型为string的标签和一个子树列表。这可以用来模拟一个典型的导航树或网站中的分类树。

  3. 假设你需要给你的导航树添加本地化功能。你被提供了一个LabelTree,其中每个标签的值是一个键,以及一个将键映射到网站必须支持的一种语言中的翻译的字典。你需要计算本地化的导航/分类树。(提示:为LabelTree定义Map。)

  4. 对前面的实现进行单元测试。

摘要

  • 在函数式编程中,集合应该是不可变的,这样现有的集合就不会被修改,而是通过创建带有所需更改的新集合。

  • 不可变集合可以既安全又高效,因为更新版本与原始集合共享大部分结构,而不会影响它。


¹ 不可变数据结构也被称为持久数据结构。在这个上下文中,“持久”一词并不表示持久到某种媒体,而只是指在内存中的持久性:原始数据结构不受创建新版本(如添加或删除元素)的任何操作的影响。此外,将“持久”一词应用于数据结构意味着它对某些操作的运行时间提供某些保证。具体来说,操作在持久数据结构中的效率应该与相应的可变结构中的效率相当,或者至少在同一数量级。这涉及到数据结构和算法设计,所以在这里我将坚持使用术语“不可变/函数式”数据结构/集合。

² 在这里,“二叉”表示每个分支都有两个子树。

³ 如果从根到叶子的所有路径长度相同或最多相差一个,则该树是平衡的。

⁴ 对数的基础将是树的基数(每个节点有多少个子节点)。一个基于列表表示的树的实际实现可能有 32 个基数,这样在插入一百万个对象后,你的树可能仍然只有 4 层的深度。

13 事件溯源:持久化的函数式方法

本章涵盖

  • 关于持久化数据的函数式思考

  • 事件溯源的概念和实现

  • 事件溯源系统的架构

在第十一章中,您看到在函数式编程中,我们避免修改状态,特别是全局状态。我提到过数据库也是一种状态,因此它也应该是不变的吗?什么?是的,你没有看到这一点吗?从概念上讲,数据库只是一个数据结构。它存储在内存中还是磁盘上,最终只是实现细节。

在第十二章中,您看到了函数式数据结构,尽管不可变,但可以进化:您可以创建任何给定结构的新状态或新视图,这些新状态或新视图基于原始结构,但不会改变原始结构。这个想法(我们通过对象、列表和树来探讨)自然适用于内存数据,也适用于存储数据,这就是我们的应用程序如何在不进行变异的情况下表示变化,甚至在数据库级别也是如此。

目前有两种实现“追加”数据存储的方法:

  • 基于断言的——将数据库视为一个在给定时间点始终增长的、包含真实事实的集合

  • 基于事件的——将数据库视为一个在给定时间点始终增长的、包含事件的集合

在这两种情况下,数据永远不会更新或删除,只有追加。¹ 我将在第 13.4 节中更详细地比较这两种方法,但我们将在本章的大部分内容中讨论基于事件的途径,通常称为事件溯源(ES)。这是因为它更容易理解和实现,使用各种类型的后端存储,并且在.NET 社区中的采用范围更广。

13.1 关于数据存储的函数式思考

今天的大多数服务器应用程序都是无状态的:当它们收到请求时,它们从数据库中检索所需的数据,进行一些处理,并持久化相关的更改(见图 13.1)。²

图 13.1 所说的无状态服务器通常依赖于一个称为数据库的大块可变数据。

事实上,无状态服务器方法之所以有效,正是因为状态是如此复杂的一个来源。如果您需要数据时可以从空中(如此说)获取数据,那么许多困难的问题就会消失。本质上,这就是无状态服务器所做的事情。

这也意味着在无状态服务器中相对容易避免状态变异:只需创建新的、更新的数据版本,并将这些版本持久化到数据库中。但如果我们认为在数据库中的值在过程中被更新或删除时我们在开发函数式程序,那我们就自欺欺人。当我们使用 CRUD 方法(就地更新存储的数据)开发应用程序时,我们本质上是在使用数据库作为一个大块的全球可变状态。

13.1.1 为什么数据存储应该是追加的

关系型数据库已经使用了大约 40 年。它们是在磁盘空间稀缺的时代被构思的,因此高效使用这些数据库至关重要。通常,只存储当前状态。当客户更改地址时,旧地址会被新地址覆盖——这种思维方式我们至今仍然保留,尽管现在它已经完全过时了。

现在,在大数据时代,情况已经逆转:存储便宜,数据有价值。覆盖数据就像把钱扔出窗外。假设一个客户从他们的购物车中移除一个商品——你该怎么办?你在数据库中删除一行吗?如果你这样做,你刚刚删除了可能有助于确定某些商品为什么没有按预期销售的有价值信息。也许客户经常在购买中途放弃某些商品,并用建议列表中的更便宜的商品替换它们。如果你删除数据,你就永远无法进行这种分析。

这就是为什么只追加存储的想法得到了人们的认可——永不删除或覆盖任何数据,只追加新数据。(例如,想想你用来存储代码的版本控制系统。你在提交新更改时是否会覆盖现有代码?)

只追加存储具有另一个显著的优点:它消除了数据库争用的问题。数据库引擎内部使用锁来确保并发连接修改相同字段时不会相互冲突。例如,想象你有一个电子商务网站,某个特定产品的购买热潮。如果该产品的库存计数被建模为一个数据库单元格中的值,该值在订单提交时更新,那么这将对该单元格造成争用,使数据库访问效率低下。像事件溯源这样的只追加方法可以消除这个问题。让我们看看事件溯源是什么样的。

13.1.2 放松并忘记存储状态

在第十一章中,我们探讨的一个重要思想是状态与实体之间的关系。状态 是实体在某一时刻的快照;相反,实体 是一系列逻辑上相关的状态的序列。状态转换 导致新的状态与实体相关联,或者更直观地说,导致实体从一个状态转换到下一个状态。

状态转换是由 事件 触发的;例如,你的银行账户会受到存款、取款、银行费用等事件的影响。因此,你的银行账户状态会发生变化,如图 13.2 所示。

图片

图 13.2 实体可以被视为一系列逻辑上相关的状态的序列。实体的身份保持不变,但状态会随着影响实体状态的事件而变化。

作为开发者,我们往往过于关注状态的表示。事实上,我们常常想当然地认为我们必须 持久化 状态。但这个隐含的假设是没有根据的:这只是关系型数据库盛行了半个世纪的效果。

通常,我们使用关系型数据库只存储实体的最新状态,覆盖之前的状态。当我们真正需要了解过去时,我们通常使用历史表来存储所有快照。这种方法效率低下,因为我们正在复制快照之间未更改的所有数据,而且它不有效,因为我们必须运行复杂的逻辑来比较两个状态,如果我们想找出导致变化的原因。

事件溯源(ES)将事物颠倒过来:它将重点从状态转移到状态转换。它不存储关于状态的数据,而是存储关于事件的数据。通过重新播放影响实体的所有事件,总是可以重建实体的当前状态。

图 13.3 显示了与图 13.2 相同的信息,但重点已改变。我们不想关注状态:状态是次要的。事实上,实体的状态(字面上)是其事件历史的函数。

图 13.3 事件溯源意味着在思考实体时的重点转移。我们不是关注实体的状态,而是关注导致新状态的转换。

给定实体的两个连续状态,很难确定导致转换的事件。相比之下,给定实体的状态和影响该实体的一个事件,很容易确定实体的新状态。因此,在 ES 中,我们持久化捕获事件细节的数据,而不是状态。

13.2 事件溯源基础

接下来,我们将看看如何在实践中应用这些想法,通过我们的 BOC 场景来说明。你会看到

  • 事件被表示为简单的、不可变的数据对象,它们捕捉了发生的事情的细节。

  • 状态也被表示为不可变的数据对象,尽管它们可能比事件具有更复杂的结构,例如父子关系。

  • 状态转换被表示为接受状态和事件作为参数并产生新状态的函数。

最后,你将看到如何从实体的事件历史中重新创建实体的状态。

13.2.1 事件表示

事件实际上非常简单,是捕获所需信息量最少的数据对象,以忠实代表发生的事情。例如,以下列表显示了一些可能影响银行账户的事件。

列表 13.1 影响银行账户的一些事件

public abstract record Event
(
   Guid EntityId,        ❶
   DateTime Timestamp
);

public record CreatedAccount
(
   Guid EntityId,
   DateTime Timestamp,
   CurrencyCode Currency
)
: Event(EntityId, Timestamp);

public record FrozeAccount
(
   Guid EntityId,
   DateTime Timestamp
)
: Event(EntityId, Timestamp);

public record DepositedCash
(
   Guid EntityId,
   DateTime Timestamp,
   decimal Amount,
   Guid BranchId
)
: Event(EntityId, Timestamp);

public record DebitedTransfer
(
   Guid EntityId,
   DateTime Timestamp,

   string Beneficiary,
   string Iban,
   string Bic,

   decimal DebitedAmount,
   string Reference
)
: Event(EntityId, Timestamp);

❶ 识别受影响的实体(在这种情况下,是一个账户)

前述事件只是可能影响账户的事件的一个子集(最明显的是,我们遗漏了现金提取和已记入的转账)。但它们足以代表,通过这些例子,你可以了解其他事件是如何处理的。

事件应该是不可变的:它们代表过去发生的事情,过去的事情无法改变。它们被持久化到存储中,因此它们也必须是可序列化的。

13.2.2 持续事件

如果你查看列表 13.1 中的示例事件,目的是将它们持久化到数据库中,你会立即注意到所有事件的结构都不同(不同的字段),因此你不能将它们存储在固定格式的结构中,如关系表。存储事件有多种选择。按照事件导向递减的顺序,你应该考虑使用以下选项:

  • 一种专门的事件数据库,如 Event Store (geteventstore.com),它是专门为事件源系统设计的。

  • 一种文档数据库,如 Redis、MongoDB 等。这些存储系统对其存储的数据结构不做任何假设。

  • 一种传统的 SQL Server 关系型数据库。

注意 无论你使用什么存储来持久化你的事件,通常都被称为事件存储。不要将其与本章中始终大写的 Event Store(事件存储)混淆,它是一个包含事件存储和许多相关功能的具体产品。

如果你选择在关系型数据库中存储事件,你需要一个包含一些标题列(如EntityIdTimestamp)的事件表,这些列是你查询实体事件历史(按时间排序,可能按时间过滤)所必需的。事件负载被序列化为 JSON 字符串并存储在一个宽列中,如表 13.1 所示。

表 13.1 事件数据可以存储在关系型数据库表中。

EntityId Timestamp EventType Data
abcd 2021-07-22 12:40 CreatedAccount
abcd 2021-07-30 13:25 DepositedCash
abcd 2021-08-03 10:33 DebitedTransfer

所有的三种存储选项都是可行的;这取决于你的需求和现有基础设施。如果你的大部分数据已经存储在关系型数据库中,而你只想将某些实体的事件源化,那么使用同一个数据库可能是有意义的,因为它将涉及更少的运营开销。

13.2.3 表示状态

我们在第十一章的大部分内容中讨论了如何表示状态,所以我们已经处于一个非常好的位置。但现在我们必须问一个问题,如果我们使用事件进行持久化,这些状态或快照的目的是什么?结果是,我们仍然需要数据实体的状态快照,出于两个完全独立的目的:

  • 我们需要快照来决定如何处理命令。 例如,如果服务器收到一个转账命令,而账户被冻结或余额不足,那么它必须拒绝该命令。

  • 我们还需要快照来向客户端显示。 我将它们称为视图模型。³

让我们处理第一种快照类型(我们将在 13.3.4 节中查看视图模型)。我们需要一个快照,它只捕获我们为了做出处理命令的决定所需要的内容。以下列表显示了一个这样的对象,它模拟账户状态。

列表 13.2 实体状态的简化模型

public sealed record AccountState
(
   CurrencyCode Currency,
   AccountStatus Status = AccountStatus.Requested,
   decimal Balance = 0m,
   decimal AllowedOverdraft = 0m
);

你会注意到,这与第十一章中讨论的AccountState类型相比有所简化。具体来说,我没有列出交易,因为我假设当前的余额和账户状态足以做出处理任何命令的决定。交易可以显示给用户,但在处理命令时不是必需的。

13.2.4 表示状态转换

现在我们来看看状态和事件是如何在状态转换中结合的。一旦你有一个状态和一个事件,你可以通过将事件应用于状态来计算下一个状态。这种计算称为状态转换,它是一个具有以下通用形式的函数:

state → event → state

换句话说,“给我一个状态和一个事件,我将计算事件之后的新状态。”针对我们的场景,这个签名变为

AccountState → Event → AccountState

在这里,Event是我们所有事件从中派生的基类,因此实现必须对事件类型进行模式匹配,然后计算一个新的AccountState,并包含相关的更改。

此外,还有一个特殊的状态转换,即账户首次创建时。在这种情况下,我们有一个事件但没有先前的状态,因此签名是这样的:

event → state

以下列表显示了我们的场景的实现。

列表 13.3 状态转换建模

public static class Account
{
   public static AccountState Create(CreatedAccount evt)   ❶
      => new AccountState
      (
         Currency: evt.Currency,
         Status: AccountStatus.Active
      );

   public static AccountState Apply
      (this AccountState acc, Event evt)
      => evt switch                                        ❷
      {
         DepositedCash e
            => acc with { Balance = acc.Balance + e.Amount },

         DebitedTransfer e
            => acc with { Balance = acc.Balance - e.DebitedAmount },

         FrozeAccount
            => acc with { Status = AccountStatus.Frozen },

         _ => throw new InvalidOperationException()        ❸
      };
}

CreatedAccount是一个特殊情况,因为没有先前的状态。

❷ 根据事件类型执行相关的转换

❸ 丢弃模式匹配任何未定义处理的事件。

第一种方法是创建的特殊情况:它接受一个CreatedAccount事件并创建一个新的AccountState,该状态包含来自事件的价值。为了简化问题,让我们假设账户一旦创建就可以设置为Active状态。

Apply方法是对状态转换的更一般化表述,它将通过事件类型进行模式匹配来处理所有其他类型的事件。如果事件是FrozeAccount,我们返回一个状态为Frozen的新状态;如果事件是DepositedCash,我们相应地增加余额,依此类推。在实际应用中,这里会有更多类型的事件。

无限制继承和丢弃模式

列表 13.3 中的switch表达式包含强制性的丢弃模式,用于处理任何与显式指定的模式不匹配的事件。这里的“强制性”是指,如果你省略它,你将收到编译器警告:没有丢弃模式,编译器无法假设模式匹配是穷尽的。Event可能有其他子类,甚至是在其他程序集中定义的、单独编译的子类。

如果丢弃模式匹配,总是抛出异常是明智的。如果你引入了一种新的事件类型,却忘记定义如何处理它,你希望代码失败。

大多数静态类型函数式语言在求和类型上采取不同的方法:当你定义一个求和类型时,你也定义了所有可能的子类型。例如,你可以说一个List可以是EmptyCons没有其他类型。在 C#中,以这种方式限制继承是不可能的,这导致我为本书中的大多数求和类型定义了Match方法。

在你可以穷尽地指定求和类型可能情况的编程语言中,模式匹配变得更加强大。编译器知道你的系统可以处理的所有可能的Event类型。这意味着你不再需要丢弃模式,更重要的是,如果你添加了一种新的Event类型,编译器会指出处理Event的所有地方,从而有效地指导你的开发过程。(你会得到编译器错误,显示你需要处理新情况的地方,而不是从丢弃模式匹配中抛出的运行时错误。)

注意,这种使用数据(例如不同类型的事件或命令)来执行不同逻辑的数据驱动方法与面向对象程序员所珍视的开放封闭原则完全相悖。

13.2.5 从过去事件重建当前状态

现在你已经看到了如何表示状态和事件,以及如何将它们与状态转换相结合,你就可以看到如何从影响该实体的过去事件历史中计算实体的当前状态。这在图 13.4 中进行了图形表示。

图像

图 13.4 从事件历史中恢复实体的当前状态

你有一系列影响账户的事件,你想要计算账户的当前状态。以下是需要考虑的三个因素:

  • 当你从一个列表开始,并希望最终得到一个单一值时,你使用Aggregate

  • 列表中的第一个事件导致账户被创建,而后续事件涉及状态转换。

  • 最后一个细节:想象一下,你查询数据库以获取所有关于账户 123 的事件,并得到一个空列表。这意味着该账户没有任何历史,因此实际上它不存在,你应该得到一个None

下面的列表显示了如何从事件历史中计算账户的状态。

列表 13.4 从事件历史中计算实体的状态

public static Option<AccountState> From
   (IEnumerable<Event> history)                             ❶
   => history.Match
   (
      Empty: () => None,
      Otherwise: (created, otherEvents) => Some
      (
         otherEvents.Aggregate
         (
            seed: Account.Create((CreatedAccount)created),  ❷
            func: (state, evt) => state.Apply(evt)          ❸
         )
      )
   );

❶ 给定事件历史

❷ 从第一个事件创建一个新的账户,将其用作累加器

❸ 应用每个后续事件

让我们先看看签名。我们正在处理一系列事件:实体的历史。这是当你查询给定账户 ID 的所有事件时从数据库中获取的事件列表。我假设这个序列是有序的;最早发生的事件应该在列表的顶部。你必须强制执行这一点,当你从数据库检索事件时,这通常是不需要额外工作的:因为事件在发生时持久化,它们按顺序附加,并且当它们被检索时,这种顺序通常被保留。

我们接着使用在第 12.1.3 节中定义的Match方法。这允许你处理空事件历史的情况,在这种情况下,账户实际上不存在,代码返回None。这就是为什么AccountState的期望返回类型被包裹在Option中的原因。

如果列表不为空,它将被分解为其头部和尾部。头部必须是一个CreatedAccount事件,而尾部包含所有后续事件。代码从CreatedAccount事件计算账户的初始状态,然后将其用作Aggregate的累加器,将所有后续事件应用于此初始状态,从而获得当前状态。

注意,如果你想看到账户的当前状态而不是过去任何时刻的状态,这可以通过评估相同的函数但只包括在所需日期之前发生的事件来轻易完成。因此,当需要审计跟踪并需要查看实体随时间变化的情况时,事件源是一个有价值的模型。

现在你已经看到了事件源如何提供一个可行的、只追加的持久化模型,从这个模型中可以轻松地计算出当前或过去的状态,让我们从高层次架构的角度看看事件源系统是什么样的。

13.3 事件源系统的架构

事件源系统中的数据流与传统系统中由关系型存储支持的数据流不同。如图 13.5 所示,在面向 CRUD 的系统(创建、读取、更新、删除)中,程序处理实体或,更好的说法,是状态。状态保存在数据库中;服务器检索状态;状态发送到客户端。在模型(数据库中存储的数据)和视图模型(发送到客户端的数据)之间的转换通常是微小的。

图片

图 13.5 传统系统与事件源系统数据流的高级比较

在事件源系统中,情况相当不同。我们持久化的是事件。但用户不会想看到事件日志,因此我们提供给用户消费的数据必须以有意义的方式结构化。因此,事件源系统可以被干净地分成两个独立的部分——通常,两个独立的服务器应用程序:

  • 命令端—这一端负责写入数据,主要是由处理从客户端接收到的命令组成。命令首先进行验证,有效的命令会导致事件被持久化和发布。

  • 查询端—这一端负责读取数据。视图模型由你希望在客户端显示的内容决定,查询端必须从存储的事件中填充这些视图模型。可选地,查询端还可以在新的事件导致视图更改时向客户端发布通知。

命令和查询端的这种自然分离导致组件更小、更专注。它还提供了灵活性:命令和查询端可以是完全独立的应用程序,因此可以独立扩展和部署。当你认为查询端的负载可能远大于命令端时,这很有优势。例如,想想当你访问像 Twitter 或 Facebook 这样的网站时,你发布的数据量与检索的数据量相比是多么的小。

相反,在命令端,你可能需要同步写入以防止并发更改。如果命令端只有一个实例,这将更容易实现。这种分离(被称为CQRS,即命令/查询责任分离),允许你轻松扩展数据密集型查询端以满足需求,同时保持较少或甚至只有一个命令端实例。

命令和查询端不必是独立的应用程序。它们可以存在于同一个应用程序中。但如果你使用事件源,这两端之间仍然存在内部分离。让我们看看如何实现它们,从命令端开始。

13.3.1 处理命令

命令,如果你愿意,是数据的最早期来源。命令由客户端发送到你的应用程序,并由命令端处理,它必须执行以下操作:

  • 验证命令

  • 将命令转换为事件

  • 持久化事件并将其发布给感兴趣的各方

让我们先比较命令和事件,它们相似但不同:

  • 命令—代表客户端的请求。由于某些原因,命令可能被忽视或不遵守。也许命令验证失败,或者处理它时系统崩溃。命令应使用祈使句命名,例如MakeTransferFreezeAccount

  • 事件—代表已经发生的事情。因此,它们不能失败或被忽视。它们应使用过去时命名,例如DebitedTransferFrozeAccount。在 ES 的上下文中,术语事件指的是导致状态转换的事件,因此必须被持久化(如果你有其他更短暂的事件需要持久化,确保你清楚地区分它们)。

除了这些,命令和事件通常捕获相同的信息,从命令创建事件只是逐字段复制(有时会有一些变化)。以下列表提供了一个示例。

列表 13.5 将命令转换为事件

using Boc.Domain.Events;                            ❶

namespace Boc.Commands;                             ❷

public abstract record Command(DateTime Timestamp);

public record FreezeAccount
(
   DateTime Timestamp,
   Guid AccountId
)
   : Command(Timestamp)
{
   public FrozeAccount ToEvent() => new             ❸
   (
      EntityId: this.AccountId,
      Timestamp: this.Timestamp
   );
}

// more commands here...

❶ 事件是领域定义的一部分。

❷ 命令是高级客户端代码的一部分。

❸ 通过逐字段复制值将命令转换为事件

我为 BOC 应用程序中的每个命令定义了类似的ToEvent方法。请注意,事件是在你的领域定义中定义的(因此,Boc.Domain.Events命名空间),而命令实际上是客户端代码的一部分(可能,事件可以在你的命令处理代码所依赖的较低级别的程序集上定义)。

事件直接影响单个实体,但事件在你的系统中广播,因此它们可能会触发创建其他事件,这些事件影响其他实体。例如,转账直接影响银行账户,但间接影响银行的现金储备。

接下来,让我们看看命令端的主要工作流程,如图 13.6 所示。

图片

图 13.6 事件源系统的命令端

首先,我将忽略验证和错误处理,这样你可以专注于数据流的基本要素。以下列表显示了命令端的入口点和主要工作流程。

列表 13.6 顶级命令处理工作流程

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Func<Guid, AccountState> getAccount,
   Action<Event> saveAndPublish
)
=> app.MapPost("/Transfer/Make", (MakeTransfer cmd) =>    ❶
{
   var account = getAccount(cmd.DebitedAccountId);        ❷

   var (evt, newState) = account.Debit(cmd);              ❸

   saveAndPublish(evt);                                   ❹

   return Ok(new { newState.Balance });                   ❺
});

❶ 处理接收命令

❷ 检索账户

❸ 执行状态转换;返回包含事件和新状态的元组

❹ 持久化事件并向感兴趣方发布

❺ 向客户端返回有关新状态的信息

此代码依赖于两个函数:

  • getAccount—检索受影响账户的当前状态(从其事件历史中计算得出,如你在第 13.2.5 节中看到的)

  • saveAndPublish—将给定的事件持久化到存储中,并将其发布给任何感兴趣的方

现在来看端点本身。它接收一个执行转账的命令,并使用getAccount函数来检索将被扣除的账户的状态。然后,它将检索到的账户状态和命令传递给Debit函数,该函数执行状态转换。

Debit返回一个包含创建的事件和账户新状态的元组。然后,代码将元组解构为其两个元素:传递给saveAndPublish的创建事件,以及用于填充发送回客户端的响应的账户新状态。让我们看看Debit函数:

public static class Account
{
   public static (Event Event, AccountState NewState) Debit
   (
      this AccountState currentState,
      MakeTransfer transfer
   )
   {
      Event evt = transfer.ToEvent();                      ❶
      AccountState newState = currentState.Apply(evt);     ❷

      return (evt, newState);
   }
}

❶ 将命令转换为事件

❷ 计算新状态

Debit 将命令转换为事件,并将该事件以及账户的当前状态输入到 Apply 函数中,以获得账户的新状态。请注意,这正是当从事件历史中计算账户的当前状态时使用的 Apply 函数。⁴ 这确保了状态转换的一致性,无论事件是现在刚刚发生还是过去发生并被重放。

13.3.2 处理事件

我们实际上将钱发送给收款人?这是作为 saveAndPublish 的一部分完成的:新创建的事件应该传播给相关方。一个专门的服务应该订阅这些事件,并将钱发送到接收银行(通过 SWIFT 或其他银行间平台)。

这可能有助于解释为什么函数被命名为 saveAndPublish:这两件事都应该原子性地发生。如果在所有订阅者能够处理事件之前,进程保存了事件然后崩溃,系统可能处于不一致的状态。例如,账户可能被扣除,但钱没有发送到 SWIFT。

如何实现这种原子性有些复杂,并且严格依赖于你针对的基础设施(无论是存储还是事件传播)。例如,如果你使用 Event Store,你可以利用事件流的 持久 订阅,这保证了事件至少被发送给订阅者一次(在这个上下文中,“持久”的含义)。

通过使用 Event Store,你可以简化 saveAndPublish 中的逻辑,使其只保存事件。事件处理程序随后订阅 Event Store 的事件流,如图 13.7 所示。

图 13.7 事件处理程序可以订阅 Event Store 发布的事件流。

13.3.3 添加验证

现在让我们添加验证,以便只有当账户的当前状态允许时,命令才被接受并转换为事件。这将在下面的列表中展示。

列表 13.7 确保只发生有效的转换

public static class Account
{
   public static Validation<(Event Event, AccountState NewState)> Debit
      (this AccountState account, MakeTransfer transfer)
   {
      if (account.Status != AccountStatus.Active)
         return Errors.AccountNotActive;

      if (account.Balance - transfer.Amount < account.AllowedOverdraft)
         return Errors.InsufficientBalance;

      Event evt = transfer.ToEvent();
      AccountState newState = account.Apply(evt);

      return (evt, newState);
   }
}

在这里,Debit 执行一些特定于账户的验证,因此返回类型被包装在 Validation 中:

  • 如果验证失败,代码将返回一个 Error(在这里,我演示了第 8.3.1 节中描述的方法,其中 Errors 类公开了可能在你应用程序中发生的每个错误的属性)。

  • 如果一切顺利,它将返回一个包含事件和新的状态的元组。

在任何情况下,返回值都隐式提升到适当状态中的 Validation。有了这个,让我们回顾主工作流程,如以下列表所示添加验证。

列表 13.8 带验证的命令处理

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Func<MakeTransfer, Validation<MakeTransfer>> validate,
   Func<Guid, Option<AccountState>> getAccount,
   Action<Event> saveAndPublish
)
=> app.MapPost("/Transfer/Make", (MakeTransfer transfer)
   => validate(transfer)
      .Bind(t => getAccount(t.DebitedAccountId)
         .ToValidation($"No account found for {t.DebitedAccountId}"))
      .Bind(acc => acc.Debit(transfer))
      .Do(result => saveAndPublish(result.Event))
      .Match(
         Invalid: errs => BadRequest(new { Errors = errs }),
         Valid: result => Ok(new { result.NewState.Balance })));

这个列表有一个新的依赖项 validate,它应该执行一些命令的一般验证,例如确保 IBAN 和 BIC 代码格式正确等。

我在 13.2.5 节提到,检索账户应该返回一个 Option 来反映请求的账户没有历史记录的情况。在这里,我们使用 ToValidationOption 转换为 Validation,如果给定的 OptionNone,则提供一个 Error 值使用。(这是我们在 6.5 节中看到的自然变换的另一个例子。)

命令的验证、实体存在的验证以及 Debit 中的账户特定验证都被建模为返回 Validation 的函数,因此可以与 Bind 结合使用。

工作流程的下一步发生在 Do 函数内部(见下文侧边栏)。这调用 saveAndPublish 并传递 Debit 的结果,因此它可以在后续对 Match 的调用中可用:工作流程的最终步骤,根据验证的结果向客户端发送适当的响应。

Do 函数

你可以在工作流程的中间使用 Do 来执行副作用。DoForEach 类似,因为它接受一个执行副作用的功能。而 ForEach 会丢弃内部值,Do 则将值传递下去,使其对后续逻辑可用。Do 的实现很简单:

public static Validation<T> Do<T>
   (this Validation<T> val, Action<T> action)
{
   val.ForEach(action);
   return val;
}

除了使用 Do,你也可以使用 Map,给它一个执行副作用并返回其输入的功能;然而,最好是明确表示,所以使用 Do 来强调正在执行副作用,而将 Map 保留用于没有副作用的纯数据转换。

Do 也被称为 TapTee。名字Tee非常形象。从管道的角度来思考:Do 就像一段 T 形的管道(数据从一端进入,同时流向产生副作用的功能和管道中的后续功能,如图所示)。

图片

与 13.6 列表中的初始骨架相比,13.8 列表中的代码增加了验证但没有异常处理。因为 getAccountsaveAndPublish 执行 I/O,它们中的任何一个都可能失败。为了表达这一点,我们需要将 Validation 与另一个效果如 Exceptional 结合使用。你将在第十八章中看到如何实现这一点。

你现在应该对事件源系统命令方面的工作方式有了相当好的了解。现在让我们看看查询方面。

13.3.4 从事件创建数据视图

现在我们已经看到了事件源系统命令方面的功能,让我们看看查询方面。我们再次从客户端开始探索。客户端以最适合用户需求的方式显示数据,服务器旨在提供显示在这些视图中的数据——视图模型。

让我们以银行账户对账单作为一个典型的银行账户视图。它包含了一个给定时间段内发生的交易列表(假设时间段与日历月份相吻合),以及该时间段开始和结束时的余额。图 13.8 展示了一个示例。

图片

图 13.8 银行对账单示例结构

接下来,让我们定义视图模型的结构,包含填充银行对账单所需的数据。我们将有一个父对象 AccountStatement,其中包含一系列 Transaction,如下所示。

列表 13.9 银行对账单的视图模型

public record AccountStatement
(
   int Month,
   int Year,
   decimal StartingBalance,
   decimal EndBalance,
   IEnumerable<Transaction> Transactions
);

public record Transaction
(
    DateTime Date,
    string Description,
    decimal DebitedAmount = 0m,
    decimal CreditedAmount = 0m
);

注意,AccountStatement 与你在列表 13.2 中看到的 AccountState 相关,但完全独立:

  • AccountState 用于命令端来处理可能影响账户的命令,因此服务器逻辑确定应包含哪些数据。

  • AccountStatement 是查询部分的一部分,因此客户端确定需要哪些数据。

这两种类型都指向同一个实体,但它们可能定义在不同的命名空间、程序集或甚至不同的应用程序中。

接下来,我们需要从给定账户的事件历史中填充这些数据。请注意,我们需要事件的完整历史记录。以下列表显示了一个为给定时间段填充 AccountStatement 的函数,该函数基于账户的事件历史。

列表 13.10 填充 AccountStatement 视图模型

public static AccountStatement Create
(
   int month,                     ❶
   int year,                      ❶
   IEnumerable<Event> events      ❷
)
{
   var startOfPeriod = new DateTime(year, month, 1);
   var endOfPeriod = startOfPeriod.AddMonths(1);

   var (eventsBeforePeriod, eventsDuringPeriod) = events
      .TakeWhile(e => endOfPeriod < e.Timestamp)
      .Partition(e => e.Timestamp <= startOfPeriod);

   var startingBalance = eventsBeforePeriod
      .Aggregate(0m, BalanceReducer);
   var endBalance = eventsDuringPeriod
      .Aggregate(startingBalance, BalanceReducer);

   return new
   (
      Month: month,
      Year: year,
      StartingBalance: startingBalance,
      EndBalance: endBalance,
      Transactions: eventsDuringPeriod.Bind(CreateTransaction)
   );
}

❶ 我们想要填充对账单的期间

❷ 账户的完整事件历史

让我们分析一下代码。首先,事件列表被拆分。我们需要在报表期开始之前发生的事件,以便计算报表期开始时的账户余额,以及在该期间发生的事件,以便计算期末余额。

为了计算起始余额,我们使用 0 作为种子值和一个累加器函数(见第 9.6 节)来累加所有先前事件,该函数根据事件如何影响余额来增加或减少余额。以下列表显示了这种方法。

列表 13.11 模型化每个事件如何影响账户余额的累加器

static decimal BalanceReducer(decimal bal, Event evt)
   => evt switch
   {
      DepositedCash e => bal + e.Amount,            ❶
      DebitedTransfer e => bal - e.DebitedAmount,   ❶
      _ => bal                                      ❷
    };

❶ 影响余额的事件

❷ 其他事件不影响余额,因此此默认子句返回运行余额。

并非所有事件都会影响余额,因此 switch 表达式的丢弃模式返回的是运行余额。

为了计算期末余额,可以使用相同的逻辑,但我们将使用起始余额作为种子值,并累加报表期内的所有事件。

现在是交易列表。一些事件(如转账)涉及交易;其他事件(如账户状态的变化)则不涉及。我在一个名为 CreateTransaction 的函数中模拟了这一点,该函数从 Event 中填充 Transaction

static Option<Transaction> CreateTransaction(Event evt)
   => evt switch
   {
      DepositedCash e => new Transaction
      (
         CreditedAmount: e.Amount,
         Description: $"Deposit at {e.BranchId}",
         Date: e.Timestamp.Date
      ),

      DebitedTransfer e => new Transaction
      (
         DebitedAmount: e.DebitedAmount,
         Description: $"Transfer to {e.Bic}/{e.Iban}; {e.Reference}",
         Date: e.Timestamp.Date
      ),

      _ => None
   };

不影响账户余额的事件不涉及交易;因此,此函数返回一个Option。您可以使用此函数计算所有属于报表的交易。但您可以使用Bind而不是Map,后者会生成一个IEnumerable<Option<Transaction>>,因为Bind会过滤掉所有的None,正如我们在第 6.5 节中看到的。

如您所见,从事件列表中填充视图模型需要一些工作和思考。涉及的数据转换通常可以通过常规的MapBindAggregate函数来完成。视图模型始终围绕用户体验为中心,并且与底层表示完全解耦。

如果涉及处理大量事件,填充视图模型可能会很耗时,因此通常需要一些优化来避免每次需要时都重新计算视图模型。一种这样的优化是查询端缓存每个视图模型的当前版本,并在接收到新事件时更新它。在这种情况下,查询端订阅由命令端发布的事件,并在接收到这些事件后更新缓存的版本,并(可选地)将更新的视图模型发布给连接的客户端。

如您所见,如果您想要一个具有关系数据库(或更好)性能特征的事件源模型,则需要做一些额外的工作来预先计算和维护视图模型。一些更复杂的优化包括为查询端设置一个专门的数据库,其中数据以优化的格式存储以供查询。例如,如果您需要查询具有任意过滤器的视图,这可以是一个关系数据库。这种查询模型始终是过去事件的副产品,因此,在出现差异的情况下,事件存储始终作为真相之源

13.4 比较不可变存储的不同方法

在本章中,您已经对 ES——一种基于事件的数据存储方法——有一个相当全面的概述。您已经看到为什么它本质上是一种函数式技术,以及存储关于状态转换的数据而不是状态提供的一些重要好处。

在本章开头提到的另一种方法是断言方法。在某种程度上,它更类似于关系模型,因为您仍然定义实体和属性,这些本质上类似于关系数据库中的行和列。(例如,您可以定义一个具有Email属性的Person实体。)

你将通过断言来修改这个数据库——例如,“从现在开始,ID 为 123 的PersonEmail属性值为 jobl@manning.com。”在未来,这个属性可能与不同的值相关联,但它在特定时间范围内与 jobl@manning.com 的值相关联的事实永远不会被遗忘、覆盖或销毁。在这个模型中,数据库变成了一个不断增长的实情集合。你可以以与查询关系数据库相同的方式查询数据库,但你可以选择查询当前状态或任何时间点的状态。

在基于断言和基于事件的方法中,你得到以下好处:

  • 审计跟踪,使得能够查询实体在任何时间点的状态

  • 没有数据库冲突,因为数据永远不会被覆盖

这些好处是固有的,因为这两种方法都拥抱不可变性。让我们看看可能影响你在这两种方法之间选择的其他因素。

13.4.1 Datomic 与 Event Store

基于断言的方法实际上只有一个实现,那就是 Datomic (www.datomic.com/),除了这里讨论的原则之外,它还实现了其他有趣的设计决策,使其在性能和可扩展性方面具有良好特性。Datomic 是一个专有产品,有一个在可扩展性方面受限的免费版本。自行推出基于断言的存储系统将是一项艰巨的任务。

相反,实现一个事件源系统相对简单:本章已经涵盖了大部分所需内容。你可以使用任何数据库(无论是 NoSQL 还是关系型)作为底层存储来编写有效的实现。对于大规模应用,仍然值得使用专门为 ES 设计的数据库,例如开源的 Event Store。⁵ 简而言之,如果你想使用基于断言的方法,你几乎不得不使用 Datomic;对于 ES,你可能需要或选择使用 Event Store。

由于 Event Store 是用.NET 开发的,它提供了一个.NET 客户端,用于通过 TCP 与存储进行通信,该项目在.NET 社区中具有良好的可见性。Datomic 是用 Clojure 开发的,与.NET 的互操作性不佳。⁶ 这些方面使得 Event Store 更有优势,部分原因是因为这一点,我们在.NET 用户中看到了对 ES(无论是否使用 Event Store)的更广泛采用。

13.4.2 你的领域有多少是事件驱动的?

在决定任何技术时,最重要的考虑因素是特定领域的具体需求:一些应用程序本质上是事件驱动的,而另一些则不是。

你如何评估 ES 是否适合你的需求?首先,看看你认为是领域事件的什么。它们有多重要?其次,看看提供的数据类型和参与方消费的数据类型之间是否存在自然差异。

以在线拍卖领域为例进行考虑。一个典型的事件是当客户对某件物品出价时。这个事件会触发变化:客户成为最高出价者,下一次出价的价值提高。另一个重要的事件是当锤子落下时。最高出价者有义务购买该物品,该物品不再出售,等等。这个领域无疑是事件驱动的。

此外,客户端消耗的数据往往与它们产生的数据形状完全不同:大多数客户端产生单个出价,但他们可能消耗包含待售物品细节、迄今为止对该物品的出价历史或他们购买物品列表的数据。因此,用户操作(命令)和它们消耗的数据(查询)之间已经存在自然解耦。ES 是这个领域的自然选择。

与此相反,想象一个能够使保险公司管理其产品的应用程序。你能想到哪些事件?可以创建一项新政策,或者将其退役,或者修改某些参数……但是等等,这些本质上都是 CRUD 操作!你仍然需要一个审计日志,因为一旦修改生效,修改产品特性可能会影响数千份合同。这非常适合基于断言的数据库。

不可变数据存储是未来发展的一个值得关注领域,因为这两种不可变存储方法都为现代应用的需求和挑战提供了重要的回应。

摘要

  • 关于数据的功能性思考也涵盖了存储。与其修改存储的数据,不如将数据库视为一个大的不可变集合:你可以添加新数据,但永远不能覆盖现有数据。

  • 不可变存储主要有两种方法:

    • 基于事件的——数据库是一个不断增长的事件集合。

    • 基于断言的——数据库是一个不断增长的事实集合。

  • 事件源意味着在事件发生时持久化事件数据。实体的状态不需要存储,因为它总是可以计算为影响实体的所有事件的“总和”。

  • 事件源系统自然地将读取和写入数据的关注点分开,使得 CQRS 架构能够在以下方面进行分离

    • 命令端——命令被接收、验证,并转换为持久化和发布的事件。

    • 查询端——事件被组合以创建视图模型,这些模型被提供给客户端,并且可选地缓存以提高性能。

  • 事件源系统包括以下组件:

    • 命令——简单、不可变的数据对象,封装了来自客户端的请求。

    • 事件——简单、不可变的数据对象,捕捉发生了什么。

    • 状态——表示实体在某个时间点的状态的数据对象。

    • 状态转换——接受状态和事件作为输入并产生新状态的函数。

    • 视图模型——用于填充视图的数据对象。它们是从事件计算得出的。

    • 事件处理器——这些订阅事件以执行业务逻辑(在命令端)或更新视图模型(在查询端)。


¹ 关系型数据库的传统功能是 CRUD 操作:创建、读取、更新和删除。数据存储的功能方法是 CRA:创建、读取、追加。

² 无状态服务器易于扩展:您可以拥有无数实例,所有这些实例都可以相互处理请求。相比之下,如果一个服务器是有状态的,并且根据其内部状态以不同的方式处理请求,那么您将限制为单个实例,或者您必须设计一种机制来确保不同的实例表现一致。

³ 在以事件形式存储的数据上运行复杂的分析可能效率低下,因此您也可能出于这个原因决定存储快照。这些快照被称为投影,并且随着事件的发生而更新,以便以高效格式查询数据。它们与视图模型在本质上没有区别(更准确地说,您可以将视图模型视为投影),因此在这本书中我不会专门处理投影。

⁴ 其他 ES 的作者可能会允许在这一点上将命令转换为多个事件,但我发现这往往会增加复杂性而没有带来任何真正的益处。相反,我发现命令应该转换为单个事件。当这个事件发布后,下游的事件处理器可以创建其他事件,影响相同的实体,但更频繁地影响其他实体。

⁵ 尽管 Event Store 对.NET 用户特别有吸引力,但它并不是唯一围绕事件流设计的数据库。另一个基于相同原则的堆栈是 Apache Kafka(它管理事件流)和 Samza(一个用于维护从这些流中计算出的视图模型的框架)。

⁶ 截至 2021 年,没有本地的.NET 客户端,因此只能通过 RESTful API 连接到 Datomic,这被认为是过时的。更多信息,请参阅docs.datomic.com/on-prem/reference/languages.html

第四部分. 高级技术

本部分处理了状态管理、异步和并发的复杂主题。

第十四章讨论了惰性评估的好处以及如何组合惰性计算。这是一个你将看到多个实际应用的通用模式。

第十五章展示了如何在不进行状态突变的情况下实现有状态的程序,以及有状态的计算也可以被组合。

第十六章处理了异步提供单个值或值流的计算,这是现代计算的一个重要部分。

第十七章展示了如何结合书中讨论过的不同效果——仍然是函数式编程中的一个开放研究课题。

第十八章讨论了响应式扩展(Rx),这是一个用于处理数据流的庞大库。

最后,第十九章介绍了消息传递并发,这是一种无锁并发风格,可以在编写有状态并发程序时使用。

第四部分中的每一章都介绍了有可能完全改变你对编写软件思考方式的重要技术。许多这些主题过于广泛,无法全面讨论,因此这些章节旨在提供一个介绍和进一步探索的起点。

14 懒计算、延续和单子组合的美丽

本章涵盖

  • 懒计算

  • 使用 Try 进行异常处理

  • 单子组合函数

  • 使用延续逃离厄运金字塔

在本章中,你将首先了解为什么有时定义懒计算(可能或可能不进行评估的函数)是可取的。然后,你将看到这些函数如何与其他函数独立于它们的评估进行组合。

一旦你对懒计算有了基本的了解,懒计算只是普通的函数,你就会看到相同的技巧可以扩展到除了懒性之外还有其他有用效果的计算。具体来说,你将学习如何使用 Try 代理安全地运行可能抛出异常的代码,以及如何组合多个 Try。然后,你将学习如何组合带有回调的函数,而不会陷入回调地狱

这些技巧共同之处在于,在所有情况下,你都将函数视为具有某些特定特性的事物,并且可以独立于它们的执行进行组合。这需要抽象上的飞跃,但结果是相当强大的。

注意:本章内容具有挑战性,所以如果你在第一次阅读时没有完全理解,请不要气馁。

14.1 懒性的美德

计算中的懒性意味着将计算推迟到结果需要时。当计算昂贵且其结果可能不需要时,这很有益。

为了引入懒性的概念,考虑以下一个随机选择两个给定元素之一的方法的例子。你可以在 REPL 中尝试它:

var rand = new Random();

T Pick<T>(T l, T r) =>
   rand.NextDouble() < 0.5 ? l : r;

Pick(1 + 2, 3 + 4) // => 3, or 7

这里值得指出的是,当你调用 Pick 时,即使最终只需要其中一个,1 + 23 + 4 这两个表达式都会被评估。¹ 因此,程序执行了一些不必要的计算。这是次优的,如果计算足够昂贵,应该避免这种情况。为了防止这种情况,我们可以将 Pick 重新编写为不是接受两个值,而是接受两个懒计算;也就是说,可以产生所需值的函数:

T Pick<T>(Func<T> l, Func<T> r) =>
   (rand.NextDouble() < 0.5 ? l : r)();

Pick(() => 1 + 2, () => 3 + 4) // => 3, or 7

Pick 现在首先在两个函数之间进行选择,然后评估其中一个。结果,只执行了一个计算。

总结来说,如果你不确定一个值是否会被需要,并且计算它可能很昂贵,可以通过将值包装在一个计算该值的函数中来懒性地传递该值。

注意:整数加法是一个极快的操作,所以在这个特定的例子中,分配两个 lambda 的成本超过了使计算懒性的好处。这种技术只有在计算密集型操作或执行 I/O 的操作中才是合理的。

接下来,你将看到这种懒 API 在处理 Option 时如何有益。

14.1.1 用于处理 Option 的懒 API

Option API 提供了一些很好的例子,说明了懒性如何有用。让我们看看这些例子。

提供回退 Option

想象你有一个返回 Option 的操作,并且你想提供一个回退——如果第一个操作返回 None,则使用另一个产生 Option 的操作。以这种方式组合两个这样的 Option 返回函数是常见场景,通过定义如下 OrElse 函数来实现:

public static Option<T> OrElse<T>
   (this Option<T> left, Option<T> right)
   => left.Match
   (
      () => right,
      (_) => left
   );

OrElse 简单地返回左边的 Option 如果它是 Some;否则,它回退到右边的 Option。例如,假设你定义了一个从缓存中查找项目的存储库,如果失败,则转到数据库:

interface IRepository<T> { Option<T> Lookup(Guid id); }

class CachingRepository<T> : IRepository<T>
{
   IDictionary<Guid, T> cache;
   IRepository<T> db;

   public Option<T> Lookup(Guid id)
      => cache.Lookup(id).OrElse(db.Lookup(id));
}

你能在前面的代码中看到问题吗?因为 OrElse 总是被调用,其参数总是被评估,这意味着即使项目在缓存中找到,你也会击中数据库。这完全违背了缓存的目的!

这可以通过使用惰性来解决。对于此类场景,我定义了一个 OrElse 的重载,它接受一个回退 Option 而不是将被评估以产生回退 Option 的函数:

public static Option<T> OrElse<T>
   (this Option<T> opt, Func<Option<T>> fallback)
   => opt.Match
   (
      None: fallback,    ❶
      Some: _ => opt
   );

❶ 仅在 None 的情况下评估回退函数

在这个实现中,只有当 optNone 时,fallback 函数才会被评估。(与之前显示的重载相比,其中回退选项 right 总是被评估。)你可以相应地修复缓存存储库的实现,如下所示:

public Option<T> Lookup(Guid id)
   => cache.Lookup(id).OrElse(() => db.Lookup(id));

现在,如果缓存查找返回 Some,则仍然会调用 OrElse,但不会调用 db.Lookup,从而实现所需的行为。

如你所见,为了使表达式的评估是惰性的,你提供的是一个函数,当调用时将评估该表达式。而不是提供 T,提供 Func<T>

使用 || 运算符作为 OrElse 的简洁替代

这里有一个与这个例子相关但与本章主题无关的有趣旁白。C# 允许你重载逻辑运算符,我在 Option| 上已经做到了这一点:

public static Option<T> operator |
(
   Option<T> l,
   Option<T> r
)
=> l.isSome ? l : r;

public static bool operator true(Option<T> opt) => opt.isSome;
public static bool operator false(Option<T> opt) => !opt.isSome;

因此,你可以使用短路 || 运算符而不是 OrElse,并且我们可以将 Lookup 函数重写如下:

public Option<T> Lookup(Guid id)
   => cache.Lookup(id) || db.Lookup(id));

因为 || 是短路的,如果左边(从缓存中查找)是 Some,则不会评估右边。代码简洁高效,并为我们提供了所需的行为。

提供默认值

当你想要从一个 Option 中提取内部值,并提供一个回退值以备 None 的情况时,这是一个类似的场景。这个操作称为 GetOrElse。例如,你可能需要从配置中查找值,如果没有指定值,则使用默认值:

string DefaultApiRoot => "localhost:8000";

string GetApiRoot(IConfigurationRoot config)
   => config.Lookup("ApiRoot").GetOrElse(DefaultApiRoot);

假设 Lookup 返回一个适当填充的 Option,其状态取决于值是否在配置中指定。注意,无论 Option 的状态如何,DefaultApiRoot 属性都会被评估。

在这种情况下,这是可以的,因为它只是简单地返回一个常量值。但如果 DefaultApiRoot 涉及到昂贵的计算,你宁愿只在其需要时执行它,通过传递延迟的默认值。这就是为什么我也提供了两个 GetOrElse 的重载:

public static T GetOrElse<T>(this Option<T> opt, T defaultValue)
   => opt.Match
   (
      () => defaultValue,
      (t) => t
   );

public static T GetOrElse<T>(this Option<T> opt, Func<T> fallback)
   => opt.Match
   (
      () => fallback(),
      (t) => t
   );

第一个重载接受一个常规回退值 T,它在调用 GetOrElse 时被评估。第二个重载接受一个 Func<T>,这是一个仅在必要时才被评估的函数。

何时应该让 API 延迟获取值?

作为指导原则,当一个函数可能不会使用其某些参数时,那些参数应该指定为延迟计算。

在某些情况下,你可能会选择提供两个重载:一个接受一个值作为参数,另一个接受一个延迟计算。然后客户端代码可以决定调用最合适的重载:

  • 如果计算值成本足够高,则延迟传递该值(更高效)。

  • 如果计算值的成本可以忽略不计,则传递该值(更易读)。

14.1.2 组合延迟计算

在本章的剩余部分,你将看到如何组合延迟计算以及为什么这样做是一种强大的技术。我们将从普通的延迟计算 Func<T> 开始,然后转向包含一些有用效果的延迟计算,例如处理错误或状态。

你看到了 Func<T> 是一种可以调用以获取 T 的延迟计算。实际上,Func<T> 可以被看作是 T 上的函子。记住,函子是你可以对其内部值应用 Map 函数的东西。这是怎么可能的?你迄今为止看到的函子都是某种容器。一个函数怎么可能是一个容器,它的内部值是什么?

好吧,你可以把一个函数看作是包含其潜在结果。比如说,Option<T> “可能包含”类型为 T 的某个值,你可以说 Func<T> “可能包含”类型为 T 的某个值,或者,也许更准确地说,它包含产生类型为 T 的值的潜力。函数的内部值是它在被评估时产生的值。

你可能知道《阿里巴巴和四十大盗》的故事。当擦亮它时,它会产生一个强大的神灯精灵。显然,这样的灯可以包含任何东西:把精灵放进去,你可以擦亮它来得到精灵;把你的祖母放进去,你可以擦亮它来得到祖母。你可以把它看作是一个函子:将一个“变蓝”函数映射到灯上,当你擦亮灯时,你会得到灯的内容变蓝。Func<T> 就是这样一种容器,其中擦亮就是函数调用。

实际上,你知道一个函子必须公开一个具有适当签名的 Map 方法。如果你遵循函子模式(见第 6.1.4 节),Func<T>Map 签名将涉及

  • 一个输入函子类型为 () T,这是一个可以被调用以生成 T 的函数。让我们称它为 f

  • 一个要映射的函数类型为 T R。让我们称它为 g

  • 一个预期的结果类型 () R,一个可以调用以生成 R 的函数。

实现相当简单:调用 f 获取一个 T,然后将其传递给 g 获取一个 R,如图 14.1 所示。列表 14.1 展示了相应的代码。

图 14.1 Func<T>Map 定义

列表 14.1 Func<T>Map 定义

public static Func<R> Map<T, R>
   (this Func<T> f, Func<T, R> g)
   => () => g(f());

注意到 Map 并没有调用 f。它接受一个延迟评估的 T 并返回一个延迟评估的 R。同时注意,实现只是函数组合。

要看到这个动作,打开 REPL,像往常一样导入 LaYumba.Functional,并输入以下内容:

var lazyGrandma = () => "grandma";
var turnBlue = (string s) => $"blue {s}";
var lazyGrandmaBlue = lazyGrandma.Map(turnBlue);

lazyGrandmaBlue() // => "blue grandma"

为了更好地理解整个计算的延迟性,你可以嵌入一些调试语句:

var lazyGrandma = () =>
{
   WriteLine("getting grandma...");
   return "grandma";
};

var turnBlue = (string s) =>
{
   WriteLine("turning blue...");
   return $"blue {s}";
};

var lazyGrandmaBlue = lazyGrandma.Map(turnBlue);  ❶

lazyGrandmaBlue()                                 ❷
// prints: getting grandma...
//         turning blue...
// => "blue grandma"

❶ 所有函数尚未被评估。

❷ 所有之前组合的函数现在都被评估了。

正如你所见,函数 lazyGrandmaturnBlue 都是在最后一行才被调用的。这表明你可以在不执行任何操作的情况下构建复杂的逻辑,直到你决定启动它们。

一旦你彻底理解了前面的示例,在 REPL 中进行了实验,并理解了 Map 的定义,理解以下列表中显示的 Bind 定义将会变得容易。

列表 14.2 Func<T>Bind 定义

public static Func<R> Bind<T, R>
   (this Func<T> f, Func<T, Func<R>> g)
   => () => g(f())();

Bind 返回一个函数,当评估时,将评估 f 以获取一个 T,将 g 应用到它以获取一个 Func<R>,然后评估它以获取结果 R

这一切都很有趣,但它的实际用途究竟有多大?因为函数已经内置到语言中,能够将 Func 作为单子处理可能不会给你带来太多。另一方面,知道函数可以像任何其他单子一样组合,我们可以将一些有趣的效果嵌入到函数的行为中。这就是本章剩余部分的内容。

14.2 使用 Try 进行异常处理

在第八章中,我展示了如何通过捕获异常并将它们返回在 Exceptional 结构中(一个可以包含异常或成功结果的构造)来从基于 Exception 的 API 转换为函数式 API。例如,如果你想安全地从 string 创建一个 Uri,你可以编写如下方法:

Exceptional<Uri> CreateUri(string uri)
{
   try { return new Uri(uri); }
   catch (Exception ex) { return ex; }
}

这方法可行,但你真的应该为每个可能抛出异常的方法都这样做吗?显然,经过几次尝试和捕获后,你可能会开始觉得所有这些尝试和捕获都是模板代码。我们能将其抽象化吗?

14.2.1 表示可能失败的计算

确实可以,使用 Try——一个表示可能抛出异常的操作的委托。它被定义为如下:

public delegate Exceptional<T> Try<T>();

Try<T> 只是一个你可以用来表示通常返回 T 但可能抛出异常的计算的委托;因此,其返回值被包装在 Exceptional 中。

Try 定义为单独的类型允许你定义针对 Try 的特定扩展方法(最重要的是 Run),它安全地调用它并返回一个适当填充的 Exceptional

public static Exceptional<T> Run<T>(this Try<T> f)
{
   try { return f(); }
   catch (Exception ex) { return ex; }
}

Run 一次完成 try-catch 仪式,因此你永远不需要再次编写 try-catch 语句。将之前的 CreateUri 方法重构为使用 Try,你可以编写:

Try<Uri> CreateUri(string uri) => () => new Uri(uri);

注意 Try 如何使你能够在不编写任何处理异常的样板代码的情况下定义 CreateUri,同时仍然可以通过使用 Run 来安全地执行 CreateUri。通过在 REPL 中输入以下内容来自行测试:

Try<Uri> CreateUri(string uri) => () => new Uri(uri);

CreateUri("http://github.com").Run()
// => Success(http://github.com/)

CreateUri("rubbish").Run()
// => Exception(Invalid URI: The format of the URI could not be...)

注意,CreateUri 的主体返回一个 Uri,但 Try<Uri> 被定义为返回一个 Exceptional<Uri>。这是可以的,因为我已经定义了从 TExceptional<T> 的隐式转换。在这里,错误处理的细节已经被抽象化,因此你可以专注于重要的代码。

作为一种简写符号,如果你不想将 CreateUri 定义为一个专用函数,你可以使用在 F 中定义的 Try 函数,该函数简单地将 Func<T> 转换为 Try<T>

Try(() => new Uri("http://google.com")).Run()
// => Success(http://google.com/)

14.2.2 从 JSON 对象中安全地提取信息

现在是有趣的部分——能够组合延迟计算的重要性。如果你有两个(或更多)可能失败的计算,你可以使用 Bind 将它们“单调地”组合成一个可能失败的单个计算。例如,想象你有一个表示 JSON 格式的对象的字符串,其结构如下:

{
  "Name": "github",
  "Uri": "http://github.com"
}

你想要定义一个方法,从 JSON 对象的Uri字段中的值创建一个 Uri。以下列表显示了进行此操作的不安全方式。

列表 14.3 从 JSON 对象中不安全地提取数据

using System.Text.Json;

record Website(string Name, string Uri);

Uri ExtractUri(string json)
{
   var website = JsonSerializer.Deserialize<Website>(json);   ❶

   return new Uri(website.Uri);                               ❷
}

❶ 将字符串反序列化为 Website

❷ 创建一个 Uri 实例

JsonSerializer.DeserializeUri 构造函数如果它们的输入格式不正确,都会抛出异常。

让我们使用 Try 来确保实现的安全性。我们可以通过将可能抛出异常的方法调用包装到 Try 中来开始,如下所示:

Try<Uri> CreateUri(string uri) => () => new Uri(uri);
Try<T> Parse<T>(string s) => () => JsonSerializer.Deserialize<T>(s);

如同往常,组合返回 Try 的多个操作的方式是使用 Bind。我们稍后将查看其定义。现在,相信它的工作,让我们使用它来定义一个方法,将前两个操作组合成另一个返回 Try 的函数:

Try<Uri> ExtractUri(string json)
   => Parse<Website>(json)
      .Bind(website => CreateUri(website.Uri));

这可以工作,但可读性并不高。LaYumba.Functional 库包括了 Try 和所有其他包含的单子的 LINQ 查询模式实现(见侧边栏“关于 LINQ 查询模式的提醒”),因此我们可以通过使用 LINQ 表达式来提高可读性,如下列所示。

列表 14.4 从 JSON 对象中安全地提取数据

Try<Uri> ExtractUri(string json) =>
   from website in Parse<Website>(json)    ❶
   from uri in CreateUri(website.Uri)      ❷
   select uri;

❶ 将字符串反序列化为 Website

❷ 创建一个 Uri 实例

列表 14.4 是列表 14.3 中不安全代码的安全对应版本。你可以看到,我们可以在不牺牲可读性的情况下进行重构。让我们向 ExtractUri 提供一些样本值,以验证它是否按预期工作:

ExtractUri(
   @"{
      ""Name"":""Github"",
      ""Uri"":""http://github.com""
     }")
   .Run()
// => Success(http://github.com/)

ExtractUri("blah!").Run()
// => Exception('b' is an invalid start of a value...)

ExtractUri("{}").Run()
// => Exception(Value cannot be null...)

ExtractUri(
   @"{
      ""Name"":""Github"",
      ""Uri"":""rubbish""
     }")
   .Run()
// => Exception(Invalid URI: The format of the URI...)

记住,一切都是惰性的。当你调用ExtractUri时,你只是得到一个可以最终执行某些计算的Try。直到你调用Run之前,实际上什么都没有发生。

14.2.3 组合可能失败的计算

现在你已经看到了如何使用Bind来组合几个可能失败的计算,让我们看看Bind是如何为Try定义的。

记住,Try<T>就像一个Func<T>,我们现在知道调用可能会抛出异常。让我们先快速回顾一下FuncBind

public static Func<R> Bind<T, R>
   (this Func<T> f, Func<T, Func<R>> g)
   => () => g(f())();

描述这段代码的一种鲁莽的方式是,它首先调用f,然后调用g。现在我们需要将其修改为与Try一起工作。首先,将Func替换为Try给出了正确的签名。(这通常是工作的一半,因为对于核心函数,如果实现类型检查,通常就能工作。)其次,因为直接调用Try可能会抛出异常,我们需要使用Run。最后,我们不想在第一个函数失败时运行第二个函数。下面的列表显示了实现。

列表 14.5 Try<T>Bind定义

public static Try<R> Bind<T, R>
   (this Try<T> f, Func<T, Try<R>> g)
   => ()
   => f.Run()                        ❶
      .Match
      (
         Exception: ex => ex,        ❷
         Success: t => g(t).Run()    ❶
      );

❶ 使用Run安全地执行每个Try

❷ 如果第一个Try失败,则不执行第二个

Bind接受一个Try和一个返回Try的函数g。然后它返回一个函数,当被调用时,运行Try,如果成功,则在结果上运行g以获得另一个Try,该Try也会被运行。

如果我们可以定义Bind,我们就可以始终定义Map,这通常更简单。我建议你将Map作为练习来定义。

LINQ 查询模式的提醒

本章的一个基本思想是你可以使用Bind来序列化计算,因此我将展示Bind的实现。

为了使用与单子类型(在这种情况下,Try)一起的 LINQ 表达式,你还需要实现我在第 10.4.2 节中讨论的 LINQ 查询模式。以下是如何做到这一点的提醒:

  • Map别名为Select

  • Bind别名为SelectMany

  • 定义一个额外的SelectMany重载,它接受一个二元投影函数。这个额外的重载可以用MapBind来定义,尽管通常可以定义一个更高效的实现。

我不会通过展示所有这些方法实现来弄乱这一章,这些实现可以在代码示例中找到。到现在为止,你已经有了理解它们的工具。

14.2.4 单子组合:这意味着什么?

在本章和下一章中,你经常会读到关于单子组合计算的内容。这听起来很复杂,但实际上并不复杂,所以让我们揭开它的神秘面纱。

首先,让我们回顾一下“正常”函数组合,这是我在第七章中提到的。假设你有两个函数:

f : A → B
g : B → C

你可以通过将f的输出简单地管道到g来组合它们,得到一个函数A C。现在想象你有了以下函数:

f' : A → Try<B>
g' : B → Try<C>

这些函数显然不能组合,因为 f' 返回一个 Try<B>,而 g' 期望一个 B,但很清楚,你可能希望通过从 Try<B> 中提取 B 并将其提供给 g' 来组合它们。这是单子组合,这正是 TryBind 所做的,正如你所看到的。

换句话说,单子组合是一种比函数组合更通用的组合函数的方法,它涉及一些逻辑,该逻辑决定了函数如何组合。这种逻辑在 Bind 函数中得到了体现。

有几种这种模式的变体。想象以下函数:

f" : A → (B, K)
g" : B → (C, K)

我们能否将它们组合成一个新的类型为 A → (C, K) 的函数?给定一个 A,计算一个 C 很容易:在 A 上运行 f",从结果元组中提取 B,并将其提供给 g")。在这个过程中,我们已经计算了两个 K,那么我们应该如何处理它们呢?如果有一种方法可以将两个 K 合并成一个 K,那么我们可以返回合并后的 K。例如,如果 K 是一个列表,我们可以返回两个列表的所有元素。如果 K 是一个合适的类型,前面形式的功能可以单子组合。²

我将在本书中演示单子组合的函数列在表 14.1 中,但还有许多其他可能的变体。

表 14.1 本书中展示的单子组合计算

Delegate 签名 部分 场景
Try<T> () → T 14.2 异常处理
Middleware<T> (T → R)R 14.3 在给定函数前后添加行为
Generator<T> int → (T, int) 15.2 生成随机数据
StatefulComputation<S, T> S → (T, S) 15.3 在计算之间保持状态

14.3 为数据库访问创建中间件管道

在本节中,我将首先展示在某些情况下使用 HOFs 如何导致深度嵌套的回调,亲切地称为“回调地狱”或“末日金字塔”。我将使用数据库访问作为具体场景来说明这个问题,并展示如何利用 LINQ 查询模式来创建平坦的单子工作流程。

本节包含了一些高级材料,这些材料不是理解后续章节所必需的,所以如果你是第一次阅读,请随意跳到第十五章。

14.3.1 组合执行设置/清理的函数

在 2.3 节中,你学习了执行一些设置和清理的函数,并使用一个在中间调用的函数进行参数化。一个例子是管理数据库连接的函数,该函数使用一个与数据库交互的函数进行参数化:

public static class ConnectionHelper
{
   public static R Connect<R>
      (ConnectionString connString, Func<SqlConnection, R> f)
   {
      using var conn = new SqlConnection(connString);
      conn.Open();
      return f(conn);
   }
}

这个函数可以在客户端代码中这样使用:

public void Log(LogMessage message)
   => Connect(connString, c => c.Execute("sp_create_log"
      , message, commandType: CommandType.StoredProcedure));

让我们定义一个类似的功能,可以在操作前后记录消息:

public static class Instrumentation
{
   public static T Trace<T>(ILogger log, string op, Func<T> f)
   {
      log.LogTrace($"Entering {op}");
      T t = f();
      log.LogTrace($"Leaving {op}");
      return t;
   }
}

如果你想要使用这两个函数(打开/关闭连接以及跟踪进入/离开块),你将编写如下所示的内容。

列表 14.6 嵌套回调难以阅读

public void Log(LogMessage message)
   => Instrumentation.Trace("CreateLog"
      , () => ConnectionHelper.Connect(connString
         , c => c.Execute("sp_create_log"
            , message, commandType: CommandType.StoredProcedure)));

这开始变得难以阅读。如果你还想做一些其他的工作设置呢?对于你添加的每个高阶函数(HOF),你的回调会嵌套更深一层,使代码更难以理解。这就是为什么它被称为“灾难金字塔”。

相反,我们理想中希望有一种干净的方式来组合 中间件管道,如图 14.2 所示。我们希望在每次访问数据库时添加一些行为(如连接管理、诊断等)。从概念上讲,这与 ASP.NET Core 中处理 HTTP 请求的中间件管道类似。

图片

图 14.2 访问数据库的中间件管道

在一个正常的、线性的函数管道中,每个函数的输出都会传递到下一个函数。每个函数都无法控制下游发生的事情。另一方面,中间件管道是 U 形的:每个函数传递一些数据,但也可以说在输出过程中接收一些数据。因此,每个函数都有能力在下游函数之前和之后执行一些操作。

我打算把这些函数或块称为 中间件。我们希望能够优雅地组合这样的中间件管道来添加日志记录、计时等功能。但是,因为每个中间件都必须接受一个回调函数作为输入参数(否则,它无法在回调返回后进行干预),我们如何才能摆脱“灾难金字塔”呢?

14.3.2 针对灾难金字塔的配方

事实上,我们可以将Bind看作是一种针对“灾难金字塔”的配方。例如,你可能记得在第八章中,我们如何使用Bind来组合几个返回Either的函数:

WakeUpEarly()
   .Bind(ShopForIngredients)
   .Bind(CookRecipe)
   .Match
   (
      Left: PlanB,
      Right: EnjoyTogether
   );

如果你展开对Bind的调用,前面的代码看起来像这样:

WakeUpEarly().Match
(
   Left: planB,
   Right: u => ShopForIngredients(u).Match
   (
      Left: planB,
      Right: ingr = CookRecipe(ingr).Match
      (
         Left: planB,
         Right: EnjoyTogether
      )
   )
);

你可以看到,Bind实际上使我们能够在这个情况下摆脱“灾难金字塔”:同样也适用于Option等。但我们能否为我们的中间件函数定义Bind

14.3.3 捕捉中间件函数的本质

为了回答这个问题,让我们看看我们的中间件函数的签名,看看我们是否可以识别并以抽象的方式捕捉到一个模式。这些是我们迄今为止看到的函数:

Connect : ConnectionString → (SqlConnection → R) → R
Trace : ILogger → string → (() → R) → R

让我们再想象一些可能需要使用中间件的例子。我们可以使用一个计时中间件来记录操作所花费的时间,以及另一个开始和提交数据库事务的中间件。签名看起来像这样:

Time : ILogger → string → (() → R) → R
Transact : SqlConnection → (SqlTransaction → R) → R

Time具有与Trace相同的签名:它接受一个日志记录器和字符串(正在计时操作的名称)以及被计时的函数。Transact类似于Connect,但它接受一个用于创建事务的连接和一个消耗事务的函数。

现在我们有了四个合理的用例,让我们看看签名中是否有某种模式:

ConnectionString → (SqlConnection → R) → R
ILogger → string → (() → R) → R
SqlConnection → (SqlTransaction → R) → R

每个函数都有一些特定于其公开的功能的参数,但肯定有一个模式。如果我们抽象掉这些特定的参数(我们可以通过部分应用提供它们)并只关注显示为粗体的参数,所有函数都有这种形式的签名:

(T → R) → R

它们都接受一个产生 R 的回调函数(尽管,在这个上下文中,它通常被称为 延续),并返回一个 R(可能是延续返回的 R 或其修改版本)。中间件函数的本质是它接受一个类型为 T → R 的延续,向其中提供一个 T 以获得一个 R,并返回一个 R,如图 14.3 所示。

图片

图 14.3 一个单独的中间件函数

让我们用一个代表者来捕捉这个本质:

// (T → dynamic) → dynamic
public delegate dynamic Middleware<T>(Func<T, dynamic> cont);

但等等。为什么它返回的是 dynamic 而不是 R

问题在于 T(延续的输入)和 R(其输出)不是同时知道的。例如,假设你想从一个具有此签名的函数(如 Connect)创建一个 Middleware 实例:

public static R Connect<R>(ConnectionString connString
   , Func<SqlConnection, R> func) // ...

Connect 接受的延续接受一个 SqlConnection 作为输入,因此我们可以使用 Connect 来定义一个 Middleware<SqlConnection>。这意味着 Middleware<T> 中的 T 类型变量解析为 SqlConnection,但我们还不知道给定的延续会产生什么,因此我们还不能解析 Connect<R> 中的 R 类型变量。

不幸的是,C# 不允许我们部分应用类型变量,因此使用 dynamic。所以尽管在概念上,我们是在考虑组合这种类型的 HOF(高阶函数)

(T → R) → R

我们实际上是这样建模它们的:

(T → dynamic) → dynamic

之后,你将看到你仍然可以在不牺牲类型安全的情况下与 Middleware 一起工作。

有趣且令人费解的是,Middleware<T>T 上的一个 monad,其中(记住)T 是传递给中间件函数的延续所接受的 输入 参数的类型。这似乎不符合直觉。T 上的 monad 通常是指包含一个或多个 T 的东西。但这里仍然适用:如果一个函数的签名是 (T → R) → R,那么它可以向给定的函数 T → R 提供一个 T,因此它必须包含或以某种方式能够产生一个 T

14.3.4 实现中间件的查询模式

是时候学习如何使用 Bind 组合两个中间件块了。本质上,Bind 将下游中间件块附加到管道中,如图 14.4 所示。

图片

图 14.4 Bind 向管道中添加一个中间件块。

Bind 的实现简单易写,但不容易完全理解:

public static Middleware<R> Bind<T, R>
   (this Middleware<T> mw, Func<T, Middleware<R>> f)
   => cont
   => mw(t => f(t)(cont));

我们有一个期望类型为 (T dynamic) 的后续函数的 Middleware<T>。然后有一个函数 f,它接受一个 T 并生成一个期望类型为 (R dynamic)Middleware<R>。我们得到的结果是一个 Middleware<R>,当提供一个后续函数 cont 时,它会运行初始中间件,并将一个运行绑定函数 f 以获得第二个中间件的函数作为后续函数,然后将其传递给第二个中间件。如果你现在还不完全理解这一点,请不要担心。

现在我们来看看 Map

public static Middleware<R> Map<T, R>
   (this Middleware<T> mw, Func<T, R> f)
   => cont
   => mw(t => cont(f(t)));

Map 接受一个 Middleware<T> 和一个从 TR 的函数 f。中间件知道如何创建一个 T 并将其提供给接受 T 的后续函数。通过应用 f,它现在知道如何创建一个 R 并将其提供给接受 R 的后续函数。你可以将 Map 视为在后续函数之前添加一个转换 T R,或者,作为替代,将其视为向管道添加一个新的设置/清理块,在设置时执行转换,并将结果作为清理传递,如图 14.5 所示。

图像

图 14.5 Map 向管道添加一个转换。

最后,一旦我们组合了所需的管道,我们可以通过传递一个后续函数来运行整个管道:

Middleware<A> mw;
Func<A, B> cont;

dynamic exp1 = mw(cont);

上述代码显示,如果你有一个 Middleware<A> 和一个类型为 A B 的后续函数 cont,你可以直接将后续函数提供给中间件。

仍然有一个小问题需要解决。请注意,当我们提供后续函数时,我们得到一个 dynamic 返回值,而我们实际上期望得到一个 B。为了保持类型安全,我们可以定义一个 Run 函数,该函数使用恒等函数作为后续函数来运行管道:

public static T Run<T>(this Middleware<T> mw)
   => (T)mw(t => t);

因为 mw 是一个 Middleware<T>(这意味着 mw 可以向其后续函数提供类型为 T 的值)并且因为在这个情况下后续函数是恒等函数,我们知道后续函数会生成一个 T,所以我们有信心将中间件运行的结果转换为 T 类型。

当我们想要运行一个管道时,我们不必直接提供后续函数,可以使用 Map 来映射后续函数,然后调用 Run

Middleware<A> mw;
Func<A, B> cont;

B exp2 = mw.Map(cont).Run()

在这里,我们将我们的后续函数 A B 映射到我们的 Middleware<A> 上,得到一个 Middleware<B>,然后运行它(使用恒等函数)以获得一个 B。请注意,这个片段中的 exp2 与上一个片段中的 exp1 相同,但我们已经恢复了类型安全。³

让我们通过重构第 2.3 节中的 DbLogger 来使用 Middleware 而不是高阶函数(HOFs)来实现这一点:

public class DbLogger
{
   Middleware<SqlConnection> Connect;

   public DbLogger(ConnectionString connString)
   {
      Connect = f => ConnectionHelper.Connect(connString, f);
   }

   public void Log(LogMessage message) => (
      from conn in Connect
      select conn.Execute("sp_create_log", message
         , commandType: CommandType.StoredProcedure)
   ).Run();

在构造函数中,我们实际上使用部分应用将连接字符串烘焙到 Connect 函数中,现在它具有正确的签名,可以用作 Middleware<SqlConnection>

Log方法中,我们创建一个包含单个中间件块的管道,该块创建数据库连接。然后我们可以使用 LINQ 语法在调用Execute(与数据库交互的主要操作)时引用conn(管道运行时将可用的连接)。

当然,我们可以通过只向Connect传递一个回调来更简洁地编写Log。但这里的关键是避免回调。随着我们向管道中添加更多块,我们只需向我们的 LINQ 理解中添加from子句就能做到这一点。你将在下一节看到这一点。

14.3.5 添加计时操作的中件

假设我们有一个数据库操作,有时比预期耗时更长,因此我们希望添加另一个中间件来记录数据库访问操作耗时。为此,我们可以定义以下 HOF:

public static class Instrumentation
{
   public static T Time<T>(ILogger log, string op, Func<T> f)
   {
      var sw = new Stopwatch();
      sw.Start();

      T t = f();

      sw.Stop();
      log.LogDebug($"{op} took {sw.ElapsedMilliseconds}ms");
      return t;
   }
}

Time接受三个参数:一个记录器,它将记录诊断消息;op,正在执行的操作的名称,它将包含在记录的消息中;以及一个表示正在计时操作功能的函数。

有一个小问题,因为Time需要一个Func<T>(一个没有输入参数的函数),而我们已经定义了中间件接受的连续形式为T dynamic(应该始终有一个输入参数)。我们可以像往常一样用Unit来弥合这个差距,但这次是在输入端。为此,我定义了一个适配函数,它将接受Unit的函数转换为不接受任何参数的函数:

public static Func<T> ToNullary<T>(this Func<Unit, T> f)
   => () => f(Unit());

在此基础上,我们可以通过以下列表所示的方式,在管道中添加一个用于记录数据库访问时间的块。

列表 14.7 结合计时和连接管理

public class DbLogger
{
   Middleware<SqlConnection> Connect;
   Func<string, Middleware<Unit>> Time;

   public DbLogger(ConnectionString connString, ILogger log)
   {
      Connect = f => ConnectionHelper.Connect(connString, f);
      Time = op => f => Instrumentation.Time(log, op, f.ToNullary());
   }

   public void DeleteOldLogs() => (
      from _    in Time("DeleteOldLogs")
      from conn in Connect
      select conn.Execute
         ( "DELETE [Logs] WHERE [Timestamp] < @upTo"
         , new { upTo = 7.Days().Ago() })
   ).Run();
}

一旦我们将对Instrumentation.Time的调用包装在一个Middleware中,我们就可以通过添加一个额外的from子句在管道中使用它。请注意,_变量将被分配由Time返回的Unit值。你可以忽略它,但 LINQ 语法不允许你省略它。

14.3.6 添加管理数据库事务的中件

作为最后的例子,让我们添加一种管理数据库事务的中间件类型。我们可以将简单的交易管理抽象成一个 HOF,如下所示:

public static R Transact<R>
   (SqlConnection conn, Func<SqlTransaction, R> f)
{
   using var tran = conn.BeginTransaction();

   R r = f(tran);
   tran.Commit();

   return r;
}

Transact接受一个连接和一个函数f,该函数消耗事务。假设f涉及多个需要原子执行的数据库操作。由于using声明是如何被解释的,如果f抛出异常,事务将被回滚。以下列表提供了一个将Transact集成到管道中的示例。

列表 14.8 提供连接和事务管理的管道

Middleware<SqlConnection> Connect(ConnectionString connString)    ❶
   => f => ConnectionHelper.Connect(connString, f);

Middleware<SqlTransaction> Transact(SqlConnection conn)           ❶
   => f => ConnectionHelper.Transact(conn, f);

Func<Guid, int> DeleteOrder(ConnectionString connString)          ❷
   => (Guid id) =>
{
   SqlTemplate deleteLinesSql = "DELETE OrderLines WHERE OrderId = @Id";
   SqlTemplate deleteOrderSql = "DELETE Orders WHERE Id = @Id";

   object param = new { Id = id };

   Middleware<int> deleteOrder =
      from conn in Connect(connString)
      from tran in Transact(conn)
      select conn.Execute(deleteLinesSql, param, tran)
           + conn.Execute(deleteOrderSql, param, tran);

   return deleteOrder.Run();
};

❶ 将现有 HOFs 转换为Middleware的适配器

❷ 连接字符串被注入。

ConnectTransact简单地将现有的 HOFs 包装成MiddlewareDeleteOrder以 curried 形式编写,这样我们可以在启动时提供连接字符串,并在运行时提供要删除的订单 ID,如第 9.4 节所述。现在看看有趣的片段——声明为deleteOrder的中间件管道:

  • Connect定义了一个块,它创建(然后处置)连接。

  • Transact定义了另一个块,它消耗连接并创建(然后处置)一个事务。

  • select子句中,我们有两个使用连接和事务的数据库操作,因此将原子执行。因为Execute返回一个int(受影响的行数),我们可以使用+来组合这两个操作。

正如你在前面的章节中已经看到的,正在删除的订单的Guid用于填充param对象的Id字段,因此它替换了 SQL 模板字符串中的@Id标记。

一旦中间件函数设置完成,添加或从管道中移除一个步骤只需一行更改。如果你正在记录时间信息,你只想记录数据库操作的时间,还是也想记录获取连接所需的时间?无论哪种情况,你都可以通过简单地更改管道中中间件的顺序来更改它,如下面的代码片段所示:

|

from _ in Time("slowQuery")    ❶
from conn in Connect
select conn.Execute(mySlowQuery)

❶ 获取连接的时间计入将被记录的时间。 |

from conn in Connect
from _ in Time("slowQuery")      ❷
select conn.Execute(mySlowQuery)

❷ 只有数据库操作被计时。 |

LINQ 查询的扁平布局使得查看和更改中间件函数的顺序变得容易。当然,这个解决方案也避免了“灾难金字塔”。尽管我已经使用了中间件的想法和相对特定的数据库访问场景来展示它,但连续性的概念更广泛,适用于任何这种形式的函数:⁴

(T → R) → R

这也意味着我们可以避免定义自定义委托MiddlewareMapBindRun的定义与这个场景无关,我们可以使用Func<Func<T, dynamic, dynamic>>而不是Middleware<T>。这甚至可能节省几行代码,因为它消除了创建正确类型委托的需求。我选择Middleware作为一个更明确的、特定领域的抽象,但这只是个人偏好。

在本章中,你看到了基于委托的 monads 如TryMiddleware如何提供强大和表达性的结构。它们允许我们优雅地处理一般问题,如异常处理,以及更具体的场景,如中间件管道。我们将在第十五章中探讨更多场景。

摘要

  • 惰性意味着将计算推迟到结果需要时。当结果最终可能不需要时,这特别有用。

  • 惰性计算可以组合成更复杂的计算,然后按需触发。

  • 当处理基于异常的 API 时,您可以使用 Try 委托类型。Run 函数安全地执行 Try 中的代码,并将结果封装在 Exceptional 中返回。

  • 形式为 (T → R) → R 的 HOF(接受回调或 延续 的函数)也可以单调组合,使您能够使用扁平 LINQ 表达式而不是深层嵌套的回调。


¹ 这是因为 C# 是一种具有严格或贪婪求值的语言(表达式在绑定到变量时立即求值)。尽管严格求值更为常见,但也有一些语言,特别是 Haskell,使用惰性求值,因此表达式仅在需要时才求值。

² 在文献中,这被称为 writer monad,对于可以始终组合成单个实例的类型称为 monoids

³ 这是因为在计算 exp2 时,我们首先计算 mw.Map(cont),这是将 cont 与最终将提供的延续组合。然后,通过调用 Run,我们提供恒等函数作为延续。得到的延续是 cont 和恒等函数的组合,这与提供 cont 作为延续完全相同。

⁴ 在文献中,这被称为 延续 monad,这再次是一个误称,因为这里的 monad 不是延续,而是接受延续作为输入的计算。

15 有状态的程序和有状态的计算

本章涵盖

  • 什么是使程序有状态的原因?

  • 不突变状态编写有状态的程序

  • 生成随机结构

  • 编写有状态的计算

从第一章开始,我就一直在反对将状态突变作为一种副作用,几乎在任何情况下都应该避免,你已经看到了几个重构程序以避免状态突变的例子。在本章中,你将看到当保持状态是需求而不是程序实现细节时,函数方法是如何工作的。

但究竟什么是有状态的程序呢?它是一个其行为会根据过去的输入或事件而不同的程序。¹ 通过类比,如果有人对你说,“早上好”,你可能会无意识地回应他们。如果那个人立刻又说,“早上好”,你的反应肯定会不同:为什么有人会连续两次说“早上好”?另一方面,无状态的程序会像之前一样无意识地继续回答“早上好”,因为它没有过去输入的概念。每次都像第一次一样。

在本章中,你将看到如何在有状态的函数程序中调和两个看似矛盾的想法——在内存中保持状态和避免状态突变。然后,你将看到如何使用第十四章中学到的技术来组合处理状态的函数。

15.1 管理状态的程序

在本节中,你将看到一个简单的命令行程序,它允许用户查找外汇汇率(FX 汇率)。与程序的一个示例交互如下(粗体字母表示用户输入):

Enter a currency pair like 'EURUSD', or 'q' to quit
usdeur
fetching rate...
0.9162
gbpusd
fetching rate...
1.2248
q

如果你已经下载了代码示例,你可以亲自尝试:

cd Examples
dotnet run CurrencyLookup_Stateless

以下列表显示了一个初始的无状态实现。

列表 15.1 简单查找外汇汇率的程序的无状态实现

WriteLine("Enter a currency pair like 'EURUSD', or 'q' to quit");
for (string input; (input = ReadLine().ToUpper()) != "Q";)
   WriteLine(RatesApi.GetRate(input));
static class RatesApi
{
   public static decimal GetRate(string ccyPair)
   {
      WriteLine($"fetching rate...");
      // ...                           ❶
    }
}

❶ 执行网络请求以获取请求的汇率

你可以忽略RatesApi.GetRate的实现细节;我们关心的是它接受一个货币对标识符,例如 EURUSD(欧元/美元),并返回汇率。

程序可以工作,但如果你反复请求相同的货币对,它每次都会执行 HTTP 请求。你可能有很多原因想要避免不必要的远程请求,比如性能、网络使用或每次请求产生的成本。接下来,我们将介绍一个内存缓存来避免查找我们之前已经检索到的汇率。

15.1.1 在内存中缓存数据

当我们检索汇率时,我们想在缓存中存储它们,并且只为之前未请求的汇率进行 HTTP 请求,如图 15.1 所示。(在实践中,你希望存储在缓存中的值在一段时间后过期,但为了集中精力在保持状态的本质方面,我将忽略这一要求。)

图 15.1 保持之前检索到的汇率的缓存

当然,作为函数式程序员,我们希望在不进行状态变更的情况下完成这项工作。程序的状态类型将是什么?字典将是一个自然的选择,将每个对标识符(如 EURUSD)映射到相应的汇率。为了确保我们不修改它,让我们将其制作为一个不可变字典:ImmutableDictionary<string, decimal>。由于这是一个相当丑陋的类型,我们将它别名为 Rates 以使代码更简洁。

以下列表提供了一个实现,它将已检索的汇率存储在缓存中,并且仅在汇率之前未检索到时才调用远程 API。它这样做而不进行状态变更。

列表 15.2 保持汇率缓存的带状态实现

using Rates = System.Collections.Immutable              ❶
   .ImmutableDictionary<string, decimal>;               ❶

public class Program
{
   public static void Main()
   {
      WriteLine("Enter a currency pair like 'EURUSD', or 'q' to quit");
      MainRec(Rates.Empty);                             ❷
   }

   static void MainRec(Rates cache)
   {
      var input = ReadLine().ToUpper();
      if (input == "Q") return;

      var (rate, newState) = GetRate(input, cache);     ❸
      WriteLine(rate);
      MainRec(newState);                                ❹
   }

   static (decimal, Rates) GetRate(string ccyPair, Rates cache)
   {
      if (cache.ContainsKey(ccyPair))                   ❺
         return (cache[ccyPair], cache);                ❺

      var rate = RatesApi.GetRate(ccyPair);             ❻

      return (rate, cache.Add(ccyPair, rate));          ❼
   }
}

❶ 程序状态的易读名称

❷ 设置初始状态并将控制权传递给 MainRec

❸ 同时获取结果以及新状态

❹ 递归地以新状态调用自身

❺ 如果可用,则使用缓存的速率

❻ 执行网络请求

❼ 返回一个包含检索到的汇率和程序更新状态的元组

看看两个 GetRate 函数的签名:

RatesApi.GetRate : string → decimal
Program.GetRate  : string → Rates → (decimal, Rates)

第一个签名是无状态版本;第二个是有状态版本。后者还接受(连同请求的货币对)程序的当前状态,并返回(连同结果汇率)程序的新状态。

重要提示:如果全局变量不能被修改,你必须通过参数传递状态并通过返回值返回。这是编写无状态应用程序而不进行修改的关键。

让我们现在转到 MainRec(用于递归),它包含程序的基本控制流程。这里要注意的是,它接受程序当前状态作为输入参数,并将其传递给 GetRate 以检索新状态(连同打印的汇率)。它通过以新状态调用自身结束。

最后,Main 仅通过程序的初始状态调用 MainRec,该初始状态是一个空的缓存。你可以将整个程序执行视为一个循环,其中 MainRec 递归地调用自身,并将当前状态版本作为参数传递。

注意,尽管程序中没有全局变量,但它仍然是一个有状态程序。程序在内存中保持一些状态,这会影响程序的操作方式。

通常来说,递归在 C# 中是一个风险业务,因为如果进行了超过约 10,000 次递归调用,它可能会崩溃堆栈。如果你想避免递归定义,可以使用循环代替。以下列表显示了重写的 Main 方法,使用循环。

列表 15.3 将递归函数转换为循环

public static void Main()
{
   WriteLine("Enter a currency pair like 'EURUSD', or 'q' to quit");
   var state = Rates.Empty;                                          ❶

   for (string input; (input = ReadLine().ToUpper()) != "Q";)
   {
      var (rate, newState) = GetRate(input, state);
      state = newState;                                              ❷
      WriteLine(rate);
   }
}

❶ 初始状态

❷ 为下一次迭代重新分配状态变量

在这里,我们不是进行递归调用,而是保持一个局部可变变量 state,根据需要将其重新分配为新状态。我们没有修改任何全局状态,所以基本思想仍然成立。

在本章的剩余示例中,我将坚持使用递归版本,我认为它更简洁。在实际应用中,你将想要使用迭代版本以避免栈溢出。

15.1.2 测试性和错误处理的重构

你已经看到了如何创建一个不需要突变的状态程序。在继续之前,我想对程序进行一些改进,以便说明一些关于测试性和错误处理的想法,这些想法你在前面的章节中已经看到了。

你会注意到,尽管在状态突变方面没有副作用,但到处都有 I/O 副作用,所以程序根本不可测试。我们可以重构 GetRate,使其接受执行 HTTP 请求的函数作为输入参数,遵循第三章中解释的模式:

static (decimal, Rates) GetRate
   (Func<string, decimal> getRate, string ccyPair, Rates cache)
{
   if (cache.ContainsKey(ccyPair))
      return (cache[ccyPair], cache);
   var rate = getRate(ccyPair);
   return (rate, cache.Add(ccyPair, rate));
}

现在 GetRate 除了通过调用给定的委托 getRate 可能产生的副作用外,没有其他副作用。因此,通过提供一个具有可预测行为的委托,可以轻松地对这个函数进行单元测试。MainRec 同样可以通过注入要调用的函数来进行测试。

接下来,没有任何错误处理:如果你输入一个不存在的货币对名称,程序会崩溃。让我们充分利用 Try。首先,我们将无状态的 GetRate 方法包装在 Try 中:

static class RatesApi
{
   public static Try<decimal> TryGetRate(string ccyPair)   ❶
      => () => GetRate(ccyPair);

   static decimal GetRate(string ccyPair) // ...           ❷
}

❶ 安全函数返回一个 Try

❷ 不安全版本的工作方式与之前相同。

有状态的计算 Program.GetRate 方法现在必须更改其签名,不再接受返回 decimal 的函数,而是接受 Try<decimal>。相应地,其返回类型也将被 Try 包装。以下是修改前后的签名:

before : (string → decimal) → string → (decimal, Rates)
after  : (string → Try<decimal>) → string → Try<(decimal, Rates)>

以下列表显示了重构后的实现。

列表 15.4 使用 Try 进行错误处理的程序重构

public class Program
{
   public static void Main()
      => MainRec("Enter a currency pair like 'EURUSD', or 'q' to quit"
         , Rates.Empty);

   static void MainRec(string message, Rates cache)
   {
      WriteLine(message);

      var input = ReadLine().ToUpper();
      if (input == "Q") return;

      GetRate(RatesApi.TryGetRate, input, cache).Run().Match
      (
        ex => MainRec($"Error: {ex.Message}", cache),
        result => MainRec(result.Rate.ToString(), result.NewState)
      );
   }

   static Try<(decimal Rate, Rates NewState)> GetRate
      (Func<string, Try<decimal>> getRate, string ccyPair, Rates cache)
   {
      if (cache.ContainsKey(ccyPair))
         return Try(() => (cache[ccyPair], cache));
      else return from rate in getRate(ccyPair)
         select (rate, cache.Add(ccyPair, rate));
   }
}

你可以亲自尝试它:

dotnet run CurrencyLookup_StatefulSafe

这里是与程序的一个示例交互:

Enter a currency pair like 'EURUSD', or 'q' to quit
eurusd
fetching rate...
1.2066
eurusd
1.2066                                                           ❶
rubbish
fetching rate...
Error: The given key 'BISH' was not present in the dictionary.   ❷
q

❶ 返回缓存的汇率

❷ 优雅地处理错误

注意我们如何能够相对轻松地添加测试性和错误处理,而没有通过接口、try-catch 语句等来膨胀实现。相反,我们有了更强大的函数签名,以及通过参数传递的函数之间更明确的关系。

15.1.3 有状态的计算

正如你在本节中看到的,如果你想以函数方式(不进行状态突变)处理状态,必须将状态作为输入参数提供给函数,并且影响状态的函数必须将其结果作为它们结果的一部分返回更新后的状态。本章的剩余部分将专注于 有状态的计算,这些是与其他状态交互的函数。

注意 有状态的计算 是那些接受一个状态(以及可能的其他参数)并返回一个新的状态(以及可能的一个返回值)的函数。它们也被称为 状态转换

状态计算可能出现在有状态和无状态的程序中。你已经看到了一些例子。在前一个场景中,GetRate 是一个状态计算,因为它接受一些状态(缓存)以及一个货币对,并返回更新后的状态以及请求的汇率。在第十三章中,静态的 Account 类只包含状态计算,每个计算都接受一个 AccountState(以及一个命令)并返回一个新的 AccountState(以及一个用于存储的事件),尽管在这种情况下,由于结果被包裹在一个 Validation 中,事情变得稍微复杂了一些。

如果你想组合几个状态计算(总是将状态传递给函数的过程),从结果中提取它并将其传递给下一个函数可能会变得相当繁琐。幸运的是,状态计算可以通过单子方式组合,从而隐藏状态传递,正如你接下来将看到的。

本章的其余部分包含高级内容,这些内容对于理解以下章节不是必需的,以防你决定跳到下一章。

15.2 用于生成随机数据的语言

随机数据有许多合法的实际用途,包括基于属性的测试(我在第十章中讨论过)、负载测试(你生成大量的随机数据,然后对你的系统进行轰炸,以查看其表现如何),以及蒙特卡洛等模拟算法。在这种情况下,我主要感兴趣的是将随机生成作为状态计算组合的一个很好的入门示例。要开始,请在 REPL 中输入以下内容:

var r = new Random(100);
r.Next() // => 2080427802
r.Next() // => 341851734
r.Next() // => 1431988776

由于你明确地将值 100 作为随机生成器的种子传递,你应该得到完全相同的结果。正如你所见,它并不像想象中那么随机。在目前的计算机中,要得到真正的随机性几乎是不可能的;相反,我们使用伪随机生成器,它使用一个打乱算法来决定性地产生一个看起来随机的输出。通常情况下,你不想每次都得到相同的值序列,因此 Random 实例通常在没有显式种子的情况下初始化;在这种情况下,使用当前时间。

如果 Random 是确定性的,它是如何每次调用 Next 时产生不同的输出呢?答案是 Random 是有状态的:每次你调用 NextRandom 实例的状态都会更新。换句话说,Next 有副作用。

Next 在没有输入参数的情况下被调用,并返回一个 int 类型的显式输出。但是它有一个隐式输入(Random 实例的当前状态),这个输入决定了输出,以及另一个隐式输出,即 Random 实例的新状态。这将反过来决定下一次调用 Next 的输出。

我们将创建一个无副作用的随机生成器,其中所有输入和输出都是明确的。生成一个数字是一个有状态的计算,因为它需要一个种子,并且还必须生成一个新的种子用于后续的生成。我们不想只生成整数,而是任何类型的值,因此生成器函数的类型可以用以下委托来捕获:

public delegate (T Value, int Seed) Generator<T>(int seed);

Generator<T> 是一个有状态的计算,它接受一个 int 值作为种子(状态)并返回一个由生成的 T 和一个新种子组成的元组,该种子可以用于生成后续的值。在箭头符号中,Generator<T> 的签名如下

int → (T, int)

要运行一个生成器,我们可以定义以下 Run 方法:

public static T Run<T>(this Generator<T> gen, int seed)
  => gen(seed).Value;

public static T Run<T>(this Generator<T> gen)
  => gen(Environment.TickCount).Value;

第一个重载使用给定的种子运行生成器并返回生成的值,忽略状态。第二个重载使用时钟在每次调用时获取不同的种子值(因此它是非纯净的,不可测试,与第一个重载不同)。接下来,让我们创建一些生成器。

15.2.1 生成随机整数

我们需要的基本构建块是一个将种子值打乱成新的 int 的生成器。以下列表显示了一种可能的实现。

列表 15.5 返回伪随机数的有状态计算

public static Generator<int> NextInt = (seed) =>
{
    seed ^= seed >> 13;
    seed ^= seed << 18;
    int result = seed & 0x7fffffff;
    return (result, result);
};

这是一个生成器,当给定一个种子时,将其打乱以获得另一个看似无关的整数。² 然后,它将此值作为结果值和用于后续计算的种子返回。

当你想生成更复杂的值时,事情开始变得有趣。结果证明,如果你可以生成一个随机的 int,你可以为任意复杂的类型生成随机值。但让我们从小步开始:知道你可以生成一个随机的 int,你将如何编写一个布尔类型等更简单类型的生成器?

15.2.2 生成其他原始类型

记住,生成器接受一个种子并返回一个新值(在这种情况下,生成的布尔值)以及一个新种子。Generator<bool> 的骨架如下:

public static Generator<bool> NextBool = (seed) =>
{
   bool result = // ???
   int newSeed = // ???
   return (result, newSeed);
};

我们如何实现它?我们已经有了一个 int 的生成器,因此我们可以生成一个 int 并根据它是偶数还是奇数返回 true/false。我们还需要返回一个新的种子,为此,我们可以利用生成 int 时计算的新种子。本质上,我们正在使用 NextInt,将结果 int 转换为 bool 并重用种子。图 15.2 说明了这一点。

图 15.2 使用 NextInt 生成器生成布尔值

实现方式如下:

public static Generator<bool> NextBool = (seed) =>
{
   var (i, newSeed) = NextInt(seed);
   return (i % 2 == 0, newSeed);
};

现在,让我们换一种不同的方式来思考这个问题。我们在这里所做的是有效地映射一个函数,该函数将 int 转换为 bool,同时重用现有 NextInt 生成器返回的新种子。我们可以将这个模式推广到定义 Map:如果你有一个 Generator<T> 和一个函数 f : T R,你可以获得一个 Generator<R>,如下所示:运行生成器以获得一个 T 和一个新种子;应用 f 以获得一个 R;返回结果 R 以及新种子。以下列表显示了 Map 的实现。

列表 15.6 Generator<T>Map 定义

public static Generator<R> Map<T, R>
(
   this Generator<T> gen,
   Func<T, R> f
)
=> seed =>                          ❶
{
    var (t, newSeed) = gen(seed);   ❷
    return (f(t), newSeed);         ❸
};

Map 返回一个生成器,当给定一个种子...

❷ ... 运行给定的生成器 gen 以获得一个 T 和一个新种子...

❸ ... 然后使用 fT 转换为 R,并将其与新种子一起返回。

我们现在可以更简洁地定义携带比 int 少的信息的类型生成器(例如 boolchar),如下所示。

列表 15.7 基于 NextInt 生成其他类型

public static Generator<bool> NextBool =>
   from i in NextInt                         ❶
   select i % 2 == 0;                        ❷

public static Generator<char> NextChar =>
   from i in NextInt
   select (char)(i % (char.MaxValue + 1));

❶ 生成一个 int...

❷ ... 返回它是否为偶数

这更易于阅读,因为我们不必显式地担心种子,我们可以用“生成一个 int,并返回它是否为偶数”来阅读代码。

15.2.3 生成复杂结构

现在,让我们继续看看我们如何生成更复杂的价值。让我们尝试生成一对整数。我们不得不写点像这样的事情:

public static Generator<(int, int)> PairOfInts = (seed0) =>
{
    var (a, seed1) = NextInt(seed0);
    var (b, seed2) = NextInt(seed1);
    return ((a, b), seed2);
};

在这里,你可以看到对于每个有状态的计算(或者每次我们生成一个随机值),我们需要提取状态(新创建的种子)并将其传递给下一个计算。这相当嘈杂。幸运的是,我们可以通过将生成器与以下列表所示的 LINQ 表达式组合来消除显式的状态传递。

列表 15.8 定义一个生成随机整数对的函数

public static Generator<(int, int)> PairOfInts =>
   from a in NextInt                               ❶
   from b in NextInt                               ❷
   select (a, b);                                  ❸

❶ 生成一个 int 并将其称为 a

❷ 生成另一个 int 并将其称为 b

❸ 返回 ab 的对

这更易于阅读,但底层原理与之前相同。这是因为我已经定义了一个 Bind/SelectMany 的实现,它负责“传递状态”,将状态从一个计算传递到下一个计算。从图形上看,图 15.3 显示了 Bind 的工作方式。列表 15.9 显示了相应的代码。

图片

图 15.3 Generator<T>Bind 定义

列表 15.9 Generator<T>Bind 定义

public static Generator<R> Bind<T, R>
(
   this Generator<T> gen,
   Func<T, Generator<R>> f
)
=> seed0 =>
{
    var (t, seed1) = gen(seed0);
    return f(t)(seed1);
};

现在我们有了所有构建块来生成任意复杂的类型。比如说我们想要创建一个 Option<int>。这很简单——为 Option 的状态生成一个布尔值,并为值生成一个 int

public static Generator<Option<int>> OptionInt =>
   from some in NextBool
   from i in NextInt
   select some ? Some(i) : None;

这看起来很熟悉。当我们在 10.1.3 节中使用 FsCheck 定义属性测试并需要提供一个生成随机 Option 的方法时,你看到了一些类似的代码。实际上,FsCheck 的随机生成器定义与这个类似。

以下列表显示了一个稍微复杂一点的例子,即生成 int 序列。

列表 15.10 生成随机数字列表

public static Generator<IEnumerable<int>> IntList
   => from empty in NextBool
      from list in empty ? Empty : NonEmpty
      select list;

static Generator<IEnumerable<int>> Empty
   => Generator.Return(Enumerable.Empty<int>());

static Generator<IEnumerable<int>> NonEmpty
   => from head in NextInt
      from tail in IntList
      select List(head).Concat(tail);

public static Generator<T> Return<T>(T value)
   => seed => (value, seed);

让我们从顶层 IntList 开始。我们生成一个随机的布尔值来告诉我们序列是否应该为空。³ 如果是,我们使用 Empty,这是一个总是返回空序列的生成器;否则,我们通过调用 NonEmpty 返回一个非空序列。这会生成一个 int 作为第一个元素,以及一个随后的随机序列。注意,Empty 使用 Return 函数为 Generator,它将一个值提升为一个总是返回该值且不影响其状态的生成器。

那么生成一个字符串呢?字符串本质上是一系列字符,因此我们可以生成一个 int 列表,将每个 int 转换为 char,并从生成的字符序列中构建一个字符串。正如你所见,我们遵循这种方法来生成一个将各种类型的生成器组合成任意复杂类型生成器的语言。

15.3 状态计算的通用模式

在许多其他场景中,我们可能想要组合多个状态计算,而不仅仅是生成随机值。为此,我们可以使用一个更通用的委托,StatefulComputation

delegate (T Value, S State) StatefulComputation<S, T>(S state);

StatefulComputation<T> 是这种形式的函数:

S → (T, S)

T 是函数的结果值,S 是状态。⁴ 你可以将它与 Generator<T> 的签名进行比较,看看它们有多么相似:

StatefulComputation<T> : S   → (T, S)
Generator<T>           : int → (T, int)

使用 Generator,传入和传出的状态始终是一个 int。在更通用的 StatefulComputation 中,状态可以是任意类型 S。因此,我们可以以相同的方式定义 MapBind(唯一的区别是额外的类型参数)并让它们负责在计算之间传递状态。

在第十一章中,我们讨论了树,你看到了如何定义一个 Map 函数,该函数创建一个新的树,其中每个元素都是将函数应用于原始树中的每个值的结果。想象一下,你现在想给每个元素分配一个数字,如图 15.4 所示。

图 15.4 树中每个元素的编号

这个操作与 Map 类似,因为你必须遍历树并对每个元素应用一个函数。但除此之外,你还必须保持一些状态(一个计数器值),这个值在访问每个元素时需要递增,并用于标记每个叶子节点。

让我们先定义一个 Numbered<T> 类型,它封装了一个 T 和一个数字:

public record Numbered<T>(T Value, int Number);

这意味着我们试图表示的操作可以表示为从 Tree<T>Tree<Numbered<T>> 的函数。

以下列表显示了一个初始实现,它遍历树,显式地传递状态(计数器值)。

列表 15.11 通过显式传递状态对树的叶子节点进行编号

using LaYumba.Functional.Data.BinaryTree;

public Tree<Numbered<T>> Number<T>(Tree<T> tree)
   => Number(tree, 0).Tree;                        ❶

(Tree<Numbered<T>> Tree, int Count) Number<T>
(
   Tree<T> tree,
   int count
)
=> tree.Match
(
   Leaf: t =>
      (
         Tree.Leaf(new Numbered<T>(t, count)),     ❷
         count + 1                                 ❸
       ),

   Branch: (l, r) =>
   {
      var (left, count1) = Number(l, count);       ❹
      var (right, count2) = Number(r, count1);     ❹
      return (Tree.Branch(left, right), count2);   ❺
   }
);

❶ 调用状态重载,传入 0 作为初始状态

❷ 使用当前计数标记这个叶子

❸ 返回增加后的计数作为新状态

❹ 递归地在左右子树上调用Number

❺ 返回更新后的新树

我们从计数为 0 的计算开始。编号函数简单地根据树类型进行匹配。如果是叶子,则包含一个T,因此Number返回一个对,其中结果是一个Numbered<T>(包装T和当前计数),新状态是增加后的计数器。如果是分支,则我们在左右子树上递归调用Number。因为这些操作中的每一个都返回一个更新后的状态,我们必须将状态传递下去,并在结果值中返回它。

虽然我发现前面的解决方案令人满意,但手动传递状态确实引入了一些噪声。我们可以通过重构代码以使用StatefulComputation委托来消除这一点。

我们首先定义一个简单的有状态计算,它接受一个int(状态,在这种情况下是计数器)并返回计数器作为值和增加后的状态作为新状态:

static StatefulComputation<int, int> GetAndIncrement
   = count => (count, count + 1);

GetAndIncrement(0) // => (0, 1)
GetAndIncrement(6) // => (6, 7)

记住,有状态计算返回一个和一个新状态GetAndIncrement返回当前计数器值作为返回值和增加后的计数器作为新状态。

GetAndIncrement的有趣之处在于它允许你查看状态:因为当前的计数器值成为计算的内部值,你可以在 LINQ 表达式中引用它。你可以在以下代码中看到这一点,我们将当前计数值分配给count变量。

以下列表显示了如何使用 LINQ 重写我们的树编号函数,以处理传递状态。

列表 15.12 使用 LINQ 对树叶子进行编号

StatefulComputation<int, Tree<Numbered<T>>> Number<T>
(
   Tree<T> tree
)
=> tree.Match
(
   Leaf: t =>
      from count in GetAndIncrement                  ❶
      select Tree.Leaf(new Numbered<T>(t, count)),   ❷

   Branch: (left, right) =>
      from newLeft in Number(left)
      from newRight in Number(right)
      select Tree.Branch(newLeft, newRight)
);

❶ 将当前计数分配给count变量,同时将增加后的计数分配给状态

❷ 结果是一张包含原始叶子值的新叶子,编号为当前计数。

如你所见,当你像Branch情况那样组合一系列有状态计算时,LINQ 确实可以改善可读性。否则,我发现显式传递状态更清晰。请注意,前面的函数返回一个计算,它直到被赋予输入状态之前什么都不做:

Number(tree).Run(0)

虽然有状态的计算无处不在,但需要链式调用多个计算的情况并不常见。然而,在特定领域,如模拟或解析器中,这种情况却经常出现。例如,一个功能解析器通常被建模为一个函数,该函数接受一个字符串(状态),消耗字符串的一部分,并产生一个结果,该结果由已解析的结构化表示和剩余待解析的字符串(新状态)组成。

摘要

  • 当编写有状态程序时,你可以通过始终显式地将状态作为函数的输入和输出的一部分来避免作为副作用改变状态。

  • 状态计算是形式为 S (T, S) 的函数。它们接受一些状态,并返回一个值以及更新后的状态。

  • 状态计算可以通过单调组合来减少从一次计算传递到下一次计算的状态的语法负担。


¹ 这意味着一个程序可能被认为是具有状态/无状态的,这取决于你如何划分程序边界。你可能有一个无状态的服务器,它使用数据库来保持状态。如果你将两者视为一个程序,那么它是具有状态的;如果你单独考虑服务器,那么它是无状态的。

² 算法的具体细节对于本次讨论的目的来说并不重要。有许多生成伪随机数的算法。

³ 这意味着,从统计学的角度来看,一半生成的列表将是空的,四分之一的列表将有一个元素,依此类推,因此这个生成器不太可能产生一个长的列表。你可以采取不同的方法,首先生成一个随机长度,假设在给定的范围内,然后填充值。正如这所显示的,一旦你开始生成随机数据,定义控制随机生成的参数就很重要。

⁴ 在函数式编程(FP)的术语中,这被称为状态单子。这是一个真正糟糕的名称,用来描述一个接受某些状态作为参数的函数。这个不幸的名称可能是理解它的最大障碍。

16 使用异步计算

本章涵盖

  • 使用 Task 来表示异步计算

  • 顺序和并行地组合异步操作

  • 与异步序列一起工作

在当今分布式应用程序的世界中,许多操作都是异步执行的。一个程序可以开始一些相对耗时的操作,例如从另一个应用程序请求数据,但它不会空闲等待该操作完成。相反,它会继续做其他工作,一旦收到数据就恢复操作。

异步操作无疑是当今程序员的日常工作。我之所以等到本书的后期才处理这个问题,是因为它增加了一个我希望推迟以使之前提出的思想更易于理解的复杂性层次。

在 C# 中,异步操作使用 Task 来表示,在本章中,你将看到 Task<T> 与其他容器(如 Option<T>Try<T> 等)并没有太大的不同。虽然 Task<T> 表示一个异步交付的单个值,但 IAsyncEnumerable<T> 是语言中最近添加的一个用于表示异步交付值序列的新增功能。我将在本章的第二部分讨论这一点。

16.1 异步计算

在本节中,我将首先介绍异步的需求以及我们如何使用 Task 来模拟值的异步交付。然后你会看到 Task<T> 只是 T 的另一个容器,因此支持像 MapBind 这样的操作。然后我们将讨论与 Task 一起工作时经常出现的一些常见问题:组合多个异步操作、处理失败、执行多次重试以及并行运行任务。

TaskValueTask

BCL 还包括 ValueTask,它是一个值类型,在用法上与 Task 类似。建议在以下情况下异步方法返回 ValueTask 而不是 Task

  • 它在热点路径上被调用,因此性能至关重要。

  • 它可能涉及异步操作,但通常同步完成;例如,从文件或远程 API 读取并缓存检索到的数据的方法。

在这种情况下,使用 ValueTask 更有效率,因为你没有在堆上分配 Task。在本章的目的上,当你发现提到 Task 时,这个想法也适用于 ValueTask。要了解更多关于 ValueTask 以及它与 Task 的区别,请访问 youtu.be/fj-LVS8hqIE,听听其创造者 Stephen Toub 的介绍。

16.1.1 异步的需求

有些操作比其他操作耗时更长——长得多!典型的计算机指令执行所需的时间在纳秒级别,而像从文件系统读取或发起网络请求这样的 I/O 操作所需的时间在毫秒或甚至秒级别。

为了更好地理解差异有多大,让我们将事物放大到更符合人类的情况:如果一个内存中的指令,比如加法运算,需要大约一秒钟,那么典型的 I/O 操作将需要数月或数年。在现实生活中,你可以在水冷却器旁等待几秒钟,直到你的杯子被填满,但你不会在银行等待数周,直到你的抵押贷款申请被处理。相反,你会提交你的申请,回到你的日常生活中,并期待在未来的某个时刻收到结果通知。

图片

图 16.1 当一个操作可以快速完成时,我们愿意等待操作完成,停止其他工作。这就是同步代码的工作方式。

图片

图 16.2 当我们发起一个需要很长时间才能完成的操作时,我们继续做其他工作,期待在操作完成时得到通知。这就是异步代码的工作方式。

这就是异步计算背后的思想:启动一个耗时较长的操作,继续做其他工作,然后在操作完成时返回。

16.1.2 使用 Task 表示异步操作

自从 C# 4 以来,处理异步计算的主要工具是基于任务的异步模式(TAP)。我假设你已经在某种程度上熟悉它;如果不是,你可以在网上找到大量的文档。简而言之,它包括以下内容:

  • 使用TaskTask<T>来表示异步操作。

  • 使用await关键字等待Task,这样就可以在异步操作完成的同时释放当前线程去做其他工作。

例如,在第 15.1 节中,我们讨论了一个从网络获取外汇汇率的程序。下面的列表显示了执行网络请求的实际代码,我之前省略了它。

列表 16.1 在网络调用完成时阻塞当前线程

public static decimal GetRate(string ccyPair)
{
   Task<string> request = new HttpClient()
      .GetStringAsync(UriFor(ccyPair));

   string body = request.Result;         ❶

   var response = JsonSerializer.Deserialize<Response>(body, opts);
   return response.ConversionRate;
}

record Response(decimal ConversionRate);

const string ApiKey = "1a2419e081f5940872d5700f";

static string UriFor(string ccyPair)
{
   var (baseCcy, quoteCcy) = ccyPair.SplitAt(3);
   return $"https://v6.exchangerate-api.com/v6/{ApiKey}"
      + $"/pair/{baseCcy}/{quoteCcy}";
}

static readonly JsonSerializerOptions opts = new()
   { PropertyNamingPolicy = new SnakeCaseNamingPolicy() };

❶ 调用Result会阻塞线程直到操作完成。

让我们通过GetRate的代码来了解远程 API 调用。我们调用UriFor来计算检索所需外汇汇率的 URI,然后使用HttpClient执行 API 查询。

当在结果Task上调用Result时,当前线程会暂停并等待从远程 API 接收响应。然后它将响应体反序列化为适当的类型,并提取所需的汇率。

在等待响应到达时阻塞是简单的控制台应用程序可以接受的,但对于大多数现实世界的应用程序来说,无论是客户端还是服务器,这都是不可接受的。在等待网络调用完成时没有必要阻塞线程。

你可以将GetRate重构为异步执行请求,如下面的列表所示。

列表 16.2 使用Task表示异步操作

public static async Task<decimal>           ❶
   GetRateAsync(string ccyPair)             ❷
{
   Task<string> request = new HttpClient()
      .GetStringAsync(UriFor(ccyPair));

   string body = await request;             ❸

   var response = JsonSerializer.Deserialize<Response>(body, opts);
   return response.ConversionRate;
}

❶ 该方法具有async修饰符并返回一个Task

❷ 按照惯例,方法名带有Async后缀。

await 会在操作完成之前释放当前线程。

注意变化:

  • 方法现在返回的不是 decimal,而是一个 Task<decimal>

  • await 暂停当前上下文(释放线程以执行其他工作),当异步操作完成且其结果可用时,上下文会恢复。

  • 当你在方法体中使用 await 时,必须标记方法为 async。¹

  • 按照惯例,返回 Task 的方法以 Async 后缀命名。²

到目前为止,没有惊喜。现在让我们从更函数式的方法来看 Task<T>

16.1.3 将 Task 作为未来值的容器

从我们在本书中构建的视角来看,将 Task<T> 视为 T 的另一个容器是很自然的。如果 Option<T> 可以被视为可能包含 T 的盒子,而 Func<T> 可以被视为可以运行以获取 T 的容器,那么 Task<T> 可以被视为一个容器,其中 T 将在未来的某个时刻实现。因此,Task<T> 是一个添加异步效应的构造。

注意:再次强调,非泛型的 Task 和泛型的 Task<T> 之间存在令人烦恼的二分法,分别代表产生 voidT 的异步操作。

在本章中,我将始终使用一个返回值(至少是 Unit),所以即使我为了简洁而写 Task,你也应该将其理解为 Task<T>

为了将 Task 作为容器的想法放入代码中,我定义了它的 ReturnMapBind 函数。这些函数有效地使 Task<T> 成为 T 上的单子,它们的实现如下所示。

列表 16.3 以 await 的方式定义 MapBind

public static Task<T> Async<T>(T t)
   => Task.FromResult(t);

public static async Task<R> Map<T, R>
   (this Task<T> task, Func<T, R> f)
   => f(await task);
public static async Task<R> Bind<T, R>
   (this Task<T> task, Func<T, Task<R>> f)
    => await f(await task);

我将使用 Async 作为 TaskReturn 函数,它将 T 提升到 Task<T>。这仅仅是 .NET 的 Task.FromResult 方法的简写。

注意如何轻松地使用 await 关键字来定义 MapBind。为什么这么容易呢?记住,Map 会从容器中提取内部值(值),应用给定的函数,并将结果重新包装回容器中。但正是这种解包和包装正是 await 语言特性所做的事情:它提取 Task 的内部值(操作完成时返回的值),当方法中包含 await 时,其结果会自动包装在一个 Task 中。³ 对于 Map 来说,剩下的就是将给定的函数应用到等待的值上。

Bind 类似。它等待给定的 Task<T>,当它完成时,结果 T 可以提供给绑定的函数。这反过来又返回一个 Task<R>,在获得所需的结果类型 R 之前,也必须等待它。

我按照同样的方式实现了 Task 的 LINQ 查询模式,因此你可以使用 LINQ 简述重写 GetRateAsync 函数,如下所示。

列表 16.4 使用 Task 的 LINQ 简述

public static Task<decimal> GetRateAsync(string ccyPair) =>
   from body in new HttpClient().GetStringAsync(UriFor(ccyPair))
   let response = JsonSerializer.Deserialize<Response>(body, opts)
   select response.ConversionRate;

在列表 16.4 的 LINQ 表达式中,from子句取Task的内部值并将其绑定到变量body(更普遍地,当你看到像from s in m这样的子句时,你可以将其读作,“提取m的内部值并将其称为s,然后……”);这正是await所做的。区别在于await是针对Task的,而 LINQ 表达式可以与任何单子一起使用。

将此与列表 16.2 进行比较,你会发现它执行了相同的操作。此外,请注意,async修饰符已消失,因为方法体不包含await运算符。

当然,一旦你实现了Bind/SelectMany,你就可以用它来组合多个异步操作。以下列表演示了这一点;为了获得最佳性能,它使用GetStreamAsync而不是GetStringAsyncGetStreamAsync产生一个可以由反序列化器异步消耗的流。

列表 16.5 使用 LINQ 表达式链式执行异步操作

public static Task<decimal> GetRateAsync(string ccyPair) =>
   from str in new HttpClient()
      .GetStreamAsync(UriFor(ccyPair))
   from response in JsonSerializer
      .DeserializeAsync<Response>(str, opts)
   select response.ConversionRate;

这是利用异步性最多且避免在内存中存储响应(在处理大型有效负载时这将是不高效的)的版本。请注意,需要两个from子句,因为我们现在有两个异步操作(如果未使用 LINQ 表达式,则对应于两个await运算符的出现)。

惰性与异步计算的比较

惰性和异步计算都允许你编写“在未来运行”的代码。也就是说,在某个时刻,你的程序定义了如何处理由惰性计算Func<T>或异步计算Task<T>返回的值T,但那些指令随后在稍后的时间执行。

这两个之间也有重要的区别。从定义计算的代码的角度来看

  • 创建一个惰性计算(如FuncTryStatefulComputation等)不会开始计算。实际上,它什么也不做(没有副作用)。

  • 创建Task启动一个异步计算。

从消耗计算结果的代码的角度来看

  • 消耗惰性值的代码“决定”何时运行计算,获取计算值。

  • 消耗异步值的代码无法控制何时将接收到计算值。

16.1.4 处理失败

我提到你可以将Task<T>视为一个添加异步效果的构造。事实上,它还捕获错误处理。因为异步操作通常是 I/O 操作,所以出错的可能性很高。幸运的是,Task<T>也通过StatusException属性提供错误处理。

这很重要。想象一下,你有一个同步计算,并使用Exceptional<T>来模拟可能失败的计算。如果你现在想使计算异步,你不需要Task<Exceptional<T>>,只需要Task<T>

为了看看如何组合各种异步计算的一些示例,让我们看看检索汇率场景的一些稍微复杂的变化。

想象一下,如果你的公司已经购买了 CurrencyLayer 的订阅,这是一家通过 API 提供高质量汇率数据的公司(即,与市场相比延迟较短的 数据)。如果由于某种原因,调用 CurrencyLayer 的 API 失败,你希望回退到 RatesAPI,这是我们迄今为止一直在使用的。首先,假设你定义了两个封装 API 访问的类:

public static class CurrencyLayer
{
   public static Task<decimal> GetRateAsync(string ccyPair) => //...
}
public static class RatesApi
{
   public static Task<decimal> GetRateAsync(string ccyPair) => //...
}

CurrencyLayer的实现与RatesApi类似,但它被调整为适应 CurrencyLayer 的 API,该 API 返回具有不同结构的数据。有趣的部分是结合两次对GetRateAsync的调用。对于这类任务,你可以使用OrElse函数,它接受一个任务和一个在任务失败时使用的回退(这个想法与第十四章中为Option定义的OrElse函数类似):

public static Task<T> OrElse<T>
   (this Task<T> task, Func<Task<T>> fallback)
   => task.ContinueWith(t =>
         t.Status == TaskStatus.Faulted
            ? fallback()
            : Async(t.Result)
      )
      .Unwrap();           ❶

❶ 将Task<Task<T>>扁平化为Task<T>

注意到OrElse假设Task要么失败要么成功。在现实中,C#的Task也支持取消,但这个特性很少使用,并且会使 API 变得复杂,所以在这里我不会处理取消。你可以如下使用OrElse

CurrencyLayer.GetRateAsync(ccyPair)
   .OrElse(() => RatesApi.GetRateAsync(ccyPair))

结果是一个新的Task,如果操作成功,则返回 CurrencyLayer 返回的值,否则返回 RatesAPI 返回的值。

当然,总是有可能两个调用都失败——比如说,网络断开。因此,我们还需要一个函数来指定任务失败时要执行的操作。我将称之为Recover

public static Task<T> Recover<T>
(
   this Task<T> task,
   Func<Exception, T> fallback
)
=> task.ContinueWith(t =>
   t.Status == TaskStatus.Faulted
      ? fallback(t.Exception)
      : t.Result);

你可以如下使用Recover

RatesApi
   .GetRateAsync("USDEUR")
   .Map(rate => $"The rate is {rate}")
   .Recover(ex => $"Error fetching rate: {ex.Message}")

Recover通常在工作流的末尾使用,以指定如果在某个地方发生错误时要执行的操作。你可以像使用Match处理OptionEither一样使用Recover。但是Match是同步的;Task没有可以匹配的内容,因为其状态直到未来某个时刻才可用,所以技术上Recover更像是故障情况下的Map(你可以通过查看其签名来确认这一点)。

定义一个同时处理成功和失败情况的Map的重载也是合理的:

public static Task<R> Map<T, R>
(
   this Task<T> task,
   Func<Exception, R> Faulted,
   Func<T, R> Completed
)
=> task.ContinueWith(t =>
   t.Status == TaskStatus.Faulted
      ? Faulted(t.Exception)
      : Completed(t.Result));

这可以如下使用:

RatesApi.GetRateAsync("USDEUR").Map(
   Faulted: ex => $"Error fetching rate: {ex.Message}",
   Completed: rate => $"The rate is {rate}")

16.1.5 货币转换的 HTTP API

让我们通过编写一个允许客户端将一种货币的金额转换为另一种货币的 API 端点来将这些内容全部组合起来。与该 API 的示例交互如下:

$ curl http://localhost:5000/convert/1000/USD/to/EUR -s
896.9000

$ curl http://localhost:5000/convert/1000/USD/to/JPY -s
103089.0000

$ curl http://localhost:5000/convert/1000/XXX/to/XXX -s
{"message":"An unexpected error has occurred"}

你可以通过调用类似“convert/1000/USD/to/EUR”的 API 来找出 1,000 美元相当于多少欧元。以下是实现方式:

Task<IResult> Convert
(
   decimal amount,
   string baseCcy,
   string quoteCcy
)
=> RatesApi.GetRateAsync(baseCcy + quoteCcy)
   .OrElse(() => CurrencyLayer.GetRateAsync(baseCcy + quoteCcy))   ❶
    .Map(rate => amount * rate)                                    ❷
    .Map
   (
      Faulted: ex => StatusCode(500),                              ❸
       Completed: result => Ok(result)
   );

app.MapGet("convert/{amount}/{baseCcy}/to/{quoteCcy}", Convert);

❶ 回退到二级 API。

❷ 执行汇率转换。

❸ 在失败的情况下指定要执行的操作

当应用程序收到请求时,它调用 CurrencyLayer API 以获取相关汇率。如果失败,它调用 RatesAPI。一旦它有了汇率,它就使用它来计算目标货币中的等价金额。最后,它将成功的结果映射到 200,失败映射到 500。

你可能记得在第八章中提到,一旦你进入了特权世界,你应该尽可能长时间地留在那里。对于Task来说,这一点尤其正确:处于Task的世界意味着编写将在未来运行的代码,因此在这种情况下离开特权世界意味着阻塞线程并等待未来追上。我们几乎从不希望这样做。

注意,处理请求的方法返回一个Task<IResult>:ASP.NET 在Task运行完成时向客户端发送响应,你不需要担心这将在何时发生。在这种情况下,你永远不需要离开Task的特权世界。

16.1.6 如果失败,尝试更多几次

当远程操作(如对 HTTP API 的调用)失败时,失败的原因通常是瞬时的:可能是有连接故障,或者远程服务器正在重启。换句话说,一次失败的操作,如果在几秒或几分钟后再尝试可能会成功。

当操作失败时需要重试的需求,在处理你无法控制的第三方 API 时是一个常见的要求。以下列表展示了一个简单而优雅的解决方案,它执行异步操作,如果失败则重试指定次数。

列表 16.6 使用指数退避重试

public static Task<T> Retry<T>
   (int retries, int delayMillis, Func<Task<T>> start)
   => retries <= 0
      ? start()                                                ❶
      : start().OrElse(() =>
         from _ in Task.Delay(delayMillis)                     ❷
         from t in Retry(retries - 1, delayMillis * 2, start)  ❷
         select t);

❶ 最后一次尝试

❷ 如果尝试失败,等待一段时间然后重试。

要使用它,只需将执行远程操作的功能包装在Retry函数的调用中:

Retry(10, 1000, () => RatesApi.GetRateAsync("GBPUSD"))

这指定了操作最多重试 10 次,尝试之间的初始延迟为 1 秒。最后一个参数是要执行的操作,它以懒加载的方式指定,因为调用函数会启动任务。

注意,Retry是递归的:如果操作失败,它将等待指定的间隔,然后重试相同的操作,减少剩余的重试次数,并将等待的间隔加倍(一种称为指数退避的重试策略)。

16.1.7 并行运行异步操作

因为Task用于表示耗时操作,所以当你可能时并行执行它们是很自然的。

假设你想检查不同航空公司提供的价格。假设你有一些封装访问航空公司 API 的类,每个类都实现了Airline接口:

interface Airline
{
   Task<Flight> BestFare(string origin, string dest, DateTime departure);
}

BestFare可以为你获取给定路线和日期上最便宜的航班。航班详情通过远程 API 查询,因此结果自然被封装在Task中。

现在想象一下,如果我们回到了 90 年代,你想要用最少的钱环游欧洲。你需要查看市场上仅有的两家低成本航空公司:EasyJet 和 Ryanair。然后你可以找到在给定日期两个机场之间提供的最佳价格,如下所示:

Airline ryanair;
Airline easyjet;

Task<Flight> BestFareM(string origin, string dest, DateTime departure)
   => from r in ryanair.BestFare(origin, dest, departure)
      from e in easyjet.BestFare(origin, dest, departure)
      select r.Price < e.Price ? r : e;

这确实可行,但并非最佳方案。因为 LINQ 查询是单子(monadic)的,easyjet.BestFare 将仅在 ryanair.BestFare 完成后调用(你很快就会明白原因)。但为什么要等待呢?毕竟,这两个调用是完全独立的,所以我们没有理由不能并行执行这两个调用。

你可能还记得第十章中提到的,当你有独立的计算时,你可以使用应用(applicatives)。下面的列表展示了为 Task 定义的 Apply,它再次在 await 的层面上被相当简单实现。

列表 16.7 ApplyTask 的实现

public static async Task<R> Apply<T, R>
   (this Task<Func<T, R>> f, Task<T> arg)
   => (await f)(await arg);

public static Task<Func<T2, R>> Apply<T1, T2, R>
   (this Task<Func<T1, T2, R>> f, Task<T1> arg)
   => Apply(f.Map(F.Curry), arg);

就像其他容器一样,重要的重载是第一个(其中一元函数被封装在容器中),而更多参数的重载可以通过对函数进行柯里化(currying)来定义。就像 MapBind 一样,实现只是简单地使用 await 关键字来引用 Task 的内部值。Apply 等待封装的函数,等待封装的参数,并将函数应用于参数。结果自动封装在一个任务中,这是使用 await 的结果。

下面的列表展示了如何使用 Apply 更高效地找到更便宜的票价。

列表 16.8 使用 Apply 并行执行两个 Task

Task<Flight> BestFareA(string origin, string dest, DateTime departure)
{
   var pickCheaper = (Flight l, Flight r)
      => l.Price < r.Price ? l : r;

   return Async(pickCheaper)
      .Apply(ryanair.BestFare(origin, dest, departure))
      .Apply(easyjet.BestFare(origin, dest, departure));
}

在这个版本中,对 BestFare 的两次调用是独立启动的,因此它们并行运行。BestFareA 完成所需的总时间由 API 调用所需时间较长的一方决定——而不是它们的总和。

要更好地理解为什么 Apply 会并行运行任务,而 Bind 会顺序运行,请查看以下列表,它展示了 BindApply 并列的情况。

列表 16.9 Bind 顺序运行任务,Apply 并行运行

public static async Task<R> Bind<T, R>
   (this Task<T> task, Func<T, Task<R>> f)
    => await f(await task);

public static async Task<R> Apply<T, R>
   (this Task<Func<T, R>> f, Task<T> arg)
   => (await f)(await arg);

Bind 首先等待给定的 Task<T> 完成,然后才评估启动第二个任务的函数。它顺序运行任务,并且不能做其他事,因为需要 T 的值来创建第二个任务。

另一方面,Apply 接受两个 Task,这意味着两个任务都已经启动。考虑到这一点,让我们重新审视这段代码:

Async(PickCheaper)
   .Apply(ryanair.BestFare(origin, dest, departure))
   .Apply(easyjet.BestFare(origin, dest, departure));

当你第一次调用 Apply(使用 Ryanair 任务)时,它会立即返回一个新的 Task,而不等待 Ryanair 任务完成(这是 Apply 内部 await 的行为)。然后程序立即继续创建 EasyJet 任务。因此,两个任务并行运行。换句话说,BindApply 之间的行为差异是由它们的签名决定的:

  • 使用 Bind 时,必须等待第一个 Task 完成,才能创建第二个任务,因此它应该在创建 Task 依赖于另一个返回值时使用。

  • 使用 Apply 时,两个任务都由调用者提供,因此你应该在任务可以独立启动时使用它。

如果你有不止两家,而是一长串低成本航空公司要比较,就像今天这样呢?为了应对这个更复杂的场景,我们需要一个新的工具:Traverse,我们将在第 17.1 节中看到。但首先,我们将通过查看异步值的序列来结束这一章。

16.2 异步流

Task<T> 适用于建模需要一些时间才能交付单个 T 的操作,允许你编写异步代码而不增加过多的复杂性。然而,我们经常有返回的不是单个 T,而是多个 T 的操作,这些 T 可以单独或批量交付,项目或批量之间有相对较长的时间间隔。以下是一些例子:

  • 从分页 API 中检索多页内容。 每页通过单个异步操作检索,包含一定数量的资源,但你需要检索多页才能获取所需的所有资源。

  • 读取文件。 你可以异步逐行读取文件内容,而不是将整个文件内容读入内存;这允许你在文件的其他部分仍在读取时开始处理已读取的行。

  • 从云托管数据库中检索数据。

就像任何长时间运行的操作一样,我们不希望等待请求的值被交付。相反,我们希望在异步请求启动后立即释放调用线程,如图 16.3 所示。

图片

图 16.3 异步序列。信息消费者请求一些数据,信息生产者以非微不足道的延迟异步返回这些数据。

我们可以将此类场景建模为 异步流——异步交付的值流。这些值通过 IAsyncEnumerable<T> 接口表示,C# 8 引入了用于创建和消费 IAsyncEnumerable 的专用语法。

IAsyncEnumerable 类似于 Task(因为它提供了异步性)和 IEnumerable(因为它提供了聚合)。如果你愿意,它结合了这两种效果。表 16.1 展示了这些不同抽象之间的关系。

表 16.1 IAsyncEnumerable 与其他抽象的比较

同步 异步
单个值 T Task<T>
多个值 IEnumerable<T> IAsyncEnumerable<T>

因此,你可能会问,“IAsyncEnumerable<T>Task<IEnumerable<T>> 有何不同?”关键在于,使用 Task<IEnumerable<T>> 时,你必须等待包含的 Task 完成,才能消费得到的 T。而使用 IAsyncEnumerable<T>,另一方面,你可以在接收到 T 后立即开始消费,无需等待流的结束。让我们探讨一些具体的场景。

16.2.1 将文件作为异步流读取

想象你正在电子商务领域工作,需要跟踪每个产品在仓库中的可用库存数量。业务的物流方面使用了一个过时的协议:仓库的库存配送记录为逗号分隔值(CSV)文件,每天结束时上传。你必须编写一个从 CSV 文件中读取(每一行代表特定产品的库存配送)并相应更新电子商务数据库的过程。

由于从文件中读取相对较慢,将其建模为异步操作是自然的;此外,你可以逐行读取文件内容。这比将大文件的全部内容存储在内存中更有效。因此,你可以使用IAsyncEnumerable,如下面的列表所示。

列表 16.10 将文件内容作为异步字符串流读取

using System.Collections.Generic;
using System.IO;

static async IAsyncEnumerable<string> ReadLines(string path)
{
   using StreamReader reader = File.OpenText(path);
   while (!reader.EndOfStream)
      yield return await reader.ReadLineAsync();
}

注意,为了生成一个IAsyncEnumerable,你需要结合使用yield return(就像使用IEnumerable一样)和await。实际上,每次你有一个需要重复调用的异步操作(返回Task<T>的方法,例如这里的ReadLineAsync)时,你可以考虑使用IAsyncEnumerable<T>。现在我们有了字符串的异步流,我们可以使用每一行来填充一个数据对象(我将称之为Delivery),并使用它来更新数据库:

record Delivery(long ArticleID, int Quantity);    ❶

static Delivery Parse(string s)                   ❷
{
   string[] ss = s.Split(',');
   return new(long.Parse(ss[0]), int.Parse(ss[1]));
}

static void UpdateDb(Delivery r) => // ...        ❸

❶ 模拟一个配送

❷ 从文件中的一行填充一个Delivery

❸ 使用配送信息更新数据库

在这些构建块就绪之后,我们可以编写一个程序,使用 CSV 文件中的值来更新数据库:

public static async Task Main()
{
   await foreach (var line in ReadLines("warehouse.csv"))   ❶
   {
      Delivery d = Parse(line);                             ❷
      UpdateDb(d);                                          ❸
   }
}

❶ 消费 CSV 文件的行流

❷ 将每一行解析为Delivery

❸ 将Delivery保存到数据库中

注意,在这里我们使用await foreach来消费IAsyncEnumerable中的值。这与使用foreach消费IEnumerable中的元素类似。

16.2.2 函数式消费异步流

我希望你现在正在想,“但我们绝不想使用foreach来显式地遍历集合中的元素;相反,我们想使用Map或 LINQ 表达式来将每一行转换为Delivery,并使用ForEach来更新数据库!”

当然,你可能会认为这是我在整本书中一直在遵循的方法。唯一的缺点是,必须在IAsyncEnumerable上的相关扩展方法通过引用System.Interactive.Async包来导入。一旦设置了此引用,你就可以像以下列表所示那样重写程序。

列表 16.11 利用System.Interactive.Async中的扩展方法

using System.Linq;                              ❶

public static async Task Main()
   => await ReadDeliveries("warehouse.csv")
      .ForEachAsync(UpdateDb);                  ❷

static IAsyncEnumerable<Delivery> ReadDeliveries(string path)
   => from line in ReadLines(path)              ❸
      select Parse(line);                       ❸

IAsyncEnumerable的扩展在这个命名空间中。

❷ 对流中的每个元素执行副作用

❸ 对流中的每个元素应用一个函数

在这里,我们使用 LINQ 理解将每个异步传递的字符串转换为 Delivery,并使用 ForEachAsync 来更新数据库。为什么它被称为 ForEachAsync 而不是仅仅 ForEach?因为它仅在流中的所有值都已被处理时完成,它返回一个 Task,并且对于返回 Task 的操作,惯例是使用 Async 后缀。

注意,我已经将 UpdateDb 定义为同步的。在实际操作中,你可能会将此操作也异步化;在这种情况下,它将返回一个 Task 而不是 void。然后你需要按以下方式修改程序,使用 ForEachAwaitAsync 而不是 ForEachAsync

public static async Task Main()
   => await ReadDeliveries("warehouse.csv")
      .ForEachAwaitAsync(UpdateDbAsync);

static Task UpdateDbAsync(Delivery r) => // ...

16.2.3 从多个流中消费数据

到目前为止,你已经看到了如何定义异步流以及如何使用 Select(无论是直接使用还是通过包含单个 from 子句的 LINQ 理解)来消费其值进行数据转换(直接或通过 LINQ 理解),以及使用 ForEachAsyncForEachAwaitAsync 来执行副作用。接下来,我们将探讨使用包含多个 from 子句的 LINQ 理解。正如你在第 10.4 节中了解到的,这相当于 SelectMany,本质上就是 Bind

想象一下,你的客户不仅有,而且有几个仓库。他们都会在一天结束时将各自的 CSV 文件上传到目录中,因此你的程序需要更改以处理多个文件。这个更改相当简单。不是获取文件路径,ReadDeliveries 可以获取目录路径并处理该目录中存在的所有文件:

static IAsyncEnumerable<Delivery> ReadDeliveries(string dir)
   => from path in Directory.EnumerateFiles(dir).ToAsyncEnumerable()
      from line in ReadLines(path)
      select Parse(line);

就这样!只需一行更改。EnumerateFiles 返回一个 IEnumerable<string>。这需要提升为 IAsyncEnumerable<string>,以便可以在处理每个文件生成的流中使用 LINQ 理解。请注意,文件将按顺序处理;因此,结果流将在移动到第二个文件之前包含来自第一个文件的所有交付,依此类推。

16.2.4 使用异步流进行聚合和排序

异步流非常强大,因为它们允许你在流结束之前开始消费流中的值。在我们的例子中,这意味着你可以在读取 CSV 文件的同时开始更新数据库。在某些场景中,这可以给你带来巨大的效率提升。

现在想象一下,仓库在一天中会收到几批货物,可能包括几个相同产品的交付,因此 CSV 文件可能包含几个相同产品 ID 的条目。如果是这种情况,你希望对该产品执行单个数据库更新。然后你的代码需要按以下方式更改:

public static async Task Main()
   => await ReadDeliveries("warehouse.csv")
      .GroupBy(r => r.ProductID)
      .SelectAwait(async grp => new Delivery(grp.Key
         , await grp.SumAsync(r => r.Quantity)))
      .ForEachAwaitAsync(UpdateDbAsync);

这里关键点是你在流中按产品 ID 对元素进行分组;这是通过GroupBy完成的,就像你会用IEnumerable做的那样。在每个分组内部,然后计算所有数量的总和以创建每个产品的单个Delivery。但请注意,你不能像在IEnumerable上使用Sum那样使用Sum;相反,你必须使用SumAsync,它返回一个Task(因为你必须等待接收所有项目,然后才能计算它们的总和)。

因此,尽管代码是正确的,但你可能会注意到我们实际上失去了一些异步操作的优势。我们需要等待所有元素接收完毕才能计算它们的总和或其他聚合操作。因此,在这种情况下,IAsyncEnumerable最终并不比Task<IEnumerable>更好。如果你想要排序的值,情况也是如此。

摘要

  • Task<T>表示一个异步交付T的计算。

  • 应该在底层操作可能具有显著延迟的情况下使用Task,例如大多数 I/O 操作。

  • 返回Task的函数可以用MapBind和其他几个组合子来组合,以指定错误处理或多次重试。

  • 如果Task是独立的,它们可以并行运行。你可以将Task用作应用,并通过Apply组合几个Task来并行运行它们。

  • IAsyncEnumerable<T>表示一个异步交付的T序列。

  • 使用System.Interactive.Async中的扩展方法来处理IAsyncEnumerable;这也包括 LINQ 查询模式的实现。

  • 请记住,一些异步流的操作,如排序和聚合,需要流中的所有元素,因此会失去使用异步流获得的某些效率。


¹ 这有点遗憾,因为async增加了噪声,尤其是在 lambda 中使用时。严格来说,这不是必需的:可以设计不使用async的语言语法;然而,它被添加是为了使语言与最初添加到语言中的await保持向后兼容。

²我对这种命名约定有强烈的反对意见。这是微软在async的早期提出的。一方面,你不会给返回字符串的方法加上特殊的-Str后缀,对吧?那么为什么要在Task上这样做呢?我认为这种约定的背后想法是,为了在同时公开相同操作的同步和异步版本的 API 中方便区分。但这导致了糟糕的设计:如果一个方法应该是异步的,那么使用同步版本是不理想的。API 应该通过只公开异步版本来鼓励做正确的事情。如果两种版本都公开了,那么,如果有什么不同的话,同步版本应该带有-Sync后缀,这将像眼中钉一样突出。好的设计使得做正确的事情变得容易,所以强迫异步版本使用更长、更嘈杂的名称是糟糕的设计。不幸的是,这种约定已经足够普及,以至于被认为是标准。

await不仅与Task一起使用,还可以与任何可等待的(任何定义了返回INotifyCompletionGetAwaiter [实例或扩展]方法的值)一起使用。

17 可穿越和堆叠的单子

本章涵盖

  • 可穿越的:处理升序类型列表

  • 组合不同单子的效果

到目前为止,在本书中,你已经看到了许多不同的容器,它们为底层值添加了一些效果——Option 用于可选性,IEnumerable 用于聚合,Task 用于异步,等等。随着我们的容器列表不断增长,我们不可避免地会遇到组合不同容器的问题:

  • 如果你有一个想要执行的 Task 列表,你如何将它们组合成一个在所有操作完成后完成的单个 Task

  • 如果你有一个 Task<Validation<T>> 类型的值,你如何用类型为 T Task<R> 的函数以最少的噪音来组合它?

本章将为你提供组合不同容器效果的工具,并展示如何避免过多的嵌套容器。

17.1 可穿越的:处理升序值列表

Traverse 是 FP 中稍微有些神秘的核函数之一,它允许你处理升序值列表。通过一个例子来接近它可能最容易。

想象一个简单的命令行应用程序,它读取用户输入的以逗号分隔的数字列表,并返回所有给定数字的总和。我们可以从这里开始:

using Double = LaYumba.Functional.Double;    ❶
using String = LaYumba.Functional.String;    ❷

var input = Console.ReadLine();

var nums = input.Split(',')  // Array<string>
   .Map(String.Trim)         // IEnumerable<string>
   .Map(Double.Parse);       // IEnumerable<Option<double>>

❶ 提供了一个返回 Option 的函数来解析 double

❷ 提供了一个静态的 Trim 函数

我们将输入字符串分割成字符串数组,并使用 Trim 移除任何空白。然后我们可以将解析函数 Double.Parse 映射到这个列表上,该函数的签名是 string Option<double>。结果,我们得到一个 IEnumerable<Option<double>>

而实际上,我们真正想要的是一个 Option<IEnumerable<double>>,如果 任何 数字解析失败,它应该是 None。在这种情况下,我们可以警告用户更正他们的输入。¹ 我们看到 Map 产生了一个类型,其效果是按照与我们需要的相反的顺序堆叠的。

这是一个相当常见的场景,因此有一个名为 Traverse 的特定函数来处理它,而 Traverse 定义的类型被称为 可穿越的。图 17.1 展示了 MapTraverse 之间的关系。

图 17.1 比较 MapTraverse

让我们推广可穿越的概念:

  • 我们有一个 T 的可穿越结构,所以让我们用 Tr<T> 来表示这一点。在这个例子中,它是 IEnumerable<string>

  • 我们有一个跨越世界的函数,f : T A<R>,其中 A 至少是一个应用。在这个例子中,它是 Double.Parse,它具有类型 string Option<double>

  • 我们希望得到一个 A<Tr<R>>

Traverse 的一般签名是这个形式

Tr<T> → (T → A<R>) → A<Tr<R>>

对于这个例子,它是

IEnumerable<T> → (T → Option<R>) → Option<IEnumerable<R>>

17.1.1 使用单子 Traverse 验证值列表

让我们看看如何根据前面的签名实现Traverse。如果你查看签名中的顶级类型,你会发现我们从一个列表开始,最终得到一个单一值。记住,我们使用Aggregate将列表缩减为一个单一值,这在第 9.6 节中已经介绍过。

Aggregate接受一个累加器和减法函数,该函数将列表中的每个元素与累加器组合。如果列表为空,则返回累加器作为结果。这很简单;我们只需创建一个空的IEnumerable,并使用Some将其提升到Option,如下列所示。

列表 17.1 返回OptionTraverse的单调函数

public static Option<IEnumerable<R>> Traverse<T, R>
(
   this IEnumerable<T> ts,
   Func<T, Option<R>> f
)
=> ts.Aggregate
(
   seed: Some(Enumerable.Empty<R>()),    ❶
   func: (optRs, t) =>
      from rs in optRs                   ❷
      from r in f(t)                     ❸
      select rs.Append(r)                ❹
);

❶ 如果可遍历的为空,则提升一个空实例

❷ 从Option中提取累积的R列表

❸ 将函数应用于当前元素,并从结果Option中提取值

❹ 将值追加到列表中,并将结果列表提升到Option

现在,让我们看看减法函数——这是有趣的部分。它的类型是

Option<IEnumerable<R>> → T → Option<IEnumerable<R>>

当我们将函数f应用于值t时,我们得到一个Option<R>。之后,我们必须满足以下签名:

Option<IEnumerable<R>> → Option<R> → Option<IEnumerable<R>>

让我们暂时简化一下,从每个元素中移除Option

IEnumerable<R> → R → IEnumerable<R>

现在很明显,问题是将单个R追加到IEnumerable<R>中,得到一个包含所有已遍历元素的IEnumerable<R>。追加应该在提升的Option世界中发生,因为所有值都被包裹在Option中。正如你在第十章中学到的,我们可以在提升的世界中以适用性或单调的方式应用函数。这里我们使用单调流程。

现在你已经看到了Traverse的定义,让我们回到用户输入的逗号分隔数字列表解析的场景。以下列表显示了我们可以如何使用Traverse实现这一点。

列表 17.2 安全解析并求和逗号分隔的数字列表

using Double = LaYumba.Functional.Double;
using String = LaYumba.Functional.String;

var input = Console.ReadLine();
var result = Process(input);
Console.WriteLine(result);

static string Process(string input)
   => input.Split(',')        // Array<string>
      .Map(String.Trim)       // IEnumerable<string>
      .Traverse(Double.Parse) // Option<IEnumerable<double>>
      .Map(Enumerable.Sum)    // Option<double>
      .Match
      (
         () => "Some of your inputs could not be parsed",
         (sum) => $"The sum is {sum}"
      );

在前面的列表中,顶级语句执行 I/O,而所有逻辑都在Process函数中。让我们测试它以查看行为:

Process("1, 2, 3")
// => "The sum is 6"

Process("one, two, 3")
// => "Some of your inputs could not be parsed"

17.1.2 使用适用性遍历收集验证错误

让我们改进错误处理,以便我们可以告诉用户哪些值是错误的。为此,我们需要Validation,它可以包含一个错误列表。这意味着我们需要一个Traverse的实现,它接受一个值列表和一个返回Validation的函数。这如下列所示。

列表 17.3 返回ValidationTraverse的单调函数

public static Validation<IEnumerable<R>> TraverseM<T, R>
(
   this IEnumerable<T> ts,
   Func<T, Validation<R>> f
)
=> ts.Aggregate
(
   seed: Valid(Enumerable.Empty<R>()),
   func: (valRs, t) => from rs in valRs
                       from r in f(t)
                       select rs.Append(r)
);

此实现与返回Option的函数实现(列表 17.1)类似,除了签名和使用的Return函数是Valid而不是Some。这种重复是由于缺少同时适用于OptionValidation的通用抽象。²

注意,我之所以将函数命名为TraverseM(对于 monadic),是因为实现是 monadic 的。如果一个项目验证失败,验证函数将不会对后续的任何项目进行调用。

如果我们想要累积错误,我们应该使用 applicative flow(如果你需要回顾为什么是这样,请参阅第 10.5 节)。因此,我们可以定义TraverseA(对于 applicative),使用相同的签名但使用 applicative flow,如下列所示。

列表 17.4 使用返回Validation的函数的Applicative Traverse`

static Func<IEnumerable<T>, T, IEnumerable<T>> Append<T>()
   => (ts, t) => ts.Append(t);

public static Validation<IEnumerable<R>> TraverseA<T, R>
(
   this IEnumerable<T> ts,
   Func<T, Validation<R>> f
)
=> ts.Aggregate
(
   seed: Valid(Enumerable.Empty<R>()),                     ❶
   func: (valRs, t) =>
      Valid(Append<R>())                                   ❷
         .Apply(valRs)                                     ❸
         .Apply(f(t))                                      ❹
);

public static Validation<IEnumerable<R>> Traverse<T, R>
   (this IEnumerable<T> list, Func<T, Validation<R>> f)
   => TraverseA(list, f);                                  ❺

❶ 如果可遍历的是空的,则提升一个空实例

❷ 提升特定于RAppend函数

❸ 应用到累加器

❹ 将f应用于当前元素;结果Validation中包裹的RAppend的第二个参数。

❺ 对于ValidationTraverse应默认为 applicative 实现。

TraverseA的实现类似于TraverseM,不同之处在于在 reducer 函数中,追加是通过 applicative 而不是 monadic flow 完成的。因此,验证函数fts中的每个T都会被调用,所有验证错误都会累积在结果Validation中。

因为这是我们通常希望Validation具有的行为,所以我将Traverse定义为指向 applicative 实现TraverseA,但如果你想要短路行为,仍然有TraverseM的空间。

下面的列表显示了重构后使用Validation的程序。

列表 17.5 安全解析和求和逗号分隔的数字列表

static Validation<double> Validate(string s)
   => Double.Parse(s).Match
   (
      () => Error($"'{s}' is not a valid number"),
      (d) => Valid(d)
   );

static string Process(string input)
   => input.Split(',')        // Array<string>
      .Map(String.Trim)       // IEnumerable<string>
      .Traverse(Validate)     // Validation<IEnumerable<double>>
      .Map(Enumerable.Sum)    // Validation<double>
      .Match
      (
         errs => string.Join(", ", errs),
         sum => $"The sum is {sum}"
      );

列表仅显示了Process(顶级语句与之前相同)的更新实现。如果我们测试这个增强的实现,我们现在会得到以下结果:

Process("1, 2, 3")
// => "The sum is 6"

Process("one, two, 3")
// => "'one' is not a valid number, 'two' is not a valid number"

如您所见,在第二个示例中,随着我们遍历输入列表,验证错误已经累积。如果我们使用 monadic 实现TraverseM,我们只会得到第一个错误。

17.1.3 将多个验证器应用于单个值

前面的示例演示了如何将单个验证函数应用于要验证的值列表。那么,当你有一个要验证的单个值和多个验证函数时怎么办?

我们在第 9.6.3 节中处理了这样的场景,其中有一个请求对象要验证,以及一个验证器列表,每个验证器都检查某些有效性条件是否得到满足。作为提醒,我们定义了一个Validator委托来捕获执行验证的函数:

// T => Validation<T>
public delegate Validation<T> Validator<T>(T t);

挑战在于编写一个单一的Validator函数,结合多个Validator的验证,收集所有错误。我们不得不跳过几个障碍来定义一个具有此行为的HarvestErrors函数(列表 9.22)。

现在您已经了解了如何使用返回Validation的函数与Traverse一起使用,我们可以将HarvestErrors重写得更简洁,如下列所示。

列表 17.6 从多个验证器中聚合错误

public static Validator<T> HarvestErrors<T>
   (params Validator<T>[] validators)
   => t
   => validators
      .Traverse(validate => validate(t))
      .Map(_ => t);

在这里,Traverse 返回一个 Validation<IEnumerable<T>>,收集所有错误。如果没有错误,类型为 IEnumerable<T> 的内部值将包含与验证器数量相同数量的输入值 t。随后的 Map 调用将忽略这个 IEnumerable 并用正在验证的原始对象替换它。以下是在实践中使用 HarvestErrors 的一个示例:

Validator<string> ShouldBeLowerCase
   = s => (s == s.ToLower())
      ? Valid(s)
      : Error($"{s} should be lower case");

Validator<string> ShouldBeOfLength(int n)
   => s => (s.Length == n)
      ? Valid(s)
      : Error($"{s} should be of length {n}");

Validator<string> ValidateCountryCode
   = HarvestErrors(ShouldBeLowerCase, ShouldBeOfLength(2));

ValidateCountryCode("us")
// => Valid(us)

ValidateCountryCode("US")
// => Invalid([US should be lower case])

ValidateCountryCode("USA")
// => Invalid([USA should be lower case, USA should be of length 2])

17.1.4 使用 Traverse 与 Task 等待多个结果

TraverseTask 的工作方式与与 Validation 的工作方式相似。我们可以定义 TraverseA,它使用应用流并并行运行所有任务,TraverseM,它使用单调流并顺序运行任务,以及默认为 TraverseATraverse,因为并行运行独立异步操作通常是首选的。给定一个长时间运行的操作列表,我们可以使用 Traverse 获取一个单一的 Task,我们可以使用它来等待所有结果。

在第 16.1.7 节中,我们讨论了比较两家航空公司的航班价格。使用 Traverse,我们能够处理航空公司列表。想象一下,每个航空公司的航班可以通过返回 Task<IEnumerable<Flight>> 的方法进行查询,而我们想要获取给定日期和路线上的所有航班,并按价格排序:

interface Airline
{
   Task<IEnumerable<Flight>> Flights
      (string origin, string destination, DateTime departure);
}

我们如何获取所有航空公司的所有航班?注意如果我们使用 Map 会发生什么:

IEnumerable<Airline> airlines;

IEnumerable<Task<IEnumerable<Flight>>> flights =
   airlines.Map(a => a.Flights(from, to, on));

我们最终得到一个 IEnumerable<Task<IEnumerable<Flight>>>。这根本不是我们想要的!

使用 Traverse,相反,我们将得到一个 Task<IEnumerable<IEnumerable<Flight>>>,一个当所有航空公司都被查询(如果任何查询失败则失败)时完成的单一任务。任务的内部值是一个列表的列表(每个航空公司一个列表),然后可以将其展平和排序,以获取按价格排序的结果列表:

async Task<IEnumerable<Flight>> Search(IEnumerable<Airline> airlines
   , string origin, string dest, DateTime departure)
{
   var flights = await airlines
      .Traverse(a => a.Flights(origin, dest, departure));
   return flights.Flatten().OrderBy(f => f.Price);
}

Flatten 只是一个方便函数,它使用恒等函数调用 Bind,因此将嵌套的 IEnumerable 转换为包含所有航空公司航班的单个列表。然后,这个列表按价格排序。

大多数时候,你希望有并行行为,所以我定义了 TraverseTraverseA 相同。另一方面,如果你有 100 个任务,第二个任务失败,那么单调遍历将帮助你避免在应用遍历中使用时启动的 98 个任务。因此,你选择的实现取决于用例,这也是为什么包括两者的原因。

让我们看看这个示例的最后一个变体。在现实生活中,你可能不希望搜索失败,如果第三方 API 的数十个查询中的一个失败。想象一下,你想要显示可用的最佳结果,就像许多价格比较网站一样。如果提供商的 API 不可用,该提供商的结果将不可用,但我们仍然希望看到其他所有提供商的结果。

变更很简单!我们可以使用第 16.1.4 节中展示的 Recover 函数,这样每个查询在远程查询失败时都会返回一个空的航班列表:

async Task<IEnumerable<Flight>> Search(IEnumerable<Airline> airlines
   , string origin, string dest, DateTime departure)
{
   var flights = await airlines
      .Traverse(a => a.Flights(origin, dest, departure)
                      .Recover(ex => Enumerable.Empty<Flight>()));

   return flights.Flatten().OrderBy(f => f.Price);
}

在这里,我们有一个函数,它并行查询多个 API,忽略任何失败,并将所有成功的结果汇总到一个按价格排序的单个列表中。我认为这是一个很好的例子,说明了如何通过组合核心函数如TraverseBind等,以少量代码和努力来指定丰富的行为。

17.1.5 为单值结构定义 Traverse

到目前为止,你已经看到了如何使用TraverseIEnumerable以及返回OptionValidationTask或任何其他应用函数。实际上,Traverse更加通用。IEnumerable并不是唯一的可遍历结构;你可以为书中看到的大多数结构定义Traverse。如果我们采取一种从细节到整体的方法,我们可以将Traverse视为一种工具,它以与执行Map相反的方式堆叠效果:

Map      : Tr<T> → (T → A<R>) → Tr<A<R>>
Traverse : Tr<T> → (T → A<R>) → A<Tr<R>>

如果我们有一个返回应用A的函数,Map返回一个内部有A的类型,而Traverse返回一个外部有A的类型。

例如,在第八章中,我们有一个场景,我们使用Map将返回Validation的函数与返回Exceptional的函数组合起来。代码是这样的:

Func<MakeTransfer, Validation<MakeTransfer>> validate;
Func<MakeTransfer, Exceptional<Unit>> save;

public Validation<Exceptional<Unit>> Handle(MakeTransfer request)
   => validate(request).Map(save);

如果出于某种原因,我们想要返回一个Exceptional<Validation<Unit>>,那会怎样呢?好吧,现在你知道了这个技巧:只需将Map替换为Traverse

public Exceptional<Validation<Unit>> Handle(MakeTransfer request)
   => validate(request).Traverse(save);

但我们能否使Validation可遍历呢?答案是肯定的。记住,我们可以将Option视为一个最多只有一个元素的列表。对于EitherValidationExceptional也是同样的道理:成功情况可以被视为一个包含单个元素的遍历结构;失败情况则为空。

在这种情况下,我们需要一个定义Traverse的函数,它接受一个Validation和一个返回Exceptional的函数。下面的列表显示了实现。

列表 17.7 使 Validation 可遍历

public static Exceptional<Validation<R>> Traverse<T, R>
(
   this Validation<T> valT,
   Func<T, Exceptional<R>> f
)
=> valT.Match
(
   Invalid: errs => Exceptional(Invalid<R>(errs)),
   Valid: t => f(t).Map(Valid)
);

基本情况是如果ValidationInvalid;这与空列表的情况类似。在这里,我们创建一个所需输出类型的价值,保留验证错误。如果ValidationValid,这意味着我们应该“遍历”它包含的单个元素,命名为t。我们将返回Exception的函数f应用于它,以获得Exceptional<R>,然后我们Map``Valid函数于其上,将内部值r提升到Validation<R>,从而得到所需的输出类型,Exceptional<Validation<R>>

你可以遵循这个模式来为其他单值或无值结构定义Traverse。注意,一旦你定义了Traverse,那么当你有,比如说,一个Validation<Exceptional<T>>并且想要反转效果顺序时,你只需使用带有恒等函数的Traverse即可。

总结来说,Traverse 不仅对处理提升值列表很有用,而且在更一般的情况下,无论何时你有堆叠效应,Traverse 都是你可以使用的工具之一,以确保类型不会占上风。

17.2 结合异步性和验证(或任何其他两个单子效应)

大多数企业应用程序都是分布式的,依赖于多个外部系统,因此大部分代码都是异步运行的。如果你想使用 OptionValidation 这样的结构,很快你就会处理 Task<Option<T>>Task<Validation<T>>Validation<Task<T>> 等等。

17.2.1 堆叠单子的难题

这些嵌套类型可能难以处理。当你在一个单子内部工作时,例如 Option,一切都很正常,因为你可以使用 Bind 来组合多个返回 Option 的计算。但是,如果你有一个返回 Option<Task<T>> 的函数,另一个函数的类型是 T Option<R>,你该如何组合它们?你将如何使用一个 Option<Task<T>> 与一个类型为 T Task<Option<R>> 的函数?

我们可以更普遍地将其称为堆叠单子的难题。为了说明这个问题以及如何解决它,让我们回顾第十三章中的一个例子。以下列表显示了处理 API 请求进行转账的端点的骨架版本。

列表 17.8 MakeTransfer 命令处理程序的骨架

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Func<Guid, AccountState> getAccount,
   Action<Event> saveAndPublish
)

=> app.MapPost("/Transfer/Make", (MakeTransfer cmd) =>   ❶
{
   var account = getAccount(cmd.DebitedAccountId);       ❷

   var (evt, newState) = account.Debit(cmd);             ❸

   saveAndPublish(evt);                                  ❹

   return Ok(new { newState.Balance });                  ❺
});

❶ 处理接收到的命令

❷ 检索账户

❸ 执行状态转换;返回一个包含事件和新状态的元组

❹ 持久化事件并将其发布给感兴趣的各方

❺ 向客户端返回有关新状态的信息

上述代码作为大纲。接下来,你将看到如何添加异步性、错误处理和验证。

首先,我们将注入一个新的依赖项来对 MakeTransfer 命令进行验证。其类型将是 Validator<MakeTransfer>,这是一个具有以下签名的委托:

MakeTransfer → Validation<MakeTransfer>

接下来,我们需要修改现有依赖项的签名。当我们调用 getAccount 来检索账户的当前状态时,该操作将击中数据库。我们希望使其异步,因此结果类型应该被包装在 Task 中。此外,连接到数据库时可能会发生错误。幸运的是,Task 已经捕获了这一点。最后,还有账户可能不存在(对于给定的 ID 没有记录任何事件)的可能性,因此结果也应该被包装在 Option 中。完整的签名将是

getAccount : Guid → Task<Option<AccountState>>

保存和发布事件也应异步进行,因此签名应该是

saveAndPublish : Event → Task

最后,请记住 Account.Debit 也返回其结果被包装在 Validation 中:

Account.Debit :
   AccountState → MakeTransfer → Validation<(Event, AccountState)>

现在,让我们编写一个带有所有这些效应的命令处理程序的骨架:

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Validator<MakeTransfer> validate,
   Func<Guid, Task<Option<AccountState>>> getAccount,
   Func<Event, Task> saveAndPublish
)
=> app.MapPost("/Transfer/Make", (MakeTransfer transfer) =>
{
   Task<Validation<AccountState>> outcome = // ...

   return outcome.Map
   (
      Faulted: ex => StatusCode(StatusCodes.Status500InternalServerError),
      Completed: val => val.Match
      (
         Invalid: errs => BadRequest(new { Errors = errs }),
         Valid: newState => Ok(new { Balance = newState.Balance })
      )
   );
});

到目前为止,我们已经列出了带有新签名的依赖项,确定了主工作流程将返回一个 Task<Validation<AccountState>>(因为将有一些异步操作,并且将有一些验证),并将其可能的状态映射到适当填充的 HTTP 响应。现在才是真正的任务:我们如何组合所需的函数?

17.2.2 减少效果的数量

首先,我们需要几个适配器。注意,getAccount 返回一个 Option(包裹在一个 Task 中),这意味着我们应该考虑没有找到账户的情况。如果没有账户意味着什么?这意味着命令被错误地填充,因此我们可以将 None 映射到一个带有适当错误的 Validation

LaYumba.Functional 定义了 ToValidation,这是一个自然转换,它“提升”一个 Option 到一个 Validation。它使用 Option 的内部值将 Some 映射到 Valid,并使用提供的 ErrorNone 映射到 Invalid

public static Validation<T> ToValidation<T>
   (this Option<T> opt, Error error)
   => opt.Match
   (
      () => Invalid(error),
      (t) => Valid(t)
   );

getAccount 的情况下,返回的 Option 被包裹在一个 Task 中,这样我们就不直接应用 ToValidation,而是使用 Map

Func<Guid, Task<Option<AccountState>>> getAccount;

Func<Guid, Task<Validation<AccountState>>> getAccountVal
   = id => getAccount(id)
      .Map(opt => opt.ToValidation(Errors.UnknownAccountId(id)));

至少现在我们只处理两个单子:TaskValidation

第二,saveAndPublish 返回一个 Task,它没有内部值,所以它不会很好地组合。让我们编写一个适配器,它返回 Task<Unit> 代替:

Func<Event, Task> saveAndPublish;

Func<Event, Task<Unit>> saveAndPublishF
   = async e =>
   {
      await saveAndPublish(e);
      return Unit();
   };

让我们再次看看我们必须组合的函数,以计算工作流程的结果:

validate        : MakeTransfer → Validation<MakeTransfer>
getAccountVal   : Guid → Task<Validation<AccountState>>
Account.Debit   : AccountState → MakeTransfer
                  → Validation<(Event, AccountState)>
saveAndPublishF : Event → Task<Unit>

如果我们一路使用 Map,我们将得到一个结果类型为 Validation<Task<Validation<Validation<Task<Unit>>>>>。我们可以尝试使用复杂的 Traverse 调用组合来改变单子的顺序,以及 Bind 来扁平化它们。说实话,我试了。这花了大约半小时的时间来弄清楚,结果是晦涩的,不是你愿意经常重构的东西!

我们必须寻找更好的方法。理想情况下,我们希望编写如下内容:

from tr in validate(transfer)
from acc in GetAccount(tr.DebitedAccountId)
from result in Account.Debit(acc, tr)
from _ in SaveAndPublish(result.Event)
select result.NewState

然后,我们将有一些底层的 SelectSelectMany 实现来确定如何组合类型。不幸的是,这不能以足够通用的方式实现:添加过多的 SelectMany 重载将导致重载解析失败。好消息是我们可以有一个接近的近似。你将在下面看到这一点。

17.2.3 带有单子堆栈的 LINQ 表达式

我们可以实现 Bind 和 LINQ 查询模式,针对特定的单子堆栈;在这种情况下,Task<Validation<T>>。³ 这允许我们在 LINQ 表达式中组合返回 Task<Validation<>> 的几个函数。考虑到这一点,我们可以通过以下规则将现有函数适配到这种类型:

  • 如果我们有一个 Task<Validation<T>>(或返回这种类型的函数),那么就没有什么要做的。这就是我们正在工作的单子。

  • 如果我们有一个 Validation<T>,我们可以使用 Async 函数将其提升到 Task,从而获得一个 Task<Validation<T>>

  • 如果我们有一个 Task<T>,我们可以将 Valid 函数映射到它上面,再次获得 Task<Validation<T>>

  • 如果我们有一个 Validation<Task<T>>,我们可以使用恒等函数调用 Traverse 来交换容器。

因此,我们之前的查询需要按以下方式修改:

from tr in Async(validate(transfer))                ❶
from acc in GetAccount(tr.DebitedAccountId)         ❷
from result in Async(Account.Debit(acc, tr))        ❶
from _ in SaveAndPublish(result.Event).Map(Valid)   ❸
select result.NewState;

❶ 使用 AsyncValidation 提升到 Task<Validation<>>

GetAccount 返回一个 Task<Validation<>>,这是我们正在使用的单子栈。

❸ 使用 Map(Valid)Task 转换为 Task<Validation<>>

只要为 Task<Validation<T>> 定义了适当的 SelectSelectMany 实现,这就会起作用。正如你所看到的,生成的代码仍然相当干净且易于理解和重构。我们只需添加几个对 AsyncMap(Valid) 的调用,以使类型对齐。下面的列表显示了命令处理器的完整实现,重构后包括异步和验证。

列表 17.9 命令处理器,包括异步和验证

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Validator<MakeTransfer> validate,
   Func<Guid, Task<Option<AccountState>>> getAccount,
   Func<Event, Task> saveAndPublish
)
{
   var getAccountVal = (Guid id) => getAccount(id)
         .Map(opt => opt.ToValidation(Errors.UnknownAccountId(id)));

   var saveAndPublishF = async (Event e) 
      => { await saveAndPublish(e); return Unit(); };

   app.MapPost("/Transfer/Make", (MakeTransfer transfer) =>
   {
      Task<Validation<AccountState>> outcome =
         from tr in Async(validate(transfer))
         from acc in getAccountVal(tr.DebitedAccountId)
         from result in Async(Account.Debit(acc, tr))
         from _ in saveAndPublishF(result.Event).Map(Valid)
         select result.NewState;

      return outcome.Map
      (
         Faulted: ex => StatusCode(StatusCodes.Status500InternalServerError),
         Completed: val => val.Match
         (
            Invalid: errs => BadRequest(new { Errors = errs }),
            Valid: newState => Ok(new { Balance = newState.Balance })
         )
      );
   });
}

让我们看看代码。首先,作为工作流程一部分需要完成的操作被注入为依赖项。接下来,我们有一些适配函数,用于将 Option 转换为 Validation 和将 Task 转换为 Task<Unit>。然后,我们配置处理传输请求的端点。在这里,我们使用 LINQ 表达式来组合工作流程中的不同操作。最后,我们将结果结果转换为表示我们希望返回的 HTTP 响应的对象。

正如你在本章中看到的,虽然单子在这个单一单子类型的环境中很棒,易于使用,但当需要组合多个单子效应时,事情会变得更加复杂。请注意,这不仅仅是在 C# 中如此,甚至在函数式语言中也是如此。即使在 Haskell 中,单子无处不在,堆叠的单子通常是通过相当笨拙的 单子转换器 来处理的。一个更有前景的方法被称为 可组合效应,它在一种相当小众的函数式语言 Idris 中具有一等支持。可能未来的编程语言不仅会有针对单子的优化语法,如 LINQ,还会有针对单子栈的优化语法。

作为一项实际指南,请记住,组合多个单子会增加复杂性,并限制不同单子的嵌套层数,使其符合你的实际需求。例如,一旦我们在前面的示例中将 Option 转换为 Validation 来简化事情,我们只需处理两个堆叠的单子而不是三个。同样,如果你有一个 Task<Try<T>>,你可能可以将其缩减为 Task<T>,因为 Task 可以捕获在运行 Try 时抛出的任何异常。最后,如果你发现自己总是使用两个单子的堆栈,你可以编写一个新的类型,将这两个效应封装到该单个类型中。例如,Task 封装了异步和错误处理。

摘要

  • 如果你有两个单子,AB,你可能希望将它们堆叠成 A<B<T>> 这样的值来组合这两个单子的效果。

  • 你可以使用 Traverse 来反转堆栈中单子的顺序。

  • 为这样的堆栈实现 LINQ 查询模式可以让你相对容易地组合 ABA<B<>>

  • 仍然,堆叠的单子往往比较繁琐,所以请尽量少用。


¹ 你可能还记得在第六章中,我们可以使用 Bind 而不是 Map 来过滤掉所有的 None 值,并且只累加成功解析的数字。这在当前场景中并不理想:我们可能会默默地移除用户可能错误输入的值,从而得到一个错误的结果。

² 关于这一点的原因在第六章中进行了讨论,在“为什么函子不是一个接口?”的侧边栏中。

³ 我不会展示实现代码,它包含在代码示例中。这确实是库代码,不是库用户应该关心的代码。你也许还会问是否每个单子堆栈都需要实现,确实如此,考虑到我们在书中一直遵循的模式化方法。

18 数据流和响应式扩展

本章涵盖

  • 使用 IObservable 来表示数据流

  • 创建、转换和组合 IObservable

  • 了解何时应该使用 IObservable

如果你曾经去过像华尔街或金丝雀码头这样的金融中心,你可能见过一个 股票行情板,一个显示最广泛交易的股票最新交易价格的发光板。这是一个很好的 数据流 的表示:随时间传递的相关值的流。

交易员(无论是人类还是算法)都会关注价格,以便他们可以 做出反应:如果某只股票的价格上升到或下降到某个水平,他们可能会根据他们的投资策略决定买入或卖出。本质上,这就是 响应式编程 的工作方式:你定义并消费数据流,可能以有趣的方式转换数据流中的数据,并定义你的程序应该如何对所消费的数据做出反应。

例如,如果你家里有物联网设备,你可能会有广播某些参数(如房间亮度或温度)的传感器,以及响应这些参数变化的设备(调节窗户百叶或空调)。

或者,在一个像我在第十三章中描述的事件源系统中,你可以将事件作为流发布,并定义对那些事件的下游处理,以便在每次交易时重新计算账户余额,并在余额变为负数时向账户持有人发送通知。

在本章中,你将学习如何使用 IObservable 接口来建模数据流,并使用响应式扩展(Rx)来创建、转换和组合 IObservable。我们还将讨论哪些场景从使用 IObservable 中受益。

Rx 是一组用于处理 IObservable 的库,就像 LINQ 提供了用于处理 IEnumerable 的实用工具一样。Rx 是一个丰富的框架,因此本章的范围无法涵盖其全面内容。相反,我们将仅查看一些基本功能和 IObservable 的应用,以及它与我们之前所讨论的其他抽象之间的关系。

18.1 使用 IObservable 表示数据流

如果你将数组视为空间(内存中的空间)中值的序列,那么你可以将 IObservable 视为时间中的值的序列:

  • 使用 IEnumerable,你可以随意枚举其值。

  • 使用 IObservable,你可以观察值随它们到来。

就像我们在第十六章中讨论的 IAsyncEnumerable 一样,IObservable 在包含多个值方面类似于 IEnumerable,而在值异步传递方面类似于 Task。表 18.1 展示了 IObservable 与其他抽象之间的关系。

表 18.1 IObservable 与其他抽象的比较

同步 异步
单个值 T Task<T>
多个值 IEnumerable<T> IAsyncEnumerable<T>

IObservable 因此比 IEnumerableTask 都更通用。你可以将 IEnumerable 视为 IObservable 的一个特例,它立即产生所有值,而你可以将 Task 视为 IObservable 的另一个特例,它只产生单个值。IObservableIAsyncEnumerable 之间的区别是什么,为什么我们需要两者?

  • IAsyncEnumerable 以消费者为中心:消费数据的组件请求生产者一些数据,并返回一个异步值流——数据由消费者“拉取”。消费者与生产者交互,因此与 IAsyncEnumerable 一起工作的库被称为 交互式扩展(Interactive Extensions)(Ix)。这些包命名为 System.Interactive.*。(IAsyncEnumerable 本身包含在 System.Collections.Generic 命名空间中的 BCL 中。)

  • IObservable 以生产者为中心:消费者订阅数据,数据由生产者“推送”出来。消费者仅仅对收到的值做出“反应”;因此,与 IObservable 一起工作的库被称为 响应式扩展(Reactive Extensions)(Rx)。这些包命名为 System.Reactive.*。(IObservable 本身包含在 System 命名空间中的 BCL 中。)

注意:Rx 和 Ix 都由 .NET 基金会维护;它们是开源的,托管在 github.com/dotnet/reactive

Rx 已经存在很多年了(Rx 的实现不仅限于 .NET,还有许多其他语言),因此你可以利用更多的资源和专业知识。相比之下,异步流和 Ix 是最近才加入的;然而,由于 C# 8 通过 yield returnawait 关键字(我们在第十六章中看到)提供了原生语言支持,因此创建和消费它们感觉更容易。

18.1.1 时间序列的值

通过 宝石图(marble diagrams) 是了解 IObservable 的最简单方式。图 18.1 展示了一些示例。每个 IObservable 都用一个箭头表示时间,用宝石表示 IObservable 产生的值。

图 18.1 宝石图提供了一种直观理解 IObservable 的方法。

该图像说明 IObservable 可以产生三种不同类型的消息:

  • OnNext 信号表示新的值,所以如果你的 IObservable 表示事件流,当事件准备好被消费时,OnNext 将被触发。这是 IObservable 最重要的消息,通常也是你唯一感兴趣的消息。

  • OnCompleted 信号表示 IObservable 已完成,并将不再产生更多值。

  • OnError 信号表示发生了错误,并提供相关的 Exception

IObservable 协议

IObservable 协议指定 IObservable 应根据以下语法产生消息:

OnNext* (OnCompleted|OnError)?

也就是说,一个 IObservable 可以产生任意数量的 TOnNext),可能后跟一个表示成功完成(OnCompleted)或错误(OnError)的单个值。这意味着在完成方面有三种可能性。一个 IObservable 可以

  • 永不完成

  • 正常完成,带有完成消息

  • 异常完成;在这种情况下,它产生一个 Exception

一个 IObservable 永远不会在完成之后产生任何值,无论它是正常完成还是出错完成。

18.1.2 订阅 IObservable

观察-ables 与观察-ers 一起工作。简单来说,

  • 观察者产生值

  • 观察者消费它们

如果你想要消费 IObservable 产生的消息,你可以创建一个观察者并通过 Subscribe 方法将其与 IObservable 关联起来。最简单的方法是提供一个回调,该回调处理 IObservable 产生的值,如下所示:

using System;                 ❶
using System.Reactive.Linq;   ❷

IObservable<int> nums = //...

nums.Subscribe(Console.WriteLine);

❶ 展示了 IObservable 接口

❷ 展示了下面的 Subscribe 扩展方法

当我说 nums “产生”一个 int 值时,我真正想说的是它调用给定的函数(在这种情况下,Console.WriteLine)并传递该值。前面代码的结果是当 nums 产生一个 int 值时,它会被打印出来。

我觉得命名有点令人困惑;你可能会期望一个 IObservable 有一个 Observe 方法,但相反,它被称作 Subscribe。基本上,你可以把它们看作同义词:观察者是一个订阅者,为了观察一个 IObservable,你需要订阅它。

那么 IObservable 可以产生的其他类型消息是什么?你也可以为这些消息提供处理程序。例如,下面的列表显示了一个便利方法 Trace,它将一个观察者附加到一个 IObservable 上;这个观察者简单地在每个 IObservable 信号时打印诊断消息。我们稍后会使用这个方法进行调试。

列表 18.1 订阅 IObservable 产生的消息

using static System.Console;

public static IDisposable Trace<T>
   (this IObservable<T> source, string name)
   => source.Subscribe
   (
      onNext: t => WriteLine($"{name} -> {t}"),
      onError: ex => WriteLine($"{name} ERROR: {ex.Message}"),
      onCompleted: () => WriteLine($"{name} END")
   );

Subscribe 实际上接受三个处理程序(所有都是可选参数)来处理 IObservable<T> 可以产生的不同消息。为什么处理程序是可选的应该很清楚:如果你不期望 IObservable 任何时候都会完成,就没有必要提供 onComplete 处理程序。

订阅的一个更面向对象的选择是使用 IObserver 接口调用 Subscribe,这个接口出人意料地暴露了 OnNextOnErrorOnCompleted 方法。¹

注意,Subscribe 返回一个 IDisposable(订阅)。通过释放它,你可以取消订阅。

在本节中,你看到了围绕 IObservable 的基本概念和术语。这需要吸收很多内容,但不用担心,随着你看到一些示例,事情会变得清晰。以下是一些需要记住的基本思想:

  • 可观察对象产生值;观察者消费它们。

  • 你可以通过使用 Subscribe 将一个观察者与一个可观察对象关联起来。

  • 一个可观察对象通过调用观察者的 OnNext 处理程序来产生一个值。

18.2 创建 IObservables

你现在知道如何通过订阅 IObservable 来消费流中的数据。但你是如何首先获得一个 IObservable 的呢?IObservableIObserver 接口包含在 .NET Standard 中,但如果你想要创建或对 IObservables 执行许多其他操作,你通常会使用通过安装 System.Reactive 包的响应式扩展 (Rx)。²

创建 IObservables 的推荐方法是使用静态 Observable 类中包含的几个专用方法之一;我们将在下面看看。我建议你在可能的情况下始终在 REPL 中跟随。

18.2.1 创建一个计时器

可以用一个每隔固定时间发出信号的 IObservable 来模拟计时器。我们可以用以下这样的宝石图来表示它:

图片

这是一种开始实验 IObservables 的好方法,因为它简单但确实包含了时间的元素。以下列表中的代码使用 Observable.Interval 创建了一个计时器。

列表 18.2 创建每秒发出信号的 IObservable

using System.Reactive.Linq;

var oneSec = TimeSpan.FromSeconds(1);
IObservable<long> ticks = Observable.Interval(oneSec);

在这里,我们定义 ticks 为一个 IObservable,它将在一秒后开始发出信号,产生一个每秒递增的 long 计数器值,从 0 开始。注意我说的是“将开始”发出信号?结果 IObservable 是惰性的,所以除非有订阅者,实际上什么都不会发生。如果没有人听,为什么要说话呢?

如果我们想看到一些有形的结果,我们需要订阅 IObservable。我们可以使用之前定义的 Trace 方法来做这件事:

ticks.Trace("ticks");

在这个阶段,你将开始在控制台看到以下消息依次出现,每秒一条:

ticks -> 0
ticks -> 1
ticks -> 2
ticks -> 3
ticks -> 4
...

因为这个 IObservable 从不完成,你将不得不重置 REPL 来停止噪音——抱歉!

18.2.2 使用 Subject 来告诉 IObservable 它何时应该发出信号

创建一个 IObservable 的另一种方式是通过实例化一个 SubjectSubject 是一个 IObservable,你可以命令式地告诉它产生一个值,然后它会反过来将其推送到它的观察者。例如,以下列表显示了一个程序,它将控制台输入转换为由 Subject 信号化的值。

列表 18.3 将用户输入建模为流

using System.Reactive.Subjects;
using static System.Console;

var inputs = new Subject<string>();           ❶

using (inputs.Trace("inputs"))                ❷
{
   for (string input; (input = ReadLine()) != "q";)
      inputs.OnNext(input);                   ❸

   inputs.OnCompleted();                      ❹
}                                             ❺

❶ 创建一个 Subject

❷ 订阅到 Subject

❸ 告诉 Subject 产生一个值,并将其推送到其观察者

❹ 告诉 Subject 发出完成信号

❺ 离开 using 块将释放订阅。

每当用户输入一些输入时,代码通过调用其 OnNext 方法将该值推送到 Subject。当用户输入“q”时,代码退出 for 循环并调用 SubjectOnCompleted 方法,表示流已结束。在这里,我们已经使用列表 18.1 中定义的 Trace 方法订阅了输入流,因此对于每个用户输入,我们都会打印出诊断消息。

与程序的交互看起来像这样(用户输入以粗体显示):

hello
inputs -> hello
world
inputs -> world
q
inputs END

避免使用Subject

Subject对于演示目的很有用,但它以命令式方式工作(你告诉Subject何时触发),这与 Rx 的响应式哲学(你指定在发生某些事情时如何响应)有些相悖。

因此,建议尽可能避免使用Subject,而改用其他方法,例如Observable.Create,你将在下一节中看到。

作为练习,尝试使用Observable.Create重写列表 18.3 中的代码,以创建用户输入的IObservable

18.2.3 从基于回调的订阅创建IObservables

如果你的系统订阅了外部数据源,例如消息队列、事件代理或发布/订阅,你可以将该数据源建模为IObservable

例如,Redis 可以用作发布/订阅。Redis 的 API 公开了一个Subscribe方法,允许你注册一个回调,该回调接收在给定频道上发布到 Redis 的消息(Redis 频道只是一个字符串;它允许订阅者指定他们感兴趣的哪些消息)。以下列表显示了如何使用Observable.Create创建一个在从 Redis 接收到消息时发出信号的IObservable

列表 18.4:从发布到 Redis 的消息创建IObservable

using StackExchange.Redis;
using System.Reactive.Linq;

ConnectionMultiplexer redis
   = ConnectionMultiplexer.Connect("localhost");

IObservable<RedisValue> RedisNotifications
(
   RedisChannel channel
)
=> Observable.Create<RedisValue>(observer =>                    ❶
{
   var sub = redis.GetSubscriber();
   sub.Subscribe(channel, (_, val) => observer.OnNext(val));    ❷
   return () => sub.Unsubscribe(channel);                       ❸
});

Create接受一个观察者,所以给定的函数只有在订阅时才会被调用。

❷ 从基于回调的Subscribe实现转换为由IObservable产生的值

❸ 返回一个在订阅被释放时将被调用的函数

前面的方法返回一个IObservable,它产生从给定频道接收到的 Redis 上的值。你可以这样使用它:

RedisChannel weather = "weather";

var weatherUpdates = RedisNotifications(weather);         ❶
weatherUpdates.Subscribe(
   onNext: val => WriteLine($"It's {val} out there"));    ❷

redis.GetDatabase(0).Publish(weather, "stormy");          ❸
// prints: It's stormy out there                          ❸

❶ 获取一个在天气频道发布消息时发出信号的IObservable

❷ 订阅到IObservable

❸ 发布一个值会导致weatherUpdates发出信号;因此调用onNext处理程序。

你可能会问,“我们到底获得了什么?”毕竟,我们本可以使用 Redis 的Subscribe方法注册一个回调来处理消息;相反,我们现在有一个IObservable,需要Subscribe到它来处理消息。关键是,有了IObservable,我们可以利用 Rx 中包含的许多操作符(我们将在第 18.3 节中讨论)以及调度器(用于优化性能,但超出了本章的范围)。

18.2.4 从更简单的结构创建IObservables

我说过IObservable<T>比一个值T、一个Task<T>或一个IEnumerable<T>更通用,所以让我们看看每个这些如何提升为IObservable。如果你想要将其中一个较不强大的结构与IObservable结合使用,这会很有用。

Return允许你将单个值提升到类似这样的IObservable

即,它立即产生值然后完成。这里有一个例子:

IObservable<string> justHello = Observable.Return("hello");
justHello.Trace("justHello");

// prints: justHello -> hello
//         justHello END

Return 接收一个值 T 并将其提升到 IObservable<T>。这是第一个 Return 函数实际上被命名为 Return 的容器!

让我们看看如何从一个单个异步值——一个 Task 创建 IObservable。这里,我们有一个看起来像这样的 IObservable

图片

经过一段时间,我们将得到一个单一值,紧接着是完成信号的信号。在代码中,它看起来像这样:

Observable.FromAsync(() => RatesApi.GetRateAsync("USDEUR"))
   .Trace("singleUsdEur");
// prints: singleUsdEur -> 0.92
//         singleUsdEur END

最后,从 IEnumerable 创建的 IObservable 看起来像这样:

图片

即,它立即产生 IEnumerable 中的所有值并完成:

IEnumerable<char> e = new[] { 'a', 'b', 'c' };
IObservable<char> chars = e.ToObservable();
chars.Trace("chars");

// prints: chars -> a
//         chars -> b
//         chars -> c
//         chars END

你现在已经看到了创建 IObservable 的许多方法,但不是全部。你可能会以其他方式创建 IObservable;例如,在 GUI 应用程序中,你可以使用 Observable.FromEventFromEventPattern 将鼠标点击等事件转换为事件流。

现在你已经了解了如何创建和订阅 IObservable,让我们继续探讨最迷人的领域:转换和组合不同的流。

18.3 转换和组合数据流

使用流的强大之处在于你可以以多种方式组合它们,并基于现有的流定义新的流。你处理的是整个流,而不是流中的单个值(就像在大多数事件驱动设计中那样)。

Rx 提供了许多函数(通常称为 算子)以各种方式转换和组合 IObservable。我将讨论最常用的几个,并添加一些我自己的算子。你会认识到函数式 API 的典型特征:纯净性和可组合性。

18.3.1 流转换

你可以通过以某种方式转换现有的可观察对象来创建新的可观察对象。其中最简单的操作是映射。这是通过 Select 方法实现的,它(就像任何其他容器一样)通过将给定的函数应用于流中的每个元素,如图 18.2 所示。

图片

图 18.2 Select 将函数映射到流上。

这里有一些创建计时器并在其上映射简单函数的代码:

var oneSec = TimeSpan.FromSeconds(1);
var ticks = Observable.Interval(oneSec);

ticks.Select(n => n * 10)
   .Trace("ticksX10");

我们在最后一行使用 Trace 方法附加了一个观察者,因此前面的代码将导致以下消息每秒打印一次:

ticksX10 -> 0
ticksX10 -> 10
ticksX10 -> 20
ticksX10 -> 30
ticksX10 -> 40
...

因为 Select 遵循 LINQ 查询模式,我们可以用 LINQ 写出相同的内容:

from n in ticks select n * 10

使用 Select,我们可以用可观察对象的形式重写我们的简单程序,该程序检查汇率(首次在列表 15.1 中介绍):

public static void Main()
{
   var inputs = new Subject<string>();            ❶

   var rates =
      from pair in inputs                         ❷
      select RatesApi.GetRateAsync(pair).Result;  ❷

   using (inputs.Trace("inputs"))                 ❸
   using (rates.Trace("rates"))                   ❸
       for (string input; (input = ReadLine().ToUpper()) != "Q";)
         inputs.OnNext(input);
}

❶ 用户输入的值流

❷ 将用户输入映射到相应的检索值

❸ 订阅两个流以产生调试消息

在这里,inputs 代表用户输入的货币对流,而在 rates 中,我们将这些对映射到从网络检索到的相应值。我们使用常规的 Trace 方法订阅这两个可观察对象,因此与该程序的交互可能如下:

eurusd
inputs -> EURUSD
rates -> 1.0852
chfusd
inputs -> CHFUSD
rates -> 1.0114

然而,请注意,在代码中,我们有一个阻塞调用到 Result。在实际应用中,我们不想阻塞一个线程,那么我们如何避免这种情况呢?

我们看到,一个 Task 可以很容易地提升为 IObservable。如果我们将获取每个远程 API 的速率的 Task 提升为 IObservable 而不是等待其结果,那么我们得到一个 IObservableIObservables。听起来熟悉吗?Bind!我们可以使用 SelectMany 来代替 Select,它将结果扁平化为单个 IObservable。因此,我们可以将 rates 流的定义重写如下:

var rates = inputs.SelectMany
   (pair => Observable.FromAsync(() => RatesApi.GetRateAsync(pair)));

Observable.FromAsyncGetRateAsync 返回的 Task 提升为 IObservableSelectMany 将所有这些 IObservables 扁平化为单个 IObservable

由于总是可以将 Task 提升为 IObservable,因此 SelectMany 存在一个重载,它正是这样做的(这与我们在第 6.5 节中如何重载 Bind 以与 IEnumerable 和返回 Option 的函数一起工作类似)。这意味着我们可以避免显式调用 FromAsync 并返回一个 Task。此外,我们可以使用 LINQ 查询:

var rates =
   from pair in inputs
   from rate in RatesApi.GetRateAsync(pair)
   select rate;

因此修改后的程序与之前的工作方式相同,但没有阻塞调用 Result

IObservable 还支持许多由 IEnumerable 支持的其他操作,例如使用 Where 过滤、Take(获取前 n 个值)、SkipFirst 等。

18.3.2 合并和分区流

此外,还有许多运算符允许您将两个流合并为一个。例如,Concat 产生一个 IObservable 的所有值,然后是另一个 IObservable 中的所有值,如图 18.3 所示。

图片

图 18.3 Concat 等待一个 IObservable 完成,然后从另一个 IObservable 产生元素。

例如,在我们的汇率查找中,我们有一个名为 rates 的可观察对象,其中包含检索到的汇率。如果我们想要一个包含程序应输出到控制台的所有消息的可观察对象,这必须包括检索到的汇率,但还必须包括一个初始消息,提示用户输入。我们可以使用 Return 将此单个消息提升到 IObservable,然后使用 Concat 将它与其他消息组合起来:

IObservable<decimal> rates = //...

IObservable<string> outputs = Observable
   .Return("Enter a currency pair like 'EURUSD', or 'q' to quit")
   .Concat(rates.Select(Decimal.ToString));

实际上,为 IObservable 提供起始值的需求如此普遍,以至于有一个专门的功能——StartWith。前面的代码等同于以下内容:

var outputs = rates.Select(Decimal.ToString)
   .StartWith("Enter a currency pair like 'EURUSD', or 'q' to quit");

Concat 在从右侧可观察对象产生值之前等待左侧 IObservable 完成,如图 18.4 所示。

图片

图 18.4 Merge 将两个 IObservable 合并成一个。

例如,如果您有一个包含有效值和错误消息的流,您可以使用 Merge 将它们组合如下:

IObservable<decimal> rates = //...
IObservable<string> errors = //...

var outputs = rates.Select(Decimal.ToString)
   .Merge(errors);

正如您可能想要合并来自不同流的值一样,相反的操作——根据某些标准对流进行分区——也是非常有用的。图 18.5 说明了这一点。

图 18.5 根据谓词分区 IObservable

Partition 返回一对 IObservable,因此您可以像这样解构它:

var (evens, odds) = ticks.Partition(x => x % 2 == 0);

IObservable 的值进行分区大致相当于处理单个值时的 if 语句,因此当您有一个根据某些条件想要以不同方式处理的值流时,它是有用的。例如,如果您有一个消息流和一些验证标准,您可以将流分为两个流,一个是有效消息流,另一个是无效消息流,并相应地处理它们。

18.3.3 使用 IObservable 进行错误处理

在使用 IObservable 进行错误处理时,其工作方式可能与您预期的不同。在大多数程序中,未捕获的异常要么导致整个应用程序崩溃,要么导致单个消息/请求的处理失败,而后续请求则正常工作。为了说明在 Rx 中事情是如何不同工作的,考虑我们查找汇率的程序版本:

var inputs = new Subject<string>();

var rates =
   from pair in inputs
   from rate in RatesApi.GetRateAsync(pair)
   select rate;

var outputs = from r in rates select r.ToString();

using (inputs.Trace("inputs"))
using (rates.Trace("rates"))
using (outputs.Trace("outputs"))
   for (string input; (input = ReadLine().ToUpper()) != "Q";)
      inputs.OnNext(input);

该程序捕获了三个流,每个流都依赖于另一个(outputs 是基于 rates 定义的,而 rates 是基于 inputs 定义的,如图 18.6 所示),我们使用 Trace 打印所有这些流的诊断信息。

图 18.6 三 IObservable 之间的简单数据流

现在看看如果您通过传递无效的货币对来破坏程序会发生什么:

eurusd
inputs -> EURUSD
rates -> 1.0852
outputs -> 1.0852
chfusd
inputs -> CHFUSD
rates -> 1.0114
outputs -> 1.0114
xxx
inputs -> XXX
rates ERROR: Input string was not in a correct format.
outputs ERROR: Input string was not in a correct format.
chfusd
inputs -> CHFUSD
eurusd
inputs -> EURUSD

这表明一旦 rates 发生错误,它就不再发出信号。这种行为符合 IObservable 协议的指定(参见关于“IObservable 协议”的侧边栏)。因此,所有下游的部分也都“死亡”。但是,失败的 IObservable 上游部分是正常的:inputs 仍在发出信号,就像任何其他以 inputs 为定义的 IObservable 一样。

为了防止您的系统进入这种状态,即数据流的一个分支死亡,而剩余的图仍然保持运行,您可以使用您学到的函数式错误处理技术。

以下列表显示了 LaYumba.Functional 中包含的辅助函数 Safely 的实现,该函数允许您安全地将返回 Task 的函数应用于流中的每个元素。结果是两个流:一个成功计算值的流和一个异常流。

列表 18.5 安全执行 Task 并返回两个流

public static (IObservable<R> Completed, IObservable<Exception> Faulted)
   Safely<T, R>(this IObservable<T> ts, Func<T, Task<R>> f)
   => ts
      .SelectMany(t => f(t).Map(
         Faulted: ex => ex,                                           ❶
         Completed: r => Exceptional(r)))                             ❶
      .Partition();

static (IObservable<T> Successes, IObservable<Exception> Exceptions)  ❷
   Partition<T>(this IObservable<Exceptional<T>> excTs)               ❷
{
   bool IsSuccess(Exceptional<T> ex)
      => ex.Match(_ => false, _ => true);

   T ExtractValue(Exceptional<T> ex)
      => ex.Match(_ => default, t => t);

   Exception ExtractException(Exceptional<T> ex)
      => ex.Match(exc => exc, _ => default);

   var (ts, errs) = excTs.Partition(IsSuccess);
   return
   (
      Successes: ts.Select(ExtractValue),
      Exceptions: errs.Select(ExtractException)
   );
}

❶ 将每个 Task<R> 转换为 Task<Exceptional<R>> 以获取 Exceptional

❷ 将 Exceptional 流分区为成功计算值和异常

对于给定流中的每个T,我们应用返回Task的函数f。然后,我们使用第 16.1.4 节中定义的二进制重载的Map将每个结果Task<R>转换为Task<Exceptional<R>>。这就是我们获得安全性的地方:而不是一个在访问时抛出异常的内值R,我们有一个处于适当状态的Exceptional<R>SelectMany消除了流中的Task,并返回一个Exceptional的流。然后我们可以将它们分为成功和异常。

在此基础上,我们可以重构我们的程序以更优雅地处理错误:

var (rates, errors) = inputs.Safely(RatesApi.GetRateAsync);

18.3.4 整合一切

以下列表展示了你在本节中学到的各种技术。它显示了重构以安全处理错误且没有调试信息的汇率查找程序。

列表 18.6 重构以安全处理错误的程序

public static void Main()
{
   var inputs = new Subject<string>();

   var (rates, errors) = inputs.Safely(RatesApi.GetRateAsync);

   var outputs = rates
      .Select(Decimal.ToString)
      .Merge(errors.Select(ex => ex.Message))
      .StartWith("Enter a currency pair like 'EURUSD', or 'q' to quit");

   using (outputs.Subscribe(WriteLine))
      for (string input; (input = ReadLine().ToUpper()) != "Q";)
         inputs.OnNext(input);
}

图 18.7 中的数据流图显示了涉及的各个IObservable以及它们之间的依赖关系。

图 18.7 具有处理错误单独分支的数据流

注意Safely如何允许我们创建两个分支,每个分支都可以独立处理,直到获得两种情况的一致表示,然后它们可以合并。

这个程序很好地说明了使用IObservables 的程序通常由三个部分组成:

  • 设置数据源——在我们的例子中,这由inputs捕获。

  • 处理数据——这是你使用SelectMerge等函数的地方。

  • 消费结果——观察者消费最下游的IObservables(在这种情况下,outputs)以执行副作用。

18.4 实现跨越多个事件的逻辑

到目前为止,我主要致力于让你熟悉IObservables 以及可以与它们一起使用的许多运算符。为此,我使用了像汇率查找这样的熟悉例子。毕竟,既然你可以将任何值TTask<T>IEnumerable<T>提升为IObservable<T>,你几乎可以用IObservables 来编写所有代码!但你应该这样做吗?

当然,答案可能不是。IObservable和 Rx 真正发光的领域是当你可以使用它们来编写无需任何显式状态操作的状态程序时。通过状态程序,我指的是事件不是独立处理的程序;过去的事件会影响如何处理新事件。在本节中,你将看到一些这样的例子。

18.4.1 检测按键序列

在某个时候,你可能编写了一个事件处理器,它监听用户的按键并基于按下的键和键修饰符执行一些操作。基于回调的方法对于许多情况来说是满意的,但如果你想监听特定的按键序列怎么办?例如,假设你想在用户按下组合键 Alt-K-B 时实现某些行为。

在这种情况下,按下 Alt-B 应该导致不同的行为,这取决于它是否被先前的 Alt-K 短时间内跟随,因此按键不能独立处理。如果你有一个基于回调的机制来处理单个按键事件,那么当用户按下 Alt-K 时,你实际上需要启动一个状态机,然后等待可能跟随的 Alt-B,如果在规定时间内没有收到 Alt-B,则回退到之前的状态。这实际上相当复杂!

使用 IObservable,这个问题可以更优雅地解决。假设我们有一个按键事件的流,keys。我们正在寻找两个事件——Alt-K 和 Alt-B,它们在同一个流中快速连续发生。为了做到这一点,我们需要探索如何将一个流与自身组合。考虑以下图表:

图片

理解这个图表非常重要。表达式 keys.Select(_ => keys) 产生一个新的 IObservable,它将 keys 产生的每个值映射到 keys 本身。因此,当 keys 产生第一个值“a”时,这个新的 IObservable 产生一个包含 keys 中所有后续值的 IObservable。当 keys 产生第二个值“b”时,这个新的 IObservable 产生另一个包含“b”之后所有值的 IObservable,依此类推。³

查看类型也可以帮助澄清这一点:

keys                   : IObservable<KeyInfo>
_ => keys              : KeyInfo → IObservable<KeyInfo>
keys.Select(_ => keys) : IObservable<IObservable<KeyInfo>>

如果我们使用 SelectMany 代替,所有这些值都会被展平到一个单一的流中:

图片

当然,如果我们正在寻找两个连续的按键,我们不需要所有跟随一个项目的值,只需要下一个。而不是将每个值映射到整个 IObservable,让我们使用 Take 减少到第一个项目:

图片

我们越来越接近了。现在,让我们进行以下更改:

  • 而不是忽略当前值,将其与下一个值配对。

  • 使用 SelectMany 获取平展的 IObservable

  • 使用 LINQ 语法。

结果表达式将 IObservable 中的每个值与其之前发出的值配对:

图片

这是一个相当有用的函数,我将称之为 PairWithPrevious。我们稍后会使用它。

但对于这个特定的场景,我们只想在时间上足够接近的情况下创建对。这可以通过使用 Take 的重载来实现,它接受一个 Timespan 作为以下列表所示。

列表 18.7 检测用户按下 Alt-K-B 键序列

IObservable<ConsoleKeyInfo> keys = //...
var halfSec = TimeSpan.FromMilliseconds(500);

var keysAlt = keys
   .Where(key => key.Modifiers.HasFlag(ConsoleModifiers.Alt));

var twoKeyCombis =
   from first in keysAlt                         ❶
   from second in keysAlt.Take(halfSec).Take(1)  ❶
   select (First: first, Second: second);        ❶

var altKB =
   from pair in twoKeyCombis
   where pair.First.Key == ConsoleKey.K
      && pair.Second.Key == ConsoleKey.B
   select Unit();

❶ 对于任何按键,将其与在半秒内发生的下一个按键配对

如你所见,解决方案简单而优雅。你可以应用这种方法来识别事件序列中的更复杂模式——而无需显式跟踪状态和引入副作用!

你可能也已经意识到,提出这样的解决方案并不一定容易。熟悉 IObservable 及其众多操作符需要一段时间,并且需要理解如何使用它们。

18.4.2 对多个事件源做出反应

假设我们有一个以欧元计价的银行账户,我们希望跟踪其美元价值。余额的变化和汇率的变化都会导致美元余额的变化。为了对来自不同流的变动做出反应,我们可以使用 CombineLatest,它在一个 IObservable 信号时获取两个可观察对象的最新值,如图 18.8 所示。

图 18.8 CombineLatest 在两个 IObservable 中的任何一个发出信号时都会发出信号。

其用法如下:

IObservable<decimal> balance = //...
IObservable<decimal> eurUsdRate = //...

var balanceInUsd = balance.CombineLatest(eurUsdRate
   , (bal, rate) => bal * rate);

这可行,但它没有考虑到汇率比账户余额波动性更大的事实。事实上,如果汇率来自外汇市场,每秒钟可能会有数十或数百次微小的变动!显然,对于想要关注其财务状况的私人客户来说,不需要这么详细的程度。对汇率每一次变动做出反应会向客户发送大量不想要的提醒。

这是一个 IObservable 产生过多数据的例子(请参阅“背压”侧边栏)。为此,我们可以使用 Sample 操作符,它接受一个充当数据源的 IObservable,以及另一个表示何时产生值的 IObservableSample 在图 18.9 中展示。

图 18.9 Sample 在采样流信号时产生源流的值。

在这个场景中,我们可以创建一个每 10 分钟发出信号的 IObservable,并使用它来采样汇率流,如下所示。

列表 18.8 每 10 分钟从 IObservable 中采样一个值

IObservable<decimal> balance = //...
IObservable<decimal> eurUsdRate = //...

var tenMins = TimeSpan.FromMinutes(10);
var sampler = Observable.Interval(tenMins);
var eurUsdSampled = eurUsdRate.Sample(sampler);

var balanceInUsd = balance.CombineLatest(eurUsdSampled
   , (bal, rate) => bal * rate);

这是我们逻辑跨越多个事件场景的另一个例子,使用 Rx 操作符 CombineLatestSample 允许我们在不显式保持任何状态的情况下编码此逻辑。

背压:当 IObservable 产生数据过快时

当你迭代 IEnumerableIAsyncEnumerable 中的项时,你是在“拉取”或请求项,因此你可以根据自己的节奏处理它们。使用 IObservable 时,项是“推”给你的(消费代码)。如果一个 IObservable 产生的值比订阅的观察者能够消费的更快,这可能会导致过度的 背压,从而对你的系统造成压力。

为了缓解背压,Rx 提供了几个操作符:

  • Throttle

  • Sample

  • Buffer

  • Window

  • Debounce

每个都有不同的行为和几个重载,所以我们不会详细讨论它们。重点是,使用这些算子,你可以轻松地声明式地实现逻辑,例如,“我想每次以 10 个批次的数量消费项目”,或者“如果一系列值快速连续到达,我只想要消费最后一个”。在基于回调的解决方案中实现这种逻辑,其中每个值都是独立接收的,你需要手动保持一些状态。

18.4.3 当账户透支时通知

对于一个最终的、更面向业务的例子,想象一下在 BOC 应用程序的环境中,我们消费了影响银行账户的所有交易的流,并且我们希望在账户余额变为负数时向客户发送通知。

账户余额是所有影响它的交易的总和,因此在任何时候,给定一个账户过去 Transaction 的列表,你可以使用 Aggregate 计算其当前余额。

对于 IObservable,有一个 Aggregate 函数;它等待 IObservable 完成,并将它产生的所有值聚合成一个单一值。但这并不是我们需要的:我们不想等待流完成,而是每次接收到一个新的 Transaction 时都重新计算余额。为此,我们可以使用 Scan(见图 18.10),它与 Aggregate 类似,但会聚合所有之前的值以及每次产生的新值。

图片

图 18.10 Scan 聚合到目前为止产生的所有值。

因此,我们可以有效地使用 Scan 来保持状态。给定影响银行账户的 TransactionIObservable,我们可以使用 Scan 来累加所有过去交易的数量,在账户余额发生变化时,获得一个发出新余额信号的 IObservable

IObservable<Transaction> transactions = //...
decimal initialBalance = 0;

IObservable<decimal> balance = transactions.Scan(initialBalance
   , (bal, trans) => bal + trans.Amount);

现在我们有一个表示账户当前余额的值流,我们需要单独识别哪些余额变化会导致账户“陷入赤字”,从正数变为负数。

为了做到这一点,我们需要查看余额的变化,我们可以使用 PairWithPrevious 来做到这一点,它同时发出当前值和之前发出的值。你已经在 18.4.1 节中看到了 PairWithPrevious 的实现,但这里再次提供以供参考:

// ----1-------2---------3--------4------>
//
//            PairWithPrevious
//
// ------------(1,2)-----(2,3)----(3,4)-->
//
public static IObservable<(T Previous, T Current)>
   PairWithPrevious<T>(this IObservable<T> source)
   => from first in source
      from second in source.Take(1)
      select (Previous: first, Current: second);

这是许多可以基于现有操作定义的自定义操作示例之一。前面的代码片段还展示了如何使用 ASCII 珠石图来记录你的代码。

我们可以使用 PairWithPrevious 来发出当账户余额变为负数时的信号:

IObservable<Unit> dipsIntoTheRed =
   from bal in balance.PairWithPrevious()
   where bal.Previous >= 0
      && bal.Current < 0
   select Unit();

现在让我们使事情更接近现实世界。如果你的系统接收到了一个交易流,这可能会包括所有账户的交易。因此,我们必须按账户 ID 对它们进行分组,以便正确计算余额。GroupBy 对于 IObservable 的工作方式与 IEnumerable 类似,但它返回一个流流。

图片

以下列表展示了如何适应逻辑,假设有一个所有账户的初始交易流。

列表 18.9 当账户透支时发出信号

IObservable<Transaction> transactions = //...    ❶

IObservable<Guid> dipsIntoRed = transactions
   .GroupBy(t => t.AccountId)                    ❷
   .Select(DipsIntoTheRed)                       ❸
   .MergeAll();                                  ❹

static IObservable<Guid> DipsIntoTheRed
   (IGroupedObservable<Guid, Transaction> transactions)
{
   Guid accountId = transactions.Key;
   decimal initialBalance = 0;

   var balance = transactions.Scan(initialBalance
      , (bal, trans) => bal + trans.Amount);

   return from bal in balance.PairWithPrevious()
          where bal.Previous >= 0
             && bal.Current < 0
          select accountId;                     ❺
}

public static IObservable<T> MergeAll<T>
   (this IObservable<IObservable<T>> source)
   => source.SelectMany(x => x);

❶ 包含所有账户的交易

❷ 按账户 ID 分组

❸ 发出特定账户透支的信号

❹ 将结果扁平化为单个可观察对象

❺ 发出违规账户的 ID

现在我们从所有账户的交易流开始,最终得到一个 Guid 流,该流会在任何账户出现透支时发出信号,包括标识违规账户的 Guid。注意这个程序是如何有效地跟踪所有账户的余额,而无需我们进行任何显式的状态操作。

18.5 你应该在何时使用 IObservable?

在本章中,你已经看到了如何使用 IObservable 来表示数据流,以及如何使用 Rx 创建和操作 IObservables。Rx 有许多细节和特性我们还没有讨论,但我们已经涵盖了足够的内容,让你开始使用 IObservables 并根据需要进一步探索 Rx 的特性。⁴

正如你所看到的,拥有一个能够捕获数据流的抽象,使你能够检测模式并指定跨越同一流中多个事件或不同流的事件的逻辑。这就是我建议使用 IObservable 的地方。其逆命题是,如果你的事件可以独立处理,那么你可能不应该使用 IObservables,因为使用它们可能会降低你代码的可读性。

需要记住的一个重要事情是,由于 OnNext 没有返回值,IObservable 只能向下推送数据,而永远不会接收任何数据。因此,IObservables 最好组合成 单向数据流。例如,如果你从队列中读取事件并将一些数据写入数据库作为结果,IObservable 就是一个很好的选择;同样,如果你有一个通过 WebSockets 与网络客户端通信的服务器,客户端和服务器之间以发送和忘记的方式交换消息,IObservable 同样适用。

另一方面,IObservables 并不适合像 HTTP 这样的请求-响应模型。你可以将接收到的请求建模为一个流,并计算一个响应流,但你将无法轻松地将这些响应与原始请求关联起来。

最后,如果你有无法用 Rx 的操作符捕获的复杂同步模式,并且需要更精细地控制消息的顺序和处理方式,你可能会发现 System.DataFlow 命名空间(基于内存队列)中的构建块更合适。

摘要

  • IObservable<T> 表示 T,即时间序列中的值序列。

  • IObservable 根据语法产生消息

    OnNext* (OnCompleted|OnError)?.
    
  • 使用 IObservables 编写程序涉及三个步骤:

    • 使用 System.Reactive.Linq .Observable 中的方法创建 IObservables。

    • 使用 Rx 或您可能定义的其他运算符转换和组合 IObservable

    • 订阅并消费 IObservable 生成的值。

  • 使用 Subscribe 将观察者关联到 IObservable

  • 通过处置 Subscribe 返回的订阅来移除观察者。

  • 将副作用(在观察者中)与逻辑(在流转换中)分离。

  • 在决定是否使用 IObservable 时,请考虑以下因素:

    • IObservable 允许您指定跨越多个事件的逻辑。

    • IObservable 适用于建模单向数据流。


IObserver 是在 IObservable 接口中声明的方法。接受回调的重载是一个扩展方法。

² Rx 包含几个库。主要库 System.Reactive 打包了您最常需要的包:System.Reactive.InterfacesSystem.Reactive.CoreSystem.Reactive.LinqSystem.Reactive.PlatformServices。还有几个在其他更专业场景中很有用的包,例如如果您正在使用 Windows forms。

假设 keys 是一个 IEnumerable,那么 keys.Select(_ => keys) 会是什么样子:对于每个值,你都会获取整个 IEnumerable。最终,你会得到一个包含 keysn 个副本的 IEnumerablenkeys 的长度)。由于 IObservable 中存在时间元素,所以当你说“给我 keys”时,你实际上得到的是 keys 将在未来产生的所有值。

⁴ 为了让您了解未涵盖的内容,还有许多其他运算符以及 Rx 的重要实现细节:调度器(确定如何调度对观察者的调用)、 观察者(并非所有观察者都是懒加载的),以及具有不同行为的 Subject 等。

19 基于消息传递的并发简介

本章涵盖了

  • 为什么有时需要共享可变状态

  • 理解基于消息传递的并发

  • 使用 C#进行代理编程

  • 将基于代理的实现隐藏在传统的 API 后面

每个经验丰富的开发者都有一些亲身经历,了解处理死锁和竞态条件等问题的难度。这些是可能在涉及共享可变状态(即,在并发执行的进程之间)的并发程序中出现的难题。

这就是为什么,在这本书的整个过程中,你已经看到了许多如何在不依赖共享可变状态的情况下解决问题的例子。实际上,我的建议是尽可能避免共享可变状态,而函数式编程(FP)提供了一个很好的范式来实现这一点。

在本章中,你将了解为什么有时无法避免共享可变状态,以及有哪些策略可以同步对共享可变状态的访问。然后我们将专注于这些策略之一:基于代理的并发,这是一种依赖于代理之间消息传递的并发编程风格,这些代理“拥有”一些它们以单线程方式访问的状态。使用代理编程在 F#程序员中很受欢迎,但你将看到它在 C#中也是完全可以实现的。

19.1 需要共享可变状态

在设计并行算法时,通常可以避免共享可变状态。例如,如果你有一个计算密集型问题,你希望并行化,你通常可以将数据集或任务分解成这样的方式,即几个线程独立地计算一个中间结果。因此,这些线程可以在不需要共享任何状态的情况下完成他们的工作。另一个线程可以通过组合所有中间结果来计算最终结果。

然而,避免共享可变状态并不总是可能的。尽管在并行计算的情况下通常可以实现,但如果并发源是多线程,那就困难得多。例如,想象一个多线程应用程序,比如一个在多个线程上处理请求的服务器,它需要执行以下操作:

  • 保持一个应用程序范围内的计数器,以便可以生成唯一的、顺序的账户号码。

  • 在内存中缓存一些资源以提高效率。

  • 表示现实世界中的实体,如待售的商品、交易、合同等,确保在收到两个购买同一(唯一、现实世界)商品的并发请求时,不会重复出售。

在这种情况下,对于服务器应用程序使用的许多线程之间共享可变状态基本上是必需的。为了防止并发访问导致数据不一致,你需要确保状态不能被不同线程同时访问(或者至少,不能被更新)。也就是说,你需要对共享可变状态进行同步访问。

在主流编程中,这种同步通常是通过锁来实现的。定义了代码的关键部分,一次只能由一个线程进入。当一个线程进入关键部分时,它会阻止其他线程进入。函数式程序员倾向于避免使用锁,而转而采用替代技术:

  • 比较并交换(CAS)——CAS 允许你原子地读取和更新单个值,这可以在 C#中使用Interlocked.CompareExchange方法完成。

  • 软件事务内存(STM)——STM 允许你在事务中更新可变状态,这为这些更新如何发生提供了一些有趣的保证:

    • 每个线程都在隔离中执行事务。 它看到的是一个不受其他线程上并发发生的事务影响的程序状态视图。

    • 然后事务原子性地提交。 要么将事务中的所有更改保存,要么一个都不保存。¹

    • 冲突的事务不一定失败。 如果一个事务因为另一个并发事务中做出的冲突更改而失败,它可以带着对数据的全新视图重新尝试。

  • 消息传递并发——这种方法的理念是设置轻量级进程,它们拥有某些可变状态的独占所有权。进程之间的通信是通过消息传递进行的,进程按顺序处理消息,从而防止对它们状态的并发访问。这种方法的两种主要实现是:

    • actor 模型——这种模型最著名的是在爱立信与 Erlang 语言结合下实现的,但其他语言,包括 C#,也有许多实现。在这个模型中,进程被称为actor,它们可以分布在不同进程和机器上。

    • 基于代理的并发——这是受到 actor 模型的启发,但它要简单得多,因为称为代理的进程只存在于一个应用程序中。

CAS 只允许你处理单个值,因此它为非常有限数量的场景提供了一个有效的解决方案。

STM 是进程内并发的 重要范式,它在 Clojure 和 Haskell 开发者中尤其受欢迎,因为这些语言提供了引人注目且经过实战检验的 STM 实现。如果你想在 C#中探索这个范式,language-ext 包含了 AtomRef 的实现,这些是允许你原子地更新线程间共享数据的原语。²

在本章的其余部分,我将专注于消息传递并发,特别是基于代理的并发。你将在以后看到代理和 actor 在更多细节上的区别。让我们首先从将消息传递并发作为一个编程模型来探讨。

19.2 理解消息传递并发

您可以将代理(或演员;基本思想是相同的)视为一个拥有某些可变状态的独占所有权的进程。演员之间的通信是通过消息传递进行的,这样状态就永远不会从代理外部被访问。此外,传入的消息是顺序处理的,这样就不会发生并发状态更新。

图 19.1 说明了代理:一个在循环中运行的进程。它有一个消息队列,其中消息被排队,它还有一些状态。当消息被出队并处理时,代理通常会执行以下操作之一:

  • 执行副作用

  • 向其他代理发送消息

  • 创建其他代理

  • 计算其新状态

图 19.1 一个代理由一个消息收件箱和一个处理循环组成。

新状态将在下一次迭代中用作当前状态,当处理以下消息时。

让我们从我刚才描述的理想化、几乎是伪代码的代理实现开始。仔细查看以下列表中的代码,看看每个部分如何对应于图 19.1 中的描述。

列表 19.1 代理的理想化实现

public sealed class Agent<State, Msg>
{
   BlockingCollection<Msg> inbox                     ❶
      = new BlockingCollection<Msg>(new ConcurrentQueue<Msg>());

   public void Tell(Msg message)                     ❷
      => inbox.Add(message);                         ❷

   public Agent                                      ❸
   (
      State initialState,
      Func<State, Msg, State> process
   )
   {
      void Loop(State state)
      {
         Msg message = inbox.Take();                 ❹
         State newState = process(state, message);   ❺
         Loop(newState);                             ❻
      }

      Task.Run(() => Loop(initialState));            ❼
   }
}

❶ 使用并发队列作为消息收件箱

❷ 将消息告诉代理只是将消息入队。

❸ 通过提供初始状态和处理函数创建代理

❹ 一旦可用就出队消息

❺ 处理消息,确定代理的新状态

❻ 使用新状态进行循环

❼ 演员在自己的进程中运行。

这里有一些有趣的事情要注意。首先,请注意,只有两个公共成员,因此只能允许与代理进行两种交互:

  • 您可以创建(或启动)一个代理。

  • 您可以告诉它一个消息,这只是在代理的收件箱中入队消息。

您可以从这些原始操作定义更复杂的交互。

让我们现在看看处理循环,它在Loop函数中编码。这从收件箱中取出第一条消息(或等待直到有消息可用)并使用代理的处理函数及其当前状态进行处理。这产生了代理的新状态,该状态用于下一次循环的执行。

注意,除了在调用给定的处理函数时可能发生的任何副作用之外,实现是无副作用的。状态变化是通过始终将状态作为Loop函数的参数传递来捕获的(这是您在 15 章中已经看到的技术)。

注意,此实现假定State必须是一个不可变类型;否则,它可能被process函数共享并在代理处理循环的范围之外任意更新。因此,状态只“看起来”是可变的,因为每次调用Loop时都使用状态的新版本。

最后,花点时间看看构造函数的签名。它让你想起了什么吗?与 Enumerable.Aggregate 进行比较。你能看出它基本上是相同的吗?代理的当前状态是它迄今为止接收到的所有消息的归约结果,使用初始状态作为累加器值,处理函数作为归约器。这是对代理接收到的消息流进行的时间折叠。

这种实现是优雅的,并且在一个具有尾调用消除的语言中会工作得很好。但在 C# 中,这个特性并不存在,因此我们需要做一些修改以实现栈安全。此外,我们还可以通过使用 .NET 中的现有功能来放弃许多底层细节。我们将在下一部分查看这一点。

19.2.1 在 C# 中实现代理

.NET 包含一个名为 MailboxProcessor 的代理实现,但它是为 F# 设计的,从 C# 使用起来有些尴尬。尽管前面的实现对于理解这个想法很有用,但它并不是最优的。相反,在接下来的示例中,我将使用一个更实用的代理实现,它包含在 LaYumba.Functional 中,并在下面的列表中展示。

列表 19.2 基于 Dataflow.ActionBlock 的代理实现

using System.Threading.Tasks.Dataflow;

public interface Agent<Msg>
{
   void Tell(Msg message);
}

class StatefulAgent<State, Msg> : Agent<Msg>
{
   private State state;
   private readonly ActionBlock<Msg> actionBlock;

   public StatefulAgent
   (
      State initialState,
      Func<State, Msg, State> process
   )
   {
      state = initialState;

      actionBlock = new ActionBlock<Msg>(msg =>
      {
         var newState = process(state, msg);   ❶
         state = newState;                     ❷
      });
   }

   public void Tell(Msg message)
      => actionBlock.Post(message);            ❸
}

❶ 使用当前状态处理消息

❷ 将结果分配给存储的状态

❸ 消息的排队和处理由 ActionBlock 管理。

在这里,我已经将递归调用(可能导致栈溢出)替换为一个单例可变变量 state,它跟踪代理的状态,并在处理每个消息时重新分配。尽管这是一个副作用,但消息是顺序处理的,因此防止了并发写入。

我还通过使用 .NET 的 Dataflow 库中的构建块之一 ActionBlock 来放弃了管理队列和过程的细节。ActionBlock 包含一个缓冲区(默认情况下,大小无界),充当代理的收件箱,并且只允许固定数量的线程进入该块(默认情况下,单个线程),确保消息顺序处理。

State 应仍然是一个不可变类型(否则,如前所述,它可能被 process 函数共享并在 ActionBlock 的作用域之外被修改)。如果观察到这一点,代码是线程安全的。

从客户端代码的角度来看,没有任何变化:我们仍然只有两个具有与之前相同签名的公共成员。Agent<Msg> 接口的原因有两个:

  • 从客户端代码消耗代理的角度来看,你只能向它传递消息,因此通过使用接口,我们避免了暴露状态的类型参数。毕竟,状态类型是代理的实现细节。

  • 你可以设想其他实现,例如无状态代理或持久化状态的代理。

最后,这里有一些方便的方法,可以轻松创建代理:

public static class Agent
{
   public static Agent<Msg> Start<State, Msg>
      ( State initialState
      , Func<State, Msg, State> process)
      => new StatefulAgent<State, Msg>(initialState, process);

   public static Agent<Msg> Start<Msg>(Action<Msg> action)
      => new StatelessAgent<Msg>(action);
}

第一个重载简单地使用给定的参数创建一个代理。第二个接受一个动作并用于创建一个无状态代理:一个按顺序处理消息但不保留任何状态的代理。(实现是微不足道的,因为它只是创建了一个带有给定ActionActionBlock)。我们还可以定义具有异步处理函数/动作的代理;为了简洁起见,我省略了重载,但完整的实现可以在代码示例中找到。接下来,我们将开始使用代理。

19.2.2 开始使用代理

让我们看看一些使用代理的简单示例。我们将构建几个简单的代理,它们以图 19.2 所示的方式交互。

图 19.2 通过交换消息的代理之间的简单交互

我们将从一个非常简单、无状态的代理开始,该代理接收类型为string的消息并将其打印出来。你可以在 REPL 中跟随:

Agent<string> logger = Agent.Start((string msg) => WriteLine(msg));

logger.Tell("Agent X");
// prints: Agent X

接下来,让我们定义与logger和彼此交互的pingpong代理:

Agent<string> ping, pong = null;

ping = Agent.Start((string msg) =>
{
   if (msg == "STOP") return;

   logger.Tell($"Received '{msg}'; Sending 'PING'");
   Task.Delay(500).Wait();
   pong.Tell("PING");
});

pong = Agent.Start(0, (int count, string msg) =>
{
   int newCount = count + 1;
   string nextMsg = (newCount < 5) ? "PONG" : "STOP";

   logger.Tell($"Received '{msg}' #{newCount}; Sending '{nextMsg}'");
   Task.Delay(500).Wait();
   ping.Tell(nextMsg);

   return newCount;
});

ping.Tell("START");

在这里,我们定义了两个额外的代理。ping是无状态的;它向logger代理发送消息,并向pong代理发送 PING 消息,除非它被告知的消息是 STOP,在这种情况下,它什么都不做。代理根据消息有不同的行为是很常见的,也就是说,将消息解释为命令。

现在,让我们看看一个有状态的代理:pong。实现与ping非常相似。它向ping发送 PONG,但它还保持一个计数器作为状态。计数器在每次消息后递增,并在五条消息后,代理发送一个 STOP 消息。

当我们在最后一行向ping发送初始的 START 消息时,整个 ping-pong 过程就开始了。运行程序会导致以下内容被打印出来:

Received 'START'; Sending 'PING'
Received 'PING' #1; Sending 'PONG'
Received 'PONG'; Sending 'PING'
Received 'PING' #2; Sending 'PONG'
Received 'PONG'; Sending 'PING'
Received 'PING' #3; Sending 'PONG'
Received 'PONG'; Sending 'PING'
Received 'PING' #4; Sending 'PONG'
Received 'PONG'; Sending 'PING'
Received 'PING' #5; Sending 'STOP'

现在你已经看到了一些简单的代理交互,是时候转向更接近现实世界需求的内容了。

19.2.3 使用代理处理并发请求

让我们回顾一下提供汇率的服务场景。该服务应从汇率 API 检索汇率并将其缓存。我们在第 15.1 节中看到了一个简单的实现,但那里的交互是通过命令行进行的,因此请求必然是一个接一个地到来。

让我们改变一下。让我们想象该服务是更大系统的一部分,并且其他组件可能通过消息代理请求汇率,如图 19.3 所示。

图 19.3 一个可能同时接收多个请求的系统

组件通过通过消息代理发送消息来相互通信。要与货币查找服务通信,定义了以下消息:

record FxRateRequest
(
   string CcyPair,     ❶
   string Sender       ❷
 );

record FxRateResponse
(
   string CcyPair,
   decimal Rate,
   string Recipient    ❷
);

❶ 正在请求汇率的货币对

❷ 发送者和接收者字段允许消息代理正确路由消息。

我们假设消息代理是多线程的,因此我们的服务可以在同一时间接收来自不同线程的多个请求。

在这种情况下,线程间共享状态是一个要求:如果我们为每个线程有一个不同的缓存,那将是不理想的。因此,我们需要一些同步来确保我们不执行不必要的远程查找,并且缓存更新不会导致竞争条件。

接下来,我们将看到如何使用代理来实现这一点。首先,我们需要一些设置代码,定义与消息代理的交互。这显示在列表 19.3 中。请注意,代码并不特定于任何特定的消息代理;我们只需要能够订阅它以接收请求并使用它来发送响应。(代码示例包括一个使用 Redis 作为其底层传输的MessageBroker实现。)

列表 19.3 设置与消息代理的交互

public static void SetUp(MessageBroker broker)
{
   Agent<FxRateResponse> sendResponse = Agent.Start(
      (FxRateResponse res) => broker.Send(res.Recipient, res));      ❶

   Agent<FxRateRequest> processRequest
      = StartReqProcessor(sendResponse);                             ❷

   broker.Subscribe<FxRateRequest>("FxRates", processRequest.Tell);  ❸
}

❶ 发送响应的代理

❷ 处理请求并使用先前定义的代理发送响应的代理

❸ 当收到请求时,将其传递给处理代理

从底部开始,我们订阅接收在“FxRates”频道上广播的请求,提供一个回调来处理请求。这个回调(将在多个线程上调用)只是简单地将请求传递给上一行定义的处理代理。因此,尽管请求是在多个线程上接收的,但它们将立即排队在处理代理的收件箱中并按顺序处理。

这是否意味着处理现在是单线程的,我们就失去了多线程的任何好处?不一定!如果处理代理做了所有的处理,那确实是这样。相反,让我们采取更细粒度的方法:我们可以为每个货币对有一个代理负责获取和存储其特定对的汇率。请求处理代理将只负责管理这些按货币对划分的代理并将工作委托给它们,如图 19.4 所示。

图 19.4 显示代理之间并行工作的分解

现在让我们看看代理的定义。以下列表显示了高级代理,它处理传入的请求并启动低级的按货币对划分的代理,将工作委托给它们。

列表 19.4 一个协调代理将请求路由到每个货币对的代理

using CcyAgents = System.Collections.Immutable
   .ImmutableDictionary<string, Agent<string>>;

static Agent<FxRateRequest> StartReqProcessor
   (Agent<FxRateResponse> sendResponse)

   => Agent.Start(CcyAgents.Empty
      , (CcyAgents state, FxRateRequest request) =>
   {
      string ccyPair = request.CcyPair;

      Agent<string> agent = state
         .Lookup(ccyPair)
         .GetOrElse(() => StartAgentFor(ccyPair, sendResponse));   ❶

      agent.Tell(request.Sender);                                  ❷
      return state.Add(ccyPair, agent);
   });

❶ 如果需要,为请求的货币对启动一个新的代理

❷ 将请求传递给负责该对的代理

如您所见,请求处理代理持有的是代理的缓存,而不是值,每个货币对一个代理。它根据需要启动这些代理,并将请求转发给它们。

这种解决方案的好处是,对于一种货币的请求,例如 GBPUSD,不会影响另一种货币的请求,例如 EURUSD。另一方面,如果你同时收到多个 GBPUSD 的请求,只有一次远程请求会被用来获取该汇率,而其他请求将被排队。

最后,以下列表提供了管理单一货币对汇率的代理的定义。

列表 19.5 管理单一货币对汇率的代理

static Agent<string> StartAgentFor
(
   string ccyPair,
   Agent<FxRateResponse> sendResponse
)
=> Agent.Start<Option<decimal>, string>
(
   initialState: None,
   process: async (optRate, recipient) =>
   {
      decimal rate = await optRate.Map(Async)
         .GetOrElse(() => RatesApi.GetRateAsync(ccyPair));   ❶

      sendResponse.Tell(new FxRateResponse                   ❷
      (
         CcyPair: ccyPair,
         Rate: rate,
         Recipient: recipient
      ));

      return Some(rate);                                     ❸
   }
);

❶ 如果需要,从远程 API 获取速率

❷ 发送响应

❸ 代理的新状态

这个代理的状态是单一对的汇率;它被包裹在一个Option中,因为当代理首次创建时,它还没有可用的汇率。在收到请求后,代理决定是否需要远程查找(你可以轻松地改进这一点,如果缓存的值已过期,则获取汇率)。

为了使例子简单,我避免了过期问题和错误处理的问题。我还假设向消息代理发送请求是一个具有最小延迟的“发送并忘记”操作,因此可以有一个单独的代理执行它。

例子中的主要观点是,使用具有消息顺序处理的代理可以非常高效。然而,这确实需要从我们在本书中追求的函数式方法以及传统的使用锁的方法进行思维上的转变。

19.2.4 代理与演员

代理和演员密切相关。在两种情况下,单个线程按顺序处理消息并与其他演员/代理通过发送消息进行通信。也存在一些重要的区别:

  • 代理在单个进程中运行,而演员是为分布式系统设计的。在我们迄今为止看到的例子中,对代理的引用指的是当前进程中的特定实例。另一方面,对演员的引用是位置透明的;当你有一个演员的引用时,该演员可能正在同一进程中运行,也可能在另一个进程中运行,可能在远程机器上。代理的引用是一个指针,而演员的引用是演员模型实现用来在进程间路由消息的 ID。

  • 演员模型实现旨在具有容错性。例如,Erlang 包括监督者:监控受监督演员并在他们失败时采取行动的演员。常规演员处理快乐路径,而监督者负责恢复,从而提高系统的鲁棒性。代理没有这样的对应物。

  • 代理(或演员)的状态应该是不可变的,并且永远不会在代理的作用域之外共享。然而,在我们的代理实现中,没有任何阻止一个缺乏经验的开发者创建一个状态可变的代理并将该可变状态传递给其他组件,从而允许从代理的作用域之外改变该状态。对于演员,消息是序列化的,因此这种情况不应发生。

如您所见,尽管代理和演员背后的基本思想相同,但演员模型更为丰富和复杂。您只有在需要跨不同应用程序或机器协调并发操作时才应考虑使用演员模型;否则,运营和设置成本将是不合理的,您应该依靠代理。

虽然我能够用几行代码实现一个演员,但实现演员模型要复杂得多。因此,如果您想使用演员,您可能会使用几个 .NET 演员模型实现之一:

  • 奥尔良 (github.com/dotnet/orleans) 是微软对演员模型的实现。它具有明显的面向对象的感觉。其背后的哲学是,经验较少的开发者可以像与本地对象交互一样与演员(称为 grains)交互,而无需接触任何特定于演员模型的额外复杂性。奥尔良负责管理粒子的生命周期,这意味着其状态自动保存在内存中或持久化到存储中。持久化可以到各种媒体,包括 SQL Server 和 Azure 上的云存储。

  • Akka.NET (getakka.net/) 是 Scala 开发者中流行的 Akka 框架的社区驱动移植。它早于奥尔良,并且对其消息驱动特性更为明确,因此入门门槛更高。为演员的状态传输和持久化提供了各种选项。

  • echo (github.com/louthy/echo-process) 是最接近 Erlang 的 .NET 实现,由保罗·劳斯开发。它在语法和配置方面都是最轻量级的选项:您可以使用与代理相同的函数创建一个演员(称为 process),或者您可以使用基于接口的方法(如果您需要处理不同类型的消息,这种方法读起来更自然)。开箱即用,echo 只支持跨应用程序域的消息传递和通过 Redis 的持久化,但您可以实现适配器以针对不同的基础设施。

所有这些演员模型实现不仅在术语上不同,而且在重要的技术细节上也有所不同,因此很难提供一个对 演员模型 的描述而不针对某个特定实现。这就是我选择用一个简单的代理实现来展示消息传递并发基本思想的原因之一。您可以将这些原则应用到基于演员的编程中,但您需要学习其他原则,例如使用监督器进行错误处理以及特定实现提供的消息传递保证。

19.3 功能性 API,基于代理的实现

基于代理的编程甚至是函数式编程吗?尽管代理和演员是在函数式语言的环境中开发的(记住,在我们的理想化实现中,代理是无副作用的),但基于代理的编程与你在本书中看到的函数式技术截然不同:

  • 你告诉代理一个消息,这通常被解释为命令,因此语义是命令式的。

  • 代理通常会对另一个代理执行副作用或发送消息,而这个代理随后会执行副作用。

  • 最重要的是,告诉代理一个消息不会返回数据,因此你不能像使用函数那样将告诉操作组合成管道。

  • FP 将逻辑与数据分离;代理包含数据以及在处理函数中至少一些逻辑。

因此,基于代理的编程“感觉”与你在本书中看到的 FP 不同,因此关于基于代理的并发是否实际上是函数式技术是有争议的。如果你认为它不是(正如我倾向于认为的那样),那么你必须得出结论,FP 在某些类型的并发(共享可变状态无法避免)方面并不擅长,并且需要用不同的范式来补充,如基于代理的编程或演员模型。

使用代理,可以轻松地编程单向数据流:数据始终向前流动(流向下一个代理),并且永远不会返回数据。面对这种情况,我们有两种选择:

  • 接受单向数据流的理念,并以这种方式编写应用程序。在这种方法中,如果你有客户端连接到服务器,你不会使用像 HTTP 这样的请求-响应模型,而是使用基于消息的协议,如 WebSockets 或消息代理。这是一个可行的方案,特别是如果你的领域足够以事件驱动,你已经有了一个消息基础设施。

  • 在更传统的 API 后面隐藏代理特定的细节。这表明代理应该能够对消息发送者返回响应。在这种方法中,代理被用作并发原语,是实现细节(就像锁一样),并且不应该决定程序的设计。我们将在下一节探讨这种方法。

19.3.1 代理作为实现细节

我们首先需要的是从代理那里获得回复的方法,一种“返回值”的形式。想象一下,发送者创建了一个包含它可以等待的处理器的消息。然后,它将消息告诉代理,代理在该处理器上发出信号,使其对发送者可用。有了这个,我们就可以在“一次性发送”的Tell协议之上实现双向通信。

TaskCompletionSource 提供了适合此目的的句柄:发送者可以创建一个 TaskCompletionSource,将其添加到消息负载中,并等待其 Task。代理将在准备好时完成其工作并在 TaskCompletionSource 上设置结果。对于每个你想要响应的消息手动做这件事将是繁琐的,所以相反,我在我的 LaYumba.Functional 库中包含了一个增强的代理,它负责所有这些连接。我不会在这里包含实现细节,但接口定义如下:

public interface Agent<Msg, Reply>
{
   Task<Reply> Tell(Msg message);
}

注意,这是一个全新的接口,它有两个通用参数:代理接受的消息类型以及它回复的类型。向这个代理发送类型为 Msg 的消息将返回一个 Task<Reply>。要启动这种类型的代理,我们将使用一个类型为

State → Msg → (State, Reply)

或者它的异步版本

State → Msg → Task<(State, Reply)>

这是一个函数,它根据代理的当前状态和接收到的消息,不仅计算代理的新状态,还计算要返回给发送者的回复。

让我们看看一个简单的例子——一个保持计数器并可以被指示增加计数器的代理,它还返回计数器的新的值:

var counter = Agent.Start(0
   , (int state, int msg) =>
   {
      var newState = state + msg;
      return (newState, newState);   ❶
    });

❶ 返回要存储的新状态和回复给发送者

你现在可以这样消费这个代理:

var newCount = await counter.Tell(1);
newCount // => 1
newCount = await counter.Tell(1);
newCount // => 2

注意,Tell 返回一个 Task<int>,所以调用者可以像任何异步函数一样等待回复。本质上,你可以使用这个代理作为线程安全、有状态、异步的 Msg Reply 类型的函数版本:

  • 线程安全 因为它内部使用一个 ActionBlock,一次处理一个消息

  • 有状态的 因为代理保持的状态可能会因为处理消息而改变

  • 异步 因为你的消息可能需要等待代理处理其队列中的其他消息

这意味着,与使用锁相比,你不仅获得了安全性(没有死锁),还获得了性能(锁会阻塞当前线程,而 await 则释放线程去做其他工作)。

19.3.2 在传统 API 后面隐藏代理

现在我们已经建立了一种双向通信机制,我们可以通过隐藏基于代理编程的细节来改进 API。例如,在计数器的例子中,我们可以定义一个 Counter 类,如下所示。

列表 19.6 在公共 API 后面隐藏基于代理的实现

public sealed class Counter
{
   readonly Agent<int, int> counter =           ❶
      Agent.Start(0, (int state, int msg) =>
         {
            var newState = state + msg;
            return (newState, newState);
         });

   public Task<int> IncrementBy(int amount)     ❷
      => counter.Tell(amount);
}

❶ 代理是实现细节。

Counter 的公共接口

现在,Counter 的消费者可以无忧无虑地不知道其基于代理的实现。典型的交互方式如下:

var counter = new Counter();
var newCount = counter.IncrementBy(10);
await newCount // => 10

19.4 LOB 应用中的消息传递并发

在 LOB 应用程序中,需要同步访问某些共享状态通常是由于应用程序中的实体代表现实世界实体,我们需要确保并发访问不会使它们处于无效状态或以其他方式破坏业务规则。例如,对特定商品的两次并发购买请求不应导致该商品被卖两次。同样,多人游戏中的并发移动不应导致游戏处于无效状态。

让我们看看这在我们的银行场景中会如何展开。我们需要确保当不同的交易(借记、贷记、转账)同时发生时,它们不会使账户处于无效状态。这意味着我们需要同步对账户数据的访问吗?不一定!让我们看看如果我们不对并发采取任何特殊措施会发生什么。

想象一个余额为 1,000 元的账户。一个自动的直接借记付款发生,导致账户被扣除 800 元。同时,请求转账 200 元,以便 200 元的金额也被扣除。如果我们使用本书中迄今为止所示的事件溯源方法,我们得到以下结果:

  • 直接借记请求导致事件的创建,记录了 800 元的借记,调用者将收到一个余额为 200 元的更新状态。

  • 转账请求同样导致事件的创建,记录了 200 元的借记,调用者将收到一个余额为 800 元的更新状态。

  • 当账户下次加载时,其状态是从所有过去的事件中计算出来的,以确保其状态将正确地有一个余额为 0。

  • 随着新事件的发布,任何订阅更新的客户端都可以反映这些状态变化。(例如,在发起转账请求的客户端设备上,当直接借记发生时,可以通知用户账户余额始终是最新的。)

简而言之,如果你使用不可变对象和事件溯源,你不会因为并发更新而导致不一致的数据;这是事件溯源的一个重要好处。

现在让我们用一个新的业务需求丰富这个场景。每个账户都分配了一个最大允许的透支额,这意味着账户余额永远不会低于一定金额。现在想象我们有以下情况:

  • 余额为 1,000 元,最大透支额为 500 元的账户

  • 800 元的直接借记付款

  • 同时,还有一个 800 元的转账请求

如果你没有同步对账户数据的访问,两个请求都将成功,导致账户透支 600 元,这违反了我们的业务要求,即透支额不应超过 500 元。为了强制执行最大允许的透支额,我们需要同步修改账户余额的操作执行,因此在这种情况下,并发请求中的一个应该失败。接下来,你将看到如何使用 actor 来实现这一点。

19.4.1 使用代理同步访问账户数据

为了确保账户数据不会被不同的请求同时影响,我们可以为每个账户关联一个代理。请注意,代理足够轻量级,因此可以拥有数千甚至数百万个。另外请注意,我假设有一个单独的服务器进程,通过该进程可以影响账户。如果不是这种情况,您需要使用 actor 模型实现,但以下实现的精髓仍然有效。

要将代理与账户关联,我们将定义一个具有基于代理实现的AccountProcess类。这意味着我们现在使用三个类来表示账户:

  • AccountState—一个记录,表示在特定时间点账户的状态

  • Account—一个只包含用于计算状态转换的纯函数的静态类

  • AccountProcess—一个基于代理的实现,用于跟踪账户的当前状态并处理影响账户状态的任何命令

您在第十三章中看到了AccountAccountState的实现,这些不需要更改。下面的列表显示了AccountProcess的实现。

列表 19.7 顺序处理影响账户的命令

using Result = Validation<(Event Event, AccountState NewState)>;

public class AccountProcess
{
   Agent<Command, Result> agent;

   public AccountProcess
   (
      AccountState initialState,
      Func<Event, Task<Unit>> saveAndPublish
   )
   => this.agent = Agent.Start(initialState
      , async (AccountState state, Command cmd) =>
      {
         Result result = cmd switch
         {
            MakeTransfer transfer => state.Debit(transfer),        ❶
            FreezeAccount freeze => state.Freeze(freeze),          ❶
         };

         await result.Traverse(tpl => saveAndPublish(tpl.Event));  ❷
         var newState = result
            .Map(tpl => tpl.NewState)
            .GetOrElse(state);

         return (newState, result);
      });

   public Task<Result> Handle(Command cmd)
      => agent.Tell(cmd);                                          ❸
}

❶ 使用纯函数来计算命令的结果

❷ 在块内持久化事件,这样代理就不会在未持久化的状态下处理新的消息

❸ 所有命令都排队并顺序处理。

AccountProcess的每个实例内部都包含一个代理,这样就可以顺序处理影响账户的所有命令。让我们看看代理的主体:首先,我们根据命令和当前状态计算命令的结果。这是使用纯、静态函数完成的。

记住,结果是包含结果Event和新的账户状态的Validation。如果结果是Valid,我们继续保存和发布创建的事件(检查作为Traverse的一部分进行)。

需要注意的是,持久化发生在处理函数内部。也就是说,代理在成功持久化代表其当前状态转换的事件之前,不应更新其状态并开始处理新的消息。(否则,持久化事件可能会失败,导致代理的状态与持久化事件捕获的状态不匹配。)

最后,我们返回账户的更新状态(用于处理后续命令)和命令的结果。这个结果包括新的状态和创建的事件,封装在一个Validation中。这使得将请求的成功和结果细节发送回客户端变得容易。

注意代理(和 actor)如何结合状态、行为和持久性(因此,它们被标记为“比对象更面向对象”)。在这个实现中,我注入了一个用于持久化事件的函数,而大多数 actor 模型的实现包括一些可配置的机制来持久化 actor 的状态。

19.4.2 维护账户注册表

现在我们有一个可以以线程安全的方式处理特定账户命令的AccountProcess。但是 API 端点的代码如何获取相关账户的AccountProcess实例?我们如何确保我们不会意外地为同一账户创建两个AccountProcess

我们需要一个单一的应用程序范围注册表,用于存储所有活动的AccountProcess。它需要管理它们的创建并通过 ID 提供服务,以便处理客户端请求的代码可以通过提供请求中包含的账户 ID 简单地获取一个AccountProcess

Actor 模型实现中内置了这样的注册表,允许你将任何特定的 actor 注册到任意 ID。在我们的案例中,我们将构建自己的简单注册表。以下列表展示了第一次尝试这样做的方法。

列表 19.8 存储和管理AccountProcess的创建

using AccountsCache = ImmutableDictionary<Guid, AccountProcess>;

public class AccountRegistry
{
   Agent<Guid, Option<AccountProcess>> agent;

   public AccountRegistry
   (
      Func<Guid, Task<Option<AccountState>>> loadState,
      Func<Event, Task<Unit>> saveAndPublish
   )
   => this.agent = Agent.Start
   (
      initialState: AccountsCache.Empty,
      process: async (AccountsCache cache, Guid id) =>
      {
         if (cache.TryGetValue(id, out AccountProcess account))
            return (cache, Some(account));

         var optAccount = await loadState(id);                         ❶

         return optAccount.Map(accState =>
         {
            AccountProcess account = new(accState, saveAndPublish);    ❷
            return (cache.Add(id, account), Some(account));
         })
         .GetOrElse(() => (cache, None));
      }
   );

   public Task<Option<AccountProcess>> Lookup(Guid id)
      => agent.Tell(id);
}

❶ 如果请求的AccountProcess不在缓存中,则从数据库加载当前状态

❷ 使用检索到的状态创建AccountProcess

在这个实现中,我们有一个单独的代理来管理一个缓存,其中保存了所有活动的AccountProcess实例。如果找不到给定 ID 的AccountProcess,则从数据库中检索账户的当前状态,用于创建一个新的AccountProcess,并将其添加到缓存中。请注意,像往常一样,loadState函数返回一个Task<Option<AccountState>>,以承认该操作是异步的,并且可能找不到给定 ID 的数据。

在继续阅读之前,再次审视一下实现。你能看到这种方法的任何问题吗?让我们看看:在代理体内部完成从数据库加载账户状态;这是有必要的吗?这意味着读取账户x的状态将阻塞对账户y感兴趣的另一个线程。这肯定不是最优的!

19.4.3 代理不是对象

这是一种常见的学校男孩错误,当你开始习惯使用代理或 actor 进行编程时。尽管代理和 actor 类似于对象,但你不能把它们当作对象来考虑。列表 19.8 中的错误是我们从概念上赋予代理提供请求的AgentProcess的责任,这导致了一个次优解。

相反,代理应该只负责管理一些状态。所讨论的代理管理一个字典,因此我们可以调用它来查找一个条目,或者添加一个新条目,但前往数据库检索数据是一个相对较慢的操作,这与直接管理AgentProcess的缓存无关。

考虑到这一点,让我们考虑一个替代方案。一个想要获取账户 ID 的AgentProcess的线程应该做以下事情:

  1. 要求代理查找 ID。

  2. 如果没有存储AgentProcess,则从数据库中检索账户的状态(这次耗时操作将在调用线程上完成,因此不会影响代理)。

  3. 要求代理使用给定的状态和 ID 创建并注册一个新的AgentProcess

这意味着我们可能需要访问代理两次,因此我们需要两种不同的消息类型来指定我们想要代理做什么。下面的列表显示了可以定义不同类型的消息来传达调用者的意图。

列表 19.9 不同的消息类型传达调用者的意图

public class AccountRegistry
{
   abstract record Msg(Guid Id);
   record LookupMsg(Guid Id) : Msg(Id);
   record RegisterMsg(Guid Id, AccountState AccountState) : Msg(Id);
}

我将这些消息类型定义为内部类,因为它们仅在AccountRegistry类内部使用,用于与其代理通信。

我们现在可以定义Lookup方法,它构成了AccountRegistry的公共 API(因此,在调用者的线程上执行),如下所示:

public class AccountRegistry
{
   Agent<Msg, Option<Account>> agent;
   Func<Guid, Task<Option<AccountState>>> loadState;

   public Task<Option<Account>> Lookup(Guid id)
      => agent
         .Tell(new LookupMsg(id))                                   ❶
         .OrElse(() =>
            from state in loadState(id)                             ❷
            from account in agent.Tell(new RegisterMsg(id, state))  ❸
            select account);
}

❶ 告诉代理查找给定的 ID

❷ 如果查找失败,则在调用线程中加载状态。

❸ 告诉代理使用给定的状态和 ID 注册一个新的进程

它首先要求代理查找 ID;如果查找失败,则从数据库中检索状态。请注意,这是在调用线程上完成的,这样代理就可以自由地处理其他消息。最后,向代理发送第二条消息,要求它使用给定的账户状态和 ID 创建并注册一个AccountProcess

注意,所有操作都在Task<Option<>>堆栈内发生,因为这是loadStateTell返回的类型。即使是这里的OrElse也会解析为我在Task<Option<T>>上定义的重载,如果Task失败或内部OptionNone,则执行给定的回退函数。

需要展示的只剩下代理的修订版定义,该定义在AccountRegistry的构造函数中开始。下面的列表展示了这一点。

列表 19.10 存储账户AccountProcess注册表的代理

using AccountsCache
   = ImmutableDictionary<Guid, Agents.Account>;

public class AccountRegistry
{
   Agent<Msg, Option<Account>> agent;
   Func<Guid, Task<Option<AccountState>>> loadState;

   public AccountRegistry
   (
      Func<Guid, Task<Option<AccountState>>> loadState,
      Func<Event, Task<Unit>> saveAndPublish
   )
   {
      this.loadState = loadState;

      this.agent = Agent.Start
      (
         initialState: AccountsCache.Empty,
         process: (AccountsCache cache, Msg msg)
            => msg switch                                      ❶
         {
            LookupMsg m => (cache, cache.Lookup(m.Id)),

            RegisterMsg m => cache.Lookup(m.Id).Match
            (
               Some: acc => (cache, Some(acc)),                ❷
               None: () =>
               {
                  AccountProcess acc                           ❸
                     = new(m.AccountState, saveAndPublish);    ❸
                  return (cache.Add(m.Id, acc), Some(acc));    ❸
               }
            )
         }
      );
   }

   public Task<Option<Account>> Lookup(Guid id) => // as above...
}

❶ 代理使用模式匹配根据发送给它的消息执行不同的操作。

❷ 一个边缘情况,其中两个并发请求都加载了账户状态

❸ 创建并注册一个新的AccountProcess

这个实现稍微复杂一些,但更高效,这个例子给了我们机会看到使用代理编程时的一个常见陷阱:即在不需要严格同步访问代理状态的情况下,在代理的主体中执行昂贵的操作。

另一方面,在两种提出的实现中,一旦创建了AccountProcess,它就永远不会终止;它将事件持久化到数据库中,以保持存储版本与内存状态同步,但我们最多只从数据库中读取一次。这是好事还是坏事?这取决于你最终在内存中会有多少数据以及你有多少可用内存。这可能是巨大的优化,因为访问内存数据比访问数据库快得多。将所有数据保持在内存中是 actor 模型的一个大优势:因为 actor 可以跨机器分布,你可以使用的内存量没有实际限制,并且访问内存(即使是通过网络)比访问本地数据库要快得多。

19.4.4 将所有内容组合在一起

在放置了之前的构建块之后,让我们看看我们的 API 端点实现是如何变化的:

public static void ConfigureMakeTransferEndpoint
(
   WebApplication app,
   Validator<MakeTransfer> validate,
   AccountRegistry accounts                       ❶
)
{
   var getAccountVal = (Guid id)                  ❷
      => accounts
         .Lookup(id)
         .Map(opt => opt.ToValidation(Errors.UnknownAccountId(id)));

   app.MapPost("/Transfer/Make", (MakeTransfer transfer) =>
   {
      Task<Validation<AccountState>> outcome =
         from cmd in Async(validate(transfer))
         from acc in getAccountVal(cmd.DebitedAccountId)
         from result in acc.Handle(cmd)           ❸
         select result.NewState;

      return outcome.Map(
        Faulted: ex => StatusCode(500),
        Completed: val => val.Match(
           Invalid: errs => BadRequest(new { Errors = errs }),
           Valid: newState => Ok(new { Balance = newState.Balance })));
   });
}

❶ 通过账户 ID 获取AccountProcess是必需的

❷ 从Task<Option<>Task<Validation<>的变化

AccountProcess处理命令,更新账户状态并持久化/发布相应的事件。

端点实现依赖于Validator来验证命令,并依赖于AccountRegistry来检索相关账户的AccountProcess

与第十三章中的版本相比,主要的变化是,result元组仅用于反馈,而持久化和发布事件发生在AccountProcessHandle方法中。正如你所看到的,这是防止对账户状态进行并发修改所必需的,这可能会违反诸如限制账户最大透支等业务规则。

我没有包括读取和写入存储中事件的功能实现,因为它们非常特定于技术,并且不涉及任何特别有趣的逻辑。

你现在已经看到了处理货币转移的端到端解决方案的所有主要组件,这些解决方案增加了对账户状态同步访问的约束。

摘要

  • 同时访问的共享可变状态可能会引起困难的问题。

  • 因此,在可能的情况下,你应该完全避免共享可变状态。在并行处理场景中,这通常是这种情况。

  • 在其他类型的并发中,特别是在需要模拟现实世界实体的多线程应用程序中,通常需要共享可变状态。

  • 访问共享可变状态必须进行序列化,以避免对数据进行不一致的更改。这可以通过使用锁来实现,也可以通过使用无锁技术来实现。

  • 消息传递并发是一种技术,通过限制状态修改到拥有某些状态独占所有权的进程(actor/agents)来避免使用锁,他们可以单线程地访问这些状态,以响应发送给他们的消息。

  • 一个 actor/agent 是一个轻量级进程,具有

    • 一个收件箱,其中发送给它的消息会被排队

    • 它具有排他所有权的某些状态

    • 一个处理循环,在其中它按顺序处理消息,采取诸如创建与其他代理通信、改变其状态和执行副作用等行动

  • 代理和演员在本质上相似,但存在重要的区别:

    • 演员(Actors)是分布式的,而代理(Agents)是局部于单个进程的。

    • 与代理不同,演员模型包括错误处理机制,如监督者(supervisors),当被监督的演员失败时,它们会采取行动。

  • 消息传递并发与其他 FP 技术感觉相当不同,主要是因为 FP 通过组合函数工作,而演员/代理倾向于以“发射并遗忘”的方式工作。

  • 可以使用基于代理或演员的底层实现来编写高级功能 API。


¹ 实际上,有几种不同的策略可以用来实现具有不同特性的 STM。一些实现还强制执行一致性,这意味着可以强制执行事务不能违反的守恒性。原子性、一致性和隔离性听起来熟悉吗?这是因为它们是许多数据库保证的 ACID 属性中的三个——最后一个属性是持久性,当然,它不适用于 STM,因为它专门涉及内存数据。

² 我已经在前言中提到了 language-ext,这是一个 C#的功能库。代码可在github.com/louthy/language-ext找到,并且对于一些展示如何使用 STM 特性的基本代码示例,请参阅github.com/louthy/language-ext/wiki/Concurrency

附录 A.使用 C#的早期版本

这本书的第二版是为 C# 10 编写的,并利用了语言最新的功能,前提是它们与函数式编程相关。如果你正在处理使用 C# 早期版本的遗留项目,你仍然可以应用这本书中讨论的所有想法。本附录向你展示了如何做到这一点。

A.1 C# 9 之前的不可变数据对象

在这本书中,我使用了记录和结构体来处理所有数据对象。记录默认是不可变的,结构体在函数间按值复制时也是复制的,因此它们也被视为不可变。如果你想要使用不可变数据对象,但需要使用 C# 9 之前的版本,你必须依赖以下选项之一:

  • 按惯例将对象视为不可变。

  • 手动定义不可变对象。

  • 使用 F# 作为你的领域对象。

为了说明这些策略中的每一个,我将回到编写 AccountState 类的任务,以表示 BOC 应用程序中银行账户的状态。我们在第 11.3 节中看到了这个。

A.1.1 按惯例的不可变性

在引入记录之前,C# 开发者通常使用空构造函数和属性获取器和设置器来定义数据对象。以下列表展示了如何使用这种方法来模拟银行账户的状态。

列表 A.1 银行账户状态的简单模型

public enum AccountStatus
{ Requested, Active, Frozen, Dormant, Closed }

public class AccountState
{
   public AccountStatus Status { get; set; }
   public CurrencyCode Currency { get; set; }
   public decimal AllowedOverdraft { get; set; }
   public List<Transaction> TransactionHistory { get; set; }

   public AccountState()
     => TransactionHistory = new List<Transaction>();
}

这允许我们以如下列表所示的对象初始化器语法优雅地创建新实例。

列表 A.2 使用方便的对象初始化器语法

var account = new AccountState
{
   Currency = "EUR"
};

这创建了一个新的账户,其中 Currency 属性被显式设置;其他属性初始化为它们的默认值。请注意,对象初始化器语法调用无参数构造函数和 AccountState 中定义的公共设置器。

A.1.2 定义复制方法

如果我们要表示状态的变化,例如账户被冻结,我们将创建一个新的 AccountState,并带有新的 Status。我们可以通过在 AccountState 上添加一个方便的方法来实现,如下列表所示。

列表 A.3 定义复制方法

public class AccountState
{
   public AccountState WithStatus(AccountStatus newStatus)
      => new AccountState
      {
         Status = newStatus,                            ❶
         Currency = this.Currency,                      ❷
         AllowedOverdraft = this.AllowedOverdraft,      ❷
         TransactionHistory = this.TransactionHistory   ❷
      };
}

❶ 更新字段

❷ 所有其他字段都从当前状态复制。

WithStatus 是一个返回实例副本的方法,除了 Status 以外,其他方面都与原始实例相同,Status 的值如所给。这与使用 AddDays 和在 DateTime 上定义的类似方法得到的行为相似:它们都返回一个新的实例(参见第 11.2.1 节)。

类似于 WithStatus 这样的方法被称为 复制方法with-ers,因为惯例是给它们命名为 With[Property]。以下列表展示了如何调用一个复制方法来表示账户状态的改变。

列表 A.4 获取对象的修改版本

var newState = account.WithStatus(AccountStatus.Frozen);

复制方法与记录中的 with 表达式类似,因为它们返回原始对象的副本,其中一个属性已被更新。

注意:通过复制方法表示更改的成本并不像你想象的那么高,正如第 11.3 节中已经讨论的那样(特别是在“使用不可变对象对性能的影响”侧边栏中)。这是因为像WithStatus这样的复制方法会创建原始对象的浅拷贝:这是一个快速且足以保证安全性的操作(假设对象的所有子对象也都是不可变的)。

A.1.3 强制不可变性

到目前为止所示的实施方案使用属性 setter 来最初填充对象(A.1.1 节)和使用复制方法来获取更新版本(A.1.2 节)。这种方法被称为约定不可变性:你使用约定和纪律来避免突变。setter 是公开的,但它们应该在对象初始化后 never 被调用。但这并不能阻止一个不认同不可变性的淘气的同事直接设置字段:

account.Status = AccountStatus.Frozen;

如果你想要防止这种破坏性更新,你必须通过完全移除属性 setter 来使你的对象不可变。然后,新的实例必须通过将所有值作为参数传递给构造函数来填充,如下列所示。

列表 A.5:朝着不可变性重构:移除所有 setter

public class AccountState
{
   public AccountStatus Status { get; }
   public CurrencyCode Currency { get; }
   public decimal AllowedOverdraft { get; }
   public List<Transaction> Transactions { get; }

   public AccountState
   (
      CurrencyCode Currency,
      AccountStatus Status = AccountStatus.Requested,
      decimal AllowedOverdraft = 0,
      List<Transaction> Transactions = null
   )
   {
      this.Status = Status;
      this.Currency = Currency;
      this.AllowedOverdraft = AllowedOverdraft;
      this.Transactions = Transactions ?? new List<Transaction>();
   }

   public AccountState WithStatus(AccountStatus newStatus)
      => new AccountState
      (
         Status: newStatus,
         Currency: this.Currency,
         AllowedOverdraft: this.AllowedOverdraft,
         Transactions: this.TransactionHistory
      );
}

在构造函数中,我使用了命名参数和默认值,这样我就可以使用类似于我们之前使用的对象初始化器语法来创建一个新的实例。现在我们可以用如下方式创建一个新的账户,并赋予其合理的值:

var account = new AccountState
(
   Currency: "EUR",
   Status: AccountStatus.Active
);

WithStatus复制方法与之前一样工作。注意,我们现在强制为Currency提供一个值,这在使用对象初始化器语法时是不可能的。因此,我们在保持可读性的同时,使实现更加健壮。

提示:强制你的代码客户端使用构造函数或工厂函数来实例化对象可以提高代码的健壮性,因为你可以在这个点强制执行业务规则,使得无法创建处于无效状态的对象,例如没有货币的账户。

A.1.4 一直到底的不可变性

我们还没有完成,因为对于一个对象来说,要成为不可变的,它的所有组成部分也必须是不可变的。在这里,我们使用了一个可变的List,所以你的淘气的同事仍然可以通过编写以下代码来有效地突变账户状态:

account.Transactions.Clear();

防止这种情况最有效的方法是创建构造函数提供的列表的副本,并将内容存储在一个不可变列表中。以下列表显示了如何使用System.Collections.Immutable库中的ImmutableList类型来完成此操作。¹

列表 A.6:通过使用不可变集合防止突变

using System.Collections.Immutable;

public sealed class AccountState                               ❶
{
   public IEnumerable<Transaction> TransactionHistory { get; }

   public AccountState(CurrencyCode Currency
      , AccountStatus Status = AccountStatus.Requested
      , decimal AllowedOverdraft = 0
      , IEnumerable<Transaction> Transactions = null)
   {
      // ...
      TransactionHistory = ImmutableList.CreateRange           ❷
         (Transactions ?? Enumerable.Empty<Transaction>());    ❷
   }
}

❶ 将类标记为密封以防止可变子类

❷ 创建并存储给定列表的防御性副本

当创建一个新的AccountState时,给定的交易列表会被复制并存储在一个ImmutableList中。这被称为防御性复制。现在,AccountState的交易列表不能被任何消费者更改,即使构造函数中给出的列表在以后被更改,它也不会受到影响。幸运的是,CreateRange足够智能,如果它被给定了ImmutableList,它就会直接返回它,这样复制方法就不会产生任何额外的开销。

此外,TransactionCurrency也必须是不可变类型。我还将AccountState标记为sealed以防止创建可变的子类。现在,AccountState在理论上确实是不可变的。在实践中,仍然可以通过反射来修改实例,这样你那淘气的同事仍然可以占据上风。² 但至少现在没有通过错误修改对象的空间了。

你如何将一笔新交易添加到列表中?你不需要这样做。你创建一个新的列表,其中包含新交易以及所有现有交易,并且这个新列表将成为一个新的AccountState的一部分,如下面的列表所示。

列表 A.7 向列表添加元素需要一个新的父对象

using LaYumba.Functional;                               ❶

public sealed class AccountState
{
   public AccountState Add(Transaction t)
      => new AccountState
      (
         Transactions: TransactionHistory.Prepend(t),   ❷
         Currency: this.Currency,                       ❸
         Status: this.Status,                           ❸
         AllowedOverdraft: this.AllowedOverdraft        ❸
      );
}

❶ 将Prepend作为IEnumerable的扩展方法

❷ 包含现有值和即将添加的一个新的IEnumerable

❸ 所有其他字段都按常规复制。

注意,在这个特定的情况下,我们是在列表中预加交易。这是特定领域的;在大多数情况下,你感兴趣的是最新的交易,所以最有效的方法是将最新的交易放在列表的前面。

A.1.5 无样板复制方法?

现在我们已经成功地将AccountState实现为一个不可变类型,让我们面对一个痛点:编写复制方法并不有趣! 想象一个有 10 个属性的对象,所有这些属性都需要复制方法。如果有任何集合,你需要将它们复制到不可变集合中,并添加复制方法来添加或删除这些集合中的项目。这有很多样板代码!

下面的列表展示了如何通过包含一个带有命名可选参数的单个With方法来减轻这种情况,就像我们在列表 A.5 中的AccountState构造函数中使用它们一样。

列表 A.8 一个可以设置任何属性的单一With方法

public AccountState With
(
   AccountStatus? Status = null,                                 ❶
   decimal? AllowedOverdraft = null                              ❶
 )
=> new AccountState
(
   Status: Status ?? this.Status,                                ❷
   AllowedOverdraft: AllowedOverdraft ?? this.AllowedOverdraft,  ❷
   Currency: this.Currency,                                      ❸
   Transactions: this.TransactionHistory                         ❸
);

null表示该字段未指定。

❷ 如果未指定值,则使用当前实例的值

❸ 你可以防止任意更改。

null 的默认值表示值尚未指定;在这种情况下,将使用当前实例的值来填充复制。对于值类型字段,你可以使用相应的可空类型作为参数类型,以允许 null 作为默认值。因为默认值 null 表示字段尚未指定,因此将使用当前值,所以不可能使用此方法将字段设置为 null。鉴于第 5.5.1 节中关于 nullOption 的讨论,你可能已经看出这并不是一个好主意。

注意,在列表 A.8 中,我们只允许更改两个字段,因为我们假设我们永远不能更改银行账户的货币或对交易历史进行任意更改。这种方法使我们能够在保留对想要允许的操作的细粒度控制的同时减少样板代码。使用方法如下:

public static AccountState Freeze(this AccountState account)
   => account.With(Status: AccountStatus.Frozen);

public static AccountState RedFlag(this AccountState account)
   => account.With
   (
      Status: AccountStatus.Frozen,
      AllowedOverdraft: 0m
   );

这不仅读起来清晰,而且与使用经典的 With[Property] 方法相比,性能更好:如果我们需要更新多个字段,则只创建一个新实例。我强烈推荐使用这个单一的 With 方法,而不是为每个字段定义复制方法。

另一种方法是定义一个通用的辅助器,它执行复制和更新而不需要任何样板代码。我在 LaYumba.Functional.Immutable 类中实现了一个这样的通用 With 方法,如下所示。

列表 A.9 使用通用复制方法

using LaYumba.Functional;

var oldState = new AccountState("EUR", AccountStatus.Active);
var newState = oldState.With(a => a.Status, AccountStatus.Frozen);

oldState.Status   // => AccountStatus.Active
newState.Status   // => AccountStatus.Frozen
newState.Currency // => "EUR"

在这里,With 是一个在 object 上的扩展方法,它接受一个 Expression 来标识要更新的属性和新的值。使用反射,它随后创建原始对象的位复制,识别指定属性的备份字段,并将其设置为给定值。

简而言之,它做了我们想要的事情——对于任何字段和任何类型。优点是,这使我们免于编写繁琐的复制方法。缺点是,反射相对较慢,并且当我们显式选择在 With 中可以更新的字段时,我们失去了细粒度的控制。

A.1.6 比较不可变性的策略

总结来说,在 C# 9 引入记录之前,强制不可变性是一个棘手的问题,并且在函数式编程时也是一个最大的障碍。

这里是我所讨论的两种方法的优缺点:

  • 约定不可变性——在这种方法中,你不需要做任何额外的工作来防止突变;你只是像可能避免使用 gotounsafe 指针访问和位操作(仅举几例,这些是语言允许但已被证明有问题的操作)一样避免它。如果你独立工作或与从第一天起就支持这种方法的团队一起工作,这可以是一个可行的选择。当然,缺点是突变可能会悄悄地出现。

  • 在 C# 中定义不可变对象——这种方法为你提供了一个更健壮的模型,它向其他开发者传达了该对象不应该被修改的信息。如果你在一个项目中工作,其中并没有全面使用不可变性,那么这种方法是首选的。与约定不可变性相比,它至少需要在定义构造函数时做一些额外的工作。

要使事情更加复杂,第三方库可能有限制,这些限制决定了你的选择。传统上,.NET 的反序列化器和 ORM 使用空构造函数和可设置属性来创建和填充对象。如果你依赖于具有此类要求的库,约定不可变性可能就是你的唯一选择。

A.2 在 C# 8 之前进行模式匹配

模式匹配是一种语言特性,允许你根据某些数据(最重要的是其类型)的 形状 执行不同的代码。它是静态类型函数式语言的一个基本特性,我们在书中广泛使用了它,无论是通过 switch 表达式还是通过定义一个 Match 方法。

在本节中,我将描述模式匹配在 C# 中随版本演变的支持情况,并展示一个解决方案,即使你正在使用较旧的 C# 版本,也可以使用模式匹配。

A.2.1 C# 对模式匹配的增量支持

很长一段时间,C# 对模式匹配的支持都很差。直到 C# 7,switch 语句只支持非常有限的形式的模式匹配,允许你匹配表达式的确切值。那么,匹配表达式的 类型 呢?例如,假设你有一个以下简单的领域:

enum Ripeness { Green, Yellow, Brown }

abstract class Reward { }

class Peanut : Reward { }
class Banana : Reward { public Ripeness Ripeness; }

在 C# 6 之前,计算给定 Reward 的描述必须像以下列表所示那样进行。

列表 A.10 在 C# 6 中对表达式类型进行匹配

string Describe(Reward reward)
{
   Peanut peanut = reward as Peanut;
   if (peanut != null)
      return "It's a peanut";

   Banana banana = reward as Banana;
   if (banana != null)
      return $"It's a {banana.Ripeness} banana";

   return "It's a reward I don't know or care about";
}

对于这样一个简单的操作,这确实非常繁琐且嘈杂。C# 7 引入了一些对模式匹配的有限支持,以便前面的代码可以像以下列表所示那样简化。

列表 A.11 使用 is 在 C# 7 中进行类型匹配

string Describe(Reward reward)
{
   if (reward is Peanut _)
      return "It's a peanut";

   if (reward is Banana banana)
      return $"It's a {banana.Ripeness} banana";

   return "It's a reward I don't know or care about";
}

或者,也可以使用以下列表所示的 switch 语句。

列表 A.12 使用 switch 在 C# 7 中进行类型匹配

string Describe(Reward reward)
{
   switch (reward)
   {
      case Peanut _:
         return "It's a peanut";
      case Banana banana:
         return $"It's a {banana.Ripeness} banana";
      default:
         return "It's a reward I don't know or care about";
   }
}

这仍然相当尴尬,尤其是在函数式编程中,我们希望使用表达式,而 ifswitch 都在每个分支中期望语句。

最后,C# 8 引入了 switch 表达式(你在书中看到了几个例子),允许我们将前面的代码写成以下列表所示的形式。

列表 A.13 C# 8 中的 switch 表达式

string Describe(Reward reward)
   => reward switch
   {
       Banana banana => $"It's a {banana.Ripeness} banana",
       Peanut _ => "It's a peanut",
       _ => "It's a reward I don't know or care about"
   };

A.2.2 用于模式匹配表达式的自定义解决方案

如果你正在使用 C# 8 之前的版本的代码库,你仍然可以使用我包含在 LaYumba.Functional 中的 Pattern 类来进行类型匹配。它可以像以下列表所示那样使用。

列表 A.14 用于基于表达式的模式匹配的自定义 Pattern

string Describe(Reward reward)
   => new Pattern<string>                                 ❶
   {
      (Peanut _) => "It's a peanut",                      ❷
      (Banana b) => $"It's a {b.Ripeness} banana"         ❷
   }
   .Default("It's a reward I don't know or care about")   ❸
   .Match(reward);                                        ❹

❶ 泛型参数指定调用 Match 时返回的类型。

❷ 一个函数列表;第一个匹配类型的函数将被评估。

❸ 可选地添加默认值或处理程序

❹ 提供要匹配的值

这不如一等语言支持性能好,也没有所有像解构这样的功能,但如果您只是对类型匹配感兴趣,这仍然是一个很好的解决方案。

您首先设置处理每个情况的函数(内部,Pattern 实质上是一个函数列表,所以我使用了列表初始化语法)。您可以可选地调用 Default 来提供一个默认值或一个函数,如果找不到匹配的函数则使用。最后,您使用 Match 来提供要匹配的值;这将评估第一个输入类型与给定值类型匹配的函数。

此外,Pattern 还有一个非泛型版本,其中 Match 返回 dynamic。您可以在前面的示例中通过简单地省略 <string> 来使用这个版本,从而使语法更加简洁。

提示:在这本书中,您看到了为 OptionEitherListTree 等实现的 Match 方法。这些方法有效地执行模式匹配。当您一开始就知道需要处理的所有情况时(例如,Option 只能是 SomeNone),定义此类方法是有意义的。相比之下,Pattern 类对于开放继承的类型很有用,如 EventReward,您可以根据系统的发展添加新的子类。

A.3 重新审视事件源示例

为了说明之前描述的技术,让我们回顾一下 13.2 节中的事件源场景,并假设我们只能使用 C# 6。我们没有记录,所以为了表示账户的状态,我们将定义 AccountState 为一个不可变类。所有属性都将只读,并在构造函数中填充。以下列表显示了实现。

列表 A.15 表示账户状态的不可变类

public sealed class AccountState
{
   public AccountStatus Status { get; }                     ❶
   public CurrencyCode Currency { get; }                    ❶
   public decimal Balance { get; }                          ❶
   public decimal AllowedOverdraft { get; }                 ❶

   public AccountState
   (
      CurrencyCode Currency,
      AccountStatus Status = AccountStatus.Requested,
      decimal Balance = 0m,
      decimal AllowedOverdraft = 0m
   )
   {
      this.Currency = Currency;                             ❷
       this.Status = Status;                                ❷
       this.Balance = Balance;                              ❷
       this.AllowedOverdraft = AllowedOverdraft;            ❷
    }

   public AccountState WithStatus(AccountStatus newStatus)  ❸
      => new AccountState
      (
         Status: newStatus,
         Balance: this.Balance,
         Currency: this.Currency,
         AllowedOverdraft: this.AllowedOverdraft
      );

   public AccountState Debit(decimal amount)                ❸
      => Credit(-amount);

   public AccountState Credit(decimal amount)               ❸
      => new AccountState
      (
         Balance: this.Balance + amount,
         Currency: this.Currency,
         Status: this.Status,
         AllowedOverdraft: this.AllowedOverdraft
      );
}

❶ 所有属性都是只读的。

❷ 在构造函数中初始化属性

❸ 提供复制方法以创建修改后的副本

除了属性和构造函数之外,AccountState 还有一个 WithStatus 复制方法,它创建一个新的 AccountState,并带有更新的状态。DebitCredit 也是复制方法,它们创建一个带有更新余额的副本。(这个相当长的类定义替换了列表 13.2 中的记录定义,后者只有七行。)

现在,关于状态转换。记住,我们使用账户历史中的第一个事件来创建一个 AccountState,然后使用每个事件来计算事件之后账户的新状态。状态转换的签名是

AccountState → Event → AccountState

为了实现状态转换,我们根据事件类型进行模式匹配,并相应地更新 AccountState

列表 A.16 使用模式匹配建模状态转换

public static class Account
{
   public static AccountState Create(CreatedAccount evt)    ❶
      => new AccountState
         (
            Currency: evt.Currency,
            Status: AccountStatus.Active
         );

   public static AccountState Apply
      (this AccountState account, Event evt)
      => new Pattern                                        ❷
      {
         (DepositedCash e) => account.Credit(e.Amount),
         (DebitedTransfer e) => account.Debit(e.DebitedAmount),
         (FrozeAccount _) => account.WithStatus(AccountStatus.Frozen),
      }
      .Match(evt);
}

CreatedAccount 是一个特殊情况,因为没有先前的状态。

❷ 根据事件类型调用相关的转换

由于无法依赖语言对模式匹配的支持,此代码有效地使用了 A.2.2 节中展示的模式匹配解决方案。

A.4 结论

正如你所见,本书中讨论的所有技术都可以用于使用较旧版本的 C# 的遗留项目。当然,如果你能的话,升级到 C# 的最新版本以利用新的语言特性,特别是记录和模式匹配。


¹ 微软开发了 System.Collections.Immutable 库来补充 BCL 中的可变集合,因此它的感觉应该很熟悉。你必须从 NuGet 获取它。

System.Reflection 中的实用工具允许你在运行时查看和修改任何字段的值,包括 privatereadonly 字段以及自动属性的底层字段。

结语。接下来是什么?

恭喜您接受学习函数式编程的挑战,并完成了这本书的阅读!您现在已经熟悉了函数式编程的所有基本概念以及一些高级技术。我希望您喜欢这本书,并鼓励您通过评论、社交媒体或与同事交谈来分享您的感受。作为告别,我想给您一些建议,如果您想进一步探索函数式编程,可以参考以下资源。

首先,我邀请您观看我在 2017 年 NDC 悉尼大会上做的关于逻辑与副作用的演讲。在演讲中,我概述了如何使用不同的技术来驯服副作用,包括自由单子,这是将逻辑与副作用分离的最激进的方法。视频链接在这里:youtu.be/wJq86IXkFdQ

您的下一步可能就是学习一种函数式语言(或者几种)。C#是一种多范式语言,因此您可以随意混合使用。另一方面,函数式语言会强制您在整个过程中使用函数式方法,例如,完全不允许任何状态变化。您还会发现,函数式语言对本书中介绍的技术提供了更好的语法支持。学习函数式语言的另一个好处是,它允许您利用其他学习资源:书籍、博客、演讲等等。

目前大多数关于函数式编程的学习材料都包含 Haskell 或 Scala 的代码示例。自然的选择是学习 Haskell,它是参考函数式语言,也是函数式程序员之间的通用语言。为此,我推荐您阅读 Miran Lipovaca 的《Learn You a Haskell for Great Good》(No Starch Press,2011 年)。¹ 另一种学习 Haskell 的好方法是跟随 Erik Meijer 的在线课程学习函数式编程。²

Scala 是一种在 Java 虚拟机上运行的多范式语言,它强调函数式编程。Scala 社区活跃地解决如何将通常起源于学术界的函数式编程思想最好地应用于工业界的问题。如果您想学习 Scala,我建议您通过跟随 Martin Odersky 的在线课程来学习。³

我喜欢两种较新的函数式语言:Elm 和 Elixir,它们都得到了热情的用户社区的支持,并且正在获得越来越多的关注,尤其是在初创公司中。我希望在接下来的几年里,这两种语言能够得到更广泛的采用和认可。

Elm (elm-lang.org/) 是一种强类型、纯函数式客户端语言,它编译成 JavaScript。语法简洁,类似于 Haskell 或 F#,但语言和工具更易于用户使用。它包括一个框架,负责管理状态和执行副作用。因此,程序员只需编写纯函数。简单来说,Elm 可以让任何现有的 JavaScript 框架相形见绌。如果您是全栈 Web 开发者,请考虑使用 Elm 进行前端开发。

Elixir (elixir-lang.org/) 是一种动态类型语言,它在 Erlang 虚拟机上运行,基于第十五章中讨论的演员模型,因此,如果您对高度并发的系统感兴趣,它特别适合。您将想要进一步探索消息传递并发。

最后,我建议您查看 Edwin Brady 的《使用 Idris 进行类型驱动开发》(Manning,2017)。即使您只阅读了几个章节,并且从未计划在 Idris 中编写程序,看到在为它优化的语言中类型驱动开发是如何工作的,也可能激发您将一些这些技术带入您的编码实践中。

还有许多其他函数式和多范式语言(如果遗漏了您最喜欢的,请见谅!),每种语言都有其独特的吸引力。但您在这本书中学到的 FP 思想是语言无关的,并且将使您在最多几天或几周内就能掌握任何函数式语言的基本知识。

再见。


¹ 您可以免费在线阅读完整内容,请访问 learnyouahaskell.com/,但请考虑购买一本以奖励作者的辛勤工作。

² Erik Meijer 是 LINQ 和 Rx 的主要贡献者之一。他的 FP 在线课程可在 edX (www.edx.org/) 上找到,您可以使用 Haskell 或其他几种语言跟随学习。

³ Martin Odersky 是 Scala 的创造者,他的在线课程可在 Coursera (www.coursera.org/) 上找到。

posted @ 2025-11-09 18:02  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报