前言
Haskell算是一个非常著名的函数式编程语言了,可惜由于各种事情缠身,之前一直没有机会系统地学习。现参考课程:CIS194,结合我个人的一些理解编写这篇笔记,希望能对大家有帮助。
本篇笔记主要还是面向完全零基础的初学者,也算是我个人对自己Haskell知识体系的梳理。因此原课程很多比较具体的高级编程细节(比如fold
,Monoid
等实际编程的常用函数或class
,原教程花了很大篇幅介绍)我都将其忽略了,着重整理好Haskell基础语法(实际上光是基础语法也只是挑了最常用的那些写法来讲)。如有谬误可以评论区提醒。
但是原教材介绍的那些高级概念对于理解Haskell语言的编程思路和优势还是很有帮助的,所以我也打算以后另写一篇文章,补上关于那些内容的介绍😊。
搭建开发环境
安装工具链
点击链接:ghcup安装,然后会出现对应操作系统的工具链安装命令。复制这个命令运行,就可以进行安装了。
安装编辑器插件
个人使用的是Visual Studio Code这款编辑器。采用的插件是Haskell,在插件栏搜索这个插件然后点击安装即可。
第一次打开的时候插件可能会提示需要安装一些工具链的其它组件,点“是”让它自动安装即可。
测试是否安装成功
完成上面两步之后,相关的运行程序应该已经被自动加入了环境变量。现在打开一个新的终端,尝试调用ghc.exe
,看看是否已经安装成功:
C:\Users>ghc --version
The Glorious Glasgow Haskell Compilation System, version 8.10.7
像上面这样,调用ghc --version
能输出编译器的版本信息,就说明安装成功了。
如果没有正确输出,也有可能是安装程序没有将编译器自动添加到环境变量Path
中,可以尝试手动添加,看是否能解决问题。
Haskell语法简介
Hello World
这一小节,先通过一个能输出“Hello World”的程序,来让读者大致清楚Haskell文件是如何编译或者运行的。这样接下来每一节的内容,读者都可以自行编写一个能运行的程序,或者利用交互式的命令行,验证所说的内容。
编写一份Haskell源代码
一般来说,Haskell程序源代码文件的后缀是.hs
。此外还有一种后缀名是.lhs
,个人理解就跟Python程序的.py
和.ipynb
的区别差不多,前者就是纯粹的代码,后者一般用于一些教程文档,在大量注释中穿插一些实践代码。
现在建立一个文件,可以命名为demo.hs
,其中输入内容:
main = print "Hello World"
这个就是一份最简单的Haskell程序源代码了。
如你所见,Haskell其实跟其它很多语言也一样,一般来说需要一个“main
函数”(这个main
实际上并不是一个函数,而是一个类型为IO ()
的变量,Haskell程序的运行原理就是要计算main
这个变量的值。关于这点以后会详细解释),这是整个程序执行的入口。这份代码里面main
干的事情很好理解,就是输出了字符串"Hello World"
。
在Haskell中可以以换行区分语句,同时也支持以;
隔开不同的语句,这点类似JavaScript。本人比较习惯用换行,所以本笔记中都以换行分隔语句。
Haskell中的注释
几乎每个语言都提供给编写者写注释的功能,Haskell当然也不例外。Haskell的注释系统类似C语言:--
就像C语言中的//
;{-
,-}
则类似C语言中的/*
,*/
。如下例:
-- 这是单行注释
{-
这是多行注释
这是多行注释
-}
main = print "Hello World" -- 单行注释也可以写在一行代码后面
用解释器执行Haskell程序
要让这份源代码顺利执行,有两种方式,一种是先调用编译器,编译为一个可执行文件,然后执行那个文件;另一种是调用解释器,直接解析和执行源代码。
如果想使用解释器直接解释执行的话,就要调用runHaskell.exe
。在终端中输入runHaskell ./demo.hs
:
PS E:\2022SummerTerm\Haskell> runHaskell ./demo.hs
"Hello World"
如上,解释器正确解析源代码,输出了"Hello World"
。
编译Haskell程序
如果要采用编译执行的方法,则对应的要调用ghc.exe
。在终端输入ghc ./demo.hs
:
PS E:\2022SummerTerm\Haskell> ghc ./demo.hs
[1 of 1] Compiling Main ( demo.hs, demo.o )
Linking demo.exe ...
编译器就会进行编译了。接下来在文件夹中应该能得到demo.exe
这个可执行文件,然后调用它:
PS E:\2022SummerTerm\Haskell> ./demo
"Hello World"
也是成功输出了"Hello World"
。
交互式窗口
除此之外,也可以使用交互式的命令行终端。调用ghci.exe
就能进入交互式的命令行:
PS E:\2022SummerTerm\Haskell> ghci
GHCi, version 8.10.7: https://www.haskell.org/ghc/ :? for help
Prelude>
这个就跟Python的交互式终端很像了,直接输入表达式print "Hello World"
,按enter就能执行该表达式了:
Prelude> print "Hello World"
"Hello World"
Prelude>
成功输出了"Hello World"
。
说到ghci
,一般来说可以用:
引导,使用一些方便快捷的小功能,比如:t
后面接一个表达式,可以输出该表达式的类型;:l
可以用于引入一整个文件,等等。见下例:
Prelude> :t 'a'
'a' :: Char
前面:t
引导,最后正确输出后面表达式'a'
的类型是Char
。
变量和一些常用类型
和其它语言一样,Haskell也有变量的概念,每个变量都拥有自己的类型。Haskell的变量声明非常简单,就是变量名 = 值
的形式,例如x = 1
就声明了一个值为1
的变量x
,x
的类型是自动推导的,比如这里应该就是Num a => a
(关于这个表达式,=>
前面的内容是一些说明性语句,Num a
表示a
这个类型是属于Num
这个“类型集合”的,而=>
后面的内容才是对x
的类型标注,这里就是说x
类型是a
。以后会详细解释)。
编写Haskell程序时,一般希望变量名是驼峰式的命名风格,不过使用其它风格也不会导致错误。需要注意的是Haskell中变量名不能用大写字母开头,因为那将被解释为一个自定义的数据类型(或者自定义的类型集);另外Haskell的变量名中非首位可以带有'
(虽然'
也同时被用于包裹Char
类型的字面量),举个例子,像_ooksUksf'''''
,kYSIFyuksf____ksfh'''skfjsdj'''s'
等都可以是合法的变量名,反而像X
,Y
这种是不合法的。
跟其它很多语言(比如Python)一样,很多时候我们会用_
这个变量名表示不会用到的变量。稍有不同的是,如果是写过Python的读者,应该会知道Python里面赋给_
的值并不会真的被丢弃,比如写了_ = 0
之后可以写print(_)
,会输出0
;而在Haskell中,_
这个变量是确实被丢弃了,也就是比如写了_ = 0
之后要是想输出或者使用_
会直接报错。
有的时候为了观看代码更加清晰,或者出于什么其它目的,也可以先通过变量名 :: 类型
的方式事先标注变量的类型,再声明变量,例如:
x :: Double
x = 1
这样变量x
就是Double
类型的了(如果不写x :: Double
,那么x
的类型就会是1
的默认类型Num a => a
了)。有的量(比如数字)也支持直接用::
引导,指定为具体类型,比如上述代码也可以写成:
x = 1 :: Double
这里1 :: Double
就是一个Double
类型的量了,把它赋给x
,那么x
自然就是Double
类型的一个变量。
Haskell的变量比较特殊,更类似“常量”的概念,也就是在一个完整的Haskell源代码文件中,同一个变量不能被赋值两次。比如如下这个源代码片段会因为重复给变量x
赋值而无法通过编译:
x :: Int
x = 1
x = 2
需要注意的是,在调用ghci
使用交互式命令行时,并不是在执行一个完整的脚本,所以两次对同一个变量赋值并不会报错,而是解析为删除原有变量,然后新建一个同名变量。
下面介绍Haskell中几种比较常用的类型:
Int
这个被称为原生整数类型,根据电脑的硬件,操作系统等条件,解析为固定位的整数,常见的就是32位整数或者64位整数。要查看具体的范围是多大,可以通过maxBound :: Int
和minBound :: Int
获得。
Integer
这个就类似于Python里面的整数,只要内存够大,可以存储任意大的整数。参考代码:
x :: Integer
x = 999999999999999999999999999 -- 这个数已经超出了Int的范围
main = print (maxBound :: Int, minBound :: Int, x)
运行这段代码的输出:
PS E:\2022SummerTerm\Haskell> runHaskell ./demo.hs
(9223372036854775807,-9223372036854775808,999999999999999999999999999)
这里输出的前两个数就是本机Int
类型数值的上下限,第三个数就是x
的值,是大于Int
的上限的。实际上如果声明过x :: Int
,那么在后续x = 999999999999999999999999999
的时候都没法通过编译。
Float
,Double
没什么特殊的,Float
是单精度浮点数,Double
是双精度浮点数。
Bool
布尔类型,值只有True
和False
。
Char
任意一个Unicode单字。Char
类型的变量值一般都是用''
包裹,例如'三'
,'3'
,'c'
等等。
提到Char
,那就得提到String
,这点其实很像C语言,String
类型就是Char
类型数组,变量值可以用""
包裹,比如"3"
,"3c三"
等都是字符串类型。此外也可以用数组变量的一般表示,比如['3']
,['3', 'c', '三']
,两种形式完全等价。
[]
(数组)
上面通过String
引入了Haskell中的数组概念。其实与其说是“数组”,不如细化一点称作“链表”,因为实际上Haskell中数组的实现方式就是链表结构。这就是说,不能像C语言那样,很方便地得到一个数组某个特定位置存的值(指效率不高,实际上通过!!
运算符还是能直接获得特定位置的元素的,不过出于效率考虑不推荐那么写)。但是相对的,其它操作,比如连接两个数组,就会方便得多。
声明一个数组,只用把其中的各个值用逗号连接,然后用方括号套起来即可,例如:
myList = [100, 300, 90, 87]
需要注意的是数组中的数据类型应该是一致的,比如上面的例子,myList
中元素的数据类型就是一致的Num a => a
。如果写诸如myList = [1, 'a']
之类的表达式则是不行的。
其实严格来说,[]
本身是一个类型构造器,属于* -> *
这一“超类”(英文:kind
,就像每个变量都有自己的类型,每个类型或者类型构造器都有自己的“超类”)。这个表达式的意思就是说,[]
需要接受另一个具体类型,才能组合成一个真正的类型,比如上文的myList
实际类型其实是Num a => [a]
,其中[a]
就是[]
接受了类型a
的结果,才是一个具体的类型。
针对数组这一类型,Haskell还提供了另一个常用的语法糖,一个非空的数组总能写成a : as
的形式,其中a
表示第一个元素,as
表示剩余元素组成的数组。这个写法不仅能用于构造数组,也能用于变量的赋值,例如:
myList = 8 : [1, 2] -- myList: [8, 1, 2]
firstElement : others = myList -- firstElement: 8, others: [1, 2]
firstErr : _ = [] -- 调用fristErr会报错,因为[]为空,并没有第一个元素
如果读者真的尝试运行上面的代码片段,会发现实际上并不会立即报错。这是因为执行完firstErr : _ = []
后,firstErr
的值并不会立即开始计算。直到后面真正使用了firstErr
,比如写main = print firstErr
,才会出现报错。这是由于Haskell的懒惰计算机制,后面会详细解释。
对于数字类型的数组,很多时候也会用..
连接首尾数字,然后用方括号套起来表示一个数组,例如[1 .. 4]
就表示[1, 2, 3, 4]
。其实使用..
的表达方式不只限于整数(比如可以写[1.1 .. 7.2]
,[1.7 .. 6]
之类的),不过实际使用大都是用于生成整数数组。
用这种方式也可以表示无限数组,也就是只给出第一个元素,却不给出末尾元素,例如[1 .. ]
就表示一个无限数组,它表示[1, 2, 3, ... ]
。由于Haskell的惰性计算原则,很多时候我们都可以写出一些具有无限性的表达式出来(例如无限数组,可以无限递归下去的函数等)。但是只要我们后续按规则使用其中的有限项,程序就不会出问题。例如写:
x : _ = [1 .. ]
main = print x
会正确输出1
,而不会无限展开[1 .. ]
导致出错。
()
(元组)
Haskell中元组的表示形式和使用方式基本和Python没有区别。唯一有区别的就是Haskell中似乎无法直接构造一个单元素的元组(因为Haskell中元组构造不能有多余的逗号)。总之基本就是Python怎么写Haskell就怎么写,举一些例子,读者应该很好理解:
myTuple = (1, 'o', "string", (+)) -- 构造一个元组,元组的元素类型可以不一样
-- +是加法运算符,那么(+)就是一个表示求和的二元函数
-- 类型: (Num a, Num b) => (a, Char, [Char], b -> b -> b)
(x, _, _, _) = myTuple -- 利用元组解包给变量赋值
-- x: 1
(y, _) = (1, 2, 3) -- 会报错,元组并不支持用_捕获多个元素
眼尖的读者可能会看到,上面对于函数
(+)
的类型标注是Num b => b -> b -> b
。按理来说对于这样一个二元函数,标注为Num b => b b -> b
不是看起来更自然一点么?思考一下为什么这么写。
一些常用的运算符
在上文关于元组的讨论中,我引入了一个表达式:(+)
,其实就是表示+
这个运算符,当它被小括号包裹,就可以作为一个函数,这个函数接受两个输入,然后输出它们的和。跟很多语言的设计思路一样,Haskell的运算符本质都是函数,而函数也可以通过一定的方式转化为一个对应的运算符。
因此这里要稍微说一下Haskell中函数是怎么使用的,跟很多其它语言不同,Haskell中使用函数并不需要额外的括号。这就是说,比如同样要传参a
和b
给一个二元函数func
,如果是C,Python,Java这些语言,就是写func(a, b)
,而在Haskell中则是直接写func a b
即可(如果写func(a, b)
,其实意思就是func
接受了一个参数(a, b)
,而前面讲过(a, b)
是一个元组)。
实际上,从根本来说,Haskell中所有的函数都是一元函数,上文写func a b
,其实是先计算func a
,这个表达式的结果是一个函数(比如记为lambda
),然后再计算lambda b
,这个表达式的结果就是func a b
的结果了。关于这点,会在后面做更详细的说明;而一般没必要想得那么抽象,直接看成func
同时接受了a
和b
两个参数会更好理解一些。
所以上面说过(+)
是一个二元函数,那么要计算1
和2
的和,就既可以写1 + 2
,也可以写(+) 1 2
,这两种表示方法是等价的。
以+
作引,下面简单讲讲Haskell中比较常用的一些运算符:
+
,-
,*
加,减和乘运算。这三个运算符对应函数的类型都是Num a => a -> a -> a
,就是对于任何一个属于“类型集合”Num
的类型a
,两个a
类型的变量都可以用这三个运算符中任意一个连接起来,然后计算得到对应的值。
这里要注意,Haskell是一个强类型语言,确定了类型的变量并不会像C之类的语言那样在某些表达式中自动转换为其它类型的变量,所以+
,-
和*
连接的两个变量类型应该是完全一致的。举个例子,(1 :: Int) + (1 :: Int)
的结果会是Int
类型的2
,但是如果写(1 :: Integer) + (1 :: Int)
则会出错。
/
,`div`
,`mod`
四则运算有加减乘除,在除法方面则稍微复杂一点,/
表示的除法对应的函数类型是Fractional a => a -> a -> a
,也就是只支持小数类型之间的运算。如果要表示整数除法求商,则要用到`div`
运算符,例如13 `div` 5
,运算结果是2
(相对地,13 / 5
的结果为2.6
)。
上文说到过给+
等运算符加上一个小括号,就会变成一个对应的函数。与之对应的,其实所有的二元函数都可以通过用一对反引号包裹的方式,变成一个对应的运算符。其中`div`
就是一个很好的例子,其实本来div
是一个二元函数,上文求13
对5
的商,完全可以写成div 13 5
。一般地,对于二元函数func
,func a b
就等价于a `func` b
。
那么同理,求整数除法中的余数,就要用到`mod`
运算符了,例如13 `mod` 5
结果就是3
,等价于mod 13 5
。
++
上文说过,由于Haskell中数组的实现方式是链表,所以进行数组拼接将会非常方便。这里++
就是拼接运算符,它可以接受两个类型相同的数组作为参数,返回它们拼接后的结果,例如:
listA = [1, 2, 3]
listB = [4, 5]
listC = listA ++ listB
那么listC
就会是[1, 2, 3, 4, 5]
。
特殊地,因为String
是就是Char
数组,所以字符串间也可以用++
进行拼接,例如"Hello" ++ " " ++ "world"
的结果就是"Hello world"
。
其实++
运算符不止可以拼接数组,对于满足特定条件的一系列数据类型,都可以使用++
做拼接操作。这个以后会详细说明。
==
,/=
,>
,<
,>=
,<=
这些都是常见的比较运算符。Haskell的不等于号是用/=
表示的(很多其它语言都是用的!=
),这是因为Haskell的数学背景更深一点,/=
的样子和实际的不等号(≠
)更接近。
这部分的使用跟其它语言也差不多。值得一提的是Haskell中类型相同的数组和元组之间也可以作比较,比较是否相等时,只有当对应位置的元素全部相等才会认为相等;比较大小时,则从首个元素开始一一比较,返回第一个不等元素之间的大小关系。例如:
r1 = ('a', 'b') == ("a", 'b') -- 出错,因为左式类型(Char, Char),而右式类型(Char, String)
r2 = ('a', 2) == ('b', 1) -- 顺利运行,结果为False
r3 = ('a', 2, 3) < ('a', 1, 4) -- 左右类型相同,顺利运行
-- 先比较两者首位,'a'和'a'相等,分不出大小
-- 因此继续比较次位,2比1大,故r3为False
r4 = [1, 2, 3] < [0, 2] -- False
r5 = [1, 2, 3] > [1, 2] -- 首位和次位都分别相同
-- 右式所有元素都已比完,则更长的左式被认为更大,故r5为True
r6 = "abc" > "bc" -- String也是特殊的数组,因此也可以按上述规则比较大小
-- r6为False
&&
,||
,not
这也是两个很常见的运算符了,分别表示逻辑运算的“且”和“或”。和其它很多语言一样,这两个逻辑运算都是“短路运算”。这就是说,对于a && b
,如果a
计算出来是False
,那么会直接忽略b
的计算,整个表达式返回False
;对于a || b
,如果a
已知是True
,那么也会跳过b
的计算直接返回True
。下例就可以说明这个现象:
a : _ = [] -- 计算a的值时,应该会导致出错
r1 = False && a -- 后续输出r1,会直接输出False而不出错,说明a的值并没有进行计算
r2 = True || a -- 与上一行同理
说到逻辑运算,那就不得不提到取非的操作,Haskell中用not
表示,not True
就是False
,not False
就是True
。
`map`
对于数组来说,这是一个很重要的运算符。`map`
接受两个参数,左边为一个函数,右边则为一个数组,它会返回对传入的数组的各个元素都调用传入的函数后,产生的那些结果组成的数组。
举个例子,even
是一个Haskell标准库提供的函数,接受一个整数作为参数,若为偶数则返回True
,否则返回False
。那么even `map` [0, 1, 2, 4]
的结果就是[True, False, True, True]
。
当然了,`map`
其实就是从函数map
转化而来的一个操作符,也有很多人更喜欢直接写map func array
。其实使用哪种写法都没有关系,每个人的习惯不一样罢了。
和++
一样,其实`map`
也不是只能用于数组。有一系列数组类型,都可以参与`map`
运算,这件事情以后也会提到。
函数
定义一个函数
通过上述对运算符的介绍,引入了函数这个概念。其实Haskell中定义一个函数很简单,因为函数也不过是一种特殊的变量罢了。我们当然可以采用直接赋值的方式来定义函数,例如:
myEven = even
这样一来我们就定义了一个函数myEven
,它的功能和函数even
完全一致。如果调用myEven 0
,那么会返回True
,调用myEven 1
则会返回False
。
不过很多时候事情并不会这么简单,我们很可能要针对函数的输入,做一些操作,然后得到输出。像上面这种直接赋值的定义方法,完全无法利用输入的信息。Haskell中,要捕获函数的输入非常简单,直接在函数名后面跟上变量名,就可以捕获输入,例如:
addOne x = x + 1
这样就定义了一个函数addOne
,当它接受一个参数时,参数会被捕获为x
,然后函数返回x + 1
的值。
作为一个变量,addOne
自然也有自己的类型。这里因为参数x
能够进行+ 1
的运算,所以x
需要是一个属于Num
类型集的类型,因此addOne
的类型应该是Num a => a -> a
。
定义这样一个简单的加一函数尚且能比较轻松地看出类型,但是如果函数体比较复杂的话可能就没这么轻松了。所以对于函数,一般建议先用::
指定它的类型,然后再写具体的函数体。
对于多元函数,也是如此。考虑一个简单的功能,输入两个整数,然后输出它们的和,这个时候我们希望能一次性捕获两个参数,那么就简单把它们列出在函数名后面即可:
myAdd :: Int -> Int -> Int
myAdd x y = x + y
分条定义函数
回到myAdd
函数,看起来它的功能还是有点简单(实际上,这样完全可以直接写myAdd = (+)
)。再考虑一个稍微复杂一点的,如果当第一个参数为1
时,我们希望函数直接返回y
,而对于其它情况仍希望返回x + y
,应该怎么写?如果是其它语言,可以在函数体内判断一下第一个参数是否等于1
,然后分情况返回,但是如果使用Haskell,一般更习惯用函数匹配的写法表达这个意思:
myAdd :: Int -> Int -> Int
myAdd 1 y = y
myAdd x y = x + y
当调用函数myAdd
时,Haskell会从上而下逐个尝试匹配参数,并以第一个成功匹配到的情况执行。这样比如调用myAdd 1 2
,因为第一个参数1
能被常量1
匹配,第二个参数2
能被变量y
捕获,那么Haskell就会直接按myAdd 1 y = y
执行,然后返回2
。
注意Haskell并不会多么“智能”,它只是严格按照从上到下的顺序进行匹配罢了,所以如果把两个表达式反过来,写成:
myAdd :: Int -> Int -> Int
myAdd x y = x + y
myAdd 1 y = y
那么对于myAdd 1 2
,因为可以直接被第一行的表达式捕获,所以会按myAdd x y = x + y
返回3
。在这个例子中,实际上myAdd 1 y = y
永远都不会被执行到,Haskell也会报相应的警告(Warning: Pattern match is redundant
)。
函数的本质是一元函数
上文提到过,Haskell中的任意函数都可以看作一个一元函数,这里也不例外,我们可以把myAdd
看作一个一元函数,然后按一元函数的定义方式定义它。按照这个定义,myAdd
应该接受一个整形参数,然后返回一个类型为Int -> Int
的函数。这样写出来应该形如:
myAdd :: Int -> Int -> Int
myAdd 1 = ...
myAdd x = ...
那么返回的函数分别是什么呢?
myAdd 1
的结果应该满足这样的性质,它接受任意一个整数y
,然后返回y
本身。这种原封不动返回参数自身的函数,标准库已经提供给我们了,叫做id
,所以第一条表达式可以写成myAdd 1 = id
。看起来id
的功能很傻,实际上这个函数主要就是为了这种情况服务的。
而对于其它的输入,myAdd x
应该返回一个函数,这个函数接受参数y
然后把x
和y
的和作为输出。在Haskell中,对于一个二元操作符,想表达这种函数尤为简单,直接写(x +)
即可。这是一种语法糖,表示的是一个函数,功能就是接受一个参数,然后返回x
加那个参数的结果(参照这个写法,读者不难理解(+ x)
,(`map` [1, 2, 3])
等表达式的意思)。这样一来,myAdd
的第二条表达式就可以写成myAdd x = (x +)
了。
所以总结一下,如果想以一元函数的形式定义myAdd
,可以这么写:
myAdd :: Int -> Int -> Int
myAdd 1 = id
myAdd x = (x +)
需要注意的是,定义函数时不同元数的表达式不能混用,比如如果按以下写法是不行的:
myAdd :: Int -> Int -> Int
myAdd 1 = id
myAdd x y = x + y
小思考:要是函数定义为
myAdd (x, y) = x + y
,这是几元函数,具体类型是什么?还能利用id
,(x +)
等函数换一种写法吗?
guards
(布尔表达式匹配)
上面这个函数的功能还是稍显简陋。假如现在再做一点改进,当第一个参数不大于1
的时候,希望直接返回第二个参数;否则返回两参数的和,应该怎么写?如果还是按上面这样一一枚举,依次写myAdd 1 = id
,myAdd 0 = id
,myAdd (-1) = id
等,那么肯定是行不通的,因为不大于1
的整数有无限个。
考虑其它语言,要完成这个功能一般就是判断一下第一个参数x
是否满足x <= 1
,然后按判断结果分别返回结果。Haskell中一样能用if ... then ... else
语句进行类似的操作,比如可以这么写:
myAdd :: Int -> Int -> Int
myAdd x y = if x <= 1 then y else x + y
不过如果条件再复杂一点呢,比如对于首个参数大于1
的情况,如果它是奇数,那么也直接返回第二个参数?当然也可以:
myAdd :: Int -> Int -> Int
myAdd x y = if x <= 1 then
y
else if even x then
x + y
else
y
不过这样实现显得非常冗长。鉴于这种多次判断的情况在定义函数的时候经常出现,Haskell提供了对应的语法糖,就是被称作guards
的一种写法:
myAdd :: Int -> Int -> Int
myAdd x y
| x <= 1 = y
| even x = x + y
| True = y
这种写法跟连续的if ... then ... else ...
逻辑完全一样,不过用|
隔开了各个判断条件。同样是由上到下匹配第一个能判断为True
的布尔表达式,然后执行该表达式后=
连接的内容,这样看上去就清晰得多。
很多时候像上面那样,最后一个表达式会以True
作为其布尔判断,因为这样可以保证myAdd
能对所有的输入都有输出。这是一种良好的编程习惯,但是其实Haskell从语法层面并没有硬性规定一个函数要对所有输入都给出确定的输出,比如如果写:
myAdd :: Int -> Int -> Int
myAdd x y
| x <= 1 = y
| even x = x + y
也是完全可以运行的,不过比如调用myAdd 3 2
的话,程序就会崩溃(报错Non-exhaustive patterns in function myAdd
)。所以非常建议读者写guards
时,都能在最末尾使用一个True
。
由于末尾使用True
的情况过于常见,为了从语义上更便于理解,Haskell标准库内置了一个值为True
的变量otherwise
。这样一来就可以写成:
myAdd :: Int -> Int -> Int
myAdd x y
| x <= 1 = y
| even x = x + y
| otherwise = y
语义上看就是“某某条件则……,某某条件则……,否则……”的结构,就更好理解了。注意otherwise
并不是保留字,只是一个普通的变量罢了,所以实际上在其它要用True
的情况中用otherwise
(比如otherwise && False
之类的)也是可以的,不过非常不建议这么做。
匿名函数
考虑这样一件事情,对于一个整数数组,如果希望能把其中所有的1
都转化为2
,其它元素保持不变,应该怎么实现这个功能?之前介绍过`map`
这个运算符,那么事情就非常简单,可以先构建一个函数func
,接受一个整数输入,当输入为1
时返回2
,否则返回输入本身,然后再把这个func
通过`map`
与数组进行运算,就能得到我们想要的数组了。写成代码就是这样:
myList = [1, 2, 3, 4]
func :: Int -> Int
func 1 = 2
func x = x
myList' = func `map` myList
看起来很不错,但是美中不足的是这个func
显得有点多余:它实现的功能过于简单,而且后续很可能不会再用到这个函数了,给它专门分配func
这个函数名,似乎有点“浪费”。对于只用一次的简单函数,我们希望能用一种简单的手段直接描述它的功能,而不分配一个函数名给它,这就是匿名函数的使用目的。
要构造一个匿名函数,需要以\
开头。据说,这是因为匿名函数又称“λ表达式”,而字符“λ”去掉左边一撇后就是\
,所以Haskell中便以\
引导一个匿名函数。\
后是列举的函数变量,随后接一个->
,后面写的就是函数的函数体。
如果用匿名函数表示func
,就是\x -> if x == 1 then 2 else x
。这就是说,这个函数只接受一个输入,将它捕获为x
,然后返回值就是if x == 1 then 2 else x
这个表达式的值,也就是当x
为1
时返回2
,否则返回x
本身。这样一来上面的代码就可以大大简化,写成这样了:
myList = [1, 2, 3, 4]
myList' = (\x -> if x == 1 then 2 else x) `map` myList
当然也可以定义多元的匿名函数,比如(\x y -> x + y + 3) 1 2
,这个表达式的值就是1 + 2 + 3 = 6
。就像其它Haskell多元函数一样,多元的匿名函数本质上还是一个一元函数。比如如果写(\x y -> x + y + 3) 1
,那么从效果来看实际上得到的就是(4 +)
这个函数。
对于忽略输入,直接返回常数值的函数,比如\_ -> 1
,即使使用了匿名函数的表达方式也显得冗长。对于这种情况Haskell也提供了另一种语法糖,就是使用const
关键字。例如前面举的返回常量1
的例子,其实可以写成(const 1)
。
复合运算符
实际写代码时,很多时候都会出现函数嵌套的现象,也就是某个函数的输出被另一个函数作为了输入。例如(1 +) ((1 +) x)
,这里两个(1 +)
都是一个一元函数,先计算右边(1 +) x
的值,然后再用(1 +)
作用一次,得到的就是该表达式的值。
这个例子看起来很傻,因为我们都知道其实这就等价于写(2 +) x
。这就是说,其实两个(1 +)
连续作用就等价于(2 +)
。这就是复合函数的意义,我们可以说(1 +)
和(1 +)
复合得到的结果是(2 +)
。在Haskell中,“复合”的概念是由运算符.
表示的,(1 +) . (1 +)
表示的就是两个(1 +)
复合的意思,如果我们写func = (1 +) . (1 +)
,那么我们可以认为func
就是(2 +)
。
借用这种方式,我们能够更加方便地定义函数。举个例子,比如对于一个整形数组,我们希望能将其每一项都加上1
,然后再在数组末尾添上一个0
。如果要把这个操作封装成一个一元函数func
,可以这么写:
func :: [Int] -> [Int]
func xs = (+ 1) `map` xs ++ [0]
这当然是很好的,不过我们可以换一种思路,每一项都+ 1
的操作其实就是对输入应用了map (+ 1)
这个函数,末尾补上一个0
等价于对上一步的输出再应用一次(++ [0])
这个函数。所以我们也可以这么写:
func :: [Int] -> [Int]
func = (++ [0]) . (map (+ 1))
这种定义函数时,完全不捕获变量的表达方式称作Point-Tree
风格。在.
操作符和一系列特定的预设函数的帮助下,理论上所有的函数都可以以这种风格表示。
适当地使用这种风格能让代码变得更简洁,但是有的时候过度使用会导致可读性下降。原教程(Lec 4)上举了个小例子:考虑函数\f g x y -> f (x ++ g x) (g y)
,如果硬要写成Point-Tree
风格就是join . ((flip . ((.) .)) .) . (. ap (++)) . (.)
,显然正常人更容易看懂前者。
自定义类型
Haskell不是一个面向对象的语言,所以它并没有像C++,Java那样复杂的OOP接口。但是跟C,Rust等语言一样,Haskell也提供自定义简单的数据结构的功能。
data
关键词和构造函数
Haskell中要声明一种自定义的数据类型,要用data
关键字引导,后面写该数据类型的名字(前面讲变量时说过,变量名不能以大写字母开头;对应的,这里数据类型的名字必须以大写字母开头),接等号后连接其构造函数(构造函数名也要以大写字母开头)。
举个例子,比如现在想声明一个“个人信息”类型(PersonalInfo
),其中要包含一个String
类型的变量表示名字,和一个Int
类型的变量表示年龄,那么我们就可以这么写:
data PersonalInfo = PersonalInfo String Int
这里PersonalInfo
这个名字,既是一个数据类型名,也表示一个构造函数(类型是String -> Int -> PersonalInfo
)。在自定义数据结构时,很多时候我们都选择让其构造函数名跟类型名相同,这样我们看代码就会更方便。
我们可以用构造函数将数据存储进我们自定义的类型中:
demoInfo = PersonalInfo "Tom" 25
也可以利用构造函数把数据从自定义的类型中解包出来:
PersonalInfo demoName demoAge = demoInfo
-- demoName: "Tom"
-- demoAge: 25
作为一个函数,构造函数当然也可以看作一个一元函数。这就是说,比如我们写func = PersonalInfo "Lily"
,那么它就表示一个函数,可以接受一个整形变量,然后返回一个PersonalInfo
类型的变量,其中该变量存储的名字是"Lily"
,年龄是接受的那个整形变量。
绑定构造函数的参数名
然而这个表示还是有点让人困惑,单从字面上看,我们只知道PersonalInfo
分别存储了一个String
和Int
类型的变量,却很难看出它们的含义。比如说,要是认为其中String
类型的那个变量存储的是身份证号码,看上去也非常合理。针对这种情况,我们可以给构造函数的每个参数绑定一个名字,例如我们可以写:
data PersonalInfo = PersonalInfo {name :: String, age :: Int}
这样一来不管是构造还是解包,语义上都清晰得多。此外由于绑定了变量名,参数的顺序并不用严格按照String
,Int
的顺序,如下:
demoInfo = PersonalInfo {name="Bob", age=90}
PersonalInfo {age=demoAge, name=demoName} = demoInfo
-- demoName: "Bob"
-- demoAge: 90
当然了,绑定变量名后,原有的按顺序传参进行构造以及按顺序进行解包的写法也仍然是可行的,并不是每次都要写name=
,age=
这些冗杂的绑定参量。
多条构造函数
现在来点更复杂的,要是我们想自己实现一种简单的数组,应该怎么写?为了方便,我们不妨只实现存储Int
的数组,叫作MyIntList
。
数组有一个性质,从中剔除某些元素之后它还是数组。也就是说,我们可以递归地定义它的构造函数,每个构造函数接受一个整数值和一个数组,表示把该整数拼接到那个数组前面构成的新数组。写成代码的话,就是这样:
data MyIntList = Cons Int MyIntList
这里把这条构造函数取名叫Cons
,因为语义是构建(当然,像PersonalInfo
那样跟类名相同也是完全可以的,但是因为参数本身就有个MyIntList
类型,那样写看起来就比较容易产生混淆)。但是我们发现这样不停递归下去,并没有一个尽头。比如要是我们想表示[1]
,就应该写Cons 1 ...
,其中...
应该是一个表示[]
的MyIntList
,但是它并没有一个首元素,我们无法应用构造函数Cons
构建一个空列表。
所以我们希望MyIntList
能有另一种构造函数,它不接受任何参数,然后返回一个表示空串的MyIntList
值(其实此时它的类型已经不是一个函数了,而是一个MyIntList
类型的变量。不过为了方便,我们还是以“构造函数”称呼它吧,看成一个无参数的函数)。在Haskell中当然也是可以的,比如如果我们把这条构造函数命名为Empty
,就可以这么写:
data MyIntList = Cons Int MyIntList
| Empty
这样一来,MyIntList
就拥有了两条构造函数Cons
和Empty
,不管选用哪一条都可以成功构建出一个MyIntList
类型的变量。不同的构造函数之间要使用|
连接。
回到上文,此时想表达[1]
就很轻松了,可以写Cons 1 Empty
。
函数定义中的构造函数捕获
在上文介绍函数时,就引入过“匹配捕获”的概念:函数可以匹配变量的模式,然后应用第一条符合这个模式的定义(例如,写myAdd 1 y = y
,其实隐含的意义就是第一个参数符合“值为1
”这个模式)。对于自定义类型的构造函数,Haskell专门提供了对其的支持。
举个例子,假如对于MyIntList
,我们希望能写一个函数,方便地将该类型的变量转换为[Int]
类型的变量,那么我们就不得不区分它到底是通过Empty
构建的还是用Cons
构建的。对于Empty
,我们希望直接返回[]
;对于Cons
,则将第一个值提取出来,然后通过递归的方式处理剩下的值:
trans :: MyIntList -> [Int]
trans Empty = []
trans (Cons x xs) = x : trans xs
同理,对于绑定了参数名的构造函数,捕获时也能用相同的语法指定这些参数的捕获位置。
再考虑一个情景,对于一个MyIntList
类型的变量,如果我们希望删除它的非0
首元(意思就是,如果其非空而且第一个元素非0
,就把第一个元素删除),这个操作包装成一个函数应该怎么写呢?借助构造函数捕获,我们很容易给出一种实现:
delFstNotZero :: MyIntList -> MyIntList
delFstNotZero Empty = Empty
delFstNotZero (Cons 0 xs) = Cons 0 xs
delFstNotZero (Cons _ xs) = xs
看第二条:delFstNotZero (Cons 0 xs) = Cons 0 xs
,等号左右两边完全是一样的,这多少显得有点冗杂。可是如果我们直接用delFstNotZero myIntList = ...
的形式来写,我们又不能很方便地直接解包判断第一个元素是不是0
。换句话说,我们既想捕获构造函数的参数细节,又想直接捕获整个传入的变量,此时就可以用@
给整个变量绑定一个名字,例如:
delFstNotZero :: MyIntList -> MyIntList
delFstNotZero Empty = Empty
delFstNotZero input@(Cons 0 xs) = input
delFstNotZero (Cons _ xs) = xs
这样一来就看起来简洁许多。
newtype
关键词和惰性计算
对于只有一条构造函数,且该构造函数接受的参数只有一个的类型,Haskell更建议我们用newtype
关键词而不是data
(当然,从语法上说继续沿用data
也是没有错的)。例如:
newtype IntWrapper = IntWrapper Int
这个看起来有点匪夷所思,为什么要专门有这样一个特例呢?全部都直接用data
不是看上去更整齐么?其实这跟Haskell的惰性计算原理有关。
“惰性计算”这个词在很早的时候就提到过,当时只是简单地解释了一下,Haskell并不会在声明一个变量的时候立即计算它的值。只有在必要的时候,Haskell才会开始进行计算。所谓“必要”,一是语法层面规定的对main
的值的求解,二就是当遇到模式匹配的时候。
例如,回到之前对MyIntList
的讨论,当我们声明x = Cons 1 Empty
时,Haskell并没有计算x
的值,在底层代码中,Haskell只会记录有x
这个MyIntList
类型的变量,它具体的值并不知道,但是可以通过Cons 1 Empty
这个表达式计算出来。假如后续没有进行对x
的解包,哪怕被用来赋值(比如写y = x
,Cons u v = x
),对x
值的具体计算就永远不会进行了。Haskell就是靠这样的机制保证运行的效率,以及实现可以声明无限数组之类的特性。
而如果对x
进行解包,比如传给函数trans
(因为trans
需要判断x
是不是Empty
,假如不是,还要捕获Cons
构造函数的两个参数),x
就不得不往下算一步了(当然,只是计算到所需的最小步骤,例如如果x = Cons 1 (Cons 2 Empty)
,只会展开第一层,知道x
是Cons 1 y
,而其中y
并没有算出具体值,而是记录它可以通过表达式Cons 2 Empty
计算得到)。
由于每当进行模式匹配的时候都必须进行解包计算,有的时候可能还是会完成一些无意义的计算,比如参考函数func (Cons _ _) = 0
,即使它是一个常数函数,甚至已经通过_
告诉Haskell可以直接忽略构造函数的参数了,但是Haskell仍然要进一步展开传入的参数才能执行这个函数(读者可以想一想为什么这么设计)。
有一个简单的方法可以验证我上面说的内容。在Haskell中,可以用标准库提供的变量undefined
表示任意类型的一个不可计算值(这就是说,除非程序一直没有计算这个值,否则一旦尝试计算就会引发崩溃)。尝试运行以下代码,程序会崩溃,说明尝试计算了undefined
:
data MyIntList' = Cons Int Int
func (Cons _ _) = 0
main = print (func undefined)
相对地,如果函数定义改为func _ = 0
,那么就不会崩溃,而是正常输出0
,说明此时由于函数定义没有进行解包,那么undefined
就会保持未计算的状态直接传入func
。
唯有当这个类型只有一个单一参数的构造函数的时候,这个类型从某种程度来说就跟那个构造函数的参数的类型是等价的。例如上文定义的IntWrapper
,只要有一个Int
类型的变量,它就可以通过那个唯一的构造函数生成一个IntWrapper
的变量;假如有一个IntWrapper
类型的变量,它的使用价值也唯有通过唯一的那个构造函数解包出那个Int
类型的变量。在这种情况下,函数捕获的参数写_
和IntWrapper _
都是等价的,因为它后续的使用方式都是可以确定的。
这就是为什么要专门弄一个newtype
关键词。虽说写data
并没有语法错误,但是从效率上说可能会产生额外的计算。通过以下的例子可以很好地辨明它们之间的区别:
newtype IntWrapper = IntWrapper Int
data IntWrapper' = IntWrapper' Int
func (IntWrapper _) = 0
func' (IntWrapper' _) = 1
main = print (func undefined) >> print (func' undefined)
关于其中出现的>>
,其实也是一个运算符,这里用它连接两个print
表达式,就表示先执行左边的,再执行右边的。如果运行这个程序,会发现先输出一个0
,然后崩溃。
type
关键词
如果形容上文定义的IntWrapper
是“基本等同于”Int
的话,它的使用方式未免太麻烦了一点。有的时候,我们也希望能不要使用构造函数和解包,来创建一个某类型的“别名”。这时就可以用type
关键词,比如如果写type Int' = Int
,那么Int'
就相当于Int
的别名了,我们使用Int
的任何场合都可以换成Int'
。
泛类型
前面很多地方的类型标注都出现过类似这样的表达式:Num p => p
,(Num p, Num q) => p -> q
。之前稍微提到过,=>
前面表示的是对类型的说明,Num p
就表示p
是属于Num
这个“类型集合”的一个类型,=>
后面才是具体的对变量的类型说明。这就是一种泛类型的特性,就像C++的template
一样,使用泛类型,写了一条表达式就可以自动运用于各种不同的具体类型上面。
class
关键词
在Haskell中,像Num
这种能表示一些有共同特征的类型的东西,其实叫作class
(我不知道应该翻译成什么,我一般都叫它“类型集合”)。这个概念就类似Java中的interface
,你可以声明一个class
,规定需要有哪些方法,然后选取一个具体的类型具体实现那些方法,那么那个类型就被认为属于这个class
了。
要表达一个类型集合,只需用class
关键词引导,后面跟上该类型集合的名字,和它能描述的类型名,再后面用where
引导应有的各个方法的名字和类型。
例如,我们想表达这样一个概念,很多数据类型都可以被转换为一个Int
类型的变量。不只是Num
所包含的,比如[Int]
类型,我们可以定义这个数组元素的和就是它转换为的Int
变量的值。那么这样我们就不得不自己定义一种新的类型集合,其中的类型都需要能满足一个方法,该方法能把该类型的一个变量转换为一个Int
类型的变量,不如就叫作toInt
吧。而这个类型集合不如就称作Intable
。如此一来我们就可以写:
class Intable a where
toInt :: a -> Int
看到了么,其实语法还是很简洁的。
instance
关键词
现在定义了一个类型集合Intable
,但是我们还没有声明其中包含的类型。要加入一个类型,只需以instance
关键词引导,然后采用类似的语法具体实现类型集合要求的那些方法即可。
先从简单的入手吧,显然Int
应当是属于Intable
的,其中toInt
函数可以设计为返回该Int
变量本身,也就是toInt = id
。那么我们就可以这么写:
instance Intable Int where
toInt = id
对于[Int]
也是类似的:
instance Intable [Int] where
toInt [] = 0
toInt x : xs = x + toInt xs
当然了,求出一个数组的元素和的操作其实标准库早就给我们封装了,叫作sum
。我们直接写toInt = sum
也是可以的。
注意,由于历史原因,早期的Haskell的不支持这种写法(指对[Int]
等复杂类型使用instance
)。如果你正在用最新版的Haskell而且运行上面的代码时遇到了这样的报错,那么可以在源代码文件的第一行加上编译参数{-# LANGUAGE FlexibleInstances #-}
。限于主题和篇幅因素,关于各种编译参数本文不会深入讲解。
这时候我们发现其实不只是[Int]
,对于任何Intable
的类型组成的数组,我们都可以类似地定义它们的toInt
函数,也就是每个元素的toInt
结果的和。要表达这一点,我们就要在instance
定义语句中也使用Intable p => ...
这样的表达式:
instance (Intable p) => Intable [p] where
toInt = sum . map toInt
当然了,这条instance
定义其实已经包含了前面对[Int]
的定义了,所以实际使用之前要把上文对[Int]
的定义删除。
全类型
上述Num p => ...
这类语法似乎总是默认p
需要在一个类型集合中。有的时候我们可能只是想表示“任意类型这个概念”,比如(const 0)
,它的类型标注应该是直接接受一个任意类型的变量,然后返回0
。那么应该如何表示“任意类型”这个概念呢?
其实这也完全不是问题,因为Haskell语言这套标注系统的设计逻辑就是以全类型为基底的。这就是说,在类型标注中任意小写字母开头的类型名都被当作表示“任意类型”,而=>
前面的内容也就更类似于一个后期加上去的限制。所以表示全类型,直接用一个小写字母开头的类型名就好,例如(const 0)
的类型标注其实就是Num b => a -> b
。
如果从这个角度理解,那么也就立即能知道=>
前面对各个泛类型的限制可以是有很多条的。例如类型标注(Num b, Ord b) => b -> b -> b
。
这种类型标注当然也适用于使用data
自定义类型的场景。这类场景很常见的就是包装器,它们可以包装任何一种类型的变量,然后加上一些额外的功能,便于程序员使用。其中最常见的就是Maybe
,一般来说用于规避错误,比如进行除法的时候,除数是0
会导致表达式没有意义,这个时候就可以用一个Nothing
来标注这个结果没意义,否则用一个Just
构造函数把值包裹进去。除了数之外,还有很多地方可能用到这层含义,比如解决一个问题,有解则返回用Just
包装的问题的解,否则返回Nothing
。所以我们可以这么写:
data Maybe' a = Just' a
| Nothing'
由于实际上标准库已经实现了Maybe
,所以这里每个关键字都加上了'
,以免出现冲突。
之前说过的“安全除法”功能,就可以包装成这样一个函数:
myDivide :: (Integral i) => i -> i -> Maybe' i
myDivide _ 0 = Nothing'
myDivide a b = Just' (a `div` b)
deriving
关键词
有这样一类功能,在定义任何一个类型时都很可能用得到,比如能否应用==
,/=
运算符来判断相等性,能否直接调用print
函数把这个类型的一个变量打印出来,等等。而且关键是它们的实现一般都很雷同,比如实现==
往往就是需要构造函数和其中的参数依次相等,将一个类型打印出来就是打印构造函数和各个参数。这个时候每次都重新实现一遍会显得很冗余,我们就可以用deriving
这个关键字。
deriving
关键字一般接在一个类型定义的后面,后接一些类型集合,表示希望系统自动生成该类型集合的定义。一般来说我们都只使用Show
,Ord
,Eq
,不过其实这个系统也可以写得很深入,可以看Haskell官网的官方介绍(因为那些高级用法使用频率不是很高,而且本人也没怎么用过,所以这里就不写了)。
还是回到之前的例子,我们定义了类型MyIntList
,如果想直接支持对这个类型变量的格式化输出,就可以这么写:
data MyIntList = Cons Int MyIntList
| Empty
deriving Show
x = Empty
y = Cons 1 (Cons 2 Empty)
main = print x >> print y
运行这份源代码,应该会有这样的输出:
Empty
Cons 1 (Cons 2 Empty)
相对地,要是没有写deriving Show
,是无法成功调用print
函数的。
IO
从Hello World那一节开始,我就引出了IO
这个概念:Haskell中main
应当是一个IO *
类型(这里*
表示任意类型,因为实际上IO
是个构造函数,需要接受一个类型才能构造出一个新类型)的变量,而对它的求值就是整个Haskell程序运行的入口。
实际上从语法层面来说IO
也只是一个普通的类型构造器,但是它实在太过重要,不论是输入输出的实现还是整个程序运行的根基(main
)都跟IO
有关,因此还是有必要单独拿出来介绍。
输入和输出
就如同字面意思,IO
表示Input & Output
,也就是输入和输出。输入函数(比如readFile
)和输出函数(比如print
)其实返回的都是一个IO *
类型的变量。
IO *
类型的变量跟其它所有的变量一样,遵从惰性计算原则。这就是说,比如如果只是写x = print 100
,那么实际上只是构建了一个IO ()
类型的变量x
,但是如果没有开始对x
的值的计算,那么程序永远都不会因为这一条语句输出100
。
()
(空变量,空类型)
就像C语言中的void
,有的时候形式上我们希望有一个占位符变量或者类型,但是我们又不希望这个占位符有太多意义,在Haskell中这就是()
。本来()
应该放在类型系统中介绍,但是那时没有介绍类型构造器,解释起来很麻烦。
就像IO ()
这个表达式暗示的那样,()
可以作为一个类型,在这个表达式中,IO
是类型构造器,()
是一个接受的具体类型。这没什么特殊的,就跟Maybe Int
这类表达式一样。
除此之外()
也可以作为一个变量使用,最常见的就是return 0
。跟其它大多数语言不一样,return
在Haskell中并不会直接停止整个函数,而只是一个普通函数名而已。这个函数,是类型集合Monad
要求实现的函数之一(这里不对Monad
深入介绍了,因为属于实际应用的高级内容),而IO
恰好属于这个集合。因此有的时候如果真的需要表示main
中什么也不用做,就可以这么写:main = return ()
。这里()
就是作为一个变量了,传递给了return
这个函数。对于IO
类型来说,return ()
就表示什么也不干。
说到
return
,顺带一提Maybe
也属于Monad
类型集合。所以每当需要写Just x
的时候,也就等价于写return x
——不过为了代码的语义性,一般还是推荐写Just x
。
>>
运算符和do
语句块
上文中不止一次地出现过print x >> print y
这样的表达式。我们知道print x
和print y
都应该返回一个IO ()
类型的变量,那么就很容易看出>>
其实是一个运算符,连接两个IO ()
类型的变量。从效果上来说,它会让左边的变量先求值再让右边的变量求值,也就是达成了“依次输出”的效果。
>>
其实也是类型集合Monad
要求实现的函数(毕竟运算符等同于函数)之一,一般来说要达成依次连接各个变量的效果。当我们要依次输出多条语句的时候,就可以用到这个运算符。
但是很多时候如果连接的内容过多,就会显得有点臃肿,为了解决这个问题Haskell提供了do
引导的语句块语法,它一般来说只用于顺序拼接IO *
变量。虽然Haskell有自动的分块规则,但是为了表达清晰,一般还是会用{}
括起来显式地标出代码块,然后用;
显式地分隔每条语句,例如:
main = do {
print "Hello";
print "World";
return (); -- 当然了,这个return ()可以不写
}
这里其实也可以验证一下,Haskell中return
真的就只是一个普通函数而已,并不会有切断函数运行进程的效果。例如:
main = do {
print "Hello";
print "World";
return ();
print "Other Words";
}
输出:
"Hello"
"World"
"Other Words"
在do
语句块中用<-
获取输入
对于输入函数,其实本质上也是个IO String
类型的值,比如getLine
直接就是个IO String
类型的变量;getFile
接受一个String
类型的变量,然后返回一个IO String
类型的变量。现在我们要做的,就是要从IO String
中取出它包裹的那个String
变量的值。
要实现这个功能,就需要在do
语句块中用<-
进行赋值。这也是>>
运算符做不到的一点。举个例子,读入用户的名字,然后用Hello
给他打个招呼:
main = do {
print "Input Your Name:";
name <- getLine;
print ("Hello, " ++ name ++ "!");
}
输出:
"Input Your Name:"
ThisIsMyName
"Hello, ThisIsMyName!"
看了这整篇笔记,可能有的读者会疑惑为什么Haskell输出字符串一直有个双引号,其实这只是函数选择的问题,如果使用putStrLn
这个函数,就不会有引号了。由于IO
放到了最后讲,为了前面也能用较简单的方式输出各类变量,就一律使用了print
。
一些练习
这里辅以一些实际的算法练习(其实主要来自原课程的教学例题和作业),并给出我个人的参考实现,这些实际的练习也许可以让读者更快上手Haskell编程。
Hailstone序列
Hailstone序列是一种很著名的序列,从一个正整数开始,如果该整数是偶数,则产生它除以2
的结果,否则产生它乘以3
再加1
,重复这个流程直到得到1
,过程中产生的所有数(包括一开始那个数)组成的序列就称为Hailstone序列。
这里希望读入用户输入的一个数,然后输出这个整数的Hailstone序列。
参考代码
nextHailstone x
| even x = x `div` 2
| otherwise = 3 * x + 1
listHailstone 1 = [1]
listHailstone x = x : listHailstone (nextHailstone x)
charToInt x = fromEnum x - fromEnum '0'
stringToInt [] = 0
stringToInt (a : as) = charToInt a * 10 ^ length as + stringToInt as
main = do {
putStrLn "input n:";
line <- getLine;
(print . listHailstone . stringToInt) line;
}
运行结果
input n:
65
[65,196,98,49,148,74,37,112,56,28,14,7,22,11,34,17,52,26,13,40,20,10,5,16,8,4,2,1]
跳跃取值
假设用户向程序输入了一个字符串(假定该字符串长度不小于2
),现在希望能展现该字符串所有可能的能得到长度至少为2
的字符串的跳跃取值方案的列表(跳跃取值就是按一定步长取出字符串中的每个值,例如步长为2
即需包含array[0]
,array[2]
,array[4]
……)。
参考代码
strTail [] _ = []
strTail str 0 = str
strTail (a : as) n = strTail as (n - 1)
dealStr [] _ = []
dealStr str@(a : _) n = a : dealStr (strTail str n) n
main = do {
putStrLn "input:";
line <- getLine;
print (dealStr line `map` [1 .. length line - 1]);
}
运行结果
input:
hello!
["hello!","hlo","hl","ho","h!"]
其它作业题也有一些很有意思的应用,不过文字描述通常都很长。想更深一步的读者可以直接去看原课程的各个作业题。