目录
多态的概念
多态的定义及实现
多态的构成条件
虚函数
虚函数的重写
虚函数重写的两个例外
C++11 override和final
重载、覆盖(重写)、隐藏(重定义)的对比
抽象类
概念
接口继承和实现继承
多态的原理
虚函数表
多态的原理
动态绑定和静态绑定
单继承和多继承的虚函数表
单继承中的虚函数表
多继承中的虚函数表
菱形继承、菱形虚拟继承
多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
例如在生活当中,对于买票这个行为,面对不同的对象就会有不同的方式,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
又比如,当我们使用各种网购app挑选商品的时候。我们会发现搜索同一件商品,一个经常购物的账号显示的价格,可能会比一个没怎么使用过的账号价格更高,并且这个新号的优惠力度也会更大。
通俗来说,不同身份的人去做同一件事,所产生的行为是不同的,这就是多态。
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了
Person。Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
虚函数:被virtual修饰的类成员函数称为虚函数。
class Person {
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
}
【注意】
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
//void BuyTicket() { cout << "买票-半价" << endl; }
//注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,
//也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性)
//但是该种写法不是很规范,不建议这样使用
};
class Soldier : public Person
{
public:
//子类的虚函数重写了父类的虚函数
virtual void BuyTicket()
{
cout << "优先-买票" << endl;
}
};
void Func(Person& p)
{
//通过父类的引用调用虚函数
p.BuyTicket();
}
void Func(Person* p)
{
//通过父类的指针调用虚函数
p->BuyTicket();
}
int main()
{
Person p; //普通人
Student st; //学生
Soldier sd; //军人
Func(p); //买票-全价
Func(st); //买票-半价
Func(sd); //优先买票
Func(&p); //买票-全价
Func(&st); //买票-半价
Func(&sd); //优先买票
return 0;
}
协变(基类与派生类虚函数的返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用,称为协变。
class A{};
class B : public A {};
class Person {
public:
//返回基类A的指针
virtual A* f() {return new A;}
};
class Student : public Person {
public:
//返回派生类B的指针
virtual B* f() {return new B;}
};
析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。
//父类
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
//子类
class Student : public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
对于析构函数为什么一定要构成多态,下面的场景可以解答:分别new一个父类对象和子类对象,并均用父类指针指向它们,然后分别用delete调用析构函数并释放对象空间。
int main()
{
//分别new一个父类对象和子类对象,并均用父类指针指向它们
Person* p1 = new Person;
Person* p2 = new Student;
//使用delete调用析构函数并释放对象空间
delete p1;
delete p2;
return 0;
}
在这种场景下,若是父类和子类的析构函数没有构成重写就可能会导致内存泄漏,因为此时delete p1和delete p2都是调用的父类的析构函数,而我们所期望的是p1调用父类的析构函数,p2调用子类的析构函数,即我们期望的是一种多态行为。
此时只有父类和子类的析构函数构成了重写,才能使得delete按照我们的预期进行析构函数的调用,才能实现多态,避免内存泄漏。因此,为了避免出现这种情况,比较建议将父类的析构函数定义为虚函数。
从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数
名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有
得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮
助用户检测是否重写。
1.final:修饰虚函数,表示该虚函数不能再被重写
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
//此时无法重写
virtual void Drive() {cout << "Benz-舒适" << endl;}
};
父类Car的虚函数Drive被final修饰后就不能再被重写了,子类若是重写了父类的Drive函数则编译报错。
2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Car
{
public:
virtual void Drive() {}
};
class Benz :public Car
{
public:
//没有重写则编译报错
virtual void Drive() override {cout << "Benz-舒适" << endl;}
};
子类Benz虚函数Drive被override修饰,编译时就会检查子类的Drive函数是否重写了父类的虚函数,如果没有则会编译报错。
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class Car
{
public:
//纯虚函数
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
//重写纯虚函数
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
//重写纯虚函数
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
}
Car是一个抽象类不能实例化出对象,但是派生类的Benz和BMW可以实例化出对象,基类的Car指针也可以指向派生类。
void Test()
{
//派生类重写了纯虚函数,可以实例化出对象
Benz car1;
BMW car2;
//不同对象用基类指针调用Drive函数,完成不同的行为
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}
抽象类既然不能实例化出对象,那抽象类存在的意义是什么?
实现继承: 普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数。
接口继承: 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。
所以虚函数存在的意义就是为了实现多态的功能,如果你不实现多态的话,就不要把函数定义成虚函数。
对于下面的一个基类,Base类实例化出对象的大小是多少?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
通过观察测试,我们发现Base类实例化的对象b的大小是8个字节。
那么为什么Base的大小会是8个字节那,因为在b对象当中除了_b成员外,实际上还有一个_vfptr放在对象的前面。对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么派生类中这个表放了些什么呢?我们接着往下分析。
虚函数表中到底放的是什么?
// 针对上面的代码我们做出以下改造
// 1.我们增加一个派生类Derive去继承Base
// 2.Derive中重写Func1
// 3.Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
}
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
通过调试可以发现,基类对象b和派生类对象d当中除了自己的成员变量之外,基类和派生类对象都有一个虚表指针,分别指向属于自己的虚表。
实际上虚表当中存储的就是虚函数的地址,因为父类当中的Func1和Func2都是虚函数,所以父类对象b的虚表当中存储的就是虚函数Func1和Func2的地址。
而子类虽然继承了父类的虚函数Func1和Func2,但是子类对父类的虚函数Func1进行了重写,因此,子类对象d的虚表当中存储的是父类的虚函数Func2的地址和重写的Func1的地址。这就是为什么虚函数的重写也叫做覆盖。
覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。
其次需要注意的是:Func2是虚函数,所以继承下来后放进了子类的虚表,而Func3是普通成员函数,继承下来后不会放进子类的虚表。此外,虚函数表本质是一个存虚函数指针的指针数组,一般情况下会在这个数组最后放一个nullptr。
派生类编译过程如下:
【常见疑问】
1.虚表是什么阶段初始化的?
2.虚函数存在哪里?
3.虚表存在哪里?
虚表实际上是在构造函数初始化列表阶段进行初始化的,注意虚表当中存的是虚函数的地址不是虚函数,虚函数和普通函数一样,都是存在代码段的,只是他的地址又存到了虚表当中。另外,对象中存的不是虚表而是指向虚表的指针。
同时注意虚表实际上是存在代码段的。
上面分析了这个半天了那么多态的原理到底是什么?还记得这里Func函数传Person调用的
Person::BuyTicket,传Student调用的是Student::BuyTicket。
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}
观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚
函数是Person::BuyTicket。观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中找到虚函数是Student::BuyTicket。这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
用虚函数。反思一下为什么?
必须完成虚函数的重写是因为我们需要完成子类虚表当中虚函数地址的覆盖,那为什么必须使用父类的指针或者引用去调用虚函数呢?
使用父类指针或者引用时,实际上是一种切片行为,切片时只会让父类指针或者引用得到父类对象或子类对象中切出来的那一部分。 因此,我们后序用p1和p2调用虚函数时,p1和p2通过虚表指针找到的虚表是不一样的,最终调用的函数也是不一样的。
使用父类对象时,切片得到部分成员变量后,会调用父类的拷贝构造函数对那部分成员变量进行拷贝构造,而拷贝构造出来的父类对象p1和p2当中的虚表指针指向的都是父类对象的虚表。因为同类型的对象共享一张虚表,他们的虚表指针指向的虚表是一样的。
看出满足多态以后的函数调用,不是在编译时确定的,是运行起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。
总结一下:
对于下列代码:
//父类
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
//子类
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
我们若是按照如下方式调用BuyTicket函数,则不构成多态,函数的调用是在编译时确定的。
int main()
{
Student Johnson;
Person p = Johnson; //不构成多态
p.BuyTicket();
return 0;
}
而我们若是按照如下方式调用BuyTicket函数,则构成多态,函数的调用是在运行时确定的。
int main()
{
Student Johnson;
Person& p = Johnson; //构成多态
p.BuyTicket();
return 0;
}
相比不构成多态时的代码,构成多态时调用函数的那句代码翻译成汇编后就变成了八条汇编指令,主要原因就是我们需要在运行时,先到指定对象的虚表中找到要调用的虚函数,然后才能进行函数的调用。这样就很好的体现了静态绑定是在编译时确定的,而动态绑定是在运行时确定的。
以下列单继承关系为例,我们来看看基类和派生类的虚表模型。
//基类
class Base
{
public:
virtual void func1() { cout << "Base::func1()" << endl; }
virtual void func2() { cout << "Base::func2()" << endl; }
private:
int _a;
};
//派生类
class Derive : public Base
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
virtual void func4() { cout << "Derive::func4()" << endl; }
private:
int _b;
};
其中,基类和派生类对象的虚表模型如下:
在单继承关系当中,派生类的虚表生成过程如下:
以下列多继承关系为例,我们来看看基类和派生类的虚表模型。
//基类1
class Base1
{
public:
virtual void func1() { cout << "Base1::func1()" << endl; }
virtual void func2() { cout << "Base1::func2()" << endl; }
private:
int _b1;
};
//基类2
class Base2
{
public:
virtual void func1() { cout << "Base2::func1()" << endl; }
virtual void func2() { cout << "Base2::func2()" << endl; }
private:
int _b2;
};
//多继承派生类
class Derive : public Base1, public Base2
{
public:
virtual void func1() { cout << "Derive::func1()" << endl; }
virtual void func3() { cout << "Derive::func3()" << endl; }
private:
int _d1;
};
其中,两个基类的虚表模型与上面单继承的基类虚表相同,而派生类的虚表模型就不那么简单了,派生类的虚表模型如下:
在多继承关系当中,派生类的虚表生成过程如下:
以下列菱形虚拟继承关系为例,我们来看看基类和派生类的虚表模型。
class A
{
public:
virtual void funcA()
{
cout << "A::funcA()" << endl;
}
private:
int _a;
};
class B : virtual public A
{
public:
virtual void funcA()
{
cout << "B::funcA()" << endl;
}
virtual void funcB()
{
cout << "B::funcB()" << endl;
}
private:
int _b;
};
class C : virtual public A
{
public:
virtual void funcA()
{
cout << "C::funcA()" << endl;
}
virtual void funcC()
{
cout << "C::funcC()" << endl;
}
private:
int _c;
};
class D : public B, public C
{
public:
virtual void funcA()
{
cout << "D::funcA()" << endl;
}
virtual void funcD()
{
cout << "D::funcD()" << endl;
}
private:
int _d;
};
代码继承关系如上图。其中,A类当中有一个虚函数funcA,B类当中有一个虚函数funcB,C类当中有一个虚函数funcC,D类当中有一个虚函数funcD。此外B类、C类和D类当中均对A类当中的funcA进行了重写。
D类对象当中成员的分布情况较为复杂,D类的继承方式是菱形虚拟继承,在D类对象当中,将A类继承下来的成员放到了最后,除此之外,D类对象的成员还包括从B类继承下来的成员、从C类继承下来的成员和成员变量_d。需要注意的是,D类对象当中的虚函数funcD的地址是存储到了B类的虚表当中。
不难看出,菱形虚拟继承当中的派生类对象成员函数以及成员变量的分布情况非常复杂。
因此实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面使用这样的模型访问基类成员有一定的性能损耗。