Skip to content

Latest commit

 

History

History
1017 lines (789 loc) · 46.6 KB

File metadata and controls

1017 lines (789 loc) · 46.6 KB

十、Lambdas, LINQ,和函数式编程

尽管 C# 的核心是一种面向对象的编程语言,但它实际上是一种多范式语言。 到目前为止,在本书中,我们已经讨论了命令式编程、面向对象编程和泛型编程。 然而,C# 也支持函数式编程特性。 在[第七章](07.html# _idTextAnchor134),*【收藏 T7】,和【显示】第八章,【病人】*高级主题,我们已经使用其中的一些,例如 lambdas 和综合语言查询(LINQ)

在本章中,我们将从函数式编程的角度详细讨论这些问题。 学习函数式编程技术将帮助您以声明式的方式编写代码,这种方式通常比等效的 im 执行代码更简单、更容易理解。

本章所涵盖的主题如下:

  • 函数式编程
  • 作为一等公民
  • Lambda 表达式
  • LINQ
  • 更实用的编程概念

在本章结束时,你将能够详细地理解 lambda 表达式,并能够将它们与 LINQ 一起使用,从各种来源查询数据。 此外,您将熟悉函数式编程的概念和技术,如高阶函数、闭包、单子和单子。

让我们以函数式编程及其核心原则的概述开始本章。

函数式编程

C# 是一种通用的多范式编程语言。 然而,到目前为止,在本书中,我们只讨论了命令式编程范式,它使用语句来改变程序的状态,并专注于描述程序如何操作。 在命令式编程中,函数可能有副作用,因此在它们执行时改变程序状态。 另外,函数的执行也可能取决于程序的状态。

相反的范例是函数式编程,它关注于描述一个程序做什么,而不是如何做什么。 函数式编程将计算视为对函数的求值; 它使用不可变数据,避免改变状态。 函数式编程是一种声明式编程范式,其中使用表达式而不是语句。 函数不再有副作用,而是幂等的。 这意味着每次调用具有相同参数的函数都会产生相同的结果。

功能性的编程提供了几个优点,包括以下几点:

  • 代码更容易理解和维护,因为函数不会改变状态,只依赖于它们接收的参数。
  • 出于同样的原因,代码更容易测试。
  • 实现并发更简单、更有效,因为数据是不可变的,函数也没有副作用,这就避免了数据竞争。

不变性(对象具有不变的状态)和副作用自由函数(函数在其局部范围之外不修改值或状态)是函数式编程的核心。 为了更好地理解这一点,让我们看看下面的示例。 我们有一个名为Rectangle(也可以是一个类)的结构,它表示一个矩形:

struct Rectangle
{
    public int Left;
    public int Right;
    public int Top;
    public int Bottom;
    public int Width { get { return Right - Left; } }
    public int Height { get { return Bottom - Top; } }
    public Rectangle(int l, int t, int r, int b)
    {
        Left = l;
        Top = t;
        Right = r;
        Bottom = b;
    }
}

我们可以实例化该类型并更改其属性。 例如,如果我们想将矩形的宽度膨胀为 10 个单位,在每个方向上相等,我们可以这样做:

var r = new Rectangle(10, 10, 30, 20);
r.Left -= 5;
r.Right += 5;
r.Top -= 5;
r.Bottom += 5;

我们也可以编写一个我们可以调用的函数。 这可以是一个成员函数,如下所示:

public void Inflate(int l, int t, int r, int b)
{
    Left -= l;
    Right += r;
    Top -= t;
    Bottom += b;
}
// invoked as
r.Inflate(5, 0, 5, 0);

这个也可以是非成员函数,如下面的代码所示。 两者之间的区别只是设计问题。 如果我们不能修改源代码,将其作为扩展方法编写是唯一的选择:

static void Inflate(ref Rectangle rect, 
                    int l, int t, int r, int b)
{
    rect.Left -= l;
    rect.Right += r;
    rect.Top -= t;
    rect.Bottom += b;
}
// invoked as
Inflate(ref r, 5, 0, 5, 0);

矩形数据类型是可变的,因为它的状态可以改变。 **Inflate()**方法有副作用,因为它改变了矩形的状态。 在函数式编程中,矩形应该是不可变的。 这里显示了一个可能的实现:

struct Rectangle
{
    public readonly int Left;
    public readonly int Right;
    public readonly int Top;
    public readonly int Bottom;
    public int Width { get { return Right - Left; } }
    public int Height { get { return Bottom - Top; } }
    public Rectangle(int l, int t, int r, int b)
    {
        Left = l;
        Top = t;
        Right = r;
        Bottom = b;
    }
}

纯函数版本的**充气()**方法不会有副作用。 它的行为将仅仅取决于参数,并且结果将是相同的,无论使用相同的参数调用多少次。 这种实现的一个例子如下:

static Rectangle Inflate(Rectangle rect, 
                         int l, int t, int r, int b)
{
    return new Rectangle(rect.Left - l, rect.Top - t,
                         rect.Right + r, rect.Bottom + b);
}

现在可以在下面的例子中使用:

var r = new Rectangle(10, 10, 30, 20);
r = Inflate(r, 5, 0, 5, 0);

函数式编程起源于 lambda 演算(由 Alonzo Church 开发),它是一个框架或数学系统,用于表示基于函数抽象和使用变量绑定和替换的应用的计算。 有些编程语言,如 Haskell,是纯函数性的。 其他的,比如 C#,支持多种范例,并不是纯粹的功能性。

前面的示例显示了一个变量r,它被初始化为一个值,然后进行更改。 在纯函数式编程中,这是不可能的。 变量一旦初始化,就不能改变值; 相反,必须赋给一个新变量。 这允许用表达式的值替换表达式,即称为引用透明性的属性。

C# 使我们能够使用函数式编程的概念和习惯用法来编写代码。 所有这些的核心是 lambda 表达式,我们稍后将深入研究它。 在此之前,我们需要探索另一个函数式编程的支柱,即将函数视为一等公民

作为一等公民

在[第八章](08.html# _idTextAnchor154),Advanced Topics中,我们学习了 delegate 和 events。 委托看起来像一个函数,但它是一种类型,保存对其签名与委托定义匹配的函数的引用。 委托实例可以作为函数参数的对象传递。 让我们看一个例子,我们有一个委托,它接受两个int参数,并返回一个int值:

public delegate int Combine(int a, int b);

然后,我们有不同的功能,如添加(),添加两个整数,并返回总和,子(),减去两个整数,并返回其不同,或Mul(),将两个整数,并返回他们的产品。 它们的签名与委托相匹配,因此Combine委托的实例可以包含对所有这些函数的引用。 这些功能如下:

class Math
{
    public static int Add(int a, int b) { return a + b; }
    public static int Sub(int a, int b) { return a - b; }
    public static int Mul(int a, int b) { return a * b; }
}

我们可以编写一个通用函数,将其中一个函数应用于两个参数。 这样的函数可能看起来像这样:

int Apply(int a, int b, Combine f)
{
    return f(a, b);
}

调用它很简单——我们将参数和引用传递给我们想要调用的实际函数:

var s = Apply(2, 3, Math.Add);
var d = Apply(2, 3, Math.Sub);
var p = Apply(2, 3, Math.Mul);

为了方便起见,. net 定义了一组称为Func的通用委托,以避免一直定义自己的委托。 它们在系统命名空间中定义,如下所示:

public delegate TResult Func<out TResult>();
public delegate TResult Func<in T,out TResult>(T arg);
public delegate TResult Func<in T1,in T2,out TResult>(T1 arg1, T2 arg2);
...
public delegate TResult Func<in T1,in T2,in T3,in T4,in T5,in T6,in T7,in T8,in T9,in T10,in T11,in T12,in T13,in T14,in T15,in T16,out TResult>(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8, T9 arg9, T10 arg10, T11 arg11, T12 arg12, T13 arg13, T14 arg14, T15 arg15, T16 arg16);

这是一个 17 个重载的集合,它们接受 0、1 或最多 16 个参数(可能是不同类型的)并返回一个值。 使用这些系统委托,我们可以将Apply函数重写如下:

T Apply<T>(T a, T b, Func<T, T, T> f)
{
    return f(a, b);
}

这个版本的函数是泛型的,因此可以用其他类型的参数调用它,而不仅仅是整数。 在前面的例子中调用函数的方式没有改变。

这些委托返回一个值,因此它们不能用于没有返回值的函数。 在系统命名空间中有一组类似的重载,称为Action,其定义如下:

public delegate void Action();
public delegate void Action<in T>(T obj);
public delegate void Action<in T1,in T2>(T1 arg1, T2 arg2);
...
public delegate void Action<in T1,in T2,in T3,in T4,in T5,in T6,in T7,in T8,in T9,in T10,in T11,in T12,in T13,in T14,in T15,in T16>(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8, T9 arg9, T10 arg10, T11 arg11, T12 arg12, T13 arg13, T14 arg14, T15 arg15, T16 arg16);

这些代表非常类似于我们前面看到的Func代表。 唯一的区别是它们不返回值。 仍然有 17 个重载接受 0、1 或最多 16 个输入参数。

在下面的例子中,应用函数重载的,它还需要一个参数行动><字符串类型,这是一个函数只有一个参数的字符串类型和返回什么。 在应用函数之后,但在返回结果之前,这个动作会用一个描述实际操作的字符串调用:****

**```cs T Apply(T a, T b, Func<T, T, T> f, Action log) { var r = f(a, b); log?.Invoke($"{f.Method.Name}({a},{b}) = {r}"); return r; }


我们可以通过传递**Console 来调用这个新的重载。 最后一个参数是 WriteLine**,这将导致操作被记录到控制台:

```cs
var s = Apply(2, 3, Math.Add, Console.WriteLine);
var p = Apply(2, 3, Math.Mul, Console.WriteLine);

Applyfunction 被称为高阶函数。 高阶函数是接受一个或多个函数作为参数、返回一个函数或两者兼备的函数。 所有其他函数称为一阶函数

有许多高阶函数您可能正在使用而没有任何实现。 例如,**List. sort (ComparisonComparison)**就是这样一个函数。 来自 LINQ 的大多数查询谓词(我们将在本章稍后的LINQ一节中探讨)都是高阶函数。

高阶函数的一个例子是返回另一个函数的函数,如下面的代码片段所示。 **ApplyReverse()**接受一个函数作为实参,并返回另一个函数,该函数调用带有两个实参的实参函数,但顺序相反:

Func<T, T, T> ApplyReverse<T>(Func<T, T, T> f)
{
    return delegate(T a, T b) { return f(b, a); };
}

这个函数的调用如下:

var s = ApplyReverse<int>(Math.Add)(2, 3);
var d = ApplyReverse<int>(Math.Sub)(2, 3);

到目前为止,我们所看到的是 C# 可以将函数作为参数传递、从函数中返回函数、将函数赋值给变量、将函数存储在数据结构中或定义匿名函数(即没有名称的函数)。 还可以嵌套函数并测试对函数的引用是否相等。 做这些事情的编程语言被认为是把函数当作一级公民,它的函数也是一级公民。 因此,C# 就是这样一种语言。

回到前面的例子,调用**Apply()**方法的另一种更简单的方法如下:

var s = Apply(2, 3, (a, b) => a + b);
var d = Apply(2, 3, (a, b) => a - b);
var p = Apply(2, 3, (a, b) => a * b);

这里,来自Math类的方法被替换为 lambda 表达式,如**(a, b) =>a + b**。 我们甚至可以将**Apply()**函数定义为 lambda 表达式,并相应地调用它:

Func<int, int, Func<int, int, int>, int> apply = 
   (a, b, f) => f(a, b);
var s = apply(2, 3, (a, b) => a + b);
var d = apply(2, 3, (a, b) => a - b);
var p = apply(2, 3, (a, b) => a * b);

我们将在下一节深入研究 lambda 表达式。

Lambda 表达式

Lambda 表达式是编写匿名函数的一种方便方式。 它们是代码块,可以是表达式,也可以是一条或多条语句,它们的行为类似于函数,可以分配给委托。 因此,lambda 表达式可以作为参数传递给函数,也可以从函数返回。 它们是编写 LINQ 查询、将函数传递给高阶函数(包括应该由**Task.Run()**异步执行的代码)和创建表达式树的一种方便的方法。

表达式树是一种用树状数据结构表示代码的方法,节点作为表达式(如方法调用或二进制操作)。 可以编译和执行这些表达式树,从而可以对可执行代码执行动态更改。 表达式树用于实现各种数据源的 LINQ 提供程序,并在 DLR 中提供. net 框架和动态语言之间的互操作性。

让我们从一个简单的例子开始,我们有一个整数列表,我们想要去掉所有的奇数。 可以这样写(注意,**IsOdd()**函数既可以是类方法,也可以是局部函数):

bool IsOdd(int n) { return n % 2 == 1; }
var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(IsOdd);

这段代码实际上可以通过匿名方法来简化,匿名方法允许我们将代码传递给委托,而无需定义单独的**IsOdd()**函数:

var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(delegate (int n) { return n % 2 == 1; });

Lambda 表达式允许我们用更简单的语法进一步简化代码,编译器将其转换为类似于前面代码的内容:

var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(n => n % 2 == 1);

lambda 表达式我们可以看到**(n =>n % 2 = = 1)两部分分开了=【显示】**,λ的声明符:

  • 表达式的左边部分是参数的列表(如果有多个参数,则用逗号隔开,用括号括起来)。
  • 表达式的右边要么是一个表达式,要么是一个语句。 如果右边的部分是一个表达式(如前面的示例),则将称为表达式 lambda。 如果右边的部分是一个语句,则称为语句 lambda

语句总是用花括号**{}**括起来。 任何表达式都可以写成一个语句。 表达式 lambda 是语句 lambda 的简化版本。 前面的表达式 lambda 的例子可以用语句 lambda 写成如下形式:

var list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
list.RemoveAll(n => { return n % 2 == 1; });

lambda 表达式有几个例子:

lambda 没有自己的类型。 相反,它的类型要么是它被分配给的委托的类型,要么是系统。 表达式类型时,使用 lambdas 构建表达式树。 不返回值的 lambda 对应于System。 操作委托(并且可以分配给一个)。 返回值的 lambda 对应于系统。 Funcdelegate。

在编写 lambda 表达式时,不需要编写参数的类型,因为这些参数由编译器推断出来。 类型推断的规则如下:

  • lambda 必须具有与分配给它的委托相同数量的参数。
  • lambda 的每个参数都必须隐式地转换为分配给它的委托的相应参数。
  • 如果 lambda 有返回值,则必须将其类型隐式转换为分配给它的委托的返回类型。

Lambda 表达式可以是异步的。 这样的 lambda 前面有async关键字,并且必须至少包含一个await表达式。 下面的示例显示了 Windows 窗体上一个按钮的单击事件的异步处理程序:

public partial class MyForm : Form
{
    public MyForm()
    {
        InitializeComponent();

        myButton.Click += async (sender, e) =>
        {
            await ExampleMethodAsync();
        };
    }
    private async Task ExampleMethodAsync()
    {
        // a time-consuming action
        await Task.Delay(1000);
    }
}

在本例中,MyForm是一个表单类,在其构造函数中,我们为Click事件注册了一个处理程序。 这是使用 lambda 表达式完成的,但 lambda 是异步的(它调用异步函数),因此需要在前面加上async

Lambdas 可以使用方法范围内的变量或包含 lambda 表达式的类型的变量。 当在 lambda 中使用变量时,将捕获它,以便即使它超出了作用域也可以使用它。 在 lambda 中使用这些变量之前,必须明确地给它们赋值。 在下面的例子中,lambda 表达式捕获了两个变量——值函数参数和Data类成员:

class Foo
{
    public int Data { get; private set; }
    public Foo(int value)
    {
        Data = value;
    }
    public void Scramble(int value, int iterations)
    {
        Func<int, int> apply = (i) => Data ^ i + value;
        for(int i = 0; i < iterations; ++i)
           Data = apply(i);
    }
}

以下是适用于 lambda 表达式中变量作用域的规则:

  • 在 lambda 表达式中引入的变量在 lambda 之外是不可见的(例如,在封闭方法中)。
  • 一个 lambda 不能从封闭方法的、ref中捕获参数。****
  • 被 lambda 表达式捕获的变量不会被垃圾收集,即使在 lambda 赋值的委托被垃圾收集之前,这些变量会超出作用域。
  • lambda 表达式的 return 语句仅引用 lambda 所表示的匿名方法,并不导致外围方法返回。

lambda 表达式最常见的用例是编写 LINQ 查询表达式。 我们将在下一节中讨论这个问题。

linq

LINQ 是一组技术,它允许开发人员以一致的方式查询大量数据源。 通常,您将使用不同的语言和技术来查询不同类型的数据,例如 SQL 用于关系数据库,XPath 用于 XML。 SQL 查询被编写为字符串,这使得它们不可能在编译时验证,并增加了出现运行时错误的机会。

LINQ 定义了一组运算符和内置的语言语法来查询数据。 LINQ 查询是强类型的,因此在编译时进行验证。 LINQ 还提供了一个框架来构建自己的 LINQ 提供者,这些提供者是将查询转换为特定于特定数据源的 api 的组件。 该框架为查询对象(. net 中的任何集合)、关系数据库和 XML 提供了内置支持。 第三方已经为许多数据源编写了 LINQ 提供程序,比如 web 服务。

LINQ 使开发人员能够专注于要做什么,而不去关心如何做事情。 为了更好地理解它是如何工作的,让我们看一个例子,我们有一个整数数组,我们想要找到所有奇数的和。 通常情况下,你会这样写:

int[] arr = { 1, 1, 3, 5, 8, 13, 21, 34};
int sum = 0;
for(int i = 0; i < arr.Length; ++i)
{
    if (arr[i] % 2 == 1)
    sum += arr[i];
}

使用 LINQ,可以将所有这些冗长的代码简化为如下行:

int sum = arr.Where(x => x % 2 == 1).Sum();

这里,我们使用了 LINQ 标准查询运算符,这些运算符是对序列进行操作并提供查询功能的扩展方法,包括过滤、投影、聚合、排序等等。 然而,其中许多查询运算符在 LINQ 查询语法中有直接支持,LINQ 查询语法是一种非常类似于 SQL 的查询语言。 使用查询语言,可以将问题的解决方案写如下:

int sum = (from x in arr
           where x % 2 == 1
           select x).Sum();

正如您在本例中看到的,并不是每个查询运算符在查询语法中都具有等价的语法。 **Sum()**和所有其他聚合运算符都没有等价的。 在下面的章节中,我们将更详细地研究这两种 LINQ 风格。

标准查询运算符

LINQ 的标准查询运算符一组扩展方法,操作序列,实现**<IEnumerable T【病人】这个 IQueryable<>。 前者导出一个枚举器,允许对序列进行迭代。 后者是一个特定于 linq 的接口,继承自IEnumerable,为我们提供了针对特定数据源评估查询的功能。 标准查询运算符被定义为EnumerableQueryable**类的扩展方法,这取决于它们操作的序列的类型。 作为扩展方法,可以使用静态方法语法或实例方法语法调用它们。

大多数查询运算符可能返回多个值。 这些方法返回IEnumerable或IQueryable,这使得将它们链接在一起成为可能。 对数据源的实际查询被推迟,直到对它们返回的可枚举对象进行迭代。 另一方面,返回单个值的标准查询运算符(如Sum()或Count())不会延迟执行并立即执行。

下表包含了所有 LINQ 标准查询运算符的名称:

有大量的标准查询运算符。 讨论每一个问题都超出了本书的范围。 您应该阅读官方文档或其他资源以熟悉所有这些内容。

为了让我们更加熟悉 LINQ,我们将看几个例子。 在第一个例子中,我们要计算句子中单词的数量。 我们考虑点(。 ()、逗号()和空格作为分隔符。 我们将字符串分成几部分,然后过滤所有不为空的部分并计数。 使用 LINQ,这就像做以下简单的事情:

var text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
var count = text.Split(new char[] { ' ', ',', '.' })
                .Where(w => !string.IsNullOrEmpty(w))
                .Count();

但是,如果我们想根据单词的长度对它们进行分组并将它们打印到控制台,那么问题就变得有点复杂了。 我们需要创建以单词长度为键,以单词本身为元素的组,过滤掉长度为 0 的组,并根据单词长度升序排列剩余的组:

var groups = text.Split(new char[] { ' ', ',', '.' })
                 .GroupBy(w => w.Length, w => w.ToLower())
                 .Select(g => new { Length =g.Key, Words = g })
                 .Where(g => g.Length > 0)
                 .OrderBy(g => g.Length);
foreach (var group in groups)
{
    Console.WriteLine($"Length={group.Length}");
    foreach (var word in group.Words)
    {
        Console.WriteLine($" {word}");
    }
}

虽然前面的查询是在调用**Count()**时执行的,但是这个查询的执行被推迟到实际遍历它为止。

到目前为止,我们看到的例子并不太复杂。 但是,使用 LINQ,您可以构建更复杂的查询。 为了说明这一点,让我们考虑一个为客户处理订单的系统。 系统与诸如CustomerArticleOrderLineOrder这样的实体一起工作,它们以非常简单的形式显示在这里:

class Customer
{
    public long Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
}
class Article
{
    public long Id { get; set; }
    public string EAN13 { get; set; }
    public string Name { get; set; }
    public double Price { get; set; }
}
class OrderLine
{
    public long Id { get; set; }
    public long OrderId { get; set; }
    public long ArticleId { get; set; }
    public double Quantity { get; set; }
    public double Discount { get; set; }
}
class Order
{
    public long Id { get; set; }
    public DateTime Date { get; set; }
    public long CustomerId { get; set; }
    public double Discount { get; set; }
}

让我们也考虑这些类型的序列,如下所示(为简单起见,每种类型只显示了几条记录,但你可以在本书附带的源代码中找到完整的示例):

var articles = new List<Article>()
{
     new Article(){ Id = 1, EAN13 = "5901234123457", 
                    Name = "paper", Price = 100.0},
     new Article(){ Id = 2, EAN13 = "5901234123466", 
                    Name = "pen", Price = 200.0},
     /* more */
};
var customers = new List<Customer>()
{
     new Customer() { Id = 101, FirstName = "John", 
               LastName = "Doe", Email = "john.doe@email.com"},
     new Customer() { Id = 102, FirstName = "Jane", 
               LastName = "Doe", Email = "jane.doe@email.com"},
     /* more */
};
var orders = new List<Order>()
{
     new Order() { Id = 1001, Date = new DateTime(2020, 3, 12),
                   CustomerId = customers[0].Id },
     new Order() { Id = 1002, Date = new DateTime(2020, 4, 23),
                   CustomerId = customers[1].Id },
     /* more */
};
var orderlines = new List<OrderLine>()
{
    new OrderLine(){ Id = 1, OrderId=orders[0].Id, 
                     ArticleId = articles[0].Id, Quantity=2},
    new OrderLine(){ Id = 2, OrderId=orders[0].Id, 
                     ArticleId = articles[1].Id, Quantity=1},
    /* more */
};

我们想要找到答案的问题是,一个特定的客户从给定的一天以来购买的所有商品的名称是什么? 用命令式的方法来写这个可能很麻烦,但是使用 LINQ,可以表达如下:

var query = 
    orders.Join(orderlines,
                o => o.Id,
                ol => ol.OrderId,
                (o, ol) => new { Order = o, Line = ol })
          .Join(customers,
                o => o.Order.CustomerId,
                c => c.Id,
                (o, c) => new { o.Order, o.Line, Customer = c})
          .Join(articles,
                o => o.Line.ArticleId,
                a => a.Id,
                (o, a) => new { o.Order, o.Line, 
                               o.Customer, Article = a})
        .Where(o => o.Order.Date >= new DateTime(2020, 4, 1) &&
                    o.Customer.FirstName == "John")
          .OrderBy(o => o.Article.Name) 
          .Select(o => o.Article.Name);

在本例中,我们将订单与订单行和客户连接起来,并将订单行与商品连接起来,并且只保留在 2020 年 4 月 1 日之后为名字为 John 的客户创建的订单。 然后,我们按条目名称按字典顺序排序,只选择条目名称来项目。

有几个**Join()**操作,其语法看起来比较难以理解。 让我们用下面的例子来解释它:

orders.Join(orderlines,
            o => o.Id,
            ol => ol.OrderId,
            (o, ol) => new { Order = o, Line = ol })

在这里,顺序被称为外部序列顺序被称为内部序列Join()的第二个参数,即o =>o.Id,被称为用于外部序列的键选择器。 我们使用它来选择订单。 Join()的第三个辐角是ol =>ol。 OrderId,称为内部序列键选择器。 我们使用它来选择订单行。

基本上,这两个 lambda 表达式帮助匹配具有OrderId等于订单 ID 的订单行。 最后一个参数,(o, ol) =>new {Order = o, Line = ol},是连接操作的投影。 我们正在创建一个具有两个属性的新对象:OrderLine

一些标准查询运算符使用起来比较简单,而其他的则比较复杂,可能需要一些实践才能很好地理解。 然而,对于它们中的许多,还有一种更简单的替代方法——LINQ 查询语法,我们将在下一节中探讨它。

查询语法

LINQ 查询语法基本上是标准查询运算符的语法糖(也就是说,一种简化的语法,旨在使事情更容易编写和理解)。 编译器将用查询语法编写的查询转换为使用标准查询运算符编写的查询。 查询语法比标准查询运算符更简单、更易于阅读,但它们在语义上是等价的。 但是,正如前面提到的,并不是所有标准查询运算符在查询语法中都具有等价的内容。

为了了解标准查询运算符的方法语法和查询语法的比较情况,让我们使用查询语法重写上一节中的示例。

首先,让我们看一下计算一段文本中的单词的问题。 使用查询语法,查询变为如下所示。 注意,**Count()**在查询语法中没有等价的语句:

var count = (from w in text.Split(new char[] { ' ', ',', '.' })
             where !string.IsNullOrEmpty(w)
             select w).Count();

另一方面,第二个问题可以完全使用查询语法编写,如下所示:

var groups = from w in text.Split(new char[] { ' ', ',', '.' })
             group w.ToLower() by w.Length into g
             where g.Key > 0
             orderby g.Key
             select new { Length = g.Key, Words = g };
foreach (var group in groups)
{
    Console.Write($"Length={group.Length}: ");
    Console.WriteLine(string.Join(',', group.Words));
}

打印文本有点不同。 单词显示在一行上,用逗号分隔。 为了组成以逗号分隔的单词的文本,我们使用了**string. join()**静态方法,该方法接受一个分隔符和一个值序列,并将它们连接到单个字符串中。 这个程序的输出如下:

Length=2: do,ut,et
Length=3: sit,sed
Length=4: amet,elit
Length=5: lorem,ipsum,dolor,magna
Length=6: tempor,labore,dolore,aliqua
Length=7: eiusmod
Length=10: adipiscing,incididunt
Length=11: consectetur

我们要重写的最后一个问题是关于客户订单的例子。 这个查询可以非常简洁地表示,如下面的代码所示。 这段代码类似于 SQL,并且join操作绝对更容易读、写和理解:

var query = from o in orders
            join ol in orderlines on o.Id equals ol.OrderId
            join c in customers on o.CustomerId equals c.Id
            join a in articles on ol.ArticleId equals a.Id
            where o.Date >= new DateTime(2019, 4, 1) &&
                  c.FirstName == "John"
            orderby a.Name
            select a.Name;

从这些示例中可以看到,LINQ 帮助以比使用传统命令式编程更简单的方式构建查询。 可以使用类似 SQL 的语言以一致的方式查询不同性质的数据源。 查询是强类型的,并在编译时验证,这有助于解决许多潜在的错误。

现在,让我们看看一些更函数式编程的概念:部分函数应用、curry、闭包、monoids 和单子。

更多的函数式编程概念

在本章的开始,我们了解了一般函数式编程的概念,主要是高阶函数和不变性。 在这一节中,我们将探索几个更函数式编程的概念和技术-部分函数应用、curry、闭包、monoids 和 monads。

部分功能应用

部分功能应用的过程与一个函数 N 参数一个参数并返回另一个函数N - 1 参数修复后的参数为一个函数的参数。 当然,有可能调用不止一个参数,比如M*,在这种情况下,返回的函数将有N-M参数。*

为了理解这是如何工作的,让我们从一个函数开始,它有几个形参,并返回一个字符串(包含参数的值):

string AsString(int a, double b, string c)
{
    return $"a={a}, b={b}, c={c}";
}

如果我们调用这个函数作为AsString(42, 43.5, "44"),结果是字符串**"a=42, b=43.5, c=44"。 然而,如果我们有一个函数(我们称其为Apply()**),它将一个实参绑定到该函数的第一个形参,那么我们可以像下面这样调用它,并得到相同的结果:

var f1 = Apply<int, double, string, string>(AsString, 42);
var result = f1(43.5, "44");

这样一个**Apply()**函数的实现如下:

Func<T2, T3, TResult>
Apply<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult> f, T1 arg)
{
    return (b, c) => f(arg, b, c);
}

这个高阶函数接受另一个函数和一个值作为参数,然后返回另一个低一个参数的高阶函数。 该函数解析为调用带有参数参数值和其他参数的f参数函数。

我们也有可能继续这个过程,将函数简化为另一个少一个参数的函数,直到得到一个没有参数的函数,如下所示:

var f1 = Apply<int, double, string, string>(AsString, 42);
var f2 = Apply(f1, 43.5);
var f3 = Apply(f2, "44");
string result = f3();

然而,为了实现这一点,我们需要对**Apply()**函数进行额外的重载,并使用适当数量的参数。 对于这里显示的情况,我们需要以下(在实践中,如果你的函数有三个以上的参数,你需要更多的重载来考虑所有可能的参数数量):

Func<T2, TResult> Apply<T1, T2, TResult>(Func<T1, T2, TResult> f, T1 arg)
{
    return b => f(arg, b);
}
Func<TResult> Apply<T1, TResult>(Func<T1, TResult> f, T1 arg)
{
    return () => f(arg);
}

在本例中,需要注意的是,实际调用**AsString()函数只在提供了所有参数时才会发生; 也就是说,当我们调用f3()**时。

您可能想知道局部函数应用在什么时候是有用的。 典型的情况是多次(或多次)调用一个函数,而某些参数是相同的。 在这种情况下,有几个选择,包括以下:

  • 在定义函数时,为函数参数提供默认值。 然而,由于不同的原因,这可能是不可能的。 可能默认值只在某些上下文中有意义,或者可能您实际上并不拥有代码,因此无法提供它们。
  • 在您多次调用函数的类中,您可以编写一个带有较少参数的助手函数,该函数以正确的默认值调用该函数。

部分函数应用(在许多这种情况下)可能是更简单的解决方案。

咖喱

curry是将带有N参数的函数分解为N函数,并将1参数。 这种技术以数学家和逻辑学家 Haskell Curry 命名,函数式编程语言Haskell也以他的名字命名。

curry 套用允许在只能使用一个参数的函数的上下文中使用具有多个参数的函数。 这方面的一个例子是数学中的分析技术,它只能应用于具有单一参数的函数。

考虑到上一节中的**AsString()**函数,对该函数进行 curry 操作将实现以下功能:

  • 返回一个函数f1
  • 当用参数调用时,它将返回一个函数f2
  • 当用参数b调用时,它将返回一个函数f3
  • 当使用参数c调用时,它将调用AsString(a, b, c)

当放入代码时,它们看起来如下所示:

var f1 = Curry<int, double, string, string>(AsString);
var f2 = f1(42);
var f3 = f2(43.5);
string result = f3("44");

这里看到的通用的**Curry()函数类似于上一节中的Apply()**函数。 但是,不是返回一个带有N-1实参的函数,而是返回一个只有一个实参的函数:

Func<T1, Func<T2, Func<T3, TResult>>> 
Curry<T1, T2, T3, TResult>(Func<T1, T2, T3, TResult> f)
{
    return a => b => c => f(a, b, c);
}

这个函数可以用来 curry 只有三个参数的函数。 如果您需要对具有其他数量参数的函数执行此操作,那么您需要为其提供适当的重载(就像**Apply()**的情况一样)。

你应该注意,你不一定需要分解AsString 结尾()函数三个不同的时期,与见过 f1,f2 和 f3【显示】。 您可以跳过中间函数,并通过适当调用该函数来实现相同的结果,如下代码所示:

var f = Curry<int, double, string, string>(AsString);
string result = f(42)(43.5)("44");

函数编程中的另一个重要概念是闭包。 我们将在下一节中了解闭包。

闭包

闭包被定义为一种技术,用于在具有一级函数的语言中实现词汇作用域名称绑定。 词法或静态作用域是在定义变量的块上设置变量的作用域,因此在该作用域内只能通过变量名引用它。

**信息框

C# 中的作用域被称为静态词法,可以在编译时查看。 与之相反的是动态作用域,它们只在运行时解析,但在 C# 中不支持。

正如我们在本章前面所看到的,C# 是一种拥有一流函数的语言,因为你可以将函数赋给变量,传递它们,并调用它们。 然而,闭包的这种定义可能比较难以理解,所以我们将使用一个示例一步步地解释它。

让我们考虑以下例子:

class Program
{
    static Func<int, int> Increment()
    {
        int step = 1;
        return x => x + step;
    }
    static void Main(string[] args)
    {
        var inc = Increment();
        Console.WriteLine(inc(42));
    }
}

这里,我们有一个名为Increment()的函数,它返回另一个函数,该函数用一个值来增加其参数。 但是,该值既不是作为参数传递给 lambda,也不是作为 lambda 中的局部变量定义。 相反,它是从外部范围捕获的。 因此,步进变量被称为自由变量。 当编译器在 lambda 表达式中看到它时,它会在 lambda 范围内查找步骤变量的定义; 如果未找到,则查找外围作用域,在本例中,该作用域是**Increment()**函数。 如果它也不存在,它就会在类作用域中看得更远,以此类推。

接下来发生的是,我们将Increment()函数(这是另一个函数)返回的值赋给inc变量,然后用42值调用该变量。 结果是值43被打印到控制台。

问题是,这是如何工作的? step变量实际上是一个局部函数变量,当Increment()被调用时,该变量就会超出作用域。 然而,在调用从Increment()返回的函数时,它的值是已知的。 这是因为 lambda 表达式x =>x +步骤被认为是关闭自由变量步骤上的,从而定义了闭包。 lambda 表达式和步骤一起传递(作为闭包的一部分),这样通常会超出范围的变量在调用闭包时仍然存在。

闭包一直在使用,而我们甚至没有意识到这一点。 考虑下面的例子,我们有一个引擎列表,我们想搜索一个功率和容量最小的引擎。 你通常会用 lambda 表达式写如下内容:

var list = new List<Engine>();
var minp = 75.0;
var minc = 1600;
var engine = list.Find(e => e.Power >= minp && 
                       e.Capacity >= minc);

但这实际上是创建一个闭包,因为 lambda 关闭了minpminc自由变量。 如果语言中不支持闭包,那么编写这样的代码会很麻烦。 您基本上必须编写一个类来捕获这些变量的值,并且具有一个方法,该方法接受一个Engine对象,并将其属性与这些值进行比较。 在这种情况下,代码可以如下所示:

sealed class EngineFinder
{
    public EngineFinder(double minPower, int minCapacity)
    {
        this.minPower = minPower;
        this.minCapacity = minCapacity;
    }
    public double minPower;
    public int minCapacity;
    public bool IsMatch(Engine engine)
    {
        return engine.Power >= minPower && 
            engine.Capacity >= minCapacity;
    }
}
var engine = list.Find(new EngineFinder(minp, minc).IsMatch);

这与编译器在遇到闭包时所做的非常相似,但这是一种您不必关注的细节。

您还应该注意到,由 lambda 在闭包中捕获的自由变量可以改变值。 我们用下面的示例来说明这一点,其中GetNextId()函数定义了一个闭包,该闭包在每次调用时增加捕获的自由变量id的值:

Func<int> GetNextId()
{
    int id = 1;
    return () => id++;
}
var nextId = GetNextId();
Console.WriteLine(nextId()); // prints 1
Console.WriteLine(nextId()); // prints 2
Console.WriteLine(nextId()); // prints 3

我们将在下一节学习单偏元。

单类

一个单类是一个代数结构,它具有一个结合的二元运算和一个恒等元素。 任何具有这两个元素的 C# 类型都是单一类。 Monoids 对于定义概念和重用代码很有用。 它们帮助我们用简单的组件构建复杂的行为,而不需要在代码中引入新的概念。 让我们看看如何在 C# 中创建和使用 monoids。

我们可以在 C# 中定义一个泛型接口来表示一个单 oid,如下所示:

interface IMonoid<T>
{
    T Combine(T a, T b);
    T Identity { get; }
}

该半群保证了结合性和左右同一性,因此对于任何值,abc,我们有以下内容:

  • Combine((Combine(a, b), c) == Combine(a, b, c))
  • Combine(Identify, a) == a
  • Combine(a, Identity) == a

串接字符串或列表是关联二进制操作的一个例子。 提供该函数的类型,加上一个标识元素(在这些情况下是一个空字符串或空列表),就是一个单 oid。 所以,我们实际上可以在 C# 中实现如下:

struct ConcatList<T> : IMonoid<List<T>>
{
    public List<T> Identity => new List<T> { };
    public List<T> Combine(List<T> a, List<T> b)
    {
        var l = new List<T>(a);
        l.AddRange(b);
        return l;
    }
}
struct ConcatString : IMonoid<string>
{
    public string Identity => string.Empty;
    public string Combine(string a, string b)
    {
        return a + b;
    }
}

ConcatListConcatString都是单类的例子。 后者可以如下使用:

var m = new ConcatString();
var text = m.Combine("Learning", m.Combine(" ", "C# 8"));
Console.WriteLine(text);

这将打印学习 C# 8到控制台。 然而,这段代码使用起来有点麻烦。 我们可以通过创建一个名为**Concat()**的静态方法来简化它,该方法接受一个单类和一个元素序列,并使用单类的二进制操作及其初始值的恒等式将它们组合在一起:

static class Monoid
{
    public static T Concat<MT, T>(IEnumerable<T> seq)
        where MT : struct, IMonoid<T>
    {
       var result = default(MT).Identity;
       foreach (var e in seq)
           result = default(MT).Combine(result, e);
       return result;
    }
}

有了这个助手类,我们可以编写以下简化代码:

var text = Monoid.Concat<ConcatString, string>(
              new[] { "Learning", " ", "C# 8"});
Console.WriteLine(text);
var list = Monoid.Concat<ConcatList<int>, List<int>>(
    new[] { new List<int>{ 1,2,3},
    new List<int> { 4, 5 },
    new List<int> { } });
Console.WriteLine(string.Join(",", list));

在本例的第一部分中,我们将一个字符串列表连接为一个字符串,并将其打印到控制台。 在第二部分中,我们将一个整数列表连接到一个单独的整数列表中,这些整数稍后也会打印到控制台。

在下一节中,我们将看一看单子。

单子

这是一个概念,通常更难解释,也许也更难理解,尽管已经有很多关于它的文献。 在本书中,我们将尝试用简单的术语来解释它,但我们建议您阅读其他资源。

一句话,一个单子是一个容器,封装了一些功能上的价值,它包装。 我们经常在 C# 中使用单子而没有意识到这一点。 **Nullable**是一个单子,它定义了一个特殊的功能nullability,这意味着一个值可能存在或不存在。 【t16.1】任务 T>与等待是一个单细胞生物,它定义了一个特殊的功能,【显示】异步性,这意味着可以使用价值之前,实际上是计算。 IEnumerableT11】with the LINQ querySelectMany()运算符也是一个单子。

一个单子有两个操作:

  • 一个值v转换为一个容器(v ->C(v))。 在函数式编程中,这个函数被称为return
  • 两个容器压扁成一个容器(C(C(v)) ->C(v))。 在函数式编程中,这被称为绑定

让我们看看下面的例子:

var numbers = new int[][]{ new[]{ 1, 2, 3},
                           new[]{ 4, 5 },
                           new[]{ 6, 7} };
IEnumerable<int> odds = numbers.SelectMany(
                           n => n.Where(x => x % 2 == 1));

这里,是整数数组的数组。 SelectMany()用于选择奇数子序列。 然而,这将结果简化为IEnumerable,而不是IEnumerable<IEnumerable>。 如前所述,**IEnumerablewithSelectMany()**是单目。

但是你如何在 C# 中实现一个单子? 最简单的形式如下:

class Monad<T>
{
    public Monad(T value) => Value = value;
    public T Value { get; }
    public Monad<U> Bind<U>(Func<T, Monad<U>> f) => f(Value);
}

这实际上是称为恒等单子。 它让你构建一个容器,包装一个值,如果你把一个单子到一个单子和绑定它的身份x =>x,你会得到最初的单子回来:

var m = new Monad<int>(42);
var mm = new Monad<Monad<int>>(m);
var r = mm.Bind(x => x); // r equals m

另一个例子如何这个单子可以使用显示在下面的代码:

var m = new Monad<int>(21);
var r = m.Bind(x => new Monad<int>(x * 2))
         .Bind(x => new Monad<string>($"x={x}"));
Console.WriteLine(r.Value); // prints x=42

在本例中,m是一个封装整数值21的单子。 我们绑定一个函数,返回一个新的单子,它的值是初始值的两倍。 我们可以再次绑定这个单子与一个函数,转换成一个字符串整数。

从这个示例中,您可以看到这些绑定操作可以链接在一起。 这就是流畅接口所提供的——一种通过链接方法编写代码的机制。 可以使用下面的例子进一步说明这一点——给定一个系统,其中一个企业有客户,客户下订单,并且一个订单可以包含一个或多个商品,您需要找到特定企业的所有客户购买的所有不同的商品。

为简单起见,让我们考虑以下类:

class Business
{
    public IEnumerable<Customer> GetCustomers() { 
      return /* … */; }
}
class Customer
{
    public IEnumerable<Order> GetOrders() { return /* … */; }
}
class Order
{
    public IEnumerable<Article> GetArticles() { return /* … */; }
}
class Article { }

在典型的命令式风格中,您可以如下实现解决方案:

IEnumerable<Article> GetArticlesSoldBy(Business business)
{
    var articles = new HashSet<Article>();
    foreach (var customer in business.GetCustomers())
    {
        foreach (var order in customer.GetOrders())
        {
            foreach (var article in order.GetArticles())
            {
                articles.Add(article);
            }
        }
    }
    return articles;
}

然而,这可以通过使用 LINQ 和**IEnumerableSelectMany()**单子来简化。 函数式编程风格的实现可以如下所示:

IEnumerable<Article> GetArticlesSoldBy(Business business)
{
    return business.GetCustomers()
                   .SelectMany(c => c.GetOrders())
                   .SelectMany(o => o.GetArticles())
                   .Distinct()
                   .ToList();
}

这使用了连贯的接口模式,其结果是更简洁的代码,也更容易理解。

总结

本章背离了 C# 的命令式编程特点,因为我们探讨了该语言中内置的函数式编程概念和技术。 我们学习了高阶函数、lambda 表达式、部分函数应用、局部套用、闭包、monoids 和 monads。 我们还介绍了 LINQ 的两种风格:方法语法和查询语法。 这些主题中的大多数都比本书所建议的范围更复杂和先进。 因此,我们建议您使用其他资源来掌握它们。

在下一章中,我们将看看.NET 和 C# 的动态编程能力提供的反射服务。

测试你所学的内容

  1. 函数式编程的主要特征是什么? 它有什么好处?
  2. 什么是高阶函数?
  3. 是什么使函数成为 C# 语言中的头等公民?
  4. 什么是 lambda 表达式? 写 lambda 表达式的语法是什么?
  5. 在 lambda 表达式中,适用于变量范围的规则是什么?
  6. LINQ 是什么? 标准查询运算符是什么?查询语法是什么?
  7. **Select()SelectMany()**有什么区别?
  8. 什么是偏函数应用,它与 curry 有什么不同?
  9. 什么是单类?
  10. 什么是单子?****