跳转到内容

Haskell/模式匹配

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

在之前的模块中,我们介绍了模式匹配,并偶尔提及它。现在我们已经对这门语言有一定的了解了,是时候进行深入的学习了。我们将从一个简短的描述开始讨论,并在本章中进一步展开。

在模式匹配中,我们尝试将值匹配模式上,如果需要,可以将变量绑定到匹配成功的结果。.

分析模式匹配

[编辑 | 编辑源代码]

模式匹配几乎无处不在。例如,考虑这个 map 的定义

map _ []     = []
map f (x:xs) = f x : map f xs

在表面上,每个方程式包含四个不同的模式,总共两个。

  • f 是一个模式,它匹配任何值,并将 f 变量绑定到匹配到的任何值。
  • (x:xs) 是一个模式,它匹配一个非空列表,该列表由一个值组成(该值被绑定到 x 变量)使用 (:) 函数连接到另一个值上(该值被绑定到 xs)。
  • [] 是一个模式,它匹配空列表。它不绑定任何变量。
  • _ 是一个模式,它匹配任何值而不绑定(通配符,“不关心”模式)。

(x:xs) 模式中,xxs 可以被视为用于匹配列表各个部分的子模式。就像 f 一样,它们匹配任何值 - 尽管很明显,如果匹配成功且 x 的类型为 a,则 xs 的类型将为 [a]。最后,这些考虑表明 xs 也会匹配空列表,因此一个元素列表也会匹配 (x:xs)

从上面的分析中,我们可以说模式匹配给我们提供了一种方法来

  • 识别值。例如,当 map 被调用且第二个参数匹配 [] 时,map 的第一个方程式被使用,而不是第二个方程式。
  • 将变量绑定到识别到的值。在这种情况下,当使用第二个方程式时,fxxs 变量被分配到传递给 map 的参数值,因此我们可以在 = 右侧通过变量使用这些值。正如 _[] 所示,绑定不是模式匹配的必要部分,而只是使用变量名作为模式的副作用。
  • 将值分解成各个部分,就像 (x:xs) 模式通过将两个变量绑定到匹配参数(非空列表)的各个部分(头部和尾部)来做的那样。

与构造函数的连接

[编辑 | 编辑源代码]

尽管上面进行了详细的分析,但我们似乎仍然感觉有些神奇,我们似乎分解了列表,就好像我们在撤销 (:) 运算符的效果。小心:这个过程不适用于任何任意的运算符。例如,人们可能会想到定义一个函数,使用 (++) 来截断列表的前三个元素。

dropThree ([x,y,z] ++ xs) = xs

但这将不起作用(++) 函数不允许在模式中使用。事实上,大多数其他对列表进行操作的函数都被禁止进行模式匹配。那么,哪些函数允许的呢?

简单地说,构造函数 - 用于构建代数数据类型值的函数。让我们考虑一个随机的例子

data Foo = Bar | Baz Int

这里 BarBazFoo 类型 的构造函数。你可以将它们用于匹配 Foo 值的模式,并将变量绑定到用 Baz 构造的 Foo 中包含的 Int 值。

f :: Foo -> Int
f Bar     = 1
f (Baz x) = x - 1

这与类型声明模块中的 showAnniversaryshowDate 完全一样。例如

data Date = Date Int Int Int   -- Year, Month, Day
showDate :: Date -> String
showDate (Date y m d) = show y ++ "-" ++ show m ++ "-" ++ show d

showDate 定义左侧的 (Date y m d) 模式匹配一个 Date(用 Date 构造函数构建)并将 ymd 变量绑定到 Date 值的内容。

为什么它适用于列表?

[编辑 | 编辑源代码]

至于列表,就模式匹配而言,它们与用 data 定义的代数数据类型没有区别。它的工作原理就好像列表是用这个 data 声明定义的(注意以下语法实际上无效:列表实际上与 Haskell 结合得太紧密,无法像这样定义)。

data [a] = [] | a : [a]

因此,空列表 [](:) 函数是列表数据类型的构造函数,因此你可以用它们进行模式匹配。[] 不接受任何参数,因此当它用于模式匹配时,不会绑定任何变量。(:) 接受两个参数,列表头部和尾部,因此当模式被识别时,可能会将变量绑定到它们。

Prelude> :t []
[] :: [a]
Prelude> :t (:)
(:) :: a -> [a] -> [a]

此外,由于 [x, y, z] 只是 x:y:z:[] 的语法糖,我们可以使用仅包含模式匹配来实现类似于 dropThree 的功能。

dropThree :: [a] -> [a]
dropThree (_:_:_:xs) = xs
dropThree _          = []

第一个模式将匹配任何至少包含三个元素的列表。当列表无法匹配主模式时,通用的第二个定义提供了一个合理的默认值[1],从而避免了由于模式匹配失败而导致的运行时崩溃。

注意


从我们可以用简单的模式匹配来编写 dropThree 函数这一事实并不意味着我们应该这样做!虽然解决方案很简单,但为如此特定的东西编写代码仍然是一种浪费。我们可以直接使用 Prelude 并用 drop 3 xs 来解决这个问题。就像之前关于直接编写递归函数的说法一样,我们也可以说:不要对模式匹配过于兴奋……


元组构造函数

[编辑 | 编辑源代码]

类似的考虑适用于元组。我们通过模式匹配访问它们的组件……

fstPlusSnd :: (Num a) => (a, a) -> a
fstPlusSnd (x, y) = x + y

norm3D :: (Floating a) => (a, a, a) -> a
norm3D (x, y, z) = sqrt (x^2 + y^2 + z^2)

……是通过元组构造函数的存在来实现的。对于对,构造函数是逗号运算符 (,);对于更大的元组,有 (,,)(,,,) 等等。这些运算符有点特殊,因为我们不能以常规方式使用它们作为中缀运算符;因此 5 , 3 不是编写 (5, 3) 的有效方法。然而,所有这些都可以用前缀方式使用,这在某些情况下非常有用。

Prelude> (,) 5 3
(5,3)
Prelude> (,,,) "George" "John" "Paul" "Ringo"
("George","John","Paul","Ringo")

匹配字面值

[编辑 | 编辑源代码]

如本书前面所述,像这样简单的分段函数定义

f :: Int -> Int
f 0 = 1
f 1 = 5
f 2 = 2
f _ = -1

也在进行模式匹配,将 f 的参数与 Int 字面值 0、1 和 2 进行匹配,最后与 _ 匹配。通常,数值和字符字面值可以在模式匹配中单独使用[2],也可以与构造函数模式一起使用。例如,这个函数

g :: [Int] -> Bool
g (0:[]) = False
g (0:xs) = True
g _ = False

对于 [0] 列表将计算为 False,如果列表的第一个元素为 0 且尾部非空,则计算为 True,在所有其他情况下计算为 False。此外,包含字面元素的列表,例如 [1,2,3],甚至 "abc"(等同于 ['a','b','c'])也可以用于模式匹配,因为这些形式只是 (:) 构造函数的语法糖。

上述考虑仅适用于字面值,因此以下代码将不起作用

k = 1
--again, this won't work as expected
h :: Int -> Bool
h k = True
h _ = False
练习
  1. 在 GHCi 中测试上面有缺陷的 h 函数,使用等于 1 和不同于 1 的参数。然后解释哪里出错了。
  2. 在本节关于使用字面量进行模式匹配的内容中,我们没有提到布尔值 TrueFalse,但我们也可以使用它们进行模式匹配,如 下一步 章中所示。你能猜到为什么我们省略了它们吗?(提示: 我们编写布尔值的方式有什么独特之处?)


语法技巧

[edit | edit source]

作为模式

[edit | edit source]

有时,在匹配值中的子模式时,将名称绑定到正在匹配的整个值可能很有用。作为模式允许这样做:它们的形式为 var@pattern,并且还具有将名称 var 绑定到由 pattern 匹配的整个值的效果。例如,以下是对 map 主题的玩具变体

contrivedMap :: ([a] -> a -> b) -> [a] -> [b]
contrivedMap f [] = []
contrivedMap f list@(x:xs) = f list x : contrivedMap f xs

contrivedMap 不仅将 x 传递给参数函数 f,还传递了每个递归调用的参数使用的未分割列表。在没有作为模式的情况下编写它会有点笨拙,因为我们必须要么使用 head,要么不必要地重新构造list 的原始值,即实际上在右侧计算 x:xs

contrivedMap :: ([a] -> a -> b) -> [a] -> [b]
contrivedMap f [] = []
contrivedMap f (x:xs) = f (x:xs) x : contrivedMap f xs
练习
实现 scanr,如 列表 III 中的练习,但这次使用作为模式。

记录简介

[edit | edit source]

对于具有多个元素的构造函数,记录 提供了一种使用以下语法在数据类型中命名值的方法

data Foo2 = Bar2 | Baz2 {bazNumber::Int, bazName::String}

使用记录允许仅对与我们正在编写的函数相关的变量进行匹配和绑定,从而使代码更清晰

h :: Foo2 -> Int
h Baz2 {bazName=name} = length name
h Bar2 {} = 0

x = Baz2 1 "Haskell"     -- construct by declaration order, try ":t Baz2" in GHCi
y = Baz2 {bazName = "Curry", bazNumber = 2} -- construct by name

h x -- 7
h y -- 5

此外,即使您在 data 声明中不使用记录,{} 模式也可以用于匹配构造函数,而不管数据类型元素是什么。

data Foo = Bar | Baz Int
g :: Foo -> Bool
g Bar {} = True
g Baz {} = False

如果我们修改构造函数 BarBaz 的元素数量或类型,函数 g 不必更改。

使用记录语法还有其他优势,我们将在 命名字段 章的更多关于数据类型部分中详细介绍。

我们可以在哪里使用模式匹配

[edit | edit source]

简短的答案是,无论您可以在哪里绑定变量,您都可以进行模式匹配。让我们看一下我们以前见过的这些地方;在后面的章节中将介绍一些更多的地方。

方程

[edit | edit source]

最明显的用例是函数定义方程的左侧,这是我们迄今为止示例的主题。

map _ []     = []
map f (x:xs) = f x : map f xs

map 定义中,我们对两个方程的左侧进行模式匹配,并在第二个方程上绑定变量。

let 表达式和 where 子句

[edit | edit source]

letwhere 都是进行局部变量绑定的方法。因此,您也可以在其中使用模式匹配。一个简单的例子

y = let (x:_) = map (*2) [1,2,3]
    in x + 5

或者,等效地,

y = x + 5
    where 
    (x:_) = map (*2) [1,2,3]

这里,x 将绑定到 map ((*) 2) [1,2,3] 的第一个元素。因此,y 将计算为 .

Lambda 抽象

[edit | edit source]

模式匹配可以直接在 lambda 抽象中使用

swap = \(x,y) -> (y,x)

然而,很明显,此语法只允许一个模式(或者在多参数 lambda 抽象的情况下,每个参数一个模式)。

列表推导

[edit | edit source]

在列表推导中的 | 之后,您可以进行模式匹配。这实际上非常有用,并且为推导的表达能力增加了许多功能。让我们看看它是如何在一个稍微复杂一点的例子中工作的。Prelude 提供了一个 Maybe 类型,它具有以下构造函数

data Maybe a = Nothing | Just a

它通常用于保存操作的结果,该操作可能成功也可能失败;如果操作成功,则使用 Just 构造函数并将值传递给它;否则使用 Nothing[3] 实用函数 catMaybes(可从 Data.Maybe 库模块获得)接受一个 Maybe 列表(可能包含“Just”和“Nothing”Maybe),并通过过滤掉 Nothing 值并删除 Just xJust 包装器来检索包含的值。使用列表推导编写它非常简单

catMaybes :: [Maybe a] -> [a]
catMaybes ms = [ x | Just x <- ms ]

使用列表推导执行此任务的另一个好处是,如果模式匹配失败(即它遇到 Nothing),它只会继续执行 ms 中的下一个元素,从而避免需要使用备用函数定义显式处理我们不感兴趣的构造函数。[4]

do

[edit | edit source]

简单输入和输出 章中使用的 do 块中,我们可以使用左箭头变量绑定的左侧进行模式匹配

putFirstChar = do
    (c:_) <- getLine
    putStrLn [c]

此外,就模式匹配而言,do 块中的 let 绑定与“实际”的 let 表达式相同。

笔记

  1. 对于此特定任务合理,并且仅仅是因为期望 dropThree 在应用于一个列表(例如,两个元素)时会给出 []。对于不同的问题,如果第一次匹配失败,返回任何列表可能并不合理。在后面的章节中,我们将考虑一种处理此类情况的简单方法。
  2. 正如可能预期的那样,这种使用字面量的匹配不是基于构造函数的。相反,幕后有一个相等性比较
  3. 这种操作的典型例子是在字典中查找值 - 这可能只是一个 [(a, b)] 列表,其中元组是键值对,或者是一个更复杂的实现。无论如何,如果我们给定一个任意键,尝试检索一个值,我们无法保证我们实际上会找到与该键关联的值。
  4. 它以这种方式工作而不是在模式匹配失败时崩溃的原因与列表推导的真实本质有关:它们实际上是列表单子的包装器。当我们讨论单子时,我们最终会解释这意味着什么。
华夏公益教科书