多态

多态是面向对象编程的主要特征之一
基类指针/引用可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,这种现象即为多态
简单而言,多态就是用相同或相似的方法进行处理而得到的是不同的结果
即使用方法看起来没有区别,但实现的功能是完全不同的,达到一个界面,多种实现的效果

多态分类

多态分为两类:编译时的多态,运行时的多态

编译时的多态包括:函数重载,运算符重载
运行时的多态:虚函数实现 (动态绑定)

继承和动态绑定在两个方面简化了程序:

  • 可以容易地编写与其他类相似但又不相同的新类
  • 可以容易地编写忽略这些相似类型之间区别的程序

许多应用程序的特性可以用一些相关但略有不同的概念描述,面向对象编程与这种应用非常匹配。通过继承可以定义一些类型,可以模拟不同种类,而通过动态绑定可以编写程序,使用这些类而又忽略与具体类型相关的差异。
继承和动态绑定在概念上非常简单,但对于如何创建应用程序以及对于程序设计语言必须支持的特性,含义深远。
面向对象编程的关键思想是多态性,因为在许多情况下可以互换地使用派生类型或基类型的许多形态,所以称通过继承而相关联的类型为多态类型。
c++中,多态性仅用于通过继承而相关联的类型的引用或指针,我们称因继承而相关的类构成了一个继承层次,其中一个类称为根,所有其他类直接或间接地继承根类。

虚函数

c++提供一种相对于重载更灵活的多态机制:虚函数
虚函数允许函数调用与函数体匹配在运行时才确定,提供的是一种动态绑定的机制。
通过虚函数机制,将基类的成员声明为虚函数形式,就可以通过基类指针或引用来访问派生类中的同名成员函数的目的。
提供虚函数的机制可以提升程序的复用性,基类使用虚函数提供一个接口,但派生类可以定义自己的实现版本。虚函数调用的解释依赖于它的对象类型,这就实现了一个接口而多种语义的概念,例:

class Base
{
public:
    virtual void print(){cout<<"打印基类";}
};
class Derived:public Base
{
public:
    virtual void print(){cout<<"打印派生类";}
};
void p(Base &b){b.print();}                       //注意这里必须要传引用或指针,如果传值话就是用Derived赋值一个Base对象
int main()                                        //此时会发生切割,Derived类的虚函数根本不会被拷贝到Base对象中
{
    int choice;Base base,*bp;Derived derived;
    cin>>choice;                                  //此时choice的值依赖于运行时的标准输入
    if(choice!=0)bp=&derived;                     //p的初始化又依赖于choice的值
    else(bp=&base);                               //因此,只有运行时才能确定指针是指向基类对象还是派生类对象
    p(*bp);                                       //从而达到同样的调用方式却可以得到不同的结果
}

注意传值是无法实现多态的,只有指针或引用才能实现多态的效果,对于这点网上很多资料解释都不太详细,个人理解是这样的:

  • 首先是指针和引用类型只要求基地址和偏移量信息,不关心实际对象类型的含义,相当于把指向的内存解释成指针或引用的类型就行了。因此这这种情况下直接访问到派生类原本的vfptr所存储的vftable即可实现多态
  • 而对于一个实际的对象来说,是要明确其类型的,把一个派生类对象转换为基类对象,那么这个对象就应该是基类的,所以vfptr中应该存储的是基类的vftable,访问的虚函数也就是基类的版本
    • 对于不同类型的赋值来说,涉及到类型转换和基类构造函数的初始化,而vfptr正是在构造函数初始化时自动初始化的一个常量

虚函数的声明

在基类中使用virtual关键字声明的成员函数即为虚函数,虚函数可以在一个或多个派生类中被重新定义,但要求在重定义时虚函数的原型(返回值类型,函数名,参数列表)必须完全相同(更准确地说这叫重写)
基类中函数具有虚特性的条件:

  • 在基类中用virtual声明
  • 在公有派生类中原型一致地重载该虚函数
  • 定义基类引用或指针,使其引用或指向派生类对象,当通过该引用或指针调用虚函数时,该函数将体现出虚特性来

在c++中,基类必须指出需要派生类重定义哪些函数,定义为virtual的函数是基类希望派生类重新定义的,基类希望派生类继承的函数不能定义为虚函数。在派生类中重载虚函数时必须与基类中函数原型完全相同,否则该函数将丢失虚特性。

  • 在语法上来说派生类不重写基类虚函数是可以的,不过这样派生类的vfptr将指向基类(上一层)的vftable
  • 如果返回类型不同则编译报错,如果原型不同仅仅函数名相同则编译器认为这只是一般的函数重载,虚特性丢失

虚函数的实现机制

虚函数表和虚函数表指针

编译时,为每个具有虚函数的建立一张虚函数表vftable,表中存放的是这个类中所有虚函数的指针,同时用一个虚函数表指针vfptr指向这个表的入口。
对象的内存空间除了保存数据成员外还保存了vfptrvfptr由构造函数来初始化,是一个常量。当访问某个虚函数时,不是直接找到那个函数的地址,而是通过vfptr间接查到它的地址。因此,当基类指针指向派生类对象时,此时对象保存的vfptr(vfptr是基类和派生类都有的)指向的还是派生类的vftable。
注意:vfptr不是只有一个,而是看继承了几个基类,一般有几个就有几个vfptr(将该派生类新定义的虚函数加入到其中一个vfptr对应的vftable中)

vftable和对象是相关的,本质是通过this指针找到对象保存的vfptr指针再找到vftable最后找到里面存储的虚函数在代码区的地址,因此虚函数必须是类的非静态成员函数,即虚函数不可能是全局函数、静态成员函数、全局友元函数(类中声明另一个类的虚函数为其自身的友元函数是可行的),主要原因在于通过非静态成员函数隐含传递的this指针才能找到vfptr指针。

需要注意的是,不同平台、不同编译器厂商所生成的vfptr在内存中的布局是不同的,有些将vfptr置于对象内存中的开头处,有些则置于结尾处,这一点并非c++标准所要求的,而是编译器所采用的“内部方式”。至于vftable也是一样,大部分是存储在rodata段,也有的是存储在内存映射的可执行文件中,位于堆栈之间的共享库中。

基于这些不确定的行为,因此最好永远不要做任何相关的内存假设,也不要使用memcpy()之类的函数复制对象,而应该使用初始化或赋值的方式来复制对象。

在成员函数中调用虚函数

在基类或派生类的成员函数中,可以直接调用类等级中的虚函数,此时根据的是this指针中指向的对象来判断调用的是哪个函数。
构造函数不能定义为虚函数,因为vptr需要在构造函数中进行初始化,但析构函数可以定义为虚函数,并且通常情况下这样可以在运行时决定基类和派生类的析构层次

  • 比如delete释放基类指针所指向的派生类对象时,就可以调用派生类析构函数,而派生类的析构函数中默认合成了基类的析构函数,因此可以达到先调用基类的析构函数,再调用派生类的析构函数的目的
    • 如果不定义虚析构函数,那么直接释放基类指针则直接调用的是基类的析构函数,则派生类对象无法释放内存
  • 不过事实上,只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用virtual关键字声明,编译器都自动将其声明为虚析构函数
  • 一般来说,一个类如果定义了虚函数,则最好将析构函数也定义成虚函数,因为虚函数基本上意味着程序会利用基类指针引用指向派生类对象以实现多态性

例:虚析构函数和非虚析构函数的区别

class Base1{public:virtual ~Base1(){cout<<"析构Base1类"<<endl;}};
class Base2{public:~Base2(){cout<<"析构Base2类"<<endl;}};
class Derived1:public Base1{public:virtual ~Derived1(){cout<<"析构Derived1类"<<endl;}};
class Derived2:public Base2{public:~Derived2(){cout<<"析构Derived2类"<<endl;}};
int main(){
    Base1 *base1=new Derived1();
    Base2 *base2=new Derived2();
    delete base1;delete base2;
}    //输出结果:析构Derived1类 析构Base1类 析构Base2类

虚函数示例程序:书店卖书分平价和打折两种,使用相同的接口实现不同的操作

#include<iostream>
#include<string>
using namespace std;
class Bookbase
{
    string bookname;
protected:
    float price;
public:
    Bookbase(string bn,float p):bookname(bn),price(p){}
    virtual float net_price(int n){return n*price;}
};
class Bulkitem:public Bookbase
{
    int minbulk;
    float discount;
public:
    Bulkitem(string bn="",float p=0,int mb=0,float dc=0):Bookbase(bn,p),minbulk(mb),discount(dc){}
    virtual float net_price(int n){if(n>=minbulk)return(1-discount)*n*price;return n*price;}
};
float totalprice(Bookbase &book,int n)
{
    return book.net_price(n);
}
int main()
{
    Bookbase bookbase("我爱c++",10000);
    Bulkitem bulkitem("我爱c++",10000,10,0.01);
    cout<<totalprice(bookbase,15)<<endl;
    cout<<totalprice(bulkitem,15)<<endl;
}  

虚函数表与继承的关系

  • 基类和派生类的vftable不是同一个,每个类有属于自己的vftable
  • 如果是单继承,派生类中仅含有一个vftable,该vftable从唯一基类处拷贝,然后改写该vftable
  • 多重继承的原则就是派生类只需要关心它上一级基类的虚函数表模型,然后一层层推导
  • 派生类的vftable中的数据(也就是函数地址)拷贝于基类的vftable(多继承则向每个基类都拷贝一个),如果派生类中重写了基类虚函数,那么该派生类的vftable中对应的基类虚函数地址会更改为派生类重写后的函数地址,也就是派生类的函数地址
    • 派生类的vftable最终应包含基类中虚函数(重写的和未重写的)以及和新定义的虚函数
    • 如果是多继承则要么将新定义的虚函数插入到其中一个基类的vftable的后面(一般是第一个),要么新增一个vftable,看编译器具体实现
    • 每定义一个新的虚函数,则在继承链的vftable中就会新增一个表项,所以随着继承层数增加vftable的数量和复杂度会越来越高
  • 当多继承中多个基类具有相同名字的虚函数时,派生类将一次性重写所有的同名虚函数
    • 如果想要对不同基类的同名虚函数进行不同改写的话,可以靠增加代理实现(增加中间一层继承)

纯虚函数、抽象类、接口类

有的时候,基类是无法给出具体的功能实现的,或者尽管可以实现基类但生成的对象也是无意义的
存在这样一种基类和派生类的关系:

基类表示抽象的概念,提供一些公共的接口,表示这类对象所拥有的共同操作,而派生类体现这些接口的实现过程

基类中这些公共接口只需要有声明而不需要具体实现,即纯虚函数,纯虚函数刻画了派生类应该遵循的协议,这些协议的具体实现由派生类来决定
纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0,表明此函数为纯虚函数,基本格式为:

virtual 虚函数类型 函数名(参数列表)=0;

当把一个函数声明为纯虚函数以后,就要求任何派生类都定义自己的实现

class Shape                               //接口类
{
    virtual float perimeter()=0;          //最后的=0并不表示函数返回值为0,它只起形式上的作用
    virtual float area()=0;
};

只要拥有纯虚函数的类,称其为抽象类,抽象类不能被实例化,只能作为基类被使用,抽象类的派生类需要实现纯虚函数,否则该派生类也是一个抽象类。
所有成员函数都是纯虚函数的抽象类,称其为接口类,表示类本身没有实体,只是告诉使用者这个类提供了哪些功能,以及函数原型,通过提供接口类,使用者可以通过接口类的纯虚函数调用派生类的具体实现,同时使用时看不到派生类的具体实现,从而达到派生类功能独立的目的,只要接口不变,调用者无需修改调用函数,即依赖倒置原则(依赖于接口而不依赖于实现)

  • 实际上c++的定义中是没有接口类这个概念的,不过满足以上的条件的抽象类可以实现与其他语言中的接口相同的功能
  • 因为所有成员函数都是虚函数,所以没有构造函数(反正也不需要生成对象),也不需要析构函数
  • 接口类可以声明静态常量作为接口的返回值状态