Haskell的副作用究竟是如何隔离的

大家都知道Haskell是纯函数式语言,因为Haskell将side-effects隔离出来了,但是“隔离”是怎么体现出来的,一直很难讲清楚,举个例子试着分析一下。

隔离副作用的表现就是,对于一个纯函数,永远无法引入副作用,换句话说,对于相同的输入,输出永远相同。在Haskell里,你永远无法定义出一个类型为String -> String的函数,对于相同的输入,输出的String值不同。

我们举一个John Hughes的Paper《Programming with Arrows》中的例子来说明。

假设我们想定义一个函数,计算某个字符串中某个单词的计数,最简洁的方式是使用Function Composition:

count w =
    length . filter (==w) . words

可以看出来,这个函数的类型是:String -> Int。这是一个无副作用的函数,对于同样的字符串,输出的结果永远相同。

现在我们改变一下这个函数的功能,不从字符串里面统计,而是读一个文件,然后输出向终端输出最后的结果,类似unixwc命令。我们可能很自然的会想将函数改造成这个样子:

count w =
    print . length . filter (==w) . words . readFile

代码虽然很直观,但是这个函数是编译不了的,因为类型不对,readFile的类型是FilePath -> IO String,返回值是IO String,是一个IO Monad,也就是说是有副作用的。

IO Monad的内部状态是无法escape的,所以你无法使用readFile函数,在IO Monad之外,写出一个类型为FilePath -> String函数来读取文件内容并返回。通过这种类型上的约束,Haskell就可以避免副作用的引入。

如果想实现这个功能,函数类型就只能是FilePath -> IO ()

count w =
    (>>=print) . liftM (length . filter (==w) . words) . readFile

我们从后向前来解读一下。

readFile的类型是FilePath -> IO String,表示整个函数的参数类型是FilePath,其实就是String

length . filter (==w) . words的类型是String -> Int,若想将这个函数应用到IO Monad里,为了类型兼容,必须要先lift这个函数,即将a -> b的函数lift成m a -> m b。在我们的环境下,就是将 String -> Int提升成IO String -> IO Int

这两部分组合后,即liftM (length . filter (==w) . words) . readFile,大家应该可以看出来,返回值类型是IO Int,但是print的类型为a -> IO (),在这个上下文中,即为Int -> IO (),无法直接使用。如果想使用,就需要一个类型为IO Int -> (Int -> IO ()) -> IO ()的函数,去掉具体类型的话,其实就是IO a -> (a -> IO b) -> IO b,这不就是Monad的bind运算符(>>=)吗?

最后理一下:

  • liftM (length . filter (==w) . words) . readFile的类型是FilePath -> IO Int
  • >>=的类型是IO Int -> (a -> IO ()) -> IO ()
  • print的类型是Int -> IO ()

将这三个函数放到一起,就是:

count w filePath=
    (liftM (length . filter (==w) . words) . readFile) filePath >>= print

写成point-free的形式,就是:

count w =
    (>>=print) . liftM (length . filter (==w) . words) . readFile