在本章中,我将介绍当今生产中使用的几乎所有 C# 代码库中都可能存在的函数式编程功能。我假设至少是 .NET 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 变量都会一直处于作用域中,因此即使它们没有被使用,它们也会一直处于内存中。
它们不仅都在作用域中,而且我们保存了所有动作影片的两个副本,一个在原始 allFilms 变量中,另一个在这个新的 actionFilms 变量中。这比我们严格需要保存的内存要多。
我们还强制执行操作顺序。我们已经指定了何时循环、何时添加等。每个步骤应该在何处和何时执行。如果在执行数据转换时有任何中间步骤,我们也会指定它们,并将它们保存在更多可能长期存在的变量中。
如果有比我们决定的更优化的操作顺序怎么办?如果后面的代码实际上意味着我们最终不会返回 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 遵循函数式范式。
对于还不熟悉 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 语句块,以至于代码开始类似于家谱的形状。
对于每个嵌套的 If 语句,复杂性实际上会加倍。如果代码库中散布着多个返回语句,情况尤其如此。如果没有详细考虑日益复杂的代码库,那么无意中得到 Null 或其他意外值的风险就会增加。函数式编程不鼓励这样的结构,并且不容易出现这种程度的复杂性,也不容易出现潜在的意外后果。
在上面的代码示例中,我们在 2 个不同的地方定义了 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
};
我知道我现在重复了 AlternateTuesday
标志,但这意味着确定返回属性的所有变量都在一个地方定义。这使得以后使用起来更加简单。
如果属性非常复杂,需要多行代码,或者需要占用大量空间的一系列 Linq 操作,那么我会创建一个分离函数来包含该复杂逻辑。不过,我仍然会将基于结果的返回置于一切的核心位置。
关于可枚举的几句话
我有时认为可枚举是 C# 中使用最少、理解最少的功能之一。可枚举是数据列表的最抽象表示 - 如此抽象以至于它本身不包含任何数据,它实际上只是内存中保存的关于如何获取数据的描述。 Enumerable 甚至不知道有多少项可用,直到它遍历所有内容 - 它所知道的只是当前项在哪里,以及如何迭代到下一个项。
这称为 惰性求值 或 延迟执行。在开发中,懒惰是一件好事。不要让任何人告诉你不是这样。
事实上,如果您愿意,您甚至可以为 Enumerable 编写自己的整个自定义行为。在表面之下,有一个称为枚举器的对象。与该对象交互可用于获取当前项,或迭代到下一个项。您不能使用它来确定列表的长度,并且迭代只能在一个方向上进行。
看看这个代码示例:
var input = new[]
{
75,
22,
36
};
var output = input.Select(x => DoSomethingOne(x))
.Select(x => DoSomethingTwo(x))
.Select(x => DoSomethingThree(x));
您认为操作顺序是什么?您可能会认为运行时将获取原始输入数组,将 DoSomethingOne 应用于所有 3 个元素以创建第二个数组,然后再次将所有三个元素应用于 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
循环运行所获得的结果完全相同,但我们实际上已将操作顺序的控制权移交给了运行时。我们并不关心临时保存变量的细节,也不关心何时何地存储什么。相反,我们只是描述我们想要的操作,并期望最后得到一个答案。
它可能并不总是看起来完全一样,这取决于调用它的代码是什么样子。但意图始终不变,即 Enumerable 仅在需要的确切时刻实际生成数据。它们在哪里定义并不重要,重要的是何时使用它们。
使用 Enumerable 而不是实体数组,我们实际上已经设法实现了编写声明性代码所需的一些行为。
令人难以置信的是,如果我像这样重写代码,上面写的日志文件看起来仍然一样:
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 上。您最有可能发现的是,即使传递了该行,它也无法向您显示任何数据。这是因为它实际上还没有执行任何操作。
如果我做了这样的事情,事情就会改变:
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 之后都调用 ToArray 也没关系。只要您不修改任何结果数组或其中包含的对象,您就遵循了函数范式。
重要的是要摆脱定义列表、使用 ForEach 循环遍历源对象,然后将每个新项目添加到列表的命令式做法。这很冗长、难以阅读,而且说实话相当乏味。为什么要用困难的方式做事?只需使用一个简单、美观的 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());
在我上面的示例中,我使用 Tuple 将来自两个查找函数的数据与每个给定的电影 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");
我们正在迭代 Linq 已经排序的复杂对象列表,因此我们无法在此处使用 Enumerable.Range 技巧。即使是 Tuple 也不行,因为我们需要一种方法将两个数组连接在一起。更糟糕的是,在枚举之前,您不知道 Enumerable 有多长,所以我们甚至不知道要制作多长的 Range 列表。
相反,我们可以使用 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 使用 For
或 ForEach
循环的情况。由于 C# 支持函数式范式,因此几乎总是有声明式方法来解决问题。
获取“i”索引位置变量的两种不同方法是命令式代码与声明式代码的一个很好的例子。命令式、面向对象方法让开发人员手动创建一个变量来保存 i 的值,并明确设置变量要增加的位置。声明式代码不关心变量的定义位置,也不关心每个索引值的确定方式。
还有其他方法可以进一步实现这一点,但在本章中,我坚持使用相对简单的案例,不需要使用 C# 进行破解。这些都是任何人都可以立即使用的现成功能。
注意 - 请注意,我使用 string.Join
将字符串链接在一起。这不仅是 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);
还有另一个函数以相同的方式计算平均值,称为平均值。据我所知,没有用于计算中位数的函数。
但是,我可以使用一些快速的函数式代码来计算中位数。它看起来像这样:
var numbers = new [] {
83,
27,
11,
98
};
bool IsEvenNumber(int number) => number % 2 == 0;
var sortedList = numbers.OrderBy(x => x).ToArray();
var median = IsEvenNumber(sortedList.Count())
? sortedList.Skip((sortedList.Count()/2)-1).Take(2).Average()
: sortedList.Skip((sortedList.Count()) / 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(
(0.0M, 0.0M),
(runningTotals, x) => (
runningTotals.Item1 + x.Budget,
runningTotals.Item2 + 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);
现在这是函数式的,但并不是很理想。首先,我将一个函数嵌套在另一个函数中。递归很好,但不太优雅。
另一个主要问题是,如果增量列表很大,则扩展性不好。我会告诉你我的意思。
假设增量只有 3 个值:2、-12 和 9。在这种情况下,我们希望答案返回 1,因为数组的第二个位置(即索引 = 1)结果为零(10+2-12)。我们还希望永远不会计算 9。这就是我们在这里的代码中寻找的效率节省。
但是,递归代码实际上发生了什么。
首先,它调用 GetFirstPositionWithValueZero,当前值为 10(即起始值),并且允许 i 为默认值 -1。
函数主体是三元 if 语句。如果已达到零,则返回 i,否则再次调用该函数,但使用更新后的 current 和 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; }
}
这是不可变的吗?它肯定不是。任何这些属性都可以通过 setter 替换为新值。IList 还提供了一组函数,允许添加或删除其底层数组。
我们可以将 setter 设为私有,这意味着我们必须通过详细的构造函数实例化该类:
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;
}
}
现在它是不可变的吗?不,老实说它不是。属性可以在类内部替换,但开发人员可以确保永远不会添加这样的代码。在这种情况下,整数属性 PropB 和 IEnumerable PropD 没问题,但其他一切仍然是可变的。确实,我们实际上不能直接替换它们中的任何一个,但 List 仍然可以改变其元素,字符串实际上是一个 char[],因此可以对它做任何事情。
如果我们实际上不需要保存 PropE 的可变副本,我们可以轻松地用 IEnumerable 或 IReadOnlyList 替换它,但这仍然留下了字符串和 DateTime 字段的问题。
还有可能引入类似这样的内容:
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 的所有属性也有可能可变 - 除非也遵循具有私有设置器的相同结构。
那么代码库之外的类呢?那么 Microsoft 类或来自第三方 Nuget 包的类呢?没有办法强制执行不变性。
不幸的是,根本没有办法强制执行通用不变性,即使在最新版本的 C# 中也没有。我认为出于向后兼容性的原因,永远不会有。
我的解决方案远非完美。我只是假装项目中存在不变性,并且永远不会更改任何对象。旧版本的 C# 中没有任何东西提供任何级别的强制执行,因此您只需为自己或团队做出决定,假设它确实存在。
将所有内容放在一起 - 完整的功能流程
我已经谈了很多关于一些简单的技术,您可以使用它们立即使您的代码更具功能性。现在,我想展示一个完整的(即使很小)应用程序,该应用程序旨在演示端到端的功能流程。
我将编写一个非常简单的 CSV 解析器。在我的示例中,我想读取包含有关《神秘博士》前几季数据的 CSV 文件的完整文本。我想读取数据,将其解析为普通的旧式 C# 对象(POCO,即仅包含数据而不包含逻辑的类),然后将其聚合成一份报告,该报告会计算剧集数以及每个季节已知缺失的剧集数。为了本示例的目的,我简化了 CSV 解析。我并不担心字符串字段周围的引号、字段值中的逗号或任何需要额外解析的值。所有这些都有第三方库!我只是在证明一个观点。
这个完整的过程代表了一种很好的典型功能流程。取一个项目,将其分解为一个列表,应用列表操作,然后再次聚合回单个值。
这是我的 CSV 文件的结构:
-
[0] - 季数。1 到 39 之间的整数值。我现在冒着给这本书定档的风险,但目前为止已经有 39 季了。
-
[1] - 故事名称 - 我不关心的字符串字段
-
[2] - 编剧 - 同上
-
[3] - 导演 - 同上
-
[4] - 集数 - 在《神秘博士》中,所有故事都包含 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.Selct(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
// NoEps (int) = the total number of episodes in all
// serials in the season
// NoMisEps (int) = The total number of missing episodes
// from the season
var aggregatedReportLines = groupedBySeason.Select(x =>
x.Aggregate((S: x.Key, NoEps: 0, NoMisEps: 0),
(acc, val) => (acc.S,
acc.NoEps + val.NumberOfEpisodes,
acc.NoMisEps + 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.NoEps,
NumberOfMIssingEpisodes = x.NoMisEps,
PercentageMissing = (x.NoMisEps/x.NoEps)*100
});
// format the report lines to a list of strings
var reportTextLines = report.Select(x => $"{x.SeasonNumber}, {x.NumberOfEpisodes}," +
"{x.NumberofMissingEpisodes},{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,No Episodes,No MissingEps,Percentage Missing";
// 最终报告由标题、新行和报告主体组成
var finalReport = $"{reportHeader}{Environment.NewLine}{reportTextLines}";
如果你好奇的话,结果看起来会是这样的(我添加了一些标签以使其更易读):
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, NoEps: 0, NoMisEps: 0),
(acc, val) => (acc.S,
acc.NoEps + int.Parse(va[4]),
acc.NoMisEps + int.Parse(val[5]))
)
)
.Select(x => $"{x.S}, {x.NoEps},{x.NoMisEps},{(x.NoMisEps/x.NoEps)*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。 - 尽可能将尽可能多的函数写成小而简洁的箭头函数。
要么将其作为生产代码的一部分,要么出去寻找代码挑战网站,例如 The Advent 或 Project Euler。您可以全力以赴。
掌握了这一点后,您就可以进入下一步了。希望您到目前为止玩得开心!
总结
在本章中,我们研究了各种基于 Linq 的简单技术,用于使用至少 .NET 3.5 在任何 C# 代码库中立即编写函数式代码。
我们讨论了 Select 语句的更高级功能、Linq 的一些不太为人所知的功能以及聚合和递归的方法。
在下一章中,我将介绍 C# 中的一些最新开发成果,这些成果可用于更新的代码库。