天天看点

Haskell 中的 Monad 和 IO

对于 Haskell 初学者来说,Monad 和 IO 或许是掌握 Haskell 之路上的第一大难关。本文将会以尽量浅显的方式介绍 Monad 和 IO 背后的原理和设计思想,希望能够给 Haskell 初学者们一些思考与启发。

本文假设您对函数式编程有一定的理解,因为这是讨论 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)
           

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

(x, db1) = query db
(ok, db2) = update (db1, (x + 1))
           

问题就在于,我们不能直接把

query

的返回值加 1 1 1 然后传给

update

,因为

query

函数除了返回 x x x 之外,还会返回一个额外的东西

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 :: DB -> (DB, Int)
queryAndUpdate db = query db `dbThen` \x -> update (x + 1)
           

表示的意思就是:将第一个操作

query

和第二个操作

update

串联起来,串联的规则是:对于

query

返回的结果 x x x,调用函数

\x -> update (x + 1)

执行 u p d a t e ( 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 db = query db >>= (\x -> update (x + 1))
           

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

>>

return

)了:

>> :: StateTransDB a -> (StateTransDB b -> StateTransDB b)
  StateTransDB >> k = StateTransDB >>= \_ -> k

  return :: a -> StateTransDB a
           

>>

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

update (db, 42) >> query
           

这个操作会首先向数据库中写入 42,忽略返回的

Bool

值,然后再查询数据库,返回结果。

return

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

ret42 = return 42
(db1, x) = ret42 db
           

之后,

x = 42

,并且

db1

db

的状态应当是相同的。

要对

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 IO

Monad 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。

Bonus:一般化的 Monad

在前文中,我们介绍了用 Monad 实现串联状态转移操作的方法。但 Monad 实际上还有更加广泛的用途。

回忆 Monad 中的

>>=

操作的定义:

>>= :: m a -> (a -> m b) -> m b
           

在上文中,我们把

StateTransDB

代入 m 中,实现了 数据库状态转移操作 的串联。但

StateTransDB

是一个复杂类型(状态转移函数),我们能否把更简单的类型代入 m 中呢?

您或许知道 Haskell 标准库中有一个叫做

Maybe

的类型。它的定义如下:

data Maybe a = Just a | Nothing
           

也就是说,一个

Maybe a

代表着一个 可空的

a

对象,其中可能含有一个

a

的值,也可能什么都没有,类似于 C 语言中的指针,或 Rust 中的

Option<A>

类似于状态转移函数,

Maybe

类型也带来了一个附加问题:如果有某个函数返回

Maybe a

,那么在我们的代码中,就必须通过模式匹配来把这个

Maybe a

展开成

a

,才能继续调用之后的操作。

假定我们又定义了一个不可变的数据库

DB

,它的查询操作名为

doQuery

,定义如下:

doQuery :: (DB, Query) -> Maybe Record
           

假设我们需要进行一个多层的嵌套查询。如果没有语法糖,那么我们的代码可能就需要写成这样:

r = case doQuery (db, q1) of
  Nothing -> Nothing
  Just r1 -> case doQuery (db, (q2 r1)) of
    Nothing -> Nothing
    Just r2 -> case doQuery (db, (q3 r2)) of
      Nothing -> Nothing
      Just r3 -> ...
           

这就造成了与上文一样的问题,并且因为模式匹配的存在,使得代码层数严重加深,造成了程序员很忌讳的「箭头形嵌套」。

解决方法是:我们可以像上文中一样,定义一个

then

函数:

then :: Maybe a -> (a -> Maybe b) -> Maybe b

m `then` f = case m of
  Nothing -> Nothing
  Just a -> f a
           

这个

then

函数的作用就是:给定前一个操作的结果

Maybe a

和一个 输入

a

,输出

Maybe b

的中间函数,输出中间函数的结果

Maybe b

借助

then

函数,我们的多层嵌套查询代码就可以改进成如下这样:

r = doQuery (db, q1) `then` \r1 ->
    doQuery (db, (q2 r1)) `then` \r2 ->
    doQuery (db, (q3 r2)) `then` \r3 -> ...
           

在 Haskell 标准库中,

Maybe

类型也实现了 Monad,而

then

函数类型实际上就是

Monad Maybe

类型中的

>>=

操作:

>>= :: Maybe a -> (a -> Maybe b) -> Maybe b
           

借助 Haskell 的

do

语法,上面的代码还能够继续简化成:

r = do
  r1 <- doQuery (db, q1)
  r2 <- doQuery (db, (q2 r1))
  r3 <- doQuery (db, (q3 r2))
  ...
  return rn
           

注意:上面的代码可能会造成一种「无论前一个操作是否成功,后一个操作都会被执行」的错觉。这种错觉是

Monad Maybe

>>=

操作的定义导致的,因为

>>=

操作在遇到

Nothing

的时候,根本不会调用中间函数,自然也不会触发后续的操作。这种错觉是由命令式编程语言的刻板印象导致的,若要摆脱这样的错觉,除了理解 Monad 和

do

背后的逻辑之外,还需要勤加练习才能熟练掌握。

如果对上面的构造过程加以抽象,我们可以发现:

Maybe

实际上代表着一种类型包装器,它可以为现有的类型增加一些附加的功能(对于状态转移函数来说是状态管理,对于

Maybe

来说是 nullable)。而如果遇到了一些需要 把类型包装器解开,暴露内部的类型 的情况,则 Monad 作为一种编程范式,可以降低代码的复杂度,并且对于一名熟练的程序员来说,还能够降低阅读代码的负担。

如果您对 Monad 在函数式编程语言中的应用比较感兴趣,您还可以参考另一篇文章:15 分钟了解 Monad - Angry Bugs on 知乎

继续阅读