- 示例工程
【免费下载链接】fpinscala
Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"
本文围绕《Functional Programming in Scala》配套仓库 fpinscala 中 answerkey/laziness/10.answer.md 的官方解答展开,讲解如何利用 Chapter 2 学过的"递归辅助函数"模式,在惰性求值的LazyList之上构造一个永不终止的斐波那契数列,并给出仓库源码、测试用例与unfold变体的对照分析。读完本文,你将掌握按名参数(by-name)与LazyList.cons的惰性语义、无限流的递归构造技巧,以及如何在仓库中验证你的实现。
一、练习背景:Chapter 5(laziness)中的第 10 题
fpinscala 仓库按书籍章节组织练习,Chapter 5 对应laziness包。练习文件 src/main/scala/fpinscala/exercises/laziness/LazyList.scala 中,第 10 题的待实现桩代码位于object LazyList内:
lazy val fibs: LazyList[Int] = ???题目要求:构造一个表示无限斐波那契数列的LazyList[Int],其元素依次为0, 1, 1, 2, 3, 5, 8, 13, ...。关键约束是它必须是一个无限的惰性列表——不能提前把数列"算完"(也永远算不完),只能按需(on demand)生成下一个元素。
对应提示文件 answerkey/laziness/10.hint.md 给出解题方向:
Chapter two discussed writing loops functionally, using a recursive helper function - how would that apply here?
即:第二章(gettingstarted)讨论过如何用递归辅助函数以函数式方式表达循环,本题应当沿用这一思路——只是这里的"循环"被嵌入惰性列表的构造中。
二、官方解答:递归辅助函数驱动的斐波那契流
answerkey/laziness/10.answer.md 给出的完整解答只有六行:
val fibs = def go(current: Int, next: Int): LazyList[Int] = cons(current, go(next, current + next)) go(0, 1)逐行拆解:
def go(current: Int, next: Int): LazyList[Int]:定义局部递归辅助函数go。它携带两个相邻的斐波那契数current与next作为"循环变量",这正是把命令式循环(while+ 两个中间变量)翻译成函数式递归的标准手法;cons(current, go(next, current + next)):把当前的current作为列表头,而列表尾是递归调用go(next, current + next)产生的新流。由于cons的尾参数是按名传递的,这一步递归在访问到该尾元素之前根本不会执行;go(0, 1):以斐波那契的前两项0、1作为初始状态启动生成器。
展开看前几个元素:go(0,1)的头是0,尾是go(1,1);go(1,1)的头是1,尾是go(1,2);go(1,2)的头是1,尾是go(2,3)……依次得到0, 1, 1, 2, 3, 5, ...,与斐波那契数列完全一致。
注意val fibs被声明为lazy val(练习桩代码中也是lazy val fibs: LazyList[Int] = ???)。这意味着fibs本身在首次被访问前不会求值,首次访问时才执行go(0, 1)并缓存结果——后续所有访问都复用同一个流对象,不会反复重建。
三、惰性机制:为什么"无限"也能安全构造
要理解这段代码为何不会栈溢出或无限递归,需要回到LazyList的数据结构与cons实现。在 src/main/scala/fpinscala/answers/laziness/LazyList.scala 中可以看到:
enum LazyList[+A]: case Empty case Cons(h: () => A, t: () => LazyList[A])Cons的两个字段都是函数:h: () => A在首次访问头元素时才调用,t: () => LazyList[A]在访问尾时才调用。而伴生对象中的智能构造器cons进一步用lazy val缓存求值结果:
def consA: LazyList[A] = lazy val head = hd lazy val tail = tl Cons(() => head, () => tail)关键在于:
- 按名参数:
hd、tl是=>类型,传入的表达式(包括go(next, current + next)这个递归调用)在cons内部没有被立即求值,而是被包装进lazy val; - 按需展开:只有当你调用
fibs.take(10)、fibs.headOption或foldRight等操作真正"拉动"流时,才会触发对应位置的h()/t()求值,从而向前展开一层; - 每一层递归都发生在"将来":
go的递归调用只出现在列表尾的按名参数里,因此在构造当前Cons单元时不会触发下一层,无限递归被惰性语义"冻结"住了。
这正是 Chapter 5 核心概念"非严格求值(non-strictness)"的体现:LazyList的构造过程本身不求值任何东西,只有消费过程才逐步触发求值,因此可以表达"无限"的数据结构。
四、与unfold实现(练习 12)的对比
第 12 题要求用通用展开函数unfold重新实现各种无限流,其中就包含斐波那契。unfold的标准实现见 answerkey/laziness/11.answer.md:
def unfoldA, S(f: S => Option[(A, S)]): LazyList[A] = f(state) match case Some((h,s)) => cons(h, unfold(s)(f)) case None => empty而 answerkey/laziness/12.answer.md 给出的fibsViaUnfold:
val fibsViaUnfold: LazyList[Int] = unfold((0,1)): case (current, next) => Some((current, (next, current + next)))两种写法在数学上是完全等价的:
| 维度 | 练习 10:go递归辅助函数 | 练习 12:fibsViaUnfold |
|---|---|---|
| 状态 | 函数参数(current, next)隐式传递 | 显式状态元组(0, 1)在unfold中传递 |
| 转移逻辑 | go(next, current + next)递归调用 | Some((current, (next, current + next)))返回状态转换 |
| 终止条件 | 无(无限流,永不返回Empty) | unfold的f永不返回None |
| 本质 | 手写"状态转移循环" | 把状态转移抽象成S => Option[(A, S)]函数 |
练习 10 先让学生亲手"展开"循环逻辑,练习 12 再将其抽象进unfold——这是教材中典型的"先具体后抽象"训练路径。fibsViaUnfold中的模式匹配写法case (current, next) =>是 Scala 3 的简洁语法,等价于p => p match { case (f0, f1) => ... }。
五、仓库测试如何验证这一解答
仓库为懒列表提供了基于属性测试(property-based testing)的验证套件 src/test/scala/fpinscala/exercises/laziness/LazyListSuite.scala。其中针对fibs的测试:
test("LazyList.fib")(genLengthOfFibonacciSeq): n => assertEquals(fibs.take(n).toList, theFirst21FibonacciNumbers.take(n).toList)它随机选取长度n(0 到前 21 个斐波那契数的长度之间),断言fibs.take(n).toList与预置的基准序列前n项完全一致。基准数据定义在 src/test/scala/fpinscala/exercises/common/Common.scala:
lazy val theFirst21FibonacciNumbers = IndexedSeq(0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765)同文件还定义了genLengthOfFibonacciSeq: Gen[Int] = Gen.choose(0, theFirst21FibonacciNumbers.length),用Gen.choose在合法区间内生成随机测试长度。类似的测试也覆盖了fibsViaUnfold:
test("LazyList.fibsViaUnfold")(genLengthOfFibonacciSeq): n => assertEquals(fibsViaUnfold.take(n).toList, theFirst21FibonacciNumbers.take(n).toList)也就是说,无论你采用练习 10 的递归辅助函数写法,还是练习 12 的unfold写法,最终都要通过同一份基准序列的校验,这从测试层面印证了两种实现的结果一致性。
六、延伸思考:栈安全与性能
一个值得注意的细节是:go的递归调用位于cons的按名尾参数中,因此它并不是严格意义上的尾递归(也没有加@annotation.tailrec)。但这并不构成问题:
- 构造是惰性的:
go的递归只有在该位置被"拉动"时才发生,每次只展开一层,不会出现一次性深递归; - 展开是增量式的:典型消费操作(如
take、headOption)只展开有限的若干层就停止,剩余部分始终保持未求值状态; - 缓存避免重复计算:由于
cons内部用lazy val缓存了head与tail,同一个流节点被多次访问时不会重复执行生成逻辑,例如fibs.take(10).toList与再次遍历fibs前 10 项,头部的h()只会被求值一次。
相比之下,answerkey/laziness/09.answer.md 中from(n)的写法cons(n, from(n + 1))与本解答结构完全同构——go可以看作from的"带双状态版本",这也是 Chapter 5 系列练习的递进设计:从单状态递增(from)到双状态斐波那契(fibs),再到通用展开(unfold)。
七、动手验证:在仓库中运行测试
仓库 README(README.md)说明该项目基于 Scala CLI 构建,Chapter 5 对应laziness包。在仓库根目录可执行:
# 编译全部练习与解答 scala-cli compile . # 启动 REPL 并查看斐波那契流 scala-cli console . scala> import fpinscala.exercises.laziness.LazyList.* scala> fibs.take(10).toList // List(0, 1, 1, 2, 3, 5, 8, 13, 21, 34) # 运行 laziness 包的全部单元测试 scala-cli test . -- 'fpinscala.exercises.laziness.*'注意:练习桩代码中fibs仍为???,直接运行测试会失败;当你参照本文解答在练习文件中实现后,LazyList.fib与LazyList.fibsViaUnfold两个测试即可通过。参考实现位于 src/main/scala/fpinscala/answers/laziness/LazyList.scala 的object LazyList中(第 202~205 行)。
小结
练习 10 的官方解答虽只有六行,却浓缩了三个核心知识点:以递归辅助函数表达函数式"循环"(Chapter 2 思想的复用)、以按名参数与cons的lazy val缓存实现惰性求值、以及"无限结构 + 按需展开"的LazyList编程范式。掌握它之后,无论是unfold重构(练习 12)还是后续章节的流式 IO,都建立在这一套"状态转移 + 惰性尾递归"的思维之上。
- 示例工程
【免费下载链接】fpinscala
Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"
相关推荐
fpinscala 惰性求值练习精解:用 LazyList 递归构造无限整数序列(from 与 fromViaUnfold)
fpinscala 惰性求值练习精解:用 LazyList 递归构造无限整数序列(from 与 fromViaUnfold) 本篇围绕《Functional P
示例工程fpinscala 练习精解:用 Scala 3 尾递归实现斐波那契数列(第 2 章 Exercise 1)
fpinscala 练习精解:用 Scala 3 尾递归实现斐波那契数列(第 2 章 Exercise 1) 导读 本文围绕 fpinscala 仓库第 2 章
示例工程fpinscala 实战:用尾递归函数实现斐波那契数列——gettingstarted 第 1 题(01.hint.md)完整解读
fpinscala 实战:用尾递归函数实现斐波那契数列——gettingstarted 第 1 题(01.hint.md)完整解读 导读 本文围绕 fpinsc
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考