目录
一.什么是多态
1.用现实例子理解多态
例如: 在火车站买票, 普通人买票全价, 学生买票半价, 军人优先买票
同样是买票这个行为, 但是不同身份的人去买, 所需要的价格是不一样的
class Person
{
public:
virtual void _BuyTicket()
{
cout << "普通人全价买票" << endl;
}
};
class Child : public Person
{
public:
virtual void _BuyTicket()
{
cout << "儿童半价买票" << endl;
}
};
class Soldier : public Person
{
public:
virtual void _BuyTicket()
{
cout << "军人优先买票" << endl;
}
};
void BuyTicket(Person& p)
{
p._BuyTicket();
}
int main()
{
Person p;
Child c;
Soldier s;
BuyTicket(p);
BuyTicket(c);
BuyTicket(s);
return 0;
}
2.多态的本质
从表面现象来看, 用指向不同子类对象的父类指针或引用, 去调用相同的函数, 得到不同的结果, 这就是多态
本质其实是, 子类对父类中虚函数的重写
二.如何构成多态(虚函数的作用)
1.什么是虚函数
语法: 在成员函数前加上virtual即可
class Base
{
public:
virtual void func()
{
cout << "hello virtual" << endl;
}
};
注: 只有成员函数才能成为虚函数, 虚函数存在的意义就是为了实现多态, 所以如果你将某个函数写为虚函数, 那么就意味着这个函数需要被子类重写
2.达到多态的条件
1.构成重写关系
父类成员函数必须是虚函数, 子类成员函数必须与父类的虚函数函数名相同, 返回值相同, 参数相同(子类成员函数的virtual关键字可有可无)
如果子类重写的成员函数与父类的虚函数返回值不同, 则必须为"协变", 如果都不满足, 则构成隐藏关系
可以说子类成员函数与父类成员函数如果不构成重写关系, 那么就构成隐藏关系, 并且构成重写关系的同时, 也是一个隐藏关系
协变: 父类虚函数的返回值是一对继承关系中的父类类型指针或者引用
子类重写函数的返回值是一对继承关系中的子类类型指针或引用
父子返回值不可以反过来, 必须子是子类指针或引用, 父是父类指针或引用
若是指针都是指针, 若是引用都是引用, 必须一一对应, 单纯的父类类型与子类类型不构成协变
总结: 如果一对继承关系中B继承A, 则A*与B*构成协变, A&与B&构成协变, A与B不构成协变
2.完成多态调用
必须由一个指向子类对象的父类指针或引用, 去调用重写后的虚函数
补充: 如果用子类对象调用, 则不构成多态调用, 此时会用到隐藏关系, 会直接去调用子类中的
如果用指向父类对象的父类指针或引用调用, 则不构成多态, 此时会直接调用到父类的虚函数
三.重载, 重写, 隐藏三者之间的关系
1.重载的概念
重载: 在相同作用域下, 函数名相同, 参数个数不同, 或者参数顺序不同, 或者参数类型不同, 调用方会去调用参数最匹配的那一个重载函数, 返回值不可以作为函数重载的条件, 因为调用方并不知道你需要哪种返回值, 也就无法匹配, 出现二义性
2.重写的概念
重写也称为覆盖
重写: 在一对继承关系中, 不同作用域下, 父类成员函数定义为虚函数, 子类参数相同返回值相同(或为协变)的同名成员函数会重写父类虚函数, 将函数地址覆盖掉之前的地址, 重写并没有覆盖掉父类原有的虚函数,覆盖的只是虚函数表中对应的父类虚函数的地址
3.隐藏的概念
隐藏也称为重定义
隐藏: 在一对继承关系中, 不同作用域下, 子类的成员函数名与父类成员函数名相同, 则构成隐藏
重写关系与隐藏关系可以共同存在, 一般如果是重写关系就一定也是隐藏关系, 但由于实际需求, 我已经重写了, 就会去使用多态调用, 从而就会使用重写关系而不使用隐藏关系, 所以如果构成重写我们一般只强调重写关系, 如果不是多态调用则退而求其次, 就使用隐藏关系, 但如果子类和基类的同名函数如果不构成重写, 则一定构成隐藏
4.重写并不一定是多态调用(重点概念)
重写是一种关系, 而多态是一种调用方式
想要达到多态调用, 则必须实现对父类虚函数的重写, 并且由指向子类对象的父类指针或引用去调用
如果此时只满足重写, 而不满足第二个条件, 这就不是一个多态调用, 也就不会使用他的重写关系, 而是去使用隐藏关系
但只是不使用重写关系, 他的关系依旧是重写
四.动态绑定与静态绑定
静态绑定: 在程序编译期间确定的行为, 也称为静态多态, 例如: 函数重载, 模板
动态绑定: 在程序运行期间确定的行为, 也称为动态多态, 例如: 本篇文章所讲的多态调用
五.多态调用和普通调用
普通调用(静态联编): 1.不分文件编写, 编译阶段直接在调用处写好, 确定要call的函数地址
2.分文件编写, 链接阶段在符号表进行链接(通过函数声明确定函数定义并且链到一起)
多态调用(动态联编): 运行时调用, 进程运行起来之后去虚函数表中找到虚函数地址后加载到eax中, 然后call eax内的函数地址
对于调用子类重写虚函数而言:
多态调用: 通过指向这个子类对象的父类指针或引用调用重写后的虚函数
普通调用: 直接通过子类对象调用
普通调用和多态调用这两种调用方式, 调用的是同一个函数, 最终call的是同一个函数的地址
class Base
{
public:
virtual void func()
{
cout << "Base - func" << endl;
}
};
class Child : public Base
{
public:
virtual void func()
{
cout << "Child - func" << endl;
}
};
int main()
{
Child c;
Base* pb = new Child;
c.func();
pb->func();
return 0;
}
但是, 下面这张图, 按理来说c.func()和pb->func()应该调用的是同一个函数, 为什么输出结果一个是Child: 10一个是Child: 9呢?
解释: 首先明确的就是c.func()是普通调用是静态的, pb->func()是多态调用是动态的
多态调用是运行时调用, 而函数参数的缺省值是在编译阶段就已经确认好了的, 所以当编译器编译pb->func()这句代码时, 此时还并没有运行起来, 也就并没有去虚函数表中去找, 所以此时编译器认为pb是父类指针, 先暂时让func以父类的函数声明编译, 当编译结束进程开始运行时, 此时这是一个多态调用, 开始去虚函数表找, 找到了就去call虚函数表中对应的函数地址
所以本质上调用的是同一个子类的虚函数func, 而我们看到的缺省值不同是因为多态调用是动态的, 而缺省值的设置是静态的, 是编译阶段去做的事
如果没有构成重写, 此时使用指向子类对象的父类指针或引用去调用父类虚函数, 显然不满足多态调用的全部条件, 那此时是否会去虚函数表中找呢?
答案是: 会执行去虚函数表中找这一过程, 并且找到的就是父类没有被重写的虚函数
那此时这里到底是不是一个多态调用呢? 要看从哪种角度出发去理解这件事
可以理解为这里不是一个多态调用, 因为只满足多态调用的其中一个条件, 而并没有发生虚函数的重写, 最终也就没有达到多态调用的目的
也可以理解为这是一个多态调用, 因为进程在运行过程中确实去虚函数表中找了, 只是到最后没有找到的是没有重写的虚函数而已, 并且机器是付出了寻找这一代价的, 普通调用和多态调用的一个最大的区别就是效率不同, 这里已经去虚函数表找了且效率已经降低了
六.虚函数表
1.什么是虚函数表
虚函数表内部存放的是重写后的函数的地址, 32位下对象内通常会在最开始(地址最低处)存储一个四字节的指针指向虚函数表
2.每个类有几个虚函数表, 是否共用同一个?
单继承模型下: 每个有虚函数的类都会存在一个属于这个类的虚函数表
多继承模型下: 每个有虚函数的类都会存在至少一个属于这个类的虚函数表
本质上: 子类的虚函数表是拷贝的父类中的虚函数表, 如果子类对父类虚函数有重写行为, 那么将修改虚函数表内被重写的虚函数地址
所以, 即便是子类没有重写父类虚函数也没有新增新的虚函数, 那么子类和父类的虚函数表内的内容虽然一致, 但是子类和父类是各有一个虚函数表的, 其并不是共用关系
3.虚函数表创建时机与创建位置
对象内的虚函数表指针在构造函数初始化列表结束时被初始化完成
虚函数表是在编译阶段被创建在常量区的
所以子类构造函数在初始化列表处调用父类构造时, 父类构造内部的函数调用并不会形成多态调用, 而是普通调用, 因为此时子类还没有初始化虚函数表地址
为什么要这么设计?
原因就是, 当子类构造还没走完, 这时子类对象内成员变量仍然是未初始化的随机值, 这时如果父类构造中可以执行多态调用, 运行结果的错误是很严重的, 且是不可预估的
4.打印虚函数表
vs2019的调试器看不到子类新增的func2虚函数(因为这个虚函数没有完成重写, 但是实际也是在Child类的虚函数表中的), 我们可以试着编写一个程序来打印出对象的虚函数表中所有的虚函数
//打印虚函数表
typedef void(*VFPTR)();
class Base
{
public:
virtual void func1() { cout << "Base: func1" << endl; }
};
class Child : public Base
{
public:
virtual void func1() { cout << "Child: func1" << endl; }
virtual void func2() { cout << "Child: func2" << endl; }
};
void PrintVFT(VFPTR* table)
{
//写法一
//for (int i = 0; i < 2; i++)
//{
// VFPTR pt = table[i];
// pt();
//}
//写法二
for (int i = 0; table[i] != nullptr; i++)
{
printf("VFtable[%d]: %p -- ", i, table[i]);
VFPTR pt = table[i];
pt();
}
}
int main()
{
Child c;
printf("虚函数表: %p\n", (VFPTR)*(int*)(&c));
PrintVFT((VFPTR*)*(int*)(&c));
return 0;
}
七.单继承与多继承中的虚函数表
结论已在第四点<虚函数表>中给出, 以下多以程序运行结果截图来证实第四点的结论
补充: 多继承中, 子类新增的未发生重写的虚函数被添加到第一个被继承的拷贝的基类虚表中
1.单继承
1)父类存在虚函数, 子类既不重写也不新增, 父子虚函数表各有一个, 注意不是共用关系!
2)父类存在虚函数, 子类重写但是没有新增虚函数, 父子虚函数表各有一个, 注意不是共用关系!
3)父类存在虚函数, 子类没有重写但是新增了虚函数, 父子虚函数表各有一个, 注意不是共用关系!
这里注意: 子类新增的虚函数也是存在在虚函数表内的, 但是编译器只会在虚表中显示出最初始的父类的虚函数以及重写后的父类虚函数, 并不会显示出子类虚函数, 这是编译器做出的特殊处理
4)多次单继承关系, C继承B, B继承A, 其中A,B,C的虚函数表关系, 每个类都一个属于自己的虚表
2.多继承
b1和b2各有一张虚表
c有两张虚表, 分别是Base1和Base2重写后的拷贝
仔细剖析上图:
综上总结: 若父类存在虚函数, 子类和父类的虚函数表各有一张
子类与父类的虚函数表不是共用关系, 但每个类实例化出的自己的所有对象共用同一张虚表
八.哪些成员函数可以是虚函数
1.为什么构造函数不能定义成虚函数
构造函数不可以定义为虚函数, 因为虚函数表指针是在对象调用构造函数的初始化列表部分结束之后才初始化的, 所以构造函数定义为虚函数没有意义, 在编译器中也强制检查了, 如果将构造函数定义为虚函数直接编译报错
2.为什么推荐将析构函数定义成虚函数
C++中所有类的析构函数在编译器眼中都统一命名为destructor, 就是为了让析构函数可以通过多态的方式调用
将析构函数定义为虚函数, 从而支持多态调用的好处是:
如果析构函数不写为虚函数, 那么将无法通过父类指针去析构一个子类对象
析构pa时, 只析构了子类对象中的父类成员变量
如果析构定义为了虚函数, pa是指向子类对象的父类指针, delete时pa去调用析构函数时发生多态调用, pa就可以调用到子类的析构函数
3.静态成员函数能否成为虚函数
静态成员函数没有this指针, 可以通过类域直接调用, 静态成员函数是在编译阶段就已经确定好了的(作用域本身就是在编译阶段才有的概念), 所以对静态函数而言成为虚函数没有意义
在语法上static与virtual关键字也是不可以同时出现的
4.inline函数能否成为虚函数
inline与virtual是可以同时存在的
内联函数的概念: inline关键字可以将一个函数成为内联函数, 如果真的是内联函数, 则该函数就不需要地址了, 会直接将函数拷贝到调用处, 而inline关键字对于编译器而言只是一个建议, 当函数体过大, 虽然函数还具有内联属性, 但已经不被当做内联函数处理了, 而是在调用处call函数地址
当内联函数成为了虚函数, 则需要将虚函数的地址填入到虚函数表中, 但是只要不是多态调用这仍然还是一个内联函数, inline虚函数需要将inline虚函数的地址填到虚函数表中, 但是不妨碍在普通调用时将函数体在调用处展开, 只有在多态调用时才会去找地址, 才会使inline失效
所以虚函数仅仅是需要一个地址填入虚函数表, 但并不妨碍函数体展开, 多态调用则是需要去调用在虚表找到的函数地址, 这个概念要想清楚!
所以在多态调用中, inline会失效; 普通调用中inline仍发挥作用; 这里与是否是虚函数没有直接关系
5.拷贝构造和赋值运算符重载能否成为虚函数
构造函数不能成为虚函数, 拷贝构造是构造函数的一种, 也不能成为虚函数, 在语法上不能加virtual
赋值运算符在语法上可以加virtual, 但是并不会构成重写, 因为子类和父类的operator=的参数是不同的
九.纯虚函数与抽象类
1.纯虚函数
语法: 虚函数后面加一个=0
class Base
{
public:
virtual void test() = 0;
};
纯虚函数是类的成员函数且可以有函数定义, 但一般不去写, 因为纯序函数存在的意义就是让子类继承然后重写, 从而达到多态的目的
有纯虚函数存在的类称为抽象类
2.抽象类
抽象类不可以实例化对象, 如果一个类继承了抽象类却没有对抽象类中的纯虚函数重写, 则继承的那个子类也成为了抽象类
抽象类存在的意义:
例如: 植物抽象类, 实例化一个植物对象是没有意义的, 植物类的用途是让玫瑰类, 竹子类...去继承植物类然后重写植物类中的纯虚函数, 实例化出的玫瑰类对象 -- 玫瑰花, 竹子类对象 -- 竹子, 百合类对象 -- 百合花, 这才是我们想要的, 让后通过多态调用就可以在"看似同一个函数中"调用出不同的结果
十.C++11新增关键字final与override
1.final
final修饰虚函数, 表示该虚函数不能再被重写
class Base
{
public:
virtual void func() final;
{
//...
}
};
2.override
override检查派生类的虚函数是否重写了基类虚函数, 如果没有重写就引发编译报错
class Base
{
public:
virtual void func()
{
//...
}
};
class Child : public Base
{
public:
virtual void func() override
{
//...
}
};
十一.多态中权限问题
以下程序输出2, 为什么私有继承并且子类虚函数权限为私有还可以通过赋值后的a去调用到子类中的test呢?
因为test构成重写, 而a赋值后又是指向子类对象的父类指针, 所以构成多态调用
多态调用是运行时在虚函数表中找对应的函数地址, 当程序运行起来之后,父类指针或引用直接去虚函数表找到对应的函数地址就可以调用了,虚函数表内的函数就没有权限这个概念了
所以一但程序通过编译,运行起来之后重写的虚函数是不关心权限的
但是, 不是说构成多态调用的函数不关心权限吗? 为什么当父类的虚函数权限成为私有之后编译器编译报错了?
报错: A::test()不可访问
多态是运行时才有的,但是这个是编译阶段的报错说的是A域下的test不可访问,所以在a调用test时在编译阶段,编译器认为调用的是私有的父类test成员函数
也就是说这是编译时的报错, 在编译阶段就没有通过, 编译时a调用test并不能去虚表找, 所以先认为调用的父类test