第 4 章:定义函数(Defining functions)
原文:Graham Hutton, Programming in Haskell, Second Edition, Chapter 4。维护者已确认本书可翻译并发布用于学习研究。
本章介绍 Haskell 中定义函数的一系列机制。我们从条件表达式和守卫方程开始,然后介绍简单但强大的模式匹配思想,最后介绍 lambda 表达式和操作符截面。
4.1 由旧函数构造新函数(New from old)
定义新函数最直接的方式,也许就是把一个或多个已有函数组合起来。下面展示了几个可以这样定义的库函数。
判断一个整数是否为偶数:
even :: Integral a => a -> Bool
even n = n `mod` 2 == 0
在第 n 个元素处分割列表:
splitAt :: Int -> [a] -> ([a],[a])
splitAt n xs = (take n xs, drop n xs)
求倒数:
recip :: Fractional a => a -> a
recip n = 1/n
注意上面 even 和 recip 的类型中使用了类约束,这精确表达了这样一个思想:这些函数分别可以应用于任意整数类型和任意分数类型的数字。
4.2 条件表达式(Conditional expressions)
Haskell 提供了多种定义函数的方式,用于在若干可能结果之间做选择。其中最简单的是条件表达式,它使用一个称为条件的逻辑表达式,在两个相同类型的结果之间做选择。如果条件为 True,就选择第一个结果;如果条件为 False,就选择第二个结果。例如,返回整数绝对值的库函数 abs 可以定义如下:
abs :: Int -> Int
abs n = if n >= 0 then n else -n
条件表达式可以嵌套,也就是说,它们可以包含其他条件表达式。例如,返回整数符号的库函数 signum 可以定义如下:
signum :: Int -> Int
signum n = if n < 0 then -1 else
if n == 0 then 0 else 1
注意,与某些编程语言不同,Haskell 中的条件表达式必须始终包含 else 分支,这避免了著名的悬空 else 问题。例如,如果 else 分支是可选的,那么表达式 if True then if False then 1 else 2 既可能返回结果 2,也可能产生错误,具体取决于唯一的 else 分支被认为属于内部条件表达式还是外部条件表达式。
4.3 守卫方程(Guarded equations)
除了使用条件表达式,函数也可以使用守卫方程(guarded equations)来定义。在这种方式中,一系列称为守卫(guards)的逻辑表达式用于在一系列相同类型的结果之间做选择。如果第一个守卫为 True,就选择第一个结果;否则,如果第二个守卫为 True,就选择第二个结果,依此类推。例如,库函数 abs 也可以用守卫方程定义如下:
abs n | n >= 0 = n
| otherwise = -n
符号 | 读作“使得”(such that),而守卫 otherwise 在标准 Prelude 中简单定义为 otherwise = True。用 otherwise 结束一组守卫并不是必需的,但它提供了一种方便方式来处理所有其他情况,同时也避免了这组守卫中没有任何一个为 True 的可能性,否则这种情况会导致错误。
相对于条件表达式,守卫方程的主要好处是带有多个守卫的定义更容易阅读。例如,库函数 signum 用下面方式定义时更容易理解:
signum n | n < 0 = -1
| n == 0 = 0
| otherwise = 1
4.4 模式匹配(Pattern matching)
许多函数都可以使用模式匹配给出简单而直观的定义。在模式匹配中,一系列称为模式的语法表达式用于在一系列相同类型的结果之间做选择。如果第一个模式匹配成功,就选择第一个结果;否则,如果第二个模式匹配成功,就选择第二个结果,依此类推。例如,返回逻辑值取反结果的库函数 not 可以定义如下:
not :: Bool -> Bool
not False = True
not True = False
带有多个参数的函数也可以使用模式匹配定义。在这种情况下,每个方程中针对各个参数的模式会按顺序匹配。例如,返回两个逻辑值合取结果的库操作符 && 可以定义如下:
(&&) :: Bool -> Bool -> Bool
True && True = True
True && False = False
False && True = False
False && False = False
不过,这个定义可以简化:后三个方程都返回 False,与两个参数的值无关,因此可以使用通配符模式 _ 把它们合并为一个方程。通配符模式可以匹配任意值:
True && True = True
_ && _ = False
这个版本还有一个好处:在第 15 章讨论的惰性求值下,如果第一个参数为 False,就可以直接返回结果 False,而不需要对第二个参数求值。在实践中,Prelude 使用的 && 定义也具有这个性质,但它只根据第一个参数的值来决定使用哪个方程:
True && b = b
False && _ = False
也就是说,如果第一个参数为 True,结果就是第二个参数的值;如果第一个参数为 False,结果就是 False。
注意,Haskell 不允许在单个方程中对多个参数使用相同的名字。例如,下面这个 && 操作符定义基于这样一个观察:如果两个逻辑参数相等,结果就是同一个值;否则结果为 False。但由于违反了上述命名要求,它是无效定义:
b && b = b
_ && _ = False
不过,如果愿意,可以使用守卫来判断两个参数是否相等,从而得到这个定义的有效版本:
b && c | b == c = b
| otherwise = False
到目前为止,我们只考虑了基本模式:它们要么是值,要么是变量,要么是通配符模式。本节余下部分介绍两种通过组合较小模式来构造较大模式的有用方式。
元组模式(Tuple patterns)
由模式组成的元组本身也是一个模式,它可以匹配任何具有相同元数的元组,并要求其中各个组件按顺序匹配对应模式。例如,分别选择二元组第一个和第二个组件的库函数 fst 和 snd 定义如下:
fst :: (a,b) -> a
fst (x,_) = x
snd :: (a,b) -> b
snd (_,y) = y
列表模式(List patterns)
类似地,由模式组成的列表本身也是一个模式,它可以匹配任何长度相同的列表,并要求其中各个元素按顺序匹配对应模式。例如,一个函数 test 用于判断列表是否恰好包含三个字符,并且以字母 'a' 开头,可以定义如下:
test :: [Char] -> Bool
test ['a',_,_] = True
test _ = False
到目前为止,我们一直把列表看作 Haskell 中的原始概念。事实上,列表本身并不是这样的原始概念,而是从空列表 [] 开始,使用一个称为 cons 的操作符 :,每次添加一个元素构造出来的。cons 会把一个新元素添加到已有列表的开头,从而构造出一个新列表。例如,列表 [1,2,3] 可以分解如下:
[1,2,3]
= { list notation }
1 : [2,3]
= { list notation }
1 : (2 : [3])
= { list notation }
1 : (2 : (3 : []))
也就是说,[1,2,3] 只是 1:(2:(3:[])) 的缩写。为了在处理这类列表时避免过多括号,cons 操作符默认向右结合。例如,1:2:3:[] 表示 1:(2:(3:[]))。
除了用于构造列表,cons 操作符也可以用于构造模式。这类模式可以匹配任何非空列表,并要求列表的第一个元素和剩余元素按顺序匹配对应模式。例如,现在可以定义一个更一般的 test 函数,用于判断包含任意数量字符的列表是否以字母 'a' 开头:
test :: [Char] -> Bool
test ('a':_) = True
test _ = False
类似地,分别选择和移除非空列表第一个元素的库函数 head 和 tail 定义如下:
head :: [a] -> a
head (x:_) = x
tail :: [a] -> [a]
tail (_:xs) = xs
注意,cons 模式必须加上圆括号,因为函数应用的优先级高于语言中的所有其他操作符。例如,没有括号的定义 head x:_ = x 表示 (head x):_ = x,这既不是想要的含义,也是一个无效定义。
4.5 Lambda 表达式(Lambda expressions)
除了使用方程定义函数,还可以使用 lambda 表达式构造函数。lambda 表达式包含每个参数的模式,以及一个主体,用来说明如何根据这些参数计算结果,但它并不会给函数本身命名。换句话说,lambda 表达式是无名函数。
例如,下面可以构造一个无名函数,它接收单个数字 x 作为参数,并产生结果 x + x:
\x -> x + x
符号 \ 表示希腊字母 lambda,写作 λ。虽然用 lambda 表达式构造的函数没有名字,但它们可以像任何其他函数一样使用。例如:
> (\x -> x + x) 2
4
lambda 表达式本身很有趣,同时也有若干实际用途。首先,它们可以用于形式化柯里化函数定义的含义。例如,定义:
add :: Int -> Int -> Int
add x y = x + y
可以理解为:
add :: Int -> (Int -> Int)
add = \x -> (\y -> x + y)
这精确说明了 add 是一个接收整数 x 并返回一个函数的函数,而返回的函数再接收另一个整数 y,并返回结果 x + y。此外,以这种方式改写原始定义还有一个好处:函数类型和函数定义方式现在具有相同的语法形式,即 ? -> (? -> ?)。
其次,当定义那些本质上会返回函数作为结果的函数时,lambda 表达式也很有用,这种返回函数并不是柯里化带来的结果。例如,库函数 const 返回一个总是产生给定值的常量函数,可以定义如下:
const :: a -> b -> a
const x _ = x
不过,用一种明确表示 const 会返回一个函数作为结果的方式来定义它更自然:在类型中加入括号,并在定义本身中使用 lambda 表达式:
const :: a -> (b -> a)
const x = \_ -> x
最后,lambda 表达式可以用于避免给程序中只引用一次的函数命名。例如,一个返回前 n 个奇数的函数 odds 可以定义如下:
odds :: Int -> [Int]
odds n = map f [0..n-1]
where
f x = x*2 + 1
(库函数 map 会把一个函数应用到列表的所有元素上。)不过,因为局部定义的函数 f 只被引用了一次,所以可以使用 lambda 表达式来简化 odds 的定义:
odds :: Int -> [Int]
odds n = map (\x -> x*2 + 1) [0..n-1]
4.6 操作符截面(Operator sections)
像 + 这样写在两个参数之间的函数称为操作符。正如我们已经看到的,任何带两个参数的函数都可以通过把函数名包在反引号中转换为操作符,例如 7 `div` 2。不过,反过来也可以。具体来说,任何操作符都可以通过把操作符名包在圆括号中,转换成写在参数前面的柯里化函数,例如 (+) 1 2。此外,如果需要,这个约定还允许把其中一个参数也包含在圆括号中,例如 (1+) 2 和 (+2) 1。
一般来说,如果 # 是一个操作符,那么对于参数 x 和 y,形式为 (#)、(x #) 和 (# y) 的表达式称为截面(sections),它们作为函数的含义可以用 lambda 表达式形式化如下:
(#) = \x -> (\y -> x # y)
(x #) = \y -> x # y
(# y) = \x -> x # y
截面有三种主要用途。首先,它们可以用特别紧凑的方式构造一些简单但有用的函数,如下面示例所示:
(+) -- \x -> (\y -> x+y),加法函数
(1+) -- \y -> 1+y,后继函数
(1/) -- \y -> 1/y,倒数函数
(*2) -- \x -> x*2,翻倍函数
(/2) -- \x -> x/2,减半函数
其次,说明操作符类型时必须使用截面,因为操作符本身在 Haskell 中不是有效表达式。例如,整数加法操作符 + 的类型说明如下:
(+) :: Int -> Int -> Int
最后,把操作符作为参数传给其他函数时,也必须使用截面。例如,计算整数列表之和的库函数 sum 可以通过把操作符 + 作为参数传给库函数 foldl 来定义;foldl 本身会在第 7 章讨论:
sum :: [Int] -> Int
sum = foldl (+) 0
4.7 章注(Chapter remarks)
Haskell Report [4] 给出了通过翻译到语言中更原始特性来解释模式匹配的形式化含义。定义无名函数时使用的希腊字母 λ 来自 lambda 演算 [6],这是 Haskell 所基于的函数数学理论。
4.8 练习(Exercises)
- 使用库函数定义一个函数
halve :: [a] -> ([a],[a]),它把一个偶数长度的列表分成两半。例如:
> halve [1,2,3,4,5,6]
([1,2,3],[4,5,6])
-
定义函数
third :: [a] -> a,它返回一个至少包含三个元素的列表中的第三个元素。分别使用:a.
head和tail; b. 列表索引!!; c. 模式匹配。 -
考虑函数
safetail :: [a] -> [a],它的行为与tail相同,但会把空列表映射为自身,而不是产生错误。使用tail和函数null :: [a] -> Bool(该函数判断列表是否为空),分别用下面方式定义safetail:a. 条件表达式; b. 守卫方程; c. 模式匹配。
-
仿照 4.4 节中的
&&,说明如何使用模式匹配以四种不同方式定义析取操作符||。 -
不使用任何其他库函数或操作符,说明如何用条件表达式形式化下面这个逻辑合取
&&的模式匹配定义的含义:
True && True = True
_ && _ = False
提示:使用两个嵌套的条件表达式。
- 对下面这个替代定义做同样的事情,并注意所需条件表达式数量的差异:
True && b = b
False && _ = False
- 说明如何用 lambda 表达式形式化下面这个柯里化函数定义的含义:
mult :: Int -> Int -> Int -> Int
mult x y z = x*y*z
-
Luhn 算法用于检查银行卡号中的简单错误,例如误输入了某个数字。它的过程如下:
- 把每个数字看作一个单独的数;
- 从倒数第二个数字开始向左,每隔一个数字翻倍;
- 对每个现在大于 9 的数字减去 9;
- 把得到的所有数字相加;
- 如果总和能被 10 整除,则卡号有效。
定义函数 luhnDouble :: Int -> Int,它把一个数字翻倍,并在结果大于 9 时减去 9。例如:
> luhnDouble 3
6
> luhnDouble 6
3
使用 luhnDouble 和整数余数函数 mod,定义函数 luhn :: Int -> Int -> Int -> Int -> Bool,用于判断四位银行卡号是否有效。例如:
> luhn 1 7 8 4
True
> luhn 4 7 8 3
False
在第 7 章的练习中,我们会考虑这个函数的一个更一般版本,它可以接受任意长度的卡号。
练习 1-4 的解答见附录 A。