“尾调用帧复用另行扩展本教学 ABI:只对同尺寸、无溢出参数、没有未完成清理且栈对象不逃逸的尾边保留原返回槽并转移到目标体入口。它不改变本页普通 call 的建帧过程,也不使任意外部 ABI…”
形式陈述
一个调用在尾位置,表示被调用函数的结果就是当前函数的结果,当前函数无需再执行其他工作。形式上,它把结果交给与当前函数相同的续延。尾调用可以调用另一个函数,也可以互递归;“文本最后一个调用”和“自己调用自己”都不是完整判据。
在本页表达式语言中,从每个函数体开始标记尾上下文:let 的体继承尾标记,右侧不继承;if 的两个结果分支继承,条件不继承;序列的最后一项继承,前项不继承;加法、构造器、调用的函数位置和实参位置都不继承。位于这些尾上下文中的应用才是尾调用。return g(x) 是尾调用,return 1+g(x) 则还须加一。
本页扩展旧教学 ABI,其普通 call/return 完全不变。只为已验证的尾边增加 tail_transfer:所有参与函数最多两个普通参数、固定 32 字节局部区,返回一个字;没有额外栈参数、可变长帧、异常清理、捕获栈续延和指向当前帧对象的逃逸引用。目标跳到被调用者完成序言之后的函数体入口,因此不能再次压入返回地址或旧 FP。
固定布局仍为 [FP] 保存父 FP,[FP+8] 保存原调用者返回地址,[FP−8] 保存原调用者的 S,[FP−16..FP−32] 为局部区;SP=FP−32。一次尾转移先按语言顺序完整求出新函数值、环境和所有普通实参;在覆盖旧参数前,把新参数保存在互不冲突的临时位置。随后恢复 S 为 [FP−8],保留三项原调用者保存信息,更新 E、A0、A1,按目标入口所需初始化局部槽并跳转。被调用者将来使用通常尾声返回到原调用者。
这里调用点必须已经证明旧局部值没有后续读取,栈上对象不会被目标继续引用。若实参指向当前帧中即将被覆盖的对象,单纯尾位置也不够;生命期分析要阻止这类复用,或改用足够长寿命的存储。
直觉
普通调用为“回来之后再做什么”保存一个返回位置。尾调用回来以后唯一要做的事情还是立刻返回,于是可以直接继承当前函数原本的返回位置。活动函数换了,等待它完成的外层计算没有增加。
栈帧复用是一种实现,语言层的 proper tail recursion 是空间行为要求。Scheme 的要求还涉及捕获续延,远大于本页简化 ABI。[1] 不能用这里一段 jump 代码声称实现了所有语言的完整尾调用保证。
例子与边界
累加器走四个状态,只留一层帧
sum(n,a) = if n==0 then a else sum(n-1,a+n)
main = sum(3,0)
main 用普通 call 进入 sum。设当前 FP=8144, SP=8112,[8144]=8192、[8152]=main.after_sum、[8136]=55。四次函数体入口参数依次为 (3,0)、(2,3)、(1,5)、(0,6),FP、SP 和三个保存槽一直相同。最后 R=6,普通尾声恢复 S=55、FP=8192、SP=8160,并跳到 main.after_sum。
尾转移的结果不变量为
若所有递归边使用普通 call,n=3 时需要同时保留四个 sum 帧,共192字节,随后逐层立即返回。对一般 n≥0,普通版本活动帧数 n+1,而复用版本为1。两者仍执行 n 次加法,尾调用没有把线性时间变成常数时间。
实参必须是平行搬移
尾调用 f(a,b) -> g(b,a) 的两个新值来自旧状态。原 (a,b)=(2,9),正确结果应传 (9,2)。顺序执行 A0=A1; A1=A0 会得到 (9,9);先保存 t=A0 再搬 A0=A1; A1=t 才正确。本页让所有新实参先进入临时向量,逻辑上是平行赋值;真实寄存器实现可复用既有并行复制环消解,但不能省去它。
sum(n)=if n==0 then 0 else n+sum(n-1) 的递归调用不在尾位置。若不改变算法便丢掉当前帧,返回时还要加的 n 就不见了。先改写成累加器形式需额外的算术等价证明;在会溢出或有异常的整数语义下也不能不加条件地变换。
推论与应用
正确性、根映射和资源账本
在允许复用的尾边,源当前续延与目标继承的返回槽表达同一个后续计算。新实参与环境先从旧状态求出,故复用不会改变参数值;原调用者保存槽不变,故最终尾声恢复的控制状态正确;当前局部不再被合法引用,故覆盖不会破坏未来数据。三项条件共同给出一次尾边的模拟,沿尾调用链归纳即可。
若目标可能在安全点触发移动 GC,尾转移还必须切换到目标函数体入口对应的根映射,旧死槽不再当根,新环境与跨点参数要可找到。搬移中途不允许暂停,或者为中间状态单独提供映射。本页把尾转移作为不可中断的短运行时步骤,未实现并发暂停协议。
一条尾边搬移 k 个参数、初始化 s 个目标局部字,时间为
迁移任务:even(n) 与 odd(n) 在 n>0 时尾调用对方并减一,零时分别返回 true、false。even(4) 的函数序列为 even、odd、even、odd、even,结果 true,按同 ABI 仍只有一帧。再在 odd 的返回前加入 log(result),调用便不再尾部,除非语言证明该日志已进入相同续延或采用另一种变换;不能只依据递归图判断。
本页保留旧调用约定“不承诺尾调用优化”的原模型,新增的是明确受限的替代执行边。外部 ABI 的参数个数、栈对齐、清理责任或展开协议不匹配时,编译器可以保守保留普通调用,不能任意跳入外部函数中部。
参考资料
[1] Richard Kelsey、William Clinger、Jonathan Rees(编),Revised⁵ Report on the Algorithmic Language Scheme,1998,§3.5,印刷页7–8:“Proper tail recursion”及尾上下文。这里只引用语言层要求,教学 ABI 不冒充完整 Scheme 实现。
[2] Cormac Flanagan 等,The Essence of Compiling with Continuations,1993,§6 的尾位置 let 恒等返回优化;同一续延的基础沿用本站续延和CPS 变换页。