什么是 CPS(Continuation-Passing Style,续延传递风格)
定义 :在 CPS 中,函数不直接返回结果 ;它把"接下来要做什么"(称为续延 、continuation)当作额外的函数参数 传入,并在计算出结果后调用这个续延,把结果交给它。
直白说:返回值改成回调,把"后续计算"显式化。
- 直写(Direct Style, DS):
kotlin
fun add1(x: Int): Int = x + 1
fun twice(x: Int): Int = add1(add1(x))
- CPS 写法(额外参数 k 表示"下一步怎么处理结果"):
kotlin
fun add1CPS(x: Int, k: (Int) -> Unit) {
k(x + 1)
}
fun twiceCPS(x: Int, k: (Int) -> Unit) {
add1CPS(x) { y ->
add1CPS(y) { z ->
k(z)
}
}
}
关键点:
-
"返回"= 调用 k(result);
-
控制权向调用方"倒置"(Inversion of Control):谁持有 k,谁就掌控后续。
为什么有 CPS(它解决了什么)
-
显式化控制流 :把"下一步该干嘛"变成一等值(函数),方便中断/恢复/跳转。
-
跨平台实现异步 :在没有原生"可挂起栈帧"的平台(如 JVM)上,用 CPS 能把异步/事件驱动编译成普通函数调用。
-
编译优化:编译器把程序变成 CPS 后,控制流/异常/返回都统一成"调用某个 continuation",便于做内联、寄存器分配、尾调用优化等(很多编译器的中间表示会采用 CPS 或等价形式)。
-
高级控制结构:异常、早退、回溯、协程、生成器、async/await 都可以用 continuation 来解释或实现。
与协程/suspend的关系(Kotlin 视角)
Kotlin suspend 本质就是CPS 改写 再配合一个状态机:
-
suspend fun foo(a: Int): R 会被编译成近似:fun foo(a: Int, cont: Continuation): Any
-
函数内部每个挂起点都会"保存现场(局部变量 + 下一步 label)→ 返回 COROUTINE_SUSPENDED";
-
将来准备继续时,运行时调用 cont.resumeWith(...),回到状态机里对应的 label 处"读档续跑"。
因此:协程是 CPS + 状态机的工程化落地(你写同步风格,编译器生成 CPS/状态机/调度)。
把直风格系统性地改写为 CPS(核心规则)
设 ⟦e⟧k 表示"把表达式 e 变成 CPS,并把结果交给续延 k":
- 常量/值:⟦v⟧k = k(v)
- 一元/二元运算:先把操作数 CPS 化,再在 k 里做运算:
ini
⟦e1 + e2⟧k =
⟦e1⟧ { v1 ->
⟦e2⟧ { v2 ->
k(v1 + v2)
}
}
-
函数定义:直风格 f(x) = body 变成 fCPS(x, k) = ⟦body⟧k
-
函数调用:⟦f(e)⟧k = ⟦f⟧ { vf -> ⟦e⟧ { ve -> vf(ve, k) } }(简化情况下常直接写成 fCPS(e, k))
-
条件分支:⟦if (b) e1 else e2⟧k = ⟦b⟧ { vb -> if (vb) ⟦e1⟧k else ⟦e2⟧k }
-
顺序:let x = e1 in e2
⟦let x = e1 in e2⟧k = ⟦e1⟧ { v1 -> ⟦e2[x:=v1]⟧k }
-
异常 :可引入错误续延 kErr,把抛异常编译成调用 kErr(e)。
手写这些会"回调金字塔",所以工程里通常由编译器自动做 CPS 变换(比如 Kotlin、C#、JS Babel/TS)。
CPS 的两种"续延"形态
-
未分界(undelimited)continuation:类似 call/cc,捕获"到程序顶层"的整个未来控制流,威力强但难以局部化管理。
-
分界(delimited)continuation:用 shift/reset 或等价机制把"可捕获的未来"限定在一个代码块内,更易组合(很多现代语言/库偏好这类)。
典型应用
- 异步/并发:CPS = "把异步回调一等化"。async/await、协程 suspend 均可解释为 CPS + 调度。
- 异常、提早返回、重试:把异常看成调用"错误续延"。
- 回溯/搜索:携带"失败时如何继续"的续延来实现回溯(逻辑编程/解析器组合子常用)。
- 编译器中间表示:控制流显式、无隐式返回,适合做优化与代码生成。
- 尾调用优化/Trampoline :CPS 形式天然尾递归,结合"蹦床(trampoline)"在没有 TCO 的平台上避免栈溢出:
kotlin
// 概念:把"下一步"封成 () -> Step 的 thunk,用 while 循环反复执行
sealed interface Step
class Done<T>(val value: T): Step
class More(val thunk: () -> Step): Step
fun run(step: Step): Any {
var s = step
while (s is More) s = s.thunk()
return (s as Done<*>).value
}
优点与代价
优点
-
控制流显式;能表达挂起/恢复、异常、回溯、早退等高级流程;
-
跨平台易实现异步与协程(无需 VM 改造);
-
便于编译器优化与分析(所有"返回"都是函数调用)。
代价
-
手写会出现"回调地狱";需要语言/编译器把直风格自动变为 CPS;
-
会多出闭包/对象分配与字段读写开销(现代编译器通过内联、逃逸分析、状态机化等降低成本);
-
调试反编译产物会看到 label、ContinuationImpl 等"样板"。
一眼记住
-
CPS = 把"返回"改成"调用续延" ;
-
它让"下一步该干嘛"成为显式的一等值,从而支持挂起/恢复 、异常/早退 、回溯等;
-
Kotlin suspend == CPS 改写 + 状态机 + 调度器 的工程实现,你写同步风格,编译器替你做"回调化"。