1. Pure Language
Haskell是一个纯函数式编程语言(pure language),
任何一个函数的调用结果只会取决于它的参数。
而C语言中的rand(),getchar()函数,每次调用都会返回不同的结果,
因此,在Haskell实现它们中几乎是不可能的。
此外,Haskell中的函数是没有副作用的(side effect),
调用它们,不会影响外部世界(real world)。
比如,不能修改文件,不能在屏幕上显示字符,不能通过网络发送消息,等等。
这意味着,任何相同参数的函数调用,都可以被它的调用结果所取代。
而且语言本身保证了,这样做不会影响程序的最终结果。
2. Compiler optimization
C编译器会猜测哪些函数没有副作用,不会依赖可变的全局变量。
可是,如果猜错了的话,这种优化就会不小心改变了程序的语义。
因此,C编译器总是保守的猜测,或者需要程序员指出某个函数是否纯函数。
与C编译器不同的是,Haskell编译器是一系列纯粹的数学变换,
因此,结果可能是高度优化的。
此外,纯粹的数学计算,可以很容易被切分成多线程并行执行,
正好符合当今计算机多核的发展趋势。
而且,纯粹的数学计算,错误会更少,也容易被验证,
它会使Haskell代码更健壮,执行效率更高。
Haskell的纯函数性,允许编译器只进行那些必要的调用,称之为惰性求值(lazy evaluation)。
对于那些纯数学计算,这是一件极好的事情,
可是对于IO操作(IO action),就出问题了。
putStrLn "Press any key to begin formatting"
以上函数调用,不会返回任何有意义的结果,
编译器可能会忽略,或者重新排列类似这种函数的调用,
我们如何避免它这么做呢?
即,在惰性求值的语言中,我们如何实现那些依赖状态改变的算法,
如何写出有副作用的程序。
这个问题在Haskell历史上出现过了很多种解决方案,
目前标准的方法是使用Monad。
3. Monad的本来面目
Monad是范畴论(category theory)中的一个概念,范畴论是数学的一个分支。
幸运的是,为了解决IO问题和副作用,我们不必学它。
3.1 纯函数的问题
假如我们要在Haskell中实现getchar
,它可能是Char
类型的。
getchar :: Char
getchar = ...
然后,我们根据getchar
可以实现get2chars
,
get2chars :: [Char]
get2chars = [getchar, getchar]
我们发现get2chars
是有问题的。
(1)因为Haskell编译器把所有函数看做纯函数,所以,get2chars
对getchar
的两次调用,只会被执行一次。
(2)即使执行了两次getchar
,编译器也不能保证哪个getchar
先被执行。
3.2 重复调用
为了解决第一个问题,我们可以引入一个假的参数(fake parameter),
每次使用不同的参数调用getchar
。
getchar :: Int -> Char
getchar = ...
get2chars :: [Char]
get2chars = [getchar 1, getchar 2]
然后,get2chars
也会遇到只会被调用一次的问题,也应该附带假的参数。
get2chars :: Int -> [Char]
get2chars _ = [getchar 1, getchar 2]
3.3 数据依赖
现在我们需要给编译器一些线索,让它知道哪个函数先被调用。
然而,除了数据的依赖关系(data dependency)之外,
Haskell语言本身并没有提供任何表示执行顺序的方法。
我们如何人为添加一些数据依赖呢?
如何保证第一个getchar
比第二个getchar
先执行呢?
为了达到这个目的,我们需要让第一个getchar
额外返回一个值,
并把这个值传给第二个getchar
。
getchar :: Int -> (Char, Int)
getchar = ...
get2chars :: Int -> [Char]
get2chars _ = [a, b]
where (a, i) = getchar 1
(b, _) = getchar i
这样我们就解决了第二个问题,
由于第二个getchar
需要第一个getchar
的返回值i
,
所以,它必须等第一个getchar
执行完才能被调用。
3.4 无用参数
以上我们给get2chars
增加了一个额外的参数,但是没有使用它,
Haskell编译器十分聪明,它能看到这一点,会进行相应的优化,
因此get2chars
还是无法被重复调用。
为了解决这个问题,我们不妨把get2chars
的参数,传给第一个getchar
。
get2chars :: Int -> [Char]
get2chars i0 = [a, b]
where (a, i1) = getchar i0
(b, i2) = getchar i1
3.5 传递下去
此外,由于get2chars
也需要建立数据依赖,
所以,也必须多返回一个参数。
get2chars :: Int -> (String, Int)
get2chars i0 = ...
get4chars :: [Char]
get4chars :: (a++b)
where (a, i1) = get2chars i0
(b, i2) = get2chars i1
这个额外返回的参数应该是什么呢?
如果我们使用整数常量,聪明的Haskell编译器也会看到这一点,
我们不如使用最后一个getchar
额外返回的值,作为get2chars
额外的返回值。
get2chars :: Int -> (String, Int)
get2chars i0 = ([a, b], i2)
where (a, i1) = getchar i0
(b, i2) = getchar i1
到目前为止,我们已经实现了Haskell的整个IO系统。
4. RealWorld
以上我们给那些有副作用的函数,增加了额外的参数和额外的返回值,
为了让它们可以被重复调用,并且保证它们的执行顺序。
于是,main
函数应该具有以下类型,
main :: RealWorld -> ((), RealWorld)
其中,我们用RealWorld
这个假的类型(fake type)替代了上文中的Int
。
RealWorld
类型的值就像一个接力棒,在函数的执行过程中进行传递。
当main
调用某个IO函数的时候,它就把RealWorld
传给它作为参数,
此外,这个IO函数还额外返回一个RealWorld
传递给下一个IO函数。
为了描述的清晰起见,我们定义一个类型别名(type synonym)。
type IO a = RealWorld -> (a, RealWorld)
因此,main
的类型就是IO ()
,getChar
的类型就是IO Char
,等等。
我们来看看main
调用两次getChar
的例子。
getChar :: IO Char -- RealWorld -> (Char, RealWorld)
getChar = ...
main :: IO () -- RealWorld -> ((), RealWorld)
main world0 = let (a, world1) = getChar world0
(b, world2) = getChar world1
in ((), world2)
main
将world0
传给第一个getChar
,
这个getChar
返回一个新的world1
,传递给第二个getChar
,
然后main
返回第二个getChar
返回的world2
。
网友评论