函数式编程26:IO 描述与执行边界
把扣款函数的返回值从 Int 改成 IO[Int],不能自动消除重复扣款。需要先确定三个时刻:调用函数时是否已经扣款,得到 IO 之后是否还没有扣款,以及同一个 IO 被运行两次时究竟发生几次请求。返回类型相同的两个实现,在这些问题上可以完全不同。
本章以事件记录代替真实扣款。输入固定为整数二十,计算加一,事件包括构造、运行和映射。这样既能观察最终结果,也能观察相同结果背后的执行次数。关键前置是纯函数、惰性求值、flatMap 和效果嵌套;教学目标是能够判断副作用是否真正进入延迟边界,而不只是识别 IO 这个名称。
实验固定 Scala 3.3.7、Cats 2.12.0、Cats Effect 3.5.7、JDK 21。完整的实验源码 Main.scala与本次执行证据 result.json对应。证据保存环境、源码摘要、命令、退出码与断言输出。它验证的是本章的有限场景,不是 IO 的全部定律或运行时实现。
从返回结果改为返回计算描述
普通函数 readPrice(): Int 调用后给出一个整数。如果实现内部读取文件,文件操作已经发生;调用者拿到整数后无法再决定这次读取应当延迟、重试或取消。若接口改成 readPrice(): IO[Int],可以把文件读取封装在描述里,让调用者先组合程序,再交给运行时执行。
“可以”这个限定不能省略。下面两个函数的声明都返回 IO,但第一个函数把读取动作放在了构造之前。IO.pure 得到的只是读取完成后的值,外围类型无法改变已经发生的事情。
1 | |
代码片段中的 read 是教学端口,函数体可以由计数器替代。实验没有请求外部服务。第一个函数按照 Scala 的参数求值规则先计算 read();第二个函数交给 IO 构造器一个待执行表达式。区分点落在表达式放置的位置,不在变量名是 effect 还是 task。
Cats Effect 的 Effects 文档把效果表述为待执行动作的描述,IO API 也说明相同描述可以被重复解释。这里采用这个定义,并把“产生描述”与“执行描述”作为不同观察阶段。官方 Concepts、IO API
这样组织代码的直接收益,是业务组合函数不必立刻接触外部世界。例如解析订单之后决定是否查询价格,查询之后再决定是否写入,可以返回一个完整的 IO。测试可以先检查非法订单是否根本没有进入查询分支,再检查正常订单的查询次数。若查询在函数入口已经发生,后面的组合再正确,也无法恢复“校验失败时不查询”的契约。
一个只负责延迟和接续的教学实现
用零参数函数保存计算,是理解描述的最短路径。TinyIO[A] 保存 () => A,其 map 和 flatMap 只创建新的闭包。闭包调用前,没有执行被保存的动作。这个结构与 Java 的 Supplier 有相似的延迟能力,但不能据此把 Supplier 当作完整的效果运行时。
1 | |
map 的类型是 TinyIO[A] => (A => B) => TinyIO[B]。运行返回的闭包时,先运行源闭包拿到 A,再调用纯转换函数得到 B。flatMap 接收的函数则返回另一个 TinyIO,因此外层闭包还需要执行这个新描述,才能交付 B。两种实现的差别来自函数返回类型,不来自“链式写法看起来更方便”。
实验中的 tiny 先记录 tiny-run,返回二十;map 再记录 tiny-map,返回二十一。构造它之后,事件列表没有这两种记录。调用 unsafeRun() 两次之后,二者各出现两次,结果都是二十一。这里的 map 故意加入记录,是为了观察时序;实际业务映射优先使用纯函数。
这个实现存在明确缺口。闭包直接递归调用其他闭包,深层组合可能消耗 JVM 栈;异常直接从调用栈抛出;没有独立的取消协议;也没有异步回调注册、资源释放和运行线程安排。方法名字叫 flatMap,最多说明它按某种方式接续计算,不能证明这些运行时能力已经成立。
TinyIO 的 unsafeRun 之所以保留这个显眼名称,是因为调用它会越过描述边界。它不保证安全捕获异常,也不保证重复调用不会重复写文件。正式程序里到处调用它,相当于把控制权再次分散到任意函数内部。教学实现只用来解释延迟,后续生命周期实验使用真实 Cats Effect。
构造完成后应观察什么
实验把所有对照放在 IO.defer 内,使每次运行实验获得独立事件列表。该列表只由一个 fiber 顺序访问,不承担并发日志的责任。多个任务共享记录时需要并发安全结构;这里引入同步容器反而会掩盖最基本的求值问题。
1 | |
这两个 val 都构造完成之后,断言要求列表严格等于只含 eager-construct 的列表。这个断言比“cold 返回 IO”强:它检查构造期间没有误执行 cold 的业务动作,也检查 eager 确实留下了反例记录。若把 cold 内的动作提前赋给一个普通 val,构造断言会失败。
调用点还可能通过辅助方法泄漏求值。例如 def make(): IO[Int] = { record(); IO.pure(20) } 的记录在每次调用 make 时发生。将 make() 的结果放进列表再做 sequence,并不能把这批已经发生的记录推迟。审查时需要展开效果工厂,而不能只检查最外层类型。
相同问题也会出现在调试输出上。println("constructing") 放在 IO 之外,程序加载模块时就可能打印;IO.println("running") 返回的是打印描述。若用日志推断启动时机,应先确认日志属于哪个阶段,否则实验本身会把构造日志误作执行日志。
map 得到的嵌套不会自行消失
假设一个转换函数返回 IO。对它使用 map,结果类型应当保留内层 IO。实验显式声明这个类型,避免编译器推导掩盖学习目标。
1 | |
运行 nested 会执行外层 map 函数。该函数返回已有的 cold 描述,所以 inner 是 IO[Int],还不是整数。在 inner <- nested 之后插入观察,io-run 的计数必须仍为零。下一次绑定 inner 时,才运行其中的动作。
如果改成 IO.pure(1).flatMap(_ => cold),结果类型就是 IO[Int]。flatMap 把“拿到下一段描述”和“继续解释下一段”组合起来。这个区别可以沿着类型逐层追踪,无需把 IO 想象成一个能随意拆开的盒子。效果值在这里没有提供一个纯的 get: IO[A] => A。
map 的转换函数也可以直接做副作用,但这样会把业务动作藏进本应简单的值转换。实验为了观测 map 调用会写事件;应用代码通常应让 A => B 保持纯,把需要 IO 的转换写成 A => IO[B] 并用 flatMap 接续。这样读者能从签名看出下一步是否涉及外部动作,也能给这一步单独安排失败处理。
重复引用、重复运行和结果共享
执行 inner 和 cold 后,实验断言两次结果都是二十一,io-run 的次数是二。随后把 eager 连续运行两次,eager-construct 仍只有一次。eager 保存的是构造时得到的十;cold 保存的动作在每次运行时重新执行。这是“同一个对象”与“同一次执行”之间的差别。
val cold 只限制这个局部变量不能重新赋值,不能约束 IO 被解释几次。把 cold 写入一个包含两项的列表,再 sequence,也会安排两次执行;列表两项恰好引用同一个对象并不构成缓存契约。对象地址相等无法替代效果次数断言。
需要共享结果时,应明确决定共享的生命周期。例如一次请求里查询一遍,再把结果传给两个纯函数,可以在 for 中绑定一次 A,后面重复使用 A。这样共享的是得到的值,执行次数从结构上就能看出来。若要跨请求缓存,还需要考虑过期、错误是否缓存、并发请求是否合并,以及取消其中一个等待者会不会影响其他等待者。
这也解释了为什么重复运行 IO 不能直接等同于业务重试安全。计数器增加两次很容易观察,远端扣款是否重复则取决于接口协议。IO 可以控制动作何时开始,却不能给远端调用凭空增加幂等键。网络断开时,客户端没有结果,并不表示服务端没有完成操作。
一个更具体的设计是把“生成请求标识”与“发送请求”分开。如果在每次重试内部重新生成标识,服务端可能把它们识别成不同请求;如果把标识作为显式输入传入,重试才能重用同一业务身份。这里不实现支付协议,提出这个边界是为了防止把运行时重试能力当成领域正确性。
defer 延迟的是描述的构造
IO(a) 中的 a 是普通计算,运行后得到 A;IO.defer(ioa) 中的 ioa 本身返回 IO,运行时先构造这一段描述,再接着解释。二者的层级不同。实验用 defer-build 标记描述工厂被调用的次数。
1 | |
rebuilt 构造完毕时没有 defer-build。运行两次后,这个标记出现两次,cold 也再运行两次。这里没有自动记忆工厂返回的描述,更没有自动记忆最终整数。defer 解决的是构造时机,缓存解决的是执行共享,两者不能互换。
defer 对需要在运行时选择分支的代码尤其有用。如果工厂读取一个可变配置,然后创建对应 IO,把工厂提前调用会固定构造时的配置;把工厂放进 defer 会让每次运行读取当时的配置。哪种行为正确由接口契约决定。为了可重放测试,通常更容易把配置快照作为函数输入,再生成描述,而不是让描述到处读取全局变量。
延迟还涉及递归构造。如果一个函数在返回 IO 之前立即调用自身,程序可能在构造阶段无限递归;运行时还没有获得控制权。把递归步骤安排到延迟边界之后,才有机会由解释器逐步推进。本章的 TinyIO 不承诺这种推进栈安全,深链与 trampoline 的验证属于单独的执行边界课题。
异常是否位于效果边界之内
实验分别在 IO[Int](throw ...) 与 IO.pure[Int](throw ...) 中抛出普通参数异常。前者能够通过 .attempt 得到失败值;后者甚至没有成功构造出 IO,需要外层的 scala.util.Try 捕获。延迟异常断言检查消息 delayed;严格参数场景断言构造失败且事件列表未变,没有额外检查异常消息。两条检查分别针对执行错误与构造错误的边界。
attempt 的类型为 IO[Either[Throwable, A]]。它把这次效果执行产生的错误变成结果数据,外层 IO 仍然需要运行。把它误读成 Either[Throwable, A],就容易在还没有执行时宣布“异常已经被处理”。同理,在 IO 构造前就抛出的异常,也不处在该 IO 的 attempt 管理范围内。
null 本身不等于效果失败。在本章的 Scala 配置与 Cats Effect 3.5.7 下,IO.pure[String](null) 和 IO(null: String) 都成功保留 null,运行各自的 .attempt 得到 Right(null)。四条实际断言分别检查这两个成功值,以及随后在 map 内解引用的失败:
1 | |
这里失败的是执行 map 时的 .length 调用,保留 null 的前一步没有失败。IO 不会自动把 null 转成缺席或拒绝它;需要表达可预期的缺席时,应显式使用 Option,需要非空输入时则在入口校验。这个例子也没有把非空性加入 IO[String] 的类型保证。
这里的 Throwable 错误通道与领域错误还应区分。订单数量非法可以用 Either[ValidationError, Order] 表达;磁盘读取失败由 IO 报错。IO[Either[ValidationError, Order]] 同时表达两层边界,不能为了类型好看而把所有领域拒绝改成异常。反过来,吞掉磁盘错误返回空列表,会让调用方无法区分系统故障与“没有订单”。
取消又有独立语义。一个等待中的任务被取消,不应简单当成普通 Throwable 后恢复执行原来的业务流程。资源清理与取消结果将在后续实验中分别观察;本章的 attempt 断言覆盖普通延迟异常、成功的 null 值与后续解引用错误,不声称验证了取消。
运行入口与阻塞端口
实验对象扩展 IOApp.Simple,其 run 返回完整 IO[Unit]。程序入口负责运行描述,内部代码通过 for、map、flatMap 组合。这样可以在一个地方说明运行时生命周期,避免每个工具函数自行启动一个解释器并同步等待。
不过,所有动作包在 IO 中并不意味着所有动作都适合使用相同构造器。普通短小计算可用 IO.delay;同步阻塞的文件或旧 JDBC 调用应使用对应阻塞边界。官方 Sync 文档区分 delay、blocking 与基于线程中断的 interruptible,并说明阻塞操作的取消限制。Sync 官方说明
选择 blocking 的目的,是明确这个调用可能占住执行线程。它不把同步驱动改成异步驱动,也不保证请求能立即停止。若底层 API 忽略中断,仅改变外层效果类型不能改变它的能力。接入一个旧接口时,需要同时核对启动时机、完成信号和取消接口。
本章没有测量线程数、吞吐量或内存占用。事件记录也没有时间戳,无法据此证明某种构造器更快。它只回答动作发生在哪个阶段以及重复了几次;这是进入资源与并发讨论之前必须先稳定的观察层。
从一个导入接口推导效果工厂契约
考虑一个只接收文本的接口:先解析数量,数量合法时读取单价,最后计算总额。解析不需要外部世界,可定义成 String => Either[InputError, Int];查询单价需要 IO,可定义成 Int => IO[BigDecimal];乘法仍是纯函数。组合后的接口可以是 String => IO[Either[InputError, BigDecimal]]。外层 IO 表示允许执行查询,内层 Either 表示输入是否被业务接受,这两种失败不需要压成一种异常。
对于文本 "bad",解析应返回领域错误,不构造依赖有效数量的查询;对于文本 "2",解析得到二,运行 IO 时才调用单价端口。假设端口返回五,总额为十。这个例子最重要的对照不是十是否算对,而是非法输入的查询次数为零,合法输入每次运行的查询次数为一。如果解析失败后仍提前查询,返回值可能完全正确,但接口的外部动作契约已经被破坏。
这说明类型签名需要配合一个构造约定:调用效果工厂本身不应启动它宣称要描述的外部工作。否则 String => IO[...] 只能告诉调用方后面还有一个 IO,不能告诉它前面已经发生多少事。团队可以通过端口实现审查和构造阶段计数测试固定约定,不必尝试从类型名称推断实现。
边界选择还会影响错误处理的层级。若解析函数错误地直接抛出普通异常,且在效果工厂返回之前调用它,那么最后加 .attempt 的调用方仍可能接不住这个异常。修复方向有两个:让解析遵守显式 Either 契约,或把确实可能抛出的纯库调用放入合适的效果捕获范围。前者表达可预期的非法输入,后者表达库调用失败;不能仅为了统一写法而删除领域错误信息。
该导入接口是设计推导,未新增到本章实验中。与它相关的现有观察是构造零执行、运行次数和严格参数异常。将它用于真实导入时,应增加上面两个输入与查询次数的端口测试,不能把教学计数器的通过记录当成实际服务已经验收。
延迟边界中的引用仍然可能变化
延迟表达式可以保存对可变对象的引用,而不是保存当时的值。设一个配置对象先含单价五,构造 IO 后改为七,运行 IO 时读取对象字段,观察到的是运行时读取的状态。若在构造前把字段复制为不可变数值,再由 IO 使用该数值,观察到的则是快照。两者都是延迟执行,差别在于闭包捕获了什么。
因此“构造时没有副作用”不等于“以后每次运行都可重放相同结果”。时钟、随机数、可变配置和文件内容都可能在运行之间变化。想要可重放业务计算,应将需要固定的输入作为数据传入纯核心;确实要读取最新状态时,则把读取动作和观测时间明确放进效果边界。把一切都包进 IO 并不会自动获得输入快照。
本章事件列表也是一个被捕获的可变对象,所以最外层使用 IO.defer 为每次实验创建新的列表。若把列表提升到对象级变量,第二次运行实验会累积第一次的记录,构造断言可能失败,或者必须人为清空才能继续。这种污染会使“被测程序重复执行”与“测试夹具重复使用”混在一起。独立夹具让两个执行次数保持可区分。
共享一个不可变结果与共享一个可变句柄也不同。IO.pure(existingReader) 不会重复打开文件,但它会重复交付同一个 reader,其读取位置随前一次使用改变;这既不是结果缓存,也不是安全的资源工厂。即使构造次数断言为一,也不能推出共享正确。下一章的 Resource 正是要把有效期加入讨论,而不是继续用一个返回对象的 IO 隐藏所有权。
对嵌套描述还有一个对应误区:取得 IO[IO[A]] 的内层 IO 并不保证内层独立于外层运行的临时状态。若内层捕获了外层范围里的资源,等到外层结束后才运行它,可能面对失效句柄。类型层级解释组合结构,资源范围解释有效期;二者要一起设计,不能靠多加一层 IO 延长对象寿命。
重跑与验收题
在仓库根目录运行统一入口,编译缓存和临时工作目录位于系统临时目录,实验不会在源码旁生成 class 文件。
1 | |
本次通过的十一项断言中,七项覆盖构造不执行 cold、TinyIO 重复运行、map 保留嵌套、IO 不默认记忆结果、defer 重建、延迟异常和严格参数异常;四项覆盖 pure 与延迟构造各自成功保留 null,以及随后解引用时各自进入错误通道。输出事件先出现 eager 构造,随后是两组 tiny,再是四组 cold,其中后两组各有 defer 构造标记。二十一这个返回值本身不能证明这些顺序,事件和次数断言才负责这部分观察。
类型题:令 f: Int => IO[String],写出 cold.map(f)、cold.flatMap(f) 与 cold.map(f).flatten 的类型。第一项是 IO[IO[String]],后两项是 IO[String]。继续说明第一项只运行一层时是否执行 f 返回的字符串动作;答案是否,除非 f 在构造描述时已经泄漏了副作用。
修改题:把 cold 的 IO { ... } 改为 IO.pure { ... },保留原断言运行。构造检查必须失败;随后修复延迟边界,并让日志记录与加一操作都留在受控执行中。再把 rebuilt 的定义改为先调用工厂得到 val、然后重复使用,新增断言区分工厂次数与动作次数。不能仅修改预期数字而不解释行为变化。
已有的 Scala E01:Cats 与 IO 的执行、取消和资源释放中,“创建任务与运行描述”是这部分知识的旧入口。本章独立增加 TinyIO 推导、嵌套类型、构造与执行异常边界,以及 null 值与解引用错误的对照,保留旧文及其证据归属。资源释放、取消和任务归属需要真实运行时协议,无法由本章的闭包实现或十一项断言推导出来。
