CPS:它是什么、为什么有用、怎么写、和协程/suspend 的关系、优缺点与常见应用

什么是 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(它解决了什么)

  1. 显式化控制流 :把"下一步该干嘛"变成一等值(函数),方便中断/恢复/跳转

  2. 跨平台实现异步 :在没有原生"可挂起栈帧"的平台(如 JVM)上,用 CPS 能把异步/事件驱动编译成普通函数调用。

  3. 编译优化:编译器把程序变成 CPS 后,控制流/异常/返回都统一成"调用某个 continuation",便于做内联、寄存器分配、尾调用优化等(很多编译器的中间表示会采用 CPS 或等价形式)。

  4. 高级控制结构:异常、早退、回溯、协程、生成器、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 或等价机制把"可捕获的未来"限定在一个代码块内,更易组合(很多现代语言/库偏好这类)。

典型应用

  1. 异步/并发:CPS = "把异步回调一等化"。async/await、协程 suspend 均可解释为 CPS + 调度。
  2. 异常、提早返回、重试:把异常看成调用"错误续延"。
  3. 回溯/搜索:携带"失败时如何继续"的续延来实现回溯(逻辑编程/解析器组合子常用)。
  4. 编译器中间表示:控制流显式、无隐式返回,适合做优化与代码生成。
  5. 尾调用优化/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 改写 + 状态机 + 调度器 的工程实现,你写同步风格,编译器替你做"回调化"。

相关推荐
阿蒙Amon2 小时前
C#每日面试题-常量和只读变量的区别
java·面试·c#
程序员小白条2 小时前
面试 Java 基础八股文十问十答第八期
java·开发语言·数据库·spring·面试·职场和发展·毕设
xlp666hub4 小时前
Linux 设备模型学习笔记(1)
面试·嵌入式
南囝coding5 小时前
CSS终于能做瀑布流了!三行代码搞定,告别JavaScript布局
前端·后端·面试
踏浪无痕5 小时前
Go 的协程是线程吗?别被"轻量级线程"骗了
后端·面试·go
一只叫煤球的猫6 小时前
为什么Java里面,Service 层不直接返回 Result 对象?
java·spring boot·面试
求梦8207 小时前
字节前端面试复盘
面试·职场和发展
C雨后彩虹7 小时前
书籍叠放问题
java·数据结构·算法·华为·面试
码农水水8 小时前
中国电网Java面试被问:流批一体架构的实现和状态管理
java·c语言·开发语言·面试·职场和发展·架构·kafka
程序员清风8 小时前
猿辅导二面:线上出现的OOM是如何排查的?
java·后端·面试