C# 函数式编程






4.98/5 (196投票s)
本文将解释如何在 C# 中使用函数式编程。
目录
引言
函数式编程是一种在 C# 中经常与面向对象编程结合使用的编程范例。C# 使您能够使用面向对象的概念进行命令式编程,但您也可以进行声明式编程。在声明式编程中,您使用一种更具描述性的方式来定义您想要做什么(what),而不是您想要如何(how)执行某个操作。例如,想象一下您想找到价格低于 20 的、按标题排序的前 10 本书。在函数式编程中,您会这样定义:
books.Where(price<20).OrderBy(title).Take(10);
在这里,您只需指定要选择价格低于 20 的书籍,按标题排序,然后取前十本。如您所见,您没有指定如何执行此操作 - 只指定了您想做什么。
现在您会说:“好吧,但这只是简单的 C# LINQ - 为什么我们称之为函数式编程?” LINQ 只是一个支持函数式编程概念的实现库。然而,函数式编程远不止于此。在本文中,您可能会发现一些有趣的使用示例。
请注意,有些语言对函数式编程有更好的支持。例如,F# 语言对函数式编程概念有更好的支持,并且可以与 C# 代码一起使用。但是,如果您不想学习一门新语言,这里您可以找到 C# 在函数式编程领域为您提供的功能。
函数作为一等公民
函数式编程中的基本概念是函数。在函数式编程中,我们需要像创建其他对象一样创建函数,操作它们,将它们传递给其他函数等等。因此,我们需要更多的自由,因为函数不仅仅是类的一部分 - 它们应该是独立的。在 C# 中,您可以像使用其他对象类型一样使用函数对象。在接下来的章节中,您将看到我们如何定义函数类型,为函数对象(引用)赋值,以及创建复杂的函数。
函数类型
函数对象必须具有某种类型。在 C# 中,我们可以定义泛型函数或强类型委托。委托可以被认为是函数原型的定义,其中定义了方法的签名。“实例对象”是委托类型的指针,指向具有与委托定义匹配的原型的函数(静态方法、类方法)。下面的代码展示了一个委托定义的示例
delegate double MyFunction(double x);
此委托定义了一个函数原型,该函数接受一个 double
参数并返回一个 double
类型的结果。请注意,只有类型是重要的,方法和参数的实际名称是任意的。此委托类型匹配许多三角函数、对数函数、指数函数、多项式函数以及其他函数。例如,您可以定义一个引用某个数学函数、通过函数变量执行它并将结果放入另一个变量的函数变量。下面的列表显示了一个示例
MyFunction f = Math.Sin;
double y = f(4); //y=sin(4)
f = Math.Exp;
y = f(4); //y=exp(4)
除了强类型委托,您还可以使用泛型函数类型 Func<T1, T2, T3, ...,Tn, Tresult>,其中 T1, T2, T3, ...,Tn 是参数的类型(如果函数有参数),Tresult 是返回类型。与前面代码等效的示例显示在以下列表中
Func<double, double> f = Math.Sin;
double y = f(4); //y=sin(4)
f = Math.Exp;
y = f(4); //y=exp(4)
您可以定义自己的命名委托或使用泛型类型。除了 Func
,您还有两个类似的泛型类型
- Predicate<T1, T2, T3, ...,Tn> 表示一个返回
true
/false
值的函数 - 等效于 Func<T1, T2, T3, ...,Tn, bool> - Action<T1, T2, T3, ...,Tn> 表示一个不返回任何值的过程 - 等效于 Func<T1, T2, T3, ...,Tn, void>
Predicate 是一个接受一些参数并返回 true
或 false
值的函数。在以下示例中,显示了一个接受 string
参数的谓词函数。此函数的值设置为 String.IsNullOrEmpty
。此函数接受一个 string
参数并返回该 string
是否为 null
或空的信息 - 因此,它匹配 Predicate<string>
类型。
Predicate<string> isEmptyString = String.IsNullOrEmpty;
if (isEmptyString("Test"))
{
throw new Exception("'Test' cannot be empty");
}
您可以将 Predicate<string>
替换为 Func<string, bool>
。
Actions 是一类可以执行的过程。它们接受一些参数但不返回任何内容。在以下示例中,显示了一个接受 string
参数的 Action,它指向标准的 Console.WriteLine
方法。
Action<string> println = Console.WriteLine;
println("Test");
当使用参数调用 Action 引用时,调用将被转发到 Console.WriteLine
方法。当您使用 Action 类型时,您只需要定义输入参数列表,因为没有返回值。在此示例中,Action<string>
等效于 Func<string, void>
。
函数值
一旦定义了函数对象,您就可以像在前面的列表中一样将它们赋值给其他现有函数,或者赋值给其他函数变量。此外,您可以像传递标准变量一样将它们传递给其他函数。如果您想为一个函数赋值,您有以下选择:
- 将函数对象指向按名称引用的现有方法。
- 使用 lambda 表达式或委托创建匿名函数,并将其赋值给函数对象。
- 创建一个函数表达式,您可以在其中添加或减去函数,并将此类多播委托赋值给函数对象(下一节将介绍)。
在以下列表中显示了一些为函数赋值的示例
Func<double, double> f1 = Math.Sin;
Func<double, double> f2 = Math.Exp;
double y = f1(4) + f2(5); //y=sin(3) + exp(5)
f2 = f1;
y = f2(9); //y=sin(9)
在这种情况下,方法被引用为 ClassName.MethodName
。除了现有函数,您还可以动态创建函数并将其赋值给变量。在这种情况下,您可以使用匿名委托或 lambda 表达式。下面的列表显示了一个示例
Func<double, double> f = delegate(double x) { return 3*x+1; }
double y = f(4); //y=13
f = x => 3*x+1;
y = f(5); //y=16
在此代码中,我们已将一个委托赋值给一个接受 double
参数 x
并返回 double
值 3*x+1
的变量。由于此动态创建的函数匹配 Func<double, double>
,因此可以将其赋值给函数变量。在第三行,赋值了一个等效的 lambda 表达式。正如您所见,lambda 表达式与函数相同:它只是 参数 => 返回表达式 格式的函数表示。下表显示了一些 lambda 表达式和等效的委托
Lambda 表达式 | 委托 |
| delegate(){ return 3; } |
() => DateTime.Now | delegate(){ return DateTime.Now; }; |
(x) => x+1 | delegate(int x){ return x+1; } |
x => Math.Log(x+1)-1 | delegate(int x){ return Math.Log(x+1)-1; } |
(x, y) => x+y | delegate(int x, int y){ return x+y;} |
(x, y) => x+y | delegate(string x, string y){ return x+y;} |
Lambda 表达式必须包含定义参数名称的部分 - 如果 lambda 表达式没有参数,应放置空括号 ()
。如果参数列表中只有一个参数,则不需要括号。在 =>
符号之后,您需要放置一个将要返回的表达式。
正如您所见,lambda 表达式等同于常规函数(委托),但它们更适用于创建短函数。此外,一个有用的优点是您不必显式定义参数类型。在上例中,定义了一个 lambda 表达式 (x, y) => x+y
,它可以用于加法运算,也可以用于字符串连接。如果您使用委托,您将需要显式定义参数类型,并且不能将一个委托用于其他类型。
构建自己的函数的能力可能很有用,当您需要创建新的函数时。例如,您可以创建一些不是 Math
类一部分但您需要使用的数学函数。其中一些函数是
- asinh(x) = log(x + sqrt(x2 + 1))
- acosh(x) = log(x + sqrt(x2 - 1))
- atanh(x) = (log(1+x) - log(1-x))/2
您可以轻松地创建这些函数并将它们赋值给函数变量,如下面的列表所示
Func<double, double> asinh = delegate(double x) { return Math.Log( x + Math.Sqrt(x*x+1) ) ;
Func<double, double> acosh = x => Math.Log( x + Math.Sqrt(x*x-1) ) ;
Func<double, double> atanh = x => 0.5*( Math.Log( 1 + x ) - Math.Log( 1 -x ) ) ;
您可以通过委托或 lambda 表达式创建这些函数,因为它们是等效的。
函数算术
您还可以将函数(委托)添加到或从表达式中减去。如果您将两个函数值相加,当调用结果函数变量时,两个函数都会被执行。这就是所谓的多播委托。您还可以从多播委托中删除一些函数。在下面的示例中,我添加了两个匹配 Action<string>
类型的函数
static void Hello(string s)
{
System.Console.WriteLine(" Hello, {0}!", s);
}
static void Goodbye(string s)
{
System.Console.WriteLine(" Goodbye, {0}!", s);
}
这些方法接受一个 string
参数并且不返回任何内容。下面的代码展示了如何应用委托算术来操作多播委托。
Action<string> action = Console.WriteLine;
Action<string> hello = Hello;
Action<string> goodbye = Goodbye;
action += Hello;
action += (x) => { Console.WriteLine(" Greating {0} from lambda expression", x); };
action("First"); // called WriteLine, Hello, and lambda expression
action -= hello;
action("Second"); // called WriteLine, and lambda expression
action = Console.WriteLine + goodbye
+ delegate(string x){
Console.WriteLine(" Greating {0} from delegate", x);
};
action("Third"); // called WriteLine, Goodbye, and delegate
(action - Goodbye)("Fourth"); // called WriteLine and delegate
首先,我们创建了三个指向函数 WriteLine
、Hello
和 Goodbye
的委托。然后,我们将函数 Hello
和一个新的 lambda 表达式添加到第一个委托。运算符 +=
用于附加将由多播委托调用的新函数。最后,它将创建一个多播委托,该委托将在被调用时调用所有三个函数。
然后,从委托操作中删除了函数 Hello
。请注意,函数 Hello
是直接通过名称添加的,但它是通过指向它的委托 hello
删除的。当 Action 被第二次调用时,只会调用两个函数。
在第三组中,将 WriteLine
委托添加到 Goodbye
方法和一个新的匿名委托。这个“和”被赋值给委托操作,因此之前的组合丢失了。当 Action(“Third
”)被调用时,这三个函数将被执行。
最后,您可以看到如何创建一个表达式并执行它。在最后一个语句中,表达式 action - goodbye
的结果是一个不包含 Goodbye
函数的操作混合。表达式的结果没有赋值给任何委托变量 - 它只是被执行了。
如果您将此代码放入某个控制台应用程序中,结果将类似于以下屏幕截图
此外,您始终可以获取有关多播委托中当前函数集的信息。以下代码获取多播委托 action
的调用列表,并为调用列表中的每个委托输出方法名称
action.GetInvocationList().ToList().ForEach(del => Console.WriteLine(del.Method.Name));
除了名称,您还可以获取函数的其他参数,例如返回类型、参数,甚至可以显式调用调用列表中的某些委托。
C# 函数式编程
现在我们可以开始函数式编程的示例了。函数可以作为参数传递的事实使我们能够创建非常通用的结构。例如,想象一下您想创建一个通用函数,该函数确定数组中满足某个条件的对象的数量。下面的示例展示了如何实现此函数
public static int Count<T>(T[] arr, Predicate<T> condition)
{
int counter = 0;
for (int i = 0; i < arr.Length; i++)
if (condition(arr[i]))
counter++;
return counter;
}
在此函数中,我们正在计算数组中满足条件的元素的数量。代码不硬编码判断条件是否满足,而是将其作为参数(谓词)传递。此函数可用于各种情况,例如计算标题长度大于 10 个字符的书籍数量,或者价格低于 20 的书籍数量,或者计算数组中的负数数量。下面显示了一些示例
Predicate<string> longWords = delegate(string word) { return word.Length > 10; };
int numberOfBooksWithLongNames = Count(words, longWords);
int numberOfCheapbooks = Count(books, delegate(Book b) { return b.Price< 20; });
int numberOfNegativeNumbers = Count(numbers, x => x < 0);
int numberOfEmptyBookTitles = Count(words, String.IsNullOrEmpty);
正如您所见,相同的函数用于不同的区域 - 您只需要定义一个不同的条件(谓词)应用于该函数。谓词可以是委托、lambda 表达式或现有静态函数。
您可以使用函数式编程来替换一些标准的 C# 构造。一个典型的例子是下面列表所示的 using
块
using (obj)
{
obj.DoAction();
}
Using
块应用于可 disposable 对象。在 using
块中,您可以处理对象,调用一些方法等。当 using
块结束时,对象将被 dispose。代替 using
块,您可以创建自己的函数来包装要应用于该对象的操作。
public static void Use<T>(this T obj, Action<T> action) where T : IDisposable
{
using (obj)
{
action(obj);
}
}
这里创建了一个接受操作(应执行的操作)并将其包装在 using
块中的扩展方法。以下代码显示了一个使用示例
obj.Use( x=> x.DoAction(); );
您可以将任何结构(如 if
、while
、foreach
)转换为函数。在下面的示例中,您可以看到如何使用 ForEach
扩展方法(它等同于 foreach
循环)从字符串列表中输出名称
var names = new List<string>();
names.ForEach(n => Console.WriteLine(n));
List<T>
类的 ForEach<T>(Action<T> action)
方法的工作方式与标准的 for each 循环相同,但这种方式,您拥有更紧凑的语法。在以下示例中,您将看到函数式编程的一些常见用法。
使用 LINQ
您在上一列表中看到的函数非常通用且有用,但在大多数情况下,您甚至不必创建它。C# 附带 LINQ 扩展,您可以在其中找到许多有用的通用函数。例如,您可以使用 LINQ Count
方法代替前面示例中定义的那个来执行相同的操作。使用 LINQ Count
函数编写的相同示例是
Predicate<string> longWords = delegate(string word) { return word.Length > 10; };
int numberOfBooksWithLongNames = words.Count(longWords);
int numberOfCheapbooks = books.Count( delegate(Book b) { return b.Price< 20; });
int numberOfNegativeNumbers = numbers.Count(x => x < 0);
此函数是直接内置的,因此您可以使用它。LINQ 还有许多其他有用的函数,如 Average
、Any
和 Min
,它们可以以相同的方式使用 - 只需传递要用于检查数组中是否包含某些对象的谓词。以下列表显示了其他 LINQ 函数的一些示例
int averageDigits = number.Average( digit => digit>=0 && digit < 10);
int isAnyCheaperThan200 = books.Any( b => b.Price< 200 );
int maxNegativeNumber = numbers.Max(x => x < 0);
在 LINQ 库中,您有许多函数可以即开即用以进行函数式编程。例如,您有一组接受谓词并处理源集合的函数 - 其中一些是
- <source>.Where( <predicate> ) - 查找源中满足谓词的所有实体
- <source>.First( <predicate> ) - 查找源中满足谓词的第一个实体
- <source>.Count( <predicate> ) - 查找源中满足谓词的实体数量
- <source>.TakeWhile( <predicate> ) - 获取源中满足某个谓词的元素序列
- <source>.SkipWhile( <predicate> ) - 跳过源中满足谓词的实体
- <source>.Any( <predicate> ) - 检查源中的任何对象是否满足谓词中的条件
- <source>.All( <predicate> ) - 检查源中的所有对象是否满足谓词中的条件
您可以在 使用 LINQ 查询 文章中查看这些方法的更多详细信息。LINQ 具有处理返回某些值的常规函数的函数。例如,如果您传递返回 string
或 int
的条件函数,您可以使用以下函数对集合元素进行排序
<source>.OrderBy(<criterion>).ThenBy(<criterion>)
Criterion 是任何函数,它为源集合中的每个元素返回一个可用于排序的值。
我在这里不会展示太多 LINQ 的细节,但如果您对此感兴趣,可以查看 使用 LINQ 查询 文章。但是,我将只展示一些用法示例,有些人可能不知道。
想象一下,您需要找到字符串数组中 null
或空字符串的数量。代码如下
string[] words = new string[] { "C#", ".NET", null, "MVC", "", "Visual Studio" };
int num = words.Count( String.IsNullOrEmpty );
正如您所见,您不必总是将 lambda 表达式传递给 LINQ 函数。LINQ 函数需要一个谓词,因此您可以传递一个 lambda 表达式(在 90% 的情况下,您会这样做),但您也可以直接传递一个应被评估的函数。
实际上,您将对 90% 的需要实现的任何功能使用 LINQ,并且只为尚未提供的功能创建自定义函数。
高阶函数
将函数视为常规对象使我们能够将它们用作其他函数的参数和结果。处理其他函数的函数称为高阶函数。前面示例中的 Count
函数是一个高阶函数,因为它接受一个谓词函数并在检查条件时将其应用于每个元素。使用 LINQ 库时,高阶函数很常见。例如,如果您想使用某个函数将一个序列转换为一个新的序列,您将使用类似 LINQ Select
函数的内容
var squares = numbers.Select( num => num*num );
在上例中,您可以看到一个 LINQ 函数 Select
,它将集合中的每个数字映射到其平方值。此 Select
函数没什么特别的,它可以轻松地实现为以下高阶函数
// Apply a function f T1 -> T2 to each element of data using a List
public static IEnumerable<T2> MySelect<T1, T2>(this IEnumerable<T1> data, Func<T1, T2> f)
{
List<T2> retVal= new List<T2>();
foreach (T1 x in data) retVal.Add(f(x));
return retVal;
}
在此示例中,该函数遍历列表,将函数 f
应用于每个元素,将结果放入新列表中,然后返回集合。通过更改函数 f
,您可以使用相同的通用高阶函数创建原始序列的各种转换。您可以在下面的 Akram 的评论中看到此函数的更多示例。
示例 - 函数组合
在这里,您可以看到如何使用高阶函数来实现两个函数的组合。让我们想象我们有一个函数 f
(类型为 Func<X,Y>
),它将类型 X
的元素转换为类型 Y
的元素。此外,我们还需要另一个函数 g
(类型为 Func<Y,Z>
),它将类型 Y
的元素转换为类型 Z
的元素。这些函数和元素集显示在下图
现在,我们想创建一个单一函数 fog(x)
,它将类型 X
的元素直接转换为类型 Z
的元素(函数类型为 Func<X,Z>
)。此函数将按如下方式工作 - 首先,它将函数 f
应用于类型 X
的某个元素以计算集合 Y
中的元素,然后它将函数 g
应用于结果。此公式如下所示
fog(x) = g(f(x))
此函数代表函数 f
和 g 的组合,类型为 Func<X,Z>
,并直接将类型 X
的元素映射到类型 Z
的元素。下图显示了这一点
现在,我们可以创建一个实现此组合的高阶函数。此函数将接受两个泛型类型的函数 Func<X,Y>
和 Func<Y,Z>
(表示将要组合的函数),并返回一个类型为 Func<X,Z>
的函数(表示结果组合)。创建两个函数组合的高阶函数的代码如下所示
static Func<X, Z> Compose<X, Y, Z>(Func<X, Y> f, Func<Y, Z> g)
{
return (x) => g(f(x));
}
如上所述,此高阶函数接受两个函数 f
和 g
,并创建一个接受参数 x
并计算 g(f(x))
的新函数。根据上面的定义,这就是函数组合。
如果您想使用此函数创建计算 exp(sin(x))
的函数,您将使用如下代码
Func<double, double> sin = Math.Sin;
Func<double, double> exp = Math.Exp;
Func<double, double> exp_sin = Compose(sin, exp);
double y = exp_sin(3);
在这种情况下,X
、Y
和 Z
是相同的类型 - double
。
正如您所见,Compose
函数的结果不是一个值 - 它是一个可以执行的新函数。您可以使用类似的方法生成自己的函数。
异步函数
函数的一个重要特性是它们可以异步调用,即您可以启动一个函数,继续工作,然后在需要结果时等待函数完成。C# 中的每个函数对象都有以下方法
BeginInvoke
,它启动函数执行但不等待函数完成IsCompleted
,它检查执行是否完成EndInvoke
,它阻塞当前线程的执行直到函数完成
下面的列表显示了一个异步执行函数的示例
Func<int, int, int> f = Klass.SlowFunction;
//Start execution
IAsyncResult async = f.BeginInvoke(5, 3, null, null); //calls function with arguments (5,3)
//Check is function completed
if(async.IsCompleted) {
int result = f.EndInvoke(async);
}
//Finally - demand result
int sum = f.EndInvoke(async);
在此示例中,对某个慢函数(计算两个数字之和)的引用被赋值给函数变量 f
。您可以单独开始调用并传递参数,检查计算是否完成,并显式请求结果。下面的列表显示了这种函数的示例
public class Klass{
public static int SlowFunction(int x, int y){
Thread.Sleep(10000);
return x+y;
}
}
这只是一个模拟,但您可以在发送电子邮件、执行长 SQL 查询等的函数中使用此方法。
带回调的异步函数
在前面的示例中,您看到了参数是如何传递给函数的,但还有两个额外的参数设置为 null
。这些参数是
- 回调函数,当函数完成时将被调用
- 要传递给回调函数的某个对象
这样,您就不需要显式检查函数是否已执行 - 只需传递一个回调函数,当函数完成执行时将被调用。下面的代码显示了一个带回调的调用示例
Func<int, int, int> f = Klass.SlowFunction;
//Start execution
f.BeginInvoke(5, 3, async =>
{
string arguments = (string)async.AsyncState;
var ar = (AsyncResult)async;
var fn = (Func<int, int, int>)ar.AsyncDelegate;
int result = fn.EndInvoke(async);
Console.WriteLine("f({0}) = {1}", arguments, result);
},
"5,3"
);
回调函数(或此示例中的 lambda 表达式)接受一个 async
参数,该参数包含有关异步调用的信息。您可以使用 AsyncState
属性来确定 BeginInvoke
调用的第四个参数(在本例中是以字符串“5,3
”传递的参数)。如果将 lambda 表达式的参数转换为 AsyncResults
,则可以找到原始函数并调用其 EndInvoke
方法。结果,您可以将其打印到控制台窗口。
正如您所见,主代码中不再需要显式检查函数是否已完成 - 只需告诉它完成后应该做什么。
元组
元组是动态表示数据结构的一种有用方式,形式为 (1
, "name
", 2
, true
, 7
)。您可以通过将属于元组的一组字段传递给 Tuple.Create
方法来创建元组。下面的示例展示了一个使用元组的函数
Random rnd = new Random();
Tuple<double, double> CreateRandomPoint() {
var x = rnd.NextDouble() * 10;
var y = rnd.NextDouble() * 10;
return Tuple.Create(x, y);
}
此函数在 10x10 的区域内创建一个随机的二维点。除了元组,您还可以显式定义一个 Point
类并在定义中传递它,但这种方式更通用。
当您想定义使用结构化数据的通用函数时,可以使用元组。如果您想创建一个谓词来确定二维点是否位于半径为 1 的圆内,您将使用如下代码
Predicate<Tuple<double, double>> isInCircle;
isInCircle = t => ( t.Item1*t.Item1+t.Item2*t.Item2 < 1 );
在这里,我们有一个谓词,它接受一个具有两个 double
元素的元组,并确定坐标是否在圆内。您可以使用 Item1
、Item2
等属性访问元组中的任何字段。现在让我们看看如何使用此函数和谓词来查找位于半径为 1
的圆内的所有点。
for(int i=0; i<100; i++){
var point = CreateRandomPoint();
if(isInCircle(t))
Console.WriteLine("Point {0} in placed within the circle with radius 1", point);
}
在这里,我们有一个循环,在该循环中生成 100 个随机点,并对每个点进行检查 isInCircle
函数是否满足。
当您想创建不创建预定义类的数据结构时,可以使用元组。元组可以在使用动态对象或匿名对象的相同代码中使用,但元组的一个优点是您可以将它们用作参数或返回值。
这里有一个更复杂的例子 - 想象一下您已将公司信息表示为类型为 Tuple<int, string, bool, int>
的元组,其中第一个项是 ID,第二个是公司名称,第三个是标记该元组为分支的标志,最后一个项是总部的 ID。这在您创建某种类型的查询时非常常见,例如 S
ELECT id, name, isOffice, parentOfficeID
,并且您将每个列放入元组的单独维度中。现在,我们将创建一个函数,该函数接受一个元组并创建一个新的 branch office
元组
Tuple<int, string, bool, int?> CreateBranchOffice(Tuple<int, string, bool, int?> company){
var branch = Tuple.Create(1, company.Item2, company.Item3, null);
Console.WriteLine(company);
branch = Tuple.Create(10*company.Item1+1, company.Item2 +
" Office", true, company.Item1);
Console.WriteLine(t);
var office = new { ID = branch.Item1,
Name = branch.Item2,
IsOffice = branch.Item3,
ParentID = company.Item4 };
return branch;
}
在此示例中,函数接受 company
并创建一个 branch office
。company
和 branch office
都表示为具有四个项的元组。您可以使用 Tuple.Create
方法创建新元组,并使用 Item
属性访问元组的元素。
此外,在此代码中,创建了一个匿名对象 office
,它具有 ID
、Name
、IsOffice
和 ParentID
属性,这等同于元组(此对象只是创建了但没有使用)。在此语句中,您可以看到将元组转换为实际对象的容易程度。您还可以看到元组的一个优点 - 如果您想将 office
作为返回值,您将面临定义 CreateBranchOffice
函数的返回类型的困难。office
对象是匿名对象,其类在函数体外部不为人所知,因此您无法定义函数的返回类型。如果您想返回一个动态结构而不是元组,您需要定义一个单独的类而不是匿名类,或者您需要将返回类型设置为 dynamic
,但那样您将丢失有关返回对象结构的信息。
闭包
当我们谈论函数时,我们必须谈论变量的作用域。在标准函数中,我们有以下类型的变量
- 传递给函数的参数
- 在函数体内部定义和使用的局部变量。它们在函数结束时立即销毁。
- 在函数外部定义并在函数体中引用的全局变量。这些变量即使在函数结束后仍然存在。
当我们创建委托或 lambda 表达式时,我们使用参数和局部变量,并且我们可以引用委托外部的变量。下面的列表显示了一个示例
int val = 0;
Func<int, int> add = delegate(int delta) { val+=delta; return val; };
val = 10;
var x = add(5); // val = 15, x = 15
var y = add(7); // val = 22, y = 22
在这里,我们创建了一个委托,它将参数的值加到在委托外部定义的变量 val
上。它还返回一个结果值。我们可以同时在主代码中以及间接通过委托调用修改 val
变量。
那么,如果变量 val
是高阶函数中返回此委托的局部变量,会发生什么?下面的列表显示了那种函数的示例
static Func<int, int> ReturnClosureFunction()
{
int val = 0;
Func<int, int> add = delegate(int delta) { val += delta; return val; };
val = 10;
return add;
}
以及调用代码
Func<int, int> add = ReturnClosureFunction();
var x = add(5);
var y = add(7);
这可能是一个问题,因为局部变量 val
在 ReturnClosureFunction
结束时消失,但委托被返回并且仍然存在于调用代码中。委托代码会因为引用变量 val
而中断,而 val
在 ReturnClosureFunction
函数之外不存在吗?答案是 - **不**。如果委托引用外部变量,它将被绑定到委托,只要它存在于任何作用域中。这意味着在上面的调用代码中,x
变量将被设置为 15
,y
变量将被设置为 22
。如果委托引用其作用域之外的某个变量,该变量将表现为函数对象(委托)的属性 - 无论何时调用函数,它都将成为函数的一部分。
我们将在以下两个示例中看到闭包的使用方式。
示例 1 - 共享数据
这种有趣的特性可以用来实现函数之间的数据共享。使用闭包共享变量,您可以实现各种数据结构,如列表、堆栈、队列等,并仅公开修改共享数据的委托。例如,让我们检查以下代码
int val = 0;
Action increment = () => val++;
Action decrement = delegate() { val--; };
Action print = () => Console.WriteLine("val = " + val);
increment(); // val = 1
print();
increment(); // val = 2
print();
increment(); // val = 3
print();
decrement(); // val = 4
print();
在这里,我们有一个变量,它由 lambda 表达式 increment
和委托 decrement
更新。它还被委托 print
使用。每次您使用任何函数时,它们都会读/写相同的共享变量。即使共享变量超出了使用范围,这也将起作用。例如,我们可以创建一个定义此局部变量的函数,该函数将返回三个函数
static Tuple<Action, Action, Action> CreateBoundFunctions()
{
int val = 0;
Action increment = () => val++;
Action decrement = delegate() { val--; };
Action print = () => Console.WriteLine("val = " + val);
return Tuple.Create<Action, Action, Action>(increment, decrement, print);
}
在此示例中,我们返回一个包含三个 Action 的元组作为返回结果。以下代码显示了如何在它们被创建的代码之外使用这三个函数
var tuple = CreateBoundFunctions();
Action increment = tuple.Item1;
Action decrement = tuple.Item2;
Action print = tuple.Item3;
increment();
print(); // 1
increment();
print(); // 2
increment();
print(); // 3
decrement();
print(); // 2
此代码与前面所有三个函数都在同一作用域中创建的代码相同。每次我们调用任何绑定的函数时,它们都会修改不再在作用域中的共享变量。
示例 2 - 缓存
现在我们将看到另一种使用闭包实现缓存的方法。缓存可以通过创建一个接受要缓存的函数并返回一个在一段时间内缓存结果的新函数来实现。下面的列表显示了一个示例
public static Func<T> Cache<T>(this Func<T> func, int cacheInterval)
{
var cachedValue = func();
var timeCached = DateTime.Now;
Func<T> cachedFunc = () => {
if ((DateTime.Now - timeCached).Seconds >= cacheInterval)
{
timeCached = DateTime.Now;
cachedValue = func();
}
return cachedValue;
};
return cachedFunc;
}
在这里,我们扩展了返回某个类型 T 的函数(Func<T> func
)。此扩展方法接受缓存函数值的周期并返回一个新函数。首先,我们执行函数以确定要缓存的值,并记录缓存该值的时间。然后,我们创建了一个与 Func<T>
类型相同的函数 cachedFunc
。此函数确定当前时间与缓存值的时间之间的差是否大于或等于缓存间隔。如果是,则新的缓存时间将设置为当前时间,并更新缓存值。结果,此函数将返回缓存的值。
变量 cacheInterval
、cachedValue
和 timeCached
绑定到缓存的函数,并作为函数的一部分行为。这使我们能够记忆最后一个值并确定它应该缓存多长时间。
在以下示例中,我们可以看到如何使用此扩展来缓存返回当前时间的函数的值
Func<DateTime> now = () => DateTime.Now;
Func<DateTime> nowCached = now.Cache(4);
Console.WriteLine("\tCurrent time\tCached time");
for (int i = 0; i < 20; i++)
{
Console.WriteLine("{0}.\t{1:T}\t{2:T}", i+1, now(), nowCached());
Thread.Sleep(1000);
}
我们创建了一个不接受参数并返回当前日期的函数(第一个 lambda 表达式)。然后,我们将 Cache 扩展函数应用于此函数以创建缓存版本。此缓存版本将在 4 秒的间隔内返回相同的值。
为了在 for
循环中演示缓存,将输出原始版本和缓存版本的计算值。结果显示在以下输出窗口中
正如您所见,函数的可缓存版本每四秒返回相同的时间值,这取决于缓存间隔的定义。
递归
递归是函数可以调用自身的特性。递归最常用的示例之一是整数的阶乘计算。n 的阶乘(Fact(n) = n!)是所有小于或等于 n 的数的乘积,即 n! = n*(n-1)*(n-2)*(n-3)*...*3*2*1。阶乘定义 n! = n * (n-1)! 有一个有趣的特点,这个特点被用来定义递归。
如果 n 为 1,则我们无需计算阶乘 - 它是 1。如果 n 大于 1,我们可以计算 n-1 的阶乘并乘以 n。下面的列表显示了这种递归函数的示例
static int Factorial(int n)
{
return n < 1 ? 1 : n * Factorial(n - 1);
}
这在静态命名的函数中很容易做到,但您不能直接在委托/lambda 表达式中执行此操作,因为它们不能按名称引用自身。但是,有一个技巧可以实现递归
static Func<int, int> Factorial()
{
Func<int, int> factorial = null;
factorial = n => n < 1 ? 1 : n * factorial(n - 1);
return factorial;
}
在这个高阶函数(它返回一个函数对象)中,factorial
被定义为局部变量。然后,factorial
被赋值给使用上述逻辑的 lambda 表达式(如果参数为 1 或更小,则返回 1,否则调用 factorial
并传入参数 n-1
)。这样,我们在定义之前声明了一个函数,并在定义中使用了递归调用它的引用。以下示例显示了如何使用此递归函数
var f = Factorial();
for(int i=0; i<10; i++)
Console.WriteLine("{0}! = {1}", i, f(i));
如果您可以将阶乘实现为高阶函数,那么它意味着您可以将其格式化为匿名函数。在以下示例中,您可以看到如何定义一个计算数字阶乘的委托。此委托用于 LINQ 以查找阶乘小于 7 的所有数字。
var numbers = new[] { 5,1,3,7,2,6,4};
Func<int, int> factorial = delegate(int num) {
Func<int, int> locFactorial = null;
locFactorial = n => n == 1 ? 1 : n * locFactorial(n - 1);
return locFactorial(num);
};
var smallnums = numbers.Where(n => factorial(n) < 7);
此外,您可以将其转换为 lambda 表达式并直接传递给 LINQ 函数,如下面的示例所示
var numbers = new[] { 5,1,3,7,2,6,4};
var smallnums = numbers.Where(num => {
Func<int, int> factorial = null;
factorial = n => n == 1 ? 1 : n * factorial(n - 1);
return factorial(num) < 7;
});
不幸的是,这个 lambda 表达式不像您期望的那样紧凑,因为我们需要定义局部阶乘函数。正如您所见,即使使用匿名函数/lambda 表达式,也可以实现递归。
部分函数
部分函数是通过使用默认值来减少函数参数数量的函数。如果您有一个具有 N 个参数的函数,您可以创建一个具有 N-1 个参数的包装函数,该函数调用原始函数并使用固定的(默认)参数。
想象一下,您有一个函数 Func<double, double, double>
,它有两个 double
参数并返回一个 double
结果。您可能希望创建一个新的单参数函数,该函数具有第一个参数的默认值。在这种情况下,您将创建一个接受第一个参数默认值的部分高阶函数,并创建一个单参数函数,该函数始终将第一个值传递给原始函数,而唯一的参数只是将参数传递给函数并设置默认值
public static Func<T2, TR> Partial1<T1, T2, TR>(this Func<T1, T2, TR> func, T1 first)
{
return b => func(first, b);
}
Math.Pow(double, double)
是一个可以用此部分函数扩展的示例。使用此函数,我们可以设置第一个参数的默认值并派生出 2x、ex(Math.Exp
)、5x、10x 等函数。下面的示例显示了一个示例
double x;
Func<double, double, double> pow = Math.Pow;
Func<double, double> exp = pow.Partial1( Math.E );// exp(x) = Math.Pow(Math.E, x)
Func<double, double> step = pow.Partial1( 2 );// step(x) = Math.Pow(2, x)
if (exp(4) == Math.Exp(4))
x = step(5); //x = 2*2*2*2*2
除了第一个参数,我们还可以将其第二个参数设置为默认值。在这种情况下,部分函数将如下所示
public static Func<T1, TR> Partial2<T1, T2, TR>(this Func<T1, T2, TR> func, T2 second)
{
return a => func(a, second);
}
使用此函数,我们可以从 Math.Pow
函数派生出如 x2(平方)、 √ x (平方根)、x3(立方)等函数。以下列表显示了从 Math.Pow
派生这些函数的代码
double x;
Func<double, double, double> pow = Math.Pow;
Func<double, double> square = pow.Partial2( 2 ); // square(x) = Math.Pow(x,2)
Func<double, double> squareroot = pow.Partial2( 0.5 ); // squareroot(x) = Math.Pow(x, 0.5)
Func<double, double> cube = pow.Partial2( 3 ); // cube(x) = Math.Pow(x,3)
x = square(5); //x = 25
x = sqrt(9); //x = 3
x = cube(3); //x = 27
在下面的示例中,将展示如何使用此功能。
示例 - 减少空间维度
我将用一个数学示例来解释部分函数(以及稍后介绍的柯里化)- 在三维坐标系统中确定点的距离。三维坐标系中的一个点显示在下图。正如您所见,每个点都有三个坐标 - 每个轴一个。
想象一下,您需要确定点到坐标系中心 (0,0,0) 的距离。这个函数将如下所示
static double Distance(double x, double y, double z)
{
return Math.Sqrt(x * x + y * y + z * z);
}
此函数确定三维空间中的标准欧几里德距离。您可以像使用其他函数一样使用此函数
Func<double, double, double, double> distance3D = Distance;
var d1 = distance3D(3, 6, -1);
var d2 = distance3D(3, 6, 0);
var d3 = distance3D(3, 4, 0);
var d4 = distance3D(3, 3, 0);
想象一下,您总是处理地面上的点 - 这些是二维点,最后一个坐标(高度)始终为 z=0
。在前图中,显示了一个 Pxy 点(点 P 在 xy 平面上的投影),其中 z 坐标为 0。如果您处理 xy 平面上的二维点并想确定这些点到中心的距离,您可以使用 Distance
函数,但您需要重复最后一个参数 z=0 每次调用,或者您需要重写原始函数只使用二维。但是,您可以创建一个高阶函数,该函数接受原始的 3D 函数并返回一个 2D 函数,其中 z 的默认值始终设置。此函数显示在以下示例中
static Func<T1, T2, TResult> SetDefaultArgument<T1, T2, T3,
TResult>(this Func<T1, T2, T3, TResult> function, T3 defaultZ)
{
return (x, y) => function(x, y, defaultZ);
}
此函数接受一个具有三个 double
参数并返回一个 double
值的函数,以及一个将作为固定最后一个参数的 double
值。然后它返回一个具有两个 double
参数的新函数,该函数将两个参数和一个固定值传递给原始函数。
现在您可以应用默认值并使用返回的 2D 距离函数
Func<double, double, double> distance2D = distance3D.SetDefaultArgument(0);
var d1 = distance2D(3, 6); // distance3D(3, 6, 0);
var d2 = distance2D(3, 4); // distance3D(3, 4, 0);
var d3 = distance2D(1, 2); // distance3D(1, 2, 0);
您可以应用任何高度。例如,您可能希望处理高度为 z=3
的点,而不是高度为 z=0
的点,如下图所示
我们可以通过将默认参数设置为 3
来使用相同的 distance3D
函数。代码显示在以下列表中
Func<double, double, double> distance2D_atLevel = distance3D.SetDefaultArgument(3);
var d1 = distance2D_atLevel(3, 6); // distance3D(3, 6, 3);
var d2 = distance2D_atLevel(3, 3); // distance3D(3, 3, 3);
var d3 = distance2D_atLevel(1, 1); // distance3D(1, 1, 3);
var d4 = distance2D_atLevel(0, 6); // distance3D(0, 6, 3);
如果您需要一个单维函数,您可以创建另一个函数扩展,该扩展将一个两参数函数转换为一个单参数函数
static Func<T1, TResult> SetDefaultArgument<T1, T2, TResult>
(this Func<T1, T2, TResult> function, T2 defaultY)
{
return x => function(x, defaultY);
}
这个高阶函数与前一个类似,但它使用一个两参数函数并返回一个单参数函数。以下是一些如何使用此单值函数的示例
Func<double, double> distance = distance2D.SetDefaultArgument(3);
var d1 = distance(7); //distance3D(7, 3, 0)
var d2 = distance(3); //distance3D(3, 3, 0)
var d3 = distance(0); //distance3D(0, 3, 0)
distance = distance3D.SetDefaultArgument(2).SetDefaultArgument(4);
double d4 = distance(12); //distance3D(12, 4, 2)
double d5 = distance(-5); //distance3D(-5, 4, 2)
double d6 = distance(-2); //distance3D(-2, 4, 2)
在此示例中,我们正在创建一个单参数函数 Distance
。我们可以通过为两参数 distance2D
函数设置默认参数值来创建此函数,或者通过为三参数函数 distance3D
设置两个默认值来创建。在两种情况下,我们都将得到一个具有一个参数的函数。正如您所见,参数分区是分割函数的好方法。
柯里化函数
在前一个示例中,展示了如何通过将参数数量减少一个来转换函数。如果您想进一步细分,您将需要为分区创建一个另一个高阶函数。柯里化是通过使用一个高阶函数将 N 个参数的函数分解为 N 个单参数调用来转换函数的一种方法。
例如,想象一下您有一个函数,它使用起始位置和长度从字符串中提取子字符串。此方法可能如下面的示例所示调用
var substring = mystring.Substring(3,5);
如果您有两个单参数函数而不是一个双参数函数,那将更方便,并且调用将如下所示
var substring = mystring.Skip(3).Take(5);
使用两个单参数函数,您可以更自由地使用和组合函数。例如,如果您需要从第三个字符到末尾的子字符串,您将只调用 Skip(3)
。如果您只需要字符串的前五个字符,您将只调用 Take(5)
而不调用 Skip
。在原始函数中,即使您不需要,您也需要为起始位置或长度传递默认参数,或者创建 Substring
函数的各种组合,其中第一个参数默认为 0
,第二个参数默认为长度。
柯里化是一种将 N 个参数的函数分解为 N 个单参数调用的方法。为了演示柯里化,我们将在此使用与前一个示例相同的 3D Distance
函数
static double Distance(double x, double y, double z)
{
return Math.Sqrt(x * x + y * y + z * z);
}
现在我们将使用以下通用高阶函数来将三参数函数分解为单参数函数列表
static Func<T1, Func<T2, Func<T3, TResult>>> Curry<T1, T2,
T3, TResult>(Func<T1, T2, T3, TResult> function)
{
return a => b => c => function(a, b, c);
}
此函数将三参数函数分解为一组三个单参数函数(monads)。现在我们可以将 Distance
函数转换为柯里化版本。我们需要应用 Curry
高阶函数并传入所有 double
参数
var curriedDistance = Curry<double, double, double, double>(Distance);
double d = curriedDistance(3)(4)(12);
此外,您还可以看到函数是如何被调用的。它不是作为三参数函数 distance (3, 4, 12) 调用,而是作为单参数函数链 curriedDistance(3)(4)(12)
调用。在此示例中,使用了返回原始函数柯里化版本的单独函数,但您可以将其创建为扩展方法。您需要做的就是将其放入一个单独的 static
类并在第一个参数中添加 this
修饰符
public static Func<T1, Func<T2, Func<T3, TResult>>>
CurryMe<T1, T2, T3, TResult>(this Func<T1, T2, T3, TResult> function)
{
return a => b => c => function(a, b, c);
}
现在您可以使用更方便的语法(例如函数对象的作为方法,无需显式定义类型)来柯里化函数
Func<double, double, double, double> fnDistance = Distance;
var curriedDistance = fnDistance.CurryMe();
double d = curriedDistance(3)(4)(12);
请注意,您可以将此扩展应用于函数对象(fnDistance
)。在这里,我们将演示在与前一节相同的示例中使用 curry 函数 - 减少三维空间。
示例 - 减少空间维度
使用一个柯里化函数,您可以轻松地派生出任何参数数量较少的函数。例如,想象一下您想创建一个二维距离函数,该函数计算高度 z=3 上的点的距离,如下面的示例所示
为了派生一个只处理高度为 z=3
的点的函数,您需要调用柯里化距离函数并传入参数 (3),结果您将得到一个用于确定该级别距离的 2D 函数。下面的列表显示了一个示例
Func<double, Func<double, double>> distance2DAtZ3 = curriedDistance(3);
double d2 = distance2DAtZ3(4)(6); // d2 = distance3D(4, 6, 3)
double d3 = distance2DAtZ3(0)(1); // d3 = distance3D(0, 1, 3)
double d4 = distance2DAtZ3(2)(9); // d4 = distance3D(2, 9, 3)
double d5 = distance2DAtZ3(3)(4); // d5 = distance3D(3, 4, 3)
如果您需要一个单参数距离函数,该函数计算高度为 y=4
和 z=3
的点的距离,您可以通过附加参数来切分 2D 函数 distance2DAtZ3
Func<double, double> distance1DAtY4Z3 = distance2DAtZ3(4);
double d6 = distance1DAtY4Z3(-4); // d6 = distance3D(-4, 4, 3)
double d7 = distance1DAtY4Z3(12); // d7 = distance3D(12, 4, 3)
double d8 = distance1DAtY4Z3(94); // d8 = distance3D(94, 4, 3)
double d9 = distance1DAtY4Z3(10); // d9 = distance3D(10, 4, 3)
如果您尚未切分函数 distance2DAtZ3
,您可以直接在原始柯里化函数上应用两个默认参数
Func<double, double> distance1DAtY4Z3 = curriedDistance(4)(3);
double d6 = distance1DAtY4Z3(-4); // d6 = distance3D(-4, 4, 3)
double d7 = distance1DAtY4Z3(12); // d7 = distance3D(12, 4, 3)
double d8 = distance1DAtY4Z3(94); // d8 = distance3D(94, 4, 3)
double d9 = distance1DAtY4Z3(10); // d9 = distance3D(10, 4, 3)
正如您所见,柯里化使您能够仅使用一个通用的高阶函数轻松地减少多值函数的参数数量,而不是像在前一个示例中那样为每个应该减少的参数编写几个部分函数。
反柯里化
您还可以使用高阶函数将柯里化函数还原回去。在这种情况下,具有多个单参数的柯里化版本将被转换回多参数函数。下面的列表显示了一个将三参数柯里化函数反柯里化的扩展方法的示例
public static Func<T1, T2, T3, TR> UnCurry<T1, T2, T3, TR>(
this Func<T1, Func<T2, Func<T3, TR>>> curriedFunc)
{
return (a, b, c) => curriedFunc(a)(b)(c);
}
此函数接受柯里化版本的函数,并创建一个新的三参数函数,该函数将在单参数模式下调用柯里化函数。下面的列表显示了一个 Uncurry
方法的用法示例
var curriedDistance = Curry<double, double, double, double>(Distance);
double d = curriedDistance(3)(4)(12);
Func<double, double, double, double> originalDistance = curriedDistance.UnCurry();
d = originalDistance(3, 4, 12);
结论
在本文中,我们看到了 C# 函数式编程的一些基本可能性。函数式编程是一个更广泛的领域,无法在一篇文章中完全解释。因此,您可能需要探索 C# 中的许多其他概念,才能创建更有效的函数式编程代码。其中一些概念包括表达式树、惰性求值、缓存等。此外,您可能还会发现函数式编程在 F# 或 Haskel 等其他语言中的实现方式。尽管 C# 没有这些语言的相同可能性,但您可能会从中获得关于如何编写更有效的函数式构造的一些想法。要涵盖函数式编程的所有内容,可能需要写一整本书,但我相信我已经成功地涵盖了本文中的大部分重要方面。
此外,您可以查看下面的评论部分,其中发布了许多关于 惰性求值、多重继承、访问者设计模式实现、折叠 等的示例(非常感谢 Akram 提供大部分示例)。我将这些示例的较短版本添加到了本文中,但您可以在评论中找到许多关于这些主题的完整示例。
您还可以查看 数据驱动编程结合 C# 函数式编程 文章中一些很棒的函数式编程示例。
历史
在 Akram 和 noav 等人的帮助下,我一直在改进本文,他们在评论区发布了有趣的示例和建议。在这里,您可以找到有关文章主要变更的信息。
- 2012 年 5 月 11 日 - 闭包。添加了描述函数式编程中闭包的章节。
- 2012 年 5 月 12 日 - 缓存。根据 noav 的 Memoize 扩展想法,在闭包部分添加了一个缓存值的示例。