跳转到内容

Haskell/列表 II

来自维基教科书,为开放世界提供开放书籍

之前我们了解到 Haskell 使用 cons 操作符 (:) 和空列表 [] 来构建列表。我们看到了如何使用递归和模式匹配来逐个处理列表。在本章和下一章中,我们将深入探讨列表处理的更深入技术,并发现一些新的符号。我们将初次接触 Haskell 的一些特性,如无限列表、列表推导式和高阶函数。

注意

在本章中,您将阅读和编写函数,这些函数会对列表中的元素进行求和、减法和乘法运算。为简单起见,我们将假设列表元素的类型为 Integer。但是,正如您从关于 类型基础 II 的讨论中所记得的那样,Num 类型类中存在许多不同的类型。作为一种练习,您可以计算出如果我们使这些函数多态,允许列表元素具有 Num 类中的任何类型,那么这些函数的类型签名将是什么。要检查您的签名,只需暂时省略它们,将函数加载到 GHCi 中,使用 :t 并让类型推断来指导您。


重建列表

[编辑 | 编辑源代码]

这是一个将整数列表中每个元素加倍的函数

doubleList :: [Integer] -> [Integer]
doubleList [] = []
doubleList (n:ns) = (2 * n) : doubleList ns

在这里,基本情况为空列表,它计算为空列表。在递归情况下,doubleList 构建一个新的列表,使用 (:)。这个新列表的第一个元素是参数头部元素的两倍,我们通过递归调用 doubleList 对参数尾部进行操作来获得其余结果。当尾部变为空列表时,将调用基本情况,递归将停止。[1]

让我们研究一个示例表达式的求值过程

doubleList [1,2,3,4]

我们可以通过将参数代入函数定义来手动进行计算,就像教科书中的代数一样

doubleList 1:[2,3,4] = (1*2) : doubleList (2 : [3,4])
                     = (1*2) : (2*2) : doubleList (3 : [4])
                     = (1*2) : (2*2) : (3*2) : doubleList (4 : [])
                     = (1*2) : (2*2) : (3*2) : (4*2) : doubleList []
                     = (1*2) : (2*2) : (3*2) : (4*2) : []
                     = 2 : 4 : 6 : 8 : []
                     = [2, 4, 6, 8]

因此,我们重建了原始列表,将每个元素替换为其两倍。

在这个手动求值练习中,我们选择何时计算乘法的 时机 不会影响结果。我们也可以在每次递归调用 doubleList 之后立即计算倍数。[2]

Haskell 在一些重要方面利用了这种对求值顺序的灵活性。作为一种 函数式编程语言,编译器会做出大部分关于何时实际计算内容的决定。作为一种 惰性 语言,Haskell 通常会延迟求值,直到需要最终值(有时可能永远不会出现)。[3] 从程序员的角度来看,求值顺序很少重要。[4]

要将列表中每个元素乘以 3,我们可以使用与 doubleList 相同的策略

tripleList :: [Integer] -> [Integer]
tripleList [] = []
tripleList (n:ns) = (3 * n) : tripleList ns

但我们不想为每个不同的乘数编写一个新的列表乘法函数(例如,将列表中的元素乘以 4、8、17 等)。因此,让我们创建一个通用函数,允许使用 任何 数字进行乘法。我们的新函数将接受两个参数:被乘数以及要乘以的 Integer 列表

multiplyList :: Integer -> [Integer] -> [Integer]
multiplyList _ [] = []
multiplyList m (n:ns) = (m * n) : multiplyList m ns

此示例使用 _ 作为“无关紧要”的模式。被乘数在基本情况下未使用,因此我们忽略该参数,而不是给它一个名称(如 mnns)。

我们可以测试 multiplyList,以确保它按预期工作

Prelude> multiplyList 17 [1,2,3,4]
[17,34,51,68]
练习

编写以下函数并测试它们。不要忘记类型签名。

  1. takeInt 返回列表中的前 n 个项目。因此,takeInt 4 [11,21,31,41,51,61] 返回 [11,21,31,41]
  2. dropInt 删除列表中的前 n 个项目并返回其余部分。因此,dropInt 3 [11,21,31,41,51] 返回 [41,51]
  3. sumInt 返回列表中所有项目的总和。
  4. scanSum 对列表中的所有项目进行求和,并返回运行总计的列表。因此,scanSum [2,3,4,5] 返回 [2,5,9,14]
  5. diffs 返回相邻项目之间差值的列表。因此,diffs [3,5,6,8] 返回 [2,1,2]。(提示:一种解决方案涉及编写一个辅助函数,该函数接受两个列表并计算对应元素之间的差值。或者,您可以探索具有至少两个元素的列表可以与 (x:y:ys) 模式相匹配的事实。)
前三个函数在 Prelude 中,名为 takedropsum

进一步泛化

[编辑 | 编辑源代码]

在本章中,我们从一个受限于将元素乘以 2 的函数开始。然后,我们认识到,我们可以通过创建 multiplyList 来避免为每个乘数硬编码一个新函数,这样就可以轻松地使用任何 Integer。现在,如果我们想要使用其他运算符(如加法)或计算每个元素的平方,该怎么办?

我们可以使用 Haskell 的关键功能进一步泛化。但是,由于解决方案可能令人意外,我们将以一种迂回的方式来解决这个问题。考虑 multiplyList 的类型签名

multiplyList :: Integer -> [Integer] -> [Integer]

首先要了解的是,类型签名中的 -> 箭头是 右结合 的。这意味着我们可以将此签名理解为

multiplyList :: Integer -> ([Integer] -> [Integer])

我们应该如何理解它?它告诉我们 multiplyList 是一个函数,它接受一个 Integer 参数并计算出另一个函数。因此,返回的函数接受一个 Integer 列表并返回另一个 Integer 列表。

考虑我们以前使用 multiplyList 重新定义的 doubleList 函数

doubleList :: [Integer] -> [Integer]
doubleList xs = multiplyList 2 xs

以这种方式编写,我们可以清楚地消去 `xs`

doubleList = multiplyList 2

这种定义风格(没有参数变量)被称为 无点 风格。我们的定义现在表明,对 multiplyList 应用一个参数不会导致求值失败,而是会得到一个更具体的函数,其类型为 [Integer] -> [Integer],而不是以最终的 [Integer] 值结束。

我们现在看到,Haskell 中的函数的行为与任何其他值非常相似。函数可以返回其他函数,函数可以作为对象独立存在,而无需提及它们的论据。函数似乎几乎与普通常量一样。我们甚至可以将函数本身用作 参数 吗?可以,这就是泛化 multiplyList 的下一步的关键。我们需要一个函数,它接受任何其他合适的函数并将给定的函数应用于列表中的元素

applyToIntegers :: (Integer -> Integer) -> [Integer] -> [Integer]
applyToIntegers _ [] = []
applyToIntegers f (n:ns) = (f n) : applyToIntegers f ns

使用 applyToIntegers,我们可以接受 任何 Integer -> Integer 函数并将其应用于 Integer 列表中的元素。因此,我们可以使用此通用函数重新定义 multiplyList

multiplyList :: Integer -> [Integer] -> [Integer]
multiplyList m = applyToIntegers ((*) m)

这将使用 (*) 函数,并使用单个初始参数创建一个新的函数,该函数准备接受另一个参数(在本用例中,将来自给定列表中的数字)。

柯里化

[编辑 | 编辑源代码]

如果所有这些抽象概念让您感到困惑,请考虑一个具体的例子:当我们在 Haskell 中进行 5 * 7 的乘法时,(*) 函数不会同时接受两个参数,而实际上它会首先接受 5 并返回一个新的 5* 函数;然后,该函数 接受第二个参数,并将其乘以 5。因此,在我们的示例中,我们将 7 作为 5* 函数的参数,然后 该函数 返回最终的计算结果(35)。

因此,Haskell 中的所有函数实际上只接受一个参数。但是,当我们知道为了得到最终结果将生成多少个中间函数时,我们可以将函数 视为 接受多个参数。我们通常所说的函数接受的参数个数实际上是在第一个参数和最终的非函数结果值之间生成的单参数函数的个数。

在将参数输入复杂函数时创建中间函数的过程称为 柯里化(以 Haskell Curry 的名字命名,也是 Haskell 编程语言的同名人物)。

map 函数

[编辑 | 编辑源代码]

虽然 applyToIntegers 的类型为 (Integer -> Integer) -> [Integer] -> [Integer],但定义本身不包含任何特定于整数的内容。要使用相同逻辑处理其他类型的列表,我们可以定义诸如 applyToCharsapplyToStrings 等版本。它们将具有相同的定义,但类型签名不同。我们可以使用泛化的最后一步来避免所有这些冗余:创建一个完全多态的版本,其签名为 (a -> b) -> [a] -> [b]。Prelude 已经有了这个函数,它被称为 map

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

使用 map,我们可以轻松地实现各种不同的函数,例如...

multiplyList :: Integer -> [Integer] -> [Integer]
multiplyList m = map ((*) m)

...以及...

heads :: [[a]] -> [a]
heads = map head
Prelude> heads [[1,2,3,4],[4,3,2,1],[5,10,15]]
[1,4,5]

map 是将函数应用于列表中每个元素的通用解决方案。我们最初的 doubleList 问题只是 map 的一个特例。像 map 这样的接受其他函数作为参数的函数称为 *高阶函数*。在下一章中,我们将学习更多关于列表处理的高阶函数。

练习
  1. 使用 map 来构建函数,这些函数在给定一个 Int 列表 xs 时,返回
    • 一个列表,该列表是 xs 的逐元素取反。
    • 一个 Int 列表的列表 xss,对于 xs 的每个元素,包含 xs 的约数。你可以使用以下函数来获取约数
      divisors p = [ f | f <- [1..p], p `mod` f == 0 ]
    • xss 的逐元素取反。
  2. 实现一个运行长度编码 (RLE) 编码器和解码器。
    • RLE 的思想很简单;给定一些输入
      "aaaabbaaa"
      通过获取每个字符序列的长度来压缩它:(4,'a'), (2, 'b'), (3, 'a')
    • concatgroup 函数可能会有帮助。为了使用 group,请在 ghci 提示符中键入 :m Data.List 或在你的 Haskell 源代码文件中添加 import Data.List 来导入 Data.List 模块。
    • 你的 encodedecode 函数的类型是什么?

提示和技巧

[edit | edit source]

关于 Haskell 中列表的一些杂项说明

点点表示法

[edit | edit source]

Haskell 有一种方便的简写方式来编写等间距整数的有序列表。以下是一些示例来说明它

Code             Result
----             ------
[1..10]          [1,2,3,4,5,6,7,8,9,10]
[2,4..10]        [2,4,6,8,10]
[5,4..1]         [5,4,3,2,1]
[1,3..10]        [1,3,5,7,9]

相同的表示法适用于字符,甚至适用于浮点数。不幸的是,由于舍入误差,浮点数存在问题。试试这个

[0,0.1 .. 1]

注意

.. 表示法 *只* 适用于连续元素之间具有固定差值的序列。例如,你不能写...

[0,1,1,2,3,5,8..100]

... 并期望神奇地得到斐波那契数列的其余部分。[5]


无限列表

[edit | edit source]

由于惰性求值,Haskell 列表可以是 *无限* 的。例如,以下代码生成从 1 开始的无限整数列表

[1..]

(如果你在 GHCi 中尝试这个,请记住你可以在 Ctrl-c 中停止求值)。

相同的效果可以通过递归函数来实现

intsFrom n = n : intsFrom (n + 1) -- note there is no base case!
positiveInts = intsFrom 1

无限列表在实践中很有用,因为 Haskell 的惰性求值在任何给定时刻实际上只求值它需要的部分。在大多数情况下,我们可以将无限列表视为普通的列表。程序只有在求值需要列表中的所有值时才会进入无限循环。因此,我们无法对无限列表进行排序或打印,但是

evens = doubleList [1..]

将定义 "evens" 为无限列表 [2,4,6,8..],然后我们可以将 "evens" 传递给其他函数,这些函数只需要求值列表的一部分即可获得其 *最终* 结果。Haskell 会知道只使用最终需要的无限列表的部分。

与硬编码一个长的有限列表相比,定义一个无限列表,然后取前几个项目通常更方便。无限列表也可以成为交互式程序顶层传统无限循环的便捷替代方案。

关于 headtail 的说明

[edit | edit source]

在使用 ( : ) 模式或 head/tail 来拆分列表时,模式匹配几乎总是更可取的。使用 headtail 可能很诱人,因为它们简单且简洁,但很容易忘记它们在空列表上会失败(运行时崩溃永远不好)。我们确实有一个 Prelude 函数,null :: [a] -> Bool,它对空列表返回 True,对其他列表返回 False,因此这提供了一种安全的方式来检查空列表,而无需模式匹配;但匹配空列表往往比使用 null 的对应 if-then-else 表达式更干净、更清晰。

练习
  1. 关于本章第一组练习的解决方案,scanSum (takeInt 10 [1..])takeInt 10 (scanSum [1..]) 之间有什么区别吗?
  2. 编写函数,当应用于列表时,给出列表的 *最后一个* 元素以及去掉最后一个元素的列表(不使用 reverse)。
    此功能由 Prelude 通过lastinit函数提供。与 headtail 一样,它们在给定空列表时会爆炸。

注释

  1. 如果我们忘记了基本情况,一旦递归到达空列表,(x:xs) 模式匹配就会失败,我们会得到一个错误。
  2. …只要没有计算结果导致错误或不终止,在这种情况下一切正常。
  3. 编译器有时可能会更早地评估一些东西以提高效率。
  4. 一个例外是 *无限列表* (!) 的情况,我们将在短时间内讨论。
  5. http://en.wikipedia.org/wiki/Fibonacci_number
华夏公益教科书