Haskell 基本语法(二)模式匹配

式匹配包含一系列特定的模式,用来判断数据是否符合规则,且能够通过这些模式把符合要求的数据解构出来。
Haskell 中的模式匹配可以应用到任意的数据类型上(数字、字符、列表、元组等等)。

函数中的模式匹配

可以在函数体的定义中,用不同的代码行分别指定不同的模式:

1
2
3
4
5
6
7
8
9
10
11
12
Prelude> :{
Prelude| lucky :: (Integral a) => a -> String
Prelude| lucky 7 = "LUCKY NUMBER SEVEN!"
Prelude| lucky x = "Sorry, you're out of luck!"
Prelude| :}
Prelude>
Prelude> lucky 1
"Sorry, you're out of luck!"
Prelude> lucky 10
"Sorry, you're out of luck!"
Prelude> lucky 7
"LUCKY NUMBER SEVEN!"

PS:上述代码是在 Haskell 的交互式解释器(REPL) ghci 中定义和执行函数的效果。
lucky 这种包含多行代码的函数,在 ghci 解释器中直接定义时,需要把整个函数体用 :{:} 括起来(否则解释器会报错)。实际的 lucky 函数代码应为:

1
2
3
lucky :: (Integral a) => a -> String  
lucky 7 = "LUCKY NUMBER SEVEN!"
lucky x = "Sorry, you're out of luck, pal!"

:{:} 从代码的角度讲是多余的,只是 ghci 解释器的缘故,导致必须加上这两个分隔符。若在文件中编写代码,则应该使用第二种形式。

代码的第一行 lucky :: (Integral a) => a -> String 是函数的类型签名,也可以省略,解释器会自行推导。
lucky 7lucky x 两行代码则指定了具体的两个模式:
当函数输入为数字 7 时匹配第一个模式,任何其他的数字输入则匹配第二个模式并将该输入值绑定给变量 x

一个包含更多个模式的函数:

1
2
3
4
5
6
7
sayMe :: (Integral a) => a -> 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 必须作为最后一个模式。
函数体中的模式会按照自顶而下的顺序检查是否匹配,若当前的模式已完成匹配,则忽略后面的检查;若当前模式不匹配,则继续向下逐个进行检查。
sayMe x 作为顶部的第一个模式(它实际上会匹配所有合法值),则任何输入值都会在第一步就完成匹配,进而忽略后面的 sayMe 1sayMe 2 等模式,不再进行判断。即输入任何数字都会先匹配 x 并输出 Not between 1 and 5。

使用模式匹配和递归实现阶乘函数

1
2
3
factorial :: (Integral a) => a -> a  
factorial 0 = 1
factorial n = n * factorial (n - 1)

比如当输入为 3 时,factorial 函数会匹配第二个模式,结果为 3 * (factorial 2)。继续迭代,进一步计算结果中的 factorial 2,得到 3 * (2 * (factorial 1))3 * (2 * (1 * (factorial 0)))
factorial 0 会匹配第一个模式得到结果 1,迭代终止,再和前面的数字相乘后得到最终结果。

假如将 factorial n = n * factorial (n - 1) 作为第一个模式,则 factorial n 会匹配包含数字 0 在内的所有数字,另一个模式 factorial 0 = 1 就永远不会触发。从而导致迭代没有终止条件,一直进行下去。
因此,在模式匹配中,更精确更有指向性的模式总是放在相对通用和宽泛的模式前面

在使用模式匹配时,应该总是包含一个 catch-all 模式,这样就不会出现所有模式都不匹配的情况。若程序的输入与所有模式都不匹配,程序会崩溃掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
Prelude> :{
Prelude| charName :: Char -> String
Prelude| charName 'a' = "Albert"
Prelude| charName 'b' = "Broseph"
Prelude| charName 'c' = "Cecil"
Prelude| :}
Prelude>
Prelude> charName 'a'
"Albert"
Prelude> charName 'b'
"Broseph"
Prelude> charName 'h'
*** Exception: <interactive>:(3,1)-(5,22): Non-exhaustive patterns in function charName

元组中的模式匹配

在不使用模式匹配的情况下,实现一个计算两个向量之和的函数:

1
2
addVectors :: (Num a) => (a, a) -> (a, a) -> (a, a)
addVectors a b = (fst a + fst b, snd a + snd b)

通过模式匹配实现上述功能:

1
2
3
4
5
6
7
Prelude> :{
Prelude| addVectors :: (Num a) => (a, a) -> (a, a) -> (a, a)
Prelude| addVectors (x1, y1) (x2, y2) = (x1 + x2, y1 + y2)
Prelude| :}
Prelude>
Prelude> addVectors (1, 2) (3, 4)
(4,6)

fstsnd 函数可以分别用来获取元组中的第一个和第二个元素(但是只针对包含两个元素的元组)。
对于有 3 个元素的元组,实际上可以借助模式匹配自己实现:

1
2
3
4
5
6
7
8
first :: (a, b, c) -> a
first (x, _, _) = x

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

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

可以在列表推导中使用模式匹配:

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

甚至列表本身也可以用于模式匹配。
如模式 x:xs 会将列表的第一个元素绑定给变量 x,把其余元素绑定给 xs。此模式的应用非常普遍,尤其是在递归函数中。
如果想提取列表的前 3 个元素并将它们绑定给指定变量,可以使用 x:y:z:zs 形式的模式。

利用对列表的模式匹配实现自定义的 head 函数(获取列表中的第一个元素):

1
2
3
4
5
6
7
8
9
10
Prelude> :{
Prelude| head' :: [a] -> a
Prelude| head' [] = error "Can't call head on an empty list!"
Prelude| head' (x:_) = x
Prelude| :}
Prelude>
Prelude> head' [4, 5, 6]
4
Prelude> head' "Hello"
'H'

借助递归和模式匹配实现自定义的 length 函数(获取列表的长度):

1
2
3
length' :: (Num b) => [a] -> b
length' [] = 0
length' (_:xs) = 1 + length' xs

对于任何一个合法的输入如 "ham"length' 函数的计算过程如下:
length' "ham" => 1 + length' "am" => 1 + (1 + length' "m") => 1 + (1 + (1 + length' [])) => 1 + (1 + (1 + 0))

实现自定义的 sum 函数(求列表中各元素之和):

1
2
3
sum' :: (Num a) => [a] -> a
sum' [] = 0
sum' (x:xs) = x + sum' xs

守卫(guards)

守卫一般用来测试某个(些)值的特定属性是否为真,很像 if 语句。守卫和模式整合得非常好。

以下是一个求 BMI(体重指数)的函数定义:

1
2
3
4
5
6
bmiTell :: (RealFloat a) => a -> 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,则继续测试下一个守卫。

通常情况下,最后一个守卫是 otherwise。它其实是 otherwise = True 的简写形式,会捕获所有剩余的情况。

守卫可以配合有多个参数的函数使用:

1
2
3
4
5
6
bmiTell :: (RealFloat a) => a -> a -> 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!"

1
2
Prelude> bmiTell 65 1.75
"You're supposedly normal. Pffft, I bet you're ugly!"

通过守卫自定义 max 函数:

1
2
3
4
max' :: (Ord a) => a -> a -> a
max' a b
| a > b = a
| otherwise = b

where
可以通过 where 关键字优化上面的 bmiTell 函数:

1
2
3
4
5
6
7
bmiTell :: (RealFloat a) => a -> a -> 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

变量 bmi 在这里只计算了一次,不同于之前的 weight / height ^ 2 有可能会被重复计算 3 次。

更进一步,bmiTell 函数还可以改为如下形式:

1
2
3
4
5
6
7
8
9
10
bmiTell :: (RealFloat a) => a -> a -> 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

where 语句中也可以定义函数,比如通过由多个包含身高体重的元组组成的列表,计算一系列 BMI 值:

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

参考资料

Learn You a Haskell for Great Good!