C--函数式编程-全-
C# 函数式编程(全)
原文:
zh.annas-archive.org/md5/445c5024138799c1ed6a1899c0d17e5d
译者:飞龙
前言
函数式编程(FP)是软件开发历史上最伟大的创新之一,它也很酷。同样有趣。不仅如此,它每年都在增长。
我尽可能经常参加开发者会议,并且我注意到一个趋势。每年,关于函数式编程的内容总是越来越多,而不是越来越少。甚至通常会有一个完整的轨道专门讨论它,其他讲座中也经常包含函数式编程内容作为讨论的一个点。
它正在慢慢地变得非常重要。为什么呢?
随着容器化和无服务器应用程序等概念的增长,函数式编程不仅仅是开发者空闲项目的一点乐趣;它不是几年后就会被遗忘的时尚。它真正有利于为我们的利益相关者带来益处。
在 .NET 世界中有几个额外因素在起作用。
C# 首席设计师 Mads Torgerson 本人也是函数式编程的粉丝,也是将函数式范式引入 .NET 背后的主要推动力之一。还有 F# - .NET 的函数式语言。F# 和 C# 共享一个通用运行时,因此 F# 团队请求的许多函数特性最终也以某种形式在 C# 中得到实现。
但是一个重要的问题是 - 它是什么?而且¹,我需要学习一个全新的编程语言才能使用它吗?好消息是,如果你是 .NET 开发者,那么你不需要花大量时间学习新技术来跟上时代 - 你甚至不需要投资于另一个第三方库以增加应用程序的依赖关系 - 这一切都可以通过现成的 C# 代码实现 - 尽管可能需要进行一些调整。
本书将介绍函数式编程的所有基本概念,展示它们的好处,以及如何在 C# 中实现它们 - 不仅仅是为了你自己的业余编程,还着眼于如何在你的工作生活中立即获得益处。
谁应该阅读本书?
本书面向开发者 - 无论是专业人士、学生还是业余爱好者 - 他们已经对 C# 有了基础了解。你不需要成为专家,但需要熟悉基础知识,并且能够至少简单地组合一个 C# 应用程序。
这本书还将涵盖一些更高级的 .NET 主题,但在出现时我会提供解释。
这本书是为几类人写的:
-
那些已经学习了 C# 基础知识,但希望找到进一步学习的途径。学习更高级的技术,写出更好、更健壮的代码。
-
.NET 开发者听说过函数式编程,甚至可能知道它是什么,但想知道如何在 C# 中开始写这种方式的代码。
-
F# 开发者寻找继续使用你习惯的功能玩具的方法。
-
那些从另一种函数式或支持函数式语言(如 Java)迁移到.NET 的人。
-
任何真正热爱编码的人。如果你整天在办公室写代码,然后回家继续写,那么这本书可能适合你。
为什么我写了这本书
我对编程感兴趣已经很久了。当我还是个小男孩时,我们有一台 ZX Spectrum - 这是 Sinclair Research 在 80 年代初开发的一款早期英国家用电脑。如果有人记得 Commodore 64,它有点像那个,但原始得多。它只有 15 种颜色² - 其中一种是黑色。我有更先进的型号,有 48k 内存,虽然我爸爸有早期型号 - ZX81 - 它只有 1k 内存(还有橡胶键)。它甚至不能有有颜色的字符精灵,只能有屏幕上的区域,所以你的游戏角色会根据他们站在前面的东西的颜色改变颜色。简而言之,它简直是黑科技中的极品。
其中最好的一点是,它具有一个基于文本的编程接口的操作系统,加载游戏需要使用代码(从盒式磁带,使用命令 LOAD ""),但也有为孩子们准备的杂志和书籍,其中包含可供自行输入的游戏代码,正是从这些杂志和书籍中,我对计算机代码的奥秘产生了持久的痴迷。非常感谢,Usbourne Publishing!
大约在我 14 岁左右时,学校的一个基于计算机的职业建议程序建议我考虑从事软件开发职业。这是我第一次意识到,你可以将这种愚蠢的爱好变成实际可以赚钱的东西!
大学毕业后,是时候找份正式工作了,那时我第一次接触到了 C#。所以,接下来的步骤,我想,就是学习如何正确地开发代码。简单吧?说实话,将近 20 年过去了,我仍在努力摸索。
在我编程生涯中的一个重大转折点是,我参加了挪威的开发者会议,终于开始理解我一直听说的“函数式编程”究竟是什么。函数式代码优雅、简洁,易于阅读,这是其他形式的代码所不具备的特点。像任何类型的代码一样,仍然有可能编写看起来很糟糕的代码库,但它从根本上感觉就像是终于以正确的方式编写代码,这是其他编码风格从未给予我的感觉。希望在阅读本书后,你不仅会同意这一点,还会对探索更多其他方式感兴趣。
阅读本书指南
这本书的组织方式如下:
介绍了什么是函数式编程,它的来源以及为什么我们中的任何人都应该对它感兴趣。我认为它为我们的雇主带来了重大的商业利益,并且这是值得添加到您的开发者工具包中的技能。
-
第一章讨论了您可以立即开始在 C#中以函数式编程方式编码的方法,而无需引用任何新的 Nuget 包,第三方库或使用语言进行 hack。本章中的几乎所有示例都适用于自 C#版本 3 以来的几乎每个版本。本章代表了函数式编程的第一步,所有代码都相当简单,但为即将到来的内容奠定了基础。
-
第二章提供了一些稍微不太常规的方法来看待我们在 C#中已经可用的结构。它包括了将函数式范式推向更高级别的方法。在这一点上,仍然没有额外的代码依赖性,但是事情开始在这里看起来有点不同寻常。
-
第 4 至 7 章分别展示了函数式编程范式的一个组成部分,以及如何在 C#中实现它。在这些章节中,我们开始稍微调整 C#的结构。
-
第 8 和 9 章更多地讨论了在商业环境中使用函数式 C#的实际问题。
随时根据您准备好的水平深入研究。这不是一本小说³,按照您认为合理的顺序阅读章节。
致谢
我应该首先感谢的人是凯瑟琳·多拉德。几年前,她在 NDC Oslo 发表了一场名为“C#的函数式技术”的演讲。这是我第一次真正接触到函数式编程,这是我曾经有过的第一个真正的启发,真是令人大开眼界(https://www.youtube.com/watch?v=rHmIf5xmKQg)。
我在这条道路上跟随的另一位大师是恩里科·布安诺,他的书“C#中的函数式编程”(ISBN:978-1617293955)是我第一次真正理解一些难以理解的函数式概念是如何工作的。
伊恩·拉塞尔,马修·弗莱彻,利亚姆·莱利,马克斯·迪茨,史蒂夫“Talks Code”柯林斯,杰拉尔多·利斯,马特·伊兰德,拉胡尔·纳特,西瓦·古迪瓦达,克里斯蒂安·霍斯达尔,马丁·富斯,戴夫·麦科洛,塞巴斯蒂安·罗宾斯,大卫·谢弗,彼得·德·坦德,马克·西曼阅读了初稿并提供了宝贵的反馈。谢谢,伙计们!
我的编辑,吉尔·莱昂纳德。她必须有耐心像圣人一样忍受我整整一年!
本书使用的约定
本书中使用了以下排版约定:
Italic
表示新术语,URL,电子邮件地址,文件名和文件扩展名。
Constant width
用于程序清单,以及在段落中引用程序元素,如变量或函数名称,数据库,数据类型,环境变量,语句和关键字。
Constant width bold
显示用户应该按照字面意思输入的命令或其他文本。
Constant width italic
显示应该被用户提供的值或上下文确定的值替换的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般注释。
警告
此元素指示警告或注意事项。
使用代码示例
补充材料(代码示例、练习等)可以在即将到来的链接下载。
如果您有关于代码示例的技术问题或使用问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了大部分代码,否则无需获得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书的示例代码需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。
我们感谢,但通常不需要归属。归属通常包括标题、作者、出版商和 ISBN。例如:“Functional Programming with C# by Simon J. Painter (O’Reilly). Copyright 2024 Simon Painter, 978-1-492-09707-5.”
如果您认为您使用的代码示例超出了公平使用范围或上述许可,请随时通过permissions@oreilly.com联系我们。
O’Reilly 在线学习
注意
超过 40 年来,O’Reilly Media 提供技术和业务培训、知识和见解,帮助企业成功。
我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和 200 多个其他出版商的广泛的文本和视频。有关更多信息,请访问http://oreilly.com。
如何联系我们
请将有关本书的评论和问题寄给出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为这本书准备了一个网页,上面列出了勘误、示例和任何额外信息。您可以在即将到来的链接上访问这个页面。
电子邮件bookquestions@oreilly.com 用于对本书提出评论或技术问题。
有关我们的书籍和课程的新闻和信息,请访问http://oreilly.com。
在 Facebook 上找到我们:http://facebook.com/oreilly
关注我们的 Twitter:http://twitter.com/oreillymedia
观看我们的 YouTube 频道:http://www.youtube.com/oreillymedia
致辞
这本书献给了我的妻子,Sushma Mahadik。我的宝贝。同时也献给我的两个女儿,Sophie 和 Katie。爸爸爱你们,女孩们。
¹ 好的,两个问题
² 8 种基础颜色,以及每种颜色的明亮版本。其中一种是黑色,不过,地球上怎么可能有明亮的黑色!所以..15。
³ 但如果是这样,您可以保证巴特勒一定已经做到了!
第一章:介绍
如果您之前学过很多编程 - 无论是 C#、Visual BASIC、Python 还是其他任何语言 - 那么您学到的很可能都是围绕着目前最主流的编程范式 - 面向对象编程。
面向对象编程已经存在了相当长的时间。确切的日期存在争议,但很可能是在 50 年代末 60 年代初某个时候发明的。
面向对象编码是围绕着将数据片段 - 称为属性 - 和功能包装成称为类的逻辑代码块的想法,这些类被用作从中实例化对象的模板。它涉及更多内容:继承,多态性,虚拟和抽象方法。诸如此类的各种东西。
然而,这并不是一本面向对象编程的书。事实上,如果您已经对面向对象有所了解,那么放下您已经掌握的知识,您可能会从本书中获得更多收获。
我在本书中要描述的是一种作为面向对象的替代的编程风格 - 函数式编程。尽管最近几年函数式编程开始得到一些主流认可,但它实际上和面向对象一样古老 - 甚至可能更古老。它基于数学原理,这些原理在 19 世纪末至 20 世纪 50 年代间由各种人开发,并自 1960 年代以来一直是一些编程语言的特征。
在本书中,我将向您展示如何在 C#中实现它,而无需学习全新的编程语言。
在我们开始编写代码之前,我想先讨论一下函数式编程本身。它是什么?我们为什么要感兴趣?最佳使用时机是什么?所有这些都是非常重要的问题。
什么是函数式编程?
函数式编程中有一些基本概念,其中许多概念名称相对晦涩,但实际上并不难理解。我会尽量在这里简单地阐述它们。
它是一种语言,一个 API,还是什么?
不,函数式编程不是 Nuget 中的语言或第三方插件库,它是一种范式。我指的是什么?虽然有更正式的范式定义,但我认为它是一种编程风格。就像吉他可以作为同样的乐器,但可以演奏许多,甚至是完全不同的音乐风格一样,一些编程语言也支持不同的工作风格。
函数式编程与面向对象编码一样古老,甚至可能更古老。我稍后会详细讨论其起源,但现在只需知道它并不新鲜,其理论不仅先于面向对象,而且大部分也先于计算机行业本身。
还值得注意的是,您可以像混合摇滚和爵士乐一样结合编程范式。它们不仅可以结合,而且有时候您可以利用每种范式的最佳特性来产生更好的最终结果。
编程范式有多种多样¹,但为简单起见,我只讨论现代编程中最常见的两种:
命令式
这在相当长的一段时间内是唯一的编程范式。过程化和面向对象(OO)属于此类。这些编程风格更直接地指导执行环境执行详细的步骤,即哪个变量包含哪些中间步骤,以及如何逐步详细执行过程。这通常是学校/大学/工作中教授的编程方式。
声明式
在这种编程范式中,我们不太关心如何精确实现目标,代码更接近描述最终过程中所需的内容,而细节(包括执行步骤的顺序等)更多地留在执行环境的控制中。这是函数式编程所属的类别。SQL 也属于此类,因此在某些方面,函数式编程更接近 SQL 而不是 OO。在编写 SQL 语句时,你不关心操作的顺序(实际上并不是 SELECT 然后 WHERE 然后 ORDER BY),也不关心数据转换的具体细节,你只需编写一个有效描述所需输出的脚本。这些也是函数式 C#的一些目标,因此那些具有与 SQL Server 或其他关系数据库的背景的人可能会发现一些相关的想法更容易理解。
除了这些之外,还有许多其他编程范式,但它们远超出本书的范围。公平地说,除了这两种以外,大多数都相当隐晦,因此你不太可能在短时间内遇到它们。
函数式编程的特性
在接下来的几节中,我将讨论函数式编程的每一个特性,以及它们对开发者的实际意义。
不可变性
如果某物体能够改变,那么它也可以说是变异,就像一个少年变异忍者²。另一种说法是,某物体可以变异,这意味着它是可变的。另一方面,如果某物体根本不能改变,那么它是不可变的。
在编程中,这指的是变量在定义时设置其值,并且在此后永远不可更改。如果需要新值,则应基于旧值创建一个新变量。这是函数式代码中所有变量的处理方式。
这与命令式代码略有不同,但最终会产生更接近数学运算的程序,鼓励良好的结构和更可预测的、因此更健壮的代码。
.NET
中DateTime
和String
都是不可变数据结构。你可能认为你已经改变了它们,但在幕后,每个改变都在堆栈上创建了一个新项。这就是为什么大多数新开发者在For
循环中连接字符串时会被提醒,以及为什么你绝对不应该这样做的原因。
高阶函数
这些是作为变量传递的函数。这可以是作为局部变量、函数的参数或函数的返回值。Func<T,TResult>
或Action<T>
委托类型就是这种情况的完美示例。
如果您不熟悉这些委托,这是它们的简要工作原理。
它们都是以变量形式存储的函数。它们都接受一组泛型类型,这些类型表示它们的参数和返回类型(如果有的话)。Func
和Action
之间的区别在于Action
不返回任何值 - 即它是一个不包含return
关键字的void
函数。在Func
中列出的最后一个泛型类型是它的返回类型。
这些函数:
// Given parameters 10 and 20, this would output the following string:
// "10 + 20 = 30"
public string ComposeMessage(int a, int b)
{
return a + " + " + b + " = " + (a + b);
}
public void LogMessage(string a)
{
this.Logger.LogInfo("message received: " + a);
}
可以像这样重写为委托类型:
Func<int, int, string> ComposeMessage =
(a, b) => a + " + " + b + " = " + (a + b);
Action<string> LogMessage = a =>
this.Logger.LogInfo($"message received: {x}");
这些委托类型可以像普通函数一样被调用:
var message = ComposeMessage(10, 20);
LogMessage(message);
使用这些委托类型的一个重大优势是它们存储在可以在代码库中传递的变量中。它们可以作为其他函数的参数或返回类型包含在内。正确使用时,它们是 C#中更强大的特性之一。
使用函数式编程技术,委托类型可以组合在一起,从较小的功能构建块中创建更大、更复杂的函数。就像乐高积木一样,将它们放在一起来组成一个千年隼号模型,或者你喜欢的其他任何东西。这就是为什么这种编程范式被称为函数式编程的真正原因,因为我们用函数来构建我们的应用程序,而不是像名字所暗示的那样,其他范式中的代码不起作用。如果它们没有作用,为什么会有人使用它们呢?
实际上 - 对你来说的一个经验法则。如果有疑问,函数式编程的答案几乎肯定是“函数、函数和更多函数”。
注意
有两种可调用的代码模块。函数和方法。区别在于函数总是返回一个值,但方法不返回。在 C#中,函数返回某种数据,而方法的返回类型是void
。方法几乎不可避免地涉及副作用,因此在我们的代码中应尽量避免使用它们 - 除非无法避免。日志记录可能是方法的一个使用示例,这不仅是无法避免的,而且对于良好的生产代码也是必不可少的。
表达式而非语句
这里需要一些定义。
表达式是评估为值的离散代码单元。我是什么意思?
在其最简单的形式中,这些是表达式:
const int exp1 = 6;
const int exp2 = 6 * 10;
我们也可以传递值来形成我们的表达式,所以这也是其中之一:
public int AddTen(int x) => x + 10;
这也是。它执行一个操作 - 即评估一个布尔值,但最终用于返回一个 bool
,所以它是一个表达式:
public bool IsTen(int x) => x == 10;
如果纯粹用于确定要返回的值,你还可以将三元 if
语句视为表达式:
var randomNumber = this._rnd.Generate();
var message = randomNumber == 10
? "It was ten"
: "it wasn't ten";
另一个快速的经验法则 - 如果一行代码有一个等号,那么它很可能是一个表达式,因为它正在为某个东西赋值。在这条规则中存在一些灰色地带。对其他函数的调用可能会有各种意想不到的后果。但把它记在心里还是个不错的主意。
语句 另一方面是一些不评估数据的代码片段。这些更像是一个指令,告诉执行环境通过关键字如 if
、where
、for
、foreach
等改变执行顺序,或者调用不返回任何东西的函数 - 由此暗示进行某种操作。像这样:
this._thingDoer.GoDoSomething();
还有一个经验法则³,如果没有等号,那肯定是一个语句。
基于表达式的编程
如果有帮助的话,回想一下你在学校时的数学课。还记得在得出最终答案时你必须写出的那些计算过程吗?基于表达式的编程就像那样。
每一行都是一个完整的计算,建立在一个或多个前面的行基础之上。通过编写基于表达式的代码,你在函数运行时留下了你的工作成果,一劳永逸。除了其他好处外,这样做更容易调试,因为你可以回顾所有先前的值,并且知道它们没有被循环的先前迭代或其他任何因素更改。
这可能看起来像是一个不可能完成的任务,几乎就像是让你绑起手臂来编程一样。但完全有可能,而且并不一定困难。在 C# 中,大多数工具已经有了大约十年的历史,而且还有许多更有效的结构。
这里是一个我所说的例子:
public decimal CalculateHypotenuse(decimal b, decimal c)
{
var bSquared = b * b;
var cSquared = c * c;
var aSquared = bSquared + cSquared;
var a = Math.Sqrt(aSquared);
return a;
}
现在严格来说,你可以将它写成一行,但看起来可能不那么美观和易读易懂,对吧?为了保存所有中间变量,我也可以像这样写:
public decimal CalculateHypotenuse(decimal b, decimal c)
{
var returnValue = b * b;
returnValue += c * c;
returnValue = Math.Sqrt(returnValue);
return returnValue;
}
这里的问题在于没有变量名会使得阅读起来有点困难,并且所有的中间值都会丢失 - 如果存在 bug,我们必须逐步检查每个阶段的 returnValue
。而在基于表达式的解决方案中,所有的工作都保留在原地。
在这种方式下工作一段时间后,回到旧方式实际上会显得有些奇怪,甚至有些笨拙和累赘。
引用透明性
这听起来像是一个简单概念的可怕名称。在函数式编程中有一个叫做“纯函数”的概念。这些函数具有以下属性:
-
它们不会对函数外的任何东西进行更改。不会更新状态,不会存储文件,等等。
-
给定相同的参数值集合,无论系统处于什么状态,它们始终返回完全相同的结果。无论如何,没有例外。
-
它们不会有任何意外的副作用。抛出异常也包括在内。
这些术语源自这样一个观念:给定相同的输入,总是得到相同的输出,因此在计算中,您基本上可以用函数调用交换最终值,只要这些输入存在。例如:
var addTen = (int x) => x + 10;
var twenty = addTen(10);
使用参数为 10 调用 addTen 将始终计算为 20,没有任何异常。在这么简单的函数中也不可能存在任何副作用。因此,可以在原则上将对 addTen(10) 的引用替换为常量值 20 而没有副作用。这就是引用透明度。
这里是一些纯函数:
public int Add(int a, int b) => a + b;
public string SayHello(string name) => "Hello " +
(string.IsNullOrWhitespace(name)
? "I don't believe we've met. Would you like a Jelly Baby?"
: name);
注意不能发生任何副作用(我确保字符串包含了空检查),函数外部没有任何改变,只生成并返回了一个新值。
这里是这些相同函数的不纯版本:
public void Add(int a) => this.total += a; // Alters state
public string SayHello() => "Hello " + this.Name;
// Reads from state instead of a parameter value
在这两种情况下,都引用了当前类的属性,这超出了函数本身的范围。Add 函数甚至修改了该状态属性。SayHello 函数也没有空检查。所有这些因素意味着我们不能将这些函数视为“纯”的。
这些如何?
public string SayHello() => "Hello " + this.GetName();
public string SayHello2(Customer c)
{
c.SaidHelloTo = true;
return "Hello " + (c?.Name ?? "Unknown Person");
}
public string SayHello3(string name) =>
DateTime.Now + " - Hello " + (name ?? "Unknown Person");
这些都不太可能是纯的。
SayHello 依赖于函数本身之外的功能。我并不确切知道 GetName()
做了什么⁴。如果它只是返回一个常量,那么我们可以认为 SayHello()
是纯的。另一方面,如果它在数据库表中进行查找,那么可能会出现缺少数据或丢失网络数据包导致抛出错误,这些都是意外副作用的例子。如果必须使用函数来检索名称,我会考虑使用 Func<T, TResult>
委托来安全地将功能注入到我们的 SayHello 函数中。
SayHello2 修改了传入的对象 - 这是使用该函数的一个明显副作用。通过引用传递对象并像这样修改它们在面向对象编程中并不罕见,但在函数式编程中绝对不会这样做。我可能会通过将对象属性的更新和打招呼的处理分离为不同的函数来使其成为纯函数。
SayHello3 使用了 DateTime.Now
,每次使用时返回不同的值。这与纯函数的完全相反。修复的一种简单方法是在函数中添加一个 DateTime
参数,并将该值传递进去。
引用透明度是显著增加功能代码可测试性的特征之一。这意味着必须使用其他技术来跟踪状态,稍后我会详细说明。
在我们的应用程序中,尤其是一旦我们不得不与外界,用户或一些不遵循函数范式的第三方库进行交互时,我们的“纯度”存在一定的限制。在这里或那里,我们总是不得不做出妥协。
在这一点上,我通常喜欢引用一个比喻。影子有两部分:本影和半影⁵。本影是影子的实心黑暗部分,事实上大部分影子都是本影。半影是围绕外部的灰色模糊圆圈,是影子和非影子相遇的部分,一个渐变为另一个的部分。在 C#应用程序中,我想象纯粹代码区域是本影,而妥协区域是半影。我的任务是最大化纯粹区域,并尽可能减少非纯粹区域。
如果你想要这种架构模式的更正式定义,Gary Bernhardt 曾在演讲中称其为功能核心,命令式外壳⁶。
递归
如果你不理解这个,请参见:递归,否则请参见:说真的,递归
说真的,递归
递归几乎存在于编程的整个历史中。它是一个调用自身以实现无限(但希望不是无限的)循环的函数。这对于曾经编写过用于遍历文件夹结构或编写高效排序算法的人来说应该是熟悉的。
递归函数通常分为两部分:
-
条件,用于确定是否应再次调用函数,或者是否已达到最终状态(例如已找到我们正在计算的值,没有子文件夹可探索等)。
-
返回语句,它要么返回最终值,要么引用同一个函数,具体取决于最终状态条件的结果。
这里是一个非常简单的递归加法⁷:
public int AddUntil(int startValue, int endValue)
{
if (startValue >= endValue)
return startValue;
else
return AddUntil(startValue + 1, endValue);
}
尽管上述例子很愚蠢,但请注意,我从未更改任何参数整数的值。递归函数的每次调用都使用基于其自身接收的值的参数值。这是不可变性的另一个例子 - 我没有改变变量中的值,而是使用基于接收到的值的表达式调用函数。
递归是函数式编程用作替代 While 和 ForEach 语句的方法之一。然而,在 C#中存在一些性能问题。稍后将会有一个章节来更详细地讨论递归,但现在请谨慎使用,并跟随我的步伐。一切将会变得清晰...
模式匹配
在 C# 中,这基本上就是带有“加速”条纹的 Switch
语句。不过,F# 将这个概念推向了更深的层次。在几个版本后,我们已经在 C# 中实现了这一概念,C# 8 中引入的 Switch
表达式就是我们自己的本地实现,而 Microsoft 团队一直在不断增强它。
它可以根据对象的类型和属性改变执行路径,可以用来减少一组大量嵌套的 if 语句,例如这样:
public int NumberOfDays(int month, bool isLeapYear)
{
if(month == 2)
{
if(isLeapYear)
return 29;
else
return 28;
}
if(month == 1 || month == 3 || month == 5 || month == 7 ||
month == 8 || month == 10 || month == 12)
return 31;
else
return 30;
}
简化为几行,像这样:
public int NumberOfDays(int month, bool isLeapYear) =>
(month, isLeapYear) switch
{
{ month: 2, isLeapYear: true } => 29,
{ month: 2 } => 28,
{ month: 1 or 3 or 5 or 7 or 8 or 10 or 12 } => 31,
_ => 31
};
这是一个令人难以置信且强大的特性,也是我最喜欢的事情之一⁸。
在接下来的几章中会有许多这样的例子,如果你对看到更多关于这一切的内容感兴趣的话,可以跳过。
此外,对于那些仍在使用旧版本的 C# 的人来说,有实现这一方法的方式,稍后我将展示一些技巧。
无状态的
面向对象的代码通常有一组状态对象,它们表示一个过程 - 真实的或虚拟的。这些状态对象定期更新,以保持与它们所代表的内容同步。例如像这样的东西:
public class DoctorWho
{
public int NumberOfStories { get; set; }
public int CurrentDoctor { get; set; }
public string CurrentDoctorActor { get; set; }
public int SeasonNumber { get; set; }
}
public class DoctorWhoRepository
{
private DoctorWho State;
public DoctorWhoRepository(DoctorWho initialState)
{
this.State = initialState;
}
public void AddNewSeason(int storiesInSeason)
{
this.State.NumberOfStories += storiesInSeason;
this.State.SeasonNumber++;
}
public void RegenerateDoctor(string newActorName)
{
this.State.CurrentDoctor++;
this.State.CurrentDoctorActor = newActorName;
}
}
好吧,如果你想要进行函数式编程,就永远不要再做那种事了。没有一个中心状态对象的概念,也没有修改其属性的概念,就像上面的代码示例中一样。
真的吗?感觉像是最纯粹的疯狂,不是吗?严格来说,确实有一个状态,但更多地是系统的 emergent property。
任何曾经使用 React-Redux 的人已经接触过状态的函数式方法(这反过来又受到函数式编程语言 Elm 的启发)。在 Redux 中,应用程序状态是一个不可变对象,不进行更新,而是由开发者定义一个函数,该函数接受旧状态、一个命令和任何必要的参数,然后基于旧状态返回一个新的状态对象。在 C# 9 中引入 Record 类型后,这个过程变得非常容易。稍后我会详细讨论这个问题。但现在,关于如何重构其中一个存储库函数以函数方式运行的简单版本,可能会是这样:
public DoctorWho RegenerateDoctor(DoctorWho oldState, string newActorName)
{
return new DoctorWho
{
NumberOfStories = oldState.NumberOfStories,
CurrentDoctor = oldState.CurrentDoctor + 1,
CurrentDoctorActor = newActorName,
SeasonNumber = oldState.SeasonNumber
};
}
显然,在外部使用时,它的使用方式会有所不同。事实上,现在称其为存储库可能有点错误。稍后我将更多地讨论编写无状态对象代码所需的策略。希望这足以让你了解函数式代码的工作方式。
烘焙蛋糕
如果你想对这些范式之间的差异稍微高级一点的描述。这是它们如何都能制作蛋糕⁹:
一个命令式的蛋糕
这不是真正的 C# 代码,只是一种 .NET 主题的伪代码,用来给这个虚构的问题提供命令式解决方案的印象。
Oven.SetTemperatureInCentigrade(180);
for(int i=0; i < 3; i++)
{
bowl.AddEgg();
bool isEggBeaten = false;
while(!isEggBeaten)
{
Bowl.BeatContents();
isEggBeaten = Bowl.IsStirred();
}
}
for(int i == 0; i < 12; i++)
{
OvenTray.Add(paperCase[i]);
OvenTray.AddToCase(bowl.TakeSpoonfullOfContents());
}
Oven.Add(OvenTray);
Thread.PauseMinutes(25);
Oven.ExtractAll();
对我来说,这代表了典型的复杂的命令式代码。有很多小的短暂变量用来跟踪状态。它也非常关注事情的精确顺序。更像是给一个完全没有智能的机器人的指令,需要一切都明确指出。
一个声明式的蛋糕
下面是一个完全虚构的声明式代码可能如何解决同样问题的例子:
Oven.SetTemperatureInCentigrade(180);
var cakeBatter = EggBox.Take(3)
.Each(e => Bowl.Add(e)
.Then(b =>
b.While(x => !x.IsStirred, x.BeatContents())
)
)
.DivideInto(12)
.Each(cb =>
OvenTray.Add(PaperCaseBox.Take(1).Add(cb))
);
如果你对函数式编程不熟悉,现在看起来可能有些奇怪和不寻常,但在这本书的过程中,我将解释这一切是如何工作的,其好处是什么,以及如何在 C#中自己实现这一切。
不过,值得注意的是,这里没有状态跟踪变量,也没有If
或While
语句。我甚至不确定操作的顺序一定是什么,但这并不重要,因为系统会按需完成任何必要的步骤。
这更像是给一个稍微聪明一点的机器人的指令。至少可以自己思考一下,至少在过程式代码中可以通过组合一个 While 循环和一些状态跟踪代码行来存在“直到某种状态存在”的指令。
函数式编程从何而来?
我想先澄清一件事,尽管有些人可能会这么认为,函数式编程已经存在很久了。真的 很久了 - 至少从计算的角度来看是这样。我的观点是 - 它不像是最新潮的 JavaScript 框架,今年很火,明年就可能过时了。它比所有现代编程语言甚至计算本身都要古老。函数式编程比我们任何人都要早,而且很可能在我们都退休之后仍然存在。我稍微有些强调的观点是,投资时间和精力来学习和理解它是值得的。即使有一天你发现自己不再在 C#中工作,大多数其他编程语言都在不同程度上支持函数式概念(JavaScript 在某种程度上甚至超越了大多数语言的梦想),因此这些技能在你职业生涯的其余部分仍然很重要。
在我继续本节之前,有一个小提示 - 我不是数学家。我喜欢数学,它是我在学校、大学和大学里最喜欢的科目之一,但最终会有一个更高层次的理论数学,即使是我自己也会眼花缭乱,头疼。话虽如此,我会尽力简要地谈谈函数式编程究竟来自哪里。实际上,它来自于那个理论数学的世界。
大多数人能够提及的函数式编程历史上的第一位人物通常是哈斯克尔·布鲁克斯·柯里(1900-1982 年),一位美国数学家,他现在有至少三种以他命名的编程语言,以及函数式概念“柯里化”(稍后详述)。他的工作是在称为“组合逻辑”的东西上进行的 - 一种涉及以 lambda(或箭头)表达式形式编写函数,然后组合它们以创建更复杂逻辑的数学概念。这是函数式编程的基础。尽管柯里并不是第一个研究这个问题的人,但他是在他的数学前辈撰写的论文和书籍之后。
-
阿隆佐·丘奇(1903-1955 年,美国人) - 正是丘奇创造了我们今天在 C#等语言中使用的“Lambda 表达式”这一术语。
-
摩西·绍恩菲克尔(1888-1942 年,俄罗斯人) - 绍恩菲克尔撰写了有关组合逻辑的论文,这是哈斯克尔·柯里工作的基础之一。
-
弗里德里希·弗雷格(1848-1925 年,德国人) - 可以说是首个描述我们现在称之为柯里化的概念的人。尽管很重要要正确地归功于发现者,但弗雷格并不完全拥有同样的影响力。
第一批函数式编程语言是:
-
IPL(信息处理语言),由艾伦·纽厄尔(1927-1992 年,美国人)、克利夫·肖(1922-1991 年,美国人)和赫伯特·西蒙(1916-2001 年,美国人)于 1956 年开发。
-
LISP(LISt Processor),由约翰·麦卡锡(1927-2011 年,美国人)于 1958 年开发。据说,LISP 至今仍有一些忠实的粉丝,并且在一些企业中仍在生产使用。不过我个人从未见过直接的证据。
有趣的是,这两种语言都不被称为“纯”函数式语言。像 C#、Java 和许多其他语言一样,它们采用了一种混合的方法,不像现代的“纯”函数式语言,如 Haskell 和 Elm。
我不想过多地谈论(尽管有趣的)函数式编程的历史,但希望从我展示的内容中很明显,它有着悠久而辉煌的传统。
还有谁在进行函数式编程?
正如我之前所说,函数式编程已经存在了一段时间,不只是.NET 开发人员对其表现出兴趣。相反,许多其他语言比.NET 提供了更长时间的函数式范式支持。
我说支持是指它提供了在函数式范式中实现代码的能力。这大致有两种风味:
纯函数式语言
旨在让开发人员专门编写函数式代码。所有变量都是不可变的,提供了柯里化、高阶函数等功能。这些语言可能也能实现一些面向对象的特性,但这显然不是开发团队的首要关注点。
混合或多范式语言
这两个术语可以完全互换使用。它们描述了能够在两种或更多范式中编写代码的编程语言。通常同时支持的范式是功能性和面向对象的。可能不存在任何支持范式的完美实现。通常情况下,面向对象可能完全支持,但并非所有功能性的特性都可用。
纯功能性编程语言
现在有超过十几种纯功能语言,这里简要介绍今天使用最广泛的三种:
Haskell
Haskell 在银行业被广泛使用。对于任何真正想深入掌握功能性编程的人来说,它经常被推荐作为一个很好的起点。这可能确实如此,但老实说,我没有时间和精力去学习一门我在日常工作中根本不打算使用的完整编程语言。
如果你真的有兴趣在在工作前先成为一个功能范式的专家,那么请务必去寻找 Haskell 的内容。一个经常推荐的资源是 Miran Lipovača 的《Learn You a Haskell For Great Good》¹¹。我自己从未读过这本书,但我的朋友们读过并称赞它很棒。
Elm
Elm 似乎最近有些流行起来,即使只是因为 Elm 在 UI 中执行更新的系统已被许多其他项目采纳和实施,包括 ReactJS。这种“Elm 架构”是我想留到后面章节讨论的内容。
Elixir
基于与 Erlang 相同的虚拟机的通用编程语言。它在工业界非常流行,甚至每年都有自己的会议。
PureScript
PureScript 编译成 JavaScript,因此可以用于创建功能性的前端代码,以及服务器端代码和在等距编程环境中创建桌面应用程序——例如 Node.JS,它允许在客户端和服务器端使用同一种语言。
学习一门纯功能语言是否值得?
至少在目前,面向对象在绝大多数软件开发世界中占主导地位,功能性范式则是后来才需要学习的。我不排除将来可能会有所改变,但目前至少我们处于这种情况中。
我听到有人争论说,从面向对象来看,最好先学习其纯形式的功能性编程,然后再回来将这些学习应用在 C#中。
如果这是你想做的事情,那就去做吧。愉快地享受吧。我毫不怀疑这是一个值得的努力。
对我来说,这个观点让我想起了我们这里曾经有的那些老师,他们坚持认为孩子们应该学习拉丁语,因为作为许多欧洲语言的根源,拉丁语的知识可以很容易地转移到法语、意大利语、西班牙语等。
我在某种程度上不同意这一点¹²。与拉丁语不同,纯函数式语言不一定是困难的,尽管它们与面向对象开发非常不同。事实上,与面向对象相比,FP 的概念要少得多。话虽如此,那些在整个职业生涯中深度参与面向对象开发的人可能会发现调整更为困难。
然而,拉丁语和纯函数式语言的相似之处在于它们代表了更纯粹、祖先的形式。它们在少数专业兴趣领域之外的价值都很有限。
学习拉丁语几乎完全是无用的,除非你对法律、古典文学、古代历史等感兴趣。学习现代法语或意大利语更有用得多。它们远比拉丁语易学,而且你可以现在用它们去访问美丽的地方并与那里的友好人士交流。比利时也有一些很棒的法语漫画。去看看吧,我会等你的。
同样地,很少有地方会真正将纯函数式语言用于生产。你将会花费大量时间完全改变你的工作方式,并最终学习一门你可能永远不会在自己的业余代码之外使用的语言。我已经做这个工作很长时间了,到目前为止,我从未遇到过一家公司在实际生产中使用比 C# 更先进的东西。
C# 的美妙之处在于它支持面向对象和函数式风格的代码,因此您可以根据需要在它们之间切换。您可以在同一个代码库中舒适地使用其中一个范式或另一个范式的许多特性,而无需任何惩罚。这两种范式可以在同一代码库中相对轻松地并存,因此您可以以适合自己的节奏从纯面向对象转向函数式,反之亦然。
纯函数式语言中是不可能的,即使 C# 中有很多函数式特性是不可能的。
那么 F# 呢?我应该学习 F# 吗?
这可能是我经常被问到的最常见的问题。那么 F# 呢?它并不是一种纯函数式语言,但是它更接近于正确实现这种范式而不是 C#。它拥有各种各样的函数式特性,可以直接使用,并且编码简单且性能出色 - 为什么不使用呢?
在回答这个问题之前,我总是喜欢检查房间里的出口情况。F# 有一群热情的用户,他们可能都比我聪明¹³。但是……
这不是因为 F# 难学。从我看来,它确实容易学习,如果你完全是编程新手,那它很可能比 C# 更容易学习。
并不是说 F# 不会带来商业利益,因为我真诚地相信它会。
并不是说 F# 不能做任何其他语言能做的事情。它肯定可以。我见过一些关于如何制作全栈 F# Web 应用程序的令人印象深刻的演讲。
这是一个专业决定。在我去过的每个国家,至少找到 C#开发者并不困难。如果我要把大型开发者大会的每个与会者的名字都放在帽子里,随机抽取一个,那么这个人很可能能够专业地编写 C#代码。如果团队决定投资于 C#代码库,那么保持团队中有足够工程师来保持代码的良好维护,让业务相对满意,将不会是一件艰难的事情。
另一方面,了解 F#的开发人员相对较少。我认识的不多。通过在代码库中加入 F#,你可能会依赖团队确保始终有足够的了解 F#的人可用,否则可能会冒一些代码难以维护的风险,因为了解该语言的人数不多。
我应该指出,风险并不像引入全新技术那样高,比如说 Node.JS。F#仍然是一种.NET 语言,编译成相同的中间语言。你甚至可以在同一个解决方案中轻松引用 F#项目和 C#项目。然而,对于大多数.NET 开发者来说,这仍然是一种完全陌生的语法。
随着时间的推移,我坚定地希望这种情况会有所改变。我非常喜欢我所看到的 F#,并且我很愿意做更多相关工作。如果我的老板告诉我,已经做出了采纳 F#的业务决定,我会第一个欢呼!
事实是,目前这种情况并不是真正的可能性。谁知道未来会带来什么变化。也许这本书的将来版本将不得不进行大幅修改,以适应突然兴起的对 F#的热爱,但就目前而言,我看不到这种情况在近期会发生。
我建议先试读这本书。如果你喜欢所见的内容,也许 F#可能会是你下一个函数式编程之旅的目的地。
多范式语言
可能可以认为除了纯函数式语言之外的所有语言都是某种形式的混合语言。换句话说,至少可以实现一些函数式编程范式的一些方面。这可能是真的,但我只是简要地看一下一些完全或大部分可以实现这一特性的语言,并且这些语言团队明确提供了这一功能。
JavaScript
JavaScript 当然几乎就像编程语言的狂野西部,几乎任何事情都可以用它来实现,并且它在函数式编程方面表现得非常出色。可以说它在函数式编程方面表现得比面向对象编程还要好。如果你想了解如何正确地使用 JS 进行函数式编程,请查看 Douglas Crockford 的《JavaScript: The Good Parts》以及他的一些在线讲座(例如www.youtube.com/watch?v=_DKkVvOt6dk
)。
Python
Python 迅速成为了开源社区的最爱编程语言,就在过去的几年里。让我惊讶的是它竟然存在于 80 年代末期!Python 支持高阶函数,并提供了一些库:itertools 和 functools,以允许进一步实现函数式特性。
Java
Java 平台对函数式特性的支持与.NET 相同。此外,还有一些分支项目,如 Scala、Clojure 和 Kotlin,提供的函数式特性远远超过了 Java 语言本身。
F#
我已经在前一节中详细讨论过这个问题,所以现在我不会再多说了。这是.NET 更纯粹的函数式风格语言。C#和 F#库之间也可以进行互操作性,因此您可以使用两者的最佳功能来构建项目。
C#
微软自从早期就慢慢地添加了对函数式编程的支持。可以说,委托协变性和 C# 2.0 中的匿名方法的引入可能被认为是支持函数式范式的第一个项目。事情真正开始启动是在接下来的一年,当 C# 3.0 引入我认为是迄今为止添加到 C#中的最具变革性的功能之一时- LINQ。
我稍后会详细讨论它,但 LINQ 深深植根于函数式范式,并且是我们开始编写 C#函数式代码的最佳工具之一。事实上,C#团队明确规定,每个发布的 C#版本都应比之前的版本更加支持函数式编程。驱使这一决定的因素有很多,其中之一就是 F#,它经常向.NET 运行时团队请求 C#最终也会从中受益的新的函数式特性。
函数式编程的好处
我希望你拿起这本书是因为你已经对函数式编程心生兴趣,并且想要立即开始。对于团队讨论是否在工作中使用它,本节可能会有所帮助。
简洁
尽管不是函数式编程的特性,但我最喜欢的许多好处之一就是它看起来多么简洁和优雅,与面向对象或命令式代码相比。
其他代码风格更关注于如何做某事的低级细节,以至于有时候甚至需要大量的代码来弄清楚这个某事到底是什么。函数式编程更注重描述需要什么。为了实现这个目标,精确地更新哪些变量以及何时更新这些变量的细节不是我们关心的重点。
我曾经与一些开发者讨论过这个问题,他们不喜欢减少与数据处理底层的参与度的想法,但我个人更愿意让执行环境来处理这个问题,这样我就少了一件需要关心的事情。
这似乎是一个小事情,但我真的很喜欢函数式代码相对于命令式替代方法的简洁性。开发者的工作是一项困难的工作¹⁴,我们经常要处理需要快速掌握的复杂代码库。如果你很难理解一个函数到底做了什么,那么企业为支付你的这个费用,而不是编写新代码而损失的钱会越多。函数式代码通常以接近自然语言的方式描述正在完成的工作。这也使得查找错误变得更容易,进而节省了企业的时间和金钱。
可测试
很多人称函数式编程的一个最喜欢的特性是它的测试性。事实上,它确实如此。如果你的代码库不能接近 100%地进行测试,那么有可能你没有正确地遵循这种编程范式。
测试驱动开发(TDD)和行为驱动开发(BDD)现在是重要的专业实践。这些是编程技术,首先为生产代码编写自动化单元测试,然后编写确保测试通过的实际代码。它倾向于产生设计更好、更健壮的代码。函数式编程能够很好地支持这些实践。这反过来导致了更好的代码库和生产中更少的错误。
健壮
功能性编程不仅仅是为了更健壮的代码库而设计的。它内部有结构,可以有效地防止错误发生。
或者它们会阻止任何进一步的意外行为,从而更容易准确报告问题。在函数式编程中不存在 NULL 的概念。这单独就可以避免大量可能的错误,同时减少需要编写的自动化测试数量。
可预测
函数式代码从代码块的开头开始,按照顺序逐步执行。完全按照一种易于遵循的代码流程。这是过程式代码无法做到的。它有循环和分支语句。函数式代码只有单一且易于跟随的代码流。
如果正确执行,甚至不会有任何的 Try/Catch 块。我经常发现,这些块在处理操作顺序不可预测的代码时,是最严重的问题之一。如果 Try 块的范围不小并且与 Catch 紧密耦合,有时候它会像是盲目地把一块石头扔到空中一样。谁知道它会落在哪里,谁或者什么会接住它。谁能说这样的程序流中可能会出现什么意外行为呢。
在我的职业生涯中,不正确设计的 Try/Catch 块是我观察到的许多生产中意外行为的根源,这是一个在函数式范式中根本不存在的问题。
函数式代码仍然可能存在不正确的错误处理,但函数式编程的本质会阻止它。
更好地支持并发
在软件开发领域有两个近年来变得非常重要的新发展:
容器化
这是由 Docker 和 Kubernetes 等产品提供的。这个概念是,应用程序不再在传统服务器上运行¹⁵,而是在部署时由脚本生成的一种类似迷你虚拟机(VM)的东西。虽然不完全相同,没有硬件仿真,但从用户角度来看,结果大致相同。它解决了“在我的机器上可以工作”的问题,这对许多开发人员来说非常熟悉。许多公司的软件基础设施包括将许多相同应用程序的实例堆叠在一组容器中,所有这些容器都在处理相同的输入源。无论是队列、用户请求还是其他内容。托管它们的环境甚至可以根据需求调整活动容器的数量。
无服务器
这对.NET 开发人员可能很熟悉,例如 Azure Functions 或 AWS Lambdas。这不是部署到传统的 Web 服务器(如 IIS)的代码,而是作为一个单独的函数存在于云托管环境中。这允许与容器相同类型的自动扩展,同时还可以进行微级优化,这样可以在更关键的功能上花费更多资金,在输出较长的功能上花费较少资金。
在这两种技术中,都大量利用并发处理;即多个相同功能的实例同时在相同的输入源上工作。就像.NET 的异步功能,但应用范围更广。
任何异步操作的问题往往发生在共享资源上,无论是内存状态还是字面上的共享物理或基于软件的外部资源。
函数式编程不使用状态,因此线程、容器或无服务器函数之间不能共享状态。
当正确实现时,遵循函数式范式可以更轻松地实现这些非常需求的技术特性,而不会在生产中产生任何意外行为。
减少代码噪音
在音频处理中,有一个称为信噪比的概念。这是根据信号(您要听的东西)的音量级别与噪音(嘶嘶声、爆裂声、隆隆声或背景中的其他声音)之间的比率来衡量录音的清晰度。
在编码中,信号是代码块的业务逻辑 - 它实际上试图完成的事情。代码的做什么。
噪音 是为了完成目标而必须编写的所有样板代码。例如 For 循环定义,If 语句,这种东西。
相比过程化代码,整洁、简明的函数式编程大大减少了样板代码,因此具有更好的信号噪音比。
这不仅仅是对开发者的好处。稳健、易于维护的代码库意味着企业在维护和增强方面需要花费更少的金钱。
最适合使用函数式编程的地方
函数式编程可以做任何其他范式可以做的事情,但它在某些领域更为强大和有益,并且在其他领域中可能需要妥协并引入一些面向对象的特性,或者稍微放松函数式范式的规则。至少在 .NET 中,必须进行妥协,因为任何基础类或附加库都倾向于按照面向对象的范式编写。这不适用于纯函数式语言。
函数式编程在高度可预测的场景中表现良好。例如,数据处理模块 - 将数据从一种形式转换为另一种形式的函数。处理来自用户或数据库的数据的业务逻辑类,然后将其传递到其他地方进行渲染。类似这样的东西。
函数式编程的无状态性质使其成为并发系统的重要推动因素 - 如高度异步的代码库,或者多个处理器同时监听同一输入队列的地方。当没有共享状态时,几乎不可能出现资源争用问题。
如果你的团队正在考虑使用无服务器应用程序 - 例如 Azure Functions,那么函数式编程出于同样的原因可以很好地实现这一点。
对于高度商业关键的系统,考虑使用函数式编程是值得的,因为这种范式的工作方式使得生成的代码比采用面向对象范式编写的应用程序更少出错,更加健壮。如果系统在发生未处理的异常或无效输入时不能崩溃或失败(即意外终止),那么函数式编程可能是最佳选择。
在哪些情况下应考虑使用其他编程范式?
当然,你并不一定需要这样做。函数式可以做任何事情,但有几个领域可能值得寻找其他范式 - 纯粹在 C# 上下文中。再次提到,C# 是一种混合语言,因此许多范式可以根据开发者的需求并存并行。我当然知道我更喜欢哪一个!
与外部实体的交互是需要考虑的一个领域。输入输出、用户输入、第三方应用程序、Web API 等。这些都无法成为纯函数(即没有副作用的函数),因此必须进行妥协。从 NuGet 包导入的第三方模块也是如此。甚至有一些较旧的 Microsoft 库在功能上也无法与函数式编程兼容。这在.NET Core 中仍然适用。如果你想看一个具体的例子,请看看.NET 中的SmtpClient
或MailMessage
类。
在 C#世界中,如果性能是你项目唯一、最重要的关注点,甚至超越了可读性和模块化,那么遵循函数式范式可能并不是最好的选择。函数式 C#代码的性能并不一定差,但也不一定是最优的解决方案。
我会认为函数式编程的好处远远超过了任何轻微的性能损失,在当今,大多数时候很容易通过投入更多硬件(适当的虚拟或物理硬件)来解决应用程序的问题,而这往往比开发、测试、调试和维护以命令式编码方式编写的代码所需的额外开发时间要便宜一个数量级。例如,在开发要部署到某种移动设备上的代码时,性能至关重要,因为内存有限且无法更新。
我们能走多远?
不幸的是,在 C#中完全实现函数式范式是不可能的。其中有各种原因,包括语言的向后兼容性需求以及对依然是强类型语言的限制。
本书的目的不是向你展示如何做所有的事情,而是展示什么是可能和不可能的边界。我还会特别关注那些在维护生产代码库的人。这最终是一个关于函数式编码风格的实用、务实的指南。
Monad – 现在不要担心这个
Monad 通常被认为是函数式编程的恐怖故事。在维基百科上查看定义,你会看到一串奇怪的字母,包括 Fs、Gs、箭头以及比你当地图书馆书架下找到的还要多的括号。这些正式的定义即使现在我也觉得完全看不懂。说到底,我是一个工程师,不是数学家。
Douglas Crockford 曾经说过,Monad 的诅咒在于,一旦你掌握了理解它的能力,你就失去了解释它的能力。所以我不会详细解释。它们可能会在本书的某个地方显现出来,尤其是在不太可能的时间。
别担心,一切都会好起来的。我们会一起克服所有困难的。相信我...
概要
在这个使用 C#进行函数式编程的激动人心的第一部分中,我们的强大而令人敬畏的英雄 - 你 - 勇敢地学到了什么是函数式编程,以及为什么值得学习。
对函数式范式的重要特性进行了初步简短的介绍:
-
不可变性
-
高阶函数
-
更倾向于表达式而不是语句
-
引用透明度
-
递归
-
模式匹配
-
无状态
有关函数式编程最适用的领域以及是否需要讨论是否纯粹使用它的讨论。
我们还探讨了使用函数式范式编写应用程序的许多许多优点。
在下一集的激动人心的情节中,我们将开始探讨你可以在这里、现在使用 C#所能做的事情。不需要新的第三方库或 Visual Studio 扩展。只需一些纯正的 C#和一点点智慧。
翻页后再回来听更多关于它的内容。同样的.NET 时间,同样的.NET 频道¹⁶。
¹ 包括香草,以及我个人最喜欢的 - 香蕉
² 当我在 90 年代在英国长大时,它们是英雄海龟。我想电视工作人员试图避免“忍者”一词的暴力联想。尽管如此,他们仍然让我们经常看到我们的英雄们在他们的反派身上使用锋利的刀具
³ 必须感谢函数式编程大师马克·西曼(https://blog.ploeh.dk/)给了我这些方便的规则。
⁴ 因为我为了这个例子而编造了这个
⁵ 好吧,艺术家,我知道实际上有大约 12 个,但这对我的比喻已经足够了
⁶ 有关这个主题的讨论在这里可以找到:https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell
⁷ 不要在生产代码中使用这个特定的例子。我为了解释的目的保持简单
⁸ 由于某种原因,茱莉·安德鲁斯不接我电话,讨论更新的.NET 版本的她著名歌曲之一
⁹ 有些创意上的自由
¹⁰ 比如“我搞不定这个该死的代码!”
¹¹ 可以免费在线阅读http://www.learnyouahaskell.com。告诉他们是我推荐的
¹² 虽然我正在学习拉丁语。 Insipiens sum. Huiusmodi res est ioci facio.
¹³ 特别感谢 F# 高手 Ian Russell,在本书的 F# 内容中提供了帮助。谢谢你,Ian!
¹⁴ 至少这是我们告诉我们的经理们的
¹⁵ 虚拟或者其他方式
¹⁶ 或者书籍,如果我们想挑剔的话
第二章:什么我们已经可以做了?
本章讨论的一些代码和概念可能对某些人来说显得微不足道,但请耐心等待。更有经验的开发人员可能想跳到第三章,我在其中讨论 C#为函数式程序员提供的最新发展,或者跳到第四章,我在其中展示了一些使用你可能已经熟悉的特性来实现一些函数式特性的新颖方法。
在本章中,我将探讨几乎在今天所有使用中的 C#代码库中可能出现的函数式编程特性。我将假设至少.NET Framework 3.5,并且通过一些微小的修改,本章提供的所有代码示例都将在该环境中工作。即使你在更新版本的.NET 中工作,但对函数式编程不熟悉,我仍然建议阅读本章,因为它应该为你在函数式编程中提供一个很好的起点。
那些对函数式代码已经很熟悉,只想看看在最新版本的.NET 中有什么可用的人,最好跳到下一章。
入门
函数式编程真的很简单!尽管许多人认为它比面向对象编程难学,但实际上学起来更简单。需要学习的概念更少,而且你实际上要考虑的东西也更少。
如果你不相信我,试着向你家庭中的非技术人员解释多态性!那些对面向对象编程感到舒适的人往往已经做了很长时间,以至于可能已经忘记了刚开始时有多难理解。
函数式编程并不难理解,只是不同而已。我曾经与许多刚从大学毕业的学生交流过,他们对此充满热情。所以,如果他们可以做到...
尽管如此,关于学习函数式编程需要学习很多东西的传言似乎仍在流传。但如果我告诉你,如果你已经用 C#写了一段时间,你很可能已经在写函数式代码了呢?让我告诉你我的看法...
你的第一个函数式代码
在我们开始编写一些功能性代码之前,让我们先看看一些非功能性的内容。这是你在你的 C#职业生涯开始不久就可能学到的一种风格。
一个非功能性的电影查询
在我快速编造的例子中,我从我的想象数据存储中获取了所有电影的列表,并创建了一个新的列表,从第一个列表复制过来,但只包含动作类型的项目¹。
public IEnumerable<Film> GetFilmsByGenre(string genre)
{
var allFilms = GetAllFilms();
var chosenFilms = new List<Film>();
foreach (var f in allFilms)
{
if (f.Genre == genre)
{
chosenFilms.Add((f));
}
}
return chosenFilms;
}
var actionFilms = GetFilmsByGenre("Action");
这段代码有什么问题?至少,它不够优雅。我们写了很多代码来完成一些相对简单的事情。
我们还创建了一个新对象,只要这个函数在运行,它就会保持在作用域内。如果整个函数只是这么一小段代码,那就没什么好担心的了。但是,如果这只是一个非常长的函数的一部分呢?在那种情况下,allFilms 和 actionFilms 变量都会保持在作用域内,因此在内存中占据位置,即使它们没有被使用。
可能并不一定有所有数据的副本保留在正在复制的项目中,这取决于它是类、结构体还是其他类型。但至少,对于这两个项目在作用域内的时间,会不必要地在内存中保留一组重复的引用。这仍然比我们严格需要保留的内存更多。
我们还强制执行操作的顺序。我们指定了循环的时机,添加的时机等等。每个步骤的执行位置和时间都已经明确。如果数据转换中有任何中间步骤需要执行,我们也会指定它们,并将它们保存在更长寿的变量中。
我可以像这样用yield
返回解决一些问题:
public IEnumerable<Film> GetFilmsByGenre(string genre)
{
var allFilms = GetAllFilms();
foreach (var f in allFilms)
{
if (f.Genre == genre)
{
yield return f;
}
}
}
var actionFilms = GetFilmsByGenre("Action");
不过,这并没有减少多少行代码。
如果有比我们已经决定的更优化的操作顺序呢?如果稍后的代码实际上意味着我们最终并不返回 actionFilms 的内容呢?我们会不必要地做了这些工作。
这是过程式代码的永恒问题。一切都必须一一列举。我们在函数式编程中的主要目标之一是远离这一点。不要对每件小事都如此具体。放松一点,接受声明性的代码。
一个函数式的电影查询
那么,上面的代码样本如果按照函数式风格编写会是什么样子?我希望你们中的许多人已经猜到了如何重新编写它。
public IEnumerable<Film> GetFilmsByGenre(IEnumerable<Film> source, string genre) =>
source.Where(x => x.Genre == genre);
var allFilms = GetAllFilms();
var actionFilms = GetFilmsByGenre(allFilms, "Action");
如果此时有人说“这不就是 LINQ 吗?”,是的。没错。我会告诉你们一个小秘密 - LINQ 遵循函数式范式。
只是快速地,对于还不熟悉 LINQ 强大之处的人来说。这是一个自 C#早期就存在的库,为数据集合提供了丰富的过滤、修改和扩展功能。像Select
、Where
和All
这样的函数来自 LINQ,在全球范围内广泛使用。
回想一下函数式编程特性的列表,看看 LINQ 实现了多少……
-
高阶函数 - 传递给 LINQ 函数的 Lambda 表达式都是函数,作为参数变量传入。
-
不变性 - LINQ 不会改变源数组,它返回基于旧数组的新的
Enumerable
。 -
表达式而非语句 - 我们已经消除了
ForEach
和If
的使用。 -
引用透明性 - 我在这里写的 Lambda 表达式实际上确实符合引用透明性(即“无副作用”),尽管没有强制执行。我很容易可以引用 Lambda 外部的字符串变量。通过要求将源数据作为参数传入,我还使得测试更加容易,而不需要创建和设置某种 Mock 来代表数据存储连接。函数所需的一切都由其自己的参数提供。
迭代也可以通过递归完成,至少我不知道 Where 函数的源代码是什么样的。在没有相反证据的情况下,我只是相信它这样做。
这个微小的一行代码示例在很多方面是函数式方法的完美例子。我们传递函数来对一组数据执行操作,根据旧数据创建新的数据集。
通过遵循函数式范式,我们最终得到了更简洁、更易读,因此更易于维护的东西。
结果导向编程
函数式代码的一个共同特征是,它更加专注于最终结果,而不是达到结果的过程。构建复杂对象的完全过程化方法是在代码块开始时将其空实例化,然后在过程中逐步填充每个属性。
像这样:
var sourceData = GetSourceData();
var obj = new ComplexCustomObject();
obj.PropertyA = sourceData.Something + sourceData.SomethingElse;
obj.PropertyB = sourceData.Ping * sourceData.Pong;
if(sourceData.AlternateTuesday)
{
obj.PropertyC = sourceData.CaptainKirk;
obj.PropertyD = sourceData.MrSpock;
}
else
{
obj.PropertyC = sourceData.CaptainPicard;
obj.PropertyD = sourceData.NumberOne;
}
return obj;
这种方法的问题在于,它很容易被滥用。我在这里创建的这个虚构的小代码块短小且易于维护。然而,在实际的生产代码中经常发生的情况是,代码可能变得非常长,有多个数据源需要预处理、连接、重新处理等。你可能会看到长长的嵌套的 If 语句块,使得代码开始类似家谱的形状。
对于每个嵌套的 If 语句,复杂性实际上是成倍增加的。如果代码库中散布着多个返回语句,情况尤其如此。如果不仔细考虑逐渐复杂的代码库,很容易出现意外结束为 Null 或其他意外值的风险。函数式编程不鼓励这样的结构,并且不容易出现这种复杂性或潜在的意外后果。
在我们上面的代码示例中,我们在两个不同的地方定义了 PropertyC 和 PropertyD。在这里处理起来并不太难,但我见过一些例子,其中同一个属性在多个类和子类中定义了大约半打地方²。
我不知道你是否曾经不得不处理过这样的代码?我却经历过很多次。
这类庞大而笨重的代码库随着时间的推移只会变得更难处理。随着每次增加,开发人员实际完成工作的速度会下降,业务可能会感到沮丧,因为他们不明白为什么他们的“简单”更新会花费如此之长时间。
函数式代码理想情况下应该写成小而简洁的块,完全专注于最终产品。它偏好的表达式是基于数学工作的,所以你真的希望像小公式一样写,精确地定义一个值及其所有组成变量。不应该在代码库中上下搜索以找出值的来源。
类似这样的东西:
function ComplexCustomObject MakeObject(SourceData source) =>
new ComplexCustomObject
{
PropertyA = source.Something + source.SomethingElse,
PropertyB = source.Ping * source.Pong,
PropertyC = source.AlternateTuesday
? source.CaptainKirk
: source.CaptainPicard,
PropertyD = source.AlternateTuesday
? source.MrSpock,
: source.NumberOne
};
我知道我现在在重复交替星期二标志,但这意味着所有决定返回属性的变量都在一个地方定义。这样将来处理起来会简单得多。
如果一个属性非常复杂,需要多行代码,或者一系列占用大量空间的 Linq 操作,那么我会创建一个独立的函数来包含这些复杂逻辑。尽管如此,我仍然会保留中心的、基于结果的返回在所有代码的核心位置。
关于枚举的几句话
我有时候觉得枚举是 C# 中最被低估和最不被理解的功能之一。一个枚举是数据集合的最抽象表示形式 - 如此抽象,以至于它本身不包含任何数据,实际上只是一个在内存中描述如何获取数据的说明。一个枚举甚至不知道有多少个可用项,直到遍历了所有内容 - 它只知道当前项在哪里,以及如何迭代到下一个。
这被称为 惰性求值 或 延迟执行。在开发中,偷懒是件好事情。不要让任何人告诉你相反³。
事实上,如果你想的话,甚至可以为枚举编写自定义行为。在表面下,有一个叫做 Enumerator 的对象。与其交互可以用来获取当前项,或者迭代到下一个。你不能用它确定列表的长度,迭代只能单向进行。
看看这段代码示例:
首先是一组简单的日志记录函数,它们将消息放入一个字符串列表中:
IList<string> c = new List<string>();
public int DoSomethingOne(int x)
{
c.Add(DateTime.Now + " - DoSomethingOne (" + x + ")");
return x;
}
public int DoSomethingTwo(int x)
{
c.Add(DateTime.Now + " - DoSomethingTwo (" + x + ")");
return x;
}
public int DoSomethingThree(int x)
{
c.Add(DateTime.Now + " - DoSomethingThree (" + x + ")");
return x;
}
然后是一小段代码,依次调用每个“DoSomething”函数,使用不同的数据。
var input = new[]
{
75,
22,
36
};
var output = input.Select(x => DoSomethingOne(x))
.Select(x => DoSomethingTwo(x))
.Select(x => DoSomethingThree(x))
.ToArray();
你认为操作的顺序是什么?你可能会认为运行时会拿到原始输入数组,对所有 3 个元素应用 DoSomethingOne 来创建第二个数组,然后再对所有三个元素进行 DoSomethingTwo,依此类推。
如果我要检查那个字符串列表的内容,我会找到这样的东西:
18/08/1982 11:24:00 - DoSomethingOne(75)
18/08/1982 11:24:01 - DoSomethingTwo(75)
18/08/1982 11:24:02 - DoSomethingThree(75)
18/08/1982 11:24:03 - DoSomethingOne(22)
18/08/1982 11:24:04 - DoSomethingTwo(22)
18/08/1982 11:24:05 - DoSomethingThree(22)
18/08/1982 11:24:06 - DoSomethingOne(36)
18/08/1982 11:24:07 - DoSomethingTwo(36)
18/08/1982 11:24:08 - DoSomethingThree(36)
实际上,它几乎与通过For
/ForEach
循环运行的效果相同,但我们已经有效地将操作顺序控制权交给了运行时。我们不关心临时变量的琐碎细节,不关心什么时候把什么放在哪里。相反,我们只是描述我们想要的操作,并期望在最后得到一个单一的答案。
它可能不总是看起来完全一样,这取决于调用它的代码是什么样的。但意图始终保持不变,即可枚举对象只在实际需要数据时才会产生数据。它们的定义位置并不重要,关键是它们何时被使用会有所不同。
通过使用可枚举而不是固定数组,我们实际上已经成功实现了一些需要编写声明性代码的行为。
令人难以置信的是,如果我像这样重写代码,我上面写的日志文件仍然会看起来一样:
var input = new[]
{
1,
2,
3
};
var temp1 = input.Select(x => DoSomethingOne(x));
var temp2 = input.Select(x => DoSomethingTwo(x));
var finalAnswer = input.Select(x => DoSomethingThree(x));
temp1、temp2 和 finalAnswer 都是可枚举的,除非被迭代,否则它们都不包含任何数据。
这里有一个你可以尝试的实验。写一些类似这个示例的代码。不要完全复制,可能简单一些,比如一系列选择操作修改整数值。在 Visual Studio 中设置一个断点,移动操作指针直到 finalAnswer 被传递,然后悬停在 finalAnswer 上。你很可能会发现,即使已经通过了这一行,它也无法向你显示任何数据。因为它实际上还没有执行任何操作。
Things would change if I did something like this:
var input = new[]
{
1,
2,
3
};
var temp1 = input.Select(x => DoSomethingOne(x)).ToArray();
var temp2 = input.Select(x => DoSomethingTwo(x)).ToArray();
var finalAnswer = input.Select(x => DoSomethingThree(x)).ToArray();
因为我现在明确调用ToArray()
来强制执行每个中间步骤的枚举,那么我们确实会在移动到下一个停止之前为输入中的每个项目调用 DoSomethingOne。
现在日志文件看起来是这样的:
18/08/1982 11:24:00 - DoSomethingOne(75)
18/08/1982 11:24:01 - DoSomethingOne(22)
18/08/1982 11:24:02 - DoSomethingOne(36)
18/08/1982 11:24:03 - DoSomethingTwo(75)
18/08/1982 11:24:04 - DoSomethingTwo(22)
18/08/1982 11:24:05 - DoSomethingTwo(36)
18/08/1982 11:24:06 - DoSomethingThree(75)
18/08/1982 11:24:07 - DoSomethingThree(22)
18/08/1982 11:24:08 - DoSomethingThree(36)
因此,我几乎总是建议在使用ToArray()
或ToList()
⁴之前尽可能等待,因为这样我们可以尽可能地保持操作未执行。如果后续逻辑完全阻止枚举操作发生,甚至可能根本不执行。
有些情况例外。要么是为了性能,要么是为了避免多次迭代。当 Enumerable 保持未枚举状态时,它没有任何数据,但操作本身仍然保留在内存中。如果你将过多的这些操作叠加在一起 - 特别是如果开始执行递归操作,那么你可能会发现内存消耗过大,性能受到影响,甚至可能导致堆栈溢出。
更倾向于使用表达式而不是语句。
在本章的其余部分,我将提供更多例子,展示如何更有效地使用 Linq,避免使用诸如 If、Where、For 等语句或改变状态(即改变变量的值)的需要。
会有一些不可能或不理想的情况。但这正是本书其余部分要解决的问题。
谦卑的选择
如果你已经读到了本书的这一部分,你很可能已经意识到了 Select 语句以及如何使用它们。不过,大多数我谈话的人似乎并不知道一些功能,它们都是可以用来使我们的代码更加函数式的东西。
第一件事情是我在前一节已经展示过的 - 你可以将它们链接起来。可以作为一系列的 Select 函数调用 - 一个接一个地,或者在一行代码中;或者你可以将每个 Select 的结果存储在不同的本地变量中。从功能上讲,这两种方法是相同的。甚至在每次调用 ToArray 之后都不重要。只要你不修改任何结果数组或其中包含的对象,你就遵循了函数式范式。
重要的是要摆脱的是命令式的做法,即定义一个 List,通过 ForEach 循环遍历源对象,然后将每个新项目添加到 List 中。这样做冗长,阅读起来更困难,老实说相当乏味。为什么要走弯路呢?只需使用一个漂亮简单的 Select 语句。
通过元组传递工作值
元组是在 C#7 中引入的。Nuget 包确实存在,允许一些较旧版本的 C# 使用它们。它们基本上是一种快速而肮脏地收集属性的方式,而无需创建和维护一个类。
如果你有一些属性想要在一个地方保留一会儿,然后立即处理掉,元组对于这个很棒。
如果你有多个对象想要在 Select 之间传递,或者想要在一个 Select 中传入或传出多个项目,那么你可以使用元组。
var filmIds = new[]
{
4665,
6718,
7101
};
var filmsWithCast = filmIds.Select(x => (
film: GetFilm(x),
castList: GetCastList(x)
));
var renderedFilmDetails = filmsWithCast.Select(x =>
@$"
Title: {x.film.Title}
Director: {x.film.Director}
Cast: {string.Join(", ", x.castList)}
".Trim());
在我的示例中,我使用一个元组来配对每个给定电影 Id 的两个查找函数的数据,这意味着我可以运行一个后续的 Select 来简化这对对象为一个单一的返回值。
需要迭代器值
如果你正在将一个 Enumerable 选择成一个新形式,而且你需要迭代器作为转换的一部分,该怎么办呢?
var films = GetAllFilmsForDirector("Jean-Pierre Jeunet")
.OrderByDescending(x => x.BoxOfficeRevenue);
var i = 1;
Console.WriteLine("The films of visionary French director");
Console.WriteLine("Jean-Pierre Jeunet in descending order"
Console.WriteLine(" of financial success are as follows:");
foreach (var f in films)
{
Console.WriteLine($"{i} - {f.Title}");
i++;
}
Console.WriteLine("But his best by far is Amelie");
我们可以使用 Select 语句的一个令人惊讶的少有人知道的特性 - 它有一个重载,允许我们访问迭代器作为 Select 的一部分。你所要做的就是提供一个带有 2 个参数的 Lambda 表达式,第二个参数是一个整数,表示当前项目的索引位置。
这是我们代码的函数式版本的样子:
var films = GetAllFilmsForDirector("Jean-Pierre Jeunet")
.OrderByDescending(x => x.BoxOfficeRevenue);
Console.WriteLine("The films of visionary French director");
Console.WriteLine("Jean-Pierre Jeunet in descending order"
Console.WriteLine(" of financial success are as follows:");
var formattedFilms = films.Select((x, i) => $"{i} - {x.Title}");
Console.WriteLine(string.Join(Environment.NewLine, formattedFilms));
Console.WriteLine("But his best by far is Amelie");
使用这些技巧,几乎不存在需要在 List 上使用 ForEach
循环的情况。由于 C# 对函数式范式的支持,几乎总是有声明性方法可用来解决问题。
获取“i”索引位置变量的两种不同方法是命令式与声明式代码的一个很好的例子。命令式、面向对象的方法是开发者手动创建一个变量来保存 i 的值,并显式设置变量递增的位置。声明式代码不关心变量的定义位置,也不关心每个索引值是如何确定的。
注意 - 我使用了string.Join
将字符串链接在一起。这不仅是 C#语言中的另一个隐藏宝石,而且也是聚合的一个例子,即将一组东西转换为单一的东西。我们将在接下来的几节中详细介绍。
没有起始数组
对于每次迭代获取 i 值的最后一个技巧非常适用于首先有一个数组 - 或者其他某种集合 - 的情况。如果没有数组呢?如果需要任意迭代一定次数呢?
这些情况 - 有点罕见 - 你需要一个老式的For
循环而不是ForEach
。如何从无中创建一个数组呢?
在这种情况下,你的两个最好的朋友是两个静态方法 - Enumerable.Range
和 Enumerable.Repeat
。
Range 从一个起始整数值创建一个数组,并要求你告诉它数组应该有多少元素。然后根据这些规格创建一个整数数组。
例如:
var a = Enumerable.Range(8, 5);
var s = string.Join(", ", a);
// s = "8, 9, 10, 11, 12"
// That's 5 elements, each one higher than the last,
// starting with 8.
制作好一个数组后,我们可以应用 LINQ 操作来得到我们的最终结果。让我们想象我正在为我女儿们准备九乘表的描述⁵。
var nineTimesTable = Enumerable.Range(1,10)
.Select(x => x + " times 9 is " + (x * 9));
var message = string.Join("\r\n", nineTimesTable);
再举一个例子,如果我想从某种类型的网格中获取所有值,其中需要 x 和 y 值来获取每个值。我想象有一个网格存储库,我可以用来获取值。
想象一下网格是一个 5x5 的,这是我如何获得每一个值的方式:
var coords = Enumerable.Range(1, 5)
.SelectMany(x => Enumerable.Range(1, 5)
.Select(y => (X: x, Y: y))
);
var values = coords.Select(x => this.gridRepo.GetVal(x.Item1,x.Item2);
这里的第一行生成一个整数数组,值为[1, 2, 3, 4, 5]
。然后我使用另一个Select
将这些整数转换为另一个数组,使用Enumerable.Range
的另一个调用。这意味着我现在有一个包含 5 个元素的数组,每个元素本身都是一个包含 5 个整数的数组。在嵌套数组上使用 Select,我将这些子元素中的每一个转换为一个元组,该元组从父数组(x)和子数组(y)中取一个值。使用 SelectMany 来将整个结构展平为所有可能坐标的简单列表,看起来像这样:(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (2, 1), (2, 2)
…等等。
可以通过将坐标数组选择到存储库的 GetVal 函数的一组调用中获取值,从我在上一行创建的坐标元组中传递 X 和 Y 的值。
我们可能会遇到的另一种情况是需要在每种情况下使用相同的起始值,但需要根据数组中的位置以不同方式进行转换。这就是 Enumerable.Repeat
的用武之地。
Enumerable.Repeat
创建每个值都完全相同的元素,您可以指定要重复的元素数量。
您无法使用 Enumerable.Range
逆向计数。如果我们想做前面的例子,但从 (5,5) 开始向后移动到 (1,1),这里是如何做的示例:
var gridCoords = Enumerable.Repeat(5, 5).Select((x, i) => x - i)
.SelectMany(x => Enumerable.Repeat(5, 5)
.Select((y, i) => (x, y - i))
);
var values = coords.Select(x => this.gridRepo.GetVal(x.Item1,x.Item2);
这看起来更复杂了,但实际上并非如此。我所做的是将 Enumerable.Range
调用替换为一个两步操作。
首先调用 Enumerable.Repeat
,它重复整数值 5 - 5 次。结果得到这样一个数组:[5, 5, 5, 5, 5]
。
完成这一步后,我使用了 Select
的重载版本,其中包括了 i 的值,然后从数组的当前值中减去了该 i 的值。这意味着在第一次迭代中,返回值是数组中的当前值 i(5)减去 i 的值(第一次迭代为 0),因此简单地返回 5。在下一次迭代中,i 的值为 1,所以 5-1 就返回 4。依此类推。
最后,我们得到了一个看起来像这样的数组:(5, 5), (5, 4), (5, 3), (5, 2), (5, 1), (4, 5), (4, 4)
…等等。
还有更进一步的方法,但在本章中,我将专注于相对简单的情况,这些情况不需要对 C# 进行调试。这些都是每个人都可以立即使用的开箱即用功能。
多对一 - 聚合的微妙艺术
我们已经看过用于将一种东西转换为另一种东西的循环,X 项进入 → X 项新出。这样的事情。还有一个循环的用例我想要涵盖 - 将许多项减少为单个值。
这可能是进行总计、计算平均值、均值或其他统计数据,或者进行其他更复杂的聚合操作。
在过程化代码中,我们会有一个循环,一个状态跟踪值,并在循环内基于数组中的每个项不断更新状态。这里有一个我所说的简单示例:
var total = 0;
foreach(var x in listOfIntegers)
{
total += x;
}
实际上,LINQ 中有一个内置的方法可以做到这一点:
var total = listOfIntegers.Sum();
实际上,根本不应该有必要手动执行这种“长手计算”。即使我们要从对象数组中创建特定属性的总和,LINQ 也能够帮助我们完成:
var films = GetAllFilmsForDirector("Alfred Hitchcock");
var totalRevenue = films.Sum(x => x.BoxOfficeRevenue);
对于计算均值同样有一个函数,称为 Average。至少据我所知,没有计算中位数的函数。
我可以用一小段函数式风格的代码来计算中位数,看起来像这样:
var numbers = new [] {
83,
27,
11,
98
};
bool IsEvenNumber(int number) => number % 2 == 0;
var sortedList = numbers.OrderBy(x => x).ToArray();
var sortedListCount = sortedList.Count();
var median = IsEvenNumber(sortedList.Count())
? sortedList.Skip((sortedListCount/2)-1).Take(2).Average()
: sortedList.Skip((sortedListCount) / 2).First();
// median = 55.
有时候需要更复杂的聚合。例如,如果我们想从一个包含复杂对象的 Enumerable 中获取两个不同值的总和会怎样?
过程化代码可能如下所示:
var films = GetAllFilmsForDirector("Christopher Nolan");
var totalBudget = 0.0M;
var totalRevenue = 0.0M;
foreach (var f in films)
{
totalBudget += f.Budget;
totalRevenue += f.BoxOfficeRevenue;
}
我们可以使用两个单独的 Sum 函数调用,但那么我们将两次迭代 Enumerable,这绝对不是获取信息的有效方式。相反,我们可以使用 Linq 的另一个奇特而不为人知的特性 - 聚合函数。它包括以下组件:
-
种子 - 最终值的起始值。
-
一个聚合函数,它有两个参数 - 我们正在聚合的 Enumerable 中的当前项,和当前的累计值。
种子不一定是原始类型,比如整数或其他东西,它同样可以是一个复杂对象。然而,为了以函数式风格重写上面的代码示例,我们只需要一个简单的元组。
var films = GetAllFilmsForDirector("Christopher Nolan");
var (totalBudget, totalRevenue) = films.Aggregate(
(Budget: 0.0M, Revenue: 0.0M),
(runningTotals, x) => (
runningTotals.Budget + x.Budget,
runningTotals.Revenue + x.BoxOfficeRevenue
)
);
在正确的位置,Aggregate 是 C#中一个非常强大的功能,值得花时间去探索和正确理解。
它还是函数式编程中另一个重要概念的示例 - 递归。
定制化迭代行为
递归存在于许多迭代的函数式版本中。为了不了解的人好处,它是一个重复调用自身的函数,直到满足某些条件为止。
这是一种非常强大的技术,但在 C#中有一些需要记住的限制。最重要的两个是:
-
如果开发不当,可能导致无限循环,这会一直运行直到用户终止应用程序,或者堆栈上的所有可用空间被消耗完。正如著名英国奇幻 RPG 游戏秀《Knightmare》的传奇地下城主 Treguard 所说:“哦,那真是个恶心的事情”⁶。
-
在 C#中,它们倾向于消耗比其他形式的迭代更多的内存。虽然有办法解决这个问题,但那是另一个章节的话题。
我对递归还有很多话要说,我们很快会讨论到这一点,但是对于这一章节的目的,我将给出我能想到的最简单的例子。
假设你想要遍历一个 Enumerable 但是你不知道要遍历多久。假设你有一个整数的增量值列表(即每次添加或减少的量),你想找出从起始值(无论是什么)到 0 需要多少步。
你可以很容易地通过一个 Aggregate 调用获得最终值,但我们不想要最终值。我们对所有中间值感兴趣,并且希望通过迭代提前停止。这是一个简单的算术操作,但如果涉及到复杂对象在真实场景中,提前终止过程可能会显著节省性能。
在过程式代码中,你可能会写成这样:
var deltas = GetDeltas().ToArray();
var startingValue = 10;
var currentValue = startingValue;
var i = -1;
foreach(var d in deltas)
{
if(currentValue == 0)
{
break;
}
i++;
currentValue = startingValue + d;
}
return i;
在这个例子中,我返回-1 来表明起始值已经是我们要找的值,否则我返回导致达到 0 的数组的基于零的索引。
这是我递归地完成它的方式:
var deltas = GetDeltas().ToArray();
int GetFirstPositionWithValueZero(int currentValue, int i = -1) =>
currentValue == 0
? i
: GetFirstPositionWithValueZero(currentValue + deltas[i], i + 1);
return GetFirstPositionWithValueZero(10);
现在这已经是函数式的了,但实际上并不是理想的。嵌套函数有其存在的必要性,但我个人认为它在这里的使用方式并不像代码本可以那样可读。虽然递归很美妙,但我认为可以更清晰些。
另一个主要问题是,如果 delta 列表很大,这种方法将无法很好地扩展。我会给你展示一下我的意思。
让我们假设 Deltas 只有 3 个值:2,-12 和 9。在这种情况下,我们期望答案返回 1,因为数组的第二个位置(即索引=1)导致了 0(10+2-12)。我们也期望 9 永远不会被评估。这就是我们在这里寻找代码效率的节约。
不过实际上递归代码的情况是这样的。
首先,它以当前值为 10(即起始值)调用 GetFirstPositionWithValueZero,并允许 i 为默认值-1。
函数的主体是一个三元 if 语句。如果达到了零,返回 i,否则再次调用函数,但使用更新后的当前值和 i。
这就是第一个 delta(即 i=0,即 2)时会发生的情况,所以 GetFirstPositionWithValueZero 现在被调用,当前值更新为 12,并且 i 为 0。
新值不是 0,所以第二次调用 GetFirstPositionWithValueZero 将再次调用自身,这次当前值更新为 delta[1]并且 i 增加到 1。delta[1]为-12,这意味着第三次调用会返回 0,因此 i 可以简单地被返回。
不过,问题来了……
第三次调用得到了一个答案,但前两次调用仍然保留在内存中并存储在堆栈上。第三次调用返回 1,这个值传递到第二次调用 GetFirstPositionWithValueZero,现在它也返回 1,依此类推……直到最初的第一次调用 GetFirstPositionWithValueZero 返回 1。
如果你想稍微图形化地看一下,可以想象它看起来像这样:
GetFirstPositionWithValueZero(10, -1)
GetFirstPositionWithValueZero(12, 0)
GetFirstPositionWithValueZero(0, 1)
return 1;
return 1;
return 1;
这在我们的数组中有 3 个项目时是可以接受的,但如果有数百个呢!
递归,正如我所说的,是一个强大的工具,但在 C#中会有成本。更纯粹的函数式语言(包括 F#)有一个称为尾递归优化的特性,允许使用递归而不会出现内存使用问题。
尾递归是一个重要的概念,我将在后面专门的一章中回到它,所以我不会在这里进一步详细讨论它。
就目前而言,C#的默认设置不允许尾递归,尽管在.NET 公共语言运行时(CLR)中是可用的。我们可以尝试一些技巧来使其对我们可用,但这些技巧对本章来说有点复杂,所以我会在稍后的某个时候再谈论它们。
暂时,按照这里描述的方式考虑递归,并记住您可能希望在何时何地使用它时要小心。
不可变性
函数式编程在 C# 中不仅仅是 Linq。我想讨论的另一个重要特性是不可变性(即一旦声明,变量的值不会改变)。在 C# 中可能达到何种程度?
首先,关于 C# 8 及更高版本的不可变性有一些新的发展。请参阅下一章了解更多内容。对于本章来说,我限制自己只讨论.NET 的几乎所有版本都适用的内容。
首先,让我们考虑一下这个小的 C# 片段:
public class ClassA
{
public string PropA { get; set; }
public int PropB { get; set; }
public DateTime PropC { get; set; }
public IEnumerable<double> PropD { get; set; }
public IList<string> PropE { get; set; }
}
这是不可变的吗?非常不是。任何这些属性都可以通过设置器替换为新值。IList 也提供一组函数,允许其底层数组进行添加或删除操作。
我们可以将设置器设为私有,这意味着我们必须通过详细的构造函数来实例化类:
public class ClassA
{
public string PropA { get; private set; }
public int PropB { get; private set; }
public DateTime PropC { get; private set; }
public IEnumerable<double> PropD { get; private set; }
public IList<string> PropE { get; private set; }
public ClassA(string propA, int propB, DateTime propC, IEnumerable<double> propD, IList<string> propE)
{
this.PropA = propA;
this.PropB = propB;
this.PropC = propC;
this.PropD = propD;
this.PropE = propE;
}
}
现在是不是不可变的?不,老实说不是。确实,你不能在 ClassA 外部直接替换任何属性,这很好。属性可以在类内部替换,但开发人员可以确保永远不会添加这样的代码。希望你有某种代码审查系统来确保这一点。
PropA 和 PropC 都很好 - 字符串和 DateTime 在 C# 中都是不可变的。PropB 的 int 值也没问题 - int 类型除了其值外无法更改任何内容。
然而还存在一些问题。
PropE 是一个列表,即使我们不能替换整个对象,仍然可以添加、删除和替换值。如果我们实际上不需要持有 PropE 的可变副本,我们可以轻松地将其替换为 IEnumerable 或 IReadOnlyList。
IEnumerable<double>
类型的 PropD 乍一看似乎没问题,但如果作为一个 List<double>
传递给构造函数,而外部仍然引用这种类型,那么它仍然可以通过这种方式改变其内容。
还有可能引入类似于这样的东西:
public class ClassA
{
public string PropA { get; private set; }
public int PropB { get; private set; }
public DateTime PropC { get; private set; }
public IEnumerable<double> PropD { get; private set; }
public IList<string> PropE { get; private set; }
public SubClassB PropF { get; private set; }
public ClassA(string propA, int propB, DateTime propC, IEnumerable<double> propD, IList<string> propE, SubClassB propF)
{
this.PropA = propA;
this.PropB = propB;
this.PropC = propC;
this.PropD = propD;
this.PropE = propE;
this.PropF = propF
}
}
PropF 的所有属性可能也会是可变的 - 除非在那里也遵循具有私有设置器的相同结构。
从外部代码库中获取的类有什么不同呢?微软的类或第三方 NuGet 包中的类呢?没有办法强制不可变性。
不幸的是,根本没有办法强制通用的不可变性,即使在最新版本的 C# 中也是如此。出于向后兼容的原因,我认为永远都不会有。
如果有一种本地化的 C# 方法可以默认确保不可变性,那将会很棒,但实际上并没有 - 也不太可能因为向后兼容的原因。我的解决方案是,在编码时,我简单地 假装 项目中存在不可变性,从不改变任何对象。在 C# 中,没有任何形式的强制执行,因此你只能自己或团队内部做出决定。
将所有内容整合在一起 - 完整的功能流程
我已经谈了很多关于如何立即使用一些简单技术使你的代码更具功能性。现在,我想展示一个完整的、虽小但完整的应用程序,用于展示一个端到端的功能流程。
我将编写一个非常简单的 CSV 解析器。在我的示例中,我想要读取包含有关《Doctor Who》前几个系列数据的完整 CSV 文件的文本⁷。我想要读取数据,将其解析为普通的 C#对象(POCO,即仅包含数据而没有逻辑的类),然后将其聚合成一个报告,该报告计算每个季度的剧集数以及已知丢失的剧集数。⁸。为了这个示例的目的,我简化了 CSV 解析。我不会担心字符串字段周围的引号,字段值中的逗号或需要额外解析的任何值。对于所有这些都有第三方库!我只是在证明一个观点。
这个完整的过程代表了一个很好的、典型的功能流。将单个项拆分成列表,应用列表操作,然后再次聚合为单个值。
这是我的 CSV 文件的结构:
-
[0] - 季节数。整数值介于 1 和 39 之间。现在看起来我有点冒险,因为到目前为止已经有 39 个季度了。
-
[1] - 故事名称 - 我不关心的字符串字段
-
[2] - 编剧 - 同上
-
[3] - 导演 - 同上
-
[4] - 剧集数 - 在《Doctor Who》中,所有故事包含 1 到 14 集。直到 1989 年,所有故事都是多集连续剧。
-
[5] - 缺失剧集数 - 这部连续剧中不知道存在的集数。任何非零数目对我来说都太多了,但这就是生活。
我想最终得到的报告只包括以下字段:
-
季节数
-
总剧集数
-
总缺失剧集数
-
缺失百分比
让我们继续编写一些代码……。
var text = File.ReadAllText(filePath);
// Split the string containing the whole contents of the
// file into an array where each line of the original file
// (i.e. each record) is an array element
var splitLines = text.Split(Environment.NewLine);
// Split each line into an array of fields, splitting the
// source array by the ',' character. Convert to Array
// for each access.
var splitLinesAndFields = splitLines.Select(x => x.Split(",").ToArray());
// Convert each string array of fields into a data class.
// parse any non-string fields into the correct type.
// Not strictly necessary, based on the final aggregation
// that follows, but I believe in leaving behind easily
// extendible code
var parsedData = splitLinesAndFields.Select(x => new Story
{
SeasonNumber = int.Parse(x[0]),
StoryName = x[1],
Writer = x[2],
Director = x[3],
NumberOfEpisodes = int.Parse(x[4]),
NumberOfMissingEpisodes = int.Parse(x[5])
});
// group by SeasonNumber, this gives us an array of Story
// objects for each season of the TV series
var groupedBySeason = parsedData.GroupBy(x => SeasonNumber);
// Use a 3 field Tuple as the aggregate state:
// S (int) = the season number. Not required for
// the aggregation, but we need a way
// to pin each set of aggregated totals
// to a season
// NumEps (int) = the total number of episodes in all
// serials in the season
// NumMisEps (int) = The total number of missing episodes
// from the season
var aggregatedReportLines = groupedBySeason.Select(x =>
x.Aggregate((S: x.Key, NumEps: 0, NumMisEps: 0),
(acc, val) => (acc.S,
acc.NumEps + val.NumberOfEpisodes,
acc.NumMisEps + val.NumberOfMissingEpisodes)
)
);
// convert the Tuple-based results set to a proper
// object and add in the calculated field PercentageMissing
// not strictly necessary, but makes for more readable
// and extendible code
var report = aggregatedReportLines.Select(x => new ReportLine
{
SeasonNumber = x.S,
NumberOfEpisodes = x.NumEps,
NumberOfMIssingEpisodes = x.NumMisEps,
PercentageMissing = (x.NumMisEps/x.NumEps)*100
});
// format the report lines to a list of strings
var reportTextLines = report.Select(x => $"{x.SeasonNumber}\t {x.NumberOfEpisodes}\t" +
$"{x.NumberofMissingEpisodes}\t{x.PercentageMissing}");
// join the lines into a large single string with New Line
// characters between each line
var reportBody = string.Join(Environment.NewLine, reportTextLines);
var reportHeader = "Season\tNo Episodes\tNo MissingEps\tPercentage Missing";
// the final report consists of the header, a new line, then the reportbody
var finalReport = $"{reportHeader}{Environment.NewLine}{reportTextLines}";
如果你感兴趣,结果看起来会像这样(“\t”字符是制表符,使其更易读):
Season No Episodes No Missing Eps Percentage Missing,
1 42 9 21.4
2 39 2 5.1
3 45 28 62.2
4 43 33 76.7
5 40 18 45
6 44 8 18.2
7 25 0 0
8 25 0 0
9 26 0 0
...
注意,我本可以使代码示例更简洁,并且像这样将所有内容写在一个长长的连贯表达式中:
var reportTextLines = File.ReadAllText(filePath)
.Split(Environment.NewLine)
.Select(x => x.Split(",").ToArray())
.GroupBy(x => x[0])
.Select(x =>
x.Aggregate((S: x.Key, NumEps: 0, NumMisEps: 0),
(acc, val) => (acc.S,
acc.NumEps + int.Parse(va[4]),
acc.NumMisEps + int.Parse(val[5]))
)
)
.Select(x => $"{x.S}, {x.NumEps},{x.NumMisEps},{(x.NumMisEps/x.NumEps)*100}");
var reportBody = string.Join(Environment.NewLine, reportTextLines);
var reportHeader = "Season,No Episodes,No MissingEps,Percentage Missing";
var finalReport = $"{reportHeader}{Environment.NewLine}{reportHeader}";
这种方法没有错,但我喜欢将其拆分成单独的行,原因有几个:
-
变量名提供了一些关于你的代码正在做什么的见解。我们有点像是在半强制性地进行代码注释。
-
可以检查中间变量,查看每个步骤中的内容。这使得调试更容易,因为正如我在前一章中所说的那样——就像在数学答案中回顾你的工作,看看哪一步错了。
没有最终的功能差异,没有任何会被最终用户注意到的地方,所以采用哪种风格更多是个人品味的问题。无论你以何种方式写作,都要保持可读性和易于跟踪。
更进一步 - 发展你的函数式编程技能
这里有一个挑战给你。如果这里描述的一些或全部技术对你来说是新的,那就去尝试一下,享受一番吧。
挑战自己按照以下规则编写代码:
-
将所有变量视为不可变的 - 一旦设置,不要更改任何变量值。基本上把所有东西都当作常量对待。
-
不允许使用以下语句 -
If
、For
、ForEach
、While
。只有在三元表达式中可以接受If
- 例如:someBoolean ? valueOne : valueTwo。 -
尽可能写尽可能多的函数,简洁的箭头函数(也称为 Lambda 表达式)。
要么将其作为你的生产代码的一部分,要么去寻找一个代码挑战网站,比如The Advent of Code (https://adventofcode.com)或Project Euler (https://projecteuler.net)。找些你感兴趣的事情来做。
如果你不想在 Visual Studio 中为这些练习创建整个解决方案,总还有 LINQPad(https://www.linqpad.net/)可以快速轻松地编写一些 C#代码。
当你掌握了这些之后,你就准备好迈向下一步了。希望你到目前为止玩得开心!
摘要
在本章中,我们探讨了一些基于简单 Linq 技术的方法,可立即在任何 C#代码库中使用至少.NET Framework 3.5 编写函数式风格的代码,因为这些功能是永恒的,并且在.NET 的每个后续版本中都无需更新或替换。
我们讨论了 Select 语句的更高级特性,Linq 的一些较少知名的特性以及聚合和递归方法。
在下一章中,我将探讨一些最新的 C#开发进展,可以在更新的代码库中使用。
¹ 说实话,我更喜欢科幻。
² 并且在一个例子中,一些定义也在数据库存储过程之外。
³ 除了你的雇主。他们支付你的账单。如果他们很好的话,每年还会送你一张生日卡。
⁴ 作为一名函数式程序员,我坚信应尽可能暴露最抽象的接口,因此我从来不使用ToList()
。即使ToList
稍微快一点,我也只用ToArray()
。
⁵ 不行,Sophie。仅仅用手指操作是不够的!
⁶ 看看他本人的视频 这里 (https://www.youtube.com/watch?v=OISR3al5Bnk)
⁷ 对于那些不熟悉的人来说,这是一部自 1963 年以来断断续续播放的英国科幻系列。在我看来,这是有史以来最伟大的电视系列。关于这一点,我不接受任何异议。
⁸ 令人遗憾的是,BBC 在 1970 年代销毁了许多该系列的剧集。如果你有其中的任何剧集,请归还给我。
第三章:C# 7 及以后的函数式编程
我不确定具体是在何时做出决定将 C# 设计为混合面向对象/函数式语言。最初的基础工作是在 C# 3 中奠定的。那时引入了 Lambda 表达式和匿名类型等特性,后来成为 .NET 3.5 中 LINQ 的一部分。
然后,在相当长的一段时间内,关于函数式特性并没有什么新东西。事实上,直到 2017 年 C# 7 发布之后,函数式编程似乎再次对 C# 团队变得相关起来。
从 C# 7 开始,每个版本的 C# 都包含了一些新的、令人兴奋的内容,以更多函数式编码的方式,这种趋势目前看来并没有停止的迹象!
在上一章中,我们看了一些几乎可以在野外使用的任何 C# 代码库中实现的函数式特性。在本章中,我们将抛弃这种假设,看看如果你的代码库允许使用最新的特性或至少自 C# 7 以来发布的特性,你可以使用哪些功能。
元组
元组在 C# 7 中被引入。Nuget 包存在以允许一些旧版本的 C# 使用它们。它们基本上是一种快速且简单的属性集合方式,而无需创建和维护一个类。
如果你有几个属性想要在一个地方暂时保留一会儿,然后立即处理掉,元组非常适合。
如果你有多个对象想要在选择操作之间传递,或者想要在一个操作中传入或传出多个项,那么你可以使用元组。
这是使用元组的一个例子:
var filmIds = new[]
{
4665,
6718,
7101
};
// Turns each int element of the filmIds array
// into a tuple containing the film and cast list
// as separate properties
var filmsWithCast = filmIds.Select(x => (
film: GetFilm(x),
castList: GetCastList(x)
));
// 'x' here is a tuple, and it's now being converted to a string
var renderedFilmDetails = filmsWithCast.Select(x =>
"Title: " + x.film.Title +
"Director: " + x.film.Director +
"Cast: " + string.Join(", ", x.castList));
在我的例子中,我使用元组来配对每个给定电影 ID 的两个查找函数的数据,这意味着我可以运行后续的选择操作,将一对对象简化为单个返回值。
模式匹配
Switch 语句已经存在比今天大多数仍在工作的开发者还要久远。它们有它们的用途,但在所能做的事情上相当有限。函数式编程将这一概念提升了几个层次。这就是模式匹配的作用。
C# 7 开始引入这一功能到 C# 语言中,并在后续版本中进行了多次增强,未来很可能还会增加更多功能。
模式匹配是节省大量工作的一种绝佳方式。为了让你明白我的意思,我现在将展示一些过程式代码,并展示模式匹配在几个不同版本的 C# 中是如何实现的。
过程化银行账户
举个例子,让我们想象一个经典的面向对象的示例 - 银行账户。我将创建一组银行账户类型,每种类型都有不同的规则来计算利息金额。这些并不是真实银行业务,完全出自我的想象。
这些是我的规则:
-
标准银行账户通过将余额乘以账户的利率来计算利息
-
余额为 10,000 或更少的高级银行账户是标准银行账户
-
余额超过 10,000 的高级银行账户应用了一个额外奖励利率增强的利率
-
百万富翁的银行账户,他们拥有的钱比一个十进制可以容纳的最大值还要多(这是一个非常非常大的数字 - 大约 8*10²⁸,所以他们肯定非常富有。你认为如果我要求他们一点钱,他们会愿意借给我吗?我需要一双新鞋)。他们有一个溢出余额属性,用于添加所有那些他们拥有的超过最大十进制值的钱,这些钱无法像我们这些平民一样存储在标准余额属性中。他们需要根据两个余额计算利息。
-
大富翁玩家的银行账户。他们经过“前进”时会额外得到 200。我没有实现“直接去监狱”逻辑,一天只有那么多时间。
这些是我的类:
public class StandardBankAccount
{
public decimal Balance { get; set; }
public decimal InterestRate { get; set; }
}
public class PremiumBankAccount : StandardBankAccount
{
public decimal BonusInterestRate { get; set; }
}
public class MillionairesBankAccount : StandardBankAccount
{
public decimal OverflowBalance { get; set; }
}
public class MonopolyPlayersBankAccount : StandardBankAccount
{
public decimal PassingGoBonus { get; set; }
}
对于银行账户实现计算利息功能的过程化方法 - 或者我认为的“长式”方法,可能会像这样:
public decimal CalculateNewBalance(StandardBankAccount sba)
{
// If real type of object is PremiumBankAccount
if (sba.GetType() == typeof(PremiumBankAccount))
{
// cast to correct type so we can access the Bonus interest
var pba = (PremiumBankAccount)sba;
if (pba.Balance > 10000)
{
return pba.Balance * (pba.InterestRate + pba.BonusInterestRate);
}
}
// if real type of object is a Millionaire's bank account
if(sba.GetType() == typeof(MillionairesBankAccount))
{
// cast to the correct type so we can get access to the overflow
var mba = (MillionairesBankAccount)sba;
return (mba.Balance * mba.InterestRate) +
(mba.OverflowBalance * mba.InterestRate)
}
// if real type of object is a Monopoly Player's bank account
if(sba.GetType() == typeof(MonopolyPlayersBankAccount))
{
// cast to the correct type so we can get access to the bonus
var mba = (MonopolyPlayersBankAccount)sba;
return (mba.Balance * mba.InterestRate) +
mba.PassingGoBonus
}
// no special rules apply
return sba.Balance * sba.InterestRate;
}
与过程化代码一样,上面的代码不太简洁,可能需要一点时间来理解其意图。一旦系统投入生产,如果添加了许多新规则,它也很容易被滥用。
面向对象的方法要么使用接口,要么使用多态性 - 即创建一个带有 CalculateNewBalance 函数的抽象基类。问题在于,现在逻辑分散在许多地方,而不是包含在一个易于阅读的函数中。
在接下来的部分中,我将展示每个后续版本的 C#是如何处理这个问题的。
C# 7 中的模式匹配
C# 7 为我们提供了解决这个问题的两种不同方法。首先是新的is
运算符 - 一种比以前可用的检查类型更方便的方式。is
运算符还可以用于自动将源变量转换为正确的类型。
我们更新后的源码将看起来像这样:
public decimal CalculateNewBalance(StandardBankAccount sba)
{
// If real type of object is PremiumBankAccount
if (sba is PremiumBankAccount pba)
{
if (pba.Balance > 10000)
{
return pba.Balance * (pba.InterestRate + pba.BonusInterestRate);
}
}
// if real type of object is a Millionaire's bank account
if(sba is MillionairesBankAccount mba)
{
return (mba.Balance * mba.InterestRate) +
(mba.OverflowBalance * mba.InterestRate);
}
// if real type of object is a Monopoly Player's bank account
if(sba is MonopolyPlayersBankAccount mba)
{
return (mba.Balance * mba.InterestRate) +
mba.PassingGoBonus;
}
// no special rules apply
return sba.Balance * sba.InterestRate;
}
请注意上述代码示例中,使用is
运算符,我们还可以自动将源变量包装成正确类型的新局部变量。
这不错,有点更加优雅,我们也节省了一些冗余的行数,但我们可以做得更好,这就是 C# 7 的另一个特性介入的地方 - 类型切换。
public decimal CalculateNewBalance(StandardBankAccount sba)
{
switch (sba)
{
case PremiumBankAccount pba when pba.Balance > 10000:
return pba.Balance * (pba.InterestRate + pba.BonusInterestRate);
case MillionairesBankAccount mba:
return (mba.Balance * mba.InterestRate) +
(mba.OverflowBalance & mba.InterestRate);
case MonopolyPlayersBankAccount mba:
return (mba.Balance * mba.InterestRate) + PassingGoBonus;
default:
return sba.Balance * sba.InterestRate;
}
}
挺酷,对吧?模式匹配似乎是近年来 C#中最发达的功能之一。正如我即将展示的,自此以来的每个主要 C#版本都在其上继续添加功能。
C# 8 中的模式匹配
C# 8 中的事情有了进展,基本上是相同的概念,但有了一个新的、更新的匹配语法,更接近 JSON,或者说是一个 C# 对象初始化表达式。任意数量的子句可以放在对象检查的大括号内,而默认情况现在由 _ 丢弃字符表示。
public decimal CalculateNewBalance(StandardBankAccount sba) =>
sba switch
{
PremiumBankAccount { Balance: > 10000 } pba => pba.Balance *
(pba.InterestRate + pba.BonusInterestRate),
MillionairesBankAccount mba => (mba.Balance * mba.InterestRate) +
(mba.OverflowBalance & mba.InterestRate);
MonopolyPlayersBankAccount mba =>
(mba.Balance * mba.InterestRate) + PassingGoBonus;
_ => sba.Balance * sba.InterestRate
};
}
此外,switch 现在也可以是一个表达式,你可以将其用作小型单用途函数的主体,具有出乎意料的丰富功能。这意味着它也可以存储在 Func 委托中,以便可能作为高阶函数传递。
这是一个使用老童年游戏的例子:剪刀、石头、布。在美国被称为石头、纸、剪刀,在日本被称为石头、纸、剪刀。在以下示例中,我创建了一个 Func
委托,并制定了以下规则:
-
两名玩家同时画出相同的 = 平局
-
剪刀胜纸
-
石头胜纸
-
石头胜剪刀
这个函数具体确定了从我的角度对我的想象对手的结果是什么。
public enum SPS
{
Scissor,
Paper,
Stone
}
public enum GameResult
{
Win,
Lose,
Draw
}
var calculateMatchResult = (SPS myMove, SPS theirMove) =>
(myMove, theirMove) switch
{
_ when myMove == theirMove => GameResult.Draw,
( SPS.Scissor, SPS.Paper) => GameResult.Win,
( SPS.Paper, SPS.Stone ) => GameResult.Win,
(SPS.Stone, SPS.Scissor) => GameResult.Win,
_ => GameResult.Lose
};
将其存储在 'Func<SPS,SPS>' 类型的变量中后,我可以将其传递到任何需要它的地方。
这可以作为函数的参数,以便在运行时可以注入功能:
public string formatGames(IEnumerable<(SPS,SPS)> game, Func<SPS,SPS,Result) calc) =>
string.Join("\r\n", game.Select((x, i) => "Game " + i + ": " + calc(x.Item1,x.Item2).ToString());
如果我想要测试该函数的逻辑而不将实际逻辑放入其中,我可以轻松地从测试方法中注入自己的 Func
,这样我就不必关心真实逻辑是什么——可以在其他专门的测试中进行测试。
这是使结构更加有用的又一个小改进。
C# 9 中的模式匹配
在 C# 9 中没有添加重大内容,但有几个不错的小功能。现在在模式列表的大括号内部,is
表达式的 and
和 not
关键字可以工作了,并且如果不需要其属性,则不再需要一个用于转换类型的局部变量。
虽然不是突破性的,但这确实继续减少必要的样板代码量,并为我们提供了更多表达性更强的语法片段。
我在下一个示例中加入了一些更多的规则,使用这些功能。现在有两类带有不同特殊利率水平的 PremiumBankAccounts,还有一种用于已关闭账户的银行账户类型,不应该产生任何利息¹。
public decimal CalculateNewBalance(StandardBankAccount sba) =>
sba switch
{
PremiumBankAccount { Balance: > 10000 and <= 20000 } pba => pba.Balance *
(pba.InterestRate + pba.BonusInterestRate),
PremiumBankAccount { Balance: > 20000 } pba => pba.Balance *
(pba.InterestRate + pba.BonusInterestRate * 1.25M),
MillionairesBankAccount mba => (mba.Balance * mba.InterestRate) +
(mba.OverflowBalance + mba.InterestRate),
MonopolyPlayersBankAccount {CurrSquare: not "InJail" } mba =>
(mba.Balance * mba.InterestRate) + mba.PassingGoBonus;
ClosedBankAccount => 0,
_ => sba.Balance * sba.InterestRate
};
}
还不错,对吧?
C# 10 中的模式匹配
像 C# 9 一样,C# 10 只是增加了另一个不错的节省时间和样板的功能。用于比较属于正在检查的类型的子对象属性的简单语法。
public decimal CalculateNewBalance(StandardBankAccount sba) =>
sba switch
{
PremiumBankAccount { Balance: > 10000 and <= 20000 } pba =>
pba.Balance * (pba.InterestRate + pba.BonusInterestRate),
MillionairesBankAccount mba =>
(mba.Balance * mba.InterestRate) + (mba.OverflowBalance + mba.InterestRate),
MonopolyPlayersBankAccount {CurrSquare: not "InJail" } mba =>
(mba.Balance * mba.InterestRate) + PassingGoBonus,
MonopolyPlayersBankAccount {Player.FirstName: "Simon" } mba =>
(mba.Balance * mba.InterestRate) + (mba.PassingGoBonus / 2),
ClosedBankAccount => 0,
_ => sba.Balance * sba.InterestRate
};
在这个有些愚蠢的例子中,现在可以在通过 Monopoly 时排除所有的“Simon”们赚取这么多钱。可怜的我。
我建议此时再花点时间检查上面的函数。想象一下,如果不作为模式匹配表达式完成,将需要编写多少行代码!事实上,它从技术上讲仅包括一行代码。一…真的很长…行代码,有很多 NewLines 使其可读。尽管如此,这个观点仍然适用。
C# 11
C# 11 包含了一个新的模式匹配功能,可能使用范围有些有限,但当符合其条件时将会非常有用。
.NET 团队已经添加了基于 Enumerable 内容进行匹配甚至将其解构为单独变量的能力。
让我们想象我们正在创建一个非常简单的基于文本的冒险游戏。当我很小的时候,这些东西很流行。冒险游戏是通过键入命令来玩的。想象一下像 Monkey Island 这样的东西,但没有图形,只有文本。你必须更多地依靠自己的想象力。
第一个任务是从用户那里接收输入并决定他们试图做什么。在英语命令中,动词通常作为句子的第一个词。“GO WEST”,“KILL THE GOBLIN”,“EAT THE SUSPICIOUS-LOOKING MUSHROOM”。这里的相关动词是 GO、KILL 和 EAT 分别。
这是我们如何使用 C# 11 模式匹配的方式:
var verb = input.Split(" ") switch
{
["GO", "TO",.. var rest] => this.actions.GoTo(rest),
["GO", .. var rest] => this.actions.GoTo(rest),
["EAT", .. var rest] => this.actions.Eat(rest),
["KILL", .. var rest] => this.actions.Kill(rest)
};
上述开关表达式中的“..”表示“我不在乎数组中的其他内容,请忽略它”。在其后放置一个变量用于包含除了那些特别匹配的部分之外的数组中的其他所有内容。
在我上面的示例中,如果我输入文本“GO WEST”,那么 GoTo 操作将以单元素数组["WEST"]作为参数调用,因为“GO”是匹配的一部分。
这是另一种很好的使用方式。想象我正在将人们的姓名处理成数据结构,我想要其中 3 个是 FirstName、LastName 和一个数组 - MiddleNames(我只有一个中间名,但很多人有多个)。
public class Person
{
public string FirstName { get; set; }
public IEnumerable<string> MiddleNames { get; set; }
public string LastName { get; set; }
}
// The real name of Doctor Who actor, Sylvester McCoy
var input = "Percy James Patrick Kent-Smith".Split(" ");
var sylv = new Person
{
FirstName = input.First(),
MiddleNames = input is [_, .. var mns, _] ? mns : Enumerable.Empty<string>(),
LastName = input.Last()
};
在此示例中,Person 类被实例化为:
FirstName = “Percy”, LastName = “Kent-Smith”, MiddleNames = [ “James”, “Patrick” ]
我不确定我会找到很多使用场景,但当我找到时,它可能会让我非常兴奋。这是一个非常强大的功能。
区分联合
我不确定这是否是我们将来在 C# 中会得到的东西。我知道目前 Nuget 上至少有两个尝试来实现这个概念:
-
Harry McIntyre 的 OneOf (https://github.com/mcintyre321/OneOf)
-
Kim Hugener-Olsen 的 Sundew.DiscriminatedUnions (https://github.com/sundews/Sundew.DiscriminatedUnions)
我在第六章详细讨论了区分联合及其在 C# 中的实现方式,如果您想了解更多,请跳转到那里。
简言之:它们是一种可能是几种类型之一的类型。它们在 F# 中可以本地使用,但是截至目前 C# 并没有这些功能,而它们是否会被添加还不得而知。
与此同时,在 GitHub 上正在进行讨论(https://github.com/dotnet/csharplang/issues/113),并且已存在提案(https://github.com/dotnet/csharplang/blob/main/proposals/discriminated-unions.md)。
我不知道有任何严肃的计划将它们添加到 C# 12 中,所以现在我们只能继续观望!
活动模式
这是我可以预见到的一个 F# 特性很快会被添加到 C# 中。这是对模式匹配的增强,允许在表达式的左侧“模式”部分执行函数。这是一个 F# 的例子:
let (|IsDateTime|_|) (input:string) =
let success, value = DateTime.TryParse input
if success then Some value else None
let tryParseDateTime input =
match input with
| IsDateTime dt -> Some dt
| _ -> None
F# 开发者能够做的事情,例如这个例子,是提供自己的自定义函数,以便放在表达式的左侧“模式”部分。
在这里,“IsDateTime”是自定义函数,定义在第一行。它接受一个字符串,并且如果解析成功则返回一个值,如果解析失败则返回一个类似于空结果的值。
模式匹配表达式“tryParseDateTime”使用 IsDateTime 作为模式,如果从 IsDateTime 返回了一个值,则选择模式匹配表达式中的该情况,并返回生成的 DateTime。
不要过多担心 F# 语法的复杂性,我不指望你在这里学习这些。有其他的 F# 书籍,你可能会选择一本或多本来了解。
-
由 Isaac Abraham 撰写的《Get Programming with F#》(Manning)
-
由 Ian Russell 编写的《Essential F#》(https://leanpub.com/essential-fsharp)
-
由 Scott Wlaschin 编写的《F# for Fun and Profit》(https://fsharpforfunandprofit.com/)
这两个 F# 功能是否会在以后的 C# 版本中提供还有待观察,但是 C# 和 F# 共享一个通用语言运行时,因此它们被移植过来并非不可能。
只读结构体
我这里不打算详细讨论结构体,还有其他优秀的书籍详细讲述了 C# 的特性。从 C# 的角度来看,它们的优点在于它们是按值在函数之间传递的,而不是按引用 - 即传递的是一个副本,原始对象保持不变。传统的面向对象技术是将一个对象传递到一个函数中,以便在那里修改它,违背了函数式程序员的原则。我们基于类实例化一个对象,然后再也不改变它。
结构体已存在很长时间了,虽然它们是按值传递的,但仍然可以修改其属性,因此它们并非完全不可变。至少直到 C# 7.2。
现在,可以向结构体定义添加只读修饰符,在设计时强制所有结构体属性为只读。任何尝试向属性添加设置器将导致编译器错误。
由于所有属性都被强制为只读,在 C# 7.2 中,所有属性都需要包含在构造函数中才能设置。看起来像这样:
public readonly struct Movie
{
public string Title { get; private set; };
public string Directory { get; private set; };
public IEnumerable<string> Cast { get; private set; };
public Movie(string title, string directory, IEnumerable<string> cast)
{
this.Title = title;
this.Directory = directory;
this.Cast = cast;
}
}
var bladeRunner = new Movie(
"Blade Runner",
"Ridley Scott",
new []
{
"Harrison Ford",
"Sean Young"
}
);
这仍然有点笨拙,迫使我们在每次向结构体添加属性时更新构造函数,但这仍然比没有强化要好。
还值得讨论的是,我已经在结构体中添加了一个列表的情况:
public readonly struct Movie
{
public readonly string Title;
public readonly string Directory;
public readonly IList<string> Cast;
public Movie(string title, string directory, IList<string> cast)
{
this.Title = title;
this.Directory = directory;
this.Cast = cast;
}
}
var bladeRunner = new Movie(
"Blade Runner",
"Ridley Scott",
new []
{
"Harrison Ford",
"Sean Young"
}
);
bladeRunner.Cast.Add(("Edward James Olmos"));
这将编译,并且应用程序将运行,但当调用Add()
函数时将抛出错误。结构体的只读性质被强制执行是件好事,但我不喜欢必须担心另一个潜在的未处理异常。
但是开发人员现在可以添加只读修饰符以澄清意图,这将防止任何可能避免的可变性添加到结构体中。即使这意味着还必须有另一层错误处理。
仅初始化的设置器
C# 9 引入了一种新的自动属性类型。我们已经有了Get
和Set
,但现在还有Init
。
如果您有一个附有Get
和Set
的类属性,这意味着可以随时检索或替换该属性。
如果它有Get
和Init
,则在对象实例化时可以设置其值,但之后不能再更改。
这意味着我们的只读结构体(以及我们所有的类)现在可以以稍微更美观的语法进行实例化,然后处于只读状态:
public readonly struct Movie
{
public string Title { get; init; }
public string Director { get; init; }
public IEnumerable<string> Cast { get; init; }
}
var bladeRunner = new Movie
{
Title = "Blade Runner",
Director = "Ridley Scott",
Cast = new []
{
"Harrison Ford",
"Sean Young"
}
};
这意味着我们不必再维护一个复杂的构造函数(即一个为每个属性都有参数的构造函数 - 可能会有几十个),以及属性本身,这消除了恼人的样板代码的潜在来源。
尽管如此,我们仍然面临异常抛出的问题,当尝试修改列表和子对象时。
记录类型
在 C# 9 中,自从模式匹配之后,我的最爱之一 - 记录类型。如果你还没有机会自己尝试过这些,那么请尽快尝试一下。它们非常棒。
表面上看,它们看起来与结构体相似。在 C# 9 中,记录类型基于类,因此通过引用传递。
自 C# 10 及以后,这种情况不再适用,记录现在更像是结构体,这意味着它们可以按值传递。然而,与结构体不同的是,没有只读修饰符,因此不可变性必须由开发人员来强制执行。这是“银翼杀手”代码的更新版本:
public record Movie
{
public string Title { get; init; }
public string Director { get; init; }
public IEnumerable<string> Cast { get; init; }
}
var bladeRunner = new Movie
{
Title = "Blade Runner",
Director = "Ridley Scott",
Cast = new []
{
"Harrison Ford",
"Sean Young"
}
};
它看起来并没有那么不同,对吧?然而,记录真正独特之处在于当你想创建一个修改过的版本时。让我们假设一下,在我们的 C# 10 应用程序中,我们想为《银翼杀手》导演剪辑版创建一个新的电影记录³。就我们的目的而言,这完全相同,只是有一个不同的标题。为了节省定义数据,我们会从原始记录中直接复制数据,但进行一处修改。如果是使用只读结构体,我们必须像这样做:
public readonly struct Movie
{
public string Title { get; init; }
public string Director { get; init; }
public IEnumerable<string> Cast { get; init; }
}
var bladeRunner = new Movie
{
Title = "Blade Runner",
Director = "Ridley Scott",
Cast = new []
{
"Harrison Ford",
"Sean Young"
}
};
var bladeRunnerDirectors = new Movie
{
Title = $"{bladeRunner.Title} - The Director's Cut",
Director = bladeRunner.Director,
Cast = bladeRunner.Cast
};
这遵循函数式编程范式,并不算太糟糕,但如果我们想要强制不可变性,我们必须在应用程序中包含另一大堆样板代码。
如果我们有类似需要根据用户交互或某种外部依赖定期更新的状态对象,那么这变得很重要。使用只读结构体方法,我们将不得不进行大量属性复制。
记录类型为我们提供了一个绝对惊艳的新关键字 - with
。这是一种快捷方便的方法,可以创建一个现有记录的副本,并进行修改。使用记录类型的《银翼杀手》导演剪辑版的更新代码如下所示:
public record Movie
{
public string Title { get; init; }
public string Director { get; init; }
public IEnumerable<string> Cast { get; init; }
}
var bladeRunner = new Movie
{
Title = "Blade Runner",
Director = "Ridley Scott",
Cast = new []
{
"Harrison Ford",
"Sean Young"
}
};
var bladeRunnerDirectors = bladeRunner with
{
Title = $"{bladeRunner.Title} - The Director's Cut"
};
酷吧?使用记录类型可以节省大量的样板代码。
我最近用函数式 C# 写了一个文本冒险游戏。我创建了一个名为 GameState 的中心记录类型,包含玩家迄今为止的所有进展。我使用了一个庞大的模式匹配语句来判断玩家本回合的操作,并使用简单的 with 语句通过返回修改后的副本来更新状态。这是编写状态机的一种优雅方式,通过去除大量无趣的样板代码,大大澄清了意图。
记录的一个很棒的特性是,你甚至可以简单地用一行来定义它们,像这样:
public record Movie(string Title, string Director, IEnumerable<string> Cast);
使用这种定义风格创建 Movie 的实例不能使用花括号,必须使用一个函数:
var bladeRunner = new Movie(
"Blade Runner",
"Ridley Scott",
new[]
{
"Harrison Ford",
"Sean Young"
});
请注意,除非使用类似这样的构造标记,否则必须按顺序提供所有属性:
var bladeRunner = new Movie(
Cast: new[]
{
"Harrison Ford",
"Sean Young"
},
Director: "Ridley Scott",
Title: "Blade Runner");
你仍然必须提供所有属性,但可以按任意顺序放置它们。这对你有多大好处呢……
你更喜欢哪种语法是个人偏好问题。在大多数情况下,它们是等效的。
可空引用类型
尽管听起来不像是一种新类型,就像记录类型一样。这实际上是一种编译器选项,是在 C# 8 中引入的。这个选项在 CSPROJ 文件中设置,就像这个摘录中的一样:
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
如果你更喜欢使用 UI,则选项也可以在项目属性的“构建”部分中设置。
严格来说,启用空引用类型功能并不会改变编译器生成的代码行为,但它确实会在 IDE 和编译器中添加一组额外的警告,以帮助避免可能将 NULL 赋值的情况。以下是一些被添加到我的电影记录类型中的警告,提醒我属性可能会为 NULL:
如果我试图将《银翼杀手导演剪辑版》的标题设置为 NULL,那么会出现另一个例子:
请记住,这些只是编译器警告。代码仍将执行而没有任何错误。它只是指导您编写代码,减少包含空引用异常的可能性 - 这只能是一件好事。
避免使用 NULL 值通常是一个良好的实践,无论是否进行功能编程。NULL 被称为“十亿美元的错误”。它是由托尼·霍尔在 60 年代中期发明的,自那时以来,它一直是生产中错误的主要原因之一。将一个对象传递到某些意外地变成 NULL 的地方。这会导致空引用异常,而在你遇到第一个这样的异常之前,你在这个行业中也不需要有很长时间!
将 NULL 作为一个值添加到您的代码库中会增加不必要的复杂性,并引入潜在错误的另一个来源。这就是为什么值得注意编译器警告,并尽可能避免在您的代码库中使用 NULL。
如果某个值有很好的理由应为 NUL,则可以通过像这样添加 ? 字符到属性中来实现:
public record Movie
{
public string? Title { get; init; }
public string? Director { get; init; }
public IEnumerable<string>? Cast { get; init; }
}
唯一考虑在我的代码库中故意添加可空属性的情况是第三方库需要它。即使如此,我也不会允许可空被持久化到我的代码中 - 我可能会把它藏在某个地方,让解析外部数据的代码能看到它,然后将其转换为更安全、更受控的结构,以传递给系统的其他部分。
未来
在撰写本文时,C# 11 已经发布并作为 .NET 7 的一部分已经得到了很好的确认。.NET 8 和 C# 12 的计划刚刚开始制定,但尚不清楚它们将包含什么 - 如果有的话 - 对功能编程人员来说可能会有一些新的东西可以做更多的功能编程。
总结
在本章中,我们查看了自从功能编程开始集成到 C# 3 和 4 以来发布的所有 C# 特性。我们研究了它们是什么,如何使用它们,以及为什么值得考虑。
广义上来说,这些可以分为两类:
-
模式匹配,在 C#中实现为一种高级形式的 switch 语句,允许编写非常强大且节省代码的逻辑,简洁明了。我们看到每个 C#版本都为开发者贡献了更多的模式匹配特性。
-
不可变性,即变量一旦实例化就无法修改的能力。出于向后兼容性的原因,真正的不可变性在 C#中很难实现,但 C#正在新增一些特性,如只读结构体和记录类型,使开发者可以更轻松地以一种容易假装不可变性存在的方式工作,而无需向应用程序添加大量繁琐的样板代码。
在下一章中,我们将进一步探讨一些方法,展示如何以新颖的方式使用 C#的现有特性,以丰富您的函数式编程工具箱。
¹ 坦率地说,任何银行都不会提供这种服务。
² C# in a Nutshell 这本书也是由 O’Reilly 出版的,也是不错的选择。
³ 在我看来,远远优于戏剧性的版本。
第四章:通过功能代码工作智能,而不是努力工作
到目前为止,我所涵盖的一切都是微软 C# 团队旨在实现的功能编程。您会在微软网站上找到这些功能的提及及其示例。然而,在本章中,我想开始对 C# 进行更富创意的探索。
我不知道你怎么看,但我喜欢偷懒,或者说我不喜欢浪费时间在冗长的样板代码上。函数式编程的许多精彩之处之一就是它的简洁性,相比命令式代码。
在本章中,我将展示如何推动功能编程的边界,超出 C# 的开箱即用功能,以及如何在旧版本中实现一些较新版本的 C#,希望能让您更快地完成日常工作。
本章将探讨以下三个广泛的类别:
枚举中的 Funcs
Func
委托似乎并没有被广泛使用,但它们是 C# 的非常强大的特性。我将展示一些使用它们来扩展 C# 能力的方法。在这种情况下,通过将它们添加到 Enumerable 并使用 Linq 表达式对它们进行操作。
Funcs 作为过滤器
您还可以将 Funcs 用作过滤器 - 这是一种位于您和真正想要达到的值之间的东西。您可以使用这些原则编写一些精彩的代码。
自定义枚举
我之前讨论过 IEnumerable 及其有多酷的功能,但你知道你可以打开它们并实现自定义行为吗?我会向你展示如何做。
是时候变得“Func-y”
我在介绍中已经谈到了 Func
委托类型,但简要回顾一下 - 它们是存储为变量的函数。您定义它们接受什么参数,返回什么,并像任何其他函数一样调用它们。这里有一个快速的例子:
private readonly Func<Person, DateTime, string> SayHello =
(Person p, DateTime today) => today + " : " + "Hello " + p.Name;
在两个尖括号之间的列表中,最后一个泛型类型是返回值,之前的所有类型都是参数。我上面的例子接受两个字符串参数并返回一个字符串。
我们接下来将经常见到许多Func 委托,所以请确保在继续阅读之前对它们感到舒适。
枚举中的 Funcs
我见过很多将 Funcs 作为函数参数的例子,但我不确定有多少开发人员意识到你可以将它们放入 Enumerable,并创建一些有趣的行为。
首先,显而易见的是 - 将它们放入数组中以对同一数据进行多次操作:
private IEnumerable<Func<Employee, string>> descriptors = new []
{
x => "First Name = " + x.firstName,
x => "Last Name = " + x.lastName,
x => "MiddleNames = string.Join(" ", x.MiddleNames)
}
public string DescribeEmployee(Employee emp) =>
string.Join(Environment.NewLine, descriptors.Select(x => x(emp)));
使用这种方法,我们可以从单一的数据源(这里是一个 Employee 对象)生成多个相同类型的记录,在我的案例中,我使用内置的 .NET 方法 string.Join
聚合生成一个统一的字符串呈现给最终用户。
这种方法相对于简单的 StringBuilder 有一些优势。
首先,数组可以动态组装。每个属性和其呈现方式可能有多个规则,这些规则可以根据某些自定义逻辑从一组本地变量中选择。
其次,这是一个可枚举对象,因此通过这种方式定义它,我们利用了可枚举对象称为惰性评估的功能。关于可枚举对象的一点是,它们不是数组,甚至不是数据。它们只是指向某个东西的指针,这个东西将告诉你如何提取数据。很可能 - 实际上通常是这样的情况 - 可枚举对象背后的源头是一个简单的数组,但不一定如此。可枚举对象需要在每次通过 ForEach
循环访问下一个项目时执行一个函数。可枚举对象的开发目的是使其在最后可能的时刻才转换为实际数据 - 通常是在开始 ForEach
循环迭代时。大多数情况下,如果内存中有一个数组供可枚举对象使用,这并不重要,但如果有一个昂贵的函数或者用于驱动它的外部系统查找,则惰性加载可以非常有用,以防止不必要的工作。
可枚举对象的元素将逐一评估,并且只有当它们被某个执行枚举的过程使用时才会被使用。例如,如果我们使用 LINQ 的 Any
函数来评估可枚举对象中的每个元素,它将在找到满足指定条件的第一个元素时停止枚举,这意味着剩余的元素将保持未评估状态。
最后,从维护的角度来看,这种技术更容易维护。向最终结果添加新行就像向数组添加新元素一样简单。它还作为对未来程序员的一种限制,使他们更难在不适当的地方加入过多复杂的逻辑。
一个超级简单的验证器
让我们设想一个快速验证函数。它们通常看起来像这样:
public bool IsPasswordValid(string password)
{
if(password.Length <= 6)
return false;
if(password.Length > 20)
return false;
if(!password.Any(x => Char.IsLower(x)))
return false;
if(!password.Any(x => Char.IsUpper(x)))
return false;
if(!password.Any(x => Char.IsSymbol(x)))
return false;
if(password.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& password.Contains("Bieber", StringComparison.OrdinalIgnoreCase))
return false;
return true;
}
嗯,首先,实际上这是一堆代码,用来实现一个相当简单的规则。在这里,命令式代码迫使我们写下一堆重复的样板代码。除此之外,如果我们想要添加另一条规则,那可能会多写大约 4 行代码,但其实只有 1 行对我们特别有意义。
要是能把它压缩成几行简单的代码就好了……
嗯……既然你这么客气地问了,那就给你看看:
public bool IsPasswordValid(string password) =>
new Func<string, bool>[]
{
x => x.Length > 6,
x => x.Length <= 20,
x => x.Any(y => Char.IsLower(y)),
x => x.Any(y => Char.IsUpper(y)),
x => x.Any(y => Char.IsSymbol(y)),
x => !x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& !x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
}.All(f => f(password));
现在不那么长了吧?这里我做了什么?我把所有规则放入了一个将字符串转换为布尔值的 Func 数组中 - 即检查单个验证规则。我使用了一个 Linq 语句 - .All()
。这个函数的目的是评估我给它的任何 Lambda 表达式对其附加的数组的所有元素进行检查。如果其中一个返回 false,那么进程将提前终止,并且从All
返回 false(如前所述,后续值不会被访问,因此惰性评估通过不评估它们来节省时间)。如果所有项都返回 true,则All
也返回 true。
我们有效地重新创建了第一个代码示例,但我们被迫编写的样板代码 - If 语句和早期返回 - 现在在结构中是隐含的。
这也有一个优势,那就是作为代码结构非常容易维护。如果你愿意,甚至可以将其泛化为一个扩展方法。我经常这样做。类似于这样:
public static bool IsValid<T>(this T @this, params Func<T,bool>[] rules) =>
rules.All(x => x(@this));
这进一步减少了密码验证器的大小,并为您提供了一个方便的通用结构可供其他用途使用:
public bool IsPasswordValid(string password) =>
password.IsValid(
x => x.Length > 6,
x => x.Length <= 20,
x => x.Any(y => Char.IsLower(y)),
x => x.Any(y => Char.IsUpper(y)),
x => x.Any(y => Char.IsSymbol(y)),
x => !x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& !x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
)
此时此刻,我希望你重新考虑是否再写像第一个验证代码示例那样长而臃肿的东西了。
我认为IsValid
检查更易读和维护,但如果你想要一个更符合原始代码示例的代码片段,那么可以创建一个新的扩展方法,使用Any
代替All
:
public static bool IsInvalid<T>(this T @this, params Func<string,bool>[] rules) =>
rules.Any(x => @this);
这意味着每个数组元素的布尔逻辑可以被反转,因为它们最初是这样的:
public bool IsPasswordValid(string password) =>
!password.IsInvalid(
x => x.Length <= 6,
x => x.Length > 20,
x => !x.Any(y => Char.IsLower(y)),
x => !x.Any(y => Char.IsUpper(y)),
x => !x.Any(y => Char.IsSymbol(y)),
x => x.Contains("Justin", StringComparison.OrdinalIgnoreCase)
&& x.Contains("Bieber", StringComparison.OrdinalIgnoreCase)
)
如果您希望维护IsValid
和IsInvalid
两个函数,因为它们在代码库中各有用处,那么通过简单地引用其中一个来节省编码工作并避免未来的潜在维护任务可能是值得的:
public static bool IsValid<T>(this T @this, params Func<T,bool>[] rules) =>
rules.All(x => x(@this));
public static bool IsInvalid<T>(this T @this, params Func<T,bool>[] rules) =>
!@this.IsValid(rules);
聪明地使用它,我的年轻函数式学徒。
旧版本 C#的模式匹配:
模式匹配是近年来 C#中最好的功能之一,与记录类型一起,但除了最新的.NET 版本外,其他版本都不支持 - 更多有关 C# 7 及以上本机模式匹配的详情,请参见第三章。
有没有一种方法可以允许模式匹配发生,但不需要升级到较新版本的 C#?
当然有。它远不及 C# 8 中的本机语法那么优雅,但它提供了一些相同的好处。
在这个例子中,我将根据英国所得税规则的大大简化版本计算某人应缴纳的税款。请注意,这比真实情况简单得多。我不想陷入税务复杂性的泥沼。
我将要应用的规则如下:
-
年收入 ≤ £12,570,则不扣税。
-
年收入在£12,571 到£50,270 之间,则缴纳 20%的税款。
-
年收入在£50,271 到£150,000 之间,则缴纳 40%的税款。
-
年收入超过£150,000,则缴纳 45%的税款。
如果你想手写(即非功能性地)编写这段代码,看起来会像这样:
decimal ApplyTax(decimal income)
{
if (income <= 12570)
return income;
else if (income <=50270)
return income * 0.8M;
else if (income <= 150000)
return income * 0.6M;
else
return income * 0.55M;
}
现在,在 C# 8 及以后的版本中,switch 表达式可以压缩为几行代码。只要你至少运行 C# 7(即.NET Framework 4.7),这就是我可以创建的模式匹配风格:
var inputValue = 25000M;
var updatedValue = inputValue.Match(
(x => x <= 12570, x => x),
(x => x <= 50270, x => x * 0.8M),
(x => x <= 150000, x => x * 0.6M)
).DefaultMatch(x => x * 0.55M);
我传递了一个包含 2 个 lambda 表达式的元组数组。第一个确定输入是否与当前模式匹配,第二个是匹配时发生的值转换。最后检查是否应用默认模式 - 即因为没有其他模式匹配。
尽管长度只有原始代码样本的一小部分,但这包含了所有相同的功能。这里左侧元组的匹配模式很简单,但它们可以包含像您想要的那样复杂的表达式,甚至可以是调用包含详细匹配条件的整个函数。
那么,我是如何让这个工作的呢?这是一个非常简单的版本,提供了大部分所需的功能:
public static class ExtensionMethods
{
public static TOutput Match<TInput, TOutput>(
this TInput @this,
params (Func<TInput, bool> IsMatch,
Func<TInput, TOutput> Transform)[] matches)
{
var match = matches.FirstOrDefault(x => x.IsMatch(@this));
var returnValue = match.Transform(@this);
return returnValue;
}
}
我使用 Linq 方法FirstOrDefault
首先遍历左侧函数,以找到返回 true 的函数(即具有正确条件的函数),然后调用右侧的转换 Func 来获取我的修改后的值。
这很好,除非没有任何模式匹配,我们可能会遇到一些问题。很可能会出现空引用异常。
要覆盖这一点,我们需要强制提供默认匹配的需要(简单的else
语句的等效或 switch 表达式中的 _ 模式匹配)。
我的答案是让Match
函数返回一个占位符对象,该对象保存从匹配表达式转换得到的值,或执行默认的模式 lambda 表达式。改进后的版本如下:
public static MatchValueOrDefault<TInput, TOutput> Match<TInput, TOutput>(
this TInput @this,
params (Func<TInput, bool>,
Func<TInput, TOutput>)[] predicates)
{
var match = predicates.FirstOrDefault(x => x.Item1(@this));
var returnValue = match?.Item2(@this);
return new MatchValueOrDefault<TInput, TOutput>(returnValue, @this);
}
public class MatchValueOrDefault<TInput, TOutput>
{
private readonly TOutput value;
private readonly TInput originalValue;
public MatchValueOrDefault(TOutput value, TInput originalValue)
{
this.value = value;
this.originalValue = originalValue;
}
public TOutput DefaultMatch(Func<TInput, TOutput> defaultMatch)
{
if (EqualityComparer<TOutput>.Default.Equals(default, this.value))
{
return defaultMatch(this.originalValue);
}
else
{
return this.value;
}
}
与最新版本的 C#相比,此方法受到严重限制。它没有对象类型匹配,并且语法不那么优雅,但仍然可用,并且可以节省大量样板代码,同时也鼓励良好的代码标准。
较旧版本的 C#,不包括元组的版本,可以考虑使用KeyValuePair<T,T>
,尽管语法远非理想。
你不相信?好的,我们来试试。别说我没警告过你……
扩展方法本身几乎一样,并且只需少量修改即可使用KeyValuePair
代替元组:
public static MatchValueOrDefault<TInput, TOutput> Match<TInput, TOutput>(
this TInput @this,
params KeyValuePair<Func<TInput, bool>, Func<TInput, TOutput>>[] predicates)
{
var match = predicates.FirstOrDefault(x => x.Key(@this));
var returnValue = match.Value(@this);
return new MatchValueOrDefault<TInput, TOutput>(returnValue, @this);
}
这里有个丑陋的地方。创建KeyValuePair
对象的语法非常糟糕:
var inputValue = 25000M;
var updatedValue = inputValue.Match(
new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
x => x <= 12570, x => x),
new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
x => x <= 50270, x => x * 0.8M),
new KeyValuePair<Func<decimal, bool>, Func<decimal, decimal>>(
x => x <= 150000, x => x * 0.6M)
).DefaultMatch(x => x * 0.55M);
因此,在 C# 4 中仍然可以使用某种形式的模式匹配,但我不确定你这样做能获得多少好处。这可能由你来决定。至少我已经向你展示了路径。
函数式过滤
函数不仅可以用于将一种形式的数据转换为另一种形式,还可以用作过滤器,额外的层,它们位于开发者和信息或功能的原始来源之间。
本节将介绍如何使用这种方法的几个示例,使您的日常 C#编码看起来更简单,同时更少出错。
使字典更加有用
在 C#中,我绝对喜欢的一件事情是字典。如果适当使用,它们可以通过几个简单而优雅的类似数组的查找来减少大量丑陋、样板化的代码。一旦创建,它们在查找数据时也非常高效。
然而,它们存在一个问题,这使得通常需要添加大量样板代码,这些代码无效化了它们使用的原因。考虑以下代码示例:
var doctorLookup = new []
{
( 1, "William Hartnell" ),
( 2, "Patrick Troughton" ),
( 3, "Jon Pertwee" ),
( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2);
var fifthDoctorInfo = $"The 5th Doctor was played by {doctorLookup[5]}";
这段代码怎么了?它触犯了我发现的字典的一个令人费解的代码特性,如果尝试查找一个不存在的条目¹,它将触发一个必须处理的异常!
处理这种情况的唯一安全方法是使用 C#中提供的几种方法之一,在编译字符串之前检查可用键,就像这样:
var doctorLookup = new []
{
( 1, "William Hartnell" ),
( 2, "Patrick Troughton" ),
( 3, "Jon Pertwee" ),
( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2);
var fifthDoctorActor = doctorLookup.ContainsKey(5) ? doctorLookup[5] : "An Unknown Actor";
var fifthDoctorInfo = $"The 5th Doctor was played by {fifthDoctorActor}";
或者,使用稍新版本的 C#,还可以使用TryGetValue
函数来简化这段代码:
var fifthDoctorActor = doctorLookup.TryGetValue(5, out string value) ? value : "An Unknown Actor";
那么,我们能否使用函数式编程技术来减少我们的样板代码,并为我们提供字典的所有有用功能,但又不会出现糟糕的膨胀倾向?你可以打赌!
首先,我需要一个快速的扩展方法:
public static class ExtensionMethods
{
public static Func<TKey, TValue> ToLookup<TKey,TValue>(
this IDictionary<TKey,TValue> @this)
{
return x => @this.TryGetValue(x, out TValue? value) ? value : default;
}
public static Func<TKey, TValue> ToLookup<TKey,TValue>(
this IDictionary<TKey,TValue> @this,
TValue defaultVal)
{
return x => @this.ContainsKey(x) ? @this[x] : defaultVal;
}
}
我稍后会进一步解释,但首先,这是我如何使用我的扩展方法:
var doctorLookup = new []
{
( 1, "William Hartnell" ),
( 2, "Patrick Troughton" ),
( 3, "Jon Pertwee" ),
( 4, "Tom Baker" )
}.ToDictionary(x => x.Item1, x => x.Item2)
.ToLookup("An Unknown Actor");
var fifthDoctorInfo = $"The 5th Doctor was played by {doctorLookup(5)}";
// output = "The 5th Doctor was played by An Unknown Actor"
注意区别了吗?
仔细观察,现在我使用的是圆括号函数,而不是方括号访问字典/数组的值。这是因为它实际上不再是一个字典!它是一个函数。
如果你看我的扩展方法,它们返回函数,但它们是保持原始 Dictionary 对象在其存在期间有效的函数。基本上,它们就像是位于 Dictionary 和代码库其余部分之间的过滤器层。这些函数决定了是否安全使用 Dictionary。
这意味着我们可以使用一个字典,但是当找不到键时不会再抛出异常,我们可以返回类型的默认值(通常是 Null),或者提供我们自己的默认值。简单吧。
这种方法唯一的缺点是它不再是一个字典。这意味着你不能进一步修改它,或者在它上面执行任何 LINQ 操作。然而,如果你确信你不需要这样做,那么这是你可以使用的东西。
解析值
另一个导致冗长、样板代码的常见原因是将字符串解析为其他形式。例如,对于在假设我们在.NET Framework 中工作且不可用 appsettings.JSON 和IOption<T>
功能的设置对象中进行解析:
public Settings GetSettings()
{
var settings = new Settings();
var retriesString = ConfigurationManager.AppSettings["NumberOfRetries"];
var retriesHasValue = int.TryParse(retriesString, out var retriesInt);
if(retriesHasValue)
settings.NumberOfRetries = retriesInt;
else
settings.NumberOfRetries = 5;
var pollingHourStr = ConfigurationManager.AppSettings["HourToStartPollingAt"];
var pollingHourHasValue = int.TryParse(pollingHourStr, out var pollingHourInt);
if(pollingHourHasValue)
settings.HourToStartPollingAt = pollingHourInt;
else
settings.HourToStartPollingAt = 0;
var alertEmailStr = ConfigurationManager.AppSettings["AlertEmailAddress"];
if(string.IsNullOrWhiteSpace(alertEmailStr))
settings.AlertEmailAddress = "test@thecompany.net";
else
settings.AlertEmailAddress = aea.ToString();
var serverNameString = ConfigurationManager.AppSettings["ServerName"];
if(string.IsNullOrWhiteSpace(serverNameString))
settings.ServerName = "TestServer";
else
settings.ServerName = sn.ToString();
return settings;
}
这样做一些简单的事情确实需要很多代码,对吧?在那里有很多样板代码噪音,使得代码的意图除了熟悉这类操作的人之外几乎无法理解。而且,如果要添加新的设置,每个设置可能需要新增 5 到 6 行代码。这是一种浪费。
相反,我们可以更加功能化地处理,将结构隐藏在某个地方,只留下代码的意图可见。
通常情况下,这里是一个扩展方法来为我处理业务:
public static class ExtensionMethods
{
public static int ToIntOrDefault(this object @this, int defaultVal = 0) =>
int.TryParse(@this?.ToString() ?? string.Empty, out var parsedValue)
? parsedValue
: defaultVal;
public static string ToStringOrDefault(this object @this, string defaultVal = "") =>
string.IsNullOrWhiteSpace(@this?.ToString() ?? string.Empty)
? defaultVal
: @this.ToString();
}
这消除了第一个示例中所有重复的代码,并允许我们转向更可读、以结果为导向的代码示例,如下所示:
public Settings GetSettings() =>
new Settings
{
NumberOfRetries = ConfigurationManager.AppSettings["NumberOfRetries"]
.ToIntOrDefault(5),
HourToStartPollingAt = ConfigurationManager.AppSettings["HourToStartPollingAt"]
.ToIntOrDefault(0),
AlertEmailAddress = ConfigurationManager.AppSettings["AlertEmailAddress"]
.ToStringOrDefault("test@thecompany.net"),
ServerName = ConfigurationManager.AppSettings["ServerName"]
.ToStringOrDefault("TestServer"),
};
现在一目了然代码的作用,缺省值是什么,以及如何通过一行代码添加更多的设置。
除了 int
和 string
之外的任何其他设置值类型都需要创建另一个扩展方法,但这并不是什么大困难。
自定义枚举
大多数人在编码时可能都使用过 Enumerables,但您知道表面下有一个引擎可以访问并用于创建各种有趣的自定义行为吗?
使用自定义迭代器时,可以大大减少在需要更复杂行为时循环数据所需的代码行数。不过,首先需要了解 Enumerable 在表面之下是如何工作的。
在 Enumerable 表面下有一个类,驱动枚举的引擎,这使得你可以使用 ForEach 来循环遍历值。它被称为 Enumerator。
枚举器基本上有两个特性:
-
当前:从可枚举中获取当前项。可以随意调用多次,前提是不尝试移动到下一项。如果在首次调用 MoveNext 之前尝试获取当前值,则会抛出异常。
-
MoveNext:从当前项移动,并尝试看看是否有另一个可选择的值。如果找到另一个值,则返回
True
;如果已经到达 Enumerable 的末尾,或者根本没有元素,则返回False
。首次调用此方法时,它将 Enumerator 指向 Enumerable 的第一个元素。
查询相邻元素
一个相对简单的例子来开始。假设我想遍历一个整数的 Enumerable,看看是否包含任何连续的数字。
一种命令式的解决方案可能看起来像这样:
public IEnumerable<int> GenerateRandomNumbers()
{
var rnd = new Random();
var returnValue = new List<int>();
for (var i = 0; i < 100; i++)
{
returnValue.Add(rnd.Next(1, 100));
}
return returnValue;
}
public bool ContainsConsecutiveNumbers(IEnumerable<int> data)
{
// OK, you caught me out OrderBy isn't strictly Imperative, but
// there's no way I'm going to write out a sorting algorithm out
// here just to prove a point!
var sortedData = data.OrderBy(x => x).ToArray();
for (var i = 0; i < sortedData.Length - 1; i++)
{
if ((sortedData[i] + 1) == sortedData[i + 1])
return true;
}
return false;
}
var result = ContainsConsecutiveNumbers(GenerateRandomNumbers());
Console.WriteLine(result);
要使这段代码功能化,通常情况下,我们需要一个扩展方法。这个方法接收 Enumerable,提取其 Enumerator,并控制定制的行为。
为了避免使用命令式风格的循环,我在这里使用了递归。简而言之,递归是通过让函数重复调用自身来实现无限循环的一种方式。
我将在后面的章节中重新讨论递归的概念。但现在,我只会使用标准的简单递归版本。
public static bool Any<T>(this IEnumerable<T> @this, Func<T, T, bool> evaluator)
{
using var enumerator = @this.GetEnumerator();
var hasElements = enumerator.MoveNext();
return hasElements && Any(enumerator, evaluator, enumerator.Current);
}
private static bool Any<T>(IEnumerator<T> enumerator,
Func<T, T, bool> evaluator,
T previousElement)
{
var moreItems = enumerator.MoveNext();
return moreItems && (evaluator(previousElement, enumerator.Current)
? true
: Any(enumerator, evaluator, enumerator.Current));
}
那么,这里发生了什么?在某种程度上有点像杂耍。我首先提取枚举器,然后移到第一项。
在私有函数内部,我接受枚举器(现在指向第一个项目)、“我们完成了吗”的评估器函数以及同一个第一个项目的副本。
接着,我立即移到下一个项目,并运行评估器函数,传入第一个项目和新的“当前项目”,这样它们就可以进行比较。
此时,要么我们发现物品用完了,要么评估器返回 true,这种情况下我们可以终止迭代。如果 MoveNext 返回 true,那么我们检查previousValue
和Current
是否符合我们的要求(由evaluator
指定)。如果符合,则完成并返回 true,否则我们递归调用以检查其余的值。
这是查找连续数字的代码的更新版本:
public IEnumerable<int> GenerateRandomNumbers()
{
var rnd = new Random();
var returnValue = Enumerable.Repeat(0, 100)
.Select(x => rnd.Next(1, 100));
return returnValue;
}
public bool ContainsConsecutiveNumbers(IEnumerable<int> data)
{
var sortedData = data.OrderBy(x => x).ToArray();
var result = sortedData.Any((prev, curr) => cur == prev + 1);
return result;
}
这样,基于相同逻辑,创建一个All
方法也相当容易,如下所示:
public static bool All<T>(this IEnumerator<T> enumerator, Func<T,T,bool> evaluator, T previousElement)
{
var moreItems = enumerator.MoveNext();
return moreItems
? evaluator(previousElement, enumerator.Current)
? All(enumerator, evaluator, enumerator.Current)
: false
: true;
}
public static bool All<T>(this IEnumerable<T> @this, Func<T,T,bool> evaluator)
{
using var enumerator = @this.GetEnumerator();
var hasElements = enumerator.MoveNext();
return hasElements
? All(enumerator, evaluator, enumerator.Current)
: true;
}
唯一的区别在于决定是否继续的条件以及我们是否需要提前返回。使用All
,关键是检查每对值,并且仅在找到不满足条件的情况下提前退出循环。
迭代直到满足条件
这基本上是替换 while 循环的一种方法,所以这是我们不一定需要的另一种语句。
对于我的示例,我想象文本冒险游戏的轮换系统可能是什么样子。对于年轻读者来说 - 这就是我们在旧日子里没有图形之前的样子。你过去必须写下你想做的事情,然后游戏会写下发生的事情。有点像书,只不过你自己写发生了什么。
其中一个游戏的基本结构大致如下:
-
写下当前位置的描述
-
接收用户输入
-
执行请求的命令
这里展示了命令式代码如何处理这种情况:
var gameState = new State
{
IsAlive = true,
HitPoints = 100
};
while(gameState.IsAlive)
{
var message = this.ComposeMessageToUser(gameState);
var userInput = this.InteractWithUser(message);
this.UpdateState(gameState, userInput);
if(gameState.HitPoints <= 0)
gameState.IsAlive = false;
}
原则上,我们想要的是一个类似 Linq 风格的聚合函数,但不是循环遍历数组的方式,然后结束。相反,我们希望它持续循环,直到满足我们的结束条件(玩家死亡)。我这里稍微简化了一下,显然在真正的游戏中,我们的玩家也可能会赢。但我的示例游戏就像生活一样,生活并不公平!
对于这种情况的扩展方法,我们可以从尾递归优化调用中受益,我将在后面的章节中研究这方面的选择,但目前我将仅使用简单的递归 - 如果回合很多可能会成为一个问题,但目前它会阻止我过早引入太多想法。
public static class ExtensionMethods
{
public static T AggregateUntil<T>(
this T @this,
Func<T,bool> endCondition,
Func<T,T> update) =>
endCondition(@this)
? @this
: AggregateUntil(update(@this), endCondition, update);
}
使用这个方法,我可以完全摆脱while
循环,并将整个回合序列转换为一个函数,如下所示:
var gameState = new State
{
IsAlive = true,
HitPoints = 100
};
var endState = gameState.AggregateUntil(
x => x.HitPoints <= 0,
x => {
var message = this.ComposeMessageToUser(x);
var userInput = this.InteractWithUser(message);
return this.UpdateState(x, userInput);
});
这还不完美,但现在它是可以运行的。有更好的方法来处理游戏状态更新的多个步骤,以及如何以函数式的方式处理用户交互的问题。在第[X]章中将有专门的部分讨论这个问题。
结论
在本章中,我们探讨了如何使用 Funcs、Enumerables 和扩展方法来扩展 C#,使得编写函数式代码更加容易,并且解决了语言中一些现有的局限性。
我相信我只是触及到了这些技术的皮毛,还有很多其他的技术等待被发现和应用。
在我们的下一章中,我们将讨论高阶函数,以及一些可以利用它们来创建更多有用功能的结构。
¹ 顺便提一句,那是彼得·戴维森。
² 但希望不是无限的!
³ 如果你想亲自体验,请去玩一下史诗冒险游戏 Zork。试着不要被 Grue 吃掉!
第五章:高阶函数
欢迎回到我的朋友们,这场永无止境的表演。
本章,我们将探讨高阶函数的用途。我将探讨在 C#中使用它们的新颖方式,以节省您的工作量,并使代码不太可能失败。
但是,什么是高阶函数呢?
高阶函数对于一些非常简单的事情来说是一个稍微奇怪的名字。事实上,如果你花了很多时间使用 LINQ,你很可能已经在使用它们了。它们有两种风味,这是第一种:
var liberatorCrew = new []
{
"Roj Blake",
"Kerr Avon",
"Vila Restal",
"Jenna Stannis",
"Cally",
"Olag Gan",
"Zen"
};
var filteredList = liberatorCrew.Where(x => x.First() > 'M');
传递到Where
函数中的是一个箭头表达式 - 这只是一种用于编写匿名函数的简写。长格式版本将如下所示:
function bool IsGreaterThanM(char c)
{
return c > 'm';
}
因此,在这里,函数已作为参数传递给另一个函数,在其内部的其他地方执行。
这是高阶函数的另一个使用示例:
public Func<int, int> MakeAddFunc(int x) => y => x + y;
请注意,这里有两个箭头,而不是一个。我们正在获取一个整数 x,并从中返回一个新函数。在该新函数中,对 x 的引用将使用在最初调用 MakeAddFunc 时提供的任何内容填充。
例如:
var addTenFunction = MakeAddFunc(10);
var answer = addTenFunction(5);
// answer is 15
通过将 10 传递到MakeAddFunc
中,在上面的示例中,我创建了一个新函数,其功能只是将 10 添加到您传递给它的任何其他整数中。
简而言之,高阶函数是具有以下一项或多项属性的函数:
-
接受一个函数作为参数
-
将函数作为其返回类型返回
在 C#中,这通常通过Func
(用于具有返回类型的函数)或Action
(用于返回 void 的函数)委托类型完成。
这是一个相当简单的想法,甚至更容易实现 - 但它们对你的代码库可能产生的影响是不可思议的。
在本章中,我将介绍如何使用高阶函数来改进您的日常编码方式。
我还将深入研究称为组合子的高阶函数的下一级用法。这些允许以一种创建更复杂和有用行为的方式传递函数。它们之所以被称为组合子,是因为它们源自一种称为组合逻辑的数学技术。你以后不需要担心再听到这个术语,或者关于任何高级数学的引用 - 我不会去那里。只是以防你好奇...
问题报告
要开始,我们将查看一些问题代码。假设您的公司要求您编写一个函数来获取某种数据存储(XML 文件、JSON 文件,谁知道。无所谓),总结每种可能值的数量,然后将该数据传输到其他地方。除此之外,他们希望在找不到任何数据时发送一个单独的消息。我管理一个非常宽松的公司,所以让我们保持有趣,想象你在邪恶银河帝国™工作,并且你正在对你的雷达上的反抗联盟飞船进行分类。
代码可能如下所示:
public void SendEnemyShipWeaponrySummary()
{
try
{
var enemyShips = this.DataStore.GetEnemyShips();
var summaryNumbers = enemyShips.GroupBy(x => x.Type)
.Select(x => (Type: x.Key, Count: x.Count()));
var report = new Report
{
Title = "Enemy Ship Type",
Rows = summaryNumbers.Select(X => new ReportItem
{
ColumnOne = X.Type,
ColumnTwo = X.Count.ToString()
})
};
if (!report.Rows.Any())
this.CommunicationSystem.SendNoDataWarning();
else
this.CommunicationSystem.SendReport(report);
}
catch (Exception e)
{
this.Logger.LogError(e,
$"An error occurred in {nameof(SendEnemyShipWeaponrySummary)}: {e.Message}");
}
}
这没问题,对吧?对吧?好吧,想想这种情景。你坐在桌前,吃着你的每日速食面¹,突然发现——就像《侏罗纪公园》一样——你的咖啡里开始有了节奏感的涟漪。这意味着你的噩梦来了。你的老板!让我们假设你的老板是——我随便说的——一个高个子、深沉嗓音的绅士,穿着黑色斗篷,患有可怕的哮喘。而且他真的讨厌人们惹他生气。非常讨厌。
他对你创建的第一个函数感到满意。你可以松一口气了。但现在他想要第二个函数。这个函数将创建另一个摘要,但这次是关于每艘飞船的武器水平。无论它们是无武装、轻装、重装还是能毁灭行星的。那种情况。
简单啊,你想。老板会对我多快地完成这个任务感到印象深刻的。所以,你做了看起来最简单的事情 Ctrl+C
,然后 Ctrl+V
复制和粘贴原始内容,改变名称,改变你要总结的属性,最后得到了这样的东西:
public void GenerateEnemyShipWeaponrySummary()
{
try
{
var enemyShips = this.DataStore.GetEnemyShips();
var summaryNumbers = enemyShips.GroupBy(x => x.WeaponryLevel)
.Select(x => (Type: x.Key, Count: x.Count()));
var report = new Report
{
Title = "Enemy Ship Weaponry Level",
Rows = summaryNumbers.Select(X => new ReportItem
{
ColumnOne = X.Type,
ColumnTwo = X.Count.ToString()
})
};
if (!report.Rows.Any())
this.CommunicationSystem.SendNoDataWarning();
else
this.CommunicationSystem.SendReport(report);
}
catch (Exception e)
{
this.Logger.LogError(e,
$"An error occurred in {nameof(GenerateEnemyShipWeaponrySummary)}: {e.Message}");
}
}
五秒钟的工作,一天或两天在你象征性的铲子上使劲,还时不时地抱怨这里的工作有多难,而你暗中又在玩今天的 Wordle。完成任务,到处拍背,对吧?对吧?
嗯……这种方法存在几个问题。
首先,让我们考虑单元测试。作为优秀的、正直的代码公民,我们对所有的代码进行单元测试。想象一下,我们对第一个函数进行了彻底的单元测试。当我们复制和粘贴第二个函数时,此时单元测试覆盖率是多少呢?
我给你一个提示——它介于零和零之间。你也可以复制并粘贴测试,这也可以,但这样我们每次复制和粘贴的代码量就更多了。
这种方法不适合扩展。如果我们的老板在这之后还想要另一个函数,再一个,再一个。如果我们最终被要求做 50 个函数?或者 100 个?!那就是大量的代码。你最终会得到一些上千行长的东西,这不是我乐意支持的。
当你考虑到我职业生涯初期发生的一件事情时,情况变得更糟。我曾在一家组织工作,他们有一个桌面应用程序,根据几个输入参数为每个客户进行一系列复杂的计算。每年规则都会改变,但旧的规则基础必须复制,因为可能需要查看以前年份的计算结果。
所以,在我加入团队之前,一直在开发该应用程序的人每年都复制了一大块代码。做了一些小改动,然后在某处添加了指向新版本的链接,完成了。
有一年,我被委派去做这些年度更改,于是我开始了,年轻、无邪,怀揣改变世界的热情。在进行更改时,我注意到了一些奇怪的现象。有一个与我的更改毫无关系的字段出现了错误。我修复了这个错误,但接着我产生了一个让我心情沉重的想法…
我查看了每个之前版本的代码库,每一年的版本,几乎所有的版本都有同样的 bug。这个 bug 大约 10 年前引入,从那以后每位开发者都精确复制了这个 bug。因此,我不得不多次修复它,使得测试工作的工作量成倍增加。
思考一下这个问题——复制粘贴真的节省了你多少时间吗?我经常处理那些可能在几十年后仍然存在且毫无放弃迹象的应用程序。
当我决定在编码工作中节省时间时,我尝试审视整个应用程序的生命周期,并考虑一个决策在十年后可能产生的后果。
要回到我们的主题,我如何使用高阶函数来解决这个问题?好了,你们准备好了吗?那么,我就开始说了…
Thunks
一个代码束,其中包含一个存储计算的存储计算,可以在请求时执行,被正式称为Thunk。就像一块木板打在你头上发出的声音一样。关于这是否比读这本书更伤脑筋,这是一个有争议的问题!
在 C#中,我们可以使用Func
委托来实现这一点。我们可以编写接受Func
委托作为参数值的函数,以允许我们的函数中某些计算留空,这些空缺可以通过外部世界,通过箭头函数来填补。
虽然这个技术有一个严肃的、正式的数学术语,我喜欢称之为“甜甜圈函数”,因为这更具描述性。它们就像普通函数,但中间有一个空洞!这个空洞我会请别人填补必要的功能。
这是重构问题报告函数的一种潜在方式:
public void SendEnemyShipWeaponrySummary() =>
GenerateSummary(x => x.Type, "Enemy Ship Type Summary");
public void GenerateEnemyShipWeaponryLevelSummary() =>
GenerateSummary(x => x.WeaponryLevel, "Enemy Ship WeaponryLevel");
private void GenerateSummary(Func<EnemyShip, string> summarySelector, string reportName)
{
try
{
var enemyShips = this.DataStore.GetEnemyShips();
var summaryNumbers = enemyShips.GroupBy(summarySelector)
.Select(x => (Type: x.Key, Count: x.Count()));
var report = new Report
{
Title = reportName,
Rows = summaryNumbers.Select(X => new ReportItem
{
ColumnOne = X.Type,
ColumnTwo = X.Count.ToString()
})
};
if (!report.Rows.Any())
this.CommunicationSystem.SendNoDataWarning();
else
this.CommunicationSystem.SendReport(report);
}
catch (Exception e)
{
this.Logger.LogError(e,
$"An error occurred in {nameof(GenerateSummary)}, report: {reportName}, message: {e.Message}");
}
}
在这个修订版本中,我们获得了一些优势。
首先,每个新报告的额外行数仅为一行!这使得代码库更加整洁,更易于阅读。代码与新函数的意图非常接近——即与第一个函数相同,但有一些变化。
其次,在对第一个功能进行单元测试后,当我们创建第二个功能时,单元测试水平仍然接近 100%。从功能上讲,唯一的区别是报告名称和要汇总的字段。
最后,对基础函数的任何增强或错误修复将同时应用于所有报表函数。这对相对较少的工作量来说带来了很多好处。还有非常高的信心度,如果一个报表函数测试通过了,其他所有报表函数也将会是一样。
有可能会对这个版本感到满意。但如果是我,我实际上会考虑进一步,将带有其Func
参数的私有版本暴露在接口上,供希望使用它的任何人使用。
像这样:
public interface IGenerateReports
{
void GenerateSummary(Func<EnemyShip, string> summarySelector, string reportName)
}
实现方式是将前面代码示例中的私有函数公开化。这样一来,至少在希望为不同字段添加额外报告时,不需要修改接口或实现类。
这使得创建报告的工作完全可以由任何消耗这个类的代码模块任意完成。这样做不仅节省了我们这样的开发者在维护报告集方面的大量负担,而且更多地放在关心报告本身的团队手中。想象一下,现在不再需要向开发团队提交多少变更请求。
如果你真的想要野心勃勃,你可以进一步将Func
参数公开为Func<ReportLine,string>
,以允许报告类的用户定义自定义格式。你也可以使用Action
参数来实现定制的日志记录或事件处理。这只是我那个傻乎乎、虚构的报告类。通过这种方式使用高阶函数的可能性是无限的。
尽管这是一个功能编程的特性,但这确实使我们牢牢地遵循了面向对象设计的 SOLID 原则中的O - 开闭原则²,即模块应该对扩展开放,对修改关闭。
令人惊讶的是,在 C#中,面向对象和功能编程如何能够互补。我经常认为,开发人员应该确保自己在两种范式中都能够熟练运用,这样才能有效地将它们结合使用。
函数链
请允许我介绍你可能从未意识到需要的最好朋友 - Map 函数。这个函数通常也被称为 Chain 和 Pipe,但为了保持一致性,在本书中我们将统一称它为 Map。恐怕很多功能性结构会根据编程语言和实现方式有很多不同的名称,我会尽量在适当时指出。
现在,我是英国人,有一个关于英国人的俗语是我们喜欢谈论天气。这完全是真的。我们的国家有时一天内会经历四季,所以天气对我们来说是一个持续引发兴趣的话题。
曾经我为一家美国公司工作,那时候,当我与同事通过视频通话讨论天气时,话题往往不可避免地转向了天气。他们告诉我外面的温度大约是 100 度。我使用摄氏度工作,所以对我来说这听起来非常像水的沸点。考虑到我的同事并没有因为血液沸腾而尖叫,我怀疑是其他因素在起作用。当然,他们使用的是华氏度,因此我需要将其转换为我理解的单位,用以下公式:
-
减去 32
-
然后,乘以 5
-
然后,除以 9
这将给出大约 38 度的摄氏温度,温暖而舒适,对于人类生活大多数时间是安全的。
我如何在完全这种多步操作中编码这个过程,然后返回一个格式化的字符串呢?我可以将它们全部拼接成一行,就像这样:
public string FahrenheitToCelcius(decimal tempInF) =>
Math.Round(((tempInF-32) *5 / 9), 2) + "°C";
虽然不是很易读,对吧?说实话,在实际编码中,我可能不会对此太过挑剔,但我正在展示一种技术,不想深陷其中,所以请耐心等待。
编写这个多步骤操作的方式如下:
string FahrenheitToCelcius(decimal tempInF)
{
var a = tempInF - 32;
var b = a * 5;
var c = b / 9;
var d = Math.Round(c, 2);
var returnValue = d + "°C";
return returnValue;
}
这样更易读,更易于维护,但仍然存在一个问题。我们正在创建打算仅使用一次然后丢弃的变量。在这个小函数中,这并不是太重要,但如果这是一个庞大的千行函数呢?如果不是这些小小的十进制变量,而是一个大型复杂对象呢?在第 1000 行,那个不打算再次使用的变量仍然在作用域中,并占用内存。创建一个在下一行之后不打算再使用的变量也有点混乱。这就是 Map 发挥作用的地方。
Map 类似于 LINQ Select
函数,但不是作用于可枚举的每个元素,而是作用于对象。任何对象。你传递一个 Lambda 箭头函数,方式与 Select
相同,只是你的 x 参数指的是基础对象。如果你将其应用于可枚举对象,x 参数将指整个可枚举对象,而不是其中的单个元素。
这是我修改后的华氏转摄氏度函数的样子:
public string FahrenheitToCelcius(decimal tempInF) =>
tempInF.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Map(x => Math.Round(x, 2))
.Map(x => x + "°C");
完全相同的功能,友好的多阶段操作,但没有丢弃的变量。每个箭头函数执行后,它们的内容就会被垃圾回收。被乘以 5 的十进制 x 在下一个箭头函数获取其结果并将其除以 9 时,也将被处理掉。
这是实现 Map 的方法:
public static class MapExtensionMethods
{
public static TOut Map<TIn, TOut>(this TIn @this, Func<TIn, TOut> f) =>
f(@this);
}
它很小,不是吗?尽管如此,我经常使用这种特定方法。每当我想要对数据进行多步转换时,这使得将整个函数体转换为简单的箭头函数变得更容易,就像我上面基于 Map 的 FahrenheitToCelcius 函数一样。
这个方法还有更高级的版本,包括错误处理等,我将在第七章中详细介绍。但目前,这是一个您可以立即开始玩耍的奇妙小玩具。大叔西蒙送给你的提前圣诞礼物。嘿,嘿,嘿。
如果您不想在每次转换时更改类型,那么可能存在一种更简洁的 Map 实现。如果符合您的需求,这样更清晰、更简洁。
可以这样实现:
public static T Map<T>(this T @this, params Func<T,T>[] transformations) =>
transformations.Aggregate(@this, (agg, x) => x(agg));
使用它,基本的华氏温度到摄氏温度的转换将会像这样:
public decimal FahrenheitToCelcius(decimal tempInF) =>
tempInF.Map(
x => x - 32,
x => x * 5,
x => x / 9
x => Math.Round(x, 2);
这可能值得使用,以节省一些简单情况下的样板代码,比如温度转换。请参阅第八章有关柯里化的一些想法,了解如何使其看起来更好。
分支组合器
我也听说过这个被称为“Converge”。不过我更喜欢“Fork”,它更详细地描述了它的工作原理。Fork 组合器用于接收单个值,然后同时以多种方式处理它,然后将所有这些单独的分支合并为一个单一的最终值。它可以将一些相当复杂的多步计算简化为一行代码。
这个过程大致会像这样运行:
-
从一个单一值开始
-
将其输入一组“分支”函数 - 每个函数都独立作用于原始输入以产生某种输出
-
“join”函数将分支的结果合并为最终结果。
下面是我可能使用它的几个示例。
如果您想在函数定义中指定参数的数量 - 而不是从数组中具有未指定数量的分支,则可以使用 Fork 来计算平均值:
var numbers = new [] { 4, 8, 15, 16, 23, 42 }
var average = numbers.Fork(
x => x.Sum(),
x => x.Count(),
(s, c) => s / c
);
// average = 18
或者这里有个来自过去的东西,一个用于计算三角形斜边的 Fork:
var triangle = new Triangle(100, 200);
var hypotenuse = triangle.Fork(
x => Math.Pow(x.A, 2),
x => Math.Pow(x.B, 2),
(a2, b2) => Math.Sqrt(a2 + b2)
);
实现看起来像这样:
public static class ext
{
public static TOut Fork<TIn, T1, T2, TOut>(
this TIn @this,
Func<TIn, T1> f1,
Func<TIn, T2> f2,
Func<T1,T2,TOut> fout)
{
var p1 = f1(@this);
var p2 = f2(@this);
var result = fout(p1, p2);
return result;
}
}
请注意,拥有两个泛型类型,每个分支一个,意味着这些函数可以返回任何类型的组合。
你也可以轻松地为任意数量的参数编写版本,但你想考虑的每个额外参数都需要一个额外的扩展方法。
如果您想进一步,并且有无限数量的“分支”,那么只要您愿意使用每个生成的相同中间类型,这很容易实现:
public static class ForkExtensionMethods
{
public static TEnd Fork<TStart, TMiddle, TEnd>(
this TStart @this,
Func<TMiddle, TEnd> joinFunction,
params Func<TStart, TMiddle>[] prongs
)
{
var intermediateValues = prongs.Select(x => x(@this));
var returnValue = joinFunction(intermediateValues);
return returnValue;
}
例如,我们可以用它来基于对象创建一个文本描述:
var personData = this.personRepository.GetPerson(24601);
var description = personData.Fork(
prongs => string.Join(Environment.NewLine, prongs),
x => "My name is " + x.FirstName + " " + x.LastName,
x => "I am " + x.Age + " years old.",
x => "I live in " + x.Address.Town
)
// This might, for example, produce:
//
// My name is Jean Valjean
// I am 30 years old
// I live in Montreuil-sur-mer
使用这个分支示例,我们可以轻松地添加更多描述性的行,但保持相同的复杂性和可读性。
Alt 组合器
我也见过这被称为“Or”、“Alternate”和“Alternation”。它用于将一组函数绑定在一起以实现相同的目标,但应该依次尝试,直到其中一个返回一个值。
将其视为“尝试方法 A,如果不行,则尝试方法 B,如果不行,则尝试方法 C,如果还不行,我想我们没办法了”的工作方式。
让我们试着想象一种情景,我们可能希望通过尝试多种方法来查找某物:
var jamesBond = "007"
.Alt(x => this.hotelService.ScanGuestsForSpies(x),
x => this.airportService.CheckPassengersForSpies(x),
x => this.barService.CheckGutterForDrunkSpies(x));
if(jamesBond != null)
this.deathTrapService.CauseHorribleDeath(jamesBond);
- 只要这三种方法中的一种返回与英国政府的一个酒鬼、边缘厌恶女性主义者、凶恶的雇员相对应的值,那么 jamesBond 变量就不会为空。哪个函数首先返回值就是最后一个要运行的函数。
那么在找到我们的敌人已经逃跑之前,我们如何实现这个函数呢?像这样:
public static TOut Alt<TIn, TOut>(this TIn @this, params Func<TIn, TOut>[] args) =>
args.Select(x => x(@this))
.First(x => x != null);
请记住,LINQ 的Select
函数采用延迟加载的原则运行,所以即使我看起来在将整个Func
数组转换为具体类型,实际上我并没有,因为First
函数将阻止在其中一个返回非空值后执行任何元素。LINQ 真是太棒了,不是吗?
Compose
函数式语言的一个共同特性是能够从一组较小的函数构建出一个高阶函数。任何涉及组合函数的过程都称为组合。
JavaScript 库如 RamdaJS³具有出色的组合功能,但是在这种情况下,C#的强类型实际上对其起到了反作用。
在 C#中有几种组合函数的方法。第一种是最简单的,只是使用基本的 Map 函数,如本章前面描述的那样:
var input = 100M;
var f = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output = f(input);
// output = "37.78 degrees"
在这里,f是一个组合的高阶函数。有 5 个函数(例如 x ⇒ x - 32,计算的那些步骤)用于创建它,这些函数被描述为匿名的 lambda 表达式。它们像乐高积木一样组合成一个更大、更复杂的行为。
此时一个有效的问题是 - 组合函数的意义何在?
答案是,您不一定要一次完成整个过程。您可以分步构建它,然后最终使用相同的基础部件创建许多函数。
现在想象一下,我还想拥有一个表示相反转换的Func
委托 - 我们最终会得到两个这样的函数:
var input = 100M;
var fahrenheitToCelcius = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output = fahrenheitToCelcius(input);
Console.WriteLine(output);.
// 37.78 degrees
var input2 = 37.78M;
var celciusToFahrenheit = (decimal x) =>
x.Map(x => x * 9)
.Map(x => x / 5)
.Map(x => x + 32)
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output2 = celciusToFahrenheit(input2);
Console.WriteLine(output2);
// 100.00 degrees
每个函数的最后两行实际上是相同的。重复每次都重复它们有点浪费吗?我们可以使用 Compose 函数消除这种重复:
var formatDecimal = (decimal x) => x
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var input = 100M;
var celciusToFahrenheit = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9);
var fToCFormatted = celciusToFahrenheit.Compose(formatDecimal);
var output = fToCFormatted(input);
Console.WriteLine(output);
var input2 = 37.78M;
var celciusToFahrenheit = (decimal x) =>
x.Map(x => x * 9)
.Map(x => x / 5)
.Map(x => x + 32);
var cToFFormatted = celciusToFahrenheit.Compose(formatDecimal);
var output2 = cToFFormatted(input2);
Console.WriteLine(output2);
从功能上讲,这些使用 Compose 的新版本与仅使用 Map 的先前版本是相同的。
Compose 函数执行的任务与 Map 几乎相同,不同之处在于我们最终生成的是一个Func
委托,而不是最终值。这是执行 Compose 过程的代码:
public static class ComposeExtensionMethods
{
public static Func<TIn, NewTOut> Compose<TIn, OldTOut, NewTOut>(
this Func<TIn, OldTOut> @this,
Func<OldTOut, NewTOut> f) =>
x => f(@this(x));
}
使用 Compose,我们已经消除了一些不必要的复制。任何对格式化过程的改进都将同时应用于Func
委托对象。
然而存在一个限制。在 C#中,不能将扩展方法附加到 lambda 表达式或直接到函数上。如果我们将 lambda 表达式引用为Func
或Action
委托,则可以将扩展方法附加到 lambda 表达式,但是在此之前,它需要被分配到一个变量中,以便自动设置为委托类型。这就是为什么在上面的示例中,在调用Compose
之前需要将Map
函数链分配给变量的原因 - 否则,可以简单地在Map
链的末尾调用Compose
并节省变量分配。
这个过程与面向对象编程中通过继承重用代码类似,只是在单行级别进行,而且需要的样板代码要少得多。它还将这些类似的相关代码放在一起,而不是必须分散在不同的类和文件中。
转换
Transducer 是一种将基于列表的操作(如 Select 和 Where)与某种形式的聚合结合起来,对值列表执行多个转换,最后将其折叠为单个最终值的方法。
虽然 Compose 是一个有用的功能,但它也有一些限制。它实际上只替代了一个 Map 函数的位置 - 即它作用于整个对象,并且无法对可枚举对象执行 LINQ 操作。你可以在数组中组合并放入 Select 和 Where 操作,但老实说,这看起来非常凌乱:
var numbers = new [] { 4, 8, 15, 16, 23, 42 };
var add5 = (IEnumerable<int> x) => x.Select(y => y + 5);
var Add5MultiplyBy10 = add5.Compose(x => x.Select(y => y * 10));
var numbersGreaterThan100 = Add5MultiplyBy10.Compose(x => x.Where(y => y > 100));
var composeMessage = numbersGreaterThan100.Compose(x => string.Join(",", x));
Console.WriteLine("Output = " + composeMessage(numbers));
// Output = 130,200,210,280,470
如果你对此感到满意,那么尽管使用它。本质上并没有什么错,除了相当不雅。
还有另一种结构可以使用 - Transduce。Transduce 操作作用于数组,并代表功能流的所有阶段:
-
过滤(即
.Where
)- 减少元素的数量 -
转换(即
.Select
)- 将它们转换为新的形式 -
聚合(即,嗯... 实际上就是聚合)- 使用这些规则将许多项的集合缩减为单个项。
在 C#中可以有许多实现方式,但这是一种可能性:
public static TFinalOut Transduce<TIn, TFilterOut, TFinalOut>(
this IEnumerable<TIn> @this,
Func<IEnumerable<TIn>, IEnumerable<TFilterOut>> transformer,
Func<IEnumerable<TFilterOut>, TFinalOut> aggregator) =>
aggregator(transformer(@this));
此扩展方法采用一个转换方法 - 用户定义的Select
和Where
的任何组合,最终将可枚举对象从一种形式和大小转换为另一种形式。该方法还接受一个聚合器,将转换器的输出转换为单个值。
这是我上面定义的组合函数如何使用此版本的 Transduce 方法实现的方式:
var numbers = new [] { 4, 8, 15, 16, 23, 42 };
// N.B - I could make this a single line with brackets, but
// I find this more readable, and it's functionally identical due
// to lazy evaluation of Enumerables
var transformer = (IEnumerable<int> x) => x
.Select(y => y + 5)
.Select(y => y * 10)
.Where(y => y > 100);
var aggregator = (IEnumerable<int> x) => string.Join(", ", x);
var output = numbers.Transduce(transformer, aggregator);
Console.WriteLine("Output = " + output);
// Output = 130, 200, 210, 280, 470
或者,如果您更喜欢将所有东西都处理为Func
委托,以便可以重用 Transducer 函数,则可以这样编写:
var numbers = new [] { 4, 8, 15, 16, 23, 42 };
var transformer = (IEnumerable<int> x) => x
.Select(y => y + 5)
.Select(y => y * 10)
.Where(y => y > 100);
var aggregator = (IEnumerable<int> x) => string.Join(", ", x);
var transducer = transformer.ToTransducer(aggregator);
var output2 = transducer(numbers);
Console.WriteLine("Output = " + output2);
这是更新后的扩展方法:
public static class TransducerExtensionMethod
{
public static Func<IEnumerable<TIn>, NewTOut> ToTransducer<TIn, OldTOut, NewTOut>(
this Func<IEnumerable<TIn>,
IEnumerable<OldTOut>> @this,
Func<IEnumerable<OldTOut>, NewTOut> aggregator) =>
x => aggregator(@this(x));
}
现在我们生成了一个Func
委托变量,可以作为函数在任意多个整数数组上使用,该单一Func
将执行所需数量的转换和过滤,然后将数组聚合为单个最终值。
点击
我经常听到有人对函数链提出的一个普遍关注是,在其中执行记录日志是不可能的 - 除非你将链中的一个链接指向一个带有记录调用的单独函数。
函数式编程中有一种技术,可以用来在任何函数链的某个点检查函数链的内容 - Tap 函数。
Tap 函数有点像旧侦探片中的窃丨听丨器⁴。它允许监视和处理信息流,但不会干扰或改变它。
实现 Tap 的方式如下:
public static class Extensions
{
public static T Tap<T>(this T @this, Action<T> action)
{
action(@this);
return @this;
}
一个Action
委托实际上就像一个无返回值的函数。在这个实例中,它接受一个参数 - 一个泛型类型 T。Tap 函数将链中当前对象的当前值传递给 Action,在那里可以进行记录,然后返回相同对象的未修改副本。
你可以像这样使用它:
var input = 100M;
var fahrenheitToCelcius = (decimal x) => x.Map(x => x - 32)
.Map(x => x * 5)
.Map(x => x / 9)
.Tap(x => this.logger.LogInformation("the un-rounded value is " + x))
.Map(x => Math.Round(x, 2))
.Map(x => $"{x} degrees");
var output = fahrenheitToCelcius(input);
Console.WriteLine(output);
// 37.78 degrees
在这个新版本的华氏度转换为摄氏度的函数链中,我现在在基本计算完成后开始窥探它,但在我开始四舍五入和格式化字符串之前。
我在 Tap 中添加了一个调用记录器的调用,但你可以将其换成Console.WriteLine
或者其他你想要的东西。
尝试/捕获
函数式编程中有几种更高级的结构来处理错误。如果你只是想要一些快速简单的东西,你可以在几行代码中快速实现它,但它有其局限性,请继续阅读。否则,试着提前看看下一章的歧视联盟,以及在高级函数结构之后的章节。在那里可以找到大量关于处理没有副作用的错误的内容。
但是现在,让我们看看我们可以用几行简单的代码做些什么……
理论上,在函数风格的代码中间不应该有任何错误。如果一切都按照无副作用的代码、不可变变量等函数式原则进行,你应该是安全的。然而,在边缘处总是可能有一些被认为是不安全的交互。
假设你有一个场景,你想要在一个整数 Id 的外部系统中进行查找。这个外部系统可以是数据库,Web API,网络共享上的平面文件,或者任何其他东西。这些可能性的共同点是,它们中的任何一个都可能由于多种原因而失败,其中很少有任何一个是开发者的错。
可能会出现网络问题,本地或远程计算机上的硬件问题,无意中的人为干预。问题的列表还在继续……
这就是你通常在面向对象代码中处理这种情况的方式:
pubic IEnumerable<Snack> GetSnackByType(int typeId)
{
try
{
var returnValue = this.DataStore.GetSnackByType(typeId);
return returnValue;
}
catch(Exception e)
{
this.logger.LogError(e, $"There aren't any pork scratchings left!");
return Enumerable.Empty<Snack>()
}
}
关于这个代码块,有两件事我不喜欢。首先是我们必须用多少样板代码来填充代码。我们必须添加大量强大的编码以保护自己,以防我们没有引起的问题。
另一个问题是 try/catch 块本身。它打破了操作顺序,将程序执行从原来的位置移动到一些可能难以找到的地方。在这种情况下,这是一个很好、简单、紧凑的小函数,并且很容易确定 Catch 的位置。不过,我曾在代码库中工作过,其中 Catch 位于比故障发生位置高几层的函数中。在那个代码库中,由于假设某些代码行会被执行而实际上未被执行,经常出现错误。
说实话,我可能对上面的代码块在生产中并不会有太多问题,但如果不加以检查,不良编码实践可能会渗入其中。代码中没有任何阻止未来编码人员在此处引入多级嵌套函数的机制。
我认为最好的解决方案是采用一种方法,消除所有样板文件,并使引入不良代码结构变得困难,甚至不可能。
类似这样:
pubic IEnumerable<Snack> GetSnackByType(int typeId)
{
var result = typeId.MapWithTryCatch(this.DataStore.GetSnackByType)
?? Enumerable.Empty<Snack>();
return result;
}
我正在执行一个带有内嵌 Try/Catch 的 Map 函数。新的 Map 函数如果一切正常则返回一个值,如果失败则返回 null
。
扩展方法如下:
public static class Extensions
{
public static TOut MapWithTryCatch<TIn,TOut>(this TIn @this, Func<TIn,TOut> f)
{
try
{
return f(@this);
}
catch()
{
return default;
}
}
}
不过这并不是完美的解决方案。那么错误日志记录呢?这是未记录错误消息的重大错误。
有几种方法可以考虑解决这个问题。任何一种都可以,所以按照你的兴致去做。
一种选择是改为使用一个接受 ILogger
实例并返回包含 Try/Catch 功能的 Func
委托的扩展方法。类似这样:
public static class TryCatchExtensionMethods
{
public static TOut CreateTryCatch<TIn,TOut>(this TIn @this, ILogger logger)
{
Func<TIn,TOut> f =>
{
try
{
return f(@this);
}
catch(Exception e)
{
logger.LogError(e, "An error occurred");
return default;
}
}
}
}
使用方法基本相似:
public IEnumerable<Snack> GetSnackByType(int typeId)
{
var tryCatch = typeId.CreateTryCatch(this.logger);
var result = tryCatch(this.DataStore.GetSnackByType)
?? Enumerable.Empty<Snack>();
return result;
}
只增加了一行样板文件,现在开始记录。可惜的是,除了错误本身外,我们无法在消息中添加任何具体内容。扩展方法不知道它被调用的位置或错误的上下文,这使得在整个代码库中重复使用该方法非常方便。
如果你不希望 Try/Catch 意识到 ILogger
接口,或者每次都想提供自定义错误消息,那么我们需要考虑一些更复杂的方法来处理错误消息。
另一种选择是返回一个包含正在执行的函数的返回值以及一些关于是否工作、是否存在错误及其内容的元数据对象。类似这样:
public class ExecutionResult<T>
{
public T Result { get; init; }
public Exception Error { get; init; }
}
public static class Extensions
{
public static ExtensionResult<TOut> MapWithTryCatch<TIn,TOut>(this TIn @this, Func<TIn,TOut> f)
{
try
{
var result = f(@this);
return new ExecutionResult<TOut>
{
Result = result
};
}
catch(Exception e)
{
return new ExecutionResult<TOut>
{
Error = e
};
}
}
}
我真的不喜欢这种方法。它违反了面向对象设计的 SOLID 原则之一 - 接口隔离原则。嗯,有点。从技术上讲,这适用于接口,但我尽量在任何地方都应用它。即使我写函数式代码。理念是,我们不应被迫在类或接口中包含我们实际上不需要的内容。在这里,我们强制一个成功运行包含一个 Exception
属性,它永远不会需要,同样,一个失败运行将不得不包含它永远不会需要的 Result 属性。
还有其他方法可以做到这一点,但我选择了简单的方法,并返回了一个带有结果的 ExecutionResult
类的版本,或者一个带有默认值的 Result 和返回的异常。
这意味着我可以像这样调用它:
pubic IEnumerable<Snack> GetSnackByType(int typeId)
{
var result = typeId.MapWithTryCatch(this.DataStore.GetSnackByType);
if(result.Value == null)
{
this.Logger.LogException(result.Error, "We ran out of jammy dodgers!");
return Enumerable.Empty<Snack>();
}
return result.Result;
}
除了不必要的字段外,这种方法还有另一个问题 - 现在开发者使用 Try/Catch 函数需要增加额外的样板代码来检查错误。
跳到下一章节,以更纯函数式的方式处理此类返回值的替代方法。但现在,这里有一种稍微更清洁的处理方式。
首先,我将添加另一个扩展方法。这次是附加到 ExecutionResult 对象:
public static T OnError<T>(this ExecutionResult<T> @this, Action<Exception> errorHandler)
{
if (@this.Error != null)
errorHandler(@this.Error);
return @this.Result;
}
我这里做的第一步是先检查是否有错误。如果有,那么执行用户定义的 Action
- 这可能是一个日志操作。最后,将 ExecutionResult 解包成其实际返回的数据对象。
所有这些意味着你现在可以这样处理 Try/Catch:
public IEnumerable<Snack> GetSnackByTypeId(int typeId) =>
typeId.MapWithTryCatch(DataStore.GetSnackByType)
.OnError(e => this.Logger.LogError(e, "We ran out of custard creams!"));
虽然这远非完美的解决方案,但在不深入函数理论的情况下,它是可行且优雅的,足以不触发我的内部完美主义。这也迫使用户在使用时考虑错误处理,这只能是一件好事!
处理空值
空引用异常很烦人,不是吗?如果你想责怪某人,那就是一个叫 Tony Hoare 的家伙,他在 60 年代发明了 Null 的概念。不过,我们最好不要责怪任何人。我相信他是一个可爱的人,所有认识他的人都喜欢他。无论如何,我们可以希望大家都同意,空引用异常确实是一个很大的麻烦。
那么,有没有一种函数式的方式来处理它们?如果你读到这里,你可能知道答案将会是一个响亮的“是!”⁵。
Unless 函数接受一个布尔条件和一个 Action
委托,并且仅在布尔条件为假时执行 Action
- 即 Action
总是执行,除非 条件为真。
这样的用法最常见的情况就是 - 你猜对了 - 检查空值。
这是一个我试图替换的代码示例。这是一个很少见的 Dalek 的源代码片段⁶:
public void BusinessAsUsual()
{
var enemies = this.scanner.FindLifeforms('all');
foreach(var e in enemies)
{
this.Gun.Blast(e.Coordinates.Longitude, e.Coordinates.Latitude);
this.Speech.ScreamAt(e, "EXTERMINATE");
}
}
这一切都很好,也许会留下许多人被一个移动的胡椒罐形状的精神病突变体杀死。但是,如果 Coordinates 对象因某种原因为空呢?没错——空引用异常。
这就是我们使其功能化并引入 Unless 函数以防止异常发生的地方。这就是 Unless 的样子:
public static class UnlessExtensionMethods
{
public void Unless<T>(this T @this, Func<bool> condition, Action<T> f)
{
if(!condition(@this)
{
f(@this);
}
}
}
很遗憾,它必须是一个 void。如果我们将 Action
换成 Func
,那么从扩展方法返回 Func
的结果是可以的。然而,当条件为真时,我们不执行时怎么办?那我返回什么?这个问题真的没有一个答案。
这是我如何用它来制作我的新的、超级、更致命的功能达雷克:
public void BusinessAsUsual()
{
var enemies = this.scanner.FindLifeforms('all');
foreach(var e in enemies)
{
e.unless(
x => x.Coordinates == null,
x => this.Gun.Blast(e.Coordinates.Longitude, e.Coordinates.Latitude)
)
// May as well do this anyway, since we're here.
this.Speech.ScreamAt(e, "EXTERMINATE");
}
}
使用这个方法,一个空的 Coordinates 对象不会导致异常,枪根本不会被开火。
在接下来的几章中,有更多方法可以预防空指针异常——这些方法需要更高级的编码和一些理论,但在工作方式上更加彻底。敬请期待。
更新一个 Enumerable
我将用一个有用的示例结束这一节。它涉及更新一个 Enumerable 中的元素,而不改变任何数据!
关于可枚举的要记住的一点是,它们被设计用来利用“惰性评估”——即直到最后可能的时刻才实际从指向数据源的一组函数转换为实际数据。很多时候,使用 Select
函数并不会触发评估,因此我们可以用它们有效地创建过滤器,坐落在数据源和代码中枚举数据的位置之间。
这是修改 Enumerable 的一个示例,使位置 x 处的项目被替换:
var sourceData = new []
{
"Hello", "Doctor", "Yesterday", "Today", "Tomorrow", "Continue"
}
var updatedData = sourceData.ReplaceAt(1, "Darkness, my old friend");
var finalString = string.Join(" ", updatedData);
// Hello Darkness, my old friend Yesterday Today Tomorrow Continue
我所做的是调用一个函数来替换位置 1(即“Doctor”)的元素为一个新值。尽管有两个变量,在这段代码片段结束后,对源数据实际上并没有做任何操作。此外,直到调用 string.Join
时,才会进行实际的替换,因为那是需要具体值的时刻。
这是如何完成的:
public static class Extensions
{
public static IEnumerable<T> ReplaceAt(this IEnumerable<T> @this,
int loc,
T replacement) =>
@this.Select((x, i) => i == loc ? replacement : x);
}
这里返回的 Enumerable 实际上指向原始 Enumerable,并从那里获取其值,但有一个关键的区别。如果元素的索引等于用户定义的值(在我们的示例中是第二个元素,即 1),那么所有其他值都将不变地传递。
如果你愿意的话,你可以提供一个函数来执行更新——让用户能够基于正在被替换的旧数据项来生成新版本的数据项。
这是你会做到的:
public static class Extensions
{
public static IEnumerable<T> ReplaceAt(this IEnumerable<T> @this,
int loc,
Func<T, T> replacement) =>
@this.Select((x, i) => i == loc ? replacement(x) : x);
}
使用起来也很简单:
var sourceData = new []
{
"Hello", "Doctor", "Yesterday", "Today", "Tomorrow", "Continue"
}
var updatedData = sourceData.ReplaceAt(1, x => x + " Who");
var finalString = string.Join(" ", updatedData);
// Hello Doctor Who Yesterday Today Tomorrow Continue
我们也可能不知道要更新的元素的 Id - 实际上可能有多个要更新的项目。这是一种基于提供 T 到 Bool 转换 Func
的替代 Enumerable 更新函数,用于标识应该更新的记录。
这个例子是基于桌游 - 我最喜欢的爱好之一 - 让我永远耐心的妻子很恼火!在这种情况下,BoardGame 对象上有一个 Tag 属性,其中包含描述游戏的元数据标签(“家庭”,“合作”,“复杂”等),这将被搜索引擎应用程序使用。已决定为适合单人游戏的游戏添加另一个标签 - “独奏”。
var sourceData = this.DataStore.GetBoardGames();
var updatedData = sourceData.ReplaceWhen(
x => x.NumberOfPlayersAllowed.Contains(1),
x => x with { Tags = x.Tags.Append("solo") });
this.DataStore.Save(updatedData);
实现是我们已经讨论过的代码的变体:
public static class ReplaceWhenExtensions
{
public static IEnumerable<T> ReplaceWhen<T>(this IEnumerable<T> @this,
Func<T, bool> shouldReplace,
Func<T, T> replacement) =>
@this.Select(x => shouldReplace(x) ? replacement(x) : x);
}
此函数可用于替代许多 If 语句的需求,并将它们简化为更简单、更可预测的操作。
结论
在本章中,我们探讨了使用高阶函数概念开发丰富功能以避免需要面向对象风格语句的各种方法。
如果您有任何关于自己用于高阶函数用途的想法,请随时与我们联系。您永远不知道,它可能会出现在本书的未来版本中!
在接下来的章节中,我们将探讨辨识联合,以及这个函数式概念如何帮助更好地模拟代码库中的概念,并消除通常在非函数式项目中所需的大量防御性代码。享受吧!
¹ 理想情况下,您可以找到最热辣的口味。吃时火焰应从您的嘴里冒出!
² 在此处了解更多信息:https://en.wikipedia.org/wiki/SOLID - 或者如果您更喜欢视频,这里有一个由我亲自主持的视频:https://www.youtube.com/watch?v=0vJb_B47J6U
³ 自己看看这里:https://ramdajs.com/
⁴ 我猜那可能就是它们得名的原因
⁵ 还有,祝贺您走到了这一步。虽然你花的时间可能没有我多!
⁶ 对于未了解的人来说,它们是英国科幻电视系列《Doctor Who》中的主要反派。在这里看看它们的表现:https://www.youtube.com/watch?v=d77jOE2Cjx8
第六章:区分联合
区分联合(DUs)是一种定义类型(或在 OO 世界中的类)的方式,实际上是一组不同类型中的一种。在任何给定时刻,必须在使用之前检查 DU 实例的类型。
F#本地支持 DUs,并且这是 F#开发人员广泛使用的功能。尽管与 C#共享一个公共运行时,并且该功能理论上可用,但目前只有计划在某个时候将其引入 C#中 - 但不确定如何或何时。在此期间,我们可以用抽象类粗略模拟它们,这就是我将在本章中讨论的技术。
本章是我们首次涉足一些更高级的函数式编程领域。本书的前几章更侧重于开发者如何聪明工作,而不是辛苦工作。我们还探讨了如何减少样板文件,并使代码更健壮和可维护。
区分联合¹是一种编程结构,也可以做到这一点,但不仅仅是一个简单的扩展方法,或者是一个单行修复以消除一些样板文件。DUs 更接近于设计模式的概念 - 因为它们有一个结构,并且需要围绕它实现一些逻辑。
假日时间
让我们想象一个老式的面向对象问题,我们正在为度假套餐创建一个系统。你知道的 - 旅行社为您安排旅行、住宿等等。我会让你想象一下你要去的美丽目的地。就我个人而言,我非常喜欢希腊群岛。
public class Holiday
{
public int Id { get; set; }
public Location Destination { get; set; }
public Location DepartureAirport { get; set; }
public DateTime StartDate { get; set; }
public int DurationOfStay { get; set; }
}
public class HolidayWithMeals : Holiday
{
public int NumberOfMeals { get; set; }
}
现在想象一下,我们正在为客户创建一个账户页面²,我们想列出他们迄今为止购买的所有东西。实际上并不那么困难。我们可以使用一些相对较新的is
语句来构建必要的字符串。以下是我们可以做的一种方式:
public string formatHoliday(Holiday h) =>
"From: " + h.DepartureAirport.Name + Environment.NewLine +
"To: " + h.Destination.Name + Environment.NewLine +
"Duration: " + h.DurationOfStay + " Day(s)" +
(
h is HolidayWithMeals hm
? Environment.NewLine + "Number of Meals: " + hm.NumberOfMeals
: string.Empty
);
如果我想快速引入一些功能性思想来改进这个问题,我可以考虑引入一个分支组合子(见上一章),基本类型是假日,子类型是带有餐饮的假日。本质上是相同的东西,但多了一个或两个额外的字段。
如果…公司启动了一个项目。现在,他们将开始提供与度假无关的其他类型的服务。他们还将开始提供不涉及酒店、航班或其他任何类似事物的日游。也许是伦敦的塔桥入口³,或者是巴黎的埃菲尔铁塔的快速游览。无论你喜欢什么。世界是你的。
对象看起来会像这样:
public class DayTrip
{
public int Id { get; set; }
public DateTime DateOfTrip { get; set; }
public Location Attraction { get; set; }
public bool CoachTripRequired { get; set; }
}
但问题是,如果我们想要从一个假日对象继承来表示这种新情况,这是行不通的。我见过一些人采取的方法是将所有字段合并在一起,以及一个布尔值来指示应该查看哪些字段。
类似这样:
public class CustomerOffering
{
public int Id { get; set; }
public Location Destination { get; set; }
public Location DepartureAirport { get; set; }
public DateTime StartDate { get; set; }
public int DurationOfStay { get; set; }
public bool CoachTripRequired { get; set; }
public bool IsDayTrip { get; set; }
}
这是一个糟糕的想法,原因有几个。首先,你违反了接口隔离原则。无论它真正是哪种类型,你都在强制它保存对它来说无关的字段。我们还将“Destination”和“Attraction”的概念重复了一遍,以及这里的“DateOfTrip”和“StartDate”,以避免重复,但这意味着我们失去了一些使处理日行程代码有意义的术语。
另一个选择是将它们作为完全独立的对象类型保留,彼此之间没有任何关系。尽管如此,这样做会失去一个很好的特性,即能够通过简洁的方式遍历每个对象。我们无法按日期顺序在单个表中列出所有内容。必须有多个表。
所有这些可能性似乎都不太好。但这正是 DUs 以优化解决方案应对问题的地方。在下一节中,我将向你展示如何使用它们来提供最佳解决方案。
使用鉴别联合的节日
在 F# 中,你可以像这样为我们的客户提供示例创建一个联合类型:
type CustomerOffering =
| Holiday
| HolidayWithMeals
| DayTrip
这意味着你可以实例化一个新的 CustomerOffering 实例,但有三种不同的类型,每种类型可能有其自己完全不同的属性。
这是我们在 C# 中可以接近这种方法的方式:
public abstract class CustomerOffering
{
public int Id { Get; set; }
}
public class Holiday : CustomerOffering
{
public Location Destination { get; set; }
public Location DepartureAirport { get; set; }
public DateTime StartDate { get; set; }
public int DurationOfStay { get; set; }
}
public class HolidayWithMeals : Holiday
{
public int NumberOfMeals { get; set; }
}
public class DayTrip : CustomerOffering
{
public DateTime DateOfTrip { get; set; }
public Location Attraction { get; set; }
public bool CoachTripRequired { get; set; }
}
表面上看,它似乎与类集合的第一个版本并没有完全不同,但有一个重要的区别。基类是抽象的 - 你实际上不能创建一个 CustomerOffering 类。它不是一个具有一个顶级父类的类族树,而是所有子类都是不同的,但在层次结构中是相等的。
这里有一个类层次结构图,可以更清晰地显示两种方法之间的区别:
DayTrip 类在任何情况下都不必符合与 Holiday 类有关的任何概念。DayTrip 完全是自己的东西。这意味着它可以使用与其自身业务逻辑完全相符的属性名称,而不必追溯 Holiday 的一些属性。换句话说 - DayTrip 不是 Holiday 的扩展,而是其替代品。
这还意味着你可以有所有 CustomerOfferings 的单个数组,尽管它们之间差异很大。无需分开的数据源。
我们可以在代码中处理一个 CustomerOffering 对象数组,使用模式匹配语句:
public string formatCustomerOffering(CustomerOffering c) =>
c switch
{
HolidayWithMeals hm => this.formatHolidayWithMeal(hm),
Holiday h => this.formatHoliday(h),
DayTrip dt => this.formatDayTrip(tp)
};
这简化了接收鉴别联合的所有代码,并产生了更具描述性的代码,更准确描述函数的所有可能结果。
薛定谔的联合
如果你想要一个关于这些工作原理的类比,想想可怜的薛定谔的猫。这是奥地利物理学家厄温·薛定谔提出的一个思想实验,旨在突出量子力学中的悖论。这个想法是,给定一个包含一只猫和一个有 50-50 几率衰变的放射性同位素的盒子,这会杀死猫。关键是,根据量子物理学,直到有人打开盒子检查猫时,猫同时处于生和死的两种状态。这意味着猫同时是活着和死的。
这也意味着,如果薛定谔先生将他的猫/同位素盒子邮寄给一个朋友,他们会得到一个可能包含两种状态之一的盒子,直到他们打开它,他们不知道是哪种状态。当然,邮政服务是什么样子的,猫到达时可能已经死了无论如何。这就是为什么你真的不应该在家里尝试这个。相信我,我不是医生,也不在电视上扮演医生。
这就是歧视性联合的工作原理。一个返回的值,但可能存在于两种或更多状态中。在检查之前,你不知道它是哪种状态。
如果一个类不关心它的状态,你甚至可以将其传递给它的下一个目的地而不打开它。
作为代码的薛定谔的猫可能看起来像这样:
public abstract class SchrödingersCat { }
public class AliveCat : SchrödingersCat { }
public class DeadCat : SchrödingersCat { }
我希望你现在清楚歧视性联合实际上是什么。我将在本章的其余部分演示一些它们的示例。
命名约定
让我们想象一个用于从个体组件中写出人名的代码模块。如果你有一个传统的英国名字,就像我的名字一样,那么这就相当简单。一个用于写我的名字的类看起来会像这样:
public class BritishName
{
public string FirstName { get; set; }
public IEnumerable<string> MiddleNames { get; set; }
public string LastName { get; set; }
public string Honorific { get; set; }
}
var simonsName = new BritishName
{
Honorific = "Mr.",
FirstName = "Simon",
MiddleNames = new [] { "John" },
LastName = "Painter
};
渲染代码将会像这样简单:
public string formatName(BritishName bn) =>
bn.Honorific + " " bn.FirstName + " " + string.Join(" ", bn.MiddleNames) +
" " + bn.LastName;
// Results in "Mr Simon John Painter"
全部完成了,对吧?好吧,这适用于传统的英国名字,但中国名字呢?它们的书写顺序与英国名字不同。中国名字的书写顺序是<姓><名>,许多中国人还有一个“字” - 一个西式名字,专业上使用。
让我们以传奇演员、导演、作家、特技演员、歌手和全能人类 - 成龙为例。他的真实姓名是房仕龙。在这组名字中,他的姓氏是房。他的个人姓名(通常在英语中称为名字或基督教名)是仕龙。成龙是他从很小就用的一个敬称。这种名字风格与我上面创建的 formatName 函数根本不奏效。
我可能会稍微修改数据使其工作。类似这样:
var jackie = new BritishName
{
Honorific = "Xiānsheng", // equivalent of "Mr."
FirstName = "Fang",
LastName = "Shilong"
}
// results in "xiānsheng Fang Shilong"
所以,很好,这样才能正确按顺序写出他的两个官方名称。但他的谦称呢?没有东西写出来。另外,“先生”的中文等价词 - 先生⁷ - 实际上在名字之后,所以这实际上相当糟糕 - 即使我们试图重新使用现有的字段。
我们可以向代码中添加大量的if
语句来检查所描述的人员的国籍,但如果我们尝试扩展以包括超过 2 种国籍,这种方法很快就会变成噩梦。
再次强调,更好的方法是使用带标签的联合体来表示根本不同的数据结构,以一种能够反映它们试图表示的事物实际情况的形式。
public abstract class Name { }
public class BritishName : Name
{
public string FirstName { get; set; }
public IEnumerable<string> MiddleNames { get; set; }
public string LastName { get; set; }
public string Honorific { get; set; }
}
public class ChineseName : Name
{
public string FamilyName { get; set; }
public string GivenName { get; set; }
public string Honorific { get; set; }
public string CourtesyName { get; set; }
}
在我的想象场景中,可能为每种名称类型分别存在独立的数据源 - 每个都有自己的架构。也许每个国家都有一个 Web API?
使用这个联合体,我们实际上可以创建一个包含我和成龙的名字数组⁸
var names = new Name[]
{
new BritishName
{
Honorific = "Mr.",
FirstName = "Simon",
MiddleNames = new [] { "John" },
LastName = "Painter"
},
new ChineseName
{
Honorific = "Xiānsheng",
FamilyName = "Fang",
GivenName = "Shilong",
CourtestyName = "Jackie"
}
}
然后,我可以通过模式匹配表达式扩展我的格式化函数:
public string formatName(Name n) =>
n switch
{
BritishName bn => bn.Honorific + " " bn.FirstName + " "
+ string.Join(" ", bn.MiddleNames) + " " + bn.LastName,
ChineseName cn => cn.FamilyName + " " + cn.GivenName + " " +
cn.Honorific + " \"" + cn.CourtesyName + "\""
};
var output = string.Join(Environment.NewLine, names);
// output =
// Mr. Simon John Painter
// Fang Shilong Xiānsheng "Jackie"
同样的原则可以应用于世界任何地方的任何风格的命名,给定的字段名称将始终对该国家有意义,并且始终以正确的样式显示,而不是重新使用现有字段。
数据库查找
我通常会考虑在 C#中将带标签的联合体作为接口定义的函数的返回类型的情况。
我特别可能在查找数据源的查找函数中使用这种技术的领域。假设你想在某种系统中找到某人的详细信息。该函数将接受一个整数 Id 值,并返回一个 Person 记录。
至少通常会发现人们这样做。像这样的东西:
public Person GetPerson(int id)
{
// Fill in some code here. Whatever data
// store you want to use. Except mini-disc.
}
但是如果你仔细想想,返回一个Person
对象只是函数可能的一种返回状态。
如果输入了一个不存在的人员 Id,会怎么样?你可以返回Null
,我想,但这并不描述实际发生的情况。如果有一个处理过的Exception
导致没有返回任何东西呢?Null
并没有告诉你返回它的原因。
另一种可能性是引发Exception
。这可能不是你的代码的错,但如果存在网络问题或其他问题,这种情况确实可能发生。在这种情况下,你会返回什么?
而不是返回一个没有解释的Null
并强制代码库的其他部分处理它,或者在其中包含异常等元数据字段的替代返回类型对象,我们可以创建一个带标签的联合体:
public abstract class PersonLookupResult
{
public int Id { get; set; }
}
public class PersonFound : PersonLookupResult
{
public Person Person { get; set; }
}
public class PersonNotFound : PersonLookupResult
{
}
public class ErrorWhileSearchingPerson : PersonLookupResult
{
public Exception Error { get; set; }
}
所有这些意味着我们现在可以从我们的 GetPersonById 函数中返回一个单一的类,告诉使用该类的代码一个已返回这三种状态之一,但已经确定是哪一个。不需要对返回的对象应用逻辑来确定它是否起作用,这些状态完全描述了需要处理的每种情况。
函数看起来可能像这样:
public PersonLookupResult GetPerson(int id)
{
try
{
var personFromDb = this.Db.Person.Lookup(id);
return personFromDb == null
? new PersonNotFound { Id = id }
: new PersonFound
{
Person = personFromDb,
Id = id
};
}
catch(Exception e)
{
return new ErrorWhileSearchingPerson
{
Id = id,
Error = e
}
}
}
再次消耗它就是使用模式匹配表达式确定要做什么:
public string DescribePerson(int id)
{
var p = this.PersonRepository.GetPerson(id);
return p switch
{
PersonFound pf => "Their name is " + pf.Name,
PersonNotFound _ => "Person not found",
ErrorWhileSearchingPerson e => "An error occurred" + e.Error.Message
};
}
发送电子邮件
上一个示例适用于期望返回值的情况,但如果没有返回值呢?假设我写了一些代码给客户或家人发送电子邮件,但不想自己写信息⁹。
我不期望有任何回报,但如果发生错误,我可能想知道,所以这一次我特别关心的只有两种状态。
我会这样完成它:
public abstract class EmailSendResult
{
}
public class EmailSuccess : EmailSendResult
{
}
public class EmailFailure : EmailSendResult
{
pubic Exception Error { get; set; }
}
在代码中使用这个类可能会是这样:
public EmailSendResult SendEmail(string recipient, string message)
{
try
{
this.AzureEmailUtility.SendEmail(recipient, message);
return new EmailSuccess();
}
catch(Exception e)
{
return new EmailFailure
{
Error = e
};
}
}
在代码库中的其他地方使用函数会是这样:
var result = this.EmailTool.SendEmail("Season's Greetings", "Hi, Uncle John. How's it going?");
var messageToWriteToConsole = result switch
{
EmailFailure ef => "An error occurred sending the email: " + ef.Error.Message,
EmailSuccess _ => "Email send successful",
_ => "Unknow Response"
};
this.Console.WriteLine(messageToWriteToConsole);
这意味着我可以再次从函数中返回错误消息和失败状态,但没有任何地方依赖于不需要的属性。
控制台输入
有一段时间我产生了一个疯狂的想法,通过将一款使用 HP Timeshare BASIC 编写的旧文本游戏转换成功能式的 C#,来尝试我的函数式编程技能。
游戏名叫《俄勒冈之旅》,可以追溯到 1975 年。难以置信,竟然比我还要老!甚至比《星球大战》还要老。事实上,它甚至早于显示器问世,当时必须在看起来像打字机的设备上进行游戏。在那些日子里,代码中的“print”意味着真的要打印!
游戏代码最关键的一点是定期从用户那里获取输入。大多数时候需要一个整数 - 要么是从列表中选择一个命令,要么是输入购买货物的数量。其他时候,接收文本并确认用户输入的内容也很重要 - 就像在打猎小游戏中,用户需要尽快输入“BANG”以模拟精确击中目标。
我本可以在代码库中有一个模块,从控制台返回原始用户输入。这意味着整个代码库中每个需要整数值的地方都需要进行检查,然后解析成整数,然后再继续实际需要的逻辑。
使用歧视联合更明智的想法是,用于表示游戏逻辑识别的不同状态,并将必要的整数检查代码保留在一个地方。
像这样:
public abstract class UserInput
{
}
public class TextInput : UserInput
{
public string Input { get; set; }
}
public class IntegerInput : UserInput
{
public int Input { get; set; }
}
public class NoInput : UserInput
{
}
public class ErrorFromConsole : UserInput
{
public Exception Error { get; set; }
}
老实说,我不太确定控制台可能出现什么错误,但我认为排除这种可能性并不明智,尤其是因为这是我们应用代码无法控制的事物。
这里的想法是,我逐渐从代码库之外的不纯净区域转移到其内部的纯净控制区域。就像一个多阶段气闸一样。
谈到控制台超出我们控制范围... 如果我们希望保持尽可能函数化的代码库,最好将其隐藏在接口后面,这样我们可以在测试时注入模拟,并将代码的非纯净区域推迟一些。
就像这样:
public interface IConsole
{
UserInput ReadInput(string userPromptMessage);
}
public class ConsoleShim : IConsole
{
public UserInput ReadInput(string userPromptMessage)
{
try
{
Console.WriteLine(userPromptMessage);
var input = Console.ReadLine();
return new TextInput
{
Input = input
};
}
catch(Exception e)
{
return new ErrorFromConsole
{
Error = e
};
}
}
}
那是与用户互动的最基本表示方式。因为这是一个具有副作用的系统区域,我希望尽可能将其保持小巧。
之后,我创建了另一层,但这次实际上对从玩家接收到的文本应用了一些逻辑:
public class UserInteraction
{
private readonly IConsole _console;
public UserInteraction(IConsole console)
{
this._console = console;
}
public UserInput GetInputFromUser(string message)
{
var input = this._console.ReadInput(message);
var returnValue = input switch
{
TextInput x when string.IsNullOrWhiteSpace(x.Input) =>
new NoInput(),
TextInput x when int.TryParse(x.Input, out var _)=>
new IntegerInput
{
Input = int.Parse(x.Input)
},
TextInput x => new TextInput
{
Input = x.Input
}
};
return returnValue;
}
}
这意味着,如果我想提示用户输入,并保证他们给了我一个整数,现在编写代码就非常容易了:
public int GetPlayerSpendOnOxen()
{
var input = this.UserInteraction.GetInputFromUser("How much do you want to spend on Oxen?");
var returnValue = input switch
{
IntegerInput ii => ii.Input,
_ => {
this.UserInteraction.WriteMessage("Try again");
return GetPlayerSpendOnOxen();
}
};
return returnValue;
}
在这个代码块中,我正在提示玩家输入。然后,我检查它是否是我预期的整数 - 基于已经通过区分联合进行的检查。如果是整数,很好。任务完成,返回该整数。
如果不是整数,则需要提示玩家再试一次,然后再次调用此函数,递归地。我可以更详细地介绍捕获和记录接收到的任何错误,但我认为这已经充分演示了原则。
还要注意,这个函数中不需要 Try/Catch。这已经由更低层次的函数处理了。
在我的俄勒冈之旅转换中,有许多许多地方需要检查整数的这段代码。想象一下,通过将整数检查包装到返回对象的结构中,我节省了多少代码!
通用联合
所有迄今为止的区分联合均完全特定于情况。在结束本章之前,我想讨论一些创建完全通用、可重复使用版本的几个选项。
首先,让我再强调一下 - 我们不能像 F#中的人们那样轻松、即兴地声明区分联合。我们无法做到这一点。抱歉。我们能做的最好的就是尽可能地模拟它,以某种样板代码作为平衡。
这里有几种你可以使用的功能结构。顺便说一句,下一章节将介绍更高级的使用方式。敬请期待。
或许
如果你使用区分联合来表示函数可能未找到数据的情况,那么 Maybe 结构可能适合你。
实现看起来像这样:
public abstract class Maybe<T>
{
}
public class Something<T> : Maybe<T>
{
public Something(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Nothing<T> : Maybe<T>
{
}
你基本上是在将 Maybe 抽象作为另一个类的包装器使用,实际上是你的函数返回的类,但通过这种方式包装它,你向外界表明可能并不一定会返回任何东西。
下面是你可以如何用于返回单个对象的函数:
public Maybe<DoctorWho> GetDoctor(int doctorNumber)
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.QuerySingleOrDefault<Doctor>(
"SELECT * FROM [dbo].[Doctors] WHERE DocNum = @docNum",
new { docNum = doctorNumber });
return data == null
? new Nothing<DoctorWho>();
: new Something<DoctorWho>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Nothing<DoctorWho>();
}
}
你会像这样使用它:
// William Hartnell. He's the best!
var doc = this.DoctorRepository.GetDoctor(1);
var message = doc switch
{
Something<DoctorWho> s => "Played by " + s.Value.ActorName,
Nothing<DoctorWho> _ => "Unknown Doctor"
};
这并不特别有效地处理错误情况。一个 Nothing 状态至少可以防止未处理的异常发生,并且我们正在记录,但没有任何有用的内容传递给最终用户。
Result
Maybe 的一个替代方案是 Result,它表示函数可能抛出错误而不是返回任何内容。它可能看起来像这样:
public abstract class Result<T>
{
}
public class Success : Result<T>
{
public Success<T>(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Failure<T> : Result<T>
{
public Failure(Exception e)
{
this.Error = e;
}
public Exception Error { get; init; }
}
现在,“获取医生”的函数的 Result 版本看起来是这样的:
public Result<DoctorWho> GetDoctor(int doctorNumber)
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.QuerySingleOrDefault<Doctor>(
"SELECT * FROM [dbo].[Doctors] WHERE DocNum = @docNum",
new { docNum = doctorNumber });
return new Success<DoctorWho>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Failure<DoctorWho>(e);
}
}
你可能考虑使用它,就像这样:
// Sylvester McCoy. He's the best too!
var doc = this.DoctorRepository.GetDoctor(7);
var message = doc switch
{
Success<DoctorWho> s when s.Value == null => "Unknown Doctor!",
Success<DoctorWho> s2 => "Played by " + s2.Value.ActorName,
Failure<DoctorWho> e => "An error occurred: " e.Error.Message
};
现在,我正在处理歧视联盟的一个可能状态中的错误场景,但是 null 检查的负担落到了接收函数。
Maybe vs Result
在这一点上一个非常合理的问题是,哪一个更好使用?Maybe 还是 Result?
Maybe 提供了一个状态,通知用户没有找到任何数据,消除了空检查的需要,但实际上悄悄地吞噬了错误。这比未处理的异常要好,但可能会导致未报告的错误。
Result 优雅地处理错误,但增加了接收函数检查 null 的负担。
我个人的偏好?这可能不严格符合这些结构的标准定义,但我将它们结合成一个。我通常有一个 3 状态的 Maybe - Something,Nothing,Error。它可以处理代码库可以抛出的几乎所有情况。
这将是我个人解决问题的方式:
public abstract class Maybe<T>
{
}
public class Something<T> : Maybe<T>
{
public Something(T value)
{
this.Value = value;
}
public T Value { get; init; }
}
public class Nothing<T> : Maybe<T>
{
}
public class Error<T> : Maybe<T>
{
public Error(Exception e)
{
this.CapturedError = e;
}
public Exception CapturedError { get; init; }
}
我会这样使用它:
public Maybe<DoctorWho> GetDoctor(int doctorNumber)
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.QuerySingleOrDefault<Doctor>(
"SELECT * FROM [dbo].[Doctors] WHERE DocNum = @docNum",
new { docNum = doctorNumber });
return data == null
? new Nothing<DoctorWho>();
: new Something<DoctorWho>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Error<DoctorWho>(e);
}
}
这意味着接收函数现在可以使用模式匹配表达式优雅地处理所有三种状态:
// Peter Capaldi. The other, other best Doctor!
var doc = this.DoctorRepository.GetDoctor(12);
var message = doc switch
{
Nothing<DoctorWho> _ => "Unknown Doctor!",
Something<DoctorWho> s => "Played by " + s.Value.ActorName,
Error<DoctorWho> e => "An error occurred: " e.Error.Message
};
这使我能够对任何给定的场景提供完整的响应集,当从需要连接到冷、黑、饥饿狼充斥的程序之外的世界返回时,轻松地允许更多信息化的响应返回给最终用户。
在我们完成这个话题之前,这里是我如何使用同样的结构来处理一个返回类型为 Enumerable 的情况:
public Maybe<IEnumerable<DoctorWho>> GetAllDoctors()
{
try
{
using var conn = this._connectionFactory.Make();
// Dapper query to the db
var data = conn.Query<Doctor>(
"SELECT * FROM [dbo].[Doctors]");
return data == null || !data.Any()
? new Nothing<IEnumerable<DoctorWho>>();
: new Something<IEnumerable<DoctorWho>>(data);
}
catch(Exception e)
{
this.logger.LogError(e, "An error occurred getting doctor " + doctorNumber);
return new Error<IEnumerable<DoctorWho>>(e);
}
}
这使我可以处理来自函数的响应,就像这样:
// Great chaps. All of them!
var doc = this.DoctorRepository.GetAllDoctors();
var message = doc switch
{
Nothing<IEnumerable<DoctorWho>> _ => "No Doctors found!",
Something<IEnumerable<DoctorWho>> s => "The Doctors were played by: " +
string.Join(Environment.NewLine, s.Value.Select(x => x.ActorName),
Error<IEnumerable<DoctorWho>> e => "An error occurred: " e.Error.Message
};
再一次,既优雅又完美,一切都被考虑进去了。这是我在日常编码中经常使用的方法,我希望在阅读本章后,你也能这样做!
Either
Something 和 Result - 以某种形式 - 现在通用地处理了从函数返回的可能行为不确定的情况。那么在你可能想要返回两种或更多完全不同类型的情况下怎么办?
这就是 Either 类型的用处。语法可能不是最好的,但它确实有效。
public abstract class Either<T1, T2>
{
}
public class Left<T1, T2> : Either<T1, T2>
{
public Left(T1 value)
{
Value = value;
}
public T1 Value { get; init; }
}
public class Right<T1, T2> : Either<T1, T2>
{
public Right(T2 value)
{
Value = value;
}
public T2 Value { get; init; }
}
我可以用它来创建一个可以左右移动的类型,就像这样:
public Either<string, int> QuestionOrAnswer() =>
new Random().Next(1, 6) >= 4
? new Left<string, int>("What do you get if you mulitply 6 by 9?")
: new Right<string, int>(42);
var data = QuestionOrAnswer();
var output = data switch
{
Left<string, int> l => "The ultimate question was: " + l.Value,
Right<string, int> r => "The ultimate answer was: " + r.Value.ToString()
};
当然,你可以扩展它,以包含三种或更多不同的可能类型。我不太确定你会怎么称呼它们,但这肯定是可能的。唯一比较麻烦的是,你必须在很多地方包含所有通用类型的引用。不过至少它是有效的……
结论
本章我们讨论了可辨别联合体(Discriminated Unions)。它们究竟是什么,如何使用以及作为代码特性它们是多么强大。
可辨别联合体(Discriminated Unions)可以大大减少样板代码,并利用一种数据类型来描述系统的所有可能状态,这种方式极大地鼓励接收函数适当地处理它们。
可辨别联合体(Discriminated Unions)在 C# 中的实现并不像在 F# 或其他函数式语言中那样简单,但在 C# 中也有可能实现。
在下一章中,我将探讨一些更高级的函数概念,这将使可辨别联合体(Discriminated Unions)提升到一个新的水平!
¹ 请允许我向大家保证,尽管被称为“可辨别联合体”,它们与任何人对爱情和/或婚姻的看法或工会组织没有任何联系。
² 我没告诉过你吗?我们现在是旅游业务员了,你我!我们将向毫不知情的顾客推销廉价假期,直到我们富有和满足地退休。或者继续做我们现在正在做的事情。无论哪种方式,都行。
³ 这不是伦敦桥,你所想到的那个著名的。伦敦桥在别的地方。事实上,在亚利桑那州。不,真的。查一下吧。
⁴ 注:从未有人这样做过。我不知道有一只猫曾因量子力学而被牺牲过。
⁵ 不知怎么地。我从来没有真正理解过它的这一部分。
⁶ 哇,这将是多么糟糕的生日礼物啊。谢谢你,薛定谔!
⁷ “先生” - 它字面上意思是“出生较早的人”。有趣的是,如果你用日语写同样的字母,它会发音为“Sensei”。我是个书呆子 - 我喜欢这样的东西!
⁸ 悲伤的是,这是我与他真实接触的最接近的机会。如果你还没有看过他的香港电影,请务必看一些!我建议从《警察故事》系列开始。
⁹ 开个玩笑,朋友们,诚实的!请不要把我从你们的圣诞卡名单上划掉!
第七章:函数式流程
调用外部系统,无论是数据库、Web API 还是其他,都是一件很头疼的事情,不是吗?在使用你的数据 - 函数中最重要的部分之前,你必须:
-
捕获并处理任何异常。也许是网络故障,或者数据库服务器离线了?
-
检查从数据库返回的内容不是 NULL
-
检查是否有一个实际合理的数据集,即使它不是 null。
这就是许多繁琐的样板代码,所有这些都妨碍了你的实际业务逻辑。
使用上一章的 Maybe 判别联合将在处理未找到记录或遇到错误时有所帮助,但即便如此,仍然需要一些样板代码。
如果我告诉你有一种方法,你永远不用再见到未处理的异常了?不仅如此,你甚至不需要再使用 Try/Catch 块了。至于 Null 检查?忘了吧。你再也不用做那些事了。
不相信我?好吧,准备好,我要向你介绍函数式编程中我最喜欢的一个特性。这是我在日常工作中经常使用的,希望读完这一章后,你也会用到。
再次审视 Maybe
提到上一章的 Maybe 判别联合 - 我现在想重新讨论它,但这次我要向你展示它可以比你想象的更加有用。
我要做的是在前几章使用过的 Map 扩展方法中加入一个版本。如果你还记得第五章“链式函数”中的Map
组合子,它类似于 LINQ 的 Select 方法,只不过它作用于整个源对象,而不是其个别元素。
这一次,我要在 Map 里面加入一些逻辑,这将决定将产生哪种实际类型。这一次我给它起了一个不同的名字 - Bind¹
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when !EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
那么,这里发生了什么?可能有几种情况:
-
当前值为
This
- 即被 Maybe 持有的当前对象是一个Something
- 即一个实际值,并且持有的值是非默认值²,在这种情况下,执行提供的函数,并将其输出作为新的Something
返回。 -
当前值为
This
是一个Something
,但其内部的值是默认值(在大多数情况下是 Null),在这种情况下,我们现在返回一个Nothing
。 -
当前值为
This
是一个 Nothing,在这种情况下,返回另一个 Nothing。没有必要再做其他事情。 -
当前值为
This
是一个错误。同样,除了将其传递下去外,没有任何其他做的必要。
这一切的意义何在?好吧,想象一下以下的过程化代码:
public string MakeGreeting(int employeeId)
{
try
{
var e = this.empRepo.GetById(employeeId);
if(e != null)
{
return "Hello " + e.Salutation + " " + e.Name
}
return "Employee not found";
}
catch(Exception e)
{
return "An error occurred: " + e.Message;
}
}
当你看它时,这段代码的实际目的非常简单。获取一个员工。如果没有问题,向他们打招呼。但是,由于空值和未处理的异常存在,我们不得不编写大量的防御性代码。空检查和 Try/Catch
块。不仅在这里,在我们的整个代码库中都是如此。
更糟糕的是,我们让调用此函数的代码知道该如何处理它。我们如何表示发生了错误,或者找不到员工?在我的例子中,我只是返回一个字符串,供我们编写的任何应用程序盲目显示。另一个选择是返回带有元数据附加的返回对象(例如 bool DataFound
,bool ExceptionOccurred
,Exception CapturedException
- 这样的东西)。
然而,使用 Maybe
和 Bind
函数,这些都是不必要的。可以将代码重写如下:
public Maybe<string> MakeGreeting(int employeeId) =>
new Something(employeeId)
.Bind(x => this.empRepo.GetById(x))
.Bind(x => "Hello " + x.Salutation + " " + x.Name);
想想我列出的每个 Bind
的可能结果。
如果员工存储库返回一个空值,则下一个 Bind
调用将标识为某个具有默认值(即空)的东西,并且不执行构造问候字符串函数,而是返回一个 Nothing
。
如果存储库发生错误(可能是网络连接问题,无法预测或防止的问题),则简单地传递错误,而不执行函数。
我要表达的最终观点是,组装问候语的箭头函数只有在前一步 a) 返回了实际值且 b) 没有抛出未处理的异常时才会执行。
这意味着上面使用 Bind
方法编写的小函数在功能上与以前的版本完全相同,覆盖了防御性代码。
情况会变得更好…
我们不再返回字符串,而是返回 Maybe<string>
。这是一个可以用来通知调用我们函数的结果的辨别联合,告诉它是否工作等等。这可以在外部世界用于决定如何处理结果值,或者可以在后续的 Bind
调用链中使用。
要么像这样:
public Interface IUserInterface
{
void WriteMessage(string s);
}
// Bit of magic here because it doesn't matter
this.UserInterface = Factory.MakeUserInterface();
var message = makeGreetingResult switch
{
Something s => s.Value,
Nothing _ => "Hi, but I've never heard of you.",
Error _ => "An error occurred, try again"
};
this.UserInterface.WriteMessage(message);
或者,您可以调整 UserInterface
模块,以便它以 Maybe
作为参数:
public Interface IUserInterface
{
void WriteMessage(Maybe<string> s);
}
// Bit of magic here because it doesn't matter
this.UserInterface = Factory.MakeUserInterface();
var logonMessage = MakeGreeting(employeeId)
.Bind(x => x + Environment.NewLine + MakeUserInfo(employeeId));
this.UserInterface.WriteMessage(logonMessage);
在接口中用 Maybe<T>
替换具体值表明,消费它的类不能确定操作是否有效,并迫使消费类考虑每种可能性及其处理方式。它还完全让消费类决定如何响应。没有必要让返回 Maybe
的类对接下来发生的事情感兴趣。
我遇到的这种编程风格的最佳描述是在 Scott Wlashin 的讲座和相关文章中,称为铁路导向编程 ³。
Wlashin 将这个过程描述为像一个铁路线,有一系列的道岔。每组道岔都是一个 Bind 调用。火车从 Something 线开始,每次执行传递给 Bind 的函数时,火车要么继续前进到下一组道岔,要么切换到 Nothing 路径,直接滑向线路末端的车站,而无需做更多工作。
这是一种美丽、优雅的编码方式,大大减少了样板代码。
要是这种结构有一个方便的技术术语就好了。哦等等,有!它叫做 Monad!
我在开头就说过它们可能会突然出现在某处。如果有人告诉你 Monad 很复杂,我希望你现在能看到他们错了。
Monad 就像是围绕某种值的包装。就像一个信封或者一个墨西哥卷饼。它们保存值,但并不评论它到底设置了什么。
它们的作用是让你能够挂接函数,提供一个安全的环境进行操作,而不必担心负面后果,比如空引用异常。
Bind 函数就像接力赛一样 - 每次调用都执行某种操作,然后将其值传递给下一个运行者。它还为您处理错误和空值,因此您无需担心编写太多的防御性代码。
如果你愿意,可以想象它就像一个防爆箱。你有一个想要打开的包裹,但你不知道它是安全的,像一封信⁴,还是可能是爆炸品,等待你打开盖子时将你击倒。如果你把它放在 Monad 容器中,它可以安全地打开或爆炸,但 Monad 会保护你免受后果。
那基本上就是这样。嗯,大多数情况下是这样的。
在本章的其余部分,我将考虑我们可以用 Monad 做什么,以及还有哪些其他类型的 Monad。不过不用担心,现在“难”部分已经过去了,如果你已经通过了这一点,仍然与我在一起,那么这本书的其余部分将会很轻松⁵。
Maybe 和调试
有时关于 Bind 语句串的评论是,在 Visual Studio 中使用调试工具逐步跟踪更改变得更难。特别是当你有这样的场景时:
var returnValue = idValue.ToMaybe()
.Bind(transformationOne)
.Bind(transformationTwo)
.Bind(transformationThree);
实际上,在大多数 Visual Studio 的版本中都是可能的,但你需要确保你不断地按“Step-in”键进入 Bind 调用内部的箭头函数。如果值没有正确计算,这对于了解正在发生的事情还不是最好的。当你考虑 Step-in 将进入 Maybe 的 Bind 函数并需要执行更多步骤才能看到箭头函数的结果时,情况会更糟。
我倾向于每行写一个 Bind,每个 Bind 存储一个包含它们各自输出的变量:
var idMaybe = idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x));
var transTwo = transOne.Bind(x => transformationTwo(x));
var returnValue = transTwo.Bind(x => transformationThree(x));
从功能上讲,这两个示例是相同的,只是我们分别捕获每个输出,而不是立即将其馈送到另一个函数并丢弃它们。
第二个示例更容易诊断问题,因为您可以检查每个中间值。由于函数式编程技术的使用,一旦设置变量就不修改它 - 这意味着处理过程中的每个中间步骤都是固定的,可以详细了解发生了什么事情,以及在出现错误时如何以及在哪里出错。
这些中间值将在它们所属的更大函数的整个生命周期内保持在作用域内。因此,如果是一个特别大的函数,并且其中一个中间值很大,那么合并它们以尽早去除这个大中间值可能是值得的。
这个决定主要是个人风格的问题,以及一个或两个代码库的约束。无论您选择哪个都可以。
Map vs Bind
严格来说,按照函数范式实现 Bind 函数时,我并没有这样做。
应该 有两个附加到 Maybe 的函数:Map 和 Bind。它们几乎相同,但有一个小而微妙的区别。
Map 函数与我在上一节中描述的函数类似 - 它连接到 Maybe
实际的 Bind 需要您传入一个返回新类型的 Maybe 的函数 - 即 Maybe
public static Maybe<TOut> Map<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f) => // Some implementation here
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, Maybe<TOut>> f) => // Some Other implementation here
例如,如果您有一个调用数据库并返回 Maybe<IEnumerable<Customer>>
类型的函数,表示可能找到或未找到客户的列表 - 那么您将使用 Bind 函数调用它。
将 Enumerable
中的客户端转换为其他形式的任何后续链式函数调用都可以使用 Map 调用,因为这些变化是数据到数据的转换,而不是数据到可能性的转换。
这是如何实现一个正确的 Bind 的方法:
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, Maybe<TOut>> f)
{
try
{
var returnValue = @this switch
{
Something<TIn> s => f(s.Value),
_ => new Nothing<TOut>()
};
return returnValue;
}
catch (Exception _)
{
return new Nothing<TOut>();
}
}
下面是如何使用它的示例:
public Interface CustomerDataRepo
{
Maybe<Customer> GetCustomerById(int customerId);
}
public string DescribeCustomer(int customerId) =>
new Something<int>(customerId)
.Bind(x => this.customerDataRepo.GetCustomerById(x))
.Map(x => "Hello " + x.Name);
使用这个新的 Bind 函数并将之前的一个命名为 Map,您将更接近函数范式。
但是在生产代码中,我个人不这样做。我只是为两个目的都使用一个名为 Bind 的函数。
你可能会问,为什么呢?
这主要是为了防止混淆,说实话。JavaScript 有一个名为 Map 的原生函数,但它的操作类似于 C# 中的 Select
,作用于数组的各个元素。在 C# 中,Jimmy Bogard 的 AutoMapper 库⁶ 中也有一个 Map 函数,用于将一个对象数组从一种类型转换为另一种类型。
由于许多 C#代码库中已经在使用两个 Map 函数的情况下,我认为在其中添加另一个 Map 函数可能会让查看我的代码的其他人感到困惑。因此,我为所有目的使用 Bind,因为在 C#或 JavaScript 中没有任何地方已经存在 Bind 函数 - 除了实现函数式范式的库中。
你可以自行选择使用更严格准确的同时使用 Map 和 Bind 的版本,或者在我看来更少混淆和更实用的路线,即简单地使用多个 Bind 函数实现每个目的。
我将继续假设这本书的其余部分采用第二种选项。
Maybe 和原始类型
这听起来像是一本从未写成的惊险故事小说的标题。可能涉及我们的女英雄 - Maybe 船长,在一个由侵略性洞穴居民族群组成的失落文明中挥舞着救援。
实际上,在 C#中,原始类型是一组不默认为 Null 的内置类型之一。以下是其中的一些:
-
布尔型
-
字节型
-
有符号字节型
-
字符型
-
十进制
-
双精度浮点型
-
单精度浮点型
-
整型
-
无符号整型
-
有符号自然数型
-
无符号自然数型
-
长整型
-
无符号长整型⁷
-
短整型
-
无符号短整型
这里的重点是,如果我在之前章节的Bind
函数中使用任何这些类型,并将它们的值设置为 0,它们将违反对default
的检查,因为大多数这些类型的默认值为 0⁸
这里有一个单元测试的示例,会失败(我正在使用 XUnit 和 FluentAssertions,以获得更友好、易读的断言风格):
[Fact]
public Task primitive_types_should_not_default_to_nothing()
{
var input = new Something<int>(0);
var output = input.Bind(x => x + 10);
(output as Something<int>).Value.Should().Be(10);
}
这个测试将一个值为 0 的整数存储在一个 Maybe 中,然后尝试将其Bind
为一个比原值高 10 的值 - 即应该等于 10。在现有代码中,Bind
内部的 switch 操作会将值 0 视为默认值,并将返回类型从Something<int>
切换为Nothing<int>
,并且不会执行加 10 的函数,这意味着在我的单元测试中输出会被切换为 null,并且测试将因为空引用异常而失败。
有人可能会说,这并不是正确的行为,0 是一个整数的有效值。
只需在Bind
函数中添加一行代码即可轻松解决:
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when !EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(f(s.Value)),
// This is the new line
Something<TIn> s when s.GetType().GetGenericArguments()[0].IsPrimitive => new Something<TOut>(f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
新行检查Maybe<T>
的第一个泛型参数 - 即T
的“真实”类型。我在本节开头列出的所有类型的IsPrimitive
值都将设置为true
。
如果我使用修改后的Bind
函数重新运行我的单元测试,那么值为 0 的int
仍然不会匹配不是default
的检查,但下一行会匹配,因为 int 是一个原始类型。
现在,这意味着所有原始类型都不能成为Nothing<T>
。这是对错是你自己评估的问题。
如果 T 是一个 bool
,你可能会认为它是 Nothing<T>
。如果是这种情况,需要在第一两行之间的 switch 中添加另一种情况来处理 T 特定的情况为 bool
的情况。
如果一个布尔 false
被传递到执行计算的函数中可能也很重要。正如我所说的,这是一个你最好自己回答的问题。
完全避免这种情况的一种方法是始终传递一个可空的类作为 T,这样你就可以确保在尝试判断你所看到的是 Something
还是 Nothing
时能得到正确的行为。
Maybe 和 日志记录
在专业环境中考虑使用单子的另一件事情是至关重要的开发者工具 - 日志记录。关于函数进度状态的日志信息通常至关重要,不仅仅是错误信息,还包括各种重要信息。
当然可以做类似这样的事情:
var idMaybe = idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x));
if(transOne is Something<MyClass> s)
{
this.Logger.LogInformation("Processing item " + s.Value.Id);
}
else if (transOne is Nothing<MyClass>)
{
this.Logger.LogWarning("No record found for " + idValue");
}
else if (transOne is Error<MyClass> e)
{
this.Logger.LogError(e, "An error occurred for " + idValue);
}
如果你做了很多这样的操作,这可能会失控。特别是在整个过程中有许多需要记录的 Bind。
也许可以直到最后才留出错误日志,或者甚至在控制器中,或者最终发起此请求的任何其他地方。错误消息将不经修改地从一方传递到另一方。但这仍然会留下偶尔的信息或警告用途的日志消息。
我更喜欢添加扩展方法到 Maybe 中以提供一组事件处理函数:
public static class MaybeLoggingExtensions
{
public static Maybe<T> OnSomething(this Maybe<T> @this, Action<T> a)
{
if(@this is Something<T>)
{
a(@this);
}
return @this;
}
public static Maybe<T> OnNothing(this Maybe<T> @this, Action a)
{
if(@this is Nothing<T> _)
{
a();
}
return @this;
}
}
public static Maybe<T> OnError(this Maybe<T> @this, Action<Exception> a)
{
if(@this is Error<T> e)
{
a(e.CapturedError);
}
return @this;
}
那么,我使用它的方式更像是这样:
var idMaybe idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x))
.OnSomething(x => this.Logger.LogInformation("Processing item " + x.Id))
.OnNothing(() => this.Logger.LogWarning("No record found for " + idValue))
.OnError(e => this.Logger.LogError(e, "An error occurred for " + idValue));
这相当可用,尽管它确实有一个缺点。
OnNothing 和 OnError 状态将从 Bind 传播到未修改的 Bind,因此如果你有一长串带有 OnNothing 或 OnError 处理程序函数的 Bind 调用,它们每次都会触发。就像这样:
var idMaybe idValue.ToMaybe();
var transOne = idMaybe.Bind(x => transformationOne(x))
.OnNothing(() => this.Logger.LogWarning("Nothing happened one");
var transTwo = transOne.Bind(x => transformationTwo(x))
.OnNothing(() => this.Logger.LogWarning("Nothing happened two");
var returnValue = transTwo.Bind(x => transformationThree(x))
.OnNothing(() => this.Logger.LogWarning("Nothing happened three");
在上面的代码示例中,所有三个 OnNothing 将会触发,并写入三条警告日志。你可能希望这样,也可能不希望。在第一个 Nothing 之后,可能就不再那么有趣了。
我确实对这个问题有一个解决方案,但这意味着需要编写更多的代码。
创建一个从原始的 Nothing 和 Error 下降的新实例:
public class UnhandledNothing<T> : Nothing<T>
{
}
public class UnhandledError<T> : Error<T>
{
}
我们还需要修改 Bind 函数,以便在从 Something 路径切换到其中之一时返回这些类型。
public static Maybe<TOut> Bind<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, TOut> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when !EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(f(s.Value)),
Something<TIn> s when s.GetType().GetGenericArguments()[0].IsPrimitive => new Something<TOut>(f(s.Value)),
Something<TIn> _ => new UnhandledNothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
UnhandledNothing<TIn> _ => new UnhandledNothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
UnhandledError<TIn> e => new UnhandledError<TOut>(e.CapturedError),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new UnhandledError<TOut>(e);
}
}
然后最后,需要更新处理函数:
public static class MaybeLoggingExtensions
{
public static Maybe<T> OnNothing(this Maybe<T> @this, Action a)
{
if(@this is UnhandledNothing<T> _)
{
a();
return new Nothing<T>();
}
return @this;
}
}
public static Maybe<T> OnError(this Maybe<T> @this, Action<Exception> a)
{
if(@this is UnhandledError<T> e)
{
a(e.CapturedError);
return new Error<T>(e.CapturedError);
}
return @this;
}
所有这些意味着,当从 Something 切换到其他状态时,Maybe 会切换到一个状态,不仅表示发生了 Nothing 或异常,而且还表示尚未处理该状态。
一旦调用了处理函数之一,并发现了未处理的状态,那么回调就会触发日志记录,或者其他操作,并返回一个新对象,保持相同的状态类型,但这次指示它不再是未处理的状态。
这意味着在我上面示例中多次使用 Bind
调用并附加了 OnNothing
函数时,只有第一个 OnNothing
会被触发,其余的会被忽略。
在代码库的其他地方,你仍然可以使用模式匹配语句来检查 Maybe
的类型,以便在 Maybe
达到最终目的地时执行某些动作或其他操作。
Maybe
和异步
所以,我知道你接下来要问我什么。看,我得委婉地拒绝你了。我已经结婚了。哦?我的错。异步和 Monad。是的,好的。继续...
如何处理 Monad 内部调用到异步进程?老实说,这并不难。保留你已经编写的 Maybe Bind
函数,并将这些内容添加到代码库中:
public static async Task<Maybe<TOut>> BindAsync<TIn, TOut>(this Maybe<TIn> @this, Func<TIn, Task<TOut>> f)
{
try
{
Maybe<TOut> updatedValue = @this switch
{
Something<TIn> s when EqualityComparer<TIn>.Default.Equals(s.Value, default) =>
new Something<TOut>(await f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.ErrorMessage),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return updatedValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
我在这里做的所有事情就是在我们传递的值周围再包装一层。
第一层是 Maybe
- 表示我们尝试的操作可能没有成功
第二层是 Task
- 表示首先需要执行一个 async
操作,然后才能到达 Maybe
。
在你更广泛的代码库中使用这个最好一次一行地进行,这样你可以避免在同一个 Bind
调用链中混合使用异步和非异步版本,否则你可能会得到一个 Task<T>
作为一种类型传递,而不是实际的类型 T
。此外,这意味着你可以将每个异步调用分离出来,并使用 await
语句获取真实的值以传递给下一个 Bind
操作。
嵌套的 Maybe
到目前为止,我展示的 Maybe
存在一些问题。这是我真正意识到的,一旦我将许多接口更改为使用 Maybe<T>
作为涉及外部交互的返回类型后。
下面是一个需要考虑的场景。我创建了几种不同类型的数据加载器。它们可以是数据库、Web API 或其他。并不重要:
public interface DataLoaderOne
{
Maybe<string> GetStringOne();
}
public interface DataLoaderTwo
{
Maybe<string> GetStringTwo(string stringOne);
}
public interface DataLoaderThree
{
Maybe<string> GetStringThree(string stringTwo);
}
在代码的其他部分,我想通过 Bind
调用依次调用这些接口。
注意 Maybe<string>
返回类型的重点在于,我可以通过 Bind
函数引用它们,如果任何 dataLoader
调用失败,后续步骤 将不会 执行,并且我会得到一个 Nothing<string>
或 Error<string>
以供检查。
像这样:
var finalString = dataLoaderOne.GetStringOne()
.Bind(x => dataLoaderTwo.GetStringTwo(x))
.Bind(x => dataLoaderThree.GetStringThree(x));
不过我发现这段代码不会编译。你觉得为什么会这样?
这里有三个函数调用在工作,并且所有三个返回类型都是 Maybe<string>
。一次看一行,看看会发生什么:
-
GetStringOne
首先返回一个Maybe<string>
。目前为止,一切顺利。 -
然后
Bind
调用附加到Maybe<string>
并解压缩到一个字符串,传递给GetStringTwo
,其返回类型被弹出到一个新的Maybe
中以便安全保持。 -
下一个绑定调用展开了上一个绑定的返回类型,使其仅为
GetStringTwo
的返回类型 - 但GetStringTwo
并未返回string
,而是返回了Maybe<string>
。因此,在第二个绑定调用中,x
实际上等于Maybe<string>
,无法传递给GetStringThree
!
我可以通过直接访问存储在x
中的 Maybe 值来解决这个问题,但首先我需要将其转换为Something
。但如果不是Something
呢?如果在GetStringOne
与数据库交互时发生错误呢?如果找不到任何字符串呢?
我基本上需要一种方法来解包一个嵌套的 Maybe,但仅在它返回包含真实值的 Something 时,对其进行处理,否则我们需要匹配其不成功路径(Nothing 或 Error)。
我的做法是创建另一个 Bind 函数,与我们已经创建的其他两个并列,但这个函数专门处理嵌套的 Maybes 问题。
我会这样做:
public static Maybe<TOut> Bind<TIn, TOut>(
this Maybe<Maybe<TIn>> @this, Func<TIn, TOut> f)
{
try
{
var returnValue = @this switch
{
Something<Maybe<TIn>> s => s.Value.Bind(f),
Error<Maybe<TIn>> e => new Error<TOut>(e.ErrorMessage),
Nothing<Maybe<TIn>> => new Nothing<TOut>(),
_ => new Error<TOut>(new Exception("New Maybe state that isn't coded for!: " + @this.GetType()))
};
return returnValue;
}
catch (Exception e)
{
return new Error<TOut>(e);
}
}
我们在这里做的是获取嵌套绑定(Maybe<Maybe<string>>
)并对其调用 Bind,在回调函数中展开第一层,使我们仅剩下Maybe<string>
,从而可以在 Maybe 上执行与之前 Bind 函数中相同的逻辑。
这也需要针对async
版本完成:
public static async Task<Maybe<TOut>> BindAsync<TIn, TOut>(this Maybe<Maybe<TIn>> @this, Func<TIn, Task<TOut>> f)
{
try
{
var returnValue = await @this.Bind(async x =>
{
var updatedValue = @this switch
{
Something<TIn> s when
EqualityComparer<TIn>.Default.Equals(s.Value, default(TIn)) =>
new Something<TOut>(await f(s.Value)),
Something<TIn> _ => new Nothing<TOut>(),
Nothing<TIn> _ => new Nothing<TOut>(),
Error<TIn> e => new Error<TOut>(e.CapturedError)
}
return updatedValue;
});
return returnValue;
}
catch(Exception e)
{
return new Error<TOut>(e);
}
}
如果你希望以另一种方式思考这个过程。想想 LINQ 中的SelectMany
函数。如果你向它提供一个数组的数组 - 即多维数组,则你会得到一个单维的扁平数组。现在,处理嵌套的Maybe
对象允许我们在 Monads 中执行相同的操作。这实际上是 Monads 的一个“法则” - 任何自称为 Monad 的东西都应遵循的特性。
事实上,这使我顺利过渡到下一个主题。什么是法则,它们的用途是什么,以及我们如何确保我们的 C# Monad 也遵循它们…
法则
严格来说,要被认为是一个真正的 Monad,必须遵循一组规则(称为法则)。我将简要讨论每个法则,这样你就能自己判断你是否在看一个真正的 Monad。
左单位元法则
这表明,一个 Monad,如果给定一个函数作为其 Bind 方法的参数,将返回与直接运行函数相同且没有副作用的等效结果。
这里有一些 C#代码来演示:
Func<int, int> MultiplyByTwo = x => x * 2;
var input = 100;
var runFunctionOutput = MultiplyByTwo(input);
var monadOutput = new Something<int>(input).Bind(MultiplyByTwo);
// runFunctionOutput and monadOutput.Value should both
// be identical - 200 - to conform to the Left Identity Law.
右单位元法则
在我解释这个之前,我需要往回退几步…
首先,我需要解释一下 Functor。这些是将一个事物或一组事物从一种形式转换为另一种形式的函数。Map
、Bind
和Select
都是 Functor 的示例。
最简单的函子是 Identity 函子。Identity 是一个函数,给定一个输入,返回未更改且没有副作用的同一输入。当你组合函数时,它可能会有用。
我在此处感兴趣的唯一原因是,它是第二个 Monad 定律的基础 - 右单位元法则。这意味着当 Monad 在其 Bind 函数中给定一个 Identity Functor 时,将无副作用地返回原始值。
我可以像这样测试我上一章中创建的 Maybe:
Func<int,int> identityInt = (int x) => x;
var input = 200;
var result = new Something<int>(input).Bind(identityInt);
// result = 200
这只意味着 Maybe 接受一个不会产生错误或Null
的函数,执行它,然后准确地返回其输出,而不是其他任何内容。
这两个定律的基本要点很简单,即 Monad 不能以任何方式干预传入或传出的数据,也不能干预传递给 Bind 方法作为参数的函数的执行。Monad 只是一个管道,函数和数据通过它流动。
结合律
前两个定律应该相对琐碎,而我的 Maybe 实现同时满足这两个定律。最后一个定律,即结合律,更难以解释。
它基本上意味着嵌套的 Monad 如何并不重要,最终你总是得到一个包含值的单一 Monad。
这里是一个简单的 C# 示例:
var input = 100;
var op1 = (int x) => x * 2;
var op2 = (int x) => x + 100;
var versionOne = new Something<int>(input)
.Bind(op1)
.Bind(op2);
// versionOne.Value = 100 * 2 + 100 = 300
var versionTwo = new Something<int>(input)
.Bind(x => new Something<int>(x).Bind(op1)).Bind(op2);
// If we don't implement something to fulfill the
// associativity law, we'll end up with a type of
// Something<Something<int>>, where we want this to be
// the exact same type and value as versionOne
回顾前几节关于如何处理嵌套 Maybe 的描述“嵌套 Maybe”,你会看到这是如何实现的。
幸运的话,现在我们已经看过了三个 Monad 定律,我已经证明了我的 Maybe 是一个真正的、毫不吝啬的、诚实的 Monad。
在接下来的部分,我将向您展示另一个 Monad,您可能会用它来消除需要在各处共享的变量。
Reader
让我们想象一下,我们正在组织某种报告。它从 SQL Server 数据库中进行一系列数据拉取。
首先,我们需要获取特定用户的记录。
接下来,使用该记录,我们从我们完全虚构的书店中获取他们最近的订单⁹。
最后,我们将最近的订单记录转换为订单中的物品列表,并在报告中返回其中的一些细节。
我们想利用一种 Monad 风格的 Bind 操作,那么如何确保每个步骤中的数据与数据库连接对象一起传递?这没问题,我们可以组合一个 Tuple,并简单地传递两个对象。
public string MakeOrderReport(string userName) =>
(
Conn: this.connFactory.MakeDbConnection(),
userid
)
.Bind(x => (
x.Conn,
Customer: this.customerRepo.GetCustomer(x.Conn, x.userName)
)
.Bind(x => (
x.Conn,
Order: this.orderRepo.GetCustomerOrders(x.Conn, x.Customer.Id)
),
.Bind(x => this.Order.Items.First())
.Bind(x => string.Join("\r\n", x));
这是一个可行的解决方案,但有点丑陋。存在一些重复的步骤,仅用于在 Bind 操作之间持久化连接对象。这影响了函数的可读性。
从纯粹性方面来说,这也不纯粹。这个函数必须创建数据库连接,这是一种副作用。
还有另一种功能结构,我们可以用来解决所有这些问题 - Reader Monad。这是函数式编程对依赖注入的回答,但在函数级别而不是类级别。
在上述函数的情况下,我们希望注入 IDbConnection,以便可以在其他地方实例化它,使 MakeOrderReport 保持纯净 - 即没有任何副作用。
这里是一个非常简单的读取器的使用示例:
var reader = new Reader<int, string>(e => e.ToString());
var result = reader.Run(100);
我们在这里定义的是一个读取器,它接受一个存储但不执行的函数。该函数以其参数作为“环境”变量类型 - 这是我们将来将要注入的当前未知值,并根据该参数返回一个值,在我们的案例中是一个整数。
“int → string” 函数存储在第一行的读取器中,然后在第二行我们调用“Run”函数,该函数提供了缺失的环境变量值,这里是 100。由于环境最终已提供,因此读取器因此可以使用它来返回一个实际的值。
由于这是一个单子,这也意味着我们应该有 Bind 函数来提供一个流程。这是它们将如何被使用的方式:
var reader = new Reader<int, int>(e => e * 100)
.Bind(x => x / 50)
.Bind(x => x.ToString());
var result = reader.Run(100);
注意,“reader” 变量的类型是 Reader<int, string>
。这是因为每个 Bind 调用都在前一个函数周围放置了一个包装器,该函数具有相同的替代返回类型,但不同的参数。
在带有参数 e => e * 100
的第一行中,该函数将在稍后执行,然后运行等等...
这是读取器的一个更现实的用法:
public Customre GetCustomerData(string userName, IDbConnection db) =>
new Reader(this.customerRepo.GetCustomer(userName, x))
.Run(db);
或者,您实际上可以简单地返回读取器,并允许外部世界继续使用 Bind 调用来进一步修改它,然后再运行函数将其转换为正确的值。
public Reader<IdbConnection, User> GetCustomerData(string userName) =>
new Reader(this.customerRepo.GetCustomer(userName, x));
这种方式,同一个函数可以被多次调用,使用读取器的 Bind 函数将其转换为我想要的实际类型。
例如,如果我想获取客户的订单数据:
var dbConn = this.DbConnectionFactory.GetConnection();
var orders = this._customerRepo.GetCustomerData("simon.painter")
.Bind(X => x.OrderData.ToArray())
.Run(dbConn);
想象一下,您正在创建一个只能通过插入正确类型的变量来打开的盒子。
使用读取器还意味着可以轻松地将模拟的 IDbConnection 注入到这些函数中,并基于它们编写单元测试。
根据您希望如何构建代码的方式,甚至可以考虑在接口上公开读取器。它不必像传递进来的 DbConnection 那样成为一个依赖项,它可以是数据库表的 Id 值,或者任何你喜欢的东西。也许是这样:
public interface IDataStore
{
Reader<int,Customer> GetCustomerData();
Reader<Guid,Product> GetProductData();
Reader<int,IEnumerable<Order>> GetCustomerOrders();
}
有各种各样的方法可以使用这个,这完全取决于适合你的是什么,以及你试图做什么。
在下一节中,我将展示这个想法的变种 - 状态单子。
状态
原则上,状态单子与读取器非常相似。定义了一个容器,它需要某种形式的状态对象将自身转换为一个正确的最终数据。绑定可用于提供额外的数据转换,但直到提供状态之前什么都不会发生。
它与众不同的两个方面是:
-
不再是“环境”类型,而是称为“状态”类型。
-
在 Bind 操作之间传递的不是一个而是两个项。
在 Reader 中,原始的 Environment 类型仅在绑定链的开头可见。使用 State 单子,它一直持续到最后。状态类型及其当前值设置为一个元组,从一个步骤传递到下一个。值和状态都可以替换为新值。值可以更改类型,但状态在整个过程中是一个单一类型,如果需要,可以更新其值。
你还可以随意提取或替换状态单子中的状态对象,使用函数随时。
我的实现并不严格遵循你在 Haskell 等语言中看到的方式,但我认为这种类型的实现在 C# 中很麻烦,我不确定这样做有什么意义。我在这里展示的版本在日常 C# 编码中可能会有所用处。
public class State<TS, TV>
{
public TS CurrentState { get; init; }
public TV CurrentValue { get; init; }
public State(TS s, TV v)
{
CurrentValue = v;
CurrentState = s;
}
}
State 单子没有多个状态,因此不需要一个基础抽象类。它只是一个简单的类,有两个属性 - 一个值和一个状态(即我们将通过每个实例传递的东西)。
逻辑必须实现为扩展方法:
public static class StateMonadExtensions
{
public static State<TS, TV> ToState<TS, TV>(this TS @this, TV value) =>
new(@this, value);
public static State<TS, TV> Update<TS, TV>(
this State<TS, TV> @this,
Func<TS, TS> f
) => new(f(@this.CurrentState), @this.CurrentValue);
}
通常情况下,实现代码不多,但有很多有趣的效果。
这是我使用它的方式:
public IEnumerable<Order> MakeOrderReport(string userName) =>
this.connFactory.MakeDbConnection().ToState(userName)
.Bind((s, x) => this.customerRepo.GetCustomer(s, x))
.Bind((s, x) => this.orderRepo.GetCustomreOrders(s, x.Id))
这里的想法是,状态对象作为 s 传递到链中,最后一个 Bind 的结果作为 x 传递进来。基于这两个值,你可以确定下一个值应该是什么。
这仅留下了更新当前状态的功能。我会用这个扩展方法来实现:
public static State<TS, TV>Update<TS,TV>(
this State<TS,TV> @this,
Func<TS, TS> f
) => new(@this.CurrentState, f(@this.CurrentState));
为了说明的简单例子,这里是一个简单的例子:
var result = 10.ToState(10)
.Bind((s, x) => s * x)
.Bind((s, x) => x - s) // s=10, x = 90
.Update(s => s - 5) // s=5, x = 90
.Bind((s, x) => x / 5); // s=5, x = 18
使用这种方法,你可以有箭头函数,带有几个状态位,这些状态位将从一个 Bind 操作流到下一个,需要时甚至可以更新。它可以防止你被迫将整洁的箭头函数转换为带大括号的完整函数,或者通过每个 Bind 传递一个大而笨重的只读数据元组。
这个实现已经去掉了你在 Haskell 中会找到的形式,即只有在定义完整的绑定链时才传递初始状态值,但我认为在 C# 上下文中,这个版本更有用,而且编码起来更容易!
也许一个状态?
你可能会注意到在上一个代码示例中,并没有使用 Bind 函数的功能,比如 Maybe,来捕获错误条件和返回的空结果在其中一个或多个可能状态中。是否可以将 Maybe 和 Reader 合并为一个单子,既持久化一个状态对象 又 处理错误?
是的,有几种方法可以实现它,具体取决于你打算如何使用它。我将展示我首选的解决方案。首先,我会调整 State 类,以便它不再存储一个值,而是存储一个 Maybe 包含的值。
public class State<TS, TV>
{
public TS CurrentState { get; init; }
public Maybe<TV> CurrentValue { get; init; }
public State(TS s, TV v)
{
CurrentValue = new Something<TV>(v);
CurrentState = s;
}
}
}
然后我会调整 Bind 函数,以考虑 Maybe,但不改变函数的签名:
public static State<TS, TNew> Bind<TS, TOld, TNew>(
this State<TS, TOld> @this, Func<TS, TOld, TNew> f) =>
new(@this.CurrentState, @this.CurrentValue.Bind(x => f(@this.CurrentState, x)));
使用方法几乎完全相同,只是现在 Value 的类型是 Maybe
public Maybe<IEnumerable<order>> MakeOrderReport(string userName) =>
this.connFactory.MakeDbConnection().ToState(userName)
.Bind((s, x) => this.customerRepo.GetCustomer(s, x)
.Bind((s, x) => this.orderRepo.GetCustomerOrders(s, x.Id))
是否希望以这种方式合并 Maybe 和 State Monad 的概念,或者更愿意将它们分开,完全取决于您。
如果您遵循这种方法,您只需要确保在某个时候使用 Switch 表达式将 Maybe 转换为单一的具体值。
还有一件事也要记住 - State Monad 的 CurrentValue 对象不一定是数据,它也可以是一个 Func
委托,允许您在 Bind 调用之间传递一些功能性。
在接下来的部分中,我将探讨在 C# 中可能已经在使用的其他单子。
您已经在使用的示例
信不信由你,如果您已经使用 C# 工作了一段时间,那么您很可能已经在使用单子。让我们看看一些例子。
Enumerable
如果 Enumerable 不是一个单子,那么它几乎是最接近的了,至少一旦我们调用 LINQ - 正如我们已经知道的那样,LINQ 是基于函数编程概念开发的。
Enumerable 的 Select
方法在可枚举对象中操作各个元素,但它仍然遵循左恒等法则:
var op = x => x * 2;
var input = new [] { 100 };
var enumerableResult = input.Select(op);
var directResult = new [] { op(input.First()) };
// both equal the same value - { 200 }
和右恒等法则:
var op = x => x;
var input = new [] { 100 };
var enumerableResult = input.Select(op);
var directResult = new [] { op(input.First()) };
// both equal the same value - { 100 }
那么仅剩下结合律 - 它仍然是被认为是真正的单子所必需的。Enumerable 遵循这一法则吗?当然是的。通过使用 SelectMany。
考虑一下这个:
var createEnumerable = (int x) => Enumerable.Range(0, x);
var input = new [] { 100, 200 }
var output = input.SelectMany(createEnumerable);
// output = single dimension array with 300 elements
在这里,我们有嵌套的可枚举对象作为单个可枚举对象输出。这就是结合律。因此,QED,等等。是的。可枚举对象是单子。
Task
那么任务呢?它们也是单子吗?我敢打赌,如果你已经使用 C# 一段时间,它们绝对是单子,而且我可以证明。
让我们再次审视这些法则。
左恒等法则,使用任务的函数调用应该与直接调用函数调用匹配。证明这一点稍微有些棘手,因为异步方法始终返回 Task
或 Task<T>
类型 - 这在许多方面与 Maybe<T>
相同,如果你仔细想想的话。它是围绕可能或可能不会解析为实际数据的类型的包装器。但是,如果我们回到抽象层次,我认为我们仍然可以证明法则是遵守的:
public Func<int> op = x => x * 2;
public async Task<int> asyncOp(int x) => await Task.FromResult(op(x));
var taskResult = await asyncOp(100);
var nonTaskResult = op(100);
// The result is the same - 200
我并不是说这是我必须引以为豪的 C# 代码,但至少它确实证明了一个观点,即无论我是通过异步包装方法调用还是直接调用 op
,结果都是相同的。这就是左恒等法则的确认。那么右恒等法则呢?老实说,这几乎是相同的代码:
// Notice the function is simply returning x back again unchanged this time.
public Func<int> op = x => x;
public async Task<int> asyncOp(int x) => await Task.FromResult(op(x));
var taskResult = await asyncOp(100);
var nonTaskResult = op(100);
// The result is the same as the initial input - 100
这就是恒等法则的解决方案。那么同样重要的结合律呢?信不信由你,我们可以用任务来演示这一点。
async Task<int> op1(int x) => await Task.FromResult(10 * x);
async Task<int> pp2() => await Task.FromResult(100);
var result = await op1(await pp2());
// result = 1,000
在这里,我们有一个将 Task<int>
作为参数传递给另一个 Task<int>
,但是通过嵌套调用 await
,可以将其平铺为一个简单的 int
- 这才是实际的结果类型。
希望我赢得了我的啤酒?我的是一品脱¹¹,请。欧式风格的半升也可以。
其他结构
诚实地说——如果你对我的 Maybe 单子版本满意,并且不介意深入进一步,那么请随意跳到下一章节。你可以仅通过 Maybe 轻松实现你可能想要实现的大多数功能。我将描述一些存在于更广泛函数编程语言世界中的其他单子类型,你可能想考虑在 C# 中实现它们。
如果你打算从 C# 中挤出最后几个非函数式代码的残余,那么这些单子可能会引起你的兴趣。它们也可能会从理论的角度引起兴趣。然而,如果你想进一步探讨 Monad 概念并继续实现这些内容,完全取决于你。
现在,严格来说,我在本章和上一章中一直在构建的 Maybe 单子版本是两种不同单子的混合。
一个真正的 Maybe 单子只有两种状态——Something(或 Just)和 Nothing(或 Empty)。就是这样。用于处理错误状态的单子是 Either(也称为 Result)单子。它有两种状态——Left 和 Right。
Right 是“幸福”路径,其中每个传递给 Bind 命令的函数都有效,一切都是正确的。
Left 是“不幸”路径,表示发生了某种错误,而该错误包含在 Left 中。
左右命名惯例可能源自许多文化中的一个重要概念,即左手是邪恶的,右手是善良的。这甚至在我们的语言中也有所体现——拉丁语中“left”的字面意思就是“Sinister(邪恶)”。然而,在这些启蒙时代,我们不再驱逐左撇子¹²离开家园,或者无论他们以前做过什么。
我不会在这里详细描述这个实现,你可以通过采用我版本的 Maybe 并移除“Nothing”类基本实现它。
同样,你可以通过移除错误类(Error class)来创建一个真正的 Maybe——尽管我认为通过将两者合并成一个单一实体,你可以处理与外部资源交互时可能遇到的几乎所有情况。
我的方法是纯粹和完全正确的经典函数理论吗?不是。它在生产代码中有用吗?100% 是。
除了 Maybe 和 Either 之外,还有许多单子,如果你转向像 Haskell 这样的编程语言,你很可能会经常使用它们。以下是一些例子:
-
Identity — 一个单子,简单地返回你输入的任何值。当深入学习更纯粹函数理论的更深层时,这些单子有其用处,但在 C# 中并没有真正的应用场景。
-
State - 用于对一个值运行一系列操作。与
Bind
方法有些类似,但也有一个状态对象被传递,作为额外的对象用于计算。在 C#中,我们可以使用 LINQ 的Aggregate
函数,或者使用 Maybe 的Bind
函数与元组或类似的结构一起传递必要的状态对象。 -
IO - 用于允许与外部资源交互而不引入不纯的函数。在 C#中,我们可以遵循控制反转模式(即依赖注入)来解决测试等问题。
-
环境 - 在 Haskell 中被称为 Reader 单子。通常用于像日志记录这样的过程,以封装掉副作用。如果你试图确保你的语言正在强制执行函数式编程的严格规则,这是有用的,但我在 C#中看不到任何好处。
正如你从上面的列表中看到的,函数式编程世界中有许多其他的单子,但我认为大多数或全部都对我们没有实际好处。归根结底,C#是一种混合的函数式/面向对象语言。它已经扩展到支持函数式编程概念,但它永远不会成为纯函数式语言,也没有尝试将其视为这样的好处。
我强烈建议尝试使用 Maybe/Either 单子,但除此之外,我实在不会打扰,除非你对在 C#中的函数式编程的想法有兴趣,看看你能推动这个想法到什么程度¹³。不过,这不适用于你的生产环境。
在最后一节中,我将提供一个完整的示例,展示如何在应用程序中使用单子。
一个示例
好的,我们开始吧。让我们把所有内容整合到一个伟大而史诗般的单子功能堆中。我们在本章的示例中已经谈论过假期,所以这次我要集中讨论我们实际上如何去机场 - 这将需要一系列的查找、数据转换,所有这些通常需要错误处理和分支逻辑,如果我们要遵循更传统的面向对象方法。我希望您会同意,使用函数式编程技术和单子,看起来要优雅得多。
首先,我们需要我们的接口。我实际上不会编写每一个我们的代码所需的依赖项,所以我只是定义我们将需要的接口:
public interface IMappingSystem
{
Maybe<Address> GetAddress(Location l);
}
public interface IRoutePlanner
{
Task<Maybe<Route>> DetermineRoute(Address a, Address b);
}
public interface ITrafficMonitor
{
Maybe<TrafficAdvice> GetAdvice(Route r);
}
public interface IPricingCalculator
{
decimal PriceRoute(Route r);
}
做完这些,我将编写代码来消耗它们。我想象中的具体场景是这样的 - 这是不久的将来。无人驾驶汽车已经成为一种事物。以至于大多数人不再拥有个人车辆,而只需使用手机上的应用程序从自动驾驶汽车的云中直接将车辆带到他们的家中¹⁴。
这个过程大致如下:
-
最初的输入是用户提供的起始位置和目的地。
-
需要查找映射系统中的每个位置,并转换为正确的地址。
-
需要从内部数据存储中获取用户的账户。
-
路由需要通过交通服务进行检查。
-
必须调用定价服务来确定旅程的费用。
-
价格返回给用户。
在代码中,这个过程可能看起来像这样:
public Maybe<decimal> DeterminePrice(Location from, Location to)
{
var addresses = this.mapping.GetAddress(from).Bind(x =>
(From: x,
To: this.mapping.GetAddress(to)));
var route = await addresses.BindAsync (async x => await this.router.DetermineRoute(x.From, x,To));
var trafficInfo = route.Bind(x => this.trafficAdvisor.GetAdvice(x));
var hasRoadWorks = trafficInfo is Something<TrafficAdvice> s &&
s.Value.RoadworksOnRoute;
var price = route.Bind(x => this.pricing.PriceRoute(x));
var finalPrice = route.Bind(x => hasRoadWorks ? x *= 1.1 : x);
return finalPrice;
}
这样更容易,不是吗?在我结束本章之前,我想解释一下代码示例中发生的一些细节。
首先,这里没有任何错误处理。任何这些外部依赖可能导致错误被抛出,或者在它们各自的数据存储中找不到详细信息。Monad Bind
函数处理所有这些逻辑。例如,如果路由器无法确定路由(可能发生网络错误),那么此时 Maybe 将被设置为 Error<Route>
,并且不会执行后续操作。最终的返回类型将是 Error<decimal>
,因为 Error
类在每一步都会重新创建,但实际的 Exception
在实例之间传递。外部世界负责处理最终返回的值,直到那时为止。
如果我们按照面向对象的方法编写此代码,那么该函数很可能会长两到三倍,以包括 Try/Catch
块和针对每个对象的检查以确认它们是否有效。
我在需要建立一组输入的情况下使用了元组。对于地址对象的情况,这意味着如果第一个地址找不到,那么不会尝试查找第二个地址。这也意味着第二个函数所需的两个输入都在一个地方可用,然后我们可以使用另一个 Bind
调用来访问它们(假设地址查找返回了一个真实的值)。
最后几个步骤实际上并不涉及对外部依赖的调用,但通过继续使用 Bind
函数,可以假定其参数 Lambda 表达式内部有一个真实值可用,因为如果没有,Lambda 就不会被执行。
至此,我们几乎有一个完全功能的 C# 代码片段了。希望你喜欢。
结论
在本章中,我们探讨了一个可怕的函数式编程概念,已经众所周知,它使成年开发人员在他们廉价的鞋子上颤抖。一切顺利的话,这对你来说应该不再是一个谜。
我已经向你展示了如何:
-
大幅减少所需代码量。
-
介绍一个隐式错误处理系统。
所有都使用 Maybe Monad,以及如何自己创建一个。
在下一章中,我将简要讨论柯里化的概念。我们在下一页见。
¹ 我不知道为什么会使用那个名字。真的不知道。尽管如此,这是相当普遍的,也是一种标准。请稍作等待。
² 我会说非空值,但整数默认为 0,布尔值默认为 false。
³ 查看这里阅读文章,这绝对值得您花时间:https://fsharpforfunandprofit.com/rop/
⁴ 或活着的薛定谔的猫。实际上,这是安全的选择吗?我养过猫,我知道它们是什么样子!
⁵ 最好是芝士蛋糕。保罗·荷莱伍德会很失望地得知我不喜欢很多种蛋糕,但纽约风格的芝士蛋糕绝对是我喜欢的一种!
⁶ 您可以在这里阅读更多信息:https://automapper.org/ 如果在您的代码中经常需要快速简单地在类型之间进行转换,它是非常有用的工具。
⁷ 不是一种茶!也不是《龙珠》中的一个猪角色。
⁸ 除了布尔值,默认为 false,和字符值默认为\0。
⁹ 我是虚构的所有者,你可以是虚构的帮助客户找到他们想要的东西的人。我真是慷慨呢,不是吗?
¹⁰ 我喜欢黑色艾尔啤酒和欧式风格的拉格啤酒。他们在明尼苏达州酿造的浓烈的东西也非常棒。
¹¹ 那是 568 毫升。我知道其他国家对这个词有不同的定义。
¹² 其中包括我的兄弟。嗨,马克 - 你在一本编程书中被提到了!我想知道你是否会知道这件事?
¹³ C# 而不是.NET 的普遍性 - F# 也是.NET。
¹⁴ 对我来说这听起来都很棒,我不太喜欢开车,而且如果我是乘客,我会非常晕车。我非常欢迎我们的无人驾驶汽车统治者。
第八章:Currying 和 部分应用
Currying 和 部分应用 是从古老数学论文中直接衍生出来的两个更多的函数式概念。前者与印度食物毫不相关(尽管它确实很美味)¹,事实上,它是以卓越的美国数学家 Haskell Brooks Curry 命名的,他命名了不少于三种编程语言²。
Currying 源自 Haskell Curry 在组合逻辑上的工作,这为现代函数式编程提供了一个基础之一。我不会给出干燥的正式定义,我会通过例子来解释。这是一个有点像 C# 伪代码的加法函数示例:
public interface ICurriedFunctions
{
decimal Add(decimal a, decimal b);
}
var curry = // some logic for obtaining an implementation of the interface
var answer = curry.Add(100, 200);
在这个例子中,我们期望 answer 简单地是 300(即 100+200),这确实是它会得到的结果。
不过,如果我只提供一个参数呢?像这样:
public interface ICurriedFunctions
{
decimal Add(decimal a, decimal b);
}
var curry = // some logic for obtaining an implementation of the interface
var answer = curry.Add(100); // What could it be?
在这种情况下,如果这是一个假设的柯里化函数,你认为 answer 会返回什么?
在函数式编程中,我制定了一个经验法则 - 如果有一个问题,答案可能是“函数”。这在这里是适用的情况。
如果这是一个柯里化函数,那么 answer 变量将是一个函数。它将是原始 Add
函数的修改版本,但现在第一个参数已经固定为值 100 - 实际上这是一个新函数,它将 100 加到你提供的任何值上。
你可以像这样使用它:
public interface ICurriedFunctions
{
decimal Add(decimal a, decimal b);
}
var curry = // some logic for obtaining an implementation of the interface
var add100 = curry.Add(100); // Func<decimal,decimal>, adds 100 to the input
var answerA = add100(200); // 300 -> 200+100
var answerB = add100(0); // 100 -> 0+100
var answerC = add100(900); // 1000 -> 900+100
基本上,这是一种从具有多个参数的函数开始,并从中创建多个更具体版本的方法。一个单一的基本函数可以成为许多不同的函数。如果你愿意的话,可以将它与 OO 概念的继承进行比较?但实际上它与继承完全不一样。实际上只有一个具有任何逻辑的基本函数 - 其余实际上是指向该基本函数的带参数的指针,准备向其输入。
不过,Currying 究竟有什么用?你怎么使用它?
让我解释一下……
Currying 和大型函数
在上面我提供的“添加”示例中,我们只有一对参数,因此在可能进行 Currying 时,我们只有两种可能的处理方式:
-
提供第一个参数,获取一个函数
-
提供两个参数并获得一个值
Currying 如何处理具有超过 2 个基本参数的函数?为此,我将使用一个简单的 CSV 解析器的示例 - 即一个接收 CSV 文本文件,按行分割记录,然后使用一些分隔符(通常是逗号)再次分割记录内的各个属性。
让我们想象我写了一个用于加载一批书籍数据的解析器函数:
// Input in the format:
//
//title,author,publicationDate
//The Hitch-Hiker's Guide to the Galaxy,Douglas Adams,1979
//Dimension of Miracles,Robert Sheckley,1968
//The Stainless Steel Rat,Harry Harrison,1957
//The Unorthodox Engineers,Colin Kapp,1979
public IEnumerable<Book> ParseBooks(string fileName) =>
File.ReadAllText(fileName)
.Split("\r\n")
.Skip(1) // Skip the header
.Select(x => x.split(",").ToArray())
.Select(x => new Book
{
Title = x[0],
Author = x[1],
PublicationDate = x[2]
});
var bookData = parseBooks("books.csv");
这一切看起来都很好,但接下来的两组书籍有不同的格式。Books2.csv 使用管道符号而不是逗号分隔字段,而 Books3.csv 来自 Linux 环境,其行结束符是 "\n" 而不是 Windows 风格的 "\r\n"。
我们可以通过创建三个几乎相同的函数来解决这个问题。不过,我不喜欢不必要的复制,因为这会给未来想要维护代码库的开发人员带来太多问题。
一个更合理的解决方案是为可能发生变化的每一项添加参数,就像这样:
public IEnumerable<Book> ParseBooks(
string lineBreak,
bool skipHeader,
string fieldDelimiter,
string fileName
) =>
File.ReadAllText(fileName)
.Split(lineBreak)
.Skip(skipHeader ? 1 : 0)
.Select(x => x.split(fieldDelimiter).ToArray())
.Select(x => new Book
{
Title = x[0],
Author = x[1],
PublicationDate = x[2]
});
var bookData = ParseBooks(Environment.NewLine, true, ",", "books.csv");
现在,如果我想按照非函数化的方法使用这个函数,我将不得不填写每个可能的 CSV 文件风格的所有参数,就像这样:
var bookData1 = ParseBooks(Environment.NewLine, true, ",", "books.csv");
var bookData2 = ParseBooks(Environment.NewLine, true, "|", "books2.csv");
var bookData3 = ParseBooks("\n", false, ",", "books3.csv");
实际上,柯里化意味着逐个提供参数。对柯里化函数的任何调用都会产生一个新函数,该新函数的参数少一个,或者如果所有基本函数的参数都已提供,则产生一个具体值。
来自前一个代码示例中已提供参数的调用可以替换成这样:
// First some magic that curries the parseBooks function
// I'll look into implementation details later, let's just
// understand the theory for now.
var curriedParseBooks = ParseBooks.Curry();
// these two have 3 parameters - string, string, string
var parseSkipHeader = curriedParseBooks(true);
var parseNoHeader = curriedParseBooks(false);
// 2 parameters
var parseSkipHeaderEnvNl = parseSkipHeader(Environment.NewLine);
var parseNoHeaderLinux = parseNoHeader("\n");
// 1 parameter each
var parseSkipHeaderEnvNlCommarDel = parseSkipHeaderEnvNl(",");
var parseSkipHeaderEnvNlPipeDel = parseSkipHeaderEnvNl("|");
var parseNoHeaderLinuxCommarDel = parseNoHeaderLinux(",");
// Actual data, Enumerables of Book data
var bookData1 = parseSkipHeaderEnvNlCommarDel("books.csv");
var bookData2 = parseSkipHeaderEnvNlPipeDel("books2.csv");
var bookData3 = parseNoHeaderLinuxCommarDel("books3.csv");
关键在于,柯里化将具有 X 个参数的函数转变为 X 个函数的序列,每个函数只有一个参数 - 最后一个函数返回最终结果。
如果你真的非常想的话,你甚至可以像上面那样写出这些函数调用!
var bookData1 = parseBooks(true)(Environment.NewLine)(",")("books.csv")
var bookData2 = parseBooks(true)(Environment.NewLine)("|")("books2.csv")
var bookData3 = parseBooks(true)("\n")(",")("books3.csv")
函数柯里化的第一个示例的要点是,我们逐步构建一个超特定版本的函数,它只接受文件名作为参数。除此之外,我们还将所有中间版本存储起来,以便在构建其他函数时重复使用。
我们实际上在这里做的是像用乐高积木搭建墙壁一样构建函数,其中每个积木都是一个函数。或者,如果你想从另一个角度来考虑,这是一个函数家族树,每个阶段的选择都导致家族中的一个分支:
图 8-1. A 解析书籍函数的家族树
另一个可能在实际中有用的例子是将日志函数分割为多个更具体的函数:
// For the sake of this exercise, the parameters are
// an enum (log type - warning, error, info, etc.) and a string
// containing a message to store in the log file
var logger = getLoggerFunction()
var curriedLogger = logger.Curry();
var logInfo = curriedLogger(LogLevel.Info);
var logWarning = curriedLogger(LogLevel.Warning);
var logError = curriedLogger(LogLevel.Error);
// You'd use them then, like this:
logInfo("This currying lark works a treat!");
这种方法有几个有用的特性:
-
事实上,我们最终只创建了一个单一的函数,但是从这个函数中,我们设法创建了至少 3 个可用的变体,这些变体可以传递并且只需一个文件名即可使用。这将代码复用提升到了一个新的水平!
-
还有所有中间函数也是可用的。这些函数可以直接使用,也可以用作创建额外新函数的起点。
在 C# 中,柯里化还有另一个用途。我将在下一节中讨论这个问题。
柯里化和高阶函数
如果我想用柯里化来创建几个函数来在摄氏度和华氏度之间转换,我会从像这样的柯里化基本算术操作开始:
// once again, the Currying process is just magic for now.
// Keep reading for the implementation
var add = ((x,y) => x + y).Curry();
var subtract = ((x,y) => y - x).Curry();
var multiply = ((x,y) => x * y).Curry();
var divide = ((x,y) => y / x).Curry();
使用这个方法,再加上前一章的映射函数,我们可以创建一组相当简洁的函数定义:
var celsiusToFahrenheit = x =>
x.Map(multiply(9))
.Map(divide(5))
.Map(add(32));
var fahrenheitToCelsius = x=>
x.Map(subtract(32))
.Map(multiply(5))
.Map(divide(9));
是否发现任何有用的内容很大程度上取决于用例 - 你实际上想要实现什么以及柯里化是否适用于它。
现在你可以在 C# 中使用它,就像你看到的那样。只要我们能找到在 C# 中实现它的方法……
在 .NET 中进行柯里化
所以,重要的问题是:更多基于函数式的语言可以在代码库中所有函数中本地实现这一点,那么在 .NET 中我们能做类似的事情吗?
简短的答案是差不多吧。
更长的答案是是的,有点。虽然不像在函数式语言(例如 F#)中那样优雅,那里这些功能都是开箱即用的。我们需要硬编码、创建静态类,或者在语言中稍微蹒跚并跳过一些 hoops。
硬编码的方法假设你只会以柯里化的方式使用函数,就像这样:
var Add = (decimal x) => (decimal y) => x + y;
var Subtract = (decimal x) => (decimal y) => y - x;
var Multiply = (decimal x) => (decimal y) => x * y;
var Divide = (decimal x) => (decimal y) => y / x;
注意每个函数中有两组箭头,意味着我们定义了一个返回另一个 Func
委托的 Func
委托 - 即实际类型是 Func<decimal, Func<decimal, decimal>>
。只要你使用的是 C# 10 或更高版本,你就能利用 var
关键字隐式地获取类型,就像上面的例子一样。较旧版本的 C# 可能需要在代码示例中显式声明委托的类型。
第二个选项是创建一个静态类,可以在代码库中的任何地方引用。你可以随意命名它,但我选择用 F
来表示函数式。
public static class CurryingExtensions
{
public static Func<T1, Func<T2, TOut>> Curry<T1, T2, TOut>(
Func<T1, T2, TOut> functionToCurry) =>
(T1 x) => (T2 y) => functionToCurry(x, y);
public static Func<T1, Func<T2, Func<T3, TOut>>> Curry<T1, T2, T3, TOut>(
Func<T1, T2, T3, TOut> functionToCurry) =>
(T1 x) => (T2 y) => (T3 z) => functionToCurry(x, y, z);
public static Func<T1, Func<T2, Func<T3, Func<T4, TOut>>>> Curry<T1, T2, T3, T4, TOut>(
Func<T1, T2, T3, T4, TOut> functionToCurry) =>
(T1 x) => (T2 y) => (T3 z) => (T4 a) => functionToCurry(x, y, z, a);
}
这实际上在调用被柯里化的最终函数和使用它的代码区域之间放置了多层 Func
委托。
这种方法的缺点是,我们必须为每种可能的参数数量创建一个柯里化方法。我的示例涵盖了具有 2、3 或 4 个参数的函数。具有更多参数的函数需要构建另一个 Curry 方法,根据相同的公式。
另一个问题是,Visual Studio 无法隐式确定传入的函数的类型,因此有必要在调用 F.Curry 时定义被柯里化的函数,并声明每个参数的类型,就像这样:
var Add = F.Curry((decimal x, decimal y) => x + y);
var Subtract = F.Curry((decimal x, decimal y) => y - x);
var Multiply = F.Curry((decimal x, decimal y) => x * y);
var Divide = F.Curry((decimal y, decimal y) => y / x);
最后一个选项 - 也是我更喜欢的选项 - 是使用扩展方法来减少必需的样板代码。对于具有 2、3 和 4 个参数的函数,定义如下:
public static class Ext
{
public static Func<T1,Func<T2, T3>> Curry<T1,T2,T3>(
this Func<T1,T2,T3> @this) =>
(T1 x) => (T2 y) => @this(x, y);
public static Func<T1,Func<T2,Func<T3,T4>>>Curry<T1,T2,T3,T4>(
this Func<T1,T2,T3,T4> @this) =>
(T1 x) => (T2 y) => (T3 z) => @this(x, y, z);
public static Func<T1,Func<T2,Func<T3,Func<T4,T5>>>>Curry<T1,T2,T3,T4,T5>(
this Func<T1,T2,T3,T4,T5> @this) =>
(T1 x) => (T2 y) => (T3 z) => (T4 a) => @this(x, y, z, a);
}
那是一个相当丑陋的代码块,不是吗?好消息是,你可以把它放在代码库的深处,并且大部分时间都可以忘记它的存在。
使用方式如下:
// specifically define the function on one line
// it has to be stored as a `Func` delegate, rather than a
// Lambda expression
var Add = (decimal x, decimal y) => x + y;
var CurriedAdd = Add.Curry();
var add10 = CurriedAdd(10);
var answer = add10(100);
// answer = 110
那就是柯里化。你们中眼尖的可能已经注意到,这一章被称为“柯里化和部分应用”。
什么是部分应用?嗯……既然你这么客气地问了……
部分应用
部分应用的工作原理与柯里化非常类似,但它们之间有微妙的区别。这两个术语经常被误用,互换使用。
Currying 专门 处理将带有一组参数的函数转换为一系列连续的函数调用,每个调用只有一个参数(技术术语是 一元 函数)。
部分应用,另一方面,允许您一次应用尽可能多的参数。有数据出现如果所有的参数都填写完毕。
返回到我之前的解析函数示例,这些是我们正在处理的格式:
-
book1 - Windows 行结束,头部,字段用逗号
-
book2 - Windows 行结束,头部,字段用管道符号
-
book3 - Linux 行结束,没有头部,字段用逗号
使用柯里化方法,我们为设置 Book3 的每个参数创建了中间步骤,即使它们最终只用于每个参数的唯一用途。我们还为 book1 和 book2 的 SkipHeader 和行结束参数做了同样的事情,尽管它们是相同的。
可以这样做来节省空间:
var curriedParseBooks = parseBooks.Curry();
var parseNoHeaderLinuxCommaDel = curriedParseBooks(false)("\n")(",");
var parseWindowsHeader = curriedParseBooks(true)(Environment.NewLine);
var parseWindowsHeaderComma = parseWindowsHeader(",");
var parseWindowsHeaderPipe = parseWindowsHeader("|");
// Actual data, Enumerables of Book data
var bookData1 = parseWindowsHeaderComma("books.csv");
var bookData2 = parseWindowsHeaderPipe("books2.csv");
var bookData3 = parseNoHeaderLinuxCommaDel("books3.csv");
但是,如果我们能够简洁地使用部分应用来应用这 2 个参数,那就更清晰了。
// I'm using an extension method called Partial to apply
// parameters. Check out the next section for implementation details
var parseNoHeaderLinuxCommarDel = ParseBooks.Partial(false,"\n",",");
var parseWindowsHeader =
curriedParseBooks.Partial(true,Environment.NewLine);
var parseWindowsHeaderComma = parseWindowsHeader.Partial(",");
var parseWindowsHeaderPipe = parseWindowsHeader.Partial("|");
// Actual data, Enumerables of Book data
var bookData1 = parseWindowsHeaderComma("books.csv");
var bookData2 = parseWindowsHeaderPipe("books2.csv");
var bookData3 = parseNoHeaderLinuxCommarDel("books3.csv");
我认为这是一个相当优雅的解决方案,它仍然允许我们在需要时具有可重用的中间函数,但仍然只有一个基本函数。
在下一节中,我将向您展示如何实际实现这一点。
.NET 中的部分应用
这是个坏消息。没有任何一种方式可以优雅地在 C# 中实现部分应用。您需要做的是为每个参数组合创建一个扩展方法。
在我刚刚给出的示例中,我需要:
-
4 个参数变成 1 个参数,用于
parseNoHeaderLinuxCommaDel
-
4 个参数变成 2 个参数,用于
parseWindowsHeader
-
2 个参数变成 1 个参数,用于
parseWindowsHeaderComma
和parseWindowsHeaderPipe
这是每个示例将会是什么样子:
public static class PartialApplicationExtensions
{
// 4 parameters to 1
public static Func<T4,TOut> Partial<T1,T2,T3,T4,TOut>(
this Func<T1,T2,T3,T4,TOut> f,
T1 one, T2 two, T3 three) => (T4 four) => f(one, two, three, four);
// 4 parameters to 2
public static Func<T3,T4,TOut>Partial<T1,T2,T3,T4,TOut>(
this Func<T1,T2,T3,T4,TOut> f,
T1 one, T2 two) => (T3 three, T4 four) => f(one, two, three, four);
// 2 parameters to 1
public static Func<T2, TOut> Partial<T1,T2,TOut>(
this Func<T1,T2,TOut> f, T1 one) =>
(T2 two) => f(one, two);
}
如果您决定部分应用是一种您想要追求的技术,那么您可以根据需要将部分方法添加到代码库中,或者将一段时间留出来创建您可能需要的尽可能多的部分方法。
结论
Currying 和部分应用是函数式编程中两个强大且相关的概念。不幸的是,它们在 C# 中并不原生支持,并且不太可能会支持。
它们可以通过使用静态类或扩展方法来实现,这为代码库增加了一些样板代码 - 这有些讽刺,考虑到这些技术部分是为了减少样板。
鉴于 C# 不像 F# 和其他函数式语言那样完全支持高阶函数。C# 不能像转换为 Func
委托那样传递函数。
即使函数被转换为 Func
,Roslyn 编译器也不能始终正确确定参数类型。
在 C#领域,这些技术可能永远不如其他语言那么有用。尽管如此,它们在减少模板代码和实现比其他方式更高的代码可重用性方面仍然有其用处。
是否使用它们是个人偏好的问题。我不认为它们对于功能型 C#是必需的,但还是值得探索的。
在我们的下一章中,我们将探索在功能型 C#中无限循环的更深层奥秘,以及什么是尾递归调用。
¹ 美食提示:如果你曾经来到孟买,一定要尝试在 Shivaji Park 的 Tibb’s Frankie,你不会后悔的!
² 显然是 Haskell,但还有 Brook 和 Curry 这两种不那么出名的语言。
第九章:无限循环
我们在前几章中看到函数式编程如何用 LINQ 函数如Select
或Aggregate
替换For
和ForEach
循环。这绝对是太棒了 - 前提是您正在处理固定长度的数组或者一个Enumerable
,它会自行决定何时结束迭代。
如果你根本不确定你想要迭代多久怎么办?如果你一直迭代直到满足某个条件呢?
这里有一个非功能性代码的例子,展示了我所说的那种事情,基于 Monopoly 棋盘游戏的松散基础。想象一下,你被关在监狱里,你这个淘气鬼!有以下几种方法可以让你脱困:
-
以您所在的货币支付 50(如果您在美国,则为 50 美元,或者在英国的我这里为 50 英镑¹)
-
掷一个双子
-
使用一个“出狱卡”
在真实的 Monopoly 游戏中,还需要考虑其他玩家的回合,但我简化为无限循环,直到满足其中一个条件。如果我真的这么做了,我可能会在这里添加一些验证逻辑,但再次强调,我要保持简单。
var inJail = true;
var inventory = getInventory();
var rnd = getRandomNumberGenerator();
while(inJail)
{
var playerAction = getAction();
if(playerAction == Actions.PayFine)
{
inventory.Money -= 50;
inJail = false;
}
else if(playerAction == Actions.GetOutOfJailFree)
{
inventory.GetOutOfJailFree -= 1;
inJail = false;
}
else if(playerAction == Actions.RollDice)
{
var dieOne = rnd.Random(1, 6);
var dieTwo = rnd.Random(1,6);
inJail = dieOne != dieTwo; // Stay in jail if the dice are different
}
}
您无法使用Select
语句执行上述操作。我们无法确定何时满足条件,因此我们将继续在While
循环中迭代,直到其中一个条件被满足。
那么我们如何使其功能化?While
循环是一个语句(具体说来是一个控制流语句),因此它不被函数式编程语言所青睐。
有几种选择,我会逐一描述每一种,但这是一个需要做出某种权衡的领域。每个选择都有后果,我会尽量考虑它们各自的利弊。
系好你的安全带,我们出发吧…
递归
处理无限循环的经典函数式编程方法是使用递归。简而言之,对于那些不熟悉的人 - 递归是使用调用自身的函数。还会有某种条件确定是否应该进行另一次迭代,或者是否实际返回数据。
如果决策是在递归函数的末尾做出的,这被称为尾递归。
解决 Monopoly 问题的纯递归解决方案可能如下所示:
public record Inventory
{
public int Money { get; set; }
public int GetOutOfJail { get; set; }
}
// I'm making the Inventory object a Record to make it
// a bit easier to be functional
var inventory = getInventory();
var rnd = getRandomNumberGenerator();
var updatedInventory = GetOutOfJail(inventory);
private Inventory GetOutOfJail(Inventory oldInv)
{
var playerAction = getAction();
return playerAction switch
{
Actions.PayFine => oldInv with
{
Money = oldInv.Money - 50
},
Actions.GetOutOfJailFree => oldInv with
{
GetOutOfJail = oldInv.GetOutOfJail - 1
},
Actions.RollDice =>
{
var dieOne = rnd.Random(1, 6);
var dieTwo = rnd.Random(1,6);
// return unmodified state, or else
// iterate again
return dieOne == dieTwo
? oldInv
: GetOutOfJail(oldInv);
}
};
}
任务完成了,对吧?嗯,并不完全是这样,我在使用上面的这种函数之前会非常谨慎地考虑。问题在于每个嵌套的函数调用都会在.NET 运行时的堆栈中添加一个新项目,如果递归调用很多,那么这可能会对性能产生负面影响,或者用堆栈溢出异常终止应用程序。
如果确保只有少数迭代,那么递归方法基本上没有什么问题。你还必须确保,如果代码的使用在增强后发生了显著变化,这一点会被重新考虑。可能会出现这样的情况,有一天这个很少使用的函数在某天变成了一个大量迭代的重度使用函数。如果这种情况发生了,业务可能会想知道为什么他们的优秀应用突然变得接近无响应。
所以,正如我所说的,在使用 C#中的递归算法之前,请非常谨慎。这样做的优点是相对简单,而且不需要编写任何样板代码来实现它。
注意
F#以及许多其他更强调函数式的语言,具有称为尾递归调用优化的特性,这意味着可以编写递归函数而不会使堆栈溢出。然而,这在 C#中是不可用的,并且将来也没有计划将其提供。根据情况,F#优化将创建带有while(true)
循环的中间语言(IL)代码,或者利用一个称为goto
的 IL 命令将执行环境的指针物理地移回循环的开始位置。
我确实调查了从 F#引用通用的尾递归调用,并通过编译的 DLL 将其暴露给 C#的可能性,但这会有自己的性能问题,使其成为一种徒劳的努力。
我在网上看到另一个可能性的讨论,那就是添加一个后构建事件,直接操作 C#编译成的.NET 中间语言,使其回顾性地使用 F#的尾部优化特性。这非常聪明,但对我来说听起来太像辛苦的工作了。这也可能是一个额外的维护任务。
在接下来的部分中,我将探讨一种在 C#中模拟尾递归调用优化的技术。
立体跳板(Trampolining)
我并不完全确定术语Trampolining的起源,但它早于.NET。我能找到的最早参考文献是 90 年代的学术论文,研究在 C 中实现 LiSP 的一些特性。我猜这个术语甚至比那还要年长一些。
基本思想是你有一个以thunk作为参数的函数 - thunk是存储在变量中的一段代码。在 C#中,这些是作为Func
或Action
实现的。
得到thunk后,你创建一个带有while(true)
的无限循环,并且通过某种方式评估一个条件,以确定循环是否应该终止。这可以通过返回bool
的额外Func
或者某种需要通过thunk在每次迭代中更新的包装对象来完成。
但归根结底,我们看到的基本上是在代码库的后面隐藏了一个while
循环。while
并非纯函数式,但这是我们可能需要妥协的地方之一。从根本上讲,C#是一种混合语言,支持 OO 和 FP 范式。总会有一些地方无法像 F#那样精确地控制其行为。这就是其中之一。
有几种方法可以实现 trampolining,但我倾向于选择这种方法:
public static class FunctionalExtensions
{
public static T IterateUntil<T>(
this T @this,
Func<T, T> updateFunction,
Func<T, bool> endCondition)
{
var currentThis = @this;
while (!endCondition(currentThis))
{
currentThis = updateFunction(currentThis);
}
return currentThis;
}
}
通过附加到类型T
,它是一个通用的,这个扩展方法因此会附加到 C#代码库中的所有内容。第一个参数是一个Func
委托,它更新T
代表的类型到基于外部定义的任何规则的新形式。第二个是另一个Func
,它返回导致循环终止的条件。
由于这是一个简单的While
循环,不存在堆栈大小的问题。虽然它不是纯函数式编程,但这是一个折衷的方案。至少,它是代码库中深藏的一个while
循环实例。也许有一天,微软会发布一个新功能,以便以某种方式实现适当的尾递归调用优化,那么这个函数就可以重新实现,代码应该继续像之前一样工作,但少一个命令式代码特性实例。
使用这个版本的不定迭代,Monopoly 代码现在看起来像这样:
// we need everything required to both update and
// assess whether we should continue or not in a
// single object, so I'm considering it "state" rather than
// simply inventory
var playerState = geState();
var rnd = getRandomNumberGenerator();
var playerState.IterateUntil(x => {
var action = GetAction();
return action switch
{
Actions.PayFine => x with
{
Money = x.Money - 50,
LastAction = action
},
Actions.GetOutOfJailFree => x with
{
GetOutOfJail = x.GetOutOfJail - 1,
LastAction = action
},
_ => x with
{
DieOne = rnd.Random(1, 6),
DieTwo = rnd.Random(1, 6)
}
}
},
x => x.LastAction == Actions.PayFine ||
x.LastAction == Actions.GetOutOfJailFree ||
x.DieOne == x.DieTwo
);
还有另一种你可以用来实现 - Trampolining。从功能上看,它的行为与隐藏的while
循环相同,性能方面也基本相同。
我并不确定是否有额外的好处,而且个人感觉它看起来比while
循环不那么友好,但它可能略微更符合函数式编程范式,因为它省去了while
语句。
如果你喜欢,可以使用这个,但在我看来,这是个人喜好的问题。
这个版本使用了一个 C#命令,通常在任何其他情况下我都极力建议你不要使用。自从 BASIC 时代以来,这种编码方式一直存在,今天仍以某种形式存在 - goto
命令。
在 BASIC 中,你可以通过调用goto
并指定行号来移动到任意的代码行。这通常是在 BASIC 中实现循环的方式。但是在 C#中,你需要创建标签,而goto
只能移动到这些标签处。
这是使用两个标签重新实现IterateUntil
的方法。一个称为LoopBeginning,相当于while
循环开头的{字符。第二个标签称为LoopEnding,表示循环的结束,或while
循环的}字符。
public static T IterateUntil<T>(
this T @this,
Func<T, T> updateFunction,
Func<T, bool> endCondition)
{
var currentThis = @this;
LoopBeginning:
currentThis = updateFunction(currentThis);
if(endCondition(currentThis))
goto LoopEnding;
goto LoopBeginning;
LoopEnding:
return currentThis;
}
我会让你决定你更喜欢哪个版本。它们几乎是等效的。但无论你做什么,绝对不要在代码中的任何其他地方使用goto
,除非你绝对,完全,彻底地知道你在做什么,以及为什么没有更好的选择。
像某位爱蛇,没鼻子的邪恶巫师 - goto
命令既伟大又可怕,如果不明智地使用。
它很强大,可以通过其他任何方式无法实现的方式创建效果,并在某些情况下提高效率。但它也很危险,因为在执行期间,指针可以跳转到代码库中的任意点,而不管这样做是否有任何意义。如果使用不当,您可能会在代码库中遇到难以解释和难以调试的问题。
请非常谨慎地使用goto
语句。
还有第三个选项,需要相当多的样板代码,但最终看起来比之前的版本友好一些。
看一看,看看你的想法如何。
自定义迭代器
第三个选项是在IEnumerables
和IEnumerators
上进行调试。关于IEnumerables
的一点是它们实际上并不是数组,它们只是指向数据“当前”项的指针,并包含如何获取下一项的指令。因此,我们可以创建自己的IEnumerable
接口实现,但具有自己的行为。
在我们的大富翁示例中,我们希望有一个IEnumerable
,它将迭代直到用户选择了一个方法来摆脱监狱,或者投掷了双倍。
我们首先创建IEnumereable
的实现,它只有一个必须实现的函数:GetEnumerator()
。IEnumerator
是实际进行枚举工作的类,这是我们接下来要讨论的内容。
枚举器的解剖
这实际上就是IEnumerator
接口的样子(它继承了一些其他接口的函数,因此这里有一些函数是为了满足继承要求而存在的):
public interface IEnumerator<T>
{
object Current { get; }
object IEnumerator.Current { get; }
void Dispose();
bool MoveNext();
void Reset();
}
每个这些函数都有一个非常具体的工作要做:
表 9-1. IEnumerator 的组成函数
Function | Behavior | Returns |
---|---|---|
Current | 获取当前数据项 | 当前项,或者如果迭代尚未开始则为 null |
IEnumerator.Current | 作为当前项,这是从 IEnumerable 实现的另一个接口引用的相同项 | 作为当前项 |
Dispose | 将 Enumerable 中的所有内容拆分以实现 IEnumerator | Void |
MoveNext | 移动到下一项 | 如果找到另一个项则为 true,如果枚举过程完成则为 false |
Reset | 回到数据集的开头 | void |
大多数情况下,Enumerator
只是在数组上进行枚举,这种情况下,我想实现可能应该大致如下:
public class ArrayEnumerable<T> : IEnumerator<T>
{
public readonly T[] _data;
public int pos = -1;
public ArrayEnumerable(T[] data)
{
this._data = data;
}
private T GetCurrent() => this.pos > -1 ? _data[this.pos] : default;
T IEnumerator<T>.Current => GetCurrent();
object IEnumerator.Current => GetCurrent();
public void Dispose()
{
// Run! Run for your life!
// Run before the GC gets us all!
}
public bool MoveNext()
{
this.pos++;
return this.pos < this._data.Length;
}
public void Reset()
{
this.pos = -1;
}
}
我认为微软的实际代码可能比这复杂得多——你希望有更多的错误处理和参数检查,但这个简单的实现给出了Enumerator
的工作内容的一个概念。
实现自定义枚举器
知道它在表面下如何工作,你就可以看到如何在Enumerable
中实现任何你想要的行为。我将通过创建一个IEnumerable
实现来展示这种技术有多强大,它只通过在MoveNext
中放入以下代码来遍历数组中每个其他项:
public bool MoveNext()
{
pos += 2;
return this.pos < this._data.Length;
}
// This turns { 1, 2, 3, 4 }
// into { 2, 4 }
怎么样,一个可以在枚举时每个项目循环两次的Enumerator
,实际上创建了每个项目的副本:
public bool IsCopy = false;
public bool MoveNext()
{
if(this.IsCopy)
{
this.pos = this.pos + 1;
}
this.IsCopy = !this.IsCopy;
return this.pos < this._data.Length
}
// This turns { 1, 2, 3 }
// into { 1, 1, 2, 2, 3, 3 }
或者一个完整的实现,倒着开始,以Enumerator
外部包装器为开始:
public class BackwardsEnumerator<T> : IEnumerable<T>
{
private readonly T[] data;
public BackwardsEnumerator(IEnumerable<T> data)
{
this.data = data.ToArray();
}
public IEnumerator<T> GetEnumerator()
{
return new BackwardsArrayEnumerable<T>(this.data);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
然后是驱动反向运动的实际Enumerator
:
public class BackwardsArrayEnumerable<T> : IEnumerator<T>
{
public readonly T[] _data;
public int pos;
public BackwardsArrayEnumerable(T[] data)
{
this._data = data ?? new T[0];
this.pos = this._data.Length;
}
T Current => (this._data != null && this._data.Length > 0 &&
this.pos >= 0 && this.pos < this._data.Length)
? _data[pos] : default;
object IEnumerator.Current => this.Current;
T IEnumerator<T>.Current => this.Current;
public void Dispose()
{
// Nothing to dispose
}
public bool MoveNext()
{
this.pos = this.pos - 1;
return this.pos >= 0;
}
public void Reset()
{
this.pos = this._data.Length;
}
}
这个逆向枚举的使用方式几乎与普通枚举完全相同:
var data = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var backwardsEnumerator = new BackwardsEnumerator<int>(data);
var list = new List<int>();
foreach(var d in backwardsEnumerator)
{
list.Add(d);
}
// list = { 8, 7, 6, 5, 4, 3, 2, 1 }
所以,现在你已经看到了如何轻松地创建自己想要的任何自定义行为的Enumerable
,应该很容易制造一个迭代无限的Enumerable
。
无限循环的可枚举
试试看快速连续说十遍这个章节标题!
正如你在上一节中看到的,其实Enumerable
并没有特别的原因必须从头开始循环到结尾。我们可以让它表现得任何我们想要的方式。
在这种情况下,我想要做的是——而不是一个数组——我想要传递一种单一的状态对象,以及一个用于确定循环是否应该继续的代码束(即 Thunk 或Func
委托)。
逆向工作,我要做的第一件事是Enumerator
。这是一个完全定制的枚举过程,所以我不会试图以任何方式使其通用化。我正在编写的逻辑在游戏状态对象之外是没有意义的。
虽然在我的假想的大富翁实现中,我可能想要进行几次不同的迭代,所以我会使操作和循环终止逻辑稍微通用化。
public class GameEnumerator : IEnumerator<Game>
{
// I need this in case of a restart
private Game StartState;
private Game CurrentState;
// old game state -> new game state
private readonly Func<Game, Game> iterator;
// Should the iteration stop?
private Func<Game, bool> endCondition;
// some tricky logic required to ensure the final
// game state is iterated. Normal logic is that if
// the MoveNext function returns false, then there isn't
// anything pulled from Current, the loop simply terminates
private bool stopIterating = false;
public GameEnumerator(Func<Game, Game> iterator,
Func<Game, bool> endCondition, Game state)
{
this.StartState = state;
this.CurrentState = state;
this.iterator = iterator;
this.endCondition = endCondition;
}
public Game Current => this.CurrentState;
object IEnumerator.Current => Current;
public void Dispose()
{
// Nothing to dispose
}
public bool MoveNext()
{
var newState = this.iterator(this.CurrentState);
// Not strictly functional here, but as always with
// this topic, a compromise is needed
this.CurrentState = newState;
// Have we completed the final iteration? That's done after
// reaching the end condition
if (stopIterating)
return false;
var endConditionMet = this.endCondition(this.CurrentState);
var lastIteration = !this.stopIterating && endConditionMet;
this.stopIterating = endConditionMet;
return !this.stopIterating || lastIteration;
}
public void Reset()
{
// restore the initial state
this.CurrentState = this.StartState;
}
}
这就完成了困难的部分!我们有一个引擎在表面下,它允许我们迭代连续的状态,直到我们完成为止——无论我们决定“完成”意味着什么。
下一个需要的项目是运行Enumerator
的IEnumerable
。那非常简单:
public class GameIterator : IEnumerable<Game>
{
private readonly Game _startState;
private readonly Func<Game,Game> _iterator;
private readonly Func<Game,bool> _endCondition;
public GameIterator(Game startState, Func<Game, Game> iterator,
Func<Game, bool> endCondition)
{
this._startState = startState;
this._iterator = iterator;
this._endCondition = endCondition;
}
public IEnumerator<Game> GetEnumerator() =>
new GameEnumerator(this._startState, this._iterator, this._endCondition);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
现在一切都准备就绪,可以执行自定义迭代了。我只需要定义我的自定义逻辑,设置迭代器。
var playerState = getState();
var rnd = getRandomNumberGenerator();
var endCondition = (Game x) => x.x.LastAction == Actions.PayFine ||
x.LastAction == Actions.GetOutOfJailFree ||
x.DieOne == x.DieTwo);
var update = (Game x) => {
var action = GetAction();
return action switch
{
Actions.PayFine => x with
{
Money = x.Money - 50,
LastAction = action
},
Actions.GetOutOfJailFree => x with
{
GetOutOfJail = x.GetOutOfJail - 1,
LastAction = action
},
_ => x with
{
DieOne = rnd.Random(1, 6),
DieTwo = rnd.Random(1, 6)
}
}
}
var gameIterator = new GameIterator(playerState, update, endCondition);
有几种处理迭代本身的选项,我想花点时间讨论每个选项的细节。
使用无限迭代器
严格来说,作为一个完全成熟的Iterator
,可以应用任何 LINQ 操作,以及标准的ForEach
迭代。
ForEach
可能是处理这种迭代最简单的方法,但它并不严格符合函数式编程的要求。这取决于你,如果你愿意通过添加一个有限的语句来妥协,或者想寻找一个更纯粹的函数式替代方案。可能会像这样:
foreach(var g in gameIterator)
{
// store the updated state outside of the loop.
playerState = g;
// Here you can do whatever logic you'd like to do
// to message back to the player. Write a message onto screen
// or whatever is useful for them to be prompted to do another action
}
// At the end of the loop here, the player is now out of jail, and
// the game can continue with the updated version of playerState;
老实说,在生产代码中,这不会让我太担心。但是,我们所做的是否定了我们在试图从代码库中清除非函数式代码方面所做的所有工作。
另一种选择涉及使用 LINQ。作为一个完整的 Enumerable
,我们的 GameIterator
可以应用任何 LINQ 操作。但哪些才是最好的呢?
Select
是一个明显的起点,但它可能不完全按照你的预期行为。用法基本上与你以前进行的任何普通 Select
列表操作一样:
var gameStates = gameIterator.Select(x => x);
这里的诀窍在于我们将 gameIterator
视为一个数组,因此从中进行 Select
将会得到一个游戏状态数组。基本上,你会得到一个包含用户经历的每一个中间步骤的数组,最后一个元素是最终状态。
将这简化为仅仅最终状态的简单方法是用 Last
替代 Select
:
var endState = var gameStates = gameIterator.Last();
当然,这假设你对中间步骤不感兴趣。也许你想为每个状态更新向用户发送一条消息,那么你可能想选择并提供一个转换。
或许是这样的:
var messages = gameIterator.Select(x =>
"You chose to do " + x.LastAction + " and are " +
(x.InJail ? "In Jail" : "Free to go!");
);
不过,这会消除实际的游戏状态,因此可能 Aggregate
是一个更好的选择:
var stateAndMessages = (
Messages: Enumerable.Empty<string>(),
State: playerState
);
var updatedStateAndMessages =
stateAndMessages.Aggregate(stateAndMessages, (acc, x) => (
acc.Messages.Append("You chose to do " + x.LastAction + " and are " +
(x.InJail ? "In Jail" : "Free to go!")),
x
));
Aggregate
过程中每次迭代中的 x 是游戏状态的更新版本,它将继续聚合,直到满足声明的结束条件。每次迭代都会向列表附加一条消息,因此最终你得到的是一个包含要传递给玩家的消息数组和游戏状态的 Tuple
。
请注意,任何使用 LINQ 语句的地方都会以某种方式提前终止迭代 - First
、Take
等,这可能导致我们的实例中玩家仍然处于监狱中的迭代过程提前结束。
当然,这可能是你实际想要的行为!例如,也许你限制玩家在继续游戏的另一部分或另一位玩家的回合之前只能进行几个动作。类似这样的情况。
在这种技术中,你可以提出各种逻辑可能性。
结论
我们已经研究了如何在 C# 中实现无限迭代,而不使用 ForEach
语句,这将导致代码更清晰,执行过程中的副作用更少。
纯函数式地做这件事情是不太可能的,有几种选择可供选择 - 所有这些选项在某种程度上都对函数式范式进行了妥协,但这就是在 C# 中工作的本质。
你希望使用哪种选项(如果有的话),完全取决于个人选择以及适用于你的项目的任何约束条件。
但是,请在使用递归时非常小心。它是一种快速的迭代方法,完全函数化,但如果不小心,可能会导致内存使用方面的显著性能问题。
在接下来的章节中,我将介绍一种利用纯函数优化算法性能的好方法。
¹ 这在印度等国家可能行不通。50 印度卢比不会带给你太多。这也就意味着,你真的认为你能用 200 美元买整条街吗!
第十章:记忆化
纯函数的优点不仅在于产生可预测的结果。尽管这是一件好事,但我们还有另一种方法可以利用这种行为来获益。
记忆化有点像缓存,特别是从 MemoryCache
的 GetOrAdd
函数来说。它的作用是取某种键值,如果该键已存在于缓存中,则将对象返回出来。如果不存在,则需要传入一个生成所需值的函数。
记忆化的工作原理与此相同,只不过其范围可能不会延伸到单个计算之外。
这在某种需要递归进行的多步计算中是有用的,或者因某种原因需要多次执行相同的计算。
或许最好的方法是通过一个例子来解释这一点...
Bacon 数
曾经想过有一种娱乐方式来浪费一个或两个下午吗?试试 Bacon 数吧。它基于凯文·贝肯是演艺界的中心人物的想法,连接所有演员。就像所有道路通往罗马一样,所有演员在某个层面上都与凯文·贝肯有连接。一个演员的 Bacon 数是你需要经过的电影连接数,才能到达凯文·贝肯。我们来看几个例子:
凯文·贝肯:简单。Bacon 数为 0,因为他就是大贝肯本人。
汤姆·汉克斯:Bacon 数为 1。他与 KB 在我个人最爱的电影之一《阿波罗 13 号》中合作。经常与汤姆·汉克斯合作的梅格·瑞恩也是 1,因为她在《剪切中》中与 KB 合作。
大卫·田纳特:Bacon 数为 2。他与科林·费斯在电影《圣特里尼安学校 2》中合作出演。科林·费斯与凯文·贝肯合作出演了《真相何在》,这是两部电影,所以他们之间有连接,因此 Bacon 数为 2。令人难以置信的是,玛丽莲·梦露的分数也为 2,因为凯文·贝肯出演了《JFK》,与杰克·莱蒙合作出演了《热情如火》。
宝莱坞巨星阿米尔·汗的 Bacon 数为 3。他与传奇巨星阿米塔布·巴赫查恩合作出演了《宝莱坞谈话》。阿米塔布出演了《了不起的盖茨比》,与托比·麦奎尔合作出演了《超越所有界限》。
我的 Bacon 数是无穷大!这是因为我从未以演员身份出现在电影中²。而我所知的最高 Bacon 数的持有者是威廉·鲁弗斯·沙夫特,一位美国内战将军,他也出现在 1989 年制作的非虚构电影中,这使他的 Bacon 数高达 10!
对了,希望你能理解这些规则。
假设你想编写程序来找出这些演员中 Bacon 数最低的是谁。像这样:
var actors = new []
{
"Tom Hanks",
"Meg Ryan",
"David Tennant",
"Marilyn Monroe",
"Aamir Khan"
};
var actorsWithBaconNumber = actors.Select(x => (a: x, b: GetBaconNumber(x)));
var report = string.Join("\r\n", actorsWithBaconNumber.Select(x =>
x.a+ ": " + x.b);
有很多方法可以计算 GetBaconNumber。最有可能使用某种电影数据的 Web API。还有更高级的“最短路径”算法,但简单起见,我会说是这样的:
-
获取凯文·贝肯的所有电影。将这些电影中的所有演员分配为 1 号。如果目标演员(例如汤姆·汉克斯)在其中,则返回答案为 1。否则继续。
-
从前一步骤中的每个演员(不包括凯文·贝肯本人)获取他们的所有电影列表,这些电影尚未被检查。给这些电影中尚未分配号码的所有演员分配值 2。
-
每次迭代时,将演员集合分配逐渐增加的值,直到最终到达目标演员并返回其编号。
由于存在用于计算这些数字的 API,我们下载演员的电影作品列表或下载演员名单的每部电影都需要大量的处理时间。
此外,这些演员和他们的电影之间有很多重叠,所以除非我们介入并采取措施,否则我们将多次检查同一部电影。
一种选择是创建一个状态对象,传递给某种聚合函数。这是一个不确定的循环,因此我们还需要选择一种妥协功能原则的选项,允许这种循环。
它可能看起来像这样(注意,我编造了 Web API,所以不要期望这在实际应用中能工作):
public int CalculateBaconNumber(string actor)
{
var initialState = (
checkedActors: new Dictionary<string, int>(),
actorsToCheck: new[] { "Kevin Bacon" },
baconNumber: 0
);
var answer = initialState.IterateUntil(
x => x.checkedActors.ContainsKey(actor),
acc => {
var filmsToCheck =
acc.actorsToCheck.SelectMany(GetAllActorsFilms);
var newActorsFound = filmsToCheck.SelectMany(x => x.ActorList)
.Distinct()
.ToArray();
return (
acc.checkedActors.Concat(acc.actorsToCheck
.Where(x => !acc.checkedActors.ContainsKey(x))
.Select(x =>
new KeyValuePair<string, int>(x, acc.baconNumber)))
.ToArray()
.ToDictionary(x => x.Key, x => x.Value),
newActorsFound.SelectMany(GetAllActorsFilms)
.SelectMany(x => x.ActorList).ToArray(),
acc.baconNumber + 1
);
});
return answer.checkedActors[actor];
}
这个方法不错,但可以更好。有很多样板代码涉及跟踪演员是否已经被检查过。例如,有很多使用 Distinct 的地方。
使用 Memoization,我们得到一个通用版本的检查,就像一个缓存,但它存在于我们执行的计算范围内,并且不会在其之外持久存在。如果您确实希望保存计算值以在调用此函数时保持持久性,则MemoryCache
可能是更好的选择。
我可以创建一个 Memoized 函数,以获取我上面列出的演员曾参演的电影列表,就像这样:
var getAllActorsFilms = (String a) => this._filmGetter.GetAllActorsFilms(a);
var getAllFilmsMemoized = getAllActorsFilms(getAllActorsFilms);
var kb1 = getAllFilmsMemoized("Kevin Bacon");
var kb2 = getAllFilmsMemoized("Kevin Bacon");
var kb3 = getAllFilmsMemoized("Kevin Bacon");
var kb4 = getAllFilmsMemoized("Kevin Bacon");
我在那里调用了相同的函数 4 次,按理说它应该去电影数据存储库,并获取数据的新副本 4 次。实际上,仅在填充kb1
时执行了一次。从那时起,每次都返回相同数据的副本。
另外,请注意,Memoized 版本和原始版本位于不同的行上。这是 C#的限制。您无法在函数上调用扩展方法,只能在Func
委托上调用,并且箭头函数在存储在变量中之前不是Func
。
一些函数式语言在 Memoization 方面具有开箱即用的支持,但奇怪的是,F#没有。
这是 Bacon Number 计算的更新版本,这次利用了 Memoization 功能:
public int CalculateBaconNumber2(string actor)
{
var initialState = (
checkedActors: new Dictionary<string, int>(),
actorsToCheck: new[] { "Kevin Bacon" },
baconNumber: 0
);
var getActorsFilms = GetAllActorsFilms;
var getActorsFilmsMem = getActorsFilms.Memoize();
var answer = initialState.IterateUntil(
x => x.checkedActors.ContainsKey(actor),
acc => {
var filmsToCheck = acc.actorsToCheck.SelectMany(getActorsFilmsMem);
var newActorsFound = filmsToCheck.SelectMany(x => x.ActorList)
.Distinct()
.ToArray();
return (
acc.checkedActors.Concat(acc.actorsToCheck
.Where(x => !acc.checkedActors.ContainsKey(x))
.Select(x => new KeyValuePair<string, int>(x, acc.baconNumber)))
.ToArray()
.ToDictionary(x => x.Key, x => x.Value),
newActorsFound.SelectMany(getActorsFilmsMem)
.SelectMany(x => x.ActorList).ToArray(),
acc.baconNumber + 1
);
});
return answer.checkedActors[actor];
}
实际上唯一的区别在于我们创建了一个从远程资源获取电影数据的本地版本,然后进行了记忆化,并且以后只引用了记忆化的版本。这意味着保证了没有不必要的重复数据请求。
在 C#中实现记忆化
现在你已经了解了一些基础知识,这就是如何为简单的单参数函数创建一个记忆化函数的方法:
public static Func<T1, TOut> Memoize<T1, TOut>(this Func<T1, TOut> @this)
{
var dict = new Dictionary<T1, TOut>();
return x =>
{
if (!dict.ContainsKey(x))
dict.Add(x, @this(x));
return dict[x];
};
}
这个版本的 Memoize 期望“live data”函数只有一个任意类型的参数。如果期望更多的参数,则需要进一步的 Memoize 扩展方法,像这样:
public static Func<T1, T2, TOut> Memoize<T1, T2, TOut>(this Func<T1, T2, TOut> @this)
{
var dict = new Dictionary<string, TOut>();
return (x, y) =>
{
var key = $"{x},{y}";
if (!dict.ContainsKey(key))
dict.Add(key, @this(x, y));
return dict[key];
};
}
现在,要使这个工作正常,我假设ToString()
方法返回一些有意义的东西,这意味着它很可能必须是一个基本类型(如string
或int
)才能正常工作。类的ToString()
方法通常只返回类的类型描述,而不是其属性。
如果你确实需要将类作为参数进行记忆化,那么需要一些创意。保持通用性的最简单方法可能是向Memoize
函数添加参数,要求开发者提供自定义的ToString
函数。像这样:
public static Func<T1, TOut> Memoize<T1, TOut>(
this Func<T1, TOut> @this,
Func<T1, string> keyGenerator)
{
var dict = new Dictionary<string, TOut>();
return x =>
{
var key = keyGenerator(x);
if (!dict.ContainsKey(key))
dict.Add(key, @this(x));
return dict[key];
};
}
public static Func<T1, T2, TOut> Memoize<T1, T2, TOut>(
this Func<T1, T2, TOut> @this,
Func<T1, T2, string> keyGenerator)
{
var dict = new Dictionary<string, TOut>();
return (x, y) =>
{
var key = keyGenerator(x, y);
if (!dict.ContainsKey(key))
dict.Add(key, @this(x, y));
return dict[key];
};
}
如果你愿意,你可以这样称呼它:
var getCastForFilm((Film x) => this.castRepo.GetCast(x.Filmid);
var getCastForFilmM = getCastForFilm.Memoize(x => x.Id.ToString());
这只有在你的函数保持纯粹的情况下才可能实现。如果你的“live”函数有任何副作用,那么你可能不会得到你预期的结果。这取决于那些副作用是什么。
从实际的角度来看,我不担心将日志添加到“live”函数中,但如果生成类的每个实例中预期存在唯一的属性,则我可能会担心。
在某些情况下,可能希望结果在对Memoize
函数的多次调用之间保持持久化,这种情况下,你还需要添加一个 MemoryCache 参数,并从外部传入一个实例。不过我并不认为有很多情况下这是个好主意。
结论
在这一章中,我们探讨了记忆化,它是什么以及如何实现它。它是缓存的一种轻量级替代方案,可用于显著减少执行具有大量重复元素的复杂计算所需的时间。
现在理论部分就到这里了!这不仅是本章的结束,也是整本书的这一部分的结束。
第一部分是如何使用函数式思想和现成的 C#来改进你的日常编码。第二部分深入探讨了函数式编程背后的实际理论,以及如何通过一些创意的方法实现它。第三部分将会更加哲学性,为你提供一些关于如何利用在这里学到的知识的下一步方向的提示。
转过来,如果你敢的话,进入…第三部分…。
¹ 这可能并不正确,抱歉贝肯先生。但我仍然爱你!
² 虽然我不会拒绝。有人知道想要招聘一个年迈、超重、英国技术宅的电影导演吗?我可能不会演詹姆斯·邦德,但我愿意试试!
第十一章:实用函数式 C#
我不仅仅是一个漂亮的面孔¹,每天我还在虚拟 IT 煤矿工作,多年来,我也有幸在各种活动中花费了大量时间谈论这个主题²。在讲话时,有几个问题经常出现。
最常见的实际上是“为什么我们不直接使用 F#”。参见“关于 F# 的问题?我应该学习 F# 吗?”以了解我对这个特定问题的回答。这几乎每次我演讲时都会被问到,这也是我提供如此详细答案的原因之一。
奇怪的是,第二个最常见的问题是解释单子(我在第七章中有所描述)。希望在达到这一点后,你现在已经成为一个专家了。
在这些之后,下一个最常见的问题是关于性能的。有一种普遍的观念,即在生产代码中,与面向对象(OO)相比,C# 中的函数式编程效率低下。我想在书的这一部分,谈论一下性能问题,以及在采用日常生活中的函数式 C# 之前是否需要关注这个问题。或者,至少是涉及到 .NET 代码的日常生活。对我来说,这两件事有很大的重叠。
函数式 C# 和性能
现在,我将继续看看函数式 C# 和性能。为此,我需要一些代码作为测试主题,可以将其与命令式代码(即面向对象编程所属的编程范式)进行比较。
现在,我是年度编码活动“编程之旅”的忠实粉丝³,在他们 2015 年首次活动中发表的第一个挑战经常是我向人们推荐的一个很好的例子,展示了函数式思维如何产生影响。
问题在于:
输入是一个严格由字符 ( 和 ) 组成的字符串⁴。这些字符代表电梯的运动。一个 ( 表示上升一层,一个 ) 表示下降一层。我们从地面层开始,不用字母 G,而是用数字 0 表示,这意味着我们可以专门使用整数值表示当前楼层,负数表示地面以下的楼层。
这有两个部分,非常适合用于性能测试。第一部分是运行一系列指令并计算最终楼层。第二部分是找出哪个输入字符串的字符可以使你到达楼层 -1(即地下室)。
这里有一个例子给你。给定输入字符串 "((((“,我们先上升 3 层,然后下降 5 层,然后再上升 4 层。
对于第一部分,答案是 2,我们最终停留在的楼层,因为 3-5+4 = 2。
对于第二部分,答案是 6 - 第 7 个字符首先使我们处于-1 楼,但数组位置是 6,因为它是基于零的数组。
这两个谜题是明确和不定循环的绝佳示例,谜题提供的输入足够大(超过 7,000 个字符),这将导致代码需要一段时间才能运行。足够让我收集一些统计数据。
如果您关心剧透,请在继续之前前往页面⁵ 并解决谜题。只需知道,如果您使用功能性方法,您可以在一行中解决它!
好吧,现在警告剧透,这一节之后,我将进入一个完全剧透的解决方案,这个 - 目前 8 岁 - 谜题。
基线 - 一种命令式解决方案
在我准备好的各种功能性解决方案中看性能之前,我首先要看的是在命令式解决方案中性能是如何的。这是一种科学实验,为了是一个适当的实验,我们需要一个控制。一个基线,用来比较所有功能结果。
对于性能测量,我正在使用 Benchmark.NET。对于那些不熟悉这个工具的人来说,它在某些方面类似于单元测试,不同之处在于它将比较同一段代码的几个版本之间的性能。它运行相同的代码多次,以获得像运行时间和内存使用量之类的平均值。
以下完全使用命令式风格编码的两个谜题的解决方案。
// i.e. the definite loop
public int GetFinalFloorNumber(string input)
{
var floor = 0;
foreach(var i in input)
{
if(i == '(')
floor++;
else
i--;
}
return floor;
}
// i.e. the indefinte loop
public int WhichCharacterEntersBasement(string input)
{
var floor = 0;
var charNo = 0;
foreach(var i in input)
{
charNo++;
if(i == '(')
floor++;
else
floor--;
if(floor == -1)
return charNo;
}
}
可能有更好的解决方案,但我觉得这个可以。
性能结果
现在这些测试是在我开发者的笔记本上针对一个 7,000 字符的输入进行的,因此在尝试复制此实验时,您可能看到的实际数字可能会有所不同。接下来几节的主要目的是比较相同测试设置的结果。
命令式基准结果
由于我的必须使用它的结果:
表 11-1。面向对象的性能结果
循环类型 | 平均花费时间 | 时间花费标准偏差 | 内存分配 |
---|---|---|---|
明确 | 10.59μs^(a) | 0.108μs | 24b |
不定 | 2.226μs | 0.0141μs | 24b |
^(a) 这些是微秒。 |
尽管任务的规模很大,实际所需时间确实非常短。不错。不定循环更快,但您会预期到这一点 - 它不需要循环整个输入字符串。
在接下来的几节中,我将逐个 FP 实现每种循环类型,并看看这对性能有何影响。
明确循环解决方案
我确实说过可以在一行中解决这个问题,不是吗?虽然是一行相当长的代码,但还是一行。这是我的一行:
public int GetFinalFloorNumber(string input) =>
input.Sum(x => x switch
{
'(' => 1,
_ => -1
});
我在其中放了一些换行符以便阅读,但技术上它仍然是单行!
我们正在进行一个Sum
聚合方法,它根据当前字符在每次迭代中添加 1 或-1。请记住,在 C#中,string
既是一小段文本又是一个数组,这就是为什么我可以像这样对它应用 LINQ 操作的原因。
这会对性能产生什么影响?我们来看看。
表 11-2. 求和聚合性能结果
解决方案 | 平均耗时 | 耗时标准偏差 | 分配内存 |
---|---|---|---|
命令式基线 | 10.59μs | 0.108μs | 24b |
求和聚合 | 60.75μs | 0.38μs | 56b |
这里的性能确实更差,无法避免这一事实。这只是 LINQ 的开箱即用,因此即使使用 Microsoft 提供的工具之一,我们仍然不如命令式代码高效。
你觉得呢,现在我该放弃吗?不太可能!我还有几件事情想尝试。
如果我们将char
→ int
转换的过程分开成两行会有什么不同吗?
public int GetFinalFloorNumber(string input) =>
input.Select(x => x == '(' ? 1 : -1).Sum();
这会有任何影响吗?
表 11-3. 选择然后求和聚合性能结果
解决方案 | 平均耗时 | 耗时标准偏差 | 分配内存 |
---|---|---|---|
命令式基线 | 10.59μs | 0.108μs | 24b |
选择/求和聚合 | 84.89μs | 0.38μs | 112b |
嗯,实际上情况更糟。好吧,试试将其转换为另一种数据结构,比如字典,它具有出色的读取速度。
public int GetFinalFloorNumber(string input)
{
var grouped = input.GroupBy(x => x).ToDictionary(x => x.Key, x => x.Count());
var answer = grouped['('] - grouped[')'];
return answer;
}
这次我正在创建一个Grouping
,其中每个可能的char
值是其中的一个Group
。在我们的示例中,将只有两个带有*
(或)*的键的
Group`。一旦我获得了分组,我就从一个分组中减去另一个分组的大小,以获得最终的楼层(即从总向下移动次数中减去总向上移动次数)。
表 11-4. 选择然后求和聚合性能结果
解决方案 | 平均耗时 | 耗时标准偏差 | 分配内存 |
---|---|---|---|
命令式基线 | 10.59μs | 0.108μs | 24b |
分组/字典聚合 | 93.86μs | 0.333μs | 33.18Kb |
不仅如此,分配的内存量还很可怕。
我仍然想尝试一个无限循环,还有一些其他事情,在我总结我对所有这些的看法之前。但目前来说 - 我觉得情况没有看起来那么糟糕。继续阅读以了解我的意思。
无限循环解决方案
现在我要试试几种解决无限循环谜题的方法 - 即输入字符串的哪个字符首先将我们带到地板-1。我不能说那会在哪里,但我们需要循环直到满足条件。
首先,我一直听说在 C#中递归是一个坏主意,如果你重视你的堆栈的话。让我们看看情况会有多糟糕。这是问题的递归解决方案:
public int WhichCharacterEntersBasement(string input)
{
int GetBasementFloor(string s, int currentFloor = 0, int currentChar = 0) =>
currentFloor == -1
? currentChar
: GetBasementFloor(s[1..], s[0] ==
'(' ? currentFloor + 1 : currentFloor - 1, currentChar + 1);
return GetBasementFloor(input);
}
美好、整洁和紧凑。但性能损失如何?
表 11-5. 递归循环性能结果
解决方案 | 平均时间花费 | 时间花费标准偏差 | 内存分配 |
---|---|---|---|
命令基准 | 2.226μs | 0.0141μs | 24b |
递归循环 | 1,030ms | 4.4733μs | 20.7Mb |
这实在是相当震惊。请注意,这些时间结果是以毫秒计算的,而不是微秒。那是差了一千倍。内存使用量也太大,无法存储在旧软盘盒上。
你现在有了你需要的证据,如果需要的话,递归在 C#中是一个非常糟糕的想法。除非你确切知道你在做什么,否则我会完全避免它。
那么非递归的函数式解决方案呢?这些需要某种妥协。让我们从 “跳板操作” 中使用我的IterateUntil
函数开始。这对性能有什么影响?
这是我的代码:
public int WhichCharacterEntersBasement(string input)
{
var startingInput = new FloorState
{
InputString = input,
CurrentChar = 0,
CurrentFloor = 0
};
var returnValue = startingInput.IterateUntil(x =>
x with
{
CurrentChar = x.CurrentChar + 1,
CurrentFloor = x.InputString[x.CurrentChar] ==
'(' ? x.CurrentFloor + 1 : x.CurrentFloor - 1
}
, x => x.CurrentFloor == -1);
return returnValue.CurrentChar;
}
public record FloorState
{
public string InputString { get; set; }
public int CurrentFloor { get; set; }
public int CurrentChar { get; set; }
}
这次我需要一些跟踪状态的东西。我正在尝试一种record
类型,因为它们已经被提供给我们,允许编写更多功能代码。那么结果如何:
表 11-6. 跳板操作性能结果
解决方案 | 平均时间花费 | 时间花费标准偏差 | 内存分配 |
---|---|---|---|
命令基准 | 2.226μs | 0.0141μs | 24b |
跳板操作 | 24.050μs | 0.3215μs | 55.6Kb |
这次并不那么令人震惊,但仍然比命令式版本差。操作过程中存储的内存量仍然相当大。
如果我把record
替换为微软提供的之前的函数式结构 - 元组,那会提高性能吗?
public int WhichCharacterEntersBasement(string input)
{
var startingInput = (InputString: input, CurrentFloor: 0, CurrentChar: 0);
var (_, _, currentChar) = startingInput.IterateUntil(x =>
(
x.InputString,
x.InputString[x.CurrentChar] ==
'(' ? x.CurrentFloor + 1 : x.CurrentFloor - 1,
x.CurrentChar + 1
), x => x.CurrentFloor == -1);
return currentChar;
}
不幸的是,这看起来并不那么友好。我喜欢record
带给我们的可爱的语法糖,但如果性能是我们的目标,那么可读性和可维护性可能就要在其祭坛上牺牲了。
如果你看一下表 11-7,你会看到性能如何:
表 11-7. 使用元组的跳板操作性能结果
解决方案 | 平均时间花费 | 时间花费标准偏差 | 内存分配 |
---|---|---|---|
命令基准 | 2.226μs | 0.0141μs | 24b |
使用元组的跳板操作 | 17.132μs | 0.0584μs | 24b |
实际上这个相当明显更好。花费的时间仍然稍微差了一点,但分配的内存数量实际上完全相同!
我想要测试的最后一个测试是我在 “自定义迭代器” 中展示的自定义Enumerable
选项。它与使用元组进行跳板操作的比较如何⁶
public class LiftEnumerable : IEnumerable<int>
{
private readonly string _input;
public LiftEnumerable(string input)
{
this._input = input;
}
public IEnumerator<int> GetEnumerator() => new LifeEnumerator(this._input);
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class LifeEnumerator : IEnumerator<int>
{
private int _currentFloorNumber = 0;
private int _currentCharacter = -1;
private readonly string input;
public LifeEnumerator(string input)
{
this.input = input;
}
public bool MoveNext()
{
var startingFloorNumber = this._currentFloorNumber;
this._currentCharacter++;
this._currentFloorNumber = startingFloorNumber == -1 ? -1 : this.input[this._currentCharacter] == '(' ? this._currentFloorNumber + 1 : this._currentFloorNumber - 1;
return startingFloorNumber != -1;
}
public void Reset()
{
this._currentCharacter= -1;
this._currentFloorNumber = 0;
}
public int Current => this._currentCharacter + 1;
object IEnumerator.Current => Current;
public void Dispose()
{
}
}
// The actual code call
public int WhichCharacterEntersBasement(string input)
{
var result = new LiftEnumerable(input).Select(x => x);
return result.Last();
}
这是一大堆代码,但它是否更高效呢?
表 11-8. 自定义 Enumerable 性能结果
解决方案 | 平均时间花费 | 时间花费标准偏差 | 内存分配 |
---|---|---|---|
命令基准 | 2.226μs | 0.0141μs | 24b |
自定义 Enumerable | 24.033μs | 0.1072μs | 136b |
这个版本的执行时间与使用 record
的 Trampolining 示例几乎相同,但在这个版本中分配了更多的数据。情况并不太糟,但仍然远远没有递归版本那么糟糕。
我们另一个选择是尝试将 F# 与 C# 互操作,以确保在 C# 无法正常运行时获得功能代码。让我们来看看这个选项。
与 F# 的互操作性能
实际上,可以在 F# 项目中编写代码,并在 C# 中引用它,就像它只是另一个 .NET 库一样。这是一个相当简单的过程,我会为您详细介绍。
在 Visual Studio 解决方案中创建一个新的项目,但选择下拉菜单中的 F# 而不是 C# 库。
您可以在您的 C# 代码中引用 F# 项目。请注意,除非单独编译 F# 项目,否则您编写的代码在 C# 中不可见。我不确定为什么会这样,但这是一个必要的步骤。
由于这是一本 C# 书籍,我不会详细介绍如何编写 F#,但看到如果我们绝对需要完全功能性的代码而不必与函数式 C# 的限制妥协时,性能将如何比较是很有趣的。
如果你感兴趣,这里有一个解决问题的 F# 方案——如果你不明白它如何工作也不用担心。我在这里呈现它作为一种珍品,而不是你需要学习的东西⁷。
module Advent =
let calculateFinalFloor start input =
input
|> Seq.fold (fun acc c -> match c with | '(' -> acc+1 | ')' -> acc-1 | _ -> acc) start
let whichStepIsTheBasement start input =
input
|> Seq.scan (fun acc c -> match c with | '(' -> acc+1 | ')' -> acc-1 | _ -> acc) start
|> Seq.findIndex(fun i -> i = -1)
这段代码纯粹是函数式的。Seq
是 F# 中 Enumerable
的等效物,因此由于这两种类型的惰性加载特性,这里有一些很好的效率节省特性在起作用。
那么性能如何呢?F# 的高性能特性是否会因在 C# 通道上的引用而受到抵消?让我们看看表 11-9 的结果……
表 11-9. F# 互操作性能结果
循环类型 | 解决方案 | 平均时间 | 时间标准偏差 | 内存分配 |
---|---|---|---|---|
确定性 | 命令基线 | 10.59μs | 0.108μs | 24b |
确定性 | F# 互操作 | 63.63μs | 0.551μs | 326b |
不确定性 | 命令基线 | 2.226μs | 0.0141μs | 24b |
不确定性 | F# 互操作 | 32.873μs | 0.1002μs | 216b |
结果仍然较差,但只差了几倍。如果您希望选择这条路线,这是一个可行的选择。但这将意味着首先学习 F#,而这远远超出了本书的范围。至少你知道将来有这个选择。
外部因素和性能
信不信由你,迄今为止我们看到的一切实际上都是与在 C# 代码之外与世界进行任何形式的交互相比显得不那么重要。让我给你展示一下我是什么意思。
在下一个实验中,我修改了原始 OO 基线函数和 FP 解决方案的最高效版本,即使用元组的版本。我将它们设置为不接受任何输入字符串,而是从存储在本地 Windows 文件系统中的文件中加载相同数据。从功能上看,我有相同的输入和相同的结果,但现在涉及到文件操作。
这会带来怎样的不同呢?好吧,表 11-10 恰好包含了答案...
表 11-10。文件处理性能结果
循环类型 | 解决方案 | 平均耗时 | 耗时标准差 | 分配的内存 |
---|---|---|---|---|
确定 | 命令基线 | 10.59μs | 0.108μs | 24b |
确定 | 带文件的命令式 | 380.21μs | 15.187μs | 37.8Kb |
确定 | 带文件的 FP | 450.28μs | 9.169μs | 37.9Kb |
不确定 | 命令基线 | 2.226μs | 0.0141μs | 24b |
不确定 | 带文件的命令式 | 326.006μs | 1.7735μs | 37.8Kb |
不确定 | 带文件的 FP | 366.010μs | 2.2281μs | 93.22Kb |
这怎么样?函数式解决方案仍然需要更长的时间,但差异比例要小得多。当我们将内存中的 FP 解决方案与使用元组的确定循环与命令式等效进行比较时,FP 版本完成所需的时间大约是确定循环的 8.5 倍。当我们比较包含文件加载的两个版本时,所需时间的比例差仅约为 1.2 倍。并不是那么多。
想象一下,如果有 HTTP 调用到 Web API 或建立在网络上的数据库连接?我可以向你保证,所花费的时间会比我们在这里看到的要糟糕得多。
如果可以的话,请加入我,我们将在下一节中总结这些实验得出的结论。
这一切意味着什么?
首先 - 不可避免的事实是,函数式 C#比写得好的面向对象代码效率低。就是这样。
我们还可以证明,如果可能的话,尽可能紧凑地保持纯 LINQ 操作是函数式特性中最有效的。如果需要某种状态对象,则元组目前是最佳选择。不过,这假设性能是我们代码的最重要目标。总是这样吗?这完全取决于你试图实现什么,以及你的客户是谁。
如果你计划制作一个拥有高清 3D 图形的精致 VR 应用程序,那么老实说,你最好完全远离函数式编程。对于这样的事情,你需要尽可能地从代码中挤出每一丝性能,甚至更多。
那么其他人呢?我在许多不同的公司工作过,我会说在几乎所有这些公司中,对我开发工作的许多驱动因素中,性能并不一定是最关键的。
我大多数工作的是为公司内部员工设计的定制 Web 应用程序,作为他们日常工作负载的一部分。每当有需求交到我手上时,通常更重要的是快速完成并发布到生产环境,而不是花时间担心性能。
即使应用程序出现了减速问题,如今也很容易在 Azure 或 AWS 上点击按钮,添加额外的虚拟 RAM 芯片到虚拟服务器,问题基本上就解决了。
但是那旧的可恶的 RAM 芯片对业务的成本又如何?
嗯,怎么样?让我这样告诉你。函数式风格的 C# 比面向对象的等效方案更容易理解、修改和维护。我们可以更快地进行更改并发布,并且第一次就能确保一切顺利无错误地运行。
鉴于这一事实,对公司造成的成本更大的是什么?多一个虚拟 RAM 芯片,还是因为你做所有额外开发工作而导致的每小时时间成本,以及解决本不必要的 bug,否则这些 bug 将最终进入生产环境?
如果没有其他,函数式的 C# 更加愉快的开发体验,确保工作环境能让每个人都开心,这难道不重要吗?
无论如何 - 即使我谈到了可能需要更多 RAM 的潜在需求,我们真的需要它吗?
在本章前面我做过的实验中,你可以看到,尽管函数式解决方案比面向对象的等效方案需要更多时间 - 但一旦我们涉及文件操作,其比例差异实际上并不是很大。几乎不足以开始担心。
这里是两种使用文件作为输入的解决方案的性能结果。每个饼图的两个部分分别表示加载文件所需的时间和处理已加载数据所需的时间。
图 11-1. 使用文件操作进行性能测试的比较
当你换个角度看时,情况并不那么糟糕,是吗?除非你真的正在处理每个性能损失都有财务后果的项目。如果是这样,我建议你放下这本书,转而拿起一本关于高性能 .NET 代码的书籍。这类书有几本。
如果你对性能不那么专注,而更愿意使用易于维护的更优雅的代码,那么我建议任何性能上的微小损失都是值得付出的代价,以便以这种风格编写代码。
就像往常一样,这取决于你。你个人的编码偏好。你工作环境的限制以及你所在团队的情况。当然,还有你试图开发的东西。
你是成年人⁸,所以我会让你自己决定。
在本章的第二部分中,我将考虑在生产环境中使用功能化 C# 时可能遇到的几个实际问题,以及对每个问题的看法。
功能化的 C# 关注点与问题
好了,时间不等人。让我们开始吧。第一个问题,请!
我应该将我的代码库做多功能化吗?
功能化的 C# 的一个美妙之处在于,功能性内容并不是框架的一部分。你可以选择多少功能化程度,这个滑动尺度可以任意调整。
你可以决定使整个代码库变得功能化。或者限制在一个项目中。一个类。一个函数。或者 - 必要时 - 一行代码。在同一个函数中混合功能性和非功能性是可行的。我可能不会这样做。但关键是你可以。
我不是纯粹主义者。我理解在生产环境中,除了使代码完全符合你的期望之外,还有各种其他问题需要考虑。
不仅是在哪里应该是功能化,还有到何种程度。你想全面采用。使用部分应用,单子,辨识联合和其他内容。或者 - 你可能只是觉得用大量的 LINQ 替换While
循环。也可以。你感觉舒服就好。没有必要觉得自己是事业的叛徒。我们是开发者,不是犹太人民阵线⁹。
另一个考虑因素是你的同事对功能性有多舒适。请考虑,无论你的功能性代码有多么美好,它仍然需要团队维护。我会认为单子类型并不是很难理解,但在他们准备好支持包含这些代码之前,你仍然需要说服每个人学会如何使用它们。即使这可能符合他们的最佳利益。
你可以引马到水,但那…
正如我一直在说的 - 最终,你必须问自己一个问题,“我想要达到什么目标?”。一旦你诚实地回答了这个问题,你的决策过程应该相对容易。
你可以在办公室四处放几本这本书。看看会发生什么。我不知道你在出版日期之后多久开始阅读这本书,但甚至有可能我仍然在。随时可以让人们联系我提问。
我应该如何组织一个功能化的 C# 解决方案?
在类和文件夹中,与现有的 OO C# 项目基本相同。严格来说,类并不是一个功能概念。如果你看看 F# 项目,它们根本不一定有类。
你可以把所有的代码放在模块中,但它们并不完全相同。它们是 F# 开发者将代码分组的便捷方式。更像是 C# 的命名空间。模块本身没有功能含义。
另一方面,在 C#中,必须有类。没有其他办法,所以这仍然是你必须做的事情。
在纯函数式风格的 C#中,你可以使每个类和函数都是静态的。不过,如果你这样做了,我确信你在某个地方会遇到实际问题¹⁰,特别是当你必须尝试与 Nuget 或内置的 Microsoft 类之一进行交互时。
缺乏依赖注入也会使单元测试变得更加困难。像 Haskell 这样的语言通过使用各种 Monad 来解决这个问题。不过,在 C#中,我会让你的生活变得简单,只需使用标准类和一个 IoC 容器,希望你已经在使用了。
如何在应用程序之间共享我的函数方法?
我维护一组充满类和扩展方法的函数库,这些库提供了我想在代码中使用的所有 Monad、Discriminiated Unions 和其他有用的扩展方法的实现。通常我在解决方案中有一个名为“Common”的单独项目,我会把任何通用的东西放在那里。这样它就可以在代码库的某个地方使用,但在我继续工作时我不一定要再看一遍。
我的 Common 项目的内容目前是从一个解决方案复制到另一个解决方案。
在工作中的某个时候,我们计划建立我们自己的 Nuget 本地实例,当我们这样做时,我们可能会将 Common 项目设置为可消耗的 Nuget 包。这将使分发错误修复、改进、新功能等变得容易。不过,目前来说,每次复制大部分时间都能正常工作。
你点了这个披萨吗?
Erm,我不太确定。看起来像是放了凤尾鱼,所以可能不适合我。我点了一份肉盛宴。让我去和利亚姆确认一下,我觉得那可能是他的。5 秒,我马上回来...
如何说服我的团队成员也这样做?
很好的问题。当你弄清楚如何始终如一地做到这一点时,告诉我你是如何做到的。
我已经在这个主题上发表演讲很长时间了,对函数式编程的反应似乎分为几类:
-
天啊!这是开发的圣杯!我要更多!
-
这有点有趣,但我想我会继续按照我一直以来的方式工作,谢谢。
-
这太糟糕了。噢!
我无法给你任何关于每个阵营有多少人的真实统计数据,但我感觉大约是第一组和最后一组中的人数很少,而中间的大多数人。
鉴于这种情况,作为函数式编程倡导者,我们的工作是试图说服他们改变他们的方式。请记住,大多数人类都是习惯于习惯的生物,不太愿意像那样对他们的日常生活进行巨大的改变 - 除非有一个很好的理由。
还要看你工作的地方以及项目的约束条件是什么。如果你正在创建每一个内存位都至关重要的移动应用程序,或者为 VR 设备上的 3D 图形系统创建代码,你可能会发现功能性编程很难推广。
如果你不这样做,可能会有一种可能性,你可以通过谈论这些好处来说服每个人。只是不要变成一个功能性编程的乏味人物。可能会有一个时刻,显而易见地,大家都没有兴趣。在那一点上,最好暂时停止,并希望通过消耗战赢得长期的胜利,而不是通过大胆的正面进攻。
我要关注的好处,大致按降序排列,是:
-
可靠性 - 功能性应用程序往往更加健壮。失败的可能性较小。功能性编程还极大地促进了单元测试。因此,遵循这种范式,你最终将获得更高质量的产品,失败率更低。这将为公司节省大量时间,避免在生产中解决 bug 所需的时间。
-
开发速度 - 一旦你熟悉了这种范式,使用功能性范式编写代码通常更快、更容易!由于功能性代码的结构方式,增强功能特别容易。我会谈论在初始开发阶段能够节省的时间和金钱。
-
微软支持 - 这是.NET 团队的一个明确目标,即支持功能性范式。谈谈这种编程风格并不是在滥用.NET,实际上是使用它的本意。大部分情况下……
-
易于学习 - 希望你现在读到这本书的这一点后,会有这种感觉。我不觉得功能性编程很难学,至少一旦你摒弃了所有的正式定义和听起来吓人的术语。事实上,采用 FP 所需学习的内容比新开发者完全学习面向对象范式所需的要少。我会向所有人保证,学习的负担并不大。
-
挑选并选择 - 还要提到,你可以根据需要采纳这种范式的多少。这并不意味着你需要放弃你的旧代码库,转移到完全不同的代码库上。你可以从重构一个单一函数开始小规模尝试,你甚至可能不选择使用单子(虽然我认为你应该!)。
-
这并不新鲜! - 最终可能值得讨论功能性范式的年龄。许多公司可能不愿意采纳一个尚未证明能够持续足够时间值得投资的新潮技术。这是一个合理的担忧,但 FP 作为软件开发概念首次使用于 1960 年代,并且其根源可以追溯到 19 世纪末期。它在生产环境中已经被证明了无数次。
我真的不会立即开始谈论单子的三大法则、F#、柯里化或类似的事情。尽量让一切尽可能熟悉。
除了自己谈论它,这里还有这本书,以及许多其他关于这个主题的好书。接下来的一节中,我会推荐我最喜欢的那些书。
在我的解决方案中包含 F# 项目值得吗?
由你决定。在性能方面,据我所知并没有真正的问题。
最好的用法可能是考虑将代码库中深层次、基于规则的部分制作为 F#。这些函数基于一组业务需求将数据从一种形式转换为另一种形式。
F# 很可能会使那些部分更加整洁、更加健壮和更加高效。
如果你追求的是函数式编程,它将比 C# 能做的事情更加功能强大。
我唯一考虑的事情是你的团队是否愿意支持它。如果他们愿意 - 太好了。去做吧。如果不愿意,与他们一起讨论。这种决定需要大家一起做出。
如果你的团队对 C# 不太熟悉,至少可以安心的是大部分的函数式编程范式在 C# 中仍然存在。
函数式编码会解决我所有的问题吗?
取决于你面临的问题是什么?
函数式编程不太可能改善你的扑克游戏,或在你需要时为你冲杯咖啡。
它肯定会为你提供一个更好的代码库,在生产中运行更佳,并且未来支持起来也更容易。
对于一个下定决心写糟糕代码的人,或者某天懒惰并犯错,这仍然是可能的。地球上没有什么¹¹可以阻止这种情况发生。除了强制执行自动化测试、代码审查和手动质量检查之类的常规方法。这些方法从开发行业开始就一直存在。
然而,函数式风格的编码将使问题更容易发现。它简洁的风格使得一眼就能看出一个函数实际做了什么,以及它是否真正做到了其名称所暗示的。
如前面本章中所述,函数式编程也不会是解决你编码需求中最高效的解决方案,但它足够接近,除非绝对的性能极限对你很重要,否则它很可能是完全合适的。
这些都不会帮助你解决任何常规项目管理问题。对于需求不明确的问题,你得自己和你信任的任何业务分析师解决。
函数式编程会让你看起来很酷。孩子们甚至可能在你路过时向你竖起大拇指。这就是函数式编程的街头声誉。真实事实。
康纳利、摩尔还是克雷格?
以上都不是,我是蒂莫西·道尔顿的铁杆粉丝。他值得更多关注。
通过函数式思考问题
这并不是一种编写软件的唯一方式,就像没有一种软件开发方式一样。不过,如果有帮助的话,我会简要描述我的过程。
我会首先思考你尝试编写的代码的逻辑步骤。我是说,试着将其分解为你与他人讨论工作时会描述的步骤。即“首先我会做 X,然后我会做 Y,然后 Z”。这并不是一种你可以特别适用于面向对象风格代码的工作方式,但实际上却是分解函数式代码的最佳方式。
然后,我会根据这些步骤编写每一段函数式代码。
同时,在任何可能的情况下,我建议将你正在处理的任何内容作为Enumerable
的一部分,无论是基本类型、复杂对象还是Func
委托。函数式编程在运行类似于 T-SQL 的基于列表的操作时往往最为有效。
我建议不要让函数链变得太长。在开发过程中,你需要有机会检查复杂计算的先前步骤,以确保一切按你的期望工作。这也让你有机会为每个阶段的过程应用有意义的名称。
函数式编程非常支持单元测试,因此我建议尽可能将整个过程分解为尽可能多的小函数,并确保你已经尽可能彻底地测试了每一个函数。
如果你有能力逻辑上分解步骤,那么尽可能充分利用它。
结论
本章是一个两面性的游戏。
在第一部分中,我们考虑了函数式代码性能差的神话,并希望在某种程度上揭开它。我们看到这是一个真实的现象,但与你的代码中的任何 I/O 量相比,其差异微不足道。
在第二部分中,我们考虑了工业环境中函数式编程的一些更深奥的哲学问题。
函数式编程将在《你死我活》第十二章中回归。届时,我将探讨使用 Nuget 第三方包进行函数式编程的选项。
¹ 好吧,我甚至都不是那样,但请让我保留我的幻想,好吗!
² 不然我会让家人听我吹牛。
³ 网站链接在此处:https://adventofcode.com/。每天两个编码挑战,为期 24 天,直到圣诞节。我从未能够实时完成这样的事件,但这些都是极好的难题。
⁴ 或者“lift”,对于我的同胞们来说
⁵ 你可以在此找到它:https://adventofcode.com/2015/day/1,玩得开心!
⁶ 现在,快速十次说出那个词!我敢你试试看!
⁷ 再次感谢 F#大师 Ian Russell 提供的这段代码。
⁸ 可能吧。如果你不是这样,而且你读到了这么远,那么你将在生活中取得成功!
⁹ 分离者!
¹⁰ 彩虹之上?
¹¹ 或许这也毫无意义,但谁知道呢…
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
2022-06-18 ApacheCN 校对活动参与手册
2022-06-18 # ApacheCN 校对活动参与手册