跳转到内容

Haskell/高阶函数

来自 Wikibooks,开放世界中的开放书籍

函数式编程的核心思想是函数就像任何其他值一样。函数式风格的力量来自将函数本身作为普通值处理,即通过将函数传递给其他函数以及从函数返回函数。接受另一个函数(或多个函数)作为参数的函数称为高阶函数。它们几乎可以在任何 Haskell 程序中找到,实际上我们已经遇到了一些,例如map和各种 folds。在讨论列表 II中的map时,我们看到了高阶函数的常见示例。现在,我们将探索一些操作函数的常见代码编写方法。

排序算法

[编辑 | 编辑源代码]

为了有一个具体的例子,我们将考虑对列表进行排序的任务。快速排序是一种众所周知的递归排序算法。要将它的排序策略应用于列表,我们首先选择一个元素,然后将列表的其余部分分成 (A) 应该放在所选元素之前的元素,(B) 等于所选元素的元素,以及 (C) 应该放在之后的元素。然后,我们将相同的算法应用于未排序的 (A) 和 (C) 列表。经过足够的递归排序后,我们将所有内容拼接在一起,得到最终的排序列表。这种策略可以非常简单地转换为 Haskell 实现。

-- Type signature: any list with elements in the Ord class can be sorted.
quickSort :: (Ord a) => [a] -> [a]
-- Base case:
-- If the list is empty, there is nothing to do.
quickSort [] = []

-- The recursive case:
-- We pick the first element as our "pivot", the rest is to be sorted.
-- Note how the pivot itself ends up included in the middle part.
quickSort (x : xs) = (quickSort less) ++ (x : equal) ++ (quickSort more)
    where
        less = filter (< x) xs
        equal = filter (== x) xs
        more = filter (> x) xs

应该指出的是,我们的quickSort相当天真。更有效的实现将避免在每个递归步骤中对filter进行三次遍历,并且不会使用(++)来构建排序列表。此外,与我们的实现不同,原始快速排序算法使用可变性在内存中进行排序。[1] 我们现在将忽略这些问题,因为我们更感兴趣的是排序函数的使用模式,而不是准确的实现。

Haskell 中几乎所有基本数据类型都是Ord类的成员,Ord类用于排序测试,就像Eq类用于相等性测试一样。Ord类定义了给定类型的“自然”排序。它提供了一个名为compare的函数,其类型为

compare :: (Ord a) => a -> a -> Ordering

compare接受两个值并比较它们,返回一个Ordering值,如果第一个值小于第二个值,则为LT;如果相等,则为EQ;如果大于,则为GT。对于Ord类型,(<)、来自Eq(==)(>)可以看作是compare的快捷方式,它们检查三种可能性之一,并返回一个Bool来指示指定的排序是否根据该类型的Ord规范为真。请注意,我们在quickSort定义中使用filter的每个测试都对应于compare的可能结果之一,因此我们可能写过,例如,lessless = filter (\y -> y `compare` x == LT) xs

选择比较方式

[编辑 | 编辑源代码]

使用quickSort,对任何元素位于Ord类中的列表进行排序都很容易。假设我们有一个String列表,我们想要对它们进行排序;我们只需将quickSort应用于该列表。在本章的其余部分,我们将使用一个只有几个词的伪词典(但包含数千个词的词典也能同样有效)。

dictionary = ["I", "have", "a", "thing", "for", "Linux"]

quickSort dictionary返回

["I", "Linux", "a", "for", "have", "thing"]

如您所见,默认情况下会考虑大小写进行排序。Haskell String是 Unicode 字符的列表。Unicode(以及几乎所有其他字符编码)指定大写字母的字符代码小于小写字母的字符代码。因此“Z”小于“a”。

为了获得正确的类似词典的排序,我们需要一个不区分大小写的quickSort。为了实现这一点,我们可以从上面compare的讨论中获得提示。quickSort的递归情况可以改写为

quickSort compare (x : xs) = (quickSort compare less) ++ (x : equal) ++ (quickSort compare more)
    where
        less  = filter (\y -> y `compare` x == LT) xs
        equal = filter (\y -> y `compare` x == EQ) xs
        more  = filter (\y -> y `compare` x == GT) xs

虽然这个版本不如原始版本整洁,但它表明元素的排序完全取决于compare函数。这意味着我们只需要用我们选择的(Ord a) => a -> a -> Ordering函数替换compare即可。因此,我们更新的quickSort'是一个高阶函数,它接受一个比较函数和要排序的列表。

quickSort' :: (Ord a) => (a -> a -> Ordering) -> [a] -> [a]
-- No matter how we compare two things the base case doesn't change,
-- so we use the _ "wildcard" to ignore the comparison function.
quickSort' _ [] = []

-- c is our comparison function
quickSort' c (x : xs) = (quickSort' c less) ++ (x : equal) ++ (quickSort' c more)
    where
        less  = filter (\y -> y `c` x == LT) xs
        equal = filter (\y -> y `c` x == EQ) xs
        more  = filter (\y -> y `c` x == GT) xs

我们可以重复使用我们的quickSort'函数来服务于许多不同的目的。

如果我们想要降序,我们只需使用reverse (quickSort dictionary)反转我们最初排序的列表。然而,为了真正按降序进行初始排序,我们可以为quickSort'提供一个比较函数,该函数返回通常的Ordering的反面。

-- the usual ordering uses the compare function from the Ord class
usual = compare

-- the descending ordering, note we flip the order of the arguments to compare
descending x y = compare y x

-- the case-insensitive version is left as an exercise!
insensitive = ... 
-- How can we do case-insensitive comparisons without making a big list of all possible cases?

注意

Data.List提供了一个用于排序列表的sort函数。它没有使用快速排序;相反,它使用了对称为归并排序的算法的有效实现。Data.List还包括sortBy,它接受一个自定义比较函数,就像我们的quickSort'一样。


练习
编写insensitive,使得quickSort' insensitive dictionary给出["a", "for", "have", "I", "Linux", "thing"]

高阶函数和类型

[编辑 | 编辑源代码]

柯里化(在走向最终结果的途中生成中间函数)的概念最早在早期的“列表 II”一章中介绍。这是一个回顾柯里化工作原理的好地方。

我们的quickSort'的类型为(a -> a -> Ordering) -> [a] -> [a]

大多数情况下,高阶函数的类型提供了一个使用它的指南。阅读类型签名的一种直接方法是“quickSort'首先接受一个函数作为参数,该函数给出两个a的排序。它的第二个参数是一个a列表。最后,它返回一个新的a列表”。这足以正确地猜测它使用给定的排序函数对列表进行排序。

请注意,围绕a -> a -> Ordering的括号是必须的。它们指定a -> a -> Ordering构成单个参数,该参数恰好是一个函数。

如果没有括号,我们将得到a -> a -> Ordering -> [a] -> [a],它接受四个参数(没有一个是函数本身),而不是我们想要的两个,并且它不会按预期工作。

请记住,->运算符是右结合的。因此,我们错误的类型签名a -> a -> Ordering -> [a] -> [a]a -> (a -> (Ordering -> ([a] -> [a])))含义相同。

鉴于->是右结合的,正确quickSort'签名的显式分组版本实际上是(a -> a -> Ordering) -> ([a] -> [a])。这非常合理。我们最初缺少可调整比较函数参数的quickSort的类型为[a] -> [a]。它接受一个列表并对其进行排序。我们新的quickSort'只是一个生成quickSort风格函数的函数!如果我们为(a -> a -> Ordering)部分插入compare,那么我们只是返回我们最初的简单quickSort函数。如果我们对参数使用不同的比较函数,我们会生成一个不同quickSort函数变体。

当然,如果我们不仅给出一个比较函数作为参数,而且还输入一个实际要排序的列表,那么最终的结果就不是新的quickSort风格函数;相反,它会继续并将列表传递给新函数,并返回排序后的列表作为我们的最终结果。


练习

(具有挑战性) 以下练习结合了您已经了解的高阶函数、递归和 I/O。我们将重新创建在命令式语言中称为for 循环的东西。实现一个函数

for :: a -> (a -> Bool) -> (a -> a) -> (a -> IO ()) -> IO ()
for i p f job = -- ???

使用此函数的示例可能是

for 1 (<10) (+1) print

它在屏幕上打印数字 1 到 9。

for 的预期行为是:从初始值i开始,for 首先检查p i,如果它评估为真,则执行job i,否则停止,什么也不做。如果执行了job i,它将使用f修改此值,并检查修改后的值f i是否满足某些条件p。如果不满足,它会停止;否则,for 循环会继续,使用修改后的f i 代替i

  1. 在 Haskell 中实现 for 循环。
  2. 上面的段落给出了 for 循环的命令式描述。用更函数式的术语描述您的实现。

一些更具挑战性的练习,你可以尝试一下

  1. 考虑一个像“打印从 1 到 10 的数字列表”这样的任务。鉴于 print 是一个函数,并且我们想要将其应用于一个数字列表,使用 map 听起来是自然而然的做法。但是,它真的能起作用吗?
  2. 实现一个函数 sequenceIO :: [IO a] -> IO [a]。给定一个动作列表,此函数按顺序运行每个动作,并将所有结果作为列表返回。
  3. 实现一个函数 mapIO :: (a -> IO b) -> [a] -> IO [b],它接受一个类型为 a -> IO b 的函数和一个类型为 [a] 的列表,对列表中的每个元素运行该动作,并返回结果。
这个练习的灵感来自 osfameron 的一篇博文。不要偷看!

函数操作

[编辑 | 编辑源代码]

我们将通过讨论一些常见且有用的通用高阶函数的示例来结束本章。熟悉这些将大大提高你在编写和阅读 Haskell 代码方面的技能。

翻转参数

[编辑 | 编辑源代码]

flip 是一个方便的小型 Prelude 函数。它接受一个具有两个参数的函数,并返回具有相同函数但参数交换后的版本。

flip :: (a -> b -> c) -> b -> a -> c

flip 在使用中

Prelude> (flip (/)) 3 1
0.3333333333333333
Prelude> (flip map) [1,2,3] (*2)
[2,4,6]

我们可以使用 flip 来编写 quickSort 示例中 descending 比较函数的无点版本

descending = flip compare

当我们想将一个具有两个不同类型参数的函数传递给另一个函数,而这些参数相对于高阶函数的签名处于错误顺序时,flip 特别有用。

(.) 组合运算符是另一个高阶函数。它具有以下签名

(.) :: (b -> c) -> (a -> b) -> a -> c

(.) 接受两个函数作为参数,并返回一个新函数,该函数将第二个函数应用于参数,然后是第一个函数。(将 (.) 的类型写为 (b -> c) -> (a -> b) -> (a -> c) 可以使这更容易理解。)

组合和高阶函数提供了一系列强大的技巧。作为一个小示例,首先考虑 inits 函数,它在 Data.List 模块中定义。引用文档,它“返回参数的所有初始段,最短的放在最前面”,因此

Prelude Data.List> inits [1,2,3]
[[],[1],[1,2],[1,2,3]]

我们可以使用 Prelude 中的以下高阶函数提供 inits 的单行实现(为增加戏剧效果而写成无点形式):flipscanl(.)map

myInits :: [a] -> [[a]]
myInits = map reverse . scanl (flip (:)) []

一开始,吞下一个如此简练的定义可能看起来令人生畏,因此请慢慢地,一点一点地分析它,回忆起每个函数的作用,并将类型签名作为指导。

myInits 的定义非常简洁和干净,括号使用量保持在最低限度。当然,如果一个人过度使用组合,写出很长的 (.) 链,事情就会变得混乱;但是,当合理部署时,这些无点风格就显得格外出色。此外,实现本身相当“高层次”:我们没有明确处理诸如模式匹配或递归之类的细节;我们部署的函数——包括高阶函数及其函数参数——负责处理这样的底层实现细节。

($) 是一个奇特的高阶运算符。它的类型是

($) :: (a -> b) -> a -> b

它将函数作为其第一个参数,它所做的全部是将函数应用于第二个参数,因此,例如,(head $ "abc") == (head "abc")

你可能会认为 ($) 完全没有用!但是,关于它有两个有趣的地方。首先,($) 的优先级非常低,[2]与具有最高优先级的普通函数应用不同。实际上,这意味着我们可以通过使用 $ 来打破优先级,从而避免令人困惑的括号嵌套。我们编写了一个 myInits 的非无点版本,而无需添加新的括号

myInits :: [a] -> [[a]]
myInits xs = map reverse . scanl (flip (:)) [] $ xs

此外,由于 ($) 只是一个恰好应用函数的函数,而函数只是值,所以我们可以编写像这样的有趣表达式

map ($ 2) [(2*), (4*), (8*)]

(是的,这是一个函数列表,它是完全合法的。)

uncurrycurry

[编辑 | 编辑源代码]

顾名思义,uncurry 是一个撤销柯里化的函数;也就是说,它将一个具有两个参数的函数转换为一个将一对作为其唯一参数的函数。

uncurry :: (a -> b -> c) -> (a, b) -> c
Prelude> let addPair = uncurry (+)
Prelude> addPair (2, 3)
5

uncurry 在野外偶尔看到的一个有趣的用法是与 ($) 相结合,这样一来,对的第一个元素就应用于第二个元素。

Prelude> uncurry ($) (reverse, "stressed")
"desserts"

还有 curry,它与 uncurry 相反。

curry :: ((a, b) -> c) -> a -> b -> c
Prelude> curry addPair 2 3 -- addPair as in the earlier example.
5

由于大多数 Haskell 函数已经柯里化,因此 curry 远不如 uncurry 常见。

idconst

[编辑 | 编辑源代码]

最后,我们应该提一下两个函数,虽然它们本身不是高阶函数,但最常作为高阶函数的参数使用。id,即恒等函数,是一个类型为 a -> a 的函数,它会返回其参数不变。

Prelude> id "Hello"
"Hello"

id 相似,const 是一个 a -> b -> a 函数,其工作方式如下

Prelude> const "Hello" "world"
"Hello"

const 接受两个参数,丢弃第二个参数并返回第一个参数。当被视为一个参数的函数时,a -> (b -> a),它返回一个常数函数,无论传入什么参数,它始终返回相同的值。

idconst 一开始可能看起来毫无用处。但是,在处理高阶函数时,有时需要传递一个虚拟函数,无论是对参数不进行任何操作的函数,还是始终返回相同值的函数。idconst 为我们提供了用于此类情况的便捷虚拟函数。

练习
  1. 编写 curryuncurryconst 的实现。
  2. 在不测试它们的情况下描述以下函数的作用
    • uncurry const
    • curry fst
    • curry swap,其中 swap :: (a, b) -> (b, a) 交换一对元素。(swap 可以在 Data.Tuple 中找到。)
  3. (非常难) 使用 foldr 来实现 foldl。提示:首先回顾 列表 III 中关于 foldrfoldl 的部分。有两种解决方案;一种比较简单,但相对乏味,而另一种则真正有趣。对于有趣的解决方案,仔细考虑如何将列表中的所有函数组合起来。

注释

  1. 真正的、就地快速排序可以在 Haskell 中完成,但这需要一些我们将不在初学者教程中讨论的相当高级的工具。
  2. 作为提醒,这里的优先级与数学中 * 优先级高于 + 的含义相同。
华夏公益教科书