翻译: It’s not always obvious when tail-call optimization is allowed
原文链接:https://quuxplusone.github.io/blog/2021/01/09/tail-call-optimization/
翻译许可:
我最初本文作为 “A C++ acronym glossary” (2019-08-02) 中的一部分编写,但决定最好将其单独列出来放到一个博客中。
“TCO”代表“tail call optimization”。 这是一种编译器优化,它将代码中的函数调用(通常会向栈上 push frame)并将其转换为 jump 指令(不会将新的 frame push 到栈上)。 只有 return bar()
这样在函数为尾部发生调用的时候,编译器可能会采用这种优化: 编译器说:“看,我知道 bar
执行完就返回给调用者,就是 return bar()
的这个函数了,让我们直接在本函数的 caller
处调用 bar()
”!
Tail call optimization 通常可以用于 return bar()
这种形式,但不能用于 return bar()+1
。
在 C++ 中,编程者很难准确地弄清楚哪里允许发生 TCO。 主要原因是 non-trivial destructors:
1 | void foo1() { bar(); } // tail call |
这也包含 temporary objects 的析构:
1 | void bar(std::string_view); |
但即使这里所有东西都是 trivially destructible 的,您也可能因为需要 stack pointer 或其他东西,导致阻止发生 TCO 。https://godbolt.org/z/vcY3v9 :
1 | void bar(); |
有趣的是,在上面的示例中,GCC 和 MSVC 为 foo1
生成 jmp bar
,但 Clang 和 ICC 没有该优化。
您可能会合理地问为什么我们不能对 foo2
进行相同的优化。 我认为原因是 C++ 保证每个变量(在其生命周期内)都有一个唯一的地址。 如果我们要像这样实现 bar
:
1 | const int *addr_of_i; |
那么程序可以判断该实现是否(不符合规定)将 j
放入与 i
相同的内存位置。 然而,没有规则规定 j
不能与 42
生成的临时对象共享内存位置,因为该临时对象的生命周期与 j
的生命周期不重叠。
一个实现是否执行 TCO 是可以观察到的,因为从某种意义上说,tail-recursive 函数在使用 TCO 时可能使用 O(1)
的栈空间,但在不使用 TCO 时使用 O(n) 的栈空间——因此,当递归足够深时,会“爆破堆栈” (stack overflow)。 然而,C++ 的抽象机实际上并没有任何「爆破堆栈」的概念。 C++ 程序没有一致的方法来检测或处理该情况。
原文:However, C++’s abstract machine doesn’t really have any notion of blowing the stack. There’s no conforming way for a C++ program to detect that condition or deal with it. 不确定翻译对了…
因此,足够偏执的 C++ 代码合作者将避免非常深的递归。 执行此操作的一种技术是手动进行 tail-recursion optimization:
1 | int gcd(int x, int y) { |
改写为:
1 | int gcd(int x, int y) { |
并改写成
1 | int gcd(int x, int y) { |
执行最后一步通常非常重要,这不仅因为这种方式( https://en.wikipedia.org/wiki/Structured_programming )使代码更易读,还因为 goto 是极少数阻止将函数标记为 constexpr 的 C++ 结构之一 (见 https://stackoverflow.com/questions/45266577/why-disallow-goto-in-constexpr-functions )。 如果您希望函数为 constexpr(无论出于何种原因),则必须避免 goto。 这是 C++ 在风格上固执己见的罕见情况。
(如果你没有见过 tie = tuple
trick , 你可能会喜欢我在 CppCon 2020 上的演讲 “Back to Basics: Algebraic Data Types.”.)
Posted 2021-01-09