文章目录
在Haskell中写代码最不习惯的一个地方大概就是遇到循环时无从表达。在Haskell中,既没有for
循环,也没有while
循环。以至于在写代码时,你的很多想法没法用代码表达。这感觉大概就像一个三岁小孩面对一只螃蟹,嗯……(陷入沉思)
为什么Haskell没有循环?
我们一直在强调Haskell中的一个基本原则:一切皆是表达式。循环和表达式似乎怎么也联系不起来,循环本身是没有结果的,而且你很难去界定循环的结果应该是什么。其次,除了不需要结束的死循环,循环通常伴随着状态变化,而改变和Haskell中的一切皆不可变是相冲突的。所以Haskell中并没有循环的语法,这让习惯了命令式编程的我们感到抓心饶肝。本期文章我们就来看看如何在Haskell中去处理循环类问题。
递归
在命令式编程中,我们经常会做一种优化,把递归转化成循环方式。对于基于栈的编程语言来说,递归太费栈了。然而在Haskell中,递归是我们的基本思考模式。在某些问题上,递归要比循环更加直观。虽然Haskell也运行在冯诺依曼机器上,但是我们不用担心递归的问题,因为Haskell的编译器足够"聪明"。
递归和循环是可以相互转化的,在循环问题中,有一类是经典递归问题,比如汉诺塔和斐波拉契数列算法等,对于这类问题,它们的递归特性十分明显,直接递归就完事了,这里我们看一个快速排序和八皇后的问题。
- 快速排序
qsort [] = []
qsort (x:xs) = let left = (qsort . filter (<=x)) xs
right = (qsort . filter (>x)) xs
in left ++ [x] ++ right
- 八皇后
safe _ [] _ = True
safe a (x:xs) n = a /= x && a /= x + n && a /= x - n && safe a xs (n+1)
queens 0 = [[]]
queens n = [x : y | y <- queens (n-1), x <- [1..8], safe x y 1]
八皇后问题
八皇后问题是要求在一个 8x8 的棋盘内摆上8枚棋子,要求任何两个棋子不能在同一条横线、竖线和对角线上。按递归的思路,假设我们已经在前 n-1 行成功摆上棋子,现在要在第 n 行摆上棋子。我们只需要在第 n 行的每一列上试图摆上一颗棋子,如果能符合要求,那我们就会得到摆放前 n 行的一个解,要得到第 n 行的所有解,只需要把第 n 行的每一列都摆一下,把所有符合要求的结果都放入集合就可以了。同样,第 n-1 行的所有可能解也是这么来的。
对于经典循环类问题,也可以转化为递归算法,但通常不一定是最好的做法,下一节单子抽象中我们会看到这类问题的解法。此处仅以一个非常无聊的示例作为示例展示。
boring 0 = return ()
boring n = do
boring (n-1)
putStrLn $ "无聊x" ++ show n
确实够无聊的。还有千万不要输入负数,你的栈会爆炸的。
单子抽象
单子的一个重要作用是可以组合计算,循环也是一种控制结构,用来重复执行某个计算。重复执行某个计算,也可以抽象成组合n个同样的计算,这就是单子的威力。甚至通过单子抽象,我们也能方便的解决某些递归问题。
我们可以将循环类问题分为单纯循环和基于数据结构的循环。数据结构基本上就是线性表,虽然也有基于图和树的循环算法,但最终都是转化为了基于线性表的循环。单纯循环不依赖与数据结构,只需要指定循环次数。
基于数据结构的循环
由于Haskell的强大抽象能力,Haskell中基于数据结构的循环不止限于列表。不过列表作为最常用的数据结构,我们还是以它为例。Haskell本身提供了丰富且功能强大的函数来操作列表,它们也能解决部分循环问题。关于这些函数会在列表专题中介绍,这里我们只看几个基于单子抽象的结构控制函数。
接下来要介绍的函数都在Control.Monad
模块,因此别忘了导入它。
sequence和sequence_
sequence
和sequence_
严格来说是用来连接单子运算的,它们的区别是后者不会保留单子运算结果。以下所有成对的函数都是以_
结尾的不会保留单子计算结果。
> :t sequence
sequence :: (Traversable t, Monad m) => t (m a) -> m (t a)
> :t sequence_
sequence_ :: (Foldable t, Monad m) => t (m a) -> m ()
一个简单的例子是通过sequence
读取一个字符串列表,通过sequence_
打印列表,因为对于打印,我们并不关心结果。
readNstring n = sequence $ take n $ repeat getLine
printNstring xs = sequence_ $ map print xs
mapM和mapM_
mapM
和mapM_
可以将单子运算应用到列表,它们的类型如下。
> :t mapM
mapM :: (Traversable t, Monad m) => (a -> m b) -> t a -> m (t b)
> :t mapM_
mapM_ :: (Foldable t, Monad m) => (a -> m b) -> t a -> m ()
例如前一个例子中打印字符串列表用mapM_
表示如下:
printNstring' :: Foldable t => t String -> IO ()
printNstring' = mapM_ putStrLn
实际上,mapM_
就是sequence_ . map
。
forM和forM_
forM
和forM_
与mapM
和mapM_
的区别仅仅是参数顺序不同,前者实际上就是flip mapM
和flip mamM_
。
> :t forM
forM :: (Traversable t, Monad m) => t a -> (a -> m b) -> m (t b)
> :t forM_
forM_ :: (Foldable t, Monad m) => t a -> (a -> m b) -> m ()
第一个例子中打印字符串列表的函数同样可以用forM_
实现。
printNstring'' xs = forM_ xs putStrLn
这就有点Go语言循环的味道了。
var xs = []string{"1", "2"}
for _, s := range xs {
println(s)
}
filterM
filterM
将filter
推广到了单子运算,类型如下。
> :t filterM
filterM :: Applicative m => (a -> m Bool) -> [a] -> m [a]
foldM和foldM_
foldM
和foldM_
是把fold
推广到了单子运算,类型如下。
> :t foldM
foldM
:: (Foldable t, Monad m) => (b -> a -> m b) -> b -> t a -> m b
> :t foldM_
foldM_
:: (Foldable t, Monad m) => (b -> a -> m b) -> b -> t a -> m ()
我们可以用它来求解N皇后问题。
queensN n = foldM placeQueen [] [1..n]
where placeQueen xs _ = [x:xs | x <- [1..n], safe x xs 1]
单纯循环
replicateM和replicateM_
replicateM
和replicateM_
不再依赖数据结构,而是可以指定循环次数,有点像for i := 0; i < 10; i++ { }
。比如第一个例子中读取字符串列表的函数也可以通过它来实现。
readNstring' n = replicateM n getLine
forever
forever
用于需要无限循环的场景,类似于for { }
。比如我们可以做一个没有感情的复读机。
repeater = forever $ do
a <- getLine
putStrLn a