Can-Tail调用优化与RAII共存

Can Tail Call Optimization and RAII Co-Exist?

本文关键字:RAII 共存 优化 调用 Can-Tail      更新时间:2023-10-16

我想不出一种真正的RAII语言在规范中也有尾调用优化,但我知道许多C++实现可以将其作为特定于实现的优化。

这给那些这样做的实现带来了一个问题:假设析构函数是在自动变量作用域的末尾调用的,而而不是是由一个单独的垃圾收集例程调用的,这是否违反了TCO的约束,即递归调用必须是函数末尾的最后一条指令?

例如:-

#include <iostream>
class test_object {
public:
    test_object() { std::cout << "Constructing...n"; }
    ~test_object() { std::cout << "Destructing...n"; }
};
void test_function(int count);
int main()
{
    test_function(999);
}
void test_function(int count)
{
    if (!count) return;
    test_object obj;
    test_function(count - 1);
}

"建造…"会被写999次,然后"破坏…"会再写999次。最终,999个test_object实例将在展开之前自动分配。但是,假设一个实现具有TCO,会存在1000个堆栈帧还是只有1个?

递归调用后的析构函数是否与实际TCO实现要求相冲突?

从表面上看,RAII肯定会对抗TCO。但是,请记住,编译器可以通过多种方式"逍遥法外"。

第一种也是最明显的情况是,如果析构函数是琐碎的,这意味着它是默认的析构函数(编译器生成),并且所有子对象也有琐碎的析构因子,那么析构函数实际上是不存在的(总是被优化掉)。在这种情况下,TCO可以照常执行。

然后,析构函数可以被内联(它的代码被直接获取并放入函数中,而不是像函数一样被调用)。在这种情况下,它可以归结为在return语句之后有一些"清理"代码。如果编译器能够确定最终结果是相同的,它就可以重新排序操作("假设"规则),如果重新排序可以产生更好的代码,它也会这样做(通常),我认为TCO是大多数编译器应用的考虑因素之一(即,如果它能够重新排序,使代码变得适合TCO,那么它就会这样做)。

对于其他情况,如果编译器不能"足够聪明"独自完成,那么它就变成了程序员的责任。这种自动析构函数调用的存在确实使程序员在尾部调用后更难看到抑制TCO的清理代码,但就程序员使函数成为TCO候选函数的能力而言,这并没有任何区别。例如:

void nonRAII_recursion(int a) {
  int* arr = new int[a];
  // do some stuff with array "arr"
  delete[] arr;
  nonRAII_recursion(--a);  // tail-call
};

现在,一个简单的RAII_recursion实现可能是:

void RAII_recursion(int a) {
  std::vector<int> arr(a);
  // do some stuff with vector "arr"
  RAII_recursion(--a);  // tail-call
};  // arr gets destroyed here, not good for TCO.

但一个聪明的程序员仍然可以看到这是行不通的(除非向量析构函数是内联的,在这种情况下很可能是内联的),并且可以很容易地纠正这种情况:

void RAII_recursion(int a) {
  {
    std::vector<int> arr(a);
    // do some stuff with vector "arr"
  }; // arr gets destroyed here
  RAII_recursion(--a);  // tail-call
};

我相信你可以证明,基本上没有这种技巧不能用来确保TCO的应用。因此,RAII只是让我们更难看到TCO是否可以应用。但我认为,那些足够聪明地设计具有TCO能力的递归调用的程序员,也足够聪明地看到那些需要在尾部调用之前强制发生的"隐藏"析构函数调用。

添加注释:这样看,析构函数隐藏了一些自动清理代码。如果您需要清理代码(即,非平凡的析构函数),无论您是否使用RAII(例如,C样式数组或其他什么),您都需要它。然后,如果您希望TCO成为可能,则必须能够在执行尾调用之前进行清理(使用或不使用RAII),并且有可能在尾调用之前强制销毁RAII对象(例如,将它们放在一个额外的范围内)。

如果编译器执行TCO,那么相对于不执行TCO时,调用析构函数的顺序会发生变化。

如果编译器能够证明这种重新排序无关紧要(例如,如果析构函数是琐碎的),那么根据,就好像规则一样,它可以执行TCO。然而,在您的示例中,编译器无法证明这一点,也不会实现TCO。