为什么在C++中我们需要虚函数?

1637
从我所了解的来看,虚函数是基类中的函数,你可以在其派生类中进行重写。
但是早些时候,在学习基本继承时,我能够在派生类中重写基类函数而不使用virtual
我在这里漏掉了什么?我知道虚函数还有更多内容,而且似乎很重要,所以我想明确一下它到底是什么。

9
这可能是虚函数最大的好处——在不修改旧代码的情况下,能够以一种结构化的方式编写代码,使新派生类自动与旧代码兼容! - user3530616
说实话,虚函数是面向对象编程(OOP)的基本特性,用于类型擦除。我认为,非虚方法才是Object Pascal和C++的特殊之处,它们优化了不必要的大型虚函数表(vtable),并允许POD兼容类。许多OOP语言期望_每个_方法都可以被重写。 - Swift - Friday Pie
1
这是一个好问题。事实上,在其他语言(如Java或PHP)中,C++中的虚拟概念被抽象化了。在C++中,您只能获得更多控制权以处理一些罕见情况(请注意多重继承或DDOD的特殊情况)。但是为什么要在stackoverflow.com上发布这个问题呢? - ILCAI
1
我还没有找到比这个更好的教程:https://nrecursions.blogspot.com/2015/06/so-why-do-we-need-virtual-functions.html - John David
@ILCAI 我不会称其为“抽象化”,而是默认为虚拟的。C++ 中默认情况下没有这个特性,是因为当不需要时它可能会降低性能,而 C++ 的一个原则是不必为你不使用的功能付出性能代价。 - Jimmy T.
显示剩余3条评论
28个回答

3131

以下是我如何理解不仅仅是 virtual 函数的定义,还有为什么它们是必需的:

假设你有这两个类:

class Animal
{
    public:
        void eat() { std::cout << "I'm eating generic food."; }
};

class Cat : public Animal
{
    public:
        void eat() { std::cout << "I'm eating a rat."; }
};

在你的主函数中:

Animal *animal = new Animal;
Cat *cat = new Cat;

animal->eat(); // Outputs: "I'm eating generic food."
cat->eat();    // Outputs: "I'm eating a rat."

到目前为止都还不错,对吧?动物吃泛指的食物,猫吃老鼠,这一切都没有用到virtual

现在我们稍微改变一下,让eat()通过一个中间函数调用(这里只是为了举例而创建的一个简单函数):

// This can go at the top of the main.cpp file
void func(Animal *xyz) { xyz->eat(); }

现在我们的主函数是:

Animal *animal = new Animal;
Cat *cat = new Cat;

func(animal); // Outputs: "I'm eating generic food."
func(cat);    // Outputs: "I'm eating generic food."

哦哦……我们把一只猫传递给了func(),但是它不会吃老鼠。你应该重载func()以便它接受一个Cat*吗?如果你必须从Animal派生更多的动物,它们都需要自己的func()

解决方案是将来自Animal类的eat()函数变为虚函数:

class Animal
{
    public:
        virtual void eat() { std::cout << "I'm eating generic food."; }
};

class Cat : public Animal
{
    public:
        void eat() { std::cout << "I'm eating a rat."; }
};

主函数:

func(animal); // Outputs: "I'm eating generic food."
func(cat);    // Outputs: "I'm eating a rat."

完成。


241
如果我理解正确,那么虚函数允许调用子类的方法,即使该对象被视为其超类? - Kenny Worden
210
不用通过一个中间函数“func”的例子来解释晚绑定,这里有一种更直接的演示方式--__Animal *animal = new Animal;__ __//Cat *cat = new Cat;__ __Animal *cat = new Cat;__ __animal->eat();__ // 输出: "我正在吃着通用的食物." __cat->eat();__ // 输出: "我正在吃着通用的食物." 即使你在给子类对象(Cat)赋值,调用的方法是基于指针类型(Animal)而不是它所指向的对象类型。这就是为什么需要“虚拟”的原因。 - rexbelia
79
这个 C++ 的默认行为只让我感到很奇怪,难道只有我这么觉得吗?我本来以为没有“virtual”关键字的代码也可以运行。 - David 天宇 Wong
33
我认为 virtual 关键字引入了动态绑定的概念,与静态绑定相对应。如果你之前主要使用的是像 Java 这样的语言,这可能会感觉有些奇怪。 - Peter Chaula
49
首先,虚函数调用比普通函数调用要昂贵得多。C++ 的哲学是默认快速执行,所以默认情况下使用虚函数调用是不明智的。第二个原因是,如果从库中继承类并且它在不更改基类行为的情况下更改了公共或私有方法的内部实现(其中调用了虚方法),则虚函数调用可能导致代码崩溃。 - saolof
显示剩余23条评论

826
没有使用virtual关键字时,会出现"早期绑定"。方法的具体实现在编译时根据调用指针的类型来决定。
使用virtual关键字时,会出现"晚期绑定"。方法的具体实现在运行时根据指向的对象的类型来决定,即对象最初构造时的类型。这与指向该对象的指针的类型可能并不一致,需要注意。
class Base
{
  public:
            void Method1 ()  {  std::cout << "Base::Method1" << std::endl;  }
    virtual void Method2 ()  {  std::cout << "Base::Method2" << std::endl;  }
};

class Derived : public Base
{
  public:
    void Method1 ()          {  std::cout << "Derived::Method1" << std::endl;  }
    void Method2 () override {  std::cout << "Derived::Method2" << std::endl;  }
    // Note - override is optional; adding it to Method1 would result in an error
};

Base* basePtr = new Derived ();
// Note - constructed as Derived, but pointer stored as Base*

basePtr->Method1 ();  //  Prints "Base::Method1"
basePtr->Method2 ();  //  Prints "Derived::Method2"

参见


16
很好,用更好的例子迅速解决了问题。但是这种方法过于简单了,提问者应该真正阅读页面http://www.parashift.com/c++-faq-lite/virtual-functions.html。其他人已经在与此线程相关的SO文章中指向了这个资源,但我认为这值得再次提及。 - Sonny
55
我不知道“early”和“late”绑定是否是C++社区中特定使用的术语,但正确的术语是静态(在编译时)和动态(在运行时)绑定。 - mike
40
“Late binding”一词最早可以追溯到20世纪60年代,可以在ACM通讯杂志中找到。如果每个概念只有一个正确的单词,那不是很好吗?不幸的是,情况并非如此。术语“early binding”和“late binding”早于C++甚至面向对象编程,并且与您使用的术语一样正确。 - user180247
5
@BJovke - 这个答案是在 C++11 发布之前编写的。即使如此,我刚在 GCC 6.3.0 中编译了它(默认使用 C++14),没有任何问题 - 显然将变量声明和调用封装在一个 main 函数中等等。指向派生类的指针隐式地转换为指向基类的指针(更具体的隐式转换为更一般的)。相反,您需要显式转换,通常使用 dynamic_cast。其他任何操作都很容易产生未定义行为,因此请确保您知道自己在做什么。据我所知,这种情况甚至在 C++98 之前也没有改变。 - user180247
15
请注意,现今的C++编译器通常能够对晚期绑定进行优化——当它们可以确定绑定将是什么时。这也被称为“去虚拟化”。 - einpoklum
显示剩余13条评论

93

你需要至少一层继承和一个向上转型来演示它。这里是一个非常简单的例子:

class Animal
{        
    public: 
      // turn the following virtual modifier on/off to see what happens
      //virtual   
      std::string Says() { return "?"; }  
};

class Dog: public Animal
{
    public: std::string Says() { return "Woof"; }
};

void test()
{
    Dog* d = new Dog();
    Animal* a = d;       // refer to Dog instance with Animal pointer

    std::cout << d->Says();   // always Woof
    std::cout << a->Says();   // Woof or ?, depends on virtual
}

45
你的示例表明返回的字符串取决于函数是否为虚函数,但没有说明哪个结果对应虚函数,哪个结果对应非虚函数。此外,由于未使用返回的字符串,因此有些令人困惑。 - Ross
10
带有Virtual关键字:_Woof_。 不带Virtual关键字:_?_。 - Hesham Eraqi
@HeshamEraqi 如果没有虚拟函数,它就是早期绑定,会显示基类的“?”。 - Ahmad

61

虚函数用于支持运行时多态性

也就是说,virtual关键字告诉编译器不要在编译时做出决定(函数绑定),而是将其推迟到运行时

  • 您可以通过在其基类声明中使用关键字virtual来使函数成为虚函数。例如:

    class Base
    {
       virtual void func();
    }
    
  • 当一个基类有一个虚成员函数时,任何继承自该基类的类都可以使用完全相同的原型重新定义该函数,即只能重新定义功能,而不能重新定义函数的接口。

    class Derive : public Base
    {
       void func();
    }
    
  • 基类指针可以用于指向基类对象以及派生类对象。

  • 当使用基类指针调用虚函数时,编译器会在运行时决定调用哪个版本的函数,即基类版本还是重写的派生类版本。这被称为运行时多态性


50
你需要虚函数来实现 安全向下转型简洁明了以及简短代码。这就是虚函数的作用:通过表现出的简单且简洁的代码进行安全向下转换,避免了更加复杂和冗长代码中不安全的手动强制类型转换。
非虚函数 ⇒ 静态绑定 ========================================

以下代码故意"错误"。它没有将value方法声明为virtual,因此产生了一个意外的“错误”结果,即0:

#include <iostream>
using namespace std;

class Expression
{
public:
    auto value() const
        -> double
    { return 0.0; }         // This should never be invoked, really.
};

class Number
    : public Expression
{
private:
    double  number_;
    
public:
    auto value() const
        -> double
    { return number_; }     // This is OK.

    Number( double const number )
        : Expression()
        , number_( number )
    {}
};

class Sum
    : public Expression
{
private:
    Expression const*   a_;
    Expression const*   b_;
    
public:
    auto value() const
        -> double
    { return a_->value() + b_->value(); }       // Uhm, bad! Very bad!

    Sum( Expression const* const a, Expression const* const b )
        : Expression()
        , a_( a )
        , b_( b )
    {}
};

auto main() -> int
{
    Number const    a( 3.14 );
    Number const    b( 2.72 );
    Number const    c( 1.0 );

    Sum const       sum_ab( &a, &b );
    Sum const       sum( &sum_ab, &c );
    
    cout << sum.value() << endl;
}
在被注释为“bad”的代码行中,调用了Expression::value方法,因为在编译时已知的类型是Expressionvalue方法不是虚函数。
虚函数 ⇒ 动态绑定。 ====================================== 在静态已知类型Expression中声明valuevirtual可以确保每次调用都会检查对象的实际类型,并调用适合该动态类型value实现。
#include <iostream>
using namespace std;

class Expression
{
public:
    virtual
    auto value() const -> double
        = 0;
};

class Number
    : public Expression
{
private:
    double  number_;
    
public:
    auto value() const -> double
        override
    { return number_; }

    Number( double const number )
        : Expression()
        , number_( number )
    {}
};

class Sum
    : public Expression
{
private:
    Expression const*   a_;
    Expression const*   b_;
    
public:
    auto value() const -> double
        override
    { return a_->value() + b_->value(); }    // Dynamic binding, OK!

    Sum( Expression const* const a, Expression const* const b )
        : Expression()
        , a_( a )
        , b_( b )
    {}
};

auto main() -> int
{
    Number const    a( 3.14 );
    Number const    b( 2.72 );
    Number const    c( 1.0 );

    Sum const       sum_ab( &a, &b );
    Sum const       sum( &sum_ab, &c );
    
    cout << sum.value() << endl;
}

这里的输出是6.86,正如应该的那样,因为虚拟方法被动态地调用。这也被称为调用的动态绑定。进行了一些小的检查,找到了对象的实际动态类型,并调用了相关的方法实现。

相关的实现是最具体(最派生)类中的实现。

请注意,这里派生类中的方法实现没有标记为virtual,而是被标记为override。它们可以标记为virtual,但它们自动成为虚拟的。关键字override确保如果某个基类中没有这样的虚拟方法,则会出现错误(这是可取的)。


没有使用虚拟方法带来的丑陋 ==================================================

如果没有使用virtual,就必须实现一些自己的版本的动态绑定。这通常涉及不安全的手动向下转换、复杂性和冗长性。

对于仅有一个函数的情况,如此处,将函数指针存储在对象中并通过该函数指针调用即可满足要求,但即便如此,仍然涉及一些不安全的向下转换、复杂性和冗长性,即:

#include <iostream>
using namespace std;

class Expression
{
protected:
    typedef auto Value_func( Expression const* ) -> double;

    Value_func* value_func_;

public:
    auto value() const
        -> double
    { return value_func_( this ); }
    
    Expression(): value_func_( nullptr ) {}     // Like a pure virtual.
};

class Number
    : public Expression
{
private:
    double  number_;
    
    static
    auto specific_value_func( Expression const* expr )
        -> double
    { return static_cast<Number const*>( expr )->number_; }

public:
    Number( double const number )
        : Expression()
        , number_( number )
    { value_func_ = &Number::specific_value_func; }
};

class Sum
    : public Expression
{
private:
    Expression const*   a_;
    Expression const*   b_;
    
    static
    auto specific_value_func( Expression const* expr )
        -> double
    {
        auto const p_self  = static_cast<Sum const*>( expr );
        return p_self->a_->value() + p_self->b_->value();
    }

public:
    Sum( Expression const* const a, Expression const* const b )
        : Expression()
        , a_( a )
        , b_( b )
    { value_func_ = &Sum::specific_value_func; }
};


auto main() -> int
{
    Number const    a( 3.14 );
    Number const    b( 2.72 );
    Number const    c( 1.0 );

    Sum const       sum_ab( &a, &b );
    Sum const       sum( &sum_ab, &c );
    
    cout << sum.value() << endl;
}

从积极的角度来看,如果你像上面那样遇到了不安全的向下转换、复杂性和冗长性,通常可以使用虚拟方法真正地帮助解决问题。


39
如果基类是Base,派生类是Der,你可以有一个Base *p指针,它实际上指向Der的实例。当你调用p->foo()时,如果foo不是虚函数,则会执行Base版本的foo,忽略p实际上指向Der的事实。如果foo是虚函数,则p->foo()将执行“最底层”覆盖的foo,完全考虑指向的项目的实际类。因此,虚函数和非虚函数之间的区别实际上非常重要:前者允许运行时多态,这是面向对象编程的核心概念,而后者则不允许。

9
很抱歉打扰您,但编译时多态仍然是多态。 即使重载非成员函数也是一种多态形式 - 在您提供的链接中使用术语即为特设多态。这里的区别在于早期绑定和晚期绑定。 - user180247
7
@Steve314,作为一个苛刻的人,你是正确的(作为一个同样的苛刻者,我赞成;-)——编辑答案以添加缺失的形容词;-)。 - Alex Martelli

30

虽然与上述答案使用相同的概念,但是我希望添加另一种使用虚函数的方法。以下是一个示例程序:

虚析构函数

考虑下面的程序,在不将Base类的析构函数声明为虚拟的情况下,可能无法清理Cat的内存。

class Animal {
    public:
    ~Animal() {
        cout << "Deleting an Animal" << endl;
    }
};
class Cat:public Animal {
    public:
    ~Cat() {
        cout << "Deleting an Animal name Cat" << endl;
    }
};

int main() {
    Animal *a = new Cat();
    delete a;
    return 0;
}

输出:

Deleting an Animal
class Animal {
    public:
    virtual ~Animal() {
        cout << "Deleting an Animal" << endl;
    }
};
class Cat:public Animal {
    public:
    ~Cat(){
        cout << "Deleting an Animal name Cat" << endl;
    }
};

int main() {
    Animal *a = new Cat();
    delete a;
    return 0;
}
输出:
Deleting an Animal name Cat
Deleting an Animal

13
如果没有将基类析构函数声明为虚函数,就可能无法清理Cat对象的内存。更糟糕的是,通过基类指针或引用删除派生对象是纯粹未定义的行为。因此,不仅可能会泄漏一些内存,而且该程序是不合法的,因此编译器可能会将其转换为任何东西:机器码可能正常工作,也可能什么也不做,或从你的鼻子召唤恶魔等。这就是为什么如果一个程序设计成某个用户可能通过基础引用删除派生实例,那么基类必须有一个虚析构函数。 - underscore_d

28

解释虚函数的必要性 [易于理解]

#include<iostream>

using namespace std;

class A{
public: 
        void show(){
        cout << " Hello from Class A";
    }
};

class B :public A{
public:
     void show(){
        cout << " Hello from Class B";
    }
};


int main(){

    A *a1 = new B; // Create a base class pointer and assign address of derived object.
    a1->show();

}

输出将会是:

Hello from Class A.

但使用虚函数:

#include<iostream>

using namespace std;

class A{
public:
    virtual void show(){
        cout << " Hello from Class A";
    }
};

class B :public A{
public:
    virtual void show(){
        cout << " Hello from Class B";
    }
};


int main(){

    A *a1 = new B;
    a1->show();

}

输出将会是:

Hello from Class B.
因此,使用虚函数可以实现运行时多态性。

24
你需要区分重载和覆盖。没有使用虚关键字,你只会重载基类的一个方法。这意味着仅仅是隐藏。 假设你有一个基类Base和一个派生类Specialized,它们都实现了void foo()。现在你有一个指向Specialized实例的Base指针。当你调用foo()时,你可以观察到virtual的差异: 如果该方法是虚拟的,将使用Specialized的实现,如果缺少,则选择来自Base的版本。 最佳实践是从基类中永远不要重载方法。使方法非虚拟是作者告诉你它在子类中的扩展并不打算的方式。

3
如果没有 virtual,你并不是在重载函数而是在进行隐藏。如果基类 B 有一个或多个名为 foo 的函数,而派生类 D 定义了名为 foo 的函数,则所有这些在 B 中的 foo 都被隐藏了。它们可以通过作用域解析访问为 B::foo。如果要将 B::foo 函数提升到 D 中进行重载,必须使用 using B::foo - Kaz
“重载”基本上意味着您编写了另一个函数,纯属巧合地与现有函数具有相同的名称。 - gnasher729

23

我已经把答案以对话的形式整理好了:


为什么我们需要虚函数?

因为多态。

什么是多态?

基类指针可以指向派生类对象的事实。

这个多态的定义如何导致我们需要虚函数?

通过早绑定

什么是早绑定?

C++中的早绑定(编译时绑定)意味着在程序执行之前函数调用已经固定。

那么呢...?

如果你使用一个基类型作为函数的参数,编译器将只识别基接口。如果你使用任何来自派生类的参数来调用该函数,它会被切割掉,这不是你想发生的。

如果这不是我们想要发生的,为什么还允许发生?

因为我们需要多态!

那什么是多态的好处呢?

你可以使用一个基类型指针作为单个函数的参数,在程序运行时,你可以使用那个单一的基指针进行解引用,从而访问每个派生类型接口(例如它们的成员函数),没有任何问题。

我还是不知道虚函数有什么用...!这是我的第一个问题!

好吧,这是因为你问的太早了!

为什么我们需要虚函数?

假设您使用一个基指针调用函数,该指针具有其派生类之一的对象的地址。正如我们之前谈到的那样,在运行时,这个指针被解引用,目前为止没问题,然而,我们期望执行“来自我们的派生类”的方法(即成员函数)!但是,在基类中已经定义了相同的方法(具有相同的头文件),所以为什么您的程序还要费心选择另一个方法呢?换句话说,我是说,你怎么能把这种情况和以前通常发生的事情区分开来呢?

简短的答案是“基类中的虚成员函数”,稍微长一点的答案是,“在这一步中,如果程序在基类中看到虚函数,它会意识到你正在尝试使用多态”,因此会转向派生类(使用v-table,一种晚绑定形式)来查找具有相同头文件但具有-预期的不同实现的另一个方法

为什么是不同的实现?

你这个笨蛋!去读一本好书吧!

好的,等等等等,为什么有人要用基指针,而不是直接使用派生类型指针?你来判断,这一切麻烦值得吗?看看下面这两个代码片段:

//1:

Parent* p1 = &boy;
p1 -> task();
Parent* p2 = &girl;
p2 -> task();

//2:

Boy* p1 = &boy;
p1 -> task();
Girl* p2 = &girl;
p2 -> task();

好的,虽然我认为1仍然比2更好,但是你可以像这样写1

//1:

Parent* p1 = &boy;
p1 -> task();
p1 = &girl;
p1 -> task();

而且,您应该意识到这只是我迄今为止解释给你的所有东西的一个人为运用。相反,假设您在程序中有一个函数,该函数分别使用从每个派生类中继承的方法(getMonthBenefit()):

double totalMonthBenefit = 0;    
std::vector<CentralShop*> mainShop = { &shop1, &shop2, &shop3, &shop4, &shop5, &shop6};
for(CentralShop* x : mainShop){
     totalMonthBenefit += x -> getMonthBenefit();
}

现在,尝试重新写这个句子,不必头痛!

double totalMonthBenefit=0;
Shop1* branch1 = &shop1;
Shop2* branch2 = &shop2;
Shop3* branch3 = &shop3;
Shop4* branch4 = &shop4;
Shop5* branch5 = &shop5;
Shop6* branch6 = &shop6;
totalMonthBenefit += branch1 -> getMonthBenefit();
totalMonthBenefit += branch2 -> getMonthBenefit();
totalMonthBenefit += branch3 -> getMonthBenefit();
totalMonthBenefit += branch4 -> getMonthBenefit();
totalMonthBenefit += branch5 -> getMonthBenefit();
totalMonthBenefit += branch6 -> getMonthBenefit();

而且实际上,这可能仍然是一个人为的例子!


2
强调使用单个(超级)对象类型迭代不同类型的(子)对象的概念是一个很好的观点,谢谢你。 - gawkface

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