回炉重造——虚函数和纯虚函数

写在前面

  • C++中的虚函数的作用主要是实现了多态的机制。

  • 多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用相同的代码来实现可变的算法。


C++虚函数

概念

用virtual关键字修饰的函数就叫虚函数。

虚函数实现机制:

C++编译阶段,没办法知道一个基类的指针或引用所指对象的类型,所以没办法通过这个指针判断调用的虚函数到底是谁的,所以只能通过查找虚函数表来找到函数的入口地址。

一个类,如果有虚函数,那么编译器在编译这个类的时候就会为它添加一个虚函数表,以及指向这个虚函数表的指针。继承这个基类的之类,也会新建一个虚函数表,如果没有重载,那么这个新的虚函数表中的函数指针就被拷贝为父类该函数的地址,否则为新的函数地址。编译器会将这些函数指针在虚函数表中按照基类中该函数出现的次序排列,子类中的虚函数表也将以这种方式排列。

每个有虚函数的类都有一个虚函数表指针pv,当通过指针或引用调用一个虚函数时,先通过pv找到虚函数表,然后根据这个虚函数在虚函数表中的偏移量来找到正确的函数地址,然后再CALL之。

虚函数举例说明

虚函数表是在类之外的,一个类的size不包括虚函数表的大小。而虚函数指针则包含在类中,sizeof一个类则会包含一个虚函数表的指针。

  • 测试虚函数表指针:
class Base
{
public:
    virtual void f1()
    {
        cout << "Base::f1()" << endl;
    }
    int _base;
};
int main()
{
    Base b;
    b._base = 1;
    cout << sizeof(b) << endl;   // 输出 8(64位VS2013环境)
    return 0;
}

对象 b 的大小为什么为 8 字节?

另外4个字节存放了一个void**类型的指针_vfptr,该指针就是虚表指针
虚表指针指向虚函数表,该表存放的就是类Base中的虚函数的地址。

  • 多继承模式下的对象模型:
class Base1
{
public:
      virtual void f1()
      {
            cout << "Base1::f1()" << endl;
      }
      int _base1;
};
class Base2
{
public:
      virtual void f1()
      {
            cout << "Base2::f1()" << endl;
      }
      int _base2;
};
class Derived :public Base1,public Base2
{
public:
      virtual void f1()
      {
            cout << "Derived::f1()" << endl;
      }
      virtual void f2()
      {
            cout << "Derived::f2()" << endl;
      }
      int _derived;
};


纯虚函数

概念

  • 纯虚函数的定义:
class Shape
{
public:
    virtual  double calcArea()//虚函数
    {....}
    virtual  double calcPerimeter()=0;//纯虚函数
    ....
};

纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”。

为什么要引入虚函数

1、为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。

2、在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

3、为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。

4、声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。

纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。

5、定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
纯虚函数的意义:让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

纯虚函数的实现原理:

在虚函数原理的基础上,虚函数表中,虚函数的地址是一个有意义的值,如果是纯虚函数就实实在在的写一个0。

抽象类

  • 含有纯虚函数的类被称为抽象类

含有纯虚函数的类被称为抽象类,比如上面代码中的类就是一个抽象类,包含一个计算周长的纯虚函数。哪怕只有一个纯虚函数,那么这个类也是一个抽象类,纯虚函数没有函数体,所以抽象类不允许实例化对象,抽象类的子类也可以是一个抽象类。抽象类子类只有把抽象类当中的所有的纯虚函数都做了实现才可以实例化对象。

对于抽象的类来说,我们往往不希望它能实例化,因为实例化之后也没什么用,而对于一些具体的类来说,我们要求必须实现那些要求(纯虚函数),使之成为有具体动作的类。

接口类

  • 仅含有纯虚函数的类称为接口类

如果在抽象类当中仅含有纯虚函数而不含其他任何东西,我们称之为接口类。

1、没有任何数据成员
2、仅有成员函数
3、成员函数都是纯虚函数

  • 接口类理解:
class Flyable//会飞
{
public:
    virtual void takeoff()=0;//起飞
    virtual void land()=0;//降落
};
class Bird:public Flyable
{
public:
    ....
    virtual void takeoff(){....}
    virtual void land(){....}
private:
    ....
};
void flyMatch(Flyable *a,Flyable *b)//飞行比赛
//要求传入一个会飞对象的指针,此时鸟类的对象指针可以传入进来
{
    ....
    a->takeoff();
    b->takeoff();
    a->land();
    b->land();
}

上面的代码,定义一个会飞的接口,凡是实现这个接口的都是会飞的,飞行比赛要求会飞的来参加,鸟实现了会飞的接口,所以鸟可以参加飞行比赛,如果复杂点定义一个能够射击的接口,那么实现射击接口的类就可以参加战争之类需要会射击的对象,有一个战斗机类通过多继承实现会飞的接口和射击的接口还可以参加空中作战。


一些重要的问题

问题一:多态中存在的内存泄露

如果在圆形的类中定义一个圆心的坐标,并且坐标是在堆中申请的内存,则在mian函数中通过父类指针操作子类对象的成员函数的时候是没有问题的,可是在销毁对象内存的时候则只是执行了父类的析构函数,子类的析构函数却没有执行,这会导致内存泄漏。部分代码如下(想去借助父类指针去销毁子类对象的时候去不能去销毁子类对象)

如果delete后边跟父类的指针则只会执行父类的析构函数,如果delete后面跟的是子类的指针,那么它即会执行子类的析构函数,也会执行父类的析构函数

class Circle:public Shape
{
public:
    Circle(int x,int y,double r);
    ~Circle();
    virtual double calcArea();
    ....
private:
    double m_dR;
    Coordinate *m_pCenter;      //坐标类指针
    ....
};
Circle::Circle(int x,int y,double r)
{
    m_pCenter=new Coordinate(x,y);
    m_dR=r;
}
Circle::~Circle()
{
    delete m_pCenter;
    m_pCenter-NULL;
}
....
int main()
{
    Shape *shape1=new Circle(3,5,4.0);
    shape1->calcArea();
    delete shape1;
    shape1=NULL;
    return 0;
}

我们必须要去解决这个问题,不解决这个问题当使用的时候都会造成内存泄漏。面对这种情况则需要引入虚析构函数:

class Shape
{
public:
    ....
    virtual ~Shape();
private:
    ....
};
class Circle:public Shape
{
public:
    virtual ~Circle();//与虚函数相同,此处virtual可以不写,系统将会自动添加,建议写上
    ....
};
....

这样父类指针指向的是哪个对象,哪个对象的构造函数就会先执行,然后执行父类的构造函数。销毁的时候子类的析构函数也会执行。

virtual关键字可以修饰普通的成员函数,也可以修饰析构函数,但并不是没有限制。

virtual在函数中的使用限制:

  • 1、普通函数不能是虚函数,也就是说这个函数必须是某一个类的成员函数,不可以是一个全局函数,否则会导致编译错误。

  • 2、静态成员函数不能是虚函数 static成员函数是和类同生共处的,他不属于任何对象,使用virtual也将导致错误。

  • 3、内联函数不能是虚函数 如果修饰内联函数 如果内联函数被virtual修饰,计算机会忽略inline使它变成存粹的虚函数。

  • 4、构造函数不能是虚函数,否则会出现编译错误。

问题二:多态的实现原理

虚函数表指针:类中除了定义的函数成员,还有一个成员是虚函数表指针(占四个基本内存单元),这个指针指向一个虚函数表的起始位置,这个表会与类的定义同时出现,这个表存放着该类的虚函数指针,调用的时候可以找到该类的虚函数表指针,通过虚函数表指针找到虚函数表,通过虚函数表的偏移找到函数的入口地址,从而找到要使用的虚函数。

当实例化一个该类的子类对象的时候,(如果)该类的子类并没有定义虚函数,但是却从父类中继承了虚函数,所以在实例化该类子类对象的时候也会产生一个虚函数表,这个虚函数表是子类的虚函数表,但是记录的子类的虚函数地址却是与父类的是一样的。所以通过子类对象的虚函数表指针找到自己的虚函数表,在自己的虚函数表找到的要执行的函数指针也是父类的相应函数入口的地址。

如果我们在子类中定义了从父类继承来的虚函数,对于父类来说情况是不变的,对于子类来说它的虚函数表与之前的虚函数表是一样的,但是此时子类定义了自己的(从父类那继承来的)相应函数,所以它的虚函数表当中管于这个函数的指针就会覆盖掉原有的指向父类函数的指针的值,换句话说就是指向了自己定义的相应函数,这样如果用父类的指针,指向子类的对象,就会通过子类对象当中的虚函数表指针找到子类的虚函数表,从而通过子类的虚函数表找到子类的相应虚函数地址,而此时的地址已经是该函数自己定义的虚函数入口地址,而不是父类的相应虚函数入口地址,所以执行的将会是子类当中的虚函数。这就是多态的原理。

问题三:函数的覆盖和隐藏

  • 父类和子类出现同名函数称为隐藏。

父类对象.函数名(…); //调用父类的函数
子类对象.函数名(…); //调用子类的函数
子类对象.父类名::函数名(…);//子类调用从父类继承来的函数。

  • 父类和子类出现同名虚函数称为覆盖

父类指针=new 子类名(…);

父类指针->函数名(…);//调用子类的虚函数。

问题四:虚析构函数的实现原理

  • 虚析构函数的特点:

当我们在父类中通过virtual修饰析构函数之后,通过父类指针指向子类对象,通过delete接父类指针就可以释放掉子类对象。

  • 理论前提:

执行完子类的析构函数就会执行父类的析构函数

  • 原理:

如果父类当中定义了虚析构函数,那么父类的虚函数表当中就会有一个父类的虚析构函数的入口指针,指向的是父类的虚析构函数,子类虚函数表当中也会产生一个子类的虚析构函数的入口指针,指向的是子类的虚析构函数,这个时候使用父类的指针指向子类的对象,delete接父类指针,就会通过指向的子类的对象找到子类的虚函数表指针,从而找到虚函数表,再虚函数表中找到子类的虚析构函数,从而使得子类的析构函数得以执行,子类的析构函数执行之后系统会自动执行父类的虚析构函数。这个是虚析构函数的实现原理。

问题五:设计一个不能被继承的类

类的构造函数会自动调用父类的构造函数。同样,子类的析构函数也会自动调用父类的析构函数。要想一个类不能被继承,我们只要把它的构造函数和析构函数都定义为私有函数。那么当一个类试图从它那继承的时候,必然会由于试图调用构造函数、析构函数而导致编译错误。

问题六:C语言模拟实现C++的继承与多态

一、面向过程编程与面向对象编程的区别

C语言是一种典型的面向过程编程语言,而C++确实在它的基础上改进的一款面向对象编程语言,那么,面向过程与面向对象到底有什么样的区别呢?

【从设计方法角度看】
面向过程程序设计方法采用函数(或过程)来描述对数据的操作,但又将函数与其操作的数据分离开来。
面向对象程序设计方法是将数据和对象的操作封装在一起,作为一个整体来处理。

【从维护角度看】
面向过程程序设计以过程为中心,难于维护。
面向对象程序设计以数据为中心,数据相对功能而言,有较强的稳定性,因此更易于维护。

二、继承与多态的概念

继承:是面向对象最显著的一个特性。继承是从已有的类中派生出新的类,新的类能吸收已有类的数据属性
和行为,并能扩展新的能力,已有类被称为父类/基类,新增加的类被称作子类/派生类。

多态:按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同现方式即为多态。同一操作作
用于不同的对象,可以有不同的解释,产生不同的执行结果,这就是多态性。简单说就是允许基类的
指针指向子类的对象。

#pragma once
#include 
using namespace std;

//C++中的继承与多态
struct A
{
    virtual void fun()    //C++中的多态:通过虚函数实现
    {
        cout<<"A:fun()"<int a;
};
struct B:public A         //C++中的继承:B类公有继承A类
{
    virtual void fun()    //C++中的多态:通过虚函数实现(子类的关键字virtual可加可不加)
    {
        cout<<"B:fun()"<int b;
};

//C语言模拟C++的继承与多态

typedef void (*FUN)();      //定义一个函数指针来实现对成员函数的继承

struct _A       //父类
{
    FUN _fun;   //由于C语言中结构体不能包含函数,故只能用函数指针在外面实现

    int _a;
};

struct _B         //子类
{
    _A _a_;     //在子类中定义一个基类的对象即可实现对父类的继承
    int _b;
};

void _fA()       //父类的同名函数
{
    printf("_A:_fun()\n");
}
void _fB()       //子类的同名函数
{
    printf("_B:_fun()\n");
}


void Test()
{
    //测试C++中的继承与多态
    A a;    //定义一个父类对象a
    B b;    //定义一个子类对象b

    A* p1 = &a;   //定义一个父类指针指向父类的对象
    p1->fun();    //调用父类的同名函数
    p1 = &b;      //让父类指针指向子类的对象
    p1->fun();    //调用子类的同名函数


    //C语言模拟继承与多态的测试
    _A _a;    //定义一个父类对象_a
    _B _b;    //定义一个子类对象_b
    _a._fun = _fA;        //父类的对象调用父类的同名函数
    _b._a_._fun = _fB;    //子类的对象调用子类的同名函数

    _A* p2 = &_a;   //定义一个父类指针指向父类的对象
    p2->_fun();     //调用父类的同名函数
    p2 = (_A*)&_b;  //让父类指针指向子类的对象,由于类型不匹配所以要进行强转
    p2->_fun();     //调用子类的同名函数
}

你可能感兴趣的:(C++基础,C/C++)