Haskell/列表和元组
Haskell 使用两种基本结构来管理多个值:列表和元组。它们都通过将多个值组合成一个单一组合值来工作。
让我们在 GHCi 中构建一些列表
Prelude> let numbers = [1,2,3,4] Prelude> let truths = [True, False, False] Prelude> let strings = ["here", "are", "some", "strings"]
方括号界定列表,各个元素用逗号分隔。唯一的限制是,列表中的所有元素必须是相同类型。尝试定义具有混合类型元素的列表会导致典型的类型错误
Prelude> let mixed = [True, "bonjour"] <interactive>:1:19: Couldn't match `Bool' against `[Char]' Expected type: Bool Inferred type: [Char] In the list element: "bonjour" In the definition of `mixed': mixed = [True, "bonjour"]
除了使用方括号和逗号一次性指定整个列表之外,还可以使用 (:)
运算符(发音为“cons”)逐个构建列表。这种构建列表的过程通常被称为consing。这个术语来自 LISP 程序员,他们发明了动词“cons”(“constructor”的助记符)来指代这种将元素追加到列表的特定任务。
示例: 将某些内容添加到列表中
Prelude> let numbers = [1,2,3,4] Prelude> numbers [1,2,3,4] Prelude> 0:numbers [0,1,2,3,4]
当您将某些内容添加到列表中 (something:someList
) 时,您将获得另一个列表。因此,您可以根据需要继续 consing。请注意,cons 运算符从右到左计算。另一种(更一般的)理解方式是,它将左边的第一个值和右边的整个表达式取进来。
示例: 将许多内容添加到列表中
Prelude> 1:0:numbers [1,0,1,2,3,4] Prelude> 2:1:0:numbers [2,1,0,1,2,3,4] Prelude> 5:4:3:2:1:0:numbers [5,4,3,2,1,0,1,2,3,4]
实际上,Haskell 通过将所有元素添加到空列表 []
中来构建所有列表。逗号和括号符号只是语法糖。因此,[1,2,3,4,5]
等效于 1:2:3:4:5:[]
但是,您需要注意列表构造中可能出现的一个潜在陷阱。虽然 True:False:[]
是完全合法的 Haskell,但 True:False
不是
示例: 哎呦!
Prelude> True:False <interactive>:1:5: Couldn't match `[Bool]' against `Bool' Expected type: [Bool] Inferred type: Bool In the second argument of `(:)', namely `False' In the definition of `it': it = True : False
True:False
产生了一个看起来很熟悉的类型错误消息。它告诉我们 cons 运算符 (:)
(实际上只是一个函数)期望列表作为它的第二个参数,但我们给它传递了另一个 Bool
。(:)
只知道如何将东西添加到列表中。[1]
因此,在使用 cons 时,请记住
- 列表的元素必须具有相同的类型。
- 您只能将某些内容添加到列表中 (
(:)
),而不能反过来(您不能将列表添加到元素中)。因此,最右边的项目必须是一个列表,而左边的项目必须是独立的元素,而不是列表。
练习 |
---|
|
正如我们在“类型基础”模块中简要提到的那样,Haskell 中的字符串只是字符列表。这意味着类型为 String 的值可以像任何其他列表一样被操作。例如,可以使用 (:)
连接字符并以空列表结尾,或者使用逗号和括号表示法,而不是直接将字符串输入为用双引号括起来的字符序列,而是可以通过一系列 Char 值来构造它们。
Prelude> "hey" == ['h','e','y'] True Prelude> "hey" == 'h':'e':'y':[] True
使用双引号字符串只是更具语法的糖。
列表可以包含任何东西——只要它们都是相同的类型。因为列表也是事物,所以列表可以包含其他列表!在解释器中尝试以下操作
示例: 列表可以包含列表
Prelude> let listOfLists = [[1,2],[3,4],[5,6]] Prelude> listOfLists [[1,2],[3,4],[5,6]]
列表的列表有时很棘手,因为事物的列表与事物本身的类型不同。类型 Int
与 [Int]
不同。让我们通过一些练习来理清这些含义
练习 |
---|
|
不同类型的元素的列表不能被 cons,但空列表可以被任何类型的列表 cons。例如,[]:[[1, 2], [1, 2, 3]]
是有效的,并将产生 [[], [1, 2], [1, 2, 3]]
,而 [1]:[[1, 2], [1, 2, 3]]
是有效的,并将产生 [[1], [1, 2], [1, 2, 3]]
,但 ['a']:[[1, 2], [1, 2, 3]]
将产生错误消息。
列表的列表允许我们表达一些复杂的有结构数据(例如二维矩阵)。它们也是 Haskell 类型系统真正闪耀的地方之一。人类程序员(包括这本维基教科书的合著者)在处理列表的列表时总是会感到困惑,而对类型的限制通常有助于克服潜在的混乱。
元组提供了一种在单个值中存储多个值的其他方式。元组和列表有两个主要区别
- 元组具有固定数量的元素(不可变);您不能对元组进行 consing。因此,当您事先知道要存储多少个值时,使用元组是有意义的。例如,我们可能想要一种类型来存储点的二维坐标。我们确切地知道每个点需要多少个值(两个 - x 和 y 坐标),因此元组是适用的。
- 元组的元素不需要是相同的类型。例如,在电话簿应用程序中,我们可能想要通过将三个值压缩成一个来处理条目:姓名、电话号码和我们拨打电话的次数。在这种情况下,三个值不会具有相同的类型,因为姓名和电话号码是字符串,但联系人计数器将是一个数字,所以列表将不起作用。
元组用括号标记,元素用逗号分隔。让我们看一些元组示例
示例: 一些元组
(True, 1)
("Hello world", False)
(4, 5, "Six", True, 'b')
第一个示例是一个包含两个元素的元组:True 和 1。下一个示例又包含两个元素:“Hello world” 和 False。第三个示例是一个包含五个元素的元组:4(一个数字)、5(另一个数字)、“Six”(一个字符串)、True(一个布尔值)和 'b'(一个字符)。
关于术语的快速说明:通常,您使用n 元组来表示大小为n 的元组。通常,我们将 2 元组(即包含 2 个元素的元组)称为对,并将 3 元组称为三元组。更大尺寸的元组实际上并不常见,但我们可以逻辑地将命名系统扩展为四元组、五元组等等。
练习 |
---|
|
当您想从函数中返回多个值时,元组非常方便。在许多语言中,一次返回两个或多个值通常需要将它们包装在单一用途的数据结构中,也许只在该函数中使用。在 Haskell 中,我们将这些结果作为元组返回。
我们可以在关于将列表存储在列表中的元组上应用相同的推理。元组也是事物,所以你可以在元组中存储元组(在元组中,直到任意级别的复杂性)。同样,你也可以拥有元组列表,元组列表,以及各种相关的组合。
示例:嵌套元组和列表
((2,3), True)
((2,3), [2,3])
[(1,2), (3,4), (5,6)]
元组的类型不仅由其大小定义,而且与列表一样,由它包含的对象的类型定义。例如,元组("Hello",32)
和(47,"World")
从根本上是不同的。一个是(String,Int)
类型,而另一个是(Int,String)
类型。这对构建元组列表有影响。我们可以很容易地拥有像[("a",1),("b",9),("c",9)]
这样的列表,但是Haskell不能拥有像[("a",1),(2,"b"),(9,"c")]
这样的列表。
练习 |
---|
|
为了使列表和元组有用,我们需要访问它们包含的内部值。
让我们从表示点(x, y)坐标的对(即 2 元组)开始。想象你想要在棋盘上指定一个特定的方格。你可以从 1 到 8 给行和列贴标签。然后,一对(2, 5)
可以表示第 2 行第 5 列的方格。假设我们想要一个函数来查找给定行中的所有棋子。我们可以从所有棋子的坐标开始,然后查看行部分,看看它是否等于我们要检查的任何行。给定棋子的坐标对(x, y)
,我们的函数需要提取x
(行坐标)。为了达到这种目的,有两个标准函数,fst
和snd
,它们分别检索[2]对中的第一个和第二个元素。让我们看一些例子
示例:使用fst
和snd
Prelude> fst (2, 5) 2 Prelude> fst (True, "boo") True Prelude> snd (5, "Hello") "Hello"
请注意,根据定义,这些函数仅适用于对。[3]
对于列表,函数head
和tail
大致类似于fst
和snd
。它们通过拆开(:)
连接的部分来拆解列表。head
计算列表的第一个元素,而tail
给出列表的其余部分。
示例:使用head
和tail
Prelude> 2:[7,5,0] [2,7,5,0] Prelude> head [2,7,5,0] 2 Prelude> tail [2,7,5,0] [7,5,0]
注意
不幸的是,head
和tail
有一个严重的问题。如果我们将它们中的任何一个应用于空列表...
Prelude> head [] *** Exception: Prelude.head: empty list
...它会爆炸,因为空列表没有第一个元素,也没有其他任何元素。在GHCi之外,尝试在空列表上运行head
或tail
会使程序崩溃。
我们暂时会使用head
和tail
,但我们希望避免在实际代码中出现这种故障的任何风险,因此我们将在后面的章节中学习更好的选择。有人可能会问“有什么问题呢?如果我们小心,从不向它们传递空列表,或者我们以某种方式在调用它们之前测试列表是否为空,那么使用head
和tail
就可以正常工作。”但那条路通向疯狂。
随着程序变得越来越大、越来越复杂,空列表最终被传递给head
和tail
的地方数量会迅速增加,我们可能会犯错的地方数量也会随之增加。作为经验法则,你应该避免可能在没有警告的情况下失败的函数。随着我们在本书中不断前进,我们将学习更好的方法来避免这些风险。
这里介绍的四个函数似乎并没有完全解决我们开始这部分内容时遇到的问题。虽然fst
和snd
为对提供了一个令人满意的解决方案,但对于有三个或更多元素的元组呢?对于列表,我们能做得比仅仅在第一个元素之后分解它们更好吗?目前,我们必须将这些问题搁置。在我们完成一些必要的准备工作之后,我们将在未来关于列表操作的章节中回到这个主题。现在,请记住,分离列表的头和尾将允许我们做任何我们想做的事情。
练习 |
---|
|
回想一下,列表的类型取决于其元素的类型,并用方括号将其括起来表示
Prelude> :t [True, False] [True, False] :: [Bool] Prelude> :t ["hey", "my"] ["hey", "my"] :: [[Char]]
Bool
的列表与[Char]
的列表(与String
的列表相同,因为[Char]
和String
是同义词)是不同的类型。由于函数只接受函数类型中指定的类型的参数,这可能会导致一些复杂情况。例如,考虑head
的情况。鉴于[Int]
、[Bool]
和[String]
是不同的类型,我们似乎需要为每种情况分别创建函数——headInt :: [Int] -> Int
、headBool :: [Bool] -> Bool
、headString :: [String] -> String
等等… 然而,这不仅非常烦人,而且毫无意义。毕竟,列表的组装方式与它们包含的值的类型无关,因此我们期望获取列表第一个元素的过程在所有情况下都保持一致。
幸运的是,我们确实有一个适用于所有列表的单一函数head
Prelude> head [True, False] True Prelude> head ["hey", "my"] "hey"
这怎么可能呢?像往常一样,检查head
的类型会提供一个很好的提示
示例:我们的第一个多态类型
Prelude> :t head head :: [a] -> a
签名中的a
不是类型——记住类型名称总是以大写字母开头。相反,它是一个类型变量。当Haskell看到一个类型变量时,它允许任何类型代替它的位置。在类型论(数学的一个分支)中,这被称为多态性:只有单一类型的函数或值被称为单态的,而使用类型变量来承认多种类型的东西则被称为多态的。例如,head
的类型告诉我们它接受一个任意类型(a
)的列表([a]
),并返回该相同类型(a
)的值。
请注意,在单个类型签名中,同一个类型变量的所有情况必须是相同类型。例如,
f :: a -> a
表示f
接受任何类型的参数,并返回与参数相同类型的结果,而不是
f :: a -> b
表示f
接受任何类型的参数,并返回任何类型的结果,该结果可能或可能不与我们为a
提供的类型匹配。不同的类型变量没有指定类型必须不同,它只是说它们可以不同。
正如我们所见,你可以使用fst
和snd
函数来提取对的一部分。到目前为止,你应该已经养成了习惯,即对于你遇到的每一个函数,都要问“这是什么类型?”。让我们考虑fst
和snd
的情况。这两个函数以对作为参数,并返回该对的一个元素。与列表一样,对的类型取决于其元素的类型,因此函数需要是多态的。还要记住,对(以及一般意义上的元组)不需要在内部类型方面是同质的。因此,如果我们要说
fst :: (a, a) -> a
那意味着fst
只有在给定作为输入的对的第一个和第二个部分具有相同类型的情况下才能工作。因此,正确的类型是
示例:fst
和snd
的类型
fst :: (a, b) -> a
snd :: (a, b) -> b
如果你除了类型签名之外对fst
和snd
一无所知,你仍然可能会猜测它们分别返回对的第一个和第二个部分。虽然这是正确的,但其他函数也可能具有相同的类型签名。所有签名都表明的是,它们只需要返回与对的第一个和第二个部分相同类型的东西。
练习 |
---|
为以下函数给出类型签名
|
本章介绍了列表和元组。它们之间的关键相似之处和区别在于
- 列表由方括号和逗号定义:
[1,2,3]
。- 只要列表的所有元素都是相同类型,列表就可以包含任何东西。
- 列表也可以通过 cons 运算符
(:)
构建,但你只能将东西 cons 到列表上。
- 元组由圆括号和逗号定义:
("Bob",32)
- 元组可以包含任何东西,即使是不同类型的东西。
- 元组的长度在其类型中编码;不同长度的元组将具有不同的类型。
- 列表和元组可以以多种方式组合:列表中包含列表,元组中包含列表,等等,但它们的标准必须仍然满足才能使组合有效。