摘要:作为一名 C# 开发人员,您可能已经在编写一些函数式代码而没有意识到这一点。本文将介绍一些您已经在C#中使用的函数方法,以及 C# 7 中对函数式编程的一些改进。 尽管 .NET 框架的函数式编程语言是F#,同时,C# 是一个面向对象的语言,但它也有很多可以用于函数式编程技术的特性。你可能已经写了一些功能的代码而没有意识到它!
函数式编程范例
函数式编程是相对于目前比较流行和通用的面向对象编程的另一种编程模式。 有几个与其他编程范例不同的关键概念。我们首先为最常见的定义提供阐述,以便我们在整个文章中看清这些定义。 函数式编程的基本组成是纯函数。它们由以下两个属性定义:
- 他们的结果完全取决于传递给它的参数。没有内部或外部的状态影响它。
- 他们不会造成任何副作用。被调用的次数不会改变程序行为。
由于这些属性,函数调用可以被安全地替换其结果,例如函数每次执行的结果都缓存到一个键值对(被称为memoization的技术)。 纯函数很适合形成 组合函数,将两个或多个函数组合成一个新函数的过程,该函数返回相同的结果,就好像其所有的构成函数都按顺序调用一样。如果ComposedFn是Fn1和Fn2的函数组合,那么下面的断言将永远正确:
Assert.That(ComposedFn(x), Is.EqualTo(Fn2(Fn1(x))));
作为其他函数的参数可以进一步提高其可重用性。这样的高阶函数可以作为通用的 辅助者 (helper) ,它应用多次作为参数传递的另一个函数,例如一个数组的所有项目:
Array.Exists(persons, IsMinor);
在上面的代码中,IsMinor 是一个在别处定义的函数。使之有效,语言必须支持其为第一类对象,即允许函数像类型一样用作参数的语言结构。
数据总是用不可变的对象来表示的,也就是在初始创建后不能改变状态的对象。每当一个值发生变化,就必须创建一个新的对象,而不是修改现有的对象。因为所有对象都保证不会改变,所以它们本质上是线程安全的,也就是说,它们可以安全地用于多线程程序中,而不会受到竞争条件的威胁。 由于函数是纯粹的,对象是不可变的直接结果,在函数编程中没有共享状态。 函数只能根据参数进行操作,而参数不能改变,从而影响其他接收相同参数的函数。他们可以影响程序的其余部分的唯一方法是将返回的结果作为参数传递给其他函数。 这样可以防止函数之间的任何隐藏的交叉交互,使得它们可以安全地以任何顺序甚至并行运行,除非一个函数直接依赖于另一个函数的结果。 有了这些基本的模块,函数式编程最终会被比命令式更具声明,即用 描述 代替 如何计算 。 以下两个将字符串数组转换为小写的函数清楚地表明了两种方法之间的区别:
string[] Imperative(string[] words)
{
var lowerCaseWords = new string[words.Length]; for (int i = 0; i < words.Length; i++) { lowerCaseWords[i] = words[i].ToLower(); } return lowerCaseWords; } string[] Declarative(string[] words) { return words.Select(word => word.ToLower()).ToArray(); }
虽然你会听到很多其他的函数式编程概念,比如 monads, functors, currying, referential transparency等,但是这些模块应该足以让你了解什么是函数式编程,以及它与面向对象编程有什么不同。
在 C# 中编写函数式代码
由于语言主要是面向对象的,所以默认并不总是引导你使用这样的代码,但是有了意图和足够的自律,你的代码可以变得更加实用。
不可变类型
你很可能习惯于在C#中编写可变类型,但只需很少的改变,就可以使它们不可变:
public class Person
{
public string FirstName { get; private set; } public string LastName { get; private set; } public Person(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } }
私有属性构造器使对象初始创建后不可能为它们分配不同的值。为了使对象真正不可变,所有的属性也必须是不可变的类型。否则,它们的值将通过改变属性来改变,而不是为它们分配一个新的值。 上面的 Person 类型是不可变的,因为 string 也是一个不可变的类型,也就是说它的值不能像其所有的实例方法一样被改变,所以返回一个新的字符串实例。但是这是规则的一个例外,大多数 .NET 框架中类型都是可变的。 如果你希望你的类型是不可变的,你不应该使用除了原始类型以外的其他内建类型,而应该使用字符串作为公共属性。 要更改对象的属性,例如更改人物的名字,需要创建一个新的对象:
public static Person Rename(Person person, string firstName) { return new Person(firstName, person.LastName); }
当一个类型有很多属性时,编写这样的函数可能会变得非常繁琐。因此,对于不可变类型来说,为这样的场景实现 With helper 函数是一个好习惯:
public Person With(string firstName = null, string lastName = null) { return new Person(firstName ?? this.FirstName, lastName ?? this.LastName); }
这个函数创建了修改了任意数量属性的对象的副本。我们的 Rename 函数现在可以简单地调用这个帮助器来创建修改后的 Person :
public static Person Rename(Person person, string firstName) { return person.With(firstName: firstName); }
只有两个属性的好处可能不是很明显,但不管这个类型有多少个属性,这个语法允许我们只列出我们想要修改的属性作为命名参数。
纯函数
使函数变 "纯" 需要更多的训练,而不是使对象不可变。 没有语言功能可以帮助程序员确保一个特定的功能是纯粹的。不要使用任何内部或外部的状态,不要引起副作用,不要调用任何不纯的函数。 当然,也没有什么能阻止你使用函数参数和调用其他纯函数,从而使函数变得纯粹。上面的 Rename 函数是一个纯函数的例子:它不调用任何非纯函数,也不使用传递给它的参数以外的任何其他数据。
组合函数
通过定义一个新的函数,可以将多个函数合并成一个函数,该函数调用其所有组合函数(让我们忽略不需要连续多次调用Rename的事实):
public static Person MultiRename(Person person) { return Rename(Rename(person, "Jane"), "Jack"); }
重命名方法的签名迫使我们嵌套调用,随着函数调用次数的增加,这些调用会变得难以理解和理解。如果我们使用With方法,我们的意图变得更清晰:
public static Person MultiRename(Person person)
{
return person.With(firstName: "Jane").With(firstName: "Jack"); }
为了使代码更具可读性,我们可以将调用链分成多行,保持可管理性,无论我们将多少个函数组合成一个:
public static Person MultiRename(Person person)
{
return person
.With(firstName: "Jane") .With(firstName: "Jack"); }
没有好的方法来分割与重命名类似的嵌套调用函数。当然,With 方法允许链接语法,因为它是一个实例方法。但是,在函数式编程规范中,函数应该和它们所作用的数据分开声明,比如 Rename 函数。 虽然在 函数式语言 F# 中有一个流水线操作符(|>)来允许组合这些函数,但我们可以利用 C# 中的扩展方法:
public static class PersonExtensions
{
public static Person Rename(this Person person, string firstName) { return person.With(firstName: firstName); } }
这允许我们组合非实例方法调用,就像实例方法调用一样:
public static Person MultiRename(Person person) { return person.Rename("Jane").Rename("Jack"); }
.NET Framework中的函数式 API 示例
为了体验C#中的函数式编程,你不需要自己编写所有的对象和函数。 在 .NET 框架中有一些可用的函数式 API 供您使用。
不变集合
我们已经提到,在.NET框架中,字符串和原始类型是不可变的类型。 但是,也有一些可选的 不可变集合类型 。从技术上讲,它们并不是.NET框架的一部分,因为它们是作为独立的 NuGet 包 System.Collections.Immutable 分发。 另一方面,它们是新的开源跨平台 .NET 运行时 .NET Core 的一个组成部分。 命名空间包括所有常用的集合类型:数组,列表,集合,字典,队列和堆栈。 顾名思义,它们都是不可改变的,即它们在创建之后不能被改变。相反,每个更改都会创建一个新实例。这使得不可变集合以与.NET框架基类库中包含的并发集合不同的方式完全线程安全。 使用并发集合,多个线程不能同时修改数据,但仍可以访问修改。对于不可变的集合,任何更改只对创建它们的线程可见,因为原始集合保持不变。 尽管为每个可变操作创建了一个新的实例,为了保持集合的高性能,它们的实现利用了结构共享。 这意味着在集合的新修改实例中,来自先前实例的未修改的部分尽可能被重用,因此需要较少的内存分配并且导致垃圾收集器的工作较少。 在函数式编程中这种常见的技术是可以实现的,即对象不能改变,因此可以安全地重用。
使用不可变集合和常规集合最大的区别在于它们的创建。
由于每次更改都创建一个新实例,因此您希望创建集合中已包含所有初始项目的集合。因此,不可变集合不具有公共构造函数,但提供了三种创建方法:
- 工厂方法创建接受 0个 或 更多的项目来初始化集合:
var list = ImmutableList.Create(1, 2, 3, 4);
- Builder 是一个高效的可变集合,可以很容易地转换为不可变的集合:
var builder = ImmutableList.CreateBuilder<int>(); builder.Add(1); builder.AddRange(new[] { 2, 3, 4 }); var list = builder.ToImmutable();</int>
- 可以使用扩展方法从IEnumerable创建不可变集合:
var list = new[] { 1, 2, 3, 4 }.ToImmutableList();
不可变集合的可变操作与常规集合中的可变操作类似,但它们都返回集合的新实例,表示将操作应用于原始实例的结果。 如果您不想丢失更改,则必须在此之后使用此新实例:
var modifiedList = list.Add(5);
执行上述语句后,列表的值仍然是 {1,2,3,4} 。得到的 modifiedList 将具有 {1,2,3,4,5} 的值。 无论对于一个非功能性程序员来说,不可变的集合看起来是多么的不寻常,它们是编写.NET框架功能代码的一个非常重要的基石。创建你自己的不可变集合类型将是一个重大的努力。
LINQ - 语言集成查询
.NET框架中一个更好的函数式的API是LINQ。 虽然它从来没有被宣传为函数式,但它体现了许多以前引入的函数式性质。 如果我们在 LINQ 扩展方法仔细一看,很明显几乎所有的都表明其函数式:他们允许我们声明我们想要获得什么,而不是如何做。
var result = persons
.Where(p => p.FirstName == "John")
.Select(p => p.LastName)
.OrderBy(s => s.ToLower())
.ToList();
以上查询返回名为 John 的姓氏的有序列表。我们只提供了预期的结果,而不是提供详细的操作顺序。可用的扩展方法也很容易使用链式语法进行组合。 尽管LINQ函数并不是作用于不可变的类型,但它们仍然是纯函数,除非通过传递变异函数作为参数来滥用。 它们被实现为对只读接口 IEnumerable 集合进行操作,而不修改集合中的项目。 他们的结果只取决于输入参数,只要作为参数传递的函数也是纯的,它们不会产生任何全局副作用。在我们刚刚看到的例子中,人员集合以及其中的任何项目都不会被修改。 许多 LINQ 函数是 高阶函数:它们接受其他函数作为参数。在上面的示例代码中,lambda表达式作为函数参数传入,但是它们可以很容易地在其他地方定义并传入,而不是以内联的方式创建:
public bool FirstNameIsJohn(Person p) { return p.FirstName == "John"; } public string PersonLastName(Person p) { return p.LastName; } public string StringToLower(string s) { return s.ToLower(); } var result = persons .Where(FirstNameIsJohn) .Select(PersonLastName) .OrderBy(StringToLower) .ToList();
当函数参数和我们的情况一样简单时,代码通常会更容易理解内联 lambda 表达式而不是单独的函数。然而,随着实现的逻辑变得更加复杂和可重用,把它们定义为独立的函数,开始变得更有意义。
结论:
函数式编程范式当然有一些优点,这也促成了它近来日益普及。 在没有共享状态的情况下,并行和多线程变得更容易,因为我们不必处理同步问题和竞争条件。纯函数和不变性可以使代码更容易理解。 由于函数只依赖于它们明确列出的参数,因此我们可以更容易地识别一个函数是否需要另一个函数的结果,以及何时这两个函数是独立的,因此可以并行运行。单个纯函数也更容易进行单元测试,因为所有的测试用例都可以通过传递不同的输入参数和验证返回值来覆盖。没有其他的外部依赖模拟和检查。
如果所有这些都让你想为自己尝试函数式编程,那么首先在 C# 中执行它可能比在同一时间学习一种新语言更容易。您可以通过更多地利用现有的函数式 API 来缓慢起步,并以更具说明性的方式继续编写代码。 如果你看到了足够的好处,那么你可以学习 F#,稍后再熟悉这些概念。