- 示例工程
【免费下载链接】fpinscala
Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"
本文以 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 are
RNG.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)逐行拆解:
def map2A, B, C(f: (A, B) => C): Rand[C]—— 柯里化签名:先接收两个随机动作,再接收合并函数。之所以把f放在第二个参数列表,是为了方便局部应用,例如map2(double, double)(_ + _)。rng0 =>—— 返回一个函数,入参就是"接受的那个 RNG",与提示第一句呼应。val (a, rng1) = ra(rng0)—— 把初始 RNG 交给第一个动作,同时解出值a和新状态rng1。val (b, rng2) = rb(rng1)—— 把新状态rng1交给第二个动作,绝不复用rng0。(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"
相关推荐
fpinscala 状态章节练习解析:用 flatMap 重新实现 map 与 map2(Rand/State 第 6 章)
fpinscala 状态章节练习解析:用 flatMap 重新实现 map 与 map2(Rand/State 第 6 章) 导读 本篇文章围绕《Functio
示例工程fpinscala 练习精讲:用多态函数与尾递归实现 `isSorted` 有序性检测(第 2 章练习 2 详解)
fpinscala 练习精讲:用多态函数与尾递归实现 isSorted 有序性检测(第 2 章练习 2 详解) 本文以 answerkey/gettingsta
示例工程fpinscala 第 6 章状态传递精解:intDouble、doubleInt 与 double3 组合随机生成器的实现原理
fpinscala 第 6 章状态传递精解:intDouble、doubleInt 与 double3 组合随机生成器的实现原理 导读 本文围绕 fpinsca
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考