0.前言
阅读《Haskell 趣学指南》有感。
1.基础知识
1.1 基本类型
1.1.1 类型与类型注解
Haskell 实际上是一个静态类型的编译语言,且类型系统采用了 Hindley-Milner 推导算法,这使得 Haskell 可以在一些正常命令式语言所不支持的地方根据上下文推导出所需类型。
而且 Haskell 在部分代码上下文中对缩进敏感。
在 GHC 交互式命令行中,可以使用 :t 查阅一个实体的类型;要标注一个名称的类型,应当使用 :: 做类型注解。
hs
ghci> :t 'a'
'a' :: Char
ghci> :t True
True :: Bool
ghci> :t "HELLO!" -- 和其他语言类似,字符串是一个由 Char 构成的 List
"HELLO!" :: [Char]
ghci> :t (True, 'a')
(True, 'a') :: (Bool, Char)
ghci> :t 4 == 5 -- Haskell 还是一个表达式语言,所以表达式本身也有类型
4 == 5 :: Bool
函数类型与普通类型不同,函数类型是一个通过 -> 连接若干类型的类型。
hs
removeNonUppercase :: [Char] -> [Char]
addThree :: Int -> Int -> Int -> Int
函数类型里只有最后一个类型是返回值类型,其余全是参数类型。
实际上,由于 Haskell 支持自动柯里化,所以也可以理解为
->具有右结合性质:每个->都是一个接收上一个->的类型、并返回一个函数的函数类型。
1.1.2 代数数据类型
大部分我们主动定义的、包含多个字段的类型都属于代数数据类型(ADT),它们通过关键字 data 定义。
hs
data Bool = False | True
-- 与 Bool 同理,作为 ADT 时,可以假想 Int 是这么定义的:
data Int = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | 2 | ... | 2147483647
-- 如果需要包含字段,则可以用两种方式定义
data Shape = Circle Float Float Float | Rectangle Float Float Float Float
定义 ADT 时,内部的具名字段被叫做值构造器,可以用这个名称构造一个该类型对应字段的对象;而且一个值构造器就是一个函数。
hs
ghci> Circle 10 20 5 -- 构造了一个类型 Shape 的 Circle 字段的对象
Circle 10.0 20.0 5.0
ghci> :t Circle
Circle :: Float -> Float -> Float -> Shape
ghci> Rectangle 50 230 60 90
Rectangle 50.0 230.0 60.0 90.0
特别的,当类型只有一个字段的时候,值构造子一般会与类型重名。
hs
data Point = Point Float Float
data Shape = Circle Point Float | Rectangle Point Point
如果需要获取对应字段的内容,就需要额外定义处理这个类型的函数。
hs
data Person = Person String String Int Float String String
firstName :: Person -> String
firstName (Person firstname _ _ _ _ _) = firstname
lastName :: Person -> String
lastName (Person _ lastname _ _ _ _) = lastname
-- 以此类推...
但其实可以用 Record Syntax 定义类型,这样编译器会自动生成这些函数。
hs
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
, height :: Float
, phoneNumber :: String
, flavor :: String
}
1.1.3 List 和 Range
函数式语言里,List、Range 和 Tuple 一般都是基本类型的一部分,也就是说它们受到了语言的特殊对待。
List 可以通过中括号 [] 构造。
hs
ghci> let lostNumbers = [4, 8, 15, 16, 23, 48]
ghci> lostNumbers
[4,8,15,16,23,48]
ghci 里可以用
let关键字定义一个常量。
可以使用 ++ 运算符拼接两个 List,使用 : 运算符在一个 List 的前端插入一个元素。
hs
ghci> [1, 2, 3, 4] ++ [9, 10, 11, 12]
[1,2,3,4,9,10,11,12]
ghci> 'A':" SMALL CAT"
"A SMALL CAT"
由于 Haskell 是惰性求值的,所以完全可以把一个列表 [1, 2, 3, 4] 理解为 1:(2:(3:(4:[]))),每次取出一个元素时会自动求值到对应位置。
Range 则需要通过 List 构造,它需要一个起点、终点和一个步长,用于生成一个惰性的范围;它的语法结构是 [start, next..end],Haskell 会根据 next 和 start 的差感知步长,且 start 与 end 构成闭区间。
如果不写 end,会得到一个无限长的惰性序列。
hs
ghci> [1..20]
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]
ghci> [3, 6..20]
[3,6,9,12,15,18]
List 支持类似列表推导式的列表生成方式,语法结构有些类似于数学上的集合:[x | x <- generator, predicate]。
hs
ghci> [x * 2 | x <- [1..10]]
[2,4,6,8,10,12,14,16,18,20]
ghci> [ c | c <- "Hahaha! Ahahaha!", c `elem` ['A'..'Z']] -- elem 用于检查参数是否在某个范围里
"HA"
1.1.4 Tuple
Tuple 是一个用括号包裹若干个异质元素的类型;对于双元素的 Tuple,可以用 fst 和 snd 获取元组的前后两个元素。
hs
ghci> fst (8, 11)
8
ghci> snd (8, 11)
11
由于括号表达式和单一元素元组在语法结构上是相同的,为了避免歧义,Haskell 不能写出单一元素的元组,形如 (a) 的结构只能得到 a 本身。
类似的是,Rust 同样用圆括号表示元组和括号,但 Rust 允许构造一个单元素元组,只是必须在括号内额外加一个逗号以示区分。
1.2 函数与表达式
作为函数式语言,Haskell 的函数满足左结合性,且具有最高优先级。
函数命名一般遵循小驼峰,而类型采用大驼峰。
hs
ghci> min 9 10
9
ghci> succ 9 + max 5 4 + 1
16
ghci> (succ 9) + (max 5 4) + 1
16
函数默认是前缀式调用,也就是一个函数名紧跟着参数列表,但可以用反引号 ` 包裹函数名实现中缀调用,但这种调用形式只能消耗左右两侧的各一个参数;如果参数不足,函数会因为自动柯里化,而产出一个结果是函数类型的表达式。
这种参数不足的函数调用被称为部分调用。
hs
ghci> 4 `div` 2
2
反单引号还可以用在函数定义时,此时同样可以将前缀调用格式变成中缀式。
运算符实际上也是一个函数,但是运算符天然就是中缀调用,如果需要前缀式调用则需要给运算符加上括号。
Haskell 在自定义运算符的限制上相当宽松,只要运算符由以下几个字符组成且不能以 : 开头即可:
冒号开头的运算符只能作为值构造器使用,也就是用一个冒号开头冒号结尾的运算符作为字段名。
! # $ % & * + . / < = > ? @ \ ^ | - ~。
定义运算符时可以用 infix、infixl 和 infixr 决定运算符的结合性,且可以定义优先级。优先级一共有 10 级,从 0 开始。
hs
-- 左结合,优先级 6
infixl 6 +!
(+!) :: Int -> Int -> Int
x +! y = x * x + y * y
main = print (3 +! 4 * 2)
-- 因为 * 的优先级是 7,+! 是 6
-- 所以这等同于 3 +! (4 * 2) = 3^2 + 8^2 = 9 + 64 = 73
1.2.1 函数实现与模式匹配
定义函数时一般会先声明函数及其函数类型,然后给函数一个实现。
hs
doubleInteger :: Int -> Int
doubleInteger x = x * 2
在函数声明之下的所有函数实现,都属于对满足函数类型的调用的特化匹配(即模式匹配)。
hs
lucky :: Int -> String
lucky 7 = "LUCKY NUMBER SEVEN!" -- 如果调用 lucky 7,会匹配到这行
lucky x = "Sorry, you're out of luck, pal!" -- 否则一概匹配到这行
使用 as 语法可以同时解构参数(模式匹配)并且给被解构的参数本身一个具名引用。
hs
capital :: String -> String
capital "" = "Empty string, whoops!"
capital all@(x:xs) = "The first letter of " ++ all ++ " is " ++ [x]
x:xs可以理解为将x绑定到列表头部,xs则是其余部分;这是一种非常经典的递归模式匹配。除了这种做法,还可以绑定多个首部元素:
x:y:z:xs,但这只能匹配大小大于 3 的 List。
1.2.2 Guard
Guard 是一种简化的模式匹配结构,它可以让我们在一次实现中写出多个模式。
hs
bmiTell :: Float -> Float -> String
bmiTell bmi
| bmi <= 18.5 = "You're underweight, you emo, you!"
| bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
| bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
| otherwise = "You're a whale, congratulations!"
1.2.3 where
where 可以用在函数实现中,用于定义局部的变量或函数。
hs
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
| bmi <= 18.5 = "You're underweight, you emo, you!"
| bmi <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
| bmi <= 30.0 = "You're fat! Lose some weight, fatty!"
| otherwise = "You're a whale, congratulations!"
where bmi = weight / height ^ 2
initials :: String -> String -> String
initials firstname lastname = [f] ++ ". " ++ [l] ++ "."
where (f:_) = firstname -- 可以在 where 里使用模式匹配
(l:_) = lastname
1.2.4 let
let 与 where 类似,但 let 是一个表达式,且可以在任何位置定义局部变量;其语法格式是 let [bindings] in [expressions]。
hs
cylinder :: Float -> Float -> Float
cylinder r h =
let sideArea = 2 * pi * r * h
topArea = pi * r ^2 -- bindings 部分的名称可以在 in 后使用
in sideArea + 2 * topArea
1.2.5 case 表达式
模式匹配实际上就是 case 表达式的语法糖,而 case 表达式的语法结构如下。
txt
case expression of pattern -> result
pattern -> result
pattern -> result
...
用起来一般长这样:
hs
head' xs = case xs of [] -> error "No head for empty lists!"
(x:_) -> x
1.2.6 if 表达式
同样,if 语句也是一个表达式,它必须同时包含 if 和 else 分支。
hs
doubleSmallNumber x = (if x > 100 then x else x * 2) + 1
如果确实不需要 else 分支,可以使用 when 函数;但 when 函数必须接收一种特殊的类型:monad。
1.3 类型系统
1.3.1 类型变量
类型变量一般只出现在函数类型,以及类型定义中。
如果希望函数能接受任意类型的参数,则需要在函数类型里加上一个与任何类型无关的类型参数。
hs
show' :: a -> String -- 一般在函数名尾部加一个单引号表示当前函数与另一个同名函数实现有所不同
show' x = show x -- 通过函数类型可以推断:x 是一个类型 a 的对象
如果需要定义一个"容器"类型,就需要在 ADT 的类型名后跟随一个类型参数。
hs
data Maybe a = Nothing | Just a -- 泛型参数 a 的名字通常从 a 开始取
1.3.2 Typeclass 和派生
Typeclass 定义了某些类型必须支持哪些行为。
例如 Eq Typeclass 允许派生自该类型类进行等值比较,Ord 则允许构造全序关系,Show 能将一个类型对象转为字符串,Read 能从字符串中解析得到一个对象,等等。
一个 Typeclass 会要求派生自它的类型实现某些方法,例如标准库的 Eq:
hs
class Eq a where -- 同理,a 是一个类型参数
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
x == y = not (x /= y) -- 交互递归表示只需要实现其中一个即可
x /= y = not (x == y)
如果需要让一个类型具有某个 typeclass 所指定的行为,就需要指定这个类型是该 typeclass 的一个实例。
hs
data TrafficLight = Red | Yellow | Green
instance Eq TrafficLight where
Red == Red = True
Green == Green = True
Yellow == Yellow = True
_ == _ = False
显然不是所有 typeclass 都值得我们手动指定的,因此 Haskell 允许我们用 deriving 关键字自动派生。
hs
data Person = Person { firstName :: String
, lastName :: String
, age :: Int
} deriving (Eq, Show, Read)
-- 现在可以对 Person 类型对象判等,并且该类型可以被转化为字符串,同时可以从字符串中解析得到 Person 类型
1.3.3 类型约束
没有约束的泛型就是残次品,所以当我们需要约束一个类型必须满足某些性质的时候,我们要求它"必须派生自某个 typeclass"。
hs
show' :: (Show a) => a -> String -- a 必须是一个派生自 Show 的类型
data (Ord) k => Map k v = ... -- k 必须是一个具有全序关系的类型
1.3.4 类型别名
Haskell 有两种方式定义一个类型的别名,分别是 type 和 newtype。前者定义一个严格等价于原类型的同义词,而后者会构造一个与原类型完全无关的新类型,但 Haskell 依然按照原类型的方式处理它(实际上没有产生新类型)。
hs
type String = [Char] -- 类型别名只能产生一个同义名
newtype ZipList a = ZipList { getZipList :: [a] } deriving (Eq, Show)
-- 但 newtype 可以让我们假装拥有了一个新的类型,而且还支持派生 typeclass
newtype 产生的类型必须有且只有一个值构造子(即只存在一个字段)。
1.3.5 类型种类
所有不需要额外类型参数的类型都称作具体类型,记作 *;在 ghc 里可以使用 :k 查询一个类型的种类(kind)。
hs
ghci> :k Int
Int :: *
如果一个类型需要一个具体类型以生成一个新的具体类型,那么这个类型就是一个类型构造子,记作 * -> *;例如 Maybe。
hs
ghci> :k Maybe
Maybe :: * -> *
以此类推,如果一个类型接收一个类型参数并生成一个类型构造子,那么这个类型就是构造类型构造子的构造子,它的 kind 可以是 ∗ −> ( ∗ −> ∗ );因为 -> 满足右结合,所以也可以直接写作 * -> * -> *。
这种 kind 的类型也称作高阶类型(Higher Kinded Type,HKT)。实际上,具有部分参数(柯里化)的 Either 就是一个类型构造子,因此 Either 本身就是一个高阶类型。
之所以强调"可以是",是因为我们并不限定这个类型参数本身的 kind,因此这个类型参数同样可以是一个 HKT。
有趣的是,在 Haskell 里,只要一个具体类型的类型参数大于等于 2,就有可能出现 HKT。
hs
tofu :: j a -> Int
以上面这个函数为例,由于在 -> 左右侧的类型必须是具体类型,因此 j a 和 Int(显然)的 kind 均为 *,而且很明显 j 必须至少 为 * -> *。
之所以说"至少",是因为对于 a 来说,我们可以构造一个类型构造子,这个构造子接受一个类型参数但不使用(所谓幻影类型),因此这个类型参数的 kind 在原则上是任意的,由此可以得出这个构造子同样能够成为 HKT。
这种类型可以是这样的:
hs
data Ghost g = Ghost
但是一般实践中(也即不开启语言扩展的情况下)我们永远优先假设 a 是具体类型,因为这是一个车库喷火龙问题。
这里
a实际上是 C++ 追求了很久的 universal template parameter。
1.4 模块
Haskell 使用 import 引入其他模块的功能;在 ghci 里可以使用 :m 加载某个模块。
若需要加载多个模块,ghci 里可以直接列出:
hs
ghci> :m Data.List Data.Map Data.Set
在文件里则需要逐行给出。此外,可以要求仅加载模块内的某些实体,或者单独去掉。
hs
import Data.List (nub, sort) -- 仅加载 nub 和 sort
import Data.List hiding (nub) -- 单独去掉 nub
import qualified Data.Map -- 要求调用 Data.Map 的函数时必须引用模块名,即 Data.Map.filter
import qualified Data.Map as M -- 给模块名 Data.Map 一个新名字 M
一个文件就可以是一个模块,如果需要导出其中的功能,就需要在文件首部写出要导出的名称实体。
hs
module Geometry
( sphereVolume
, sphereArea
, cubeVolume
, cubeArea
, cuboidArea
, cuboidVolume
) where
-- 然后在下面补充实现
也可以将一个模块拆分为多个文件构成的子模块。
hs
module Geometry.Sphere
( volume
, area
) where
-- 例如在 Geometry 文件夹下的 Sphere.hs 文件里可以这么写
-- ...
2.函数式编程
2.1 高阶函数与 lambda
高阶函数即接收函数的函数;由于 Haskell 支持自动柯里化,因此所有需要两个参数以上的函数都可以认为(部分调用的返回值)是一个高阶函数。
而标准库提供的 map 是高阶函数的核心,它能将一个函数作用在不同的对象上。
不是什么时候我们手上都有一个能够传递的函数,所以如果需要临时定义一个函数,就可以使用 lambda。Haskell 的 lambda 非常简单:由一个反斜杠 \ 开头、用空格分隔多个参数、-> 开启函数体。
hs
addOne :: Int -> Int
addOne = \x -> x + 1
-- 这里有一个有意思的地方:虽然我们给 addOne 的其中一个模式是无参函数
-- 但由于 addOne 返回的是一个接收一个参数的函数本身,所以 addOne 实际上表现得也像一个具有一个参数的函数
-- 利用这一点我们可以在接下来的章节里实现一种名为 point-free style 的风格
2.2 函数调用符和函数组合
由于函数具有急迫的左结合性,因此在某些复杂函数调用场景下我们不得不写出多个嵌套丑陋的括号。
为了减少这种痛苦,Haskell 提供了一个右结合 、优先级为 0 的函数调用符 $。
hs
($) :: (a -> b) -> a -> b
f $ x = f x -- 所谓恒等变换
这一运算符的好处是,可以将某些嵌套括号变成一系列函数调用:
hs
sum (map sqrt [1..130]) -- 从这样
sum $ map sqrt [1..130] -- 变成这样
因为 $ 的结合性与函数调用本身相反,所以 sum 只能吃下 $ 往右的整个函数调用;又因为 $ 的优先级最低,所以 map 优先与右侧的 sqrt [1..130] 结合成一个结果,因此执行结果与原来相同。
此外,范畴论里绕不开的一点是函数组合,即 (f∘g)(x)=f(g(x))(f \circ g)(x) = f(g(x))(f∘g)(x)=f(g(x));作为函数式语言,Haskell 当然有必要支持这一点,也即 . 函数。
hs
(.) :: (b -> c) -> (a -> b) -> a -> c
f . g = \x -> f (g x)
阅读时可以将
.理解为:"把右侧函数的结果传递给左侧函数"。
函数组合同样可以用来消除括号,不过与 $ 不同,这里消除的是函数嵌套调用的括号:
hs
fn x = ceiling (negate (tan (cos (max 50 x)))) -- 从这样
fn = ceiling . negate . tan . cos . max 50 -- 变成这样
-- 下面那个恰好是一个 point-free style,也即不显式指定所需的函数参数,而由自动柯里化完成
2.3 Functor 函子
函子指的是一类支持 map 方法的容器,它的 map 方法能够将一个容器内的值映射为新的值,且保持容器结构不变。
显然,一个能被标注为函子的类型必须首先来自于一个类型构造子。
实际上,将范畴视作是更高层范畴的对象时,函子就是两个范畴之间的态射;函子 F 能将范畴 C 的对象以及态射映射为范畴 D 中的对象及态射,并保持结构不变。
Haskell 提供了一个 Functor typeclass 以定义该代数结构。
hs
class Functor f where
fmap :: (a -> b) -> f a -> f b
比较特殊的是函数类型也可以是一个函子,此时作用在函数类型上的 map 就是函数组合:
hs
instance Functor ((->) r) where -- 运算符前缀调用必须用括号包裹
fmap f g = (\x -> f (g x)) -- 其实可以直接写成 fmap = (.)
显然 ((->) r) 也是一个类型构造子。
尽管 Haskell 没有要求(实际上也无法追踪),但函子必须满足两个性质:
- 恒等律,对一个函子调用恒等函数
id,也就是fmap id,必须等于什么都没做 - 结合律,多个
fmap的结合顺序不应该改变结果
2.3.1 Applicative Functor
普通函子在容器内存储的是值、传递的是函数时工作得非常好,但如果一个容器内存储了一个函数本身,那么就没法用 fmap 把这个被裹在容器里的函数作用在另一个函子上了。
如果我们把一个部分调用的函数
fmap到函子上,而且这个函数在接受了函子内的值之后仍然保持部分调用的形态,那么我们就会得到一个被放在函子里的函数。
这个时候就涉及到了 Applicative functor;当然 applicative functor 首先得是一个 functor。
hs
class (Functor f) => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b -- applicative functor 的 fmap 函数,可以理解为 apply
从 Applicative 的定义可以知道,对于裹在函子里的函数,现在我们可以这样调用:
hs
ghci> Just (+3) <*> Just 9
Just 12
ghci> pure (+3) <*> Just 10 -- pure 和调用 Just 是等价的
Just 13
ghci> Just (++ "hahah") <*> Nothing
Nothing
ghci> Nothing <*> Just "woot"
Nothing
为了和 <*> 保持对称,Haskell 为普通函子的 fmap 也提供了一个运算符:
hs
(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x
在函子中,List 是一个特殊的存在,对它做 <*> 和 <$> 会在两侧 List 上发生笛卡尔积:
hs
ghci> [(*0), (+100), (^2)] <*> [1,2,3]
[0,0,0,101,102,103,1,4,9]
这被称为非确定性计算。
之所以会这样,是因为 List 这么实现的 Applicative:
hs
instance Applicative [] where
pure x = [x]
fs <*> xs = [f x | f <- fs, x <- xs] -- 显然这里是一个双重嵌套循环
如果需要严格一一对应地应用函数,需要使用 ZipList。
2.4 Monoid 幺半群
半群是一个运算满足封闭性与结合性、且有一个二元运算的代数结构,例如全体正整数与加法构成一个加法半群。
幺半群则在半群基础上额外要求元素具有一个单位元,例如 List 和 ++ 以及 [] 构成一个 List 幺半群。
实际上,我们遇到的绝大多数数据类型都和某个合法的二元运算构成一个幺半群。
如果在幺半群的基础上,元素还具有逆元,那么我们就得到了一个群。
在范畴论里,幺半群是一个只有一个对象的范畴。
范畴论的"对象"指的是参与运算(范畴论里叫态射)的元素种类,只有一个对象表示这个代数结构的所有元素都能和所有元素(笛卡尔积)参与同一个二元运算,反之则表示有些元素不能和其他元素进行运算。
例如两个行列不相等的矩阵无法做乘法,因此矩阵与乘法组成的范畴具有无限多个对象。
Haskell 是这样定义幺半群的:
hs
class Monoid m where
mempty :: m -- 单位元
mappend :: m -> m -> m
mconcat :: [m] -> m -- 用于支持对列表上元素 m 的折叠的函数
mconcat = foldr mappend mempty
因为幺半群天然支持一个二元运算 mappend,因而如果有若干个幺半群元素被放在一个 List 当中,我们可以自然而然地用某种方式折叠它并得到唯一结果;但对于某些特殊的类型,折叠操作可以有与二元运算 mappend 完全不同且效率更高的实现,因此 Haskell 允许类型自己实现 mconcat
2.4.1 一些幺半群
显然,List 与拼接 ++ 构成一个幺半群。
hs
instance Monoid [a] where
mempty = []
mappend = (++)
Ordering 也是一个幺半群,比较有趣的是,在该幺半群上的二元运算是有侧重性的:
hs
instance Monoid Ordering where
mempty = EQ
LT `mappend` _ = LT
GT `mappend` _ = GT
EQ `mappend` y = y
这意味着我们可以利用这个选择性提前短路逻辑:
hs
import Data.Monoid
lengthCompare :: String -> String -> Ordering
lengthCompare x y = (length x `compare` length y) `mappend`
(x `compare` y)
-- 如果第一个括号的结果不是 EQ 则提前返回,因为惰性求值特性,此时不会对第二个括号进行求值
这种短路逻辑与 Maybe 的二元操作实现是相同的。
2.5 Monad 单子
Monad 不过就是一个自函子范畴上的幺半群。
Monad 首先是一个幺半群,然后它的单位元和二元操作必须满足两种性质:
- 单位元必须能把任意类型 T 包装成 Monad(也就是说 Monad 本身必须是一个 Functor)
- 二元操作还得是一个固定形式的函数
一般来说,在范畴论里的单位元并不是一个预先存在的对象,实际上,它是一个将 1 映射为幺半群中元素的函数(态射),所以定义中的单位元必须也是一个函数:
hs
unit :: t -> M t -- 任意类型 t 充当了这个 1
bind :: M a -> (a -> M b) -> M b
所谓的"自函子"范畴,指的是该函子连接的两个范畴均属于同一个范畴。
实际上这表示,在整个类型系统这个范畴(Hask 范畴)下,态射总是将类型范畴内的对象映射到类型范畴中(返回值必须是一个类型,而不是什么莫名其妙的东西),因此它具有一个指向自身范畴的态射,故而叫做自函子;至于幺半群就不用解释了,因此 monad 的确是一个自函子范畴上的幺半群。
当然了,Haskell 的类型系统内的所有操作都只能产生一个类型系统内的类型,因此可以说里面的所有函子都是一个自函子。
在 Haskell 里,我们这样定义一个 monad:
hs
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
x >> y = x >>= \_ -> y
-- >> 函数的意思是:接收一个 monad,对它求值并丢弃,然后返回另一个 monad
-- >> 与 >>= 共同构成了 do 块的底层操作,即对 do 解糖后可以得到一系列 >> 与 >>= 的组合
fail :: String -> m a
fail msg = error msg
因为历史问题,虽然一个
Monad首先必须得是Functor和Applicative,但 Haskell 没有作出限制。
我们经常会需要同时处理多个 monad,为了将它们串联起来求值,并且减少一些 >>= 和显式 lambda,Haskell 提供了一个语法糖 do:
hs
foo :: Maybe String
foo = do
x <- Just 3 -- 从这个 Monad 里取出值,这一语法只能出现在 do 块中
y <- Just "!" -- 此外,Haskell 的 do 块对缩进敏感
Just (show x ++ y)
同样的,do 构成一个表达式,它的类型以及值是最后一个 Monad 的操作结果。
List 同样也能作为一个 monad,但为了满足"自函子"性质,它的 bind 操作有些特殊,这被称为 Map-then-Flatten 操作。
hs
instance Monad [] where
return x = [x]
xs >>= f = concat (map f xs)
-- 对列表内每个元素均调用 f,且 f 必须返回一个列表
-- 然后会把产生的所有列表拼接到一起,"拍扁"之后作为一个新的扁平列表返回
fail _ = []
如果不做 flatten 操作,那么 List 的 >>= 返回后就不再是 [x] 结构,而是类似于 [[x], [y]] 的形态,这会违反自函子约束。
同样的,monad 也必须满足一些基本运算规律:
- 左单位元,即
return x >>= f的结果应该等于f x - 右单位元,即
m >>= return的结果应该等于m,也就是什么都不做 - 结合律,组合的顺序不影响求值结果
Haskell 也不会跟踪一个派生了 Monad 的类型是否遵循这些性质,因此这需要由用户自己确保。
2.6 输入输出
2.6.1 异常
Haskell 同样使用异常表达一些不可恢复错误,例如除零。
所谓的异常 exception 实际上就是一个普通的 ADT,但是它派生自特定的 typeclass。
hs
import Control.Exception
import Data.Typeable -- 用于 RTTI
-- 必须派生 Show 和 Typeable
data MyProjectException
= NetworkError String
| InvalidConfig Int
deriving (Show, Typeable)
-- 和其他语言同理,Haskell 里也有一个表示所有异常类型的 exception typeclass
instance Exception MyProjectException
-- 在不开启语言扩展的情况下 Exception 不是可以直接 deriving 的 typeclass,所以必须手动写一个实例
需要抛出异常时,在 IO 过程中可以使用 throwIO,但在纯代码里只能使用 throw,而且不允许在纯代码中捕获异常。
因为 Haskell 的惰性求值特性,因此一个简单的 throw 在实际求值之前并不会导致异常抛出,所以使用它并不一定能够保证在正确的地方抛出对应的异常。
但 IO action 的特性确保了调用 throwIO 时一定可以对它求值,从而确保了异常按照我们期望的方式抛出。
而且
throwIO也能阻止编译器按照我们不希望的方式优化代码并重排执行顺序。
比较有意思的是,尽管异常抛出会打断执行流,导致程序从抛出点脱离并进入栈回溯,但实际上 throwIO 和 throw 作为一个函数,它们依然具有一个看起来无害的函数类型。
hs
throw :: Exception e => e -> a
throwIO :: Exception e => e -> IO a
这种中断控制流的函数一般被认为返回一个特殊的类型:底类型(Bottom Type)。
一般的类型体系中,会存在两个特殊的类型:顶类型(Top Type)和底类型。顶类型是所有类型的基类,也就是 Python、Java 里的 Object(当然 Java 比较蹩脚,基础数据类型不继承这个顶类型);而底类型则要求所有类型都是它的基类,例如 Python 的 Never 和 Rust 的 !。Haskell 里也有个 undefined :: a 的值表示这个底类型对象。
所谓"所有类型都是它的基类",其实可以理解为底类型可以经由类型协变自动转换为任何类型。因此 Haskell 将 throw 的返回值定义为一个泛型参数 a,使得 throw 的返回值的确是一个底类型。
当调用一段可能抛出异常的代码时,如果不希望程序崩溃,就需要使用 try 或 catch 函数。
其中 catch 的类型是 IO a -> (e -> IO a) -> IO a,即接收一个 IO 和一个错误 handler,如果前者抛出了异常则调用传入的 handler,否则正常返回。
而 try 的类型是 IO a -> IO ( Either e a ),也就是把前者可能抛出的异常塞进一个 Either 中。
hs
import Control.Exception
-- 使用 try
main = do
result <- try (evaluate (5 `div` 0)) :: IO ( Either ArithException Int )
case result of
Left ex -> putStrLn $ "捕获到数学错误: " ++ show ex
Right val -> print val
-- 使用 catch
main = do
-- 如果这里出错了,就执行后面的 Lambda 表达式
catch (readFile "test.txt") $ \ex -> do
let err = ex :: IOException
putStrLn $ "读取失败了,原因是: " ++ show err
2.6.2 IO action
Haskell 的入口是一个名为 main 的函数,它必须返回一个 IO () 类型,且该类型是一个 monad。
IO 实际上是大部分与外部世界交互的函数会返回的一个类型,例如 putStrLn 和 getLine。
在 main 中我们往往使用 do 块串联所有的 IO monad。
hs
main = do
c <- getChar
if c /= ' '
then do
putChar c
main
else return ()
除了 IO 以外,所有需要产生副作用的函数都会与 monad 有关;这是因为一串 monad 求值需要自左向右进行,这严格规定了求值顺序,使得副作用可以按照顺序发生。
当然,如果副作用产生了一次不可恢复错误,那么 Haskell 会抛出异常打断流程。
另外,一些有状态的操作被包装为 monad 后,我们可以用一个 do 串联多个状态变换操作,因此能够写出更简洁的代码。