跳转到内容

Haskell/下一步

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

本章介绍了模式匹配以及两个新的语法元素:if 表达式和let 绑定。

if / then / else

[编辑 | 编辑源代码]

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 的定义类似。

此外,上面演示的技巧也可以用于列表。以下是headtail 的实际定义

示例: headtail 和模式

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绑定

[编辑 | 编辑源代码]

为了结束本章,我们简单说一下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)

注意

缩进 章节中,全面解释了缩进规则。


注释

  1. 这里我们不会过多地关注将无意义的值(比如 (-4))传递给函数会发生什么。然而,总的来说,最好仔细考虑一下这种“奇怪”的情况,以避免将来出现讨厌的错误。
  2. 如果您要在 GHCi 中进行尝试,请将您的版本命名为其他名称以避免名称冲突;比如,(&!&)。
华夏公益教科书