编译器如何从C++的新 final 关键字中受益?

How does the compiler benefit from C++'s new final keyword?

本文关键字:关键字 final 的新 C++ 编译器      更新时间:2023-10-16

c++ 11将允许将类和虚方法标记为final,以禁止从它们派生或重写它们。

class Driver {
  virtual void print() const;
};
class KeyboardDriver : public Driver {
  void print(int) const final;
};
class MouseDriver final : public Driver {
  void print(int) const;
};
class Data final {
  int values_;
};

这是非常有用的,因为它告诉接口的读者使用这个类/方法的意图。如果用户试图重写,则会得到诊断信息,这可能也很有用。

但是从编译器的角度来看有什么优势吗?当编译器知道"这个类永远不会被派生"或"这个虚函数永远不会被覆盖"时,编译器可以做任何不同的事情吗?

对于final,我主要发现只有N2751引用了它。通过对一些讨论的筛选,我发现了来自c++/CLI方面的争论,但没有明确提示为什么final可能对编译器有用。我正在考虑这个问题,因为我也看到了标记类final的一些缺点:要对受保护的成员函数进行单元测试,可以派生类并插入测试代码。有时,这些课程很适合用final标记。这种技术在这些情况下是不可能的。

我可以想到一个场景,从优化的角度来看,它可能对编译器有帮助。我不确定编译器实现者是否值得付出努力,但至少在理论上是可能的。

对于派生的final类型的virtual调用调度,您可以确保没有从该类型派生的其他类型。这意味着(至少在理论上)final关键字可以在编译时正确解析一些virtual调用,这将使virtual调用无法实现的许多优化成为可能。

例如,如果您有delete most_derived_ptr,其中most_derived_ptr是指向派生final类型的指针,那么编译器可以简化对virtual析构函数的调用。

对于virtual成员函数在指向最派生类型的引用/指针上的调用也是如此。

如果今天有任何编译器这样做,我会感到非常惊讶,但这似乎是未来十年左右可能实现的事情。

可以推断出(在没有friend s的情况下)在final class中标记为protected的东西也有效地变成了private

虚函数调用比正常调用稍微昂贵一些。除了实际执行调用之外,运行时必须首先确定要调用哪个函数,这通常会导致:

  1. 定位v表指针,并通过它到达v表
  2. 在v表中定位函数指针,并通过它执行调用

与直接调用相比,在直接调用中,函数的地址是预先知道的(并且用符号硬编码),这会导致很小的开销。好的编译器会设法使它只比常规调用慢10%-15%,如果函数有任何内容,这通常是微不足道的。

编译器的优化器仍然寻求避免各种开销,而去虚拟化函数调用通常是容易实现的。例如,参见c++ 03:

struct Base { virtual ~Base(); };
struct Derived: Base { virtual ~Derived(); };
void foo() {
  Derived d; (void)d;
}

叮当声得到:

define void @foo()() {
  ; Allocate and initialize `d`
  %d = alloca i8**, align 8
  %tmpcast = bitcast i8*** %d to %struct.Derived*
  store i8** getelementptr inbounds ([4 x i8*]* @vtable for Derived, i64 0, i64 2), i8*** %d, align 8
  ; Call `d`'s destructor
  call void @Derived::~Derived()(%struct.Derived* %tmpcast)
  ret void
}

正如你所看到的,编译器已经足够聪明地判断出dDerived,那么就没有必要引起虚调用的开销。

实际上,它可以很好地优化以下函数:

void bar() {
  Base* b = new Derived();
  delete b;
}

然而,在某些情况下,编译器无法得出这个结论:

Derived* newDerived();
void deleteDerived(Derived* d) { delete d; }

在这里,我们可以(天真地)期望调用deleteDerived(newDerived());会得到与之前相同的代码。然而,事实并非如此:

define void @foobar()() {
  %1 = tail call %struct.Derived* @newDerived()()
  %2 = icmp eq %struct.Derived* %1, null
  br i1 %2, label %_Z13deleteDerivedP7Derived.exit, label %3
; <label>:3                                       ; preds = %0
  %4 = bitcast %struct.Derived* %1 to void (%struct.Derived*)***
  %5 = load void (%struct.Derived*)*** %4, align 8
  %6 = getelementptr inbounds void (%struct.Derived*)** %5, i64 1
  %7 = load void (%struct.Derived*)** %6, align 8
  tail call void %7(%struct.Derived* %1)
  br label %_Z13deleteDerivedP7Derived.exit
_Z13deleteDerivedP7Derived.exit:                  ; preds = %3, %0
  ret void
}

约定可以规定newDerived返回Derived,但是编译器不能做这样的假设:如果它返回的是进一步派生的东西呢?因此,您可以看到检索v-table指针、在表中选择适当的项并最终执行调用所涉及的所有丑陋的机制。

然而,如果我们把final放进去,那么我们给编译器一个保证,它不能是其他任何东西:

define void @deleteDerived2(Derived2*)(%struct.Derived2* %d) {
  %1 = icmp eq %struct.Derived2* %d, null
  br i1 %1, label %4, label %2
; <label>:2                                       ; preds = %0
  %3 = bitcast i8* %1 to %struct.Derived2*
  tail call void @Derived2::~Derived2()(%struct.Derived2* %3)
  br label %4
; <label>:4                                      ; preds = %2, %0
  ret void
}

简而言之:final允许编译器在无法检测到的情况下避免相关函数的虚调用的开销。

取决于您如何看待它,编译器还有一个进一步的好处(尽管这个好处只是回报给用户,所以可以说这不是编译器的好处):编译器可以避免对具有不确定行为的结构发出警告,这些结构是可重写的。

例如,考虑以下代码:
class Base
{
  public:
    virtual void foo() { }
    Base() { }
    ~Base();
};
void destroy(Base* b)
{
  delete b;
}

当观察到delete b时,许多编译器会对b的非虚析构函数发出警告。如果从Base继承的类Derived有自己的~Derived析构函数,在动态分配的Derived实例上使用destroy通常会调用~Base,但不会调用~Derived。因此,~Derived的清理操作不会发生,这可能很糟糕(尽管在大多数情况下可能不是灾难性的)。

如果编译器知道Base不能继承,那么~Base是非虚的就没有问题,因为不会意外跳过派生清理。将final添加到class Base中,可以为编译器提供不发出警告的信息。

我知道这样使用final会抑制Clang的警告。我不知道其他编译器是否在这里发出警告,或者在确定是否发出警告时是否考虑了最终性。