c#函数式编程 Functional Programming in C# [1]
第1部分核心概念 在这一部分,我们将介绍函数式编程的基本技术和原理。 第 1 章首先介绍什么是函数式编程,以及 C# 如何支持函数式编程。然后深入研究高阶函数,这是 FP 的基本技术。 第 2 章解释了什么是纯函数,为什么纯度对函数的可测试性有重要影响,以及为什么纯函数很适合并行化和其他优化。 第 3 章介绍了设计类型和函数签名的原则——你以为自己知道但从函数的角度来看时会感到耳目一新的事
第1部分
核心概念
在这一部分,我们将介绍函数式编程的基本技术和原理。
第 1 章首先介绍什么是函数式编程,以及 C# 如何支持函数式编程。然后深入研究高阶函数,这是 FP 的基本技术。
第 2 章解释了什么是纯函数,为什么纯度对函数的可测试性有重要影响,以及为什么纯函数很适合并行化和其他优化。
第 3 章介绍了设计类型和函数签名的原则——你以为自己知道但从函数的角度来看时会感到耳目一新的事情。
第 4 章介绍了 FP 的一些核心功能:Map、Bind、ForEach 和 Where(过滤器)。这些函数提供了与 FP 中最常见的数据结构进行交互的基本工具。
第 5 章展示了如何将函数链接到捕获程序工作流的管道中。然后它扩大了以功能风格开发整体用例的范围。
在第 1 部分结束时,您将对以函数式风格编写的程序有一个很好的感觉,并且您将了解这种风格必须提供的好处。
介绍函数式编程
本章涵盖
- 函数式编程的好处和原则
- C#语言的功能特性
- C#中函数的表示
- 高阶函数
函数式编程是一种编程范式:一种与您可能习惯的主流命令式范式不同的思考程序的方式。出于这个原因,学习函数式思考具有挑战性,但也非常丰富。我的目标是读完这本书,你永远不会用和以前一样的眼睛看代码!
学习过程可能会很坎坷。当某些东西在您的脑海中响起时,您可能会从看似晦涩或无用的令人沮丧的概念转变为令人振奋的概念,并且您可以用几行优雅的函数式代码替换一团糟的命令式代码。
本章将解决您在开始此旅程时可能会遇到的一些问题:函数式编程究竟是什么?我为什么要在乎?我可以在 C# 中进行函数式编码吗?值得付出努力吗?
我们将从高级概述开始,了解什么是函数式编程 (FP),以及 C# 语言对函数式编程的支持程度。然后我们将讨论函数以及它们在 C# 中的表示方式。最后,我们将用高阶函数将脚浸入水中,我将用一个实际例子来说明。
1.1 什么叫函数式编程?
究竟什么是函数式编程?在非常高的层次上,它是一种强调功能同时避免状态突变的编程风格。这个定义已经是双重的,因为它包含两个基本概念:
- 函数作为第一公民
- 避免状态突变
让我们看看这些是什么意思。
1.1.1 函数作为第一公民
在函数是第一公民的语言中,您可以将它们用作其他函数的输入或输出,您可以将它们分配给变量,并且可以将它们存储在集合中。换句话说,您可以使用函数执行您可以使用任何其他类型的值执行的所有操作。
例如,在 REPL 中输入以下内容:
Func<int, int> triple = x => x * 3;
var range = Enumerable.Range(1, 3);
var triples = range.Select(triple);
triples // => [3, 6, 9]
在这个例子中,你首先声明了一个函数,该函数返回一个给定的整数的三倍,并将其分配给变量triple。然后使用Range创建一个IEnumerable< int >,其值为[1,2,3]。 然后你调用Select(IEnumerable的一个扩展方法),给它提供范围和triple函数作为参数;这将创建一个新的IEnumerable,包含通过对输入范围中的每个元素应用triple函数得到的元素。
这个简短的片段证明了函数在C#中确实是第一公民的值,因为你可以将乘以3的函数分配给变量triple,并将其作为Select的一个参数。在本书中,你会看到,把函数当作值来处理,可以写出一些非常强大和简洁的代码。
1.1.2 避免状态突变
如果我们遵循函数式范式,我们应该避免状态突变:一旦创建,对象就不会改变,变量也不应该被重新赋值。术语突变表示一个值在原地被改变–更新内存中某个地方的值。例如,下面的代码创建并填充了一个数组,然后就地更新数组的一个值:
int[] nums = { 1, 2, 3 };
nums[0] = 7;
nums // => [7, 2, 3]
这种更新也称为破坏性更新,因为在更新之前存储的值被破坏了。在进行函数式编码时,应始终避免这些。(纯函数式语言根本不允许就地更新。)
遵循这一原则,对列表进行排序或过滤不应就地修改列表,而应在不影响原始列表的情况下创建一个新的、经过适当过滤或排序的列表。在 REPL 中键入以下内容以查看使用 LINQ 的 Where 和 OrderBy 函数对列表进行排序或过滤时会发生什么。
清单 1.1 函数式方法:Where 和 OrderBy 不影响原始列表
Func<int, bool> isOdd = x => x % 2 == 1;
int[] original = { 7, 6, 1 };
var sorted = original.OrderBy(x => x);
var filtered = original.Where(isOdd);
original // => [7, 6, 1] // 原始列表未受影响。
sorted // => [1, 6, 7] //排序和过滤产生了新的列表。
filtered // => [7, 1]
正如你所看到的,原始列表没有受到排序或过滤操作的影响,它产生了新的IEnumerables。
我们来看一个反例。如果你有一个 List< T >,你可以通过调用它的 Sort 方法就地排序。
var original = new List<int> { 5, 7, 1 };
original.Sort();
original // => [1, 5, 7]
在这种情况下,排序后,原始排序被破坏。你马上就会明白为什么这是有问题的。
注意: 您在框架中看到函数式和非函数式方法的原因是历史性的:List< T >.Sort 早于 LINQ,这标志着在功能方向上的决定性转变。
1.1.3 编写具有强大保障的程序
在我们刚才讨论的两个概念中,函数作为第一公民最初似乎更令人兴奋,我们将在本章的后半部分集中讨论它。但在我们继续之前,我想简要地说明一下为什么避免状态突变也是大有好处的,因为它消除了许多由可改变状态引起的复杂性。
让我们看一个例子。(我们将更详细地重新讨论这些主题,所以如果此时一切都不清楚,请不要担心。)在 REPL 中键入以下代码。
清单 1.3 从并发进程中改变状态会产生不可预测的结果
using static System.Linq.Enumerable;
using static System.Console;
var nums = Range(-10000, 20001).Reverse().ToList();
// => [10000, 9999, ... , -9999, -10000]
Action task1 = () => WriteLine(nums.Sum());
Action task2 = () => { nums.Sort(); WriteLine(nums.Sum()); };
Parallel.Invoke(task1, task2); //并行执行两个任务
// prints: 92332970
// 0
这里你定义nums是一个介于10,000和-10,000之间的所有整数的列表;它们的总和显然应该是0。然后你创建两个任务:task1计算并打印出总和;task2首先对列表进行排序,然后计算并打印总和。 如果独立运行,每个set任务都会正确计算出总和。然而,当你并行运行这两个任务时,task1得出了一个不正确的、不可预知的结果。
很容易看出原因:当任务1读取列表中的数字来计算总和时,任务2正在重新排列这个列表。 这有点像在别人翻书的时候试图读一本书:你会读到一些很乱的句子!从图形上看,这可以用图1.1来说明。
如果我们使用LINQ的OrderBy方法,而不是对列表进行原地排序呢?
Action task3 = () => WriteLine(nums.OrderBy(x => x).Sum());
Parallel.Invoke(task1, task3);
// prints: 0
// 0

如您所见,使用 LINQ 的函数式实现可为您提供可预测的结果,即使您并行执行任务也是如此。 这是因为 task3 并没有修改原始列表,而是创建了一个全新的数据“视图”,该视图是有序的——task1 和 task3 从原始列表并发读取,但并发读取不会导致任何不一致,如图所示 在图 1.2 中。
这个简单的例子说明了一个更广泛的事实:当开发人员以命令式风格(明确地突变程序状态)编写应用程序,并在后来引入并发性(由于新的要求,或需要提高性能),他们不可避免地面临大量的工作,并可能出现一些困难的错误。 当一个程序从一开始就以函数式风格编写时,通常可以免费添加并发性,或者大大减少工作量。我们将在第2章和第9章中进一步讨论状态突变和并发性。现在,让我们回到FP的概述上来。
尽管大多数人都会同意,将函数视为第一类值和避免状态突变是FP的基本原则,但它们的应用引起了一系列的实践和技术,因此,哪些技术应该被认为是必要的并包括在这样一本书中,这是值得讨论的。
我鼓励你以务实的态度对待这个问题,并尝试将FP理解为一套工具,你可以用它来解决你的编程任务。 当你学会了这些技术,你就会开始从不同的角度看问题:你会开始函数式的思考。
现在我们有了FP的工作定义,让我们看看C#语言本身,以及它对FP技术的支持。
函数式vs面向对象?
我经常被要求将FP与面向对象编程(OOP)进行比较和对比。这并不简单,主要是因为对OOP应该是什么样子有许多不正确的假设。
理论上,OOP 的基本原理(封装、数据抽象等)与 FP 的原理是正交的,所以没有理由不能将两个范式结合起来。
然而,在实践中,大多数面向对象(OO)的开发者在他们的方法实现中严重依赖命令式风格,在原地突变状态并使用显式控制流:他们在大处使用OO设计,在小处使用命令式编程。因此,真正的问题是命令式编程与函数式编程,我将在本章末尾总结FP的好处。
另一个经常出现的问题是 FP 在构建大型复杂应用程序方面与 OOP 有何不同。构建复杂应用程序的艰巨艺术取决于以下几个原则:
- 模块化(将软件划分为可重用的组件)
- 关注点分离(每个组件应该只做一件事)
- 分层(高级组件可以依赖于低级组件,反之亦然)
- 松散耦合(对组件的更改不应影响依赖于它的组件)
无论所讨论的组件是函数、类还是应用程序,这些原则通常都是有效的。
它们也不是专门针对OOP的,所以同样的原则也可以用来构建一个用函数式风格编写的应用程序–区别在于组件是什么,以及它们暴露了哪些API。
在实践中,对纯函数的功能强调(我们将在第2章中讨论)和可组合性(第5章)使得实现其中一些设计目标变得非常容易。
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐



所有评论(0)