Haskell/下一步
本章介绍了模式匹配以及两个新的语法元素:if
表达式和let
绑定。
Haskell 语法支持if... then... else... 形式的普通条件表达式。例如,考虑一个函数,如果它的参数小于0
则返回(-1)
;如果它的参数是0
则返回0
;如果它的参数大于0
则返回1
。预定义的signum
函数已经完成了这项工作;但为了说明起见,让我们定义我们自己的版本
示例: signum 函数。
mySignum x =
if x < 0
then -1
else if x > 0
then 1
else 0
您可以尝试使用这个
*Main> mySignum 5 1 *Main> mySignum 0 0 *Main> mySignum (5 - 10) -1 *Main> mySignum (-1) -1
在最后一个例子中,"-1"
括号是必须的;如果缺少,Haskell 会认为您试图从mySignum
中减去1
(这会导致类型错误)。
在 if/then/else 结构中,首先评估条件(在本例中为x < 0
)。如果结果为True
,则整个结构将评估为then表达式;否则(如果条件为False
),则该结构将评估为else表达式。所有这些都非常直观。但是,如果您以前使用过命令式语言,那么您可能会惊讶地发现 Haskell 总是要求同时有then 和else子句。该结构必须在两种情况下都产生一个值,并且特别是在两种情况下都产生相同类型的值。
使用 if / then / else 的函数定义,如上面的示例,可以使用Guards重写。
示例: 从 if 到 guards
mySignum x
| x < 0 = -1
| x > 0 = 1
| otherwise = 0
类似地,在Truth values 中定义的绝对值函数可以用 if/then/else 呈现
示例: 从 guards 到 if
absolute x =
if x < 0
then -x
else x
为什么要使用 if/then/else 而不是 guards?正如您将在后面的示例以及您自己的编程中看到的那样,两种处理条件的方式在可读性或便捷性方面可能会有所不同,具体取决于情况。在很多情况下,两种选择都能同样有效。
考虑一个程序,该程序跟踪来自赛车比赛的统计数据,其中赛车手根据他们在每场比赛中的分类获得积分,计分规则为
- 第一名获得 10 分;
- 第二名获得 6 分;
- 第三名获得 4 分;
- 第四名获得 3 分;
- 第五名获得 2 分;
- 第六名获得 1 分;
- 其他赛车手没有积分。
我们可以编写一个简单的函数,它接收一个分类(用一个整数表示:1
表示第一名等[1])并返回获得的积分。一个可能的解决方案使用 if/then/else
示例: 使用 if/then/else 计算积分
pts :: Int -> Int
pts x =
if x == 1
then 10
else if x == 2
then 6
else if x == 3
then 4
else if x == 4
then 3
else if x == 5
then 2
else if x == 6
then 1
else 0
太糟糕了!不可否认,如果我们使用 guards 而不是 if/then/else,它看起来不会这么难看,但编写(和阅读!)所有这些相等性测试仍然很乏味。不过,我们可以做得更好
示例: 使用分段定义计算积分
pts :: Int -> Int
pts 1 = 10
pts 2 = 6
pts 3 = 4
pts 4 = 3
pts 5 = 2
pts 6 = 1
pts _ = 0
好多了。但是,即使以这种方式定义pts
(我们现在将其任意地称为分段定义)以清晰的方式向代码阅读者展示了该函数的作用,但考虑到我们迄今为止看到的 Haskell,语法看起来很奇怪。为什么pts
有七个等式?这些数字在它们的左侧在做什么?变量参数呢?
Haskell 的这个特性称为模式匹配。当我们调用pts
时,参数会与每个等式左侧的数字进行匹配,而这些数字 wiederum 是模式。匹配是按照我们编写等式的顺序进行的。首先,参数与第一个等式中的1
进行匹配。如果参数确实是1
,则我们匹配成功,并且使用第一个等式;因此pts 1
将如预期的那样评估为10
。否则,将按照相同的方式顺序尝试其他等式。然而,最后一个等式有点不同:_
是一个特殊的模式,通常称为“通配符”,可以理解为“任何东西”:它与任何东西都匹配;因此,如果参数与任何以前的模式都不匹配,则pts
将返回零。
至于缺少x
或任何其他代表参数的变量,我们根本不需要它来编写定义。所有可能的返回值都是常量。此外,变量用于在定义的右侧表达关系,因此在我们的pts
函数中,x
是不必要的。
但是,我们可以使用一个变量来使pts
更简洁。给予赛车手的积分从第三名到第六名按每位置一分的速度规律递减。注意到这一点后,我们可以消除七个等式中的三个,如下所示
示例: 混合样式
pts :: Int -> Int
pts 1 = 10
pts 2 = 6
pts x
| x <= 6 = 7 - x
| otherwise = 0
所以,我们可以混合两种定义风格。事实上,当我们在等式的左侧编写pts x
时,我们也使用模式匹配!作为模式,x
(或任何其他变量名)与_
一样匹配任何东西;唯一的区别是它还为我们提供了在右侧使用的名称(在本例中,这是编写7 - x
所必需的)。
练习 |
---|
从第二版pts 到第三版,我们有点作弊:它们并不完全相同。你能发现区别是什么吗? |
除了整数,模式匹配还可以使用各种其他类型的值。一个方便的例子是布尔值。例如,我们在Truth values 中遇到的(||)
逻辑或运算符可以定义为
示例: (||)
(||) :: Bool -> Bool -> Bool
False || False = False
_ || _ = True
或者
示例: (||)
,另一种方式
(||) :: Bool -> Bool -> Bool
True || _ = True
False || y = y
当同时匹配两个或多个参数时,只有当所有参数都匹配时,才会使用该等式。
现在,让我们讨论使用模式匹配时可能出现的一些问题
- 如果我们将一个匹配任何东西的模式(例如每个
pts
示例中的最后一个模式)放在更具体的模式之前,则后者将被忽略。GHC(i) 通常会在这种情况下警告我们“模式匹配重叠”。 - 如果没有模式匹配成功,则会触发错误。通常,最好确保模式涵盖所有情况,就像
otherwise
guard 不是必需的,但强烈建议使用一样。 - 最后,虽然您可以尝试使用各种方法(重新)定义
(&&)
[2],但这里有一个版本不能正常工作
(&&) :: Bool -> Bool -> Bool
x && x = x -- oops!
_ && _ = False
- 该程序不会测试参数是否相等,仅仅因为我们碰巧对两个参数使用了相同的名称。就匹配而言,我们也可以在第一种情况下编写
_ && _
。更糟糕的是:因为我们对两个参数都使用了相同的名称,所以 GHC(i) 由于“`x` 的冲突定义”而拒绝该函数。
虽然上面的例子表明模式匹配有助于编写更优雅的代码,但这并不能解释它为什么如此重要。因此,让我们考虑编写fst
定义的问题,该函数提取一对中的第一个元素。在这一点上,这似乎是不可能的任务,因为访问一对第一个值的唯一方法是使用fst
本身...但是,以下函数执行与fst
相同的操作(在 GHCi 中确认)
示例: fst
的定义
fst' :: (a, b) -> a
fst' (x, _) = x
太神奇了!我们没有在等式的左侧使用普通变量,而是用 2 元组的模式(即(,)
)指定了参数,该模式用一个变量和_
模式填充。然后,该变量会自动与元组的第一个分量相关联,我们使用它来编写等式的右侧。当然,snd
的定义类似。
此外,上面演示的技巧也可以用于列表。以下是head
和tail
的实际定义
示例: head
、tail
和模式
head :: [a] -> a
head (x:_) = x
head [] = error "Prelude.head: empty list"
tail :: [a] -> [a]
tail (_:xs) = xs
tail [] = error "Prelude.tail: empty list"
与上一个示例相比,唯一重要的改变是将(,)
替换为 cons 运算符(:)
的模式。这些函数还使用空列表的模式[]
有一个等式;但是,由于空列表没有头或尾,所以除了使用error
打印更漂亮的错误消息之外,我们什么也做不了。
总之,模式匹配的力量来自它在访问复杂值的部分中的使用。特别是,在Recursion 及其后的章节中,列表上的模式匹配将被广泛使用。稍后,我们将探讨这个看似神奇的特性背后发生了什么。
为了结束本章,我们简单说一下let
绑定(一种用于进行局部声明的where
子句的替代方案)。例如,考虑寻找形式为 的多项式(换句话说,是二次方程的解 - 回想一下你的初中数学课)的根。它的解由下式给出
- .
我们可以编写以下函数来计算 的两个值
roots a b c =
((-b + sqrt(b * b - 4 * a * c)) / (2 * a),
(-b - sqrt(b * b - 4 * a * c)) / (2 * a))
然而,在两种情况下都写sqrt(b * b - 4 * a * c)
项很烦人;我们可以使用局部绑定来代替,使用where
或,如将在下面演示的那样,使用let
声明
roots a b c =
let sdisc = sqrt (b * b - 4 * a * c)
in ((-b + sdisc) / (2 * a),
(-b - sdisc) / (2 * a))
我们将let
关键字放在声明之前,然后使用in
来表示我们正在返回到函数的“主体”。可以在单个let
...in
块内放置多个声明 - 只要确保它们的缩进量相同,否则会出现语法错误。
roots a b c =
let sdisc = sqrt (b * b - 4 * a * c)
twice_a = 2 * a
in ((-b + sdisc) / twice_a,
(-b - sdisc) / twice_a)
由于缩进在 Haskell 中在语法上很重要,因此您需要小心使用的是制表符还是空格。最好的解决方案是将您的文本编辑器配置为插入两个或四个空格以代替制表符。如果您坚持将制表符保留为不同的符号,至少要确保您的制表符始终具有相同的长度。 |
注意
在 缩进 章节中,全面解释了缩进规则。