Scala 11:Iterator、View 与 LazyList,延迟之后发生什么
同一份变换读两次,为什么有时没数据,有时又算一遍
把订单行转换成金额,只取前两行,然后再次读取。Iterator 可能从第三行继续,View 可能重新执行前两行的变换,LazyList 则可能复用已经计算的结果。它们都能写成 map 后接 take,但“延迟”没有说明消费位置、缓存和对象保留的差异。
这个问题不能只用返回列表是否相等来回答。两个结果一样,回调可能执行两次;第二次结果为空,可能是游标已经耗尽;回调没有重跑,可能是结果被缓存并继续占用内存。需要同时观察值、执行次数和引用关系,才能判断某个接口是否适合订单预览、重复报价或持续流式读取。
本章固定 Scala 3.3.7、标准库 2.13.16 和 JDK 21。测试用有限消费控制无限来源,用计数器观察计算,用对象身份观察记忆化。本章没有堆转储或 GC 回收实验,因此不报告释放字节数,也不把保留引用的推导冒充实测泄漏规模。
Iterator 保存消费位置
1 | |
创建映射后的 Iterator 时,映射函数尚未对元素运行。take(2) 形成受限消费,toList 才拉取并保存两个结果。原来的 it 已经推进了两个元素,继续消费自然只剩第三个。再次消费得到空列表,不是计算被记忆化为 None,而是这个迭代器没有剩余元素。
因此,Iterator 是带位置的消费接口。把它保存到两个变量,并不会自动获得两个独立游标。一个调用方读取之后,另一个调用方看到的位置也可能变化。若订单校验和金额汇总都要读取完整数据,应提供能够创建新迭代器的数据源,或明确物化一次结果供二者共享。
hasNext 与 next 的职责也应区分。普通 next 才取得下一项,但某些经过过滤的迭代器为了回答 hasNext,可能需要向上游读取并寻找下一个满足条件的元素。不能把“只调用 hasNext”普遍当成完全无计算或无副作用。正确的接口约定应围绕消费操作及上游协议,而不是仅凭方法名猜测。
耗尽后继续调用 next 通常会失败;把 Iterator 当成永远存在的序列,需要人为引入错误。正常路径应检查消费协议,或者使用集合组合器表达有限需求。本文用 toList 观察剩余内容,避免通过异常控制普通结束条件。
View 保存变换描述,重复遍历可以重新计算
1 | |
这个 View 建立在可重复遍历的 List 上。每次消费都能从来源重新获得元素,map 的结果没有因为上一次消费而自动永久缓存,所以两次取前两项让计数器增加到四。这与 Iterator 的单次推进不同,也与 LazyList 的按节点记忆化不同。
这里必须保留“建立在 List 上”的限定。任意 View 的行为还取决于来源是否稳定、是否可重入,以及具体操作怎样实现。尤其是针对可变来源的视图,它可能在稍后消费时看到更新后的元素。一个稳定的 view 变量只固定了这个视图对象,并没有把来源冻结成快照。
View 可以避免某些严格中间集合。例如连续 map、filter、take,消费少量结果时不必先产生全部中间列表。但避免中间容器不代表所有操作都可以只看少量元素。排序需要取得足够数据形成整体顺序,求总和必须读取全部所需元素,过滤前两项也可能需要检查很多候选。需求决定读取量,惰性只改变执行时点和组合方式。
把有副作用的转换放入 View,会使重复遍历重复副作用。若 map 内部扣减库存,预览一次和提交一次可能扣减两次;若它只是纯金额计算,重复执行主要影响成本。是否可以安全重复,取决于变换本身的语义,View 不提供“业务操作仅执行一次”的保证。
LazyList 缓存已求出的节点结果
1 | |
LazyList.from(1) 表示从一开始不断生成整数的来源。map 同样延迟,首次消费前三项时才创建三个 Box。再次从同一个 retainedHead 读取这三项,使用已经计算的结果,计数器仍然是三,而且首对象身份相同。这个实验把“没有重复执行”与“复用同一结果对象”联系起来。
继续读取第四项,计数器增加到四。记忆化不意味着构造时已经计算了全部结果,它只保留实际求过的部分。无限来源能在程序中被表达,是因为消费受 take 等操作限制;直接对无限来源求完整 toList,不会因为类型叫 LazyList 就自动终止。
同一个表达式重新调用也不必共享缓存。如果把构造 LazyList 的代码放进 def,每次调用都创建一份新来源,那么每份来源各自记忆化。把 def 的调用结果保存在 val 中并复用,才会复用这个具体实例的节点。变量绑定方式与集合延迟行为叠加后,必须分别计数,不能只看最外层一个关键字。
缓存也保留对象身份。若缓存的 Box 有可变字段,第二次遍历会看到同一个 Box 的当前状态,未必是第一次计算时的历史快照。LazyList 记忆化的是值或引用,不会深复制引用指向的对象。与不可变集合类似,节点结构稳定不等于整个可达对象图深层不可变。
保留头引用会保留怎样的路径
实验始终保存 retainedHead。已经求值的头节点能够通向后续节点,已求出的内容因而仍可能从这个根引用到达。取完第四项后,再读取头部还能得到第一批的同一个 Box,这为缓存可重访提供直接证据。
这与“消费完毕就释放”的直觉不同。只要上层仍保存整个 LazyList 的头,为了让后续遍历复用历史,已求出的前缀就需要继续可达。若处理器只想顺序前进,长期保留头可能延长大量历史数据的生命。Iterator 通常更贴近一次性消费,但它的来源、闭包或外部缓存也可能持有引用,所以不能反向声称 Iterator 一定常量内存。
把头部变量置空也不是可靠的 GC 验收。程序里可能还有 first、again 等列表保存同一元素,运行时何时回收也没有立即承诺。本文因此不通过 sleep 后检查内存来声称回收成功,而是把证据限定为缓存次数和可达引用关系。若要定位真实内存问题,应使用堆分析寻找实际保留链。
记忆化是一个明确的空间时间交换:重复消费能够减少计算,但存活实例可能保存更多结果。它是否合适取决于是否真正需要重访、单个元素大小、来源长度和持有范围。订单报表若只输出一次,就未必需要为整个历史前缀支付缓存;交互式分页若频繁重访,缓存又可能有价值。
延迟消费还会跨越资源边界
如果来源依赖已打开的文件,把 Iterator 或 View 从资源块返回之后再消费,读取可能发生在文件已经关闭之后。类型上仍然拥有一个可消费对象,不代表底层资源还可用。问题来自创建和执行分离,而不是某个容器名称本身不安全。
一种修复是在资源存活范围内完成消费并生成独立的严格结果;另一种修复是让消费过程与资源管理合在同一作用域。选择取决于输入规模和内存限制。把所有数据强制装进 List 虽然容易避免晚读关闭文件,却可能无法处理大文件。更完整的关闭协议将在资源章节验证,本章只指出延期求值改变了资源使用时点。
异常时点也类似。转换函数可能在创建视图时没有执行,因此错误直到 toList、head 或其他消费动作才出现。日志若只记录“构造成功”,并不能证明数据已经校验。接口返回的是结果还是待执行计算,应在方法命名和返回类型说明中明确。
用三条时间线检查同形语法
对 Iterator,可以记录创建、消费前两项、消费剩余、耗尽四个时点;对基于 List 的 View,可以记录创建、第一次遍历、第二次遍历;对同一个 LazyList,可以记录创建、首次前缀、重复前缀、扩展前缀。每条时间线都问回调是否执行、游标是否变化、结果是否保留。
本章真实计数是 Iterator 总共三次、View 两次各取二项共四次、LazyList 首次三项计算三次且重访不增加,扩展第四项后变为四次。这些数字与具体操作绑定,不能替换成“惰性总是更快”之类判断。一个昂贵过滤器可能为了两个结果执行一百次,原因仍能沿同样时间线解释。
隔离编译反例把 Iterator[Int] 直接赋给 List[Int]。编译失败说明二者不是可随意替换的同一类型;若需要 List,应显式消费并物化。这一步消耗来源并占用空间,不能把 toList 视为无行为的类型转换。
截断放置的位置会改变已经完成的工作
如果先对严格 List 执行带计数器的 map,再 take(2),映射阶段已经处理全部元素,后面的截断无法撤销先前计算。把同一表达式改成 view.map.take(2).toList,变换由消费驱动,才可能只计算需要的前缀。两段代码返回相同列表,不意味着它们对副作用或昂贵计算等价。重构时应先决定回调是否允许少执行,再判断这种优化是否合法。
过滤与截断的先后也不是纯粹性能选择。先 take(2) 再过滤,表示只在原始前两项中筛选;先过滤再 take(2),表示从整个来源寻找前两个合格结果。前者可能只返回一个结果,后者可能读取更多输入。订单展示中的“前两条有效订单”和“前两条订单中的有效者”是不同需求,语法相似不能消除区别。
对于无限来源,某个过滤条件如果永远无法匹配,take(1) 也未必能终止,因为消费方仍在寻找第一项。有限结果需求并不总能推出有限工作量。若业务需要超时、取消或读取上限,应额外定义终止协议;惰性容器只提供按需取值机制,没有自动给搜索过程加期限。
手算与修改练习
手算:在首次取得 LazyList 前三项后,再执行 retainedHead.take(2).toList,回调增加几次?答案是零,因为同一实例的前两项已经求值。若改成重新调用一个创建 LazyList 的 def,则新实例没有这份缓存,会重新计算。
修改练习:在 View 的 map 后增加 filter(amount => amount % 20 == 0),再 take(2)。把输入扩成 List(1,2,3,4,5),记录一次消费中 map 和 filter 各自调用次数,预期都为四次,结果为二和四对应的金额。重复消费后次数翻倍。再改成同一个 LazyList,分开记录映射结果记忆化与过滤节点记忆化,确认读到相同前缀不会重新触发已完成部分。答案需要保存实际次数,而不是只验证最终列表。
实验记录与依据
源码为 examples/scala-lab/snippets/11/Chapter11.scala,入口 scalaexamples.Chapter11。命令、诊断与输出见 RUN.md。
- Iterator 官方说明:核对消费位置和遍历接口。
- View 官方说明:核对延迟变换与来源关系。
- LazyList 2.13.16 API:核对按需计算、记忆化及保留头部的边界。本文没有声称完成堆占用测量。
前置阅读:不可变集合与结构共享。
