【问题标题】:Can constexpr function evaluation do tail recursion optimizationconstexpr 函数求值可以做尾递归优化吗
【发布时间】:2012-03-04 17:41:25
【问题描述】:

我想知道对于长循环我们是否可以利用 C++11 中 constexpr 的尾递归?

【问题讨论】:

  • 你有一个例子来说明你的意思吗?
  • 我将您的问题解读为:在 constexpr 函数的编译时评估期间是否可以使用尾递归。你是这个意思吗?
  • 对不起,我在火车上,所以我无法提供详细的代码示例。如果你知道的话,请其他人添加一个例子。我的意思是什么!
  • 如果它是 constexpr,那么它就是编译时间,那么这有什么关系呢?您是否担心您的编译器会用完堆栈大小?
  • @kos 是的,我是。为什么没关系?我想一个这样的 constexpr 堆栈帧在运行时需要比真实帧更大的大小

标签: c++ c++11 language-lawyer constexpr


【解决方案1】:

根据[implimits] 中的规则,允许实现对constexpr 计算设置递归深度限制。具有完整的constexpr 实现(gcc 和 clang)的两个编译器都应用了这样的限制,使用标准建议的 512 次递归调用的默认值。对于这两种编译器,以及遵循标准建议的任何其他实现,尾递归优化基本上是无法检测到的(除非编译器在达到其递归限制之前会崩溃)。

一个实现可以选择只计算它无法在其递归深度限制中应用尾递归优化的调用,或者不提供这样的限制。但是,这样的实现可能会对其用户造成伤害,因为它可能会崩溃(由于堆栈溢出)或无法在深度或无限递归的 constexpr 评估上终止。

关于达到递归深度限制时会发生什么,Pubby 的示例提出了一个有趣的观点。 [expr.const]p2 指定

调用 constexpr 函数或 constexpr 构造函数,将超出实现定义的递归限制(参见附件 B);

不是常量表达式。因此,如果在需要常量表达式的上下文中达到递归限制,则程序格式错误。如果在不需要常量表达式的上下文中调用constexpr 函数,则通常不需要实现在翻译时尝试对其求值,但如果它选择这样做,并且达到递归限制,则需要改为在运行时执行调用。在一个完整的、可编译的测试程序上:

constexpr unsigned long long f(unsigned long long n, unsigned long long s=0) {
  return n ? f(n-1,s+n) : s;
}
constexpr unsigned long long k = f(0xffffffff);

GCC 说:

depthlimit.cpp:4:46:   in constexpr expansion of ‘f(4294967295ull, 0ull)’
depthlimit.cpp:2:23:   in constexpr expansion of ‘f((n + -1ull), (s + n))’
depthlimit.cpp:2:23:   in constexpr expansion of ‘f((n + -1ull), (s + n))’
[... over 500 more copies of the previous message cut ...]
depthlimit.cpp:2:23:   in constexpr expansion of ‘f((n + -1ull), (s + n))’
depthlimit.cpp:4:46: error: constexpr evaluation depth exceeds maximum of 512 (use -fconstexpr-depth= to increase the maximum)

叮当声说:

depthlimit.cpp:4:30: error: constexpr variable 'k' must be initialized by a constant expression
constexpr unsigned long long k = f(0xffffffff);
                             ^   ~~~~~~~~~~~~~
depthlimit.cpp:2:14: note: constexpr evaluation exceeded maximum depth of 512 calls
  return n ? f(n-1,s+n) : s;
             ^
depthlimit.cpp:2:14: note: in call to 'f(4294966784, 2194728157440)'
depthlimit.cpp:2:14: note: in call to 'f(4294966785, 2190433190655)'
depthlimit.cpp:2:14: note: in call to 'f(4294966786, 2186138223869)'
depthlimit.cpp:2:14: note: in call to 'f(4294966787, 2181843257082)'
depthlimit.cpp:2:14: note: in call to 'f(4294966788, 2177548290294)'
depthlimit.cpp:2:14: note: (skipping 502 calls in backtrace; use -fconstexpr-backtrace-limit=0 to see all)
depthlimit.cpp:2:14: note: in call to 'f(4294967291, 17179869174)'
depthlimit.cpp:2:14: note: in call to 'f(4294967292, 12884901882)'
depthlimit.cpp:2:14: note: in call to 'f(4294967293, 8589934589)'
depthlimit.cpp:2:14: note: in call to 'f(4294967294, 4294967295)'
depthlimit.cpp:4:34: note: in call to 'f(4294967295, 0)'
constexpr unsigned long long k = f(0xffffffff);
                                 ^

如果我们修改代码以便在翻译时不需要进行评估:

constexpr unsigned long long f(unsigned long long n, unsigned long long s=0) {
  return n ? f(n-1,s+n) : s;
}
int main(int, char *[]) {
  return f(0xffffffff);
}

然后两个编译器都接受它,并生成在运行时计算结果的代码。使用-O0 构建时,此代码由于堆栈溢出而失败。使用-O2 构建时,编译器的优化器会转换代码以使用尾递归并且代码可以正确运行(但请注意,此尾递归与constexpr 评估无关)。

【讨论】:

    【解决方案2】:

    我不明白为什么它不可能,但这是实现细节的质量。

    例如,对模板使用记忆化是传统的做法,这样编译器就不会再窒息了:

    template <size_t N>
    struct Fib { static size_t const value = Fib <N-1>::value + Fib<N-2>::value; };
    
    template <>
    struct Fib<1> { static size_t const value = 1; }
    
    template <>
    struct Fib<0> { static size_t const value = 0; }
    

    而是记住已经计算的值,以将其评估的复杂性降低到 O(N)。

    尾递归(和伪尾递归)是优化,并且与大多数优化一样不受标准约束,因此没有理由不可能。然而,一个特定的编译器是否使用它是很难预测的。

    标准在 5.19 [expr.const] 中说:

    2/ 条件表达式是核心常量表达式,除非它涉及以下之一作为潜在评估的子表达式 (3.2) [...]:

    • 对 constexpr 函数或 constexpr 构造函数的调用将超出实现定义的递归限制(参见附件 B);

    并阅读附件 B:

    2/ 这些限制可能会限制数量,包括以下描述的数量或其他数量。建议将每个数量后面的括号中的数字作为该数量的最小值。但是,这些数量仅供参考,并不能确定合规性。

    • 递归 constexpr 函数调用 [512]。

    尾递归没有被提及。

    【讨论】:

    • GCC 在评估斐波那契函数的等效constexpr 形式时似乎也使用了记忆。如果它使用常规模板实例化机制来评估 constexpr 函数,我不会感到惊讶。
    • @JohannesD:我也不会,memoization 对于纯函数非常有效,而且模板代码和 constexpr 函数都是纯的(至少对于编译时评估的部分)。记忆对他们来说是有意义的,但有一定的限制。
    【解决方案3】:

    我不确定我是否明白你在问什么。如果担心是否 编译器会将尾递归转换为循环,它是未指定的, 函数是否为constexpr。如果是一个 递归函数可以是constexpr,那我不认为tail 递归是相关的。如果我正确阅读了标准:

    constexpr unsigned ack( unsigned m, unsigned n )
    {
        return m == 0
            ? n + 1
            : n == 0
            ? ack( m - 1, 1 )
            : ack( m - 1, ack( m, n - 1 ) );
    }
    

    是一个有效的 constexpr(尽管我希望编译器会抱怨 除了最小的nm 之外的所有人都缺乏资源,至少如果 该函数在需要常量表达式的上下文中使用)。

    【讨论】:

      【解决方案4】:

      我已经看到 GCC 执行了这种优化。这是一个例子:

      constexpr unsigned long long fun1(unsigned long long n, unsigned long long sum = 0) {
        return (n != 0) ? fun1(n-1,sum+n) : sum;
      }
      fun1(0xFFFFFFFF);
      

      适用于 -O2,否则崩溃。

      令人惊讶的是,它也在优化这个:

      constexpr unsigned long long fun2(unsigned long long n) {
        return (n != 0) ? n + fun2(n-1) : 0;
      }
      

      我检查了非 conspexpr 表单的反汇编,我可以确认它正在被优化成一个循环。

      但不是这个:

      constexpr unsigned long long fun3(unsigned long long n) {
        return (n != 0) ? n + fun3(n-1) + fun3(n-1) : 0;
      }
      

      总之,GCC 将优化成一个循环,就像它对非 consexpr 函数所做的一样。至少使用 -O2 及以上。

      【讨论】:

      • 很好...跨编译器的可移植性已经够难的了,我希望他们不要将程序的良好格式置于优化级别:x
      • @Matt 这个程序格式正确。他们只是增加了实施限制。我在 gcc 方面没有看到任何坏事。
      • 这个答案完全不正确。首先,优化级别不能(也不在 GCC AFAICT 中)影响 constexpr 评估。其次,上面对fun1 的调用实际上并不是一个常量表达式,因此是在运行时计算的。尝试像 constexpr auto x = fun1(0xFFFFFFFF); 那样初始化一个 constexpr 变量,GCC 会给你一个很长的错误消息。
      【解决方案5】:

      “尾声”一开始可能是用词不当。 constexpr 函数更接近于数学函数。对于数学函数,以下两个函数是相同的:

      constexpr unsigned long long fun1(unsigned long long n) {
        if (n == 0) return 0 ;
        return n + fun1(n-1);
      }
      constexpr unsigned long long fun2(unsigned long long n) {
        if (n != 0) return n + fun2(n-1);
        return  0;
      }
      

      但从过程编程的角度来看,它们绝对不是。只有第一个似乎适合尾调用优化。

      【讨论】:

      • 这些调用都不是尾调用。
      猜你喜欢
      • 2017-05-23
      • 1970-01-01
      • 2010-10-20
      • 1970-01-01
      • 2018-03-08
      • 2012-10-14
      • 2020-06-13
      • 1970-01-01
      相关资源
      最近更新 更多