内联虚函数真的毫无意义吗?

192

我在收到代码审查评论时遇到了这个问题,评论中说虚函数不需要是内连的。

我曾认为,在直接调用对象的函数中,内联虚函数可能会派上用场。但我想到了反驳的论点——为什么要定义虚函数,然后使用对象调用方法呢?

由于它们几乎永远不会被扩展,所以最好不要使用内联虚函数吗?

我用于分析的代码段:

class Temp
{
public:

    virtual ~Temp()
    {
    }
    virtual void myVirtualFunction() const
    {
        cout<<"Temp::myVirtualFunction"<<endl;
    }

};

class TempDerived : public Temp
{
public:

    void myVirtualFunction() const
    {
        cout<<"TempDerived::myVirtualFunction"<<endl;
    }

};

int main(void) 
{
    TempDerived aDerivedObj;
    //Compiler thinks it's safe to expand the virtual functions
    aDerivedObj.myVirtualFunction();

    //type of object Temp points to is always known;
    //does compiler still expand virtual functions?
    //I doubt compiler would be this much intelligent!
    Temp* pTemp = &aDerivedObj;
    pTemp->myVirtualFunction();

    return 0;
}

1
考虑使用所需的任何开关编译示例,以获取汇编程序清单,然后向代码审查人员展示编译器确实可以内联虚拟函数。 - Thomas L Holaday
1
通常情况下,上述代码不会进行内联,因为你正在调用基类中的虚函数。尽管这取决于编译器的智能程度。如果它能够指出 pTemp->myVirtualFunction() 可以被解析为非虚拟调用,则可以将其内联。通过g++ 3.4.2编译器,这个引用调用被内联了:TempDerived& pTemp = aDerivedObj; pTemp.myVirtualFunction();但是你的代码没有被内联。 - mip
1
gcc 实际上做的一件事情是将 vtable 条目与特定符号进行比较,如果匹配,则在循环中使用内联变体。如果内联函数为空并且可以消除循环,则这尤其有用。 - Simon Richter
1
现代编译器在编译时会尽力确定指针的可能值。仅仅使用指针是不足以防止在任何重要的优化级别下进行内联的;GCC甚至在优化级别为零时也会执行简化操作! - curiousguy
13个回答

166

虚函数有时可以内联。以下摘自C++ faq:

"只有当编译器知道虚函数调用的目标对象的“确切类”时,才能内联虚函数调用。这只有在编译器拥有实际对象而不是指向对象的指针或引用时才会发生。也就是说,使用本地对象、全局/静态对象或完全包含在复合对象内的对象。"


10
没错,但值得记住的是,即使调用可以在编译时解析并且可以进行内联,编译器仍然可以选择忽略内联指示符。 - sharptooth
6
我认为内联的另一种情况是当您以这样的方式调用方法时:this->Temp::myVirtualFunction(),这种调用可以跳过虚表解析,而函数应该可以无问题地内联 - 为什么以及是否要这样做是另一个话题 :)。 - RnR
5
不需要使用 "this->",只用限定名称就足够了。这种行为适用于析构函数、构造函数以及通常的赋值运算符(请参见我的答案)。 - Richard Corden
2
sharptooth - 是的,但据我所知,这适用于所有内联函数,而不仅仅是虚拟内联函数。 - Colen
2
void f(const Base& lhs, const Base& rhs) { } ------在函数的实现中,直到运行时你才知道lhs和rhs指向什么。 - Baiyan Huang
显示剩余2条评论

83

C++11新增了final。这改变了原先的答案:现在不再需要知道对象的确切类别,只需知道对象至少具有函数声明为final的类类型即可:

class A { 
  virtual void foo();
};
class B : public A {
  inline virtual void foo() final { } 
};
class C : public B
{
};

void bar(B const& b) {
  A const& a = b; // Allowed, every B is an A.
  a.foo(); // Call to B::foo() can be inlined, even if b is actually a class C.
}

无法在VS 2017中进行内联。 - Yola
1
我认为这样行不通。通过类型 A 的指针 / 引用调用 foo() 永远不能进行内联。调用 b.foo() 应该允许内联。除非您建议编译器已经知道这是类型 B,因为它知道上一行。但这不是典型的用法。 - Jeffrey Faust
例如,比较这里 bar 和 bas 的生成代码:https://godbolt.org/g/xy3rNh - Jeffrey Faust
3
这段话的意思是:“信息应该被传播,没有理由不这样做。而根据那个链接,‘icc’似乎就是在做这件事。” - Alexey Romanov
1
@AlexeyRomanov编译器有超越标准的优化自由,它们确实这样做了!对于像上面这样简单的情况,编译器可以知道类型并进行此优化。事情很少这么简单,在编译时通常无法确定多态变量的实际类型。我认为OP关心的是“一般情况”,而不是这些特殊情况。 - Jeffrey Faust

39

还有一类虚函数,在这种情况下仍然有必要将它们设置为内联。考虑以下情况:

class Base {
public:
  inline virtual ~Base () { }
};

class Derived1 : public Base {
  inline virtual ~Derived1 () { } // Implicitly calls Base::~Base ();
};

class Derived2 : public Derived1 {
  inline virtual ~Derived2 () { } // Implicitly calls Derived1::~Derived1 ();
};

void foo (Base * base) {
  delete base;             // Virtual call
}

调用delete 'base'将执行虚函数调用以调用正确的派生类析构函数,此调用不会被内联。但是,由于每个析构函数都调用其父类的析构函数(在这些情况下为空),编译器可以内联那些调用,因为它们不会通过虚函数调用基类函数。

相同的原理也适用于基类构造函数或任何一组函数,在这些函数中,派生类的实现也调用了基类的实现。


26
需要注意的是,空括号并不总是意味着析构函数什么也不做。析构函数会默认销毁类中的每个成员对象,因此如果基类中有几个向量,在这些空括号中可能需要进行大量的工作! - Philip
在类中定义函数时,不需要使用inline关键字,因为它是隐式的。 - Bas

14

我曾见过一些编译器不会在没有任何非内联函数的情况下发出任何 v-table(并且只在一个实现文件中定义而不是在头文件中定义)。它们会抛出类似于“缺少 vtable-for-class-A”之类的错误,这会让你感到混乱,就像我一样。

实际上,这不符合标准,但是确实发生了,因此请至少将一个虚函数放在头文件之外(如果只有虚析构函数),以便编译器可以在那个位置为类发出一个 vtable。我知道在某些版本的gcc中会发生这种情况。

正如某人所提到的,内联虚函数有时可能是有益的,但当然大多数情况下您将在不知道对象的动态类型时使用它,因为这正是virtual首次推出的原因。

然而,编译器不能完全忽略inline。它除了可以加速函数调用之外还有其他语义。在类中定义的隐式内联是一种机制,允许您将定义放入头文件中:只有inline函数才能在整个程序中定义多次而不违反任何规则。最终,它的行为就像您在整个程序中只定义了一次一样,即使您将头文件多次包含在链接在一起的不同文件中。


13

实际上,只要虚函数们被静态地链接在一起,它们总是可以被内联的:假设我们有一个抽象类 Base,其中有一个虚函数 F,并且有派生类 Derived1Derived2

class Base {
  virtual void F() = 0;
};

class Derived1 : public Base {
  virtual void F();
};

class Derived2 : public Base {
  virtual void F();
};

假设调用语句为b->F();(其中b的类型为Base*),那么显然它是虚函数。但是你(或者编译器...)可以这样重写它(假设typeof是类似于typeid的函数,返回一个可以在switch语句中使用的值)

switch (typeof(b)) {
  case Derived1: b->Derived1::F(); break; // static, inlineable call
  case Derived2: b->Derived2::F(); break; // static, inlineable call
  case Base:     assert(!"pure virtual function call!");
  default:       b->F(); break; // virtual call (dyn-loaded code)
}

虽然我们仍然需要使用RTTI来获取typeof,但是可以通过将vtable嵌入指令流并为所有涉及的类专门化调用,从而有效地内联调用。这也可以通过仅为一些类(比如,只有Derived1)进行专门化来实现。

switch (typeof(b)) {
  case Derived1: b->Derived1::F(); break; // hot path
  default:       b->F(); break; // default virtual call, cold path
}

1
有没有编译器可以做到这一点?还是这只是猜测?如果我过于怀疑,请原谅,但是您在上面的描述中的语气听起来有点像“它们完全可以做到这一点!”,这与“一些编译器可以做到这一点”不同。 - Alex Meiburg
1
是的,Graal可以进行多态内联(也适用于通过Sulong传递的LLVM位代码)。 - CAFxX

4

3

inline 并不会做任何事情 - 它只是一个提示。编译器可能会忽略它,或者如果看到实现并且喜欢这个想法,可能会在没有 inline 的情况下内联调用事件。如果代码清晰度受到威胁,则应删除 inline


4
对于只操作单个TU的编译器,它们只能隐式内联定义在该TU中的函数。如果要在多个TU中定义一个函数,则必须将其声明为"inline"。 "inline"不仅仅是一个提示,对于g++/makefile构建而言,它可以带来显著的性能改进。 - Richard Corden

3
内联声明的虚函数在通过对象调用时进行内联处理,在通过指针或引用调用时被忽略。

1
编译器只能在编译时可以明确解析函数调用时,才能将其内联。
然而,虚函数是在运行时解析的,因此编译器无法内联调用,因为在编译时无法确定动态类型(因此也无法确定要调用的函数实现)。

1
当您从同一类或派生类中调用基类方法时,该调用是明确且非虚拟的。 - sharptooth
1
@sharptooth:但这样它就会成为一个非虚拟的内联方法。编译器可以内联你没有要求的函数,并且它可能更清楚何时内联或不内联。让它自己决定。 - David Rodríguez - dribeas
1
@dribeas:是的,这正是我所说的。我只是反对虚函数在运行时解析的说法 - 这只有在调用是虚拟的时才是真的,而不是针对确切的类。 - sharptooth
我认为那是无稽之谈。任何函数都可以被内联起来,无论它有多大或者是否是虚函数,这取决于编译器的实现方式。如果您不同意,那么我认为您的编译器也不能生成非内联代码。也就是说,编译器可以包含在运行时测试无法在编译时解决的条件的代码。就像现代编译器可以在编译时解析常量值/减少数值表达式一样。如果一个函数/方法没有被内联,那并不意味着它不能被内联。 - user1985657

1

使用现代编译器,内联它们不会有任何问题。一些古老的编译器/链接器组合可能会创建多个虚表,但我认为这已经不是问题了。


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