Monad的Reader和State
参考材料:
写的非常好的haskell学习笔记:haskell 笔记-Monad
KU的课程材料:Week 2 - Advanced Programming Course Notes (diku-dk.github.io)
感觉monad这东西非常的抽象,从开始学到还行的理解花了有2天左右的时间,我在网上看到的大部分信息都是介绍了这玩意怎么用,但是为什么要如此设计基本都没有讲清楚,因此以本人目前的粗浅理解写一篇博客记录一下学习中碰到的一些问题。
概念理解
type Monad :: (* -> *) -> Constraint
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b
基础的什么是Monad 的信息可直接看:haskell 笔记-Monad。
Monad的精华其实就在于 绑定>>=
这个运算,他的名字叫“绑定”,乍看之下非常的奇怪,这函数的定义跟实在是跟绑定没有什么关系,如何理解?我认为Monad应该可以分为3个部分去看:
(>>=) :: m a -> (a -> ...) -> ...
:这一部分就是m a
将自己的结果计算出来,输出一个a
类型的值(>>=) :: ... -> (a -> ...) -> ...
:之前计算的被给到了一个函数,这个运算的结果可以在将来被用到!(>>=) :: ... -> (. -> m b) -> m b
:后续有一个新的monad,新的monad的类型随便,不一定非得是m a了
举个例子比较形象,看一个do的例子,其实do就是>>=
的语法糖:
func m input = do
x = m input
f x
先别管这个函数为啥写,只需要看do 里面的东西,第一个行x = m input
,其中右侧的 m input
对应了第一部,计算出一个结果。然后是 x = ...
,代表着将计算出的信息 “绑定” 到一个东西身上。最后是 z = f x
右侧的 f x
,他对应了 m b
这一项。
上面的例子可以等价的写成:
func m input = do
m input >>= \x ->
(f x)
这在就好看懂了,定义中的 (a -> m b)
这个函数其实会被处理成匿名函数的形式,意义就是承接上一个 monad 返回的信息,承接完之后,直接执行后面的新的monad,将新的monad的结果作为整体的返回值。
将 (a -> m b)
理解成一个写的非常抽象的函数让我一直没法理解为什么要这么设计,这么设计是为了解决什么问题? do的语法糖解释了设计原因,a -> m b
其实被解析为了一个lambda 函数,m a
的输出被匿名函数保存了下来,然后接着执行一个新的函数 m b
,最后返回 m b
的执行结果就好了。
这其实也解释了为什么 >>=
输入进来的是一个 m a
,但是输出出去的是一个 m b
,也就是说输出的类型完全可以与输入不一样,因为本质上 >>=
只进行m a
的结果的绑定,m b
是lambda 函数的函数体,随便写,唯一的要求是同一种 Monad m
包裹。
Reader的学习
Reader的目的是构建一个可读的公共变量,State的目的是就是构建一个可以在前后传递的东西。要理解Reader和State起作用的方法,需要直接看源代码,就10行左右,只是确实太抽象了。
Monad 中的Reader 和 State其实更多反映的是一种设计模式,即如何利用monad达成特定的目的。之所以说是一种设计模式,是因为实际使用过程中可能不太能碰到一个Reader就能解决所有问题的情况,因此可能会碰到需要同时使用Reader叠加别的东西的情况,此时就需要自己根据特定的需求构建自己Reader了。
现在从0构建一个Reader出来,大概的说一下思想,现在需要对表达式进行解析,构建出的Reader为:newtype MyReader a = MyReader (Env -> a)
,其中的Env为一个 [(String, Int)]
类型。也就是说,此时的Monad 为 MyReader
,它包裹了一个函数。他的绑定 (>>=
) 可以被写为如下的形式:
(>>=) :: MyReader a -> (a -> MyReader b) -> MyReader b
MyReader x >>= f = MyReader $ \env ->
let x' = x env
MyReader f' = f x'
in f' env
首先要注意,在写函数的具体实现的时候,MyReader x 对应的是一个(Env -> a)的函数,要分清类型定义和 模式匹配
这里的x为一个 (env -> a) 类型的函数,因此 x env就会得到一个类型为a的结果,计为 x'
这里的f为一个 (a -> MyReader b) 类型的函数,f x' 会得到一个MyReader b,也就是一个 MyReader (Env -> b) 的函数
通过MyReader f'对MyReader (Env -> b),f' 为(Env -> b) 的函数
f' env的结果为b,配合上最外围的MyReader $ \env ->,最后的结果为MyReader $ \env -> b,刚好就是 MyReader b
以上是对这段绑定实际执行顺序的说明,接下来是此段话的含义:
首先MyReader 包裹的是一段函数,那么显然在真正运行的时候,是需要外界传入一个 env的,这个之后要传入的env,此用env表示
这个首先被绑定左侧的MyReader a 接收了,计算出结果后MyReader b (也就是 f')一样会接受这个env,然后计算出一个类型为b的结果。
抽象的来说,当执行到某一层的时候,当前层的两个式子都接受了同一个env,且当接下来递归的处理这两个式子的子式时,他们也会接受此刻的这个env,由此实现构建一个全局的可读变量。
最终的效果是,当我需要执行一个MyReader 包裹的表达式的时候,我在顶层传入一个env,在整个运行的过程中都存在可读的env变量
如果碰到了中途需要修改env的情况:
local :: (Env -> Env) -> MyReader a -> MyReader a
local f (MyReader g) = MyReader $ \env -> g (f env) -- 就是把MyReader g 的输入env使用一个(Env -> Env)函数包裹了
这个local可以暂时的修改env的信息,不过仔细一看定义,其只会对由local包裹的子式生效,因为修改env其实是通过修改一个(Env -> Env)的函数实现的,相当于在对一个树进行遍历时,假设在某个中间节点 node1 修改了env的值,这个修改仅仅会对 node1的子节点生效,对其余的节点无影响
State学习
newtype State s a = State (s -> (a, s))
此处的返回值是一个state 和 一个结果a
instance Monad (State env) where
State m >>= f = State $ \state ->
let (x, state') = m state
State f' = f x
in f' state'
可以看到首先输入的state给了m(一个函数),然后输出的结果 x 和 新的状态state'
f为一个 (a-> (s -> (a, s)))的函数,输入x后新的f' 输入最新的state',其返回值作为最终的结果
抽象的理解就是输入一个状态,首先是 >>= 左侧的函数运算,随后将运算结果进行绑定(此步骤对应了构建一个lambda函数)
接下来的新的 State包裹的函数所传入的状态为m 所输出的状态
get :: State s s
get = State $ \s -> (s, s)
put :: s -> State s ()
put s = State $ \_ -> ((), s)
就是无脑的将state 修改为自己设定的值
组合Reader和State
这个例子就体现了为什么应该将Reader和State理解成一种设计模式,因为实际应用的时候完全可以将他们组合起来进行处理。
instance Monad (RS env s) where
RS m >>= f = RS $ \env state ->
let (x, state') = m env state
RS f' = f x
in f' env state'
-- The Functor and Applicative instances are then just the usual boilerplate.
instance Functor (RS env s) where
fmap = liftM
instance Applicative (RS env s) where
pure x = RS $ \_env state -> (x, state)
(<*>) = ap
-- We can then define the following API for RS, providing both State and Reader-like operations
runRS :: env -> s -> RS env s a -> (a, s)
runRS env state (RS f) = f env state
rsGet :: RS env s s
rsGet = RS $ \_env state -> (state, state)
rsPut :: s -> RS env s ()
rsPut state = RS $ \_env _ -> ((), state)
rsAsk :: RS env s env
rsAsk = RS $ \env state -> (env, state)
rsLocal :: (env -> env) -> RS env s env -> RS env s env
rsLocal f (RS g) = RS $ \env state -> g (f env) state
一堆杂问题
**问:**将
>>=
理解成绑定的话,(>>=) :: m a -> (a -> m b) -> m b
这里的m b
一定要使用m a
的结果吗?不一定,这么
m b
用不用m a
的输出都可以。就像写lambda函数\input -> f (....)
,实际的函数体部分用不用参数都可以,所以有的时候会发现在do里面会有这种写法。这里的意思就是不关心m a
算完后的返回值。 比如使用State解析一个表达式,需要的信息全部放到 State中了,返回值不重要。do _ <- m a
也正是因为存在返回值不重要的情况,有时间会见到一个写法:
Evam ()
。此处的EvalM
是一个 monad,()
是一种类型,一般这种写法就是表示返回值不重要,也就是do
中<-
左侧要被绑定的位置不重要。在 Haskell 中,
()
是一个特殊的类型,称为“单位类型”(Unit type),表示一个没有信息的值。它类似于其他编程语言中的void
或null
,但在 Haskell 中它是一个完整的类型,可以参与各种类型运算和组合。
**问:**如何知道 do中绑定
<-
得到的输出究竟是什么?do是
>>=
的语法糖,所以要回到>>=
的实现中找结果。在
(>>=) :: m a -> (a -> m b) -> m b
中可以看出,被绑定的结果应该是a -> m b
中的a,那也应该在>>=
中实现。这是Reader和State的组合RS: instance Monad (RS env s) where RS m >>= f = RS $ \env state -> let (x, state') = m env state RS f' = f x in f' env state' f对应的是 a -> m b,要看的是首先输入f的东西,此处的就是(x, state') = m env state 中的x
这是Reader (>>=) :: MyReader a -> (a -> MyReader b) -> MyReader b MyReader x >>= f = MyReader $ \env -> let x' = x env MyReader f' = f x' in f' env f对应的是 (a -> MyReader b),要看的是首先输入f的东西,此处的就是x' = x env 中的 x'
问:
newtype Parser a = Parser {runParser :: String -> Maybe (a, String)}
请简要的为我介绍这种类型定义的写法,这里的runParser又是什么?在 Haskell 中,
{runParser :: xxx}
这种语法称为记录语法(Record Syntax),用于为数据构造子定义字段名。它为数据类型或新类型中的每个字段生成一个对应的访问函数。在你给出的newtype
定义中,这种写法定义了一个Parser
类型,并为它包含的字段runParser
提供了一个访问函数。记录语法的基本形式如下:
data TypeName = ConstructorName { fieldName1 :: Type1, fieldName2 :: Type2, ... }
在
newtype
或data
定义中,{ ... }
内部定义的fieldName
是字段的名称,它自动生成一个访问函数,用于提取该字段的值。对于
newtype
,记录语法的作用是定义一个单字段的新类型,并为其生成一个字段访问函数。类似于:newtype Parser a = Parser { runParser :: String -> Maybe (a, String) } -- 等价于如下的写法: newtype Parser a = Parser (String -> Maybe (a, String)) -- 手动定义一个访问函数 runParser :: Parser a -> (String -> Maybe (a, String)) runParser (Parser f) = f
runParser
是一个自动生成的访问函数,用于提取Parser
类型中的字段值。
runParser
的类型签名是:runParser :: Parser a -> (String -> Maybe (a, String))
runParser
的作用是将Parser
类型解包,获取内部封装的解析函数String -> Maybe (a, String)
。记录语法的优点
使用记录语法的主要优点是:
字段访问方便:
- 通过字段名自动生成的访问函数,可以方便地获取数据结构中的某个字段。
语义更清晰:
- 字段名提供了更清晰的语义,有助于理解数据结构中的每个部分的含义。
代码更简洁:
- 自动生成的访问函数避免了手动编写这些函数的重复劳动,代码更简洁易读。
问:为什么总可以看到要将一个操作转为对应的monad的情况?
答:因为
>>=
的定义>>=) :: m a -> (a -> m b) -> m b
,后面的出现的是m b
,但也依旧是monad m
,在整个do 的链条中出现的都应该是同一种monad。