Haskell/类型基础
在编程中,类型用于将类似的值归类。在 Haskell 中,类型系统是一种强大的方式,可以减少代码中的错误数量。
编程处理不同类型的实体。例如,考虑将两个数字加在一起
2 和 3 是什么?它们是数字。中间的加号呢?那肯定不是数字,但它代表我们可以对两个数字执行的操作,即加法。
类似地,考虑一个程序,它会询问您的姓名,然后用“Hello”信息向您致意。您的姓名和“Hello”这个词都不是数字。它们是什么呢?我们可能会将所有单词、句子等等称为文本。在编程中,通常使用一个更深奥的词:字符串,它指的是“字符的字符串”。
数据库清楚地说明了类型的概念。例如,假设我们有一个数据库中的表格,用于存储有关某人联系人的详细信息;一种个人电话簿。内容可能如下所示
姓 | 名 | 地址 | 电话号码 |
福尔摩斯 | 夏洛克 | 伦敦贝克街 221B 号 | 743756 |
琼斯 | 鲍勃 | 维尔斯镇长路街 99 号 | 655523 |
每个条目中的字段包含值。福尔摩斯是一个值,和维尔斯镇长路街 99 号一样,以及655523也是。让我们根据类型对本例中的值进行分类。“姓”和“名”包含文本,因此我们说这些值的类型为 String。
乍一看,我们可能会将地址归类为 String。但是,地址背后语义非常复杂。许多人类约定规定我们如何解释地址。例如,如果地址文本的开头包含一个数字,它很可能就是房子的号码。如果不是,那么它可能就是房子的名称,除非它以“邮政信箱”开头,在这种情况下,它只是一个邮政信箱地址,并不表示该人在哪里居住。地址的每个部分都有其自身的含义。
原则上,我们可以说地址是字符串,但这并不能捕获地址的许多重要特征。当我们将某事物描述为字符串时,我们所说的只是它是字符(字母、数字等)的序列。识别某事物为专门的类型更有意义。如果我们知道某事物是 Address,我们立即会了解有关该数据片段的更多信息,例如,我们可以使用赋予地址意义的“人类约定”来解释它。
我们也可以将此原理应用于电话号码。我们可以指定 TelephoneNumber 类型。然后,如果我们遇到一些任意数字序列,恰好是 TelephoneNumber 类型,那么我们就可以访问比仅仅是一个 Number 更多的信息,例如,我们可以开始在初始数字上查找区号和国家代码等内容。
不将电话号码视为数字的另一个原因是,对它们进行算术运算毫无意义。例如,将 TelephoneNumber 乘以 100 的意义和预期效果是什么?它将无法通过电话呼叫任何人。此外,组成电话号码的每个数字都很重要;我们不能接受通过舍入甚至省略前导零而丢失其中一些数字。
描述和分类事物如何帮助我们编写良好的程序?一旦我们定义了一个类型,我们就可以指定对它可以做什么或不能做什么。这使得管理更大的程序并避免错误变得更加容易。
让我们使用 GHCi 来探索类型的工作原理。可以使用 :type
(或缩写为 :t
)命令检查任何表达式的类型。在上一模块中的布尔值上尝试一下
示例:在 GHCi 中探索布尔值的类型
Prelude> :type True True :: Bool Prelude> :type False False :: Bool Prelude> :t (3 < 5) (3 < 5) :: Bool
符号 ::
会出现在其他几个地方,可以简单地理解为“类型为”,它表示一个类型签名。
:type
显示,Haskell 中的真值类型为 Bool
,如上所示,对于两个可能的值 True
和 False
,以及用于评估为其中之一的示例表达式。请注意,布尔值不仅仅用于值比较。Bool
捕获了是/否答案的语义,因此它可以表示任何这种类型的信息,例如,在电子表格中是否找到了名称,或者用户是否切换了开/关选项。
现在让我们在新的内容上尝试 :t
。文字字符通过用单引号括起来来输入。例如,这是单个字母 H
示例:在 GHCi 中对文字字符使用 :type 命令
Prelude> :t 'H' 'H' :: Char
所以,文字字符值类型为 Char
(“字符”的缩写)。现在,单引号仅适用于单个字符,因此,如果我们需要输入更长的文本,即字符的字符串,我们改用双引号
示例:在 GHCi 中对文字字符串使用 :type 命令
Prelude> :t "Hello World" "Hello World" :: [Char]
为什么我们再次得到 Char
?区别在于方括号。[Char]
表示多个字符链接在一起,形成一个字符列表。Haskell 认为所有字符串都是字符列表。列表在 Haskell 中通常是重要的实体,我们将在稍后更详细地介绍它们。
练习 |
---|
|
顺便说一句,Haskell 允许使用类型同义词,它们的工作原理与人类语言中的同义词非常相似(意思相同的词,例如,“大”和“大”)。在 Haskell 中,类型同义词是类型的替代名称。例如,String
被定义为 [Char]
的同义词,因此我们可以随意用一个替换另一个。因此,说
"Hello World" :: String
也完全有效,在很多情况下更具可读性。从这里开始,我们将主要将文本值称为 String
,而不是 [Char]
。
到目前为止,我们已经了解了值(字符串、布尔值、字符等)如何拥有类型,以及这些类型如何帮助我们对它们进行分类和描述。现在,让我们来看看让 Haskell 类型系统真正强大的关键所在:函数 也拥有类型。[1] 让我们看一些例子来了解它是如何工作的。
我们可以使用 not
来否定布尔值(例如,not True
计算结果为 False
,反之亦然)。为了确定函数的类型,我们需要考虑两件事:它作为输入接受的值的类型和它返回的值的类型。在这个例子中,事情很简单。not
接受一个 Bool
(要否定的布尔值),并返回一个 Bool
(否定的布尔值)。写下它的表示法是
示例: not
的类型签名
not :: Bool -> Bool
你可以把它理解为“not
是一个从类型为 Bool
的事物到类型为 Bool
的事物的函数”。
使用:t在函数上将按预期工作
Prelude> :t not not :: Bool -> Bool
函数类型描述的是它接受的论据类型以及它计算结果的类型。
文本对计算机来说是一个难题。在最低级别上,计算机只认识二进制的 1 和 0。为了表示文本,每个字符首先被转换为一个数字,然后这个数字被转换为二进制并存储起来。这就是一段文本(只是一系列字符)是如何被编码成二进制的。通常,我们只关心如何将字符编码成它们的数字表示形式,因为计算机会在没有我们干预的情况下完成转换为二进制数字的操作。
将字符转换为数字最简单的方法是简单地写下所有可能的字符,然后对它们进行编号。例如,我们可以决定 'a' 对应 1,'b' 对应 2,等等。这就是所谓的 ASCII 标准:取 128 个常用的字符并对它们进行编号(ASCII 实际上并没有从 'a' 开始,但总体思路是一样的)。当然,如果我们每次想要编码一个字符时都要在一个大型查找表中查找,那将会是一项非常繁琐的工作,所以我们有两种函数可以帮我们做到这一点,chr
(读作“char”)和 ord
[2]
示例: chr
和 ord
的类型签名
chr :: Int -> Char
ord :: Char -> Int
我们已经知道 Char
的含义。上面签名中的新类型 Int
指的是整数,是许多不同类型的数字中的一种。[3] chr
的类型签名告诉我们,它接受一个类型为 Int
的论据(一个整数),并计算结果为类型为 Char 的结果。ord
的情况正好相反:它接受类型为 Char 的事物,并返回类型为 Int 的事物。有了类型签名的信息,我们立即就能清楚地知道哪个函数将一个字符编码成一个数字代码(ord
),哪个函数将它解码回一个字符(chr
)。
为了使事情更加具体,这里举几个例子。请注意,这两个函数默认情况下不可用;所以在 GHCi 中尝试使用它们之前,你需要使用 :module Data.Char
(或 :m Data.Char
)命令来加载定义它们的 Data.Char 模块。
示例: 对 chr
和 ord
的函数调用
Prelude> :m Data.Char Prelude Data.Char> chr 97 'a' Prelude Data.Char> chr 98 'b' Prelude Data.Char> ord 'c' 99
接受多个参数的函数的类型是什么呢?
示例: 一个具有多个参数的函数
xor p q = (p || q) && not (p && q)
(xor
是异或函数,如果两个参数中只有一个为真,则计算结果为真,但不能同时为真;否则计算结果为假。)
形成接受多个参数的函数的类型的通用技术是简单地按顺序写下所有参数的类型(因此在本例中,首先是 p
然后是 q
),然后用 ->
将它们连接起来。最后,将结果类型的类型添加到行的末尾,并在它的前面加上一个最后的 ->
。[4] 在这个例子中,我们有
- 写下参数的类型。在本例中,使用
(||)
和(&&)
表明p
和q
必须是类型为Bool
的类型Bool Bool ^^ p is a Bool ^^ q is a Bool as well
- 用
->
填充空缺Bool -> Bool
- 添加结果类型和一个最后的
->
。在我们的例子中,我们只是在做一些基本的布尔运算,所以结果仍然是一个 Bool。Bool -> Bool -> Bool ^^ We're returning a Bool ^^ This is the extra -> that got added in
因此,最终的签名是
示例: xor
的签名
xor :: Bool -> Bool -> Bool
正如你将在本课程的 Haskell 实践部分中学到的那样,一组流行的 Haskell 库是 GUI(Graphical User Interface,图形用户界面)库。它们提供了处理计算机用户熟悉的视觉内容的函数:菜单、按钮、应用程序窗口、移动鼠标等。其中一个库中的一个函数叫做 openWindow
,你可以使用它在你的应用程序中打开一个新窗口。例如,假设你正在编写一个文字处理器,用户点击了“选项”按钮。你需要打开一个新的窗口,其中包含他们可以更改的所有选项。让我们看一下这个函数的类型签名:[5]
示例: openWindow
openWindow :: WindowTitle -> WindowSize -> Window
你可能不了解这些类型,但它们很简单。那里的三个类型,WindowTitle
、WindowSize
和 Window
,都是由提供 openWindow
的 GUI 库定义的。正如我们之前看到的,两个箭头意味着前两个类型是参数的类型,最后一个是结果的类型。WindowTitle
保存窗口的标题(通常出现在窗口顶部的标题栏中),WindowSize
指定窗口的大小。然后,该函数返回一个类型为Window的值,它代表实际的窗口。
所以,即使你以前从未见过函数,也不知道它是如何工作的,类型签名也能让你对函数的功能有一个大致的了解。养成使用:t测试你遇到的每个新函数的习惯。如果你现在就开始这样做,你不仅会学习关于标准库 Haskell 函数的知识,而且还会培养对 Haskell 中函数的一种有用的直觉。
练习 |
---|
以下函数的类型是什么?对于任何涉及数字的函数,你可以假装数字是 Int。
|
我们已经探索了类型背后的基本理论以及它们是如何应用于 Haskell 的。现在,我们将了解类型签名是如何用于在源文件中对函数进行注释的。考虑我们前面例子中的 xor
函数
示例: 一个带有签名的函数
xor :: Bool -> Bool -> Bool
xor p q = (p || q) && not (p && q)
这就是我们要做的所有事情。为了最大程度地清晰,类型签名放在相应的函数定义之上。
我们以这种方式添加的签名扮演着双重角色:它们既向人类读者,也向编译器/解释器阐明了函数的类型。
如果类型签名告诉解释器(或编译器)函数的类型,那么我们如何在没有类型签名的情况下编写最早的 Haskell 代码呢? 嗯,当你没有告诉 Haskell 你函数和变量的类型时,它会通过一个叫做 *类型推断* 的过程来推断它们。本质上,编译器从它知道的类型开始,然后推断出其余值的类型。考虑一个一般的例子。
示例:简单的类型推断
-- We're deliberately not providing a type signature for this function
isL c = c == 'l'
isL
是一个函数,它接受一个参数 c
并返回 c == 'l'
的计算结果。在没有类型签名的情况下,c
的类型和结果的类型都没有指定。然而,在表达式 c == 'l'
中,编译器知道 'l'
是一个 Char
。由于 c
和 'l'
使用 (==)
进行相等比较,并且 (==)
的两个参数必须具有相同的类型,[6] 因此 c
必须是一个 Char
。最后,由于 isL c
是 (==)
的结果,因此它必须是一个 Bool
。因此,我们得到了函数的签名
示例:带有类型的 isL
isL :: Char -> Bool
isL c = c == 'l'
事实上,如果你省略类型签名,Haskell 编译器会通过这个过程发现它。你可以使用:t在 isL
上验证,无论是否有签名。
那么,既然类型可以被推断出来,为什么要写类型签名呢?在某些情况下,编译器缺乏信息来推断类型,因此签名变得强制性。在其他一些情况下,我们可以使用类型签名来在一定程度上影响函数或值的最终类型。这些情况现在不必担心,但我们还有其他几个理由要包含类型签名
- 文档:类型签名使你的代码更容易阅读。对于大多数函数来说,函数名称加上函数类型就足以猜测函数的作用。当然,对代码进行注释会有所帮助,但明确地说明类型也有帮助。
- 调试:当你用类型签名注释一个函数,然后在函数体中打错字,从而改变了变量的类型,编译器会在 *编译时* 告诉你你的函数是错误的。省略类型签名可能会允许你的错误函数编译,并且编译器会为它分配错误的类型。直到你运行程序,你才意识到自己犯了这个错误。
类型和可读性
[edit | edit source]一个稍微更现实的例子将帮助我们更好地理解签名如何帮助文档。下面引用的代码片段是一个很小的 *模块*(模块是准备库的典型方式),这种组织代码的方式类似于与 GHC 捆绑在一起的库。
注意
不要因为试图理解这些函数是如何工作的而变得疯狂;这无关紧要,因为我们还没有涵盖许多正在使用的功能。继续读下去,一起玩吧。
示例:带有类型签名的模块
module StringManip where
import Data.Char
uppercase, lowercase :: String -> String
uppercase = map toUpper
lowercase = map toLower
capitalize :: String -> String
capitalize x =
let capWord [] = []
capWord (x:xs) = toUpper x : xs
in unwords (map capWord (words x))
这个小库提供了三个字符串操作函数。uppercase
将字符串转换为大写,lowercase
转换为小写,capitalize
将每个单词的首字母大写。这些函数中的每一个都接受一个 String
作为参数,并计算出另一个 String
。即使我们不理解这些函数是如何工作的,查看类型签名也能让我们立即知道参数和返回值的类型。结合合理的函数名称,我们有足够的信息来弄清楚如何使用这些函数。
注意,当函数具有相同的类型时,我们可以选择为它们都写一个签名,方法是在它们的名称之间用逗号隔开,就像上面用 uppercase
和 lowercase
一样。
类型防止错误
[edit | edit source]类型在防止错误中的作用对于类型化语言至关重要。当你传递表达式时,你必须确保类型匹配,就像这里一样。如果它们不匹配,当你尝试编译时,你会得到 *类型错误*;你的程序将无法通过 *类型检查*。这有助于减少程序中的错误。举一个非常简单的例子
示例:一个无法类型检查的程序
"hello" + " world" -- type error
这一行会导致程序在编译时失败。你不能将两个字符串加在一起。很可能,程序员原本打算使用类似的连接运算符,它可以将两个字符串连接在一起形成一个字符串
示例:我们错误的程序,已修复
"hello" ++ " world" -- "hello world"
这是一个容易犯的错误,但 Haskell 在你尝试编译时就捕获了这个错误。你不必等到运行程序才能发现这个错误。
更新程序通常会涉及对类型的更改。如果更改是无意的,或者产生了无法预料的后果,那么它会在编译时显现出来。Haskell 程序员经常说,一旦他们修复了所有类型错误,并且他们的程序编译通过,他们就倾向于“正常工作”。行为可能并不总是与意图相符,但程序不会崩溃。Haskell 的 *运行时错误*(指你的程序在运行时出错,而不是在编译时出错)比其他语言要少得多。