Skip to content

定义Definition

分界续延与 shift/reset

Delimited continuations · Shift and reset · 受限续延

用最近的 reset 划定捕获范围,说明 shift 如何重新安装边界、组合式返回并多次恢复同一段纯计算。

形式陈述 ​

reset 决定捕获到哪里 ​

分界续延只表示某个边界以内的剩余计算。本页固定经典的 shift/reset:纯粹、从左到右按值、单种无标签提示符,整数、λ、应用与加法。reset(e) 划定边界;shift k.e 将到最近 reset 为止的续延绑定给 k,并在该边界内转而执行 e。

将不跨越 reset 的纯求值上下文写为

E::=[ ]∣Ee∣vE∣E+e∣n+E.

E 中没有 reset(E) 分支,因而向外寻找捕获范围时遇到 reset 就停止。外层上下文 F 可以包含其他 reset。核心规则为

F[reset(E[shift k.e])]→F[reset(e[k:=λx.reset(E[x])])].

x 取新鲜变量,替换使用无捕获替换。另外 reset(v)→v,普通应用仍要求实参先是值。捕获的 k 表现为 λx.reset(E[x]):调用它会执行保存的片段,并将结果返回调用处,而不是抛弃整个调用处的续延。[1, §4.4]

本文要求实际存在一个外围 reset。没有外围 reset 的 shift 是未处理控制操作,不给它一个隐含捕获终点;原文的某些顶层求值定义默认一层外部边界,阅读两者时须对齐这个约定。模型没有可变 store、异常、dynamic-wind、多标签提示符或一次性资源。

直觉

保存一段工作,完成后仍回来 ​

完整续延像“把结果交给一整条后续路线,并离开现在的位置”;分界续延像“执行一段保存的子计算,做完再回来”。reset 把子计算的边界写在程序里,shift 决定在这个边界内把哪一段剩余工作变成值。

重新安装 reset 不是装饰。若保存的片段以后又执行 shift,新 shift 应只看见恢复片段自身的边界,而不能把调用 k 的外层工作也捕获进去。规则中的内层 reset 正是保障这个条件的地方。

例子与边界

相同的加法外观,得到 106 ​

计算

text
reset(1 + shift k. (100 + k(5)))

捕获 E=1+[ ],所以 k=λx.reset(1+x)。依次变成

reset(100+(λx.reset(1+x))5)→∗reset(100+6)→106.

调用 k 的结果 6 回到 100+[],这层加法没有被丢弃。对应的call/cc 例 1+callcc(lambda k.100+k(5)) 则得到 6。差别在调用保存控制值时是否保留调用处,而不只是捕获帧数的多少。

一段片段调用两次 ​

text
reset(1 + shift k. (k(10) + k(20)))

两次调用保存的 1+[ ],分别得到 11 和 21,再在 shift 主体中相加得 32。外面的 1 已经包含在每次 k 的执行里,不应再给最终的 32 加一次。此模型的保存片段不可变,可多次恢复;若实现采用一次性 continuation,必须改接口或显式复制,不能直接运行这个例子。

多次恢复会多次执行片段里的计算。加入状态后,两次执行可能读写同一存储,不会因名字叫“同一个续延”而自动复用第一次的数值;缓存结果和保存控制片段是两种不同操作。

最近边界与重新安装边界 ​

对

text
reset(100 + reset(1 + shift k. 10))

shift 只捕获内层 1+[ ],主体忽略 k 后内层返回 10,外层继续加 100,结果 110。删掉内层 reset 后,捕获范围扩大为 100+(1+[ ]),主体仍忽略 k,整个结果便是 10。

另一个例子直接检验重新安装:

text
reset((shift k. (100 + k(1))) + (shift j. 10))

第一次捕获的片段是 E=[ ]+(shift j.10)。调用 k(1) 时,新 reset 包住这个片段;第二个 shift 只退出这个片段并产生 10,返回 k 的调用处后还要加 100,最终为 110。若错误地把 k 解释成 λx.E[x],第二个 shift 会连调用处的 100+[ ] 一并捕获并丢弃,得到 10。这说明即使两个实现都能算对前一个简单 106 例,也可能在嵌套捕获上不同。

推论与应用

一条可实现的帧边界协议 ​

可以在 CEK 风格的不可变帧链里加入 Mark 帧。执行 reset 时压入 Mark;执行 shift 时收集当前栈到第一个 Mark 之前的帧,将这段作为 Part 值绑定给 k,再保留 Mark 及外层栈执行主体。若搜索不到 Mark,报告未处理 shift。

应用 Part 时,先在当前调用处 K 上放一个新的 Mark,再把保存片段的帧按原顺序接到这个 Mark 上,并把实参作为片段的输入值。片段返回后经过新 Mark,继续原来的 K。这恰好实现 λx.reset(E[x]);最重要的不变量是“保存片段不含自己外层的 Mark,恢复时新建的 Mark 位于保存片段与调用处之间”。[1, §§4.1–4.4]

本页下载执行器的帧链操作在捕获时扫描并保存 d 个帧,需 O(d+1) 时间与空间;恢复时重建这些链节点,也需 O(d+1),随后执行片段的工作另计。捕获还用字典复制来扩展当前环境,若环境有 a 个绑定,额外需 O(a+1) 时间与空间。默认事件日志会枚举调用处的 c 个帧,使一次恢复再增加 O(c+1) 的时间与日志空间;record_events=False 可关闭日志枚举,但不会消除环境复制。其它持久表示可用不同的切分与拼接策略,成本须按实际数据结构分别计算。多次恢复的总时间至少要覆盖各次实际执行,保存环境还可能延长所引用对象的生命期。

代数效应处理器也可截获操作并提供恢复点,但具体深/浅处理、操作名和恢复规则属于不同接口。某些语言可以相互编码,不等于任意 handler 都满足本文 shift/reset 规则。本文也不把去掉重新安装 reset 的 control/prompt 变体混为同一个算子。

迁移任务使用上面的第二次 shift 例:先在正确执行器中记录两次捕获的帧,再构造一个仅移除 Part 调用时新建 Mark 的错误版本,检验结果从 110 变成 10,并指出第二次捕获多收进了哪一帧。另将最近边界例的内层 reset 删除,比较 110 与 10;这次改变发生在源程序,而非错误实现。两项测试分别检查恢复协议和源程序边界,不能互相替代。终点任务列出这些输入与可比较的帧事件。

参考资料

[1] Małgorzata Biernacka、Dariusz Biernacki、Olivier Danvy,An Operational Foundation for Delimited Continuations in the CPS Hierarchy,BRICS RS-04-29,December 2004,§§4.1–4.4,正文 pp.11–17:双层续延求值器、机器及归约语义;p.17 明确用 λx.reset(E[x]) 解释捕获上下文。本文使用同一层控制并显式要求外围 reset,未扩展到整个 CPS 层级。

[2] Olivier Danvy、Andrzej Filinski,Abstracting Control,Lisp and Functional Programming,1990,pp.151–160,DOI 10.1145/91556.91622。shift/reset 的原始来源;具体操作规则以 [1] 的可读作者报告为核验依据。

关系图谱5 个相邻概念 · 3 类关系

拖动节点调整位置。

显示关系

显示:依赖

  1. 前置三跳
  2. 前置二跳
  3. 前置一跳
  4. 当前条目
  5. 后续一跳
  6. 后续二跳
  7. 后续三跳
文字版关系按与当前条目的最短距离分组
类型化关系