C++中的虚函数、重写与多态

在C++中顺利使用虚函数需知道的细节

  • 如函数在派生类中的定义有别于基类中的定义,而且你希望它成为虚函数,就要为基类的函数声明添加保留字virtual。在派生类的函数声明中,则可以不添加virtual。函数在基类中virtual,在派生类中自动virtual(但为了澄清,最好派生类中也将函数声明标记为virtual,尽管这非必须)。
  • 保留字virtual在函数声明中添加,不要再函数定义中添加。
  • 除非使用保留字virtual,否则不能获得虚函数,也不能获得虚函数的任何好处。
  • 既然虚函数如此好用,为何不将所有成员函数都设为virtual?这似乎只有一个理由——效率。编译器和“运行时”环境要为虚函数做多得多的工作。所以,无谓地将成员函数为virtual会影响程序执行效率。

重写

虚函数定义在派生类中发生改变时我们说函数定义被重写。一些C++书籍区分了重定义(redefine)和重写(override)。两者都是在派生类更改函数定义。函数是虚函数,就称为重写。如果不是,就称为重定义。对于我们程序员而言,这种区分似乎有点无聊,因为程序员在两种情况下做的事情是一样的。不过,编译器对于这两种情况确定是区别对待的。

多态

多态性是指借助晚期绑定技术,为一个函数名关联多种含义的能力。因此,多态性、晚期绑定和虚函数其实是同一个主题。

虚函数和扩展类型兼容性、切割问题

#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;

class Pet
{
public:
    virtual void print();
    string name;
};

class Dog : public Pet
{
public:
    virtual void print();
    string breed; // 品种
};

void Pet::print()
{
    cout << "Pet name: " << name << endl;
}

void Dog::print()
{
    cout << "Dog name: " << name << ", breed: " << breed << endl;
}

int main()
{
    Pet vPet;
    Dog vDog;
    vDog.name = "Tiny";
    vDog.breed = "Great Dane";
    vPet = vDog;
    // cout << vPet.breed;
    return 0;
}

上述代码vPet = vDog;的赋值是允许的,但赋给变量vPet的值会丢失其breed字段。这称为切割问题(slicing problem)。例如,cout << vPet.breed会报错。

切割问题:在将派生类对象赋给基类变量时,派生类对象有、基类没有的数据成员会在赋值过程中丢失,基类没有的成员函数也会丢失。在最终的基类对象中,将无法使用这些丢失的成员。

切割测试:

#include <iostream>
#include <string>

using std::cout;
using std::endl;
using std::string;

class Demo
{
public:
    Demo(const string& s): str(s)
    {
        cout << "Demo constructor called (" + str + ").\n";
    }
    ~Demo()
    {
        cout << "Demo deconstructor called (" + str + ").\n";
    }
    Demo(const Demo& other)
    {
        str = other.str;
        cout << "Demo copy constructor called (" + str + ").\n";
    }
    Demo& operator=(const Demo& other)
    {
        str = other.str;
        cout << "Demo operator= called (" + str + ").\n";
        return *this;
    }
private:
    string str;
};

class Base
{
public:
    Demo member1 = Demo("member1");
};

class Derived : public Base
{
public:
    Demo member2 = Demo("member2");
};

int main()
{
    Derived derived;
    Base base;
    base = derived;
}
/* Output
Demo constructor called (member1).
Demo constructor called (member2).
Demo constructor called (member1).
Demo operator= called (member1).
Demo deconstructor called (member1).
Demo deconstructor called (member2).
Demo deconstructor called (member1).
*/

幸好,C++提供了一种方式,允许在将一个Dog视为Pet的同时不丢失品种名称:

Pet *pPet;
Dog *pDog;
pDog = new Dog;
pDog->name = "Tiny";
pDog->breed = "Great Dane";
pPet = pDog;
pPet->print(); // prints "Dog name: Tiny, breed: Great Dane"

基类Petprint()声明为virtual。所以一旦编译器看到pPet->print();就会检查PetDogvirtual表,判断pPet指向的是Dog类型的对象。因此,它会使用Dog::print(),而不是Pet::print()

配合动态变量进行OOP是一种全然不同的编程方式。只要记住以下两条简单的规则,理解起来就容易得多。

  1. 如果指针pAncestor的域类型是指针pDescendant的域类型的基类,则以下指针赋值操作允许:pAncestor = pDescendant;。此外,pDescendant指向的动态变量的任何数据成员或成员函数都不会丢失。
  2. 虽然动态变量所有附加字段(成员)都没有丢,但要用virtual成员函数访问。

视图对虚成员函数定义不齐全的类进行编译

编译前,如果还有任何尚未实现的virtual成员函数,编译就会失败,并产生形如undefined reference to Class_Name virtual table的错误信息。即使没有派生类,只有一个virtual成员,并且没有调用该虚函数,只要函数没有定义,就会产生这种形式的消息。此外,可能还会产生进一步的错误消息,声称程序对默认构造函数进行了未定义的引用,即使确实已定义了这些构造函数。

始终/尽量使析构函数成为虚函数(主要讲述把析构函数声明为虚函数的优点)

这里主要阐述让析构函数称为虚函数的好处,但实际上也有坏处。在《Effective C++》条款07中有提到具体内容,见本文后记

析构函数最好都是虚函数。但在解释它为什么好之前,首先解释一下析构函数和指针如何交互,以及虚析构函数的具体含义。如以下代码,其中SomeClass是含有非虚析构函数的类:

SomeClass *p = new SomeClass;
// ...
delete p;

p调用delete,会自动调用SomeClass类的析构函数,现在看看将析构函数标记为virtual之后会发生什么。为了描述析构函数与虚函数机制的交互,最简单的方式是将所有析构函数都视为同名(即使它们并非真的同名)。如假定Derived类是Base类的派生类,并假定Base类的析构函数标记为virtual,现在分析以下代码:

Base *pBase = new Derived;
// ...
delete pBase;

pBase调用delete时,会调用一个析构函数。由于Base类中的析构函数标记为virtual,且指向的对象是Derived类型,故会调用Derived的析构函数(它进而调用Base类的析构函数)。若Base类的析构函数没有标记为virtual,则只调用Base类的析构函数。

还要注意一点,将析构函数标记为virtual后,派生类的所有析构函数都自动成为virtual的(不管是否用virtual标记)。同样,这种行为就好比所有析构函数具有相同的名称(即使事实上不同名)

现在,已准备好解释为什么所有析构函数都应该是虚函数。假定Base类有一个指针类型的成员变量pBBase类的构造函数会创建由pB指向的一个动态变量,而Base类的析构函数会删除之;另外,假定Base类的析构函数没有标记为virtual,并假定Derived类(从Base派生)有一个指针类型的成员变量pDDerived类的构造函数会创建由pD指向的一个动态变量,而Derived类的析构函数会删除之。则以下代码

Base *pBase = new Derived;
// ...
delete pBase;

由于基类析构函数未标记为virtual,所以只会调用Base类的析构函数。这会将pB指向的动态变量的内存返还给自由存储;但pD指向的动态变量占用的内存永远不会返还给自由存储直到程序终止。

另一方面,将基类Base析构函数标记为virtualdelete pBase;时会调用Derived类的析构函数(因为指向的对象是Derived类型)。Derived类的析构函数会删除pD指向的动态变量,再自动调用基类Base的析构函数删除pB指向的动态变量。

测试代码:

#include <iostream>

class Base
{
public:
    Base()
    {
        baseData = new int;
        std::cout << "baseData allocated.\n";
    }
    ~Base()
    {
        delete baseData;
        std::cout << "baseData deleted.\n";
    }
private:
    int *baseData;
};

class Derived : public Base
{
public:
    Derived()
    {
        derivedData = new int;
        std::cout << "derivedData allocated.\n";
    }
    ~Derived()
    {
        delete derivedData;
        std::cout << "derivedData deleted.\n";
    }
private:
    int *derivedData;

};

int main()
{
    Base *base = new Derived;
    delete base;
}
/* Output
baseData allocated.
derivedData allocated.
baseData deleted.
*/

将第11行的~Base()改为virtual ~Base(),程序输出为

/* Output
baseData allocated.
derivedData allocated.
derivedData deleted.
baseData deleted.
*/

后记

参考:Walter Savitch《Problem Solving with C++, Tenth Edition》《Effective C++》。

《Effective C++》条款07:“为多态基类声明virtual析构函数”中提到:

  • 带多态性质的基类应该声明一个virtual析构函数;如果类带有任何virtual函数,则它就应该拥有一个virtual析构函数。
  • 类的设计目的如果不是作为基类使用,或不是为了具备多态性,就不该声明virtual析构函数。(如标准库input_iterator_tag等)

原文链接: https://www.cnblogs.com/sandychn/p/12421961.html

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

    C++中的虚函数、重写与多态

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/333510

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年3月1日 下午9:12
下一篇 2023年3月1日 下午9:13

相关推荐