【问题标题】:How does the compiler benefit from C++'s new final keyword?编译器如何从 C++ 的新 final 关键字中受益?
【发布时间】:2011-09-24 11:50:42
【问题描述】:

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 标记。在这些情况下,这种技术是不可能的。

【问题讨论】:

  • “这个虚函数永远不会被覆盖”——在这种情况下,调用不需要依赖动态调度(方法/析构函数)。由编译器编写者选择进行这种优化。考虑到 c++ 的正确性/严格性,i 个人希望看到它。当编译器知道类型时(例如,在视线内初始化或作为类型化成员变量),可以对实例进行相同的优化。比虚拟调用的开销更重要的是,通常可以根据执行的扩展知识进行内联或优化。
  • 我真的不明白这有什么关系。 overridefinal 的目的是让编译器阻止用户搞砸。您应该使用它们来阻止自己和他人做错事。编译器可能会或可能不会使某些东西更快实际上无关紧要,因为无论如何您都应该始终在适当的地方使用它们。

标签: c++ compiler-construction final c++11


【解决方案1】:

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

在派生的final 类型上使用virtual 调用调度,您可以确定没有其他任何东西从该类型派生。这意味着(至少在理论上)final 关键字可以在编译时正确解析一些virtual 调用,这将使virtual 调用无法进行的一些优化成为可能。

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

同样地,在对最派生类型的引用/指针上调用virtual 成员函数。

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

还可能有一些方法能够推断出(在没有friends 的情况下)在final 中标记为protected 的东西class 也有效地变为private

【讨论】:

  • +1,为什么这是一条评论。这个值得回答
  • 它从评论开始,因为我认为这将是一个扔掉“这里可能有答案”的提示/评论,直到我意识到我已经编辑了足够多的评论,基本上可以写一个回答而不是扔掉提示!
  • 在最好的情况下是否允许内联那些不再是虚拟的函数调用?这可能是巨大的好处!这将使inline 提示对virtual 函数有意义,即。析构函数-
  • @towi 任何可以在编译时解析的调用都可以被内联。 (实际上,您可以推测内联调用无论如何,如果事实证明您实际上并不想要内联的内容,则使用调度逻辑。不知道是否有任何 C++ 编译器这样做,但这是在 JIT 编译器中内联的全部前提用于动态语言。)
  • @gsnedders:JIT 可以确保它们推测内联的类型至少在调用站点发生过一次,而 C++ 没有这样的保证,因此很难知道它是否成功。编译器必须能够证明在编译时至少有一些时间是 X 类型,这听起来很棘手。不过,不知道是否有任何编译器这样做,它们可能存在。
【解决方案2】:

对函数的虚拟调用比正常调用的成本略高。除了实际执行调用之外,运行时必须首先确定调用哪个函数,这通常会导致:

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

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

编译器的优化器仍然试图避免各种开销,而去虚拟化函数调用通常是一个容易实现的目标。例如,参见 C++03:

struct Base { virtual ~Base(); };

struct Derived: Base { virtual ~Derived(); };

void foo() {
  Derived d; (void)d;
}

Clang 获取:

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 允许编译器在无法检测到相关函数的情况下避免虚拟调用的开销。

【讨论】:

  • 那么,通过应用他的优化魔法, 已经有一个几乎可以直接从中受益的编译器了? Clang 快要准备好了吗?
  • 真正的好处不仅来自去虚拟化,还来自内联。 (理论上)是否可以让编译器内联那些去虚拟化的调用?
  • @towi:是的,可以内联。 Clang 中的去虚拟化很早就出现了,因为它是在前端完成的优化(优化器不支持 OO)。因此,一旦调用被去虚拟化,所有可以对常规函数进行的优化都是可能的。当然,它们是否真的发生取决于优化器是否认为它们是一个好主意。至于第一个问题,我不认为 Clang 已经完全准备好,尽管它已经在某些地方受益。我实际上是手动制作了最后一个去虚拟化 ;)
  • "优秀的编译器设法让它比常规调用慢 10%-15%" 编译实现这一目标?
【解决方案3】:

根据您的看法,编译器还有另一个好处(尽管这种好处只是对用户有好处,因此可以说这不是编译器的好处):编译器可以避免为具有不确定行为的构造发出警告可以覆盖的东西。

例如,考虑以下代码:

class Base
{
  public:
    virtual void foo() { }
    Base() { }
    ~Base();
};

void destroy(Base* b)
{
  delete b;
}

当观察到delete b 时,许多编译器会针对b 的非虚拟析构函数发出警告。如果一个类Derived 继承自Base 并有自己的~Derived 析构函数,则在动态分配的Derived 实例上使用destroy 通常会(根据规范行为未定义)调用~Base,但它会不要打电话给~Derived。因此~Derived 的清理操作不会发生,这可能很糟糕(尽管在大多数情况下可能不是灾难性的)。

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

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

【讨论】:

  • 它应该有一个新的警告:警告 3:完全没有意义地使用 virtual 关键字 - 请删除它。
  • 在这种情况下,是的,额外的警告可能会很好。但是请注意,除非该类具有内部链接,否则一个完全独立的文件中的某些完全独立的类可能会从它继承(我认为这是可以的,只要两个定义相同,根据一个定义规则),在另一个编译器看不到的编译单元。因此,编译器并不总是可以发出这样的警告,尽管它看起来可以。
  • 或许可以改为在链接器中实现
  • 是的,我认为可以。在实践中,我认为链接器往往不会与链接 C++ 紧密相关,以至于它们具有暴露此类警告的知识或智慧。 :-(
猜你喜欢
  • 2017-04-24
  • 1970-01-01
  • 1970-01-01
  • 2016-02-21
  • 2014-08-15
  • 2012-09-14
  • 2011-03-04
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多