Haskell的思考方式
早前对Haskell的学习有两方面。第一个方面是把意识中的命令式语言术语转换成函数式的:我们必须把其他语言中的编程习惯替换掉。这样做不是因为命令式语言不好,而是因为在函数式语言中其他的技术会做的更好。
另一方面挑战是学习标准库的使用。在任何语言中,库都像杠杆一样倍增我们解决问题的能力。Haskell的库比其他语言的更加倾向于操作更高层抽象。学习它们需要多付出些努力,不过它们能提供更强大的能力。
在本章,将介绍一些一般的函数式编程技术。我们会通过来自命令式语言中的例子来强调需要做的思路转换。这么做时会涉及一些Haskell标准库的基础。过程中也会停下来介绍些语言的更多特性。
In this chapter, we'll introduce a number of common functional programming techniques. We'll draw upon examples from imperative languages to highlight the shift in thinking that we'll need to make. As we do so, we'll walk through some of the fundamentals of Haskell's standard libraries. We'll also intermittently cover a few more language features along the way. 1 comment
简单的命令行框架
在本章大部分时候只需要关注与外部世界没有交互的代码。为保持对实际代码的关注,先开发一个“纯”的代码与外部世界之间的交互桥梁。我们的框见简单的读取一个文件的内容,应用一个函数到文件上,之后把结果写到另一个文件中。
-- file: ch04/InteractWith.hs
-- Save this in a source file, e.g. Interact.hs
import System.Environment (getArgs)
interactWith function inputFile outputFile = do
input <- readFile inputFile
writeFile outputFile (function input)
main = mainWith myFunction
where mainWith function = do
args <- getArgs
case args of
[input,output] -> interactWith function input output
_ -> putStrLn "error: exactly two arguments needed"
-- replace "id" with the name of our function below
myFunction = id
这是写一个简单但完整的文件处理程序的全部。可以把它编译成一个名为 InteractWith 的可执行程序。
$ ghc --make InteractWith
[1 of 1] Compiling Main ( InteractWith.hs, InteractWith.o )
Linking InteractWith ...
如果从shell或者命令行上执行它,它接受两个文件名:要读取的文件名和要写入的文件名。
$ ./Interact
error: exactly two arguments needed
$ ./Interact hello-in.txt hello-out.txt
$ cat hello-in.txt
hello world
$ cat hello-out.txt
hello world
源代码中有些新的记号。do关键字引入一个动作块可以造成对实际世界的副作用,例如读或者写文件。 <- 操作符是do程序块中赋值的等价物。这些解释足够让我们开始了。将在第7章 "I/O"中详细介绍这些记号和 I/O的一般操作。
遥测是一个不能与外部世界对话的函数,只要简单的把要测试的函数替换掉上面程序中名为 id 的函数。不管我们的函数做什么,它的类型必须是 String -> String:也就是说它接受一个字符串,返回结果也是一个字符串。
热身:可移植的分割文本行函数
Haskell提供了一个内建函数 lines 可以用来按行分割文本字符串。它返回去除了行结束符的字符串列表。
ghci> :type lines
lines :: String -> [String]
ghci> lines "line 1\nline 2"
["line 1","line 2"]
ghci> lines "foo\n\nbar\n"
["foo","","bar"]
虽然lines函数很有用,不过它依赖于用"文本模式"读取文件来工作。文本模式在很多编程语言中是很常见的特性:在Windows系统上读写文件时提供特别的行为。用文本模式读文件时,文件I/O库会把每行结尾的"\r\n"序列(回车后跟换行)转换成 "\n"(换行),在写文件时则相反。在Unix类系统上,文本模式不会进行这种转换。这个差别造成的结果是,如果在一个平台上读取另一个平台中生成的文件,行尾可能变成一团糟。(readFile和writeFile 都工作在文本模式)。
ghci> lines "a\r\nb"
["a\r","b"]
lines函数只在换行符处分割字符串,而不管行尾的回车符。如果在Linux或Unix上读取Windows上生成的文本文件,会在每一行最后跟一个回车符。
我们已经很舒服的使用Python中的“通用换行符”支持很久了:它可以透明的为我们处理Unix和Windows行尾约定。我们想要在Haskell中提供与此类似的东西。
因为刚刚开始Haskell代码的阅读,我们会细致的讨论Haskell实现的细节。
-- file: ch04/SplitLines.hs
splitLines :: String -> [String]
我们的函数的类型签名指出它接受单独一个字符串:未知行尾约定的文件内容。返回表示文件每一行的字符串的列表。
-- file: ch04/SplitLines.hs
splitLines [] = []
splitLines cs =
let (pre, suf) = break isLineTerminator cs
in pre : case suf of
('\r':'\n':rest) -> splitLines rest
('\r':rest) -> splitLines rest
('\n':rest) -> splitLines rest
_ -> []
isLineTerminator c = c == '\r' || c == '\n'
深入细节之前,现注意看下我们如何组织代码。先把重要部分的代码放在前面,把 isLineTerminator 的定义放到最后。因为对辅助函数命名,使得可以在读到它的定义之前就猜到它的用途,这可以让阅读代码时更顺畅。
Prelude 模块中定义了名为break的函数,可以用来把列表分为两个部分。它需要一个函数来检查列表中的元素,通过返回True的来指出是否要在这个元素位置上切分列表。break函数返回一个数据对,由判断函数返回True之前的子列表(pre) 和列表剩下的部分(suf)组成。
ghci> break odd [2,4,5,6,8]
([2,4],[5,6,8])
ghci> :module +Data.Char
ghci> break isUpper "isUpper"
("is","Upper")
因为一次只需要匹配一个单独的回车或者换行,因此一次检查列表中一个元素就足够满足需要了。
splitLines 的第一个等式指出,如果匹配到空字符串就不需要再做什么了。
在第二个表达式中,先对输入字符串应用break。pre 是行结束符之前的子串,suf 是字符串剩下的部分。如果存在后半部分的话,其中将包含行结束符。
"pre :" 表达式告诉我们将把 pre 的值加到行列表的开头。通过用case表达式来查看剩余的部分,来决定下面如何做。case表达式的结果讲座为列表构造子 (:) 的第二个参数。
第一个模式匹配一个由回车和换行开始的字符串。变量rest绑定到字符串其余的部分。其他的模式类似,很容易看明白。
对Haskell程序的口头描述不一定容易看懂。我们可以一步步使用ghci来获得更好的理解,并探测函数在不同情形下的行为。
先从一个不包含行结束符的字符串开始。
ghci> splitLines "foo"
["foo"]
这里,break函数的应用没有找到行结束符,因此后 suf 返回为空。
ghci> break isLineTerminator "foo"
("foo","")
这样splitLines 中的case表达式一定在第四个分支上匹配,执行结束。更有趣些的情况如何呢?
ghci> splitLines "foo\r\nbar"
["foo","bar"]
break的第一次应用返回一个非空的后缀。
ghci> break isLineTerminator "foo\r\nbar"
("foo","\r\nbar")
因为后缀以回车符开始,后跟一个换行符,这与case表达式的第一个分支匹配。因此pre绑定到 "foo" 而 suf 绑定到 "bar"。递归的调用 splitLines,这一次在 "bar"上。
ghci> splitLines "bar"
["bar"]
结果是我们构造了一个列表,表头是 "foo" 而表尾是 ["bar"]。
ghci> "foo" : ["bar"]
["foo","bar"]
这种用ghci做的试验是理解和调试代码块的好方法。还有更重要的,像是偶然发生的一个好处。在ghci种测试复杂的代码非常困难,因此我们倾向于写更小些的函数。这更进一步促进了代码的可读性。
创建并复用小而强大的代码块的风格是函数式编程的一个基础部分。
行结尾转换程序
把我们的splitLines函数挂接到之前写的小框架中。复制源文件Interact.hs到新文件 FixLines.hs中。把splitLines函数加到新的源文件中。因为我们的函数必须生成一个单独的字符串,因此必须把行的列表重新组合在一起。 Prelude 模块提供了一个 unlines 函数来连接字符串的列表,每行结尾增加一个换行符。
-- file: ch04/SplitLines.hs
fixLines :: String -> String
fixLines input = unlines (splitLines input)
如果把id函数换成 fixLines,就可以编译一个可执行程序,它可以把文本转换成系统本地的换行方式。
$ ghc --make FixLines
[1 of 1] Compiling Main ( FixLines.hs, FixLines.o )
Linking FixLines ...
如果在Windows系统上,下载一个Unix系统上创建的文本(比如 gpl-3.0.txt)。用标准的记事本程序打开它。所有的行应该都跑一起去了,根本没法读。用你刚刚创建的 FixLines 命令处理下这个文件,再用记事本打开输出的文件。行结尾负应该已经被修正了。
在Unix类的系统上,标准的分页器和编辑器会隐藏Windows的换行。因此验证FixLines是否真的去掉了它们有些困难。这里的一些命令可以帮忙。
$ file gpl-3.0.txt
gpl-3.0.txt: ASCII English text
$ unix2dos gpl-3.0.txt
unix2dos: converting file gpl-3.0.txt to DOS format ...
$ file gpl-3.0.txt
gpl-3.0.txt: ASCII English text, with CRLF line terminators
中缀函数
通常在Haskell中定义和使用函数时,写出函数名,后跟它的参数。这种是前缀写法,因为函数的名字在它的参数之前。
如果一个函数或者构造子取两个或更多的参数,可以选择用中缀格式把它放到第一个和第二个参数中间。这可以让我们把函数当作中缀操作符来用。
要用中缀格式定义或使用函数或值构造子,用反引号把它的名字括起来。这里是函数和类型的中缀定义。
-- file: ch04/Plus.hs
a `plus` b = a + b
data a `Pair` b = a `Pair` b
deriving (Show)
-- 可以把构造子按前缀或者中缀格式使用
foo = Pair 1 2
bar = True `Pair` "quux"
因为中缀记号只是单纯的语法便利,它不会改变函数的行为。
ghci> 1 `plus` 2
3
ghci> plus 1 2
3
ghci> True `Pair` "something"
True `Pair` "something"
ghci> Pair True "something"
True `Pair` "something"
中缀记号
用中缀记号经常可以提高可读性。例如,Prelude模块中定义了一个函数 elem,它支持列表中是否出现某个值。如果用前缀记号来使用elem,很容易读。
ghci> elem 'a' "camogie"
True
如果换成中缀记号的话,代码更容易懂了。现在可以更清楚的表示检查左侧的值是否出现在右侧的列表中。
ghci> 3 `elem` [1,2,4,8]
False
Data.List 模块中的很多有用的函数可以因此显著提升可读性。 isPrefixOf函数告诉我们是否一个列表与另一个列表的开头匹配。
ghci> :module +Data.List
ghci> "foo" `isPrefixOf` "foobar"
True
isInfixOf 和 isSuffixOf 分别匹配一个列表中的任意位置和结尾位置。
ghci> "needle" `isInfixOf` "haystack full of needle thingies"
True
ghci> "end" `isSuffixOf` "the end"
True
没有一个硬性直接的规则指出是该用中缀还是前缀写法,虽然前缀写法要更常见。最好是选择让你的代码看上去更易读的那种。
[Note] 注意不熟悉的语言中的相似写法
其他一些编程语言中也使用反引号,但是除了看上去相似外,Haskell中的反引号的目的与其他如 Perl, Python, Unix shell 中的意思并不相同。
反引号在Haskell里面唯一的用处是包起函数名。我们不能把一个值为函数的复杂的表达式用反引号括起来。或许这样可以很方便,但是现在这门语言还不允许这样。
处理列表
列表就像函数式编程中空气和水一样重要,理应获得特别的关注。标准Prelude中定义了好几打的列表处理函数。它们中很多都是不可或缺的工具,所以尽早学习它们是很重要的。
不论好坏,这一节的内容读起来好像是函数的清单一样。为什么要一次列出这么多的函数呢?因为这些函数都很易学并且几乎无处不在。如果我们没把这些放到手边的话,最终会重新发明轮子,把时间浪费在重写标准库中已有的简单函数上。因此容忍这样一个大的列表,将会给你节省很多。
Data.List 模块是所有标准列表函数的“真正”的所在地。Prelude模块只是把Data.List 模块中导出的函数子集重新导出了。Data.List中一些有用的函数没有在标准prelude中重导出。在介绍下面的列表函数时,会明确指出哪些函数只存在于Data.List中。
ghci> :module +Data.List
因为这些函数都不复杂,没有那个函数会超过3行Haskell代码,因此会简明的介绍每一个。实际上,在你读了它们后重新写出它们的定义是很好的练习。
基本列表操作
length函数给出列表元素数目。
ghci> :type length
length :: [a] -> Int
ghci> length []
0
ghci> length [1,2,3]
3
ghci> length "strings are lists, too"
22
要确定一个列表是否为空,用null函数。
ghci> :type null
null :: [a] -> Bool
ghci> null []
True
ghci> null "plugh"
False
要访问列表的第一个元素,用 head 函数。
ghci> :type head
head :: [a] -> a
ghci> head [1,2,3]
1
相反,tail 返回列表除了表头外的部分。
ghci> :type tail
tail :: [a] -> [a]
ghci> tail "foo"
"oo"
另一个函数 last,返回列表最后一个元素。
ghci> :type last
last :: [a] -> a
ghci> last "bar"
'r'
与last相对的是 init,返回输入的列表中除了最后一个元素的部分。
ghci> :type init
init :: [a] -> [a]
ghci> init "bar"
"ba"
上面一些函数不能处理空列表,所以如果不知道列表是否为空的话要当心。执行失败会是什么样子呢?
ghci> head []
*** Exception: Prelude.head: empty list
在ghci里面尝试上面的每一个函数。用空列表调用时哪些会出错?
安全合理的处理可能出错的函数
当我们使用head这样的函数时,我们知道如果给他传入空的列表,它会出错,因此调用head前先检查列表的长度具有很大的诱惑。我们构造一个例子来演示我们的想法。
-- file: ch04/EfficientList.hs
myDumbExample xs = if length xs > 0
then head xs
else 'Z'
如果我们从Perl 或者 Python语言转过来的话,这看上去是完美的方法。在幕后,Python的列表是数组;Perl也是数组。因此它们必须知道数组的长度,调用 len(foo) 或者 scalar(@foo) 是很自然的。但是像很多其他事情一样,盲目的把这些假设带入Haskell并不是一个好主意。
我们已经见过列表的代数数据类型的定义很多次了,列表并不显式的存储它自己的长度。这样,length唯一能做的就是遍历整个列表。
因此,当我们只是想知道列表是否为空时,调用length并不是一个好的策略。如果操作的列表是有限长度的,它会潜在的做很多我们不需要的工作。由于Haskell让我们很容易创建无限列表,对length粗心的使用还会导致无限循环。
这里恰当的函数是null,它的执行时间是常数级的。更好的是,用null可以让我们更好的指出代码实际关心的列表属性是什么。这里是表示myDumbExample的两种改进。
-- file: ch04/EfficientList.hs
mySmartExample xs = if not (null xs)
then head xs
else 'Z'
myOtherExample (x:_) = x
myOtherExample [] = 'Z'
部分和全部函数
只返回输入数据的一个子集的函数称为部分函数(调用错误不算返回值)。把全部输入的结果返回的函数称为全部函数。
知道正在用的函数是部分还是全部函数总是很有用的。用无法处理的输入调用一个部分函数或许是Haskell程序中最大的bug来源。
有些Haskell程序员更进一步把这些函数用诸如 unsafe 这样的前缀来命名,以避免偶然用错。
在标准Prelude模块中定义了相当多“unsafe”的部分函数,但是没有给出相应的“safe”的完全函数。
更简单的列表操作
Haskell中的“追加”函数名为 (++)。
ghci> :type (++)
(++) :: [a] -> [a] -> [a]
ghci> "foo" ++ "bar"
"foobar"
ghci> [] ++ [1,2,3]
[1,2,3]
ghci> [True] ++ []
[True]
concat 函数取一个列表的列表,所有的列表具有相同的类型,并把它们连接成一个单独的列表。
ghci> :type concat
concat :: [[a]] -> [a]
ghci> concat [[1,2,3], [4,5,6]]
[1,2,3,4,5,6]
它去除了一层嵌套。
ghci> concat [[[1,2],[3]], [[4],[5],[6]]]
[[1,2],[3],[4],[5],[6]]
ghci> concat (concat [[[1,2],[3]], [[4],[5],[6]]])
[1,2,3,4,5,6]
reverse函数返回列表元素的倒序列表。
ghci> :type reverse
reverse :: [a] -> [a]
ghci> reverse "foo"
"oof"
对Bool 的列表,and 和 or 函数把它们取两个参数的表亲 (&&) 和 (||) 应用到整个列表上。
ghci> :type and
and :: [Bool] -> Bool
ghci> and [True,False,True]
False
ghci> and []
True
ghci> :type or
or :: [Bool] -> Bool
ghci> or [False,False,False,True,False]
True
ghci> or []
False
它们还有更有用的表亲 all 和 any,它们可以操作任意类型的列表。它们取一个判断函数作为第一个参数;如果判断函数在列表的每个元素上都成功的话, all 返回True。而只要有一个列表上的元素判断成功,any就会返回True。
ghci> :type all
all :: (a -> Bool) -> [a] -> Bool
ghci> all odd [1,3,5]
True
ghci> all odd [3,1,4,1,5,9,2,6,5]
False
ghci> all odd []
True
ghci> :type any
any :: (a -> Bool) -> [a] -> Bool
ghci> any even [3,1,4,1,5,9,2,6,5]
True
ghci> any even []
False
处理子列表
在“函数应用”一节中已经见过的take函数,它返回一个列表的前 k 个元素组成的子列表。相反,drop函数去掉列表开头的k个元素。
ghci> :type take
take :: Int -> [a] -> [a]
ghci> take 3 "foobar"
"foo"
ghci> take 2 [1]
[1]
ghci> :type drop
drop :: Int -> [a] -> [a]
ghci> drop 3 "xyzzy"
"zy"
ghci> drop 1 []
[]
splitAt 函数组合了take和drop函数,把输入的列表在给定的下标处截成两段,并返回一对子列表。
ghci> :type splitAt
splitAt :: Int -> [a] -> ([a], [a])
ghci> splitAt 3 "foobar"
("foo","bar")
takeWhile 和 dropWhile 函数取一个判断条件:takeWhile 在判断条件返回True时从列表的开头取元素,而 dropWhile 在判断条件返回True时从列表的开头去掉元素。
ghci> :type takeWhile
takeWhile :: (a -> Bool) -> [a] -> [a]
ghci> takeWhile odd [1,3,5,6,8,9,11]
[1,3,5]
ghci> :type dropWhile
dropWhile :: (a -> Bool) -> [a] -> [a]
ghci> dropWhile even [2,4,6,7,9,10,12]
[7,9,10,12]
splitAt 将take和drop的结果用元组返回,而 break 函数(在“热身:可移植的文本分行”一节见过这个函数)和 span 函数把 takeWhile 和 dropWhile 的结果用元组返回。
每个函数取一个判断条件;当判断失败时break函数消耗输入,而span在判断成功时消耗输入。
ghci> :type span
span :: (a -> Bool) -> [a] -> ([a], [a])
ghci> span even [2,4,6,7,9,10,11]
([2,4,6],[7,9,10,11])
ghci> :type break
break :: (a -> Bool) -> [a] -> ([a], [a])
ghci> break even [1,3,5,6,8,9,10]
([1,3,5],[6,8,9,10])
搜索列表
我们已经看到,elem函数可以指出一个值是否出现在列表中。它有一个配对函数 notElem。
ghci> :type elem
elem :: (Eq a) => a -> [a] -> Bool
ghci> 2 `elem` [5,3,2,1,1]
True
ghci> 2 `notElem` [5,3,2,1,1]
False
对于更通用的搜索,filter函数取一个判断条件,返回列表中每一个判断成功的元素。
ghci> :type filter
filter :: (a -> Bool) -> [a] -> [a]
ghci> filter odd [2,4,1,3,6,8,5,7]
[1,3,5,7]
Data.List 中有三个判断 isPrefixOf, isInfixOf 和 isSuffixOf,可以让我们判断一个子列表是否出现在一个更大的列表中。最简单的使用方式是中缀写法。
isPrefixOf 函数告诉我们它左侧参数是否匹配右侧参数的开始部分。
ghci> :module +Data.List
ghci> :type isPrefixOf
isPrefixOf :: (Eq a) => [a] -> [a] -> Bool
ghci> "foo" `isPrefixOf` "foobar"
True
ghci> [1,2] `isPrefixOf` []
False
isInfixOf 函数指出它的左侧参数是否是右侧列表的子列表。
ghci> :module +Data.List
ghci> [2,6] `isInfixOf` [3,1,4,1,5,9,2,6,5,3,5,8,9,7,9]
True
ghci> "funk" `isInfixOf` "sonic youth"
False
isSuffixOf 操作不需要任何解释了。
ghci> :module +Data.List
ghci> ".c" `isSuffixOf` "crashme.c"
True
一次操作多个列表
zip函数取两个列表并把它们像“拉链”那样合并成一个单独的数对的列表。结果列表的长度与输入列表中较短的那个相等。
ghci> :type zip
zip :: [a] -> [b] -> [(a, b)]
ghci> zip [12,72,93] "zippity"
[(12,'z'),(72,'i'),(93,'p')]
更有用的是 zipWith 函数,它把两个列表元素组合的数对应用一个函数,生成的列表与输入中较短的列表长度相同。
ghci> :type zipWith
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
ghci> zipWith (+) [1,2,3] [4,5,6]
[5,7,9]
Haskell的类型系统使得写一个可变参数数量的函数很有挑战性。因此如果你想要 zip 三个列表,要调用 zip3 或者 zipWith3,更多的一直到 zip7 和 zipWith7.
特别的字符串处理函数
在“热身:可移植的分割文本行函数”一节,我们遇到了标准分行函数,以及与之相对的 unlines。注意 unlines 总是在它的结果最末尾增加一个换行。
ghci> lines "foo\nbar"
["foo","bar"]
ghci> unlines ["foo", "bar"]
"foo\nbar\n"
word函数把输入字符串以任意空白分割。与之相对的是 unwords,它把词的列表用一个空格合并起来。
ghci> words "the \r quick \t brown\n\n\nfox"
["the","quick","brown","fox"]
ghci> unwords ["jumps", "over", "the", "lazy", "dog"]
"jumps over the lazy dog"
练习
1. 写一个自己“safe”版本的部分列表函数,保证它们永远不会失败。作为提示,你可以考虑用下面的类型。
-- file: ch04/ch04.exercises.hs
safeHead :: [a] -> Maybe a
safeTail :: [a] -> Maybe [a]
safeLast :: [a] -> Maybe a
safeInit :: [a] -> Maybe [a]
2. 写一个类似于words的splitWith函数,它取一个断言和一个任意类型的列表,它在每个断言返回 False的元素上分割列表。
-- file: ch04/ch04.exercises.hs
splitWith :: (a -> Bool) -> [a] -> [[a]]
3. 用前面“简单的命令行框架”一节中介绍的框架写一个程序,把输入的每一行的第一个单词输出出来。
4. 写一个程序来“转置”一个文件中的文本。例如,它应该把 "hello\nworld\n" 转换成 "hw\neo\nlr\nll\nod\n"。
如何理解循环
不像传统的语言那样,Haskell既没有for循环也没有while循环。如果有大量数据要处理,如何替代呢?有一些可能的答案。
显式递归
要从带循环的语言转到没有它的语言中,最直接的办法是看一些列子,看看不同点在哪。这是一个C的函数,它取一个数字的字符串,把它转成一个整数。
int as_int(char *str)
{
int acc; /* accumulate the partial result */
for (acc = 0; isdigit(*str); str++) {
acc = acc * 10 + (*str - '0');
}
return acc;
}
Haskell里没有循环结构,如何才能表示这种很直接的代码呢?
我们并不一定非要用类型签名开头,不过它可以帮助提醒我们正在干什么。
-- file: ch04/IntParse.hs
import Data.Char (digitToInt) -- we'll need ord shortly
asInt :: String -> Int
C代码遍历字符串的时候增量计算结果,Haskell的代码也可以。不过在Haskell里,我们可以用函数来表示循环的等价物。我们称其为 loop,只是为了保持漂亮和清晰。
-- file: ch04/IntParse.hs
loop :: Int -> String -> Int
asInt xs = loop 0 xs
loop的第一个参数是要用的累积器变量。给他传入0相当于在C程序循环开始时给 acc 变量进行初始化。
钻进实际的代码前,我们闲来考虑下要操作的数据。我们熟悉的 String 只是 [Char] 的同义词,一个字符的列表。要正确的遍历它最简单的办法是考虑列表的结构:要么是空的,要么是一个单独的元素后跟列表的剩余部分。
可以直接用列表的类型构造子的模式匹配来表达这种结构化思路。先考虑简单的情况比较方便:这里意味着空列表。
-- file: ch04/IntParse.hs
loop acc [] = acc
一个空的列表不只意味着“输入字符串为空”;它也是遍历一个非空字符串结束时所遇到的情况。因此当遇到空字符串时并不“错误退出”。而是做一些合理的动作。在这里合理的动作时结束循环,并把累积值返回。
要考虑的其他情况是输入字符串不为空。需要处理列表当前的元素以及列表剩余的部分。
-- file: ch04/IntParse.hs
loop acc (x:xs) = let acc' = acc * 10 + digitToInt x
in loop acc' xs
计算出累积器的新值,命名为 acc'。然后再次调用 loop 函数,传入更新过的 acc' 值和输入列表剩余的部分。这相当于在C 中开始了一轮新的循环。
[Note] 变量名中的单引号
记住在Haskell中单引号是合法的变量名字符,发音为"prime"。在Haskell程序中涉及到变量时有一个习惯,如一个变量叫foo,而另一个叫 foo'。则通常可以假定他与 foo 有某种联系。经常是 foo 的新值,如上面的代码。
有时会看到扩展的写法,如foo''。但是记住名字后面跟了几个单引号很令人讨厌,还好一行中超过两个单引号的情况极少。实际上甚至一个单引号都容易忘掉,读者会被搞糊涂。因此用单引号的写法认识就可以了,尽量还是少用为妙。
每次调用loop自身,它都会获得一个累积器的新值,它消耗输入列表中的一个元素。最终将会碰到列表结尾,然后 [] 模式被匹配,递归调用结束。
这个函数工作的如何呢?对于正整数,很完美。
ghci> asInt "33"
33
但是由于我们关注于遍历列表,忽略了错误处理,因此如果给他输入不合理的值,它的行为不正常。
ghci> asInt ""
0
ghci> asInt "potato"
*** Exception: Char.digitToInt: not a digit 'p'
我们把修正这个的函数的缺点推迟到第??页的“练习”中。
因为loop函数的最后一步只是简单的调用自身,因此它是尾递归函数的一个例子。这段代码中还有另一个惯用方式。考虑列表的结构,把空的和非空的情况分开考虑,是一种称为结构递归的方式。
我们称不递归的情况(列表为空)为基本情况(有时称为中止情况)。人们把函数调用自身的情况称为递归(好奇怪!),或许他们会同意数学归纳法而称其为归纳情况。
作为常用技术,结构递归不只被限制于列表;也可以把它用在其他的代数数据类型上。后面会更多讨论它。
[Note] 尾递归为何了不起?
在命令式语言中,循环执行时占用空间一定。Haskell中没有循环,而用尾递归函数代替。一个一般的函数在调用自己时,会分配一些空间,这样它才知道要返回到哪里。
显然,如果一个函数每次递归调用自己时都会分配内存的话,将是相对于循环的巨大的缺点:这将需要线性空间而不是常数空间。然而,函数式语言实现了尾递归调用检测,并将尾递归调用转变成常数空间使用;这称为尾递归调用优化,简称 TCO。
没有命令式的语言实现TCO;这就是为什么在命令式语言中使用函数式风格经常会造成内存泄露和糟糕的性能。
转换全部输入
考虑另一个C函数 square,它把列表中每一个元素计算平方。
void square(double *out, const double *in, size_t length)
{
for (size_t i = 0; i < length; i++) {
out[i] = in[i] * in[i];
}
}
它包含一个普通的简单循环,它对输入数组中的每一个元素做相同的事。如何在Haskell中写这个循环呢?
-- file: ch04/Map.hs
square :: [Double] -> [Double]
square (x:xs) = x*x : square xs
square [] = []
我们的square函数由两个模式匹配等式组成。第一个把非空列表的开头解构,得到它的头和尾(即 head 和 tail)。它将第一个元素求平方,然后把它放到一个新的列表的开头,这个列表是在剩下的列表上调用square函数构造的。第二个等式保证它在到达输入列表末尾时可以停止。
square的作用是构建一个新的列表,与它的输入列表长度相同,输入列表中的每个元素在输出列表上用其平方替代。
这里是另一个C循环,将输入字符串的每一个字母转换成大写。
#include <ctype.h>
char *uppercase(const char *in)
{
char *out = strdup(in);
if (out != NULL) {
for (size_t i = 0; out[i] != '\0'; i++) {
out[i] = toupper(out[i]);
}
}
return out;
}
看下Haskell中的相应程序。
-- file: ch04/Map.hs
import Data.Char (toUpper)
upperCase :: String -> String
upperCase (x:xs) = toUpper x : upperCase xs
upperCase [] = []
这里从标准 Data.Char 模块导入 toUpper 函数,这个模块中有很多有用的函数函数用来操作Char数据。
upperCase函数与之前的 square 函数有相似的模式。当输入列表为空时中止;当输入不为空时,在第一个元素上调用 toUpper,之后把它与在列表剩余部分调用本身的结果相组合,构建出新的列表。
这些例子都使用了相同的模式,来写处理Haskell里的列表的递归函数。基本情况处理输入列表为空的情况。递归情况处理非空的列表;它对表头做些处理,然后在列表末尾递归调用自身。
在列表上映射
刚刚定义的square和upperCase函数产生与输入列表相同长度的输出,并且每个元素只做一件事情。这是Haskell中一个非常通用的模式,因此预定义了一个map函数来简化它。map将一个函数应用到列表的每个元素上,将这些应用的结果返回成一个新的列表。
这是用 map 重写的 square和upperCase函数。
-- file: ch04/Map.hs
square2 xs = map squareOne xs
where squareOne x = x * x
upperCase2 xs = map toUpper xs
这是我们首次近距离的观看一个把其他函数当作参数的函数。简单的检查其类型,就可以知道很多map的信息。
ghci> :type map
map :: (a -> b) -> [a] -> [b]
类型签名告诉我们map函数取两个参数。第一个是一个函数,它取一个类型为a 的值,并返回另一个类型为b的值。
因为map取另一个函数作为参数,因此我们称其为高阶函数(higher-order function)。(虽然名字叫高阶函数,其实它并没有什么神秘的;它只是表示以函数作为参数或者返回值的函数的一个术语。)
因为map函数把square和upperCase函数中的通用模式抽象出来了,因此我们可以用更少的样板代码复用它。你可以自己想出如何实现它。
-- file: ch04/Map.hs
myMap :: (a -> b) -> [a] -> [b]
myMap f (x:xs) = f x : myMap f xs
myMap _ _ = []
[Note] 通配符在这里做什么
如果你初学函数式编程,这种模式匹配的原因并不总是显而易见的。例如,在上面的myMap定义中,第一个等式中把要映射的函数绑定到变量f上,但是第二个用了两个通配符做参数。发生了什么事呢?
在f那里用通配符意味着我们不会在等式的右边调用函数f。对于列表的参数呢?列表类型有两个构造子。myMap的第一个等式里已经匹配了非空的情况。除此外,第二个等式中的构造子一定是空列表的构造子,因此就不需要通过匹配来看究竟是什么值了。
出于编程风格的原因,对于熟悉的简单类型如列表和Maybe,用通配符是可以的。对于更复杂或不熟悉的类型,明确的用类型构造子会更安全也更加可读。
试一下myMap函数,我们可以确信它与标准的map函数行为类似。
ghci> :module +Data.Char
ghci> map toLower "SHOUTING"
"shouting"
ghci> myMap toUpper "whispering"
"WHISPERING"
ghci> map negate [1,2,3]
[-1,-2,-3]
识别并抽象出重复的用法,以便复用代码。这种模式是Haskell编程中非常普遍的。虽然抽象并不是Haskell独有的,但高阶函数让它变得非常简单。
选择输入的片段
数据序列上的另一个通常操作是,从序列上梳过找出符合一定标准的元素。这里有一个函数在数字列表上遍历找出所有偶数。我们的代码有一个递归情况,比之前的函数稍微复杂些:只有数字是偶数时才把它放到返回列表上。用守卫语法可以很好的表达。
-- file: ch04/Filter.hs
oddList :: [Int] -> [Int]
oddList (x:xs) | odd x = x : oddList xs
| otherwise = oddList xs
oddList _ = []
实际运行看下。
ghci> oddList [1,1,2,3,5,8,13,21,34]
[1,1,3,5,13,21]
这种用法非常通用,因此Prelude定义了一个函数 filter,之前已经介绍过它了。它去除掉了在列表上递归的样板代码。
ghci> :type filter
filter :: (a -> Bool) -> [a] -> [a]
ghci> filter odd [3,1,4,1,5,9,2,6,5]
[3,1,1,5,9,5]
filter函数把一个断言应用到输入列表的每个元素上,只返回那些断言求值为True的元素组成的列表。很快将在“右侧折叠”一节再次讨论它。
从一个集合中计算出一个值
集合中另一件常见的事是将其压缩(reduce)成一个值。一个简单的例子是对列表的值求和。
-- file: ch04/Sum.hs
mySum xs = helper 0 xs
where helper acc (x:xs) = helper (acc + x) xs
helper acc _ = acc
helper函数是尾递归的,它用名为acc的累积器(accumulator)参数来保存当前列表的部分和。在 asInt 中已经见过了,这是在纯函数式语言中表示循环的“自然”方式。
对于更复杂的情况,我们看下 Adler-32校验和。这是一种流行的校验和算法;它把两个16位的校验和连接成单独的一个32位的校验和。第一个校验和是所有输入字节的和,再加1。第二个校验和是所有第一个校验和中间值的和。两种情况下的和都对65521取模。这里是一个简单的没有经过优化的Java实现。(如果不懂Java的话可以跳过)
public class Adler32
{
private static final int base = 65521;
public static int compute(byte[] data, int offset, int length)
{
int a = 1, b = 0;
for (int i = offset; i < offset + length; i++) {
a = (a + (data[i] & 0xff)) % base;
b = (a + b) % base;
}
return (b << 16) | a;
}
}
虽然Adler-32是种简单的校验和,但是涉及到位操作这个代码不是特别容易读懂。用Haskell的实现会好些么?
-- file: ch04/Adler32.hs
import Data.Char (ord)
import Data.Bits (shiftL, (.&.), (.|.))
base = 65521
adler32 xs = helper 1 0 xs
where helper a b (x:xs) = let a' = (a + (ord x .&. 0xff)) `mod` base
b' = (a' + b) `mod` base
in helper a' b' xs
helper a b _ = (b `shiftL` 16) .|. a
这段代码并不比Java的代码好读,但是让我们看看究竟怎么做的。首先先要介绍一些心函数。shiftL 函数实现了逻辑左移; (.&.) 提供了位“与”操作; (.|.) 提供了位“或”操作。
helper 函数又是尾递归的。在Java中每次循环迭代时更新的两个变量,在Haskell里转换成了累积器参数。在输入列表结束时中止递归,并计算出校验和返回。
如果我们退一步,可以把Haskell 的 adler32重新组织成类似早前的 mySum 函数。可以用一个数对做累积器代替两个累积器参数。
-- file: ch04/Adler32.hs
adler32_try2 xs = helper (1,0) xs
where helper (a,b) (x:xs) =
let a' = (a + (ord x .&. 0xff)) `mod` base
b' = (a' + b) `mod` base
in helper (a',b') xs
helper (a,b) _ = (b `shiftL` 16) .|. a
为什么要做这个看上去没有意义的解构修改呢?因为我们已经看到了 map 和 filter函数,我们可以从mySum和adler32_try2中把通用的行为提取成高阶函数。可以把这种行为描述成“对列表中每个元素做一些操作,同时更新一个累积器,完成后返回累积器的值”。
这种类型的函数称为折叠(fold),因为它把一个列表“折叠”成一个值。在列表上有两种折叠方式, foldl 从左侧(表开始)折叠,foldr从右侧(表末尾)折叠。
左折叠
这里是foldl 的定义。
-- file: ch04/Fold.hs
foldl :: (a -> b -> a) -> a -> [b] -> a
foldl step zero (x:xs) = foldl step (step zero x) xs
foldl _ zero [] = zero
foldl 函数取一个“step”函数、一个累积器的初始值和一个列表作为参数。“step”函数取一个累积器和列表上的一个元素作为参数,返回一个新的累加器的值。foldl 所做的就是在当前的累积器和列表的一个元素上调用"step"函数,并用新的累积器的值和列表剩余的部分递归的调用自己。
把 foldl 称为“左折叠”是因为它从左(表开头)到右的消耗列表。
这是用foldl 重写的mySum。
-- file: ch04/Sum.hs
foldlSum xs = foldl step 0 xs
where step acc x = acc + x
局部函数step只是把两个数相加,因此我们用加法操作符来代替它,并且去掉不必要的where子句。
-- file: ch04/Sum.hs
niceSum :: [Integer] -> Integer
niceSum xs = foldl (+) 0 xs
注意这个代码比原先的mySum要简单很多。不再用显式的递归,因为foldl 帮我们做了。我们把问题简化成两件事:累积器的初始值应该是什么(foldl 的第二个参数),如何更新累积器( (+) 函数)。作为附加的奖励,代码变短了,这会让它更容易理解。
我们来更深入的看下这里foldl的行为,我们手工的写出 niceSum [1,2,3] 求值时的每一步。
-- file: ch04/Fold.hs
foldl (+) 0 (1:2:3:[])
== foldl (+) (0 + 1) (2:3:[])
== foldl (+) ((0 + 1) + 2) (3:[])
== foldl (+) (((0 + 1) + 2) + 3) []
== (((0 + 1) + 2) + 3)
我们可以用foldl 来重写 adler32_try2,这样我们可以集中关注重要的细节。
-- file: ch04/Adler32.hs
adler32_foldl xs = let (a, b) = foldl step (1, 0) xs
in (b `shiftL` 16) .|. a
where step (a, b) x = let a' = a + (ord x .&. 0xff)
in (a' `mod` base, (a' + b) `mod` base)
在这里我们的累积器时一个数对,因此foldl 的结果也将一样。当foldl返回时,我们将最后的累积器分开,并用位操作把他们结合成合适的校验和。
为什么使用 folds, maps, 和 filters?
乍一看adler32_foldl并不比adler32_try2更短。为什么在这里要用折叠呢?它的优点在于折叠是Haskell中非常通用的操作,他们具有规则的可预测的行为。
这意味着有一点经验的读者,对用fold的代码理解起来要比显式的递归来得容易。折叠操作不会产生任何的出人意料的事,但是一个显式的递归函数并不是显而易见的。显式的递归需要我们仔细的阅读才能明白到底发生了什么。
这条理由也适用于其他的高阶库函数,包括我们已经见到的 map 和filter。因为它们是良好定义的库函数,我们只需要学习他们一次,在遇到使用它们的代码时就更容易理解了。这些可读性的提高对于写代码也有帮助。一旦我们开始用高阶函数思考,就可以写出更快的写出简洁的代码。
右折叠
与foldl 相对的是 foldr,它从列表的右侧开始折叠。
-- file: ch04/Fold.hs
foldr :: (a -> b -> b) -> b -> [a] -> b
foldr step zero (x:xs) = step x (foldr step zero xs)
foldr _ zero [] = zero
像“左折叠”一节中niceSum一样,我们跟随相同的求值过程: foldr (+) 0 [1,2,3]。
-- file: ch04/Fold.hs
foldr (+) 0 (1:2:3:[])
== 1 + foldr (+) 0 (2:3:[])
== 1 + (2 + foldr (+) 0 (3:[])
== 1 + (2 + (3 + foldr (+) 0 []))
== 1 + (2 + (3 + 0))
foldl和foldr的区别可以从括号与“空列表”元素的位置看出来。在foldl里,空列表元素在左边,所有的括号组织在左边。在foldr里,0在右边,并且括号都在右侧。
对foldr如何工作有个直观的解释:把空列表换成0值,并把列表中的每一个构造子替换成 step 函数的应用。
-- file: ch04/Fold.hs
1 : (2 : (3 : []))
1 + (2 + (3 + 0 ))
初看上去 foldr 好像没foldl有用:从右到左折叠有什么用处呢?但是考虑下Prelude里的filter函数,我们刚刚在“选择输入片段”一节见过。如果我们用显式递归来写filter,看上去就像这样。
-- file: ch04/Fold.hs
filter :: (a -> Bool) -> [a] -> [a]
filter p [] = []
filter p (x:xs)
| p x = x : filter p xs
| otherwise = filter p xs
或许有些令人惊讶,我们可以把filter写成一个折叠,用foldr。
-- file: ch04/Fold.hs
myFilter p xs = foldr step [] xs
where step x ys | p x = x : ys
| otherwise = ys
这个定义有点令人头疼,我们深入的看下它。与foldl一样,foldr取一个函数和一个基本情况(列表为空时的动作)作为参数。通过读filter的类型可知,myFilter 函数必须返回一个与输入类型相同的列表,因此基本情况应当是这种类型的空列表,step 辅助函数必须返回一个列表。
我们知道foldr 每次在输入列表的一个元素上调用step,将累积器作为其第二个参数,因此step的动作就很简单了。如果断言返回True,它将那个元素添加到累积器列表中;否则将不会动那个列表。
可以用foldr来表示的一类函数称为原始(primitive)递归。很大数量的列表操作函数是原始递归。例如,这是用foldr写的map函数。
-- file: ch04/Fold.hs
myMap :: (a -> b) -> [a] -> [b]
myMap f xs = foldr step [] xs
where step x ys = f x : ys
实际上,我们甚至可以用foldr来写出foldl。
-- file: ch04/Fold.hs
myFoldl :: (a -> b -> a) -> a -> [b] -> a
myFoldl f z xs = foldr step id xs z
where step x g a = g (f a x)
[Tip] 用foldr来理解foldl
如果想挑战自己的话,尝试研究下上面用foldr定义的foldl。提醒下:这并非无关紧要的!你手头上需要下面的工具:一些头疼药品和一杯水,ghci(用来弄清楚 id 函数的作用),纸和笔。
可以跟随上面的手工演算过程来看出 foldl 和 foldr 的实际操作。如果遇到困难,在读过“部分函数应用和柯里化”后会更简单些。
回到前面对foldr简单的解释,考虑它的另一个有用的方式是它把输入列表进行转换。它的头两个参数是“对列表的每一个头/尾元素做什么”,和“对列表的结尾如何替换”。
foldr用 "identity" 来转换,就是用空列表来替换它自己,并在每一个头/尾对上应用列表构造子。
-- file: ch04/Fold.hs
identity :: [a] -> [a]
identity xs = foldr (:) [] xs
它把一个列表转换成它自身的一个拷贝。
ghci> identity [1,2,3]
[1,2,3]
如果 foldr 把列表的末尾用其他一些值替换,这给我们看待Haskell的列表添加函数的另一种方式:
ghci> [1,2,3] ++ [4,5,6]
[1,2,3,4,5,6]
要把一个列表添加到另一个上面,我们要做的就是把第二个列表替换到第一个列表的结尾。
-- file: ch04/Fold.hs
append :: [a] -> [a] -> [a]
append xs ys = foldr (:) ys xs
尝试一下:
ghci> append [1,2,3] [4,5,6]
[1,2,3,4,5,6]
这里我们把每一个列表构造子替换成另一个列表构造子,但是我们把空列表换成了要添加的那个列表。
对fold扩展论述指出,在我们的列表编程工具箱中,foldr函数与“操作列表”一节中介绍的更基本的列表函数几乎一样重要。它可以增量的使用和生成列表,这使他在写惰性数据处理代码时非常有用。
左折叠,惰性和内存泄露
为保持开始的讨论简单,本章中大部分使用 foldl。这对测试很方便,但是实际中我们从来不用 foldl。原因与Haskell的非严格求值有关。如果调用 foldl (+) [1,2,3] ,将求值为表达式 (((0 + 1) + 2) + 3)。我们看下函数展开的方式就可以看到它的发生:
-- file: ch04/Fold.hs
foldl (+) 0 (1:2:3:[])
== foldl (+) (0 + 1) (2:3:[])
== foldl (+) ((0 + 1) + 2) (3:[])
== foldl (+) (((0 + 1) + 2) + 3) []
== (((0 + 1) + 2) + 3)
最后的表达式直到它的值被用到时才会求值为6。在它求值前,它必须保存成一个thunk。不出意外的话,存储一个thunk远比存储一个单独的值代价高多了,而且越复杂的thunk表达式,需要越多的空间。对于一些简单的算数计算,把表达式存储成thunk比直接计算它要话费更多的计算。最终要花费更多的空间和时间。
当GHC求值一个thunk表达式时,它用内部的桟来左。因为一个thunk表达式有可能无限大,GHC设置了桟的最大值限制。感谢这个限制,我们可以在ghci中尝试一些巨大的thunk表达式,不用担心它消耗掉全部的内存。
ghci> foldl (+) 0 [1..1000]
500500
从上面的展开式可以推测它会创建一个1000个整数和999个(+)应用的 thunk。为表示一个单独的数字需要花费的内存和工作实在多的过分了!对于更大的表达式,虽然其大小还算适度,但结果更加夸张:
ghci> foldl (+) 0 [1..1000000]
*** Exception: stack overflow
在小的表达式上,foldl工作正常但是很慢,这是因为它引起的thunk的过载。我们成这种不可见的thunk为内存泄露,因为我们的代码操作正常,但是却用了远多于它应该使用的内存数量。
对更大的表达式,有内存泄露的代码将会像上面那样失败。foldl的内存泄露是Haskell新手的经典拦路虎。幸运的是,这很容易避免。
Data.List 模块定义了名为 foldl' 的函数,它和foldl 相似但是不会创建 thunk。两个行为的不同点很明显:
ghci> foldl (+) 0 [1..1000000]
*** Exception: stack overflow
ghci> :module +Data.List
ghci> foldl' (+) 0 [1..1000000]
500000500000
因为foldl 创建thunk的行为,在实际程序中最好避免使用它。即使它当是并没有崩溃,它也是不必要的低效。相反,导入 Data.List 并使用 foldl'。
练习
(????译注: 原版书中练习的编号排错了!)
1. 使用fold (选择适当的fold会让你的代码更简单)重写和改进前面“显式递归”一节中的 asInt 函数。
-- file: ch04/ch04.exercises.hs
asInt_fold :: String -> Int
你的函数的行为应当像下面这样:
ghci> asInt_fold "101"
101
ghci> asInt_fold "-31337"
-31337
ghci> asInt_fold "1798"
1798
扩展你的函数,调用error来处理下面几种异常情况:
ghci> asInt_fold ""
0
ghci> asInt_fold "-"
0
ghci> asInt_fold "-3"
-3
ghci> asInt_fold "2.7"
*** Exception: Char.digitToInt: not a digit '.'
ghci> asInt_fold "314159265358979323846"
564616105916946374
2. asInt_fold 函数使用 error,因此它的调用者不能处理错误。重写这个函数来修正这个问题:
-- file: ch04/ch04.exercises.hs
type ErrorMessage = String
asInt_either :: String -> Either ErrorMessage Int
ghci> asInt_either "33"
Right 33
ghci> asInt_either "foo"
Left "non-digit 'o'"
3. Prelude 中的函数 concat ,把一个列表的列表拼接成一个单独的列表,它的类型为:
-- file: ch04/ch04.exercises.hs
concat :: [[a]] -> [a]
用 foldr 写出concat的定义。
4. 实现自己的标准 takeWhile函数,先用显式递归,然后用foldr。
5. Data.List 模块中定义了一个 groupBy 函数,它有如下类型:
-- file: ch04/ch04.exercises.hs
groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
用 ghci 载入 Data.List 模块,弄明白groupBy 做什么,然后用f
Partial function application and currying
old写出自己的定义。
6. 下面这些Prelude中的函数,你可以用fold重写出多少?
* any
* cycle
* words
* unlines
那些既可以使用 foldl' 也可以使用 foldr 的函数,用哪种更合适?
延伸阅读
Graham Hutton 的"A tuorial on the universality and expressiveness of fold"一文(http://www.cs.nott.ac.uk/~gmh/fold.pdf)是对fold非常优秀的深入介绍。它包括很多的例子来介绍如何用简单的系统的技术,来把显式递归转换成fold。
匿名函数(lambda)
我们已经看到很多的函数定义里们使用了简短的辅助函数。
-- file: ch04/Partial.hs
isInAny needle haystack = any inSequence haystack
where inSequence s = needle `isInfixOf` s
Haskell 允许我们写完全匿名的函数,这可以让我们不需要给辅助函数命名。匿名函数经常称为“lambda”函数,继承自lambda演算。用反斜线来定义一个匿名函数, \ 发音为 lambda 。它后面跟函数的参数(可以包含模式),然后是箭头 -> 指向函数体。
lambda表达式最好用例子来演示。这是用匿名函数重写的 isInAny 。
-- file: ch04/Partial.hs
isInAny2 needle haystack = any (\s -> needle `isInfixOf` s) haystack
我们用括号来把lambda表达式包起来,这样Haskell就可以识别出函数体的结尾。
匿名函数在每一方面都和具名函数一样,但是Haskell在如何定义它上增加了一些重要的限制。最重要的是,普通函数上可以用多个子句包含不同的模式和守卫,而lambda表达式的定义里只能有一个单独的子句。
一个子句的局限限制了我们如何在lambda表达式定义中使用模式。通常我们写正常的函数,用多个子句来覆盖不同的模式匹配可能。
-- file: ch04/Lambda.hs
safeHead (x:_) = Just x
safeHead _ = Nothing
但是因为不能写多个子句的lambda定义,我们必须确定我们用的模式可以匹配任意情况:
-- file: ch04/Lambda.hs
unsafeHead = \(x:_) -> x
如果用一个模式匹配失败的值,来调用这个unsafeHead 的定义,它将崩溃:
ghci> :type unsafeHead
unsafeHead :: [t] -> t
ghci> unsafeHead [1]
1
ghci> unsafeHead []
*** Exception: Lambda.hs:7:13-23: Non-exhaustive patterns in lambda
定义可以通过类型检查,因此它编译能通过,而错误会发生在运行时。这个故事告诉我们在定义匿名函数时要小心使用模式:确保你的模式不会失败!
要注意的另一件事是,上面的的isInAny 和 isInAny2函数第一个用具名函数做辅助函数,第二个在中间插入了一个匿名函数,第一个版本要比第二个版本更易度一些。具名的辅助函数不会扰乱使用它的函数的处理流,而且明智的选取名字可以给我们更多关于函数做什么的信息。
相比较,在函数体中遇到一个lambda定义时,我们必须“换挡”并小心的阅读它的定义来理解它做了什么。为有助于可读性和可维护性,我们倾向于在很多情况下避免使用lambda表达式,尽管它可以让我们在函数定义中少输入一些字符。经常,我们会使用部分应用函数来代替,它会比lambda和具名函数都更清晰更可读。还不知道什么时部分函数应用?继续读吧!
这些警告并不是说lambda没用,只是说当我们考虑使用他们的时候要留意潜在的隐患。在后面的章节里,我们会看到它们作为“胶水”的宝贵作用。
部分函数应用和柯里化
你也许会奇怪为什么 -> 箭头在函数的类型签名中看上去好像用于两种目的:
ghci> :type dropWhile
dropWhile :: (a -> Bool) -> [a] -> [a]
看上去好像 -> 把 dropWhile 的参数分隔开,但是也用来分开参数和返回类型。其实实际上 -> 只有一个含义:它表示一个函数读取一个类型为 -> 左侧类型的参数,并返回 -> 右侧类型的值。
这里的内涵非常的重要:在Haskell中,所有的函数只取一个参数。虽然 dropWhile 看上去像是取两个参数的函数,但实际上它只取一个参数,并返回一个取一个参数的函数。这里是一个完全有效的Haskell表达式:
ghci> :module +Data.Char
ghci> :type dropWhile isSpace
dropWhile isSpace :: [Char] -> [Char]
看上其很有用。dropWhile isSpace 的值是一个函数,它把一个字符串开头的空格去除。这有什么用呢?作为一个例子,可以把它当作一个高阶函数的参数:
ghci> map (dropWhile isSpace) [" a","f"," e"]
["a","f","e"]
每次给一个函数提供一个参数时,我们就可以从它的类型签名的前面“砍去”一个元素。用zip3的例子来说明我们所说的意思;这个函数把三个列表压成一个三元组的列表。
ghci> :type zip3
zip3 :: [a] -> [b] -> [c] -> [(a, b, c)]
ghci> zip3 "foo" "bar" "quux"
[('f','b','q'),('o','a','u'),('o','r','u')]
如果只给zip3 提供一个参数,将得到一个接收两个参数的函数。不管我们给这个复合函数提供什么参数,它的第一个参数总是我们指定的固定值。
ghci> :type zip3 "foo"
zip3 "foo" :: [b] -> [c] -> [(Char, b, c)]
ghci> let zip3foo = zip3 "foo"
ghci> :type zip3foo
zip3foo :: [b] -> [c] -> [(Char, b, c)]
ghci> (zip3 "foo") "aaa" "bbb"
[('f','a','b'),('o','a','b'),('o','a','b')]
ghci> zip3foo "aaa" "bbb"
[('f','a','b'),('o','a','b'),('o','a','b')]
ghci> zip3foo [1,2,3] [True,False,True]
[('f',1,True),('o',2,False),('o',3,True)]
当我们给一个函数传递少于它可接收的参数时,我们称其为函数的部分应用:我们只应用函数的部分参数。
在上面的例子里,我们有一个部分应用的函数, zip3 "foo" , 和一个新的函数 zip3foo。可以看到这两个的类型签名以及行为都是一样的。
当给两个参数时也是一样,它会给我们一个只有一个参数的函数:
ghci> let zip3foobar = zip3 "foo" "bar"
ghci> :type zip3foobar
zip3foobar :: [c] -> [(Char, Char, c)]
ghci> zip3foobar "quux"
[('f','b','q'),('o','a','u'),('o','r','u')]
ghci> zip3foobar [1,2]
[('f','b',1),('o','a',2)]
部分函数应用可以让我们避免写一些讨厌的一次性函数。从这方面来说它经常比“匿名函数(lambda)”一节介绍的匿名函数更有用。回头看下那里定义的 isInAny 函数,这里用部分应用函数来替换具名辅助函数和lambda表达式:
-- file: ch04/Partial.hs
isInAny3 needle haystack = any (isInfixOf needle) haystack
这里isInfixOf needle 表达式是一个部分应用哈市女孩。我们把 isInfixOf 函数的第一个参数“固定”为参数列表中的 needle 变量。这给了我们一个部分应用的函数,它与早前版本的辅助函数或lambda表达式有相同的类型签名和行为。
部分函数应用称为柯里化(currying),以逻辑学家 Haskell Curry 命名(Haskell语言也是以他命名的)。
作为柯里化应用的另一个例子,我们回到“左折叠 foldl”一节中的列表求和函数:
-- file: ch04/Sum.hs
niceSum :: [Integer] -> Integer
niceSum xs = foldl (+) 0 xs
我们不需要完全应用foldl;我们可以在参数列表和foldl的参数列表中省略 xs,这样得到了一个具有相同类型的更加紧凑的函数。
-- file: ch04/Sum.hs
nicerSum :: [Integer] -> Integer
nicerSum = foldl (+) 0
节(section ?? 断面 横切 )
Haskell为中缀风格的部分函数应用提供了一些快捷写法。如果用括号括起来一个操作符,可以在括号里面给它提供左或右参数,得到一个部分应用的函数。这种部分应用称为节(section)。
ghci> (1+) 2
3
ghci> map (*3) [24,36]
[72,108]
ghci> map (2^) [3,5,7,9]
[8,32,128,512]
如果在节里提供了左侧参数,调用结果函数时将会把一个参数提供给操作符的右侧参数。反之亦然。
我们可以把函数命用反引号括起来当作中缀操作符。这可以让我们在节里使用函数。
ghci> :type (`elem` ['a'..'z'])
(`elem` ['a'..'z']) :: Char -> Bool
前面的定义把 elem 的第二个参数固定,得到一个检查参数是否为小写字母的函数:
ghci> (`elem` ['a'..'z']) 'f'
True
把它当作 all 的一个参数,我们得到了一个检查整个字符串是否都是小写的函数:
ghci> all (`elem` ['a'..'z']) "Frobozz"
False
如果我们使用这种风格,可以更进一步提高 isInAny3 函数的可读性:
-- file: ch04/Partial.hs
isInAny4 needle haystack = any (needle `isInfixOf`) haystack
As-模式
Haskell 的Data.List 模块中的 tails 函数推广了之前介绍的 tail 函数。它不是返回列表的一个"tail",而是返回全部:
ghci> :m +Data.List
ghci> tail "foobar"
"oobar"
ghci> tail (tail "foobar")
"obar"
ghci> tails "foobar"
["foobar","oobar","obar","bar","ar","r",""]
这些字符串的每一个都是初始字符串的词尾,因此tails生成了所有词尾的列表,及末尾的一个额外的空列表。它总是生成哪个额外的空列表,即使在输入列表也是空的时候:
ghci> tails []
[[]]
如果我们想要一个和tails 一样行为,但只返回非空的词尾的函数呢?一种可能是我们手工编写自己的版本。我们将使用一个新的记号 @ 符号:
-- file: ch04/SuffixTree.hs
suffixes :: [a] -> [[a]]
suffixes xs@(_:xs') = xs : suffixes xs'
suffixes _ = []
xs@(_:xs') 模式称为 as-模式,它的意思是“把变量xs绑定到匹配@符号右侧的值”。
在我们的例子中,如果"@"后面的模式匹配上了,xs将绑定到匹配的整个列表,而 xs' 绑定到除了表头的全部列表(用通配符 _ 模式表示我们不关心表头的元素)。
ghci> tails "foo"
["foo","oo","o",""]
ghci> suffixes "foo"
["foo","oo","o"]
as-模式让我们的代码更可读。比较下没有as-模式的定义,就可以看出它的用处:
-- file: ch04/SuffixTree.hs
noAsPattern :: [a] -> [[a]]
noAsPattern (x:xs) = (x:xs) : noAsPattern xs
noAsPattern _ = []
这里的函数体中,把刚刚在模式匹配中解析的列表又组合回去了。
as- 模式除了简单的可读性外还有更使用的用处:它可以帮助我们共享数据而不是复制它们。在noAsPattern 的定义中,匹配到 (x:xs) 时我们在函数体中构造了一个它的拷贝。这导致我们在运行时分配新的列表节点。它可能很廉价,但并不是免费的。相反,在定义 suffixes 时,我们重用了as-模式匹配到的 xs 值。因为我们重用了一个存在的值,因此避免了一点内存分配。
通过组合复用代码
引入一个新的函数 suffixes 有点羞愧,因为已经存在了一个做得事几乎一样的tails 函数了。我们不能做的更好么?
回想“操作列表”一节中介绍的init函数:它返回列表除了最后一个元素外的全部:
-- file: ch04/SuffixTree.hs
suffixes2 xs = init (tails xs)
suffixes2 函数与suffixes的行为相同,但是它只有一行代码:
ghci> suffixes2 "foo"
["foo","oo","o"]
我们退一步,就会看出这里闪现的一个模式:应用一个函数,然后把它的结果应用到另一个函数上。我们把这个模式写成一个函数:
-- file: ch04/SuffixTree.hs
compose :: (b -> c) -> (a -> b) -> a -> c
compose f g x = f (g x)
现在有了一个 compose 函数,它可以用来把两个另外的函数“粘合”在一起:
-- file: ch04/SuffixTree.hs
suffixes3 xs = compose init tails xs
Haskell的自动柯里化可以让我们抛弃掉xs变量,这样可以让定义更短:
-- file: ch04/SuffixTree.hs
suffixes4 = compose init tails
性用的是,我们不需要写自己的compose函数。像这样把两个函数连接在一起非常普遍,因此Prelude中通过 (.) 操作符提供了函数组合:
-- file: ch04/SuffixTree.hs
suffixes5 = init . tails
(.) 操作符并不是特殊的语法;它只是一个平常的操作符:
ghci> :type (.)
(.) :: (b -> c) -> (a -> b) -> a -> c
ghci> :type suffixes
suffixes :: [a] -> [[a]]
ghci> :type suffixes5
suffixes5 :: [a] -> [[a]]
ghci> suffixes5 "foo"
["foo","oo","o"]
我们可以随时通过组合的函数链来创建新的函数,把它们用 (.) 操作符缝合在一起,只要(当然)每一个 (.) 右侧的函数类型与其左侧的函数的参数类型相匹配就可以。
看一个例子,我们来解决一个简单的问题:数一个字符串中大写字母开头的单词:
ghci> :module +Data.Char
ghci> let capCount = length . filter (isUpper . head) . words
ghci> capCount "Hello there, Mom!"
2
我们可以通过检查它的每一块来理解这个组合函数。(.) 函数是右结合性的,因此我们从右向左处理:
ghci> :type words
words :: String -> [String]
words 函数的结果类型是 [String],因此(.) 左边的不管是什么都必须接受一致的参数:
ghci> :type isUpper . head
isUpper . head :: [Char] -> Bool
这个函数当单词以大写字母开头时返回 True(在ghci中试验下),因此 filter (isUpper . head) 返回一个只包含开头是大写字母的单词列表:
ghci> :type filter (isUpper . head)
filter (isUpper . head) :: [[Char]] -> [[Char]]
因为这个表达式返回一个列表,剩下要做的就是计算这个列表的长度了,我们用另一个组合来做。
这是一个真实应用程序中的例子。我们想要抽取 libpcap的C元程序头文件中的宏定义的名字列表,libpcap是一个流行的网络包过滤库。它的头文件中包含了很大数量的定义,格式如下:
#define DLT_EN10MB 1 /* Ethernet (10Mb) */
#define DLT_EN3MB 2 /* Experimental Ethernet (3Mb) */
#define DLT_AX25 3 /* Amateur Radio AX.25 */
我们的目标是提取如DLT_EN10MB 和 DLT_AX25这样的名字:
-- file: ch04/dlts.hs
import Data.List (isPrefixOf)
dlts :: String -> [String]
dlts = foldr step [] . lines
我们把整个文件当作一个字符串,用 lines 把它切分成列表,然后在返回的列表上调用 foldr step [] 。 step 辅助函数对单个一行进行操作:
-- file: ch04/dlts.hs
where step l ds
| "#define DLT_" `isPrefixOf` l = secondWord l : ds
| otherwise = ds
secondWord = head . tail . words
当我们的的守卫表达式匹配到一个宏定义时,我们把宏的名字放到返回列表的开头;否则就不动那个列表。
虽然secondWord函数体中的单个函数我们都熟悉了,但它还是需要一些练习来熟悉如何用函数组合来将部分串接成链。我们看下过程。
我们再次从右到左处理。第一个函数是words:
ghci> :type words
words :: String -> [String]
ghci> words "#define DLT_CHAOS 5"
["#define","DLT_CHAOS","5"]
然后在words的结果上调用 tail:
ghci> :type tail
tail :: [a] -> [a]
ghci> tail ["#define","DLT_CHAOS","5"]
["DLT_CHAOS","5"]
ghci> :type tail . words
tail . words :: String -> [String]
ghci> (tail . words) "#define DLT_CHAOS 5"
["DLT_CHAOS","5"]
最后在drop 1 . words 的结果上调用 head 给出我们宏的名字:
ghci> :type head . tail . words
head . tail . words :: String -> String
ghci> (head . tail . words) "#define DLT_CHAOS 5"
"DLT_CHAOS"
明智的使用你的头脑
“安全稳健的使用会崩溃的函数”一节中已经警告过不安全的列表处理了,这里我们调用了 head 和 tail 两个不安全的列表函数。有什么问题?
在这种情况,我们可以通过检查确信不会发生运行时错误。step 定义中的模式守卫包含两个词,因此当在通过了模式守卫的任何字符串上调用 words时,将获得至少有两个元素的列表: "#define" 和"DLT_"开头的宏。
这是需要做的一种验证,以使我们自己确信代码不会在调用不完整的函数时崩溃。不要忘记我们之前的告诫:调用这样的不安全函数需要小心,它经常会让我们的代码在微妙的情况下更脆弱。如果由于某些原因我们修改了模式守卫,使他只包含一个单词,我们的程序将可能会崩溃,因为函数体假设它会接收到两个词。
书写可读代码的忠告
本章到现在我们已经看到了两个Haskell的诱人特性:尾递归和匿名函数。他们很美妙,但我们不经常用它们。
很多列表操作符用库函数的组合来表示最容易,如 map, take 和filter。没错这需要一些练习来习惯使用他们。作为原始投资的回报,我们可以更快的读写代码,并且更少bug。
原因很简单。尾递归函数定义与命令式语言中的循环具有相同的问题:它是完全通用的。它可以执行一些过滤,一些映射,或者谁知道什么别的操作。我们必须去看函数的全部定义细节,才能看出它实际做了什么。相对的,map 和大部分其他列表操作函数只做一件事情。我们能保证这些简单的构建块做了什么,并把注意力放在代码实际要表达的含义上,不需要看它操作输入的细节。
在尾递归函数(完全通用)与列表操作工具箱(它们每一个做一件事)之间,是两个fold的中间层。一个fold 比相同功能的map和filter的组合更难理解一些,但是它比一个尾递归函数更有规律更可预测。作为一个通用规则,如果可以用库函数的组合,就不要用 fold,其他情况下优先使用fold 而不是手工写尾递归循环。
对于匿名函数,它们会打断代码段的“阅读流”。经常在let或where子句中写一个局部函数定义与写个匿名函数一样简单,最好用局部函数。用具名函数还有个双重的好处:阅读使用它的代码时不需要理解函数的定义,精心选择的函数名称可以当作局部的文档。
内存泄露和严格执行
早前讨论的foldl函数不是Haskell代码中唯一可能发生内存泄露的地方。我们会用它来演示非严格计算有时会很成问题,以及如何解决出现的问题。
[Tip] 你是否需要立刻知道关于内存泄露的全部内容?
完全可以先跳过本节,知道你在现实中遇到了内存泄露问题再回来。用foldr来生成列表,其他时候用 foldl' 代替 foldl,这样内存泄露暂时不会来烦你。
用seq避免内存泄露
把不进行惰性求值的表达式称为严格(strict),因此 foldl' 是严格的左折叠。它通过使用特殊函数seq 来忽避免Haskell通常的非严格求值。
-- file: ch04/Fold.hs
foldl' _ zero [] = zero
foldl' step zero (x:xs) =
let new = step zero x
in new `seq` foldl' step new xs
seq函数有一个特别的类型,暗示出它不按通常的规则执行。
ghci> :type seq
seq :: a -> t -> t
它的操作方式如下:当对一个seq表达式求值时,它强制将第一个参数求值,之后返回第二个参数。它实际上不对第一个参数做任何事:seq只是作为强制求值的方式而存在。我们简单的看下实际执行过程。
-- file: ch04/Fold.hs
foldl' (+) 1 (2:[])
展开如下。
-- file: ch04/Fold.hs
let new = 1 + 2
in new `seq` foldl' (+) new []
seq强制将new求值为3,并返回第二个参数。
-- file: ch04/Fold.hs
foldl' (+) 3 []
最终得到下面的结果。
-- file: ch04/Fold.hs
3
感谢seq,没有任何 thunk 出现。
学习使用seq
如果没有一些说明,要高效的使用seq有些神秘。这里是一些使用它的规则。
要取得任何效果,seq表达式必须是表达式中第一个要求值的。
-- file: ch04/Fold.hs
-- incorrect: seq is hidden by the application of someFunc
-- since someFunc will be evaluated first, seq may occur too late
hiddenInside x y = someFunc (x `seq` y)
-- incorrect: a variation of the above mistake
hiddenByLet x y z = let a = x `seq` someFunc y
in anotherFunc a z
-- correct: seq will be evaluated first, forcing evaluation of x
onTheOutside x y = x `seq` someFunc y
要对一些值进行严格求值,把seq链接起来。
-- file: ch04/Fold.hs
chained x y z = x `seq` y `seq` someFunc z
通常的错误是将seq与两个不相关的表达式一起使用。
-- file: ch04/Fold.hs
badExpression step zero (x:xs) =
seq (step zero x)
(badExpression step (step zero x) xs)
这里表面上是要严格求值 step zero x。因为这个表达式与函数体中的是重复的,对第一个表达式严格求值并不会对第二个产生影响。上面foldl' 的定义里使用 let的方式,展示了如何正确的获得所需的效果。
当求值一个表达式时,seq一遇到构建子就会停止。对简单类型如数字来说,它将对其完全求值。代数数据类型则不同了。考虑 (1+2):(3+4):[] 的值。对它应用seq,将会求值 (1+2) thunk。(??)由于当它遇到第一个 (:) 构造子时会停止,因此对第二个 thunk 没有影响。对元组也一样:seq ((1+2),(3+4)) True 对数对中的 thunk 什么也不做,因为它直接遇到了数对的构造子。
如果必要,可以用一半的函数式编程技术来绕过这些限制。
-- file: ch04/Fold.hs
strictPair (a,b) = a `seq` b `seq` (a,b)
strictList (x:xs) = x `seq` x : strictList xs
strictList [] = []
要注意seq并非无偿的:它在运行时必须执行一个检查看一个表达式是否已经被求值了。省着点用。比如,当我们的strictPair函数对数对第一个构造子前的内容求值时,它增加了模式匹配、两次seq应用和构造一些元组的开销。如果用基准测试来测量它的性能的话,会发现它拖慢了程序。
撇开性能的影响,seq也不能对性能问题包治百病。你能够严格求值不代表你应该这么做。对seq粗心的使用可能没任何效果;只是把存在的内存泄露挪了地方,或者引入新的泄露。
seq是否必要以及是否工作良好,最好的指导是性能测量和profile工具,在第25章 《性能概括与优化》将会讨论他们。以实际测量的经验,将会建立起合适使用seq最有用的感觉。
[8] 不幸的是没有篇幅包含他们了。
[9] 选择反斜线是因为它与希腊字母 lambda, λ 有些像。虽然GHC允许Unicode输入,它还是把 λ 当作一个字符,而不是 \ 的同义词。
转载于:https://www.cnblogs.com/IBBC/archive/2011/07/25/2116269.html