Haskell之纯粹, 非纯粹以及Monad

最近看Haskell, 感觉两个重点的基础性的东西必须要掌握一个是类型系统一个是Monad, 这是Haskell区别于其他语言的两个最大特征. 这次讨论一下Monad.

Monad是个大坑, 像进了rabbit hole一样, 看的东西越来越多, 而且似乎看得越多越糊涂, 这一阶段最累. 随着越深入, 碰到的其他概念也会越来越多, 然后会形成一个大概的印象.

前前后后看了一堆Monad教程和视频, 这篇文章也断断续续写了很久.

误解一: Haskell是纯函数式语言

这个说法本身就有问题, 字面上看好像让人觉得Haskell中只有纯函数, 这个名字是非常具有误导性的. 所谓纯函数是具备特定特征的函数, 任何语言都支持纯函数. 所有的纯函数的纯粹度都是一样的, 没有哪个纯函数比另一个纯函数更加纯粹.

纯函数只是我们以特定方式创造的函数. Haskell和所有其他编程语言一样有纯函数也有非纯函数, Haskell的特点在于他解决了一个历史性的的难题: 在编译阶段区分纯函数和非纯函数, 并确保非纯函数不会"感染"纯函数, 而其他语言是不具备这个功能的. 所用的方法就是Monad, Monad并不是唯一的办法, 只是目前为止最有效的办法.

例如readFile, 这个函数在任何编程语言当中实现都必然是一个impure的函数, 输入同一个文件名不可能保证返回的结果都是一样的. Haskell也不例外, 在普通语言中这样的函数和所有其他函数是不加区分的, 这样的语言中拿到一个函数我们无法知道他是pure还是impure, 没有任何手段让我们可以确定他是否pure, 即使文档也不可靠, 因为即使是这个函数的作者也不能保证它是pure的, 唯一的方法是考察这个函数调用的所有函数, 每一句代码以及这些函数各自的所有调用和所有代码, 如果真的没有任何副作用, 才可以确定, 但是在Haskell当中我们总是会知道, 因为编译器知道, 如果编译器认定一个函数是pure的, 那么他必然是, 编译器不会允许impure函数去感染一个pure函数. 这是Haskell的例外之处, 也就是pure functional的真正的意思.

这种区分的根本意义在于控制复杂度. 我们知道纯函数有很多非常好的性质, 其中最重要的一点是可组合性(composability). 关于这一点, 推荐Brain Beckman的"Don't fear Monad"视频. 两个纯函数组合的结果会产生一个新的纯函数, 然后可以继续组合, 而一个impure函数和一个纯函数组合的结果是一个新的impure函数, 这就是之前提到的impure函数具有感染性. impure函数的行为是不可预测的, 不安全的, 任何一个impure函数都是一个潜在的麻烦制造者, 一个定时炸弹. 在传统的编程语言中没有任何机制来保护程序员免于impure函数的危险. 最后的结果是系统复杂度会像杂草一样不受控制的疯长, 变成一团Spagett, 让维护和debug变成一场噩梦.

这种pure和impure的分离是通过IO Monad实现的, 当然理解IO Monad, 必须先理解Monad, IO Monad只是Monad这种设计模式的一种应用. Monad不是唯一一种能够达到我们的目的方式, 只是其他的方式更加丑陋而已. 目前我们应该先记住IO Monad所要达成的目的, 然后在仔细考察他是如何做到的.

误解二: 用Category Theory去解释Monad

你不需要了解CT的任何知识才能理解Monad, 你甚至不需要理会Monad这个单词本身, 换成abc或者"&^%*"都没关系.

误解三: 用一两个具体的例子来说明Monad

Monad是一个设计模式, 并不是某个具体的实现, 好比面向接口编程, 这是一种模式, 我们可以用这个思想来实现各种各样的功能, 但不能说某个具体的方案本身是面向接口. Maybe 和 IO action都是用了Monad模式 , 但达到的目的则不同.

所以理解Monad的比较好的方法是一个从抽象到具体, 然后再从具体返回抽象的过程.

Monad的三个要素

M type constructor

类比, box, 盒子, 容器, container, context, 上下文.

IO 操作evaluate的时候返回的是一个box, 而不是直接的value, 所以你不能直接使用这个value这样就避免了被impure函数感染, 因为IO 操作对同样的输入是可以产生不同输出的, 这个输出我们不可以直接变为pure函数的输入. Haskell的做法是先从这个盒子中把值取出来, 然后交给pure函数去处理.这样从pure函数的角度看, 他完全不知道这个value是怎么来的, 这个值是通过IO操作产生的还是普通的常量对他来说都不重要, 重要的是, 他只看到value, 不必关心value的产生方式, 当pure函数以这种方式来使用这个值的时候其纯粹性不会受到影响.

在前一篇文章中我们已经看到了他的具体表现递归遍历目录之Haskell, Java, Clojure对比

. 其中的一段代码

 
names <- getDirectoryContents topdir
let properNames = filter (`notElem` [".", ".."]) names
 

这里names包含的是value, 而其产生的方式, 是由一个impure的getDirectoryContents函数得到的, 第二行, 使用这个value的时候是以纯函数的方式进行的. 在Haskell当中将上面的两行代码混在一起的行为在编译器级别就禁止了.

可见这个盒子或者容器或者上下文的存在可以影响对value进行计算的方式, 对于IO Monad, 一切的IO操作必须返回IO a类型或者IO (), 即不允许直接返回value, 盒子的存在使得IO操作即impure函数不能直接和纯函数组合. 当然不同的上下文对计算的影响的方式不同, 于是有了各种不同的Monad. 例如Maybe Monad, 其上下文中隐含着可能的failure, 如果上下文表示的是Nothing, 那么任何计算的结果也是Nothing, 造成一种短路效应. 假如没有这种上下文, 那么在计算过程中我必须手动写if语句来判断null的情况, 如果是null, 当然不能对其做任何计算, 如果不是null则按照正常的流程计算.

bind

这个名字很奇怪, 虽然不能说用的不恰当, 但是这个名字对于理解Monad几乎没什么用. 在Monad中, 我们知道value是和上下文关联在一起的, 或者更加形象的说法, value是放在某种容器当中的, 但是我们仍然要进行计算, 即要把value拿出来, 输入到函数当中, 返回新的value, 在Monad计算当中, 容器或者上下文必须一级一级的传递下去, 伴随着整个计算序列, 因此计算完成之后得到的value仍然要装回容器当中, 而不能是裸露的value.

bind正是为完成这个任务而设置的.

 
m a -> ( a -> m b) -> m b
 

bind按其字面意思就是将一个包含a类型的盒子 m a 和一个接受a类型的value并返回新的包含b类型的盒子的函数bind到一起. bind的结果同样是包含value的容器, 显然我们可以继续将其和另外的函数再次绑定, 然后按照这样的序列进行下去.

return

同样这个名字也是奇怪的, 不仅如此, return比bind更具有误导性, return和返回值没有任何关系. return只是将value装到盒子中.

 
return :: a -> m a
 

Monad三定律

 
1. return a >>= k  ==  k a
2. m >>= return  ==  m
3. m >>= (\x -> k x >>= h)  ==  (m >>= k) >>= h
 

IO Monad是one way Monad

所谓one way Monad就是一旦value进入这种Monad就永远不可能逃出来. 具体表现是任何一个函数, 其中如果使用了IO Monad中的value, 那么这个函数的返回值必然也是IO Monad. 即他不会是一个纯函数. 这样纯函数和impure函数就被严格区分开来了.

IO Monad和副作用

纯函数和副作用有着天然的不相容性. 而Haskell中他们却优雅的结合在一起, 这一切得以实现的基础就是IO Monad.

IO 是functor

IO是Monad, 而Monad也是functor. 因此IO也是functor, 因此IO也是支持fmap的.

 
 
let a = getLine
let b = fmap ((++) "adasd") a
Prelude> :t b
b :: IO [Char]
c <- b
Prelude> :t c
c :: [Char]
 

IO的源代码

目前看到的几种IO的实现

 
-- 1
data IO a :: * -> *
-- 2
type IO = World -> World
-- 3
newtype IO a = IO (State# RealWorld -> (# State# RealWorld, a #))
 

第三个是可以直接在GHC的源码中找到的, 第二种是某些作者用来说明IO的原理的. 或者可以直接在GHCI中输入

 
Prelude> :info IO
newtype IO a
  = GHC.Types.IO (GHC.Prim.State# GHC.Prim.RealWorld
                  -> (# GHC.Prim.State# GHC.Prim.RealWorld, a #))
      -- Defined in ‘GHC.Types’
instance Monad IO -- Defined in ‘GHC.Base’
instance Functor IO -- Defined in ‘GHC.Base’
 

Monad和AOP

广义的讲, monad是这样一种模式: 一系列的连续的计算, 当然这个计算的主角是value, 值,但是monad在这种连续的计算中的每一步都附带了一个context, 而这个context是可以影响计算的执行方式的.如果没有monad, 那么value在这种连续的, 系列的计算过程中是完全裸露的, 在这种情况下, 如果要在这个过程中对计算方式做任何的改变, 我们必须明确的将其表示出来, 我们的核心关注点(core concern)是关于value的计算本身, 而某种需要特殊关注的可能是一种横切关注点, 即不是属于代码的核心逻辑, 举例来说, 记录日志的logging代码, 执行事务的提交回滚操作, 输入参数的null检查. 这些边边角角的东西无论是在写代码的时候还是读代码的时候都造成了混乱(clutter).主要的核心逻辑的和边边角角的东西混在一起了. 从这个角度看, Monad和AOP有相似的地方.

关于容器的类比

在介绍Monad的时候, 用容器来类比是非常常见的手法. 但是这个比喻其实并不恰当, 如果先入为主的用这个概念来理解Monad, 那么在某些时候会遇到困难.

任何一种将value包装起来, 而使其变成一种和原有的value区别开来的手段都可以被当做容器. 最典型的是类型. 例如假设有一块4个字节的内存, 当这块数据的类型被解释为Int的时候, 我们可以对这块内存的值做加减乘除的运算. 然而对于同样的一块内存我们可以做不同的解释, 例如Just 4, 现在这块内存的类型是Maybe Int. 虽然还是这些字节, 但是当我们用Maybe Int来标记他的时候, 他变成了完全不同的东西. 不再是一个可以做加减乘除的整数, 他甚至不再是一个数, 那么他是什么呢? 任何我们对Maybe Int的定义都是, 可见数据是什么完全依赖于我们的解释. Haskell的类型系统是非常严格的, 你无法欺骗他. 对任何具备特定类型数据, 你所能做的操作是既定的, 不存在越轨的行为. 在C/C++这样的语言中你可以欺骗你的类型系统, 即type cast机制.

那么现在Maybe Int可以被当做一个容器. 理论上我们可以用包含单个元素的数组来当做容器, 假设我们可以定义这样一个类型

 
typedef container int [1]
 

我们的类型系统理解container这个类型的语义, 不会允许对这种类型的对象作出范围之外的操作, 那么这个类型也可以扮演容器的角色. 下面两个对象是不同的.

 
int a = 3;
container b = container a;
 

因此实现Monad的具体方式是非常多的. Javascript 中没有静态类型, 怎么在里面实现Monad? 我们可以用函数当做容器

 
var container = function (value) {
  return function () {return value;};
};
 
var b = container(3);
 

b是什么? 一个函数, 这个函数返回其中所包含的值. 函数由代码组成, 怎么能包含值呢? 知道closure原理的人一眼就可以看出来, 这是利用了闭包的机制. 从另一个角度看, 我们可以将匿名函数当成一个类型

 
function () {return value;}
 

而这个类型的实例可以是这样

 
function () {return 3;}
function () {return "hello,world";}
function () {return 3.14;}
 

如果我们将这个类型标记为T, 那么我们可以在基本类型的基础上构造新的类型, 上面三个实例的类型是T Int, T String, T Double. 当我们将value包装起来时候, 他变成了完全不同的东西, 你不可能用两个T Int的实例做算术运算. 下面的写法语义上是无意义的

 
var a = function () {return 3;};
var b = function () {return 4;};
var c = a + b;
 

如果在控制台中运行上面的代码, c会是一个字符串:

 
"function () {return 3;}function () {return 4;}"
 

是不是莫名其妙.

在Java中我们可以用类来模拟容器

 
class Container {
    private Int a ;
}
 

IO Monad不是容器

上面我们说了容器可以作为一种理解方式来类比Monad. 但是并不是没有缺陷. Monad是一个抽象层次非常高的概念, 一旦用容器来类比, 立刻就降低了抽象层次, 反而离题更远.

像List和Maybe这样的Monad, 用容器去解释是合适的, 而IO Monad则不适合. 更常用的是action, 例如getLine函数的返回类型是IO String, 我们不能把他想象成一个容器包含着一个值, 因为在执行这个action之前, 里面都是没有值的, 只有在执行后, 当用户输入一个字符串, 这个字符串作为value返回的时候, 才能看到那个值. 如果硬要说这个是一个容器的话, 容器中包含的是action, 从其中取值的时候, 即触发action的执行, 执行的结果作为value被返回.

如果觉得action有点虚无缥缈的话, 再具体一点可以把getLine的返回类型IO String, 当成一个function. 如果把函数当成value, 并且是可以比较的, 那么getLine某种意义上是一个纯函数. 因为每次总是返回同一个函数.

实际上Monad之所以是Monad不在于他做了什么事情, 无论是包含一个整数还是包含一个IO action, 而是因为他符合Monad的特点和性质. 好比我们要向一个原始人解释什么是自然数, 我们会怎么做? 一个数之所以是自然数, 就是因为他就是自然数, 换句话说, 自然数是被我们定义出来的, 而不是被实现出来的.

IO is magic

Haskell 的IO不是在Haskell本身完成的, 而是GHC运行时. 所有IO相关的操作来源于几个内置的IO操作. 并且在运行时委托给GHC运行时完成, 所有这些IO操作的触发来源是main. 理论上用户所写的Haskell代码本身不可能产生任何IO操作.

理解的难点

Monad本身是非常简单的, 上面所讲的三个要素就足够描述Monad. 但这只是一种形式上的简单, 其实质则是完全不简单的, 一个与之类比的事物是Lambda Calculus, 同样也只是三条非常简单的规则, 但是其本质一点都不简单.