book
归档: Haskell 
flag
mode_edit

没想到还能这样玩…真是有一套啊

ST Ref 提供了和 IO Ref 类似的操作:

data ST s a
data STRef s a
newSTRef :: a -> ST s (STRef s a)
readSTRef :: STRef s a -> ST s a
writeSTRef :: STRef s a -> a -> ST s ()

runST :: (forall s. ST s a) -> a

看起来和 IO Ref 的一套几乎一模一样,除了 ST Ref 多了一个类型参数 s ,而且 ST Monad 有一个很劲爆的函数 runST ,通过它的类型签名,我们可以发现,它可以让我们从 ST Monad 回来!而 IO Monad 是不能这样做的,除非使用 unsafePerformIO :: IO a -> a。那么,ST Ref 为什么可以这样做呢?这样做会不会破坏 Haskell 的纯洁性呢?以及,runST 里的 forall 是什么意思呢?

RankNTypes

考虑一个普普通通的多态函数

head :: [a] -> a

当我们使用 head [1..] 的时候,我们可以认为,它的类型签名中的 a 被统一替换成了 Integer ,这就很像 $\lambda\textrm{-Calculus}$ 里的 $\alpha\textrm{-deduction}$。

这样做在大部分情况下都是没有太大问题的。但是假如我们脑洞大开,写出了这样一个函数,把列表操作 [a] -> a lift 到元组上:

liftF f (xs, ys) = (f xs, f ys)

我们可以试探性地先写一个它的类型签名出来:

liftF :: ([a] -> a) -> ([b], [c]) -> (b, c)

但是这样好像不太对劲!根据我们之前总结出来的「替换」规则,这样写 a 是没法被替换的。那写成这样呢?

liftF :: ([b] -> b) -> ([b], [c]) -> (b, c)

这就更不对劲了,这样一来,f ys 的类型就对不上了。

forall

首先我们打开语言拓展 RankNTypes

{-# LANGUAGE RankNTypes #-}

我们通常写的多态函数,实际上都是以下形式的简写:

head :: forall a   .  [a]             ->  a
map  :: forall a b .  (a -> b) -> [a] -> [b]
id   :: forall a   .   a              ->  a

我们可以注意到,格式大概是 forall <TypeNames> . <Signature> ,这样写相当于「$\lambda~a.([a]\to a)$ 」。为了解释方便,我们来写一点 Idris。

head :: Type -> List Type -> Type
head _ (x::xs) = x

在 Idris 里,类型也是一等公民,我们可以这样调用 head

head Nat 1::2::3

这里我们传入了一个类型名。得到类型名之后,编译器会把 Type 代入类型签名剩下的部分。

事实上这完全多此一举,我们完全可以按照 Haskell 的那一套思路来写。但是这部分工作总是要有人做的,实际上 forall 就起到了这样的作用,编译器会自动推断我们调用函数所用到的参数的值的类型,然后把类型名给替换掉。而 Haskell 为我们隐藏了起来这部分内容,即 Rank 1 Type 不必书写 forall

我们以此把之前的函数签名改写一下:

liftF :: (forall a . ([a] -> a)) -> ([b], [c]) -> (b, c)

这样一来,编译器会先把 b 代入 [a] -> a 里,得到 [b] -> b ,再把 c 代入 [a] -> a 里,得到 [c] -> c然后满意地点点头

ST Monad

ST Monad 里的 sforall 究竟是干什么的呢?我们不妨先把 forall 去掉,看看会发生什么:

newSTRef :: a -> ST s (STRef s a)
runST :: ST s a -> a

runST $ newSTRef (1::Int)

这可真是要命,newSTRef 会得到一个 ST s (STRef s Int),再执行一下 runST ,我们惊奇地发现,函数内部的状态已经泄露到外面去了,这可不太行啊,得治治这些动不动上房揭瓦的 Programmer。

那么接下来就是感受前人智慧的时间,实际上类型 s 什么都没有做,它是一个 Phantom Type,它唯一用途,就是防止用户上房揭瓦,把函数内部的状态泄露到外面去。

runST :: (forall s. ST s a) -> a

加上 forall 限定后,事情发生了一些变化,注意到前文我们对 forall 和 $\lambda\textrm{-Calculus}$ 的相似性的讨论,我们不难发现,惨遭 forall 的类型名是只能在 forall 的括号内部使用的,这就很像 $\lambda~x.x+1$ ,我们不能在这个函数的外面使用这个函数里面的 $x$ 一样。这样来说,类型 s 就只能在这个括号的内部发挥余热了。如果某个人试图 runST $ newSTRef (1::Int),从而把状态给泄露出去,他能不能成功呢?我们来看一下类型:

newSTRef 会得到一个 ST s (STRef s Int),但是当他试图 runST 的时候会发现,函数签名会变成这样:runST :: (forall s. ST s (STRef s Int)) -> STRef s Int ,类型 s 被泄露到了括号外面!这就会编译错误,从而从根本上杜绝了上房揭瓦的可能性。

有了 ST Monad,我们就可以愉快地写一些看似很纯,实际上有内部状态的东西了。