【c++】虚函数与纯虚函数以及抽象类的虚析构问题

 一.虚函数 [虚函数借助于指针或者引用来达到多态的效果 ]

1.1 定义

virtual void fuc();

在基类中将一个函数声明为虚函数,使该函数具有虚属性,那么其所有派生函数中该函数的重写都具备了虚属性,也就使得基类指针可以调用派生类实例中继承自该基类的所有成员函数,且若有重写,调用的都是重写后的函数。

class A
{
public:
    virtual void foo(){// 虚函数关键字 virtual
        cout<<"A::foo() is called"<<endl;
    }
};

//派生类根据需要,重写基类虚函数
class B:public A
{
public:
    void foo(){
        cout<<"B::foo() is called"<<endl;
    }
};
int main(void){
    A *a = new B();//基类指针指向new出的派生类
    a->foo();   // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
    return 0;
}

这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。

1.2 作用【动态联编实现多态

虚函数的作用:基于向上类型转换,基类通过虚函数可以对多个子类相似的功能实现统一管理。

#include <iostream>
using namespace std;
class Animal{
public:
    virtual void speak(){ cout << "Animal" << endl; }
};
class Cat : public Animal{
public:
    void speak(){ cout << "Cat" << endl; }
};
class Dog : public Animal{
public:
    void speak(){ cout << "Dog" << endl; }
};
// 注意:此处必须是引用,不然类cat、dog传参时会被自动转换为基类animal,输出均为Animal
void Speak(Animal &a){
    a.speak();
}
int main(){
    Animal animal;
    Cat cat;
    Dog dog;
    Speak(animal);  // 输出 "Animal"
    Speak(cat);     // 输出 "Cat"
    Speak(dog);     // 输出 "Dog"
}
  • 尽管在顶层函数的定义中是以基类A作为其参数,但却能接受基类A的任一子类作为其参数。事实上,这是基于自动向上类型转换,即子类转换为它的父类型。
  • 虽然子类转换成了它的父类型,但却可正确调用属于子类而不属于父类的成员函数。这是虚函数的功劳。

这样,我们通过设计一个以基类型作为参数的顶层函数,就可实现基类及其所有子类相似功能的统一管理,达到一个接口,多种结果,而不用理会不同对象自身的类型。

 

C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。如果调用非虚函数,则无论实际对象是什么类型,都执行基类类型所定义的函数。非虚函数总是在编译时根据调用该函数的对象,引用或指针的类型而确定【编译时绑定】。如果调用虚函数,则直到运行时才能确定调用哪个函数,运行的虚函数是引用所绑定或指针所指向的对象所属类型定义的版本【运行时绑定】。虚函数必须是基类的非静态成员函数。虚函数的作用是实现动态联编,也就是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数重新定义,在派生类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。以实现统一的接口,不同定义过程。如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。

1.3 实现原理【V-Table虚表

虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的,简称为V-Table。在这个表中,主是要一个类的虚函数的地址表【如果一个类中有虚函数,则实例化对象中需要额外占用内存来存放虚表】,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

二.纯虚函数 [规范派生类行为]

2.1 纯虚函数定义【vftable表中对应的表项被赋值为0

声明纯虚函数可使当前类变成抽象类禁止该类被实例化,并要求其非抽象类的派生类必须实现该函数。纯虚函数在类的vftable表中对应的表项被赋值为0,也就是指向一个不存在的函数。由于编译器绝对不允许有调用一个不存在的函数的可能,所以该类不能生成对象。在它的派生类中,除非重写此函数,否则也不能生成对象。因此,这也是含有纯虚函数的抽象类是不能实例化的原因。

virtual void fuc()=0

定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口,强制子类设计者必须用该函数实现某种功能,规范派生类的行为

纯虚函数的意义,让所有的派生类对象都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”

2.2 抽象类

抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。

1.抽象类的定义:称带有纯虚函数的类为抽象类

2.抽象类的作用:

抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。

3.使用抽象类时注意:

  • 虚析构函数:避免内存泄漏,如果基类是非虚析构,那么在基类指针释放时,仅执行基类析构,不会执行派生类的析构,造成内存泄漏;
  • 禁止实例化:抽象类不能定义实例,但可以声明指向实现该抽象类的具体类的指针或引用;
  • 纯虚函数允许实现:在基类中,定义的纯虚函数可以在基类中实现,也可以在派生类中通过base::fuc()来调用基类纯虚函数,另外,根据C++类的规则,因为派生类析构函数会调用基类的析构函数,所以析构函数必须有函数体;
  • 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。

下面展示虚函数和纯虚函数的代码示例,注意观察注释内容:

class Base
{
 public:
  virtual void print() = 0;//纯虚函数,可以有函数体,可实例化的派生类必须重写它
  virtual void play() {//虚函数,有函数体,若派生类没有重写它,就原样继承下来
    cout << "Base play!" << endl;
  }
  virtual ~Base() = 0;//纯虚析构函数,必须要有函数体;为使派生类能完全释放资源,基类析构函数必须声明为虚函数,防止使用基类指针仅执行基类析构,未执行派生类析构,从而造成内存泄漏
  //virtual ~Base(){} //很多情况下,基类的虚函数都会有函数体,将析构函数声明为纯虚函数是一种抽象化基类、禁止实例化的一种方法。
};
void Base::print() {
  //被声明为纯虚函数,仍可以有函数体
  //但派生类若想实例化,必须重写纯虚函数,派生类中可以调用基类有函数体的纯虚函数(Base::print())
  cout << "Base print!" << endl;
}
Base::~Base(){
//被声明为纯虚析构函数,根据C++类的规则,因为其派生类析构函数会调用基类的析构函数,所以必须有函数体,否则不知道你执行了啥
}

4.问题:

(1).虚析构有好处,为什么类中不默认虚析构呢?

C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。

(2).有虚析构有好处,为什么类中没有虚构造呢?

  • 存储空间角度(没构造哪来的虚表):虚函数对应一个vtable(类中若有虚函数,自动有虚表来维护),这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable 中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)

  • 使用角度虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用,通过基类的指针或引用来调用派生类相应的行为,从而实现多态构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。

  • 实现上看,vbtl 在构造函数调用后才建立,因而构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。

三.虚函数与纯虚函数使用时注意事项: 

  1. 抽象基类纯虚函数对类最大的束缚就是类中一旦出现纯虚函数,就不可实例化了,它就是一个抽象类,抽象类是不允许实例化的;
  2. 派生类必须重写基类的纯虚函数:纯虚函数还是可以有函数体,只不过最终还是要被派生类重写,在派生类的函数中可以调用基类中有函数体的纯虚函数;
  3. 定义基类虚析构为使派生类能完全释放资源,基类析构函数必须声明为虚函数,否则,在多态中针对用基类指针接收一个派生类对象后[ A*p=new B() ],delete该指针就只能回收与基类相关的资源,派生类未得到释放,会造成内存泄漏;因此,抽象类的析构必须是虚析构,才能让派生类完全释放资源。
  4. 纯虚析构必须要有函数体根据C++析构函数的调用规则,派生类会调用基类析构函数,如果基类析构函数没有函数体会造成函数调用失败而报错,这是纯虚析构函数与普通纯虚函数不同之处。
  5. 虚函数不能有static关键字:在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。

 

————————————————

参考
链接:https://blog.csdn.net/Spade_/article/details/79465273
链接:https://blog.csdn.net/qq_38755753/article/details/103298444

链接:https://blog.csdn.net/weixin_42837024/article/details/104961205

posted on 2021-08-20 10:03  斗战胜佛美猴王  阅读(371)  评论(0编辑  收藏  举报