Real World Haskell 第二章 类型与函数
为何关心类型?
Haskell中任何一个表达式和函数都有类型。比如,逻辑值True是Bool类型,"foo"的类型是String。一个值的类型说明它与同类型的其他值共享一些特定的属性。例如,可以把数字相加,可以把列表进行连接,这些操作是这些类型的属性。我们说一个表达式“有一个类型X”或者“类型是X”。
开始深入探讨Haskell的类型系统之前,先来说下为什么要关心类型:它们究竟是干什么的?在计算机的最底层所关心的是字节,没有任何附加的结构。类型系统给了我们抽象的能力。类型赋予原始的字节以意义:我们就可以说“这些字节是文本”,“那些字节是一个机票预定”等。通常类型系统还可以让我们避免不小心把类型弄混:例如,通常类型系统不会让我们把一个旅馆预定当作一个租车收据处理。
引入抽象性的好处是可以让我们忘掉或忽略低层细节。如果知道自己程序中的一个值是字符串,就不需要知道字符串内部的实现细节:我可以认为这个字符串和我处理过的其他字符串一样的工作。
类型系统的有趣之处在于它们并不完全相同。实际上,不同的类型系统经常连关心的问题也不一样。一门语言的类型系统极深的影响了我们用此语言思考和写程序的方式。
Haskell的类型系统允许我们在非常抽象的层次上思考,这可以让我们写出简洁且强大的程序。
Haskell的类型系统
Haskell中类型有三个有趣的方面:它们是强类型的;它们是静态的;可以被自动推断。让我们更详细探讨每个方面的细节。有可能的话,我们会给出其他语言中的与Haskell类型系统的相类似的概念。并会简单谈下这些关联性的强弱。
强类型
Haskell是强类型系统,意思是类型系统保证程序中不存在如下这种特定的错误:试图写出无意义的表达式,如把整数当成函数来用之类的。例如,一个函数需要一个整数参数,但传给它一个字符串,Haskell编译器会拒绝编译。
我们称一个表达式遵从语言的类型规则为适当类型。表达式不遵从类型规则称为不当类型,并会导致一个类型错误。
Haskell 的强类型观念的另一方面是它不会自动将值从一个类型强制到另一种(类型强制也称映射或转换)。例如,一个函数接受一个浮点型参数,那么C语言编译器会不加警告的自动将整数参数转成浮点数,但是相似的情况下Haskell编译器会抛出一个编译错误。我们必须明确的应用强制转换函数来强制类型。
偶尔在写某种特定的代码时强类型会造成困难。比如,C语言写底层代码时的一种经典做法是通过映射把字节数组当成复杂的数据结构来处理。这非常高效,因为不需要把字节拷贝来拷贝去。Haskell的类型系统不允许这种强制。要做到这种数据结构化,需要做一些拷贝,这会有一些性能损失。
强类型的巨大好处是在代码导致问题之前捕获真正的错误。例如,在强类型语言中,当需要整数时不会意外的使用字符串。
[Note]
类型的强弱
要知道,很多语言社区有它们自己对“强类型”的定义。尽管如此,我们简明的概括下类型系统中表示强度的这个概念。
在计算机科学学术上,“强”和“弱”有一个狭义的技术含义:表示类型系统的宽容度。与较强的类型系统相比,在较弱些的类型系统中,更多的表达式被当作有效的。
例如,在Perl语言里,表达式 "foo" + 2 结果为2,但是表达式"13foo" + 2 值为15。Haskell中,这两个表达式都是无效的,因为(+)操作符需要两个操作数都是数字。因为Perl的类型系统比Haskell的更加宽容,因此在此技术含义下我们说Perl的类型更“弱”。
围绕类型系统的激烈争论有自然语言的根源,人们把自然语言中的观念附加到“强”和“弱”这两个词上:我们经常认为强壮比弱小好。大多数程序员用日常语言而不是学术的行话,并且学术界经常对任何不合他们口味的类型系统“拍砖”(原文如此)。结果导致网上的灌水和激烈论战。
静态类型
静态类型的含义是编译器在代码执行之前,即编译时,就知道每一个值和表达式的类型。当我们试图使用类型不匹配的表达式时,Haskell的编译器或解释器会检测到,在运行之前就能拒绝我们的代码并给出错误信息。
ghci> True && "false"
<interactive>:1:8:
Couldn't match expected type `Bool' against inferred type `[Char]'
In the second argument of `(&&)', namely `"false"'
In the expression: True && "false"
In the definition of `it': it = True && "false"
这个错误信息我们之前见过。编译器推断表达式 "false" 的类型为 [Char]。(&&)操作符的两个操作数都需要Bool类型,它的左操作值符合要求。但是"false"的实际类型并不匹配需要的类型,因此编译器将此表达式当作不当类型拒绝了。
偶尔在写某些有用的代码时静态类型会造成困难。在像Python之类的语言中,"duck typing"很平常,如果一个对象的行为足够像另一个对象就可以替换那个对象。幸运的是Haskell的类型类系统用一种安全且方便的形式提供了动态类型的几乎所有优点,这将在第六章“使用类型类”中介绍。Haskell对于真正的动态类型编程提供一些支持,尽管不如那些完全拥抱此观念的语言那样来的简单。
Haskell的强类型和静态类型的结合使它不可能在运行时发生类型错误(译注:几乎不可能)。不过这意味着我们需要提前做更多思考,这也能排除很多简单的但很难查找的错误。Haskell社区有句老话:能编译通过的Haskell程序差不多就是正确的程序。(或许更现实的说法是 Haskell代码具有更少的一般错误。)
动态语言写的程序需要很多测试来证明没有简单的类型错误。测试无法提供全面的覆盖:例如为使程序更加模块化而进行代码重构这样平常的任务,有可能没有测试而引入新的类型错误。
在Haskell中,编译器可以向我们证明没有类型错误:编译通过的Haskell程序不会在运行时发生类型错误。重构经常会把代码改来改去,重新编译并修正几次直到编译器告诉我们“一切正常”。
把静态类型系统类比成拼图游戏有助于我们理解它的价值。在Haskell里,如果一块拼图形状错误,那它就不合适。在动态类型语言中,所有的拼图都是1x1的方块因此总是合适的,所以你需要不断的查看拼出来的图形,并通过测试来看它否正确。
类型推断
最后,Haskell的编译器可以自动的给出程序中几乎所有表达式的类型。这个过程就是类型推断。Haskell允许我们显式的声明任何值的类型,但是由于类型推断的存在,这几乎总是可选的,除一些必要的需要显式声明。
对类型系统的期待
我们关于Haskell类型系统的主要能力和好处的探索,将会持续几个章节。起初,你可能发现Haskell的类型有些繁琐.
在Python或Ruby中只需要简单的写些代码,执行起来看它是否执行正常,而在Haskell中,要先确保程序通过了类型检查器的检查。为何要忍受这个较高的学习曲线呢?
因为静态强类型系统使Haskell程序更安全,类型推断使它更简洁。结果很强大:最终使这门语言比流行的静态类型语言更安全,且经常比动态类型语言更具表达能力。这是一个强有力的宣言,我们将用整本书来证明它。
如果用过动态语言的话,起初可能会觉得修复类型错误需要做更多工作。就当是把大部分调试工作提前了吧。编译器会指出代码中的很多逻辑错误,而不是让你被程序运行时的错误绊倒。
更进一步,因为Haskell可以推断表达式和函数的类型,因此你不需要像一些不够强大的静态类型语言中那样强制描述类型,不需要增加此负担即可获得静态类型的好处。在其他语言中,类型系统服务于编译器。在Haskell中,它为你服务。代价是你必须学习如何与它提供的框架配合。
我们将在全书各处介绍Haskell类型的新用法,这将帮我们书写和测试实际的代码。因此,类型系统用途的完整图景将逐步拼合。每一步都是自适应的,最终整体将会大于每一部分之和。
一些常用的基本类型
在“类型初步”那一节中,介绍了几个类型。这里更多的常用基本类型。
* Char 值表示一个Unicode字符。
* Bool 值表示一个逻辑值。Bool类型的可能取值为 True 和 False。
* Int 类型用于有符号定长整数值。Int能表示的值的确切范围取决于系统中最长的“原生”整数:在32位机器上,整数经常是32位长,在64位机器上,长度经常为64位。Haskell标准只是保证一个整数至少有28位。(有恰好8位,16位的有符号或无符号数字类型,后面会介绍)。
* Integer 值是有符号的无限长整数。Integer不像Int那样用得频繁,因为他们在执行性能和空间占用上要代价更大。但是另一方面,Integer 的计算不会不加提示的溢出,因此它们的结果更能保证正确。
* Double 值用于浮点数。典型的Double值是64位长的,使用系统原生的浮点数表示。(也存在一个更短的Float类型,不过不鼓励使用;Haskell编译器的作者更加关注于Double的效率,因此Float会更慢些。)
在“类型初步”那一节已经简明的介绍了Haskell的类型标记。当显式写出类型时,使用 表达式 :: MyType 这种写法表示这个表达式的类型是 MyType。如果省略 :: 和后面的类型,Haskell编译器会推断这个表达式的类型。
ghci> :type 'a'
'a' :: Char
ghci> 'a' :: Char
'a'
ghci> [1,2,3] :: Int
<interactive>:1:0:
Couldn't match expected type `Int' against inferred type `[a]'
In the expression: [1, 2, 3] :: Int
In the definition of `it': it = [1, 2, 3] :: Int
:: 和它后面的类型的组合称为类型标签。
函数应用
现在我们已经知道了一些数据类型,让我们把注意力转到如何用函数操作这些已知的类型上。
在Haskell中应用一个函数,要写出函数的名字,后面跟它的参数。
ghci> odd 3
True
ghci> odd 6
False
不需要用括号或者逗号来把函数的参数分组或隔开;只需要写出函数的名字,把每个参数在后面按顺序列出来就可以了。以 compare 函数为例,它接受两个参数。
ghci> compare 2 3
LT
ghci> compare 3 3
EQ
ghci> compare 3 2
GT
如果习惯了其他语言中的函数调用语法的话,这种写法要花点时间来适应,不过这很简单并且是统一的。
函数应用比操作符拥有更高的优先级,因此下面两个表达式是相同的含义。
ghci> (compare 2 3) == LT
True
ghci> compare 2 3 == LT
True
上面的括号不会造成什么影响,但增加了些显示上的干扰。不过有时候,我们必须使用括号来指出复杂表达式应该怎样被解析。
ghci> compare (sqrt 3) (sqrt 6)
LT
这样将会给 compare 应用 sqrt 3 和 sqrt 6 的结果。如果省略了括号,看上去好像我们要试着传递四个参数给compare,而不是它能接受的两个。
有用的组合数据类型:列表和元组
组合数据类型由其他的类型构造而成。Haskell中最常见的组合类型是列表和元组。
在“字符串和字符”那一节已经提到了列表类型,我们知道Haskell用Char值的列表来表示文本字符串,并且字符的列表类型写作[Char]。
head函数返回列表的第一个元素。
ghci> head [1,2,3,4]
1
ghci> head ['a','b','c']
'a'
相对的,tail函数返回列表除去开头外剩下的部分。
ghci> tail [1,2,3,4]
[2,3,4]
ghci> tail [2,3,4]
[3,4]
ghci> tail [True,False]
[False]
ghci> tail "list"
"ist"
ghci> tail []
*** Exception: Prelude.tail: empty list
可以看出,head和tail可以应用到不同类型的列表上。在[Char]值上应用head返回一个Char类型值,应用在[Bool]值上返回一个Bool值。head函数并不关心它所操作的列表是什么类型的。
因为列表中的值可以是任意类型,因此称列表类型为多态的。要写一个多态类型,需要用一个小写字母开头的类型变量。类型变量是一个占位符,最终会替换成实际的类型。
可以把类型变量括在方括号中: [a] 表示“a的列表”。这相当于说“不关心它有什么样的类型,我可以生成它的一个列表”。
[Note] 区分类型名称和类型变量
现在可以知道为什么类型名开头必须用大写字母:这可以令它与类型变量区分开。类型变量开头必须用小写字母。
当谈论特定类型的值的列表时,用这种类型来替换类型变量。这样类型 [Int] 就是一个Int类型值的列表,因为我们用 Int 来替换了 a 。类似的 [MyPersonalType] 是一个 MyPersonalType类型值的列表。也可以递归的使用这种替换: [[Int]]是一个 [Int]类型值的列表,一个Int值的列表的列表。
ghci> :type [[True],[False,False]]
[[True],[False,False]] :: [[Bool]]
这个表达式的类型为Bool值的列表的列表。
[Note] 特别的列表
列表是Haskell集合类型中的“面包和黄油”。在命令式语言中,通过在一个循环上迭代来多次执行任务。而在Haskell中则是通过递归或用函数来遍历一个列表。列表是通向用数据来组织程序和控制流这一思想的最简单的垫脚石。在第四章“函数式编程”中将花更多时间来讨论列表。
元组是长度固定的值的集合,每个值可以是不同类型。这与列表恰好相反,列表可以任意长,但其中的元素必须具有相同的类型。
为帮助理解其中的不同,假设我们想要记录关于一本书的两部分信息。有一个出版年份的数字,还有一个标题字符串。无法用列表来记录这两部分信息,因为它们的类型不同。作为替代,可以使用元组。
ghci> (1964, "Labyrinths")
(1964,"Labyrinths")
把元组的元素用括号括起来,通过逗号分隔。使用相同的符号来写它的类型。
ghci> :type (True, "hello")
(True, "hello") :: (Bool, [Char])
ghci> (4, ['a', 'm'], (16, True))
(4,"am",(16,True))
有个特殊的类型 (),行为像一个零元素的元组。这个类型只有一个值,也写作()。它的类型和值经常被称为“单元”(unit)。如果你熟悉C语言的话,()与void有点像。
Haskell 并没有一个元素的元组的概念。经常使用元组的元素数目作为名称前缀。二元组有两个元素,经常称为对(pair)。三元组有三个元素(有时称为 triple)。五元组有5个,以此类推。实际上,元素数量稍多些的元组处理器来比较麻烦,因此很少使用过多的元素的元组。
元组的类型表示了它的元素的数量,位置和元素的类型。这意味着包含不同数量元素或者元素的类型不同的元组是不同的类型的,类型的顺序不同的元组也是不同的。
ghci> :type (False, 'a')
(False, 'a') :: (Bool, Char)
ghci> :type ('a', False)
('a', False) :: (Char, Bool)
在这个例子里,表达式 (False, 'a') 类型为 (Bool, Char), 这与('a', False)的类型不同。虽然元素的数量和它们的类型相同,但是元素类型的位置不同,因此这两个类型是有区别的。
ghci> :type (False, 'a', 'b')
(False, 'a', 'b') :: (Bool, Char, Char)
这个(Bool, Char, Char)类型与(Bool, Char)类型不同,因为它有三个元素而不是两个。
经常使用元组来从函数中返回多个值。也可以在需要固定长度的数据集合,而又不需要自定义一个容器类型时使用元组,
练习
1. 下面这些表达式的类型是什么?
False
(["foo", "bar"], 'a')
[(True, []), (False, [['a']])]
操作列表和元组的函数
关于列表和元组的讨论提到了怎样创建它们,但是还很少提及如何进一步操作。目前只介绍了两个列表的函数:head 和 tail。
一对相关的列表函数 take 和 drop,接受两个参数。给一个数字 n 和一个列表,take 返回列表的前 n 个元素,而 drop 返回列表除去前 n 个元素后的剩下的列表。(这些函数使用两个参数,注意用空格分隔每个函数和它的参数)
ghci> take 2 [1,2,3,4,5]
[1,2]
ghci> drop 3 [1,2,3,4,5]
[4,5]
相应的,元组中用 fst 和 snd 函数来返回一个对中的第一个和第二个元素。
ghci> fst (1,'a')
1
ghci> snd (1,'a')
'a'
如果读者有任何其他语言的背景,这两个表达式看上去像是把两个参数应用到一个函数上。而在Haskell的函数应用约定下,这两个都是将一个单独的二元组作为参数应用到函数上。
[Note] Haskell的元组不是不可变列表
如果读者来自Python界,可能会习惯于列表和元组总是可以互换。尽管Python的元组是不可变的,但它可以被编号和用与列表相同的函数来迭代。在Haskell中不是这样,所以不要试图把这个观念带进未知的语言环境中。
作为演示,看下fst 和 snd 的类型签名:它们只为二元组“对”定义,不能用在其他长度的元组上。Haskell的类型系统使得写出通用的“取出任意长度元组的第二个元素”函数变得很困难。
给函数传递表达式
在Haskell中,函数应用是左结合性的。最好通过例子演示这一点:表达式 a b c d 等价于 (((a b) c) d)。如果想要把一个表达式当作另一个的参数,必须用括号来明确的告诉解析器我们的真实意图。这里有个例子。
ghci> head (drop 4 "azerty")
't'
我们可以这样读“把表达式 drop 4 "azerty" 当作参数给 head”。如果去掉括号,这个错误的表达式好像是把三个参数传给 head。编译会因为类型错误而失败,head 需要单独一个列表做为参数,。
函数类型与纯函数
看下函数的类型。
ghci> :type lines
lines :: String -> [String]
可以把上面的 -> 读作 “到”,可以粗略的当作“返回”。这样整个类型签名读作“lines类型为String到字符串列表”。来试下应用这个函数。
ghci> lines "the quick\nbrown fox\njumps"
["the quick","brown fox","jumps"]
lines函数把一个字符串在行的边界进行分割。注意它的类型签名给了我们这个函数实际干什么的暗示:它接受一个字符串,并返回很多字符串。这是函数式语言一个极其有价值的特性。
副作用引入了系统全局状态与函数行为之间的依赖性。我们暂时从Haskell离开一会,并考虑命令式程序语言。假设一个函数读取并返回全局变量的值。如果其他的代码可以修改这个全局变量,那么对于这个函数的应用将依赖于这个全局变量当前的值。这个函数即使自己没有修改这个变量也将具有副作用。
副作用在函数中被无形的导入或导出。而在Haskell中,函数默认不具有副作用:一个函数的结果只依赖于明确提供给它的输入。我们称这样的函数为纯函数;具有副作用的函数是不纯的。
如果函数有副作用,可以在它的类型签名中看出来:函数的结果类型会以 IO 开头。
ghci> :type readFile
readFile :: FilePath -> IO String
Haskell的类型系统会阻止我们意外的把纯函数代码与不纯的代码混合。
Haskell源文件,编写简单的函数
现在知道了如何应用函数,是时候把注意力转向如何书写它们了。虽然可以在ghci中写函数,不过这不是做这件事的好环境。它只接受一个高度受限制的 Haskell 子集:最重要的是定义函数的语法与我们在Haskell源文件中用的不同。因此我们停下来,并创建一个源文件。
Haskell的源文件经常以.hs后缀标识。这里是一个简单的函数定义:打开一个名为 add.hs 的文件,把下面的内容加进去。
-- file: ch03/add.hs
add a b = a + b
在 = 的左边是函数的名字和参数。在右边是函数体。保存了源文件后,就可以在ghci中载入它,并能直接使用我们新定义的 add 函数。(在载入文件后ghci的提示符显示会变化)
ghci> :load add.hs
[1 of 1] Compiling Main ( add.hs, interpreted )
Ok, modules loaded: Main.
ghci> add 1 2
3
[Note] 如果ghci找不到源文件?
运行ghci的时候可能会找不到你的源文件。它会在它运行的目录中寻找源文件。如果那不是你的源代码实际所在的目录,可以使用ghci的 :cd 命令来更换它的工作目录。
ghci> :cd /tmp
或者,可以把Haskell源文件的路径作为参数提供给:load。路径可以使绝对路径或者相对于ghci当前目录的相对路径。
当用值1和2应用到 add 时,在定义左侧的变量 a 和 b 被赋值(或“绑定”)值1和2,所以结果是表达式 1 + 2。
Haskell没有 return 关键字,因为函数就是一个单独的表达式,不是一个语句序列。表达式的值就是函数的返回值。(Haskell确实有一个名为 return 的函数,不过我们不会很快讨论它;它与命令式语言中的含义不同)
当在Haskell代码中看到 = 符号时,它表示“含义是”:在它左侧的名字被定义为它右侧的表达式。
那么,什么是变量?
在Haskell中,变量给命名表达式提供了一种方法。一旦一个变量绑定到(意为相关联)一个特定的表达式,它的值将不会变化:我们总是可以用变量来替代写出表达式,两种方式得到相同的结果。
如果你习惯于命令式语言,可能会认为变量是标识一个内存位置(或其他等价物),它可以在不同时间保存不同的值。在命令式语言中,可以随时更改一个变量的值,因此不断检查这个内存地址每次会给出不同的结果。
这两种变量的观念中关键的不同点是,在Haskell中,一旦一个变量绑定到一个表达式,我们总是可以用它替换那个表达式,因为它不会改变。在命令式语言中,这种替换的概念行不通。
例如,如果运行下面的Python脚本,会打印数字11.
x = 10
x = 11
# value of x is now 11
print x
相反,在Haskell中尝试这样做会造成错误。
-- file: ch02/Assign.hs
x = 10
x = 11
不能给x赋值两次。
ghci> :load Assign
[1 of 1] Compiling Main ( Assign.hs, interpreted )
Assign.hs:4:0:
Multiple declarations of `Main.x'
Declared at: Assign.hs:3:0
Assign.hs:4:0
Failed, modules loaded: none.
条件执行
像很多其他语言一样,Haskell有 if 表达式。先实际看下它,然后我们会解释发生了什么。作为例子,我们会写自己版本的标准 drop 函数。开始前先探测下 drop 如何执行,这样我们可以复现它的行为。
ghci> drop 2 "foobar"
"obar"
ghci> drop 4 "foobar"
"ar"
ghci> drop 4 [1,2]
[]
ghci> drop 0 [1,2]
[1,2]
ghci> drop 7 []
[]
ghci> drop (-2) "foo"
"foo"
从上面结果看,如果要去除的数量小于或等于0的话将返回原列表。除此外,它将去除列表中的元素,直到结尾或者到达给定的数量。这里是具有同样行为的 myDrop 函数,使用了Haskell的 if 表达式来决定要做什么。下面的 null 函数检查一个列表是否为空。
-- file: ch02/myDrop.hs
myDrop n xs = if n <= 0 || null xs
then xs
else myDrop (n - 1) (tail xs)
Haskell中,缩进是重要的:它将延续一个已有定义,而不是开始一个新的。不要忽略这些缩进。
你可能会奇怪Haskell函数中变量名 xs 的出处。这是一个列表命名的通常模式:可以把s当作后缀,因此xs就是x的复数形式。
把我们的Haskell函数保存到名为 myDrop.hs 的文件中,之后在ghci中载入。
ghci> :load myDrop.hs
[1 of 1] Compiling Main ( myDrop.hs, interpreted )
Ok, modules loaded: Main.
ghci> myDrop 2 "foobar"
"obar"
ghci> myDrop 4 "foobar"
"ar"
ghci> myDrop 4 [1,2]
[]
ghci> myDrop 0 [1,2]
[1,2]
ghci> myDrop 7 []
[]
ghci> myDrop (-2) "foo"
"foo"
现在看到 myDrop 的使用,让我们回到源代码看看新引入的新事物。
首先,我们引入了 -- ,单行注释的开始。注释直到行尾。
之后是if关键字本身,它是由三部分组成的表达式。
* if后直接跟一个Bool类型的表达式。称之为条件。
* then关键字,后跟另一个表达式。这个表达式将用作条件表达式求值为True时if表达式的值。
* else关键字,后跟另一个表达式。这个表达式将用作条件表达式求值为False时if表达式的值。
then和else关键字之后的表达式称为“分支”。分支必须具有相同的类型;if表达式也将是这种类型的。像 if True then 1 else "foo" 这样的表达式的分支有不同的类型,因此是不当类型的,会被编译器或解释器拒绝。
注意Haskell是面向表达式的语言。在命令式语言中,忽略if的else分支是可以的,因为使用的是语句,不是表达式。但是当使用表达式时,缺少else的if在条件求值为False时将不具有一个有意义的结果,因此是没有意义的。
我们的条件中包含了其他新事物。null 函数指出一个列表是否为空,(||)操作符对它的Bool类型参数执行逻辑“或”操作。
ghci> :type null
null :: [a] -> Bool
ghci> :type (||)
(||) :: Bool -> Bool -> Bool
[Tip] 操作符不是特殊的
注意我们可以通过用括号括起 || 来找出它的类型。(||)操作符并不是语言“内置”的:它是一个平常的函数。
(||)是“逻辑短路”的:如果左侧的操作数求值为True,将不会求值右侧的操作数。在大多数语言中,短路求值需要特殊的支持,但是Haskell中不需要。我们将很快看到原因。
然后,我们的函数递归的应用自己。这是我们第一个递归的例子,很快将讨论它的一些细节。
最后,我们的if表达式跨越多行。为了简洁,我们把then和else分支排列到if的下面。使用相同缩进的语句数量不重要。如果愿意,可以把整个表达式写在单独一行中。
-- file: ch02/myDrop.hs
myDropX n xs = if n <= 0 || null xs then xs else myDropX (n - 1) (tail xs)
这个版本的长度使它更难阅读。我们经常把if表达式分成几行,使得条件和每个分支容易被注意到。
作为比较,这里是Python中的myDrop 的等价程序。两个的结构相似:每删除一个列表开头的元素就将计数器递减。
def myDrop(n, elts):
while n > 0 and elts:
n = n - 1
elts = elts[1:]
return elts
通过例子理解求值
至今在 myDrop 的描述中,我们关注的是表面的特点。我们需要更深入些,并展示函数应用工作方式的思维模型。为此,我们先研究一些简单的例子,直到可以把表达式 myDrop 2 "abcd" 的执行过程走通。
我们已经提到几次将变量替换为表达式,我们将在此使用这种能力。这个过程不断的重写表达式,把变量替换成表达式,直到达到最后的结果。最好拿出纸笔好跟随我们的演示来做。
惰性求值
我们从一个简单的非递归函数的定义开始。
-- file: ch02/RoundToEven.hs
isOdd n = mod n 2 == 1
这里 mod 是标准的取模函数。理解Haskell中求值如何工作的第一步是领会 isOdd (1 + 2) 这个表达式求值的结果。
开始Haskell中的求值过程前,先看下更熟悉的语言中的求值策略。首先,子表达式1 + 2 求值得到3。然后 n 绑定到 3 并求 isOdd 3的值。最后 mod 3 2 求值得到1,1 == 1 求值得到True。
在严格求值语言中,函数的参数在函数应用前先被求值。Haskell选择了另外的路:非严格求值。
在 Haskell中,子表达式 1 + 2 并不被简化为3。而是创建一个“约定”:当需要表达式 idOdd (1 + 2) 的值时我们可以计算它。用来追踪一个未被求值的表达式的记录称为形式实在替换程序。这就是发生的所有事:我们创建了一个转换程序,并将实际的求值延迟到真正需要它的时候。如果这个表达式的结果后面从来没有被用到,则根本不计算它的值。
非严格求值经常称为惰性求值。
一个更复杂的例子
现在看下表达式 myDrop 2 "abcd" 的求值,使用 print 来确保它将被求值。
ghci> print (myDrop 2 "abcd")
"cd"
第一步尝试应用 print,这需要它的参数已经被求值。为此,我们把值 2 和 "abcd" 应用到函数 myDrop 。把变量 n 绑定到值 2 上,xs 绑定到 "abcd" 上。把这些值替换进 myDrop 中的条件表达式,得到如下表达式。
ghci> :type 2 <= 0 || null "abcd"
2 <= 0 || null "abcd" :: Bool
要求出条件的值,这些已经足够了。这需要求值 (||) 表达式。为了确定它的值,(||)表达式需要检查它的左操作数的值。
ghci> 2 <= 0
False
把这个值替换到 (||) 表达式中得到下面的表达式。
ghci> :type False || null "abcd"
False || null "abcd" :: Bool
如果(||)左操作数求值为True的话就不会对右操作数求值,因为它的结果不会影响整个表达式。因为左操作数是False, (||) 必须对右操作数求值。
ghci> null "abcd"
False
把这个值替换进(||)表达式。因为两个操作数求值都为False, (||)表达式也是False,因此条件求值为False。
ghci> False || False
False
这导致 if 表达式的 else 分支被求值。这个分支包含对myDrop的递归应用。
[Note] 自由使用的短路
很多语言需要特殊处理“逻辑或”操作,才能让它在左操作数为真时短路。在Haskell中,(||)是一个普通的函数:非严格求值使这项能力集成进了语言。
在Haskell中,可以简单的定义具备短路能力的新函数。
-- file: ch02/shortCircuit.hs
newOr a b = if a then a else b
如果写一个 newOr True (length [1..] > 0) 这样的表达式 ,将不会对第二个操作数求值。(这很好,那个表达式试图计算一个无限列表的长度。如果对它求值的话,会挂起ghci,无限循环直到我们杀掉它。)
如果在Python一类语言中写类似的函数,严格求值会造成麻烦:传递给newOr 前每一个参数都会被求值,我们就不能避免第二个参数的无限循环了。
递归
当递归的应用 myDrop 时, n 绑定到 2 - 1 这个形实转换程序上,xs 绑定到 tail "abcd"。
现在又重新从myDrop的开始求值。把新的 n 和 xs 的值替换进条件表达式。
ghci> :type (2 - 1) <= 0 || null (tail "abcd")
(2 - 1) <= 0 || null (tail "abcd") :: Bool
这是左操作数求值过程的浓缩的版本。
ghci> :type (2 - 1) <= 0
(2 - 1) <= 0 :: Bool
ghci> 2 - 1
1
ghci> 1 <= 0
False
就如期待的那样,直到需要时才对表达式 2 - 1 求值。我们也对右操作数惰性求值,延迟 tail "abcd" 直到我们需要它的值时。
ghci> :type null (tail "abcd")
null (tail "abcd") :: Bool
ghci> tail "abcd"
"bcd"
ghci> null "bcd"
False
条件又求值为False,导致 else 分支再次被求值。
为了对条件求值,我们必须对表达式 n 和 xs 求值,现在知道在此次 myDrop 的应用中,n 值为 1,xs 为 "bcd"。
结束递归
在下次 myDrop 的递归应用中,绑定 n 到 1 - 1, xs 到 tail "bcd"。
ghci> :type (1 - 1) <= 0 || null (tail "bcd")
(1 - 1) <= 0 || null (tail "bcd") :: Bool
再一次,(||)需要先对左操作数求值。
ghci> :type (1 - 1) <= 0
(1 - 1) <= 0 :: Bool
ghci> 1 - 1
0
ghci> 0 <= 0
True
终于,这个表达式求值为True。
ghci> True || null (tail "bcd")
True
因为右操作数不会影响(||)的结果,它将不会被求值,条件的结果为True。这导致对 then 分支求值。
ghci> :type tail "bcd"
tail "bcd" :: [Char]
从递归中返回
记住我们现在处在 myDrop 的第二个递归应用中。这次应用求值为 tail "bcd"。从这个函数的应用中返回,将 myDrop (1 - 1) (tail "bcd")这个表达式替换成此次应用的结果。
ghci> myDrop (1 - 1) (tail "bcd") == tail "bcd"
True
我们从第一个递归应用中返回,用第二个递归应用的结果替换 myDrop (2 - 1) (tail "abcd"),得到这个应用的结果。
ghci> myDrop (2 - 1) (tail "abcd") == tail "bcd"
True
最后,从我们最开始的应用返回,使用第一次递归应用的结果替换。
ghci> myDrop 2 "abcd" == tail "bcd"
True
注意,当我们从每一个后继递归应用中返回时,表达式 tail "bcd" 都不需要被求值:起初的表达式求值的最后结果是一个形实转换程序。只有在ghci需要打印它的时候才最终被求值。
ghci> myDrop 2 "abcd"
"cd"
ghci> tail "bcd"
"cd"
我们学到了什么?
这里我们建立了一些重要的观点。
* 可以用替换和重写来理解Haskell表达式的求值。
* 惰性求值导致求值被推迟到我们需要一个值时,并且对一个表达式只进行按需求值:刚好够用于获得它的值的。
* 应用一个函数的结果可以是一个形实转换程序(一个被推迟的表达式)。
Haskell的多态
介绍列表时,我们提到列表的类型是多态的。我们在这里探讨Haskell的多态的更多细节。
要获得列表的最后一个元素,使用 last 函数。它返回的值具有与列表元素相同的类型,但是不管列表的元素实际是什么类型的 last 操作都一样。
ghci> last [1,2,3,4,5]
5
ghci> last "baz"
'z'
从它的类型签名含有类型变量可以看出这一点。
ghci> :type last
last :: [a] -> a
这里,a是类型变量。我们可以把这个签名读作“取一个列表,其元素有相同类型a,返回一个具有相同类型a的值”。
[Tip] 识别类型变量
类型变量总是以小写字母开头。通过上下文总是可以把类型变量与普通变量区分开来,因为类型和函数的语言是分开的:类型变量在类型签名中,一般变量在普通表达式中。
Haskell的实际应用中类型变量的名字一般都很短。一个字母的名字非常常见;长些的名字则不常见。类型签名经常很简洁;我们通过保持名字的简短来获得更多可读性,而不是靠名字的描述性。
函数的类型签名中有类型变量时,说明它的一些参数可以是任意类型,我们称这个函数是多态的。
当应用 last 到 Char 的列表时,编译器将把类型签名中的每个 a 替换成Char,得到 last 的输入类型 [Char],因此last类型为 [Char] -> Char。
这种多态称为参数型多态。通过类比可以容易的明白为何选择此名字:就像函数可以有一个参数并绑定到真实值一样,Haskell的类型也可以有一个参数并绑定到其他类型。
[Tip] 关于命名法
如果一个类型含有类型参数,那么称它为参数化类型,或多态类型。如果一个函数或者值的类型包含类型参数,我们成它为多态的。
当看到参数化类型时,就能知道代码并不关心实际的类型是什么。我们还可以更进一步说:没有办法找出实际的类型是什么,或者用此类型来操作这个值。无法创建这种类型的值,也不能深入查看一个值。唯一能做的就是把它当作一个完全抽象的“黑箱”对待。很快就会看到这很重要的一个原因。
参数化多态是Haskell支持的多态中最显见的一种。Haskell的参数化多态直接影响了Java和C#中的泛型功能的设计。Haskell中的参数化类型与Java泛型中的类型变量相似。C++模板也与参数化多态类似。
为把Haskell中的多态与其他语言中的概念区别清楚,这里有一些其他语言中常见而Haskell中不存在的形式。
在主流的面向对象语言中,子类型多态比参数化多态应用的更广泛。C++和Java中的子类机制实现了子类型多态。父类定义了一组行为,子类中可以修改或扩展它们。Haskell并不是面向对象语言,它不提供子类化多态。
强制多态也很常见,可以将一种类型的值隐式的转换成另一种类型的值。很多语言提供了一些强制多态的形式:例如自动在整数和浮点数之间转换。Haskell将这种简单的自动转换也慎重的避免了。
这不是Haskell的多态的全部内容,在第六章“使用类型类”我们将会回到这个主题。
多态函数的理解
在“函数类型与纯函数”一节,我们提到通过函数的类型签名来领会其行为。可以在多态函数上应用同样的推理方式。再看下fst函数。
ghci> :type fst
fst :: (a, b) -> a
首先注意到它的参数含有两个类型变量 a 和 b,说明这个元组的两个元素可以是不同类型的。
fst的结果类型为a。我们已经提到过参数化多态使得实际的类型不可知:fst没有足够的信息来构造一个类型为 a 的值,也不能把一个 a 类型值转成 b 类型。因此它唯一可能的行为是返回这个对的第一个元素(除去无限循环或崩溃的情况)。
延伸阅读
任何非传染的函数如果类型是 (a, b) -> a 必将与 fst 的行为完全一致,这后面有深层的数学涵义。此外,这种理解可以扩展到更复杂的多态函数中。在[Waldler89]论文中深入的讨论了这个过程。
(有建议说我们应该建立一个“理论框”来讨论深入的内容,并引用学术论文。)
多参数函数的类型
至今我们还没怎么看过多于一个参数的函数的类型签名。我们已经用过一些这样的函数;看下其中一个 take 的类型签名。
ghci> :type take
take :: Int -> [a] -> [a]
显然有一个 Int 和一些列表,但是为什么在类型签名中有两个 -> 符号?Haskell从右到左的组织这个箭头链;也就是 -> 是右结合性的。如果引入括号,可以更清楚的看到这个类型签名是如何解释的。
-- file: ch02/Take.hs
take :: Int -> ([a] -> [a])
从这里看,好像应该把这个类型签名读作:函数接受一个参数Int并返回另一个函数。另一个函数也接受一个列表参数,并返回一个同类型的列表作为返回值。
这种说法是对的,不过不大容易看出这中说法后面的重大意义。在“部分函数应用和柯里化”那一节将返回这个主题,那时我们已经花了些时间来写函数了。现在,可以把最后一个 -> 后面的类型当作函数的返回类型,前面类型当作函数的参数的类型。
现在可以给前面定义的 myDrop 函数写出类型签名。
-- file: ch02/myDrop.hs
myDrop :: Int -> [a] -> [a]
练习
1. Haskell提供了一个标准函数 last :: [a] -> a,返回一个列表的最后一个元素。只通过读它的类型,我们可以知道这个函数可能的行为是什么?这个函数显然不能做什么?
2. 写一个函数 lastButOne,返回最后一个元素之前的那个元素。
3. 在ghci中载入你的 lastButOne函数,尝试用不同长度的列表执行它。如果传的列表太短会发生什么?
为何偏爱纯函数?
很少有程序语言像Haskell那样坚持把纯函数当作默认的。这个选择的意义和价值非常深远重大。
因为应用一个纯函数的结果只依赖于它的参数,我们经常可以通过简单的读一个纯函数的名字并理解它的类型签名,来获得它的行为的强烈暗示。做为例子看下 not 函数。
ghci> :type not
not :: Bool -> Bool
即使不知道这个函数的名字,单单它的签名已经限制了它可能的有效行为只能是如下几种。
* 忽略它的参数,它总是返回 True 或者 False。
* 不加修改的返回它的参数。
* 否定参数。
我们也知道这个函数不能做的一些事:不能访问文件;不能连接网络;不能返回现在的时间。
纯函数性使得理解代码变得简单。一个纯函数的行为不依赖于全局变量的值,不依赖于数据库的内容,也不依赖于网络连接的状态。纯的代码是天生模块化的:每一个函数都是自包含的,并有一个定义良好的接口。
把纯函数性当作默认的有一个不显见的结果,那就是与不纯的代码工作变得简单了。Haskell的编程风格鼓励把必须使用副作用的代码与不需要副作用的代码区分开来。在这种风格下,通过对纯函数的代码应用“提升”,使不纯的代码保持简单。
软件的大部分风险来自与外部世界的交互,要处理不良的或缺失的数据,或者处理恶意攻击。因为Haskell的类型系统明确指出了哪部分代码有副作用,我们可以对此适当防护。因为我们的代码风格保持了非纯代码的隔离和简单,我们的“受攻击面”很小。
结论
在这一章里我们快速浏览了Haskell的类型系统和大部分的语法。已经看到了最常用的类型,知道了如何写简单的函数。介绍了多态,条件表达式,纯函数性和惰性求值。
有非常多信息需要吸收。在第三章“定义类型,流式函数”中,将在此基础知识上强化对Haskell的理解。
[2]“如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子”。
[3] 有时我们需要给编译器一些信息来帮助它在理解代码时做选择。
[4] 在“Haskell的多态”一节中将更多的讨论多态。
[5] ghci环境中操作的称为 IO monad。在第七章“I/O”中,将深入讨论 IO monad,那时将会明白这个ghci武断的强加给我们的限制的意义。
[6] “非严格”和“惰性”这两个术语有些许不同的技术含义,不过这里不对这些不同到细节展开讨论。