You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

在Haskell中用newtype重写Monad:若干技术疑问

Haskell 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.19 02:45:44