☰
fpinscala 第 4 章练习精解:用 sequence 将 List[Option[A]] 转换为 Option[List[A]]
2026/10/12 1:40:13 网站建设 项目流程
  • 示例工程

【免费下载链接】fpinscala

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

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

导读

本文围绕《Functional Programming in Scala》配套仓库 fpinscala 中第 4 章错误处理(errorhandling)的练习 04 展开,详细拆解sequence函数的两种实现:显式递归与foldRight+map2组合实现。你将掌握「把一批可能失败的计算聚合成一次失败」的核心技巧,理解 Scala 子类型化 ADT 在类型推断上带来的陷阱,并通过仓库源码、属性测试与后续章节的抽象推广,看清这一模式从Option一路泛化到Either、Validated乃至通用Monad的完整脉络。

练习背景:第 4 章的错误处理与Option

本练习位于answerkey/errorhandling/04.answer.md,对应书中第 4 章「错误处理」(错误处理不一定是抛出异常,而是用返回值表达失败)。仓库用enum自定义了一套Option类型(exercises 版本),并刻意隐藏了标准库的Option:

// Hide std library `Option` since we are writing our own in this chapter import scala.{Option as _, Some as _, None as _} enum Option[+A]: case Some(get: A) case None

练习 04 之前,前几题已经依次实现了map、getOrElse、flatMap、orElse、filter、variance与map2。其中map2的参考实现(见 03.answer.md)是本题的关键前置:

def map2A,B,C(f: (A, B) => C): Option[C] = a.flatMap(aa => b.map(bb => f(aa, bb)))

map2把两个独立的Option组合成一个:只有当两个值都存在时才返回Some(f(aa, bb)),否则返回None。它是理解sequence的「胶水」函数。

练习目标:把List[Option[A]]翻转成Option[List[A]]

练习题目要求在 exercises 的 Option.scala 中实现:

def sequenceA: Option[List[A]] = ???

语义很直观:给定一个元素均为Option的列表,如果全部元素都是Some,则返回Some(所有值组成的列表);只要存在任何一个None,整个结果就是None。这正是「把一批可能失败的计算聚合成一次失败」的短路语义。

配套的 04.hint.md 给出两条提示:一是用模式匹配拆分列表,在 cons 分支递归调用sequence;二是用foldRight替你处理递归。

方案一:显式递归(模式匹配驱动)

04.answer.md给出的第一种实现直接、清晰:

def sequenceA: Option[List[A]] = as match case Nil => Some(Nil) case h :: t => h.flatMap(hh => sequence(t).map(hh :: _))

逐行解读:

  • case Nil => Some(Nil):空列表直接成功,返回Some(Nil),这是递归的终止条件。
  • case h :: t => ...:把列表拆成头部h: Option[A]与尾部t: List[Option[A]]。若头部是None,h.flatMap(...)立即得到None,整个递归短路返回;若头部是Some(hh),则先递归处理尾部sequence(t),再通过.map(hh :: _)把当前值hh拼到结果列表头部。

这里的关键在于flatMap承担了双重职责:一方面「解包」头部可能存在的值,另一方面一旦遇到None就让失败沿递归向上传播。这与 answers 版本实现完全一致,可以直接对照验证。

方案二:foldRight+map2,以及必须的类型注解

04.answer.md给出的第二种实现,是把递归交给foldRight、把元素组合交给map2:

def sequence_1A: Option[List[A]] = as.foldRight[Option[List[A]]](Some(Nil))((a, acc) => map2(a, acc)(_ :: _))

这里foldRight的初始值取Some(Nil),组合函数(a, acc) => map2(a, acc)(_ :: _)把当前元素a与已折叠出的结果acc用map2连接——_ :: _即(hh, t) => hh :: t。由于foldRight从右往左处理,元素顺序得以保持。

这是本练习最重要的知识点。文档明确指出:foldRight上的类型注解[Option[List[A]]]是必须的;去掉它,Scala 会把折叠结果类型错误地推断为Some[Nil.type],进而报类型错误(可以亲手删掉注解试一试)。原因在于:Scala 用子类型化(subtyping)来编码代数数据类型(ADT)——Some[Nil.type]是Some[List[A]]的子类型,编译器在无法确定B的统一类型时,会选取过于具体的Some[Nil.type]作为foldRight的结果类型,导致后续与acc的组合无法通过类型检查。

这是一个在 Scala 3 枚举 ADT 下反复出现的典型陷阱:给多态高阶函数(如foldRight)传 ADT 构造器字面量(如Some(Nil))时,务必显式标注预期的结果类型。同样的注解手法在后面的 traverse_1(as.foldRight[Option[List[B]]](Some(Nil))(...))中再次出现,值得记住。

用属性测试验证两种实现

仓库为练习配备了基于属性测试的验证用例,见 OptionSuite.scala:

test("Option.sequence")(genOptionSeq): optionList => val expected: Option[List[Int]] = if optionList.contains(None) then None else Some(optionList.flatMap(_.map(List(_)).getOrElse(List.empty[Int]))) assertEquals(Option.sequence(optionList), expected)

测试用Gen生成三类输入(见 genOptionSeq):含None的列表、全Some的列表、以及恒为List(None)的边界情况,并断言:列表只要包含None结果就是None,否则把各元素值取出来拼成Some[List[Int]]。跑通该测试即可验证你的实现语义正确。

在仓库根目录可用 Scala CLI 运行该测试(构建方式见 README.md):

scala-cli test . -- 'fpinscala.exercises.errorhandling.OptionSuite.Option.sequence'

从 sequence 到 traverse:更通用的形态

sequence有一个更通用的兄弟函数traverse,它允许对每个元素先做一次可能失败的计算再聚合。紧跟其后的练习 05 给出了参考实现(见 05.answer.md):

def traverseA, B(f: A => Option[B]): Option[List[B]] = as match case Nil => Some(Nil) case h::t => map2(f(h), traverse(t)(f))(_ :: _) def traverse_1A, B(f: A => Option[B]): Option[List[B]] = as.foldRight[Option[List[B]]](Some(Nil))((h,t) => map2(f(h),t)(_ :: _)) def sequenceViaTraverseA: Option[List[A]] = traverse(as)(x => x)

注意traverse的结构与sequence几乎同构——把「头部已经是Option」换成「对头部应用f得到Option」。而sequence完全可以用traverse(as)(x => x)(恒等函数)来表达,answers 源码正是这样实现的。实际工程中优先写traverse而不是sequence,因为它覆盖的场景更广(例如解析字符串列表为整数列表),且避免了先构造List[Option[B]]的中间层。

模式推广:Either、Validated 与通用 Monad

sequence/traverse的价值在于它是一个可复用的组合子,仓库中多处体现了这一模式的推广:

  • Either:在 answers/errorhandling/Either.scala 中,traverse/sequence把List[Either[E, A]]聚合为Either[E, List[A]],语义与Option版本一致——遇到第一个Left即短路。
  • Validated:在 Validated.scala 中,Invalid携带的是List[E],其map2会把两边的错误累加合并而非短路,因此sequence能一次性收集所有校验错误——这正是表单校验等场景需要的「累积错误」语义,与Either的「快速失败」形成对照。
  • 通用Monad:到了第 11 章,answers/monads/Monad.scala 把同一模式抽象到了任意单子F:
def sequenceA: F[List[A]] = fas.foldRight(unit(List[A]()))((fa, acc) => fa.map2(acc)(_ :: _)) def traverseA, B(f: A => F[B]): F[List[B]] = as.foldRight(unit(List[B]()))((a, acc) => f(a).map2(acc)(_ :: _))

对比可见,第 4 章在Option上学到的foldRight+map2写法,正是后续通用抽象的原型。理解本题,等于为理解 Monad 的sequence/traverse与第 12 章Applicative的Traverse(见 applicative/Traverse.scala)打下了地基。

小结与自查清单

  • 短路语义:sequence遇到任一None即整体失败;全部成功才返回Some。
  • 两种写法:显式递归(flatMap+map拼接)与foldRight+map2,二者等价。
  • 类型注解陷阱:foldRight的初始值若为 ADT 构造器(如Some(Nil)),必须显式标注结果类型Option[List[A]],否则 Scala 子类型推断会误选Some[Nil.type]导致编译失败。
  • 优先traverse:sequence可写作traverse(as)(x => x),更通用的形态覆盖更多场景。
  • 模式复用:同一组合子贯穿Either、Validated与通用Monad/Applicative,是函数式错误处理的核心构件。

动手验证时,先在 exercises/errorhandling/Option.scala 中完成sequence,再对照 answers/errorhandling/Option.scala 的参考实现,最后运行OptionSuite中的Option.sequence属性测试确认语义正确。

  • 示例工程

【免费下载链接】fpinscala

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

项目地址:https://gitcode.com/gh_mirrors/fp/fpinscala
点击查看免费下载
上一篇:终极指南:OR-Tools与Docker集成的跨平台部署最佳实践
下一篇:FastSAM 完整指南:如何在 50 倍速度下实现精准图像分割

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询