F--之书-全-

F# 之书(全)

原文:zh.annas-archive.org/md5/5b28d7d0becce626407ff48676233f6c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

从一开始,.NET 框架的一个承诺就是语言互操作性;也就是说,面向该平台的开发者可以用一种语言编写代码,并通过公共语言基础结构(CLI)与另一种语言编写的代码进行交互。

早期的示例通常包括一个用 C# 编写的库,利用了一个用 Visual Basic 编写的库,反之亦然。理想情况下,这样可以让开发者使用最适合解决问题的语言来解决不同的问题。但实际上,事情并非如此,因为开发者往往会选择 C# 或 Visual Basic 并用它来构建整个解决方案。这一点并不奇怪,因为历史上,除少数例外,语言之间的差异主要是语法上的(而且随着平台的成熟,这些语言之间的差异也越来越小)。

现在,经过十多年,F# 已经成为 .NET 生态系统中的第三大主要语言。但 F# 提供了什么是传统 .NET 语言所没有的,为什么你应该关心它?

F# 将函数式编程引入了 .NET 开发。虽然 C# 和 Visual Basic 都具备一些函数式特性,但它们首先是面向对象的语言;它们主要关注行为和管理不断变化的系统状态。相比之下,F# 是一种函数式优先的语言,关注的是将函数应用于数据。这种差异不仅会对你编写代码的方式产生深远影响,还会影响你如何思考代码。

当你阅读本书时,你将会了解 F# 的函数式特性如何强制实施各种限制,虽然这些限制一开始可能显得很局限,但一旦你接受了它们,你会发现你的代码更小、更正确、更可预测。此外,你还会发现 F# 的许多独特构造如何简化常见的开发任务,从而让你能够专注于解决你正在尝试解决的问题,而不是编译器要求的复杂设置。这些特点使得 F# 成为 C# 和 Visual Basic 的完美补充,常常为实现 .NET 的混合语言解决方案目标铺平道路。

本书适合谁阅读?

自 2002 和 2003 年 .NET 平台的早期公开发布以来,我一直在使用 C# 专业开发软件。因此,我写这本书是为了像我一样的人:那些有经验的 .NET 开发者,希望在保留已用工具和库的安全网的同时,开始接触函数式编程。

本书虽然侧重于 .NET 开发,但经验丰富的开发者如果是从其他背景转向 F#,依然能在这些页面中找到大量有价值的内容,因为书中讨论的原则通常并不特定于某个平台。

本书如何组织?

《F#书籍》分为 12 章,旨在介绍 F#的各个主要语言特性。我建议你从头到尾阅读本书,而不是跳读,因为每一章都会建立在前一章介绍的概念之上。

  • 第一章。为你提供了对 F#的初步了解,并描述了它在.NET 生态系统中的位置。在本章中,你将学到开始使用 F#编码所需的知识,项目的结构,以及一些可能让新手感到困惑的细节。

  • 第二章。介绍了 F#交互式环境,这是一个不可或缺的读取-评估-打印循环(REPL)工具,随 F#一起提供。在这里,你将看到 F#交互式如何帮助你探索问题领域,甚至让你利用.NET 框架的全部功能将 F#作为脚本语言使用。

  • 第三章。讲解了 F#的基础知识。本章涉及的主题包括默认不可变性、绑定、核心数据类型、类型推断、命令式流程控制和泛型。尽管本章讨论的许多概念对于有经验的开发者来说可能比较熟悉,但我仍然鼓励你阅读,因为 F#经常允许你以意想不到的方式使用它们。

  • 第四章。深入探讨了 F#的面向对象功能。本章中,你将看到如何开发出与 C#或 Visual Basic 等更成熟的面向对象语言一样强大的对象模型。

  • 第五章。带你进入托管函数式编程的旅程,介绍了函数作为数据、柯里化、部分应用和委托等概念。此外,你还将了解一些典型的与函数式编程相关的 F#数据结构。

  • 第六章。探讨了各种.NET 集合类型,如数组和序列,在 F#中的表示方式。你还将接触到几种新的集合类型,包括 F#的列表、集合和映射。

  • 第七章。介绍了 F#最强大的构造之一:匹配表达式。在这一章,你将揭示如何在一个表达式内分解复杂类型并分支代码。

  • 第八章。展示了如何通过对数字类型强制使用度量单位(如英寸、英尺、米等)为代码增加额外的安全性。

  • 第九章。解释了引号表达式——F#版的 LINQ 表达式树。在这里,你将看到如何组合、拆解和应用引号表达式。

  • 第十章。探索一些 F# 特有的访问数据方式,包括查询表达式和 F# 最令人兴奋的特性之一:类型提供器。

  • 第十一章。简要介绍了使用 F# 进行异步和并行编程。主题包括如何从 F# 中使用任务并行库、异步工作流以及基于代理的编程,使用 MailboxProcessor<'T>

  • 第十二章。讨论如何创建计算表达式(在其他函数式语言中通常称为 单子)以控制数据从一个表达式流向另一个表达式。

附加资源

作为由 F# 软件基金会管理的开源语言,F# 得到了全球各领域开发者的支持,涵盖了广泛的学科。虽然我在整本书中尽力提供了全面的解释和示例,但如果你希望更深入地探索某个主题,以下资源可能对你有所帮助。

  • The Book of F# 陪伴页面 *(nostarch.com/f_sharp)*。这是你获取内容更新和本书中使用的代码示例的来源。

  • **F# 软件基金会 (fsharp.org/)**。这里应该是你的第一站。在这里,你可以找到所有语言文档的链接,包括语言参考、语言规范、组件设计指南等。

  • **F# for Fun and Profit (fsharpforfunandprofit.com/)**。在这里,你会找到涵盖几乎所有语言方面的大量示例。

  • **Try F# (www.tryfsharp.org/)**。这个基于浏览器的工具让你通过引导教程实验语言并学习它。

第一章. 认识 F#

F# 最初在微软研究院剑桥分部开发,是一种以函数为主的多范式语言。通俗来说,这意味着尽管 F# 的语法和构造强调编写将函数应用于数据的代码,但它也是一种功能全面的面向对象语言,并且加入了一些命令式编程的构造。

F# 起源于 2002 年,但直到 2005 年微软发布了版本 1.0,才迎来了第一次重大发布。F# 源自 ML 语言,并受到 OCaml 的深刻启发。在早期的开发过程中,F# 团队努力保持与 ML 的语法兼容,但随着时间推移,语言有所不同。逐渐地,F# 找到了在 Visual Studio 中的第一等公民地位,从 Visual Studio 2010 开始,每个版本都提供了现成的项目模板。F# 的最新版本随 Visual Studio 2013 一同发布,版本号为 3.1。

尽管 F# 被包含在 Visual Studio 中,但它仍然拥有不应有的声誉,认为它只是学术界或高度专业化的金融软件中使用的冷门语言。因此,它未能在企业软件中获得广泛应用,但随着开发人员开始理解函数式语言的优点,这种情况似乎正在改变。F# 是一门开源语言,使用 Apache 2.0 许可证,并且每个平台都有可用的编译器,这也在帮助这门语言获得更多的关注。微软继续为 F# 做出重大贡献,但语言本身是由独立的 F# 软件基金会管理的。

本章的目标是让你了解 F# 程序在 Visual Studio 项目和代码层面的组织结构。在你学习这门语言的过程中,你会发现 F# 真正是一种通用编程语言,能够满足大多数现代软件开发任务的需求。

除非另有说明,本书中的示例是使用 F# 3.1 在 Visual Studio 2013(专业版和终极版)中开发的。如果出于某种原因你没有使用 Visual Studio,不用担心,本书中的大部分示例无论你使用哪个平台都适用。

尽管我在本书中并未专门讲解,但如果你打算使用除 Visual Studio 之外的开发环境,可以参考 F# 软件基金会网站上的大量资源,帮助你入门,网址是 fsharp.org/。你还可以在浏览器中尝试 F#,网址是 www.tryfsharp.org/.

F# 与 Visual Studio

因为本书主要面向有经验的 .NET 开发人员,所以我假设你已经知道如何在 Visual Studio 中创建项目。我将直接介绍可用的不同 F# 项目模板,并简要讨论 F# 项目中的文件组织结构。

项目模板

每个 Visual F#项目模板都列在新建项目对话框中的 Visual F#类别下,但该类别在列表中的位置会根据你的 IDE 设置有所不同。如果 Visual F#类别没有立即列在已安装模板下,请检查其他语言节点。如果仍然没有看到,确保已安装 F#组件。图 1-1 展示了在为 F#开发并针对.NET 4.0 配置的 IDE 中,每个模板的显示方式。

如你所见,提供了五个模板。模板名称非常直观,以下是简要说明:

  • 控制台应用程序。创建一个新的命令行应用程序。

  • 。创建一个新的库,可以从其他应用程序或库中引用。

    Visual Studio 2013 中的 F#项目模板图 1-1. Visual Studio 2013 中的 F#项目模板

  • 教程。这是一个快速了解 F#提供的功能的方式,但对于开始新项目来说并不太有用。

  • 便携式库。创建一个便携式类库,可以同时被.NET 4.5 和 Windows Store 应用程序使用。

  • 便携式库(遗留版)。创建一个便携式类库,可以同时被.NET 4.0 和 Silverlight 应用程序使用。

  • Silverlight 库。创建一个新的库,可以在 Silverlight 应用程序中引用。

一旦你使用这些模板创建了一个项目,你应该能看到熟悉的 Visual Studio 界面,包括文本编辑器、解决方案资源管理器以及你通常打开的其他窗口。根据你之前是否使用过 F#,你也可能会看到 F#互动窗口。

显眼缺失的模板包括 Windows Forms 应用程序、WPF 应用程序和 ASP.NET 应用程序的模板。缺失的一个主要原因是许多设计工具尚未更新以支持生成或理解 F#代码。尽管缺少内置模板,你仍然可以使用这些技术构建 F#应用程序,但通常需要做更多的手动工作。

注意

GitHub 上的 F#社区模板库托管了多个额外的模板。写本文时,库中仅包含少量的 Visual Studio 模板,但随着时间推移,可能会添加针对其他编辑器(如 Xamarin Studio)的模板。你可以在 github.com/fsharp/FSharpCommunityTemplates/ 找到该库。

项目组织

当你第一次看到 Visual Studio 在从上述模板创建项目后的项目工作区时,可能会误以为 F# 项目和 C# 或 Visual Basic 项目一样。从某些方面来看,确实如此。例如,你可以通过按 F5 启动可执行项目,Visual Studio 调试器可以逐步执行 F# 代码,文件通过解决方案资源管理器进行管理。然而,F# 的项目组织与传统 .NET 语言有很大的不同。实际上,你可能会发现 F# 的代码结构几乎和语言本身一样陌生。

传统的 .NET 项目通常遵循每个文件一个类型的惯例;也就是说,单独的数据类型几乎总是存储在不同的文件中,并按与项目命名空间相对应的文件夹层次结构进行组织。除了避免循环程序集引用外,关于如何或何时在项目中出现某个元素,几乎没有什么固定的规则。除非涉及访问修饰符(如 public、private 等),否则类型和成员可以互相引用,不管它们在项目中定义的位置在哪里。

有些规则是可以打破的,但在这种情况下,F# 彻底摧毁了项目组织规则书,然后焚烧了残骸。它对项目的组织方式有着极其严格的规定,且理由充分:F# 代码是自上而下进行评估的。这意味着,不仅单个代码文件内的声明顺序很重要,项目中的文件顺序同样至关重要!

新的 F# 程序员常常会向项目中添加一个新文件,填入一些定义,然后出现编译错误,提示新定义缺失。这通常是因为程序员忘记将新创建的文件移动到会使用这些定义的文件之前。幸运的是,在 F# 项目中更改文件顺序相对简单,因为 IDE 中有上下移动文件的右键菜单和快捷键,如 图 1-2 所示。

F# 自上而下的评估顺序的另一个重要影响是,不允许使用文件夹。文件夹本身不会破坏评估顺序,但它们确实会使其变得更加复杂,因此在 IDE 中没有添加文件夹的选项。

你可能会想,这种评估结构究竟能带来什么优势。其主要好处是,编译器可以对你的代码做出更多假设,从而为你提供其他 .NET 语言无法比拟的类型推断能力。此外,这种评估结构避免了不经意的递归定义(即两个或多个类型相互依赖)。这促使你更多地思考类型的使用方式和使用位置,并且在合适的地方强制你明确递归定义。

Solution Explorer 的上下文菜单中的移动和添加选项图 1-2. Solution Explorer 中上下文菜单的移动和添加选项

空白字符的重要性

新接触 F# 的人通常会很快注意到缺少大括号或 BEGINEND 分隔符。F# 的设计者并没有依赖语法符号来表示代码块,而是决定让空白字符具有意义。

在一个代码块内的代码必须比打开该代码块的行缩进得更远。例如,当你定义一个函数时,属于函数体的行必须比函数声明的第一字符向右缩进。缩进的距离并不重要,重要的是代码被缩进,并且同一个代码块中的每行缩进级别要保持一致。

在大多数编程语言中,这通常是制表符与空格之间的老生常谈的争论点,但在 F# 中并非如此。F# 编译器在这个问题上采取了铁腕政策,明确禁止使用制表符,因为制表符所代表的空格数是不可知的。当你开始编写 F# 时,你可能希望配置 Visual Studio 的文本编辑器选项,将制表符替换为空格。

一种语法,统领一切

说 F# 需要一致的缩进,或者明确禁止制表符,并不完全准确。F# 实际上有两种语法格式:冗长格式和轻量格式。冗长格式要求你更明确地编写代码,但对缩进不那么敏感。在冗长语法下,代码块的结束不是通过减少缩进级别来表示的,而是通过使用额外的关键字,如 enddone 来表示。

在 F# 的初期,冗长格式是标准,但随着语言的发展,轻量语法逐渐受到青睐,现在成为了默认语法。当然,冗长格式和轻量格式之间还有其他差异,但它们超出了本书的范围。本书中的所有示例都没有使用冗长语法,但如果你渴望编写更多代码,可以通过在代码文件中使用 #light off 指令来恢复到冗长语法。

代码分组构造

F# 中有两种主要的代码分组方式:命名空间和模块。在单文件项目中,声明命名空间或模块是可选的,因为文件的内容会隐式地成为一个与文件同名的模块——例如,如果你的文件名是 Program.fs,那么模块会自动命名为 Program。然而,在所有其他情况下,每个文件必须以命名空间或模块声明开始。

命名空间

F# 的命名空间与 C# 和 Visual Basic 中的命名空间相同,它们允许你通过名称对相关代码进行分组,从而减少命名冲突的可能性。命名空间可以包含模块和类型定义,但不能直接包含任何值或函数。

你可以使用namespace关键字后跟标识符来声明命名空间。例如,本书中的代码可能会有如下的命名空间:

namespace TheBookOfFSharp

你还可以通过嵌套命名空间来声明更细粒度的命名空间。嵌套的命名空间使用完全限定的名称声明,每个层级由点(.)分隔。例如,我们可以将本章的所有代码分组到一个嵌套的命名空间中,如下所示:

namespace TheBookOfFSharp.Chapter1

就像在其他.NET 语言中一样,你可以将命名空间分割到多个文件和程序集。你还可以在一个文件中声明多个命名空间,但不能将它们内联嵌套;每个命名空间声明必须是顶级块。

如果你想将代码放入.NET 的全局命名空间,可以使用global关键字声明命名空间,如下所示:

namespace global

每当你声明一个命名空间时,已经加载到该命名空间中的其他代码会立即对你的代码可用。然而,在所有其他情况下,你必须完全限定类型或模块名称,或者使用open关键字导入它们,就像在 C#中使用using指令或在 Visual Basic 中使用Imports语句一样。以下代码片段展示了这两种方法:

// Fully qualified name
let now = System.DateTime.Now

// Imported namespace
open System
let today = DateTime.Now.Date

模块

模块与命名空间类似,因为它们允许你逻辑地分组代码。然而,与命名空间不同,模块可以直接包含值和函数。实际上,模块更像是其他.NET 语言中只包含静态成员的类;事实上,它们就是这样在编译后的程序集中的表现。

模块分为两类:顶级模块和本地模块。顶级模块将所有代码包含在一个单一的实现文件中。相反,本地模块用于当多个模块或不属于任何模块的类型在同一文件中定义时。

你可以使用module关键字后跟标识符来声明模块,如下所示:

module TheBookOfFSharp

与命名空间不同,模块定义不能跨文件,但你可以在单一文件中定义多个模块。你也可以像这样将模块直接嵌套在父模块中:

module OuterModule
  module NestedModule =
    do ()

当你想同时使用命名空间和顶级模块时,F#提供了一个方便的语法快捷方式,将它们合并为单个声明。要利用这一点,只需在模块名称前包含完整的限定名称,如下所示:

module TheBookOfFSharp.Chapter1.QualifiedModule

在上面的代码片段中,我们在TheBookOfFSharp.Chapter1命名空间中声明了一个名为QualifiedModule的模块。

最后,您可以通过open关键字导入模块成员,就像它们属于一个命名空间一样。例如,要导入QualifiedModule中定义的任何类型,我们可以写:

open TheBookOfFSharp.Chapter1.QualifiedModule

为了简化这个过程,你可以使用AutoOpen属性来修饰模块,如下所示:

[<AutoOpen>]
module TheBookOfFSharp.Chapter1.QualifiedModule

通过将该属性应用于模块,每当你显式地打开包含该模块的命名空间时,模块也会被打开。

表达式无处不在

F#的一个显著特征是它是一种基于表达式的语言;也就是说,几乎所有被求值的内容都会返回结果。当你学习 F#时,你会很快发现,编写应用程序和库是通过组合表达式来产生结果的练习。这与 C#等语言形成鲜明对比,在这些语言中,通常只有方法(和运算符)返回结果。在 F#中,像if...else这样的看似熟悉的结构焕发新生,因为就像所有表达式一样,if...else表达式会返回结果。考虑下面这段代码,它使用 C#的if...else语句打印一个字符串,表示一个数字是偶数还是奇数:

// C#
var testNumber = 10;
string evenOrOdd;

if (testNumber % 2 == 0)
      evenOrOdd = "even";
else
      evenOrOdd = "odd";

Console.WriteLine(evenOrOdd);

现在,比较一下这段功能等效的 F#代码,它使用了if...else表达式:

// F#
let testNumber = 10
let evenOrOdd = if testNumber % 2 = 0 then "even" else "odd"
Console.WriteLine evenOrOdd

你可能首先注意到的是,F#版本更简洁。然而,可能不那么明显的是,F#版本消除了 C#版本中的可变状态(evenOrOdd在赋值之前是未初始化的)。在这个简单的示例中,这不是问题,因为可变状态是隔离的,但在更大的应用程序中,可变状态会导致脆弱且常常不可预测的代码库。

你可能会争辩(正确地说)我们可以使用 C#的条件操作符来代替if...else语句,从而实现与 F#代码相同的效果。但这个例子的关键点在于,即使是看似熟悉的结构,在 F#中也会返回值。

应用程序入口点

在 F#应用程序中,项目中最后一个文件中定义的初始化程序默认作为应用程序的入口点。为了更好地控制应用程序的启动方式,你可以通过使用EntryPoint特性装饰一个let绑定的函数来将其作为应用程序的入口点。这允许你使用任意函数来代替 C#或 Visual Basic 应用程序中的Main方法或过程。因此,装饰过的函数必须接受一个字符串数组并返回一个整数,才能被视为有效。这样的函数通常遵循以下模式:

**[<EntryPoint>]**
let main argv =
  // initialization code
  0

隐式返回值

由于 F#是一种以表达式为基础的语言,F#编译器能够对你的代码做出更多的假设。因为所有表达式都会返回值,所有函数也是表达式,所以可以推断所有函数都会返回一个值。因此,编译器可以假设在函数中最后一个被求值的表达式就是函数的返回值;你无需显式使用return关键字来声明它。

举个例子,考虑上一节中的main函数。在这个函数中,0是隐式返回的,因为它是该函数中最后被求值的表达式。类似地,考虑这个函数,它只是简单地将两个整数相加:

let add x y = x + y

这里,add 函数接受两个参数,xy,并且只包含一个表达式:加法操作。由于加法操作是在调用 add 时最后一个被评估的表达式,add 隐式地返回该操作的结果。

你的第一个 F# 程序

现在你已经学会了如何构建 F# 项目,是时候看看一些“真实”的 F# 代码了,这些代码超越了基本的语法。尽管传统的“Hello world”类型应用程序的即时满足感是刚开始学习新语言时很好的信心提升,但我决定放弃这种方法,转而选择一个既有用又能很好展示 F# 多种功能的示例:一个逆波兰表示法(RPN)计算器。

RPN 是一种后缀表示法,用于数学表达式;也就是说,它是一种表示计算的方式,每个运算符紧跟其操作数。例如,要表示计算 1 和 2 的和,我们通常会写作 1 + 2;然而,使用 RPN 时,我们会写作 1 2 +

通常,你可以通过遍历一系列数字和运算符来实现 RPN 计算器。每个项目都会被检查,数字会被压入堆栈,而运算符则从堆栈中弹出适当数量的操作数,进行计算,并将结果重新压入堆栈。处理结束时,堆栈中剩下的唯一项目应为表达式的结果。图 1-3 大致说明了当该过程应用于表达式 4 2 5 * + 时的情况。

逆波兰表示法应用图 1-3. 逆波兰表示法应用

从左到右工作,你可以看到如何向堆栈中添加和移除项目,最终得出14作为结果。不过,正如你将看到的,使用 F# 实现一个基本的逆波兰表示法(RPN)计算器只需要几行代码,甚至不需要管理一个可变堆栈!

如果你想在 Visual Studio 中跟随这个示例进行操作,创建一个使用 F# 应用程序模板的新项目。准备好后,将文本编辑器的内容替换为以下代码(请注意,F# 是区分大小写的):

module TheBookOfFSharp.RpnCalculator

open System

let evalRpnExpr (s : string) =
  let solve items current =
    match (current, items) with
    | "+", y::x::t -> (x + y)::t
    | "-", y::x::t -> (x - y)::t
    | "*", y::x::t -> (x * y)::t
    | "/", y::x::t -> (x / y)::t
    | _ -> (float current)::items
  (s.Split(' ') |> Seq.fold solve []).Head

[<EntryPoint>]
let main argv =
  [ "4 2 5 * + 1 3 2 * + /"
    "5 4 6 + /"
    "10 4 3 + 2 * -"
    "2 3 +"
    "90 34 12 33 55 66 + * - + -"
    "90 3 -" ]
  |> List.map (fun expr -> expr, evalRpnExpr expr)
  |> List.iter (fun (expr, result) -> printfn "(%s) = %A" expr result)
  Console.ReadLine() |> ignore
  0

完成输入 RPN 计算器代码后,按 F5 键并观察输出。你应该能看到如 图 1-4 所示的结果。

逆波兰表示法计算器结果图 1-4. 逆波兰表示法计算器结果

如果你现在看不懂 RPN 计算器的代码,不要气馁;这正是重点!目前你只需要明白整个 RPN 计算都包含在evalRpnExpr函数中。之所以从这个例子开始,是因为它不仅展示了一些地道的 F#代码,还演示了许多重要的概念,比如默认不可变性、函数作为数据、模式匹配、递归、库函数、部分应用、F#列表和管道化。这些概念协同作用,创建了高度表达性且可预测的代码。在本书的过程中,你将详细探讨这些概念及更多内容。随着阅读的深入,我鼓励你定期回顾这个例子,看看这样一个小程序包含了多少功能。

概述

尽管 F#有着作为小众语言的声誉,但它是一门富有表现力、以函数为核心、支持多范式的语言,根植于 ML,并且对大多数现代软件开发活动都非常有用。正如你将在接下来的章节中看到的,编写高效的 F#代码就是学习如何将你在命名空间和模块中定义的类型、函数和数值组合成表达式。也就是说,传统的.NET 开发者需要适应语言的一些细微差别,如自上而下的求值、空格的意义以及隐式返回。然而,一旦你克服了初始的学习曲线,你就会看到 F#简单却富有表现力的语法将使你能够解决复杂的问题,并且写出的代码更稳定、可预测。

第二章. F# Interactive

如果用 .NET Framework 进行真正的函数式编程的前景还不足够吸引你,那么 F# Interactive (FSI) 带来的生产力提升一定会。FSI 是一个 读取-评估-打印循环 (REPL) 工具,你可以用它来探索问题领域,并在编写代码时进行测试。它还兼任脚本宿主,允许你利用 F# 的优雅和 .NET Framework 的强大功能来自动化常见任务。像 F# 这样的编译语言如何能互动式地使用呢?因为在幕后,FSI 会将输入编译成动态生成的程序集。

运行 F# Interactive

在 FSI 中工作有两种方式:通过 Visual Studio 中的 F# Interactive 窗口或 fsi.exe 控制台应用程序。选择通常是基于方便性。我通常更喜欢在 F# Interactive 窗口中工作,因为它能轻松融入我的 Visual Studio 开发工作流。我一般用这个窗口进行探索性任务,而将控制台保留用于脚本执行。

要在 Visual Studio 中打开 F# Interactive 窗口,请按 CTRL-ALT-F;你应该看到如图 2-1 所示的提示。默认情况下,fsi.exe 只能通过 Visual Studio 命令提示符快捷方式访问,而不能通过基本的 Windows 命令提示符访问。如果你希望通过其他命令提示符访问 fsi.exe,需要将其位置添加到你的路径环境变量中。默认情况下,F# 安装在 *%PROGRAMFILES(x86)%\Microsoft SDKs\F#\3.0\Framework\v4.0*(在 32 位系统上为 %PROGRAMFILES%)中。

Visual Studio 2013 中的 F# Interactive 窗口图 2-1. Visual Studio 2013 中的 F# Interactive 窗口

除了打开 Interactive 窗口外,你还可以使用 ALT-ENTER 将代码发送到窗口,在这种情况下,执行该代码的结果也会显示出来。这使得测试新概念变得非常容易:如果你不确定某个功能是否有效,通常可以通过写一点代码,发送到 FSI,并检查结果来立即尝试。

从文本编辑器发送代码并不是在 FSI 中评估表达式的唯一方式;你也可以直接从其提示符运行代码。这种灵活性对于提高生产力非常有帮助,因为你可以在文本编辑器中处理一段代码,将其发送到 FSI,然后在 FSI 窗口中与之互动,进行实验。

直接在交互式窗口中输入代码与从文本编辑器发送代码之间有一个重要区别。当你从编辑器发送代码时,它会自动编译并执行,而直接输入的代码在没有使用双分号模式(;;)终止时不会执行。例如,要执行简单的加法,你可以将 1 + 1 输入到文本编辑器并发送到 FSI,或者直接在 FSI 提示符下输入 1 + 1;;。这两种方法的结果相同,但由于必须使用双分号来表示代码输入的结束,FSI 允许你直接在提示符下输入并执行多行代码。

注意

尽管在提示符下可以输入多行代码,但通常这会带来更多麻烦,因为一旦发生打字错误,你必须重新开始。我倾向于尽可能使用单行语句在提示符下进行操作。(幸运的是,从这样的错误中恢复通常只是修正错误并重新尝试。)

F# 交互式输出

使 FSI 如此有用的一个特点是它会报告它所做的所有事情。每当你在 FSI 中执行代码时,它会显示 val,后面跟着标识符名称、数据类型和它所创建的每个绑定的值。例如,当你定义并调用一个函数时,FSI 会创建两个绑定:一个是函数本身,另一个是结果,如下所示。

> **let add a b = a + b**
**let sum = add 1 2;;**

val add : a:int -> b:int -> int
val sum : int = 3

it 标识符

在 FSI 中,你不总是需要显式定义绑定;在大多数交互式会话中,你可以直接评估一个表达式。例如,你可以直接调用 add 函数,而无需像这样定义 sum 标识符。

> **add 1 2;;**
val it : int = 3

当你没有显式命名某个东西时(例如进行简单计算或检查函数输出时),FSI 会自动将结果绑定到 it 标识符。你可以在后续评估中引用 it,但请注意,正如 Highlander 中所说,只有一个;每当 FSI 隐式绑定某个东西时,该值会被替换。你可以通过评估多个表达式而不显式将结果绑定到标识符来看到这种行为,如下所示。

> **it;;**
val it : int = 3

> **add 3 4;;**
val it : int = 7

> **it;;**
val it : int = 7

关于 it 标识符的关键是:喜爱它、使用它,但不要依赖它。

在沙盒中玩耍

即使在 Visual Studio 中运行,FSI 也是一个沙盒,它与任何你没有明确告诉它的代码完全隔离并且不知情。这种隔离提供了一层“工作”和“娱乐”之间的保护,但也意味着为了让它有用,你需要与外部世界进行交互。为此,我们使用指令。

FSI 提供了几个指令,可以在交互式会话或脚本中调用。其中包括用于刷新你记忆的指令,加载其他 F# 源文件中的代码,引用程序集,甚至提供一些性能统计信息。

#help

如果你忘记了任何指令,可以在 FSI 提示符下调用#help指令,以获取可用指令的列表以及每个指令的简要描述。

#quit

如果你需要从命令提示符退出 FSI,可以使用#quit指令结束会话。尽管你可以在 Visual Studio 的 FSI 窗口中使用#quit,但我建议使用图 2-2 中显示的重置交互式会话上下文菜单项,因为它会清除之前的输出,并自动开始一个新的会话。

重置交互式会话上下文菜单项图 2-2. 重置交互式会话上下文菜单项

#load

加载现有代码到 FSI 会话的一种方法是使用如下面所示的#load指令。#load指令接受一个或多个字符串参数,这些参数包含外部源文件的绝对路径或相对路径。FSI 应该加载、编译并执行列出的文件(按顺序),并使其内容在当前会话中可用。

> **#load @"D:\Dev\FSharp\Samples\Chapter2\MySourceFile.fs";;**
[Loading D:\Dev\FSharp\Samples\Chapter2\MySourceFile.fs]
-- *snip* --

轻松加载

Visual Studio 中的 F# 项目模板鼓励你通过包含一个脚本来加载多个文件,你可以更新该脚本以包含任何新文件。通过保持该脚本与项目结构同步,你可以轻松地将代码从项目加载到 FSI 并进行实验。

虽然你可以在单个#load指令中包含多个源文件,但通常更容易为每个文件使用单独的指令。原因是,如果你正在积极编辑其中一个文件并且出现错误,编译器会将整个指令标记为问题。通过使用多个指令,你可以更快地定位问题文件。

#r

#r指令对于程序集的作用就像#load指令对源文件的作用一样。你可以使用#r来引用任何 .NET 程序集(遵循目标框架和平台的通常限制)。如果你需要的程序集已经位于程序集搜索路径中某个文件夹中,你可以通过名称来识别它,否则你需要包含完整路径。例如,如果你需要加载System.Configuration,你可以使用:

> **#r "System.Configuration";;**
--> Referenced 'C:\Program Files (x86)\Reference Assemblies\Microsoft\
Framework\.NETFramework\v4.5\System.Configuration.dll'

FSI 会回应加载的每个程序集的完整路径。

#I

当你需要引用一个尚未包含在搜索路径中的文件夹中的多个程序集时,你可以使用#I指令将该文件夹添加到 FSI 的程序集搜索路径中。

> **#I @"D:\Dev\FSharp\Samples\Chapter2\Bin\Debug";;**
--> Added 'D:\Dev\FSharp\Samples\Chapter2\Bin\Debug' to library include path

一旦文件夹被添加到搜索路径中,你应该能够通过名称而不是完整路径来引用其中的程序集。

#time

#time指令通过在输出中打印一些统计信息,为你的代码提供额外的可见性。你可以通过使用带有on字符串参数的#time指令来启用计时信息。

> **#time "on";;**
-- > Timing now on

启用计时后,每次在 FSI 中执行代码时,统计信息都会被计算出来。这些统计信息包括实时、CPU 时间和垃圾回收操作的次数,涵盖所有三代。例如,为了帮助优化一个慢函数,您可以启用计时来调用它,并看到类似这样的输出:

> **DoSomethingSlow();;**
Real: 00:00:01.095, CPU: 00:00:01.107, GC gen0: 25, gen1: 23, gen2: 23
val it : unit = ()

当您完成统计并且不再希望在 FSI 输出中看到它们时,可以使用 #time 指令和 off 字符串参数禁用它们。

> **#time "off";;**
--> Timing now off

脚本编写

由于 F# 是一种 .NET 语言,您的大多数 F# 代码将放在 .fs 文件中,并编译成程序集,以供大型应用程序使用。然而,当与 FSI 配合使用时,F# 可以作为脚本语言,利用它的强大功能来自动化常见任务,并且完全支持 .NET Framework。

例如,假设您想将多个 PDF 文件合并成一个文档。您可以为此编写一个控制台应用程序,但使用开源的 PDFsharp 库来操作单个 PDF 文件,编写脚本则更为简便。该脚本大约需要 30 行代码(包括空行)。F# 提供简洁的语法,并且能够发挥 .NET Framework 的强大功能,非常适合这种任务。

创建 .fsx 文件作为脚本有几个好处。首先,《沙箱中的操作》 中描述的指令是 FSI 特性,因此它们不能出现在标准源文件中。此外,由于 .fsx 文件与 fsi.exe 相关联,您可以直接通过 shell 上下文菜单执行它们,如图 2-3 所示。这使得像 PDF 合并这样的脚本可以根据需要轻松运行。

Run with F# Interactive context menu item图 2-3. 使用 F# Interactive 上下文菜单项运行

要将脚本添加到项目中,请在解决方案资源管理器中选择项目,按 CTRL-SHIFT-A 打开 添加新项 对话框,然后选择 F# 脚本文件,如图 2-4 所示。

Adding an F# script file to a project图 2-4. 将 F# 脚本文件添加到项目中

要在 Visual Studio 2013 中快速创建独立的 .fsx 文件,请按 CTRL-N 打开 新建文件 对话框,选择左侧菜单中的 脚本,然后找到 F# 脚本文件 选项,如图 2-5 所示。

Creating a standalone F# script file图 2-5. 创建独立的 F# 脚本文件

F# Interactive 选项

除了在沙盒中玩耍一章中讨论的指令外,FSI 还提供了几个命令行选项,允许你控制它的行为。这些选项中有些提供了 FSI 指令功能的替代方案,而另一些则控制编译器的行为。我不会在这里介绍所有可用的选项,但我会重点讲解你最有可能使用的选项。(要查看 FSI 选项的完整列表,请运行fsi.exe –help。)这些选项适用于你通过命令提示符还是 F# Interactive 窗口运行 FSI。要在 Visual Studio 中设置选项,请转到工具选项,在左侧列表中找到F# 工具,然后将新选项输入到F# Interactive 选项文本框中,如图 2-6 所示。

设置 F# Interactive 选项图 2-6. 设置 F# Interactive 选项

注意

Visual Studio 中的 F# Interactive 选项设置是全局设置。更改它将影响所有窗口实例。

--load

--load选项是#load指令的命令行等价物。它允许你在 FSI 会话启动时指定外部源文件供 FSI 编译并加载,例如:

**fsi --load:MyFirstScript.fsx**

--load选项不会处理指定文件中的任何指令,因此如果需要评估任何指令,请改用--use选项。

--use

--load一样,--use选项加载外部源文件,但它还会在加载文件时处理诸如#load#I之类的指令。

**fsi --use:MyFirstScript.fsx**

--reference

就像你可以使用--load--use导入源文件一样,你也可以使用--reference选项(或其简写形式-r)来引用外部程序集。这与#r指令的效果相同。

**fsi --reference:System.Configuration**

#r指令一样,如果程序集不在已包含的搜索路径中,请确保包括程序集的完整路径。

--lib

--lib选项的作用与#I指令相同,都是将指定的文件夹添加到程序集搜索路径中。它的简写是-I

**fsi --lib:D:\Dev\FSharp\Samples\Chapter2\Bin\Debug**

--define

与其他 .NET 语言一样,F# 允许你定义条件编译符号(如 Visual Studio 中预定义的DEBUGRELEASE符号),这些符号会影响代码的编译方式。要在 FSI 会话中定义符号,请使用--define选项。

**fsi --define:DEBUG**

FSI 和 F# 编译器会根据代码的编译方式自动为你定义某些符号。例如,当你在 FSI 会话中运行编译后的代码时,无论是通过提示符输入,还是从文本编辑器发送,或导入另一个文件,FSI 都会定义INTERACTIVE符号。直接编译的 F# 代码则会得到COMPILED符号。这些符号在代码必须根据环境差异在 FSI 会话和编译后的程序集之间表现不同的情况下变得非常重要。

--exec

默认情况下,FSI 进程在评估完脚本后不会终止。要强制其退出而不是返回 FSI 提示符,可以指定--exec选项。

**fsi --load:MyFirstScript.fsx --exec**

现在,当脚本完成时,您将自动返回命令提示符。

--

如果您的代码需要命令行参数,可以通过--选项将它们传递给 FSI;这实际上是一个分隔符,告诉 FSI 将所有剩余的参数视为代码的参数,而非 FSI 本身的参数。

**fsi --load:MyFirstScript.fsx --exec -- Dave**

当依赖命令行参数的代码可能从 FSI 会话或已编译程序集执行时,您应该使用INTERACTIVECOMPILED符号来确保正确读取参数。例如,在典型的.NET 应用程序中,您会使用System.Environment.GetCommandLineArgs()来解析参数。对于COMPILED代码也是如此,但在INTERACTIVE代码中,执行过程实际上是 FSI 而不是您的程序集。因此,GetCommandLineArgs方法返回的是传递给 FSI 进程的所有参数,而不仅仅是用于脚本的参数!为了适应这个差异,交互式代码通常应该调用fsi.CommandLineArgs。您可以通过条件编译轻松改变这种行为,如下所示。

**let getCommandLineArgs() =**
**#if INTERACTIVE**
  **fsi.CommandLineArgs**
**#else**
  **System.Environment.GetCommandLineArgs()**
**#endif**

getCommandLineArgs() |> printfn "%A"

幸运的是,两个函数返回相同的结果:一个字符串数组,其中第一个项目是脚本/可执行文件的名称。这极大简化了任何参数解析代码,因为最终结果是一样的。

--quiet

根据脚本的功能,FSI 可能会输出大量信息,有时结果会被噪音掩盖。要让 FSI 保持安静,可以使用--quiet选项。

**fsi --quiet**

--quiet选项将抑制 FSI 通常会输出的几乎所有内容,包括绑定、文件加载和程序集引用(但如果启用计时,统计信息除外)。FSI 仍然会显示错误信息和任何代码发送到控制台的内容。

--optimize

--optimize选项控制是否对代码应用编译器优化。它在 Visual Studio 中默认启用。

--tailcalls

我们将在第五章中详细讨论尾递归,但现在只需知道--tailcalls选项控制编译器是否对尾递归函数进行优化。此选项在 FSI 中默认启用。

总结

在本章中,您学习了如何使用 F#的 REPL 工具 F# Interactive 来帮助您探索问题并找到解决方案。您还了解了如何通过指令和命令行选项自定义 FSI 的行为。在下一章,我们将通过学习一些关键特性来开始探索 F#语言本身,无论您是采用函数式、面向对象还是命令式编程风格,这些特性都是通用的。

第三章 基础知识

在上一章中,你学习了如何通过快速反馈和任务自动化来提升 F# Interactive 的工作流程。现在,我们将把这些知识付诸实践,探索一些基本的语言特性。本章介绍的概念无论你主要使用命令式、面向对象还是函数式编程风格,都是适用的。

本章大部分内容集中于 F# 如何处理与 .NET Framework 中核心概念相关的内容,如核心数据类型、枚举、流程控制、泛型和异常处理。你还将学习如何通过控制副作用、默认不可变性、类型推断和选项类型来帮助你编写更可预测的代码。无论主题如何,你应该开始看到 F# 如何作为 C# 和 Visual Basic 的一种有力替代方案脱颖而出。

不可变性与副作用

如果你是从主要使用面向对象编程的背景转到 F#,你可能会发现最难适应的特性是默认不可变性。这是与传统 .NET 语言的根本区别,因为传统的语言对可以更改的内容和时间几乎没有限制。在没有默认不可变性的语言中编写的程序可能是不可预测的,因为系统状态(程序数据)几乎可以随时改变。我们将这些变化称为副作用

一些副作用,如写入控制台,相对来说是无害的,但如果它们影响共享资源呢?如果调用一个函数改变了在其他地方使用的值呢?无论何时调用,函数是否总是会产生相同的结果?考虑下面这个 C# 示例,它引用了一个公共字段进行乘法运算:

//C#
using System;
using System.Linq;

class Example
{
  public static int multiplier = 1;

  private static void ① Multiply(int value)
  {
    var result = value * multiplier;
    Console.WriteLine("{0} x {1} = {2}", value, ②multiplier++, result);
  }

  static void Main()
  {
    var range = Enumerable.Range(1, 100);
    foreach(var i in range)
    {
      Multiply(i);
    }
  }
}

// First 10 results
// 1 x 1 = 1
// 2 x 2 = 4
// 3 x 3 = 9
// 4 x 4 = 16
// 5 x 5 = 25
// 6 x 6 = 36
// 7 x 7 = 49
// 8 x 8 = 64
// 9 x 9 = 81
// 10 x 10 = 100

在这个例子中,Multiply 方法①有一个副作用,即multiplier会被递增②。只要程序中的其他部分没有变化,它是相对可预测的,但一旦你改变 Multiply 方法调用的顺序,增加另一个 Multiply 方法的调用,或者通过其他机制更改 multiplier 字段,所有后续结果就会变得不可预测。

为了进一步复杂化问题,考虑当多次并行调用 Multiply 时会发生什么,以下是 Main 方法的修改版本:

//C#
static void Main()
{
  var range = Enumerable.Range(1, 100);
  System.Threading.Tasks.Parallel.ForEach(range, i => Multiply(i));
}

// First 10 results
// 1 x 1 = 1
// 6 x 3 = 18
// 7 x 4 = 28
// 5 x 2 = 10
// 10 x 6 = 60
// 11 x 7 = 77
// 12 x 8 = 96
// 13 x 9 = 117
// 14 x 10 = 140
// 15 x 11 = 165

在并行运行时,无法保证哪个操作会先执行,因此运行这段代码 10 次可能会得到 10 种不同的结果。使用可变值所带来的不可预测性就是为什么全局状态(在应用程序中任何地方都可以访问的值)通常被认为是有害的原因。正确管理全局状态需要一种纪律,而随着团队和项目的扩大,这种纪律的执行可能变得越来越困难。

函数式纯粹性

像 F# 这样的函数式语言通常用其数学纯度来描述。在 纯粹的 函数式语言(如 Haskell)中,程序完全由确定性的 表达式 组成,这些表达式总是返回一个值,并且除非在某些特定情况下,副作用是明确禁止的。相比之下,F# 是一种 不纯 的函数式语言。因此,它通过默认将值设为不可变,朝着提高可预测性迈出了重要的一步。

这并不是说 F# 不能像传统意义上的变量那样使用变量;这只是意味着,为了改变一个值,你必须明确允许它,并应尽可能限制该值的作用范围。通过保持范围狭窄,你可以主要以函数式风格编写代码,但在适当的情况下在独立片段中切换到更具命令式或面向对象的风格。

通过通过默认的不变性来管理副作用,F# 代码更自然地适用于并行和并发环境。在许多情况下,仔细控制哪些内容可以更改,减少了(如果不是消除了的话)对共享资源加锁的需求,并确保多个进程不会试图对整体系统状态做出可能冲突或改变行为的更改。随着软件开发演变,越来越多地利用现代计算中普遍存在的多处理器或多核心系统,这种额外的安全性变得愈加重要。

绑定

绑定 是 F# 用于标识值或可执行代码的主要方式。共有三种类型的绑定——letusedo——每种都有其特定用途。

让绑定

let 绑定只是将名称与值关联。它们是最常见和最通用的绑定类型。(我在第二章中简要介绍了 let 绑定。)你可以使用 let 关键字创建一个 let 绑定。例如,绑定一个整数值,你可以使用如下代码:

let intValue = 1

同样,绑定一个字符串,你可以使用如下代码:

let strValue = "hello"

但是,let 绑定并不限于简单的赋值。你也可以用它们来标识函数或其他表达式:

let add a b = a + b
let sum = add 1 2

字面量

尽管我们迄今看到的 let 绑定是不可变的,但它们不能像传统 .NET 中的常量值那样被视为常量。绑定更像是 C# 中的 readonly 变量(Visual Basic 中的 ReadOnly),而不是常量,因为它们的值是在运行时解析的,而不是在编译时内联替换。你可以通过使用 Literal 特性来定义一个真正的 .NET 常量值,F# 中称之为 字面量。(F# 遵循与其他 .NET 语言相同的约定,使得 Attribute 后缀是可选的,因此在此示例中,LiteralLiteralAttribute 都是可以接受的。)

**[<Literal>]**
let FahrenheitBoilingPoint = 212

这使得编译器将定义视为 C# 中的 const(Visual Basic 中的 Const),意味着该值将在使用的地方内联编译。因此,作为 Literal 装饰的绑定必须是完全构造的值类型、字符串或 null。

可变绑定

如果你尝试使用赋值操作符(<-)更改默认绑定的值,编译器会告诉你这是不允许的。

let name = "Dave"

name <- "Nadia"
// Error – immutable binding

要使一个绑定变为可变,只需在其定义中包含 mutable 关键字。一旦定义了可变绑定,你就可以随意更改它的值。

let mutable name = "Dave"

name <- "Nadia"
// OK – mutable binding

当然,这里有一个警告:可变绑定与 闭包(可以访问在其定义作用域内可见的绑定的内联函数)不太兼容。

// Horrible, invalid code
let addSomeNumbers nums =
  let ① mutable sum = 0
  let add = ② (fun num -> sum <- sum + num)
  Array.iter (fun num -> add num) [| 1..10 |]

在这个例子中,可变绑定 sum① 被 add 闭包 ② 捕获。如果你尝试编译这段代码,编译器会礼貌地告诉你错误,并建议你要么消除这个变更,要么使用另一个可变结构——引用单元格

引用单元格

引用单元格像可变绑定一样,它们的值可以在运行时改变,但它们的工作方式完全不同。一个合理的理解方式是,引用单元格类似于指针,就像可变绑定类似于传统变量。也就是说,引用单元格其实并不是真正的指针,因为它们是封装可变值的具体类型,而不是指向特定资源或内存地址。

你可以像普通的 let 绑定一样创建一个新的引用单元格,只不过你在绑定的值前加上 ref 操作符。

let cell = **ref** 0

访问和更改引用单元格的值需要不同的语法,因为我们需要操作封装的值,而不是引用单元格本身。

① cell := 100
  printf "%i" ②! cell

如你所见,在 ① 处,:= 操作符用于更改引用单元格的值,在 ② 处,! 操作符用于返回单元格的值。

使用绑定

F# 提供了一种绑定机制,用于实现 IDisposable 接口的类型,这种机制类似于 C# 的 using 语句。在 F# 中,当你希望编译器插入一个对 IDisposable 对象的 Dispose 方法的调用时,你可以使用 use 关键字创建一个 use 绑定。

using 语句一样,它限定了 IDisposable 对象的作用域块,use 绑定创建的对象会在其所在的块结束时被处置;也就是说,如果在一个函数的顶层创建了 use 绑定,该对象将在函数返回后立即被处置。类似地,如果 use 绑定是在一个嵌套结构(例如循环)内创建的,该对象将在循环迭代完成时被处置。

以下示例演示了这个原理的实际应用:

open System

let ① createDisposable name =
  printfn "creating: %s" name
  ②{ new IDisposable with
   member x.Dispose() =
     printfn "disposing: %s" name
  }

let ③testDisposable() =
  use root = createDisposable "outer"
  for i in [1..2] do
    use nested = createDisposable (sprintf "inner %i" i)
    printfn "completing iteration %i" i
  printfn "leaving function"

在这个例子中,createDisposable 函数 ① 向控制台输出一条消息,告诉你正在创建一个可处置的对象。然后它返回一个对象,当该对象被处置时会输出一条消息 ②。testDisposable 函数 ③ 在一个简单的 for 循环内外反复调用 createDisposable 函数,并输出消息,告诉你每个代码块何时终止。

调用 testDisposable 函数会产生如下输出,显示每个对象在其包含块中被创建和处置的时间。

creating: outer
creating: inner 1
completing iteration 1
disposing: inner 1
creating: inner 2
completing iteration 2
disposing: inner 2
leaving function
disposing: outer

一个简单且更实用的 use 绑定示例是将一些文本写入文件,如下所示:

  open System.IO
① let writeToFile filename buffer =
    ②use fs = ③new FileStream(filename, FileMode.CreateNew, FileAccess.Write)
    fs.Write(buffer, 0, buffer.Length)

请注意,在 ① 处,使用了 let 绑定来定义 writeToFile 函数(函数在 F# 中也是数据),而在 ② 处,use 绑定与 new 关键字一起使用 ③ 来创建 FileStream。 (new 关键字在 F# 中是可选的,但按照惯例,每当创建一个 IDisposable 对象时都会包含它,以表示该对象应当被处置。如果你在没有 new 关键字的情况下创建 use 绑定,编译器会发出警告。)

use 绑定不能直接在模块内使用,主要是因为模块本质上是静态类,它们永远不会超出作用域。如果你尝试直接在模块中定义 use 绑定,你将收到编译器的警告,提示该绑定将被当作 let 绑定处理,如下所示:

warning FS0524: 'use' bindings are not permitted in modules and are
treated as 'let' bindings

using 函数

若想对 IDisposable 有更多控制,使用 using 函数。尽管它本身不是一个绑定,using 提供的功能与 C# 的 using 语句有些相似:给它一个 IDisposable 和一个接受该实例的函数,using 会在完成时自动调用 Dispose,如下面所示:

open System.Drawing
using (Image.FromFile(@"C:\Windows\Web\Screen\img100.jpg"))
      (fun img -> printfn "%i x %i" img.Width img.Height)

从某些方面来说,using 比它的 C# 对应物更强大,因为像 F# 中的每个表达式一样,它都会返回一个值。考虑以下修改版的前一个例子:

  open System.Drawing
① let w, h = using (Image.FromFile(@"C:\Windows\Web\Screen\img100.jpg"))
                   (fun img -> ②(img.Width, img.Height))
③ printfn "Dimensions: %i x %i" w h

我们没有在传递给 using 函数的 ② 中的函数内直接将维度写入控制台,而是将它们作为 元组(一个包含多个数据项的简单类型)返回,并将每个组件值绑定到有意义的名称,如 ① 所示,然后在 ③ 中写入控制台。即便在这个简单的例子中,你也可以看到 F# 的组合式、表达性语法如何通过消除大部分 管道代码(为满足编译器需要编写的代码),让你能够专注于问题本身,从而得出更易于理解的解决方案。

在 C# 中复制 using 函数

我非常喜欢 F# 的 using 函数,因此我为我的 C# 项目创建了一些静态辅助方法。

// C#
public static class IDisposableHelper
{
  public static TResult Using<TResource, TResult>
    (TResource resource, Func<TResource, TResult> action)
      where TResource : IDisposable
  {
    using(resource) return action(resource);
  }

  public static void Using<TResource>
    (TResource resource, Action<TResource> action)
      where TResource : IDisposable
  {
    using(resource) action(resource);
  }
}

它们不算漂亮,但能够完成任务。

现在,这里是使用我的辅助函数的前面例子的 C# 版本。

// C#
// using System.Drawing
IDisposableHelper.Using(
  Image.FromFile(@"C:\Windows\Web\Screen\img100.jpg"),
  img => Console.WriteLine("Dimensions: {0} x {1}", img.Width, img.Height)
);

var dims =
  IDisposableHelper.Using(
    Image.FromFile(@"C:\Windows\Web\Screen\img100.jpg"),
    img => Tuple.Create(img.Width, img.Height)
  );

Console.WriteLine("Dimensions: {0} x {1}", dims.Item1, dims.Item2);

尽管代码看起来和行为上与 F# 版本相似,但我发现 F# 版本更加简洁,特别是它对元组的语法支持。

do 绑定

最后一种绑定类型是 do 绑定,通过 do 关键字定义。与其他绑定类型不同,do 绑定不会将值附加到名称上;它们用于在函数或值定义之外执行一些代码。

do绑定通常在循环结构、序列表达式、类构造函数和模块初始化中使用。我们将在后续章节中逐一讨论这些场景。

标识符命名

我们已经看过一些标识符,但还没有真正探讨什么才是有效的标识符。像任何编程语言一样,F#也有命名规则。

F#中的标识符与大多数编程语言非常相似。通常,F#标识符必须以下划线(_)、大写字母或小写字母开头,后面可以跟随任何组合。数字也可以作为标识符中的有效字符,只要它们不是第一个字符。例如,以下是有效的标识符。

let myIdentifier = ""
let _myIdentifier1 = ""

F#中最有趣的标识符之一是替代的引用标识符格式,它有更少的限制。通过将标识符用双反引号(")括起来,你可以几乎使用任何字符串作为有效的 F#标识符,例如这样。

let "This is a valid F# identifier" = ""

通常最好谨慎使用引用标识符,但在某些情况下,它们非常有用。例如,它们经常用于命名单元测试。通过使用引用标识符命名测试,你可以专注于描述测试内容,而不是争论命名约定。如果你使用测试框架(如 NUnit),测试列表中的完整引用名称可以明确说明正在测试什么。

核心数据类型

作为.NET 语言,F#支持完整的公共语言基础结构(CLI)类型。每个核心原始类型,甚至一些更复杂的类型,如System.String,都作为类型缩写(现有类型的便捷别名)暴露出来。许多类型还支持附加的语法支持,以增强类型推断(编译器自动确定数据类型的能力)或简化与它们的交互。

布尔值和运算符

bool类型缩写暴露了标准的System.Boolean结构。与其他语言一样,bool只能取两个值:truefalse

F#语言包括一些用于比较布尔值的运算符,具体列表请参见表 3-1。

表 3-1. 布尔运算符

运算符 描述
not 取反
&#124;&#124; OR
&& AND

OR 和 AND 运算符是短路运算符,因此当左侧的表达式满足整体条件时,它们会立即返回。对于 OR 运算符,如果左侧的表达式为真,则不需要评估右侧的表达式。同样,AND 运算符只有在左侧的表达式为真时才会评估右侧的表达式。

数值类型

F# 提供了与其他 .NET 语言相同的数字类型选择。表 3-2 列出了常用的数字类型及其相应的 .NET 类型、值范围和后缀。

表 3-2. 常见的数字类型

类型缩写 .NET 类型 范围 后缀
byte System.Byte 0 到 255 uy
sbyte, int8 System.SByte –128 到 127 y
int16 System.Int16 –32,768 到 32,767 s
uint16 System.UInt16 0 到 65,535 us
int, int32 System.Int32 –2³¹ 到 2³¹–1
uint, uint32 System.UInt32 0 到 2³²–1 u, ul
int64 System.Int64 –2⁶³ 到 2⁶³–1 L
uint64 System.UInt64 0 到 2⁶⁴–1 UL
decimal System.Decimal –2⁹⁶–1 到 2⁹⁶–1 M
float, double System.Double 64 位双精度数,精确到大约 15 位数字
float32, single System.Single 32 位单精度数,精确到大约 7 位数字 F, f
bigint System.Numerics.BigInteger 无定义的上限或下限 I
nativeint System.IntPtr 32 位平台特定整数 n
unativeint System.UIntPtr 32 位平台特定的无符号整数 un

一般来说,后缀在 F# 中使用的频率比其他 .NET 语言更高,因为它们为编译器提供了正确推断类型所需的所有信息。

数字运算符

正如你所预期的,F# 包含了许多用于处理数字类型的内置运算符。表 3-3 列出了常用的算术、比较和位运算操作。

表 3-3. 数字运算符

操作符 描述
+ 一元正号(不改变表达式的符号)未检查的加法
- 一元负号(改变表达式的符号)未检查的减法
* 未检查的乘法
/ 未检查的除法
% 未检查的取模
** 未检查的指数(仅对浮点类型有效)
= 等于
> 大于
< 小于
>= 大于或等于
<= 小于或等于
<> 不等于
&&& 位运算与
&#124;&#124;&#124; 位运算或
^^^ 位运算异或
~~~ 位运算取反
<<< 位运算左移
>>> 位运算右移

需要注意的是,尽管 表 3-3 中的大多数运算符都可以用于任何数字类型,但位运算符仅适用于整数类型。此外,由于浮点数在内存中的表示方式,你应该避免直接使用等于运算符,否则可能会得到错误的结果,如下所示:

> **let x = 0.33333**
**let y = 1.0 / 3.0**
**x = y;;**

val x : float = 0.33333
val y : float = 0.3333333333
val it : bool = false

与其使用等号操作符(=),不如计算两个浮点值之间的差异,并验证该差异是否在阈值范围内。我通常更倾向于将这种操作定义为一个函数,以便复用。

> **open System**
**let approximatelyEqual (x : float) (y : float) (threshold : float) =**
  **Math.Abs(x - y) <= Math.Abs(threshold)**
**approximatelyEqual 0.33333 (1.0 / 3.0) 0.001;;**

val approximatelyEqual : x:float -> y:float -> threshold:float -> bool
val it : bool = true

数字转换函数

当你在 F# 中处理数值数据类型时,没有隐式类型转换。这主要是因为类型转换被视为副作用,且由于隐式类型转换引发的计算问题通常很难定位。

若要在同一表达式中处理不同的数值类型,你需要使用适当的内建转换函数进行显式转换。每个转换函数的名称与目标类型的缩写相同,这使得它们非常容易记住。例如,要将整数值转换为浮点数,你可以调用 float 函数,如此示例所示。

let marchHighTemps = [ 33.0; 30.0; 33.0; 38.0; 36.0; 31.0; 35.0;
                       42.0; 53.0; 65.0; 59.0; 42.0; 31.0; 41.0;
                       49.0; 45.0; 37.0; 42.0; 40.0; 32.0; 33.0;
                       42.0; 48.0; 36.0; 34.0; 38.0; 41.0; 46.0;
                       54.0; 57.0; 59.0 ]
let totalMarchHighTemps = List.sum marchHighTemps
let average = totalMarchHighTemps / float marchHighTemps.Length

字符

作为一门 .NET 语言,F# 延续了使用 16 位 Unicode 来表示字符数据的传统。单个字符由 System.Char 表示,并通过 char 类型缩写暴露给 F#。你可以通过将它们包裹在单引号中来绑定大多数单个 Unicode 字符,而其余字符则通过转义字符代码表示,如下所示。

> **let letterA = 'a'**
**let copyrightSign = '\u00A9';;**

val letterA : char = 'a'
val copyrightSign : char = '©'

除了 Unicode 字符转义代码外,F# 还有一些其他转义序列用于表示一些常见字符,如 表格 3-4 中列出。

表格 3-4. 常见转义序列

字符 序列
退格符 \b
换行符 \n
回车符 \r
制表符 \t
反斜杠 \\
引号 \"
撇号 \'

字符串

字符串是 char 的顺序集合,并由 string 类型缩写表示。F# 中有三种类型的字符串:字符串字面量、逐字字符串和三引号字符串。

字符串字面量

最常见的字符串定义是 字符串字面量,它被引号括起来,如下所示。

> **let myString = "hello world!";;**
val myString : string = "hello world!"

字符串字面量可以包含 表格 3-4 中描述的相同字符和转义序列。字符串字面量中的换行符会被保留,除非它们前面有一个反斜杠(\)字符。如果存在反斜杠,换行符将被移除。

逐字字符串

逐字字符串 与字符串字面量非常相似,唯一不同的是它们以 @ 字符开头并且忽略转义序列。你可以在字符串中嵌入引号,但必须以 "" 的形式写入,像这样:

> **let verbatimString = @"Hello, my name is ""Dave""";;**
val verbatimString : string = "Hello, my name is "Dave""

不解析转义序列使得逐字字符串成为表示包含反斜杠的系统路径的良好选择,前提是你没有将它们存储在某个配置设置中。(你没有硬编码路径,对吧?)

三引号字符串

顾名思义,三重引号字符串是用三个引号括起来的,像这样:""Klaatu barada nikto!""。三重引号字符串类似于逐字字符串,它忽略所有的转义序列,但它也忽略双引号。这种类型的字符串在处理包含嵌入引号的格式化字符数据时最为有用,例如 XML 文档。例如:

> **let tripleQuoted = """<person name="Dave" age="33" />""";;**
val tripleQuoted : string = "<person name="Dave" age="33" />"

字符串连接

当你想要将多个字符串组合在一起时,可以通过多种方式进行连接。首先,System.String类中的传统Concat方法是其中的一种。这个方法和你在其他.NET 语言中所期望的完全一致。

> **System.String.Concat("abc", "123");;**
val it : string = "abc123"

警告

在使用 String.Concat 时要小心,避免不小心使用 FSharp.Core 中定义的 concat 扩展方法。这个 concat 扩展方法与 String.Join 方法更为相似,而非 String.Concat。

你还可以使用+^运算符来使代码看起来更简洁。例如:

> **"abc" + "123";;**
val it : string = "abc123"

+运算符更受青睐,特别是在跨语言场景中,因为它是在System.String上定义的。^运算符是为了兼容 ML 而提供的,如果你在代码中使用它,编译器会发出警告。

类型推导

到目前为止,我小心翼翼地避免在示例中明确声明任何数据类型,以便展示 F#最有趣的特点之一:类型推导。类型推导意味着编译器通常能够根据单个值和使用情况推断出数据类型。事实上,F#的类型推导能力如此强大,以至于它常常给 F#的新手一种语言是动态类型的错觉,尽管它实际上是静态类型的。

F#当然不是第一个支持类型推导的.NET 语言。C#通过var关键字支持类型推导,Visual Basic 也支持,当Option Infer启用时同样如此。然而,尽管 C#和 Visual Basic 中的类型推导有助于避免一些显式的类型声明,但它们仅在非常有限的情况下有效。而且,尽管 C#和 Visual Basic 能够推导出单个值的数据类型,但它们仍然通常要求你在多个地方显式指定类型。相比之下,F#的自顶向下的求值方式将类型推导提升到.NET 中前所未见的水平。

F#的类型推导能力贯穿整个语言。你之前见过类型推导的例子,从简单的值到函数参数和返回类型,但这个特性甚至渗透到 F#的面向对象特性中。

有点提前讲得太多了,我们来看看 F#的类型推导在简单类定义中的帮助,先从 C#的一个示例开始。

// C#
using System;

public class Person
{
  public Person(Guid id, string name, int age)
  {
    Id = id;
    Name = name;
    Age = age;
  }

  public Guid Id { get; private set; }
  public string Name { get; private set; }
  public int Age { get; private set; }
}

即便是在这个简单的示例中,C#也需要不少于六个显式的类型声明。如果你想进一步将其扩展,定义readonly备份变量,而不是使用带有私有 setter 的自动实现属性,那么类型声明的数量将增加到九个!

现在我们来看一个 F#中的等效类。

type Person (id : System.Guid, name : string, age : int) =
  member x.Id = id
  member x.Name = name
  member x.Age = age

是的,那两个定义确实是相同的类!不相信?图 3-1 显示了根据反编译工具 ILSpy 查看每个类在编译后的程序集中的样子。

F# 和 C# 编译后的类比较图 3-1. F# 和 C# 编译后的类比较

注意

这两个类之间有一个微妙的差别没有在图中展示。C# 类通过私有设置器设置属性值,而 F# 类则放弃了私有设置器,完全依赖于后备变量。

正如你在反编译的代码中看到的,两个类几乎是一样的。忽略两种语言之间的其他语法差异(面向对象编程在第四章有介绍),你可以看到 F# 的类型推断在整个示例中的实际应用。在 F# 中,我们只需要在构造函数中为每个成员指定数据类型,并且在许多情况下编译器也能在此推断出类型。即使是每个属性的数据类型,也从这个定义中自动推断出来!

在编译器无法推断类型的情况下,你可以添加 类型注解 来告诉它应该使用什么类型。你可以在引入新值的任何地方使用类型注解。例如,你可以在 let 绑定中这样添加类型注解:

let **i : int** = 42;;

你也可以为函数定义的每个部分添加注解。一个加法函数,可能会这样注解:

let add **(a : int) (b : int) : int** = a + b

在这个示例中,没有进行类型推断,因为定义明确指定了类型。

空值性

如果项目结构的差异和不可变性还不足以让你头晕,F# 还有另一个技巧:null 几乎从不使用!在 F# 中,你不能直接创建 null 值,除非使用库函数,且在 F# 中定义的类型只有在使用 AllowNullLiteral 特性装饰时,null 才能作为有效值。如果不是为了与使用没有相同限制的语言编写的 .NET 程序集进行互操作,null 可能根本不会包含在语言中。

通过对 null 添加如此严格的限制,F# 语言设计者大大减少了遇到孤立的 null 引用的可能性,特别是当你完全在 F# 中工作时。这意味着你花在检查每个引用类型实例是否为 null 上的时间会更少,再去处理它之前。

也就是说,null 仍然是 F# 中的一个有效关键字,你会发现确实有时需要使用它,特别是在与其他 .NET 语言编写的程序集交互时。通常,你会将 null 作为参数传递给库函数,或者验证库函数的返回值是否为 null

选项

虽然 F#努力消除软件中的空值,但在某些情况下,某些东西确实没有值。没有空值可能会让这看起来像个问题,但语言已经为你考虑到了这一点。

F#并不是简单地允许null对每个引用类型都有效,而是通过Option<'T>类型采取了选择加入的方式。这个类型是一个泛型判别联合,包含两个值:Some('T)None。从某种程度上来说,选项就像可空类型,但它们的显式特性使得缺少有意义的值变得显而易见。(我们将在第五章中讲解判别联合,泛型将在本章稍后介绍。)

选项在 F#中非常重要,以至于它们在类型注解中通过option关键字得到了语法支持,如下所示:

> **let middleName : string option = None;;**
val middleName : string option = None

> **let middleName = Some("William");;**
val middleName : string option = Some "William"

选项也是编译器表示构造函数或方法的可选参数的方式。通过在参数前加上问号(?),可以使一个参数变为可选的。可选参数只能放在参数列表的末尾,如下所示:

type Container() =
  member x.Fill ①?stopAtPercent =
    printfn "%s" <| match (②defaultArg stopAtPercent 0.5) with
                    | 1.0 -> "Filled it up"
                    | stopAt -> sprintf "Filled to %s" (stopAt.ToString("P2"))
let bottle = Container()

在前面的示例中,①是可选的stopAtPercent参数。函数需要处理stopAtPercentNone的情况。一个常见的提供默认值的方式是使用defaultArg函数②。这个函数有点像 C#中的空合并运算符(??),只不过它是与选项一起使用,而不是与空值一起使用。defaultArg函数接受一个选项作为第一个参数,并在它是Some<_>时返回它的值;否则,返回第二个参数。

单位类型

表达式必须始终求值为一个值,但有时它们只是为了副作用而求值,例如写入日志或更新数据库。在这些情况下,可以使用unit类型。unit类型由()(一对空括号)表示,是一个具体类型,具有单一值,表示没有特定的值存在,因此任何返回unit的表达式的结果可以安全地忽略。(在某些方面,unit类似于 C#中的void返回类型,它应该在一个函数没有实际返回值时返回,但它在语法上也用于表示无参数函数。)

每当一个表达式返回除unit之外的值时,F#要求你对它做点什么。编译器并不关心你是将值绑定到标识符,还是将它作为参数传递给函数;它只希望你使用它。当你没有对返回值做任何操作时,编译器会警告该表达式应该具有unit类型,因为它可能实际上表示一个程序错误(这个警告只会在编译后的代码中显示,FSI 中不会出现)。例如:

**let add a b = a + b**
// Compiler warning
add 2 3

如果你不想对返回值做任何操作,可以将结果传递给ignore函数,它接受一个单一的、不受约束的泛型参数,并返回unit

**let add a b = a + b**
// No warning
add 2 3 |> ignore

在这个例子中,add 函数的结果通过 前向管道操作符 (|>) 发送到 ignore 函数。该操作符会评估左侧的表达式,并将结果作为最后一个参数传递给右侧的表达式。我们将在 管道 中详细介绍前向管道操作符。

枚举

枚举 通过让你为整数值分配描述性标签,帮助你编写更具可读性的代码。F# 枚举与其他 .NET 语言编译成相同的 CLI 类型,因此 C# 或 Visual Basic 中适用的所有功能和限制在 F# 中也适用。

F# 中枚举的基本语法是:

type enum-name =
   | value1 = integer-literal1
   | value2 = integer-literal2
   -- *snip* --

然而,与 C# 和 Visual Basic 不同,F# 不会自动为每个标签生成一个枚举值,因此你需要显式地提供一个。例如,如果你的程序将每一天表示为一个整数,你可能会像这样定义一个 DayOfWeek 枚举:

type DayOfWeek =
| Sunday = 0
| Monday = 1
| Tuesday = 2
| Wednesday = 3
| Thursday = 4
| Friday = 5
| Saturday = 6

如果你希望基于 int 以外的整数类型来定义枚举,只需在标签定义中包含适当的后缀。例如,你可以通过改变每个值的后缀,将之前的 DayOfWeek 示例轻松改为使用 sbyte 作为其基础类型:

type DayOfWeekByte =
| Sunday = **0y**
| Monday = **1y**
| Tuesday = **2y**
-- *snip* --

标志枚举

到目前为止,我们看到的枚举仅表示单一值。然而,通常每个标签会通过位掩码中的位置表示一个值,这样可以将多个项组合在一起。

例如,考虑 System.Text.RegularExpressions 命名空间中的 RegexOptions 枚举。该枚举允许你通过使用逻辑 or 运算符将多个值组合在一起,从而控制正则表达式引擎如何处理模式,例如:

open System.Text.RegularExpressions
let re = new Regex("^(Didactic Code)$",
                   RegexOptions.Compiled ||| RegexOptions.IgnoreCase)

为了在你自己的枚举中实现相同的结果,添加 Flags 特性并使用 2 的幂值。

open System

**[<Flags>]**
type DayOfWeek =
| None = 0
| Sunday = 1
| Monday = 2
| Tuesday = 4
| Wednesday = 8
| Thursday = 16
| Friday = 32
| Saturday = 64

注意

Flags 特性不是必需的,但建议包含它,以便向其他开发者展示该枚举应如何使用。

现在,你可以通过将星期六和星期天的值组合在一起来表示周末,就像我们之前做的那样。

let weekend = DayOfWeek.Saturday ||| DayOfWeek.Sunday

如果你知道多个值将会经常组合,考虑将这些组合包含在你的枚举定义中。F# 不允许在定义中按名称引用其他值,但你仍然可以提供相应的整数值。例如,在 DayOfWeek 中,你可以提供 WeekdaysWeekendDays,其值分别为 6265

open System

[<Flags>]
type DayOfWeek =
-- *snip* --
| **Weekdays = 62**
| **WeekendDays = 65**

使用 System.EnumHasFlag 方法,可以轻松确定某个特定枚举值是否设置了特定标志。

> **DayOfWeek.Weekdays.HasFlag DayOfWeek.Monday;;**
val it : bool = true
> **DayOfWeek.Weekdays.HasFlag DayOfWeek.Thursday;;**
val it : bool = true
> **DayOfWeek.Weekdays.HasFlag DayOfWeek.Sunday;;**
val it : bool = false

重建枚举值

使用命名标签来表示整数值是避免代码中出现魔法数字(没有明显含义的数字)的一种好方法,但如果你将底层值(比如保存到数据库)存储起来,稍后又想从中重建原始的枚举值该怎么办呢?内置的enum函数可以让你对整数(int32)值做到这一点。

> **enum<DayOfWeek> 16;;**
val it : DayOfWeek = Thursday

当枚举的底层类型不是int32时,请改用Microsoft.FSharp.Core.LanguagePrimitives模块命名空间中的EnumOfValue函数。

> **open Microsoft.FSharp.Core.LanguagePrimitives**
**EnumOfValue<sbyte, DayOfWeek> 16y;;**
val it : DayOfWeek = Thursday

注意

枚举类型不限于标签所标识的值,因此在使用这些函数时,请确保只创建你在代码中已经考虑过的枚举值。

流程控制

尽管 F#强调函数式编程,但它完全支持多种命令式结构用于循环和分支。这些结构与其他构造如序列表达式(尤其是循环结构)结合使用时尤其有用,但在其他上下文中也同样有用。

循环

递归是函数式编程中首选的循环机制,但 F#也包括一些典型的命令式语言中的循环方法。这些循环结构与其他语言中的类似。

注意

F#没有提供(如 break 或 continue 之类的)机制来提前终止循环,因此在使用循环时要特别小心。

while 循环

最简单的迭代结构是while...do循环。顾名思义,这种结构会评估一个布尔表达式,并在该条件为真时进行迭代。while循环适用于需要迭代一个不确定次数的情况,但由于它们本质上依赖于状态变化,因此无法用于纯函数式编程。循环体可以是任何返回unit的表达式。

while循环有助于响应用户输入。在下面的示例中,echoUserInput函数使用while循环回显用户在控制台输入的内容,直到遇到单词quit为止。

let echoUserInput (getInput : unit -> string) =
  let mutable input = getInput()
  while not (input.ToUpper().Equals("QUIT")) do
    printfn "You entered: %s" input
    input <- getInput()

echoUserInput (fun () -> printfn "Type something and press enter"
                         System.Console.ReadLine())

for 循环

当你知道需要进行多少次迭代时,可以使用其中一种for循环变体:简单的或可枚举的。简单for循环非常有限,只能迭代整数范围,并始终返回unit。尝试返回其他内容将导致编译错误。

简单的for循环在你知道需要迭代多少次时非常有用。以下是一个简单for循环的例子,打印数字 0 到 100:

for i = 0 to 100 do printfn "%i" i

通过将to关键字替换为downto关键字,你可以使一个简单的for循环进行倒计时。

for i = 100 **downto** 0 do printfn "%A" i

for循环的更强大变种是枚举型for循环。在某些方面,枚举型for循环类似于 C#的foreach循环,因为它作用于任何序列(实现了IEnumerable<'T>的集合类型)。例如,枚举型for循环使得遍历整数范围变得容易,如下所示:

for i in [0..10] do
  printfn "%A" i

然而,实际上,枚举型的for循环是一个方便的语法快捷方式,它在序列上应用了 F#强大的模式匹配能力。通过模式匹配,你可以从更复杂的类型中提取值,甚至在循环定义中执行一些基本的过滤!无需 LINQ!

① type MpaaRating =
  | G
  | PG
  | PG13
  | R
  | NC17

② type Movie = { Title : string; Year : int; Rating : MpaaRating option }

③ let movies = [ { Title = "The Last Witch Hunter"; Year = 2014; Rating = None }
                 { Title = "Riddick"; Year = 2013; Rating = Some(R) }
                 { Title = "Fast Five"; Year = 2011; Rating = Some(PG13) }
                 { Title = "Babylon A.D."; Year = 2008; Rating = Some(PG13) } ]

④ for { Title = t; Year = y; Rating = Some(r) } in movies do
    printfn "%s (%i) - %A" t y r

在①处,我们看到一个表示评分尺度的区分联合,在②处看到一个表示电影并带有可选评分的记录类型,在③处看到一个 F#列表,最后在④处看到一个for...in循环,结合模式匹配来查找所有已经评分的电影。编译器会高亮显示模式匹配并警告你没有覆盖所有情况,但这没关系,因为我们把它当作一个过滤器。

注意

别担心现在讨论的模式匹配、区分联合、记录类型以及其他函数式概念。我们将在第五章和第七章中详细探讨每一个概念。

分支

F#仅提供一种命令式的分支构造:if...then...else表达式,如下所示。此表达式在if部分求值一个布尔表达式。当该表达式求值为true时,执行then分支;否则,执行else分支(如果有的话)。

let isEven number =
  if number % 2 = 0 then
    printfn "%i is even" number
  else
    printfn "%i is odd" number

你可以通过elif关键字(else if的快捷方式)将多个if...then...else表达式连接起来,如下所示。这与将它们嵌套在一起的效果相同,不过结果更加可读。

let isEven number =
  if number = 0 then
    printfn "zero"
  **elif** number % 2 = 0 then
    printfn "%i is even" number
  else
    printfn "%i is odd" number

因为if...then...else表达式返回一个值,所以像 C#中的条件操作符(?:)这样的构造不再需要。需要注意的是,由于表达式返回一个值,具体的行为会根据其使用方式有所不同。当只指定if分支时,其表达式必须求值为unit,但当同时指定ifelse分支时,它们的表达式必须都求值为相同类型。

到目前为止,在每个例子中,if...then...else表达式的结果都是unit,但如果将函数改为使用sprintf而不是printfn,会发生什么呢?

let **isEven** number =
  if number = 0 then
    **sprintf** "zero"
  elif number % 2 = 0 then
    **sprintf** "%i is even" number
  else
    **sprintf** "%i is odd" number

isEven函数实际上将消息作为字符串返回,而不是打印到控制台。你可以通过在 FSI 中调用该函数来验证这一点,如下所示:

> **isEven 0;;**
val it : string = "zero"
> **isEven 1;;**
val it : string = "1 is odd"
> **isEven 2;;**
val it : string = "2 is even"

泛型

Don Syme,F#的设计者和架构师,深度参与了最终成为.NET 框架中泛型的研究和开发。拥有这样的背景,F#中的泛型异常强大,某些方面甚至比其他.NET 语言中的泛型更为强大,毫不奇怪。

泛型允许你定义可以直接与任何数据类型一起工作的函数、类、方法、接口和结构。没有泛型时,唯一编写与多种数据类型兼容的类型安全代码的方式是为每个类型编写单独的实现。然而,这种方法是有限制的,因为任何依赖于该代码的新类型都需要自己的实现。泛型通过根据你在代码中提供的类型参数自动为你生成这些实现,从而抽象化了这种复杂性。

为了展示泛型的真正用处,考虑一下原始 .NET 集合类型之一,ArrayList,与其“亲戚”泛型列表的对比。ArrayList 类是一种从 .NET 初期就存在的集合类型,在泛型被引入框架之前就已经使用了。为了能够存储任何类型的数据,它必须将每个元素视为 System.Object。因此,使用 ArrayList 编写的代码几乎总是涉及过多的类型转换,尤其是列表中的元素。更糟糕的是,ArrayList 没有办法强制元素之间保持一致性,因此,尽管开发人员可能认为列表中的每个元素都是字符串,但它也可能包含整数、浮动点数或其他任何数据类型的实例。这种类型的代码非常容易出错,并且往往对性能产生负面影响。

另一方面,泛型 List<'T> 类可以实例化为处理任何特定数据类型。它消除了关于元素类型的所有歧义,并通常避免了类型转换(即使有子类化的情况),从而导致更可靠和高效的代码。

自泛型引入以来,几乎每个 .NET 开发中的创新都离不开它们的身影,包括 LINQ 和任务并行库(Task Parallel Library)。在某些方面,泛型在 F# 开发中的作用甚至比在传统 .NET 开发中更为重要,因为它们在类型推断过程中的角色以及像静态解析类型参数这样的概念(在静态解析类型参数中有讨论)。

在 F# 中,大多数泛型类型参数都以撇号开头。例如,'a'A'TInput 都是有效的类型参数名称。根据约定,F# 使用小写顺序标识符表示推断的类型参数,而用户定义的类型参数则以大写字母开头。

自动泛化

F# 的类型推断特性在可能的情况下优先使用泛型类型,采用一种被称为自动泛化的过程。以下是其应用示例:

> **let toTriple a b c = (a, b, c);;**
val toTriple : a:'a -> b:'b -> c:'c -> 'a * 'b * 'c

在这个示例中,toTriple函数将它的三个参数转换为一个包含三项的元组(有时称为三元组)。我们将在第五章中详细探讨箭头和元组;现在只需知道,编译器自动将这三个参数分别泛化为类型'a'b'c

编译器是否能够自动泛化一个参数,主要取决于该参数的使用方式和位置。只有在具有显式参数的完整函数定义中,对不可变值进行自动泛化时,编译器才会尝试进行泛化。

显式泛化

如果编译器不能自动泛化一个参数,或者你想对其进行更多控制,可以通过类型注解显式地泛化一个参数。当你想要约束允许的类型时,这尤其有用。你可以像下面这样,用显式的类型参数重写之前的toTriple示例:

> **let toTriple (a : 'A) (b : 'B) (c : 'C) = (a, b, c);;**
val toTriple : a:'A -> b:'B -> c:'C -> 'A * 'B * 'C

当类型参数没有约束时,你在使用它们时会受到很大限制。通常,你只能将它们与其他没有约束的泛型类型或函数一起使用,此外几乎无法调用超出System.Object上定义的方法。如果你需要依赖于类型的某个方面进行操作,比如调用接口方法,你将需要添加一个约束

如果你熟悉 C#或 Visual Basic 中的泛型约束,可能会对你实际上能够约束的内容感到沮丧。在这些语言中,你只能将类型参数约束为引用类型、值类型、具有默认构造函数的类型、继承自特定类的类型以及实现特定接口的类型。而 F#不仅支持这些,还增加了一些其他约束。

注意

大多数约束类型适用于标准类型参数,但有一些仅适用于 F#特定形式的类型参数,称为 静态解析类型参数。在以下示例中,你将看到这些约束在使用插入符号(^)而不是撇号的类型参数的内联函数中定义。静态解析类型参数将在本节后面描述。

你通过在泛型类型注解后跟when和约束来应用约束。你可以通过使用and将多个约束结合起来。

  • 子类型约束。子类型约束将可接受的类型限制为约束类型本身或从该类型派生的任何类型。当约束类型是接口时,提供的类型需要实现该接口。

    let myFunc (stream : 'T when 'T :> System.IO.Stream) = ()
    

    空值约束 空值约束将可接受的类型限制为那些null是有效值的类型。

    let inline myFunc (a : ^T when ^T : null) = ()
    
  • 成员约束。成员约束确保提供的类型包含一个具有特定签名的成员。你可以根据实例成员或静态成员来约束类型。

    // instance member
    let inline myFunc
      (a : ^T when ^T : (member ReadLine : unit -> string)) = ()
    
    // static member
    let inline myFunc
      (a : ^T when ^T : (static member Parse : string -> ^T)) = ()
    
  • 默认构造函数约束。默认构造函数约束确保提供的类型具有默认构造函数。

    let myFunc (stream : 'T when 'T : (new : unit -> 'T)) = ()
    
  • 值类型约束。值类型约束将提供的类型限制为任何.NET 值类型,除了System.Nullable<_>

    let myFunc (stream : 'T when 'T : struct) = ()
    
  • 引用类型约束。引用类型约束确保提供的类型是.NET 引用类型。

    let myFunc (stream : 'T when 'T : not struct) = ()
    
  • 枚举约束。枚举约束将提供的类型限制为具有特定基础类型的枚举类型。

    let myFunc (stream : 'T when 'T : enum<int32>) = ()
    
  • 委托约束。委托约束将提供的类型限制为具有特定参数集和返回类型的委托类型。委托约束主要用于传统的.NET 事件处理程序。

    open Systemlet
    myFunc (stream : 'T when 'T : delegate<obj * EventArgs, unit>) = ()
    
  • 非托管约束。非托管约束将提供的类型限制为非托管类型,如某些数值原始类型和枚举类型。

    let myFunc (stream : 'T when 'T : unmanaged) = ()
    
  • 相等性约束。相等性约束将提供的类型限制为支持相等性的类型。此约束被认为是弱约束,因为几乎所有 CLI 类型都满足此约束。

    let myFunc (stream : 'T when 'T : equality) = ()
    
  • 比较约束。比较约束仅由实现了System.IComparable、数组、nativeintunativeint的类型满足,除非该类型具有NoEquality属性。

    let myFunc (stream : 'T when 'T : comparison) = ()
    

灵活类型

尽管灵活类型并非严格的泛型结构,灵活类型实际上是子类型约束的语法简写。它们在高阶函数的函数参数中尤其有用,因为自动类型转换通常不会自动发生。

你可以通过在类型注解中用#字符作为前缀来指定灵活类型。

let myFunc (stream : #System.IO.Stream) = ()

通配符模式

当你想使用泛型类型作为参数,但希望编译器推断出类型时,可以使用通配符模式来代替命名的类型参数。通配符模式用下划线表示。

let printList (l : List<**_**>) = l |> List.iter (fun i -> printfn "%O" i)

上述函数将打印 F#列表中的每个元素,并使用其ToString函数,而不管列表中包含的是什么类型。

静态解析的类型参数

F#有两种泛型分类。第一种(我们到目前为止几乎专注于此)是标准泛型,与其他.NET 语言中的泛型相同。第二种,称为静态解析类型参数,是 F#特有的,并用插入符号(^)代替撇号来表示。静态解析类型参数强制编译器在编译时解析类型,而不是在运行时解析。这意味着编译器为每个解析出的类型生成泛型类型的一个版本,而不是生成单一版本。

静态解析类型参数主要用于内联函数,并且特别适用于自定义操作符,如此处所示。

let inline (!**) x = x ** 2.0

当此操作符被编译时,它使用静态解析和约束来确保任何使用它的类型都包括Pow函数,基于**操作符的使用。

val inline ( !** ) :
  x: **^a** -> **^a** when **^a** : (static member **Pow** : **^a** * float -> **^a**)

出现问题时

尽管你已尽最大努力,并且 F#提供了额外的安全性,但事情还是可能出错。适当的错误处理是任何程序的关键组成部分。F#在标准的.NET 异常机制基础上,提供了额外的语法支持,使你能够轻松地抛出(或在 F#术语中称为提升)和处理异常。(为了方便,标准的异常类型System.Exception简写为exn。)

处理异常

错误条件始终是可能的,因此在它们出现时,正确处理它们是非常重要的。F#提供了两种错误处理结构:try...withtry...finally。这两种结构是完全独立的;也就是说,F#中没有try...with...finally结构。如果你需要同时使用withfinally块,通常会将try...with块嵌套在try...finally块内,尽管嵌套的顺序并不重要。

try...with 表达式

try...with结构中,try块中的表达式会被求值,如果其中有任何异常被抛出,则使用 F#模式匹配来定位with块中合适的处理器。

输入/输出相关操作,如从文件中读取,是使用异常处理结构的典型示例,因为你受到外部因素的制约,比如网络可用性问题或文件权限问题。在这个例子中,我们尝试读取一个文本文件并将其内容写入控制台,但在try块中进行。

open System.IO

**try**
  use file = File.OpenText "somefile.txt"
  file.ReadToEnd() |> printfn "%s"
**with**
| ①:? FileNotFoundException -> printfn "File not found"
| ②_ -> printfn "Error loading file"

如果抛出异常,执行将转到with块,系统首先尝试使用①,一个类型测试模式(匹配特定数据类型的模式)来查找处理程序。在这种情况下,使用通配符模式②(一个匹配所有内容的通用模式)作为一般异常处理程序。如果找不到合适的匹配,异常会在调用栈中冒泡,直到找到处理程序或应用程序失败。

不深入探讨模式匹配的细节,我们可以看一下如何利用with块的潜力。现在,FileNotFoundException的处理程序并不十分有用,因为它没有提供关于哪个文件未找到的任何信息。你可以通过在模式中使用as关键字来包含标识符,从而捕获异常并在处理程序中使用它。

try
-- *snip* --
with
| :? FileNotFoundException **as** ①ex ->
 ②printfn "% was not found" ex.FileName
| _ -> printfn "Error loading file"

现在定义了ex标识符①后,你可以在打印消息中包含文件名②。

当两个或多个异常类型应该使用相同的处理程序时,你甚至可以将这些情况结合起来。

try
-- *snip* --
with
| :? FileNotFoundException as ex ->
  printfn "%s was not found" ex.FileName
**| :? PathTooLongException**
**| :? ArgumentNullException**
**| :? ArgumentException ->**
  **printfn "Invalid filename"**
| _ -> printfn "Error loading file"

有时你可能希望在某一层次上部分处理一个异常,但仍然允许它在调用栈中向上遍历到另一个处理程序。你可以正常地使用raise函数抛出异常,但这样做会丢失嵌入在异常中的调用栈信息,后续的处理程序会将你的处理程序识别为错误的源。为了保留堆栈跟踪,重新抛出异常,可以使用一个仅在with块内有效的函数:reraise

try
-- *snip* --
with
| :? FileNotFoundException as ex ->
  printfn "%s was not found" ex.FileName
| _ ->
  printfn "Error loading file"
  **reraise()**

与 C#和 Visual Basic 不同,F#的try...with结构是一个表达式,因此它会返回一个值。到目前为止的所有示例都返回了unit。这为你如何使用该结构提供了更多可能性,但也意味着每个异常情况的返回类型必须与try块相同。

一个常见的做法是让try...with返回一个选项类型,其中try块返回Some<_>,而每个异常情况返回None。你可以遵循这个模式来返回文本文件的内容。

open System
open System.Diagnostics
open System.IO

let fileContents =
  try
    use file = File.OpenText "somefile.txt"
  ①Some <| file.ReadToEnd()
  with
  | :? FileNotFoundException as ex ->
    printfn "%s was not found" ex.FileName
  ②None
  | _ ->
    printfn "Error loading file"
    reraise()

在这个示例中,你可以看到①处创建了一个包含文本文件内容的选项并返回。在②处,FileNotFoundException处理程序返回了None

try...finally 表达式

try...finally结构用于执行必须运行的代码,不管try块中的代码是否抛出异常。

通常,try...finally用于清理try块中可能打开的任何资源,如下所示:

try
  use file = File.OpenText "somefile.txt"
  Some <| file.ReadToEnd()
finally
  printfn "cleaning up"

抛出异常

如果你只能处理库函数抛出的异常,但不能抛出自己的异常,那么异常处理机制的用处就不大了。你可以使用raise函数抛出任何类型的异常。

let filename = "x"
if not (File.Exists filename) then
  **raise** <| FileNotFoundException("filename was null or empty")

除了raise,F#还包括一些其他函数,用于抛出一些常用的异常。failwithfailwithf函数方便用于一般异常。这两个函数都会抛出Microsoft.FSharp.Core.FailureException,但failwithf函数允许你使用 F#格式字符串(详见 String Formatting),如下所示。

// failwith
if not (File.Exists filename) then
  **failwith "File not found"**

// failwithf
if not (String.IsNullOrEmpty filename) then
  **failwithf "%s could not be found" filename**

另一个常见的异常类型是System.ArgumentException,可以通过内置函数轻松抛出。为了方便抛出它,可以使用invalidArg函数。

if not (String.IsNullOrEmpty filename) then
  **invalidArg** "filename" (sprintf "%s is not a valid file name" filename)

自定义异常

通常最好使用预定义的异常类型,如ArgumentExceptionFormatException,甚至是NullReferenceException,但如果你必须定义自己的异常类型,可以定义一个扩展自System.Exception的新类。例如:

type MyException(message, category) =
  inherit exn(message)
  member x.Category = category
  override x.ToString() = sprintf "[%s] %s" category message

你可以使用raise函数抛出自定义异常,并像处理其他异常类型一样,在try...withtry...finally块中处理它。在这里你可以看到自定义的MyException异常被抛出并捕获。

try
  **raise** <| MyException("blah", "debug")
with
  | **:? MyException as ex** -> printfn "My Exception: %s" <| ex.ToString()
  | _ as ex -> printfn "General Exception: %s" <| ex.ToString()

还有一种轻量级的替代方案来创建异常类。在 F# 中,你可以使用 exception 关键字定义一个自定义的异常类型及其相关数据。以这种方式创建的异常仍然是标准的 .NET 异常,派生自 System.Exception,但其语法借鉴了一些函数式编程概念(特别是语法元组和区分联合类型)来实现其魔力。

**exception** RetryAttemptFailed of string * int
**exception** RetryCountExceeded of string

你可以像处理任何异常一样抛出这些异常。然而,处理这些异常更加简化,因为你可以使用与区分联合类型相同的模式匹配语法(更多关于模式匹配的内容请参见第七章),不仅可以确定使用哪个处理器,还能将相关数据绑定到有用的标识符。

一个通用的 retry 函数可能会抛出不同的异常类型,指示它应该继续尝试还是放弃,这取决于它已经尝试执行某个操作的次数。

let ①retry maxTries action =
  let ②rec retryInternal attempt =
    try
      if not (action()) then
        raise <| if attempt > maxTries then
                    ③RetryCountExceeded("Maximum attempts exceeded.")
                 else
                    ④RetryAttemptFailed(sprintf "Attempt %i failed." attempt, attempt)

   with
   | ⑤RetryAttemptFailed(msg, count) as ex -> Console.WriteLine(msg)
                                             retryInternal (count + 1)
   | ⑥RetryCountExceeded(msg) -> Console.WriteLine(msg)
                                reraise()
  ⑦retryInternal 1

retry 5 (fun() -> false)

在这个示例中,retry 函数 ① 接受两个参数。第一个参数表示最大尝试次数,第二个参数是一个返回布尔值的函数。所有工作都在 retryInternal ② 中执行,这是一个嵌套的 递归函数,它调用自身并调用传入的函数。如果传入的函数返回 false,则抛出 RetryCountExceeded 异常 ③ 或 RetryAttemptFailed 异常 ④。当抛出 RetryAttemptFailed 异常时,处理器 ⑤ 会将异常消息写入控制台,然后再次调用 retryInternal 函数,计数器增加。如果抛出 RetryCountExceeded 异常,处理器 ⑥ 会将异常消息写入控制台,然后重新抛出异常,以便另一个处理器进行处理。当然,这个过程必须从某个地方开始,因此我们通过初始调用 retryInternal ⑦ 来表示第一次尝试,传入的参数为 1。

这种语法简洁性确实是有代价的。尽管 RetryAttemptFailedRetryCountExceeded 是标准异常类型,但你真的希望将它们限制在你的 F# 程序集中,因为在其他语言中使用它们可能会很麻烦。相关数据被定义为语法元组,因此在编译后的代码中,单独的值没有描述性的名称;相反,这些值会被分配一些“有用的”生成名称,比如 Data0Data1。更糟糕的是,编译器无法知道哪些相关数据项应该被视为异常的 Message 属性,因此使用的是默认消息(来自基类异常)。

字符串格式化

你可能猜到了,经过验证的 Console.WriteConsole.WriteLineString.Format 方法在 F# 中是完全可以接受的。当你需要完全控制格式化时,你必须使用它们。尽管它们功能强大,但它们并没有充分利用 F# 所能提供的一切。

F# 有其独特的字符串格式化功能,可以与 printfprintfnsprintf 等函数一起使用。为什么语言设计者要在 .NET 内置机制已经如此强大的情况下,再构建一个格式化机制呢?因为 F# 的本地格式化能力比传统的机制更能与编译器紧密结合。首先,F# 格式字符串中使用的标记通常比核心方法中的格式字符串更容易记住,但这并不是主要优势。真正使 F# 格式化系统与众不同的是,它与 F# 的类型推导系统紧密集成!编译器会验证每个标记是否有匹配的值,并且每个提供的值是否与相应标记的类型匹配!

要简单地格式化一个字符串,你可以使用 sprintf 函数。例如,下面是如何快速格式化一个基本的整数值。

> **sprintf "%d" 123;;**
val it : string = "123"

当然,整数并不是唯一可以以这种方式格式化的数据类型。表 3-5 展示了常见的格式字符串标记列表。

表 3-5. 常见格式标记

Token 描述
%A 使用 F# 的默认布局设置打印任何值
%b 格式化一个布尔值为 truefalse
%c 格式化一个字符
%d, %i 格式化任何整数
%e, %E 使用科学记数法格式化一个浮动点数
%f 格式化一个浮动点数
%g, %G %e%f 的快捷方式;会自动选择更简洁的表示。
%M 格式化一个十进制值
%o 八进制
%O 通过调用其 ToString 方法打印任何值
%s 格式化一个字符串
%x 小写十六进制
%X 大写十六进制

为了确保格式化的文本至少宽度达到一定字符数,你可以在 % 后添加一个可选的宽度值。(默认格式化器不会截断你的数据,除非格式标记明确允许。)例如:

> **printfn "%5s" "ABC";;**
ABC

> **printfn "%5s" "ABCDEFGHI";;**
ABCDEFGHI

你可以将多个修饰符与标记组合,以获得更多格式化的灵活性,如 表 3-6 所示。

表 3-6. 数值格式字符串修饰符

修饰符 效果 示例 结果
0 与宽度一起使用时,用零填充额外的空格 "%010d" "0000000123"
- 将文本左对齐到可用空间内 "%-10d" "123 "
+ 如果数字是正数,则在前面加上正号 "%+d" "+123"
(空格) 如果数字是正数,则在前面加空格 "% d" " 123"

你还可以在一个标记内组合多个修饰符。例如,你可以使用标记 %+010d 来打印一个前面用零和加号(+)填充的数字。

类型缩写

类型缩写允许你为现有类型定义一个新名称,就像核心数据类型被暴露给 F#一样。你可以在 C#中使用using指令做类似的事情,但 F#的类型缩写允许你在整个库中使用该名称(当然是在定义之后),而不是仅限于单个文件内。

你可以使用type关键字、标识符和类型来定义类型缩写。如果你想将System.IO.FileStream引用为fs,你可以使用以下方式:

type fs = System.IO.FileStream

注释

当你想描述某段代码的功能时,可以使用注释。F#提供了三种注释方式:行尾注释、块注释和 XML 文档注释。

行尾注释

行尾(或单行注释以两个斜杠字符(//)开头。正如其名称所示,它们包括从注释符号到行尾的所有内容。这些注释通常单独占一行,但也可以出现在一行的末尾。

// This is an end-of-line comment
let x = 42 // Answer to the Ultimate Question of Life, The Universe, and Everything

块注释

块注释(**)为分隔符,通常用于需要跨多行的注释。

(* This is a block comment *)

(*
  So is this
*)

你也可以在一行未注释的代码中间使用块注释。

let x (* : int *) = 42

注意在块注释中包含的内容,因为编译器将其视为字符串、逐字字符串和三引号字符串。如果你不小心包含了一个引号(或三个连续的引号),编译器会认为你开始了一个字符串,如果找不到对应的闭合符号,就会产生语法错误。

(* "This is ok" *)
(* """This is not *)

XML 文档

像其他.NET 语言一样,F#也允许使用XML 文档注释,使用三斜杠(///)。这些注释在技术上只是行尾注释的一种特殊情况,编译器保留内容以构建一个 XML 文档,最终可以作为文档使用。

本书并未详细讨论 XML 文档注释,但请记住,注释对记录你的 API 非常有用。至少我建议在所有应用程序的公共和内部类型及成员上使用注释。

你的 XML 文档注释通常包括一些元素,如summaryparamreturnssummary元素简要描述被注释的代码,param元素识别并描述函数或构造函数的各个参数,returns元素描述函数的返回值。

你可能会像这样记录一个计算某个圆形度量(基于其半径)的函数:

**/// <summary>**
**/// Given a radius, calculate the diameter, area, and circumference**
**/// of a circle**
**/// </summary>**
**/// <param name="radius">The circle's radius</param>**
**/// <returns>**
**/// A triple containing the diameter, area, and circumference**
**/// </returns>**
let measureCircle radius =
    let diameter = radius * 2.0
    let area = Math.PI * (radius ** 2.0)
    let circumference = 2.0 * Math.PI * radius
    (diameter, area, circumference)

即使你不打算分发生成的 XML 文件,XML 文档注释也能通过 IntelliSense 为你提供关于文档化类型和成员的信息。在图 3-2 中,你可以看到前一个示例中的摘要,它包含在鼠标悬停在 Visual Studio 中的measureCircle函数时显示的工具提示中。

IntelliSense 中的 XML 文档图 3-2. IntelliSense 中的 XML 文档

XML 文档注释有一个快捷方式。当你只写摘要时,你可以简单地使用三个斜杠并省略标签。以下是使用快捷方式书写的前一个示例中的摘要:

/// Given a radius, calculate the diameter, area, and circumference
/// of a circle
let measureCircle radius =
-- *snip* --

如你所见,当你的注释太长而无法放在一行时,只需确保每一行都以三个斜杠开始,就可以将其分成多行书写。

摘要

在本章中,我们探讨了 F#语言的一些基本概念。你已经看到可变数据可能带来的问题,以及 F#默认的不可变性、类型推断能力和显式选择无值数据的方法如何帮助你编写更强健、更容错的代码。你还了解了 F#如何支持核心 CLI 类型和.NET 框架的其他基础功能,如枚举、泛型、异常处理和字符串格式化。

然而,F#真正脱颖而出的地方,是它在如此基础的层面上扩展了如此多的概念。像use绑定这样的构造能够在不需要额外嵌套的情况下处理对象,返回值的异常处理器,以及与编译器紧密结合的字符串格式化函数,都能对你的生产力产生立即且积极的影响。

在下一章中,我们将基于这些概念,进一步探讨 F#的面向对象能力。我们将看到这里介绍的概念如何帮助你快速开发复杂的库,同时让你专注于问题而非编译器。

第四章. 保持面向目标

多年来,面向对象(OO)开发一直是开发业务软件,特别是在企业中的事实标准,所以你可能已经熟悉了其中许多核心原则。作为一门 .NET 语言,F# 支持与其他 .NET 语言中相同的各种结构——包括类、结构体和接口——这应该不令人惊讶。尽管 F# 被认为是一种只适用于学术练习或高度专业化软件的利基语言,但其通用、多范式的特性使其适用于大多数开发场景。然而,既然 C# 和 Visual Basic 已经得到了广泛应用,为什么还要选择 F# 作为面向对象语言呢?

做出选择的一大因素是 F# 的简洁语法,但像类型推断、对象表达式以及将面向对象与函数式风格结合的能力等特性也提供了有力的理由。然而,面对现实:即使你主要采用函数式方式进行开发,当你在 .NET 框架上开发软件时,最终还是需要与对象打交道;这就是平台的特性所在。

在本章中,你将学习如何用更少的代码在 F# 中创建面向对象的结构,同时仍然能够构建强大的框架,这些框架能够与使用更专门的面向对象语言构建的类似框架相抗衡。

从概念上讲,F# 中的类与其他 OO 语言中的类相同,它们将相关的数据和行为封装为字段、属性、方法和事件(统称为成员),以模拟现实世界的对象或概念。像 C# 和 Visual Basic 中的类一样,F# 类是引用类型,支持单一继承和多重接口实现,并且可以控制对其成员的访问。与 F# 中的所有用户定义数据类型一样,类是通过 type 关键字来声明的。(编译器根据结构推断构造,而不需要为每种数据类型使用不同的关键字。)

为了说明这一点,我们再来看一下在第三章中讨论类型推断时引入的类定义。

type Person (id : Guid, name : string, age : int) =
  member x.Id = id
  member x.Name = name
  member x.Age = age

这个示例包含了大量的定义。在仅仅四行代码中,就有一个带有主构造函数、三个参数和三个隐式只读属性的类!虽然与其他 .NET 语言有很大不同,但这种简洁性正是 F# 区别于其他语言的方式之一。

构造函数

构造函数是创建和初始化新类实例的方式。它们实际上是专门的函数,返回完全初始化的类实例。F# 中的类不需要构造函数,如下所示:

type ConstructorlessClass = class end

这个示例中的空类是有效的 F#代码,但与 C#不同,如果你没有定义构造函数,编译器不会自动生成默认构造函数(无参数的构造函数)。由于一个没有成员、无法实例化的类基本没有用处,所以你的类通常会至少有一个构造函数和一个成员。

注意

你可能选择省略构造函数的一个原因是该类型的每个成员都是静态的;也就是说,它适用于类型本身,而非某个单独的实例。稍后我们将在本章中详细讨论静态成员。

与其他面向对象(OO)语言一样,你可以通过调用构造函数来创建新的类实例。在我们的Person类中只有一个构造函数,所以选择是明确的。

let me = **Person(Guid.NewGuid(), "Dave", 33)**

使用new关键字来创建新的类实例是可选的。按照约定,只有在创建实现了IDisposable接口的类实例时,你才会使用new关键字。

F#构造函数有两种类型:主构造函数和附加构造函数。

主构造函数

F#类可以拥有一个主构造函数,其参数直接嵌入在类型定义中。主构造函数的主体包含一系列letdo绑定,表示类的字段定义和初始化代码。

type Person  ①(name : string, dob : System.DateTime) =
② let age = (System.DateTime.Now - dob).TotalDays / 365.25
③ do printfn "Creating person: %s (Age: %f)" name age
  member x.Name = name
  member x.DateOfBirth = dob
  member x.Age = age

在这个示例中,主构造函数包含带有类型注解的参数列表①,一个用于计算年龄的单一字段定义②,以及一个do绑定③,在对象构造时打印出人的姓名和年龄。主构造函数的所有参数都会自动作为字段在整个类中使用,因此不需要显式地映射它们。

编译器通常可以推断每个构造函数参数的类型,因此通常无需包含显式的类型注解。在前面的示例中,dob参数仍然需要一个类型注解(或者在中间绑定上使用类型注解),这样编译器才能解析正确的减法运算符重载。然而,这种情况更多是例外而非规则,正如下一个示例所示,编译器可以推断nameage参数的类型分别为stringint

type Person (name, age) =
  do printfn "Creating person: %s (Age: %i)" name age
  member x.Name = name
  member x.Age = age

let me = Person ("Dave", 33)

默认情况下,主构造函数是公开的,但你可以通过在参数列表前添加访问修饰符来更改这一点。如果你在实现单例模式时,可能会考虑更改主构造函数的可访问性,单例模式规定类型只能有一个实例,如下所示:

type Greeter **private** () =
  static let _instance = lazy (Greeter())
  static member Instance with get() = _instance.Force()
  member x.SayHello() = printfn "hello"

Greeter.Instance.SayHello()

关于 F#中的可访问性更多内容

访问修饰符 限制了绑定、类型和成员在整个程序中的作用域。F#与 C#和 Visual Basic 不同,F#仅直接支持publicprivateinternal修饰符。在 F#中不能定义protected类成员,部分原因是protected成员会使语言的函数式特性变得复杂。尽管如此,F#仍然会遵循在其他语言中定义的protected成员,因此它们不会公开访问,并且你仍然可以在派生类中重写它们,而不会破坏抽象。

额外的构造函数

你在主构造函数之外定义的构造函数称为额外构造函数。额外的构造函数使用new关键字定义,后面跟着参数列表和构造函数体,如下所示。虽然额外的构造函数必须始终调用主构造函数,但它们可以通过另一个构造函数间接调用,从而允许你链式调用构造函数。

type Person (name, age) =
  do printfn "Creating person: %s (Age: %i)" name age
  **new (name) = Person(name, 0)**
  **new () = Person("")**
  member x.Name = name
  member x.Age = age

额外的构造函数可以包含它们自己的let绑定和其他表达式,但与主构造函数中的不同,任何此类元素都将局部于定义它们的构造函数,而不是作为字段暴露出来。

额外的构造函数可以像主构造函数一样调用额外的代码,但它们使用then关键字,而不是do绑定。在这个例子中,每个额外的构造函数都包含then关键字,以便打印一条消息,指示哪个构造函数被调用。

type Person (name, age) =
  do printfn "Creating person: %s (Age: %i)" name age
  new (name) = Person(name, 0)
               **then printfn "Creating person with default age"**
  new () = Person("")
           **then printfn "Creating person with default name and age"**
  member x.Name = name
  member x.Age = age

没有主构造函数的类在初始化时表现得稍微不同。当你使用它们时,必须显式定义字段,使用val关键字,任何额外的构造函数必须初始化没有使用DefaultValue属性修饰的字段,如下所示:

type Person =
  val _name : string
  val _age : int
  new (name, age) = { _name = name; _age = age }
  new (name) = Person(name, 0)
  new () = Person("")
  member x.Name = x._name
  member x.Age = x._age

自引用标识符

有时候你可能希望在构造函数中引用类成员。默认情况下,类成员是不可访问的,因为它们需要递归引用类型,但你可以通过as关键字和自引用标识符来启用自引用,如下所示:

type Person (name, age) **as this** =
  do printfn "Creating person: %s (Age: %i)" this.Name this.Age
  member x.Name = name
  member x.Age = age

你可以为自引用标识符选择任何名称,只要你遵循标识符的常规规则。如果你真的想激怒未来的自己或任何维护你代码的人,甚至可以使用像下面这样的带引号的标识符。

type Person (name, age) as **``This is a bad identifier``** =
  do
    printfn "Creating person: %s (Age: %i)"
      **``This is a bad identifier``.Name**
      **``This is a bad identifier``.Age**
      member x.Name = name
      member x.Age = age

通常最好坚持使用简短的名称。常见的约定是使用xthis。但无论你选择什么,记得保持一致!

警告

如果你定义了一个自引用标识符,但在构造函数中没有使用它,编译器将生成警告。原因是使用as关键字使得类定义变得递归,这会导致额外的运行时验证,从而可能对类层次结构中的类型初始化产生负面影响。只有在真正需要时,才在主构造函数中使用自引用标识符。

字段

字段定义了与对象相关联的数据元素。在前一部分中,我们简要地了解了两种创建字段的方式。在本节中,我们将更详细地讨论字段的创建。

let 绑定

创建字段的第一种方式是使用主构造函数中的let绑定。这些字段必须在主构造函数中初始化,并且总是对类是私有的。尽管它们在创建时必须初始化,但你可以像任何let绑定一样使其值可变,如下所示:

type Person () =
  **let mutable name : string = ""**
  member x.Name
    with get() = name
    and set(v) = name <- v

在这里,使用可变的let绑定来定义Name属性的后备存储。

显式字段

当你想对字段进行更多控制,或者你的类没有主构造函数时,可以使用val关键字创建显式字段。显式字段不需要立即初始化,但在具有主构造函数的类中,你需要使用DefaultValue属性对它们进行修饰,以确保它们被初始化为适当的“零”值,如下所示:

type Person () =
  **[<DefaultValue>] val mutable n : string**
  member x.Name
    with get() = x.n
    and set(v) = x.n <- v

在这个例子中,n是一个显式字段。由于n的类型是string,它被初始化为null,如你所见:

> **let p = Person()**
p.Name;;

val p : Person
val it : string = null

显式字段默认是公共的,但你可以通过在定义中包含private访问修饰符将它们设置为私有,如下所示:

type Person () =
  [<DefaultValue>] val mutable **private** n : string
  -- *snip* --

属性

和字段一样,属性表示与对象相关联的数据。不过与字段不同的是,属性提供了更多的控制,允许你通过某种组合的get和/或set函数(统称为访问器)来控制数据的访问或修改方式。

你可以隐式或显式地定义属性。一个指导原则是,当你暴露一个简单值时,优先使用隐式属性;当你需要在获取或设置属性值时使用自定义逻辑时,改用显式属性。

显式属性

显式属性是指你定义并控制后备存储(通常使用let绑定),并且自己实现getset函数体。你可以使用member关键字定义一个显式属性,后跟自引用、属性名称、类型注解(如果编译器无法推断出类型),以及函数体,如下所示:

type Person() =
  **let mutable name = ""**
  **member x.Name**
    **with get() = name**
    **and set(value) = name <- value**

在这个例子中,name字段是Name属性的私有后备存储。一旦你创建了这个Person类的实例,就可以像下面这样使用赋值运算符给Name属性赋值:

let me = Person()
me.Name <- "Dave"

你可以使用另一种语法来代替and关键字,在这种语法中,getset访问器被定义为独立的属性。

type Person() =
  let mutable name = ""
  **member x.Name with get() = name**
  **member x.Name with set(value) = name <- value**

无论你选择哪种语法,属性默认是公共的,但你可以通过在with(或and)关键字后插入访问修饰符(publicprivateinternal)来控制它们的可访问性,如下所示:

type Person() =
  let mutable name = ""
  member x.Name
    with **public** get() = name
    and **internal** set(value) = name <- value

如果你希望Name属性为只读属性,可以通过将值作为主构造函数的一个参数,并去除and set...这一行来修改类,方法如下:

type Person(name) =
  member x.Name with get() = name

当然,这是 F#,所以尽管定义只读属性已经很简单,但通过显式语法还有一种更简单的方式。

type Person(name) =
  member x.Name = name

在创建只读属性时,编译器会自动为你生成get访问器函数。

隐式属性

隐式或自动属性在 F# 3.0 版本中引入(如果你使用的是 2.0 版本,则需要使用显式属性)。它们与 C#中的自动实现属性非常相似,允许编译器生成适当的后备存储和相应的get/set访问器主体。隐式属性与显式属性非常相似,但有一些区别。

首先,隐式属性被视为类型初始化的一部分,因此必须出现在其他成员定义之前,通常与主构造函数一起定义。接下来,它们通过member val关键字对进行定义,并且必须初始化为默认值,如下所示。(它们不能包含自引用标识符。)最后,它们的访问级别只能在属性级别更改,而不能在访问器级别更改。

type Person() =
  **member val Name = "" with get, set**

如果你的隐式属性是只读的,你可以像这样省略with表达式:

type Person(name) =
  **member val Name = name**

索引属性

F# 类也可以拥有索引属性,这些属性对于定义一个类似数组的接口以处理顺序数据非常有用。索引属性的定义方式与普通属性相似,不同之处在于get访问器包含一个参数。

在创建索引属性时,命名为Item会使其成为默认索引属性,并通过点操作符和一对括号来支持便捷的语法(.[...])。例如,考虑一个接受字符串并通过默认索引器暴露每个单词的类,像这样:

type Sentence(initial : string) =
  let mutable words = initial.Split ' '
  let mutable text = initial
  **member x.Item**
    **with get i = words.[i]**
    **and set i v =**
      **words.[i] <- v**
      **text <- System.String.Join(" ", words)**

请注意,Item属性定义方式与普通属性相似,包含get,甚至是set访问器。因为这个索引器只是words数组(String.Split返回一个数组)的一个封装,它接受一个整数值并返回对应的单词。

F# 数组是基于零索引的,因此你可以像这样从一个句子中获取第二个单词:

> let s = Sentence "Don't forget to drink your Ovaltine"
**s.[1];;**

val s1 : Sentence
val it : string = "forget"

要更改第二个单词,你可以以相同的方式引用索引,并使用赋值操作符(<-)如下所示:

> **s.[1] <- "remember";;**
val it : unit = ()
> **s.[1];;**
val it : string = "remember"

此外,默认的索引属性可以是多维的。例如,你可以定义一个属性,通过包含两个参数来返回一个单词中的特定字符。

type Sentence(initial : string) =
  -- *snip* --
  member x.Item with get(w, i) = words.[w].[i]

现在,你可以轻松地像这样获取第二个单词的第一个字符:

> **s.[1, 0];;**
val it : char = 'f'

那么,如果你想定义另一个索引属性来获取原始字符串中的某个字符该怎么办?你已经定义了一个接受整数的默认索引属性,所以不能那样做。在 C#中,你必须将其创建为一个方法,但在 F#中,任何属性都可以是一个索引属性。例如:

type Sentence(initial : string) =
  -- *snip* --
  member x.Chars with get(i) = text.[i]

唯一的注意事项是,你不能像使用默认索引属性时那样使用点/括号语法;你必须将属性当作方法来访问(如实例方法所描述),并通过在属性名称后面加上括号内的索引值来访问:

> **s.Chars(0);;**
val it : char = 'D'

虽然它看起来像是一个方法调用,但如果Chars索引属性包含一个set访问器,你会像操作其他属性一样使用赋值运算符来改变底层的值。

初始化时设置

一种替代的对象初始化语法允许你在构造函数调用中直接设置各个属性的值。要使用这种对象初始化语法,你只需在正常构造函数参数之后,紧接着写出每个属性的名称和值(用等号分隔)。我们可以通过重新考虑之前的Person类示例来说明这一点。

type Person() =
  member val Name = "" with get, set

因为Person类只有一个单一的无参构造函数,所以你可以先创建一个实例,然后在第二个操作中给Name属性赋值。但要一次性完成这一切会更加简洁,像这样:

let p = Person(Name = "Dave")

使用这种语法时有一个注意点:你初始化的任何属性必须是可写的。

方法

方法是与类关联的函数,表示该类型的行为。

实例方法

定义实例方法有两种方式。第一种方式使用member关键字定义公共方法,这与定义属性的方式类似,如下面的GetArea方法所示。

open System

type Circle(diameter : float) =
  member x.Diameter = diameter
  **member x.GetArea() =**
    **let r = diameter / 2.0**
    **System.Math.PI * (r ** 2.0)**

这里,Circle类通过一个diameter值初始化,并包含一个无参的公共方法GetArea,该方法计算圆的面积。因为GetArea是实例方法,你需要创建一个Circle类的实例才能像下面这样调用它:

> **let c = Circle 5.0**
**c.GetArea();;**

val c : Circle
val it : float = 19.63495408

方法可访问性

与属性一样,你可以通过访问修饰符来控制方法的访问权限。例如,要将一个方法设置为私有,只需在方法签名中加入private关键字,如下所示的GetRadius方法:

type Circle(diameter : float) =
  member **private** x.GetRadius() = diameter / 2.0
  member x.Diameter = diameter
  member x.GetArea() = System.Math.PI * (x.GetRadius() ** 2.0)

另外,你也可以使用let绑定来定义一个私有函数,如下所示:

type Circle(diameter : float) =
  **let getRadius() = diameter / 2.0**
  member x.Diameter = diameter
  member x.GetArea() = System.Math.PI * (getRadius() ** 2.0)

命名参数

当你调用一个方法时,通常会提供一个以逗号分隔的参数列表,每个参数对应于相同位置的参数。为了提供一些额外的灵活性,F# 允许对方法和构造函数使用命名参数。通过命名参数,每个参数都通过名称显式地与特定参数关联。在某些情况下,命名参数有助于澄清你的代码,但它们也允许你按任意顺序指定参数。

以下示例包含一个计算三维空间中两点之间欧几里得距离(准确来说是 RGB 颜色)的函数。

open System
open System.Drawing

type ColorDistance() =
  member x.GetEuclideanDistance(c1 : Color, c2 : Color) =
    let getPointDistance p1 p2 = (float p1 - float p2) ** 2.0
    [ getPointDistance c1.R c2.R
      getPointDistance c1.G c2.G
      getPointDistance c1.B c2.B ] |> List.sum |> Math.Sqrt

你可以通过指定两个颜色来正常调用GetEuclideanDistance方法,或者像这样通过指定参数名称来调用:

> **let d = ColorDistance()**
**d.GetEuclideanDistance(Color.White, Color.Black);;**

val d : ColorDistance
val it : float = 441.6729559

> **d.GetEuclideanDistance(c2 = Color.White, c1 = Color.Snow);;**
val it : float = 7.071067812

你可以按任意顺序指定命名参数。你也可以将命名参数与未命名参数一起使用,但如果这样做,未命名参数必须首先出现在参数列表中。最后,由于命名参数仅适用于使用成员语法定义的方法,因此不能与通过let绑定创建的函数一起使用。

重载方法

重载方法与同一类中的一个或多个其他方法共享相同的名称,但具有不同的参数集。重载方法通常定义参数的子集,每个重载调用一个更具体的形式,并为其他参数提供默认值。

例如,如果你正在构建一个与自己喜欢的版本控制系统对接的工具,你可能会定义一个Commit方法,该方法接受一个文件列表、描述和目标分支。为了让目标分支成为可选项,你可以像这里展示的那样重载Commit函数:

open System.IO

type Repository() =
  member ① x.Commit(files, desc, branch) =
    printfn "Committed %i files (%s) to \"%s\"" (List.length files) desc branch
  member ② x.Commit(files, desc) =
    x.Commit(files, desc,  ③"default")

在这个示例中,①处的重载负责将更改提交到仓库,而②处的重载则通过在③处提供的默认值,使分支参数变为可选。

可选参数

尽管 F# 支持方法重载,但你可能不会经常使用它,因为 F# 还支持可选参数,这些参数通常更方便。如果你在参数名称前加上问号(?),编译器会将其视为可选参数。

可选参数在 F# 中与 C# 和 Visual Basic 中有所不同。在其他语言中,可选参数定义时会指定一个默认值,当省略相应参数时会使用该默认值。但在 F# 中,参数实际上会被编译为option<_>类型,并默认为None。(可选参数的值表现得像任何其他的 option 类型值,因此你仍然需要在方法中使用defaultArg或模式匹配来获取有意义的值,具体取决于情况。)

让我们改写上一节中的Repository示例,使用一个可选参数,而不是重载方法。

open System.IO

type Repository() =
  static member Commit(files, desc, ?branch) =
    let targetBranch = defaultArg branch "default"
    printfn "Committed %i files (%s) to \"%s\"" (List.length files) desc targetBranch

尽管你需要在方法中管理可选参数,但现在只需要维护一个方法,而不是多个重载版本。如你所见,可选参数可以减少由于在重载中使用不一致的默认值而产生的缺陷的可能性,并且它们简化了重构,因为只需要更改一个方法。

切片表达式

索引属性,在索引属性中介绍,非常适合处理封装序列中的单个值,但有时你可能需要处理该序列中的一系列值。传统上,你必须通过索引器手动获取每个项,或者实现IEnumerable<'T>并通过 LINQ 的SkipTake扩展方法的某种组合来获取值。切片表达式类似于索引属性,只不过它们使用范围表达式来标识应该包含在结果序列中的项。

要在你的类中使用切片表达式,你需要实现一个GetSlice方法。其实GetSlice方法并没有什么特别之处;它只是编译器在遇到切片表达式语法时会查找的方法。为了说明切片表达式,让我们回顾一下索引属性部分的Sentence类。

type Sentence(initial : string) =
  let words = initial.Split ' '
  let text = initial
  member x.GetSlice(lower, upper) =
    match defaultArg lower 0 with
    | l when l >= words.Length -> Array.empty<string>
    | l -> match defaultArg upper (words.Length - 1) with
           | u when u >= words.Length -> words.[l..]
           | u -> words.[l..u]

基本的类定义与之前相同,只不过这次我们有一个接受上下边界的GetSlice()方法。(不要纠结这里的匹配表达式;有关详细讨论,请参见第七章。现在知道它们只是在进行一些边界检查就足够了。)

你可以在代码中直接调用这个方法,但表达式形式更为方便。例如,要获取句子中的第二、第三和第四个单词,你可以这样写:

> **let s = Sentence "Don't forget to drink your Ovaltine"**
**s.[1..3];;**

val s : Sentence
val it : string [] = [|"forget"; "to"; "drink"|]

切片表达式的一个优点是,边界参数是可选的,因此你可以使用开放的范围。要指定一个没有下边界的范围,只需在切片表达式中省略第一个值(即 1),在这种情况下,它等同于[0..3]

> **s.[..3];;**
val it : string [] = [|"Don't"; "forget"; "to"; "drink"|]

类似地,你可以省略第二个参数,获取到集合的末尾的项。

> **s.[3..];;**
val it : string [] = [|"drink"; "your"; "Ovaltine"|]

与索引属性类似,切片表达式可以作用于二维,但你需要重载GetSlice方法,以接受定义上下边界对的四个参数。继续使用Sentence示例,我们可以添加一个多维切片重载,以便从一组单词中获取一系列字符,像这样:

type Sentence(initial : string) =
  -- *snip* --
  member x.GetSlice(lower1, upper1, lower2, upper2) =
    x.GetSlice(lower1, upper1)
    |> Array.map
        (fun w -> match defaultArg lower2 0 with
                  | l when l >= w.Length -> ""
                  | l -> match defaultArg upper2 (w.Length - 1) with
                         | u when u >= w.Length -> w.[l..]
                         | u -> w.[l..u])

要使用这个重载,只需在切片表达式中用逗号分隔范围对。

> **s.[1..4, ..1];;**
val it : string [] = [|"fo"; "to"; "dr"; "yo"|]

事件

最后一种成员类型是事件。事件在整个 .NET Framework 中都有应用,一些显著的例子可以在用户界面组件和 ADO.NET 中找到。与其他 .NET 语言一样,F# 的事件本质上是响应某些操作(如按钮点击或异步进程完成)时调用的一系列函数集合。

在许多方面,F# 的事件与传统的 .NET 事件具有相同的作用,但它们是完全不同的机制。然而,为了实现跨语言兼容性,它们可以与 .NET 事件系统结合使用。(稍后我们将在本节中看到,如何通过 CLIEvent 属性来利用这种能力)

基本事件处理

F# 中的事件是 Event<'T> 类的实例(位于 FSharp.Core.Control 中)。Event<'T> 类的一个主要特性是,它提供了比你可能习惯的更加明确的发布/订阅模型。在这个模型中,你可以通过调用 Add 函数向事件添加事件处理程序,从而订阅发布的事件。

例如,System.Timers.Timer 类发布了一个 Elapsed 事件,你可以订阅该事件。

  let ticks = ref 0
  let t = ① new System.Timers.Timer(500.0)
  t.Elapsed.Add ② (fun ea -> printfn "tick"; ticks := ticks.Value + 1)
③ t.Start()
  while ticks.Value < 5 do ()
  t.Dispose()

在这里,我们在①处创建一个新的 Timer 类实例。在②处,我们使用lambda 表达式(匿名函数)作为事件处理程序订阅 Elapsed 函数。定时器在③处启动后,事件处理程序每半秒打印 tick 并增加一个引用单元格的值(记住,像 lambda 表达式创建的闭包不能使用可变的 let 绑定)。当计时器的计数器达到五时,循环将终止,定时器将停止并被释放。

观察事件

F# 事件的另一个主要好处是,它们使你能够将事件视为可以智能地分区、过滤、聚合或以其他方式在触发时处理的序列。Event 模块定义了许多函数——如 addfilterpartitionpairwise——可以接受发布的事件。

为了演示这一原则,让我们来看一个 ADO.NET 中的例子。DataTable 类会在响应某些操作(如行的更改或删除)时触发多种事件。如果你想处理 RowChanged 事件,可以添加一个事件处理程序(就像前一部分一样),并包含逻辑来过滤掉你不关心的事件,或者你也可以使用 Event 模块中的 filter 函数,仅在需要时调用你的处理程序,如下所示:

  open System
  open System.Data

  let dt = new DataTable("person")
  dt.Columns.AddRange
    [| new DataColumn("person_id", typedefof<int>)
       new DataColumn("first_name", typedefof<string>)
       new DataColumn("last_name", typedefof<string>) |]
  dt.Constraints.Add("pk_person", dt.Columns.[0], true)

  let 1 h1, h2 =
2 dt.RowChanged
    |> 3 Event.partition
           4(fun ea ->
              let ln = ea.Row.["last_name"] :?> string
              ln.Equals("Pond", StringComparison.InvariantCultureIgnoreCase))

5 h1.Add (fun _ -> printfn "Come along, Pond")
6 h2.Add (fun _ -> printfn "Row changed")

我们将跳过这个例子前半部分的讨论;对我们来说,重要的是它设置了一个带有三列和主键的 DataTable。这里真正重要的是 partition 函数。

在这个例子中,我们通过在④提供一个委托(以 lambda 表达式的形式)和在②提供DataTableRowChanged事件发布的Event对象,来在③调用partition函数。然后,partition函数返回两个新事件,我们在①绑定到h1h2。最后,我们通过在⑤和⑥调用它们的Add方法来订阅这两个新事件。

现在表结构和事件处理程序已经就绪,我们可以添加一些行并查看事件是如何被触发的。

**> dt.Rows.Add(1, "Rory", "Williams") |> ignore;;**
Row changed
val it : unit = ()
**> dt.Rows.Add(2, "Amelia", "Pond") |> ignore;;**
Come along, Pond
val it : unit = ()

正如你所看到的,当第一行被添加时,姓氏不符合筛选条件,因此触发了h2。然而,第二行符合条件,因此触发了h1

如果调用分区函数的语法看起来像是反向的,那是因为它确实如此;前向管道操作符|>)将其左操作数作为右操作数指定函数的最终参数。(前向管道操作符在 F#中使用频繁,我们将在第五章中更详细地探讨它。)

自定义事件

你可以在你的类型中定义自定义事件。然而,做到这一点与其他.NET 语言有所不同,因为事件仅作为对象存在于 F#中,并且缺少关键字支持。

除了定义类型外,首先你需要做的是为你的事件对象创建一个字段(使用let绑定)。这是用来协调发布和触发事件的对象。一旦字段被定义,你可以通过自己定义的属性将事件的Publish属性暴露给外部。最后,你需要在某个地方通过调用Trigger函数来触发事件。

type Toggle() =
  **let toggleChangedEvent = Event<_>()**
  let mutable isOn = false

  member x.ToggleChanged = **toggleChangedEvent.Publish**

  member x.Toggle() =
    isOn <- not isOn
    **toggleChangedEvent.Trigger (x, isOn)**

定义了类型后,你可以创建一个新实例,并像任何内建类型一样订阅ToggleChanged事件。例如,接下来我们使用分区来创建两个新的事件处理程序,一个处理切换打开时的情况,另一个处理切换关闭时的情况。调用Event.map只是通过丢弃第一个参数(源或发送者,按.NET 约定)重新表述事件,然后再调用partition函数。

let myToggle = Toggle()
let onHandler, offHandler =
  **myToggle.ToggleChanged**
  |> **Event.map** (fun (_, isOn) -> isOn)
  |> **Event.partition** (fun isOn -> isOn)

onHandler |> **Event.add** (fun _ -> printfn "Turned on!")
offHandler |> **Event.add** (fun _ -> printfn "Turned off!")

现在,每次调用Toggle方法都会触发ToggleChanged事件,并执行两个处理程序中的一个。

**> myToggle.Toggle();;**
Turned on!
val it : unit = ()
**> myToggle.Toggle();;**
Turned off!
val it : unit = ()

正如你刚才看到的,ToggleChanged事件在 F#中是完全启用的。如果你的类只会在 F#程序集内部使用,你可以到此为止。然而,如果你需要在其他语言编写的程序集里使用它,你还需要做一件事:用CLIEvent特性装饰ToggleChanged属性。

**[<CLIEvent>]**
member x.ToggleChanged = toggleChangedEvent.Publish

CLIEvent特性指示编译器包括适当的元数据,使得该事件可以从其他.NET 语言中消费。

结构

结构(或 结构体)与类类似,都可以拥有字段、属性、方法和事件。结构体的定义方式与类相同,不同之处在于类型必须使用 Struct 特性进行修饰。

**[<Struct>]**
type Circle(diameter : float) =
  member x.getRadius() = diameter / 2.0
  member x.Diameter = diameter
  member x.GetArea() = System.Math.PI * (x.getRadius() ** 2.0)

然而,尽管类和结构体有相似之处,实际上它们在幕后是非常不同的。它们之间的主要区别在于结构体是 值类型

这个差异很重要,因为它不仅影响你如何与数据交互,还影响值类型在计算机内存中的表示方式。对于这两种类型,运行时都会在内存中分配空间来存储值。值类型总是会导致新的内存分配,并将数据复制到该空间。而对于引用类型,内存只会分配一次,通过引用来访问其位置。

当你将一个引用类型传递给函数时,运行时会在内存中创建该位置的新引用,而不是数据的副本。因此,引用类型更容易通过副作用造成破坏,因为当你将引用类型传递给函数时,对该对象所做的任何修改都会立即反映到该对象被引用的地方。相反,传递值类型给函数会创建该值的副本,因此对其所做的任何修改仅限于该实例。

结构体的初始化方式也不同于类。与类不同,编译器会为结构体生成一个默认的(无参数)构造函数,该构造函数将所有字段初始化为适当的零值(zeronull 等)。这意味着,除非是静态字段,否则你不能使用 let 绑定在结构体中创建私有实例字段或方法;相反,你必须使用 val 来定义结构体实例字段。此外,你不能定义自己的默认构造函数,因此你定义的任何附加构造函数必须至少接受一个参数。(只要不包含主构造函数,你的字段仍然可以是可变的。)

由于引用类型和值类型在内存分配方式上的不同,结构体不能包含其自身类型的字段。如果没有这个限制,结构体实例的内存需求将是无限大的,因为每个实例都会递归地要求另一个相同类型实例所需的空间。

最后,结构体可以实现接口,但不能参与继承。无论如何,结构体仍然从 System.Object 派生,因此你可以重写方法(如 ToString)。

继承

在面向对象编程中,继承 描述了两个类型之间的 身份 关系,类似于苹果 一种水果。F# 类支持 单继承,这意味着任何给定的类只能直接继承另一个类,以建立类层次结构。通过继承,基类公开的公共(有时是内部)成员会自动在派生类中可用。你可以在以下代码片段中看到这一原则的应用。

type BaseType() =
  member x.SayHello name = printfn "Hello, %s" name

type DerivedType() =
  inherit BaseType()

这里定义的DerivedType没有定义任何自己的功能,但由于它继承自BaseType,所以可以通过DerivedType访问SayHello方法。

F# 的继承要求有一个主构造函数。要指定基类,可以在主构造函数中包含inherit关键字,后跟基类型名称及其构造函数参数,然后再进行任何绑定或成员定义。例如,一个任务管理系统可能有一个WorkItem类,表示系统中的所有工作项,以及像DefectEnhancement这样的专门化类,这些类继承自WorkItem类,具体如下面加粗所示。

type WorkItem(summary : string, desc : string) =
  member val Summary = summary
  member val Description = desc

type Defect(summary, desc, severity : int) =
  **inherit WorkItem(summary, desc)**
  member val Severity = severity
type Enhancement(summary, desc, requestedBy : string) =
  **inherit WorkItem(summary, desc)**
  member val RequestedBy = requestedBy

每个 .NET 类,包括原始数据类型,最终都会参与继承。同时,当你定义一个类而没有显式指定基类时,定义的类会隐式地继承自System.Object

类型转换

在第三章中,你学习了如何在数值类型之间进行转换。类型也可以通过向上转换和向下转换运算符在其类型层次结构内进行转换。

向上转换

直到现在我一直坚持认为 F# 中没有隐式转换,但这并不完全正确。唯一的隐式向上转换(转换为继承结构中更高层次的类型)发生的情况是,当类型被传递给一个方法或一个let绑定的函数,而该方法的参数类型是灵活类型。在其他所有情况下,你必须显式地使用静态类型转换运算符(:>)来转换类型。

为了展示静态类型转换运算符的实际应用,让我们继续使用WorkItem示例,创建一个Defect并立即将其转换为WorkItem

**> let w = Defect("Incompatibility detected", "Delete", 1) :> WorkItem;;**
val w : WorkItem

静态类型转换运算符在编译时解析有效的转换。如果代码能够编译,转换就一定会成功。

向下转换

向上转换的相反操作是向下转换。向下转换用于将一个类型转换为其层次结构中更低的类型,即将基类型转换为派生类型。要执行向下转换,可以使用动态类型转换运算符(:?>)。

因为我们在前面的示例中创建的WorkItem实例仍然是一个Defect,所以我们可以使用动态类型转换运算符将其转换回WorkItem

**> let d = w :?> Defect;;**
val d : Defect

与静态类型转换运算符不同,动态类型转换运算符直到运行时才会解析,因此,如果目标类型不适用于源对象,可能会出现InvalidCastException。例如,如果你尝试将w向下转换为Enhancement,转换将失败。

**> let e = w :?> Enhancement;;**
System.InvalidCastException: Unable to cast object of type 'Defect' to type 'Enhancement'.
   at Microsoft.FSharp.Core.LanguagePrimitives.IntrinsicFunctions.UnboxGenericT
   at <StartupCode$FSI_0007>.$FSI_0007.main@()
Stopped due to error

重写成员

除了重用代码外,你还可以通过重写基类的成员来改变基类所提供的功能。

例如,System.Object上定义的ToString方法是一个很好的(但常被忽视的)调试工具,其默认实现并不特别有用,因为它仅仅返回类型名称。为了使其更有用,你的类可以重写默认功能,并返回一个真正描述对象的字符串。

为了说明这一点,考虑之前的WorkItem类。如果你调用它的ToString方法,你将看到类似下面的内容:

**> let w = WorkItem("Take out the trash", "It's overflowing!")**
**w.ToString();;**

val w : WorkItem
val it : string = "FSI_0002+WorkItem"

注意

在前面的例子中,FSI_0002+是调用 FSI 代码时的产物。你的类型名称可能会有所不同。

要覆盖默认行为并使ToString返回更有用的内容,请使用override关键字定义一个新方法。

type WorkItem(summary : string, desc : string) =
  -- *snip* --
  **override** x.ToString() = sprintf "%s" x.Summary

如果现在调用ToString,结果将是摘要文本,而不是类型名称。

**> let w = WorkItem("Take out the trash", "It's overflowing!")**
**w.ToString();;**

val w : WorkItem = Take out the trash
val it : string = "Take out the trash"

每个类型只能覆盖给定函数一次,但你可以在层次结构的多个级别进行覆盖。例如,这里展示了如何在Defect类中再次覆盖ToString,以显示缺陷的严重性:

type Defect(summary, desc, severity : int) =
  inherit WorkItem(summary, desc)
  member val Severity = severity
  **override x.ToString() = sprintf "%s (%i)" x.Summary x.Severity**

当覆盖虚拟成员(一个具有默认实现的抽象成员)时,可以通过base关键字调用基类的功能。base关键字的行为像自我标识符,只不过它代表的是基类。

继续我们的ToString重写主题,为了增强默认行为,你的重写可以像这样调用base.ToString()

type Defect(summary, desc, severity : int) =
  -- *snip* --
  override x.ToString() =
    sprintf "%s (%i)" (**base.ToString()**) x.Severity

请注意,base关键字仅在显式继承自其他类型的类中可用。要在继承自System.Object的类中使用base关键字,你需要显式地继承它,如下所示:

type WorkItem(summary : string, desc : string) =
  inherit System.Object()
  -- *snip* --
  override x.ToString() =
    sprintf "[%s] %s" (base.ToString()) x.Summary

抽象类

抽象类是不能直接实例化的类;它只能通过派生类访问。抽象类通常为一组相关类定义公共接口和可选实现,这些类以不同方式满足类似需求。抽象类在.NET 框架中被广泛使用,一个很好的例子是System.IO命名空间中的TextWriter类。

TextWriter类定义了一个写入字符到某个地方的通用机制。它不关心字符写入的位置或方式,但它协调了这一过程,具体的实现细节则交给像StreamWriterStringWriterHttpWriter这样的派生类来完成。

你可以通过用AbstractClass属性修饰类型定义来定义自己的抽象类。例如,要创建一个简单的树形结构,你可以使用如下的抽象类:

**[<AbstractClass>]**
type Node(name : string, ?content : Node list) =
  member x.Name = name
  member x.Content = content

抽象成员

定义抽象类的一个原因是为了定义抽象成员,即没有实现的成员。抽象成员仅允许出现在抽象类中(或者接口中,见接口),并且必须在派生类中实现。当你想定义一个类做什么,但不关心它是如何做的时,抽象成员非常有用。

抽象属性

当你想定义与特定类型关联的数据,但不关心这些数据是如何存储的或在访问时会发生什么时,你可以使用abstract关键字定义抽象属性

例如,这个抽象类包含一个抽象属性:

[<AbstractClass>]
type AbstractBaseClass() =
  **abstract member SomeData : string with get, set**

AbstractBaseClass 仅要求其子类实现SomeData属性,但它们可以自由实现自己的存储机制。例如,一个派生类可能使用传统的后备存储,而另一个则可能选择使用 .NET 泛型字典,如下所示:

type BindingBackedClass() =
  **inherit AbstractBaseClass()**
  let mutable someData = ""
  **override x.SomeData**
    with get() = someData
    and set(v) = someData <- v

type DictionaryBackedClass() =
  **inherit AbstractBaseClass()**
  let dict = System.Collections.Generic.Dictionary<string, string>()
[<Literal>]
let SomeDataKey = "SomeData"
**override x.SomeData**
  with get() =
    match dict.TryGetValue(SomeDataKey) with
    | true, v -> v
    | _, _ -> ""
  and set(v) =
    match System.String.IsNullOrEmpty(v) with
    | true when dict.ContainsKey(SomeDataKey) ->
        dict.Remove(SomeDataKey) |> ignore
    | _ -> dict.[SomeDataKey] <- v

如你所见,BindingBackedClassDictionaryBackedClass都继承自AbstractBaseClass,但它们以非常不同的方式实现了SomeData属性。

抽象方法

尽管你可以定义抽象属性,但你更有可能使用抽象方法。像抽象属性一样,抽象方法允许你定义派生类必须实现的能力,而无需指定任何实现细节。例如,在计算形状的面积时,你可能会定义一个抽象的Shape类,其中包含一个抽象的GetArea方法。

[<AbstractClass>]
type Shape() =
  **abstract member GetArea : unit -> float**

由于该方法没有实现,你必须显式定义整个签名。在这种情况下,GetArea方法接受unit并返回一个浮动值。

重写方法也类似于重写属性,正如你在以下CircleRectangle类中看到的那样:

open System

type Circle(r : float) =
  inherit Shape()
  member val Radius = r
  **override x.GetArea()** =
    Math.Pow(Math.PI * r, 2.0)

type Rectangle(w : float, h : float) =
  inherit Shape()
  member val Width = w
  member val Height = h
  **override x.GetArea()** = w * h

虚拟成员

与 C# 和 Visual Basic 一样,F# 也允许虚拟成员——即可以在派生类中重写的属性或方法。但与其他 .NET 语言不同,F# 对虚拟成员采取了更为字面化的方法。例如,在 C# 中,你会在非私有实例成员定义中包含virtual修饰符,而在 Visual Basic 中,你则使用Overridable修饰符来实现相同的效果。

F#中的虚拟成员与抽象成员密切相关。实际上,要创建一个虚拟成员,你首先定义一个抽象成员,然后使用default关键字提供默认实现。例如,在以下代码中,Node类是一个简单树结构的基础。它提供了两个虚拟方法,AddChildRemoveChild,用于帮助控制树结构。

open System
open System.Collections.Generic

type Node(name : string) =
  let children = List<Node>()
  member x.Children with get() = children.AsReadOnly()
  **abstract member AddChild : Node -> unit**
  **abstract member RemoveChild : Node -> unit**
  **default x.AddChild(n) = children.Add n**
  **default x.RemoveChild(n) = children.Remove n |> ignore**

通过这个定义,所有Node类的实例(包括任何派生类型)都将允许子节点。为了创建一个不允许子节点的特定Node类,你可以定义一个TerminalNode类,并重写这两个虚拟方法,以防止添加或移除子节点。

type TerminalNode(name : string) =
  inherit Node(name)
  [<Literal>]
  let notSupportedMsg = "Cannot add or remove children"
  **override x.AddChild(n)** =
    raise (NotSupportedException(notSupportedMsg))
  **override x.RemoveChild(n)** =
    raise (NotSupportedException(notSupportedMsg))

密封类

密封类是不能作为其他类基类的类。在 .NET Framework 中,最著名的密封类之一是System.String

你可以通过使用Sealed属性来创建你自己的密封类,如以下代码片段所示:

**[<Sealed>]**
type NotInheritable() = class end

如果你试图创建一个继承自NotInheritable类的其他类,编译器将会抛出类似如下的错误:

> **type InvalidClass() =**
     **inherit NotInheritable();;**

       inherit NotInheritable();;
       --^^^^^^^^^^^^^^^^^^^^^^^^

stdin(4,3): error FS0945: Cannot inherit a sealed type

静态成员

字段、属性和方法默认是实例成员。你可以通过在成员定义前加上static关键字,将它们设为静态成员,使其适用于类型而不是特定实例。

关于静态类的说明

在 C# 中,静态类 是一个隐式封闭的类,无法实例化,并且其中的所有成员都是静态的。在 F# 中,大多数情况下,当你需要类似静态类的功能时,会将其放在模块中。然而,模块有一定的局限性。例如,模块不允许你重载函数。

尽管 F# 并不像 C# 那样直接支持静态类,但你可以通过一些语法技巧来实现类似的效果。为此,需要省略主构造函数(或者如果需要静态初始化器,则将其设为私有),以确保无法创建实例,然后验证每个成员是否为静态成员(F# 编译器不会为你强制执行此检查)。为了完整性,可以使用 SealedAttribute 装饰类,确保没有任何类继承自它。

静态初始化器

静态初始化器,或称 静态构造函数,每个类只会执行一次,并确保某些代码在类首次使用之前执行。在 F# 中,可以通过一系列静态的 letdo 绑定来创建静态初始化器,就像定义主构造函数时一样。事实上,如果你的类需要静态初始化器,必须包含一个主构造函数来容纳这些静态绑定,如下所示:

type ClassWithStaticCtor() =
  **static let mutable staticField = 0**
  **static do printfn "Invoking static initializer"**
            **staticField <- 10**
  do printfn "Static Field Value: %i" staticField

静态初始化器只能访问它所包含类的静态成员。如果你尝试在静态初始化器中访问实例成员,将会收到编译错误。

静态字段

静态字段 常用于作为单一引用,以便你需要反复使用某些数据。例如,为了将某些数据与类本身关联,可以通过在 let 绑定前加上 static 关键字来定义一个静态字段,如下所示:

module Logger =
  let private log l c m = printfn "%-5s [%s] %s" l c m
  let LogInfo = log "INFO"
  let LogError = log "ERROR"

type MyService() =
  **static let logCategory = "MyService"**
  member x.DoSomething() =
    Logger.LogInfo logCategory "Doing something"
  member x.DoSomethingElse() =
    Logger.LogError logCategory "Doing something else"

当调用 DoSomethingDoSomethingElse 方法时,每个方法都会调用 Logger 模块中的一个函数,写入相同类别的日志消息,但不会重复数据。

> **let svc = MyService()**
**svc.DoSomething()**
**svc.DoSomethingElse();;**
INFO [MyService] Doing something
ERROR [MyService] Doing something else

静态属性

属性也可以是静态的。这里使用了一个只读的 静态属性 来暴露特定方法在所有类实例中被调用的次数。

type Processor() =
  static let mutable itemsProcessed = 0
  **static member ItemsProcessed = itemsProcessed**
  member x.Process() =
    **itemsProcessed <- itemsProcessed + 1**
    printfn "Processing..."

每次调用 Process 方法时,它会递增 itemsProcessed 字段并打印一条消息。要查看 Process 方法在所有实例中被调用的次数,请检查 Processor 类本身的 ItemsProcessed 属性。

> **while Processor.ItemsProcessed < 5 do (Processor()).Process();;**
Processing...
Processing...
Processing...
Processing...
Processing...
val it : unit = ()

这个示例会迭代,直到 Process 方法被调用的次数少于五次。每次迭代都会创建一个新的 Processor 类实例,并调用它的 Process 方法(这展示了静态属性与实例无关的特性)。

静态方法

和其他静态成员一样,静态方法是应用于类型而不是实例的。例如,静态方法常用于工厂模式(一种创建相似类实例的常见方法,而无需依赖特定的实现)。在一些工厂模式的变种中,静态方法返回符合特定接口的对象的新实例。为了说明这一概念,假设有一个需要处理不同图像格式的应用程序。你可能有一个抽象的ImageReader类,其他类型可以从该类派生,以处理特定的格式,如 JPEG、GIF 和 PNG。

[<AbstractClass>]
type ImageReader() =
  abstract member Dimensions : int * int with get
  abstract member Resolution : int * int with get
  abstract member Content : byte array with get

type JpgImageReader(fileName : string) =
  inherit ImageReader()
  -- *snip* --

type GifImageReader(fileName : string) =
  inherit ImageReader()
  -- *snip* --

type PngImageReader(fileName : string) =
  inherit ImageReader()
  -- *snip* --

创建这些类实例的工厂方法可能如下所示:

open System.IO

[<Sealed>]
type ImageReaderFactory private() =
  static member CreateReader(fileName) =
    let fi = FileInfo(fileName)

    match fi.Extension.ToUpper() with
    | ".JPG" -> JpgImageReader(fileName) :> ImageReader
    | ".GIF" -> GifImageReader(fileName) :> ImageReader
    | ".PNG" -> PngImageReader(fileName) :> ImageReader
    | ext -> failwith (sprintf "Unsupported extension: %s" ext)

上述代码段中的静态CreateReader方法使用 F#模式匹配,根据提供的文件名创建适当的ImageReader实现。当文件扩展名无法识别时,它会抛出一个异常,指示该格式不受支持。由于该方法是静态的,因此可以在不创建ImageReaderFactory类实例的情况下调用它,如下所示:

ImageReaderFactory.CreateReader "MyPicture.jpg"
ImageReaderFactory.CreateReader "MyPicture.gif"
ImageReaderFactory.CreateReader "MyPicture.png"
ImageReaderFactory.CreateReader "MyPicture.targa"

相互递归类型

当两个或多个类型相互依赖,无法单独使用其中一个类型而不依赖另一个时,这些类型被称为相互递归

举个例子,想象一本书和它的页面。书本可以包含一组页面,但每个页面也可能会引用回书本。请记住,F#是自上而下进行评估的,那么你会先定义哪个类型呢?书本还是页面?由于书本依赖于其页面,并且页面又引用回书本,这里就有了相互递归。这意味着你必须使用and关键字将这些类型一起定义,如下所示:

**type Book() =**
  let pages = List<Page>()
  member x.Pages with get() = pages.AsReadOnly()
  member x.AddPage(pageNumber : int, page : Page) =
    if page.Owner = Some(x) then failwith "Page is already part of a book"
    pages.Insert(pageNumber - 1, page)
**and Page(content : string) =**
  let mutable owner : Book option = None
  member x.Content = content
  member x.Owner with get() = owner
  member internal x.Owner with set(v) = owner <- v
  override x.ToString() = content

接口

在面向对象编程中,接口指定了类型必须支持的属性、方法,有时还包括事件。在某些方面,接口类似于抽象类,但存在一些重要的区别。首先,与抽象类不同,接口不能包含其成员的实现;它们的成员必须是抽象的。此外,由于接口定义了实现者必须支持的功能,因此所有接口成员默认是公开的。最后,接口不受与类相同的继承限制:一个类可以实现任意数量的接口(结构体也可以)。

实现接口

F#在接口实现方面与其.NET 语言的同行有所不同。C#和 Visual Basic 都允许隐式和显式实现。通过隐式实现,接口成员可以通过实现类直接访问,而通过显式实现,接口成员只能在将实现类型视为接口时访问。

考虑这个 C#示例,其中有两个类都实现了IDisposable接口:

// C#

class ImplicitExample : IDisposable
{
① **public void Dispose()**
  {
    Console.WriteLine("Disposing");
  }
}

class ExplicitExample : IDisposable
{
② **void IDisposable.Dispose()**
  {
    Console.WriteLine("Disposing");
  }
}

这两个类都实现了IDisposable,但ImplicitExample①是隐式实现的,而ExplicitExample②是显式实现的。这种差异会显著影响你在每个类中调用Dispose方法的方式,如下所示:

  // C#

  var ex1 = ①new ImplicitExample();
② ex1.Dispose();

  var ex2 = ③ new ExplicitExample();
④ ((IDisposable)ex2).Dispose();

在①处我们实例化了ImplicitExample,在③处我们实例化了ExplicitExample。对于这两个类,我们都调用了Dispose方法,但因为DisposeImplicitExample类中是隐式实现的,所以我们可以直接通过ex1调用它,如在②处所示。如果我们尝试对ex2采用相同的方法,编译器会报错,因为DisposeExplicitExample类中是显式实现的。相反,我们需要将ex2强制转换为IDisposable,如在④处所示,才能调用其Dispose方法。

注意

F#中的所有接口实现都是显式的。尽管 F#支持在其他语言中定义的类型上进行隐式接口实现,但你在 F#中定义的任何实现都会是显式的。

在 F#中实现接口类似于继承其他类,只是它使用了interface关键字。例如,要在某个类型中实现IDisposable,你可以这样做:

open System

type MyDisposable() =
  **interface IDisposable with**
    member x.Dispose() = printfn "Disposing"

要手动调用MyDisposable类的Dispose方法,你需要将实例强制转换为IDisposable,如下所示,使用静态强制转换运算符:

let d = new MyDisposable()
(d :> IDisposable).Dispose()

定义接口

当你定义一个没有任何构造函数并且只有抽象成员的类型时,F# 编译器会推断该类型是一个接口。例如,一个用于处理图像数据的接口可能像这样:

open System.Drawing
open System.IO

**type IImageAdapter =**
  **abstract member PixelDimensions : SizeF with get**
  **abstract member VerticalResolution : int with get**
  **abstract member HorizontalResolution : int with get**
  **abstract member GetRawData : unit -> Stream**

如你所见,IImageAdapter类型没有构造函数,且它的四个成员都是抽象的。要定义一个空接口,或者说是标记接口,你可以用interface end关键字对来结束定义:

type IMarker = **interface end**

注意

在.NET 开发中,接口名通常以大写字母 I 开头。为了保持一致性,你应该遵循这一惯例。

与类一样,接口也可以继承彼此,以定义更为专门的契约。同样,接口继承也是通过inherit关键字实现的。

让我们继续以图像处理为例。IImageAdapter接口对于处理任何图像格式都很有帮助,但某些格式包含其他格式没有的功能。为了处理这些功能,你可以定义额外的接口来表示这些功能。例如,在处理支持透明度的格式时,你可能会创建一个从IImageAdapter继承的ITransparentImageAdapter,如下所示:

type ITransparentImageAdapter =
  inherit IImageAdapter
  abstract member TransparentColor : Color with get, set

现在,任何实现了ITransparentImageAdapter的类型必须实现IImageAdapterITransparentImageAdapter中定义的所有成员。

自定义操作符

在第三章中,你看到了许多用于处理内置数据类型的预定义操作符。你可以使用操作符重载来扩展这些操作符到你的类型上。通过重载操作符,你可以让自定义类型的交互更自然一些。

F#中的操作符有两种形式:前缀和中缀。前缀操作符放在操作数之前,而中缀操作符则放在操作数之间。F#操作符还可以是一元二元的,意味着它们分别对一个或两个参数进行操作。自定义操作符定义为静态方法,只不过名称是操作符并用括号括起来。

前缀操作符

当定义前缀操作符时,你必须以波浪号(~)开头,以便与具有相同名称的中缀操作符区分开来。波浪号本身不是操作符的一部分。为了演示操作符重载,我们将定义一个表示基本 RGB 颜色的类型。请参考以下类定义:

type RgbColor(r, g, b) =
  member x.Red = r
  member x.Green = g
  member x.Blue = b
  override x.ToString() = sprintf "(%i, %i, %i)" r g b

要计算负颜色,你可以定义一个GetNegative函数,但将负号(-)作为前缀放在实例前面是不是更直观呢?

type RgbColor(r, g, b) =
  -- *snip* --
  /// Negate a color
  **static member (~-) (r : RgbColor) =**
    RgbColor(
      r.Red ^^^ 0xFF,
      r.Green ^^^ 0xFF,
      r.Blue ^^^ 0xFF
    )

定义好自定义操作符后,你现在可以创建一个颜色实例,并用如下便捷的语法来查找它的负值:

**> let yellow = RgbColor(255, 255, 0)**
**let blue = -yellow;;**

val yellow : RgbColor = (255, 255, 0)
val blue : RgbColor = (0, 0, 255)

中缀操作符

创建中缀操作符几乎就像创建前缀操作符,只是你不需要在名称前加波浪号字符。

继续使用RgbColor的示例,使用我们熟悉且自然的+-操作符来加减两种颜色会非常方便。

open System

type RgbColor(r, g, b) =
  -- *snip* --
  /// Add two colors
  **static member (+) (l : RgbColor, r : RgbColor) =**
    RgbColor(
      Math.Min(255, l.Red + r.Red),
      Math.Min(255, l.Green + r.Green),
      Math.Min(255, l.Blue + r.Blue)
    )
  /// Subtract two colors
  **static member (-) (l : RgbColor, r : RgbColor) =**
    RgbColor(
      Math.Max(0, l.Red - r.Red),
      Math.Max(0, l.Green - r.Green),
      Math.Max(0, l.Blue - r.Blue)
    )

现在,我们可以像加减数字一样加减颜色。

**> let red = RgbColor(255, 0, 0)**
**let green = RgbColor(0, 255, 0)**
**let yellow = red + green;;**

val red : RgbColor = (255, 0, 0)
val green : RgbColor = (0, 255, 0)
val yellow : RgbColor = (255, 255, 0)

**> let magenta = RgbColor(255, 0, 255)**
**let blue = magenta - red;;**

val magenta : RgbColor = (255, 0, 255)
val blue : RgbColor = (0, 0, 255)

新操作符

你不仅可以重载现有的操作符,还可以使用!%&*+-./<=>?@^|~等字符的各种组合来定义自定义操作符。创建自定义操作符可能会很复杂,因为你选择的组合决定了操作的优先级(precedence)和结合性(右到左或左到右)。此外,如果选择的操作符不直观,可能会影响代码的可读性。因此,如果你仍然想定义一个新的操作符,其定义方式与重载操作符相同。

例如,在前一节中我们重载了+操作符来加两个颜色,但如果是混合颜色呢?+操作符本来是一个不错的混合操作符选择,但由于它已经被用于加颜色,我们可以改用+=操作符来代替。

type RgbColor(r, g, b) =
  -- *snip* --
  /// Blend two colors
  **static member (+=) (l : RgbColor, r : RgbColor) =**
    RgbColor(
      (l.Red + r.Red) / 2,
      (l.Green + r.Green) / 2,
      (l.Blue + r.Blue) / 2
    )

现在,混合两种颜色就像加它们一样简单:

**> let grey = yellow += blue;;**

val grey : RgbColor = (127, 127, 127)

全局操作符

F#不仅允许你在类型上重载操作符,还可以在全局范围内定义操作符。这使你能够为你无法控制的类型创建新操作符!例如,要为标准System.Drawing.Color结构定义任何自定义操作符,你可以使用let绑定在全局层面定义一个新操作符,如下所示:

open System
open System.Drawing

**let (+) (l : Color) (r : Color) =**
    Color.FromArgb(
      255, // Alpha channel
      Math.Min(255, int <| l.R + r.R),
      Math.Min(255, int <| l.G + r.G),
      Math.Min(255, int <| l.B + r.B)
    )

警告

在定义全局操作符时要小心。你定义的任何与内置操作符冲突的操作符都会优先级更高,这意味着你可能会无意中替换核心功能。

对象表达式

作为正式继承的替代,F#提供了对象表达式,这是一种创建基于现有类或接口的临时(匿名)类型的便捷构造。对象表达式在你需要一个一次性类型,但又不想创建正式类型时很有用。(虽然这个类比并不完全完美,但你可以将对象表达式视为类型的 lambda 表达式,因为对象表达式的结果是一个实现接口或继承自基类的新类型的实例。)

例如,考虑一个简化的游戏场景,其中角色可以装备武器。你可能会看到像这样的武器接口和角色类:

type IWeapon =
  abstract Description : string with get
  abstract Power : int with get

type Character(name : string, maxHP : int) =
  member x.Name = name
  member val HP = maxHP with get, set
  member val Weapon : IWeapon option = None with get, set
  member x.Attack(o : Character) =
    let power = match x.Weapon with
                | Some(w) -> w.Power
                | None -> 1 // fists
    o.HP <- System.Math.Max(0, o.HP - power)
  override x.ToString() =
    sprintf "%s: %i/%i" name x.HP maxHP

你可以使用这些定义来创建几个角色:

let witchKing = Character("Witch-king", 100)
let frodo = Character("Frodo", 50)

按照目前的写法,如果其中一个角色攻击另一个角色,他不会造成太大伤害,因为他只有拳头。给每个角色配备武器会是个不错的主意,但我们现在只有IWeapon接口。我们可以为每个想得出的武器定义类型,但通过对象表达式编写一个函数来创建武器会更方便。

对象表达式,如以下forgeWeapon函数中的表达式,通过new关键字后跟类型名称、with关键字和所有成员定义(用大括号包裹)来定义。

let forgeWeapon desc power =
  **{ new IWeapon with**
      **member x.Description with get() = desc**
      **member x.Power with get() = power }**

有了forgeWeapon函数,我们可以为我们的角色创建一些武器。

> **let morgulBlade = forgeWeapon "Morgul-blade" 25**
**let sting = forgeWeapon "Sting" 10;;**

val morgulBlade : IWeapon
val sting : IWeapon

正如你所看到的,两次调用forgeWeapon都将返回IWeapon的新实例。它们可以像通过类型定义正式定义的那样使用,例如将它们分别分配给角色并调用Attack函数:

witchKing.Weapon <- Some(morgulBlade)
frodo.Weapon <- Some(sting)

witchKing.Attack frodo

尽管对象表达式很方便,但并不适用于每种情况。它们的一个主要缺点是,必须实现底层类型的每个抽象成员。如果底层接口或基类有很多抽象成员,对象表达式可能会变得非常繁琐,因此你可能会考虑使用其他构造。

对象表达式不限于单一基类型。要使用对象表达式实现多个基类型,可以使用类似继承的语法。例如,如果你希望通过forgeWeapon函数创建的武器也实现IDisposable,你可以使用以下代码:

let forgeWeapon desc power =
  { new IWeapon with
      member x.Description with get() = desc
      member x.Power with get() = power
    **interface System.IDisposable with**
      **member x.Dispose() = printfn "Disposing"** }

创建新武器和之前一样:

let narsil = forgeWeapon "Narsil" 25

通过对象表达式创建的包含多个基类型的对象,总是被视为new关键字后面紧接着列出的类型,除非它们被显式地转换为其他类型。例如,在forgeWeapon函数的情况下,返回的对象将是IWeapon,除非你将其转换为IDisposable

(narsil :?> System.IDisposable).Dispose()

类型扩展

当 LINQ 被加入到.NET 框架时,它为 C#和 Visual Basic 引入的一个令人兴奋的特性是扩展方法。扩展方法允许你在不依赖继承或其他设计模式(如装饰器模式)的情况下向现有类型添加新方法。F#提供了类似的功能,不过它不仅限于方法。在 F#中,你可以创建扩展方法、属性、事件,甚至静态成员!

你可以通过类型扩展类型增强来扩展 F#中的现有类型。类型扩展有两种类型:内在扩展和可选扩展。

内在扩展必须在与被扩展类型相同的命名空间或模块中定义,并且与该类型在同一个源文件中。新扩展在代码编译时成为扩展类型的一部分,并且可以通过反射查看。内在扩展非常适合在构建类型时按增量方式分组相关部分,或作为构建互递归类型定义的替代方案。

可选扩展必须在一个模块中定义。与它们在 C#和 Visual Basic 中的对应物一样,可选扩展只有在其包含的命名空间或模块被打开时才可访问,但无法通过反射看到。可选扩展最适合为你无法控制或在其他程序集定义的类型添加自定义功能。

无论是定义内在扩展还是可选扩展,语法都是相同的。你从一个新的类型定义开始。不同之处在于,你不是使用主构造函数和等号,而是使用with关键字,后跟扩展定义。例如,在这里我们通过静态方法和实例方法扩展了Color结构体(位于System.Drawing命名空间)。

module ColorExtensions =
  open System
  open System.Drawing
  open System.Text.RegularExpressions

  // Regular expression to parse the ARGB components from a hex string
① let private hexPattern =
    Regex("^#(?<color>[\dA-F]{8})$", RegexOptions.IgnoreCase ||| RegexOptions.Compiled)

  // Type extension
② type Color with
  ③ static member FromHex(hex) =
      match hexPattern.Match hex with
      | matches when matches.Success ->
        Color.FromArgb <| Convert.ToInt32(matches.Groups.["color"].Value, 16)
      | _ -> Color.Empty
  ④ member x.ToHex() = sprintf "#%02X%02X%02X%02X" x.A x.R x.G x.B

这个可选的类型扩展通过允许你从已知的十六进制颜色字符串创建新的实例或将颜色转换为十六进制颜色字符串,增强了Color结构体的可用性。类型扩展本身位于②。静态扩展方法③依赖于正则表达式(用于解析字符串的领域特定语言)①来匹配并提取十六进制值,将其转换为传递给Color构造函数的 ARGB 值。实例扩展方法④则简单地返回格式化为十六进制字符串的 ARGB 值。

跨语言考虑

尽管目标相似,F#中的扩展方法实现与.NET 框架中的其他部分有所不同。因此,在 F#中定义的可选扩展方法,在 C#或 Visual Basic 中无法作为扩展方法访问,除非你在类型定义和扩展方法中都包含Extension特性。

摘要

尽管 F# 在 .NET 框架中被视为一种小众的函数式语言,但在本章中你已经看到,F# 同时也是一门功能完备的面向对象语言。通过众多示例,你了解了 F# 简洁的语法如何帮助你开发健壮的面向对象框架,其中包括类、结构和接口。你甚至看到如何实现一些常见的设计模式,如单例模式和工厂模式。

尽管 F# 支持与其更成熟的对等语言相同的常见面向对象概念,但你也学到了它如何通过观察和类型增强,将运算符重载、事件和扩展方法等熟悉的概念扩展为更强大的功能。最后,你还了解了如何利用全新的构造,如对象表达式,通过在需要时创建临时类型来提高代码质量。

第五章:让我们来了解函数式编程

我之前多次提到过 F# 是一种函数式语言,但正如你从前面的章节中学到的,你可以在 F# 中构建丰富的应用程序,而不使用任何函数式技巧。这是否意味着 F# 并不是真正的函数式语言呢?不是的。F# 是一种通用的多范式语言,允许你以最适合任务的风格进行编程。它被认为是一种函数式优先语言,这意味着它的结构鼓励使用函数式风格。换句话说,在 F# 中开发时,你应该尽可能倾向于函数式方法,并在合适的情况下切换到其他风格。

在本章中,我们将了解函数式编程的真正含义,以及 F# 中的函数与其他语言中的函数有何不同。一旦我们建立了这一基础,我们将探索几种在函数式编程中常用的数据类型,并简要了解惰性求值。

什么是函数式编程?

函数式编程在软件开发中采用了与面向对象编程根本不同的方法。面向对象编程主要关注于管理一个不断变化的系统状态,而函数式编程则强调不可变性和确定性函数的应用。这种差异极大地改变了你构建软件的方式,因为在面向对象编程中,你主要关心的是定义类(或结构体),而在函数式编程中,你的重点是定义函数,特别强调它们的输入和输出。

F# 是一种不纯的函数式语言,其中数据默认是不可变的,尽管你仍然可以定义可变数据或在函数中引起其他副作用。不可变性是函数式编程概念的一部分,称为参照透明性,这意味着一个表达式可以被其结果替代,而不影响程序的行为。例如,如果你可以用 let sum = 15 替换 let sum = add 5 10 而不影响程序的行为,那么 add 被称为具有参照透明性。但不可变性和参照透明性只是函数式编程的两个方面,它们并不会单独让一种语言变成函数式语言。

使用函数进行编程

如果你从未进行过任何“真实”的函数式编程,F# 将永远改变你对函数的思考方式,因为它的函数在结构和行为上都与数学函数高度相似。例如,第三章介绍了 unit 类型,但我避开了讨论它在函数式编程中的重要性。与 C# 和 Visual Basic 不同,F# 不区分返回值的函数和不返回值的函数。事实上,F# 中的每个函数都接受恰好一个输入值并返回恰好一个输出值。unit 类型使这种行为成为可能。当一个函数没有特定输入(没有参数)时,它实际上接受 unit。类似地,当一个函数没有特定输出时,它返回 unit

每个 F# 函数都返回一个值,这一事实使得编译器可以对你的代码做出某些假设。一个重要的假设是函数中最后一个被评估的表达式是函数的返回值。这意味着,虽然 return 是 F# 中的一个关键字,但你不需要明确地标识返回值。

函数作为数据

任何函数式语言的一个定义性(也是可能最重要的)特征是它把函数当作任何其他数据类型来处理。虽然 .NET Framework 一直在某种程度上支持这个概念(通过委托),但直到最近,委托由于过于繁琐,几乎只在少数几个有限的场景中可行。直到引入 LINQ 和 Lambda 表达式的好处,以及内置的泛型委托类型(ActionFunc),委托才真正发挥了其全部潜力。F# 在幕后使用委托,但与 C# 和 Visual Basic 不同,它的语法通过 -> 符号抽象化了委托。-> 符号通常读作“传递到”或“返回”,它标识一个值是一个函数值,其中左侧指定的数据类型是函数的输入类型,右侧的数据类型是返回类型。例如,一个既接受又返回字符串的函数签名是 string -> string。类似地,一个没有参数并返回字符串的函数表示为 unit -> string

当你开始使用高阶函数(接受或返回其他函数的函数)时,函数签名会变得越来越复杂。高阶函数在 F#(以及函数式编程一般)中被广泛使用,因为它们允许你隔离函数的公共部分,并替换那些会变化的部分。

从某种意义上说,高阶函数对函数式编程的意义,就像接口对面向对象编程的意义一样。例如,考虑一个将转换应用于字符串并打印结果的函数。它的签名可能类似于 (string -> string) -> string -> unit。这个简单的符号大大提高了代码的可理解性,比直接处理委托要容易得多。

注意

你可以在类型注解中使用函数签名,只要你期待一个函数。和其他数据类型一样,编译器通常能够推断出函数类型。

互操作性考虑

尽管 F# 函数最终是基于委托的,但在与其他 .NET 语言编写的库一起使用时要小心,因为委托类型不能互换。F# 函数依赖于重载的 FSharpFunc 委托类型,而传统的 .NET 委托通常基于 FuncAction 类型。如果你需要将 FuncAction 委托传入 F# 程序集中,可以使用以下类来简化转换。

open System.Runtime.CompilerServices

[<Extension>]
type public FSharpFuncUtil =
  [<Extension>]
  static member ToFSharpFunc<'a, 'b> (func : System.Func<'a, 'b>) =
    fun x -> func.Invoke(x)

  [<Extension>]
  static member ToFSharpFunc<'a> (act : System.Action<'a>) =
    fun x -> act.Invoke(x)

FSharpFuncUtil 类定义了重载的 ToFSharpFunc 方法,作为传统的 .NET 扩展方法(通过在类和方法上使用 ExtensionAttribute),这样你就可以轻松地从其他语言调用它们。第一个重载处理单参数 Func 实例,而第二个重载处理单参数 Action 实例。这些扩展方法并没有覆盖所有使用场景,但它们无疑是一个不错的起点。

柯里化

F# 中的函数与你可能习惯的方式有些不同。例如,考虑在第二章中介绍的简单 add 函数。

let add a b = a + b

你可能会认为 add 接受两个参数,但 F# 函数并不是这样工作的。记住,在 F# 中,每个函数接受恰好一个输入并返回恰好一个输出。如果你在 FSI 中创建上述绑定或在 Visual Studio 中悬停在名称上,你会看到它的签名是:

val add : a:int -> b:int -> int

在这里,add 被绑定到一个接受整数(a)并返回一个函数的函数。返回的函数接受一个整数(b)并返回一个整数。理解这种自动的函数链式调用——称为柯里化——对于有效使用 F# 至关重要,因为它启用了多个影响你如何设计函数的其他特性。

为了更好地说明柯里化是如何工作的,让我们重新编写 add,使其更接近编译后的代码。

> **let add a = fun b -> (+) a b;;**

val add : a:int -> b:int -> int

这里最重要的是,这个版本和之前的版本具有完全相同的签名。然而,在这里,add 只接受一个参数(a)并返回一个由 lambda 表达式定义的独立函数。返回的函数接受第二个参数(b)并调用乘法运算符,作为另一个函数调用。

部分应用

柯里化函数解锁的一项功能是部分应用。部分应用 允许你通过提供部分参数来从现有函数创建新函数。例如,在 add 的情况下,你可以使用部分应用来创建一个新的 addTen 函数,它总是将 10 加到一个数字上。

> **let addTen = add 10;;**

val addTen : ① (int -> int)
> **addTen 10;;**
val it : int = 20

请注意在①处如何列出了 addTen 的定义和签名。尽管我们在定义中没有明确包括任何参数,但签名仍然是一个接受并返回整数的函数。编译器根据提供的参数(在这种情况下仅为 10)尽可能地计算柯里化的 add 函数,并将结果函数绑定到 addTen 这个名称上。

柯里化一次应用一个参数,从左到右,因此部分应用的参数必须对应于函数的第一个参数。

警告

一旦你熟悉了柯里化和部分应用,你可能会开始考虑是否可以通过返回 Func 或 Action 实例在 C# 或 Visual Basic 中模拟它们。不要这么做。这两种语言并不支持这种类型的函数式编程,因此模拟这些概念充其量是笨拙的,最糟糕的情况下极容易出错。

管道化

与柯里化(currying)常常关联的另一个特性(并在 F# 中广泛使用)是管道化(pipelining)。管道化允许你通过计算一个表达式并将结果作为最终参数传递给另一个函数,来创建自己的函数链。

前向管道化

通常,你会使用前向管道化操作符|>)将值传递给下一个函数。如果你不想对函数返回的结果做任何处理(当它返回的结果不是 unit 时),你可以像这样将结果传递给 ignore 函数:

add 2 3 |> ignore

管道化不仅限于像忽略结果这样的简单场景。只要接收函数的最后一个参数与源函数的返回类型兼容,你就可以创建复杂的函数链。例如,假设你有一个包含每日温度(以华氏度为单位)的列表,并且你想计算平均温度、将其转换为摄氏度并打印结果。你可以采用传统的过程式方式为每个步骤定义绑定,或者你可以使用管道化将这些步骤链在一起,如下所示:

let fahrenheitToCelsius degreesF = (degreesF - 32.0) * (5.0 / 9.0)

let marchHighTemps = [ 33.0; 30.0; 33.0; 38.0; 36.0; 31.0; 35.0;
                       42.0; 53.0; 65.0; 59.0; 42.0; 31.0; 41.0;
                       49.0; 45.0; 37.0; 42.0; 40.0; 32.0; 33.0;
                       42.0; 48.0; 36.0; 34.0; 38.0; 41.0; 46.0;
                       54.0; 57.0; 59.0 ]
**marchHighTemps**
**|> List.average**
**|> fahrenheitToCelsius**
**|> printfn "March Average (C): %f"**

这里,marchHighTemps 列表被传递到 List 模块的 average 函数。接着,average 函数被计算,其结果传递给 fahrenheitToCelsius 函数。最后,摄氏温度的平均值被传递给 printfn

反向管道化

与前向管道化操作符类似,反向管道化操作符<|)将表达式的结果作为最终参数传递给另一个函数,但它是从右到左进行的。由于它改变了表达式中的优先级,反向管道化操作符有时可以替代括号使用。

反向管道化操作符可能会改变你代码的语义。例如,在上一节中的 fahrenheitToCelsius 示例中,重点放在温度列表上,因为它是首先列出的。若要改变语义以强调输出,你可以将 printfn 函数调用放在反向管道化操作符之前。

printfn "March Average (F): %f" <| List.average marchHighTemps

非柯里化函数

尽管流水线通常与柯里化函数相关联,但它也适用于仅接受单一参数的非柯里化函数(如方法)。例如,为了强制延迟执行,你可以将一个值传递给 TimeSpan 类的静态 FromSeconds 方法,然后将生成的 TimeSpan 对象传递给 Thread.Sleep,如下所示。

5.0
|> System.TimeSpan.FromSeconds
|> System.Threading.Thread.Sleep

因为 TimeSpan 类和 Thread 类都没有在 F# 中定义,所以这些函数并未被柯里化,但你可以看到如何使用前向流水线运算符将这些函数链在一起。

函数组合

与流水线类似,函数组合允许你创建函数链。它有两种形式:前向(>>)和后向(<<)。

函数组合遵循与流水线相同的输入输出规则。函数组合的不同之处在于,组合运算符不仅定义一次性操作,而是生成新的函数。继续使用我们的平均温度示例,你可以使用前向组合运算符轻松地将 List.averagefahrenheitToCelsius 函数组合成一个新函数。

> **let averageInCelsius = List.average >> fahrenheitToCelsius;;**

val averageInCelsius : (float list -> float)

组合运算符会生成一个新的函数,该函数接受一个浮点数列表并返回一个浮点数。现在,你不再需要独立调用这两个函数,而是可以直接调用 averageInCelsius

printfn "March average (C): %f" <| **averageInCelsius marchHighTemps**

与流水线一样,你可以将非柯里化函数组合起来。例如,你也可以将强制延迟示例从非柯里化函数中组合起来。

> **let delay = System.TimeSpan.FromSeconds >> System.Threading.Thread.Sleep;;**

val delay : (float -> unit)

正如你所期望的那样,你现在可以调用 delay 函数来暂时暂停执行。

> **delay 5.0;;**
val it : unit = ()

递归函数

通常与命令式代码相关联的循环构造有三种:while 循环、简单的 for 循环和可枚举的 for 循环。因为每种循环都依赖于状态变化来确定退出条件,所以在编写纯函数式代码时,你需要采取不同的循环方式。在函数式编程中,首选的循环机制是递归递归函数是指直接或间接通过另一个函数调用自身的函数。

尽管类型内的方法是隐式递归的,但通过 let 绑定的函数(如模块内定义的函数)并不是递归的。要使一个 let 绑定的函数递归,你必须在其定义中包含 rec 关键字,正如这个阶乘函数所示。

let **rec** factorial v =
  match v with | 1L -> 1L
               | _ -> v * **factorial (v - 1L)**

rec 关键字指示编译器在函数内使函数名可用,但不会改变函数的签名(int64 -> int64)。

尾递归

前面的阶乘示例很简单,但它存在一个重大缺陷。例如,考虑调用factorial 5时会发生什么。在每次递归迭代中(当值不为 1 时),该函数都会计算vv - 1的阶乘的乘积。换句话说,为给定值计算阶乘本质上需要每个后续的阶乘调用都完成。运行时,它看起来大致如下:

5L * (factorial 4L)
5L * (4L * (factorial 3L))
5L * (4L * (3L * (factorial 2L)))
-- *snip* --

上面的代码片段显示了每个调用都会被加入到栈中。对于阶乘函数来说,这不太可能成为问题,因为计算很快就会溢出数据类型,但更复杂的递归场景可能会导致栈空间耗尽。为了解决这个问题,可以通过删除对后续迭代的依赖,将函数修改为使用尾递归,如下所示:

① let factorial v =
  let ② rec fact c p =
    match c with | 0L -> p
                 | _ ->  ③ fact <| c - 1L <| c * p
  ④ fact v 1L

修改后的阶乘函数①创建并调用了一个嵌套的递归函数fact②,来隔离实现细节。fact函数接收当前迭代值(c)和前一次迭代计算的积(p)。在③(非零情况下),fact函数进行递归调用。(注意,递归调用的参数只有在此处计算。)最后,为了启动递归,factorial函数④调用第一个fact迭代,传入提供的值和1L

尽管递归调用仍然存在于代码中,但当 F#编译器检测到没有迭代依赖于后续迭代时,它会通过将递归替换为命令式循环来优化编译后的形式。这允许系统根据需要进行迭代。你可以通过插入断点并查看调用栈窗口(如果你以控制台应用程序运行此代码)或打印出从System.Diagnostics.StackTrace返回的栈信息来观察这种优化,如下所示。(请注意,你的命名空间可能会有所不同。)

**Standard recursion**
   at FSI_0024.printTrace()
   at FSI_0028.factorial(Int64 v)
   at FSI_0028.factorial(Int64 v)
   at FSI_0028.factorial(Int64 v)
   at FSI_0028.factorial(Int64 v)
   at FSI_0028.factorial(Int64 v)
   at <StartupCode$FSI_0029>.$FSI_0029.main@()
   -- *snip* --

**Tail recursion**
   at FSI_0024.printTrace()
   at FSI_0030.fact@75-8(Int64 c, Int64 p)
   at <StartupCode$FSI_0031>.$FSI_0031.main@()
   -- *snip* --

互递归函数

当两个或更多的函数互相递归调用时,它们被称为互递归。像互递归类型(在第四章中描述的那样)一样,互递归函数必须一起定义,并使用and关键字。例如,斐波那契数的计算可以通过互递归轻松表达。

let fibonacci n =
  **let rec f = function**
              **| 1 -> 1**
              **| n -> g (n - 1)**
  **and g = function**
          **| 1 -> 0**
          **| n -> g (n - 1) + f (n - 1)**
  f n + g n

上面的fibonacci函数定义了两个互递归函数,fg。(每个内部的function关键字是模式匹配的快捷方式。)对于所有值不为 1 的情况,f调用g。类似地,g递归地调用自己和f

由于互递归被隐藏在fibonacci内部,代码的使用者可以直接调用fibonacci。例如,要计算斐波那契数列中的第六个数字,可以这样写:

> **fibonacci 6;;**
val it : int = 8

互递归可能很有用,但这个例子实际上仅适用于说明概念。出于性能考虑,一个更现实的 Fibonacci 示例可能会放弃互递归,改为使用一种叫做 备忘录化 的技术,其中昂贵的计算只进行一次,结果会被缓存,以避免多次计算相同的值。

Lambda 表达式

如果你曾经使用过 LINQ 或做过其他函数式编程,你可能已经熟悉 lambda 表达式(有时也叫 函数表达式)。lambda 表达式在函数式编程中被广泛使用。简而言之,它们提供了一种方便的方式来定义简单的、单次使用的匿名(无名)函数。当函数仅在其上下文中有意义时(例如,在过滤集合时),lambda 表达式通常比 let 绑定的函数更受欢迎。

Lambda 表达式的语法类似于函数值,只不过它以 fun 关键字开头,省略了函数标识符,并且使用箭头符号(->)代替等号。例如,你可以将华氏度到摄氏度的转换函数作为 lambda 表达式内联表示,并立即像这样求值:

**(fun degreesF -> (degreesF - 32.0) * (5.0 / 9.0))** 212.0

尽管像这样定义临时函数确实是 lambda 表达式的一种用途,但它们更常见的是与高阶函数的调用一起内联创建,或者被包含在管道链中。

闭包

闭包使得函数能够访问在其定义的作用域内可见的值,无论该值是否是函数的一部分。尽管闭包通常与 lambda 表达式相关联,但使用 let 绑定创建的嵌套函数也可以是闭包,因为它们最终都会编译为 FSharpFunc 或正式的方法。闭包通常用于隔离某些状态。例如,考虑经典的闭包示例——一个返回能够操作内部计数器值的函数,如下所示:

let createCounter() =
  let count = ref 0
  (fun () -> count := !count + 1
             !count)

createCounter 函数定义了一个由返回的函数捕获的引用单元。因为引用单元在返回函数创建时处于作用域内,所以该函数无论何时被调用,都可以访问它。这使得你可以在没有正式类型定义的情况下模拟一个有状态的对象。

要观察函数修改引用单元值的过程,我们只需要调用生成的函数,并像这样调用它:

let increment = createCounter()
for i in [1..10] do printfn "%i" (increment())

函数类型

F# 原生支持几种额外的数据类型。这些类型——元组、记录和区分联合类型——通常与函数式编程相关联,但它们在混合范式开发中也非常有用。虽然这些类型各有特定的用途,但它们的共同目标是帮助你始终关注你的软件正在解决的问题。

元组

最基本的函数式类型是 元组。元组是将多个值组合成一个单一不可变结构的便捷方式,而无需创建自定义类型。元组通常表示为以逗号分隔的列表,有时会被括在括号中。例如,下面两个表示几何点的元组定义都是有效的。

> **let point1 = 10.0, 10.0;;**

val point1 : float * float = (10.0, 10.0)

> **let point2 = (20.0, 20.0);;**

val point2 : float * float = (20.0, 20.0)

元组类型的签名包括每个值的类型,类型之间用星号(*)分隔。星号作为元组元素的分隔符是出于数学原因:元组表示它们元素所包含的所有值的笛卡尔积。因此,要在类型注解中表达元组,你应该将其写成一个以星号分隔的类型列表,如下所示:

let point : **float * float** = 0.0, 0.0

尽管在语法上有一些相似之处,尤其是当值被括号括起来时,但重要的是要认识到,除了包含多个值这一点之外,元组并不是集合;它们只是将固定数量的值组合在一个单一的结构中。元组类型并未实现 IEnumerable<'T>,因此不能在可枚举的 for 循环中进行枚举或迭代,并且单个元组值只能通过像 Item1Item2 这样的非特定名称的属性来访问。

.NET 中的元组

元组一直是 F# 的一部分,但直到 .NET 4 才被引入到更大的 .NET Framework 中。在 .NET 4 之前,元组类位于 FSharp.Core 库中,但它们现在已被移到 mscorlib。这个差异只有在你打算编写针对早期版本 .NET Framework 的跨语言代码时才重要,因为它会影响你引用的程序集。

提取值

元组常用于从函数返回多个值,或者在不进行柯里化的情况下将多个值传递给函数。例如,计算一条直线的斜率时,你可以将两个点作为元组传递给 slope 函数。为了使函数工作,你需要某种方式来访问单独的值。(幸运的是,元组值总是按定义的顺序可以访问,因此减少了很多猜测的工作。)

在处理 对偶(包含两个值的元组,例如我们之前讨论的几何点)时,你可以使用 fstsnd 函数分别获取第一个和第二个值,如此处所示。

let slope p1 p2 =
  let x1 = **fst** p1
  let y1 = **snd** p1
  let x2 = **fst** p2
  let y2 = **snd** p2
  (y1 - y2) / (x1 - x2)

slope (13.0, 8.0) (1.0, 2.0)

注意我们如何使用 fstsnd 函数定义不同坐标的绑定。不过,如你所见,以这种方式提取每个值可能会变得相当繁琐,而且这些函数仅适用于对偶(包含两个值的元组);如果你尝试在 三元组(包含三个值的元组)上使用它们,你会遇到类型不匹配的问题。(原因在于,元组在底层会编译为 Tuple 类的九种通用重载之一。)除了共享相同的名称外,元组类相互独立且通常不兼容。

更实际的提取元组值的方法是引入元组模式。元组模式允许你通过用逗号分隔标识符来为元组中的每个值指定一个标识符。例如,这里是修改后的 slope 函数,使用元组模式而不是对偶函数。

let slope p1 p2 =
  let **x1, y1** = p1
  let **x2, y2** = p2
  (y1 - y2) / (x1 - x2)

你可以看到元组模式如何提供帮助,但你需要小心使用它们。如果你的模式与元组中的值数量不匹配,你将得到类型不匹配的错误。

幸运的是,不像对偶函数那样,解决这个问题仅仅是添加或删除标识符的问题。如果你不关心元组模式中的某个特定值,可以使用通配符模式(_)忽略它。例如,如果你有三维坐标,但只关心 z 坐标,你可以按如下方式忽略 x 和 y 值:

> let **_, _, z** = (10.0, 10.0, 10.0);;

val z : int = 10

元组模式不限于 let 绑定。实际上,我们可以进一步修改 slope 函数,并直接在函数签名中包含模式!

let slope **(x1, y1) (x2, y2)** = (y1 - y2) / (x1 - x2)

相等性语义

尽管它们在形式上是引用类型,但每种内置的元组类型都实现了 IStructuralEquatable 接口。这确保了所有的相等性比较都涉及比较每个组件的值,而不是检查两个元组实例是否引用了内存中相同的 Tuple 对象。换句话说,当两个元组实例中对应组件的值相同时,它们被认为是相等的,如下所示:

> **(1, 2) = (1, 2);;**
val it : bool = true
> **(2, 1) = (1, 2);;**
val it : bool = false

由于 fstsnd 函数仅适用于对偶,比较不同长度的元组将会导致错误。

语法元组

到目前为止,我们查看的所有元组都是具体的元组,但 F# 还包括了语法元组。在大多数情况下,语法元组是 F# 处理其他语言中非柯里化函数的方式。因为 F# 函数总是接受一个参数,而 C# 和 Visual Basic 中的函数可以接受多个参数,为了调用其他语言编写的库中的函数,你可以使用语法元组,让编译器处理细节。

例如,String 类的 Format 方法同时接受一个格式字符串和一个 params 参数数组。如果 String.Format 是一个柯里化函数,你会期望它的签名类似于 Format : format:string -> params args : obj [] -> string,但实际并非如此。相反,如果你将鼠标悬停在 Visual Studio 中的函数名上,你会看到它的签名实际上是 Format(format:string, params args : obj []) : string。这种区别非常重要,因为它意味着参数必须作为一个整体传递,而不是像柯里化函数那样逐个传递。如果你尝试以柯里化 F# 函数的方式调用这个方法,你将得到类似如下的错误:

> **System.String.Format "hello {0}" "Dave";;**

  System.String.Format "hello {0}" "Dave";;
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

stdin(3,1): error FS0003: This value is not a function and cannot be applied

在 F# 中调用 String.Format 的正确方式是使用语法元组,如下所示:

> **System.String.Format ("hello {0}", "Dave");;**
val it : string = "hello Dave"

你可能已经注意到,F# 通常在调用函数时不需要在参数周围加括号;它主要使用括号来确定优先级。由于函数是从左到右应用的,你主要会在函数调用中使用括号来将另一个函数的结果作为参数传递。在这种情况下,参数周围的括号是必要的。没有它们,左到右的求值将导致编译器基本上将表达式当作 ((System.String.Format "hello {0}"), "Dave") 处理。一般来说,最好在语法元组周围加上括号,以消除任何歧义。

out 参数

F# 并不直接支持 out 参数——即通过引用传递并在方法体内赋值,以便返回给调用者的参数。为了完全支持 .NET Framework,F# 需要一种方法来访问 out 参数值。例如,多个数值数据类型类中的 TryParse 方法尝试将字符串转换为相应的数值类型,并返回一个表示成功或失败的布尔值。如果转换成功,TryParse 方法将 out 参数设置为适当的转换值。例如,调用 System.Int32.TryParse 并传入 "10" 将返回 true 并将 out 参数设置为 10。类似地,调用相同的函数并传入 "abc" 将返回 false 并保持 out 参数不变。

在 C# 中,调用 System.Int32.TryParse 看起来是这样的:

  // C#
① int v;
  var r = System.Int32.TryParse("10", **out v**);

在函数式语言中,out 参数的问题在于它们需要副作用,正如①处的未初始化变量所示。为了绕过这个问题,F# 编译器将返回值和 out 参数转换为一对。因此,当你在 F# 中调用带有 out 参数的方法时,你就像调用任何返回元组的函数一样处理它。

在 F# 中调用相同的 Int32.TryParse 方法看起来是这样的:

// F#
let r, v = System.Int32.TryParse "10"

要查看生成类的幕后细节,我们可以再次使用 ILSpy 来查看它在 C# 中的表示方式。

// C#
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace <StartupCode$Samples>
{
  internal static class $Samples
  {
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    internal static readonly Tuple<bool, int> patternInput@3;
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    internal static readonly int v@3;
    [DebuggerBrowsable(DebuggerBrowsableState.Never)]
    internal static readonly bool r@3;
    [DebuggerBrowsable(DebuggerBrowsableState.Never), DebuggerNonUserCode, CompilerGenerated]
    internal static int init@;
  ① static $Samples()
    {
      int item = 0;
      $Samples.patternInput@3 = ② new Tuple<bool, int>(③ int.TryParse("10", out item), item);
      ④ $Samples.v@3 = Samples.patternInput@3.Item2;
      ⑤ $Samples.r@3 = Samples.patternInput@3.Item1;
    }
  }
}

在这里,F# 编译器将 Int32.TryParse 调用封装在一个静态类中。生成的类的静态构造函数 ① 在 ③ 处调用 TryParse 并将结果封装在元组中 ②。然后,内部的 v@3r@3 字段分别在 ④ 和 ⑤ 处被赋值为 out 参数值和返回值。反过来,通过 let 绑定定义的 vr 值被编译成只读属性,这些属性返回 v@3r@3 的值。

记录类型

像元组一样,记录类型允许你将值组合成一个不可变的结构。你可以把它们看作是在元组和自定义类之间架起的功能性桥梁。记录类型提供了与元组相同的许多便利性,如简单的语法和值相等语义,同时还允许你对其内部结构进行一定控制,并能够添加自定义功能。

定义记录类型

记录类型定义由type关键字、标识符以及所有用大括号括起来的标签与类型注释列表组成。例如,以下列出的是一个表示 RGB 颜色的简单记录类型。

> **type rgbColor = { R : byte; G : byte; B : byte };;**

type rgbColor =
  {R: byte;
   G: byte;
   B: byte;}

如果你查看编译器从这个定义生成的内容,你会看到一个封闭的类,带有只读属性、相等语义以及一个用于初始化所有值的构造函数。

注意

在定义单行记录类型时,必须用分号分隔每个标签和类型注释对。如果将每对标签和类型注释放在单独的一行,则可以安全地省略分号。

创建记录

新的记录是通过记录表达式创建的。记录表达式允许您为记录类型中的每个标签指定一个值。例如,您可以使用记录表达式创建一个新的rgbColor实例,如下所示。(请注意,与定义记录类型时一样,您必须用分号分隔每个标签或赋值对,或者将其放在单独的一行。)

> **let red = { R = 255uy; G = 0uy; B = 0uy };;**

val red : rgbColor = {R = 255uy;
                      G = 0uy;
                      B = 0uy;}

请注意,在记录表达式中,我们没有包含对rgbColor类型的显式引用。这是 F#类型推断引擎工作原理的另一个例子。仅凭标签,编译器就能够推断出我们正在创建一个rgbColor实例。因为编译器依赖标签而非位置来确定正确的类型,所以顺序不重要。这意味着您可以以任意顺序放置标签和值对。在这里,我们以GBR的顺序创建了一个rgbColor实例。

> **let red = { G = 0uy; B = 0uy; R = 255uy };;**

val red : rgbColor = {R = 255uy;
                      G = 0uy;
                      B = 0uy;}

与元组不同,我们不需要像fstsnd这样的特殊值提取函数来处理记录类型,因为每个值可以通过它的标签来访问。例如,一个将rgbColor值转换为其十六进制字符串等效值的函数可能是这样的:

let rgbColorToHex (c : rgbColor) =
  sprintf "#%02X%02X%02X" **c.R c.G c.B**

避免命名冲突

编译器通常能够推断出正确的类型,但也可能定义两个具有相同结构的记录类型。考虑一下当你添加一个与rgbColor结构相同的color类型时会发生什么。

> **type rgbColor = { R : byte; G : byte; B : byte }**
**type color = { R : byte; G : byte; B : byte };;**

type rgbColor =
  {R: byte;
   G: byte;
   B: byte;}
type color =
  {R: byte;
   G: byte;
   B: byte;}

> **let red = { R = 255uy; G = 0uy; B = 0uy };;**

val red : ① color = {R = 255uy;
                     G = 0uy;
                     B = 0uy;}

尽管有两个结构相同的记录类型,类型推断仍然成功,但请注意,在①处,生成的类型是color。由于 F#的自顶向下评估,编译器使用最最近定义的与标签匹配的类型。如果您的目标是将red定义为color,那是没问题的,但如果您希望使用rgbColor,则必须在记录表达式中稍微明确一些,并包括类型名,如下所示:

> **let red = {** ① **rgbColor.R = 255uy; G = 0uy; B = 0uy };;**

val red : ② rgbColor = {R = 255uy;
                        G = 0uy;
                        B = 0uy;}

通过在①处使用类型名限定其中一个名称,您可以绕过类型推断,正确的类型会在②处被解析。(尽管技术上您可以在任何名称上限定类型,但惯例是只在第一个名称或所有名称上限定类型。)

复制记录

你不仅可以使用记录表达式从头创建新的记录实例,还可以通过将值向前复制并为一个或多个属性设置新值来从现有实例创建新的记录实例。另一种语法,称为复制和更新记录表达式,使得从红色创建黄色变得简单,如下所示:

> let red = { R = 255uy; G = 0uy; B = 0uy }
let yellow = **{ red with G = 255uy };;**

val red : color = {R = 255uy;
                   G = 0uy;
                   B = 0uy;}
val yellow : color = {R = 255uy;
                      G = 255uy;
                      B = 0uy;}

要为多个属性指定新值,使用分号分隔它们。

可变性

像 F#中的几乎所有其他内容一样,记录类型默认是不可变的。然而,由于其语法非常便捷,它们通常被用来代替类。然而,在许多情况下,这些场景要求可变性。为了在 F#中使记录类型的属性可变,可以像使用let绑定一样使用mutable关键字。例如,你可以像这样使rgbColor的所有成员变为可变:

> **type rgbColor = { mutable R : byte**
                  **mutable G : byte**
                  **mutable B : byte };;**

type rgbColor =
  {mutable R: byte;
   mutable G: byte;
   mutable B: byte;}

当记录类型的属性是可变的时,你可以像这样使用标准赋值运算符(<-)来改变其值:

let myColor = { R = 255uy; G = 255uy; B = 255uy }
myColor.G <- 100uy

Climutable

虽然记录类型默认支持二进制序列化,但其他形式的序列化需要默认构造函数和可写属性。为了在更多情况下使用记录类型代替类,F#团队在 F# 3.0 中引入了CLIMutable属性。

使用此属性装饰记录类型会指示编译器包含一个默认构造函数,并使生成的属性可读/可写,但编译器不会在 F#中暴露这些功能。即使生成的属性是可写的,除非它们在记录类型定义中明确标记为mutable,否则它们的值无法在 F#代码中更改。因此,在跨语言边界使用CLIMutable记录类型时,要小心以确保不会无意中改变某些内容。

附加成员

因为记录类型实际上只是类的语法糖,所以你可以像在类中一样定义附加成员。例如,你可以像这样增加一个方法,使rgbColor返回其十六进制字符串等价物:

type rgbColor = { R : byte; G : byte; B : byte }
                **member x.ToHexString() =**
                  **sprintf "#%02X%02X%02X" x.R x.G x.B**

现在,你可以在任何rgbColor实例上调用ToHexString方法。

> **red.ToHexString();;**
val it : string = "#FF0000"

记录类型上的附加成员也可以是静态的。例如,假设你想将几个常见颜色暴露为记录类型上的静态属性。你可以这样做:

type rgbColor = { R : byte; G : byte; B : byte }
                -- *snip* --
                **static member Red = { R = 255uy; G = 0uy; B = 0uy }**
                **static member Green = { R = 0uy; G = 255uy; B = 0uy }**
                **static member Blue = { R = 0uy; G = 0uy; B = 255uy }**

静态的RedGreenBlue属性像其他任何静态成员一样,可以在需要rgbColor实例的地方使用。

> **rgbColor.Red.ToHexString();;**
val it : string = "#FF0000"

你还可以为记录类型创建自定义运算符作为静态成员。我们来实现一个加法运算符来添加两个rgbColor实例。

open System
type rgbColor = { R : byte; G : byte; B : byte }
                -- *snip* --
                **static member (+) (l : rgbColor, r : rgbColor) =**
                  **{ R = Math.Min(255uy, l.R + r.R)**
                   **G = Math.Min(255uy, l.G + r.G)**
                   **B = Math.Min(255uy, l.B + r.B) }**

rgbColor的运算符重载像任何其他运算符一样被定义和调用:

> **let yellow = { R = 255uy; G = 0uy; B = 0uy } +**
             **{ R = 0uy; G = 255uy; B = 0uy };;**

val yellow : rgbColor = {R = 255uy;
                         G = 255uy;
                         B = 0uy;}

区别联合

区别联合是用户定义的数据类型,其值被限制为一组已知的值,称为联合案例。其他流行的.NET 语言中没有等价的结构。

刚开始时,你可能会因为语法非常相似而把一些简单的区分联合类型误认为枚举类型,但它们实际上是完全不同的构造。首先,枚举类型仅仅是为已知的整数值定义标签,但它们并不局限于这些值。相比之下,区分联合类型的唯一有效值是它们的联合案例。此外,每个联合案例可以独立存在,或者包含关联的不可变数据。

内置的Option<'T>类型突出了这些要点。我们这里只对它的定义感兴趣,因此我们来看一下它的定义。

type Option<'T> =
| None
| Some of 'T

Option<'T>定义了两个案例,NoneSomeNone是一个空的联合案例,意味着它不包含任何关联的数据。另一方面,Some有一个与之关联的'T实例,如of关键字所示。

为了演示区分联合类型如何强制执行一组特定的值,让我们定义一个简单的函数,接受一个泛型选项,当选项是Some时输出关联的值,或者当选项是None时输出"None"

let showValue (v : _ option) =
  printfn "%s" (match v with
                | Some x -> x.ToString()
                | None -> "None")

当我们调用这个函数时,我们只需要提供其中一个选项案例:

> **Some 123 |> showValue;;**
123
val it : unit = ()
> **Some "abc" |> showValue;;**
abc
val it : unit = ()
> **None |> showValue;;**
None
val it : unit = ()

注意,在对showValue的三次调用中,我们只指定了联合案例的名称。编译器将SomeNone都解析为Option<'T>。 (如果发生命名冲突,你可以像使用记录类型时一样,使用区分联合类型名称来限定案例名称。) 然而,如果你用除SomeNone之外的值来调用showValue,编译器会抛出如下错误:

> **showValue "xyz";;**

  showValue "xyz";;
  ----------^^^^^

stdin(9,11): error FS0001: This expression was expected to have type
    Option<'a>
but here has type
    string

定义区分联合类型

与其他类型一样,区分联合类型的定义以type关键字开头。联合案例之间用竖线分隔。第一个联合案例前的竖线是可选的,但在只有一个案例的情况下省略它可能会引起混淆,因为它会使定义看起来像是类型缩写。事实上,如果在单案例的区分联合类型中省略竖线,并且该案例没有与之关联的数据,当与其他类型发生命名冲突时,编译器会将该定义视为类型缩写。

定义联合案例时,标识符的正常规则适用,但有一个例外:联合案例的名称必须以大写字母开头,以帮助编译器在模式匹配中区分联合案例与其他标识符。如果案例名称不是以大写字母开头,编译器会抛出错误。

实际上,区分联合类型通常有三个用途:

  • 表示简单的对象层次结构

  • 表示树形结构

  • 替代类型缩写

简单的对象层次结构

区分联合类型通常用于表示简单的对象层次结构。实际上,它们在这方面表现得非常出色,以至于它们常常被用作正式类和继承的替代方案。

假设你正在开发一个需要一些基本几何功能的系统。在面向对象的环境中,这些功能可能包括一个 IShape 接口和一些具体的形状类,例如 CircleRectangleTriangle,它们都实现了 IShape 接口。一个可能的实现如下所示:

type IShape = interface end

type Circle(r : float) =
  interface IShape
  member x.Radius = r

type Rectangle(w : float, h : float) =
  interface IShape
  member x.Width = w
  member x.Height = h

type Triangle(l1 : float, l2 : float, l3 : float) =
  interface IShape
  member x.Leg1 = l1
  member x.Leg2 = l2
  member x.Leg3 = l3

被区分的联合类型提供了一种更简洁的替代方案,且不易引发副作用。以下是该对象层次结构在被区分联合类型下可能的样子:

type Shape =
/// Describes a circle by its radius
| Circle of float
/// Describes a rectangle by its width and height
| Rectangle of ① float * float
/// Describes a triangle by its three sides
| Triangle of ② float * float * float

它在内部更大

被区分的联合类型比其语法看起来要复杂得多。每个被区分的联合类型会编译成一个抽象类,负责处理相等性和比较语义、类型检查以及联合情况的创建。类似地,每个联合情况会编译成一个类,既嵌套在联合类内部,又继承自联合类。联合情况类定义了每个关联值的属性和存储,并包含一个内部构造函数。

尽管可以在其他语言中模拟某些被区分联合类型的功能,但这样做并不简单。为了证明被区分联合类型的复杂性,我们在 ILSpy 中检查刚刚定义的 Shape 类型时,发现它竟然生成了近 700 行 C# 代码!

Shape 类型定义了三种情况:CircleRectangleTriangle。每种情况都有至少一个与其代表的形状相关联的值。请注意在①和②中,如何使用元组语法将多个数据值与一个情况关联。尽管使用了元组语法,但情况实际上并不会编译成元组。相反,每个关联的数据项会编译成一个独立的属性,并遵循元组命名模式(即 Item1Item2 等)。这一区别很重要,因为从联合情况到元组没有直接的转换,这意味着你不能将它们互换使用。唯一的例外是,当类型被括号包裹时,编译器会将分组解释为元组。换句话说,编译器将 of string * intof (string * int) 区别对待;前者是类似元组的,而后者实际上就是元组。不过,除非你确实需要一个真正的元组,否则请使用默认格式。

正如你所预期的,创建 Shape 实例的方式与创建 Option<'T> 实例的方式相同。例如,以下是如何创建每个情况的实例:

let c = Circle(3.0)
let r = Rectangle(10.0, 12.0)
let t = Triangle(25.0, 20.0, 7.0)

使用元组语法表示多个关联值的一个主要问题是很容易忘记每个位置代表什么。为了解决这个问题,可以在每个情况前面包含 XML 文档注释——就像本节中 Shape 定义前面的注释那样,作为提醒。

幸运的是,问题已经得到解决。F# 3.1 中的一项语言增强是支持命名联合体类型字段。经过精炼的语法看起来像是当前的元组语法和类型注解字段定义的混合。例如,在新语法下,Shape可以重新定义如下。

type Shape =
| Circle of Radius : float
| Rectangle of Width : float * Height : float
| Triangle of Leg1 : float * Leg2 : float * Leg3 : float

对于使用 F# 3.1 语法定义的区分联合体,创建新的案例实例对开发者更友好——不仅因为标签会出现在 IntelliSense 中,还因为您可以像这样使用命名参数:

let c = Circle(Radius = 3.0)
let r = Rectangle(Width = 10.0, Height = 12.0)
let t = Triangle(Leg1 = 25.0, Leg2 = 20.0, Leg3 = 7.0)

树结构

区分联合体也可以是自引用的,这意味着与联合体某个案例相关的数据可以是同一联合体中的另一个案例。这对于创建像这样简单的树结构非常有用,它表示一个基本的标记结构:

type Markup =
| ContentElement of string * ① Markup list
| EmptyElement of string
| Content of string

这个定义中的大部分应该已经很熟悉了,但请注意,ContentElement案例有一个关联的字符串和Markup类型值的列表。

嵌套的Markup列表①使得构建一个简单的 HTML 文档变得非常简单,像下面这样。在这里,ContentElement节点表示包含额外内容的元素(如htmlheadbody),而Content节点表示包含在ContentElement中的原始文本。

let movieList =
  ContentElement("html",
    [ ContentElement("head", [ ContentElement("title", [ Content "Guilty Pleasures" ])])
      ContentElement("body",
        [ ContentElement("article",
            [ ContentElement("h1", [ Content "Some Guilty Pleasures" ])
              ContentElement("p",
                [ Content "These are "
                  ContentElement("strong", [ Content "a few" ])
                  Content " of my guilty pleasures" ])
              ContentElement("ul",
                [ ContentElement("li", [ Content "Crank (2006)" ])
                  ContentElement("li", [ Content "Starship Troopers (1997)" ])
                  ContentElement("li", [ Content "RoboCop (1987)" ])])])])])

要将前面的树结构转换为实际的 HTML 文档,您可以编写一个简单的递归函数,并使用匹配表达式处理每个联合体案例,像这样:

let rec toHtml markup =
  match markup with
  | ① ContentElement (tag, children) ->
        use w = new System.IO.StringWriter()
        children
          |> Seq.map toHtml
          |> Seq.iter (fun (s : string) -> w.Write(s))
        sprintf "<%s>%s</%s>" tag (w.ToString()) tag
  | ② EmptyElement (tag) -> sprintf "<%s />" tag
  | ③ Content (c) -> sprintf "%s" c

这里的match表达式大致类似于 C# 中的 switch 语句或 Visual Basic 中的 SELECT CASE 语句。每个匹配案例,由竖线符号(|)表示,与一个标识符模式匹配,该模式包括联合体案例的名称和其所有相关值的标识符。例如,①处的匹配案例匹配ContentElement项,并在案例体(箭头后面的部分)中使用tagchildren标识符表示相关值。同样,②和③处的匹配案例分别匹配EmptyElementContent案例。(请注意,由于匹配表达式会返回一个值,因此每个匹配案例的返回类型必须相同。)

使用movieList调用toHtml函数会生成以下 HTML(已格式化以便阅读)。在查看生成的 HTML 时,试着追溯每个元素在movieList中的节点。

<html>
  <head>
    <title>Guilty Pleasures</title>
  </head>
  <body>
    <article>
        <h1>Some Guilty Pleasures</h1>
        <p>These are <strong>a few</strong> of my guilty pleasures</p>
        <ul>
            <li>Crank (2006)</li>
            <li>Starship Troopers (1997)</li>
            <li>RoboCop (1987)</li>
        </ul>
    </article>
  </body>
</html>

替换类型缩写

单案例区分联合体可以作为类型缩写的有用替代方案,虽然类型缩写对于创建现有类型的别名很方便,但它们并不会提供额外的类型安全性。例如,假设您将UserId定义为System.Guid的别名,并且有一个函数UserId -> User。尽管该函数接受UserId,但没有任何东西可以阻止您传入任意的Guid,无论该Guid实际代表什么。

让我们扩展前一节的标记示例,展示单案例区分联合体如何解决这个问题。如果您想在浏览器中显示生成的 HTML,您可以定义一个像这样的函数:

  open System.IO

① type HtmlString = string

  let displayHtml (html ②: HtmlString) =
    let fn = Path.Combine(Path.GetTempPath(), "HtmlDemo.htm")
    let bytes = System.Text.UTF8Encoding.UTF8.GetBytes html
    using (new FileStream(fn, FileMode.Create, FileAccess.Write))
          (fun fs -> fs.Write(bytes, 0, bytes.Length))
    System.Diagnostics.Process.Start(fn).WaitForExit()
    File.Delete fn

displayHtml函数的实际机制对于这个讨论并不重要。相反,请将注意力集中在① HtmlString类型别名和② 类型注解明确声明html参数是HtmlString

从签名可以明显看出,displayHtml函数期望传入的字符串包含 HTML,但由于HtmlString仅仅是类型的别名,并不能确保它实际上是 HTML。按照当前写法,movieList |> toHtml |> displayHtml"abc123" |> displayHtml都是有效的。

为了引入更多的类型安全性,我们可以用单案例区分联合类型替换HtmlString定义,如下所示:

type HtmlString = | HtmlString of string

由于HtmlString现在是一个区分联合类型,我们需要修改displayHtml函数以提取关联的字符串。我们可以通过两种方式实现这一点。第一种方法需要我们更改函数的签名以包括标识符模式。或者,我们可以不改变签名,改为引入一个中间绑定(同样使用标识符模式)来处理关联值。第一种方法更简洁,因此我们将使用这种方法。

let displayHtml **(HtmlString(html))** =
  let fn = Path.Combine(Path.GetTempPath(), "HtmlDemo.htm")
  let bytes = System.Text.UTF8Encoding.UTF8.GetBytes html
  using (new FileStream(fn, FileMode.Create, FileAccess.Write))
        (fun fs -> fs.Write(bytes, 0, bytes.Length))
  System.Diagnostics.Process.Start(fn).WaitForExit()
  File.Delete fn

要调用displayHtml函数,我们只需要将toHtml函数的字符串包装在HtmlString实例中,并将其传递给displayHtml,如下所示:

HtmlString(movieList |> toHtml) |> displayHtml

最后,我们可以通过修改toHtml函数返回HtmlString而不是字符串来进一步简化这段代码。一种做法如下所示:

let rec toHtml markup =
  match markup with
  | ContentElement (tag, children) ->
        use w = new System.IO.StringWriter()
        children
          |> Seq.map toHtml
          |> Seq.iter (fun ① (HtmlString(html)) -> w.Write(html))
        HtmlString (sprintf "<%s>%s</%s>" tag (w.ToString()) tag)
  | EmptyElement (tag) -> HtmlString (sprintf "<%s />" tag)
  | Content (c) -> HtmlString (sprintf "%s" c)

在这个修订版中,我们将每个案例的返回值包装在HtmlString实例中。然而,更不平凡的是①,现使用标识符模式从递归结果中提取 HTML,以便将原始文本写入StringWriter

现在,toHtml函数返回一个HtmlString,将其结果传递给displayHtml简化为如下代码:

movieList |> toHtml |> displayHtml

单案例区分联合类型无法保证任何关联值实际上是正确的,但它们提供了一些额外的安全性,迫使开发者在传递给函数时做出有意识的决定。开发者可以创建一个包含任意字符串的HtmlString实例,但如果这样做,他们将被迫考虑数据是否正确。

附加成员

与记录类型类似,区分联合类型也允许附加成员。例如,我们可以将toHtml函数重新定义为Markup区分联合类型上的方法,如下所示:

type Markup =
| ContentElement of string * Markup list
| EmptyElement of string
| Content of string

member x.toHtml() =
  match x with
  | ContentElement (tag, children) ->
        use w = new System.IO.StringWriter()
        children
          |> Seq.map (fun m -> m.toHtml())
          |> Seq.iter (fun (HtmlString(html)) -> w.Write(html))
        HtmlString (sprintf "<%s>%s</%s>" tag (w.ToString()) tag)
  | EmptyElement (tag) -> HtmlString (sprintf "<%s />" tag)
  | Content (c) -> HtmlString (sprintf "%s" c)

调用这个方法就像调用其他类型的方法一样:

movieList.toHtml() |> displayHtml

惰性求值

默认情况下,F#使用急切求值,这意味着表达式会立即求值。大多数情况下,急切求值在 F#中是没问题的,但有时你可以通过推迟执行直到结果真正需要时来提高感知性能,这就是惰性求值

F#支持一些启用懒惰求值的机制,但最简单和最常见的方法之一是通过使用lazy关键字。在这里,lazy关键字与一系列包含延迟的表达式结合使用,以模拟一个长时间运行的操作。

**> let lazyOperation = lazy (printfn "evaluating lazy expression"**
                          **System.Threading.Thread.Sleep(1000)**
                          **42);;**

val lazyOperation : Lazy<int> = Value is not created.

你可以看到lazy关键字的影响。如果这个表达式是立即求值的,那么evaluating lazy expression将会被打印,并且在返回42之前会有一个即时的延迟一秒钟。相反,这个表达式的结果是内置的Lazy<'T>类型的一个实例。在这种情况下,编译器推断出返回类型,并创建了一个Lazy<int>的实例。

注意

小心跨语言边界使用懒类型。在 F# 3.0 之前,Lazy<'T>类位于 FSharp.Core 程序集。在.NET 4.0 中,Lazy<'T>被移动到了mscorlib

lazy关键字创建的Lazy<'T>实例可以像其他类型一样传递,但底层的表达式不会被求值,直到你通过调用Force方法或访问它的Value属性来强制求值,如下所示。通常约定更倾向于使用Force方法,但其实无论你使用它还是Value属性来强制求值都没有关系。在内部,Force只是一个扩展方法,它封装了Value属性。

> **lazyOperation.Force() |> printfn "Result: %i";;**
evaluating lazy expression
Result: 42
val it : unit = ()

现在我们已经强制求值,我们看到底层的表达式已经打印了它的信息,休眠了一段时间,并返回了42Lazy<'T>类型也可以通过记忆化提高应用程序性能。一旦相关的表达式被求值,它的结果会被缓存到Lazy<'T>实例中,并在随后的请求中使用。如果该表达式涉及一个昂贵或耗时的操作,结果可能会非常显著。

为了更有效地观察记忆化的影响,我们可以在 FSI 中启用计时,并重复强制求值,如下所示:

> **let lazyOperation = lazy (System.Threading.Thread.Sleep(1000); 42)**
**#time "on";;**

val lazyOperation : Lazy<int> = Value is not created.

--> Timing now on

> **lazyOperation.Force() |> printfn "Result: %i";;**
Result: 42
Real: ① 00:00:01.004, CPU: 00:00:00.000, GC gen0: 0, gen1: 0, gen2: 0
val it : unit = ()
> **lazyOperation.Force() |> printfn "Result: %i";;**
Result: 42
Real: ② 00:00:00.001, CPU: 00:00:00.000, GC gen0: 0, gen1: 0, gen2: 0
val it : unit = ()
> **lazyOperation.Force() |> printfn "Result: %i";;**
Result: 42
Real: ③ 00:00:00.001, CPU: 00:00:00.000, GC gen0: 0, gen1: 0, gen2: 0
val it : unit = ()

如你所见,在①处,第一次调用Force时,我们付出了让线程休眠的代价。随后在②和③处的调用则瞬间完成,因为记忆化机制已经缓存了结果。

总结

正如你在本章中看到的,函数式编程要求与面向对象编程不同的思维方式。面向对象编程强调管理系统状态,而函数式编程则更关注通过将无副作用的函数应用于数据来确保程序的正确性和可预测性。像 F#这样的函数式语言将函数视为数据。这样,它们通过更高阶函数、柯里化、部分应用、管道化和函数组合等概念,在系统内部实现了更大的组合性。像元组、记录类型和判别联合等函数式数据类型,通过让你专注于解决问题,而不是试图满足编译器,帮助你编写正确的代码。

第六章:走进集合

编程任务通常需要处理数据集合。 .NET 框架一直通过如数组ArrayList类等构造来支持这一场景,但直到 .NET 2.0 引入泛型后,集合支持才真正成熟。

F# 在 .NET 的基础上进行扩展,不仅支持所有现有的集合类型,还带来了一些自己的集合类型。在本章中,我们将看到一些经典集合类型在 F# 中的作用,接着我们将探索 F# 特有的集合类型。在此过程中,我们将看到内建的集合模块如何为传统类型和 F# 特有类型增添一些函数式的特色,使得处理这些类型变得轻松自如。

序列

在 .NET 中,序列是指一类具有共同类型的值的集合。更具体地说,序列是实现了IEnumerable<'T>接口的任何类型。

几乎所有 .NET 中的主要集合类型都是序列。例如,泛型集合类型(如 Dictionary<'TKey, 'TValue>List<'T>),甚至一些通常不被视为集合的类型(如 String)也实现了 IEnumerable<'T>。相反,遗留集合类型(如 ArrayListHashtable)早于泛型推出,因此它们只实现了非泛型的 IEnumerable 接口。因此,它们并不强制执行单一的公共类型,通常被视为可枚举集合,而非序列。

在 F# 中,IEnumerable<'T>通常表示为seq<'T>'T seq。像values : 'A seq这样的类型注解会被编译成IEnumerable<'A>,任何实现了IEnumerable<'T>的类型都可以在预期序列的地方使用。不过要小心直接使用具体的集合类型,因为底层实现可能是可变的。

创建序列

今天的 .NET 开发人员已经习惯了与序列打交道,但在 LINQ 引入之前,直接对 IEnumerable<'T> 进行编程相对较为罕见。开发人员通常会针对特定的集合类型编写代码。尽管如此,LINQ 的 IEnumerable<'T> 扩展方法将这一抽象推到了前台,教会了开发人员,他们并不总是需要知道集合的任何信息,除了它实现了 GetEnumerator 方法。即便 LINQ 带来了所有的便利,它也仅仅是为操作 IEnumerable<'T> 提供了一个框架;在 LINQ 中创建任意序列仍然需要一个方法来实例化特定的序列类型。

F#通过像序列和范围表达式这样的概念,进一步将序列创建的抽象化,超越了 LINQ 的范畴。尽管每个序列最终仍然是IEnumerable<'T>的实现,编译器可以自由地提供自己的实现。Seq模块还包括多个函数,用于创建新的序列。

序列表达式

序列表达式允许你通过反复应用其他 F#表达式并生成(返回)结果到一个新序列中,从而创建新的序列。在某些情况下,特别是当你处理大型或计算开销大的集合时,序列表达式内部使用的序列类型比其他集合类型更为合适,因为它们只在需要时才会创建值。这些序列类型通常也一次只在内存中存储一个值,因此它们非常适合处理大型数据集。

注意

序列表达式从技术上讲是一种内置的工作流,称为计算表达式。我们将在第十二章中详细介绍这些构造。

你可以通过将一个或多个表达式封装在序列构建器内,并使用do绑定配合yield关键字来创建一个序列表达式。例如,假设你有一个名为ArnoldMovies.txt的文件,其中包含以下数据:

The Terminator,1984
Predator,1987
Commando,1985
The Running Man,1987
True Lies,1994
Last Action Hero,1993
Total Recall,1990
Conan the Barbarian,1982
Conan the Destroyer,1984
Hercules in New York,1969

你可以使用如下的序列表达式将文本文件中的每一行读入一个序列:

let lines = **seq {** use r = new System.IO.StreamReader("ArnoldMovies.txt")
                  while not r.EndOfStream **do yield** r.ReadLine() **}**

这里,使用while循环反复从StreamReader读取每一行,并在每次迭代时返回一行。(在某些简单的序列表达式中,例如使用可枚举的for循环时,do yield可以用->运算符替代,但为了保持一致性,我通常使用do yield。)

如果你想将这个序列写入控制台,你可以将它传递给printfn函数并使用默认格式化器(通过%A标记),但只会输出前四个值,如下所示:

> **lines |> printfn "%A";;**
seq ["The Terminator,1984"; "Predator,1987"; "Commando,1985"; "The Running Man,1987"; ...]
val it : unit = ()

要打印序列中的每个值,你需要强制对整个构造进行枚举。

范围表达式

尽管范围表达式在形式上类似于你在第四章中学到的切片表达式,因为它们使用..运算符,但它们实际上是专门的序列表达式,允许你在一个值的范围内创建序列。范围表达式类似于Enumerable.Range方法,但它们更强大,因为它们不限于整数。例如,你可以像这样轻松创建一个包含从 0 到 10 的整数的序列:

seq { 0..10 }

或者,你可以通过以下方式创建一个包含从 0 到 10 的浮点数的序列:

seq { 0.0..10.0 }

同样,你可以像这样创建一个包含字符az的序列:

seq { 'a'..'z' }

在大多数情况下,您还可以包括一个值,用于标识生成序列时要跳过多少个值。例如,使用以下表达式创建一个包含从 0 到 100 的 10 的倍数的序列是很容易的:

seq { 0..10..100 }

这种范围表达式形式仅适用于数值类型,因此不能与字符数据一起使用。例如,以下表达式会导致错误。

seq { 'a'..2..'z' }

最后,你可以通过使用负步长值来创建一个值递减的序列,如下所示:

seq { 99..-1..0 }

空序列

当你需要一个没有任何元素的序列时,可以使用Seq模块的通用empty函数来创建。例如,创建一个空字符串序列,你可以像这样调用Seq.empty

> **let emptySequence = Seq.empty<string>;;**

val emptySequence : seq<string>

或者,如果你不需要特定的类型,可以通过省略类型参数,让编译器自动推断序列类型:

> **let emptySequence = Seq.empty;;**

val emptySequence : seq<'a>

初始化序列

另一个模块函数,Seq.init,可以创建一个包含最多指定数量元素的序列。例如,要创建一个包含 10 个随机数的序列,你可以这样写:

> **let rand = System.Random();;**

val rand : System.Random

> **Seq.init 10 (fun _ -> rand.Next(100));;**
val it : seq<int> = seq [22; 34; 73; 42; ...]

操作序列

Seq模块提供了许多用于操作任何序列的函数。接下来将介绍的函数列表是Seq模块中最有用的一些函数,但并不全面。

虽然接下来讨论的每个函数都属于Seq模块,但许多函数在其他集合模块中也有专门的对应函数。为了节省篇幅,我只会讲解常见的函数,但我强烈鼓励你探索其他模块,并发现适合你任务的工具。

什么时候函数不是一个函数?

你可能已经注意到,在两个空序列示例中,Seq.empty都没有传入任何参数。Seq.empty与我们迄今为止遇到的每个函数不同,它更像是一个基本的值绑定,而不是一个函数。事实上,如果你传入一个参数调用Seq.empty,你会得到一个编译器错误,提示你该值(Seq.empty)不是一个函数,不能被应用。

为什么Seq.empty被称为一个函数,而编译器却声称不是?因为它与一些其他函数(如Operators.typeofOperators.typedefof)一起,是一个特殊情况值,称为类型函数。类型函数通常保留给那些根据其类型参数计算值的纯函数,因此——尽管它们在编译后的程序集里表现为方法——它们在 F#代码中仍然被视为值。

查找序列长度

你可以像这样使用Seq.length来确定序列包含多少个元素:

seq { 0..99 } |> Seq.length

不过要小心使用Seq.length,因为根据底层集合类型,它可能会强制枚举整个序列,或者以其他方式影响性能。考虑以下代码,它使用Seq.length = 0检查序列是否为空:

seq { for i in 1..10 do
      printfn "Evaluating %i" i
      yield i }
|> Seq.length = 0

要确定序列的长度,系统必须通过调用枚举器的 MoveNext 方法来遍历序列,直到它返回 false。每次调用 MoveNext 都涉及执行获取下一个值所需的任何工作。在这个例子中,获取下一个值涉及将字符串写入控制台,如下所示:

Evaluating 1
Evaluating 2
Evaluating 3
-- *snip* --
Evaluating 10
val it : bool = false

向控制台写入一些文本是微不足道的,但即便如此,这仍然是多余的工作,因为结果实际上并没有被用于任何地方。如果超出这个简单的例子,你可以很容易地想象每次调用 MoveNext 都会触发一个昂贵的计算或数据库调用。如果你只需要确定序列是否有元素,你应该使用 Seq.isEmpty 函数。

Seq.isEmpty 检查一个序列是否包含任何元素,而不需要强制遍历整个序列。考虑以下代码,它将 Seq.length = 0 替换为 Seq.isEmpty

seq { for i in 1..10 do
      printfn "Evaluating %i" i
      yield i }
|> Seq.isEmpty

因为 Seq.isEmpty 一旦找到元素就会返回 false,所以 MoveNext 只会被调用一次,结果是:

Evaluating 1
val it : bool = false

如你所见,尽管序列表达式定义了 10 个元素,但只有第一个被打印,因为一旦函数找到一个值,评估就停止了。

遍历序列

Seq.iter 函数是功能等价于可枚举 for 循环的函数,它遍历一个序列,并对每个元素应用一个函数。例如,要打印一个包含从 0 到 99 的值的序列中的每个元素,你可以这样写:

> **seq { 0..99 } |> Seq.iter (printfn "%i");;**
0
1
2
-- *snip* --
97
98
99
val it : unit = ()

转换序列

Seq.map 类似于 Seq.iter,它将一个函数应用于序列中的每个元素,但与 Seq.iter 不同的是,它会用结果构建一个新的序列。例如,要创建一个新的序列,其中包含来自序列的元素的平方,你可以这样写:

> **seq { 0..99 } |> Seq.map (fun i -> i * i);;**
val it : seq<int> = seq [0; 1; 4; 9; ...]

排序序列

Seq 模块定义了几个排序序列的函数。每个排序函数都会创建一个新的序列,原始序列保持不变。

最简单的排序函数 Seq.sort 使用基于 IComparable<'T> 接口的默认比较方式对元素进行排序。例如,你可以将 Seq.sort 应用于一组随机整数序列,如下所示:

> **let rand = System.Random();;**

val rand : System.Random

> **Seq.init 10 (fun _ -> rand.Next 100) |> Seq.sort;;**
val it : seq<int> = seq [0; 11; 16; 19; ...]

对于更复杂的排序需求,你可以使用 Seq.sortBy 函数。除了需要排序的序列外,它还接受一个函数,该函数返回用于排序的每个元素的值。

例如,ArnoldMovies.txt 中列出的每部电影在 序列表达式 中都包含上映年份。如果你想按上映年份排序这些电影,你可以修改序列表达式,提取出各个值,如下所示:

let movies =
  seq { use r = new System.IO.StreamReader("ArnoldMovies.txt")
        while not r.EndOfStream do
          let l = r.ReadLine().Split(',')
          yield ① l.[0], int l.[1] }

在 ① 处,序列表达式现在返回包含每部电影标题和上映年份的元组。我们可以将序列与 snd 函数(获取年份)一起发送到 Seq.sortBy,如下所示:

> **movies |> Seq.sortBy snd;;**
val it : seq<string * int> =
  seq
    [("Hercules in New York", 1969); ("Conan the Barbarian", 1982);
     ("The Terminator", 1984); ("Conan the Destroyer", 1984); ...]

或者,为了按标题排序电影,你可以将 snd 替换为 fst

> seq { use r = new System.IO.StreamReader(fileName)
      while not r.EndOfStream do
        let l = r.ReadLine().Split(',')
        yield l.[0], int l.[1] }
|> **Seq.sortBy fst**;;
val it : seq<string * int> =
  seq
    [("Commando", 1985); ("Conan the Barbarian", 1982);
     ("Conan the Destroyer", 1984); ("Hercules in New York", 1969); ...]

过滤序列

当你只希望处理符合特定条件的元素时,可以使用 Seq.filter 函数来创建一个只包含符合条件的元素的新序列。例如,继续使用电影主题,你可以像这样获取 1984 年之前上映的电影:

> **movies |> Seq.filter (fun (_, year) -> year < 1985);;**
val it : seq<string * int> =
  seq
    [("The Terminator", 1984); ("Conan the Barbarian", 1982);
     ("Conan the Destroyer", 1984); ("Hercules in New York", 1969)]

聚合序列

Seq 模块提供了多个函数,用于聚合序列中的元素。最灵活(也是最复杂)的聚合函数是 Seq.fold,它遍历序列,对每个元素应用一个函数,并将结果作为累加器值返回。例如,Seq.fold 使得计算序列元素之和变得非常简单:

> **seq { 1 .. 10 } |> Seq.fold** ① **(fun s c -> s + c)** ② **0;;**
val it : int = 55

这个例子展示了如何以一种方式将 1 到 10 的值相加。Seq.fold 用于聚合的函数①接受两个值:一个聚合值(本质上是一个累加总和),以及当前元素。我们还需要给 fold 函数提供一个初始聚合值②,通常用 0 来表示。随着 fold 的执行,它会将聚合函数应用到序列中的每个元素,并返回新的聚合值以供下一次迭代使用。

由于加法运算符本身满足聚合函数的要求,我们可以像这样简化之前的表达式:

> **seq { 1..10 } |> Seq.fold (+) 0;;**
val it : int = 55

一个稍微更为专门的聚合函数是 Seq.reducereduce 函数与 fold 函数非常相似,不同之处在于传递给计算的聚合值始终与序列的元素类型相同,而 fold 可以将数据转换为其他类型。reduce 函数与 fold 的另一个区别是,它不接受初始聚合值。相反,reduce 会将聚合值初始化为序列中的第一个值。为了看到 Seq.reduce 的实际效果,我们可以将之前的表达式改写如下:

> **seq { 1 .. 10 } |> Seq.reduce (+);;**
val it : int = 55

如预期的那样,不论是使用 Seq.fold 还是 Seq.reduce,序列中元素相加的结果是相同的。

Seq.foldSeq.reduce 并不是计算序列聚合值的唯一方法;一些常见的聚合操作,如求和和平均数,已经有了专门的函数。例如,我们可以使用 Seq.sum 来计算元素的总和,而不必像之前那样使用 Seq.reduce

> seq { 1..10 } |> **Seq.sum;;**
val it : int = 55

同样地,要计算平均数,可以像这样使用 Seq.average

> seq { 1.0..10.0 } |> **Seq.average;;**
val it : float = 5.5

需要注意的是,Seq.average 只适用于支持整数除法的类型。如果你尝试用一个整数序列来使用它,你会遇到如下错误:

> seq { 1..10 } |> Seq.average;;

  seq { 1..10 } |> Seq.average;;
  -----------------^^^^^^^^^^^

stdin(2,18): error FS0001: The type 'int' does not support the operator 'DivideByInt'

Seq.sort 类似,Seq.sumSeq.average 函数也有对应的 Seq.sumBySeq.averageBy 函数,它们接受一个函数,让你指定应使用哪个值来进行计算。这些函数的语法与 Seq.sortBy 相同,因此我会留给你自己去多做一些关于 Seq 模块的实验。

数组

F#数组和传统的.NET 数组是相同的结构。它们包含固定数量的值(每个值的类型相同),并且是零索引的。尽管数组绑定本身是不可变的,但单个数组元素是可变的,因此你需要小心不要引入不必要的副作用。也就是说,数组的可变性在某些情况下使得它们比其他集合构造更具吸引力,因为改变元素值不需要进一步的内存分配。

创建数组

F#提供了多种方式来创建新的数组,并控制每个元素的初始值,既可以使用原生语法,也可以使用模块函数。

数组表达式

创建数组的最常见方式之一是使用数组表达式。数组表达式由一个以分号分隔的值列表组成,这些值被[||]标记包围。例如,你可以像这样创建一个字符串数组(如果每个值单独写在一行上,你可以省略分号):

> **let names = [| "Rose"; "Martha"; "Donna"; "Amy"; "Clara" |];;**

val names : string [] = [|"Rose"; "Martha"; "Donna"; "Amy"; "Clara"|]

最后,你可以通过将一个序列表达式包含在[||]之间来生成一个数组。然而,与序列构造器不同的是,当数组表达式被求值时,数组将完全构造出来。将这个例子与序列表达式讨论中的对应例子进行比较:

> **let lines = [| use r = new System.IO.StreamReader("ArnoldMovies.txt")**
                  **while not r.EndOfStream do yield r.ReadLine() |];;**

val lines : string [] =
  [|"The Terminator,1984"; "Predator,1987"; "Commando,1985";
    "The Running Man,1987"; "True Lies,1994"; "Last Action Hero,1993";
    "Total Recall,1990"; "Conan the Barbarian,1982";
    "Conan the Destroyer,1984"; "Hercules in New York,1969"|]

如你所见,默认的数组打印格式化器会打印每个元素(它将输出限制在 100 个元素),而不是仅打印前四个元素。

空数组

如果你需要创建一个空数组,可以使用一对空的方括号:

let emptyArray = [| |]

这种方法的缺点是,根据上下文的不同,你可能需要添加类型注解,以确保编译器不会自动将数组泛化。这样的定义看起来可能是这样的:

let emptyArray : int array = [| |];;

在前面的例子中,类型注解int array是一种类似英语的语法。如果你更喜欢传统的形式,你也可以使用int[]。如果没有类型注解,编译器将把数组定义为'a []

创建空数组的另一种方式是使用Array.empty函数。和Seq模块中的对应函数一样,Array.empty是一个类型函数,因此你可以不带任何参数调用它来创建一个零长度的数组。要使用这个函数创建一个空的字符串数组,只需写:

Array.empty<string>

如果你更愿意让编译器推断底层类型或自动泛化它,你可以省略类型参数。

初始化数组

如果你想快速创建一个所有元素都初始化为基础类型默认值的数组,可以使用Array.zeroCreate。假设你知道需要一个包含五个字符串的数组,但还不知道每个元素中将存储什么值。你可以像这样创建这个数组:

> **let stringArray = Array.zeroCreate<string> 5;;**

val stringArray : string [] = [|null; null; null; null; null|]

因为Array.zeroCreate使用底层类型的默认值,因此可能会将元素初始化为null,就像这里一样。如果null对该类型有效,并且你正在像这样创建数组,那么你需要编写代码以防止NullReferenceException

或者,Array.init允许你将每个元素初始化为特定值。Array.initSeq.init的数组专用等价物。它的语法相同,但它创建并返回一个数组。例如,要创建一个新数组,其中的元素被初始化为空字符串,你可以这样写:

> **let stringArray = Array.init 5 (fun _ -> "");;**

val stringArray : string [] = [|""; ""; ""; ""; ""|]

这里,提供的函数仅返回空字符串,但你的初始化函数可以轻松地包含更复杂的逻辑,允许你为每个元素计算不同的值。

使用数组

在 F#中使用数组与在其他.NET 语言中使用它们类似,但 F#通过诸如切片表达式和Array模块等构造扩展了数组的用途。

访问元素

通过索引属性可以访问单个数组元素。例如,要从之前定义的lines数组中检索第四个元素,你可以这样写:

> **lines.[3];;**
val it : string = "The Running Man,1987"

你可以将索引器语法与赋值运算符结合,来改变数组的单个元素。例如,要替换Last Action Hero,你可以这样写:

lines.[5] <- "Batman & Robin,1997"

如果你更喜欢以一种更函数式的方法来检索和修改数组元素,Array模块通过getset函数提供了支持。在下面的示例中,我们将创建一个数组,改变第二个元素的值,检索新值,并将其输出到控制台。

> **let movies = [| "The Terminator"; "Predator"; "Commando" |];;**

val movies : string [] = [|"The Terminator"; "Predator"; "Commando"|]

> **Array.set movies 1 "Batman & Robin"**
**Array.get movies 1 |> printfn "%s";;**
Batman & Robin

val it : unit = ()

最后,数组还支持切片表达式。如第四章中所述,切片表达式可以让你轻松地从集合中检索一系列值,像这样:

> **lines.[1..3];;**
val it : string [] =
  [|"Predator,1987"; "Commando,1985"; "The Running Man,1987"|]

复制数组

你可以通过Array.copy轻松地将一个数组的元素复制到新数组中。在这里,我们创建一个包含数字 1 到 10 的数组,并立即将它们复制到另一个数组中。

[| 1..10 |] |> Array.copy

在后台,Array.copy是对 CLR 的Array.Clone方法的封装,该方法创建源数组的浅拷贝。Array.copy提供了额外的好处,即自动将Clone返回的对象实例强制转换为适当的数组类型;也就是说,将一个整数数组直接传递给Array.Clone会得到一个obj实例,而将该数组传递给Array.copy则会得到一个int array实例。

排序数组

数组可以像其他序列一样进行排序,但Array模块提供了一些专门的排序函数,以利用单个数组元素是可变这一事实。不幸的是,这些函数中的每一个都会返回unit而不是排序后的数组,因此它们在管道或组合链中并不特别有效。

第一个就地排序函数sortInPlace使用默认比较机制对数组进行排序。下面的代码片段演示了如何对一组随机整数进行排序。

> **let r = System.Random()**
**let ints = Array.init 5 (fun _ -> r.Next(-100, 100));;**

val r : System.Random
val ints : int [] = [|-94; 20; 13; -99; 0|]

> **ints |> Array.sortInPlace;;**
val it : unit = ()
> **ints;;**
val it : int [] = [|-99; -94; 0; 13; 20|]

如果您需要更多的排序控制,您可以使用sortInPlaceBysortInPlaceWith函数。sortInPlaceBy函数让您提供一个转换函数,这个函数将在排序过程中使用。sortInPlaceWith函数接受一个比较函数,该函数返回一个整数,若小于零表示第一个值大于第二个值,若大于零表示第一个值小于第二个值,若等于零表示第一个和第二个值相等。

为了更好地理解这两种方法,考虑下面这个包含一些电影及其上映年份的元组数组。

let movies = [| ("The Terminator", "1984")
                ("Predator", "1987")
                ("Commando", "1985")
                ("Total Recall", "1990")
                ("Conan the Destroyer", "1984") |]

排序年份的最简单方法是通过sortInPlaceBy投影年份值,像这样:

> **movies |> Array.sortInPlaceBy (fun (_, y) -> y)**
**movies;;**

val it : (string * string) [] =
  [|("The Terminator", "1984"); ("Conan the Destroyer", "1984");
    ("Commando", "1985"); ("Predator", "1987"); ("Total Recall", "1990")|]

或者,我们可以直接使用sortInPlaceWith来比较两个元素:

> **movies |> Array.sortInPlaceWith (fun (_, y1) (_, y2) -> if y1 < y2 then -1**
                                                        **elif y1 > y2 then 1**
                                                        **else 0)**
**movies;;**

val it : (string * string) [] =
  [|("The Terminator", "1984"); ("Conan the Destroyer", "1984");
    ("Commando", "1985"); ("Predator", "1987"); ("Total Recall", "1990")|]

如您所见,sortInPlaceBy允许您根据特定元素底层类型的默认相等语义进行排序,而sortInPlaceWith则允许您为数组中的每个元素定义自己的相等语义。

多维数组

到目前为止,我们看到的所有数组都是一维数组。虽然也可以创建多维数组,但由于没有直接的语法支持,这稍微复杂一些。对于二维数组,您可以将一个序列的序列(通常是数组或列表)传递给array2D运算符。要创建超过二维的数组,您需要使用Array3D.initArray4D.init函数。多维数组有模块(如Array2DArray3D),这些模块包含Array模块中定义的专门子集。

注意

F#支持的最大维度数是四。

假设您想将前面章节中的电影表示为一个二维数组,而不是元组数组。您可以写类似以下的代码,将一个数组的数组传递给array2D运算符:

let movies = array2D [| [| "The Terminator"; "1984" |]
                        [| "Predator"; "1987" |]
                        [| "Commando"; "1985" |]
                        [| "The Running Man"; "1987" |]
                        [| "True Lies"; "1994" |]
                        [| "Last Action Hero"; "1993" |]
                        [| "Total Recall"; "1990" |]
                        [| "Conan the Barbarian"; "1982" |]
                        [| "Conan the Destroyer"; "1984" |]
                        [| "Hercules in New York"; "1969" |] |]

您可以使用熟悉的索引器语法访问二维数组中的任何值。例如,要获取Commando的上映年份,您可以写movies.[2, 1],这将返回1985。然而,更有趣的是,您可以通过切片表达式进行更多操作。

切片表达式使得从源数组中创建包含子集的新数组变得非常容易。例如,您可以垂直切片movies数组,创建只包含电影名称或上映年份的新数组,像这样:

> **movies.[0..,0..0];;**
val it : string [,] = [["The Terminator"]
                       ["Predator"]
                       ["Commando"]
                       ["The Running Man"]
                       -- *snip* --]

> **movies.[0..,1..1];;**
val it : string [,] = [["1984"]
                       ["1987"]
                       ["1985"]
                       ["1987"]
                       -- *snip* --]

您还可以水平切片数组,创建只包含几行的新数组:

> **movies.[1..3,0..];;**
val it : string [,] = [["Predator"; "1987"]
                       ["Commando"; "1985"]
                       ["The Running Man"; "1987"]]

多维数组在数据具有良好的矩形形状时非常有用,但当哪怕只有一行的元素数量不同,它们就不适用了。考虑一下如果我们试图在二维movies数组中包含导演名会发生什么(为了简洁起见,这里我们只使用三个标题)。

> **let movies = array2D [| [| "The Terminator"; "1984"; "James Cameron" |]**
                        **[| "Predator"; "1987"; "John McTiernan" |]**
                        **[| "Commando"; "1985" |] |];;**
System.ArgumentException: The arrays have different lengths.
Parameter name: vals
-- *snip* --
Stopped due to error

当然,一种可能的解决方案是为缺少导演名的行提供一个空字符串作为第三个元素。或者,你可以使用一个锯齿数组。

锯齿数组

锯齿数组是数组的数组。与多维数组不同,锯齿数组不要求具有矩形结构。要转换前面的失败示例,我们只需要移除对array2D函数的调用。

> **let movies = [| [| "The Terminator"; "1984"; "James Cameron" |]**
                **[| "Predator"; "1987"; "John McTiernan" |]**
                **[| "Commando"; "1985" |] |];;**

val movies : string [] [] =
  [|[|"The Terminator"; "1984"; "James Cameron"|];
    [|"Predator"; "1987"; "John McTiernan"|]; [|"Commando"; "1985"|]|]

正如你可能预料到的,既然movies现在是一个锯齿数组,你需要使用不同的语法来访问每个元素。在使用锯齿数组时,你还需要编写更多的防御性代码,因为无法保证某个特定索引在任何给定的行中都是有效的。也就是说,你可以像这样从第二行获取导演的名字:

> **movies.[1].[2];;**
val it : string = "John McTiernan"

不管你怎么切片

F# 3.1 版本新增了一些数组切片的扩展功能,这些功能在这里没有涉及,但确实很有用。在 F# 3.0 中,数组切片要求切片的维度与源数组相同。而在 F# 3.1 中,这一限制已被取消,因此你可以从一个二维数组中创建一维切片,依此类推。

列表

列表在 F#开发中广泛使用。当.NET 开发人员讨论列表时,他们通常指的是泛型List<'T>类。尽管在 F#中使用泛型列表是可能的(有时甚至是可取的),但该语言定义了另一个基于单链表的不可变构造。在 F#中,使用列表语法创建的列表会编译为Microsoft.FSharp.Collections命名空间中的FSharpList<'T>类的实例,这也是我们在本节中将要讨论的列表类型。

除了List<'T>FSharpList<'T>都是泛型序列类型(它们都实现了IEnumerable<'T>接口)外,它们几乎没有什么共同点,不能互换使用。在多语言解决方案中工作时,你需要小心不要混用这两种列表类型。

注意

你可以通过打开System.Collections.Generic命名空间或通过内置的ResizeArray<'T>类型缩写,直接使用泛型List<'T>类。

创建列表

在 F#中创建列表与创建数组非常相似,因此我不会花太多时间解释各种形式。创建数组和列表的唯一语法区别是括号样式。要创建一个新列表,你需要将分号分隔的值、范围表达式或列表序列表达式放在方括号([])中,像这样:

> **let names = [ "Rose"; "Martha"; "Donna"; "Amy"; "Clara" ];;**

val names : string list = ["Rose"; "Martha"; "Donna"; "Amy"; "Clara"]

> **let numbers = [ 1..11 ];;**

val numbers : int list = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11]

要创建一个空列表,你可以使用List.empty或一对空的括号。

使用列表

尽管在操作 F#列表和List<'T>时有一些相似之处,但它们主要是语法上的,涉及访问单个已知元素。除此之外,F#列表非常独特,特别是它们的头尾结构,非常适合函数式编程,尤其是递归技术。

访问元素

当你想获取某个特定位置的元素时,你可以使用熟悉的索引语法,就像操作数组一样。或者,你也可以使用List.nth来获得相同的结果:

> **List.nth [ 'A'..'Z' ] 3;;**
val it : char = 'D'

比通过索引访问特定元素更有趣(且通常更有用)的是列表的。列表的头是它的第一个元素,而它的尾是除了头以外的所有元素。你可以通过HeadTail属性,或List.headList.tail模块函数获取列表的头和尾。以下是使用模块函数的示例:

> **let names = [ "Rose"; "Martha"; "Donna"; "Amy"; "Clara" ];;**

val names : string list = ["Rose"; "Martha"; "Donna"; "Amy"; "Clara"]

> **List.head names;;**
val it : string = "Rose"
> **List.tail names;;**
val it : string list = ["Martha"; "Donna"; "Amy"; "Clara"]

注意

模式匹配是获取头部和尾部的另一种方式,但我们将把这个讨论留到第七章。

为什么你只想获取第一个元素或其他所有元素?递归。如果你必须使用索引遍历列表,你需要同时跟踪列表和当前位置。通过将列表分成头和尾部分,你可以在操作头部分后继续递归遍历尾部分。

考虑这个函数,它返回一个布尔值,指示一个列表是否包含特定的值(类似于List.exists模块函数)。

let rec contains fn l =
  if l = [] then false
  else fn(List.head l) || contains fn (List.tail l)

contains函数接受一个用于测试元素的函数和一个要扫描的列表。contains首先检查提供的列表是否为空。如果列表为空,contains会立即返回false;否则,它会使用提供的函数测试列表的头部,或者递归地调用contains,传入该函数和列表的尾部。

现在让我们从一个空列表开始测试几个值:

> **[] |> contains (fun n -> n = "Rose");;**
val it : bool = false

你可以看到,当列表为空时,contains正确地返回了false,但对于一个有元素的列表呢?

> **let names = [ "Rose"; "Martha"; "Donna"; "Amy"; "Clara" ];;**

val names : string list = ["Rose"; "Martha"; "Donna"; "Amy"; "Clara"]

> **names |> contains (fun n -> n = "Amy");;**
val it : bool = true
> **names |> contains (fun n -> n = "Rory");;**
val it : bool = false

contains函数递归地遍历列表,使用提供的函数检查每个元素,如果元素不匹配,就将尾部传递给contains

合并列表

虽然 F#列表是不可变的,但我们仍然可以从现有列表构造新列表。F#提供了两种主要机制:cons运算符(::)和通过@运算符进行的列表连接。

cons运算符(之所以命名为cons,是因为它构造了一个新列表)本质上是将一个元素添加到现有列表的前面,如下所示:

> **let names = [ "Rose"; "Martha"; "Donna"; "Amy"; "Clara" ]**
**let newNames = "Ace" :: names;;**

val names : string list = ["Rose"; "Martha"; "Donna"; "Amy"; "Clara"]
val newNames : string list =
  ["Ace"; "Rose"; "Martha"; "Donna"; "Amy"; "Clara"]

cons 操作符不会对现有列表进行任何更改。相反,它只是创建一个新的列表,头部设置为新值,尾部设置为现有列表。cons 操作符只能将单个项添加到列表中,但由于它位于列表的开头,因此是一个快速操作。如果你想合并两个列表,你需要使用列表连接。

要连接两个列表,你可以使用列表连接操作符(@)或 List.append 模块函数,如下所示:

> **let classicNames = [ "Susan"; "Barbara"; "Sarah Jane" ]**
**let modernNames = [ "Rose"; "Martha"; "Donna"; "Amy"; "Clara" ];;**

val classicNames : string list = ["Susan"; "Barbara"; "Sarah Jane"]
val modernNames : string list = ["Rose"; "Martha"; "Donna"; "Amy"; "Clara"]

> **classicNames @ modernNames;;**
val it : string list =
  ["Susan"; "Barbara"; "Sarah Jane"; "Rose"; "Martha"; "Donna"; "Amy"; "Clara"]
> **List.append classicNames modernNames;;**
val it : string list =
  ["Susan"; "Barbara"; "Sarah Jane"; "Rose"; "Martha"; "Donna"; "Amy"; "Clara"]

使用连接操作符创建的列表与使用 List.append 创建的列表没有区别。从内部实现来看,List.append 封装了追加操作符,因此它们在功能上是等效的。

要同时合并多个列表,你可以像这样将一系列列表传递给 List.concat

> **List.concat [[ "Susan"; "Sarah Jane" ]**
             **[ "Rose"; "Martha" ]**
             **["Donna"; "Amy"; "Clara"]];;**
val it : string list =
  ["Susan"; "Sarah Jane"; "Rose"; "Martha"; "Donna"; "Amy"; "Clara"]

现在,最初的三个独立列表已经合并成一个包含每个项的单一列表。

集合

在 F# 中,集合 是一个不可变的唯一值集合,其顺序不被保留。F# 的集合与数学集合密切相关(可以参考维恩图),并提供许多有助于比较集合的操作。

创建集合

创建集合时没有像特殊括号格式这样的语法糖,因此,如果你想使用集合,你需要依赖类型构造器或一些 Set 模块函数(如 Set.ofList,它可以从 F# 列表创建集合)。例如,要创建一个包含字母表字母的集合,你可以这样写:

> **let alphabet = [ 'A'..'Z' ] |> Set.ofList;;**

val alphabet : Set<char> =
  set ['A'; 'B'; 'C'; 'D'; 'E'; 'F'; 'G'; 'H'; 'I'; ...]

Set<'T> 类定义了添加和移除集合中值的方法,但由于 F# 集合是不可变的,这两个方法都会返回新的集合,并保持原集合不变。Add 方法对于从空集合填充新集合非常有用,例如:

> **let vowels = Set.empty.Add('A').Add('E').Add('I').Add('O').Add('U');;**

val vowels : Set<char> = set ['A'; 'E'; 'I'; 'O'; 'U']

当然,以这种方式创建集合比 F# 中典型的做法更加面向对象。

集合操作

因为集合与数学中的集合关系密切,Set 模块提供了多个函数,用于执行各种集合操作,如查找并集、交集和差集,甚至可以确定两个集合是否作为子集或超集相关联。

并集

要找到两个集合的并集——即包含在第一个或第二个集合中的那些元素——你可以使用如下的 Set.union 函数:

> **let set1 = [ 1..5 ] |> Set.ofList**
**let set2 = [ 3..7 ] |> Set.ofList**
**Set.union set1 set2;;**

val set1 : Set<int> = set [1; 2; 3; 4; 5]
val set2 : Set<int> = set [3; 4; 5; 6; 7]
val it : Set<int> = set [1; 2; 3; 4; 5; 6; 7]

这里,set1 包含整数一到五,而 set2 包含整数三到七。由于两个集合的并集包含在任一集合中找到的每个不同的值,set1set2 的并集是从一到七的整数范围。

Set<'T> 类还定义了一个自定义的 + 操作符,可以用来找到两个集合的并集:

> **set1 + set2;;**
val it : Set<int> = set [1; 2; 3; 4; 5; 6; 7]

交集

Set.intersect 函数返回一个新集合,仅包含在两个集合中都存在的元素。例如,如果你有一个包含从一到五的元素的集合,另一个集合包含从三到七的元素,你可以这样找到交集:

> **let set1 = [ 1..5 ] |> Set.ofList**
**let set2 = [ 3..7 ] |> Set.ofList**
**Set.intersect set1 set2;;**

val set1 : Set<int> = set [1; 2; 3; 4; 5]
val set2 : Set<int> = set [3; 4; 5; 6; 7]
val it : Set<int> = set [3; 4; 5]

结果交集集合只包含set1set2中共有的三个值——在本例中为 3、4 和 5。

区别

虽然交集包含两个集合共有的所有元素,但差集包含仅在第一个集合中找到的元素。你可以使用Set.difference函数来找到两个集合之间的差异。

> **let set1 = [ 1..5 ] |> Set.ofList**
**let set2 = [ 3..7 ] |> Set.ofList**
**Set.difference set1 set2;;**

val set1 : Set<int> = set [1; 2; 3; 4; 5]
val set2 : Set<int> = set [3; 4; 5; 6; 7]
val it : Set<int> = set [1; 2]

这里,第一个集合包含第二个集合中没有的两个元素,12;因此,差集只包含这些值。

就像交集一样,Set<'T>类定义了一个自定义的运算符,该运算符返回一个包含两个集合差异的集合。

> **set1 - set2;;**
val it : Set<int> = set [1; 2]

子集和超集

Set模块通过四个函数使我们容易判断两个集合是否存在子集或超集关系:isSubsetisProperSubsetisSupersetisProperSuperset。基本子集/超集与真正子集/超集之间的区别在于,真正的子集/超集需要至少有一个在对方集合中不存在的额外元素。以下集合可以说明这一点:

> **let set1 = [ 1..5 ] |> Set.ofList**
**let set2 = [ 1..5 ] |> Set.ofList;;**

val set1 : Set<int> = set [1; 2; 3; 4; 5]
val set2 : Set<int> = set [1; 2; 3; 4; 5]

因为set1set2包含相同的值,所以可以认为set1set2的超集。相反,set2可以被认为是set1的子集。然而,基于同样的原因,set2不能是set1的真正子集,正如以下代码片段所示。

> **Set.isSuperset set1 set2;;**
val it : bool = true
> **Set.isProperSuperset set1 set2;;**
val it : bool = false
> **Set.isSubset set2 set1;;**
val it : bool = true
> **Set.isProperSubset set2 set1;;**
val it : bool = false

要使set2成为set1的一个真正子集,我们需要重新定义set1,使其至少包含一个额外的值。

> **let set1 = [ 0..5 ] |> Set.ofList;;**

val set1 : Set<int> = set [0; 1; 2; 3; 4; 5]

现在,如果我们再次测试子集和超集,我们应该会看到set2既是set1的子集,也是其真正子集。

> **Set.isSuperset set1 set2;;**
val it : bool = true
> **Set.isProperSuperset set1 set2;;**
val it : bool = true
> **Set.isSubset set2 set1;;**
val it : bool = true
> **Set.isProperSubset set2 set1;;**
val it : bool = true

映射

Map类型表示一个无序的不可变字典(键到值的映射),并提供了与通用Dictionary<'TKey, 'TValue>类相同的许多功能。

注意

尽管Map<'Key, 'Value>类和相关的Map模块提供了添加和移除条目的方法,但作为不可变构造体,只有在底层条目不会改变时,映射才有意义。从映射中添加和删除条目需要创建一个新的映射实例,并将数据从源实例复制过来,因此比修改可变字典要慢得多。

创建映射

与集合一样,F#不提供直接的语法支持来创建映射,因此也需要使用类型构造器或Map模块函数来创建它们。无论你选择哪种方式,映射总是基于一系列包含键和值的元组。在这里,我们将一个包含各州及其相应首府的列表传递给类型构造器:

> **let stateCapitals =**
  **Map [("Indiana", "Indianapolis")**
       **("Michigan", "Lansing")**
       **("Ohio", "Columbus")**
       **("Kentucky", "Frankfort")**
       **("Illinois", "Springfield")];;**

val stateCapitals : Map<string, string> =
  map
    [("Illinois", "Springfield"); ("Indiana", "Indianapolis");
     ("Kentucky", "Frankfort"); ("Michigan", "Lansing"); ("Ohio", "Columbus")]

使用映射

由于映射类似于不可变字典,与它们交互的方式类似于Dictionary<'TKey, 'TValue>

查找值

与通用字典类似,Map类型提供了一个索引属性,通过已知键访问值。例如,使用stateCapitals映射,我们可以这样查找印第安纳州的首府:

> **stateCapitals.["Indiana"];;**
val it : string = "Indianapolis"

Map.find函数让我们通过函数式的方式做同样的事情。

> **stateCapitals |> Map.find "Indiana";;**
val it : string = "Indianapolis"

前面两种方法的最大问题是,当映射中没有该键时,它们会抛出KeyNotFoundException。为了避免这种异常,你可以使用Map.containsKey函数来检查映射中是否包含某个特定键。如果你想测试stateCapitals是否包含华盛顿,可以写出如下代码:

> **stateCapitals |> Map.containsKey "Washington";;**
val it : bool = false

最后,如果你更倾向于通过单次操作来测试键并获取映射的值,你可以使用Map.tryFind函数,它返回一个option,指示是否找到键以及相关的值,如下所示:

> **stateCapitals |> Map.tryFind "Washington";;**
val it : string option = None
> **stateCapitals |> Map.tryFind "Indiana";;**
val it : string option = Some "Indianapolis"

查找键

有时,你可能需要根据映射的值查找键。Map模块提供了两个函数来实现这一点:findKeytryFindKey。就像它们的值查找对应函数一样,findKeytryFindKey的区别在于,当无法找到符合条件的值时,findKey会抛出KeyNotFoundException,而tryFindKey则不会。

要查找键,你需要传递一个接受键及其映射值的函数,并返回一个布尔值,指示值是否符合你的标准。例如,要通过首都查找一个州,可以使用Map.tryFindKey,你可以写出如下代码:

> **stateCapitals |> Map.tryFindKey (fun k v -> v = "Indianapolis");;**
val it : string option = Some "Indiana"
> **stateCapitals |> Map.tryFindKey (fun k v -> v = "Olympia");;**
val it : string option = None

如你所见,tryFindKey返回一个option,因此你需要根据SomeNone进行相应的测试。

在集合类型之间进行转换

有时你会有一个集合类型的实例,但你实际上需要一个不同的类型。例如,你可能正在处理一个 F#列表,但想要应用一个仅适用于数组的函数。每个集合模块都包含几个函数,可以轻松地在许多其他集合类型之间进行转换。

在每个模块中,转换函数的命名是根据转换方向和目标类型来命名的。例如,要将一个序列转换为数组,你可以将序列传递给Seq.toArrayArray.ofSeq,像这样:

> **seq { 1..10 } |> Seq.toArray;;**
val it : int [] = [|1; 2; 3; 4; 5; 6; 7; 8; 9; 10|]
> **seq { 1..10 } |> Array.ofSeq;;**
val it : int [] = [|1; 2; 3; 4; 5; 6; 7; 8; 9; 10|]

类似地,要将一个列表转换为序列,你可以将列表传递给List.toSeqSeq.ofListSetMap模块也允许你根据相同的约定,在序列、数组和映射之间进行转换。

尽管大多数转换函数会创建一个新的集合,但其中一些通过类型转换工作。例如,Seq.ofList只是将源列表转换为seq<'t>(记住,FSharpList<'T>实现了IEnumerable<'T>,所以这是一个有效的转换),而List.ofArray则创建一个新的数组,并用列表的值填充它。如果有任何问题关于结果集合是类型转换还是新对象,你可以使用静态方法obj.ReferenceEquals来检查,如下所示:

> **let l = [ 1..10 ]**
**obj.ReferenceEquals(l, Seq.ofList l);;**

val l : int list = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
val it : bool = ① true

> **let a = [| 1..10 |]**
**obj.ReferenceEquals(a, List.ofArray a);;**

val a : int [] = [|1; 2; 3; 4; 5; 6; 7; 8; 9; 10|]
val it : bool = ② false

上面的代码片段展示了调用Seq.ofListList.ofArray的结果。你可以看到,① Seq.ofList返回相同的对象,而List.ofArray②返回一个新的对象。

总结

处理数据集合是几乎每个复杂应用程序都必须做的事情。F#让你能够使用所有传统的.NET 集合,如数组和泛型列表,同时还添加了其他几种类型,比如 F#列表、集合和映射,这些更适合函数式编程。

在许多方面,使用 F#处理数据集合比传统.NET 开发更加简化,因为像序列表达式、范围表达式和切片表达式这样的语言特性使得创建集合变得更容易,同时也能更方便地访问单个元素。

最后,像SeqArrayList这样的各种集合模块提供了一种便捷的机制,可以在各自的集合类型上执行许多常见任务。

第七章 模式,模式,处处是模式

模式匹配是 F# 最强大的特性之一。模式在语言中根深蒂固,许多你已经见过的结构都会使用模式,例如 let 绑定、try...with 表达式和 lambda 表达式。在本章中,你将学习匹配表达式、预定义模式类型以及如何使用活动模式创建你自己的模式。

匹配表达式

尽管 F# 允许通过 if 表达式进行命令式风格的分支,但它们可能难以维护,尤其是当条件逻辑的复杂性增加时。匹配表达式是 F# 主要的分支机制。

从表面上看,许多匹配表达式类似于 C# 的 switch 或 Visual Basic 的 Select Case 语句,但它们要强大得多。例如,switchSelect Case 只对常量值进行操作,而匹配表达式根据哪个模式与输入匹配来选择要评估的表达式。在最基本的形式中,匹配表达式的结构如下:

match ①*test-expression* with
  | ②*pattern1* -> ③*result-expression1*
  | ④*pattern2* -> ⑤*result-expression2*
  | ...

在上面的语法中,① 处的表达式首先被求值,并依次与表达式体中的每个模式进行比较,直到找到匹配项。例如,如果结果满足 ② 处的模式,③ 处的表达式将被求值。否则,④ 处的模式将被测试,如果匹配,⑤ 处的表达式将被求值,依此类推。因为匹配表达式也会返回一个值,所以每个结果表达式必须是相同类型的。

模式是按顺序匹配的这一事实对如何构建代码有影响;你必须组织你的匹配表达式,使得模式从最具体到最一般排列。如果更一般的模式排在更具体的模式前面,以至于阻止了后续模式的评估,编译器将会对每个受影响的模式发出警告。

匹配表达式可以与各种数据类型一起使用,包括(但不限于)数字、字符串、元组和记录。例如,下面是一个简单的匹配表达式,它与一个带有区分联合类型的函数一起工作:

let testOption opt =
  match opt with
  | Some(v) -> printfn "Some: %i" v
  | None -> printfn "None"

在这个代码片段中,opt 被推断为 int option 类型,匹配表达式包含了 SomeNone 两种情况的模式。当匹配表达式被求值时,它首先测试 opt 是否与 Some 匹配。如果匹配,模式将 Some 中的值绑定到 v,然后当结果表达式被求值时,v 被打印出来。同样地,当匹配到 None 时,结果表达式简单地打印出 "None"

守卫子句

除了将不同的值与模式进行匹配外,你还可以通过 守卫子句 进一步完善每种情况,这些子句允许你指定额外的条件,只有满足这些条件才能满足某个情况。例如,你可以使用守卫子句(通过插入 when 后跟条件)来区分正数和负数,如下所示:

let testNumber value =
  match value with
  | ①v when v < 0 -> printfn "%i is negative" v
  | ②v when v > 0 -> printfn "%i is positive" v
  | _ -> printfn "zero"

在这个例子中,我们有两个具有相同模式但不同守卫子句的情况。尽管任何整数都会匹配这三种模式中的任何一种,但模式①和②上的守卫子句会导致匹配失败,除非捕获的值符合它们的标准。

你可以使用布尔运算符将多个守卫子句结合起来,以实现更复杂的匹配逻辑。例如,你可以构造一个仅匹配正整数的偶数的情况,如下所示:

let testNumber value =
  match value with
  | v when v > 0 && v % 2 = 0 -> printfn "%i is positive and even" v
  | v -> printfn "%i is zero, negative, or odd" v

模式匹配函数

有一种替代的匹配表达式语法,称为模式匹配函数。使用模式匹配函数语法,match...with部分的匹配表达式被function替代,如下所示:

> **let testOption =**
  **function**
  | **Some(v) -> printfn "Some: %i" v**
  | **None -> printfn "None";;**

val testOption : _arg1:int option -> unit

从输出中的签名可以看到,通过使用模式匹配函数语法,我们将testOption绑定为一个接受int option(生成的名称为_arg1)并返回unit的函数。以这种方式使用function关键字是创建模式匹配 lambda 表达式的便捷方式,并且在功能上等价于编写:

fun x ->
  match x with
  | Some(v) -> printfn "Some: %i" v
  | None -> printfn "None";;

由于模式匹配函数只是 lambda 表达式的简化版,将匹配表达式传递给高阶函数是非常简单的。假设你想从一个包含可选整数的列表中过滤掉所有None值,你可以考虑将一个模式匹配函数传递给List.filter函数,如下所示:

[ Some 10; None; Some 4; None; Some 0; Some 7 ]
|> List.filter (function | Some(_) -> true
                         | None -> false)

当执行filter函数时,它将对源列表中的每一项调用模式匹配函数,当项为Some(_)时返回true,当项为None时返回false。因此,由filter创建的列表将只包含Some 10Some 4Some 0Some 7

穷尽匹配

当一个匹配表达式包含的模式能够涵盖测试表达式的所有可能结果时,称其为穷尽覆盖的。当存在一个未被模式覆盖的值时,编译器会发出警告。考虑当我们匹配一个整数,但只覆盖了少数几个情况时会发生什么。

> **let numberToString =**
  **function**
  **| 0 -> "zero"**
  **| 1 -> "one"**
  **| 2 -> "two"**
  **| 3 -> "three";;**

    function
  --^^^^^^^^

stdin(4,3): warning FS0025: Incomplete pattern matches on this expression. For
example, the value '4' may indicate a case not covered by the pattern(s).

val numberToString : _arg1:int -> string

在这里,你可以看到如果整数是 0、1、2 或 3 之外的任何值,它将永远不会匹配。编译器甚至提供了一个可能未被覆盖的值——在这个例子中是四。如果numberToString被调用时传入一个未被覆盖的值,调用将失败并抛出MatchFailureException

> **numberToString 4;;**
Microsoft.FSharp.Core.MatchFailureException: The match cases were incomplete
   at FSI_0025.numberToString(Int32 _arg1)
   at <StartupCode$FSI_0026>.$FSI_0026.main@()
Stopped due to error

为了解决这个问题,你可以添加更多模式,尝试匹配所有可能的值,但许多时候(比如整数)匹配所有可能的值是不可行的。其他时候,你可能只关心几个特定的情况。在这两种情况下,你都可以使用匹配任何值的模式:变量模式或通配符模式。

变量模式

变量模式通过标识符表示,并在你希望匹配任何值并将该值绑定到一个名称时使用。通过变量模式定义的任何名称都可以在该情况的守卫子句和结果表达式中使用。例如,为了使 numberToString 函数更加完备,你可以像这样修改函数,加入一个变量模式:

let numberToString =
  function
  | 0 -> "zero"
  | 1 -> "one"
  | 2 -> "two"
  | 3 -> "three"
① | n -> sprintf "%O" n

当你在 ① 处包含一个变量模式时,除了 0、1、2 或 3 之外的任何值都会被绑定到 n,并简单地转换为字符串。

变量模式中定义的标识符应该以小写字母开头,以区分它与标识符模式。现在,调用 numberToString 并传入 4 将不再出现错误,如下所示:

> **numberToString 4;;**
val it : string = "4"

通配符模式

通配符模式,用单个下划线字符(_)表示,工作原理与变量模式相同,只是它丢弃匹配的值,而不是将其绑定到名称。

以下是经过修改的 numberToString 实现,加入了通配符模式。注意,由于匹配的值会被丢弃,我们需要返回一个通用的字符串,而不是基于匹配值的字符串。

let numberToString =
  function
  | 0 -> "zero"
  | 1 -> "one"
  | 2 -> "two"
  | 3 -> "three"
  | _ -> "unknown"

匹配常量值

常量模式由硬编码的数字、字符、字符串和枚举值组成。你已经看到了一些常量模式的例子,但为了重申,接下来 numberToString 函数中的前四个情况都是常量模式。

let numberToString =
  function
  | 0 -> "zero"
  | 1 -> "one"
  | 2 -> "two"
  | 3 -> "three"
  | _ -> "..."

在这里,数字 0 到 3 被明确匹配并返回数字对应的单词。所有其他值都进入通配符情况。

标识符模式

当一个模式由多个字符组成并且以大写字母开头时,编译器会尝试将其解析为名称。这被称为 标识符模式,通常指代判别联合情况、带有 LiteralAttribute 的标识符或异常名称(如在 try...with 块中所见)。

匹配联合情况

当标识符是一个判别联合情况时,该模式被称为 联合情况模式。联合情况模式必须为该情况关联的每个数据项包括一个通配符或标识符。如果该情况没有任何关联数据,则情况标签可以单独出现。

考虑以下定义了一些形状的判别联合:

type Shape =
| Circle of float
| Rectangle of float * float
| Triangle of float * float * float

从这个定义开始,定义一个函数来使用匹配表达式计算任何包含形状的周长就变得很简单了。以下是一个可能的实现:

let getPerimeter =
  function
  | Circle(r) -> 2.0 * System.Math.PI * r
  | Rectangle(w, h) -> 2.0 * (w + h)
  | Triangle(l1, l2, l3) -> l1 + l2 + l3

如你所见,由判别联合定义的每个形状都被涵盖,且每个情况中的数据项被提取为有意义的名称,例如圆的半径 r,矩形的宽度 w 和高度 h

匹配字面量

当编译器遇到一个使用 LiteralAttribute 定义的标识符作为情况时,它被称为 字面量模式,但它的处理方式和常量模式一样。

这是修改后的 numberToString 函数,使用了一些字面量模式代替常量模式:

[<LiteralAttribute>]
let Zero = 0
[<LiteralAttribute>]
let One = 1
[<LiteralAttribute>]
let Two = 2
[<LiteralAttribute>]
let Three = 3

let numberToString =
  function
  | Zero -> "zero"
  | One -> "one"
  | Two -> "two"
  | Three -> "three"
  | _ -> "unknown"

匹配空值

当对包含 null 为有效值的类型进行模式匹配时,你通常会想包括一个空值模式,以尽可能隔离所有的 null 值。空值模式通过 null 关键字表示。

考虑这个 matchString 模式匹配函数:

> **let matchString =**
  **function**
  **| "" -> None**
  **| v -> Some(v.ToString());;**

val matchString : _arg1:string -> string option

matchString 函数包含两种情况:一个用于空字符串的常量模式和一个用于其他所有内容的变量模式。编译器很高兴为我们创建这个函数,并且没有警告我们关于不完整的模式匹配,但这里有一个潜在的严重问题:null 是字符串的有效值,但变量模式匹配任何值,包括 null!如果一个 null 字符串传递给 matchString,当对 v 调用 ToString 方法时,NullReferenceException 将被抛出,因为变量模式匹配 null,并因此将 v 设置为 null,如下面所示:

> **matchString null;;**
System.NullReferenceException: Object reference not set to an instance of an object.
   at FSI_0070.matchString(String _arg1) in C:\Users\Dave\AppData\Local\Temp\~vsE434.fsx:line 68
   at <StartupCode$FSI_0071>.$FSI_0071.main@()
Stopped due to error

在变量模式之前添加空值模式将确保 null 值不会泄露到应用程序的其他部分。按照惯例,空值模式通常列在最前面,因此这里的做法是将 null 和空字符串模式与“或”模式结合使用:

let matchString =
  function
  **| null**
  | "" -> None
  | v -> Some(v.ToString())

匹配元组

你可以使用元组模式匹配并解构元组到其组成元素。例如,一个表示二维点的元组可以通过 let 绑定中的元组模式解构为单独的 x 和 y 坐标,像这样:

let point = 10, 20
let x, y = point

在这个示例中,值 1020point 中提取并分别绑定到 xy 标识符。

类似地,你可以在匹配表达式中使用多个元组模式,基于元组值执行分支操作。以点的主题为例,假设要判断某个点是否位于原点或沿轴线,你可以写类似以下代码:

let locatePoint p =
  match p with
  | ① (0, 0) -> sprintf "%A is at the origin" p
  | ② (_, 0) -> sprintf "%A is on the x-axis" p
  | ③ (0, _) -> sprintf "%A is on the y-axis" p
  | ④ (x, y) -> sprintf "Point (%i, %i)" x y

locatePoint 函数不仅突出了使用多个元组模式,还展示了如何将多种模式类型结合起来形成更复杂的分支逻辑。例如,① 在元组模式中使用了两个常量模式,而 ② 和 ③ 分别在元组模式中使用了一个常量模式和一个通配符模式。最后,④ 在元组模式中使用了两个变量模式。

请记住,元组模式中的项数必须与元组本身的项数相匹配。例如,尝试将一个包含两项的元组模式与一个包含三项的元组进行匹配将导致编译错误,因为它们的基础类型不兼容。

匹配记录

记录类型可以通过记录模式参与模式匹配。使用记录模式,可以匹配并解构单个记录实例,提取出其各个值。

考虑以下基于典型美国姓名的记录类型定义:

type Name = { First : string; Middle : string option; Last : string }

在这种记录类型中,名字和姓氏都是必填的,但中间名是可选的。你可以使用匹配表达式根据是否指定中间名来格式化名字,如下所示:

let formatName =
  function
  | { First = f; Middle = Some(m); Last = l } -> sprintf "%s, %s %s" l f m
  | { First = f; Middle = None; Last = l } -> sprintf "%s, %s" l f

在这里,两个模式分别将名字和姓氏绑定到标识符 fl。更有趣的是,模式如何将中间名与 Some(m)None 的联合情况进行匹配。当匹配表达式与包含中间名的 Name 进行评估时,中间名将绑定到 m。否则,匹配失败,None 情况会被评估。

formatName 函数中的模式从记录中提取每个值,但记录模式也可以作用于标签的子集。例如,如果你只想确定一个名字是否包含中间名,你可以构造一个像下面这样的匹配表达式:

let hasMiddleName =
  function
  | { Middle = Some(_) } -> true
  | { Middle = None } -> false

编译器通常可以自动解析模式是针对哪种记录类型构造的,但如果无法确定,你可以像下面这样指定类型名称:

let hasMiddleName =
  function
  | { **Name.**Middle = Some(_) } -> true
  | { **Name.**Middle = None } -> false

通过像这样限定模式,通常只有在存在多个具有冲突定义的记录类型时才需要。

匹配集合

模式匹配不仅限于单一值或类似元组和记录这样的结构化数据。F# 还包括几种模式,用于匹配一维数组和列表。如果你想匹配另一种集合类型,通常需要通过 List.ofSeqArray.ofSeq 或类似的机制将集合转换为列表或数组。

数组模式

数组模式与数组定义非常相似,允许你匹配具有特定元素个数的数组。例如,你可以使用数组模式来确定数组的长度,如下所示:

let getLength =
  function
  | null -> 0
  | [| |] -> 0
  | [| _ |] -> 1
  | [| _; _; |] -> 2
  | [| _; _; _ |] -> 3
  | a -> a |> Array.length

忽略数组长度的计算通常直接通过 Array.length 属性检查,而不通过这种人为的模式匹配例子,getLength 函数展示了数组模式如何匹配固定大小数组中的单个元素。

列表模式

列表模式类似于数组模式,只不过它们看起来像并且作用于 F# 列表。这里是 getLength 函数的修改版,已调整为与 F# 列表而非数组配合使用。

let getLength =
  function
  | [ ] -> 0
  | [ _ ] -> 1
  | [ _; _; ] -> 2
  | [ _; _; _ ] -> 3
  | lst -> lst |> List.length

请注意,没有 null 情况,因为 null 不是 F# 列表的有效值。

Cons 模式

另一种匹配 F# 列表的方法是使用 Cons 模式。在模式匹配中,cons 操作符 (::) 是反向工作的;它不是将元素添加到列表前面,而是将列表的头部和尾部分开。这使得你能够递归地匹配具有任意数量元素的列表。

与我们的主题一致,下面是如何使用 Cons 模式通过模式匹配来查找集合的长度:

let getLength n =
  ① let rec len c l =
    match l with
    | ② [] -> c
    | ③ _ :: t -> len (c + 1) t
  len 0 n

这个版本的getLength函数与 F#列表的内部length属性实现非常相似。它定义了len ①,一个内部函数,递归地匹配空模式 ② 或 Cons 模式 ③。匹配到空列表时,len返回提供的计数值(c);否则,它会递归调用,递增计数并传递尾部。getLength中的 Cons 模式使用通配符模式来匹配头值,因为在后续操作中不需要它。

按类型匹配

F#有两种方式可以匹配特定的数据类型:类型注解模式和动态类型测试模式。

类型注解模式

类型注解模式允许你指定匹配值的类型。它们在模式匹配函数中特别有用,在这些函数中,编译器需要一些额外的帮助来确定函数隐式参数的预期类型。例如,以下函数用于检查一个字符串是否以大写字母开头:

// Does not compile
let startsWithUpperCase =
  function
  | ① s when ② s.Length > 0 && s.[0] = System.Char.ToUpper s.[0] -> true
  | _ -> false

然而,按目前的写法,startsWithUpperCase函数无法编译。它会失败并显示以下错误:

~vsD607.fsx(83,12): error FS0072: Lookup on object of indeterminate type based
on information prior to this program point. A type annotation may be needed
prior to this program point to constrain the type of the object. This may
allow the lookup to be resolved.

该编译失败的原因是守卫条件在 ② 依赖于字符串属性,但这些属性不可用,因为编译器已经自动泛化了函数的隐式参数。为了解决这个问题,我们可以修改函数,使其显式地使用字符串参数,或者我们可以在 ① 的模式中包括类型注解,像这样(注意括号是必须的):

let startsWithUpperCase =
  function
  | **(s : string)** when s.Length > 0 && s.[0] = System.Char.ToUpper s.[0] ->
    true
  | _ -> false

使用类型注解后,参数不再自动泛化,从而使得字符串的属性可以在守卫条件中使用。

动态类型测试模式

动态类型测试模式在某种程度上是类型注解模式的对立面。类型注解模式要求每个案例都匹配相同的数据类型,而动态类型测试模式在匹配的值是特定类型的实例时满足条件;也就是说,如果你注解一个模式以匹配字符串,每个案例都必须匹配字符串。因此,动态类型测试模式非常适合匹配类型层次结构。例如,你可能会匹配一个接口实例,但使用动态类型测试模式为特定的实现提供不同的逻辑。动态类型测试模式类似于动态类型转换操作符(:?>),除了省略了>符号。

以下detectColorSpace函数展示了如何通过匹配三种记录类型来使用动态类型测试模式。如果没有类型匹配,该函数会抛出异常。

type RgbColor = { R : int; G : int; B : int }
type CmykColor = { C : int; M : int; Y : int; K : int }
type HslColor = { H : int; S : int; L : int }

let detectColorSpace (cs : obj) =
  match cs with
  **| :? RgbColor -> printfn "RGB"**
  **| :? CmykColor -> printfn "CMYK"**
  **| :? HslColor -> printfn "HSL"**
  | _ -> failwith "Unrecognized"

作为模式

As 模式让你将一个名称绑定到整个匹配值,尤其在使用模式匹配和模式匹配函数的let绑定中很有用,因为在这些情况下,你没有直接访问匹配值的命名方式。

通常,let绑定只是将一个名字绑定到一个值,但正如你所见,你还可以在let绑定中使用模式来分解一个值,并将名字绑定到它的每个组成部分,像这样:

> **let x, y = (10, 20);;**

val y : int = 20
val x : int = 10

如果你想绑定不仅仅是组成部分,而是整个值,你可以像这样显式使用两个let绑定:

> **let point = (10, 20)**
**let x, y = point;;**

val point : int * int = (10, 20)
val y : int = 20
val x : int = 10

拥有两个独立的let绑定当然是可行的,但通过将它们合并为一个使用 As 模式的绑定会更加简洁,如下所示:

> **let x, y as point = (10, 20);;**

val point : int * int = (10, 20)
val y : int = 20
val x : int = 10

As 模式不仅限于在let绑定中使用;你还可以在匹配表达式中使用它。在这里,我们在每个案例中都包含了一个 As 模式,用来将匹配到的元组绑定到一个名称上。

let locatePoint =
  function
  | (0, 0) as p -> sprintf "%A is at the origin" p
  | (_, 0) as p -> sprintf "%A is on the X-Axis" p
  | (0, _) as p -> sprintf "%A is on the Y-Axis" p
  | (x, y) as p -> sprintf "Point (%i, %i)" x y

通过 AND 组合模式

使用AND 模式,有时也叫合取模式,你可以通过将多个兼容的模式与一个和符号(&)结合,来匹配输入。要匹配成功,输入必须满足每一个模式。

一般来说,在基本的模式匹配场景中,AND 模式并不是特别有用,因为更具表现力的守卫子句通常更适合完成任务。尽管如此,AND 模式在某些情况下仍然有用,例如在匹配另一个模式时提取值。(AND 模式在活跃模式中也被广泛使用,稍后我们会讨论。)例如,要确定一个二维点是否位于原点或沿坐标轴上,你可以写出类似这样的代码:

let locatePoint =
  function
  | (0, 0) as p -> sprintf "%A is at the origin" p
  | ① (x, y) & (_, 0) -> sprintf "(%i, %i) is on the x-axis" x y
  | ② (x, y) & (0, _) -> sprintf "(%i, %i) is on the y-axis" x y
  | (x, y) -> sprintf "Point (%i, %i)" x y

locatePoint函数在①和②使用与模式相结合的 AND 模式,从元组中提取xy的值,当第二个或第一个值分别为 0 时。

通过 OR 组合模式

如果多个模式在匹配时应该执行相同的代码,你可以使用 OR(或析取)模式将它们结合起来。OR 模式通过竖线字符(|)将多个模式组合在一起。在许多方面,OR 模式类似于 C#中switch语句的穿透案例。

在这里,locatePoint函数已经被修改为使用 OR 模式,这样就能为位于任一坐标轴上的点打印相同的信息:

let locatePoint =
  function
  | (0, 0) as p -> sprintf "%A is at the origin" p
  | ① (_, 0) | ② (0, _) as p -> ③ sprintf "%A is on an axis" p
  | p -> sprintf "Point %A" p

在这个版本的locatePoint中,当①或②处的模式满足时,③处的表达式会被求值。

模式中的括号

在组合模式时,你可以通过括号来确定优先级。例如,要从一个点中提取xy的值,并且匹配该点是否位于任一坐标轴上,你可以写出类似这样的代码:

let locatePoint =
  function
  | (0, 0) as p -> sprintf "%A is at the origin" p
  | (x, y) & ① ((_, 0) | (0, _)) -> sprintf "(%i, %i) is on an axis" x y
  | p -> sprintf "Point %A" p

在这里,你匹配了三个模式,通过将两个坐标轴检查模式用括号括起来,在①处建立了结合性。

活跃模式

当内置的模式类型无法完全满足需求时,你可以使用活跃模式。活跃模式是一种特殊的函数定义,称为活跃识别器,在这种模式下,你定义一个或多个案例名称,以便在模式匹配表达式中使用。

活动模式具有许多与内置模式类型相同的特征;它们接受一个输入值,并能将该值分解为其组成部分。然而,与基本模式不同的是,活动模式不仅允许你定义每个命名情况的匹配条件,还可以接受其他输入。

活动模式的定义语法如下:

let (|CaseName1|CaseName2|...|CaseNameN|) [parameters] -> expression

如你所见,情况名称被包含在 (||) 之间(称为 香蕉夹),并且以管道符分隔。活动模式定义必须至少包括一个参数用于匹配值,并且由于活动识别器函数是柯里化的,匹配的值必须是最终参数,以便与匹配表达式正确配合。最后,表达式的返回值必须是其中一个命名的情况,并附带任何相关的数据。

活动模式有许多用途,但一个好的例子是可能解决著名的 FizzBuzz 问题。对于那些未接触过的人,FizzBuzz 是一个面试中雇主有时用来筛选候选人的谜题。问题的核心任务很简单,通常表述如下:

编写一个程序,打印从 1 到 100 的数字。但是,对于 3 的倍数,打印 "Fizz" 代替数字;对于 5 的倍数,打印 "Buzz"。对于同时是 3 和 5 的倍数的数字,打印 "FizzBuzz"

明确来说,活动模式当然不是解决 FizzBuzz 问题的唯一方式(也不一定是最好的方式)。但是,FizzBuzz 问题—其包含多个重叠的规则—使我们能够展示活动模式的强大。

我们可以从定义活动识别器开始。从前面的描述中,我们知道需要四个模式:FizzBuzzFizzBuzz,以及一个默认情况用于其他所有情况。我们还知道每种情况的标准,因此我们的识别器可能长得像这样:

let (|Fizz|Buzz|FizzBuzz|Other|) n =
  match ① (n % 3, n % 5) with
  | ② 0, 0 -> FizzBuzz
  | ③ 0, _ -> Fizz
  | ④ _, 0 -> Buzz
  | ⑤ _ -> Other n

这里我们有一个活动识别器,它定义了四个情况名称。识别器的主体依赖于进一步的模式匹配来选择适当的情况。在 ① 处,我们构造一个元组,包含 n 对 3 和 5 的取余值。然后,我们使用一系列元组模式来识别正确的情况,最具体的是 ②,其中两个元素都为 0。③ 和 ④ 处的情况分别匹配当 n 能被 3 和 5 整除时。最后一个情况⑤,使用通配符模式来匹配所有其他情况,并返回 Other 以及提供的数字。尽管活动模式让我们解决了一部分问题,但我们仍然需要打印结果。

活动识别器仅识别给定数字符合哪种情况,因此我们仍然需要一种方法将每个情况转换为字符串。我们可以使用像这样的模式匹配函数轻松地映射这些情况:

let fizzBuzz =
  function
  | Fizz -> "Fizz"
  | Buzz -> "Buzz"
  | FizzBuzz -> "FizzBuzz"
  | Other n -> n.ToString()

上面的fizzBuzz函数使用了基本的模式匹配,但它没有使用内置的模式,而是使用了由活动识别器定义的模式。注意Other案例包含了一个变量模式n,用于保存与其关联的数字。

最后,我们可以通过打印结果来完成任务。我们可以用命令式的方式来做,但因为函数式编程更有趣,所以我们用类似这样的序列:

seq { 1..100 }
|> Seq.map fizzBuzz
|> Seq.iter (printfn "%s")

在这里,我们创建一个包含 1 到 100 的数字的序列,并将其传递给Seq.map,后者创建一个包含从fizzBuzz返回的字符串的新序列。然后,将生成的序列传递给Seq.iter以打印每个值。

部分活动模式

尽管活动模式很方便,但它们确实有一些缺点。首先,每个输入必须映射到一个命名的案例。其次,活动模式最多只能有七个命名案例。如果你的情况不需要映射每个可能的输入,或者你需要超过七个案例,你可以转向部分活动模式。

部分活动模式的结构与完全活动模式相同,但它们不包括案例名称的列表,而是只包括一个单一的案例名称,后跟一个下划线。部分活动模式的基本语法如下:

let (|CaseName|_|) [parameters] = expression

部分活动模式返回的值与完全活动模式有所不同。它们不会直接返回案例,而是返回模式类型的一个选项。例如,如果你有一个Fizz的部分活动模式,表达式需要返回Some(Fizz)None。不过,对于匹配表达式来说,选项是透明的,因此你只需要处理案例名称。

注意

如果你正在 F#交互式窗口中跟进,建议在继续下一个示例之前重置会话,以避免活动模式之间可能发生的命名冲突。

为了看到部分活动模式的实际应用,我们可以回到 FizzBuzz 问题。使用部分活动模式可以让我们更简洁地重写解决方案。我们可以像这样定义部分活动模式:

let (|Fizz|_|) n = if n % 3 = 0 then Some Fizz else None
let (|Buzz|_|) n = if n % 5 = 0 then Some Buzz else None

在阅读上面的代码片段后,你可能首先想到的是“为什么只有两个案例,而问题明确指定了三个?”原因是部分活动模式是独立评估的。因此,为了满足要求,我们可以构造一个匹配表达式,使得一个案例同时匹配FizzBuzz,使用一个 AND 模式,如下所示:

let fizzBuzz =
  function
  | Fizz & Buzz -> "FizzBuzz"
  | Fizz -> "Fizz"
  | Buzz -> "Buzz"
  | n -> n.ToString()

现在,剩下的就是像之前一样打印所需的值:

seq { 1..100 }
|> Seq.map fizzBuzz
|> Seq.iter (printfn "%s")

参数化活动模式

到目前为止,我们看到的所有活动模式都只接受单一的匹配值;我们还没有看到接受额外参数来帮助匹配的模式。记住,活动识别器函数是柯里化的,因此,要在活动模式定义中包括额外的参数,你需要在匹配输入参数之前列出它们。

仅使用一个参数化部分激活模式,也可以构造另一种 FizzBuzz 问题的解决方案。考虑以下定义:

let (|DivisibleBy|_|) d n = if n % d = 0 then Some DivisibleBy else None

这个部分激活模式看起来与我们在上一节中定义的FizzBuzz部分激活模式完全相同,唯一的区别是它包括了d参数,并在表达式中使用它。现在我们可以使用这个模式从任何输入中解析出正确的单词,如下所示:

let fizzBuzz =
  function
  | DivisibleBy 3 & DivisibleBy 5 -> "FizzBuzz"
  | DivisibleBy 3 -> "Fizz"
  | DivisibleBy 5 -> "Buzz"
  | n -> n.ToString()

现在,我们不再为FizzBuzz编写专门的案例,而是通过参数化模式匹配输入是否能被三或五整除。输出结果与之前没有区别:

seq { 1..100 }
|> Seq.map fizzBuzz
|> Seq.iter (printfn "%s")

总结

模式匹配是 F#最强大、最灵活的特性之一。尽管它在表面上与其他语言中的基于案例的分支结构有些相似,但 F#的匹配表达式完全是另一种形式。模式匹配不仅提供了一种表达式丰富的方式来匹配和分解几乎任何数据类型,甚至还能够返回值。

在本章中,你学习了如何直接使用match...with构造匹配表达式,以及如何间接使用function关键字。你还看到简单的模式类型,如通配符、变量和常量模式,如何独立使用或与记录和列表等更复杂的模式结合使用。最后,你学习了如何使用完全和部分激活模式创建自己的自定义模式。

第八章:测量标准

在一个长而复杂的计算机程序中,混淆测量单位是非常容易发生的。当这种混淆发生时,后果可能是极其昂贵的,甚至是悲剧性的。最著名的例子之一是 1999 年 NASA 的火星气候轨道探测器坠毁事件。事故调查揭示,坠毁的原因是单位不匹配;使用了磅力秒而不是牛顿秒。这一错误导致了不正确的轨迹计算,最终导致了探测器的毁灭。

可以争辩说,适当的测试应该能检测到计算错误,从而避免坠毁,但更大的问题是,如果编程语言通过其类型系统强制使用正确的单位,这个错误是否根本不会发生。

多年来,人们一直在尝试在软件系统中强制使用测量单位,通常通过外部库来实现,并且成功程度不一。F#是最早将测量单位作为其静态类型检查系统的原生部分之一的编程语言之一。除了提供比基本类型系统更高的安全性外,F#的测量单位还可以通过消除关于代码中实际期望内容的模糊性来增强代码的可读性,而无需依赖更长的标识符。

定义度量

为了启用静态测量检查,你首先需要定义一个度量。度量是类似类型的构造,带有Measure属性来表示实际世界中的测量。它们可以包含一个可选的测量公式,通过其他度量来描述该度量。例如,以下定义创建了一个名为英尺的度量单位:

[<Measure>] type foot

国际单位制

F# 3.0 包含了国际单位制(SI)单位的预定义度量类型,包括米、千克和安培等。你可以在Microsoft.FSharp.Data.UnitSystems命名空间中找到每个 SI 单位。在 F# 3.0 之前,SI 单位包含在 F# PowerPack 中,并可以在Microsoft.FSharp.Math命名空间中找到。

测量公式

测量公式允许你基于一个或多个先前定义的度量来定义派生度量。最基本的情况是,公式作为一种简单的方式为类型创建同义词。例如,如果你已经定义了一个名为foot的度量,并希望将其缩写为ft,你可以这样写:

[<Measure>] type ft = foot

然而,测量公式并不总是那么简单;它们也可以用来描述类型之间更复杂的关系,例如距离与时间的关系。例如,英里每小时可以定义为m / h(假设mh之前已分别定义为英里和小时)。

在编写测量公式时,以下是一些最重要的指南:

  • 你可以通过用空格或星号(*)分隔两个度量来乘度量,从而创建一个积度量。例如,扭矩有时以磅-英尺为单位,且可以在 F#中表示为:

    [<Measure>] type lb
    [<Measure>] type ft
    [<Measure>] type lbft = lb ft
    
  • 你可以通过用斜杠(/)分隔两个度量来除度量,从而创建一个商度量。例如,按时间计算的距离,如每小时多少英里,可以这样表示:

    [<Measure>] type m
    [<Measure>] type h
    [<Measure>] type mph = m / h
    
  • 正整数和负整数值可以用来表示两个度量之间的指数关系。例如,平方英尺可以这样表示:

    [<Measure>] type ft
    [<Measure>] type sqft = ft ^ 2
    

应用度量

一旦你定义了一些度量,你就可以将它们应用于值。F#默认定义了带度量的sbyteint16int32int64floatfloat32decimal原始类型。没有度量注释的值称为无度量无量纲

要将度量应用于常量值,你只需将值注释为该度量,就像将度量作为泛型类型参数一样。例如,你可以按如下方式定义一个以英尺为单位的长度和以平方英尺为单位的面积:

> **let length = 10.0<ft>**
**let area = 10.0<sqft>;;**

val length : float<ft> = 10.0
val area : float<sqft> = 10.0

如你所见,length绑定到float<ft>,而area绑定到float<sqft>

星星去哪儿了?

尽管度量单位在 F#的类型系统中起着重要作用,但它们在编译过程中会被擦除,因此对编译后的代码没有影响。这并不是说度量类型在编译后的程序集内不存在;它只是意味着它们没有附加到任何单独的值上。擦除的最终结果是,度量单位只能在 F#代码中强制执行,而任何其他语言编写的程序集使用的度量感知函数或类型将被视为无度量。

度量注释非常适合常量值,但我们如何将度量应用于外部数据(例如从数据库读取的数据)呢?将无度量值转换为有度量值的最简单方法是将其乘以一个有度量的值,像这样:

[<Measure>] type dpi
let resolution = 300.0 * 1.0<dpi>

在这里,我们定义了一个表示每英寸点数(dpi)的度量,并通过将300.0乘以1.0<dpi>来创建分辨率。

对于一个更为冗长的替代方案,你可以使用LanguagePrimitives模块中的七个WithMeasure函数之一。每个WithMeasure函数对应于一个测量的原语类型。下面是如何使用FloatWithMeasure函数创建一个新的测量值:

[<Measure>] type dpi
let resolution = LanguagePrimitives.FloatWithMeasure<dpi> 300.0

WithMeasure函数在其意图上稍微显得更为明确,并且显然更为冗长。通常,它们的使用保留在类型推断失败时。

去除度量

绝大多数函数不接受带有单位的值,因此你可能需要从值中去除度量。幸运的是,像应用度量一样,去除度量也很简单。

去除度量的典型方法是简单地将值除以一个度量为1的数值,像这样:

[<Measure>] type dpi
300.0<dpi> / 1.0<dpi>

另外,你可以使用相应的类型转换运算符来达到相同的效果。例如,我们可以通过调用float函数来去除300.0<dpi>的单位,如下所示:

[<Measure>] type dpi
float 300.0<dpi>

强制措施

由于度量单位是 F#类型系统的一部分,你可以通过参数上的类型注解来强制传递给函数的值使用正确的单位。在这里,我们定义了一个getArea函数,要求传入的宽度和高度必须以英尺为单位:

> **let getArea (w : float<ft>) (h : float<ft>) = w * h;;**

val getArea : w:float<ft> -> h:float<ft> -> float<ft ^ 2>

如果你使用无单位的参数调用getArea,如图所示,你将收到以下错误:

> **getArea 10.0 10.0;;**

  getArea 10.0 10.0;;
  --------^^^^

C:\Users\Dave\AppData\Local\Temp\stdin(9,9): error FS0001: This expression was expected to have type
    float<ft>
but here has type
    float

同样,如果你使用带有错误度量(或没有度量单位)注解的参数调用getArea,将导致编译器错误。要正确调用getArea函数,你必须提供正确单位的值,如下所示:

> **getArea 10.0<ft> 10.0<ft>;;**
val it : float<ft ^ 2> = 100.0

请注意,尽管我们已将sqft定义为ft ^ 2,但函数的返回值是float<ft ^ 2>。编译器不会自动转换度量单位,除非通过返回类型注解明确指示进行转换,如下所示:

> **let getArea (w : float<ft>) (h : float<ft>) : float<sqft> = w * h;;**

val getArea : w:float<ft> -> h:float<ft> -> float<sqft>

> **getArea 10.0<ft> 10.0<ft>;;**
val it : float<sqft> = 100.0

范围

在范围表达式中是允许使用带单位的度量单位的,但有一个限制:你必须提供步长值。要创建带单位的范围,你可以像这样写:

> **let measuredRange = [1.0<ft>..1.0<ft>..10.0<ft>];;**

val measuredRange : float<ft> list =
  [1.0; 2.0; 3.0; 4.0; 5.0; 6.0; 7.0; 8.0; 9.0; 10.0]

如果没有明确的步长值,编译器将尝试使用底层类型的默认无单位值来创建范围,并会抛出错误。

度量单位之间的转换

尽管度量公式允许你创建导出单位,但它们实际上没有足够的灵活性来支持度量单位之间的任意转换。为了绕过这个限制,你可以为度量类型定义静态成员,用于转换因子和函数。

静态转换因子

在度量类型上定义转换因子与定义静态属性的语法相同。例如,由于每英尺有 12 英寸,你可以像这样写:

[<Measure>] type ft
[<Measure>] type inch = static member perFoot = 12.0<inch/ft>

perFoot转换可以通过inch类型访问,像访问任何静态属性一样。要将英尺转换为英寸,你需要将以英尺为单位的值乘以inch.perFoot,如下所示:

> **2.0<ft> * inch.perFoot;;**
val it : float<inch> = 24.0

注意,编译器如何通过乘法操作推断结果应该以英寸为单位。类似地,我们可以通过将以英寸为单位的值除以inch.perFoot来将英寸转换为英尺:

> **36.0<inch> / inch.perFoot;;**
val it : float<ft> = 3.0

静态转换函数

当你需要的不仅仅是转换因子时,你可以直接在度量类型上定义静态转换函数(及其逆转换)。在两个度量类型上始终如一地定义转换函数有助于避免混淆它们的定义位置。

为了最大化代码重用,你可以通过使用and关键字将度量类型定义为相互递归的类型。在这里,我们将华氏度和摄氏度的度量定义为相互递归的类型:

[<Measure>]
type f =
  static member toCelsius (t : float<f>) = ((float t - 32.0) * (5.0/9.0)) * 1.0<c>
  static member fromCelsius (t : float<c>) = ((float t * (9.0/5.0)) + 32.0) * 1.0<f>
and
  [<Measure>]
  c =
    static member toFahrenheit = f.fromCelsius
    static member fromFahrenheit = f.toCelsius

华氏度度量包含用于转换为摄氏度和从摄氏度转换回来的函数。同样,摄氏度度量也包含用于转换为华氏度和从华氏度转换回来的函数,但通过相互递归定义,它可以重用华氏度类型上定义的函数。

根据你的度量定义或转换函数的复杂性,你可能会发现将类型独立定义,然后通过内建类型扩展添加静态方法会更清晰。以下代码片段展示了一种可能的方法:

[<Measure>] type f
[<Measure>] type c

let fahrenheitToCelsius (t : float<f>) =
  ((float t - 32.0) * (5.0/9.0)) * 1.0<c>

let celsiusToFahrenheit (t : float<c>) =
  ((float t * (9.0/5.0)) + 32.0) * 1.0<f>

type f with static member toCelsius = fahrenheitToCelsius
            static member fromCelsius = celsiusToFahrenheit

type c with static member toFahrenheit = celsiusToFahrenheit
            static member fromFahrenheit = fahrenheitToCelsius

在这里,度量类型是独立定义的(没有相互递归),并紧跟着转换函数。由于转换函数没有附加到度量类型上,我们通过扩展度量类型并添加静态属性来公开这些转换函数。

通用度量

你已经看到了许多如何为特定度量类型编写度量感知函数的例子,但也可以使用通用度量编写针对任意度量的函数。编写这样的函数与为特定度量类型编写函数相同,只不过你不使用具体的单位值,而是使用下划线字符(_)。或者,当你的函数接受多个必须使用相同通用度量类型的参数时,你可以使用通用标识符(例如'U)代替下划线。

当你需要针对多种度量执行相同操作时,可能会使用通用度量。例如,你可以编写一个计算任意测量值float平方的函数,代码如下:

let square (v : float<_>) = v * v

因为square被定义为使用通用度量,所以它的参数可以接受任何度量类型。事实上,它的参数甚至可以是没有度量的。在这里,我们使用平方函数来计算平方英寸、平方英尺和无度量的平方:

> **square 10.0<inch>;;**
val it : float<inch ^ 2> = 100.0
> **square 10.0<ft>;;**
val it : float<ft ^ 2> = 100.0
> **square 10.0;;**
val it : float = 100.0

自定义度量感知类型

你可以通过定义一个带有Measure属性的类型参数来创建你自己的度量感知类型。考虑以下记录类型:

type Point< ① [<Measure>] 'u > = { X : ② float<'u>; Y : ③ float<'u> } with
  member ④ this.FindDistance other =
    let deltaX = other.X - this.X
    let deltaY = other.Y - this.Y
    sqrt ((deltaX * deltaX) + (deltaY * deltaY))

Point类型的行为与其他记录类型相同,只是它的成员被定义为通用度量。Point不只是处理没有度量的浮动值,而是包含一个度量'u①,X②和Y③使用此度量。Point还定义了一个FindDistance函数④,该函数执行度量安全计算,以查找两个点之间的距离。这里我们创建了一个Point实例,并对另一个Point调用FindDistance函数:

> **let p = { X = 10.0<inch>; Y = 10.0<inch> }**
**p.FindDistance { X = 20.0<inch>; Y = 15.0<inch> };;**

val p : Point<inch> = {X = 10.0;
                       Y = 10.0;}
val it : float<inch> = 11.18033989

如果你尝试用使用不同度量单位的Point调用FindDistance,编译器会抛出类似这样的类型不匹配错误:

> **p.FindDistance { X = 20.0<ft>; Y = 15.0<ft> };;**

  p.FindDistance { X = 20.0<ft>; Y = 15.0<ft> };;
  ---------------------^^^^^^^^

C:\Users\Dave\AppData\Local\Temp\stdin(5,22): error FS0001: Type mismatch. Expecting a
    float<inch>
but given a
    float<ft>
The unit of measure 'inch' does not match the unit of measure 'ft'

自定义度量感知类型也不限于记录类型。例如,你可以像这样定义一个等效的度量感知类:

type Point< [<Measure>] 'u > (x : float<'u>, y : float<'u>) =
  member this.X = x
  member this.Y = y
  member this.FindDistance (other : Point<'u>) =
    let deltaX = other.X - this.X
    let deltaY = other.Y - this.Y
    sqrt ((deltaX * deltaX) + (deltaY * deltaY))

总结

大多数编程语言依赖程序员的自律来确保度量单位的正确和一致使用。F# 帮助开发者生成更准确代码的独特方式之一,就是通过在其类型系统中直接包含丰富的度量单位语法。

F# 不仅包括国际单位制(SI)的预定义度量类型,而且还允许你定义自己的度量单位。你可以通过在常量值上添加适当的度量单位注解,或在函数定义中将其包含在类型注解中,从而强制使用正确的度量单位进行计算。最后,你还可以使用类似泛型的语法定义自己的度量单位感知类型。

第九章. 我能引用你说的这些吗?

LINQ 引入 .NET Framework 的另一个特性是表达式树。通常使用与 lambda 表达式相同的语法,表达式树 编译的不是可执行代码,而是一个描述代码的树结构,并可以被解析以转换成其他形式。这种编程方式通常被称为 元编程。就像我们可以将元数据视为描述数据的数据一样,我们也可以将元编程视为描述代码的代码。

本章并不是关于表达式树的;它讨论的是 F# 中类似的结构,叫做 引用表达式,也称为 代码引用。引用表达式解决了与表达式树相同的基本问题,但采取了根本不同的方法。在深入探讨如何在 F# 代码中构造和解析引用表达式之前,让我们快速比较一下表达式树与引用表达式。

比较表达式树和引用表达式

表达式树常常与 LINQ 提供者一起使用,用于将某些 C# 或 Visual Basic 表达式转换为 SQL,但它们不仅仅用于语言间的代码转换。有时,表达式树也被用来为本来可能令人困惑或容易出错的代码增加额外的安全性或可读性。考虑一下在 WPF 和 Silverlight 中常用的 INotifyPropertyChanged 接口。

INotifyPropertyChanged 定义了一个成员:一个带有字符串参数 PropertyName 的事件,该参数标识了发生变化并触发事件的属性。你可以通过创建一个 PropertyChangedEventArgs 实例,并将属性名作为字符串传递给构造函数来触发 PropertyChanged 事件。然而,这种方法容易出错:因为在传递给 PropertyChangedEventArgs 构造函数的字符串没有内在的检查,可能会提供一个无效的名称。表达式树可以帮助避免像这样的错误,如下所示的 C# 类,利用表达式树安全地识别更改的属性,而无需依赖大量的反射代码:

// C#
public class PropertyChangedExample
  : INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged;

  private string _myProperty = String.Empty;

  public string MyProperty
  {
    get { return _myProperty; }
    set
    {
      _myProperty = value;
      RaisePropertyChangedEvent(①() => MyProperty);
    }
  }

  protected void RaisePropertyChangedEvent<TValue>(
    ② Expression<Func<TValue>> propertyExpr)
  {
   if(PropertyChanged == null) return;

   var memberExpr = ③(MemberExpression)propertyExpr.Body;
   var ea = new PropertyChangedEventArgs(④ memberExpr.Member.Name);

   PropertyChanged(this, ea);
  }
}

上面的示例展示了实现 INotifyPropertyChanged 的典型模式的一个变化。它并没有像通常那样传递一个魔法字符串给 RaisePropertyChangedEvent 方法①,而是使用了一个 lambda 表达式。然而,这个 lambda 表达式并没有编译成一个委托。相反,C# 编译器通过签名推断出应该将该 lambda 表达式编译为表达式树②。在方法内部,我们随后将表达式的主体强制转换为 MemberExpression,在③处提取属性名称,并将其传递给 PropertyChangedEventArgs 在④处。

引用表达式在 F# 中的作用类似,但与表达式树不同,它们在设计时强调了函数式编程,而不仅仅是它们的构造方式,还包括它们的解析方式。此外,表达式树并不支持许多 F# 中的重要概念。相比之下,引用表达式完全理解诸如柯里化、部分应用和递归声明(let rec)等概念。最后,引用表达式设计为递归解析,这使得遍历整个引用结构几乎变得微不足道。

你可以按如下方式使用引用表达式将前面的 C# 类重写为 F#:

// F#
open Microsoft.FSharp.Quotations
open Microsoft.FSharp.Quotations.Patterns
open System.ComponentModel

type PropertyChangedExample() as x =
  let pce = Event<_, _>()
  let mutable _myProperty = ""
① let triggerPce =
    function
    | ② PropertyGet(_, pi, _) ->
        let ea = PropertyChangedEventArgs(pi.Name)
        pce.Trigger(x, ea)
    | _ -> failwith "PropertyGet quotation is required"
  interface INotifyPropertyChanged with
    [<CLIEvent>]
    member x.PropertyChanged = pce.Publish
  member x.MyProperty with get() = _myProperty
                      and set(value) = _myProperty <- value
                                       triggerPce(③ <@@ x.MyProperty @@>)

这个修订版本的 PropertyChangedExample 类结构与 C# 版本非常相似。如同 C# 版本一样,PropertyChangedEvent 并未直接公开。相反,位于①的 triggerPce 函数接受一个引用表达式,并使用模式匹配来判断提供的引用表达式是否代表获取一个属性的值(如②所示)。最后,在对 triggerPce 的调用中,③的 lambda 表达式被引用表达式取代,且该引用表达式以 <@@@@> 包裹属性引用的形式呈现。通过使用引用表达式,我们允许编译器判断所提供的属性是否有效,而不是希望自己输入正确的名称。以这种方式使用引用表达式还能防止未来重构时,我们移除或重命名属性却忘记更新字符串的问题。

尽管引用表达式和表达式树有许多相似之处,但它们并不完全相同。首先,没有内建的方式来评估引用表达式,也没有内建的方式将引用表达式转换为表达式树。如果你需要执行这两项任务,你将需要依赖 F# PowerPack,或其他提供这些功能的库。然而,随着 F# 3.0 引入查询表达式(第十章),这些需求应该会减少。

组合引用表达式

引用表达式可以有两种形式:强类型和弱类型。两者之间的区别有些误导,因为所有的引用表达式最终都是基于 Expr<'T>Expr 类型,这些类型位于 Microsoft.FSharp.Quotations 命名空间中。在这个上下文中,强类型和弱类型实际上是指引用是否包含关于表达式类型的信息,而不是通过其组成部分来描述表达式。你可以通过其 Raw 属性从强类型的引用表达式获取一个弱类型的引用表达式。

除了 ExprExpr<'T> 类型之外,Microsoft.FSharp.Quotations 命名空间还包含 Var 类型。Var 类型用于引用表达式中,用来描述绑定信息,包括绑定名称、数据类型以及绑定是否可变。

无论引用表达式是强类型还是弱类型,所有引用表达式都有一些约束条件。首先,引用中禁止出现对象表达式。其次,引用不能解析为泛型表达式。最后,引用必须是一个完整的表达式;即,引用必须做的不仅仅是定义一个 let 绑定。尝试创建一个违反任何这些条件的引用表达式将导致编译错误。

引用字面量

要创建一个引用表达式,您只需要将表达式包含在 <@@><@@@@> 之间,其中第一种形式创建一个强类型的引用表达式,第二种形式创建一个弱类型的引用表达式。例如,要创建一个表示乘法的强类型引用表达式,您可以像这样编写:

> **open Microsoft.FSharp.Quotations**
**let x, y = 10, 10**
**let expr = <@ x * y @>;;**

val x : int = 10
val y : int = 10
val expr : ① Expr<int> =
  Call (None, op_Multiply, [PropertyGet (None, x, []), PropertyGet (None, y, [])])

在上面的代码片段中,引用表达式的底层类型是 ① Expr<int>。在这种情况下,编译器推断引用表达式的类型为 int,并将该类型与表达式一起传递。该表达式的值是源表达式组成元素的列表。稍后我们将深入分析这些部分的含义以及如何使用它们来分解引用表达式。

引用表达式可以像前面的例子一样简单,但也可以表示更复杂的表达式,包括 lambda 表达式。例如,一个乘法两个整数的 lambda 表达式可以像这样被引用:

> **open Microsoft.FSharp.Quotations**
**let expr = <@ fun a b -> a * b @>;;**

val expr : Expr<(int -> int -> int)> =
  Lambda (a, Lambda (b, Call (None, op_Multiply, [a, b])))

同样,您可以在一个引用表达式中包含多个表达式。在这里,定义了一个 let 绑定的函数,并将其应用于两个整数值:

> **let expr = <@ let mult x y = x * y**
                 **mult 10 20 @>;;**

val expr : Quotations.Expr<int> =
  Let (mult, Lambda (x, Lambda (y, Call (None, op_Multiply, [x, y]))),
     Application (Application (mult, Value (10)), Value (20)))

.NET 反射

创建引用表达式的另一种方式是通过标准 .NET 反射。通常,引用表达式是从不可执行的代码中创建的,但有时您可能会发现,您已经定义了一个包含要引用的代码的函数。与其复制代码,您可以使用 ReflectedDefinition 属性装饰该函数:

type Calc =
  [<ReflectedDefinition>]
  static member Multiply x y = x * y

在这里,Multiply 正常编译,因此可以直接调用,但 ReflectedDefinition 属性指示编译器还需要生成一个弱类型的引用表达式,并将结果嵌入编译后的程序集。要访问生成的引用表达式,您需要获取一个表示编译方法的标准反射 MethodInfo 对象,并将其传递给 Expr 类的静态方法 TryGetReflectedDefinition

> **let expr =**
  **typeof<Calc>**
    **.GetMethod("Multiply")**
  **|> Expr.TryGetReflectedDefinition;;**

val expr : Expr option =
  Some Lambda (x, Lambda (y, Call (None, op_Multiply, [x, y])))

当您需要在一个类型中引用多个值时,给每个值加上 ReflectedDefinition 属性可能会显得繁琐。幸运的是,您也可以将该属性应用于模块和类型,以分别为它们的每个值或成员生成引用表达式。

手动组合

构建引用表达式的最终方法是通过链式调用Expr类型的静态方法手动构建一个表达式。Expr类型定义了 40 多个方法来创建新的Expr实例,每个实例表示在引用表达式中可能出现的各种构造。

Expr方法的定义方式使得它们的目的应该已经非常清楚,既然你已经了解了 F#中的数据结构和语言构造,我就不再详细讲解每一个方法了。不过有两点是非常重要的需要注意的。

首先,方法参数是元组形式的,因此不同于柯里化多个参数,它们必须以元组形式提供。其次,许多方法——近 50%的方法——使用.NET 反射来构造相应的表达式。

手动构建引用表达式可能很繁琐,但它能给你最大程度的控制权,来决定表达式的构建方式。更重要的是,这些方法允许你基于你无法控制的代码来构建引用表达式,因此这些代码无法装饰上ReflectedDefinition属性。

为了演示手动构建引用表达式的过程,让我们通过构建一个使用乘法操作符将两个值相乘的方法来逐步实现。首先,我们需要使用反射来访问定义乘法操作符的Operators模块,如下所示:

let operators =
  System.Type.GetType("Microsoft.FSharp.Core.Operators, FSharp.Core")

这个绑定使用部分限定名称来标识我们正在寻找的类型。(我们不得不在这里使用反射,因为typeof<'T>typedefof<'T>在模块上不起作用。)现在,我们已经有了对Operators模块的引用,可以通过方法名称op_Multiply使用GetMethod方法获取对乘法操作符方法的引用:

let multiplyOperator = operators.GetMethod("op_Multiply")

接下来,我们检查返回的MethodInfo以获取操作符的每个参数。为了将这些参数包含在我们的表达式中,我们需要从相应的PropertyInfo实例创建Var实例。我们可以通过使用Array.map函数轻松地将每个参数进行转换。为了方便起见,我们还可以使用数组模式将结果数组转换为元组,如下所示:

let varX, varY =
  multiplyOperator.GetParameters()
  |> Array.map (fun p -> Var(p.Name, p.ParameterType))
  |> (function | [| x; y |] -> x, y
               | _ -> failwith "not supported")

我们现在已经有足够的信息来构建引用表达式:

let call = Expr.Call(multiplyOperator, [ Expr.Var(varX); Expr.Var(varY) ])
let innerLambda = Expr.Lambda(varY, call)
let outerLambda = Expr.Lambda(varX, innerLambda)

前面的绑定逐步构建了一个引用表达式,表示一个柯里化的函数,该函数用于将两个值相乘。正如你所看到的,引用表达式包含了乘法操作符的方法调用,一个内部的 lambda 表达式应用了y值,还有一个外部的 lambda 表达式应用了x值。如果你检查outerLambda的值,你应该会看到如下表示的结果表达式:

val outerLambda : Expr =
  Lambda (x, Lambda (y, Call (None, op_Multiply, [x, y])))

经过这么多工作,我们终于得到了一个等价于这个弱类型表达式的引用表达式:

<@@ fun x y -> x * y @@>

为了方便起见,我在这里完整地包含了之前的示例,你可以看到所有部分如何协同工作。

let operators =
  System.Type.GetType("Microsoft.FSharp.Core.Operators, FSharp.Core")
let multiplyOperator = operators.GetMethod("op_Multiply")
let varX, varY =
  multiplyOperator.GetParameters()
  |> Array.map (fun p -> Var(p.Name, p.ParameterType))
  |> (function | [| x; y |] -> x, y
               | _ -> failwith "not supported")

let call = Expr.Call(multiplyOperator, [ Expr.Var(varX); Expr.Var(varY) ])
let innerLambda = Expr.Lambda(varY, call)
let outerLambda = Expr.Lambda(varX, innerLambda)

引用表达式拼接

如果你需要合并多个引用表达式,你可以通过将每个引用表达式传递给 Expr 类上的适当静态方法(通常是 Call)手动构建一个新的引用表达式,但有一种更简单的方法:你可以通过使用拼接运算符将它们拼接在一起,从而创建一个新的字面量引用表达式。例如,假设你有以下序列和强类型引用表达式:

let numbers = seq { 1..10 }
let sum = <@ Seq.sum numbers @>
let count = <@ Seq.length numbers @>

你可以将 sumcount 合并成一个新的引用表达式,表示通过强类型拼接运算符 (%) 计算序列的平均值,如下所示:

let avgExpr = <@ %sum / %count @>

弱类型引用表达式也可以进行拼接。如果 sumcount 被定义为弱类型引用表达式(通过 <@@ ... @@> 语法),你可以使用弱类型拼接运算符 (%%) 进行拼接,如下所示:

let avgExpr = <@@ %%sum / %%count @@>

引用表达式的分解

虽然代码引用有助于你理解代码的结构,但它们的主要优势在于分解。F# 包含三个模块,这些模块也位于 Microsoft.FSharp.Quotations 命名空间中,定义了大量的完整和部分活跃模式,你可以使用它们将引用的表达式按不同粒度的程度分解为其组成部分。

  • Pattern 模块Pattern 模块中的部分活跃模式匹配 F# 语言的基本特性,如函数调用、函数应用、循环结构、原始值、绑定定义和对象创建。它们几乎一对一地对应于 Expr 类型上定义的函数,帮助你识别在最常见的表达式中使用哪个模式。

  • DerivedPatterns 模块DerivedPatterns 模块包含部分活跃模式,主要用于匹配表示原始字面量的引号表达式、基本布尔运算符(如 &&||)以及使用 ReflectedDefinition 装饰的结构。

  • ExprShape 模块ExprShape 模块定义了一个完整的活跃模式,包含三个情况:ShapeVarShapeLambdaShapeCombination。它设计用于递归模式匹配,因此你可以轻松地遍历引用的表达式,在整个过程中匹配每一个表达式。

引用表达式解析

与其详细讲解每个模块中定义的具体活跃模式,我认为更有帮助的是看看它们如何协同工作。我们将从一个典型示例开始,使用每个模块中的一些模式来构建一个表示 F# 引用语法的字符串。

open System.Text
open Microsoft.FSharp.Quotations.Patterns
open Microsoft.FSharp.Quotations.DerivedPatterns
open Microsoft.FSharp.Quotations.ExprShape

let rec showSyntax =
  function
  | Int32 v ->
      sprintf "%i" v
  | Value (v, _) ->
      sprintf "%s" (v.ToString())
  | SpecificCall <@@ (+) @@> (_, _, exprs) ->
      let left = showSyntax exprs.Head
      let right = showSyntax exprs.Tail.Head
      sprintf "%s + %s" left right
  | SpecificCall <@@ (-) @@> (_, _, exprs) ->
      let left = showSyntax exprs.Head
      let right = showSyntax exprs.Tail.Head
      sprintf "%s - %s" left right
  | Call (opt, mi, exprs) ->
      let owner = match opt with
                  | Some expr -> showSyntax expr
                  | None -> sprintf "%s" mi.DeclaringType.Name
      if exprs.IsEmpty then
        sprintf "%s.%s ()" owner mi.Name
      else
        let sb = StringBuilder(showSyntax exprs.Head)
        exprs.Tail
        |> List.iter (fun expr ->
                           sb
                             .Append(",")
                             .Append(showSyntax expr) |> ignore)
        sprintf "%s.%s (%s)" owner mi.Name (sb.ToString())
  | ShapeVar var ->
      sprintf "%A" var
  | ShapeLambda (p, body) ->
      sprintf "fun %s -> %s" p.Name (showSyntax body)
  | ShapeCombination (o, exprs) ->
      let sb = StringBuilder()
      exprs |> List.iter (fun expr -> sb.Append(showSyntax expr) |> ignore)
      sb.ToString()

上面的示例可能看起来令人畏惧,但尽管包含了许多匹配案例,实际上当你把它拆开看时,它并不是特别复杂。首先要注意的是,showSyntax函数是递归的,这使得我们能够遍历树状结构中的任何嵌套表达式。每个匹配案例都属于三个引号表达式模块之一,并且匹配特定类型的表达式。我不会详细介绍每个案例的主体,因为它们没有引入新的概念,但我鼓励你尝试实验。

前两个案例,Int32Value,匹配单个字面值。Int32模式是一个派生模式,只匹配整数值,而Value是一个基础模式,匹配任何字面值。从定义中可以看出,这两个模式都提取了字面值。Value模式还会提取相应的数据类型,但由于我们在这里没有使用它,我们仅用通配符模式将其丢弃。

紧接着Value案例后面是两个SpecificCall案例和一个通用的Call案例。SpecificCall案例是派生的模式,分别匹配加法和减法运算符的调用(作为内联弱类型的引号表达式)。另一方面,Call案例是一个基础模式,匹配任何函数调用。SpecificCall案例比Call案例要简单得多,因为我们可以在了解匹配构成的情况下,对代码做出某些假设。而Call案例则需要做更多的工作来展开表达式。

最后,我们到了最后三个案例:ShapeVarShapeLambdaShapeCombination。其中最简单的ShapeVar,匹配任何变量定义。(注意,这里使用变量一词比使用绑定更合适,因为它代表了代码中的一个占位符。)ShapeVar捕获的值包括变量名、数据类型和可变性等信息。ShapeLambda匹配任何 lambda 表达式,捕获其参数定义和作为嵌套表达式的主体。最后一个案例,ShapeCombination,匹配任何其他表达式,并且为了完整性也包括在内。

要查看showSyntax函数的实际效果,你可以传入任何引号表达式。只需记住,这种实现几乎无法覆盖所有可能的情况,因此对于更复杂的表达式,结果可能不会特别理想。不过,作为开始,这里有一些示例输入和结果:

> **showSyntax <@ fun x y -> x + y @>;;**
val it : string = "fun x -> fun y -> x + y"
> **showSyntax <@ fun x y -> x - y @>;;**
val it : string = "fun x -> fun y -> x - y"
> **showSyntax <@ 10 * 20 @>;;**
val it : string = "Operators.op_Multiply (10,20)"
> **showSyntax <@@ System.Math.Max(10, 20) @@>;;**
val it : string = "Math.Max (10,20)"

替代反射

就像你可以使用表达式树来实现类似反射的功能(正如你在本章开头看到的那样),你也可以使用引号表达式来实现类似的效果。为了演示,我将使用一个经过改编的版本,这个示例在我第一次学习引号表达式时非常有帮助。

这个示例,原始形式可以在 fssnip.net/eu/ 中找到,定义了一个广泛使用高阶函数、部分应用和引用表达式的模块,允许你为你的类型定义临时验证函数。我们将从完整的代码列表开始,在你有机会消化它之后再进行详细解析。

module Validation =
  open System
  open Microsoft.FSharp.Quotations
  open Microsoft.FSharp.Quotations.Patterns

  type Test<'e> = | Test of ('e -> (string * string) option)

  ① let private add (quote : Expr<'x>) message args validate (xs : Test<'e> list) =
    let propName, eval =
      match quote with
      | PropertyGet (_, p, _) -> p.Name, fun x -> p.GetValue(x, [||])
      | Value (_, ty) when ty = typeof<'e> -> "x", box
      | _ -> failwith "Unsupported expression"
    let test entity =
      let value = eval entity
      if validate (unbox value) then None
      else Some (propName, String.Format(message, Array.ofList (value :: args)))
    Test(test) :: xs

  ② let notNull quote =
    let validator = (fun v -> v <> null)
    add quote "Is a required field" [] validator

  ③ let notEmpty quote =
    add quote "Cannot be empty" [] (String.IsNullOrWhiteSpace >> not)

  ④ let between quote min max =
    let validator = (fun v -> v >= min && v <= max)
    add quote "Must be at least {2} and greater than {1}" [min; max] validator

  ⑤ let createValidator (f : 'e -> Test<'e> list -> Test<'e> list) =
    let entries = f Unchecked.defaultof<_> []
    fun entity -> List.choose (fun (Test test) -> test entity) entries

Validation 模块的核心是私有的 add 函数,位于 ① 处。此函数接受五个参数,每个参数都参与验证。最为关键的是第一个参数 quote,第三个参数 validate,以及最后一个参数 xs。这三个参数分别代表标识正在验证属性的引用、验证函数和测试函数列表。

add 函数内部,我们首先尝试将 quotePropertyGetValue 活跃模式匹配,以适当地从源对象中提取值,以便稍后将其传递给验证函数。接着,我们定义了一个名为 test 的函数,调用提供的 validate 函数,并返回一个选项值,指示提取的值是否有效。最后,test 函数被包装在 Test 联合类型中,并添加到 xs 列表前面,最终返回整个列表。

add 函数到位后,我们定义了多种函数,这些函数返回部分应用版的 add,从而使我们拥有了富有表现力的验证语法。在这个例子中,我们定义了 notNull ②、notEmpty ③ 和 between ④。每个函数接受一个被引用的表达式,并将其与接下来的三个参数一起应用于 add,从而生成新的函数,这些函数仅接受一个 Test 联合类型的列表并返回相同的列表。

createValidator ⑤ 函数是进入 Validation 模块的主要入口。createValidator 接受一个柯里化函数,其参数包括一个通用值和一个 Test 联合类型的列表(类型相同),最终返回另一个 Test 联合类型的列表。注意第二个参数和返回值与 notNullnotEmptybetween 函数返回的函数是相对应的。这里的含义是,我们可以组合一个验证函数传递给 createValidator,以便稍后随意调用。

现在 Validation 模块已完全定义,我们可以看到如何使用它。让我们从打开 Validation 模块并定义一个简单的记录类型定义开始,之后我们可以针对这个类型进行验证。

open Validation
type TestType = { ObjectValue : obj
                  StringValue : string
                  IntValue : int }

这个类型没有什么特别之处,它仅包含了我们可以引用用于验证的三个标签。现在,我们可以通过如下方式调用 createValidator 来创建一个验证方法:

let validate =
  createValidator <| fun x -> notNull <@ x.ObjectValue @> >>
                              notEmpty <@ x.StringValue @> >>
                              between <@ x.IntValue @> 1 100

在这里,我们通过在传递给createValidator的函数中使用组合操作符,将对notNullnotEmptybetween的调用链式连接起来。最终返回的函数(由createValidator返回)然后绑定到validate。每个链式调用都包含一个引用表达式,用于标识TestType的标签。你甚至可以看到这里,F#的类型推断在确定表达式中x的类型时发挥了作用。

现在我们需要做的就是通过传递TestType的实例来调用validate函数。当所有值满足验证时,validate会像这样简单地返回一个空列表:

> **{ ObjectValue = obj(); StringValue = "Sample"; IntValue = 35 }**
**|> validate;;**
val it : (string * string) list = []

另一方面,当一个或多个值未通过验证时,validate函数会返回一个列表,包含失败的成员名称以及失败信息,如这里所示,所有三个值都失败了:

> **{ ObjectValue = null; StringValue = ""; IntValue = 1000 }**
**|> validate;;**
val it : (string * string) list =
  [("IntValue", "Must be at least 100 and greater than 1");
   ("StringValue", "Cannot be empty"); ("ObjectValue", "Is a required field")]

概要

尽管引用表达式的作用与 LINQ 引入的表达式树类似,但 F#的引用表达式更适合函数式编程。正如你所看到的,你可以通过字面表达式、使用ReflectedDefinition特性通过反射直接构造引用表达式,或通过反射和Expr类中的静态方法编程构造引用表达式。然而,引用表达式的真正力量来自于它们的解构。通过使用在PatternsDerivedPatternsExprShape模块中定义的活动模式,你可以在不同粒度上解构引用表达式,从而完成多种任务,如语言翻译甚至灵活的验证。

第十章 数据展示

几乎每个今天编写的应用程序都需要强大的机制来访问和操作数据。尽管.NET 框架中的各种数据访问技术都可以在 F#中使用,本章重点介绍两个特定领域:查询表达式和类型提供程序。

查询表达式

当 LINQ 被添加到.NET 中时,它彻底改变了我们访问数据的方式,通过提供统一的语法来查询来自不同数据源的数据。在 LINQ 引入后,C#和 Visual Basic 被扩展,加入了查询语法,这是一种类似 SQL 的语法,包含上下文敏感的关键字,实际上是对多个语言特性(如扩展方法和 lambda 表达式)的语法糖。从这个角度来看,F#有些姗姗来迟,因为在 F# 3.0 之前,使用 LINQ 的唯一方式是直接调用 LINQ 扩展方法。

尽管它们基于函数式编程,但直接使用 LINQ 方法由于其流畅接口,具有非常强的面向对象特征;序列被传递给方法,这些方法返回新的序列,且方法通常通过点符号进行链式调用。考虑以下查询,它直接对 F#列表使用 LINQ 扩展方法,筛选出奇数,并按降序对结果进行排序(记得打开System.Linq命名空间):

[ 1..100 ]
  .Where(fun n -> n % 2 = 0)
  .OrderByDescending(fun n -> n)

正如你所看到的,以这种方式链式调用方法比函数式编程更具面向对象的特性。查询表达式,在 F# 3.0 中引入,通过提供类似 SQL 的语法,改变了这一点,这种语法类似于 C#和 Visual Basic 中的查询语法。它们实际上就是 F#的 LINQ。

查询表达式的形式是query { ... }。在花括号内,我们可以识别一系列我们希望应用于序列的操作,从而形成一个查询。例如,我们可以将之前的查询重写为这样的查询表达式(对于查询表达式,不需要显式地打开System.Linq):

query { for n in [ 1..100 ] do
        where (n % 2 = 0)
        sortByDescending n }

现在,过滤和排序列表看起来和感觉上更具函数式编程的特点。我们不再直接链式调用方法,而是以更惯用的方式表达查询,使用表达式组合和函数调用。由于查询表达式是 LINQ 技术的包装器,因此你可以在任何序列上使用它们。

鉴于这个简单的例子,有人可能会认为SeqList模块函数可以达到类似的效果,在许多情况下,确实如此。例如,我们可以轻松地将where运算符替换为Seq.filter函数的调用。同样,我们也可以常常使用Seq.sortBy来代替sortBy运算符。一个不那么显而易见的事实是,由于查询表达式是建立在 LINQ 之上的,它们可以提供额外的优化,比如在 SQL 查询中生成WHERE子句,以防止从数据库中检索大量数据集。

为了简便起见,除非另有说明,本章中的每个查询表达式示例将使用以下QuerySource模块中定义的类型和集合。

module QuerySource =
  open System

  type film = { id : int; name : string; releaseYear : int; gross : Nullable<float> }
              override x.ToString() = sprintf "%s (%i)" x.name x.releaseYear
  type actor = { id : int; firstName : string; lastName : string }
              override x.ToString() = sprintf "%s, %s" x.lastName x.firstName
  type filmActor = { filmId : int; actorId : int }

  let films =
    [ { id = 1; name = "The Terminator"; releaseYear = 1984; gross = Nullable 38400000.0 }
      { id = 2; name = "Predator"; releaseYear = 1987; gross = Nullable 59735548.0 }
      { id = 3; name = "Commando"; releaseYear = 1985; gross = Nullable<float>() }
      { id = 4; name = "The Running Man"; releaseYear = 1987; gross = Nullable 38122105.0 }
      { id = 5; name = "Conan the Destroyer"; releaseYear = 1984; gross = Nullable<float>() } ]

  let actors =
    [ { id = 1; firstName = "Arnold"; lastName = "Schwarzenegger" }
      { id = 2; firstName = "Linda"; lastName = "Hamilton" }
      { id = 3; firstName = "Carl"; lastName = "Weathers" }
      { id = 4; firstName = "Jesse"; lastName = "Ventura" }
      { id = 5; firstName = "Vernon"; lastName = "Wells" } ]
  let filmActors =
    [ { filmId = 1; actorId = 1 }
      { filmId = 1; actorId = 2 }
      { filmId = 2; actorId = 1 }
      { filmId = 2; actorId = 3 }
      { filmId = 2; actorId = 4 }
      { filmId = 3; actorId = 1 }
      { filmId = 3; actorId = 5 }
      { filmId = 4; actorId = 1 }
      { filmId = 4; actorId = 4 }
      (* Intentionally omitted actor for filmId = 5 *) ]

QuerySource模块本身并没有特别有趣的地方,但这里定义的类型和集合足以代表我们可以以多种方式查询的基本数据模型。filmactor类型还包括了ToString的重写,以简化查询输出。

基本查询

在最基本的形式中,查询表达式由一个可枚举的for循环和一个投影组成。可枚举的for循环为源序列中的项定义一个名称。投影标识查询返回的数据。

最常见的投影操作符之一是select,它等同于 LINQ 的Select方法,并定义结果序列中每个项的结构(类似于Seq.map)。最基本的情况下,select操作只是直接投影每个数据项,像这样:

query { for f in QuerySource.films do select f }

结果为:

val it : seq<QuerySource.film> =
  seq
    [{id = 1;
      name = "The Terminator";
      releaseYear = 1984;
      gross = 38400000.0;};
      -- *snip* -- ]

select操作不仅限于仅投影源数据项;它们还可以转换源序列,投影更复杂的类型,如元组、记录或类。例如,要投影一个包含电影名称及其上映年份的元组,可以这样写:

query { for f in QuerySource.films do
        select (f.name, f.releaseYear) }

结果为:

val it : seq<string * int> =
  seq
    [("The Terminator", 1984); ("Predator", 1987); ("Commando", 1985);
     ("The Running Man", 1987); ...]

在这些简单的示例中,我们显式地包含了select操作来转换源序列。随着查询复杂性的增加,通常隐含地投影原始的、未变换的数据项,因此select操作通常可以安全地省略。为了节省空间,我通常会用ToString投影结果,但我鼓励你尝试不同的投影方式,以熟悉查询行为。

数据过滤

查询通常涉及指定某些条件来筛选掉不需要的数据。过滤数据有两种主要方法:基于谓词的过滤器和不同项过滤器。

基于谓词的过滤器

基于谓词的过滤器允许你通过指定每个源序列项必须满足的条件来过滤数据,从而将其包含在投影序列中。要创建一个基于谓词的过滤器,只需在查询中包含 F#等效于 LINQ 的Where方法——where操作符,后跟一个布尔表达式(通常称为谓词)。(注意,表达式通常需要加上括号。)例如,要仅选择 1984 年上映的电影,可以写成这样:

query { for f in QuerySource.films do
        where (f.releaseYear = 1984)
        select (f.ToString()) }

获取:

val it : seq<string> =
  seq ["The Terminator (1984)"; "Conan the Destroyer (1984)"]

在编写基于谓词的过滤器时,必须注意源序列的底层类型。对于到目前为止看到的简单示例,这并不是问题,但在许多情况下,特别是当你处理IQueryable<'T>实例时,可能需要处理空值。

空值可能在查询表达式中造成问题,因为标准比较操作符无法处理它们。例如,如果你使用标准的等于操作符查询所有总票房不超过 4000 万美元的电影,像这样:

query { for f in QuerySource.films do
        where (f.gross <= 40000000.0)
        select (f.ToString()) }

你将会收到以下错误,因为 gross 被定义为 Nullable<float>

QueryExpressions.fsx(53,16): error FS0001: The type 'System.Nullable<float>'
does not support the 'comparison' constraint. For example, it does not support
the 'System.IComparable' interface

为了绕过这个限制,你需要使用 Microsoft.FSharp.Linq.NullableOperators 模块中定义的可空操作符。这些操作符与标准操作符类似,只不过当左操作数是 Nullable<_> 时,它们以问号 (?) 开头;当右操作数是 Nullable<_> 时,它们以问号结尾;或者当两个操作数都是 Nullable<_> 时,它们被问号包围。表 10-1 列出了每个可空操作符。

表 10-1. 可空操作符

操作符 左侧可空 右侧可空 双侧可空
等于 ?= =? ?=?
不等式 ?<> <>? ?<>?
大于 ?> >? ?>?
大于或等于 ?>= >=? ?>=?
小于 ?< <? ?<?
小于或等于 ?<= <=? ?<=?
加法 ?+ +? ?+?
减法 ?- -? ?-?
乘法 ?* *? ?*?
除法 ?/ /? ?/?
取模 ?% %? ?%?

现在我们可以使用适当的可空操作符重写之前的查询,像这样:

open Microsoft.FSharp.Linq.NullableOperators

query { for f in QuerySource.films do
        where (f.gross ?<= 40000000.0)
        select (f.ToString()) }

获取:

val it : seq<string> = seq ["The Terminator (1984)"; "The Running Man (1987)"]

正如你所看到的,尽管底层序列包含一些空值,查询仍然返回了两个匹配项。

可以使用布尔操作符将多个谓词连接在一起。例如,为了只获取 1987 年发布且总票房不超过 4000 万美元的电影,你可以这样写:

query { for f in QuerySource.films do
        where (f.releaseYear = 1987 && f.gross ?<= 40000000.0)
        select (f.ToString()) }

得到:

val it : seq<string> = seq ["The Running Man (1987)"]

不同项筛选器

查询表达式可以通过筛选重复项,仅生成底层序列中独特值的序列。为了实现这一点,你只需要在查询中包含 distinct 操作符。

distinct 操作符对应于 LINQ 的 Distinct 方法,但与 C# 或 VB 不同,查询表达式允许你直接在查询中包含它,而不是作为单独的方法调用。例如,要查询不同的发行年份,你可以写:

query { for f in QuerySource.films do
        select f.releaseYear
        distinct }

在这里,我们已将不同的发行年份投影到一个新的序列中:

val it : seq<int> = seq [1984; 1987; 1985]

访问单个项目

一个序列中包含多个项目是很常见的,但你实际上只关心其中的一个项目。查询表达式包括多个操作符,用于访问序列中的第一个项目、最后一个项目或任意项目。

获取第一个或最后一个项目

要从序列中获取第一个项目,可以使用headheadOrDefault操作符。这些操作符分别对应于 LINQ 中FirstFirstOrDefault方法的无参重载,但使用更具函数式编程风格的head来标识第一个项目(就像在 F#列表中一样)。headheadOrDefault的区别在于,head在源序列为空时会抛出异常,而headOrDefault则返回Unchecked.defaultof<_>

要从序列中获取第一个项目,只需像这样将序列投影到其中一个头操作符:

query { for f in QuerySource.films do headOrDefault }

在这种情况下,结果是:

val it : QuerySource.film = {id = 1;
                             name = "The Terminator";
                             releaseYear = 1984;
                             gross = 38400000.0;}

类似地,你可以使用lastlastOrDefault操作符获取序列中的最后一个项目。这些操作符的行为与它们的头操作符相同,last在序列为空时会抛出异常,而lastOrDefault则不会。根据底层序列类型,获取最后一个项目可能需要遍历整个序列,因此要小心,因为该操作可能会很昂贵或耗时。

获取任意项目

当你想通过索引获取特定项目时,可以使用nth操作符,它相当于 LINQ 中的ElementAt方法。例如,要从films序列中获取第三个元素,你可以这样构造查询:

query { for f in QuerySource.films do nth 2 }

在这里,结果是:

val it : QuerySource.film = {id = 3;
                             name = "Commando";
                             releaseYear = 1985;
                             gross = null;}

尽管nth操作符在你已经知道索引的情况下非常有用,但更常见的是想要获取第一个符合某些条件的项目。在这些情况下,你会希望使用find操作符。

find操作符相当于调用 LINQ 的First方法并传入一个谓词。它也类似于where操作符,不同之处在于,它只返回单一的项目,而不是返回一个新的序列。例如,要获取 1987 年列出的第一部电影,你可以这样写:

query { for f in QuerySource.films do find (f.releaseYear = 1987) }

执行此查询将给你:

val it : QuerySource.film = {id = 2;
                             name = "Predator";
                             releaseYear = 1987;
                             gross = 59735548.0;}

find操作符对于查找第一个符合某些条件的项目非常有用,但它不能保证第一个匹配项是唯一匹配项。当你想返回一个单一的值,但又需要确保查询结果只包含一个项目(例如,当你通过键值查找项目时),你可以使用exactlyOne操作符,它对应于 LINQ 中Single方法的无参重载。例如,要根据id获取一部电影并确保唯一性,你可以这样写:

query { for f in QuerySource.films do
        where (f.id = 4)
        exactlyOne }

在这种情况下,查询返回:

val it : QuerySource.film = {id = 4;
                             name = "The Running Man";
                             releaseYear = 1987;
                             gross = 38122105.0;}

当源序列不包含正好一个项目时,exactlyOne操作符会抛出异常。如果你希望在源序列为空时使用默认值,可以改用exactlyOneOrDefault操作符。但请注意,如果源序列包含多个项目,exactlyOneOrDefault仍然会抛出异常。

注意

查询表达式语法不包括等同于基于谓词的SingleSingleOrDefault重载的操作符。

排序结果

查询表达式使得排序数据变得容易,而且在某些方面,比各种集合模块中的排序函数更加灵活。这些排序操作符允许你对可空值和非可空值进行升序或降序排序,甚至可以按多个值排序。

按升序排序

按升序排序一个序列需要使用sortBysortByNullable操作符。这两个操作符都基于 LINQ 的

OrderBy方法。内部而言,这些方法的差异仅在于对其参数应用的泛型约束。正如它们的名字所暗示的那样,sortBy操作符用于非可空值,而sortByNullable用于Nullable<_>值。

使用这两个操作符时,你需要指定排序的值。例如,要按名称排序电影,你可以这样写:

query { for f in QuerySource.films do
        sortBy f.name
        select (f.ToString()) }

这将返回以下序列:

val it : seq<string> =
  seq
    ["Commando (1985)"; "Conan the Destroyer (1984)"; "Predator (1987)";
     "The Running Man (1987)"; ...]

降序排序

要按降序排序一个序列,你可以使用sortByDescendingsortByNullableDescending操作符。这些操作符基于 LINQ 的OrderByDescending方法,和它们的升序对应物一样,内部的区别仅在于对其参数应用的泛型约束。

要按名称降序排序films序列,你可以这样写:

query { for f in QuerySource.films do
        sortByDescending f.name
        select (f.ToString()) }

这将返回:

val it : seq<string> =
  seq
    ["The Terminator (1984)"; "The Running Man (1987)"; "Predator (1987)";
     "Conan the Destroyer (1984)"; ...]

按多个值排序

要按多个值排序,首先使用sortBysortByDescending操作符进行排序,然后用其中一个thenBy操作符提供后续排序值。和主要的排序操作符一样,thenBy也有变体,允许你在使用可空值和非可空值时进行升序或降序排序。

四个thenBy变体只能在某个sortBy变体之后出现,它们是:

  • thenBy

  • thenByNullable

  • thenByDescending

  • thenByNullableDescending

这些操作符基于 LINQ 的ThenByThenByDescending方法。要查看它们的实际应用,假设我们先按releaseYear排序films序列,再按gross降序排序:

query { for f in QuerySource.films do
        sortBy f.releaseYear
        thenByNullableDescending f.gross
        select (f.releaseYear, f.name, f.gross) }

该查询结果如下排序序列:

val it : seq<int * string * System.Nullable<float>> =
  seq
    [(1984, "The Terminator", 38400000.0); (1984, "Conan the Destroyer", null);
     (1985, "Commando", null); (1987, "Predator", 59735548.0); ...]

你可以链式调用更多thenBy操作符,来创建更复杂的排序场景。

分组

另一个常见的查询操作是分组。查询表达式提供了两个操作符,均基于 LINQ 的GroupBy方法,用于执行此操作。两个操作符都会生成一个中间序列,该序列包含IGrouping<_,_>实例,你可以在查询的后续部分引用它。

第一个操作符groupBy允许你指定一个键值,按照该键值对源序列中的项目进行分组。每个由groupBy生成的IGrouping<_,_>都包含键值和一个子序列,该子序列包含所有与键匹配的源序列中的项目。例如,要按上映年份分组电影,你可以这样写:

query { for f in QuerySource.films do
        groupBy f.releaseYear into g
        sortBy g.Key
        select (g.Key, g) }

该查询生成的结果(为易读性起见,已格式化并简化):

val it : seq<int * IGrouping<int, QuerySource.film>> =
  seq
    [(1984, seq [{id = 1; -- *snip* --};
                 {id = 5; -- *snip* --}]);
     (1985, seq [{id = 3; -- *snip* --}]);
     (1987, seq [{id = 2; -- *snip* --};
                 {id = 4; -- *snip* --}])]

并非总是需要像groupBy运算符那样在结果的IGrouping<_,_>中包含完整的源项。相反,您可以使用groupValBy运算符来指定要包含的内容,无论是源中的单个值还是其他转换。与我们迄今为止看到的其他运算符不同,groupValBy接受两个参数:要包含在结果中的值和键值。

为了演示groupValBy运算符,让我们再次按releaseYear对电影进行分组,但这次我们将包含一个包含电影name和其gross收入的元组:

query { for f in QuerySource.films do
        groupValBy (f.name, f.gross) f.releaseYear into g
        sortBy g.Key
        select (g.Key, g) }

这给我们带来了:

val it : seq<int * IGrouping<int,(string * System.Nullable<float>)>> =
  seq
    [(1984,
      seq [("The Terminator", 38400000.0); ("Conan the Destroyer", null)]);
     (1985, seq [("Commando", null)]);
     (1987, seq [("Predator", 59735548.0); ("The Running Man", 38122105.0)])]

现在,结果分组中只包含我们显式请求的数据,而不是完整的电影实例。

分页

查询表达式允许您轻松地对序列进行分页。想象一下典型的搜索结果页面,其中项目按每页若干个(例如,10 个)进行分割。与其管理标识用户应该查看哪个分区的占位符,您可以使用查询表达式,它提供了skipskipWhiletaketakeWhile运算符,帮助您直接在查询中获取正确的分区。这些运算符中的每一个都与其底层的 LINQ 方法同名。

skiptake运算符都接受一个整数,表示要跳过或包含的项目数。例如,您可以编写一个函数来获取特定的页面,像这样:

let getFilmPageBySize pageSize pageNumber =
  query { for f in QuerySource.films do
          skip (pageSize * (pageNumber - 1))
          take pageSize
          select (f.ToString()) }

现在,获取特定页面只需调用getFilmPage函数。例如,要获取包含三项的第一页,您可以这样写:

getFilmPageBySize 3 1

这将产生:

val it : seq<string> =
  seq ["The Terminator (1984)"; "Predator (1987)"; "Commando (1985)"]

同样,您可以通过以下方式获取第二页结果:

getFilmPageBySize 3 2

这给我们带来了:

val it : seq<string> =
  seq ["The Running Man (1987)"; "Conan the Destroyer (1984)"]

指定比序列中实际存在更多的项目是可以的。如果到达序列的末尾,skiptake运算符会返回到目前为止已选择的项目,并且不会抛出任何异常。

skipWhiletakeWhile运算符与skiptake非常相似,不同之处在于它们不是针对已知数量的项目进行操作,而是根据条件跳过或选择项目。这对于根据某些标准分页处理可变数量的项目非常有用。例如,以下函数返回给定年份上映的电影:

let getFilmPageByYear year =
  query { for f in QuerySource.films do
          sortBy f.releaseYear
          skipWhile (f.releaseYear < year)
          takeWhile (f.releaseYear = year)
          select (f.ToString()) }

调用此函数并传入一个年份将生成一个包含零个或多个项目的序列。例如,调用它并传入 1984 将返回:

val it : seq<string> =
  seq ["The Terminator (1984)"; "Conan the Destroyer (1984)"]

而调用 1986 时将返回空项,因为源序列中没有 1986 年上映的电影。

如果您想知道是否可以通过单个where运算符简化这个按releaseYear分页的简单示例,答案是可以。这个示例只是展示了takeWhile的效果。wheretakeWhile的作用类似,但区分它们非常重要,特别是在面对更复杂的谓词时。两者的区别在于,takeWhile在找到不匹配的项时会立即停止,而where则不会。

数据聚合

我们常常需要展示或以其他方式处理表格数据,有时我们真正需要的是数据的聚合视图。诸如计算序列中项数、求和或寻找平均值等聚合操作,都是常见的需求,可以通过内置的查询操作符来实现。

计算序列中的项数很简单;只需将序列映射到count操作符。

query { for f in QuerySource.films do count }

评估此查询告诉我们films序列中包含五个项。然而,请注意,计算序列中的项数可能是一个昂贵的操作;它通常需要枚举整个序列,这可能会对性能产生负面影响。尽管如此,基于此操作符的Count方法足够智能,可以跳过某些序列(如数组)。如果你仅仅是为了判断序列是否包含数据而计算项数,你应该考虑使用exists操作符,详见检测项。

其余的聚合操作符使你可以根据选择器轻松地对序列执行数学聚合。这些操作符——minBymaxBysumByaverageBy——分别允许你计算最小值、最大值、总和或平均值。在内部,minBymaxBy操作符分别使用 LINQ 的MinMax方法,但sumByaverageBy提供了自己的实现,并且完全独立于 LINQ。

这四个操作符中的每一个也都有其对应的可空操作符,能够处理可空值,类似于排序结果中介绍的排序操作符。为了演示,我们将使用可空形式查询films序列。

为了找到票房最高的电影,我们可以写:

query { for f in QuerySource.films do maxByNullable f.gross }

如预期,运行此查询返回59735548.0。将maxByNullable替换为minByNullable返回38122105.0,而sumByNullable返回136257653.0。然而,averageByNullable操作符的行为并不像你可能预期的那样。

使用averageByNullable计算总收入的平均值为27251530.6。发生的情况是,尽管操作符在求和阶段跳过了空值,但它仍然将总和除以序列中的项数,而不管跳过了多少空项。这意味着空值实际上被当作零处理,这可能是可取的,也可能不可取。本章稍后我们将探讨如何定义一个新的查询操作符,在计算平均值时真正忽略空值。

检测项

到目前为止,我们已经探索了多种方式来构造查询表达式,以便转换、过滤、排序、分组和聚合序列。然而,有时你并不关心从序列中获取特定项,而是希望检查序列是否包含符合某些标准的数据。与返回新序列或特定项不同,本节讨论的运算符返回一个布尔值,指示序列是否包含所需的数据。像distinct运算符一样,这些运算符是查询表达式的一部分,这是 F#查询表达式与 C#和 Visual Basic 查询语法的另一个区别。

当你想查看一个已知的项是否包含在一个序列中时,你可以使用contains运算符。contains运算符基于 LINQ 的Contains方法,接受你要查找的项作为其参数。例如,如果我们想检测幼儿园警探是否存在于films集合中,可以这样写:

open System
open QuerySource

let kindergartenCop =
  { id = 6; name = "Kindergarten Cop"; releaseYear = 1990; gross = Nullable 91457688.0 }

query { for f in films do
        contains kindergartenCop }

调用此查询会告诉你幼儿园警探不在集合中(让我松了口气)。然而,如你所见,contains运算符实际上只适用于当你已经有对可能已经是集合一部分的项的引用时。如果你只知道你正在寻找的部分值,例如电影名称,你可以修改查询,投影每个名称并将你要查找的名称传递给contains,像这样:

query { for f in QuerySource.films do
        select f.name
        contains "Kindergarten Cop" }

然而,像这样投影值并不是特别高效,因为它涉及在找到指定项之前对整个序列进行枚举。相反,你可以使用另一个运算符exists,它基于 LINQ 的Any方法。exists运算符类似于where,只是它在找到符合谓词的项时就停止枚举序列,并返回truefalse。例如,之前的查询可以用exists这样表达:

query { for f in QuerySource.films do
        exists (f.name = "Kindergarten Cop") }

当然,传递给exists的谓词不必查找特定项。我们可以很容易地通过以下查询来判断是否有任何电影的票房至少达到 5000 万美元:

open Microsoft.FSharp.Linq.NullableOperators

query { for f in QuerySource.films do
        exists (f.gross ?>= 50000000.0) }

因为掠食者的票房接近 6000 万美元,所以之前的查询返回true。如果你想检查序列中的每个项是否满足某个条件,可以使用all运算符。基于 LINQ 的All方法,all运算符枚举序列,当每个项都符合谓词时返回true。如果遇到一个不符合谓词的项,枚举停止,all返回false。例如,若要检查每部电影的票房是否至少达到 5000 万美元,你可以构建如下查询:

query { for f in QuerySource.films do
        all (f.gross ?>= 50000000.0) }

在我们的films集合中,只有一项满足条件,因此查询返回false

联接多个数据源

从单个序列查询数据是有用的,但数据通常分布在多个源中。查询表达式继承了 LINQ 的连接功能,它允许你在单个表达式中查询来自多个源的数据。查询表达式中的连接类似于可枚举的for循环,它们包括一个迭代标识符和源序列,但以适当的join操作符开始,并且还包括连接条件。

第一种连接类型,内连接,使用join操作符将一个序列中的值与第二个序列中的值进行关联。在内部,join操作符使用 LINQ 的Join方法来实现其功能。一旦序列连接完成,来自两个序列的值可以通过后续操作符(如whereselect)进行引用。

直到现在,我们编写的所有查询只使用了films集合。回想一下,在我们在本章开始时创建QuerySource模块时,我们还定义了另外两个集合:actorsfilmActors。这三个集合(filmsactorsfilmActors)一起建模了filmsactors之间的多对多关系,其中filmActors作为连接表。我们可以使用join操作符将这三个集合结合在一个查询中,如下所示:

query { for f in QuerySource.films do
        join fa in QuerySource.filmActors on (f.id = fa.filmId)
        join a in QuerySource.actors on (fa.actorId = a.id)
        select (f.name, f.releaseYear, a.lastName, a.firstName) }

将多个序列连接在一起,只需要为每个序列包含一个连接表达式,并通过它们的成员及等式操作符确定它们之间的关系。调用这个查询将产生以下序列(根据 FSI 进行了截断):

val it : seq<string * int * string * string> =
  seq
    [("The Terminator", 1984, "Schwarzenegger", "Arnold");
     ("The Terminator", 1984, "Hamilton", "Linda");
     ("Predator", 1987, "Schwarzenegger", "Arnold");
     ("Predator", 1987, "Weathers", "Carl"); ...]

F#通过groupJoin操作符公开了 LINQ 的GroupJoin功能。这允许你连接两个序列,但与选择满足连接条件的单个项不同,它将满足连接条件的每个项投影到另一个可以在查询中后续引用的序列中。你可以使用这个中间序列来创建一个层次化的数据结构,这类似于groupBy操作符创建的IGrouping<_,_>实例。

请考虑以下查询,它创建了一个层次结构,其中每个演员根据他或她出演的电影进行分组:

query { for f in QuerySource.films do
        groupJoin fa in QuerySource.filmActors on (f.id = fa.filmId) into junction
        select (f.name, query { for j in junction do
                                join a in QuerySource.actors on (j.actorId = a.id)
                                select (a.lastName, a.firstName) } ) }

在这里,我们使用groupJoin操作符创建了一个名为junction的中间序列。在投影的元组内,我们有一个嵌套查询,在这个查询中,我们将actorsjunction连接并投影出各个演员的名字。这将产生以下序列,我已为可读性格式化:

val it : seq<string * seq<string * string>> =
  seq
    [("The Terminator", seq [("Schwarzenegger", "Arnold");
                             ("Hamilton", "Linda")]);
     ("Predator", seq [("Schwarzenegger", "Arnold");
                       ("Weathers", "Carl");
                       ("Ventura", "Jesse")]);
     ("Commando", seq [("Schwarzenegger", "Arnold");
                       ("Wells", "Vernon")]);
     ("The Running Man", seq [("Schwarzenegger", "Arnold");
                              ("Ventura", "Jesse")]);
     ...]

如你所见,外部查询(films部分)返回一个包含元组的单一序列。每个项内部都有一个包含与该电影相关的演员的序列。从这些截断的结果中无法看出的是,当连接的序列中的项没有满足连接条件时(例如毁灭者 Conan),groupJoin操作创建的序列将为空。

如果你更喜欢将groupJoin的结果展平,而不是返回层次结构,你可以在groupJoin操作后跟一个可枚举的for循环,使用连接序列作为循环源。在这里,之前的查询被重构为将每个演员与电影同行返回:

query { for f in QuerySource.films do
        groupJoin fa in QuerySource.filmActors on (f.id = fa.filmId) into junction
        for j in junction do
        join a in QuerySource.actors on (j.actorId = a.id)
        select (f.name, f.releaseYear, a.lastName, a.firstName) }

这个查询的结果与内连接的结果相同,因此我在这里不会重复输出。在大多数情况下,你会希望使用join操作符,避免创建中间连接序列的开销,但在某些情况下,使用groupJoin这样的操作是有意义的:左外连接。

默认情况下,如果在groupJoin中没有任何项满足连接条件,结果是一个空序列。然而,如果你在结果序列上使用DefaultIfEmpty方法,你会得到一个新序列,包含一个单一项,该项是基础类型的默认值。为了在查询中执行左外连接,你可以像在之前的查询中一样使用groupJoin操作符,但在可枚举的for循环中包括对DefaultIfEmpty的调用——例如,j.DefaultIfEmpty()。或者,你可以使用leftOuterJoin操作符来实现相同的结果。

不幸的是,左外连接是 F#和其他.NET 框架之间不一致的一个领域,这可能会导致很多麻烦。但这实际上只有在你使用核心 F#类型时才会成为问题。考虑以下查询:

query { for f in QuerySource.films do
        leftOuterJoin fa in QuerySource.filmActors on (f.id = fa.filmId) into junction
        for j in junction do
        join a in QuerySource.actors on (j.actorId = a.id)
        select (f.name, f.releaseYear, a.lastName, a.firstName) }
|> Seq.iter (printfn "%O")

当这个查询枚举(通过Seq.iter)时,它会抛出NullReferenceException,一旦尝试连接《野蛮人柯南》的演员信息。因为在filmActors序列中没有该电影的条目,所以左外连接中的DefaultIfEmpty调用导致junction中的唯一条目为null

等等,什么?nullfilmActor不是一个记录类型吗?如果null对于记录类型来说不是有效值,那它怎么可能是null呢?答案在于,通过调用.NET 框架的方法,我们已经脱离了 F#的沙箱。null在 F#中对于记录类型可能不是有效的,但公共语言运行时(CLR)并没有记录类型的概念;它只知道值类型和引用类型,从它的角度来看,记录类型只是引用类型。因此,null是一个有效值。不幸的是,因为我们的代码完全是在 F#中,并且 F#编译器强制执行记录类型的值约束,我们无法通过模式匹配或if...then表达式来处理null值。我们甚至不能使用AllowNullLiteral属性,因为编译器也不允许那样做。

解决这个问题有点麻烦。我们可以先将查询分成两部分:一部分连接actorsfilmActors,另一部分连接films,像这样:

let actorsFilmActors =
  query { for a in QuerySource.actors do
          join fa in QuerySource.filmActors on (a.id = fa.actorId)
          select (fa.filmId, a) }

query { for f in QuerySource.films do
        leftOuterJoin (id, a) in actorsFilmActors on (f.id = id) into junction
        for (_, a) in junction do
        select (f.name, a.lastName, a.firstName) }

这是一个不错的开始,但在枚举类型的for循环中,针对junction的元组模式匹配仍然会引发NullReferenceException,因为 F#也不允许元组值为null。不过,我们可以使用另一种解决方法:将其强制转换为obj类型。

query { for f in QuerySource.films do
        leftOuterJoin (id, a) in actorsFilmActors on (f.id = id) into junction
        for x in junction do
        select (match (**x :> obj**) with

               | null -> (f.name, "", "")
               | _ -> let _, a = x
                      (f.name, a.lastName, a.firstName))
      }

null可能不是元组的有效值,但对于obj却是。通过显式地将其上转换为obj,我们可以使用模式匹配来检测null值,并返回适当的元组,而不是抛出异常。

扩展查询表达式

如你在前面的章节中所看到的,查询表达式提供了一种简便且富有表现力的方式来处理数据。查询表达式还提供了一个真正将其与 C#和 Visual Basic 中的查询语法区分开的好处:它们是完全可扩展的。在本节中,我将展示几个额外的运算符。我们将首先通过定义暴露SingleSingleOrDefault参数化重载的运算符,填补内置运算符的空白。接下来,我们将进入一个更复杂的示例,允许我们在忽略所有null值的情况下计算平均值。

示例:ExactlyOneWhen

回顾获取任意项,exactlyOneexactlyOneOrDefault运算符暴露了 LINQ 的SingleSingleOrDefault无参数版本,但对于接受谓词的重载版本,并没有类似的运算符。我们可以通过利用 F#类型扩展的强大功能,轻松定义我们自己的运算符来暴露这些方法。

为了创建自定义运算符,我们需要扩展位于Microsoft.FSharp.Linq命名空间中的QueryBuilder类。这个类定义了最终作为查询运算符的方法。从根本上讲,我们定义的类型扩展与任何其他类型扩展没有区别;我们只需要包含一些属性,以便编译器知道这些函数在查询表达式中的行为。

以下是完整的代码列表:

open System
open Microsoft.FSharp.Linq

type QueryBuilder with

① [<CustomOperation("exactlyOneWhen")>]
  member ② __.ExactlyOneWhen (③ source : QuerySource<'T,'Q>,
                              ④ [<ProjectionParameter>] selector) =

    System.Linq.Enumerable.Single (source.Source, Func<_,_>(selector))

  [<CustomOperation("exactlyOneOrDefaultWhen")>]
  member __.ExactlyOneOrDefaultWhen (source : QuerySource<'T,'Q>,
                                     [<ProjectionParameter>] selector) =
    System.Linq.Enumerable.SingleOrDefault (source.Source, Func<_,_>(selector))

这个代码片段在QueryBuilder类上定义了两个扩展方法:exactlyOneWhenexactlyOneOrDefaultWhen。由于它们非常相似,我们将重点关注exactlyOneWhen运算符。第一个需要关注的项是应用于方法本身的CustomOperation属性①。该属性表明该方法应该在查询表达式和运算符名称中可用。

接下来,方法的this标识符是两个下划线字符②,以保持与其他运算符定义的一致性。位于③处的source参数,标注为QuerySource<'T, 'Q>,标识了运算符将作用的序列。

紧跟在source后面的是selector参数④。该参数是一个函数,将应用于source中的每个项,以确定它是否应该被选中。应用于selectorProjectionParameter属性指示编译器该函数被推断为接受'T(从source推断出来),这样你就可以像直接操作实例一样编写选择器函数;也就是说,如果你正在查询films集合,并且使用f作为迭代标识符,你可以写f.id = 4。如果没有ProjectionParameter,你就必须使用完整的 lambda 语法(或正式函数),而不仅仅是这个表达式。

定义了新的运算符后,我们现在可以编写使用这些运算符的查询。例如,要使用exactlyOneWhen运算符通过id查找电影,你可以写:

query { for f in QuerySource.films do
        exactlyOneWhen (f.id = 4) }

如你所见,使用这些运算符后,你不再需要在检查序列是否只包含一个项目之前,先使用where运算符来过滤结果。

示例:AverageByNotNull

对于一个更复杂的自定义运算符示例,让我们提供一个替代方案,代替在聚合数据中使用的averageByNullable运算符来计算电影的平均总票房。计算结果显示平均值为27251530.6,因为两个空值从总和中被排除,但除数仍然是五。如果你希望真正忽略空值并将总和除以三,averageByNullable运算符无法帮助你,但你可以定义一个像这样的自定义运算符:

open System
open Microsoft.FSharp.Linq

type QueryBuilder with

  -- *snip* --
  [<CustomOperation("averageByNotNull")>]
  member inline __.AverageByNotNull< 'T, 'Q, 'Value
                      when 'Value :> ValueType
                      and 'Value : struct
                      and 'Value : (new : unit -> 'Value)
                      and 'Value : (static member op_Explicit : 'Value -> float)>
 (source : QuerySource<'T, 'Q>,
  [<ProjectionParameter>] selector : 'T -> Nullable<'Value>) =
   source.Source
   |> Seq.fold
       (fun (s, c) v -> let i = v |> selector
                        if i.HasValue then
                         (s + float i.Value, c + 1)
                        else (s, c))
       (0.0, 0)
   |> (function
      | (_, 0) -> Nullable<float>()
      | (sum, count) -> Nullable(sum / float count))

请注意,AverageByNotNull方法结合了与exactlyOneWhenexactlyOneOrDefaultWhen相同的许多原则;也就是说,它们都涉及CustomOperationProjectionParameter属性。AverageByNotNull与其他方法的不同之处在于,它被定义为内联方法,以确保泛型参数能够得到解决。由于它们非常相似,我为AverageByNotNull的方法签名和泛型约束基本上基于averageByNullable运算符,尽管我稍微简化了它以便演示。

现在我们已经定义了averageByNotNull运算符,可以像这样将其包含在查询中:

query { for f in QuerySource.films do
        averageByNotNull f.gross }

执行此查询会返回45419217.67,与通过averageByNullable返回的27251530.6形成鲜明对比。

类型提供者

除了查询表达式,F# 3.0 的另一个“杀手级功能”是类型提供者。类型提供者的开发目的是抽象化外部数据工作所需的类型、属性和方法的创建过程,因为这个过程通常既繁琐、容易出错,又难以维护。

许多类型提供者可以类比为传统的对象关系映射(ORM)工具,如 NHibernate 或 Entity Framework,尽管它们的范围可能更广。ORM 工具通常需要大量的配置才能有效使用。尽管有一些工具简化了许多流行 ORM 技术的配置过程,但它们仍然需要大量的维护。类 ORM 的类型提供者旨在通过将类型生成作为编译过程的一部分来消除这种开销。

类型提供者的另一个主要用途是简化其他复杂的接口。考虑一下像使用正则表达式匹配字符串这样的繁琐且容易出错的操作。正则表达式的语法本身就足够令人困惑,但从匹配集合中获取命名捕获还需要使用字符串键来识别你要访问的值。正则表达式类型提供者可以通过生成与正则表达式中的命名捕获对应的类型来简化接口。

无论提供者满足哪种需求类型,它们都提供三种主要的好处:

  • 通过消除手动创建映射和类型定义的需求,使数据中心的探索性编程更易于访问

  • 消除手动维护映射或其他类型定义的行政负担

  • 降低因未检测到底层数据结构变化而引起错误的可能性

类型提供者的详细讨论超出了本书的范围。本节旨在介绍许多可以使用的类型提供者,这些提供者要么是 F# 核心分发的一部分,要么是通过一些流行的第三方库提供的。在你了解了可用的类型提供者后,我们将讨论如何初始化和使用一些类型提供者,轻松获取你关心的数据。

可用的类型提供者

F# 3.0 默认包含多个类型提供者。表 10-2 列出了内置提供者及其简要描述。

表 10-2. 内置类型提供者

提供者 描述
DbmlFile 提供与数据库标记语言文件(.dbml)描述的 SQL Server 数据库对应的类型
EdmxFile 提供与 LINQ-to-Entities 映射文件(.edmx)描述的数据库对应的类型
ODataService 提供与 OData 服务返回的类型对应的类型
SqlDataProvider 提供与 SQL Server 数据库对应的类型
SqlEntityProvider 提供与通过 LINQ-to-Entities 映射对应的数据库类型
WsdlService 提供与 WSDL 基础的 Web 服务返回的类型对应的类型

内置的类型提供程序列表非常简单,主要集中在数据库或类似数据库的数据源上。即便如此,提供的内容覆盖了相当多的用例。如果你的数据超出了内置类型所覆盖的范围,你可以定义自定义的类型提供程序,但这超出了本书的讨论范围。

在你开始构建自己的类型提供程序之前,应该先看看是否有第三方提供程序可以满足你的需求。在撰写本文时,几个流行的库包含了许多有用的类型提供程序,最著名的包括:FSharpxFSharp.Data。表 10-3 列出了每个库中的几个类型提供程序,让你了解哪些类型提供程序是现成的,以及它们在不同场景中的应用。这个列表并不详尽无遗;肯定还有其他可用的库。

表 10-3. 一些可用的第三方类型提供程序

提供程序 描述 FSharpx FSharp.Data
AppSettingsProvider 提供与配置文件中 AppSettings 部分的节点对应的类型
CsvProvider 提供可以轻松解析逗号分隔值(CSV)文件的类型
ExcelProvider 提供与 Excel 工作簿交互所需的类型
FileSystemProvider 提供与文件系统交互所需的类型
JsonProvider 提供表示 JavaScript 对象表示法(JSON)文档的类型
RegexProvider 提供可以检查正则表达式匹配的类型
XamlProvider 提供可以轻松解析 XAML 的类型
XmlProvider 提供表示 XML 文档的类型

使用类型提供程序

无论你需要哪种类型的提供程序,初始化时始终遵循相同的基本模式:

type *name* = *providerName*<*parameters*>

在上述语法中,name 是你用来访问提供程序功能的名称,providerName 标识提供程序类型本身,parameters 是控制提供程序行为的特定参数。参数通常包括连接字符串或数据源路径等内容,但最终每个类型提供程序都负责定义它所接受的参数。

在 Visual Studio 中第一次使用提供程序时,您将看到一个安全对话框,如图 10-1 所示:

类型提供程序安全对话框图 10-1. 类型提供程序安全对话框

如对话框所示,类型提供程序可以连接到远程数据源并执行自定义代码,以实现构建和智能感知功能。一旦启用或禁用了类型提供程序,系统将不再提示您。如果以后想更改选择,您可以在 Visual Studio 选项对话框中的 F#工具下找到类型提供程序的列表。

示例:访问 OData 服务

这个第一个示例使用ODataService类型提供程序从www.odata.org/查询公开可用的 Northwind 示例 OData 服务。首先,我们需要引用两个程序集:

#r "System.Data.Services.Client"
#r "FSharp.Data.TypeProviders"

第一个程序集包含了ODataService提供程序所需的几个 Windows 通信基础(WCF)类。虽然我们在这个示例中并没有直接使用 WCF 类型,但如果没有添加引用,会导致编译错误。第二个程序集包含了提供程序本身。添加了这些程序集引用后,我们现在可以打开包含ODataService提供程序的命名空间:

open Microsoft.FSharp.Data.TypeProviders

接下来,我们包含一个类型定义,引用了适当的类型提供程序以及指向 Northwind 服务的地址:

type northwind =
  ODataService<"http://services.odata.org/V3/Northwind/Northwind.svc/">

ODataService提供程序接受提供的地址,附加$metadata,然后构造并导入服务描述的类型。为了与服务进行交互,我们需要通过提供程序类型获取数据上下文,如下所示:

let svc = northwind.GetDataContext()

在建立数据上下文后,我们现在拥有了查询数据所需的一切。这里我们将使用查询表达式从 Northwind 服务中获取一些发票信息。

let invoices =
  query { for i in svc.Invoices do
          sortByNullableDescending i.ShippedDate
          select (i.OrderDate, i.CustomerName, i.ProductName)
          take 5 }

上述查询没有什么特别的地方;它使用标准查询操作符选择了OrderDateCustomerNameProductName,从五个最近发货的发票中提取数据。特殊之处在于,凭借指向 OData 服务的类型提供程序,我们得到了一个完整的类型层次结构,模拟了服务暴露的类型。

注意

并不是所有的标准查询操作符都被每个数据源支持。例如,OData 不支持连接操作,因此在包含两个 OData 数据源的查询中使用连接操作会导致错误。

尽管我们已经定义了invoices绑定,但查询执行会推迟,直到我们真正枚举序列。为了简便起见,我们可以通过将序列传递给Seq.iter来执行此操作,像这样打印每个项目:

invoices |> Seq.iter (printfn "%A")

执行上述代码时,我运行它时打印出了以下项目,但如果源数据发生变化,您的结果可能会不同:

(5/4/1998 12:00:00 AM, "Drachenblut Delikatessen", "Jack's New England Clam Chowder")
(4/30/1998 12:00:00 AM, "Hungry Owl All-Night Grocers", "Sasquatch Ale")
(4/30/1998 12:00:00 AM, "Hungry Owl All-Night Grocers", "Boston Crab Meat")
(4/30/1998 12:00:00 AM, "Hungry Owl All-Night Grocers", "Jack's New England Clam Chowder")
(5/4/1998 12:00:00 AM, "Tortuga Restaurante", "Chartreuse verte")

到目前为止,ODataService提供程序一直是一个黑盒;只要你提供一个有效的地址,它通常就能正常工作,你不必考虑它是如何工作的。当你进行探索性编程时,这特别有用,但当提供程序没有返回你预期的结果时,它可能会让人感到沮丧。幸运的是,你可以订阅一些事件,以便深入了解提供程序正在做什么:SendingRequestReadingEntity

SendingRequest事件在提供程序创建新的HttpWebRequest时发生,而ReadingEntity事件则在数据读取到实体中后发生。为了讨论的目的,我们将重点关注SendingRequest,因为它可以精确显示正在请求的内容,并帮助你优化查询。

使用SendingRequest时最有帮助的事情之一是查询与SendingRequestEventArgs关联的WebRequest对象的RequestUri属性。RequestUri包含 OData 请求的完整地址,因此一旦获取该地址,你可以将其粘贴到浏览器(或其他诊断工具,如 Fiddler)中进行调整。获取 URI 的一个简单方法是直接将其打印到控制台,如下所示:

svc.DataContext.SendingRequest.Add (fun args -> printfn "%O" args.Request.RequestUri)

只要在查询枚举之前执行前面的代码片段,URI 将在结果之前打印出来。在本节描述的查询中,打印的 URI 是:http://services.odata.org/V3/ Northwind/Northwind.svc/Invoices()?$orderby=ShippedDate%20desc&$top=5&$select= OrderDate, CustomerName, ProductName

为了方便起见,本节中的整个示例,包括对SendingRequest的订阅,已在此完整呈现:

#r "System.Data.Services.Client"
#r "FSharp.Data.TypeProviders"

open Microsoft.FSharp.Data.TypeProviders

type northwind =
  ODataService<"http://services.odata.org/V3/Northwind/Northwind.svc/">
let svc = northwind.GetDataContext()

let invoices =
  query { for i in svc.Invoices do
          sortByNullableDescending i.ShippedDate
          select (i.OrderDate, i.CustomerName, i.ProductName)
          take 5 }

svc.DataContext.SendingRequest.Add (fun args -> printfn "%O" args.Request.RequestUri)
invoices |> Seq.iter (printfn "%A")

示例:使用 RegexProvider 解析字符串

在这个示例中,我们将看看FSharpx项目中的RegexProvider如何生成与正则表达式对应的类型,在处理匹配项时为你提供显著的安全性。要使用这个提供程序,你需要从 NuGet 获取FSharpx.TypeProviders.Regex包,或者从 GitHub 下载源代码(github.com/fsharp/fsharpx/)。

ODataProvider示例一样,我们将首先引用一些程序集并打开一些命名空间:

#r "System.Drawing"
#r @"..\packages\FSharpx.TypeProviders.Regex.1.8.41\lib\40\FSharpx.TypeProviders.Regex.dll"

open System
open System.Drawing

由于我创建此脚本时使用了包含来自 NuGet 的FSharp.TypeProviders.Regex包的项目,因此我只是通过相对路径直接引用了该包;程序集的路径在你的机器上可能有所不同,具体取决于你如何获取该程序集及其版本。

引用程序集并打开常用命名空间后,我们现在可以创建类型提供程序。创建 RegexProvider 类似于创建 ODataService,不同之处在于,RegexProvider 不是使用 URI,而是使用正则表达式模式。对于这个例子,我们将创建一个 RegexProvider,使用一个简单的模式来匹配十六进制的 RGB 值。(这里在原始字符串之前的空格很重要。如果没有空格,编译器会尝试将字符串解释为一个带引号的表达式,这显然不是我们想要的。)

type colorRegex =
  FSharpx.Regex< @"^#(?<Red>[\dA-F]{2})(?<Green>[\dA-F]{2})(?<Blue>[\dA-F]{2})$">

RegexProvider 的工作方式与 ODataService 有些不同,因为它并非真正作为查询源使用。相反,我们将编写一个函数,利用类型提供程序将十六进制字符串转换为标准的 .NET Color 实例,前提是它符合正则表达式模式。

let convertToRgbColor color =
  let inline hexToDec hex = Convert.ToInt32(hex, 16)
  let m = color |> ① colorRegex().Match
  if m.Success then
    Some (Color.FromArgb(② m.Red.Value |> hexToDec,
                         ③ m.Green.Value |> hexToDec,
                         ④ m.Blue.Value |> hexToDec))
  else None

在前面的代码中,我们将提供的 color 字符串传递给 colorRegex 的新实例中的 Match 方法①。Match 返回的值类似于我们直接使用正则表达式时返回的 Match 对象(通过 System.Text.RegularExpressions 中的 Regex 类),但正如你在 ②、③ 和 ④ 中看到的,它还包括与源正则表达式中定义的命名组匹配的命名属性!这意味着你不必再为访问单个命名捕获而苦苦挣扎,避免了使用神奇字符串!

要进行测试,我们只需要将一些字符串传递给 convertToRgbColor 函数。在这里,我们对列表中的每个字符串调用该函数:

[ ""; "#FFFFFF"; "#000000"; "#B0C4DE" ]
|> List.iter
  (convertToRgbColor >>
   (function
    | None -> printfn "Not a color"
    | Some(c) -> printfn "%O" c))

评估此代码应该得到以下结果:

Not a color
Color [A=255, R=255, G=255, B=255]
Color [A=255, R=0, G=0, B=0]
Color [A=255, R=176, G=196, B=222]

正如你所看到的,第一个字符串没有匹配颜色模式,因此没有被转换,而其余三个项目则被转换并相应地写入。

总结

随着 F# 3.0 中查询表达式和类型提供程序的增加,F# 在成为一个更适合数据密集型开发工作的语言方面迈出了巨大步伐。

查询表达式以一种惯用的方式将 LINQ 的强大功能带入语言中。借助它们,您可以轻松地组合复杂的查询,以分析和展示来自各种数据源的数据。此外,查询表达式的可扩展性使其非常适合更复杂的需求。

类型提供程序通过抽象出与不同数据源映射的类型创建细节,进一步扩展了 F# 已经丰富的数据体验。它们极大地提升了开发者在数据中心场景中进行探索性编程的能力,因为开发者无需过多关注如何访问数据。最后,类型提供程序可以通过在构建过程中检测底层数据结构的变化,为代码添加额外的安全性。

第十一章。异步编程和并行编程

在计算机历史的大部分时间里,软件开发者一直受益于处理器制造商不断突破其芯片时钟速度的极限。如果你希望软件运行得更快(处理更大的数据集,或因为用户抱怨系统在忙碌时似乎冻结了),通常只需要升级到最新的处理器。然而,过去十年左右,情况发生了变化:处理器制造商开始通过增加处理核心来提高处理器性能,而不是单纯提高时钟速度。

尽管处理器架构已经发生变化,但软件架构大体上仍保持不变。多核处理器已成为常态,但许多应用程序仍然以只有一个核心可用的方式编写,因此无法充分利用底层硬件。长时间运行的任务仍然在 UI 线程上执行,大型数据集通常是同步处理的。这其中一个重要原因是,传统上,异步编程和并行编程足够复杂且容易出错,因此它们通常是专家开发者在高度专业化的软件中使用的领域。

幸运的是,软件正在逐步迎头赶上。程序员们正在意识到,通过更快的硬件来解决性能问题的时代已经过去,越来越重要的是在架构层面考虑并发处理的需求。

尽管它们密切相关,异步编程和并行编程的目标是不同的。异步编程旨在分离处理过程并减少阻塞,以便长时间运行的任务不会阻碍系统在同一进程中完成其他任务。相比之下,并行处理则旨在通过将工作划分为可分配给多个处理器并独立操作的任务,从而提高性能。

自.NET Framework 诞生以来,它通过线程和多种同步机制(如监视器、互斥量、信号量等)支持异步编程和并行编程。异步编程模型(APM),其中类定义了BeginXEndX方法用于需要异步执行的操作(例如System.IO.FileStream类中的BeginReadEndRead方法),长期以来一直是.NET 中异步编程的首选方式。

本章将探讨 F#使异步编程和并行编程更易于访问的几种方式,从而让你可以专注于创建正确的解决方案。我们将从对任务并行库(Task Parallel Library)的简要介绍开始。接下来,我们将讨论另一个 F#构造:异步工作流。最后,我们将介绍MailboxProcessor,F#基于代理的异步编程模型。

任务并行库

正如其名称所示,任务并行库(TPL) 擅长处理并行编程场景,并且是 CPU 密集型操作的首选机制。它通过统一的接口抽象了管理线程、锁、回调、取消和异常处理的大部分复杂性。尽管 TPL 并非专门针对 F#,但理解它的基本概念仍然很有帮助,尤其是当你需要与使用它的库中的代码进行交互时。

TPL 提供两种并行类型:数据并行和任务并行。

  • 数据并行。涉及对序列中的每个值执行特定操作,通过有效地将工作分配到可用的处理资源上。在数据并行模型下,你需要指定一个序列及相应的操作,TPL 会决定如何划分数据并相应地分配工作。

  • 任务并行。专注于并发执行独立任务。在任务并行中,你需要手动创建和管理任务,但该模型为你提供了更多的控制权。通过各种 Task 类,你可以轻松启动异步处理,等待任务完成,返回值,设置后续任务或生成额外任务。

注意

本节内容并非旨在提供关于 TPL 的全面指南。因此,它不会涉及任务创建、调度、管理或其他相关主题的许多细节。这里的目的是建立一个基准,提供足够的信息,使你在使用 TPL 编写代码时能够立即提高生产力。

潜在并行性

直接操作线程与使用 TPL 之间的一个主要区别在于,TPL 是基于任务的,而不是基于线程的。这一差异非常重要,因为 TPL 试图通过从线程池中提取线程来并发执行任务,但它并不保证并行性。这被称为 潜在并行性

每当你直接创建一个线程时,就会产生分配和调度的开销。如果没有足够的系统资源来支持线程,这种开销可能会对整体系统性能造成不利影响。基本的并发机制,如线程池,帮助通过重用现有线程来减少影响,但 TPL 进一步考虑了可用的系统资源。如果没有足够的资源可用,或者 TPL 认为并行执行任务会对性能造成不利影响,它将同步执行任务。随着资源随时间波动,TPL 的任务调度和工作划分算法帮助重新平衡工作,以有效利用可用资源。

数据并行

数据并行性主要通过使用位于 System.Threading.Tasks 命名空间中的 Parallel 类的静态 ForForEach 方法来实现。正如它们的名称所示,这些方法本质上是简单的 for 循环和可枚举 for 循环的并行版本。

注意

数据并行性还可以通过 PLINQ(并行 LINQ)的 AsParallel 扩展方法来实现。为了简化在 F# 中处理并行序列,F# PowerPack 中的 PSeq 模块使用与 Seq 模块相同的命名法暴露了许多 ParallelEnumerable 方法。

对于普通用法,Parallel.ForParallel.ForEach 仅在输入上有所不同;Parallel.For 接受范围边界,而 Parallel.ForEach 接受一个序列。这两个方法还接受一个作为循环体的函数,并且它们会隐式地等待所有迭代完成后再将控制权返回给调用者。由于这两个方法非常相似,本节的示例将统一使用 Parallel.For 以保持一致性。

最简单的形式,即并行 for 循环,仅为范围中的每个值调用一次操作。在这里,我们使用并行 for 循环来写出数字 0 到 99。

open System
open System.Threading.Tasks

Parallel.For(0, 100, printfn "%i")

这个代码片段几乎不需要解释。传递给 Parallel.For 的第一个参数标识范围的包含起点,第二个参数标识范围的排除终点。第三个参数是一个将数字写入控制台的函数。

锁定与避免锁定

现在我们正在处理并发,前面的示例中有一个微妙的 bug。内部,printfn 会在解析模式时逐步将文本发送到 System.Console.Out。因此,随着每个并行迭代的执行,可能会同时调用多个 printfn,导致一些项目交织在一起。

注意

用于本讨论的示例在 F# 3.1 中并不是一个问题,因为 printf 和相关函数得到了改进,使其运行速度比以前的版本快了最多 40 倍。

我们可以通过几种方式来解决这个问题。一个方法是使用 lock 操作符控制对 System.Console.Out 的访问。lock 操作符与 C# 中的 lock 语句(Visual Basic 中的 SyncLock)具有相同的作用,即在锁定资源释放之前,防止其他线程执行该代码块。以下是将前一个示例重新编写以使用锁定的代码:

Parallel.For(0, 100, fun n -> lock Console.Out (fun () -> printfn "%i" n))

有时锁定是合适的,但像这样使用它是一个糟糕的主意。通过锁定,我们抵消了并行化循环的大部分好处,因为一次只能写入一个项目!相反,我们希望尝试另一种方法,避免锁定并且不交织结果。

实现满意结果的最简单方法之一是函数组合。在这里,我们使用 sprint 函数来格式化数字,并将结果传递给 Console.WriteLine

Parallel.For(0, 100, (sprintf "%i") >> Console.WriteLine)

这种方法有效,因为每次调用 sprintf 都是写入一个独立的 StringBuilder,而不是共享的 TextWriter。这消除了锁的需求,从而消除了应用程序中的潜在瓶颈。

并行循环的短路操作

与 F# 内建的 for 循环不同,并行循环通过 ParallelLoopState 类的 BreakStop 方法提供了一些短路机制。TPL 负责创建和管理循环状态,因此你所需要做的就是使用暴露这些方法的重载。考虑以下 shortCircuitExample 函数:

open System.Collections.Concurrent
open System.Threading.Tasks

let shortCircuitExample shortCircuit =
  let bag = ConcurrentBag<_>()
  Parallel.For(
    0,
    999999,
  ① fun i s -> if i < 10000 then bag.Add i else shortCircuit s) |> ignore
  (bag, bag.Count)

与之前的示例一样,shortCircuitExample 函数使用 Parallel.For,但请注意在①处,提供的函数接受两个参数,而不是一个。第二个参数 s 是循环状态。

使用 shortCircuitExample 后,我们可以调用它,传递一个接受 ParallelLoopState 实例并调用 StopBreak 的函数,像这样:

shortCircuitExample (fun s -> s.Stop()) |> printfn "%A"
shortCircuitExample (fun s -> s.Break()) |> printfn "%A"

上述两行都会强制并行循环在所有迭代完成之前终止,但它们的效果截然不同。Stop 会使循环在最早的便利时终止,但允许正在执行的任何迭代继续;而Break 会使循环在当前迭代之后的最早时刻终止。你还需要注意,避免连续调用 StopBreak,以免引发 InvalidOperationException

这两个方法的差异可能非常明显。例如,在我桌面的一次运行中,Break 版本处理了 10,000 个项,而 Stop 版本仅处理了 975 个。

取消并行循环

取消并行 for 循环类似于短路操作,只不过它不是通过 StopBreak 方法从内部终止循环,而是识别一个外部的 取消令牌,该令牌由循环监视并做出响应。与短路机制不同,取消操作会强制所有使用相同令牌配置的任务停止。取消操作会引发 OperationCanceledException,因此你需要适当处理这个异常。

以下函数演示了如何取消并行 for 循环:

open System
open System.Threading.Tasks

let parallelForWithCancellation (wait : int) =
  use tokenSource = new ① System.Threading.CancellationTokenSource(wait)

  try
    Parallel.For(
      0,
      Int32.MaxValue,
    ② ParallelOptions(③ CancellationToken = ④ tokenSource.Token),
      fun (i : int) -> Console.WriteLine i
    ) |> ignore
  with
  | :? ⑤ OperationCanceledException -> printfn "Cancelled!"
  | ex -> printfn "%O" ex

在上面的代码中,我们在①创建了一个 CancellationTokenSource。该对象初始化为在指定的毫秒数后自动取消。在 try 块内部,我们使用了一个重载的 Parallel.For,它接受一个 ParallelOptions 实例,如②所示。通过这个 ParallelOptions 实例,我们将 CancellationToken 属性③初始化为 CancellationTokenSource ④所暴露的令牌。当令牌源的内部定时器到期时,并行循环会引发一个异常,然后在⑤处捕获并处理。虽然我们依赖于一个自动取消的 CancellationTokenSource,你也可以通过调用 Cancel 方法手动强制取消,通常是在另一个任务或线程中执行。

任务并行性

任务并行性使你可以在执行代码并行时,仍能对执行过程有最大的控制,同时抽象掉了许多实现细节。

创建并启动任务

任务可以通过几种方式创建和启动。最简单,但最不灵活的方式是 Parallel.Invoke 方法,它接受一个或多个函数并行执行,并隐式等待它们完成,像这样:

open System
open System.Threading.Tasks

Parallel.Invoke(
  (fun () -> printfn "Task 1"),
  (fun () -> Task.Delay(100).Wait()
             printfn "Task 2"),
  (fun () -> printfn "Task 3")
)

printfn "Done"

在这里,Parallel.Invoke 创建并启动了三个独立的任务。第一个和第三个任务只是简单地打印消息,而第二个任务在打印消息之前等待了 100 毫秒。

Parallel.Invoke 限制了你可以做的事情,因为它不暴露任何关于单个任务的信息,也没有提供任务成功或失败的反馈。你可以通过提供取消令牌来捕获和处理任务引发的异常并取消任务(类似于在取消并行循环中使用的方法),但就是这么多了。当你想对任务做更高级的操作时,你需要手动创建它们。

创建任务有两种手动方式:通过构造函数直接创建,或者通过 TaskFactory。就我们的目的而言,这两种方法的主要区别在于,使用构造函数创建任务时,你必须手动启动它们。微软推荐在任务创建和调度不需要分开时,优先使用 TaskFactory

要使用 Task 构造函数创建一个新任务,你只需要提供一个作为任务主体的函数,像这样:

open System.Threading.Tasks

let t = new Task(fun () -> printfn "Manual Task")

这会创建一个打印字符串的新任务。要启动任务,调用它的 Start 方法。

t.Start()

或者,你可以通过 TaskFactory 将这两个步骤合并为一步。方便的是,Task 类有一个静态的 Factory 属性,已经预设为一个默认的 TaskFactory,因此你无需自己创建一个。在这里,我们使用默认工厂的 StartNew 方法创建并启动一个任务:

open System.Threading.Tasks

let t = Task.Factory.StartNew(fun () -> printfn "Factory Task")

从任务中返回值

我们迄今为止所看到的任务只是调用一个操作,但你还需要知道如何返回值——这是传统异步模型中一个常见且繁琐的过程。TPL 通过一个泛型 Task<'T> 类使返回值变得简单,其中 'T 代表任务的返回类型。

警告

以下示例中使用的随机数生成方法足以用于演示,但请注意,System.Random 类不是线程安全的,即使每个任务创建一个新的实例也可能不足以保证线程安全。如果你的解决方案需要更强大的并发随机数生成方法,建议阅读 Stephen Toub 关于该主题的文章,地址是 blogs.msdn.com/b/pfxteam/archive/2009/02/19/9434171.aspx

创建返回值的任务几乎与我们已经看过的基本任务相同。Task<'T>类提供了一组构造函数重载,这些重载与非泛型Task类的构造函数类似,TaskFactory包括StartNew的泛型重载。为了演示,让我们使用StartNew<'T>来创建并运行一个返回随机数的任务。

let t = Task.Factory.StartNew(fun () -> System.Random().Next())

这个示例中唯一真正值得注意的地方是传递给StartNew的函数返回一个整数,并且泛型重载是被推断出来的。当然,返回一个值如果没有方法来访问它,是没有太大意义的,这就是为什么Task<'T>提供了Result属性,当任务完成时,它将包含返回值。在这里,我们展示了如何访问返回值:

**t.Result** |> printfn "Result: %i"

由于这是一个异步操作,因此无法保证在访问Result属性之前,任务已经执行完成。为此,Resultget访问器会检查任务是否已完成,并在必要时等待任务完成后再返回结果。通常,在任务开始后,立即访问结果不太常见,而是作为后续操作的一部分进行访问(如本章稍后所示)。

等待任务完成

当你的程序依赖于一个或多个任务完成后才能继续处理时,你可以使用其中一种等待机制来等待这些任务。为了方便,本节中的示例将使用以下函数,该函数返回一个新函数,该函数在打印消息之前会随机睡眠一段时间(模拟一个持续时间最长为delayMs的长时间操作):

let randomWait (delayMs : int) (msg : string) =
  fun () -> (System.Random().Next delayMs |> Task.Delay).Wait()
            Console.WriteLine msg

我们可以使用TaskFactory来创建任务,并通过任务的Wait方法等待它完成,如下所示:

let waitTask = Task.Factory.StartNew(randomWait 1000 "Task Finished")
**waitTask.Wait()**
printfn "Done Waiting"

在这段代码中,一个新的任务被创建并启动,但由于显式等待,直到任务完成后,消息“Done Waiting”才会被写入控制台。当后续代码依赖于任务完成时,这种方式非常有用。

你通常会希望并行运行多个任务,并在其中一个任务完成之前阻塞。为此,你可以使用Task类的静态WaitAny方法。最基本的WaitAny重载接受一个任务数组,并且只要数组中的任何一个任务完成,它就会停止阻塞。这里,我们将三个已启动的任务传递给WaitAny

Task.WaitAny(
    Task.Factory.StartNew(randomWait 2000 "Task 0 Finished"),
    Task.Factory.StartNew(randomWait 2000 "Task 1 Finished"),
    Task.Factory.StartNew(randomWait 2000 "Task 2 Finished"))
Console.WriteLine "Done Waiting"

当三个任务中的任何一个完成时,WaitAny将停止阻塞,从而允许执行继续进行到Console.WriteLine调用。请注意,WaitAny在解除阻塞时不会终止剩余的任务,因此它们会继续与源线程并行执行。

WaitAny类似,Task类提供了一个静态的WaitAll方法。WaitAll同样接受一个params任务数组,但与允许执行在一个任务完成时继续不同,WaitAll只有在所有任务都完成时才会解除阻塞。由于代码的区别仅在于调用了哪个方法,所以我没有包括示例,但我鼓励你尝试每种方法。在尝试时,可以多次运行每种形式并观察其差异。

延续任务

传统上,每当你希望在某些并行或异步代码完成后立即执行某些代码时,你需要将一个函数(称为回调)传递给异步代码。在 .NET 中,回调通常通过内置的AsyncCallback委托类型来实现。

使用回调是有效的,但它们可能会使代码变得复杂且难以维护。TPL 通过延续任务大大简化了这个过程,延续任务是配置为在一个或多个任务(称为先行任务)完成时启动的任务。

最简单的延续任务是由单个任务创建的。我们从创建一个作为先行任务的任务开始:

let antecedent =
  new Task<string>(
    fun () ->
      Console.WriteLine("Started antecedent")
      System.Threading.Thread.Sleep(1000)
      Console.WriteLine("Completed antecedent")
      "Job's done")

现在我们有了一个任务,我们可以通过将一个函数传递给任务的ContinueWith方法来设置延续任务,像这样:

let continuation =
  antecedent.ContinueWith(
    fun ① (a : Task<string>) ->
      Console.WriteLine("Started continuation")
      Console.WriteLine("Antecedent status: {0}", a.Status)
      Console.WriteLine("Antecedent result: {0}", a.Result)
      Console.WriteLine("Completed continuation"))

如你所见,创建一个延续任务与创建常规任务非常相似,但请注意在①处传递给ContinueWith方法的函数如何接受一个类型为Task<string>的参数。这个参数代表先行任务,以便延续任务可以根据先行任务的状态(例如,RanToCompletionFaultedCanceled等)或其结果(如果有的话)来分支。

此时,两个任务都尚未开始,因此我们将启动antecedent。当它完成时,TPL 将自动启动continuation。我们可以通过以下方式观察这种行为:

antecedent.Start()
Console.WriteLine("Waiting for continuation")
continuation.Wait()
Console.WriteLine("Done")

应该打印以下信息:

Waiting for continuation
Started antecedent
Completed antecedent
Started continuation
Antecedent status: RanToCompletion
Completed continuation
Done

ContinueWith方法在你处理单个任务时非常有用。当你有多个任务时,你可以转向TaskFactoryContinueWhenAnyContinueWhenAll方法。像它们的WaitAnyWaitAll对应方法一样,ContinueWhenAnyContinueWhenAll方法将在数组中的任何任务或所有任务完成时启动延续任务。为了简洁起见,我们将重点介绍ContinueWhenAll方法。

let antecedents =
  [|
    new Task(
        fun () ->
          Console.WriteLine("Started first antecedent")
          System.Threading.Thread.Sleep(1000)
          Console.WriteLine("Completed first antecedent"))
    new Task(
        fun () ->
          Console.WriteLine("Started second antecedent")
          System.Threading.Thread.Sleep(1250)
          Console.WriteLine("Completed second antecedent"))
    new Task(
        fun () ->
          Console.WriteLine("Started third antecedent")
          System.Threading.Thread.Sleep(1000)
          Console.WriteLine("Completed third antecedent"))
  |]

let continuation =
  ① Task.Factory.ContinueWhenAll(
    antecedents,
    fun ② (a : Task array) ->
      Console.WriteLine("Started continuation")
      for x in a do Console.WriteLine("Antecedent status: {0}", x.Status)
      Console.WriteLine("Completed continuation"))

for a in antecedents do a.Start()

Console.WriteLine("Waiting for continuation")
continuation.Wait()
Console.WriteLine("Done")

ContinueWhenAny遵循与WaitAny相同的模式。在这里,我们定义了三个任务,并在创建延续任务后手动启动它们,在①处创建延续任务。请注意在②处延续任务的参数。与使用ContinueWithContinueWhenAny时传递单个先行任务不同,使用ContinueWhenAll创建的延续任务接受一个任务数组。这个数组包含传递给ContinueWhenAll的所有任务,而不是启动延续任务的单个任务。这使你能够检查每个先行任务并根据需要细粒度地处理成功和失败的场景。

取消任务

取消任务在本质上与取消并行for循环相同,但它需要更多的工作,因为并行for循环会为你处理取消的细节。以下函数演示了取消任务,并遵循了典型的取消处理模式:

let taskWithCancellation (cancelDelay : int) (taskDelay : int) =
① use tokenSource = new System.Threading.CancellationTokenSource(cancelDelay)
② let token = tokenSource.Token

  try
    let t =
      Task.Factory.StartNew(
        (fun () ->
         ③ token.ThrowIfCancellationRequested()
          printfn "passed cancellation check; waiting"
          System.Threading.Thread.Sleep taskDelay
         ④ token.ThrowIfCancellationRequested()),
         token)
      ⑤ t.Wait()
  with
  | ex -> printfn "%O" ex
  printfn "Done"

与取消并行for循环类似,我们首先在①创建一个CancellationTokenSource。为了方便起见,我们在②将该令牌绑定到一个名称,以便在任务基于的函数内引用它。在任务体内,我们首先在③调用令牌的ThrowIfCancellationRequested方法,该方法检查令牌的IsCancellationRequested属性,如果该属性返回true,则抛出OperationCanceledException。我们这样做是为了确保在任务启动时如果请求了取消,就不会执行不必要的工作。当没有抛出异常时,执行将继续进行。在④,我们再次检查取消状态,以避免任务成功完成。最后,在⑤我们等待任务完成,以便处理任务抛出的任何异常。

异常处理

异常可以由任何数量的执行任务在任何时候抛出。当这种情况发生时,我们需要一种方法来捕获和处理它们。在前一节中,我们以通用方式处理了异常——通过匹配任何异常并将其写入控制台。如果你执行了taskWithCancellation函数,你可能注意到我们捕获的异常不是OperationCanceledException,而是一个包含OperationCanceledExceptionAggregateException。基本的异常类不太适合并行场景,因为它们只表示单一的失败。为了弥补这一点,介绍了一个新的异常类型AggregateException,它允许我们在一个构造体中报告一个或多个失败。

尽管你完全可以直接处理AggregateException,但通常你会希望在其中找到一个特定的异常。为此,AggregateException类提供了Handle方法,该方法遍历其InnerExceptions集合中的异常,以便你找到真正关心的异常并进行相应处理。

try
  raise (AggregateException(
          NotSupportedException(),
          ArgumentException(),
          AggregateException(
            ArgumentNullException(),
            NotImplementedException())))
with
| :? AggregateException as ex ->
      ex.Handle(
        ① Func<_, _>(
          function
          ② | :? AggregateException as ex1 ->
               ③ ex1.Handle(
                 Func<_, _>(
                   function
                   | :? NotImplementedException as ex2 -> printfn "%O" ex2; true
                   | _ -> true))
               true
           | _ -> true))

处理AggregateException遵循熟悉的异常处理模式:我们匹配AggregateException并将其绑定到名称ex,正如你所预期的那样。在处理程序内部,我们调用Handle方法①,接受一个Func<exn, bool>,表示提供的函数接受一个异常并返回布尔值。(为了像这里一样使用模式匹配函数,我们显式构造Func<_, _>实例,并让编译器推断出适当的类型参数。)在模式匹配函数②内部,我们检测是否有嵌套的AggregateException并在③处进行处理。在每一层,我们需要返回一个布尔值,指示特定的异常是否已处理。如果我们对任何异常返回false,则会抛出一个新的AggregateException,该异常包含未处理的异常。

处理AggregateException像这样可能变得相当繁琐、复杂和乏味。幸运的是,AggregateException提供了另一个方法Flatten,通过迭代InnerExceptions集合并递归遍历每个嵌套的AggregateException,来简化错误处理,构造一个新的AggregateException实例,该实例直接包含源异常层次结构中的所有异常。例如,我们可以修改之前的示例,使用Flatten来简化处理程序,如下所示:

try
  raise (AggregateException(
          NotSupportedException(),
          ArgumentException(),
          AggregateException(
            ArgumentNullException(),
            NotImplementedException())))
with
| :? AggregateException as ex ->
      ex.**Flatten()**.Handle(
        Func<_, _>(
          function
          | :? NotImplementedException as ex2 -> printfn "%O" ex2; true
          | _ -> true))

在这个修改后的示例中,我们对已展平的AggregateException调用Handle。由于只有一层需要处理,我们可以省略对嵌套AggregateExceptions的检查,直接处理NotImplementedException

异步工作流

尽管 TPL 为异步和并行编程带来了许多改进,但 F#提供了自己的模型,这种模型更好地匹配了语言强调的函数式范式。虽然有时在 F#中使用 TPL 是可取的(特别是在跨语言边界工作时),但你通常会转向 F#的异步工作流,它们最适合 I/O 操作。

异步工作流提供了一种统一且符合习惯的方式,用于在线程池上组合和执行异步代码。此外,它们的特性通常使得我们很难(如果不是不可能的话)陷入即使在 TPL 中也存在的某些异步陷阱。

注意

就像我们的 TPL 讨论一样,本节旨在为你提供异步工作流的基本工作知识,而不是作为一个全面的指南。

创建和启动异步工作流

异步工作流基于位于Microsoft.FSharp.Control命名空间中的Async<'T>类。该类型表示你希望异步运行的一段代码,最终返回某个值。不过,不是直接创建Async<'T>实例,我们通过异步表达式来组合它们,就像我们组合序列或查询一样。

异步表达式采用以下形式:

async { 异步表达式 }

在这里,async-expressions 代表一个或多个将参与异步操作的表达式。除了我们在本书中看到的标准表达式外,异步工作流允许你轻松地调用额外的工作流,并等待结果而不阻塞,通过一些熟悉的关键字如 letuse 的特殊变体。例如,let! 关键字调用一个异步工作流,并将结果绑定到一个名称。类似地,use! 关键字调用一个异步工作流,该工作流返回一个可处置对象,将结果绑定到一个名称,并在超出作用域时处置该对象。还可以使用 return! 关键字调用一个异步工作流并立即返回结果。

为了演示,我们将使用异步工作流的“hello world”示例:请求多个网页。首先,让我们定义一些函数来封装创建异步页面请求所需的逻辑(请注意,在 FSharp.Data 框架中有一个类似的函数 Http.AsyncRequestString):

open System
open System.IO
open System.Net

type StreamReader with
  member x.AsyncReadToEnd () =
    async { do! Async.SwitchToNewThread()
            let content = x.ReadToEnd()
            do! Async.SwitchToThreadPool()
            return content }

let getPage (uri : Uri) =
  async {

  let req = WebRequest.Create uri
  use! response = req.AsyncGetResponse()
  use stream = response.GetResponseStream()
  use reader = new StreamReader(stream)
  return! reader.AsyncReadToEnd()
}

在打开相关命名空间之后,我们通过单个 AsyncReadToEnd 方法扩展了 StreamReader 类。这个方法来自 F# PowerPack,类似于现有的 ReadToEndAsync 方法,不同之处在于,它并没有使用 TPL,而是返回一个异步工作流,我们可以在描述如何发起页面请求的 getPage 函数的最终步骤中进行评估。整体表达式的流程非常标准:创建一个 WebRequest,等待响应,然后显式返回响应流的内容。

注意

AsyncGetResponseMethod 是 F# 核心库中定义的一个扩展方法。它方便地将标准 .NET 代码包装在另一个异步工作流中,这使得使用 use! 成为可能,并大大简化了代码。

重要的是要认识到,getPage 实际上并不执行请求;它仅仅创建了一个表示请求的 Async<string> 实例。这使我们能够定义多个请求,或者将它们传递给其他函数。我们甚至可以多次执行请求。要执行请求,我们需要转向静态的 Async 类,你可以将其视为异步工作流的控制器。

启动异步工作流有多种方法。一些常见的方法列在表 11-1 中。

表 11-1. 常见的异步启动方法

方法 描述
RunSynchronously 启动异步工作流并等待其结果。
Start 启动异步工作流,但不等待结果。
StartImmediate 使用当前线程立即启动异步工作流。适用于 UI 更新。
StartWithContinuations 立即使用当前线程启动一个异步工作流,根据操作完成的情况调用成功、异常或取消的延续。

你选择的方法主要取决于工作流的具体任务,但通常情况下,除非应用程序需要其他方法,否则你会使用Start。由getPage函数创建的工作流返回的是一个网页请求的结果。由于我们在发起请求,通常我们不希望忽略结果,因此需要通过延续来处理结果。最简单的方法是将getPage的调用包装在另一个异步表达式中,当它完成时将结果传递给另一个函数,并使用Start启动整个工作流。在这里,我们调用getPage并打印结果:

async {
  let! content = Uri "http://nostarch.com" |> getPage
  content.Substring(0, 50) |> printfn "%s" }
|> Async.Start

使用 Async

Async是一个静态类而不是一个模块,这对你与其交互的方式有一定影响。与模块提供let绑定的函数不同,Async提供方法,其中许多方法是重载的,主要是为了帮助取消操作。此外,Async的方法通常采用面向对象的方法设计,这与核心 F#库中常见的设计方式不同。因此,它们的参数通常是元组,这使得使用管道操作时会比较困难。

另外,我们可以使用StartWithContinuations方法,该方法接受一个异步工作流以及三个函数,分别在工作流成功完成、抛出异常或被取消时调用。以下代码展示了这种方法:

Async.StartWithContinuations(
  ① getPage(Uri "http://nostarch.com"),
  ② (fun c -> c.Substring(0, 50) |> printfn "%s..."),
  ③ (printfn "Exception: %O"),
  ④ (fun _ -> printfn "Cancelled")
)

当异步操作①成功完成时,成功的延续②将被调用,并且页面源代码的前 50 个字符将被打印。如果操作抛出异常,异常延续③将被执行,并打印异常信息。最后,如果操作被取消,正如在取消异步工作流中所描述的,取消延续④将被执行,并显示一条通知用户操作已取消的信息。

我们也可以不依赖延续,而是使用RunSynchronously方法直接获取结果,如下所示:

let html =
  Uri "http://nostarch.com"
  |> getPage
  |> Async.RunSynchronously

当然,像这样运行一个单一的异步工作流实际上违背了异步运行的初衷,因为RunSynchronously会等待结果。相反,RunSynchronously通常与Async.Parallel一起使用,用于并行运行多个工作流并等待它们全部完成。例如,我们可以通过一个异步工作流数组来发起多个请求,如下所示:

open System.Text.RegularExpressions

[| getPage(Uri "http://nostarch.com")
   getPage(Uri "http://microsoft.com")
   getPage(Uri "http://fsharp.org") |]
|> Async.Parallel
|> Async.RunSynchronously
|> Seq.iter (fun c -> let sample = c.Substring(0, 50)
                      Regex.Replace(sample, @"[\r\n]| {2,}", "")
                      |> printfn "%s...")

在这里,我们使用 Parallel 方法将每个异步工作流合并成一个单一的工作流,然后将其传递给 RunSynchronously 方法。当每个请求完成时,我们遍历结果数组,去除一些字符以便于阅读,并打印结果。

取消异步工作流

在上一节中,我提到过异步工作流可以被取消。就像在 TPL 中一样,异步工作流使用取消令牌来控制取消。你可以自己管理令牌,这在某些情况下甚至是必要的,但在很多情况下,你可以依赖于 Async 类的默认令牌。

对于简单的场景,例如当你通过 StartStartWithContinuations 方法启动单个工作流时,你可以使用 CancelDefaultToken 方法来取消工作流,如下所示:

① Async.StartWithContinuations(
    getPage(Uri "http://nostarch.com"),
    (fun c -> c.Substring(0, 50) |> printfn "%s..."),
    (printfn "Exception: %O"),
    (fun _ -> printfn "Cancelled")
  )

② Async.CancelDefaultToken()

StartWithContinuations 方法① 监视默认令牌,并在通过 CancelDefaultToken 方法② 标记令牌为已取消时取消工作流。在此示例中,由于工作流在完成之前被取消,因此会调用取消回调,而不是成功回调,导致显示取消消息。

TryCancelled 方法接受一个工作流和一个在请求取消时将被调用的函数,这是一个很好的替代方案,适用于不返回值的工作流。在这里,displayPartialPage 函数将对 getPage 的调用包装在另一个异步工作流中。外部工作流等待响应并在收到消息时输出前 50 个字符。由于 TryCancelled 返回另一个工作流,并且不会自动启动它,我们需要通过调用 Start 显式启动它。

let displayPartialPage uri =
  Async.TryCancelled(
    async {
      let! c = getPage uri
      Regex.Replace(c.Substring(0, 50), @"[\r\n]| {2,}", "")
      |> sprintf "[%O] %s..." uri
      |> Console.WriteLine },
    (sprintf "[%O] Cancelled: %O" uri >> Console.WriteLine))

Async.Start(displayPartialPage (Uri "http://nostarch.com"))

Async.CancelDefaultToken()

默认令牌通常足以取消工作流。当你执行多个工作流并希望协调取消时,或者如果你希望对取消有更多控制时,你可以提供自己的令牌。考虑一下,当你请求三个页面并使用默认令牌请求取消时,会发生什么。

[| Uri "http://nostarch.com"
   Uri "http://microsoft.com"
   Uri "http://fsharp.org" |]
|> Array.iter (fun u -> Async.Start(displayPartialPage u))

Async.CancelDefaultToken()

执行上述代码通常会导致所有三个工作流被取消。(通常是这样,但并非总是如此,因为有可能一个或多个工作流在取消处理之前就完成了。)为了隔离每个工作流的取消,我们可以使用一个重载的 Start 方法,接受用户指定的令牌,如下所示:

  open System.Threading

  let tokens =
    [| Uri "http://nostarch.com"
       Uri "http://didacticcode.com"
       Uri "http://fsharp.org" |]
    |> Array.map (fun u -> ① let ts = new CancellationTokenSource()
                           Async.Start(displayPartialPage u, ② ts.Token)
                           ts)
③ tokens.[0].Cancel()
④ tokens.[1].Cancel()

在这个修改版中,我们使用 Array.map 将每个 Uri 映射到一个具有自己 CancellationTokenSource 的工作流,该源在①处创建。然后,我们将相关的令牌作为第二个参数传递给 Async.Start②,最后返回 CancellationTokenSource。最后,在③和④处,我们分别请求取消第一个和第二个请求,允许第三个请求正常进行。

取消异步工作流的一个特别好的地方在于,与 TPL 不同,取消令牌会自动传播到整个工作流。这意味着你无需手动确保每个新工作流都获得一个令牌,从而使代码更加简洁。

异常处理

由于异常可能在异步工作流中发生,因此了解如何正确处理它们是非常重要的。有几种异常处理选项可供选择,但它们的有效性可能会根据你的具体操作有所不同。

处理异步工作流中异常的最统一方式是将潜在有问题的代码包装在异步表达式中的 try...with 块内。例如,我们可以提供一个版本的 getPage 函数来处理页面请求和读取过程中抛出的异常,如下所示:

let getPageSafe uri =
  async {
    try
      let! content = getPage uri
      return Some content
    with
    | :? NotSupportedException as ex ->
      Console.WriteLine "Caught NotSupportedException"
      return None
    | :? OutOfMemoryException as ex ->
      Console.WriteLine "Caught OutOfMemoryException"
      return None
    | ex ->
      ex |> sprintf "Caught general exception: %O" |> Console.WriteLine
      return None }

上述代码中的 try...with 块没有什么异常——我们只是将对 getPage 的异步调用包装在 try...with 块中,并将成功读取的结果作为一个选项返回。如果操作抛出异常,我们匹配异常类型,打印消息,并返回 None

处理异步工作流异常的另一种方式是使用 Async.Catch 方法。与 StartWithContinuations 相比,Async.Catch 采用了更具函数式风格的方式:它返回 Choice<'T, exn>,其中 'T 是异步工作流的返回类型,exn 是工作流抛出的异常,而不是接受一个异常处理函数。

Choice 类型是一个带有两个联合情况的区分联合类型:Choice1Of2Choice2Of2。对于 Async.CatchChoice1Of2 代表工作流的成功完成并包含结果,而 Choice2Of2 代表失败并包含第一个抛出的异常。

使用 Async.Catch 处理异常使你能够结构化异步代码,创建符合惯用法的管道化数据流。例如,以下代码展示了如何将异步操作建模为一系列函数应用,从一个 Uri 开始。

Uri "http://nostarch.com"
|> getPage
|> Async.Catch
|> Async.RunSynchronously
|> function
   | Choice1Of2 result -> Some result
   | Choice2Of2 ex ->
      match ex with
      | :? NotSupportedException ->
        Console.WriteLine "Caught NotSupportedException"
      | :? OutOfMemoryException ->
        Console.WriteLine "Caught OutOfMemoryException"
      | ex ->
        ex.Message |> sprintf "Exception: %s" |> Console.WriteLine
      None

在这里,一个 Uri 被传递到 getPage 函数以创建一个异步工作流。生成的工作流被传递到 Async.Catch 中,设置另一个工作流,然后我们将其传递到 Async.RunSynchronously,以便等待结果。最后,我们将 Choice 传递给模式匹配函数,在该函数中我们要么返回 Some result,要么处理异常后返回 None

异步工作流与任务并行库

除了我们迄今为止看到的基于 ThreadPool 的异步操作外,Async 类还提供了几个用于处理 TPL 任务的方法。其中最显著的是 StartAsTaskAwaitTask

StartAsTask 方法作为 TPL 任务调用一个异步工作流。通常,你会在 CPU 密集型操作或者需要将异步工作流暴露给使用 TPL 的 C# 或 Visual Basic 代码时使用它。例如,我们可以像这样将 getPage 函数的结果视为 TPL 任务:

Uri "http://nostarch.com"
|> getPage
|> Async.StartAsTask
|> (fun t -> ① t.Result.Substring(0, 50))
|> printfn "%s"

① 处存在 Result 属性,表明 StartAsTask 的结果确实是一个 Task。在更现实的场景中,你可能不会启动一个任务后立刻阻塞等待结果,但这个示例仅用于展示如何将异步工作流作为 TPL Task 启动。

StartAsTask 方法在你需要创建新任务时非常方便,但如果你需要处理一个已有的任务怎么办?考虑一下在 .NET 4.5 中添加到 System.Net.WebClient 类中的 DownloadStringTaskAsync 方法。这个方法与我们的 getPage 函数的作用相同,区别在于它将下载资源封装在 TPL 任务中。

在 C# 中,你可以像这样通过 async 修饰符和 await 操作符轻松处理此类方法:

// C#
// using System.Threading.Tasks

private static ① async Task<string> GetPageAsync(string uri)
{
    using (var client = new System.Net.WebClient())
    {
      return ② await client.DownloadStringTaskAsync(uri);
    }
}

static void Main()
{
    var result = GetPageAsync("http://nostarch.com").Result;
    Console.WriteLine("{0}", result.Substring(0, 50));
    Console.ReadLine();
}

从一个大大简化的角度来看,在前面的 C# 代码中发生的事情是这样的:async 修饰符①被应用于 GetPageAsync 方法,表示方法的一部分将异步执行。接着,await 操作符②表示执行应该返回给调用者,方法的其余部分应作为一个后续操作,待任务完成时执行。

异步工作流使我们能够在 F# 中使用 AwaitTask 方法结合 TPL 任务和 let!use!return! 跟随类似的模式。下面是 F# 中的对应代码:

// F#
open System.Threading.Tasks

let getPageAsync (uri : string) =
  async {
    use client = new System.Net.WebClient()
  ① return! Async.AwaitTask (client.DownloadStringTaskAsync uri)
  }

async {
② let! result = getPageAsync "http://nostarch.com"
  result.Substring(0, 50) |> printfn "%s"
} |> Async.Start

虽然它们在功能上并不完全等价(C# 版本会在 Main 中等待结果,而 F# 版本则将结果传递给一个后续操作),但 F# 方法与 C# 的方法类似。在 F# 版本中,通过 getPageAsync 函数创建的异步工作流使用 return!Async.AwaitTask ① 来等待任务完成后返回结果。然后,在第二个异步工作流中,let! ② 用于求值 getPageAsync,同时打印结果被视为一个后续操作。

基于代理的编程

如果说 TPL 和异步工作流还不足以让并行和异步编程变得足够易用,F# 还借用了来自 Erlang 的消息处理机制。MailboxProcessor<'T> 类实现了一个基于队列的系统,用于通过共享内存异步路由消息(数据项)到处理器。这在多个源(客户端)需要从单一目标(服务器)请求某些内容的场景中非常有用,经典的例子就是 web 服务器。此外,由于 MailboxProcessor 实例极其轻量化,应用程序可以管理成千上万个实例而不至于出现问题。这一特性使得邮件处理器能够独立工作或通过实例之间传递消息共同工作。

MailboxProcessor实例通常被称为代理,我将在本节中遵循这一惯例。在这方面,基于代理的编程中常见的做法是将MailboxProcessor<'T>别名为Agent<'T>,如下所示:

type Agent<'T> = MailboxProcessor<'T>

通过类型别名化,我们可以使用更方便的名称创建代理。

入门

我认为理解基于代理的编程的最佳方式是通过示例。我们从一个简单的代理开始,它会打印传递给它的任何内容。

type Message = | Message of obj

let echoAgent =
① Agent<Message>.Start(
    fun inbox ->
    ② let rec loop () =
        async {
          let! (Message(content)) =  ③ inbox.Receive()
          printfn "%O" content
        ④ return! loop()}
    ⑤ loop())

在前面的代码中,我们通过将一个函数传递给Start方法创建了一个名为echoAgent的代理,如①所示。根据惯例,函数的参数被命名为inbox,因为它是我们接收新消息的邮箱。在②处,我们定义了递归的loop函数,我们将不断调用该函数来接收新消息。

注意

使用 while 循环来进行命令式循环当然是可能的,但递归函数是更典型的方法。函数式循环提供了额外的好处,当你需要管理多个状态时,它使得提供不同的循环逻辑变得更加容易。例如,如果你的代理在暂停状态下需要与运行状态下有不同的行为,你可以定义一对互相递归的函数,它们都返回一个工作流,根据相应的状态进行处理。

在循环内部,我们创建了一个异步工作流,首先使用Receive方法异步地从inbox接收消息,如③所示。接下来,接收到的消息会被打印出来,然后在④处进行异步递归调用loop。最后,在⑤处,我们通过进行标准的同步调用来启动递归,调用loop

echoAgent处于主动监听状态时,我们可以通过Post方法向它发送一些消息,如下所示:

> **Message "nuqneH" |> echoAgent.Post;;**
nuqneH
> **Message 123 |> echoAgent.Post;;**
123
> **Message [ 1; 2; 3 ] |> echoAgent.Post;;**
[1; 2; 3]

如你所见,当echoAgent接收到一条消息时,它会将其写入控制台,然后echoAgent等待另一条消息,整个过程不断重复。

扫描消息

echoAgent示例中,我们使用Receive方法从底层队列获取消息。在许多情况下,Receive方法是合适的,但它会将消息从队列中移除,导致难以过滤消息。为了有选择性地处理消息,你可以考虑改用Scan方法。

扫描消息的方式与直接接收消息的方式不同。Scan方法不是直接内联处理消息并始终返回异步工作流,而是接受一个过滤函数,该函数接受一条消息并返回Async<'T>选项。换句话说,当消息是你想处理的内容时,你返回Some<Async<'T>>;否则,返回None。为了演示,我们将修改echoAgent,只处理字符串和整数。

let echoAgent2 =
  Agent<Message>.Start(fun inbox ->
    let rec loop () =
      inbox.Scan(fun (Message(x)) ->
       match x with
       | ① :? string
       | ② :? int ->
         Some (async { printfn "%O" x
                       return! loop() })
       | _ -> printfn "<not handled>"; None)
   loop())

在①和②处,你可以看到标准的动态类型测试模式,用来分别将传入的消息过滤为字符串和整数。当消息是这两种类型之一时,我们会将一个异步工作流与Some关联并返回它。对于所有其他消息,我们返回NoneScan检查返回的值,当它是Some时,消息被消费(从队列中移除)并触发工作流。返回值是None时,Scan立即等待另一个消息。

echoAgent2传递消息和之前一样——只需通过Post方法传递消息:

> **Message "nuqneH" |> echoAgent2.Post;;**
nuqneH
> **Message 123 |> echoAgent2.Post;;**
123
> **Message [ 1; 2; 3 ] |> echoAgent2.Post;;**
<not handled>

扫描消息确实提供了一些处理消息的灵活性,但你需要注意你向代理发布了什么,因为Scan未处理的消息会保留在队列中。随着队列大小的增加,扫描将需要更长的时间,因此,如果不小心使用这种方法,你可能会很快遇到性能问题。你可以通过检查CurrentQueueLength属性来查看队列中有多少消息。如果你需要从队列中移除消息,可以通过调用Receive来处理队列中的每条消息,但如果你需要这样做,这可能意味着一个更大的设计问题需要解决。

回复消息

到目前为止,我们创建的代理都是自包含的:它们接收消息,处理消息,然后等待下一个消息。不过,代理不必在孤立中工作。有一种方法可以使代理更加互动,那就是让它们通过AsyncReplyChannel来回复。为了演示这一点,我们再次修改echoAgent,这次,我们不再在代理内部打印消息,而是让它进行回复。

① type ReplyMessage = | ReplyMessage of obj * AsyncReplyChannel<obj>

  let echoAgent3 =
    Agent.Start(fun inbox ->
      let rec loop () =
        async {
          let! ② (ReplyMessage(m, c)) = inbox.Receive()
          ③ c.Reply m
          return! loop()
      }
    loop())

echoAgent3的整体结构与之前的版本差别不大。为了方便起见,我们使用了一个判别联合类型①作为我们的消息类型,这在基于代理的编程中是典型的做法。在这种情况下,ReplyMessage联合类型有一个单一的案例,包含两个关联值,一个对象和一个回复通道。

在循环体内,我们使用模式匹配②来识别联合案例并提取消息和通道。然后,我们将消息传递给通道的Reply方法③,然后再继续循环。现在剩下的就是向代理发送消息了。

ReplyMessage的第二个值是一个AsyncReplyChannel<obj>,正如你已经看到的那样。理论上,我们可以手动构建一个回复通道,并使用Post方法将ReplyMessage发送给代理,但那样我们就必须手动处理等待结果的过程。获取回复通道有更好的方法——即PostAndReply方法及其变种。

PostAndReply方法与Post方法略有不同,它们并不直接接受消息,而是高阶函数,接受一个函数,该函数接收一个预构建的回复通道并返回完全构建好的方法。为了方便起见,我们会简单地创建一个ReplyMessage,如下所示:

echoAgent3.PostAndReply(fun c -> ReplyMessage("hello", c))
|> printfn "Response: %O"

在内部,PostAndReply(及其变体)构建回复通道,并将其传递给提供的函数,该函数随后创建最终发布到代理的消息。

示例:基于代理的计算器

现在,您已经看到了多种创建和与代理交互的方式,让我们来看看一个更有趣的示例,将多个概念结合在一起,做一些比简单重复输入更有用的事情:一个基于代理的计算器。我们将从定义一个代表计算器将支持的消息的区分联合类型开始。

type Operation =
| Add of float
| Subtract of float
| Multiply of float
| Divide of float
| Clear
| Current of AsyncReplyChannel<float>

Operation联合类型定义了六个案例。在这些案例中,四个表示基本的数学操作,并且有一个与之关联的float值用于计算。Clear案例允许我们清除存储的值。最后,Current案例允许我们通过其关联的回复通道查询代理的当前值。从这个定义中,我们可以创建一个新的代理来处理每个案例,如下所示:

let calcAgent =
  Agent.Start(fun inbox ->
   let rec loop total =
     async {
       let! msg = inbox.Receive()
       let newValue =
         match msg with
         | Add x -> total + x
         | Subtract x -> total - x
         | Multiply x -> total * x
         | Divide x -> total / x
         | Clear -> 0.0
         | Current channel ->
           channel.Reply total
           total
       return! loop newValue }
   loop 0.0)

即使calcAgent看起来保持了一个运行中的总和,它实际上有点是一个假象,因为我们仅通过将一个值(total)传递给递归的loop函数来保持状态。当calcAgent收到消息时,它使用模式匹配来确定适当的操作,并将结果绑定到newValue。例如,当它收到AddSubtractMultiplyDivide操作时,它会对total应用相应的数学操作。同样,当它收到Clear操作时,它仅返回0.0,而Current在回复后返回total

要查看calcAgent的实际操作,我们只需要发送一些消息给它:

[ Add 10.0
  Subtract 5.0
  Multiply 10.0
  Divide 2.0 ]
|> List.iter (calcAgent.Post)

calcAgent.PostAndReply(Current) |> printfn "Result: %f"
calcAgent.Post(Clear)
calcAgent.PostAndReply(Current) |> printfn "Result: %f"

在前面的代码片段中,我们简单地将一个Operations列表传递给List.iter,并将每个消息发送到calcAgent。当这些消息被处理后,我们查询当前值,清空,然后再次查询以确保总数已经归零。调用前面的代码片段会产生以下结果:

Result: 25.000000
Result: 0.000000

总结

异步和并行编程长期以来被视为专门用于特定软件的工具,仅限于经验丰富的开发者。随着处理器制造商通过增加核心而不是提高时钟速度来提升处理器性能,软件开发者不再能够仅通过升级硬件来解决性能问题,也无法继续期待用户在长时间运行的操作完成之前一直等待返回控制。

像 F# 这样的语言通过提供多种强大的机制,使得异步和并行编程变得更加易于接触。TPL(任务并行库)使开发者能够高效地处理 CPU 密集型操作,如处理大型数据集,同时有效利用系统资源。像异步工作流这样的语言特性,能够在 IO 密集型操作(例如 Web 请求或文件访问)期间保持应用程序的响应性。最后,基于代理的编程让你可以轻松协调复杂的系统,通过启动独立的异步进程,而无需直接管理传统线程模型的复杂性。通过这些方法,你可以构建出可扩展、响应迅速的应用程序,满足现代计算的需求,同时让你专注于软件要解决的实际问题。

第十二章。计算表达式

在第六章中,我们了解了序列表达式如何简化序列的创建。在第十章中,我们看到查询表达式如何为从不同数据源查询数据提供统一的方法。类似地,在第十一章中,我们探讨了如何利用异步工作流简化异步操作的创建与执行。这些构造各自服务于不同的目的,但它们共同的特点是,它们都是 F#语言的另一个特性:计算表达式。

计算表达式,有时也称为工作流,提供了一种方便的构造,用于表达一系列操作,其中数据流和副作用被控制。从这个角度来看,计算表达式类似于其他函数式语言所称的单子。然而,计算表达式的不同之处在于,它们被设计成个别表达式看起来像语言的自然部分。

在计算表达式的上下文中,你可以重新利用几个熟悉的语言元素——例如letuse关键字以及for循环——将语法与语言统一。计算表达式还为其中一些元素提供了替代的“bang”语法,允许你嵌套计算表达式进行内联求值。

由于该特性具有广泛的适用性,计算表达式可以简化与复杂类型的交互,并适用于各种场景。例如,我们已经知道内置的计算表达式可以简化序列创建、查询和异步处理,但它们也在日志记录和一些项目中具有应用,例如{m}brace 框架,旨在简化将计算任务卸载到云端。

在本章中,我们将探讨计算表达式的内部工作原理。我们将跳过讨论单子理论,因为它并不能帮助你理解计算表达式如何融入到你的解决方案中。相反,我们将从了解构建器类及其如何启用计算表达式开始。在建立了这个基础后,我们将通过两个自定义计算表达式的示例来进行讲解。

计算表达式的结构

你已经熟悉了编写计算表达式的基本模式,但直到现在,你还没有看到它们如何运作,除了在我们创建一些额外查询操作符时对其背后原理进行简短的介绍(参见扩展查询表达式)。为了更一般地重申,计算表达式具有以下形式:

*builder-name* { *computation-expression-body* }

计算表达式围绕一个基本的计算类型(有时称为单子类型)设计,我们通过透明地调用构建器类暴露的方法来进行计算。在前面的语法中,builder-name 表示构建器类的一个具体实例,computation-expression-body 表示一系列嵌套的表达式,这些表达式映射到产生计算类型实例所需的方法调用。例如,异步工作流基于 Async<'T>,通过 AsyncBuilder 构建。类似地,查询表达式基于 QuerySource<'T, 'Q>,通过 QueryBuilder 构建。

注意

序列表达式在计算表达式领域中是一种特例,因为它们不遵循正常的实现模式。尽管序列表达式使用计算表达式语法并基于 IEnumerable<'T>,但它们没有对应的构建器类。相反,通常由构建器类处理的细节直接由 F# 编译器处理。

构建器类定义了计算表达式支持的操作。定义构建器类在很大程度上是一种约定,因为没有特定的接口需要实现,也没有基类需要继承。对于构建器类的命名没有严格的规则,但通常是通过在基础类型名称后附加 Builder 来命名(例如,AsyncBuilderQueryBuilder)。

虽然计算表达式是语言的一部分,但它们实际上只是语法糖——一种更方便的方式来调用构建器类的方法。当编译器遇到看似计算表达式的代码时,它会尝试通过一个叫做去糖化的过程将代码转换为一系列方法调用。这个过程涉及用构建器类型上对应实例方法的调用替换计算表达式中的每个操作(类似于 LINQ 查询表达式如何转换为 C# 和 Visual Basic 中的扩展方法调用和委托)。我喜欢把构建器类的方法分为两组。第一组列在表 12-1 中,控制各种语法元素,如绑定、forwhile 循环、以及返回值。

表 12-1. 语法元素控制方法

方法 描述 签名
Bind 启用 let!do! 绑定 M<'T> * ('T -> M<'U>) -> M<'U>
For 启用 for 循环 seq<'T> * ('T -> M<'U>) -> M<'U>seq<'T> * ('T -> M<'U>) -> seq<M<'U>>
Return 启用 return 'T -> M<'T>
ReturnFrom 启用 return! M<'T> -> M<'T>
TryFinally 允许通过 try...finally 进行异常处理 M<'T> * (unit -> unit) -> M<'T>
TryWith 允许通过 try...with 进行异常处理 M<'T> * (exn -> M<'T>) -> M<'T>
Using 使得可以使用 useuse! 创建 IDisposable 对象 'T * ('T -> M<'U>) -> M<'U>'U :> IDisposable
While 允许在计算表达式中使用 while...do 循环 (unit -> bool) * M<'T> -> M<'T>
Yield 使用 yield 关键字,以类似序列的方式从嵌套的计算表达式中返回项 'T -> M<'T>
YieldFrom 使用 yield! 关键字,以类似序列的方式从嵌套的计算表达式中返回项 M<'T> -> M<'T>

第二组方法,控制计算表达式如何被评估的方法,列在表 12-2 中。

表 12-2. 影响计算表达式评估的方法

方法 描述 签名
Combine 将计算表达式的两个部分合并成一个 M<'T> * M<'T> -> M<'T>M<unit> * M<'T> -> M<'T>
Delay 将计算表达式包装成一个函数,以便延迟执行,从而帮助防止不必要的副作用 (unit -> M<'T>) -> M<'T>
Run 在评估计算表达式时作为最后一步执行;可以通过调用 Delay 返回的函数来“撤销”延迟,也可以将结果转换为更易消费的格式 M<'T> -> M<'T>M<'T> -> 'T
Zero 返回表达式的单子类型的默认值;当计算表达式没有显式返回值时使用 unit -> M<'T>'T 可以是 unit

由于计算表达式的设计目的是使其能够适用于各种情况,因此保持它们尽可能通用非常重要。这一点通过签名的高度通用结构得以体现。例如,M<_>的符号表示底层类型封装了另一个值。

在你的构建器类中并不需要实现表 12-1 中列出的每个方法。然而,如果你省略了某些方法,相应的映射语法将无法在计算表达式中使用,编译器会报错。例如,如果你尝试在自定义计算表达式中包含 use 绑定,但省略了构建器类中的 Using 方法,编译将失败,并显示如下错误信息:

error FS0708: This control construct may only be used if the computation
expression builder defines a 'Using' method

同样,并非每个方法都需要从表 12-2 实现,但在某些情况下未实现某些方法可能会导致不希望出现的结果。例如,未实现 Delay 方法将阻止你组合返回多个结果的表达式。此外,当计算表达式涉及副作用时,未实现 Delay 方法可能会过早引发副作用——无论它们出现在表达式的何处——因为它们会在遇到时立即被评估,而不是被包装在一个函数中以便延迟执行。

当我们仅仅讨论构建器类和方法调用时,计算表达式可能很难理解。我认为,走过一些简单的实现示例,看看这些组件如何协同工作,更加有帮助。本章剩余的部分我们将讨论两个示例。特别是,我们将查看构建器实现、它们对应的表达式语法以及去糖过程。

示例:FizzBuzz

在第七章中,我们研究了几种通过使用Seq.map迭代序列以及使用带有活动模式和部分活动模式的模式匹配函数来解决 FizzBuzz 问题的方法。然而,FizzBuzz 问题的核心本质上只是一个序列转换的练习。因此,使用计算表达式可以轻松解决该问题。

当作为计算表达式实现时,我们的 FizzBuzz 序列可以以一种方式构建,使其看起来和行为像一个标准的序列表达式。然而,使用计算表达式时,将数字映射到相应的字符串将完全抽象化,隐藏在构建器类中。

由于 FizzBuzz 将整数转换为字符串并且不包含内在状态,我们将跳过创建中介包装类型,直接从创建构建器类开始,逐步实现,首先从 Yield 方法开始。

type FizzBuzzSequenceBuilder() =
  member x.Yield(v) =
    match (v % 3, v % 5) with
    | 0, 0 -> "FizzBuzz"
    | 0, _ -> "Fizz"
    | _, 0 -> "Buzz"
    | _ -> v.ToString()

现在我们已经有了一个基础的构建器类,我们可以创建实例,并在每次 FizzBuzz 计算表达式中使用它,像这样:

let fizzbuzz = FizzBuzzSequenceBuilder()

就这样!没有什么花哨的地方;我们只是通过它的主构造函数创建了类的一个实例。为了将该实例用作计算表达式,我们可以编写如下内容:

> **fizzbuzz { yield 1 };;**
val it : string = "1"

如你所见,评估前面的表达式并没有给我们预期的结果。它并没有返回一个字符串序列,而是只返回了一个单一的字符串,因为到目前为止,构建器类还不知道如何创建序列;它只是基于整数值返回一个字符串。你可以在去糖后的形式中更清楚地看到这一点,它大致如下:

fizzbuzz.Yield 1

要获得一个字符串序列,我们可以让Yield返回一个单例序列(只包含一个项目的序列),但这样做会使实现其他方法(如ForWhile)变得复杂。相反,我们将扩展构建器类,包含Delay方法,如下所示(确保在更新构建器类后重新创建构建器实例,以确保使用最新定义来评估fizzbuzz表达式):

type FizzBuzzSequenceBuilder() =
-- *snip* --
member x.Delay(f) = f() |> Seq.singleton

Delay方法到位的情况下,评估之前的fizzbuzz表达式会得到一个稍微更理想的结果:

> **fizzbuzz { yield 1 };;**
val it : seq<string> = seq ["1"]

同样,去糖化后的表达式可以帮助澄清发生了什么。通过包含Delay方法,去糖化后的形式现在如下所示:

fizzbuzz.Delay(fun () -> fizzbuzz.Yield 1)

但是,如今,来自fizzbuzz表达式的所有结果都只是一个单例序列,因为我们无法生成多个值。实际上,试图按照以下方式生成多个值将导致编译器错误,指示构建器类必须定义一个Combine方法:

fizzbuzz {
  yield 1
  yield 2
  yield 3 }

为了使前面的代码片段能够正常工作,我们将提供两个重载版本的Combine方法。重载方法的原因是,根据表达式中的位置,我们可能是将单个字符串组合成一个序列,或者是将一个新的字符串附加到现有的序列中。我们需要小心,避免创建包含序列的序列,因此我们还需要重载现有的Delay方法,使其简单地返回一个提供的序列。我们可以按如下方式实现这些方法:

type FizzBuzzSequenceBuilder() =
  -- *snip* --
  member x.Delay(f : unit -> string seq) = f()
  member x.Combine(l, r) =
    Seq.append (Seq.singleton l) (Seq.singleton r)
  member x.Combine(l, r) =
    Seq.append (Seq.singleton l) r

现在,评估前面的fizzbuzz表达式将得到一个包含三个字符串的序列:

> **fizzbuzz {**
  **yield 1**
  **yield 2**
  **yield 3 };;**
val it : seq<string> = seq ["1"; "2"; "Fizz"]

当像这样生成多个结果时,去糖化过程会产生一个更复杂的链式方法调用。例如,去糖化前面的表达式(生成三个项)会得到类似下面的代码:

fizzbuzz.Delay (fun () ->
  fizzbuzz.Combine (
    fizzbuzz.Yield 1,
    fizzbuzz.Delay (fun () ->
      fizzbuzz.Combine(
        fizzbuzz.Yield 2,
        fizzbuzz.Delay (fun () -> fizzbuzz.Yield 3)))))

一次性生成一个实例的方式(我们一直在使用的这种方式)并不是构建任意长度序列的高效方法。如果我们能够通过一个for循环来组合一个fizzbuzz表达式,那就会更好。为此,我们需要实现For方法。我们采取的方法是简单地包装一次对Seq.map的调用,如下所示:

type FizzBuzzSequenceBuilder() =
  -- *snip* --
  member x.For(g, f) = Seq.map f g

现在生成 FizzBuzz 序列变得非常简单,因为我们可以将一个单独的yield表达式嵌套在for循环中,而不是使用多个yield表达式,像这样:

fizzbuzz { for x = 1 to 99 do yield x }

在构建器类中实现YieldDelayCombineFor方法的一个优点是,我们可以将这些风格组合起来,从而实现更灵活的表达式。例如,我们可以直接在循环中生成值,然后再将它们输出:

fizzbuzz { yield 1
           yield 2
           for x = 3 to 50 do yield x }

如目前所写,构建器类并不支持你可以组合各种表达式的每一种方式,但你不应该在添加适当的重载以支持更多场景时遇到问题。

为了方便起见,这里是完整的构建器类:

type FizzBuzzSequenceBuilder() =
  member x.Yield(v) =
    match (v % 3, v % 5) with
    | 0, 0 -> "FizzBuzz"
    | 0, _ -> "Fizz"
    | _, 0 -> "Buzz"
    | _ -> v.ToString()
  member x.Delay(f) = f() |> Seq.singleton
  member x.Delay(f : unit -> string seq) = f()
  member x.Combine(l, r) =
    Seq.append (Seq.singleton l) (Seq.singleton r)
  member x.Combine(l, r) =
    Seq.append (Seq.singleton l) r
  member x.For(g, f) = Seq.map f g

示例:构建字符串

FizzBuzz 很好地展示了如何使用计算表达式通过ForYield方法创建自己的类似序列的构造,但它对于日常计算并不特别实用。为了得到一个更实用的例子,我们转向一个常见的编程任务:合并字符串。

长久以来,使用StringBuilder构建字符串通常比连接字符串更高效已被广泛认可。StringBuilder的流畅接口使代码保持相当简洁,如下所示:

open System.Text

StringBuilder("The quick ")
  .Append("brown fox ")
  .Append("jumps over ")
  .Append("the lazy dog")
  .ToString()

创建一个StringBuider实例并将不同的Append方法链接调用并不完全符合函数式优先的范式,然而,Printf模块通过bprintf函数试图解决这种脱节问题,bprintf函数格式化一个字符串并将其附加到StringBuilder实例中,如下所示:

let sb = System.Text.StringBuilder()
Printf.bprintf sb "The quick "
Printf.bprintf sb "brown fox "
Printf.bprintf sb "jumps over "
Printf.bprintf sb "the lazy dog"
sb.ToString() |> printfn "%s"

然而,bprintf所完成的事情仅仅是将实例方法调用替换为一个接收StringBuilder作为参数的函数调用。更重要的是,你仍然需要管理StringBuilder实例,并将其传递给每一个bprintf调用。通过计算表达式,你不仅可以让字符串构造看起来像 F#语言的自然部分,还可以抽象掉StringBuilder!我们将很快定义的计算表达式将允许我们使用以下语法组合字符串:

buildstring {
  yield "The quick "
  yield "brown fox "
  yield "jumps over "
  yield "the lazy dog" }

在这里,我们通过在buildstring表达式中yield多个字符串来将它们串联起来。为了实现这一点,我们首先需要定义表达式的基础类型。为了方便起见,我们将使用一个称为StringFragment的判别联合来跟踪我们在yield时所有的字符串。StringFragment类型定义如下:

open System.Text

type StringFragment =
| ① Empty
| ② Fragment of string
| ③ Concat of StringFragment * StringFragment
  override x.ToString() =
    let rec flatten frag (sb : StringBuilder) =
      match frag with
      | Empty -> sb
      | Fragment(s) -> sb.Append(s)
      | Concat(s1, s2) -> sb |> flatten s1 |> flatten s2
    (StringBuilder() |> flatten x).ToString()

StringFragment联合体有三种情况,Empty①,Fragment②和Concat③。Empty表示空字符串,而Fragment包含一个单一的字符串。最后的情况,Concat,形成一个StringFragment实例的层次结构,最终通过ToString方法将它们连接在一起。这种类型的优点在于,一旦构建器就位,你就不需要手动管理这些实例或StringBuilder了。

构建器类,我们称之为StringFragmentBuilder,与

FizzBuzzBuilder,但它不是创建序列,而是创建StringFragment。根据之前的语法,我们已经知道我们将使用yield关键字,因此我们需要提供一个Yield方法。为了生成多个项,我们还需要实现CombineDelay方法。此外,允许嵌套表达式也是一个不错的主意,因此我们将实现一个YieldFrom方法。以下是完整的StringFragmentBuilder类,以及与buildString表达式一起使用的实例:

type StringFragmentBuilder() =
  member x.Zero() = Empty
  member x.Yield(v) = Fragment(v)
  member x.YieldFrom(v) = v
  member x.Combine(l, r) = Concat(l, r)
  member x.Delay(f) = f()
  member x.For(s, f) =
    Seq.map f s
    |> Seq.reduce (fun l r -> x.Combine(l, r))

let buildstring = StringFragmentBuilder()

StringFragmentBuilder类比FizzBuzzSequenceBuilder简单得多,因为它仅关注将字符串映射到StringFragments并控制执行。我们逐一查看每个方法,以了解它们在计算表达式中的使用方式。

第一个方法Zero为表达式返回一个默认值。在这种情况下,我们返回Empty表示一个空字符串。在去糖化过程中,当表达式返回unit或嵌套的if表达式不包括else分支时,会自动插入对Zero的调用。

Yield方法在buildstring表达式中启用了yield关键字。在这个实现中,Yield接受一个字符串,并将其包装在一个新的Fragment实例中。

YieldFrom方法允许你通过yield!关键字求值一个嵌套的buildstring表达式。这个方法类似于Yield,但它返回的是嵌套表达式创建的StringFragment,而不是返回一个新的StringFragment

每个yieldyield!在计算表达式中代表着表达式的一部分结束,因此我们需要一种方法将它们合并在一起。为此,我们使用Combine方法,它本质上将表达式的其余部分视为一个延续。Combine接受两个StringFragments,并将它们各自包装在一个Concat实例中。

Combine,暴露

我认为通过查看去糖化的形式,更容易理解Combine方法的作用。假设你正在编写一个buildstring表达式,将"A""B"合并为一个字符串,如下所示:

buildstring {
  yield "A"
  yield "B" }

该表达式的相应去糖化形式将非常类似于此:

buildstring.Combine(
  buildstring.Yield("A"),
  buildstring.Yield("B"))

为了更清晰地理解,我将去糖化的形式简化为仅包含理解过程所需的部分。这里,第一个Yield调用返回Fragment("A"),第二个返回Fragment("B")Combine方法接受这两者并生成以下内容:

Concat (Fragment "A", Fragment "B")

Combine会在第一个yield之后为每个yield调用。如果我们的假设示例扩展到也yield "C",那么去糖化后的形式将类似于以下简化代码:

buildstring.Combine(
  buildstring.Yield("A"),
  buildstring.Combine(
    buildstring.Yield("B"),
    buildstring.Yield("C")))

结果的StringFragment应为:

Concat (Fragment "A", Concat (Fragment "B", Fragment "C"))

StringFragmentBuilder类中的下一个方法Delay控制计算表达式何时被求值。当一个计算表达式有多个部分时,编译器要求你定义Delay以避免过早求值包含副作用的表达式,并在表达式组合时控制执行。许多方法调用被包装在传递给Delay的函数中,这样这些表达式部分直到调用Delay时才会被求值。更具体地说,整个表达式被包装在一个Delay调用中,每个Combine调用的第二个参数计算也被如此包装。去糖化后的形式大致如下(为清晰起见简化):

buildstring.Delay(
  fun () ->
    buildstring.Combine(
      buildstring.Yield("A"),
      buildstring.Delay(
        fun () ->
          buildstring.Combine(
           buildstring.Yield("B"),
             buildstring.Delay(
               fun () ->
                 buildstring.Yield("C"))))))

最后,For方法允许我们在buildstring表达式中使用for循环。然而,与 FizzBuzz 实现不同,这个版本采用了 Map/Reduce 模式,将提供的序列值映射到单独的StringFragment实例,然后通过Combine方法将它们减少成一个单一的StringFragment实例。这个扁平化的实例可以与其他实例一起使用。

现在你已经看过构建器类,并理解了这些方法是如何通过去糖化过程协同工作的,让我们来看一个完整的例子,展示如何执行整个链条。为此,我们可以使用buildstring表达式来构建一首关于农夫和他的小狗 Bingo 的儿童歌曲的歌词。这首歌的简单歌词和重复性质使得它很容易用编程的方式表示,如下所示:

let bingo() =
  let buildNamePhrase fullName =
    buildstring {
      yield "And "
      yield fullName
      yield " was his name-o\n"
   }
  let buildClapAndSpellPhrases maxChars chars =
    let clapCount = maxChars - (List.length chars)
    let spellPart =
      List.init clapCount (fun _ -> "*clap*") @ chars
      |> Seq.ofList
      |> String.concat "-"
    buildstring {
      for i in 1..3 do yield spellPart
                       yield "\n" }
  let rec buildVerse fullName (chars : string list) =
    buildstring {
      yield "There was a farmer who had a dog,\n"
      yield! buildNamePhrase fullName
      yield! buildClapAndSpellPhrases fullName.Length chars
      yield! buildNamePhrase fullName
      match chars with
      | [] -> ()
      | _::nextChars -> yield "\n"
                        yield! buildVerse fullName nextChars
    }
  let name = "Bingo"
  let letters = [ for c in name.ToUpper() -> c.ToString() ]
  buildVerse name letters

bingo函数内部嵌套了三个函数:buildNamePhrasebuildClapAndSpellPhrasesbuildVerse。这三个函数通过buildstring表达式构建一个StringFragment。在每个诗句的末尾,buildstring表达式包含一个match表达式,用来判断是否应该以Zero值(通过返回unit来隐含表示)结束,或者通过yield!关键字递归地包含另一个完全构造的诗句。

评估前面的代码片段应该会打印出以下字符串(记住,%O标记会通过调用相应对象的ToString方法来格式化该参数):

> **bingo() |> printfn "%O";;**
There was a farmer who had a dog,
And Bingo was his name-o!
B-I-N-G-O
B-I-N-G-O
B-I-N-G-O
And Bingo was his name-o!

There was a farmer who had a dog,
And Bingo was his name-o!
*clap*-I-N-G-O
*clap*-I-N-G-O
*clap*-I-N-G-O
And Bingo was his name-o!

There was a farmer who had a dog,
And Bingo was his name-o!
*clap*-*clap*-N-G-O
*clap*-*clap*-N-G-O
*clap*-*clap*-N-G-O
And Bingo was his name-o!
-- *snip* --

总结

计算表达式在 F#中扮演着重要角色。开箱即用,它们使得创建序列、从不同数据源查询数据以及管理异步操作看起来像是语言的原生功能,借助了语言中熟悉的元素。它们还具有完全的可扩展性,因此你可以通过创建构建器类来定义自己的计算表达式,构造底层类型的实例。创建自定义的计算表达式可能是一个具有挑战性的任务,但一旦理解了每个构建器类方法的目的和去糖化过程,最终结果可以使代码更加简洁、具有描述性。

关于计算表达式的信息可能比较难找,但你可以使用一些资源进行深入学习。首先,F# for Fun and Profit系列文章(* fsharpforfunandprofit.com/series/computation-expressions.htm )提供了许多涵盖不同构建器方法的示例。如果你需要一些更真实的应用实例,可以查看 GitHub 上的 ExtCore 项目( github.com/jack-pappas/ExtCore/*),其中包含了多个计算表达式的实际应用,如懒加载列表实现。

posted @ 2025-11-25 17:06  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报