学好-Haskell-成就非凡-全-

学好 Haskell 成就非凡(全)

原文:Learn You a Haskell for Great Good

译者:飞龙

协议:CC BY-NC-SA 4.0

简介

Haskell 很有趣,这就是它的全部!

这本书的目标是那些有在命令式语言中编程经验的人——例如 C++、Java 和 Python——现在想尝试 Haskell。但即使你没有太多的编程经验,我敢打赌像你这样的聪明人也能跟上并学习 Haskell。

我对 Haskell 的第一反应是这种语言太奇怪了。但过了那个最初的障碍后,一切都很顺利。即使 Haskell 最初对你来说很奇怪,也不要放弃。学习 Haskell 几乎就像再次从头开始学习编程一样。它很有趣,并迫使你以不同的方式思考。

注意

如果你真的遇到了难题,freenode 网络上的 #haskell IRC 频道是一个询问问题的好地方。那里的人通常很友好、耐心、理解。他们是 Haskell 新手的极好资源。

那么,什么是 Haskell 呢?

Haskell 是一种纯函数式编程语言。

命令式编程语言中,你给计算机一系列任务,然后它执行这些任务。在执行它们的过程中,计算机可以改变状态。例如,你可以将变量 a 设置为 5,然后做一些可能会改变 a 值的事情。还有用于执行指令多次的流程控制结构,例如 forwhile 循环。

纯函数式编程是不同的。你不是告诉计算机做什么——你是告诉它什么内容。例如,你可以告诉计算机一个数的阶乘是从 1 到该数的每个整数的乘积,或者一个数字列表的总和是第一个数字加上剩余数字的总和。你可以将这两个操作都表达为函数

无标题图片

在函数式编程中,你不能将一个变量设置为一个值,然后稍后将其设置为另一个值。如果你说 a 是 5,你不能只是改变主意说它是其他东西。毕竟,你说过它是 5。(你是什么,一个骗子?)

在纯函数式语言中,一个函数没有副作用。函数唯一能做的就是计算某事并返回结果。起初,这似乎很有限制,但实际上有一些非常好的结果。如果一个函数用相同的参数被调用两次,它保证两次都会返回相同的结果。这个特性被称为引用透明性。它让程序员能够轻松地推断(甚至证明)一个函数是正确的。然后你可以通过将这些简单函数粘合在一起来构建更复杂的函数。

Haskell 是 惰性的。这意味着除非明确告知,否则 Haskell 不会执行函数直到需要向你展示结果。这是通过引用透明性实现的。如果你知道一个函数的结果只取决于该函数接收的参数,那么你实际上计算该函数结果的时间并不重要。作为一个惰性语言,Haskell 利用这一事实,尽可能推迟实际计算结果。一旦你想看到结果,Haskell 将只进行显示这些结果所需的最小计算。惰性还允许你创建看似无限的数据结构,因为只有你选择显示的数据结构的部分才会实际被计算。

无标题图片

让我们看看 Haskell 惰性的一个例子。假设你有一个数字列表,xs = [1,2,3,4,5,6,7,8],以及一个名为 doubleMe 的函数,该函数将每个元素加倍并返回一个新的列表。如果你想将你的列表乘以 8,你的代码可能看起来像这样:

doubleMe(doubleMe(doubleMe(xs)))

一种命令式语言可能会遍历列表一次,制作一个副本,然后返回。然后它还会遍历列表另外两次,每次都制作副本,并返回结果。

在一个惰性语言中,对列表调用 doubleMe 而不强制它显示结果,只会让程序告诉你,“是的,是的,我稍后会做!”一旦你想看到结果,第一个 doubleMe 调用第二个,并要求立即得到结果。然后第二个告诉第三个同样的要求,第三个不情愿地返回一个加倍后的 1,即 2。第二个 doubleMe 接收到这个结果,并返回 4 给第一个。第一个 doubleMe 然后将这个结果加倍,并告诉你最终结果列表中的第一个元素是 8。由于 Haskell 的惰性,doubleMe 调用只需遍历列表一次,而且只有当你真正需要这样做的时候。

Haskell 是 静态类型 的。这意味着当你编译程序时,编译器知道哪段代码是数字,哪段是字符串等等。静态类型意味着许多可能的错误可以在编译时捕获。例如,如果你尝试将数字和字符串相加,编译器会向你抱怨。

无标题图片

Haskell 使用一个非常好的类型系统,具有 类型推断。这意味着你不需要显式地为每段代码标记类型,因为 Haskell 的类型系统可以智能地推断出来。例如,如果你说 a = 5 + 4,你不需要告诉 Haskell a 是一个数字——它自己可以推断出来。类型推断使你更容易编写更通用的代码。如果你编写了一个接受两个参数并将它们相加的函数,但你没有明确声明它们的类型,该函数将适用于任何像数字一样的两个参数。

Haskell 语法优雅且简洁。因为它使用了大量的高级概念,所以 Haskell 程序通常比它们的命令式等效程序更短。程序越短,维护起来越容易,并且错误也更少。

Haskell 是一些非常聪明的人(拥有博士学位)创造的。Haskell 的工作始于 1987 年,当时一组研究人员聚集在一起设计一种出色的语言。定义语言稳定版本的 Haskell 报告于 1999 年发布。

你需要准备什么

简而言之,要开始学习 Haskell,你需要一个文本编辑器和 Haskell 编译器。你可能已经安装了你的首选文本编辑器,所以我们不会在这方面浪费时间。最受欢迎的 Haskell 编译器是格拉斯哥 Haskell 编译器(GHC),我们将在这本书中使用它。

获取所需内容的最佳方式是下载 Haskell 平台。Haskell 平台不仅包括 GHC 编译器,还包括许多有用的 Haskell 库!要为您的系统获取 Haskell 平台,请访问 hackage.haskell.org/platform/ 并遵循您操作系统的说明。

GHC 可以编译 Haskell 脚本(通常带有 .hs 扩展名),它还拥有交互式模式。从那里,你可以从脚本中加载函数,然后直接调用它们以查看即时结果。尤其是在学习过程中,使用交互式模式比每次更改后编译和运行代码要容易得多。

一旦安装了 Haskell 平台,如果你使用的是 Linux 或 Mac OS X 系统,请打开一个新的终端窗口。如果你的操作系统是 Windows,请转到命令提示符。一旦到达那里,输入 ghci 并按回车键以启动交互式模式。(如果你的系统找不到 GHCi 程序,你可以尝试重新启动你的计算机。)

如果你在一个脚本中定义了一些函数——例如,myfunctions.hs——你可以通过输入 :l myfunctions 将这些函数加载到 GHCi 中。(确保 myfunctions.hs 在你启动 GHCi 的同一文件夹中。)

如果你更改了 .hs 脚本,运行 :l myfunctions 以重新加载文件,或者运行 :r,这将重新加载当前脚本。我的常规工作流程是在 .hs 文件中定义一些函数,将其加载到 GHCi 中,对其进行操作,更改文件,然后重复。这正是本书将要进行的操作。

致谢

感谢所有提出更正、建议和鼓励话语的人。还要感谢基思、山姆和玛丽莲,因为他们让我看起来像一位真正的作家。

第一章 开始

如果你是不读前言的糟糕类型的人,你可能仍然想回去读最后一节——它解释了如何使用这本书,以及如何使用 GHC 加载函数。

首先,让我们启动 GHC 的交互模式并调用一些函数,这样我们就可以对 Haskell 有一个非常基本的了解。打开一个终端并输入 ghci。你会看到类似这样的内容:

GHCi, version 6.12.3: http://www.haskell.org/ghc/  :? for help
Loading package ghc-prim ... linking ... done.
Loading package integer-gmp ... linking ... done.
Loading package base ... linking ... done.
Loading package ffi-1.0 ... linking ... done.

注意

GHCi 的默认提示符是 Prelude>,但我们将使用 ghci> 作为本书中示例的提示符。要使你的提示符与本书匹配,请在 GHCi 中输入 :set prompt "ghci> "。如果你不想每次运行 GHCi 都这样做,在你的主目录中创建一个名为 .ghci 的文件,并将其内容设置为 :set prompt "ghci> "

恭喜你进入了 GHCi!现在让我们尝试一些简单的算术:

ghci> 2 + 15
17
ghci> 49 * 100
4900
ghci> 1892 - 1472
420
ghci> 5 / 2
2.5

无标题图片

如果我们在一个表达式中使用多个运算符,Haskell 将按照考虑运算符优先级的顺序执行它们。例如,* 的优先级高于 -,所以 50 * 100 - 4999 被视为 (50 * 100) - 4999

我们也可以使用括号来明确指定运算的顺序,如下所示:

ghci> (50 * 100) - 4999
1
ghci> 50 * 100 - 4999
1
ghci> 50 * (100 - 4999)
-244950

真的很酷,不是吗?(我知道现在还不是,但请耐心等待。)

要注意的一个陷阱是负数常量。始终最好在算术表达式中它们出现的地方用括号包围。例如,输入 5 * -3 会让 GHCi 大喊大叫,但输入 5 * (-3) 会正常工作。

布尔代数在 Haskell 中也很简单。像许多其他编程语言一样,Haskell 有布尔值 TrueFalse,并使用 && 运算符进行合取(布尔 ),使用 || 运算符进行析取(布尔 ),以及使用 not 运算符来否定 TrueFalse 值:

ghci> True && False
False
ghci> True && True
True
ghci> False || True
True
ghci> not False
True
ghci> not (True && True)
False

我们可以使用 ==/= 运算符测试两个值的相等或不等,如下所示:

ghci> 5 == 5
True
ghci> 1 == 0
False
ghci> 5 /= 5
False
ghci> 5 /= 4
True
ghci> "hello" == "hello"
True

然而,在混合匹配值时要小心!如果我们输入类似 5 + "llama" 的内容,我们会得到以下错误信息:

No instance for (Num [Char])
arising from a use of `+' at <interactive>:1:0-9
Possible fix: add an instance declaration for (Num [Char])
In the expression: 5 + "llama"
In the definition of `it': it = 5 + "llama"

GHCi 告诉我们的是 "llama" 不是一个数字,所以它不知道如何将 5 加到它上面。+ 运算符期望它的两个输入都是数字。

另一方面,== 运算符适用于任何可以比较的两个项目,有一个例外:它们都必须是相同类型的。例如,如果我们尝试输入 True == 5,GHCi 会抱怨。

注意

5 + 4.0 是一个有效的表达式,因为虽然 4.0 不是一个整数,但 5 很狡猾,可以像整数或浮点数一样行动。在这种情况下,5 适应以匹配浮点值 4.0 的类型。

我们稍后会更仔细地看看类型。

调用函数

无标题图片

你可能没有意识到,但我们实际上一直在使用函数。例如,*是一个接受两个数字并将它们相乘的函数。正如你所看到的,我们通过将其夹在我们要相乘的两个数字之间来应用(或调用)它。这被称为中缀函数。

然而,大多数函数都是前缀函数。在 Haskell 中调用前缀函数时,函数名首先出现,然后是一个空格,然后是其参数(也用空格分隔)。例如,我们将尝试调用 Haskell 中最无聊的函数之一,succ

ghci> succ 8
9

succ函数接受一个参数,可以是任何有明确定义的后继的值,并返回该值。整数值的后继只是下一个更大的数。

现在,让我们调用两个接受多个参数的前缀函数,minmax

ghci> min 9 10
9
ghci> min 3.4 3.2
3.2
ghci> max 100 101
101

minmax函数各自接受两个参数,可以将它们按某种顺序排列(如数字!),并分别返回较小或较大的值。

函数应用在 Haskell 中具有所有操作的最高优先级。换句话说,这两个语句是等价的。

ghci> succ 9 + max 5 4 + 1
16
ghci> (succ 9) + (max 5 4) + 1
16

这意味着如果我们想得到9 * 10的后继,我们不能简单地写下

ghci> succ 9 * 10

由于运算符的优先级,这将评估为 9 的后继(即 10)乘以 10,得到 100。要得到我们想要的结果,我们需要输入

ghci> succ (9 * 10)

这返回了 91。

如果一个函数接受两个参数,我们也可以通过在其名称周围加上反引号(`)将其作为中缀函数调用。例如,div函数接受两个整数并执行整数除法,如下所示:

ghci> div 92 10
9

然而,当我们这样称呼它时,可能会对哪个数字被哪个数字除产生一些混淆。通过使用反引号,我们可以将其称为中缀函数,突然间它似乎变得清晰多了:

ghci> 92 `div` 10
9

许多习惯于命令式语言的程序员倾向于坚持括号表示函数应用的观点,他们难以适应 Haskell 的方式。只需记住,如果你看到类似bar (bar 3)的东西,这意味着我们首先以3作为参数调用bar函数,然后将该结果传递给bar函数。在 C 语言中,等效的表达式可能是bar(bar(3))

宝宝的第一函数

无标题图片

函数定义的语法与函数调用类似:函数名后面跟着参数,参数之间用空格分隔。然后参数列表后面跟着=运算符,函数体由随后的代码组成。

例如,我们将编写一个简单的函数,该函数接受一个数字并将其乘以 2。打开你最喜欢的文本编辑器,输入以下内容:

doubleMe x = x + x

将此文件保存为 baby.hs。现在运行 ghci,确保 baby.hs 文件位于你的当前目录中。一旦进入 GHCi,输入 :l baby 来加载文件。现在我们可以玩我们的新函数:

ghci> :l baby
[1 of 1] Compiling Main             ( baby.hs, interpreted )
Ok, modules loaded: Main.
ghci> doubleMe 9
18
ghci> doubleMe 8.3
16.6

因为 + 既可以作用于整数,也可以作用于浮点数(实际上,作用于任何可以被认为是数字的东西),所以我们的函数也可以与这些类型中的任何一种一起使用。

现在让我们编写一个函数,它接受两个数字,将每个数字乘以二,然后将它们相加。将以下代码添加到 baby.hs 文件中:

doubleUs x y = x * 2 + y * 2

注意

Haskell 中的函数不必按任何特定顺序定义,所以 baby.hs 文件中哪个函数先来并不重要。

现在保存文件,并在 GHCi 中输入 :l baby 来加载你的新函数。测试这个函数会产生可预测的结果:

ghci> doubleUs 4 9
26
ghci> doubleUs 2.3 34.2
73.0
ghci> doubleUs 28 88 + doubleMe 123
478

你定义的函数也可以相互调用。考虑到这一点,我们可以按以下方式重新定义 doubleUs

doubleUs x y = doubleMe x + doubleMe y

这是在使用 Haskell 时你会看到的一个常见模式的简单示例:基本且显然正确的函数可以组合成更复杂的函数。这是一种避免代码重复的好方法。例如,如果有一天数学家发现 2 和 3 实际上是相同的,并且你必须更改你的程序,你只需将 doubleMe 重新定义为 x + x + x,由于 doubleUs 调用了 doubleMe,它现在也会在这个奇怪的、新的世界里自动正确地工作,在这个世界里 2 等于 3。

现在让我们编写一个函数,该函数将一个数字乘以 2,但仅当该数字小于或等于 100 时(因为大于 100 的数字已经足够大了!)。

doubleSmallNumber x = if x > 100
                        then x
                        else x*2

这个例子介绍了 Haskell 的 if 语句。你可能已经从其他语言中熟悉了 if 语句,但 Haskell 的独特之处在于 else 部分是强制性的。

命令式语言中的程序在程序运行时,计算机执行一系列步骤。当存在没有相应 elseif 语句且条件不满足时,那么属于 if 语句的步骤就不会被执行。因此,在命令式语言中,if 语句可以什么也不做。

另一方面,Haskell 程序是一系列函数的集合。函数用于将数据值转换为结果值,每个函数都应该返回某个值,这个值反过来又可以被另一个函数使用。由于每个函数都必须返回某些值,这意味着每个 if 都必须有一个相应的 else。否则,你可能会编写一个在满足某个条件时具有返回值但在不满足该条件时没有返回值的函数!简而言之:Haskell 的 if 是一个必须返回值的 表达式,而不是一个语句。

假设我们想要一个函数,该函数将 doubleSmallNumber 函数生成的每个数字加一。这个新函数的主体看起来像这样:

doubleSmallNumber' x = (if x > 100 then x else x*2) + 1

注意括号的放置。如果我们省略了它们,函数只会添加一个,如果 x 小于或等于 100。还要注意函数名末尾的单引号 (')。单引号在 Haskell 的语法中没有特殊含义,这意味着它是一个有效的函数名字符。我们通常使用 ' 来表示函数的严格版本(即不是懒加载的版本),或者是一个与类似名称的函数或变量的略微修改版本。

由于单引号 ' 是函数名中的一个有效字符,我们可以编写一个看起来像这样的函数:

conanO'Brien = "It's a-me, Conan O'Brien!"

这里有两点需要注意。首先,我们没有在函数名中将 Conan 大写。在 Haskell 中,函数不能以大写字母开头。(我们稍后会看到原因。)其次,需要注意的是,这个函数没有接受任何参数。当一个函数不接受任何参数时,我们通常称其为定义名称。因为我们一旦定义了名称(或函数),就不能更改它们的意思,所以函数 conanO'Brien 和字符串 "It's a-me, Conan O'Brien!" 可以互换使用。

列表简介

无标题图片

Haskell 中的列表是同构数据结构,这意味着它们存储相同类型的多个元素。例如,我们可以有一个整数列表或字符列表,但不能有一个同时包含整数和字符的列表。

列表被方括号包围,列表值由逗号分隔:

ghci> let lostNumbers = [4,8,15,16,23,42]
ghci> lostNumbers
[4,8,15,16,23,42]

注意

使用 let 关键字在 GHCi 中定义名称。在 GHCi 中输入 let a = 1 等同于在脚本中写入 a = 1,然后使用 :l 加载它。

连接

当处理列表时,最常见的操作之一是连接。在 Haskell 中,这是使用 ++ 操作符完成的:

ghci> [1,2,3,4] ++ [9,10,11,12]
[1,2,3,4,9,10,11,12]
ghci> "hello" ++ " " ++ "world"
"hello world"
ghci> ['w','o'] ++ ['o','t']
"woot"

注意

在 Haskell 中,字符串实际上只是字符列表。例如,字符串 "hello" 实际上与列表 ['h','e','l','l','o'] 相同。正因为如此,我们可以在字符串上使用列表函数,这非常方便。

当反复在长字符串上使用 ++ 操作符时要小心。当你组合两个列表时,Haskell 必须遍历整个第一个列表(++ 左侧的列表)。当处理较小的列表时,这没问题,但将内容追加到包含五千万条记录的列表末尾将花费一些时间。

然而,将内容添加到列表的开头是一个几乎瞬时的操作。我们使用 : 操作符(也称为cons操作符)来完成这项操作:

ghci> 'A':" SMALL CAT"
"A SMALL CAT"
ghci> 5:[1,2,3,4,5]
[5,1,2,3,4,5]

注意在第一个例子中,: 接受一个字符和一个字符列表(字符串)作为其参数。同样,在第二个例子中,: 接受一个数字和一个数字列表。: 操作符的第一个参数始终需要是列表中值的单个项目,并且与它被添加到的列表中的值类型相同。

另一方面,++ 操作符始终接受两个列表作为参数。即使你只使用 ++ 向列表末尾添加单个元素,你也必须用方括号将其包围,这样 Haskell 才会将其视为列表:

ghci> [1,2,3,4] ++ [5]
[1,2,3,4,5]

[1,2,3,4] ++ 5 写作是错误的,因为 ++ 的两个参数都应该列表,而 5 不是一个列表;它是一个数字。

有趣的是,在 Haskell 中,[1,2,3] 只是 1:2:3:[] 的语法糖。[] 是一个空列表。如果我们向其中添加 3,它就变成了 [3]。然后如果我们向其中添加 2,它就变成了 [2,3],依此类推。

注意

[][[]][[],[],[]] 都是不同的事物。第一个是一个空列表,第二个是一个包含一个空列表的列表,第三个是一个包含三个空列表的列表。

访问列表元素

如果你想通过索引获取列表中的元素,请使用 !! 操作符。与大多数编程语言一样,索引从 0 开始:

ghci> "Steve Buscemi" !! 6
'B'
ghci> [9.4,33.2,96.2,11.2,23.25] !! 1
33.2

然而,如果你尝试(比如说)从一个只有四个元素的列表中获取第六个元素,你会得到一个错误,所以请小心!

列表嵌套

列表可以包含列表作为元素,列表可以包含包含列表的列表,依此类推。 . . .

ghci> let b = [[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]]
ghci> b
[[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]]
ghci> b ++ [[1,1,1,1]]
[[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3],[1,1,1,1]]
ghci> [6,6,6]:b
[[6,6,6],[1,2,3,4],[5,3,3,3],[1,2,2,3,4],[1,2,3]]
ghci> b !! 2
[1,2,2,3,4]

列表中的列表可以有不同的长度,但它们不能有不同的类型。就像你不能有一个包含一些字符和一些数字作为元素的列表一样,你也不能有一个包含一些字符列表和一些数字列表的列表。

比较列表

如果列表中包含的项可以进行比较,则可以比较列表。当使用 <<=>=> 来比较两个列表时,它们按字典顺序进行比较。这意味着首先比较两个列表的头部,如果它们相等,则比较第二个元素。如果第二个元素也相等,则比较第三个元素,依此类推,直到找到不同的元素。两个列表的顺序由第一对不同元素的顺序决定。

例如,当我们评估 [3,4,2] < [3,4,3] 时,Haskell 会看到 33 是相等的,所以它比较 44。这两个也是相等的,所以它比较 232 小于 3,所以它得出结论,第一个列表小于第二个列表。对于 <=>=> 也是如此。

ghci> [3,2,1] > [2,1,0]
True
ghci> [3,2,1] > [2,10,100]
True
ghci> [3,4,2] < [3,4,3]
True
ghci> [3,4,2] > [2,4]
True
ghci> [3,4,2] == [3,4,2]
True

此外,非空列表始终被认为大于空列表。这使得两个列表的排序在所有情况下都定义良好,包括当一个列表是另一个列表的正确初始段时。

更多列表操作

这里有一些更基本的列表函数,以及它们使用示例。

head 函数接受一个列表并返回其头部,或第一个元素:

ghci> head [5,4,3,2,1]
5

tail 函数接受一个列表并返回其尾部。换句话说,它切掉了列表的头部:

ghci> tail [5,4,3,2,1]
[4,3,2,1]

last 函数返回列表的最后一个元素:

ghci> last [5,4,3,2,1]
1

init 函数接受一个列表并返回除了其最后一个元素之外的所有内容:

ghci> init [5,4,3,2,1]
[5,4,3,2]

为了帮助我们可视化这些函数,我们可以将列表想象成一个怪物,如下所示:

无标题图片

但如果我们尝试获取一个空列表的头部会发生什么呢?

ghci> head []
*** Exception: Prelude.head: empty list

哇——它在我们面前爆炸了!如果没有怪物,它就没有头部。当使用headtaillastinit时,要小心不要在空列表上使用它们。这个错误在编译时无法捕获,因此总是好的做法是预防意外地让 Haskell 给你从空列表中获取元素。

length函数接受一个列表并返回其长度:

ghci> length [5,4,3,2,1]
5

null函数检查列表是否为空。如果是,它返回True,否则返回False

ghci> null [1,2,3]
False
ghci> null []
True

reverse函数反转列表:

ghci> reverse [5,4,3,2,1]
[1,2,3,4,5]

take函数接受一个数字和一个列表。它从列表的开头提取指定数量的元素,如下所示:

ghci> take 3 [5,4,3,2,1]
[5,4,3]
ghci> take 1 [3,9,3]
[3]
ghci> take 5 [1,2]
[1,2]
ghci> take 0 [6,6,6]
[]

如果我们尝试获取比列表中元素更多的元素,Haskell 会返回整个列表。如果我们获取 0 个元素,我们会得到一个空列表。

drop函数的工作方式类似,但它只从列表的开头删除(最多)指定数量的元素:

ghci> drop 3 [8,4,2,1,5,6]
[1,5,6]
ghci> drop 0 [1,2,3,4]
[1,2,3,4]
ghci> drop 100 [1,2,3,4]
[]

maximum函数接受可以按某种顺序排列的项目列表,并返回最大元素。minimum函数类似,但它返回最小项:

ghci> maximum [1,9,2,3,4]
9
ghci> minimum [8,4,2,1,5,6]
1

sum函数接受一个数字列表并返回它们的和。product函数接受一个数字列表并返回它们的乘积:

ghci> sum [5,2,1,6,3,2,5,7]
31
ghci> product [6,2,1,2]
24
ghci> product [1,2,5,6,7,9,2,0]
0

elem函数接受一个项目和项目列表,并告诉我们该项目是否是列表的元素。它通常被用作中缀函数,因为这样更容易阅读。

ghci> 4 `elem` [3,4,5,6]
True
ghci> 10 `elem` [3,4,5,6]
False

德克萨斯范围

无标题图片

如果我们需要一个由 1 到 20 之间的数字组成的列表呢?当然,我们可以直接输入它们,但这不是对那些对他们的编程语言有卓越要求的绅士们的解决方案。相反,我们将使用范围。范围用于创建由可以编号或按顺序计数的元素组成的列表。

例如,数字可以进行编号:1, 2, 3, 4,等等。字符也可以进行编号:字母表是从 A 到 Z 的字符编号。然而,名字却不能进行编号。(“John”之后是什么?我不知道!)

要创建一个包含从 1 到 20 的所有自然数的列表,你只需输入[1..20]。在 Haskell 中,这与输入[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]完全相同。这两者之间的唯一区别是手动编写长的编号序列是愚蠢的。

这里有一些更多的例子:

ghci> [1..20]
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
ghci> ['a'..'z']
"abcdefghijklmnopqrstuvwxyz"
ghci> ['K'..'Z']
"KLMNOPQRSTUVWXYZ"

你也可以指定范围中元素之间的步长。如果我们想要一个包含 1 到 20 之间所有偶数的列表,或者 1 到 20 之间每隔一个数的列表呢?这只是一个用逗号分隔前两个元素并指定上限的问题:

ghci> [2,4..20]
[2,4,6,8,10,12,14,16,18,20]
ghci> [3,6..20]
[3,6,9,12,15,18]

虽然它们非常方便,但带有步长的范围并不总是像人们期望的那样智能。例如,你不能输入 [1,2,4,8,16..100] 并期望得到所有不大于 100 的 2 的幂。首先,你只能指定一个步长大小。此外,一些不是算术的序列不能仅通过给出它们的前几个项来明确指定。

注意

要创建一个包含从 20 到 1 的所有数字的列表,你不能只输入 [20..1],你必须输入 [20,19..1]。当你使用不带步长的范围(如 [20..1])时,Haskell 将从空列表开始,然后不断将起始元素增加 1,直到它达到或超过范围的结束元素。因为 20 已经大于 1,所以结果将只是一个空列表。

你也可以通过不指定上限来使用范围创建无限列表。例如,让我们创建一个包含前 24 个 13 的倍数的列表。这是其中一种方法:

ghci> [13,26..24*13]
[13,26,39,52,65,78,91,104,117,130,143,156,169,
182,195,208,221,234,247,260,273,286,299,312]

但实际上有一个更好的方法——使用一个无限列表:

ghci> take 24 [13,26..]
[13,26,39,52,65,78,91,104,117,130,143,156,169,182,
195,208,221,234,247,260,273,286,299,312]

因为 Haskell 是惰性的,它不会立即尝试评估整个无限列表(这是好事,因为它永远不会完成)。相反,它将等待查看你需要从那个无限列表中获取哪些元素。在上面的例子中,它看到你只需要前 24 个元素,并且它很乐意满足你的要求。

这里有一些可以用来生成长列表或无限列表的函数:

  • cycle函数接受一个列表并无限复制其元素以形成一个无限列表。如果你尝试显示结果,它将永远继续,所以请确保在某个地方将其切片:

    ghci> take 10 (cycle [1,2,3])
    [1,2,3,1,2,3,1,2,3,1]
    ghci> take 12 (cycle "LOL ")
    "LOL LOL LOL "
    
  • repeat函数接受一个元素并生成一个只包含该元素的无限列表。这就像用一个只有一个元素的列表进行循环:

    ghci> take 10 (repeat 5)
    [5,5,5,5,5,5,5,5,5,5]
    
  • replicate是一个创建由单个元素组成的列表的更简单方法。它接受列表的长度和要复制的元素,如下所示:

    ghci> replicate 3 10
    [10,10,10]
    

关于范围的最后一点:在使用浮点数时要小心!因为浮点数,由于其本质,只有有限的精度,所以在范围内使用它们可能会得到一些相当奇怪的结果,就像你在这里看到的那样:

ghci> [0.1, 0.3 .. 1]
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]

我是一个列表推导

无标题图片

列表推导是一种过滤、转换和组合列表的方法。

它们与数学中的集合推导概念非常相似。集合推导通常用于从其他集合中构建集合。一个简单的集合推导示例是:{ 2 · x|xN, x ≤ 10}。这里使用的确切语法并不重要——重要的是这个语句说的是,“取所有小于或等于 10 的自然数,将每个数乘以 2,并使用这些结果创建一个新的集合。”

如果我们想在 Haskell 中写出相同的内容,我们可以使用列表操作:take 10 [2,4..]。然而,我们也可以使用列表推导来完成同样的事情,如下所示:

ghci> [x*2 | x <- [1..10]]
[2,4,6,8,10,12,14,16,18,20]

让我们更仔细地看看这个例子中的列表推导式,以更好地理解列表推导式的语法。

[x*2 | x <- [1..10]] 中,我们说我们 提取 元素来自列表 [1..10][x <- [1..10]] 意味着 x 取每个从 [1..10] 中提取的元素的值。换句话说,我们将 [1..10] 中的每个元素 绑定x。垂直管道(|)之前的部分是列表推导式的 输出。输出是我们指定我们希望提取的元素如何在结果列表中反映的部分。在这个例子中,我们说我们希望从列表 [1..10] 中提取的每个元素都加倍。

这可能看起来比第一个例子更长更复杂,但如果我们想要做的不仅仅是加倍这些数字,会怎么样呢?这正是列表推导式真正派上用场的地方。

例如,让我们在我们的理解中添加一个条件(也称为 谓词)。谓词位于列表推导式的末尾,并通过逗号与理解的其他部分分开。假设我们只想包含那些在加倍后大于或等于 12 的元素:

ghci> [x*2 | x <- [1..10], x*2 >= 12]
[12,14,16,18,20]

如果我们想要所有 50 到 100 之间,除以 7 余数为 3 的数字,会怎样?很简单:

ghci> [ x | x <- [50..100], x `mod` 7 == 3]
[52,59,66,73,80,87,94]

注意

使用谓词去除列表的部分也称为 过滤

现在来看另一个例子。假设我们想要一个理解,将大于 10 的奇数替换为 "BANG!",将小于 10 的奇数替换为 "BOOM!"。如果一个数字不是奇数,我们就将其从列表中排除。为了方便,我们将这个理解放入一个函数中,这样我们就可以轻松地重用它:

boomBangs xs = [ if x < 10 then "BOOM!" else "BANG!" | x <- xs, odd x]

注意

记住,如果你试图在 GHCi 中定义这个函数,你必须在函数名之前包含一个 let。然而,如果你在脚本中定义这个函数,然后将其加载到 GHCi 中,你就不需要与 let 糟糕地打交道。

当传递一个奇数给 odd 函数时,它返回 True,否则返回 False。只有当所有谓词评估为 True 时,元素才包含在列表中。

ghci> boomBangs [7..13]
["BOOM!","BOOM!","BANG!","BANG!"]

我们可以包含尽可能多的谓词,所有谓词都由逗号分隔。例如,如果我们想要所有 10 到 20 之间的数字,但不包括 13、15 或 19,我们会这样做:

ghci> [ x | x <- [10..20], x /= 13, x /= 15, x /= 19]
[10,11,12,14,16,17,18,20]

不仅可以在列表推导式中包含多个谓词,还可以从几个列表中提取值。当从几个列表中提取值时,这些列表的所有元素组合都会反映在结果列表中:

ghci> [x+y | x <- [1,2,3], y <- [10,100,1000]]
[11,101,1001,12,102,1002,13,103,1003]

在这里,x是从[1,2,3]中抽取的,而y是从[10,100,1000]中抽取的。这两个列表以以下方式组合。首先,x变为1,当x1时,y[10,100,1000]中的每一个值。因为列表推导式的输出是x+y,所以值111011001被添加到结果列表的开头(1被添加到101001000)。之后,x变为2,同样的事情发生,导致元素121021002被添加到结果列表中。当x抽取值3时,情况也是如此。

以这种方式,列表[1,2,3]中的每个元素x都会与列表[10,100,1000]中的每个元素y以所有可能的方式组合,使用x+y来生成这些组合的结果列表。

这里还有一个例子:如果我们有两个列表[2,5,10][8,10,11],并且我们想要得到那些列表中所有可能数字组合的乘积,我们可以使用以下推导式:

ghci> [ x*y | x <- [2,5,10], y <- [8,10,11]]
[16,20,22,40,50,55,80,100,110]

如预期的那样,新列表的长度是 9。现在,如果我们想得到所有大于 50 的可能乘积呢?我们只需添加另一个谓词:

ghci> [ x*y | x <- [2,5,10], y <- [8,10,11], x*y > 50]
[55,80,100,110]

为了达到史诗般的幽默效果,让我们创建一个结合形容词列表和名词列表的列表推导式。

ghci> let nouns = ["hobo","frog","pope"]
ghci> let adjectives = ["lazy","grouchy","scheming"]
ghci> [adjective ++ " " ++ noun | adjective <- adjectives, noun <- nouns]
["lazy hobo","lazy frog","lazy pope","grouchy hobo","grouchy frog",
"grouchy pope","scheming hobo","scheming frog","scheming pope"]

我们甚至可以使用列表推导式来编写我们自己的length函数版本!我们将它称为length'。这个函数将列表中的每个元素替换为1,然后使用sum将它们全部加起来,得到列表的长度。

length' xs = sum [1 | _ <- xs]

在这里,我们使用下划线(_)作为临时变量来存储我们从中提取的项目,因为我们实际上并不关心这些值。

记住,字符串也是列表,所以我们可以使用列表推导式来处理和生成字符串。以下是一个函数的示例,它接受一个字符串并从中删除所有小写字母:

removeNonUppercase st = [ c | c <- st, c `elem` ['A'..'Z']]

这个谓词在这里做了所有的工作。它表示,只有当字符是列表['A'..'Z']的元素时,它才会被包含在新列表中。我们可以在 GHCi 中加载这个函数并测试它:

ghci> removeNonUppercase "Hahaha! Ahahaha!"
"HA"
ghci> removeNonUppercase "IdontLIKEFROGS"
"ILIKEFROGS"

如果你在操作包含列表的列表,你也可以创建嵌套的列表推导式。例如,让我们取一个包含几个数字列表的列表,并移除所有奇数而不展开列表:

ghci> let xxs = [[1,3,5,2,3,1,2,4,5],[1,2,3,4,5,6,7,8,9],[1,2,4,2,1,6,3,1,3,2,3,6]]
ghci> [ [ x | x <- xs, even x ] | xs <- xxs]
[[2,2,4],[2,4,6,8],[2,4,2,6,2,6]]

这里外层列表推导式的输出是另一个列表推导式。列表推导式总是产生某种列表,所以我们可以知道这里的输出将是一个数字列表的列表。

注意

你可以将列表推导式拆分到多行以提高其可读性。如果你不在 GHCi 中,这可以是一个很大的帮助,尤其是在处理嵌套推导式时。

元组

无标题图片

元组用于存储多个异构元素作为单个值。

在某些方面,元组与列表非常相似。然而,有一些基本区别。首先,如前所述,元组是异构的。这意味着单个元组可以存储几种不同类型的元素。其次,元组具有固定的大小——你需要提前知道你将存储多少个元素。

元组被圆括号包围,并且它们的组件由逗号分隔:

ghci> (1, 3)
(1,3)
ghci> (3, 'a', "hello")
(3,'a',"hello")
ghci> (50, 50.4, "hello", 'b')
(50,50.4,"hello",'b')

使用元组

作为元组何时有用的例子,让我们考虑在 Haskell 中如何表示二维向量。一种方法是将两个项目列表表示为 [x,y]。但是,假设我们想要创建一个向量列表,以表示坐标平面中二维形状的角。我们可以简单地创建一个列表的列表,如下所示:[[1,2],[8,11],[4,5]]

然而,这种方法的问题是我们也可以创建一个列表,如 [[1,2],[8,11,5],[4,5]] 并尝试将其用作向量列表。尽管它作为向量列表没有意义,但 Haskell 没有问题地将此列表出现在之前列表可以出现的地方,因为它们都是同一类型(数字的列表的列表)。这可能会使编写操作向量和形状的函数变得更加复杂。

相反,大小为二的元组(也称为)和大小为三的元组(也称为三元组)被视为两种不同的类型,这意味着列表不能由对和三元组组成。这使得元组在表示向量时非常有用。

我们可以通过用圆括号而不是方括号包围它们来将向量更改为元组,如下所示:[(1,2),(8,11),(4,5)]。现在,如果我们尝试混合成对和三元组,我们会得到一个错误,如下所示:

ghci> [(1,2),(8,11,5),(4,5)]
Couldn't match expected type `(t, t1)'
against inferred type `(t2, t3, t4)'
In the expression: (8, 11, 5)
In the expression: [(1, 2), (8, 11, 5), (4, 5)]
In the definition of `it': it = [(1, 2), (8, 11, 5), (4, 5)]

Haskell 还认为具有相同长度但包含不同类型数据的元组是不同的元组类型。例如,你不能创建一个包含元组的列表,如 [(1,2),("One",2)],因为第一个是数字的对,而第二个是包含字符串后跟数字的对。

元组可以用来轻松地表示各种数据。例如,如果我们想在 Haskell 中表示某人的姓名和年龄,我们可以使用一个三元组:("Christopher", "Walken", 55)

记住,元组的大小是固定的——你应该只在事先知道你需要多少个元素时才使用它们。元组之所以如此严格,是因为,如前所述,元组的大小被视为其类型的一部分。不幸的是,这意味着你不能编写一个通用的函数来向元组追加元素——你需要为追加到对(以产生三元组)编写一个函数,为追加到三元组(以产生四元组)编写另一个函数,为追加到四元组编写另一个函数,依此类推。

与列表一样,如果它们的组件可以比较,元组可以相互比较。然而,与列表不同,你不能比较不同大小的两个元组。

虽然存在单元素列表,但没有单元素元组。当你这么想的时候,这是有道理的:单元素元组的属性将仅仅是它包含的值的属性,所以区分新的类型不会给我们带来任何好处。

使用对

在 Haskell 中,以对的形式存储数据非常常见,并且有一些有用的函数来操作它们。以下是对对进行操作的两种函数:

  • fst接受一个对并返回其第一个组件:

    ghci> fst (8, 11)
    8
    ghci> fst ("Wow", False)
    "Wow"
    
  • snd接受一个对并——惊喜!——返回其第二个组件:

    ghci> snd (8, 11)
    11
    ghci> snd ("Wow", False)
    False
    

注意

这些函数只对对进行操作。它们不会在三元组、四元组、五元组等上工作。我们稍后会介绍从元组中提取数据的不同方法。

zip函数是一种产生对列表的酷方法。它接受两个列表,然后将它们“压缩”成一个列表,通过将匹配的元素组合成对来实现。这是一个非常简单的函数,但它可以在你想要以特定方式组合两个列表或同时遍历两个列表时非常有用。以下是一个演示:

ghci> zip [1,2,3,4,5] [5,5,5,5,5]
[(1,5),(2,5),(3,5),(4,5),(5,5)]
ghci> zip [1..5] ["one", "two", "three", "four", "five"]
[(1,"one"),(2,"two"),(3,"three"),(4,"four"),(5,"five")]

注意,由于对可以包含不同类型的元素,zip可以接受包含不同类型元素的两个列表。但如果列表的长度不匹配会发生什么呢?

ghci> zip [5,3,2,6,2,7,2,5,4,6,6] ["im","a","turtle"]
[(5,"im"),(3,"a"),(2,"turtle")]

正如你在上面的例子中看到的,只有与所需的一样多的较长列表被使用——其余的只是被忽略。而且因为 Haskell 使用惰性求值,我们甚至可以将有限列表与无限列表zip

ghci> zip [1..] ["apple", "orange", "cherry", "mango"]
[(1,"apple"),(2,"orange"),(3,"cherry"),(4,"mango")]

寻找直角三角形

让我们用一个结合元组和列表解析的问题来结束。我们将使用 Haskell 找到一个符合所有这些条件的直角三角形:

  • 三边的长度都是整数。

  • 每条边的长度都小于或等于 10。

  • 三角形的周长(边长的总和)等于 24。

无标题图片

如果一个三角形的一个角是直角(90 度角),那么这个三角形就是直角三角形。直角三角形有一个有用的性质:如果你平方形成直角的边的长度,然后将这些平方相加,那么这个和等于与直角相对的边的长度的平方。在图片中,与直角相邻的边被标记为ab,与直角相对的边被标记为c。我们称那个边为斜边

作为第一步,让我们生成所有可能的由小于或等于 10 的元素组成的三元组:

ghci> let triples = [ (a,b,c) | c <- [1..10], a <- [1..10], b <- [1..10] ]

我们在理解右侧的三个列表中抽取,输出表达式左侧将它们组合成一个三元组列表。如果你在 GHCi 中评估triples,你会得到一个包含 1,000 个条目的列表,所以我们在这里不会显示它。

接下来,我们将通过添加一个检查勾股定理(a² + b² == c²)是否成立的谓词来过滤掉不代表直角三角形的三角形。我们还将修改函数以确保边a不大于斜边c,并且边b不大于边a

ghci> let rightTriangles = [ (a,b,c) | c <- [1..10], a <- [1..c], b <- [1..a],
a² + b² == c²]

注意我们如何改变了从列表中抽取值的范围。这确保了我们不会检查不必要的三角形,例如边b大于斜边(在直角三角形中,斜边总是最长的边)的情况。我们还假设边b永远不会大于边a。这不会造成任何损害,因为对于每个被排除在外的、满足a² + b² == c²b > a的三角形(a,b,c),都会包含三角形(b,a,c)——只是边长顺序相反。(否则,我们的结果列表将包含实际上是相同三角形的成对三角形。)

注意

在 GHCi 中,你不能将定义和表达式拆分到多行。然而,在这本书中,我们偶尔需要将单行拆分,以便代码可以全部适合在页面上。(否则这本书会非常宽,无法放在任何正常的书架上——然后你就得买更大的书架了!)

我们几乎完成了。现在,我们只需要修改函数,使其只输出周长等于 24 的三角形:

ghci> let rightTriangles' = [ (a,b,c) | c <- [1..10],
 a <- [1..c], b <- [1..a], a² + b² == c², a+b+c == 24]
ghci> rightTriangles'
[(6,8,10)]

我们的答案就在这里!这是函数式编程中的一种常见模式:你从一个特定的候选解决方案集合开始,然后依次对这些解决方案应用转换和过滤,直到将可能性缩小到你想要的那个(或几个)解决方案。

第二章:相信类型

无标题图片

Haskell 最强大的优点之一是其强大的类型系统。

在 Haskell 中,每个表达式的类型在编译时都是已知的,这导致代码更安全。如果你编写了一个尝试用数字除以布尔类型的程序,它将无法编译。这是好事,因为最好在编译时捕获这些错误,而不是让程序在以后崩溃。Haskell 中的每样东西都有类型,所以编译器可以在编译程序之前对程序进行大量的推理。

与 Java 或 Pascal 不同,Haskell 具有类型推断功能。例如,如果我们写一个数字,我们不需要告诉 Haskell 它是一个数字,因为它可以自己推断出来。

到目前为止,我们只对 Haskell 的类型进行了非常表面的了解,但理解类型系统是学习 Haskell 的一个非常重要的部分。

显式类型声明

我们可以使用 GHCi 来检查一些表达式的类型。我们将通过使用 :t 命令来实现,该命令后面跟任何有效表达式,会告诉我们它的类型。让我们试一试:

ghci> :t 'a'
'a' :: Char
ghci> :t True
True :: Bool
ghci> :t "HELLO!"
"HELLO!" :: [Char]
ghci> :t (True, 'a')
(True, 'a') :: (Bool, Char)
ghci> :t 4 == 5
4 == 5 :: Bool

这里的 :: 操作符读作“类型为”。显式类型总是用首字母大写来表示。'a' 的类型是 Char,代表字符TrueBool 类型,或布尔类型。字符串 "HELLO!" 显示其类型为 [Char]。方括号表示列表,所以我们将其读作字符列表。与列表不同,每个元组的长度都有自己的类型。因此,元组 (True, 'a') 的类型是 (Bool, Char),而 ('a','b','c') 的类型是 (Char, Char, Char)4 == 5 总是返回 False,所以它的类型是 Bool

无标题图片

函数也有类型。当我们编写自己的函数时,我们可以选择给它们一个显式的类型声明。这通常被认为是一种良好的实践(除非编写非常简短的功能)。从现在开始,我们将对所有我们进行类型声明的函数进行类型声明。

记得我们在第一章“开始”中制作的列表推导?那个过滤掉字符串小写字母的?这是带有类型声明的样子:

removeNonUppercase :: [Char] -> [Char]
removeNonUppercase st = [ c | c <- st, c `elem` ['A'..'Z']]

removeNonUppercase 函数的类型是 [Char] -> [Char],这意味着它接受一个字符串作为参数并返回另一个字符串。

但我们如何指定一个接受多个参数的函数的类型呢?这里有一个简单的函数,它接受三个整数并将它们相加:

addThree :: Int -> Int -> Int -> Int
addThree x y z = x + y + z

参数和返回类型由 -> 字符分隔,返回类型总是在声明中最后出现。(在第五章“高阶函数”中,你会看到为什么它们都用 -> 分隔,而不是有更明确的区分。)

如果你想给你的函数一个类型声明,但又不确定应该是什么,你总是可以只写不带声明的函数,然后用 :t 来检查它。由于函数是表达式,:t 对它们的作用方式与你在本节开头看到的方式相同。

常见的 Haskell 类型

让我们看看一些常见的 Haskell 类型,它们用于表示基本事物,如数字、字符和布尔值。以下是一个概述:

  • Int 代表整数。它用于表示整数。7 可以是一个 Int,但 7.2 不能。Int有界 的,这意味着它有一个最小值和一个最大值。

    注意

    我们使用的是 GHC 编译器,其中 Int 的范围由你电脑上机器字的大小决定。所以如果你有一个 64 位 CPU,你的系统上最低的 Int 很可能是 -2⁶³,最高的是 2⁶³。

  • Integer 也用于存储整数,但它没有界限,因此可以用来表示非常大的数字。(我指的是 真的 很大!)然而,Int 更高效。例如,尝试将以下函数保存到文件中:

    factorial :: Integer -> Integer
    factorial n = product [1..n]
    

    然后,使用 :l 将其加载到 GHCi 中并测试它:

    ghci> factorial 50
    30414093201713378043612608166064768844377641568960512000000000000
    
  • Float 是一个单精度浮点数。将以下函数添加到你正在工作的文件中:

    circumference :: Float -> Float
    circumference r = 2 * pi * r
    

    然后,加载并测试它:

    ghci> circumference 4.0
    25.132742
    
  • Double 是一个双精度浮点数,精度是单精度的两倍。双精度数值类型使用两倍的位来表示数字。额外的位增加了它们的精度,但同时也占用更多的内存。这里还有一个可以添加到你的文件中的函数:

    circumference' :: Double -> Double
    circumference' r = 2 * pi * r
    

    现在加载并测试它。特别注意 circumferencecircumference' 之间的精度差异。

    ghci> circumference' 4.0
    25.132741228718345
    
  • Bool 是一个布尔类型。它只能有两个值:TrueFalse

  • Char 表示一个 Unicode 字符。它用单引号表示。字符的列表是一个字符串。

  • 元组是类型,但它们的定义也取决于它们的长度以及组件的类型。因此,从理论上讲,存在无限多的元组类型。(在实践中,元组最多可以有 62 个元素——远远超过你需要的。)注意,空元组 () 也是一个类型,它只能有一个值:()

类型变量

对于某些函数能够在各种类型上操作是有意义的。例如,head 函数接受一个列表并返回该列表的头部元素。列表中包含的是数字、字符,甚至是更复杂的列表,这并不重要!该函数应该能够处理包含几乎所有内容的列表。

你认为 head 函数的类型是什么?让我们用 :t 函数来检查:

ghci> :t head
head :: [a] -> a

a 是什么?记住类型名称以大写字母开头,所以它不能是一个类型。这实际上是一个 类型变量 的例子,这意味着 a 可以是任何类型。

无标题图片

类型变量允许函数以类型安全的方式在多种类型的值上操作。这与其他编程语言中的泛型非常相似。然而,Haskell 的版本要强大得多,因为它允许我们轻松地编写非常通用的函数。

使用类型变量的函数被称为多态函数head 的类型声明表明它接受任何类型的列表并返回该类型的一个元素。

注意

虽然类型变量可以有超过一个字符的名称,但我们通常给它们取名为 abcd 等等。

记得 fst 吗?它返回一个配对中的第一个元素。让我们检查它的类型:

ghci> :t fst
fst :: (a, b) -> a

你可以看到 fst 接受一个元组并返回一个与第一个元素相同类型的元素。这就是为什么我们可以在包含任何两种类型元素的配对上使用 fst。注意,尽管 ab 是不同的类型变量,它们不一定是不同类型。这仅仅意味着第一个元素的类型和返回值的类型将是相同的。

类型类 101

类型类是一个定义某些行为的接口。如果一个类型是类型类的实例,那么它支持并实现了类型类所描述的行为。

无标题图片

更具体地说,类型类指定了一组函数,当我们决定将一个类型作为类型类的实例时,我们定义了这些函数对该类型的意义。

定义相等的类型类是一个很好的例子。许多类型的值可以通过使用 == 运算符进行比较。让我们检查这个运算符的类型签名:

ghci> :t (==)
(==) :: (Eq a) => a -> a -> Bool

注意,等号运算符(==)实际上是一个函数。同样,+*-/以及几乎所有的其他运算符也都是函数。如果一个函数仅由特殊字符组成,它默认被视为中缀函数。如果我们想检查其类型、传递给另一个函数或将其作为前缀函数调用,我们需要将其括号包围,就像前面的例子一样。

这个例子展示了新内容:=> 符号。这个符号之前的一切被称为类约束。我们可以这样阅读这个类型声明:等式函数接受任何两个相同类型的值并返回一个 Bool。这两个值的类型必须是 Eq 类的实例。

Eq 类型类提供了一个用于测试相等的接口。如果对特定类型的两个项目进行比较相等是有意义的,那么该类型可以是 Eq 类型类的实例。所有标准的 Haskell 类型(除了输入/输出类型和函数)都是 Eq 的实例。

注意

重要的是要注意,类型类与面向对象编程语言中的类并不相同。

让我们看看一些最常见的 Haskell 类型类,它们使我们的类型可以轻松地进行相等性和顺序比较,作为字符串打印等。

Eq 类型类

正如我们所讨论的,Eq 用于支持等性测试的类型。它的实例实现的函数是 ==/=。这意味着如果函数中对类型变量有 Eq 类约束,它会在定义的某个地方使用 ==/=。当一个类型实现一个函数时,这意味着它定义了当使用该特定类型时函数的行为。以下是一些在 Eq 的各种实例上执行这些操作的例子:

ghci> 5 == 5
True
ghci> 5 /= 5
False
ghci> 'a' == 'a'
True
ghci> "Ho Ho" == "Ho Ho"
True
ghci> 3.432 == 3.432
True

Ord 类型类

Ord 是一个类型类,其值可以按某种顺序排列。例如,让我们看看大于 (>) 操作符的类型:

ghci> :t (>)
(>) :: (Ord a) => a -> a -> Bool

> 类型与 == 类型的用法相似。它接受两个参数并返回一个 Bool,告诉我们这两个事物之间是否存在某种关系。

我们到目前为止所讨论的所有类型(再次强调,除了函数)都是 Ord 类型的实例。Ord 包含了所有标准比较函数,如 >, <, >=<=

compare 函数接受两个类型为 Ord 实例的值,并返回一个 OrderingOrdering 是一个类型,可以是 GTLTEQ,分别代表大于、小于或等于。

ghci> "Abrakadabra" < "Zebra"
True
ghci> "Abrakadabra" `compare` "Zebra"
LT
ghci> 5 >= 2
True
ghci> 5 `compare` 3
GT
ghci> 'b' > 'a'
True

Show 类型类

类型为 Show 类型类实例的值可以表示为字符串。我们到目前为止所讨论的所有类型(除了函数)都是 Show 类型的实例。在这个类型类上操作的最常用的函数是 show,它将给定的值打印为字符串:

ghci> show 3
"3"
ghci> show 5.334
"5.334"
ghci> show True
"True"

Read 类型类

Read 可以被认为是 Show 类型类的对立面。同样,我们到目前为止所讨论的所有类型都是 Read 类型的实例。read 函数接受一个字符串并返回一个类型为 Read 实例的值:

ghci> read "True" || False
True
ghci> read "8.2" + 3.8
12.0
ghci> read "5" - 2
3
ghci> read "[1,2,3,4]" ++ [3]
[1,2,3,4,3]

到目前为止一切顺利。但如果我们尝试输入 read "4" 会发生什么呢?

ghci> read "4"
<interactive>:1:0:
    Ambiguous type variable 'a' in the constraint:
      'Read a' arising from a use of 'read' at <interactive>:1:0-7
    Probable fix: add a type signature that fixes these type variable(s)

GHCi 告诉我们它不知道我们想要什么返回值。注意,在之前的 read 使用中,我们之后对结果做了些处理,这使得 GHCi 能够推断出我们想要的返回类型。如果我们将其用作布尔值,例如,它知道它必须返回一个 Bool。但现在它知道我们想要的是 Read 类型的某个类型,但它不知道具体是哪一个。让我们看看 read 的类型签名:

ghci> :t read
read :: (Read a) => String -> a

注意

String 只是 [Char] 的另一个名称。String[Char] 可以互换使用,但我们将主要坚持使用 String,因为它更容易编写且更易读。

我们可以看到 read 函数返回的值的类型是 Read 的实例,但如果以某种方式使用这个结果,它没有方法知道具体是哪种类型。为了解决这个问题,我们可以使用 类型注解

类型注解是明确告诉 Haskell 表达式类型的一种方式。我们通过在表达式的末尾添加 :: 并指定一个类型来实现这一点:

ghci> read "5" :: Int
5
ghci> read "5" :: Float
5.0
ghci> (read "5" :: Float) * 4
20.0
ghci> read "[1,2,3,4]" :: [Int]
[1,2,3,4]
ghci> read "(3, 'a')" :: (Int, Char)
(3, 'a')

编译器可以自己推断大多数表达式的类型。然而,有时编译器不知道对于像 read "5" 这样的表达式应该返回 Int 类型的值还是 Float 类型的值。为了查看类型,Haskell 需要实际评估 read "5"。但由于 Haskell 是静态类型语言,它需要在代码编译(或在 GHCi 的情况下评估)之前知道所有类型。因此,我们需要告诉 Haskell,“嘿,这个表达式应该有这种类型,以防你不知道!”

我们可以只给 Haskell 提供它需要的最少信息,以确定 read 应该返回哪种类型的值。例如,如果我们使用 read 并将其结果塞入列表中,Haskell 可以通过查看列表中的其他元素来确定我们想要的类型:

ghci> [read "True", False, True, False]
[True, False, True, False]

由于我们将 read "True" 作为 Bool 值列表中的一个元素使用,Haskell 看到这一点,read "True" 的类型也必须是 Bool

枚举类型类

Enum 实例是顺序排列的类型——它们的值可以被枚举。Enum 类型类的主要优点是我们可以在列表范围内使用它的值。它们还有定义好的后继和前驱,我们可以通过 succpred 函数来获取。这个类中的类型示例包括 ()BoolCharOrderingIntIntegerFloatDouble

ghci> ['a'..'e']
"abcde"
ghci> [LT .. GT]
[LT,EQ,GT]
ghci> [3 .. 5]
[3,4,5]
ghci> succ 'B'
'C'

有界类型类

Bounded 类型类的实例有一个上界和一个下界,可以通过使用 minBoundmaxBound 函数来检查:

ghci> minBound :: Int
-2147483648
ghci> maxBound :: Char
'\1114111'
ghci> maxBound :: Bool
True
ghci> minBound :: Bool
False

minBoundmaxBound 函数很有趣,因为它们有一个类型 (Bounded a) => a。在某种意义上,它们是多态常量。

注意,所有组件都是 Bounded 实例的元组也被认为是 Bounded 的实例:

ghci> maxBound :: (Bool, Int, Char)
(True,2147483647,'\1114111')

数值类型类

Num 是一个数值类型类。它的实例可以像数字一样行动。让我们检查一个数字的类型:

ghci> :t 20
20 :: (Num t) => t

看起来整数也是多态常量。它们可以像 Num 类型类的任何实例(IntIntegerFloatDouble)一样行动:

ghci> 20 :: Int
20
ghci> 20 :: Integer
20
ghci> 20 :: Float
20.0
ghci> 20 :: Double
20.0

例如,我们可以检查 * 操作符的类型:

ghci> :t (*)
(*) :: (Num a) => a -> a -> a

这表明 * 接受两个数字并返回相同类型的数字。由于这种类型约束,(5 :: Int) * (6 :: Integer) 将导致类型错误,而 5 * (6 :: Integer) 将正常工作。5 可以像 IntegerInt 一样行动,但不能同时两者都是。

要成为 Num 的实例,一个类型必须已经存在于 ShowEq 中。

浮点类型类

Floating 类型类包括 FloatDouble 类型,它们用于存储浮点数。

需要使用浮点数来表示结果的 Floating 类型类的值实例的函数需要进行有意义的计算。一些例子是 sincossqrt

整数类型类

Integral 是另一个数值类型类。虽然 Num 包括所有数字,包括实数整数,但 Integral 类只包括整数(整体数)。这个类型类包括 IntInteger 类型。

处理数字的一个特别有用的函数是 fromIntegral。它有以下类型声明:

fromIntegral :: (Num b, Integral a) => a -> b

注意

注意到 fromIntegral 在其类型签名中有几个类约束。这是完全有效的——多个类约束在括号内由逗号分隔。

从其类型签名中,我们可以看到 fromIntegral 接收一个整数并将其转换为更通用的数字。当你想使整数和浮点数类型很好地协同工作时,这非常有用。例如,length 函数有如下类型声明:

length :: [a] -> Int

这意味着如果我们尝试获取列表的长度并将其加到 3.2 上,我们会得到一个错误(因为我们尝试将一个 Int 加到一个浮点数上)。为了解决这个问题,我们可以使用 fromIntegral,如下所示:

ghci> fromIntegral (length [1,2,3,4]) + 3.2
7.2

关于类型类的几点注意事项

因为类型类定义了一个抽象接口,一个类型可以成为多个类型类的实例,一个类型类也可以有多个类型作为其实例。例如,Char 类型是多个类型类的实例,其中两个是 EqOrd,因为我们可以检查两个字符是否相等,也可以按字母顺序比较它们。

有时,一个类型必须首先成为某个类型类的实例,才能允许它成为另一个类型类的实例。例如,要成为 Ord 的实例,一个类型必须首先成为 Eq 的实例。换句话说,成为 Eq 的实例是成为 Ord 实例的先决条件。如果你这样想,这是有道理的,因为如果你可以比较两个事物进行排序,你也应该能够判断这些事物是否相等。

第三章。函数中的语法

在本章中,我们将探讨使您能够以可读和合理的方式编写 Haskell 函数的语法。我们将了解如何快速分解值、避免大的 if else 链,并将中间计算的结果存储起来,以便您可以多次重用。

模式匹配

模式匹配 用于指定某些数据应遵守的模式,并根据这些模式分解数据。

无标题图片

在定义 Haskell 中的函数时,您可以为不同的模式创建单独的函数体。这导致代码简单、易读。您几乎可以在任何数据类型上执行模式匹配——数字、字符、列表、元组等等。例如,让我们编写一个简单的函数,检查我们传递给它的数字是否是 7:

lucky :: Int -> String
lucky 7 = "LUCKY NUMBER SEVEN!"
lucky x = "Sorry, you're out of luck, pal!"

当您调用 lucky 时,模式将从上到下进行检查。当传递的参数符合指定的模式时,将使用相应的函数体。这里数字符合第一个模式的情况只有它是 7。在这种情况下,将使用函数体 "LUCKY NUMBER SEVEN!"。如果不是 7,它将跌入第二个模式,该模式匹配任何内容并将其绑定到 x

当我们在模式中使用以小写字母开头的名称(如 xymyNumber)而不是实际值(如 7)时,它将充当一个通配符模式。该模式将始终匹配提供的值,并且我们可以通过我们为模式使用的名称来引用该值。

样本函数也可以通过使用 if 表达式轻松实现。然而,如果我们想编写一个函数,该函数接受一个数字,如果它在 1 到 5 之间,则将其打印为单词;否则,打印 "Not between 1 and 5",该怎么办?没有模式匹配,我们需要构建一个相当复杂的 if/then/else 树。但是,模式匹配使这个函数变得简单易写:

sayMe :: Int -> String
sayMe 1 = "One!"
sayMe 2 = "Two!"
sayMe 3 = "Three!"
sayMe 4 = "Four!"
sayMe 5 = "Five!"
sayMe x = "Not between 1 and 5"

注意,如果我们将最后一个模式(sayMe x)移到顶部,函数将始终打印 "Not between 1 and 5",因为数字没有机会跌入并检查任何其他模式。

记得我们在上一章中实现的阶乘函数吗?我们将一个数字 n 的阶乘定义为 product [1..n]。我们也可以递归地定义阶乘函数。如果一个函数在其定义内部调用自身,则该函数是递归定义的。阶乘函数通常以这种方式在数学中定义。我们首先声明 0 的阶乘是 1。然后我们声明任何正整数的阶乘是该整数乘以其前驱的阶乘。以下是将其转换为 Haskell 术语的示例:

factorial :: Int -> Int
factorial 0 = 1
factorial n = n * factorial (n - 1)

这是我们第一次递归定义函数。递归在 Haskell 中很重要,我们将在第四章(第四章)中更详细地探讨它。

模式匹配也可能失败。例如,我们可以定义一个函数如下:

charName :: Char -> String
charName 'a' = "Albert"
charName 'b' = "Broseph"
charName 'c' = "Cecil"

这个函数看起来一开始似乎工作得很好。然而,如果我们尝试用它没有预料到的输入调用它,我们会得到一个错误:

ghci> charName 'a'
"Albert"
ghci> charName 'b'
"Broseph"
ghci> charName 'h'
"*** Exception: tut.hs:(53,0)-(55,21): Non-exhaustive patterns in function charName

它会抱怨我们有“非穷尽模式”,这是合理的。在创建模式时,我们应该始终在末尾包含一个通配符模式,这样我们的程序在接收到一些意外的输入时就不会崩溃。

元组模式匹配

模式匹配也可以用于对偶。如果我们想编写一个函数,该函数接受二维空间中的两个向量(表示为对偶)并将它们相加怎么办?(要加两个向量,我们分别将它们的 x 分量和 y 分量相加。)如果我们不知道模式匹配,我们可能会这样做:

addVectors :: (Double, Double) -> (Double, Double) -> (Double, Double)
addVectors a b = (fst a + fst b, snd a + snd b)

好吧,这确实可行,但还有更好的方法来做这件事。让我们修改这个函数,使其使用模式匹配:

addVectors :: (Double, Double) -> (Double, Double) -> (Double, Double)
addVectors (x1, y1) (x2, y2) = (x1 + x2, y1 + y2)

这更好。它清楚地表明参数是元组,并且通过立即给元组组件命名来提高可读性。注意,这已经是一个通配符模式。addVectors的类型在这两种情况下都是相同的,所以我们保证会得到两个对偶作为参数:

ghci> :t addVectors
addVectors :: (Double, Double) -> (Double, Double) -> (Double, Double)

fstsnd提取对偶的组件。但是关于三元组呢?嗯,没有提供提取三元组第三个组件的函数,但我们可以自己创建一个:

first :: (a, b, c) -> a
first (x, _, _) = x

second :: (a, b, c) -> b
second (_, y, _) = y

third :: (a, b, c) -> c
third (_, _, z) = z

_字符与列表推导中的含义相同。我们真的不关心那部分,所以我们只使用一个_来表示一个通用变量。

列表和列表推导中的模式匹配

你也可以在列表推导中使用模式匹配,如下所示:

ghci> let xs = [(1,3),(4,3),(2,4),(5,3),(5,6),(3,1)]
ghci> [a+b | (a, b) <- xs]
[4,7,6,8,11,4]

如果模式匹配失败,列表推导将简单地移动到下一个元素,并且失败的元素将不会包含在结果列表中。

正规列表也可以用于模式匹配。你可以匹配空列表[]或任何涉及:和空列表的模式。(记住,[1,2,3]只是1:2:3:[]的语法糖。)像x:xs这样的模式会将列表的头部绑定到x,并将其余部分绑定到xs。如果列表只有一个元素,那么xs将简单地是空列表。

注意

Haskell 程序员经常使用x:xs模式,尤其是在递归函数中。然而,包含:字符的模式只会匹配长度为一或更长的列表。

现在我们已经了解了如何对列表进行模式匹配,让我们自己实现head函数:

head' :: [a] -> a
head' [] = error "Can't call head on an empty list, dummy!"
head' (x:_) = x

加载函数后,我们可以这样测试它:

ghci> head' [4,5,6]
4
ghci> head' "Hello"
'H'

注意,如果我们想将某个东西绑定到多个变量(即使其中一个变量是_),我们必须将它们放在括号中,这样 Haskell 才能正确解析它们。

还要注意 error 函数的使用。此函数接受一个字符串作为参数,并使用该字符串生成运行时错误。它本质上会崩溃你的程序,所以最好不要过多使用它。(但调用空列表上的 head 真的没有意义!)

作为另一个示例,让我们编写一个简单的函数,该函数接受一个列表并以冗长、不便的方式打印其元素:

tell :: (Show a) => [a] -> String
tell [] = "The list is empty"
tell (x:[]) = "The list has one element: " ++ show x
tell (x:y:[]) = "The list has two elements: " ++ show x ++ " and " ++ show y
tell (x:y:_) = "This list is long. The first two elements are: " ++ show x
               ++ " and " ++ show y

注意,(x:[])(x:y:[]) 可以重写为 [x][x,y]。然而,我们无法使用方括号重写 (x:y:_),因为它匹配长度为 2 或更长的任何列表。

这里是使用此函数的一些示例:

ghci> tell [1]
"The list has one element: 1"
ghci> tell [True,False]
"The list has two elements: True and False"
ghci> tell [1,2,3,4]
"This list is long. The first two elements are: 1 and 2"
ghci> tell []
"The list is empty"

tell 函数是安全的,因为它可以匹配空列表、单元素列表、双元素列表以及超过两个元素的列表。它知道如何处理任何长度的列表,因此它总是会返回一个有用的值。

那么如果我们定义一个只能处理包含三个元素的列表的函数会怎样?以下是一个此类函数的示例:

badAdd :: (Num a) => [a] -> a
badAdd (x:y:z:[]) = x + y + z

当我们给它一个它不期望的列表时,会发生以下情况:

ghci> badAdd [100,20]
*** Exception: examples.hs:8:0-25: Non-exhaustive patterns in function badAdd

哎呀!这不好!如果这种情况发生在编译程序中而不是在 GHCi 中,程序将会崩溃。

关于列表模式匹配的最后一件事要注意的是:你无法在模式匹配中使用 ++ 操作符。(记住,++ 操作符将两个列表连接成一个。)例如,如果你尝试对 (xs ++ ys) 进行模式匹配,Haskell 将无法确定 xs 列表和 ys 列表中会有什么。虽然将内容与 (xs ++ [x,y,z]) 或甚至 (xs ++ [x]) 进行匹配看起来合乎逻辑,但由于列表的性质,你无法这样做。

As-patterns

此外,还有一种特殊的模式类型称为 as-pattern。As-pattern 允许你根据模式拆分一个项目,同时仍然保留对整个原始项目的引用。要创建一个 as-pattern,请在常规模式之前加上一个名称和一个 @ 字符。

例如,我们可以创建以下 as-pattern:xs@(x:y:ys)。这个模式将匹配与 x:y:ys 完全相同的列表,但你可以轻松地使用 xs 访问整个原始列表,而不必每次都输入 x:y:ys。以下是一个使用 as-pattern 的简单函数示例:

firstLetter :: String -> String
firstLetter "" = "Empty string, whoops!"
firstLetter all@(x:xs) = "The first letter of " ++ all ++ " is " ++ [x]

在加载函数后,我们可以按以下方式测试它:

ghci> firstLetter "Dracula"
"The first letter of Dracula is D"

Guards,Guards!

我们使用模式来检查传递给我们的函数的值是否以某种方式构建。当我们想要我们的函数检查传递值的某些属性是否为真或假时,我们使用 guards。这听起来很像一个 if 表达式,而且它确实非常相似。然而,当有多个条件时,guards 的可读性会更好,并且它们与模式配合得很好。

让我们深入进去,写一个使用守卫的函数。这个函数会根据你的身体质量指数(BMI)以不同的方式责备你。你的 BMI 是通过将你的体重(以千克为单位)除以你的身高(以米为单位)的平方来计算的。如果你的 BMI 小于 18.5,你被认为是体重过轻。如果它在 18.5 到 25 之间,你被认为是正常。BMI 为 25 到 30 是超重,超过 30 是肥胖。(请注意,这个函数实际上不会计算你的 BMI;它只是将 BMI 作为参数,然后告诉你。)以下是该函数:

无标题的图片

bmiTell :: => Double -> String
bmiTell bmi
    | bmi <= 18.5 = "You're underweight, you emo, you!"
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise   = "You're a whale, congratulations!"

守卫由一个管道字符 (|) 表示,后面跟着一个布尔表达式,然后是如果该表达式评估为 True 将要使用的函数体。如果表达式评估为 False,函数将跳转到下一个守卫,然后重复这个过程。守卫至少需要缩进一个空格。(我喜欢用四个空格缩进,这样代码更易读。)

例如,如果我们用 24.3 的 BMI 值调用这个函数,它将首先检查这个值是否小于或等于 18.5。因为不是,它将跳转到下一个守卫。检查是通过第二个守卫进行的,因为 24.3 小于 25.0,所以返回第二个字符串。

守卫在命令式语言中非常类似于一个大的 if/else 树,尽管它们读起来更清晰。虽然大的 if/else 树通常是不受欢迎的,但有时一个问题被定义得如此离散,以至于你无法绕过它们。在这些情况下,守卫是一个非常好的替代方案。

在函数中,最后一个守卫通常是 otherwise,它会捕获所有内容。如果一个函数中的所有守卫都评估为 False,并且我们没有提供一个 otherwise 捕获所有内容的守卫,评估将跳转到下一个模式。(这就是模式和守卫如何很好地一起工作。)如果没有找到合适的守卫或模式,将抛出错误。

当然,我们也可以使用守卫与接受多个参数的函数。让我们修改 bmiTell,使其接受一个身高和一个体重,并为我们计算 BMI:

bmiTell :: Double -> Double -> String
bmiTell weight height
    | weight / height ^ 2 <= 18.5 = "You're underweight, you emo, you!"
    | weight / height ^ 2 <= 25.0 = "You're supposedly
 normal. Pffft, I bet you're ugly!"
    | weight / height ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise                   = "You're a whale, congratulations!"

现在,让我们看看我是否胖:

ghci> bmiTell 85 1.90
"You're supposedly normal. Pffft, I bet you're ugly!"

哈哈!我不胖!但 Haskell 刚才说我丑。随便吧!

注意

一个常见的初学者错误是在函数名和参数后面放一个等号 (=),在第一个守卫之前。这将导致语法错误。

作为另一个简单的例子,让我们实现自己的 max 函数来比较两个项目并返回较大的一个:

max' :: (Ord a) => a -> a -> a max' a b
    | a <= b    = b
    | otherwise = a

我们也可以使用守卫来实现自己的 compare 函数:

myCompare :: (Ord a) => a -> a -> Ordering
a `myCompare` b
    | a == b    = EQ
    | a <= b     = LT
    | otherwise = GT
ghci> 3 `myCompare` 2
GT

注意

不仅我们可以用反引号作为中缀调用函数,我们还可以用反引号来定义它们。有时这使它们更容易阅读。

哪里?!

当编程时,我们通常想要避免反复计算相同的值。只计算一次并将结果存储起来要容易得多。在命令式编程语言中,你会通过将计算结果存储在变量中来解决这个问题。在本节中,你将学习如何使用 Haskell 的where关键字来存储中间计算的结果,这提供了类似的功能。

在前面的部分,我们定义了一个 BMI 计算函数如下所示:

bmiTell :: Double -> Double -> String
bmiTell weight height
    | weight / height ^ 2 <= 18.5 = "You're underweight, you emo, you!"
    | weight / height ^ 2 <= 25.0 = "You're
 supposedly normal. Pffft, I bet you're ugly!"
    | weight / height ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise                   = "You're a whale, congratulations!"

注意,在这个代码中我们重复进行了三次 BMI 计算。我们可以通过使用where关键字将这个值绑定到一个变量上,然后使用这个变量代替 BMI 计算来避免这种情况,如下所示:

bmiTell :: Double -> Double -> String
bmiTell weight height
    | bmi <= 18.5 = "You're underweight, you emo, you!"
    | bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
    | otherwise   = "You're a whale, congratulations!"
    where bmi = weight / height ^ 2

我们在守卫之后放置where关键字,然后使用它来定义一个或多个变量或函数。这些名称在所有守卫中都是可见的。如果我们决定想要以不同的方式计算 BMI,我们只需要更改一次。这种技术通过给事物命名来提高可读性,甚至可以使我们的程序运行得更快,因为我们的值只计算一次。

如果我们愿意,甚至可以做得更多,将函数写成这样:

bmiTell :: Double -> Double -> String
bmiTell weight height
    | bmi <= skinny = "You're underweight, you emo, you!"
    | bmi <= normal = "You're supposedly normal. Pffft, I bet you're ugly!"
    | bmi <= fat    = "You're fat! Lose some weight, fatty!"
    | otherwise     = "You're a whale, congratulations!"
    where bmi = weight / height ^ 2
          skinny = 18.5
          normal = 25.0
          fat = 30.0

备注

注意,所有变量名都排列在同一列中。如果你不这样对齐它们,Haskell 会感到困惑,它不知道它们都是同一个块的一部分。

哪儿是 Scope

在函数的where部分定义的变量只对该函数可见,所以我们不需要担心它们会污染其他函数的命名空间。如果我们想在几个不同的函数中使用这样的变量,我们必须在全局范围内定义它。

此外,where绑定不会在不同的模式函数体之间共享。例如,假设我们想要编写一个函数,它接受一个名字,如果它识别出这个名字,就会礼貌地问候这个人,如果不认识,就不会那么礼貌。我们可能定义它如下:

greet :: String -> String greet "Juan" = niceGreeting ++ " Juan!"
greet "Fernando" = niceGreeting ++ " Fernando!"
greet name = badGreeting ++ " " ++ name
    where niceGreeting = "Hello! So very nice to see you,"
          badGreeting = "Oh! Pfft. It's you."

这个函数按原样是无法工作的。因为where绑定不会在不同的模式函数体之间共享,只有最后一个函数体可以看到由where绑定定义的问候语。为了使这个函数正确工作,badGreetingniceGreeting必须像这样在全局范围内定义:

badGreeting :: String
badGreeting = "Oh! Pfft. It's you."

niceGreeting :: String
niceGreeting = "Hello! So very nice to see you,"

greet :: String -> String
greet "Juan" = niceGreeting ++ " Juan!"
greet "Fernando" = niceGreeting ++ " Fernando!"
greet name = badGreeting ++ " " ++ name

使用 where 进行模式匹配

你也可以使用where绑定来进行模式匹配。我们本来可以像这样编写 BMI 函数的where部分:

...
    where bmi = weight / height ^ 2
          (skinny, normal, fat) = (18.5, 25.0, 30.0)

作为这种技术的例子,让我们编写一个函数,它获取一个名字和姓氏,并返回首字母缩写:

initials :: String -> String -> String
initials firstname lastname = [f] ++ ". " ++ [l] ++ "."
    where (f:_) = firstname
          (l:_) = lastname

我们也可以直接在函数的参数中进行这种模式匹配(这将更短、更易读),但这个例子表明,也可以在where绑定中这样做。

在 where 块中的函数

正如我们在where块中定义了常量一样,我们也可以定义函数。保持我们健康编程的主题,让我们定义一个函数,它接受一个重量/身高对的列表,并返回一个 BMI 列表:

calcBmis :: [(Double, Double)] -> [Double]
calcBmis xs = [bmi w h | (w, h) <- xs]
    where bmi weight height = weight / height ^ 2

就这些了!我们需要在这个例子中将bmi作为函数引入的原因是我们不能仅仅从函数的参数中计算出一个 BMI。我们需要检查传递给函数的列表,并且列表中的每一对都有一个不同的 BMI。

让它成为

let表达式与where绑定非常相似。where允许你在函数的末尾绑定变量,并且这些变量对整个函数都是可见的,包括所有的守卫。另一方面,let表达式允许你在任何地方绑定变量,并且它们本身就是表达式。然而,它们非常局部,并且不会跨越守卫。就像任何用于将值绑定到名称的 Haskell 构造一样,let表达式可以用于模式匹配。

无标题图片

现在让我们看看let的实际应用。以下函数根据圆柱的高度和半径返回其表面积:

cylinder :: Double -> Double -> Double
cylinder r h =
    let sideArea = 2 * pi * r * h
        topArea = pi * r ^ 2
    in  sideArea + 2 * topArea

let表达式的形式为let <bindings> in <expression>。你用let定义的变量在整个let表达式中都是可见的。

是的,我们也可以用where绑定来定义这个。那么这两个有什么区别呢?起初,看起来唯一的区别是let将绑定放在前面,表达式放在后面,而where则相反。

实际上,这两个之间的主要区别是let表达式是……嗯……表达式,而where绑定则不是。如果某物是一个表达式,那么它就有值。"boo!"是一个表达式,3 + 5head [1,2,3]也是。这意味着你几乎可以在代码的任何地方使用let表达式,如下所示:

ghci> 4 * (let a = 9 in a + 1) + 2
42

这里还有一些其他使用let表达式的有用方法:

  • 它们可以用来在局部作用域中引入函数:

    ghci> [let square x = x * x in (square 5, square 3, square 2)]
    [(25,9,4)]
    
  • 它们可以用分号分隔,这在你想内联绑定多个变量但无法对齐列时很有帮助:

    ghci> (let a = 100; b = 200; c = 300 in a*b*c,
     let foo="Hey "; bar = "there!" in foo ++ bar)
    (6000000,"Hey there!")
    
  • 使用let表达式进行模式匹配可以非常有助于快速将元组分解成组件并将这些组件绑定到名称,如下所示:

    ghci> (let (a, b, c) = (1, 2, 3) in a+b+c) * 100
    600
    

    在这里,我们使用一个带有模式匹配的let表达式来解构三元组(1,2,3)。我们将其第一个组件称为a,第二个组件称为b,第三个组件称为cin a+b+c部分表示整个let表达式的值将是a+b+c。最后,我们将这个值乘以100

  • 你可以在列表推导式中使用let表达式。我们将在下一节中更详细地探讨这一点。

如果let表达式如此酷,为什么不用它们呢?嗯,因为let表达式是表达式,并且它们的范围相当局部,所以不能在守卫中使用。此外,有些人更喜欢where绑定,因为它们的变量是在它们所使用的函数之后定义的,而不是之前。这使得函数体更接近其名称和类型声明,这可以使代码更易读。

let在列表推导式中

让我们重写我们之前计算体重/身高对的列表的例子,但我们将使用列表推导式中的let表达式,而不是使用带有where的辅助函数:

calcBmis :: [(Double, Double)] -> [Double]
calcBmis xs = [bmi | (w, h) <- xs, let bmi = w / h ^ 2]

每次列表推导式从原始列表中取一个元组并将其组件绑定到wh时,let表达式会将w / h ^ 2绑定到名称bmi。然后我们只需将bmi作为列表推导式的输出即可。

我们在列表推导式中包含一个let,就像我们使用谓词一样,但不是过滤列表,而是将值绑定到名称上。在这个let中定义的名称对输出(|之前的部分)以及let之后的列表推导式中的所有内容都是可见的。因此,使用这种技术,我们可以使我们的函数只返回肥胖人的 BMI,如下所示:

calcBmis :: [(Double, Double)] -> [Double]
calcBmis xs = [bmi | (w, h) <- xs, let bmi = w / h ^ 2, bmi > 25.0]

列表推导式的(w, h) <- xs部分称为生成器。我们无法在生成器中引用bmi变量,因为它是let绑定之前定义的。

let在 GHCi 中

绑定中的in部分在直接在 GHCi 中定义函数和常量时也可以省略。如果我们这样做,那么这些名称将在整个交互会话中可见:

ghci> let zoot x y z = x * y + z
ghci> zoot 3 9 2
29
ghci> let boot x y z = x * y + z in boot 3 4 2
14
ghci> boot
<interactive>:1:0: Not in scope: `boot'

因为我们省略了第一行的in部分,GHCi 知道我们在这行代码中没有使用zoot,所以它为整个会话记住了它。然而,在第二个let表达式中,我们包含了in部分,并立即用一些参数调用boot。不省略in部分的let表达式本身就是一个表达式,它代表一个值,所以 GHCi 只是打印了那个值。

case表达式

case 表达式允许你为特定变量的特定值执行代码块。本质上,它们是在你的代码的几乎任何地方使用模式匹配的一种方式。许多语言(如 C、C++和 Java)都有某种形式的case语句,所以你可能已经熟悉这个概念。

无标题图片

Haskell 将这个概念提升了一个层次。正如其名所示,case表达式是表达式,就像if else表达式和let表达式一样。我们不仅可以根据变量的可能值来评估表达式,还可以进行模式匹配。

这与在函数定义中对参数执行模式匹配非常相似,其中你取一个值,对它进行模式匹配,并根据该值评估代码片段。实际上,这种模式匹配只是case表达式的语法糖。例如,以下两段代码执行相同的功能,可以互换:

head' :: [a] -> a
head' [] = error "No head for empty lists!"
head' (x:_) = x
head' :: [a] -> a
head' xs = case xs of [] -> error "No head for empty lists!"
                      (x:_) -> x

下面是case表达式的语法:

case *`expression`* of *`pattern`* -> *`result`*
                   *`pattern`* -> *`result`*
                   *`pattern`* -> *`result`*
                   ...

这很简单。使用第一个与表达式匹配的模式。如果它通过了整个case表达式并且没有找到合适的模式,则会发生运行时错误。

函数参数上的模式匹配只能在定义函数时进行,但case表达式可以在任何地方使用。例如,你可以使用它们在表达式中进行模式匹配,如下所示:

describeList :: [a] -> String
describeList ls = "The list is " ++ case ls of [] -> "empty."
                                               [x] -> "a singleton list."
                                               xs -> "a longer list."

在这里,case表达式的工作方式是这样的:首先将ls与空列表的模式进行比较。如果ls为空,则整个case表达式假定值为"empty"。如果ls不是一个空列表,则将其与只有一个元素的列表的模式进行比较。如果模式匹配成功,则case表达式的值为"a singleton list"。如果这两个模式都不匹配,则应用通配符模式xs。最后,将case表达式的结果与字符串"The list is"连接起来。每个case表达式代表一个值。这就是为什么我们能够在字符串"The list is"和我们的case表达式之间使用++

因为函数定义中的模式匹配与使用case表达式相同,所以我们也可以像这样定义describeList函数:

describeList :: [a] -> String
describeList ls = "The list is " ++ what ls
    where what [] = "empty."
          what [x] = "a singleton list."
          what xs = "a longer list."

这个函数的行为与上一个例子中的函数相同,尽管我们使用了不同的语法结构来定义它。函数whatls调用,然后发生常规的模式匹配操作。一旦这个函数返回一个字符串,它就会被与字符串"The list is"连接起来。

第四章。你好,递归!

在本章中,我们将探讨递归。我们将了解为什么递归在 Haskell 编程中很重要,以及我们如何通过递归思考来找到非常简洁和优雅的解决方案。

递归是一种定义函数的方式,其中函数在其定义内部应用自己。换句话说,函数调用自己。如果你仍然不知道递归是什么,请阅读这句话。(哈哈!只是开玩笑!)

无标题图片

开个玩笑,递归定义的函数的策略是将当前问题分解为相同类型的小问题,然后尝试解决这些子问题,如果需要,进一步分解它们。最终,我们达到问题的 基本情况(或基本情况),不能再分解,并且程序员需要明确(非递归地)定义其解决方案。

数学中的定义通常是递归的。例如,我们可以递归地指定 斐波那契序列 如下:我们通过直接说 F(0) = 0 和 F(1) = 1 来定义前两个斐波那契数,这意味着零和第一斐波那契数分别是 0 和 1。这些都是我们的基本情况。

然后,我们指定对于除了 0 或 1 之外的任何自然数,相应的斐波那契数是前两个斐波那契数的和。换句话说,F(n) = F(n-1) + F(n-2)。例如,F(3) 是 F(2) + F(1),这又分解为 (F(1) + F(0)) + F(1)。因为我们现在只剩下非递归定义的斐波那契数,我们可以安全地说 F(3) 的值是 2。

递归在 Haskell 中很重要,因为与命令式语言不同,你在 Haskell 中通过声明“是什么”而不是指定“如何”来执行计算。这就是为什么 Haskell 不是关于向计算机发出一系列要执行的步骤,而是直接定义所需的结果,通常以递归的方式。

最大酷

让我们看看一个现有的 Haskell 函数,看看如果我们把大脑切换到“R”档(代表“递归”),我们如何自己编写这个函数。

maximum 函数接受一个可以排序的事物列表(即 Ord 类型类的实例)并返回其中的最大值。它可以非常优雅地使用递归表达。

在我们讨论递归解决方案之前,想想你可能如何命令式地实现 maximum 函数。你可能设置一个变量来保存当前的最大值,然后你会遍历列表中的每个元素。如果当前元素比当前最大值大,你会用那个元素替换最大值。循环结束时剩下的最大值将是最终结果。

现在,让我们看看我们如何递归地定义它。首先,我们需要定义一个基本情况:我们说单元素列表的最大值等于它唯一的元素。但如果列表有多个元素呢?嗯,那么我们就检查哪个更大:第一个元素(头部)还是列表剩余部分的最大值(尾部)。下面是我们递归 maximum' 函数的代码:

maximum' :: (Ord a) => [a] -> a
maximum' [] = error "maximum of empty list!"
maximum' [x] = x
maximum' (x:xs) = max x (maximum' xs)

正如你所看到的,模式匹配对于定义递归函数非常有用。能够匹配和分解值使得将查找最大值的问题分解为相关情况和递归子问题变得容易。

第一个模式表示如果列表为空,程序应该崩溃。这很有道理,因为我们根本无法说空列表的最大值是什么。第二个模式表示如果 maximum' 接收到一个单元素列表,它应该只返回该列表的唯一元素。

我们的第三个模式代表了递归的核心。列表被分为头部和尾部。我们将头部称为 x,尾部称为 xs。然后,我们利用我们老朋友,max 函数。max 函数接受两个参数,并返回其中较大的一个。如果 x 大于 xs 中最大的元素,我们的函数将返回 x,否则它将返回 xs 中的最大元素。但是我们的 maximum' 函数是如何在 xs 中找到最大元素的?简单——通过递归调用自己!

无标题图片

让我们通过一个具体的例子来分析这段代码,以防你难以想象 maximum' 的工作方式。如果我们对 [2,5,1] 调用 maximum',前两个模式不匹配函数调用。然而,第三个模式匹配,所以列表值被拆分为 2[5,1],然后对 [5,1] 调用 maximum'

对于这次对 maximum' 的新调用,[5,1] 匹配第三个模式,并且输入列表再次被拆分——这次是 5[1]——然后对 [1] 递归调用 maximum'。这是一个单元素列表,所以最新的调用现在匹配我们的一个基本情况,并返回 1 作为结果。

现在,我们上升一个层次,使用 max 函数比较 511 是我们上一次递归调用的结果。由于 5 更大,我们现在知道 [5,1] 的最大值是 5

最后,将 2[5,1] 的最大值比较,我们现在知道它是 5,我们得到了原始问题的答案。由于 5 大于 2,我们现在可以说 5[2,5,1] 的最大值。

一些更多的递归函数

现在我们已经看到了如何递归地思考,让我们以这种方式实现更多函数。像 maximum 一样,这些函数在 Haskell 中已经存在,但我们将编写自己的版本来锻炼我们递归肌肉群的递归肌肉中的递归肌肉纤维。让我们变得更强壮!

replicate

首先,我们将实现 replicate。记住,replicate 接受一个 Int 和一个值,并返回一个包含该值重复次数(即 Int 指定的次数)的列表。例如,replicate 3 5 返回一个包含三个五的列表:[5,5,5]

让我们考虑基本情况。如果我们被要求复制零次或更少的次数,我们立即知道应该返回什么。如果我们尝试复制零次,我们应该得到一个空列表。我们还声明负数的结果应该相同,因为复制少于零次的项没有意义。

通常,一个包含 n 次重复的 x 的列表是一个以 x 为首元素,尾部由 x 复制 n-1 次组成的列表。我们得到以下代码:

replicate' :: Int -> a -> [a]
replicate' n x
    | n <= 0    = []
    | otherwise = x : replicate' (n-1) x

我们在这里使用守卫而不是模式,因为我们正在测试布尔条件。

take

接下来,我们将实现 take。这个函数从指定的列表中返回指定数量的元素。例如,take 3 [5,4,3,2,1] 将返回 [5,4,3]。如果我们尝试从一个列表中取出零个或更少的元素,我们应该得到一个空列表,如果我们尝试从一个空列表中取出任何东西,我们也应该得到一个空列表。注意,这些都是我们的两个基本情况。现在让我们编写这个函数:

take' :: (Num i, Ord i) => i -> [a] -> [a]
take' n _
    | n <= 0   = []
take' _ []     = []
take' n (x:xs) = x : take' (n-1) xs

注意,在第一个模式中,指定如果我们尝试从一个列表中取出零个或更少的元素,我们会得到一个空列表,我们使用 _ 占位符来匹配列表值,因为我们在这种情况下并不关心它是什么。另外,注意我们使用了守卫,但没有 otherwise 部分。这意味着如果 n 转而大于 0,匹配将传递到下一个模式。

无标题图片

第二个模式表明,如果我们尝试从一个空列表中取出任何数量的东西,我们都会得到一个空列表。

第三个模式将列表分为头部和尾部。我们称头部为 x,尾部为 xs。然后我们声明从列表中取出 n 个元素与创建一个以 x 为首元素,从 xs 中取出 n-1 个元素作为其余元素的列表是相同的。

reverse

reverse 函数接受一个列表并返回一个包含相同元素但顺序相反的列表。同样,空列表是基本情况,因为尝试反转一个空列表只会得到一个空列表。那么函数的其余部分呢?好吧,如果我们把原始列表分成头部和尾部,我们想要的反转列表就是尾部的反转,头部被固定在末尾:

reverse' :: [a] -> [a]
reverse' [] = []
reverse' (x:xs) = reverse' xs ++ [x]

repeat

repeat 函数接受一个元素并返回一个由该元素组成的无限列表。repeat 的递归实现非常简单:

repeat' :: a -> [a]
repeat' x = x:repeat' x

调用repeat 3将给我们一个以3作为头部,尾部有无限多个3的列表。所以调用repeat 3的结果是3:repeat 3,这又等于3:(3:repeat 3),然后等于3:(3:(3:repeat 3)),以此类推。repeat 3永远不会完成评估。然而,take 5 (repeat 3)将给我们一个包含五个3的列表。本质上,这就像调用replicate 5 3

这是一个很好的例子,说明了我们可以如何成功地使用没有基本情况但可以创建无限列表的递归——我们只需确保在某个地方切断它们即可。

zip

zip是我们已经在第一章中遇到过的另一个用于处理列表的函数。它接受两个列表并将它们组合在一起。例如,调用zip [1,2,3] [7,8]返回[(1,7),(2,8)](该函数截断较长的列表以匹配较短的长度)。

使用空列表压缩某个东西只会返回一个空列表,这就是我们的基本情况。然而,zip函数接受两个列表作为参数,所以实际上有两个基本情况:

zip' :: [a] -> [b] -> [(a,b)]
zip' _ [] = []
zip' [] _ = []
zip' (x:xs) (y:ys) = (x,y):zip' xs ys

前两种模式是我们的基本情况:如果第一个或第二个列表为空,我们返回一个空列表。第三种模式说明将两个列表组合在一起相当于将它们的头部配对,然后将它们的压缩尾部附加到那个头部上。

例如,如果我们用[1,2,3]['a','b']调用zip',该函数将形成(1,'a')作为结果的第一元素,然后将[2,3][b]压缩在一起以获得其余的结果。在另一次递归调用之后,该函数将尝试将[3][]压缩,这符合基本情况模式之一。最终结果直接计算为(1,'a'):((2,'b'):[]),这正好是[(1,'a'),(2,'b')]

elem

让我们再实现一个标准库函数:elem。该函数接受一个值和一个列表,并检查该值是否是列表的成员。同样,空列表是一个基本情况——空列表不包含任何值,所以它当然不可能包含我们要找的值。一般来说,如果我们幸运的话,我们可能在我们所寻找的值在列表的头部;否则,我们必须检查它是否在尾部。以下是代码:

elem' :: (Eq a) => a -> [a] -> Bool
elem' a [] = False
elem' a (x:xs)
    | a == x    = True
    | otherwise = a `elem'` xs

快速排序!

对包含可以按顺序排列的元素(如数字)的列表进行排序的问题自然适合递归解决方案。有许多递归排序列表的方法,但我们将查看其中最酷的一种:快速排序。首先,我们将了解算法的工作原理,然后我们将用 Haskell 实现它。

无标题图片

算法

快速排序算法的工作原理是这样的。你有一个想要排序的列表,比如说[5,1,9,4,6,7,3]。你选择第一个元素,即5,并将所有小于或等于5的其他列表元素放在它的左侧。然后,你将大于5的元素放在它的右侧。如果你这样做,你会得到一个看起来像这样的列表:[1,4,3,5,9,6,7]。在这个例子中,5被称为枢轴,因为我们选择将其他元素与它比较并将它们移动到它的左右两侧。我们选择第一个元素作为枢轴的唯一原因是它将很容易通过模式匹配来捕获。但事实上,任何元素都可以作为枢轴。

现在,我们通过在它们上调用相同的函数递归地排序枢轴左右两侧的所有元素。最终结果是完全排序的列表!

无标题图片

上述图解说明了快速排序在我们例子中的工作原理。当我们想要排序[5,1,9,4,6,7,3]时,我们决定第一个元素是我们的枢轴。然后我们在[1,4,3][9,6,7]之间将其夹在中间。一旦我们这样做,我们就使用相同的方法对[1,4,3][9,6,7]进行排序。

要排序[1,4,3],我们选择第一个元素1作为枢轴,并创建一个包含小于或等于1的元素的列表。结果是一个空列表[],因为1[1,4,3]中最小的元素。大于1的元素移到它的右侧,所以是[4,3]。同样,[4,3]也是以相同的方式排序的。它最终也会被拆分成空列表并重新组合。

算法然后返回到1的右侧,其左侧是空列表。突然,我们有了[1,3,4],它是排序好的。这被保留在5的左侧。

一旦5右侧的元素以相同的方式排序,我们将得到一个完全排序的列表:[1,3,4,5,6,7,9]

代码

现在我们已经熟悉了快速排序算法,让我们深入了解其在 Haskell 中的实现:

quicksort :: (Ord a) => [a] -> [a]
quicksort [] = []
quicksort (x:xs) =
    let smallerOrEqual = [a | a <- xs, a <= x]
        larger = [a | a <- xs, a > x]
    in  quicksort smallerOrEqual ++ [x] ++ quicksort larger

我们函数的类型签名是quicksort :: (Ord a) => [a] -> [a],空列表是基础情况,正如我们刚才看到的。

记住,我们将所有小于或等于x(我们的枢轴)的元素放在它的左侧。要检索这些元素,我们使用列表推导式[a | a <- xs, a <= x]。这个列表推导式将从xs(所有不是我们的枢轴的元素)中抽取,并仅保留满足条件a <= x的元素,这意味着小于或等于x的元素。然后我们以类似的方式获取大于x的元素列表。

我们使用let绑定来给两个列表起方便的名字:smallerOrEquallarger。最后,我们使用列表连接运算符(++)和我们的quicksort函数的递归应用来表达我们想要最终的列表由一个排序后的smallerOrEqual列表、我们的枢轴以及一个排序后的larger列表组成。

让我们测试一下我们的函数,看看它是否表现正常:

ghci> quicksort [10,2,5,3,1,6,7,4,2,3,4,8,9]
[1,2,2,3,3,4,4,5,6,7,8,9,10]
ghci> quicksort "the quick brown fox jumps over the lazy dog"
"        abcdeeefghhijklmnoooopqrrsttuuvwxyz"

那就是我所说的!

递归思考

在本章中,我们使用了大量的递归,而且正如你可能注意到的,它有一个模式。你首先定义一个基本情况:当输入是平凡的时,它保持简单、非递归的解决方案。例如,排序空列表的结果是空列表,因为——好吧,那还能是什么?

然后,你将你的问题分解为一个或多个子问题,并通过对它们应用相同的函数递归地解决这些子问题。然后,你从这些已解决的子问题中构建你的最终解决方案。例如,在排序时,我们将我们的列表分解为两个列表,加上一个枢轴。我们通过将相同的函数应用于它们来分别对每个列表进行排序。当我们得到结果时,我们将它们合并成一个大的已排序列表。

无标题的图片

处理递归的最佳方式是识别基本情况,并思考你如何将当前的问题分解成类似但更小的问题。如果你正确地选择了基本情况和子问题,你甚至不需要考虑所有事情发生的细节。你只需相信子问题的解决方案是正确的,然后你可以从这些较小的解决方案中构建你的最终解决方案。

第五章。高阶函数

Haskell 函数可以接受函数作为参数,并返回函数作为返回值。执行这些操作的函数被称为高阶函数。高阶函数是解决问题和思考程序的一种非常强大的方式,当使用像 Haskell 这样的函数式编程语言时,它们是必不可少的。

柯里化函数

Haskell 中的每个函数官方上只接受一个参数。但到目前为止,我们已经定义并使用了几个接受超过一个参数的函数——这是怎么做到的?

无标题的图片

嗯,这是一个巧妙的技巧!我们迄今为止使用的所有接受多个参数的函数都是柯里化函数。柯里化函数是一种函数,它不是接受多个参数,而是始终只接受一个参数。然后当它用那个参数被调用时,它返回一个接受下一个参数的函数,依此类推。

这最好用一个例子来说明。让我们以我们的好朋友max函数为例。它看起来像接受两个参数并返回较大的那个。例如,考虑表达式max 4 5。我们用两个参数调用max函数:45。首先,max应用于值4。当我们把max应用于4时,返回的实际上是一个函数,然后这个函数被应用于值5。将这个函数应用于5最终返回一个数值。因此,以下两个调用是等价的:

ghci> max 4 5
5
ghci> (max 4) 5
5

要理解它是如何工作的,让我们检查一下max函数的类型:

ghci> :t max
max :: (Ord a) => a -> a -> a

这也可以写成以下形式:

max :: (Ord a) => a -> (a -> a)

每当我们有一个带有箭头->的类型签名时,这意味着它是一个函数,它接受箭头左侧的任何内容,并返回一个类型在箭头右侧指示的值。当我们有类似a -> (a -> a)的东西时,我们正在处理一个接受类型为a的值的函数,它返回一个也接受类型为a的值并返回类型为a的值的函数。

无标题的图片

那么,这对我们有什么好处呢?简单来说,如果我们用一个参数太少的方式来调用一个函数,我们会得到一个部分应用的函数,这是一个接受我们遗漏的参数数量的函数。例如,当我们执行max 4时,我们得到一个接受一个参数的函数。使用部分应用(如果你愿意的话,就是用参数太少的函数调用)是一种创建函数的好方法,因此我们可以将它们传递给其他函数。

看看这个简单的小函数:

multThree :: Int -> Int -> Int -> Int
multThree x y z = x * y * z

当我们调用 multThree 3 5 9((multThree 3) 5) 9 时,实际上发生了什么?首先,multThree 被应用到 3 上,因为它们之间有一个空格。这创建了一个接受一个参数并返回一个函数的函数。然后,这个函数被应用到 5 上,创建了一个将接受一个参数,将 35 相乘,然后将结果乘以该参数的函数。这个函数被应用到 9 上,结果是 135

你可以将函数想象成小型的工厂,它们接受一些材料并生产出东西。使用这个类比,我们向我们的 multThree 工厂提供数字 3,但它不是生产一个数字,而是产生一个稍微小一点的工厂。这个工厂接收数字 5 并也吐出一个工厂。第三个工厂接收数字 9,然后产生我们的结果数字,135

记住,这个函数的类型也可以写成以下形式:

multThree :: Int -> (Int -> (Int -> Int))

-> 前面的类型(或类型变量)是函数接受的值的类型,在它后面的类型是它返回的值的类型。所以我们的函数接受一个类型为 Int 的值并返回一个类型为 (Int -> (Int -> Int) 的函数。同样,这个 函数接受一个类型为 Int 的值并返回一个类型为 Int -> Int 的函数。最后,这个 函数只接受一个类型为 Int 的值并返回另一个类型为 Int 的值。

让我们看看一个例子,说明我们如何通过调用参数不足的函数来创建一个新的函数:

ghci> let multTwoWithNine = multThree 9
ghci> multTwoWithNine 2 3
54

在这个例子中,表达式 multThree 9 得到一个接受两个参数的函数。我们称这个函数为 multTwoWithNine,因为 multThree 9 是一个接受两个参数的函数。如果两个参数都提供了,它将在它们之间乘以两个参数,然后乘以 9,因为我们通过将 multThree 应用到 9 得到 multTwoWithNine 函数。

如果我们想要创建一个接受 Int 并将其与 100 进行比较的函数,我们可以这样做:

compareWithHundred :: Int -> Ordering
compareWithHundred x = compare 100 x

例如,让我们尝试用 99 调用这个函数:

ghci> compareWithHundred 99
GT

100 大于 99,所以函数返回 GT,即大于。

现在,让我们思考一下 compare 100 会返回什么:一个接受一个数字并将其与 100 进行比较的函数,这正是我们在例子中试图得到的东西。换句话说,以下定义和前面的定义是等价的:

compareWithHundred :: Int -> Ordering
compareWithHundred = compare 100

类型声明保持不变,因为 compare 100 返回一个函数。compare 的类型是 (Ord a) => a -> (a -> Ordering)。当我们将其应用到 100 上时,我们得到一个接受一个数字并返回一个 Ordering 的函数。

部分

中缀函数也可以通过使用 部分应用 来部分应用。要部分应用一个中缀函数,只需将其用括号括起来,并在一边提供一个参数。这创建了一个接受一个参数并将其应用到缺少操作数的边的函数。这里有一个令人难以置信的简单例子:

divideByTen :: (Floating a) => a -> a
divideByTen = (/10)

如下代码所示,调用 divideByTen 200 等同于调用 200 / 10(/10) 200

ghci> divideByTen 200
20.0
ghci> 200 / 10
20.0
ghci> (/10) 200
20.0

让我们再看另一个例子。这个函数检查传递给它的字符是否为大写字母:

isUpperAlphanum :: Char -> Bool
isUpperAlphanum = (`elem` ['A'..'Z'])

在使用 -(负号或减号)运算符时需要注意的只有一点。根据部分应用的定义,(-4) 会得到一个接受一个数字并从它减去 4 的函数。然而,为了方便,(-4) 表示负四。所以,如果你想创建一个从它得到的参数中减去 4 的函数,你可以这样部分应用 subtract 函数:(subtract 4)

打印函数

到目前为止,我们已经将部分应用函数绑定到名称上,然后提供了剩余的参数以查看结果。然而,我们从未尝试将函数本身打印到终端。那么,我们尝试在 GHCi 中输入 multThree 3 4 而不是用 let 绑定它到一个名称或传递给另一个函数会发生什么呢?

ghci> multThree 3 4
<interactive>:1:0:
    No instance for (Show (a -> a))
      arising from a use of `print' at <interactive>:1:0-12
    Possible fix: add an instance declaration for (Show (a -> a))
    In the expression: print it
    In a 'do' expression: print it

GHCi 告诉我们表达式产生了一个类型为 a -> a 的函数,但它不知道如何将其打印到屏幕上。函数不是 Show 类型类的实例,所以我们无法得到一个函数的整洁字符串表示。这与我们在 GHCi 提示符中输入 1 + 1 的情况不同。在这种情况下,GHCi 计算出 2 作为结果,然后对 2 调用 show 来获取该数字的文本表示。2 的文本表示只是字符串 "2",然后将其打印到屏幕上。

注意

确保你彻底理解了柯里化函数和部分应用的工作原理,因为它们非常重要!

有些时候需要高级思维

在 Haskell 中,函数可以接受其他函数作为参数,并且如你所见,它们也可以返回函数作为返回值。为了演示这个概念,让我们编写一个接受一个函数,然后将其应用于某个值两次的函数:

applyTwice :: (a -> a) -> a -> a
applyTwice f x = f (f x)

无标题图片

注意类型声明。在我们的早期示例中,我们声明函数类型时不需要括号,因为 -> 是自然右结合的。然而,在这里括号是强制性的。它们表示第一个参数是一个接受一个参数并返回相同类型值(a -> a)的函数。第二个参数是类型为 a 的某个东西,返回值的类型也是 a。注意,a 的类型无关紧要——它可以是一个 IntString 或其他任何类型,但所有值必须是相同类型。

注意

你现在知道,在底层,看似接受多个参数的函数实际上只接受一个参数并返回一个部分应用函数。然而,为了简化问题,我将继续说一个给定的函数接受多个参数。

applyTwice 函数的主体非常简单。我们只是使用参数 f 作为函数,通过在 fx 之间留空格将 x 应用到它上。然后我们再次将结果应用到 f 上。以下是该函数的一些示例:

ghci> applyTwice (+3) 10
16
ghci> applyTwice (++ " HAHA") "HEY"
"HEY HAHA HAHA"
ghci> applyTwice ("HAHA " ++) "HEY"
"HAHA HAHA HEY"
ghci> applyTwice (multThree 2 2) 9
144
ghci> applyTwice (3:) [1]
[3,3,1]

部分应用的神奇之处和实用性显而易见。如果我们的函数需要我们传递一个只接受一个参数的函数,我们只需将函数部分应用到只接受一个参数的点,然后传递它。例如,+ 函数接受两个参数,在这个例子中,我们通过使用部分应用使其只接受一个参数。

实现 zipWith

现在我们将使用高阶编程来实现标准库中的一个非常有用的函数,称为 zipWith。它接受一个函数和两个列表作为参数,然后通过在对应元素之间应用该函数来连接两个列表。以下是我们的实现方法:

zipWith' :: (a -> b -> c) -> [a] -> [b] -> [c]
zipWith' _ [] _ = []
zipWith' _ _ [] = []
zipWith' f (x:xs) (y:ys) = f x y : zipWith' f xs ys

首先,让我们看看类型声明。第一个参数是一个接受两个参数并返回一个值的函数。它们不必是同一类型,但可以是。第二个和第三个参数是列表,最终返回值也是一个列表。

第一个列表必须是类型 a 的值列表,因为连接函数将其第一个参数作为 a 类型。第二个必须是类型 b 的列表,因为连接函数的第二个参数是类型 b。结果是类型 c 的元素列表。

注意

记住,如果你正在编写一个函数(尤其是高阶函数),并且你不确定类型,你可以尝试省略类型声明,并使用 :t 检查 Haskell 推断的类型。

这个函数类似于正常的 zip 函数。基本案例是相同的,尽管有一个额外的参数(连接函数)。然而,在基本案例中,这个参数并不重要,所以我们可以只使用 _ 字符。最后一个模式中的函数体也类似于 zip,不过它不是做 (x, y),而是 f x y

下面是 zipWith' 函数可以做的所有不同事情的演示:

ghci> zipWith' (+) [4,2,5,6] [2,6,2,3]
[6,8,7,9]
ghci> zipWith' max [6,3,2,1] [7,3,1,5]
[7,3,2,5]
ghci> zipWith' (++) ["foo ", "bar ", "baz "] ["fighters", "hoppers", "aldrin"]
["foo fighters","bar hoppers","baz aldrin"]
ghci> zipWith' (*) (replicate 5 2) [1..]
[2,4,6,8,10]
ghci> zipWith' (zipWith' (*)) [[1,2,3],[3,5,6],[2,3,4]] [[3,2,2],[3,4,5],[5,4,3]]
[[3,4,6],[9,20,30],[10,12,12]]

如您所见,一个单独的高阶函数可以以非常灵活的方式使用。

实现 flip

现在,我们将实现标准库中的另一个函数,称为 flipflip 函数接受一个函数并返回一个函数,类似于我们的原始函数,但前两个参数被反转。我们可以这样实现它:

flip' :: (a -> b -> c) -> (b -> a -> c)
flip' f = g
    where g x y = f y x

从类型声明中可以看出,flip 接受一个接受 ab 类型的函数,并返回一个接受 ba 类型的函数。但是,因为函数默认是柯里化的,所以第二个括号实际上是不必要的。箭头 -> 默认是右结合的,所以 (a -> b -> c) -> (b -> a -> c)(a -> b -> c) -> (b -> (a -> c)) 相同,这又与 (a -> b -> c) -> b -> a -> c 相同。我们写了 g x y = f y x。如果这是真的,那么 f y x = g x y 也必须成立,对吧?记住这一点,我们可以以更简单的方式定义这个函数:

flip' :: (a -> b -> c) -> b -> a -> c
flip' f y x = f x y

在这个 flip 函数的新版本中,我们利用了函数是柯里化的这一事实。当我们调用 flip f 而不带参数 yx 时,它将返回一个接受这两个参数但调用顺序相反的 f 函数。

尽管翻转函数通常传递给其他函数,但通过提前思考和编写它们完全应用时的最终结果,我们可以利用柯里化来制作高阶函数。

ghci> zip [1,2,3,4,5] "hello"
[(1,'h'),(2,'e'),(3,'l'),(4,'l'),(5,'o')]
ghci> flip' zip [1,2,3,4,5] "hello"
[('h',1),('e',2),('l',3),('l',4),('o',5)]
ghci> zipWith div [2,2..] [10,8,6,4,2]
[0,0,0,0,1]
ghci> zipWith (flip' div) [2,2..] [10,8,6,4,2]
[5,4,3,2,1]

如果我们翻转 zip 函数,我们将得到一个类似于 zip 的函数,除了第一个列表的项目被放置在元组的第二个组件中,反之亦然。flip div 函数将其第二个参数除以第一个参数,因此当将数字 210 传递给 flip div 时,结果与使用 div 10 2 相同。

函数式程序员的工具箱

作为函数式程序员,我们很少只想对一个值进行操作。我们通常想要处理一组数字、字母或其他类型的数据,并将该集合转换以产生我们的结果。在本节中,我们将探讨一些有用的函数,这些函数可以帮助我们处理多个值。

map 函数

map 函数接受一个函数和一个列表,并将该函数应用于列表中的每个元素,从而生成一个新列表。以下是它的定义:

map :: (a -> b) -> [a] -> [b]
map _ [] = []
map f (x:xs) = f x : map f xs

类型签名表明 map 接受一个从 ab 的函数和一个 a 类型的值列表,并返回一个 b 类型的值列表。

map 是一个多才多艺的高阶函数,可以用多种方式使用。以下是它的实际应用:

ghci> map (+3) [1,5,3,1,6]
[4,8,6,4,9]
ghci> map (++ "!") ["BIFF", "BANG", "POW"]
["BIFF!","BANG!","POW!"]
ghci> map (replicate 3) [3..6]
[[3,3,3],[4,4,4],[5,5,5],[6,6,6]]
ghci> map (map (²)) [[1,2],[3,4,5,6],[7,8]]
[[1,4],[9,16,25,36],[49,64]]
ghci> map fst [(1,2),(3,5),(6,3),(2,6),(2,5)]
[1,3,6,2,2]

你可能已经注意到,这些示例中的每一个也可以通过列表推导来实现。例如,map (+3) [1,5,3,1,6] 技术上等同于 [x+3 | x <- [1,5,3,1,6]]。然而,使用 map 函数通常会使你的代码更易于阅读,尤其是当你开始处理映射的映射时。

filter 函数

filter 函数接受一个谓词和一个列表,并返回满足该谓词的元素列表。(记住,谓词 是一个告诉某事是否为真的函数;也就是说,一个返回布尔值的函数。)类型签名和实现如下所示:

filter :: (a -> Bool) -> [a] -> [a]
filter _ [] = []
filter p (x:xs)
    | p x       = x : filter p xs
    | otherwise = filter p xs

如果 p x 评估为 True,则该元素包含在新列表中。如果它不评估为 True,则不包含在新列表中。

这里有一些 filter 的示例:

ghci> filter (>3) [1,5,3,2,1,6,4,3,2,1]
[5,6,4]
ghci> filter (==3) [1,2,3,4,5]
[3]
ghci> filter even [1..10]
[2,4,6,8,10]
ghci> let notNull x = not (null x) in filter notNull
 [[1,2,3],[],[3,4,5],[2,2],[],[],[]]
[[1,2,3],[3,4,5],[2,2]]
ghci> filter (`elem` ['a'..'z']) "u LaUgH aT mE BeCaUsE I aM diFfeRent"
"uagameasadifeent"
ghci> filter (`elem` ['A'..'Z']) "i LAuGh at you bEcause u R all the same"
"LAGER"

map函数一样,所有这些例子也可以通过使用推导式和谓词来实现。没有固定的规则来决定何时使用mapfilter与使用列表推导式。你只需要根据代码和上下文决定哪个更易读。

在列表推导式中应用多个谓词的filter等价于多次过滤或使用逻辑&&函数连接谓词。以下是一个例子:

ghci> filter (<15) (filter even [1..20])
[2,4,6,8,10,12,14]

在这个例子中,我们取列表[1..20]并过滤它,使得只有偶数剩下。然后我们传递这个列表到filter (<15)以去除 15 及以上的数字。这里是列表推导式的版本:

ghci> [x | x <- [1..20], x < 15, even x]
[2,4,6,8,10,12,14]

我们使用列表推导式,从列表[1..20]中抽取,然后说明一个数字要进入结果列表需要满足的条件。

记得我们在第四章中提到的quicksort函数?我们使用列表推导式来过滤出小于(或等于)或大于枢轴的列表元素。我们可以通过使用filter以更可读的方式实现相同的功能:

quicksort :: (Ord a) => [a] -> [a]
quicksort [] = []
quicksort (x:xs) =
    let smallerOrEqual = filter (<= x) xs
        larger = filter (> x) xs
    in  quicksort smallerOrEqual ++ [x] ++ quicksort larger

map 和 filter 的更多示例

无标题图片

作为另一个例子,让我们找出小于 100,000 且能被 3,829 整除的最大数字。为了做到这一点,我们只需过滤一组可能的解:

largestDivisible :: Integer
largestDivisible = head (filter p [100000,99999..])
    where p x = x `mod` 3829 == 0

首先,我们制作一个小于 100,000 的所有数字的降序列表。然后我们通过谓词过滤它。因为数字是按降序排列的,所以满足我们谓词的最大数字将是过滤列表的第一个元素。而且因为我们最终只使用过滤列表的头部,所以过滤列表是有限的还是无限的无关紧要。Haskell 的惰性使得评估在找到第一个合适的解时停止。

作为下一个例子,我们将找出小于 10,000 的所有奇数平方的和。在我们的解决方案中,我们将使用takeWhile函数。这个函数接受一个谓词和一个列表。从列表的开始处开始,只要谓词为真,它就返回列表的元素。一旦找到一个不满足谓词的元素,函数就会停止并返回结果列表。例如,要获取字符串的第一个单词,我们可以这样做:

ghci> takeWhile (/=' ') "elephants know how to party"
"elephants"

要找出小于 10,000 的所有奇数平方的和,我们首先将(²)函数映射到无限列表[1..]上。然后我们过滤这个列表,只保留奇数元素。接下来,使用takeWhile,我们只从列表中取出小于 10,000 的元素。最后,我们得到这个列表的和(使用sum函数)。在这个例子中,我们甚至不需要定义一个函数,因为在 GHCi 中我们可以一行完成:

ghci> sum (takeWhile (<10000) (filter odd (map (²) [1..])))
166650

太棒了!我们从一个初始数据(所有自然数的无限列表)开始,然后对其进行映射、过滤和裁剪,直到它满足我们的需求。最后,我们只需将其求和!

我们也可以用列表推导式来写这个例子,如下所示:

ghci> sum (takeWhile (<10000) [m | m <- [n² | n <- [1..]], odd m])
166650

对于我们的下一个问题,我们将处理柯尔察茨序列。一个柯尔察茨序列(也称为柯尔察茨链)被定义为如下:

  • 从任何自然数开始。

  • 如果数字是 1,停止。

  • 如果数字是偶数,将其除以 2。

  • 如果数字是奇数,将其乘以 3 再加 1。

  • 用结果数字重复算法。

实质上,这给我们一个数字链。数学家们理论认为,对于所有起始数字,链最终都会结束在数字 1。例如,如果我们从数字 13 开始,我们得到这个序列:13, 40, 20, 10, 5, 16, 8, 4, 2, 1。 (13 × 3 + 1 等于 40。40 除以 2 等于 20,以此类推。) 我们可以看到,以 13 开始的链有 10 项。

这里是我们想要解决的问题:对于 1 到 100 之间的所有起始数字,有多少柯尔察茨链的长度大于 15?

我们的第一步将是编写一个生成链的函数:

chain :: Integer -> [Integer]
chain 1 = [1]
chain n
    | even n =  n:chain (n `div` 2)
    | odd n  =  n:chain (n*3 + 1)

这是一个相当标准的递归函数。基本情况是 1,因为所有我们的链最终都会结束在 1。我们可以测试这个函数,看看它是否工作正常:

ghci> chain 10
[10,5,16,8,4,2,1]
ghci> chain 1
[1]
ghci> chain 30
[30,15,46,23,70,35,106,53,160,80,40,20,10,5,16,8,4,2,1]

现在我们可以编写numLongChains函数,它实际上回答了我们的问题:

numLongChains :: Int
numLongChains = length (filter isLong (map chain [1..100]))
    where isLong xs = length xs > 15

我们将chain函数映射到[1..100]以获得一系列链,这些链本身也以列表的形式表示。然后我们通过一个谓词来过滤它们,该谓词检查列表的长度是否超过 15。一旦过滤完成,我们就可以看到结果列表中剩下多少链。

注意

这个函数的类型是numLongChains :: Int,因为length返回一个Int而不是Num a。如果我们想返回一个更一般的Num a,我们可以在结果长度上使用fromIntegral

映射多参数函数

到目前为止,我们映射了只接受一个参数的函数(如map (*2) [0..])。然而,我们也可以映射接受多个参数的函数。例如,我们可以做类似map (*) [0..]的事情。在这种情况下,函数*,其类型为(Num a) => a -> a -> a,被应用到列表中的每个数字上。

正如你所见,给一个需要两个参数的函数只提供一个参数,会导致它返回一个只接受一个参数的函数。所以如果我们将*映射到列表[0..],我们就会得到一个只接受一个参数的函数列表。

这里有一个例子:

ghci> let listOfFuns = map (*) [0..]
ghci> (listOfFuns !! 4) 5
20

从我们的列表中获取索引为4的元素返回一个等价于(4*)的函数。然后我们只需将5应用到该函数上,这相当于(4*) 5,或者简单地4 * 5

独立函数

独立函数是我们需要只使用一次函数时使用的匿名函数。

无标题图片

通常,我们创建一个 lambda 的唯一目的是将其传递给一个高阶函数。要声明一个 lambda,我们写一个\(因为如果你足够用力地眯眼,它有点像希腊字母 lambda(λ)),然后我们写函数的参数,用空格分隔。之后是->,然后是函数体。我们通常用括号包围 lambda。

在上一节中,我们在numLongChains函数中使用了where绑定来创建isLong函数,仅为了将其传递给filter。我们也可以这样做,就像这样使用 lambda:

numLongChains :: Int
numLongChains = length (filter (\xs -> length xs > 15) (map chain [1..100]))

无标题图片

Lambda 是表达式,这就是为什么我们可以像这样将它们传递给函数。表达式(\xs -> length xs > 15)返回一个函数,告诉我们传递给它的列表长度是否大于 15。

不理解柯里化和部分应用如何工作的人经常在不必要的地方使用 lambda。例如,以下表达式是等价的:

ghci> map (+3) [1,6,3,2]
[4,9,6,5]
ghci> map (\x -> x + 3) [1,6,3,2]
[4,9,6,5]

(+3)(\\x -> x + 3)都是接受一个数字并将其加 3 的函数,所以这些表达式产生相同的结果。然而,在这种情况下,我们不想创建一个 lambda,因为使用部分应用的可读性更好。

正如普通函数一样,lambda 可以接受任意数量的参数:

ghci> zipWith (\a b -> (a * 30 + 3) / b) [5,4,3,2,1] [1,2,3,4,5]
[153.0,61.5,31.0,15.75,6.6]

正如普通函数一样,你可以在 lambda 中进行模式匹配。唯一的区别是,你不能为一个参数定义多个模式(比如为同一个参数创建一个[]和一个(x:xs)模式,然后让值通过)。

ghci> map (\(a,b) -> a + b) [(1,2),(3,5),(6,3),(2,6),(2,5)]
[3,8,9,8,7]

注意

如果 lambda 中的模式匹配失败,将发生运行时错误,所以请小心!

让我们看看另一个有趣的例子:

addThree :: Int -> Int -> Int -> Int
addThree x y z = x + y + z

addThree :: Int -> Int -> Int -> Int
addThree' = \x -> \y -> \z -> x + y + z

由于函数默认是柯里化的,这两个函数是等价的。然而,第一个addThree函数的可读性要高得多。第二个函数几乎只是一个花招,用来说明柯里化。

注意

注意,在第二个例子中,lambda 表达式没有被括号包围。当你写一个没有括号的 lambda 表达式时,它假定箭头->右侧的所有内容都属于它。因此,在这种情况下,省略括号可以节省一些打字。当然,如果你更喜欢的话,也可以包括括号。

然而,有时使用柯里化符号而不是它是有用的。我认为当flip函数这样定义时,它的可读性最高:

flip' :: (a -> b -> c) -> b -> a -> c
flip' f = \x y -> f y x

即使这与写作flip' f x y = f y x相同,我们新的符号使得很明显,这通常会被用来产生一个新的函数。flip最常见的使用案例是只传递函数参数,或者函数参数和一个额外的参数,然后将得到的函数传递给mapzipWith

ghci> zipWith (flip (++)) ["love you", "love me"] ["i ", "you "]
["i love you","you love me"]
ghci> map (flip subtract 20) [1,2,3,4]
[19,18,17,16]

当你想要明确指出你的函数旨在部分应用并随后作为参数传递给其他函数时,你可以在自己的函数中使用这种方式使用 lambda。

我折叠你

在我们处理递归(第四章)时,许多操作列表的递归函数遵循相同的模式。我们有一个空列表的基例,我们引入了 x:xs 模式,然后执行涉及单个元素和列表其余部分的一些操作。事实证明,这是一个非常常见的模式,因此 Haskell 的创造者引入了一些有用的函数,称为折叠,来封装它。折叠允许你将数据结构(如列表)缩减为单个值。

无标题图片

折叠(Folds)可以用来实现任何一次遍历列表,逐个元素,然后基于这些元素返回结果的函数。每当你要遍历一个列表来返回某个结果时,很可能你需要一个折叠操作。

折叠操作需要一个二元函数(一个接受两个参数的函数,例如 +div),一个起始值(通常称为累加器),以及一个要折叠的列表。

列表可以从左侧或右侧折叠。折叠函数使用给定的二元函数调用,使用累加器和列表的第一个(或最后一个)元素作为参数。结果值是新的累加器。然后折叠函数再次使用新的累加器和列表的新第一个(或最后一个)元素调用二元函数,产生另一个新的累加器。这个过程重复进行,直到函数遍历了整个列表,并将其缩减为单个累加器值。

使用 foldl 的左折叠

首先,让我们看看 foldl 函数。这被称为左折叠,因为它从列表的左侧开始折叠。在这种情况下,二元函数应用于起始累加器和列表的头部。这会产生一个新的累加器值,然后使用该值和下一个元素调用二元函数,依此类推。

让我们再次实现 sum 函数,这次使用折叠而不是显式的递归:

sum' :: (Num a) => [a] -> a
sum' xs = foldl (\acc x -> acc + x) 0 xs

现在我们可以测试它:

ghci> sum' [3,5,2,1]
11

无标题图片

让我们深入了解一下这个折叠是如何发生的。\acc x -> acc + x 是二进制函数。0 是起始值,xs 是要折叠的列表。首先,03 分别作为 accx 参数传递给二进制函数。在这种情况下,二进制函数只是一个加法,所以这两个值相加,产生新的累加器值 3。接下来,3 和下一个列表值 (5) 被传递给二进制函数,并将它们相加以产生新的累加器值 8。以同样的方式,82 相加以产生 10,然后 101 相加以产生最终值 11。恭喜你,你已经折叠了你的第一个列表!

左侧的图示说明了折叠是如何一步一步发生的。+ 左侧的数字是累加器值。你可以看到累加器是如何从左侧消耗列表的。(嗯嗯嗯!)如果我们考虑到函数是柯里化的,我们可以将这个实现写得更加简洁,如下所示:

sum' :: (Num a) => [a] -> a
sum' = foldl (+) 0

Lambda 函数 (\acc x -> acc + x)(+) 相同。我们可以省略 xs 作为参数,因为调用 foldl (+) 0 将返回一个接受列表的函数。通常,如果你有一个像 foo a = bar b a 这样的函数,你可以通过柯里化将其重写为 foo = bar b

使用 foldr 的右折叠

右折叠函数 foldr 与左折叠类似,但累加器从右侧消耗值。此外,右折叠的二进制函数的参数顺序是颠倒的:当前列表值是第一个参数,累加器是第二个。 (右折叠的累加器在右侧是有意义的,因为它从右侧折叠。)

折叠的累加器值(以及结果)可以是任何类型。它可以是数字、布尔值,甚至是新列表。作为一个例子,让我们用右折叠实现 map 函数。累加器将是一个列表,我们将逐个元素累积映射的列表。当然,我们的起始元素需要是一个空列表:

map' :: (a -> b) -> [a] -> [b]
map' f xs = foldr (\x acc -> f x : acc) [] xs

如果我们将 (+3) 映射到 [1,2,3],我们将从右侧接近列表。我们取最后一个元素,即 3,并将其应用于该函数,得到 6。然后我们将其预连接到累加器,累加器最初是 []6:[][6],因此现在是累加器。然后我们将 (+3) 应用到 2 上,得到 5,并将其预连接到累加器。我们的新累加器值现在是 [5,6]。然后我们将 (+3) 应用到 1 上,并将结果再次预连接到累加器,得到最终结果 [4,5,6]

当然,我们也可以用左折叠来实现这个函数,如下所示:

map' :: (a -> b) -> [a] -> [b]
map' f xs = foldl (\acc x -> acc ++ [f x]) [] xs

然而,++ 函数比 : 慢得多,所以我们通常在从列表构建新列表时使用右折叠。

两种折叠类型之间的一大区别是,右折叠可以在无限列表上工作,而左折叠则不行!

让我们再实现一个使用右折叠的函数。正如你所知,elem函数检查一个值是否是列表的一部分。以下是我们可以如何使用foldr来实现它的方法:

elem' :: (Eq a) => a -> [a] -> Bool
elem' y ys = foldr (\x acc -> if x == y then True else acc) False ys

在这里,累加器是一个布尔值。(记住,在处理折叠时,累加器值的类型和最终结果的类型总是相同的。)我们从一个False的值开始,因为我们假设值一开始就不在列表中。这也给我们提供了正确的值,如果我们对空列表调用它,因为对空列表调用折叠只会返回起始值。

接下来,我们检查当前元素是否是我们想要的元素。如果是,我们将累加器设置为True。如果不是,我们只保持累加器不变。如果它之前是False,它将保持那样,因为当前元素不是我们正在寻找的。如果它是True,它将保持那样,因为剩余的列表被折叠起来。

无标题图片

foldl 和 foldr1 函数

foldl1foldr1函数的工作方式与foldlfoldr非常相似,只是你不需要提供它们一个明确的起始累加器。它们假设列表的第一个(或最后一个)元素作为起始累加器,然后从它旁边的元素开始折叠。考虑到这一点,maximum函数可以这样实现:

maximum' :: (Ord a) => [a] -> a
maximum' = foldl1 max

我们通过使用foldl1实现了maximum。与提供起始累加器不同,foldl1只是假设第一个元素作为起始累加器,然后继续到第二个元素。所以foldl1所需的就是一个二元函数和一个要折叠的列表!我们从列表的开始处开始,然后比较每个元素与累加器。如果它比我们的累加器大,我们就将其作为新的累加器;否则,我们保持原来的累加器。我们将max传递给foldl1作为二元函数,因为它正是这样做的:取两个值并返回较大的那个。当我们完成列表的折叠后,只剩下最大的元素。

因为它们依赖于它们被调用的列表至少有一个元素,所以这些函数在用空列表调用时会导致运行时错误。另一方面,foldlfoldr与空列表一起工作得很好。

注意

当进行折叠时,考虑它对空列表的作用。如果函数在给空列表时没有意义,你可能可以使用foldl1foldr1来实现它。

一些折叠示例

为了展示折叠有多么强大,让我们使用折叠来实现一些标准库函数。首先,我们将编写自己的reverse版本:

reverse' :: [a] -> [a]
reverse' = foldl (\acc x -> x : acc) []

在这里,我们通过使用空列表作为起始累加器,然后从左边接近我们的原始列表,并将当前元素放在累加器的开头来反转列表。

函数\acc x -> x : acc就像:函数一样,只是参数被翻转了。这就是为什么我们也可以像这样写出reverse'

reverse' :: [a] -> [a]
reverse' = foldl (flip (:)) []

接下来,我们将实现 product

product' :: (Num a) => [a] -> a
product' = foldl (*) 1

要计算列表中所有数字的乘积,我们以 1 作为累加器开始。然后我们使用 * 函数向左折叠,将每个元素与累加器相乘。

现在我们将实现 filter

filter' :: (a -> Bool) -> [a] -> [a]
filter' p = foldr (\x acc -> if p x then x : acc else acc) []

在这里,我们使用空列表作为起始累加器。然后我们从右向左折叠并检查每个元素。p 是我们的谓词。如果 p xTrue——意味着如果谓词对当前元素成立——我们将它放在累加器的开头。否则,我们只是重新使用我们的旧累加器。

最后,我们将实现 last

last' :: [a] -> a
last' = foldl1 (\_ x -> x)

要获取列表的最后一个元素,我们使用 foldl1。我们从列表的第一个元素开始,然后使用一个二元函数,该函数忽略累加器并将当前元素始终设置为新的累加器。一旦我们到达末尾,累加器(即最后一个元素)将被返回。

另一种看待折叠的方式

另一种想象左右折叠的方式是将某个函数连续应用于列表中的元素。假设我们有一个右折叠,二元函数 f 和起始累加器 z。当我们对列表 [3,4,5,6] 进行右折叠时,我们实际上在做以下操作:

f 3 (f 4 (f 5 (f 6 z)))

f 被调用时使用列表中的最后一个元素和累加器,然后该值作为累加器传递给倒数第二个值,依此类推。

如果我们将 f 设为 + 并将初始累加器值设为 0,我们正在做以下操作:

3 + (4 + (5 + (6 + 0)))

或者,如果我们将 + 写作前缀函数,我们正在做以下操作:

(+) 3 ((+) 4 ((+) 5 ((+) 6 0)))

类似地,使用 g 作为二元函数和 z 作为累加器对该列表进行左折叠等同于以下操作:

g (g (g (g z 3) 4) 5) 6

如果我们使用 flip (:) 作为二元函数并将 [] 作为累加器(因此我们正在反转列表),那么这等同于以下操作:

flip (:) (flip (:) (flip (:) (flip (:) [] 3) 4) 5) 6

确实,如果你评估这个表达式,你会得到 [6,5,4,3]

折叠无限列表

将折叠视为对列表值进行连续函数应用可以让你了解为什么 foldr 有时在无限列表上工作得很好。让我们使用 foldr 实现函数 and,然后像我们之前的例子一样将其写成一系列连续的函数应用。你会看到 foldr 如何与 Haskell 的惰性一起在具有无限长度的列表上操作。

and 函数接受一个 Bool 值列表并返回 False 如果有一个或多个元素是 False;否则,它返回 True。我们将从右向左处理列表,并使用 True 作为起始累加器。我们将使用 && 作为二元函数,因为我们只想在所有元素都是 True 时得到 True&& 函数在其任一参数为 False 时返回 False,因此如果我们遇到列表中的 False 元素,累加器将被设置为 False,最终结果也将是 False,即使所有剩余的元素都是 True

and' :: [Bool] -> Bool
and' xs = foldr (&&) True xs

了解 foldr 的工作原理后,我们看到表达式 and' [True,False,True] 将被评估如下:

True && (False && (True && True))

最后的True代表我们的起始累加器,而前三个Bool值来自列表[True,False,True]。如果我们尝试评估前面的表达式,我们将得到False

现在如果我们尝试使用无限列表,比如repeat False,它有无限个元素,所有这些元素都是False,会发生什么?如果我们把它写出来,我们得到类似这样的东西:

False && (False && (False && (False ...

Haskell 是惰性的,所以它只会计算它真正必须计算的内容。&&函数以这种方式工作,即如果它的第一个参数是False,它会忽略其第二个参数,因为&&函数只有在两个参数都是True时才返回True

(&&) :: Bool -> Bool -> Bool
True && x = x False && _ = False

在无限个False值的列表的情况下,第二个模式匹配,并且False被返回,而 Haskell 不需要评估无限列表的其余部分:

ghci> and' (repeat False)
False

当我们传递给foldr的二进制函数不需要总是评估其第二个参数以给出某种答案时,foldr将在无限列表上工作。例如,如果第一个参数是False&&就不关心其第二个参数是什么。

扫描

scanlscanr函数类似于foldlfoldr,但它们以列表的形式报告所有中间累加器状态。scanl1scanr1函数类似于foldl1foldr1。以下是一些这些函数实际应用的例子:

ghci> scanl (+) 0 [3,5,2,1]
[0,3,8,10,11]
ghci> scanr (+) 0 [3,5,2,1]
[11,8,3,1,0]
ghci> scanl1 (\acc x -> if x > acc then x else acc) [3,4,5,3,7,9,2,1]
[3,4,5,5,7,9,9,9]
ghci> scanl (flip (:)) [] [3,2,1]
[[],[3],[2,3],[1,2,3]]

当使用scanl时,最终结果将位于结果列表的最后一个元素中。scanr将结果放在列表的头部。

扫描用于监控可以表示为折叠的函数的进展。作为一个使用扫描的练习,让我们尝试回答这个问题:要使所有自然数的平方根之和超过 1,000,需要多少个元素?

要得到所有自然数的平方根,我们只需调用map sqrt [1..]。要得到总和,我们可以使用折叠。然而,因为我们对总和的进展情况感兴趣,我们将使用扫描。一旦我们完成了扫描,我们就可以检查有多少个总和小于 1,000。

sqrtSums :: Int
sqrtSums = length (takeWhile (<1000) (scanl1 (+) (map sqrt [1..]))) + 1

我们在这里使用takeWhile而不是filter,因为一旦找到等于或超过 1,000 的数字,filter就不会截断结果列表;它会继续搜索。即使我们知道列表是递增的,filter也不知道,所以我们使用takeWhile来截断扫描列表,直到出现第一个总和大于 1,000 的情况。

扫描列表中的第一个总和将是 1。第二个将是 1 加上 2 的平方根。第三个将是那个数加上 3 的平方根。如果有x个总和小于 1,000,那么总和超过 1,000 需要x+1 个元素:

ghci> sqrtSums
131
ghci> sum (map sqrt [1..131])
1005.0942035344083
ghci> sum (map sqrt [1..130])
993.6486803921487

看哪,我们的答案是正确的!如果我们把前 130 个平方根相加,结果将略低于 1,000,但如果我们再加上一个,就会超过我们的阈值。

使用$进行函数应用

现在,我们将看看$函数,也称为函数应用操作符。首先,让我们看看它是如何定义的:

($) :: (a -> b) -> a -> b
f $ x = f x

无标题图片

究竟是怎么回事?这个无用的函数是什么?它只是函数应用!好吧,这几乎是对的,但并不完全正确。而正常的函数应用(在两个事物之间留空格)具有很高的优先级,而 $ 函数具有最低的优先级。带有空格的函数应用是左结合的(因此 f a b c((f a) b) c 相同),而带有 $ 的函数应用是右结合的。

这对我们有什么帮助?大多数时候,它是一个方便的函数,让我们可以少写一些括号。例如,考虑表达式 sum (map sqrt [1..130])。因为 $ 具有如此低的优先级,我们可以将那个表达式重写为 sum $ map sqrt [1..130]。当遇到 $ 时,其右侧的表达式被用作左侧函数的参数。

那么 sqrt 3 + 4 + 9 呢?这是将 9、4 和 3 的平方根相加。然而,如果我们想计算 3 + 4 + 9 的平方根,我们需要写成 sqrt (3 + 4 + 9)。使用 $,我们也可以写成 sqrt $ 3 + 4 + 9。你可以想象 $ 几乎等同于在表达式的最右侧写一个开括号,然后写一个闭括号。

让我们看看另一个例子:

ghci> sum (filter (> 10) (map (*2) [2..10]))
80

哇,这么多括号!看起来有点丑。这里,(*2) 被映射到 [2..10],然后我们过滤结果列表,只保留大于 10 的那些数字,最后将这些数字相加。

我们可以使用 $ 函数重写我们之前的例子,使其更容易看懂:

ghci> sum $ filter (> 10) (map (*2) [2..10])
80

$ 函数是右结合的,这意味着 f $ g $ xf $ (g $ x) 相同。考虑到这一点,前面的例子可以再次重写如下:

ghci> sum $ filter (> 10) $ map (*2) [2..10]
80

除了去掉括号外,$ 允许我们将函数应用视为另一个函数。这使得我们能够,例如,将函数应用映射到函数列表上,如下所示:

ghci> map ($ 3) [(4+), (10*), (²), sqrt]
[7.0,30.0,9.0,1.7320508075688772]

这里,函数 ($ 3) 被映射到列表上。如果你考虑 ($ 3) 函数做了什么,你会发现它接受一个函数并将其应用于 3。所以列表中的每个函数都被应用于 3,这在结果中是显而易见的。

函数复合

在数学中,函数复合 定义如下:(f º g)(x) = f(g(x))。这意味着复合两个函数相当于先调用一个函数并传递一些值,然后调用另一个函数并传递第一个函数的结果。

在 Haskell 中,函数复合基本上是同一件事。我们使用 . 函数进行函数复合,该函数定义如下:

(.) :: (b -> c) -> (a -> b) -> a -> c
f . g = \x -> f (g x)

无标题图片

注意类型声明。f 必须接受一个与 g 返回值类型相同的参数。因此,结果函数接受与 g 相同类型的参数,并返回与 f 相同类型的值。例如,表达式 negate . (* 3) 返回一个函数,该函数接受一个数字,将其乘以 3,然后取其相反数。

函数组合的一个用途是动态创建函数并将其传递给其他函数。当然,我们可以使用 lambda 表达式来做到这一点,但很多时候,函数组合更清晰、更简洁。

例如,假设我们有一个数字列表,并且我们想将它们全部转换为负数。一种方法是通过获取每个数字的绝对值然后取其相反数,如下所示:

ghci> map (\x -> negate (abs x)) [5,-3,-6,7,-3,2,-19,24]
[-5,-3,-6,-7,-3,-2,-19,-24]

注意 lambda 及其看起来像函数组合的结果。使用函数组合,我们可以将其重写如下:

ghci> map (negate . abs) [5,-3,-6,7,-3,2,-19,24]
[-5,-3,-6,-7,-3,-2,-19,-24]

太棒了!函数组合是右结合的,所以我们可以一次组合多个函数。表达式 f (g (z x)) 等价于 (f . g . z) x。考虑到这一点,我们可以将一些混乱的表达式,比如这个:

ghci> map (\xs -> negate (sum (tail xs))) [[1..5],[3..6],[1..7]]
[-14,-15,-27]

变得更加简洁,如下所示:

ghci> map (negate . sum . tail) [[1..5],[3..6],[1..7]]
[-14,-15,-27]

negate . sum . tail 是一个函数,它接受一个列表,对其应用 tail 函数,然后对那个结果应用 sum 函数,最后对前一个结果应用 negate。所以它与前面的 lambda 表达式等价。

多参数的函数组合

但对于需要多个参数的函数怎么办?如果我们想在函数组合中使用它们,通常必须部分应用它们,以便每个函数只接受一个参数。考虑这个表达式:

sum (replicate 5 (max 6.7 8.9))

这个表达式可以被重写如下:

(sum . replicate 5) max 6.7 8.9

这与以下表达式等价:

sum . replicate 5 $ max 6.7 8.9

函数 replicate 5 被应用于 max 6.7 8.9 的结果,然后 sum 函数被应用于该结果。请注意,我们部分应用了 replicate 函数,使其只接受一个参数,因此当 max 6.7 8.9 的结果传递给 replicate 5 时,结果是一个数字列表,然后该列表被传递给 sum

如果我们想使用函数组合重写一个包含许多括号的复杂表达式,我们可以先写出最内层的函数及其参数。然后我们在它前面加上一个 $ 符号,并通过省略它们最后的参数并在它们之间加上点来组合所有之前的函数。比如说我们有一个这样的表达式:

replicate 2 (product (map (*3) (zipWith max [1,2] [4,5])))

我们可以写成以下形式:

replicate 2 . product . map (*3) $ zipWith max [1,2] [4,5]

我们是如何将第一个例子转换为第二个例子的呢?首先,我们看看最右边的函数及其参数,就在一串闭合括号之前。这个函数是 zipWith max [1,2] [4,5]。我们将保持它不变,所以我们现在有这个:

zipWith max [1,2] [4,5]

然后,我们看看哪个函数被应用于 zipWith max [1,2] [4,5],我们看到它是 map (*3)。所以我们在这两者之间加上一个 $ 符号:

map (*3) $ zipWith max [1,2] [4,5]

现在我们开始组合。我们检查所有这些应用了哪个函数,我们看到是product,所以我们将其与map (*3)组合:

product . map (*3) $ zipWith max [1,2] [4,5]

最后,我们看到函数replicate 2被应用到所有这些上,我们可以将表达式写成以下形式:

replicate 2 . product . map (*3) $ zipWith max [1,2] [4,5]

如果表达式以三个括号结束,那么很可能,如果你按照这个程序将其翻译成函数组合,它将有两个组合运算符。

无点风格

函数组合的另一个常见用途是在无点风格中定义函数。例如,考虑我们之前编写的一个函数:

sum' :: (Num a) => [a] -> a
sum' xs = foldl (+) 0 xs

xs位于等号两边的最右边。由于柯里化,我们可以省略两边的xs,因为调用foldl (+) 0创建了一个接受列表的函数。这样,我们就在无点风格中编写函数:

sum' :: (Num a) => [a] -> a
sum' = foldl (+) 0

作为另一个例子,让我们尝试以下函数的无点风格写法:

fn x = ceiling (negate (tan (cos (max 50 x))))

我们不能简单地在等号两边都去掉x,因为函数体内的x被括号包围。cos (max 50)是没有意义的——你不能得到一个函数的余弦值。我们可以做的是将fn表示为函数的组合,如下所示:

fn = ceiling . negate . tan . cos . max 50

太棒了!很多时候,无点风格更易于阅读和简洁,因为它让你思考函数以及什么样的函数组合会产生,而不是思考数据以及它是如何被重新排列的。你可以使用简单的函数,并用组合作为粘合剂来形成更复杂的函数。

然而,如果一个函数过于复杂,用无点风格编写它实际上可能更难以阅读。因此,不建议编写长的函数组合链。首选的风格是使用let绑定来给中间结果命名,或者将问题分解成更易于阅读代码的人理解的子问题。

在本章的早期,我们解决了寻找小于 10,000 的所有奇数平方和的问题。以下是将其放入函数中的解决方案:

oddSquareSum :: Integer
oddSquareSum = sum (takeWhile (<10000) (filter odd (map (²) [1..])))

利用我们对函数组合的了解,我们也可以这样写函数:

oddSquareSum :: Integer
oddSquareSum = sum . takeWhile (<10000) . filter odd $ map (²) [1..]

起初这可能有点奇怪,但你会很快习惯这种风格。由于我们去掉了括号,视觉噪音更少。阅读时,你只需说filter odd被应用到map (²) [1..]的结果上,然后takeWhile (<10000)被应用到那个结果上,最后sum被应用到那个结果上。

第六章:模块

Haskell 的模块本质上是一个定义了一些函数、类型和类型类的文件。Haskell 的程序是一组模块的集合。

无标题图片

一个模块可以在其中定义许多函数和类型,并且它导出其中的一些。这意味着它使它们对外界可见并可使用。

将代码分成几个模块有许多优点。如果一个模块足够通用,它导出的函数可以在许多不同的程序中使用。如果你的代码被分成不相互依赖太多的自包含模块(我们也称它们为松耦合),你可以在以后重用它们。当你将代码分成几个部分时,代码更易于管理。

Haskell 标准库被分成模块,每个模块都包含一些相关且服务于某些共同目的的函数和类型。有用于操作列表、并发编程、处理复数等的模块。我们迄今为止处理的所有函数、类型和类型类都是Prelude模块的一部分,该模块默认导入。

在本章中,我们将检查一些有用的模块及其函数。但首先,你需要知道如何导入模块。

导入模块

在 Haskell 脚本中导入模块的语法是import ModuleName。这必须在定义任何函数之前完成,因此导入通常位于文件顶部。一个脚本可以导入多个模块——只需将每个import语句放在单独的一行上。

一个有用的模块示例是Data.List,它包含了一组用于处理列表的函数。让我们导入这个模块并使用它的一个函数来创建一个自己的函数,该函数告诉我们列表中有多少唯一元素。

import Data.List

numUniques :: (Eq a) => [a] -> Int
numUniques = length . nub

当你导入Data.List时,Data.List导出的所有函数都变得可用;你可以在脚本的任何地方调用它们。其中之一是nub函数,它接受一个列表并去除重复元素。将lengthnub组合成length . nub会产生一个函数,其效果等同于\xs -> length (nub xs)

注意

要搜索函数或找出它们的位置,请使用 Hoogle,它可以在www.haskell.org/hoogle/找到。它是一个真正出色的 Haskell 搜索引擎,允许你通过函数名、模块名或甚至类型签名进行搜索。

使用 GHCi 时,你也可以访问模块的函数。如果你在 GHCi 中,并且想要能够调用Data.List导出的函数,请输入以下内容:

ghci> :m + Data.List

如果你想要从 GHCi 访问多个模块,你不需要多次输入:m +。你可以一次性加载多个模块,如下例所示:

ghci> :m + Data.List Data.Map Data.Set

然而,如果您已经加载了一个已经导入模块的脚本,您不需要使用 :m + 来访问该模块。如果您只需要从模块中导入几个函数,您可以仅选择性地导入这些函数。例如,以下是您如何仅从 Data.List 中导入 nubsort 函数的方法:

import Data.List (nub, sort)

您也可以选择导入一个模块的所有函数,除了几个选定的函数。当几个模块导出具有相同名称的函数,并且您想去除这些函数时,这通常很有用。比如说,您已经有一个名为 nub 的函数,并且您想导入 Data.List 中的所有函数,除了 nub 函数。以下是这样做的方法:

import Data.List hiding (nub)

处理名称冲突的另一种方法是进行 限定导入。考虑 Data.Map 模块,它提供了一个通过键查找值的数据结构。此模块导出许多与 Prelude 函数具有相同名称的函数,例如 filternull。所以如果我们导入了 Data.Map 并调用 filter,Haskell 就不知道要使用哪个函数。以下是解决方法:

import qualified Data.Map

现在如果我们想引用 Data.Mapfilter 函数,我们必须使用 Data.Map.filter。仅输入 filter 仍然指的是我们所有人都知道和喜爱的普通 filter。但是,在模块中的每个函数前面都输入 Data.Map 有点繁琐。这就是为什么我们可以将限定导入重命名为更短的名字:

import qualified Data.Map as M

现在要引用 Data.Mapfilter 函数,我们只需使用 M.filter

正如您所看到的,. 符号用于引用已导入为限定形式的模块中的函数,例如 M.filter。我们还用它来进行函数组合。那么,Haskell 是如何知道我们使用它的意思的呢?嗯,如果我们将其放置在限定模块名称和函数之间,没有空格,它就被视为仅引用导入的函数;否则,它被视为函数组合。

注意

获取新的 Haskell 知识的一个好方法就是点击标准库文档,探索模块及其函数。您还可以查看每个模块的 Haskell 源代码。阅读一些模块的源代码将让您对 Haskell 有一个扎实的感受。

使用模块函数解决问题

标准库中的模块提供了许多函数,可以在使用 Haskell 编码时使我们的生活更轻松。让我们看看如何使用各种 Haskell 模块中的函数来解决一些问题的示例。

计数单词

假设我们有一个包含许多单词的字符串,我们想知道每个单词在字符串中出现的次数。我们将使用的第一个模块函数是来自 Data.Listwords 函数。words 函数将一个字符串转换成一个字符串列表,其中每个字符串都是一个单词。以下是一个快速演示:

ghci> words "hey these are the words in this sentence"
["hey","these","are","the","words","in","this","sentence"]
ghci> words "hey these           are    the words in this sentence"
["hey","these","are","the","words","in","this","sentence"]

然后,我们将使用 group 函数,它也位于 Data.List 中,将相同的单词分组在一起。此函数接受一个列表,如果相邻元素相等,则将它们分组到子列表中:

ghci> group [1,1,1,1,2,2,2,2,3,3,2,2,2,5,6,7]
[[1,1,1,1],[2,2,2,2],[3,3],[2,2,2],[5],[6],[7]]

但如果列表中相等的元素不是相邻的,会发生什么呢?

ghci> group ["boom","bip","bip","boom","boom"]
[["boom"],["bip","bip"],["boom","boom"]]

我们得到了两个包含字符串"boom"的列表,尽管我们希望某个单词的所有出现都最终出现在同一个列表中。我们该怎么办呢?嗯,我们可以在排序单词列表之前进行排序!为此,我们将使用位于Data.List中的sort函数。它接受可以排序的事物列表,并返回一个新列表,类似于旧列表,但按从小到大的顺序排列:

ghci> sort [5,4,3,7,2,1]
[1,2,3,4,5,7]
ghci> sort ["boom","bip","bip","boom","boom"]
["bip","bip","boom","boom","boom"]

注意,字符串是按字母顺序排列的。

我们已经拥有了我们的食谱的所有原料。现在我们只需要把它写下来。我们将取一个字符串,将其分解成一个单词列表,对这些单词进行排序,然后分组。最后,我们将使用一些映射魔法来获取像("boom", 3)这样的元组,这意味着单词"boom"出现了三次。

import Data.List

wordNums :: String -> [(String,Int)]
wordNums = map (\ws -> (head ws, length ws)) . group . sort . words

我们使用函数组合来制作我们的最终函数。它接受一个字符串,例如"wa wa wee wa",然后对该字符串应用words,得到["wa","wa","wee","wa"]。然后对结果应用sort,我们得到["wa","wa","wa","wee"]。将group应用于此结果将相邻且相等的单词分组,因此我们得到一个字符串列表的列表:[["wa","wa","wa"],["wee"]]。然后我们对分组后的单词应用一个函数,该函数接受一个列表并返回一个元组,其中第一个元素是列表的头部,第二个元素是其长度。我们的最终结果是[("wa",3),("wee",1)]

下面是如何不使用函数组合来编写这个函数的方法:

wordNums xs = map (\ws -> (head ws,length ws)) (group (sort (words xs)))

哇,括号过多!我想很容易看出函数组合是如何使这个函数更易读的。

针对麦草堆

对于我们的下一个任务,如果我们选择接受,我们将制作一个函数,该函数接受两个列表并告诉我们第一个列表是否完全包含在第二个列表中的任何地方。例如,列表[3,4]包含在[1,2,3,4,5]中,而[2,5]则不包含。我们将被搜索的列表称为麦草堆,我们正在搜索的列表称为

对于这次冒险,我们将使用tails函数,该函数位于Data.List中。tails接受一个列表,并连续对该列表应用tail函数。以下是一个例子:

ghci> tails "party"
["party","arty","rty","ty","y",""]
ghci> tails [1,2,3]
[[1,2,3],[2,3],[3],[]]

到目前为止,可能还不明显为什么我们需要tails。另一个例子将澄清这一点。

假设我们正在搜索字符串"art"在字符串"party"中的位置。首先,我们使用tails来获取列表的所有尾部。然后我们检查每个尾部,如果任何一个以字符串"art"开头,那么我们就找到了麦草堆中的针!如果我们正在搜索"boo""party"中的位置,则没有任何尾部以字符串"boo"开头。

要检查一个字符串是否以另一个字符串开头,我们将使用isPrefixOf函数,该函数也位于Data.List中。它接受两个列表,并告诉我们第二个列表是否以第一个列表开头。

ghci> "hawaii" `isPrefixOf` "hawaii joe"
True
ghci> "haha" `isPrefixOf` "ha"
False
ghci> "ha" `isPrefixOf` "ha"
True

现在我们只需要检查我们的稻草堆的任何尾部是否以我们的针开始。为此,我们可以使用Data.List中的any函数。它接受一个谓词和一个列表,并告诉我们列表中的任何元素是否满足谓词。看这里:

ghci> any (> 4) [1,2,3]
False
ghci> any (=='F') "Frank Sobotka"
True
ghci> any (\x -> x > 5 && x < 10) [1,4,11]
False

让我们把这些函数放在一起:

import Data.List

isIn :: (Eq a) => [a] -> [a] -> Bool
needle `isIn` haystack = any (needle `isPrefixOf`) (tails haystack)

这就是全部!我们使用tails来生成我们稻草堆的尾部列表,然后看看是否有任何一个以我们的针开始。让我们试运行一下:

ghci> "art" `isIn` "party"
True
ghci> [1,2] `isIn` [1,3,5]
False

哦,等等!我们刚才制作的函数已经在Data.List中!诅咒!它被称为isInfixOf,并且它与我们的isIn函数做相同的工作。

凯撒密码沙拉

盖乌斯·尤利乌斯·凯撒将一项重要任务托付给了我们。我们必须将一份绝密信息传送到高卢的马克·安东尼。以防我们被俘获,我们将使用Data.Char中的某些函数来变得有点狡猾,并通过使用凯撒密码来编码信息。

无标题图片

凯撒密码是一种通过在字母表中按固定位数移动每个字符来编码信息的原始方法。我们可以轻松地创建我们自己的凯撒密码,我们不会限制自己只使用字母表——我们将使用 Unicode 字符的全范围。

为了在字母表中向前和向后移动字符,我们将使用Data.Char模块的ordchr函数,这些函数将字符转换为相应的数字,反之亦然:

ghci> ord 'a'
97
ghci> chr 97
'a'
ghci> map ord "abcdefgh"
[97,98,99,100,101,102,103,104]

ord 'a'返回97,因为'a'是字符 Unicode 表中的第九十七个字符。

两个字符的ord值之间的差异等于它们在 Unicode 表中的距离。

让我们编写一个函数,该函数接受要移动的位置数和字符串,然后返回该字符串,其中每个字符都按该位置数在字母表中向前移动。

import Data.Char

encode :: Int -> String -> String
encode offset msg = map (\c -> chr $ ord c + offset) msg

编码一个字符串就像取我们的信息,映射一个函数,该函数接受一个字符,将其转换为相应的数字,添加一个偏移量,然后将其转换回字符。一个组合牛仔会这样写这个函数:(chr . (+ offset) . ord)。

ghci> encode 3 "hey mark"
"kh|#pdun"
ghci> encode 5 "please instruct your men"
"uqjfxj%nsxywzhy%~tzw%rjs"
ghci> encode 1 "to party hard"
"up!qbsuz!ibse"

这肯定已经编码了!

解码一条信息基本上就是将其按照最初移动的位数反向移动。

decode :: Int -> String -> String
decode shift msg = encode (negate shift) msg

现在我们可以通过解码凯撒的信息来测试它:

ghci> decode 3 "kh|#pdun"
"hey mark"
ghci> decode 5 "uqjfxj%nsxywzhy%~tzw%rjs"
"please instruct your men"
ghci> decode 1 "up!qbsuz!ibse"
"to party hard"

在严格的左折叠中

在上一章中,你看到了foldl是如何工作的,以及你可以如何使用它来实现各种酷函数。然而,foldl有一个我们尚未探索的陷阱:使用foldl有时会导致所谓的堆栈溢出错误,这发生在你的程序在计算机内存的特定部分使用太多空间时。为了演示,让我们使用foldl+函数来求和由一百个1组成的列表:

ghci> foldl (+) 0 (replicate 100 1)
100

这似乎有效。如果我们想使用foldl来求和,作为 Dr. Evil 所说,一百万1组成的列表,会怎样呢?

ghci> foldl (+) 0 (replicate 1000000 1)
*** Exception: stack overflow

无标题图片

哎呀,这真是太邪恶了!那么这是为什么发生的呢?Haskell 是惰性的,因此它会尽可能推迟实际值的计算。当我们使用foldl时,Haskell 不会在每一步计算(即评估)实际的累加器。相反,它会推迟评估。在下一步中,它再次不会评估累加器,而是推迟评估。它还会在内存中保留旧的推迟计算,因为新的计算通常引用其结果。所以当折叠愉快地进行时,它会积累一大堆推迟的计算,每个计算都占用相当数量的内存。最终,这可能导致栈溢出错误。

这是 Haskell 如何评估表达式foldl (+) 0 [1,2,3]的方式:

foldl (+) 0 [1,2,3] =
foldl (+) (0 + 1) [2,3] =
foldl (+) ((0 + 1) + 2) [3] =
foldl (+) (((0 + 1) + 2) + 3) [] =
((0 + 1) + 2) + 3 =
(1 + 2) + 3 =
3 + 3 =
6

如您所见,它首先建立一大堆推迟的计算。然后,一旦它达到空列表,它就会开始实际评估这些推迟的计算。这对小列表来说没问题,但对于包含超过一百万个元素的列表,你会得到栈溢出错误,因为评估所有这些推迟的计算是通过递归完成的。如果有一个名为foldl'的函数,它不推迟计算,那不是很好吗?它会这样工作:

foldl' (+) 0 [1,2,3] =
foldl' (+) 1 [2,3] =
foldl' (+) 3 [3] =
foldl' (+) 6 [] =
6

foldl的步骤之间不会推迟计算,而是会立即评估。嗯,我们很幸运,因为Data.List提供了这种更严格的foldl版本,它确实被称为foldl'。让我们尝试使用foldl'计算一百万个1的和:

ghci> foldl' (+) 0 (replicate 1000000 1)
1000000

大获成功!所以,如果你在使用foldl时遇到栈溢出错误,尝试切换到foldl'。还有foldl1的更严格版本,名为foldl1'

让我们寻找一些有趣的数字

无标题图片

你正在街道上行走,一位老妇人走到你面前说:“对不起,第一个数字之和等于 40 的自然数是什么?”

那么,现在怎么办,高手?让我们使用一些 Haskell 魔法来找到这样的数字。例如,如果我们对数字 123 的数字求和,我们得到 6,因为 1 + 2 + 3 等于 6。那么,第一个具有这种性质的数字是什么,它的数字之和等于 40?

首先,让我们创建一个函数,它接受一个数字并告诉我们它的数字之和。这里我们将使用一个巧妙的技巧。首先,我们将使用show函数将我们的数字转换为字符串。一旦我们有了字符串,我们将把该字符串中的每个字符转换为数字,然后只对这组数字求和。为了将一个字符转换为数字,我们将使用来自Data.Char的便捷函数digitToInt。它接受一个Char并返回一个Int

ghci> digitToInt '2'
2
ghci> digitToInt 'F'
15
ghci> digitToInt 'z'
*** Exception: Char.digitToInt: not a digit 'z'

它作用于范围从'0''9'和从'A''F'(它们也可以是小写)的字符。

这是我们的函数,它接受一个数字并返回其数字之和:

import Data.Char
import Data.List

digitSum :: Int -> Int
digitSum = sum . map digitToInt . show

我们将其转换为字符串,对那个字符串应用digitToInt,然后对得到的一组数字求和。

现在我们需要找到第一个自然数,当我们对其应用digitSum时,我们得到的结果是40。为了做到这一点,我们将使用位于Data.List中的find函数。它接受一个谓词和一个列表,并返回列表中第一个满足谓词的元素。然而,它有一个相当特殊的类型声明:

ghci> :t find
find :: (a -> Bool) -> [a] -> Maybe a

无标题图片

第一个参数是一个谓词,第二个参数是一个列表——这里没什么大不了的。但是返回值呢?它说Maybe a。这是一种你之前没有见过的类型。类型为Maybe a的值有点像类型为[a]的列表。而一个列表可以有零个、一个或多个元素,一个类型为Maybe a的值可以有零个元素或只有一个元素。我们使用它来表示可能失败的情况。要创建一个不包含任何内容的值,我们只需使用Nothing。这与空列表类似。要构造一个包含内容的值,比如字符串"hey",我们写Just "hey"。这里有一个快速演示:

ghci> Nothing
Nothing
ghci> Just "hey"
Just "hey"
ghci> Just 3
Just 3
ghci> :t Just "hey"
Just "hey" :: Maybe [Char]
ghci> :t Just True
Just True :: Maybe Bool

如你所见,一个值为Just True的类型是Maybe Bool,有点像包含布尔值的列表会有类型[Bool]

如果find找到一个满足谓词的元素,它将返回该元素包裹在Just中。如果没有,它将返回Nothing

ghci> find (> 4) [3,4,5,6,7]
Just 5
ghci> find odd [2,4,6,8,9]
Just 9
ghci> find (=='z') "mjolnir"
Nothing

现在让我们回到编写我们的函数。我们有了digitSum函数,也知道find是如何工作的,所以剩下的只是将这两个结合起来。记住,我们想要找到第一个数字,其各位数字之和为 40。

firstTo40 :: Maybe Int
firstTo40 = find (\x -> digitSum x == 40) [1..]

我们只是取无限列表[1..],然后找到第一个digitSum为 40 的数字。

ghci> firstTo40
Just 49999

我们得到了答案!如果我们想要创建一个更通用的函数,它不是固定在 40 上,而是接受我们想要的和作为参数,我们可以这样修改:

firstTo :: Int -> Maybe Int
firstTo n = find (\x -> digitSum x == n) [1..]

这里是一个快速测试:

ghci> firstTo 27
Just 999
ghci> firstTo 1
Just 1
ghci> firstTo 13
Just 49

将键映射到值

当处理某种集合中的数据时,我们通常不关心它的顺序;我们只想能够通过某个键来访问它。例如,如果我们想知道谁住在某个地址,我们希望根据地址查找名字。在这样做的时候,我们说我们通过某种键(那个人的地址)查找了我们想要的价值(某人的名字)。

几乎一样好:关联列表

有很多方法可以实现键/值映射。其中之一是关联列表。关联列表(也称为字典)是用于存储键/值对的列表,其中顺序不重要。例如,我们可能会使用关联列表来存储电话号码,其中电话号码是值,人的名字是键。我们不在乎它们存储的顺序;我们只想为正确的人找到正确的电话号码。

在 Haskell 中表示关联列表最明显的方式可能是通过一个对列表。对中的第一个组件将是键,第二个组件将是值。以下是一个包含电话号码的关联列表示例:

phoneBook =
    [("betty", "555-2938")
    ,("bonnie", "452-2928")
    ,("patsy", "493-2928")
    ,("lucille", "205-2928")
    ,("wendy", "939-8282")
    ,("penny", "853-2492")
    ]

尽管这个缩进看起来有些奇怪,但这实际上只是一个字符串对的列表。

处理关联列表时最常见的任务是按键查找某个值。让我们创建一个按键查找值的函数。

findKey :: (Eq k) => k -> [(k, v)] -> v
findKey key xs = snd . head . filter (\(k, v) -> key == k) $ xs

这相当简单。该函数接收一个键和一个列表,过滤列表,只保留匹配的键,获取第一个匹配的键/值对,并返回值。

但如果我们正在寻找的键不在关联列表中会发生什么呢?嗯。在这里,如果一个键不在关联列表中,我们最终会尝试获取一个空列表的头部,这会抛出一个运行时错误。我们应该避免让我们的程序如此容易崩溃,所以让我们使用 Maybe 数据类型。如果我们找不到键,我们将返回 Nothing。如果我们找到了它,我们将返回 Just something,其中 something 是与该键对应的值。

findKey :: (Eq k) => k -> [(k, v)] -> Maybe v
findKey key [] = Nothing
findKey key ((k,v):xs)
    | key == x  = Just v
    | otherwise = findKey key xs

看看类型声明。它接受一个可以等价的键和一个关联列表,然后可能产生一个值。听起来很合理。

这是一个教科书级别的递归函数,它作用于一个列表。基本情况,将列表拆分为头部和尾部,递归调用——所有这些都在这里。这是经典的折叠模式,那么让我们看看这会如何作为一个折叠来实现。

findKey :: (Eq k) => k -> [(k, v)] -> Maybe v
findKey key xs = foldr (\(k, v) acc -> if key == k then Just v else acc) Nothing xs

注意

通常使用折叠来处理这种标准的列表递归模式比显式地编写递归更好,因为它们更容易阅读和识别。当看到 foldr 调用时,每个人都知道这是一个折叠,但阅读显式递归则需要更多的思考。

ghci> findKey "penny" phoneBook
Just "853-2492"
ghci> findKey "betty" phoneBook
Just "555-2938"
ghci> findKey "wilma" phoneBook
Nothing

这效果非常好!如果我们有女孩的电话号码,我们 Just 就能得到这个号码;否则,我们得到 Nothing

进入 Data.Map

我们刚刚实现了 Data.List 中的 lookup 函数。如果我们想要与键对应的值,我们需要遍历列表的所有元素,直到找到它。

无标题的图片

结果表明,Data.Map 模块提供了比关联列表快得多的关联列表,并且它还提供了很多实用函数。从现在开始,我们将说我们在使用 映射 而不是关联列表。

由于 Data.Map 导出的函数与 PreludeData.List 中的函数冲突,我们将进行限定导入。

import qualified Data.Map as Map

将这个 import 语句放入脚本中,然后通过 GHCi 加载脚本。

我们将通过使用 Data.Map 中的 fromList 函数将关联列表转换为映射。fromList 函数接收一个关联列表(以列表的形式)并返回一个具有相同关联的映射。让我们首先对 fromList 进行一些实验:

ghci> Map.fromList [(3,"shoes"),(4,"trees"),(9,"bees")]
fromList [(3,"shoes"),(4,"trees"),(9,"bees")]
ghci> Map.fromList [("kima","greggs"),("jimmy","mcnulty"),("jay","landsman")]
fromList [("jay","landsman"),("jimmy","mcnulty"),("kima","greggs")]

Data.Map 的映射在终端上显示时,它显示为 fromList,然后是一个表示映射的关联列表,尽管它不再是列表了。

如果原始关联列表中有重复的键,这些重复的键将被简单地丢弃:

ghci> Map.fromList [("MS",1),("MS",2),("MS",3)]
fromList [("MS",3)]

这是 fromList 的类型签名:

Map.fromList :: (Ord k) => [(k, v)] -> Map.Map k v

它表示它接受一个类型为 kv 的键值对列表,并返回一个映射,该映射将类型为 k 的键映射到类型为 v 的值。请注意,当我们使用正常列表进行关联列表时,键只需要是可等的(它们的类型属于 Eq 类型类),但现在它们必须是可排序的。这是 Data.Map 模块中的一个基本约束。它需要键是可排序的,这样它可以更有效地排列和访问它们。

现在,我们可以修改我们的原始 phoneBook 关联列表,使其成为一个映射。我们还将添加一个类型声明,只是因为我们可以:

import qualified Data.Map as Map

phoneBook :: Map.Map String String
phoneBook = Map.fromList $
    [("betty", "555-2938")
    ,("bonnie", "452-2928")
    ,("patsy", "493-2928")
    ,("lucille", "205-2928")
    ,("wendy", "939-8282")
    ,("penny", "853-2492")
    ]

太酷了!让我们把这个脚本加载到 GHCi 中,并玩一下我们的 phoneBook。首先,我们将使用 lookup 来搜索一些电话号码。lookup 接受一个键和一个映射,并尝试在映射中找到相应的值。如果成功,它返回一个包裹在 Just 中的值;否则,它返回一个 Nothing

ghci> :t Map.lookup
Map.lookup :: (Ord k) => k -> Map.Map k a -> Maybe a
ghci> Map.lookup "betty" phoneBook
Just "555-2938"
ghci> Map.lookup "wendy" phoneBook
Just "939-8282"
ghci> Map.lookup "grace" phoneBook
Nothing

对于我们的下一个技巧,我们将通过插入一个号码来创建一个新的从 phoneBook 映射。insert 接受一个键、一个值和一个映射,并返回一个新的映射,它与旧映射相同,但键和值被插入:

ghci> :t Map.insert
Map.insert :: (Ord k) => k -> a -> Map.Map k a -> Map.Map k a
ghci> Map.lookup "grace" phoneBook
Nothing
ghci> let newBook = Map.insert "grace" "341-9021" phoneBook
ghci> Map.lookup "grace" newBook
Just "341-9021"

让我们检查一下我们有多少个数字。我们将使用来自 Data.Mapsize 函数,它接受一个映射并返回其大小。这很简单:

ghci> :t Map.size
Map.size :: Map.Map k a -> Int
ghci> Map.size phoneBook
6
ghci> Map.size newBook
7

无标题的图片

我们电话簿中的号码以字符串的形式表示。假设我们更愿意使用 Int 的列表来表示电话号码。所以,我们不想有一个像 "939-8282" 这样的号码,我们想要 [9,3,9,8,2,8,2]。首先,我们将创建一个函数,将电话号码字符串转换为 Int 的列表。我们可以尝试将 digitToIntData.Char 映射到我们的字符串,但它不知道如何处理破折号!这就是为什么我们需要从字符串中去除任何不是数字的东西。为此,我们将寻求 Data.Char 中的 isDigit 函数的帮助,它接受一个字符并告诉我们它是否代表一个数字。一旦我们过滤了我们的字符串,我们只需将其映射到 digitToInt 上即可。

string2digits :: String -> [Int]
string2digits = map digitToInt . filter isDigit

哦,如果你还没有做的话,请务必 import Data.Char

让我们试试这个:

ghci> string2digits "948-9282"
[9,4,8,9,2,8,2]

非常酷!现在,让我们使用 Data.Map 中的 map 函数将 string2digits 映射到我们的 phoneBook 上:

ghci> let intBook = Map.map string2digits phoneBook
ghci> :t intBook
intBook :: Map.Map String [Int]
ghci> Map.lookup "betty" intBook
Just [5,5,5,2,9,3,8]

Data.Map 中的 map 函数接受一个函数和一个映射,并将该函数应用于映射中的每个值。

让我们扩展我们的电话簿。假设一个人可以有多个号码,并且我们已经设置了一个这样的关联列表:

phoneBook =
    [("betty", "555-2938")
    ,("betty", "342-2492")
    ,("bonnie", "452-2928")
    ,("patsy", "493-2928")
    ,("patsy", "943-2929")
    ,("patsy", "827-9162")
    ,("lucille", "205-2928")
    ,("wendy", "939-8282")
    ,("penny", "853-2492")
    ,("penny", "555-2111")
    ]

如果我们只是使用fromList将它们放入映射中,我们会丢失一些数字!相反,我们将使用Data.Map中找到的另一个函数:fromListWith。这个函数的作用类似于fromList,但它不会丢弃重复的键,而是使用提供给它的函数来决定如何处理它们。

phoneBookToMap :: (Ord k) => [(k, String)] -> Map.Map k String
phoneBookToMap xs = Map.fromListWith add xs
    where add number1 number2 = number1 ++ ", " ++ number2

如果fromListWith发现键已经存在,它将使用提供给它的函数将这两个值合并成一个,并用它通过传递冲突的值给函数得到的新值替换旧值:

ghci> Map.lookup "patsy" $ phoneBookToMap phoneBook
"827-9162, 943-2929, 493-2928"
ghci> Map.lookup "wendy" $ phoneBookToMap phoneBook
"939-8282"
ghci> Map.lookup "betty" $ phoneBookToMap phoneBook
"342-2492, 555-2938"

我们也可以首先将关联列表中的所有值都变成单元素列表,然后使用++来合并数字:

phoneBookToMap :: (Ord k) => [(k, a)] -> Map.Map k [a]
phoneBookToMap xs = Map.fromListWith (++) $ map (\(k, v) -> (k, [v])) xs

让我们在 GHCi 中测试这个:

ghci> Map.lookup "patsy" $ phoneBookToMap phoneBook
["827-9162","943-2929","493-2928"]

非常整洁!

现在假设我们正在从一个数字的关联列表中创建一个映射,并且当发现重复的键时,我们想要保留键的最大值。我们可以这样做:

ghci> Map.fromListWith max [(2,3),(2,5),(2,100),(3,29),(3,22),(3,11),(4,22),(4,15)]
fromList [(2,100),(3,29),(4,22)]

或者我们也可以选择将具有相同键的值相加:

ghci> Map.fromListWith (+) [(2,3),(2,5),(2,100),(3,29),(3,22),(3,11),(4,22),(4,15)]
fromList [(2,108),(3,62),(4,37)]

所以,你已经看到了Data.Map和 Haskell 提供的其他模块相当酷。接下来,我们将看看如何创建自己的模块。

创建我们的模块

正如我在本章开头所说,当你编写程序时,将具有相似目的的功能和类型放入一个单独的模块中是一种良好的实践。这样,你只需导入你的模块,就可以轻松地在其他程序中重用这些函数。

无标题图片

我们说一个模块导出函数。当你导入一个模块时,你可以使用它导出的函数。模块还可以定义它内部使用的函数,但我们只能看到和使用它导出的那些。

几何模块

为了演示,我们将创建一个提供一些用于计算几个几何对象体积和面积的函数的小模块。我们将首先创建一个名为Geometry.hs的文件。

在模块的开头,我们指定模块名称。如果我们有一个名为Geometry.hs的文件,那么我们应该将我们的模块命名为Geometry。我们指定它导出的函数,然后我们可以添加函数。所以我们将从以下内容开始:

module Geometry
( sphereVolume
, sphereArea
, cubeVolume
, cubeArea
, cuboidArea
, cuboidVolume
) where

如您所见,我们将为球体、立方体和长方体计算面积和体积。球体就像葡萄柚一样是圆形的,立方体就像骰子,而(长方形的)长方体就像一盒香烟。(孩子们,不要吸烟!)

现在让我们定义我们的函数:

module Geometry
( sphereVolume
, sphereArea
, cubeVolume
, cubeArea
, cuboidArea
, cuboidVolume
) where

sphereVolume :: Float -> Float
sphereVolume radius = (4.0 / 3.0) * pi * (radius ^ 3)

sphereArea :: Float -> Float
sphereArea radius = 4 * pi * (radius ^ 2)

cubeVolume :: Float -> Float
cubeVolume side = cuboidVolume side side side

cubeArea :: Float -> Float
cubeArea side = cuboidArea side side side

cuboidVolume :: Float -> Float -> Float -> Float
cuboidVolume a b c = rectArea a b * c

cuboidArea :: Float -> Float -> Float -> Float
cuboidArea a b c = rectArea a b * 2 + rectArea a c * 2 +
rectArea c b * 2

rectArea :: Float -> Float -> Float
rectArea a b = a * b

这是一种相当标准的几何,但有几个需要注意的事项。一个是由于立方体只是长方体的特殊情况,我们通过将其视为所有边长都相同的立方体来定义其面积和体积。我们还定义了一个名为rectArea的辅助函数,它根据边的长度计算矩形的面积。它相当简单,因为它只是乘法。注意我们在模块中的函数(在cuboidAreacuboidVolume中)使用了它,但我们没有导出它!这是因为我们希望我们的模块只提供处理三维对象的函数。

在创建模块时,我们通常只导出那些作为我们模块接口的函数,以便隐藏实现细节。使用我们的Geometry模块的人不需要关心我们没有导出的函数。我们可以决定完全更改这些函数或在更新的版本中删除它们(我们可以删除rectArea并仅使用*),因为一开始我们没有导出它们,所以没有人会介意。

要使用我们的模块,我们只需这样做:

import Geometry

然而,Geometry.hs必须放在导入它的模块所在的同一个文件夹中。

层次化模块

模块也可以具有层次结构。每个模块可以包含多个子模块,这些子模块也可以有自己的子模块。让我们将几何函数分类,使Geometry成为一个包含三个子模块的模块:每个对象类型一个。

首先,我们将创建一个名为Geometry的文件夹。在其中,我们将放置三个文件:Sphere.hsCuboid.hsCube.hs。让我们看看每个文件包含什么。

下面是Sphere.hs的内容:

module Geometry.Sphere
( volume
, area
) where

volume :: Float -> Float
volume radius = (4.0 / 3.0) * pi * (radius ^ 3)

area :: Float -> Float
area radius = 4 * pi * (radius ^ 2)

Cuboid.hs文件看起来像这样:

module Geometry.Cuboid
( volume
, area
) where

volume :: Float -> Float -> Float -> Float
volume a b c = rectArea a b * c

area :: Float -> Float -> Float -> Float
area a b c = rectArea a b * 2 + rectArea a c * 2 + rectArea c b * 2

rectArea :: Float -> Float -> Float
rectArea a b = a * b

我们最后一个文件,Cube.hs,包含以下内容:

module Geometry.Cube
( volume
, area
) where

import qualified Geometry.Cuboid as Cuboid

volume :: Float -> Float
volume side = Cuboid.volume side side side

area :: Float -> Float
area side = Cuboid.area side side side

注意我们是如何将Sphere.hs放在名为Geometry的文件夹中,并将模块名称定义为Geometry.Sphere。我们对立方体和长方体对象也做了同样的事情。还要注意,在所有三个子模块中,我们定义了具有相同名称的函数。我们可以这样做,因为它们在不同的模块中。

因此,现在我们可以这样做:

无标题图片

import Geometry.Sphere

然后我们可以调用areavolume,它们会给出球体的面积和体积。

如果我们要处理两个或更多这样的模块,我们需要进行限定导入,因为它们导出了具有相同名称的函数。以下是一个例子:

import qualified Geometry.Sphere as Sphere
import qualified Geometry.Cuboid as Cuboid
import qualified Geometry.Cube as Cube

然后我们可以调用Sphere.areaSphere.volumeCuboid.area等等,每个都会计算相应对象的面积或体积。

下次当你发现自己正在编写一个非常大且有很多函数的文件时,寻找那些服务于某些共同目的的函数,并考虑将它们放入自己的模块中。这样,当你编写需要一些相同功能性的程序时,你就可以直接导入你的模块了。

第七章. 创建我们自己的类型和类型类

到目前为止,我们已经遇到了很多数据类型:BoolIntCharMaybe等等。但我们如何创建自己的呢?在本章中,你将学习如何创建自定义类型并将它们投入使用!

无标题图片

定义新的数据类型

要创建我们自己的类型,可以使用data关键字。让我们看看标准库中Bool类型是如何定义的。

data Bool = False | True

使用这种方式使用data关键字意味着正在定义一个新的数据类型。等号之前的部分表示类型,在这种情况下是Bool。等号之后的部分是值构造函数。它们指定了该类型可以具有的不同值。|读作“或”。因此,我们可以这样理解:Bool类型可以有TrueFalse的值。请注意,类型名和值构造函数都必须以大写字母开头。

以类似的方式,我们可以将Int类型视为如下定义:

data Int = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | 2 | ... | 2147483647

第一个和最后一个值构造函数是Int可能的最小和最大值。实际上并不是这样定义的——你可以看到我省略了一堆数字,但这对于说明目的很有用。

现在让我们思考一下如何在 Haskell 中表示形状。一种方法就是使用元组。一个圆可以表示为(43.1, 55.0, 10.4),其中前两个字段是圆心的坐标,第三个字段是半径。问题是这些也可以代表一个 3D 向量或任何可以用三个数字识别的东西。一个更好的解决方案是创建我们自己的类型来表示形状。

形状塑造

假设一个形状可以是圆形或矩形。这里有一个可能的定义:

data Shape = Circle Float Float Float | Rectangle Float Float Float Float

这是什么意思呢?可以这样想:Circle值构造函数有三个字段,它们接受浮点数。因此,当我们编写值构造函数时,我们可以选择性地在其后添加一些类型,这些类型定义了它将包含的值的类型。在这里,前两个字段是它的中心的坐标,第三个字段是它的半径。Rectangle值构造函数有四个字段,接受浮点数。前两个字段作为其左上角的坐标,后两个字段作为其右下角的坐标。

值构造函数实际上是最终返回数据类型值的函数。让我们看看这两个值构造函数的类型签名。

ghci> :t Circle
Circle :: Float -> Float -> Float -> Shape
ghci> :t Rectangle
Rectangle :: Float -> Float -> Float -> Float -> Shape

所以值构造函数就像其他一切一样是函数。谁能想到呢?数据类型中的字段作为其值构造函数的参数。

现在让我们创建一个函数,它接受一个Shape并返回其面积。

area :: Shape -> Float
area (Circle _ _ r) = pi * r ^ 2
area (Rectangle x1 y1 x2 y2) = (abs $ x2 - x1) * (abs $ y2 - y1)

首先,注意类型声明。它表示该函数接受一个 Shape 并返回一个 Float。我们无法编写 Circle -> Float 的类型声明,因为 Circle 不是一个类型,而 Shape 是(就像我们无法编写类型声明为 True -> Int 的函数一样)。

接下来,注意我们可以对构造函数进行模式匹配。我们已经在 []False5 等值上这样做过了,但那些值没有任何字段。在这种情况下,我们只需写一个构造函数,然后将它的字段绑定到名称上。因为我们只对半径感兴趣,所以我们实际上不关心前两个字段,它们告诉我们圆在哪里。

ghci> area $ Circle 10 20 10
314.15927
ghci> area $ Rectangle 0 0 100 100
10000.0

哈哈,它工作了!但如果我们尝试从提示符中直接打印出Circle 10 20 5,我们会得到一个错误。这是因为 Haskell 还不知道如何将我们的数据类型显示为字符串(目前还不行)。记住,当我们尝试从提示符中打印一个值时,Haskell 首先应用 show 函数来获取我们值的字符串表示,然后将其打印到终端。

要使我们的 Shape 类型成为 Show 类型类的一部分,我们修改它如下:

data Shape = Circle Float Float Float | Rectangle Float Float Float Float
     deriving (Show)

我们现在不会过多关注 deriving。让我们这样说,如果我们在一个数据声明的末尾添加 deriving (Show)(它可以在同一行或下一行——这无关紧要),Haskell 会自动使该类型成为 Show 类型类的一部分。我们将在 派生实例 中更详细地了解 deriving

所以现在我们可以这样做:

ghci> Circle 10 20 5
Circle 10.0 20.0 5.0
ghci> Rectangle 50 230 60 90
Rectangle 50.0 230.0 60.0 90.0

值构造函数是函数,因此我们可以将它们映射、部分应用等等。如果我们想要一个具有不同半径的同心圆列表,我们可以这样做:

ghci> map (Circle 10 20) [4,5,6,6]
[Circle 10.0 20.0 4.0,Circle 10.0 20.0 5.0,Circle 10.0 20.0 6.0,Circle 10.0
20.0 6.0]

使用点数据类型改进形状

我们的数据类型很好,但可以更好。让我们定义一个中间数据类型,它定义了二维空间中的一个点。然后我们可以使用它来使我们的形状更容易理解。

data Point = Point Float Float deriving (Show)
data Shape = Circle Point Float | Rectangle Point Point deriving (Show)

注意,当我们定义一个点时,我们使用了相同名称的数据类型和值构造函数。这没有特殊含义,尽管如果只有一个值构造函数,这是常见的。所以现在 Circle 有两个字段:一个是 Point 类型,另一个是 Float 类型。这使得理解什么是什么是容易的。同样适用于 Rectangle。现在我们需要调整我们的 area 函数来反映这些变化。

area :: Shape -> Float
area (Circle _ r) = pi * r ^ 2
area (Rectangle (Point x1 y1) (Point x2 y2)) = (abs $ x2 - x1) * (abs $ y2 - y1)

我们唯一需要改变的是模式。我们在 Circle 模式中忽略了整个点。在 Rectangle 模式中,我们只是使用嵌套模式匹配来获取点的字段。如果我们出于某种原因需要引用点本身,我们可以使用 as-patterns。

现在我们可以测试我们的改进版本:

ghci> area (Rectangle (Point 0 0) (Point 100 100))
10000.0
ghci> area (Circle (Point 0 0) 24)
1809.5574

那么一个推动形状的函数怎么样?它接受一个形状,它在 x 轴上移动的量,以及它在 y 轴上移动的量。它返回一个新的形状,具有相同的尺寸,但位于其他位置。

nudge :: Shape -> Float -> Float -> Shape
nudge (Circle (Point x y) r) a b = Circle (Point (x+a) (y+b)) r
nudge (Rectangle (Point x1 y1) (Point x2 y2)) a b
    = Rectangle (Point (x1+a) (y1+b)) (Point (x2+a) (y2+b))

这相当直接。我们将推力量加到表示形状位置的点上。让我们测试一下:

ghci> nudge (Circle (Point 34 34) 10) 5 10
Circle (Point 39.0 44.0) 10.0

如果我们不希望直接处理点,我们可以创建一些辅助函数,这些函数在零坐标处创建一些形状,然后推动它们。

首先,让我们编写一个函数,它接受一个半径并创建一个位于坐标系原点的圆,半径为我们提供的半径:

baseCircle :: Float -> Shape
baseCircle r = Circle (Point 0 0) r

现在让我们编写一个函数,它接受宽度和高度,并使用这些尺寸创建一个矩形,其左下角位于原点:

baseRect :: Float -> Float -> Shape
baseRect width height = Rectangle (Point 0 0) (Point width height)

现在我们可以使用这些函数来创建位于坐标系原点的形状,然后将它们推动到我们想要的位置,这使得创建形状变得更容易:

ghci> nudge (baseRect 40 100) 60 23
Rectangle (Point 60.0 23.0) (Point 100.0 123.0)

在模块中导出我们的形状

您还可以在自定义模块中导出您的数据类型。为此,只需写出您要导出的类型以及您要导出的函数,然后添加一些括号,指定您想要导出的值构造函数,并用逗号分隔。如果您想导出给定类型的所有值构造函数,只需写两个点(..)。

假设我们想在模块中导出我们的形状函数和类型。我们开始是这样的:

module Shapes
( Point(..)
, Shape(..)
, area
, nudge
, baseCircle
, baseRect
) where

通过使用Shape(..),我们导出了Shape的所有值构造函数。这意味着导入我们模块的人可以使用RectangleCircle值构造函数来创建形状。这与写Shape (Rectangle, Circle)相同,但更简洁。

此外,如果我们决定稍后向我们的类型添加一些值构造函数,我们不需要修改导出。这是因为使用..会自动导出给定类型的所有值构造函数。

或者,我们也可以选择不通过在导出语句中只写Shape而不带括号来导出Shape的任何值构造函数。这样,导入我们模块的人只能通过使用我们在模块中提供的辅助函数baseCirclebaseRect来创建形状。

记住,值构造函数只是接受字段作为参数并返回某种类型(如Shape)值的函数。因此,当我们选择不导出它们时,我们阻止导入我们模块的人直接使用这些值构造函数。不导出我们数据类型的值构造函数使它们更加抽象,因为我们隐藏了它们的实现。此外,使用我们模块的人不能对值构造函数进行模式匹配。如果我们希望导入我们模块的人只能通过我们在模块中提供的辅助函数与我们类型交互,这是好的。这样,他们就不需要了解我们模块的内部细节,只要我们导出的函数行为相同,我们就可以随时更改这些细节。

Data.Map 使用这种方法。你不能直接使用其值构造函数来创建映射,无论它是什么,因为它没有被导出。然而,你可以通过使用辅助函数(如 Map.fromList)之一来创建映射。负责 Data.Map 的人可以在不破坏现有程序的情况下更改映射的内部表示方式。

但是对于更简单的数据类型,导出值构造函数也是完全可以接受的。

记录语法

现在让我们看看我们如何创建另一种类型的数据类型。假设我们被要求创建一个描述人的数据类型。我们想要存储关于这个人的信息包括名字、姓氏、年龄、身高、电话号码和最喜欢的冰淇淋口味。(我不知道你,但这是我想要了解的所有关于一个人的信息。)让我们试试看!

无标题的图片

data Person = Person String String Int Float String String deriving (Show)

第一个字段是名字,第二个是姓氏,第三个是年龄,以此类推。现在让我们创建一个人。

ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
ghci> guy
Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"

这有点酷,尽管稍微有点难以阅读。

现在如果我们想创建函数来获取关于一个人的特定信息呢?我们需要一个函数来获取某个人的名字,一个函数来获取某个人的姓氏,等等。好吧,我们需要像这样定义它们:

firstName :: Person -> String
firstName (Person firstname _ _ _ _ _) = firstname

lastName :: Person -> String
lastName (Person _ lastname _ _ _ _) = lastname

age :: Person -> Int
age (Person _ _ age _ _ _) = age

height :: Person -> Float
height (Person _ _ _ height _ _) = height

phoneNumber :: Person -> String
phoneNumber (Person _ _ _ _ number _) = number

flavor :: Person -> String
flavor (Person _ _ _ _ _ flavor) = flavor

呼呼!我确实不喜欢写这个!但尽管写起来非常繁琐且 无聊,这种方法是有效的。

ghci> let guy = Person "Buddy" "Finklestein" 43 184.2 "526-2928" "Chocolate"
ghci> firstName guy
"Buddy"
ghci> height guy
184.2
ghci> flavor guy
"Chocolate"

“然而,肯定还有更好的方法!”你说。好吧,不,没有,抱歉。开个玩笑——确实有。哈哈哈!

Haskell 给我们提供了另一种编写数据类型的方法。以下是使用 记录语法 实现相同功能的方法:

data Person = Person { firstName :: String
                     , lastName :: String
                     , age :: Int
                     , height :: Float
                     , phoneNumber :: String
                     , flavor :: String } deriving (Show)

所以,我们不是简单地一个接一个地命名字段类型,并用空格分隔它们,而是使用花括号。首先,我们写字段名(例如,firstName),然后是双冒号(::),接着是类型。结果的数据类型完全相同。使用这种语法的最大好处是它创建了一些查找数据类型中字段的函数。通过使用记录语法来创建这种数据类型,Haskell 自动创建了这些函数:firstNamelastNameageheightphoneNumberflavor。看看这个例子:

ghci> :t flavor
flavor :: Person -> String
ghci> :t firstName
firstName :: Person -> String

使用记录语法还有一个好处。当我们为类型推导 Show 时,如果我们使用记录语法来定义和实例化类型,它将以不同的方式显示。

假设我们有一个表示汽车的类型。我们想要跟踪制造它的公司、型号名称以及它的生产年份。我们可以不使用记录语法来定义这个类型,如下所示:

data Car = Car String String Int deriving (Show)

汽车是这样显示的:

ghci> Car "Ford" "Mustang" 1967
Car "Ford" "Mustang" 1967

现在让我们看看使用记录语法定义会发生什么:

data Car = Car { company :: String
               , model :: String
               , year :: Int
               } deriving (Show)

我们可以像这样创建一辆汽车:

ghci> Car {company="Ford", model="Mustang", year=1967}
Car {company = "Ford", model = "Mustang", year = 1967}

当制造一辆新车时,我们不需要将字段按正确的顺序放置,只要我们列出所有字段即可。但是,如果我们不使用记录语法,我们必须按顺序指定它们。

当构造函数有几个字段且不清楚哪个字段是哪个时,使用记录语法。如果我们通过 data Vector = Vector Int Int Int 来创建一个三维向量数据类型,那么字段就是向量的分量,这很显然。然而,在我们的 PersonCar 类型中,字段并不那么明显,我们极大地受益于使用记录语法。

类型参数

值构造函数可以接受一些参数并生成一个新的值。例如,Car 构造函数接受三个值并生成一个 car 值。以类似的方式,类型构造函数可以接受类型作为参数来生成新的类型。一开始这可能会听起来有些过于抽象,但实际上并不复杂。(如果你熟悉 C++ 中的模板,你会看到一些相似之处。)为了清楚地了解类型参数在实际中的应用,让我们看看我们之前遇到的一个类型是如何实现的。

data Maybe a = Nothing | Just a

无标题的图片

这里的 a 是类型参数。由于涉及到类型参数,我们称 Maybe类型构造函数。根据我们想要这个数据类型在非 Nothing 时持有的内容,这个类型构造函数最终可以生成 Maybe IntMaybe CarMaybe String 等类型。没有任何值可以有 Maybe 的类型,因为那不是一个类型——它是一个类型构造函数。为了使这成为一个真正的类型,一个值可以成为其一部分,它必须填充所有类型参数。

因此,如果我们把 Char 作为类型参数传递给 Maybe,我们得到一个 Maybe Char 类型。例如,值 Just 'a' 的类型是 Maybe Char

大多数时候,我们不会显式地将类型作为参数传递给类型构造函数。这是因为 Haskell 有类型推断。所以当我们创建一个值 Just 'a' 时,例如,Haskell 会推断出它是一个 Maybe Char

如果我们想显式地传递一个类型作为类型参数,我们必须在 Haskell 的类型部分中这样做,这通常是在 :: 符号之后。例如,如果我们想让 Just 3 的类型是 Maybe Int,这会很有用。默认情况下,Haskell 会推断出该值的类型为 (Num a) => Maybe a。我们可以使用显式的类型注解来稍微限制一下类型:

ghci> Just 3 :: Maybe Int
Just 3

你可能不知道,在我们使用 Maybe 之前,我们已经使用了一个具有类型参数的类型:列表类型。尽管有一些语法糖,但列表类型接受一个参数来生成一个具体类型。值可以有 [Int] 类型、[Char] 类型或 [[String]] 类型,但你不能有一个只有 [] 类型的值。

注意

我们说一个类型是 具体的,如果它根本不接受任何类型参数(比如 IntBool),或者如果它接受类型参数并且它们都被填充了(比如 Maybe Char)。如果你有一个值,它的类型始终是一个具体类型。

让我们玩一玩 Maybe 类型:

ghci> Just "Haha"
Just "Haha"
ghci> Just 84
Just 84
ghci> :t Just "Haha"
Just "Haha" :: Maybe [Char]
ghci> :t Just 84
Just 84 :: (Num a) => Maybe a
ghci> :t Nothing
Nothing :: Maybe a
ghci> Just 10 :: Maybe Double
Just 10.0

类型参数是有用的,因为它们允许我们创建可以持有不同类型的数据类型。例如,我们可以为它可能包含的每个类型创建一个单独的类似 Maybe 的数据类型,如下所示:

data IntMaybe = INothing | IJust Int

data StringMaybe = SNothing | SJust String

data ShapeMaybe = ShNothing | ShJust Shape

但更好的是,我们可以使用类型参数来创建一个通用的 Maybe,它可以包含任何类型的值!

注意,Nothing 的类型是 Maybe a。它的类型是 多态的,这意味着它具有类型变量,即 Maybe a 中的 a。如果某个函数需要一个 Maybe Int 作为参数,我们可以给它一个 Nothing,因为 Nothing 本身不包含任何值,所以这无关紧要。Maybe a 类型可以在必要时充当 Maybe Int,就像 5 可以充当 IntDouble 一样。同样,空列表的类型是 [a]。空列表可以充当任何类型的列表。这就是为什么我们可以这样做 [1,2,3] ++ []["ha","ha","ha"] ++ []

我们是否应该对 Car 进行参数化?

在什么情况下使用类型参数是有意义的?通常,当我们的数据类型无论其持有的值的类型如何都能正常工作时,我们会使用它们,就像我们的 Maybe a 类型一样。如果我们的类型充当某种类型的盒子,那么使用参数是好的。

考虑我们的 Car 数据类型:

data Car = Car { company :: String
               , model :: String
               , year :: Int
               } deriving (Show)

我们可以将其修改为如下:

data Car a b c = Car { company :: a
                     , model :: b
                     , year :: c
                     } deriving (Show)

但我们真的会从中受益吗?可能不会,因为我们只会定义出只对 Car String String Int 类型起作用的函数。例如,根据我们对 Car 的第一个定义,我们可以创建一个函数,以易于阅读的格式显示汽车属性。

tellCar :: Car -> String
tellCar (Car {company = c, model = m, year = y}) =
    "This " ++ c ++ " " ++ m ++ " was made in " ++ show y

我们可以像这样测试它:

ghci> let stang = Car {company="Ford", model="Mustang", year=1967}
ghci> tellCar stang
"This Ford Mustang was made in 1967"

这是一个很好的小函数!类型声明很可爱,而且它工作得很好。

现在假设 CarCar a b c 呢?

tellCar :: (Show a) => Car String String a -> String
tellCar (Car {company = c, model = m, year = y}) =
    "This " ++ c ++ " " ++ m ++ " was made in " ++ show y

我们需要强制这个函数接受一个 (Show a) => Car String String a 类型的 Car。你可以看到类型签名更复杂,唯一的实际好处是我们可以使用任何 Show 类型类的实例作为 c 的类型:

ghci> tellCar (Car "Ford" "Mustang" 1967)
"This Ford Mustang was made in 1967"
ghci> tellCar (Car "Ford" "Mustang" "nineteen sixty seven")
"This Ford Mustang was made in \"nineteen sixty seven\""
ghci> :t Car "Ford" "Mustang" 1967
Car "Ford" "Mustang" 1967 :: (Num t) => Car [Char] [Char] t
ghci> :t Car "Ford" "Mustang" "nineteen sixty seven"
Car "Ford" "Mustang" "nineteen sixty seven" :: Car [Char] [Char] [Char]

然而,在现实生活中,我们大多数时候会使用 Car String String Int。所以,对 Car 类型进行参数化并不值得。

我们通常在数据类型内部的各种值构造函数所包含的类型对类型本身的工作并不重要时使用类型参数。一堆东西是一堆东西,而这堆东西的类型并不重要。如果我们需要求和一堆数字,我们可以在求和函数中指定我们具体想要一个数字列表。对于 Maybe 也是如此,它表示要么什么都没有,要么有一个东西。那个东西的类型并不重要。

你已经遇到的一个参数化类型的例子是 Data.Map 中的 Map k vk 是映射中键的类型,而 v 是值的类型。这是一个类型参数非常有用的好例子。对映射进行参数化使我们能够将任何类型映射到任何其他类型,只要键的类型是 Ord 类型类的一部分。如果我们正在定义一个映射类型,我们可以在数据声明中添加一个类型类约束:

data (Ord k) => Map k v = ...

然而,在 Haskell 中,永远不在数据声明中添加类型类约束是一个非常强的约定。为什么?好吧,因为它提供的好处不多,我们最终会写出更多的类约束,即使我们不需要它们。如果我们将 Ord k 约束放入 Map k v 的数据声明中,我们仍然需要在假设映射中的键可以排序的函数中放入约束。如果我们不在数据声明中放入约束,那么我们就不需要在那些不关心键是否可以排序的函数的类型声明中放入 (Ord k) =>。这样一个函数的例子是 toList,它只是将映射转换为一个关联列表。它的类型签名是 toList :: Map k a -> [(k, a)]。如果 Map k v 在其数据声明中有一个类型约束,toList 的类型就需要是 toList :: (Ord k) => Map k a -> [(k, a)],即使这个函数并不按顺序比较键。

所以不要在数据声明中放入类型约束,即使看起来似乎有道理。无论如何,你都需要将它们放入函数类型声明中。

Vector von Doom

让我们实现一个三维向量类型,并为它添加一些操作。我们将使其成为一个参数化类型,因为尽管它通常将包含数值类型,但它仍然支持多种类型,例如 IntIntegerDouble,仅举几个例子。

data Vector a = Vector a a a deriving (Show)

vplus :: (Num a) => Vector a -> Vector a -> Vector a
(Vector i j k) `vplus` (Vector l m n) = Vector (i+l) (j+m) (k+n)

dotProd :: (Num a) => Vector a -> Vector a -> a
(Vector i j k) `dotProd` (Vector l m n) = i*l + j*m + k*n

vmult :: (Num a) => Vector a -> a -> Vector a
(Vector i j k) `vmult` m = Vector (i*m) (j*m) (k*m)

想象一个向量在空间中就像一个箭头——一条指向某处的线。向量 Vector 3 4 5 将是一条从三维空间中的坐标 (0,0,0) 开始并指向坐标 (3,4,5) 的线。

向量函数的工作方式如下:

  • vplus 函数将两个向量相加。这是通过将它们的对应分量相加来完成的。当你将两个向量相加时,你会得到一个与将第二个向量放在第一个向量的末尾然后从第一个向量的开始到第二个向量的末尾画一条向量相同的向量。所以将两个向量相加的结果是第三个向量。

  • dotProd 函数获取两个向量的点积。点积的结果是一个数字,我们通过成对乘以向量的分量并将它们全部相加来得到它。当我们想要找出两个向量之间的角度时,两个向量的点积非常有用。

  • vmult 函数将一个向量与一个数字相乘。如果我们用一个数字乘以一个向量,我们将向量的每个分量与该数字相乘,实际上会延长(或缩短)它,但仍然指向大致相同的方向。

这些函数可以作用于任何形式为 Vector a 的类型,只要 aNum 类型类的实例。例如,它们可以作用于 Vector IntVector IntegerVector Float 等类型的值,因为 IntIntegerFloat 都是 Num 类型类的实例。然而,它们不能作用于 Vector CharVector Bool 类型的值。

此外,如果你检查这些函数的类型声明,你会发现它们只能作用于相同类型的向量,并且涉及的数字也必须是向量中包含的类型。我们不能将 Vector IntVector Double 相加。

注意,我们没有在数据声明中放置 Num 类约束。正如前一小节所解释的,即使我们放置了它,我们仍然需要在函数中重复它。

再次强调,区分类型构造器和值构造器非常重要。在声明数据类型时,= 前的部分是类型构造器,而其后的构造器(可能由 | 字符分隔)是值构造器。例如,给一个函数以下类型是错误的:

Vector a a a -> Vector a a a -> a

这不工作是因为我们向量的类型是 Vector a,而不是 Vector a a a。它只接受一个类型参数,尽管它的值构造器有三个字段。

现在,让我们来玩一下我们的向量。

ghci> Vector 3 5 8 `vplus` Vector 9 2 8
Vector 12 7 16
ghci> Vector 3 5 8 `vplus` Vector 9 2 8 `vplus` Vector 0 2 3
Vector 12 9 19
ghci> Vector 3 9 7 `vmult` 10
Vector 30 90 70
ghci> Vector 4 9 5 `dotProd` Vector 9.0 2.0 4.0
74.0
ghci> Vector 2 9 3 `vmult` (Vector 4 9 5 `dotProd` Vector 9 2 4)
Vector 148 666 222

派生实例

在 Type Classes 101 中,你了解到类型类是一种接口,它定义了一些行为,并且如果一个类型支持这种行为,它可以成为类型类的一个实例。例如,Int 类型是 Eq 类型类的一个实例,因为 Eq 类型类定义了可以相等的行为。由于整数可以相等,Int 成为了 Eq 类型类的一部分。真正的实用性来自于作为 Eq 接口的函数,即 ==/=。如果一个类型是 Eq 类型类的一部分,我们就可以使用该类型的值来使用 == 函数。这就是为什么 4 == 4"foo" == "bar" 这样的表达式可以类型检查的原因。

无标题图片

Haskell 类型类经常与 Java、Python、C++等语言中的类混淆,这让很多程序员感到困惑。在这些语言中,类是从中创建可以执行某些操作的对象的蓝图。但我们在 Haskell 类型类中并不创建数据。相反,我们首先创建我们的数据类型,然后考虑它如何行动。如果它可以像可以等同的东西一样行动,我们就让它成为Eq类型类的实例。如果它可以像可以排序的东西一样行动,我们就让它成为Ord类型类的实例。

让我们看看 Haskell 如何自动使我们的类型成为以下类型类的实例:EqOrdEnumBoundedShowRead。如果我们使用deriving关键字来创建我们的数据类型,Haskell 可以推导出这些上下文中我们类型的行怍。

等同人物

考虑这个数据类型:

data Person = Person { firstName :: String
                     , lastName :: String
                     , age :: Int
                     }

它描述了一个人物。让我们假设没有两个人的名字、姓氏和年龄组合是相同的。如果我们有两个人的记录,查看他们是否代表同一个人是否有意义?当然有意义。我们可以尝试将它们等同起来,看看它们是否相等。这就是为什么这种类型成为Eq类型类的一部分是有意义的。我们将推导出实例。

data Person = Person { firstName :: String
                     , lastName :: String
                     , age :: Int
                     } deriving (Eq)

当我们为一个类型推导出Eq实例,然后尝试使用==/=比较该类型的两个值时,Haskell 会检查值构造函数是否匹配(这里只有一个值构造函数),然后它会通过使用==测试每一对字段来检查包含在其中的所有数据是否匹配。然而,有一个问题:所有字段的类型也必须是Eq类型类的一部分。但由于StringInt都是这种情况,所以我们没问题。

首先,让我们创建一些人。将以下内容放入脚本中:

mikeD = Person {firstName = "Michael", lastName = "Diamond", age = 43}
adRock = Person {firstName = "Adam", lastName = "Horovitz", age = 41}
mca = Person {firstName = "Adam", lastName = "Yauch", age = 44}

现在让我们测试我们的Eq实例:

ghci> mca == adRock
False
ghci> mikeD == adRock
False
ghci> mikeD == mikeD
True
ghci> mikeD == Person {firstName = "Michael", lastName = "Diamond", age = 43}
True

当然,由于Person现在在Eq中,我们可以将其用作所有在类型签名中有Eq a类约束的函数的a,例如elem

ghci> let beastieBoys = [mca, adRock, mikeD]
ghci> mikeD `elem` beastieBoys
True

展示如何读取

ShowRead类型类是为了可以转换为或从字符串转换的事物。与Eq一样,如果类型的构造函数有字段,它们的类型必须是ShowRead的一部分,如果我们想使我们的类型成为它们的实例。

让我们把Person数据类型也变成ShowRead的一部分。

data Person = Person { firstName :: String
                     , lastName :: String
                     , age :: Int
                     } deriving (Eq, Show, Read)

现在我们可以将一个人打印到终端。

ghci> mikeD
Person {firstName = "Michael", lastName = "Diamond", age = 43}
ghci> "mikeD is: " ++ show mikeD
"mikeD is: Person {firstName = \"Michael\", lastName = \"Diamond\", age = 43}"

如果我们在将Person数据类型变成Show的一部分之前在终端上打印一个人,Haskell 会抱怨,声称它不知道如何将一个人表示为字符串。但由于我们首先为数据类型推导出一个Show实例,所以我们没有收到任何抱怨。

Read 几乎是 Show 的逆类型类。它是用于将字符串转换为我们的类型的值。但请记住,当我们使用 read 函数时,我们可能需要使用显式的类型注解来告诉 Haskell 我们想要得到的结果类型。为了演示这一点,让我们将表示一个人的字符串放入脚本中,然后在 GHCi 中加载该脚本:

mysteryDude = "Person { firstName =\"Michael\"" ++
                     ", lastName =\"Diamond\"" ++
                     ", age = 43}"

我们像这样将字符串跨越多行,以提高可读性。如果我们想 read 那个字符串,我们需要告诉 Haskell 我们期望返回哪种类型:

ghci> read mysteryDude :: Person
Person {firstName = "Michael", lastName = "Diamond", age = 43}

如果我们稍后以 Haskell 可以推断出它应该将其视为人的方式使用我们的 read 结果,我们就不需要使用类型注解。

ghci> read mysteryDude == mikeD
True

我们也可以读取参数化类型,但我们必须给 Haskell 足够的信息,以便它可以确定我们想要哪种类型。如果我们尝试以下操作,我们会得到一个错误:

ghci> read "Just 3" :: Maybe a

在这种情况下,Haskell 不知道应该为类型参数 a 使用哪种类型。但如果我们告诉它我们想要它是一个 Int,它就可以正常工作:

ghci> read "Just 3" :: Maybe Int
Just 3

法庭秩序!

我们可以为 Ord 类型类推导实例,它是用于具有可排序值的类型的。如果我们比较使用不同构造函数制作的同一类型的两个值,则首先定义的值被认为是较小的。例如,考虑 Bool 类型,它可以具有 FalseTrue 的值。为了了解它在比较时的行为,我们可以将其视为如下实现:

data Bool = False | True deriving (Ord)

因为 False 值构造函数先指定,而 True 值构造函数在其后指定,我们可以认为 True 大于 False

ghci> True `compare` False
GT
ghci> True > False
True
ghci> True < False
False

如果两个值使用相同的构造函数创建,它们被认为是相等的,除非它们有字段。如果有字段,则比较字段以确定哪个更大。(注意,在这种情况下,字段的类型也必须是 Ord 类型类的成员。)

Maybe a 数据类型中,Nothing 值构造函数在 Just 值构造函数之前指定,因此 Nothing 的值始终小于 Just something 的值,即使那个“something”是一万亿。但如果我们指定两个 Just 值,那么它将比较它们内部的内容。

ghci> Nothing < Just 100
True
ghci> Nothing > Just (-49999)
False
ghci> Just 3 `compare` Just 2
GT
ghci> Just 100 > Just 50
True

然而,我们不能做类似 Just (*3) > Just (*2) 的事情,因为 (*3)(*2) 是函数,它们不是 Ord 的实例。

任何一周的日子

我们可以轻松地使用代数数据类型来制作枚举,EnumBounded 类型类帮助我们做到这一点。考虑以下数据类型:

data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday

因为所有类型的值构造函数都是零元(即它们没有任何字段),我们可以将其作为 Enum 类型类的一部分。Enum 类型类是用于有前驱和后继的事物。我们还可以将其作为 Bounded 类型类的一部分,它是用于有最低可能值和最高可能值的事物。而且,既然我们提到了这一点,让我们也使其成为所有其他可推导类型类的实例。

data Day = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday
            deriving (Eq, Ord, Show, Read, Bounded, Enum)

现在,让我们看看我们如何使用我们新的Day类型。因为它属于ShowRead类型类,我们可以将此类型的值转换为字符串,反之亦然。

ghci> Wednesday
Wednesday
ghci> show Wednesday
"Wednesday"
ghci> read "Saturday" :: Day
Saturday

因为它是EqOrd类型类的一部分,我们可以比较或等同日期。

ghci> Saturday == Sunday
False
ghci> Saturday == Saturday
True
ghci> Saturday > Friday
True
ghci> Monday `compare` Wednesday
LT

它也是Bounded的一部分,因此我们可以获取最低和最高的日期。

ghci> minBound :: Day
Monday
ghci> maxBound :: Day
Sunday

由于它是Enum的一个实例,我们可以获取日期的前驱和后继,并从它们中创建列表范围!

ghci> succ Monday
Tuesday
ghci> pred Saturday
Friday
ghci> [Thursday .. Sunday]
[Thursday,Friday,Saturday,Sunday]
ghci> [minBound .. maxBound] :: [Day]
[Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday]

类型别名

如前所述,在编写类型时,[Char]String类型是等效的,可以互换。这是通过类型别名实现的。

类型别名本身并不真正做任何事情——它们只是给一些类型赋予不同的名称,以便让阅读我们的代码和文档的人更容易理解。以下是标准库如何将String定义为[Char]的别名的示例:

无标题图片

type String = [Char]

这里的type关键字可能具有误导性,因为并没有创建一个新的类型(这是通过data关键字完成的)。相反,这定义了一个现有类型的别名。

如果我们创建一个将字符串转换为大写的函数并命名为toUpperString,我们可以给它以下类型声明:

toUpperString :: [Char] -> [Char]

或者,我们可以使用以下类型声明:

toUpperString :: String -> String.

这两者本质上相同,但后者更易于阅读。

使我们的电话簿更美观

当我们处理Data.Map模块时,我们首先用一个关联列表(键/值对的列表)来表示电话簿,然后再将其转换为映射。以下是那个版本:

phoneBook :: [(String, String)]
phoneBook =
    [("betty", "555-2938")
    ,("bonnie", "452-2928")
    ,("patsy", "493-2928")
    ,("lucille", "205-2928")
    ,("wendy", "939-8282")
    ,("penny", "853-2492")
    ]

phoneBook的类型是[(String, String)]。这告诉我们它是一个将字符串映射到字符串的关联列表,但除此之外没有其他信息。让我们创建一个类型别名来在类型声明中传达更多信息。

type PhoneBook = [(String,String)]

现在电话簿的类型声明可以是phoneBook :: PhoneBook。让我们也为String创建一个类型别名。

type PhoneNumber = String
type Name = String
type PhoneBook = [(Name, PhoneNumber)]

Haskell 程序员在想要在函数中传达更多关于字符串信息时——即它们实际代表什么信息时,会给String类型赋予类型别名。

因此,现在,当我们实现一个接受一个名称和一个号码并检查该名称和号码组合是否在我们的电话簿中的函数时,我们可以给它一个非常漂亮且描述性的类型声明。

inPhoneBook :: Name -> PhoneNumber -> PhoneBook -> Bool
inPhoneBook name pnumber pbook = (name, pnumber) `elem` pbook

如果我们决定不使用类型别名,我们的函数将具有以下类型:

inPhoneBook :: String -> String -> [(String, String)] -> Bool

在这种情况下,利用类型别名的好处使得类型声明更容易理解。然而,你不应该过度使用这些别名。我们引入类型别名是为了描述某些现有类型在我们函数中的表示(因此我们的类型声明成为更好的文档)或者当某个类型很长且重复很多(如[(String, String)])但在我们函数的上下文中表示更具体的内容时。

参数化类型别名

类型同义词也可以是参数化的。如果我们想要一个表示关联列表类型的类型,但仍然希望它是通用的,以便可以使用任何类型作为键和值,我们可以这样做:

type AssocList k v = [(k, v)]

现在有一个通过键在关联列表中获取值的函数,其类型可以是 (Eq k) => k -> AssocList k v -> Maybe vAssocList 是一个类型构造器,它接受两种类型并产生一个具体类型——例如,AssocList Int String

正如我们可以部分应用函数以获取新函数一样,我们也可以部分应用类型参数并从中获取新的类型构造器。当我们用太少的参数调用函数时,我们得到一个新的函数。同样,我们可以指定一个类型构造器,它具有太少的类型参数,并得到一个部分应用类型构造器。如果我们想要一个表示从整数到某种类型的映射(来自 Data.Map)的类型,我们可以这样做:

type IntMap v = Map Int v

或者我们可以这样做:

type IntMap = Map Int

无论哪种方式,IntMap 类型构造器都接受一个参数,即整数所指向的类型。

如果你打算尝试实现这个,你可能想要做 Data.Map 的有条件导入。当你进行有条件导入时,类型构造器也需要在模块名称之前。

type IntMap = Map.Map Int

确保你真正理解了类型构造器和值构造器之间的区别。仅仅因为我们创建了一个名为 IntMapAssocList 的类型同义词,并不意味着我们可以做像 AssocList [(1,2), (4,5),(7,9)] 这样的事情。它仅仅意味着我们可以使用不同的名称来引用其类型。我们可以做 [(1,2),(3,5),(8,9)] :: AssocList Int Int,这将使列表内部的数字假设为 Int 类型。然而,我们仍然可以用与任何正常整数对列表相同的方式使用该列表。

类型同义词(以及类型一般)只能在 Haskell 的类型部分中使用。Haskell 的类型部分包括数据声明和类型声明,以及在类型声明或类型注解中的 :: 之后。

向左走,然后向右走

另一个酷炫的数据类型,它接受两个类型作为其参数,是 Either a b 类型。这大致是如何定义的:

data Either a b = Left a | Right b deriving (Eq, Ord, Read, Show)

它有两个值构造器。如果使用 Left,则其内容类型为 a;如果使用 Right,则其内容类型为 b。因此,我们可以使用此类型来封装一个或另一个类型的值。当我们得到类型为 Either a b 的值时,我们通常会在 LeftRight 上进行模式匹配,并根据哪个匹配执行不同的操作。

ghci> Right 20
Right 20
ghci> Left "w00t"
Left "w00t"
ghci> :t Right 'a'
Right 'a' :: Either a Char
ghci> :t Left True
Left True :: Either Bool b

在此代码中,当我们检查 Left True 的类型时,我们看到类型是 Either Bool b。第一个类型参数是 Bool,因为我们用 Left 值构造器创建了我们的值,而第二个类型参数保持多态。这与 Nothing 值具有类型 Maybe a 的方式类似。

到目前为止,你主要看到Maybe a被用来表示可能失败的计算的结果。但有时,Maybe a还不够好,因为Nothing除了表明有失败之外,没有传达太多信息。对于只能以一种方式失败的功能,或者如果我们对它们如何或为什么失败不感兴趣,这是可以接受的。例如,Data.Map查找只有在键不在映射中时才会失败,所以我们确切地知道发生了什么。

然而,当我们对某个函数如何或为什么失败感兴趣时,我们通常使用Either a b的结果类型,其中a是一个可以告诉我们有关可能失败的信息的类型,而b是成功计算的类型。因此,错误使用Left值构造函数,结果使用Right

例如,假设一所高中有储物柜,以便学生有地方存放他们的 Guns N’ Roses 海报。每个储物柜都有一个密码组合。当学生需要分配一个储物柜时,他们会告诉储物柜管理员他们想要的储物柜号码,然后管理员会给他们密码。然而,如果有人已经在使用那个储物柜,学生就需要选择一个不同的储物柜。我们将使用Data.Map中的映射来表示储物柜。它将把储物柜号码映射到一个表示储物柜是否在使用以及储物柜密码的元组。

import qualified Data.Map as Map

data LockerState = Taken | Free deriving (Show, Eq)

type Code = String

type LockerMap = Map.Map Int (LockerState, Code)

我们引入一个新的数据类型来表示储物柜是被占用还是空闲的,并为储物柜密码创建了一个类型同义词。我们还为从整数到储物柜状态和密码的映射类型创建了一个类型同义词。

接下来,我们将创建一个在储物柜映射中搜索密码的函数。我们将使用Either String Code类型来表示我们的结果,因为我们的查找可能以两种方式失败:储物柜可能已被占用,在这种情况下我们无法得知密码,或者储物柜号码可能不存在。如果查找失败,我们只需使用一个String来指示发生了什么。

lockerLookup :: Int -> LockerMap -> Either String Code
lockerLookup lockerNumber map = case Map.lookup lockerNumber map of
    Nothing -> Left $ "Locker " ++ show lockerNumber ++ " doesn't exist!"
    Just (state, code) -> if state /= Taken
                            then Right code
                            else Left $ "Locker " ++ show lockerNumber
                                        ++ " is already taken!"

我们在映射中进行正常的查找。如果我们得到Nothing,我们返回一个类型为Left String的值,表示储物柜不存在。如果我们找到了它,然后我们进行额外的检查以查看储物柜是否在使用中。如果是,我们返回一个Left,表示它已经被占用。如果不是,我们返回一个类型为Right Code的值,在其中我们给学生提供正确的储物柜密码。实际上是一个Right String(它是一个Right [Char]),但我们添加了这个类型同义词来在类型声明中引入一些额外的文档。

这里有一个示例映射:

lockers :: LockerMap
lockers = Map.fromList
    [(100,(Taken, "ZD39I"))
    ,(101,(Free, "JAH3I"))
    ,(103,(Free, "IQSA9"))
    ,(105,(Free, "QOTSA"))
    ,(109,(Taken, "893JJ"))
    ,(110,(Taken, "99292"))
    ]

现在我们尝试查找一些储物柜密码。

ghci> lockerLookup 101 lockers
Right "JAH3I"
ghci> lockerLookup 100 lockers
Left "Locker 100 is already taken!"
ghci> lockerLookup 102 lockers
Left "Locker number 102 doesn't exist!"
ghci> lockerLookup 110 lockers
Left "Locker 110 is already taken!"
ghci> lockerLookup 105 lockers
Right "QOTSA"

我们本可以使用Maybe a来表示结果,但那样我们就不知道为什么无法获取密码。但现在我们的结果类型中包含了关于失败的信息。

递归数据结构

正如你所见,代数数据类型中的构造函数可以有多个字段(或者根本没有任何字段),并且每个字段都必须是某种具体类型。因此,我们可以在字段的类型中创建自身作为类型的类型!这意味着我们可以创建递归数据类型,其中某个类型的某个值包含该类型的值,而这些值又包含更多相同类型的值,以此类推。

想想这个列表:[5]。这仅仅是5:[]的语法糖。在:的左边有一个值;在右边有一个列表。在这种情况下,它是一个空列表。那么,关于列表[4,5]呢?嗯,它解糖后变成4:(5:[])。看看第一个:,我们可以看到它左边也有一个元素,右边是一个列表(5:[])。同样的规则也适用于像3:(4:(5:6:[]))这样的列表,它可以写成这样,也可以写成3:4:5:6:[](因为:是右结合的)或者[3,4,5,6]

无标题图片

列表可以是一个空列表,或者它可以通过:与另一个列表(可能是一个空列表)连接在一起。

让我们使用代数数据类型来实现我们自己的列表!

data List a = Empty | Cons a (List a) deriving (Show, Read, Eq, Ord)

这符合我们列表的定义。它要么是一个空列表,要么是一个头部和一些值的组合,以及一个列表。如果你对此感到困惑,你可能发现用记录语法理解它更容易。

data List a = Empty | Cons { listHead :: a, listTail :: List a}
    deriving (Show, Read, Eq, Ord)

你可能也对这里的Cons构造函数感到困惑。非正式地说,Cons:的另一个名字。在列表中,:实际上是一个构造函数,它接受一个值和另一个列表,并返回一个列表。换句话说,它有两个字段:一个字段是a的类型,另一个字段是List a的类型。

ghci> Empty
Empty
ghci> 5 `Cons` Empty
Cons 5 Empty
ghci> 4 `Cons` (5 `Cons` Empty)
Cons 4 (Cons 5 Empty)
ghci> 3 `Cons` (4 `Cons` (5 `Cons` Empty))
Cons 3 (Cons 4 (Cons 5 Empty))

我们以中缀方式调用了我们的Cons构造函数,这样你可以看到它就像:一样。Empty就像[],而4 Cons(5Cons Empty)就像4:(5:[])

改进我们的列表

我们可以定义使用仅使用特殊字符命名的函数,使其自动成为中缀函数。我们也可以用构造函数做同样的事情,因为它们只是返回数据类型的函数。然而,有一个限制:中缀构造函数必须以冒号开头。所以看看这个:

infixr 5 :-:
data List a = Empty | a :-: (List a) deriving (Show, Read, Eq, Ord)

首先,注意一个新的语法结构:固定性声明,它位于我们的数据声明上方。当我们定义函数作为操作符时,我们可以使用它来给它们一个固定性(但我们不必这样做)。固定性说明了操作符的绑定紧密程度以及它是左结合还是右结合。例如,*操作符的固定性是infixl 7 *,而+操作符的固定性是infixl 6。这意味着它们都是左结合的(换句话说,4 * 3 * 2(4 * 3) * 2相同),但*的绑定比+更紧密,因为它的固定性更高。所以5 * 4 + 3等价于(5 * 4) + 3

否则,我们只是写了a :-: (List a)而不是Cons a (List a)。现在,我们可以在我们的列表类型中这样写出列表:

ghci> 3 :-: 4 :-: 5 :-: Empty
3 :-: (4 :-: (5 :-: Empty))
ghci> let a = 3 :-: 4 :-: 5 :-: Empty
ghci> 100 :-: a
100 :-: (3 :-: (4 :-: (5 :-: Empty)))

让我们创建一个函数,将两个我们的列表相加。这就是 ++ 在普通列表中的定义方式:

infixr 5  ++
(++) :: [a] -> [a] -> [a]
[]     ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)

我们将直接将其纳入我们的列表。我们将该函数命名为 ^++

infixr 5  ^++
(^++) :: List a -> List a -> List a
Empty ^++ ys = ys
(x :-: xs) ^++ ys = x :-: (xs ^++ ys)

现在我们来试试看:

ghci> let a = 3 :-: 4 :-: 5 :-: Empty
ghci> let b = 6 :-: 7 :-: Empty
ghci> a ^++ b
3 :-: (4 :-: (5 :-: (6 :-: (7 :-: Empty))))

如果我们愿意,我们可以为我们自己的列表类型实现所有操作列表的函数。

注意我们是如何在 (x :-: xs) 上进行模式匹配的。这之所以有效,是因为模式匹配实际上是关于匹配构造函数的。我们可以匹配 :-:,因为它是我们自己的列表类型的构造函数,我们也可以匹配 :,因为它是我们内置列表类型的构造函数。同样适用于 []。因为模式匹配(只)在构造函数上工作,我们可以匹配正常的前缀构造函数或类似 8'a' 的东西,它们基本上是数值和字符类型的构造函数。

让我们种一棵树

为了更好地理解 Haskell 中的递归数据结构,我们将实现一个二叉搜索树。

在二叉搜索树中,一个元素指向两个元素——一个在其左侧,一个在其右侧。左侧的元素较小;右侧的元素较大。这些元素中的每一个也可以指向两个元素(或一个或没有)。实际上,每个元素最多有两个子树。

无标题的图片

二叉搜索树的一个有趣之处在于,我们知道,例如,5 的左子树中的所有元素都将小于 5。右子树中的元素将更大。所以如果我们需要查找 8 是否在我们的树中,我们从 5 开始,然后因为 8 大于 5,我们向右走。我们现在在 7,因为 8 大于 7,我们再次向右走。我们已经在三步之内找到了我们的元素!如果这是一个普通的列表(或者一棵树,但实际是不平衡的),我们需要走七步才能看到 8 是否在其中。

注意

Data.SetData.Map 中的集合和映射是通过树实现的,但它们使用的是 平衡 二叉搜索树,而不是普通的二叉搜索树。一棵树是平衡的,如果它的左右子树的高度大致相同。这使得通过树进行搜索更快。但在我们的例子中,我们只实现普通的二叉搜索树。

这里我们要说的是:一棵树要么是一个空树,要么是一个包含一些值和两个树的元素。这似乎非常适合代数数据类型!

data Tree a = EmptyTree | Node a (Tree a) (Tree a) deriving (Show)

我们不是手动构建一棵树,而是创建一个函数,它接受一棵树和一个元素,并将元素插入其中。我们通过比较新值与树的根节点来完成此操作。如果它小于根,我们向左走;如果它大于根,我们向右走。然后我们对每个后续节点做同样的操作,直到我们到达一个空树。一旦我们到达一个空树,我们就插入一个带有我们新值的节点。

在像 C 这样的语言中,我们会通过修改树内部的指针和值来完成这个操作。在 Haskell 中,我们不能直接修改我们的树,所以每次我们决定向左或向右移动时,都需要创建一个新的子树。最终,插入函数会返回一个全新的树,因为 Haskell 没有指针的概念,只有值。因此,我们的插入函数的类型将类似于 a -> Tree a -> Tree a。它接受一个元素和一个树,并返回一个包含该元素的新树。这看起来可能不太高效,但 Haskell 使得在旧树和新树之间共享大多数子树成为可能。

这里有两个用于构建树的函数:

singleton :: a -> Tree a
singleton x = Node x EmptyTree EmptyTree

treeInsert :: (Ord a) => a -> Tree a -> Tree a
treeInsert x EmptyTree = singleton x
treeInsert x (Node a left right)
    | x == a = Node x left right
    | x < a  = Node a (treeInsert x left) right
    | x > a  = Node a left (treeInsert x right)

singleton 是一个用于创建单节点树的实用函数(一个只有一个节点的树)。它只是一个创建一个将其根设置为某个值,并且有两个空子树的节点的快捷方式。

treeInsert 函数用于将一个元素插入到树中。在这里,我们首先有一个模式作为基本情况。如果我们到达了一个空子树,这意味着我们到达了想要去的地方,我们插入一个包含我们的元素的单一节点树。如果我们不是在空树中插入,那么我们需要做一些检查。首先,如果我们正在插入的元素等于根元素,我们只返回一个相同的树。如果它更小,我们返回一个具有相同根值和相同右子树的树,但用我们的值插入的树替换其左子树。如果我们值大于根元素,我们以相反的方式做同样的操作。

接下来,我们将创建一个函数来检查某个元素是否在树中:

treeElem :: (Ord a) => a -> Tree a -> Bool
treeElem x EmptyTree = False
treeElem x (Node a left right)
    | x == a = True
    | x < a  = treeElem x left
    | x > a  = treeElem x right

首先,我们定义基本的情况。如果我们在一个空树中寻找一个元素,那么它肯定不在那里。注意这与在列表中搜索元素时的基本情况是相同的。如果我们不在空树中寻找元素,那么我们需要检查一些事情。如果根节点中的元素是我们正在寻找的,那就太好了!如果不是,那会怎样?嗯,我们可以利用知道所有左边的元素都比根节点小的知识。如果我们正在寻找的元素小于根节点,我们检查它是否在左子树中。如果它更大,我们检查它是否在右子树中。

现在,让我们用我们的树来玩点有趣的东西!我们不会手动创建一个(尽管我们可以),我们将使用折叠从列表中构建一个树。记住,几乎可以使用折叠来实现遍历列表一次并返回值的任何操作!我们将从一个空树开始,然后从右向左遍历列表,并将元素逐个插入到我们的累加树中。

ghci> let nums = [8,6,4,1,7,3,5]
ghci> let numsTree = foldr treeInsert EmptyTree nums
ghci> numsTree
Node 5
    (Node 3
        (Node 1 EmptyTree EmptyTree)
        (Node 4 EmptyTree EmptyTree)
    )
    (Node 7
        (Node 6 EmptyTree EmptyTree)
        (Node 8 EmptyTree EmptyTree)
    )

注意

如果你在这个 GHCi 中运行它,numsTree 的结果将会打印成一行长字符串。在这里,它被拆分成多行;否则,它就会超出页面!

在这个 foldr 中,treeInsert 是折叠二进制函数(它接受一个树和一个列表元素,并产生一个新的树),而 EmptyTree 是起始累加器。nums 当然是我们正在折叠的列表。

当我们将树打印到控制台时,它不是很易读,但我们仍然可以辨认出它的结构。我们看到根节点是 5,并且它有两个子树:一个根节点为 3,另一个根节点为 7

我们还可以检查某些值是否包含在树中,如下所示:

ghci> 8 `treeElem` numsTree
True
ghci> 100 `treeElem` numsTree
False
ghci> 1 `treeElem` numsTree
True
ghci> 10 `treeElem` numsTree
False

如您所见,代数数据结构在 Haskell 中是一个非常酷且强大的概念。我们可以使用它们来创建从布尔值和星期几枚举到二叉搜索树等任何东西!

类型类 102

到目前为止,你已经了解了一些标准的 Haskell 类型类,并看到了它们包含哪些类型。你还学习了如何通过请求 Haskell 推导实例来自动创建自己的标准类型类的类型实例。本节解释了如何创建自己的类型类以及如何手动创建它们的类型实例。

快速类型类回顾:类型类有点像接口。类型类定义了一些行为(例如比较相等、比较排序和枚举)。可以以这种方式表现的类型是该类型类的实例。类型类的行为是通过定义函数或只是类型声明来实现的,然后我们实现它们。所以当我们说一个类型是类型类的实例时,我们的意思是我们可以使用类型类定义的函数与该类型一起使用。

无标题图片

注意

记住,类型类与 Java 或 Python 等语言中的类没有任何关系。这会让很多人感到困惑,所以我想你现在忘记所有关于命令式语言中类的知识!

Eq 类型类内部

例如,让我们看看 Eq 类型类。记住,Eq 是用于可以相等比较的值。它定义了函数 ==/=。如果我们有 Car 类型,并且使用相等函数 == 比较两个汽车是有意义的,那么 Car 成为 Eq 的实例也是有意义的。

这是标准库中定义 Eq 类的示例:

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool
    x == y = not (x /= y)
    x /= y = not (x == y)

哇!这里有一些奇怪的语法和关键字!

class Eq a where 表示正在定义一个新的类型类 Eqa 是类型变量,因此 a 将扮演即将成为 Eq 实例的类型角色。(它不需要被称为 a,甚至不需要是一个字母——它只需要全部小写即可。)

接下来,定义了几个函数。请注意,实现函数体本身不是强制性的;只需要它们的类型声明。在这里,实现了Eq定义的函数的函数体——通过相互递归定义。它表示,如果两个值的类型是Eq的实例,并且它们不相等,则它们相等;如果它们不相等,则它们不同。你很快就会看到这如何帮助我们。

在类型类中定义的函数的最终类型也值得注意。如果我们有,比如说,class Eq a where,然后在那个类中定义一个类型声明,比如(==) :: a -> a -> Bool,当我们稍后检查那个函数的类型时,它将具有(Eq a) => a -> a -> Bool的类型。

交通灯数据类型

因此,一旦我们有一个类,我们能用它做什么呢?我们可以创建该类的类型实例,并获得一些很好的功能。以这个类型为例:

data TrafficLight = Red | Yellow | Green

它定义了交通灯的状态。注意我们并没有为它推导任何类实例。那是因为我们打算手动编写一些实例。下面是如何将其制作成Eq的实例:

instance Eq TrafficLight where
    Red == Red = True
    Green == Green = True
    Yellow == Yellow = True
    _ == _ = False

我们是通过使用instance关键字做到这一点的。所以class是用来定义新的类型类,而instance是用来使我们的类型成为类型类的实例。当我们定义Eq时,我们写了class Eq a where,并且我们说a扮演着后来将被制作成实例的任何类型的角色。我们可以清楚地看到这一点,因为当我们制作实例时,我们写instance Eq TrafficLight where。我们将a替换为实际类型。

由于在类声明中==是通过/=定义的,反之亦然,因此我们只需要在实例声明中重写其中一个。这被称为类型类的最小完整定义——我们必须实现的函数的最小集合,以便我们的类型可以按照类所宣传的方式行为。为了满足Eq的最小完整定义,我们需要重写==/=之一。如果Eq简单地定义如下:

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool

在将类型制作成Eq的实例时,我们需要实现这两个函数,因为 Haskell 不知道这两个函数是如何相关的。那么,最小完整定义将是==/=

你可以看到我们通过模式匹配简单地实现了==。由于有许多更多的情况是两个灯不相等,我们指定了那些相等的,然后只是做了一个通配符模式,说如果它不是前面的任何组合,那么两个灯不相等。

让我们手动将这个实例化为Show。为了满足Show的最小完整定义,我们只需要实现它的show函数,该函数接收一个值并将其转换为字符串:

instance Show TrafficLight where
    show Red = "Red light"
    show Yellow = "Yellow light"
    show Green = "Green light"

再次,我们使用了模式匹配来实现我们的目标。让我们看看它是如何实际工作的:

ghci> Red == Red
True
ghci> Red == Yellow
False
ghci> Red `elem` [Red, Yellow, Green]
True
ghci> [Red, Yellow, Green]
[Red light,Yellow light,Green light]

我们本可以直接推导 Eq,它会产生相同的效果(但我们没有这样做是为了教育目的)。然而,推导 Show 会直接将值构造函数转换为字符串。如果我们想让灯光显示为 Red light,我们需要手动进行实例声明。

子类化

你也可以创建其他类型类的子类类型类。Num 类的声明有点长,但这里是第一部分:

class (Eq a) => Num a where
   ...

如前所述,有很多地方可以塞入类约束。所以这就像写 class Num a where 一样,但我们声明我们的类型 a 必须是 Eq 的一个实例。我们实际上是在说,在我们将其作为 Num 的实例之前,我们需要将类型作为 Eq 的实例。在某个类型被视为数字之前,能够确定该类型的值是否可以相等是有意义的。

子类化的内容就这么多——它只是对类声明的类约束!在类声明或实例声明中定义函数体时,我们可以假设 aEq 的一部分,因此我们可以使用 == 操作符来比较该类型的值。

参数化类型作为类型类的实例

Maybe 或列表类型是如何成为类型类实例的呢?Maybe 与例如 TrafficLight 的不同之处在于,Maybe 本身不是一个具体类型——它是一个接受一个类型参数(如 Char)以产生一个具体类型(如 Maybe Char)的类型构造器。让我们再次看看 Eq 类型类:

class Eq a where
    (==) :: a -> a -> Bool
    (/=) :: a -> a -> Bool
    x == y = not (x /= y)
    x /= y = not (x == y)

从类型声明中,我们看到 a 被用作一个具体类型,因为函数中的所有类型都必须是具体的。记住,你不能有一个类型为 a -> Maybe 的函数,但你 可以 有一个类型为 a -> Maybe aMaybe Int -> Maybe String 的函数。这就是为什么我们不能这样做:

instance Eq Maybe where
    ...

a 必须是一个具体类型,而 Maybe 不是;它是一个接受一个参数并然后 产生 一个具体类型的类型构造器。

如果我们需要为 Maybe 的类型参数可能采取的每个可能的类型都创建一个单独的实例,那将会很繁琐。如果我们需要编写 instance Eq (Maybe Int) whereinstance Eq (Maybe Char) where 以及为每个类型进行类似的操作,我们将一事无成。这就是为什么我们可以将参数留为一个类型变量,如下所示:

instance Eq (Maybe m) where
    Just x == Just y = x == y
    Nothing == Nothing = True
    _ == _ = False

这就像说,我们希望将所有形式为 Maybe something 的类型都成为 Eq 的实例。我们实际上可以写成 (Maybe something),但使用单个字母符合 Haskell 风格。

这里 (Maybe m) 的作用类似于 class Eq a where 中的 a。虽然 Maybe 不是一个具体类型,但 Maybe m 是。通过指定一个类型参数作为类型变量(m,它以小写形式出现),我们表示我们希望所有形式为 Maybe m 的类型,其中 m 是任何类型,都是 Eq 的一个实例。

然而,这里有一个问题。你能发现吗?我们在 Maybe 的内容上使用了 ==,但我们没有保证 Maybe 包含的内容可以用 Eq 使用!这就是我们修改实例声明的原因:

instance (Eq m) => Eq (Maybe m) where
    Just x == Just y = x == y
    Nothing == Nothing = True
    _ == _ = False

我们需要添加一个类约束!通过这个实例声明,我们表示我们希望所有形式为 Maybe m 的类型都属于 Eq 类型类,但只有那些 mMaybe 内部包含的内容)也是 Eq 类型一部分的类型。这正是 Haskell 会推导出实例的方式。

大多数情况下,类声明中的类约束用于将一个类型类作为另一个类型类的子类,而实例声明中的类约束用于表达对某些类型内容的约束。例如,在这里我们要求 Maybe 的内容也属于 Eq 类型类。

在创建实例时,如果你看到类型在类型声明中用作具体类型(如 a -> a -> Bool 中的 a),你需要提供类型参数并添加括号,以便最终得到一个具体类型。

请注意,你试图使其成为实例的类型将替换类声明中的参数。当你创建实例时,class Eq a where 中的 a 将被替换为一个实际类型,所以试着在函数类型声明中也将你的类型放入心中。以下类型声明实际上并没有太多意义:

(==) :: Maybe -> Maybe -> Bool

但这会这样做:

(==) :: (Eq m) => Maybe m -> Maybe m -> Bool

这只是想让你思考一下,因为 == 总是具有 (==) :: (Eq a) => a -> a -> Bool 的类型,无论我们创建什么实例。

哦,还有一件事:如果你想查看一个类型类的实例,只需在 GHCi 中输入 :info YourTypeClass。例如,输入 :info Num 将会显示类型类定义了哪些函数,并且会给你一个类型类中类型的列表。:info 对类型和类型构造器也有效。如果你输入 :info Maybe,它将显示 Maybe 是哪些类型类的实例。以下是一个例子:

ghci> :info Maybe
data Maybe a = Nothing | Just a -- Defined in Data.Maybe
instance (Eq a) => Eq (Maybe a) -- Defined in Data.Maybe
instance Monad Maybe -- Defined in Data.Maybe
instance Functor Maybe -- Defined in Data.Maybe
instance (Ord a) => Ord (Maybe a) -- Defined in Data.Maybe
instance (Read a) => Read (Maybe a) -- Defined in GHC.Read
instance (Show a) => Show (Maybe a) -- Defined in GHC.Show

一个是/否类型类

在 JavaScript 和一些其他弱类型语言中,你可以在 if 表达式中放置几乎任何东西。例如,在 JavaScript 中,你可以这样做:

if (0) alert("YEAH!") else alert("NO!")

或者像这样:

if ("") alert ("YEAH!") else alert("NO!")

或者像这样:

if (false) alert("YEAH!") else alert("NO!")

所有这些都会抛出一个 NO! 的警报。

然而,以下代码将给出一个 YEAH! 的警报,因为 JavaScript 将任何非空字符串视为真值:

if ("WHAT") alert ("YEAH!") else alert("NO!")

尽管在 Haskell 中严格使用 Bool 进行布尔语义处理效果更好,但让我们为了好玩尝试实现这种类似 JavaScript 的行为!我们将从一个类声明开始:

class YesNo a where
    yesno :: a -> Bool

这很简单。YesNo 类型类定义了一个函数。这个函数接受一个可以被认为是包含某些真值概念的类型的值,并肯定地告诉我们它是真是假。注意,从我们在函数中使用 a 的方式来看,a 必须是一个具体类型。

接下来,让我们定义一些实例。对于数字,我们将假设(就像在 JavaScript 中一样)任何不是 0 的数字在布尔上下文中都是真的,而 0 是假的。

instance YesNo Int where
    yesno 0 = False
    yesno _ = True

空列表(以及由此扩展的字符串)是一个否定的值,而非空列表是一个肯定的值。

instance YesNo [a] where
    yesno [] = False
    yesno _ = True

注意我们只是在那里放了一个类型参数 a,使列表成为一个具体类型,尽管我们没有对列表中包含的类型做出任何假设。

Bool 本身也包含真值和假值,哪个是真哪个是假非常明显:

instance YesNo Bool where
    yesno = id

id 是什么呢?它只是一个标准库函数,接受一个参数并返回相同的东西,这正是我们在这里要写的。

让我们把 Maybe a 也变成一个实例:

instance YesNo (Maybe a) where
    yesno (Just _) = True
    yesno Nothing = False

无标题图片

我们不需要类约束,因为我们没有对 Maybe 的内容做出任何假设。我们只是说,如果它是 Just 值,那么它就是有点真;如果它是 Nothing,那么它就是有点假。我们仍然需要写出 (Maybe a) 而不是仅仅 Maybe。如果你这么想,一个 Maybe -> Bool 函数是不存在的(因为 Maybe 不是一个具体类型),而一个 Maybe a -> Bool 是很好用的。尽管如此,这真的很酷,因为现在任何形式的 Maybe something 类型都是 YesNo 的一部分,而那“something”是什么并不重要。

之前,我们定义了一个 Tree a 类型,它表示一个二叉搜索树。我们可以这样说,空树是有点假的,而任何不是空树的都是有点真的:

instance YesNo (Tree a) where
    yesno EmptyTree = False
    yesno _ = True

交通灯可以是肯定或否定的值吗?当然可以。如果是红灯,你就停下。如果是绿灯,你就走。(如果是黄灯呢?嗯,我通常闯黄灯,因为我喜欢肾上腺素。)

instance YesNo TrafficLight where
    yesno Red = False
    yesno _ = True

现在我们有一些实例,让我们去玩玩吧!

ghci> yesno $ length []
False
ghci> yesno "haha"
True
ghci> yesno ""
False
ghci> yesno $ Just 0
True
ghci> yesno True
True
ghci> yesno EmptyTree
False
ghci> yesno []
False
ghci> yesno [0,0,0]
True
ghci> :t yesno
yesno :: (YesNo a) => a -> Bool

它工作得很好!

现在让我们创建一个模拟 if 语句的功能,但这个功能使用 YesNo 值。

yesnoIf :: (YesNo y) => y -> a -> a -> a
yesnoIf yesnoVal yesResult noResult =
    if yesno yesnoVal
        then yesResult
        else noResult

这个函数接受一个 YesNo 值和任何类型的两个值。如果 yes-no-ish 值更像是 yes,它就返回两个值中的第一个;否则,它就返回第二个。让我们试试:

ghci> yesnoIf [] "YEAH!" "NO!"
"NO!"
ghci> yesnoIf [2,3,4] "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf True "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf (Just 500) "YEAH!" "NO!"
"YEAH!"
ghci> yesnoIf Nothing "YEAH!" "NO!"
"NO!"

函数式类型类

到目前为止,我们已经遇到了标准库中的许多类型类。我们玩过 Ord,它是用于可排序的东西。我们与 Eq 一起玩耍,它是用于可以相等的东西。我们看到了 Show,它为可以以字符串形式显示值的类型提供了一个接口。我们的好朋友 Read 在我们需要将字符串转换为某些类型的值时总是存在。现在,我们将要看看 Functor 类型类,它是用于可以映射的东西。

无标题图片

你现在可能正在想列表,因为映射列表在 Haskell 中是一个如此占主导地位的习语。而且你是对的,列表类型是 Functor 类型类的一部分。

要了解 Functor 类型类,还有什么比看到它是如何实现的更好的方法呢?让我们看看。

class Functor f where
    fmap :: (a -> b) -> f a -> f b

我们可以看到它定义了一个函数fmap,并且没有为该函数提供任何默认实现。fmap的类型很有趣。到目前为止的类型类定义中,扮演类型类中类型角色的类型变量是一个具体类型,比如(==) :: (Eq a) => a -> a -> Bool中的a。但现在,f不是一个具体类型(一个值可以持有的类型,如IntBoolMaybe String),而是一个类型构造函数,它接受一个类型参数。(快速复习示例:Maybe Int是一个具体类型,但Maybe是一个接受一个类型作为参数的类型构造函数。)

我们可以看到fmap接受一个从一种类型到另一种类型的函数,以及一个应用了一种类型的函子值,并返回一个应用了另一种类型的函子值。如果这听起来有点令人困惑,不要担心——所有的一切都会在我们检查一些示例时很快揭晓。

嗯……fmap的类型声明让我想起了某件事。让我们看看map函数的类型签名:

map :: (a -> b) -> [a] -> [b]

啊,有趣!它接受一个从一种类型到另一种类型的函数和一个同一类型的列表,并返回另一个类型的列表。我的朋友们,我想我们找到了一个函子!实际上,map只是一个只在列表上工作的fmap。以下是列表如何成为Functor类型类的一个实例:

instance Functor [] where
    fmap = map

就这样!注意我们并没有写instance Functor [a] where。这是因为f必须是一个类型构造函数,它接受一个类型,我们可以在以下类型声明中看到这一点:

fmap :: (a -> b) -> f a -> f b

[a]已经是一个具体类型(包含任何类型的列表),而[]是一个类型构造函数,它接受一个类型并可以产生如[Int][String]或甚至[[String]]这样的类型。

由于对于列表来说,fmap就是map,因此当我们使用这些函数对列表进行操作时,我们会得到相同的结果:

ghci> fmap (*2) [1..3]
[2,4,6]
ghci> map (*2) [1..3]
[2,4,6]

当我们对空列表进行mapfmap操作时会发生什么?当然,我们会得到一个空列表。它将类型为[a]的空列表转换为类型为[b]的空列表。

也许作为一个函子

可以像盒子一样行动的类型可以是函子。你可以把列表想象成一个可以是空的或者里面可以包含某些东西的盒子,包括另一个盒子。那个盒子也可以是空的或者包含某些东西和另一个盒子,以此类推。那么,还有什么具有类似盒子的属性?首先,Maybe a类型。在某种程度上,它像一个可以容纳无物的盒子(在这种情况下,它具有Nothing的值),或者它可以包含一个项目(比如"HAHA",在这种情况下,它具有Just "HAHA"的值)。

以下是Maybe如何成为函子的例子:

instance Functor Maybe where
    fmap f (Just x) = Just (f x)
    fmap f Nothing = Nothing

再次注意,我们是如何写instance Functor Maybe where而不是instance Functor (Maybe m) where,就像我们在处理YesNo时做的那样。Functor需要一个只接受一个类型的类型构造函数,而不是一个具体类型。如果你在心理上用Maybe替换ffmap在这个特定类型上就像一个(a -> b) -> Maybe a -> Maybe b,这看起来是合理的。但是如果你用(Maybe m)替换f,那么它看起来就像一个(a -> b) -> Maybe m a -> Maybe m b,这是没有意义的,因为Maybe只接受一个类型参数。

fmap的实现相当简单。如果它是一个空的Nothing值,那么就简单地返回一个Nothing。如果我们映射一个空的盒子,我们得到一个空的盒子。如果我们映射一个空的列表,我们得到一个空的列表。如果不是空的值,而是一个包含在Just中的单个值,那么我们就在Just的内容上应用函数:

ghci> fmap (++ " HEY GUYS IM INSIDE THE JUST") (Just "Something serious.")
Just "Something serious. HEY GUYS IM INSIDE THE JUST"
ghci> fmap (++ " HEY GUYS IM INSIDE THE JUST") Nothing
Nothing
ghci> fmap (*2) (Just 200)
Just 400
ghci> fmap (*2) Nothing
Nothing

树也是函子

另一个可以被映射并成为Functor实例的是我们的Tree a类型。它可以被看作是一个盒子(它包含几个或没有值),而Tree类型构造函数恰好接受一个类型参数。如果你把fmap看作是专门为Tree设计的函数,它的类型签名将看起来像这样:(a -> b) -> Tree a -> Tree b

我们将在这个问题上使用递归。映射一个空的树将产生一个空的树。映射一个非空树将产生一个树,其根值被我们的函数应用,其左右子树将是之前的子树,但它们被映射了我们的函数。以下是代码:

instance Functor Tree where
    fmap f EmptyTree = EmptyTree
    fmap f (Node x left right) = Node (f x) (fmap f left) (fmap f right)

现在我们来测试一下:

ghci> fmap (*2) EmptyTree
EmptyTree
ghci> fmap (*4) (foldr treeInsert EmptyTree [5,7,3])
Node 20 (Node 12 EmptyTree EmptyTree) (Node 28 EmptyTree EmptyTree)

但是要小心!如果你使用Tree a类型来表示二叉搜索树,在映射函数之后,无法保证它仍然是二叉搜索树。要被认为是二叉搜索树,某个节点左侧的所有元素必须小于该节点的元素,而右侧的所有元素必须大于。但是如果你在二叉搜索树上映射一个像negate这样的函数,节点左侧的元素突然变得大于其元素,你的二叉搜索树就变成了一个普通的二叉树。

Either a作为函子

那么Either a b呢?它能成为一个函子吗?Functor类型类需要一个只接受一个类型参数的类型构造函数,但Either接受两个。嗯嗯……我知道,我们可以通过只提供参数来部分应用Either,这样它就有一个自由参数。

这里是如何在标准库中,更具体地说在Control.Monad.Instances模块中,将Either a定义为函子:

instance Functor (Either a) where
    fmap f (Right x) = Right (f x)
    fmap f (Left x) = Left x

好吧好吧,这里有什么?你可以看到Either a是如何被定义为实例而不是仅仅Either。这是因为Either a是一个类型构造函数,它接受一个参数,而Either接受两个。如果fmap专门为Either a设计,其类型签名将是这样的:

(b -> c) -> Either a b -> Either a c

因为这与以下内容相同:

(b -> c) -> (Either a) b -> (Either a) c

当值构造函数为Right时,函数会被映射,但在Left的情况下则不会映射。为什么是这样呢?好吧,回顾一下Either a b类型的定义,我们看到:

data Either a b = Left a | Right b

如果我们想要将一个函数映射到这两个上,ab需要是同一类型。想想看:如果我们尝试映射一个接受字符串并返回字符串的函数,而b是字符串但a是数字,这实际上是不会奏效的。此外,考虑到如果fmap只操作Either a b值,它的类型会是什么,我们可以看到第一个参数必须保持不变,而第二个参数可以改变,第一个参数由Left值构造函数实现。

如果我们将Left部分视为一个带有错误信息的空盒子,旁边写着为什么它是空的,这也很好地与我们的盒子类比相吻合。

Data.Map中的映射也可以被制作成函子值,因为它们持有值(或没有!)在Map k v的情况下,fmap将函数v -> v'映射到类型为Map k v的映射上,并返回类型为Map k v'的映射。

注意

单引号'在类型中没有任何特殊含义,就像它在命名值时没有特殊含义一样。它只是用来表示相似但略有不同的事物。

作为练习,你可以尝试自己弄清楚如何将Map k制作成Functor的实例!

如你所见,从例子中,Functor类型类可以表示相当酷的高阶概念。你也在部分应用类型和制作实例方面有了更多的实践。在第十一章(Chapter 11)中,我们将查看适用于函子的某些定律。

种类与一些类型相关概念

类型构造函数接受其他类型作为参数,最终产生具体类型。这种行为与函数类似,函数接受值作为参数以产生值。同样,类型构造函数也可以部分应用。例如,Either String是一个类型构造函数,它接受一个类型并产生一个具体类型,如Either String Int

无标题图片

在本节中,我们将正式定义类型如何应用于类型构造函数。你实际上不需要阅读本节就可以继续你的神奇 Haskell 之旅,但它可能有助于你了解 Haskell 的类型系统是如何工作的。而且,如果你现在还没有完全理解,那也没关系。

像这样的值3"YEAH"takeWhile(函数也是值——我们可以传递它们等)各自有自己的类型。类型是值携带的小标签,这样我们就可以对值进行推理。但是类型有自己的小标签,称为种类。种类基本上是类型的类型。这听起来可能有点奇怪和令人困惑,但实际上这是一个非常酷的概念。

那么,种类是什么,它们有什么用?好吧,让我们通过在 GHCi 中使用:k命令来检查类型的种类:

ghci> :k Int
Int :: *

那个 * 符号是什么意思?它表示类型是一个具体类型。具体类型是指不包含任何类型参数的类型。值只能具有具体类型的类型。如果我要大声读出 *(我还没有这样做过),我会说“星号”,或者简单地说是“类型”。

好的,现在让我们看看 Maybe 的种类是什么:

ghci> :k Maybe
Maybe :: * -> *

这种类型告诉我们 Maybe 类型构造函数接受一个具体类型(比如 Int)并返回一个具体类型(比如 Maybe Int)。就像 Int -> Int 表示一个函数接受一个 Int 并返回一个 Int 一样,* -> * 表示类型构造函数接受一个具体类型并返回一个具体类型。让我们将类型参数应用到 Maybe 上,看看那个类型的种类是什么:

ghci> :k Maybe Int
Maybe Int :: *

正如你可能预期的,我们将类型参数应用到 Maybe 上,得到了一个具体类型(这就是 * -> * 的意思)。与此类似(尽管并不等价——类型和种类是两回事)的是,如果我们调用 :t isUpper:t isUpper 'A'isUpper 函数的类型是 Char -> Bool,而 isUpper 'A' 的类型是 Bool,因为它的值基本上是 False。然而,这两个类型都有一个种类 *

我们使用 :k 在类型上获取其种类,就像我们可以使用 :t 在值上获取其类型一样。再次强调,类型是值的标签,种类是类型的标签,两者之间有相似之处。

现在,让我们看看 Either 的种类:

ghci> :k Either
Either :: * -> * -> *

这告诉我们 Either 接受两个具体类型作为类型参数来产生一个具体类型。它看起来也有些像函数类型声明,该函数接受两个值并返回某个东西。类型构造函数是柯里化的(就像函数一样),因此我们可以部分应用它们,就像你在这里看到的那样:

ghci> :k Either String
Either String :: * -> *
ghci> :k Either String Int
Either String Int :: *

当我们想要将 Either a 纳入 Functor 类型类时,我们需要部分应用它,因为 Functor 希望的是只接受一个参数的类型,而 Either 接受两个。换句话说,Functor 希望的是种类为 * -> * 的类型,因此我们需要部分应用 Either 来得到这个种类,而不是它原始的种类 * -> * -> *

再次查看 Functor 的定义,我们可以看到 f 类型变量被用作一个接受一个具体类型并产生一个具体类型的类型:

class Functor f where
    fmap :: (a -> b) -> f a -> f b

我们知道它必须产生一个具体类型,因为它被用作函数中值的类型。由此,我们可以推断出想要与 Functor 成为朋友的类型必须是种类为 * -> * 的类型。

第八章。输入和输出

在本章中,你将学习如何从键盘接收输入并将内容打印到屏幕上。

但首先,我们将介绍输入和输出(I/O)的基础知识:

  • 什么是 I/O 操作?

  • I/O 操作是如何使我们能够进行 I/O 的?

  • I/O 操作实际上何时执行?

处理 I/O 引发了对 Haskell 函数工作方式的约束问题,所以我们将看看我们如何绕过这个问题。

将纯与杂分离

到现在为止,你已经习惯了 Haskell 是一种纯函数式语言的事实。你不会给计算机一系列要执行的步骤,而是给它某些事物是什么的定义。此外,函数不允许有副作用。函数只能根据我们提供给它的参数返回一些结果。如果一个函数用相同的参数调用两次,它必须返回相同的结果。

虽然一开始这可能看起来有点限制,但实际上真的很酷。在命令式语言中,你无法保证一个简单的函数在处理数字时不会烧毁你的房子或绑架你的狗。例如,在前一章我们制作二叉搜索树时,我们并没有通过修改树本身来插入一个元素;相反,我们的函数实际上返回了一个的树,新元素被插入其中。

无标题图片

函数无法改变状态——例如更新全局变量——的事实是好的,因为它有助于我们推理程序。然而,这里有一个问题:如果一个函数不能改变世界中的任何东西,它如何告诉我们它计算了什么?为了做到这一点,它必须改变输出设备(通常是屏幕)的状态,然后发射光子到我们的大脑,这改变了我们的心态,伙计。

但别灰心,并非一切都已失去。Haskell 有一个非常巧妙的系统来处理具有副作用的功能。它巧妙地将我们程序的纯部分和杂部分分开,后者执行所有脏活,比如与键盘和屏幕交互。通过将这两部分分开,我们仍然可以推理我们的纯程序,并利用纯性提供的一切——比如惰性、健壮性和可组合性——同时轻松地与外界沟通。你将在本章中看到这一点是如何发挥作用的。

Hello, World!

到目前为止,我们总是将函数加载到 GHCi 中以测试它们。我们也以这种方式探索了标准库函数。现在我们终于要编写我们的第一个真正的 Haskell 程序了!太好了!而且确实如此,我们将做那个古老的 Hello, world!游戏。

无标题图片

首先,将以下内容输入你喜欢的文本编辑器:

main = putStrLn "hello, world"

我们刚刚定义了main,并在其中调用了一个名为putStrLn的函数,参数为"hello, world"。将文件保存为*helloworld.hs*

我们将要做一些以前从未做过的事情:编译我们的程序,这样我们就可以得到一个可执行的文件,我们可以运行它!打开你的终端,导航到*helloworld.hs*所在的目录,并输入以下内容:

$ ghc --make helloworld

这将调用 GHC 编译器并告诉它编译我们的程序。它应该报告如下:

[1 of 1] Compiling Main ( helloworld.hs, helloworld.o )
Linking helloworld ...

现在,你可以在终端输入以下内容来运行你的程序:

$ ./helloworld

注意

如果你使用的是 Windows,运行程序时不需要输入./helloworld,只需输入helloworld.exe即可。

我们程序打印出以下内容:

hello, world

然后就是这样——我们的第一个编译程序,它在终端上打印了一些内容。多么无聊啊!

让我们检查我们写了什么。首先,让我们看看函数putStrLn的类型:

ghci> :t putStrLn
putStrLn :: String -> IO ()
ghci> :t putStrLn "hello, world"
putStrLn "hello, world" :: IO ()

我们可以像这样读取putStrLn的类型:putStrLn接受一个字符串并返回一个I/O 操作,其结果类型为()(即空元组,也称为单元)。

一个 I/O 操作是指执行时会产生副作用(如读取输入或打印屏幕或文件中的内容)的操作,并且还会呈现一些结果。我们说 I/O 操作产生这个结果。将字符串打印到终端实际上并没有任何有意义的返回值,所以使用了一个虚拟值()

注意

空元组是值()`,它也有一个类型()。

那么 I/O 操作何时会被执行呢?嗯,这就是main发挥作用的地方。当我们将名称指定为main并运行我们的程序时,I/O 操作将被执行。

将 I/O 操作粘合在一起

整个程序只包含一个 I/O 操作似乎有点限制。这就是为什么我们可以使用do语法将多个 I/O 操作粘合在一起。看看下面的例子:

main = do
    putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn ("Hey " ++ name ++ ", you rock!")

哦,有趣——新的语法!这几乎就像一个命令式程序。如果你编译并运行它,它将表现得正如你所期望的那样。

注意,我们说了do,然后我们列出了一系列步骤,就像在一个命令式程序中做的那样。这些步骤中的每一个都是一个 I/O 操作。通过使用do语法将它们组合在一起,我们将它们粘合成了一个 I/O 操作。我们得到的行为类型为IO (),因为这是内部最后一个 I/O 操作的类型。正因为如此,main总是有一个类型签名main :: IO something,其中something是某个具体的类型。我们通常不会为main指定类型声明。

那第三行,声明name <- getLine,看起来它从输入读取一行并将其存储到名为name的变量中。它真的这样做吗?好吧,让我们检查getLine的类型。

ghci> :t getLine
getLine :: IO String

无标题的图片

我们可以看到getLine是一个 I/O 操作,它产生一个String。这很有道理,因为它会等待用户在终端输入某些内容,然后这些内容将以字符串的形式表示。

那么name <- getLine是怎么回事呢?你可以这样阅读这段代码:执行不纯操作getLine,然后将它的结果值绑定到name上。getLine的类型是IO String,所以name的类型将是String

你可以把不纯操作想象成一个有脚的盒子,它会走出现实世界并做些事情(比如在墙上涂鸦)并可能带回一些数据。一旦它为你获取了这些数据,唯一打开盒子并获取其中数据的方法就是使用<-构造。如果我们从不纯操作中取出数据,我们只能在另一个不纯操作中进行。这就是 Haskell 如何整洁地分离我们代码的纯和不纯部分。getLine是不纯的,因为它的结果值在两次执行时可能不会相同。

当我们做name <- getLine时,name只是一个普通字符串,因为它代表盒子里的内容。例如,我们可以有一个非常复杂的函数,它以你的名字(一个普通字符串)作为参数,并根据你的名字告诉你你的命运,就像这样:

main = do
    putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn $ "Zis is your future: " ++ tellFortune name

tellFortune函数(或者它传递name给的其他任何函数)不需要了解任何关于 I/O 的事情——它只是一个普通的String -> String函数!

要了解正常值和不纯操作的不同,考虑以下行。这是否有效?

nameTag = "Hello, my name is " ++ getLine

如果你说了不,就去吃一块饼干。如果你说了是,就喝一碗熔岩。 (只是开玩笑——别这么做!) 这行不通,因为++需要它的两个参数都是同一类型的列表。左边的参数类型是String(或者如果你愿意,是[Char]),而getLine的类型是IO String。记住,你不能将字符串和不纯操作连接起来。首先,你需要从不纯操作中获取结果以获得String类型的值,而唯一的方法是在另一个不纯操作中做类似name <- getLine的事情。

如果我们想要处理不纯数据,我们必须在不纯的环境中处理。不纯的污点就像不死生物的瘟疫一样四处蔓延,因此我们最好将代码中的 I/O 部分保持尽可能小。

每个执行的不纯操作都会产生一个结果。这就是为什么我们之前的例子也可以写成这样:

main = do
    foo <- putStrLn "Hello, what's your name?"
    name <- getLine
    putStrLn ("Hey " ++ name ++ ", you rock!")

然而,foo将只有一个()值,所以这样做可能有点多余。注意我们没有将最后的putStrLn绑定到任何东西上。这是因为在一个do块中,最后一个操作不能像前两个那样绑定到名字上。当我们进入第十三章(Chapter 13)的 monads 世界时,你会看到为什么是这样的。现在,重要的是do块会自动从最后一个操作中提取值,并将其作为自己的结果。

除了最后一行外,do块中不绑定的每一行也可以用绑定来写。所以putStrLn "BLAH"可以写成_ <- putStrLn "BLAH"。但这没有用,所以我们省略了<-,对于像putStrLn这样的不产生重要结果的 I/O 操作。

你认为当我们做类似以下操作时会发生什么?

myLine = getLine

你认为它会从输入中读取,并将那个值绑定到name上吗?不,它不会。这仅仅是将getLine I/O 操作赋予了一个不同的名字,叫做myLine。记住,要从 I/O 操作中获取值,你必须通过使用<-将其绑定到名字来在另一个 I/O 操作中执行它。

当 I/O 操作被赋予main这个名字,或者当它们在一个更大的 I/O 操作内部,这个 I/O 操作是通过do块组合的,I/O 操作将会执行。我们也可以使用do块将几个 I/O 操作粘合在一起,然后我们可以将这个 I/O 操作用在另一个do块中,依此类推。如果它们最终落入main中,它们将会执行。

当我们在 GHCi 中输入一个 I/O 操作并按回车键时,也会执行 I/O 操作:当我们将 I/O 操作在 GHCi 中输入并按回车键时,也会执行 I/O 操作。

ghci> putStrLn "HEEY"
HEEY

即使我们在 GHCi 中输入一个数字或调用一个函数并按回车键,GHCi 也会将show应用到结果值上,然后它会使用putStrLn将其打印到终端。

在 I/O 操作中使用let

当使用do语法将 I/O 操作粘合在一起时,我们可以使用let语法将纯值绑定到名字上。与<-用于执行 I/O 操作并将结果绑定到名字不同,let用于我们只想在 I/O 操作内部给正常值命名时。它与列表推导式中的let语法类似。

让我们看看一个使用<-let绑定名字的 I/O 操作的例子。

import Data.Char

main = do
    putStrLn "What's your first name?"
    firstName <- getLine
    putStrLn "What's your last name?"
    lastName <- getLine
    let bigFirstName = map toUpper firstName
        bigLastName = map toUpper lastName
    putStrLn $ "hey " ++ bigFirstName ++ " "
                      ++ bigLastName
                      ++ ", how are you?"

看看do块中的 I/O 操作是如何对齐的?也请注意let是如何与 I/O 操作对齐的,以及let的名字是如何彼此对齐的?这是良好的实践,因为在 Haskell 中缩进很重要。

我们编写了map toUpper firstName,它将像"John"这样的字符串转换成更酷的字符串"JOHN"。我们将这个大写后的字符串绑定到一个名字上,然后将其用于打印到终端的字符串中。

你可能想知道何时使用<-'和何时使用let绑定。<-'用于执行 I/O 操作并将它们的结果绑定到名称。然而,map toUpper firstName不是一个 I/O 操作——它是一个 Haskell 中的纯表达式。所以当你想要将 I/O 操作的结果绑定到名称时,你可以使用<-',而你可以使用let绑定将纯表达式绑定到名称。如果我们做了像let firstName = getLine这样的操作,我们只是将getLine I/O 操作命名为不同的名称,我们仍然需要通过<-'来执行它并绑定其结果。

反向操作

为了更好地了解在 Haskell 中进行 I/O,让我们编写一个简单的程序,该程序持续读取一行并打印出相同行中单词的逆序。当输入一个空行时,程序将停止执行。这是该程序:

main = do
    line <- getLine
    if null line
        then return ()
        else do
            putStrLn $ reverseWords line
            main

reverseWords :: String -> String
reverseWords = unwords . map reverse . words

要了解它做什么,将其保存为reverse.hs,然后编译并运行它:

$ ghc --make reverse.hs
[1 of 1] Compiling Main             ( reverse.hs, reverse.o )
Linking reverse ...
$ ./reverse
clean up on aisle number nine
naelc pu no elsia rebmun enin
the goat of error shines a light upon your life
eht taog fo rorre senihs a thgil nopu ruoy efil
it was all a dream
ti saw lla a maerd

我们的reverseWords函数只是一个普通函数。它接受一个如"hey there man"这样的字符串,并对其应用words以产生一个如["hey","there","man"]的单词列表。我们在这个列表上映射reverse,得到["yeh","ereht","nam"],然后我们使用unwords将其放回一个字符串中。最终结果是"yeh ereht nam"

那么main呢?首先,我们通过执行getLine从终端获取一行,并将其称为line。接下来,我们有一个条件表达式。记住,在 Haskell 中,每个if都必须有一个对应的else,因为每个表达式都必须有某种类型的值。我们的if表示当条件为真(在我们的情况下,我们输入的行是空的)时,我们执行一个 I/O 操作;当它不为真时,执行else下的 I/O 操作。

因为我们需要在else之后恰好有一个 I/O 操作,所以我们使用do块将两个 I/O 操作粘合在一起成为一个。我们也可以将这部分写成如下形式:

else (do
    putStrLn $ reverseWords line
    main)

这使得do块可以被视为一个 I/O 操作,但看起来更丑陋。

do块内部,我们将reverseWords应用于从getLine获取的行,然后将其打印到终端。之后,我们只是执行main。它是递归执行的,这是可以的,因为main本身就是一个 I/O 操作。所以从某种意义上说,我们又回到了程序的开始。

如果null lineTrue,则执行then之后的代码:return ()。你可能在其他语言中使用return关键字从子程序或函数返回。但在 Haskell 中,return与大多数其他语言中的return完全不同。

在 Haskell(特别是在 I/O 操作中),return将一个纯值转换为一个 I/O 操作。回到 I/O 操作的盒子类比,return取一个值并将其包裹在一个盒子中。结果 I/O 操作实际上并不做任何事情;它只是作为其结果提供那个值。所以在一个 I/O 上下文中,return "haha"的类型将是IO String

将纯值转换成不执行任何操作的 I/O 操作有什么意义呢?嗯,在空输入行的情况下,我们需要执行一些 I/O 操作。这就是为什么我们通过编写 return () 创建了一个不执行任何操作的虚假 I/O 操作。

与其他语言不同,使用 return 不会导致 I/O do 块在执行中结束。例如,这个程序会一直运行到最后一行:

main = do
    return ()
    return "HAHAHA"
    line <- getLine
    return "BLAH BLAH BLAH"
    return 4
    putStrLn line

再次,所有这些 return 的用途只是创建产生结果的 I/O 操作,然后因为它们没有被绑定到名字上而被丢弃。

我们可以使用 return<- 结合来将东西绑定到名字上:

main = do
    a <- return "hell"
    b <- return "yeah!"
    putStrLn $ a ++ " " ++ b

所以你看,return 有点像是 <- 的对立面。虽然 return 取一个值并将其包裹在一个盒子里,但 <- 取一个盒子(并执行它)然后从中取出值,并将其绑定到一个名字上。但这样做有点多余,特别是既然你可以在 do 块中使用 let 来绑定到名字,就像这样:

main = do
    let a = "hell"
        b = "yeah"
    putStrLn $ a ++ " " ++ b

在处理 I/O do 块时,我们通常使用 return 要么是因为我们需要创建一个不执行任何操作的 I/O 操作,要么是因为我们不希望由 do 块组成的 I/O 操作具有其最后操作的返回值。当我们希望它具有不同的返回值时,我们使用 return 来创建一个总是产生我们期望的结果的 I/O 操作,并将其放在末尾。

一些有用的 I/O 函数

Haskell 提供了许多有用的函数和 I/O 操作。让我们看看其中的一些,看看它们是如何使用的。

putStr

putStrputStrLn 很相似,因为它接受一个字符串作为参数,并返回一个将字符串打印到终端的 I/O 操作。然而,putStr 在打印字符串后不会跳到新的一行,而 putStrLn 会。例如,看看这段代码:

main = do
    putStr "Hey, "
    putStr "I'm "
    putStrLn "Andy!"

如果我们编译并运行这个程序,我们会得到以下输出:

Hey, I'm Andy!

putChar

putChar 函数接受一个字符,并返回一个将字符打印到终端的 I/O 操作:

main = do
    putChar 't'
    putChar 'e'
    putChar 'h'

putStr 可以通过 putChar 的帮助递归定义。putStr 的基本情况是空字符串,所以如果我们正在打印一个空字符串,我们只需使用 return () 返回一个不执行任何操作的 I/O 操作。如果不是空的,那么我们通过 putChar 打印字符串的第一个字符,然后递归地打印其余部分:

putStr :: String -> IO ()
putStr [] = return ()
putStr (x:xs) = do
    putChar x
    putStr xs

注意我们如何在 I/O 中使用递归,就像我们可以在纯代码中使用它一样。我们定义基本情况,然后思考实际的结果是什么。在这种情况下,它是一个首先输出第一个字符然后输出字符串其余部分的操作。

print

print 接受任何类型的值,只要它是 Show 的实例(这意味着我们知道如何将其表示为字符串),将 show 应用到该值以“字符串化”它,然后将该字符串输出到终端。基本上,它只是 putStrLn . show。它首先对值运行 show,然后将结果传递给 putStrLn,它返回一个将我们的值打印到终端的 I/O 操作。

main = do
    print True
    print 2
    print "haha"
    print 3.2
    print [3,4,3]

编译并运行此代码,我们得到以下输出:

True
2
"haha"
3.2
[3,4,3]

如您所见,这是一个非常实用的函数。记得我们之前讨论过 I/O 操作只有在它们落入 main 或我们在 GHCi 提示符中尝试评估它们时才会执行吗?当我们输入一个值(如 3[1,2,3])并按回车键时,GHCi 实际上会对该值使用 print 来在终端上显示它!

ghci> 3
3
ghci> print 3
3
ghci> map (++"!") ["hey","ho","woo"]
["hey!","ho!","woo!"]
ghci> print $ map (++"!") ["hey","ho","woo"]
["hey!","ho!","woo!"]

当我们想要打印字符串时,我们通常使用 putStrLn,因为我们不希望它们周围有引号。然而,对于将其他类型的数据打印到终端,print 是最常用的。

when

when 函数位于 Control.Monad 中(要访问它,请使用 import Control.Monad)。它很有趣,因为在 do 块中,它看起来像是一个流程控制语句,但实际上它是一个普通函数。

when 函数接受一个 Bool 和一个 I/O 操作,如果那个 Bool 值是 True,它返回我们提供给它的相同 I/O 操作。然而,如果它是 False,它返回 return () 操作,这个操作不会做任何事情。

这里有一个小程序,它会要求输入一些内容,并将其打印回终端,但只有当输入是 SWORDFISH 时:

import Control.Monad

main = do
    input <- getLine
    when (input == "SWORDFISH") $ do
        putStrLn input

没有使用 when,我们需要像这样编写程序:

main = do
    input <- getLine
    if (input == "SWORDFISH")
        then putStrLn input
        else return ()

如您所见,when 函数在我们想要在满足条件时执行一些 I/O 操作,但在其他情况下不执行时非常有用。

sequence

sequence 函数接受一个 I/O 操作的列表,并返回一个 I/O 操作,该操作将依次执行这些操作。这个 I/O 操作产生的结果将是执行的所有 I/O 操作的结果列表。例如,我们可以这样做:

main = do
    a <- getLine
    b <- getLine
    c <- getLine
    print [a,b,c]

或者我们可以这样做:

main = do
    rs <- sequence [getLine, getLine, getLine]
    print rs

这两个版本的输出结果完全相同。sequence [getLine, getLine, getLine] 创建了一个 I/O 操作,该操作将执行 getLine 三次。如果我们将这个操作绑定到一个名称上,结果将是一个包含所有结果的列表。所以在这种情况下,结果将是一个包含用户在提示符中输入的三个东西的列表。

使用 sequence 的一个常见模式是当我们将 printputStrLn 等函数映射到列表上。执行 map print [1,2,3,4] 不会创建一个 I/O 操作,而是会创建一个 I/O 操作的列表。实际上,这和写下面这样的代码是相同的:

[print 1, print 2, print 3, print 4]

如果我们想要将这个 I/O 操作的列表转换成一个 I/O 操作,我们必须对其进行序列化:

ghci> sequence $ map print [1,2,3,4,5]
1
2
3
4
5
[(),(),(),(),()]

但输出末尾的 [(),(),(),(),()] 是什么意思呢?好吧,当我们评估 GHCi 中的 I/O 操作时,该操作会被执行,然后打印出其结果,除非该结果是 ()。这就是为什么在 GHCi 中评估 putStrLn "hehe" 只会打印出 hehe——putStrLn "hehe" 产生 ()。但是当我们输入 getLine 到 GHCi 中时,那个 I/O 操作的结果会被打印出来,因为 getLine 的类型是 IO String

mapM

因为在列表上映射返回 I/O 操作的函数,然后对其进行序列化是非常常见的,所以引入了实用函数 mapMmapM_mapM 接受一个函数和一个列表,将函数映射到列表上,然后对其进行序列化。mapM_ 做同样的事情,但随后会丢弃结果。我们通常在不在乎我们序列化的 I/O 操作的结果时使用 mapM_。以下是一个 mapM 的例子:

ghci> mapM print [1,2,3]
1
2
3
[(),(),()]

但我们并不关心最后的三个单元列表,所以使用这个形式更好:

ghci> mapM_ print [1,2,3]
1
2
3

forever

forever 函数接受一个 I/O 操作,并返回一个无限重复该 I/O 操作的 I/O 操作。它位于 Control.Monad 中。以下这个小程序将无限期地要求用户输入一些内容,并以全部大写字母的形式将其返回:

import Control.Monad
import Data.Char

main = forever $ do
    putStr "Give me some input: "
    l <- getLine
    putStrLn $ map toUpper l

forM

forM(位于 Control.Monad 中)类似于 mapM,但其参数顺序相反。第一个参数是列表,第二个是要映射到该列表上的函数,然后对其进行序列化。这有什么用呢?嗯,通过一些创造性的 lambda 和 do 表达式使用,我们可以做这样的事情:

import Control.Monad

main = do
    colors <- forM [1,2,3,4] (\a -> do
        putStrLn $ "Which color do you associate with the number "
                   ++ show a ++ "?"
        color <- getLine
        return color)
    putStrLn "The colors that you associate with 1, 2, 3 and 4 are: "
    mapM putStrLn colors

当我们尝试这样做时,我们得到以下结果:

Which color do you associate with the number 1?
white
Which color do you associate with the number 2?
blue
Which color do you associate with the number 3?
red
Which color do you associate with the number 4?
orange
The colors that you associate with 1, 2, 3 and 4 are:
white
blue
red
orange

(\a -> do ... ) lambda 是一个接受一个数字并返回一个 I/O 操作的函数。注意我们在内部 do 块中调用了 return color。我们这样做是为了让 do 块定义的 I/O 操作产生代表我们选择的颜色的字符串。实际上我们不必这样做,因为 getLine 已经产生了我们选择的颜色,并且它是 do 块中的最后一行。执行 color <- getLine 然后执行 return color 只是从 getLine 中解包结果然后再打包它——这和只调用 getLine 是一样的。

forM 函数(使用其两个参数调用)产生一个 I/O 操作,我们将结果绑定到 colorscolors 只是一个普通的字符串列表。最后,我们通过调用 mapM putStrLn colors 打印出所有这些颜色。

你可以将 forM 理解为,“为这个列表中的每个元素创建一个 I/O 操作。每个 I/O 操作将做什么可以依赖于用于创建该操作的元素。最后,执行这些操作并将它们的结果绑定到某个地方。”(尽管我们不需要绑定它;我们也可以简单地丢弃它。)

实际上,我们可以不使用 forM 就达到相同的结果,但使用 forM 使得代码更易读。通常,当我们想要映射和序列化一些在 do 表达式上即时定义的操作时,我们会使用 forM

I/O 操作回顾

让我们快速回顾一下 I/O 基础知识。I/O 操作就像 Haskell 中的任何其他值一样,是值。我们可以将它们作为参数传递给函数,函数也可以将 I/O 操作作为结果返回。

I/O 操作的特殊之处在于,如果它们发生在main函数中(或者是在 GHCi 命令行中的结果),它们就会被执行。这时,它们会在你的屏幕上写东西,或者通过你的扬声器播放“Yakety Sax”。每个 I/O 操作还可以产生一个结果,告诉你它从现实世界获得了什么。

第九章. 更多输入和更多输出

现在你已经理解了 Haskell I/O 背后的概念,我们可以开始用它做一些有趣的事情了。在本章中,我们将与文件交互,生成随机数,处理命令行参数等等。敬请期待!

文件和流

在了解了 I/O 操作的工作原理之后,我们可以继续使用 Haskell 来读写文件。但首先,让我们看看如何使用 Haskell 轻松处理数据流。数据流是指在一段时间内进入或离开程序的一系列数据。例如,当你通过键盘向程序输入字符时,这些字符可以被视为一个流。

无标题图片

输入重定向

许多交互式程序通过键盘获取用户的输入。然而,通过将文本文件的内容提供给程序来获取输入通常更方便。为了实现这一点,我们使用输入重定向

输入重定向在我们的 Haskell 程序中将非常有用,让我们看看它是如何工作的。首先,创建一个包含以下小俳句的文本文件,并将其保存为haiku.txt

I'm a lil' teapot
What's with that airplane food, huh?
It's so small, tasteless

嗯,俳句很糟糕——那又怎样?如果有人知道任何好的俳句教程,请告诉我。

现在我们将编写一个小程序,它可以从输入中连续获取一行,并将其全部转换为大写输出:

import Control.Monad
import Data.Char

main = forever $ do
    l <- getLine
    putStrLn $ map toUpper l

将此程序保存为capslocker.hs并编译它。

我们不是通过键盘输入行,而是通过重定向将haiku.txt作为输入传递给我们的程序。要做到这一点,我们在程序名称后添加一个<字符,然后指定我们想要作为输入的文件。看看这个例子:

$ ghc --make capslocker
[1 of 1] Compiling Main             ( capslocker.hs, capslocker.o )
Linking capslocker ...
$ ./capslocker < haiku.txt
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLANE FOOD, HUH?
IT'S SO SMALL, TASTELESS
capslocker <stdin>: hGetLine: end of file

我们所做的是相当于运行capslocker,在终端中输入我们的俳句,然后发送一个文件结束字符(通常通过按 ctrl-D 完成)。这就像运行capslocker并说:“等等,不要从键盘读取。取而代之的是这个文件的内容!”

从输入流中获取字符串

让我们看看一个 I/O 操作,它通过允许我们将输入流视为普通字符串来简化输入流的处理:getContentsgetContents读取从标准输入直到遇到文件结束字符的所有内容。它的类型是getContents :: IO StringgetContents的酷之处在于它执行懒 I/O。这意味着当我们执行foo <- getContents时,getContents不会一次性读取所有输入,将其存储在内存中,然后将其绑定到foo。不,getContents是懒的!它会说:“是的,是的,我会在需要的时候从终端读取输入!”

在我们的 capslocker.hs 示例中,我们使用了 forever 来逐行读取输入,然后将其以大写形式打印出来。如果我们选择使用 getContents,它会为我们处理 I/O 细节,例如何时读取输入以及读取多少输入。因为我们的程序是关于接受一些输入并将其转换为一些输出,我们可以通过使用 getContents 来使其更短:

import Data.Char

main = do
    contents <- getContents
    putStr $ map toUpper contents

我们运行 getContents I/O 操作,并命名它产生的字符串为 contents。然后我们对该字符串应用 toUpper 并将结果打印到终端。记住,因为字符串基本上是懒加载的列表,而 getContents 是 I/O 懒加载;它不会试图一次性读取所有内容并将其存储到内存中,然后再打印出大写锁定的版本。相反,它会在读取的同时打印出大写锁定的版本,因为它只有在必须时才会从输入中读取一行。

让我们测试一下:

$ ./capslocker < haiku.txt
I'M A LIL' TEAPOT
WHAT'S WITH THAT AIRPLANE FOOD, HUH?
IT'S SO SMALL, TASTELESS

所以,它(程序)是有效的。如果我们只运行 capslocker 并尝试自己输入这些行会怎样?(要退出程序,只需按 ctrl-D。)

$ ./capslocker
hey ho
HEY HO
lets go
LETS GO

很不错!正如你所见,它逐行打印出大写锁定输入。

getContents 的结果绑定到 contents 时,它不是以真正的字符串形式在内存中表示,而更像是一个承诺,即字符串最终会被生成。当我们对 contents 应用 toUpper 时,这也是一个承诺,即对最终内容应用该函数。最后,当 putStr 发生时,它对前面的承诺说,“嘿,我需要一个带大写锁定的行!”它目前还没有任何行,所以它对 contents 说,“从终端获取一行怎么样?”这时 getContents 实际上从终端读取并给请求它产生有形内容的代码一行。然后,该代码对这一行应用 toUpper 并将其传递给 putStr,打印这一行。然后 putStr 说,“嘿,我需要下一行——快点!”这会一直重复,直到没有更多的输入,这由文件结束字符表示。

现在我们来编写一个程序,它接受一些输入并只打印出那些少于 10 个字符的行:

main = do
    contents <- getContents
    putStr (shortLinesOnly contents)

shortLinesOnly :: String -> String
shortLinesOnly = unlines . filter (\line -> length line < 10) . lines

我们已经将程序的 I/O 部分尽可能地简化。因为我们的程序应该根据一些输入打印出某些内容,我们可以通过读取输入内容,对它们运行一个函数,然后打印出该函数返回的内容来实现它。

shortLinesOnly 函数接受一个字符串,例如 "short\nlooooooong\nbort"。在这个例子中,该字符串有三行:其中两行较短,中间的一行较长。它将该字符串应用到 lines 函数上,将其转换为 ["short", "looooooong", "bort"]。这个字符串列表随后被过滤,只保留那些少于 10 个字符的行,生成 ["short", "bort"]。最后,unlines 将该列表连接成一个单独的换行分隔字符串,得到 "short\nbort"

让我们试试。将以下文本保存为 shortlines.txt

i'm short
so am i
i am a loooooooooong line!!!
yeah i'm long so what hahahaha!!!!!!
short line
loooooooooooooooooooooooooooong
short

现在我们将编译我们的程序,我们将其保存为 shortlinesonly.hs

$ ghc --make shortlinesonly
[1 of 1] Compiling Main             ( shortlinesonly.hs, shortlinesonly.o )
Linking shortlinesonly ...

为了测试它,我们将 shortlines.txt 的内容重定向到我们的程序中,如下所示:

$ ./shortlinesonly < shortlines.txt
i'm short
so am i
short

你可以看到只有短行被打印到终端。

转换输入

从输入中获取一些字符串,使用一个函数对其进行转换,然后输出结果的模式非常常见,以至于有一个使这项工作更加容易的函数,称为 interactinteract 接受一个类型为 String -> String 的函数作为参数,并返回一个 I/O 操作,该操作将获取一些输入,在该输入上运行该函数,然后打印出函数的结果。让我们修改我们的程序以使用 interact

main = interact shortLinesOnly

shortLinesOnly :: String -> String
shortLinesOnly = unlines . filter (\line -> length line < 10) . lines

我们可以通过将文件重定向到程序中或运行程序然后逐行从键盘输入来使用这个程序。两种情况下的输出都是相同的,但当我们通过键盘进行输入时,输出会与我们所输入的内容交织在一起,就像我们手动将输入输入到我们的 capslocker 程序中一样。

让我们编写一个程序,该程序持续读取一行,然后输出该行是否是回文。我们可以使用 getLine 读取一行,告诉用户它是否是回文,然后再次运行 main。但如果我们使用 interact 会更简单。在使用 interact 时,考虑你需要做什么来将一些输入转换为所需的输出。在我们的情况下,我们想要将输入的每一行替换为 "palindrome""not a palindrome"

respondPalindromes :: String -> String
respondPalindromes =
    unlines .
    map (\xs -> if isPal xs then "palindrome" else "not a palindrome") .
    lines

isPal :: String -> Bool
isPal xs = xs == reverse xs

这个程序相当简单。首先,它将像这样的字符串:

"elephant\nABCBA\nwhatever"

转换为一个如下所示的数组:

["elephant", "ABCBA", "whatever"]

然后它对 lambda 进行映射,得到以下结果:

["not a palindrome", "palindrome", "not a palindrome"]

接下来,unlines 将该列表连接成一个单独的、以换行符分隔的字符串。现在我们只需创建一个主要的 I/O 操作:

main = interact respondPalindromes

让我们测试它:

$ ./palindromes
hehe
not a palindrome
ABCBA
palindrome
cookie
not a palindrome

尽管我们创建了一个将一个大的输入字符串转换成另一个字符串的程序,但它表现得就像我们创建了一个逐行进行转换的程序。这是因为 Haskell 是惰性的,它想要打印结果字符串的第一行,但它不能,因为它还没有输入的第一行。所以,一旦我们给它输入的第一行,它就会打印输出的第一行。我们通过发出行结束字符来退出程序。

我们也可以通过将文件重定向到程序中来使用这个程序。创建以下文件并将其保存为 words.txt

dogaroo
radar
rotor
madam

通过将其重定向到我们的程序,我们得到以下结果:

$ ./palindrome < words.txt
not a palindrome
palindrome
palindrome
palindrome

再次,我们得到了与如果我们运行程序并将单词直接输入到标准输入中相同的输出。我们只是看不到程序获取的输入,因为那个输入来自文件。

所以现在你看到了惰性 I/O 的工作原理以及我们如何利用它。你只需考虑对于某个给定的输入,输出应该是什么,然后编写一个函数来完成这个转换。在惰性 I/O 中,直到绝对必须时,才从输入中取出任何内容,因为我们想要立即打印的内容取决于那个输入。

读取和写入文件

到目前为止,我们通过向终端打印内容并从中读取来处理 I/O。但是,关于读取和写入文件呢?嗯,在某种程度上,我们已经在做了。

一种从终端读取的想法是,它就像从(某种程度上特殊的)文件中读取一样。对于将内容写入终端也是如此——它有点像写入文件。我们可以称这两个文件为stdoutstdin,分别表示标准输出和标准输入。向文件写入和从文件读取与向标准输出写入和从标准输入读取非常相似。

我们将从一个非常简单的程序开始,这个程序打开一个名为girlfriend.txt的文件,其中包含 Avril Lavigne 热门歌曲“Girlfriend”中的一段歌词,并将其打印到终端。以下是girlfriend.txt的内容:

Hey! Hey! You! You!
I don't like your girlfriend!
No way! No way!
I think you need a new one!

下面是我们的程序:

import System.IO

main = do
    handle <- openFile "girlfriend.txt" ReadMode
    contents <- hGetContents handle
    putStr contents
    hClose handle

如果我们编译并运行它,我们会得到预期的结果:

$ ./girlfriend
Hey! Hey! You! You!
I don't like your girlfriend!
No way! No way!
I think you need a new one!

让我们逐行分析这一行。第一行只是四个感叹号,以引起我们的注意。在第二行,Avril 告诉我们她不喜欢我们目前的女性伴侣。第三行用来强调这种不满,第四行建议我们应该去找一个合适的替代者。

让我们也逐行分析程序。我们的程序是由do块粘合在一起的几个 I/O 操作。在do块的第一行有一个新函数openFile。它有以下类型签名:

openFile :: FilePath -> IOMode -> IO Handle

openFile函数接受一个文件路径和一个IOMode,并返回一个 I/O 操作,该操作将打开一个文件并返回文件关联的句柄作为其结果。FilePath只是String的一个类型同义词,定义如下:

type FilePath = String

IOMode是一个定义如下类型的类型:

data IOMode = ReadMode | WriteMode | AppendMode | ReadWriteMode

就像代表一周七天可能值的类型一样,这个类型是一个枚举,表示我们想要对我们打开的文件做什么。注意,这个类型是IOMode而不是IO ModeIO Mode将是产生某种类型Mode值的 I/O 操作的类型。IOMode只是一个简单的枚举。

无标题图片

最后,openFile返回一个 I/O 操作,该操作将以指定的模式打开指定的文件。如果我们将该操作的结果绑定到某个东西上,我们就会得到一个Handle,它表示我们的文件在哪里。我们将使用这个句柄,以便我们知道从哪个文件读取。

在下一行,我们有一个名为hGetContents的函数。它接受一个Handle,这样它就知道从哪个文件获取内容,并返回一个IO String——一个包含文件内容的 I/O 操作作为其结果。这个函数基本上与getContents相同。唯一的区别是getContents会自动从标准输入(即终端)读取,而hGetContents接受一个文件句柄,告诉它从哪个文件读取。在其他所有方面,它们的工作方式相同。

就像 getContents 一样,hGetContents 不会试图一次性读取整个文件并存储在内存中,而是按需读取内容。这真的很酷,因为我们可以将 contents 视为整个文件的内容,但实际上并没有加载到内存中。所以如果这是一个非常大的文件,使用 hGetContents 不会耗尽我们的内存。

注意文件句柄和文件实际内容之间的区别。文件句柄只是指向文件中的当前位置。内容是文件中实际的内容。如果你想象你的整个文件系统就像一本非常大的书,那么文件句柄就像一个书签,显示你当前正在阅读(或写入)的位置。

使用 putStr contents,我们将内容打印到标准输出,然后执行 hClose,它接受一个句柄并返回一个关闭文件的 I/O 操作。你需要在用 openFile 打开文件后自己关闭文件!如果你尝试打开一个尚未关闭句柄的文件,你的程序可能会终止。

使用 withFile 函数

另一种像我们刚才那样处理文件内容的方法是使用 withFile 函数,它具有以下类型签名:

withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a

它需要一个文件的路径、一个 IOMode 以及一个接受句柄并返回一些 I/O 操作的函数。然后它返回一个 I/O 操作,该操作将打开该文件,对文件进行一些操作,然后关闭它。此外,如果我们操作文件时出了问题,withFile 确保文件句柄被关闭。这听起来可能有点复杂,但实际上很简单,特别是如果我们使用 lambda。

下面是将我们之前的示例重写为使用 withFile 的样子:

import System.IO

main = do
    withFile "girlfriend.txt" ReadMode (\handle -> do
        contents <- hGetContents handle
        putStr contents)

(\handle -> ...) 是一个函数,它接受一个句柄并返回一个 I/O 操作,通常是这样做的,使用 lambda。它需要一个返回 I/O 操作的函数,而不是仅仅接受一个要执行的操作并关闭文件,因为我们要传递给它的 I/O 操作不知道在哪个文件上操作。这样,withFile 打开文件,然后将句柄传递给它提供的函数。它从这个函数返回一个 I/O 操作,然后创建一个与原始操作相同的 I/O 操作,但它还确保文件句柄被关闭,即使出了问题。

到括号时间了

通常,如果一段代码调用 error(例如当我们尝试将 head 应用到一个空列表上时)或者在进行输入输出时出了大问题,我们的程序会终止,并显示某种错误信息。在这种情况下,我们说发生了 异常withFile 函数确保即使发生异常,文件句柄也会被关闭。

这种场景经常出现。我们获取一些资源(如文件句柄),并想对其进行操作,但同时也想确保资源被释放(例如,关闭文件句柄)。正是为了这种情况,Control.Exception 模块提供了 bracket 函数。它具有以下类型签名:

无标题图片

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c

它的第一个参数是一个获取资源的 I/O 操作,例如文件句柄。第二个参数是一个释放该资源的函数。即使发生异常,该函数也会被调用。第三个参数是一个也接受该资源并对其进行操作的函数。第三个参数是主要操作发生的地方,比如从文件中读取或写入文件。

因为 bracket 是关于获取资源、对其进行操作并确保其被释放的,所以实现 withFile 非常简单:

withFile :: FilePath -> IOMode -> (Handle -> IO a) -> IO a
withFile name mode f = bracket (openFile name mode)
    (\handle -> hClose handle)
    (\handle -> f handle)

我们传递给 bracket 的第一个参数打开文件,其结果是文件句柄。第二个参数接收该句柄并关闭它。bracket 确保即使发生异常也会执行此操作。最后,bracket 的第三个参数接收一个句柄并对其应用函数 f,该函数接收一个文件句柄并对其进行操作,例如从相应文件中读取或写入。

抓取句柄!

就像 hGetContents 的工作方式类似于 getContents 但针对特定文件一样,像 hGetLinehPutStrhPutStrLnhGetChar 等函数的工作方式也类似于没有 h 的对应函数,但它们只接受一个句柄作为参数并在该特定文件上操作,而不是在标准输入或标准输出上操作。例如,putStrLn 接收一个字符串并返回一个 I/O 操作,该操作将打印出该字符串到终端并在其后添加一个换行符。hPutStrLn 接收一个句柄和一个字符串并返回一个 I/O 操作,该操作将字符串写入与句柄关联的文件,并在其后添加一个换行符。同样地,hGetLine 接收一个句柄并返回一个 I/O 操作,该操作从其文件中读取一行。

加载文件然后将内容作为字符串处理是如此常见,以至于我们有三个小巧的函数来使我们的工作更加容易:readFilewriteFileappendFile

readFile 函数的类型签名为 readFile :: FilePath -> IO String。(记住,FilePath 只是 String 的一个花哨名称。)readFile 函数接收一个文件的路径,并返回一个 I/O 操作,该操作将(当然,是惰性地)读取该文件并将内容绑定为一个字符串。通常比调用 openFile 然后使用结果句柄调用 hGetContents 更方便。以下是我们可以如何使用 readFile 重写之前的示例:

import System.IO

main = do
    contents <- readFile "girlfriend.txt"
    putStr contents

因为我们没有句柄来识别我们的文件,所以我们不能手动关闭它,所以当使用 readFile 时,Haskell 会为我们做这件事。

writeFile 函数的类型为 writeFile :: FilePath -> String -> IO ()。它接受一个文件的路径和一个要写入该文件的字符串,并返回一个将执行写入的 I/O 操作。如果这样的文件已存在,它将在写入之前被截断为零长度。以下是如何将 girlfriend.txt 转换为大写版本并将其写入 girlfriendcaps.txt

import System.IO
import Data.Char

main = do
    contents <- readFile "girlfriend.txt"
    writeFile "girlfriendcaps.txt" (map toUpper contents)

appendFile 函数与 writeFile 函数具有相同的类型签名,并且几乎以相同的方式工作。唯一的区别是,如果文件已存在,appendFile 不会将其截断为零长度。相反,它将内容追加到该文件的末尾。

待办事项列表

让我们通过编写一个程序来使用 appendFile 函数,该程序将任务添加到列出我们必须做的事情的文本文件中。我们假设该文件名为 todo.txt,并且它包含每行一个任务。我们的程序将从标准输入读取一行并将其添加到我们的待办事项列表中:

import System.IO

main = do
    todoItem <- getLine
    appendFile "todo.txt" (todoItem ++ "\n")

注意,我们在每行的末尾添加了 "\n",因为 getLine 不会给我们一个换行符。

将文件保存为 appendtodo.hs,编译它,然后运行几次,并给它一些待办事项。

$ ./appendtodo
Iron the dishes
$ ./appendtodo
Dust the dog
$ ./appendtodo
Take salad out of the oven
$ cat todo.txt
Iron the dishes
Dust the dog
Take salad out of the oven

注意

cat 是 Unix 类型的系统上的一个程序,可以用来将文本文件打印到终端。在 Windows 系统上,您可以使用您喜欢的文本编辑器查看任何给定时间 todo.txt 中的内容。

删除项目

我们已经编写了一个程序来向我们的待办事项列表 todo.txt 中添加新项目。现在让我们编写一个程序来删除项目。我们将使用 System.Directory 的一些新函数和 System.IO 中的一个新函数,所有这些函数将在代码列表之后进行解释。

import System.IO
import System.Directory
import Data.List

main = do
    contents <- readFile "todo.txt"
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line)
                                    [0..] todoTasks
    putStrLn "These are your TO-DO items:"
    mapM_ putStrLn numberedTasks
    putStrLn "Which one do you want to delete?"
    numberString <- getLine
    let number = read numberString
        newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
    (tempName, tempHandle) <- openTempFile "." "temp"
    hPutStr tempHandle newTodoItems
    hClose tempHandle
    removeFile "todo.txt"
    renameFile tempName "todo.txt"

首先,我们读取 todo.txt 并将其内容绑定到 contents。然后我们将内容拆分为一个字符串列表,每行一个字符串。所以 todoTasks 现在看起来像这样:

["Iron the dishes", "Dust the dog", "Take salad out of the oven"]

我们将 0 及其以上的数字与一个函数进行压缩,该函数接受一个数字(如 3)和一个字符串(如 "hey"),并返回一个新的字符串(如 "3 - hey")。现在 numberedTasks 看起来像这样:

["0 - Iron the dishes"
,"1 - Dust the dog"
,"2 - Take salad out of the oven"
]

然后,我们使用 mapM_ putStrLn numberedTasks 来逐行打印每个任务,询问用户要删除哪个,并等待用户输入一个数字。假设我们想要删除编号 1 (Dust the dog),所以我们输入 1numberString 现在是 "1",因为我们想要一个数字而不是一个字符串,所以我们应用 read 来获取 1 并使用 let 将其绑定到 number

记得 delete!! 函数吗?!! 从列表中返回一个具有某些索引的元素。delete 从列表中删除元素的第一种出现,并返回一个不包含该出现的新列表。(todoTasks !! number) 结果为 "Dust the dog"。我们从 todoTasks 中删除 "Dust the dog" 的第一次出现,然后使用 unlines 将其合并成一行,并将其命名为 newTodoItems

然后我们使用一个之前未曾遇到的函数,来自 System.IOopenTempFile。它的名字相当直观。它接受一个指向临时目录的路径和一个文件模板名称,并打开一个临时文件。我们使用 "." 作为临时目录,因为在几乎任何操作系统上,. 都表示当前目录。我们使用 "temp" 作为临时文件的模板名称,这意味着临时文件将被命名为 temp 加上一些随机字符。它返回一个创建临时文件的 I/O 操作,在该 I/O 操作的结果中是一个值对:临时文件名称和处理句柄。我们本可以直接打开一个名为 todo2.txt 或类似的普通文件,但使用 openTempFile 是更好的实践,这样你知道你不太可能覆盖任何东西。

现在我们已经打开了临时文件,我们将 newTodoItems 写入其中。旧文件保持不变,临时文件包含旧文件的所有行,除了我们删除的那一行。

之后,我们关闭原始和临时文件,并使用 removeFile 删除原始文件,该函数接受一个文件路径并删除它。在删除旧的 todo.txt 之后,我们使用 renameFile 将临时文件重命名为 todo.txtremoveFilerenameFile(都在 System.Directory 中)接受文件路径而不是句柄作为它们的参数。

将其保存为 deletetodo.hs,编译并尝试运行:

$ ./deletetodo
These are your TO-DO items:
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
Which one do you want to delete?
1

现在我们来看看哪些条目仍然存在:

$ cat todo.txt
Iron the dishes
Take salad out of the oven

哎,酷!让我们再删除一个条目:

$ ./deletetodo
These are your TO-DO items:
0 - Iron the dishes
1 - Take salad out of the oven
Which one do you want to delete?
0

检查文件后,我们看到只有一个条目剩下:

$ cat todo.txt
Take salad out of the oven

所以,一切正常。然而,关于这个程序有一件事有点不对劲。如果我们打开临时文件后出现问题,程序会终止,但临时文件不会得到清理。让我们解决这个问题。

清理

为了确保在出现问题时清理我们的临时文件,我们将使用来自 Control.ExceptionbracketOnError 函数。它与 bracket 非常相似,但 bracket 会获取资源并在我们使用后确保总是执行一些清理,而 bracketOnError 仅在抛出异常时执行清理。以下是代码:

import System.IO
import System.Directory
import Data.List
import Control.Exception

main = do
    contents <- readFile "todo.txt"
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line)
                                    [0..] todoTasks
    putStrLn "These are your TO-DO items:"
    mapM_ putStrLn numberedTasks
    putStrLn "Which one do you want to delete?"
    numberString <- getLine
    let number = read numberString
        newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
    bracketOnError (openTempFile "." "temp")
        (\(tempName, tempHandle) -> do
            hClose tempHandle
            removeFile tempName)
        (\(tempName, tempHandle) -> do
            hPutStr tempHandle newTodoItems
            hClose tempHandle
            removeFile "todo.txt"
            renameFile tempName "todo.txt")

我们不是正常使用 openTempFile,而是用 bracketOnError 来使用它。接下来,我们编写当发生错误时我们希望发生的事情;也就是说,我们希望关闭临时句柄并删除临时文件。最后,我们编写当一切顺利时我们希望对临时文件做什么,这些行与之前相同。我们写入新项目,关闭临时句柄,删除我们的当前文件,并将临时文件重命名。

命令行参数

处理命令行参数如果你想要制作一个在终端上运行的脚本或应用程序几乎是必需的。幸运的是,Haskell 的标准库提供了一个很好的方法来获取程序的命令行参数。

在上一节中,我们制作了一个用于向我们的待办事项列表添加项目的程序和一个用于删除项目的程序。它们的问题是我们只是硬编码了我们的待办文件名。我们决定文件将命名为todo.txt,并且用户永远不会需要管理多个待办事项列表。

无标题图片

一个解决方案是始终询问用户他们想要使用哪个文件作为他们的待办事项列表。我们在想要知道要删除哪个项目时使用了这种方法。它有效,但不是最佳解决方案,因为它要求用户运行程序,等待程序询问他们,然后向程序提供一些输入。这被称为交互式程序。

与交互式命令行程序相关的难题是:如果你想要自动化程序的执行,就像脚本一样,制作一个与程序交互的脚本比只调用一个或多个程序的脚本要难。这就是为什么我们有时希望用户在运行程序时告诉程序他们想要什么,而不是让程序在运行时询问用户。那么,还有什么更好的方法让用户在运行程序时告诉程序他们想要它做什么,比通过命令行参数更合适呢?

System.Environment模块有两个酷炫的 I/O 操作,用于获取命令行参数:getArgsgetProgNamegetArgs的类型为getArgs :: IO [String],它是一个 I/O 操作,将获取程序运行时使用的参数,并返回这些参数的列表。getProgName的类型为getProgName :: IO String,它是一个 I/O 操作,返回程序名称。以下是一个小型程序,演示了这两个操作的工作原理:

import System.Environment
import Data.List

main = do
   args <- getArgs
   progName <- getProgName
   putStrLn "The arguments are:"
   mapM putStrLn args
   putStrLn "The program name is:"
   putStrLn progName

首先,我们将命令行参数绑定到args,将程序名称绑定到progName。然后,我们使用putStrLn打印出所有程序参数以及程序本身的名称。让我们将其编译为arg-test并尝试运行它:

$ ./arg-test first second w00t "multi word arg"
The arguments are:
first
second
w00t
multi word arg
The program name is:
arg-test

与待办事项列表的更多乐趣

在之前的示例中,我们制作了一个用于添加任务的程序和一个完全独立的用于删除它们的程序。现在我们将它们合并成一个程序,它是否添加或删除项目将取决于我们传递给它的命令行参数。我们还将使其能够操作不同的文件,而不仅仅是todo.txt

我们将我们的程序命名为todo,它将能够执行三种不同的操作:

  • 查看任务

  • 添加任务

  • 删除任务

要将任务添加到todo.txt文件中,我们在终端输入它:

$ ./todo add todo.txt "Find the magic sword of power"

要查看任务,我们输入view命令:

$ ./todo view todo.txt

要删除任务,我们使用其索引:

$ ./todo remove todo.txt 2

多任务待办事项列表

我们将首先创建一个函数,该函数接受一个字符串形式的命令,如"add""view",并返回一个函数,该函数接受一个参数列表并返回一个执行我们想要的 I/O 操作的动作:

import System.Environment
import System.Directory
import System.IO
import Data.List

dispatch :: String -> [String] -> IO ()
dispatch "add" = add
dispatch "view" = view
dispatch "remove" = remove

我们将定义main如下:

main = do
    (command:argList) <- getArgs
    dispatch command argList

首先,我们获取参数并将它们绑定到 (command:argList)。这意味着第一个参数将被绑定到 command,其余的参数将被绑定到 argList。在 main 块的下一行,我们将 dispatch 函数应用于命令,这会导致 addviewremove 函数。然后我们将该函数应用于 argList

假设我们这样调用我们的程序:

$ ./todo add todo.txt "Find the magic sword of power"

command"add",而 argList["todo.txt", "Find the magic sword of power"]。这样,dispatch 函数的第二个模式匹配就会成功,它将返回 add 函数。最后,我们将该函数应用于 argList,这将导致一个 I/O 动作,将项目添加到我们的待办事项列表中。

现在我们来实现 addviewremove 函数。让我们从 add 开始:

add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")

我们可能这样调用我们的程序:

./todo add todo.txt "Find the magic sword of power"

main 块的第一个模式匹配中,"add" 将被绑定到 command,而 ["todo.txt", "Find the magic sword of power"] 将传递给从 dispatch 函数获得的函数。因此,因为我们现在没有处理错误输入,所以我们立即对包含这两个元素的列表进行模式匹配,并返回一个 I/O 动作,将这一行追加到文件的末尾,并附带一个换行符。

接下来,让我们实现列表查看功能。如果我们想查看文件中的项目,我们执行 ./todo view todo.txt。所以在第一个模式匹配中,command 将是 "view",而 argList 将是 ["todo.txt"]。下面是完整的函数:

view :: [String] -> IO ()
view [fileName] = do
    contents <- readFile fileName
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line)
                        [0..] todoTasks
    putStr $ unlines numberedTasks

当我们创建 deletetodo 程序,该程序只能从待办事项列表中删除项目时,它具有显示待办事项列表中项目的能力,所以这段代码与上一个程序的那部分非常相似。

最后,我们将实现 remove。它与只删除任务的程序非常相似,所以如果你不明白这里删除项的工作原理,请回顾 删除项 在 删除项。

remove :: [String] -> IO ()
remove [fileName, numberString] = do
    contents <- readFile fileName
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line)
                                    [0..] todoTasks
    putStrLn "These are your TO-DO items:"
    mapM_ putStrLn numberedTasks
    let number = read numberString
        newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
    bracketOnError (openTempFile "." "temp")
        (\(tempName, tempHandle) -> do
            hClose tempHandle
            removeFile tempName)

        (\(tempName, tempHandle) -> do
            hPutStr tempHandle newTodoItems
            hClose tempHandle
            removeFile "todo.txt"
            renameFile tempName "todo.txt")

我们根据 fileName 打开了文件,并打开了一个临时文件,删除了用户想要删除的行,将其写入临时文件,删除了原始文件,并将临时文件重命名为 fileName

这里是整个程序的全貌:

import System.Environment
import System.Directory
import System.IO
import Data.List

dispatch :: String -> [String] -> IO ()
dispatch "add" = add
dispatch "view" = view
dispatch "remove" = remove

main = do
    (command:argList) <- getArgs
    dispatch command argList

add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")

view :: [String] -> IO ()
view [fileName] = do
    contents <- readFile fileName
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line)
                        [0..] todoTasks
    putStr $ unlines numberedTasks

remove :: [String] -> IO ()
remove [fileName, numberString] = do
    contents <- readFile fileName
    let todoTasks = lines contents
        numberedTasks = zipWith (\n line -> show n ++ " - " ++ line)
                                    [0..] todoTasks
    putStrLn "These are your TO-DO items:"
    mapM_ putStrLn numberedTasks
    let number = read numberString
        newTodoItems = unlines $ delete (todoTasks !! number) todoTasks
    bracketOnError (openTempFile "." "temp")
        (\(tempName, tempHandle) -> do
            hClose tempHandle
            removeFile tempName)
        (\(tempName, tempHandle) -> do
            hPutStr tempHandle newTodoItems
            hClose tempHandle
            removeFile "todo.txt"
            renameFile tempName "todo.txt")

总结我们的解决方案,我们创建了一个dispatch函数,它将命令映射到以列表形式接受一些命令行参数的函数,并返回一个 I/O 操作。我们看到command是什么,然后根据这个,从dispatch函数中获取适当的函数。我们用剩余的命令行参数调用该函数,以获取一个将执行适当操作的 I/O 操作,然后只需执行该操作即可。使用高阶函数允许我们只需告诉dispatch函数给我们适当的函数,然后告诉该函数给我们一些命令行参数的 I/O 操作。

让我们尝试运行我们的应用程序!

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven

$ ./todo add todo.txt "Pick up children from dry cleaners"

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Take salad out of the oven
3 - Pick up children from dry cleaners

$ ./todo remove todo.txt 2

$ ./todo view todo.txt
0 - Iron the dishes
1 - Dust the dog
2 - Pick up children from dry cleaners :

使用dispatch函数的另一个酷特点是添加功能很容易。只需向dispatch添加一个额外的模式并实现相应的函数,然后你就可以笑出来了!作为一个练习,你可以尝试实现一个bump函数,该函数将接受一个文件和一个任务编号,并返回一个将任务提升到待办列表顶部的 I/O 操作。

处理错误输入

我们可以将这个程序扩展,使其在遇到错误输入时更加优雅地失败,而不是从 Haskell 中打印出丑陋的错误信息。我们可以通过在dispatch函数的末尾添加一个通配符模式,并使其返回一个忽略参数列表并告诉我们该命令不存在的函数来开始:

dispatch :: String -> [String] -> IO ()
dispatch "add" = add
dispatch "view" = view
dispatch "remove" = remove
dispatch command = doesntExist command

doesntExist :: String -> [String] -> IO ()
doesntExist command _ =
    putStrLn $ "The " ++ command ++ " command doesn't exist"

我们还可以向addviewremove函数添加通配符模式,这样程序就能告诉用户他们是否给某个命令提供了错误的参数数量。以下是一个例子:

add :: [String] -> IO ()
add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n")
add _ = putStrLn "The add command takes exactly two arguments"

如果add应用于不恰好包含两个元素的列表,第一个模式匹配将失败,但第二个模式匹配将成功,并有助于用户了解他们的错误方式。我们也可以将类似的通配符模式添加到viewremove中。

注意,我们还没有涵盖所有输入错误的情况。例如,假设我们像这样运行我们的程序:

./todo

在这种情况下,程序将会崩溃,因为我们使用了do块中的(command:argList)模式,但没有考虑到没有任何参数的情况!我们也没有在尝试打开文件之前检查我们正在操作的文件是否存在。添加这些预防措施并不难,但有点繁琐,因此将这个程序完全做成傻瓜式留给了读者作为练习。

随机性

在编程过程中,很多时候你需要获取一些随机数据(好吧,伪随机数据,因为我们都知道唯一真正的随机源是一个骑独轮车、一手拿着奶酪、另一手拿着屁股的猴子)。例如,你可能正在制作一个需要掷骰子的游戏,或者你需要生成一些数据来测试你的程序。在本节中,我们将探讨如何让 Haskell 生成看似随机的数据,以及为什么我们需要外部输入来生成足够随机的值。

无标题图片

大多数编程语言都有返回随机数的函数。每次调用该函数时,你都会获取一个不同的随机数。Haskell 呢?记住 Haskell 是一种纯函数式语言。这意味着它具有引用透明性。而这意味着一个函数,如果给定了相同的参数两次,必须产生两次相同的结果。这真的很酷,因为它允许我们推理程序,并使我们能够延迟求值直到真正需要它。然而,这也使得获取随机数变得有些棘手。

假设我们有一个这样的函数:

randomNumber :: Int
randomNumber = 4

作为随机数函数来说,它并不太有用,因为它总是会返回 4。(尽管我可以向你保证这个 4 是完全随机的,因为我使用骰子来决定它。)

其他语言是如何生成看似随机的数的呢?嗯,它们会取一些初始数据,比如当前时间,然后基于这些数据生成看似随机的数。在 Haskell 中,我们可以通过创建一个函数来生成随机数,这个函数接受一些初始数据或随机性作为参数,并产生一个随机数。我们使用 I/O 将随机性从外部引入我们的程序。

进入 System.Random 模块。它包含了所有满足我们随机需求的功能。让我们直接深入到它导出的一个函数:random。这是它的类型签名:

random :: (RandomGen g, Random a) => g -> (a, g)

哇!在这个类型声明中我们有一些新的类型类!RandomGen 类型类是为可以作为随机性来源的类型。Random 类型类是为其值可以是随机的类型。我们可以通过随机产生 TrueFalse 来生成随机的布尔值。我们也可以生成随机的数字。一个函数能取一个随机值吗?我不这么认为!如果我们尝试将 random 的类型声明翻译成英文,我们得到的东西类似于这样:它接受一个随机生成器(这是我们随机性的来源)并返回一个随机值和一个新的随机生成器。为什么它还要返回一个新的生成器以及一个随机值呢?好吧,你很快就会看到。

要使用我们的 random 函数,我们需要获取那些随机生成器中的一个。System.Random 模块导出了一个酷炫的类型,即 StdGen,它是 RandomGen 类型类的一个实例。我们可以手动创建一个 StdGen,或者我们可以告诉系统根据多种(某种程度上的)随机因素给我们提供一个。

要手动创建一个随机生成器,请使用 mkStdGen 函数。它的类型是 mkStdGen :: Int -> StdGen。它接受一个整数,并根据这个整数给我们提供一个随机生成器。那么,让我们尝试一起使用 randommkStdGen 来获取一个(几乎)随机的数。

ghci> random (mkStdGen 100)
<interactive>:1:0:
    Ambiguous type variable `a' in the constraint:
      `Random a' arising from a use of `random' at <interactive>:1:0-20
    Probable fix: add a type signature that fixes these type variable(s)

这是怎么回事?啊,对了,random 函数可以返回 Random 类型类中任何类型的值,所以我们需要通知 Haskell 我们想要哪种类型。另外,别忘了它以一对的形式返回一个随机值和一个随机生成器。

ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)

最后,一个看起来有点随机的数字!元组的第一个组件是我们的数字,第二个组件是我们新随机生成器的文本表示。如果我们再次用相同的随机生成器调用 random 会发生什么?

ghci> random (mkStdGen 100) :: (Int, StdGen)
(-1352021624,651872571 1655838864)

当然,对于相同的参数,我们得到相同的结果。所以让我们尝试给它一个不同的随机生成器作为参数:

ghci> random (mkStdGen 949494) :: (Int, StdGen)
(539963926,466647808 1655838864)

太好了,一个不同的数字!我们可以使用类型注解从该函数获取不同的类型。

ghci> random (mkStdGen 949488) :: (Float, StdGen)
(0.8938442,1597344447 1655838864)

ghci> random (mkStdGen 949488) :: (Bool, StdGen)
(False,1485632275 40692)
ghci> random (mkStdGen 949488) :: (Integer, StdGen)
(1691547873,1597344447 1655838864)

掷硬币

让我们编写一个模拟掷硬币三次的函数。如果 random 没有返回一个随机值和一个新的生成器,我们就需要让这个函数接受三个随机生成器作为参数,并为每个生成器返回硬币投掷结果。但是,如果一个生成器可以生成类型 Int 的随机值(它可以有大量不同的值),它应该能够生成三次硬币投掷(只能有八个不同的最终结果)。所以这就是 random 返回一个值和一个新的生成器时非常有用的地方。

我们用一个简单的 Bool 来表示一枚硬币:True 表示反面,False 表示正面。

threeCoins :: StdGen -> (Bool, Bool, Bool)
threeCoins gen =
    let (firstCoin, newGen) = random gen
        (secondCoin, newGen') = random newGen
        (thirdCoin, newGen'') = random newGen'
    in  (firstCoin, secondCoin, thirdCoin)

我们用作为参数获取的生成器调用 random 来获取一枚硬币和一个新的生成器。然后我们再次调用它,但这次是用我们的新生成器,以获取第二枚硬币。我们对第三枚硬币做同样的操作。如果我们每次都使用相同的生成器,所有硬币的值都会相同,所以我们只会得到 (False, False, False)(True, True, True) 作为结果。

ghci> threeCoins (mkStdGen 21)
(True,True,True)
ghci> threeCoins (mkStdGen 22)
(True,False,True)
ghci> threeCoins (mkStdGen 943)
(True,False,True)
ghci> threeCoins (mkStdGen 944)
(True,True,True)

注意,我们不需要调用 random gen :: (Bool, StdGen)。因为我们已经在函数的类型声明中指定了我们要布尔值,所以 Haskell 可以推断出在这种情况下我们想要一个布尔值。

更多随机函数

如果我们想要掷更多硬币呢?为此,有一个名为 randoms 的函数,它接受一个生成器并返回基于该生成器的无限序列值。

ghci> take 5 $ randoms (mkStdGen 11) :: [Int]
[-1807975507,545074951,-1015194702,-1622477312,-502893664]
ghci> take 5 $ randoms (mkStdGen 11) :: [Bool]
[True,True,True,True,False]
ghci> take 5 $ randoms (mkStdGen 11) :: [Float]
[7.904789e-2,0.62691015,0.26363158,0.12223756,0.38291094]

为什么 randoms 不返回一个新的生成器以及一个列表?我们可以非常容易地实现 randoms 函数如下:

randoms' :: (RandomGen g, Random a) => g -> [a]
randoms' gen = let (value, newGen) = random gen in value:randoms' newGen

这是一个递归定义。我们从当前生成器中获取一个随机值和一个新的生成器,然后创建一个列表,其头部是值,其尾部是基于新生成器的随机数。因为我们可能需要生成无限数量的数字,所以我们不能将新的随机生成器返回。

我们可以创建一个函数,生成一个有限流数字和一个新的生成器,如下所示:

finiteRandoms :: (RandomGen g, Random a, Num n) => n -> g -> ([a], g)
finiteRandoms 0 gen = ([], gen)
finiteRandoms n gen =
    let (value, newGen) = random gen
        (restOfList, finalGen) = finiteRandoms (n-1) newGen
    in  (value:restOfList, finalGen)

再次强调,这是一个递归定义。我们说,如果我们想要零个数字,我们只需返回一个空列表和给我们的生成器。对于任何其他数量的随机值,我们首先获取一个随机数和一个新的生成器。这将作为头部。然后我们说尾部将是使用新生成器生成的 n - 1 个数字。然后我们返回头部和列表的其余部分连接,以及我们从获取 n - 1 个随机数得到的最终生成器。

如果我们想要某个范围内的随机值怎么办?到目前为止的所有随机整数都太大或太小。如果我们想掷骰子怎么办?嗯,我们用 randomR 来实现这个目的。它有这个类型:

randomR :: (RandomGen g, Random a) :: (a, a) -> g -> (a, g)

这意味着它有点像 random,但它将一对值作为其第一个参数,这些值设置了下限和上限,并且最终生成的值将在这个范围内。

ghci> randomR (1,6) (mkStdGen 359353)
(6,1494289578 40692)
ghci> randomR (1,6) (mkStdGen 35935335)
(3,1250031057 40692)

还有 randomRs,它可以在我们定义的范围内生成随机值的流。看看这个例子:

ghci> take 10 $ randomRs ('a','z') (mkStdGen 3) :: [Char]
"ndkxbvmomg"

它看起来像一个非常秘密的密码,不是吗?

随机性和 I/O

你可能想知道这一节与 I/O 有什么关系。到目前为止,我们还没有做任何关于 I/O 的事情。我们总是通过创建一些任意的整数来手动制作我们的随机数生成器。问题是,如果我们真的在我们的实际程序中这样做,它们总是会返回相同的随机数,这对我们来说是不好的。这就是为什么 System.Random 提供了 getStdGen I/O 操作,它具有 IO StdGen 类型。它向系统请求一些初始数据,并使用它来启动 全局生成器getStdGen 在你将其绑定到某个东西时获取那个全局随机生成器。

这里有一个简单的程序,它可以生成一个随机字符串:

import System.Random

main = do
    gen <- getStdGen
    putStrLn $ take 20 (randomRs ('a','z') gen)

现在我们来测试它:

$ ./random_string
pybphhzzhuepknbykxhe
$ ./random_string
eiqgcxykivpudlsvvjpg
$ ./random_string
nzdceoconysdgcyqjruo
$ ./random_string
bakzhnnuzrkgvesqplrx

但你需要小心。仅仅执行两次 getStdGen 就会向系统请求两次相同的全局生成器。假设我们这样做:

import System.Random

main = do
    gen <- getStdGen
    putStrLn $ take 20 (randomRs ('a','z') gen)
    gen2 <- getStdGen
    putStr $ take 20 (randomRs ('a','z') gen2)

我们将两次打印出相同的字符串!

获取两个不同字符串的最佳方式是使用 newStdGen 操作,它将我们的当前随机生成器分成两个生成器。它使用其中一个更新全局随机生成器,并将另一个作为其结果产生。

import System.Random

main = do
    gen <- getStdGen
    putStrLn $ take 20 (randomRs ('a','z') gen)
    gen' <- newStdGen
    putStr $ take 20 (randomRs ('a','z') gen')

不仅当我们把 newStdGen 绑定到某个东西上时我们会得到一个新的随机生成器,全局生成器也会更新。这意味着如果我们再次执行 getStdGen 并将其绑定到某个东西上,我们会得到一个与 gen 不同的生成器。

这里有一个小程序,它会让用户猜测它正在想的是哪个数字:

import System.Random
import Control.Monad(when)

main = do
    gen <- getStdGen
    askForNumber gen

askForNumber :: StdGen -> IO ()
askForNumber gen = do
    let (randNumber, newGen) = randomR (1,10) gen :: (Int, StdGen)
    putStrLn "Which number in the range from 1 to 10 am I thinking of? "
    numberString <- getLine
    when (not $ null numberString) $ do
        let number = read numberString

        if randNumber == number
            then putStrLn "You are correct!"
            else putStrLn $ "Sorry, it was " ++ show randNumber
        askForNumber newGen

无标题的图片

我们创建了一个函数 askForNumber,它接受一个随机数生成器,并返回一个 I/O 操作,该操作会提示你输入一个数字,然后告诉你是否猜对了。

askForNumber 中,我们首先生成一个随机数和一个基于我们作为参数得到的生成器的新生成器,并将它们命名为 randNumbernewGen。(在这个例子中,让我们假设生成的数字是 7。)然后我们告诉用户猜测我们在想哪个数字。我们执行 getLine 并将其结果绑定到 numberString。当用户输入 7 时,numberString 变为 "7"。接下来,我们使用 when 来检查用户输入的字符串是否为空字符串。如果不是,将执行传递给 whendo 块组成的操作。我们使用 readnumberString 进行操作以将其转换为数字,因此 number 现在是 7

注意

如果用户输入了一些 read 无法解析的输入(比如 "haha"),我们的程序将因一个丑陋的错误信息而崩溃。如果您不希望程序在错误输入上崩溃,请使用 reads,它在无法读取字符串时返回一个空列表。当它成功时,它返回一个包含元组的单元素列表,其中一个组件是您希望的价值,另一个组件是它没有消费的字符串。试试看!

我们检查我们输入的数字是否等于随机生成的数字,并给用户相应的信息。然后我们递归地执行 askForNumber,但这次使用我们得到的新生成器。这给我们一个 I/O 操作,就像我们执行的操作一样,只是它依赖于不同的生成器。

main 只是从系统中获取一个随机生成器,并用它调用 askForNumber 来获取初始操作。

下面是我们的程序在运行中的样子:

$ ./guess_the_number
Which number in the range from 1 to 10 am I thinking of?
4
Sorry, it was 3
Which number in the range from 1 to 10 am I thinking of?
10
You are correct!
Which number in the range from 1 to 10 am I thinking of?
2
Sorry, it was 4
Which number in the range from 1 to 10 am I thinking of?
5
Sorry, it was 10
Which number in the range from 1 to 10 am I thinking of?

这是制作相同程序的另一种方法:

import System.Random
import Control.Monad(when)

main = do
    gen <- getStdGen
    let (randNumber, _) = randomR (1,10) gen :: (Int, StdGen)
    putStrLn "Which number in the range from 1 to 10 am I thinking of? "
    numberString <- getLine
    when (not $ null numberString) $ do
        let number = read numberString
        if randNumber == number
            then putStrLn "You are correct!"
            else putStrLn $ "Sorry, it was " ++ show randNumber
        newStdGen
        main

它与上一个版本非常相似,但我们不是创建一个接受生成器并递归调用自身的新更新生成器的函数,而是在 main 中完成所有工作。在告诉用户他的猜测是否正确后,我们更新全局生成器,然后再次调用 main。两种方法都是有效的,但我更喜欢第一种,因为它在 main 中做的工作更少,并且提供了一个我可以轻松重用的函数。

字节字符串

列表当然很有用。到目前为止,我们几乎在所有地方都使用了它们。有许多函数可以操作它们,而 Haskell 的惰性允许我们用过滤和映射列表来替换其他语言的 forwhile 循环。由于评估只有在真正需要时才会发生,因此像无限列表(甚至无限列表的无限列表!)对我们来说都不是问题。这就是为什么列表也可以用来表示流,无论是从标准输入读取还是从文件读取。我们只需打开一个文件,就可以将其作为字符串读取,即使它只有在需要时才会被访问。

无标题图片

然而,将文件作为字符串处理有一个缺点:它通常比较慢。列表真的很懒惰。记住,像[1,2,3,4]这样的列表是1:2:3:4:[]的语法糖。当列表的第一个元素被强制评估(比如打印它)时,列表的其余部分2:3:4:[]仍然只是一个列表的承诺,以此类推。我们称那个承诺为thunk

Thunk 基本上是一个延迟计算。Haskell 通过使用 thunks 并在需要时才计算它们来实现懒惰性,而不是预先计算一切。所以你可以把列表看作是承诺,一旦真正需要,下一个元素就会被交付,以及它后面的元素的承诺。不需要太大的思维跳跃就可以得出结论,将简单的数字列表作为一系列 thunks 处理可能不是世界上最高效的技术。

这种开销在大多数时候不会困扰我们,但当我们读取大文件并操作它们时,它就变成了一个缺点。这就是为什么 Haskell 有字节串。字节串有点像列表,只是每个元素的大小为 1 字节(或 8 位)。它们处理懒惰性的方式也不同。

严格和懒惰字节串

字节串有两种类型:严格和懒惰。严格字节串位于Data.ByteString中,并且完全去除了懒惰性。没有涉及 thunks。一个严格字节串代表一个数组中的字节序列。你不能有无限严格的字节串。如果你评估一个严格字节串的第一个字节,你必须评估整个序列。

另一种类型的字节串位于Data.ByteString.Lazy中。它们是懒惰的,但并不像列表那样懒惰。由于列表中的 thunks 数量与元素数量相同,因此对于某些目的来说,它们有点慢。懒惰字节串采用不同的方法。它们存储在块中(不要与 thunks 混淆!),每个块的大小为 64KB。所以如果你评估一个懒惰字节串中的字节(例如通过打印它),前 64KB 将被评估。之后,它只是对剩余块的一个承诺。懒惰字节串有点像大小为 64KB 的严格字节串列表。当你用懒惰字节串处理文件时,它将逐块读取。这很酷,因为它不会导致内存使用量激增,而且 64KB 很可能整齐地适合你的 CPU 的 L2 缓存。

如果你查看Data.ByteString.Lazy的文档,你会看到它有很多与Data.List中相同名称的函数,但类型签名中用ByteString代替了[a],用Word8代替了a。这些函数与在列表上工作的函数类似。因为名称相同,我们将在脚本中进行限定导入,然后将该脚本加载到 GHCi 中,通过字节串进行实验:

import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as S

B有懒惰的字节串类型和函数,而S有严格的。我们将主要使用懒惰版本。

pack函数的类型签名是pack :: [Word8] -> ByteString。这意味着它接受一个类型为Word8的字节数组并返回一个ByteString。您可以将其视为接受一个懒列表,并将其转换为不那么懒的列表,这样它只在 64KB 间隔处是懒的。

Word8类型类似于Int,但它表示一个无符号 8 位数字。这意味着它具有更小的范围,仅为 0 到 255。而且就像Int一样,它属于Num类型类。例如,我们知道值5是多态的,它可以像任何数值类型一样行动,包括Word8

这是我们将数字列表打包到字节字符串中的方法:

ghci> B.pack [99,97,110]
Chunk "can" Empty
ghci> B.pack [98..120]
Chunk "bcdefghijklmnopqrstuvwx" Empty

我们只将少量值打包到字节字符串中,因此它们适合在一个块中。Empty类似于列表中的[]——它们都表示一个空序列。

如您所见,您不需要指定您的数字类型为Word8,因为类型系统可以使数字选择该类型。如果您尝试使用像336这样的大数字作为Word8,它将简单地回绕到80

当我们需要逐字节检查字节字符串时,我们需要将其解包。unpack函数是pack的逆函数。它接受一个字节字符串并将其转换为字节数组。

这里有一个例子:

ghci> let by = B.pack [98,111,114,116]
ghci> by
Chunk "bort" Empty
ghci> B.unpack by
[98,111,114,116]

你也可以在严格和懒字节字符串之间来回转换。toChunks函数接受一个懒字节字符串并将其转换为一系列严格的字节字符串。fromChunks函数接受一系列严格的字节字符串并将其转换为懒字节字符串:

ghci> B.fromChunks [S.pack [40,41,42], S.pack [43,44,45], S.pack [46,47,48]]
Chunk "()*" (Chunk "+,-" (Chunk "./0" Empty))

如果您有很多小的严格字节字符串并且想要高效地处理它们,而不需要在内存中将它们合并成一个大的严格字节字符串,这是一个好方法。

字节字符串版本的:称为cons。它接受一个字节和一个字节字符串,并将字节放在开头。

ghci> B.cons 85 $ B.pack [80,81,82,84]
Chunk "U" (Chunk "PQRT" Empty)

字节字符串模块包含许多与Data.List中函数类似的功能,包括但不限于headtailinitnulllengthmapreversefoldlfoldrconcattakeWhilefilter等。有关字节字符串函数的完整列表,请查看字节字符串包的文档,链接为hackage.haskell.org/package/bytestring/

字节字符串模块也包含一些与System.IO中某些函数同名且行为相同的函数,但将Strings替换为ByteStrings。例如,System.IO中的readFile函数具有以下类型:

readFile :: FilePath -> IO String

字节字符串模块中的readFile函数具有以下类型:

readFile :: FilePath -> IO ByteString

注意

如果你使用严格的字节字符串并且尝试读取文件,整个文件将一次性被读入内存!使用懒字节字符串,文件将分块读取。

使用字节字符串复制文件

让我们编写一个程序,它接受两个文件名作为命令行参数并将第一个文件复制到第二个文件。请注意,System.Directory已经有一个名为copyFile的函数,但无论如何,我们都要实现自己的文件复制函数和程序。以下是代码:

import System.Environment
import System.Directory
import System.IO
import Control.Exception
import qualified Data.ByteString.Lazy as B

main = do
    (fileName1:fileName2:_) <- getArgs
    copy fileName1 fileName2

copy source dest = do
    contents <- B.readFile source
    bracketOnError
        (openTempFile "." "temp")
        (\(tempName, tempHandle) -> do
            hClose tempHandle
            removeFile tempName)
        (\(tempName, tempHandle) -> do
            B.hPutStr tempHandle contents
            hClose tempHandle
            renameFile tempName dest)

首先,在main函数中,我们只获取命令行参数并调用我们的copy函数,那里发生了魔法。一种做法是直接从一个文件读取并写入到另一个文件。但如果出了问题(比如我们没有足够的磁盘空间来复制文件),我们就会得到一个混乱的文件。所以我们会先写入到一个临时文件。然后如果出了问题,我们就可以简单地删除那个文件。

首先,我们使用B.readFile读取源文件的正文。然后我们使用bracketOnError来设置错误处理。我们使用openTempFile "." "temp"获取资源,它返回一个包含临时文件名和处理器的元组。接下来,我们说明如果发生错误会发生什么。如果出了问题,我们关闭处理器并删除临时文件。最后,我们进行复制。我们使用B.hPutStr将内容写入临时文件。我们关闭临时文件并将其重命名为我们想要的最终名称。

注意,我们只是使用了B.readFileB.hPutStr而不是它们的常规变体。我们不需要使用特殊的 bytestring 函数来打开、关闭和重命名文件。我们只需要在读取和写入时使用 bytestring 函数。

让我们测试一下:

$ ./bytestringcopy bart.txt bort.txt

一个不使用 bytestrings 的程序可能看起来就像这样。唯一的区别是我们使用了B.readFileB.writeFile而不是readFilewriteFile

许多时候,你只需进行必要的导入,然后在一些函数前加上有资格的模块名称,就可以将使用普通字符串的程序转换为使用 bytestrings 的程序。有时,你需要转换你编写的用于处理字符串的函数,以便它们可以处理 bytestrings,但这并不难。

无论何时你需要在一个大量读取数据到字符串的程序中提高性能,都尝试使用 bytestrings。很可能你只需付出很少的努力就能获得一些性能提升。我通常使用普通字符串编写程序,如果性能不令人满意,我会将它们转换为使用 bytestrings。

第十章。函数式解决问题

在本章中,我们将探讨几个有趣的问题,并思考如何尽可能优雅地使用函数式编程技术来解决它们。这将给你一个机会来锻炼你新学会的 Haskell 技能,并练习你的编码技巧。

逆波兰表示法计算器

通常,我们在学校处理代数表达式时,会以中缀方式书写。例如,我们写成 10 - (4 + 3) * 2。加法(+)、乘法(*)和减法(-)都是中缀运算符,就像 Haskell 中的中缀函数(+, elem 等)。作为人类,我们可以在脑海中轻松解析这种形式。缺点是我们需要使用括号来表示优先级。

另一种书写代数表达式的方法是使用逆波兰表示法,或称 RPN。在 RPN 中,运算符位于数字之后,而不是夹在它们之间。所以,我们不是写 4 + 3,而是写 4 3 +。但是,我们如何写包含多个运算符的表达式呢?例如,我们如何写一个将 43 相加然后乘以 10 的表达式?很简单:4 3 + 10 *。因为 4 3 + 等于 7,所以整个表达式等同于 7 10 *

计算逆波兰表达式

为了了解如何计算逆波兰表达式,想象一个数字栈。我们从左到右遍历表达式。每次遇到一个数字,就将其放在栈顶(将其压入栈中)。当我们遇到一个运算符时,我们从栈顶弹出两个数字(现在栈中只剩 10),使用这两个数字和运算符,然后将结果数压回栈中。当我们到达表达式的末尾时,我们应该剩下代表结果的单个数字(假设表达式是正确形成的)。

让我们看看如何计算逆波兰表达式 10 4 3 + 2 * -

  1. 我们将 10 压入栈中,因此栈现在包含 10

  2. 下一个项目是 4,所以我们也将它压入栈中。栈现在是 10, 4

  3. 我们用同样的方法处理 3,栈现在是 10, 4, 3

  4. 我们遇到了一个运算符:+。我们从栈顶弹出两个数字(现在栈中只剩 10),将这两个数字相加,然后将结果压回栈中。栈现在是 10, 7

  5. 我们将 2 压入栈中,栈现在变为 10, 7, 2

  6. 我们遇到了另一个运算符。我们从栈中弹出 72,将它们相乘,然后将结果压回栈中。72 相乘得到 14,因此栈现在是 10, 14

  7. 最后,我们遇到了一个 -。我们从栈中弹出 1014,从 10 中减去 14,然后将结果压回栈中。

  8. 栈中的数字现在是 -4。因为我们的表达式中没有更多的数字或运算符,这就是我们的结果!

无标题图片

因此,这就是手动计算逆波兰表达式的方法。现在让我们考虑如何编写一个 Haskell 函数来完成同样的工作。

编写逆波兰表达式(RPN)函数

我们的函数将接受一个包含逆波兰表达式(RPN)的字符串作为参数(例如 "10 4 3 + 2 * -"),并返回该表达式的结果。

那这个函数的类型会是什么样子呢?我们希望它接受一个字符串作为参数,并返回一个数字作为结果。比如说,我们希望结果是双精度浮点数,因为我们还想包含除法操作。所以它的类型可能类似于以下这样:

solveRPN :: String -> Double

注意

在处理实现之前先思考函数的类型声明非常有帮助。在 Haskell 中,函数的类型声明由于非常强的类型系统,可以告诉你很多关于函数的信息。

无标题图片

当在 Haskell 中实现一个问题的解决方案时,考虑你是如何手动解决这个问题的可能会有所帮助。对于我们的逆波兰表达式计算,我们将每个由空格分隔的数字或运算符视为一个单独的项目。因此,如果我们首先将字符串 "10 4 3 + 2 * -" 分解成如下的项目列表,可能会有所帮助:

["10","4","3","+","2","*","-"].

接下来,我们是如何处理我们头上的这个项目列表的?我们是从左到右遍历它的,并在这样做的同时保持一个栈。这个过程让你想起了什么吗?在 I Fold You So 中,你看到了几乎任何通过逐个遍历列表元素并构建(累加)一些结果——无论是数字、列表、栈还是其他东西——的函数都可以通过折叠来实现。

在这种情况下,我们将使用左折叠,因为我们是从左到右遍历列表的。累加值将是我们的栈,所以折叠的结果也将是一个栈(尽管如我们所见,它将只包含一个项目)。

还有一件事需要考虑,那就是我们如何表示这个栈。让我们使用一个列表,并将栈顶放在列表的头部。在列表头部(开始处)添加元素比在末尾添加要快得多。所以如果我们有一个包含 10, 4, 3 的栈,我们将它表示为列表 [3,4,10]

现在我们已经有了足够的信息来大致勾勒出我们的函数。它将接受一个类似于 "10 4 3 + 2 * -" 的字符串,并使用 words 函数将其分解成一系列项目。接下来,我们将对这个列表进行左折叠操作,最终得到一个只有一个项目的栈(在这个例子中,是 [-4])。我们将这个单一的项目从列表中取出,这就是我们的最终结果!

下面是这个函数的草图:

solveRPN :: String -> Double
solveRPN expression = head (foldl foldingFunction [] (words expression))
    where  foldingFunction stack item = ...

我们将表达式转换成一个项目列表。然后我们使用折叠函数对这个项目列表进行折叠。注意[],它代表起始累加器。累加器是我们的栈,所以[]代表一个空栈,这是我们开始的地方。在得到只有一个项目的最终栈后,我们应用head函数到这个列表上以获取项目。

现在只剩下实现一个折叠函数,它将接受一个栈,如[4,10],和一个项目,如"3",并返回一个新的栈[3,4,10]。如果栈是[4,10]且项目是"*",那么该函数需要返回[40]

在我们编写折叠函数之前,让我们将我们的函数转换为无参数风格,因为它有很多让我感到不安的括号:

solveRPN :: String -> Double
solveRPN = head . foldl foldingFunction [] . words
    where  foldingFunction stack item = ...

这样就好多了。

折叠函数将接受一个栈和一个项目,并返回一个新的栈。我们将使用模式匹配来获取栈的顶部项目,并对操作符如"*""-"进行模式匹配。这里就是实现了折叠函数的样子:

solveRPN :: String -> Double
solveRPN = head . foldl foldingFunction [] . words
    where  foldingFunction (x:y:ys) "*" = (y * x):ys
           foldingFunction (x:y:ys) "+" = (y + x):ys
           foldingFunction (x:y:ys) "-" = (y - x):ys
           foldingFunction xs numberString = read numberString:xs

我们将其展开为四种模式。模式将从顶部开始尝试。首先,折叠函数将检查当前项目是否是"*"。如果是,那么它将取一个如[3,4,9,3]的列表,并将其前两个元素分别命名为xy。所以在这种情况下,x将是3y将是4ys将是[9,3]。它将返回一个与ys相同的列表,但以xy的乘积作为头部。有了这个,我们将弹出栈中最上面的两个数字,将它们相乘,然后将结果推回栈上。如果项目不是"*",模式匹配将失败,将检查"+",依此类推。

如果项目不是操作符之一,我们假设它是一个表示数字的字符串。如果是数字,我们只需将read函数应用到这个字符串上,从中获取数字,并返回之前栈的状态,但将这个数字推到栈顶。

对于项目列表["2","3","+"],我们的函数将从左侧开始折叠。初始栈将是[]。它将使用[]作为栈(累加器)和"2"作为项目调用折叠函数。因为这个项目不是操作符,所以它将被读取并添加到[]的开头。所以新的栈现在是[2]。折叠函数将使用[2]作为栈和"3"作为项目再次被调用,生成新的栈[3,2]。然后它第三次被调用,栈是[3,2],项目是"+"。这导致这两个数字从栈中弹出,相加,然后推回。最终的栈是[5],这是我们返回的数字。

让我们玩一下我们的函数:

ghci> solveRPN "10 4 3 + 2 * -"
-4.0
ghci> solveRPN "2 3.5 +"
5.5
ghci> solveRPN "90 34 12 33 55 66 + * - +"
-3947.0
ghci> solveRPN "90 34 12 33 55 66 + * - + -"
4037.0
ghci> solveRPN "90 3.8 -"
86.2

太棒了!它工作了!

添加更多操作符

这个解决方案的一个优点是它可以很容易地修改以支持各种其他运算符。它们甚至不需要是二元运算符。例如,我们可以创建一个名为 "log" 的运算符,它只需从栈中弹出一个数字并返回其对数。我们还可以创建操作多个数字的运算符,例如 "sum",它将弹出所有数字并返回它们的总和。

让我们修改我们的函数以接受更多的一些运算符。

solveRPN :: String -> Double
solveRPN = head . foldl foldingFunction [] . words
    where  foldingFunction (x:y:ys) "*" = (y * x):ys
           foldingFunction (x:y:ys) "+" = (y + x):ys
           foldingFunction (x:y:ys) "-" = (y - x):ys
           foldingFunction (x:y:ys) "/" = (y / x):ys
           foldingFunction (x:y:ys) "^" = (y ** x):ys
           foldingFunction (x:xs) "ln" = log x:xs
           foldingFunction xs "sum" = [sum xs]
           foldingFunction xs numberString = read numberString:xs

/ 当然是除法,** 是指数。对于对数运算符,我们只需对单个元素和其余栈进行模式匹配,因为我们只需要一个元素来执行其自然对数。对于求和运算符,我们返回一个只有一个元素的栈,这个元素是到目前为止栈中所有元素的总和。

ghci> solveRPN "2.7 ln"
0.9932517730102834
ghci> solveRPN "10 10 10 10 sum 4 /"
10.0
ghci> solveRPN "10 10 10 10 10 sum 4 /"
12.5
ghci> solveRPN "10 2 ^"
100.0

我认为创建一个能够计算任意浮点逆波兰表示法(RPN)表达式并且可以在 10 行代码内轻松扩展的函数是非常棒的。

注意

这个逆波兰表示法(RPN)计算解决方案实际上并不具备容错性。当输入不合理的值时,可能会导致运行时错误。但别担心,你将在第十四章中学习如何使这个函数更加健壮。

伦敦希思罗机场到伦敦

假设我们正在出差。我们的飞机刚刚在英国降落,我们租了一辆车。我们有一个会议马上就要开始了,我们需要尽快(但安全地!)从希思罗机场赶到伦敦。

从希思罗机场到伦敦有两条主要道路,以及许多区域道路与之交叉。从一个交叉点到另一个交叉点的旅行时间是固定的。我们必须找到最佳路径,以确保我们按时到达伦敦的会议。我们从左侧开始,可以选择穿越到另一条主要道路或前进。

无标题图片

正如你在图片中看到的,在这种情况下,从希思罗机场到伦敦的最快路径是从主道路 B 开始,穿过,然后在 A 上前进,再次穿过,然后在 B 上前进两次。如果我们选择这条路径,需要 75 分钟。如果我们选择其他任何路径,都会花费更长的时间。

我们的任务是编写一个程序,它接受表示道路系统的输入并打印出穿过它的最快路径。以下是这个案例的输入示例:

50
10
30
5
90
20
40
2
25
10
8
0

为了在心理上解析输入文件,以三为单位读取它,并将道路系统划分为几个部分。每个部分由道路 A、道路 B 和一条交叉道路组成。为了使其整齐地分成三部分,我们说有一个最后的交叉部分,需要 0 分钟来驾驶通过。这是因为我们不在乎到达伦敦的地点,只要我们在伦敦,伙计!

正如我们在考虑逆波兰计算器问题时所做的那样,我们将分三步解决这个问题:

  1. 先放下 Haskell,想想我们如何手动解决这个问题。在逆波兰表达式计算器部分,我们首先确定在手动计算表达式时,我们在心中保持一种堆栈,然后逐个处理表达式中的每个项。

  2. 考虑我们如何在 Haskell 中表示我们的数据。对于我们的逆波兰表达式计算器,我们决定使用字符串列表来表示我们的表达式。

  3. 找出如何在 Haskell 中操作这些数据,以便我们产生一个解决方案。对于计算器,我们使用了左折叠来遍历字符串列表,同时保持一个堆栈以产生解决方案。

计算最快路径

那么,我们如何手动计算出从希思罗机场到伦敦的最快路径呢?嗯,我们只需看看整个图景,尝试猜测最快路径,并希望我们的猜测是正确的。这个解决方案对于非常小的输入是有效的,但如果我们有一个有 10,000 个路段的道路呢?哎呀!我们也不能肯定我们的解决方案是最优的;我们只能说我们相当肯定。所以,这不是一个好的解决方案。

这是我们道路系统的简化图:

无标题图片

我们能否计算出在道路A上的第一个交叉点(A上的第一个点,标记为A1)的最快路径?这相当简单。我们只需看看直接在A上前进是否比在B上前进然后横穿更快。显然,通过B前进然后横穿更快,因为这样只需要 40 分钟,而直接通过A需要 50 分钟。那么交叉点B1呢?我们看到直接通过B(成本为 10 分钟)要快得多,因为通过A然后横穿需要我们 80 分钟!

现在我们知道了到达A1的最快路径:通过B然后横穿。我们将这称为路径B, C,成本为 40 分钟。我们还知道到达B1的最快路径:直接通过B。所以这是一个只有B的路径,耗时 10 分钟。如果我们想知道两条主路上下一个交叉点的最快路径,这些知识对我们有帮助吗?哎呀,当然有帮助了!

让我们看看到达A2的最快路径是什么。要到达A2,我们要么直接从A1A2,要么从B1前进然后横穿(记住我们只能向前移动或横穿到另一边)。因为我们知道到达A1B1的成本,我们可以轻松地找出到达A2的最佳路径。到达A1需要我们 40 分钟,然后从A1A2需要 5 分钟,所以这是路径B, C, A,总成本为 45 分钟。到达B1只需要 10 分钟,但然后还需要额外 110 分钟才能到达B2并横穿!所以显然,到达A2的最快路径是B, C, A。同样,到达B2的最快方式是从A1前进然后横穿。

注意

你可能自己在想,“但通过在B1处先交叉然后前进,如何到达A2?” 好吧,当我们寻找到A1的最佳路径时,我们已经涵盖了从B1A1的交叉,所以在下一步中我们也不需要考虑这一点。

现在我们有了到A2B2的最佳路径,我们可以重复这个过程,直到到达终点。一旦我们计算了A4B4的最佳路径,耗时较短的那个就是最优路径。

因此,本质上,对于第二个部分,我们只是重复我们最初所做的步骤,但我们要考虑AB上的先前最佳路径。我们可以说,在第一步中,我们也考虑了AB上的最佳路径——它们都是成本为 0 分钟的空路径。

总结来说,为了从希思罗机场到伦敦找到最佳路径,我们这样做:

  1. 我们查看通往主路A上下一个路口的最佳路径。有两种选择:直接前进或从相对的道路开始,然后前进并交叉。我们记住成本和路径。

  2. 我们使用相同的方法来找到通往主路B上下一个路口的最佳路径,并记住它。

  3. 我们检查从上一个A路口到下一个路口的路径是否比从上一个B路口过去然后交叉的时间更短。我们记住较快的路径。我们对相对的路口也做同样的处理。

  4. 我们对每个部分都这样做,直到达到最后。

  5. 一旦我们到达终点,两个路径中较快的那个就是我们的最优路径。

因此,本质上,我们保持A路上的一个最快路径和一个B路上的最快路径。当我们到达终点时,这两个路径中较快的那个就是我们的路径。

现在我们知道如何手动找出最快的路径。如果你有足够的时间、纸张和铅笔,你可以找出任何数量部分的公路系统的最快路径。

使用 Haskell 表示道路系统

我们如何用 Haskell 的数据类型表示这个道路系统?

回想一下我们手动解决问题的方法,我们同时检查了三个道路部分的时间:A路上的道路部分,它在B路上的相对部分,以及C部分,它接触那两个部分并将它们连接起来。当我们寻找到A1B1的最快路径时,我们只处理了前三个部分的时间,分别是 50、10 和 30。我们将那称为一个部分。所以,我们用于这个示例的道路系统可以很容易地表示为四个部分:

  • 50, 10, 30

  • 5, 90, 20

  • 40, 2, 25

  • 10, 8, 0

总是保持我们的数据类型尽可能简单(尽管不能更简单了!)以下是我们的道路系统的数据类型:

data Section = Section { getA :: Int, getB :: Int, getC :: Int }
    deriving (Show)

type RoadSystem = [Section]

这是最简单的情况,我有一种感觉,它将完美地适用于实现我们的解决方案。

Section 是一个简单的代数数据类型,它包含三个整数,表示其三个道路部分的时间。我们还引入了一个类型同义词,表示RoadSystem是一个部分的列表。

注意

我们也可以使用(Int, Int, Int)三元组来表示一个道路部分。使用元组而不是创建自己的代数数据类型对于一些小而局部的东西来说很好,但对于更复杂的表现通常更好。它给类型系统提供了更多关于是什么的信息。我们可以使用(Int, Int, Int)来表示一个道路部分或三维空间中的向量,并且我们可以对这两个进行操作,但这允许我们混淆它们。如果我们使用SectionVector数据类型,那么我们就不可能意外地将一个向量加到道路系统的部分上。

我们从希思罗到伦敦的道路系统现在可以表示如下:

heathrowToLondon :: RoadSystem
heathrowToLondon = [ Section 50 10 30
                   , Section 5 90 20
                   , Section 40 2 25
                   , Section 10 8 0
                   ]

现在我们需要做的就是用 Haskell 实现这个解决方案。

编写最优路径函数

对于任何给定的道路系统计算最短路径的函数的类型声明应该是什么?它应该接受一个道路系统作为参数并返回一个路径。我们将路径表示为一个列表。

让我们引入一个Label类型,它只是ABC的枚举。我们还将创建一个名为Path的类型同义词。

data Label = A | B | C deriving (Show)
type Path = [(Label, Int)]

我们将称之为optimalPath的函数应该具有以下类型:

optimalPath :: RoadSystem -> Path

如果用heathrowToLondon道路系统调用,它应该返回以下路径:

[(B,10),(C,30),(A,5),(C,20),(B,2),(B,8)]

我们将需要从左到右遍历包含部分的列表,并在过程中保持AB上的最优路径。我们将随着列表的遍历,从左到右累积最佳路径。那听起来像什么?叮,叮,叮!没错,一个左折叠

在手动解决问题时,有一个步骤我们反复进行。这涉及到检查到目前为止的AB上的最优路径以及当前部分,以生成AB上的新最优路径。例如,一开始,AB的最优路径分别是[][]。我们检查了Section 50 10 30并得出结论,A1的新最优路径是[(B,10),(C,30)],而B1的最优路径是[(B,10)]。如果你把这个步骤看作一个函数,它接受一对路径和一个部分,并生成一对新的路径。所以它的类型是:

roadStep :: (Path, Path) -> Section -> (Path, Path)

让我们实现这个函数,因为它肯定是有用的:

roadStep :: (Path, Path) -> Section -> (Path, Path)
roadStep (pathA, pathB) (Section a b c) =
    let timeA = sum (map snd pathA)
        timeB = sum (map snd pathB)
        forwardTimeToA = timeA + a
        crossTimeToA = timeB + b + c
        forwardTimeToB = timeB + b
        crossTimeToB = timeA + a + c
        newPathToA = if forwardTimeToA <= crossTimeToA
                        then (A, a):pathA
                        else (C, c):(B, b):pathB
        newPathToB = if forwardTimeToB <= crossTimeToB
                        then (B, b):pathB
                        else (C, c):(A, a):pathA
    in  (newPathToA, newPathToB)

这里发生了什么?首先,我们根据A上的最佳时间计算道路A上的最优时间,并为B做同样的操作。我们做sum (map snd pathA),所以如果pathA类似于[(A,100),(C,20)],则timeA变为120

forwardTimeToA是如果我们直接从A上的前一个交叉路口前往下一个交叉路口,到达A上的下一个交叉路口所需的时间。它等于我们之前A上的最佳时间加上当前部分的A部分持续时间。

无标题图片

crossTimeToA 是如果我们从上一个 B 向前走到下一个 A 然后交叉过去所需的时间。这是到目前为止到达上一个 B 的最佳时间加上该部分 B 的时间加上该部分 C 的时间。

我们以同样的方式确定 forwardTimeToBcrossTimeToB

现在我们知道了到达 AB 的最佳方式,我们只需要根据这个新路径到 AB。如果我们只是向前走就能更快地到达 A,我们将 newPathToA 设置为 (A, a):pathA。基本上,我们将 Label A 和该部分的持续时间 a 预先添加到迄今为止的 A 上最优路径中。我们说到达下一个 A 交叉路口的最佳路径是到达前一个 A 交叉路口然后通过 A 向前走一步。记住,A 只是一个标签,而 aInt 类型。

为什么我们选择在列表开头添加元素而不是执行 pathA ++ [(A, a)]?嗯,将元素添加到列表的开头比添加到末尾要快得多。这意味着一旦我们使用这个函数折叠列表,路径就会是错误的顺序,但稍后很容易反转列表。

如果通过从道路 B 向前走到下一个 A 交叉路口并交叉过去更快,则 newPathToA 是到 B 的旧路径,然后向前走并交叉到 A。对于 newPathToB,我们做同样的事情,只是一切都进行了镜像。

最后,我们以一对的形式返回 newPathToAnewPathToB

让我们在 heathrowToLondon 的第一个部分上运行这个函数。因为这是第一个部分,AB 参数上最好的路径将是一对空列表。

ghci> roadStep ([], []) (head heathrowToLondon)
([(C,30),(B,10)],[(B,10)])

记住路径是反转的,所以从右到左读取它们。从这一点,我们可以看出到达下一个 A 的最佳路径是从 B 开始然后交叉到 A。到达下一个 B 的最佳路径是直接从 B 的起点向前走。

注意

当我们执行 timeA = sum (map snd pathA) 时,我们正在计算每一步路径上的时间。如果我们实现 roadStep 以接受并返回 AB 上的最佳时间以及路径本身,我们就不需要这样做。

现在我们有一个函数,它接受一对路径和一个部分,并生成一条新的最优路径,我们可以轻松地对一系列部分进行左折叠。roadStep 使用 ([], []) 和第一个部分调用,并返回到该部分的两个最优路径。然后它使用这对路径和下一个部分调用,依此类推。当我们走过所有部分后,我们剩下的是一对最优路径,其中较短的那个是我们的答案。考虑到这一点,我们可以实现 optimalPath

optimalPath :: RoadSystem -> Path
optimalPath roadSystem =
    let (bestAPath, bestBPath) = foldl roadStep ([], []) roadSystem
    in  if sum (map snd bestAPath) <= sum (map snd bestBPath)
            then reverse bestAPath
            else reverse bestBPath

我们对 roadSystem(记住它是一个部分的列表)进行左折叠,初始累加器是一个空路径对的组合。这个折叠的结果是一对路径,所以我们在这个对上模式匹配以获取路径本身。然后我们检查哪一个更快,并返回它。在返回之前,我们还反转了它,因为到目前为止的最优路径由于我们选择在前面添加而不是在后面添加而被反转了。

让我们测试一下!

ghci> optimalPath heathrowToLondon
[(B,10),(C,30),(A,5),(C,20),(B,2),(B,8),(C,0)]

这是我们应该得到的结果!它与我们的预期结果略有不同,因为有一个 (C,0) 步骤在末尾,这意味着一旦我们到达伦敦,我们就跨越到另一条道路上。但由于这次穿越不占用任何时间,这仍然是正确的结果。

从输入中获取道路系统

我们已经有了找到最优路径的函数,所以现在我们只需要从标准输入读取道路系统的文本表示,将其转换为 RoadSystem 类型,通过我们的 optimalPath 函数运行它,并打印出结果路径。

首先,让我们创建一个函数,它接受一个列表并将其分成相同大小的组。我们将它命名为 groupsOf

groupsOf :: Int -> [a] -> [[a]]
groupsOf 0 _ = undefined
groupsOf _ [] = []
groupsOf n xs = take n xs : groupsOf n (drop n xs)

对于 [1..10] 的参数,groupsOf 3 应该产生以下结果:

[[1,2,3],[4,5,6],[7,8,9],[10]]

如您所见,这是一个标准的递归函数。执行 groupsOf 3 [1..10] 等于以下内容:

[1,2,3] : groupsOf 3 [4,5,6,7,8,9,10]

当递归完成后,我们得到我们的列表,以三个为一组。这是我们的主函数,它从标准输入读取,将其制作成 RoadSystem,并打印出最短路径:

import Data.List

main = do
    contents <- getContents
    let threes = groupsOf 3 (map read $ lines contents)
        roadSystem = map (\[a,b,c] -> Section a b c) threes
        path = optimalPath roadSystem
        pathString = concat $ map (show . fst) path
        pathTime = sum $ map snd path
    putStrLn $ "The best path to take is: " ++ pathString
    putStrLn $ "Time taken: " ++ show pathTime

首先,我们从标准输入获取所有内容。然后,我们将 lines 应用到我们的内容上,将类似 "50\n10\n30\n ... 的内容转换为更干净的内容,如 ["50","10","30" ...。然后,我们映射 read 到它上面,将其转换为数字列表。我们将 groupsOf 3 应用到它上面,使其变成长度为 3 的列表的列表。然后,我们将 lambda 函数 (\[a,b,c] -> Section a b c) 映射到这个列表的列表上。

如您所见,lambda 函数只是将长度为 3 的列表转换为一个部分。因此,roadSystem 现在是我们的道路系统,它甚至具有正确的类型:RoadSystem(或 [Section])。我们将其应用于 optimalPath,得到路径和总时间的良好文本表示,并将其打印出来。

我们将以下文本保存到名为 paths.txt 的文件中:

50
10
30
5
90
20
40
2
25
10
8
0

然后,我们像这样将其输入到我们的程序中:

$ runhaskell heathrow.hs < paths.txt
The best path to take is: BCACBBC
Time taken: 75

工作得很好!

您可以使用对 Data.Random 模块的了解来生成一个更长的道路系统,然后将其输入到我们刚刚编写的代码中。如果您遇到栈溢出,可以将 foldl 改为 foldl',将 sum 改为 foldl' (+) 0。或者,在运行之前,尝试按以下方式编译它:

$ ghc --make -O heathrow.hs

包含 O 标志会开启优化,这有助于防止 foldlsum 等函数导致栈溢出。

第十一章. 应用函子

Haskell 结合了纯度、高阶函数、参数化代数数据类型和类型类,这使得实现多态性比在其他语言中要容易得多。我们不需要考虑类型属于一个大层次。相反,我们考虑类型可以像什么样子,然后通过适当的类型类将它们连接起来。一个Int可以像很多东西一样——一个可比较的东西、一个有序的东西、一个可枚举的东西等等。

类型类是开放的,这意味着我们可以定义自己的数据类型,考虑它可能像什么样子,并将其与定义其行为的类型类连接起来。我们还可以引入一个新的类型类,然后使已经存在的类型成为它的实例。正因为如此,以及因为 Haskell 的类型系统允许我们仅通过函数的类型声明就了解很多关于函数的信息,我们可以定义定义非常通用和抽象行为的类型类。

我们之前讨论过定义操作以检查两个事物是否相等以及按某种顺序比较两个事物的类型类。这些行为非常抽象且优雅,尽管我们并不认为它们非常特别,因为我们大多数时候都在处理这些。在第七章(第七章. 创建自己的类型和类型类)中介绍了函子,它们是值可以被映射的类型。这是一个有用的例子,展示了类型类可以描述的既实用又相当抽象的性质。在本章中,我们将更深入地探讨函子,以及比函子更强、更有用的版本,称为应用函子

函数式编程重述

正如你在第七章(第七章. 创建自己的类型和类型类)中学到的,函子是可以被映射的事物,例如列表、Maybe和树。在 Haskell 中,它们通过Functor类型类来描述,该类型类只有一个类型类方法:fmapfmap的类型是fmap :: (a -> b) -> f a -> f b,这意味着“给我一个接受a并返回b的函数,以及一个包含a(或多个)的盒子,我会给你一个包含b(或多个)的盒子。”它将函数应用于盒子内的元素。

我们还可以将函子值视为具有附加上下文的值。例如,Maybe值有额外的上下文,即它们可能失败。对于列表,上下文是值实际上可以同时是多个值或没有值。fmap在保留其上下文的同时将函数应用于值。

如果我们想让一个类型构造器成为Functor的实例,它必须具有* -> *的类型,这意味着它恰好接受一个具体的类型作为类型参数。例如,Maybe可以成为一个实例,因为它接受一个类型参数来产生一个具体类型,如Maybe IntMaybe String。如果一个类型构造器接受两个参数,如Either,我们需要部分应用类型构造器,直到它只接受一个类型参数。因此,我们不能写instance Functor Either where,但我们可以写instance Functor (Either a) where。然后如果我们想象fmap只为Either a,它将具有以下类型声明:

fmap :: (b -> c) -> Either a b -> Either a c

如你所见,Either a部分是固定的,因为Either a只接受一个类型参数。

I/O 操作作为函子

你已经学会了如何让许多类型(好吧,类型构造器实际上)成为Functor的实例:[]MaybeEither a,以及我们在第七章中创建的Tree类型。你看到了如何通过它们映射函数以获得巨大的好处。现在,让我们来看看IO实例。

如果某个值具有如IO String这样的类型,这意味着它是一个将进入现实世界并为我们获取一些字符串的 I/O 操作,然后它将作为结果产生。我们可以在do语法中使用<-来将这个结果绑定到一个名字上。在第八章中,我们讨论了 I/O 操作如何像有脚的盒子一样走出去并为我们从外部世界获取一些值。我们可以检查它们获取了什么,但在检查之后,我们需要将值包装回IO中。考虑到这个有脚的盒子类比,你可以看到IO如何像一个函子。

让我们看看IO是如何成为Functor的实例的。当我们对 I/O 操作应用一个函数时,我们想要得到一个 I/O 操作,它执行相同的事情,但将我们的函数应用于其结果值。以下是代码:

instance Functor IO where
    fmap f action = do
        result <- action
        return (f result)

在 I/O 操作上映射某个东西的结果将是一个 I/O 操作,所以我们可以立即使用do语法来粘合两个操作并创建一个新的操作。在fmap的实现中,我们创建一个新的 I/O 操作,它首先执行原始的 I/O 操作,并调用其结果为result。然后我们做return (f result)。回想一下,return是一个函数,它创建一个不执行任何操作但只产生结果的 I/O 操作。

do块产生的操作将始终产生其最后一个操作的值。这就是为什么我们使用return来创建一个不执行任何操作但只产生f result作为新 I/O 操作结果的 I/O 操作。查看以下代码:

main = do line <- getLine
          let line' = reverse line
          putStrLn $ "You said " ++ line' ++ " backwards!"
          putStrLn $ "Yes, you said " ++ line' ++ " backwards!"

用户被提示输入一行,我们将其返回,但顺序相反。以下是使用fmap重写此操作的方法:

main = do line <- fmap reverse getLine
          putStrLn $ "You said " ++ line ++ " backwards!"
          putStrLn $ "Yes, you really said " ++ line ++ " backwards!"

无标题图片

正如我们可以对 Just "blah" 应用 fmap reverse 来得到 Just "halb" 一样,我们也可以对 getLine 应用 fmap reversegetLine 是一个类型为 IO String 的 I/O 操作,对它应用 reverse 会生成一个 I/O 操作,该操作将进入现实世界获取一行,然后对其结果应用 reverse。以同样的方式,我们可以将一个函数应用于 Maybe 框架内部的内容,我们也可以将一个函数应用于 IO 框架内部的内容,但它必须进入现实世界以获取某些内容。然后当我们使用 <- 绑定它到一个名称时,该名称将反映已经应用了 reverse 的结果。

I/O 操作 fmap (++"!") getLine 的行为就像 getLine 一样,除了其结果总是附加了 "!"

如果 fmap 仅限于 IO,其类型将是 fmap :: (a -> b) -> IO a -> IO bfmap 接收一个函数和一个 I/O 操作,并返回一个新的 I/O 操作,它类似于旧的操作,除了函数被应用于其包含的结果。

如果你发现自己将 I/O 操作的结果绑定到一个名称上,只是为了应用一个函数并称其为另一个名称,考虑使用 fmap。如果你想将多个函数应用于一个在函子内部的数据,你可以在顶层声明自己的函数,创建一个 lambda 函数,或者,理想情况下,使用函数组合:

import Data.Char
import Data.List

main = do line <- fmap (intersperse '-' . reverse . map toUpper) getLine
          putStrLn line

如果我们用输入 hello there 运行它,会发生以下情况:

$ ./fmapping_io
hello there
E-R-E-H-T- -O-L-L-E-H

intersperse '-' . reverse . map toUpper 函数接收一个字符串,将其映射到 toUpper,然后对结果应用 reverse,最后将 intersperse '-' 应用到结果上。这是一种更优雅的写法,如下所示:

(\xs -> intersperse '-' (reverse (map toUpper xs)))

函数作为函子

我们一直在处理的一个 Functor 的另一个实例是 (->) r。但是等等!(->) r 究竟是什么意思?函数类型 r -> a 可以重写为 (->) r a,就像我们可以将 2 + 3 写作 (+) 2 3 一样。当我们将其视为 (->) r a 时,我们可以以稍微不同的方式看待 (->)。它只是一个类型构造器,它接受两个类型参数,就像 Either 一样。

但请记住,类型构造器必须恰好接受一个类型参数,以便它可以成为 Functor 的实例。这就是为什么我们不能将 (->) 作为 Functor 的实例;然而,如果我们部分应用它到 (->) r,它不会引起任何问题。如果语法允许类型构造器通过部分应用(如我们可以通过 (2+) 部分应用 +,它等同于 (+) 2),我们可以将 (->) r 写作 (r ->)

函数如何成为函子?让我们看看实现,它位于 Control.Monad.Instances 中:

instance Functor ((->) r) where
    fmap f g = (\x -> f (g x))

首先,让我们思考一下 fmap 的类型:

fmap :: (a -> b) -> f a -> f b

接下来,让我们在心中将每个 f,即我们的函子实例所扮演的角色,替换为 (->) r。这将使我们能够看到 fmap 应该如何在这个特定实例中表现。以下是结果:

fmap :: (a -> b) -> ((->) r a) -> ((->) r b)

现在,我们可以将 (->) r a(->) r b 类型写成中缀 r -> ar -> b,就像我们通常处理函数一样:

fmap :: (a -> b) -> (r -> a) -> (r -> b)

好吧,在函数上映射函数必须产生一个函数,就像在 Maybe 上映射函数必须产生一个 Maybe,以及在列表上映射函数必须产生一个列表一样。前面的类型告诉我们什么?我们看到它接受一个从 ab 的函数和一个从 ra 的函数,并返回一个从 rb 的函数。这让你想起了什么吗?是的,函数组合!我们将 r -> a 的输出管道输入到 a -> b 的输入中,以获得一个 r -> b 的函数,这正是函数组合的全部内容。这里有另一种编写此实例的方法:

instance Functor ((->) r) where
    fmap = (.)

这清楚地表明,在函数上使用 fmap 实际上就是函数组合。在脚本中,导入 Control.Monad.Instances,因为实例定义在那里,然后加载脚本并尝试在函数上应用映射:

ghci> :t fmap (*3) (+100)
fmap (*3) (+100) :: (Num a) => a -> a
ghci> fmap (*3) (+100) 1
303
ghci> (*3) `fmap` (+100) $ 1
303
ghci> (*3) . (+100) $ 1
303
ghci> fmap (show . (*3)) (+100) 1
"303"

我们可以将 fmap 调用为一个中缀函数,以便清楚地看到它与 . 的相似之处。在第二行输入中,我们正在将 (*3) 映射到 (+100),这将产生一个函数,它将接受一个输入,将 (+100) 应用到该输入上,然后将 (*3) 应用到该结果上。然后我们将该函数应用到 1 上。

正如所有函子一样,函数可以被看作是有上下文的价值。当我们有一个像 (+3) 这样的函数时,我们可以将值视为函数的最终结果,上下文是我们需要将函数应用到某物上才能得到结果。使用 fmap (*3)(+100) 上将创建另一个像 (+100) 一样的函数,但在产生结果之前,(*3) 将被应用到该结果上。

当在函数上使用时,fmap 是函数组合的事实现在可能并不那么有用,但至少它非常有趣。它也让我们的大脑有点弯曲,并让我们看到那些更像计算而不是盒子的东西(IO(->) r)可以成为函子。被映射到计算上的函数会产生相同类型的计算,但该计算的结果会通过该函数进行修改。

无标题图片

在我们继续到 fmap 应该遵循的规则之前,让我们再次思考一下 fmap 的类型:

fmap :: (Functor f) => (a -> b) -> f a -> f b

在 第五章 中引入柯里化函数时,首先声明所有 Haskell 函数实际上只接受一个参数。一个 a -> b -> c 函数只接受一个类型为 a 的参数,并返回一个 b -> c 的函数,该函数接受一个参数并返回 c。这就是为什么用太少的参数调用函数(部分应用它)会给我们返回一个接受我们省略的参数数量的函数(如果我们再次将函数视为接受多个参数)。因此,a -> b -> c 可以写成 a -> (b -> c),以使柯里化更明显。

同样地,如果我们写fmap :: (a -> b) -> (f a -> f b),我们可以把fmap不是看作是一个接受一个函数和一个函子值并返回一个函子值的函数,而是一个接受一个函数并返回一个新函数的函数,这个新函数与旧函数类似,除了它接受一个函子值作为参数并返回一个函子值作为结果。它接受一个a -> b函数并返回一个函数f a -> f b。这被称为提升一个函数。让我们使用 GHCi 的:t命令来玩这个想法:

ghci> :t fmap (*2)
fmap (*2) :: (Num a, Functor f) => f a -> f a
ghci> :t fmap (replicate 3)
fmap (replicate 3) :: (Functor f) => f a -> f [a]

表达式fmap (*2)是一个函数,它接受一个数字上的函子f并返回一个数字上的函子。这个函子可以是列表、MaybeEither String或其他任何东西。表达式fmap (replicate 3)将接受任何类型的函子并返回一个该类型元素列表的函子。如果我们部分应用,比如fmap (++"!")并将其绑定到 GHCi 中的一个名称,这会更加明显。

你可以从两种方式来思考fmap

  • 作为一种函数,它接受一个函数和一个函子值,然后在该函子值上映射该函数

  • 作为一种函数,它接受一个函数并将其提升,使其在函子值上操作

这两种观点都是正确的。

类型fmap (replicate 3) :: (Functor f) => f a -> f [a]意味着这个函数将在任何函子上工作。它将做什么取决于函子。如果我们对列表使用fmap (replicate 3),将选择列表的fmap实现,这仅仅是map。如果我们对Maybe a使用它,它将对Just中的值应用replicate 3。如果是Nothing,它将保持Nothing。以下是一些示例:

ghci> fmap (replicate 3) [1,2,3,4]
[[1,1,1],[2,2,2],[3,3,3],[4,4,4]]
ghci> fmap (replicate 3) (Just 4)
Just [4,4,4]
ghci> fmap (replicate 3) (Right "blah")
Right ["blah","blah","blah"]
ghci> fmap (replicate 3) Nothing
Nothing
ghci> fmap (replicate 3) (Left "foo")
Left "foo"

函子定律

所有函子都应表现出某些类型的属性和行为。它们应该可靠地表现为可以被映射的对象。在函子上调用fmap应该只是将函数映射到函子——仅此而已。这种行为在函子定律中描述。所有Functor实例都应该遵守这两条定律。Haskell 不会自动执行这些定律,所以当你创建一个函子时,你需要自己测试它们。标准库中的所有Functor实例都遵守这些定律。

法则 1

第一条函子定律指出,如果我们将id函数映射到函子值上,我们得到的函子值应该与原始的函子值相同。更正式地说,这意味着fmap id = id。所以本质上,这表明如果我们对函子值执行fmap id,它应该与直接应用id到值上相同。记住id是恒等函数,它只是返回其参数未修改。它也可以写成\x -> x。如果我们把函子值看作是可以被映射的对象,fmap id = id定律似乎有点平凡或明显。

让我们看看这个定律对于几个函子的值是否成立。

ghci> fmap id (Just 3)
Just 3
ghci> id (Just 3)
Just 3
ghci> fmap id [1..5]
[1,2,3,4,5]
ghci> id [1..5]
[1,2,3,4,5]
ghci> fmap id []
[]
ghci> fmap id Nothing
Nothing

Maybefmap实现为例,我们可以弄清楚为什么第一条函子定律成立:

instance Functor Maybe where
    fmap f (Just x) = Just (f x)
    fmap f Nothing = Nothing

我们想象id在实现中扮演了f参数的角色。我们看到如果我们对Just x应用fmap id,结果将是Just (id x),因为id只是返回其参数,我们可以推断出Just (id x)等于Just x。所以现在我们知道,如果我们对带有Just构造函数的Maybe值应用id,我们将得到相同的值。

看到对Nothing值应用id返回相同的值是显而易见的。所以从fmap实现的这两个方程中,我们发现定律fmap id = id成立。

法则 2

第二定律表明,先组合两个函数,然后将结果函数映射到 functor 上,应该与首先将一个函数映射到 functor 上,然后映射另一个函数相同。形式上写成,这意味着fmap (f . g) = fmap f . fmap g。或者用另一种方式写,对于任何 functor 值x,以下应该成立:fmap (f . g) x = fmap f (fmap g x)

无标题图片

如果我们可以证明某个类型遵守了两个 functor 定律,我们就可以依赖它在映射时的基本行为与其他 functors 相同。我们可以知道,当我们对它使用fmap时,幕后不会有除了映射之外的其他操作,并且它将表现得像一个可以被映射的对象——即,一个 functor。

我们通过查看某个类型的fmap实现,然后使用我们用来检查Maybe是否遵守第一定律的方法,来找出某个类型的第二定律是如何成立的。所以,为了检查Maybe的第二 functor 定律是如何成立的,如果我们对Nothing应用fmap (f . g),我们得到Nothing,因为对Nothing应用任何函数都会返回Nothing。如果我们调用fmap f (fmap g Nothing),我们得到Nothing,原因相同。

如果Maybe是一个Nothing值,那么看到第二定律是如何成立的很容易。但是,如果它是一个Just值呢?嗯,如果我们对(Just x)应用fmap (f . g),从实现中我们可以看到它被实现为Just ((f . g) x),这是Just (f (g x))。如果我们对(fmap g (Just x))应用fmap f,从实现中我们可以看到fmap g (Just x)Just (g x)。因此,fmap f (fmap g (Just x))等于fmap f (Just (g x)),从实现中我们可以看到这等于Just (f (g x))

如果你对这个证明有点困惑,不要担心。确保你理解了函数组合是如何工作的。很多时候,你可以直观地看到这些定律是如何成立的,因为类型表现得像容器或函数。你也可以在类型的许多不同值上尝试它们,并能够有把握地说一个类型确实遵守了定律。

违反定律

让我们看看一个类型构造函数是Functor类型类的实例但实际上并不是 functor 的病理例子,因为它不满足定律。假设我们有以下类型:

data CMaybe a = CNothing | CJust Int a deriving (Show)

这里的 C 代表计数器。它是一种数据类型,看起来很像 Maybe a,但 Just 部分包含两个字段而不是一个。CJust 值构造函数中的第一个字段将始终具有 Int 类型,并且它将是一种计数器。第二个字段是类型 a,它来自类型参数,其类型将取决于我们为 CMaybe a 选择的具体类型。让我们来玩玩我们的新类型:

ghci> CNothing
CNothing
ghci> CJust 0 "haha"
CJust 0 "haha"
ghci> :t CNothing
CNothing :: CMaybe a
ghci> :t CJust 0 "haha"
CJust 0 "haha" :: CMaybe [Char]
ghci> CJust 100 [1,2,3]
CJust 100 [1,2,3]

如果我们使用 CNothing 构造函数,则没有字段。如果我们使用 CJust 构造函数,第一个字段是一个整数,第二个字段可以是任何类型。让我们将其作为 Functor 的实例,这样每次我们使用 fmap 时,函数都应用于第二个字段,而第一个字段增加 1。

instance Functor CMaybe where
    fmap f CNothing = CNothing
    fmap f (CJust counter x) = CJust (counter+1) (f x)

这有点像 Maybe 的实例实现,除了当我们对不表示空盒(CJust 值)的值执行 fmap 时,我们不仅将函数应用到内容上;我们还增加计数器 1。到目前为止,一切看起来都很酷。我们甚至可以玩玩这个:

ghci> fmap (++"ha") (CJust 0 "ho")
CJust 1 "hoha"
ghci> fmap (++"he") (fmap (++"ha") (CJust 0 "ho"))
CJust 2 "hohahe"
ghci> fmap (++"blah") CNothing
CNothing

这是否遵守函子定律?为了看到某物不遵守定律,只需找到一个反例就足够了:

ghci> fmap id (CJust 0 "haha")
CJust 1 "haha"
ghci> id (CJust 0 "haha")
CJust 0 "haha"

根据第一个函子定律,如果我们对函子值映射 id,它应该与直接用相同的函子值调用 id 相同。我们的例子表明,这并不适用于我们的 CMaybe 函子。尽管它是 Functor 类型类的一部分,但它不遵守这个函子定律,因此它不是一个函子。

由于 CMaybe 即使假装是函子也未能成为函子,因此将其用作函子可能会导致一些错误的代码。当我们使用函子时,我们不应该关心我们首先组合几个函数然后映射到函子值,还是我们依次将每个函数映射到函子值。但与 CMaybe 不同,这很重要,因为它跟踪它被映射了多少次。这很不酷!如果我们想让 CMaybe 遵守函子定律,我们需要确保在执行 fmapInt 字段保持不变。

起初,函子定律可能看起来有些令人困惑且不必要。但如果我们知道一个类型遵守这两个定律,我们就可以对其行为做出某些假设。如果一个类型遵守函子定律,我们知道对那个类型的值调用 fmap 只会将函数映射到它上面——仅此而已。这导致代码更加抽象和可扩展,因为我们可以使用定律来推理任何函子都应该具有的行为,并编写在任何函子上都能可靠运行的函数。

下次你将一个类型作为 Functor 的实例时,花一分钟确保它遵守函子定律。你可以逐行检查实现,看看定律是否成立,或者尝试找到一个反例。一旦你处理了足够的函子,你将开始认识到它们共有的属性和行为,并开始直观地看到类型是否遵守函子定律。

使用应用函子

在本节中,我们将探讨应用函子,它们是增强版的函子。

到目前为止,我们一直专注于映射只接受一个参数的函数到函子。但是,当我们映射一个接受两个参数的函数到函子上时会发生什么?让我们看看这个的几个具体例子。

无标题图片

如果我们有Just 3并调用fmap (*) (Just 3),我们会得到什么?从MaybeFunctor实例实现中,我们知道如果它是一个Just值,它将把函数应用于Just内部的值。因此,执行fmap (*) (Just 3)的结果是Just ((*) 3),如果我们使用部分应用,也可以写成Just (3 *)。有趣的是!我们得到了一个被Just包裹的函数!

这里有一些函子值内部的更多函数:

ghci> :t fmap (++) (Just "hey")
fmap (++) (Just "hey") :: Maybe ([Char] -> [Char])
ghci> :t fmap compare (Just 'a')
fmap compare (Just 'a') :: Maybe (Char -> Ordering)
ghci> :t fmap compare "A LIST OF CHARS"
fmap compare "A LIST OF CHARS" :: [Char -> Ordering]
ghci> :t fmap (\x y z -> x + y / z) [3,4,5,6]
fmap (\x y z -> x + y / z) [3,4,5,6] :: (Fractional a) => [a -> a -> a]

如果我们将compare映射到一个字符列表上,它的类型是(Ord a) => a -> a -> Ordering,我们会得到一个类型为Char -> Ordering的函数列表,因为compare函数被部分应用于列表中的字符。它不是一个(Ord a) => a -> Ordering函数的列表,因为第一个a应用的是Char,因此第二个a必须决定其类型为Char

我们看到通过将“多参数”函数映射到函子值上,我们得到了包含函数在内的函子值。那么现在我们能对它们做什么呢?首先,我们可以映射接受这些函数作为参数的函数,因为函子值内部的东西将被提供给我们要映射的函数作为参数:

ghci> let a = fmap (*) [1,2,3,4]
ghci> :t a
a :: [Integer -> Integer]
ghci> fmap (\f -> f 9) a
[9,18,27,36]

但如果我们有一个Just (3 *)的函子值和一个Just 5的函子值,而我们想要从Just (3 *)中提取函数并映射到Just 5上呢?使用普通的函子,我们就没有运气了,因为它们只支持在现有的函子上映射普通函数。即使当我们对包含函数的函子映射\f -> f 9时,我们只是在它上面映射了一个普通函数。但我们不能使用fmap提供的方法将一个函子值内部的函数映射到另一个函子值上。我们可以通过模式匹配Just构造函数来从中提取函数,然后映射到Just 5上,但我们正在寻找一个更通用和抽象的方法,它可以在函子之间工作。

问候应用函子

认识一下Applicative类型类,它在Control.Applicative模块中定义。它定义了两个函数:pure<*>。它不提供这两个函数的默认实现,所以如果我们想让某个东西成为一个应用函子,我们需要定义它们两个。类定义如下:

class (Functor f) => Applicative f where
    pure :: a -> f a
    (<*>) :: f (a -> b) -> f a -> f b

这简单的三行类定义告诉我们很多!第一行开始定义 Applicative 类,并且也引入了一个类约束。约束说,如果我们想使一个类型构造器成为 Applicative 类型类的一部分,它必须首先在 Functor 中。这就是为什么如果我们知道一个类型构造器是 Applicative 类型类的一部分,它也在 Functor 中,所以我们可以对它使用 fmap

它定义的第一个方法被称为 pure。它的类型声明是 pure :: a -> f a。在这里,f 扮演了我们应用函子实例的角色。因为 Haskell 有一个非常好的类型系统,而且因为所有函数能做的只是接受一些参数并返回一些值,我们可以从类型声明中得知很多信息,这也不例外。

pure 应该接受任何类型的值,并返回一个包含该值的适用值的适用值。这里的“里面”再次指的是我们的盒子类比,尽管我们已经看到它并不总是经得起推敲。但 a -> f a 的类型声明仍然相当描述性。我们接受一个值,并将其包裹在一个适用值中,这个适用值的结果里面就是那个值。关于 pure 的更好思考方式可能是说它接受一个值并将其放入某种默认(或纯)上下文中——一个最小的上下文,仍然产生那个值。

<*> 函数非常有趣。它有这个类型声明:

f (a -> b) -> f a -> f b

这让你想起什么吗?它就像 fmap :: (a -> b) -> f a -> f b。你可以把 <*> 函数看作是某种增强版的 fmap。而 fmap 接受一个函数和一个函子值,并在函子值内部应用这个函数,<*> 接受一个包含函数的函子值和另一个函子,然后从第一个函子中提取那个函数,然后将其映射到第二个函子上。

可能的适用函子

让我们看看 MaybeApplicative 实例实现:

instance Applicative Maybe where
    pure = Just
    Nothing <*> _ = Nothing
    (Just f) <*> something = fmap f something

再次,从类定义中,我们看到扮演应用函子角色的 f 应该接受一个具体类型作为参数,所以我们写 instance Applicative Maybe where 而不是 instance Applicative (Maybe a) where

接下来,我们有 pure。记住,它应该接受一些东西并将其包裹在应用值中。我们写 pure = Just,因为像 Just 这样的值构造器是普通函数。我们也可以写成 pure x = Just x

最后,我们有 <*> 的定义。我们不能从一个 Nothing 中提取一个函数,因为它里面没有函数。所以如果我们尝试从一个 Nothing 中提取一个函数,结果是 Nothing

Applicative类的类定义中,有一个Functor类约束,这意味着我们可以假设<*>函数的两个参数都是函子值。如果第一个参数不是一个Nothing,而是一个包含某些函数的Just,那么我们就可以说我们想要将这个函数映射到第二个参数上。这也处理了第二个参数是Nothing的情况,因为对任何函数在Nothing上执行fmap操作都会返回一个Nothing。所以对于Maybe<*>会从左边的值中提取函数(如果它是Just),并将其映射到右边的值上。如果任一参数是Nothing,结果就是Nothing

现在我们来试一试:

ghci> Just (+3) <*> Just 9
Just 12

ghci> pure (+3) <*> Just 10
Just 13
ghci> pure (+3) <*> Just 9
Just 12
ghci> Just (++"hahah") <*> Nothing
Nothing
ghci> Nothing <*> Just "woot"
Nothing

你可以看到在这个情况下,执行pure (+3)Just (+3)是相同的。如果你在应用式上下文中处理Maybe值(使用它们与<*>一起),请使用pure;否则,坚持使用Just

前四行展示了函数是如何被提取然后映射的,但在这个情况下,它们可以通过仅仅将未包装的函数映射到函子上来实现。最后一行很有趣,因为我们尝试从一个Nothing中提取一个函数,然后将其映射到某个值上,结果是Nothing

对于普通函子,当你将一个函数映射到一个函子上时,你无法以任何通用方式得到结果,即使结果是部分应用函数。另一方面,应用式函子允许你用一个函数操作多个函子。

应用式风格

使用Applicative类型类,我们可以链式使用<*>函数,这样我们就可以无缝地对多个应用值进行操作,而不仅仅是单个值。例如,看看这个:

ghci> pure (+) <*> Just 3 <*> Just 5
Just 8
ghci> pure (+) <*> Just 3 <*> Nothing
Nothing
ghci> pure (+) <*> Nothing <*> Just 5
Nothing

无标题图片

我们将+函数包裹在一个应用值中,然后使用<*>用两个参数(都是应用值)调用它。

让我们一步一步地看看这是如何发生的。<*>是左结合的,这意味着这个:

pure (+) <*> Just 3 <*> Just 5

这与这个相同:

(pure (+) <*> Just 3) <*> Just 5

首先,+函数被放入一个应用值中——在这个例子中,是一个包含函数的Maybe值。所以我们有pure (+),它是Just (+)。接下来,Just (+) <*> Just 3发生了。这个结果是因为部分应用。只将+函数应用到3上会得到一个接受一个参数并将 3 加到它上面的函数。最后,执行Just (3+) <*> Just 5,结果是Just 8

这不是太棒了吗?应用式函子和pure f <*> x <*> y <*> ...的应用式风格允许我们使用一个期望参数不是应用值函数,并使用该函数对多个应用值进行操作。该函数可以接受我们想要的任意数量的参数,因为它总是在<*>出现之间逐步部分应用。

如果我们考虑这样一个事实,即 pure f <*> x 等于 fmap f x,这就会变得更加方便和明显。这是应用性定律之一。我们将在本章后面更详细地探讨应用性定律,但让我们先思考一下它在这里是如何应用的。pure 将一个值放入默认上下文中。如果我们只是将一个函数放入默认上下文中,然后从中提取并应用到另一个应用性函子中的值,那么这和只是映射该函数到该应用性函子上是一样的。我们不需要写 pure f <*> x <*> y <*> ...,我们可以写 fmap f x <*> y <*> ...。这就是为什么 Control.Applicative 导出了一个名为 <$> 的函数,它只是作为中缀操作符的 fmap。下面是如何定义它的:

(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x

注意

记住,类型变量与参数名称或其他值名称无关。这里函数声明中的 f 是一个带有类约束的类型变量,表示任何替换 f 的类型构造函数都应该在 Functor 类型类中。函数体中的 f 表示一个映射到 x 的函数。我们使用 f 来表示这两者并不意味着它们表示相同的事物。

通过使用 <$>,应用性风格真正地闪耀,因为现在如果我们想在三个应用性值之间应用一个函数 f,我们可以写 f <$> x <*> y <*> z。如果参数是普通值而不是应用性函子,我们会写 f x y z

让我们更仔细地看看它是如何工作的。假设我们想要将 Just "johntra"Just "volta" 这两个值连接成一个 String,并在 Maybe 函子内部。我们可以这样做:

ghci> (++) <$> Just "johntra" <*> Just "volta"
Just "johntravolta"

在我们看到它是如何发生之前,比较前面的行与这一行:

ghci> (++) "johntra" "volta"
"johntravolta"

要在应用性函子上使用普通函数,只需在周围撒一些 <$><*>,函数就会在应用性上操作并返回一个应用性。这有多酷?

回到我们的 (++) <$> Just "johntra" <*> Just "volta":首先 (++),它有一个类型 (++) :: [a] -> [a] -> [a],被映射到 Just "johntra" 上。这产生了一个与 Just ("johntra"++) 相同的值,其类型为 Maybe ([Char] -> [Char])。注意 (++) 的第一个参数是如何被消耗掉的,以及 a 如何变成了 Char 值。现在 Just ("johntra"++) <*> Just "volta" 发生了,它从 Just 中提取出函数并将其映射到 Just "volta" 上,结果得到 Just "johntravolta"。如果这两个值中的任何一个都是 Nothing,结果也会是 Nothing

到目前为止,我们只在示例中使用了 Maybe,你可能认为应用性函子都是关于 Maybe 的。实际上,Applicative 有很多其他实例,所以让我们来认识它们!

列表

列表(实际上是列表类型构造函数 [])是应用性函子。多么令人惊讶!下面是如何使 [] 成为 Applicative 的实例:

instance Applicative [] where
    pure x = [x]
    fs <*> xs = [f x | f <- fs, x <- xs]

记住,pure 函数接受一个值并将其放入默认上下文中。换句话说,它将其放入一个最小的上下文中,但仍然可以产生该值。对于列表而言,最小的上下文将是空列表,但空列表代表没有值,因此它不能包含我们使用 pure 的值。这就是为什么 pure 函数接受一个值并将其放入单元素列表中的原因。同样,Maybe 应用函子(applicative functor)的最小上下文将是 Nothing,但它代表的是没有值而不是值,因此在 Maybe 的实例实现中 pure 被实现为 Just

这是 pure 函数的实际应用:

ghci> pure "Hey" :: [String]
["Hey"]
ghci> pure "Hey" :: Maybe String
Just "Hey"

那么 <*> 呢?如果 <*> 函数的类型仅限于列表,我们将会得到 (<*>) :: [a -> b] -> [a] -> [b]。它是通过列表推导实现的。<*> 必须以某种方式从其左参数中提取函数,然后将其映射到右参数上。但左边的列表可以包含零个、一个或多个函数,右边的列表也可以包含多个值。这就是为什么我们使用列表推导从两个列表中抽取。我们将左列表中的每个可能的函数应用到右列表中的每个可能的值上。结果列表包含了将左列表中的函数应用到右列表中的每个值上的所有可能的组合。

我们可以这样使用 <*>

ghci> [(*0),(+100),(²)] <*> [1,2,3]
[0,0,0,101,102,103,1,4,9]

左列表有三个函数,右列表有三个值,因此结果列表将有九个元素。左列表中的每个函数都会应用到右列表中的每个函数上。如果我们有一个接受两个参数的函数列表,我们可以在两个列表之间应用这些函数。

在以下示例中,我们在两个列表之间应用了两个函数:

ghci> [(+),(*)] <*> [1,2] <*> [3,4]
[4,5,5,6,3,4,6,8]

<*> 是左结合的,所以 [(+),(*)] <*> [1,2] 首先发生,结果是一个与 [(1+),(2+),(1*),(2*)] 相同的列表,因为左边的每个函数都会应用到右边的每个值上。然后 [(1+),(2+),(1*),(2*)] <*> [3,4] 发生,产生了最终结果。

使用列表的适用性风格很有趣!

ghci> (++) <$> ["ha","heh","hmm"] <*> ["?","!","."]
["ha?","ha!","ha.","heh?","heh!","heh.","hmm?","hmm!","hmm."]

再次强调,我们只是通过插入适当的适用性运算符,在两个字符串列表之间使用了一个普通函数来连接两个字符串列表。

你可以将列表视为非确定性计算。例如,值 100"what" 可以被视为只有一个结果的确定性计算,而列表 [1,2,3] 可以被视为无法决定想要哪个结果的计算,因此它向我们展示了所有可能的结果。所以当你写 (+) <$> [1,2,3] <*> [4,5,6] 这样的代码时,你可以将其视为使用 + 将两个非确定性计算相加,从而产生另一个结果更加不确定的非确定性计算。

在列表上使用应用式风格通常是一个很好的列表推导式的替代品。在第一章中,我们想看到[2,5,10][8,10,11]的所有可能乘积,所以我们做了以下操作:

ghci> [ x*y | x <- [2,5,10], y <- [8,10,11]]
[16,20,22,40,50,55,80,100,110]

我们只是从两个列表中抽取元素,并在每个元素组合之间应用一个函数。这也可以用应用式风格来完成:

ghci> (*) <$> [2,5,10] <*> [8,10,11]
[16,20,22,40,50,55,80,100,110]

这对我来说更清晰,因为更容易看出我们只是在两个非确定性计算之间调用*。如果我们想要所有大于 50 的两个列表的所有可能乘积,我们会使用以下代码:

ghci> filter (>50) $ (*) <$> [2,5,10] <*> [8,10,11]
[55,80,100,110]

很容易看出pure f <*> xs与列表上的fmap f xs相等。pure f只是[f],而[f] <*> xs将左列表中的每个函数应用到右列表中的每个值上,但由于左列表中只有一个函数,所以它就像映射。

IO 也是一个应用式函子

我们已经遇到的另一个Applicative实例是IO。这是实例的实现方式:

instance Applicative IO where
    pure = return
    a <*> b = do
        f <- a
        x <- b
        return (f x)

无标题图片

由于pure的核心理念是将值置于最小的上下文中,同时仍然保持其作为结果的价值,因此pure仅仅是return是有意义的。return执行一个不进行任何操作的 I/O 操作。它只是作为其结果产生一些值,而不执行任何像向终端打印或从文件读取这样的 I/O 操作。

如果<*>针对IO进行了特殊化,它将具有类型(<*>) :: IO (a -> b) -> IO a -> IO b。在IO的情况下,它接受一个产生函数的 I/O 操作a,执行该函数,并将该函数绑定到f。然后它执行b并将结果绑定到x。最后,它将函数f应用到x上,并产生这个结果。我们在这里使用了do语法来实现它。(记住,do语法是关于执行多个 I/O 操作并将它们粘合为一个。)

使用Maybe[],我们可以将<*>视为简单地从其左参数中提取一个函数,然后将其应用到右参数上。对于IO,提取仍然在游戏中,但现在我们还有一个关于序列化的概念,因为我们正在将两个 I/O 操作粘合为一个。我们需要从第一个 I/O 操作中提取函数,但要从 I/O 操作中提取结果,它必须被执行。考虑以下情况:

myAction :: IO String
myAction = do
    a <- getLine
    b <- getLine
    return $ a ++ b

这是一个会提示用户输入两行并作为其结果返回这两行连接的 I/O 操作。我们通过将两个getLine I/O 操作和一个return粘合在一起来实现它,因为我们希望我们的新粘合 I/O 操作保留a ++ b的结果。另一种写法是使用应用式风格:

myAction :: IO String
myAction = (++) <$> getLine <*> getLine

这和我们之前做的是一样的事情,当时我们在创建一个在两个其他 I/O 操作的结果之间应用函数的 I/O 操作。记住 getLine 是一个类型为 getLine :: IO String 的 I/O 操作。当我们使用 <*> 在两个应用值之间时,结果是另一个应用值,所以这一切都很有意义。

如果我们回到盒子类比,我们可以想象 getLine 是一个盒子,它会进入现实世界并为我们获取一个字符串。调用 (++) <$> getLine <*> getLine 会创建一个新的更大的盒子,它会将这两个盒子发送到终端去获取行,然后将其作为结果呈现这两个行的连接。

表达式 (++) <$> getLine <*> getLine 的类型是 IO String。这意味着该表达式是一个完全正常的 I/O 操作,就像其他任何操作一样,它也会产生一个结果值。这就是为什么我们可以做类似这样的事情:

main = do
    a <- (++) <$> getLine <*> getLine
    putStrLn $ "The two lines concatenated turn out to be: " ++ a

函数作为应用

Applicative 的另一个实例是 (->) r,或函数。我们并不经常将函数用作应用,但这个概念仍然非常有趣,所以让我们看看函数实例是如何实现的。

instance Applicative ((->) r) where
    pure x = (\_ -> x)
    f <*> g = \x -> f x (g x)

当我们用 pure 将一个值包裹成应用值时,它产生的结果必须是那个值。最小的默认上下文仍然会产生那个值作为结果。这就是为什么在函数实例实现中,pure 接收一个值并创建一个忽略其参数并始终返回该值的函数。pure 对于 (->) r 实例的类型是 pure :: a -> (r -> a)

ghci> (pure 3) "blah"
3

由于柯里化,函数应用是左结合的,所以我们可以省略括号。

ghci> pure 3 "blah"
3

<*> 的实例实现有点难以理解,所以我们只需看看如何以应用风格使用函数作为应用函子:

ghci> :t (+) <$> (+3) <*> (*100)
(+) <$> (+3) <*> (*100) :: (Num a) => a -> a
ghci> (+) <$> (+3) <*> (*100) $ 5
508

使用两个应用值调用 <*> 会得到一个应用值,所以如果我们用它来操作两个函数,我们就会得到一个函数。那么这里发生了什么?当我们执行 (+) <$> (+3) <*> (*100) 时,我们创建了一个函数,它会在 (+3)(*100) 的结果上使用 + 并返回那个结果。在 (+) <$> (+3) <*> (*100) $ 5 中,(+3)(*100) 首先应用于 5,得到 8500。然后 + 被调用,使用 8500,得到 508

以下代码类似:

ghci> (\x y z -> [x,y,z]) <$> (+3) <*> (*2) <*> (/2) $ 5
[8.0,10.0,2.5]

我们创建了一个函数,它会调用函数 \x y z -> [x,y,z],使用 (+3)(*2)(/2) 的最终结果。5 被喂给这三个函数中的每一个,然后使用这些结果调用 \x y z -> [x, y, z]

无标题图片

注意

你不必完全理解 Applicative(->) r 实例是如何工作的,所以如果你现在不理解这一切,不要绝望。尝试玩转应用风格和函数,以获得一些关于如何将函数作为应用使用的见解。

压缩列表

实际上,列表作为 applicative 函子有更多的方式。我们已经介绍了一种方法:使用函数列表和值列表调用<*>,这会产生一个包含所有可能的将左列表中的函数应用于右列表中的值的组合的列表。

例如,如果我们编写[(+3),(*2)] <*> [1,2](+3)将被应用于12,而(*2)也将被应用于12,结果是一个包含四个元素的列表:[4,5,2,4]。然而,[(+3),(*2)] <*> [1,2]也可以以这种方式工作,即左列表中的第一个函数应用于右列表的第一个值,第二个函数应用于第二个值,依此类推。这将产生一个包含两个值的列表:[4,4]。你可以将其视为[1 + 3, 2 * 2]

我们还没有遇到的一个Applicative实例是ZipList,它位于Control.Applicative中。

因为一个类型不能为同一个类型类有两个实例,所以引入了ZipList a类型,它有一个构造函数(ZipList)和一个字段(一个列表)。以下是实例:

instance Applicative ZipList where
        pure x = ZipList (repeat x)
        ZipList fs <*> ZipList xs = ZipList (zipWith (\f x -> f x) fs xs)

<*>将第一个函数应用于第一个值,第二个函数应用于第二个值,依此类推。这是通过zipWith (\f x -> f x) fs xs完成的。由于zipWith的工作方式,结果列表的长度将等于两个列表中较短的那个。

pure在这里也很有趣。它接受一个值并将其放入一个只重复该值无限次的列表中。pure "haha"的结果是ZipList (["haha", "haha","haha"...。这可能会有些令人困惑,因为你已经了解到pure应该将值放入一个最小化但仍能产生该值的上下文中。你可能还在想,无限列表几乎不可能是最小化的。但是,在 zip 列表中,这是有意义的,因为它必须在每个位置上产生该值。这也满足了pure f <*> xs应该等于fmap f xs的法律。如果pure 3仅仅返回ZipList [3],那么pure (*2) <*> ZipList [1,5,10]将导致ZipList [2],因为两个 zip 列表组合后的列表长度等于两个列表中较短的那个。如果我们用一个有限列表和一个无限列表进行 zip,结果列表的长度将始终等于有限列表的长度。

那么,zip 列表在 applicative 风格中是如何工作的呢?嗯,ZipList a类型没有Show实例,因此我们需要使用getZipList函数从一个 zip 列表中提取一个原始列表:

ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100,100]
[101,102,103]
ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100..]
[101,102,103]
ghci> getZipList $ max <$> ZipList [1,2,3,4,5,3] <*> ZipList [5,3,1,2]
[5,3,3,4]
ghci> getZipList $ (,,) <$> ZipList "dog" <*> ZipList "cat" <*> ZipList "rat"
[('d','c','r'),('o','a','a'),('g','t','t')]

注意

(,,)函数等同于\x y z -> (x,y,z)。同样,(,)函数等同于\x y -> (x,y)

除了 zipWith 之外,标准库还有 zipWith3zipWith4 等函数,一直可以到 zipWith7zipWith 接受一个接受两个参数的函数,并用它来连接两个列表。zipWith3 接受一个接受三个参数的函数,并用它来连接三个列表,以此类推。通过使用以应用风格连接的列表,我们不需要为想要连接的每个列表数量都有一个单独的连接函数。我们只需使用应用风格将任意数量的列表与一个函数连接起来,这非常方便。

应用定律

正如正常的函子一样,应用函子附带一些定律。最重要的定律是 pure f <*> x = fmap f x 这个定律。作为一个练习,你可以为我们在本章中遇到的一些应用函子证明这个定律。以下是一些其他的应用定律:

  • pure id <*> v = v

  • pure (.) <*> u <*> v <*> w = u <*> (v <*> w)

  • pure f <*> pure x = pure (f x)

  • u <*> pure y = pure ($ y) <*> u

我们不会详细讨论它们,因为这会占用很多页面并且有点无聊。如果你感兴趣,你可以仔细看看它们,看看它们是否适用于某些实例。

应用函子的有用函数

Control.Applicative 定义了一个名为 liftA2 的函数,其类型如下:

liftA2 :: (Applicative f) => (a -> b -> c) -> f a -> f b -> f c

它被定义为这样:

liftA2 :: (Applicative f) => (a -> b -> c) -> f a -> f b -> f c
liftA2 f a b = f <$> a <*> b

它只是在一个应用值之间应用一个函数,隐藏了我们讨论过的应用风格。然而,它清楚地展示了为什么应用函子比普通函子更强大。

对于普通函子,我们可以在一个函子值上映射函数。对于应用函子,我们可以在几个函子值之间应用一个函数。观察这个函数的类型 (a -> b -> c) -> (f a -> f b -> f c) 也很有趣。当我们这样看待它时,我们可以说 liftA2 接受一个正常的二元函数并将其提升为一个在两个应用值上操作的函数。

这里有一个有趣的概念:我们可以取两个应用值并将它们组合成一个应用值,其中包含这两个应用值的结果列表。例如,我们有 Just 3Just 4。让我们假设第二个包含一个单元素列表,因为这很容易实现:

ghci> fmap (\x -> [x]) (Just 4)
Just [4]

好吧,所以假设我们有 Just 3Just [4]。我们如何得到 Just [3,4]?这很简单:

ghci> liftA2 (:) (Just 3) (Just [4])
Just [3,4]
ghci> (:) <$> Just 3 <*> Just [4]
Just [3,4]

记住 : 是一个函数,它接受一个元素和一个列表,并返回一个新列表,其中包含该元素在开头。现在我们有了 Just [3,4],我们能否将其与 Just 2 结合以产生 Just [2,3,4]?是的,我们可以。看起来我们可以将任意数量的应用值组合成一个包含那些应用值结果列表的应用值。

让我们尝试实现一个函数,它接受一个应用值列表并返回一个结果值为列表的应用值。我们将它称为 sequenceA

sequenceA :: (Applicative f) => [f a] -> f [a]
sequenceA [] = pure []
sequenceA (x:xs) = (:) <$> x <*> sequenceA xs

哎,递归!首先,我们看看类型。它将一个 applicative 值的列表转换成一个包含列表的 applicative 值。从那以后,我们可以为基本情况打下一些基础。如果我们想将一个空列表转换成一个包含结果列表的 applicative 值,我们只需将一个空列表放入默认上下文中。现在轮到递归了。如果我们有一个包含头部和尾部的列表(记住x是一个 applicative 值,而xs是它们的列表),我们在尾部上调用sequenceA,这将产生一个包含列表的 applicative 值。然后我们只需将 applicative x中的值作为列表的前缀添加到那个 applicative 中,这样就完成了!

假设我们这样做:

sequenceA [Just 1, Just 2]}

根据定义,这等于以下内容:

(:) <$> Just 1 <*> sequenceA [Just 2]

进一步分解,我们得到以下内容:

(:) <$> Just 1 <*> ((:) <$> Just 2 <*> sequenceA [])

我们知道sequenceA []最终会变成Just [],所以这个表达式现在如下所示:

(:) <$> Just 1 <*> ((:) <$> Just 2 <*> Just [])

这就是:

(:) <$> Just 1 <*> Just [2]

这等于Just [1,2]

实现sequenceA的另一种方式是使用折叠。记住,几乎任何通过遍历列表元素并沿途累积结果的函数都可以使用折叠来实现:

sequenceA :: (Applicative f) => [f a] -> f [a]
sequenceA = foldr (liftA2 (:)) (pure [])

我们从列表的右侧开始处理列表,并使用一个累加器值pure []作为起始值。我们在累加器和列表的最后一个元素之间放置liftA2 (:),这会产生一个包含单例元素的 applicative。然后我们使用现在的最后一个元素和当前的累加器调用liftA2 (:),以此类推,直到我们只剩下一个累加器,它包含所有 applicative 的结果列表。

让我们在一些 applicative 上试一下我们的函数:

ghci> sequenceA [Just 3, Just 2, Just 1]
Just [3,2,1]
ghci> sequenceA [Just 3, Nothing, Just 1]
Nothing
ghci> sequenceA [(+3),(+2),(+1)] 3
[6,5,4]
ghci> sequenceA [[1,2,3],[4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> sequenceA [[1,2,3],[4,5,6],[3,4,4],[]]
[]

当用于Maybe值时,sequenceA创建一个包含所有结果列表的Maybe值。如果一个值是Nothing,那么结果也是Nothing。当你有一系列Maybe值,并且你只对那些没有Nothing的值感兴趣时,这很酷。

当与函数一起使用时,sequenceA接受一个函数列表,并返回一个返回列表的函数。在我们的例子中,我们创建了一个函数,它接受一个数字作为参数,并将其应用于列表中的每个函数,然后返回一个结果列表。sequenceA [(+3),(+2),(+1)] 3将使用3调用(+3),使用3调用(+2),使用3调用(+1),并将所有这些结果作为列表展示。

执行(+) <$> (+3) <*> (*2)将创建一个函数,该函数接受一个参数,将其传递给(+3)(*2),然后使用这两个结果调用+。同样地,sequenceA [(+3),(*2)]创建一个函数,该函数接受一个参数,并将其传递给列表中的所有函数。而不是使用函数的结果调用+,而是使用:pure []的组合来收集这些结果到一个列表中,这就是该函数的结果。

当我们有一系列函数,并希望将相同的输入提供给所有这些函数,然后查看结果列表时,使用sequenceA是有用的。例如,假设我们有一个数字,我们想知道它是否满足列表中的所有谓词。这里有一种方法可以做到这一点:

ghci> map (\f -> f 7) [(>4),(<10),odd]
[True,True,True]
ghci> and $ map (\f -> f 7) [(>4),(<10),odd]
True

记住,and接受一个布尔值列表,如果它们都是True,则返回True。另一种实现相同功能的方法是使用sequenceA

ghci> sequenceA [(>4),(<10),odd] 7
[True,True,True]
ghci> and $ sequenceA [(>4),(<10),odd] 7
True

sequenceA [(>4),(<10),odd]创建一个函数,该函数将接受一个数字并将其提供给[(>4),(<10),odd]中的所有谓词,并返回一个布尔值列表。它将类型为(Num a) => [a -> Bool]的列表转换为类型为(Num a) => a -> [Bool]的函数。非常巧妙,不是吗?

由于列表是同质的,列表中的所有函数都必须是相同类型的函数。你不能有一个像[ord, (+3)]这样的列表,因为ord接受一个字符并返回一个数字,而(+3)接受一个数字并返回一个数字。

当与[]一起使用时,sequenceA接受一个列表的列表,并返回一个列表的列表。它实际上创建了包含所有可能元素组合的列表。为了说明,这里是用sequenceA和列表推导式完成的先前的例子:

ghci> sequenceA [[1,2,3],[4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> [[x,y] | x <- [1,2,3], y <- [4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> sequenceA [[1,2],[3,4]]
[[1,3],[1,4],[2,3],[2,4]]
ghci> [[x,y] | x <- [1,2], y <- [3,4]]
[[1,3],[1,4],[2,3],[2,4]]
ghci> sequenceA [[1,2],[3,4],[5,6]]
[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]
ghci> [[x,y,z] | x <- [1,2], y <- [3,4], z <- [5,6]]
[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]

(+) <$> [1,2] <*> [4,5,6]的结果是一个非确定性计算x + y,其中x取自[1,2]中的每个值,y取自[4,5,6]中的每个值。我们将其表示为一个包含所有可能结果的列表。同样,当我们调用sequenceA [[1,2],[3,4],[5,6]]时,结果是[x,y,z]的非确定性计算,其中x取自[1,2]中的每个值,y取自[3,4]中的每个值,依此类推。为了表示这个非确定性计算的结果,我们使用一个列表,其中每个元素都是一个可能的列表。这就是为什么结果是列表的列表。

当与 I/O 操作一起使用时,sequenceAsequence相同!它接受一个 I/O 操作的列表,并返回一个 I/O 操作,该操作将执行每个操作,并将结果作为执行这些 I/O 操作的结果的列表。这是因为要将[IO a]值转换为IO [a]值,以使 I/O 操作在执行时产生一个结果列表,所有这些 I/O 操作都必须按顺序排列,以便在求值时依次执行。你无法在不执行的情况下获得 I/O 操作的结果。

让我们按顺序排列三个getLine I/O 操作:

ghci> sequenceA [getLine, getLine, getLine]
heyh
ho
woo
["heyh","ho","woo"]

总结来说,应用函子不仅有趣,而且有用。它们允许我们通过应用风格结合不同的计算——例如 I/O 计算、非确定性计算、可能失败的计算等等。仅仅通过使用<$><*>,我们就可以使用普通函数对任意数量的应用函子进行统一操作,并利用每个函子的语义。

第十二章 单例

本章介绍了一个有用且有趣的数据类型类:Monoid。这个数据类型类是为那些值可以通过二元运算组合在一起的数据类型。我们将详细介绍单例是什么以及它们的定律是什么。然后我们将看看 Haskell 中的几个单例以及它们如何有用。

首先,让我们看看newtype关键字,因为当我们深入到单例的奇妙世界时,我们会大量使用它。

将现有类型包装到新类型中

到目前为止,你已经学会了如何使用data关键字创建自己的代数数据类型。你也看到了如何使用type关键字给现有类型赋予同义词。在本节中,我们将探讨如何使用newtype关键字从现有数据类型创建新类型。我们还将讨论为什么我们最初想要这样做。

无标题图片

在第十一章中,你看到了几种使列表类型成为应用函子的方法。一种方法是将<*>应用于列表中的每个函数,并将其应用于列表右侧的每个值,从而产生将左侧列表中的函数应用于右侧列表中值的所有可能组合:

ghci> [(+1),(*100),(*5)] <*> [1,2,3]
[2,3,4,100,200,300,5,10,15]

第二种方法是取<*>左侧的第一个函数并将其应用于右侧的第一个值,然后从左侧的列表中取第二个函数并将其应用于右侧的第二个值,以此类推。最终,这有点像将两个列表一起压缩。

但列表已经是Applicative的一个实例,那么我们如何以第二种方式也使列表成为Applicative的一个实例呢?正如你所学的,ZipList a类型就是为了这个原因被引入的。这个类型有一个值构造器ZipList,它只有一个字段。我们将我们要包装的列表放在这个字段中。然后ZipList被制作成一个Applicative的实例,这样当我们想要以压缩方式使用列表作为应用函子时,我们只需用ZipList构造函数将其包装起来。一旦完成,我们就可以用getZipList解包:

ghci> getZipList $ ZipList [(+1),(*100),(*5)] <*> ZipList [1,2,3] $
[2,200,15]

那么,这与这个newtype关键字有什么关系呢?好吧,想想我们如何为我们的ZipList a类型编写数据声明。这里有一种方法:

data ZipList a = ZipList [a]

这是一个只有一个值构造器的类型,而这个值构造器只有一个字段,是一个事物列表。我们可能还想使用记录语法,这样我们就可以自动得到一个从ZipList中提取列表的函数:

data ZipList a = ZipList { getZipList :: [a] }

这看起来不错,实际上工作得相当好。我们有两种方法可以使现有类型成为类型类的一个实例,所以我们使用data关键字将那个类型包装到另一个类型中,并在第二种方式中使另一个类型成为实例。

Haskell 中的 newtype 关键字正是为了那些我们只想取一个类型并将其包装起来以呈现为另一种类型的情况。在实际库中,ZipList a 被定义为如下:

newtype ZipList a = ZipList { getZipList :: [a] }

不同于 data 关键字,我们使用 newtype 关键字。那么为什么是 newtype 呢?首先,newtype 更快。如果你使用 data 关键字来包装一个类型,当你的程序运行时,会有一些开销,因为所有的包装和解包。但是如果你使用 newtype,Haskell 知道你只是用它来将现有类型包装成新类型(因此得名),因为你希望它在内部相同但类型不同。有了这个想法,Haskell 会在确定哪个值属于哪种类型后,去除包装和解包。

那为什么不用 newtype 而不是 data 始终如一呢?当你使用 newtype 关键字从一个现有类型创建新类型时,你只能有一个值构造函数,而这个构造函数只能有一个字段。但是使用 data,你可以创建具有多个值构造函数的数据类型,每个构造函数可以有零个或多个字段:

data Profession = Fighter | Archer | Accountant

data Race = Human | Elf | Orc | Goblin

data PlayerCharacter = PlayerCharacter Race Profession

我们也可以像使用 data 一样使用 deriving 关键字与 newtype。我们可以为 EqOrdEnumBoundedShowRead 导出实例。如果我们为类型类导出实例,我们包装的类型必须已经在该类型类中。这是有道理的,因为 newtype 只是对现有类型的包装。因此,现在如果我们这样做,我们可以打印和比较我们新类型的值:

newtype CharList = CharList { getCharList :: [Char] } deriving (Eq, Show)

让我们试一试:

ghci> CharList "this will be shown!"
CharList {getCharList = "this will be shown!"}
ghci> CharList "benny" == CharList "benny"
True
ghci> CharList "benny" == CharList "oisters"
False

在这个特定的 newtype 中,值构造函数具有以下类型:

CharList :: [Char] -> CharList

它接受一个 [Char] 值,例如 "my sharona",并返回一个 CharList 值。从前面的例子中,我们使用了 CharList 值构造函数,我们可以看到这确实是正确的。相反,getCharList 函数,由于我们在 newtype 中使用了记录语法,因此为我们生成,具有以下类型:

getCharList :: CharList -> [Char]

它接受一个 CharList 值并将其转换为 [Char] 值。你可以将其视为包装和解包,但你也可以将其视为将值从一种类型转换为另一种类型。

使用 newtype 创建类型类实例

许多时候,我们希望使我们的类型成为某些类型类的实例,但类型参数并不匹配我们想要做的事情。使 Maybe 成为 Functor 的实例很容易,因为 Functor 类型类的定义如下:

class Functor f where
    fmap :: (a -> b) -> f a -> f b

因此,我们只需从以下内容开始:

instance Functor Maybe where

然后我们实现 fmap

所有类型参数加起来是因为 MaybeFunctor 类型类的定义中取代了 f。如果我们把 fmap 看作只作用于 Maybe,它最终会表现得像这样:

fmap :: (a -> b) -> Maybe a -> Maybe b

这不是很好吗?现在如果我们想以这种方式使元组成为 Functor 的一个实例,即当我们对元组上的 fmap 函数进行操作时,它会被应用到元组的第一个组件上?这样,执行 fmap (+3) (1, 1) 将会得到 (4, 1)。结果证明,为这个写实例有点困难。对于 Maybe,我们只需说 instance Functor Maybe where,因为只有接受恰好一个参数的类型构造函数才能成为 Functor 的一个实例。但似乎没有方法可以对 (a, b) 做出类似的事情,这样类型参数 a 就会在我们使用 fmap 时改变。为了解决这个问题,我们可以以某种方式 newtype 我们的元组,使得第二个类型参数代表元组中第一个组件的类型:

无标题图片

newtype Pair b a = Pair { getPair :: (a, b) }

现在我们可以将其变成 Functor 的一个实例,这样函数就会映射到第一个组件:

instance Functor (Pair c) where
    fmap f (Pair (x, y)) = Pair (f x, y)

如你所见,我们可以对使用 newtype 定义的类型进行模式匹配。我们进行模式匹配以获取底层的元组,将函数 f 应用到元组的第一个组件上,然后使用 Pair 值构造函数将元组转换回我们的 Pair b a。如果我们想象如果 fmap 只在新的元组上工作,它的类型会是什么样子,它看起来会是这样:

fmap :: (a -> b) -> Pair c a -> Pair c b

再次,我们说 instance Functor (Pair c) where,因此 Pair c 替换了 Functor 类型类定义中的 f

class Functor f where
    fmap :: (a -> b) -> f a -> f b

现在,如果我们把一个元组转换成 Pair b a,我们就可以在它上面使用 fmap,函数会被映射到第一个组件:

ghci> getPair $ fmap (*100) (Pair (2, 3))
(200,3)
ghci> getPair $ fmap reverse (Pair ("london calling", 3))
("gnillac nodnol",3)

关于 newtype 惰性

newtype 可以做的唯一事情是将现有的类型转换成新的类型,因此内部上,Haskell 可以像原始类型一样表示使用 newtype 定义的类型的值,同时知道它们的类型现在是不同的。这意味着 newtype 不仅通常比 data 快,它的模式匹配机制也更加惰性。让我们看看这意味着什么。

如你所知,Haskell 默认是惰性的,这意味着只有当我们尝试实际打印函数的结果时,才会进行任何计算。更进一步,只有那些对我们函数来说必要的计算才会被执行。Haskell 中的 undefined 值代表一个错误的计算。如果我们尝试评估它(即,强制 Haskell 实际计算它)通过将其打印到终端,Haskell 将会抛出一个异常(技术上称为异常):

ghci> undefined
*** Exception: Prelude.undefined

然而,如果我们创建一个包含一些 undefined 值的列表,但只请求列表的头部,它不是 undefined,一切都会顺利。这是因为如果我们只想看到列表的第一个元素,Haskell 不需要评估列表中的其他任何元素。以下是一个例子:

ghci> head [3,4,5,undefined,2,undefined]
3

现在考虑以下类型:

data CoolBool = CoolBool { getCoolBool :: Bool }

这是你用 data 关键字定义的普通的代数数据类型。它有一个值构造函数,该构造函数有一个字段,其类型为 Bool。让我们编写一个函数,该函数对 CoolBool 进行模式匹配,并返回值 "hello",无论 CoolBool 内部的 BoolTrue 还是 False

helloMe :: CoolBool -> String
helloMe (CoolBool _) = "hello"

我们不是将这个函数应用到正常的 CoolBool 上,而是给它一个惊喜,将其应用到 undefined 上!

ghci> helloMe undefined
"*** Exception: Prelude.undefined

哎呀!一个异常!为什么会出现这个异常?使用 data 关键字定义的类型可以有多个值构造函数(尽管 CoolBool 只有一个)。所以为了查看传递给我们的函数的值是否符合 (CoolBool _) 模式,Haskell 必须评估值,以便看到我们在创建值时使用了哪个值构造函数。而且当我们尝试评估一个 undefined 值时,即使是稍微评估一下,也会抛出异常。

对于 CoolBool,我们不用 data 关键字,而是尝试使用 newtype

newtype CoolBool = CoolBool { getCoolBool :: Bool }

我们不需要更改我们的 helloMe 函数,因为无论你使用 newtype 还是 data 来定义你的类型,模式匹配语法都是相同的。让我们在这里做同样的事情,并将 helloMe 应用到一个 undefined 值上:

ghci> helloMe undefined
"hello"

它成功了!嗯,为什么是这样呢?好吧,正如你所学的,当你使用 newtype 时,Haskell 可以在内部以与原始值相同的方式表示新类型的值。它不需要为它们添加另一个盒子;它只需要知道这些值是不同类型的。而且因为 Haskell 知道使用 newtype 关键字创建的类型只能有一个构造函数,所以它不需要评估传递给函数的值以确保该值符合 (CoolBool _) 模式,因为 newtype 类型只能有一个可能的价值构造函数和一个字段!

无标题图片

这种行为上的差异可能看起来微不足道,但实际上非常重要。它表明,尽管使用 datanewtype 定义的类型在程序员看来行为相似(因为它们都有值构造函数和字段),但实际上它们是两种不同的机制。data 可以用来从头开始创建自己的类型,而 newtype 只是从一个已存在的类型中创建一个全新的类型。对 newtype 值的模式匹配不像从盒子里取出东西(就像 data 那样),而是更像是直接从一种类型转换到另一种类型。

typenewtypedata

到目前为止,你可能对 typedatanewtype 之间的区别感到有些困惑,所以让我们回顾一下它们的用法。

type 关键字用于创建类型别名。我们只是给一个已存在的类型起另一个名字,以便更容易引用该类型。比如说我们做了以下操作:

type IntList = [Int]

这只是允许我们将 [Int] 类型称为 IntList。它们可以互换使用。我们不会得到 IntList 值构造函数或类似的东西。因为 [Int]IntList 只是引用同一类型的两种方式,所以我们在类型注解中使用哪个名称都无关紧要:

ghci> ([1,2,3] :: IntList) ++ ([1,2,3] :: [Int])
[1,2,3,1,2,3]

当我们想要使我们的类型签名更具描述性时,我们会使用类型同义词(type synonyms)。我们给类型命名,以便在它们被使用的函数的上下文中告诉我们它们的作用。例如,当我们使用类型为 [(String, String)] 的关联列表来表示电话簿时,我们在第七章(Chapter 7)中给它命名为 PhoneBook,这样我们的函数的类型签名就更容易阅读了。

newtype 关键字用于将现有类型封装在新类型中,这主要是为了让它们更容易成为某些类型类(type class)的实例。当我们使用 newtype 封装现有类型时,得到的类型与原始类型是分开的。假设我们创建以下 newtype

newtype CharList = CharList { getCharList :: [Char] }

我们不能使用 ++ 来组合 CharList 和类型为 [Char] 的列表。我们甚至不能使用 ++ 来组合两个 CharList 列表,因为 ++ 只在列表上工作,而 CharList 类型不是列表,尽管可以说 CharList 包含一个列表。然而,我们可以将两个 CharList 转换为列表,使用 ++ 组合它们,然后将结果转换回 CharList

当我们在 newtype 声明中使用记录语法时,我们会得到在新的类型和原始类型之间进行转换的函数——即我们的 newtype 的值构造函数以及从其字段中提取值的函数。新的类型也不会自动成为原始类型所属的类型类的实例,因此我们需要推导或手动编写它。

在实践中,你可以将 newtype 声明视为只能有一个构造函数和一个字段的 data 声明。如果你发现自己正在编写这样的 data 声明,考虑使用 newtype

data 关键字用于创建自己的数据类型。你可以随意使用它们。它们可以有任意多的构造函数和字段,并且可以用来实现任何代数数据类型——从列表和类似 Maybe 的类型到树。

总结来说,以下是如何使用这些关键字:

  • 如果你只是想让你的类型签名看起来更整洁、更具描述性,那么你可能想要使用类型同义词。

  • 如果你想要将现有类型封装成新类型以便使其成为类型类的实例,那么你很可能是在寻找 newtype

  • 如果你想要创建全新的东西,那么很可能你是在寻找 data 关键字。

关于那些幺半群

Haskell 中的类型类用于表示具有某些共同行为的类型。我们最初从简单的类型类 Eq 开始,它是用于可以相等比较的类型的值,以及 Ord,它是用于可以排序的事物。然后我们转向更有趣的类型类,如 FunctorApplicative

无标题图片

当我们创建一个类型时,我们会考虑它支持哪些行为(它能像什么一样行动),然后根据我们想要的行为决定将其作为哪个类型类的实例。如果我们的类型值可以相等比较,我们就将我们的类型作为 Eq 类型类的实例。如果我们看到我们的类型是一种类型的函子,我们就将其作为 Functor 的实例,等等。

现在考虑以下情况:* 是一个接受两个数字并将它们相乘的函数。如果我们用一个 1 乘以某个数字,结果总是等于那个数字。无论是 1 * x 还是 x * 1,结果总是 x。同样,++ 是一个接受两个东西并返回第三个东西的函数。但它不是乘以数字,而是接受两个列表并将它们连接起来。并且与 * 类似,它也有一个不会改变另一个值的特定值,当与 ++ 一起使用时。这个值是空列表:[]

ghci> 4 * 1
4
ghci> 1 * 9
9
ghci> [1,2,3] ++ []
[1,2,3]
ghci> [] ++ [0.5, 2.5]
[0.5,2.5]

看起来 *1 以及 ++[] 具有一些共同的特性:

  • 函数接受两个参数。

  • 参数和返回值具有相同的类型。

  • 存在这样一个值,当与二元函数一起使用时不会改变其他值。

这两个操作还有另一个共同点,可能不像我们之前的观察那么明显:当我们有三个或更多值,并且我们想要使用二元函数将它们减少到单个结果时,我们应用二元函数到值的顺序并不重要。例如,无论是 (3 * 4) * 5 还是 3 * (4 * 5),结果都是 60。对于 ++ 也是如此:

ghci> (3 * 2) * (8 * 5)
240
ghci> 3 * (2 * (8 * 5))
240
ghci> "la" ++ ("di" ++ "da")
"ladida"
ghci> ("la" ++ "di") ++ "da"
"ladida"

我们称这个属性为结合律* 是结合的,++ 也是。然而,例如 - 并不是结合的;表达式 (5 - 3) - 45 - (3 - 4) 得到的数字是不同的。

通过了解这些属性,我们偶然发现了单子!

单子类型类

一个 单子 由一个结合的二元函数和一个作为该函数的恒等元的值组成。当某个东西在函数中作为恒等元时,这意味着当与该函数和某个其他值一起调用时,结果总是等于那个其他值。1* 的恒等元,[]++ 的恒等元。在 Haskell 的世界中,还有很多其他单子可以找到,这就是为什么存在 Monoid 类型类。它是用于可以像单子一样的类型。让我们看看类型类是如何定义的:

class Monoid m where
    mempty :: m
    mappend :: m -> m -> m
    mconcat :: [m] -> m
    mconcat = foldr mappend mempty

Monoid类型类在import Data.Monoid中定义。让我们花些时间来正确地熟悉它。

首先,我们看到只有具体类型可以被制作成Monoid的实例,因为类型类定义中的m不接受任何类型参数。这与需要其实例是接受一个参数的类型构造函数的FunctorApplicative不同。

无标题图片

第一个函数是mempty。它实际上不是一个函数,因为它不接受任何参数。它是一个多态常量,类似于Bounded中的minBoundmempty代表特定单例的恒等值。

接下来,我们介绍mappend,正如你可能猜到的,这是一个二元函数。它接受两个相同类型的值,并返回另一个相同类型的值。将其命名为mappend的决定多少有些不幸,因为它暗示我们在以某种方式附加两个东西。虽然++确实接受两个列表并将一个附加到另一个上,但*实际上并没有进行任何附加;它只是将两个数字相乘。当你遇到其他Monoid实例时,你会发现它们大多数也不会附加值。所以,避免从附加的角度思考,只需将mappend视为一个二元函数,它接受两个单例值并返回第三个值。

在这个类型类定义中的最后一个函数是mconcat。它接受一个单例值列表,并通过在列表元素之间使用mappend将它们归约为一个单一值。它有一个默认实现,它只是将mempty作为起始值,并从右向左折叠列表使用mappend。因为默认实现对于大多数实例来说都很好,所以我们不会过多关注mconcat。当将一个类型作为Monoid的实例时,只需实现memptymappend就足够了。尽管对于某些实例,可能存在更有效的方法来实现mconcat,但默认实现对于大多数情况来说已经足够好了。

单例定律

在继续到Monoid的具体实例之前,让我们简要地看一下单例定律。

你已经了解到必须有一个值作为二元函数的恒等值,并且二元函数必须是结合的。可以创建不遵循这些规则的Monoid实例,但这样的实例对任何人都没有用处,因为当我们使用Monoid类型类时,我们依赖于其实例像单例一样行动。否则,这有什么意义?这就是为什么在创建单例实例时,我们需要确保它们遵循这些定律:

  • mempty `mappend` x = x

  • x `mappend` mempty = x

  • (x `mappend` y) `mappend` z = x `mappend` (y `mappend` z)

前两条定律说明 mempty 必须作为 mappend 的单位,第三条定律说明 mappend 必须是结合的(我们使用 mappend 将几个幺半群值归一化的顺序并不重要)。Haskell 不强制执行这些定律,因此我们需要小心确保我们的实例确实遵守它们。

认识一些幺半群

现在你已经了解了幺半群的相关知识,让我们看看一些 Haskell 类型是幺半群,它们的 Monoid 实例是什么样的,以及它们的用途。

列表是幺半群

是的,列表是幺半群!正如你所看到的,++ 函数和空列表 [] 构成了一个幺半群。实例非常简单:

instance Monoid [a] where
    mempty = []
    mappend = (++)

列表是 Monoid 类型类的一个实例,无论它们包含的元素类型如何。注意,我们写了 instance Monoid [a] 而不是 instance Monoid [],因为 Monoid 要求实例有一个具体的类型。

进行测试运行,我们没有遇到任何惊喜:

ghci> [1,2,3] `mappend` [4,5,6]
[1,2,3,4,5,6]
ghci> ("one" `mappend` "two") `mappend` "tree"
"onetwotree"
ghci> "one" `mappend` ("two" `mappend` "tree")
"onetwotree"
ghci> "one" `mappend` "two" `mappend` "tree"
"onetwotree"
ghci> "pang" `mappend` mempty
"pang"
ghci> mconcat [[1,2],[3,6],[9]]
[1,2,3,6,9]
ghci> mempty :: [a]
[]

无标题的图片

注意,在最后一行,我们写了一个显式的类型注解。如果我们只写了 mempty,GHCi 就不知道要使用哪个实例,因此我们需要说明我们想要列表实例。我们能够使用 [a] 的通用类型(而不是指定 [Int][String]),因为空列表可以充当包含任何类型的容器。

由于 mconcat 有一个默认实现,当我们使某个东西成为 Monoid 的实例时,我们就可以免费获得它。在列表的情况下,mconcat 证明就是 concat。它接受一个列表的列表并将其展平,因为这相当于在列表中所有相邻列表之间进行 ++ 操作。

幺半群定律确实适用于列表实例。当我们有几个列表并将它们 mappend(或 ++)在一起时,我们首先做哪一个并不重要,因为它们最终只是连接在末尾。此外,空列表充当单位,所以一切顺利。

注意,幺半群不需要 a mappend b 等于 b mappend a。在列表的情况下,显然不是这样的:

ghci> "one" `mappend` "two"
"onetwo"
ghci> "two" `mappend` "one"
"twoone"

这是可以接受的。对于乘法来说,3 * 55 * 3 是相同的,这只是乘法的一个属性,但并不是所有(实际上,大多数)幺半群都如此。

乘积和和

我们已经考察了一种将数字视为幺半群的方法:只需让二元函数为 *,单位值为 1。另一种将数字视为幺半群的方法是让二元函数为 +,单位值为 0

ghci> 0 + 4
4
ghci> 5 + 0
5
ghci> (1 + 3) + 5
9
ghci> 1 + (3 + 5)
9

幺半群定律成立,因为如果你将 0 加到任何数字上,结果就是那个数字。加法也是结合的,所以我们没有问题。

有两种同样有效的方式让数字成为单子,我们选择哪一种呢?好吧,我们不必选择。记住,当某个类型有几种方式成为相同类型类(type class)的实例时,我们可以将这个类型包裹在一个newtype中,然后以不同的方式让这个新类型成为类型类的实例。我们可以既吃蛋糕又吃蛋糕。

Data.Monoid模块导出两种类型用于此目的:ProductSumProduct的定义如下:

newtype Product a =  Product { getProduct :: a }
    deriving (Eq, Ord, Read, Show, Bounded)

这很简单——只是一个带有单个类型参数的newtype包装器,以及一些派生实例。它的Monoid实例定义如下:

instance Num a => Monoid (Product a) where
    mempty = Product 1
    Product x `mappend` Product y = Product (x * y)

mempty只是1被包裹在Product构造函数中。mappendProduct构造函数上模式匹配,将两个数字相乘,然后将结果数字包裹起来。正如你所看到的,有一个Num a类约束。这意味着对于所有已经是Num实例的a值,Product aMonoid的实例。为了将Product a用作单子,我们需要进行一些newtype的包裹和展开:

ghci> getProduct $ Product 3 `mappend` Product 9
27
ghci> getProduct $ Product 3 `mappend` mempty
3
ghci> getProduct $ Product 3 `mappend` Product 4 `mappend` Product 2
24
ghci> getProduct . mconcat . map Product $ [3,4,2]
24

Sum的定义与Product相同,实例也类似。我们以相同的方式使用它:

ghci> getSum $ Sum 2 `mappend` Sum 9
11
ghci> getSum $ mempty `mappend` Sum 3
3
ghci> getSum . mconcat . map Sum $ [1,2,3]
6

任何和所有

另一种可以以两种不同但同样有效的方式表现得像单子(monoid)的类型是Bool。第一种方式是让表示逻辑或(OR)的函数||作为二元函数,同时使用False作为单位值。使用逻辑或时,如果两个参数中的任何一个为True,它就返回True;否则返回False。因此,如果我们使用False作为单位值,那么与False结合时返回False,与True结合时返回TrueAny newtype构造函数就是这样成为Monoid实例的。它的定义如下:

newtype Any = Any { getAny :: Bool }
    deriving (Eq, Ord, Read, Show, Bounded)

它的实例看起来是这样的:

instance Monoid Any where
        mempty = Any False
        Any x `mappend` Any y = Any (x || y)

它被称为Any是因为x mappend y如果其中任何一个为True,就会返回True。即使三个或更多的Any包裹的Bool值被mappend在一起,只要其中任何一个为True,结果就会保持True

ghci> getAny $ Any True `mappend` Any False
True
ghci> getAny $ mempty `mappend` Any True
True
ghci> getAny . mconcat . map Any $ [False, False, False, True]
True
ghci> getAny $ mempty `mappend` mempty
False

Bool成为Monoid实例的另一种方式是做相反的事情:让&&作为二元函数,然后让True作为单位值。逻辑与(AND)只有在两个参数都为True时才会返回True

这是newtype声明:

newtype All = All { getAll :: Bool }
        deriving (Eq, Ord, Read, Show, Bounded)

这是它的实例:

instance Monoid All where
        mempty = All True
        All x `mappend` All y = All (x && y)

当我们mappend``All类型的值时,结果只有在mappend操作中使用的所有值都为True时才会是True

ghci> getAll $ mempty `mappend` All True
True
ghci> getAll $ mempty `mappend` All False
False
ghci> getAll . mconcat . map All $ [True, True, True]
True
ghci> getAll . mconcat . map All $ [True, True, False]
False

就像乘法和加法一样,我们通常明确地声明二元函数,而不是将它们包裹在newtypes 中,然后使用mappendmemptymconcat对于AnyAll来说似乎很有用,但通常使用orand函数更容易。or接受Bool值的列表,如果其中任何一个为True,就返回Trueand接受相同的值,如果所有值都为True,则返回True

排序单子

记得Ordering类型吗?它在比较事物时用作结果,并且可以有三种值:LTEQGT,分别代表小于、等于和大于。

ghci> 1 `compare` 2
LT
ghci> 2 `compare` 2
EQ
ghci> 3 `compare` 2
GT

对于列表、数字和布尔值,找到幺半群只是查看已经存在的常用函数,看看它们是否表现出某种幺半群行为。对于Ordering,我们需要更仔细地观察才能识别出幺半群。结果是,排序Monoid实例与我们之前遇到的实例一样直观,而且也非常有用:

instance Monoid Ordering where
    mempty = EQ
    LT `mappend` _ = LT
    EQ `mappend` y = y
    GT `mappend` _ = GT

实例设置如下:当我们对两个Ordering值进行mappend操作时,左边的值被保留,除非左边的值是EQ。如果左边的值是EQ,则右边的值是结果。恒等值是EQ。起初,这可能会显得有些随意,但实际上它与我们按字母顺序比较单词的方式相似。我们查看前两个字母,如果它们不同,我们就可以决定哪个单词在字典中排在前面。然而,如果前两个字母相同,我们就继续比较下一对字母,并重复这个过程。

无标题图片

例如,当我们按字母顺序比较单词oxon时,我们看到每个单词的第一个字母相同,然后我们继续比较第二个字母。由于x在字母表中大于n,我们知道这两个单词的比较结果。为了理解EQ作为恒等值的含义,请注意,如果我们把相同的字母放在两个单词的相同位置,这不会改变它们的字母顺序;例如,oix仍然在字母表中大于oin

重要的是要注意,在OrderingMonoid实例中,x mappend y不等于y mappend x。因为除非第一个参数是EQ,否则它会被保留,所以LT mappend GT的结果将是LT,而GT mappend LT的结果将是GT

ghci> LT `mappend` GT
LT
ghci> GT `mappend` LT
GT
ghci> mempty `mappend` LT
LT
ghci> mempty `mappend` GT
GT

好吧,那么这个幺半群有什么用呢?假设我们正在编写一个函数,它接受两个字符串,比较它们的长度,并返回一个Ordering。但如果字符串长度相同,我们不想立即返回EQ,而是想按字母顺序比较它们。

这是一种编写方式:

lengthCompare :: String -> String -> Ordering
lengthCompare x y = let a = length x `compare` length y
                        b = x `compare` y
                    in  if a == EQ then b else a

我们将比较长度的结果命名为a,将字母比较的结果命名为b,然后如果长度相等,我们返回它们的字母顺序。

但通过运用我们对Ordering作为幺半群的理解,我们可以以更简单的方式重写这个函数:

import Data.Monoid

lengthCompare :: String -> String -> Ordering
lengthCompare x y = (length x `compare` length y) `mappend`
                    (x `compare` y)

让我们试试这个:

ghci> lengthCompare "zen" "ants"
LT
ghci> lengthCompare "zen" "ant"
GT

记住,当我们使用mappend时,除非它是EQ,否则其左参数会被保留;如果是EQ,则保留右参数。这就是为什么我们把我们认为的第一、更重要、标准作为第一个参数。现在假设我们想要扩展这个函数,使其也比较元音的数量,并将这个设置为比较的第二重要标准。我们修改它如下:

import Data.Monoid

lengthCompare :: String -> String -> Ordering
lengthCompare x y = (length x `compare` length y) `mappend`
                    (vowels x `compare` vowels y) `mappend`
                    (x `compare` y)
    where vowels = length . filter (`elem` "aeiou")

我们创建了一个辅助函数,它接受一个字符串,并告诉我们它有多少元音,首先通过过滤字符串中的字母来仅保留在字符串"aeiou"中的字母,然后应用length

ghci> lengthCompare "zen" "anna"
LT
ghci> lengthCompare "zen" "ana"
LT
ghci> lengthCompare "zen" "ann"
GT

在第一个例子中,发现长度不同,因此返回LT,因为"zen"的长度小于"anna"的长度。在第二个例子中,长度相同,但第二个字符串有更多的元音,所以再次返回LT。在第三个例子中,它们的长度和元音数量都相同,所以它们按字母顺序比较,"zen"获胜。

Ordering单例非常有用,因为它允许我们通过许多不同的标准轻松比较事物,并将这些标准按顺序排列,从最重要的到最不重要的。

可能的元音

让我们看看Maybe a可以以哪些方式成为Monoid的实例,以及这些实例如何有用。

一种方法是将Maybe a视为单例,前提是其类型参数a也是单例,然后以这种方式实现mappend,使其使用被Just包裹的值的mappend操作。我们使用Nothing作为单位元,因此如果我们正在mappend的两个值中有一个是Nothing,我们保留另一个值。以下是实例声明:

instance Monoid a => Monoid (Maybe a) where
    mempty = Nothing
    Nothing `mappend` m = m
    m `mappend` Nothing = m
    Just m1 `mappend` Just m2 = Just (m1 `mappend` m2)

注意类约束。它表示只有当aMonoid的实例时,Maybe a才是Monoid的实例。如果我们用mappendNothing结合,结果就是那个值。如果我们mappend两个Just值,Just的内容会被mappend然后再次包裹在一个Just中。我们可以这样做,因为类约束确保了Just内部类型的实例是Monoid

ghci> Nothing `mappend` Just "andy"
Just "andy"
ghci> Just LT `mappend` Nothing
Just LT
ghci> Just (Sum 3) `mappend` Just (Sum 4)
Just (Sum {getSum = 7})

当我们处理可能失败的计算结果的单例时,这很有用。因为这个实例,我们不需要通过查看它们是Nothing还是Just值来检查计算是否失败;我们只需继续将它们视为正常的单例。

但如果Maybe内容的类型不是Monoid的实例呢?注意,在上一个实例声明中,我们必须依赖内容是单例的唯一情况是当mappend的两个参数都是Just值。当我们不知道内容是否是单例时,我们无法在它们之间使用mappend,那么我们该怎么办?好吧,我们可以做的一件事是丢弃第二个值,保留第一个值。为此,存在First a类型。这是它的定义:

newtype First a = First { getFirst :: Maybe a }
    deriving (Eq, Ord, Read, Show)

我们将一个Maybe anewtype包装起来。Monoid实例如下:

instance Monoid (First a) where
    mempty = First Nothing
    First (Just x) `mappend` _ = First (Just x)
    First Nothing `mappend` x = x

mempty只是一个用First newtype构造函数包装的Nothing。如果mappend的第一个参数是一个Just值,我们忽略第二个参数。如果第一个参数是Nothing,那么无论第二个参数是Just还是Nothing,我们都将其作为结果呈现:

ghci> getFirst $ First (Just 'a') `mappend` First (Just 'b')
Just 'a'
ghci> getFirst $ First Nothing `mappend` First (Just 'b')
Just 'b'
ghci> getFirst $ First (Just 'a') `mappend` First Nothing
Just 'a'

当我们有一堆Maybe值,只想知道其中是否有任何一个值是Just时,First很有用。mconcat函数派上了用场:

ghci> getFirst . mconcat . map First $ [Nothing, Just 9, Just 10]
Just 9

如果我们想在Maybe a上定义一个 monoid,使得当mappend的两个参数都是Just值时,保留第二个参数,Data.Monoid提供了Last a类型,它就像First a,但在mappendmconcat时保留最后一个非Nothing值:

ghci> getLast . mconcat . map Last $ [Nothing, Just 9, Just 10]
Just 10
ghci> getLast $ Last (Just "one") `mappend` Last (Just "two")
Just "two"

使用 Monoids 进行折叠

将 monoids 用于工作的更有趣的方法之一是让它们帮助我们定义各种数据结构的折叠。到目前为止,我们已经对列表进行了折叠,但列表并不是唯一可以折叠的数据结构。我们可以定义几乎任何数据结构的折叠。特别是树非常适合折叠。

因为有这么多与折叠配合得很好的数据结构,所以引入了Foldable类型类。就像Functor是用于可以映射的事物一样,Foldable是用于可以折叠的事物!它可以在Data.Foldable中找到,因为它导出了一些与Prelude中的函数名称冲突的函数,所以最好导入时使用限定名(并且配上罗勒):

import qualified Data.Foldable as F

为了节省我们宝贵的按键,我们已将其导入为F

那么,这个类型类定义了哪些功能?嗯,其中就包括foldrfoldlfoldr1foldl1。咦?我们已经知道这些函数了。这有什么新意呢?让我们比较一下FoldablefoldrPrelude中的foldr的类型,看看它们有什么不同:

ghci> :t foldr
foldr :: (a -> b -> b) -> b -> [a] -> b
ghci> :t F.foldr
F.foldr :: (F.Foldable t) => (a -> b -> b) -> b -> t a -> b

啊!所以,foldr接受一个列表并将其折叠起来,而Data.Foldable中的foldr接受任何可以折叠的类型,而不仅仅是列表!正如预期的那样,两个foldr函数对列表都做了相同的事情:

ghci> foldr (*) 1 [1,2,3]
6
ghci> F.foldr (*) 1 [1,2,3]
6

另一个支持折叠的数据结构是我们都熟悉并喜爱的Maybe

ghci> F.foldl (+) 2 (Just 9)
11
ghci> F.foldr (||) False (Just True)
True

但是对Maybe值进行折叠并不那么有趣。如果它是一个Just值,它就表现得像一个只有一个元素的列表;如果它是Nothing,它就像一个空列表。让我们考察一个稍微复杂一点的数据结构。

记得第七章中提到的树数据结构吗?我们是这样定义它的:

data Tree a = EmptyTree | Node a (Tree a) (Tree a) deriving (Show)

你已经了解到树要么是一个不包含任何值的空树,要么是一个包含一个值并且还有两个其他树的节点。定义它之后,我们使其成为Functor的一个实例,这样我们就可以在它上面fmap函数了。现在我们将它变成Foldable的一个实例,这样我们就可以折叠它了。

使类型构造函数成为 Foldable 实例的一种方法是为它直接实现 foldr。但另一种,通常更容易的方法是实现 foldMap 函数,这也是 Foldable 类型类的一部分。foldMap 函数具有以下类型:

foldMap :: (Monoid m, Foldable t) => (a -> m) -> t a -> m

它的第一个参数是一个函数,该函数接受我们的可折叠结构包含的类型(在此用 a 表示)的值,并返回一个单例值。它的第二个参数是一个包含类型 a 值的可折叠结构。它将该函数映射到可折叠结构上,从而产生一个包含单例值的可折叠结构。然后,通过在这些单例值之间执行 mappend 操作,将它们全部合并成一个单例值。这个函数在目前可能听起来有些奇怪,但你会看到它非常容易实现。实现这个函数就是使我们的类型成为 Foldable 实例的全部所需!所以如果我们只是为某种类型实现 foldMap,我们就可以免费获得该类型的 foldrfoldl

这就是我们将 Tree 做为 Foldable 实例的方法:

instance F.Foldable Tree where
    foldMap f EmptyTree = mempty
    foldMap f (Node x l r) = F.foldMap f l `mappend`
                             f x           `mappend`
                             F.foldMap f r

如果我们提供了一个函数,该函数接受我们树的一个元素并返回一个单例值,我们如何将整个树缩减成一个单例值?当我们使用 fmap 在我们的树上进行操作时,我们将映射到的函数应用到节点上,然后递归地将函数映射到左子树和右子树。在这里,我们不仅要映射一个函数,还要使用 mappend 将结果连接成一个单例值。首先,我们考虑空树的情况——一个悲伤的、孤独的树,没有任何值或子树。它不包含任何我们可以提供给我们的单例制作函数的值,所以我们只是说,如果我们的树是空的,它变成的单例值是 mempty

无标题图片

非空节点的案例要有趣一些。它包含两个子树以及一个值。在这种情况下,我们递归地对左子树和右子树应用相同的函数 f。记住,我们的 foldMap 结果是一个单例值。我们还对节点中的值应用了我们的函数 f。现在我们有三个单例值(来自我们的子树以及将 f 应用到节点值的结果),我们只需要将它们组合成一个单一的值。为此,我们使用 mappend,并且自然地,左子树先来,然后是节点值,最后是右子树。

注意,我们不需要提供接受一个值并返回单例值的函数。我们通过 foldMap 参数接收该函数,我们只需要决定在哪里应用该函数以及如何连接从它生成的结果单例。

现在我们已经为我们的树类型有了 Foldable 实例,我们就可以免费获得 foldrfoldl!考虑这个树:

testTree = Node 5
            (Node 3
                (Node 1 EmptyTree EmptyTree)
                (Node 6 EmptyTree EmptyTree)
            )
            (Node 9
                (Node 8 EmptyTree EmptyTree)
                (Node 10 EmptyTree EmptyTree)
            )

它的根节点是 5,然后它的左节点有 3,左边是 1,右边是 6。根的右节点有一个 9,然后左边是 8,最右边是 10。有了 Foldable 实例,我们可以执行所有在列表上可以执行的折叠操作:

ghci> F.foldl (+) 0 testTree
42
ghci> F.foldl (*) 1 testTree
64800

foldMap 不仅对创建新的 Foldable 实例有用。它还可以帮助我们简化结构到一个单一的 monoid 值。例如,如果我们想知道我们的树中是否有任何数字等于 3,我们可以这样做:

ghci> getAny $ F.foldMap (\x -> Any $ x == 3) testTree
True

这里,\x -> Any $ x == 3 是一个函数,它接受一个数字并返回一个 monoid 值:一个被 Any 包裹的 BoolfoldMap 将此函数应用于我们树中的每个元素,然后使用 mappend 将结果 monoids 简化成一个单一的 monoid。假设我们这样做:

ghci> getAny $ F.foldMap (\x -> Any $ x > 15) testTree
False

在将 lambda 函数应用于它们之后,我们树中的所有节点都将持有值 Any False。但为了最终得到 Truemappend 对于 Any 必须至少有一个 True 值作为参数。这就是为什么最终结果是 False,这在逻辑上是合理的,因为我们的树中没有任何值大于 15

我们也可以通过使用 \x -> [x] 函数进行 foldMap 操作,轻松地将我们的树转换为列表。通过首先将此函数投影到我们的树上,每个元素都变成了单元素列表。所有这些单元素列表之间的 mappend 操作将产生一个包含我们树中所有元素的单一列表:

ghci> F.foldMap (\x -> [x]) testTree
[1,3,6,5,8,9,10]

很酷的是,这些技巧不仅限于树。它们适用于任何 Foldable 实例!

第十三章。一打 monad

当我们在第七章中首次讨论 functor 时,你看到了它们对于可以映射的值是一个有用的概念。然后,在第十一章第十一章。应用 functor 中,我们通过应用 functor 进一步发展了这个概念,它允许我们将某些数据类型的值视为带有上下文的值,并在这些值上使用正常函数,同时保留这些上下文的意义。

在本章中,你将学习关于monad的内容,它们只是增强的应用 functor,就像应用 functor 是增强的 functor 一样。

升级我们的应用 functor

当我们开始使用 functor 时,你看到了使用Functor类型类在各个数据类型上映射函数是可能的。functor 的介绍让我们提出了这样的问题:“当我们有一个类型为a -> b的函数和一些数据类型f a时,我们如何将这个函数映射到数据类型上,最终得到f b?”你看到了如何将函数映射到Maybe a、列表[a]IO a等上。你甚至看到了如何将函数a -> b映射到其他类型为r -> a的函数上,以得到类型为r -> b的函数。为了回答如何将函数映射到某些数据类型的问题,我们只需要查看fmap的类型:

无标题图片

fmap :: (Functor f) => (a -> b) -> f a -> f b

然后我们只需要通过编写适当的Functor实例来使它适用于我们的数据类型。

然后你看到了 functor 可能的改进,并提出了更多问题。如果函数a -> b已经包裹在一个 functor 值中,那会怎样?比如说我们有Just (*3)——我们如何将其应用于Just 5?如果我们不想将其应用于Just 5,而是应用于Nothing呢?或者如果我们有[(*2),(+4)],我们如何将其应用于[1,2,3]?这怎么可能呢?为此,引入了Applicative类型类:

(<*>) :: (Applicative f) => f (a -> b) -> f a -> f b

你也看到了,你可以将一个普通值包裹在数据类型中。例如,我们可以取一个1并把它包裹成Just 1。或者我们可以把它变成[1]。它甚至可以成为一个什么也不做,只是产生1的 I/O 操作。执行这个操作的函数被称为pure

应用值可以看作是添加了上下文的值——用技术术语来说,就是一个花哨的值。例如,字符'a'只是一个普通字符,而Just 'a'则有一些额外的上下文。我们不是有一个Char,而是一个Maybe Char,这告诉我们它的值可能是一个字符,但也可能是一个字符的缺失。Applicative类型类允许我们使用带有上下文的正常函数对这些值进行操作,并且这个上下文被保留。观察一个例子:

ghci> (*) <$> Just 2 <*> Just 8
Just 16
ghci> (++) <$> Just "klingon" <*> Nothing
Nothing
ghci> (-) <$> [3,4] <*> [1,2,3]
[2,1,0,3,2,1]

因此,现在我们将它们视为应用值,Maybe a的值代表可能失败的计算,[a]的值代表有多个结果(非确定性计算)的计算,IO a的值代表有副作用的价值,等等。

单子是应用函子的自然扩展,并且它们为以下问题提供了一个解决方案:如果我们有一个带有上下文的价值m a,我们如何应用一个接受正常a并返回带有上下文的价值的函数?换句话说,我们如何将类型为a -> m b的函数应用到类型为m a的价值上?本质上,我们想要这个函数:

(>>=) :: (Monad m) => m a -> (a -> m b) -> m b

如果我们有一个复杂的价值和一个接受正常值但返回复杂值的函数,我们如何将这个复杂的价值喂给函数?这是处理单子时的主要关注点。我们写m a而不是f a,因为m代表Monad,但单子只是支持>>=的应用函子。>>=函数被称为绑定

当我们有一个正常的值a和一个正常的函数a -> b时,将值喂给函数是非常容易的——我们只需正常地将函数应用到值上,就是这样。但是当我们处理带有特定上下文的价值时,需要一些思考才能看到这些复杂的价值是如何喂给函数的,以及如何考虑它们的行为。但你会看到,这就像一、二、三那么简单。

通过Maybe入门

既然你对单子的概念有了模糊的了解,让我们使这个想法更加具体。不出所料,Maybe是一个单子。在这里,我们将更深入地探讨它在这个角色中的工作方式。

注意

确保你现在理解了应用函子(我们在第十一章中讨论了它们)。你应该对各种Applicative实例的工作方式和它们所代表的计算类型有所感觉。为了理解单子,你将把现有的应用函子知识提升到更高层次。

无标题图片

类型为Maybe a的值代表类型为a的值,但带有可能失败上下文。Just "dharma"的值意味着字符串"dharma"存在。Nothing的值代表其不存在,或者如果你把字符串看作是计算的结果,它意味着计算失败了。

当我们把Maybe作为一个函子来考虑时,我们看到了,如果我们想要在它上面fmap一个函数,如果里面是Just值,函数会被映射到其内部。否则,会保留Nothing,因为没有东西可以映射!

ghci> fmap (++"!") (Just "wisdom")
Just "wisdom!"
ghci> fmap (++"!") Nothing
Nothing

作为应用函子,Maybe函数的行为类似。然而,在应用函子中,函数本身处于一个上下文中,以及它被应用到的值。Maybe作为一个应用函子,当我们使用<*>将一个Maybe内的函数应用于另一个Maybe内的值时,它们都必须是Just值,结果才是一个Just值;否则,结果是Nothing。这很有道理。如果你缺少函数或你要应用它的东西,你不能凭空创造出来,所以你需要传播失败。

ghci> Just (+3) <*> Just 3
Just 6
ghci> Nothing <*> Just "greed"
Nothing
ghci> Just ord <*> Nothing
Nothing

使用应用风格让普通函数作用于Maybe值的方式与此类似。所有值都必须是Just值;否则,结果都是Nothing

ghci> max <$> Just 3 <*> Just 6
Just 6
ghci> max <$> Just 3 <*> Nothing
Nothing

现在,让我们思考一下如何使用>>=Maybe结合。>>=接受一个单子值和一个接受正常值的函数。它返回一个单子值,并成功地将该函数应用于单子值。如果函数接受正常值,它是如何做到这一点的呢?嗯,它必须考虑到那个单子值的上下文。

在这种情况下,>>=将接受一个Maybe a值和一个类型为a -> Maybe b的函数,并以某种方式将函数应用于Maybe a。为了弄清楚它是如何做到这一点的,我们可以使用我们对Maybe作为一个应用函子的理解。让我们假设我们有一个函数\x -> Just (x+1)。它接受一个数字,将其加 1,并用Just包装起来:

ghci> (\x -> Just (x+1)) 1
Just 2
ghci> (\x -> Just (x+1)) 100
Just 101

如果我们给它1,它评估为Just 2。如果我们给它数字100,结果是Just 101。这似乎非常直接。但我们如何将Maybe值喂给这个函数呢?如果我们考虑Maybe作为应用函子的行为,回答这个问题就很简单了。我们给它一个Just值,取Just里面的东西,并将函数应用于它。如果我们给它一个Nothing,那么我们只剩下一个函数,但没有东西可以应用它。在这种情况下,我们就像之前做的那样,说结果是Nothing

我们暂时不叫它>>=,而是叫它applyMaybe。它接受一个Maybe a和一个返回Maybe b的函数,并成功地将该函数应用于Maybe a。下面是相应的代码:

applyMaybe :: Maybe a -> (a -> Maybe b) -> Maybe b
applyMaybe Nothing f  = Nothing
applyMaybe (Just x) f = f x

现在让我们来玩玩这个。我们将将其用作中缀函数,这样Maybe值就在左边,函数在右边:

ghci> Just 3 `applyMaybe` \x -> Just (x+1)
Just 4
ghci> Just "smile" `applyMaybe` \x -> Just (x ++ " :)")
Just "smile :)"
ghci> Nothing `applyMaybe` \x -> Just (x+1)
Nothing
ghci> Nothing `applyMaybe` \x -> Just (x ++ " :)")
Nothing

在这个例子中,当我们使用applyMaybe与一个Just值和一个函数时,函数简单地应用于Just内的值。当我们尝试使用它与Nothing时,整个结果都是Nothing。那么如果函数返回Nothing呢?让我们看看:

ghci> Just 3 `applyMaybe` \x -> if x > 2 then Just x else Nothing
Just 3
ghci> Just 1 `applyMaybe` \x -> if x > 2 then Just x else Nothing
Nothing

结果正如我们所预期的。如果左边的单子值是Nothing,整个结果就是Nothing。如果右边的函数返回Nothing,结果又是Nothing。这和我们使用Maybe作为应用时得到的结果类似,如果在混合中存在Nothing

看起来我们已经弄清楚如何取一个花哨的值,将其传递给一个接受普通值的函数,并返回一个花哨的值。我们是通过记住 Maybe 值代表可能失败的计算来做到这一点的。

你可能会问自己,“这有什么用?” 可能看起来应用函子比单子更强,因为应用函子允许我们取一个普通函数并使其能够操作带有上下文的值。在本章中,你将看到单子作为应用函子的升级,也可以做到这一点。事实上,它们可以做应用函子做不到的一些酷炫的事情。

我们稍后会回到 Maybe,但首先,让我们看看属于单子的类型类。

单子类型类

就像函子有 Functor 类型类,应用函子有 Applicative 类型类一样,单子也有自己的类型类:Monad! (哇,谁能想到?)

class Monad m where
    return :: a -> m a

    (>>=) :: m a -> (a -> m b) -> m b

    (>>) :: m a -> m b -> m b
    x >> y = x >>= \_ -> y

    fail :: String -> m a
    fail msg = error msg

第一行说 class Monad m where。但是等等,我没有说过单子只是增强的应用函子吗?难道不应该有一个类约束,比如 class (Applicative m) => Monad m where,这样类型在成为单子之前必须是一个应用函子?嗯,应该是这样的,但 Haskell 被创造出来时,人们还没有意识到应用函子非常适合 Haskell。但请放心,每个单子都是一个应用函子,即使 Monad 类声明没有这么说。

无标题图片

Monad 类型类定义的第一个函数是 return。它与 Applicative 类型类的 pure 相同。所以,尽管它有不同的名字,但你已经熟悉它了。return 的类型是 (Monad m) => a -> m a。它接受一个值并将其放入一个最小的默认上下文中,该上下文仍然包含该值。换句话说,return 接受某物并将其包裹在单子中。我们在 第八章 处理 I/O 时已经使用了 return。我们用它来取一个值并创建一个虚假的 I/O 操作,该操作除了产生该值外什么都不做。对于 Maybe,它取一个值并将其包裹在 Just 中。

注意

提醒一下:return 与大多数其他语言中的 return 完全不同。它不会结束函数执行。它只是取一个普通值并将其放入一个上下文中。

下一个函数是 >>=,或绑定。它就像函数应用,但不是取一个普通值并将其传递给一个普通函数,而是取一个单子值(即带有上下文的值)并将其传递给一个接受普通值但返回单子值的函数。

无标题图片

接下来是>>=。我们现在不会过多关注它,因为它有一个默认实现,并且当创建Monad实例时很少实现。我们将在 Banana on a Wire 中更详细地探讨它。

Monad类型类的最后一个函数是fail。我们在代码中从不显式使用它。相反,Haskell 使用它来在后面你将遇到的特殊语法结构中启用失败。现在我们不需要过多关注fail

现在你已经知道了Monad类型类的样子,让我们看看Maybe是如何成为Monad的一个实例的!

instance Monad Maybe where
    return x = Just x
    Nothing >>= f = Nothing
    Just x >>= f  = f x
    fail _ = Nothing

returnpure相同,所以这一点是显而易见的。我们做了与Applicative类型类中相同的事情,将其包裹在Just中。>>=函数与我们的applyMaybe相同。当我们向函数传递Maybe a时,我们牢记上下文,如果左边的值是Nothing,则返回Nothing。再次,如果没有值,那么就没有办法将我们的函数应用于它。如果是Just,我们就取出里面的值并对其应用f

我们可以围绕Maybe作为monad进行一些实验:

ghci> return "WHAT" :: Maybe String
Just "WHAT"
ghci> Just 9 >>= \x -> return (x*10)
Just 90
ghci> Nothing >>= \x -> return (x*10)
Nothing

第一行没有什么新奇的,因为我们已经使用过pureMaybe,而且我们知道return只是以不同名称的pure

接下来的两行展示了>>=的使用。注意当我们将Just 9传递给函数\x -> return (x*10)时,函数内的x取值为9。这好像我们能够从Maybe中提取值而不需要模式匹配。而且我们也没有丢失Maybe值的上下文,因为当它是Nothing时,使用>>=的结果也将是Nothing

Walk the Line

现在你已经知道了如何在考虑可能失败的情况下,将一个Maybe a值传递给一个类型为a -> Maybe b的函数,让我们看看我们如何可以重复使用>>=来处理多个Maybe a值的计算。

皮埃尔决定从他在鱼场的工作中休息一下,尝试走钢丝。他在这方面并不差,但他有一个问题:鸟儿总是落在他的平衡杆上!它们来短暂休息,与它们的鸟类朋友聊天,然后去找面包屑。如果杆子两边的鸟儿数量总是相等,这不会让他太烦恼。但有时,所有的鸟儿都决定他们更喜欢一边。它们把他弄失衡,结果皮埃尔(他使用了安全网)尴尬地摔倒了。

无标题图片

假设皮埃尔保持平衡的条件是杆子两侧的鸟的数量都在三个以内。所以如果右边有一只鸟,左边有四只鸟,他就没问题。但如果第五只鸟降落在左边,他就失去了平衡,然后就会跌落。

我们将模拟鸟在杆子上降落和飞走,并查看在经过一定数量的鸟的到达和离开后,皮埃尔是否还在那里。例如,我们想看看如果首先有一只鸟降落在左边,然后四只鸟占据了右边,最后左边的那只鸟决定飞走,会发生什么。

代码,代码,代码

我们可以用一对简单的整数来表示杆子。第一个组成部分将表示左边的鸟的数量,第二个组成部分表示右边的鸟的数量:

type Birds = Int
type Pole = (Birds, Birds)

首先,我们为 Int 创建了一个类型同义词,称为 Birds,因为我们正在使用整数来表示鸟的数量。然后我们创建了一个类型同义词 (Birds, Birds) 并将其称为 Pole(不要与波兰血统的人混淆)。

现在来考虑添加一些函数,这些函数可以将一定数量的鸟降落在杆子的这一侧或那一侧。

landLeft :: Birds -> Pole -> Pole
landLeft n (left, right) = (left + n, right)

landRight :: Birds -> Pole -> Pole
landRight n (left, right) = (left, right + n)

让我们试试看:

ghci> landLeft 2 (0, 0)
(2,0)
ghci> landRight 1 (1, 2)
(1,3)
ghci> landRight (-1) (1, 2)
(1,1)

要让鸟飞走,我们只需让一侧降落的鸟的数量为负数。因为将鸟降落在 Pole 上会返回一个 Pole,所以我们可以链式调用 landLeftlandRight

ghci> landLeft 2 (landRight 1 (landLeft 1 (0, 0)))
(3,1)

当我们将函数 landLeft 1 应用到 (0, 0) 上时,我们得到 (1, 0)。然后我们在右边降一只鸟,结果变为 (1, 1)。最后,左边降两只鸟,结果变为 (3, 1)。我们通过先写函数再写其参数的方式来应用函数到某个东西上,但在这里,如果杆子先写,然后是降落函数会更好。假设我们创建一个这样的函数:

x -: f = f x

我们可以通过先写参数再写函数的方式来应用函数:

ghci> 100 -: (*3)
300
ghci> True -: not
False
ghci> (0, 0) -: landLeft 2
(2,0)

通过使用这种形式,我们可以以更可读的方式反复在杆子上降落鸟:

ghci> (0, 0) -: landLeft 1 -: landRight 1 -: landLeft 2
(3,1)

真是酷!这个版本与我们之前反复将鸟降落在杆子上的版本等效,但看起来更整洁。在这里,我们一开始从 (0, 0) 开始,然后左降一只鸟,右降一只,最后左降两只。

我要飞走了

到目前为止一切顺利,但如果一侧降落了十只鸟会怎样?

ghci> landLeft 10 (0, 3)
(10,3)

左边有十只鸟,右边只有三只?这肯定会让可怜的皮埃尔从空中跌落!这里很明显,但如果有一系列的降落像这样:

ghci> (0, 0) -: landLeft 1 -: landRight 4 -: landLeft (-1) -: landRight (-2)
(0,2)

看起来一切似乎都正常,但如果你按照这里的步骤来,你会看到在某个时刻右边有四只鸟,左边没有鸟!为了解决这个问题,我们需要重新审视我们的 landLeftlandRight 函数。

我们希望landLeftlandRight函数能够失败。我们希望它们在平衡正常时返回一根新杆,但在鸟儿以不平衡的方式降落时失败。还有什么比使用Maybe来添加失败上下文到值更好的方法呢!让我们重新设计这些函数:

landLeft :: Birds -> Pole -> Maybe Pole
landLeft n (left, right)
    | abs ((left + n) - right) < 4 = Just (left + n, right)
    | otherwise                    = Nothing

landRight :: Birds -> Pole -> Maybe Pole
landRight n (left, right)
    | abs (left - (right + n)) < 4 = Just (left, right + n)
    | otherwise                    = Nothing

这些函数现在返回一个Maybe Pole而不是Pole。它们仍然接受与之前相同的鸟儿数量和旧杆,但随后会检查在杆上放下这么多鸟儿是否会让皮埃尔失去平衡。我们使用守卫来检查新杆上鸟儿数量的差异是否小于4。如果是,我们将新杆包裹在Just中并返回它。如果不是,我们返回一个Nothing,表示失败。

让我们试试这些小家伙:

ghci> landLeft 2 (0, 0)
Just (2,0)
ghci> landLeft 10 (0, 3)
Nothing

当我们放下鸟儿而不让皮埃尔失去平衡时,我们得到一根用Just包裹的新杆。但当更多的鸟儿最终落在杆的一侧时,我们得到一个Nothing。这很酷,但我们似乎失去了反复在杆上放下鸟儿的能力。我们不能再做landLeft 1 (landRight 1 (0, 0))了,因为当我们将landRight 1应用于(0, 0)时,我们得到的是一个Maybe Pole,而不是PolelandLeft 1需要一个Pole,而不是Maybe Pole

我们需要一种方法来接受一个Maybe Pole并将其提供给一个接受Pole并返回Maybe Pole的函数。幸运的是,我们有>>=,它对Maybe做了这件事。让我们试试:

ghci> landRight 1 (0, 0) >>= landLeft 2
Just (2,1)

记得landLeft 2的类型是Pole -> Maybe Pole。我们不能直接将landRight 1 (0, 0)的结果Maybe Pole喂给landLeft 2,所以我们使用>>=来带上下文地取那个值,并将其提供给landLeft 2>>=确实允许我们将Maybe值作为带上下文的值来处理。如果我们将Nothing喂给landLeft 2,结果就是Nothing,失败被传播:

ghci> Nothing >>= landLeft 2
Nothing

通过这种方式,我们现在可以链式调用可能失败的降落,因为>>=允许我们将单子值喂给接受正常值的函数。以下是一系列鸟儿降落的序列:

ghci> return (0, 0) >>= landRight 2 >>= landLeft 2 >>= landRight 2
Just (2,4)

在开始时,我们使用return来取一根杆并将其包裹在Just中。我们可以直接将landRight 2应用于(0, 0)——结果会相同——但这样,我们可以通过为每个函数使用>>=来保持一致性。Just (0, 0)被喂给landRight 2,结果产生Just (0, 2)。然后,这个结果被喂给landLeft 2,结果产生Just (2, 2),依此类推。

记得在我们将失败引入皮埃尔的常规之前给出的那个例子吗?

ghci> (0, 0) -: landLeft 1 -: landRight 4 -: landLeft (-1) -: landRight (-2)
(0,2)

它并没有很好地模拟他与鸟儿的互动。在中间,他的平衡失调,但结果并没有反映出这一点。现在让我们通过使用单子应用(>>=)而不是正常应用来修复这个问题:

ghci> return (0, 0) >>= landLeft 1 >>= landRight 4 >>= landLeft (-1) >>= landRight (-2)
Nothing

最终结果表示失败,这正是我们所期望的。让我们看看这个结果是如何得到的:

  1. return(0, 0)放入一个默认上下文中,使其成为一个Just (0, 0)

  2. Just (0, 0) >>= landLeft 1 发生。由于 Just (0, 0) 是一个 Just 值,landLeft 1 被应用于 (0, 0),结果是一个 Just (1, 0),因为鸟儿仍然相对平衡。

  3. Just (1, 0) >>= landRight 4 发生,结果是 Just (1, 4),尽管平衡只是勉强保持。

  4. Just (1, 4) 被传递到 landLeft (-1)。这意味着 landLeft (-1) (1, 4) 发生。现在由于 landLeft 的工作方式,这导致了一个 Nothing,因为结果杆是不平衡的。

  5. 现在我们有一个 Nothing,它被传递到 landRight (-2),但由于它是一个 Nothing,结果自动变为 Nothing,因为我们没有东西可以应用 landRight (-2)

我们不能仅仅通过使用 Maybe 作为应用性来实现这一点。如果你尝试这样做,你会陷入困境,因为应用性函子不允许应用性值之间有太多的交互。它们最多只能通过应用性风格将应用性值用作函数的参数。

应用性操作符将获取它们的结果,并以适合每个应用性的方式将它们传递给函数,然后将最终的适用性值组合起来,但它们之间并没有太多的交互。然而,在这里,每一步都依赖于前一步的结果。在每次着陆时,都会检查前一步的可能结果和杆的平衡。这决定了着陆是成功还是失败。

电线上的香蕉

现在让我们设计一个函数,它忽略平衡杆上当前鸟的数量,只是让皮埃尔滑倒并跌倒。我们将它称为 banana

无标题图片

banana :: Pole -> Maybe Pole
banana _ = Nothing

我们可以将这个函数与我们的鸟儿着陆一起链式调用。它总会导致我们的行进者跌倒,因为它忽略传递给它的任何内容,并总是返回失败。

ghci> return (0, 0) >>= landLeft 1 >>= banana >>= landRight 1
Nothing

Just (1, 0) 被传递到 banana,但它产生了一个 Nothing,导致一切结果都是 Nothing。多么不幸啊!

而不是创建忽略输入并仅返回预定单子值的函数,我们可以使用 >> 函数。以下是它的默认实现:

(>>) :: (Monad m) => m a -> m b -> m b
m >> n = m >>= \_ -> n

通常情况下,将某个值传递给一个忽略其参数并总是返回某个预定值的函数,总是导致返回那个预定值。然而,在使用单子的情况下,还需要考虑它们的上下文和意义。下面是如何使用 >>Maybe 一起工作的示例:

ghci> Nothing >> Just 3
Nothing
ghci> Just 3 >> Just 4
Just 4
ghci> Just 3 >> Nothing
Nothing

如果我们将 >> 替换为 >>= \_ ->,就可以很容易地看到发生了什么。

我们可以在链中用 >> 和一个 Nothing 替换我们的 banana 函数,以确保和明显的失败:

ghci> return (0, 0) >>= landLeft 1 >> Nothing >>= landRight 1
Nothing

如果我们没有选择将 Maybe 值视为具有失败上下文的值并将它们传递给函数,这会是什么样子?以下是鸟儿着陆的一系列示例:

routine :: Maybe Pole
routine = case landLeft 1 (0, 0) of
    Nothing -> Nothing
    Just pole1 -> case landRight 4 pole1 of
        Nothing -> Nothing
        Just pole2 -> case landLeft 2 pole2 of
            Nothing -> Nothing
            Just pole3 -> landLeft 1 pole3

我们在左边着陆一只鸟,然后检查失败的可能性和成功的可能性。在失败的情况下,我们返回一个 Nothing。在成功的情况下,我们在右边着陆鸟,然后再次做同样的事情。将这个怪物转换成一个整洁的单调应用链 >>= 是一个经典的例子,说明了 Maybe 单调在需要连续进行基于可能失败的计算的运算时可以节省大量时间。

注意 Maybe 实现 >>= 中的逻辑,它正是通过查看值是否为 Nothing 并根据这一知识采取行动。如果值是 Nothing,它将立即返回一个 Nothing。如果值不是 Nothing,它将使用 Just 内部的值继续前进。

无标题图片

在本节中,我们探讨了当函数返回的值支持失败时,一些函数的工作效果会更好。通过将这些值转换为 Maybe 值,并用 >>= 替换正常函数应用,我们得到了一种几乎免费处理失败的方法。这是因为 >>= 应该保留它所应用函数的上下文。在这种情况下,上下文是我们的值是有失败的值。因此,当我们对这些值应用函数时,总是考虑到失败的可能性。

do 语法

Haskell 中的 Monads 非常有用,以至于它们拥有自己的特殊语法,称为 do 语法。你已经在 第八章 中遇到了 do 语法,当时我们用它来将几个 I/O 操作粘合在一起。实际上,do 语法不仅限于 IO,它可以用于任何单调。它的原则仍然是相同的:按顺序粘合单调值。

考虑这个熟悉的单调应用例子:

ghci> Just 3 >>= (\x -> Just (show x ++ "!"))
Just "3!"

已经历过,做过。给一个返回一个值的函数传递一个单调值——没什么大不了的。注意当我们这样做时,x 在 lambda 内部变成了 3。一旦我们进入那个 lambda,它就只是一个普通值,而不是单调值。那么,如果我们在这个函数内部有另一个 >>= 呢?看看这个例子:

ghci> Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))
Just "3!"

啊,嵌套使用 >>=!在外层 lambda 中,我们将 Just "!" 传递给 lambda \y -> Just (show x ++ y)。在这个 lambda 内部,y 变成了 "!"x 仍然是 3,因为我们是从外层 lambda 中获取它的。这一切都让我想起了以下表达式:

ghci> let x = 3; y = "!" in show x ++ y
"3!"

这里的主要区别在于我们 >>= 例子中的值是单调的。它们是有失败上下文的值。我们可以用失败来替换其中的任何一个:

ghci> Nothing >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))
Nothing
ghci> Just 3 >>= (\x -> Nothing >>= (\y -> Just (show x ++ y)))
Nothing
ghci> Just 3 >>= (\x -> Just "!" >>= (\y -> Nothing))
Nothing

在第一行,自然地将Nothing提供给一个函数会导致一个Nothing。在第二行,我们向一个函数提供Just 3,这时x变成了3。但是然后我们向内部 lambda 提供一个Nothing,其结果是Nothing,这导致外部 lambda 也产生Nothing。所以这有点像在let表达式中给变量赋值,只是所涉及的值是单子值。

为了进一步说明这一点,让我们把这个写在一个脚本中,并让每个Maybe值占据它自己的行:

foo :: Maybe String
foo = Just 3   >>= (\x ->
      Just "!" >>= (\y ->
      Just (show x ++ y)))

为了让我们免于编写所有这些讨厌的 lambda 函数,Haskell 给了我们do表示法。它允许我们像这样编写之前的代码:

foo :: Maybe String
foo = do
    x <- Just 3
    y <- Just "!"
    Just (show x ++ y)

无标题图片

看起来我们获得了从Maybe值中临时提取东西的能力,而无需在每一步检查Maybe值是Just值还是Nothing值。多么酷啊!如果我们尝试提取的任何值是Nothing,整个do表达式将导致一个Nothing。我们正在拉出它们的(可能存在的)值,让>>=来处理这些值带来的上下文。

do表达式只是连接单子值的不同语法。

如我所做

do表达式中,每一行如果不是let行,就是一个单子值。为了检查其结果,我们使用<-。如果我们有一个Maybe String并将其绑定到一个变量上,那么这个变量将是一个String,就像我们使用>>=向 lambda 函数提供单子值时一样。

do表达式中,最后一个单子值——就像这里的Just (show x ++ y)——不能使用<-来绑定其结果,因为如果我们把do表达式转换回一系列>>=应用的链,那就没有意义了。相反,它的结果是整个粘合的单子值的结果,考虑到之前任何一个可能的失败。例如,检查以下行:

ghci> Just 9 >>= (\x -> Just (x > 8))
Just True

因为>>=的左参数是一个Just值,所以 lambda 函数被应用于9,结果是Just True。我们可以用do表示法重写这个,如下所示:

marySue :: Maybe Bool
marySue = do
    x <- Just 9
    Just (x > 8)

比较这两个版本,很容易看出整个单子值的结果是do表达式中最后一个单子值的结果,所有前面的单子值都连接到它里面。

皮埃尔归来

我们走钢丝演员的表演也可以用do表示法来表示。landLeftlandRight接受一些鸟和一根杆,并产生一个用Just包裹的杆。例外的情况是当走钢丝演员滑倒时,这时会产生一个Nothing。我们使用>>=来连接连续的步骤,因为每一步都依赖于前一步,并且每一步都有一个可能的失败上下文。这里有两只鸟落在左边,然后两只鸟落在右边,最后一只鸟落在左边:

routine :: Maybe Pole
routine = do
    start <- return (0, 0)
    first <- landLeft 2 start
    second <- landRight 2 first
    landLeft 1 second

让我们看看他是否成功:

ghci> routine
Just (3,2)

他做到了!

当我们通过显式编写 >>= 来执行这些常规操作时,我们通常会说出类似于 return (0, 0) >>= landLeft 2 的话,因为 landLeft 2 是一个返回 Maybe 值的函数。然而,使用 do 表达式时,每一行都必须包含一个单调值。因此,我们显式地将之前的 Pole 传递给 landLeft landRight 函数。如果我们检查我们绑定的 Maybe 值的变量,start 将是 (0, 0)first 将是 (2, 0) 等等。

由于 do 表达式是逐行编写的,它们可能看起来像命令式代码,但它们只是顺序的,因为每一行中的每个值都依赖于前一个值的結果,以及它们的上下文(在这种情况下,它们是成功还是失败)。

再次,让我们看看如果我们没有使用 Maybe 的单调特性,这段代码会是什么样子:

routine :: Maybe Pole
routine =
    case Just (0, 0) of
        Nothing -> Nothing
        Just start -> case landLeft 2 start of
            Nothing -> Nothing
            Just first -> case landRight 2 first of
                Nothing -> Nothing
                Just second -> landLeft 1 second

看看在成功的情况下,Just (0, 0) 中的元组变成了 startlandLeft 2 start 的结果变成了 first,以此类推?

如果我们想在 do 表示法中给 Pierre 抛一个香蕉皮,我们可以这样做:

routine :: Maybe Pole
routine = do
    start <- return (0, 0)
    first <- landLeft 2 start
    Nothing
    second <- landRight 2 first
    landLeft 1 second

当我们在 do 表示法中写一行代码而没有使用 <- 来绑定单调值时,它就像在想要忽略结果的单调值后面加上 >> 一样。我们按顺序排列单调值,但忽略其结果,因为我们不关心它是什么。此外,这比写其等价形式 _ <- Nothing 更美观。

何时使用 do 表示法,何时显式使用 >>= 取决于你。我认为这个例子适合显式编写 >>=,因为每个步骤都具体依赖于前一个步骤的结果。使用 do 表示法,我们需要特别指出鸟儿落在哪个极点上,但每次我们只是使用前一次着陆的结果。但即便如此,它还是给了我们一些关于 do 表示法的见解。

模式匹配与失败

do 表示法中,当我们将单调值绑定到名称时,我们可以利用模式匹配,就像在 let 表达式和函数参数中一样。以下是一个 do 表达式中的模式匹配示例:

justH :: Maybe Char
justH = do
    (x:xs) <- Just "hello"
    return x

我们使用模式匹配来获取字符串 "hello" 的第一个字符,并将其作为结果展示。因此 justH 的值是 Just 'h'

如果这个模式匹配失败了怎么办?当在函数中对一个模式进行匹配失败时,会尝试下一个模式。如果匹配在给定函数的所有模式上都失败了,则会抛出一个错误,程序崩溃。另一方面,let 表达式中的失败模式匹配会立即产生错误,因为 let 表达式中不存在模式下降的机制。

do 表达式中的模式匹配失败时,fail 函数(Monad 类型类的一部分)允许它在当前单调的上下文中导致失败,而不是使程序崩溃。以下是它的默认实现:

fail :: (Monad m) => String -> m a
fail msg = error msg

因此,默认情况下,它确实会导致程序崩溃。但是,结合可能失败上下文(如Maybe)的单子通常会在它们自己的实现中处理它。对于Maybe,它的实现如下:

fail _ = Nothing

它忽略了错误消息并创建了一个Nothing。因此,当在do记法中编写的Maybe值中的模式匹配失败时,整个值的结果将是Nothing。这比程序崩溃要好。下面是一个带有必败模式匹配的do表达式:

wopwop :: Maybe Char
wopwop = do
    (x:xs) <- Just ""
    return x

模式匹配失败,因此效果等同于将带有模式的整个行替换为Nothing。让我们试一试:

ghci> wopwop
Nothing

失败的模式匹配在我们的单子上下文中引起了一个失败,而不是引起程序范围的失败,这相当不错。

列表单子

到目前为止,你已经看到了Maybe值可以被视为具有失败上下文的值,以及我们如何通过使用>>=将它们馈送到函数中来将失败处理纳入我们的代码。在本节中,我们将探讨如何使用列表的单子特性以清晰和可读的方式将非确定性引入我们的代码。

无标题图片

在第十一章中,我们讨论了当列表作为应用函子使用时如何表示非确定性值。像5这样的值是确定的——它只有一个结果,我们确切地知道它是什么。另一方面,像[3,8,9]这样的值包含多个结果,因此我们可以将其视为一个同时包含多个值的单一值。使用列表作为应用函子很好地展示了这种非确定性。

ghci> (*) <$> [1,2,3] <*> [10,100,1000]
[10,100,1000,20,200,2000,30,300,3000]

结果列表包括从左侧列表中乘以右侧列表中元素的所有的可能组合。在处理非确定性时,我们可以做出许多选择,所以我们只是尝试所有这些。这意味着结果也是一个不确定性值,但它有更多的结果。

这种非确定性上下文很好地转化为单子。下面是列表的Monad实例看起来像什么:

instance Monad [] where
    return x = [x]
    xs >>= f = concat (map f xs)
    fail _ = []

如你所知,returnpure做同样的事情,你已经熟悉了列表中的returnreturn接受一个值并将其放入一个最小的默认上下文中,该上下文仍然产生该值。换句话说,return创建一个只有一个值作为结果的列表。当我们只想将一个普通值包装到列表中以便它可以与不确定性值交互时,这很有用。

>>=是关于取一个具有上下文(一个单子值)的值并将其馈送到一个接受普通值并返回具有上下文的值的函数。如果该函数只是产生一个普通值而不是一个具有上下文的值,>>=就不会那么有用——使用一次后,上下文就会丢失。

让我们尝试将一个不确定性值馈送到一个函数:

ghci> [3,4,5] >>= \x -> [x,-x]
[3,-3,4,-4,5,-5]

当我们使用 >>=Maybe 时,单子值被传递给函数,同时处理可能的失败。这里,它为我们处理了非确定性。

[3,4,5] 是一个非确定性值,我们将其输入到一个返回非确定性值的函数中。结果是也是非确定性的,它包含了从列表 [3,4,5] 中取元素并传递给函数 \x -> [x,-x] 的所有可能结果。这个函数接受一个数字并产生两个结果:一个取反和一个保持不变。所以当我们使用 >>= 将这个列表传递给函数时,每个数字都被取反并且保持不变。Lambda 中的 x 取代了传递给它的列表中的每个值。

要了解这是如何实现的,我们可以直接查看实现过程。首先,我们从列表 [3,4,5] 开始。然后我们将 lambda 函数映射到它上面,得到以下结果:

[[3,-3],[4,-4],[5,-5]]

Lambda 函数应用于每个元素,我们得到一个列表的列表。最后,我们只需将列表展平,voilà,我们就对一个非确定性值应用了一个非确定性函数!

非确定性还包括对失败的支持。空列表 [] 几乎等同于 Nothing,因为它表示没有结果。这就是为什么失败被定义为空列表。错误信息被丢弃。让我们来玩一些会失败的列表:

ghci> [] >>= \x -> ["bad","mad","rad"]
[]
ghci> [1,2,3] >>= \x -> []
[]

在第一行,一个空列表被传递给 lambda。因为列表没有元素,所以没有元素可以传递给函数,所以结果是空列表。这类似于将 Nothing 传递给函数。在第二行,每个元素都传递给函数,但元素被忽略,函数只返回一个空列表。因为函数对进入它的每个元素都失败了,所以结果是失败。

正如与 Maybe 值一样,我们可以使用 >>= 连接多个列表,传播非确定性:

ghci> [1,2] >>= \n -> ['a','b'] >>= \ch -> return (n, ch)
[(1,'a'),(1,'b'),(2,'a'),(2,'b')]

无标题图片

列表 [1,2] 中的数字绑定到 n,列表 ['a','b'] 中的字符绑定到 ch。然后我们执行 return (n, ch)(或 [(n, ch)]),这意味着取一个 (n, ch) 对并将其放入默认的最小上下文中。在这种情况下,它创建了一个最小的列表,仍然将 (n, ch) 作为结果呈现,并且尽可能减少非确定性。它对上下文的影响是最小的。我们说的是,“对于 [1,2] 中的每个元素,遍历 ['a','b'] 中的每个元素,并生成一个来自每个列表的一个元素的元组。”

一般而言,因为 return 接收一个值并将其包裹在最小上下文中,它没有额外的效果(比如在 Maybe 中失败或在列表中产生更多非确定性),但它确实呈现了某种结果。

当非确定性值相互作用时,你可以将它们的计算视为一棵树,其中列表中的每一个可能结果代表一个单独的分支。以下是之前表达式用 do 语法重写的样子:

listOfTuples :: [(Int, Char)]
listOfTuples = do
    n <- [1,2]
    ch <- ['a','b']
    return (n, ch)

这使得 n[1,2] 中获取每一个值,ch['a','b'] 中获取每一个值这一点更加明显。就像 Maybe 一样,我们正在从单子值中提取元素,并将它们当作普通值处理,而 >>= 则为我们处理上下文。在这个情况下,上下文是非确定性。

do 语法和列表推导

使用 do 语法与列表可能会让你想起之前看到过的东西。例如,检查以下代码片段:

ghci> [ (n, ch) | n <- [1,2], ch <- ['a','b'] ]
[(1,'a'),(1,'b'),(2,'a'),(2,'b')]

是的,列表推导!在我们的 do 语法示例中,n 变成了 [1,2] 中的每一个结果。对于每一个这样的结果,ch 被分配了 ['a','b'] 中的一个结果,然后最后一行将 (n, ch) 放入一个默认上下文(一个单元素列表)中,以不引入任何额外的非确定性来呈现它。在这个列表推导中,发生了同样的事情,但我们不需要在最后写 return 来呈现 (n, ch) 作为结果,因为列表推导的输出部分已经为我们做了这件事。

实际上,列表推导只是使用列表作为单子的语法糖。最终,列表推导和 do 语法中的列表都转换为使用 >>= 来执行具有非确定性的计算。

MonadPlus 和 guard 函数

列表推导允许我们过滤输出。例如,我们可以过滤一个数字列表,只搜索包含数字 7 的数字:

ghci> [ x | x <- [1..50], '7' `elem` show x ]
[7,17,27,37,47]

我们将 show 应用到 x 上,将我们的数字转换为字符串,然后检查字符 '7' 是否是那个字符串的一部分。

要了解列表推导中的过滤是如何转换为列表单子的,我们需要查看 guard 函数和 MonadPlus 类型类。

MonadPlus 类型类是为那些也可以作为单子使用的单子设计的。以下是它的定义:

class Monad m => MonadPlus m where
    mzero :: m a
    mplus :: m a -> m a -> m a

mzeroMonoid 类型类的 mempty 同义,而 mplus 对应于 mappend。因为列表既是单子也是单子,所以它们可以成为这个类型类的一个实例:

instance MonadPlus [] where
    mzero = []
    mplus = (++)

对于列表,mzero 表示一个没有任何结果的非确定性计算——一个失败的计算。mplus 将两个非确定性值合并为一个。guard 函数的定义如下:

guard :: (MonadPlus m) => Bool -> m ()
guard True = return ()
guard False = mzero

guard 接收一个布尔值。如果该值为 Trueguard 接收一个 () 并将其放入一个最小的默认上下文中,该上下文仍然成功。如果布尔值为 Falseguard 会创建一个失败的单子值。下面是它的实际应用:

ghci> guard (5 > 2) :: Maybe ()
Just ()
ghci> guard (1 > 2) :: Maybe ()
Nothing
ghci> guard (5 > 2) :: [()]
[()]
ghci> guard (1 > 2) :: [()]
[]

这看起来很有趣,但它有什么用呢?在列表单子中,我们用它来过滤非确定性计算:

ghci> [1..50] >>= (\x -> guard ('7' `elem` show x) >> return x)
[7,17,27,37,47]

这里得到的结果与之前列表推导的结果相同。guard 是如何实现这一点的呢?让我们先看看 guard>> 的结合使用:

ghci> guard (5 > 2) >> return "cool" :: [String]
["cool"]
ghci> guard (1 > 2) >> return "cool" :: [String]
[]

如果guard成功,则其内部包含的结果是一个空元组。所以然后我们使用>>来忽略那个空元组,并呈现其他内容作为结果。然而,如果guard失败,那么稍后的return也会失败,因为将空列表传递给使用>>=的函数总是会导致空列表。guard基本上说,“如果这个布尔值是False,那么在这里产生一个失败。否则,创建一个具有空结果()的成功值。”所有这些只是允许计算继续。

这是上一个例子用do表示法重写的样子:

sevensOnly :: [Int]
sevensOnly = do
    x <- [1..50]
    guard ('7' `elem` show x)
    return x

如果我们忘记使用returnx作为最终结果呈现,那么结果列表将只是一个空元组的列表。这是以列表推导式的形式再次呈现:

ghci> [ x | x <- [1..50], '7' `elem` show x ]
[7,17,27,37,47]

因此,在列表推导式中进行过滤与使用guard相同。

骑士的冒险

这里有一个非常适合用非确定性方法解决的问题。假设我们有一个棋盘,上面只有一个骑士棋子。我们想知道这个骑士是否能在三步之内到达某个特定位置。我们只需用一对数字来表示骑士在棋盘上的位置。第一个数字将决定他所在的列,第二个数字将决定他所在的行。

无标题图片

让我们为骑士在棋盘上的当前位置创建一个类型同义词:

type KnightPos = (Int, Int)

现在假设骑士从(6, 2)开始。他能否在正好三步内到达(6, 1)?从当前位置下一步的最佳移动是什么?我知道——为什么不选择所有这些!我们有非确定性可用,所以不是选择一个移动,而是一次性选择所有这些。这是一个函数,它接受骑士的位置并返回所有可能的下一步移动:

moveKnight :: KnightPos -> [KnightPos]
moveKnight (c,r) = do
    (c', r') <- [(c+2,r-1),(c+2,r+1),(c-2,r-1),(c-2,r+1)
               ,(c+1,r-2),(c+1,r+2),(c-1,r-2),(c-1,r+2)
               ]
    guard (c' `elem` [1..8] && r' `elem` [1..8])
    return (c', r')

骑士可以始终水平或垂直移动一步,也可以水平或垂直移动两步,但他的移动必须是水平和垂直的结合。(c', r')取自移动列表中的每个值,然后guard确保新的移动(c', r')仍然在棋盘上。如果不是,它将产生一个空列表,这会导致失败,并且对于该位置不会执行return (c', r')

这个函数也可以不使用列表作为单子来编写。这是使用filter编写它的方法:

moveKnight :: KnightPos -> [KnightPos]
moveKnight (c, r) = filter onBoard
    [(c+2,r-1),(c+2,r+1),(c-2,r-1),(c-2,r+1)
    ,(c+1,r-2),(c+1,r+2),(c-1,r-2),(c-1,r+2)
    ]
    where onBoard (c, r) = c `elem` [1..8] && r `elem` [1..8]

这两个版本做的是同一件事,所以选择一个看起来更舒服的。让我们试试看:

ghci> moveKnight (6, 2)
[(8,1),(8,3),(4,1),(4,3),(7,4),(5,4)]
ghci> moveKnight (8, 1)
[(6,2),(7,3)]

工作得非常出色!我们取一个位置,然后一次性执行所有可能的移动,可以说是这样。

因此,现在我们有了非确定性的下一个位置,我们只需使用>>=将其传递给moveKnight。这是一个函数,它接受一个位置,并返回从该位置出发三步内可以到达的所有位置:

in3 :: KnightPos -> [KnightPos]
in3 start = do
    first <- moveKnight start
    second <- moveKnight first
    moveKnight second

如果你传入(6, 2),生成的列表会相当大。这是因为如果用三步到达某个位置有几种方式,那么这个位置会在列表中出现多次。

这是前面代码没有使用do表示法的样子。

in3 start = return start >>= moveKnight >>= moveKnight >>= moveKnight

使用>>=一次给我们提供了从起点开始的所有可能的移动。当我们第二次使用>>=时,对于每一种可能的第一步移动,都会计算出每一种可能的下一步移动,最后一步也是如此。

通过将return应用于值并将其放入默认上下文中,然后通过>>=将其传递给函数,与直接将函数应用于该值是相同的,但我们在这里这样做是为了风格。

现在,让我们创建一个函数,它接受两个位置并告诉我们是否可以在恰好三步内从一个位置移动到另一个位置:

canReachIn3 :: KnightPos -> KnightPos -> Bool
canReachIn3 start end = end `elem` in3 start

我们通过三个步骤生成所有可能的位置,然后我们看看我们正在寻找的位置是否在其中。以下是如何检查在三次移动内能否从(6, 2)移动到(6, 1)的示例:

ghci> (6, 2) `canReachIn3` (6, 1)
True

是的!那么从(6, 2)(7, 3)呢?

ghci> (6, 2) `canReachIn3` (7, 3)
False

不!作为一个练习,你可以修改这个函数,以便当你可以从一个位置到达另一个位置时,它告诉你应该采取哪种移动。在第十四章中,你会看到如何修改这个函数,以便我们也可以传递要采取的移动次数,而不是像现在这样硬编码这个数字。

单子法则

就像函子和应用函子一样,单子附带了一些所有单子实例都必须遵守的法则。仅仅因为某物被制作成Monad类型类的实例,并不意味着它实际上是一个单子。为了一个类型真正成为单子,该类型的单子法则必须成立。这些法则允许我们对类型及其行为做出合理的假设。

无标题图片

Haskell 允许任何类型都可以成为任何类型类的实例,只要类型检查通过。但是,它无法检查类型是否满足单子法则,因此如果我们正在创建Monad类型类的新的实例,我们需要合理地确信该类型的单子法则一切正常。我们可以依赖标准库中提供的类型来满足这些法则,但当我们着手创建自己的单子时,我们需要手动检查这些法则是否成立。但别担心,它们并不复杂。

左恒等性

第一个单子法则指出,如果我们取一个值,将其放入与return相关的默认上下文中,然后通过使用>>=将其传递给一个函数,那么这和仅仅取这个值并对其应用函数是相同的。用正式的说法,return x >>= ff x是同一件该死的事情。

如果你将单子值视为具有上下文的值,将return视为将值放入一个默认的最小上下文中,该上下文仍然将值作为函数的结果呈现,那么这个法则是有意义的。如果这个上下文真的很小,将这个单子值传递给函数不应该与直接应用函数到正常值有很大不同——实际上,它确实没有不同。

对于 Maybe 单子,return 被定义为 JustMaybe 单子完全是关于可能的失败,如果我们有一个想要放入这种上下文中的值,将其视为成功的计算是有意义的,因为我们知道这个值是什么。以下是一些使用 returnMaybe 的示例:

ghci> return 3 >>= (\x -> Just (x+100000))
Just 100003
ghci> (\x -> Just (x+100000)) 3
Just 100003

对于列表单子,return 将某个值放入一个单元素列表中。列表的 >>= 实现遍历列表中的所有值并将函数应用于它们。然而,由于单元素列表中只有一个值,它就等同于将函数应用于该值:

ghci> return "WoM" >>= (\x -> [x,x,x])
["WoM","WoM","WoM"]
ghci> (\x -> [x,x,x]) "WoM"
["WoM","WoM","WoM"]

你已经了解到,对于 IO,使用 return 会创建一个没有副作用但只呈现值的 I/O 操作。因此,这个法则对 IO 也适用是有意义的。

右单位性

第二个法则指出,如果我们有一个单子值并且使用 >>= 将其馈送到 return,结果是我们的原始单子值。形式上,m >>= returnm 没有区别。

这个法则可能没有第一个法则那么明显。让我们看看为什么它应该成立。当我们通过使用 >>= 将单子值馈送到函数时,这些函数将普通值作为输入并返回单子值。return 也是一个这样的函数,如果你考虑它的类型的话。

return 将一个值放入一个最小的上下文中,同时仍然将其作为其结果呈现。这意味着,例如,对于 Maybe,它不会引入任何失败;对于列表,它不会引入任何额外的非确定性。

这里是一些单子的测试运行:

ghci> Just "move on up" >>= (\x -> return x)
Just "move on up"
ghci> [1,2,3,4] >>= (\x -> return x)
[1,2,3,4]
ghci> putStrLn "Wah!" >>= (\x -> return x)
Wah!

在这个列表示例中,>>= 的实现如下:

xs >>= f = concat (map f xs)

因此,当我们把 [1,2,3,4] 送到 return 时,首先 return 被映射到 [1,2,3, 4] 上,结果得到 [[1],[2],[3],[4]]。然后这些被连接起来,我们就有了原始列表。

左单位性和右单位性基本上是描述 return 应如何行为的法则。这是一个重要的函数,用于将普通值转换为单子值,并且如果它产生的单子值有任何超过所需的最小上下文,那就不好了。

结合性

最终的单子法则指出,当我们有一个使用 >>= 的单子函数应用链时,它们嵌套的方式不应该很重要。形式上写,执行 (m >>= f) >>= g 就像执行 m >>= (\x -> f x >>= g)

嗯嗯,现在这里发生了什么?我们有一个单子值 m 和两个单子函数 fg。当我们使用 (m >>= f) >>= g 时,我们正在将 m 馈送到 f,这会产生一个单子值。然后我们把这个单子值馈送到 g。在表达式 m >>= (\x -> f x >>= g) 中,我们取一个单子值并将其交给一个函数,该函数将 f x 的结果馈送到 g。这两个如何相等并不容易看出,所以让我们看看一个使这种等式更清晰的例子。

记得我们有一次看到我们的走钢丝表演者皮埃尔在平衡杆上走钢丝,而鸟儿在他的平衡杆上着陆吗?为了模拟鸟儿在他的平衡杆上着陆,我们制作了一系列可能产生失败的功能链:

ghci> return (0, 0) >>= landRight 2 >>= landLeft 2 >>= landRight 2
Just (2,4)

我们从Just (0, 0)开始,然后将该值绑定到下一个单子函数landRight 2。这个结果又是一个单子值,然后被绑定到下一个单子函数,以此类推。如果我们显式地添加括号,我们会写成以下这样:

ghci> ((return (0, 0) >>= landRight 2) >>= landLeft 2) >>= landRight 2
Just (2,4)

但我们也可以这样写程序:

return (0, 0) >>= (\x ->
landRight 2 x >>= (\y ->
landLeft 2 y >>= (\z ->
landRight 2 z)))

return (0, 0)Just (0, 0)相同,当我们将其喂给 lambda 函数时,x变为(0, 0)landRight接受一些鸟和一根杆(一个数字元组),这就是它接收到的。这导致了一个Just (0, 2),然后我们将其喂给下一个 lambda 函数,此时y(0, 2)。这个过程一直持续到最后的鸟着陆,产生一个Just (2, 4),这正是整个表达式的结果。

所以,你如何嵌套向单子函数传递值并不重要。重要的是它们的含义。让我们考虑另一种看待这条法则的方式。假设我们组合两个名为fg的函数:

(.) :: (b -> c) -> (a -> b) -> (a -> c)
f . g = (\x -> f (g x))

如果g的类型是a -> b,而f的类型是b -> c,我们将它们排列成一个新函数,该函数的类型是a -> c,这样它的参数就可以在那些函数之间传递。现在,如果这两个函数都是单子呢?如果它们返回的值是单子值呢?如果我们有一个类型为a -> m b的函数,我们不能直接将其结果传递给一个类型为b -> m c的函数,因为这个函数接受一个普通的b,而不是单子。然而,我们可以使用>>=来实现这一点。

(<=<) :: (Monad m) => (b -> m c) -> (a -> m b) -> (a -> m c)
f <=< g = (\x -> g x >>= f)

现在我们可以组合两个单子函数:

ghci> let f x = [x,-x]
ghci> let g x = [x*3,x*2]
ghci> let h = f <=< g
ghci> h 3
[9,-9,6,-6]

好吧,这很酷。但这与结合律有什么关系呢?嗯,当我们把这条法则看作是组合的法则时,它表明f <=< (g <=< h)应该与(f <=< g) <=< h相同。这只是另一种说法,即对于单子来说,操作的嵌套不应该很重要。

如果我们将前两条法则翻译成使用<=<,那么左单位法则表明对于每个单子函数ff <=< return与简单地写f相同。右单位法则说return <=< f也与f没有区别。这与如果f是一个普通函数,(f . g) . hf . (g . h)相同,f . id始终与f相同,以及id . f也只是f类似。

在本章中,我们探讨了单子的基础知识,并学习了Maybe单子和列表单子是如何工作的。在下一章,我们将探索许多其他酷炫的单子,我们还将自己创建一些。

第十四章. 更多单子

您已经看到单子可以用来取具有上下文的值并将它们应用于函数,以及使用>>=do记法如何让您专注于值本身,而 Haskell 为您处理上下文。

您已经遇到了Maybe单子,并看到了它是如何为值添加可能的失败上下文的。您已经了解了列表单子,并看到了它是如何让我们轻松地将非确定性引入我们的程序的。您还学习了如何在IO单子中工作,即使您在知道什么是单子之前就已经这样做了!

在本章中,我们将介绍几个其他单子。您将看到它们如何通过让您将各种值视为单子值来使您的程序更清晰。对单子的进一步探索也将巩固您识别和操作单子的直觉。

无标题图片

我们将要探索的单子都是mtl包的一部分。(Haskell 的是一组模块。)mtl包包含在 Haskell 平台中,所以您可能已经拥有了它。要检查您是否拥有它,请在命令行中输入ghc-pkg list。这将显示您安装了哪些 Haskell 包,其中之一应该是mtl,后面跟着一个版本号。

写作者?我几乎不认识她!

我们已经用Maybe单子、列表单子和IO单子武装了我们的枪。现在让我们把Writer单子装进弹仓,看看我们开火时会发生什么!

Maybe单子是为具有附加失败上下文的值,列表单子是为非确定性值,Writer单子是为附加了另一个值(充当某种日志值)的值。Writer允许我们在确保所有日志值都组合成一个日志值的同时进行计算,然后这个日志值附加到结果上。

例如,我们可能希望给我们的值添加一些解释当前情况的字符串,这可能是为了调试目的。考虑一个函数,它接受一伙强盗的数量,并告诉我们这是否是一个大团伙。这是一个非常简单的函数:

isBigGang :: Int -> Bool
isBigGang x = x > 9

现在,如果我们想让函数不仅返回一个TrueFalse值,还想返回一个日志字符串来说明它做了什么?嗯,我们只需创建那个字符串,并和我们的Bool一起返回:

isBigGang :: Int -> (Bool, String)
isBigGang x = (x > 9, "Compared gang size to 9.")

因此,现在,我们不再只是返回一个Bool,而是返回一个元组,其中元组的第一个组件是实际值,第二个组件是伴随该值的字符串。现在我们的值有了更多的上下文。让我们试试看:

ghci> isBigGang 3
(False,"Compared gang size to 9.")
ghci> isBigGang 30
(True,"Compared gang size to 9.")

到目前为止,一切顺利。isBigGang 接受一个正常值并返回一个带有上下文的值。正如你刚才看到的,给它一个正常值并没有问题。现在假设我们已经有了一个附加了日志字符串的值,例如 (3, "Smallish gang."),我们想要将其传递给 isBigGang?看起来我们又一次面临了这个问题:如果我们有一个接受正常值并返回带有上下文值的函数,我们如何将带有上下文的值传递给这个函数?

在上一章探索 Maybe 单子时,我们创建了一个函数 applyMaybe。这个函数接受一个 Maybe a 值和一个类型为 a -> Maybe b 的函数。我们将那个 Maybe a 值传递给函数,尽管该函数接受一个正常的 a 而不是 Maybe a。它是通过注意 Maybe a 值的上下文来做到这一点的,即它们是可能失败的值。但在 a -> Maybe b 函数内部,我们可以将那个值视为一个普通值,因为 applyMaybe(稍后变为 >>=)负责检查它是否是 NothingJust 值。

无标题图片

同样地,让我们创建一个函数,它接受一个附加了日志的值——即一个 (a, String) 值——和一个类型为 a -> (b, String) 的函数,并将该值传递给该函数。我们将它称为 applyLog。但是 (a, String) 值并不携带可能的失败上下文,而是携带额外的日志值上下文。因此,applyLog 将确保原始值的日志不会丢失,而是与函数结果值的日志一起连接。下面是 applyLog 的实现:

applyLog :: (a, String) -> (a -> (b, String)) -> (b, String)
applyLog (x, log) f = let (y, newLog) = f x in (y, log ++ newLog)

当我们有一个想要传递给函数的带有上下文的值时,我们通常会尝试将实际值与上下文分开,将函数应用于值,然后查看上下文是否得到处理。在 Maybe 单子中,我们检查值是否是 Just x,如果是,我们就取那个 x 并将其应用于函数。在这种情况下,找到实际值非常容易,因为我们处理的是一个包含一个值和一个日志的配对。所以,我们首先只取值,即 x,并将函数 f 应用于它。我们得到一个 (y, newLog) 的配对,其中 y 是新的结果,newLog 是新的日志。但如果我们以这种方式返回结果,旧的日志值就不会包含在结果中,所以我们返回一个 (y, log ++ newLog) 的配对。我们使用 ++ 将新的日志追加到旧的日志上。

下面是 applyLog 的实际应用:

ghci> (3, "Smallish gang.") `applyLog` isBigGang
(False,"Smallish gang.Compared gang size to 9.")
ghci> (30, "A freaking platoon.") `applyLog` isBigGang
(True,"A freaking platoon.Compared gang size to 9.")

结果与之前相似,但现在团伙的人数有了其伴随的日志,该日志包含在结果日志中。

下面是使用 applyLog 的几个更多示例:

ghci> ("Tobin", "Got outlaw name.") `applyLog` (\x -> (length x, "Applied length."))
(5,"Got outlaw name.Applied length.")
ghci> ("Bathcat", "Got outlaw name.") `applyLog` (\x -> (length x, "Applied length."))
(7,"Got outlaw name.Applied length.")

看看在 lambda 表达式中,x 只是一个普通字符串而不是一个元组,以及 applyLog 如何处理日志的追加?

单子拯救

目前,applyLog接受类型为(a, String)的值,但日志必须是String类型有原因吗?它使用++来追加日志,所以这难道不能适用于任何类型的列表,而不仅仅是字符列表吗?当然可以。我们可以将其类型更改为以下形式:

applyLog :: (a, [c]) -> (a -> (b, [c])) -> (b, [c])

现在日志是一个列表。列表中包含的值的类型必须与原始列表以及函数返回的列表相同。否则,我们就无法使用++将它们粘合在一起。

这对 bytestrings 也适用吗?没有理由不适用。然而,我们现在拥有的类型仅适用于列表。似乎我们需要为 bytestrings 创建一个单独的applyLog。但是等等!列表和 bytestrings 都是幺半群。因此,它们都是Monoid类型类的实例,这意味着它们实现了mappend函数。对于列表和 bytestrings,mappend是用于连接的。看看它是如何工作的:

ghci> [1,2,3] `mappend` [4,5,6]
[1,2,3,4,5,6]
ghci> B.pack [99,104,105] `mappend` B.pack [104,117,97,104,117,97]
Chunk "chi" (Chunk "huahua" Empty)

太棒了!现在我们的applyLog可以适用于任何幺半群。我们需要更改类型以及实现来反映这一点,因为我们需要将++更改为mappend

applyLog :: (Monoid m) => (a, m) -> (a -> (b, m)) -> (b, m)
applyLog (x, log) f = let (y, newLog) = f x in (y, log `mappend` newLog)

因为伴随的值现在可以是任何幺半群值,我们不再需要将元组视为值和日志;现在我们可以将其视为带有伴随幺半群值的值。例如,我们可以有一个包含项目名称和项目价格的元组作为幺半群值。我们只需使用Sum newtype来确保在操作项目时价格会被累加。这里有一个向一些牛仔食品订单添加饮料的函数:

import Data.Monoid

type Food = String
type Price = Sum Int

addDrink :: Food -> (Food, Price)
addDrink "beans" = ("milk", Sum 25)
addDrink "jerky" = ("whiskey", Sum 99)
addDrink _ = ("beer", Sum 30)

我们用字符串来表示食物,并用Int类型在Sum newtype包装器中跟踪某物的价格。作为提醒,使用Sum进行mappend操作会将包装的值相加:

ghci> Sum 3 `mappend` Sum 9
Sum {getSum = 12}

addDrink函数相当简单。如果我们吃豆子,它返回"milk"以及Sum 25,即 25 美分被包装在Sum中。如果我们吃肉干,我们喝威士忌。如果我们吃其他任何东西,我们喝啤酒。现在直接应用这个函数到一个食物上不会很有趣。但是,使用applyLog将带有自身价格的食品传递给这个函数是值得一看的:

ghci> ("beans", Sum 10) `applyLog` addDrink
("milk",Sum {getSum = 35})
ghci> ("jerky", Sum 25) `applyLog` addDrink
("whiskey",Sum {getSum = 124})
ghci> ("dogmeat", Sum 5) `applyLog` addDrink
("beer",Sum {getSum = 35})

牛奶的价格是 25 美分,但如果我们再买 25 美分的豆子,最终就要支付 35 美分。

现在很清楚附加的值不总是日志。它可以是一个任何幺半群值,并且两个这样的值如何组合取决于幺半群。当我们处理日志时,它们被连接,但现在,数字正在相加。

因为addDrink返回的值是类型为(Food, Price)的元组,所以我们可以将这个结果再次传递给addDrink,这样它就会告诉我们应该和餐点一起喝什么,以及这会花费我们多少钱。让我们试一试:

ghci> ("dogmeat", Sum 5) `applyLog` addDrink `applyLog` addDrink
("beer",Sum {getSum = 65})

在一些狗肉中加一杯饮料会导致啤酒和额外的 30 美分,所以结果是("beer", Sum 35)。如果我们使用applyLog将这个结果传递给addDrink,我们就会得到另一杯啤酒,结果是("beer", Sum 65)

Writer 类型

现在你已经看到了一个附加了 monoid 的值如何像一个 monadic 值一样行动,让我们来检查这种值的Monad实例。Control.Monad.Writer模块导出了Writer w a类型及其Monad实例以及一些处理此类值的有用函数。

要将 monoid 附加到值上,我们只需要将它们组合成一个元组。Writer w a类型只是这个的newtype包装器。它的定义非常简单:

newtype Writer w a = Writer { runWriter :: (a, w) }

它被包裹在一个newtype中,这样它就可以成为一个Monad的实例,并且它的类型与普通元组分开。a类型参数表示值的类型,w类型参数表示附加的 monoid 值的类型。

Control.Monad.Writer模块保留更改其内部实现Writer w a类型的方式的权利,因此它没有导出Writer值构造函数。然而,它确实导出了writer函数,它执行与Writer构造函数相同的功能。当你想要从一个元组创建一个Writer值时使用它。

因为Writer值构造函数没有导出,所以你也不能对它进行模式匹配。相反,你需要使用runWriter函数,它接受一个被Writer newtype包裹的元组并将其解包,返回一个简单的元组。

它的Monad实例定义如下:

instance (Monoid w) => Monad (Writer w) where
    return x = Writer (x, mempty)
    (Writer (x, v)) >>= f = let (Writer (y, v')) = f x in Writer (y, v `mappend` v')

首先,让我们来检查>>=。它的实现基本上与applyLog相同,只是现在我们的元组被包裹在Writer newtype中,我们需要在模式匹配时解包它。我们取值x并对其应用函数f。这给我们一个Writer w a值,我们使用let表达式来对其模式匹配。我们将y作为新的结果呈现,并使用mappend将旧的 monoid 值与新的值组合起来。我们将结果值打包成一个元组,然后使用Writer构造函数将其包裹起来,这样我们的结果就是一个Writer值,而不是一个未解包的元组。

无标题的图片

那么,关于return呢?它必须返回一个值,并将其放入一个默认的最小上下文中,仍然将这个值作为结果呈现。对于Writer值,这样的上下文会是什么样子呢?如果我们想让伴随的 monoid 值尽可能少地影响其他 monoid 值,使用mempty是有意义的。

mempty用于表示恒等 monoid 值,如""Sum 0和空字节数组。每当我们在mempty和某个其他 monoid 值之间使用mappend时,结果就是那个其他 monoid 值。所以,如果我们使用return来创建一个Writer值,然后使用>>=将这个值传递给一个函数,那么结果 monoid 值就只有函数返回的内容。

让我们多次使用return在数字3上,每次都与不同的 monoid 配对:

ghci> runWriter (return 3 :: Writer String Int)
(3,"")
ghci> runWriter (return 3 :: Writer (Sum Int) Int)
(3,Sum {getSum = 0})
ghci> runWriter (return 3 :: Writer (Product Int) Int)
(3,Product {getProduct = 1})

由于 Writer 没有提供 Show 实例,我们使用了 runWriter 来将我们的 Writer 值转换为可以显示的正常元组。对于 String,单例值是空字符串。对于 Sum,它是 0,因为如果我们把 0 加到某个东西上,那个东西就会保持不变。对于 Product,恒等值是 1

Writer 实例没有为 fail 提供实现,所以如果 do 语法中的模式匹配失败,会调用 error

使用 Writerdo 语法

现在我们有了 Monad 实例,我们可以自由地使用 do 语法来处理 Writer 值。当我们有多个 Writer 值并想对它们进行操作时,这很方便。与其他单调类似,我们可以将它们视为普通值,上下文会由我们处理。在这种情况下,所有附加的单例值都会通过 mappend 组合,并反映在最终结果中。

这里是使用 Writerdo 语法来乘以两个数字的简单示例:

import Control.Monad.Writer

logNumber :: Int -> Writer [String] Int
logNumber x = writer (x, ["Got number: " ++ show x])

multWithLog :: Writer [String] Int multWithLog = do
    a <- logNumber 3
    b <- logNumber 5
    return (a*b)

logNumber 接收一个数字并将其转换为一个 Writer 值。注意我们是如何使用 writer 函数来构建一个 Writer 值,而不是直接使用 Writer 构造函数。对于单例,我们使用一个字符串列表,并给数字配上一个单例列表,表示我们拥有那个数字。multWithLog 是一个 Writer 值,它将 35 相乘并确保它们附加的日志包含在最终的日志中。我们使用 return 来展示 a*b 作为结果。因为 return 只是将某物放入一个最小化的上下文中,我们可以确信它不会向日志中添加任何内容。

如果我们运行这段代码,我们会看到以下内容:

ghci> runWriter multWithLog
(15,["Got number: 3","Got number: 5"])

有时候,我们只想在某个特定的点包含某个单例值。为此,tell 函数很有用。它是 MonadWriter 类型类的一部分。在 Writer 的情况下,它接收一个单例值,如 ["This is going on"],并创建一个 Writer 值,该值将虚拟值 () 作为其结果,但附加了所需的单例值。当我们有一个以 () 作为其结果的单调值时,我们不会将其绑定到变量上。

这里是包含了一些额外报告的 multWithLog

multWithLog :: Writer [String] Int
multWithLog = do
    a <- logNumber 3
    b <- logNumber 5
    tell ["Gonna multiply these two"]
    return (a*b)

重要的是 return (a*b) 是最后一行,因为在 do 表达式的最后一行中的结果是整个 do 表达式的结果。如果我们把 tell 放在最后一行,这个 do 表达式的结果将是 ()。我们会丢失乘法的结果。然而,日志会保持不变。以下是实际操作:

ghci> runWriter multWithLog
(15,["Got number: 3","Got number: 5","Gonna multiply these two"])

将日志添加到程序中

欧几里得算法接收两个数字并计算它们的最大公约数——即仍然可以同时整除这两个数字的最大数。Haskell 已经有了一个 gcd 函数,它正是做这件事,但让我们实现我们自己的函数,并给它添加日志功能。以下是正常的算法:

gcd' :: Int -> Int -> Int gcd' a b
    | b == 0    = a
    | otherwise = gcd' b (a `mod` b)

算法非常简单。首先,它检查第二个数字是否为 0。如果是,那么结果是第一个数字。如果不是,那么结果是第二个数字和第一个数字除以第二个数字的余数的最大公约数。

例如,如果我们想知道 8 和 3 的最大公约数,我们只需遵循这个算法。因为 3 不为 0,我们需要找到 3 和 2 的最大公约数(如果我们用 3 除以 8,余数是 2)。接下来,我们找到 3 和 2 的最大公约数。2 仍然不为 0,所以我们现在有 2 和 1。第二个数字不为 0,所以我们再次为 1 和 0 运行算法,因为用 2 除以 1 的余数是 0。最后,因为第二个数字现在是 0,最终结果是 1。让我们看看我们的代码是否一致:

ghci> gcd' 8 3
1

它确实如此!非常好!现在,我们想要给我们的结果添加一个上下文,这个上下文将是一个作为日志的幺半群值。和之前一样,我们将使用字符串列表作为我们的幺半群。因此,这应该是我们新的 gcd' 函数的类型:

gcd' :: Int -> Int -> Writer [String] Int

现在剩下的只是给我们的函数添加日志值。以下是代码:

import Control.Monad.Writer

gcd' :: Int -> Int -> Writer [String] Int
gcd' a b
    | b == 0 = do
        tell ["Finished with " ++ show a]
        return a
    | otherwise = do
        tell [show a ++ " mod " ++ show b ++ " = " ++ show (a `mod` b)]
        gcd' b (a `mod` b)

这个函数接受两个普通的 Int 值,并返回一个 Writer [String] Int——即一个具有日志上下文的 Int。在 b0 的情况下,我们不是只给出 a 作为结果,而是使用 do 表达式来组合一个 Writer 值作为结果。首先,我们使用 tell 来报告我们已经完成,然后我们使用 return 来将 a 作为 do 表达式的结果呈现。我们也可以这样写:

writer (a, ["Finished with " ++ show a])

然而,我认为 do 表达式更容易阅读。

接下来,我们有 b 不为 0 的情况。在这种情况下,我们记录我们正在使用 mod 来找出 ab 的余数。然后 do 表达式的第二行只是递归调用 gcd'。记住,gcd' 现在最终返回一个 Writer 值,所以 gcd' b (a mod b)do 表达式中的一行是完全可以接受的。

让我们尝试我们的新 gcd'。它的结果是 Writer [String] Int 值,如果我们从它的 newtype 中解包,我们得到一个元组。元组的第一个部分是结果。让我们看看它是否正确:

ghci> fst $ runWriter (gcd' 8 3)
1

好的!那么关于日志呢?因为日志是字符串列表,让我们使用 mapM_ putStrLn 来在屏幕上打印这些字符串:

ghci> mapM_ putStrLn $ snd $ runWriter (gcd' 8 3)
8 mod 3 = 2
3 mod 2 = 1
2 mod 1 = 0
Finished with 1

我认为我们能够将我们的普通算法改变为一种在执行过程中报告其操作的算法,这真是太棒了。我们只是通过将普通值改为幺半群值就做到了这一点。我们让 Writer>>= 实现来为我们处理日志。

你几乎可以给任何函数添加日志机制。你只需在需要的地方将普通值替换为 Writer 值,并将普通函数应用改为 >>=(或者如果它增加了可读性,可以使用 do 表达式)。

低效的列表构建

当使用 Writer 模态时,你需要小心选择哪个单例,因为使用列表有时可能会非常慢。列表使用 ++ 作为 mappend,如果列表很长,使用 ++ 将东西添加到列表的末尾会很慢。

在我们的 gcd' 函数中,日志记录很快,因为列表连接最终看起来像这样:

a ++ (b ++ (c ++ (d ++ (e ++ f))))

列表是一种从左到右构建的数据结构。这是高效的,因为我们首先完全构建列表的左半部分,然后才在右边添加一个更长的列表。但如果我们不小心,使用 Writer 模态可能会产生如下所示的列表连接:

无标题图片

((((a ++ b) ++ c) ++ d) ++ e) ++ f

它与左关联而不是右关联。它效率低下,因为每次它想要将右侧部分添加到左侧部分时,都必须从头开始构建左侧部分!

以下函数类似于 gcd',但它以相反的顺序记录信息。首先,它为剩余的步骤生成日志,然后它将当前步骤添加到日志的末尾。

import Control.Monad.Writer

gcdReverse :: Int -> Int -> Writer [String]
Int gcdReverse a b
    | b == 0 = do
        tell ["Finished with " ++ show a]
        return a
    | otherwise = do
        result <- gcdReverse b (a `mod` b)
        tell [show a ++ " mod " ++ show b ++ " = " ++ show (a `mod` b)]
        return result

它首先进行递归,并将其结果值绑定到 result。然后它将当前步骤添加到日志中,但当前步骤位于递归生成的日志末尾。最后,它将递归的结果作为最终结果呈现。下面是它的实际操作:

ghci> mapM_ putStrLn $ snd $ runWriter (gcdReverse 8 3)
Finished with 1
2 mod 1 = 0
3 mod 2 = 1
8 mod 3 = 2

这个函数效率低下,因为它最终将 ++ 的使用关联到左侧而不是右侧。

由于列表有时在重复以这种方式连接时可能效率低下,因此最好使用始终支持高效连接的数据结构。其中一种数据结构是差分列表。

使用差分列表

虽然与普通列表相似,但 差分列表 实际上是一个函数,它接受一个列表并将其另一个列表前置。例如,类似于 [1,2,3] 的差分列表等价函数是 \xs -> [1,2,3] ++ xs。一个普通的空列表是 [],而一个空的差分列表是函数 \xs -> [] ++ xs

差分列表支持高效连接。当我们使用 ++ 连接两个普通列表时,代码必须走到 ++ 左侧列表的末尾,然后将另一个列表粘贴在那里。但如果我们采用差分列表方法并将我们的列表表示为函数呢?

可以这样连接两个差分列表:

f `append` g = \xs -> f (g xs)

记住,fg 是接受列表并将某些内容前置到列表中的函数。例如,如果 f 是函数 ("dog"++)(这只是另一种写法 \xs -> "dog" ++ xs),而 g 是函数 ("meat"++),那么 f append g 将创建一个新函数,它等价于以下内容:

\xs -> "dog" ++ ("meat" ++ xs)

我们只是通过创建一个新的函数来连接两个差分列表,该函数首先将一个差分列表应用于某个列表,然后应用于另一个列表。

让我们为差分列表创建一个 newtype 包装器,这样我们就可以轻松地给它们提供单例实例:

newtype DiffList a = DiffList { getDiffList :: [a] -> [a] }

我们包装的类型是 [a] -> [a],因为差分列表只是一个接受列表并返回另一个列表的函数。将普通列表转换为差分列表以及相反操作都很简单:

toDiffList :: [a] -> DiffList a
toDiffList xs = DiffList (xs++)

fromDiffList :: DiffList a -> [a]
fromDiffList (DiffList f) = f []

要将普通列表转换为差分列表,我们只需做我们之前做过的事情,将其变成一个将某个东西添加到另一个列表的函数。因为差分列表是一个将某个东西添加到另一个列表的函数,如果我们只想得到那个东西,我们只需将函数应用于一个空列表!

这里是 Monoid 实例:

instance Monoid (DiffList a) where
    mempty = DiffList (\xs -> [] ++ xs)
    (DiffList f) `mappend` (DiffList g) = DiffList (\xs -> f (g xs))

注意对于列表,mempty 只是 id 函数,而 mappend 实际上只是函数组合。让我们看看这行不行:

ghci> fromDiffList (toDiffList [1,2,3,4] `mappend` toDiffList [1,2,3])
[1,2,3,4,1,2,3]

极佳!现在我们可以通过使其使用差分列表而不是普通列表来提高我们的 gcdReverse 函数的效率:

import Control.Monad.Writer

gcd' :: Int -> Int -> Writer (DiffList String) Int gcd' a b
    | b == 0 = do
        tell (toDiffList ["Finished with " ++ show a])
        return a
    | otherwise = do
        result <- gcd' b (a `mod` b)
        tell (toDiffList [show a ++ " mod " ++ show b ++ " = " ++ show (a `mod` b)])
        return result

我们只需要将单例的类型从 [String] 更改为 DiffList String,然后在使用 tell 时,使用 toDiffList 将我们的普通列表转换为差分列表。让我们看看日志是否被正确组装:

ghci> mapM_ putStrLn . fromDiffList . snd . runWriter $ gcdReverse 110 34
Finished with 2
8 mod 2 = 0
34 mod 8 = 2
110 mod 34 = 8

我们执行 gcdReverse 110 34,然后使用 runWriternewtype 中解包它,然后应用 snd 来获取日志,然后应用 fromDiffList 将其转换为普通列表,最后将其条目打印到屏幕上。

性能比较

为了了解差分列表可能如何提高你的性能,考虑以下函数。它只是从某个数字倒数到零,但产生的日志是反向的,就像 gcdReverse 一样,因此日志中的数字实际上是被倒数的。

finalCountDown :: Int -> Writer (DiffList String) ()
finalCountDown 0 = do
    tell (toDiffList ["0"])
finalCountDown x = do
    finalCountDown (x-1)
    tell (toDiffList [show x])

如果我们给它 0,它就只是记录这个值。对于任何其他数字,它首先将其前一个数字减到 0,然后将该数字追加到日志中。所以,如果我们将 finalCountDown 应用到 100,字符串 "100" 将是日志中最后一个出现的数字。

如果你将这个函数加载到 GHCi 中并应用于一个大数字,比如 500000,你会发现它很快就开始从 0 开始计数:

ghci> mapM_ putStrLn . fromDiffList . snd . runWriter $ finalCountDown 500000
0
1
2
...

然而,如果你将其更改为使用普通列表而不是差分列表,就像这样:

finalCountDown :: Int -> Writer [String] ()
finalCountDown 0 = do
    tell ["0"]
finalCountDown x = do
    finalCountDown (x-1)
    tell [show x]

然后告诉 GHCi 开始计数:

ghci> mapM_ putStrLn . snd . runWriter $ finalCountDown 500000

你会发现计数真的非常慢。

当然,这不是测试程序速度的正确和科学的方法。然而,我们能够看到,在这种情况下,使用差分列表可以立即开始产生结果,而普通列表则需要很长时间。

哦,顺便说一下,欧洲乐队的歌曲“Final Countdown”现在在你的脑海中回荡。享受吧!

读者?哦,不是这个笑话又来了

在 第十一章 中,你看到函数类型 (->) rFunctor 的一个实例。将函数 f 映射到函数 g 将会创建一个函数,它接受与 g 相同的东西,将其应用于 g,然后将 f 应用于那个结果。所以基本上,我们创建了一个新的函数,它类似于 g,但在返回结果之前,f 也会应用于那个结果。以下是一个例子:

无标题图片

ghci> let f = (*5)
ghci> let g = (+3)
ghci> (fmap f g) 8
55

您还看到函数是应用函子。它们允许我们像已经拥有函数的结果一样操作函数的最终结果。以下是一个例子:

ghci> let f = (+) <$> (*2) <*> (+10)
ghci> f 3
19

表达式 (+) <$> (*2) <*> (+10) 创建了一个函数,它接受一个数字,将该数字传递给 (*2)(+10),然后将结果相加。例如,如果我们将这个函数应用到 3 上,它将 (*2)(+10) 都应用到 3 上,得到 613。然后它用 613 调用 (+),结果是 19

函数作为 Monad

不仅函数类型 (->) r 是一个函子和应用函子,它还是一个 Monad。就像您迄今为止遇到的其他 monadic 值一样,一个函数也可以被视为一个带有上下文的价值。函数的上下文是那个值尚未存在,我们需要将那个函数应用到某个东西上以获得其结果。

因为您已经熟悉了函数作为函子以及应用函子的运作方式,让我们直接深入探讨一下它们的 Monad 实例是什么样的。它位于 Control.Monad.Instances 中,大致如下:

instance Monad ((->) r) where
    return x = \_ -> x
    h >>= f = \w -> f (h w) w

您已经看到了函数的 pure 实现方式,return 几乎与 pure 相同。它接受一个值并将其放入一个最小上下文中,该上下文的结果总是那个值。要创建一个总是有特定值作为其结果的函数的唯一方法就是让它完全忽略其参数。

>>= 的实现可能看起来有点晦涩,但实际上并不复杂。当我们使用 >>= 将 monadic 值传递给一个函数时,结果始终是一个 monadic 值。所以,在这种情况下,当我们将一个函数传递给另一个函数时,结果也是一个函数。这就是为什么结果从 lambda 表达式开始。

到目前为止,所有 >>= 的实现都某种程度地将结果与 monadic 值隔离开来,然后应用函数 f 到那个结果上。这里也是同样的情况。要从函数中获取结果,我们需要将其应用到某个东西上,这就是为什么我们在这里使用 (h w),然后应用 f 到它。f 返回一个 monadic 值,在我们的例子中是一个函数,所以我们也将它应用到 w 上。

读取者(Reader)Monad

如果您现在还不明白 >>= 的工作原理,不要担心。在几个例子之后,您会发现这实际上是一个非常简单的 Monad。这里有一个利用它的 do 表达式:

import Control.Monad.Instances

addStuff :: Int -> Int
addStuff = do
    a <- (*2)
    b <- (+10)
    return (a+b)

这与我们在之前写的应用表达式是同一回事,但现在它依赖于函数是单子。do表达式总是产生一个单子值,而这个例子也不例外。这个单子值的输出是一个函数。它接受一个数字,然后对该数字应用(*2),结果变为a。对(*2)应用到的相同数字应用(+10),结果变为b。在其它单子中,return没有实际效果,只是创建一个表示某些结果的单子值。这表示a+b是这个函数的结果。如果我们测试它,我们会得到之前相同的结果:

ghci> addStuff 3
19

在这种情况下,(*2)(+10)都应用于数字3return (a+b)也是如此,但它忽略了那个值,总是将a+b作为结果呈现。因此,函数单子也被称为读者单子。所有函数都从同一个源读取。为了使这一点更加清晰,我们可以将addStuff重写如下:

addStuff :: Int -> Int
addStuff x = let
    a = (*2) x
    b = (+10) x
    in a+b

你可以看到,读者单子允许我们将函数作为具有上下文的价值来处理。我们可以假装我们已经知道函数将返回什么。它是通过将函数粘合成一个函数,并将该函数的参数传递给所有组成它的函数来做到这一点的。因此,如果我们有很多函数,它们都缺少一个参数,并且最终将应用于同一事物,我们可以使用读者单子来提取它们的未来结果,而>>=实现将确保一切顺利。

精美的有状态计算

Haskell 是一种纯语言,正因为如此,我们的程序由不能改变任何全局状态或变量的函数组成;它们只能进行一些计算并返回结果。这种限制实际上使我们的程序更容易思考,因为它使我们免于担心某个时间点每个变量的值是什么。

无标题图片

然而,一些问题本质上是状态化的,因为它们依赖于随时间变化的状态。虽然这对 Haskell 来说不是问题,但这些计算建模起来可能有点繁琐。这就是为什么 Haskell 有State单子,它使得处理状态化问题变得轻而易举,同时仍然保持一切都很纯净。

当我们在第九章中查看随机数时,我们处理了接受随机生成器作为参数并返回随机数和新随机生成器的函数。如果我们想生成多个随机数,我们总是需要使用前一个函数返回的随机生成器及其结果。例如,要创建一个接受StdGen并基于该生成器抛掷三次硬币的函数,我们这样做:

threeCoins :: StdGen -> (Bool, Bool, Bool)
threeCoins gen =
    let (firstCoin, newGen) = random gen
        (secondCoin, newGen') = random newGen
        (thirdCoin, newGen'') = random newGen'
    in  (firstCoin, secondCoin, thirdCoin)

此函数接受一个生成器 gen,然后 random gen 返回一个 Bool 值以及一个新的生成器。为了抛出第二个硬币,我们使用新的生成器,依此类推。

在大多数其他语言中,我们不需要返回一个与随机数一起的新生成器。我们只需修改现有的一个!但是,由于 Haskell 是纯的,我们无法这样做,因此我们需要获取一些状态,从中生成一个结果以及一个新的状态,然后使用那个新的状态来生成新的结果。

你可能会认为为了避免以这种方式手动处理有状态的计算,我们需要放弃 Haskell 的纯度。但是,我们不必这样做,因为有一个特殊的小 monad 叫做 State monad,它可以为我们处理所有这些状态事务,而不会影响 Haskell 编程中使其如此酷的任何纯度。

有状态的计算

为了帮助演示有状态的计算,让我们先给它们一个类型。我们将说一个有状态的计算是一个函数,它接受一些状态并返回一个值以及一些新的状态。该函数具有以下类型:

s -> (a, s)

s 是状态类型,a 是有状态计算的结果。

注意

在大多数其他语言中,赋值可以被认为是有状态的计算。例如,当我们在一个命令式语言中执行 x = 5 时,它通常会将值 5 赋给变量 x,并且它也将 5 作为表达式具有该值。如果你从函数的角度来看,它就像一个函数,它接受一个状态(即之前已分配的所有变量)并返回一个结果(在这种情况下,5),以及一个新的状态,这将包括所有之前的变量映射以及新分配的变量。

这个有状态的计算——一个接受状态并返回结果和新状态的函数——可以被认为是一个具有上下文的价值。实际的价值是结果,而上下文是我们必须提供一些初始状态才能实际得到那个结果,并且除了得到一个结果之外,我们还得到一个新的状态。

栈和石子

假设我们想要模拟一个栈。一个是一个包含一些元素的数据结构,并且支持恰好两个操作:

  • 压入 元素到栈中,这将在栈顶添加一个元素

  • 弹出 栈中的元素,这将从栈中移除最顶部的元素

我们将使用一个列表来表示我们的栈,列表的头部作为栈顶。为了帮助我们完成任务,我们将创建两个函数:

  • pop 将接受一个栈,弹出其中一个项目,并将该项目作为结果返回。它还将返回一个新的栈,其中不包含被弹出的项目。

  • push 将接受一个项目和栈,然后将该项目推入栈中。它将返回 () 作为其结果,以及一个新的栈。

这里是正在使用的函数:

type Stack = [Int]

pop :: Stack -> (Int, Stack)
pop (x:xs) = (x, xs)

push :: Int -> Stack -> ((), Stack)
push a xs = ((), a:xs)

我们在向栈中推入时使用 () 作为结果,因为向栈中推入一个项目没有重要的结果值——它的主要任务是改变栈。如果我们只应用 push 的第一个参数,我们得到一个有状态的计算。pop 已经是一个有状态的计算,因为其类型。

让我们写一小段代码来模拟使用这些函数的栈。我们将取一个栈,向其中推入 3,然后弹出两个元素,仅此而已。如下所示:

stackManip :: Stack -> (Int, Stack)
stackManip stack = let
    ((), newStack1) = push 3 stack
    (a , newStack2) = pop newStack1
    in pop newStack2

我们取一个 stack,然后执行 push 1 stack,这会产生一个元组。元组的第一个部分是 (),第二个部分是新的栈,我们称之为 newStack1。然后我们从 newStack1 中弹出一个数字,这会产生一个数字 a(即我们推入的 3)和一个新的栈,我们称之为 newStack2。然后我们从 newStack2 中弹出一个数字,我们得到一个数字 b 和一个 newStack3。我们返回一个包含那个数字和那个栈的元组。让我们试试看:

ghci> stackManip [5,8,2,1]
(5,[8,2,1])

结果是 5,新的栈是 [8,2,1]。注意 stackManip 本身就是一个有状态的计算。我们已经取了一堆有状态的计算并将它们某种程度地粘合在一起。嗯,听起来很熟悉。

之前 stackManip 的代码有点繁琐,因为我们手动将状态提供给每个有状态的计算,然后存储它,然后再将其提供给下一个。如果我们可以像下面这样,而不是手动将栈提供给每个函数,那岂不是更酷?

stackManip = do
    push 3
    a <- pop
    pop

好吧,使用 State 算子将允许我们做到这一点。有了它,我们将能够使用这些有状态的计算,而无需手动管理状态。

状态算子

Control.Monad.State 模块提供了一个封装有状态的计算的 newtype。以下是它的定义:

newtype State s a = State { runState :: s -> (a, s) }

State s a 是一个操作类型为 s 的状态的计算,并且具有类型为 a 的结果。

Control.Monad.Writer 类似,Control.Monad.State 也不导出其值构造函数。如果你想将一个有状态的计算封装在 State newtype 中,可以使用 state 函数,它执行与 State 构造函数相同的功能。

现在你已经了解了有状态的计算是什么,以及它们甚至可以被视为具有上下文的价值,让我们来看看它们的 Monad 实例:

instance Monad (State s) where
    return x = State $ \s -> (x, s)
    (State h) >>= f = State $ \s -> let (a, newState) = h s
                                        (State g) = f a
                                    in  g newState

我们使用 return 的目的是取一个值并创建一个有状态的计算,该计算始终将该值作为其结果。这就是为什么我们只创建一个 lambda \s -> (x, s)。我们始终将 x 作为有状态的计算的结果呈现,状态保持不变,因为 return 必须将一个值放入最小上下文中。因此,return 将创建一个有状态的计算,该计算呈现某个值作为结果并保持状态不变。

无标题图片

那么>>=呢?嗯,将状态计算馈送到带有>>=的函数的结果必须是一个状态计算,对吧?所以,我们首先从State newtype包装器开始,然后输入一个 lambda。这个 lambda 将成为我们的新状态计算。但它在里面做什么呢?嗯,我们需要以某种方式从第一个状态计算中提取结果值。因为我们现在正处于状态计算中,我们可以将状态计算h的当前状态s提供给h,这将产生一个结果和新的状态的配对:(a, newState)

到目前为止,每次我们实现>>=时,一旦我们从单子值中提取了结果,我们就将函数f应用到它上面以获得新的单子值。在Writer中,在这样做并得到新的单子值之后,我们仍然需要确保通过mappend将旧的单子值与新的单子值合并来处理上下文。这里我们执行f a,我们得到一个新的状态计算g。现在我们有一个新的状态计算和一个新的状态(命名为newState),我们只需将这个状态计算g应用到newState上。结果是最终结果和最终状态的元组!

因此,使用>>=,我们似乎将两个状态计算粘合在一起。第二个计算隐藏在一个函数内部,该函数接受前一个计算的结果。因为poppush已经是状态计算,所以很容易将它们包装到State包装器中:

import Control.Monad.State

pop :: State Stack Int
pop = state $ \(x:xs) -> (x, xs)

push :: Int -> State Stack ()
push a = state $ \xs -> ((), a:xs)

注意我们是如何使用state函数将一个函数包装到State newtype中,而不是直接使用State值构造函数。

pop已经是一个状态计算,而push接受一个Int并返回一个状态计算。现在我们可以重写我们之前将3推入栈中然后弹出两个数字的例子,如下所示:

import Control.Monad.State

stackManip :: State Stack Int
stackManip = do
    push 3
    a <- pop
    pop

看看我们是如何将一个推送和两个弹出操作合并成一个状态计算的吗?当我们从它的newtype包装器中展开它时,我们得到一个函数,我们可以向它提供一个初始状态:

ghci> runState stackManip [5,8,2,1]
(5,[8,2,1])

我们不需要将第二个pop绑定到a上,因为我们根本就没有使用那个a。所以,我们可以这样写:

stackManip :: State Stack Int
stackManip = do
    push 3
    pop
    pop

非常酷。但如果我们想做一些更复杂的事情怎么办?比如说,我们想从栈中弹出数字,如果这个数字是5,我们就将其推回栈中并停止。如果不是5,我们就推回38。以下是代码:

stackStuff :: State Stack ()
stackStuff = do
    a <- pop
    if a == 5
        then push 5
        else do
            push 3
            push 8

这相当直接。让我们用一个初始栈来运行它:

ghci> runState stackStuff [9,0,2,1,0]
((),[8,3,0,2,1,0])

记住do表达式会产生单子值,并且使用State单子,单个do表达式也是一个状态函数。因为stackManipstackStuff是普通的状态计算,我们可以将它们粘合在一起以产生进一步的状态计算:

moreStack :: State Stack ()
moreStack = do
    a <- stackManip
    if a == 100
        then stackStuff
        else return ()

如果stackManip在当前栈上的结果是100,我们运行stackStuff;否则,我们什么都不做。return ()只是保持状态不变并什么都不做。

获取和设置状态

Control.Monad.State模块提供了一个名为MonadState的类型类,它有两个相当有用的函数:getput。对于Stateget函数的实现如下:

get = state $ \s -> (s, s)

它只是将当前状态呈现为结果。

put函数接受一些状态并创建一个带状态函数,用它来替换当前状态:

put newState = state $ \s -> ((), newState)

因此,有了这些,我们可以看到当前的栈是什么,或者我们可以用整个其他栈来替换它,如下所示:

stackyStack :: State Stack ()
stackyStack = do
    stackNow <- get
    if stackNow == [1,2,3]
        then put [8,3,1]
        else put [9,2,1]

我们也可以使用getput来实现poppush。以下是pop的实现:

pop :: State Stack Int
pop = do
    (x:xs) <- get
    put xs
    return x

我们使用get来获取整个栈,然后我们使用put来将除了顶部元素之外的所有内容设置为新的状态。然后我们使用return来呈现x作为结果。

这是使用getput实现的push

push :: Int -> State Stack ()
push x = do
    xs <- get
    put (x:xs)

我们只需使用get来获取当前栈,并使用put来设置新状态作为我们的栈,元素x在顶部。

值得检查如果>>=只对State值有效,它的类型会是什么:

(>>=) :: State s a -> (a -> State s b) -> State s b

看看状态s的类型保持不变,但结果类型可以从a变为b?这意味着我们可以将几个具有不同类型结果的带状态计算粘合在一起,但状态类型必须保持相同。那么这是为什么?例如,对于Maybe>>=有如下类型:

(>>=) :: Maybe a -> (a -> Maybe b) -> Maybe b

很显然,单体本身,Maybe,不会改变。在两个不同的单体之间使用>>=是没有意义的。嗯,对于State单体,单体实际上是State s,所以如果那个s不同,我们就会在两个不同的单体之间使用>>=

随机性和状态单体

在本节的开始,我们讨论了生成随机数有时可能会很尴尬。每个随机函数都接受一个生成器并返回一个随机数以及一个新的生成器,如果我们想生成另一个随机数,我们必须使用新的生成器而不是旧的生成器。State单体使得处理这个问题变得容易多了。

System.Random中的random函数具有以下类型:

random :: (RandomGen g, Random a) => g -> (a, g)

这意味着它接受一个随机生成器并生成一个随机数以及一个新的生成器。我们可以看到这是一个带状态的计算,因此我们可以通过使用state函数将其包裹在State newtype构造函数中,然后将其用作单调值,这样状态传递就由我们处理了:

import System.Random
import Control.Monad.State

randomSt :: (RandomGen g, Random a) => State g a
randomSt = state random

所以,现在如果我们想抛掷三枚硬币(True是反面,False是正面),我们只需做以下操作:

import System.Random
import Control.Monad.State

threeCoins :: State StdGen (Bool, Bool, Bool)
threeCoins = do
    a <- randomSt
    b <- randomSt
    c <- randomSt
    return (a, b, c)

threeCoins现在是一个带状态的计算,在获取初始随机生成器后,它将这个生成器传递给第一个randomSt,它产生一个数字和一个新的生成器,这个生成器被传递给下一个,依此类推。我们使用return (a, b, c)来呈现(a, b, c)作为结果,而不改变最近的生成器。让我们试一试:

ghci> runState threeCoins (mkStdGen 33)
((True,False,True),680029187 2103410263)

现在执行需要保存一些状态在步骤之间的操作变得容易多了!

墙上的错误错误

到现在为止,你应该知道 Maybe 是用来给值添加可能失败的上下文的。一个值可以是 Just somethingNothing。尽管它可能很有用,但当我们遇到 Nothing 时,我们只知道发生了某种类型的失败——没有方法可以挤进更多信息来告诉我们是哪种失败。

Either e a 类型还允许我们将可能失败上下文纳入我们的值中。它还允许我们将值附加到失败上,以便它们可以描述出错的原因或提供有关失败的其他有用信息。一个 Either e a 值可以是表示正确答案和成功的 Right 值,或者可以是一个表示失败的 Left 值。以下是一个示例:

ghci> :t Right 4
Right 4 :: (Num t) => Either a t
ghci> :t Left "out of cheese error"
Left "out of cheese error" :: Either [Char] b

这基本上就是一个增强版的 Maybe,所以它作为一个单子是有意义的。它也可以被视为一个添加了可能失败上下文的价值,但现在当出现错误时,也会附加一个值。

它的 Monad 实例与 Maybe 类似,可以在 Control.Monad.Error 中找到:

instance (Error e) => Monad (Either e) where
    return x = Right x
    Right x >>= f = f x
    Left err >>= f = Left err
    fail msg = Left (strMsg msg)

和往常一样,return 接收一个值并将其放入默认的最小上下文中。它使用 Right 构造函数包装我们的值,因为我们使用 Right 来表示存在结果的计算成功。这与 Maybereturn 非常相似。

>>= 检查两种可能的情况:LeftRight。在 Right 的情况下,函数 f 被应用于其内部值,类似于 Just 的情况,其中函数只是应用于其内容。在错误的情况下,保留 Left 值及其内容,这些内容描述了失败。

Either eMonad 实例有一个额外的要求。包含在 Left 中的值的类型——由 e 类型参数索引——必须是 Error 类型类的实例。Error 类型类是为可以像错误消息一样行动的类型设计的。它定义了 strMsg 函数,该函数接受字符串形式的错误并返回这样的值。String 类型是 Error 实例的一个很好的例子!在 String 的情况下,strMsg 函数只是返回它接收到的字符串:

ghci> :t strMsg
strMsg :: (Error a) => String -> a
ghci> strMsg "boom!" :: String
"boom!"

但由于我们通常使用 String 来描述 Either 中的错误,所以我们不必过于担心这一点。当 do 表达式中的模式匹配失败时,使用 Left 值来表示这种失败。

这里有一些使用示例:

ghci> Left "boom" >>= \x -> return (x+1)
Left "boom"
ghci> Left "boom " >>= \x -> Left "no way!"
Left "boom "
ghci> Right 100 >>= \x -> Left "no way!"
Left "no way!"

当我们使用 >>=Left 值传递给一个函数时,该函数会被忽略,并返回一个相同的 Left 值。当我们向函数传递 Right 值时,该函数会被应用于其内部的内容,但在这个情况下,该函数仍然产生了 Left 值!

当我们尝试向一个也成功的函数传递 Right 值时,我们会遇到一个奇特的类型错误。嗯嗯。

ghci> Right 3 >>= \x -> return (x + 100)

<interactive>:1:0:
    Ambiguous type variable `a' in the constraints:
      `Error a' arising from a use of `it' at <interactive>:1:0-33
      `Show a' arising from a use of `print' at <interactive>:1:0-33
    Probable fix: add a type signature that fixes these type variable(s)

Haskell 说它不知道为我们的 Either e a 类型值的 e 部分选择哪种类型,即使我们只是在打印 Right 部分。这是由于 Monad 实例上的 Error e 约束。所以,如果你在使用 Either 作为单子时遇到这种类型的类型错误,只需添加一个显式的类型签名:

ghci> Right 3 >>= \x -> return (x + 100) :: Either String Int
Right 103

现在它工作了!

除了这个小问题之外,使用错误单子与使用 Maybe 作为单子非常相似。

注意

在上一章中,我们使用了 Maybe 的单子特性来模拟走钢丝者落在平衡杆上。作为一个练习,你可以用错误单子重写它,这样当走钢丝者滑倒并跌落时,你可以记住他跌落时杆子两侧有多少只鸟。

一些有用的单子函数

在本节中,我们将探讨一些操作于单子值或返回单子值作为其结果(或两者都是!)的函数。这类函数通常被称为单子函数。虽然其中一些将是全新的,但其他一些将是你已经知道的函数的单子对应物,如 filterfoldl。在这里,我们将查看 liftMjoinfilterMfoldM

liftM 和朋友们

当我们开始攀登 Monad 山之旅时,我们首先了解了函子,它们用于可以映射的事物。然后我们介绍了改进后的函子,称为应用函子,它允许我们在几个应用值之间应用正常函数,以及将一个正常值放入某个默认上下文中。最后,我们介绍了单子作为改进的应用函子,它增加了这些具有上下文值的值能够以某种方式被喂入正常函数的能力。

无标题图片

因此,每个单子都是一个应用函子,每个应用函子都是一个函子。Applicative 类型类有一个类约束,即我们的类型必须是一个 Functor 的实例,我们才能将其作为 Applicative 的实例。Monad 应该对 Applicative 有相同的约束,因为每个单子都是一个应用函子,但它没有,因为 Monad 类型类是在 Applicative 之前被引入到 Haskell 中的。

尽管每个单子都是一个函子,但我们不需要依赖于它有一个 Functor 实例,因为有了 liftM 函数。liftM 接收一个函数和一个单子值,并将该函数映射到单子值上。所以这基本上与 fmap 相同!这是 liftM 的类型:

liftM :: (Monad m) => (a -> b) -> m a -> m b

这是 fmap 的类型:

fmap :: (Functor f) => (a -> b) -> f a -> f b

如果一个类型的 FunctorMonad 实例遵守函子和单子定律,这两者等同于同一件事(并且我们迄今为止遇到的所有的单子都遵守这两者)。这有点像 purereturn 做的是同一件事,但一个有 Applicative 类约束,而另一个有 Monad 约束。让我们尝试一下 liftM

ghci> liftM (*3) (Just 8)
Just 24
ghci> fmap (*3) (Just 8)
Just 24
ghci> runWriter $ liftM not $ Writer (True, "chickpeas")
(False,"chickpeas")
ghci> runWriter $ fmap not $ Writer (True, "chickpeas")
(False,"chickpeas")
ghci> runState (liftM (+100) pop) [1,2,3,4]
(101,[2,3,4])
ghci> runState (fmap (+100) pop) [1,2,3,4]
(101,[2,3,4])

你已经非常熟悉 fmap 如何与 Maybe 值一起工作了。liftM 做的是同样的事情。对于 Writer 值,函数映射到元组的第一个组件,即结果。在运行 fmapliftM 过一个有状态的计算后,会得到另一个有状态的计算,但其最终结果会被提供的函数修改。如果我们没有在运行 pop 之前映射 (+100) 到它,它将返回 (1, [2,3,4])

这就是 liftM 的实现方式:

liftM :: (Monad m) => (a -> b) -> m a -> m b
liftM f m = m >>= (\x -> return (f x))

或者使用 do 语法:

liftM :: (Monad m) => (a -> b) -> m a -> m b
liftM f m = do
    x <- m
    return (f x)

我们将单子值 m 传入函数,然后在我们将其放回默认上下文之前,将函数 f 应用到其结果上。由于单子定律,这保证了上下文不会改变;它只改变单子值呈现的结果。

你可以看到 liftM 是在不引用 Functor 类型类的情况下实现的。这意味着我们可以只通过使用单子为我们提供的便利来实现 fmap(或者 liftM——你想叫它什么都可以)。正因为如此,我们可以得出结论,单子至少和函子一样强大。

Applicative 类型类允许我们像处理普通值一样,在具有上下文的环境中应用函数,如下所示:

ghci> (+) <$> Just 3 <*> Just 5
Just 8
ghci> (+) <$> Just 3 <*> Nothing
Nothing

使用这种应用风格使事情变得相当简单。<$> 就是 fmap,而 <*> 是来自 Applicative 类型类的一个函数,其类型如下:

(<*>) :: (Applicative f) => f (a -> b) -> f a -> f b

所以它有点像 fmap,但函数本身处于一个上下文中。我们需要以某种方式从上下文中提取它,并将其映射到 f a 值上,然后重新组装上下文。因为 Haskell 中默认所有函数都是柯里化的,我们可以使用 <$><*> 的组合来在应用值之间应用需要多个参数的函数。

无论如何,结果证明,就像 fmap 一样,<*> 也可以仅使用 Monad 类型类提供的内容来实现。ap 函数基本上是 <*>,但有一个 Monad 约束而不是 Applicative 约束。以下是它的定义:

ap :: (Monad m) => m (a -> b) -> m a -> m b
ap mf m = do
    f <- mf
    x <- m
    return (f x)

mf 是一个结果为函数的单子值。因为函数以及值都在上下文中,我们从上下文中获取函数并调用它 f,然后获取值并调用它 x,最后将函数应用于值并呈现结果。以下是一个快速演示:

ghci> Just (+3) <*> Just 4
Just 7
ghci> Just (+3) `ap` Just 4
Just 7
ghci> [(+1),(+2),(+3)] <*> [10,11]
[11,12,12,13,13,14]
ghci> [(+1),(+2),(+3)] `ap` [10,11]
[11,12,12,13,13,14]

现在我们可以看到,单子至少和应用值一样强大,因为我们可以使用 Monad 中的函数来实现 Applicative 中的函数。实际上,很多时候,当发现一个类型是单子时,人们首先编写一个 Monad 实例,然后通过只说 purereturn<*>ap 来创建一个 Applicative 实例。同样,如果你已经有了某个东西的 Monad 实例,你只需说 fmapliftM 就可以给它一个 Functor 实例。

liftA2 是一个用于在两个应用值之间应用函数的便利函数。它定义如下:

liftA2 :: (Applicative f) => (a -> b -> c) -> f a -> f b -> f c
liftA2 f x y = f <$> x <*> y

liftM2 函数做的是同样的事情,但是有一个 Monad 约束。还有 liftM3liftM4liftM5 函数。

你看到了 monads 至少和 applicatives 和 functors 一样强大,尽管所有 monads 都是 functors 和 applicative functors,它们并不一定有 FunctorApplicative 实例。我们检查了 functors 和 applicative functors 所使用的函数的 monadic 等价物。

join 函数

这里有一些值得思考的问题:如果一个 monadic 值的结果是另一个 monadic 值(一个 monadic 值嵌套在另一个中),你能将它们展平成一个单一的、正常的 monadic 值吗?例如,如果我们有 Just (Just 9),我们能将其变成 Just 9 吗?实际上,任何嵌套的 monadic 值都可以被展平,而且这实际上是 monads 独有的一个属性。为此,我们有 join 函数。它的类型是这样的:

join :: (Monad m) => m (m a) -> m a

所以,join 取一个 monadic 值中的 monadic 值,并给我们一个 monadic 值——换句话说,它展平了它。这里有一些 Maybe 值的例子:

ghci> join (Just (Just 9))
Just 9
ghci> join (Just Nothing)
Nothing ghci> join Nothing
Nothing

第一行有一个成功的计算作为成功计算的结果,所以它们都被合并成了一个大的成功计算。第二行有一个 Nothing 作为 Just 值的结果。在我们之前处理 Maybe 值时,无论我们想要将几个值组合成一个——无论是使用 <*> 还是 >>=——它们都必须是 Just 值,结果才是一个 Just 值。如果过程中有任何失败,结果就是失败,这里也是同样的情况。在第三行,我们尝试展平一开始就是失败的情况,所以结果也是失败。

展平列表相当直观:

ghci> join [[1,2,3],[4,5,6]]
[1,2,3,4,5,6]

如你所见,对于列表,join 就是 concat。为了展平一个结果本身也是 Writer 值的 Writer 值,我们需要 mappend 单例值:

ghci> runWriter $ join (Writer (Writer (1, "aaa"), "bbb"))
(1,"bbbaaa")

外部单例值 "bbb" 首先出现,然后 "aaa" 被附加到它上面。直观地说,当你想要检查 Writer 值的结果时,你需要先将它的单例值写入日志,然后才能查看它内部的内容。

展平 Either 值与展平 Maybe 值非常相似:

ghci> join (Right (Right 9)) :: Either String Int
Right 9
ghci> join (Right (Left "error")) :: Either String Int
Left "error"
ghci> join (Left "error") :: Either String Int
Left "error"

如果我们将 join 应用到一个结果也是状态计算的状态计算中,结果是先运行外部状态计算,然后是结果状态计算。看看它是如何工作的:

ghci> runState (join (state $ \s -> (push 10, 1:2:s))) [0,0,0]
((),[10,1,2,0,0,0])

这里的 lambda 函数接受一个状态,将 21 放到栈上,并以 push 10 作为其结果。所以,当整个结构通过 join 展平并运行时,它首先将 21 放到栈上,然后执行 push 10,将一个 10 推到栈顶。

join 的实现如下:

join :: (Monad m) => m (m a) -> m a
join mm = do
    m <- mm
    m

因为 mm 的结果是单调值,所以我们得到这个结果,然后单独放在一行上,因为它是一个单调值。这里的技巧在于,当我们调用 m <- mm 时,我们正在使用的单调语境得到了处理。这就是为什么,例如,Maybe 值只有在外部和内部值都是 Just 值时才会产生 Just 值。如果 mm 值预先设置为 Just (Just 8),这将是这样的:

joinedMaybes :: Maybe Int
joinedMaybes = do
    m <- Just (Just 8)
    m

关于 join 最有趣的事情可能是,对于每个单调,使用 >>= 将单调值传递给一个函数与只是映射该函数到值上,然后使用 join 来展平产生的嵌套单调值是相同的!换句话说,m >>= f 总是等于 join (fmap f m)。当你这么想的时候,这是有意义的。

无标题图片

使用 >>=,我们总是在思考如何将一个单调值传递给一个接受普通值但返回单调值的函数。如果我们只是将这个函数映射到单调值上,我们就会得到一个单调值嵌套在另一个单调值中。例如,假设我们有 Just 9 和函数 \x -> Just (x+1)。如果我们将这个函数映射到 Just 9 上,我们最终得到的是 Just (Just 10)

如果我们为某些类型创建自己的 Monad 实例,m >>= f 总是等于 join (fmap f m) 非常有用。这是因为通常更容易弄清楚如何展平嵌套的单调值,而不是弄清楚如何实现 >>=

另一件有趣的事情是,join 不能仅使用函子和应用提供的功能来实现。这让我们得出结论,不仅单调与函子和应用一样强大,而且实际上更强,因为我们可以用它们做更多的事情,比只用函子和应用更多。

filterM

filter 函数几乎是 Haskell 编程的面包(map 是黄油)。它接受一个谓词和一个要过滤的列表,然后返回一个新列表,其中只保留满足谓词的元素。它的类型如下:

filter :: (a -> Bool) -> [a] -> [a]

谓词接受列表中的一个元素并返回一个 Bool 值。现在,如果它返回的 Bool 值实际上是一个单调值怎么办?如果它带有上下文怎么办?例如,如果谓词产生的每个 TrueFalse 值都附带一个单调值,比如 ["Accepted the number 5"]["3 is too small"],会怎样?如果是这样,我们预计结果列表也会附带所有产生的日志值。所以,如果谓词返回的 Bool 值带有上下文,我们预计最终结果列表也会附带一些上下文。否则,每个 Bool 带来的上下文就会丢失。

来自 Control.MonadfilterM 函数正是我们想要的!它的类型如下:

filterM :: (Monad m) => (a -> m Bool) -> [a] -> m [a]

谓词返回一个单调值,其结果是Bool,但由于它是一个单调值,其上下文可以是可能的失败、非确定性等等!为了确保上下文反映在最终结果中,结果也是一个单调值。

让我们取一个列表,只保留小于 4 的值。首先,我们将使用常规的filter函数:

ghci> filter (\x -> x < 4) [9,1,5,2,10,3]
[1,2,3]

这很简单。现在,让我们创建一个谓词,除了提供一个TrueFalse的结果外,还提供它所做操作的日志。当然,我们将使用Writer单调子来做到这一点:

keepSmall :: Int -> Writer [String] Bool
keepSmall x
    | x < 4 = do
        tell ["Keeping " ++ show x]
        return True
    | otherwise = do
        tell [show x ++ " is too large, throwing it away"]
        return False

这个函数不仅返回一个Bool,还返回一个Writer [String] Bool。它是一个单调谓词。听起来很复杂,不是吗?如果数字小于4,我们报告我们保留它,然后return True

现在,让我们将列表传递给filterM。因为谓词返回一个Writer值,所以结果列表也将是一个Writer值。

ghci> fst $ runWriter $ filterM keepSmall [9,1,5,2,10,3]
[1,2,3]

检查结果Writer值,我们看到一切正常。现在,让我们打印日志,看看我们有什么:

ghci> mapM_ putStrLn $ snd $ runWriter $ filterM keepSmall [9,1,5,2,10,3]
9 is too large, throwing it away
Keeping 1
5 is too large, throwing it away
Keeping 2
10 is too large, throwing it away
Keeping 3

因此,仅仅通过向filterM提供一个单调谓词,我们就能在利用我们使用的单调上下文的同时过滤列表。

一个非常酷的 Haskell 技巧是使用filterM来获取列表的幂集(如果我们现在将它们视为集合的话)。某个集合的幂集是该集合所有子集的集合。所以如果我们有一个集合如[1,2,3],它的幂集包括以下集合:

[1,2,3]
[1,2]
[1,3]
[1]
[2,3]
[2]
[3]
[]

换句话说,获取幂集就像获取从集合中保留和丢弃元素的所有组合。例如,[2,3]是排除了数字1的原始集合,[1,2]是排除了3的原始集合,以此类推。

要创建一个返回某些列表幂集的函数,我们将依赖于非确定性。我们取列表[1,2,3],然后查看第一个元素,即1,然后问自己,“我们应该保留它还是丢弃它?”实际上,我们希望两者都做。所以,我们将过滤一个列表,我们将使用一个谓词,该谓词非确定性地将列表中的每个元素都保留和丢弃。这是我们的powerset函数:

powerset :: [a] -> [[a]]
powerset xs = filterM (\x -> [True, False]) xs

等等,这就完了?是的。我们选择丢弃和保留每个元素,无论这个元素是什么。我们有一个非确定性谓词,所以结果列表也将是一个非确定性值,因此它将是一个列表的列表。让我们试一试:

ghci> powerset [1,2,3]
[[1,2,3],[1,2],[1,3],[1],[2,3],[2],[3],[]]

这需要一点思考才能理解。只需考虑列表作为不知道自己要成为什么的非确定性值,所以它们决定一次性成为一切,这个概念就更容易理解。

foldM

foldl 的单子对应物是 foldM。如果你还记得第五章(ch05.html "第五章. 高阶函数”)中的折叠,你知道 foldl 接收一个二进制函数、一个起始累加器和要折叠的列表,然后使用二进制函数从左到右折叠成一个单一值。foldM 做的是同样的事情,但它接收一个产生单子值的二进制函数,并使用该函数折叠列表。不出所料,结果也是单子的。foldl 的类型如下:

foldl :: (a -> b -> a) -> a -> [b] -> a

foldM 有以下类型:

foldM :: (Monad m) => (a -> b -> m a) -> a -> [b] -> m a

二进制函数返回的值是单子的,所以整个折叠的结果也是单子的。让我们用折叠来求一个数字列表的和:

ghci> foldl (\acc x -> acc + x) 0 [2,8,3,1]
14

起始累加器是 0,然后 2 被添加到累加器中,得到一个新的累加器,其值为 2。将 8 添加到这个累加器中,得到累加器值为 10,依此类推。当我们到达末尾时,最终的累加器就是结果。

现在,如果我们想对数字列表求和,但附加条件是如果列表中的任何数字大于 9,整个操作就会失败?使用一个检查当前数字是否大于 9 的二进制函数是有意义的。如果是,函数失败;如果不是,函数继续执行。由于这种额外的失败可能性,让我们让我们的二进制函数返回一个 Maybe 累加器而不是一个普通的累加器。下面是这个二进制函数:

binSmalls :: Int -> Int -> Maybe Int
binSmalls acc x
    | x > 9     = Nothing
    | otherwise = Just (acc + x)

由于我们的二进制函数现在是一个单子函数,我们无法使用正常的 foldl;我们必须使用 foldM。下面是操作:

ghci> foldM binSmalls 0 [2,8,3,1]
Just 14
ghci> foldM binSmalls 0 [2,11,3,1]
Nothing

太棒了!因为列表中的一个数字大于 9,整个结果变成了 Nothing。使用返回 Writer 值的二进制函数进行折叠也很酷,因为这样你可以在折叠过程中记录你想要的任何内容。

制作一个安全的 RPN 计算器

当我们在第十章(ch10.html "第十章. 函数式解决问题”)中解决实现逆波兰表达式(RPN)计算器的问题时,我们注意到只要输入有意义,它就能正常工作。但如果出了问题,它会导致我们的整个程序崩溃。现在我们知道了如何使现有的代码成为单子,让我们利用 Maybe 单子来给我们的 RPN 计算器添加错误处理功能。

无标题图片

我们通过将字符串 "1 3 + 2 *" 分解成单词,得到类似 ["1","3","+","2","*"] 的内容来实现我们的 RPN 计算器。然后我们通过从空栈开始,使用一个二进制折叠函数将数字添加到栈中或操作栈顶的数字来求和和除法等操作来折叠这个列表。

这是我们函数的主体:

import Data.List

solveRPN :: String -> Double
solveRPN = head . foldl foldingFunction [] . words

我们将表达式转换成一个字符串列表,并使用我们的折叠函数进行折叠。然后,当我们只剩一个项目在栈中时,我们将该项目作为答案返回。这就是我们的折叠函数:

foldingFunction :: [Double] -> String -> [Double]
foldingFunction (x:y:ys) "*" = (y * x):ys
foldingFunction (x:y:ys) "+" = (y + x):ys
foldingFunction (x:y:ys) "-" = (y - x):ys
foldingFunction xs numberString = read numberString:xs

折叠的累加器是一个栈,我们用 Double 值的列表来表示。当折叠函数遍历 RPN 表达式时,如果当前项是一个操作符,它会从栈顶取出两个项目,在它们之间应用操作符,然后将结果放回栈上。如果当前项是一个表示数字的字符串,它会将该字符串转换成实际的数字,并返回一个新的栈,就像旧的栈一样,只是将那个数字推到顶部。

让我们首先使我们的折叠函数能够优雅地失败。它的类型将从现在改变为这个:

foldingFunction :: [Double] -> String -> Maybe [Double]

所以,它要么返回一个新的栈,要么以 Nothing 失败。

reads 函数类似于 read,但它在成功读取时返回一个包含单个元素的列表。如果读取失败,它将返回一个空列表。除了返回它读取的值之外,它还会返回它未消费的字符串部分。我们将说它必须始终消耗完整输入才能工作,并为了方便将其制作成 readMaybe 函数。下面是它:

readMaybe :: (Read a) => String -> Maybe a
readMaybe st = case reads st of [(x, "")] -> Just x
                                _ -> Nothing

现在让我们测试它:

ghci> readMaybe "1" :: Maybe Int
Just 1
ghci> readMaybe "GOTO HELL" :: Maybe Int
Nothing

好的,它似乎工作得很好。所以,让我们将我们的折叠函数变成一个可能失败的 monadic 函数:

foldingFunction :: [Double] -> String -> Maybe [Double]
foldingFunction (x:y:ys) "*" = return ((y * x):ys)
foldingFunction (x:y:ys) "+" = return ((y + x):ys)
foldingFunction (x:y:ys) "-" = return ((y - x):ys)
foldingFunction xs numberString = liftM (:xs) (readMaybe numberString)

前三个情况与旧版本类似,只是新的栈被包裹在一个 Just 中(我们在这里使用 return 来做这个,但我们可以同样写 Just)。在最后一个情况中,我们使用 readMaybe numberString,然后对它应用 (:xs) 映射。所以,如果栈 xs[1.0,2.0],并且 readMaybe numberString 结果是 Just 3.0,结果是 Just [3.0,1.0,2.0]。如果 readMaybe numberString 结果是 Nothing,结果是 Nothing

让我们通过单独尝试折叠函数来测试它:

ghci> foldingFunction [3,2] "*"
Just [6.0]
ghci> foldingFunction [3,2] "-"
Just [-1.0]
ghci> foldingFunction [] "*"
Nothing ghci> foldingFunction [] "1"
Just [1.0]
ghci> foldingFunction [] "1 wawawawa"
Nothing

看起来它正在工作!现在是我们改进后的 solveRPN 的时候了,女士们先生们!

import Data.List

solveRPN :: String -> Maybe Double
solveRPN st = do
    [result] <- foldM foldingFunction [] (words st)
    return result

就像上一个版本一样,我们将字符串转换成一个单词列表。然后我们进行折叠,从空栈开始,但不是做正常的 foldl,而是做 foldM。那个 foldM 的结果应该是一个包含列表的 Maybe 值(这是我们最终的栈),而这个列表应该只有一个值。我们使用 do 表达式来获取那个值,并将其命名为 result。如果 foldM 返回 Nothing,整个操作将是一个 Nothing,因为这就是 Maybe 的工作方式。注意,我们在 do 表达式中进行模式匹配,所以如果列表有多个值或根本没有值,模式匹配将失败,并产生一个 Nothing。在最后一行,我们只是调用 return result 来将 RPN 计算的结果作为最终 Maybe 值的结果呈现。

让我们试一试:

ghci> solveRPN "1 2 * 4 +"
Just 6.0
ghci> solveRPN "1 2 * 4 + 5 *"
Just 30.0
ghci> solveRPN "1 2 * 4"
Nothing ghci> solveRPN "1 8 wharglbllargh"
Nothing

第一次失败是因为最终的栈不是一个只有一个元素的列表,所以在 do 表达式中的模式匹配失败了。第二次失败是因为 readMaybe 返回了一个 Nothing

组合单调函数

当我们讨论第十三章(第十三章) 中的单调法则时,你了解到 <=< 函数就像组合一样,但不是为像 a -> b 这样的普通函数工作,而是为像 a -> m b 这样的单调函数工作。以下是一个例子:

ghci> let f = (+1) . (*100)
ghci> f 4
401
ghci> let g = (\x -> return (x+1)) <=< (\x -> return (x*100))
ghci> Just 4 >>= g
Just 401

在这个例子中,我们首先组合了两个普通函数,将结果函数应用于 4,然后组合了两个单调函数,并用 >>=Just 4 传递给结果函数。

如果你有一系列函数在列表中,你可以通过使用 id 作为起始累加器和 . 函数作为二元函数,将它们全部组合成一个大的函数。以下是一个例子:

ghci> let f = foldr (.) id [(+1),(*100),(+1)]
ghci> f 1
201

函数 f 接受一个数字,然后将其加 1,将结果乘以 100,然后再次加 1

我们可以用相同的方式组合单调函数,但不是使用普通组合,而是使用 <=<,而不是 id,我们使用 return。我们不需要在 foldM 上使用 foldr 或类似的东西,因为 <=< 函数确保组合以单调方式发生。

当你在第十三章(第十三章) 中介绍列表单调时,我们用它来确定骑士是否能在棋盘上通过恰好三步从一个位置移动到另一个位置。我们创建了一个名为 moveKnight 的函数,它接受骑士在棋盘上的位置,并返回他可以做出的所有可能的下一步移动。然后,为了生成他经过三步后可能拥有的所有位置,我们创建了以下函数:

in3 start = return start >>= moveKnight >>= moveKnight >>= moveKnight

为了检查他是否能在三步内从 start 移动到 end,我们做了以下操作:

canReachIn3 :: KnightPos -> KnightPos -> Bool
canReachIn3 start end = end `elem` in3 start

使用单调函数组合,我们可以创建一个像 in3 这样的函数,除了生成骑士在走三步后可以拥有的所有位置外,我们还可以为任意数量的步数做这件事。如果你看 in3,你会看到我们使用了我们的 moveKnight 三次,每次都使用 >>= 将所有可能的前一个位置传递给它。所以现在,让我们让它更通用。以下是方法:

import Data.List

inMany :: Int -> KnightPos -> [KnightPos]
inMany x start = return start >>= foldr (<=<) return (replicate x moveKnight)

首先,我们使用 replicate 创建一个包含 xmoveKnight 函数副本的列表。然后我们将所有这些函数单调组合成一个,这给了我们一个接受起始位置并非确定性地移动骑士 x 次的函数。然后我们只需用 return 将起始位置变成一个单元素列表,并将其传递给该函数。

现在,我们可以将我们的 canReachIn3 函数变得更通用:

canReachIn :: Int -> KnightPos -> KnightPos -> Bool
canReachIn x start end = end `elem` inMany x start

创建单调

在本节中,我们将查看一个示例,说明如何创建一个类型,将其识别为单子(monad),然后赋予适当的Monad实例。我们通常不会仅仅为了创建一个单子而创建一个单子。相反,我们创建一个类型,其目的是模拟某个问题的某个方面,然后稍后,如果我们看到该类型代表一个具有上下文的价值并且可以像单子一样操作,我们就会给它一个Monad实例。

无标题图片

正如你所见,列表用于表示非确定性值。像[3,5,9]这样的列表可以被视为一个单一的、无法决定它将是什么的非确定性值。当我们用>>=将列表输入到一个函数中时,它只是对所有可能的选项进行选择,从列表中取一个元素并对其应用函数,然后将这些结果也以列表的形式呈现。

如果我们将列表[3,5,9]视为359同时发生,我们可能会注意到没有关于这些数字中每个数字发生概率的信息。如果我们想模拟一个非确定性值如[3,5,9],但想表达3有 50%的概率发生,而59都有 25%的概率发生,该怎么办?让我们尝试让它工作!

假设列表中的每一项都附带另一个值:它发生的概率。可能以这种方式呈现该值是有意义的:

[(3,0.5),(5,0.25),(9,0.25)]

在数学中,概率通常不是用百分比来表示的,而是用介于 0 和 1 之间的实数来表示。0 表示没有任何机会发生某事,而 1 表示它一定会发生。浮点数很快就会变得混乱,因为它们往往会丢失精度,但 Haskell 提供了一个有理数的类型。它被称为Rational,位于Data.Ratio中。要创建一个Rational,我们将其写成分数的形式。分子和分母由%分隔。以下是一些示例:

ghci> 1%4
1 % 4
ghci> 1%2 + 1%2
1 % 1
ghci> 1%3 + 5%4
19 % 12

第一行只是四分之一。在第二行,我们添加两个二分之一以得到一个整体。在第三行,我们将三分之一与五分之四相加得到十二分之十九。因此,让我们放弃浮点数,并使用Rational来表示概率:

ghci> [(3,1%2),(5,1%4),(9,1%4)]
[(3,1 % 2),(5,1 % 4),(9,1 % 4)]

好吧,所以3有 50%的概率发生,而59将每四次发生一次。相当不错。

我们对列表添加了一些额外的上下文,因此这也代表了具有上下文的价值。在我们继续之前,让我们将其包装成一个newtype,因为有些事情告诉我我们将要创建一些实例。

import Data.Ratio

newtype Prob a = Prob { getProb :: [(a, Rational)] } deriving Show

这是一个函子(functor)吗?嗯,列表是一个函子,所以这应该也是一个函子,因为我们只是向列表中添加了一些东西。当我们对列表映射一个函数时,我们将其应用于每个元素。这里,我们也将它应用于每个元素,但我们将保留概率不变。让我们创建一个实例:

instance Functor Prob where
    fmap f (Prob xs) = Prob $ map (\(x, p) -> (f x, p)) xs

我们使用模式匹配从newtype中展开它,将函数f应用于值的同时保持概率不变,然后再将其包装回原样。让我们看看它是否有效:

ghci> fmap negate (Prob [(3,1%2),(5,1%4),(9,1%4)])
Prob {getProb = [(-3,1 % 2),(-5,1 % 4),(-9,1 % 4)]}

注意,概率的总和应该总是等于1。如果这些都是可能发生的事情,那么它们的概率总和不应该是任何其他值。一个 75%的概率落地尾巴,50%的概率落地头部的硬币似乎只能存在于某个奇怪的世界中。

现在是最大的问题:这是一个单子吗?鉴于列表是一个单子,这似乎也应该是一个单子。首先,让我们考虑return。对于列表,它是如何工作的?它接受一个值并将其放入单元素列表中。那么这里呢?嗯,因为它应该是一个默认的最小上下文,它也应该创建一个单元素列表。那么概率呢?嗯,return x应该创建一个总是呈现x作为其结果的单子值,所以概率为0是没有意义的。如果它总是必须以这个值作为其结果呈现,那么概率应该是1

那么>>=呢?这似乎有点棘手,所以让我们利用m >>= f对于单子总是等于join (fmap f m)的事实,并思考我们如何平铺概率列表的列表。作为一个例子,让我们考虑这个列表,其中有一个 25%的概率是'a''b'中的任何一个会发生。'a''b'发生的可能性是相等的。还有 75%的概率是'c''d'中的任何一个会发生。'c''d'发生的可能性也是相等的。这是一个概率列表的图片,它模拟了这个场景:

无标题图片

这些字母发生的概率是多少?如果我们只画四个带有概率的盒子,这些概率会是什么?为了找出答案,我们只需要将每个概率乘以它包含的所有概率。'a''b'都会在八次中发生一次,因为如果我们把一半乘以四分之一,我们得到八分之一。'c'会在八次中发生三次,因为四分之三乘以一半是三又八分之一。'd'也会在八次中发生三次。如果我们把所有概率加起来,它们仍然等于一。

这是用概率列表表示的这种情况:

thisSituation :: Prob (Prob Char)
thisSituation = Prob
    [(Prob [('a',1%2),('b',1%2)], 1%4)
    ,(Prob [('c',1%2),('d',1%2)], 3%4)
    ]

注意,它的类型是Prob (Prob Char)。所以现在我们已经弄清楚如何平铺嵌套的概率列表,我们只需要编写这个代码。然后我们可以简单地写成>>=作为join (fmap f m),我们就有了单子!所以这里是flatten,我们将使用它,因为join这个名字已经被占用了:

flatten :: Prob (Prob a) -> Prob a
flatten (Prob xs) = Prob $ concat $ map multAll xs
    where multAll (Prob innerxs, p) = map (\(x, r) -> (x, p*r)) innerxs

函数 multAll 接收一个概率列表元组和与之相关的概率 p,然后将每个内部概率与 p 相乘,返回一个包含项目和概率的列表对。我们将 multAll 映射到嵌套概率列表中的每个对上,然后我们只需展平生成的嵌套列表。

现在我们已经拥有了所有需要的东西。我们可以编写一个 Monad 实例了!

instance Monad Prob where
    return x = Prob [(x,1%1)]
    m >>= f = flatten (fmap f m)
    fail _ = Prob []

因为我们已经完成了所有艰苦的工作,所以实例非常简单。我们还定义了 fail 函数,它与列表中的 fail 函数相同,所以如果 do 表达式中有模式匹配失败,失败就会发生在概率列表的上下文中。

检查我们刚刚创建的 Monad 是否满足 Monad 法则也很重要:

无标题图片

  1. 第一法则指出 return x >>= f 应该等于 f x。一个严格的证明可能会相当繁琐,但我们可以看到,如果我们用 return 将一个值放入默认上下文中,然后在这个值上应用一个函数,然后展平生成的概率列表,那么从该函数产生的每个概率都会乘以 return 制造的 1%1 概率,所以它不会影响上下文。

  2. 第二法则指出 m >>= returnm 没有区别。对于我们的例子,m >>= return 等于 m 的推理与第一法则类似。

  3. 第三定律指出 f <=< (g <=< h) 应该与 (f <=< g) <=< h 相同。这一点也是正确的,因为它适用于构成概率 Monad 基础的列表 Monad,并且因为乘法是结合的。1%2 * (1%3 * 1%5) 等于 (1%2 * 1%3) * 1%5

现在我们有了 Monad,我们可以用它做什么呢?嗯,它可以帮助我们进行概率计算。我们可以将概率事件视为具有上下文的价值,概率 Monad 将确保这些概率反映在最终结果的概率中。

假设我们有两枚正常的硬币和一枚落地时九次出现反面,一次出现正面的“作弊”硬币。如果我们一次性抛掷所有硬币,所有硬币都落地反面的概率是多少?首先,让我们为正常硬币的抛掷和作弊硬币的抛掷设定概率值:

data Coin = Heads | Tails deriving (Show, Eq)

coin :: Prob Coin
coin = Prob [(Heads,1%2),(Tails,1%2)]

loadedCoin :: Prob Coin
loadedCoin = Prob [(Heads,1%10),(Tails,9%10)]

最后,抛掷硬币的动作:

import Data.List (all)

flipThree :: Prob Bool
flipThree = do
    a <- coin
    b <- coin
    c <- loadedCoin
    return (all (==Tails) [a,b,c])

尝试一下,我们发现尽管我们使用了作弊硬币,所有三枚硬币都落地反面的概率并不高:

ghci> getProb flipThree
[(False,1 % 40),(False,9 % 40),(False,1 % 40),(False,9 % 40),
  (False,1 % 40),(False,9 % 40),(False,1 % 40),(True,9 % 40)]

它们三个中有三个会落地反面,占 40 次中的 9 次,不到 25%。我们看到我们的 Monad 不懂得如何将所有硬币都不落地反面的 False 结果合并成一个结果。这不是一个大问题,因为编写一个将所有相同结果合并成一个结果的函数相当简单(并且留给读者作为练习)。

在本节中,我们从有一个问题(列表是否也可以携带关于概率的信息?)开始,到创建一个类型,识别一个单子,最后创建一个实例并对其进行操作。我认为这相当吸引人!到目前为止,你应该已经对单子及其内容有了相当好的理解。

第十五章。Zipper

虽然 Haskell 的纯净性带来了一大堆好处,但它让我们以不同于不纯净语言的方式处理一些问题。

由于引用透明性,在 Haskell 中,如果一个值代表相同的东西,那么一个值和另一个值一样好。所以,如果我们有一棵满是五的树(可能是高五,也许?),并且我们想将其中一个改成六,我们必须有一种方法来确切地知道我们想要更改树中的哪个五。我们需要知道它在我们的树中的位置。在不纯净的语言中,我们只需记录五在内存中的位置并更改它。但在 Haskell 中,一个五和一个五一样好,所以我们不能根据它们在内存中的位置进行区分。

无标题图片

我们也不能真正地 更改 任何东西。当我们说“更改树”时,我们实际上意味着我们取一棵树并返回一个与原始树相似但略有不同的新树。

我们可以做的事情之一是记住从树根到我们想要更改的元素的一条路径。我们可以说,“拿这棵树,向左走,向右走,然后再向左走,然后更改那里的元素。”虽然这可行,但可能不太高效。如果我们想稍后更改靠近之前更改的元素的元素,我们需要再次从树根走到我们的元素!

在本章中,你将看到如何将某些数据结构装备上一种称为 zipper 的东西,以便以使更改其元素变得容易和遍历它变得高效的方式关注数据结构的一部分。太棒了!

散步

就像你在生物课上学到的那样,有很多不同种类的树,所以让我们选择一个种子,我们将用它来种植我们的树。这里就是:

data Tree a = Empty | Node a (Tree a) (Tree a) deriving (Show)

我们的树要么是空的,要么是一个包含元素和两个子树的节点。这里有一个很好的例子,我将它免费提供给你,读者!

freeTree :: Tree Char freeTree =
    Node 'P'
        (Node 'O'
            (Node 'L'
                (Node 'N' Empty Empty)
                (Node 'T' Empty Empty)
            )
            (Node 'Y'
                (Node 'S' Empty Empty)
                (Node 'A' Empty Empty)
            )
        )
        (Node 'L'
            (Node 'W'
                (Node 'C' Empty Empty)
                (Node 'R' Empty Empty)
            )
            (Node 'A'
                (Node 'A' Empty Empty)
                (Node 'C' Empty Empty)
            )
        )

这里是图形表示的这棵树:

无标题图片

注意树中的 W 吗?如果我们想把它改成 P,我们会怎么做?嗯,一种方法是对我们的树进行模式匹配,直到我们找到元素,首先向右走,然后向左走。这是这段代码:

changeToP :: Tree Char -> Tree Char
changeToP (Node x l (Node y (Node _ m n) r)) = Node x l (Node y (Node 'P' m n) r)

哎呀!这不仅相当难看,而且有点令人困惑。这里实际上发生了什么?嗯,我们在我们的树上进行模式匹配,并给它根元素命名为 x(它变成了根的 'P'),并给它左子树命名为 l。我们没有给它右子树命名,而是进一步对它进行模式匹配。我们继续进行这种模式匹配,直到我们达到根为 'W' 的子树。一旦我们进行了匹配,我们就重建树,但现在包含 'W' 的子树现在以 'P' 为根。

有没有更好的方法来做这件事?如果我们让我们的函数接受一个树和一个方向列表会怎样。方向将是 LR,分别代表左或右,我们将通过跟随提供的方向来更改我们到达的元素。看看这个:

data Direction = L | R deriving (Show)
type Directions = [Direction]

changeToP :: Directions -> Tree Char -> Tree Char
changeToP (L:ds) (Node x l r) = Node x (changeToP ds l) r
changeToP (R:ds) (Node x l r) = Node x l (changeToP ds r)
changeToP [] (Node _ l r) = Node 'P' l r

如果方向列表的第一个元素是 L,我们将构建一个类似于旧树的新树,但其左子树有一个元素被更改为 'P'。当我们递归调用 changeToP 时,我们只给它方向列表的尾部,因为我们已经向左移动了。在 R 的情况下,我们做同样的事情。如果方向列表为空,这意味着我们已经到达了目的地,因此我们返回一个类似于提供的树,但它的根元素是 'P'

为了避免打印整个树,让我们创建一个函数,它接受一个方向列表并告诉我们目的地的元素:

elemAt :: Directions -> Tree a -> a
elemAt (L:ds) (Node _ l _) = elemAt ds l
elemAt (R:ds) (Node _ _ r) = elemAt ds r
elemAt [] (Node x _ _) = x

这个函数实际上与 changeToP 非常相似。区别在于,它不是在途中记住东西并重建树,而是除了目的地之外忽略一切。在这里,我们将 'W' 更改为 'P',并看看我们新树中的更改是否保持不变:

ghci> let newTree = changeToP [R,L] freeTree
ghci> elemAt [R,L] newTree
'P'

这似乎有效。在这些函数中,方向列表充当一种 焦点,因为它精确地指出了我们的树中的一个子树。例如,方向列表 [R] 会聚焦于根右侧的子树。一个空的方向列表会聚焦于主树本身。

虽然这个技术看起来很酷,但它可能相当低效,尤其是如果我们想要重复更改元素。比如说,我们有一个非常大的树和一个指向树底部的某个元素的长方向列表。我们使用方向列表在树上漫步并更改底部的元素。如果我们想要更改接近我们刚刚更改的元素的另一个元素,我们需要从树的根开始再次走到底部。真麻烦!

在下一节中,我们将找到一种更好的方法来聚焦于子树——这种方法允许我们高效地切换到附近的子树。

路径标记的踪迹

无标题图片

为了聚焦于一个子树,我们想要比仅仅遵循从树根开始的方向列表更好的东西。如果我们从树的根开始,一次向左或向右移动一步,并在路上留下“路径标记”会怎么样?使用这种方法,当我们向左移动时,我们会记住我们向左移动了,当我们向右移动时,我们会记住我们向右移动了。让我们试试。

为了表示我们的路径标记,我们也会使用一个方向值列表(LR 值),但我们将不称之为 Directions,而是称之为 Breadcrumbs,因为当我们沿着树向下移动时,我们的方向将会被反转。

type Breadcrumbs = [Direction]

这是一个函数,它接受一个树和一些面包屑,并将移动到左子树,同时将 L 添加到表示我们的面包屑的列表的头部:

goLeft :: (Tree a, Breadcrumbs) -> (Tree a, Breadcrumbs)
goLeft (Node _ l _, bs) = (l, L:bs)

我们忽略根节点和右子树中的元素,只返回左子树以及带有 L 作为头部的旧面包屑。

这是一个向右走的函数:

goRight :: (Tree a, Breadcrumbs) -> (Tree a, Breadcrumbs)
goRight (Node _ _ r, bs) = (r, R:bs)

它的工作方式与向左走的方式相同。

让我们使用这些函数来将我们的 freeTree 向右走然后向左走。

ghci> goLeft (goRight (freeTree, []))
(Node 'W' (Node 'C' Empty Empty) (Node 'R' Empty Empty),[L,R])

无标题图片

现在我们有一个根节点为 'W' 的树,其左子树的根节点为 'C',其右子树的根节点为 'R'。面包屑是 [L,R],因为我们首先向右走然后向左走。

为了使沿着我们的树行走更清晰,我们可以使用来自第十三章的 -: 函数,我们定义如下:

x -: f = f x

这允许我们通过首先写下值,然后一个 -:, 然后函数的方式来应用函数。所以,我们不再需要 goRight (freeTree, []),而是可以写成 (freeTree, []) -: goRight。使用这种形式,我们可以重写前面的例子,使其更明显地表明我们先向右走然后向左走:

ghci> (freeTree, []) -: goRight -: goLeft
(Node 'W' (Node 'C' Empty Empty) (Node 'R' Empty Empty),[L,R])

向上走

如果我们想在我们的树中向上走呢?从我们的面包屑中,我们知道当前树是其父树的左子树,并且它是其父树的右子树,这就是我们所知道的一切。面包屑没有告诉我们足够关于当前子树的父树的信息,使我们能够向上走树。看起来除了我们采取的方向外,单个面包屑还应该包含我们返回时所需的所有其他数据。在这种情况下,那就是父树中的元素及其右子树。

通常,单个面包屑应该包含重建父节点所需的所有数据。因此,它应该包含我们未采取的所有路径的信息,并且它还应该知道我们所采取的方向。然而,它必须不包含我们目前关注的子树。这是因为我们已经在元组的第一个组件中有了那个子树。如果我们也在面包屑中包含它,我们就会得到重复信息。

我们不希望有重复的信息,因为如果我们改变我们关注的子树中的某些元素,面包屑中的现有信息将与我们所做的更改不一致。当我们改变我们的关注点时,重复信息就会过时。如果我们的树包含很多元素,它也可能占用大量的内存。

让我们修改我们的面包屑,使它们也包含我们在左右移动时之前忽略的所有信息。我们将不再使用 Direction,而将创建一个新的数据类型:

data Crumb a = LeftCrumb a (Tree a) | RightCrumb a (Tree a) deriving (Show)

现在,我们不仅有L,还有包含从节点移动到的元素和未访问的右树的LeftCrumb。而不是R,我们有包含从节点移动到的元素和未访问的左树的RightCrumb

这些面包屑现在包含了重新创建我们走过的树的所需所有数据。因此,它们不仅仅是普通的面包屑,更像是我们沿途留下的软盘,因为它们包含的信息比我们采取的方向要多得多。

从本质上讲,每个面包屑现在都像是一个有洞的树节点。当我们深入树中时,面包屑携带了从我们移动离开的节点所携带的所有信息,除了我们选择关注的子树。它还需要注意洞的位置。在LeftCrumb的情况下,我们知道我们向左移动,所以缺失的子树是左边的。

让我们也将我们的Breadcrumbs类型别名更改为反映这一点:

type Breadcrumbs a = [Crumb a]

接下来,我们需要修改goLeftgoRight函数,在面包屑中存储我们未采取的路径信息,而不是像之前那样忽略这些信息。以下是goLeft

goLeft :: (Tree a, Breadcrumbs a) -> (Tree a, Breadcrumbs a)
goLeft (Node x l r, bs) = (l, LeftCrumb x r:bs)

你可以看到,它与我们的之前的goLeft非常相似,但我们不是仅仅在我们的面包屑列表的头部添加一个L,而是添加一个LeftCrumb来表示我们向左移动。我们还为我们的LeftCrumb配备了从节点移动到的元素(那就是x)和未访问的右子树。

注意,此函数假设当前焦点所在的树不是Empty。空树没有任何子树,因此如果我们尝试从一个空树向左移动,将会发生错误。这是因为对Node的模式匹配将不会成功,而且没有模式会处理Empty

goRight类似:

goRight :: (Tree a, Breadcrumbs a) -> (Tree a, Breadcrumbs a)
goRight (Node x l r, bs) = (r, RightCrumb x l:bs)

我们之前能够左右移动。我们现在拥有的能力是通过记住关于父节点和未访问路径的信息,实际上能够向上移动。下面是goUp函数:

goUp :: (Tree a, Breadcrumbs a) -> (Tree a, Breadcrumbs a)
goUp (t, LeftCrumb x r:bs) = (Node x t r, bs)
goUp (t, RightCrumb x l:bs) = (Node x l t, bs)

无标题图片

我们正在关注树t,并检查最新的Crumb。如果它是一个LeftCrumb,我们使用我们的树t作为左子树,并使用关于未访问的右子树和元素的详细信息来填充Node的其余部分。因为我们“返回”并拾取最后一个面包屑,然后使用它来重新创建父树,所以新的列表不包含那个面包屑。

注意,如果我们已经在树的顶部并且想要向上移动,这个函数会导致错误。稍后,我们将使用Maybe monad 来表示移动焦点时可能出现的失败。

通过一对Tree aBreadcrumbs a,我们拥有了重建整个树所需的所有信息,并且我们还关注了一个子树。这种方案使我们能够轻松地向上、向左和向右移动。

包含数据结构的一部分及其周围环境的对称为拉链,因为将焦点在数据结构中上下移动类似于裤子拉链的操作。所以,创建一个类型同义词是很有趣的:

type Zipper a = (Tree a, Breadcrumbs a)

我更愿意将类型同义词命名为Focus,因为这使它更清楚地表明我们正在关注数据结构的一部分。但由于Zipper这个名字更广泛地用来描述这种设置,我们将坚持使用它。

在焦点下操作树

现在我们能够上下移动,让我们创建一个函数来修改拉链关注的子树根部的元素:

modify :: (a -> a) -> Zipper a -> Zipper a
modify f (Node x l r, bs) = (Node (f x) l r, bs)
modify f (Empty, bs) = (Empty, bs)

如果我们关注一个节点,我们使用函数f修改其根元素。如果我们关注一个空树,我们保持它不变。现在我们可以从一个树开始,移动到任何我们想要的地方,并修改一个元素,同时保持对该元素的焦点,这样我们就可以轻松地向上或向下移动。这里有一个例子:

ghci> let newFocus = modify (\_ -> 'P') (goRight (goLeft (freeTree, [])))

我们向左走,然后向右走,然后通过将其替换为'P'来修改根元素。如果我们使用-:来写,这读起来更好:

ghci> let newFocus = (freeTree, []) -: goLeft -: goRight -: modify (\_ -> 'P')

然后,如果我们想的话,我们可以向上移动并用神秘的'X'替换一个元素:

ghci> let newFocus2 = modify (\_ -> 'X') (goUp newFocus)

或者我们可以用-:来写:

ghci> let newFocus2 = newFocus -: goUp -: modify (\_ -> 'X')

向上移动很容易,因为我们留下的面包屑构成了我们未关注的部分数据结构,但它被反转了,有点像把袜子翻过来。这就是为什么当我们想要向上移动时,我们不需要从根开始并向下走。我们只需取我们反转树的顶部,从而反转它的一部分并将其添加到我们的焦点中。

每个节点都有两个子树,即使这些子树是空的。所以,如果我们关注一个空子树,我们可以做的一件事是用非空子树替换它,从而将树附加到一个叶节点上。这个代码很简单:

attach :: Tree a -> Zipper a -> Zipper a
attach t (_, bs) = (t, bs)

我们取一个树和一个拉链,并返回一个新的拉链,其焦点被提供的树所替换。我们不仅可以通过用新树替换空子树来扩展树,还可以替换现有的子树。让我们将一个树附加到我们的freeTree的左侧:

ghci> let farLeft = (freeTree, []) -: goLeft -: goLeft -: goLeft -: goLeft
ghci> let newFocus = farLeft -: attach (Node 'Z' Empty Empty)

newFocus现在关注的是我们刚刚附加的树,其余的树以反转的形式位于面包屑中。如果我们使用goUp走到树的顶端,它将是与freeTree相同的树,但在其左侧额外有一个'Z'

直接走到顶部,那里空气清新、干净!

创建一个函数,使其能够走到树的顶端,无论我们关注的是什么,这实际上非常简单。下面是它的样子:

topMost :: Zipper a -> Zipper a
topMost (t, []) = (t, [])
topMost z = topMost (goUp z)

如果我们的增强面包屑轨迹为空,这意味着我们已经在树的根上了,所以我们只需返回当前的焦点。否则,我们向上移动以获取父节点的焦点,然后递归地应用topMost

因此,现在我们可以绕着我们的树走动,向左、向右和向上移动,在旅途中应用 modifyattach。然后,当我们完成我们的修改后,我们使用 topMost 来关注树的根,并从适当的视角看到我们所做的更改。

专注于列表

展开器可以与几乎任何数据结构一起使用,所以它们可以与列表的子列表一起使用并不令人惊讶。毕竟,列表几乎就像树一样,除了树中的节点有一个元素(或没有)和几个子树,列表中的节点有一个元素和只有一个子列表。当我们实现了自己的列表时,在第七章中,我们定义了我们的数据类型如下:

data List a = Empty | Cons a (List a) deriving (Show, Read, Eq, Ord)

将其与我们的二叉树定义进行比较,很容易看出列表可以被视为每个节点只有一个子树的树。

无标题图片

一个像 [1,2,3] 这样的列表可以写成 1:2:3:[]。它由列表的头部组成,即 1,然后是列表的尾部,即 2:3:[]2:3:[] 也有一个头部,即 2,和一个尾部,即 3:[]。对于 3:[]3 是头部,而尾部是空列表 []

让我们为列表创建一个展开器。要改变对列表子列表的关注,我们可以向前或向后移动(而与树不同,我们可以向上、向左或向右移动)。关注的部分将是一个子树,并且随着我们的前进,我们将留下面包屑。

现在,一个列表的单个面包屑由什么组成?当我们处理二叉树时,面包屑需要保存父节点根部的元素以及我们没有选择的全部子树。它还必须记住我们是向左还是向右移动。因此,它需要拥有节点所拥有的所有信息,除了我们选择关注的子树。

列表比树简单。我们不需要记住我们是向左还是向右移动,因为进入列表的深度只有一种方式。因为每个节点只有一个子列表,所以我们也不需要记住我们没有走的路径。看起来我们只需要记住前一个元素。如果我们有一个像 [3,4,5] 的列表,并且我们知道前一个元素是 2,我们只需将那个元素放在列表的头部,就可以回退到 [2,3,4,5]

因为这里的单个面包屑只是元素,所以我们实际上不需要将它放入数据类型中,就像我们在为树展开器创建 Crumb 数据类型时做的那样。

type ListZipper a = ([a], [a])

第一个列表代表我们关注的列表,第二个列表是面包屑列表。让我们创建在列表中向前和向后移动的函数:

goForward :: ListZipper a -> ListZipper a
goForward (x:xs, bs) = (xs, x:bs)

goBack :: ListZipper a -> ListZipper a
goBack (xs, b:bs) = (b:xs, bs)

当我们向前移动时,我们专注于当前列表的尾部,并将头部元素作为面包屑留下。当我们向后移动时,我们取最新的面包屑并将其放在列表的开头。这里有两个函数在起作用:

ghci> let xs = [1,2,3,4]
ghci> goForward (xs, [])
([2,3,4],[1])
ghci> goForward ([2,3,4], [1])
([3,4],[2,1])
ghci> goForward ([3,4], [2,1])
([4],[3,2,1])
ghci> goBack ([4], [3,2,1])
([3,4],[2,1])

你可以看到,在列表的情况下,面包屑不过是你的列表的反转部分。我们移动开来的元素总是进入面包屑的头部。然后,只需从面包屑的头部取出该元素并将其作为我们关注的头部,就可以轻松地返回。这也使得我们更容易理解为什么我们称之为 zipper——它确实看起来像拉链的滑块上下移动。

如果你正在制作一个文本编辑器,你可以使用字符串列表来表示当前打开的文本行,然后你可以使用 zippers 来知道光标当前聚焦在哪一行。使用 zippers 也会使在文本中任何地方插入新行或删除现有行变得更加容易。

非常简单的文件系统

为了演示如何使用 zippers,让我们用树来表示一个非常简单的文件系统。然后我们可以为该文件系统制作一个 zippers,这样我们就可以在文件夹之间移动,就像我们在真实文件系统中跳转一样。

平均的分层文件系统主要由文件和文件夹组成。文件是数据单元,并且有名称。文件夹用于组织这些文件,可以包含文件或其他文件夹。在我们的简单示例中,让我们假设文件系统中的项目是以下之一:

  • 一个文件,它带有名称和一些数据

  • 一个文件夹,它有一个名称并包含其他项目,这些项目可以是文件或文件夹本身

这里有一个数据类型和一些类型同义词,这样我们就可以知道是什么:

type Name = String
type Data = String
data FSItem = File Name Data | Folder Name [FSItem] deriving (Show)

一个文件带有两个字符串,分别代表它的名称和它持有的数据。一个文件夹带有代表其名称的字符串和一个项目列表。如果该列表为空,则我们有一个空文件夹。

这里有一个包含一些文件和子文件夹的文件夹(实际上是我现在的磁盘内容):

myDisk :: FSItem
myDisk =
    Folder "root"
        [ File "goat_yelling_like_man.wmv" "baaaaaa"
        , File "pope_time.avi" "god bless"
        , Folder "pics"
            [ File "ape_throwing_up.jpg" "bleargh"
            , File "watermelon_smash.gif" "smash!!"
            , File "skull_man(scary).bmp" "Yikes!"
            ]
        , File "dijon_poupon.doc" "best mustard"
        , Folder "programs"
            [ File "fartwizard.exe" "10gotofart"
            , File "owl_bandit.dmg" "mov eax, h00t"
            , File "not_a_virus.exe" "really not a virus"
            , Folder "source code"
                [ File "best_hs_prog.hs" "main = print (fix error)"
                , File "random.hs" "main = print 4"
                ]
            ]
        ]

为我们的文件系统制作一个 Zipper

现在我们有了文件系统,我们需要的只是一个 zippers,这样我们就可以在它周围压缩和扩展,添加、修改和删除文件和文件夹。与二叉树和列表一样,我们的面包屑将包含关于我们没有访问的所有内容的详细信息。单个面包屑应该存储除了我们当前关注的子树之外的所有内容。它还应该记录洞的位置,这样一旦我们向上移动,我们就可以将我们之前的关注点插入到洞中。

无标题图片

在这种情况下,一个面包屑应该像文件夹一样,只是它应该缺少我们当前选择的文件夹。“为什么不像文件一样?”你可能会问?好吧,因为我们一旦专注于一个文件,我们就不能在文件系统中进一步深入,所以留下一个表明我们来自文件的面包屑是没有意义的。文件有点像空树。

如果我们关注的是文件夹 "root",然后关注文件 "dijon_poupon.doc",我们留下的面包屑应该是什么样子?嗯,它应该包含其父文件夹的名称,以及我们关注的文件之前和之后的项目。所以,我们只需要一个 Name 和两个项目列表。通过为在我们关注的项之前和之后的项目保持单独的列表,我们知道一旦我们向上移动,它应该放在哪里。这样,我们就知道洞的位置。

这是我们的文件系统面包屑类型:

data FSCrumb = FSCrumb Name [FSItem] [FSItem] deriving (Show)

这里是我们的拉链的同义词:

type FSZipper = (FSItem, [FSCrumb])

在层次结构中向上回退非常简单。我们只需取最新的面包屑,并从当前焦点和面包屑中组装一个新的焦点,如下所示:

fsUp :: FSZipper -> FSZipper
fsUp (item, FSCrumb name ls rs:bs) = (Folder name (ls ++ [item] ++ rs), bs)

因为我们的面包屑知道父文件夹的名称,以及文件夹中我们关注的项目之前的项目(那是 ls)和之后的项目(那是 rs),向上移动很容易。

想要深入文件系统吗?如果我们处于 "root",并且想要关注 "dijon_poupon.doc",我们留下的面包屑将包括名称 "root",以及 "dijon_poupon.doc" 之前和之后的项目。这里有一个函数,给定一个名称,关注当前焦点文件夹中位于该名称的文件或文件夹:

import Data.List (break)

fsTo :: Name -> FSZipper -> FSZipper
fsTo name (Folder folderName items, bs) =
    let (ls, item:rs) = break (nameIs name) items
    in  (item, FSCrumb folderName ls rs:bs)

nameIs :: Name -> FSItem -> Bool
nameIs name (Folder folderName _) = name == folderName
nameIs name (File fileName _) = name == fileName

fsTo 接受一个 Name 和一个 FSZipper,并返回一个新的 FSZipper,该 FSZipper 关注具有给定名称的文件。该文件必须位于当前焦点文件夹中。这个函数不会到处搜索——它只是在当前文件夹中查找。

无标题图片

首先,我们使用 break 将文件夹中的项目列表拆分为我们正在搜索的文件之前的项目和之后的项目。break 接受一个谓词和一个列表,并返回一对列表。这对中的第一个列表包含谓词返回 False 的项目。然后,一旦谓词对一个项目返回 True,它将该项目以及列表的其余部分放在对的第二个项目中。我们创建了一个辅助函数 nameIs,它接受一个名称和一个文件系统项,如果名称匹配则返回 True

现在 ls 是一个包含我们正在搜索的项目之前的项目列表,item 是那个项目本身,而 rs 是在其文件夹中跟随它的项目列表。现在我们有了这些,我们只需将 break 得到的项目作为焦点,并构建一个包含所有所需数据的面包屑。

注意,如果我们寻找的名称不在文件夹中,模式 item:rs 将尝试在空列表上匹配,并且我们会得到一个错误。而且如果我们的当前焦点是一个文件而不是文件夹,我们也会得到一个错误,程序会崩溃。

因此,我们可以上下移动我们的文件系统。让我们从根目录开始,走到文件 "skull_man(scary).bmp"

ghci> let newFocus = (myDisk, []) -: fsTo "pics" -: fsTo "skull_man(scary).bmp"

newFocus 现在是一个聚焦于 "skull_man(scary).bmp" 文件的 zippers。让我们获取 zippers 的第一个组件(焦点本身)并看看这是否真的正确:

ghci> fst newFocus
File "skull_man(scary).bmp" "Yikes!"

让我们向上移动并关注其相邻的文件 "watermelon_smash.gif":

ghci> let newFocus2 = newFocus -: fsUp -: fsTo "watermelon_smash.gif"
ghci> fst newFocus2
File "watermelon_smash.gif" "smash!!"

操作文件系统

现在我们能够导航我们的文件系统,操作它变得容易。这里有一个函数可以重命名当前聚焦的文件或文件夹:

fsRename :: Name -> FSZipper -> FSZipper
fsRename newName (Folder name items, bs) = (Folder newName items, bs)
fsRename newName (File name dat, bs) = (File newName dat, bs)

让我们把 "pics" 文件夹重命名为 "cspi":

ghci> let newFocus = (myDisk, []) -: fsTo "pics" -: fsRename "cspi" -: fsUp

我们下到了 "pics" 文件夹,重命名了它,然后移动了备份。

那么一个在当前文件夹中创建新项目的函数呢?看这里:

fsNewFile :: FSItem -> FSZipper -> FSZipper
fsNewFile item (Folder folderName items, bs) =
    (Folder folderName (item:items), bs)

简单得就像馅饼一样。注意,如果我们试图添加一个项目但焦点在文件而不是文件夹上,这会导致崩溃。

让我们在 "pics" 文件夹中添加一个文件,然后向上移动到根目录:

ghci> let newFocus =
    (myDisk, []) -: fsTo "pics" -: fsNewFile (File "heh.jpg" "lol") -: fsUp

所有这些真正酷的地方在于,当我们修改我们的文件系统时,我们的更改实际上并没有在原地做出,而是函数返回了一个全新的文件系统。这样,我们就可以访问我们的旧文件系统(在这种情况下,myDisk),以及新的一个(newFocus 的第一个组件)。

通过使用 zippers,我们免费获得版本控制。我们总是可以引用数据结构的旧版本,即使在我们更改它们之后。这不仅仅局限于 zippers,但它是 Haskell 的一个特性,因为它的数据结构是不可变的。然而,通过 zippers,我们得到了轻松高效地遍历数据结构的能力,因此 Haskell 数据结构的持久性真正开始闪耀。

小心脚下

到目前为止,在遍历我们的数据结构——无论是二叉树、列表还是文件系统——我们并不真的关心我们是否走得太远而跌倒了。例如,我们的 goLeft 函数接受一个二叉树的 zipping 并将焦点移动到其左子树:

goLeft :: Zipper a -> Zipper a
goLeft (Node x l r, bs) = (l, LeftCrumb x r:bs)

但如果我们离开的树是一个空树呢?如果它不是一个 Node,而是一个 Empty 呢?在这种情况下,我们会得到一个运行时错误,因为模式匹配会失败,我们没有为空树制作模式,而空树没有任何子树。

到目前为止,我们只是假设我们永远不会尝试关注空树的左子树,因为它的左子树不存在。但去空树的左子树并没有太多意义,而且到目前为止,我们只是方便地忽略了这一点。

或者如果我们已经到了某个树的根,没有面包屑但仍然试图向上移动呢?同样的事情会发生。似乎在使用 zippers 时,任何一步都可能成为我们的最后一步(提示:不祥的音乐)。换句话说,任何移动都可能成功,但也可能导致失败。这让你想起了什么吗?当然:monads!更具体地说,是 Maybe monad,它为正常值添加了可能的失败上下文。

无标题图片

让我们使用 Maybe 模态来给我们的移动添加一个可能失败的上下文。我们将把在二叉树 zippers 上工作的函数变成模态函数。

首先,让我们处理 goLeftgoRight 的可能失败。到目前为止,可能失败的函数的失败总是反映在其结果中,这个例子也不例外。

这里是添加了失败可能性的 goLeftgoRight

goLeft :: Zipper a -> Maybe (Zipper a)
goLeft (Node x l r, bs) = Just (l, LeftCrumb x r:bs)
goLeft (Empty, _) = Nothing

goRight :: Zipper a -> Maybe (Zipper a)
goRight (Node x l r, bs) = Just (r, RightCrumb x l:bs)
goRight (Empty, _) = Nothing

现在,如果我们尝试在空树的一侧迈出一步,我们会得到一个 Nothing

ghci> goLeft (Empty, [])
Nothing
ghci> goLeft (Node 'A' Empty Empty, [])
Just (Empty,[LeftCrumb 'A' Empty])

看起来不错!那么向上走呢?之前的问题发生在我们尝试向上走但没有更多的面包屑时,这意味着我们已经到达了树的根。这是 goUp 函数,如果我们不保持在我们树的范围内,它会抛出一个错误:

goUp :: Zipper a -> Zipper a
goUp (t, LeftCrumb x r:bs) = (Node x t r, bs)
goUp (t, RightCrumb x l:bs) = (Node x l t, bs)

让我们修改它以优雅地失败:

goUp :: Zipper a -> Maybe (Zipper a)
goUp (t, LeftCrumb x r:bs) = Just (Node x t r, bs)
goUp (t, RightCrumb x l:bs) = Just (Node x l t, bs)
goUp (_, []) = Nothing

如果我们有面包屑,一切正常,我们返回一个成功的新焦点。如果没有面包屑,我们返回一个失败。

以前,这些函数接受 zippers 并返回 zippers,这意味着我们可以像这样将它们链接起来以进行遍历:

gchi> let newFocus = (freeTree, []) -: goLeft -: goRight

但是现在,它们不再返回 Zipper a,而是返回 Maybe (Zipper a),并且这样链接函数是不行的。我们在处理第十三章 第十三章 中的走钢丝者时也遇到了类似的问题。他也是一步一步地走,每一步都可能失败,因为一大群鸟可能会落在他的平衡杆的一侧,使他跌倒。

现在轮到我们成为笑话的主角了,因为我们成了行走的人,而且我们在自己设计的迷宫中穿梭。幸运的是,我们可以从走钢丝的人那里学到东西,就像他做的那样:用 >>= 替换正常的函数应用。这需要一个带有上下文(在我们的情况下,是 Maybe (Zipper a),它有一个可能失败上下文)的值,并将其输入到一个函数中,同时确保上下文得到处理。所以就像我们的走钢丝者一样,我们将用所有的 -: 操作符来交换 >>= 操作符。然后我们就能再次链接我们的函数了!看看它是如何工作的:

ghci> let coolTree = Node 1 Empty (Node 3 Empty Empty)
ghci> return (coolTree, []) >>= goRight
Just (Node 3 Empty Empty,[RightCrumb 1 Empty])
ghci> return (coolTree, []) >>= goRight >>= goRight
Just (Empty,[RightCrumb 3 Empty,RightCrumb 1 Empty])
ghci> return (coolTree, []) >>= goRight >>= goRight >>= goRight
Nothing

我们使用 return 将 zippers 放入 Just 中,然后使用 >>= 将其输入到我们的 goRight 函数中。首先,我们创建了一个在其左侧有一个空子树,在其右侧有一个有两个空子树的节点的树。当我们向右走一次时,结果是成功的,因为操作是有意义的。向右走两次也是可以的。我们最终会在一个空子树上聚焦。但是向右走三次是没有意义的——我们无法走到空子树的右边。这就是为什么结果是 Nothing 的原因。

现在我们已经给我们的树配备了安全网,以防我们跌落。 (哇,我完美地使用了这个隐喻。)

注意

我们的文件系统也有许多操作可能失败的情况,例如尝试聚焦到一个不存在文件或文件夹。作为一个练习,你可以通过使用 Maybe 模态来为我们的文件系统添加优雅失败的功能。

感谢阅读!

或者直接翻到最后一页!希望你觉得这本书有用且有趣。我努力为你提供了对 Haskell 语言及其惯用法的良好洞察。虽然 Haskell 总是有新的东西可以学习,但现在你应该能够编写酷炫的代码,以及阅读和理解他人的代码。所以赶快开始编码吧!在另一边见!

无标题图片

posted @ 2025-12-01 09:42  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报