C# 函数式编程(一)

原文:zh.annas-archive.org/md5/445c5024138799c1ed6a1899c0d17e5d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

函数式编程(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 或其他关系数据库的背景的人可能会发现一些相关的想法更容易理解。

除了这些之外,还有许多其他编程范式,但它们远超出本书的范围。公平地说,除了这两种以外,大多数都相当隐晦,因此你不太可能在短时间内遇到它们。

函数式编程的特性

在接下来的几节中,我将讨论函数式编程的每一个特性,以及它们对开发者的实际意义。

不可变性

如果某物体能够改变,那么它也可以说是变异,就像一个少年变异忍者²。另一种说法是,某物体可以变异,这意味着它是可变的。另一方面,如果某物体根本不能改变,那么它是不可变的

在编程中,这指的是变量在定义时设置其值,并且在此后永远不可更改。如果需要新值,则应基于旧值创建一个新变量。这是函数式代码中所有变量的处理方式。

这与命令式代码略有不同,但最终会产生更接近数学运算的程序,鼓励良好的结构和更可预测的、因此更健壮的代码。

.NETDateTimeString都是不可变数据结构。你可能认为你已经改变了它们,但在幕后,每个改变都在堆栈上创建了一个新项。这就是为什么大多数新开发者在For循环中连接字符串时会被提醒,以及为什么你绝对不应该这样做的原因。

高阶函数

这些是作为变量传递的函数。这可以是作为局部变量、函数的参数或函数的返回值。Func<T,TResult>Action<T>委托类型就是这种情况的完美示例。

如果您不熟悉这些委托,这是它们的简要工作原理。

它们都是以变量形式存储的函数。它们都接受一组泛型类型,这些类型表示它们的参数和返回类型(如果有的话)。FuncAction之间的区别在于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";

另一个快速的经验法则 - 如果一行代码有一个等号,那么它很可能是一个表达式,因为它正在为某个东西赋值。在这条规则中存在一些灰色地带。对其他函数的调用可能会有各种意想不到的后果。但把它记在心里还是个不错的主意。

语句 另一方面是一些不评估数据的代码片段。这些更像是一个指令,告诉执行环境通过关键字如 ifwhereforforeach 等改变执行顺序,或者调用不返回任何东西的函数 - 由此暗示进行某种操作。像这样:

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#应用程序中,我想象纯粹代码区域是本影,而妥协区域是半影。我的任务是最大化纯粹区域,并尽可能减少非纯粹区域。

https://github.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/fp-cs/img/umbra-penumbra.jpg

如果你想要这种架构模式的更正式定义,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#中自己实现这一切。

不过,值得注意的是,这里没有状态跟踪变量,也没有IfWhile语句。我甚至不确定操作的顺序一定是什么,但这并不重要,因为系统会按需完成任何必要的步骤。

这更像是给一个稍微聪明一点的机器人的指令。至少可以自己思考一下,至少在过程式代码中可以通过组合一个 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 支持高阶函数,并提供了一些库:itertoolsfunctools,以允许进一步实现函数式特性。

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 中的SmtpClientMailMessage类。

在 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#早期就存在的库,为数据集合提供了丰富的过滤、修改和扩展功能。像SelectWhereAll这样的函数来自 LINQ,在全球范围内广泛使用。

回想一下函数式编程特性的列表,看看 LINQ 实现了多少……

  • 高阶函数 - 传递给 LINQ 函数的 Lambda 表达式都是函数,作为参数变量传入。

  • 不变性 - LINQ 不会改变源数组,它返回基于旧数组的新的Enumerable

  • 表达式而非语句 - 我们已经消除了ForEachIf的使用。

  • 引用透明性 - 我在这里写的 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.RangeEnumerable.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}";

这种方法没有错,但我喜欢将其拆分成单独的行,原因有几个:

  • 变量名提供了一些关于你的代码正在做什么的见解。我们有点像是在半强制性地进行代码注释。

  • 可以检查中间变量,查看每个步骤中的内容。这使得调试更容易,因为正如我在前一章中所说的那样——就像在数学答案中回顾你的工作,看看哪一步错了。

没有最终的功能差异,没有任何会被最终用户注意到的地方,所以采用哪种风格更多是个人品味的问题。无论你以何种方式写作,都要保持可读性和易于跟踪。

更进一步 - 发展你的函数式编程技能

这里有一个挑战给你。如果这里描述的一些或全部技术对你来说是新的,那就去尝试一下,享受一番吧。

挑战自己按照以下规则编写代码:

  • 将所有变量视为不可变的 - 一旦设置,不要更改任何变量值。基本上把所有东西都当作常量对待。

  • 不允许使用以下语句 - IfForForEachWhile。只有在三元表达式中可以接受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 委托,并制定了以下规则:

  1. 两名玩家同时画出相同的 = 平局

  2. 剪刀胜纸

  3. 石头胜纸

  4. 石头胜剪刀

这个函数具体确定了从我的角度对我的想象对手的结果是什么。

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 表达式的 andnot 关键字可以工作了,并且如果不需要其属性,则不再需要一个用于转换类型的局部变量。

虽然不是突破性的,但这确实继续减少必要的样板代码量,并为我们提供了更多表达性更强的语法片段。

我在下一个示例中加入了一些更多的规则,使用这些功能。现在有两类带有不同特殊利率水平的 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 上至少有两个尝试来实现这个概念:

  1. Harry McIntyre 的 OneOf (https://github.com/mcintyre321/OneOf)

  2. 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# 书籍,你可能会选择一本或多本来了解。

  1. 由 Isaac Abraham 撰写的《Get Programming with F#》(Manning)

  2. 由 Ian Russell 编写的《Essential F#》(https://leanpub.com/essential-fsharp

  3. 由 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 引入了一种新的自动属性类型。我们已经有了GetSet,但现在还有Init

如果您有一个附有GetSet的类属性,这意味着可以随时检索或替换该属性。

如果它有GetInit,则在对象实例化时可以设置其值,但之后不能再更改。

这意味着我们的只读结构体(以及我们所有的类)现在可以以稍微更美观的语法进行实例化,然后处于只读状态:

 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:

https://github.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/fp-cs/img/ch03_001.png

如果我试图将《银翼杀手导演剪辑版》的标题设置为 NULL,那么会出现另一个例子:

https://github.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/fp-cs/img/ch03_002.png

请记住,这些只是编译器警告。代码仍将执行而没有任何错误。它只是指导您编写代码,减少包含空引用异常的可能性 - 这只能是一件好事。

避免使用 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 出版的,也是不错的选择。

³ 在我看来,远远优于戏剧性的版本。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值