ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

fpinscala 状态章节练习 06 详解:实现 Rand.map2 与 RNG 状态传递顺序的决策

fpinscala 状态章节练习 06 详解:实现 Rand.map2 与 RNG 状态传递顺序的决策
  • 示例工程

【免费下载链接】fpinscala

Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"

项目地址:https://gitcode.com/gh_mirrors/fp/fpinscala
点击查看免费下载

本文以 fpinscala 仓库第 6 章(state)练习 06 的官方提示与答案为骨架,深入讲解如何在纯函数式随机数生成器(RNG)之上实现map2组合子,重点剖析"把哪个 RNG 传给哪个参数、以什么顺序传递"这一核心设计决策,并结合仓库源码与属性测试说明如何验证实现行为。读完本文,你将掌握状态线程化组合子的实现套路、三种 RNG 传递方案的后果对比,以及如何用属性测试守住行为边界。

练习背景:Rand 与状态线程化

第 6 章(state)的核心主题是"用纯函数管理状态"。练习 06 所在的state章节在仓库中的目录结构如下:

  • 练习骨架:src/main/scala/fpinscala/exercises/state/State.scala(所有待补全的???都在这份文件里)
  • 完整答案:src/main/scala/fpinscala/answers/state/State.scala(同一章节所有习题的参考实现)
  • 官方提示与答案:answerkey/state/目录下的06.hint.md与06.answer.md

练习的起点是一个纯函数式的随机数生成器:

trait RNG: def nextInt: (Int, RNG) // Should generate a random `Int`.

其参考实现Simple是一个典型的线性同余生成器(LCG),见 answers/state/State.scala:

case class Simple(seed: Long) extends RNG: def nextInt: (Int, RNG) = val newSeed = (seed * 0x5DEECE66DL + 0xBL) & 0xFFFFFFFFFFFFL val nextRNG = Simple(newSeed) val n = (newSeed >>> 16).toInt (n, nextRNG)

关键点在于:nextInt是纯函数——同一个种子必然产生同一个整数,因此必须把"新的种子"(即新的RNG)显式返回出来,供下一次调用使用。这种"每次调用都把状态显式传下去"的写法,就是函数式编程里常说的状态线程化(state threading)。

为了让状态传递不那么啰嗦,仓库定义了一个类型别名:

type Rand[+A] = RNG => (A, RNG)

Rand[A]本质上是"一个函数:接收 RNG,返回(值 A,新的 RNG)"。在此基础上,前面几个练习已经实现了int、unit、map等基本组合子(见 answers/state/State.scala)。练习 06 要做的,就是把两个Rand组合起来。

练习 06 的目标:map2 的类型签名与语义

练习骨架中对应的待补全函数位于 exercises/state/State.scala:

def map2A, B, C(f: (A, B) => C): Rand[C] = ???

它的语义很直观:给定两个随机动作ra和rb,先用ra生成一个A,再用rb生成一个B,最后用纯函数f把两者合并成C,整体仍然是一个Rand[C]。

map2的价值在于:它让"两个随机动作的组合"可以被当作一个整体来复用。后续练习中的sequence、both、randIntDouble等组合子,几乎都建立在map2之上。

官方提示逐句解读(06.hint.md)

练习 06 的官方提示全文如下:

Start by accepting an RNG. Note that you have a choice in which RNG to pass to which function, and in what order. Think about what you expect the behavior to be, and whether your implementation meets that expectation.

这句话浓缩成三个递进的要点:

1. Start by accepting an RNG(从"接受一个 RNG"开始)

map2的返回类型是Rand[C],也就是RNG => (C, RNG)。因此实现的第一步永远是写出:

rng0 => // 从这里开始拆解

所谓"接受一个 RNG",就是先把这个入参rng0写出来,作为整个状态线程化的起点。

2. You have a choice in which RNG to pass to which function, and in what order(把哪个 RNG 传给哪个函数、以什么顺序传,你有选择)

这是本练习最核心的教学点。实现过程中你会面临至少三种方案(详见下一节),提示提醒你:这不是一道"只有一个标准答案"的题,你的选择会直接影响行为。

3. Think about what you expect the behavior to be, and whether your implementation meets that expectation(想清楚你期望的行为,并检查实现是否满足)

写完实现后,要主动问自己:我期望map2(int, int)产生什么?我的实现真的满足了吗?这一步把"能编译"与"行为正确"区分开来,也是后续属性测试的思维起点。

三种 RNG 传递方案对比

假设现在要组合两个随机动作ra和rb,初始 RNG 为rng0。你面临的选择是:rng0到底给谁?中间产生的新 RNG 又给谁?

方案 A:顺序传递(标准答案)

rng0 => val (a, rng1) = ra(rng0) // rng0 给第一个动作 val (b, rng2) = rb(rng1) // 产生的新 RNG 给第二个动作 (f(a, b), rng2)

这是 06.answer.md 给出的实现,也是绝大多数情况下的推荐写法:状态像接力棒一样,从rng0传到rng1再传到rng2,每个动作都在自己独有的随机数序列上取值。

方案 B:反序传递

rng0 => val (b, rng1) = rb(rng0) // 先把 rng0 给 rb val (a, rng2) = ra(rng1) // 再把新 RNG 给 ra (f(a, b), rng2)

答案注释里明确说:"It's not necessarily wrong to do this the other way around, since the results are random anyway."——因为结果本来就是随机的,a和b的生成顺序调换并不会破坏正确性,只是消费随机数序列的顺序不同。从"行为可预期"的角度看,两种方案都是合理的。

方案 C:同源传递(陷阱)

rng0 => val (a, rng1) = ra(rng0) // rng0 给 ra val (b, _) = rb(rng0) // 同一个 rng0 再给 rb! (f(a, b), rng1)

这是最容易写错、也最能暴露问题的一种:把同一个初始 RNG 同时传给ra和rb。由于RNG是纯函数,ra(rng0)与rb(rng0)在ra == rb时必然产生完全相同的值。答案注释点出了后果:

E.g. if both arguments areRNG.intthen we would always get two of the sameIntin the result.

也就是说,map2(int, int)((a, b) => (a, b))会永远返回(n, n)——两个相同的整数。这显然与"生成两个独立的随机数"的直觉期望相悖。更隐蔽的问题是,这种写法还把状态进度"丢了":rb本应消费rng1继续推进序列,结果却回退到rng0重新开始。

答案实现逐行剖析(06.answer.md)

官方答案及其完整注释如下(原文一字未删):

// This implementation of map2 passes the initial RNG to the first argument // and the resulting RNG to the second argument. It's not necessarily wrong // to do this the other way around, since the results are random anyway. // We could even pass the initial RNG to both `f` and `g`, but that might // have unexpected results. E.g. if both arguments are `RNG.int` then we would // always get two of the same `Int` in the result. When implementing functions // like this, it's important to consider how we would test them for // correctness. def map2A, B, C(f: (A, B) => C): Rand[C] = rng0 => val (a, rng1) = ra(rng0) val (b, rng2) = rb(rng1) (f(a, b), rng2)

逐行拆解:

  1. def map2A, B, C(f: (A, B) => C): Rand[C]—— 柯里化签名:先接收两个随机动作,再接收合并函数。之所以把f放在第二个参数列表,是为了方便局部应用,例如map2(double, double)(_ + _)。
  2. rng0 =>—— 返回一个函数,入参就是"接受的那个 RNG",与提示第一句呼应。
  3. val (a, rng1) = ra(rng0)—— 把初始 RNG 交给第一个动作,同时解出值a和新状态rng1。
  4. val (b, rng2) = rb(rng1)—— 把新状态rng1交给第二个动作,绝不复用rng0。
  5. (f(a, b), rng2)—— 合并结果,并把最终状态rng2一并返回,保证"状态线程化"不断裂。

值得注意的是,这个实现与答案中sequence(answers/state/State.scala)的配合:sequence用foldRight反复调用map2把列表里的Rand逐个串起来,之所以顺序不反,正是因为在map2内部状态是从左到右传递的:

def sequenceA: Rand[List[A]] = rs.foldRight(unit(Nil: List[A]))((r, acc) => map2(r, acc)(_ :: _))

如果map2采用方案 B(反序),sequence的结果列表顺序就会受影响——这再次印证了"顺序选择会扩散到整个组合子体系"这一事实。

用测试验证期望行为

答案注释最后一句点明了方法论:"it's important to consider how we would test them for correctness"。仓库中的属性测试正好为我们演示了这一点。

RNG.map2 的测试:不允许出现相同的两个值

在 src/test/scala/fpinscala/exercises/state/RNGSuite.scala 中,map2的测试直接针对了"同源传递"陷阱:

test("RNG.map2")(genRNG ** genCounter): case rng ** counter => val randC = map2(double, double)((d1, d2) => (d1, d2)) assert(checkRND(rng, counter, randC, (d1, d2) => isInInterval(d1) && isInInterval(d2) && d1 != d2))

这里用map2(double, double)组合两个相同的随机动作,然后断言:

  • 两个值都在[0, 1)区间内(isInInterval);
  • 两个值不相等(d1 != d2)。

这条d1 != d2断言,正是对方案 C(同源传递)的"定向打击":如果实现错误地把同一个 RNG 传给两个double,测试就会稳定失败。它把"行为期望"翻译成了可机器执行的属性。

测试底层的checkRND(RNGSuite.scala)还会额外检查"连续两次生成的值不能相同",进一步保证随机数序列真的在推进:

private def checkRNDA: Boolean = @tailrec def loop(rng: RNG, counter: Int, previousValue: Option[A] = None): Boolean = counter < 0 || { val (value, rng2) = generate(rng) if !isCorrect(value) then fail(s"The generated value '$value' is invalid.") else if previousValue.contains(value) then fail(s"The RNG generated two identical values in a row ('$value').") else loop(rng2, counter - 1, Some(value)) } loop(rng, counter)

State.map2 的测试:状态推进两步

map2的思想不止用于Rand。在同一个章节中,State类型上的map2(answers/state/State.scala)用for推导实现了同样的状态线程化:

def map2B, C(f: (A, B) => C): State[S, C] = for a <- underlying b <- sb yield f(a, b)

对应的测试在 src/test/scala/fpinscala/exercises/state/StateSuite.scala:

test("State.map2")(genStringList): list => val (c, s) = stateA.map2(stateB)(printResult).run(list) // a - the result of the initial state, b - the result of the next state val expectedC = printResult(list.headOption, list.drop(1).length) // s - the result of passing through two states (after a and b) assertEquals(c, expectedC) assertEquals(s, list.drop(2))

其中stateA取列表头部、stateB计算剩余长度,两者各自消费一步状态;最终状态断言为list.drop(2)——这精确验证了"状态按顺序推进了两步"的期望,与map2内部ra(rng0)→rb(rng1)的传递逻辑完全一致。

如果想在本地运行 state 章节的测试,仓库 README 提供了 scala-cli 命令,按包名过滤即可(详见 README.md):

scala-cli test . -- 'fpinscala.exercises.state.*'

注意:仓库默认状态下练习骨架是???,所以未完成的习题对应测试会失败;随着你逐个实现练习,测试会逐步转绿。这正是仓库设计成"练习驱动"的原因。

延伸:map2 在组合子体系中的位置

map2并非孤立存在,它是整个状态组合子大厦的基石之一:

  • both与randIntDouble:both(ra, rb)就是map2(ra, rb)((_, _)),用于把两个随机动作打包成一个元组动作(answers/state/State.scala)。
  • sequence:如前述,sequence用foldRight+map2把List[Rand[A]]串成Rand[List[A]](L120-L121)。
  • map2ViaFlatMap:一旦有了flatMap,map2可以退化定义出来:flatMap(ra)(a => map(rb)(b => f(a, b)))(L143-L144)。这说明map2是flatMap与map的组合特例,也预示了第 11 章 Monad 的抽象。
  • State.sequence/State.traverse:在更一般的State[S, A]上,同样的模式支撑起糖果机模拟器Candy.simulateMachine的实现(L192-L197),即用State.traverse(inputs)(i => State.modify(update(i)))依次处理投币与转动旋钮的输入序列。

因此,练习 06 虽小,却直接关系到你对后续sequence、flatMap乃至 Monad 抽象的理解——理解 RNG 传递顺序,就是理解"状态线程化"这一贯穿全书的主题。

小结

  • map2的实现第一步是"接受一个 RNG",第二步是决定 RNG 的传递顺序;
  • 三种方案中,"顺序传递"(初始 RNG 给第一个动作、新 RNG 给第二个动作)是标准答案;反序传递不破坏随机正确性;而"同源传递"会让map2(int, int)永远产出两个相同的整数,是必须避免的陷阱;
  • 判断实现是否正确的关键是明确行为期望:期望独立随机性,就用d1 != d2这样的属性测试把它固化下来(参见 RNGSuite.scala);
  • map2是sequence、both、map2ViaFlatMap以及State泛化版本的基础,理解它对理解整章乃至后续 Monad 章节都有直接帮助。

参考文件索引:官方提示 answerkey/state/06.hint.md、官方答案 answerkey/state/06.answer.md、练习骨架 exercises/state/State.scala、完整答案 answers/state/State.scala、相关测试 RNGSuite.scala 与 StateSuite.scala。

  • 示例工程

【免费下载链接】fpinscala

Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"

项目地址:https://gitcode.com/gh_mirrors/fp/fpinscala
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进