STATION III · 峡谷CH 12里程 12/20

Monad 巡礼:State、Reader、Writer

峡谷的最后一站是三连拱桥。上一章说 Monad 是「可编程的分号」——本章看三种经典编法:State 让分号之间自动传递状态,Reader 让分号之间自动传递配置,Writer 让分号之间自动累积日志。剧透:这三样你全都用过,名字分别叫 Redux、依赖注入和结构化日志。看懂它们的 Haskell 原型,你会对自己天天在用的框架多一层「原来如此」。

StateReaderWriter样板自动化

State:纯语言里的「可变」状态

纯函数不能改状态,但可以传递状态:吃旧状态,吐新状态,让「变化」变成一串各自不可变的快照。问题是手动传实在难看——给树的每个节点按遍历顺序编号,状态是「下一个编号」:

label :: Tree a -> Int -> (Tree (Int, a), Int)      -- 吃编号,吐(结果, 新编号)
label Leaf         n = (Leaf, n)
label (Node l x r) n =
  let (l', n1) = label l n          -- 左子树用掉一些编号
      (x', n2) = ((n1, x), n1 + 1)  -- 当前节点用一个
      (r', n3) = label r n2         -- 右子树接着用
  in  (Node l' x' r', n3)

能跑,但 n、n1、n2、n3 这条「状态穿线」全是样板:每一步都在重复「把上一步吐出的状态喂给下一步」,而且穿错线(比如把 n1 喂给了右子树)编译器拦不住——纯是纯了,危险还在。重复的模式 → 抽出来(第 5 章教的)。把「带状态的计算」定义成一种值,把穿线塞进 >>=:

newtype State s a = State (s -> (a, s))   -- 「吃状态吐(结果,新状态)」的函数,装进盒子

-- 它的 >>= 只干一件事:替你穿线
-- (State f) >>= g = State $ \s -> let (a, s') = f s in runState (g a) s'

get    :: State s s          -- 拿当前状态
put    :: s -> State s ()    -- 换新状态
modify :: (s -> s) -> State s ()
label :: Tree a -> State Int (Tree (Int, a))
label Leaf         = pure Leaf
label (Node l x r) = do
  l' <- label l
  n  <- get
  put (n + 1)
  r' <- label r
  pure (Node l' (n, x) r')

-- runState (label tree) 0 才真正开跑——State 和 IO 一样,先配方后执行

n1/n2/n3 全部蒸发:分号之间,状态自动流动。注意两个要点。其一,没有任何东西被修改——底下还是那个「旧状态进新状态出」的纯函数,State 只是把管道焊好了,所以它完全可测、可回放。其二,runState 给初始状态才执行,「描述与执行分离」再次出现——这是 IO 教过你的模式,此后每个 Monad 都是这个套路。

▸ 下游对照 · Redux,一字不差

s -> (a, s) 稍微变形:(State, Action) => State——这就是 reducer。Redux 的全部哲学(状态不可变、更新是纯函数、时间旅行调试)是 State Monad 心智模型的前端化;Elm(Haskell 直系)是中间站。你在 ViewModel 里写的 uiState.update { it.copy(count = it.count + 1) },就是 modify。区别照旧:Redux 把这个模式做成了框架,上游它是十行库代码——模式的地位差异,决定于语言能不能直接表达它

Reader:人人要读,没人要改

第二种样板:配置。数据库连接、功能开关、当前用户——几乎每个函数都要,却几乎没人改。手动传参的版本,每个函数签名都拖着 Config -> 的尾巴,而且中间层最冤:自己不用 Config,只因为三层之下的某个函数要,被迫全程经手,新增一个依赖就要改一整条调用链的签名。Reader 把这个「向下传递」自动化:

newtype Reader r a = Reader (r -> a)     -- 「给我环境,我给你结果」

ask :: Reader r r                         -- 任何深度,随取随用

handler :: Reader Config Response
handler = do
  req  <- validate            -- validate 内部也能 ask,但 handler 不用替它传
  conf <- ask
  pure (respond (dbUrl conf) req)

-- runReader handler prodConfig   ← 环境在最外层注入一次

眼熟吗?这就是依赖注入。Dagger/Hilt/Koin 的 @Inject、Spring 的容器、React 的 Context——都在解决同一个问题:「让深处的代码拿到外层的依赖,而不经中间层手手相传」。Reader 是这个思想的最小实现:没有注解处理器、没有反射、没有容器,一个 newtype 加一个 ask。看懂它,你对 DI 框架的理解会落地一层:依赖注入的本质是把「环境 → 结果」的函数组合起来,容器只是这件事的工业化包装。CoroutineContext 沿协程链隐式传播的设计,同样是 Reader 的亲戚。

值得停下感受一次「实现有多小」。Reader 的 Monad 实例,完整版:

instance Monad (Reader r) where
  return a = Reader (\_ -> a)                       -- 无视环境,直接给值
  (Reader f) >>= g = Reader $ \r ->
      let a = f r in runReader (g a) r              -- 同一个 r,发给每一步

四行。State 六行,Writer 五行。三个「框架级」的能力,合计十五行库代码——因为语言能直接表达「计算的形状」,模式就不必膨胀成框架。这是上游给你的第三次同款震撼(前两次:Maybe 两行、retry 四行)。

Writer:顺手记一笔

第三种样板:计算主线之外,顺手累积点什么——日志、审计轨迹、消耗的步数。Writer 的分号会把每步「附带产出」自动拼起来(用第 6 章的 Monoid,拼法由类型决定):

shipping :: Order -> Writer [String] Fee
shipping o = do
  tell ["基础运费 8 元"]
  fee <- if weight o > 10
           then do tell ["超重附加 5 元"]; pure 13
           else pure 8
  tell ["合计 " ++ show fee]
  pure fee

-- runWriter (shipping order) ⇒ (13, ["基础运费 8 元","超重附加 5 元","合计 13"])

主线(算运费)和旁线(记明细)在类型上分居:返回值是 (结果, 日志),测试时对日志断言就能验证决策路径——比 mock 一个 Logger 干净得多。注意 tell 的累积用的是第 6 章的 Monoid:日志类型换成 Sum Int 就变成「自动计步器」,换成 Map 就变成「指标聚合器」——旁路通道的语义跟着 Monoid 换,一套代码多种报表。工程提醒:真实项目里 Writer 出场率低于前两位(惰性累积容易攒 thunk,第 16 章的主题;高频日志走 IO 更实际),它的价值更多在心智模型:「返回值旁边挂一条 Monoid 通道」这个形状,在审计、计费、追踪场景里反复出现。

State 的经典案例:纯的随机数

State 还解决着一个你可能没意识到的老问题:随机数是副作用Random.nextInt() 每次调用结果不同——它偷偷改了内部种子,是个如假包换的不纯函数。Haskell 的标准做法把种子摆上台面:

roll :: StdGen -> (Int, StdGen)          -- 吃种子,吐(点数, 新种子)
roll = randomR (1, 6)

-- s -> (a, s) —— 这形状我们刚见过!随机数生成天生就是 State:
rollThree :: State StdGen (Int, Int, Int)
rollThree = do
  a <- state roll
  b <- state roll
  c <- state roll
  pure (a, b, c)

-- evalState rollThree (mkStdGen 2026)  ⇒ 同一种子,永远同三个点数

红利立刻兑现:随机的测试可以复现了。「昨天那个诡异失败」只要记下种子就能一帧不差地重放——第 13 章 QuickCheck 的「种子复现失败用例」正是建在这上面;游戏回放、蒙特卡洛模拟同理。把「隐藏的可变量」显式化为穿线的状态,不纯就变纯——State 是这个手法的通用包装。

收束峡谷:一张速查表

五章的内容,一张表收拢——「分号之间发生什么」:

Monad上下文语义>>= 在分号间干的事下游对应物
Maybe可能没有Nothing 则短路?. 链、Optional
Either e可能失败,带原因Left 则短路并携带错误Result、try/catch 的值化
[](列表)多种可能每种取值都试一遍嵌套循环、回溯搜索
IO要碰真实世界顺序执行,产出喂下一步async/await、suspend
State s携带可演进的状态自动穿线新旧状态Redux、reducer
Reader r依赖只读环境把环境递给每一步依赖注入、Context
Writer w附带累积产出用 <> 拼接旁路输出结构化日志、审计

现在回望,Monad 的「难」基本是错觉的叠加:它不是一个东西难,而是七个简单东西共用一个接口——每个单看都平平无奇(你下游全用过),吓人的是「它们怎么会是同一个东西」。答案:它们的样板恰好都是「分号之间的固定动作」,而 >>= 恰好是给分号编程的钩子。就这样。

⚠ 暗礁 · 别把一切塞进 Monad

新技能综合症预警:学完本卷,你会有三个月看什么都像 Monad。两条刹车。其一,能用普通函数就用普通函数——传个参数就能解决的,不需要 Reader;返回个元组就能解决的,不需要 Writer。Monad 是样板足够多时的止痛药,不是勋章。其二,多种上下文叠加(要状态又要失败又要 IO)会遇到 monad transformer(mtl/ReaderT 模式)——本书划为深水区,刻意不讲:它是真实工程的日常,但对「思想反哺」目标性价比低。第 20 章航线图会给你去处;此刻你只需知道那条河再往上还有水。

✚ 动手泅渡

1. 用 State 的 get/put/modify 写「栈」:push 和 pop 的类型分别是什么?(pop :: State [a] (Maybe a) 是个好起点——两个 Monad 出现在同一个签名里,谁在管什么?)

2. 把第 5 章的挑战题翻出来:foldr (\x k -> k . (x:)) id xs []。现在你认识 State 了——那个「函数当累加器」的 k,和 State 的 s -> (a, s) 有什么亲缘关系?

3. 找出你项目里被传了超过 5 层的参数(config/context/userId 都是常客),数一数有几层是「只传不用」的中间人。Reader/DI 能救掉几层?

4. Redux 对照题:time-travel debugging(回放任意历史状态)为什么要求 reducer 纯?用 State Monad 的语言重述这个理由。

5. 种子实验:如果把 rollThree 的 evalState 换成同一个种子跑两遍,结果一定相同——那「真随机」的程序该把 mkStdGen 的种子从哪来?这个「哪来」属于纯核心还是脏外壳?