在Haskell中用newtype重写Monad:若干技术疑问
问题背景
学习Monad时遇到以下两段代码,尝试重写过程中产生了若干技术疑问:
RWSP Monad 代码
type R = Int type W = String type S = Double newtype RWSP a = RWSP {runRWSP :: R -> S -> (a, W, S)} instance Monad RWSP where return a = RWSP(\_ state -> (a, mempty, state)) m >>= f = RWSP(\r state -> let (a, w, state') = runRWSP m r state (a', w', state'') = runRWSP (f a) r state' in (a', w++w', state'')) instance Functor RWSP where fmap = liftM instance Applicative RWSP where pure = return; (<*>) = ap
自定义CustomM 代码(存在编译问题)
newtype CustomM a = CustomM {runCustomM :: a -> String} instance Monad CustomM where return a = CustomM (\a -> mempty) m >>= f = CustomM (\a -> let str = runCustomM m str' = runCustomM (f a) in (str' ++ str)) instance Functor CustomM where fmap = liftM instance Applicative CustomM where pure = return; (<*>) = ap
疑问列表
- 1a. 原RWSP示例中let语句后为何需要
runRWSP?这是否因为runRWSP接收Monad实例m,而RWSP本身是包含类型a的Monad,需要匿名函数处理? - 1b. 能否改写RWSP的代码,不使用
runRWSP而仅使用RWSP? - 2a. let表达式中的嵌套元组为何都很重要?
f a似乎依赖第一个runRWSP m r state的返回值,而该Monad需要接收r和s是因为其newtype定义,这个理解是否正确?我改写时尝试类似写法却出错。 - bc. 此外,我猜测可将
m作为runRWSP的参数,这是因为newtype的反向特性,类似Learnyouahaskell教程中的示例:ghci> CharList "tdada" CharList {getCharList = "tdada"} ghci> getCharList (CharList "something") "something" - 3a. 能否认为bind运算符右侧需要
runRWSP,是因为bind输出是m b,需要函数,而RWSP a是Monad而非函数,无法将包装的a转换为b? - 4a. 如何正确改写CustomM的Monad实例,使其能编译通过?
疑问解答
1a. 为什么let语句中需要runRWSP?
RWSP是一个newtype包装器,它内部封装的是一个函数R -> S -> (a, W, S)。runRWSP是这个包装器的访问器函数,作用是把RWSP a类型的值拆解开,取出里面的函数。
在>>=的实现里,m是RWSP a类型的Monad实例,它本身只是一个包装后的函数容器,不能直接调用。必须用runRWSP m取出里面的函数,才能传入r和state参数执行,得到包含a、输出w和新状态state'的元组。你的理解方向是对的:RWSP是包裹了函数的Monad,必须通过访问器拿到内部函数才能处理。
1b. 能否不使用runRWSP,仅用RWSP改写?
不行。因为RWSP是构造器,只能用来把函数包装成RWSP a类型的值,而不能反过来从RWSP a中取出内部函数。Haskell的newtype包装器本身没有自动拆包的能力,必须显式使用访问器(也就是runRWSP)来获取内部的函数逻辑。
2a. 嵌套元组的重要性与你的理解是否正确?
你的理解完全正确:
- 第一个元组
(a, w, state')是执行m后得到的核心结果:a是Monad内部承载的值,w是本次计算产生的输出,state'是计算后的更新状态。 f a必须依赖这个a才能生成下一个Monad实例RWSP b,这是Monad bind的核心逻辑:将前一步计算的结果传递给后续计算,实现链式调用。- 第二个元组
(a', w', state'')是执行f a生成的Monad后的结果,最后需要把两次的输出w++w'合并(保证输出的连续性),状态更新为state'',最终包装成新的RWSP b返回。
每个元组的三个元素都缺一不可:没有a就无法触发后续计算,没有w就丢失了输出累积,没有state'就无法维护状态的连续性。你改写时出错大概率是因为忽略了某个元素的传递,或者状态/输出的合并逻辑有误。
bc. 关于newtype的反向特性猜测
完全正确。这就是Haskellnewtype的基本用法:构造器(比如CharList、RWSP)用来把普通类型包装成新类型,而访问器(getCharList、runRWSP)用来把新类型拆回普通类型。这种“包装-拆包”的特性是newtype实现自定义Monad/Applicative/Functor的核心。
3a. bind运算符右侧需要runRWSP的原因
可以这么理解,但更准确的说法是:
bind的类型是m a -> (a -> m b) -> m b。对于RWSP来说,m a是RWSP a,也就是一个包装后的函数容器。- 要实现
bind,我们需要先执行m a得到a,再把a传给f得到m b,最后把两次执行的输出和状态合并,包装成新的m b。 - 因为
m a是包装后的函数,必须用runRWSP拆包才能执行它,拿到a、w和state'。后续执行f a得到的m b同样需要拆包,才能执行并合并结果。
RWSP a本身不是函数,它只是函数的容器,所以必须通过runRWSP取出内部函数才能进行计算,进而完成从a到b的转换和Monad的串联。
4a. 正确改写CustomM的Monad实例
原CustomM的定义无法成为合法的Monad,因为它的类型参数a位于函数的输入位置(逆变位置),而Monad要求类型参数是协变的(位于输出位置)。
如果你想实现一个和字符串输出相关的Monad,建议调整CustomM的定义,做成类似Writer Monad的形式(这也是最符合字符串输出语义的Monad):
newtype CustomM a = CustomM {runCustomM :: (String, a)} instance Monad CustomM where return a = CustomM (mempty, a) m >>= f = CustomM $ let (w1, val) = runCustomM m (w2, res) = runCustomM (f val) in (w1 ++ w2, res) instance Functor CustomM where fmap = liftM instance Applicative CustomM where pure = return; (<*>) = ap
如果坚持原CustomM的定义(a -> String),它只能实现Contravariant Functor,而无法满足Monad的类型约束和定律。
内容的提问来源于stack exchange,提问作者Piskator

