F--4-0-设计模式-全-

F# 4.0 设计模式(全)

原文:zh.annas-archive.org/md5/4484e562120fa3b271b1c603dd9a4474

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

遵循设计模式是编写更好程序的一种众所周知的方法,它捕获并重用了许多应用程序中常见的许多高级抽象。本书将鼓励你通过完全拥抱函数优先的 F# 范式来发展一种地道的 F# 编码技能集。它还将帮助你利用这个强大的工具来编写简洁、无错误和跨平台的代码。

《F# 4.0 设计模式》将从帮助你培养一种函数式思维方式开始。我们将向你展示函数优先范式的好处以及如何使用它来获得最佳结果。本书将帮助你掌握主要函数式设计模式的知识,这些模式与传统“四人帮”模式之间的关系并不直接。

我们将带你了解 F# 中的模式匹配、不可变数据类型和序列。我们还将揭示高级函数式模式,查看多态函数,查看典型数据处理技术,并学习通过增强和泛化调整代码。最后,我们还将探讨高级技术,以帮助你写出无瑕疵的代码。此外,我们将探讨范式转向函数优先如何影响面向对象宇宙的设计原则和模式,并以函数代码故障排除的具体细节结束本书。

通过阅读本书,你将实现以下目标:

  • 获得使用主要函数式设计模式的知识

  • 在函数式方法下重新调整一些命令式和面向对象的原则

  • 增强你在构建和组合一阶和更高阶函数方面的信心

  • 学习如何有效地使用核心语言的模式匹配

  • 学习如何使用嵌入式代数数据类型代替自定义类型,以增加效果和代码简洁性

  • 通过观察特定库函数背后的模式,轻松导航和使用 F# 核心库

  • 识别和衡量序列和具体化数据结构之间资源消耗的差异

  • 掌握编写通用多态代码

本书涵盖的内容

第一章 ,《开始函数式思考》将帮助你培养一种通常与函数范式相关的编程方式。它将为你提供知识、关键概念以及与 F# 编程语言函数优先特性相关的技能目标列表。

第二章 ,《解构 F# 的起源和设计》将帮助你了解 F# 当代设计的起源,F# 的演变过程,以及它在 .NET 生态系统中的地位。

第三章,基本函数,帮助你获得 F#惯用用法的基础。它为你提供了对函数范式基石——函数概念——的 360 度回顾。你将学习如何将任何解决方案表示为一系列通过少量组合子连接起来的函数。本章为你准备吸收主要主题——F#惯用用法的模式。

第四章,基本模式匹配,使你对语言机制有一个良好的掌握,该机制被置于语言的核心以处理任何数据转换-F#模式匹配。本章涵盖了基本模式匹配功能,将数据分解和主动模式留到后续章节。

第五章,代数数据类型,展示了如何使用 F#标准代数数据类型(元组、区分联合和记录)作为开发自定义类型的更好替代方案。它涵盖了这些类型的组合、等价性、比较、分解和增强。

第六章,序列——数据处理模式的核心,使你熟悉函数编程中最基本的结构之一,即序列。序列是几个基本函数模式的基础,如惰性评估、序列生成器和不确定长度的序列。本章还奠定了数据转换模式分类法的蓝图。

第七章,高级技术:函数回顾,基于已经覆盖的函数、模式匹配和序列的语言使用模式。它向读者介绍了 F#的递归、折叠、记忆化和传递继续等模式。

第八章,数据处理——数据转换模式,继续深入挖掘我们在与序列相关联时开始揭示的数据转换模式。你得到了 F# 4.0 核心库捕获的多态数据转换模式的完整分类法。现在你已完全准备好,在高度优化的高质量库函数组合的帮助下,设计蓝图,大多数情况下避免自定义实现。

第九章,更多数据处理,通过 F#查询表达式和类型提供者帮助的数据解析,增加了由 F#核心库定义的数据转换模式。

第十章,类型增强和泛型计算,涵盖了基于相反类型转换的两种 F#使用模式——代码泛化和代码特殊化。你将看到通过应用这些模式可能获得的益处。

第十一章,F# 专家技巧,探讨了真正高级的 F#模式。我们将了解 F#类型提供者、并发和响应式编程的使用,并以元编程结束。

第十二章,F# 和面向对象原则/设计模式,将本书对设计模式的理解与面向对象范式的理解联系起来。我们将看到,在以函数优先的范式中,著名的面向对象设计原则和特定模式可能会发生变化、减少,或者实际上在功能优先的范式中不再存在。

第十三章,功能代码故障排除,是主要主题的重要补充,展示了如何以探索式风格进行开发,F#代码开发中的问题性质如何从运行时显著转移到编译时,以及如何解决一些典型问题。

本书所需条件

对于本书的读者来说,最友好的开发平台是 Visual Studio 2015,因为书中的一些材料,正如书名所示,是针对 F# v4.0 或更高版本的。如果你已经拥有 Visual Studio 2015 专业版或更高版本,你无需额外努力即可开始。

否则,你可以从www.visualstudio.com/vs/community/安装免费的 Visual Studio 2015 社区版。

对于 Windows 平台上的其他选项,请访问 fsharp.org:fsharp.org/use/windows/

本书的一些代码示例在 Linux 和 Mac 上也能有限地运行。有关相应的安装说明,请访问fsharp.org/use/linux/fsharp.org/use/mac/

对硬件的要求由上述开发环境的版本决定。

本书面向的对象

这本书是为网页程序员和.NET 开发者(C#开发者和 F#开发者)所写。因此,如果你有基本的 F#编程和开发性能关键型应用的经验,那么这本书适合你。对于 F#的绝对初学者来说,理解曲线可能会相当陡峭,但通过学习 F#(fsharp.org/learn.html)的一个或多个入门课程,可能会使它更容易接受。

惯例

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

代码元素,如语言关键字、运算符、表达式和值名称,如下所示:“为了定义递归函数,let 绑定可以扩展为 rec 修饰符”,“文字"$42"绑定到值total”。

代码块设置如下:

type OrderType = Sale | Refund
type Transaction = Transaction of OrderType * decimal

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


// Imperative monolithic solution a-la C/C++ 

#load "HugeNumber.fs" 

let number = hugeNumber.ToCharArray()

网址显示为jet.com

注意

警告或重要提示以如下框中显示。

小贴士

小贴士和技巧如下所示。

新术语重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“点击下一个按钮将您带到下一屏幕。”

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您在某个主题领域有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您的账户下载这本书的示例代码文件。www.packtpub.com 。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持选项卡上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买这本书的地方。

  7. 点击代码下载

文件下载完成后,请确保您使用最新版本的软件解压缩或提取文件夹。

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Fsharp-4.0-Design-Patterns。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。请查看它们!

勘误

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题下勘误部分的现有勘误列表中。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。

侵权

在互联网上,版权材料侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过版权@packtpub.com 与我们联系,并提供疑似侵权材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您在这本书的任何方面遇到问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。

第一章. 开始函数式思考

一个手持链锯的人走进一家五金店,对店员说:“两周前,你告诉我这个工具可以让我在一小时内砍倒 30 棵树。但我只能砍倒一棵。我想退货。”店员说:“让我看看。”然后启动了链锯。参观者吓得跳回去尖叫:“这是什么声音?!”- 一个古老的笑话

我的故事开头的笑话与本章的主题非常相关:为了获得使用任何工具的预期好处,你应该知道如何正确使用该工具。此外,错误使用的高级工具可能甚至比正确使用相应的简单工具还要低效。在钉木板时,锤子比显微镜更有效。

第一章,开始函数式思考,应有助于你培养一种解决日常软件工程问题的方法,这些问题通常与函数式范式相关。这意味着通过动词而不是名词来呈现解决方案,避免使用可变实体来传递状态,避免依赖副作用,并最小化代码中的移动部件数量。

在本章中,我们将涵盖以下主题:

  • F#的多范式特性

  • 通过解决以下示例问题来比较 F#范式:

    • 一种命令式单体解决方案

    • 一种面向对象的解决方案

    • 一种函数式解决方案

  • 函数式范式的特性

我将以一个关键概念列表来结束本章,这些概念需要保留和识别,以及在你函数式解决方案中可以重用的技能。

F#与编程范式之间的关系

本章以及其他章节将教会你如何从函数式范式的角度看待任何给定的软件问题。这种观点可能与你练习其他编程方法时已经形成的范式观点有显著差异。这种对所需范式转变的假设是一个备受期待的场景,考虑到所谓的TIOBE 编程社区指数www.tiobe.com/tiobe_index?page=index)的编程语言流行度因素,它可以被认为是编程语言流行度的指标。

在撰写本文时(2016 年 2 月):

  • TIOBE 指数的获胜排名#1 由 Java 编程语言获得,它与面向对象编程范式紧密相关

  • 排名#2 属于 C 编程语言,它可以被认为是代表传统的命令式过程式编程范式

  • 与函数式编程范式相关的编程语言仅在 TIOBE 指数排名中位于 21 到 50 的范围内,其中 F#以适度的排名#36

尽管如此,如果你已经读到这一点,我可以安全地假设你对 F#的兴趣并不仅仅是因为它的流行,而这种流行是由不属于本书范围的因素驱动的。对我来说,作为一个应用数学和计算机科学高级学位的持有者,在 F#生态系统内编写工程程序代码具有类似探索数学问题的美丽解决方案或分析伟大棋局的美学品质。

认真地说,我个人最重视函数式编程范式的益处,包括函数式代码的可读性和可维护性。典型的单体命令式 C 代码的这些品质可能相当差。然而,这些代码品质是否自动赋予任何掌握了 F#语法的人?当然不是。

除了学习 F#语法之外,前面的观点意味着需要获得某些技能,以便以惯用的方式使用这种编程语言。F#确实是一种多范式编程语言。它允许程序员采用许多编程范式。程序代码的函数式布局方式可以与命令式单体编程方式并行使用,或者当与环境的互操作性很重要时,可能会出现面向对象的方法。然而,F#声称自己是函数式优先的编程语言。这意味着 F#的天然编程范式是函数式;如果以函数式方式使用,该语言将带来最多的好处。在这种情况下:

"它使用户和组织能够用简单、可维护和健壮的代码来解决复杂的计算问题" - (fsharp.org/).

你可能会想知道什么是惯用用法,以及是否总是可以使用它。通过对应编码示例的比较研究来展示惯用 F#使用的方法是最好的。让我拿一个任意简单的问题,并按照命令式、面向对象,最后是函数式范式来解决它。然后,我将比较解决方案以突出函数式方法的特点。为了使这种比较完全公平,所有三种情况下的实现编程语言都将使用 F#。

一个需要解决的问题示例

我将使用 Project Euler 的第 8 题的略微修改版作为问题(projecteuler.net/problem=8):

The four adjacent digits (9989) being highlighted in the 1000-digit numbers that have the greatest product are as following: 
9 x 9 x 8 x 9 = 5832\. 

73167176531330624919225119674426574742355349194934 
96983520312774506326239578318016984801869478851843 
85861560789112949495459501737958331952853208805511 
12540698747158523863050715693290963295227443043557 
66896648950445244523161731856403098711121722383113 
62229893423380308135336276614282806444486645238749 
30358907296290491560440772390713810515859307960866 
70172427121883998797908792274921901699720888093776 
65727333001053367881220235421809751254540594752243 
52584907711670556013604839586446706324415722155397 
53697817977846174064955149290862569321978468622482 
83972241375657056057490261407972968652414535100474 
821663704844031
9989

0008895243450658541227588666881 
16427171479924442928230863465674813919123162824586 
17866458359124566529476545682848912883142607690042 
24219022671055626321111109370544217506941658960408 
07198403850962455444362981230987879927244284909188 
84580156166097919133875499200524063689912560717606 
05886116467109405077541002256983155200055935729725 
71636269561882670428252483600823257530420752963450 

Find the five adjacent digits in the same 1000-digit number that has the greatest product. What is the value of this product? 

命令式单体解决方案

让我先以直接的单体命令式方式来接近解决方案:将表示数字的 1000 个字符字符串转换为字符数组,然后将其转换为跨越所有 996 组五个相邻数字的循环,计算每个组的数字乘积并保持当前最大值。当前最大值的最终值将是解决方案;就这么简单。

为了从输入数字中移除,让我们将其放入一个单独的源代码文件HugeNumber.fs中,使用 F#的#load指令将其拉入解决方案脚本。F#源文件HugeNumber.fs如下所示:

[<AutoOpen>] 
module HugeNumber 
let hugeNumber = 
    "73167176531330624919225119674426574742355349194934\ 
    96983520312774506326239578318016984801869478851843\ 
    85861560789112949495459501737958331952853208805511\ 
    12540698747158523863050715693290963295227443043557\ 
    66896648950445244523161731856403098711121722383113\ 
    62229893423380308135336276614282806444486645238749\ 
    30358907296290491560440772390713810515859307960866\ 
    70172427121883998797908792274921901699720888093776\ 
    65727333001053367881220235421809751254540594752243\ 
    52584907711670556013604839586446706324415722155397\ 
    53697817977846174064955149290862569321978468622482\ 
    83972241375657056057490261407972968652414535100474\ 
    82166370484403199890008895243450658541227588666881\ 
    16427171479924442928230863465674813919123162824586\ 
    17866458359124566529476545682848912883142607690042\ 
    24219022671055626321111109370544217506941658960408\ 
    07198403850962455444362981230987879927244284909188\ 
    84580156166097919133875499200524063689912560717606\ 
    05886116467109405077541002256983155200055935729725\ 
 71636269561882670428252483600823257530420752963450" 

此文件将被所有问题解决方案的变体使用。

然后,实现命令式解决方案的 F#脚本Ch1_1.fsx将如下所示:

// Imperative monolithic solution a-la C/C++ 
#load "HugeNumber.fs" 
let number = hugeNumber.ToCharArray() 
let mutable maxProduct = 0 
let charZero = int('0') 
for i in 0..995 do 
  let mutable currentProduct = 1 
for j in 0..4 do 
  currentProduct <- currentProduct * (int(number.[i + j]) -      charZero) 
if maxProduct < currentProduct then 
  maxProduct <- currentProduct 
printfn "%s %d" "Imperative solution:" maxProduct 

#load "HugeNumber.fs"这一行将外部代码文件HugeNumber.fs中的stringHugeNumber.hugeNumber引入到本脚本的作用域中。

下一行let number = hugeNumber.ToCharArray()将此string值转换为包含 1000 个单独字符的数组,每个字符代表一个单独的数字。

下一行let mutable maxProduct = 0引入了一个用于携带五个相邻数字最大乘积的运行总账的可变int值。

下一行let charZero = int('0')只是一个辅助值,用于将数字的字符码转换为 0 到 9 范围内的实际int值。它实际上代表整数48,而不是像一些人可能期望的0。但是,由于十进制数字的字符'0''9'在转换为int后都有相邻的值,所以从char数字x转换为int的结果中减去charZero将正好得到整数x。关于这个问题的更多细节将在本章进一步讨论。

以下七行 F#代码是实现的精髓:

for i in 0..995 do 
  let mutable currentProduct = 1 
for j in 0..4 do 
  currentProduct <- currentProduct * (int(number.[i + j]) -     charZero) 
if maxProduct < currentProduct then 
  maxProduct <- currentProduct 

此脚本部分执行以下操作:

  • 外部数值for循环遍历数字数组从最左边的到最右边的五个相邻字符数字块,保持块序列号(0,1,2,...,955)在计数器值i中。

  • 绑定let mutable currentProduct = 1提供了一个可变的占位符,用于当前块数字的乘积。

  • 内部数值for循环遍历长度为 5 的子数组,通过将中间结果乘以具有连续编号j的每个数字的int值来计算currentProduct,使用表达式(int(number.[i + j]) - charZero)。例如,如果当前数字是5,则int('5') - int('0') = 5

  • 一个if语句关闭外部循环确保maxProduct始终包含已遍历块的最大乘积;因此,当循环完成迭代时,maxProduct包含所求的值。

最后,printfn "%s %d" "Imperative solution:" maxProduct这一行将最终结果输出到系统控制台。

使用F#交互式环境FSI)运行整个脚本将得到以下解决方案:

一个命令式单体解决方案

在 F#交互式环境中运行命令式解决方案脚本

在介绍其他解决问题的方法之前,我有几个要点想强调:

  • 该解决方案表示详细的“如何做”指导。

  • 该解决方案已用低级计算机概念表达,例如语句、循环和全局值

  • 值在执行过程中发生变化,表示状态的变化

  • 解决方案代码看起来并不结构化,它只是流动

面向对象的解决方案

现在,让我转向以面向对象的方式解决相同的问题。这种方法的典型做法是在自定义类的实例内部隐藏实现细节,并使用它们自己的方法来操作它们。为此,我将使用 F# 的 type 功能,它代表 .NET 对象类型的概念,也称为 (docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/classes )。以下代码(脚本 Ch1_2.fsx )展示了该问题的面向对象解决方案:

// Object-oriented solution a-la C# with Iterator pattern 
#load "HugeNumber.fs" 

open System 
open System.Collections.Generic 

type OfDigits(digits: char[]) = 
    let mutable product = 1 
    do 
        if digits.Length > 9 then // (9 ** 10) > Int32.MaxValue 
            raise <| ArgumentOutOfRangeException 
              ("Constrained to max 9 digit numbers") 
        let charZero = int '0' in 
        for d in digits do 
            product <- product * ((int d) - charZero) 
        member this.Product 
            with get() = product 

type SequenceOfDigits(digits: string, itemLen: int) = 
    let collection: OfDigits[] = 
       Array.zeroCreate(digits.Length -itemLen + 1) 
    do 
      for i in 0 .. digits.Length - itemLen do 
        collection.[i] <- OfDigits(digits.[i..
           (i+itemLen-1)].ToCharArray()) 
    member this.GetEnumerator() = 
        (collection :> IEnumerable<OfDigits>).GetEnumerator() 

let mutable maxProduct = 1 
for item in SequenceOfDigits(hugeNumber,5) do 
    maxProduct <- max maxProduct item.Product 

printfn "%s %d" "Object-oriented solution:" maxProduct 

此解决方案将操作两个类的对象。第一个类名为 OfDigits,代表数字序列的实体,其乘积是我们关注的主题。可以通过 OfDigits 类型构造函数 OfDigits(digits: char[]) 从一定大小的 char 元素数组创建 OfDigits 的实例,这些数组用作 OfDigits 类型构造函数的参数。

在创建实例时,每个实例都与表示其数字乘积的 product 字段相关联。无法一次性初始化 product 的原因在于:为了表示为正整数值,乘积可以由九位或更少的数字组成(因为 10 个或更多 9 的乘积将超过最大 32 位 int 值 2147483647)。为了验证这一点,product 被保持为 mutable,并初始时获得 1 的值,如下所示:

let mutable product = 1 

然后,在长度有效性检查之后,OfDigits 构造函数通过执行计算来向字段提供真实值:

let charZero = int '0' in 
for d in digits do 
  product <- product * ((int d) - charZero) 

此值可以通过实例属性 Product 访问,如下所示:

member this.Product with get() = product 

另一个类用于实现面向对象的解决方案,它代表一个可以表示任意长度数字字符串的实体,并将其表示为类型 OfDigits 的通用集合,允许枚举以遍历它并找到具有最大 Product 属性的成员。

为了实现这一目的,名为 SequenceOfDigits 的类被配备了构造函数参数,该参数携带输入数字的数字字符串和单个 OfDigits 实例参数的 itemLen 长度。在 SequenceOfDigits 实例构建期间,所有 OfDigits 实例都作为集合字段数组的元素创建。GetEnumerator() 实例方法允许您通过向上转换为 System.Collections.Generic.IEnumerable<OfDigits> 接口类型并将调用委托给后者的 GetEnumerator() 方法来枚举此数组,如下所示实例方法定义:

member this.GetEnumerator() =  (collection :> IEnumerable<OfDigits>).GetEnumerator() 

在拥有前面两个类的情况下,构建原始问题的解决方案相当简单:你从hugeNumber构建一个五位OfDigits元素的SequenceOfDigits实例,并使用for...in循环遍历它,保持最大乘积计数,类似于以下代码中所示的操作式解决方案:

let mutable maxProduct = 1 
for item in SequenceOfDigits(hugeNumber,5)
  do  maxProduct <- max maxProduct item.Product 

最后,将结果放置在系统控制台上。使用 F# FSI 运行整个脚本会得到面向对象解决方案的结果,如下面的截图所示:

面向对象的解决方案

在 F#交互式环境中运行面向对象解决方案脚本

对于那些熟悉面向对象解决问题方式的人来说,你们可能会预期第二个解决方案与第一个有所不同:

  • 它的结构明显不同,相关类的定义与这些类的使用分离

  • 类隐藏了实现的细节,只允许通过公开的属性和方法使用

  • 解决方案遵循一个众所周知的设计模式,即迭代器模式

  • 构建脚手架所需的努力远远超过实际解决方案所需的努力

函数式解决方案

最后,让我转向这本书所针对的解决方案方式,即函数式。让我们将其视为一系列数据转换。让我们从求解的解决方案开始,逆向查看,回到数字的输入字符串,如下所示:

  • 所求的解决方案是max聚合函数应用于所有五位数字序列乘积序列的结果。

  • 所有五位数字序列乘积的序列是函数应用的结果,该函数将每个五位数字序列实例从此类序列映射到五位数字序列数字的乘积的 reduce。

  • 所有五位数字序列的序列可以通过将 F#核心库的窗口函数Seq.windowed<'T>应用于后者来生成。换句话说,这意味着从左侧复制前五个数字,将其放入输出中,然后将源序列向右移动一个数字,取第一个五个数字的副本并将其放在输出中的第一组之后,再次将源序列向右移动一个数字,取前五个数字,依此类推,直到无法再从源中取出前五个数字。序列的输出序列是所求的函数应用结果。

  • 最后,所有初始数字的序列只是通过单个数字分割的初始字符串,每个数字都转换为对应的int类型,从 0 到 9。

每个前一步描述了我想要应用于单个输入参数以获得单个结果要应用哪种转换。每个后续步骤都取前一步的结果并将其作为自己的输入。

让我向您展示我通常如何借助 FSI 提供的读取-评估-打印循环REPL)模式和缩小任务维度,从类似前面一个的数据转换草图推导出工作代码。向解决方案逐步进展的过程在图 1.3中显示,我在此过程中逐渐开始添加转换步骤,以重现前面草图中的数据转换过程,该过程针对仅由 10 个数字组成的字符串"0918273645"

  1. 输入字符串通过与同名的 F#运算符管道传递 |> 作为Seq.map string的第二个参数传递。结果是 10 个字符串的序列,每个字符串代表一个单独的数字。

  2. 第 1 步的结果通过|>作为Seq.map int的第二个参数传递。现在,结果也是一个序列,但它是一个包含 10 个int数字的序列,每个数字代表一个单独的数字。

  3. 第 2 步的结果通过|>作为Seq.windowed 5的第二个参数传递。结果是六个数组的序列,每个数组代表第 2 步结果的五个连续数字,每次将序列的起始位置向右移动一个位置。

  4. 第 3 步的结果通过|>作为Seq.map (Seq.reduce (*))的第二个参数传递。第一个参数是高阶函数Seq.reduce,它将它的参数(一个包含五个数字的数组)转换为这些数字的乘积,这要归功于乘法运算符(*)。这个转换步骤的结果是六个数字,每个数字代表相应数字数组元素的乘积。

  5. 第 5 步的结果传递到Seq.max聚合函数中,该函数产生所求的最大乘积,等于2520(7 * 3 * 6 * 4 * 5)w

一个函数式解决方案

使用 REPL 逐步获得较小问题解决方案的增量过程

现在,在相当有信心认为所考虑的解决方案是好的之后,我可以将前面的步骤 1 到 5 与另一个 F# 函数组合运算符>>结合起来,该运算符只是将函数的结果作为参数粘接到右侧函数的左侧,形成一个非常紧凑的 F#脚本,如下所示,该脚本在文件Ch1_3.fsx中提供:

#load "HugeNumber.fs" 
hugeNumber |> (Seq.map (string >> int) >> Seq.windowed 5 
>> Seq.map (Seq.reduce (*)) >> Seq.max 
>> printfn "%s %d" "Functional solution:") 

前面的完整问题解决方案代码与我在几个 REPL 步骤中运行的较小问题解决方案之间的唯一区别是输入值维度。最终的代码使用了与命令式和面向对象解决方案相同的方式,从同一源文件HugeNumber.fs中取出的 1000 位hugeNumber

使用 FSI 完整运行脚本产生以下图所示的函数式解决方案结果:

一个函数式解决方案

在 F# Interactive 中运行函数式解决方案脚本

尽管附带注释有些冗长,但函数式解决方案所达到的代码质量相当出色:

  • 它甚至不利用携带中间状态的单一值

  • 它只包含一个绝对必要的算术乘法运算符,用于乘法计算

  • 它非常简洁

  • 它几乎字面地反映了原始的“做什么”考虑

  • 它仅使用六种核心 F# 库函数以某种方式组合,我们可能坚信这些函数的实现是错误-free 和高效的

前面的要点反映了典型的小规模问题惯用函数式解决方案的所有属性。现在我将逐一解释这些属性。

参与数据实体的不可变性

不使用可变程序实体的方法的积极品质是众所周知的:

  • 在构造时给定正确的状态,不可变实体在其整个生命周期内不能被无效化

  • 不可变实体易于测试

  • 它们不需要克隆或复制构造函数

  • 不可变实体自动是线程安全的

我必须指出,F# 并不是 100% 严格关于使用不可变实体。正如你可能已经注意到的,我之前在我的命令式和面向对象的解决方案中使用了值,改变了状态。但语言要求程序员额外努力引入可变状态(通过 let 绑定的 mutable 修饰符或通过 ref 单元,尽管 F# 4.0 几乎消除了对后者的需求)。

此外,语言引入的大多数数据结构也是不可变的,这意味着典型的数据转换会产生一个新的不可变实例的数据结构,从现有的数据结构中。在处理大量内存实例时,这需要程序员有一定的谨慎,但正如我的经验所教导的,开发者很容易习惯这个特性。

用动词而不是名词思考

将数据转换的过程视为动词而不是名词,对于函数式方法来说是非常典型的,因为在我们的大脑中,函数与动作而不是对象直观地相关联。你可能注意到脚本 Ch1_3.fsx 中的单个数据项 hugeNumber。其余的都是以某种方式组合的少数库函数,它们将 hugeNumber 数据项转换成控制台输出的一行。这种函数组合的方式允许阅读此代码的人完全忽略在表达式中每个出现操作符 >> 的数据转换的中间结果。

这种组合的不那么明显的推论是,F# 编译器有机会执行所谓的融合,或者说是通过合并一些相邻的数据转换步骤来优化代码的方式。例如,当相邻步骤融合在一起时,数据遍历的量可能会减少。

“什么”胜过“如何”

功能解决方案心智过程的这一特性,通过一个例子更容易展示。我在这里引用了 F#早期的一些伟大例子,这些例子之前被几位内部人士使用过。想象一下,你自己在星巴克喝美式咖啡。

“如何”的方法是给出详细的说明,例如:

  1. 喝一杯烤咖啡

  2. 烹制两杯浓缩咖啡

  3. 用热水冲泡,以产生一层奶泡

  4. 将其放入 12 盎司大小的杯中

“什么”的方法就是简单地问“我可以来一杯高杯美式咖啡吗?”

第二种方法显然更加简洁,并且最大限度地减少了得到与预期结果偏差的结果的机会。如果你现在回顾我们前面的三个解决方案,你应该会注意到这个特性。

泛化胜过专业化

函数范式的另一个显著特性是泛化。通过这种方式,我的意思是,当具体问题可以通过应用相应参数化的通用解决方案来解决时,更倾向于选择通用解决方案而不是具体解决方案。让我们转向我们的样本问题,以证明泛化的证据。调整功能解决方案以适应不同长度的数字序列(例如,8 而不是 5),对组进行另一种数学运算(例如,求和而不是乘积),另一种聚合属性(例如,最小值而不是最大值)只是对应函数参数值的更改。在其他方法中,代码需要更改多少的比较将留给你作为练习。

在隐藏它们之上,最小化移动部件

与面向对象的方法相比,这一特性特别与函数式方法相关。回想一下 F#脚本文件Ch1_2.fsx,它涉及自定义类封装实现细节,并在构造函数、迭代器和聚合属性之外暴露它们。与面向对象的方法相比,函数式方法是扁平的,不隐藏任何东西;它只是组合了一些已知部分。

产生定制部分胜过将部分还原为已知部分

函数范式与其他范式区分开来的一个惊人的特性是,在操作数据结构级别上产生定制部分的需求有限。通常,函数式编程新手倾向于为每种数据转换的情况实现自己的函数。这种幼稚的疾病通常以从实践中发现几乎任何对典型数据结构的转换都可以表示为filtermapfold操作的组合而告终。我将专门用大量内容来讨论这一现象。

懒惰的数据收集胜过急切的数据收集

让我将你的注意力转向之前提到的面向对象和函数式解决方案在内存消耗方面的比较。面向对象的解决方案急切地创建并实体化 996 个OfDigits对象集合;也就是说,其内存消耗是问题维度的线性函数。与此相反,函数式解决方案在任何时刻的max聚合中不需要超过一个OfDigits实例,根据max聚合函数的需求惰性逐个产生相同的 996 个对象,因此内存消耗是恒定的,并且(几乎)与问题维度无关。这是一个相当复杂的特点。如果你想象初始条件突然改变,hugeNumber真的很大,那么面向对象的解决方案可能会因为缺乏所需的内存而变得不可应用,而函数式解决方案,由于对此因素不敏感,将继续工作。概括这个观察结果,函数式范式允许你解决更大规模的问题,而不是通过利用数据操作的惰性方式来采取其他方法。从这个方法中产生的有趣推论是,可以操作无限长度的数据序列的技术,这些序列不需要在内存中完全实体化。

摘要

以下是在完成本章学习后你应该掌握的关键概念和技能列表:

避免使用可变状态,并在不可变值和数据结构上实现数据转换。以动词而非名词来思考编程解决方案。避免用高度详细的行为性“如何”语句来表述解决方案;相反,使用“做什么”的方法。进行泛化:优先选择参数化的通用解决方案,而不是具体的解决方案。努力最小化解决方案中的移动部分,而不是将这些移动部分隐藏到类中。尝试通过一些众所周知的功能来表述解决方案,而不是深入到创建自定义解决方案中。当适用时,优先选择惰性数据集合(序列)而非急切的数据集合。

掌握函数式思维方式的这个过程可以概括为以下三个 R——保留识别重用。你越早学会识别我将在本书中详细介绍的惯用函数式设计模式,并且越早将这些模式反复应用于日常编码活动中,你将越成为一名优秀的函数式程序员。

在接下来的章节中,我将向您介绍 F# 在特定开发场景中的许多惯用用法。这些重复的使用将代表真正的函数式编程设计模式。请记住,许多这些模式仅在一定程度上与传统面向对象设计模式(en.wikipedia.org/wiki/Design_Patterns)相关,以及其他软件工程架构设计模式(www.amazon.com/Patterns-Enterprise-Application-Architecture-Martin/dp/0321127420)。

在下一章中,我将为您提供一个全方位的高层次视角,涵盖 F# 语言特性和组成部分及其起源和演变。

第二章. 解构 F# 的起源和设计

本章从历史的角度回顾 F# 特性,尽可能追溯到它们的起源。回顾包括:

  • F# 发展时间线

  • 前身继承的语言特性

  • .NET 强制语言特性

  • F# 内在语言特性

虽然 F# 是一种以函数优先的编程语言,但同时,你也不应忘记它是一个多范式工具,如果需要的话,它允许结合不同的范式。另一个你应该记住的重要方面是,F# 是为 .NET 平台设计的,因此某些语言特性是由底层实现机制和互操作性要求塑造的。本章的目标是以一种让你能够理解 F# 设计的起源和逻辑的方式,将语言分解成组件。

F# 的发展

媒体开始提及 (developers.slashdot.org/story/02/06/08/0324233/f---a-new-net-language) F# 编程语言是在 2002 年夏季,作为微软剑桥研究院的一个研究项目 (research.microsoft.com/en-us/labs/cambridge/) ),旨在创建一个在 .NET 平台上运行的 OCaml 语言 (ocaml.org/) 的方言。计算机科学家 Don Syme (research.microsoft.com/en-us/people/dsyme/) 负责设计和首次实现。

前身

微软剑桥研究院的 F# 项目并非从零开始。F# 属于 ML (zh.wikipedia.org/wiki/ML_(编程语言)) 编程语言家族。它的前身包括标准 ML (zh.wikipedia.org/wiki/Standard_ML) ) 和 OCaml。此外,F# 在微软剑桥研究院最初还有一个双胞胎项目,名为 SML.NET,旨在将 标准 ML ( SML ) 带到 .NET 平台。

F# 版本 1

第一次发布是在 2004 年 12 月,被标记为微软研究院项目。这意味着在当时,它并没有成为微软产品的地位,尽管它提供了与 Visual Studio 2003 和 Visual Studio 2005 测试版的集成。

F# 版本 1.1

2005 年 10 月发布的这个版本标志着将面向对象特性引入语言,这标志着 F# 成为一个真正的多范式语言的里程碑。

“在这个版本中,对 F# 语言本身的重大补充是我们所说的“F# 对象和封装扩展”。这结合了我认为 .NET 面向对象范例的最佳特性与 F# 核心的函数式编程模型。这意味着 F# 已经成为一门混合了函数式/命令式/面向对象的编程语言。” —— Don Syme 在 2005 年 8 月 23 日的博客中提到。blogs.msdn.com/b/dsyme/archive/2005/08/24/455403.aspx

另一个使 1.1 版本显得沉重的特性是引入了 F# Interactive,也称为 FSI,这是一个提供 F# 脚本能力和通过频繁使用 REPL 进行代码开发的工具。这个版本适用于 Visual Studio 2005 的最终版本。

在 1.1 版本的重要里程碑之后,该语言继续频繁发布并引入新的主要功能。2007 年 10 月 17 日,微软公司正式宣布将 F# 从研究转向产品开发组织,旨在将 F# 产品化,使其成为 .NET 平台上完全集成到 Visual Studio 的另一门一流编程语言。经过一年的密集工作,2008 年 12 月宣布 F# 将作为 Visual Studio 2010 的组成部分发布。

F# 版本 2

2010 年 2 月,宣布即将包含在 Visual Studio 2010 中的 F# 版本将升级到 2.0。不久之后,2010 年 4 月,F# 2.0 正式发布,作为 Visual Studio 2010 的一部分,同时也是 Visual Studio 2008 的匹配安装版本,以及其他平台的独立编译器。F# 2.0 版本的重要里程碑反映了 F# 1.x 近 5 年的发展历程,在此期间,在函数式编程的基础上增加了面向对象的功能,如活动模式、序列表达式、异步和并行计算,以及显著的库改进。此外,值得一提的是,与 Visual Studio 的集成为在 Microsoft 平台上使用 F# 的开发者提供了世界级的工具,如调试、IntelliSense 和项目系统。此时,F# 2.0 已完全准备好用于企业级软件开发。

此外,与原始版本相比,F# 版本 2.0 界定了语言发展的一个阶段,通常被认可为成熟的 F#。

F# 版本 3

另一个半年的 F# 发展历程在 2011 年 9 月带来了 F# 3.0 的预览版本。这个版本宣称目标是信息丰富的编程。

"编程理论和实践中的一个日益增长的趋势是编程与丰富信息空间之间的交互。从数据库到网络服务,再到语义网和基于云的数据,将编程与异构的、连接的、结构丰富的、流式传输和不断发展的信息源集成的需求不断增长。大多数现代应用程序都将一个或多个外部信息源作为基本组件。为这些源提供强类型访问是强类型编程语言的关键考虑因素,以确保在信息访问中低阻抗不匹配。" 微软研究技术报告 MSR-TR-2012-101 (research.microsoft.com/apps/pubs/?id=173076 )。

为了实现这一目标,该语言获得了查询表达式,这是一个非常强大的类型提供者机制,以及针对主要企业信息交换技术的众多类型提供者参考实现。F# 3.0 在 2012 年 9 月作为 Visual Studio 2012 的一部分发布,又过了一年。

第二年,即 2013 年,标志着围绕 F# 的活动激增,这表明该语言达到了一些关键质量。Xamarin (xamarin.com ) 宣布支持 F#,实现了多平台移动开发,并在机器学习、云编程、金融时间序列、数值库和类型提供者等领域发生了多次突破性发展。

这个时期也标志着强大的跨平台开放工程社区的努力,实际上将 F# 转变为一个不再根本依赖于微软的开源跨平台共享实现。微软与 F# 的关联仅限于 Visual F#,也称为 Visual Studio 的 F# 工具,即使在这一点上,微软也转向了启用社区贡献和开放工程。

F# 版本 4

2014 年底宣布的 F# 4.0 提供了一些新功能:类型构造函数被转换为第一类函数,可变值可以被闭包捕获,还有高维数组、列表的切片语法、核心运行时库中的规范化集合等。

带着超过十多年的语言激动人心的历史演变的视野,我现在转向对语言特性的剖析。在本章中,这些特性将仅简要概述,将惯用用法的完整细节推迟到后面的章节。

前驱语言继承的特性

F# 从 ML 和 OCaml 继承了与其函数优先性质相关的核心特性。这意味着它表达计算的主要方式是通过函数的定义和应用。

F# 函数是一等实体

定义和应用函数的能力是许多编程语言的共同特征。然而,F# 与 ML 和其他函数式编程语言一样,将函数视为类似于数值值。F# 中处理函数的方式远远超出了通常与存储程序计算机概念相关的限制:

  • 函数可以用作其他函数的参数;在这种情况下,后者是高阶函数

  • 函数可以从其他函数返回

  • 函数可以从其他函数计算得出,例如,使用函数组合运算符

  • 函数可以是通常与数据相关的结构元素

函数是无副作用的

使用函数的计算主要以评估表达式的形式为主,而不是向变量赋值。表达式不带有存储在可重写内存中不断变化的值的污名。当函数triple x应用于参数值3时,它评估一些内部表达式并返回9。我们确信这个结果是连贯的,可能被反复重现,并且只有当参数值从3变为其他值时,结果才会从9变为其他值。

函数可以柔性和部分评估

柔性化是一种将多参数函数的评估转换为一系列单参数函数等价评估的方法。部分评估绑定了一个或多个柔性化函数的第一个参数,有效地产生了一个具有较少(非绑定)参数的新函数。

函数可能是匿名的

为什么要在传递给高阶函数或从其返回的函数上命名?为了简洁起见,F# 允许使用不会在其他地方调用的通用 funfunction 函数定义形式,因此省略了名称。

函数可能是递归的

递归函数(en.wikipedia.org/wiki/Recursive_function )实现的常用例子是将问题分解为几个维度更小的相同问题,即分而治之算法(en.wikipedia.org/wiki/Divide_and_conquer_algorithms ),这样就可以应用相同的解决函数。这种分解一直持续到解决方案变得简单,然后较小的解决方案被组合回原始大小的解决方案中。

函数可以引发异常

并非每个表达式都能始终返回一个结果值;这种情况最典型的例子是零除以一个数。另一个典型例子是无效的参数值,它不允许返回结果。在这种情况下,不是返回结果,而是抛出一个异常。

函数可以引用外部值

使用外部值将它们冻结在函数定义中,从而创建所谓的闭包

F# 是一种静态类型语言

表达式及其组成部分具有由 F#编译器推断的唯一类型。一般来说,不会发生隐式类型转换。F#编译器检查程序并捕获可能在动态类型语言运行时发生的错误。

F#类型推断提供类型泛化

编译器执行的类型分配算法通常允许程序员在上下文明确确定类型的情况下省略类型声明。它找到对值绑定和表达式评估最一般的类型。

F#支持参数多态

一个函数可能允许通用的参数类型;例如,计算intint64bigint列表元素总和的函数实现可能相同。

F#从 ML 继承了各种聚合数据结构

继承的数据结构包括以下内容:

  • 一个元组或代数积类型的值,允许你表示异构类型的值

  • 一个列表或相同类型的零个或多个值的有限序列

  • 通过类似于 ML 数据类型的机制定义的区分联合或自定义代数和类型,特别是允许递归类型定义(例如,二叉树)

  • 表示某种类型值的缺失或存在的选项

  • 一个类似于元组但组件被命名的记录

  • 当然是一个数组

  • 一个序列,作为 ML 中数据类型的扩展实现,具有懒加载的值构造函数

F#支持模式匹配

模式匹配是数据结构分解的强大机制,允许你将数据聚合分解成组件或根据数据聚合的特定结构/属性定义处理。

F#支持数据引用

数据引用是在 ML 中引入的,以支持可变存储和更广泛地支持命令式编程。F#为了与 ML 向后兼容而不保留地继承了这一特性。ref类型的值允许你实现可变性,改变状态并表示全局变量。

函数默认是非递归的

在这方面,F#遵循 OCaml,因此递归函数绑定应该带有rec属性。let rec立即将函数名放入作用域中,以覆盖外部作用域中可能的重复。如果没有rec属性,let只在主体完全定义后将其放入作用域,这使得在函数体内部引用不可用,或者在最坏的情况下,无意中使用了外部作用域中无意中覆盖的名称。

模块

遵循 ML 和 OCaml,F#提供模块作为将相关值、函数和类型组合在一起的方式。实际上,模块类似于 C#的静态类,为开发者提供了将相关实体分组、维护和扩展的手段。

.NET 强制的语言特性

除了从语言前辈那里继承的特性外,F# 还引入了众多特性,以便与 .NET 平台进行互操作性。

F# 遵循 .NET 公共语言基础设施

F# 代码的运行时安排由 .NET 公共 语言 基础设施CLI)定义,并且与 C# 或 VB.NET 的相同。F# 编译器读取 F# 源代码文件并生成名为 MSIL 的汇编语言中间代码,并将其打包为二进制 .NET 程序集。在代码执行阶段,MSIL 根据需要转换为机器代码,或 即时JIT)。与其他 .NET 语言的互操作性是通过 F# 生成的程序集与 C# 或 VB.NET 生成的程序集没有差异来实现的。同样,JIT 编译器负责目标硬件平台,提供可移植性。CLI 还承担了内存管理的负担,使 F# 程序受到 .NET 垃圾收集的影响。

F# 具有名义类型系统

这与 OCaml 和 ML 的结构化对象系统有显著差异。显然,这个设计决策是由存在于 .NET 对象系统内并与该系统交互的必要性所决定的,而 .NET 对象系统是名义的。

F# 完全拥抱 .NET 面向对象

F# 允许你从两个方向遵循 .NET 的面向对象范式:一方面是使用现有的 .NET 框架和面向对象库,另一方面是将 F# 代码以 .NET 库、框架和工具的形式贡献出来。

所有 F# 实体都继承自单个根类型 System.Objectobj。因此,它们自带一些可覆盖和可定制的常用方法。对于自定义的 F# 实体,如区分联合,编译器会生成这些常用 obj 方法的实现。

除了使用 .NET 类之外,F# 允许你创建自己的自定义类,这些类由 数据 组成,形式为 字段,以及以 方法属性 的形式操作这些字段的 *函数**。名为 构造函数 的特殊方法初始化每个类的实例,并将某些值分配给字段。类可以通过类型参数进一步参数化,从而获得泛型。一个类可以继承自单个 基类 并实现许多 接口。子类可以通过 重写 基类的属性和方法来修改其行为。自定义类可以从 .NET 生态系统的其他部分使用。

F# 需要调用显式接口的方法

F# 在这方面与其他 .NET 语言不同,因此让我用一个简短的代码示例来解释这一点(Ch2_2.fsx):

type IMyInterface = 
    abstract member DoIt: unit -> unit 

type MyImpl() = 
    interface IMyInterface with 
        member __.DoIt() = printfn "Did it!" 

MyImpl().DoIt() // Error: member 'DoIt' is not defined 

(MyImpl() :> IMyInterface).DoIt() 

// ... but 
let doit (doer: IMyInterface) = 
    doer.DoIt() 

doit (MyImpl()) 

前面的 MyImpl 类实现了 MyInterface 接口。然而,尝试使用该实现会隐式失败,就像 MyImpl 完全没有该方法一样。只有将 MyImpl 实例显式上转换为 MyInterface 后,实现才变得可访问。

我脑海中浮现出的这个设计决策的合理性是这样的:它将消除需要实现多个具有类似名称方法的接口时的歧义。如果我们考虑典型的接口使用,如前面的 doit 函数中的 doer 参数类型为 IMyInterface,这个问题就会变得不那么令人烦恼。在这种情况下,编译器会隐式地将 MyImpl 转换为 IMyInterface 以使用 MyImpl。前面脚本的执行在以下屏幕截图中展示了这一细微差别:

F# 需要调用显式接口的方法

对象表达式

F# 提供了一种实现接口的方法,无需为该目的创建自定义类型。以下代码展示了如何使用对象表达式(msdn.microsoft.com/en-us/library/dd233237.aspx)从前面的代码中实现 IMyInterface,然后是使用实现(Ch2_3.fsx):

// Define interface 
type IMyInterface = 
    abstract member DoIt: unit -> unit 

// Implement interface... 
let makeMyInterface() = 
    { 
        new IMyInterface with 
            member __.DoIt() = printfn "Did it!" 
    } 

//... and use it 
makeMyInterface().DoIt() 
makeMyInterface().DoIt() 

反射

F# 完全支持 .NET 反射,允许在运行时访问程序代码的元数据信息。从 F# 以及其他 .NET 语言中都可以进行运行时应用程序的反射,获取类型元数据和装饰源代码的属性。此类练习的最终目标通常是修改已执行代码的运行时行为。例如,目标可能是动态添加新组件或解决依赖关系。

F# 核心库包含在 Microsoft.FSharp.Reflection 命名空间中用于运行时分析和构建 F# 特定类型和值的综合工具(msdn.microsoft.com/en-us/visualfsharpdocs/conceptual/microsoft.fsharp.reflection-namespace-%5Bfsharp%5D)。

扩展类和模块

F# 允许以非常不显眼的方式扩展类和模块(docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/type-extensions)。第一种形式通常被称为内建扩展,类似于 C# 中的部分类。不幸的是,在 F# 中,这种形式的类扩展不能跨越源文件、命名空间或程序集的边界。

另一种形式的扩展,称为可选扩展,可以轻松地通过源文件进行。其目的显然是增强任何模块。尽管这种增强应用范围更广,但这种增强在反射中不可见,并且不能在 C#或 VB.NET 之外从 F#中使用。我们将在本书的后面部分仔细研究这些增强方法。

枚举

F#中的枚举(msdn.microsoft.com/en-us/library/dd233216.aspx )模仿 C#枚举,允许你创建命名的数值常量。由于这些不是判别联合,它们在模式匹配中的使用有限。此外,当操作二进制标志的组合时,它们可能很有用。

结构

F#中的结构(msdn.microsoft.com/en-us/library/dd233233.aspx )是用值类型表示的轻量级类。因此,它们的实例可以放在栈上,它们比引用类型占用更少的内存,并且可能不需要参与垃圾回收。

事件

F#事件(msdn.microsoft.com/en-us/library/dd233189.aspx )允许与 CLI 的.NET 事件进行互操作性。然而,F#在 CLI 之上更进一步,提供了在观察者-可观察和发布-订阅场景中的强大聚合、过滤和分区功能。

可空类型

可空类型(msdn.microsoft.com/en-us/library/dd233233.aspx )解决了由于惯用的option类型使用而在 F#中通常不存在的null值相关的问题。一般来说,在内部场景中,F#允许使用相应option类型的None情况来覆盖值的缺失。然而,当option类型不能使用时,F#代码与 C#和 VB.NET 代码的互操作性以及从 F#进行低级数据库操作可能需要使用可空类型来表示值的缺失。

与托管代码的互操作性

仅使用 F#代码的 C#和 VB.NET 可以访问在 F#中实现的公共方法,但 F#特定的数据类型或函数则不在作用域内。幸运的是,相反方向的访问更容易,因为 F#是 C#和 VB.NET 特性的超集。

与非托管代码的互操作性

当涉及到与遗留代码的互操作性或使用用 C/C++编写的库的这些不常见情况时,F#遵循 C#使用P/InvokeCOM Interop

内在的 F#语言特性

除了从 F#的前身继承的特性外,F#语言还拥有自己一套显著的创新功能。这些功能的概述将在接下来的章节中讨论。

意识到缩进的语法

是的,这是正确的;F# 编译器对源代码中的缩进非常敏感(msdn.microsoft.com/en-us/library/dd233191.aspx ),因此正确的代码格式不仅仅是美观问题。为什么?首先,编译器强制执行了代码的可读性改进,其次,这种设计选择显著减少了 F#源代码中的噪声量,因为块标记(如 C#中的花括号)不存在,总体上使得 F#源代码比等效的 C#代码短得多。

度量单位

此功能(msdn.microsoft.com/en-us/library/dd233243.aspx )允许您用关联的单位装饰值,并通过编译器静态验证单位使用是否正确,同时根据操作数的单位推断与表达式值关联的单位。让我们看看以下示例。

在这里,我定义了两个度量单位:<m> 表示米(距离)和 <s> 表示秒(时间)。了解如何从加速度和距离中找到速度,我定义了一个 fallSpeed 函数来找到物体从给定的 height 参数下落并撞击地面时的速度,如下面的代码所示(Ch2_1.fsx):

[<Measure>] type <m> // meters 
[<Measure>] type <s> // seconds 
let fallSpeed (height: float<m>) = 
  2.0 * height * 9.81<m/s²> |> sqrt 

现在使用这个函数,很容易发现一个不小心从帝国大厦顶部掉下来的水瓶以每秒 86.46 米的速度撞击了纽约市第五大道的人行道,希望没有伤害到在入口附近闲逛的游客中的任何一位。以下代码表示了前面的示例:

let empireStateBuilding = 381.0<m> 
fallSpeed empireStateBuilding 

注意,编译器只允许将 <m> 装饰的浮点数作为 fallSpeed 的参数。此外,该函数正确地推断出结果速度的单位是每秒米。不错吧?但说真的,考虑一下这篇 1999 年的 CNN 文章,标题为 “度量错误导致 NASA 探测器损失” (www.cnn.com/TECH/space/9909/30/mars.metric.02/ )。如果存在单位检查,就不会发生 1.25 亿美元的卫星损失。不幸的是,NASA 和洛克希德·马丁公司用于卫星飞行控制的软件系统各自运行在自己的度量系统中,集成测试未能发现这一缺陷,直到实际飞行开始之前。

赋值运算符重载

F#允许对现有运算符进行重载(msdn.microsoft.com/en-us/library/dd233204.aspx )以及创建新的单目和中缀运算符。它允许根据操作数的具体类型提供单目(前缀)和中缀操作的多种实现。例如,有理分数算术的实现可能使用三种加法操作版本,分别由中缀运算符+表示,适用于将分数加到整数、整数加到分数以及分数加到分数。重载具有积极的一面,可以简洁地表达操作某些领域对象的语义。但这个特性在适度时是好的,因为过度重载可能会损害代码的可读性。

内联函数

内联函数(msdn.microsoft.com/en-us/library/dd548047.aspx )代表一种特定的编译技术。通常,具有非泛型类型参数的编译函数与单个 MSIL 相关联,并且每个函数引用都被编译成对该代码的调用并返回评估结果。然而,在.NET 类型系统中,无法为泛型参数编译 MSIL。F#通过静态评估每个特定函数调用的参数并创建针对此特定函数调用非泛型参数类型的 MSIL,提供了一种聪明的解决方案。通过遵循概述的技术,F#在.NET 类型系统非常有限的支持下实现了函数参数泛化。

类型约束和静态解析的类型参数

在非常有限的设计和实现情况下,只需要有真正通用的函数类型参数。通常,需要添加自定义组合的独特附加属性来为 F#编译器提供一种方式,以便在静态上检查类型泛化是否足够具体以完成任务。在大多数此类情况下,类型推断足够智能,可以从静态上下文中推导出此类约束,但有时可能希望开发者提供一些额外的约束。提供额外静态约束的过程和详细情况由以下链接描述:msdn.microsoft.com/en-us/library/dd233203.aspx。本书在第十章中对此问题进行了审查,类型增强和泛型计算

活动模式

活动模式(msdn.microsoft.com/en-us/library/dd233248.aspx)通过允许在模式匹配规则中使用自定义函数,极大地增强了模式匹配的能力。换句话说,模式匹配可以针对任何所需的复杂程度进行专门化。活动模式对于掌握 F#至关重要,我将在接下来的章节中投入大量关注于它们。

计算表达式

计算表达式(msdn.microsoft.com/en-us/library/dd233182.aspx)是一个相当高级的主题。它们提供了用于表示复杂嵌套计算的工具,这些计算通过看似简单的语法糖进行排序和绑定。F#语言的一些自身特性是通过计算表达式实现的,即序列表达式、查询表达式和异步计算。F#还允许你编写自定义的计算表达式,提供了巨大的扩展能力。

查询表达式

查询表达式(msdn.microsoft.com/en-us/library/hh225374.aspx)代表了语言提供的计算表达式形式,用于处理语言集成查询,也称为 F#中的 LINQ。它们是解决我之前提到的信息丰富编程的机制的一部分,允许从多种来源和多种形式中消费数据,并能够统一地操作这些数据。例如,从 OData 服务、使用 WSDL 定义的 Web 服务或 SQL 服务器获取的数据可以在一定程度上进行转换,而不必考虑其来源的具体细节。

异步工作流

F#中的异步工作流(msdn.microsoft.com/en-us/library/dd233250.aspx)以与查询表达式类似的方式呈现,由语言提供的计算表达式形式展示,并展示了该机制的强大和通用性。它们允许你在高抽象级别上针对隐式提供的线程池执行异步代码,从而无需关注异步计算的安排细节。作为结果,编写 F#的异步代码几乎与同步代码一样简单。

元编程

元编程 (msdn.microsoft.com/en-us/library/dd233212.aspx ) 是一种极其强大且令人兴奋的技术,它允许程序编写其他程序。它可能采取不同的形式,发生在不同的级别:在原生机器代码级别,在 MSIL 级别,甚至在 F#或另一种编程语言的源代码级别。几年前,我对这个特性非常兴奋,并就此主题撰写了一系列简短的文章:F# 元编程第一部分:即时编译一些原生代码 (infsharpmajor.wordpress.com/2012/03/04/how-hard-is-to-jit-some-native-code-from-f/),F# 元编程第二部分:动态合成可执行 F#代码 (infsharpmajor.wordpress.com/2012/04/01/how-to-dynamically-synthesize-executable-f-code-from-text/),以及F# 元编程第三部分:即时创建 MSIL (infsharpmajor.wordpress.com/2012/04/12/creating-msil-from-f-on-the-fly/)。

然而,通常当开发者考虑 F#元编程时,会涉及到不同的程序级别,即 F#,但与称为引用表达式的语言特性相关联的半编译形式。当 F#编译器遇到特定的 F#代码时,它不会将这段代码作为程序的一部分,而是将其编译成一个表示 F#表达式的特殊对象。这个特性的强大之处在于,以这种方式编译后,F#表达式可以被进一步转换为适合在完全不同的环境中执行的形式,例如,以 JavaScript 的形式在网页浏览器中,或者在某种图形处理单元GPU)中,原则上可以到达大量的不同计算平台。

类型提供者

类型提供者 (msdn.microsoft.com/en-us/library/hh156509.aspx ) 也代表了元编程功能。然而,类型提供者并不像将某种形式的源代码转换为可执行形式那样做完全不同的事情。一个典型的类型提供者将某种类型的数据源表示为一系列具有方法和属性的类型,这些类型可以无缝地被使用,就像人类编写的类型或库一样。值得注意的是,提供的类型具有与手动编写的类型相同的品质。它们可以进行静态检查,通过 Intellisense 进行内省,由 F#编译器推断。

例如,SqlClient 类型提供者(fsprojects.github.io/FSharp.Data.SqlClient/)允许 F#开发者以类型安全的方式访问 Microsoft SQL 服务器完整的功能集。

类型提供者另一个极其强大的用例是 F#与其他编程语言之间的互操作性。这个领域的重大成功之一是 F# R 类型提供者(bluemountaincapital.github.io/FSharpRProvider/),它允许从 F#访问大量用于统计计算的 R 编程语言库。与R 类型提供者结合使用大大促进了 F#在机器学习和数据科学领域的应用。

摘要

本章使你熟悉了根据起源和设计动机分解的 F#特性。你现在更好地理解了每个语言特性从何而来,哪些特性来自机器学习流派,哪些语言设计决策是由托管.NET 平台决定的,F#的独特特性是什么,以及它们被纳入语言的原因。

拥有了这些知识,你现在准备好吸收主要内容了。在下一章中,我将转向 F#的核心特性:函数及其用法。

第三章:基本函数

在本章中,我将介绍使用函数范式构建的程序代码的核心元素,即函数。函数的概念确实无处不在。在我们周围的世界中,它可能意味着很多事物,从某物的目的到依赖关系,以及以某种方式工作。但在这里,我将通过计算机编程的棱镜来考虑它,其中函数通常意味着基于输入计算结果的方法。这次考察将包括以下内容:

  • 函数的概念、函数定义和类型签名、纯函数、引用透明性以及副作用

  • 函数参数和参数:特殊类型unit、参数数量和类型、返回值和类型、柯里化、部分函数应用

  • 高阶函数、函数作为参数和返回值、匿名函数、函数作为数据类型组成部分以及函数作为接口

  • 闭包、可变值和引用单元格

  • 类型推断和函数组件的推断类型与显式类型

  • 递归函数基础

  • 运算符作为函数

  • 函数组合和组合子

由于我的最终目标是让你接受与 REPL 开发风格和 F#惯用语的内在精神,我将通过 FSI 运行每个提到的功能,以展示原因和结果。

F#中函数的概念

让我们从我们在学校代数课上听到的函数的直观定义开始:函数是一种关系,对于每个有效输入都产生一个单一一致的结果。这样的定义足以反映函数和关系的共性和差异。在数学中,函数是一种关系,尽管并非每个关系都是函数,因为关系可能代表相同单个输入的多个结果。在下面的图中,左侧的关系Rij非常适合表示函数,因为集合I中的任何元素都映射到集合J中的唯一一个元素。然而,同一图右侧的关系Rxy不能表示函数,因为至少存在一个X中的元素,它映射到Y中的多个元素,这由红色的映射箭头所示。

F#中函数的概念

关系和函数

另一个非常重要的问题是映射的一致性。任何函数,当被反复给出相同的输入时,必须产生相同的结果。

遵循我们的直觉,在编程语言中,包括 F#,一个函数代表一种计算,其结果是通过对有效输入进行转换来确定的。与任何具体的计算一样,它消耗一些内存和一定的时间来完成,并携带某种行为。而这种行为,计算的方式,以及转换的方式,反过来是由函数定义决定的。

函数定义

通常,F# 函数具有一个名称,有参数(s),返回某种类型的结果,并且有一个主体。以下图展示了这些组件的耦合。函数类似于一个不透明的盒子,对输入进行某种转换以产生输出。它隐藏了转换是如何具体执行的细节,只向世界声明了目的和签名,换句话说,输入和输出的类型。如果函数的定义可用,函数可以被转换成一个白色透明的盒子,拆开不透明的盒子,揭示实现的细节。然而,定义可能可用也可能不可用;后一种情况对于库和模块来说是典型的(记住移动部件的隐藏)。我故意使用了输入而不是参数(s);我稍后会展示多参数的函数可以用单参数的函数来表示:

函数定义

函数组件及其目的

这些函数组件通过绑定函数值的方式,由语言语法连接在一起,如下面的伪代码所示:

let function-name parameter-list [: return-type] = body-expression 

前面的绑定并不是在 F# 中定义函数的唯一方式;它可能为特殊情况携带一些额外的元素。我将在稍后介绍这些缺失的细节。

使用前面的语法,例如,我们可以定义 F# 函数来计算给定半径的圆的面积,如下面的代码所示(Ch3_1.fsx):

let circleArea radius = System.Math.PI * radius * radius 

在这里,System.Math.PI 是 .NET System.Math 类的一个字段,表示圆的周长与其直径的比值。

使用 FSI 以半径参数的 5.0 作为参数值执行这样定义的函数,得到以下结果:

> circleArea 5.0;; 
val it : float = 78.53981634 
> 

值得注意的是,F# 没有引入任何关键字来返回函数结果。结果只是函数内部计算出的最后一个表达式的值。

函数类型签名

让我们在 FSI 中输入 circleArea 函数的唯一名称,如下面的代码所示:

> circleArea;; 
val it : (float -> float) = <fun:it@7> 

FSI 的响应表示 circleArea 函数的类型签名(float -> float),这意味着它是一个接受类型为 float 的参数并返回类型为 float 的结果的函数。这个函数类型签名非常简单。随着我们深入探讨,我们将检查更复杂的函数签名示例。我会向你展示阅读和理解它们对于函数式程序员来说是一项绝对必要的技能。

另一个细心的读者可能已经注意到的细节是:F# 编译器是如何得出radius的类型是float的结论的?目前,请相信我,编译器是根据名为类型推断的确定性过程推断出以下内容的。它在减少 F# 代码中的错误数量以及代码简洁性方面发挥着重要作用。F# 实现了一种非常具体的静态类型推断方式,称为Hindley-Milner 类型推断算法(en.wikipedia.org/wiki/Type_inference )。我将在本章的后面部分对类型推断给予充分的关注。

纯函数

计算机函数实现可能具有或不具有更抽象函数概念的关键属性:在给出相同的参数(s)时重复返回相同的结果的一致性。之前定义的circleArea函数显然具有这种属性。它不依赖于其参数和定义之外的任何东西,并且不会改变任何东西,只是简单地返回一个幂等结果。具有这些有用属性的函数被认为是纯函数,或引用透明(en.wikipedia.org/wiki/Referential_transparency );否则,它们依赖于某些东西或具有副作用,因此是非纯函数

让我演示一个简单的非纯函数,以下代码(Ch3_1.fsx)中:

let opaque arg = 
  System.DateTime.Now.Second * (if arg % 2 = 0 then 2 else 1) 

在 FSI 中运行上一行代码会得到以下结果:

> opaque 15;; 
val it : int = 46 
> opaque 15;; 
val it : int = 49 
> opaque 16;; 
val it : int = 112 
> opaque 16;; 
val it : int = 6 

因此,通过简单地观察其后续使用重复参数的调用,opaque 杂质变得明显。

函数参数和参数

在以下代码(Ch3_1.fsx)给出的示例函数定义中:

let circleArea radius = 
  System.Math.PI * radius * radius 

radius 标识符代表函数参数,即函数期望转换的值的名称。在函数使用时提供的参数值代表函数参数,如下所示,当我们应用以下代码行中的函数时:

circleArea 15.0 

15.0 是上一行函数的参数。

元组预览

在这一点上,为了揭示关于函数参数的更多细节,需要某种概念,这在逻辑上属于完全不同的语言功能,具体来说是数据类型。我正在谈论元组。由于似乎不可能构建一个理想的直线故事线,我将在这里提供必要的预览,然后在后续章节中重新探讨元组的问题。

元组msdn.microsoft.com/en-us/library/dd233200.aspx)是一种不可变的 F#数据类型,它表示一个括号内、逗号分隔的、有序的任意值组合。也就是说,组合至少包含一对值。这些值的类型完全任意,它们是否相同无关紧要。

一个元组的例子如下:

let dateParts = (2016,"Feb",29) 

元组的构成值也可以用表达式表示,如下面的代码所示:

let (perimeter,area) = 
  (System.Math.PI * 2\. * r, System.Math.PI * r * r) 

我将通过介绍元组类型签名来结束这个简短的预览。它是由按照既定顺序排列的构成类型组成的,并由*符号分隔。因此,按照这种安排,前面代码中显示的dateParts元组的类型是int*string*int

特殊类型 unit

函数领域还有一个组成部分,它来自计算机编程函数和数学函数之间的区别。这是唯一目的在于表示参数和/或结果缺失的特殊类型,即unit。这种类型是最简单可以想象的,只有一个值,由一对没有任何内容的括号表示。以下是其表示形式:

() 

尽管如此,unit在表示缺失指示符方面发挥着重要作用。这种缺失可能表现为以下函数定义,它可以是一个生成 0 到 1000 之间随机数的穷举法(Ch3_1.fsx):

let getNextRandom () = (%) System.DateTime.Now.Ticks 1000L 

如果你考虑前面的绑定,那么在getNextRandom后面有()是唯一区分表示计算过程的函数绑定和表示单个计算结果的值绑定的方法。

事实上,如果我用 FSI 运行这两个绑定变体,差异应该是值得记忆的:没有unit参数时,getNextRandom绑定到一个不可变的int64值;否则,它绑定到一个具有(unit -> int64)签名的函数,并且每次被反复调用后,它都会返回不同的结果。以下截图捕捉了这个区别:

特殊类型 unit

单位参数区分了值绑定和函数绑定

unit是函数返回的表达式的值时,也有一个类似有趣的案例。直观上应该会提示你,如果一个函数返回空值,或者说(),那么它的目的可能是引起副作用。让我们稍微改变getNextRandom的定义,如下面的代码(Ch3_1.fsx)所示:

let getNextRandom () = 
  printfn "%d" ((%) System.DateTime.Now.Ticks 1000L) 

现在,函数签名变为(unit -> unit),调用它,仅输出 0 到 999 之间的随机数,返回类型为unit

柔性函数和部分函数应用

让我们定义一个简单的函数,myPrintFunC,它接受一个string和一个int作为参数,并返回unit,如下面的代码所示:

let myPrintFunC header value = printfn "%s %d" header value 

myPrintFunC 的类型是 (string -> int -> unit )。

另一个几乎相同且简单的函数是 myPrintFunT,它也接受一个 string 和一个 int 作为参数,并返回 unit,但参数的打包方式如下所示:

let myPrintFunT (header,value) = printfn "%s %d" header value 

myPrintFunT 的类型是 (string*int -> unit )。

应用 myPrintFunC "The Answer is" 42 输出 The Answer is 42。同样,应用 myPrintFunT ("The Answer is", 42) 也输出 The Answer is 42。那么,为什么会有这么大的争议呢?

基本的区别在于这些函数接受参数的方式:myPrintFunC 的参数是柯里化的,但 myPrintFunT 的参数是打包的。

由于熟悉元组,你不会对 myPrintFunT 中的 let t = ("The Answer is", 42) 输出相同的结果:The Answer is 42 感到惊讶。myPrintFunT 的签名让我们想起了类型为 string*int 的单一函数参数。

myPrintFunC 的情况更有趣。其签名中的箭头 -> 是右结合操作,因此我可以将其签名重写为 (string -> (int -> unit) ),对吗?但是等等;(int -> unit ) 不不正是表示一个接受 int 参数并返回 unit 的函数吗?是的,它确实如此。所以,回到 myPrintFunC,为什么我不能将其视为一个接受 string 参数并返回一个新的中间函数的函数,该中间函数再接受 int 参数并返回 unit 呢?最后,函数在 F# 中是一等公民,所以返回值可以是函数类型。现在让我们回到以下代码:

(myPrintFunC "The Answer is") 42 

这仍然返回 The Answer is 42。为了使机制完全透明,让我们在 FSI 中逐步执行前面的转换步骤:

Currying and partial function application

部分函数应用

如前述截图所示,myPrintFunC 函数最初定义为具有两个参数。当它仅应用于第一个参数时,它返回另一个函数,interimFun,它只有一个参数,如果将其应用于第二个参数,将返回与原始函数应用于两个参数时完全相同的结果。正如预期的那样,结果是 The Answer is 42;道格拉斯·亚当斯的粉丝已经知道了这一点。

向你表示祝贺;你刚刚掌握了函数式编程中极其重要的技术之一,即 部分函数应用。部分应用通过简单地省略一个或多个后续函数参数来实现。

因此,柯里化建立在部分函数应用的原则之上。任何以柯里化形式定义的多参数函数定义只是柯里化过程的语法糖,隐式地将原始定义转换为函数的组合,每个函数包含一个参数。柯里化是 F# 的默认特性;它使得部分函数应用随时可用。

函数参数的数量和类型以及返回值

我想要重申之前关于 F#函数参数和返回值的发现,以便给你留下一个非常简单的心理模型,那就是:

注意

所有 F#函数都有一个单一参数并返回一个单一结果。

没有参数和/或没有返回值的函数使用unit值代替省略的实体。

具有多个参数和多个返回值的函数可以通过将参数和返回值合并成一个元组来适应前面的模型。

以柯里化形式具有多个参数的函数通过将第一个参数作为输入并返回一个新函数,该函数具有部分应用的这个参数,从而通过重复转换适应单个参数模型。

关于前面原则的更细致的细节来自.NET 方面,当我们不仅处理原始的 F#函数,还处理.NET 库和我们的自定义类型的静态和实例方法时。这些内容将在后面的章节中介绍。

高阶函数

我在很多场合提到过,函数在 F#中是一等实体,因为它们可以用作其他函数的参数,或者可以从其他函数作为结果返回。这正是高阶函数的指示。一个高阶函数可能有一个函数作为参数,它可能返回另一个函数作为结果,或者它可能同时执行这两件事。

在 F#中,所有函数都被视为函数值;这种处理方式允许你在任何使用值的环境中不区分函数和其他类型的值。我将在这里介绍一些这样的环境,即作为另一个函数的参数、从函数返回的值以及数据结构的一部分。

匿名函数

在某些情况下,定义一个没有显式名称的函数是有意义的。通常,这种能力对于被高阶函数操作的函数来说是非常有用的。需要一种简洁的方式来设置参数或结果,而不涉及完整的函数定义。为什么需要这样呢?首先考虑的是,名称可能需要用于未来的引用。如果一个函数通过名称定义,并且这个名称在程序代码的其他位置被多次引用,那么这个有名称的函数就非常有意义。相反,如果一个函数作为高阶函数的参数定义,并且从未在这个单一出现之外使用,那么这个名称就是多余的。另一个考虑因素是函数值的用法;例如,将一个函数作为另一个函数的参数使用可能不需要为前者命名。

定义匿名函数的语法如下:

fun parameter-list -> expression 

在这里,parameter-list 代表成对或柯里化的参数名称,可选地带有显式的参数类型。请注意,使用 fun 关键字定义的匿名函数代表一个 lambda 表达式 (msdn.microsoft.com/en-us/library/dd233201.aspx )。lambda 表达式具有匿名函数表示的值。理解这一点对于理解 F# 中函数的一等公民待遇非常重要。

函数作为参数

函数作为参数可能是函数式程序中最常见的用法。典型的 F# 库实现为高度优化的高阶函数集合,可以通过提供特定的函数作为参数来针对任何具体任务进行调整。例如,可以使用 Array2D.init 库函数创建一个 5x5 的平方标量矩阵,其对角线元素为 1。Array2D.init (msdn.microsoft.com/en-us/library/ee353720.aspx ) 是一个高阶函数,其签名是 (int->int->(int->int->'T)->'T[,] ),其中签名的内部部分代表所谓的 初始化器 或根据索引设置矩阵各个元素的函数。以下匿名函数可以用来根据索引初始化对角矩阵的元素:

fun x y -> if x = y then 1 else 0 

以下屏幕截图展示了在 FSI 中通过将前面的函数插入到表达式 (Ch3_2.fsx ) 中来完成此任务,如下所示:

Array2D.init 5 5 (fun x y -> if x = y then 1 else 0) 

观察正在构建并显示的所求矩阵:

函数作为参数

使用匿名函数作为高阶函数的参数

函数作为返回值

正如我在函数定义部分提到的,函数的返回值只是最后一个表达式的值。为了返回一个函数,我们可以在宿主函数的最后一个表达式中使用:要么是一个匿名函数定义,要么是部分函数应用。

让我们通过进行更难的练习来探讨这个问题。通常,有一个函数可以让你精确测量被其他函数包裹的任意计算的执行时间是非常有帮助的。此外,将环境信息嵌入到测量结果中也是非常有用的。

因此,我将实现一个高阶函数stopWatchGenerator,它接受另一个函数f作为自己的参数,该函数具有参数x,并返回一个由匿名函数表示的函数值,该匿名函数具有完全相同的签名。这个匿名函数只是用这个以毫秒精度测量的计算持续时间包装了计算(f x)。它将测量的持续时间传达给输出设备,并附带主可执行文件的名字。所以,对于 32 位 FSI,它将是[fsi];对于 64 位 FSI,它将是[fsiAnyCPU];对于自定义可执行文件,它将是可执行文件的名字。有时,这样的实用工具可以非常有帮助,对吧?

实现如下面的代码所示(Ch3_3.fsx):

let stopWatchGenerator (f:('a->'b)) (x: 'a) : (('a->'b)->'a->'b) = 
  let whoRunsMe = 
    System 
    .Diagnostics 
    .Process 
    .GetCurrentProcess() 
    .MainModule 
    .FileName 
    |> System.IO.Path.GetFileNameWithoutExtension 
    |> sprintf "[%s]:" in 
  fun f x -> 
    let stopWatch = System.Diagnostics.Stopwatch() in 
    try 
      stopWatch.Start() 
      f x 
    finally 
      printf "Took %dms in %s\n" 
      stopWatch.ElapsedMilliseconds 
      whoRunsMe 

let whatItTakes f x = (stopWatchGenerator f x) f x 

请注意,我故意为stopWatchGenerator的参数f(这是一个接受泛型类型'a的参数并返回泛型类型'b的结果的函数)和x(它是类型'a的值),以及stopWatchGenerator的返回类型(它是一个接受类型('a->'b)'a的两个柯里化参数的函数,并返回类型'b的结果的函数)指定了显式类型。

你的头开始转了吗?这是正常的,请放心,你会逐渐习惯这些看似复杂的操作,并且很快会发现它们就像苹果派一样简单。

函数stopWatchGenerator使用fun lambda 表达式返回所需的匿名函数,该表达式创建.NET System.Diagnostics.Stopwatch()的实例,并在评估目标表达式(f x)的周围包装其开始和读取。

函数whatItTakes只是函数评估阴影计时安排的一个方便缩写。

下面的截图显示了使用生成的函数的两个示例:

函数作为返回值

返回另一个函数的函数的实际应用

第一个用例检查了生成并累加前 1000 万个正数所需的时间,如下面的代码所示:

> whatItTakes (fun x -> seq {1L .. x} |> Seq.sum) 10000000L;; 
Took 242ms in [fsianycpu]: 
val it : int64 = 50000005000000L 
> 

第二个用例演示了通过应用Gregory 级数(mathworld.wolfram.com/GregorySeries.html)方法,使用任意系列长度来计算一定精度的π的方法,如下面的代码所示:

> whatItTakes (fun cutoff -> 
  (Seq.initInfinite (fun k -> (if k%2 = 0 then - 1.0 else  1.0)/((float k) * 2.0 - 1.0)) 
  |> Seq.skip 1 
  |> Seq.take cutoff 
  |> Seq.sum) * 4.0) 2000000;; 
Took 361ms in [fsianycpu]: 
val it : float = 3.141592154 
> 

如结果所示,Gregory 级数并不是计算π的最佳公式;然而,它可以用来展示函数值的强大功能。

函数作为数据类型组成部分

现在,你可能会有一个狡猾的问题:“原始类型值可以组合成更复杂类型;例如,一些int值可以存储在一个int数组中。如果函数真的是一等值,它们应该允许类似的组合。那么,构建一个函数数组怎么样?”

我的回答是:“当然,为什么不呢?”让我们考虑以下函数定义(Ch3_4.fsx):

let apply case arg = 
  if case = 0 then 
    sin arg 
  elif case = 1 then 
    cos arg 
  elif case = 2 then 
    asin arg 
  elif case = 3 then 
    acos arg 
  else 
    arg 

apply函数接受两个参数,如果第一个参数case03的范围内,它将对第二个参数arg应用相应的数学库三角函数。否则,它只返回未更改的arg值,这是一个平淡无奇的实现。让我们通过以下代码将函数排列成数组来增加一些趣味:

let apply' case arg = 
  try 
    [|sin; cos; asin; acos|].[case] arg 
  with 
    | :?System.IndexOutOfRangeException -> arg 

我使用了 F#的try...with结构来筛选出需要特定函数应用的情况值实例,以及那些只返回arg回声的实例。

这是通过[|sin; cos; asin; acos|]结构实现的,它具有(float -> float[]签名。这意味着正好符合预期,或者是一个相同类型的函数数组(接受类型为float的单个参数并返回类型为float的结果)。每个数组元素位置都与特定的函数实例相关联,通过[case]索引器,或者[|sin; cos; asin; acos|]``.[0]返回sin[|sin; cos; asin; acos|]``.[1]返回cos,依此类推。[|sin; cos; asin; acos|].[case]表达式的值是一个函数,其中情况值在03的有效范围内。因此,它可以应用于arg,得到相应的结果。超出有效范围的情况值将引发System.IndexOutOfRangeException异常,并通过简单地返回arg的回声值来捕获和处理。我必须承认,像上面那样滥用异常机制是糟糕的编码实践,但请原谅我在演示一些完全无关的功能时在玩具示例中使用它。

函数是接口

考虑到 F#作为以函数优先的语言,同时支持.NET 的面向对象类型系统,值得探讨函数与接口之间的关系。原版的《设计模式:可复用面向对象软件的基础》(www.informit.com/store/design-patterns-elements-of-reusable-object-oriented-9780201633610)在其引言中指出了以下可复用面向对象设计的原则:

针对接口编程,而不是针对实现。

从这个角度来看,函数是接口的精髓。在面向对象的世界里,接口必须在其实施之前被显式声明,以便另一个实现可以被替换,而在函数式编程领域,这种声明是多余的。只要两个或多个函数具有相同的签名,它们就可以在代码中互换使用。函数签名相当于接口。

我之前的函数数组示例清楚地展示了如何通过更改数组元素索引值来实现实现的更改。

闭包

正如我之前提到的,函数的结果取决于参数。这种依赖是否详尽?当然不是。函数定义存在于一个词法上下文中,并且可以在将参数转换为结果的过程中自由使用该上下文中的某些实体。让我们考虑以下代码示例 (Ch3_5.fsx ):

let simpleClosure = 
  let scope = "old lexical scope" 
  let enclose() = 
    sprintf "%s" scope 
  let scope = "new lexical scope" 
  sprintf "[%s][%s]" scope (enclose()) 

前面的 enclose() 函数除了 unit 没有任何参数。然而,返回的结果取决于在函数定义时词法作用域中的自由值 scope。将 scope 值绑定到 "old lexical scope"。这个值被捕获,由 enclose() 定义“封闭”。这两个部分一起构成了一个名为 闭包 的特殊实体。这个过程在以下图中以示意图的形式展示:

闭包

闭包的一个示例

因为它们是封闭的,所以自由值不会改变。在以下示例中,值 scope 在后来被新的值 "new lexical scope" 覆盖。然而,这并不会改变闭包中捕获的值。这反映在以下图中,显示了在 FSI 中运行最后一个示例,其中旧的和新的作用域共存:

闭包

简单闭包的实际应用

这里,我提供了另一个闭包的示例,这次演示了匿名函数定义创建的闭包中状态的捕获和更新 (Ch3_5.fsx ):

let trackState seed = 
  let state = ref seed in 
  fun () -> incr state; (!state, seed) 

在这个片段中,trackState 函数将其自己的参数捕获到一个闭包中,并伴随着匿名函数,每次调用时都会增加这个闭包中隐藏的局部计数器。接下来的图示展示了两个独立的闭包,counter1()counter2(),它们分别由不同的 trackState 调用创建,使用不同的种子跟踪它们自己的状态:

闭包

代表对象的闭包

这个示例突出了闭包如何被用来表示内部字段,即使语言实际上并不支持对象。在这方面,正如一个编程寓言所说,闭包是穷人的对象 (c2.com/cgi/wiki?ClosuresAndObjectsAreEquivalent ) 确实。

可变值

在函数式优先语言的精神下,F# 的值默认是不可变的。然而,该语言提供了使用可变值的设施。

可变变量可以使用值绑定的 let mutable 语法和 <- 赋值运算符来创建,以改变之前绑定的值。使用 let mutable 绑定的可变值存储在栈上。

直到 F# v4.0,不允许在闭包中捕获可变值编写代码,但从语言的 v4.0 版本开始,这种限制已经被取消。

引用单元格

对于可变值,有一个来自 OCaml 的引用单元格的替代设施。这些值使用特殊的 ref 函数和 let 绑定在堆上分配。引用单元格的底层值可以通过解引用运算符 ! 来访问。引用的值可以通过特殊的赋值运算符 := 来修改。

可变值和引用单元格之间存在细微的区别:可变值是通过值复制的,而引用单元格是通过引用复制的。让我提供一个代码片段来说明这个问题 (Ch3_6.fsx):

let mutable x = "I'm x" 
let mutable y = x 
y <- "I'm y" 
sprintf "%s|%s" x y 

let rx = ref "I'm rx" 
let ry = rx 
ry := "I'm ry" 
sprintf "%s|%s" !rx !ry 

下图通过在 FSI 中运行前面的代码片段来演示这种差异:可变值 xy 是独立的,因此改变 y 的值从与 x 的值相同到不同的值并不会以任何方式影响 x;它们的值保持不同。

然而,rxry 引用了同一个对象,所以通过 ry 引用更改底层对象时,同时也会将 rx 引用的先前对象更改为 ry 引用的同一个对象:

引用单元格

可变值和 ref 值之间的区别

类型推断

我已经在本章前面概述了类型推断。这是 F#(以及许多其他语言:首先是 C#)的一个特性,源于其静态类型属性。通过遵循从上到下、从左到右的自然代码流方向,F# 编译器能够推导出代码中存在的值的类型,包括函数类型。这种能力反过来又允许你从 F# 代码中省略显式类型声明。最终,代码可以更快地编写,相当简洁,如果编译成功,在类型上是一致的。

在编写 F# 代码时,依赖类型推断不是强制性的。在以下场景中添加显式声明可能特别有意义:

  • 当类型无法推断且编译器提示显式声明时

  • 如果代码的作者认为在某些情况下提供显式类型声明可以简化代码理解并提高其可读性

推断值类型的最明显方式是在绑定期间,根据等号右侧表达式的类型进行推断,如下面的代码所示 (Ch3_7.fsx):

let s = "I'm a string" 
let dict = 
  System.Collections.Generic.Dictionary<string, string list>() 

对于 s,这是右侧字面量的类型,或 string。对于 dict,这是在绑定右侧构建的 Dictionary<string, string list> 类型的实例。在前面提到的类似情况下,添加 sdict 的显式声明只会给代码添加不必要的噪音。

另一个相当明显的情况是在某些情况下根据函数体的定义推断函数签名,如下面的代码所示 (Ch3_7.fsx):

let gameOutcome isWin = "you " + if isWin then "win" else "loose" 

在这里,由于 isWinif 之后使用,这一事实允许 F# 编译器推断其类型为 bool;返回类型显然是 string,因此 gameOutcome 函数的签名可以推断为 (bool->string)。简单,对吧?

类型推断失败的情况可能并不简单,以下(相当天真)的片段(Ch3_7.fsx )可以说明:

let truncator limit s = 
  if s.Length > limit then 
    s.Substring(0, limit) 
  else 
    s 

在这里,F# 编译器对 s.Lengths.Substring 抱怨,如下所示:

基于程序点之前的信息在不确定类型的对象上进行查找。在程序点之前可能需要一个类型注解来约束对象的类型。这可能允许查找被解决。

将函数定义更改为 let truncator limit (s: string) = 会使 F# 编译器再次满意。

此外,如果我不那么天真,至少对边界情况进行一些检查,就像在以下代码的略微增强定义中所示(Ch3_7.fsx ):

let truncator' limit s = 
  if not (System.String.IsNullOrEmpty s) && s.Length > limit then 
    s.Substring(0, limit) 
  else 
    s 

然后,编译器可以从 System.String.IsNullOrEmpty 库函数的参数使用中推断出 s 的类型为 string;不再需要显式类型声明。

在静态约束泛型类型的领域中,类型推断变得更加重要。让我们考虑一个 logAndTrash 函数的略微复杂示例,它接受一个可丢弃的 ss 集合,将每个 s 项作为单独的文本行写入 .NET StringBuilder,丢弃集合,并返回最终的 StringBuilder 值,以便在其他地方稍后使用,如下所示代码(Ch3_7.fsx ):

let logAndTrash ss = 
  let log = System.Text.StringBuilder() 
  for s in ss do 
    sprintf "%A" s|> log.AppendLine |> ignore 
  (ss :> System.IDisposable).Dispose() 
  log 

F# 编译器非常体贴,能够推断出 logAndTrash 函数相当复杂的签名,其字面意思是如下所示:

'a -> System.Text.StringBuilder 
  when 'a :> seq<'b> and 'a :> System.IDisposable 

或者用简单的话来说,这是一个接受泛型类型 'a 的值的函数,并返回一个 StringBuilder 实例的函数,其中 'a 必须是任何泛型类型 'b 的序列,并且同时是可丢弃的。

之前代码示例中展示的类型推断案例总结如下。

提示

在 F# 中,值、函数和具有约束的泛型的类型可以在许多代码上下文中明确推断,包括但不限于-字面量、构造实例、在特定表达式部分的使用、库或自定义函数或方法的签名。

递归函数基础知识

在本章中,我想向您介绍递归函数的基础知识,并将更详细的考虑留给更高级的上下文。在此阶段,我想展示 F# 默认将函数视为非递归处理与使用 let 绑定修饰符 rec 显式声明递归函数时的强制递归处理有何不同。

看一下以下这个有些牵强的片段 (Ch3_8.fsx ):

let cutter s = 
  let cut s = 
    printfn "imitator cut: %s" s 
  let cut (s: string) = 
    if s.Length > 0 then 
      printfn "real cut: %s" s 
      cut s.[1..] 
    else 
      printfn "finished cutting" 
  cut s 

这里提供的 cutter 函数返回一个非空字符串,其目的是从左侧开始,逐个符号地切割,直到参数消失。在 cutter 函数体内,有两个 cut 内部函数的定义,其中第二个定义显然覆盖了第一个定义。此外,在第二个 cut 定义中,它通过将参数从左侧缩短一个字符来自身调用,这是一个明显的递归案例(en.wikipedia.org/wiki/Recursion)。

以下屏幕截图显示了将前面的代码输入到 FSI 中并执行,产生了一些输出:

递归函数基础

函数定义的默认非递归作用域

然而,显然,这段代码并没有按预期工作,因为为了自引用,当出现 cut s.[1..] 时,第二个 cut 定义在词法上并不完整。第二个 cut 定义没有覆盖第一个 cut 定义模仿的实例,因此第二个(真实)cut 的单个输出后面跟着模仿 cut 的单个输出,计算在这里完成。哎呀,这离预期的输出相差甚远!

在下面的屏幕截图中,cut 的第二个定义被 rec 修饰符点缀:

递归函数基础

函数定义的强制递归作用域

现在,cut 的第二个定义立即覆盖了第一个定义,允许第二个内部 cut 函数真正地调用自身,这反映了输出变化;现在,实现的行为正如预期:所有执行过的切割都是真实的。

因此,到目前为止,你应该能够理解 rec 修饰符使函数值立即可用以供引用,无需等待函数定义在词法上完整,从而使得函数能够引用自身。

运算符作为函数

抽象地思考,什么是运算符?它可以被视为一个或两个参数的函数,这些参数只有一个简洁的名称,由一个或非常少数量的符号表示。F# 热切地支持这种抽象。例如,看看以下表达式:

(%) 10 3 = 10 % 3 

在等号(=)的左侧,调用 (%) 函数,参数为 103。在等号(=)的右侧,仅存在一个 10 % 3 表达式。在 FSI 中评估整个表达式,其值为 true,因为等号(=)左右两侧的子表达式确实相同。

此外,等号(=)本身也是一个运算符。在 FSI 中使用以下表达式评估等号(=)本身 =(=);; 将揭示以下函数签名:

('a -> 'a -> bool) when 'a : equality 

前面的签名意味着(=)是一个接受两个泛型类型 'a 支持相等性的参数并返回 bool 值的函数。

注意

有关 F# 核心运算符的描述,请参阅核心.运算符模块 (F#) (msdn.microsoft.com/en-us/library/ee353754.aspx)。那些想要定义自己的运算符的人,如果适度进行,这不是一件坏事,我推荐阅读运算符重载 (F#) (msdn.microsoft.com/en-us/library/dd233204.aspx)。

函数组合

函数组合可能是函数程序员需要掌握的最基本技能。尽管这可能听起来很简单,但这实际上是关于将一些函数组合成一个更强大的组合。这听起来可能接近我之前提到的更高阶函数,确实如此。函数组合就是专注于构建函数应用链,从而从更简单的一组中实现更强大的数据转换。

组合子

如果按照定义,用于组合的函数只是某种黑盒,只能消耗参数并产生结果,那么函数组合究竟是如何进行的呢?这是正确的;函数、参数和应用的单一操作都是组合所需的所有内容(记住最小化移动部件)。尽管如此,组合仍然是由函数执行的。那些仅通过其参数或值(其中一些可能是函数值)来产生结果,而不涉及任何外部上下文的函数被称为组合子。在应用数学中有一个完整的分支,即组合逻辑(en.wikipedia.org/wiki/Combinatory_logic),它特别关注组合子的学习。这可能采取非常任性的形式;那些想要深入研究的人,我建议你们在 Google 中搜索“idiot bird combinator”字符串并跟随链接。

id 组合子

组合子的最简单代表是 id。在 FSI 中输入 (id);; 可以揭示这个函数签名 ('a -> 'a)。换句话说,这个组合子接受任何值,并简单地返回它,没有任何转换。

前向管道 |>

这个组合子是惯用 F# 的主要工作马。在 FSI 中输入 (|>); 可以揭示这个函数签名 ('a -> ('a -> 'b) -> 'b)。换句话说,这个组合子将其第二个参数,即函数 ('a -> 'b),应用于其第一个参数 'a,从而得到结果 'b

此外,可能看起来顺序并不那么重要;然而,实际上它确实很重要。涉及的一个因素是类型推断,它在管道函数组合(记住从左到右)中工作得更好。

后向管道 <|

在 FSI 中输入(<|); ;可以揭示这个函数签名:(('a -> 'b) -> 'a -> 'b)。换句话说,这个组合器将其第一个参数('a -> 'b)应用于第二个'a,得到结果'b。乍一看,这个组合器可能显得有些多余。然而,当它变得有用的重要情况之一是消除参数周围的括号,并最终提高代码的可读性。

前向组合 >>

这个组合器将函数组合在一起。在 FSI 中输入(>>);;可以揭示这个函数签名(('a -> 'b) -> ('b -> 'c) -> 'a -> 'c)。换句话说,拥有两个函数和一个参数,它将第一个函数应用于参数,然后将第二个函数应用于第一个应用的结果。

后向组合

这个组合器也将函数组合在一起,但它以不同的方式做这件事。在 FSI 中输入(<<); ;可以揭示这个函数签名(('a -> 'b) -> ('c -> 'a) -> 'c -> 'b)。换句话说,拥有两个函数和一个参数,它将第二个函数应用于参数,然后第一个函数应用于第一个应用的结果。有时,这样的应用顺序可能对提高可读性或其他原因来说很方便。

摘要

我预计这一章已经将你的直觉引向了 F#函数式第一性质所基于的一些概念。

从任何相关的代码上下文中识别和提炼这些基石,你现在已经准备好吸收主要内容。在下一章中,我将转向存在于每一个数据转换中的 F#编程技术的基石,即模式匹配

第四章。基本模式匹配

本章继续了上一章开启的功能式编程基础研究。它涵盖了基本的数据模式匹配。模式匹配是嵌入到 F# 语言核心的强大数据处理机制中的一个丰富功能特性。

对于企业开发者来说,熟练掌握 F# 的模式匹配特性是绝对必要的,因为大多数时候,企业业务都是围绕业务线应用程序LOB)中的复杂数据转换以及数据仓库和商业分析中的提取、转换、加载ETL)周期进行的。blogs.msdn.microsoft.com/dragoman/2007/07/19/what-is-a-lob-application/ en.wikipedia.org/wiki/Extract,_transform,_load

我故意将本章的主题缩小到基本模式匹配,仅仅出于教学目的。通常,F# 初学者首先将模式匹配视为强化版的命令式 switch,或者仅仅是语义上等价于编写冗长的 if...then...elif...elif... ...else... 表达式。然后,他们开始认识到模式匹配在数据结构分解中的作用。最后,通过接受活动模式,模式匹配的知识获取得以完成。

本章的目的是为您提供对与 F# match 构造相关的模式匹配特性的全面理解:

  • 这个相当复杂语言结构的整体组成

  • 匹配部分背后的隐含假设(例如匹配规则的顺序和模式案例的完整性等)

  • 特定的模式案例类型以及如何构建复合案例

将分解能力考虑推迟到下一章关于数据结构的内容。同样,我将在介绍 F# 的高级编程技术时处理活动模式

带有 match 构造的显式模式匹配形式

F# 中的显式 match 构造属于控制流元素,与 if-then-elsewhile-do 一起。其他 F# 组件中,match 是以下部分和规则的相对复杂组合:

match comparison-expression with 
  | pattern-expression1 -> result-expression1 
  ......................................... 
  | pattern-expressionN -> result-expressionN 

它以这种方式工作,即 comparison-expression 与每个以 pattern-expression1 开头的 pattern-expression 并置,并沿着列表向下进行,直到第一个匹配发生,或者通过 pattern-expressionN 仍然没有匹配。如果找到 pattern-expressionX 的匹配,则整个构造的结果是 result-expressionX 的结果。如果没有找到匹配,则抛出 MatchFailureException,表示匹配案例不完整。

F# 初学者在第一次阅读时常常遗漏的模式匹配要点如下:

  • match构造表示一个表达式,就像任何其他 F#构造一样,除了值绑定。这意味着只有一个result-expressions的值将被用作整个构造的值(假设确实发生了某种匹配)。

  • 每个pattern-expression1pattern-expressionN都必须具有相同的类型,这与比较表达式的类型也相同,以便match构造能够编译。

  • 为了使匹配构造能够编译,每个result-expression1result-expressionN都必须具有相同的类型。

  • 列出的模式到结果案例按自顶向下的顺序在运行时依次尝试。这种安排规定了从模式共同性的角度来看案例的某种顺序。更具体的模式必须先于不那么具体的模式;否则,更具体的模式将永远不会有机会被匹配。

  • 所有可能模式表示的备选方案必须是穷尽的;否则,匹配未由任何模式覆盖的比较表达式将导致MatchFailureException异常。

  • 可以使用布尔逻辑运算符 OR(|)、AND(&)和一个特殊的when守卫将更原子的模式项组合成更广泛的模式表达式。

现在,我将带你了解模式种类的多样性,以便你习惯它们的广泛功能,并变得熟悉match表达式的使用。

匹配字面量

匹配模式的最简单情况之一是由字面量表示的模式,并假设一个简单的比较表达式值相等。字面量可以是任何数值、字符或字符串类型。它们也可以是.NET 枚举的实例(每个这样的实例本质上都是整数值的符号名称别名)或带有[<Literal>]属性的值。

在以下脚本中,我可以轻松地匹配int字面量和被[<Literal>]属性装饰的int值别名THREECh4_1.fsx)。

[<Literal>] 
let THREE = 3 

let transformA v = 
  match v with 
  | 1 ->"1" 
  | 2 ->"2" 
  | THREE ->"3" 

transformA <| (1 + 2) 

这将产生预期的字符串"3"。然而,不可能将int字面量与以下脚本(Ch4_1.fsx)中的命名int常量值混合(即Multiples.ZeroMultiples.Five,尽管它们是字面量,但被类型化为Multiples枚举的成员)。

type Multiples = 
  | Zero = 0 
  | Five = 5 

let transformB ``compare me`` = 
  match ``compare me`` with 
  | Multiples.Zero ->"0" 
  | Multiples.Five ->"5" 
Multiples.Five |> transformB 

这将产生字符串"5",尽管Multiples.ZeroMultiples.Five是字面量,但它们被类型化为Multiples枚举的成员。

(此外,如果你还没有完全理解这一点,将几乎任何文本放在双引号之间,例如上面的cs ``compare me`` ,会使这段文本成为一个有效的 F#名称,并且在使用得当的情况下,可能会增加代码的可读性)。

通配符匹配

如果我将前面的脚本放入 Visual Studio,F#源代码编辑器将在cs ``compare me`` 比较表达式下绘制一条蓝色的波浪线警告,表明此match构造中的规则集不是穷尽的,如下面的截图所示:

通配符匹配

一个不完整模式匹配的例子

编译器甚至给出了一个示例值 cs ``compare me`` ,这个值不会匹配。尽管这个值不在 Multiples 类型的定义中,但如果我合成这个值作为 enum<Multiples>(1) 并将其作为参数传递给 transformB,结果将是 Microsoft.FSharp.Core.MatchFailureException 类型的运行时异常。这种情况应该引发以下问题:如何将 match all 规则放入 match 中,这意味着任何在先前规则中未指定的内容?

为了这个目的,F# 提供了特殊的 通配符模式 _,它可以匹配在先前规则中未匹配到的任何内容。借助它的帮助,并转向 F# 处理未定义值的惯用方法,即以 option 类型的值呈现结果,仅处理合法 Multiples 值的函数可以定义如下代码(Ch4_1.fsx):

let transformB' m = 
  match m with 
  | Multiples.Zero -> Some "0" 
  | Multiples.Five -> Some "5" 
  | _ -> None 

现在,transformB' 定义中的匹配包含了一组详尽的匹配情况。任何作为 m 给定的 Multiples 的合法值都将转换为相应的 Somestring option 值,而任何 m 参数的非合法值都将转换为 None 结果。

排列匹配规则

通配符模式展示了从更具体到更不具体的匹配情况排列的重要性。例如,如果我将带有通配符模式的 match all 第三规则放在前面脚本中的前两个规则之前,那么 F# 编译器将在显式的 Multiples 值下放置蓝色波浪线,表示这些规则将永远不会匹配(检查 Ch4_1.fsx 中的 transformB'' 定义)。

命名模式

当名称(标识符)出现在模式情况的位置时,F# 编译器会进行某种分析。严格来说,名称有以下几种可能性:

  • 命名字面量(例如,在早期脚本中的 THREE)

  • 判别联合的案例值(例如,如果匹配 F# 的 option 则为 None

  • 异常的类型(例如,如果匹配异常类型则为 System.ArgumentException

  • 活动模式的自定义名称(将在后续章节中介绍)

如果名称出现不符合之前列出的任何替代方案,则该名称被视为变量模式msdn.microsoft.com/en-us/library/dd547125.aspx)。它被处理得类似于通配符模式,获取 comparison-expression 参数的值,该值可以用在相应的 result-expression 中。听起来很复杂,对吧?那么让我们通过一个示例来澄清这个问题。

我只是从匹配字面量部分复制了 transformA 函数的定义,将函数名称更改为 transformA',并从上下文(Ch4_2.fsx)中移除了 THREE 字面量的定义:

let transformA' v = 
  match v with 
  | 1 -> "1" 
  | 2 -> "2" 
  | THREE -> "3" 

以下截图显示了尝试此函数版本的结果。

命名模式

将字面量模式转换为变量模式

首先,省略字面量并没有使脚本崩溃,只是产生了一个无害的警告,指出THREE可能是一个拼写错误的模式名称。将函数应用于完全偏离的参数50,得到的结果与合法参数值3相同的结果。这是怎么回事?

没有魔法;根据描述标识符,THREE没有被识别为命名字面量、区分联合情况、异常类型或活动模式。这个发现使其变成了一个扮演匹配所有模式情况角色的变量模式,result-expression只是盲目地将它输出为字符串"3"

在我的 F#开发者经验中,我至少遇到过一次这种看似无害的模式类型转换错误变成了一个讨厌的 bug。

小贴士

道德:小心处理,不要忽视 F#编译器的警告!

as 模式

有趣的是,一个模式情况可以附加一个as子句。这个子句将匹配的值绑定到一个名称,该名称可以在match构造的相应result-expression或外层let绑定的局部上下文中使用。以下脚本演示了as模式有多灵活(《Ch4_3.fsx》):

let verifyGuid g = 
  match System.Guid.TryParse g with 
  | (true,_ as r) -> sprintf "%s is a genuine GUID %A" g (snd r) 
  | (_,_ as r) -> sprintf "%s is a garbage GUID, defaults to %A" 
                        g (snd r);; 

在第一种情况下,r使用as绑定到TryParse的结果,即元组,因此表达式snd r产生了解析的 GUID 值。

在第二种情况下,asr绑定到任何元组;然而,从匹配情况的顺序中可以明显看出,这个情况匹配了失败的 GUID 解析,并且参数的值是垃圾。

以下截图反映了在 FSI 中使用as绑定匹配情况触发每个这些情况:

as 模式

带有 as 绑定的模式匹配

分组模式

我到目前为止所涵盖的模式匹配情况可以以类似于具有 OR(|)和 AND(&)运算符的布尔表达式术语的方式组合在一起。让我通过实现一个接受两个表示键的字符串参数并验证提供的值都不为空并提供详细诊断的功能来演示这项技术。

你应该能够理解为什么我应该以最具体的案例开始匹配,当两个键都为空时。下一个不那么具体的匹配由两种对称的情况表示,即第一个或第二个键为空。在这里,为了展示 F# 模式分组提供的灵活性,我使用布尔 OR 将这两个模式组合起来,同时使用表示为元组 (x,y) 的变量模式将键值捕获到局部上下文中。对于最通用的剩余情况,我知道两个键都不为空,所以这里只需要一个变量模式就足够了。所寻求的函数定义如下 (Ch4_4.fsx ):

open System 

let validate keyA keyB = 
  match (keyA,keyB) with 
  | ("","") -> "both keys are empty" 
  | (x,y) & (("",_) | (_,"")) -> 
    sprintf "one key is empty: keyA = %s; keyB = %s" x y 
  | _ & (x,y) -> 
    sprintf "both keys aren't empty: keyA = %s; keyB = %s" x y 

虽然布尔 OR 模式组合器通过组合需要相同转换表达式的某些案例来帮助达到 F# 代码的简洁性,但在常规模式匹配实践中,布尔 AND 并不经常用于组合模式案例。然而,当分组 活动模式 时,它变得非常相关,我将在后面的章节中介绍。

守卫

到目前为止,我相信你可能会同意模式匹配是一个强大的数据转换功能。为了进一步强调迄今为止考虑的功能,F# 提供了增强 pattern-expressions 的额外匹配逻辑。守卫 是一个任意布尔表达式,它使用 when 关键字附加到 pattern-expression 上。只有当其 pattern-expression 宿主匹配时,守卫才会启动。然后,计算守卫表达式,如果为 true,则触发相应的 result-expression 右侧执行的转换。否则,整个规则被视为未匹配,匹配将继续以常规方式进行。when 守卫可以在 match 构造中完全任意地混合和匹配。

为了展示 when 守卫的实际应用,让我稍微修改一下之前的例子。在两个键都不为空的情况下,有两种子情况:当键彼此相等时和不相等时。此外,我们的函数需要对这些情况中的每一个进行不同的格式化。

对于这次修改,只需要在最后一行之前添加一行代码(记住,我想添加一个更具体的匹配案例,然后它必须放在一个更通用的案例之前)。代码如下:

| (x,y) when x = y -> sprintf "both keys are not empty: keyA =    keyB = %s" x

这就是这次修改的全部内容。我鼓励你通过 FSI 在 Ch4_4.fsxCh4_5.fsx 脚本中输入脚本中提供的不同参数,并观察函数行为的改变。

匿名函数执行匹配的替代语法

F# 提供了一种特殊语法来定义执行匹配的匿名函数,或称为模式匹配函数(docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/match-expressions )。

这种语法假设匿名函数有一个参数,该参数放置在函数体开始的不可见 match 构造中。仅仅有这种定义模式匹配匿名函数的替代方式,就增加了语言的简洁性,并且更好地反映了在代码中定义此类函数的意图。

继续进行编码练习,在最新的 F# 脚本中,我将使用替代语法重写 validate 函数。然而,为了实现这一点,你需要解决以下问题。替代语法假设模式匹配函数只有一个参数,而 validate 函数有一对参数。解决方案将是应用在阅读上一章并执行柯里化后获得的技能。以下是对应的代码 (Ch4_6.fsx):

open System 

let validate key1 key2 = (key1,key2) |> function 
  | ("","") -> "both keys are empty" 
  | (x,y) & (("",_) | (_,"")) -> 
    sprintf "one key is empty: keyA = %s; keyB = %s" x y 
  | (x,y) when x = y -> 
    sprintf "both keys are not empty: keyA = keyB = %s" x 
  | (x,y) -> 
    sprintf "both keys aren't empty: keyA = %s; keyB = %s" x y 

摘要

希望这一章在纯模式匹配方面没有遗漏任何细节。你现在应该已经做好了准备,去克服 F# 初学者程序员通常会遇到的典型模式匹配挑战。我提醒你,数据分解和主动模式匹配等进一步的模式匹配特性将在后面的章节中介绍,以保持材料的逻辑流程。

在下一章中,我将转向令人兴奋的主题——代数数据类型。我们将探讨数据是如何组合的,以及数据组合背后的好处。

第五章  代数数据类型

在本章中,我将转向 F# 的特性,这些特性在主流编程语言(如 C#)中几乎缺失,在计算机科学中统称为代数数据类型(en.wikipedia.org/wiki/Algebraic_data_type )。它们通过组合其他类型(原始或进一步组合)将原始数据类型提升到更高的类型级别,如下所示:

  • 元组记录代表产品代数数据类型

  • 代表求和代数类型区分联合

我将像下面这样为这些复合类型的每个方面进行覆盖:

  • 类型组合

  • 类型相等性和比较

  • 类型分解

  • 类型增强

我将重新探讨模式匹配作为一种类型分解工具,它通常可以应用于 match 构造之外。

将数据与代数数据类型结合

通常,传统程序员会通过面向对象范式来考虑数据组合的问题。

每个人通常都会直观地理解原始数据类型是基本、内置的类型,由编译器或库支持:int64stringbigint(尽管如果严格来看,string可能被视为char数组,而bigint则被视为记录)。

程序员接下来学到的是,原始类型的实例可以聚合到集合中,如数组列表。然而,这些集合是单态的。也就是说,所有集合成员的类型必须相同。这相当有限,不是吗?

面向对象范式通过扩展了原始类型。类仅代表一个自定义类型,它通过封装隐藏了数据组合的细节,并仅向公共属性提供可见性。通常,.NET 库提供了大量的此类复合类型,例如,System.DateTime

F# 当然也支持以这种方式构造复合数据类型。然而,每次需要复合类型时都遵循繁琐且容易出错的纯旧 CPOCO)方式,这与 F# 简洁且无错误的代码承诺不符。出路在哪里?欢迎来到代数数据类型!

产品代数数据类型

在最简单的情况下,考虑我使用集合积的类比来组合类型 AB;结果将是一个数据对集合,其中第一个对成员是类型 A,第二个成员是类型 B,整个组合是 A 和 B 的笛卡尔积。

F# 提供了两种产品代数数据类型,即元组记录

元组

我已经在之前的章节中提到了元组;现在我将更深入地探讨这个主题。

元组组合

元组是由两个或更多任意类型的值组合而成的。元组值元素的类型可以是任何类型:原始类型、其他元组、自定义类和函数。例如,看看以下代码行(Ch5_1.fsx):

let tuple = (1,"2",fun() ->3) 

这表示由三个元素组成的元组,其类型为 int* string * (unit -> int)

为了属于同一类型的元组,两个元组值必须具有相同数量的元素,并且这些元素在出现顺序上具有相似的类型。

元组的等价性和比较

如果每个元素类型都支持等价约束,F# 会自动实现元组的结构等价。元组相等当且仅当它们的元素成对相等,如下面的代码所示(Ch5_1.fsx):

let a = 1, "car" 
a = (1, "car") 

前面的等式表达式值为 true。然而,对于绑定在 tuple 上的以下表达式的值,编译器会报错(Ch5_1.fsx):

tuple = (1,"2",fun() ->3) 

编译器会抱怨 (unit -> int) 类型,它是构成元组第三个元素的函数,不支持 'equality' 约束。F# 函数值没有定义等价关系。

F# 默认提供了元组的结构比较,它基于从左到右的字典序元素对之间的比较,前提是所有元素类型都满足 'comparison' 约束,如下面的代码所示(Ch5_1.fsx):

a < (2,"jet") 

前面的表达式值为 true

使用模式匹配进行元组分解

本章是履行我在第四章中关于数据结构解构工具中模式匹配的承诺的完美地方,即基本模式匹配。以下代码片段演示了如何将值绑定功能从 match 构造中提取出来(Ch5_1.fsx):

let (elem1, elem2) = a 
printfn "(%i,%s)" elem1 elem2 

在这里,elem1elem2 实际上获取了元组 a 的第一个和第二个元素的值,这通过 (1,car) 输出得到了反映。

在特定的元组解构模式中不感兴趣的元组元素可以使用熟悉的 match-all _ 模板省略,如下面的代码(Ch5_1.fsx`)所示:

let (_,_,f) = tuple in 
f() 

这个片段突出了如何从元组值中的第三个元素获取并调用一个函数;前两个元组元素通过 _ 模板简单地被忽略。

元组扩展

元组类型没有显式的名称。这一事实实际上使得正常的 F# 类型扩展变得不可能。尽管如此,仍然有一些空间可以进行一些巧妙的操作。这个操作利用了与其他 .NET 语言进行互操作的需求。

文档 (msdn.microsoft.com/en-us/library/dd233200.aspx ) 指出,元组的编译形式表示类 Tuple (msdn.microsoft.com/en-us/library/system.tuple.aspx ) 的相应重载。鉴于这一事实,我可以增强编译表示,并使用增强方法通过类型转换来实现,如下面的代码所示(Ch5_1.fsx):

let a = 1,"car" 
type System.Tuple<'T1,'T2> with 
  member t.AsString() = 
    sprintf "[[%A]:[%A]]" t.Item1 t.Item2 
(a |> box :?> System.Tuple<int,string>).AsString() 

在这里,我增强了一个具有类型 System.Tuple<'T1,'T2> 的两个泛型元素的元组,并添加了 AsString 实例方法,这允许以非常独特的方式呈现元组值。然后,给定 int*string 元组的实例,我使用 box 函数将其提升到 obj 类型,然后立即使用 :?> 操作符将其下转换为 System.Tuple<int,string> 类型,随后在欺骗性构建的 System.Tuple<int,string> 类实例上调用 AsString 增强方法,得到预期的结果,即 [[1]:["car"]]

总结一下,我可以得出结论,元组代表了一种简单的代数数据类型,非常适合简单的设计。在数据组合中使用元组而不是自定义类型是 F# 习惯用法的一个典型例子。

记录

记录代表了 F# 另一个原生的产品代数数据类型。它解决了元组异常简单导致的一些缺陷。元组最不利的特性是元组与结构上相似的另一种具体元组类型的绑定缺失。对于 F# 编译器来说,(1,"car")(10,"whiskey") 没有区别,这把区分实例类型的负担放在了程序员身上。如果能够为结构相似但语义不同的类型提供显式名称,那会很好。同时,为了停止仅仅依赖于元素位置,给元组元素添加唯一名称也会很有帮助。当然,欢迎来到 F# 记录

记录组合

F# 记录可以被视为具有显式命名类型和标签元素的元组。参考前面脚本 Ch5_1.fsx 中给出的元组示例,它可以重写如下(Ch5_2.fsx):

type transport = { code: int; name: string } 
let a = { code = 1; name = "car" } 

将前面的代码片段放入 FSI 后,你会得到以下截图所示的结果:

记录组合

定义 F# 记录类型和实例

前面的截图直观地展示了在明确标注整体及其部分时,记录相对于元组的优势。

有趣的是,记录字段的命名使得不需要坚持某种特定的字段列表顺序,如下面的代码所示(Ch5_2.fsx):

let b = { name = "jet"; code = 2 } 

没有任何问题,值 b 被识别为类型 transport 的绑定。

在构造之后,F# 记录实际上是不可变的,类似于元组。语言提供了使用 with 修饰符从现有实例创建记录的另一种形式,如下面的代码所示 (Ch5_2.fsx ):

let c = { b with transport.name = "plane" } 

这将转换为 transport { code = 2; name = "plane" } 的实例。注意使用了“完全限定”的字段名,transport.name。我这样写是为了突出如何解决歧义,因为不同的记录类型可能有同名字段。

记录的等价性和比较

没有惊喜。F# 默认情况下,为记录提供类似于元组的结构等价性和比较,但显式类型声明在此方面提供了更多的灵活性。

例如,如果不需要结构等价性而需要引用等价性,对于记录来说这不是问题,其类型定义可以装饰有 [<ReferenceEquality>] 属性,如下面的代码片段所示 (Ch5_2.fsx ):

[<ReferenceEquality>] 
type Transport = { code: int; name: string } 
let x = {Transport.code=5; name="boat" } 
let y = { x with name = "boat"} 
let noteq = x = y 
let eq = x = x 

以下屏幕截图说明了如果在 FSI 中运行此代码会发生什么:

记录的等价性和比较

F# 记录的引用等价性

注意,在用 ReferenceEquality 属性装饰 Transport 类型之后,两个结构上相等的记录 xy 将不再被认为是相等的。

注意

值得注意的是,使用 [<CLIMutable>] 属性装饰记录类型使底层记录成为标准可变 .NET CLI 类型,用于互操作性场景;特别是还提供了默认的无参数构造函数和元素可变性。有关更多详细信息,请参阅 Core.CLIMutableAttribute 类 (F#) (msdn.microsoft.com/en-us/visualfsharpdocs/conceptual/core.climutableattribute-class-%5Bfsharp%5D )。

使用模式匹配进行记录分解

使用模式匹配解构记录类似于解构元组,并且可以与或不与 match 构造一起工作。从简洁性的角度来看,后者更可取,如下面的代码所示 (Ch5_2.fsx ):

let  { transport.code = _; name = aName } = a 

这将丢弃 acode 字段,因为它不感兴趣,并将它的 name 字段与 aName 值绑定。同样的效果可以用更短的代码实现:

let { transport.name = aname} = a 

如果只需要单个字段值,那么简单的 let aName' = a.name 也可以。

记录增强

为 F#记录显式声明类型允许大量扩展。一个实现线程安全可变单例属性的记录类型扩展的示例可以在SqlClient 类型提供程序代码中找到(github.com/fsprojects/FSharp.Data.SqlClient/blob/c0de3afd43d1f2fc6c99f0adc605d4fa73f2eb9f/src/SqlClient/Configuration.fs#L87)。一个精炼的片段如下所示(Ch5_3.fsx):

type Configuration = { 
  Database: string 
  RetryCount: int 
} 

[<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>]  
[<AutoOpen>] 
module Configuration = 
  let private singleton = ref { Database  = "(local)"; RetryCount = 3 } 
  let private guard = obj() 

  type Configuration with 
    static member Current 
    with get() = lock guard <| fun() -> !singleton 
    and set value = lock guard <| fun() -> singleton := value 

printfn "Default start-up config: %A" Configuration.Current 

Configuration.Current <- { Configuration.Current with Database =    ".\SQLExpress" } 

printfn "Updated config: %A" Configuration.Current 

在这里,DatabaseRetryCount被保留为 F#记录的字段,该记录作为一个线程安全的静态属性,由singleton私有引用支持。这种模式的美丽之处在于,在任何时刻,都可以通过编程方式更改配置,同时保持单例线程安全。

求和代数数据类型

与之前覆盖的乘积代数数据类型相比,求和代数数据类型使用集合求和操作来组合新类型。这种类型的最简单情况是一个由一些单个值组成的枚举。更通用的情况是,一个将许多不同类型称为变体的类型。每个变体贡献一组可能的值,这些值是通过变体构造函数创建的。所有变体的所有可能值与集合求和(并集)结合构成求和类型。

与乘积类型相比的另一个对比是,在所有可能的变体中,只有一个可以是求和类型实例的值,而所有字段构成了乘积类型的值。

这可能听起来很复杂,但概念相当简单。让我们深入探讨。

区分联合

求和代数数据类型是在 F#中通过名为区分联合DU)的本地数据类型引入的。区分联合的极致灵活性使它们能够方便地表示世界上几乎所有的事物。正因为这个原因,F#程序员使用区分联合来构建他们在解决各种问题时提出的特定领域语言。区分联合为任意复杂度的实体提供有意义的命名,以及静态类型的好处,对于清晰表示任何规模的问题都是必不可少的。

区分联合组成

区分联合组成的方式遵循其最自然的呈现方式:它是一系列称为构造函数的变体案例列表,彼此之间由 OR 符号(|)分隔。每个案例反映单个变体(案例)。例如,看看以下定义(Ch5_4.fsx):

type ChargeAttempt =  
  | Original 
  | Retry of int 

这可以自然地反映与信用卡执行收费相关的支付处理领域部分。ChargeAttempt 可以用一个具有两种情况的区分联合来表示:Original,表示信用卡在第一次尝试时成功收费,以及 Retry,表示在最终成功之前有几次不成功的尝试。Retry 反映了总的收费尝试次数,例如,以下代码(Ch5_4.fsx)中的 Retry 4

let cco = Original 
// equivalent let cco = ChargeAttempt.Original 
let ccr = Retry 4 
// equivalent let ccr = ChargeAttempt.Retry(4) 

在前面的代码片段中,cco 是一个类型为 ChargeAttempt 的值,具有 Original 情况值;ccr 也是一个类型为 ChargeAttempt 的值,但它具有 Retry 4 的情况值。

空构造函数情况

空构造函数情况变体代表最简单的案例形式。它只是一个纯标签,没有任何关联的额外类型。我们已经在前面的代码中使用了这种情况变体,它位于单独的标签 Original 之后。

单构造函数情况

单构造函数情况代表一个只有一个情况的区分联合。这是一个非常有用且普遍的模式,它结构化了底层问题的领域,并促进了类型安全。例如,我需要表示一个具有电电压和光亮度等特性的电灯泡。使用单构造函数情况,可以这样实现(Ch5_4.fsx):

type Brightness = Brightness of int 
type Voltage = Voltage of int 
type Bulb = { voltage: Voltage; brightness: Brightness } 

let myBulb = { voltage = Voltage(110); brightness= Brightness(2500)} 

读取前面代码的人会立即接触到需要操作的关键实体。此外,将前面的数值值包装到区分联合的单构造函数情况中,创建了一个额外的类型安全层。也就是说,数字 2500 被包装到构造函数 Brightness(2500) 中,只能用于 Brightness 类型的 brightness 字段。

区分联合的相等性和比较

如以下代码所示(Ch5_4.fsx),区分联合提供了结构化相等性和比较,无需额外操作:

let lamp1br = Brightness(2500) 
lamp1br = Brightness(2500) // true 
lamp1br < Brightness(2100) // false 

带模式匹配的区分联合分解

区分联合与模式匹配非常匹配,因此使用模式匹配分解区分联合非常容易,如下所示(Ch5_4.fsx):

match myBulb.brightness with 
| Brightness(v) -> v 
// retrieves back 2500 wrapped upon construction 

区分联合增强

与 F# 记录类似,区分联合可以严重增强。让我们考虑以下现实生活中的增强示例。在电子支付领域,支付金额可能会根据选择的支付工具而打折。折扣的数量可能预先设置为以下(以下整个设置和具体数字都是虚构的):

  • 对于信用卡,折扣为零

  • 对于借记卡,折扣是 $0.35

  • 对于 ACH,折扣是 $0.75

折扣是支付服务配置的一部分,可能因一次营销活动而异。

根据支付工具应用折扣可以通过以下区分联合增强实现 (Ch5_5.fsx):

type PaymentInstrumentDiscount = 
  | CreditCard of decimal  
  | DebitCard of decimal 
  | ACH of decimal 

  member x.ApplyDiscount payment = 
    match x with 
    | CreditCard d -> payment - d 
    | DebitCard d -> payment - d 
    | ACH d -> payment - d 

在这里,特定的折扣金额通过区分联合案例构造函数与每个支付工具案例相关联:CreditCard(信用卡)、DebitCard(借记卡)或ACH(自动清算所)。除了不同的案例,该类型还共享ApplyDiscount单例方法,该方法根据所选支付工具的当前折扣计算原始支付金额的折扣金额。以下图显示了在 FSI 中运行前面的脚本的结果,其中显示了每种覆盖支付工具的$20.23 支付金额的折扣支付金额:

区分联合增强

增强 F#区分联合

摘要

在本章中,你将熟悉极其重要的 F#特性,这些特性代表代数数据类型。对于每种类型,涵盖了组合、分解、标准自定义等性和比较以及增强等主题。最后,你应理解为什么 F#与原生代数数据类型的组合优于自定义 POCO。

在下一章中,我将转向代表数据和计算二元性的激动人心的 F#序列主题。

第六章. 序列 - 数据处理模式的核心

在本章中,我们将深入探讨函数式编程中最基本且至关重要的安排之一,即序列。将任何数据变换表示为应用于任意可枚举数据容器元素的原子函数的组合的能力是函数程序员必须具备的。本章的目标是帮助您获得这种心理技能。通往这一目标的道路是由以下在这里涵盖的主题铺就的:

  • 回顾基本数据变换,并将庞大的标准库数据变换函数按少量底层处理模式进行划分

  • 考虑序列数据生成器的双重性,即它们既是数据又是按需计算

  • 介绍序列如何通过枚举任意集合来泛化它们,这代表了拉数据变换模式

  • 进一步考虑使用生成序列进行数据钻取的另一种模式

  • 通过实际探索序列的使用如何影响代码性能来总结

基本序列变换

让我们回顾一下第一章中样本问题的函数式解决方案,开始函数式思考。它代表了寻找集合的给定属性的常见函数式模式,如下所示:

  • 从表示 1000 个连续单个数字字符的给定字符串字面量中,制作一个由原始集合中仅五个连续单个数字字符的块表示的集合集合。每个块取一个五字符宽的模板的内字符,首先与字符串字面量的左侧边界对齐。然后模板向右移动一个字符,提取下一个子集合。这种将模板向右滑动的过程一直持续到模板和字面量的右侧边界对齐。确切地说,主序列由 996 个这样的五字符子序列组成。

  • 注意,此时原本寻求的最大五个连续数字的乘积属性被替换为序列元素的类似属性,每个元素代表一个候选组,所寻求的属性源于该组。值得注意的是,为了解决原始问题,必须考虑这个次级序列的所有元素(其他模式可能在这方面有所不同,例如,寻找具有给定属性的任何序列元素)。

  • 对替代序列进行完整扫描,寻找所寻求属性的极大值,即代表替代原始字符串字面量的外序列元素的内部序列组成部分的乘积。

那些关注细节的人可能已经注意到了先前解决方案方法与MapReduce(en.wikipedia.org/wiki/MapReduce)模式的相似性,但现在还没有可能的map阶段的分区和并行化。这种相似性并非偶然。在为企业业务线LOB)应用的大大小小的 F# ETL(en.wikipedia.org/wiki/Extract,_transform,_load)任务实施之后,我可以得出结论,F#核心库中涵盖枚举序列基本操作的这部分,即Microsoft.FSharp.Collections(msdn.microsoft.com/en-us/library/ee353635.aspx)命名空间下的Collections.seq库模块,已经提炼了数据序列处理的典型函数式模式。任何有效的 F#开发者都应该能够将所需的数据转换解决方案表示为Collections.seq库中这些库函数的组合。

根据我的个人经验,当将这些 70 个库函数(针对 F# 4.0 版本)视为按函数名称字母顺序排列的列表时,很难理解。如果不区分它们的共性和差异,很难记住这个或那个函数的确切功能。如果我们开始看到每个函数都实现了某种特定的数据转换模式,这种认识可能会得到加强。这些模式源于将函数式编程应用于数据处理多年的积累经验,并被 F#设计者选定为包含到核心库中的函数集合。

我认为,通过从数据处理模式关系的角度观察Collection.seq库的组成部分,可以区分以下函数组:

  • 聚合函数:这些函数遍历序列的整个范围,返回基于序列元素计算出的单个值。

  • 生成器:这些函数能够“凭空”生成序列,或者更确切地说,生成特殊类型的序列(例如空类型序列)和由元素与其序列顺序号之间的定量关系或仅由递归定义的函数(基于前一个元素生成下一个元素)定义的序列。

  • 包装器和类型转换器:这些函数要么将整个序列包装成有用的属性(缓存是包装的一个好例子),要么将序列转换为其他集合类型(列表或数组)。

  • 应用函数:这些函数只是遍历序列,为了副作用(例如,将序列元素作为字符串打印出来)而将给定的计算应用于每个元素。

  • 重组器:这些函数以类型一致的方式洗牌序列或提取其元素;换句话说,对于仅处理 seq<'T>'T 对象的 'T 类型序列。例如,通过跳过原始序列的前 100 个元素来创建一个新的序列。

  • 过滤器:这些函数关注于选择符合任意条件(的)元素。例如,尝试找到序列中第一个使给定谓词函数返回 true 的元素。

  • 映射器:这些函数通过生成转换后的序列(例如,一个将两个输入序列组合成一个结果序列的压缩函数,每个元素都是一个元组,它结合了来自两个输入序列的相同顺序号的元素)来改变原始序列(的)形状和/或类型。

使用这种分类方法,我将库函数按照以下模式集进行划分。在每个模式下,列出所有相关的库函数及其签名。我鼓励您探索这些签名,以发现导致每个分组形成的共性。

对于那些渴望深入了解的读者,本书附带代码的 Ch6_1.fsx 脚本中提供了额外信息,其中通过简短的代码示例说明了每个库函数的使用。

聚合模式

average : seq<^T> -> ^T (requires member (+) and member    DivideByInt and member get_Zero)averageBy : ('T -> ^U) -> seq<'T> -> ^U (requires ^U with static    member (+) and ^U with static member DivideByInt and ^U with    static member Zero) 
fold : ('State -> 'T -> 'State) -> 'State -> seq<'T> -> 'State 
length : seq<'T> -> int 
sum : seq<^T> -> ^T (requires member (+) and member get_Zero) 
sumBy : ('T -> ^U) -> seq<'T> -> ^U (requires ^U with static  member (+) and ^U with static member Zero) 
max : seq<'T> -> 'T (requires comparison) 
maxBy : ('T -> 'U) -> seq<'T> -> 'T (requires comparison) 
min : seq<'T> -> 'T (requires comparison) 
minBy : ('T -> 'U) -> seq<'T> -> 'T (requires comparison) 
isEmpty : seq<'T> -> bool 
reduce : ('T -> 'T -> 'T) -> seq<'T> -> 'T 
exactlyOne : seq<'T> -> 'T 
compareWith : ('T -> 'T -> int) -> seq<'T> -> seq<'T> -> int 

生成模式

empty : seq<'T>
init : int -> (int -> 'T) -> seq<'T>
initInfinite : (int -> 'T) -> seq<'T>
singleton : 'T -> seq<'T>
unfold : ('State -> 'T * 'State option) -> 'State -> seq<'T>

包装和类型转换模式

cast : IEnumerable -> seq<'T>
cache : seq<'T> -> seq<'T>
delay : (unit -> seq<'T>) -> seq<'T>
readonly : seq<'T> -> seq<'T>
toArray : seq<'T> -> 'T []
toList : seq<'T> -> 'T
list ofArray : 'T array -> seq<'T>
ofList : 'T list -> seq<'T>

应用模式

iter : ('T -> unit) -> seq<'T> -> unit
iter2 : ('T1 -> 'T2 -> unit) -> seq<'T1> -> seq<'T2> -> unit
iteri : (int -> 'T -> unit) -> seq<'T> -> unit

重组模式

append : seq<'T> -> seq<'T> -> seq<'T>
collect : ('T -> 'Collection) -> seq<'T> -> seq<'U>
concat : seq<'Collection> -> seq<'T>
head : seq<'T> -> 'T
last : seq<'T> -> 'T
nth : int -> seq<'T> -> 'T
skip : int -> seq<'T> -> seq<'T>
take : int -> seq<'T> -> seq<'T>
sort : seq<'T> -> seq<'T>
sortBy : ('T -> 'Key) -> seq<'T> -> seq<'T>
truncate : int -> seq<'T> -> seq<'T>
distinct : seq<'T> -> seq<'T>
distinctBy : ('T -> 'Key) -> seq<'T> -> seq<'T>

过滤模式

choose : ('T -> 'U option) -> seq<'T> -> seq<'U>
exists : ('T -> bool) -> seq<'T> -> bool
exists2 : ('T1 -> 'T2 -> bool) -> seq<'T1> -> seq<'T2> -> bool
filter : ('T -> bool) -> seq<'T> -> seq<'T>
find : ('T -> bool) -> seq<'T> -> 'T
findIndex : ('T -> bool) -> seq<'T> -> int
forall : ('T -> bool) -> seq<'T> -> bool
forall2 : ('T1 -> 'T2 -> bool) -> seq<'T1> -> seq<'T2> -> bool
pick : ('T -> 'U option) -> seq<'T> -> 'U
skipWhile : ('T -> bool) -> seq<'T> -> seq<'T>
takeWhile : ('T -> bool) -> seq<'T> -> seq<'T>
tryFind : ('T -> bool) -> seq<'T> -> 'T option
tryFindIndex : ('T -> bool) -> seq<'T> -> int
option tryPick : ('T -> 'U option) -> seq<'T> -> 'U option
where : ('T -> bool) -> seq<'T> -> seq<'T>

映射模式

countBy : ('T -> 'Key) -> seq<'T> -> seq<'Key * int>
groupBy : ('T -> 'Key) -> seq<'T> -> seq<'Key * seq<'T>>
pairwise : seq<'T> -> seq<'T * 'T>
map : ('T -> 'U) -> seq<'T> -> seq<'U>
map2 : ('T1 -> 'T2 -> 'U) -> seq<'T1> -> seq<'T2> -> seq<'U>
mapi : (int -> 'T -> 'U) -> seq<'T> -> seq<'U>
scan : ('State -> 'T -> 'State) -> 'State -> seq<'T> -> seq<'State> windowed : int -> seq<'T> -> seq<'T []>
zip : seq<'T1> -> seq<'T2> -> seq<'T1 * 'T2>
zip3 : seq<'T1> -> seq<'T2> -> seq<'T3> -> seq<'T1 * 'T2 * 'T3>

序列:数据和计算的二元性

F# 序列之所以如此灵活和通用,是因为其双重特性。作为一个强类型泛型数据集合,它通过 System.Collections.Generic 命名空间中的两个典型 .NET 接口暴露包含的数据,即 IEnumerable<T> (msdn.microsoft.com/en-us/library/9eekhta0(v=vs.110).aspx ) 和 IEnumerator<T> (msdn.microsoft.com/en-us/library/78dfe2yb(v=vs.110).aspx )。

这些接口体现了经典的数据拉取协议,其中数据消费者主动从生产者那里拉取数据。实际上,F# 中 seq<'T> 的类型被定义为以下缩写:

type seq<'T> = System.Collections.Generic.IEnumerable<'T> 

上一行代码在实践中意味着每个 F# 序列都是一个数据集合,可以通过获取一个允许您从序列的头部向尾部遍历的 枚举器 来遍历,从而获得其元素的值。枚举器本身可以通过 IEnumerable<'T> 接口的 GetEnumerator() 方法获得。

使用枚举器,它反过来实现了IEnumerator<'T>接口,可以通过构成此接口的成员对序列进行遍历:Current属性,它获取枚举器当前位置的序列元素的值,以及MoveNext()方法,它将枚举器的位置推进到序列的下一个元素。

很无聊,对吧?好吧,当应用于如 F# 列表这样的实例化数据集合时,其中所有元素都存在于物理内存空间中,可能确实很无聊。然而,前面的方案中没有任何内容坚持要求元素实例化!想象IEnumerator<'T>通过计算来返回'T的新构造值,以响应获取Current属性,对于MoveNext(),它只是推进序列的可想象当前位置标记。整个安排在元素占用的内存空间方面是非实例化的,因为不需要保留除一个实例化的Current元素之外的内容,对吧?有了这个,你刚刚重新发现了 F# 序列的内部工作原理!

序列作为懒数据收集

F# 序列不会在内存中积极实例化数据元素。这个特性与数据拉取协议非常吻合。也就是说,除非序列枚举器在一系列MoveNext()方法调用后到达序列中的位置,并且通过获取枚举器的Current属性来要求元素值,否则当前序列元素是不需要的。

然而,为了真正掌握 F# 序列,了解其细微差别非常重要。特别是,重要的是要意识到序列元素是否被实例化。如果一个序列是在枚举器的请求下计算的,并且没有从实例化的集合(如列表或数组)或未缓存的内容转换,那么通常没有后端内存来持久化序列元素值。相反,如果一个序列是通过库函数(例如,Seq.ofList)从一个具体集合生成的,那么在派生集合的整个生命周期中,至少必须存在原始列表的一个实例,因为这个列表可能是完全任意的,并且没有方法可以从头开始以类似重新枚举多次的方式重新创建它,如果重新枚举成本低且性能考虑不要求缓存的话。

序列作为计算

正如我刚才提到的,一个序列可以是一个具体数据集合上的枚举,枚举器在集合的一侧实现。然而,更有趣的情况是,序列具有一个枚举器,该枚举器在遍历拉取请求时程序性地生成序列元素。这些可能是不同的、语法糖化的形式,如序列推导式、序列计算表达式,或者代表章节开头考虑的生成器模式的标准库函数。作为最后的手段,可以通过实现一些必需的接口以完全去糖化的方式使序列变得活跃。这种方法最为繁琐且容易出错;然而,与其他方法相比,它提供了前所未有的灵活性。在大多数开发情况下,自定义序列枚举器实现是不必要的;然而,可能存在一些情况下,没有其他替代方案,只能采用自定义实现。这个主题将是我的下一个主题。

序列作为枚举器接口包装器

虽然实现自定义序列是一项繁琐的任务,但并非高不可攀。我将带你了解整个过程,让你理解它。无论自定义序列多么简单或复杂,实现过程都是相同的。

首先,你认为什么定义了序列的行为?显然,它不是用于序列遍历的语法结构,也不论它是否是语法糖化的。所有实现细节都被任何序列背后的实体(以及更广泛地,任何.NET 集合背后的实体)抽象化:枚举器。枚举器是一个必须实现之前提到的强类型接口IEnumerator<'T>的类(msdn.microsoft.com/en-us/library/78dfe2yb(v=vs.110).aspx)的System.Collections.Generic命名空间。反过来,IEnumerator<'T>从两个其他接口继承:System.IDisposable接口和System.Collections命名空间中的传统无类型IEnumerator接口。(注意强类型System.Collections.Generic和无类型System.Collections命名空间之间的区别)。IEnumerator<'T>重写了Current属性,并继承了IEnumerator接口的MoveNext()Reset()方法。由于涉及组件之间的关系相当复杂,我在以下图中提供了一个组件关系图,以帮助理解:

序列作为枚举器接口包装器

构成 F#序列实现的组件之间的关系

考虑到这些复杂性,任何自定义 F#序列的实现计划如下:

  1. 为序列提供一个自定义的Enumerator类,该类实现了System.Collections.Generic.IEnumerator<'T>System.Collections.IEnumeratorSystem.IDisposable接口。对于第一个接口,只需实现Current属性的覆盖,其余的实现进入非泛型IEnumerator

  2. 提供一个类似于泛型.NET 集合的GetEnumerator()方法的工厂函数,这些方法具有unit -> System.Collections.Generic.IEnumerator<'T>签名。此函数通过将其自己的参数直接传递给构造函数来构建请求的枚举器实例,然后将构建的实例向上转换为System.Collections.Generic.IEnumerator<'T>,并将结果作为之前列出的签名的一个函数返回。

  3. 提供另一个工厂函数,这次是从第 2 步中构建的函数构建所需的序列。

由于这仍然可能听起来有点复杂,让我们快速浏览一下。我想让我们实现最简单的事情:一个强类型空序列,这是一个没有元素的序列,当其枚举器没有可以枚举的内容时。

同时,显然,它必须是一个类似于.NET 库中任何其他原生序列的正常序列,或者是一个使用 F#语言设施或核心库构建的糖化序列。让我们这样做。

第 1 步 - 自定义枚举器实现

空序列的行为相当直接:Current属性的类型化和无类型版本永远不会有机会工作,因为尝试枚举空序列必须立即终止;MoveNext()总是返回false,表示已经达到序列的末尾。用 F#代码表达,这些考虑在以下片段(Ch6_2.fsx)中显示:

type private DummyEnumerate<'T>() = 
  interface System.Collections.Generic.IEnumerator<'T> with 
    member x.Current = Unchecked.defaultof<'T> 

  interface System.Collections.IEnumerator with  
    member x.Current = box Unchecked.defaultof<'T> 
    member x.MoveNext() = false 
    member x.Reset() = () 

  interface System.IDisposable with  
    member x.Dispose() = () 

如前所述,System.Collections.Generic.IEnumerator<'T>覆盖了Current,并继承自System.Collections.IEnumeratorMoveNext()Reset()。两个Current属性都使用类型化的默认值;无类型枚举器的Current属性根据规范将此默认值装箱。第 1 步现在已完成。

第 2 步 - 自定义枚举器工厂

第 2 步相当简单,特别是在我们这种情况下,实现的序列在构建时没有需要传达给计数器的特定信息,如下面的代码(Ch6_2.fsx)所示:

let makeDummyEnumerator<'T>() = 
  fun() -> (new DummyEnumerate<'T>() 
    :> System.Collections.Generic.IEnumerator<'T>) 

第 2 步也已完成。

第 3 步 - 自定义序列工厂

这个实现很简单,多亏了 F#的伟大对象表达式msdn.microsoft.com/en-us/library/dd233237.aspx)功能,如下所示(Ch6_2.fsx):

let makeSeq enumerator = 
{ 
  new System.Collections.Generic.IEnumerable<_> with 
    member x.GetEnumerator() = enumerator() 
  interface System.Collections.IEnumerable with 
    member x.GetEnumerator() = 
    (enumerator() :> System.Collections.IEnumerator) 
} 

这里开始了;很容易看出这个特定的部分在任何方面都不依赖于生成的序列,并且是作为 Helpers 库成员的好候选。实现已经完成。

现在是进行测试的最佳时机,以检查一切是否真的正常,并且我没有遗漏任何东西。简洁测试的结果反映在以下屏幕截图上:

步骤 3 - 自定义序列工厂

测试实现的空序列

空序列使用以下代码创建:

let ss = makeSeq (makeDummyEnumerator<int>()) 

然后按照以下方式进行一些检查:

  • ss |> Seq.isEmpty,正如预期的那样,返回 true

  • ss |> Seq.length,正如预期的那样,等于 0

  • 使用 ss |> Seq.skip 10 跳过一些元素的操作以预期的诊断失败

在我们切换到下一个主题之前,我想重申这一点:使用裸 .NET 接口去糖化的自定义序列实现并不有趣。它的好处是,在大多数情况下,你根本不需要下降到这个层面。语法糖化的语言构造和核心库函数会完成同样的工作。然而,偶尔,你需要做一些特别的事情,比如计算你的代码遍历序列的次数,这项技术将为你提供服务。

不定长度的序列作为设计模式

数据转换的传统工程视角是它们在内存中物化的有限集合上发生,因此允许使用 Seq.length 对这些集合进行枚举,从而得到元素的数量。然而,F# 序列(以及 .NET IEnumerable<T> 本身)提供了以下泛化:在某些情况下,一个更以数学为中心的视角可能更有用,这表明将序列视为可计数的但不一定是有限的。

一个细致的读者可能会立即提出异议,即当应用于实际计算时,可计数的实体必然是有限的,因为最终它受底层物理硬件的限制,这在边界值中体现出来,例如:

System.Int32.MaxValue = 2147483647 
System.Int64.MaxValue = 9223372036854775807L 

然而,我会通过以下说法反对这种异议:这种简单的考虑方式在任何方面都不会限制可能产生的 F# 序列的长度。作为证明,让我们在不使用任何 F# 语法糖的情况下在低级别实现 repeater,或者序列,当被赋予任何类型的元素时,返回给定元素的无限重复。

我将从以下代码(Ch6_3.fsx)中展示一个简单的 IEnumerator<'T> 实现开始:

type private Repeater<'T>(repeated) = 
  let _repeated = repeated 
    interface System.Collections.Generic.IEnumerator<'T> with 
    member x.Current = _repeated 

  interface System.Collections.IEnumerator with  
    member x.Current = box _repeated 
    member x.MoveNext() = true 
    member x.Reset() = () 

  interface System.IDisposable with 
    member x.Dispose() = () 

前面的代码片段相当直接。Repeater<'T> 类型定义了一个类,该类通过单个默认构造函数获取要重复的元素作为 repeated,并将其持久化在类的实例中作为 _repeated

然后,作为实现 System.Collections.Generic.IEnumerator<'T> 协议的一部分,该接口通过单个属性 Current 返回持久化的 _repeated 值。

然后,非泛型的 System.Collections.IEnumerator 接口的实现紧随其后,包括其三个合同方法。这就是期望的序列行为被定义的地方:Current 无类型属性也返回一个持久化的 _repeated 值,但这次,它根据合同进行装箱,产生 obj。正如能量兔所说,MoveNext() 方法应该持续进行,持续进行……以至于它总是返回 true,这意味着无论什么情况下,下一个元素都是可用的。Reset() 旧方法只是一个占位符。

最后,一个符合 IEnumerator<'T> 合同要求的 System.IDisposable 的虚假实现完成了实现。

现在,为了方便使用,我添加了一个薄薄的包装器,将实现的 Repeater<'T> 接口向上转换为显式的 System.Collections.Generic.IEnumerator<'T>,如下面的代码所示 (Ch6_3.fsx):

let repeat<'T>(e) = 
  (new Repeater<'T>(e) 
  :> System.Collections.Generic.IEnumerator<'T>) 

最后,一个通用的 makeSeq 适配函数通过实现如以下代码所示的通用和非通用版本的 IEnumerable,将任何 IEnumerator<'T> 转换为相应的 seq<'T> 序列 (Ch6_3.fsx):

let makeSeq enumerator = 
{ 
  new System.Collections.Generic.IEnumerable<'U> with 
    member x.GetEnumerator() = enumerator 
  interface System.Collections.IEnumerable with 
    member x.GetEnumerator() = 
    (enumerator :> System.Collections.IEnumerator) 
} 

在这里,enumerator 参数为构成任意 F# seqIEnumerable 的两种实现提供了底层的 IEnumerator<'T>

是时候进行实地测试了!在 FSI 中执行新创建的 makeSeq 函数,使用代表 F# 中的 repeat '.'repeat 42repeat "Hooray!" 枚举器的三个不同参数,可以得到相应类型的无限长序列,如下面的屏幕截图所示:

不定长序列作为设计模式

生成不定长序列

然而,我们如何证明这些序列确实是不定长的呢?讽刺的是,只有通过计数:如果对于任何任意大的数字,序列返回那么多元素,那么这就是证明这些序列是不定长的证据。不幸的是,这正是我们遇到了已经提到的计数问题:计数可能被底层硬件有效限制。

但等等;.NET 提供了一种数值类型,在所有实际应用中,它代表了一个任意大的可计数 System.Numerics.BigInteger (msdn.microsoft.com/en-us/library/system.numerics.biginteger(v=vs.110).aspx)。所以,基于这种类型进行计数将是非常好的。

假设你并不害怕挑战,实现一个不限于标准 int 的泛型计数技术将是一个很好的练习。对于 F# 来说,这个任务并不复杂。我建议以下惯用方法 (Ch6_3.fsx):

let inline traverse n s = 
  let counter = 
    (Seq.zip 
    (seq { LanguagePrimitives.GenericOne..n }) s) 
    .GetEnumerator() 
  let i = ref LanguagePrimitives.GenericOne 
  let mutable last = Unchecked.defaultof<_> 
  while counter.MoveNext() do 
    if !i = n then last <- counter.Current 
      i := !i + LanguagePrimitives.GenericOne 
  last 

为了允许编译器构建与用于计数的参数 n 对应的类型对齐的编译代码,traverse 计数函数被内联。traversen 参数表示预期生成的元素数量。traverse 的第二个参数 s 代表一个通用的无限序列生成器。makeSeq 与给定的通用重复元素一起,是 traverse 的完美第二个参数。

序列计数枚举器可以优雅地表示为 Seq.zip,将 makeSeq 的可能无限长序列与具有恰好预期任意大(在底层类型允许的范围内)元素数量的有限长序列组合在一起。由于组合在到达较短序列的末尾时停止,counter 值正好代表从组合表达式结果中获得的所需枚举器。

最后,我遍历获取到的枚举器,直到它停止产生元素,同时记录最后一个遍历的元素。这个 last 元素显然是最后一个元素编号和未绑定序列元素的元组,它作为任意长度证据被返回。以下截图展示了如何通过字段测试。第一个测试显示了 traverseBigInteger 计数器一起工作的情况;第二个测试仅说明了如何生成比 System.Int32.MaxValue 长出 10 个元素的序列:

不定长序列作为设计模式

检查无限序列的工作原理

另一个有趣的实验可能是生成一个长度超过 System.Int64.MaxValue 的序列,这个实验就留给你作为练习了。我唯一关心的是完成这个实验可能需要的时间。我的粗略估计显示,以每秒遍历 1,000,000 个元素的速度,至少需要 29 个世纪才能完成;因此,可能需要对方法和实现进行一些重大的修改和优化。

生成 F# 序列

正如你最近有机会注意到的,使用去糖化的 .NET 方式生成序列有很多组成部分,坦白说,并不是最佳的全能用例。幸运的是,F# 通过语法糖以及库函数提供了足够的支持,使得生成有限和无限长度的序列变得轻而易举。让我们来看看它们。

序列推导式

序列推导式允许你将序列表示为一种特殊类型的表达式,即 序列表达式 (msdn.microsoft.com/en-us/library/dd233209.aspx )。或者反过来,当序列表达式被评估时,它会产生一个序列。

序列推导式可以有多种形式。我们将讨论一些典型的形式。

范围

这些是最简单的推导式形式,从范围生成序列。观察到的范围不仅限于数字;任何支持 'get_One' 操作符的类型都行,如下所示(Ch6_4.fsx):

// odd int64 between 1 and 1000 
seq { 1L .. 2L .. 1000L } 
// val it : seq<int64> = seq [1L; 3L; 5L; 7L; ...] 

// range not necessarily must be numeric! 
seq { 'A' .. 'Z' } 
// val it : seq<char> = seq ['A'; 'B'; 'C'; 'D'; ...] 

映射

这些表达式通过允许将一个或多个枚举投影到另一种类型中,从而泛化了范围。此外,请注意,枚举定义可以非常灵活:从简单的范围到嵌套枚举,再到如这里所示(Ch6_4.fsx)的另一个序列:

// even int from 2 to 1000 
seq { for i in 1..2..999 -> ((+) 1 i) } 
// val it : seq<int> = seq [2; 4; 6; 8; ...] 

// nested enumerations 
seq { for i in 1..10 do for j in 1..10 -> if i = j then 1 else 0} 
// val it : seq<int> = seq [1; 0; 0; 0; ...] 

// cartesian product tuple projection 
seq { for i in 1..10 do for j in 1..10 -> (i,j) } 
// val it : seq<int * int> = seq [(1, 1); (1, 2); (1, 3); ...] 

// cartesian product nested enumerations 
seq { for i in seq {'a'..'b'} do for j in 1..2 -> (i,j) } 
val it : seq<char * int> = seq [('a', 1); ('a', 2); ('b', 1); ('b', 2)] 

随意序列表达式

所有序列推导式都代表了与极其强大的 计算表达式 机制相关的 F# 语法糖(msdn.microsoft.com/en-us/library/dd233182.aspx),特别是,为令人讨厌的 M-word things 提供了方便的语法,也称为 单子计算表达式代表了 F# 中极其强大的顺序和组合计算的模式。它们可以是自定义构建的;然而,F# 还提供了一些内置的计算表达式:除了 序列表达式 之外,还有 异步工作流查询表达式。我将在本书的后续章节中介绍内置的计算表达式。

随意序列表达式只是由 seq { and } 符号包裹的计算,尽管与前面提到的 范围映射 相比,计算可以是几乎任何内容。序列表达式符号内的两个结构在此处扮演着特殊角色,如下所示:

  • yield <expression> 使表达式值成为最终序列的下一个元素

  • yield! <sequence expression>(读作 yield-bang)将序列表达式操作数追加到最终序列的末尾

yield! 的存在将任意序列表达式转变为极其强大的数据转换。特别是,由于 seq {...} 仍然是一个表达式,可以用作递归函数的返回值,这种模式允许你极其简洁和优雅地实现有限和无限长度的序列,特别是,可以轻松地将任何有限序列转换为无限循环序列,这对于通过元素标记对其他序列进行分区通常非常方便。

言归正传;让我们看看一些代码!

我从一个示例开始,演示如何将整个模式匹配结构嵌套到序列表达式中,以检测序列何时应该停止。以下代码片段生成从任何非负整数到零的递减整数序列(Ch6_4.fsx):

let rec descend top =  
  seq { 
    match top with 
      | _ when top < 0 -> () 
      | _ -> 
      yield top 
      yield! descend (top - 1) 
  } 

// descend 3;; 
// val it : seq<int> = seq [3; 2; 1; 0] 
// descend -3;; 
// val it : seq<int> = seq [] 

注意,通过返回 unit 而不是产生下一个元素,实现了生成停止。

到目前为止,一切顺利。现在让我们生成一个交替字符串的无尽序列,如下所示(Ch6_4.fsx):

let rec fizzbuzz = seq {  
  yield "Fizz" 
  yield "Buzz" 
  yield! fizzbuzz 
} 
in fizzbuzz 

// val it : seq<string> = seq ["Fizz"; "Buzz"; "Fizz"; "Buzz";  ...]

为了总结这个主题,看看如何优雅地实现任何任意序列的循环化,如下所示(Ch6_4.fsx):

let rec circular ss = 
  seq { yield! ss; yield! circular ss } 

circular (seq { yield '+'; yield '-' }) 
// val it : seq<char> = seq ['+'; '-'; '+'; '-'; ...] 

需要上面定义中要求的两个感叹号来安排确实的循环化。

生成序列的库函数

现在,我转向 F#核心库提供的序列生成支持。

Seq.init

这种方法适用于长度预定义的序列,因为长度直接位于函数签名中。这是一个相当简单的函数,它假设但不指定当前元素数的投影。以下是一个示例,展示了以字符串形式在隐式(en.wikipedia.org/wiki/Tacit_programming)方式执行的序列号投影(Ch6_4.fsx):

Seq.init 10 (sprintf "%s%d""I'm element #") 
//val it : seq<string> = 
//  seq 
//    ["I'm element #0"; "I'm element #1"; "I'm element #2"; 
//    "I'm element #3"; ...] 

Seq.initInfinite

这个函数与上一个非常相似,但它确实缺少第一个参数,如这里所示(Ch6_4.fsx):

Seq.initInfinite (sprintf "%s%d""I'm element #") 
//val it : seq<string> = 
//  seq 
//    ["I'm element #0"; "I'm element #1"; "I'm element #2"; 
//    "I'm element #3"; ...] 

几乎没有什么变化,但底层抽象比有限变体更强大。不幸的是,抽象的力量很容易受到实现限制的伤害,精明的 F#程序员可能会猜到:它只有硬件架构允许的那么多元素序列号。这可以通过以下简单的技巧轻松检查(Ch6_4.fsx):

Seq.initInfinite (fun _ -> ()) 
|> Seq.skip (System.Int32.MaxValue) 
//> 
//val it : seq<unit> = 
//  Error: Enumeration based on System.Int32 exceeded System.Int32.MaxValue. 

哎呀,这真让人痛苦!

Seq.unfold

Seq.unfold库函数,作为序列生成问题的终结,是我最喜欢的。它不像其他函数那样需要处理序列号,其投影函数展开当前元素与下一个元素之间的递归关系。它还通过在返回None时将投影结果指定为option来非常巧妙地解决了停止问题。让我们通过以下示例来看看这个库函数的实际应用,这里使用了博客作者和学术界常用的斐波那契数列(en.wikipedia.org/wiki/Fibonacci_number),如这里所示(Ch6_4.fsx):

// Oh NO! Not Fibonacci again! 
let fibnums = Seq.unfold (fun (current, next) -> 
  Some(current, (next, current+next)))(1,1) 

fibnums |> Seq.take 10 |> Seq.toList 
// val it : int list = [1; 1; 2; 3; 5; 8; 13; 21; 34; 55] 

经过几年的 F#使用,我仍然对它所允许的意图清晰感到兴奋!投影函数字面上就解释了自己,所以我没有什么要补充的。

序列和代码性能

毫无疑问,序列是函数式程序员工具箱中极其强大的成员。然而,它们并非没有可能严重影响性能的“陷阱”。最好了解并避免它们。以下是一些例子:

  • 不幸的实例化,可能是不必要的/过早的元素实例化,或者相反,缺少元素实例化。

  • 数据惰性,与当前元素值不保留一起,在算法需要多次遍历或计算元素成本高昂的情况下,可能会严重损害性能。开发者应该能够通过应用诸如缓存和/或记忆化等模式来补偿这些有害因素。

  • 通常,在组合数据处理管道时,开发者可能会不小心使用一个意外需要他们枚举整个序列的库函数。这并不一定是一件坏事,但应该谨慎使用。

  • 如果前面提到的违规行为只是伤害了有限长度序列的性能,那么对无限长度序列的第一次这种疏忽就会简单地将它杀死!在处理无限长度序列时,务必小心谨慎!

序列缓存

F#语言创建者足够友好,提供了一个现成的缓存工具,即Seq.cache库函数。它应该在懒加载不是杀手锏,但元素生成成本不低且重复枚举确实需要的情况下使用。让我演示一下使用缓存是多么简单。

首先,我需要一个枚举器消耗的指示器。对于那些已经与序列内部结构有经验的人来说,这并不复杂。让我们稍微修改一下我们古老的makeSeq函数,如下所示(Ch6_5.fsx):

let makeSeq f = 
{ 
  new System.Collections.Generic.IEnumerable<'U> with 
    member x.GetEnumerator() = printfn "Fresh enumerator given"; f() 
  interface System.Collections.IEnumerable with 
    member x.GetEnumerator() = 
    (f() :> System.Collections.IEnumerator) 
} 

现在,我们准备看到缓存是如何工作的,如下所示(Ch6_5.fsx):

//caching 
let nums = (seq {1..100}).GetEnumerator |> makeSeq 
// non-cached - double enumeration 
((nums |> Seq.sum),(nums |> Seq.length)) 
//Fresh enumerator given 
//Fresh enumerator given 
//val it : int * int = (5050, 100) 

let cache = nums |> Seq.cache 
// cached - single enumeration 
((cache |> Seq.sum),(cache |> Seq.length)) 
//Fresh enumerator given 
//val it : int * int = (5050, 100) 
// just another time - no enumerations at all 
((cache |> Seq.sum),(cache |> Seq.length)) 
//val it : int * int = (5050, 100) 

首先,在没有缓存的情况下,Seq.sumSeq.length各自强制执行了独立的序列遍历,这一点由两个枚举器的存在得到了证实。

然后,在用Seq.cache包装工作序列之后,我重复使用包装序列进行计算。不出所料,我们注意到只有一个枚举器警告来填充缓存;第二次遍历在通过缓存时没有留下任何痕迹。

当然,只需重新进行计算。现在,所有数据都来自缓存,根本不会对原始序列进行任何遍历。

序列变换的融合

我想通过展示一个被称为融合的模式来结束这一章。在概念上并不困难:想象一下,你有一个函数的组合,它共同转换数据序列。在某个时刻,你的实现需要多次遍历序列。然而,编译器原则上,或者人类在实践中,可能会优化转换,因此多次遍历现在融合成了单一的一次。

让我们在实践中进行融合,重用我们的makeSeq实现作为获取枚举器的指示符,如下面的代码所示(Ch6_5.fsx):

let series = (seq {1..100}).GetEnumerator |> makeSeq 
let average dd = (Seq.sum dd) / (Seq.length dd) 
average series 
//Fresh enumerator given 
//Fresh enumerator given 
//val it : int = 50 

前面的天真实现中的average遍历了序列两次,枚举警告提供了证据。

然而,将average的实现重写为averageFused(不那么天真),最终导致这些遍历的融合,如下面的代码所示(Ch6_5.fsx):

let averageFused dd = 
  dd 
  |> Seq.fold (fun acc x -> (fst acc + x, snd acc + 1)) (0,0) 
  |> fun x -> fst x / snd x 
averageFused series 
//Fresh enumerator given 
//val it : int = 50 

单一的枚举警告完全证实了我的说法。

摘要

本章涵盖了 F# 数据处理的基础之一,即序列。现有的 F# 核心序列库允许你应用所有典型的函数式数据处理模式。

当你迫切想要实现另一个用于序列处理的定制函数时,你需要做的第一件事是确定它属于哪个已知的模式组,然后仔细检查两次这个函数是否真的尚未实现,或者是否可以简单地由剩余的库函数组合而成。回想一下第一章中的最小化移动部件而非隐藏它们部分,开始函数式思考。核心库是这类高质量部件的最小化集合,因此坚持使用它们最终会积极影响代码的质量和可读性。

你已经获得了大量关于 F# 序列内部工作原理的细节,现在应该能够通过多种方式生成序列,在适当的时候处理概念上干净的、不定长度的序列。

最后,我提供了一些序列性能提示和考虑因素,并伴随一些实用的优化编码,使你能够为进一步掌握打下良好的基础。

在下一章中,我将重新探讨函数的主题,因为你现在应该准备好在已经掌握的技能之上学习一些高级技术。

第七章. 高级技术:函数回顾

本章基于我们在前几章中观察到的函数、模式匹配和数据序列的基本 F#惯用用法。在这里,我转向数据转换的高级模式,换句话说,就是函数在数据上的重复使用。本章的目标是使你熟悉主要模式,其中结合的基本 F#惯用用法协同工作。本章涵盖了以下主题:

  • 高级递归模式,包括尾递归和函数与序列的相互递归

  • 折叠作为聚合的通用模式

  • 记忆化惰性求值作为应用于数据的即时原则的补充模式

  • 扩展函数交互核心调用-返回原则的延续传递模式

  • 通过泛化匹配活动模式的高级模式匹配

这些协同作用通常在干净、简洁、高效的 F#代码中体现出来。

深入探讨递归

我已经在第三章中简要介绍了递归,展示了rec修饰符如何改变函数定义的作用域。这种明确的指示允许函数在函数体完全定义之前引用自身。现在我将向你展示递归可以以正确或错误的方式使用,这样你就可以学会遵循正确的递归模式。

尾递归

指出函数(递归或非递归)在当前实现中消耗一定量的资源用于局部值、参数值等,这并不是什么新发现。非递归函数在调用时消耗这些资源,并在返回结果时释放它们。到目前为止,一切顺利。

但当函数调用自身时会发生什么?每个嵌套调用都可以存储局部资源,在完成这个特定的递归级别时释放。因此,深度递归可能会暂时增加资源消耗。运行时函数调用和返回语义的实现(包括 F#的实现)通常使用有限体积的应用程序堆栈空间来暂时存储局部资源。如果一个递归函数通过深度嵌套自我调用而不会展开堆栈,这个预留的体积可能会耗尽,以臭名昭著的.NET StackOverflowException结束嵌套的自我调用链。即使没有堆栈溢出,对堆栈空间的贪婪实现也会对资源和对性能造成压力,因为分配和释放堆栈帧以保持函数调用局部上下文需要时间。

一个经典的(尽管严重磨损的)例子是组织不良的递归,目的是计算阶乘函数(en.wikipedia.org/wiki/Factorial),如下所示(Ch7_1.fsx):

let rec ``naive factorial`` = function 
| n when n = 0I -> 1I 
| _ as n -> n * ``naive factorial`` (n - 1I) 

(我退回到 BigInteger 类型,因为为了引起堆栈溢出,参数应该在一个范围内,这样阶乘函数的结果可能很容易由数千位组成)。现在,借助 FSI,让我们看看 cs ``naive factorial`` 1000 cs ``naive factorial`` 10000 的值会是什么。以下截图显示第一次调用有一个相当高的数值,但第二次调用正如预测的那样,以 StackOverflowException 失败:

尾递归

非尾递归函数调用的失败

这里发生的情况是,这个实现不断地调用 cs ``naive factorial`` ,随着参数值递减,堆栈帧堆积,直到达到 cs ``naive factorial`` 0 。然后,它开始展开堆栈,执行延迟乘法,最终堆栈为空,并得到所需的功能值。很容易注意到消耗的堆栈帧的数量与函数参数值相匹配。有足够的堆栈空间来容纳 1000 个帧,但 10 倍于此就会压倒应用程序。

这里能做什么呢?我们可能意识到所有部分乘法都可以在递归展开时立即完成,并且中间结果可以作为额外的参数传递。这种对先前天真方法的巧妙转变在以下代码片段(Ch7_1.fsx)中显示出来:

let ``wise factorial`` n = 
  let rec factorial_tail_call acc = function   | n when n = 0I -> acc 
  | _ as n -> factorial_tail_call (acc * n) (n - 1I) 
  factorial_tail_call 1I n 

在先前的 cs ``wise factorial`` 定义中,递归被委托给内部 factorial_tail_call 函数,该函数有两个参数而不是一个:

  • 一个是任何计算步骤的阶乘参数(它被使用 function 而不是更具描述性的 match 构造所隐藏)

  • 另一个是累加器 acc,它携带已经执行递归步骤的中间乘积

现在很容易看出,对 factorial_tail_call 的递归调用不构成任何其他涉及上下文其他值的表达式的子表达式;此外,评估这个自包含的表达式是自我调用函数执行的最后一个操作。这就是为什么它被称为尾调用,此后,所有递归调用都是尾调用的函数被称为尾递归

让我们看看 cs ``wise factorial`` 实现在使用大量参数进行练习后会做什么。为了节省空间,让我们使用优雅的 let howLong = (string >> String.length) 组合器来输出函数结果字符串表示中的数字位数,而不是像以下截图所示的实际阶乘数:

尾递归

使用尾递归实现来推动阶乘极限

在对尾递归进行振奋人心的重构之后,我们的cs ``wise factorial`` 实现计算 100,000 的阶乘没有任何问题,或者用传统的数学符号表示,就是100,000!。确实值得兴奋,因为这个数字需要记录近五十万个数字,确切数字是456574

仔细的读者可能会注意到,cs ``wise factorial`` 的实现,其中没有子表达式和上下文携带的值,非常类似于古老的好命令循环。令人惊讶的是,这正是 F#优化编译器在这种情况下所做的事情。我建议对尾递归编译的内部工作原理感兴趣的读者参考微软 Visual F#团队的这篇博客:F#中的尾调用(blogs.msdn.microsoft.com/fsharpteam/2011/07/08/tail-calls-in-f/ )。

相互递归

到目前为止,所有考虑到的与递归相关的用例都涉及自递归,即递归函数调用自身。然而,不难推断出递归函数抽象允许自然推广,其中两个或更多函数在定义中相互调度,允许循环依赖。这种推广将相互递归模式引入其中。

为了表达这种相互依赖,F#引入了一种特殊的let rec绑定,其中两个或更多组成函数的定义通过and关键字组合在一起,如下所示:

let rec fname_a arguments = 
  < fname_a definition> 
and fname _b arguments = 
  < fname_b definition> 
........................... 

如在递归函数(msdn.microsoft.com/en-us/library/dd233232.aspx )中概述的,我已经在第三章 基本函数 中介绍了具有rec修饰符的单个函数绑定的内部工作原理。相互递归绑定简单地扩展了相同的原则:一个或多个and部分只是添加额外的绑定,使得绑定的函数名可以立即用于前向引用。

从概念上讲,相互递归是一个非常简单的推广。然而,随着移动部件数量的增加,对相互递归函数行为的推理可能会变得相当复杂,从而允许错误悄悄进入。上述观察的一个很好的例子是 MSDN 上提供的相互递归函数对EvenOdd的定义示例。以下代码显示了从那里(Ch7_2.fsx)取出的以下两个相互递归函数的定义:

// Beware, does not work as expected! 
let rec Even x = if x = 0 then true else Odd (x - 1) 
and Odd x = if x = 1 then true else Even (x - 1) 

定义看起来非常简洁和优雅,对吧?不幸的是,这种印象是表面的,前面的定义在特定情况下并不按预期工作,允许递归无限运行而不会停止。请你自己检查前面的代码对于Even(1)测试用例是如何工作的:它会无限运行!我建议对修复这个相互递归定义感兴趣的读者查看我于 2013 年 4 月发布的博客文章两个函数的故事(infsharpmajor.wordpress.com/2013/04/21/a-tale-of-two-functions/),我在那里讨论了问题的基本原理、其历史和提出的修复方案。

在我看来,相互递归函数的定义和带有许多goto操作符的命令式代码片段之间似乎存在某种相似性。在这两种情况下,心理上追踪控制流的流程都同样困难,这反过来又为错误悄悄潜入创造了机会。

现在,让我转向一个相互递归模式良好应用的示例,展示驯服其力量的推理背后的点点滴滴。我将使用我自己的Stack Overflow 回答(stackoverflow.com/a/9772027/917053),针对那里的问题使用函数式编程高效计算素数(stackoverflow.com/questions/9766613/using-functional-programming-to-compute-prime-numbers-efficiently)。我将使用在叙述中到这一点为止已经发现的模式来应对这个挑战,如下所示(Ch7_2.fsx):

let rec primes =  
  Seq.cache <| seq { yield 2; yield! Seq.unfold nextPrime 3 } 
and nextPrime n = 
  let next = n + if n%6 = 1 then 4 else 2 in 
  if isPrime n then Some(n, next) else nextPrime next 
and isPrime n = 
  f n >= 2 then 
    primes 
    |> Seq.tryFind (fun x -> n % x = 0 || x * x > n) 
    |> fun x -> x.Value * x.Value > n 
  else false 

第一部分是定义不定长度的primes序列(实际上,由于前面实现的限制,仅限于int类型的素数,但这个问题可以很容易地推广)。令人惊讶的部分在于,一个序列绑定seq {...}可以是相互递归函数绑定的一部分。尽管如此,primes绑定使用了seq { yield 2; yield! Seq.unfold nextPrime 3 }序列表达式,它产生了第一个素数 2,然后是yield!Seq.unfold生成函数,它依赖于假设存在一个nextPrime函数,给定一个素数参数可以生成下一个更大的素数。请考虑我是如何使用let绑定的rec修饰符提供的nextPrime的前向引用。这非常方便,并且允许你推迟nextPrime的定义,专注于当前序列的生成。

到目前为止,一切顺利。现在,我直接转向 nextPrime 的定义。我这样做是基于假设有一个函数 isPrime 在那里,给定一个 int 参数,可以找出它是否是素数。同样,正如之前讨论的那样,我将向前引用 isPrime,而不必担心它的实现,这要归功于允许我这样做的 let rec ...... 绑定。

nextPrime 函数是通过 Seq.unfold 高阶函数的规则构建的。它首先计算的是下一个候选素数,无论当前参数的素性如何,使用一个稍微晦涩的绑定,let next = n + if n%6 = 1 then 4 else 2。实际上,这里并没有什么令人兴奋的,显然,潜在的候选数是奇数,我以最小的奇数素数 3 开始展开。对于每个值为 n 的候选数,如果 n6 的倍数大 1,则下一个候选数将是 n + 4(因为 n + 2 显然是 3 的倍数);否则,它只是 n + 2,你知道,这只是一个小优化。接下来,有了素数候选 n 和随后的 n 素数候选 next,我使用(尚未定义的)isPrime 函数检查 n 的值是否为素数。如果是肯定的,它返回 Some(n, next) 选项;否则,它以 next 作为参数递归地调用自身。

太好了!拼图的最后一部分是定义 isPrime。首先,它筛选出小于 2 的整数(isPrime 的一个额外有用特性是它可以作为一个素性检测器从其他地方调用)。现在请注意:对于大于或等于 2 的参数值,它积极使用小于或等于参数值平方根的已生成的 primes 序列的成员,这得益于 Seq.tryFind 高阶函数的检查!这就是为什么我在 primes 的定义中用 Seq.cache 缓存序列表达式的输出;否则,isPrime 会很慢。在这里,我们用内存空间换取执行速度。因此,Seq.tryFind 遍历缓存,直到它要么找到参数值的因子,要么到达 primes 成员乘以自身大于参数值的位置。第一个结果意味着参数不是素数,第二个结果意味着它是素数。这个陈述总结了关于 primes 实现的冗长且有点令人烦恼的注释。

我通过检查 primes 实现的性能来结束这一部分。为此,让我转向熟悉的 Project Euler (projecteuler.net/ ),特别是 问题 10 - 素数求和 (projecteuler.net/problem=10 )。

let problem010 () = 
  primes 
  |> Seq.takeWhile ((>) 2000000) 
  |> (Seq.map int64 >> Seq.sum) 

primes 定义应用于不超过 2,000,000 的质数求和,将在接下来的图中展示。在我的电脑上,这只需要不到 1.5 秒。此外,考虑到重复运行的结果只需 10 毫秒,这要归功于序列缓存:

互递归

使用互递归生成质数

个人而言,我发现 primes 代码中有很多美学价值,尤其是在它如何两次使用前向引用,最终锁定在自我计算的数据上。定义中的三个相互依赖的部分都是纯函数(好吧,有点像,因为缓存确实代表了一种隐藏的状态,但以一种非常干净的形式)。这就是函数式编程的力量!

折叠

现在是回顾我在本章开头介绍尾递归时使用的 factorial 函数的完美时机。让我们从以下表达式 1I 到值 nbigint 数列中取一个序列:

Seq.init (n + 1) bigint.op_Implicit |> Seq.skip 1 

factorial(n) 函数难道不是仅仅表示了因数的乘积,每个因数都是前一个序列的成员吗?当然,它可以被看作是这样(并且可以如此实现)。让我按照命令式编程风格的最好传统,如以下所示(Ch7_3.fsx)创建这个实现:

let ``folding factorial (seq)`` n = 
  let fs = Seq.init (n + 1) bigint.op_Implicit |> Seq.skip 1 
  use er = fs.GetEnumerator() 
  let mutable acc = 1I 
  while er.MoveNext() do 
    acc <- acc * er.Current 
  acc 

用简单的话来说,这种实现可以按照以下方式展开:

  • 取一个可变值,它将作为结果累加器使用

  • 列出因数序列

  • 对于序列中的每个因子,通过将当前累加器值乘以当前因子来获取新的累加器值

  • 将最终累加器值作为函数结果返回

一些在面向对象设计方面有经验的你,可能已经在前面的实现中发现了访问者(en.wikipedia.org/wiki/Visitor_pattern ) 模式的迹象。确实,操作(在这个例子中是乘法)被应用于序列数据,而没有以任何方式改变这些数据,最终将结果作为这些重复操作的汇总。

以高阶函数签名的形式概括,可以得到以下内容:

fold: ('State -> 'T -> 'State) -> 'State -> 'T seq -> 'State 

这里,名为 folder 的类型为 ('State -> 'T -> 'State) 的函数应用于以下两个参数:

  • 第一个类型为 'State,表示累加器

  • 第二个类型为 seq 'T,表示具有 'T 类型的元素序列

folder 函数返回累加器的最终值。这个名为 fold 的函数代表了数据处理的通用模式,名为 folding

如预期的那样,前面形式化的通用折叠确实是 F#核心库的一个成员:Seq.fold<'T,'State>函数 (msdn.microsoft.com/en-us/library/ee353471.aspx )。使用Seq.fold库函数重写cs ``folding factorial (seq)`` ,该函数隐藏了所有这些讨厌的移动部件(枚举器、状态持有者和遍历枚举),给出了以下更简洁的版本(Ch7_3.fsx):

let ``folding factorial (lib)`` n = 
  Seq.init (n + 1) bigint.op_Implicit 
  |> Seq.skip 1 
  |> Seq.fold (*) 1I 

让我们从性能的角度比较这两种实现。以下截图显示了运行这两个版本的结果:

折叠

手动编写的折叠与库折叠函数的性能比较

观察到library函数的性能略优于手动编写的命令式版本,这并不令人惊讶。library函数的实现高度优化。对于那些好奇的人来说,GitHub 上当前fold函数的库实现看起来就像以下片段(Ch3_7.fsx)所示:

// Excerpt from seq.fs of FSharp.Core.Collections: 
[<CompiledName("Fold")>] 
let fold<'T,'State> f (x:'State) (source : seq<'T>)  =  
  checkNonNull "source" source 
  use e = source.GetEnumerator()  
  let f = OptimizedClosures.FSharpFunc<_,_,_>.Adapt(f) 
  let mutable state = x  
  while e.MoveNext() do 
    state <- f.Invoke(state, e.Current) 
  state 

你可能已经注意到折叠与带有累加器的尾递归有多么相似。这种相似性并非偶然。两者都通过函数调用的序列传递状态,尽管recursive函数在执行时实际实现了这些调用,而fold函数则将folder函数应用于显式的待折叠数据序列。

注意

可以形式化证明,在具有一阶元组和函数的语言中,例如 F#,任何函数都可以表示为fold。我建议对这一主题感兴趣的读者参考关于此的经典论文:Graham Hutton 的关于折叠的表达能力和普遍性的教程(www.cs.nott.ac.uk/~pszgmh/fold.pdf )。

记忆化

接下来的两个相对高级的主题,我在某种程度上将它们与编译上下文之外的即时Just in Time)方法进行了比较。在即时en.wikipedia.org/wiki/Just_in_Time)中,维基百科首先提出了一个生产策略,在制造过程中,组件在投入使用之前立即交付,作为一种减少库存成本的精益方法。

实际上,记忆化惰性求值在这个精益计算意义上是相辅相成的。虽然惰性允许你不必在结果绝对需要之前进行计算,但记忆化通过不允许这些已经执行过的昂贵资源密集型计算结果被浪费,使得这些结果可重用。

我在前面这个章节中实现素数生成时已经使用了一些缓存,为了覆盖互递归。在那里,一个昂贵的序列被缓存起来,以便使用已经生成的元素来找到尚未生成的下一个元素。现在,我想专注于缓存的一般用法,允许任何函数都可以被缓存。在这样做之前,重要的是要意识到以下内容:

  • 缓存可能只适用于 纯函数。这几乎是显而易见的;如果一个函数不是引用透明的,那么它不能被缓存,因为缓存只捕获参数,而不是参数、状态。

  • 缓存利用了预先计算的状态

基于此,让我们模仿互联网上其他地方提出的实现(blogs.msdn.microsoft.com/dsyme/2007/05/31/a-sample-of-the-memoization-pattern-in-f/www.fssnip.net/8P),以便调查相关的限制和问题,如下所示(Ch7_4.fsx):

// Memoization (F# 4.0 is required) 
let memoize f = 
  let mutable cache = Map.empty 
  fun x -> 
    match cache.TryFind(x) with 
    | Some res -> printfn "returned memoized";res 
    | None -> let res = f x in 
    cache <- cache.Add(x,res) 
    printfn "memoized, then returned"; res 

F# 编译器为缓存推断的类型如下:

memoize : f:('a -> 'b) -> ('a -> 'b) when 'a : comparison 

在这里,f 代表一个需要缓存的结果的函数,cache 作为状态存储库,在底层使用不可变的 Map F# 集合 (msdn.microsoft.com/en-us/library/ee353880.aspx)。memoize 本身代表一个完整的高阶函数,它接受一个函数作为参数,并返回一个函数。这封闭了可变的 cache(F# 4.0 的一个特性)并执行以下操作:

  • 如果其参数 x,作为对封闭的 Map cache 的键,可以被找到,那么它记录了预缓存值将被使用的信息,并返回这个 res 值。

  • 否则,它将封闭的 cache 转换为一个新的 Map,除了现有的条目外,还包括由新计算出的元组(x, f(x))表示的条目,然后记录缓存发生的事实,并返回 f(x)

让我们看看在 FSI 中它是如何工作的,以下截图捕捉了这一过程:

缓存

使用 F# Map 的缓存

首先,我将 fun x -> x*x 函数缓存起来,这个函数原本应该代表一个“胖”的资源密集型计算,变成了 fm:(int -> int) 函数。然后,我像这里所示一样,用不同的参数调用了 fm 几次:

  • fm 10:结果 100 被缓存,用于参数 10 并返回

  • fm 42:结果 1764 也被缓存并返回

  • fm 10:由于这个参数值已经出现,结果 100 被返回,无需任何重新计算

这个模式看起来相当简单;然而,它有几个需要注意的问题。

例如,memoize的签名表明需要'a来表示比较;那是什么意思?深入挖掘memoize实现会让你得出结论,这个约束仅仅是使用 F# Map作为状态持久化后的一种推论。

由于Map背后的实现可能是一个平衡树,它需要其键是可比较的以便重新平衡。哎呀!听起来这里发生了抽象泄漏(en.wikipedia.org/wiki/Leaky_abstraction)。此外,这也可能成为通用记忆化应用的一个限制因素,因为可比较性并不是泛型类型'a的通用属性。

让我们将持久化实现机制更改为通用的字典(msdn.microsoft.com/en-us/library/xfhwa508(v=vs.100).aspx),如下面的代码所示 (Ch7_4.fsx):

let memoize' f = 
  let cache = System.Collections.Generic.Dictionary() 
  fun x -> 
    match cache.TryGetValue(x) with 
    | true,res -> printfn "returned memoized";res 
    | _ -> let res = f x 
    cache.Add(x,res) 
    printfn "memoized, then returned" 
    res 

这将记忆化参数约束从比较改为相等,如下所示:

memoize' : f:('a -> 'b) -> ('a -> 'b) when 'a : equality 

这可以被认为是更普遍的情况,直到出现一些无害的使用,如下所示:

let disaster = memoize' (fun () -> 5) 
...... 
disaster() 

执行此代码将导致以下异常:

System.ArgumentNullException: Value cannot be null 

究竟是怎么回事?结果是并没有发生什么特别的事情,只是另一个抽象泄漏发生了,因此出现了问题。这次,问题源于不允许你将null值作为Dictionary键的底层持久化机制(相反,Map却乐意这样做)!

最后,我想谈谈将记忆化与递归结合的问题,因为递归经常是解决采用分而治之策略的问题的工具,其中记忆化自然适用,并且可能真正发光。

让我们考虑一些更符合目的的使用案例,例如,借助帕斯卡三角形(en.wikipedia.org/wiki/Pascal%27s_triangle)进行简单的二项式系数计算,如下所示 (Ch7_4.fsx):

let rec binomial n k =  
  if k = 0 || k = n then 1 
  else 
    binomial (n - 1) k + binomial (n - 1) (k - 1) 

帕斯卡三角形行元素之间容易察觉的递归关系让你可以期待从记忆化中获得重大好处。

binomial的记忆化实现也很简单;memoize被转换为一个内部函数,以便去除其初始版本中引入的日志。唯一剩下的问题是记忆化函数有一个参数。然而,应用反柯里化可以很好地解决这个问题,如下所示 (Ch7_4.fsx):

let rec memoizedBinomial = 
  let memoize f = 
    let cache = System.Collections.Generic.Dictionary() 
    fun x -> 
    match cache.TryGetValue(x) with 
    | true,res -> res 
    | _ -> let res = f x 
    cache.Add(x,res) 
    res 
  memoize 
  (fun (n,k) -> 
    if k = 0 || k = n then 1 
    else 
      memoizedBinomial (n - 1, k) + 
      memoizedBinomial (n - 1, k - 1)) 

现在是时候衡量记忆化带来的收益了。即将到来的图中的代码测量重复binomial 500 2 10,000 次与重复memoizedBinomial (500,2) 10,000 次所需的时间:

记忆化

“分而治之”解决方案的记忆化

比较的结果绝对令人震惊,即 23781 / 15 = 1585,这意味着记忆化将性能提高了 1585 倍!

惰性求值

这个概念非常简单。默认情况下,F# 遵循 贪婪求值 (en.wikipedia.org/wiki/Eager_evaluation ) 策略,或者一个表达式一旦绑定就会被求值。在其他函数式编程语言中可用的另一种策略是推迟计算,直到其结果绝对必要。F# 可以明确地告诉在哪里使用惰性求值;默认情况下,它只为序列使用惰性求值。在 F# 中表达惰性求值在语法上并不复杂,以下绑定如所示地达到了目的:

let name = lazy ( expression ) 

在这里,name 被绑定到计算 expression 的结果,但计算本身被推迟。name 的值类型是特殊的,即 Lazy<'T>;它表示对 'T 的封装,'T 是表达式的本身类型。计算通过调用 Lazy<'T> 类型的 Force 方法来执行,就像这样 name.Force()。这个动作也会展开 Lazy 的底层类型,因此 name.Force() 表达式的类型是 'T

请注意,这个特性并不特定于 F#;Lazy<T> 类是 System 命名空间中 .NET 框架类库的一部分。

重要的是要理解表达式只计算一次,所以如果封装到 lazy 方法中的表达式有副作用,它只会在表达式计算上执行一次。即使再次强制计算,副作用也不会发生;只会返回缓存的值。

让我们通过以下片段(Ch7_5.fsx)来演示:

let twoByTwo  = lazy (let r = 2*2 in 
  printfn "Everybody knows that 2*2=%d" r; r)  
twoByTwo.Force() 
twoByTwo.Force() 

以下截图显示了这段代码在 FSI 中的行为:

惰性求值

惰性求值和副作用

注意,twoByTwo 的绑定并没有使任何计算变得活跃,但它将未来的计算封装到了 Lazy 类型中。然后,第一个 twoByTwo.Force() 函数执行了封装的计算,因此副作用出现了。最后,任何后续的 twoByTwo.Force() 函数都只会重复带来第一次计算的结果,而没有任何副作用。

惰性求值模式在企业 F# 开发中有一个自己的领域。我经常在需要可能正在初始化的资源时使用它;如果这个需求真的实现了,我希望它只发生一次。例如,我们可以考虑在服务在生产环境中运行时从 Azure KeyVault 读取生产环境配置设置,而在其他环境中使用其他配置信息载体,例如指向数据存根的环境变量。

继续传递风格

这种安排递归的复杂技术允许你通过将所有函数调用放入尾位置(即执行剩余计算而不是将结果返回给调用者)来避免栈消耗。让我通过再次重构阶乘实现来演示这项技术,如下面的代码片段所示(Ch7_6.fsx):

let rec ``factorial (cps)`` cont = function 
  | z when z = 0I -> cont 1I 
  | n -> ``factorial (cps)`` (fun x -> cont(n * x)) (n - 1I)  

虽然有点令人费解,但代码全部由尾递归组成:

  • 对自身的递归调用cs ``factorial (cps)`` 是尾递归

  • 新的延续匿名函数也调用旧的延续,cont

cont函数推断出的签名是(BigInteger -> 'a);因此,为了执行所需的计算,使用id身份函数作为cs ``factorial (cps)`` 的第一个参数对cont来说就足够了。以下截图展示了在 FSI 中测试cs ``factorial (cps)`` 函数的延续传递风格实现:

延续传递风格

使用延续传递风格实现阶乘函数

这工作得很好,尽管那些不熟悉延续传递风格的人第一次处理这段代码时可能会头疼。

活动模式

我在第四章,基本模式匹配中承诺,我会通过涵盖活动模式来增加这个主题的内容;现在正是时候。记得守卫的匹配吗?守卫提供了一种通过附加一个具有bool结果的不定计算来深入挖掘匹配的pattern-expression函数的方法。

守卫机制为普通的模式匹配添加了一定的定制潜力,但它有点脱离:无论需要多少数据分解来完成守卫计算,所有这些努力都会在匹配和非匹配的可能计算结果中都被丢弃。不是很好吗?在模式匹配的识别和转换阶段之间有一个完全可定制的过渡?活动模式正是针对这个问题。广义上讲,活动模式代表了一种特殊类型的函数,允许在pattern-expression中使用。

它们允许你以非常简洁和优雅的方式实现一些典型的数据转换模式,如下所示:

  • 类型之间的高级转换

  • 根据相关和不相关的类别将数据分组

  • 进行完全分类,换句话说,就是将任何数据按照这个数据属于给定的一对中的特定类别进行处理

让我们看看活动模式如何与这些数据处理模式的每一个案例互动。

使用活动模式进行类型转换

活动模式在let绑定中定义时使用特殊的命名约定:

  • 即使像这样使用双引号,主动模式函数的名称也必须以大写字母开头:cs ``I'm active pattern``

  • 主动模式函数的名称必须被包裹在香蕉夹 (| and |) 中,如cs(|``Another active pattern``|)所示

主动模式工作的数据总是作为定义中的最后一个参数出现,并在使用时从上下文(matchfunction 或任何其他发生模式匹配的 F# 构造)中获取;在多参数定义中,除了最后一个参数之外的所有参数都是参数化主动模式工作的参数。

最后,当在最后一个参数的位置使用字面量时,如果主动模式计算的结果与字面量匹配,则 pattern-expression 被认为是匹配的。如果使用名称而不是字面量,则该名称绑定到主动模式计算的结果,并在相应的 result-expression 转换中使用。

这听起来是否令人困惑?实际上,它比听起来要简单。让我转向一些可能有助于说明的示例。

第一个表示一个虚拟样本,如下面的代码所示(Ch7_7.fsx):

let (|Echo|) x = x 
let checkEcho p =  
  match p with 
  | Echo 42 -> "42!" 
  | Echo x -> sprintf "%O is not good" x 

Echo 主动模式非常简约;它只是将输入回显到结果中。然后,checkEcho 函数将这个定义付诸实践。在第一个 pattern-expression 中,它简单地检查 Echo p 计算的结果(p 是从 match 构造的头部隐式获取的)是否等于 42。如果是,则相应的结果表达式返回字符串 "42!"。否则,下一个 result-expression 将无条件地将 Echo p 计算的结果绑定到变量 x,然后这个变量在 result-expression 中被用来生成一个 "... is not good" 字符串。

因此,当在 FSI 中使用前面的示例时,checkEcho 0产生"0 is not good",而checkEcho 42产生"42!"

这是否变得更清晰了?另一个加强这种理解的简单示例将是一个主动模式:

let (|``I'm active pattern``|) x = x + 2 

在保持参数和结果类型相同的情况下,这仅仅执行了一个简单的值转换。上述主动模式的使用在以下屏幕截图中显示:

使用主动模式的类型转换

使用主动模式进行简单的类型转换

定义主动模式的绑定cslet (|``I'm active pattern``|) x = x + 2不匹配任何内容;相反,它取匹配的值并返回它,然后加 2。

绑定cslet x = match 40 with ``I'm active pattern`` x -> x用作匹配构造的一部分,并给出输入参数 40,它返回绑定到 42 的和值。

绑定cslet (``I'm active pattern`` x) = 40是一个稍微令人困惑的例子,如果你记得值绑定的 let 是基于模式匹配的数据解构的边缘情况,那么 cs ``I'm active pattern`` 就应用于输入参数 40 并将结果 42 绑定到 x

到目前为止,应用活动模式进行数据转换的特定用例应该已经足够清晰;我想将其应用于一个更实际的应用场景。

使用全局唯一标识符(GUID)(en.wikipedia.org/wiki/Globally_unique_identifier )来标记在业务运行过程中出现的唯一实体是一种相当普遍的技术。例如,在 Jet.com,GUID 用于标记客户订单、商家订单、商家订单项、运输、履行中心、SKU...完整的列表会太长。这些代码大多以 32 个十六进制数字的字符串形式交换和显示。在某些系统节点中,需要验证给定的字符串是否是 GUID 的合法表示。这项任务可以很容易地通过活动模式完成,如下所示(Ch7_7.fsx):

let hexCharSet = ['0'..'9'] @ ['a'..'f'] |> set in 
let (|IsValidGuidCode|) (guidstr: string) = 
  let (|HasRightSize|) _ = guidstr.Length = 32 
  let (|IsHex|) _ = (guidstr.ToLower() |> set) = hexCharSet 
  match () with (HasRightSize rightsize & IsHex hex)-> rightsize && hex  

上述代码有许多有趣的片段,例如允许的hexCharSet十六进制字符集,这些字符只计算一次,并且是活动模式IsValidGuidCode定义的局部;一对内部活动模式HasRightSizeIsHex,每个模式只负责单个验证属性,并使用外部活动模式中的一个而不是自己的输入参数;最后,两个pattern-expressions通过&组合在一起,再次省略了参数,因为它已经传递给了它们的主体,并在result-expression中将最终结果基于在互补的pattern-expression中提炼出的实体组合。那些完全理解上述代码片段如何工作的人可以自称是活动模式主题的专家。

为了确保这段代码真的能工作,让我快速测试一下。下面的图反映了测试结果,显示IsValidGuidCode活动模式正确地将"abc"字符串识别为无效 GUID,将"0123456789AbCdEfFFEEDDCCbbAA9988 "识别为有效 GUID:

使用活动模式进行类型转换

使用活动模式验证 GUID 字符串

顺便说一下,我迄今为止所涵盖的形如(|活动模式名称|)的活动模式被称为单全活动模式,因为它们处理单个数据类型,通过包含的计算将其转换为相同或不同的数据类型。考虑的样本的另一个特点是它们都在单个参数上工作。我将在本章后面介绍带参数的活动模式

使用活动模式进行数据分区

我对 F# 活跃模式作为处理模式的使用的新尝试,关注的是典型的做法,即数据可能构成一个或多个适合处理的情况,以及“其余的”不适合的情况。在 F# 无处不在地使用能够执行上述方式划分的选项活跃模式的精神下,将输入数据类型转换为 Option 类型,其中 None 情况表示不适合的数据,而 Some 包裹一个或多个适合的数据类型。

这种活跃模式定义的明确区分是通过在活跃模式定义的右侧香蕉剪辑 |) 前面添加 |_ 字符来实现的。这种类型的活跃模式称为部分活跃模式,它们的名称组看起来像这样:(|name 1[|name 2...]|_|)。让我们考虑一个相当大的真实代码片段,来自 Jet.com 的一个生产系统,以展示这种技术。

当前任务是对 Jet.com 供应商(运输承运人、支付处理器等)的发票进行处理,这些供应商以逗号分隔的文件形式包装他们的数据。在这里,我广泛地使用“逗号分隔”,因为分隔符可以是任何字符。文件可能包含或不含标题,并且可能携带无数其他不规则性。上传这些发票进行处理,然后存档,这是一个具有一定复杂性的问题。

为了本章的目的,我将只考虑一个部分相关的问题,即识别最后上传的文件是否为已知的 Processable 类型,应该进行处理,或者它不是,应该被拒绝。

为了使书中实现前述任务的代码尽可能简短,我将供应商数量限制为仅三个,即运输承运人联邦快递(FedEx)、OnTrac和支付处理器Braintree

我从 Processable 开始,列出已知供应商文件如下(Ch7_8.fsx):

type Processable = 
| FedexFile 
| OnTracFile 
| BrainTreeFile 
with 
  override this.ToString() = match this with 
    | FedexFile -> "Fedex" 
    | OnTracFile -> "OnTrac" 
    | BrainTreeFile -> "BrainTree" 

这里没有什么花哨的;只是用区分联合表示领域实体的常见做法,可能略有增强。

接下来,文件标题是硬编码的,并且从右侧显著剥离,因为完整的内 容并不重要,如所示(Ch7_8.fsx):

let BraintreeHdr = "Transaction ID,Subscription ID,..." 
let FedexHdr = ""Bill to Account Number";"Invoice Date";..." 
let OntracHdr = "AccountNum,InvoiceNum,Reference,ShipDate,TotalCharge,..." 

最后,活跃模式定义如下(Ch7_8.fsx):

let (|IsProcessable|_|) (stream: Stream) = 
  use streamReader = new StreamReader(stream) 
  let hdr = streamReader.ReadLine() 
  [(Processable.BrainTreeFile,BraintreeHdr); 
  (Processable.FedexFile,FedexHdr); 
  (Processable.OnTracFile,OntracHdr)] 
  |> List.tryFind (fun x -> (snd x) = hdr) 
  |> function 
  | None -> (if hdr.StartsWith(""1",") then 
    Some (Processable.OnTracFile) else None) 
  | _ as zx -> Some (fst zx.Value) 

如预期的那样,活跃模式名称指向部分活跃模式,参数是 System.IO.Stream 类型,携带文件内容,其返回值是 Processable 选项类型。

函数首先创建 StreamReader 并从那里读取第一行到 hdr 值中。

然后,它接受一个元组的列表,其中成员将Processable案例与表示相应逗号分隔的文件头部的字符串字面量配对,并尝试找到元组的第二部分等于hdr的元素。如果存在这样的元素,则可以处理该文件,函数返回选项值Some,封装找到的元组的第一个部分。

如果找不到元素(选项值None情况),在此点考虑,通常OnTrac文件可能不带标题。为了利用这一知识,我进一步检查已采取的流内容以及文件是否以指向OnTrac来源的某些符号开头,活跃模式返回Some (Processable.OnTracFile);否则,该文件被认为是不可处理的。

在我看来,IsProcessable活跃模式代表了业务功能的相当简洁和清晰的实现。

使用活跃模式进行数据分类

我用适用于分类处理模式的活跃模式类型来结束我们对 F#活跃模式的精彩世界的探索,即把数据划分成完全覆盖域实体的所有子类别,不留任何非适用异常值的空间。

如你们中的一些人可能已经推断出的,与这种活跃模式相关联的名称是多案例活跃模式。其句法定义也与已经考虑过的案例非常不同。它包含在香蕉夹之间,只有几个用|管道符号分隔的案例名称。

让我们深入探讨说明性样本。一个电子商务域在支付方面考虑不同的支付条款和政策。特别是,如果支付条款不是即时的,引入确定每个特定付款何时到期的某些政策或政策是有意义的。因此,根据提供服务或商品的那一天,相应的付款是否到期取决于从那天到现在的经过时间。

使用活跃模式来实现非常直接;为了简单起见,让我们假设业务已经采用了一项单一天政策,即最多推迟三天付款(当然,在更复杂的设计中,这项政策可以是参数化的主题)如下所示(Ch7_9.fsx):

open System 

let (|Recent|Due|) (dt: DateTimeOffset) = 
  if DateTimeOffset.Now.AddDays(-3.0) <= dt then Recent 
  else Due 

let isDue = function 
| Recent -> printfn "don't do anything" 
| Due  -> printfn "time to pay this one" 

使用活跃模式的函数也很简单,但这对说明目的来说是可以接受的。前面的代码在以下图中展示:

使用活跃模式进行数据分类

多案例活跃模式用于数据分类

我忘记提到,截至今天,F# 4.0 多案例活跃模式中的最大案例数限制为 7,这可能是某些情况下使用活跃模式的限制因素。

摘要

本章深入探讨了相对高级的 F#惯用法,这是普通 F#开发者在工作日中非常频繁使用的。

下一章将涵盖更多广泛使用的语言模式,这些模式在数据处理中扮演着核心角色,F#展示了针对多种数据集合类型的多态行为。

第八章。数据压缩 – 数据转换模式

在上一章处理了函数定义和应用的先进模式之后,我想回顾一下在第六章 序列 - 数据处理模式的核心 中刚刚略微触及的主题,即与序列相关的内容。在那里,我声称相当庞大的 Collection.seq 库吸收并实现了少数几个通用数据处理模式。然后我根据这些模式重新分组了库成员。

本章将进一步探讨这些数据转换模式,这些模式不仅适用于序列,也适用于其他数据集合。本章的目标是帮助您掌握使用少量典型多态转换类别中的函数来表达数据处理需求的能力,这些类别由少量组合子组成,并通过操作最适合当前任务的数据集合类型来实现。这种方法允许您统一覆盖最广泛的具体数据转换。坚持上述方法对于 F# 程序员实践者至关重要,因为它有效地抑制了没有充分理由而开发冗长自定义解决方案的趋势,并总体上增加了 F# 程序的积极属性,如简洁性、正确性和性能。

在本章中,我们将检查:

  • F# 4.0 中数据转换库的规范化如何反映在底层转换模式共性上。这些共性具有多态性,适用于库旨在处理的各种数据集合。

  • 在 第六章 序列 - 数据处理模式的核心 中收集的转换模式如何在各种数据集合中展现出来。

这将是一次漫长的旅程,所以请和我一起坚持,保持冷静和水分充足。

F# 4.0 的核心数据转换库

F# 4.0 带给 FSharp.Core 运行时的一项增强是 规范化的数据集合模块 (blogs.msdn.microsoft.com/fsharpteam/2014/11/12/announcing-a-preview-of-f-4-0-and-the-visual-f-tools-in-vs-2015/ )。这个发展非常有趣:

  • 证实了数据处理模式在数据处理平台间的共性。例如,mapfilter 函数可以在 F# 等函数式编程语言中找到,在 LINQ 等查询工具和 PowerShell 等脚本引擎中也可以找到,仅举几例。

  • 认识到属于这些模式的具体函数是多态的,并且可能被统一应用于不同的数据集合类型。F# 4.0 成功地将这种多态性应用于最常用的数据集合类型,即 ArrayListSeq 模块。

总体而言,这个库为 F# 4.0 数据处理功能增加了 95 个针对不同集合类型的优化函数实现。这一增加使得之前提到的三个集合模块中的单个函数总数达到了 309 个(截至 2016 年 4 月),这无疑是一个相当大的成果。然而,对于一个随机开发者来说,在没有认识到一些形成性原则的情况下,要凭记忆和回忆这种安排是非常具有挑战性的。

考虑到大多数函数都统一应用于三种基础集合类型(其中一些函数自然不适用于某些具体集合;例如,toList 不适用于 List ),这仍然留下了 117 个(截至 2016 年 4 月)不同的函数名称 仅用于基础数据集合。而且别忘了与较少使用的数据集合相关的某些函数,例如 setIDictionaryArray2D 。你应该如何处理这种多样性?

幸运的是,数据转换模式只有少数几种。识别底层模式通常会对相关库函数施加顺序,使得每种模式只关联十几个函数。这样的分类数量更容易回忆。

在本章的其余部分,我们将检查这些隐藏的模式及其相应的凝聚函数组。提供的惯用代码示例有助于模式的保留、识别和重用。

数据转换模式

关于数据转换库丰富性的一个好问题是:这种压倒性的多样性最初是从哪里来的?为什么 F# 的设计者将如此多的函数(多达一百多个)包含到基础数据集合的 核心 库中?

我认为这个问题没有单一的“正确”答案。然而,从考虑典型的 ETL - 提取、转换、加载 (en.wikipedia.org/wiki/Extract,_transform,_load ) 企业数据处理流程中,可能会得到一些线索。在可变集合和任意变化的状态的世界中,这个操作可以表达如下:

void PerformETL() 
{ 
    ExtractData(); 
    TransformData(); 
    LoadData(); 
} 

这种类似于 C# 的伪代码展示了数以亿计的可能数据转换如何被隐藏在几行不透明的代码背后。我们无法对这些细节做出任何评论,除非我们仔细研究上述每个组件的实现,找出它们的功能、如何获取数据以及如何与其他相关组件共享修改状态。

现在,让我们以更函数式的方式表达语义上相似的系列活动如下:

let performETL params : unit = 
    let inputCollection = extractData params 
    let outputCollection = transformData inputCollection 
    loadData outputCollection 

前面的代码片段比它的命令式兄弟讲述了一个更好的故事。我们可以立即看出extractData是一个基于某些输入参数params的集合生成函数,这些参数从某种持久存储中产生初始的inputCollection函数。这个集合作为输入参数传递给转换函数transformData,该函数的结果是输出集合outputCollection。最后,这个集合被传递给数据加载函数loadData,并最终存储回持久存储。鉴于与持久存储的通信是以一种幂等的方式实现的,并且涉及的函数是引用透明的,这个转换链可以被任意次数地重放,并得到相同的结果。

我们甚至可以更进一步,将最后一个代码片段重写如下:

let performETL params : unit = 
  params  
   |> extractData 
   |> transformData 
   |> loadData 

现在我们真正处理代码,转换不可变数据。这段代码不依赖于内部状态的副作用。其组件具有更好的可组合性,并且如果需要,可以轻松扩展。最后,这段代码现在更加优雅,更容易阅读和理解。

你可能会问,这篇相当简单的文章如何与重要的库成员的多样性相关?

首先,有一些典型的转换与计算机科学中广泛接受的数据处理算法捕获方式相对应。例如,如果我们打算提供一个库函数来将集合拆分为一对分区,我们无法将其做得与以下伪签名中的高阶函数有很大不同:

partition: predicate:('T -> bool) -> source:'T collection 
           -> ('T collection * 'T collection) 

在这里,predicate是一个函数,它接受一个类型为'T的单个集合成员,并返回bool,其中true表示输入元素将进入结果元组的第一个集合,而false表示它将进入第二个集合。source参数表示要拆分的输入集合。我故意将“泛型”collection放入前面的签名中,我将在稍后解释原因。结果是包含source元素,通过predicate值被分割成两个集合的元组。

许多计算机科学中已知的算法几乎可以简洁地使用上述partition函数实现。例如,著名的快速排序(en.wikipedia.org/wiki/Quicksort)代表了广泛的分而治之(en.wikipedia.org/wiki/Divide_and_conquer_algorithms)算法类别。让我们看看如何优雅地使用partition实现快速排序,如下面的代码片段所示(Ch8_1.fsx):

let rec qsort : int list -> _ = function 
   | [] -> [] 
   | x::xs -> 
       let less, greater = List.partition ((>) x) xs 
       qsort less @ x :: qsort greater 

qsort函数(有些简化地)将非空输入列表参数分成两组:一组只包含列表头部小于x的元素,另一组包含其余元素。结果是将x前置的列表qsortgreater附加到列表qsortless上。太棒了!让我们看看以下截图中的 FSI 是如何实现的:

数据转换模式

使用分区函数实现快速排序

现在让我回到我为什么在前面partition函数的签名中使用collection的原因。巧合的是,这也是促使库成员多样化的考虑因素之一,那就是性能。你可以打赌,为了有效,partition应该分别针对arraylist集合实现,从而产生一对函数,每个函数都属于其各自的模块,如下所示:

List.partition: predicate:('T -> bool) -> list:'T list 
           -> ('T list * 'T list) 
Array.partition : predicate:('T -> bool) -> array:'T[] 
           -> 'T[] * 'T[] 

沿着这个思路,一个有趣的观点是 F# 4.0 核心库中缺少Seq.partition函数。这个现象的根本原因在于性能。我建议那些好奇的人查阅相关的 F#设计规范(github.com/fsharp/FSharpLangDesign/blob/5cec1d3f524240f063b6f9dad2f23ca5a9d7b158/FSharp-4.0/ListSeqArrayAdditions.md#regular-functional-operators-producing-two-or-more-output-collections )以及一个更普通的StackOverflow 问答网站上的解释(stackoverflow.com/a/31750808/917053 ),以了解确切的原因。

总结起来,F#语言设计者在定义和实现 F#核心库的数据转换函数时,一直在寻找以下因素的平衡:

  • 良好的覆盖面,这是多年函数式编程实践提炼出的典型用例

  • 不要使库的大小超过合理的极限

  • 将每个库提供的函数优化到极致,以至于任何自定义实现的相同功能都显得毫无意义

带着这种整体的观点,让我转向具体覆盖模式。在函数表示模式可以简洁表达的情况下,我将在下一行提供评估结果作为注释,以节省空间。

生成模式

这种模式很容易识别:它代表了一个从没有任何集合的状态到创建了一个集合的状态的过渡。生成模式由具有通用签名结构的库函数表示,如下所示:

name: <zero or more input parameters> -> collection<'T> 

这个通用签名导致了一些具体的使用案例,具体取决于结果集合的具体形状。

生成一个空集合

要生成一个泛型类型的空集合,核心库函数 empty 存在,允许您为任何基本集合类型生成强类型空集合,如下所示 (Ch8_2.fsx ):

let el = List.empty<string> 
// val el : string list = [] 
let ea = Array.empty<float> 
// val ea : float [] = [||] 
let es = Seq.empty<int -> string> 
// val es : seq<(int -> string)> 
// es;; 
// val it : seq<(int -> string)> = seq [] 

同样,也可以使用每个基本集合类型的相应常量表达式来实现 (Ch8_2.fsx ):

let ell: string list = [] 
// val ell : string list = [] 
let eal: float[] = [||] 
// val eal : float [] = [||] 
let esl: seq<int -> string> = seq [] 
// val esl : seq<(int -> string)> = [] 
// esl;; 
// val it : seq<(int -> string)> = [] 

生成单个元素集合

这个属于生成模式简单任务可以通过核心库函数 singleton 实现,该函数适用于每个基本集合类型。它不需要显式声明集合元素类型,因为它可以从为单个集合元素提供的类型字面量中轻松推断出来,如下面的代码所示 (Ch8_2.fsx ):

let sl = List.singleton "I'm alone" 
// val sl : string list = ["I'm alone"] 
let sa = Array.singleton 42.0 
// val sa : float [] = [|42.0|] 
let ss = Seq.singleton (fun (x:int) -> x.ToString()) 
// val ss : seq<(int -> string)> 
// ss;; 
// val it : seq<(int -> string)> = seq [<fun:ss@24>] 

再次,这也可以通过使用每个基本集合类型的相应常量表达式来实现,如下所示 (Ch8_2.fsx ):

let sll = ["I'm alone"] 
// val sll : string list = ["I'm alone"] 
let sal = [| 42.0 |] 
// val sal : float [] = [|42.0|] 
let ssl = seq [fun (x:int) -> x.ToString()] 
// val ssl : seq<(int -> string)> = [<fun:ssl@24>] 

生成一个已知大小的集合

生成模式的任务由两种不同的情况表示:集合中所有元素都具有相同值的情况和它们可以具有不同值的情况。

生成一个已知大小的集合 - 所有元素具有相同的值

F# 4.0 核心库提供了具有以下签名的函数来复制每个基本集合类型:

List.replicate: count:int -> initial:'T -> 'T list 
Array.replicate: count:int -> initial:'T -> 'T[] 
Seq.replicate: count:int -> initial:'T -> seq<'T> 

以下是一些使用示例 (Ch8_2.fsx ):

let fl = List.replicate 3 "blah" 
// val fl : string list = ["blah"; "blah"; "blah"] 
let fa = Array.replicate 3 42 
// val fa : int [] = [|42; 42; 42|] 
let fs = Seq.replicate 3 42.0 
// val fs : seq<float> 
// fs;; 
// val it : seq<float> = seq [42.0; 42.0; 42.0] 

如我之前讨论的,这可以通过使用字面量和理解表达式来实现,如下所示 (Ch8_2.fsx ):

let fll = ["blah";"blah";"blah"] 
// val fll : string list = ["blah"; "blah"; "blah"] 
let fal = [| for i in 1..3 -> 42 |] 
// val fal : int [] = [|42; 42; 42|] 
let fsl = seq { for i in 1..3 do yield 42.0 } 
// val fsl : seq<float> 
// fsl;; 
// val it : seq<float> = seq [42.0; 42.0; 42.0] 

除了 replicate 之外,F# 核心库为 数组 集合专门提供了 createzeroCreate 函数,如下所示 (Ch8_2.fsx ):

Array.create: count:int -> value:'T -> 'T[] 
Array.zeroCreate: count:int -> 'T[] 
let fac = Array.create 3 "blah" 
// val fac : string [] = [|"blah"; "blah"; "blah"|] 
let fazc: string[] = Array.zeroCreate 3 
// val fazc : string [] = [|null; null; null|] 
let fazci = Array.zeroCreate<int> 3 
// val fazci : int [] = [|0; 0; 0|] 

注意,zeroCreate 按设计不会向 F# 编译器提供任何有关目标数组类型的线索。因此,为了避免在将目标数组类型委托给类型推断时出现的臭名昭著的 error FS0030: Value restriction 错误消息,可以在值本身上添加类型注解,例如 string[] 对于 fazc,或者可以在函数名称本身上添加类型参数,例如前述代码中的 fazci<int>

生成一个已知大小的集合 - 元素可能具有不同的值

如果集合的元素需要具有不同的值怎么办?F# 核心库为我们提供了每个基本集合类型的 init 函数,其签名如下:

List.init: length:int -> initializer:(int -> 'T) -> 'T list 
Array.init : count:int -> initializer:(int -> 'T) -> 'T[] 
Seq. init: count:int -> initializer:(int -> 'T) -> seq<'T> 

以下是一些使用示例 (Ch8_2.fsx ):

let vl = List.init 4 ((*) 2) 
// val vl : int list = [0; 2; 4; 6]  
let va = let src = "closure" in Array.init src.Length (fun i -> src.[i]) 

// val va : char [] = [|'c'; 'l'; 'o'; 's'; 'u'; 'r'; 'e'|] 
let vs = Seq.init 3 id  
// val vs : seq<int> 
// vs;; 
// val it : seq<int> = seq [0; 1; 2] 

注意,initializer 被赋予了每个元素的隐式索引,这将其转换为元素值。这种转换可以非常简单,例如 vs,或者非常复杂,例如 va,其中它围绕 src 封闭,实际上将 string 转换为其字符的字符数组。

与相同值元素的情况类似,为了生成列表和数组,init的替代方案可能是字面量,对于所有三种基本集合类型,替代方案可能是理解表达式。以下是一些示例 - vllval用于字面量,其余用于具有(vlcyvacyvscy)或没有使用(vlcvacvscyield构造的理解表达式,如下所示(Ch8_2.fsx):

let vll = [0; 2; 4; 6] 
// val vll : int list = [0; 2; 4; 6] 
let vlc = [ for i in 0..3 -> i * 2 ] 
// val vlc : int list = [0; 2; 4; 6] 
let vlcy = [ for i in 0..3 do yield i * 2 ] 
// val vlcy : int list = [0; 2; 4; 6] 
let ``val`` = 
    let src = "closure" in 
    [| src.[0]; src.[1]; src.[2]; src.[3]; src.[4]; src.[5]; src.[6] |] 
// val val : char [] = [|'c'; 'l'; 'o'; 's'; 'u'; 'r'; 'e'|] 
let vac = 
    let src = "closure" in 
    [| for i in 1..src.Length -> src.[i - 1] |] 
// val vac : char [] = [|'c'; 'l'; 'o'; 's'; 'u'; 'r'; 'e'|] 
let vacy = 
    let src = "closure" in 
    [| for i in 1..src.Length do 
       yield src.[i - 1] |> System.Char.ToUpper |] 
// val vacy : char [] = [|'C'; 'L'; 'O'; 'S'; 'U'; 'R'; 'E'|] 
let vsc = seq { for i in 0..2..6 -> i} 
// vsc;; 
// val it : seq<int> = seq [0; 2; 4; 6] 
let vscy = seq { for i in 0..2..6 do yield 6 - i } 
// vscy;; 
// val it : seq<int> = seq [6; 4; 2; 0] 

注意,在理解表达式中初始化集合元素值的表达式可以是任意复杂的;例如,在vacy的情况下,它从src闭包中按元素位置索引取值,并将相应的char数组元素转换为大写字母。

在进一步处理其他用例之前,让我更深入地探讨理解表达式。它们比迄今为止展示的更强大。我已在第六章 序列 - 数据处理模式的核心 中提到过这一点,当时在讨论序列表达式可能包含多个yield以及yield!出现的情况。当为列表和数组创建理解表达式时,你可以自由使用此功能,并且可以根据你的喜好使用递归。为了证明这一点,让我通过一个快速示例演示所有这些功能,构建一个生成器,用于在lohi之间的范围内生成伪随机整数的列表,长度为len,如下所示(Ch8_2.fsx):

let randoms lo hi len = 
    let r = System.Random() 
    let max = hi + 1 
    let rec generate n = [ 
        if n < len then 
            yield r.Next(lo, max) 
            yield! generate (n + 1) 
    ] 
    generate 0 

在以下屏幕截图中给出了通过模拟三次 20 投掷骰子的结果,用于 FSI 中的randoms烟雾测试:

生成已知大小的集合 - 元素可能具有不同的值

使用伪随机数生成器模拟投掷骰子的系列

生成未知大小的集合

有时,你可能会遇到需要生成一个在生成过程中要找到大小的集合的情况。在这种情况下,以下 F#核心库函数unfold会提供帮助,如下所示:

List.unfold<'T,'State> : generator:('State -> ('T * 'State) option) -> state:'State -> 'T list 
Array.unfold<'T,'State> : generator:('State -> ('T * 'State) option) -> state:'State -> 'T[] 
Seq.unfold   : generator:('State -> ('T * 'State) option) -> state:'State -> seq<'T> 

我已经在 第六章 中提供了一个非常简单的这个函数工作原理的例子,序列 - 数据处理模式的核心;在这里,我将其内部工作原理描述得淋漓尽致。unfold 函数逐个产生结果集合元素。对于每个元素,generator 函数将一个 'State 值作为输入参数,并以 option 值的形式产生结果。如果返回的 optionSome('T * 'State) 的形式,其中包含当前生成的集合元素值 'T 和下一次迭代的 'State 值,则此返回值表示序列展开将继续。否则,当 generator 函数返回 None 时,这意味着集合展开已经完成。

让我为这个用例提供一个充满例子的例子:所谓的柯尔萨茨猜想(en.wikipedia.org/wiki/Collatz_conjecture )。让我们考虑一个由简单规则构建的整数序列,该规则是将一个元素 n 移动到下一个元素 nn:如果 n 是偶数,则 nnn 除以 2;否则,它是 3 * n + 1。这个猜想本身是,对于任何初始的 n,这个由德国数学家洛塔尔·柯尔萨茨命名的序列最终会达到 1。例如,

42 -> 24 -> 12 -> 6 -> 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1

到目前为止,还没有找到任何起始数字会导致柯尔萨茨序列中有无限数量的元素。

首先,我从一个依赖于 unfold 库函数的柯尔萨茨序列生成器 collatzLib 函数的惯用实现开始,如下所示(Ch8_2.fsx):

let collatzLib n = 
    Seq.unfold (fun n -> match n with 
                            | 0L -> None 
                            | 1L -> Some(1L, 0L) 
                            | n when n % 2L = 0L -> Some(n, n/2L) 
                            | n -> Some(n, 3L * n + 1L)) n 

注意我用来将值 1 传递给集合的技巧,如果生成继续超过它,会导致循环 ...1 -> 4 -> 2 -> 1...。对于 1L 的状态,我生成了具有 1L 作为当前值和不可能的标记值 0LSome 选项。对于标记值,生成器产生 None,并且集合增长终止。另一个预防措施是在 int64 数字域中操作,因为即使是一些不是很大的初始数字也可能将 'State`` 超出 int` 域,这是我在生成器开始花费可疑地长时间完成时,从默认的 unchecked 转换到 checked F# 算术时发现的。

到目前为止,一切顺利。我很快就要尝试这个实现了。但你们中的一些人可能已经提出了这个问题:如果可以用序列表达式来实现,这有什么意义呢?答案已经在本章的开头给出了——性能。为了实验性地证明这个陈述,让我放下不使用 unfold 库函数的自定义柯尔萨茨序列生成器实现(Ch8_2.fsx):

let rec collatzCustom num =  
    seq { 
        yield num 
        match num with 
        | 1L -> () 
        | x when x % 2L = 0L ->yield! collatzCustom (x/2L) 
        | x ->yield! collatzCustom ((x * 3L) + 1L) 
        } 

现在,让我们运行 collatzLibcollatzCustom 以比较它们之间的差异。为此,让我们找出初始数字在 2 到 1000 之间最长的 Collatz 序列集合。这个练习是 Project Euler 问题 14 (projecteuler.net/problem=14 ) 的变体。编写性能测量代码并不像这里所示的那样困难(Ch8_2.fsx)。

[2L..1000000L] |> Seq.map (collatzLib >> Seq.length) |> Seq.max 

让我们比较前面代码的性能与这个代码(Ch8_2.fsx)的性能:

[2L..1000000L] |> Seq.map (collatzCustom >> Seq.length) |> Seq.max 

通过运行时间。比较如下截图所示:

生成未知大小的集合

比较基于库函数和自定义实现的性能

要记住的教训是,使用基于库的 collatzLib 函数的运行时间仅占使用自定义实现的 collatzCustom 函数所需时间的 63%

小贴士

除非你需要速度并且绝对确信你的自定义实现会提高性能,否则不要浪费时间重新实现 F# 核心库函数提供的功能!

生成无穷大小的集合

最后,我在生成模式下的最后一个用例中达到了:无穷大小的集合。显然,当我们考虑这个情况时,底层集合类型只能是唯一的序列,因为我们还不能依赖无穷的内存资源。用于生成无穷长度序列的 F# 核心库函数签名如下:

Seq.initInfinite: initializer:(int -> 'T) -> seq<'T> 

它与 init 并没有太大的区别;它只是缺少设置集合大小的输入参数。与 initInfinite 库函数并列的是自定义的无穷大小序列实现,使用序列表达式。

我已经在 第六章,序列 - 数据处理模式的核心 中涵盖了无穷大小序列的模式,并在那里提供了一些示例,以及在第七章 第七章。高级技术:函数回顾 中提供了一些高级示例,所以在这里我不会重复。这个用例结束了生成数据转换模式涵盖的多样性。

聚合模式

聚合模式可以通过以下活动识别,当遍历集合以得到类型为 'T' 的值时,类似于集合元素的类型 'T',它携带了所有遍历元素的一些累积影响。

泛型聚合

泛型聚合数据转换模式的签名方便地类似于表示聚合的成对具体库函数:reducereduceBack,如下所示:

List.reduce: reduction:('T -> 'T -> 'T) -> list:'T list -> 'T 
List.reduceBack: reduction:('T -> 'T -> 'T) -> list:'T list -> 'T 
Array.reduce: reduction:('T -> 'T -> 'T) -> array:'T[] -> 'T 
Array.reduceBack: reduction:('T -> 'T -> 'T) -> array:'T[] -> 'T 
Seq.reduce: reduction:('T -> 'T -> 'T) -> source:seq<'T> -> 'T 

如果你还记得 第七章,高级技巧:函数回顾,前面的代码几乎与 folds 一样通用;区别在于,通过 fold 在集合中传递的状态可以是任何任意类型,不一定与集合元素类型相同,而 reduce 处理的是相同类型。用 fold 实现 reduce 很容易,但反过来则不然。

reduce 函数将 reduction 操作从集合的开始应用到结束;如果我用 r 表示 reduction 函数,那么对于 reduce 在数组集合 c 上的特殊情况,它将等同于这里所示的表达式:

...r (r (r c.[0] c.[1]) c.[2]) c.[3]... 

相反,reduceBack 从集合的右侧应用 reduction 操作;如果我用 r 再次表示 reduction 函数,那么对于 reduceBackn+1 个元素的数组集合 c 上的特殊情况,它将等同于这里所示的表达式:

... (r c.[n - 3] (r c.[n - 2] (r c.[n - 1] c.[n])) ... 

很容易注意到,对于具有结合性 (en.wikipedia.org/wiki/Associative_property ) 的 reduction 操作,reducereduceBack 在同一集合上的结果将是相同的,这可以通过以下简单测试得到证实 (Ch8_3.fsx ):

// associative operation min 
List.reduce min [1;2;3;4;5] 
// val it : int = 1 
List.reduceBack min [1;2;3;4;5] 
// val it : int = 1 

// non-associative operation (-) 
List.reduce (-) [1;2;3;4;5] 
// val it : int = -13 
List.reduceBack (-) [1;2;3;4;5] 
// val it : int = 3 

我想指出正在发生的不对称性:库中没有现成的 reduceBack 用于序列。

所有其他库聚合函数只是可以用 reduce 表达的聚合的特殊实现。在考虑它们之前,我想指出另一个模式:不是在原始元素类型 'T 上执行聚合,而是将每个集合元素投影到其他类型 'U 并在 'U 上进行聚合。

直接聚合

这个聚合库函数组的成员直接在集合元素类型 'T 上执行聚合,例如具有以下签名的库成员:

List.average : list:^T list -> ^T     
    when ^T : (static member ( + ) : ^T * ^T -> ^T)  
    and  ^T : (static member DivideByInt : ^T*int -> ^T)  
    and  ^T : (static member Zero : ^T) 
Array.average : array:^T[] -> ^T     
    when ^T : (static member ( + ) : ^T * ^T -> ^T)  
    and  ^T : (static member DivideByInt : ^T*int -> ^T)  
    and  ^T : (static member Zero : ^T) 
Seq.average : source:seq<(^T)> -> ^T     
    when ^T : (static member ( + ) : ^T * ^T -> ^T)  
    and  ^T : (static member DivideByInt : ^T*int -> ^T)  
    and  ^T : (static member Zero : ^T) 
List.max : list:'T list -> 'T when 'T : comparison 
Array.max : array:'T[] -> 'T  when 'T : comparison 
Seq.max : source:seq<'T> -> 'T when 'T : comparison 
List.min : list:'T list -> 'T when 'T : comparison 
Array.min : array:'T[] -> 'T  when 'T : comparison 
Seq.min : source:seq<'T> -> 'T when 'T : comparison 
List.sum : list:^T list -> ^T  
    when ^T : (static member ( + ) : ^T * ^T -> ^T)  
    and  ^T : (static member Zero : ^T) 
Array.sum : array: ^T[] -> ^T  
    when ^T : (static member ( + ) : ^T * ^T -> ^T)  
    and  ^T : (static member Zero : ^T) 
Seq.sum : source:seq<(^T)> -> ^T  
    when ^T : (static member ( + ) : ^T * ^T -> ^T)  
    and  ^T : (static member Zero : ^T) 

根据签名,你可能会注意到,库的聚合函数在聚合有意义的情况下对集合类型 'T 引入了静态约束。例如,显然,如果 'T 不支持比较,则不能在类型 'T 上执行最大聚合。

投影聚合

投影聚合库函数,不是在原始集合元素上执行聚合,而是首先将它们从类型 'T 投影到其他类型 'U,然后才在 'U 值上执行聚合。以下是签名:

List.averageBy : projection:('T -> ^U) -> list:'T list  -> ^U     
    when ^U : (static member ( + ) : ^U * ^U -> ^U)  
    and  ^U : (static member DivideByInt : ^U*int -> ^U)  
    and  ^U : (static member Zero : ^U) 
Array.averageBy : projection:('T -> ^U) -> array:'T[] -> ^U     
    when ^U : (static member ( + ) : ^U * ^U -> ^U)  
    and  ^U : (static member DivideByInt : ^U*int -> ^U)  
    and  ^U : (static member Zero : ^U) 
Seq.averageBy : projection:('T -> ^U) -> source:seq<'T>  -> ^U     
    when ^U : (static member ( + ) : ^U * ^U -> ^U)  
    and  ^U : (static member DivideByInt : ^U*int -> ^U)  
    and  ^U : (static member Zero : ^U) 
List.maxBy : projection:('T -> 'U) -> list:'T list -> 'T 
    when 'U : comparison 
Array.maxBy  : projection:('T -> 'U) -> array:'T[] -> 'T 
    when 'U : comparison 
Seq.maxBy : projection:('T -> 'U) -> source:seq<'T> -> 'T 
    when 'U : comparison 
List.minBy : projection:('T -> 'U) -> list:'T list -> 'T 
    when 'U : comparison 
Array.minBy : projection:('T -> 'U) -> array:'T[] -> 'T 
    when 'U : comparison 
Seq.minBy : projection:('T -> 'U) -> source:seq<'T> -> 'T 
    when 'U : comparison 
List.sumBy : projection:('T -> ^U) -> list:'T list -> ^U  
    when ^U : (static member ( + ) : ^U * ^U -> ^U)  
    and  ^U : (static member Zero : ^U) 
Array.sumBy : projection:('T -> ^U) -> array:'T[] -> ^U  
    when ^U : (static member ( + ) : ^U * ^U -> ^U)  
    and  ^U : (static member Zero : ^U) 
Seq.sumBy : projection:('T -> ^U) -> source:seq<'T>  -> ^U  
    when ^U : (static member ( + ) : ^U * ^U -> ^U)  
    and  ^U : (static member Zero : ^U) 

在考虑投影聚合时,应该提到一点复杂性——虽然 averageBysumBy 返回类型为 'U 的结果,但 maxByminBy 返回 'T。请参考以下代码示例,它突出了提到的细节 (Ch8_3.fsx ):

List.sumBy (fun x -> -x) [1;2;3] 
// val it : int = -6 
List.minBy (fun x -> -x) [1;2;3] 
// val it : int = 3 

计数聚合

聚合数据转换模式的剩余两个函数执行集合元素的计数。

第一项是那个古老的length函数:

List.length: list:'T list -> int 
Array.length: array:'T[] -> int 
Seq.length: source:seq<'T> -> int 

这里没有隐藏的惊喜。只需认识到Seq.length遍历source序列,并且当应用于无限长度的序列时,最终会崩溃。

另一个,countBy,更复杂一些:

List.countBy projection:('T -> 'Key) -> list:'T list 
    -> ('Key * int) list 
    when 'Key : equality 
Array.countBy : projection:('T -> 'Key) -> array:'T[] 
    -> ('Key * int)[] 
    when 'Key : equality 
Seq.countBy projection:('T -> 'Key) -> source:seq<'T> 
    -> seq<'Key * int> 
    when 'Key : equality 

这个高阶函数对每个元素值'T应用projection,将其转换为'Key值,计算投影到每个唯一'Key的元素数量,最后将分布作为元组集合('Key', 数量)返回。让我做一个相当有趣的观察。在本章的开头,在生成已知大小的集合中,我们实现了一个伪随机数序列生成器randoms。让我们看看通过构建一系列长投掷并分箱每个得分,期望分箱大小的偏差在统计上不显著,来模拟抛掷骰子的“随机性”。

以下代码片段模拟了抛掷一百万次骰子;因此,每个六进制结果应该有大约 166,600 次命中。让我们看看... (Ch8_3.fsx ):

randoms 1 6 10000000 
|> Seq.countBy id 
|> Seq.toList 
|> printfn "%A" 

在 FSI 中运行前面代码的结果如下所示:

计数聚合

使用countBy检查伪随机数生成器的质量

根据前面截图显示的结果,我的直觉是,底层的伪随机数生成器在模拟骰子方面还不错。而且它还相当快:生成和分箱一百万次试验的系列只用了 2 秒多。

包装和类型转换模式

属于这种数据转换模式的库函数分为两组如下:

  • 那些包装整个集合,改变其行为的操作

  • 简单地将集合从一种基本类型转换为另一种类型的操作

集合包装模式

属于这个模式的函数只有三个。它们都只适用于序列,并且具有以下签名:

Seq.cache: source:seq<'T> -> seq<'T> 
Seq.delay: generator:(unit -> seq<'T>) -> seq<'T> 
Seq.readonly : source:seq<'T> -> seq<'T> 

我已经在第六章中介绍了Seq.cache函数,序列 - 数据处理模式的核心,并在第七章的质数生成器示例中也使用了它,高级技术:函数回顾,所以我就不再多花时间在这上面了,让我们继续到下一对。

Seq.delay 允许你推迟对包装的 generator 函数的急切评估。评估将推迟到包装器被枚举时。在下面的代码片段中,存在一个急切列表解析,如果被评估,将立即打印 "Evaluating eagerList",然后返回 strings 列表。然而,由于被包装进 Seq.delay,它不会评估,直到包装器本身被具体化(Ch8_4.fsx):

let eagerList = [ 
    printfn "Evaluating eagerList" 
    yield "I" 
    yield "am" 
    yield "an" 
    yield "eager" 
    yield "list" 
] 
// Evaluating eagerList 
// val eagerList : string list = ["I"; "am"; "an"; "eager"; "list"] 
let delayedEagerList = Seq.delay(fun () -> ([ printfn "Evaluating 
                                              eagerList" 
                                            yield "I" 
                                            yield "am" 
                                            yield "an" 
                                            yield "eager" 
                                            yield "list" 
                                        ] |> Seq.ofList)) 
// val delayedEagerList : seq<string> 

delayedEagerList |> Seq.toList 
// Evaluating eagerList 
// val it : string list = ["I"; "am"; "an"; "eager"; "list"] 

前一个脚本中的注释行展示了之前描述的预期行为实际上正在发生。

Seq.readonly 在原始集合周围构建了一个包装序列,这不允许你通过类型转换重新发现和修改它。在下面的代码片段中,通过向上转换和向下转换,可以创建一个后门,并使用它帮助修改原始可变集合(Ch8_4.fsx):

let src = [|1;2;3|] 
let srcAsSeq = src :> seq<_> 
let backdoor = srcAsSeq :?> int array 
backdoor.[0] <- 10 
printfn "%A" src 
// [|10; 2; 3|] 

现在,如果 src 被包装进 Seq.readonly,尝试将序列向下转换为 int [] 将会引发类型转换异常,如下面的代码所示(Ch8_4.fsx):

let srcAsROSeq = src |> Seq.readonly 
let tryBackDoor = srcAsROSeq :?> int array 
// System.InvalidCastException: Unable to cast object of type 'mkSeq@541[System.Int32]' to type 'System.Int32[]'. 

类型转换模式

属于类型转换模式的库函数提供了基集合类型之间的对称转换,如下所示:

List.toSeq list:'T list -> seq<'T> 
List.ofSeq: source:seq<'T> -> 'T list 
List.toArray: list:'T list -> 'T[] 
List.ofArray : array:'T[] -> 'T list 
Array.toSeq: array:'T[] -> seq<'T> 
Array.ofSeq: source:seq<'T> -> 'T[] 
Array.toList: array:'T[] -> 'T list 
Array.ofList: list:'T list -> 'T[] 
Seq.toList: source:seq<'T> -> 'T list 
Seq.ofList: source:'T list -> seq<'T> 
Seq.toArray: source:seq<'T> -> 'T[] 
Seq.ofArray: source:'T[] -> seq<'T> 

这些函数非常直接,不需要额外的注释。

除了这些之外,还有一个函数可以将松散类型化的序列从旧式预泛型 System.Collections 命名空间转换为类型化序列:

Seq.cast: source:IEnumerable -> seq<'T> 

这种转换在 F# 与旧式 Microsoft 系统之间的互操作性场景中很常见,以便将它们转换为 F# 友好的强类型序列。作为一个例子,让我们看看下面的代码片段(Ch8_4.fsx):

let s = System.Collections.Stack() 
s.Push(1) 
s.Push('2') 
s.Push("xyzzy") 
s |> Seq.cast<_> |> printfn "%A" 
// seq ["xyzzy"; '2'; 1] 

在这里,你可以看到松散类型化的 Stack 集合被转换为强类型化的 F# 序列并打印出来。输出显示包含不同类型的 F# 序列:stringcharint。但是序列是强类型化的,对吧?你能确定前面序列的类型吗?

选取模式

这种数据转换模式可以通过根据某些特征(s)从集合中分离一个或多个元素来识别。这些特征可能非常多样:元素的位置、与标准匹配的元素值,仅举几例。

区分选取转换模式与其他模式的真正特征如下:选取结果始终是单个元素或单个包含从零到原始集合所有元素的集合;选取是原样提供的,没有任何额外的投影

这种看似广泛的转换类实际上只有少数几个子类:基于位置的选取、搜索和过滤。

基于位置的 选取 将元素选取标准与原始集合中元素的位置绑定;例如,选取集合的前 10 个元素。

搜索过滤确实是普遍的数据集合转换。尽管这两个转换非常相似,但它们之间有一个细微的区别,如下所述。

过滤通常与将源集合逐个元素地复制到结果集合相关,筛选出所有不符合给定标准的元素。

转到搜索,它通常与一个更复杂的过程相关。搜索的初始状态由原始集合、最初为空的搜索结果和搜索条件组成。搜索过程也会逐个元素遍历原始集合,应用搜索条件并塑造搜索结果。然而,搜索可能不仅包含匹配条件,还可能包含某种停止条件,也许还有一些排名。一个典型的搜索例子是这样的:“找到满足条件(s)的任何集合元素”。

基于此差异,我将搜索放在一个单独的选取模式中,但将过滤视为 元素组选择 模式的一部分。

基于位置的选取模式

构成此模式的 F# 核心库函数可以进一步分为两组:单个元素选择和元素组选择。

单个元素选择

这组函数通过元素在集合中占有的位置确定单个所需元素。位置可以通过输入参数显式请求,也可以通过相关函数名隐式请求。为了理解我的意思,请比较“给我第三个元素”与“给我最后一个元素”。单个元素选择返回所需的元素或表示不存在这样的元素:

List.head: list:'T list -> 'T 
Array.head: array:'T[] -> 'T 
Seq.head: source:seq<'T> -> 'T 

List.tryHead: list:'T list -> 'T option 
Array.tryHead: array:'T[] -> 'T option  
Seq.tryHead: source:seq<'T> -> 'T option 

List.last: list:'T list -> 'T 
Array.last: list:'T list -> 'T 
Seq.last: source:seq<'T> -> 'T 

List.tryLast: list:'T list -> 'T option 
Array.tryLast: list:'T list -> 'T option 
Seq.tryLast: source:seq<'T> -> 'T option 

List.item: index:int -> list:'T list -> 'T 
Array.item: index:int -> array:'T[] -> 'T 
Array.get: array:'T[] -> index:int -> 'T 
Seq.item: index:int -> source:seq<'T> -> 'T 

List.tryItem: index:int -> list:'T list -> 'T option 
Array.tryItem: index:int -> array:'T[] -> 'T option 
Seq.tryItem: index:int -> source:seq<'T> -> 'T option 

List.nth: list:'T list -> index:int -> 'T // obsolete 
Seq.nth: index:int -> source:seq<'T> -> 'T // obsolete 

List.exactlyOne: list:'T list -> 'T 
Array.exactlyOne: array:'T[] -> 'T 
Seq.exactlyOne: source:seq<'T> -> 'T 

注意这个组中的成员如何通过指示不成功的选择方式来区分。有些简单地抛出异常,而另一些则将选择结果包装在一个 option 中,其中 None 表示所寻求的元素不存在:(Ch8_5.fsx

List.head<int> [] 
// System.ArgumentException: The input list was empty. 
List.tryHead<int> [] 
// val it : int option = None 

关于以 try... 开头的函数名:这些函数允许减轻请求的元素可能缺失的风险,并很好地处理这种不幸的情况。

提示

使用选择操作的命令式形式时请谨慎。如果你绝对不确定请求的元素是否存在且不变,则退回到 try 形式。

还要注意,对于支持元素索引的数据集合,通常简单的索引使用就完成了专用库函数的工作,如下面的代码所示(Ch8_5.fsx):

let ll = [1;2;3;4] 
List.head ll = ll.[0] 
//val it : bool = true 

元素组选择

此集合转换子模式根据一系列标准安排从集合中获取一组元素:它可以是一个元素计数器,一个查看元素值的谓词,一个不希望出现的值集合,或者排除重复值:

List.take: count:int -> list:'T list -> 'T list 
Array.take: count:int -> array:'T[] -> 'T[] 
Seq.take: count:int -> source:seq<'T> -> seq<'T> 

List.takeWhile: predicate:('T -> bool) -> list:'T list -> 'T list 
Array.takeWhile: predicate:('T -> bool) -> array:'T[] -> 'T[] 
Seq.takeWhile: predicate:('T -> bool) -> source:seq<'T> -> seq<'T> 

List.truncate: count:int -> list:'T list -> 'T list 
Array.truncate: count:int -> array:'T[] -> 'T[] 
Seq.truncate: count:int -> source:seq<'T> -> seq<'T> 

List.skip: count:int -> list: 'T list -> 'T list 
Array.skip: count:int -> array:'T[] -> 'T[] 
Seq.skip: count:int -> source:seq<'T> -> seq<'T> 

List.skipWhile: predicate:('T -> bool) -> list:'T list -> 'T list 
Array.skipWhile: predicate:('T -> bool) -> array:'T[] -> 'T[] 
Seq.skipWhile: predicate:('T -> bool) -> source:seq<'T> -> seq<'T> 

List.tail: list:'T list -> 'T list 
Array.tail: array:'T[] -> 'T[] 
Seq.tail: source:seq<'T> -> seq<'T> 

List.filter: predicate:('T -> bool) -> list:'T list -> 'T list 
Array.filter: predicate:('T -> bool) -> array:'T[] -> 'T[] 
Seq.filter: predicate:('T -> bool) -> source:seq<'T> -> seq<'T> 

List.except: itemsToExclude:seq<'T> -> list:'T list -> 'T list when 'T : equality 
Array.except: itemsToExclude:seq<'T> -> array:'T[] -> 'T[] when 'T : equality 
Seq.except: itemsToExclude:seq<'T> -> source:seq<'T> -> seq<'T> when 'T : equality 

List.choose: chooser:('T -> 'U option) -> list:'T list -> 'U list 
Array.choose: chooser:('T -> 'U option) -> array:'T[] -> 'U[] 
Seq.choose: chooser:('T -> 'U option) -> source:seq<'T> -> seq<'U> 

List.where: predicate:('T -> bool) -> list:'T list -> 'T list 
Array.where: predicate:('T -> bool) -> array:'T[] -> 'T[] 
Seq.where: predicate:('T -> bool) -> source:seq<'T> -> seq<'T> 

Array.sub: array:'T[] -> startIndex:int -> count:int -> 'T[] 

List.distinct: list:'T list -> 'T list when 'T : equality 
Array.distinct: array:'T[] -> 'T[] when 'T : equality 
Seq.distinct: source:seq<'T> -> seq<'T> when 'T : equality 

List.distinctBy: projection:('T -> 'Key) -> list:'T list -> 'T list when 'Key : equality 
Array.distinctBy: projection:('T -> 'Key) -> array:'T[] -> 'T[] when 'Key : equality 
Seq.distinctBy: projection:('T -> 'Key) -> source:seq<'T> -> seq<'T> when 'Key : equality 

提示

注意,元素组选择 模式的组成部分是普遍存在的 filter 函数。

与之前实现索引切片的集合子模式类似,这是另一种元素组选择的方法(Ch8_5.fsx):

[|10;20;30;40;50;60|].[2..4] 
// val it : int [] = [|30; 40; 50|] 

你可能也会注意到,更通用的 filter 函数伴随着更具体的过滤情况,例如 takeWhileskipWhile,或者就像这里所示(Ch8_5.fsx)的 where 同义词:

let numbers = [1;2;3;4;5;6;7;8] 
List.filter (fun x -> (%) x 2 = 0) numbers = List.where (fun x -> (%) x 2 = 0) numbers 
// val it : bool = true 

搜索模式

F# 4.0 核心库提供了一套非常规范化的函数,构成了 搜索 模式,其中函数的名称确实包含了函数工作的详尽特征。

所有名称中包含 ...find... 的函数都执行搜索第一个出现的单个元素,而 ...findIndex... 则执行相同的搜索,但返回该元素在集合中的序号。

名称中包含 ...Back... 的函数在元素的自然顺序的反方向进行搜索。

与已经检查过的选择模式组类似,搜索模式的库函数实现了两种表示 "未找到" 搜索结果的方法:那些没有 try... 前缀的,如果搜索返回空结果,则抛出异常;而那些带有 try... 前缀的,在这种情况下返回 None 选项;否则,它返回包含在 Some... 选项中的找到的元素,如下所示:

List.find: predicate:('T -> bool) -> list:'T list -> 'T 
Array.find: predicate:('T -> bool) -> array:'T[] -> 'T 
Seq.find: predicate:('T -> bool) -> source:seq<'T> -> 'T 

List.tryFind: predicate:('T -> bool) -> list:'T list -> 'T option 
Array.tryFind: predicate:('T -> bool) -> array:'T[] -> 'T option 
Seq.tryFind: predicate:('T -> bool) -> source:seq<'T> -> 'T option 

List.findIndex: predicate:('T -> bool) -> list:'T list -> int 
Array.findIndex: predicate:('T -> bool) -> array:'T[] -> int 
Seq.findIndex: predicate:('T -> bool) -> source:seq<'T> -> int 

List.tryFindIndex: predicate:('T -> bool) -> list:'T list -> int option 
List.tryFindIndexBack: predicate:('T -> bool) -> list:'T list -> int option 

List.findBack: predicate:('T -> bool) -> list:'T list -> 'T 
Array.findBack: predicate:('T -> bool) -> array:'T[] -> 'T 
Seq.findBack: predicate:('T -> bool) -> source:seq<'T> -> 'T 

List.tryFindBack: predicate:('T -> bool) -> list:'T list -> 'T option 
Array.tryFindBack: predicate:('T -> bool) -> array:'T[] -> 'T option 
Seq.tryFindBack: predicate:('T -> bool) -> source:seq<'T> -> 'T option 

List.findIndexBack: predicate:('T -> bool) -> list:'T list -> int 
Array.findIndexBack: predicate:('T -> bool) -> array:'T[] -> int 
Seq.findIndexBack: predicate:('T -> bool) -> source:seq<'T> -> int 

List.pick: chooser:('T -> 'U option) -> list:'T list -> 'U 
Array.pick: chooser:('T -> 'U option) -> array:'T[] -> 'U  
Seq.pick: chooser:('T -> 'U option) -> source:seq<'T> -> 'U 

List.tryPick: chooser:('T -> 'U option) -> list:'T list -> 'U option 
Array.tryPick: chooser:('T -> 'U option) -> array:'T[] -> 'U option 
Seq.tryPick: chooser:('T -> 'U option) -> source:seq<'T> -> 'U option 

让我们通过以下列出的小工具来展示其作用(Ch8_5.fsx):

List.find (fun x -> (%) x 2 = 0) <| [1;3;5] 
// System.Collections.Generic.KeyNotFoundException: 
// Exception of type 'System.Collections.Generic.KeyNotFoundException' was thrown. 
List.tryFind (fun x -> (%) x 2 = 0) <| [1;3;5] 
// val it : int option = None 
List.find (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int = 1 
List.tryFind (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int option = Some 1 
List.findIndex (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int = 0 
List.tryFindIndex (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int option = Some 0 
List.findBack (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int = 5 
List.tryFindBack (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int option = Some 5 
List.findIndexBack (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int = 2 
List.tryFindIndexBack (fun x -> (%) x 2 <> 0) <| [1;3;5] 
// val it : int option = Some 2 

与这种非常逻辑的安排略有不同的是 (try)pick 函数组。属于此组的函数将搜索和转换功能结合起来:chooser 函数应用于类型 'T' 的每个元素,产生 None,直到第一个元素以某种方式匹配 chooser 内部表达的选择标准。然后,Some 将潜在的、不同类型的 'U' 包裹起来并返回,并且高阶函数返回类型为 'U' 的结果。如果 chooser 找不到任何合适的元素,则 pick 抛出异常,而 tryPick 返回 NoneCh8_5.fsx):

[(9,"Nine");(42,"FortyTwo");(0,"Zero")] 
|> List.pick (fun (x,y) -> if x = 42 then Some y else None) 
// val it : string = "FortyTwo" 
[(9,"Nine");(42,"FortyTwo");(0,"Zero")] 
|> List.tryPick (fun (x,y) -> if x = 42 then Some y else None) 
// val it : string option = Some "FortyTwo" 
[(9,"Nine");(42,"FortyTwo");(0,"Zero")] 
|> List.pick (fun (x,y) -> if x = 14 then Some y else None) 
// System.Collections.Generic.KeyNotFoundException: 
// Exception of type 'System.Collections.Generic.KeyNotFoundException' was thrown. 
[(9,"Nine");(42,"FortyTwo");(0,"Zero")] 
|> List.tryPick (fun (x,y) -> if x = 14 then Some y else None) 
// val it : string option = None 

请注意上述函数如何通过在遍历集合时同时应用这两个动作,在一定程度上融合了选择和转换。

分区模式

分区 模式的 F# 核心库元素消耗单个集合,通常返回多个结果集合,如下所示:

List.chunkBySize: chunkSize:int -> list:'T list -> 'T list list 
Array.chunkBySize: chunkSize:int -> array:'T[] -> 'T[][] 
Seq.chunkBySize: chunkSize:int -> source:seq<'T> -> seq<'T[]> 

List.groupBy : projection:('T -> 'Key) -> list:'T list -> ('Key * 'T list) list when 'Key : equality 
Array.groupBy: projection:('T -> 'Key) -> array:'T[] -> ('Key * 'T[])[]  when 'Key : equality 
Seq.groupBy : projection:('T -> 'Key) -> source:seq<'T> -> seq<'Key * seq<'T>> when 'Key : equality 

List.pairwise: list:'T list -> ('T * 'T) list 
Array.pairwise: array:'T[] -> ('T * 'T)[] 
Seq.pairwise: source:seq<'T> -> seq<'T * 'T> 

List.partition: predicate:('T -> bool) -> list:'T list -> ('T list * 'T list) 
Array.partition: predicate:('T -> bool) -> array:'T[] -> 'T[] * 'T[] 

List.splitAt: index:int -> list:'T list -> ('T list * 'T list) 
Array.splitAt: index:int -> array:'T[] -> ('T[] * 'T[]) 

List.splitInto: count:int -> list:'T list -> 'T list list 
Array.splitInto: count:int -> array:'T[] -> 'T[][] 
Seq.splitInto: count:int -> source:seq<'T> -> seq<'T[]> 

List.windowed: windowSize:int -> list:'T list -> 'T list list 
Array.windowed: windowSize:int -> array:'T[] -> 'T[][] 
Seq.windowed: windowSize:int -> source:seq<'T> -> seq<'T[]> 

以下是一些先前函数的简单用法示例(Ch8_6.fsx):

List.chunkBySize 2 ['a'..'g'] 
// val it : char list list = [['a'; 'b']; ['c'; 'd']; ['e'; 'f']; ['g']] 

List.groupBy (fun n -> n / 3) [1..7] 
// val it : (int * int list) list = [(0, [1; 2]); (1, [3; 4; 5]); (2, [6; 7])] 

List.pairwise [1..2..10] 
// val it : (int * int) list = [(1, 3); (3, 5); (5, 7); (7, 9)] 

["angle";"delta";"cheese";"America"] 
|> List.partition (fun (x:string) -> (System.Char.ToUpper x.[0]) = 'A') 
// val it : string list * string list = 
//  (["angle"; "America"], ["delta"; "cheese"]) 

["angle";"delta";"cheese";"America"] 
|> List.splitAt 2 
// val it : string list * string list = 
//  (["angle"; "delta"], ["cheese"; "America"]) 

["angle";"delta";"cheese";"America"] 
|> List.splitInto 3 
// val it : string list list = 
//   [["angle"; "delta"]; ["cheese"]; ["America"]] 

["angle";"delta";"cheese";"America"] 
|> List.windowed 2 
// val it : string list list = 
//   [["angle"; "delta"]; ["delta"; "cheese"]; ["cheese"; "America"]] 

重新排序模式

这组 F# 核心库函数代表了 重新排序 数据转换模式,通过多种形式的排序、反转和排列来改变集合中元素的位置:

List.rev: list:'T list -> 'T list 
Array.rev: array:'T[] -> 'T[] 
Seq.rev: source:seq<'T> -> seq<'T> 

List.sort: list:'T list -> 'T list when 'T : comparison 
Array.sort: array:'T[] -> 'T[] when 'T : comparison 
Seq.sort: source:seq<'T> -> seq<'T> when 'T : comparison 

List.sortDescending: list:'T list -> 'T list when 'T : comparison 
Array.sortDescending: array:'T[] -> 'T[] when 'T : comparison 

List.sortBy: projection:('T -> 'Key) -> list:'T list -> 'T list when 'Key : comparison 
Array.sortBy: projection:('T -> 'Key) -> array:'T[] -> 'T[] when 'Key : comparison 
Seq.sortBy: projection:('T -> 'Key) -> source:seq<'T> -> seq<'T> when 'Key : comparison 

List.sortByDescending: projection:('T -> 'Key) -> list:'T list -> 'T list when 'Key : comparison 
Array.sortByDescending: projection:('T -> 'Key) -> array:'T[] -> 'T[] when 'Key : comparison 
Seq.sortByDescending : projection:('T -> 'Key) -> source:seq<'T> -> seq<'T> when 'Key : comparison 

List.sortWith: comparer:('T -> 'T -> int) -> list:'T list -> 'T list 
Array.sortWith: comparer:('T -> 'T -> int) -> array:'T[] -> 'T[] 
Seq.sortWith : comparer:('T -> 'T -> int) -> source:seq<'T> -> seq<'T> 

List.permute : indexMap:(int -> int) -> list:'T list -> 'T list 
Array.permute : indexMap:(int -> int) -> array:'T[] -> 'T[] 
Seq.permute: indexMap:(int -> int) -> source:seq<'T> -> seq<'T> 

Array.sortInPlace: array:'T[] -> unit when 'T : comparison 
Array.sortInPlaceBy: projection:('T -> 'Key) -> array:'T[] -> unit when 'Key : comparison 
Array.sortInPlaceWith: comparer:('T -> 'T -> int) -> array:'T[] -> unit 

以下是一些重新排序转换的示例(Ch8_7.fsx):

List.sort [1;8;3;6;4;-2] 
// val it : int list = [-2; 1; 3; 4; 6; 8] 
List.sortDescending [1;8;3;6;4;-2] 
// val it : int list = [8; 6; 4; 3; 1; -2] 
List.sortBy (fun x -> x.GetHashCode()) ["Fourteen";"Zero";"Forty Two"] 
// val it : string list = ["Zero"; "Forty Two"; "Fourteen"] 

考虑到一些函数通过修改输入集合来执行重新排序。这些函数限于ArraysortInPlacesortInPlaceBysortInPlaceWith

测试模式

这是一个非常直接的模式。测试库函数而不是转换输入集合,总是返回bool结果:如果某些属性存在,则返回true,否则返回false。它们可能检查给定的集合是否包含给定的元素,是否存在具有该值的元素,将给定的谓词转换为true,是否所有元素都将给定的谓词转换为true,或者输入集合是否为空,如它们的签名所示:

List.contains: value:'T -> source:'T list -> bool when 'T : equality 
Array.contains: value:'T -> array:'T[] -> bool when 'T : equality 
Seq.contains: value:'T -> source:seq<'T> -> bool when 'T : equality 

List.exists: predicate:('T -> bool) -> list:'T list -> bool 
Array.exists: predicate:('T -> bool) -> array:'T[] -> bool 
Seq.exists: predicate:('T -> bool) -> source:seq<'T> -> bool 

List.exists2: predicate:('T1 -> 'T2 -> bool) -> list1:'T1 list -> list2:'T2 list -> bool 
Array.exists2: predicate:('T1 -> 'T2 -> bool) -> array1:'T1[] -> array2:'T2[] -> bool 
Seq.exists2: predicate:('T1 -> 'T2 -> bool) -> source1:seq<'T1> -> <'T2> -> bool 

List.forall: predicate:('T -> bool) -> list:'T list -> bool 
Array.forall: predicate:('T -> bool) -> array:'T[] -> bool 
Seq.forall: predicate:('T -> bool) -> source:seq<'T> -> bool 

List.forall2: predicate:('T1 -> 'T2 -> bool) -> list1:'T1 list -> list2:'T2 list -> bool 
Array.forall2: predicate:('T1 -> 'T2 -> bool) -> array1:'T1[] -> array2:'T2[] -> bool 
Seq.forall2: predicate:('T1 -> 'T2 -> bool) -> source1:seq<'T1> -> source2:seq<'T2> -> bool 

List.isEmpty: list:'T list -> bool 
Array.isEmpty: array:'T[] -> bool 
Seq.isEmpty: source:seq<'T> -> bool 

List.compareWith: comparer:('T -> 'T -> int) -> list1:'T list -> list2:'T list -> int 
Array.compareWith: comparer:('T -> 'T -> int) -> array1:'T[] -> array2:'T[] -> int 
Seq.compareWith: comparer:('T -> 'T -> int) -> source1:seq<'T> -> source2:seq<'T> -> int 

表示此模式的函数意图如此明显,以至于我甚至没有提供它们的用法示例;这些示例可以在 F#核心库文档中轻松找到。

迭代模式

这又是一种非常直接的数据转换模式。实际上,迭代模式并不引入任何明显的转换,而是仅仅表示集合的遍历。它的成员函数总是返回unit。在每次单独的遍历步骤中,对当前元素执行的操作都隐藏在action函数之后。

这种数据转换的方式会让我们鲜明地联想到命令式和面向对象范式,因为action有效地隐藏了正在发生的事情,并且也必须利用一些副作用才能在实际中发挥作用。那些大量(滥用)迭代数据转换模式的 F#程序通常表明,它们的作者仍然受制于一种非函数式思维方式。

表示迭代模式的函数的签名如下:

List.iter: action:('T -> unit) -> list:'T list -> unit 
Array.iter: action:('T -> unit) -> array:'T[] -> unit 
Seq.iter: action:('T -> unit) -> source:seq<'T> -> unit 

List.iter2: action:('T1 -> 'T2 -> unit) -> list1:'T1 list -> list2:'T2 list -> unit 
Array.iter2: action:('T1 -> 'T2 -> unit) -> array1:'T1[] -> array2:'T2[] -> unit 
Seq.iter2: action:('T1 -> 'T2 -> unit) -> source1:seq<'T1> -> source2:seq<'T2> -> unit 

List.iteri: action:(int -> 'T -> unit) -> list:'T list -> unit 
Array.iteri: action:(int -> 'T -> unit) -> array:'T[] -> unit 
Seq.iteri: action:(int -> 'T -> unit) -> source:seq<'T> -> unit 

List.iteri2: action:(int -> 'T1 -> 'T2 -> unit) -> list1:'T1 list -> list2:'T2 list -> unit 
Array.iteri2: action:(int -> 'T1 -> 'T2 -> unit) -> array1:'T1[] -> array2:'T2[] -> unit 
Seq.iteri2: action:(int -> 'T1 -> 'T2 -> unit) -> source1:seq<'T1> -> source2:seq<'T2> -> unit 

注意,该模式的库成员函数展示了一定程度的规范化。操作可能涉及元素(iteriter2),或者元素和索引(iteriiteri2),也可能涉及单个集合(iteriteri)或一对集合(iter2iteri2)。

与测试模式一样,在互联网上找到这些函数用法的示例并不成问题。

映射模式

映射构成了数据转换的核心,将一个或多个输入元素映射到单个结果,然后将此投影应用于整个输入集合(产生结果集合),如下成员函数签名所示:

List.map: mapping:('T -> 'U) -> list:'T list -> 'U list 
Array.map: mapping:('T -> 'U) -> array:'T[] -> 'U[] 
Seq.map: mapping:('T -> 'U) -> sequence:seq<'T> -> seq<'U> 

List.map2: mapping:('T1 -> 'T2 -> 'U) -> list1:'T1 list -> list2:'T2 list -> 'U list 
Array.map2: mapping:('T1 -> 'T2 -> 'U) -> array1:'T1[] -> array2:'T2[] -> 'U[] 
Seq.map2: mapping:('T1 -> 'T2 -> 'U) -> source1:seq<'T1> -> source2:seq<'T2> -> seq<'U> 

List.mapi: mapping:(int -> 'T -> 'U) -> list:'T list -> 'U list 
Array.mapi: mapping:(int -> 'T -> 'U) -> array:'T[] -> 'U[] 
Seq.mapi: mapping:(int -> 'T -> 'U) -> source:seq<'T> -> seq<'U> 

List.mapi2: mapping:(int -> 'T1 -> 'T2 -> 'U) -> list1:'T1 list -> list2:'T2 list -> 'U list 
Array.mapi2: mapping:(int -> 'T1 -> 'T2 -> 'U) -> array1:'T1[] -> array2:'T2[] -> 'U[] 
Seq.mapi2: mapping:(int -> 'T1 -> 'T2 -> 'U) -> source1:seq<'T1> -> source2:seq<'T2> -> seq<'U> 

List.map3: mapping:('T1 -> 'T2 -> 'T3 -> 'U) -> list1:'T1 list -> list2:'T2 list -> list3:'T3 list -> 'U list 
Array.map3: mapping:('T1 -> 'T2 -> 'T3 -> 'U) -> array1:'T1[] -> array2:'T2[] -> array3:'T3[] -> 'U[] 
Seq.map3: mapping:('T1 -> 'T2 -> 'T3 -> 'U) -> source1:seq<'T1> -> source2:seq<'T2> -> source3:seq<'T3> -> seq<'U> 

List.collect: mapping:('T -> 'U list) -> list:'T list -> 'U list 
Array.collect: mapping:('T -> 'U[]) -> array:'T[] -> 'U[] 
Seq.collect: mapping:('T -> 'Collection) -> source:seq<'T> -> seq<'U>  when 'Collection :> seq<'U> 

List.indexed: list:'T list -> (int * 'T) list 
Array.indexed: array:'T[] -> (int * 'T)[] 
Seq.indexed: source:seq<'T> -> seq<int * 'T> 

注意,属于映射模式的函数组已经相当规范化。名称类似于mapmapmap2map3)的函数将单个、一对或三对输入集合的元素映射到单个结果集合的元素。名称类似于mapimapimapi2)的函数还向投影添加了元素序号作为额外的输入参数。

collect 函数不采用相同的方法。相反,它将输入集合的每个元素投影到匹配的集合中,然后将所有这些元素匹配的集合展平成一个单一的结果集合。这有点复杂,所以我最好提供一个例子。

假设我们有一个单词数组,并希望将其转换为构成输入单词的字符列表(Ch8_7.fsx):

"Je ne regrette rien".Split([|' '|]) 
|> Seq.collect (fun x -> x.ToCharArray()) 
|> Seq.toList 
// val it : char list = 
//  ['J'; 'e'; 'n'; 'e'; 'r'; 'e'; 'g'; 
//   'r'; 'e'; 't'; 't'; 'e'; 'r'; 'i'; 'e'; 'n'] 

indexed 函数是一个辅助函数;它将任何集合转换为包含元组的集合,每个元组结合了原始元素的序号和元素本身(Ch8_7.fsx):

"Je ne regrette rien".Split([|' '|]) 
|> Seq.indexed 
// val it : seq<int * string> = 
//  seq [(0, "Je"); (1, "ne"); (2, "regrette"); (3, "rien")] 

折叠模式

我已经多次提到 fold 函数作为函数式编程中最通用和最通用的数据转换模式的表示。由于它已经被相当详细地介绍过了,这里我将不会深入细节,而只是列出以下成员函数签名所展示的这种极其通用的 折叠 模式的多种变体:

List.fold<'T,'State> : folder:('State -> 'T -> 'State) -> state:'State -> list:'T list -> 'State 
Array.fold<'T,'State> : folder:('State -> 'T -> 'State) -> state:'State -> array: 'T[] -> 'State 
Seq.fold<'T,'State> : folder:('State -> 'T -> 'State) -> state:'State -> source:seq<'T> -> 'State 
List.fold2<'T1,'T2,'State> : folder:('State -> 'T1 -> 'T2 -> 'State) -> state:'State -> list1:'T1 list -> list2:'T2 list -> 'State 
Array.fold2<'T1,'T2,'State>  : folder:('State -> 'T1 -> 'T2 -> 'State) -> state:'State -> array1:'T1[] -> array2:'T2[] -> 'State 
Seq.fold2<'T1,'T2,'State> : folder:('State -> 'T1 -> 'T2 -> 'State) -> state:'State -> source1:seq<'T1> -> source2:seq<'T2> -> 'State 
List.mapFold<'T,'State,'Result> : mapping:('State -> 'T -> 'Result * 'State) -> state:'State -> list:'T list -> 'Result list * 'State 
Array.mapFold<'T,'State,'Result> : mapping:('State -> 'T -> 'Result * 'State) -> state:'State -> array:'T[] -> 'Result[] * 'State 
Seq.mapFold<'T,'State,'Result> : mapping:('State -> 'T -> 'Result * 'State) -> state:'State -> source:seq<'T> -> seq<'Result> * 'State 

List.foldBack<'T,'State> : folder:('T -> 'State -> 'State) -> list:'T list -> state:'State -> 'State 
Array.foldBack<'T,'State> : folder:('T -> 'State -> 'State) -> array:'T[] -> state:'State -> 'State 
Seq.foldBack<'T,'State> : folder:('T -> 'State -> 'State) -> source:seq<'T> -> state:'State -> 'State 

List.foldBack2<'T1,'T2,'State> : folder:('T1 -> 'T2 -> 'State -> 'State) -> list1:'T1 list -> list2:'T2 list -> state:'State -> 'State 
Array.foldBack2<'T1,'T2,'State> : folder:('T1 -> 'T2 -> 'State -> 'State) -> array1:'T1[] -> array2:'T2[] -> state:'State -> 'State 
Seq.foldBack2<'T1,'T2,'State> : folder:('T1 -> 'T2 -> 'State -> 'State) -> source1:seq<'T1> -> source2:seq<'T2> -> state:'State -> 'State 

List.mapFoldBack<'T,'State,'Result> : mapping:('T -> 'State -> 'Result * 'State) -> list:'T list -> state:'State -> 'Result list * 'State 
Array.mapFoldBack<'T,'State,'Result> : mapping:('T -> 'State -> 'Result * 'State) -> array:'T[] -> state:'State -> 'Result[] * 'State 
Seq.mapFoldBack<'T,'State,'Result> : mapping:('T -> 'State -> 'Result * 'State) -> source:seq<'T> -> state:'State -> seq<'Result> * 'State 

List.scan<'T,'State>  : folder:('State -> 'T -> 'State) -> state:'State -> list:'T list -> 'State list 
Array.scan<'T,'State> : folder:('State -> 'T -> 'State) -> state:'State -> array:'T[] -> 'State[] 
Seq.scan<'T,'State> : folder:('State -> 'T -> 'State) -> state:'State -> source:seq<'T> -> seq<'State> 

List.scanBack<'T,'State> : folder:('T -> 'State -> 'State) -> list:'T list -> state:'State -> 'State list 
Array.scanBack<'T,'State> : folder:('T -> 'State -> 'State) -> array:'T[] -> state:'State -> 'State[] 
Seq.scanBack<'T,'State> : folder:('T -> 'State -> 'State) -> source:seq<'T> -> state:'State -> seq<'State> 

除了书中散布的多个 fold 使用示例和互联网上随时可用的示例之外,我在脚本 Ch8_8.fsx 中还提供了一些额外的示例。

合并/分割模式

我们对 F# 4.0 核心库中捕获的数据转换模式的漫长旅程已经到达了最后一站。在这里,位于 合并/分割 模式下的函数要么将一些集合合并成一个,要么通过分割一个集合成多个来执行相反的操作:

List.append: list1:'T list -> list2:'T list -> 'T list 
Array.append: array1:'T[] -> array2:'T[] -> 'T[] 
Seq.append: source1:seq<'T>  -> source2:seq<'T> -> seq<'T> 

List.concat: lists:seq<'T list> -> 'T list 
Array.concat: arrays:seq<'T[]> -> 'T[] 
Seq.concat: sources:seq<'Collection> -> seq<'T> when 'Collection :> seq<'T> 

List.zip: list1:'T1 list -> list2:'T2 list -> ('T1 * 'T2) list 
Array.zip: array1:'T1[] -> array2:'T2[] -> ('T1 * 'T2)[] 
Seq.zip: source1:seq<'T1> -> source2:seq<'T2> -> seq<'T1 * 'T2> 

List.zip3: list1:'T1 list -> list2:'T2 list -> list3:'T3 list -> ('T1 * 'T2 * 'T3) list 
Array.zip3: array1:'T1[] -> array2:'T2[] -> array3:'T3[] -> ('T1 * 'T2 * 'T3)[] 
Seq.zip3: source1:seq<'T1> -> source2:seq<'T2> -> source3:seq<'T3> -> seq<'T1 * 'T2 * 'T3> 

List.unzip: list:('T1 * 'T2) list -> ('T1 list * 'T2 list) 
Array.unzip: array:('T1 * 'T2)[] -> ('T1[] * 'T2[]) 

List.unzip3 list:('T1 * 'T2 * 'T3) list -> ('T1 list * 'T2 list * 'T3 list) 
Array.unzip3 array:('T1 * 'T2 * 'T3)[] -> ('T1[] * 'T2[] * 'T3[]) 

append 函数是合并模式的最简单形式,因为它将一对集合合并成一个单一的集合。第二个参数集合的元素紧随第一个参数集合的元素之后,出现在结果集合中。

concat 函数是 append 的泛化,适用于任何数量的输入集合,只需将其包装成序列。

最后,连接器(zipzip3)将两个或三个集合转换成对应元组的单一集合。解连接器(unzipunzip3)执行相反的操作,将元组集合转换成集合的元组。请注意,库不提供 seq 的解连接器。

概述

这是一个漫长的章节,但它是进入数据转换的通用模式和它们在 F# 4.0 核心库中的反映的必要步骤。你获得的知识将支持通过提示你围绕保留的几个参考点构建 F# 代码来构建任意数据转换的惯用蓝图的过程。当你将手头的任务在心理上分解为这里涵盖的模式的函数组合时,高质量的库函数总是可供你快速组合,从而得到一个无错误且性能充足的解决方案。

下一章将继续数据转换主题,探讨 F# 数据查询和数据解析的主题。

第九章。更多数据处理

到目前为止,所有涵盖的 F# 数据转换模式都处理的是内存中的集合。也就是说,重要的数据处理用例,如查询企业内部已持久化的数据和从企业外部摄取数据,尚未被考虑。

本章涵盖了这些数据转换场景和相关编码模式:

  • 查询外部数据。我将从使用 F# 查询表达式查询数据开始。我们将看到我们在第八章 “数据处理 - 数据转换模式” 中提炼出的相同转换模式,与核心库函数成员完全适用于查询数据库或 Web 服务中呈现的外部数据。探索查询表达式在组合方面的极限也将很有趣。

  • 从外部源解析数据。我们已经花费了大量时间考虑由主动模式增强的模式匹配。然而,我并不觉得有必要使用一些高级技术,例如解析器组合器。我将展示一些来自实战的生产质量数据解析示例,这些示例仅通过一点自定义编码即可实现。

数据查询

到目前为止,本书中数据集合的来源要么是集合生成器,要么是文件系统。让我转向更现实的业务数据源,其中数据存储在数据库中。为了访问和转换此类数据,F# 提供了 查询表达式 (msdn.microsoft.com/visualfsharpdocs/conceptual/query-expressions-%5bfsharp%5d )。

查询表达式代表一种嵌入到语言中的具体类型的计算表达式。它们允许通过查询外部源将数据带入内存,并将传入的数据转换为所需的形状。

F# 查询表达式类似于序列表达式:两者都产生数据序列。然而,在最终数据投影塑造产生的数据序列之前,查询表达式可能应用于各种类似我们在 LINQ (en.wikipedia.org/wiki/Language_Integrated_Query ) 中所见到的数据转换。查询表达式可以被视为 F# 中的 LINQ 支持。

在查询表达式之前,F# 和 LINQ

按照时间顺序,查询表达式是在 F# 3.0 中引入的。在那之前,F# 允许你通过 .NET 3.5 Enumerable 扩展方法(msdn.microsoft.com/en-us/library/system.linq.enumerable_methods(v=vs.110).aspx)访问 LINQ 机制。让我们看看以下脚本,它找出按字典顺序排列的英文字母序列中的最后一个元音字母(Ch9_1_1.fsx):

let isVowel = function 
              | 'A' | 'a' | 'E' | 'e' | 'I' | 'i' 
              | 'O' | 'o' | 'U' | 'u' -> true 
              | _ -> false 

let alphabet = seq { 'A' .. 'Z' } 

alphabet |> Seq.filter isVowel |> Seq.sortDescending |> Seq.head 
// val it : char = 'U' 

如果我们回想一下 F# 中的 alphabet 序列是 IEnumerable,那么这个任务可以通过 LINQ 扩展方法(Ch9_1_2.fsx)来完成:

open System.Linq 
let isVowel = function 
              | 'A' | 'a' | 'E' | 'e' | 'I' | 'i' 
              | 'O' | 'o' | 'U' | 'u' -> true 
              | _ -> false 
let alphabet = seq { 'A' .. 'Z' } 
alphabet.Where(isVowel).OrderByDescending(fun x -> x).First() 
// val it : char = 'U' 

使用 LINQ 扩展方法的 流畅接口 作为 F# 管道操作符 |> 的粗略替代,我们实现了定义之间的几乎一对一对应。同样的结果是通过组合 Seq 库函数 filter-sortDescending-head 和通过组合 LINQ 扩展方法 Where-OrderByDescending-First 来实现的。

介绍 F# 查询表达式

你可能会问,为什么我如此关注上述相似性?那是因为查询表达式不过是类似于我们在 第六章 中观察到的序列表达式的 语法糖。查询表达式使用 F# 计算表达式魔法将函数应用链表达为 SQL 类似操作的线性序列,这些操作在内置的计算表达式构建器 query { ... } 中进行。这种方法与 seq { ... } 生成 F# 序列的工作方式相似。上一节中给出的 Ch9_1_2.fsx 脚本可能使用查询表达式作为(Ch9_1_3.fsx):

let isVowel = function 
              | 'A' | 'a' | 'E' | 'e' | 'I' | 'i' 
              | 'O' | 'o' | 'U' | 'u' -> true 
              | _ -> false 

let alphabet = seq { 'A' .. 'Z' } 

query { 
    for letter in alphabet do 
    where (isVowel letter) 
    sortByDescending letter 
    select letter // may be omitted 
    head 
} 
// val it : char = 'U' 

在分析前面的查询表达式时,你可能注意到已经熟悉的 ETL 数据转换过程,这在 第八章 中已经考虑过,数据处理 – 数据转换模式:给定一个集合,对其成员进行一个或多个修改,最终投影查询结果。作为计算表达式 query 提供了相邻行之间的神奇粘合剂。它使得数据以类似函数通过 >> 组合器链式连接的方式从一个查询操作符流向另一个查询操作符。

查询操作符

虽然查询操作符的数量 (msdn.microsoft.com/en-us/visualfsharpdocs/conceptual/query-expressions-%5Bfsharp%5D ) 相对于 F# 核心库中集合函数的数量来说要少得多——只有大约 40 个——但查询操作符很好地融入了,在适用的情况下,数据转换模式的层次结构中(另一个类似的 分类 我设法发现的是以下一个:weblogs.asp.net/dixin/understanding-linq-to-objects-2-query-methods-and-query-expressions)。在括号中提供的与先前分类中类似名称的映射如下所示:

  • 聚合模式 (聚合): 包括 countaverageByaverageByNullableminBymaxByminByNullablemaxByNullablesumBysumByNullable

  • 搜索模式:这包括 find 函数

  • 选择模式 (限制): 这包括 lastlastOrDefaultheadheadOrDefaultnthexactlyOneexactlyOneOrDefaulttaketakeWhileskipskipWhiledistinctwhere

  • 分区模式 (分组): 这包括 groupBygroupValBy

  • 重新排序模式 (排序): 这包括 sortBysortByDescendingsortByNullablesortByNullableDescendingthenBythenByDescendingthenByNullablethenByNullableDescending

  • 测试模式 (量词): 这包括 existsall

  • 映射模式 (投影): 这包括 select

  • 合并/拆分模式 (卷积): 这包括 zipjoingroupJoinleftOuterJoin

很好!然而,到目前为止,考虑的都是在内存集合中的旋转。那么我们如何包含查询内存外的数据?F# 在这方面提供了相当大的灵活性;因此,让我们逐步进行,以便探索可用的方案之丰富性和多样性。

LINQ 提供者的作用

在使用 LINQ 时,经常被偶尔用户忽略的重要细节是查询机制对数据集合的性质是无关的。可能存在一个层,它抽象了位于 IQueryable<'T> (msdn.microsoft.com/en-us/library/bb351562(v=vs.110).aspx ) 接口背后的具体数据源的细节,而我们尚未触及这个接口。不涉及这一层,你就只能依靠我们熟悉的 IEnumerable<'T> 接口了。

这两个接口都确保了延迟执行。然而,IEnumerable<'T> 只是将与查询表达相关的数据集合引入内存,这些查询是通过相关外部手段表达的,并受进一步的 LINQ-to-Object 内存操作的约束。

相比之下,IQueryable<'T> 通过名为 LINQ provider 的组件允许 LINQ-to-Something(例如 LINQ-to-SQL, LINQ-to-OData, LINQ-to-WMI 等)工作。它确保 LINQ 查询被翻译成 Something 部分的具体替代品所理解的语言,然后由 Something 执行翻译后的查询,仅将匹配的数据集合带回内存。对于那些对 Something 可能扮演什么角色感兴趣的人,我推荐查看代表性的、虽然有些过时的 - LINQ-to-Everywhere - LINQ 提供器列表 (blogs.msdn.microsoft.com/knom/2009/04/27/linq-to-everywhere-list-of-linq-providers/ )。

上一段落中有两个关键点必须正确理解。首先,LINQ 提供器完全抽象了查询翻译和执行的细节。对于直观清晰的 LINQ-to-SQL 案例,这种翻译相当直接。翻译后的 SQL 查询将在参与数据库引擎的侧执行,仅通过网络发送服务器端查询执行的结果。对于像 LINQ-to-CRM (linqtocrm.codeplex.com/) 这样的例子,需要进一步挖掘以了解这个特定的 LINQ 提供器到底做了什么。

其次,待翻译的 LINQ 查询不应包含无法用翻译查询执行引擎表达的内容。这种违规可能通过提供器实现中的功能选择性或从上下文中无意中捕获不相关的元素而发生。这意味着如果提供器实现,例如,不支持排序操作,那么包含排序部分的 LINQ 查询将被底层提供器拒绝。此外,有时翻译查询执行引擎的能力可能不同,同一个 LINQ-to-SQL 查询可能由 Microsoft SQL 引擎成功执行,但在 MySQL 引擎上却失败得非常惨重。

考虑到 LINQ 提供器的角色,我们首先转向无 LINQ 提供器的 F# 查询案例。

通过 IEnumerable<'T> 进行外部数据查询

对于这个用例,让我找一个你可以轻松复制的例子。作为微软平台上的用户,我将使用微软提供给开发者的传统测试数据库,即 Adventureworks 2014 (msftdbprodsamples.codeplex.com/releases/view/125550 )。它已经安装在 Visual Studio 2013 伴随的 localdb 微软 SQL 引擎下。

在这个数据库中有一个 [Person].[Person] 表,其中包含除了其他信息之外的人名。让我通过以下查询来对这个表执行一个简单的分析任务:

select count(distinct [FirstName]) from [Adventureworks2014].[Person].[Person] 

这使我能够发现数据库包含 1018 个独特的个人姓氏。让我们找出这些名字是如何按照英语字母表中的第一个字母分布的。

要访问数据库,我将使用原生 .NET System.Data.SqlClient 库的简单 Reader 对象。最初(并且相当简单)的方法是将完整的独特姓氏列表通过网络发送到内存中。以下脚本实现了这种方法(Ch9_1_4.fsx):

open System.Data 
open System.Data.SqlClient 

let alphabet = seq { 'A' .. 'Z' } 

let connStr = @"Data Source=(localdb)projectsv12;Initial Catalog=Adventureworks2014;Integrated Security=true;" 
let dbConnection = new SqlConnection(connStr) 
dbConnection.Open() 

let dbCommand = new SqlCommand("select FirstName from [Person].[Person]",dbConnection) 
let names = seq { 
                printfn "reading from db"  
                use reader = dbCommand.ExecuteReader(CommandBehavior.Default) 
                while reader.Read() do yield reader.GetString(0) } 
let distribution = 
    query { 
        for letter in alphabet do 
            let howMuch = 
                query { 
                    for name in names do 
                    where (name.StartsWith(string letter)) 
                    distinct 
                    select name 
                } |> Seq.length 
            sortBy howMuch 
            select (letter, howMuch) 
    } 

distribution |> Seq.toList |> printfn "%A" 

这里有两个查询表达式:第一个遍历 alphabet 中的每个 letter,将获取完整数据集的任务委托给嵌套的第二个查询,然后在内存中过滤掉除了以当前 letter 值开头的名字之外的所有内容,丢弃重复项并找到名字的数量。外部查询根据找到的频率将这个数字放入其位置,并返回所寻求的投影 (letter, howMuch) 作为 distribution 序列。在 FSI 中实现它,我可以观察到目标名字的分布。以下截图展示了运行脚本 Ch9_1_4.fsx 的计时结果,其中 FSI 只从给定的文件路径获取源脚本代码:

通过 IEnumerable<'T> 进行外部数据查询

外部 SQL 查询:第 1 版

你可能会注意到,在运行过程中,脚本从 [Person][Person] 数据库表中完整读取了姓氏列表 26 次,这显然是过度杀鸡用牛刀,这种方法可以显著改进。

例如,我们可以参数化我们的 SQL 命令,并返回的不是所有名字,而是每个特定字母的独特名字,这将显著减少与数据库之间的流量。以下代码(Ch9_1_5.fsx)展示了重构后的脚本以反映这种改进方法:

open System.Data 
open System.Data.SqlClient 

let alphabet = seq { 'A' .. 'Z' } 

let connStr = @"Data Source=(localdb)projectsv12;Initial Catalog=Adventureworks2014;Integrated Security=true;" 
let dbConnection = new SqlConnection(connStr) 
dbConnection.Open() 

let dbCommandR l = 
    new SqlCommand( 
        (sprintf "%s%s%s" "select distinct FirstName from [Person].[Person] where FirstName like '" l  
          "%'"), dbConnection) 

let names l = seq { 
                printfn "reading from db"  
                use reader = (dbCommandR l).ExecuteReader(CommandBehavior.Default) 
                while reader.Read() do yield reader.GetString(0) } 

let distribution = 
    query { 
        for letter in alphabet do 
            let howMuch = names (string letter) |> Seq.length 
            sortBy howMuch 
            select (letter, howMuch) 
    } 
#time "on" 
distribution |> Seq.toList |> printfn "%A" 

你可能会注意到,现在不需要嵌套的 query {...} 组了,因为大量工作已经通过网络委托给了 SQL 引擎。以下截图显示了重构后的脚本的计时结果:

通过 IEnumerable<'T> 进行外部数据查询

外部 SQL 查询 - 第 2 版

你可能会观察到由于网络流量量的显著减少,性能几乎提高了四倍。

将最小化流量趋势推向极致,尽可能将工作分配给 SQL 服务器,我可能会让所有工作都推到 SQL 服务器的一侧,只留下为 F# 查询获取远程数据的初步任务,例如在此处显示的脚本的第三个版本 (Ch9_1_6.fsx):

open System.Data 
open System.Data.SqlClient 

let connStr = @"Data Source=(localdb)projectsv12;Initial Catalog=Adventureworks2014;Integrated Security=true;" 
let dbConnection = new SqlConnection(connStr) 
dbConnection.Open() 

let dbCommandF = 
    new SqlCommand("select SUBSTRING(FirstName, 1, 1),count(distinct FirstName) as "count" 
                    from [Adventureworks2014].[Person].[Person] 
                    group by SUBSTRING(FirstName, 1, 1) 
                    order by count",dbConnection) 

let frequences = seq { 
                printfn "reading from db"  
                use reader = dbCommandF.ExecuteReader(CommandBehavior.Default) 
                while reader.Read() do yield (reader.GetString(0), reader.GetInt32(1)) } 

let distribution = 
    query { 
        for freq in frequences do 
        select freq 
    } 
#time "on" 
distribution |> Seq.toList |> printfn "%A" 

注意现在所有脏活累活都由 SQL 服务器完成,这是完全正常的,因为 Microsoft SQL Server 是一款致力于数据存储和处理的软件杰作,而且它的工作做得非常好(当然,前提是你不进行有害的干扰)。运行最终脚本重构的结果在以下截图中展示。不要错过整个数据交换仅通过一次往返完成的证据:

通过 IEnumerable<'T> 进行外部数据查询

外部 SQL 查询:版本 3

哇,这个优化真是太棒了!与版本 1 相比,版本 3 大约 提高了 17.6 倍的性能。现在你的收获教训难以忘记。

小贴士

企业开发要求底层技术确保有足够的远程负载分发能力。这种能力可以通过 F# query 以及其他方式实现。

通过 IQuerable<'T> 进行外部数据查询

我希望在前一节中取得的显著性能结果之后,无需再向你证明将 LINQ 查询执行转发到远程方的能力有多么重要。然而,不要期望这可以理所当然地获得。这个方向可能有一个陡峭的学习曲线,我们很快就会注意到。

让我们以我最近在 Jet.com Inc.jet.com/about-us)工作中遇到的一个 100% 真实任务为例。我将构建一个仪表板的后端,实时显示 Jet.com 的 高薪合作伙伴(那些向 Jet.com 客户发货并完成订单获得最大赔偿金额的商家)。

我将访问 Jet.com 质量保证环境中有限的数据,因此这些数字不会那么能说明真实的高薪合作伙伴。

仪表板后端所需的数据分布在两个数据库中:SQL.Colossus 负责存储 Payments 表中的支付数据,而 SQL.IronmanData 负责存储 Partner 表中的合作伙伴数据。

如果数据位于支持跨数据库查询的相同 SQL 引擎中,那么为我提供所需数据的 T-SQL 脚本可能如下所示(Ch9_2.fsx,顶部注释部分):

select top (10) min(r.DisplayName) as Name, sum(p.[Amount]) as Total 
from [sql.colossus].[dbo].[Payments] p 
join [sql.ironmandata].[dbo].[Partner] r on r.MerchantId = p.MerchantId 
where p.[IsDeposited] = 1 
group by p.[MerchantId] 
order by total desc 

在 SQL Server Management Studio 中针对目标环境执行后,这会产生以下截图所示的结果:

通过 IQuerable<'T> 进行外部数据查询

用于向 Jet.com 高薪合作伙伴仪表板提供 SQL 查询

让我尝试使用 F# 查询表达式 query{...} 表达类似的 T-SQL 查询。为了访问 LINQ-to-SQL,我将使用比上一节中使用的 ADO.NET 更高级的 F# 机制来获取对数据的强类型访问。这种机制被称为 F# 类型提供程序。具体来说,我将使用 SQLDataConnection (LINQ to SQL) 类型提供程序(fsharp.org/guides/data-access/#sql-data-access),这是 F# 标准发行版的一部分,自 F# v3.0 以来一直针对 Microsoft Windows。

注意

对于那些对这个主题完全不熟悉的你们,可以遵循这个MSDN 演练(msdn.microsoft.com/visualfsharpdocs/conceptual/walkthrough-accessing-a-sql-database-by-using-type-providers-%5bfsharp%5d)来更好地理解本节的内容。

可以放入仪表板后端核心的 F# 脚本如下(Ch9_2.fsx):

#r "FSharp.Data.TypeProviders" 
#r "System.Data" 
#r "System.Data.Linq" 

open Microsoft.FSharp.Data.TypeProviders 
open System.Linq 

[<Literal>] 
let compileTimeCsusCS = @"Data Source=(localdb)projectsv12;Initial Catalog=Colossus.DB;Integrated Security=SSPI" 
let runTimeCsusCS = @"Data Source=***;Initial Catalog=SQL.Colossus;User ID=***;Password=***" 
[<Literal>] 
let compileTimeImCS = @"Data Source=(localdb)projectsv12;Initial Catalog=SQL.Ironman;Integrated Security=SSPI" 
let runTimeImCS = @"Data Source=***;Initial Catalog=SQL.IronmanData;User ID=***;Password=***" 

type Colossus = SqlDataConnection<compileTimeCsusCS> 
type IronManData = SqlDataConnection<compileTimeImCS> 

let pmtContext = Colossus.GetDataContext(runTimeCsusCS) 
let imContext = IronManData.GetDataContext(runTimeImCS) 

let mostPaid = 
    fun x -> query { 
                for payment in pmtContext.Payments do 
                where (payment.IsDeposited.HasValue && payment.IsDeposited.Value) 
                groupBy payment.MerchantId into p 
                let total = query { for payment in p do sumBy payment.Amount} 
                sortByDescending total 
                select (p.Key,total) 
                take x 
             } 

let active = (mostPaid 10) 
let activeIds = active |> Seq.map fst 

let mostActiveNames = 
    query { 
        for merchant in imContext.Partner do 
        where (activeIds.Contains(merchant.MerchantId)) 
        select (merchant.MerchantId,merchant.DisplayName) 
    } |> dict 

active 
|> Seq.map (fun (id, total) -> (mostActiveNames.[id],total)) 
|> Seq.iter (fun x -> printfn "%s: %.2f" (fst x) (snd x)) 

考虑到安全要求,我没有透露任何关于 Jet.com 基础设施的信息,除了一些(不一定与真实名称相符)的数据库和表名。

当涉及到类型提供程序时,重要的是要意识到提供程序本身在编译时工作,为涉及的 SQL 表的字段提供类型化访问。为了做到这一点,它需要在编译时访问 SQL 架构信息。这种对前面脚本中结构信息的访问是通过 compileTimeCsusCScompileTimeImCS 连接字符串为 Colossus.DBSQL.Ironman 数据库提供的。

注意,从类型提供程序对本地 SQL 引擎的编译时访问与应用数据无关。它只是检索有关 SQL 架构的系统数据。这些架构在结构上类似于在生产 SQL 数据引擎上承载应用数据的那些架构。因此,提供的 ColossusIronManData 类型是基于 localdb SQL 引擎构建的,而 pmtContextimContext 运行时数据上下文是基于生产服务器(通过 runTimeCsusCSrunTimeImCS 运行时连接字符串)构建的。

mostPaid 函数表示用于查找任何给定数量的高薪合作伙伴及其总存入支付的查询。正如我们可能预期的,这个函数的签名是 mostPaid : x:int -> System.Linq.IQueryable<string * decimal>,它将被 LINQ-to-SQL 提供程序转换为普通的 T-SQL,并在 SQL 服务器端执行。

另一个有趣的时刻是,在 Jet.com 的 Microsoft Azure 生产环境中,如图 SQL 查询为付费最高的 Jet.com 合作伙伴仪表板提供数据 的跨数据库查询等操作无法正常工作,因此我将访问过程分为三个阶段:

  • 在第一阶段,active 代表一个包含 (merchantId, paymentAmount) 元组的集合,其中相关合作伙伴 ID 列表 activeIds 可以轻松投影

  • 在第二阶段,另一个查询 mostActiveNames 仅检索属于付费最高的合作伙伴的合作伙伴显示名称,并将它们打包到字典中

  • 最后,active 经历了一次转换,其中 ID 被替换为 mostActiveNames.[id] ,从而得到仪表板所需的最终数据形状。

运行前一个脚本并使用 FSI 的结果在以下屏幕截图中展示;正如预期的那样,它们与之前的结果相同:

通过 IQuerable<'T>进行外部数据查询

通过 IQueryable<'T>在 F#中查询的实际操作

可组合查询

将较小的 F#子查询组合成更大的查询不是很好吗?换句话说,这意味着将多个可查询对象组合成一个 LINQ 查询,该查询被翻译成 SQL 并在数据库引擎端执行。

这听起来很有希望,并引起了一些个人开发者及团队的注意。

注意

英国爱丁堡大学的一个团队投入了最多的努力,该团队由如 Philip Wadler 等函数式编程权威人士领导。他们的成果可以在 FSharpComposableQuery (fsprojects.github.io/FSharp.Linq.ComposableQuery/index.html ) 项目主页上找到,提供 NuGet 包、源代码、教程,甚至还有关于该主题的一些理论论文。Philip Wader 在 SkillsMatter 网站上给出了一篇介绍视频演示:语言集成查询的实用理论 (skillsmatter.com/skillscasts/4486-a-practical-theory-of-language-integrated-query )。

此外,几年前,Loïc Denuzière 在 这篇博客文章 (fpish.net/blog/loic.denuziere/id/3508/2013924-f-query-expressions-and-composability ) 中提出了一种替代的、更轻量级的可组合查询方法,Loïc Denuzière 的资料可以在 fpish.net/profile/loic.denuziere 找到。该方法基于将部分 F#查询表达式拼接在一起以构建更复杂的查询。我将基于这种方法探索可组合 LINQ 查询。

在我们开始编写代码之前,我必须指出基于 LINQ-to-SQL 的 F# 查询的一个重大限制:无法执行跨数据库和跨引擎查询,因为所有子查询都必须共享相同的 LINQ 上下文!这个因素可能成为拥有众多 OLTPOLAP 数据库的企业的一个拦路虎。

为了将 Ch9_2.fsx 脚本中的 T-SQL 查询重构为可组合查询,我在仪表板用例讨论中覆盖了上述内容,我已经将 Partner 表的副本移动到 SQL.Colossus 数据库。它现在可以与 Payments 表共享相同的 LINQ 上下文。

组合方法基于引入一个特殊的 PartialQueryBuilder 类,它:

  • 引入额外方法 Run 的标准 Linq.QueryBuilder 子类

  • 使用 Source 方法增强 Linq.QueryBuilder

所有这些措施都允许你使用替代表达式构建器 pquery 编写子查询,这些查询被包裹在引号中而不是被评估。这些引号被嵌入到普通查询中,并统一评估。

在以下脚本中,它依赖于这些功能,为了简洁起见,我省略了编译时和运行时连接的分离(Ch9_3.fsx):

#r "FSharp.Data.TypeProviders" 
#r "System.Data" 
#r "System.Data.Linq" 

open Microsoft.FSharp.Data.TypeProviders 
open System.Linq 

[<Literal>] 
let runTimeCsusCS = @"Data Source=***;Initial Catalog=SQL.Colossus;User ID=***;Password=***" 

type Colossus = SqlDataConnection<runTimeCsusCS> 

let pmtContext = Colossus.GetDataContext(runTimeCsusCS) 

然后是定义 pquery 的实用部分:

type PartialQueryBuilder() = 
    inherit Linq.QueryBuilder() 
    member __.Run(e:  Quotations .Expr<Linq.QuerySource<'T,IQueryable>>) = e 

let pquery = PartialQueryBuilder() 

type Linq.QueryBuilder with 
    [<ReflectedDefinition>] 
    member __.Source(qs: Linq.QuerySource<'T,_>) = qs 

最后,组合查询如下:

let mostPaid = pquery { 
                    for payment in pmtContext.Payments do 
                    where (payment.IsDeposited.HasValue && 
                           payment.IsDeposited.Value) 
                    groupBy payment.MerchantId into p 
                    let total = pquery { for payment in p do sumBy 
                                         payment.Amount} 
                    sortByDescending total 
                    select (p.Key,total) 
                    take 10 
                         } 

let dashboard = pquery { 
                    for merchant in pmtContext.Partner do 
                        for (id,total) in %mostPaid do 
                        where (merchant.MerchantId = id ) 
                        select (merchant.DisplayName, total) 
                       } 

query { for m in %dashboard do 
           select m } |> Seq.iter (fun x -> printfn "%s: %.2f" (fst x) (snd x)) 

注意 mostPaid 如何被拼接到 dashboard 中,从而创建了一个无缝的组成,反过来,dashboard 被拼接到最终的查询中。

在 FSI 中运行脚本产生以下结果:

可组合查询

使用组合查询获取仪表板数据

你可能会想知道是否有方法可以检查查询组合是否真的发生了。幸运的是,这并不难做到。只需向 LINQ 上下文中添加以下属性即可,如下所示:

pmtContext.Payments.Context.Log <- new System.IO.StreamWriter( 
   @"C:usersgenedownloadspmtlinq.log", AutoFlush = true) 

再次运行前面的脚本后,LINQ 日志文件现在包含 SQL 引擎执行的 SQL 代码:

SELECT [t0].[DisplayName] AS [Item1], [t3].[value] AS [Item2] 
FROM [dbo].[Partner] AS [t0] 
CROSS JOIN ( 
    SELECT TOP (10) [t2].[MerchantId], [t2].[value] 
    FROM ( 
        SELECT SUM([t1].[Amount]) AS [value], [t1].[MerchantId] 
        FROM [dbo].[Payments] AS [t1] 
        WHERE ([t1].[IsDeposited] IS NOT NULL) AND (([t1].[IsDeposited]) = 1) 
        GROUP BY [t1].[MerchantId] 
        ) AS [t2] 
    ORDER BY [t2].[value] DESC 
    ) AS [t3] 
WHERE [t0].[MerchantId] = [t3].[MerchantId] 
ORDER BY [t3].[value] DESC 
-- Context: SqlProvider(Sql2008) Model: AttributedMetaModel Build: 4.0.30319.33440 

注意如何将脚本 F# 查询中的所有 IQueryable 零部件塑造成单个 SQL 语句。

数据解析

数据解析对企业来说绝对至关重要。作为 Jet.com 的企业级 F# 开发者,我每天都在遇到这种数据转换模式。每个 LOB 应用程序与第三方系统(ERP银行承运商)集成的案例都涉及到在摄取边缘的数据解析。尽管有大量承诺提供优质数据、及时性、完整性的集成技术,但无论什么名字...时间一次又一次地,我被我的承包商强迫处理平面固定格式文件、CSV 文件和 Excel 文件。这就是今天令人厌烦的现实。

在这个战场上,武器装备从基于 Regex 和 F# 活动模式的逐个案例手编解决方案到针对整个 incoming 数据类别的相当通用的解决方案不等。一些半通用的解决方案的典型例子是将以 CSV 文件和 Excel 文件形式存在的发票持久化到 SQL 服务器中,以便进行进一步的处理、对账和未来的审计。我将展示如何实现高质量解析 incoming 数据,以处理将作为 Excel 文件摄入的承运人发票消化到 SQL 服务器的用例。

用例 - 激光船发票

LaserShip 是电子商务通常用于当日快递交付的“最后一英里”配送公司之一。Jet.com 使用 LaserShip 服务以及其他承运人服务。

LaserShip 以 Excel 文件的形式提供其发票信息。为了对账和审计的目的,将 LaserShip 发票加载到 SQL 服务器中是可取的。

接近解析任务

当出现类似 ETL 任务时,我通常采用以下模式来处理:

  1. 定义将承载加载数据的 SQL 表的架构。为来自承运人的数据添加额外的字段(s),允许您将任何数据块引用回其原始的承运人文件。此外,添加合成和/或自然键字段以及合理的约束。示例 SQL 表架构已以 T-SQL 脚本 SCHEMA_LaserShip.sql 的形式提供。

  2. 使用 Excel Provider (fsprojects.github.io/ExcelProvider/) 的帮助来摄入文件。调整类型提供程序设置以抑制默认的字段类型解释并强制将字段作为字符串提供。LaserShip 向其客户提供由 LaserShip 分发的 Excel 文件模板,已作为 Excel 文件 Lasership Invoice Format.xlsx 提供。

  3. 对于在源中表示可能被省略的字段,例如 可空 值。省略的值应使用 System.DBNull.Value 单例填充。为每个字段类型创建或重用一组解析函数,返回装箱的解析值或 System.DBNull.Value

  4. 将文件内容解析到匹配数据库字段的 System.Data.DataTable 实例中,使用泛型解析函数解析值。包含从真实发票摘录的示例发票,其中已删除个人数据,已作为 Excel 文件 LaserShip20160701.xlsx 提供。

  5. 使用 ADO.NET SqlBulkCopy (msdn.microsoft.com/en-us/library/system.data.sqlclient.sqlbulkcopy(v=vs.110).aspx) 将填充的 DataTable 实例加载到 SQL 服务器中。

LaserShip 解析器实现

之前概述的方法是通过脚本 Ch9_4.fsx 实现的。由于脚本长度不适合书籍格式,因此不会在这里完整给出。相反,我将在本节中仅重现脚本中最重要的摘录以及我的注释:

#r @"C:...packagesExcelProvider.0.8.0libExcelProvider.dll" 

上面的行确保可以从脚本中访问 NuGet 包 Excel Provider 0.8.0 (www.nuget.org/packages/ExcelProvider ):

type LaserShip = ExcelFile< @"C:codePacktBookCodeChapter11lasership invoice format.xlsx", HasHeaders=true, ForceString=true> 

上面的行非常重要。它由引用的 F# Excel 类型提供者在编译时进行处理。使用给定文件路径的类型提供器到达表示数据模板的 Excel 文件 lasership invoice format.xlsx。我们通过定义 HasHeadersForceString 静态 bool 参数来调整类型提供器设置。类型提供器会即时生成提供的类型 LaserShip,这将允许通过名称访问发票行的任何单元格:

let asNullableString = 
    function 
    | null -> box System.DBNull.Value 
    | (s: string) -> s.Trim() 
                     |> function 
                        | "" -> box System.DBNull.Value 
                        | l -> box l 

之前对 asNullableString 函数的定义实现了从 Excel 数据单元格到其 obj 表示的惯用类型转换,这种表示适合放入不进行静态类型检查的 System.Data.DataTable 内存中的数据表。如果 Excel 中的数据省略了作为参数传递给函数的单元格,则返回值将是适合放入以 T-SQL 描述的数据库字段类型的 System.DBNull.Value 值,例如 NVARCHAR(...) NULL。脚本定义了类似于 asNullableString 的函数,用于 Excel 文件中的每种类型,在需要强类型检查的地方:asNullableDateasStringasNullableMoney 以及其他:

let headers = ["invno";"JobNumber";"TDate";...;"SourceId";"RowKey";] 

之前的绑定对于将内存中的数据表列与数据库表列关联到 SQLBulkCopy 是必要的:

let loadLaserShip excelPath = 
    (new LaserShip(excelPath)).Data  

这个函数定义非常重要,因为它执行从由给定 excelPath 参数指定的 Excel 文件中摄入发票数据到由提供的类型 LaserShip 购得的内存占位符 Data

let fillDataTable sourceId (rows: IEnumerable<LaserShip.Row>) = 
    let dt = new DataTable() 
    do headers |> Seq.iter(fun h-> dt.Columns.Add(new DataColumn(h))) 
    for row in rows do 
        let dr = dt.NewRow() 
        dr.Item(0) <- unbox (row.invno |> asString "invno") 
         .  .  .  .  . 
        dr.Item(36) <- unbox (row.PickupDate |> asNullableString) 
        dr.Item(37) <- sourceId 
        dt.Rows.Add(dr) 
    printfn "loaded %d rows" dt.Rows.Count 
    dt 

之前对 fillDataTable 函数的定义是脚本的核心。你可能注意到它有趣的参数类型:rows: IEnumerable<LaserShip.Row>。换句话说,rows 是另一个提供的类型 LaserShip.Row 的序列,代表发票工作表的单一行。在函数内部,创建了一个新的 DataTable 实例,并通过从 headers 中获取列名来提供。然后,将摄入的 Excel 文件的每一行解析到 dt 中,注意数据的有效性。最后,返回加载的数据表 dt

一个小但非常重要的细节:上面的 sourceId 参数仅仅引用了另一个跟踪已处理发票的表。它已经被写入内存数据表的每一行,因此在数据上传后,这个引用将在持久化到 SQL 服务器的数据中可用,以描述数据的原始来源。更详细的内容超出了此处讨论的范围。

最后,另一个重要的函数 loadIntoSQL 实现了将大量数据上传到 SQL 服务器。其定义如下:

let loadIntoSQL tableName connStr (dataTable: DataTable) = 
    use con = new SqlConnection(connStr) 
    con.Open() 
    use bulkCopy = new SqlBulkCopy(con, DestinationTableName = tableName) 
    bulkCopy.WriteToServer(dataTable) 
    printfn "Finished write to server" 

使用提供的连接字符串 connStr 值打开数据库连接 con。使用创建的连接和给定的 SQL 表名,创建并使用 SqlBulkCopy 实例将内存中的 dataTable 持久化到相关的 SQL 服务器表。

前面的脚本可以进一步泛化到只有与具体文件类型相关的数据部分是可变的程度。这个可变部分可以被集成到通用的 Excel 文件解析器中。这可以在不到一百行代码内实现。

摘要

本章总结了数据转换模式的话题。大部分内容都是关于使用 F# 查询表达式查询持久化数据。你应该能够掌握查询工作在内存集合和网络位置的数据引擎之间的分配的细微差别。

我们还触及了数据解析的重要问题,展示了几个简单的模式,这些模式可以帮助你借助 F# 类型提供者摄取任意 Excel 文件,这些内容将在第十一章“F# 专家技巧”中进一步介绍。

在下一章中,我将专注于 F# 中的类型特殊化(增强)和类型泛化的双重模式。

第十章。类型扩展和泛型计算

到这本书的这一部分,很容易注意到使用模式和相应语言特征之间的直接联系。例如,第五章,代数数据类型,清楚地表明了 F#的本地代数类型是自定义类的替代品。基于代数数据类型的实现质量和速度的提高反映了该功能使用的回报。

在本章中,我将考虑某些语言特性,它们的使用回报并不明显。尽管如此,这些特性在 F#中无处不在。我的意思是代码泛化与代码特殊化的矛盾。

我们将要涵盖以下主题:

  • 代码泛化技术,或使相同的函数代码适用于多个函数参数类型

  • 代码特殊化技术,或通过使用标准功能使函数代码比通常更具体

上述每个模式都承诺带来某些好处:改进的性能、更简洁的代码和更好的静态类型控制。本章的目标是向您展示如何识别这些模式适用的场景,并应用它们,以实现预期的收益。

代码泛化

让我先声明一下,F# 自动泛化(msdn.microsoft.com/visualfsharpdocs/conceptual/automatic-generalization-%5bfsharp%5d)函数的参数,在可能处理多种类型的多重性时。

到目前为止,我们主要处理的是数据集合的泛化。也就是说,序列对其元素类型一无所知。这就是为什么我们能够编写操作任意泛型类型序列的函数。F#的类型推断发现并携带了这一属性。

假设我们自豪地实现了自己的列表反转函数,如下所示(Ch10_1.fsx):

let reverse ls = 
    let rec rev acc = function 
    | h::t -> rev (h::acc) t 
    | []   -> acc 
    rev [] ls 

那么,我们可能会注意到 F#编译器推断出它的reverse : ls:'a list -> 'alist签名,其中'a表示该函数可以应用于任何类型的列表元素。如果我们决定检查reverse函数与不同参数类型的确切行为,我们可能会观察到其行为对于以下参数是一致的(Ch10_1.fsx):

reverse [1;2;3] 
// val it : int list = [3; 2; 1] 
reverse ["1";"2";"3"] 
// val it : string list = ["3"; "2"; "1"] 

即使我们要稍微滥用类型系统并混合不同的装箱类型(Ch10_1.fsx):

reverse [box 1.0; box 2.0M; box 3I] 
//val it : obj list = [3 {IsEven = false; 
//                        IsOne = false; 
//                        IsPowerOfTwo = false; 
//                        IsZero = false; 
//                        Sign = 1;}; 2.0M; 1.0] 

reverse函数在参数列表元素类型方面表现得真正通用。

好的,现在让我们做一些看似相似但实际上非常简单的事情,比如将参数左移一位(Ch10_1.fsx):

let twice x  = x <<< 1 

突然,F#编译器推断出非常具体的twice : x:int -> int函数签名。发生了什么?显然,有一些类型允许这种将值加倍的特殊方式,例如int64。有趣的是,让我们看看当我们跟随函数定义使用它时会发生什么,如下所示(Ch10_1.fsx):

let twice x = x <<< 1 
twice 10L 
//val twice : x:int64 -> int64 
//val it : int64 = 20L 

现在 F#编译器似乎改变了关于twice函数签名的看法,这次推断出参数和结果类型为int64。这个操作是不可逆的,这意味着尝试跟随前面的评估使用twice 10现在会被这个诊断拒绝:这个表达式预期具有类型 int64 但在这里它具有类型 int

发生了什么?为什么泛化似乎失败了?

静态解析的类型参数

正如我们刚才注意到的,F#编译器为(<<<)运算符推断了一个单态类型。向多态迈进的一步是假设能够以某种方式表达它 - 同时保持在只允许这种类型作为twice参数与运算符(<<<)一起工作的.NET 类型系统中。换句话说,编译器应该处理一个类型约束

问题在于这种约束不能在 F#编译目标语言MSIL中表达。也就是说,最新的.NET CLI 标准 (www.ecma-international.org/publications/files/ECMA-ST/ECMA-335.pdf )在 II.10.1.7 泛型参数 节中通过是一个值类型引用类型来约束类型,对于具有默认构造函数的具体引用类型。这是.NET 类型系统的问题,而不是 F#语言或编译器的问题。

F# 4.0 语言规范 (fsharp.org/specs/language-spec/4.0/FSharpSpec-4.0-latest.pdf )在第 5.2.3 节中暗示了编译器的观察行为:

重载运算符的使用不会产生泛化代码,除非定义被标记为内联。例如,看看下面的函数: let f x = x + x

它产生了一个 f 函数,只能用来添加一种类型的值,例如 int 或 float。确切类型由后续约束决定

幸运的是,F#编译器可以使用静态解析类型参数的机制在编译时强制执行这些(以及一些其他)类型的约束。对于我们的(<<<)操作符,这个类型参数将具有特殊的“帽子”前缀^a,假设在编译点类型是静态已知的(与'a相对,假设类型可以是任何东西)。由于这种静态多态函数需要根据与约束相匹配的特定静态解析类型进行特定的编译方式,F#编译器通过内联来实现这一目标,正如语言规范所暗示的。

函数内联

让我将内联机制应用于这个失败的twice函数,如下所示(Ch10_1.fsx):

let inline twice' x = x <<< 1 
// val inline twice' : 
//     x: ^a ->  ^a when 
//        ^a : (static member ( <<< ) :  ^a * int32 ->  ^a) 

注意自动推断的内联twice函数签名如何携带所需的类型参数^a的约束,该约束在编译时静态解析:类型^a必须有一个操作符(<<<),其签名是^a * int32 -> ^a

太好了,现在twice看起来像是一个多态函数,允许例如以下评估(Ch10_1.fsx):

twice' 5    // int32 argument 
twice' 5u   // uint32 argument 
twice' 5L   // int64 argument 
twice' 5UL  // uint64 argument 
twice' 5y   // sbyte argument 
twice' 5uy  // byte argument 
twice' 5s   // int16 argument 
twice' 5us  // uint16 argument 
twice' 5I   // biginteger argument 
twice' 5n   // nativeint argument 

同时,它禁止以下评估(Ch10_1.fsx):

twice' 5m //The type 'decimal' does not support the operator '<<<' 
twice' 5.0 // The type 'float' does not support the operator '<<<' 
twice' "5"// The type 'string' does not support the operator '<<<' 
twice' '5' // The type 'char' does not support the operator '<<<' 

在我们仅仅给函数定义添加了inline限定符之后,编译器就提供了所有上述的便利。但你应该意识到,编译器实际上将内联函数的实现注入到 MSIL 中,并对其进行调整以适应具有静态解析的具体类型的参数。内联是一种编译方法,允许减轻.NET CLR 的限制。

静态约束

如同之前 F#编译器对twice函数的推断,参数x可以是任何类型^a,只要^a : (static member (<<<) : ^a * int32 -> ^a).这个条件,要么由 F#编译器推断,或许,也可能是程序员有意施加的,被称为静态约束(msdn.microsoft.com/en-us/visualfsharpdocs/conceptual/constraints-%5Bfsharp%5D)。大约有十几种参数类型约束种类。你可以查看文档(docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/generics/constraints)以获取它们的完整列表。

可以使用and构造来组合约束,如下面的代码片段所示(Ch10_2.fsx):

let inline constrained (param: ^a 
    when ^a: equality and ^a: comparison) = () 

type Good = Good 

[<NoEquality; NoComparison>]type Bad = Bad 

Good |> constrained 
// Compiles just fine 
Bad |> constrained 
// Error: type Bad does not support comparison constraint 

在这里,我们有两种区分联合:Good,默认情况下是公平的可比较的,而Bad,通常也是公平的且可比较的,但在这里它被装饰了[<NoEquality; NoComparison>]属性。由于constrained函数要求其泛型param参数既是公平的又是可比较的类型,因此constrained Good可以编译,而constrained Bad则不能。

显式或推断约束?

几年前,我尝试创建 F# 泛型代码。当时,我的思路是这样的:如果需要创建一个可以处理有限几种参数类型的泛型函数,正确的做法是对后者进行适当的显式约束。我甚至在 StackOverflow (stackoverflow.com/q/16737675/917053 ) 上询问了惯用的方法。以下是我想要引用的其中一个答案(stackoverflow.com/a/16738811/917053 ):

显式约束是一种表达你想做什么的方式。还有什么比做它并且让编译器静态证明该参数对于操作是有效的更好的方法吗?

这是一个启发性的观察,不是吗?

然而,如果你仍然想根据某些外部考虑因素显式限制泛型函数的有效参数类型列表,那么你可以使用以下方法,这是我在另一个答案(stackoverflow.com/a/16739483/917053 )中提到的,该方法基于辅助类型的重载静态方法(Ch10_2.fsx):

[<AutoOpen>] 
module Restrict = 
    let inline private impl restricted = 
        printfn "%s type is OK" (restricted.GetType().FullName) 

    type Restricting = Restrict with 
        static member ($) (Restrict, value: byte) = impl value 
        static member ($) (Restrict, value: sbyte) = impl value 
        static member ($) (Restrict, value: int) = impl value 
        static member ($) (Restrict, value: uint32) = impl value 
        static member ($) (Restrict, value: bigint) = impl value 

    let inline doit restricted = Restrict $ restricted 

前面的代码片段中有三个组件:

  • 私有函数impl,它对一个泛型受限参数类型'a执行所需操作;换句话说,对没有任何约束的参数执行操作

  • 辅助类型Restricting,它有一个单一的区分联合情况Restrict,通过在所需的类型集合(bytesbyteintuint32bigint仅为了说明)上重载的静态成员$来增强

  • 用户界面函数doit,其受限参数已被其他两个部分静态约束

让我们看看以下脚本(Ch10_2.fsx)中前面代码的工作原理:

doit 1uy 
doit 1y 
doit 1 
doit 1u 
doit 1I 
doit 1L // does not compile 
doit 1.0 // does not compile 
doit 1.0m // does not compile 
doit '1' // does not compile 

前五个与受限类型匹配的用例可以正常编译;最后四个用例没有按照预期的诊断编译:

没有重载匹配op_Dollar"方法

下面的截图展示了前五个用例的成功执行:

显式或推断约束?

显式约束泛型代码的执行

内联作用域

F# 中的内联不仅限于模块级别的函数。使用内联静态和实例方法完全没问题。例如,在下面的代码片段中,我们有类型 Bar,它有一个静态方法 doIt 和类型 Foo,它是在任何泛型类型 ^T 上定义的,该类型有一个静态成员 doIt,具有匹配的签名,并且 inline 成员 Invoke 调用 ^TdoIt 方法,如下所示(Ch10_2.fsx):

type Bar() = 
  static member doIt() = 42 

type Foo< ^T when ^T: (static member doIt: unit -> int)>(data: ^T []) = 
  member inline this.Invoke () = (^T : (static member doIt : unit -> int) ()) 

let result = (Foo([|Bar()|]).Invoke()) 
// val result : int = 42 

前面的示例所展示的一个复杂问题是,从 F# 外部访问静态或实例 inline 方法,例如,使用普通的 C# -> F# 互操作性场景。记住,正常的 MSIL 无法支持这样的约束。

为了解决这个微妙的问题,上面 Invoke 方法实现的编译后的 MSIL 只会抛出异常。函数的实际内联体仅从 F# 在其程序集元数据中保持可访问。

内联优化

内联与代码优化之间的关系相当微妙。通常,很难预测通过内联泛化的后果。

然而,有时,通过内联可以实现巨大的性能提升。一个臭名昭著的例子是 F# 处理 System.DateTime 的相等性,其中 datetime1 = datetime2 表达式的编译涉及到装箱。看看下面的代码片段(Ch10_2.fsx):

open System 
#time "on" 
let x, y = DateTime.MinValue, DateTime.MaxValue 
for i = 0 to 10000000 do x = y |> ignore 
//Real: 00:00:00.421, CPU: 00:00:00.406, GC gen0: 115, gen1: 2, gen2: 1 

在这里,仅使用 = 运算符,我们可以观察到一定的垃圾回收活动。

然而,假设我们只是内联重新定义的相等运算符 ==,如下面的代码片段所示(Ch10_2.fsx):

open System 
#time "on" 
let inline eq<'a when 'a :> IEquatable<'a>> (x:'a) (y:'a) = x.Equals y 
let inline (==) x y = eq x y 
for i = 0 to 10000000 do x == y |> ignore 
//Real: 00:00:00.022, CPU: 00:00:00.015, GC gen0: 0, gen1: 0, gen2: 0 

然后,我们实现了完全没有垃圾回收活动,并且性能提高了令人印象深刻的 19(!) 倍。

编写泛型代码

我希望通过另一个非平凡泛型函数的例子来总结编写泛型代码的主题,这个函数是我作为一个“函数式编程”面试样本实现的(infsharpmajor.wordpress.com/2013/05/03/if-google-would-be-looking-to-hire-f-programmers-part-4/):给定一个任意正数,找到一个由相同数字组成的下一个更大的数。如果不存在这样的数,则返回原始数字。

我们将解决方案作为一个泛型函数来处理,允许参数为任何整型,或者仅由数字组成的任何东西,无论是 byteBigInteger 还是 nativeint

基线方法是将数字拆分为数字列表,生成所有数字排列的列表,将数字重新组合成数字,对数字列表进行排序,最后选择给定参数的下一个元素。显然,这个“解决方案”的时间和空间复杂度都很糟糕,所以让我们改进它:

  • 优化解决方案的第一个有用观察是,如果给定数字中存在一对相邻的数字,其中左侧的数字严格小于右侧的数字,则解决方案存在。

  • 下一个有用的观察是,如果我们从右到左以宽度为 2 的滑动窗口扫描给定数字的数字列表,那么第一个与第一个观察结果匹配的数字对将是变化的位置。它左边的所有内容(如果有的话)必须保持不变。

  • 最后一个有用的观察是取与第二个观察结果匹配的数字对。包括对数字对的右侧元素在内的右侧子列表是从右到左排序的。必须替换数字对的左侧元素的数字必须是子列表中的最小较大数字。我们刚刚替换的左侧元素应该放置在某个位置,以保持子列表的顺序。

现在,如果我们连接(如果非空)变化数字左侧的子列表,然后是替换数字,然后是适应变化数字后的反转子列表,并将结果数字列表转换为数字,这将产生一个具有出奇好的时间复杂度O(n)和空间复杂度O(n)的解决方案,其中 n 是原始数字中的数字数量。解决方案代码片段如下(Ch10_3.fsx):

let inline nextHigher number = 
    let g0 = LanguagePrimitives.GenericZero<'a> 
    let g1 = LanguagePrimitives.GenericOne<'a> 
    let g10 = (g1 <<< 3) + (g1 <<< 1) 

    let toDigits n = 
        let rec toDigitList digits n = 
            if n = g0 then digits 
            else toDigitList ((n % g10) :: digits) (n / g10) 
        toDigitList [] n 

    let fromDigits digits = 
        let rec fromDigitList n = function 
            | [] -> n 
            | h::t -> fromDigitList (n * g10 + h) t 
        fromDigitList g0 digits 

    let make p ll  = 
        ll |> List.rev |> List.partition ((<) p) 
        |> fun (x,y) -> (x.Head::y) @ (p::(x.Tail)) 

    let rec scan (changing: 'a list) source = 
        match source with 
        | [] -> changing 
        | h::t -> if h >= changing.Head then 
                    scan (h::changing) t 
                  else 
                    (List.rev t) @ (make h changing) 

    number |> toDigits 
           |> List.rev |> fun x -> scan [(x.Head)] (x.Tail) 
           |> fromDigits 

让我们通过以下截图中的 FSI 运行一些使用案例来观察这个效果:

编写泛型代码

非平凡函数的泛型实现

考虑到 F#编译器为nextHigher推断的静态约束表达式的复杂性,如前一个截图所示。从您脑海中想出一个如此复杂的表达式确实是一项挑战。让编译器真正地做它的工作吧。

类型增强

一般化的对立面是专业化,它与 F#中的类型增强相关联。值得注意的是,官方的F# 4.0 语言规范(fsharp.org/specs/language-spec/4.0/FSharpSpec-4.0-latest.pdf )没有使用类型扩展来引入这个术语。尽管如此,类型增强表达式实际上是普遍存在的,并且与类型扩展可以互换使用。我个人认为,增强是一个更好的同义词,因为它表示通过添加、定制甚至删除功能来对现有事物进行专业化。因此,我们将坚持使用它。

以下图显示了 F#中可用的两种类型增强方式。它们使用相同的语法,但表示不同的用例。内省增强定制您的代码,而可选增强可能定制代码之外的类型:

类型增强

类型增强的 F# 风格

但为什么你需要首先自定义自己的代码呢?这正是某些 F# 特定使用模式发挥作用的地方。这些模式可以在 F# 核心库内部以及第三方扩展中反复看到。它们解释了裸类型是如何首先创建的,然后获得一个携带一些辅助函数的关联模块,最后类型是如何通过一些静态方法进行扩展的。我们可以将 复数类型的定义 (github.com/fsprojects/powerpack-archive/blob/master/src/FSharp.PowerPack/math/complex.fs) 视为这种模式的体现:

前面的定义可能被认为是一个非常整洁的惯用内联增强模板。

让我带您了解一些类型增强的典型用例。

通过移除进行增强

初看起来,通过移除进行增强可能听起来像是自相矛盾。然而,并非如此;请耐心等待。看看以下代码片段 (Ch10_4.fsx):

type Outcome = 
| Success 
| Failure 
with 
    member x.IsFailure = 
        match x with 
        | Failure -> true 
        | _ -> false 
    member x.IsSuccess = not x.IsFailure 

在这里,我的意图是隐藏在区分联合类型 Outcome 的属性背后的模式匹配。然而,突然间,这段看似无害的代码无法编译,如下面的截图所示:

通过移除进行增强

F# DU 实现细节泄露

F# 编译器在 IsFailure 属性名下方伴随一条红色波浪线,并显示一个令人惊讶的消息(参考前面的截图),提示编译器默认为每个 <Name> 区分联合的使用情况添加了 Is<Name> 私有属性,通过定义同名属性,我们使这个细节泄露出来。

我们能否有效地从Outcome定义中移除默认的编译器生成的增强?恰好我们可以使用.NET 属性来实现这一点,这个属性专门为此目的而设计:DefaultAugmentation (msdn.microsoft.com/visualfsharpdocs/conceptual/core.defaultaugmentationattribute-class-%5bfsharp%5d )。

如果我们只是用[<DefaultAugmentation(false)>]属性装饰Outcome类型定义,那么一切都会回到直观预期的行为,并且上述属性名冲突就会消失。

通过添加来增强

现在让我来做完全相反的事情,通过添加特性来增强类型。我将使用来自 Jet.com 技术实践的真实的(当然,简化后的)案例。

想象一下 Jet 的电子商务平台支持以下交易类型(Ch10_4.fsx):

type Sale = 
    | DirectSale of decimal 
    | ManualSale of decimal 

type Refund = 
    | Refund of decimal 

当我们为了支付或分析目的而汇总这些交易时,非常希望您能够操作代表任何有效交易混合的集合。但是,我们能否在类型化集合中混合不同的类型呢?

傻瓜式的暴力方法可能会利用任何.NET 类型都是System.Object子类型的事实。因此,以下集合可能是完全可行的(Ch10_4.fsx):

let ll: obj list = [box (DirectSale 10.00M); box (Refund -3.99M)] 

然而,这种方法抹去了 F#的一个主要优势,即静态类型安全,这意味着不幸的是,从 F#编译器的角度来看,以下集合也是完全可行的:

let ll': obj list = [box (Refund -3.99M); box 1; box "Anything"] 

一个核心的面向对象开发者会继续依赖继承,引入类似于这里所示的Transaction超类(Ch10_4.fsx):

type Transaction = 
  | Sale of Sale 
  | Refund of Refund 

let ll: Transaction list = [Sale (DirectSale 5.00M); Sale (ManualSale 5.00M); Refund (Refund.Refund -1.00M)] 

这是一个可接受的方法,但从潜在的未来扩展的角度来看,它不够灵活。而且总体来说也很尴尬。

还有其他想法吗?是的,类型增强来拯救!好吧,从某种意义上说。

让我定义一个虚拟的标记接口 ITransaction,如下所示:

type ITransaction = interface end 

现在,不幸的是,F#不允许您在已定义的类型之后添加接口。但我们可以仍然定义我们的交易,按照以下方式增强标准的 DU(Ch10_4.fsx):

type Sale = 
    | DirectSale of decimal 
    | ManualSale of decimal 
    interface ITransaction 

type Refund = 
    | Refund of decimal 
    interface ITransaction 

此外,我们可以使用 F#支持函数逆变参数的惯用技巧:

let mixer (x: ITransaction) = x 

现在我们可以按照以下方式表示所寻求的集合:

let ll: list<_> = [mixer(DirectSale 10.00M); mixer(Refund -3.99M)] 

到目前为止,一切顺利。现在ll是强类型的ITransaction列表,但它可以携带任何当前(以及如果需要的话,未来)的交易类型。如果需要将它们解装回具体的交易,混合在一起并不是一个大问题,如下所示(Ch10_4.fsx):

#nowarn "25" 

let disassemble (x: ITransaction) = 
    match x with 
    | :? Sale as sale -> (function DirectSale amount -> (sprintf 
"%s%.2f" "Direct sale: " amount, amount) 
    | ManualSale amount -> (sprintf "%s%.2f" "Manual sale: " amount,
amount)) sale 
    | :? Refund as refund -> (function Refund amount -> (sprintf
"%s%.2f" "Refund: " amount, amount)) refund 

(在上面的脚本开头神秘地关闭编译器警告“25”是为了说明类型匹配的方式。F#编译器假设可能有比前面match表达式包含的更多“实现”ITransaction的类型。我知道我已经涵盖了那里所有的案例,所以这个警告只是一个噪音。)

配备了这些工具,执行例如将具体交易列表聚合到单一付款中的操作就变得容易了 (Ch10_4.fsx ):

[mixer(DirectSale 4.12M);mixer(Refund -0.10M);mixer(ManualSale 3.62M)] 
|> List.fold (fun (details, total) transaction -> 
    let message, amount = disassemble transaction in 
    (message::details, total + amount)) 
    ([],0.00M) 
|> fun (details,total) -> 
    (sprintf "%s%.2f" "Total: " total) :: details 
|> List.iter (printfn "%s") 

在 FSI 中运行前面的脚本将产生以下截图所示的结果:

通过添加增强

通过标记接口增强 DU

摘要

本章展示了在适当的情况下如何处理代码泛化与/或特殊化的问题。

在下一章中,我们仅仅会触及到高级 F# 模式的表面,因为它们的详细覆盖可能需要另一本书来阐述。

第十一章:F#专家技巧

到目前为止,本书主要介绍了构成成功 F#惯用用法核心的常规 F#功能。这些相关用法模式的共同特征是它们简单直接。对于任何中级 F#实践者来说,掌握它们是必不可少的。

在本章中,我将跳出常规空间,其中表达式总是产生结果,计算是顺序进行的,代码必须先编写才能被后续使用。我将向您介绍一些专家级别的 F#技巧,这是在非函数范式中被认为过于复杂且容易出错的令人兴奋的使用模式领域。

在本章中,我将从 F#惯用用法的角度介绍以下主题:

  • 类型提供者

  • 并发编程

  • 响应式编程

  • 元编程

我将通过提供简要概述和从企业实战中摘取的简洁用法示例来逐一介绍这些主题。我将尝试表明,这些功能并非真正令人费解,通常为开发者提供强大的安全网。然而,请不要期待对这些主题进行深入探讨。将本章内容视为成为熟练掌握这些 F#使用模式的路线图,作为刺激和实际应用提示。

关于自定义计算表达式的说明

我决定不在本书中涵盖任意的F#计算表达式(docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/computation-expressions ),尽管 F#本身在诸如序列表达式(在第六章中介绍,序列 - 数据处理模式的核心)、查询表达式(在第九章中介绍,更多数据处理)和异步表达式(将在本章中讨论)等重要的语言特性之下内置了这种机制。尽管自定义计算表达式在某些情况下可以编写非常优雅的代码,但我感觉在这里介绍这个特性可能会让我们偏离我们追求的实用性道路。

注意事项

对于那些对 F#计算表达式有深入理解和掌握兴趣的人来说,可以参考 Scott Wlaschin 关于此主题的出色详细阅读材料:“计算表达式”系列 (fsharpforfunandprofit.com/series/computation-expressions.html )。

探索类型提供者

坦白说,我认为类型提供者是 F# 中最激动人心、最强大和最实用的特性之一。在我看来,应用类型提供者的能力是使用 F# 进行企业级软件开发的最强有力的论据之一。

功能回顾

F# 中的类型提供者代表了一种相当独特的实用模式,以强类型方式操作各种数据源。这种操作是通过从数据源特征派生出的类型、方法和属性来完成的,这些类型、方法和属性在编译时以完全自动化的方式构建。开发者不需要编写和/或维护这些自动提供的数据操作手段。

自动代码生成的想法本身和金字塔一样古老,但使其与众不同的因素是它的通用性、易用性和无痛苦的使用体验。那些曾经与SqlMetal (msdn.microsoft.com/en-us/library/bb386987(v=vs.110).aspx ) 或 WSDLTool (msdn.microsoft.com/en-us/library/7h3ystb6(v=vs.100).aspx ) 作斗争的人会非常欣赏类型提供者的方式。

事实上,创建一个具有生产质量的实用类型提供者可能需要大量的技能和努力。然而,一旦创建,类型提供者组件就可以无限制地使用,因此使用的好处远远超过了构建的痛苦。

值得一提的是,自从 F# 3.0 中引入类型提供者以来,许多有价值的数据源种类已经被涵盖。自从在《Twelve F# type providers in action》blogs.msdn.microsoft.com/dsyme/2013/01/30/twelve-f-type-providers-in-action/)中提到的类型提供者构建的初期浪潮以来,可用的提供者已经变得更加成熟,提供了流畅且顺畅的使用体验。

言归正传;让我们首先看看 F# 类型提供者工作原理的大致情况。这将在以下图中展示:

功能回顾

F# 类型提供者的工作原理

不假装涵盖所有潜在的外部数据源,我在这里提到了以下几种:

  • 在企业开发中经常使用的多种特定格式的文件(Excel、逗号分隔、JSON 等等)

  • 数据库引擎(Microsoft SQL Server、Oracle、MySQL 等等)

  • 实现不同协议和数据展示格式的各种 Web API

  • 可以远程控制以实现所需处理并给出输入数据的各种应用程序引擎(Python、R、MatLab 等等)

魔法从编译时开始,当开发者引用在 F#应用程序中预期从给定数据源提供的代码类型时:对数据库的给定查询的结果,某些 Excel 文件的表格数据,某些数据的聚类结果;你叫它什么。F#编译器需要相应的类型提供者以库包的形式可用。在编译时从给定数据源获取所需的元数据,类型提供者与 F#编译器一起构建提供类型、方法和属性,允许你以强类型方式即时处理外部数据。

例如,SQLClient 类型提供者(fsprojects.github.io/FSharp.Data.SqlClient )使用编译时连接字符串,在编译期间连接到给定的数据引擎实例,并使用 T-SQL 中给定查询的文本,利用某些系统存储过程来查找与即将返回的结果集的列相关联的类型。这种类型信息转化为与查询关联的即时构建的类型。因此,如果我们是在 Visual Studio 下编译,我们将获得与编译器提供的类型相关的 F#序列的结果集字段的 Intellisense。

如果在运行时对其他具有与编译查询中参与表类似的表架构的数据引擎执行相同的查询,提供的数据库访问类型仍然适用于数据转换。

重要的是要理解,数据库模式与提供的类型相关的查询之间的对应关系是在静态类型审查下保持的;如果这个方程式的任何一部分(无论是查询表达式还是涉及的架构)发生变化,代码将无法编译。

这既是缺陷也是祝福,因为它可以可靠地保护应用程序代码和数据层之间的潜在错误。然而,在编译时需要访问 SQL 数据引擎的必要性使得构建、持续集成等安排变得复杂。

个人而言,我是上述类型提供者的忠实粉丝,并发现一个有趣的现象,那就是人们往往没有意识到编译时和运行时之间的区别,实际上限制了可能性。

小贴士

一些开发者时不时地会问,是否可以在运行时更改与提供的类型相关的查询。显然,答案是绝对的可以,因为这需要你更改已经生成的特定类型的代码。同时,在运行时更改连接字符串以访问数据要处理的目标数据引擎是完全可以(并且是预期的)。通常,后者可以通过提供具有运行时连接字符串作为参数的类型构造函数来实现。

我将在本章后面使用这个类型提供者进行演示,所以你将有机会检查你的理解。

演示问题

在选择类型提供者主题的示例问题时,我最初怀疑是否可行深入探讨类型提供者创建过程,或者这本书的格式是否限制我只能使用现有的类型提供者。我甚至征求了一群同事的意见,看看是否可以实施一个不超过 20 行 F# 代码的实用类型提供者。结果证明是肯定的,多亏了上述 SQLClient 类型提供者的作者之一,他指出了这个提供者带来的有趣问题:SQL 代码与 F# 代码之间的关系。

从关注点分离的角度来看,将属于应用程序的 T-SQL 查询作为字面量嵌入到 F# 代码中并不是一个完美的提议。理想情况下,最好将这些查询与 F# 代码分开,存放在一个单独的 SQL 脚本目录中,并为每个查询指定一个 .sql 文件。但如果我们需要在编译时将这些文件的內容作为字符串字面量表示在应用程序代码中,这种安排又如何可能呢?

哈哈!出路就是再使用另一个类型提供者!

一个内部“文件读取器”类型提供者可以在编译时将相应的 提供 类型与存储在该处的 SQL 查询文本关联起来,作为字面量字段与每个 SQL 查询文件相关联。这个字面量字段在文本中与字面量字符串常量没有区别。这种优雅的方法确实很棒!

考虑到这种在编译时和运行时考虑之间的清晰划分背后的明显教学价值,我决定提出类似的东西。

想象一下,我们想要用密钥来保护应用程序的执行,但又不想让密钥值出现在源代码的任何地方。相反,密钥可能被保存在某种类型的密钥库中,并在构建期间与应用程序相关联。这种保护的不足之处很明显,因为密钥值仍然会出现在编译后的应用程序程序集的某个地方。但这不是练习的重点。要求是:源代码中不应有任何密钥值。

演示解决方案

我们的解决方案是创建一个类型提供者,给定一个指向包含密钥的外部存储库的引用,它会提供一个具有从存储库中提取的 string 密钥值的类型,并将其作为字面量字段存储。这意味着这样的字段可以用作 match...with F# 表达式中的案例值,而不会以任何方式泄露底层值。此外,你还会对内部类型提供者的工作原理有一个牢固的理解,以及一个在不确定类型提供场景中发生什么活动时可以回忆的粘性模式。

我已经准备好开始实现。在 2016 年编写类型提供者比 2012 年首次向大众推出该功能时容易得多。感谢惊人的 F# 社区的开源努力,他们共同组装并打包了一个 SDK,以 NuGet 包的形式提供 F# 类型提供者的创建,即 FSharp.TypeProviders.StarterPack (www.nuget.org/packages/FSharp.TypeProviders.StarterPack)。请耐心等待。只需执行以下步骤:

  1. 创建一个新的 Visual Studio 项目以创建名为 KeyTypeProvider 的 F# 库。

  2. 删除两个具有 .fs.fsx 扩展名的生成文件。

  3. 使用 包管理控制台,将类型提供者启动包 NuGet 包添加到刚刚创建的项目中,输入 Install-Package FSharp.TypeProviders.StarterPack 命令,并观察项目添加了一堆源代码文件(ProvidedTypes.fsiProvidedTypes.fsDebugProvidedTypes.fs)。

  4. 添加一个名为 KeyTypeProvider.fs 的新 F# 源代码文件,并将其放置在上一条项目符号中列出的注入文件列表的最后一个文件之下(记住,F# 源代码文件被引入编译器的顺序非常重要)。

就这样;我们准备好将类型提供者代码编织到后面的文件中。我将相应的代码片段放置如下(KeyTypeProvider.fs):

namespace FSharp.IO.DesignTime 

#nowarn "0025" 

open System.Reflection 
open System.IO 
open Microsoft.FSharp.Core.CompilerServices 
open ProviderImplementation.ProvidedTypes 

[<TypeProvider>] 
type public KeyStringProvider(config : TypeProviderConfig) as this =  
    inherit TypeProviderForNamespaces() 

    let nameSpace = "FSharp.IO" 
    let assembly = Assembly.LoadFrom(config.RuntimeAssembly) 
    let providerType = ProvidedTypeDefinition(assembly, nameSpace,
        "SecretKey", baseType = None, HideObjectMethods = true) 

    do 
        providerType.DefineStaticParameters( 
            parameters = [ ProvidedStaticParameter("Path", 
                typeof<string>) ], 
            instantiationFunction = fun typeName [| :? string as path 
              |] -> 
                let t = ProvidedTypeDefinition(assembly, nameSpace,
                  typeName, baseType = Some typeof<obj>,
                  HideObjectMethods = true) 
                let fullPath = if Path.IsPathRooted(path) then path  
                  else Path.Combine(config.ResolutionFolder, path) 
                let content = File.ReadAllText(fullPath) 
                t.AddMember <| ProvidedLiteralField("Key",
                    typeof<string>, content)
                t
            ) 

        this.AddNamespace(nameSpace, [ providerType ]) 

[<assembly:TypeProviderAssembly()>] 
do() 

这不是 exactly 20 行代码,但相当接近。我将只概述前面代码片段中各个部分的目的。

注意

愿意对这类代码进行修改的各位可以参考 教程:创建类型提供者 (docs.microsoft.com/en-us/dotnet/articles/fsharp/tutorials/type-providers/creating-a-type-provider),作为辅助工具。

在引用相关库之后,我们的 KeyStringProvider 类型提供者类型(是的,类型提供者有自己的类型,当然)的定义紧随 [<TypeProvider>] 属性之后,并从 TypeProviderForNamespaces 类型继承,该类型定义在这些自动插入的代码文件中的其他地方。

下面的三行代码定义了提供类型的名和位置:FSharp.IO.SecretKey 和运行时程序集。

下面的do表达式的主体是实现的核心。它定义了提供的类型将有一个单一的Path静态参数,其类型为string,最重要的是,在实例化时,提供者将读取由Path引用的文件中的文本,并将摄入的字符串作为提供类型字面静态字段Key的值。是的,我同意本地文本文件不是最可靠的密钥库,但这个设计选择是为了简洁;密钥的保存方式与主题完全无关。这部分原则上可以用任何其他方式实现。

最后的do()表达式被[<assembly:TypeProviderAssembly()>]属性装饰,这只是类型提供者特定的.NET 程序集加载机具的标记。

我们完成了。构建我们的项目应该在目标bin目录中产生KeyTypeProvider.dll。我们的类型提供者已经准备好投入使用。

我为这个目的创建了一个简短的 F#脚本(Ch11_2.fsx):

#r @"C:\code\packtbook\KeyTypeProvider\bin\Debug\KeyTypeProvider.dll" 
open FSharp.IO 
open System 

type Vault = SecretKey< @".\Secret.txt"> 

let unlock = function 
| Vault.Key -> true 
| _ -> false 

while Console.ReadLine() |> unlock |> not do 
    printfn "Go away, Hacker!" 

printfn "Please proceed, Master!" 

为了使这个脚本编译,你需要将Secret.txt文件(在Vault的类型声明中使用我们提供的类型FSharp.IO.SecretKey引用)放入项目目录中,与文件系统中的前面脚本并排。一旦我们这样做,Visual Studio 中的 Intellisense 就开始工作,这在下面的图中有所体现:

演示解决方案

将本地文件内容打包为类型的静态字段字面值

注意,类型提供者在编译时通过 Intellisense 揭示了秘密的内容(ABigSecret字符串行)。尽管如此,秘密根本不存在于源代码中。此外,将秘密作为Vault.Keyfunction表达式的案例,并且没有来自 F#编译器的任何反对意见,这清楚地表明编译器完全接受它是一个真正的字面字符串!

现在,是时候看看所有这些在类型提供者开发环境之外的表现了,在一个单独的 FSI 会话中。结果如下面的截图所示,完全符合预期。每次当你对类型提供者模式的适用性和它应该帮助你整理问题的能力感到困惑时,就回想一下这个有趣的 F#类型提供者应用程序。

演示解决方案

在 FSI 脚本中使用 SecretKey 类型提供者

总结来说,F# 类型提供者代表了一种相当独特的自动类型生成习惯用法,这可能会在生产力以及代码质量上带来显著的提升。

探索并发计算

在许多年学术兴趣增加之后,功能编程重新受到工业界的关注,这在很大程度上是由于电子技术的实现能力。一方面,当代计算机的能力使得三十年前被认为是纯科学的计算机科学发现,由于计算速度和容量的巨大增加,现在变得非常实用。另一方面,在硅层面上,科学已经达到了进一步加速单个处理器核心操作的物理极限。因此,实际的计算速度提升是通过将一定量的计算分配给一组紧密协作的处理器来实现的。

特性回顾

事实上,这个充满活力的新世界——廉价的多人核心处理器,无法承担昂贵、易出错、精神负担沉重的编程方法。它要求有比计算机科学在计算能力广泛增长时代开发的编程原语更高层次的并发驯服抽象。

这些原语在揭示并发计算背后的主要问题中发挥了作用——这种计算比我们习惯的顺序计算要少确定性得多。如果顺序计算中的非确定性通常与实现前者的环境物理环境的缺陷有关,那么并发计算中的非确定性是内在的。这意味着,在多个并发执行的计算之间进行同步的编程原语的易出错操作提供了许多自毁前程的方式。

自我强加的非确定性的最突出例子是死锁en.wikipedia.org/wiki/Deadlock),当并发程序部分在共享资源上缺乏适当的同步时,在某些条件下可能会相互锁定。

更为复杂(并且可能更加危险)的情况是,并发代码可能在极其罕见的情况下出现异常行为。这可能是非常危险的,因为这种条件可能不会在质量保证和用户验收测试期间自行出现。然后,带有“炸弹”的缺陷代码基本上被发布到生产环境中,并且完全符合墨菲定律,在最不合适的时候发生爆炸。

功能编程对提高并发程序质量的承诺对行业来说如此宝贵,以至于许多主流编程语言都增加了附加的功能特性。

在我们深入了解 F#如何驯服并发非确定性之前,让我们看看在常见的并发伞下,需要认识到的独特方面:

  • 同步与异步:前者在评估下一个表达式之前,不会开始评估前一个表达式。后者允许你在一些半评估的表达式之间切换。

  • 并发与并行:并行假设使用多个处理单元同时评估多个表达式,而并发可能是一个处理单元对几个表达式的异步部分评估。

  • 交互式与响应式:前者驱动外部环境,而后者响应外部环境的需求。

F#提供了一种使用异步表达式/工作流docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/asynchronous-workflows)的统一机制来驯服并发。简而言之,异步表达式,即前面提到的计算表达式的特定形式,是以这种形式编写的:

async { expression } 

它具有Async<'T>的泛型类型。反过来,Async类提供了一组函数,这些函数在几种情况下触发前面表达式的实际异步评估。

这确实是一个非常优雅且直接的机制。它允许你在熟悉的功能组合形式下隐藏评估将并发进行的这一事实。例如,考虑以下无害的代码片段:

[ for i in 91..100 -> async { return i * i }] // Async<int> list 
|> Async.Parallel // Async<int []> 
|> Async.RunSynchronously // int []  

它执行了一个相当复杂的函数组合,中间类型以行注释的形式呈现,其中第一行使用列表推导表达式产生一个Async<int>list,然后借助Async.Parallel组合器扩展成Async<int[]>的并行计算,这些并行计算随后通过另一个Async.RunSynchronously组合器将它们异步计算的表达式合并到结果的int[]数组中,得到 10 个数字:

val it : int [] = 
  [|8281; 8464; 8649; 8836; 9025; 9216; 9409; 9604; 9801;
    10000|] 

我不会尝试向你证明前面的代码片段能够让你展示出通过计算并行化带来的性能提升。前面的评估如此简单,以至于并行代码片段实际上必须比仅仅顺序计算的类似代码要

[for i in 91..100 -> i * i] 

这是因为与直接的顺序列表推导评估相比,并行 CPU 异步安排应该会引入额外的开销。

然而,当我们进入企业开发领域所珍视的领域时,一切都会改变,即开始处理并行 I/O。I/O 并行化带来的性能提升将是下一个演示问题的主题,该问题展示了由 F#异步计算所启用的设计模式。

演示问题

让我构建一个 I/O 密集型应用程序,这将允许演示当应用 F#并行 I/O 异步模式时,可以实现的真正惊人的加速。一个很好的用例是 SQL Server,它具有扩展能力,允许你在与 F#作者和博客作者通常提供的多个并发 Web 请求演示相比时,实现有说服力的改进。

作为异步并发工具,我将使用FSharp.Data.SqlClient类型提供者的SqlCommandProvider功能(github.com/fsprojects/FSharp.Data.SqlClient/blob/master/src/SqlClient/SqlCommandProvider.fs),它允许使用AsyncExecute()方法进行异步查询。

我将创建同步和异步的相同任务,从 SQL Server 提取数据,然后执行性能比较,以检测和测量 F#异步 I/O 使用模式应用所获得的好处。

演示解决方案

为了简洁起见,SQL 相关部分将非常简单。在 Visual Studio 2013 或任何其他可用的 Microsoft SQL Server 安装中,针对(localdb)\ProjectsV12数据库引擎实例执行以下 T-SQL 脚本,前提是它满足类型提供者的系统要求fsprojects.github.io/FSharp.Data.SqlClient/),将从零开始创建必要的数据库组件(Ch11_1.sql):

CREATE DATABASE demo --(1) 
GO 

Use demo  
GO  

SET ANSI_NULLS ON 
GO 

SET QUOTED_IDENTIFIER ON 
GO 

CREATE PROCEDURE dbo.MockQuery --(2) 
AS 
BEGIN 
  SET NOCOUNT ON; 
  WAITFOR DELAY '00:00:01' 
  SELECT 1 
END 
GO 

在这里,标记为(1)的部分创建并准备使用demo数据库的实例,而标记为(2)的部分将dbo.MockQuery存储过程放入此数据库。这个没有输入参数的存储过程实现了一个非常简单的查询。具体来说,首先,它引入了 1 秒的时间延迟,模拟了一些数据搜索活动,然后返回一个包含整数1作为执行结果的单一数据行。

现在,我将转向注释演示解决方案的 F#脚本(Ch11_1.fsx):

#I __SOURCE_DIRECTORY__ 
#r @"../packages/FSharp.Data.SqlClient.1.8.1/lib/net40/FSharp.Data.SqlClient.dll" 
open FSharp.Data 
open System.Diagnostics 

[<Literal>] 
let connStr = @"Data Source=(localdb)\ProjectsV12;Initial Catalog=demo;Integrated Security=True" 

type Mock = SqlCommandProvider<"exec MockQuery", connStr> 

let querySync nReq = 
    use cmd = new Mock() 
    seq { 
        for i in 1..nReq do 
            yield (cmd.Execute() |> Seq.head) 
        } |> Seq.sum 

let query _ = 
    use cmd = new Mock() 
    async { 
        let! resp = cmd.AsyncExecute() 
        return (resp |> Seq.head) 
    } 

let queryAsync nReq = 
    [| for i in 1..nReq -> i |] 
    |> Array.map query 
    |> Async.Parallel 
    |> Async.RunSynchronously 
    |> Array.sum 

let timing header f args = 
    let watch = Stopwatch.StartNew() 
    f args |> printfn "%s %s %d" header "result =" 
    let elapsed = watch.ElapsedMilliseconds 
    watch.Stop() 
    printfn "%s: %d %s %d %s" header elapsed "ms. for" args       "requests" 

提示

考虑到上述 F#代码直接使用将无法编译,因为排版引入了几行换行。相反,请使用书中附带的代码部分作为工作 F#代码的来源。

在加载类型提供者包并打开所需的库之后,带有[<Literal>]属性的connStr值表示了设计时和执行时的 SQL 服务器连接字符串。如果使用其他版本的数据库引擎,此行可能需要修改。

下一行通过引入由SqlCommandProvider提供的类型Mock,展示了类型提供者魔法,确保了对由存储过程调用exec MockQuery表示的包装查询结果的静态类型访问,该调用是通过我们的connStr连接字符串进行的。

以下querySync函数确保了由提供的Mock类型实例表示的cmd命令的顺序执行。给定nReq次数,它产生一系列查询结果(每个都是结果集的单行中的1),然后使用Seq.sum聚合这个序列。如果我们评估querySync 10表达式,我们可能期望得到一个略高于 10 秒的延迟,以返回一个单一的数字,10

到目前为止,一切顺利。下面的query函数接受任何参数并返回一个类型为Async<int>的异步计算。我将这个函数放在queryAsync函数包裹的复合表达式中,有效地代表了querySync的并发变体。具体来说,nReq数字的数组被映射成相同大小的Async<int>数组,然后通过Async.Parallel全部展开,完成后通过Async.RunSynchronously重新连接,最终通过Array.sum聚合成一个单一的数字。

最后一个部分是一个高阶的计时函数,它只是测量并输出f args计算持续时间的毫秒数。

好吧;现在,是时候对我们的脚本进行测试了。我将代码放入 FSI 中,并测量querySyncqueryAsync执行 100 次的时间。您可以在下面的屏幕截图中看到测量结果:

演示解决方案

测量同步与异步 SQL 查询

你和我一样印象深刻吗?结果显示,在 SQL 查询的情况下,I/O 并行化允许性能提高大约 100 倍!

这个演示非常令人信服,我强烈建议您掌握并使用这个以及其他 F#惯用并发模式在实际工作中。

探索反应式计算

反应式计算是并发计算范围的一部分。它们只是强调了一个稍微不同的问题,即通用事件的处理。事件的处理可能是真正并发的,当同时发生的一个或多个事件被处理而没有任何形式的序列化,或者如果新事件在处理完前一个事件之前不被处理,则可能是真正顺序的。

功能审查

通常,类似于并发的事件处理视图在具有用户界面UI)组件的系统开发中根深蒂固,当来自输入设备的数据处理缓慢或反映图形 UI 组件视觉状态的数据时,这种处理简单是不可接受的,因为它会创建一个糟糕的用户体验UX)。

这一切都很不错,但让我们集中关注一个与 UI/UX 不直接相关的方面,即正在进行的事件处理的概念性考虑。由于这个考虑与 F#相关,我将限制审查的范围在.NET 边界内。

从历史上看,交互式操作系统(如 Windows)的发展提出了回调([en.wikipedia.org/wiki/Callback_(computer_programming)](https://en.wikipedia.org/wiki/Callback_(computer_programming)))的概念,这考虑到了事件事件处理器。这是反应式编程最低的概念层次,其中开发者的责任是为每个事件类提供处理器。

反应式计算的下一个抽象层次是面向对象编程,这体现在观察者设计模式en.wikipedia.org/wiki/Observer_pattern)上。现在,开发者可以将特定的事件类型处理流程视为名为Observable的事件类型(换句话说,主题)源与零个或多个对处理此主题事件感兴趣的部分(名为观察者)之间的交互。观察者通过动态注册注销到相应的 Observable 来表明对主题的兴趣。一旦出现属于主题的下一个事件,当时注册的相应观察者都会收到处理事件的提醒,然后继续等待下一个事件。

最后,反应式计算的概念精髓在计算机科学家Erik Meijer([en.wikipedia.org/wiki/Erik_Meijer_(computer_scientist)](https://en.wikipedia.org/wiki/Erik_Meijer_(computer_scientist)))领导的团队的开创性工作中得到了体现,他创建了.NET 的 Reactive Extensions (Rx)msdn.microsoft.com/en-us/library/hh242985(v=vs.103).aspx)。

Rx 背后的关键思想是通过引入一个基本的IObservable接口来集中处理推送拉取数据序列,这个接口与IEnumerable相反,因为它暴露了事件数据流。这与“正常”数据序列在被枚举后可以通过高阶函数进行拉取式组合,并使用 LINQ 进行查询类似——而可观察的事件序列(事件流)可以通过高阶函数进行接收式组合,并由 LINQ 进行处理。

F#支持上述所有三种抽象,并且与.NET 平台上的其他编程语言相比,还有一些改进。

注意

这个主题已经得到了很好的记录,我建议您参考有关Microsoft.FSharp.Control命名空间组件和Reactive Extensions (Rx)的 F#特定文档以获取详细信息:事件模块msdn.microsoft.com/visualfsharpdocs/conceptual/control.event-module-%5bfsharp%5d)。可观察模块msdn.microsoft.com/visualfsharpdocs/conceptual/control.observable-module-%5bfsharp%5d)。反应式扩展msdn.microsoft.com/en-us/data/gg577609.aspx)。

我不会重复前面的文档内容,而是将 F#反应式计算功能付诸实践,实现一个相关的实际任务。我会尽量使实现自包含。

演示问题

让我们考虑以下集成模式www.enterpriseintegrationpatterns.com/patterns/messaging/),这在企业中相当典型:通过两个点对点通道进行文档消息交换。我们是外部服务的客户端,该服务通过一对专用通道与我们通信。如果我们需要发送文档消息,我们只需将其推入出站通道,远程服务就会以某种方式消费它。如果服务向我们发送消息(s),它们会被投递到入站通道。当我们从入站通道拉取文档消息时,它就会从那里移除。以下图示说明了这种交互。

演示问题

企业双向文档交换

那些参与企业 LOB 开发的人可能已经识别出典型的电子数据交换(EDI)en.wikipedia.org/wiki/Electronic_data_interchange#Peer-to-Peer)对等案例。通常,提供商在选择特定的传输协议时相当保守,并倾向于坚持“老而金”的技术,如SSH 文件传输协议(SFTP)en.wikipedia.org/wiki/SSH_File_Transfer_Protocol),这是一种合理低成本的集成方式,当数据安全是要求时。由于企业可能需要与多个远程服务提供商进行 EDI,这种安排的数量可能相当可观。

然而,我并不打算专注于构建一个可配置的库,该库允许通过几行代码添加新的 EDI 提供商。相反,我将解决语义层,这通常位于架构考虑之外,即可能需要在双向文档消息交换中强制执行的关系,对于 SFTP 传输,这转化为将格式化文件推送到或从服务提供商那里拉取。

为了更具体一些,我提供给您一个来自 Jet.com (jet.com/ ) 财务领域的真实案例,我在那里目前以编写 F# 代码为生。让我们考虑支付系统作为客户端,银行作为服务提供商。服务的大致内容是执行付款建议并将汇款交付给 Jet.com 与其建立临时“我欠你”关系的法律实体和自然人账户:供应商和批发商、市场商人、有未报销业务费用的员工等等。

现在,让我们假设我们已经围绕 SFTP 构建了我们的通信代码,将付款推送到银行,获取回执、原始凭证等,所有重试都到位,所有轮子都在平稳运转。我们做得好吗?

结果表明答案实际上是“并不真的”。在这个时候,我们默默地假设银行的实现没有问题,基于一系列谬误,例如“这是关于金融的”,“如果银行有错误,它将无法生存”,“它太大,不允许失败”等等。然而,银行的软件只是软件,容易受到各种人为错误的影响。我们可能期望它的可靠性总体上高于一个随机初创公司最小可行产品代码,该代码构成一个实现热门商业想法的 Web 应用程序,在几次黑客马拉松期间在车库中编写。另一方面,每个银行的软件发布都包含下一个“最后一个错误”,不是吗?

有一次,Jet 的银行客户端软件没有考虑到以下场景:如果银行正确接受并执行每笔付款建议,但偶尔不向我们传达延迟的最终付款状态,会发生什么?付款接收者都对汇款现金进入他们的账户以及没有通信错误感到满意。如果我们假设我们的交付付款建议的成功结果,这个错误可能永远存在!这是一个低概率场景,但并非绝对不可能。事实上,在 Jet 的支付安排中,一个类似的缺陷在一段时间内未被注意到,直到市场报告开始显示延迟付款的数量不断增加。这真是尴尬!

我们能否通过主动处理我们的“拉”数据传输部分来解决这个问题?继续阅读以了解潜在解决方案的概述。

演示解决方案

一种(过于简化的)潜在解决方案是将“心跳”事件流与受保护的事件流混合。由于将单个特定类型的事件的保护泛化到任何类似类型的事件并不具有挑战性,让我考虑一个单一的保护事件类型以简化问题。

在这个受保护事件流混合中,我们设定了一个阈值,即在保护事件开始和实际发生受保护事件之间,认为多少次心跳是健康的。例如(具体的数字并不一定与实际情况相符),我们可以说如果 ACH 付款正在发送,并且随后有三个心跳事件后,ACHOrigination 事件仍未收到,这应该是问题的迹象,并且必须通知相关责任人员。

现在,让我使用反应式扩展(Ch11_3.fsx)实现前面的内容:

#I __SOURCE_DIRECTORY__ 
#r "../packages/FSharp.Control.Reactive.3.4.1/lib/net45/FSharp.Control.Reactive.dll" 
#r "../packages/Rx-Core.2.2.5/lib/net45/System.Reactive.Core.dll" 
#r "../packages/Rx-Interfaces.2.2.5/lib/net45/System.Reactive.Interfaces.dll" 
#r "../packages/Rx-Linq.2.2.5/lib/net45/System.Reactive.Linq.dll" 

open System.Reactive.Subjects 

type PaymentFlowEvent = 
| HeartBeat 
| ACHOrigination 
| GuardOn 

type GuardACHOrigination(flow: Subject<PaymentFlowEvent>, alerter: Subject<string>) = 
    let threshold = 3 
    let mutable beats = 0 
    let mutable guardOn = false 

    member x.Guard() = 
        beats <- 0 
        guardOn <- false 
        flow.Subscribe(function 
            | HeartBeat -> if guardOn then beats <- beats + 1; 
                printfn "Heartbeat processed"; 
                if beats > threshold && guardOn
                    then alerter.OnNext "No timely ACHOrigination" 
            | ACHOrigination -> beats <- 0; 
                guardOn <- false 
                printfn "ACHOrigination processed" 
            | GuardOn -> beats <- 0; guardOn <- true;
                printfn "ACHOrigination is guarded") 

let paymentFlow = new Subject<PaymentFlowEvent>() 
let alerter = new Subject<string>() 
let notifier = alerter.Subscribe(fun x -> printfn "Logged error %s" x) 

ignore <| GuardACHOrigination(paymentFlow,alerter).Guard() 

在从相应的 NuGet 库加载一系列所需组件后,我引入了反映先前提到的三种事件混合的 PaymentFlowEvent 类型。

接下来,GuardACHOrigination 类结合了由参数 flow 设置的 PaymentFlowEvent 事件流,该参数也称为 Subjectalerter 用于执行通知,以及将这些部分组合在一起的业务逻辑。Subject (msdn.microsoft.com/en-us/library/hh242970(v=vs.103).aspx ) 是可观察序列和观察者的组合,在先前的实现中扮演着核心角色。

Guard() 方法接受 flow,并借助其 Subscribe 方法,在流通过事件类型 PaymentFlowEvents 的每个实例到达时设置简单的状态机跟踪正在发生的事情。鉴于异常已被识别,诊断通知被推入 alerter

现在,我创建了所需的各个部分:paymentFlow 代表感兴趣的的事件流,alerter 用于在 Guard() 中接收通知,notifier 用于对 alerter 的通知事件采取行动,最后,使用 GuardACHOrigination(paymentFlow,alerter).Guard() 启动一切。

很好;现在是我们将事件流推入构建好的安排中,并观察 FSI 中的反应性行为的时候了。下面的截图反映了代码行为完全符合预期:及时的保护事件顺利通过,过期的保护事件触发警报,未受保护的事件被忽略:

演示解决方案

使用 F# 反应式代码保护事件流

在反应式方式中应用 F# 的演示模式是企业从业者应该掌握的重要工具技能。

探索引言和元编程

我想要在 F# 使用的先进模式中涵盖的最后一个功能是代码引用 (docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/code-quotations )。这个特性非常令人费解,它允许你像处理数据一样处理程序代码,并在需要时以及以需要的方式评估这些“程序作为数据”的部分。

功能回顾

从更操作的角度来看这个特性,一个程序片段可能被表示为一个表达式树,它代表代码但不从这个表示中生成代码。这允许在表达式树被评估时具有任意执行行为。它可以被评估为 F# 代码,或作为生成 JavaScript 代码的源,甚至作为 GPU 执行的代码,或以任何其他可行的方式。

引用表达式的酷之处在于它们是类型化的,可以从部分拼接在一起,或者使用主动模式分解成部分,以及其他特性。不深入细节,我想展示的是,如果需要,F# 通过允许你以编程方式调整程序代码并评估调整后的代码,提供了这一额外的灵活性层。为此,我将使用F# 引用评估器 (fsprojects.github.io/FSharp.Quotations.Evaluator/index.html )。

该特性的能力非常简短地演示如下 (Ch11_4.fsx ):

  • 获取所需的库支持:

            #I __SOURCE_DIRECTORY__ 
            #r 
             "../packages/FSharp.Quotations.Evaluator.1.0.7/lib
             /net40/FSharp.Quotations.Evaluator.dll" 
            open FSharp.Quotations.Evaluator 
    
    
  • 创建一个可变的引用 divider 值:

            let mutable divider = Quotations.Expr.Value (5) 
    
    
  • 创建并编译一个将 divider 拼接到其中的函数:

            let is5Divisor = <@ fun x -> x % %%divider = 0 @> 
               |> QuotationEvaluator.Evaluate 
    
    
  • 将编译的 is5Divisor 函数应用于几个参数:

            is5Divisor 14 // false 
            is5Divisor 15 // true 
    
    
  • 更改拼接的 divider 值:

            divider <- Quotations.Expr.Value (7) 
    
    
  • 注意,is5Divisor 的工作方式没有改变:

            is5Divisor 14 // false 
    
    
  • 将拼接的 divider 值重新编译到另一个函数中:

            let is7Divisor = <@ fun x -> x % %%divider = 0 @> 
               |> QuotationEvaluator.Evaluate 
    
    
  • 应用新编译的 is7divisor 函数:

            is7Divisor 14 // true 
    
    

在对引用的工作原理有了些了解之后,现在让我将这个特性应用到一个大型的演示问题中。

演示问题

在寻找演示问题时,我再次转向金融领域。让我们看看基于支付及时性的发票总额调整问题。提前支付未结发票可能会节省一些费用,而延迟支付可能会产生罚款。当供应商或供应商设定支付条款时,可以设置任何组合的溢价和/或罚款:既无溢价也无罚款,只有溢价,只有罚款,以及溢价和罚款都有。有一个安排可以让你轻松自然地处理这种多样性会很好。换句话说,寻求一个调整——为此,当应用于发票总额和支付日期时,找出与支付条款一致的实际支付金额。

演示解决方案

这里是实现所寻求的调整对象的脚本 (Ch11_4.fsx ):

#I __SOURCE_DIRECTORY__ 
#r "../packages/FSharp Quotations.Evaluator.1.0.7/lib/net40/FSharp.Quotations.Evaluator.dll" 
open FSharp.Quotations.Evaluator 
open System.Collections.Generic 
open System 

type Adjustment = 
| Absent 
| Premium of TimeSpan * decimal 
| Penalty of TimeSpan * decimal 

type Terms(?premium: Adjustment, ?penalty: Adjustment) = 
    let penalty = defaultArg penalty Absent 
    let premium = defaultArg premium Absent 

    member x.Adjust() = 
        match premium,penalty with 
        | Absent,Absent -> None 
        | Absent,Penalty (d,m) -> Some(<@ fun ((date:DateTime),amount) -> if DateTime.UtcNow.Date - date.Date > d then Decimal.Round(amount * (1M + m),2) else amount @> |> QuotationEvaluator.Evaluate) 
        | Premium(d,m),Absent -> Some(<@ fun ((date:DateTime),amount) -> if DateTime.UtcNow.Date - date.Date < d then Decimal.Round(amount * (1M - m),2) else amount @> |> QuotationEvaluator.Evaluate) 
        | Premium(d',m'),Penalty (d,m) -> Some(<@ fun ((date:DateTime),amount) -> 
            if DateTime.UtcNow.Date - date.Date > d then Decimal.Round(amount * (1M + m),2) 
            elif DateTime.UtcNow.Date - date.Date < d' then Decimal.Round(amount * (1M - m'),2) 
            else amount @> |> QuotationEvaluator.Evaluate) 
        | _,_ -> None 

首先要注意的是,必要的库已被加载。

然后定义Adjustment类型,它可以是AbsentPremium/Penalty,其结构为System.TimeSpan*decimal元组,其中TimeSpan部分定义了发票开具日期和付款日期之间的时间量,而decimal设置了调整乘数。对于Premium,元组被解释为“如果发票开具日期和付款日期之间的天数小于或等于TimeSpan,则应付款项应减少decimal乘数”。对于Penalty,它是“如果发票开具日期和付款日期之间的天数大于或等于TimeSpan,则应付款项应增加decimal乘数”。

Terms 类型捕获了Adjust方法中的调整项。使用 F#引言,它为每个可能的项组合定义了支付调整函数,然后它要么实现规定的调整,要么不实现。

现在,为了看到它是如何工作的,我们需要一个测试平台。让我们定义一个表示发票的记录:

type Invoice = { total:decimal ; date:System.DateTime; } 

让我们也定义一个测试发票列表:

let invoices = [ 
    { total=1005.20M; date=System.DateTime.Today.AddDays(-3.0) } 
    { total=5027.78M; date=System.DateTime.Today.AddDays(-29.0) } 
    { total=51400.49M; date=System.DateTime.Today.AddDays(-36.0) } 
] 

现在确定应付款项金额的函数现在基于条款,发票可能看起来如下:

let payment (terms: Terms) invoice = let adjust = terms.Adjust() in if adjust.IsSome then (adjust.Value) (invoice.date, invoice.total) else invoice.total 

现在,是时候定义可能的条款的完整种类了:

let terms = Terms(penalty=Penalty(TimeSpan.FromDays(31.),0.015M),
  premium=Premium(TimeSpan.FromDays(5.),0.02M)) 
let termsA = Terms() 
let termsB = Terms(Premium(TimeSpan.FromDays(4.),0.02M)) 
let termsC = Terms(penalty=Penalty(TimeSpan.FromDays(30.),0.02M)) 

最后,我们可以在以下屏幕截图中观察到,在将每个支付条款应用于相同的发票组后,所有这些是如何结合在一起的:

演示解决方案

使用 F#引言

摘要

在本章中,我们测试了高级 F#使用类别的一些特性。我希望我能够证明,即使是对于高级特性,F#仍然保持着“用简单的代码解决复杂问题”的承诺。

现在是进一步关注构成本书标题的主题的好时机。到目前为止的内容并没有以任何方式跨越与四人帮书籍en.wikipedia.org/wiki/Design_Patterns)内容通常相关联的传统设计模式视图。在下一章中,我将通过观察“经典”设计模式和原则的功能优先视角来证明所采取的方法。

第十二章。F#和面向对象原则/设计模式

前几章旨在发展和磨练你对函数式编程使用模式的品味,偶尔会关注与面向对象安排的比较。这一章是为那些有面向对象背景并且可能焦急地期待着这本书开始细致地将23 个原始的四人帮面向对象设计模式 (en.wikipedia.org/wiki/Design_Patterns))逐个移植到 F#的人准备的。

我可能让你失望,因为到目前为止所涵盖的所有主题都表明,坚持书中提倡的 F#的函数优先特性可能会使一些模式变得无关紧要、固有或普遍。换句话说,原始模式可能演变成与面向对象世界中的角色相比不那么根本的东西。

类似的转变也适用于面向对象的原则,统称为SOLID (en.wikipedia.org/wiki/SOLID_(object-oriented_design)))。也就是说,从函数式编程的角度来看,这些原则可能变得要么是默认的、无关紧要的,或者只是受到尊重,而不需要开发者付出太多的额外努力。

本章的目标是简要展示前述段落中概述的演变案例。在本章中,我们将探讨以下主题:

  • 如何在以函数优先的范式内将面向对象(OOP)的 SOLID 原则进行演变,以及这五个支柱中每一个具体发生了什么变化

  • 一些具体的设计模式(命令、模板、策略)如何减少其作用,或者仅仅等同于函数优先范式的一些片段

我不会尝试进行详尽无遗的综述。最终,这本书旨在发展函数优先范式的技能和技术,而不是 F#支持的任何其他范式。

SOLID 原则的演变

让我们考虑函数式编程范式如何演变这个以粗体字母SOLID命名的面向对象设计的基本原则。

单一职责原则

单一职责原则 (en.wikipedia.org/wiki/Single_responsibility_principle)(SRP),代表SOLID中的字母"S",在面向对象术语中是:

"一个类不应该有超过一个改变的理由"

换句话说,如果一个类的实现需要响应两个或更多独立的功能性修改,这将是其设计中违反单一职责原则SRP)的证据。在面向对象世界中遵循这一原则意味着设计由许多精简的类组成,而不是较少但更庞大的类。

如果我们将函数视为一个没有封装数据且只有一个方法的类退化情况,那么这不过是单一职责原则(SRP)的精髓。以下图展示了这一转换:

单一职责原则

在函数式编程中尊重单一职责原则

当我们用惯用的 F#进行编程时,我们将单一目的的函数组合在一起。换句话说,SRP 在 F#中自然得到提升和强制执行。

开放/封闭原则

开放/封闭原则 (en.wikipedia.org/wiki/Open/closed_principle ) ( OCP ),代表SOLID中的字母"O",指出:

"软件实体(类、模块、函数等)应当对扩展开放,但对修改封闭"

在纯面向对象领域,这种属性是通过继承赋予的,无论是直接的实现继承(即用子类替换超类)还是多态实现(即给定接口的另一种实现,它对自己封闭于修改,但开放于额外实现其他接口)。这两种形式的 OCP 在 F#的面向对象方面几乎是清晰、亲切的;然而,它们在 F#中并不构成习惯。F#中函数式首选的扩展机制是类型增强和组合。以下图作为备忘单,因为我们已在书中对这些扩展方法投入了大量关注:

开放/封闭原则

在函数式编程中尊重开放/封闭原则

上图以非常引人入胜的方式展示了在函数式首选习惯中,扩展机制是如何简单、简洁且切中要害的。

Liskov 替换原则

SOLID中的字母"L"来自Liskov 替换原则 (en.wikipedia.org/wiki/Liskov_substitution_principle ) ( LSP ),该原则指出:

"程序中的对象应当可以用其子类型实例替换,而不会改变该程序的正确性"

如此表述的LSP纯粹关注于面向对象继承,这似乎与惯用的 F#无关。然而,我至少要提到以下三个严格遵循此原则的 F#函数式首选习惯:

  • 引用透明性:如果一个函数是纯函数并且给定一个类型T的参数产生一个确定的结果,那么给定一个类型S的相应实例作为参数,其中ST的子类型,它必须确实产生相同的结果

  • F# 函数参数类型替换:基于前面讨论的要点,如果我们有一个从类型 'T 继承的类型 'S,那么 'S 的一个实例可以用作 'T 对应实例的替代;因此,对于以下函数 f'T -> 'R,表达式 f('S()) 不需要任何强制转换,如下代码片段所示 (Ch12_1.fsx ):

        type T = interface end // base 
        type S() = interface T // an implementation 
        let f (x: T) = () // a function upon base 
        f(S()) // application does not require coercion! 

  • 不可变性:如果我们构建了一个有效的不可变实例 'S,则不能通过将其用作 'T 实例的替代来利用其不可变性而使其无效

接口隔离原则

SOLID 原则中代表字母 "I" 的 接口隔离原则 (en.wikipedia.org/wiki/Interface_segregation_principle ) ( ISP ) 声称:

"许多特定于客户端的接口比一个通用接口更好"

换句话说,一个客户端与之关联的接口不应引入客户端未使用的依赖。ISP 只是 SRP 在接口上的应用。惯用的 F# 通过无状态和自然隔离的函数(代表包含恰好一个方法的接口)完全支持 ISP。

依赖倒置原则

SOLID 原则中的字母 " D " 代表 依赖倒置原则 (en.wikipedia.org/wiki/Dependency_inversion_principle ) ( DIP ),该原则指出:

"依赖抽象,不要依赖具体实现"

下图展示了在面向对象编程(OOP)中如何实现 DIP:如果类 A 的一个实例引用了类 B 的一个实例,这违反了 DIP 的直接依赖。这个问题可以通过使类 A 依赖于接口 IB 来解决。到目前为止,一切顺利,但必须有人实现 IB,对吧?让它成为类 B 的一个实例,现在它是 IB 的一个依赖,因此发生了依赖反转。

很容易注意到,在惯用的 F# 中,依赖倒置的作用就像一个普通的更高阶函数:例如,函数 f 有一个参数函数 g,它在定义 f 时被使用。当调用 f 时,任何符合 g 签名的参数函数 abc 都可以扮演 g 的角色:

依赖倒置原则

在函数式编程中遵循依赖倒置原则

减少模式

类似于 SOLID 原则,在惯用的以函数式编程为第一语言的 F# 上下文中,许多面向对象的设计模式要么减少(有时减少到消失),要么显著变形。让我们快速看一下这种转换的一些实例。我将使用我从代码库中提取的样本,这些样本用于实现 Jet.com 的支付应用程序。样本被简化以符合书籍格式。

命令设计模式

命令设计模式 (zh.wikipedia.org/wiki/命令模式 ) 在面向对象编程中代表了一种行为设计模式,其中所有在稍后执行动作所需的信息都被封装在一个对象中。但是等等;这难道不是与函数的定义完全一致吗?没错;几乎任何使用高阶函数遍历数据结构并对其每个元素应用低阶函数的 F# 习惯用法都可以被视为命令模式的实例。映射、折叠,无论你叫它什么——所有这些都属于这个类别。按照命令模式的规定行事是函数式优先的 F# 中的一个普遍习语。

让我们考虑一个例子:一个参与电子商务市场的商家的订单流程由一系列交易组成,每个交易代表销售退款。通过取订单流程的任何连续元素序列,可以找到其运行余额。现在,使事情更有趣的因素是,一些订单在引入后的一段时间内可能被取消,从而被取消。我们负责为市场财务部门跟踪运行总账。

F# 的函数式优先特性允许我们有一个非常干净、习惯用法式的解决方案。我开始于两个核心领域对象,代表订单类型和客户交易,结合订单类型及其商品成本(Ch12_2.fsx):

type OrderType = Sale | Refund 
type Transaction = Transaction of OrderType * decimal 

我继续使用两个核心函数,根据订单类型计算总数:

let sale total cost = total + cost 
let refund total cost = total - cost 

配备了这些知识,现在是时候在模式的意义上定义我们的命令了。OrderCancellation都将接受一个运行总账和一笔交易,并返回一个相应调整后的新运行总账(注意,Cancellation在总账方面与Order相对应):

let Order total = function 
| Transaction(OrderType.Sale, cost) -> sale total cost 
| Transaction(OrderType.Refund, cost) -> refund total cost 
let Cancellation total = function 
| Transaction(OrderType.Sale, cost) -> refund total cost 
| Transaction(OrderType.Refund, cost) -> sale total cost 

我完成了!让我通过在 FSI 中应用一个示例订单流程来演示构建的代码的实际效果。结果在下面的屏幕截图中展示,其中一系列orderFlow交易通过订单产生totalForward为 271.86 美元,然后通过取消操作,最终产生预期的运行总账totalBackward为 0.00 美元:

命令设计模式

命令模式作为习惯用法 F# 折叠

模板设计模式

模板设计模式 (zh.wikipedia.org/wiki/模板方法模式 ) 在面向对象编程中定义了一个算法或程序的共同骨架,其中组件可以被重写,但总体结构保持不变。再次强调,通过将函数作为一等对象来实现这一效果是微不足道的。例如,传递函数作为参数将工作得很好,因此这个模式就变得无关紧要了。

惯用 F# 的方法甚至比这更丰富,它允许函数连贯地参与接口,并以对象表达式的形式提供任何具体的实现。

让我们转向从 Jet.com 支付应用程序的企业代码库中提取的相应代码示例。在 Jet.com 市场中参与支付的合作伙伴的支付过程包括三个连续的步骤:

  1. 根据商户 ID 获取支付需求和应付金额。

  2. 格式化特定支付方式的支付。

  3. 向银行提交支付建议以执行。

模板将前面的部分保持在一起,允许你同时更改每个部分以适应情况。正如前一个示例所示,我首先定义了一些核心领域实体 (Ch12_3.fsx ):

open System 
type PayBy = ACH | Check | Wire 
             override x.ToString() = 
                match x with 
                | ACH -> "By ACH" 
                | Check -> "By Check" 
                | Wire -> "By Wire" 
type Payment = string 
type BankReqs = { ABA: string; Account: string} 
type Merchant = { MerchantId: Guid; Requisites: BankReqs } 

在这里,PayBy 代表一种特定的支付工具(支票/ACH/电汇),格式化的 Payment 只是一个类型缩写,BankReqs 代表商户的银行要求,以便账户接受存入的支付,而 Merchant 将商户 ID 和银行要求连接起来。

现在我定义了一个模板作为接口,它反映了支付流程的部分连贯性 (Ch12_3.fsx ):

type ITemplate = 
    abstract GetPaymentDue: Guid -> Merchant*decimal 
    abstract FormatPayment: Merchant*decimal -> Payment 
    abstract SubmitPayment: Payment ->bool 

这部分相当直接;GetPaymentDue 从相关持久存储中检索给定商户的要求和应付金额,FormatPayment 执行所需的支付建议格式化,而 SubmitPayment 负责将建议交付给 Jet 的银行。请注意,我故意没有在这里指定支付格式,因为这个细节可能需要延迟到实现阶段。

然后,在这里,我为 ITemplate 提供了一个具体的(模拟)实现。尽管如此,你可以看到这种安排提供了很大的灵活性;特别是,我将特定的支付工具作为实现的一个参数 (Ch12_3.fsx ):

let Template  payBy = 
    { new ITemplate with 
        member __.GetPaymentDuemerchantId = 
          printfn "Getting payment due of %s" 
          (merchantId.ToString()) 
        (* mock access to ERP getting Accounts payable due for
            merchantId *) 
        ({ MerchantId = merchantId; 
          Requisites = {ABA="021000021"; 
          Account="123456789009"} }, 25366.76M) 
        member __.FormatPayment (m,t)  = 
          printfn "Formatting payment of %s" 
          (m.MerchantId.ToString()) 
        sprintf "%s:%s:%s:%s:%.2f" "Payment to" m.Requisites.ABA 
          m.Requisites.Account (payBy.ToString()) t 
        member __.SubmitPayment p = 
          printfn "Submitting %s..." p 
          true 
     } 

最后,我使用模板将所有内容封装到函数中 (Ch12_3.fsx ):

let makePaymentmerchantIdpayBy  = 
    let template = Template payBy in 
template.GetPaymentDuemerchantId 
    |>template.FormatPayment 
    |>template.SubmitPayment 

如往常一样,为了看到这段代码的实际效果,我转向 FSI,展示了以下截图中的模拟支付结果。出于使图示适应单页书的考虑,我省略了完整的脚本源代码:

模板设计模式

模板模式在 F# 中的惯用表达消失

策略模式

策略模式 (en.wikipedia.org/wiki/Strategy_pattern ) 简单来说,就是通过实现一系列算法并在运行时相互替换来调整算法的行为。再次强调,还有什么比将函数作为一等公民的功能优先设置更适合这个目的呢?

为了说明策略模式的使用,我将使用来自 Jet.com 支付系统的一个另一个用例。在其运输操作中,Jet.com 使用多个承运人,由于运输量巨大,它通过电子方式处理承运人发票。这种处理的核心是将每个承运人的发票加载到一个临时数据表中,然后以upsert([en.wikipedia.org/wiki/Merge_(SQL)](https://en.wikipedia.org/wiki/Merge_(SQL)))的方式合并这个数据表的内容。

我通过首先概述核心行为的实现来实施这个EDIen.wikipedia.org/wiki/Electronic_data_interchange)接口的实现(Ch12_4.fsx):

open System 
open System.Data 
type InvoiceFormat = 
| Excel 
| Csv 
let load (format: InvoiceFormat) (path: String) = 
    let dt = new DataTable() in 
    (* IMPLEMENTATION GOES HERE *) 
dt 
let merge (target: string) (dt: DataTable) = 
    (* IMPLEMENTATION GOES HERE *) 
    () 

前面的代码片段表明,支持的发票格式是ExcelCSV,并且有两个通用函数可用于将发票加载到数据表中,这些发票以任何可接受的格式交付到某个位置,并将填充的数据表与相应持久存储的现有内容合并。

到目前为止,一切顺利;这两个函数可能通过一个接口来访问,该接口的实现将针对每个支持的承运人具体化(Ch12_4.fsx):

type ILoadVendorInvoices = 
    abstract LoadInvoices: String ->DataTable 
    abstract member MergeInvoices: DataTable -> unit 

现在,我为 Jet.com 用于订单运输的两个承运人——联邦快递(FedEX)和 LaserShip——提供了前面接口的具体实现(Ch12_4.fsx):

let LoadFedex = 
    { new ILoadVendorInvoices with 
        member __.LoadInvoices path = load Csv path 
        member __.MergeInvoicesdataTable = 
            merge "Fedex" dataTable 
    } 
let LoadLasership = 
    { new ILoadVendorInvoices with 
        member __.LoadInvoices path = load Excel path 
        member __.MergeInvoicesdataTable = 
            merge "Lasership" dataTable 
    } 

现在请跟我来;我们有两个ILoadVendorInvoices类型的对象,每个对象封装了自己的承运人特定信息。然而,我们可以统一地使用它们进行 EDI,如下面的函数所示(Ch12_4.fsx):

let importEDIData (loader: ILoadVendorInvoices) path = 
    loader.LoadInvoices path |>loader.MergeInvoices 

真是太美了;现在我们可以使用LoadFedexLoadLasership的实例来精确地切换 EDI 处理的模式,这正是策略模式所规定的。让我们转向 FSI 进行演示。以下截图显示了结果:

策略模式

使用 F#惯用语法表达的策略模式

摘要

本章强调了功能优先的方法并不盲目地与面向对象编程的原则和模式相矛盾。有时它也支持并增强了它们。

我将把本书的最后一章奉献给功能优先代码的故障排除主题,因为它有一些特定的内容。

第十三章:函数代码的故障排除

在本章中,我简要介绍了函数优先编程方法的一个重要方面,该方法在 F#代码开发过程中发挥作用。事实上,函数优先代码的故障排除与诸如命令式代码的故障排除不同。本章的目标是与你分享我在编写惯用 F#代码时收集的一些观察结果。它应该使你具备一些考虑因素和一些有效的错误排除技巧。

本章我们将探讨以下主题:

  • 理解为什么惯用 F#具有低缺陷率

  • 使用 REPL 和探索性编程风格

  • 解决一些编译时问题

  • 解决运行时问题

为什么惯用 F#允许更少的缺陷

不再回到函数优先和其他 F#程序员可用的范式并行的比较,我将重申(主要是轶事性的)观点,即惯用 F#代码允许的缺陷比基于面向对象或命令式范式的等效实现更少。

前十二章节对此判断做出了重大贡献。但让我简要回顾一些考虑因素,以便得出以下结论:

  • 这种缺陷率的降低并不是理所当然的。这是你在获得功能思维习惯和随后应用它们的严谨性时所付出的代价

  • 仅使用 F#本身并不能解决缺陷;代码中仍有足够的空间让错误潜入,尽管数量显著减少

  • 典型的 F#错误非常具体,通常可以预测并避免

降低错误率

这个观察结果非常重要,并源于几个因素:

  • 语言的简洁性直接导致错误率降低:更少的代码行意味着更少的错误潜入和未被注意到的机会

  • 严格的静态类型和类型推断根本不允许动态语言中常见的疏忽,当类型放置不当可能导致难以检测和消除的 bug

  • 提高抽象级别、库高阶函数和不可变性。所有这些都有助于消除许多来自状态代码不可预测执行顺序、更多“移动部件”参与以及不必要的核心库功能重实现的 bug

F#编译时错误比运行时错误更为普遍

使用传统编程语言编写的程序在语法上正确通常不会引起对其执行结果的任何假设。一般来说,这两个因素并不相关。

看起来,遵循 F#函数优先方法的实现并非如此。在 F#和基于 F#的非函数编程环境中,互联网上有大量的轶事证据表明

"如果编译成功,则运行正常"

例如,这个Haskell 维基帖子(wiki.haskell.org/Why_Haskell_just_works )就与使用相关 Haskell 编程语言编写的程序提出了类似的观察。

实际上,严格的静态类型和类型推断可以在编译时捕捉到许多随机缺陷,从而保护程序员免受在运行时观察问题并随后进行耗时且需要技能的活动——通常称为调试——以在源代码级别确定问题的真正原因所带来的高昂成本。

另一个极其重要的因素是,通过坚持使用核心库支持的少量惯用模式来实现算法,而不是操作低级语言结构。为了更好地说明我这里所说的内容,试着回答这个问题:哪种方法更容易出现实现错误,使用Seq.fold折叠序列,还是将序列实体化为数组并使用索引遍历元素,同时在可变值中聚合结果?正确的答案很容易转化为本书多次提到的观点:在函数式范式中的“最小化移动部件”的积极影响。

然而,你的折叠操作应该是正确的,这对于从算法角度来看实现的整体正确性至关重要。F#还提供了另一个除虫工具。这个工具允许开发者借助所谓的REPL(下一节将介绍)在实现过程中快速、轻松且频繁地进行快速检查。

使用 REPL 和探索式编程风格

REPL代表读取-评估-打印循环(en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop ),它代表了一种与老式 C#程序员习惯的方式截然不同的程序开发方式,即编辑源代码 - 构建编译后的程序版本 - 运行和调试循环。从它的早期开始,F#就引入了交互式开发方式(docs.microsoft.com/en-us/dotnet/articles/fsharp/tutorials/fsharp-interactive/index )。然而,更广泛地说,它为 F#开发者提供了一种被称为探索式编程(en.wikipedia.org/wiki/Exploratory_programming )的编程风格。F#提供了一个名为F# Interactive(32 位fsi.exe或 64 位兼容的fsiAnyCPU.exe)的工具,它既可以作为独立工具,也可以作为 Visual Studio 的一部分,从任何 F#项目访问。它允许你在动态构建的运行时环境中评估任何以独立 F#脚本或选定的 F#程序片段形式呈现的 F#表达式。

F#交互式是一个功能极其强大的工具。它的使用场景涵盖了从快速检查刚刚实现的一行函数行为到在生产环境中运行 F#实现的微服务。是的,我没有开玩笑;F#交互式编译器的质量几乎与正常构建编译器相同。曾经,整个 Jet.com 微服务架构都是通过一系列 F#脚本实现的,每个脚本都由一个专门的fsi进程执行。

在开发任何 F#代码时,通过在fsi中评估这个或那个片段进行快速检查的习惯,可以显著帮助实现几乎无错误的 F#实现。我强烈建议你在日常实践中获取并遵循 F#探索式编程风格。

解决一些编译时问题

虽然 REPL 可以帮助探索和调整正确的 F#代码,但它会保留编译器错误,因为评估代码片段包括fsi中嵌入的 F#编译器的编译。而且我必须承认,一些编译时错误可能会让经验不足的 F#开发者感到困惑。在这里,我将分析几种这样的错误,并提供如何摆脱它们的建议。在我这样做之前,你应该记住,由于初始缺陷通常被类型推断作为正确代码吸收,因此报告的编译错误与那种复杂的类型推断推断是一致的。也就是说,类型推断经常掩盖错误的真正原因。我们很快就会讨论一些这种情况的例子。

if-then 返回值

对于 F# if...then...表达式结果类型的类似复杂推断,其中一种最容易理解的情况发生在该结果类型不能是任何其他类型,只能是unit。通常,这似乎是反直觉的。让我们看看为什么会这样。

在下面的代码片段中,我选择了实现中的特定(<)比较运算符,只是为了保持简单(Ch13_1.fsx):

let f a b = 
    if a < b then 
        a 
    else 
        b 

在这里,函数f的推断签名表示评估 F#表达式if-then-else的结果,为f: 'a -> 'a -> 'a(需要比较),这完全合理(应该不需要太多努力就能识别出前面代码中min函数的泛型实现)。

现在,让我们看看如果省略else部分会发生什么(Ch13_1.fsx):

let f' a b = 
    if a < b then 
        a 

现在函数f'的推断签名是f': unit->unit->unit;换句话说,两个参数和结果都必须是unit类型。那是什么意思?看似反直觉的类型推断结果背后的推理实际上是有道理的。让我们想想当条件a < bfalse时,函数f'必须返回什么值?在没有明确指示的情况下,编译器决定它必须是unit。但是等等;if-then-else表达式的两个分支不应该是同一类型吗?只有当参数aunit类型时,这个条件才能满足,这意味着参数b也必须是unit类型。

好吧;但如果我尝试将类型推断推向某些方式,例如,强制尝试将a推断为泛型类型'a (Ch13_1.fsx)

let f'' (a:'a) b = 
    if a < b then 
        a 

或者,如果我们尝试将a推向更具体的方向,通过强制它成为具体类型,例如,int (Ch13_1.fsx)

let f''' (a:int) b = 
    if a < b then 
        a 

结果发现,两次尝试都是徒劳的,因为关于省略的else分支的unit返回类型的考虑仍然是有效的。在第一种情况下,编译器将只是发出一个讨厌的警告,指出

此结构导致代码的泛型程度低于类型注解所指示的程度。类型变量'a已被约束为类型'unit'

在第二种情况下,从编译器的角度来看,这是一个简单而直接的错误

此表达式预期具有unit类型,但此处具有int类型。

因此,我们应该如何处理if...then...表达式?教训是,这种条件语句的简短形式只能在需要副作用的情况下使用。好的例子包括记录一些诊断信息或更改可变值。对于必须返回真正的非unit结果的情况,必须评估完整的if-then-else表达式,并且两个分支都返回相同类型的值。

值限制

这种编译问题通常会使已经掌握并自豪地使用 F#特性(如部分应用自动泛化)的中级 F#开发者感到困惑。想象一下,您提出了一种强大的数据处理算法并正在实现它,在这个过程中享受了惯用 F#代码的力量和美感。在某个时刻,您意识到需要一个函数,该函数接受一个列表的列表并找出所有元素列表是否为空。对于一个经验丰富的函数式程序员来说,这难道不是一个小问题吗?所以您想出了类似这样的事情(Ch13_2.fsx):

let allEmpty  = List.forall ((=) []) 

惊讶!它无法编译,编译器警告如下:

值限制。值'allEmpty'已被推断为具有泛型类型val allEmpty : ('_a list list -> bool) when '_a:equality。要么使'allEmpty'的参数显式化,要么,如果您不希望它具有泛型,添加类型注解。

您(我应该承认我过去多次这样做)首先对这个混乱感到难以置信,因为 F#编译器准确地推断出了您的意图,但不知何故不喜欢它。然后您在 Google 上搜索“f#值限制”,被引荐到MSDN 自动泛化(docs.microsoft.com/en-us/dotnet/articles/fsharp/language-reference/generics/automatic-generalization ),在那里您被告知:

编译器仅在具有显式参数的完整函数定义和简单的不可变值上执行自动泛化。

这之后是处理突发问题的实用技巧。你尝试这些建议并解决问题,但留下了一些黑魔法般的余味。

对我来说,令人耳目一新的经历是阅读这篇优秀的博客文章:F# 值限制的细微差别 (blogs.msdn.microsoft.com/mulambda/2010/05/01/finer-points-of-f-value-restriction/)。我将展示应用于可变值的泛化所隐藏的危险,这可能会成为你阅读这篇博客文章并理解 F# 编译器行为背后的理由的动力。

让我们看看一个看似无害的代码片段(Ch13_2.fsx):

let gr<'a> : 'a list ref = ref [] 
gr := ["a"] 
let x: string list = !gr 
printfn "%A" x 

猜猜在执行此片段后打印的 x 值会是什么?那会是 ["a"],对吧?

错误;[] 就是发生的情况!造成这种情况的原因是 gr ,尽管看起来像 'a list ref 类型的值,但实际上是一个类型函数。在 := 操作符的左侧使用时,它只是带来一个新的未绑定引用实例。在 ! 操作符的右侧使用时,它只带来另一个新的引用实例,该实例指向一个空列表 []。为了实现直观预期的行为,我们需要将 gr 应用到类型参数字符串上绑定到具体的类型变量 cr ,然后后者,作为一个普通引用,将按预期行为(Ch13_2.fsx):

let cr = gr<string> 
cr := ["a"] 
let y = !cr 
printfn "%A" y 

现在,打印的值确实是 ["a"]。在所有情况中强制执行值限制错误,当情况偏离最安全的使用情况时,编译器保护开发者免受先前演示的意外代码行为。回到我的初始示例,可能的补救措施可以是以下任何一种(Ch13_2.fsx):

let allEmpty xs = List.forall ((=) []) xs // remedy 1 
let allEmpty : int list list -> bool  = List.forall ((=) []) 
// remedy 2 
let allEmpty = fun x -> List.forall ((=) []) x // remedy 3 

不完美的模式匹配

许多看似反直觉的 F# 编译时错误和警告属于模式匹配领域。例如,看看以下对整数参数符号的简单检测:

let positive = function 
| x when x > 0 -> true 
| x when x <= 0 -> false 

尽管看似完整,但这会产生编译器的警告:

在此表达式中存在不完整的模式匹配

结果表明,如果 F# 编译器遇到 when 守卫,它就会假设这种构造定义了一个不完整的匹配情况。这就是为什么,尽管给定的案例集在语义上是完整的,编译器仍然认为函数定义是不完整的。简单地移除过多的最后一个 when 守卫立即解决了问题(Ch13_3.fsx):

let positive' = function 
| x when x > 0 -> true 
| _ -> false 

另一个常见的相关问题是不可达的匹配规则。大多数情况下,不可达的匹配规则会在程序员错误地在规则序列中使用变量而不是字面量时发挥作用,从而创建一个过早的通配符情况。在这种情况下,编译器使用良性警告,尽管几乎总是运行时结果混乱。因此,将这些场合标记为错误可能是一个更好的设计选择。几年前,我写了一篇关于这个问题的博客文章(infsharpmajor.wordpress.com/2011/10/13/union-matching-challenge/),我在这里将其作为以下代码片段(Ch13_3.fsx)中问题的说明:

type TickTack = Tick | Tack 

let ticker x = 
    match x with 
    | Tick -> printfn "Tick" 
    | Tock -> printfn "Tock" 
    | Tack -> printfn "Tack" 
ticker Tick 
ticker Tack 

这可能会让你期待 Tick 输出后跟 Tack,但实际上,Tick 输出后应该跟 Tock

F# 编译器对前面的片段发出两个警告。第一个警告提示你,打字错误 Tock 被视为变量而不是字面量,就像在另外两种情况下字面量 TickTack 一样:

通常不应在模式中使用大写变量标识符,这可能会表明模式名称拼写错误

第二个警告:

这条规则永远不会匹配

直接指出了由打字错误引起的后果。

这里的教训是,F# 开发者应该注意警告。将编译器处理的规则不可达视为错误可能更合适。

解决运行时问题

“如果编译成功,那么它就能工作” 的这句箴言帮助追随者们在企业软件开发的时间到市场评分中取得了惊人的成绩。

以 Jet.com 作为构建绿色田野电子商务平台实现的例子,它真的在不到一年的时间里将零到最小可行产品MVP)的路径压缩了。平台的生产模式发布发生在接收后一年多一点的时间。

这是否意味着遵循以函数式优先的方法是软件开发中的银弹?当然不是在绝对尺度上,尽管在相对尺度上,改进是巨大的。

为什么成功不是详尽的?问题是,这种实践需要从血腥的想法过渡到平凡的实施问题。无论我们的实现多么准确,总有一些暗角可能潜伏着意外的问题。

让我用 Jet.com F#企业开发实践中的一个示例来演示这一点。Jet 代表一个创新的电子商务平台,汇集了许多业务领域,如互联网订购、零售销售、仓储、财务、会计、运输等。这些领域的每个通常都有自己的独特元数据分类;因此,为了并行运行它们,实现中最常见的操作之一是映射。而使用唯一非冲突标识符的普遍做法是基于 GUID 或全局唯一标识符 (en.wikipedia.org/wiki/Globally_unique_identifier )。

实际上,企业软件经常使用 GUID 作为访问键来处理字典和缓存,让我们看看.NET 核心库System.Guid的实现是否适合这个目的。

下面是一个相当简单的探索性实现,使用具有System.Guid类型键的字典。我创建了一个基于标准 F#核心库实现的简单dictionary,类型为IDictionary<Guid,int>。我仅为了简单起见,用大小数字对(Guid,int)填充它。现在,我将使用数组keys作为间接层来模拟对字典的大量随机访问,并测量性能。以下代码片段显示了所需代码片段的组成(Ch13_4.fsx):

open System 
open System.Collections.Generic 
let size = 1000 
let keys = Array.zeroCreate<Guid> size 
let mutable dictionary : IDictionary<Guid,int> = 
    Unchecked.defaultof<IDictionary<Guid,int>> 
let generate () = 
    for i in 0..(size-1) do 
        keys.[i] <- Guid.NewGuid() 
    dictionary <- seq { for i in 0..(size-1) -> (keys.[i],i) } |> dict 
generate() 
let trials = 10000000 
let rg = Random() 
let mutable result = 0 
for i in 0..trials-1 do 
    result <- dictionary.Item(keys.[rg.Next(size-1)]) 

在开启计时的情况下运行此代码片段,以下屏幕截图显示了性能指标(为了简洁,仅显示有价值的输出):

处理运行时问题

使用原生 System.Guid 访问字典

1000 万次访问在 6.445 秒内转换为每秒超过 150 万次访问。不算快。让我们把它作为基准。还有一个令人担忧的迹象是垃圾回收的数量:每 10000 次访问就有 287 次,这并不轻。

在深入挖掘观察到的代码行为的原因之前,让我仅展示为 Jet.com 进行的调查结果,以尝试改进水印。我将引入一个简单的更改,而不是使用作为复杂 Windows 系统数据结构的字典键的真正System.Guid类型,我将使用当从规范表示中去除连字符后留下的 GUID 值的表示,即十六进制字符串。例如,GUID f4d1734c-1e9e-4a25-b8d9-b7d96f48e0f将被表示为字符串f4d1734c1e9e4a25b8d9b7d96f48e0f。这将需要对之前的代码片段(Ch13_4.fsx)进行最小的更改:

let keys' = Array.zeroCreate<string> size 
let mutable dictionary' : IDictionary<string,int> = 
    Unchecked.defaultof<IDictionary<string,int>> 
let generate' () = 
    for i in 0..(size-1) do 
        keys'.[i] <- keys.[i].ToString("N") 
    dictionary' <- seq { for i in 0..(size-1) -> (keys'.[i],i) } |> dict 
generate'() 
for i in 0..trials-1 do 
    result <- dictionary'.Item(keys'.[rg.Next(size-1)]) 

在这里,我将创建一个新的keys'间接层,通过简单的数据转换从keys的对应部分生成。使用这个更改转向 FSI 带来了一个巨大的惊喜,反映在下面的屏幕截图:

解决运行时问题

从 System.Guid 切换到 string 以访问字典

与基线相比,新的访问率构成了每秒 16.4 百万次访问,几乎提高了 11 倍!此外,垃圾回收也实现了五倍提升。

现在记住,基于System.Guid的映射在平台上无处不在,你可以想象上述简单更改对整体平台性能的影响有多大。

摘要

本章应该使你为与其它开发范式相比,在以函数式编程为首要的开发中发生的缺陷类型位移做好准备。F#代码典型的运行时错误率降低缩短了开发系统的上市时间,并在必要时为性能优化释放了开发资源。

我们已经到达了本书的结尾,我在这里为你装备了大量的惯用 F#使用模式。本书的关键假设是,这种使用要求来自其他编程范式的开发者进行一定的思维习惯转变,独特的看问题角度,以及对应的功能程序员工具包中的模式和技巧。此时,你应该能够通过将问题分解为几个已知的构建块,然后使用适当的函数和组合子来构建解决方案来思考任何问题。你还被展示了使用标准 F#代数数据类型而不是自定义.NET 类的优势。在你的函数式设计中依赖你在这里获得的模式;保留、回忆并在日常实践中重用它们。

我希望这本书能引导你进入惯用函数式编程领域的道路。祝你成功到达那里!

posted @ 2025-10-23 15:07  绝不原创的飞龙  阅读(32)  评论(0)    收藏  举报