在 C# 中拥抱函数式编程






4.58/5 (9投票s)
如何在 C# 中拥抱函数式编程。
引言
构建软件是一项充满挑战的任务,而创建高质量的软件则更加艰巨。因此,有大量书籍致力于此主题,旨在简化开发过程,并且经常提出新的架构范例来增强数据组织。同时,测试驱动开发等方法论已被广泛应用于行业。
尽管如此,在过去几十年中被有些忽视,但随着多核处理器的出现而逐渐受到关注的趋势是,在软件开发中倾向于拥抱函数式编程,而不是命令式或面向对象编程。我们本系列的目标是精确地揭示这种范式意味着什么,以及为什么它值得更广泛的采用。此外,我们将探讨其陡峭学习曲线带来的挑战,这导致了其采纳速度较慢。
接下来的教科书在结束本系列时非常有用,它将函数式编程作为一个全面的主题来处理。
本文最初发布于此处:在 C# 中拥抱函数式编程
什么是函数式编程?
函数式编程经常被吹捧为解决程序中所有错误的万能药;它有助于提高可读性、可测试性和可维护性。虽然这在特定情况下是真实的,但深入了解商业宣传背后的理由至关重要。
什么导致软件中的错误?
这个问题很难简明扼要地回答。错误可能由瞬时故障、无响应的服务、不可扩展的组件或其他可用性和性能问题引起。事实上,我们这里指的是由代码中糟糕的设计引起的内在错误。其中,状态突变是更具问题性的问题之一。
-
状态突变是指修改对象或变量状态的过程。它涉及更改对象的数值或属性,通常会导致可能影响程序行为的副作用。
-
状态突变在命令式编程范式中很普遍,其重点是描述逐步过程和操作变量的状态。
状态突变带来的挑战在于,它会引入复杂性,并使推理程序行为变得更加困难。当代码库的不同部分修改相同状态时,可能会出现意外后果,导致难以追踪和调试的错误。
示例 1
请看下面的 C# 代码,它计算列表中整数的总和。
public static int Sum(List<int> list) {
var sum = 0;
foreach (var x in list)
sum += x;
return sum;
}
这段代码没有任何错误,现在,设想我们需要一个计算绝对值总和的方法。为了避免重复,我们重用了前面的函数。
public static int SumAbsolute(List<int> list) {
for (var i = 0; i < list.Count(); ++i)
list[i] = Math.Abs(list[i]);
return Sum(list);
}
现在我们可以在 main
程序中使用这些方法。
static void Main(string[] args)
{
var data = new List<int>() { -1, 0 };
Console.WriteLine($"Sum of absolute values: {SumAbsolute(data)}");
Console.WriteLine($"Sum of values: {Sum(data)}");
}
核心问题就在这里,因为 SumAbsolute
方法直接修改了输入列表,而没有事先复制其值。在我们的例子中,检测问题相对简单,但请考虑包含数千行代码的代码库中可能出现的复杂性。
示例 2
此示例摘自 C# 中的函数式编程 (Buonanno)。
public class Product
{
int inventory;
public bool IsLowOnInventory { get; private set; }
public int Inventory
{
get { return inventory; }
private set
{
inventory = value;
// At this point, the object can be in an invalid state,
// from the perspective of any thread reading its properties.
IsLowOnInventory = inventory <= 5;
}
}
}
在多线程环境中,存在一个短暂的窗口期,期间库存已更新,但 IsLowOnInventory
尚未更新。这种情况发生的频率非常低,但并非不可能,并且在这种情况下,调试会变得异常困难。这些麻烦的错误被称为竞态条件,非常难以检测,即使可能检测到。虽然有人可能认为我们的代码通常是单线程的,但不幸的是,多核处理器正使得并发越来越普遍。
事实上,转向多核机器是我们目前看到函数式编程重新受到关注的主要原因之一。
C# 中的函数式编程 (Buonanno)
相比之下,不可变性,即一旦创建了一个对象,其状态就不能被改变,有助于创建更可预测、更不容易出错的代码。稍后将详细介绍这一点。
为什么我们要谈论函数式编程?
我们刚刚注意到状态突变可能导致某些程序中出现严重的错误。现在,让我们考虑一个简单的实值函数 ff,它有一个变量,例如 f(x)=x2+x+1。f(1) 的结果是什么?
这个问题看似非常简单,结果是 3。我们如何计算?我们取 1,平方它,加 1,然后再次加 1。为什么如此大费周章?
第二种计算方法显得完全没有道理:然而,这正是我们在计算过程中修改变量状态时偶尔会做的事情(在我们的例子中,最初 x 等于 1,然后,在操作过程中,x 等于 2)。虽然这样表述可能显得微不足道,但它无疑反映了实际情况。
在评估某个特定值的数学函数时,该值在整个计算过程中保持恒定。事实上,这与函数式编程的核心概念之一一致,并且是其名称的一个基本方面:结果将仅取决于其参数,而与任何全局状态无关。这个概念被称为纯洁性,函数式编程致力于避免状态突变以保持纯洁性。
什么是纯函数?
纯函数是指给定相同的输入,总是产生相同的输出,并且没有可观察到的副作用的函数。
- 函数的输出仅由其输入决定。
- 函数不修改任何外部状态或变量。它不依赖或改变其范围之外的任何内容(无副作用)。
纯函数是函数式编程中的一个基本概念,它提高了代码的可预测性、可测试性和易于推理性。
这就是为什么在 F# 等纯函数式语言中,变量是严格不可变的,一旦初始化就无法更改。在 C# 中,设计本身不保证不可变性,我们必须采用替代方法来维护这个基本属性。
信息
最近流行的编程语言 Rust 本身并不是一种函数式语言,但变量默认是不可变的。这承认了状态突变被认为是错误的根本来源之一。
重要
副作用在计算机科学中是不可避免的。总会有修改数据库或文件的需求;否则,我们的工作将毫无意义。函数式编程的理念是简单地将这些副作用隔离在非纯函数中,并用纯函数编写其余所有代码。
为什么纯洁性很重要?
纯洁性不仅仅是一个哲学概念。有了纯函数,并行处理会大大简化,因为我们不必考虑副作用或任何全局状态。这就是为什么函数式编程被认为能增强并发性。同样,使用纯函数进行测试也变得非常简单,从而提高了可测试性。
我们将在本系列的最后一篇文章中探讨这一点。
函数式编程与数学函数相关
因此,函数式编程是一种避免突变的范式。然而,与数学函数的联系不仅限于此。
在定义函数 f 时,我们理解其域 A(它可以计算的值)并了解其可能的返回值 B。这种映射表示为 f:A⟼B。
在编程语言中,也应该遵循相同的原则:我们应该能够预测我们使用的任何函数或过程的结果。但是,情况总是如此吗?
int Divide(int x, int y)
{
return x / y;
}
签名声明该函数接受两个整数并返回另一个整数。但在所有情况下都不是如此。如果我们调用
Divide(1, 0)
怎么办?函数实现不遵守其签名,抛出DivideByZeroException
。
C# 中的函数式编程 (https://functionalprogrammingcsharp.com/honest-functions)
重要
诚实函数是指能够准确传达其可能输入和输出的所有信息,始终遵守其签名,没有任何隐藏依赖或副作用的函数。
如何使这个函数变得诚实?我们可以更改 y
参数的类型(NonZeroInteger
是一个自定义类型,可以包含除零以外的任何整数)。
诚实函数不仅仅是一个哲学概念。精确了解函数的返回值,允许链接此函数,就像在数学意义上组合函数一样。因此,诚实性或引用透明性增强了代码的可预测性、可测试性和易推理性。
LINQ 确实是 .NET 框架中以函数式风格编写的代码的示例。LINQ 允许我们有效地链接或组合函数。
var res = accounts.Where(x => x.IsActive).Select(t => t.Name).OrderByDescending();
为什么选择 C# 的函数式编程?为什么不使用 Haskell、Erlang 或 F#?
这是一个很棒的问题。既然 Haskell、F# 或其他编程语言如此出色,为什么不使用它们呢?问题在于,我们必须考虑现实:C# 开发人员过剩,而 F# 工程师却很少。
没有一家公司会冒着因采用一种没有人精通的奇妙范式而无法招聘的风险(实际上,这个选项只有大型企业才可行)。因此,我们做出了妥协,坚持使用 C# 并尝试在该语言中融入函数式编程。
信息
C# 主要是一种面向对象语言,这使其可以充当连接这两个世界的桥梁。
信息
在本系列中,我们将使用 LaYumba.Functional
库,该库可作为 NuGet 包使用。
介绍 Option 概念
作为开发人员,我们通常会使用基库中的常用方法,而不会质疑它们。例如,将字符串转换为整数是一项常见操作,如下面的代码所示。
var input = Console.ReadLine();
var s = Convert.ToInt32(input);
Console.WriteLine(s);
这段代码将为 "1230
" 或 "-2
" 等输入产生整数输出。但是,当输入例如 "hello
" 时,应用程序会产生什么?
这段代码例证了一个不诚实的函数;它的名称或签名都没有表明可以抛出异常。我们只预期返回一个整数,当最终发现错误时,开发人员会对原始代码进行一些小的修改。
var input = Console.ReadLine();
if (int.TryParse(input, out var s))
{
Console.WriteLine(s);
}
else
Console.WriteLine("An error occurred.");
这段代码有什么问题?
这段代码本身并没有什么问题。它按预期工作并有助于防止错误。但是,输出值很可能将是另一个函数的输入(通常,代码并非纯粹为了乐趣而编写),在这种情况下,我们需要处理两个分支:一个用于正确输入,一个用于错误输入。这会引入 if
-else
语句,甚至可能嵌套 if
-else
语句,最终使代码变得难以阅读,从而难以维护。
相反,函数式编程建议使用诚实函数,其中输出是精确已知的。如果一个方法可能返回异常,则必须在其签名中明确指明。这种方法允许我们组合函数,而无需诉诸 if
-else
语句,并且与数学上的对应项紧密相关。在数学中组合多个函数时,很少会指明会发生错误。
代码变得更清晰,因为我们可以链接函数。
好的,但我们该怎么做呢?引入 Option
在函数式编程中,Option
是一种数据类型,表示值是否存在。它是一种处理没有结果或未定义结果的可能性,而无需使用 null
的方法。
-
Option
类型可以是 Some(value),表示存在一个值,或者 None,表示不存在一个值。 -
使用
Option
类型有助于创建更诚实、更可预测的函数,因为它强制显式处理值的缺失。这可以带来更健壮、更少出错的代码。
信息
在文献中,Option 有时被称为 Maybe。
有了这个概念,我们可以定义一个诚实的函数来将 string
转换为整数。
private static Option<int> ConvertToInt32(string input)
{
return int.TryParse(input, out var s) ? Some(s) : None;
}
签名现在清楚地表明可能无法获得数据(例如,由于异常),与使用 null
或异常相比,提供了一种更清晰、更明确的方式来处理可能缺失的值。因此,结果可以在下游使用,以继续工作流程,而无需大量的 if
-else
条件。
var input = Console.ReadLine();
var res = ConvertToInt32(input);
Console.WriteLine(res);
这种编码方法允许我们自然地链接函数,从而使代码更具可读性。例如,考虑以下场景
- 读取字符串
- 将其转换为整数
- 将其值加倍
使用 Option
,代码与之前的算法非常相似。
var input = Console.ReadLine();
var res = ConvertToInt32(input).Map(x => x * 2);
Console.WriteLine(res);
无论转换过程中是否发生错误,或者一切顺利,我们都将获得一个结果,而无需复杂的条件分支逻辑。
信息
有关 Option
概念在 C# 中的具体实现,请参阅 C# 中的函数式编程 (Buonanno)。
介绍 Either 概念
当我们需要模拟数据缺失时,Option
概念是合适的。但是,在某些情况下,我们需要更多信息,特别是要了解为什么没有值。当程序可以抛出不同的异常时,这一点尤其重要。
Either 是一种数据类型,表示两个可能值之一:Left
或 Right
。它通常用于模拟一个可能产生成功(Right
)或失败(Left
)的计算。约定是使用 Left
来表示错误或失败情况,使用 Right
来表示成功情况。使用 Either
的优点是,它通过允许我们在 Left
情况中包含错误值,从而提供有关失败的更多信息。这在可能发生多种类型错误,并且我们希望区分它们的情况下很有用。
使用 Either
,我们可以重写之前的代码。
var input = Console.ReadLine();
var res = ConvertToInt32(input).Map(x => x * 2);
Console.WriteLine(res);
private static Either<string, int> ConvertToInt32(string input)
{
try
{
return Right(Convert.ToInt32(input));
}
catch (Exception ex)
{
return Left(ex.Message);
}
}
信息
我们只是粗略地触及了函数式编程为提高可读性提供的各种可能性。在这次简短的概述中,我们无法详尽无遗或过于严谨地深入探讨(例如 monad)的复杂细节。为了更深入地理解这些概念,建议参考一本专门介绍该主题的书籍。
我们现在将探讨如何用数据来处理函数式思维。但是,为了避免本文过于冗长,有兴趣了解此实现的读者可以在 这里找到续篇。
历史
- 2024 年 2 月 1 日:初始版本