使用虚析构函数会使非虚函数进行v-table查找吗?

6

这个主题要求的是什么。我还想知道为什么通常的CRTP示例都没有提到virtual析构函数。

编辑: 各位,请也发表关于CRTP问题的帖子,谢谢。


1
为什么您认为非虚函数可能会进行v-table查找,而存在虚析构函数呢? - the_drow
嗯,好观点!只是我不这么认为。我不确定会发生什么,所以我在问 :D。 - nakiya
4个回答

6

只有虚函数需要动态分派(因此需要 vtable 查找),而且并非在所有情况下都需要。如果编译器能够在编译时确定方法调用的最终重载者,它可以省略在运行时执行分派的步骤。如果用户代码希望这样做,还可以禁用动态分派:

struct base {
   virtual void foo() const { std::cout << "base" << std::endl; }
   void bar() const { std::cout << "bar" << std::endl; }
};
struct derived : base {
   virtual void foo() const { std::cout << "derived" << std::endl; }
};
void test( base const & b ) {
   b.foo();      // requires runtime dispatch, the type of the referred 
                 // object is unknown at compile time.
   b.base::foo();// runtime dispatch manually disabled: output will be "base"
   b.bar();      // non-virtual, no runtime dispatch
}
int main() {
   derived d;
   d.foo();      // the type of the object is known, the compiler can substitute
                 // the call with d.derived::foo()
   test( d );
}

无需在所有继承情况下提供虚析构函数,仅当代码通过指向基本类型的指针持有派生类型的对象时,才需要虚析构函数。通常的规则是:
  • 提供公共虚析构函数或受保护的非虚析构函数
规则的第二部分确保用户代码不能通过指向基类的指针删除您的对象,这意味着析构函数不需要是虚拟的。优点是,如果您的类不包含任何虚拟方法,则不会更改类的任何属性--当添加第一个虚拟方法时,类的内存布局会发生变化--并且您将在每个实例中保存vtable指针。以上两个原因中,第一个原因最为重要。
struct base1 {};
struct base2 {
   virtual ~base2() {} 
};
struct base3 {
protected:
   ~base3() {}
};
typedef base1 base;
struct derived : base { int x; };
struct other { int y; };
int main() {
   std::auto_ptr<derived> d( new derived() ); // ok: deleting at the right level
   std::auto_ptr<base> b( new derived() );    // error: deleting through a base 
                                              // pointer with non-virtual destructor
}

主函数中的最后一行问题可以通过两种不同的方式解决。如果将typedef更改为base1,那么析构函数将正确地分派到derived对象,并且代码不会导致未定义的行为。代价是derived现在需要一个虚表,每个实例都需要一个指针。更重要的是,derived不再与other布局兼容。另一种解决方案是将typedef更改为base3,在这种情况下,该问题得以解决,编译器会在那一行报错。缺点是您无法通过对基础类型的指针进行删除操作,优点是编译器可以静态确保不会出现未定义的行为。
在CRTP模式的特定情况下(请原谅冗余的“模式”),大多数作者甚至不关心使析构函数受保护,因为意图并不是通过对基本(模板化)类型的引用来持有派生类型的对象。为了安全起见,他们应该将析构函数标记为受保护的,但这很少成为问题。

这个回答和Steve Jessop的回答都很有用。我在两者之间犹豫不决。只是因为你的声望较低,所以我会将其标记为已解决 :D。 - nakiya

5
非常不可能。标准中没有任何内容可以“阻止”编译器执行一整类愚蠢低效的操作,但非虚拟函数调用仍然是非虚拟函数调用,无论该类是否具有虚拟函数。它必须调用与静态类型对应的函数版本,而不是动态类型。
struct Foo {
    void foo() { std::cout << "Foo\n"; }
    virtual void virtfoo() { std::cout << "Foo\n"; }
};
struct Bar : public Foo {
    void foo() { std::cout << "Bar\n"; }
    void virtfoo() { std::cout << "Bar\n"; }
};

int main() {
    Bar b;
    Foo *pf = &b;  // static type of *pf is Foo, dynamic type is Bar
    pf->foo();     // MUST print "Foo"
    pf->virtfoo(); // MUST print "Bar"
}

因此,在实现中没有必要将非虚函数放入vtable中,实际上在Bar的vtable中,您需要两个不同的插槽来存储Foo :: foo()Bar :: foo()。这意味着即使实现希望这样做,它也是vtable的特例用法。实际上,它不想这样做,这样做没有意义,不要担心。
CRTP基类确实应该具有非虚且受保护的析构函数。
如果类的用户可能获取指向对象的指针,将其转换为基类指针类型,然后删除它,则需要虚析构函数。虚拟析构函数意味着这将起作用。基类中的受保护析构函数阻止了它们尝试这样做(因为没有可访问的析构函数,所以delete无法编译)。因此,虚拟或受保护的解决了用户意外引发未定义行为的问题。
请参见此处的指南#4,并注意本文中的“最近”表示近10年:

http://www.gotw.ca/publications/mill18.htm

没有用户会创建一个自己的Base<Derived>对象,除非它是一个Derived对象,因为这不是CRTP基类的目的。他们只需要能够访问析构函数--所以你可以将其留在公共接口中,或者为了节省一行代码,你可以将其保留为公共的,并依靠用户不做傻事。
既然不需要,那么将其设置为虚函数是不可取的原因在于,如果一个类不需要虚函数,那么给它提供虚函数就没有意义。有一天,这可能会带来一些代价,比如对象大小、代码复杂性甚至(不太可能)速度,因此总是将事情变成虚函数是一种过早的悲观主义。在使用CRTP的C++程序员中,首选的方法是绝对清楚类的用途,是否设计为基类,如果是,则是否设计为多态基类。CRTP基类不是这样的。
用户没有理由对CRTP基类进行强制转换,即使它是公开的,因为它实际上并没有提供“更好”的接口。 CRTP基类依赖于派生类,因此如果将Derived*强制转换为Base<Derived>*,您并不会切换到更通用的接口。除非其他类也将Derived作为基类,否则没有其他类将会拥有Base<Derived>作为基类。它不适用于多态基类,因此不要将其作为多态基类。

为了节省一行代码,你可以将其保留为公共的,并依赖于用户不做傻事。但这是一个非常糟糕的想法,在这种情况下懒惰是不被允许的。 - the_drow
@the_drow:当然,但是有些编码指南对于“public”接口是否完全正确相对宽松。只要发布的接口正确,那么其他所有内容都不可访问就很好了,但并不是所有人都会在意这种事情。特别是当他们在博客文章中展示自己聪明的CRTP类时;-)所以我想我的意思是,不要假设你看到的东西有一个很好的理由,并且是最佳实践。 - Steve Jessop
@Steve Jessop:你认为这个指南应该在什么时候放宽,为什么呢?顺便说一下,我还是给你点了赞的 :) - the_drow
@the_drow:我不认为它应该变得宽松。我认为CRTP基类“真的应该”有受保护的析构函数。我只是根据我对nakiya可能已经看到的内容来表达我的观点。同时也想压实自己的责任感,因为我可以非常自信地说,如果你在SO上找到我写的CRTP基类的示例,它将具有默认公共析构函数(即我不会编写一个);-) - Steve Jessop
1
如果你所有的程序员都擅长使用C或Python,并且可以信任他们不会总是自动调用每个函数并访问源代码中看到的每个数据成员(例如头文件),而不管它是否有文档记录,那么更一般的准则“正确获取公共接口”可以放松。但最好还是把它做对。 - Steve Jessop

4

对于你的第一个问题的答案是:不会。只有对虚函数的调用才会在运行时通过虚表进行间接调用。

对于你的第二个问题的答案是:奇异递归模板模式通常使用私有继承来实现。您不需要建立一个“IS-A”关系,因此不需要传递基类指针。

例如,在

template <class Derived> class Base
{
};

class Derived : Base<Derived>
{
};

你没有代码接受一个Base<Derived>*,然后继续调用delete。因此,你从未尝试通过指向基类的指针删除派生类的对象。因此,析构函数不需要是虚拟的。

我在RTCP中看到的意义在于使用模板统一实现基类。我没有看到任何需要使用Base <Derived> *的情况。 - ThR37
现在我很困惑,除非您有一个未知的基础对象在后台为具体对象执行某些操作,否则根本没有使用CRTP的必要。还有其他用途吗? - nakiya
@nakiya:CRTP是实现静态多态性的一种工具。请参见https://dev59.com/rHVC5IYBdhLWcg3wihqv#262984,其中有一个很好的例子。 - Frerich Raabe
请注意,此示例存在缺陷,即在该情况下继承不是私有的,而是公共的。struct derived : base {}等同于class derived : public base {}。但总体论点成立。 - David Rodríguez - dribeas
@dribeas的David Rodríguez:啊,你说得对。我已经相应调整了我的例子。感谢你指出这一点。 - Frerich Raabe
1
很抱歉,您是错误的。使用CRTP模拟“是一个”关系完全合法和合理(原因是什么?请参见此链接:http://eli.thegreenplace.net/2013/12/05/the-cost-of-dynamic-virtual-calls-vs-static-crtp-dispatch-in-c)。如果我错了,请纠正我-干杯。 - h9uest

1
首先,我认为对于OP的问题,答案已经很明确了——坚决不行。
但是,是我疯了还是社区出了严重的问题?我有点害怕看到这么多人建议持有指向Base的指针/引用是无用的/罕见的。上面一些受欢迎的答案建议我们不要使用CRTP来建模IS-A关系,而我完全不同意这些观点。
众所周知,C++中没有接口这种东西。因此,为了编写可测试/可模拟的代码,许多人使用ABC作为“接口”。例如,您有一个函数void MyFunc(Base* ptr),您可以这样使用它:MyFunc(ptr_derived)。这是传统的建模IS-A关系的方法,当您在MyFunc中调用任何虚函数时,需要进行vtable查找。因此,这是一种建模IS-A关系的模式。
在一些性能关键的领域中,存在另一种测试/模拟IS-A关系的方式(pattern two) - 通过CRTP进行建模。实际上,在某些情况下,性能提升可能会非常显著(文章中为600%),请参见此link。因此,MyFunc将如下所示:template<typename Derived> void MyFunc(Base<Derived> *ptr)。当您使用MyFunc时,您需要执行MyFunc(ptr_derived);。编译器将生成与参数类型ptr_derived最匹配的MyFunc()代码副本- MyFunc(Base<Derived> *ptr)。在MyFunc内部,我们可以假设调用了由接口定义的某个函数,并且指针在编译时静态转换(请查看链接中的impl()函数),没有vtable查找的开销。
现在,请问有人能告诉我,我是在胡说八道,还是上面的答案根本没有考虑使用CRTP模式来建模IS-A关系的第二种方法?

我同意你的观点,我想听听那些回答过的人的更多意见! - fdev

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接