到了我这个年龄,很少对某些发明感到好奇了。但是阅读了 Haskell 的 Maybe 类型,确实让我感到 Haskell 语言设计实在奇妙。这还得从 Haskell 的 IO 操作谈起。
前一段时间学习了 IO 操作,发现和 IO 相关的程序设计非常类似 C 语言。当时感觉 Haskell 面对有状态系统设计的时候,最终还是黔驴技穷,不得不回归传统程序设计。
然而在学习 Maybe 类型的时候,触及到了 Haskell 的 Monad 机制。才知道,原来 IO 类型和 Maybe 类型一样,只不过是 Monad 机制的一个实例。那种类似 C 语言的机制,只不过是 Monad 机制的语法糖而已。
最终发现,Haskell 的内核实际很小,很多复杂的东西,例如列表[]、IO类型等等,都可以基于语言内核设计出来。
1、程序设计中的错误处理
假设我们写一个做除法的程序:
int div (int x, int y) {return x / y;
}
其中未考虑 y=0 时的情况,于是,调用该函数时,存在程序崩溃的可能性。
于是,有人打算用特殊值表示函数出错,比如:
int div (int x, int y) {if (y) {return x / y;} else {return MAX_INT;}
}
显然,这不是可取的解决方案。如果在复杂的算式中引用了 div 函数,我们无法知晓计算过程是否产生过错误。
2、Haskell 的 Maybe 类型
Haskell 提供了一种机制,允许我们根据当前数据类型定义出扩展类型。Maybe 就是这样一种扩展类型。Haskell版本的 div 函数如下:
safediv x y | y /= 0 = Just (div x y)| otherwise = Nothing
运行一下,看看结果:
> safediv 10 3
Just 3
> safediv 10 2
Just 5
> safediv 10 1
Just 10
> safediv 10 0
Nothing
事实上,如果复杂表达式计算过程中有错误产生,利用 Maybe 类型,在计算过程错误的时候,可以让最终计算结果为 Nothing。
我们看看 safediv 的语法声明:
> :t safediv
safediv :: Integral a => a -> a -> Maybe a
函数的返回结果是 Maybe Integral,我们看看其定义:
> :i Maybe
data Maybe a = Nothing | Just a -- Defined in ‘GHC.Base’
对于数据类型 a, Maybe a 的值在原来的数字前加上了 Just,另外增加了一个新的值 Nothing,我们可以在出现计算异常时返回这个值。
3、Maybe a 和 a 之间的数据转换
我们会好奇问,Just 2 + Just 3 = ? 我们在 GHCi 中输入看一下。
> Just 2 + Just 3<interactive>:1:1: error:? Non type-variable argument in the constraint: Num (Maybe a)(Use FlexibleContexts to permit this)? When checking the inferred typeit :: forall a. (Num a, Num (Maybe a)) => Maybe a
实际上, 类型 a 和类型 Maybe a 是两种完全不同的类型,不能用 Maybe a 类型的数据代替 a 类型的数据。2+3 是合法的加法计算式,而 Just 2 和 Just 3 不能做加法运算。如何实现 Maybe a 类型的加法运算呢?先给出一种解决方案:
add :: (Monad m, Num a) => m a -> m a -> m a
add x y = dom <- xn <- yreturn (m + n)
试验一下:
> add (Just 2) (Just 3)
Just 5
我们发现,do、return 等操作并非只针对 IO 类型,实际上所有 Monad 类型都可以用他们操作。我们看到,在 do 命令块内,可以用 <- 从 m a 类型的数据中抽取 a 类型数据。return 则可以把 a 类型数据再次包装为 m a 类型。
令人惊奇的是,do 风格操作只是 Monad 类型的语法糖而已,真正隐藏在背后的是另一个强大的语言内核功能。
4、>>= 函数序列
我们看下面函数:
tentimes x = Just (10*x)
addtwo x = Just(x + 2)
可以把一个 Num 类型的数据乘以 10,然后“包装”成 Maybe 类型的数据。例如:
>tentims 4
Just 40
>addtwo 40
Just 42
借助 >>= 符号,我们可以把 Maybe 类型的数据“解包装”后,传递给 tentimes 函数。例如:
>Just 4 >>= tentime
Just 40
实际上,>>=符号可以连起来用,把一系列函数串联起来执行:
>Just 4 >>= tentime >>= addtwo
Just 42
串联起来的这些函数都有一个特点,它们的输入类型都是 a,输出类型为 m a。因为 >>= 的参数格式是 ma >>= \a -> m a,所以这样正好可以把这些函数串联起来。
如果 >>= 后面这个函数范围类型不是 m a,而是 a,会不会导致把输入类型 m a 解包为 a 类型呢?Haskell 不允许这种事情发生。我们做一个实验看看看。先定义一个函数:
>same x = x
> Just 4 >>= same<interactive>:129:1: error:? Non type-variable argument in the constraint: Num (Maybe b)(Use FlexibleContexts to permit this)? When checking the inferred typeit :: forall b. Num (Maybe b) => Maybe b
Haskell 不允许把 m a 类型的数据还原成 a 类型,a 一旦被 Monad “包装”,只能在 >>= 流中传递数据的时候临时“解包”,但最终函数返回的结果必须再次加上包装。于是,我们看到,IO类型的数据永远都会打着 IO 类型的烙印,永远不能降级为普通数字。
5、>>= 和 do
下面这段代码:
tentimes x = Just (x*10)
addtwo x = Just (x + 2)
f x = x >>= tentime >>= addtwo
换一种写法:
tentimes x = Just (x*10)
addtwo x = Just (x + 2)
f x = doa <- xlet y = tentimes ab <- ylet z = addtwo bc <- zreturn c
do、<-、return 只是 >>= 操作的另一种直观的等价表示而已。
6、>>= 的多参数函数处理
回到本文开始的那个函数:
add :: (Monad m, Num a) => m a -> m a -> m a
add x y = dom <- xn <- yreturn (m + n)
我们用 >>= 重新实现这一段代码:
add x y = x >>= \p -> y >>= \q -> Just (p + q)
试验一下:
>add (Just 2) (Just 3)
Just 5
7、一点感悟
Monad 把一个常量世界通过包装升级成形形色色的超级世界,其中包括允许状态变化的 IO 世界。常量世界的法则在这些超级世界不再有效,因此我们需要通过管道 >>= 把数据还原成常量世界,利用常量世界运算实现超级世界的程序设计。或许我们可以实现 mma、mmma、…这样的多层超级世界。
对我来说,这远远没结束,有关函数式程序设计的种种困惑还有很多,我需要时间慢慢揭开这些秘密。