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 改写 + 状态机 + 调度器 的工程实现,你写同步风格,编译器替你做"回调化"。

相关推荐
GHOME4 小时前
vue3中setup语法糖和setup函数的区别?
前端·vue.js·面试
甜瓜看代码4 小时前
面试题总结-网络编程
面试
菠菠萝宝5 小时前
【Java八股文】12-分布式面试篇
java·分布式·zookeeper·面试·seata·redisson
南北是北北5 小时前
协程suspend 如何被编译成“状态机”
面试
一直_在路上6 小时前
Go架构师实战:玩转缓存,击破医疗IT百万QPS与“三大天灾
前端·面试
怪兽20147 小时前
谈一谈Java成员变量,局部变量和静态变量的创建和回收时机
android·面试
王嘉俊9258 小时前
Java面试宝典:核心基础知识精讲
java·开发语言·面试·java基础·八股文
南北是北北8 小时前
Kotlin Channel 开箱即用
面试
前端缘梦9 小时前
前端模块化详解:CommonJS 与 ES Module 核心原理与面试指南
前端·面试·前端工程化