Haskell 中的 Monad 和 IO 原理探析

语言: CN / TW / HK

本文假设您对函数式编程有一定的理解,因为这是讨论 Monad 和 IO 的理论基础。同时,本文会使用到一些 Haskell 基础语法,比如函数类型定义等。

本文需要您对偏函数和柯里化(currying)有所了解。如果您对此不了解,您可以参考网络上的其它文章。

前言

我们知道,纯函数式编程中的函数必须是无副作用的。也就是说,这些函数 不能拥有状态,也 不能改变外界的状态(比如使用全局变量、在屏幕上输出信息),并且每一个输入必须 唯一对应一个输出

但是,现实中的程序几乎都需要进行一些有副作用的操作。比如,一个程序至少应该能够输出一些信息,或者写入一些文件等,否则这个程序将会毫无意义。因此,即使是函数式编程语言,也有必要引入有副作用的操作。

在 Haskell 中,与副作用有关的两个最重要的概念是 Monad 和 IO。本文将由浅入深地介绍这两个概念。

本文部分译自 Noel Winstanley 的文章 What the hell are Monads?。如果您对本文内容有不同见解,欢迎您对本文提出改进意见。

状态的引入

既然函数必须是无状态的,那么我们不妨先设法引入状态,作为讨论 IO 的一个开始。

假设我们用 Haskell 编写了一个数据库系统,并且提供一个 update 函数用于更新数据库内的记录。update 函数的定义如下:

update :: DB Int -> Bool
复制代码

update 函数向数据库中写入一个 Int 值,并返回一个 Bool 值表示操作是否成功。

但这样的定义是存在问题的,因为 update 函数必然会改变 DB 的状态,而根据函数式编程的基本规则,参数 DB 是不可变的,update 函数无法修改 DB 的状态。

解决方法也很简单,既然 update 会改变 DB 的状态,那么我们不妨让 update 函数除了返回一个 Bool 值外,还返回一个 更新了状态之后的 DB,如下:

update :: DB Int -> DB Bool
复制代码

经过这样的修改,我们实际上就已经能够让 DB 对象拥有 内部状态 了。(外部状态 稍微复杂一些,这个问题将会在之后讨论。)但这样的实现方式会引起一个附加问题:它让 不同操作之间的组合 变得复杂了。假如我们还定义了一个 query 函数,如下:

query :: DB -> DB Int
复制代码

然后,我们的主程序需要做如下的事情:在数据库中查询一个值 xx,然后把 x+1x + 1 写回数据库中。那么,我们的主程序就需要这样编写:

(x, db1) = query db
(ok, db2) = update db1 (x + 1)
复制代码

问题就在于,我们不能直接把 query 的返回值加 11 然后传给 update,因为 query 函数除了返回 xx 之外,还会返回一个额外的东西 DB,它对于程序本身意义不大,但又不得不保留下来并传给之后的操作使用。这会增加程序的复杂度,增大代码阅读和维护的负担。

而 Monad 实际上是一种类型层面的语法糖,用来降低对这种「有状态的纯函数」的串联工作的复杂度。

状态转移操作的串联

注:以下两节的内容较为数学化,如果您有看不懂的地方,可以直接跳到之后的【外部状态的管理】一节,这不影响之后对外部状态和 IO 对象的讨论。

在介绍 Monad 之前,我们不妨先从简单的情况入手。我们想到,update 函数实际上表达了一种 状态转移操作。为了对这样的操作进行建模和简化,我们首先需要将 update 函数进行柯里化,如下:

update :: Int -> DB -> DB Bool
复制代码

加括号之后:

update :: Int -> (DB -> DB Bool)
复制代码

然后我们发现,其中的 (DB -> DB Bool) 可以抽象成一类函数:

type StateTransDB a = DB -> (DB, a)
复制代码

一个 StateTransDB 类型的函数实际上代表了一种 状态转移操作,它接受一个旧状态 DB,返回一个结果 a 以及一个新状态 DB

有了状态转移操作,我们就可以通过一个函数来表示状态转移操作之间的连接:

dbThen :: StateTransDB a -> (a -> StateTransDB b) -> StateTransDB b
复制代码

这个函数的意思是:给定了 前一个操作 StateTransDB a 和一个 中间函数 f :: (a -> StateTransDB b),这个函数 f 接受 前一个操作的返回值 a,返回 第二个操作 StateTransDB b。然后 dbThen 便会返回 这两个操作的串联操作。比如下面的表达式:

queryAndUpdate = query `dbThen` \x update (x + 1)
复制代码

表示的意思就是:将第一个操作 query 和第二个操作 update 串联起来,串联的规则是:对于 query 的结果 xx,调用函数 \x update (x + 1) 执行 update(x+1)update(x + 1)

queryAndUpdate 这一个操作实际上是两个操作的复合操作,我们只需要调用 queryAndUpdate db,就能够一次执行这两个操作,并获得最终的 Bool 结果和更新后的 DB

Monad

以上的类型设计实际上具有通用性。为了方便地表达这一类操作,Haskell 中引入了 Monad 的概念。Monad 本身并不能实现有状态化或有副作用化,它只是一种特殊的类型,能够在需要进行 状态转移操作的串联 时,充当语法糖的作用。

一个简单的 Monad 类型可以按如下方式定义:

class Monad m where
  >>= :: m a -> (a -> m b) -> m b
  >> :: m a -> m b -> m b
  return :: a -> m a

  m >> k = m >>= \_ -> k
复制代码

这个定义十分复杂,尤其其中有 4 个未知类型 a,b,m,k,很难从类型本身推断出其用途。

仍然以上面的数据库系统为例,数据库系统实际上可以表示成一个 Monad StateTransDB。那么,其中最重要的 >>= 操作的类型就是:

>>= :: StateTransDB a -> (a -> StateTransDB b) -> StateTransDB b
复制代码

这实际上就是我们在上面定义的 dbThen 函数。有了 Monad 之后,我们的 queryAndUpdate 函数就可以写成如下形式:

queryAndUpdate = query >>= \x update (x + 1)
复制代码

下面我们就可以继续解释剩下的两种操作了:

  >> :: StateTransDB a -> (StateTransDB b -> StateTransDB b)
  return :: a -> StateTransDB a

  StateTransDB >> k = StateTransDB >>= \_ -> k
复制代码

>> 代表着忽略前一个操作的返回值。比如:

update 42 >> query
复制代码

这个操作会首先向数据库中写入 42,忽略返回的 Bool 值,然后再查询数据库,返回结果。

return 会返回一个 空的状态转移操作,这个操作返回给定的值。比如执行以下代码:

ret42 = return 42
(db1, x) = ret42 db
复制代码

之后,x = 42,并且 db1db 的状态应当是相同的。

要对 StateTransDB 实现 Monad,我们只需要:

instance Monad StateTransDB where
  (>>=) = dbThen
  return a = (\db -> (db, a))
复制代码

外部状态的管理

下面我们讨论对外部状态的管理。在我们的数据库系统的例子中,我们只实现了内部状态。但实际的程序中难免要操作外部状态,比如磁盘上的文件,屏幕上的显示,网络套接字等。我们不妨假设 整个外部状态 保存在一种叫 World 的类型中。

外部状态相对于内部状态来说,具有不可拷贝性和不可回溯性。也就是说,我们不可能把 World 保存到一个常量中,然后用同一个 World 分别调用两个不同的操作,并观察在两条不同的世界线中所发生的事情,这是违背客观规律的。

上文中,我们的「有状态的纯函数」设计显然不能满足这两个限制条件。只要我们能够读取某个操作的返回值,我们就能够对 World 对象进行拷贝,从而破坏这两条限制。

不同的函数式编程语言对这个问题有不同的解决方案,如 Clean 语言通过扩展类型系统来限制 World 对象只能使用一次,称为 uniqueness type。Haskell 中使用了一种叫 IO 的特殊类型来防止用户程序获取和拷贝 World 对象,并且使得 World 对象永远只能单向流动。

IO 对象

在 Haskell 中,一个 IO 对象封装了 某种带有副作用的操作(比如 I/O 操作)。比如 putStrLn "Hello world!"返回 一个 IO 对象,这个 IO 对象 代表着 输出 Hello world! 这个操作。借助 Monad,可以将多个 IO 对象串联成一个 IO 对象,实现 I/O 操作的顺序执行。

以上的描述也许听起来比较玄乎,但我们只要看看 IO 对象的定义就明白了:

type IO a = World -> (World, a)
复制代码

也就是说,每个 IO 对象实际上是一个函数,这个函数的参数是 World 对象,返回值是 a 和一个修改过后的 World 对象。由于函数的参数中有 World,自然就能够做任何有副作用的事情,包括但不限于 I/O。(实际上,对外部状态的管理本质上就是 I/O 操作。)

但是,如何生成我们所需的 IO 对象就成了一个问题。比如,假设我们需要向屏幕上输出一个字符串,那么我们可能需要定义一个双参数函数,如下:

println :: String World -> World ()
复制代码

但这样就不符合 IO 的定义了。这可以借助函数柯里化来解决:

println :: String -> (World -> World ())
复制代码

这个定义经过简化之后就成为了:

println :: String -> IO ()
复制代码

这正是系统函数 putStrLn 的定义。

在 Haskell 中,IO 被定义成一个抽象数据类型,只有系统库和 Monad 才能够构造 IO 对象。这就避免了 World 对象的暴露。

用 Monad 实现 IO 的串联

毫无疑问,Haskell 的标准库中实现了 Monad IOMonad IO 中的 >>= 操作符的定义如下:

>>= :: IO a -> (a -> IO b) -> IO b
复制代码

这与我们上面讨论的状态转移函数十分类似。

为了进一步简化代码,Haskell 中提供了 do 语法。一个经典的例子是:

sayHello = do
  putStrLn "What is your name?"
  name <- getLine
  putStrLn $ "Hello, " ++ name
复制代码

在这里,我们主要关心这个语法糖拆开之后是什么样子。很简单:

sayHello = \
  putStrLn "What is your name?" >> \
  getLine >>= \
  (\name -> putStrLn $ "Hello, " ++ name)
复制代码

其中,数据的流动如下:

  • World_0 → putStrLn "What is your name?" → World_1
  • World_1 → getLine → (World_2, name)
  • IO_3 = putStrLn $ "Hello, " ++ name
  • World_2 → IO_3 → World_3

Bonus:Haskell 中的 FFI

现代的编程语言中难免会使用到其它语言编写的库。比如 Windows API 就是以 C 语言接口的形式提供给程序员的。在一种语言中调用其它语言的技术称为 FFI(foreign function interface)。

但在像 Haskell 这样的函数式编程语言中,调用 FFI 函数存在一个根本性的问题,那就是 FFI 函数几乎一定是有副作用的,这就破坏了函数式编程的基本规则。

在 Haskell 中,可能是为了简洁性和 binding 的便利性,并没有对 FFI 函数作出过多限制。比如以下声明:

foreign import ccall "exp" c_exp :: Double -> Double
复制代码

定义了一个 纯函数 double exp(double); 的 FFI 接口。

如果目标函数是有副作用的,则需要把返回值类型声明成 IO 的形式,比如:

foreign import ccall "my_func" myFunc :: Int -> IO Double
复制代码

如果您希望了解更多有关 Haskell FFI 的内容,请参见 Haskell Wiki