C--函数式编程-全-

C# 函数式编程(全)

原文:zh.annas-archive.org/md5/BA6B40D466733162BD57D5FED41DF818

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我们中的一些人可能习惯于使用面向对象编程技术开发应用程序,不关心函数式编程技术。然而,使用函数式编程是有好处的。其中一个好处是,我们将从新的角度看待我们的编程代码,因为函数式编程中的函数与数学函数相同。因为它与数学函数相同,函数式编程中的函数不包含副作用,这意味着函数调用不会对类中的其他函数产生影响。我们将在本书中讨论有关函数式编程的好处和其他相关事项的更多细节。

本书涵盖的内容

第一章 ,品味 C#中的函数式风格,通过讨论其概念和函数式与命令式编程的比较,介绍了函数式编程方法。我们还尝试将简单的命令式代码重构为函数式方法。

第二章 ,委托演练,涵盖了委托的定义、语法和用法。我们还讨论了委托的变化和内置委托。

第三章 ,用 Lambda 表达式表达匿名方法,引导我们了解委托的概念,并使用它来创建和使用匿名方法。在深入研究匿名方法之后,我们可以将其转换为 Lambda 表达式,然后应用于函数式编程。

第四章 ,使用扩展方法扩展对象功能,详细说明了在函数式编程中使用扩展方法的好处。在此之前,我们讨论了扩展方法的用法,还讨论了如何在 IntelliSense 中获取这个新方法。此外,我们尝试从其他程序集调用扩展方法。

第五章 ,使用 LINQ 轻松查询任何集合,列举了 C#提供的 LINQ 运算符,并比较了两种 LINQ 语法:流畅语法和查询表达式语法。我们还讨论了 LINQ 过程中的延迟执行。

第六章 ,使用异步编程增强函数式程序的响应性,涵盖了函数式方法的异步编程。它将解释异步编程模型和基于任务的异步模式。

第七章 ,学习递归,解释了递归在循环序列上的优势。我们还在本章讨论了直接递归和间接递归。

第八章 ,使用惰性和缓存技术优化代码,涵盖了函数式方法中用于优化代码的技术。我们讨论了惰性思维和缓存技术,以优化我们的代码。

第九章 ,使用模式,涵盖了使用模式与传统的 switch-case 操作相比的优势。我们在本章讨论了模式匹配和单子。我们使用模式匹配功能,这是 C# 7 提供的新功能。

第十章 ,在 C#函数式编程中采取行动,引导我们通过基于给定命令式代码开发函数式代码。我们利用上一章的学习成果,使用函数式方法创建一个应用程序。

第十一章 ,编码最佳实践和测试功能代码,解释了功能方法的最佳实践,包括创建诚实的签名和处理副作用。我们还将代码分为领域逻辑和可变外壳,然后使用单元测试进行测试。

您需要为本书做好准备

要阅读本书并成功编译所有源代码,我们需要一台运行 Microsoft Windows 10(或更高版本)的个人电脑,并安装 Visual Studio Community 2015 Update 3 以运行第 1-8 章、10、11 章的代码,以及安装 Visual Studio Community 2017 RC(Release Candidate)以运行第九章的代码。我们还需要.NET Framework 4.6.2,除非您需要重新编写所有源代码以在当前版本的.NET Framework 中运行。如果您想在其他平台上编译所有代码,则还需要.NET Core 1.0,因为所有代码都与.NET Core 1.0 兼容。

本书适合对象

本书适合具有 C#基础知识但没有功能性编程经验的 C#开发人员。

惯例

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以通过使用include指令包含其他上下文。”

代码块设置如下:

namespace ActionFuncDelegates
{
  public partial class Program
  {
    static void Main(string[] args)
    {
      //ActionDelegateInvoke();
      FuncDelegateInvoke();
    }
  } 

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

Console.WriteLine("Prime Number from 0 - 49 are:"); 

foreach (int i in extractedData)

Console.Write("{0} \t", i)

; 
Console.WriteLine();

任何命令行输入或输出都以以下方式编写:


C:\>dir | more

新术语重要单词以粗体显示。例如,屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“我们有一个包含{(a * b)}Body属性,包含LambdaNodeType,包含具有三个模板的Func委托的Type。”

注意

警告或重要提示会以以下方式显示。

提示

提示和技巧看起来像这样。

第一章:在 C#中品尝函数式风格

函数式编程是一种构建计算机程序元素和结构的风格,它将计算视为数学函数中的评估。虽然有一些专门设计的语言用于创建函数式编程,比如 Haskell 或 Scala,但我们也可以使用 C#来实现函数式编程的设计。

在本书的第一章中,我们将通过测试来探索函数式编程。我们将利用 C#的功能来构建一些函数式代码。我们还将处理在开发函数式程序中经常使用的 C#中的特性。在本章结束时,我们将对 C#中的函数式方法有一个概念。以下是本章我们将涵盖的主题:

  • 函数式编程概念介绍

  • 函数式和命令式方法的比较

  • 函数式编程的概念

  • 使用数学方法理解函数式编程

  • 将命令式代码重构为函数式代码

  • 函数式编程的优缺点

介绍函数式编程

在函数式编程中,我们像在数学中写函数一样写函数,没有副作用。代码函数中的变量表示函数参数的值,它类似于数学函数。这个想法是程序员定义包含表达式、定义和可以用变量表示的参数的函数,以解决问题。

程序员构建函数并将函数发送到计算机后,就轮到计算机发挥作用了。一般来说,计算机的作用是评估函数中的表达式并返回结果。我们可以想象计算机就像一个计算器,因为它会分析函数中的表达式并以打印格式将结果返回给用户。计算器将评估由作为参数传递的变量和构成函数主体的表达式组成的函数。变量在表达式中被其值替换。我们可以使用代数运算符给出简单表达式和复合表达式。由于没有赋值的表达式永远不会改变值,子表达式只需要评估一次。

假设我们在一个函数中有表达式3 + 5。计算机在完全评估完它之后肯定会返回8作为结果。然而,这只是计算机在评估表达式时的简单示例。事实上,程序员可以通过在函数内创建复杂的定义和表达式来增强计算机的能力。计算机不仅可以评估简单的表达式,还可以评估复杂的计算和表达式。

理解定义、脚本和会话

正如我们之前讨论过的关于计算器会分析函数中的表达式,让我们想象一下我们有一个计算器,它有一个像计算机一样的控制台面板。与传统计算器的区别在于,我们必须按下Enter键而不是=(等于号)来运行表达式的评估过程。在这里,我们可以输入表达式,然后按下Enter。现在,想象一下我们输入以下表达式:

3 x 9 

在按下Enter键后,计算机将在控制台中打印27,这正是我们期望的。计算机已经很好地评估了我们给出的表达式。现在,让我们来分析以下定义。想象一下我们在我们的函数式计算器上输入它们:

square a = a * a 
max a b  = a, if a >= b 
         = b, if b > a 

我们已经定义了两个定义,squaremax。我们可以称定义列表为脚本。通过调用square函数,后跟代表变量a的任何数字,我们将得到该数字的平方。同样,在max定义中,我们提供两个数字来代表变量ab,然后计算机将评估这个表达式以找出变量之间的最大数。

通过定义这两个定义,我们可以将它们用作函数,我们可以称之为会话,如下所示:

square (1 + 2) 

计算机在评估前述函数后肯定会打印9。计算机还能够评估以下函数:

max 1 2 

根据我们之前定义的定义,它将返回2作为结果。如果我们提供以下表达式,这也是可能的:

square (max 2 5) 

然后,在我们的计算器控制台面板上将显示25

我们还可以使用先前的定义修改定义。假设我们想要对整数进行四倍化,并利用square函数的定义;这是我们可以发送给计算器的内容:

quad q = square q * square q 
quad 10 

前述表达式的第一行是quad函数的定义。在第二行,我们调用该函数,我们将得到10000作为结果。

脚本可以定义变量值;例如,看一下以下内容:

radius = 20 

因此,我们应该期望计算机能够评估以下定义:

area = (22 / 7) * square (radius) 

使用替换和简化来评估表达式

使用一种称为规约的数学方法,我们可以通过替换变量或表达式来简化表达式,直到不能再进行规约为止。让我们看看我们前面的表达式square (1 + 2),并看看以下规约过程:

square (1 + 2) -> square 3 (addition) 
               -> 3 x 3    (square) 
               -> 9        (multiply) 

首先,我们有符号->来表示规约。从这个序列中,我们可以发现规约过程-换句话说,评估过程。在第一行,计算机将运行1 + 2表达式,并用3替换它以减少表达式。然后,它将通过简化square 33 x 3表达式来减少第二行的表达式。最后,它将简化3 x 3并用9替换它,这是该表达式的结果。

实际上,一个表达式在规约中可能有多种可能性。前面的规约过程是规约过程的一种可能性。我们还可以创建其他可能性,比如以下内容:

square (1 + 2) -> (1 + 2) x (1 + 2) (square) 
               -> 3 x (1 + 2)       (addition)  
               -> 3 x 3             (addition) 
               -> 9                 (multiply) 

在前面的序列中,首先可以看到正方形的规则被应用。然后,计算机在第 2 行和第 3 行中替换1 + 2。最后,它将乘以表达式中的数字。

从前面的两个例子中,我们可以得出结论,表达式可以使用简单的替换和简化来进行评估,这是数学的基本规则。我们还可以看到,表达式是值的表示,而不是值本身。但是,如果表达式不能再被规约,它将处于正常形式。

理解函数式编程中使用的函数

函数式编程使用一种强调函数及其应用而不是命令及其执行的技术。函数式编程中的大多数值都是函数值。让我们看一下以下数学符号:

f :: A -> B 

从前述符号中,我们可以说函数f是其中列出的每个元素的关系,即AB。我们称A为源类型,B为目标类型。换句话说,A -> B的符号表示A是一个参数,我们必须输入值,而B是返回值或函数评估的输出。

考虑到x表示A的一个元素,x + 2表示B的一个元素,因此我们可以创建以下数学符号:

f(x) = x + 2 

在数学中,我们使用 f(x) 来表示函数的应用。在函数式编程中,函数将作为参数传递,并在表达式评估后返回结果。

我们可以为同一个函数构造许多定义。以下两个定义类似,都会将作为参数传递的输入值乘以三:

triple y = y + y + y 
triple' y = 3 * y 

正如我们所看到的,tripletriple' 有不同的表达式。然而,它们是相同的函数,所以我们可以说 triple = triple'。尽管我们有许多定义来表达一个函数,但我们会发现,在评估过程中只有一个定义是最有效的,因为它能够在我们之前讨论的表达式简化过程中证明。不幸的是,我们无法确定哪一个是最有效的,因为这取决于评估机制的特性。

形成定义

现在,让我们回到本章开头关于定义的讨论。我们有以下定义,以便从案例分析中检索值:

max a b  = a, if a >= b 
         = b, if b > a 

在这个定义中有两个表达式,由布尔值表达式区分。这个区分器称为保护,并且我们使用它们来评估 TrueFalse 的值。第一行是这个函数的另一个结果值。它说明,如果表达式 a >= bTrue,则返回值将是 a。相反,如果表达式 b >= aTrue,则函数将返回值 b。使用这两种情况,a >= bb >= amax 值取决于 ab 的值。案例的顺序并不重要。我们还可以使用特殊词 otherwise 来定义 max 函数。这个词确保如果没有表达式结果为 True 值,否则情况将被执行。在这里,我们将使用 otherwise 重新构造我们的 max 函数:

max a b  = a, if a >= b 
         = b, otherwise 

从前面的函数定义中,我们可以看到,如果第一个表达式是 False,函数将立即返回 b,而不执行任何评估。换句话说,否则情况将始终返回 True,如果所有先前的保护都返回 False

数学符号中通常使用的另一个特殊词是 where。这个词用于为函数的表达式设置局部定义。让我们看看以下例子:

f x y = (z + 2) * (z + 3) 
        where z = x + y 

在前面的例子中,我们有一个带有变量 z 的函数 f,其值由 xy 确定。在那里,我们引入了一个局部 z 定义到函数中。这个局部定义也可以与我们之前讨论过的案例分析一起使用。以下是一个带有案例分析的连接局部定义的例子:

f x y = x + z, if x > 100 
      = x - z, otherwise 
        where z = triple(y + 3) 

在前面的函数中,有一个局部 z 定义,适用于 x + zx - z 表达式。正如我们之前讨论的,尽管函数有两个等于 (=) 符号,只有一个表达式会返回值。

柯里化

柯里化是一种简单的技术,通过顺序改变结构参数。它将把一个 n 元函数转换为 n 个一元函数。这是一种旨在规避 Lambda 函数限制的技术,Lambda 函数是一元函数。让我们再回到我们的 max 函数,并得到以下定义:

max a b  = a, if a >= b 
         = b, if b > a 

我们可以看到 max a b 函数名中没有括号。函数名中也没有逗号分隔的 ab。我们可以向函数定义中添加括号和逗号,如下所示:

max' (a,b)  = a, if a >= b 
            = b, if b > a 

乍一看,我们发现这两个函数是相同的,因为它们有相同的表达式。然而,它们是不同的,因为它们有不同的类型。max' 函数有一个参数,由一对数字组成。max' 函数的类型可以写成如下形式:

max' :: (num, num) -> num 

另一方面,max 函数有两个参数。这个函数的类型可以写成如下形式:

max :: num -> (num -> num) 

max 函数将接受一个数字,然后返回一个从单个数字到多个数字的函数。从前面的 max 函数中,我们将变量a传递给max函数,它返回一个值。然后,将该值与变量b进行比较,以找到最大的数字。

函数式编程和命令式编程的比较

函数式编程和命令式编程的主要区别在于,命令式编程会产生副作用,而函数式编程不会。在命令式编程中,表达式被评估并且其结果值被赋给变量。因此,当我们将一系列表达式分组到一个函数中时,结果值取决于那个时间点变量的状态。这就是副作用。由于状态不断变化,评估的顺序很重要。在函数式编程世界中,破坏性赋值是被禁止的,每次赋值发生时都会引入一个新变量。

准备 C#编译器

在本章的讨论中,我们将使用 C#创建一些代码。为了保持相同的环境,让我们定义一下我们将在配置设置中使用的内容。我们将在本书中讨论的所有源代码中使用 Visual Studio 2015 Community Edition 和.NET Framework 4.6.2。我们还将选择控制台应用程序项目,以便简化我们的代码开发,因为它不需要太多的设置更改。

这是我们将使用的创建 Visual Studio 项目的设置的截图:

准备 C#编译器

当我们讨论一个以csproj为文件名的源代码时,比如FuncObject.csproj,我们可以在示例代码提供的解决方案文件中找到它。它将在Program.cs文件中。以下是在 Visual Studio 中项目结构的截图:

准备 C#编译器

然而,有时在项目文件中有多个.cs文件。在这种情况下,我们可以在项目文件中的一个.cs文件中找到我们正在讨论的代码。例如,我们有一个名为FunctionalCode.csproj的项目文件。因此,当我们讨论与此项目文件相关的任何源代码时,我们可以从项目文件中的.cs文件中找到它。包含多个.cs文件的项目文件的结构如下:

准备 C#编译器

正如我们所看到的,在FunctionalCode.csproj文件中,我们不仅有Program.cs文件,还有Disposable.csFunctionalExtension.csStringBuilderExtension.csUtility.cs

我们还会在大部分代码的类名中找到partial关键字,即使我们将类写在同一个文件中。目的是为了使本书中的代码片段在示例代码中更容易找到。通过知道类名,可以更容易地在文件中找到源代码。

注意

我们还需要安装 Visual Studio Community 2017 RC,因为我们将在第九章中使用 C# 7 的新功能,使用模式

函数式编程的概念

我们还可以通过概念来区分函数式编程和命令式编程。函数式编程的核心思想被封装在构造中,比如一等函数、高阶函数、纯度、递归而不是循环以及部分函数。我们将在这个主题中讨论这些概念。

一等函数和高阶函数

在命令式编程中,给定的数据更重要,并且通过一系列函数(带有副作用)传递。函数是具有自己语义的特殊构造。实际上,函数与变量和常量的地位不同。由于函数不能作为参数传递或作为结果返回,它们被视为编程世界的二等公民。在函数式编程世界中,我们可以将函数作为参数传递并将函数作为结果返回。它们遵守与变量及其值相同的语义。因此,它们是一等公民。我们还可以通过组合创建函数的函数,称为二阶函数。对函数的组合性没有限制,它们被称为高阶函数。

幸运的是,C#语言支持这两个概念,因为它具有称为函数对象的功能,它具有类型和值。要讨论有关函数对象的更多细节,请看以下代码:

class Program 
{ 
  static void Main(string[] args) 
  { 
    Func<int, int> f = (x) => x + 2; 
    int i = f(1); 
    Console.WriteLine(i); 

    f = (x) => 2 * x + 1; 
    i = f(1); 
    Console.WriteLine(i); 
  } 
} 

我们可以在FuncObject.csproj中找到代码,如果我们运行它,它将在控制台屏幕上显示以下输出:

一等和高阶函数

为什么要显示它?让我们继续讨论函数类型和函数值。

提示

按下Ctrl + F5而不是F5以在调试模式下运行代码但不使用调试器。这对于阻止控制台在退出时关闭很有用。

函数类型

与 C#中的其他对象一样,函数对象也有类型。我们可以在函数声明中初始化类型。以下是声明函数对象的语法:

Func<T1, T2, T3, T4, ..., T16, TResult> 

请注意,我们有T1T16,它们是对应输入参数的类型,TResult是对应返回类型的类型。如果我们需要转换我们之前的数学函数,f(x) = x + 2,我们可以将其写成如下形式:

Func<int, int> f = (x) => x + 2;  

现在我们有一个函数f,它具有一个参数类型为整数和整数返回类型。在这里,我们使用 lambda 表达式定义一个委托,以赋给名为f的对象,其类型为Func。如果您对委托和 lambda 表达式不熟悉,不要担心。我们将在下一章中进一步讨论它们。

函数值

要为函数变量分配一个值,有以下可能性:

  • 函数变量可以通过引用使用类中的现有方法的名称进行分配。我们可以使用委托作为引用。让我们看一下以下代码片段:
      class Program 
      { 
        delegate int DoubleAction(int inp); 

        static void Main(string[] args) 
        { 
          DoubleAction da = Double; 
          int doubledValue = da(2); 
        } 

        static int Double(int input) 
        { 
          return input * 2; 
        } 
      } 

  • 正如我们在上面的代码中所看到的,我们使用delegateda变量分配给现有的Double()方法。

  • 可以使用 lambda 表达式将函数变量分配给匿名函数。让我们看一下以下代码片段:

      class Program 
      { 
        static void Main(string[] args) 
        { 
          Func<int, int> da =  
               input => input * 2; 

          int doubledValue = da(2); 
        } 
      } 

  • 正如我们在上面的代码中所看到的,da变量是使用 lambda 表达式分配的,我们可以像在以前的代码片段中一样使用da变量。

现在我们有一个函数变量,可以将一个变量-整数类型的变量分配给这个函数变量,例如:

int i = f(1); 

执行上述代码后,变量i的值将为3,因为我们将1作为参数传递,它将返回1 + 2。我们还可以将函数变量分配给另一个函数,如下所示:

f = (x) => 2 * x + 1; 
i = f(1); 

我们将一个新函数2 * x + 1分配给变量f,因此如果我们运行上述代码,我们将得到3

纯函数

在函数式编程中,大多数函数都没有副作用。换句话说,函数不会改变函数本身之外的任何变量。而且它是一致的,这意味着它总是为相同的输入数据返回相同的值。以下是在编程中会产生副作用的示例操作:

  • 修改全局变量或静态变量,因为这将使函数与外部世界交互。

  • 修改函数中的参数。如果我们将参数作为引用传递,通常会发生这种情况。

  • 引发异常。

  • 将输入和输出移到外部-例如,从键盘获取按键或将数据写入屏幕。

注意

尽管它不满足纯函数的规则,但我们将在程序中使用许多Console.WriteLine()方法,以便在代码示例中更容易理解。

以下是我们可以在NonPureFunction1.csproj中找到的非纯函数示例:

class Program 
{ 
  private static string strValue = "First"; 

  public static void AddSpace(string str) 
  { 
    strValue += ' ' + str; 
  } 

  static void Main(string[] args) 
  { 
    AddSpace("Second"); 
    AddSpace("Third"); 
    Console.WriteLine(strValue); 
  } 
} 

如果我们运行上述代码,预期将在控制台上显示以下结果:

纯函数

在此代码中,我们修改了AddSpace函数内的strValue全局变量。由于它修改了外部变量,因此不被视为纯函数。

让我们看看NonPureFunction2.csproj中的另一个非纯函数示例:

class Program 
{ 
  public static void AddSpace(StringBuilder sb, string str) 
  { 
    sb.Append(' ' + str); 
  } 

  static void Main(string[] args) 
  { 
    StringBuilder sb1 = new StringBuilder("First"); 
    AddSpace(sb1, "Second"); 
    AddSpace(sb1, "Third"); 
    Console.WriteLine(sb1); 
  } 
} 

我们再次看到AddSpace函数,但这次添加了一个参数类型为StringBuilder的参数。在函数中,我们使用hyphenstr修改sb参数。由于我们通过引用传递了sb变量,它也修改了Main函数中的sb1变量。请注意,它将显示与NonPureFunction2.csproj相同的输出。

要将前面两个非纯函数代码示例转换为纯函数代码,我们可以重构代码如下。此代码可以在PureFunction.csproj中找到:

class Program 
{ 
  public static string AddSpace(string strSource, string str) 
  { 
    return (strSource + ' ' + str); 
  } 

  static void Main(string[] args) 
  { 
    string str1 = "First"; 
    string str2 = AddSpace(str1, "Second"); 
    string str3 = AddSpace(str2, "Third"); 
    Console.WriteLine(str3); 
  } 
} 

运行PureFunction.csproj,我们将获得与前两个非纯函数代码相同的输出。但是,在此纯函数代码中,Main函数中有三个变量。这是因为在函数式编程中,我们不能修改我们之前初始化的变量。在AddSpace函数中,现在不是修改全局变量或参数,而是返回一个字符串值以满足函数规则。

如果我们在代码中实现纯函数,我们将获得以下优势:

  • 我们的代码将更易于阅读和维护,因为函数不依赖于外部状态和变量。它还旨在执行增加可维护性的特定任务。

  • 设计将更容易更改,因为重构更容易。

  • 测试和调试将更容易,因为很容易隔离纯函数。

递归函数

在命令式编程世界中,我们有破坏性赋值来改变变量的状态。通过使用循环,可以更改多个变量以实现计算目标。在函数式编程世界中,由于变量不能被破坏性地分配,我们需要递归函数调用来实现循环的目标。

让我们创建一个阶乘函数。在数学术语中,非负整数N的阶乘是小于或等于N的所有正整数的乘积。这通常用N!表示。我们可以将7的阶乘表示如下:

7! = 7 x 6 x 5 x 4 x 3 x 2 x 1 
   = 5040 

如果我们更深入地研究前面的公式,我们将发现公式的模式如下:

N!= N (N-1)(N-2)(N-3)(N-4)*(N-5)...

现在,让我们看一下 C#中的以下阶乘函数。这是一种命令式方法,可以在RecursiveImperative.csproj文件中找到:

public partial class Program 
{ 
  private static int GetFactorial(int intNumber) 
  { 
    if (intNumber == 0) 
    { 
      return 1; 
    } 

    return intNumber * GetFactorial(intNumber - 1); 
  } 
} 

正如我们所看到的,我们从GetFactorial()函数中调用GetFactorial()函数本身。这就是我们所说的递归函数。我们可以通过创建包含以下代码的Main()方法来使用此函数:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    Console.WriteLine( 
      "Enter an integer number (Imperative approach)"); 
    int inputNumber = Convert.ToInt32(Console.ReadLine()); 
    int factorialNumber = GetFactorial(inputNumber); 
    Console.WriteLine( 
      "{0}! is {1}", 
      inputNumber, 
      factorialNumber); 
  } 
} 

我们调用GetFactorial()方法并将我们想要的数字传递给参数。然后,该方法将我们的数字与GetFactorial()方法返回的结果相乘,其中参数已减去 1。迭代将持续到intNumber-1等于 0,然后返回 1。

现在,让我们将命令式方法中的前面递归函数与函数式方法中的递归函数进行比较。我们将使用 LINQ 功能中的Aggregate运算符的功能来实现这个目标。我们可以在RecursiveFunctional.csproj文件中找到代码。代码将如下所示:

class Program 
{ 
  static void Main(string[] args) 
  { 
    Console.WriteLine( 
      "Enter an integer number (Functional approach)"); 
    int inputNumber = Convert.ToInt32(Console.ReadLine()); 
    IEnumerable<int> ints = Enumerable.Range(1, inputNumber); 
    int factorialNumber = ints.Aggregate((f, s) => f * s); 
    Console.WriteLine( 
      "{0}! is {1}", 
      inputNumber, 
      factorialNumber); 
  } 
} 

我们初始化ints变量,其中包含从 1 到我们所需的整数的值,然后我们使用Aggregate运算符迭代intsRecursiveFunctional.csproj的输出与RecursiveImperative.csproj的输出完全相同。然而,我们在RecursiveFunctional.csproj中使用了函数式方法。

在 C#中感受函数式

本节将讨论 C#中的函数式编程。我们将讨论函数式编程的概念方面,并在 C#中编写代码。我们将通过讨论柯里化、管道化和方法链接来开始讨论。

使用数学概念理解函数式方法

在函数式编程中,函数的行为方式类似数学函数,无论在何种上下文中调用,对于给定的参数都会返回相同的值。这被称为引用透明性。为了更详细地理解这一点,考虑我们有以下数学函数表示,并且我们想将其转换为 C#中的函数式编程:

f(x) = 4x² -14x-8

C#中的函数式编程如下:

public partial class Program 
{ 
  public static int f(int x) 
  { 
    return (4 * x * x - 14 * x - 8); 
  } 
} 

从前面的函数中,我们可以在FunctionF.csproj文件中找到,如果x为 5,我们将得到 5 的f,即 22。表示如下:

f(5) = 22

我们还可以在 C#中调用f函数,如下所示:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    int i = f(5); 
    Console.WriteLine(i); 
  } 
} 

每次我们以 5 作为参数运行函数时,也就是x等于 5 时,我们总是得到 22 作为返回值。

现在,将其与命令式方法进行比较。让我们看一下以下代码,它将存储在ImperativeApproach.csproj文件中:

public partial class Program 
{ 
  static int i = 0; 

  static void increment() 
  { 
    i++; 
  } 

  static void set(int inpSet) 
  { 
    i = inpSet; 
  } 
} 

我们在Main()方法中描述以下代码:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    increment(); 
    Console.WriteLine("First increment(), i = {0}", i); 

    set(6); 
    increment(); 
    Console.WriteLine("Second increment(), i = {0}", i); 

    set(2); 
    increment(); 
    Console.WriteLine("Last increment(), i = {0}", i); 

    return; 
  } 
} 

如果我们运行ImperativeApproach.csproj,控制台屏幕应该如下截图所示:

使用数学概念理解函数式方法

在之前的命令式方法代码中,无论我们传入相同的参数,每次调用incrementset时都会得到不同的i输出。在这里,我们发现了命令式方法的所谓副作用问题。incrementset函数被称为具有副作用,因为它们修改了i的状态并与外部世界交互。

关于副作用的问题,现在我们在 C#中有以下代码:

public partial class Program 
{ 
  public static string GetSign(int val) 
  { 
    string posOrNeg; 

    if (val > 0) 
      posOrNeg = "positive"; 
    else 
      posOrNeg = "negative"; 

    return posOrNeg; 
  } 
} 

前面的代码是语句样式代码,我们可以在StatementStyle.csproj文件中找到。这是一种命令式编程技术,它定义动作而不是产生结果。我们告诉计算机要做什么。我们要求计算机比较value变量的值与零,然后将posOrNeg变量分配给相关值。我们可以通过将以下代码添加到项目中来尝试前面的函数:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    Console.WriteLine( 
      "Sign of -15 is {0}", 
      GetSign(-15)); 
  } 
} 

控制台的输出将如下所示:

使用数学概念理解函数式方法

这与我们之前的讨论一致。

我们可以通过将其修改为表达式样式代码来将其转换为函数式方法。在 C#中,我们可以使用条件运算符来实现这个目标。以下是我们从StatementStyle.csproj代码重构的代码,我们可以在ExpressionStyle.csproj文件中找到:

public partial class Program 
{ 
  public static string GetSign(int val) 
  { 
    return val > 0 ? "positive" : "negative"; 
  } 
} 

现在我们有了紧凑的代码,它具有与我们之前的许多行代码相同的行为。然而,正如我们之前讨论的,前面的代码没有副作用,因为它只返回字符串值,无需先准备变量。而在语句风格方法中,我们必须两次分配posOrNeg变量。换句话说,我们可以说函数式方法将产生一个无副作用的函数。

与命令式编程相比,在函数式编程中,我们描述我们想要的结果,而不是指定如何获得结果。假设我们有一组数据,并且想要创建一个新列表,其中包含源列表中的第 N 个元素。实现这一目标的命令式方法如下:

public partial class Program 
{ 
  static List<int> NthImperative(List<int> list, int n) 
  { 
    var newList = new List<int>(); 

    for (int i = 0; i < list.Count; i++) 
    { 
      if (i % n == 0) newList.Add(list[i]); 
    } 

    return newList; 
  } 
} 

前面的代码可以在NthElementImperative.csproj文件中找到。正如我们所看到的,在 C#中检索列表中的第 N 个元素,我们必须初始化第一个元素,以便将i定义为0。然后我们遍历列表元素,并决定当前元素是否为第 N 个元素。如果是,我们将从源列表中添加新数据到newList中。在这里,我们发现前面的源代码不是一种函数式方法,因为在添加新数据时newList变量被赋值了多次。它还包含了循环过程,而函数式方法则没有。然而,我们可以将代码转换为函数式方法,如下所示:

public partial class Program 
{ 
  static List<int> NthFunctional(List<int> list, int n) 
  { 
    return list.Where((x, i) => i % n == 0).ToList(); 
  } 
} 

再次,由于我们使用了 LINQ 功能的强大功能,我们在函数式方法中有紧凑的代码。如果我们想要尝试前面的两个函数,我们可以将以下代码插入到Main()函数中:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    List<int> listing = 
      new List<int>() { 
      0, 1, 2, 3, 4, 5, 
      6, 7, 8, 9, 10, 11, 
      12, 13, 14, 15, 16 }; 

    var list3rd_imper = NthImperative(listing, 3); 
    PrintIntList("Nth Imperative", list3rd_imper); 

    var list3rd_funct = NthFunctional(listing, 3); 
    PrintIntList("Nth Functional", list3rd_funct); 
  } 
} 

对于PrintIntList()方法,实现如下:

public partial class Program 
{ 
  static void PrintIntList( 
    string titleHeader, 
    List<int> list) 
  { 
    Console.WriteLine( 
      String.Format("{0}", 
      titleHeader)); 

    foreach (int i in list) 
    { 
      Console.Write(String.Format("{0}\t", i)); 
    } 

    Console.WriteLine("\n"); 
  } 
} 

尽管我们使用不同的方法运行了这两个函数,但我们仍然得到了相同的输出,如下所示:

使用数学概念理解函数式方法

应用元组进行函数式 C#

在.NET Framework 4 中,元组被引入为一组新的通用类,用于存储一组不同类型的元素。元组是不可变的,因此可以应用于函数式编程。当我们需要对象中的不同数据类型时,它用于表示数据结构。以下是声明元组对象的可用语法:

public class Tuple <T1>
public class Tuple <T1, T2>
public class Tuple <T1, T2, T3>
public class Tuple <T1, T2, T3, T4>
public class Tuple <T1, T2, T3, T4, T5>
public class Tuple <T1, T2, T3, T4, T5, T6>
public class Tuple <T1, T2, T3, T4, T5, T6, T7>
public class Tuple <T1, T2, T3, T4, T5, T6, T7, T8>

正如我们在前面的语法中所看到的,我们可以创建一个最多包含八个项目类型(T1T2等)的元组。Tuple具有只读属性,因此它是不可变的。让我们看一下在Tuple.csproj项目中可以找到的以下代码片段:

public partial class Program
{
  Tuple<string, int, int> geometry1 =
     new Tuple<string, int, int>(
         "Rectangle",
          2,
          3);
  Tuple<string, int, int> geometry2 =
  Tuple.Create(
         "Square",
          2,
          2);
}

要创建元组,我们有两种不同的方法,基于前面的代码。前者,我们将一个新的元组实例化为一个变量。后者,我们使用Tuple.Create()。要使用元组数据,我们可以像以下代码片段中那样使用它的项目:

public partial class Program
{
  private static void ConsumeTuple()
  {
    Console.WriteLine(
      "{0} has size {1} x {2}",
       geometry1.Item1,
       geometry1.Item2,
       geometry1.Item3);
    Console.WriteLine(
      "{0} has size {1} x {2}",
       geometry2.Item1,
       geometry2.Item2,
       geometry2.Item3);
  }
}

如果我们运行上面的ConsumeTuple()方法,我们将在控制台上得到以下输出:

应用元组进行函数式 C#

我们也可以像以下代码片段中所做的那样返回一个元组数据类型:

public partial class Program
{
  private static Tuple<int, int> (
     string shape)
  { GetSize
    if (shape == "Rectangle")
    {
      return Tuple.Create(2, 3);
    }
    else if (shape == "Square")
    {
      return Tuple.Create(2, 2);
    }
    else
    {
      return Tuple.Create(0, 0);
    }
  }
}

正如我们所看到的,GetSize()方法将返回元组数据类型。我们可以添加以下ReturnTuple()方法:

public partial class Program
{
  private static void ReturnTuple()
  {
    var rect = GetSize("Rectangle");
    Console.WriteLine(
        "Rectangle has size {0} x {1}",
          rect.Item1,
          rect.Item2);
    var square = GetSize("Square");
    Console.WriteLine(
       "Square has size {0} x {1}",
         square.Item1,
         square.Item2);
  }
}

如果我们运行上面的ReturnTuple()方法,我们将会得到与ConsumeTuple()方法完全相同的输出。

幸运的是,在 C# 7 中,我们可以返回元组数据类型,而无需声明元组,如以下代码片段所示:

public partial class Program
{
  (int, int) GetSizeInCS7(
          string shape)
    {
      if (shape == "Rectangle")
      {
        return (2, 3);
      }
      else if (shape == "Square")
      {
        return (2, 2);
      }
      else
      {
        return (0, 0);
      }
  }
}

如果我们想要为元组中的所有项目命名,我们现在可以在 C# 7 中使用以下代码片段中的技术来实现:

public partial class Program
{
  private static (int x, int y) GetSizeNamedItem(
          string shape)
  {
    if (shape == "Rectangle")
    {
      return (2, 3);
    }
    else if (shape == "Square")
    {
      return (2, 2);
    }
    else
    {
      return (0, 0);
    }
  }
}

现在,当我们像以下代码一样访问元组项目时,它将更清晰:

public partial class Program
{
  private static void ConsumeTupleByItemName()
  {
    var rect = GetSizeNamedItem("Rectangle");
    Console.WriteLine(
       "Rectangle has size {0} x {1}",
        rect.x,
        rect.y);
    var square = GetSizeNamedItem("Square");
    Console.WriteLine(
       "Square has size {0} x {1}",
        square.x,
        square.y);
  }
}

我们不再调用Item1Item2,而是调用 x 和 y 名称。

为了获得 C# 7 中元组的所有新功能,我们必须从www.nuget.org/packages/System.ValueTuple下载System.ValueTuple NuGet 包。

C#中的柯里化

在本章的开头,我们已经从理论上讨论了柯里化。当我们将一个接受多个参数的函数分割成一系列占据部分参数的函数时,我们就应用了柯里化。换句话说,当我们将较少的参数传递给一个函数时,它将期望我们返回另一个函数,以使用函数序列完成原始函数。让我们看一下NonCurriedMethod.csproj文件中的以下代码:

public partial class Program 
{ 
  public static int NonCurriedAdd(int a, int b) => a + b; 
} 

前面的函数将添加ab参数,然后返回结果。这个函数的用法在我们日常编程中很常见;例如,看一下以下代码片段:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    int add = NonCurriedAdd(2, 3); 
    Console.WriteLine(add); 
  } 
} 

现在,让我们继续讨论柯里化方法。代码将在CurriedMethod.csproj文件中找到,函数声明如下:

public partial class Program 
{ 
  public static Func<int, int> CurriedAdd(int a) => b => a + b; 
} 

我们使用Func<>委托来创建CurriedAdd()方法。我们可以以两种方式调用前面的方法,第一种如下:

public partial class Program 
{ 
  public static void CurriedStyle1() 
  { 
    int add = CurriedAdd(2)(3); 
    Console.WriteLine(add); 
  } 
} 

CurriedAdd()方法的前面调用中,我们用两个括号传递参数,这可能不太熟悉。实际上,我们也可以只传递一个参数来对CurriedAdd()方法进行柯里化。代码如下:

public partial class Program 
{ 
  public static void CurriedStyle2() 
  { 
    var addition = CurriedAdd(2); 

    int x = addition(3); 
    Console.WriteLine(x); 
  } 
} 

从前面的代码中,我们向CurriedAdd()方法提供了一个参数:

var addition = CurriedAdd(2); 

然后,它等待另一个addition表达式,我们在以下代码中提供:

int x = addition(3); 

前面的代码的结果将与NonCurried()方法完全相同。

管道

管道技术是一种将一个函数的输出作为下一个函数的输入传递的技术。操作中的数据将像管道中的水流一样流动。我们通常在命令行界面中找到这种技术。让我们看一下以下命令行:


C:\>dir | more

前面的命令行将dir命令的输出传递给more命令的输入。现在,让我们看一下以下 C#代码,我们可以在NestedMethodCalls.csproj文件中找到:

class Program 
{ 
  static void Main(string[] args) 
  { 
    Console.WriteLine( 
      Encoding.UTF8.GetString( 
        new byte[] { 0x70, 0x69, 0x70, 0x65, 0x6C, 
        0x69, 0x6E, 0x69, 0x6E, 0x67 } 
      ) 
    ); 
  } 
} 

在前面的代码中,我们使用了嵌套方法调用技术来在控制台屏幕上编写管道。如果我们想要将其重构为管道方法,我们可以看一下以下代码,我们可以在Pipelining.csproj文件中找到它:

class Program 
{ 
  static void Main(string[] args) 
  { 
    var bytes = new byte[] { 
      0x70, 0x69, 0x70, 0x65, 0x6C, 
      0x69, 0x6E, 0x69, 0x6E, 0x67 }; 
    var stringFromBytes = Encoding.UTF8.GetString(bytes); 
    Console.WriteLine(stringFromBytes); 
  } 
} 

如果运行上述代码,我们将得到完全相同的管道输出,但这次将以管道方式呈现。

方法链接

方法链接是在一行代码中链接多个方法的过程。一个方法的返回值将成为下一个方法的输入,依此类推。使用方法链接,我们不需要声明许多变量来存储每个方法的返回值。相反,方法的返回值将传递给下一个方法的参数。以下是不应用方法链接的传统方法,我们可以在TraditionalMethod.csproj中找到该代码:

class Program 
{ 
  static void Main(string[] args) 
  { 
    var sb = new StringBuilder("0123", 10); 
    sb.Append(new char[] { '4', '5', '6' }); 
    sb.AppendFormat("{0}{1}{2}", 7, 8, 9); 
    sb.Insert(0, "number: "); 
    sb.Replace('n', 'N'); 
    var str = sb.ToString(); 
    Console.WriteLine(str); 
  } 
} 

Main函数中调用了StringBuilder的五种方法和两个变量:sb用于初始化StringBuilderstr用于以字符串格式存储StringBuilder。不幸的是,我们在那里调用的五种方法修改了sb变量。我们可以重构代码以应用方法链接,以使其成为函数式。以下是函数式代码,我们可以在ChainingMethod.csproj中找到它:

class Program 
{ 
  static void Main(string[] args) 
  { 
    var str = 
      new StringBuilder("0123", 10) 
          .Append(new char[] { '4', '5', '6' }) 
          .AppendFormat("{0}{1}{2}", 7, 8, 9) 
          .Insert(0, "number: ") 
          .Replace('n', 'N') 
          .ToString(); 
    Console.WriteLine(str); 
  } 
} 

如果运行两种类型的代码,将显示相同的输出。但是,现在我们通过链接所有调用方法得到了函数式代码。

将命令式代码转换为函数式代码

在本节中,我们将通过利用方法链接将命令式代码转换为函数式代码。假设我们想要创建一个包含太阳系行星列表的 HTML 有序列表;HTML 将如下所示:

<ol id="thePlanets"> 
  <li>The Sun/li> 
  <li value="0">Mercury</li> 
  <li value="1">Venus</li> 
  <li value="2">Earth</li> 
  <li value="3">Mars</li> 
  <li value="4">Jupiter</li> 
  <li value="5">Saturn</li> 
  <li value="6">Uranus</li> 
  <li value="7">Neptune</li> 
</ol> 

命令式代码方法

我们将列出行星的名称,包括太阳。我们还将使用每个li元素中的 value 属性标记行星的顺序。前面的 HTML 代码将显示在控制台中。我们将在ImperativeCode.csproj中创建列表;请看下面:

class Program 
{ 
  static void Main(string[] args) 
  { 
    byte[] buffer; 
    using (var stream = Utility.GeneratePlanetsStream()) 
    { 
      buffer = new byte[stream.Length]; 
      stream.Read(buffer, 0, (int)stream.Length); 
    } 
    var options = Encoding.UTF8 
      .GetString(buffer) 
      .Split(new[] { Environment.NewLine, }, 
             StringSplitOptions.RemoveEmptyEntries) 
      .Select((s, ix) => Tuple.Create(ix, s)) 
      .ToDictionary(k => k.Item1, v => v.Item2); 
    var orderedList = Utility.GenerateOrderedList( 
        options, "thePlanets", true); 

    Console.WriteLine(orderedList); 
  } 
} 

Main()方法中,我们创建一个名为 buffer 的字节数组,其中包含我们在其他类中生成的行星流。代码片段如下:

byte[] buffer; 
using (var stream = Utility.GeneratePlanetsStream()) 
{ 
  buffer = new byte[stream.Length]; 
  stream.Read(buffer, 0, (int)stream.Length); 
} 

我们可以看到有一个名为Utility的类,其中包含GeneratePlanetStream()方法。这个方法用于以流格式生成太阳系中行星的列表。让我们看一下以下代码,以找出方法内部的内容:

public static partial class Utility 
{ 
  public static Stream GeneratePlanetsStream() 
  { 
    var planets = 
    String.Join( 
      Environment.NewLine, 
      new[] { 
        "Mercury", "Venus", "Earth", 
        "Mars", "Jupiter", "Saturn", 
        "Uranus", "Neptune" 
    }); 

    var buffer = Encoding.UTF8.GetBytes(planets); 
    var stream = new MemoryStream(); 
    stream.Write(buffer, 0, buffer.Length); 
    stream.Position = 0L; 

    return stream; 
  } 
} 

首先,它创建一个名为planets的变量,其中包含八个行星的名称,分别在新行上。我们使用GetBytes方法获取 ASCII 的字节,然后将其转换为流。这个流将返回给调用者函数。

main函数中,我们还有变量 options,如下所示:

var options = Encoding.UTF8 
  .GetString(buffer) 
  .Split(new[] { Environment.NewLine, }, 
    StringSplitOptions.RemoveEmptyEntries) 
  .Select((s, ix) => Tuple.Create(ix, s)) 
  .ToDictionary(k => k.Item1, v => v.Item2); 

这将创建一个包含行星名称和其在太阳系中顺序的字典类型变量。我们在这里使用 LINQ,但我们将在下一章中更深入地讨论它。

然后,我们在Utility类中调用GenerateOrderedList()方法。这个方法用于生成一个包含太阳系中行星顺序的 HTML 有序列表。代码片段如下:

var orderedList = Utility.GenerateOrderedList( 
    options, "thePlanets", true);  

如果我们看一下GenerateOrderedList()方法,我们会发现以下代码:

public static partial class Utility 
{ 
  public static string GenerateOrderedList( 
    IDictionary<int, string> options, 
    string id, 
    bool includeSun) 
  { 
    var html = new StringBuilder(); 
    html.AppendFormat("<ol id="{0}">", id); 
    html.AppendLine(); 

    if (includeSun) 
    { 
      html.AppendLine("\t<li>The Sun/li>"); 
    } 

    foreach (var opt in options) 
    { 
      html.AppendFormat("\t<li value="{0}">{1}</li>",
      opt.Key,
      opt.Value); 
      html.AppendLine(); 
    } 

    html.AppendLine("</ol>"); 

    return html.ToString(); 
  } 
} 

首先,在这个方法中,我们创建一个名为 html 的StringBuilder函数,并添加一个开放的ol标签,表示有序列表。代码片段如下:

var html = new StringBuilder(); 
    html.AppendFormat("<ol id="{0}">", id); 
    html.AppendLine(); 

我们还有一个布尔变量includeSun,用于定义我们是否需要在列表中包含太阳。我们从方法的参数中获取这个变量的值。之后,我们迭代从参数中获取的字典的内容。这个字典是由Main()方法中的 LINQ 生成的。我们通过添加li标签来列出内容。我们使用foreach关键字来实现这个目标。以下是代码片段:

foreach (var opt in options) 
{ 
  html.AppendFormat("\t<li value="{0}">{1}</li>", 
    opt.Key, 
    opt.Value); 
  html.AppendLine(); 
} 

我们可以看到StringBuilder类中的AppendFormatString.Format类似,我们可以从字典中传递KeyValue。不要忘记使用AppendLine方法为每个li标签插入新行。

最后,我们使用以下代码关闭ol标签,该标签在以下代码片段中定义:

html.AppendLine("</ol>"); 

然后,我们调用ToString()方法从StringBuilder中获取一堆字符串。现在,如果我们运行代码,我们将在控制台屏幕上得到输出,就像我们之前讨论的那样。

功能代码方法

我们已经开发了命令式代码,以构建一个行星名称的 HTML 有序列表,就像我们之前讨论的那样。现在,从这个命令式代码中,我们将重构为使用方法链接的功能代码。我们构建的功能代码将在FunctionalCode.csproj中。

GenerateOrderedList()方法

我们从GenerateOrderedList()方法开始,因为我们将修改它的前三行。在ImperativeCode.csproj中,它看起来像下面这样:

var html = new StringBuilder(); 
  html.AppendFormat("<ol id="{0}">", id); 
  html.AppendLine(); 

我们可以将前面的代码重构为这样:

var html = 
  new StringBuilder() 
    .AppendFormat("<ol id="{0}">", id) 
    .AppendLine(); 

现在代码变得更加自然,因为它应用了方法链接。然而,我们仍然能够将AppendFormat()方法与AppendLine()方法结合起来,以使其变得简单。为了实现这个目标,我们需要方法扩展的帮助。我们可以创建一个StringBuilder的方法扩展,如下所示:

public static partial class StringBuilderExtension 
{ 
  public static StringBuilder AppendFormattedLine( 
    this StringBuilder @this,
    string format, 
    params object[] args) => 
       @this.AppendFormat(format, args).AppendLine(); 
} 

现在,因为我们在StringBuilder类中有AppendFormattedLine()方法,我们可以将之前的代码片段重构为以下内容:

var html = 
  new StringBuilder() 
      .AppendFormattedLine("<ol id="{0}">", id); 

代码片段变得比以前简单得多。我们还在foreach循环内部的AppendFormat()后面调用了AppendLine(),如下所示:

foreach (var opt in options) 
{ 
  html.AppendFormat("\t<li value="{0}">{1}</li>", 
    opt.Key, 
    opt.Value); 
  html.AppendLine(); 
} 

因此,我们还可以使用我们在StringBuilder类中添加的AppendFormattedLine()函数重构前面的代码片段,如下所示:

foreach (var opt in options) 
{ 
  html.AppendFormattedLine( 
    "\t<li value="{0}">{1}</li>", 
    opt.Key, 
    opt.Value); 
} 

接下来,我们在条件关键字if内部有AppendLine()。我们还需要重构它,以使用扩展方法进行方法链接。我们可以为StringBuilder创建名为AppendLineWhen()的扩展方法。使用这种方法是为了比较我们提供的条件,然后它应该决定是否需要被写入。扩展方法将如下所示:

public static partial class StringBuilderExtension 
{ 
  public static StringBuilder AppendLineWhen( 
    this StringBuilder @this, 
    Func<bool> predicate, 
    string value) => 
        predicate() 
         ? @this.AppendLine(value) 
          : @this;  
} 

由于我们现在有了AppendLineWhen()方法,我们可以将其链接到前面的代码片段,如下所示:

var html = 
  new StringBuilder() 
    .AppendFormattedLine("<ol id="{0}">", id) 
    .AppendLineWhen(() => includeSun, "\t<li>The Sun/li>"); 

因此,我们现在有信心从GenerateOrderedList()方法中删除以下代码:

if (includeSun) 
{ 
  html.AppendLine("\t<li>The Sun/li>"); 
} 

我们还可以使AppendLineWhen()方法更通用,使其不仅接受字符串,还接受函数作为参数。让我们将AppendLineWhen()方法修改为AppendWhen()方法,如下所示:

public static partial class StringBuilderExtension 
{ 
  public static StringBuilder AppendWhen( 
    this StringBuilder @this, 
    Func<bool> predicate, 
    Func<StringBuilder, StringBuilder> fn) => 
    predicate() 
    ? fn(@this) 
    : @this; 
} 

正如我们所看到的,该函数现在将Func<StringBuilder, StringBuilder> fn作为参数,以替换字符串值。因此,它现在使用该函数来决定fn(@this)的条件。我们可以再次使用我们的新方法重构var html,如下所示:

var html = 
  new StringBuilder() 
  .AppendFormattedLine("<ol id="{0}">", id) 
  .AppendWhen( 
    () => includeSun, 
    sb => sb.AppendLine("\t<li>The Sun/li>")); 

到目前为止,我们已经链接了两种方法,它们是AppendFormattedLine()AppendWhen()方法。我们剩下的函数是foreach循环,我们需要将其链接到名为htmlStringBuilder对象。为此,我们再次创建一个名为AppendSequence()StringBuilder的扩展方法,如下所示:

public static partial class StringBuilderExtension 
{ 
  public static StringBuilder AppendSequence<T>( 
    this StringBuilder @this, 
    IEnumerable<T> sequence, 
    Func<StringBuilder, T, StringBuilder> fn) => 
      sequence.Aggregate(@this, fn); 
} 

我们使用IEnumerable接口使该函数在序列上进行迭代。它还调用IEnumerable中的Aggregate方法作为一个累加器,计算递增序列。

现在,使用AppendSequence(),我们可以重构foreach循环并将方法链接到var html,如下所示:

var html = 
  new StringBuilder() 
  .AppendFormattedLine("<ol id="{0}">", id) 
  .AppendWhen( 
    () => includeSun, 
    sb => sb.AppendLine("\t<li>The Sun/li>")) 
  .AppendSequence( 
    options, 
    (sb, opt) => 
      sb.AppendFormattedLine( 
      "\t<li value="{0}">{1}</li>", 
      opt.Key, 
      opt.Value)); 

我们添加的AppendSequence()方法以选项变量作为字典输入和sbopt的函数。此方法将迭代字典内容,然后将格式化的字符串附加到StringBuilder sb中。现在,以下foreach循环可以从代码中删除:

foreach (var opt in options) 
{ 
  html.AppendFormattedLine( 
    "\t<li value="{0}">{1}</li>", 
    opt.Key, 
    opt.Value); 
} 

接下来是我们想要链接到var html变量的html.AppendLine("</ol>")函数调用。这相当简单,因为我们只需要链式调用它而不需要做太多更改。现在让我们看一下var html赋值中的变化:

var html = 
  new StringBuilder() 
  .AppendFormattedLine("<ol id="{0}">", id) 
  .AppendWhen( 
    () => includeSun, 
    sb => sb.AppendLine("\t<li>The Sun/li>")) 
  .AppendSequence( 
    options, 
    (sb, opt) => 
      sb.AppendFormattedLine( 
        "\t<li value="{0}">{1}</li>", 
        opt.Key, 
        opt.Value)) 
  .AppendLine("</ol>"); 

正如我们在前面的代码中所看到的,我们重构了AppendLine()方法,所以现在它链接到StringBuilder声明。

GenerateOrderedList()方法中,我们有以下代码行:

return html.ToString(); 

我们还可以重构该行,使其链接到var html中的StringBuilder声明。如果我们链接它,我们将得到以下var html初始化:

var html = 
  new StringBuilder() 
  .AppendFormattedLine("<ol id="{0}">", id) 
  .AppendWhen( 
    () => includeSun, 
    sb => sb.AppendLine("\t<li>The Sun/li>")) 
  .AppendSequence( 
    options, 
    (sb, opt) => 
      sb.AppendFormattedLine( 
      "\t<li value="{0}">{1}</li>", 
      opt.Key, 
      opt.Value)) 
  .AppendLine("</ol>") 
  .ToString(); 

不幸的是,如果我们现在编译代码,它将产生CS0161错误,错误解释如下:

'Utility.GenerateOrderedList(IDictionary<int, string>, string, bool)': not all code paths return a value 

错误发生是因为该方法在预期返回字符串值时没有返回任何值。但是,由于它是函数式编程,我们可以将这个方法重构为表达式成员。完整的GenerateOrderedList()方法将如下所示:

public static partial class Utility 
{ 
  public static string GenerateOrderedList( 
    IDictionary<int, string> options, 
    string id, 
    bool includeSun) => 
      new StringBuilder() 
      .AppendFormattedLine("<ol id="{0}">", id) 
      .AppendWhen( 
        () => includeSun, 
        sb => sb.AppendLine("\t<li>The Sun/li>")) 
      .AppendSequence( 
        options, 
        (sb, opt) => 
          sb.AppendFormattedLine( 
          "\t<li value="{0}">{1}</li>", 
          opt.Key, 
          opt.Value)) 
       .AppendLine("</ol>") 
       .ToString(); 
} 

我们从前面的代码中删除了return关键字。我们还删除了html变量。我们现在有一个函数,其主体是类似 lambda 表达式而不是语句块。这个特性是在.NET Framework 4.6 中宣布的。

Main()方法

FunctionalCode.csproj中的Main()方法是我们在 C#编程中通常遇到的典型方法。方法流程如下:它从流中读取数据到字节数组,然后将这些字节转换为字符串。之后,它执行转换以修改该字符串,然后将其传递给GenerateOrderedList()方法。

如果我们看一下起始代码行,我们会得到以下代码片段:

byte[] buffer; 
using (var stream = Utility.GeneratePlanetsStream()) 
{ 
    buffer = new byte[stream.Length]; 
    stream.Read(buffer, 0, (int)stream.Length); 
} 

我们需要重构前面的代码以便进行链接。为此,我们创建一个名为Disposable的新类,其中包含Using()方法。Disposable类中的Using()方法如下:

public static class Disposable 
{ 
  public static TResult Using<TDisposable, TResult> 
  ( 
    Func<TDisposable> factory, 
    Func<TDisposable, TResult> fn) 
    where TDisposable : IDisposable 
    { 
      using (var disposable = factory()) 
      { 
        return fn(disposable); 
      } 
    } 
}

在前面的Using()方法中,我们接受两个参数:factoryfnIDisposable接口适用的函数是factory,而fn是在声明factory函数后将要执行的函数。现在,我们可以重构Main()方法的起始行如下:

var buffer = 
  Disposable 
  .Using( 
    Utility.GeneratePlanetsStream, 
    stream => 
    { 
      var buff = new byte[stream.Length]; 
      stream.Read(buff, 0, (int)stream.Length); 
      return buff; 
    }); 

与命令式代码相比,我们现在已经重构了使用Dispose.Using()方法将流读取并存储到字节数组中的代码。我们要求 lambda 流函数返回 buff 内容。现在,我们有一个缓冲变量要传递到下一个阶段,即UTF8.GetString(buffer)方法。我们在GetString(buffer)方法中实际上做的是将缓冲区转换并映射为字符串。为了链接这个方法,我们需要创建Map方法扩展。该方法将如下所示:

public static partial class FunctionalExtensions 
{ 
  public static TResult Map<TSource, TResult>( 
    this TSource @this, 
    Func<TSource, TResult> fn) => 
    fn(@this); 
} 

由于我们需要将其作为通用方法,我们在方法的参数中使用了一个通用类型。我们还在返回值中使用了一个通用类型,这样它就不会只返回字符串值。使用通用类型,这个Map扩展方法将能够将任何静态类型值转换为另一个静态类型值。我们需要为这个方法使用表达式主体成员,所以我们在这里使用了 lambda 表达式。现在我们可以将这个Map方法用于UTF8.GetString()方法。var buffer的初始化将如下所示:

var buffer = 
  Disposable 
  .Using( 
    Utility.GeneratePlanetsStream, 
    stream => 
    { 
      var buff = new byte[stream.Length]; 
      stream.Read(buff, 0, (int)stream.Length); 
      return buff; 
    }) 
    .Map(Encoding.UTF8.GetString) 
    .Split(new[] { Environment.NewLine, }, 
    StringSplitOptions.RemoveEmptyEntries) 
  .Select((s, ix) => Tuple.Create(ix, s)) 
  .ToDictionary(k => k.Item1, v => v.Item2); 

通过应用Map方法,就像前面的代码片段一样,我们不再需要以下代码:

var options = 
  Encoding 
  .UTF8 
  .GetString(buffer) 
  .Split(new[] { Environment.NewLine, },  
    StringSplitOptions.RemoveEmptyEntries) 
  .Select((s, ix) => Tuple.Create(ix, s)) 
  .ToDictionary(k => k.Item1, v => v.Item2); 

然而,问题出现了,因为下一个代码需要将变量选项作为GenerateOrderedList()方法的参数,我们可以在以下代码片段中看到:

var orderedList = Utility.GenerateOrderedList( 
  options, "thePlanets", true); 

为了解决这个问题,我们也可以使用Map方法将GenerateOrderedList()方法链接到缓冲变量初始化,这样我们就可以删除orderedList变量。现在,代码将如下所示:

var buffer = 
  Disposable 
  .Using( 
    Utility.GeneratePlanetsStream, 
    stream => 
    { 
      var buff = new byte[stream.Length]; 
      stream.Read(buff, 0, (int)stream.Length); 
      return buff; 
    }) 
  .Map(Encoding.UTF8.GetString) 
  .Split(new[] { Environment.NewLine, }, 
    StringSplitOptions.RemoveEmptyEntries) 
  .Select((s, ix) => Tuple.Create(ix, s)) 
  .ToDictionary(k => k.Item1, v => v.Item2) 
  .Map(options => Utility.GenerateOrderedList( 
    options, "thePlanets", true)); 

由于代码的最后一行是Console.WriteLine()方法,它以orderedList变量作为参数,我们可以将缓冲变量修改为orderedList。更改将如下所示:

var orderedList = 
  Disposable 
  .Using( 
    Utility.GeneratePlanetsStream, 
    stream => 
    { 
      var buff = new byte[stream.Length]; 
      stream.Read(buff, 0, (int)stream.Length); 
      return buff; 
    }) 
  .Map(Encoding.UTF8.GetString) 
  .Split(new[] { Environment.NewLine, }, 
    StringSplitOptions.RemoveEmptyEntries) 
  .Select((s, ix) => Tuple.Create(ix, s)) 
  .ToDictionary(k => k.Item1, v => v.Item2) 
  .Map(options => Utility.GenerateOrderedList( 
    options, "thePlanets", true));  

GenerateOrderedList()方法中的最后一行是Console.WriteLine()方法。我们还将这个方法链接到orderedList变量上。为此,我们需要扩展一个名为Tee的方法,其中包含我们之前讨论过的流水线技术。让我们来看一下下面的Tee方法扩展:

public static partial class FunctionalExtensions 
{ 
  public static T Tee<T>( 
    this T @this,  
    Action<T> action) 
  { 
    action(@this); 
    return @this; 
  } 
} 

从前面的代码中,我们可以看到Tee的输出将传递给Action函数的输入。然后,我们可以使用Tee链接最后一行,如下所示:

Disposable 
  .Using( 
    Utility.GeneratePlanetsStream, 
    stream => 
    { 
      var buff = new byte[stream.Length]; 
      stream.Read(buff, 0, (int)stream.Length); 
      return buff; 
    }) 
  .Map(Encoding.UTF8.GetString) 
  .Split(new[] { Environment.NewLine, }, 
    StringSplitOptions.RemoveEmptyEntries) 
  .Select((s, ix) => Tuple.Create(ix, s)) 
  .ToDictionary(k => k.Item1, v => v.Item2) 
  .Map(options => Utility.GenerateOrderedList( 
    options, "thePlanets", true)) 
  .Tee(Console.WriteLine); 

Tee可以返回由GenerateOrderedList()方法生成的 HTML,这样我们就可以从代码中删除orderedList变量。

我们还可以将Tee方法实现到前面代码中的 lambda 表达式中。我们将使用Tee来重构以下代码片段:

stream => 
{ 
  var buff = new byte[stream.Length]; 
  stream.Read(buff, 0, (int)stream.Length); 
  return buff; 
} 

让我们了解一下前面的代码片段实际上在做什么。首先,我们初始化了一个名为buff的字节数组变量,以存储与流的长度相同的字节数。然后使用stream.Read方法填充这个字节数组,然后返回字节数组。我们也可以要求Tee方法来做这个工作。代码将如下所示:

Disposable 
  .Using( 
    Utility.GeneratePlanetsStream, 
    stream => new byte[stream.Length] 
  .Tee(b => stream.Read( 
    b, 0, (int)stream.Length))) 
  .Map(Encoding.UTF8.GetString) 
  .Split(new[] { Environment.NewLine, }, 
    StringSplitOptions.RemoveEmptyEntries) 
  .Select((s, ix) => Tuple.Create(ix, s)) 
  .ToDictionary(k => k.Item1, v => v.Item2) 
  .Map(options => Utility.GenerateOrderedList( 
    options, "thePlanets", true)) 
  .Tee(Console.WriteLine);  

现在,我们有了一个新的Main()方法,应用了方法链接来实现函数式编程。

函数式编程的优缺点

到目前为止,我们已经通过使用函数式方法来创建代码来处理函数式编程。现在,我们可以看一下函数式方法的优势,例如以下内容:

  • 执行顺序并不重要,因为它是由系统处理的,计算的值是我们给定的,而不是程序员定义的值。换句话说,表达式的声明将变得唯一。因为函数式程序对数学概念有一种方法,系统将被设计成尽可能接近数学概念的表示方式。

  • 变量的值可以被其值替换,因为表达式的求值可以随时进行。因此,函数式代码在数学上更容易追踪,因为程序允许通过等号替换等号来进行操作或转换。这个特性被称为引用透明性。

  • 不可变性使函数式代码不受副作用的影响。共享变量是副作用的一个例子,它是创建并行代码的严重障碍,并导致非确定性执行。通过消除副作用,我们可以有一个良好的编码方法。

  • 惰性求值的力量将使程序运行得更快,因为它只提供了我们真正需要的查询结果。假设我们有大量数据,并且想要根据特定条件进行过滤,比如只显示包含单词“Name”的数据。在命令式编程中,我们将不得不评估所有数据的每个操作。问题在于当操作花费很长时间时,程序也需要更多时间来运行。幸运的是,应用 LINQ 的函数式编程只在需要时执行过滤操作。这就是为什么函数式编程将节省我们大量时间使用惰性求值。

  • 我们有一个使用可组合性解决复杂问题的方法。这是一个通过将问题分解来管理问题的原则,并将问题的部分交给几个函数。这个概念类似于我们组织活动并要求不同的人承担特定的责任的情况。通过这样做,我们可以确保每个人都会正确地完成所有事情。

除了函数式编程的优点之外,还有一些缺点。以下是其中一些:

  • 由于没有状态,也不允许更新变量,性能损失将会发生。当我们处理大型数据结构并且需要对任何数据执行复制时,即使只改变了一小部分数据,问题也会发生。

  • 与命令式编程相比,函数式编程会产生更多的垃圾,因为不可变性的概念需要更多的变量来处理特定的赋值。因为我们无法控制垃圾收集,性能也会下降。

总结

到目前为止,我们已经通过讨论函数式编程的介绍来了解函数式方法。我们还将函数式方法与数学概念进行了比较,当我们创建函数式程序时。现在清楚了,函数式方法使用数学方法来组成函数式程序。

构建函数有三个重要的要点;它们是定义、脚本和会话。定义是描述数学函数的特定表达式之间的等式。脚本是程序员提供的一组定义。会话是程序将包含对脚本中定义的函数的引用的表达式提交给计算机进行评估的情况。

函数式和命令式编程的比较也引出了区分两者的重要观点。现在清楚了,在函数式编程中,程序员关注所需信息的类型和所需转换的类型,而在命令式方法中,程序员关注执行任务的方式和跟踪状态的变化。

我们还探讨了函数式编程的几个概念,如头等和高阶函数、纯函数和递归函数。头等和高阶函数的概念将函数视为值,因此我们可以将其分配给变量并将其传递给函数的参数。纯函数的概念使函数没有副作用。递归函数帮助我们使用 LINQ 中的聚合功能迭代函数本身的能力。此外,函数式编程中的函数具有我们需要了解的几个特征,例如以下内容:

每次给定相同的输入集时,它总是返回相同的值。

它从不引用函数外定义的变量。

它不能改变变量的值,因为它应用了不可变的概念。

它不包含任何 I/O,例如花哨的输出或键盘输入,因为不允许发生副作用。

在 C#中测试函数式程序时,我们采用数学方法来找出如何从数学函数中组合一个 C#函数。我们学会了如何对柯里化函数进行柯里化,以便在分配第一个参数后传递第二个参数。此外,我们现在知道如何使用管道和方法链接技术使程序具有函数式。

在学习创建函数式编程的技术后,我们将命令式方法代码转换为函数式方法代码。在这里,我们从头开始组合命令式代码,然后将其重构为函数式代码。

最后,当我们对函数式编程更加熟悉时,我们可以掌握函数式编程本身的优缺点。这将是我们需要学习函数式编程的原因。

在下一章中,我们将讨论委托数据类型,以封装具有特定参数和返回类型的方法。当我们需要创建一个更清晰、更简单的函数指针时,这是非常有用的。

第二章:委托演练

在上一章中,我们在创建的代码中应用了委托。当我们讨论函数式编程的概念时,我们应用了 C#中具有的内置委托之一。在本章中,我们将通过讨论以下主题深入探讨在函数式 C#编程中经常使用的委托:

  • 委托的定义、语法和用法

  • 将委托组合成多播委托

  • 使用内置委托

  • 理解委托中的差异

介绍委托

委托是 C#中的一种数据类型,它封装了具有特定参数和返回类型(签名)的方法。换句话说,委托将定义方法的参数和返回类型。委托类似于 C/C++中的函数指针,因为两者都存储对具有特定签名的方法的引用。与 C/C++中的函数指针一样,委托保留了它所引用的方法的内存地址。如果它引用具有不同签名的函数,编译器将发出警告。然而,由于 C++语言的非托管性质,可以将函数指针指向任意位置(通过转换)。

让我们看一下以下委托语法:

[AccessModifier] delegate ReturnType DelegateName([parameters]); 

以下是上述委托语法中每个元素的解释:

  • 访问修饰符:这是用于设置委托可访问性的修饰符。它可以是 public、private、internal 或 protected。但是,我们可以省略它,如果这样做,那么默认的修饰符将是 internal。

  • 委托:这是我们需要的关键字,以初始化委托。

  • 返回类型:这是我们分配给此委托的方法的返回数据类型。

  • 委托名称:这是委托的标识。

  • 参数:这是我们分配给此委托的方法所需的参数列表。

通过引用上述语法,我们可以初始化委托,例如SingleStringDelegate

public delegate void SingleStringDelegate(string dataString); 

由于我们有了上述委托,我们可以将具有相同签名的方法分配给委托。方法可以如下:

private static void AssignData(string dataString) 
{ 
  globalString = dataString; 
} 

或者,方法可以如下:

private static void WriteToConsole(string dataText) 
{ 
  Console.WriteLine(dataText); 
} 

由于两个方法具有相同的签名,我们可以使用以下语法将它们分配给SingleStringDelegate

SingleStringDelegate delegate1 = AssignData; 

上述语法用于将AssignData()方法分配给类型为SingleStringDelegate的变量,并且对于WriteToConsole()方法,我们可以使用以下语法:

SingleStringDelegate delegate2 = WriteToConsole; 

注意

通常,委托类型的名称以单词Delegate结尾,例如SingleStringDelegate,以便能够区分委托名称和方法名称。但这不是强制性的,我们可以省略这一点。

简单委托

为了进一步讨论委托,让我们看一下以下方法,我们可以在SimpleDelegates.csproj中找到:

public partial class Program 
{ 
  static int Rectangle(int a, int b) 
  { 
    return a * b; 
  } 
} 

在上述代码中,我们可以将Rectangle()方法分配给以下代码中给定的委托变量:

public partial class Program 
{ 
  private delegate int AreaCalculatorDelegate(int x, int y); 
} 

由于方法的签名是委托类型所期望的,因此也可以将以下方法分配给AreaCalculatorDelegate委托:

public partial class Program 
{ 
  static int Square(int x, int y) 
  { 
    return x * y; 
  } 
} 

要将方法分配给委托,我们只需要创建一个具有与要分配的方法兼容签名的委托数据类型的变量。以下是Main()方法,它将创建委托变量并调用方法:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    AreaCalculatorDelegate rect = Rectangle; 
    AreaCalculatorDelegate sqr = Square; 
    int i = rect(1, 2); 
    int j = sqr(2, 3); 
    Console.WriteLine("i = " + i); 
    Console.WriteLine("j = " + j); 
  } 
} 

从上述代码中,我们创建了两个名为rectsqr的变量,它们的类型是AreaCalculatorDelegate。以下是代码片段:

AreaCalculatorDelegate rect = Rectangle; 
AreaCalculatorDelegate sqr = Square; 

由于我们已经将rectsqr变量分配给Rectangle()Square()方法,我们可以使用委托变量调用这些方法。让我们看一下以下代码片段:

int i = rect(1, 2); 
int j = sqr(2, 3); 

我们将变量ij分配为rect()sqr()的结果。虽然它们都是变量名,但它们指的是方法的地址位置。通过调用这些变量引用的方法来执行其中包含的逻辑。我们有效地执行了两个Console.WriteLine()方法来产生以下输出:

简单委托

现在读者清楚为什么我们显示了前面截图中的输出。rectsqr变量现在分别存储对Rectangle()Square()方法的引用。我们在调用rect委托时有效地调用Rectangle()方法,同时调用sqr委托时调用Square()方法。

多播委托

我们刚刚讨论了一个简单的委托,我们将特定方法分配给委托变量。我们可以称之为单播委托。然而,委托实际上可以使用一个变量调用多个方法。为此,我们可以称之为多播委托。在多播委托的情况下,它就像一个存储在内部列表中的委托列表。当我们调用多播委托时,列表中的委托按正确的顺序同步调用。创建多播委托有几种方法。我们将详细讨论两种方法:Delegate.Combine()Delegate.Remove()方法以及+=-=(增加和减少)运算符。

使用Delegate.Combine()Delegate.Remove()方法

让我们首先检查以下代码,使用Delegate.Combine()方法创建一个多播委托。假设我们有一个名为CalculatorDelegate的委托,如下所示,我们可以在CombineDelegates.csproj中找到:

public partial class Program 
{ 
  private delegate void CalculatorDelegate(int a, int b); 
} 

然后,我们有以下四种方法,它们与CalculatorDelegate签名相同:

public partial class Program 
{ 
  private static void Add(int x, int y) 
  { 
    Console.WriteLine( 
      "{0} + {1} = {2}", 
      x, 
      y, 
      x + y); 
  } 
  private static void Subtract(int x, int y) 
  { 
    Console.WriteLine( 
      "{0} - {1} = {2}", 
      x, 
      y, 
      x - y); 
  } 
  private static void Multiply(int x, int y) 
  { 
    Console.WriteLine( 
      "{0} * {1} = {2}", 
      x, 
      y, 
      x * y); 
  } 
  private static void Division(int x, int y) 
  { 
    Console.WriteLine( 
      "{0} / {1} = {2}", 
      x, 
      y, 
      x / y); 
  } 
} 

有四种方法,它们是Add()Subtract()Multiply()Division()。我们将把这些方法转换为一个单一变量类型的委托。现在,看一下以下的CombineDelegate()方法来实现这个目标:

public partial class Program 
{ 
  private static void CombineDelegate() 
  { 
    CalculatorDelegate calcMultiples = 
      (CalculatorDelegate)Delegate.Combine( 
      new CalculatorDelegate[] { 
      Add, 
      Subtract, 
      Multiply, 
      Division }); 
    Delegate[] calcList = calcMultiples.GetInvocationList(); 
    Console.WriteLine( 
      "Total delegates in calcMultiples: {0}", 
      calcList.Length); 
    calcMultiples(6, 3); 
  } 
} 

如果我们运行这个方法,将显示以下输出:

使用 Delegate.Combine()和 Delegate.Remove()方法

通过调用单个委托,我们已成功调用了四种方法。我们在前面的代码中调用的委托在以下代码片段中:

calcMultiples(6, 3); 

实际上,calcMultiples委托在内部存储了四个委托变量,对应于我们组合的每个方法。由于Delegate.Combine()方法,我们可以使用以下语法组合委托:

CalculatorDelegate calcMultiples = 
  (CalculatorDelegate)Delegate.Combine( 
    new CalculatorDelegate[] { 
      Add, 
      Subtract, 
      Multiply, 
      Division }); 

我们还可以通过从委托变量调用GetInvocationList()来创建委托数组。通过检索委托数组,我们可以像对待普通数组一样迭代数组。我们可以检索Length属性来计算调用列表中有多少个委托。

在多播委托中,我们能够组合以及从调用列表中删除委托。让我们看一下以下的RemoveDelegate()方法:

public partial class Program 
{ 
  private static void RemoveDelegate() 
  { 
    CalculatorDelegate addDel = Add; 
    CalculatorDelegate subDel = Subtract; 
    CalculatorDelegate mulDel = Multiply; 
    CalculatorDelegate divDel = Division; 
    CalculatorDelegate calcDelegates1 = 
      (CalculatorDelegate)Delegate.Combine( 
      addDel, 
      subDel); 
    CalculatorDelegate calcDelegates2 = 
      (CalculatorDelegate)Delegate.Combine( 
      calcDelegates1, 
      mulDel); 
    CalculatorDelegate calcDelegates3 = 
      (CalculatorDelegate)Delegate.Combine( 
      calcDelegates2, 
      divDel); 
    Console.WriteLine( 
      "Total delegates in calcDelegates3: {0}", 
      calcDelegates3.GetInvocationList().Length); 
    calcDelegates3(6, 3); 
    CalculatorDelegate calcDelegates4 = 
      (CalculatorDelegate)Delegate.Remove( 
      calcDelegates3, 
      mulDel); 
    Console.WriteLine( 
      "Total delegates in calcDelegates4: {0}", 
      calcDelegates4.GetInvocationList().Length); 
    calcDelegates4(6, 3); 
  } 
} 

如果我们运行前面的方法,将在控制台中显示以下输出:

使用 Delegate.Combine()和 Delegate.Remove()方法

CombineDelegate()方法类似,我们在RemoveDelegate()方法中将四种方法组合成一个单一变量类型的委托。calcDelegates3委托是保存这四种方法的委托。实际上,当我们调用calcDelegates3时,它按正确的顺序调用这四种方法。接下来,在RemoveDelegate()方法中,我们调用Delegate.Remove()方法来从调用列表中删除选定的委托。根据前面的代码,语法如下:

CalculatorDelegate calcDelegates4 = 
  (CalculatorDelegate)Delegate.Remove( 
  calcDelegates3, 
  mulDel); 

前面的代码片段用于从调用列表中移除mulDel委托变量。正如我们在前面的图中看到的,RemoveDelegate()调用的输出显示,一旦Multiply()方法从调用列表中移除,它就不再被调用。

与委托相关联的调用列表可以包含重复的条目。这意味着我们可以多次将相同的方法添加到调用列表中。现在让我们尝试通过将DuplicateEntries()方法添加到项目中来将重复的条目插入调用列表中,如下所示:

public partial class Program 
{ 
  private static void DuplicateEntries() 
  { 
    CalculatorDelegate addDel = Add; 
    CalculatorDelegate subDel = Subtract; 
    CalculatorDelegate mulDel = Multiply; 
    CalculatorDelegate duplicateDelegates1 = 
      (CalculatorDelegate)Delegate.Combine( 
      addDel, 
      subDel); 
    CalculatorDelegate duplicateDelegates2 = 
      (CalculatorDelegate)Delegate.Combine( 
      duplicateDelegates1, 
      mulDel); 
    CalculatorDelegate duplicateDelegates3 = 
      (CalculatorDelegate)Delegate.Combine( 
      duplicateDelegates2, 
      subDel); 
    CalculatorDelegate duplicateDelegates4 = 
      (CalculatorDelegate)Delegate.Combine( 
      duplicateDelegates3, 
      addDel); 
    Console.WriteLine( 
      "Total delegates in duplicateDelegates4: {0}", 
      duplicateDelegates4.GetInvocationList().Length); 
      duplicateDelegates4(6, 3); 
  } 
} 

现在让我们运行DuplicateEntries()方法,控制台将显示以下输出:

使用 Delegate.Combine()和 Delegate.Remove()方法

通过检查前面的代码,我们可以看到duplicateDelegates2变量包含三个调用方法,分别是addDelsubDelmulDel。请查看以下代码片段以获取更多详细信息:

CalculatorDelegate duplicateDelegates1 = 
  (CalculatorDelegate)Delegate.Combine( 
  addDel, 
  subDel); 
CalculatorDelegate duplicateDelegates2 = 
  (CalculatorDelegate)Delegate.Combine( 
  duplicateDelegates1, 
  mulDel); 

再次,我们像在下面的代码片段中那样,将subDeladdDel添加到调用列表中:

CalculatorDelegate duplicateDelegates3 = 
  (CalculatorDelegate)Delegate.Combine( 
  duplicateDelegates2, 
  subDel); 
CalculatorDelegate duplicateDelegates4 = 
  (CalculatorDelegate)Delegate.Combine( 
  duplicateDelegates3, 
  addDel); 

现在,duplicateDelegates4的调用列表包含两个重复的方法。然而,当我们调用DuplicateEntries()方法时,addDelsubDel被调用两次,调用顺序就像我们将委托添加到调用列表中的顺序一样。

注意

Delegate.Combine()Delegate.Remove()静态方法将返回Delegate数据类型,而不是Delegate本身的实例。因此,在使用它们时需要将这两种方法的返回强制转换为预期的实例委托。

使用+=和-=运算符

使用+=-=运算符创建多播委托非常容易,因为这样做就像处理 C#中的任何数据类型一样。我们还可以使用+-运算符向调用列表中添加和移除委托。以下是我们可以在AddSubtractDelegates.csproj中找到的示例代码,以便使用运算符合并委托并从调用列表中移除选择的委托:

public partial class Program 
{ 
  private static void AddSubtractDelegate() 
  { 
    CalculatorDelegate addDel = Add; 
    CalculatorDelegate subDel = Subtract; 
    CalculatorDelegate mulDel = Multiply; 
    CalculatorDelegate divDel = Division; 
    CalculatorDelegate multiDel = addDel + subDel; 
    multiDel += mulDel; 
    multiDel += divDel; 
    Console.WriteLine( 
      "Invoking multiDel delegate (four methods):"); 
    multiDel(8, 2); 
    multiDel = multiDel - subDel; 
    multiDel -= mulDel; 
    Console.WriteLine( 
      "Invoking multiDel delegate (after subtraction):"); 
    multiDel(8, 2); 
  } 
} 

我们还有在前面的项目CombineDelegates.csproj中使用的四个方法:Add()Subtract()Multiply()Division()。如果我们运行AddSubtractDelegate()方法,将得到以下输出:

使用+=和-=运算符

AddSubtractDelegate()方法的起始行中,我们为我们拥有的四个方法创建了四个类型为CalculatorDelegate的变量,就像我们在之前的项目中所做的一样。然后,我们创建了一个名为multiDel的变量,以生成多播委托。在这里,我们可以看到我们只使用运算符将委托添加到多播委托变量中,其中我们使用了++=运算符。让我们看一下以下代码片段:

CalculatorDelegate multiDel = addDel + subDel; 
multiDel += mulDel; 
multiDel += divDel; 
Console.WriteLine( 
  "Invoking multiDel delegate (four methods):"); 
multiDel(8, 2); 

从前面的代码片段中,将所有四个委托合并到multiDel委托中后,我们调用multiDel委托,根据输出控制台显示的内容,程序会按适当的顺序调用这四个方法。这四个方法分别是Add()Subtract()Multiply()Division()

要从调用列表中移除委托,我们在前面的代码中使用--=运算符。让我们看一下以下代码片段,以查看我们需要做什么才能移除委托:

multiDel = multiDel - subDel; 
multiDel -= mulDel; 
Console.WriteLine( 
  "Invoking multiDel delegate (after subtraction):"); 
multiDel(8, 2); 

由于我们已经从调用列表中移除了subDelmulDel委托,所以当我们调用mulDel委托时,程序只调用两个方法,即Add()Division()方法。这证明我们已成功使用--=运算符从调用列表中移除了委托。

提示

使用+=-=运算符来分配多播委托不符合函数式编程的方法,因为这会破坏不可变性概念。然而,我们仍然可以使用+-运算符以函数式方法连续地向调用列表中添加委托和从调用列表中移除委托。

内置委托

在 C#中,我们不仅能够声明一个委托,还能够使用 C#标准库中的内置委托。这个内置委托也适用于泛型数据类型,因此在讨论内置委托之前,让我们先讨论泛型委托。

泛型委托

委托类型可以使用泛型类型作为其参数。使用泛型类型,我们可以推迟一个或多个参数或返回值的类型规定,直到委托被初始化为变量。换句话说,当我们定义委托类型时,我们不指定委托参数和返回值的数据类型。为了更详细地讨论这一点,让我们看一下下面的代码,我们可以在GenericDelegates.csproj中找到:

public partial class Program 
{ 
  private delegate T FormulaDelegate<T>(T a, T b); 
} 

我们有一个名为FormulaDelegate的委托,使用了泛型数据类型。正如我们所看到的,有一个T符号,代表了在声明变量类型为FormulaDelegate时我们将定义的数据类型。我们继续添加以下两个完全不同签名的方法:

public partial class Program 
{ 
  private static int AddInt(int x, int y) 
  { 
    return x + y; 
  } 
  private static double AddDouble(double x, double y) 
  { 
    return x + y; 
  } 
} 

现在让我们看一下以下代码,以解释我们如何声明变量类型的委托并从委托中调用方法:

public partial class Program 
{ 
  private static void GenericDelegateInvoke() 
  { 
    FormulaDelegate<int> intAddition = AddInt; 
    FormulaDelegate<double> doubleAddition = AddDouble; 
    Console.WriteLine("Invoking intAddition(2, 3)"); 
    Console.WriteLine( 
      "Result = {0}", 
      intAddition(2, 3)); 
    Console.WriteLine("Invoking doubleAddition(2.2, 3.5)"); 
    Console.WriteLine( 
      "Result = {0}", 
      doubleAddition(2.2, 3.5)); 
  } 
} 

当我们运行GenericDelegateInvoke()方法时,控制台将显示以下结果:

Generic delegates

从前面的代码中,我们可以声明两个具有不同签名的方法,只使用一个委托类型。intAddition委托引用了AddInt()方法,该方法在其参数和返回值中应用了int数据类型,而doubleAddition委托引用了AddDouble()方法,该方法在其参数和返回值中应用了double数据类型。然而,为了使委托知道它所引用的方法的数据类型,我们必须在初始化委托时在尖括号(<>)中定义数据类型。以下代码片段是使用泛型数据类型进行委托初始化的委托初始化:

FormulaDelegate<int> intAddition = AddInt; 
FormulaDelegate<double> doubleAddition = AddDouble; 

因为我们已经定义了数据类型,所以委托可以匹配它所引用的方法的数据类型。这就是为什么从输出控制台中,我们可以调用具有不同签名的两个方法。

我们已成功使用了一个泛型类型的委托,应用了一个泛型模板。下面的代码,我们可以在MultiTemplateDelegates.csproj中找到,向我们展示了委托还可以在一个委托声明中应用多个泛型模板:

public partial class Program 
{ 
  private delegate void AdditionDelegate<T1, T2>( 
    T1 value1, T2 value2); 
} 

前面的代码将创建一个名为AdditionDelegate的新委托,它有两个具有两种不同数据类型的参数。T1T2代表将在变量类型的委托声明中定义的数据类型。现在,让我们创建两个具有不同签名的方法,如下所示:

public partial class Program 
{ 
  private static void AddIntDouble(int x, double y) 
  { 
    Console.WriteLine( 
      "int {0} + double {1} = {2}", 
      x, 
      y, 
      x + y); 
  } 
  private static void AddFloatDouble(float x, double y) 
  { 
    Console.WriteLine( 
      "float {0} + double {1} = {2}", 
      x, 
      y, 
      x + y); 
  } 
} 

要将AdditionDelegate委托引用到AddIntDouble()AddFloatDouble()方法并调用委托,我们可以创建VoidDelegateInvoke()方法,如下所示:

public partial class Program 
{ 
  private static void VoidDelegateInvoke() 
  { 
    AdditionDelegate<int, double> intDoubleAdd = 
      AddIntDouble; 
    AdditionDelegate<float, double> floatDoubleAdd = 
      AddFloatDouble; 
    Console.WriteLine("Invoking intDoubleAdd delegate"); 
    intDoubleAdd(1, 2.5); 
    Console.WriteLine("Invoking floatDoubleAdd delegate"); 
    floatDoubleAdd((float)1.2, 4.3); 
  } 
} 

如果我们运行VoidDelegateInvoke()方法,我们将在控制台上看到以下输出:

Generic delegates

从前面的控制台输出可以看到,尽管它们具有不同的方法签名,但我们已成功调用了intDoubleAddfloatDoubleAdd委托。这是可能的,因为我们在AdditionDelegate委托中应用了T1T2模板。

让我们再次尝试创建多模板委托,但这次我们使用具有返回值的方法。委托的声明将如下所示:

public partial class Program 
{ 
  private delegate TResult AddAndConvert<T1, T2, TResult>( 
    T1 digit1, T2 digit2); 
} 

然后,我们向我们的项目添加了两个方法AddIntDoubleConvert()AddFloatDoubleConvert()

public partial class Program 
{ 
  private static float AddIntDoubleConvert(int x, double y) 
  { 
    float result = (float)(x + y); 
    Console.WriteLine( 
      "(int) {0} + (double) {1} = (float) {2}", 
      x, 
      y, 
      result); 
    return result; 
  } 
  private static int AddFloatDoubleConvert(float x, double y) 
  { 
    int result = (int)(x + y); 
    Console.WriteLine( 
      "(float) {0} + (double) {1} = (int) {2}", 
      x, 
      y, 
      result); 
    return result; 
  } 
} 

为了使用AddAndConvert委托,我们可以创建ReturnValueDelegateInvoke()方法,如下所示:

public partial class Program 
{ 
  private static void ReturnValueDelegateInvoke() 
  { 
    AddAndConvert<int, double, float>
        intDoubleAddConvertToFloat = AddIntDoubleConvert; 
    AddAndConvert<float, double, int>
        floatDoubleAddConvertToInt = AddFloatDoubleConvert; 
    Console.WriteLine("Invoking intDoubleAddConvertToFloat delegate"); 
    float f = intDoubleAddConvertToFloat(5, 3.9); 
    Console.WriteLine("Invoking floatDoubleAddConvertToInt delegate"); 
    int i = floatDoubleAddConvertToInt((float)4.3, 2.1); 
  } 
} 

当我们调用ReturnValueDelegateInvoke()方法时,我们得到以下输出:

Generic delegates

再次,我们成功地使用多模板泛型类型调用了两种不同签名的方法。

Action 和 Func 委托

让我们回到前面在本章中讨论的以下委托声明:

public partial class Program 
{ 
  private delegate void AdditionDelegate<T1, T2>( 
    T1 value1, T2 value2); 
} 

C#有一个内置委托,最多可以接受 16 个参数并返回 void。它被称为Action委托。换句话说,Action委托将指向一个不返回任何内容并且接受零个、一个或多个输入参数的方法。由于存在Action委托,我们不再需要声明一个委托,可以立即将任何方法分配给该委托。我们可以修改前面的MultiTemplateDelegates.csproj项目,并删除AdditionDelegate委托,因为我们现在将使用Action委托。然后,MultiTemplateDelegates.csproj中的ActionDelegateInvoke()方法将被修改为ActionDelegateInvoke(),并具有以下实现:

public partial class Program 
{ 
  private static void ActionDelegateInvoke() 
  { 
    Action<int, double> intDoubleAddAction = 
      AddIntDouble; 
    Action<float, double> floatDoubleAddAction = 
      AddFloatDouble; 
    Console.WriteLine( 
      "Invoking intDoubleAddAction delegate"); 
    intDoubleAddAction(1, 2.5); 
    Console.WriteLine( 
      "Invoking floatDoubleAddAction delegate"); 
    floatDoubleAddAction((float)1.2, 4.3); 
  } 
} 

我们可以在ActionFuncDelegates.csproj项目中找到前面的代码。正如我们所看到的,现在我们应用Action委托来替换MultiTemplateDelegates.csproj项目中的AdditionDelegate委托,如下所示:

Action<int, double> intDoubleAddAction = 
  AddIntDouble; 
Action<float, double> floatDoubleAddAction = 
  AddFloatDouble; 

C#还有另一个内置委托,它通过最多 16 个参数返回一个返回值。它们是Func委托。让我们回到MultiTemplateDelegates.csproj项目,并找到以下委托:

public partial class Program 
{ 
  private delegate TResult AddAndConvert<T1, T2, TResult>( 
    T1 digit1, T2 digit2); 
} 

我们可以删除之前的委托,因为它与Func委托的声明匹配。因此,我们可以修改MultiTemplateDelegates.csproj项目中的ReturnValueDelegateInvoke()方法,使其成为以下实现的FuncDelegateInvoke()方法:

public partial class Program 
{ 
  private static void FuncDelegateInvoke() 
  { 
    Func<int, double, float> 
       intDoubleAddConvertToFloatFunc = 
          AddIntDoubleConvert; 
    Func<float, double, int> 
       floatDoubleAddConvertToIntFunc = 
          AddFloatDoubleConvert; 
    Console.WriteLine( 
      "Invoking intDoubleAddConvertToFloatFunc delegate"); 
    float f = intDoubleAddConvertToFloatFunc(5, 3.9); 
    Console.WriteLine( 
      "Invoking floatDoubleAddConvertToIntFunc delegate"); 
    int i = floatDoubleAddConvertToIntFunc((float)4.3, 2.1); 
  } 
} 

现在,我们不再需要AddAndConvert委托,因为我们已经应用了Func委托,如下所示:

Func<int, double, float> 
  intDoubleAddConvertToFloatFunc = AddIntDoubleConvert; 
Func<float, double, int> 
  floatDoubleAddConvertToIntFunc = AddFloatDoubleConvert; 

使用内置的ActionFunc委托,代码变得更短,委托的定义也变得更容易和更快。

区分委托中的变化

泛型委托具有被分配给具有不匹配签名的方法的能力。我们可以称之为委托的变化。委托中有两种变化,它们是协变和逆变。协变允许方法具有比在委托中定义的返回类型更派生(子类型)的返回类型。另一方面,逆变允许方法具有比在委托中定义的参数类型更不派生(超类型)的参数类型。

协变

以下是委托中协变的示例,我们可以在Covariance.csproj项目中找到。首先,我们初始化以下委托:

public partial class Program 
{ 
  private delegate TextWriter CovarianceDelegate(); 
} 

现在我们有一个返回TextWriter数据类型的委托。然后,我们还创建了返回StreamWriter对象的StreamWriterMethod()方法,其实现如下:

public partial class Program
{
  private static StreamWriter StreamWriterMethod()
  {
    DirectoryInfo[] arrDirs =
       new DirectoryInfo(@"C:\Windows")
    .GetDirectories(
       "s*", 
        SearchOption.TopDirectoryOnly);

    StreamWriter sw = new StreamWriter(
    Console.OpenStandardOutput());

    foreach (DirectoryInfo dir in arrDirs)
    {
      sw.WriteLine(dir.Name);
    }

    return sw;
   }
}

我们还创建了StringWriterMethod()方法,返回StringWriter对象,并具有以下实现:

public partial class Program 
{ 
  private static StringWriter StringWriterMethod() 
  { 
    StringWriter strWriter = new StringWriter(); 
    string[] arrString = new string[]{ 
      "Covariance", 
      "example", 
      "using", 
      "StringWriter", 
      "object" 
    }; 
    foreach (string str in arrString) 
    { 
      strWriter.Write(str); 
      strWriter.Write(' '); 
    } 
    return strWriter; 
  } 
} 

现在,我们有两个返回不同对象的方法,StreamWriterStringWriter。这些方法的返回值数据类型也不同,CovarianceDelegate委托返回TextWriter对象。然而,由于StreamWriterStringWriter都是从TextWriter对象派生出来的,我们可以应用协变将这两种方法分配给CovarianceDelegate委托。

以下是CovarianceStreamWriterInvoke()方法的实现,它将StreamWriterMethod()方法分配给CovarianceDelegate委托:

public partial class Program 
{ 
  private static void CovarianceStreamWriterInvoke() 
  { 
    CovarianceDelegate covDelegate; 
    Console.WriteLine( 
      "Invoking CovarianceStreamWriterInvoke method:"); 
      covDelegate = StreamWriterMethod; 
    StreamWriter sw = (StreamWriter)covDelegate(); 
    sw.AutoFlush = true; 
    Console.SetOut(sw); 
  } 
} 

StreamWriterMethod()方法中,我们创建StreamWriter,使用以下代码将内容写入控制台:

StreamWriter sw = new StreamWriter( 
  Console.OpenStandardOutput()); 

然后,在CovarianceStreamWriterInvoke()方法中,我们调用此代码以将内容写入控制台:

sw.AutoFlush = true; 
Console.SetOut(sw); 

如果我们运行CovarianceStreamWriterInvoke()方法,将在控制台中显示以下输出:

Covariance

从前面的输出控制台中,我们提供了 Visual Studio 2015 安装路径中的目录列表。实际上,如果您安装了不同版本的 Visual Studio,则可能会有不同的列表。

现在,我们将利用StringWriterMethod()方法创建一个CovarianceDelegate委托。我们创建了CovarianceStringWriterInvoke()方法,其实现如下:

public partial class Program 
{ 
  private static void CovarianceStringWriterInvoke() 
  { 
    CovarianceDelegate covDelegate; 
    Console.WriteLine( 
      "Invoking CovarianceStringWriterInvoke method:"); 
    covDelegate = StringWriterMethod; 
    StringWriter strW = (StringWriter)covDelegate(); 
    Console.WriteLine(strW.ToString()); 
  } 
} 

我们在StringWriterMethod()方法中使用以下代码生成了StringWriter

StringWriter strWriter = new StringWriter(); 
string[] arrString = new string[]{ 
  // Array of string 
}; 
foreach (string str in arrString) 
{ 
  strWriter.Write(str); 
  strWriter.Write(' '); 
} 

然后,我们调用以下代码将字符串写入控制台:

Console.WriteLine(strW.ToString()); 

如果我们运行CovarianceStringWriterInvoke()方法,那么在StringWriterMethod()方法中定义的字符串数组arrString中定义的字符串将被显示,如下所示:

协变性

现在,从我们对协变性的讨论中,我们已经证明了委托中的协变性。返回TextWriterCovarianceDelegate委托可以分配给返回StreamWriterStringWriter的方法。以下代码片段摘自前面的几个代码,以总结委托中的协变性:

private delegate TextWriter CovarianceDelegate(); 
CovarianceDelegate covDelegate; 
covDelegate = StreamWriterMethod; 
covDelegate = StringWriterMethod; 

逆变性

现在,让我们继续讨论委托中的协变性,讨论逆变性。以下是Contravariance.csproj项目中可以找到的ContravarianceDelegate委托声明:

public partial class Program 
{ 
  private delegate void ContravarianceDelegate(StreamWriter sw); 
} 

上述委托将被分配给以下具有TextWriter数据类型参数的方法,如下所示:

public partial class Program 
{ 
  private static void TextWriterMethod(TextWriter tw) 
  { 
    string[] arrString = new string[]{ 
      "Contravariance", 
      "example", 
      "using", 
      "TextWriter", 
      "object" 
    }; 
    tw = new StreamWriter(Console.OpenStandardOutput()); 
    foreach (string str in arrString) 
    { 
      tw.Write(str); 
      tw.Write(' '); 
    } 
    tw.WriteLine(); 
    Console.SetOut(tw); 
    tw.Flush(); 
  } 
} 

分配将如下所示:

public partial class Program 
{ 
  private static void ContravarianceTextWriterInvoke() 
  { 
    ContravarianceDelegate contravDelegate = TextWriterMethod; 
    TextWriter tw = null; 
    Console.WriteLine( 
      "Invoking ContravarianceTextWriterInvoke method:"); 
    contravDelegate((StreamWriter)tw); 
  } 
} 

如果我们运行ContravarianceTextWriterInvoke()方法,控制台将显示以下输出:

逆变性

从前面的输出中,我们已成功将一个接受TextWriter参数的方法分配给了一个接受StreamWriter参数的委托。这是因为StreamWriter是从TextWriter派生出来的。让我们看一下以下代码片段:

private delegate void ContravarianceDelegate(StreamWriter sw); 
private static void TextWriterMethod(TextWriter tw) 
{ 
  // Implementation 
} 
ContravarianceDelegate contravDelegate = TextWriterMethod; 
TextWriter tw = null; 
contravDelegate((StreamWriter)tw); 

上述代码片段摘自我们讨论逆变性的代码。在这里,我们可以看到contravDelegate,一个类型为ContravarianceDelegate的变量,可以分配给TextWriterMethod()方法,即使它们具有不同的签名。这是因为StreamWriter是从TextWriter对象派生出来的。由于TextWriterMethod()方法可以使用TextWriter数据类型,因此它肯定也能够使用StreamWriter数据类型。

总结

委托在封装方法时非常有用。它就像 C#中的任何数据类型,其中变量可以初始化为具有委托数据类型。由于它类似于数据类型,可以对委托应用增量和减量操作,从而可以从多个委托创建多播委托。然而,需要记住的一件事是,由于Delegate.Combine()Delegate.Remove()方法返回Delegate数据类型,所以在使用它们时,我们必须将这两种方法的返回值转换为预期的实例委托。然而,与+=-=运算符相比,由于它们是在编译器的语言级别实现的,并且委托类型是已知的,因此在增量和减量委托操作的结果上不需要进行转换。

C#还具有内置的委托ActionFunc,这使得代码更简洁,委托的定义变得更容易和更快。因此,代码变得更简单,更容易分析。此外,委托的使用中有两种变化;协变性和逆变性,这将允许我们将方法分配给委托。协变性允许方法具有比委托中定义的返回类型更派生的返回类型,而逆变性允许方法具有比委托中定义的参数类型更不派生的参数类型。

我们现在对代理有了更好的理解。让我们继续下一章,我们将利用代理的力量来使用 lambda 表达式来表达匿名方法。

第三章:使用 Lambda 表达式表达匿名方法

在上一章中,我们已经讨论了委托,因为它是理解匿名方法和 lambda 表达式的先决条件,而这也是本章的主题。通过使用匿名方法,我们可以创建一个不需要单独方法的委托实例。通过使用 lambda 表达式,我们可以为匿名方法创建一种简写语法。在本章中,我们将深入研究匿名方法以及 Lambda 表达式。本章的主题如下:

  • 应用委托来创建和使用匿名方法

  • 将匿名方法转换为 lambda 表达式

  • 了解表达式树及其与 lambda 的关系

  • 使用 lambda 表达式订阅事件

  • 在使用函数式编程中阐述 lambda 表达式的好处

了解匿名方法

在上一章中,我们已经讨论了如何使用命名方法声明委托。当使用命名方法时,我们必须首先创建一个方法,给它一个名称,然后将其与委托关联起来。为了提醒我们,与命名方法关联的简单委托声明如下所示:

delegate void DelDelegate(int x); 
void DoSomething(int i) { /* Implementation */ } 
DelDelegate d = DoSomething; 

从上述代码中,我们简单地创建了一个名为DelDelegate的委托数据类型,并且创建了一个名为DoSomething的方法。当我们有了一个命名方法后,我们可以将委托与该方法关联起来。幸运的是,C# 2.0 中宣布了匿名方法,以简化委托的使用。它们为我们提供了一种快捷方式来创建一个简单且短小的方法,该方法将被使用一次。声明匿名方法的语法如下:

delegate([parameters]) { implementation } 

匿名方法语法的每个元素的解释如下:

  • 委托:我们需要的关键字,以便初始化委托。

  • 参数:我们分配给该委托的方法所需的参数列表。

  • 实现:方法将执行的代码。如果方法需要返回一个值,可以应用返回语句。

从上述语法中,我们可以看到匿名方法是一种没有名称的方法。我们只需要定义方法的参数和实现。

创建匿名方法

为了进一步讨论,让我们创建一个简单的匿名方法,可以在SimpleAnonymousMethods.csproj项目中找到,如下所示:

public partial class Program 
{ 
  static Func<string, string> displayMessageDelegate = 
    delegate (string str) 
  { 
    return String.Format("Message: {0}", str); 
  }; 
} 

我们现在有一个匿名方法,我们将其分配给displayMessageDelegate委托。我们使用Func内置委托创建displayMessageDelegate委托,该委托只接受一个字符串参数,并且也返回一个字符串值。如果我们需要运行匿名方法,可以按照以下方式调用委托:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    Console.WriteLine( 
      displayMessageDelegate( 
          "A simple anonymous method sample.")); 
  } 
} 

运行上述代码后,我们将在控制台上获得以下输出:

创建匿名方法

正如我们在输出控制台窗口中所看到的,我们成功地通过调用委托名称调用了匿名方法。现在,让我们回到上一章,从中使用一些代码并将其重构为匿名方法。我们将重构SimpleDelegates.csproj的代码,这是我们在上一章中讨论过的。以下是匿名方法的声明,可以在SimpleDelegatesRefactor.csproj项目中找到:

public partial class Program 
{ 
  private static Func<int, int, int> AreaRectangleDelegate = 
    delegate (int a, int b) 
  { 
    return a * b; 
  }; 

  private static Func<int, int, int> AreaSquareDelegate = 
    delegate (int x, int y) 
  { 
    return x * y; 
  }; 
} 

在我们之前的代码中有两个匿名方法。我们还使用了Func委托,这是我们在上一章中讨论过的内置委托。要调用这些方法,我们可以按照以下方式调用委托名称:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    int i = AreaRectangleDelegate(1, 2); 
    int j = AreaSquareDelegate(2, 3); 
    Console.WriteLine("i = " + i); 
    Console.WriteLine("j = " + j); 
  } 
} 

如果我们运行该项目,将会得到以下输出:

创建匿名方法

SimpleDelegates.csproj项目中的代码相比,我们在上述SimpleDelegatesRefactor.csproj项目中的代码变得更简单更短,因为我们不需要声明委托。委托与匿名方法的创建同时进行,例如以下代码片段:

private static Func<int, int, int> AreaRectangleDelegate = 
  delegate (int a, int b) 
{ 
  return a * b; 
}; 

以下是我们在上一章中使用的代码,名为SimpleDelegates.csproj

public partial class Program 
{ 
  private delegate int AreaCalculatorDelegate(int x, int y); 
  static int Square(int x, int y) 
  { 
    return x * y; 
  } 
} 

使用匿名委托,我们简化了我们的代码,与上一章中生成的代码相比。

将匿名方法用作参数

我们现在已经执行了一个匿名方法。但是,匿名方法也可以作为参数传递给方法。让我们看一下以下代码,可以在AnonymousMethodAsArgument.csproj项目中找到:

public partial class Program 
{ 
  private static bool IsMultipleOfSeven(int i) 
  { 
    return i % 7 == 0; 
  } 
} 

首先,在这个项目中有一个名为FindMultipleOfSeven的方法。该方法将被传递给以下方法的参数:

public partial class Program 
{ 
  private static int FindMultipleOfSeven(List<int> numList) 
  { 
    return numList.Find(IsMultipleOfSeven); 
  } 
} 

然后,我们从以下方法调用FindMultipleOfSeven()方法:

public partial class Program 
{ 
  private static void PrintResult() 
  { 
    Console.WriteLine( 
      "The Multiple of 7 from the number list is {0}", 
      FindMultipleOfSeven(numbers)); 
  } 
} 

我们还可以定义以下List变量,以便传递给FindMultipleOfSeven()方法的参数:

public partial class Program 
{ 
  static List<int> numbers = new List<int>() 
  { 
    54, 24, 91, 70, 72, 44, 61, 93, 
    73, 3, 56, 5, 38, 60, 29, 32, 
    86, 44, 34, 25, 22, 44, 66, 7, 
    9, 59, 70, 47, 55, 95, 6, 42 
  }; 
} 

如果我们调用PrintResult()方法,我们将得到以下输出:

将匿名方法用作参数

上述程序的目标是从数字列表中找到一个乘以七的数字。由于91是满足此条件的第一个数字,因此FindMultipleOfSeven()方法返回该数字。

FindMultipleOfSeven()方法内部,我们可以找到将IsMultipleOfSeven()方法作为参数传递给Find()方法,如下面的代码片段所示:

return numList.Find(IsMultipleOfSeven); 

如果我们愿意,我们可以用匿名方法替换这个方法,如下所示:

public partial class Program 
{ 
  private static int FindMultipleOfSevenLambda( 
    List<int> numList) 
  { 
    return numList.Find( 
      delegate(int i) 
      { 
        return i % 7 == 0; 
      } 
    ); 
  } 
} 

现在我们有了FindMultipleOfSevenLambda()方法,它调用Find()方法并将匿名方法传递给方法参数。由于我们传递了匿名方法,我们不再需要FindMultipleOfSeven()方法。我们可以使用PrintResultLambda()方法调用FindMultipleOfSevenLambda()方法,如下所示:

public partial class Program 
{ 
  private static void PrintResultLambda() 
  { 
    Console.WriteLine( 
      "({0}) The Multiple of 7 from the number list is {1}", 
      "Lambda", 
      FindMultipleOfSevenLambda(numbers)); 
  } 
} 

在执行了PrintResultLambda()方法后,我们将得到以下输出:

将匿名方法用作参数

从输出窗口中可以看到,我们仍然得到91作为7的乘积的结果。但是,我们已成功将匿名方法作为方法参数传递。

编写匿名方法-一些指导方针

在编写匿名方法时,以下是一些我们应该牢记的事情:

  • 匿名方法在其声明中没有返回类型。考虑以下代码片段:
        delegate (int a, int b) 
        { 
          return a * b; 
        }; 

注意

在前面的委托声明中,我们找不到返回类型,尽管在方法实现中找到了return关键字。这是因为编译器根据委托签名推断返回类型。

  • 我们必须将委托签名的声明与方法的参数匹配。这将类似于将命名方法分配给委托。让我们看一下以下代码片段:
        private static Func<int, int, int> AreaRectangleDelegate = 
          delegate (int a, int b) 
        { 
          return a * b; 
        }; 

注意

在上面的代码片段中,我们声明了一个接受两个 int 参数并返回 int 值的委托。参考委托签名;我们在声明匿名方法时使用相同的签名。

  • 我们不允许声明变量的名称与已声明的匿名方法的变量冲突。看一下以下代码片段:
        public partial class Program 
        { 
          private static void Conflict() 
          { 
            for (int i = 0; i < numbers.Count; i++) 
            { 
              Action<int> actDelegate = delegate(int i) 
              { 
                Console.WriteLine("{0}", i); 
              }; 
              actDelegate(i); 
            } 
          } 
        } 

注意

我们永远无法编译上述代码,因为我们在Conflict()方法和actDelegate委托中都声明了变量i

匿名方法的优势

以下是使用匿名方法的一些优点:

  • 由于我们不给方法附加名称,如果我们只想调用该方法一次,它们是一个很好的解决方案。

  • 我们可以在原地编写代码,而不是在代码的其他部分编写逻辑。

  • 我们不需要声明匿名方法的返回类型,因为它将根据分配给匿名方法的委托的签名推断出来。

  • 我们可以从匿名方法中访问外部方法的局部变量。外部变量被捕获在匿名方法内部。

  • 对于只调用一次的逻辑片段,我们不需要创建一个命名方法。

Lambda 表达式

现在我们知道,匿名方法可以帮助我们创建简单而简短的方法。然而,在 C# 3.0 中,lambda 表达式被宣布为补充匿名方法的方式,提供了一种简写的方法来创建匿名方法。事实上,当编写新代码时,lambda 表达式成为首选方式。

现在,让我们来看一下最简单的 lambda 表达式语法,如下所示:

([parameters]) => expression; 

在 lambda 表达式语法中,我们只找到两个元素,即parametersexpression。像任何方法一样,lambda 表达式具有由参数表示的参数。lambda 表达式的实现由表达式表示。如果只需要一个参数,我们还可以省略参数的括号。

让我们创建一个简单的 lambda 表达式,我们可以在SimpleLambdaExpression.csproj项目中找到,如下所示:

public partial class Program 
{ 
  static Func<string, string> displayMessageDelegate = 
    str => String.Format(Message: {0}", str); 
} 

在前面的代码中,我们声明了displayMessageDelegate委托,并使用 lambda 表达式将其分配给Func委托。与SimpleDelegates.csproj项目中的方法类似,为了调用委托,我们使用以下代码:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    Console.WriteLine( 
      displayMessageDelegate( 
      "A simple lambda expression sample.")); 
  } 
} 

我们像调用方法名一样调用displayMessageDelegate委托。输出将被发送到控制台,如下所示:

Lambda 表达式

现在,让我们比较SimpleAnonymousMethods.csproj中的匿名方法和SimpleLambdaExpression.csproj项目中的 lambda 表达式的方法声明:

static Func<string, string> displayMessageDelegate = 
  delegate (string str) 
{ 
  return String.Format("Message: {0}", str); 
}; 

前面的代码片段是一个匿名方法声明,比命名方法声明更短、更简单。

static Func<string, string> displayMessageDelegate = 
  str => String.Format("Message: {0}", str); 

前面的代码片段是一个 lambda 表达式声明,比匿名方法更短、更简单。与匿名方法相比,lambda 表达式更为简洁。

将匿名方法转换为 lambda 表达式

现在,让我们讨论将匿名方法转换为 lambda 表达式。我们有以下匿名方法:

delegate (string str) 
{ 
  return String.Format("Message: {0}", str); 
}; 

我们想将其转换为 lambda 表达式,如下所示:

str => String.Format("Message: {0}", str); 

首先,我们去掉了delegate关键字,因为我们不再需要它;所以,代码将如下所示:

(string str) 
{ 
  return String.Format("Message: {0}", str); 
}; 

然后,我们用=>lambda 运算符取代大括号,使其成为内联 lambda 表达式:

(string str) => return String.Format("Message: {0}", str); 

我们也可以去掉return关键字,因为它只是返回一个值的单行代码。代码将如下所示:

(string str) => String.Format("Message: {0}", str); 

由于前面的语法现在是一个表达式而不是一个完整的语句,所以可以从前面的代码中删除分号,代码将如下所示:

(string str) => String.Format("Message: {0}", str); 

前面的表达式是一个有效的 lambda 表达式。然而,为了充分利用 lambda 表达式,我们可以进一步简化代码。代码将如下所示:

(str) => String.Format("Message: {0}", str); 

由于我们已经去掉了string数据类型,我们现在也可以去掉括号:

str => String.Format("Message: {0}", str); 

前面的语法是我们最终的 lambda 表达式。正如我们所看到的,现在我们的代码变得更易读了,因为它更简单了。

注意

如果参数列表中只包含一个参数,则可以省略 lambda 表达式的括号。

使用 lambda 表达式,我们实际上可以在匿名方法中创建委托和表达式树类型。现在,让我们找出这两种类型之间的区别。

使用 lambda 表达式创建委托类型

我们在SimpleLambdaExpression.csproj项目中创建代码时讨论了委托类型中的 lambda 表达式。现在,让我们创建另一个项目名称,以便通过以下代码进行讨论:

public partial class Program 
{ 
  private static Func<int, int, int> AreaRectangleDelegate = 
    (a, b) => a * b; 
  private static Func<int, int, int> AreaSquareDelegate = 
    (x, y) => x * y; 
} 

再次,我们重构SimpleDelegatesRefactor.csproj项目,并用 lambda 表达式替换匿名方法。正如我们所看到的,lambda 表达式被分配给了一个类型为委托的变量。在这里,我们在委托类型中创建了一个 lambda 表达式。我们可以使用在SimpleDelegatesRefactor.csproj项目中使用的Main()方法来调用AreaRectangleDelegateAreaSquareDelegate。这两个项目的结果将完全相同。

表达式树和 lambda 表达式

除了创建委托,我们还可以创建表达式树,这是一种代表表达式元素(表达式、项、因子)的数据结构。通过遍历树,我们可以解释表达式树,或者我们可以改变树中的节点来转换代码。在编译器术语中,表达式树被称为抽象语法树AST)。

现在,让我们看一下以下代码片段,以便将 lambda 表达式分配给我们之前讨论过的委托:

Func<int, int, int> AreaRectangleDelegate = 
  (a, b) => a * b; 

正如我们所看到的,前面的陈述中有三个部分。它们如下:

  • 一个变量类型的委托声明Func<int, int, int> AreaRectangleDelegate

  • 一个等号操作符=

  • 一个 lambda 表达式(a, b) => a * b

我们将把前面的代码陈述翻译成数据。为了实现这个目标,我们需要创建Expression<T>类型的实例,其中T是委托类型。Expression<T>类型在System.Linq.Expressions命名空间中定义。在项目中使用这个命名空间后,我们可以将我们前面的代码转换成表达式树,如下所示:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    Expression<Func<int, int, int>> expression = 
      (a, b) => a * b; 
  } 
} 

我们已经将前面的委托 lambda 表达式转换成了声明为Expression<T>类型的表达式树。前面代码中的变量表达式不是可执行代码,而是一个叫做表达式树的数据结构。Expression<T>类中有四个基本属性,我们将详细讨论它们。它们如下:

  • 主体:这包含了表达式的主体

  • 参数:这包含了 lambda 表达式的参数

  • NodeType:这包含了树中节点的ExpressionType类型

  • 类型:这包含了表达式的静态类型

现在,让我们在表达式变量中添加一个断点,并通过在LambdaExpressionInExpressionTree.csproj项目中按下F5来运行调试过程。在执行表达式声明行之后,我们可以在 Visual Studio IDE 的变量窗口中窥视,并得到以下截图:

表达式树和 lambda 表达式

从前面的截图中,我们有一个包含{(a * b)}Body属性,NodeType包含 Lambda,Type包含具有三个模板的Func委托,并且有两个参数。如果我们在变量窗口中展开Body信息,我们将得到类似以下截图所示的结果:

表达式树和 lambda 表达式

从前面的截图中,我们可以看到Left属性包含{a}Right属性包含{b}。使用这些属性,我们也可以以编程方式探索表达式树的主体。以下代码是exploreBody()方法,它将探索Body的属性:

public partial class Program 
{ 
  private static void exploreBody( 
    Expression<Func<int, int, int>> expr) 
  { 
    BinaryExpression body = 
      (BinaryExpression)expr.Body; 
    ParameterExpression left = 
      (ParameterExpression)body.Left; 
    ParameterExpression right = 
      (ParameterExpression)body.Right; 
    Console.WriteLine(expr.Body); 
    Console.WriteLine( 
      "\tThe left part of the expression: {0}\n" + 
      "\tThe NodeType: {1}\n" + 
      "\tThe right part: {2}\n" + 
      "\tThe Type: {3}\n", 
      left.Name, 
      body.NodeType, 
      right.Name, 
      body.Type); 
  } 
} 

如果我们运行前面的exploreBody()方法,我们将得到以下输出:

表达式树和 lambda 表达式

在前面的代码中,我们以编程方式访问了Expression<T>Body属性。为了获取Body内容,我们需要创建一个BinaryExpression数据类型,并且为了获取LeftRight属性的内容,我们需要创建一个ParameterExpressionBinaryExpressionParameterExpression数据的代码片段如下:

BinaryExpression body = 
  (BinaryExpression)expr.Body; 
ParameterExpression left = 
  (ParameterExpression)body.Left; 
ParameterExpression right = 
  (ParameterExpression)body.Right; 

我们已经成功地从表达式树中的代码创建了一个数据结构。如果我们愿意,我们可以通过编译表达式将这些数据转换回代码。我们现在有的表达式如下:

Expression<Func<int, int, int>> expression = 
  (a, b) => a * b; 

因此,我们可以编译表达式,并使用以下compilingExpr()方法运行表达式中的代码:

public partial class Program 
{ 
  private static void compilingExpr( 
    Expression<Func<int, int, int>> expr) 
  { 
    int a = 2; 
    int b = 3; 
    int compResult = expr.Compile()(a, b); 
    Console.WriteLine( 
      "The result of expression {0}"+ 
      " with a = {1} and b = {2} is {3}", 
      expr.Body, 
      a, 
      b, 
      compResult); 
  } 
} 

如果我们运行compilingExpr()方法,将在控制台窗口上显示以下输出:

表达式树和 lambda 表达式

正如我们所看到的,我们使用表达式类中的Compile()方法编译了表达式:

int compResult = expr.Compile()(a, b); 

expr.Compile()方法根据表达式的类型生成Func<int, int, int>类型的委托。我们根据其签名给Compile()方法传递参数ab,然后它返回int值。

使用 lambda 表达式订阅事件

在 C#中,对象或类可以用来在发生某事时通知其他对象或类,这就是事件。事件中有两种类,它们是发布者和订阅者。发布者是发送(或引发)事件的类或对象,而订阅者是接收(或处理)事件的类或对象。幸运的是,lambda 表达式也可以用来处理事件。让我们看一下以下代码来进一步讨论事件:

public class EventClassWithoutEvent 
{ 
  public Action OnChange { get; set; } 
  public void Raise() 
  { 
    if (OnChange != null) 
    { 
      OnChange(); 
    } 
  } 
} 

前面的代码可以在EventsInLambda.csproj项目中找到。正如我们所看到的,项目中创建了一个名为EventClassWithoutEvent的类。该类有一个名为OnChange的属性。该属性的作用是存储订阅类并在调用Raise()方法时运行。现在,让我们使用以下代码调用Raise()方法:

public partial class Program 
{ 
  private static void CreateAndRaiseEvent() 
  { 
    EventClassWithoutEvent ev = new EventClassWithoutEvent(); 
    ev.OnChange += () => 
      Console.WriteLine("1st: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("2nd: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("3rd: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("4th: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("5th: Event raised"); 
    ev.Raise(); 
  } 
} 

如果我们运行前面的CreateAndRaiseEvent()方法,将在控制台上获得以下输出:

使用 lambda 表达式订阅事件

从代码中,我们可以看到当调用CreateAndRaiseEvent()方法时,代码实例化了一个EventClassWithoutEvent类。然后它在 lambda 表达式中订阅了五种不同的方法,然后通过调用Raise()方法引发了事件。以下代码片段将进一步解释这一点:

EventClassWithoutEvent ev = new EventClassWithoutEvent(); 
ev.OnChange += () => 
  Console.WriteLine("1st: Event raised"); 
ev.Raise(); 

从前面的代码片段中,我们可以看到 lambda 表达式可以用来订阅事件,因为它使用委托来存储订阅的方法。然而,前面的代码仍然存在一个弱点。看一下这段代码中的最后一个OnChange赋值:

ev.OnChange += () => 
  Console.WriteLine("5th: Event raised"); 

现在,假设我们将其更改为这样:

ev.OnChange = () => 
  Console.WriteLine("5th: Event raised"); 

然后,我们将删除所有四个先前的订阅者。另一个弱点是EventClassWithoutEvent引发了事件,但没有任何东西可以阻止类的用户引发此事件。通过调用OnChange(),类的所有用户都可以向所有订阅者引发事件。

使用事件关键字

使用event关键字可以解决我们之前的问题,因为它将强制类的用户只能使用+=-=运算符订阅某些内容。让我们看一下以下代码来进一步解释这一点:

public class EventClassWithEvent 
{ 
  public event Action OnChange = () => { }; 
  public void Raise() 
  { 
    OnChange(); 
  } 
} 

从前面的代码中,我们可以看到我们不再使用公共属性,而是使用EventClassWithEvent类中的公共字段。使用event关键字,编译器将保护我们的字段免受未经授权的访问。事件关键字还将保护订阅列表,因为它不能使用=运算符分配给任何 lambda 表达式,而必须与+=-=运算符一起使用。现在,让我们看一下以下代码来证明这一点:

public partial class Program 
{ 
  private static void CreateAndRaiseEvent2() 
  { 
    EventClassWithEvent ev = new EventClassWithEvent(); 
    ev.OnChange += () => 
      Console.WriteLine("1st: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("2nd: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("3rd: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("4th: Event raised"); 
    ev.OnChange = () => 
      Console.WriteLine("5th: Event raised"); 
    ev.Raise(); 
  } 
} 

现在我们有一个名为CreateAndRaiseEvent2()的方法,它与CreateAndRaiseEvent()方法完全相同,只是最后的OnChange赋值使用了=运算符而不是+=运算符。然而,由于我们已经将事件关键字应用于OnChange字段,代码无法编译,将出现CS0070错误代码,如下面的屏幕截图所示:

使用事件关键字

由于事件关键字限制了=运算符的使用,不再存在风险。event关键字还阻止了类的外部用户引发事件。只有定义事件的类的部分才能引发事件。让我们来看一下EventClassWithoutEventEventClassWithEvent类之间的区别:

public partial class Program 
{ 
  private static void CreateAndRaiseEvent3() 
  { 
    EventClassWithoutEvent ev = new EventClassWithoutEvent(); 
    ev.OnChange += () => 
      Console.WriteLine("1st: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("2nd: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("3rd: Event raised"); 
    ev.OnChange(); 
    ev.OnChange += () => 
      Console.WriteLine("4th: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("5th: Event raised"); 
    ev.Raise(); 
  } 
} 

前面的CreateAndRaiseEvent3()方法的引用是CreateAndRaiseEvent(),但我们在第三个事件和第四个事件之间插入了ev.OnChange()。如果我们运行该方法,它将成功运行,并且我们将在控制台上看到以下输出:

使用事件关键字

从输出中可以看出,EventClassWithoutEvent类中的OnChange()可以引发事件。与EventClassWithEvent类相比,如果我们在任何订阅事件之间插入OnChange(),编译器将创建编译错误,如下面的代码所示:

public partial class Program 
{ 
  private static void CreateAndRaiseEvent4() 
  { 
    EventClassWithEvent ev = new EventClassWithEvent(); 
    ev.OnChange += () => 
      Console.WriteLine("1st: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("2nd: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("3rd: Event raised"); 
    ev.OnChange(); 
    ev.OnChange += () => 
      Console.WriteLine("4th: Event raised"); 
    ev.OnChange += () => 
      Console.WriteLine("5th: Event raised"); 
    ev.Raise(); 
  } 
} 

如果我们编译前面的代码,将再次得到CS0070错误代码,因为我们在第三个事件和第四个事件之间插入了ev.OnChange()

使用 EventHandler 或 EventHandler

实际上,C#有一个名为EventHandlerEventHandler<T>的类,我们可以使用它来初始化事件,而不是使用Action类。EventHandler类接受一个发送者对象和事件参数。发送者是引发事件的对象。使用EventHandler<T>,我们可以定义事件参数的类型。让我们看一下在EventWithEventHandler.csproj项目中找到的以下代码:

public class MyArgs : EventArgs 
{ 
  public int Value { get; set; } 
  public MyArgs(int value) 
  { 
    Value = value; 
  } 
} 
public class EventClassWithEventHandler 
{ 
  public event EventHandler<MyArgs> OnChange = 
    (sender, e) => { }; 
  public void Raise() 
  { 
    OnChange(this, new MyArgs(100)); 
  } 
} 

我们有两个类,名为MyArgsEventClassWithEventHandlerEventClassWithEventHandler类使用EventHandler<MyArgs>,它定义了事件参数的类型。在引发事件时,我们需要传递MyArgs的一个实例。事件的订阅者可以访问并使用参数。现在,让我们看一下以下CreateAndRaiseEvent()方法的代码:

public partial class Program 
{ 
  private static void CreateAndRaiseEvent() 
  { 
    EventClassWithEventHandler ev = 
      new EventClassWithEventHandler(); 
    ev.OnChange += (sender, e) 
      => Console.WriteLine( 
          "Event raised with args: {0}", e.Value); 
    ev.Raise(); 
  } 
} 

如果我们运行前面的代码,将在控制台上看到以下输出:

使用 EventHandler 或 EventHandler

从前面的代码中,我们可以看到 lambda 表达式发挥了订阅事件的作用,如下所示:

ev.OnChange += (sender, e) 
  => Console.WriteLine( 
      "Event raised with args: {0}", e.Value); 

在函数式编程中使用 lambda 表达式的优势

Lambda 表达式不仅是提供匿名方法的简写符号的强大方式,而且还在函数式编程中使用。在本节中,我们将讨论在函数式编程的上下文中使用 lambda 表达式的优势。

一流函数

在第一章中,在 C#中品尝函数式风格,我们在讨论函数式编程时讨论了一流函数的概念。如果函数是一流函数,函数遵循值语义。它们可以作为参数传递,从函数返回,等等。如果我们回到关于 lambda 表达式的早期话题,我们有一个名为SimpleLambdaExpression.csproj的项目,其中包含以下简单的 lambda 表达式:

public partial class Program 
{ 
  static Func<string, string> displayMessageDelegate = 
    str => String.Format(Message: {0}", str); 
} 

然后,我们可以将以下firstClassConcept()方法添加到项目中,以演示使用 lambda 表达式的一流函数:

public partial class Program 
{ 
  static private void firstClassConcept() 
  { 
    string str = displayMessageDelegate( 
      "Assign displayMessageDelegate() to variable"); 
      Console.WriteLine(str); 
  } 
} 

如我们所见,我们已成功将displayMessageDelegate()方法分配给名为str的变量,如下所示:

string str = displayMessageDelegate( 
  "Assign displayMessageDelegate() to variable"); 

如果我们运行代码,将在控制台上看到以下输出:

一流函数

我们还可以将 lambda 表达式作为其他函数的参数传递。使用displayMessageDelegate,让我们看一下以下代码:

public partial class Program 
{ 
  static private void firstClassConcept2( 
    Func<string, string> funct, 
    string message) 
  { 
    Console.WriteLine(funct(message)); 
  } 
} 

我们有一个名为firstClassConcept2的方法,它接受Func和字符串参数。我们可以按以下方式运行该方法:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    firstClassConcept2( 
      displayMessageDelegate, 
      "Pass lambda expression to argument"); 
  } 
} 

如我们所见,我们将 lambda 表达式displayMessageDelegate传递给firstClassConcept2()方法。如果我们运行该项目,将在控制台窗口上看到以下输出:

一流函数

由于我们已经成功地将一个函数分配给一个变量,并将一个函数传递给另一个函数参数,我们可以说 lambda 表达式是在函数式编程中创建一流函数的强大工具。

闭包

闭包是一个能够被分配给一个变量(第一类函数)的函数,它具有自由变量,这些变量在词法环境中被绑定。自由变量是一个不是参数的变量;或者是一个局部变量。在闭包中,任何未绑定的变量都将从定义闭包的词法环境中捕获。为了避免对这个术语感到困惑,让我们看一下以下代码,在Closure.csproj项目中可以找到:

public partial class Program 
{ 
  private static Func<int, int> GetFunction() 
  { 
    int localVar = 1; 
    Func<int, int> returnFunc = scopeVar => 
    { 
      localVar *= 2; 
      return scopeVar + localVar; 
    }; 
  return returnFunc; 
  } 
} 

从上面的代码中,我们可以看到我们有一个名为localVar的局部变量,当调用GetFunction()方法时,它将乘以 2。localVar变量在returnValue返回时绑定在 lambda 表达式中。通过分析前面的代码而不运行它,我们可能会猜测GetFunction()将返回returnFunc,每次传递给相同的参数时都将返回相同的值。这是因为localVar每次调用GetFunction()时都将始终为1,因为它是一个局部变量。正如我们在编程中学到的,局部变量是在堆栈上创建的,当方法执行完毕时它们将消失。现在,让我们调用GetFunction()方法来证明我们的猜测,使用以下代码:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    Func<int, int> incrementFunc = GetFunction(); 
    for (int i = 0; i < 10; i++) 
    { 
      Console.WriteLine( 
        "Invoking {0}: incrementFunc(1) = {1}", 
        i, 
        incrementFunc(1)); 
    } 
  } 
} 

我们将调用incrementFunc()方法,这是GetFunction()方法的返回值,调用十次,但我们总是传递 1 作为参数。根据我们之前的猜测,我们可以说incrementFunc(1)方法在所有十次调用中都将返回3。现在,让我们运行项目,我们将在控制台上看到以下输出:

Closure

根据前面的输出,我们猜错了。localVar变量与GetFunction()方法一起存在。它在每次调用方法时都会存储其值乘以 2。我们已经成功地在词法环境中绑定了一个自由变量,这就是我们所说的闭包。

总结

在本章中,我们发现匿名方法是一种没有名称的方法。我们只需要定义方法的参数和实现。这是从委托中的简写表示。然后,我们看了 lambda 表达式,这是函数式编程中的强大工具,可以提供匿名方法的简写表示。

lambda 表达式也可以用来形成表达式树,当我们需要用常规 C#表达我们的代码,解构它,检查它和解释它时,这将非常有用。表达式树就像是代码的解释。如果我们有一个<Func<int, int, int>>表达式,它解释了如果我们给代码两个整数,它将提供一个int返回。

通过 lambda 表达式也可以订阅事件。事件中有两种类,发布者和订阅者,我们可以使用 lambda 表达式订阅事件。无论我们使用event关键字还是EventHandler关键字,lambda 表达式都可以用来订阅事件。

第一类函数概念也可以通过 lambda 表达式来实现,因为通过使用它,我们可以将函数分配给变量或将函数作为其他函数的参数传递。使用 lambda 表达式,我们还可以应用闭包概念,使局部变量在函数内部保持活动状态。

目前,讨论 lambda 表达式就足够了。但是,当我们在第五章中讨论 LINQ 时,我们将再次更详细地讨论 lambda 表达式,使用 LINQ 轻松查询任何集合。而在下一章中,我们将讨论可以用来扩展方法能力的扩展方法。

第四章:使用扩展方法扩展对象功能

正如我们在上一章中已经提到的,我们将在本章中更详细地讨论扩展方法。当我们在下一章中讨论 LINQ 时,这将是有帮助的,LINQ 是 C#中函数式编程的基本技术。以下是本章我们将涵盖的主题:

  • 练习使用扩展方法并在 IntelliSense 中获得这个新方法

  • 从其他程序集调用扩展方法

  • 为接口、集合、枚举和其他对象创建新方法

  • 与函数式编程相关的扩展方法的优势

  • 扩展方法的限制

接近扩展方法

扩展方法是一种能够扩展现有类或类型的能力,而不对现有类或类型进行任何修改。这意味着扩展方法使我们能够向现有类或类型添加方法,而无需创建新的派生类型或重新编译。

扩展方法是在 C# 3.0 中引入的,可以应用于我们自己的类型或.NET 中现有的类型。扩展方法在函数式编程中将被广泛使用,因为它符合方法链的概念,我们在第一章中已经使用了在 C#中品尝函数式风格,在以函数式风格重构代码时。

创建扩展方法

扩展方法必须声明在一个静态、非泛型和非嵌套的类中。它们是静态类中的静态方法。要创建扩展方法,首先我们必须创建一个public static类,因为扩展方法必须包含在static类中。成功创建public static类后,我们在类中定义一个方法,并在第一个方法参数中添加this关键字,以指示它是一个扩展方法。具有this关键字的方法中的第一个参数必须引用我们要扩展的类的特定实例。为了使解释更清晰,让我们看一下以下代码,创建一个扩展方法,我们可以在Palindrome.csproj项目中找到:

public static class ExtensionMethods 
{ 
  public static bool IsPalindrome(this string str) 
  { 
    char[] array = str.ToCharArray(); 
    Array.Reverse(array); 
    string backwards = new string(array); 
    return str == backwards; 
  } 
} 

现在让我们解剖上述代码,以了解如何创建扩展方法。首先,我们必须成功创建public static类,如下面的代码片段所示:

public static class ExtensionMethods 
{ 
  ... 
} 

然后,我们在类中创建一个static方法,如下面的代码片段所示:

public static bool IsPalindrome(this string str) 
{ 
  ... 
} 

正如我们在前面的方法中所看到的,我们在方法的第一个参数中添加了this关键字。这表明该方法是一个扩展方法。此外,第一个参数的类型,即字符串,表示我们要扩展的类型是string数据类型。现在,通过为string类型定义IsPalindrome()扩展方法,所有字符串实例都具有IsPalindrome()方法。让我们看一下以下代码来证明这一点:

public class Program 
{ 
  static void Main(string[] args) 
  { 
    string[] strArray = { 
      "room", 
      "level", 
      "channel", 
      "heat", 
      "burn", 
      "madam", 
      "machine", 
      "jump", 
      "radar", 
      "brain" 
    }; 
    foreach (string s instrArray) 
    { 
      Console.WriteLine("{0} = {1}", s, s.IsPalindrome()); 
    } 
  } 
} 

上述的Main()函数将检查strArray数组的所有成员,无论它是否是回文。我们可以从string类型的变量s中调用IsPalindrome()方法。当从字符串类型的实例调用IsPalindrome()方法时,代码片段如下:

foreach (string s instrArray) 
{ 
  Console.WriteLine("{0} = {1}", s, s.IsPalindrome()); 
} 

如果我们运行Palindrome.csproj项目,我们可以在控制台上获得以下输出:

创建扩展方法

由于回文是一个单词或另一个字符序列,无论我们是向后读还是向前读,只有levelmadamradar如果我们对它们调用IsPalindrome()方法,将返回true。我们的扩展方法已成功创建并运行。

代码 IntelliSense 中的扩展方法

当我们为实例创建扩展方法时,与类或类型中已存在的方法相比,没有明显的区别。这是因为在调用扩展方法或实际在类型中定义的方法时,我们将执行相同的操作。然而,我们可以检查代码智能感知来了解类型内部的方法是否是扩展方法,因为扩展方法将显示在智能感知中。当IsPalindrome()扩展方法尚未定义时,以下截图是字符串实例的方法列表:

代码智能感知中的扩展方法

IsPalindrome()扩展方法已经定义时,以下截图是字符串实例的方法列表:

代码智能感知中的扩展方法

我们可以从前面两张图片中看到,扩展方法将在 Visual Studio 的代码智能感知中列出。然而,我们现在可以找到扩展方法和实际在类型中定义的方法之间的区别。扩展方法的图标有一个向下的箭头,尽管我们在实际定义的方法中找不到它。这是因为图标不同,但我们调用方法的方式完全相同。

在其他程序集中调用扩展方法

我们已经成功在上一节中创建了IsPalindrome()扩展方法。调用扩展方法非常容易,因为它是在与调用方法相同的命名空间中定义的。换句话说,IsPalindrome()扩展方法和Main()方法在同一个命名空间中。我们不需要添加对任何模块的引用,因为该方法与调用者一起存在。然而,在通常的实践中,我们可以在其他程序集中创建扩展方法,通常称为类库。使用该类库将简化扩展方法的使用,因为它可以被重用,所以我们可以在许多项目中使用该扩展方法。

引用命名空间

我们将在类库中创建一个扩展方法,并在另一个项目中调用它。让我们创建一个名为ReferencingNamespaceLib.csproj的新类库项目,并将以下代码插入ExtensionMethodsClass.cs文件中:

using System; 
namespaceReferencingNamespaceLib 
{ 
  public static class ExtensionMethodsClass 
  { 
    public static byte[] ConvertToHex(this string str) 
    { 
      int i = 0; 
      byte[] HexArray = new byte[str.Length]; 
      foreach (char ch in str) 
      { 
        HexArray[i++] = Convert.ToByte(ch); 
      } 
      returnHexArray; 
    } 
  } 
} 

从前面的代码中,我们可以看到我们在ReferencingNamespaceLib命名空间的ExtensionMethodsClass类中创建了ConvertToHex()扩展方法。ConvertToHex()扩展方法的用途是将字符串中的每个字符转换为 ASCII 码并将其存储在字节数组中。现在让我们看一下以下代码,它将调用我们可以在ReferencingNamespace.csproj项目中找到的扩展方法:

using System; 
using ReferencingNamespaceLib; 
namespace ReferencingNamespace 
{ 
  class Program 
  { 
    static void Main(string[] args) 
    { 
      int i = 0; 
      string strData = "Functional in C#"; 
      byte[] byteData = strData.ConvertToHex(); 
      foreach (char c in strData) 
      { 
        Console.WriteLine("{0} = 0x{1:X2} ({2})", 
        c.ToString(), 
        byteData[i], 
        byteData[i++]); 
      } 
    } 
  } 
} 

从前面的代码中,我们可以看到我们如何从字符串实例strData中调用ConvertToHex()扩展方法,如下所示:

string strData = "Functional in C#"; 
byte[] byteData = strData.ConvertToHex(); 

然而,为了从字符串实例中调用ConvertToHex()方法,我们必须引用ReferencingNamespaceLib程序集,并且还要导入引用程序集的命名空间。要导入程序集,我们必须使用using以及ReferencingNamespaceLib,如下面的代码片段所示:

usingReferencingNamespaceLib; 

如果我们运行ReferencingNamespace.csproj项目,我们将在控制台上得到以下输出:

引用命名空间

正如我们所看到的,C#句子中的每个字符都被转换为 ASCII 码,通过引用命名空间调用了我们为字符串类型创建的扩展方法,以十六进制和十进制格式显示。这也证明了我们已经成功在另一个程序集中。

搭便车命名空间

如果我们愿意,我们可以依赖存储字符串类型的System命名空间,这样我们就不需要导入自定义命名空间来使用扩展方法。依赖命名空间对于我们的标准编程方法也是有好处的。让我们使用PiggybackingNamespaceLib.csproj项目中的以下代码重构我们之前的ReferencingNamespaceLib.csproj代码:

namespace System 
{ 
  public static class ExtensionMethodsClass 
  { 
    public static byte[] ConvertToHex(this string str) 
    { 
      int i = 0; 
      byte[] HexArray = new byte[str.Length]; 
      foreach (char ch in str) 
      { 
        HexArray[i++] = Convert.ToByte(ch); 
      } 
      return HexArray; 
    } 
  } 
} 

如果我们观察类名、ConvertToHex()方法签名或方法的实现,我们会发现ReferencingNamespaceLib.csprojPiggybackingNamespaceLib.csproj项目之间没有区别。但是,如果我们看命名空间名称,我们会发现现在是System而不是PiggybackingNamespaceLib。我们使用System命名空间的原因是在所选命名空间中创建扩展方法。由于我们想要扩展System命名空间中的字符串类型的能力,我们也必须扩展System命名空间。我们不需要使用using关键字导入System命名空间,因为ConvertToHex()方法位于System命名空间中。现在,让我们看一下以下代码,以便在PiggybackingNamespace.csproj项目中调用System命名空间中的ConvertToHex()方法:

using System; 
namespace PiggybackingNamespace 
{ 
  class Program 
  { 
    static void Main(string[] args) 
    { 
      int i = 0; 
      string strData = "Piggybacking"; 
      byte[] byteData = strData.ConvertToHex(); 
      foreach (char c in strData) 
      { 
        Console.WriteLine("{0} = 0x{1:X2} ({2})", 
        c.ToString(), 
        byteData[i], 
        byteData[i++]); 
      } 
    } 
  } 
} 

我们重构了ReferencingNamespace.csproj项目中的前面的代码,再次发现PiggybackingNamespace.csproj项目和ReferencingNamespace.csproj项目之间没有任何区别,除了PiggybackingNamespace.csproj项目中没有导入自定义命名空间,而ReferencingNamespace.csproj项目有:

using ReferencingNamespaceLib; 

由于我们在System命名空间中创建了扩展方法,所以我们不需要导入自定义命名空间。但是,我们仍然需要引用定义扩展方法的程序集。我们可以期望得到如下截图所示的输出:

依赖命名空间

我们已成功调用了ConvertToHex()扩展方法,并发现它对从字符串数据类型获取 ASCII 代码很有用。

利用接口、集合和对象

不仅类和类型可以应用扩展方法,接口、集合和任何其他对象也可以使用扩展方法进行功能扩展。我们将在接下来的部分讨论这个问题。

扩展接口

我们可以以与在类或类型中扩展方法相同的方式扩展接口中的方法。我们仍然需要public static类和public static方法。通过扩展接口的能力,我们可以在创建扩展方法后立即使用它,而无需在我们从接口继承的类中创建实现,因为实现是在我们声明扩展方法时完成的。让我们看一下ExtendingInterface.csproj项目中的以下DataItem类:

namespace ExtendingInterface 
{ 
  public class DataItem 
  { 
    public string Name { get; set; } 
    public string Gender { get; set; } 
  } 
} 

我们还有以下IDataSource接口:

namespace ExtendingInterface 
{ 
  public interface IDataSource 
  { 
    IEnumerable<DataItem> GetItems(); 
  } 
} 

正如我们所看到的,IDataSource接口只有一个名为GetItems()的方法签名,返回IEnumerable<DataItem>。现在,我们可以创建一个类来继承IDataSource接口,我们给它一个名字ClubMember;它有GetItems()方法的实现,如下所示:

public partial class ClubMember : IDataSource 
{ 
  public IEnumerable<DataItem> GetItems() 
  { 
    foreach (var item in DataItemList) 
    { 
      yield return item; 
    } 
  } 
} 

从前面的类中,GetItems()方法将产生DataItemList中的所有数据,其内容将如下所示:

public partial class ClubMember : IDataSource 
{ 
  List<DataItem> DataItemList = new List<DataItem>() 
  { 
    newDataItem{ 
      Name ="Dorian Villarreal", 
      Gender ="Male"}, 
    newDataItem{ 
      Name ="Olivia Bradley", 
      Gender ="Female"}, 
    newDataItem{ 
      Name ="Jocelyn Garrison", 
      Gender ="Female"}, 
    newDataItem{ 
      Name ="Connor Hopkins", 
      Gender ="Male"}, 
    newDataItem{ 
      Name ="Rose Moore", 
      Gender ="Female"}, 
    newDataItem{ 
      Name ="Conner Avery", 
      Gender ="Male"}, 
    newDataItem{ 
      Name ="Lexie Irwin", 
      Gender ="Female"}, 
    newDataItem{ 
      Name ="Bobby Armstrong", 
      Gender ="Male"}, 
    newDataItem{ 
      Name ="Stanley Wilson", 
      Gender ="Male"}, 
    newDataItem{ 
      Name ="Chloe Steele", 
      Gender ="Female"} 
  }; 
} 

DataItemList中有十个DataItem类。我们可以通过GetItems()方法显示DataItemList中的所有项目,如下所示:

public class Program 
{ 
static void Main(string[] args) 
  { 
    ClubMember cm = new ClubMember(); 
    foreach (var item in cm.GetItems()) 
    { 
      Console.WriteLine( 
        "Name: {0}\tGender: {1}", 
          item.Name, 
            item.Gender); 
    } 
  } 
} 

正如我们在上述代码中所看到的,由于我们已将ClubMember类继承到IDataSource接口,并实现了GetItems()方法,因此ClubMember的实例cm可以调用GetItems()方法。当我们运行项目时,输出将如下截图所示:

扩展接口

现在,如果我们想要在不修改接口的情况下向其添加方法,我们可以为接口创建一个方法扩展。考虑到我们要向IDataSource接口添加GetItemsByGender()方法,我们可以创建如下的扩展方法:

namespaceExtendingInterface 
{ 
  public static class IDataSourceExtension 
  { 
    public static IEnumerable<DataItem>
      GetItemsByGender(thisIDataSourcesrc,string gender) 
    { 
      foreach (DataItem item in src.GetItems()) 
      { 
        if (item.Gender == gender) 
          yield return item; 
      } 
    } 
  } 
} 

通过创建上述扩展方法,ClubMember类的实例现在有一个名为GetItemsByGender()的方法。我们可以像使用方法类一样使用这个扩展方法,如下所示:

public class Program 
{ 
  static void Main(string[] args) 
  { 
    ClubMember cm = new ClubMember(); 
    foreach (var item in cm.GetItemsByGender("Female")) 
    { 
      Console.WriteLine( 
        "Name: {0}\tGender: {1}", 
        item.Name, 
        item.Gender); 
    } 
  } 
} 

GetItemsByGender()方法将返回DataItemList所选性别的IEnumerable接口。由于我们只需要获取列表中的所有女性成员,输出将如下所示:

扩展接口

我们现在可以扩展接口中的方法,而不需要在继承的类中实现该方法,因为在扩展方法定义中已经完成了。

扩展集合

在我们之前的讨论中,我们发现我们应用IEnumerable接口以收集所需的所有数据。我们还可以扩展IEnumerable接口,这是一种集合类型,以便我们可以在集合类型的实例中添加方法。

以下是ExtendingCollection.csproj项目中的代码,我们仍然使用ExtendingInterface.csproj项目中使用的DataItem.csIDataSource.cs。让我们看一下以下代码:

public static partial class IDataSourceCollectionExtension 
{ 
  public static IEnumerable<DataItem>
    GetAllItemsByGender_IEnum(thisIEnumerablesrc,string gender) 
  { 
    var items = new List<DataItem>(); 
    foreach (var s in src) 
    { 
      var refDataSource = s as IDataSource; 
      if (refDataSource != null) 
      { 
        items.AddRange(refDataSource.GetItemsByGender(gender)); 
       } 
    } 
    return items; 
  } 
} 

上述代码是IEnumerable类型的扩展方法。为了防止出现错误,我们必须使用以下代码片段对所有源项的类型进行转换:

var refDataSource = s as IDataSource; 

我们还可以扩展IEnumerable<T>类型,如下所示:

public static partial class IDataSourceCollectionExtension 
{ 
  public static IEnumerable<DataItem> 
  GetAllItemsByGender_IEnumTemplate
    (thisIEnumerable<IDataSource> src, string gender) 
  { 
    return src.SelectMany(x =>x.GetItemsByGender(gender)); 
  } 
} 

使用上述方法,我们可以扩展IEnumerable<T>类型,以拥有一个名为GetAllItemsByGender_IEnumTemplate()的方法,用于按特定性别获取项目。

现在,我们准备调用这两个扩展方法。但在调用它们之前,让我们创建以下两个类,名为ClubMember1ClubMember2

public class ClubMember1 : IDataSource 
{ 
  public IEnumerable<DataItem> GetItems() 
  { 
    return new List<DataItem> 
    { 
      newDataItem{ 
        Name ="Dorian Villarreal", 
        Gender ="Male"}, 
      newDataItem{ 
        Name ="Olivia Bradley", 
        Gender ="Female"}, 
      newDataItem{ 
        Name ="Jocelyn Garrison", 
        Gender ="Female"}, 
      newDataItem{ 
        Name ="Connor Hopkins", 
        Gender ="Male"}, 
      newDataItem{ 
        Name ="Rose Moore", 
        Gender ="Female"} 
    }; 
  } 
} 
public class ClubMember2 : IDataSource 
{ 
  public IEnumerable<DataItem> GetItems() 
  { 
    return new List<DataItem> 
    { 
      newDataItem{ 
        Name ="Conner Avery", 
        Gender ="Male"}, 
      newDataItem{ 
        Name ="Lexie Irwin", 
        Gender ="Female"}, 
      newDataItem{ 
        Name ="Bobby Armstrong", 
        Gender ="Male"}, 
      newDataItem{ 
        Name ="Stanley Wilson", 
        Gender ="Male"}, 
      newDataItem{ 
        Name ="Chloe Steele", 
        Gender ="Female"} 
    }; 
  } 
} 

现在,我们将调用GetAllItemsByGender_IEnum()GetAllItemsByGender_IEnumTemplate()扩展方法。代码将如下所示:

public class Program 
{ 
  static void Main(string[] args) 
  { 
    var sources = new IDataSource[] 
    { 
      new ClubMember1(), 
      new ClubMember2() 
    }; 
    var items = sources.GetAllItemsByGender_IEnum("Female"); 
    Console.WriteLine("Invoking GetAllItemsByGender_IEnum()"); 
    foreach (var item in items) 
    { 
      Console.WriteLine( 
        "Name: {0}\tGender: {1}", 
        item.Name, 
        item.Gender); 
    } 
  } 
} 

从上述代码中,首先我们创建一个包含IDataSource数组的sources变量。我们从ClubMember1ClubMember2类获取sources的数据。由于源是IDataSource的集合,因此可以将GetAllItemsByGender_IEnum()方法应用于它。如果我们运行上述Main()方法,将在控制台上显示以下输出:

扩展集合

我们已成功调用了GetAllItemsByGender_IEnum()扩展方法。现在,让我们尝试使用以下代码调用GetAllItemsByGender_IEnumTemplate扩展方法:

public class Program 
{ 
  static void Main(string[] args) 
  { 
    var sources = new List<IDataSource> 
    { 
      new ClubMember1(), 
      new ClubMember2() 
    }; 
    var items = 
      sources.GetAllItemsByGender_IEnumTemplate("Female"); 
    Console.WriteLine(
      "Invoking GetAllItemsByGender_IEnumTemplate()"); 
    foreach (var item in items) 
    { 
      Console.WriteLine("Name: {0}\tGender: {1}", 
        item.Name,item.Gender); 
    } 
  } 
} 

我们在尚未显示的代码中声明了sources变量,方式与之前的Main()方法中声明它的方式相同。此外,我们可以将GetAllItemsByGender_IEnumTemplate()扩展方法应用于源变量。如果我们运行上述代码,输出将如下所示:

扩展集合

通过比较输出的两个图像,我们可以看到它们之间没有区别,尽管它们扩展了不同的集合类型。

扩展对象

我们不仅可以扩展接口和集合,还可以扩展对象,这意味着我们可以扩展一切。为了讨论这一点,让我们看一下在ExtendingObject.csproj项目中可以找到的以下代码:

public static class ObjectExtension 
{ 
  public static void WriteToConsole(this object o,    stringobjectName) 
  { 
    Console.WriteLine(
      String.Format(
        "{0}: {1}\n",
        objectName,
        o.ToString())); 
  } 
} 

我们有一个名为WriteToConsole()的方法扩展,它可以应用于 C#中的所有对象,因为它扩展了Object类。要使用它,我们可以将它应用于各种对象,如下面的代码所示:

public class Program 
{ 
  static void Main(string[] args) 
  { 
    var obj1 = UInt64.MaxValue; 
    obj1.WriteToConsole(nameof(obj1)); 
    var obj2 = new DateTime(2016, 1, 1); 
    obj2.WriteToConsole(nameof(obj2)); 
    var obj3 = new DataItem 
    { 
      Name = "Marcos Raymond", 
      Gender = "Male" 
    }; 
    obj3.WriteToConsole(nameof(obj3)); 
    IEnumerable<IDataSource> obj4 =new List<IDataSource> 
    { 
      new ClubMember1(), 
      new ClubMember2() 
    }; 
    obj4.WriteToConsole(nameof(obj4)); 
  } 
} 

在我们分解前面的代码之前,让我们运行这个Main()方法,我们将在控制台上得到以下输出:

扩展对象

从前面的代码中,我们可以看到所有UInt64DateTimeDataItemIEnumerable<IDataSource>对象都可以调用我们声明的WriteToConsole()扩展方法,该方法使用this对象作为参数。

提示

在对象类型中创建扩展方法会导致框架中的所有类型都能够访问该方法。我们必须确保该方法的实现可以应用于框架支持的不同类型。

在函数式编程中使用扩展方法的优势

函数式编程中的方法链依赖于扩展方法。正如我们在第一章中已经讨论过的那样,在 C#中品尝函数式风格,方法链将使我们的代码更易于阅读,因为它可以减少代码行数。为了提高扩展方法的代码可读性,让我们看一下以下代码,可以在CodeReadability.csproj项目中找到:

using System.Linq; 
namespace CodeReadability 
{ 
  public static class HelperMethods 
  { 
    public static string TrimAllSpace(string str) 
    { 
      string retValue = ""; 
      foreach (char c in str) 
      { 
        retValue +=!char.IsWhiteSpace(c) ?c.ToString() :""; 
      } 
      return retValue; 
    } 
    public static string Capitalize(string str) 
    { 
      string retValue = ""; 
      string[] allWords = str.Split(' '); 
      foreach (string s inallWords) 
      { 
        retValue += s.First() 
        .ToString() 
        .ToUpper() 
        + s.Substring(1) 
        + " "; 
      } 
      return retValue.Trim(); 
    } 
  } 
} 

前面的代码是static类中的static方法。它不是扩展方法,因为在方法参数中我们没有使用this关键字。我们可以在HelperMethods.cs文件中找到它。TrimAllSpace()方法的用途是从字符串中删除所有空格字符,而Capitalize()方法的用途是将字符串中的第一个字母大写。我们还有完全相同的方法HelperMethods,可以在ExtensionMethods.cs文件中找到。让我们看一下以下代码,其中我们将TrimAllSpace()Capitalize()声明为扩展方法:

using System.Linq; 
namespace CodeReadability 
{ 
  public static class ExtensionMethods 
  { 
    public static string TrimAllSpace(this string str) 
    { 
      string retValue = ""; 
      foreach (char c in str) 
      { 
        retValue +=!char.IsWhiteSpace(c) ?c.ToString() :""; 
      } 
      return retValue; 
    } 
    public static string Capitalize(string str) 
    { 
      string retValue = ""; 
      string[] allWords = str.Split(' '); 
      foreach (string s inallWords) 
      { 
        retValue += s.First() 
          .ToString() 
          .ToUpper() 
          + s.Substring(1) 
          + " "; 
      } 
      return retValue.Trim(); 
    } 
  } 
} 

现在,我们将创建代码,将修剪给定字符串中的所有空格,然后将句子中的每个字符串大写。以下是在HelperMethods类中实现的代码:

static void Main(string[] args) 
{ 
  string sntc = ""; 
  foreach (string str in sentences) 
  { 
    string strTemp = str; 
    strTemp = HelperMethods.TrimAllSpace(strTemp); 
    strTemp = HelperMethods.Capitalize(strTemp); 
    sntc += strTemp + " "; 
  } 
  Console.WriteLine(sntc.Trim()); 
} 

我们还声明了一个名为sentences的字符串数组,如下所示:

static string[] sentences = new string[] 
{ 
  " h o w ", 
  " t o ", 
  " a p p l y ", 
  " e x t e n s i o n ", 
  " m e t h o d s ", 
  " i n ", 
  " c s h a r p ", 
  " p r o g r a m mi n g " 
}; 

前面的代码将产生以下输出:

在函数式编程中使用扩展方法的优势

如果我们愿意,我们可以简化前面使用HelperMethodsMain()方法,使用我们已经创建的扩展方法,如下所示:

static void Main(string[] args) 
{ 
  string sntc = ""; 
  foreach (string str in sentences) 
  { 
    sntc += str.TrimAllSpace().Capitalize() + " "; 
  } 
  Console.WriteLine(sntc.Trim()); 
} 

如果我们运行前面的Main()方法,我们将在控制台上得到完全相同的输出。但是,我们已经重构了以下代码片段:

string strTemp = str; 
strTemp = HelperMethods.TrimAllSpace(strTemp); 
strTemp = HelperMethods.Capitalize(strTemp); 
sntc += strTemp + " "; 

使用扩展方法,我们只需要这一行代码来替换四行代码:

sntc += str.TrimAllSpace().Capitalize() + " "; 

关键是我们已经减少了代码行数,使其变得更简单和更易读,流程也更清晰了。

扩展方法的限制

尽管扩展方法是实现函数式编程的强大工具,但这种技术仍然存在一些局限性。在这里,我们详细阐述了扩展方法所面临的限制,以便我们避免使用它们。

扩展静态类

随着我们进一步讨论扩展方法,我们知道扩展方法是具有公共可访问性的静态方法,位于具有公共可访问性的静态类内。扩展方法将出现在我们目标的类型或类中。但是,并非所有类都可以使用扩展方法进行扩展。现有的静态类将无法进行扩展。例如,Math类是由.NET 提供的。即使该类提供了我们通常使用的数学功能,有时我们可能需要向Math类添加其他功能。

然而,由于Math类是一个静态类,几乎不可能通过向其添加单个方法来扩展此类。假设我们想要添加Square()方法来找到一个数字与自身相乘的结果。以下是代码,我们可以在ExtendingStaticClass.csproj项目中找到,如果我们尝试向Math类添加扩展方法:

public static class StaticClassExtensionMethod 
{ 
  public static int Square(this Math m, inti) 
  { 
    return i * i; 
  } 
} 

当我们编译上述代码时,将会出现类似于以下截图所示的错误:

扩展静态类

错误消息显示Math静态方法不能作为Square()扩展方法的参数使用。为了克服这个限制,我们现在可以扩展类型而不是Math类。我们可以通过向int类型添加Square()方法来扩展int类型。以下是扩展int类的代码:

public static class StaticClassExtensionMethod 
{ 
  public static int Square(this inti) 
  { 
    return i * i; 
  } 
} 

正如我们所看到的,我们扩展了int类型,这样如果我们想要调用Square()方法,我们可以使用以下代码来调用它:

public class Program 
{ 
  static void Main(string[] args) 
  { 
    int i = 60; 
    Console.WriteLine(i.Square()); 
  } 
} 

然而,使用这种技术,我们还需要扩展其他类型,如floatdouble,以适应各种数据类型中的Square()功能。

修改现有类或类型中的方法实现

尽管扩展方法可以应用于现有的类和类型,但我们不能修改现有方法的实现。我们可以尝试使用以下代码,我们可以在ModifyingExistingMethod.csproj项目中找到:

namespace ModifyingExistingMethod 
{ 
  public static class ExtensionMethods 
  { 
    public static string ToString(this string str) 
    { 
      return "ToString() extension method"; 
    } 
  } 
} 

在上述代码中,我们尝试用前面代码中的ToString()扩展方法替换字符串类型已有的ToString()方法。幸运的是,该代码将能够成功编译。现在,让我们在项目的Main()方法中添加以下代码:

namespace ModifyingExistingMethod 
{ 
  public class Program 
  { 
    static void Main(string[] args) 
    { 
      stringstr = "This is string"; 
      Console.WriteLine(str.ToString()); 
    } 
  } 
} 

然而,如果我们运行该项目,ToString()扩展方法将永远不会被执行。我们将从现有的ToString()方法中获得输出。

总结

扩展方法为我们提供了一种简单的方法,可以向现有类或类型添加新方法,而无需修改原始类或类型。此外,我们无需重新编译代码,因为在创建扩展方法后,代码将立即识别它。扩展方法必须声明为静态方法,位于静态类中。与类或类型中的现有方法相比,该方法没有明显的区别,该方法也将出现在 IntelliSense 中。

扩展方法也可以在另一个程序集中声明,并且我们必须引用定义了该方法的静态类的命名空间,存储在其他程序集中。然而,我们可以使用附加命名空间技术,使用现有命名空间,这样我们就不需要再引用任何其他命名空间了。我们不仅可以扩展类和类型的功能,还可以扩展接口、集合和框架中的任何对象。

与其他 C#技术一样,扩展方法也有其优点和局限性。与函数式编程相关的一个优点是,扩展方法将使我们的代码应用方法链,以便应用函数式方法。然而,我们不能扩展静态类,也不能修改现有类或类型中的方法实现,这是扩展方法的局限性。

在下一章中,我们将深入研究 LINQ 技术,因为我们已经对委托、Lambda 表达式和扩展方法有足够的了解。我们还将讨论 LINQ 提供的编写函数式程序的便捷方式。

第五章:使用 LINQ 轻松查询任何集合

在讨论了委托、lambda 表达式和扩展方法之后,我们现在准备继续讨论 LINQ。在本章中,我们将深入探讨 LINQ,这在组成功能代码中是至关重要的。在这里,我们将讨论以下主题:

  • 介绍 LINQ 查询

  • 理解 LINQ 中的延迟执行

  • 比较 LINQ 流畅语法和 LINQ 查询表达式语法

  • 枚举 LINQ 运算符

开始使用 LINQ

语言集成查询LINQ)是 C# 3.0 中引入的.NET Framework 的语言特性,它使我们能够轻松查询实现IEnumerable<T>接口的集合中的数据,例如ArrayList<T>List<T>,XML 文档和数据库。使用 LINQ,查询集合中的任何数据变得更容易,因为我们不需要为不同的数据源学习不同的语法。例如,如果数据源是数据库,我们就不需要学习 SQL,而是使用 LINQ。同样,使用 LINQ 时,我们不必学习 XQuery,而是处理 XML 文档。幸运的是,LINQ 为我们提供了一个通用的语法,适用于所有数据源。

LINQ 中有两种基本数据单元;它们是序列,包括实现IEnumerable<T>的任何对象,和元素,包括序列中的项目。假设我们有以下名为intArrayint数组:

int[] intArray = 
{ 
  0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 
  10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 
  20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 
  30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 
  40, 41, 42, 43, 44, 45, 46, 47, 48, 49 
}; 

从之前的集合中,我们可以说intArray是一个序列,数组的内容,包括从 0 到 49 的数字,是元素。

可以使用称为查询运算符的方法来转换序列。查询运算符接受输入序列,然后生成转换后的序列。当枚举序列时,查询将转换序列。查询至少包括一个输入序列和一个运算符。让我们看一下以下代码,我们可以在SequencesAndElements.csproj项目中找到,它将从我们之前的集合intArray中查找素数:

public partial class Program 
{  
  public static void ExtractArray() 
  { 
    IEnumerable<int> extractedData = 
      System.Linq.Enumerable.Where 
      (intArray, i => i.IsPrime()); 
    Console.WriteLine 
      ("Prime Number from 0 - 49 are:"); 
    foreach (int i in extractedData) 
      Console.Write("{0} \t", i); 
    Console.WriteLine(); 
  } 
} 

IsPrime()扩展方法将有以下实现:

public static class ExtensionMethods 
{ 
  public static bool IsPrime(this int i) 
  { 
    if ((i % 2) == 0) 
    { 
      return i == 2; 
    } 
    int sqrt = (int)Math.Sqrt(i); 
    for (int t = 3; t <= sqrt; t = t + 2) 
    { 
      if (i % t == 0) 
      { 
        return false; 
      } 
    } 
    return i != 1; 
  } 
} 

从我们之前的代码中,我们可以看到我们使用Where运算符,它可以在System.Linq.Enumerable类中找到,将intArray序列转换为extractedData序列,如下面的代码片段所示:

IEnumerable<int> extractedData = 
  System.Linq.Enumerable.Where 
    (intArray, i => i.IsPrime()); 

extractedData集合现在将包含从intArray集合中获得的素数。如果我们运行项目,将在控制台上获得以下输出:

开始使用 LINQ

我们实际上可以以更简单的方式修改我们之前的代码片段,因为所有查询运算符都是扩展方法,可以直接在集合中使用。修改之前的代码片段如下:

IEnumerable<int> extractedData = 
  intArray.Where(i => i.IsPrime()); 

通过修改Where运算符的调用,我们将获得完整的实现,如下所示:

public partial class Program 
{ 
  public static void ExtractArrayWithMethodSyntax() 
  { 
    IEnumerable<int> extractedData = 
       intArray.Where(i => i.IsPrime()); 
    Console.WriteLine("Prime Number from 0 - 49 are:"); 
    foreach (int i in extractedData) 
      Console.Write("{0} \t", i); 
    Console.WriteLine(); 
  } 
} 

如果我们运行前面的ExtractArrayWithMethodSyntax()方法,将得到与ExtractArray()方法完全相同的输出。

延迟 LINQ 执行

当我们从集合中查询数据时,LINQ 实现了延迟执行的概念。这意味着查询不会在构造函数中执行,而是在枚举过程中执行。例如,我们使用Where运算符从集合中查询数据。实际上,直到我们枚举它时,查询才会被执行。我们可以使用foreach操作调用MoveNext命令来枚举查询。为了更详细地讨论延迟执行,让我们看一下以下代码,我们可以在DeferredExecution.csproj项目中找到:

public partial class Program 
{ 
  public static void DeferredExecution() 
  { 
    List memberList = new List() 
    { 
      new Member 
      { 
        ID = 1, 
        Name = "Eddie Morgan", 
        Gender = "Male", 
        MemberSince = new DateTime(2016, 2, 10) 
      }, 
      new Member 
      { 
        ID = 2, 
        Name = "Millie Duncan", 
        Gender = "Female", 
        MemberSince = new DateTime(2015, 4, 3) 
      }, 
      new Member 
      { 
        ID = 3, 
        Name = "Thiago Hubbard", 
        Gender = "Male", 
        MemberSince = new DateTime(2014, 1, 8) 
      }, 
      new Member 
      { 
        ID = 4, 
        Name = "Emilia Shaw", 
        Gender = "Female", 
        MemberSince = new DateTime(2015, 11, 15) 
      } 
    }; 
    IEnumerable<Member> memberQuery = 
      from m in memberList 
      where m.MemberSince.Year > 2014 
      orderby m.Name 
      select m; 
      memberList.Add(new Member 
      { 
        ID = 5, 
        Name = "Chloe Day", 
        Gender = "Female", 
        MemberSince = new DateTime(2016, 5, 28) 
      }); 
    foreach (Member m in memberQuery) 
    { 
      Console.WriteLine(m.Name); 
    } 
  } 
} 

如前面的DeferredExecution()方法的实现所示,我们构造了一个名为memberListList<Member>成员列表,其中包含每个加入俱乐部的成员的四个实例。Member类本身如下所示:

public class Member 
{ 
  public int ID { get; set; } 
  public string Name { get; set; } 
  public string Gender { get; set; } 
  public DateTime MemberSince { get; set; } 
} 

在构造memberList之后,我们从memberList中查询数据,其中包括 2014 年后加入的所有成员。在这里,我们可以确认只有四个成员中的三个满足要求。它们是 Eddie Morgan,Millie Duncan 和 Emilia Shaw,当然,因为我们在查询中使用了orderby m.Name短语,所以它们是按升序排列的。

在我们有了查询之后,我们向memberList添加了一个新成员,然后运行foreach操作以枚举查询。接下来会发生什么是,因为大多数查询操作符实现了延迟执行,只有在枚举过程中才会执行,所以在枚举查询后,我们将有四个成员,因为我们添加到memberList的最后一个成员满足查询要求。为了搞清楚这一点,让我们看一下在调用DeferredExecution()方法后我们在控制台上得到的以下输出:

延迟执行 LINQ

正如您所看到的,Chloe Day,作为最后一个加入俱乐部的成员,也包含在查询结果中。这就是延迟执行发挥作用的地方。

几乎所有查询操作符都提供延迟执行,但不包括以下操作符:

  • 返回标量值或单个元素,例如CountFirst

  • 转换查询结果,例如ToListToArrayToDictionaryToLookup。它们也被称为转换操作符。

Count()First()方法将立即执行,因为它们返回单个对象,所以几乎不可能提供延迟执行以及转换操作符。使用转换操作符,我们可以获得查询结果的缓存副本,并且可以避免由于延迟执行中的重新评估操作而重复该过程。现在,让我们看一下以下代码,我们可以在NonDeferredExecution.csproj项目中找到,以演示非延迟执行过程:

public partial class Program 
{ 
  private static void NonDeferred() 
  { 
    List<int> intList = new List<int> 
    { 
      0,  1,  2,  3,  4,  5,  6,  7,  8,  9 
    }; 
    IEnumerable<int> queryInt = intList.Select(i => i * 2); 
    int queryIntCount = queryInt.Count(); 
    List<int> queryIntCached = queryInt.ToList(); 
    int queryIntCachedCount = queryIntCached.Count(); 
    intList.Clear(); 
    Console.WriteLine( 
      String.Format( 
        "Enumerate queryInt.Count {0}.", queryIntCount)); 
    foreach (int i in queryInt) 
    { 
      Console.WriteLine(i); 
    } 
    Console.WriteLine(String.Format( 
      "Enumerate queryIntCached.Count {0}.",
      queryIntCachedCount)); 
    foreach (int i in queryIntCached) 
    { 
      Console.WriteLine(i); 
    } 
  } 
} 

首先,在前面的代码中,我们有一个名为intListList<int>整数列表,其中包含从09的数字。然后,我们创建一个名为queryInt的查询,以选择intList的所有成员并将它们乘以2。我们还使用Count()方法计算查询数据的总数。由于queryInt尚未执行,我们创建了一个名为queryIntCached的新查询,它使用ToList()转换操作符将queryInt转换为List<int>。我们还计算了该查询中数据的总数。现在我们有两个查询,queryIntqueryIntCached。然后我们清除intList并枚举这两个查询。以下是它们在控制台上显示的结果:

延迟执行 LINQ

正如您在前面的控制台中所看到的,对queryInt的枚举结果没有任何项目。这很明显,因为我们已经移除了所有intList项目,所以queryIntintList中找不到任何项目。然而,queryInt被计为十个项目,因为我们在清除intList之前运行了Count()方法,并且该方法在构造后立即执行。与queryInt相反,当我们枚举queryIntCached时,我们有十个项目的数据。这是因为我们调用了ToList()转换操作符,并且它也立即执行了。

注意

还有一种延迟执行的类型。当我们在Select方法之后链OrderBy方法时,就会发生这种情况。例如,Select方法只会在必须生成元素时检索一个元素,而OrderBy方法必须在返回第一个元素之前消耗整个输入序列。因此,当我们在Select方法之后链OrderBy方法时,执行将被延迟,直到我们检索第一个元素,然后OrderBy方法将要求Select提供所有元素。

在流畅语法和查询表达式语法之间进行选择

从我们之前的讨论中,到目前为止我们发现了两种类型的查询语法。让我们通过区分这两种语法来进一步讨论这个问题。

IEnumerable<int> queryInt = 
  intList.Select(i => i * 2); 
int queryIntCount = queryInt.Count(); 

前面的代码片段是流畅语法类型。我们通过调用 Enumerable 类中的扩展方法来调用 SelectCount 运算符。使用流畅语法,我们还可以链接方法,使其接近函数式编程,如下所示:

IEnumerable<int> queryInt = 
  intList 
    .Select(i => i * 2); 
    .Count(); 

我们在 LINQ 中查询数据时可以使用的另一种语法类型是查询表达式语法。我们在上一个主题中讨论延迟执行时应用了这种语法类型。查询表达式语法的代码片段如下:

IEnumerable<Member> memberQuery = 
  from m in memberList 
  where m.MemberSince.Year > 2014 
  orderby m.Name 
  select m; 

事实上,流畅语法和查询表达式语法将执行相同的操作。它们之间的区别只是语法。查询表达式语法中的每个关键字在 Enumerable 类中都有其自己的扩展方法。为了证明这一点,我们可以将前面的代码片段重构为以下流畅语法类型:

IEnumerable<Member> memberQuery = 
  memberList 
  .Where(m => m.MemberSince.Year > 2014) 
  .OrderBy(m => m.Name) 
  .Select(m => m); 

实际上,这两种类型的语法将得到完全相同的输出。然而,流畅语法比查询表达式语法更接近函数式方法。

理解 LINQ 流畅语法

基本上,LINQ 流畅语法是在 Enumerable 类中找到的扩展方法。该方法将扩展任何实现 IEnumerable<T> 接口的变量。流畅语法采用 lambda 表达式作为参数,表示将在序列枚举中执行的逻辑。正如我们之前讨论过的,流畅语法实现了方法链,以便在函数式方法中使用。在本章的开头,我们还讨论了扩展方法,可以直接使用其类的静态方法来调用查询运算符,即 Enumerable 类。然而,通过直接从其类调用方法,我们无法实现通常在函数式方法中使用的方法链。让我们看一下以下代码,我们可以在 FluentSyntax.csproj 项目中找到,以演示通过调用扩展方法而不是传统的 static 方法来使用流畅语法的优势:

public partial class Program 
{ 
  private static void UsingExtensionMethod() 
  { 
    IEnumerable<string> query = names 
      .Where(n => n.Length > 4) 
      .OrderBy(n => n[0]) 
      .Select(n => n.ToUpper()); 
    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

我们在前面的代码中使用的名称集合如下:

public partial class Program 
{ 
  static List<string> names = new List<string> 
  { 
    "Howard", "Pat", 
    "Jaclyn", "Kathryn", 
    "Ben", "Aaron", 
    "Stacey", "Levi", 
    "Patrick", "Tara", 
    "Joe", "Ruby", 
    "Bruce", "Cathy", 
    "Jimmy", "Kim", 
    "Kelsey", "Becky", 
    "Scott", "Dick" 
  }; 
} 

正如您所看到的,当我们在前面的代码中从集合中查询数据时,我们使用了三个查询运算符。它们是 WhereOrderBySelect 运算符。让我们看一下以下代码片段,以澄清这一点:

IEnumerable<string> query =  
  names 
  .Where(n => n.Length > 4) 
  .OrderBy(n => n[0]) 
  .Select(n => n.ToUpper()); 

根据前面的查询,我们将得到一个字符串集合,其中每个字符串包含超过四个字符。该集合将按其第一个字母的升序排列,并且字符串将以大写字符显示。如果我们运行以下截图中显示的 UsingExtensionMethod() 方法,我们将在控制台上看到以下内容:

理解 LINQ 流畅语法

现在,让我们重构前面的查询,使用传统的静态方法。但在我们进行之前,这里是我们在前面的查询中使用的三个方法的签名:

public static IEnumerable<TSource> Where<TSource>( 
  this IEnumerable<TSource> source, 
  Func<TSource, bool> predicate 
) 

public static IEnumerable<TSource> OrderBy<TSource, TKey>( 
  this IEnumerable<TSource> source, 
  Func<TSource, TKey> keySelector 
) 

public static IEnumerable<TResult> Select<TSource, TResult>( 
  this IEnumerable<TSource> source, 
  Func<TSource, TResult> selector 
) 

正如您所看到的,所有三个方法都以 IEnumerable<TSource> 作为第一个参数,并且还返回 IEnumerable<TResult>。我们可以利用这种相似性,使第一个方法的返回值可以作为第二个方法的参数,第二个方法的返回值可以作为第三个方法的参数,依此类推。

Where() 方法中,我们使用第二个参数 predicate 来基于它过滤序列。它是一个 Func<TSource, bool> 委托,所以我们可以在这里使用 lambda 表达式。在 OrderBy() 方法的第二个参数中也可以找到 Func<TSource, TKey> 委托,它用作对序列元素进行升序排序的键。它可以由匿名方法提供。最后是 Select() 方法,在其中我们使用它的第二个参数 selector,将序列中的每个元素投影为新形式。匿名方法也可以作为参数使用。

根据我们在之前的 UsingExtensionMethod() 方法中使用的方法的签名,我们可以重构查询如下:

IEnumerable<string> query = Enumerable.Select(
  Enumerable.OrderBy(Enumerable.Where(names, n => n.Length > 4),
  n => n[0]), n => n.ToUpper());

以下是完整的 UsingStaticMethod() 方法,这是当我们使用传统的静态方法而不是扩展方法时的重构代码:

public partial class Program 
{ 
  private static void UsingStaticMethod() 
  { 
    IEnumerable<string> query = 
     Enumerable.Select( 
      Enumerable.OrderBy( 
       Enumerable.Where( 
        names, n => n.Length > 4),  
         n => n[0]), n => n.ToUpper()); 
    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

通过运行 UsingStaticMethod() 方法,我们将在控制台上获得与 UsingExtensionMethod() 方法相比完全相同的输出。

理解 LINQ 查询表达式语法

LINQ 查询表达式语法是一种简写语法,我们可以使用它执行 LINQ 查询。在查询表达式语法中,.NET Framework 为每个查询操作符提供关键字,但并非所有操作符。通过使用查询语法,我们可以像在数据库中使用 SQL 查询数据一样调用操作符。当我们使用查询表达式语法时,我们的代码将更易读,并且在编写时需要更少的代码。

在流畅语法讨论中,我们创建了一个查询,从包含超过四个字符的字符串列表中提取字符串,按其第一个字母的升序排序,并转换为大写字符。我们可以使用查询表达式语法来执行此操作,如下面的代码所示,我们可以在 QueryExpressionSyntax.csproj 项目中找到:

public partial class Program 
{ 
  private static void InvokingQueryExpression() 
  { 
    IEnumerable<string> query = 
      from n in names 
      where n.Length > 4 
      orderby n[0] 
      select n.ToUpper(); 
    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

正如你所看到的,我们已经重构了之前的代码,它使用了查询表达式语法的流畅语法。事实上,如果我们运行 InvokingQueryExpression() 方法,与 UsingExtensionMethod() 方法相比,将显示完全相同的输出。

不幸的是,有几个 LINQ 操作符在查询表达式语法中没有关键字,例如 distinct 操作符,因为它不接受 lambda 表达式。在这种情况下,如果我们仍然想使用它,我们必须至少部分使用流畅语法。以下是在查询表达式语法中具有关键字的操作符:

  • Where

  • Select

  • SelectMany

  • OrderBy

  • ThenBy

  • OrderByDescending

  • ThenByDescending

  • GroupBy

  • Join

  • GroupJoin

提示

实际上,编译器在编译过程中将查询表达式语法转换为流畅语法。虽然查询表达式语法有时更容易阅读,但我们不能使用它执行所有操作;相反,我们必须使用流畅语法,例如我们在 延迟 LINQ 执行 主题中讨论的 count 操作符。我们在查询表达式语法中编写的内容也可以用流畅语法编写。因此,在使用 LINQ 编码时,特别是在功能方法中,流畅语法是最佳方法。

枚举标准查询操作符

System.Linq 命名空间中包含的 Enumerable 类中有 50 多个查询操作符。它们也被称为标准查询操作符。根据操作符的功能,我们可以将它们分为几个操作。在这里,我们将讨论 .NET Framework 提供的所有 LINQ 查询操作符。

过滤

过滤是一个操作,它将评估数据的元素,以便只选择满足条件的元素。有六个过滤操作符;它们是 WhereTakeSkipTakeWhileSkipWhileDistinct 。正如我们所知,我们已经在之前的示例代码中讨论了 Where 操作符,无论是在流畅语法还是查询表达式语法中,并且知道它将返回满足谓词给定条件的元素子集。由于我们对 Where 操作符已经足够清楚,我们可以跳过它,继续使用剩下的五个过滤操作符。

Take 操作符返回前 n 个元素并丢弃其余的元素。相反,Skip 操作符忽略前 n 个元素并返回其余的元素。让我们来看一下 FilteringOperation.csproj 项目中的以下代码:

public partial class Program 
{ 
  public static void SimplyTakeAndSkipOperator() 
  { 
    IEnumerable<int> queryTake = 
       intList.Take(10); 
    Console.WriteLine("Take operator"); 
    foreach (int i in queryTake) 
    { 
      Console.Write(String.Format("{0}\t", i)); 
    } 
    Console.WriteLine(); 
    IEnumerable<int> querySkip = intList.Skip(10); 
    Console.WriteLine("Skip operator"); 
    foreach (int i in querySkip) 
    { 
      Console.Write(String.Format("{0}\t", i)); 
    } 
    Console.WriteLine(); 
  } 
} 

在上面的代码中,我们有两个查询,queryTake应用了Take操作符,querySkip应用了Skip操作符。它们都消耗intList,实际上是一个包含以下数据的整数列表:

public partial class Program 
{ 
static List<int> intList = new List<int> 
  { 
    0,  1,  2,  3,  4, 
    5,  6,  7,  8,  9, 
    10, 11, 12, 13, 14, 
    15, 16, 17, 18, 19 
  }; 
} 

如果我们运行前面的SimplyTakeAndSkipOperator()方法,将会得到以下输出:

Filtering

前面的TakeSkip操作符示例是简单的代码,因为它处理的是一个只包含二十个元素的集合。事实上,当我们处理大量集合或者数据库时,TakeSkip操作符非常有用,可以方便用户访问数据。假设我们有一个包含一百万个整数的集合,我们要找到其中一个元素,它乘以二和七。如果不使用TakeSkip操作符,将会得到大量结果,如果在控制台上显示,会使控制台显示混乱。让我们看一下下面的代码来证明这一点:

public partial class Program 
{ 
  public static void NoTakeSkipOperator() 
  { 
    IEnumerable<int> intCollection = 
       Enumerable.Range(1, 1000000); 
    IEnumerable<int> hugeQuery = 
        intCollection 
      .Where(h => h % 2 == 0 && h % 7 == 0); 
    foreach (int x in hugeQuery) 
    { 
      Console.WriteLine(x); 
    } 
  } 
} 

正如你在这里所看到的,我们有一个包含大量数据的hugeQuery。如果我们运行该方法,需要大约十秒钟来完成所有元素的迭代。如果我们想要获取hugeQuery实际包含的元素,我们也可以添加Count操作符,即71428个元素。

现在,我们可以通过在foreach循环周围添加TakeSkip操作符来修改代码,如下所示:

public partial class Program 
{ 
  public static void TakeAndSkipOperator() 
  { 
    IEnumerable<int> intCollection = 
       Enumerable.Range(1, 1000000); 
    IEnumerable<int> hugeQuery = 
       intCollection 
         .Where(h => h % 2 == 0 && h % 7 == 0); 
    int pageSize = 10; 
    for (int i = 0; i < hugeQuery.Count()/ pageSize; i++) 
    { 
      IEnumerable<int> paginationQuery =hugeQuery 
        .Skip(i * pageSize) 
        .Take(pageSize); 
      foreach (int x in paginationQuery) 
      { 
        Console.WriteLine(x); 
      } 
      Console.WriteLine( 
         "Press Enter to continue, " + 
           "other key will stop process!"); 
      if (Console.ReadKey().Key != ConsoleKey.Enter) 
        break; 
    } 
  } 
} 

在前面的TakeAndSkipOperator()方法中,我们在高亮显示的行中添加了一些代码。现在,尽管我们有很多数据,但当我们运行该方法时,输出将会很方便地显示如下:

Filtering

如你所见,整个结果并没有全部显示在控制台上,每次只显示十个整数。用户可以按Enter键,如果他们想要继续阅读其余的数据。这通常被称为分页。TakeSkip操作符已经很好地实现了这一点。

除了讨论TakeSkip操作符,我们还将讨论过滤操作符中的TakeWhileSkipWhile操作符。在TakeWhile操作符中,输入集合将被枚举,每个元素将被发送到查询,直到谓词为false。相反,在SkipWhile中,当输入集合被枚举时,当谓词为true时,元素将被发送到查询。现在,让我们看一下下面的代码来演示TakeWhileSkipWhile操作符:

public partial class Program 
{ 
  public static void TakeWhileAndSkipWhileOperators() 
  { 
    int[] intArray = { 10, 4, 27, 53, 2, 96, 48 }; 
    IEnumerable<int> queryTakeWhile = 
       intArray.TakeWhile(n => n < 50); 
    Console.WriteLine("TakeWhile operator"); 
    foreach (int i in queryTakeWhile) 
    { 
      Console.Write(String.Format("{0}\t", i)); 
    } 
    Console.WriteLine(); 
    IEnumerable<int> querySkipWhile = 
       intArray.SkipWhile(n => n < 50); 
    Console.WriteLine("SkipWhile operator"); 
    foreach (int i in querySkipWhile) 
    { 
      Console.Write(String.Format("{0}\t", i)); 
    } 
    Console.WriteLine(); 
  } 
} 

当我们运行前面的方法时,将在控制台上得到以下输出:

Filtering

由于在谓词中有n < 50,在TakeWhile中,枚举将会发出元素,直到达到53,而在SkipWhile中,当枚举到达53时,元素开始被发出。

在这个过滤操作中,我们还有Distinct操作符。Distinct操作符将返回没有任何重复元素的输入序列。假设我们有以下代码:

public partial class Program 
{ 
  public static void DistinctOperator() 
  { 
    string words = "TheQuickBrownFoxJumpsOverTheLazyDog"; 
       IEnumerable <char> queryDistinct = words.Distinct(); 
    string distinctWords = ""; 
    foreach (char c in queryDistinct) 
    { 
      distinctWords += c.ToString(); 
    } 
    Console.WriteLine(distinctWords); 
  } 
} 

在上面的代码中,我们有一个字符串,我们打算删除该字符串中的所有重复字母。我们使用Distinct操作符来获取查询,然后枚举它。结果将如下所示:

Filtering

如你所见,由于使用了Distinct操作符,一些字母已经消失了。在这种情况下,没有重复的字母出现。

投影

投影是将对象转换为新形式的操作。有两个投影操作符,它们是SelectSelectMany。使用Select操作符,我们可以根据给定的 lambda 表达式转换每个输入元素,而使用SelectMany操作符,我们可以转换每个输入元素,然后通过连接它们来将结果序列扁平化为一个序列。

当我们讨论延迟执行 LINQ 时,我们应用了Select操作符。以下是使用Select操作符的代码片段,我们从延迟执行 LINQ 主题的示例中提取出来的:

IEnumerable<Member> memberQuery = 
  from m in memberList 
  where m.MemberSince.Year > 2014 
  orderby m.Name 
  select m; 

正如你所看到的,我们使用了Select操作符,这里是Select关键字,因为我们使用了查询表达式语法,来选择所有由Where关键字过滤的结果元素。正如我们从Select操作符中知道的,对象可以被转换成另一种形式,我们可以使用以下代码将以Member类对象类型的元素转换为以RecentMember类对象类型的元素:

IEnumerable<RecentMember> memberQuery = 
  from m in memberList 
  where m.MemberSince.Year > 2014 
  orderby m.Name 
  select new RecentMember{ 
    FirstName = m.Name.GetFirstName(), 
    LastName = m.Name.GetLastName(), 
    Gender = m.Gender, 
    MemberSince = m.MemberSince, 
    Status = "Valid" 
}; 

使用前面的代码,我们假设有一个名为RecentMember的类,如下所示:

public class RecentMember 
{ 
  public string FirstName { get; set; } 
  public string LastName { get; set; } 
  public string Gender { get; set; } 
  public DateTime MemberSince { get; set; } 
  public string Status { get; set; } 
} 

从前面的代码片段中,我们可以看到我们使用Select操作符来转换每个输入元素。我们可以将代码片段插入到以下完整的源代码中:

public partial class Program 
{ 
  public static void SelectOperator() 
  { 
    List<Member> memberList = new List<Member>() 
    { 
      new Member 
      { 
        ID = 1, 
        Name = "Eddie Morgan", 
        Gender = "Male", 
        MemberSince = new DateTime(2016, 2, 10) 
      }, 
      new Member 
      { 
        ID = 2, 
        Name = "Millie Duncan", 
        Gender = "Female", 
        MemberSince = new DateTime(2015, 4, 3) 
      }, 
      new Member 
      { 
        ID = 3, 
        Name = "Thiago Hubbard", 
        Gender = "Male", 
        MemberSince = new DateTime(2014, 1, 8) 
      }, 
      new Member 
      { 
        ID = 4, 
        Name = "Emilia Shaw", 
        Gender = "Female", 
        MemberSince = new DateTime(2015, 11, 15) 
      } 
    }; 
    IEnumerable<RecentMember> memberQuery = 
      from m in memberList 
      where m.MemberSince.Year > 2014 
      orderby m.Name 
      select new RecentMember{ 
        FirstName = m.Name.GetFirstName(), 
        LastName = m.Name.GetLastName(), 
        Gender = m.Gender, 
        MemberSince = m.MemberSince, 
        Status = "Valid" 
      }; 
    foreach (RecentMember rm in memberQuery) 
    { 
      Console.WriteLine( 
         "First Name  : " + rm.FirstName); 
      Console.WriteLine( 
         "Last Name   : " + rm.LastName); 
      Console.WriteLine( 
         "Gender      : " + rm.Gender); 
      Console.WriteLine 
         ("Member Since: " + rm.MemberSince.ToString("dd/MM/yyyy")); 
      Console.WriteLine( 
         "Status      : " + rm.Status); 
      Console.WriteLine(); 
    } 
  } 
} 

由于我们已经使用foreach迭代器枚举了查询,并使用Console.WriteLine()方法将元素写入控制台,在运行前面的SelectOperator()方法后,我们将在控制台上得到以下输出:

Projection

从前面的控制台截图中,我们可以看到我们成功地将Member类型的输入元素转换为RecentMember类型的输出元素。我们也可以使用流畅语法来产生完全相同的结果,如下面的代码片段所示:

IEnumerable<RecentMember> memberQuery = 
   memberList 
  .Where(m => m.MemberSince.Year > 2014) 
  .OrderBy(m => m.Name) 
  .Select(m => new RecentMember 
{ 
  FirstName = m.Name.GetFirstName(), 
  LastName = m.Name.GetLastName(), 
  Gender = m.Gender, 
  MemberSince = m.MemberSince, 
  Status = "Valid" 
}); 

现在,让我们继续讨论SelectMany操作符。使用这个操作符,我们可以选择多个序列,然后将结果展平成一个序列。假设我们有两个集合,我们要选择它们的所有元素;我们可以使用以下代码实现这个目标:

public partial class Program 
{ 
  public static void SelectManyOperator() 
  { 
    List<string> numberTypes = new List<string>() 
    { 
      "Multiplied by 2", 
      "Multiplied by 3" 
    }; 
    List<int> numbers = new List<int>() 
    { 
      6, 12, 18, 24 
    }; 
    IEnumerable<NumberType> query = 
       numbers.SelectMany( 
          num => numberTypes,  
          (n, t) =>new NumberType 
          { 
            TheNumber = n, 
            TheType = t 
          }); 
    foreach (NumberType nt in query) 
    { 
      Console.WriteLine(String.Format( 
         "Number: {0,2} - Types: {1}", 
           nt.TheNumber, 
             nt.TheType)); 
    } 
  } 
} 

正如你所看到的,我们有两个名为numberTypesnumbers的集合,想要从它们的元素中取出任何可能的组合。结果是以新形式NumberType的形式,定义如下:

public class NumberType 
{ 
  public int TheNumber { get; set; } 
  public string TheType { get; set; } 
} 

如果我们运行前面的SelectManyOperator()方法,将在控制台上显示以下输出:

Projection

在这段代码中,我们实际上迭代了两个集合,构造了两个集合的组合,因为SelectMany操作符的实现如下:

public static IEnumerable<TResult> SelectMany<TSource, TResult>( 
  this IEnumerable<TSource> source, 
  Func<TSource, IEnumerable<TResult>> selector) 
{ 
  foreach (TSource element in source) 
  foreach (TResult subElement in selector (element)) 
  yield return subElement; 
} 

我们还可以应用查询表达式语法来替换前面的流畅语法,使用以下代码片段:

IEnumerable<NumberType> query = 
  from n in numbers 
  from t in numberTypes 
  select new NumberType 
{ 
  TheNumber = n, 
  TheType = t 
}; 

使用查询表达式语法的输出将与流畅语法完全相同。

注意

from关键字在查询表达式语法中有两个不同的含义。当我们在语法的开头使用关键字时,它将引入原始范围变量和输入序列。当我们在任何位置使用关键字时,它将被转换为SelectMany操作符。

连接

连接是一种将不具有直接对象模型关系的不同源序列融合成单个输出序列的操作。然而,每个源中的元素都必须共享一个可以进行相等比较的值。在 LINQ 中有两个连接操作符;它们是JoinGroupJoin

Join操作符使用查找技术来匹配两个序列的元素,然后返回一个扁平的结果集。为了进一步解释这一点,让我们看一下在Joining.csproj项目中可以找到的以下代码:

public partial class Program 
{ 
  public static void JoinOperator() 
  { 
    Course hci = new Course{ 
      Title = "Human Computer Interaction", 
      CreditHours = 3}; 
    Course iis = new Course{ 
      Title = "Information in Society", 
      CreditHours = 2}; 
    Course modr = new Course{ 
      Title = "Management of Digital Records", 
      CreditHours = 3}; 
    Course micd = new Course{ 
      Title = "Moving Image Collection Development", 
      CreditHours = 2}; 
    Student carol = new Student{ 
      Name = "Carol Burks", 
      CourseTaken = modr}; 
    Student river = new Student{ 
      Name = "River Downs", 
      CourseTaken = micd}; 
    Student raylee = new Student{ 
      Name = "Raylee Price", 
      CourseTaken = hci}; 
    Student jordan = new Student{ 
      Name = "Jordan Owen", 
      CourseTaken = modr}; 
    Student denny = new Student{ 
      Name = "Denny Edwards", 
      CourseTaken = hci}; 
    Student hayden = new Student{ 
      Name = "Hayden Winters", 
      CourseTaken = iis}; 
    List<Course> courses = new List<Course>{
      hci, iis, modr, micd};
    List<Student> students = new List<Student>{
      carol, river, raylee, jordan, denny, hayden}; 
    var query = courses.Join( 
      students, 
      course => course, 
      student => student.CourseTaken, 
      (course, student) => 
        new {StudentName = student.Name, 
          CourseTaken = course.Title }); 
    foreach (var item in query) 
    { 
      Console.WriteLine( 
        "{0} - {1}", 
        item.StudentName, 
        item.CourseTaken); 
    } 
  } 
} 

前面的代码使用了以下实现的StudentCourse类:

public class Student 
{ 
  public string Name { get; set; } 
  public Course CourseTaken { get; set; } 
} 
public class Course 
{ 
  public string Title { get; set; } 
  public int CreditHours { get; set; } 
} 

如果我们运行前面的JoinOperator()方法,我们将在控制台上得到以下输出:

Joining

从前面的代码中,我们可以看到我们有两个序列,它们是coursesstudents。我们可以使用Join操作符连接这两个序列,然后创建一个匿名类型作为结果。我们也可以使用查询表达式语法来连接这两个序列。以下是我们必须在之前的查询创建中替换的代码片段:

var query = 
from c in courses 
join s in students on c.Title equals s.CourseTaken.Title 
select new { 
  StudentName = s.Name, 
  CourseTaken = c.Title }; 

如果我们再次运行JoinOperator()方法,我们将在控制台上得到完全相同的输出。

GroupJoin操作符使用与Join操作符相同的技术,但返回一个分层结果集。让我们看一下下面解释GroupJoin操作符的代码:

public partial class Program 
{ 
  public static void GroupJoinOperator() 
  { 
    Course hci = new Course{ 
      Title = "Human Computer Interaction", 
      CreditHours = 3}; 

    Course iis = new Course{ 
      Title = "Information in Society", 
      CreditHours = 2}; 

    Course modr = new Course{ 
      Title = "Management of Digital Records", 
      CreditHours = 3}; 

    Course micd = new Course{ 
      Title = "Moving Image Collection Development", 
      CreditHours = 2}; 

    Student carol = new Student{ 
      Name = "Carol Burks", 
      CourseTaken = modr}; 

    Student river = new Student{ 
      Name = "River Downs", 
      CourseTaken = micd}; 

    Student raylee = new Student{ 
      Name = "Raylee Price", 
      CourseTaken = hci}; 

    Student jordan = new Student{ 
      Name = "Jordan Owen", 
      CourseTaken = modr}; 

    Student denny = new Student{ 
      Name = "Denny Edwards", 
      CourseTaken = hci}; 

    Student hayden = new Student{ 
      Name = "Hayden Winters", 
      CourseTaken = iis}; 

    List<Course> courses = new List<Course>{ 
      hci, iis, modr, micd}; 

    List<Student> students = new List<Student>{ 
      carol, river, raylee, jordan, denny, hayden}; 

    var query = courses.GroupJoin( 
      students, 
      course => course, 
      student => student.CourseTaken, 
      (course, studentCollection) => 
      new{ 
        CourseTaken = course.Title, 
        Students =  
        studentCollection 
        .Select(student => student.Name) 
      }); 

      foreach (var item in query) 
      { 
        Console.WriteLine("{0}:", item.CourseTaken); 
        foreach (string stdnt in item.Students) 
        { 
          Console.WriteLine("  {0}", stdnt); 
        } 
      } 
    } 
} 

前面的代码与我们之前讨论过的 Join 操作符代码类似。不同之处在于我们创建查询的方式。在GroupJoin操作符中,我们将两个序列与一个键合并为另一个序列。让我们调用前面的GroupJoinOperator()方法,我们将在控制台上得到以下输出:

Joining

如您在输出中所见,我们对所有选修特定课程的学生进行分组,然后枚举查询以获得结果。

排序

排序是一种操作,它将使用默认比较器对输入序列的返回序列进行排序。例如,如果我们有一个字符串类型的序列,那么默认比较器将按字母顺序从 A 到 Z 进行排序。让我们看一下以下代码,可以在Ordering.csproj项目中找到:

public partial class Program 
{ 
  public static void OrderByOperator() 
  { 
    IEnumerable<string> query = 
      nameList.OrderBy(n => n); 

    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

对于我们必须提供给查询的序列,代码如下:

public partial class Program 
{ 
  static List<string> nameList = new List<string>() 
  { 
    "Blair", "Lane", "Jessie", "Aiden", 
    "Reggie", "Tanner", "Maddox", "Kerry" 
  }; 
} 

如果我们运行前面的OrderByOperator()方法,将在控制台上得到以下输出:

Ordering

如您所见,我们使用默认比较器执行了排序操作,因此序列按字母顺序排序。我们还可以使用查询表达式语法来替换以下代码片段:

IEnumerable<string> query = 
  nameList.OrderBy(n => n); 

我们对序列的查询表达式语法如下代码片段所示:

IEnumerable<string> query = 
  from n in nameList 
  orderby n 
  select n; 

我们可以创建自己的比较器作为键选择器,通过每个元素的最后一个字符对序列进行排序;以下是我们可以使用IComparer<T>接口来实现这一点的代码。假设我们要对先前的序列进行排序:

public partial class Program 
{ 
  public static void OrderByOperatorWithComparer() 
  { 
    IEnumerable<string> query = 
      nameList.OrderBy( 
       n => n,  
      new LastCharacterComparer()); 
    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

我们还创建了一个新类LastCharacterComparer,它继承了IComparer<string>接口,如下所示:

public class LastCharacterComparer : IComparer<string> 
{ 
  public int Compare(string x, string y) 
  { 
    return string.Compare( 
     x[x.Length - 1].ToString(), 
      y[y.Length - 1].ToString()); 
  } 
} 

当我们运行前面的OrderByOperatorWithComparer()方法时,将在控制台上得到以下输出:

Ordering

如您所见,我们现在有一个有序的序列,但排序键是每个元素的最后一个字符。这是通过我们自定义的比较器实现的。不幸的是,自定义比较器只能在流畅语法中使用。换句话说,我们不能在查询表达式方法中使用它。

当我们对序列进行排序时,可以有多个比较器作为条件。在调用OrderBy方法后,我们可以使用ThenBy扩展方法来进行第二个条件的排序。让我们看一下以下代码来演示这一点:

public partial class Program 
{ 
  public static void OrderByThenByOperator() 
  { 
    IEnumerable<string> query = nameList 
      .OrderBy(n => n.Length) 
      .ThenBy(n => n); 
    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

从前面的代码中,我们按每个元素的长度对序列进行排序,然后按字母顺序对结果进行排序。如果我们调用OrderByThenByOperator()方法,将得到以下输出:

Ordering

当我们需要使用两个条件对序列进行排序时,也可以使用查询表达式语法,如下面的代码片段所示:

IEnumerable<string> query = 
  from n in nameList 
  orderby n.Length, n 
  select n; 

如果我们在用查询表达式语法替换查询操作后再次运行OrderByThenByOperator()方法,我们将得到与使用流畅语法时相同的输出。然而,在查询表达式语法中没有ThenBy关键字。我们只需要用逗号分隔条件。

我们也可以在使用ThenBy方法时使用自定义比较器。让我们看一下以下代码来尝试这个:

public partial class Program 
{ 
  public static void OrderByThenByOperatorWithComparer() 
  { 
    IEnumerable<string> query = nameList 
      .OrderBy(n => n.Length) 
      .ThenBy(n => n, new LastCharacterComparer()); 
    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

在这段代码中,我们使用了与OrderByOperatorWithComparer()方法中相同的LastCharacterComparer类。如果我们调用OrderByThenByOperatorWithComparer()方法,将在控制台上得到以下输出:

Ordering

除了升序排序,我们还有降序排序。在流畅语法中,我们可以简单地使用OrderByDescending()ThenByDescending()方法。在代码中的使用方式与按升序排序的代码完全相同。然而,在查询表达式语法中,我们有 descending 关键字来实现这个目标。我们在orderby关键字中定义条件后,使用这个关键字,如下面的代码所示:

public partial class Program 
{ 
  public static void OrderByDescendingOperator() 
  { 
    IEnumerable<string> query = 
      from n in nameList 
      orderby n descending 
      select n; 
    foreach (string s in query) 
    { 
      Console.WriteLine(s); 
    } 
  } 
} 

如您所见,代码中也有一个 descending 关键字。实际上,我们可以用 ascending 关键字替换 descending 关键字,以按升序对序列进行排序。然而,在 LINQ 中,升序排序是默认排序,因此可以省略 ascending 关键字。如果运行代码并调用OrderByDescendingOperator()方法,将得到以下输出:

排序

分组

分组是一种操作,将生成一系列IGrouping<TKey, TElement>对象,这些对象根据TKey键值进行分组。例如,我们将按照它们文件名的第一个字母,将一个目录中的路径地址文件序列进行分组。以下代码可以在Grouping.csproj项目文件中找到,并将搜索G:\packages中的所有文件,这是 Visual Studio 2015 Community Edition 的安装文件。您可以根据计算机上的驱动器号和文件夹名称调整驱动器号和文件夹名称。

public partial class Program 
{ 
  public static void GroupingByFileNameExtension() 
  { 
    IEnumerable<string> fileList =  
      Directory.EnumerateFiles( 
        @"G:\packages", "*.*",  
        SearchOption.AllDirectories); 
    IEnumerable<IGrouping<string, string>> query = 
      fileList.GroupBy(f => 
      Path.GetFileName(f)[0].ToString()); 
    foreach (IGrouping<string, string> g in query) 
    { 
      Console.WriteLine(); 
      Console.WriteLine( 
         "File start with the letter: " +  
           g.Key); 
      foreach (string filename in g) 
      Console.WriteLine( 
         "..." + Path.GetFileName(filename)); 
     } 
  } 
} 

前面的代码将在G:\packages文件夹中(包括所有子目录)找到所有文件,然后根据它们文件名的第一个字母进行分组。如您所见,当我们使用foreach循环枚举查询时,我们有g.Key,它是用于对字符串列表进行分组的键选择器。如果运行GroupingByFileNameExtension()方法,将在控制台上得到以下输出:

分组

GroupBy扩展方法还有一个子句,可以在查询表达式语法中使用。我们可以使用的子句是groupby。以下代码片段可以替换我们先前代码中的查询:

IEnumerable<IGrouping<string, string>> query = 
  from f in fileList 
  group f by Path.GetFileName(f)[0].ToString(); 

我们仍然会得到与流畅语法输出相同的输出,尽管我们使用查询表达式语法替换了查询。如您所见,LINQ 中的分组操作只对序列进行分组,而不进行排序。我们可以使用 LINQ 提供的OrderBy操作符对结果进行排序。

在前面的查询表达式语法中,我们看到由于 group 子句也会结束查询,因此我们不需要再次使用 select 子句。然而,当使用 group 子句并添加查询继续子句时,我们仍然需要 select 子句。现在让我们看一下以下代码,它应用了查询继续子句来对序列进行排序:

public partial class Program 
{ 
  public static void GroupingByInto() 
  { 
    IEnumerable<string> fileList = 
      Directory.EnumerateFiles( 
        @"G:\packages", "*.*", 
        SearchOption.AllDirectories); 
    IEnumerable<IGrouping<string, string>> query = 
      from f in fileList 
      group f  
        by Path.GetFileName(f)[0].ToString() 
        into g 
      orderby g.Key 
      select g; 
    foreach (IGrouping<string, string> g in query) 
    { 
      Console.WriteLine( 
        "File start with the letter: " + g.Key); 
      //foreach (string filename in g) 
      Console.WriteLine(           "..." + Path.GetFileName(filename)); 
    } 
  } 
} 

如前面的代码所示,我们通过添加查询继续子句和orderby操作符来修改查询,以对序列结果进行排序。我们使用的查询继续子句是into关键字。使用into关键字,我们存储分组结果,然后再次操作分组。如果运行前面的代码,将在控制台上得到以下输出:

分组

我们故意删除了每个组的元素,因为我们现在要检查的是键本身。现在我们可以看到键是按升序排列的。这是因为我们首先存储了分组的结果,然后按升序对键进行排序。

集合操作

集合操作是一种基于相同或不同集合中等价元素的存在或不存在而返回结果集的操作。LINQ 提供了四种集合操作符,它们是ConcatUnionIntersectExcept。对于这四种集合操作符,都没有查询表达式关键字。

让我们从ConcatUnion开始。使用Concat运算符,我们将得到第一个序列的所有元素,然后是第二个序列的所有元素。Union使用Concat运算符执行此操作,但对于重复的元素只返回一个元素。以下代码在SetOperation.csproj项目中可以找到,演示了ConcatUnion之间的区别:

public partial class Program 
{ 
  public static void ConcatUnionOperator() 
  { 
    IEnumerable<int> concat = sequence1.Concat(sequence2); 
    IEnumerable<int> union = sequence1.Union(sequence2); 
    Console.WriteLine("Concat"); 
    foreach (int i in concat) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 
    Console.WriteLine("Union"); 
    foreach (int i in union) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 
  } 
} 

我们有两个序列如下:

public partial class Program 
{ 
  static int[] sequence1 = { 1, 2, 3, 4, 5, 6 }; 
  static int[] sequence2 = { 3, 4, 5, 6, 7, 8 }; 
} 

我们之前的代码尝试使用ConcatUnion运算符。根据我们的讨论,如果我们运行ConcatUnionOperator()方法,将得到以下输出:

集合操作

IntersectExcept也是集合运算符。Intersect返回两个输入序列中都存在的元素。Except返回第一个输入序列中不在第二个序列中的元素。以下代码解释了IntersectExcept之间的区别:

public partial class Program 
{ 
  public static void IntersectExceptOperator() 
  { 
    IEnumerable<int> intersect = sequence1.Intersect(sequence2); 
    IEnumerable<int> except1 = sequence1.Except(sequence2); 
    IEnumerable<int> except2 = sequence2.Except(sequence1); 
    Console.WriteLine("Intersect of Sequence"); 
    foreach (int i in intersect) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 
    Console.WriteLine("Except1"); 
    foreach (int i in except1) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 
    Console.WriteLine("Except2"); 
    foreach (int i in except2) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 
  } 
} 

如果我们调用IntersectExceptOperator()方法,将在控制台屏幕上显示以下输出:

集合操作

我们将之前在ConcatUnionOperator()方法中使用的两个序列作为输入。从上述控制台截图中可以看出,在Intersect操作中,只返回重复的元素。在Except操作中,只返回唯一的元素。

转换方法

转换方法的主要作用是将一种类型的集合转换为其他类型的集合。在这里,我们将讨论 LINQ 提供的转换方法;它们是OfTypeCastToArrayToListToDictionaryToLookup

OfTypeCast方法具有类似的功能;它们将IEnumerable转换为IEnumerable<T>。不同之处在于,OfType将丢弃错误类型的元素(如果有的话),而Cast将在存在错误类型元素时抛出异常。让我们来看一下以下代码,在ConversionMethods.csproj项目中可以找到:

public partial class Program 
{ 
  public static void OfTypeCastSimple() 
  { 
    ArrayList arrayList = new ArrayList(); 
    arrayList.AddRange(new int[] { 1, 2, 3, 4, 5 }); 

    IEnumerable<int> sequenceOfType = arrayList.OfType<int>(); 
    IEnumerable<int> sequenceCast = arrayList.Cast<int>(); 

    Console.WriteLine( 
      "OfType of arrayList"); 
    foreach (int i in sequenceOfType) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 

    Console.WriteLine( 
      "Cast of arrayList"); 
    foreach (int i in sequenceCast) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 
  } 
} 

上述代码是使用OfTypeCast转换的一个简单示例。我们有一个只包含int元素的数组。实际上,它们可以很容易地转换。如果我们运行OfTypeCastSimple()方法,将得到以下输出:

转换方法

注意

在.NET Core 中,ArrayList的定义位于System.Collections.NonGeneric.dll中。因此,我们必须在www.nuget.org/packages/System.Collections.NonGeneric/上下载 NuGet 包。

现在让我们向上述代码添加几行代码。代码现在将如下所示:

public partial class Program 
{ 
  public static void OfTypeCastComplex() 
  { 
    ArrayList arrayList = new ArrayList(); 
    arrayList.AddRange( 
      new int[] { 1, 2, 3, 4, 5 }); 

    arrayList.AddRange( 
       new string[] {"Cooper", "Shawna", "Max"}); 
    IEnumerable<int> sequenceOfType = 
       arrayList.OfType<int>(); 
    IEnumerable<int> sequenceCast = 
       arrayList.Cast<int>(); 

    Console.WriteLine( 
      "OfType of arrayList"); 
    foreach (int i in sequenceOfType) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 

    Console.WriteLine( 
       "Cast of arrayList"); 
    foreach (int i in sequenceCast) 
    { 
      Console.Write(".." + i); 
    } 
    Console.WriteLine(); 
    Console.WriteLine(); 
  } 
} 

从上述代码中,我们可以看到,我们将方法名称更改为OfTypeCastComplex,并插入了将字符串元素添加到arrayList的代码。如果我们运行该方法,OfType转换将成功运行并仅返回int元素,而Cast转换将抛出异常,因为输入序列中有一些字符串元素。

其他的转换方法包括ToArray()ToList()。它们之间的区别在于,ToArray()将序列转换为数组,而ToList()将转换为通用列表。此外,还有ToDictionary()ToLookup()方法可用于转换。ToDictionary()将根据指定的键选择器函数从序列中创建Dictionary<TKey, TValue>,而ToLookup()将根据指定的键选择器和元素选择器函数从序列中创建Lookup<TKey, TElement>

元素操作

元素操作是根据它们的索引或使用谓词从序列中提取单个元素的操作。LINQ 中存在几个元素运算符;它们是FirstFirstOrDefaultLastSingleSingleOrDefaultElementAtDefaultIfEmpty。让我们使用示例代码来了解所有这些元素运算符的功能。

以下是演示元素运算符的代码,我们可以在ElementOperation.csproj项目中找到:

public partial class Program 
{ 
  public static void FirstLastOperator() 
  { 
    Console.WriteLine( 
      "First Operator: {0}", 
      numbers.First()); 
    Console.WriteLine( 
      "First Operator with predicate: {0}", 
      numbers.First(n => n % 3 == 0)); 
    Console.WriteLine( 
      "Last Operator: {0}", 
      numbers.Last()); 
    Console.WriteLine( 
      "Last Operator with predicate: {0}", 
      numbers.Last(n => n % 4 == 0)); 
  } 
} 

前面的代码演示了FirstLast运算符的使用。数字数组如下:

public partial class Program 
{ 
  public static int[] numbers = { 
    1, 2, 3, 
    4, 5, 6, 
    7, 8, 9 
  }; 
} 

在我们进一步进行之前,让我们花一点时间看一下如果运行FirstLastOperator()方法,控制台上的以下输出:

元素操作

从输出中,我们可以发现First运算符将返回序列的第一个元素,而Last运算符将返回最后一个元素。我们还可以使用 lambda 表达式来过滤序列的FirstLast运算符。在前面的示例中,我们过滤了只能被四整除的数字序列。

不幸的是,FirstLast运算符不能返回空值;相反,它们会抛出异常。让我们检查以下代码,关于使用First运算符,它将返回一个空序列:

public partial class Program 
{ 
  public static void FirstOrDefaultOperator() 
  { 
    Console.WriteLine( 
      "First Operator with predicate: {0}", 
      numbers.First(n => n % 10 == 0)); 
    Console.WriteLine( 
      "First Operator with predicate: {0}", 
      numbers.FirstOrDefault(n => n % 10 == 0)); 
  } 
} 

如果我们取消注释前面代码中的所有注释代码行,由于没有可以被10整除的数字,该方法将抛出异常。为了解决这个问题,我们可以使用FirstOrDefault运算符,它将返回默认值,因为数字是整数序列。因此,它将返回整数的默认值,即0

我们还有SingleSingleOrDefault作为元素运算符,我们可以看一下它们在以下代码中的使用:

public partial class Program 
{ 
  public static void SingleOperator() 
  { 
    Console.WriteLine( 
      "Single Operator for number can be divided by 7: {0}", 
      numbers.Single(n => n % 7 == 0)); 
    Console.WriteLine( 
      "Single Operator for number can be divided by 2: {0}", 
      numbers.Single(n => n % 2 == 0)); 

    Console.WriteLine( 
      "SingleOrDefault Operator: {0}", 
      numbers.SingleOrDefault(n => n % 10 == 0)); 

    Console.WriteLine( 
      "SingleOrDefault Operator: {0}", 
      numbers.SingleOrDefault(n => n % 3 == 0)); 
  } 
} 

如果我们运行前面的代码,由于以下代码片段,将会抛出异常:

Console.WriteLine( 
  "Single Operator for number can be divided by 2: {0}", 
  numbers.Single(n => n % 2 == 0)); 

此外,以下代码片段会导致错误:

Console.WriteLine( 
  "SingleOrDefault Operator: {0}", 
  numbers.SingleOrDefault(n => n % 3 == 0)); 

错误发生是因为Single运算符只能有一个匹配的元素。在第一个代码片段中,我们得到了2468作为结果。在第二个代码片段中,我们得到了369作为结果。

Element操作还有ElementAtElementAtOrDefault运算符,用于从序列中获取第 n 个元素。让我们看一下以下代码,演示这些运算符的使用:

public partial class Program 
{ 
  public static void ElementAtOperator() 
  { 
    Console.WriteLine( 
      "ElementAt Operator: {0}", 
      numbers.ElementAt(5)); 

    //Console.WriteLine( 
      //"ElementAt Operator: {0}", 
      //numbers.ElementAt(11)); 

    Console.WriteLine( 
      "ElementAtOrDefault Operator: {0}", 
      numbers.ElementAtOrDefault(11)); 
  } 
} 

FirstLast运算符一样,ElementAt也必须返回值。在前面的代码中,注释的代码行将抛出异常,因为在索引11中没有元素。但是,我们可以使用ElementAtOrDefault来解决这个问题,然后注释的行将返回int的默认值。

元素操作中的最后一个是DefaultIfEmpty运算符,如果在输入序列中找不到元素,它将返回序列中的默认值。以下代码将演示DefaultIfEmpty运算符:

public partial class Program 
{ 
  public static void DefaultIfEmptyOperator() 
  { 
    List<int> numbers = new List<int>(); 

    //Console.WriteLine( 
      //"DefaultIfEmpty Operator: {0}", 
      //numbers.DefaultIfEmpty()); 

    foreach (int number in numbers.DefaultIfEmpty()) 
    { 
      Console.WriteLine( 
        "DefaultIfEmpty Operator: {0}", number); 
    } 
  } 
} 

由于DefaultIfEmpty运算符的返回值是IEnumerable<T>,我们必须对其进行枚举,即使它只包含一个元素。正如您在前面的代码中所看到的,我们注释了对 numbers 变量的直接访问,因为它将返回变量的类型,而不是变量的值。相反,我们必须枚举 numbers 查询,以获取存储在IEnumerable<T>变量中的唯一值。

总结

LINQ 使我们查询集合的任务变得更容易,因为我们不需要学习太多语法来访问不同类型的集合。它实现了延迟执行的概念,这意味着查询不会在构造函数中执行,而是在枚举过程中执行。几乎所有查询运算符都提供了延迟执行的概念;但是,对于执行以下操作的运算符,存在例外情况:

返回标量值或单个元素,例如CountFirst

将查询的结果转换为ToListToArrayToDictionaryToLookup。它们也被称为转换操作符。

换句话说,返回序列的方法实现了延迟执行,例如Select方法(IEnumerable<X>-> Select -> IEnumerable<Y>),而返回单个对象的方法不实现延迟执行,例如First方法(IEnumerable<X>-> First -> Y)

LINQ 有两种查询语法;它们是流畅语法和查询表达式语法。前者采用 lambda 表达式作为参数,表示将在序列枚举中执行的逻辑。后者是一种简写语法,我们可以使用它来执行 LINQ 查询。在查询表达式语法中,.NET Framework 为每个查询操作符提供关键字,但并非所有操作符。当我们使用查询表达式语法时,我们的代码将更易读,编码量也会减少。然而,流畅语法和查询语法都会做同样的事情。它们之间的区别只在于语法。查询表达式语法中的每个关键字都在Enumerable类中有自己的扩展方法。

通过理解 LINQ,我们现在已经有足够的知识来创建函数式编程。在下一章中,我们将讨论异步编程,以增强代码的响应性,从而构建用户友好的应用程序。

第六章:使用异步编程增强功能程序的响应性

响应式应用程序在今天的编程方法中是必不可少的。它们可以提高应用程序本身的性能,并使我们的应用程序具有用户友好的界面。我们需要在程序中异步运行代码执行过程,以实现响应式应用程序。为了实现这一目标,在本章中,我们将讨论以下主题:

  • 使用线程和线程池构建响应式应用程序

  • 学习异步编程模型模式

  • 学习基于任务的异步模式

  • 使用 async 和 await 关键字构建异步编程

  • 在功能方法中应用异步方法

构建响应式应用程序

.NET Framework 首次发布时,程序的流程是按顺序执行的。这种执行流程的缺点是我们的应用程序必须等待操作完成才能执行下一个操作。这将冻结我们的应用程序,这将是一个不愉快的用户体验。

为了最小化这个问题,.NET Framework 引入了线程,这是操作的最小单位,可以由操作系统独立调度。而异步编程意味着在单独的线程上运行一段代码,释放原始线程并在任务完成时做其他事情。

同步运行程序

让我们从创建一个运行所有操作的程序开始同步运行。以下是演示我们可以在SynchronousOperation.csproj项目中找到的同步操作的代码:

public partial class Program 
{ 
  public static void SynchronousProcess() 
  { 
    Stopwatch sw = Stopwatch.StartNew(); 
    Console.WriteLine( 
      "Start synchronous process now..."); 
    int iResult = RunSynchronousProcess(); 
    Console.WriteLine( 
      "The Result = {0}",iResult); 
    Console.WriteLine( 
      "Total Time = {0} second(s)!", 
      sw.ElapsedMilliseconds/1000); 
  } 
  public static int RunSynchronousProcess() 
  { 
    int iReturn = 0; 
    iReturn += LongProcess1(); 
    iReturn += LongProcess2(); 
    return iReturn; 
  } 
  public static int LongProcess1() 
  { 
    Thread.Sleep(5000); 
    return 5; 
  } 
  public static int LongProcess2() 
  { 
    Thread.Sleep(7000); 
    return 7; 
  } 
} 

如前面的代码所示,RunSynchronousProcess()方法执行两种方法;它们是LongProcess1()LongProcess2()方法。现在让我们调用前面的RunSynchronousProcess()方法,我们将在控制台上得到以下输出:

同步运行程序

这两种方法,LongProcess1()LongProcess2(),是独立的,每种方法都需要一定的时间来完成。由于它是同步执行的,完成这两种方法需要 12 秒。LongProcess1()方法需要 5 秒完成,LongProcess2()方法需要 7 秒完成。

在程序中应用线程

我们可以改进先前的代码,使其成为响应式程序,通过重构一些代码并向代码添加线程。重构后的代码如下,在ApplyingThreads.csproj项目中可以找到:

public partial class Program 
{ 
  public static void AsynchronousProcess() 
  { 
    Stopwatch sw = Stopwatch.StartNew(); 
    Console.WriteLine( 
      "Start asynchronous process now..."); 
    int iResult = RunAsynchronousProcess(); 
    Console.WriteLine( 
      "The Result = {0}", 
      iResult); 
    Console.WriteLine( 
      "Total Time = {0} second(s)!", 
      sw.ElapsedMilliseconds / 1000); 
  } 
  public static int RunAsynchronousProcess() 
  { 
    int iResult1 = 0; 
    // Creating thread for LongProcess1() 
    Thread thread = new Thread( 
      () => iResult1 = LongProcess1()); 
    // Starting the thread 
    thread.Start(); 
    // Running LongProcess2() 
    int iResult2 = LongProcess2(); 
    // Waiting for the thread to finish 
    thread.Join(); 
    // Return the the total result 
    return iResult1 + iResult2; 
  } 
  public static int LongProcess1() 
  { 
    Thread.Sleep(5000); 
    return 5; 
  } 
  public static int LongProcess2() 
  { 
    Thread.Sleep(7000); 
    return 7; 
  } 
} 

如我们所见,我们将先前的代码中的RunSynchronousProcess()方法重构为RunAsynchronousProcess()方法。如果我们运行RunAsynchronousProcess()方法,我们将在控制台上得到以下输出:

在程序中应用线程

RunSynchronousProcess()方法相比,我们现在在RunAsynchronousProcess()方法中有一个更快的进程。我们创建一个新的线程来运行LongProcess1()方法。线程将在使用Start()方法启动之后才会运行。看一下以下代码片段,其中我们创建并运行线程:

// Creating thread for LongProcess1() 
Thread thread = new Thread( 
  () => 
  iResult1 = LongProcess1()); 
// Starting the thread 
thread.Start(); 

线程运行后,我们可以运行其他操作,这种情况下是LongProcess2()方法。当此操作完成时,我们必须等待线程完成,然后使用线程实例的Join()方法。以下代码片段将解释这一点:

// Running LongProcess2() 
int iResult2 = LongProcess2(); 
// Waiting for the thread to finish 
thread.Join(); 

Join()方法将阻塞当前线程,直到正在执行的其他线程完成。在其他线程完成后,Join()方法将返回,然后当前线程将被解除阻塞。

使用线程池创建线程

除了使用线程本身,我们还可以使用System.Threading.ThreadPool类预先创建一些线程。如果需要从线程池中使用线程,我们可以使用这个类。在使用线程池时,您更有可能只使用QueueUserWorkItem()方法。该方法将向线程池队列中添加执行请求。如果线程池中有可用线程,请求将立即执行。让我们看一下以下代码,以演示线程池的使用,可以在UsingThreadPool.csproj项目中找到:

public partial class Program 
{ 
  public static void ThreadPoolProcess() 
  { 
    Stopwatch sw = Stopwatch.StartNew(); 
    Console.WriteLine( 
      "Start ThreadPool process now..."); 
    int iResult = RunInThreadPool(); 
    Console.WriteLine("The Result = {0}", 
      iResult); 
    Console.WriteLine("Total Time = {0} second(s)!", 
      sw.ElapsedMilliseconds / 1000); 
  } 
  public static int RunInThreadPool() 
  { 
    int iResult1 = 0; 
    // Assignin work LongProcess1() to idle thread  
    // in the thread pool  
    ThreadPool.QueueUserWorkItem((t) => 
      iResult1 = LongProcess1()); 
    // Running LongProcess2() 
    int iResult2 = LongProcess2(); 
    // Waiting the thread to be finished 
    // then returning the result 
    return iResult1 + iResult2; 
  } 
    public static int LongProcess1() 
  { 
    Thread.Sleep(5000); 
    return 5; 
  } 
  public static int LongProcess2() 
  { 
    Thread.Sleep(7000); 
    return 7; 
  } 
} 

在线程池中,我们可以调用QueueUserWorkItem()方法将新的工作项放入队列中,当我们需要运行长时间运行的进程而不是创建新线程时,线程池会管理该队列。当我们将工作发送到线程池时,有三种可能性来处理工作;它们如下:

  • 线程池中有一个或多个可用线程在空闲,因此工作可以由空闲线程处理并立即运行。

  • 没有可用的线程,但MaxThreads属性尚未达到,因此线程池将创建一个新线程,分配工作,并立即运行工作。

  • 线程池中没有可用线程,并且线程池中的线程总数已达到MaxThreads。在这种情况下,工作项将在队列中等待第一个可用线程。

现在,让我们运行ThreadPoolProcess()方法,我们将在控制台上得到以下输出:

使用线程池创建线程

正如我们在前面的截图中所看到的,当我们应用前面部分讨论的新线程时,我们得到了相似的处理时间相同的结果。

异步编程模型模式

异步编程模型APM)是一种使用IAsyncResult接口作为设计模式的异步操作。它也被称为IAsyncResult模式。为此,框架提供了名为BeginXxEndXx的方法,其中Xx是操作名称,例如,FileStream类提供的BeginReadEndRead用于异步从文件中读取字节。

同步的Read()方法与BeginRead()EndRead()的区别可以从方法的声明中识别,如下所示:

public int Read( 
  byte[] array, 
  int offset, 
  int count 
) 
public IAsyncResult BeginRead( 
  byte[] array, 
  int offset, 
  int numBytes, 
  AsyncCallback userCallback, 
  object stateObject 
) 
public int EndRead( 
  IAsyncResult asyncResult 
) 

在同步的Read()方法中,我们需要三个参数;它们是arrayoffsetnumBytes。在BeginRead()方法中,还有两个参数添加;它们是userCallback,即在异步读取操作完成时将被调用的方法,以及stateObject,用户提供的用于区分异步读取请求和其他请求的对象。

使用同步的 Read()方法

现在,让我们看一下以下代码,在APM.csproj项目中可以找到,以便更清楚地区分异步的BeginRead()方法和同步的Read()方法:

public partial class Program 
{ 
  public static void ReadFile() 
  { 
    FileStream fs = 
      File.OpenRead( 
        @"..\..\..\LoremIpsum.txt"); 
    byte[] buffer = new byte[fs.Length]; 
    int totalBytes = 
      fs.Read(buffer, 0, (int)fs.Length); 
    Console.WriteLine("Read {0} bytes.", totalBytes); 
    fs.Dispose(); 
  } 
} 

上述代码将同步读取LoremIpsum.txt文件(包含在APM.csproj项目中),这意味着在执行下一个进程之前,读取过程必须完成。如果我们运行上述的ReadFile()方法,我们将在控制台上得到以下输出:

使用同步的 Read()方法

使用 BeginRead()和 EndRead()方法

现在,让我们比较使用Read()方法进行同步读取过程与使用BeginRead()EndRead()方法进行异步读取过程的以下代码:

public partial class Program 
{ 
  public static void ReadAsyncFile() 
  { 
    FileStream fs =  
      File.OpenRead( 
        @"..\..\..\LoremIpsum.txt"); 
    byte[] buffer = new byte[fs.Length]; 
    IAsyncResult result = fs.BeginRead(buffer, 0, (int)fs.Length,
      OnReadComplete, fs); 
    //do other work while file is read 
    int i = 0; 
    do 
    { 
      Console.WriteLine("Timer Counter: {0}", ++i); 
    } 
    while (!result.IsCompleted); 
    fs.Dispose(); 
  } 
  private static void OnReadComplete(IAsyncResult result) 
  { 
    FileStream fStream = (FileStream)result.AsyncState;
    int totalBytes = fStream.EndRead(result);
    Console.WriteLine("Read {0} bytes.", totalBytes);fStream.Dispose(); 
  } 
} 

如我们所见,我们有两个名为ReadAsyncFile()OnReadComplete()的方法。ReadAsyncFile()方法将异步读取LoremIpsum.txt文件,然后在完成文件读取后立即调用OnReadComplete()方法。我们有额外的代码来确保使用以下do-while循环代码片段正确运行异步操作:

//do other work while file is read 
int i = 0; 
do 
{ 
  Console.WriteLine("Timer Counter: {0}", ++i); 
} 
while (!result.IsCompleted); 

上述do-while循环将迭代,直到异步操作完成,如IAsyncResultIsComplete属性所示。当调用BeginRead()方法时,异步操作开始,如下面的代码片段所示:

IAsyncResult result = 
  fs.BeginRead( 
    buffer, 0, (int)fs.Length, OnReadComplete, fs); 

之后,它将在读取文件的同时继续下一个过程。当读取过程完成时,将调用OnReadComplete()方法,由于OnReadComplete()方法的实现将IsFinish变量设置为 true,它将停止我们的do-while循环。

通过运行ReadAsyncFile()方法,我们将得到以下输出:

使用 BeginRead()和 EndRead()方法

从上述输出的截图中,我们可以看到在运行读取过程时,do-while循环的迭代成功执行。读取过程在do-while循环的第 64 次迭代中完成。

在 BeginRead()方法调用中添加 LINQ

我们还可以使用 LINQ 来定义OnReadComplete()方法,以便我们可以使用匿名方法替换该方法,如下所示:

public partial class Program 
{ 
  public static void ReadAsyncFileAnonymousMethod() 
  { 
    FileStream fs = 
      File.OpenRead( 
        @"..\..\..\LoremIpsum.txt"); 
    byte[] buffer = new byte[fs.Length]; 
    IAsyncResult result = fs.BeginRead(buffer, 0, (int)fs.Length,
      asyncResult => { int totalBytes = fs.EndRead(asyncResult); 
    Console.WriteLine("Read {0} bytes.", totalBytes); 
      }, null); 
    //do other work while file is read 
    int i = 0; 
    do 
    { 
      Console.WriteLine("Timer Counter: {0}", ++i); 
    } 
    while (!result.IsCompleted); 
    fs.Dispose(); 
  } 
} 

如我们所见,我们用以下代码片段替换了对BeginRead()方法的调用:

IAsyncResult result = 
  fs.BeginRead( 
    buffer, 
    0, 
    (int)fs.Length, 
    asyncResult => 
    { 
      int totalBytes = 
        fs.EndRead(asyncResult); 
      Console.WriteLine("Read {0} bytes.", totalBytes); 
    }, 
  null); 

从上述代码中,我们可以看到我们不再有OnReadComplete()方法,因为它已被匿名方法代替。我们在回调中删除了FileStream实例,因为 lambda 中的匿名方法将使用闭包访问它。如果我们调用ReadAsyncFileAnonymousMethod()方法,我们将得到与ReadAsyncFile()方法完全相同的输出,除了迭代次数,因为它取决于 CPU 速度。

除了IsCompleted属性用于获取指示异步操作是否完成的值外,处理IAsyncResult时还有三个属性可用,它们如下:

  • AsyncState:用于检索由用户定义的对象,该对象限定或包含有关异步操作的信息

  • AsyncWaitHandle:用于检索WaitHandle(来自操作系统的等待对共享资源的独占访问的对象),指示异步操作的完成情况

  • CompletedSynchronously:用于检索指示异步操作是否同步完成的值

不幸的是,应用 APM 时存在一些缺点,例如无法取消操作。这意味着我们无法取消异步操作,因为从调用BeginRead到触发回调时,没有办法取消后台进程。如果LoremIpsum.txt是一个千兆字节的文件,我们必须等待异步操作完成,而不能取消操作。

注意

由于其过时的技术,不再建议在新开发中使用 APM 模式。

基于任务的异步模式

基于任务的异步模式(TAP)是一种用于表示任意异步操作的模式。这种模式的概念是在一个方法中表示异步操作,并结合操作的状态和用于与这些操作交互的 API,使它们成为一个单一对象。这些对象是System.Threading.Tasks命名空间中的TaskTask<TResult>类型。

介绍 Task 和 Task

.NET Framework 4.0中宣布了TaskTask<TResult>类,以表示异步操作。它使用存储在线程池中的线程,但提供了任务创建的灵活性。当我们需要将方法作为任务运行但不需要返回值时,我们使用Task类;否则,当我们需要获取返回值时,我们使用Task<TResult>类。

注意

我们可以在 MSDN 网站上找到TaskTask<TResult>的完整参考,包括方法和属性,网址为msdn.microsoft.com/en-us/library/dd321424(v=vs.110).aspx

应用简单的 TAP 模型

让我们通过创建以下代码来开始讨论 TAP,我们可以在TAP.csproj项目中找到它,并使用它来异步读取文件:

public partial class Program 
{ 
  public static void ReadFileTask() 
  { 
    bool IsFinish = false; 
    FileStream fs = File.OpenRead( 
      @"..\..\..\LoremIpsum.txt"); 
    byte[] readBuffer = new byte[fs.Length]; 
    fs.ReadAsync(readBuffer,  0,  (int)fs.Length) 
      .ContinueWith(task => { 
      if (task.Status ==  
        TaskStatus.RanToCompletion) 
        { 
          IsFinish = true; 
          Console.WriteLine( 
          "Read {0} bytes.", 
          task.Result); 
        } 
        fs.Dispose();}); 
    //do other work while file is read 
    int i = 0; 
    do 
    { 
      Console.WriteLine("Timer Counter: {0}", ++i); 
    } 
    while (!IsFinish); 
    Console.WriteLine("End of ReadFileTask() method"); 
  } 
} 

如上述代码所示,FileStream类中的ReadAsync()方法将返回Task<int>,在这种情况下,它将指示从文件中读取的字节数。在调用ReadAsync()方法后,我们使用方法链接调用ContinueWith()扩展方法,如第一章中讨论的,在 C#中品尝函数式类型。它允许我们指定Action<Task<T>>,该操作将在异步操作完成后运行。

通过在任务完成后调用ContinueWith()方法,委托将立即以同步操作运行。如果我们运行前面的ReadFileTask()方法,我们将在控制台上得到以下输出:

应用简单的 TAP 模型

使用 WhenAll()扩展方法

我们在前面的部分成功应用了简单的 TAP。现在,我们将继续通过异步读取两个文件,然后仅在两个读取操作都完成后处理其他操作。让我们看一下以下代码,它将演示我们的需求:

public partial class Program 
{ 
  public static void ReadTwoFileTask() 
  { 
    bool IsFinish = false; 
    Task readFile1 = 
      ReadFileAsync( 
      @"..\..\..\LoremIpsum.txt"); 
    Task readFile2 = 
      ReadFileAsync( 
      @"..\..\..\LoremIpsum2.txt"); 
    Task.WhenAll(readFile1, readFile2) 
      .ContinueWith(task => 
      { 
        IsFinish = true; 
        Console.WriteLine( 
        "All files have been read successfully."); 
      }); 
      //do other work while file is read 
      int i = 0; 
      do 
      { 
        Console.WriteLine("Timer Counter: {0}", ++i); 
      } 
      while (!IsFinish); 
      Console.WriteLine("End of ReadTwoFileTask() method"); 
    } 
    public static Task<int> ReadFileAsync(string filePath) 
    { 
      FileStream fs = File.OpenRead(filePath); 
      byte[] readBuffer = new byte[fs.Length]; 
      Task<int> readTask = 
        fs.ReadAsync( 
        readBuffer, 
        0, 
        (int)fs.Length); 
      readTask.ContinueWith(task => 
      { 
        if (task.Status == TaskStatus.RanToCompletion) 
        Console.WriteLine( 
          "Read {0} bytes from file {1}", 
          task.Result, 
          filePath); 
        fs.Dispose(); 
      }); 
      return readTask; 
    } 
} 

我们使用Task.WhenAll()方法将作为参数传递的两个任务包装成一个更大的异步操作。然后返回一个代表这两个异步操作组合的任务。我们不需要等待两个文件的读取操作完成,但它会在这两个文件成功读取后添加一个继续操作。

如果我们运行前面的ReadTwoFileTask()方法,我们将在控制台上得到以下输出:

使用 WhenAll()扩展方法

正如我们之前讨论过的,APM 模式的缺点是我们无法取消后台进程,现在让我们尝试通过重构我们之前的代码来取消 TAP 中的任务列表。完整的代码将变成以下样子:

public partial class Program 
{ 
  public static void ReadTwoFileTaskWithCancellation() 
  { 
    bool IsFinish = false; 

    // Define the cancellation token. 
    CancellationTokenSource source = 
      new CancellationTokenSource(); 
    CancellationToken token = source.Token; 

    Task readFile1 = 
      ReadFileAsync( 
      @"..\..\..\LoremIpsum.txt"); 
    Task readFile2 = 
      ReadFileAsync( 
      @"..\..\..\LoremIpsum2.txt"); 

    Task.WhenAll(readFile1, readFile2) 
      .ContinueWith(task => 
      { 
        IsFinish = true; 
        Console.WriteLine( 
          "All files have been read successfully."); 
      } 
      , token 
    ); 

    //do other work while file is read 
    int i = 0; 
    do 
    { 
      Console.WriteLine("Timer Counter: {0}", ++i); 
      if (i > 10) 
      { 
        source.Cancel(); 
        Console.WriteLine( 
          "All tasks are cancelled at i = " + i); 
         break; 
       } 
     } 
     while (!IsFinish); 

     Console.WriteLine( 
       "End of ReadTwoFileTaskWithCancellation() method"); 
    } 
} 

如上述代码所示,我们添加了CancellationTokenSourceCancellationToken来通知取消过程。然后我们将令牌传递给Task.WhenAll()函数。任务运行后,我们可以使用source.Cancel()方法取消任务。

如果我们运行上述代码,我们将在控制台上得到以下输出:

使用 WhenAll()扩展方法

上述输出告诉我们,任务在第 11 个计数器中成功取消,因为计数器已经超过了 10。

将 APM 包装成 TAP 模型

如果框架没有为异步操作提供 TAP 模型,我们可以将 APM 的BeginXxEndXx方法包装成 TAP 模型,使用Task.FromAsync方法。让我们看一下以下代码,以演示包装过程:

public partial class Program 
{ 
  public static bool IsFinish; 
  public static void WrapApmIntoTap() 
  { 
    IsFinish = false; 
    ReadFileAsync( 
      @"..\..\..\LoremIpsum.txt"); 
      //do other work while file is read 
      int i = 0; 
    do 
    { 
      Console.WriteLine("Timer Counter: {0}", ++i); 
    } 
    while (!IsFinish); 
    Console.WriteLine( 
      "End of WrapApmIntoTap() method"); 
  } 
  private static Task<int> ReadFileAsync(string filePath) 
  { 
    FileStream fs = File.OpenRead(filePath); 
    byte[] readBuffer = new Byte[fs.Length]; 
    Task<int> readTask = 
      Task.Factory.FromAsync( 
      (Func<byte[], 
      int, 
      int, 
      AsyncCallback, 
      object, 
      IAsyncResult>) 
    fs.BeginRead, 
    (Func<IAsyncResult, int>) 
    fs.EndRead, 
    readBuffer, 
    0, 
    (int)fs.Length, 
    null); 
    readTask.ContinueWith(task => 
    { 
      if (task.Status == TaskStatus.RanToCompletion) 
      { 
        IsFinish = true; 
        Console.WriteLine( 
          "Read {0} bytes from file {1}", 
          task.Result, 
          filePath); 
      } 
      fs.Dispose(); 
    }); 
    return readTask; 
  } 
} 

从上述代码中,我们可以看到我们使用了BeginRead()EndRead()方法,实际上是 APM 模式,但我们在 TAP 模型中使用它们,如下面的代码片段所示:

Task<int> readTask = 
  Task.Factory.FromAsync( 
    (Func<byte[], 
    int, 
    int, 
    AsyncCallback, 
    object, 
    IAsyncResult>) 
    fs.BeginRead, 
    (Func<IAsyncResult, int>) 
    fs.EndRead, 
    readBuffer, 
    0, 
    (int)fs.Length, 
  null); 

如果我们运行前面的WrapApmIntoTap()方法,我们将在控制台上得到以下输出:

将 APM 包装成 TAP 模型

正如我们在输出结果的截图中所看到的,我们成功地使用了包装到 TAP 模型中的BeginRead()EndRead()方法来读取LoremIpsum.txt文件。

使用asyncawait关键字进行异步编程

asyncawait关键字是在 C# 5.0 中宣布的,并成为 C#异步编程中的最新和最伟大的东西。从 TAP 模式发展而来,C#将这两个关键字整合到语言本身中,使其变得简单易读。使用这两个关键字,TaskTask<TResult>类仍然成为异步编程的核心构建块。我们仍然会使用Task.Run()方法构建一个新的TaskTask<TResult>数据类型,就像在前一节中讨论的那样。

现在让我们看一下下面的代码,它演示了asyncawait关键字,我们可以在AsyncAwait.csproj项目中找到:

public partial class Program 
{ 
  static bool IsFinish; 
  public static void AsyncAwaitReadFile() 
  { 
    IsFinish = false; 
    ReadFileAsync(); 
    //do other work while file is read 
    int i = 0; 
    do 
    { 
      Console.WriteLine("Timer Counter: {0}", ++i); 
    } 
    while (!IsFinish); 
    Console.WriteLine("End of AsyncAwaitReadFile() method"); 
  } 
  public static async void ReadFileAsync() 
  { 
    FileStream fs = 
      File.OpenRead( 
      @"..\..\..\LoremIpsum.txt"); 
    byte[] buffer = new byte[fs.Length]; 
    int totalBytes = 
      await fs.ReadAsync( 
      buffer, 
      0, 
      (int)fs.Length); 
    Console.WriteLine("Read {0} bytes.", totalBytes); 
    IsFinish = true; 
    fs.Dispose(); 
  } 
} 

正如我们在上面的代码中所看到的,我们通过在读取文件流时添加await关键字来重构了我们上一个主题的代码,如下面的代码片段所示:

int totalBytes = 
  await fs.ReadAsync( 
    buffer, 
    0, 
    (int)fs.Length); 

此外,我们在方法名前面使用async关键字,如下面的代码片段所示:

public static async void ReadFileAsync() 
{ 
  // Implementation 
} 

从前两个代码片段中,我们可以看到await关键字只能在标记有async关键字的方法内部调用。当达到await时--在这种情况下是在await fs.ReadAsync()中--调用方法的线程将跳出方法并继续执行其他操作。然后异步代码将在一个单独的线程上执行(就像我们使用Task.Run()方法一样)。然而,await之后的所有内容都将在任务完成时被调度执行。如果我们运行上述的AsyncAwaitReadFile()方法,将在控制台上得到以下输出:

使用asyncawait关键字进行异步编程

与 TAP 模型一样,我们在这里也获得了异步结果。

函数式编程中的异步函数

现在,使用链接方法,我们将在函数式编程中使用asyncawait关键字。假设我们有三个任务,如下面的代码片段所示,并且我们需要将它们链接在一起:

public async static Task<int> FunctionA( 
  int a) => await Task.FromResult(a * 1); 
public async static Task<int> FunctionB( 
  int b) => await Task.FromResult(b * 2); 
public async static Task<int> FunctionC( 
  int c) => await Task.FromResult(c * 3); 

为此,我们必须为Task<T>创建一个名为MapAsync的新扩展方法,具体实现如下:

public static class ExtensionMethod 
{ 
  public static async Task<TResult> MapAsync<TSource, TResult>( 
    this Task<TSource> @this, 
    Func<TSource, Task<TResult>> fn) => await fn(await @this); 
} 

MapAsync()方法允许我们将方法定义为async,接受从async方法返回的任务,并await委托的调用。以下是我们用于链接AsyncChain.csproj项目中的三个任务的完整代码:

public partial class Program 
{ 
  public async static Task<int> FunctionA( 
    int a) => await Task.FromResult(a * 1); 
  public async static Task<int> FunctionB( 
    int b) => await Task.FromResult(b * 2); 
  public async static Task<int> FunctionC( 
    int c) => await Task.FromResult(c * 3); 
  public async static void AsyncChain() 
  { 
    int i = await FunctionC(10) 
    .MapAsync(FunctionB) 
    .MapAsync(FunctionA); 
    Console.WriteLine("The result = {0}", i); 
  } 
} 

如果我们运行上述的AsyncChain()方法,将在控制台上得到以下输出:

函数式编程中的异步函数

总结

异步编程是一种我们可以用来开发响应式应用程序的方式,我们成功地应用了ThreadThreadPool来实现这一目标。我们可以创建一个新线程来运行工作,或者我们可以重用线程池中的可用线程。

我们还学习了异步编程模型模式,这是一种使用IAsyncResult接口作为设计模式的异步操作。在这种模式中,我们使用了以BeginEnd开头的两种方法;例如,在我们的讨论中,这些方法是BeginRead()EndRead()BeginRead()方法在调用时启动了异步操作,然后EndRead()方法停止了操作,以便我们可以获取操作的返回值。

除了异步编程模型模式,.NET Framework 还有基于任务的异步模式来运行异步操作。这种模式的概念是在一个方法中表示异步操作,并将操作的状态和用于与这些操作交互的 API 结合成一个单一对象。我们在这种模式中使用的对象是TaskTask<TResult>,可以在System.Threading.Tasks命名空间中找到。在这种模式中,我们还可以取消作为后台进程运行的活动任务。

接着,C#宣布了asyncawait来完成异步技术,我们可以选择使用。它是从基于任务的异步模式发展而来的,其中TaskTask<TResult>类成为了异步编程的核心构建模块。本章我们做的最后一件事是尝试使用基于asyncawait关键字的扩展方法来链接这三个任务。

在下一章中,我们将讨论在函数式编程中有用的递归,以简化代码。我们将学习递归的用法,以及如何基于递归减少代码行数。

第七章:学习递归

在函数式编程的首次公告中,许多函数式语言没有循环功能来迭代序列。我们所要做的就是构建递归过程来迭代序列。尽管 C#具有诸如forwhile之类的迭代功能,但最好还是在函数式方法中讨论递归。递归也将简化我们的代码。因此,在本章中,我们将讨论以下主题:

  • 理解递归例程的工作方式

  • 将迭代重构为递归

  • 区分尾递归和累加器传递风格与续传风格

  • 理解间接递归和直接递归

  • 使用 Aggregate LINQ 运算符在函数式方法中应用递归

探索递归

递归函数是调用自身的函数。与迭代循环(例如whilefor循环)一样,它用于逐步解决复杂的任务并组合结果。但是,for循环和while循环之间存在区别。迭代将持续重复直到任务完成,而递归将将任务分解成较小的部分以解决更大的问题,然后组合结果。在函数式方法中,递归更接近数学方法,因为它通常比迭代更短,尽管在设计和测试上可能更难一些。

在第一章中,在 C#中品尝函数式风格,我们在讨论函数式编程的概念时熟悉了递归函数。在那里,我们分析了命名为GetFactorial()的阶乘函数在命令式和函数式方法中的实现。为了提醒我们,以下是GetFactorial()函数的实现,我们可以在SimpleRecursion.csproj项目中找到:

public partial class Program 
{ 
  private static int GetFactorial(int intNumber) 
  { 
    if (intNumber == 0) 
    { 
      return 1; 
    } 
    return intNumber * GetFactorial(intNumber - 1); 
  } 
} 

在我们在第一章的讨论中,在 C#中品尝函数式风格,我们知道非负整数N的阶乘是小于或等于N的所有正整数的乘积。因此,假设我们有以下函数来计算五的阶乘:

private static void GetFactorialOfFive() 
{ 
  int i = GetFactorial(5); 
  Console.WriteLine("5! is {0}",i); 
} 

正如我们可以预测的那样,如果我们调用前面的GetFactorialOfFive()方法,我们将在控制台上得到以下输出:

探索递归

回到GetFactorial()方法,我们可以看到在该方法的实现中有结束递归的代码,如下面的代码片段所示:

if (intNumber == 0) 
{ 
  return 1; 
} 

我们可以看到前面的代码是递归的基本情况,递归通常有基本情况。这个基本情况将定义递归链的结束,因为在这种情况下,每次运行递归时方法都会改变intNumber的状态,并且如果intNumber为零,链条将停止。

递归例程的工作方式

为了理解递归例程的工作方式,让我们检查一下程序的流程,看看如果我们找到五的阶乘时intNumber的状态是怎样的:

int i = GetFactorial(5) 
  (intNumber = 5) != 0 
  return (5 * GetFactorial(4)) 
    (intNumber = 4) != 0 
    return (4 * GetFactorial(3)) 
      (intNumber = 3) != 0 
      return (3 * GetFactorial(2)) 
        (intNumber = 2) != 0 
        return (2 * GetFactorial(1)) 
          (intNumber = 1) != 0 
          return (1 * GetFactorial(0)) 
            (intNumber = 0) == 0 
            return 1 
          return (1 * 1 = 1) 
        return (2 * 1 = 2) 
      return (3 * 2 = 6) 
    return (4 * 6 = 24) 
  return (5 * 24 = 120) 
i = 120 

使用前述流程,递归的工作方式变得更清晰。我们定义的基本情况定义了递归链的结束。编程语言编译器将特定情况的递归转换为迭代,因为基于循环的实现通过消除对函数调用的需求而变得更有效率。

提示

在编写程序逻辑时应谨慎应用递归。如果您错过了基本情况或给出了错误的值,可能会陷入无限递归。例如,在前面的GetFactorial()方法中,如果我们传递intNumber < 0,那么我们的程序将永远不会结束。

将迭代重构为递归

递归使我们的程序更易读,并且在函数式编程方法中是必不可少的。在这里,我们将把 for 循环迭代重构为递归方法。让我们来看看以下代码,我们可以在RefactoringIterationToRecursion.csproj项目中找到:

public partial class Program 
{ 
  public static int FindMaxIteration( 
     int[] intArray) 
  { 
    int iMax = 0; 
    for (int i = 0; i < intArray.Length; i++) 
    { 
      if (intArray[i] > iMax) 
      { 
        iMax = intArray[i]; 
      } 
    } 
    return iMax; 
  } 
} 

上述的FindMaxIteration()方法用于选择数组中的最大数。考虑到我们有以下代码来运行FindMaxIteration()方法:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    int[] intDataArray =  
       {8, 10, 24, -1, 98, 47, -101, 39 }; 
    int iMaxNumber = FindMaxIteration(intDataArray); 
    Console.WriteLine( 
       "Max Number (using FindMaxRecursive) = " + 
         iMaxNumber); 
  } 
} 

正如我们所期望的,我们将在控制台窗口中得到以下输出:

将迭代重构为递归

现在,让我们将FindMaxIteration()方法重构为递归函数。以下是FindMaxRecursive()方法的实现,它是FindMaxIteration()方法的递归版本:

public partial class Program 
{ 
  public static int FindMaxRecursive( 
     int[] intArray,  
      int iStartIndex = 0) 
  { 
    if (iStartIndex == intArray.Length - 1) 
    { 
      return intArray[iStartIndex]; 
    } 
    else 
    { 
      return Math.Max(intArray[iStartIndex],
        FindMaxRecursive(intArray,iStartIndex + 1)); 
    } 
  } 
} 

我们可以使用与FindMaxIteration()方法相同的代码来调用上述的FindMaxRecursive()方法,如下所示:

public partial class Program 
{ 
  static void Main(string[] args) 
  { 
    int[] intDataArray = {8, 10, 24, -1, 98, 47, -101, 39 }; 
    int iMaxNumber = FindMaxRecursive(intDataArray); 
    Console.WriteLine"Max Number(using FindMaxRecursive) = " +
        iMaxNumber); 
  } 
} 

正如我们在上面的方法中所看到的,我们有以下基本情况来定义递归链的结束:

if (iStartIndex == intArray.Length - 1) 
{ 
  return intArray[iStartIndex]; 
} 

如果我们运行上述代码,我们将得到与之前方法中得到的相同结果,如下面的控制台截图所示:

将迭代重构为递归

现在,让我们来看一下以下流程,了解我们如何在使用递归函数时得到这个结果:

Array = { 8, 10, 24, -1, 98, 47, -101, 39 }; 
Array.Length - 1 = 7 
int iMaxNumber = FindMaxRecursive(Array, 0) 
  (iStartIndex = 0) != 7 
  return Max(8, FindMaxRecursive(Array, 1)) 
    (iStartIndex = 1) != 7 
    return Max(10, FindMaxRecursive(Array, 2)) 
      (iStartIndex = 2) != 7 
      return Max(24, FindMaxRecursive(Array, 3)) 
        (iStartIndex = 3) != 7 
        return Max(-1, FindMaxRecursive(Array, 4)) 
          (iStartIndex = 4) != 7 
           return Max(98, FindMaxRecursive(Array, 5)) 
            (iStartIndex = 5) != 7 
            return Max(47, FindMaxRecursive(Array, 6)) 
              (iStartIndex = 6) != 7 
              return Max(-101, FindMaxRecursive(Array, 7)) 
                (iStartIndex = 7) == 7 
                return 39 
              return Max(-101, 39) = 39 
            return Max(47, 39) = 47 
          return Max(98, 47) = 98 
        return Max(-1, 98) = 98 
      return Max(24, 98) = 98 
    return Max(10, 98) = 98 
  return Max(8, 98) = 98 
iMaxNumber = 98 

使用上述流程,我们可以区分每次调用FindMaxRecursive()方法时得到的最大数的每个状态变化。然后,我们可以证明给定数组中的最大数是98

使用尾递归

在我们之前讨论的GetFactorial()方法中,使用传统递归来计算阶乘数。这种递归模型首先执行递归调用并返回值,然后计算结果。使用这种递归模型,我们在递归调用完成之前不会得到结果。

除了传统的递归模型,我们还有另一种称为尾递归的递归。尾调用成为函数中的最后一件事,并且在递归之后不执行任何操作。让我们来看看以下代码,我们可以在TailRecursion.csproj项目中找到:

public partial class Program 
{ 
  public static void TailCall(int iTotalRecursion) 
  { 
    Console.WriteLine("Value: " + iTotalRecursion); 
    if (iTotalRecursion == 0) 
    { 
      Console.WriteLine("The tail is executed"); 
      return; 
    } 
    TailCall(iTotalRecursion - 1); 
  } 
} 

从上面的代码中,当iTotalRecursion达到0时,尾部被执行,如下面的代码片段所示:

if (iTotalRecursion == 0) 
{ 
  Console.WriteLine("The tail is executed"); 
  return; 
} 

如果我们运行上述的TailCall()方法,并为iTotalRecursion参数传递5,我们将在控制台上得到以下输出:

使用尾递归

现在,让我们来看看在这段代码中每次递归调用时状态的变化:

TailCall(5) 
  (iTotalRecursion = 5) != 0 
  TailCall(4) 
    (iTotalRecursion = 4) != 0 
    TailCall(3) 
      iTotalRecursion = 3) != 0 
      TailCall(2) 
        iTotalRecursion = 2) != 0 
        TailCall(1) 
          iTotalRecursion = 1) != 0 
          TailCall(0) 
            iTotalRecursion = 0) == 0 
            Execute the process in tail 
        TailCall(1) => nothing happens 
      TailCall(2) => nothing happens 
    TailCall(3) => nothing happens 
  TailCall(4) => nothing happens 
TailCall(5) => nothing happens 

从递归的流程中,该过程仅在最后的递归调用中运行。之后,其他递归调用不会发生任何事情。换句话说,我们可以得出以下流程:

TailCall(5) 
   (iTotalRecursion = 5) != 0 
  TailCall(4) 
    (iTotalRecursion = 4) != 0 
    TailCall(3) 
      iTotalRecursion = 3) != 0 
      TailCall(2) 
        iTotalRecursion = 2) != 0 
        TailCall(1) 
          iTotalRecursion = 1) != 0 
          TailCall(0) 
            iTotalRecursion = 0) == 0 
            Execute the process in tail 
Finish! 

现在,我们的尾递归流程显而易见。尾递归的思想是尽量减少堆栈的使用,因为堆栈有时是我们拥有的昂贵资源。使用尾递归,代码不需要记住上次返回时必须返回的状态,因为在这种情况下,它在累加器参数中有临时结果。接下来的主题是遵循尾递归的两种风格;它们是累加器传递风格APS)和续传风格CPS)。

累加器传递风格

累加器传递风格APS)中,递归首先执行计算,执行递归调用,然后将当前步骤的结果传递给下一个递归步骤。让我们来看看我们从GetFactorial()方法重构的尾递归代码的累加器传递风格,我们可以在AccumulatorPassingStyle.csproj项目中找到:

public partial class Program 
{ 
  public static int GetFactorialAPS(int intNumber, 
    int accumulator = 1) 
  { 
    if (intNumber == 0) 
    { 
      return accumulator; 
    } 
    return GetFactorialAPS(intNumber - 1, 
       intNumber * accumulator); 
  } 
} 

GetFactorial()方法相比,GetFactorialAPS()方法现在有一个名为 accumulator 的第二个参数。由于阶乘0的结果是1,我们将默认值 1 赋给 accumulator 参数。现在它不仅返回一个值,而且每次调用递归函数时都返回阶乘的计算结果。为了证明这一点,考虑我们有以下代码来调用GetFactorialAPS()方法:

public partial class Program 
{ 
  private static void GetFactorialOfFiveUsingAPS() 
  { 
    int i = GetFactorialAPS(5); 
    Console.WriteLine( 
       "5! (using GetFactorialAPS) is {0}",i); 
  } 
} 

如果我们运行上述方法,我们将在控制台上得到以下输出:

累加器传递风格

现在,让我们检查GetFactorialAPS()方法的每个调用,以查看程序的以下流程中方法内部的状态变化:

int i = GetFactorialAPS(5, 1) 
  accumulator = 1 
  (intNumber = 5) != 0 
  return GetFactorialAPS(4, 5 * 1) 
    accumulator = 5 * 1 = 5 
    (intNumber = 4) != 0 
    return GetFactorialAPS(3, 4 * 5) 
      accumulator = 4 * 5 = 20 
      (intNumber = 3) != 0 
      return GetFactorialAPS(2, 3 * 20) 
        accumulator = 3 * 20 = 60 
        (intNumber = 2) != 0 
        return GetFactorialAPS(1, 2 * 60) 
          accumulator = 2 * 60 = 120 
          (intNumber = 1) != 0 
          return GetFactorialAPS(0, 1 * 120) 
            accumulator = 1 * 120 = 120 
            (intNumber = 0) == 0 
            return accumulator 
          return 120 
        return 120 
      return 120 
    return 120 
  return 120 
i = 120 

从上述流程中可以看出,由于每次调用时都执行计算,我们现在在函数的最后一次调用中得到了计算的结果,当intNumber参数达到0时,如下面的代码片段所示:

return GetFactorialTailRecursion(0, 1 * 120) 
  accumulator = 1 * 120 = 120 
  (intNumber = 0) == 0 
  return accumulator 
return 120 

我们还可以将上述的GetFactorialAPS()方法重构为GetFactorialAPS2()方法,以便不返回任何值,这样尾递归的 APS 将变得更明显。代码将如下所示:

public partial class Program 
{ 
  public static void GetFactorialAPS2( 
      int intNumber,int accumulator = 1) 
  { 
    if (intNumber == 0) 
    { 
      Console.WriteLine("The result is " + accumulator); 
      return; 
    } 
    GetFactorialAPS2(intNumber - 1, intNumber * accumulator); 
  } 
} 

假设我们有以下GetFactorialOfFiveUsingAPS2()方法来调用GetFactorialAPS2()方法:

public partial class Program 
{ 
  private static void GetFactorialOfFiveUsingAPS2() 
  { 
    Console.WriteLine("5! (using GetFactorialAPS2)"); 
    GetFactorialAPS2(5); 
  } 
} 

因此,如果我们调用上述的GetFactorialOfFiveUsingAPS2()方法,我们将在控制台上得到以下输出:

累加器传递风格

现在,GetFactorialAPS2()方法的流程变得更清晰,如下面的程序流程所示:

GetFactorialAPS2(5, 1) 
  accumulator = 1 
  (intNumber = 5) != 0 
  GetFactorialAPS2(4, 5 * 1) 
    accumulator = 5 * 1 = 5 
    (intNumber = 4) != 0 
    GetFactorialAPS2(3, 4 * 5) 
      accumulator = 4 * 5 = 20 
      (intNumber = 3) != 0 
      GetFactorialAPS2(2, 3 * 20) 
        accumulator = 3 * 20 = 60 
        (intNumber = 2) != 0 
        GetFactorialAPS2(1, 2 * 60) 
          accumulator = 2 * 60 = 120 
          (intNumber = 1) != 0 
          GetFactorialAPS2(0, 1 * 120) 
            accumulator = 1 * 120 = 120 
            (intNumber = 0) == 0 
            Show the accumulator value 
Finish! 

从上述流程中,我们可以看到每次调用GetFactorialAPS2()方法时都会计算 accumulator。这种递归类型的结果是,我们不再需要使用堆栈,因为函数在调用自身时不需要记住其起始位置。

继续传递风格

继续传递风格CPS)与 APS 具有相同的目的,即使用尾调用实现递归函数,但在处理操作时具有显式的继续。CPS 函数的返回值将传递给继续函数。

现在,让我们将GetFactorial()方法重构为以下GetFactorialCPS()方法,我们可以在ContinuationPassingStyle.csproj项目中找到它:

public partial class Program 
{ 
  public static void GetFactorialCPS(int intNumber, Action<int> 
         actCont) 
  { 
    if (intNumber == 0) 
      actCont(1); 
    else 
      GetFactorialCPS(intNumber - 1,x => actCont(intNumber * x)); 
  } 
} 

正如我们所看到的,与GetFactorialAPS()方法中使用 accumulator 不同,我们现在使用Action<T>来委托一个匿名方法,这个方法作为继续使用。假设我们有以下代码来调用GetFactorialCPS()方法:

public partial class Program 
{ 
  private static void GetFactorialOfFiveUsingCPS() 
  { 
    Console.Write("5! (using GetFactorialCPS) is "); 
    GetFactorialCPS(5,  x => Console.WriteLine(x)); 
  } 
} 

如果我们运行上述的GetFactorialOfFiveUsingCPS()方法,我们将在控制台上得到以下输出:

继续传递风格

实际上,与GetFactorial()方法或GetFactorialAPS2()方法相比,我们得到了相同的结果。然而,递归的流程现在变得有点不同,如下面的解释所示:

GetFactorialCPS(5, Console.WriteLine(x)) 
  (intNumber = 5) != 0 
  GetFactorialCPS(4, (5 * x)) 
    (intNumber = 4) != 0 
    GetFactorialCPS(3, (4 * x)) 
      (intNumber = 3) != 0 
      GetFactorialCPS(2, (3 * x)) 
        (intNumber = 2) != 0 
        GetFactorialCPS(1, (2 * x)) 
          (intNumber = 1) != 0 
          GetFactorialCPS(0, (1 * x)) 
            (intNumber = 0) != 0 
            GetFactorialCPS(0, (1 * 1)) 
          (1 * 1 = 1) 
        (2 * 1 = 2) 
      (3 * 2 = 6) 
    (4 * 6 = 24) 
  (5 * 24 = 120) 
Console.WriteLine(120) 

现在,每次递归的返回值都传递给继续过程,即Console.WriteLine()函数。

间接递归比直接递归

我们之前讨论过递归方法。实际上,在我们之前的讨论中,我们应用了直接递归,因为我们只处理了一个单一的方法,并且一遍又一遍地调用它,直到基本情况被执行。然而,还有另一种递归类型,称为间接递归。间接递归涉及至少两个函数,例如函数 A 和函数 B。在间接递归的应用中,函数 A 调用函数 B,然后函数 B 再次调用函数 A。这被认为是递归,因为当方法 B 调用方法 A 时,函数 A 实际上是活动的,当它再次调用函数 B 时。换句话说,当函数 B 再次调用函数 A 时,函数 A 的调用尚未完成。让我们来看看下面的代码,它演示了我们可以在IndirectRecursion.csproj项目中找到的间接递归:

public partial class Program 
{ 
  private static bool IsOdd(int targetNumber) 
  { 
    if (targetNumber == 0) 
    { 
      return false; 
    } 
    else 
    { 
      return IsEven(targetNumber - 1); 
    } 
  } 
  private static bool IsEven(int targetNumber) 
  { 
    if (targetNumber == 0) 
    { 
      return true; 
    } 
    else 
    { 
      return IsOdd(targetNumber - 1); 
    } 
  } 
} 

在上面的代码中,我们有两个函数:IsOdd()IsEven()。每个函数在比较结果为false时都会调用另一个函数。当targetNumber不为零时,IsOdd()函数将调用IsEven()IsEven()函数也是如此。每个函数的逻辑都很简单。例如,IsOdd()方法通过调查前一个数字targetNumber - 1是否为偶数来决定targetNumber是否为奇数。同样,IsEven()方法通过调查前一个数字是否为奇数来决定targetNumber是否为偶数。它们都将targetNumber减一,直到它变为零,由于零是一个偶数,现在很容易确定targetNumber是奇数还是偶数。现在,我们添加以下代码来检查数字5是偶数还是奇数:

public partial class Program 
{ 
  private static void CheckNumberFive() 
  { 
    Console.WriteLine("Is 5 even number? {0}", IsEven(5)); 
  } 
} 

如果我们运行上述的CheckNumberFive()方法,将在控制台上得到以下输出:

间接递归与直接递归

现在,为了更清楚地理解,让我们来看看涉及IsOdd()IsEven()方法的以下间接递归流程:

IsEven(5) 
  (targetNumber = 5) != 0 
  IsOdd(4) 
    (targetNumber = 4) != 0 
    IsEven(3) 
      (targetNumber = 3) != 0 
      IsOdd(2) 
        (targetNumber = 2) != 0 
        IsEven(1) 
          (targetNumber = 1) != 0 
            IsOdd(0) 
            (targetNumber = 0) == 0 
              Result = False 

从上面的流程中,我们可以看到,当我们检查数字 5 是偶数还是奇数时,我们向下移动到数字 4 并检查它是否为奇数。然后我们检查数字 3,依此类推,直到我们达到 0。通过达到 0,我们可以很容易地确定它是奇数还是偶数。

使用 LINQ Aggregate 进行函数式递归

当我们处理阶乘公式时,我们可以使用 LINQ Aggregate 将我们的递归函数重构为函数式方法。LINQ Aggregate 将累积给定的序列,然后我们将从累加器中得到递归的结果。在第一章中,我们已经进行了这种重构。让我们借用该章节的代码来分析Aggregate方法的使用。下面的代码将使用Aggregate方法,我们可以在RecursionUsingAggregate.csproj项目中找到:

public partial class Program 
{ 
  private static void GetFactorialAggregate(int intNumber) 
  { 
    IEnumerable<int> ints =  
       Enumerable.Range(1, intNumber); 
    int factorialNumber =  
       ints.Aggregate((f, s) => f * s); 
    Console.WriteLine("{0}! (using Aggregate) is {1}",
       intNumber, factorialNumber); 
  } 
} 

如果我们运行上述的GetFactorialAggregate()方法,并将5作为参数传递,将在控制台上得到以下输出:

使用 LINQ Aggregate 进行函数式递归

正如我们在上面的控制台截图中所看到的,与非累积递归相比,我们得到了完全相同的结果。

深入研究 Aggregate 方法

正如我们之前讨论的,Aggregate方法将累积给定的序列。让我们来看看下面的代码,我们可以在AggregateExample.csproj项目文件中找到,以演示Aggregate方法的工作原理:

public partial class Program 
{ 
  private static void AggregateInt() 
  { 
    List<int> listInt = new List<int>() { 1, 2, 3, 4, 5, 6 }; 
    int addition = listInt.Aggregate( 
       (sum, i) => sum + i); 
    Console.WriteLine("The sum of listInt is " + addition); 
  } 
} 

从上面的代码中,我们可以看到我们有一个int数据类型的列表,其中包含从 1 到 6 的数字。然后我们调用Aggregate方法来求和listInt的成员。以下是上述代码的流程:

(sum, i) => sum + i 
sum = 1 
sum = 1 + 2 
sum = 3 + 3 
sum = 6 + 4 
sum = 10 + 5 
sum = 15 + 6 
sum = 21 
addition = sum 

如果我们运行上述的AggregateInt()方法,将在控制台上得到以下输出:

深入研究 Aggregate 方法

实际上,Aggregate方法不仅可以添加数字,还可以添加字符串。让我们来看下面的代码,演示了使用Aggregate方法来添加字符串序列:

public partial class Program 
{ 
  private static void AggregateString() 
  { 
    List<string> listString = new List<string>()
      {"The", "quick", "brown", "fox", "jumps", "over",
              "the", "lazy", "dog"};
    string stringAggregate = listString.Aggregate((strAll, str) => 
              strAll + " " + str); 
    Console.WriteLine(stringAggregate); 
  } 
} 

如果我们运行前面的AggregateString()方法,我们将在控制台上得到以下输出:

深入研究 Aggregate 方法

以下是我们可以在 MSDN 中找到的Aggregate方法的声明:

public static TSource Aggregate<TSource>( 
  this IEnumerable<TSource> source, 
  Func<TSource, TSource, TSource> func 
) 

以下是基于先前声明的AggregateUsage()方法的流程:

(strAll, str) => strAll + " " + str 
strAll = "The" 
strAll = strAll + " " + str 
strAll = "The" + " " + "quick" 
strAll = "The quick" + " " + "brown" 
strAll = "The quick brown" + " " + "fox" 
strAll = "The quick brown fox" + " " + "jumps" 
strAll = "The quick brown fox jumps" + " " + "over" 
strAll = "The quick brown fox jumps over" + " " + "the" 
strAll = "The quick brown fox jumps over the" + " " + "lazy" 
strAll = "The quick brown fox jumps over the lazy" + " " + "dog" 
strAll = "The quick brown fox jumps over the lazy dog" 
stringAggregate = str 

从前面的流程中,我们可以使用Aggregate方法连接listString中的所有字符串。这证明不仅可以处理int数据类型,还可以处理字符串数据类型。

摘要

虽然 C#有一个使用forwhile循环迭代序列的功能,但最好我们使用递归来迭代序列来接触函数式编程。我们已经讨论了递归例程的工作原理,并将迭代重构为递归。我们知道在递归中,我们有一个将定义递归链结束的基本情况。

在传统的递归模型中,递归调用首先执行,然后返回值,然后计算结果。结果直到递归调用完成后才会显示。而尾递归在递归之后根本不做任何事情。尾递归有两种风格;它们是 APS 和 CPS。

除了直接递归,我们还讨论了间接递归。间接递归涉及至少两个函数。然后,我们将递归应用到使用 Aggregrate LINQ 运算符的函数方法中。我们还深入研究了 Aggregate 运算符以及它的工作原理。

在下一章中,我们将讨论优化技术,使我们的代码更加高效。我们将使用懒惰思维,这样代码将在完美的时间执行,还将使用缓存技术,这样代码不需要每次都执行。

第八章:使用懒惰和缓存技术优化代码

我们在上一章中讨论了递归,它帮助我们轻松地迭代序列。此外,我们需要讨论优化代码,因为这是一个必要的技术,如果我们想要开发一个好的程序。在函数方法中,我们可以使用懒惰和缓存技术来使我们的代码更有效,从而使其运行更快。通过讨论懒惰和缓存技术,我们将能够开发出高效的代码。在本章中,我们将讨论以下主题以了解更多关于懒惰和缓存技术的知识:

  • 在我们的代码中实现懒惰:懒惰枚举、懒惰评估、非严格评估和懒惰初始化

  • 懒惰的好处

  • 使用预计算和记忆化缓存昂贵的资源

懒惰的介绍

当我们谈论日常活动中的懒惰时,我们可能会想到一些我们不做但实际上必须做的事情。或者,我们可能因为懒惰而推迟做某事。在函数式编程中,懒惰类似于我们在日常活动中的懒惰。由于懒惰思维的概念,特定代码的执行被推迟。在第五章中,使用 LINQ 轻松查询任何集合,我们提到 LINQ 在查询数据时实现了延迟执行。

查询只有在枚举时才会执行。现在,让我们讨论一下我们可以在函数方法中使用的懒惰概念。

懒惰枚举

在.NET 框架中,有一些枚举数据集合的技术,例如数组和List<T>。然而,从内在上来说,它们是急切的评估,因为在数组中,我们必须先定义其大小,然后填充分配的内存,然后再使用它。List<T>与数组相比具有类似的概念。它采用了数组机制。这两种枚举技术之间的区别在于我们可以很容易地扩展List<T>的大小,而不是数组。

相反,.NET 框架有IEnumerable<T>来枚举数据集合,并且幸运的是,它将被懒惰地评估。实际上,数组和List<T>实现了IEnumerable<T>接口,但由于它必须由数据填充,因此必须急切地评估。在第五章中,使用 LINQ 轻松查询任何集合,我们在处理 LINQ 时使用了这个IEnumerable<T>接口。

IEnumerable<T>接口实现了IEnumerable接口,其定义如下:

public interface IEnumerable<out T> : IEnumerable 

IEnumerable<T>接口只有一个方法:GetEnumerator()。该方法的定义与下面的代码中所示的类似:

IEnumerator<T> GetEnumerator() 

正如你所看到的,GetEnumerator()方法返回IEnumerator<T>数据类型。该类型只有三种方法和一个属性。以下是它具有的方法和属性:

  • Current:这是一个存储枚举器当前位置的集合元素的属性。

  • Reset():这是一个将枚举器设置为初始位置的方法,即在集合的第一个元素之前。初始位置的索引通常是-1(减一)。

  • MoveNext():这是一个将枚举器移动到下一个集合元素的方法。

  • Dispose():这是一个释放、释放或重置非托管资源的方法。它是从IDisposable接口继承而来的。

现在,让我们玩玩斐波那契算法,它将生成无限的数字。该算法将通过添加前两个元素来生成序列。在数学术语中,该公式可以定义如下:

Fn = Fn-1 + Fn-2 

该算法的计算的前两个数字可以是 0 和 1 或 1 和 1。

使用这个算法,我们将证明IEnumerable接口是一种惰性求值。因此,我们创建了一个名为FibonacciNumbers的类,它实现了IEnumerable<Int64>接口,我们可以在LazyEnumeration.csproj项目中找到,如下面的代码所示:

public partial class Program 
{ 
  public class FibonacciNumbers 
    : IEnumerable<Int64> 
  { 
    public IEnumerator<Int64> GetEnumerator() 
    { 
      return new FibEnumerator(); 
    } 
    IEnumerator IEnumerable.GetEnumerator() 
    { 
      return GetEnumerator(); 
    } 
  } 
} 

由于FibonacciNumbers类实现了IEnumerable<T>接口,它具有我们之前讨论过的GetEnumerator()方法来枚举数据集合。并且因为IEnumerable<T>接口实现了IEnumerator<T>接口,我们创建了FibEnumerator类,如下面的代码所示:

public partial class Program 
{ 
  public class FibEnumerator 
    : IEnumerator<Int64> 
  { 
    public FibEnumerator() 
    { 
      Reset(); 
    } 
    // To get the current element 
    public Int64 Current { get; private set; } 
    // To get the last element 
    Int64 Last { get; set; } 
    object IEnumerator.Current 
    { 
      get 
      { 
        return Current; 
      } 
    } 
    public void Dispose() 
    { 
      ; // Do Nothing 
    } 
    public bool MoveNext() 
    { 
      if (Current == -1) 
      { 
        // Fibonacci algorithm 
        // F0 = 0 
        Current = 0; 
      } 
      else if (Current == 0) 
      { 
        // Fibonacci algorithm 
        // F1 = 1 
        Current = 1; 
      } 
      else 
      { 
        // Fibonacci algorithm 
        // Fn = F(n-1) + F(n-2) 
        Int64 next = Current + Last; 
        Last = Current; 
        Current = next; 
      } 
      // It's never ending sequence, 
      // so the MoveNext() always TRUE 
      return true; 
    } 
    public void Reset() 
    { 
      // Back to before first element 
      // which is -1 
      Current = -1; 
    } 
  } 
} 

现在,我们有了实现IEnumerator<T>接口的FibEnumerator类。由于该类实现了IEnumerator<T>,它具有我们已经讨论过的Reset()MoveNext()Dispose()方法。它还具有从IEnumerator<T>接口的实现中添加的Current属性。我们添加了Last属性来保存最后一个当前数字。

现在,是时候创建调用者来实例化FibonacciNumbers类了。我们可以创建GetFibonnacciNumbers()函数,其实现类似于以下代码所示:

public partial class Program 
{ 
  private static void GetFibonnacciNumbers( 
    int totalNumber) 
  { 
    FibonacciNumbers fibNumbers = 
      new FibonacciNumbers(); 
    foreach (Int64 number in 
      fibNumbers.Take(totalNumber)) 
    { 
      Console.Write(number); 
      Console.Write("\t"); 
    } 
    Console.WriteLine(); 
  } 
} 

因为FibonacciNumbers类将枚举无限数字,我们必须使用Take()方法,如下面的代码片段所示,以免创建无限循环:

foreach (Int64 number in 
  fibNumbers.Take(totalNumber)) 

假设我们需要从序列中枚举 40 个数字;我们可以将 40 作为参数传递给GetFibonnacciNumbers()函数,如下所示:

GetFibonnacciNumbers(40) 

如果我们运行上述函数,将在控制台上获得以下输出:

惰性枚举

我们可以在控制台上获得前面的输出,因为IEnumerable是一种惰性求值。这是因为只有在要求时才会调用MoveNext()方法来计算结果。想象一下,如果它不是惰性的并且总是被调用;那么,我们之前的代码将会旋转并导致无限循环。

惰性求值

我们在惰性求值中的一个简单例子是当我们处理两个布尔语句并需要比较它们时。让我们看一下以下代码,它演示了我们可以在SimpleLazyEvaluation.csproj项目中找到的惰性求值:

public partial class Program 
{ 
  private static MemberData GetMember() 
  { 
    MemberData member = null; 
    try 
    { 
      if (member != null || member.Age > 50) 
      { 
        Console.WriteLine("IF Statement is TRUE"); 
        return member; 
      } 
      else 
      { 
        Console.WriteLine("IF Statement is FALSE"); 
        return null; 
      } 
    } 
    catch (Exception e) 
    { 
      Console.WriteLine("ERROR: " + e.Message); 
      return null; 
    } 
  } 
} 

这是我们在前面代码中使用的MemberData类:

public class MemberData 
{ 
  public string Name { get; set; } 
  public string Gender { get; set; } 
  public int Age { get; set; } 
} 

如果我们运行前面的GetMember()方法,将在控制台上获得以下输出:

惰性求值

我们知道,在布尔表达式中使用||(OR)运算符时,如果至少有一个表达式为TRUE,则结果为TRUE。现在看一下以下代码片段:

if (member != null || member.Age > 50) 

在前面的例子中,当编译器发现成员!= nullFALSE时,它会评估另一个表达式,即member.Age > 50。由于成员为空,它没有Age属性;因此,当我们尝试访问此属性时,它将抛出异常。

现在,让我们将前面的代码片段重构为以下代码,使用&&(AND)运算符:

if (member != null && member.Age > 50) 

名为GetMemberANDOperator()的完整方法将如下所示:

public partial class Program 
{ 
  private static MemberData GetMemberANDOperator() 
  { 
    MemberData member = null; 
    try 
    { 
      if (member != null && member.Age > 50) 
      { 
        Console.WriteLine("IF Statement is TRUE"); 
        return member; 
      } 
      else 
      { 
        Console.WriteLine("IF Statement is FALSE"); 
        return null; 
      } 
    } 
    catch (Exception e) 
    { 
      Console.WriteLine("ERROR: " + e.Message); 
      return null; 
    } 
  } 
} 

如果我们运行前面的GetMemberANDOperator()方法,将在控制台上获得以下输出:

惰性求值

现在,if语句已成功执行,并在评估后得出FALSE。然而,在这种情况下,member.Age > 50表达式从未被评估,因此不会抛出异常。member.Age > 50表达式不被评估的原因是编译器太懒了,因为第一个表达式member != nullFALSE,而这个&&逻辑操作的结果将始终为FALSE,而不管其他表达式的结果如何。现在我们可以说,懒惰是在可以仅使用一个表达式决定结果时忽略另一个表达式。

非严格求值

有些人可能认为惰性评估与非严格评估是同义词。然而,实际上并不是同义词,因为在惰性评估中,如果不需要特定表达式的评估,它将被忽略,而在非严格评估中将应用评估的简化。让我们看一下下面的代码,以区分严格和非严格评估,我们可以在NonStrictEvaluation.csproj项目中找到:

public partial class Program 
{ 
  private static int OuterFormula(int x, int yz) 
  { 
    Console.WriteLine( 
      String.Format( 
        "Calculate {0} + InnerFormula({1})", 
        x, 
        yz)); 
    return x * yz; 
  } 
  private static int InnerFormula(int y, int z) 
  { 
    Console.WriteLine( 
      String.Format( 
        "Calculate {0} * {1}", 
        y, 
        z 
        )); 
    return y * z; 
  } 
} 

在前面的代码中,我们将计算x + (y * z)的公式。InnerFormula()函数将计算yz的乘法,而OuterFormula()函数将计算xy * z的结果的加法。在严格评估中评估公式时,我们首先计算(y * z)表达式以检索值,然后将结果添加到x。代码将如下StrictEvaluation()函数所示:

public partial class Program 
{ 
  private static void StrictEvaluation() 
  { 
    int x = 4; 
    int y = 3; 
    int z = 2; 
    Console.WriteLine("Strict Evaluation"); 
    Console.WriteLine( 
      String.Format( 
        "Calculate {0} + ({1} * {2})",x, y, z)); 
    int result = OuterFormula(x, InnerFormula(y, z)); 
    Console.WriteLine( 
      String.Format( 
        "{0} + ({1} * {2}) = {3}",x, y, z, result)); 
    Console.WriteLine(); 
  } 
} 

正如您在前面的代码片段中所看到的,我们调用OuterFormula()函数如下所示:

int result = OuterFormula(x, InnerFormula(y, z)); 

对于我们之前讨论的严格评估,我们在控制台上得到的输出将如下所示:

非严格评估

正如您在前面的图中所看到的,当我们计算4 + (3 * 2)时,我们首先计算(3 * 2)的结果,然后在获得结果后,将其添加到4

现在,让我们与非严格评估进行比较。在非严格评估中,+运算符首先被简化,然后我们简化内部公式(y * z)。我们将看到评估将从外到内开始。现在让我们将前面的OuterFormula()函数重构为OuterFormulaNonStrict()函数,如下面的代码所示:

public partial class Program 
{ 
  private static int OuterFormulaNonStrict( 
    int x, 
    Func<int, int, int> yzFunc) 
  { 
    int y = 3; 
    int z = 2; 
    Console.WriteLine( 
      String.Format( 
        "Calculate {0} + InnerFormula ({1})", 
        x, 
        y * z 
        )); 
    return x * yzFunc(3, 2); 
  } 
} 

正如您所看到的,我们将函数的第二个参数修改为Func<int, int, int>委托。我们将从NonStrictEvaluation()函数中调用OuterFormulaNonStrict(),如下所示:

public partial class Program 
{ 
  private static void NonStrictEvaluation() 
  { 
    int x = 4; 
    int y = 3; 
    int z = 2; 
    Console.WriteLine("Non-Strict Evaluation"); 
    Console.WriteLine( 
      String.Format( 
        "Calculate {0} + ({1} * {2})",x, y, z)); 
    int result = OuterFormulaNonStrict(x, InnerFormula); 
    Console.WriteLine( 
      String.Format( 
        "{0} + ({1} * {2}) = {3}",x, y, z, result)); 
    Console.WriteLine(); 
  } 
} 

在前面的代码中,我们可以看到我们将InnerFormula()函数传递给了OuterFormulaNonStrict()函数的第二个参数,如下面的代码片段所示:

int result = OuterFormulaNonStrict(x, InnerFormula); 

在前面的代码片段中,将使用非严格评估来评估表达式。为了证明这一点,让我们运行NonStrictEvaluation()函数,我们将在控制台上得到以下输出:

非严格评估

我们可以看到,我们的表达式是从外到内进行评估的。即使尚未检索到InnerFormula()函数的结果,也会首先运行OuterFormulaNonStrict()函数。如果我们连续运行OuterFormula()函数和OuterFormulaNonStrict()函数,我们将会清楚地看到评估顺序的不同,如下面的输出截图所示:

非严格评估

现在,我们可以比较一下。在严格评估中,首先运行(3 * 2)的计算,然后将其输入到(4 + InnerFormula())表达式中,而在非严格评估中,先运行(4 + InnerFormula())表达式,然后再计算(3 * 2)

惰性初始化

延迟初始化是一种优化技术,其中对象的创建被推迟直到使用它。这意味着我们可以定义一个对象,但如果尚未访问对象的成员,则不会初始化该对象。C#在 C# 4.0 中引入了Lazy<T>类,我们可以使用它来延迟初始化对象。现在,让我们看一下下面的代码,以演示我们可以在LazyInitialization.csproj项目中找到的延迟初始化:

public partial class Program 
{ 
  private static void LazyInitName(string NameOfPerson) 
  { 
    Lazy<PersonName> pn = 
      new Lazy<PersonName>( 
        () => 
          new PersonName(NameOfPerson)); 
    Console.WriteLine( 
      "Status: PersonName has been defined."); 
    if (pn.IsValueCreated) 
    { 
      Console.WriteLine( 
        "Status: PersonName has been initialized."); 
    } 
    else 
    { 
      Console.WriteLine( 
        "Status: PersonName hasn't been initialized."); 
    } 
    Console.WriteLine( 
      String.Format( 
        "Status: PersonName.Name = {0}", 
        (pn.Value as PersonName).Name)); 
    if (pn.IsValueCreated) 
    { 
      Console.WriteLine( 
        "Status: PersonName has been initialized."); 
    } 
    else 
    { 
      Console.WriteLine( 
        "Status: PersonName hasn't been initialized."); 
    } 
  } 
} 

我们定义PersonName类如下:

public class PersonName 
{ 
  public string Name { get; set; } 
  public PersonName(string name) 
  { 
    Name = name; 
    Console.WriteLine( 
      "Status: PersonName constructor has been called." 
      ); 
  } 
} 

正如您在前面的LazyInitName()函数实现中所看到的,我们使用Lazy<T>类来延迟初始化PersonName对象,如下面的代码片段所示:

Lazy<PersonName> pn = 
  new Lazy<PersonName>( 
    () => 
      new PersonName(NameOfPerson)); 

通过这样做,PersonName在定义pn变量后实际上并没有初始化,就像我们直接使用以下代码定义类时通常得到的那样:

PersonName pn = 
  new PersonName( 
    NameOfPerson); 

相反,使用延迟初始化,我们访问对象的成员以初始化它,如前所述。Lazy<T>有一个名为Value的属性,用于获取Lazy<T>实例的值。它还有一个IsValueCreated属性,用于指示是否已为此Lazy<T>实例创建了值。在LazyInitName()函数中,我们使用Value属性,如下所示:

Console.WriteLine( 
  String.Format( 
    "Status: PersonName.Name = {0}", 
    (pn.Value as PersonName).Name)); 

我们使用(pn.Value as PersonName).Name来访问pn变量实例化的PersonName类的Name属性。我们使用IsValueCreated属性来证明PersonName类是否已经初始化,如下所示:

if (pn.IsValueCreated) 
{ 
  Console.WriteLine( 
    "Status: PersonName has been initialized."); 
} 
else 
{ 
  Console.WriteLine( 
    "Status: PersonName hasn't been initialized."); 
} 

现在让我们运行LazyInitName()函数,并将Matthew Maxwell作为其参数传递,如下所示:

LazyInitName("Matthew Maxwell"); 

我们将在控制台上获得以下输出:

延迟初始化

从前面的截图中,我们获得了五行信息。我们得到的第一行是在定义PersonName时。然后我们检查IsValueCreated属性的值,以找出PersonName是否已经初始化。我们得到了FALSE的结果,这意味着它还没有初始化;所以我们在控制台上得到了第二行信息。接下来的两行是我们从延迟初始化中得到的有趣的东西。当我们访问Lazy<T>类的Value属性以检索PersonName实例的Name属性时,代码在访问PersonName类的Name属性之前调用PersonName的构造函数。这就是为什么我们在前面的控制台上有第 3 行和第 4 行。在我们再次检查IsValueCreated属性之后,我们发现PersonName现在已经初始化,并且pn变量具有PersonName的实例。

懒惰的优缺点

到目前为止,我们已经了解了懒惰。我们还可以详细说明懒惰的优点,比如:

  • 我们不需要为我们不使用的功能支付初始化时间

  • 程序执行变得更加高效,因为有时,在功能性方法中,执行顺序与命令式方法相比并不重要

  • 懒惰会使程序员通过编写高效的代码来编写更好的代码

除了优点之外,懒惰也有缺点,比如:

  • 应用程序的流程很难预测,有时我们会失去对应用程序的控制

  • 懒惰中的代码复杂性可能会导致簿记开销

缓存昂贵的资源

有时,我们必须在程序中创建昂贵的资源。如果我们只做一次,这不是问题。如果我们为同一个函数一遍又一遍地做同样的事情,那将是一个大问题。幸运的是,在功能性方法中,如果我们传递相同的输入或参数,我们将获得相同的输出。然后,我们可以缓存这些昂贵的资源,并在传递相同的参数时再次使用它。现在我们将讨论预计算和记忆化以缓存资源。

执行初始计算

我们拥有的缓存技术之一是预计算,它执行初始计算以创建查找表。当执行特定过程时,该查找表用于避免重复计算。现在我们将创建代码来比较使用和不使用预计算的过程中的差异。让我们看一下以下代码,在Precomputation.csproj项目中可以找到:

public partial class Program 
{ 
  private static void WithoutPrecomputation() 
  { 
    Console.WriteLine("WithoutPrecomputation()"); 
    Console.Write( 
      "Choose number from 0 to 99 twice "); 
    Console.WriteLine( 
      "to find the power of two result: "); 
    Console.Write("First Number: "); 
    int iInput1 = Convert.ToInt32(Console.ReadLine()); 
    Console.Write("Second Number: "); 
    int iInput2 = Convert.ToInt32(Console.ReadLine()); 
    int iOutput1 = (int) Math.Pow(iInput1, 2); 
    int iOutput2 = (int)Math.Pow(iInput2, 2); 
    Console.WriteLine( 
      "2 the power of {0} is {1}", 
      iInput1, 
      iOutput1); 
    Console.WriteLine( 
      "2 the power of {0} is {1}", 
      iInput2, 
      iOutput2); 
  } 
} 

前面简单的WithoutPrecomputation()函数将计算我们从 0 到 99 输入的两个数字的平方。假设我们要计算数字1985,我们将在控制台窗口上获得以下输出:

执行初始计算

如您所见,该函数已经很好地完成了其工作。它使用以下代码片段向用户请求两个输入数字:

Console.Write("First Number: "); 
int iInput1 =Convert.ToInt32(Console.ReadLine()); 
Console.Write("Second Number: "); 
int iInput2 = Convert.ToInt32(Console.ReadLine()); 

它使用System命名空间中的“Math.Pow()”方法来得到 n 的幂,如下面的代码片段所示:

int iOutput1 = (int) Math.Pow(iInput1, 2); 
int iOutput2 = (int)Math.Pow(iInput2, 2); 

我们可以重构“WithoutPrecomputation()”函数,以使用预计算技术,这样每当用户要求计算相同数字的平方时,它就不需要重复计算。我们将要得到的函数如下:

public partial class Program 
{ 
  private static void WithPrecomputation() 
  { 
    int[]powerOfTwos = new int[100]; 
    for (int i = 0; i < 100; i++) 
    { 
      powerOfTwos[i] = (int)Math.Pow(i, 2); 
    } 
    Console.WriteLine("WithPrecomputation()"); 
    Console.Write( 
      "Choose number from 0 to 99 twice "); 
    Console.WriteLine( 
      "to find the power of two result: "); 
    Console.Write("First Number: "); 
    int iInput1 = Convert.ToInt32(Console.ReadLine()); 
    Console.Write("Second Number: "); 
    int iInput2 = Convert.ToInt32(Console.ReadLine()); 
    int iOutput1 = FindThePowerOfTwo(powerOfTwos, iInput1); 
    int iOutput2 = FindThePowerOfTwo(powerOfTwos, iInput2); 
    Console.WriteLine( 
      "2 the power of {0} is {1}", 
      iInput1, 
      iOutput1); 
    Console.WriteLine( 
      "2 the power of {0} is {1}", 
      iInput2, 
      iOutput2); 
  } 
} 

如前面的代码中所示,我们在函数开头创建了一个名为powerOfTwos的查找表,如下面的代码片段所示:

int[] powerOfTwos = new int[100]; 
for (int i = 0; i < 100; i++) 
{ 
  powerOfTwos[i] = (int)Math.Pow(i, 2); 
} 

由于我们要求用户输入 0 到 99 之间的数字,查找表将存储来自范围数字的两个数字的幂的数据库。此外,“WithPrecomputation()”函数和“WithoutPrecomputation()”函数之间的区别在于我们有了两个结果的集合。现在我们使用“FindThePowerOfTwo()”函数,如下面的代码片段所示:

int iOutput1 = FindThePowerOfTwo(squares, iInput1); 
int iOutput2 = FindThePowerOfTwo(squares, iInput2); 

“FindThePowerOfTwo()”函数将在查找表中查找所选数字,本例中为powerOfTwos。而“FindThePowerOfTwo()”函数的实现将如下所示:

public partial class Program 
{ 
  private static int FindThePowerOfTwo ( 
    int[] precomputeData, 
    int baseNumber) 
  { 
    return precomputeData[baseNumber]; 
  } 
} 

如您所见,“FindThePowerOfTwo()”函数返回我们用baseNumber参数指定的查找表的值。如果我们运行“WithPrecomputation()”函数,我们将在控制台上获得以下输出:

执行初始计算

再次计算1985的平方,确实,我们得到的结果与运行“WithoutPrecomputation()”函数时得到的完全相同。现在,我们有了一个从 0 到 99 的平方数查找表。我们程序中的优势更加有效,因为每次我们要求计算相同的数字(1985)时,它都不需要运行计算,而是会在查找表中查找结果。

然而,我们之前探讨的预计算代码并不是一种功能性方法,因为每次调用“FindThePowerOfTwo()”函数时,它都会再次迭代平方。我们可以重构它,使其在使用柯里化的幂的情况下变得功能性,这是一种通过顺序更改结构参数的技术,我们在第一章中讨论过,在 C#中品尝函数式风格。现在让我们看一下以下代码:

public partial class Program 
{ 
  private static void WithPrecomputationFunctional() 
  { 
    int[]powerOfTwos = new int[100]; 
    for (int i = 0; i < 100; i++) 
    { 
      powerOfTwos[i] = (int) Math.Pow(i, 2); 
    } 
    Console.WriteLine("WithPrecomputationFunctional()"); 
    Console.Write( 
      "Choose number from 0 to 99 twice "); 
    Console.WriteLine( 
      "to find the power of two result: "); 
    Console.Write("First Number: "); 
    int iInput1 = Convert.ToInt32(Console.ReadLine()); 
    Console.Write("Second Number: "); 
    int iInput2 = Convert.ToInt32(Console.ReadLine()); 
    var curried = CurriedPowerOfTwo(powerOfTwos); 
    int iOutput1 = curried(iInput1); 
    int iOutput2 = curried(iInput2); 
    Console.WriteLine( 
      "2 the power of {0} is {1}", 
      iInput1, 
      iOutput1); 
    Console.WriteLine( 
      "2 the power of {0} is {1}", 
      iInput2, 
      iOutput2); 
  } 
} 

如果我们将前面的“WithPrecomputationFunctional()”函数与“WithPrecomputation()”函数进行比较,我们可以看到它现在使用了“CurriedPowerOfTwo()”函数,如下面的代码片段所示:

var curried = CurriedSquare(squares); 
int iOutput1 = curried(iInput1); 
int iOutput2 = curried(iInput2); 

使用“CurriedPowerOfTwo()”函数,我们分割函数参数,以便柯里化变量现在可以处理查找表,并且我们可以随意调用“WithPrecomputationFunctional()”函数,而无需再次迭代查找表。以下代码中可以找到“CurriedPowerOfTwo()”函数的实现:

public partial class Program 
{ 
  public static Func<int, int> 
  CurriedPowerOfTwo(int[] intArray) 
      => i => intArray[i]; 
} 

如果我们运行“WithPrecomputationFunctional()”函数,我们的控制台窗口将显示以下输出:

执行初始计算

再次,与我们之前的函数“WithoutPrecomputation()”函数和“WithPrecomputation()”函数相比,我们得到了完全相同的输出。我们已成功重构了函数,并且在这种预计算技术中已实现了功能性方法。

备忘录化

除了执行预计算技术来优化代码之外,我们还可以使用记忆化技术使我们的代码更加优化。记忆化是记住具有特定输入的函数的结果的过程。每次我们用特定的输入参数执行特定的函数时,代码都会记住结果。因此,每次我们再次使用完全相同的输入参数调用函数时,代码就不需要运行代码了;相反。它将从存储结果的位置获取结果。

让我们借用我们在第五章中讨论的重复的GetFactorial()函数,使用 LINQ 轻松查询任何集合,然后重构它以使用记忆化技术。正如我们所知,GetFactorial()函数的实现如下:

public partial class Program 
{ 
  private static int GetFactorial(int intNumber) 
  { 
    if (intNumber == 0) 
    { 
      return 1; 
    } 
    return intNumber * GetFactorial(intNumber - 1); 
  } 
} 

要使GetFactorial()函数使用记忆化,我们必须在GetFactorial()函数返回值时保存结果。前面的GetFactorial()函数的重构代码将如下所示,并且我们可以在Memoization.csproj项目中找到它:

public partial class Program 
{ 
  private static Dictionary<int, int> 
    memoizeDict = new Dictionary<int, int>(); 
  private static int GetFactorialMemoization(int intNumber) 
  { 
    if (intNumber == 0) 
    { 
      return 1; 
    } 
    if (memoizeDict.ContainsKey(intNumber)) 
    { 
      return memoizeDict[intNumber]; 
    } 
    int i = intNumber * GetFactorialMemoization( 
      intNumber - 1); 
    memoizeDict.Add(intNumber, i); 
    return i; 
  } 
} 

正如您所看到的,我们有一个名为memoizeDictDictionary类,用于存储当特定参数传递给GetFactorialMemoization()函数时的所有结果。该字典的定义如下代码片段所示:

private static Dictionary<int, int> 
  memoizeDict = new Dictionary<int, int>(); 

GetFactorial()函数相比,GetFactorialMemoization()函数的另一个区别是,当迄今为止已调用具有特定参数的GetFactorialMemoization()函数时,它现在保存结果。以下代码片段显示了此算法的代码:

private static int GetFactorialMemoization(int intNumber) 
{ 
  if (intNumber == 0) 
  { 
    return 1; 
  } 
  if (memoizeDict.ContainsKey(intNumber)) 
  { 
    return memoizeDict[intNumber]; 
  } 
  int i = intNumber * GetFactorialMemoization( 
    intNumber - 1); 
  memoizeDict.Add(intNumber, i); 
  return i; 
} 

首先,我们检查特定参数是否已传递给函数。如果是,它就不需要运行函数;相反,它只需从字典中检索结果。如果参数尚未传递,函数将运行,并且我们将结果保存在字典中。使用记忆化,我们可以优化代码,因为如果参数完全相同,我们就不需要一遍又一遍地运行函数。假设我们将 10 传递给GetFactorialMemoization()函数。如果我们再次运行函数并再次传递 10,处理速度将增加,因为它不需要运行重复的GetFactorialMemoization()函数。幸运的是,通过将 10 传递给函数参数,它还将使用 1-9 参数运行函数,因为它是一个递归函数。这 10 个项目的调用效果和结果将保存在目录中,并且使用这些参数调用函数将更快。

现在让我们比较GetFactorial()函数与GetFactorialMemoization()函数的性能。我们将传递9216作为参数,并运行它们。以下是用于调用GetFactorial()函数的RunFactorial()函数:

public partial class Program 
{ 
  private static void RunFactorial() 
  { 
    Stopwatch sw = new Stopwatch(); 
    int factorialResult = 0; 
    Console.WriteLine( 
      "RunFactorial() function is called"); 
    Console.WriteLine( 
      "Get factorial of 9216"); 
    for (int i = 1; i <= 5; i++) 
    { 
      sw.Restart(); 
      factorialResult = GetFactorial(9216); 
      sw.Stop(); 
      Console.WriteLine( 
        "Time elapsed ({0}): {1,8} ns", 
        i, 
        sw.ElapsedTicks * 
          1000000000 / 
          Stopwatch.Frequency); 
    } 
  } 
} 

如果我们运行RunFactorial()函数,我们将在控制台上得到以下输出:

Memoization

从输出中可以看出,在第一次调用GetFactorial()函数时,我们需要281461 ns,而在剩下的调用中需要大约 75,000-98,000 纳秒。由于递归的GetFactorial()函数每次都被调用,所有调用的进程速度几乎相同。现在让我们继续执行以下RunFactorialMemoization()函数,以调用GetFactorialMemoization()函数:

public partial class Program 
{ 
  private static void RunFactorialMemoization() 
  { 
    Stopwatch sw = new Stopwatch(); 
    int factorialResult = 0; 
    Console.WriteLine( 
      "RunFactorialMemoization() function is called"); 
    Console.WriteLine( 
      "Get factorial of 9216"); 
    for (int i = 1; i <= 5; i++) 
    { 
      sw.Restart(); 
      factorialResult = GetFactorialMemoization(9216); 
      sw.Stop(); 
      Console.WriteLine( 
        "Time elapsed ({0}): {1,8} ns", 
        i, 
        sw.ElapsedTicks * 
          1000000000 / 
          Stopwatch.Frequency); 
    } 
  } 
} 

如果我们运行RunFactorialMemoization()函数,我们将在控制台上得到以下输出:

Memoization

现在我们可以看到,通过使用记忆化,进程速度已经大大提高。即使在第一次调用GetFactorialMemoization()时需要额外的时间,在第 3 到 5 次调用时,进程变得更快。

摘要

我们讨论了通过懒惰可以创建高效的代码。懒惰枚举在需要迭代无限循环时非常有用,这样就不会溢出,因为IEnumerator中的MoveNext()方法只有在被要求时才会运行。此外,懒惰评估使我们的代码运行更快,因为编译器不需要检查所有布尔表达式,如果其中一个已经给出结果。

在非严格评估中,我们将编程中的函数视为数学函数。使用这种评估技术,我们使用函数方法来解决函数。

我们还熟悉了Lazy<T>类提供的延迟初始化,这意味着我们可以定义一个对象,但如果尚未访问对象的成员,则不会初始化该对象。

为了优化我们的代码,我们讨论了使用预计算和记忆化的缓存技术。在预计算中,我们准备了类似查找表的东西,这样我们就不需要用精确的参数运行函数;相反,我们只需要从表中获取结果。我们还有记忆化,以记住具有特定输入的函数的结果。使用记忆化,每次我们再次使用完全相同的输入参数调用函数时,代码就不需要再次运行代码;相反,它将从存储结果的地方获取结果。

在下一章中,我们将讨论单子及其在函数式编程中的使用。

第九章:使用模式匹配

在上一章中,我们讨论了优化代码以开发高效的代码。现在,我们将讨论使我们的代码流程按照规则进行的模式,以便更容易维护和理解程序的流程。我们将在本章中讨论的主要主题是模式匹配和 Monad 作为一种设计模式。模式匹配将使用数学方法匹配条件,以便我们能够从中获得功能性的体验。而 Monad 是函数式编程中不可分割的一部分,因为它是软件设计中复杂问题的设计模式。使用 Monad,我们可以通过放大它们的行为来为现有的数据类型提供更多的功能。本章将进一步探讨模式匹配和Monad,我们将讨论以下主题:

  • 理解函数式编程中的模式匹配

  • 使用模式匹配转换数据和切换决策

  • 简化模式匹配以使其更加功能化

  • 在 C# 7 中测试模式匹配功能

  • 找出哪些 C#类型自然实现了 Monad

  • 生成单子类型

  • 理解 Monad 的规则

解剖函数式编程中的模式匹配

在函数式编程中,模式匹配是一种分派形式,用于选择要调用的函数的正确变体。它实际上是受标准数学符号的启发,具有表达条件执行的语法。我们可以从第一章中借用代码,在 C#中品尝函数式风格,当我们谈论递归时开始我们的模式匹配讨论。以下是我们用来检索阶乘值的GetFactorial()函数:

public partial class Program 
{ 
  private static intGetFactorial(intintNumber) 
  { 
    if (intNumber == 0) 
    { 
      return 1; 
    } 
    returnintNumber * GetFactorial(intNumber - 1); 
  } 
} 

正如我们在前面的代码中所看到的,它给了我们两个定义。在这种情况下,调度程序是根据实际的intNumber参数模式是否匹配 0 来选择的。模式匹配的使用更接近于这个if条件表达式,因为我们必须决定通过提供特定的输入来选择哪个部分。

使用模式匹配转换数据

模式匹配在某种程度上是在转换数据。让我们从上一章借用另一个函数继续讨论。也许我们还记得,在扩展方法中有一个名为IsPrime()的函数,用于检查一个数是否是质数。我们将再次使用它来演示模式匹配来转换数据。对于那些忘记了IsPrime()函数实现的人,这里是代码:

public static class ExtensionMethods 
{ 
  public static bool IsPrime(this int i) 
  { 
    if ((i % 2) == 0) 
    { 
      return i == 2; 
    } 
    int sqrt = (int)Math.Sqrt(i); 
    for (int t = 3; t <= sqrt; t = t + 2) 
    { 
      if (i % t == 0) 
      { 
        return false; 
      } 
    } 
    return i != 1; 
  } 
} 

同样,我们使用模式匹配来确定数字是质数、合数还是其他。然而,现在我们将把int数字转换为文本,正如我们在MatchingPattern.csproj项目中找到的NumberFactorType()函数中所看到的:

public partial class Program 
{ 
  public static string NumberFactorType( 
    int intSelectedNumber) 
  { 
    if (intSelectedNumber < 2) 
    { 
      return "neither prime nor composite number"; 
    } 
    else if (intSelectedNumber.IsPrime()) 
    { 
      return "prime number"; 
    } 
    else 
    { 
      return "composite number"; 
    } 
  } 
} 

在前面的代码中,我们使用if...else条件语句来匹配条件,而不是在前面的示例中使用的if条件语句。现在,让我们调用NumberFactorType()函数来匹配我们给定的整数,并使用以下的TransformIntIntoText()函数将其转换为文本:

public partial class Program 
{ 
  public static void TransformIntIntoText() 
  { 
    for (int i = 0; i < 10; i++) 
    { 
      Console.WriteLine( 
        "{0} is {1}", i, NumberFactorType(i)); 
    } 
  } 
} 

我们将数字 0 到 9 传递给NumberFactorType()函数以获得匹配的结果。如果我们运行TransformIntIntoText()函数,我们将在控制台上得到以下输出:

使用模式匹配转换数据

从前面的截图中可以看出,我们已经成功地使用模式匹配将int转换为文本。

用于模式匹配的切换。

我们知道模式匹配可以将数据转换为另一种形式。这实际上类似于 LINQ 中的Select()方法,并在概念上类似于 switch case 语句。现在让我们看一下以下的HexCharToByte()函数,将十六进制字符转换为byte

public partial class Program 
{ 
  public static byte HexCharToByte( 
    char c) 
  { 
    byte res; 

    switch (c) 
    { 
      case '1': 
        res = 1; 
        break; 
      case '2': 
        res = 2; 
        break; 
      case '3': 
        res = 3; 
        break; 
      case '4': 
        res = 4; 
        break; 
      case '5': 
        res = 5; 
        break; 
      case '6': 
        res = 6; 
        break; 
      case '7': 
        res = 7; 
        break; 
      case '8': 
        res = 8; 
        break; 
      case '9': 
        res = 9; 
        break; 
      case 'A': 
      case 'a': 
        res = 10; 
        break; 
      case 'B': 
      case 'b': 
        res = 11; 
        break; 
      case 'C': 
      case 'c': 
        res = 12; 
        break; 
      case 'D': 
      case 'd': 
        res = 13; 
        break; 
      case 'E': 
      case 'e': 
        res = 14; 
        break; 
      case 'F': 
      case 'f': 
        res = 15; 
        break; 
      default: 
        res = 0; 
        break; 
    } 

    return res; 
  } 
} 

然后,我们添加一个包装器将字符串中的十六进制转换为int,如下面的HexStringToInt()函数所示:

public partial class Program 
{ 
  public static intHexStringToInt( 
    string s) 
  { 
    int iCnt = 0; 
    int retVal = 0; 
    for (inti = s.Length - 1; i>= 0; i--) 
    { 
      retVal += HexCharToByte(s[i]) *  
        (int) Math.Pow(0x10, iCnt++); 
    } 
    return retVal; 
  } 
} 

从前面的代码中,我们可以看到,我们调用HexCharToByte()函数为每个十六进制字符获取每个int值。然后,我们使用 16 的幂来获取所有十六进制值。假设我们有以下GetIntFromHexString()函数来将字符串中的多个十六进制数字转换为int

public partial class Program 
{ 
  private static void GetIntFromHexString() 
  { 
    string[] hexStrings = { 
      "FF", "12CE", "F0A0", "3BD", 
      "D43", "35", "0", "652F", 
      "8DCC", "4125" 
    }; 
    for (int i = 0; i < hexStrings.Length; i++) 
    { 
      Console.WriteLine( 
        "0x{0}\t= {1}", 
        hexStrings[i], 
        HexStringToInt(hexStrings[i])); 
    } 
  } 
} 

如果我们运行GetIntFromHexString()函数,我们将在控制台上得到以下输出:

用于模式匹配的切换

如前面的屏幕截图所示,字符串中的每个十六进制字符都被转换为int值,然后将所有结果相加。

提示

要将十六进制字符转换为字节,我们可以使用ParseTryParse方法,或者使用String.Format进行格式化。HexCharToByte()函数仅用于示例目的。

简化模式匹配

我们已成功使用switch语句来实现模式匹配。但是,该示例并未应用函数式方法,因为HexCharToByte()函数中的res变量在执行过程中被改变。现在,我们将重构HexCharToByte()函数以应用函数式方法。让我们来看看SimplifyingPatternMatching.csproj项目中的HexCharToByteFunctional()函数:

public partial class Program 
{ 
  public static byte HexCharToByteFunctional( 
    char c) 
  { 
    return c.Match() 
      .With(ch =>ch == '1', (byte)1) 
      .With(ch =>ch == '2', 2) 
      .With(ch =>ch == '3', 3) 
      .With(ch =>ch == '4', 4) 
      .With(ch =>ch == '5', 5) 
      .With(ch =>ch == '6', 6) 
      .With(ch =>ch == '7', 7) 
      .With(ch =>ch == '8', 8) 
      .With(ch =>ch == '9', 9) 
      .With(ch =>ch == 'A', 10) 
      .With(ch =>ch == 'a', 10) 
      .With(ch =>ch == 'B', 11) 
      .With(ch =>ch == 'b', 11) 
      .With(ch =>ch == 'C', 12) 
      .With(ch =>ch == 'c', 12) 
      .With(ch =>ch == 'D', 13) 
      .With(ch =>ch == 'd', 13) 
      .With(ch =>ch == 'E', 14) 
      .With(ch =>ch == 'e', 14) 
      .With(ch =>ch == 'F', 15) 
      .With(ch =>ch == 'f', 15) 
      .Else(0) 
      .Do(); 
  } 
} 

前面的HexCharToByteFunctional()函数是从HexCharToByte()函数重构而来,现在实现了函数式方法。正如您所看到的,我们有四种类似于switch语句或if...else条件语句的方法:Match()With()Else()Do()。让我们来看看前面的HexCharToByteFunctional()函数使用的Match()函数:

public static class PatternMatch 
{ 
  public static PatternMatchContext<TIn> Match<TIn>( 
    this TIn value) 
  { 
    return new PatternMatchContext<TIn>(value); 
  } 
} 

正如您所看到的,Match()函数返回新的PatternMatchContext数据类型。PatternMatchContext类的实现如下:

public class PatternMatchContext<TIn> 
{ 
  private readonlyTIn _value; 
  internal PatternMatchContext(TIn value) 
  { 
    _value = value; 
  } 

  public PatternMatchOnValue<TIn, TOut> With<TOut>( 
    Predicate<TIn> condition,  
    TOut result) 
  { 
    return new PatternMatchOnValue<TIn, TOut>(_value) 
      .With(condition, result); 
  } 
} 

Match()函数生成PatternMatchContext的新实例时,其构造函数将传递的值存储到_value私有变量中,如下面的代码片段所示:

internal PatternMatchContext(TIn value) 
{ 
  _value = value; 
} 

在这个PatternMatchContext类中,还有一个名为With()的方法,我们可以将其与_value值进行比较。该方法将调用PatternMatchOnValue类中的With()方法,其实现如下所示:

public class PatternMatchOnValue<TIn, TOut> 
{ 
  private readonlyIList<PatternMatchCase> _cases =  
    new List<PatternMatchCase>(); 
  private readonlyTIn _value; 
  private Func<TIn, TOut> _elseCase; 

  internal PatternMatchOnValue(TIn value) 
  { 
    _value = value; 
  } 

  public PatternMatchOnValue<TIn, TOut> With( 
    Predicate<TIn> condition,  
    Func<TIn, TOut> result) 
  { 
    _cases.Add(new PatternMatchCase 
    { 
      Condition = condition, 
      Result = result 
    }); 

    return this; 
  } 

  public PatternMatchOnValue<TIn, TOut> With( 
    Predicate<TIn> condition,  
    TOut result) 
  { 
    return With(condition, x => result); 
  } 

  public PatternMatchOnValue<TIn, TOut> Else( 
  Func<TIn, TOut> result) 
  { 
    if (_elseCase != null) 
    { 
      throw new InvalidOperationException( 
        "Cannot have multiple else cases"); 
    } 
    _elseCase = result; 
    return this; 
  } 

  public PatternMatchOnValue<TIn, TOut> Else( 
    TOut result) 
  { 
    return Else(x => result); 
  } 

  public TOut Do() 
  { 
    if (_elseCase != null) 
    { 
      With(x => true, _elseCase); 
      _elseCase = null; 
    } 

    `foreach (var test in _cases) 
    { 
      if (test.Condition(_value)) 
      { 
        returntest.Result(_value); 
      } 
    } 

    throw new IncompletePatternMatchException(); 
  } 

  private structPatternMatchCase 
  { 
    public Predicate<TIn> Condition; 
    publicFunc<TIn, TOut> Result; 
  } 
} 

正如您从前面的代码中所看到的,当With()方法(它是PatternMatchContext类的成员)返回PatternMatchOnValue的新实例时,其构造函数也将值存储到_value私有变量中,如下面的代码片段所示:

internal PatternMatchOnValue(TIn value) 
{ 
  _value = value; 
} 

然后调用With()方法,该方法将匿名方法作为condition和预期值作为result传递,如下面的代码片段所示:

public PatternMatchOnValue<TIn, TOut> With( 
  Predicate<TIn> condition, 
  TOut result) 
{ 
  return With(condition, x => result); 
} 

这个With()方法然后调用另一个With()方法,该方法传递Predicate<T>Func<T1, T2>,如下面的代码片段所示:

public PatternMatchOnValue<TIn, TOut> With( 
  Predicate<TIn> condition, 
  Func<TIn, TOut> result) 
{ 
  _cases.Add(new PatternMatchCase 
  { 
    Condition = condition, 
    Result = result 
  }); 

  return this; 
} 

这个With()方法收集所有情况,并将它们存储在_cases列表中,类型为PatternMatchCase,其实现如下所示:

private structPatternMatchCase 
{ 
  public Predicate<TIn> Condition; 
  publicFunc<TIn, TOut> Result; 
} 

一旦我们提供了所有条件,我们调用Else()方法,其中包含默认结果。Else()方法的实现如下所示:

public PatternMatchOnValue<TIn, TOut> Else( 
  TOut result) 
{ 
  return Else(x => result); 
} 

然后调用另一个Else()方法,传递Func<T1, T2>,如下面的代码片段所示:

public PatternMatchOnValue<TIn, TOut> Else( 
  Func<TIn, TOut> result) 
{ 
  if (_elseCase != null) 
  { 
    throw new InvalidOperationException( 
      "Cannot have multiple else cases"); 
  } 
  _elseCase = result; 
  return this; 
} 

在收集所有_cases_elseCase变量之后,我们必须调用Do()方法来比较所有情况。Do()方法的实现如下所示:

public TOut Do() 
{ 
  if (_elseCase != null) 
  { 
    With(x => true, _elseCase); 
    _elseCase = null; 
  } 
  foreach (var test in _cases) 
  { 
    if (test.Condition(_value)) 
    { 
      returntest.Result(_value); 
    } 
  } 
  throw new IncompletePatternMatchException(); 
} 

正如您所看到的,Do()方法将使用With()方法将_elseCase变量(如果有的话)分配给_cases列表,如下面的代码片段所示:

if (_elseCase != null) 
{ 
  With(x => true, _elseCase); 
  _elseCase = null; 
} 

然后,使用以下代码片段比较所有_cases列表成员,以找到正确的_value值:

foreach (var test in _cases) 
{ 
  if (test.Condition(_value)) 
  { 
    return test.Result(_value); 
  } 
} 

虽然调用Else()方法是可选的,但必须匹配所有With()方法的调用之一。如果不匹配,Do()方法将抛出IncompletePatternMatchException异常,如下面的代码片段所示:

throw new IncompletePatternMatchException(); 

目前,我们不需要在IncompletePatternMatchException异常中实现任何内容,所以我们只需要创建一个新的类实现Exception类,如下面的代码所示:

public class IncompletePatternMatchException : 
  Exception 
{ 
} 

到目前为止,我们已经成功地将HexCharToByte()函数重构为HexCharToByteFunctional()函数。我们可以修改HexStringToInt()函数以调用HexCharToByteFunctional()函数,如下面的代码所示:

public partial class Program 
{ 
  public static intHexStringToInt( 
    string s) 
  { 
    int iCnt = 0; 
    int retVal = 0; 
    for (int i = s.Length - 1; i >= 0; i--) 
    { 
      retVal += HexCharToByteFunctional(s[i]) * 
      (int)Math.Pow(0x10, iCnt++); 
    } 

    return retVal; 
  } 
} 

然而,HexStringToInt()函数并没有实现功能性方法。我们可以将其重构为HexStringToIntFunctional()函数,如下所示:

public partial class Program 
{ 
  public static intHexStringToIntFunctional( 
    string s) 
  { 
    returns.ToCharArray() 
     .ToList() 
     .Select((c, i) => new { c, i }) 
     .Sum((v) => 
       HexCharToByteFunctional(v.c) * 
         (int)Math.Pow(0x10, v.i)); 
  } 
} 

从前面的HexStringToIntFunctional()函数中,我们可以看到,首先,我们将字符串转换为字符列表,通过颠倒列表的顺序。这是因为我们需要将最低有效字节分配给最低索引。然后,我们选择列表的每个成员,并创建一个包含字符本身和索引的新类。然后,我们根据它们的索引和值对它们进行求和。现在,我们有了以下的GetIntFromHexStringFunctional()函数,并调用了HexStringToIntFunctional()函数:

public partial class Program 
{ 
  private static void GetIntFromHexStringFunctional() 
  { 
    string[] hexStrings = { 
      "FF", "12CE", "F0A0", "3BD", 
      "D43", "35", "0", "652F", 
      "8DCC", "4125" 
    }; 
    Console.WriteLine( 
      "Invoking GetIntFromHexStringFunctional() function"); 
    for (int i = 0; i<hexStrings.Length; i++) 
    { 
      Console.WriteLine( 
        "0x{0}\t= {1}", 
        hexStrings[i], 
        HexStringToIntFunctional( 
          hexStrings[i])); 
    } 
  } 
} 

这实际上与MatchingPattern.csproj项目中的GetIntFromHexString()函数类似。如果我们运行GetIntFromHexStringFunctional()函数,我们将在控制台上得到以下输出:

简化模式匹配

正如您所看到的,与MatchingPattern.csproj项目中的GetIntFromHexString()函数相比,我们得到了完全相同的输出,因为我们已经成功地将其重构为功能模式匹配。

注意

为了简化模式匹配的方法,我们可以使用Simplicity NuGet 包,我们可以直接从 Visual Studio 使用Package Manager Console下载,并输入Install-PackageSimplicity

欢迎 C# 7 中模式匹配功能的到来

C# 7 中计划的语言特性包括模式匹配,它对is运算符进行了扩展。现在我们可以在类型之后引入一个新变量,并且将这个变量赋值给is运算符的左操作数,但类型指定为右操作数。让我们通过下面的代码片段来清楚地说明这一点,我们可以在MatchingPatternCSharp7.csproj项目中找到:

public partial class Program 
{ 
  private static void IsOperatorBeforeCSharp7() 
  { 
    object o = GetData(); 
    if (o is String) 
    { 
      var s = (String)o; 
      Console.WriteLine( 
        "The object is String. Value = {0}", 
          s); 
    } 
  } 
} 

GetData()函数的实现如下:

public partial class Program 
{ 
  private static object GetData( 
      bool objectType = true) 
  { 
    if (objectType) 
        return "One"; 
    else 
        return 1; 
  } 
} 

在前面的IsOperatorBeforeCSharp7()函数中,我们应该在检查o对象变量的内容后,将s变量赋值为o的值。这是在 C# 7 引入模式匹配功能之前我们可以做的。现在,让我们将前面的代码与以下IsOperatorInCSharp7()函数进行比较:

public partial class Program 
{ 
  private static void IsOperatorInCSharp7() 
  { 
    object o = GetData(); 
    if (o is String s) 
    { 
      Console.WriteLine( 
          "The object is String. Value = {0}", 
           s); 
    } 
  } 
} 

正如我们所看到的,现在我们可以将s变量赋值为o变量的内容,但数据类型为字符串,正如我们之前讨论的那样。我们在检查条件时在if语句内部为s变量赋值。

幸运的是,这个特性也可以应用在 switch 语句中,正如我们在下面的代码片段中所看到的:

public partial class Program 
{ 
  private static void SwitchCaseInCSharp7() 
  { 
    object x = GetData( 
        false); 
    switch (x) 
    { 
      case string s: 
          Console.WriteLine( 
              "{0} is a string of length {1}", 
              x, 
              s.Length); 
          break; 
      case int i: 
          Console.WriteLine( 
              "{0} is an {1} int", 
              x, 
              (i % 2 == 0 ? "even" : "odd")); 
          break; 
      default: 
          Console.WriteLine( 
              "{0} is something else", 
              x); 
          break; 
    } 
  } 
} 

正如我们在前面的SwitchCaseInCSharp7()函数中所看到的,我们可以在case检查中将si变量赋值为x变量的内容,因此我们不需要再次赋值变量。

注意

有关 C# 7 中模式匹配功能的更多信息,我们可以在官方 Roslyn GitHub 页面上找到:github.com/dotnet/roslyn/blob/features/patterns/docs/features/patterns.md

引入 Monad 作为一种设计模式

在面向对象编程(OOP)语言如 C#中很难解释 Monad。然而,在 OOP 中,有一个有用的想法可以解释 Monad:设计模式。设计模式是软件设计中复杂问题的可重用解决方案。想象一下建筑中的设计模式。世界上许多建筑都必须具有相同的模式:门、窗户、墙壁等。如果我们将建筑中的设计模式与软件设计中的设计模式进行比较,我们会意识到它们都有相同的想法。在软件设计的设计模式中,我们有函数、类型、变量等。这些设计模式已经在 C#语言中可用,并将一起构建应用程序。

考虑到这个设计模式的定义,我们现在有了 Monad 本身的定义。Monad 是一种使用 Monad 模式的类型。而 Monad 模式是一种用于类型的设计模式。

在 C#中,有一些类型实际上自然实现了 Monad;它们是Nullable<T>IEnumerable<T>Func<T>Lazy<T>Task<T>。其中一些类型在前一章中已经讨论过。然而,我们将再次讨论它们,并与 Monad 的解释相关联。

这五种类型有一些共同点;显然,它们都是只接受一个参数T的泛型类型。它们自然实现了 monad,因为它们有一定的规则和提供的操作;换句话说,它们是类型的放大器。它们可以接受一个类型并将其转换为特殊类型。

我们可以说Nullable<T>是一种类型的放大器,因为它可以将,例如,int转换为 null,而如果没有使用Nullable<T>是不可能的,因为int只能处理-2,147,483,6482,147,483,647

让我们看一下在AmplifierOfTypes.csproj项目中可以找到的以下代码:

public partial class Program 
{ 
  private static Nullable<int> WordToNumber(string word) 
  { 
    Nullable<int> returnValue; 
    if (word == null) 
    { 
      return null; 
    } 
    switch (word.ToLower()) 
    { 
      case "zero": 
        returnValue = 0; 
        break; 
      case "one": 
        returnValue = 1; 
        break; 
      case "two": 
        returnValue = 2; 
        break; 
      case "three": 
        returnValue = 3; 
        break; 
      case "four": 
        returnValue = 4; 
        break; 
      case "five": 
        returnValue = 5; 
        break; 
      default: 
        returnValue = null; 
        break; 
    } 

    return returnValue; 
  } 
} 

前面的代码将把string类型中的数字转换为int类型。然而,由于string类型允许为 null,int类型将无法处理这种数据类型。为此,我们使用Nullable<int>作为返回类型;因此,现在返回值可以为 null,如下面的代码片段所示:

if (word == null) 
{ 
  return null; 
} 

然后,我们可以使用以下PrintStringNumber()函数调用前面的WordToNumber()函数:

public partial class Program 
{ 
  private static void PrintStringNumber( 
    string stringNumber) 
  { 
    if (stringNumber == null && 
      WordToNumber(stringNumber) == null) 
    { 
      Console.WriteLine( 
        "Word: null is Int: null"); 
    } 
    else 
    { 
      Console.WriteLine( 
        "Word: {0} is Int: {1}", 
        stringNumber.ToString(), 
        WordToNumber(stringNumber)); 
    } 
  } 
} 

现在,我们可以将int数据类型返回null,因为它已经成为了Nullable类型,如下面的代码片段所示:

if (stringNumber == null && 
  WordToNumber(stringNumber) == null) 

前面的代码片段将处理传递给WordToNumber()函数的空字符串输入。现在我们可以使用以下代码调用前面的PrintStringNumber()函数:

public partial class Program 
{ 
  private static void PrintIntContainingNull() 
  { 
    PrintStringNumber("three"); 
    PrintStringNumber("five"); 
    PrintStringNumber(null); 
    PrintStringNumber("zero"); 
    PrintStringNumber("four"); 
  } 
} 

如果我们运行PrintIntContainingNull()函数,将在控制台上得到以下输出:

将 Monad 引入为一种设计模式

从前面的截图中,您可以看到我们现在可以给int数据类型的null值,因为它已经自然实现了 monad,并且已经使用类型的放大器进行了放大。

IEnumerable<T>也实现了 monad,因为它可以放大我们传递给IEnumerable<T>T类型。假设我们想要使用IEnumerable<T>来放大字符串类型,以便对其进行枚举和排序;我们可以使用以下代码:

public partial class Program 
{ 
  private static void AmplifyString() 
  { 
    IEnumerable<string> stringEnumerable 
      = YieldNames(); 
    Console.WriteLine( 
      "Enumerate the stringEnumerable"); 

    foreach (string s -> in stringEnumerable) 
    { 
      Console.WriteLine( 
        "- {0}", s); 
    } 

    IEnumerable<string>stringSorted =  
      SortAscending(stringEnumerable); 

    Console.WriteLine(); 
    Console.WriteLine( 
      "Sort the stringEnumerable"); 

    foreach (string s -> in stringSorted) 
    { 
      Console.WriteLine( 
        "- {0}", s); 
    } 
  } 
} 

AmplifyString()函数中,我们将展示如何利用string类型来存储多个值,并表示枚举和排序,如下面的代码片段所示,用于初始化可枚举字符串:

IEnumerable<string> stringEnumerable 
  = YieldNames(); 

我们可以使用以下代码片段对可枚举字符串进行排序:

IEnumerable<string> stringSorted = 
  SortAscending(stringEnumerable); 

我们用来初始化可枚举字符串的YieldNames()函数的实现如下:

public partial class Program 
{ 
  private static IEnumerable<string> YieldNames() 
  { 
    yield return "Nicholas Shaw"; 
    yield return "Anthony Hammond"; 
    yield return "Desiree Waller"; 
    yield return "Gloria Allen"; 
    yield return "Daniel McPherson"; 
  } 
} 

我们用来对可枚举字符串进行排序的SortAscending()函数的实现如下:

public partial class Program 
{ 
  private static IEnumerable<string> SortAscending( 
    IEnumerable<string> enumString) 
  { 
    returnenumString.OrderBy(s => s); 
  } 
} 

如您所见,在YieldNames()函数的实现中,函数将产生五个以人名命名的字符串。这些人名将被保存在类型为IEnumerable<string>stringEnumerable变量中。很明显,stringEnumerable现在已经被利用,以便它可以处理多个值。在SortAscending()函数中,我们可以看到stringEnumerable已经被利用,以便它可以被排序和排序。如果我们运行上述的AmplifyString()函数,我们将在控制台上得到以下输出:

将 Monad 作为设计模式介绍

从上述的截图中,我们可以看到我们已经成功地放大了string类型,使其现在可以枚举多个string值并对它们进行排序。

正如我们在上一章中讨论的许多方式,Func<T>是一个封装方法,它返回由T参数指定的类型的值,而不需要传递任何参数。为此,我们将在我们的AmplifiedFuncType.csproj项目中创建以下的Func<T>方法:

public partial class Program 
{ 
  Func<int> MultipliedFunc; 
} 

MultipliedFunc是一个委托,将负责处理返回int值的不需要传递参数的函数。现在,以下代码将解释Func<T>也自然实现了 monad。然而,在我们进行Func<T>解释之前,我们将使用我们之前讨论过的Nullable类型创建一个包装器。让我们来看看以下的MultipliedByTwo()函数:

public partial class Program 
{ 
  private static Nullable<int>MultipliedByTwo( 
    Nullable<int>nullableInt) 
  { 
    if (nullableInt.HasValue) 
    { 
      int unWrappedInt =  
        nullableInt.Value; 
      int multipliedByTwo =  
        unWrappedInt * 2; 
      return GetNullableFromInt( 
        multipliedByTwo); 
    } 
    else 
    { 
      return new Nullable<int>(); 
    } 
  } 
} 

GetNullableFromInt()函数在MultipliedByTwo()函数中有以下实现:

public partial class Program 
{ 
  private static Nullable<int> GetNullableFromInt( 
    int iNumber) 
  { 
    return new Nullable<int>( 
      iNumber); 
  } 
} 

MultipliedByTwo()函数很简单。显然,在我们对未包装的值执行乘法运算后,它将包装未包装的值。假设我们有以下的RunMultipliedByTwo()函数:

public partial class Program 
{ 
  private static void RunMultipliedByTwo() 
  { 
    for (int i = 1; i <= 5; i++) 
    { 
      Console.WriteLine( 
        "{0} multiplied by to is equal to {1}", 
        i, MultipliedByTwo(i)); 
    } 
  } 
} 

如果我们运行上述的RunMultipliedByTwo()函数,我们将在控制台上得到以下输出:

将 Monad 作为设计模式介绍

从上述的截图中,您可以看到函数提供了一个通用模式。未包装的 1、2、3、4、5 将被乘以 2,并被包装成 2、4、6、8、10。

现在,我们将解释Func<T>。让我们创建以下的GetFuncFromInt()函数,它将返回类型为Func<int>的值:

public partial class Program 
{ 
  private static Func<int> GetFuncFromInt( 
    int iItem) 
  { 
    return () => iItem; 
  } 
} 

上述的GetFuncFromInt()函数将从int值生成一个全新的Func<T>方法。同样,我们将创建MultipliedByTwo()函数,但具有不同的签名,如下:

public partial class Program 
{ 
  private static Func<int> MultipliedByTwo( 
   Func<int> funcDelegate) 
  { 
    int unWrappedFunc =  
      funcDelegate(); 
    int multipliedByTwo =  
      unWrappedFunc* 2; 
    return GetFuncFromInt( 
      multipliedByTwo); 
  } 
} 

上述代码将成功编译。但是,假设我们有以下代码:

public partial class Program 
{ 
  private static void RunMultipliedByTwoFunc() 
  { 
    Func<int> intFunc = MultipliedByTwo( 
    () => 1 + 1); 
  } 
} 

如果我们运行上述的RunMultipliedByTwoFunc()函数,我们将得到固定的结果4,而不是公式(1 + 1) * 4。为了解决这个问题,我们可以创建如下的新代码:

public partial class Program 
{ 
  private static Func<int> MultipliedByTwoFunction( 
    Func<int> funcDelegate) 
  { 
    return () => 
    { 
      int unWrappedFunc =  
        funcDelegate(); 
      int multipliedByTwo =  
        unWrappedFunc * 2; 
      return multipliedByTwo; 
    }; 
  } 
} 

使用上述的MultipliedByTwoFunction()函数,每次请求新值时都会保留原始函数委托值。现在我们可以得出结论,我们之前的代码将使用未包装的值,然后对其进行一些操作。使用Nullable<int>操作和Func<int>操作之间存在差异,例如如何创建包装类型的结果。使用Nullable monad,我们可以直接使用未包装的值,执行计算,然后产生包装的值。然而,使用Func Monad,我们必须更加聪明,因为正如我们之前讨论的,我们必须产生一个委托以保留先前的Func Monad。

在 Monad 中,我们可以看到通过将 2 乘以包装的int,函数可以产生另一个包装的int,以便我们可以称之为放大

创建 Monadic M类型

现在,我们将通过重构我们之前的代码来实现 monad 中的高阶编程。让我们来看看GeneratingMonadInCSharp.csproj项目中可以找到的以下MultipliedByTwoFunction()函数:

public partial class Program 
{ 
  private static Nullable<int> MultipliedByTwoFunction( 
    Nullable<int> iNullable, 
    Func<int,int> funcDelegate) 
  { 
    if (iNullable.HasValue) 
    { 
      int unWrappedInt = 
        iNullable.Value; 
      int multipliedByTwo = 
        funcDelegate(unWrappedInt); 
      return new Nullable<int>( 
        multipliedByTwo); 
    } 
    else 
    { 
      return new Nullable<int>(); 
    } 
  } 
} 

从前面的MultipliedByTwoFunction()函数中可以看出,我们现在使用Func<int, int>,它传递一个整数参数来产生一个整数结果。我们现在也直接从参数中获取Nullable<int>参数。我们可以让以下的MultipliedByTwo()函数得到乘以二的值:

public partial class Program 
{ 
  private static Nullable<int> MultipliedByTwo( 
    Nullable<int> iNullable) 
  {  
    return MultipliedByTwoFunction( 
      iNullable, 
      (int x) => x * 2); 
  } 
} 

在前面的MultipliedByTwo()函数中,我们看到我们定义了iNullable值和匿名方法,如下面的代码片段所示:

return MultipliedByTwoFunction( 
  iNullable, 

 (int x) => x * 2);

假设我们有以下的RunMultipliedByTwo()函数来调用MultipliedByTwo()函数:

public partial class Program 
{ 
  private static void RunMultipliedByTwo() 
  { 
    Console.WriteLine( 
      "RunMultipliedByTwo() implementing " + 
      "higher-order programming"); 

    for (int i = 1; i <= 5; i++) 
    { 
      Console.WriteLine( 
        "{0} multiplied by to is equal to {1}", 
        i, MultipliedByTwo(i)); 
    } 
  } 
} 

如果我们运行前面的RunMultipliedByTwo()函数,我们将在控制台屏幕上得到以下输出:

创建 Monadic M类型

从前面的屏幕截图中可以看出,我们已成功重构了AmplifiedFuncType.csproj项目中的MultipliedByTwo()函数。

将通用数据类型实现到 Monad

我们还可以通过实现泛型使我们之前的MultipliedByTwo()函数更加通用,如下面的代码所示:

public partial class Program 
{ 
  private static Nullable<T> MultipliedByTwoFunction<T>( 
    Nullable<T> iNullable, 
    Func<T,T> funcDelegate) 
    where T : struct 
  { 
    if (iNullable.HasValue) 
    { 
      T unWrappedInt = iNullable.Value; 
      T multipliedByTwo = funcDelegate(unWrappedInt); 
      return new Nullable<T>( 
        multipliedByTwo); 
    } 
    else 
    { 
      return new Nullable<T>(); 
    } 
  } 
} 

如果由于某种原因我们需要一个传递整数值但结果为双精度的函数-例如,我们想要除以一个整数,我们可以放大该函数,以便它可以将值从int修改为double,如下面的代码所示:

public partial class Program 
{ 
  private static Nullable<R> MultipliedByTwoFunction<V, R>( 
    Nullable<V> iNullable, 
    Func<V,R> funcDelegate) 
  where V : struct 
  where R : struct 
  { 
    if (iNullable.HasValue) 
    { 
      V unWrappedInt = iNullable.Value; 
      R multipliedByTwo = funcDelegate(unWrappedInt); 
      return new Nullable<R>(multipliedByTwo); 
    } 
    else 
    { 
      return new Nullable<R>(); 
    } 
  } 
} 

由于Nullable是在前面的MultipliedByTwoFunction()方法中的类型放大,我们可以将它修改为任何其他类型,如下所示:

public partial class Program 
{ 
  static Lazy<R> MultipliedByTwoFunction<V,R>( 
    Lazy<V> lazy, 
  Func<V, R> function) 
  where V : struct 
  where R : struct 
  { 
    return new Lazy<R>(() => 
    { 
      V unWrappedInt = lazy.Value; 
      R multipliedByTwo = function(unWrappedInt); 
      return multipliedByTwo; 
    }); 
  } 
} 

正如我们之前讨论的,MultipliedByTwoFunction()具有单子模式,因为它传递特定类型的值并将其转换为放大类型的值。换句话说,我们有一个函数,它有一个模式,可以将从VR的函数转换为从M<V>M<R>的函数,其中M<R>是一个放大的类型。这样我们就可以编写一个具有单子模式的方法,如下所示:

public partial class Program 
{ 
  private static M<R> MonadFunction<V, R>( 
    M<V> amplified, 
    Func<V, R> function) 
  { 
    // Implementation 
  } 
} 

现在,我们有了一个单子M<T>类型,如果我们需要在函数中实现单子模式,就可以使用它。然而,如果我们看一下我们之前的MultipliedByTwoFunction<V, R>()方法,我们会发现有一些可以改进的地方,如下面的代码所示:

public partial class Program 
{ 
  private static Nullable<R> 
  MultipliedByTwoFunctionSpecial<V, R>( 
    Nullable<V> nullable, 
    Func<V, Nullable<R>> function) 
  where V : struct 
  where R : struct 
  { 
    if (nullable.HasValue) 
    { 
      V unWrappedInt = nullable.Value; 
      Nullable<R >multipliedByTwo = function(unWrappedInt); 
      return multipliedByTwo; 
    } 
    else 
    { 
      return new Nullable<R>(); 
    } 
  } 
} 

我们已将第二个参数从Func<V, R>修改为Func<V, Nullable<R>>。这样做是为了防止出现不合适的结果,比如Nullable<Nullable<double>>,如果我们期望的返回类型是Nullable<double>。我们还可以实现到另一个类型,比如Func<T>,如下面的代码所示:

public partial class Program 
{ 
  private static Func<R> 
  MultipliedByTwoFunctionSpecial<V, R>( 
    Func<V> funcDelegate, 
    Func<V, Func<R>> function) 
  { 
    return () => 
    { 
      V unwrappedValue = funcDelegate(); 
      Func<R> resultValue = function(unwrappedValue); 
      return resultValue(); 
    }; 
  } 
} 

将 Monad 实现到 Lazy和 Task

除了类型Func<T>,我们还可以将单子实现到Lazy<T>Task<T>,如下面的代码所示:

public partial class Program 
{ 
  private static Lazy<R> 
  MultipliedByTwoFunctionSpecial<V, R>( 
    Lazy<V> lazy, 
    Func<V, Lazy<R>> function) 
  { 
    return new Lazy<R>(() => 
    { 
      V unwrappedValue = lazy.Value; 
      Lazy<R>resultValue = function(unwrappedValue); 
      return resultValue.Value; 
    }); 
  } 

  Private static async Task<R> 
  MultipliedByTwoFunctionSpecial<V, R>( 
    Task<V> task, 
    Func<V, Task<R>> function) 
  { 
    V unwrappedValue = await task; 
    Task<R>resultValue = function(unwrappedValue); 
    return await resultValue; 
  } 
} 

此外,我们还可以为IEnumerable<T>实现它。代码如下:

public partial class Program 
{ 
  staticIEnumerable<R> 
  MultipliedByTwoFunctionSpecial<V, R>( 
    IEnumerable<V> sequence, 
    Func<V, IEnumerable<R>> function) 
  { 
    foreach (V unwrappedValue in sequence) 
    { 
      IEnumerable<R> resultValue = function(unwrappedValue); 
      foreach (R r in resultValue) 
      yield return r; 
    } 
  } 
} 

在我们对各种数据类型进行了MultipliedByTwoFunctionSpecial()函数的解剖之后,比如NullableFuncLazyTaskIEnumerable,我们可以看到单子类型已经将M<M<R>>扁平化为M<R>。我们可以看到,当使用Nullable类型时,我们必须避免创建Nullable<Nullable<R>>,通过检查传递的Nullable类型的参数是否有值。如果没有,就返回一个空的Nullable<R>类型,如下面的代码片段所示:

if (nullable.HasValue) 
{ 
  V unWrappedInt = nullable.Value; 
  Nullable<R> multipliedByTwo = function(unWrappedInt); 
  return multipliedByTwo; 
} 
else 
{ 
  return new Nullable<R>(); 
} 

当我们使用任务时,我们还必须等待外部任务,然后等待内部任务,以避免创建<Task<R>>任务,如下面的代码片段所示:

private static async Task<R> 
MultipliedByTwoFunctionSpecial<V, R>( 
  Task<V> task, 
  Func<V, Task<R>> function) 
{ 
  V unwrappedValue = await task; 
  Task<R> resultValue = function(unwrappedValue); 
  return await resultValue; 
} 

其他单子类型也有相同的模式。

单子模式的规则

我们已经讨论过,单子模式将始终将类型为T的值包装到M<T>的实例中,如下面的代码片段所示:

public partial class Program 
{ 

 private static M<T> MonadFunction <T>(Titem)

  { 
    // Implementation 
  } 
} 

此外,在单子模式中,如果我们有一个从VR的函数,我们可以将M<V>的实例转换为M<R>的实例,如下面的代码片段所示:

public partial class Program 
{ 
  private static M<R> MultipliedByTwoFunction <V, R>( 
    M<V> wrapped, Func<V, R> function) 
  { 
    // Implementation 
  } 
} 

单子模式的另一个规则是,如果我们有一个从VM<R>的函数,我们可以把V的类型转换为M<R>的实例,然后应用到M<V>的实例上,就像下面的代码片段所示:

public partial class Program 
{ 
  private static Func<R> 
  MultipliedByTwoFunctionSpecial<V, R>( 
    Func<V> funcDelegate, 
    Func<V, Func<R>> function) 
  { 
    // Implementation 
  } 
} 

总结

模式匹配是一种选择正确的函数变体的分发形式。换句话说,它的概念接近于if条件表达式,因为我们必须通过提供特定的输入来决定正确的选择。匹配过程可以简化为实现函数式方法。我们讨论了switch情况,然后使用 LINQ 进行了重构,使其变得函数式。

我们学习了单子本身的定义:一种使用单子模式的类型,单子模式是一种类型的设计模式。在 C#中,有一些类型自然地实现了单子模式;它们是Nullable<T>IEnumerable<T>Func<T>Lazy<T>Task<T>

到目前为止,我们已经对 C#中的函数式编程有足够的了解。在下一章中,我们将运用你在本章和之前章节学到的知识来开发一个实现函数式方法的应用程序。在即将到来的章节中,我们将把命令式代码转换为函数式代码。

第十章:在 C#函数式编程中采取行动

这是本书最重要的一章,因为我们将使用函数式方法创建一个新的应用程序。我们已经在前几章中深入讨论了函数式编程,包括函数式编程概念、语言集成查询(LINQ)、递归、优化和模式。我们现在要做的是以命令式方法开发一个应用程序,然后将其重构为函数式方法。

在本章中,我们将创建一个 Windows 窗体应用程序,并探索如何创建一个窗体,然后向其添加代码。完成本章后,我们将能够将 Windows 窗体应用程序从命令式方法重构为函数式方法。

在本章中,我们将涵盖以下主题:

  • 创建一个 Windows 窗体应用程序

  • 探索如何创建一个窗体,然后向其添加代码

  • 在命令式方法中创建引擎代码

  • 将引擎代码从命令式转换为函数式方法

在 Windows 窗体中开发函数式编程

现在,我们将在 Windows 窗体应用程序中开发一个计算器应用程序。为此,我们必须创建一个新的 Windows 窗体项目和一个新的窗体,其中包含数字 0 到 9 和其他功能的多个按钮,如下面的屏幕截图所示:

在 Windows 窗体中开发函数式编程

如您所见,我们有 10 个按钮,代表数字 0 到 9 和标准数学运算符,如加(+),减(-),乘()和除(/)。我们还有一些额外的功能按钮;它们是平方根(sqrt),百分比(%)和倒数(1/x)。其余包括这些按钮:切换符号(+/-),小数点(.),清除输入(CE),全部清除(C)和退格(del*)。我们还有一个文本框来显示我们输入的数字,并设置在窗体的顶部。最后但并非最不重要的是,在所有计算器应用程序中都有一个等号按钮。我们给所有这些控件命名,如下面的代码片段所示:

namespace CalculatorImperative 
{ 
  partial class Form1 
  { 
    private System.Windows.Forms.Button btn0; 
    private System.Windows.Forms.Button btn1; 
    private System.Windows.Forms.Button btn2; 
    private System.Windows.Forms.Button btn3; 
    private System.Windows.Forms.Button btn4; 
    private System.Windows.Forms.Button btn5; 
    private System.Windows.Forms.Button btn6; 
    private System.Windows.Forms.Button btn7; 
    private System.Windows.Forms.Button btn8; 
    private System.Windows.Forms.Button btn9; 
    private System.Windows.Forms.Button btnSwitchSign; 
    private System.Windows.Forms.Button btnDecimal; 
    private System.Windows.Forms.Button btnAdd; 
    private System.Windows.Forms.Button btnDivide; 
    private System.Windows.Forms.Button btnMultiply; 
    private System.Windows.Forms.Button btnSubstract; 
    private System.Windows.Forms.Button btnEquals; 
    private System.Windows.Forms.Button btnSqrt; 
    private System.Windows.Forms.Button btnPercent; 
    private System.Windows.Forms.Button btnInverse; 
    private System.Windows.Forms.Button btnDelete; 
    private System.Windows.Forms.Button btnClearAll; 
    private System.Windows.Forms.Button btnClearEntry; 
    private System.Windows.Forms.TextBox txtScreen; 
  } 
} 

在我们拥有所有这些控件之后,以下代码片段仅包含控件的名称和单击事件(如果有的话),我们必须设置以便简化此应用程序的创建,因为控件的名称未更改:

namespace CalculatorImperative 
{ 
  partial class Form1 
  { 
    private void InitializeComponent() 
    { 
      this.btn0.Name = "btn0"; 
      this.btn0.Click += 
        new System.EventHandler(this.btnNumber_Click); 
      this.btn1.Name = "btn1"; 

      // The rest of code can be found  
      // in the downloaded source code 
    } 
  } 
} 

附加设置,如控件的轴位置、字体或对齐方式,不重要,因为这些设置不会影响整个代码。

创建窗体的代码后台

窗体中的所有控件都已设置好,现在我们准备向其添加一些代码。如前面代码片段中的所有事件点击中所示,当按下特定按钮时,将调用五个函数:btnNumber_Click()btnFunction_Click()btnEquals_Click()btnClear_Click()btnOperator_Click()

btnNumber_Click()函数用于 0 到 9 按钮。btnFunction_Click()函数用于btnSwitchSignbtnDecimalbtnSqrtbtnPercentbtnInversebtnDelete按钮。btnEquals_Click()函数用于btnEquals按钮。btnClear_Click()函数用于btnClearAllbtnClearEntry按钮。btnOperator_Click()用于btnAddbtnSubstractbtnDividebtnMultiply按钮。还将有一些我们将讨论的辅助函数。

现在让我们看一下以下代码片段,其中包含了btnNumber_Click()函数的实现:

namespace CalculatorImperative 
{ 
  public partial class Form1 : Form 
  { 
    private void btnNumber_Click(object sender, EventArgs e) 
    { 
      Button btnNum = sender as Button; 
      int numValue; 
      switch (btnNum.Name) 
      { 
        case "btn1": 
          numValue = 1; 
          break; 
        case "btn2": 
          numValue = 2; 
          break; 
        case "btn3": 
          numValue = 3; 
          break; 
        case "btn4": 
          numValue = 4; 
          break; 
        case "btn5": 
          numValue = 5; 
          break; 
        case "btn6": 
          numValue = 6; 
          break; 
        case "btn7": 
          numValue = 7; 
          break; 
        case "btn8": 
          numValue = 8; 
          break; 
        case "btn9": 
          numValue = 9; 
          break; 
        default: 
          numValue = 0; 
          break; 
      } 
      CalcEngine.AppendNum(numValue); 
      UpdateScreen(); 
    } 
  } 
} 

如前面的代码片段所示,btnNumber_Click()函数将检测按下的数字按钮,然后在文本框中显示它。现在,让我们暂时跳过CalcEngine.AppendNum()UpdateScreen()函数,因为我们将在后面讨论它们。

让我们继续看btnFunction_Click()函数,它将在按下功能按钮时执行一个操作。该函数的实现如下:

namespace CalculatorImperative 
{ 
  public partial class Form1 : Form 
  { 
    private void btnFunction_Click(object sender, EventArgs e) 
    { 
      Button btnFunction = sender as Button; 
      string strValue; 
      switch (btnFunction.Name) 
      { 
        case "btnSqrt": 
          strValue = "sqrt"; 
          break; 
        case "btnPercent": 
          strValue = "percent"; 
          break; 
        case "btnInverse": 
          strValue = "inverse"; 
          break; 
        case "btnDelete": 
          strValue = "delete"; 
          break; 
        case "btnSwitchSign": 
          strValue = "switchSign"; 
          break; 
        case "btnDecimal": 
          strValue = "decimal"; 
          break; 
        default: 
          strValue = ""; 
          break; 
      } 
      CalcEngine.FunctionButton(strValue); 
      UpdateScreen(); 
    } 
  } 
} 

从前面的代码片段可以看出,btnFunction_Click()将在按下btnSqrtbtnPercentbtnInversebtnDeletebtnSwitchSignbtnDecimal按钮时采取行动。

负责当操作符按钮之一被按下时的函数,以下是btnOperator_Click()函数实现的代码片段:

namespace CalculatorImperative 
{ 
  public partial class Form1 : Form 
  { 
    private void btnOperator_Click(object sender, EventArgs e) 
    { 
      Button btnOperator = sender as Button; 
      string strOperator = ""; 
      switch (btnOperator.Name) 
      { 
        case "btnAdd": 
          strOperator = "add"; 
          break; 
        case "btnSubtract": 
          strOperator = "subtract"; 
          break; 
        case "btnMultiply": 
          strOperator = "multiply"; 
          break; 
        case "btnDivide": 
          strOperator = "divide"; 
          break; 
      } 
      CalcEngine.PrepareOperation( 
        strOperator); 
      UpdateScreen(); 
    } 
  } 
} 

前面的btnOperator()函数将用于运行每个操作符的操作:加、减、乘、除。然后调用CalcEngine类中的PrepareOperation()方法,我们稍后会讨论。

要清除一个条目或所有条目,我们有两个按钮:btnClearEntrybtnClearAll。这两个按钮每次生成按下事件时都会调用btnClear_Click()方法。该函数的实现如下:

namespace CalculatorImperative 
{ 
  public partial class Form1 : Form 
  { 
    private void btnClear_Click(object sender, EventArgs e) 
    { 
      if (sender is System.Windows.Forms.Button) 
      { 
        Button btnClear = sender as Button; 
        switch (btnClear.Name) 
        { 
          case "btnClearAll": 
            CalcEngine.ClearAll(); 
            UpdateScreen(); 
            break; 
          case "btnClearEntry": 
            CalcEngine.Clear(); 
            UpdateScreen(); 
            break; 
        } 
      } 
    } 
  } 
} 

CalcEngine类中也有两个方法,当这两个清除按钮被按下时会被调用:CalcEngine.Clear()用于btnClearEntry按钮,CalcEngine.ClearAll()用于btnClearAll按钮。

我们拥有的最后一个按钮是btnEquals按钮,每次按下时都会调用btnClear_Click()方法;实现如下:

namespace CalculatorImperative 
{ 
  public partial class Form1 : Form 
  { 
    private void btnEquals_Click(object sender, EventArgs e) 
    { 
      //Attempt to solve the math 
      if (!CalcEngine.Solve()) 
      { 
        btnClearAll.PerformClick(); 
      } 
      UpdateScreen(); 
    } 
  } 
} 

从前面的代码片段可以看出,当按下btnEquals按钮时,它会尝试计算用户在调用CalcEngine.Solve()方法之前给出的操作,然后更新文本框。如果计算失败,它将清除条目。

现在,让我们创建UpdateScreen()方法,用于将当前数字显示到txtScreen文本框中。实现如下:

namespace CalculatorImperative 
{ 
  public partial class Form1 : Form 
  { 
    private void UpdateScreen() 
    { 
      txtScreen.Text = FormatDisplay( 
        Convert.ToString( 
        CalcEngine.GetDisplay())); 
    } 
  } 
} 

UpdateScreen()方法内,调用FormatDisplay()方法来在txtScreen上形成显示。FormatDisplay()方法的实现如下:

namespace CalculatorImperative 
{ 
  public partial class Form1 : Form 
  { 
    private string FormatDisplay( 
      string str) 
    { 
      String dec = ""; 
      int totalCommas = 0; 
      int pos = 0; 
      bool addNegative = false; 

      if (str.StartsWith("-")) 
      { 
        str = str.Remove(0, 1); 
        addNegative = true; 
      } 

      if (str.IndexOf(".") > -1) 
      { 
        dec = str.Substring( 
          str.IndexOf("."), 
        str.Length - str.IndexOf(".")); 
        str = str.Remove( 
          str.IndexOf("."), 
          str.Length - str.IndexOf(".")); 
      } 

      if (Convert.ToDouble(str) < 
        Math.Pow(10, 19)) 
      { 
        if (str.Length > 3) 
        { 
          totalCommas = 
            (str.Length - (str.Length % 3)) / 3; 

          if (str.Length % 3 == 0) 
          { 
            totalCommas--; 
          } 

          pos = str.Length - 3; 
          while (totalCommas > 0) 
          { 
            str = str.Insert(pos, ","); 
            pos -= 3; 
            totalCommas--; 
          } 
        } 
      } 

      str += "" + dec; 
      if (str.IndexOf(".") == -1) 
      { 
        str = str + "."; 
      } 

      if (str.IndexOf(".") == 0) 
      { 
        str.Insert(0, "0"); 
      } 
      else if (str.IndexOf(".") == 
        str.Length - 2 &&  
        str.LastIndexOf("0") ==  
        str.Length - 1) 
      { 
        str = str.Remove(str.Length - 1); 
      } 

      if (addNegative) 
      { 
        str = str.Insert(0, "-"); 
      } 

      return str; 
    } 
  } 
} 

根据前面的FormatDisplay()函数实现,首先发生的是函数检查它是否为负数。如果是,首先将移除负号,然后addNegative标志将为true,如下面的代码片段所示:

if (str.StartsWith("-")) 
{ 
  str = str.Remove(0, 1); 
  addNegative = true; 
} 

然后查找小数点(.)字符以指示它是一个小数。如果找到小数点,它将把小数部分存储在dec变量中,其余部分存储在str变量中,如下面的代码片段所示:

if (str.IndexOf(".") > -1) 
{ 
  dec = str.Substring( 
    str.IndexOf("."), 
    str.Length - str.IndexOf(".")); 
  str = str.Remove( 
    str.IndexOf("."), 
    str.Length - str.IndexOf(".")); 
} 

现在,函数将确保数字小于 10¹⁹。如果是,以下代码片段将格式化数字:

if (Convert.ToDouble(str) <  
  Math.Pow(10, 19)) 
{ 
  if (str.Length > 3) 
  { 
    totalCommas = 
      (str.Length - (str.Length % 3)) / 3; 

    if (str.Length % 3 == 0) 
    { 
      totalCommas--; 
    } 

    pos = str.Length - 3; 
    while (totalCommas > 0) 
    { 
      str = str.Insert(pos, ","); 
      pos -= 3; 
      totalCommas--; 
    } 
  } 
} 

从前面的格式中得到的结果将与dec变量连接。如果dec变量中没有小数部分,则小数点字符将被添加到最后位置,如下面的代码片段所示:

str += "" + dec; 
if (str.IndexOf(".") == -1) 
{ 
  str = str + "."; 
} 

如果只有小数部分可用,则0字符将被添加到第一个位置,如下面的代码片段所示:

if (str.IndexOf(".") == 0) 
{ 
  str.Insert(0, "0"); 
} 
else if (str.IndexOf(".") == 
  str.Length - 2 && 
  str.LastIndexOf("0") == 
  str.Length - 1) 
{ 
  str = str.Remove(str.Length - 1); 
} 

最后,我们检查addNegative标志是否为true。如果是,负号(-)将被添加到第一个位置,如下所示:

if (addNegative) 
{ 
  str = str.Insert(0, "-"); 
} 

以命令式方法创建引擎代码

我们已成功创建了表单的后台代码。现在让我们在名为CalcEngine的包装类中创建引擎代码。我们将在CalculatorImperative.csproj项目中的CalcEngine.cs文件中设计它。

准备类属性

在这个计算器引擎类中,我们需要一些属性来保存参与计算过程的特定值。以下是我们将在计算过程中使用的类属性声明的代码片段:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    // This is the behind the scenes number  
    // that represents what will be on the display  
    // and what number to store as last input 
    private static string m_input; 

    // Sign of the number (positive or negative) 
    private static string m_sign; 

    // Current operator selected (+, -, * or /) 
    public static String m_operator; 

    // Last result displayed 
    private static String m_lastNum; 

    // Last input made 
    private static String m_lastInput; 

    // If the calculator should start a new input 
    // after a number is hit 
    public static bool m_wait; 

    // If the user is entering in decimal values 
    public static bool m_decimal; 

    // If the last key that was hit was the equals button 
    private static bool m_lastHitEquals;  
  } 
} 

如您所见,有八个属性将参与计算过程。m_input属性将保存我们输入的所有值和格式化数字m_sign将存储数字是+还是-m_operator属性将存储运算符,即+表示加法,-表示减法,*表示乘法,/表示除法。m_lastNum属性将保存计算结果。m_lastInput属性将保存用户输入的最后一个数字。m_wait属性是一个标志,表示数字已经输入,现在是等待运算符和下一个数字的时间。m_decimal属性标志表示是否为小数。m_lastHitEquals属性标志表示btnEquals是否已被按下。

构造构造函数

在每个类中,最好有一个构造函数来准备类的属性。这个类也是一样。以下是类构造函数实现的代码片段:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    static CalcEngine() 
    { 
      // "." is used to represent no input 
      // which registers as 0 
      m_input = "."; 

      m_sign = "+"; 
      m_operator = null; 
      m_lastNum = null; 
      m_lastInput = null; 
      m_wait = false; 
      m_decimal = false; 
      m_lastHitEquals = false; 
    } 
  } 
} 

从上面的代码片段可以看出,如果我们想要重置所有类属性,我们必须调用构造函数,即CalcEngine()。对于m_input,我们使用点(.)字符表示没有用户输入。我们还使用static修饰符,因为类将直接通过类名而不是类的实例来调用。

清除属性

之前,我们讨论过我们有两个清除方法:ClearAll()Clear(),如下所示:

switch (btnClear.Name) 
{ 
  case "btnClearAll": 
    CalcEngine.ClearAll(); 
    UpdateScreen(); 
    break; 
  case "btnClearEntry": 
    CalcEngine.Clear(); 
    UpdateScreen(); 
    break; 
} 

上面的代码片段是从btnClear_Click()方法中提取的。以下是ClearAll()方法的实现:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    // Resets all variables 
    public static void ClearAll() 
    { 
      //Reset the calculator 
      m_input = "."; 
      m_lastNum = null; 
      m_lastInput = null; 
      m_operator = null; 
      m_sign = "+"; 
      m_wait = false; 
      m_decimal = false; 
      m_lastHitEquals = false; 
    } 
  } 
} 

ClearAll()方法将重置CalcEngine类的所有属性。这类似于类构造函数的实现。因此,我们可以修改类构造函数的实现如下:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    static CalcEngine() 
    { 
      ClearAll(); 
    } 
  } 
} 

我们还有Clear()方法只清除最后一个条目。为此,我们只需要重置m_signm_inputm_decimalClear()方法的实现如下:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    // For Clear Entry,  
    // just reset appropriate variable 
    public static void Clear() 
    { 
      //Just clear the current input 
      m_sign = "+"; 
      m_input = "."; 
      m_decimal = false; 
    } 
  } 
} 

将数字附加到显示框

我们知道,我们有一个文本框来显示我们输入的数字或显示计算结果。在btnNumber_Click()方法的实现中,我们调用CalcEngine.AppendNum()方法,以下是其实现:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    // Appends number to the input 
    public static void AppendNum( 
      double numValue) 
    { 
      if (numValue == Math.Round(numValue) && 
        numValue >= 0) 
      { 
         // The rest of code can be found  
         // in the downloaded source code 
      } 
      // If they're trying to append a decimal or negative,  
      // that's impossible so just replace the entire input 
      // with that value 
      else 
      { 
         // The rest of code can be found  
         // in the downloaded source code 
      } 
    } 
  } 
} 

从上面的代码可以看出,我们必须区分带有负号的数字或带有点号标记的小数。为此,我们使用以下代码片段:

if (numValue == Math.Round(numValue) && 
    numValue >= 0) 

如果它是一个没有负数或小数点的纯数字,我们检查m_input是否为空或m_wait标志是否为true。如果是,我们可以继续进程。如果小数标志打开,我们就不需要再插入点号了;否则,我们必须添加点号。以下代码片段将更详细地解释我们的解释:

if (!IsEmpty()) 
{ 
  // if decimal is turned on 
  if (m_decimal) 
  { 
    m_input += "" + numValue; 
  } 
  else 
  { 
    m_input = m_input.Insert( 
      m_input.IndexOf("."), "" + numValue); 
  } 
} 

如您所见,我们调用IsEmpty()函数来检查m_input是否为空或m_wait标志是否为 true。函数的实现如下:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    // Indicate that user doesn't input value yet 
    private static bool IsEmpty() 
    { 
      if (m_input.Equals(".") || m_wait) 
        return true; 
      else 
        return false; 
    } 
  } 
} 

如果IsEmpty()返回true,它将继续进程,如下所示:

if (m_lastHitEquals)  
{ 
  ClearAll(); 
  m_lastHitEquals = false; 
} 

if (m_decimal) 
{ 
  m_input = "." + numValue; 
} 
else 
{ 
  m_input = numValue + "."; 
} 
m_wait = false; 

从上面的代码,首先,我们检查m_lastHitEquals标志是否打开。如果是,我们重置所有类属性,然后将m_lastHitEquals设置为关闭。然后,我们检查m_decimal标志是否打开。如果是,将点号插入数字前面。如果不是,在数字后面插入点号。之后,关闭m_wait标志。

我们还必须确保没有插入不必要的零,使用以下代码片段:

if (m_input.IndexOf("0", 0, 1) == 0 && 
  m_input.IndexOf(".") > 1) 
{ 
  //Get rid of any extra zeroes  
  //that may have been prepended 
  m_input = m_input.Remove(0, 1); 
} 

上面的代码将处理用户输入,如果不包含负号(-)或点号。如果有,我们必须使用以下代码片段来检查它是否有这些标记:

if (m_input.Contains(".") && 
  !(m_input.EndsWith("0") && 
  m_input.IndexOf(".") == 
  m_input.Length - 2)) 
{ 
  m_decimal = true; 
} 

if (m_input.Contains("-")) 
{ 
  m_sign = "-"; 
} 
else 
{ 
  m_sign = "+"; 
} 

然而,在执行上述过程之前,我们必须重置所有类属性并重新格式化数字如下:

// Start over if the last key hit  
// was the equals button  
// and no operators were chosen 
if (m_lastHitEquals)  
{ 
  ClearAll(); 
  m_lastHitEquals = false; 
} 
m_input = "" + numValue; 

// Reformat 
m_input = FormatInput(m_input); 
if (!m_input.Contains(".")) 
{ 
  m_input += "."; 
} 

再次,我们删除不必要的零并关闭m_wait标志,如下所示:

// Get rid of any extra zeroes 
// that may have been prepended or appended 
if (m_input.IndexOf("0", 0, 1) == 0 && 
  m_input.IndexOf(".") > 1) 
{ 
  m_input = m_input.Remove(0, 1); 
} 

if (m_input.EndsWith("0") &&  
  m_input.IndexOf(".") == m_input.Length - 2) 
{ 
  m_input.Remove(m_input.Length - 1); 
} 

m_wait = false; 

准备数学运算

当我们按下运算符按钮之一时,将触发btnOperator_Click()函数;在函数内部,有一个CalcEngine.PrepareOperation()函数来准备计算。CalcEngine.PrepareOperation()函数的实现如下:

namespace CalculatorImperative 
{ 
  internal class CalcEngine 
  { 
    // Handles operation functions 
    public static void PrepareOperation( 
      string strOperator) 
    { 
      switch (strOperator) 
      { 
         // The rest of code can be found  
         // in the downloaded source code 
      } 
    } 
  } 
} 

上述代码的解释很简单。我们只需要知道用户按下了哪个按钮,+,-,*或/。然后,我们检查用户输入的是否是第一个数字,方法是检查m_lastNum是否为空,或者m_wait是否打开。如果是,我们在确保m_lastNum不为空,m_lastHitEquals关闭,m_wait关闭,并且当前m_operator与用户刚刚按下的运算符不同的情况下解决计算。之后,我们用用户输入的当前运算符替换m_operator,并用已格式化的m_input填充m_lastNum。还必须应用其他设置。以下代码片段将更好地解释这一点:

// If this is the first number  
// that user inputs 
if (m_lastNum == null || 
  m_wait) 
{ 
  if (m_lastNum != null && 
    !m_operator.Equals("+") && 
    !m_lastHitEquals && 
    !m_wait) 
  Solve(); 
  m_operator = "+"; 
  m_lastNum = "" + FormatInput(m_input); 
  m_sign = "+"; 
  m_decimal = false; 
  m_wait = true; 
} 

否则,如果这不是用户输入的第一个数字,我们可以执行以下过程:

else 
{ 
    if (!m_wait) 
        Solve(); 
    m_operator = "+"; 
    m_sign = "+"; 
    m_wait = true; 
} 

格式化输入

在我们进入上一个PrepareOperation()函数中讨论的Solve()函数实现之前,让我们先讨论FormatInput()函数。以下是FormatInput()方法的实现:

namespace CalculatorImperative 
{ 
    internal class CalcEngine 
    { 
        // Formats the input into a valid double format 
        private static string FormatInput( 
            string str) 
        { 
            // Format the input to something convertable  
            // by Convert.toDouble 

            // Prepend a Zero  
            // if the string begins with a "." 
            if (str.IndexOf(".") == 0)  
            { 
                str = "0" + str; 
            } 

            // Appened a Zero  
            // if the string ends with a "." 
            if (str.IndexOf(".") ==  
                str.Length - 1)  
            { 
                str = str + "0"; 
            } 

            // If negative is turned on  
            // and there's no "-"  
            // in the current string 
            // then "-" is prepended 
            if (m_sign.Equals("-") &&  
                str != "0.0" &&  
                str.IndexOf("-") == -1)  
            { 
                str = "-" + str; 
            } 

            return str; 
        } 
    } 
} 

FormatInput()方法用于形成将显示在txtScreen文本框中的数字。

解决计算

当我们按下btnEquals按钮或具有先前输入的运算符按钮时,将调用Solve()方法来计算操作。以下是该方法的实现:

namespace CalculatorImperative 
{ 
    internal class CalcEngine 
    { 
        // Solve the currently stored expression 
        public static bool Solve() 
        { 
            bool canSolve = true; 

            // The rest of code can be found  
            // in the downloaded source code 

            return canSolve; 
        } 
    } 
} 

计算附加操作

正如我们讨论过的,我们还有其他功能按钮:btnSqrtbtnPercentbtnInversebtnDeletebtnSwitchSignbtnDecimal按钮。以下是如果按下其中一个按钮将被调用的方法:

namespace CalculatorImperative 
{ 
    internal class CalcEngine 
    { 
        // Handles decimal square roots,  
        // decimal buttons, percents, inverse, delete,  
        // and sign switching 
        public static bool FunctionButton( 
            string str) 
        { 
            bool success = false; 
            switch (str) 
            { 
               // The rest of code can be found  
               // in the downloaded source code 
            } 
            return success; 
        } 
    } 
} 

在功能方法中创建引擎代码

我们已成功使用命令式方法创建了计算器应用程序。现在,是时候将所有命令式代码重构为功能代码。我们将首先重构引擎,然后是表单后面的代码。

添加几个新属性

我们将与命令式代码完全相同的属性,只是添加了三个新属性,如下所示:

namespace CalculatorFunctional 
{ 
    public class Calc 
    { 
        public string m_input { get; set; } 
        public string m_sign { get; set; } 
        public string m_operator { get; set; } 
        public string m_lastNum { get; set; } 
        public string m_lastInput { get; set; } 
        public bool m_wait { get; set; } 
        public bool m_decimal { get; set; } 
        public bool m_lastHitEquals { get; set; } 

        public bool m_solve { get; set; } 
        public string m_answer { get; set; } 
        public bool m_funcSuccess { get; set; } 
    } 
} 

正如您在上述代码中所看到的,m_solvem_answerm_funcSuccess是我们刚刚添加的新属性。我们稍后将在Solve()函数中使用这三个附加属性。

简化模式匹配

正如我们在第九章“使用模式”中讨论的那样,我们将使用Simplicity类,我们可以在SimplicityLib.cs文件中找到。该类的实现如下:

namespace CalculatorFunctional 
{ 
    public static class CalcMethodsExtension 
    { 
        public static Calc AppendNum( 
            this Calc calc, 
            double numValue) 
        {             
           // The rest of code can be found  
           // in the downloaded source code 
        } 

        public static Calc AppendNumWhenRound( 
            this Calc calc, 
            double numValue) 
        { 
           // The rest of code can be found  
           // in the downloaded source code 
        } 

        // The rest of code can be found  
        // in the downloaded source code  
    } 
} 

分配属性

为了能够分配属性,我们需要分配属性的扩展方法。以下代码将更好地解释这一点:

namespace CalculatorFunctional 
{ 
    public static class CalcPropertiesExtension 
    { 
        public static Calc Input( 
            this Calc calc, 
            string input) 
        { 
            calc.m_input = 
                input; 
            return calc; 
        } 

        public static Calc LastNum( 
            this Calc calc, 
            string lastNum) 
        { 
            calc.m_lastNum = 
                lastNum; 
            return calc; 
        } 

        // The rest of code can be found  
        // in the downloaded source code 

        public static Calc ModifyCalcFuncSuccess( 
            this Calc calc, 
            bool val) 
        { 
            calc.m_funcSuccess = val; 
            return calc; 
        } 

        public static Calc ModifyCalcFuncSuccessBasedOn( 
            this Calc calc, 
            Func<bool> predicate) 
        { 
            return predicate() ? 
                calc.ModifyCalcFuncSuccess(true) : 
                calc.ModifyCalcFuncSuccess(false); 
        } 
    } 
} 

每次调用上述方法之一时,该方法将返回已更改目标属性的Calc类。

通过清除属性构造类

在这种功能方法中,我们不会构造类;我们将清除属性以使所有属性准备好运行该过程。我们将使用两种清除方法:Clear()ClearAll()方法。以下代码片段是这两种方法的实现:

namespace CalculatorFunctional 
{ 
    public static class CalcMethodsExtension 
    { 
        public static Calc Clear( 
            this Calc calc) 
        { 
            return calc 
                .ModifyCalcSign("+") 
                .ModifyCalcInput(".") 
                .ModifyCalcDecimal(false); 
        } 

        public static Calc ClearAll( 
            this Calc calc) 
        { 
            return calc 
                .Clear() 
                .ModifyCalcLastNum(null) 
                .ModifyCalcLastInput(null) 
                .ModifyCalcOperator(null) 
                .ModifyCalcWait(false) 
                .ModifyCalcLastHitEquals(false); 
        } 
    } 
} 

在我们讨论命令式方法时,Clear()方法是用于btnClearEntry按钮的,ClearAll()是用于btnClearAll按钮的。

将输入的数字附加到文本框

在这种功能性方法中,我们将把命令式方法中的AppendNum()方法重构为功能性方法,如下所示:

namespace CalculatorFunctional 
{ 
    public static class CalcMethodsExtension 
    { 
        public static Calc AppendNum( 
            this Calc calc, 
            double numValue) 
        { 
            // The rest of code can be found  
            // in the downloaded source code 
        } 

        public static Calc AppendNumWhenRound( 
            this Calc calc, 
            double numValue) 
        { 
           // The rest of code can be found  
           // in the downloaded source code 
        } 

        // The rest of code can be found  
        // in the downloaded source code  
    } 
} 

准备操作

为了在按下运算符按钮后准备操作,以下代码是从命令式方法中重构的PreparingOperation()方法:

namespace CalculatorFunctional 
{ 
    public static class CalcMethodsExtension 
    { 
        public static Calc PrepareOperation( 
            this Calc calc, 
            string strOperator) 
        { 
            // The rest of code can be found  
            // in the downloaded source code 
        } 

        public static Calc PrepareOperationAdd( 
            this Calc calc) 
        { 
            // The rest of code can be found  
            // in the downloaded source code 
        } 

        public static Calc  
            PrepareOperationAddLastNumNull( 
                this Calc calc) 
        { 
            // The rest of code can be found  
            // in the downloaded source code 
        } 

        // The rest of code can be found  
        // in the downloaded source code 
    } 
} 

格式化输入

为了格式化我们用来形成txtScreen输入的输入,我们将使用以下代码:

namespace CalculatorFunctional 
{ 
    public static class CalcMethodsExtension 
    { 
        public static String FormatInput( 
            this Calc calc,  
            String n) 
        { 
            return n 
                .ModifyStringWhen( 
                    () => n.IndexOf(".") == 0, 
                    () => n = "0" + n) 
                .ModifyStringWhen( 
                    () => n.IndexOf(".") == n.Length - 1, 
                    () => n = n + "0") 
                .ModifyStringWhen( 
                    () => calc.m_sign.Equals("-") && 
                        n != "0.0" && 
                        n.IndexOf("-") == -1, 
                    () => n = "-" + n); 
        } 
    } 
} 

正如您在上述代码中所看到的,我们使用了ModifyStringWhen()扩展方法,其实现如下:

namespace CalculatorFunctional 
{ 
  public static class StringMethodsExtension 
  { 
    public static string ModifyStringWhen( 
      this string @this, 
      Func<bool> predicate, 
      Func<string> modifier) 
    { 
      return predicate() 
      ? modifier() 
      : @this; 
    } 
  } 
} 

解决计算

解决计算可以使用命令式方法中的Solve()方法。以下代码是从命令式方法中重构的Solve()方法:

namespace CalculatorFunctional 
{ 
  public static class CalcMethodsExtension 
  { 
    public static Calc Solve( 
      this Calc calc) 
    { 
      return calc.CleanUp() 
      .Answer() 
      .UpdateAnswerToCalc(); 
    } 
  } 
} 

对于CleanUp()Answer()UpdateAnswerToCalc()方法的实现,我们可以使用以下代码:

namespace CalculatorFunctional 
{ 
    public static class CalcSolveMethodsExtension 
    { 
        public static Calc Answer( 
            this Calc calc) 
        { 
            calc.m_answer = calc.m_operator.Match() 
                .With(o => o == "+",  
                    calc.m_lastNum.SolveAdd( 
                        calc.m_lastInput)) 
                .With(o => o == "-",  
                    calc.m_lastNum.SolveSubtract( 
                        calc.m_lastInput)) 
                .With(o => o == "*",  
                    calc.m_lastNum.SolveMultiply( 
                        calc.m_lastInput)) 
                .With(o => o == "/",  
                    !calc.FormatInput( 
                        calc.m_lastInput).Equals( 
                            "0.0") ?  
                        calc.m_lastNum.SolveDivide( 
                            calc.m_lastInput) :  
                        "") 
                .Else("") 
                .Do(); 

            calc.m_solve = calc.m_answer.Match() 
                .With(o => o.Equals(""), false) 
                .Else(true) 
                .Do(); 

            return calc; 
        } 

        public static Calc CleanUp( 
            this Calc calc) 
        { 
            return calc 
                .ModifyCalcInputWhen( 
                    () => calc.m_input.Equals(""), 
                    "0") 
                .ModifyCalcLastNumWhen( 
                    () => calc.m_lastNum == null || 
                        calc.m_lastNum.Equals(""), 
                    "0,0") 
                .ModifyCalcLastInputWhen( 
                    () => !calc.m_wait, 
                    "" + calc.FormatInput( 
                        calc.m_input)); 
        } 

        public static Calc UpdateAnswerToCalc( 
            this Calc calc) 
        { 
            calc.m_lastNum = calc.m_answer; 
            calc.m_input = calc.m_answer; 
            calc.m_sign = "+"; 
            calc.m_decimal = false; 
            calc.m_lastHitEquals = true; 
            calc.m_wait = true; 

            calc.m_solve = true; 
            return calc; 
        } 
    } 
} 

我们还需要为string数据类型创建扩展方法,以适应加法、减法、乘法和除法运算,如下所示:

namespace CalculatorFunctional 
{ 
    public static class StringMethodsExtension 
    { 
        public static string SolveAdd( 
            this string @string,  
            string str) 
        { 
            return Convert.ToString( 
                Convert.ToDouble(@string) + 
                Convert.ToDouble(str)); 
        } 

        public static string SolveSubtract( 
            this string @string, 
            string str) 
        { 
            return Convert.ToString( 
                Convert.ToDouble(@string) - 
                Convert.ToDouble(str)); 
        } 

        public static string SolveMultiply( 
            this string @string, 
            string str) 
        { 
            return Convert.ToString( 
                Convert.ToDouble(@string) * 
                Convert.ToDouble(str)); 
        } 

        public static string SolveDivide( 
            this string @string, 
            string str) 
        { 
            return Convert.ToString( 
                Convert.ToDouble(@string) / 
                Convert.ToDouble(str)); 
        } 
    } 
} 

计算额外的操作

对于附加按钮,每次按下附加按钮时都会调用FunctionButton()方法,以下是从命令式FunctionButton()方法中重构的代码:

namespace CalculatorFunctional 
{ 
    public static class CalcMethodsExtension 
    { 
        public static Calc PrepareOperation( 
            this Calc calc, 
            string strOperator) 
        { 
            // The rest of code can be found  
            // in the downloaded source code 
        } 

        public static Calc PrepareOperationAdd( 
            this Calc calc) 
        { 
            // The rest of code can be found  
            // in the downloaded source code 
        } 

        // The rest of code can be found  
        // in the downloaded source code 
    } 
} 

摘要

我们成功地在 Windows 表单中构建了一个计算器应用程序。我们还将命令式代码重构为功能性方法。我们创建了一些扩展方法来解决所有重构过程,使它们可以是功能性的。

在下一章中,我们将讨论功能性方法中的最佳实践代码,并为我们在本章中构建的应用程序执行单元测试。

第十一章:编码最佳实践和测试函数式代码

在上一章中,我们开发了一个函数式应用程序。为了在函数式方法中创建更好的代码,我们必须遵循最佳实践规则并在我们的代码中实现它们。在本章中,我们将讨论函数式方法的概念,即纯函数,并使我们的函数类似于数学函数。本章将涵盖以下主题:

  • 防止不诚实的签名

  • 创建不可变类

  • 避免“时间耦合”

  • 处理副作用

  • 将代码分离为领域逻辑可变外壳

  • 测试函数式代码

函数式 C#中的编码最佳实践

函数式方法有纯函数的概念。这意味着只要我们传递完全相同的输入,函数将产生相同的结果。现在,让我们开始讨论按照这里概述的编码最佳实践来创建更好的函数式代码。

防止不诚实的签名

正如我们在第一章中讨论的,在 C#中尝试函数式风格,我们使用数学方法来构建我们的函数式编程代码。换句话说,函数式编程是使用数学函数进行编程。数学函数必须符合两个要求,它们是:

  • 数学函数应该在我们提供相同参数时始终返回相同的结果。

  • 数学函数的签名应该提供所有可能接受的输入值和可能产生的输出的信息。

现在让我们看一下以下的代码片段,我们可以在HonestSignature.csproj项目中找到:

public partial class Program 
{ 
  public static int SumUp( 
    int a, int b) 
  { 
    return a + b; 
  } 
} 

通过检查前面的SumUp()函数,我们可以说每次传递相同的输入时,我们将获得相同的输出。现在让我们检查以下的GenerateRandom()函数,我们也可以在HonestSignature.csproj项目中找到:

public partial class Program 
{ 
  public static int GenerateRandom( 
    int max) 
  { 
    Random rnd = new Random( 
      Guid.NewGuid() 
      .GetHashCode()); 
    return rnd.Next(max); 
  } 
} 

从前面的代码中,我们可以看到,尽管我们不断传递相同的输入,但我们将获得不同的输出。假设我们有以下的RunGenerateRandom()函数:

public partial class Program 
{ 
  public static void RunGenerateRandom() 
  { 
    for (int i = 0; i < 10; i++) 
    { 
      Console.WriteLine( 
        String.Format( 
          "Number {0} = {1}", 
          i, 
          GenerateRandom(100))); 
    } 
  } 
} 

如果我们运行前面的RunGenerateRandom()函数,将在控制台上得到以下输出:

防止不诚实的签名

从前面的代码片段中,我们通过传递完全相同的参数,即 100,10 次调用了GenerateRandom()函数。正如您在前面的图中所看到的,该函数对于这 10 次调用的每一次返回了不同的输出。因此,我们必须避免类似GenerateRandom()函数这样的函数,以创建纯函数,因为它不是数学函数。

现在让我们看一下以下的Divide()函数,它将第一个参数除以第二个参数:

public partial class Program 
{ 
  public static int Divide( 
    int a, int b) 
  { 
    return a / b; 
  } 
} 

Divide()函数看起来与SumUp()函数相似,因为Divide()函数的签名接受任意两个整数并返回另一个整数。因此,如果我们传递完全相同的参数,它将返回相同的输出。但是,如果我们将 1 和 0 作为输入参数传递会怎样呢?Divide()函数将抛出DivideByZeroException错误,而不是返回一个整数值。在这种情况下,我们可以得出结论,函数的签名没有提供关于操作结果的足够信息。它看起来函数可以处理任意两个整数类型的参数,但实际上却不能。为了解决这个问题,我们可以将前面的Divide()函数重构为以下函数:

public partial class Program 
{ 
  public static int? Divide( 
    int a, int b) 
  { 
    if (b == 0) 
    return null; 
    return a / b; 
  } 
} 

正如您在前面的Divide()函数中所看到的,我们通过在int后添加问号来添加nullable类型,以便函数的返回可以为 null。我们还添加了一个if语句,以确保永远不会抛出DivideByZeroException错误。

将可变类重构为不可变类

在函数式编程中,不可变性非常重要,因为可变操作会使我们的代码不诚实。正如我们之前讨论过的,我们需要防止不诚实的操作,以便创建我们的纯函数方法。不可变性应用于数据结构 - 例如,类意味着该类的对象在其生命周期内不能被更改。换句话说,我们可以说如果类的实例在某种方式上可以被更改,那么该类是可变的,而如果我们创建实例后就无法修改该类的实例,则它是不可变的。

现在,让我们看一下以下代码,它可以在Immutability.csproj项目中找到,以继续我们的讨论:

namespace Immutability 
{ 
  public class UserMembership 
  { 
    private User _user; 
    private DateTime _memberSince; 
    public void UpdateUser( 
      int userId, string name) 
    { 
      _user = new User( 
       userId, 
       name); 
    } 
  } 
  public class User 
  { 
    public int Id { get; } 
    public string Name { get; } 
    public User( 
      int id, 
      string name) 
    { 
      Id = id; 
      Name = name; 
    } 
  } 
} 

正如您在前面的代码中所看到的,我们有一个简单的组合。UserMembership类由_user_memberSince属性组成。我们还可以看到User类是不可变的,因为所有属性都被定义为只读。由于不可变性,UserMembership方法更新_user字段的唯一方法是创建一个新的User实例并用它替换旧的实例。请注意,User类本身在这里不包含状态,而UserMembership类包含。我们可以说UpdateUser方法通过更改对象的状态留下了副作用。

现在让我们重构UpdateUser方法并使其不可变。以下代码是重构UpdateUser方法的结果:

namespace Immutability 
{ 
  public class UserMembership 
  { 
    private readonly User _user; 
    private readonly DateTime _memberSince; 

    public UserMembership( 
      User user, 
      DateTime memberSince) 
    { 
       _user = user; 
       _memberSince = memberSince; 
    } 

 public UserMembership UpdateUser(int userId,string name) { 
      var newUser = new User(userId,name);
      return new UserMembership(newUser,_memberSince);
    }

  } 

  public class User 
  { 
    public int Id { get; } 
    public string Name { get; } 
    public User( 
      int id, 
      string name) 
    { 
      Id = id; 
      Name = name; 
    } 
  } 
} 

如您在前面的代码中所看到的,UpdateUser()方法不再更新UserMembership类的结构。相反,它创建一个新的UserMembership实例并将其作为操作的结果返回。通过重构UpdateUser方法,我们已经从方法中消除了副作用。现在清楚了操作的实际输出是什么。使用不可变数据使代码更易读,也有助于立即了解发生了什么,而不需要太多的努力。

避免可变性和时间耦合

有时,使用具有副作用的方法会损害可读性。一个方法的调用与另一个方法的调用耦合在一起。为了明确事情,让我们看一下以下代码,我们可以在TemporalCoupling.csproj项目中找到:

public class MembershipDatabase 
{ 
  private Address _address; 
  private Member _member; 
  public void Process( 
    string memberName, 
    string addressString) 
  { 
    CreateAddress( 
      addressString); 

    CreateMember( 
      memberName); 
    SaveMember(); 
  } 

  private void CreateAddress( 
    string addressString) 
  { 
    _address = new Address( 
      addressString); 
  } 

  private void CreateMember( 
    string name) 
  { 
    _member = new Member( 
    name, 
    _address); 
  } 

  private void SaveMember() 
  { 
    var repository = new Repository(); 
    repository.Save(_member); 
  } 
} 

public class Address 
{ 
  public string _addressString { get; } 
  public Address( 
    string addressString) 
  { 
    _addressString = addressString; 
  } 
} 

public class Member 
{ 
  public string _name { get; } 
  public Address _address { get; } 

  public Member( 
    string name, 
    Address address) 
  { 
    _name = name; 
    _address = address; 
  } 
} 

public class Repository 
{ 
  public static List<Member> customers { get; } 

  public void Save( 
    Member customer) 
  { 
    customers.Add(customer); 
  } 
} 

从前面的代码中,您可以看到我们有一个MembershipDatabase类,它处理一个新成员。它检索名为memberNameaddressString的输入参数,并使用它们在数据库中插入一个新成员。MembershipDatabase类中的Process()方法首先调用CreateAddress方法,该方法将创建地址,然后将其保存到私有字段中。然后CreateMember()方法检索地址并使用它来实例化一个新的Member参数,该参数保存在另一个名为member的私有字段中。最后的方法SaveMember()方法将成员保存到数据库(在此示例中,我们使用list)。这里有一个问题。Process()方法中的调用与时间耦合在一起。我们必须始终以正确的顺序调用这三个方法,以使此代码正常工作。

如果我们不按正确顺序放置方法 - 例如,如果我们在CreateMember()方法调用之后放置CreateAddress()方法调用,则由于成员将无法检索所需的依赖地址,结果成员实例将无效。同样,如果我们将SaveMember()方法调用放在其他方法之上,它将抛出NullReferenceException,因为当它尝试保存成员时,成员实例仍将为 null。

时间耦合是方法签名不诚实的结果。CreateAddress()方法有一个输出,创建一个address实例,但这个输出被隐藏在副作用下,因为我们改变了MembershipDatabase类中的Address字段。CreateMember()方法也隐藏了操作的结果。它保存了member到私有字段,但也隐藏了一些输入。从CreateMember()方法的签名来看,我们可能会认为它只需要名称参数来创建member,但实际上它引用了一个全局状态,即address字段。

SaveMember()方法也发生了同样的情况。为了消除时间耦合,我们必须在方法的签名中明确指定所有的输入和输出,或者换句话说,将所有的副作用和依赖关系移到签名级别。现在,让我们将前面包含副作用的代码重构为以下代码:

public class MembershipDatabase 
{ 
  public void Process( 
    string memberName, 
    string addressString) 
  { 
    Address address = CreateAddress( 
      addressString); 
    Member member = CreateMember( 
      memberName, 
      address); 
    SaveMember(member); 
  } 

  private Address CreateAddress( 
    string addressString) 
  { 
    return new Address( 
      addressString); 
  } 

  private Member CreateMember( 
    string name, 
    Address address) 
  { 
    return new Member( 
      name, 
      address); 
  } 

  private void SaveMember( 
    Member member) 
  { 
    var repository = new Repository(); 
    repository.Save( 
      member); 
  } 
} 

public class Address 
{ 
  public string _addressString { get; } 
  public Address( 
    string addressString) 
  { 
    _addressString = addressString; 
  } 
} 

public class Member 
{ 
  public string _name { get; } 
  public Address _address { get; } 
  public Member( 
    string name, 
    Address address) 
  { 
    _name = name; 
    _address = address; 
  } 
} 

public class Repository 
{ 
  public static List<Member> customers { get; } 

  public void Save( 
    Member customer) 
  { 
    customers.Add(customer); 
  } 
} 

从上面的代码中,我们可以看到我们已经重构了CreateAddress()CreateMember()SaveMember()Process()方法。

CreateAddress()方法现在返回Address而不是将其保存到私有字段中。在CreateMember()方法中,我们添加了一个新的参数address,并且也改变了返回类型。对于SaveMember()方法,我们现在在方法的签名中指定它作为一个依赖项,而不是引用私有字段。在Process()方法中,我们现在可以移除字段,并且成功地消除了时间耦合。

现在,我们无法在CreateMember()调用方法之后放置CreateAddress()调用方法,因为代码将无法编译。

处理副作用

尽管在函数式编程中我们需要创建一个纯函数,但我们无法完全避免副作用。正如你在前面的MembershipDatabase类中所看到的,我们有SaveMember()方法,它将会把 member 字段保存到数据库中。下面的代码片段将清楚地解释这一点:

private void SaveMember( 
  Member member) 
{ 
  var repository = new Repository(); 
  repository.Save( 
    member); 
} 

为了处理副作用,我们可以使用命令查询分离CQS)原则来区分产生副作用和不产生副作用的方法。我们可以为产生副作用的方法调用命令,而对于不产生副作用的方法调用查询。如果方法改变了某个状态,它应该是 void 类型的方法。否则,它应该返回某些东西。使用这个 CQS 原则,我们可以通过查看方法的签名来确定方法的目的。如果方法返回一个值,它将是一个查询,不会改变任何东西。如果方法没有返回值,它必须是一个命令,并且会在系统中留下一些副作用。

从前面的MembershipDatabase类中,我们现在可以确定Process()SaveMember()方法是命令类型,并且会产生一些副作用,因为它们没有返回值。相比之下,CreateAddress()CreateMember()方法是查询,不会改变任何东西,因为它们有返回值。

将代码与领域逻辑和可变外壳分离

有时,当我们的代码处理业务交易时,会多次改变一些数据。在面向对象编程语言的世界中,这是一个很常见的模式。然后我们可以将我们的代码分成领域逻辑和可变外壳。在领域逻辑中,我们简化代码,使用数学函数以函数式的方式编写业务逻辑。结果,这个领域逻辑将变得容易测试。在可变外壳中,我们放置一个可变表达式;在完成业务逻辑后,我们将这样做。

检查包含副作用的代码

现在,让我们来检查下面的代码,其中包含了许多我们将要重构的副作用,并且可以在DomainLogicAndMutatingState.csproj项目中找到它:

public class Librarianship 
{ 
  private readonly int _maxEntriesPerFile; 
  public Librarianship( 
    int maxEntriesPerFile) 
  { 
    _maxEntriesPerFile = 
    maxEntriesPerFile; 
  } 

  public void AddRecord( 
    string currentFile, 
    string visitorName, 
    string bookTitle, 
    DateTime returnDate) 
  { 
     // The rest of code can be found  
     // in the downloaded source code  
  } 

  private string GetNewFileName( 
        string existingFileName) 
  { 
    // The rest of code can be found  
    // in the downloaded source code  
  } 

  public void RemoveRecord( 
      string visitorName,  
      string directoryName) 
  { 
    foreach (string fileName in Directory.GetFiles( 
            directoryName)) 
    { 
      // The rest of code can be found  
      // in the downloaded source code  
    } 
  } 
} 

正如您在前面的代码中所看到的,它是以一种直接的方式编写的。我们将把它的责任分成两部分:一个包含所有领域逻辑的不可变核心,以及一个包含所有可变表达式的可变外壳。

Librarianship类将跟踪图书馆中所有借阅者,并记录归还日期。该类使用日志文件存储借阅者的姓名、借阅书籍的标题和归还日期。日志文件内容的模式是索引号、分号、借阅者姓名,然后再次是分号、书名,然后是分号,最后是归还日期。以下是日志文件内容的示例:

1;Arthur Jackson;Responsive Web Design;9/26/2016 
2;Maddox Webb;AngularJS by Example;9/27/2016 
3;Mel Fry;Python Machine Learning;9/28/2016 
4;Haiden Brown;Practical Data Science Cookbook;9/29/2016 
5;Sofia Hamilton;DevOps Automation Cookbook;9/30/2016 

该类必须能够在日志文件中添加新行,就像我们在AddRecord()方法中看到的那样。但在调用该方法之前,我们必须在构造类时为_maxEntriesPerFile字段指定值。

_maxEntriesPerFile字段的值将在调用AddRecord()方法时使用。如果_maxEntriesPerFile大于日志文件的当前总行数,它将使用以下代码将访客身份插入日志文件中:

if (lines.Length < _maxEntriesPerFile) 
{ 
  int lastIndex = int.Parse( 
    lines.Last() 
    .Split(';')[0]); 

  string newLine = 
    String.Format( 
    "{0};{1};{2};{3}", 
    (lastIndex + 1), 
    visitorName, 
    bookTitle, 
    returnDate 
    .ToString("d") 
  ); 

  File.AppendAllLines( 
    currentFile, 
    new[] { 
    newLine }); 
} 

否则,如果日志文件的当前总行数已达到_maxEntriesPerFile,则AddRecord()方法将创建一个新的日志文件,如下所示:

else 
{ 
  string newLine = 
    String.Format( 
    "1;{0};{1};{2}", 
    visitorName, 
    bookTitle, 
    returnDate 
    .ToString("d") 
    ); 
  string newFileName = 
    GetNewFileName( 
    currentFile); 
  File.WriteAllLines( 
    newFileName, 
    new[] { 
    newLine }); 
  currentFile = newFileName; 
} 

从前面的代码片段中,我们发现了GetNewFileName()方法,它根据当前日志文件名生成一个新的日志文件名。GetNewFileName()方法的实现如下:

private string GetNewFileName( 
  string existingFileName) 
{ 
  string fileName =  
    Path.GetFileNameWithoutExtension( 
      existingFileName); 
  int index = int.Parse( 
    fileName 
    .Split('_')[1]); 

  return String.Format( 
    "LibraryLog_{0:D4}.txt", 
    index + 1); 
} 

从前面的GetNewFileName()方法的实现中,我们可以看到日志文件名的模式是LibraryLog _0001.txtLibraryLog _0002.txt等。

AddRecord()方法还将在找不到指定的日志文件名时创建一个新的日志文件。该任务的实现如下:

if (!File.Exists(currentFile)) 
{ 
  string newLine = 
    String.Format( 
    "1;{0};{1};{2}", 
    visitorName, 
    bookTitle, 
    returnDate 
    .ToString("d") 
    ); 

  File.WriteAllLines( 
    currentFile, 
    new[] { 
    newLine }); 
} 

该类还有RemoveRecord()方法,用于从日志文件中删除访客身份。该方法的实现如下:

public void RemoveRecord( 
    string visitorName,  
    string directoryName) 
{ 
    foreach (string fileName in Directory.GetFiles( 
        directoryName)) 
    { 
        string tempFile = Path.GetTempFileName(); 
        List<string> linesToKeep = File 
            .ReadLines(fileName) 
            .Where(line => !line.Contains(visitorName)) 
            .ToList(); 

        if (linesToKeep.Count == 0) 
        { 
            File.Delete( 
                fileName); 
        } 
        else 
        { 
            File.WriteAllLines( 
                tempFile,  
                linesToKeep); 

            File.Delete( 
                fileName); 

            File.Move( 
                tempFile,  
                fileName); 
        } 
    } 
} 

RemoveRecord()方法的实现中,您可以看到它从所选目录中的可用日志文件中删除所选访客,如下面的代码片段所示:


List<string> linesToKeep = File

 .ReadLines(fileName)

 .Where(line => !line.Contains(visitorName))

 .ToList();

如果linesToKee p 不包含数据,我们可以使用以下代码安全地删除文件:


if (linesToKeep.Count == 0)

{

 File.Delete(

 fileName);

}

否则,我们只需要使用以下代码从日志文件中删除访客身份:


else

{

 File.WriteAllLines(

 tempFile, 

 linesToKeep);

 File.Delete(

 fileName);

 File.Move(

 tempFile, 

 fileName);

}

现在是时候尝试我们的Librarianship类了。首先,我们将准备一个包含书籍作者和标题的数据列表,如下所示:

public partial class Program 
{ 
    public static List<Book> bookList = 
        new List<Book>() 
        { 
            new Book( 
                "Arthur Jackson", 
                "Responsive Web Design"), 
            new Book( 
                "Maddox Webb", 
                "AngularJS by Example"), 
            new Book( 
                "Mel Fry", 
                "Python Machine Learning"), 
            new Book( 
                "Haiden Brown", 
                "Practical Data Science Cookbook"), 
            new Book( 
                "Sofia Hamilton", 
                "DevOps Automation Cookbook") 
        }; 
} 

我们有以下Book结构:

public struct Book 
{ 
    public string Borrower { get; } 
    public string Title { get; } 

    public Book( 
        string borrower, 
        string title) 
    { 
        Borrower = borrower; 
        Title = title; 
    } 
} 

我们将调用以下LibrarianshipInvocation()方法来使用Librarianship类:

public partial class Program 
{ 
    public static void LibrarianshipInvocation() 
    { 
        Librarianship librarian =  
            new Librarianship(5); 

        for (int i = 0; i < bookList.Count; i++) 
        { 
            librarian.AddRecord( 
                GetLastLogFile( 
                    AppDomain.CurrentDomain.BaseDirectory), 
                bookList[i].Borrower, 
                bookList[i].Title, 
                DateTime.Now.AddDays(i)); 
        } 
    } 
} 

正如您在前面的LibrarianshipInvocation()方法中所看到的,我们调用GetLastLogFile()方法来查找最后一个可用的日志文件。该方法的实现如下:

public partial class Program 
{ 
    public static string GetLastLogFile( 
        string LogDirectory) 
    { 
        string[] logFiles = Directory.GetFiles( 
            LogDirectory,  
            "LibraryLog_????.txt"); 

        if (logFiles.Length > 0) 
        { 
            return logFiles[logFiles.Length - 1]; 
        } 
        else 
        { 
            return "LibraryLog_0001.txt"; 
        } 
    } 
} 

当我们调用GetLastLogFile()方法时,它将查找指定目录中具有LibraryLog_????.txt模式的所有文件。然后它将返回字符串数组的最后一个成员。如果字符串数组不包含数据,它将返回LibraryLog_0001.txt作为默认的日志文件名。

如果我们运行LibrarianshipInvocation()方法,我们将看不到任何内容,但是我们将得到一个包含以下文本的新的LibraryLog_0001.txt文件:

检查包含副作用的代码

从前面的输出文件日志中,我们可以看到我们已成功创建了Librarianship类,就像预期的那样。

重构AddRecord()方法

现在是时候重构 Librarianship 类,使其成为不可变的。首先,我们将把 AddRecord() 方法变成一个数学函数。为了做到这一点,我们必须确保它不直接访问磁盘,而我们在使用 File.Exists()File.ReadAllLines()File.AppendAllLines()File.WriteAllLines() 方法时会这样做。我们将重构 AddRecord() 方法如下:

public FileAction AddRecord( 
    FileContent currentFile,  
    string visitorName, 
    string bookTitle, 
    DateTime returningDate) 
{ 
    List<DataEntry> entries = Parse(currentFile.Content); 

    if (entries.Count < _maxEntriesPerFile) 
    { 
        entries.Add( 
            new DataEntry( 
                entries.Count + 1,  
                visitorName,  
                bookTitle,  
                returningDate)); 

        string[] newContent =  
            Serialize( 
                entries); 

        return new FileAction( 
            currentFile.FileName,  
            ActionType.Update,  
            newContent); 
    } 
    else 
    { 
        var entry = new DataEntry( 
            1, 
            visitorName, 
            bookTitle, 
            returningDate); 

        string[] newContent =  
            Serialize( 
                new List<DataEntry> { entry }); 

        string newFileName =  
            GetNewFileName( 
                currentFile.FileName); 

        return new FileAction( 
            newFileName,  
            ActionType.Create,  
            newContent); 
    } 
} 

正如您在前面的代码中所看到的,我们修改了 AddRecord() 方法的签名,以便它现在不再传递任何文件名,而是传递了一个结构化的 FileContent 数据类型,其实现如下:

public struct FileContent 
{ 
    public readonly string FileName; 
    public readonly string[] Content; 

    public FileContent( 
        string fileName, 
        string[] content) 
    { 
        FileName = fileName; 
        Content = content; 
    } 
} 

正如您所看到的,FileContent 结构现在将处理文件名及其内容。AddRecord() 方法现在也返回 FileAction 数据类型。FileAction 数据类型的实现如下:

public struct FileAction 
{ 
    public readonly string FileName; 
    public readonly string[] Content; 
    public readonly ActionType Type; 

    public FileAction( 
        string fileName,  
        ActionType type,  
        string[] content) 
    { 
        FileName = fileName; 
        Type = type; 
        Content = content; 
    } 
} 

ActionType 枚举如下:

public enum ActionType 
{ 
    Create, 
    Update, 
    Delete 
} 

我们还有一个新的数据类型,即 DataEntryDataEntry 结构的实现如下:

public struct DataEntry 
{ 
    public readonly int Number; 
    public readonly string Visitor; 
    public readonly string BookTitle; 
    public readonly DateTime ReturningDate; 

    public DataEntry( 
        int number,  
        string visitor, 
        string bookTitle, 
        DateTime returningDate) 
    { 
        Number = number; 
        Visitor = visitor; 
        BookTitle = bookTitle; 
        ReturningDate = returningDate; 
    } 
} 

DataEntry 结构将处理我们想要写入日志文件的所有数据。如果我们再次检查 AddRecord() 方法,我们不会找到确保日志文件存在的过程,因为这将在一个单独的过程中完成。

我们注意到 AddRecord() 方法调用了两个新方法:Parse()Serialize() 方法。Parse() 方法用于解析日志文件内容中的所有行,然后根据日志文件的内容形成 DataEntry 列表。该方法的实现如下:

private List<DataEntry> Parse( 
    string[] content) 
{ 
    var result = new List<DataEntry>(); 

    foreach (string line in content) 
    { 
        string[] data = line.Split(';'); 
        result.Add( 
            new DataEntry( 
                int.Parse(data[0]),  
                data[1],  
                data[2], 
                DateTime.Parse(data[3]))); 
    } 

    return result; 
} 

另一方面,Serialize() 方法用于将 DataEntry 列表序列化为字符串数组。该方法的实现如下:

private string[] Serialize( 
    List<DataEntry> entries) 
{ 
    return entries 
        .Select(entry =>  
            String.Format( 
                "{0};{1};{2};{3}", 
                entry.Number, 
                entry.Visitor, 
                entry.BookTitle, 
                entry.ReturningDate 
                    .ToString("d"))) 
        .ToArray(); 
} 

重构 RemoveRecord() 方法

现在,我们回到我们的 Librarianship 类,并重构 RemoveRecord() 方法。实现如下:

public IReadOnlyList<FileAction> RemoveRecord( 
  string visitorName, 
  FileContent[] directoryFiles) 
{ 
  return directoryFiles 
    .Select(file => 
    RemoveRecordIn( 
      file, 
      visitorName)) 
  .Where(action => 
  action != null) 
  .Select(action => 
  action.Value) 
  .ToList(); 
} 

RemoveRecord() 方法现在有一个新的签名。它传递了一个 FileContent 数组,而不仅仅是目录名称。它还返回一个只读的 FileAction 列表。RemoveRecord() 方法还需要一个额外的 RemoveRecordIn() 方法,用于获取指定的文件名和文件内容,以定位将被移除的记录。RemoveRecordIn() 方法的实现如下:

private FileAction? RemoveRecordIn( 
    FileContent file,  
    string visitorName) 
{ 
    List<DataEntry> entries = Parse( 
        file.Content); 
    List<DataEntry> newContent = entries 
        .Where(x =>  
            x.Visitor != visitorName) 
        .Select((entry, index) =>  
            new DataEntry( 
                index + 1,  
                entry.Visitor,  
                entry.BookTitle, 
                entry.ReturningDate)) 
        .ToList(); 
    if (newContent.Count == entries.Count) 
        return null; 
    if (newContent.Count == 0) 
    { 
        return new FileAction( 
            file.FileName, 
            ActionType.Delete, 
            new string[0]); 
    } 
    else 
    { 
        return new FileAction( 
            file.FileName,  
            ActionType.Update,  
            Serialize( 
                newContent)); 
    } 
} 

现在,我们有了完全不可变的领域逻辑代码,并且可以在单元测试环境中运行这个领域逻辑。

在单元测试中运行领域逻辑

领域逻辑是一个不可变的源,是一个纯函数,因此我们可以一遍又一遍地运行单元测试,而不必改变测试规则。在这里,我们将在 LibrarianshipImmutable 类中测试 AddRecord()RemoveRecord() 方法。我们将为这两种方法进行五次测试。对于 AddRecord() 方法,我们将测试文件是否溢出。对于 RemoveRecord() 方法,我们将测试我们想要移除的选定记录是否可用。然后,如果选定的记录为空或不可用,文件将变为空。

测试 AddRecord() 方法

现在让我们来看一下以下的 AddRecord_LinesIsLowerThanMaxEntriesPerFileTest() 测试方法,它将向现有的日志文件添加一条记录:

[TestMethod] 
// Add record to existing log file  
// but the lines is lower then maxEntriesPerFile  
public void AddRecord_LinesIsLowerThanMaxEntriesPerFileTest() 
{ 
    LibrarianshipImmutable librarian =  
        new LibrarianshipImmutable(5); 

    FileContent file = new FileContent( 
        "LibraryLog_0001.txt",  
        new[]{ 
            "1;Arthur Jackson;Responsive Web Design;9/26/2016" 
        }); 

    FileAction action = librarian.AddRecord( 
        file, 
        "Maddox Webb", 
        "AngularJS by Example", 
        new DateTime( 
            2016, 9, 27, 0, 0, 0)); 

    Assert.AreEqual( 
        ActionType.Update,  
        action.Type); 
    Assert.AreEqual( 
        "LibraryLog_0001.txt",  
        action.FileName); 
    CollectionAssert.AreEqual( 
        new[]{ 
            "1;Arthur Jackson;Responsive Web Design;9/26/2016", 
            "2;Maddox Webb;AngularJS by Example;9/27/2016" 
        }, 
        action.Content); 
} 

AddRecord_LinesIsLowerThanMaxEntriesPerFileTest() 测试方法中,首先,我们创建一个包含 1;Arthur Jackson;Responsive Web Design;9/26/2016LibraryLog_0001.txt 文件,然后添加一个新记录,如下所示:

FileAction action = librarian.AddRecord( 
  file, 
  "Maddox Webb", 
  "AngularJS by Example", 
  new DateTime( 
    2016, 9, 27, 0, 0, 0)); 

从现在开始,我们必须确保 action.The type 必须是 ActionType.Updateaction.FileName 必须是 LibraryLog_0001.txtaction.Content 必须是两行,第一行为 1;Arthur Jackson;Responsive Web Design;9/26/2016,第二行为 2;Maddox Webb;AngularJS by Example;9/27/2016

提示

Assert.AreEqual() 方法用于验证指定的值是否相等。不幸的是,使用这个方法不会覆盖数组数据。要比较数组,我们需要使用 CollectionAssert.AreEqual() 方法,它将验证两个指定的集合是否相等。

另一个单元测试是 AddRecord_LinesHasReachMaxEntriesPerFileTest() 测试方法。该测试的实现如下:

[TestMethod] 
// Add record to a new log file  
// becausecurrent log file has reached maxEntriesPerFile  
public void AddRecord_LinesHasReachMaxEntriesPerFileTest() 
{ 
    LibrarianshipImmutable librarian =  
        new LibrarianshipImmutable(3); 

    FileContent file = new FileContent( 
        "LibraryLog_0001.txt",  
        new[]{ 
            "1;Arthur Jackson;Responsive Web Design;9/26/2016", 
            "2;Maddox Webb;AngularJS by Example;9/27/2016", 
            "3;Mel Fry;Python Machine Learning;9/28/2016" 
        }); 

    FileAction action = librarian.AddRecord( 
        file, 
        "Haiden Brown", 
        "Practical Data Science", 
        new DateTime(2016, 9, 29, 0, 0, 0)); 

    Assert.AreEqual( 
        ActionType.Create,  
        action.Type); 
    Assert.AreEqual( 
        "LibraryLog_0002.txt",  
        action.FileName); 
    CollectionAssert.AreEqual( 
        new[]{ 
            "1;Haiden Brown;Practical Data Science;9/29/2016" 
        },  
        action.Content); 
} 

在这个测试方法中,我们希望确保如果当前日志文件行数达到 maxEntriesPerFile,则会创建一个新的日志文件。首先,我们实例化 LibrarianshipImmutable 并将 maxEntriesPerFile 字段填充为 3,然后我们用以下代码填充日志文件的三个访客:

LibrarianshipImmutable librarian =  
  new LibrarianshipImmutable(3); 
FileContent file = new FileContent( 
  "LibraryLog_0001.txt", 
  new[]{ 
    "1;Arthur Jackson;Responsive Web Design;9/26/2016", 
    "2;Maddox Webb;AngularJS by Example;9/27/2016", 
    "3;Mel Fry;Python Machine Learning;9/28/2016" 
  }); 

之后,我们使用以下代码添加一个新记录:

FileAction action = librarian.AddRecord( 
  file, 
  "Haiden Brown", 
  "Practical Data Science", 
  new DateTime(2016, 9, 29, 0, 0, 0)); 

现在,我们必须确保 action.TypeActionType.Update,并创建一个名为 LibraryLog_0002.txt 的新日志文件。此外,新日志文件的内容是 1;Haiden Brown;Practical Data Science;9/29/2016

测试 RemoveRecord() 方法

正如我们之前讨论的,我们对 RemoveRecord() 方法有三个测试。首先,我们将测试从目录中的文件中删除记录。代码如下:

[TestMethod] 
// Remove selected record from files in the directory 
public void RemoveRecord_FilesIsAvailableInDirectoryTest() 
{ 
    LibrarianshipImmutable librarian =  
        new LibrarianshipImmutable(10); 

    FileContent file = new FileContent( 
        "LibraryLog_0001.txt",  
        new[] 
        { 
            "1;Arthur Jackson;Responsive Web Design;9/26/2016", 
            "2;Maddox Webb;AngularJS by Example;9/27/2016", 
            "3;Mel Fry;Python Machine Learning;9/28/2016" 
        }); 

    IReadOnlyList<FileAction> actions =  
        librarian.RemoveRecord( 
            "Arthur Jackson",  
            new[] { 
                file }); 

    Assert.AreEqual( 
        1,  
        actions.Count); 

    Assert.AreEqual( 
        "LibraryLog_0001.txt",  
        actions[0].FileName); 

    Assert.AreEqual( 
        ActionType.Update,  
        actions[0].Type); 

    CollectionAssert.AreEqual( 
        new[]{ 
            "1;Maddox Webb;AngularJS by Example;9/27/2016", 
            "2;Mel Fry;Python Machine Learning;9/28/2016" 
        },  
        actions[0].Content); 
} 

RemoveRecord_FilesIsAvailableInDirectoryTest() 测试方法中,我们首先创建一个包含三条记录的 LibraryLog_0001.txt 文件。然后,我们移除第一条记录,并确保 LibraryLog_0001.txt 只包含两条剩余的记录,并且顺序正确。

另一个测试是 RemoveRecord_FileBecomeEmptyTest(),具体实现如下:

[TestMethod] 
// Remove selected record from files in the directory 
// If file becomes empty, it will be deleted 
public void RemoveRecord_FileBecomeEmptyTest() 
{ 

    LibrarianshipImmutable librarian = 
        new LibrarianshipImmutable(10); 

    FileContent file = new FileContent( 
        "LibraryLog_0001.txt", 
        new[] 
        { 
            "1;Arthur Jackson;Responsive Web Design;9/26/2016" 
        }); 

    IReadOnlyList<FileAction> actions = 
        librarian.RemoveRecord( 
            "Arthur Jackson",  
            new[] { 
                file }); 

    Assert.AreEqual( 
        1,  
        actions.Count); 

    Assert.AreEqual( 
        "LibraryLog_0001.txt",  
        actions[0].FileName); 

    Assert.AreEqual( 
        ActionType.Delete,  
        actions[0].Type); 
} 

RemoveRecord_FileBecomeEmptyTest() 测试方法将确保如果记录被移除后日志文件为空,则日志文件将被删除。首先,我们创建一个包含一条记录的新日志文件,然后使用 RemoveRecord() 方法将其移除。

RemoveRecord() 方法的最后一个测试是 RemoveRecord_SelectedRecordIsUnavailableTest(),如果选定的记录不可用,则不会删除任何内容。测试方法的实现如下:

[TestMethod] 
// Remove nothing if selected record is unavailable 
public void RemoveRecord_SelectedRecordIsUnavailableTest() 
{ 
    LibrarianshipImmutable librarian = 
        new LibrarianshipImmutable(10); 

    FileContent file = new FileContent( 
        "LibraryLog_0001.txt", 
        new[] 
        { 
            "1;Sofia Hamilton;DevOps Automation;9/30/2016" 
        }); 

    IReadOnlyList<FileAction> actions = 
        librarian.RemoveRecord( 
            "Arthur Jackson", 
            new[] { 
                file }); 

    Assert.AreEqual( 
        0,  
        actions.Count); 
} 

如您所见,我们创建了包含 Sofia Hamilton 作为访客名称的日志文件,但我们尝试移除名为 Arthur Jackson 的访客。在这种情况下,RemoveRecord() 方法将不会移除任何内容。

执行测试

现在,是时候运行所有五个测试方法的单元测试了。运行测试后,我们将得到以下结果:

执行测试

将可变外壳添加到代码中

到目前为止,我们已经成功创建了不可变核心并覆盖了单元测试。现在,我们准备为访问磁盘的其余代码实现可变外壳。我们将创建两个类,FileProcessorAppServiceFileProcessor 类将处理所有磁盘交互。AppService 类将是 LibrarianshipImmutable 类和 FileProcessor 类之间的桥梁。

现在,让我们来看看以下 FileProcessor 类的实现,我们可以在 FileProcessor.cs 文件中找到:

namespace DomainLogicAndMutatingState 
{ 
    public class FileProcessor 
    { 
        public FileContent ReadFile( 
            string fileName) 
        { 
            return new FileContent( 
                fileName,  
                File.ReadAllLines( 
                    fileName)); 
        } 

        public FileContent[] ReadDirectory( 
            string directoryName) 
        { 
            return Directory 
                .GetFiles( 
                    directoryName) 
                .Select(x =>  
                    ReadFile(x)) 
                .ToArray(); 
        } 

        public void ApplyChanges( 
            IReadOnlyList<FileAction> actions) 
        { 
            foreach (FileAction action in actions) 
            { 
                switch (action.Type) 
                { 
                    case ActionType.Create: 
                    case ActionType.Update: 
                        File.WriteAllLines( 
                            action.FileName,  
                            action.Content); 
                        continue; 

                    case ActionType.Delete: 
                        File.Delete( 
                            action.FileName); 
                        continue; 

                    default: 
                        throw new InvalidOperationException(); 
                } 
            } 
        } 

        public void ApplyChange( 
            FileAction action) 
        { 
            ApplyChanges( 
                new List<FileAction> { 
                    action }); 
        } 
    } 
} 

在前面的 FileProcessor 类中有四个方法;它们是 ReadFile()ReadDirectory() 和两个具有不同签名的 ApplyChanges() 方法。ReadFile() 方法用于读取所选文件并将其形成为 FileContent 数据类型。ReadDirectory() 方法用于读取所选目录中的所有文件并将它们形成为 FileContent 数据数组。ApplyChanges() 方法用于对所选文件进行执行。如果操作是 CreateUpdate,则将调用 File.WriteAllLines() 方法。如果操作是 Delete,则将调用 File.Delete() 方法。否则,将抛出 InvalidOperationException 异常。

完成 FileProcessor 类后,现在是时候创建 AppService 类了。该类的实现如下,并且我们可以在 AppService.cs 文件中找到它:

namespace DomainLogicAndMutatingState 
{ 
    public class AppService 
    { 
        private readonly string _directoryName; 
        private readonly LibrarianshipImmutable _librarian; 
        private readonly FileProcessor _fileProcessor; 

        public AppService( 
            string directoryName) 
        { 
            _directoryName = directoryName; 
            _librarian = new LibrarianshipImmutable(10); 
            _fileProcessor = new FileProcessor(); 
        } 

        public void AddRecord( 
            string visitorName, 
            string bookTitle, 
            DateTime returningDate) 
        { 
            FileInfo fileInfo = new DirectoryInfo( 
                _directoryName) 
                    .GetFiles() 
                    .OrderByDescending(x =>  
                        x.LastWriteTime) 
                    .First(); 

            FileContent file =  
                _fileProcessor.ReadFile( 
                    fileInfo.Name); 

            FileAction action =  
                _librarian.AddRecord( 
                    file,  
                    visitorName, 
                    bookTitle, 
                    returningDate); 

            _fileProcessor.ApplyChange( 
                action); 
        } 

        public void RemoveRecord( 
            string visitorName) 
        { 
            FileContent[] files =  
                _fileProcessor.ReadDirectory( 
                    _directoryName); 

            IReadOnlyList<FileAction> actions = 
                _librarian.RemoveRecord( 
                    visitorName, files); 

            _fileProcessor.ApplyChanges( 
                actions); 
        } 
    } 
} 

正如我们之前讨论的那样,AppService 类被用作LibrarianshipImmutable类和FileProcessor类之间的桥梁。在这个 AppService 类中有两个方法,它们的签名与LibrarianshipImmutable类中的方法完全相同;它们是AddRecord()RemoveRecord()方法。作为桥梁,我们可以看到在类构造函数中,调用了LibrarianshipImmutableFileProcessor类的构造函数来创建一个新实例。通过在AppService类中调用AddRecord()方法,我们实际上调用了LibrarianshipImmutable类中的AddRecord()方法,然后调用了FileProcessor类中的ApplyChange()方法。同样,调用AppService类中的RemoveRecord()方法将调用LibrarianshipImmutable类中的RemoveRecord()方法,然后调用FileProcessor类中的ApplyChange()方法。

总结

诚实的签名不仅在功能方法中很重要,而且每次编码时都很重要,因为基本上,签名必须提供关于可能接受的输入值和可能产生的输出的所有信息。通过实现诚实的签名,我们将意识到我们传递给方法参数的值。我们必须确保我们也有一个不可变的类,以便实现功能,因为可变操作会使我们的代码不诚实。

尽管我们必须避免在纯函数中产生副作用,但在我们的代码中真正避免副作用几乎是不可能的。那么我们能做的就是处理它。我们可以使用命令查询分离CQS)原则来分离生成副作用和不生成副作用的方法。如果方法返回一个值,它将是一个查询,不会改变任何东西。如果方法不返回任何内容,它必须是一个命令,并将在系统中留下一些副作用。

我们还可以将我们的代码分为领域逻辑和可变外壳,以处理副作用。领域逻辑将是我们的核心程序,必须是不可变的。所有可变处理将存储在可变外壳中。通过创建领域逻辑,我们可以很容易地对其进行单元测试。我们不需要修改测试方案或运行领域逻辑的模拟测试,因为它是一个纯函数。

posted @   绝不原创的飞龙  阅读(12)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
历史上的今天:
2020-05-17 HowToDoInJava Spring 教程·翻译完成
点击右上角即可分享
微信分享提示