- 示例工程
【免费下载链接】fpinscala
Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"
导读
本文围绕《Functional Programming in Scala》配套仓库 fpinscala 中第 3 章(datastructures)练习 18 的官方提示与参考答案展开:如何用foldRight而不是显式递归来实现自定义代数数据类型List[A]上的map函数。读完本文,你将掌握"用折叠表达列表变换"的思维套路、foldRight栈不安全的成因、借助foldRightViaFoldLeft的栈安全变体,以及一种在纯函数式外壳下使用局部可变缓冲区的实用写法,并了解仓库中对应的源码与属性测试如何验证这些实现。
练习背景:自定义 List 与 map 的签名
fpinscala 仓库第 3 章的练习围绕一个自定义的代数数据类型展开,定义在 exercises 版 List.scala:
enum List[+A]: case Nil case Cons(head: A, tail: List[A])练习 18 要求实现的方法签名位于同一文件的 map 桩代码:
def mapA,B: List[B] = ???即对列表中的每个元素应用函数f,构造出一个新的、元素类型为B的列表,且不修改原列表。这是函数式编程中"结构保持变换"(structure-preserving transformation)的典型代表——map只替换每个元素的值,不改变列表的骨架形状。
官方提示解读:让 foldRight 替你递归
练习 18 的官方提示 只有一句话:
Again, try using
foldRight. You shouldn't need to resort to an explicitly recursive function.
这句提示有两个要点:
用
foldRight,不要手写显式递归。前一个练习 17(doubleToString)的提示同样是"try usingfoldRight",练习 17 的答案 展示了完全相同的套路:def doubleToString(l: List[Double]): List[String] = foldRight(l, Nil: List[String], (h,t) => Cons(h.toString,t))对比可见,
map与doubleToString是同一模式:遍历列表、对每个元素做变换、重新组装Cons。doubleToString只是map在f = (d: Double) => d.toString时的一个特例。提示与答案配合使用。仓库 README 说明了使用方式:遇到卡住的练习时,先看
answerkey/datastructures/18.hint.md,再对照answerkey/datastructures/18.answer.md与完整答案源码src/main/scala/fpinscala/answers/datastructures/List.scala。
参考答案一:基于 foldRight 的直接实现
练习 18 的参考答案 给出的第一个版本:
def mapA,B: List[B] = foldRight(l, Nil:List[B], (h, t) => Cons(f(h), t))理解这段代码的关键是foldRight的语义。仓库中 answers 版 foldRight 定义为:
def foldRightA, B => B): B = as match case Nil => z case Cons(x, xs) => f(x, foldRight(xs, z, f))一种看待foldRight的经典方式是:它把列表中的Nil构造器替换为初始值z,把Cons构造器替换为函数f。对map而言:
z取Nil: List[B],即空列表映射后仍为空;f取(h, t) => Cons(f(h), t),即"把变换后的元素f(h)与已变换的尾部t重新拼装"。
以List(1, 2, 3)(即Cons(1, Cons(2, Cons(3, Nil))))和f = _ + 1为例,展开过程为:
foldRight(Cons(1, Cons(2, Cons(3, Nil))), Nil, (h,t) => Cons(h+1, t)) = Cons(2, foldRight(Cons(2, Cons(3, Nil)), Nil, ...)) = Cons(2, Cons(3, foldRight(Cons(3, Nil), Nil, ...))) = Cons(2, Cons(3, Cons(4, foldRight(Nil, Nil, ...)))) = Cons(2, Cons(3, Cons(4, Nil)))整个过程没有使用任何显式递归,递归由foldRight内部承担,这正是提示想引导你领悟的抽象层次提升:与其反复手写模式匹配递归,不如把"遍历+重组"的骨架交给折叠,自己只提供"如何变换"的细节。
练习 19:同样的套路实现 filter
这种"用折叠定义遍历型函数"的模式在本章是成系列出现的。紧接其后的 练习 19 提示("Again, try usingfoldRight!")与 练习 19 答案 用同一思路实现filter:
def filterA: List[A] = foldRight(l, Nil: List[A], (h, t) => if f(h) then Cons(h, t) else t)map与filter的差别仅在于重组时保留元素的条件:map无条件保留变换后的元素,filter则按谓词决定是Cons(h, t)还是直接丢弃头部返回t。掌握了map,filter便水到渠成。
参考答案二:栈安全的 foldRightViaFoldLeft 变体
参考答案随后指出一个重要事实:当前实现的foldRight并非栈安全(stack-safe)。原因从它的定义可见——f(x, foldRight(xs, z, f))中递归调用发生在f的参数位置(非尾调用),每处理一个元素都会占用一个栈帧,列表很长时会导致StackOverflowError。
参考答案给出的变体一 借助foldRightViaFoldLeft规避栈溢出:
def map_1A,B: List[B] = foldRightViaFoldLeft(l, Nil:List[B], (h, t) => Cons(f(h), t))仓库中foldRightViaFoldLeft的实现(answers 版 List.scala)是:
def foldRightViaFoldLeftA, B => B): B = foldLeft(reverse(l), acc, (b, a) => f(a, b))其原理是"先反转、再左折叠":foldLeft是尾递归的(仓库中 foldLeft 带@annotation.tailrec注解),逐元素入栈不增长;reverse(l)之后左折叠恰好以"从原列表尾部到头部"的顺序访问元素,配合(b, a) => f(a, b)交换参数顺序,等效还原了右折叠的结合顺序。代价是额外的O(n)遍历,换来恒定的栈空间。
值得留意的是,答案注释中还提到 foldRightViaFoldLeft 的另两种基于函数组合的变体(foldRightViaFoldLeft_1、foldLeftViaFoldRight)——它们通过构建B => B的函数链模拟正确的结合顺序,但注释明确说明这类实现"更多是理论上的趣味,并不栈安全,不适用于大列表"。
参考答案三:局部可变缓冲区变体(更常见的生产写法)
参考答案的变体二 采用 Scala 标准库的ListBuffer:
def map_2A,B: List[B] = val buf = new collection.mutable.ListBuffer[B] def go(l: List[A]): Unit = l match case Nil => () case Cons(h, t) => buf += f(h); go(t) go(l) List(buf.toList*) // converting from the standard Scala list to the list we've defined here它的核心设计是**"外部纯函数、内部局部可变"**:
- 递归遍历(
go)把变换结果f(h)逐个追加进缓冲区,go是尾递归,栈空间恒定; - 缓冲区
buf完全在函数内部分配,对外不可见; - 最后通过
List(buf.toList*)把 Scala 标准列表转回本仓库自定义的List(变参构造apply,见 exercises 版 apply)。
答案注释强调:这种局部突变不会破坏引用透明性(referential transparency),因为突变仅发生在函数自己分配的数据结构上,外部观察者无法区分它与纯函数实现——这在纯函数式编程中是一种被认可的实现手法。事实上,本章init的答案(answers 版 init2)与练习 19 的filter_2(answers 版 filter_2)都采用了完全相同的模式,说明这是作者在严格求值列表上的惯用策略;答案也预告了第 5 章惰性列表(lazy list/stream)出现后通常不再需要这种技巧。
从 map 到 flatMap:组合优于手写
练习 20 紧接着要求实现flatMap。练习 20 的答案 指出它可以由已有函数直接组合而成:
def flatMapA,B: List[B] = concat(map(l, f))map(l, f)得到List[List[B]],再经concat(answers 版 concat,即foldRight(l, Nil: List[A], append))展平一层,就得到List[B]。答案注释也提到"也可直接用foldRight实现",但它更推崇用组合的方式"让代码的正确性更显而易见、更易读"。这与map练习的精神一脉相承:积累一组由折叠定义的原子操作,再用组合搭建更复杂的功能。
测试验证:ListSuite 如何检验 map
仓库为练习提供了基于 munit 与自研属性测试框架(PropSuite)的测试套件,见 ListSuite.scala:
test("List.map")(genIntList): list => assertEquals( List.map(list, _ * 2), scalaListToList(listToScalaList(list).map(_ * 2)) )测试用随机生成的整数列表(genIntList)驱动,把自定义List的实现结果与 Scala 标准库List.map的结果逐项比对,覆盖了空列表、单元素、任意长度等随机场景;同文件还有 doubleToString 测试、filter 测试、flatMap 测试 等,构成练习 17~20 的完整验证闭环。
在仓库中运行这些测试(基于 Scala CLI,版本见 build.scala,Scala 3.3.4 + munit 0.7.29;sbt 构建同样可用,见 build.sbt 与 project/build.properties):
scala-cli test . -- 'fpinscala.exercises.datastructures.*'注意 README 的提示:在完成练习桩代码之前运行测试会出现失败,这正是"以测试驱动练习"的设计意图——随着你填好map等桩代码,对应测试会陆续转绿。你也可以用scala-cli console .进入 REPL,import fpinscala.exercises.datastructures.List后直接验证:
scala> List.map(List(1,2,3), _ + 1) res0: fpinscala.exercises.datastructures.List[Int] = Cons(2,Cons(3,Cons(4,Nil)))小结
练习 18 的价值不在于"写出 map",而在于学会识别"用折叠代替显式递归"的模式,并理解其代价与对策:
| 实现变体 | 核心机制 | 栈安全 | 适用场景 |
|---|---|---|---|
map(foldRight) | 用foldRight替换Nil与Cons | 否(严格求值、非尾调用) | 教学示范、中小列表 |
map_1(foldRightViaFoldLeft) | 反转 + 尾递归foldLeft | 是 | 需要栈安全的大列表 |
map_2(局部 ListBuffer) | 尾递归 + 函数内局部可变缓冲区 | 是 | 生产中最常见的务实写法,且保持引用透明 |
配合官方提示"try usingfoldRight"、答案源码(answers 版 List.scala)以及属性测试(ListSuite.scala),你可以完整走通"读提示 → 写实现 → 对照答案 → 跑测试"的学习闭环,为后续filter、flatMap、zipWith等系列练习打下同一套方法论基础。
- 示例工程
【免费下载链接】fpinscala
Code, exercises, answers, and hints to go along with the book "Functional Programming in Scala"
相关推荐
fpinscala 练习解析:用 foldRight 实现 List.length(datastructures 第 9 题)
fpinscala 练习解析:用 foldRight 实现 List.length(datastructures 第 9 题) 本文围绕《Functional
示例工程fpinscala 练习 18 详解:为自定义 List 实现 map 的三种方案与栈安全取舍
fpinscala 练习 18 详解:为自定义 List 实现 map 的三种方案与栈安全取舍 导读 本文围绕《Functional Programming i
示例工程fpinscala 第 3 章练习 16 详解:用 foldRight 实现 incrementEach,把列表每个元素加 1
fpinscala 第 3 章练习 16 详解:用 foldRight 实现 incrementEach,把列表每个元素加 1 导读 incrementEach
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考