导航
第十三章 Functional Programming with C#
13.1 概述
C#从来就不是一门纯粹的面向对象语言。早先,C#是面向组件设计的。什么叫面向组件?C#提供了继承和多态的功能,这点倒是与面向对象一样,但是,C#还提供了对属性、事件,特性的原生支持。最新版本的LINQ和表达式都包括了声明式编程(declarative programming),使用声明式的LINQ表达式,编译器保存了一颗表达式树,为之后的SQL语句提供动态生成。
C#不单单基于某一种语言范式(paradigm),那些对现代C#应用程序有用的特性会不断地添加进C#的语法里。最近这些年,越来越多跟函数式编程(functional programming)相关的特性都逐步地加到了C#里。
那么什么是函数式编程呢?最重要的概念主要体现在两个方面:一个是避免状态突变(avoid state mutation),一个是函数作为一等公民(function as a first-class citizens)。接下来的俩小节会着重介绍这两者的细节。
注意:本书仅仅只能介绍函数式编程的部分内容,你需要专门的书籍进行学习。如果你想要完全使用函数式编程范式,你需要考虑使用F#语言而不是用C#。本章仅仅介绍最实用的部分——比如C#能怎么做。一些函数式编程的特性对所有类型的应用程序都有用,这也是C#提供了函数式编程特性的原因。随着时间的发展,越来越多的函数式编程特性会被添加进C#的语法里,并且更符合C#的编程风格。
13.1.1 避免状态可变
如果你用过F#,一门函数优先的语言,创建一个自定义类型,然后用这个类型构造一个对象实例,默认这个实例是不可变(immutable)的。实例只能在构造函数里初始化,并且后续不能有任何改变。如果需要动态改变,那么类型就必须显式声明成可变(mutable)的。这点和C#不一样。
在C#里,一些预定义的类型是不可变的,如string。那些修改string的方法实际上返回的是一个新的string对象。集合怎么样?LINQ的方法从来不会改变任何一个集合,像where和orderby等方法实际上返回的是过滤好的新集合。
另一方面,List<T>集合提供了一系列的排序方法,但它是一种可变的类型,因此排序的时候,原始集合会发生变化。.NET提供完全不可变的集合类包含在System.Collections.Immutable命名空间下。这些集合类提供的方法不会改变集合本身,而是返回一个新的集合。
那么不可变类型有什么优点呢?因为它保证了没有人能修改它的实例,因此多线程在访问它的时候,就不需要考虑同步的问题。使用不可变的类型,创建单元测试也会更简单。
C# 6.0开始添加了一些创建自定义类型的特性。从C# 6.0开始,你可以创建一个自动实现的只读属性,它仅仅带有一个get访问器:
public string FirstName { get; }
因为需要相关类库的支持,因此并非在任何地方都可以随心所欲地使用不可变类型(immutable type)。最近这些年,这些各种各样的限制开始被移除。举个例子,NuGet包Newtonsoft.Json允许在JSON的序列化和反序列化的时候使用非可变类型。这个类库利用构造函数,使得匹配的参数需要创建一个新的实例。EF以前其实也有类似的限制。然而,从Entity Framework Core 1.1开始,数据列可以和fields直接映射,而非每次都需要读/写Properties。
注意:
- 关于JSON的序列化在Bonus章节里会介绍,而EF Core则将在26章进行详细介绍。第21章任务和并行编程将介绍线程核同步。
- 本章并没有再次展开C#创建不可变类型的特性,因为这部分内容已经在第三章中介绍过了。C#允许你创建一个通过get访问器自动实现的Property,编译器会自动帮你创建一个内置的readonly字段并且get访问器会返回这个字段值。后续的C#版本计划提供更多的特性来创建不可变类型,例如records。
13.1.2 函数作为一等类
在函数式编程中,函数就是第一级的类(functions as first class)。这意味着,函数可以作为函数的参数,函数可以作为函数的返回值,并且函数可以赋值给变量。
C#里也有类似的实现:委托可以存储函数地址,委托也可以当做方法的参数,委托也可以被当做返回值。然而,你需要意识到,使用一个委托和使用一个普通函数还是有区别,委托有额外的开销。使用委托的时候,其实你是创建了一个Delegate类的实例,并且这个实例拥有很多方法引用。当你调用一个委托,这个方法集合里保存的方法会挨个被调用。
高阶函数
函数式编程中,那些将其他函数作为参数或者返回值的"函数",称之为高阶函数。在C#里,委托就可以作为参数或者返回值。
在前面章节里介绍的LINQ,其实就是一种高阶函数。举个例子,where方法接收一个Func<TSource, bool>谓词(predicate):
public static IEnumerable<TSource> Where(this IEnumerable<TSource> source, Func<TSource, bool> predicate);
纯函数
函数式编程里有个术语叫纯函数(pure function)。如果可能的话尽量使用纯函数,它必须满足两个条件:
- 同样的输入,同样的输出。
- 纯函数没有任何副作用,譬如改变某些状态,或者依托于外部资源。
当然不是所有的函数都需要当纯函数实现,纯函数也不是万能的。如果某个方法需要访问外部资源,你需要考虑它是否可以分成两部分,一部分包含复杂逻辑的纯函数和一部分仅仅用来访问资源的部分。
现在你已经对函数式编程的重要概念有一个大概的了解了,接下来让我们看看C#的语法是怎么实现这些概念的。
13.2 表达式体的成员
C# 6.0允许只带有get访问器的Property或者Method使用表达式来作为自己的方法体。现在,C# 7.0的时代,允许你在任何地方使用表达式来作为方法体,只要这个方法体的实现只需要一个语句。在函数式编程中,很多方法都仅仅只有一行,因此这个特性会被经常使用。代码可以精简不少行,因为你再也不需要使用{}
了。
注意:在第三章的时候已经介绍过用表达式来书写Property和Method,而在第八章的时候介绍了如何在Event中使用表达式,所以本章可能不会再重复提及这些内容。
让我们先来看看以下的例子:
public class Person
{
public Person(string name) =>name.Split(' ').ToStrings(out _firstName, out _lastName);
private string _firstName;
public string FirstName
{
get => _firstName;
set => _firstName = value;
}
private string _lastName;
public string LastName
{
get => _lastName;
set => _lastName = value;
}
public override string ToString() => $"{FirstName}{LastName}";
}
我们在Property里使用了表达式,get和set访问器都可以使用。我们在构造函数里使用了表达式,通过split方法,将接收到的参数name按照空格分隔成一个string数组,然后对这个数组调用ToStrings()方法,通过out参数的方式,赋值给两个field,_firstName和_lastName。
这个ToStrings()方法是我们自己定义的一个扩展方法:
public static class StringArrayExtensions
{
public static void ToStrings(this string[] values, out string value1,out string value2)
{
if (values == null) throw new ArgumentNullException(nameof(values));
if (values.Length != 2) throw new IndexOutOfRangeException("only arrays with 2 values allowed");
value1 = values[0];
value2 = values[1];
}
}
根据上面的这些定义,一个Person实例可以通过一个string类型的名称进行声明,并提供了FirstName和LastName的属性,以便你可以读取相应的名称:
Person p = new Person("Katharina Nagel");
Console.WriteLine($"{p.FirstName} {p.LastName}");
13.3 扩展方法
我们在第12章的时候其实已经提到了扩展方法,前面小节里也演示了一个自定义扩展函数的例子。事实上,扩展函数这个特性能为函数式编程提供很大的帮助,接下来我打算再演示一个例子,请先看一下这段代码:
public static class FunctionalExtensions
{
public static void Use<T>(this T item, Action<T> action) where T : IDisposable
{
using (item)
{
action(item);
}
}
}
通过函数式编程,很多方法都非常的短小精悍并且往往只有一行语句,就像前面那些例子中你看到的一样,这让我们的代码简化了不少。例如我们经常使用的using语句,它也可以用一个函数代替。在上面的代码里,我们为所有实现了IDisposable的类型,扩展了一个叫做Use的方法。在这个方法里我们会调用using语句,Action<T>是一个委托,用来传递实际调用的方法,只要执行完action的方法体,实例item的资源就会被释放。
Resource就是一个实现了IDisposable接口的类,我们假定它提供了一个Foo的方法,以便我们演示IDisposable功能:
class Resource : IDisposable
{
public void Foo() => Console.WriteLine("Foo");
private bool disposedValue = false;
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
Console.WriteLine("release resource");
}
disposedValue = true;
}
}
public void Dispose() => Dispose(true);
}
以往我们通过using语句在调用完成之后释放资源,通常会这么写:
using (var r = new Resource())
{
r.Foo();
}
而通过我们扩展的Use方法,这三行代码就可以通过一行来完成:
new Resouce().Use(r=>r.Foo());