把同一个动作使用两次,究竟会执行两次,还是取得同一次执行的结果?Haskell 的 IO、JavaScript 的闭包与 Promise 给出的答案不同。仅把 >>= 与 .then 放在一起比较语法,会漏掉启动时机、共享和 thenable 吸收这些实际可观察的差别。

实验用一个计数器和一组确定的事件顺序比较两门语言,不访问网络、不靠延时猜测完成。本篇补充Cats 与 IO 的执行和取消中“描述不等于执行”的讨论;本系列入口见导读与能力自测。工具链隔离在临时目录,源码只依赖 GHC 的基础库与 Node 内置断言。

do是组合语法,IO是动作类型

Haskell 的 do 不专属于 IO。实验先写一个 Maybe 计算,再显式展开绑定:

1
2
3
4
5
6
viaDo = do
x <- Just 2
y <- Just 3
pure (x + y)

viaBind = Just 2 >>= (\x -> Just 3 >>= (\y -> pure (x + y)))

两者都得到 Just 5。这里没有打开文件,也没有启动线程;do 只是按照相应实例组合计算。Haskell 2010 的 do 表达式规则描述这种展开方式。带失败模式匹配的 do 还涉及失败处理,本例使用变量模式,避免把这一层额外语义混入基础比较。

IO 的绑定类型则是 IO a -> (a -> IO b) -> IO b。它连接动作,并把前一个动作的结果传给后一个动作。Haskell Report 的基本 I/O 章节区分表达式求值与动作顺序;顺序组合要求运行时保持动作的依赖次序,不能用普通纯表达式的重排自由随意交换外部写入。

实验创建 IORef 计数器,然后定义 action:

1
2
3
4
5
counter <- newIORef (0 :: Int)
let action = modifyIORef' counter (+1) >> readIORef counter
initial <- readIORef counter
a <- action
b <- action

定义 action 后计数仍为零。执行 action 两次,a 为一、b 为二。随后 let saved=b,把 saved 使用两次只得到 2+2=4,计数仍为二。共享一个动作值与共享一次动作的返回值,是两种不同选择。

这里没有要求 IO 动作必须每次得到不同结果。读取常量文件可能两次相同,但那是外部状态和动作语义造成的结果,不能从 IO a 类型推导缓存。也没有声称所有 Haskell 值都严格纯:IORef 正是通过 IO 管理的可变状态,实验刻意用它观察动作次数。

惰性求值不能替代错误边界

take 3 [1..] 可以得到前三个整数,不需要先构造无限列表。const 7 (error "unused") 得到七,因为第二个参数没有被需要。但这不能简化成“Haskell 的错误不会发生”:一旦显式求值 error "forced",实验就捕获到异常。

1
2
forced <- try (evaluate (error "forced" :: Int))
:: IO (Either SomeException Int)

evaluate 在 IO 里将求值推进到弱头正规形,使异常落入这个 try 的作用域。对于更深的数据结构,弱头求值不代表每个字段都被强制完成。若资源读取留下惰性尾部,关闭资源之后才访问尾部,就可能把失败时点推迟到另一个位置。实际资源策略需要控制求值深度与使用范围,本实验只验证整数表达式的边界。

实验中的 modifyIORef' 使用严格更新,避免计数器累积一长串未求值的加法;但这也不等于 IORef 自动具备并发原子性。当前测试是顺序动作,不对并发计数提出保证。语言特性与运行时协议必须分别讨论。

JavaScript闭包可以延迟调用

const action=()=>++n 在定义时不增加 n,调用两次得到一、二。这与 Haskell action 在这个有限观察上的启动行为相似,但两者的类型与效果管理能力不同。JavaScript 的普通函数签名没有显式 IO 通道,也没有阻止调用者在其他位置直接修改 n。

闭包的结果是否共享由程序决定。存下 const saved=action() 后多次读取 saved,不会重新调用 action;存下函数本身再调用两次,则会执行两次。Haskell 的动作/结果区别因此并非无法用其他语言理解,只是语言和库是否显式表达、限制这些操作不同。

把函数改成 async 也不能一概称为惰性。调用 async 函数时,函数体在第一个 await 之前的部分就会开始执行;想推迟调用,仍需让外层保存一个尚未调用的函数。本实验选择普通闭包与 Promise 构造器,避免混合多层启动机制。

Promise构造器立即调用executor

实验创建共享 Promise:

1
2
3
4
5
let started = 0;
const shared = new Promise(resolve => {
started++;
resolve(started);
});

构造表达式返回时,started 已为一。对 shared 等待两次得到 [1,1],计数仍为一;两次等待都观察同一个 Promise 的完成结果,没有重新执行 executor。若改成工厂函数,每次调用都创建新 Promise,则后续两次得到二、三。

ECMAScript 的Promise 构造器算法在构造期间调用 executor。异步的是后续反应的调度,不是对任意 executor 自动延迟启动。实验还记录同步语句与 .then 回调,得到 sync,then,明确区分两种时点。

这会影响重试。如果把已经拒绝的 Promise 放进“重试两次”的函数,只重复 await 它,未必重新发起任何操作;需要一个能够重新创建动作的工厂。相反,若本来希望多处共享同一次请求,却每次调用工厂,就可能发起重复请求。本文没有实际网络请求,计数器只用于证明对象复用与动作重建的区别。

thenable吸收改变了“装入”的含义

JavaScript 对象只要有可调用的 then 属性,就可能参与 Promise 解析。实验构造一个对象,其 then 被调用时完成为数字七:

1
2
3
4
const thenable = {
then(resolve) { absorbed++; resolve(7); }
};
const wrapped = Promise.resolve(thenable);

紧接构造时,then 方法尚未被调用;await wrapped 后得到七,并确认 absorbed 为一。ECMAScript 的Promise 解析算法规定检查 then 并排入解析任务。读取 then 属性本身也可能触发 getter,这与稍后调用 then 方法是两个可观察时点;本实验采用普通方法,未覆盖 getter 效果。

因此 Promise.resolve 不是对所有 JavaScript 值都保持对象身份的普通包装器。定义 recognize(x):若 x 就是该 thenable,返回 object,否则返回 number。直接调用 recognize 得到 object,而 Promise.resolve(thenable).then(recognize) 得到 number,因为 recognize 收到的是七。

若把 Promise.resolve 当作任意值上的 pure,把 then 当作 bind,左单位律会要求两条路径相同;这个 thenable 输入构成反例。反例具体依赖对象身份观察与吸收规则,不能扩大成“Promise 没有任何可用的代数性质”,也不能反过来用几个数字样例否认反例。

先限定观察,才能讨论组合规律

实验同时在负一、零、二上,用只返回已完成 Promise 的加一和乘二函数检查左单位、右单位和结合律,共九个最终值相等断言。它们说明在这些非 thenable 数值、无额外效果的样例里,常见组合公式得到相同结果。

观察若加入执行次数、同步/异步边界、微任务数量、未处理拒绝通知或者对象身份,判断范围就变了。两个程序最终都返回三,不代表所有运行轨迹相同。讨论法律性时需要声明值域、允许的函数和等号含义,而不是把 .then 的形状当成证明。

Promise 的共享完成也不直接提供取消。一个消费者不再等待,不会由此自动撤销 executor 已启动的外部动作。取消通常需要动作 API 的独立协议,例如传递可中止信号,并检查对方是否真正响应。Haskell IO 也不能只凭一个普通动作值保证资源已释放;这要由相应异常和资源组合协议实现。

这些差异会影响从一种生态迁移到另一种生态。把可重复执行的描述替换成已启动且共享的 Promise,可能改变重试;把共享 Promise 换成每次调用的 IO,可能增加执行次数。迁移测试应检查启动、次数、结果、失败、取消与资源范围,不能只比较最后返回值。

同一个“复用”需要三种测试

动作描述复用测试应记录动作执行次数;结果复用测试应检查不会重新启动动作;完成对象复用测试则需要检查多个观察者收到同一个完成结果。把它们只写成“执行两次返回相同值”无法区分场景,因为外部动作可能本来就返回常量。本实验选择递增计数器,使三种选择产生可区分的值与次数。

错误路径也要采用同样思路。一个已经拒绝的 Promise 被等待两次,通常是在观察同一失败;一个会抛错的工厂被调用两次,则可能真正做了两次外部尝试。两者的错误消息可以完全一样,却对应不同负载与风险。重试组件的输入究竟是结果句柄还是动作工厂,应在接口中明确。

Haskell 的动作重复也不等于自动安全。若 action 是“向文件追加一行”,执行两次就会追加两次;纯的动作描述不替代业务幂等设计。把动作作为值保存,主要使组合与解释位置更明确,不会把外部世界改成不可变结构。重复执行是否允许,仍取决于动作契约。

thenable 还会影响泛型容器的预期。假设业务对象碰巧有一个名为 then 的可调用字段,进入 Promise.resolve 后可能被当成解析协议参与者,而非普通记录。若需要把它保持为数据,应在外层放入一个普通记录字段,例如返回 {value: thenable};解析过程不会递归扫描这个记录的每个字段。这样保持的是新的包装对象,接口也应相应说明。

对对象身份敏感的观察不是所有应用都需要,但不能在证明时临时删除不方便的输入。可以明确将合法域限制为数字、字符串或不含 then 的领域数据,再讨论受限性质;如果公开 API 接受任意对象,thenable 就属于需要处理的真实边界。类型约束与运行时校验之间仍存在距离。

另外,测试中的 Promise 都在本地完成,不涉及浏览器任务队列、计时器与 I/O 回调之间的全部调度关系。sync,then 只证明本段同步代码先于对应反应执行。把它扩展成跨运行时的所有事件顺序表,会超出当前证据。需要更多调度结论时,应构造能区分目标关系的事件记录,而不是延长一次等待。

实际运行与修改练习

本次使用官方 GHC 9.6.7 ARM64 二进制与 Node 22.22.2。GHC 的 System.Info.compilerVersion 在该运行中输出 ghc=9.6,并不包含补丁号;实际 runghc --version 输出 runghc 9.6.7,ghc --numeric-version 输出 9.6.7。默认 shell 没有 runghc,本次将官方包解压后,通过 ./configure --prefix=/private/tmp/fp-elective-tools/installed 与 make install 安装到隔离目录。成功运行的完整命令是:

1
2
PATH=/private/tmp/fp-elective-tools/installed/bin:$PATH \
node examples/functional-programming/run.mjs E07

这个目录属于本次本机临时工具链,不会随源码分发,也可能被系统清理。其他机器应先安装适合自身架构的 GHC,并将实际安装目录的 bin 加入 PATH;确认版本命令成功后再运行统一入口。仅克隆仓库不会安装 Haskell,也不应把 Scala 实验可运行当作 runghc 已就绪的证据。

实际记录包含:

1
2
3
IO: construct=0; run=1,2; reused-result=4/count2; do=bind/Just5; lazy-unused=7; forced-error=caught
closure=1,2; promise-start=1; shared=1,1; factory=2,3; thenable=7/object-lost
value-only-laws=9; callback-order=sync,then; thenable-left-unit=counterexample

输入为Main.hs和Main.mjs,命令、环境与退出状态见result.json。官方安装包来源是GHC 9.6.7 下载页,实验没有把编译器或缓存放进仓库。

手算自测:若把 action 的返回值保存为 a,再计算 a+a,计数器会增加几次?如果把 action 本身放进顺序组合两次,又会增加几次?必须区分“构造动作”“执行动作”“使用结果”三个位置。

修改题将 thenable 的 then 改成 getter,在 getter 与返回的方法里分别记录事件,再与同步语句、then 回调比较顺序。另一项修改是在 Haskell 里构造一个第一字段正常、第二字段报错的二元组,先 evaluate 元组,再单独 evaluate 第二字段,验证弱头求值与深度求值的区别。不要用增加睡眠时间代替精确的事件与求值边界。