目录
一、多态的概念
二、多态的定义和实现
1. 多态的构成条件
2. 虚函数
3.虚函数的重写
3.1 析构函数的重写
4. override 和 final (C++11)
5. 重载、重定义(隐藏)、重写(覆盖)的对比
三、抽象类
1. 概念
2. 接口继承和实现继承
四、多态原理
1. 再次分析多态条件
2. 虚函数表
3. 动态绑定与静态绑定
四、单继承与多继承的虚函数表
1. 单继承中的虚函数表
2. 多继承中的虚函数表
五、常见面试题
一、多态的概念
多态,顾名思义,有多种形态。具体来说就是去完成某个行为,当不同的对象去完成时会产生出不同的状态
举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。
再举个栗子: 最近为了争夺在线支付市场,某宝年底经常会做诱人的扫红包-支付-给奖励金的活动。那么大家想想为什么有人扫的红包又大又新鲜8块、10块...,而有人扫的红包都是1毛,5毛....。其实这背后也是一个多态行为。某宝首先会分析你的账户数据,比如你是新用户、比如你没有经常某宝支付等等,那么你需要被鼓励使用某宝,那么就你扫码金额 = random()%99;比如你经常使用某宝支付或者某宝账户中常年没钱,那么就不需要太鼓励你去使用某宝,那么就你扫码金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。ps:支付宝红包问题纯属瞎编,大家仅供娱乐。
二、多态的定义和实现
1. 多态的构成条件
在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
2. 虚函数
虚函数定义:被virtual修饰的类成员函数被称为虚函数,全局的函数不能加。
虚函数是为了重写而生,它的意义就是如此。
虚函数就是虚函数,和虚继承没关系,只是用了同一个关键字。此外我们还学了仿函数,这是重载()的operator()函数
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl;}
};
虚函数重写的一些细节:
1、 派生类的重写虚函数可以不加virtual,因为基类写了,派生类继承后直接重写了。但是建议都加上virtual
2、 协变——返回值可以不同,但是返回值必须是具有父子关系的指针或引用(而且必须同时是指针或引用,其他类的父子关系也可以)不常用
多态条件:
1、调用函数是重写的虚函数
2、必须通过基类的指针或者引用调用虚函数
- 多态调用看的是指向的对象
- 普通对象看当前者类型
int main()
{Person* p = new Person;delete p;p = new Student;delete p; //p->destructor() + operator delete(p)return 0;
}
3.虚函数的重写
虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同——三同),称子类的虚函数重写了基类的虚函数。
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 ps;Student st;Func(ps);Func(st);return 0;
}
//打印
买票-全价
买票-半价
问:可以看到我们两次调用Func函数传递的参数分别为父类Person类的对象和子类Sudent类的对象,而在Func的形参处,我们使用Person类变量p接收两参数,这就意味着在接收st时会发生切片(上转型),但是编译器是如何通过切出的父类的那一部分成员变量(成员函数不在类内部),而能调用子类Student的BuyTicket函数呢?当Func形参类型由引用改为普通类型时为什么都打印全价呢?
答:这里就体现了多态的含义,当不同的对象传递过去时,会调用不同的函数,即多态调用看的是指向的对象
对于第一个问题,
对于第二个问题,是语法规定,我们必须要同时满足多态的两个条件:
1. 调用函数是重写的虚函数 (如果父类没有virtual函数,即使子类有虚函数也不能实现多态,但是如果父类有virtual而子类重写时不加virtual,这种情况是可以实现多态)
2. 通过基类的指针或者引用调用虚函数(例如在Func的型参处,用引用接收和普通变量接收结果不同)
所以当Func形参类型由引用改为普通类型,不构成多态条件,那么形参是普通对象,它就只看当前类型是Person对象,就调用Person的函数
虚函数重写的细节:
- 派生类的重写虚函数可以不加virtual,但是不建议这样使用
- 协变,返回值的类型可以不同,到那时要求返回值必须是父子关系指针或引用,必须同时是指针或引用,并且父子关系也不能互换(不常用)(下面的代码就是协变返回值不同的样例)
class A {};class B : public A {};class Person { public:virtual A* BuyTicket() const { cout << "买票-全价" << endl;return 0;} };class Student : public Person { public:virtual B* BuyTicket() const { cout << "买票-半价" << endl;return 0;} };
3.1 析构函数的重写
问:析构函数可以是虚函数吗?为什么需要是虚函数?
答:如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。
虽然函数名不相同,看起来违背了重写的规则,其实不然,因为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor
析构函数加virtual,是不是虚函数重写?
是,因为类析构函数都被处理成destructor这个统一的名字,并且没有返回值,形参相同,构成了重写条件。
为什么要这么处理呢?
因为要让他们构成重写
为什么要让它们构成重写?
为了下面场景的需求,记住这个场景,这就是 destructor 的原因
class Person {
public:~Person() { cout << "~Person()" << endl; }
};class Student : public Person {
public:~Student() {cout << "~Student()" << endl;delete[] ptr;}protected:int* ptr = new int[10];
};int main()
{Person* p = new Person;delete p;p = new Student;delete p; // p->destructor() + operator delete(p)return 0;
}
//打印
~Person()
~Person()
第二次p被赋值为Student实例化的对象,而释放p时调用的是Person的析构函数,但是我们要的应该是Student的析构函数,那么这就意味着new出的Student内部的10个int型数组内存泄漏!
在学习delete时,我们讲解了delete是分为两步进行的,第一步调用其所在类的析构函数,第二部调用operator delete函数释放空间,在delete时看的是delete后面的类型(普通调用),delete p的第一步也就成为了Person类析构函数p->destructor()
因为上转型对象的影响,析构时就有了问题。我们所期望的是p指向谁,就调用谁的析构函数,即p->destructor()是一个多态调用,而不是普通调用,那么怎么解决这个问题?
将它们构成重写!但是重写需要三同,因为它们的析构函数名不同,所以析构函数都被编译器处理成destructor这个统一的名字,加上virtual形成多态,最终构成重写
只要基类的析构加上virtual,由于子类的virtual是可以省略的,那么也就意味着编写者可能在不知情的情况下,在子类编写的析构已经构成了虚函数重写,这就会解决对于上转型对象析构时会有内存泄露的情况,所以父类写virtual,子类可以省略virtual可以认为是为了此情况设计的
所以在大多数会被继承的基类的析构函数加上virtual是有很多好处的,但是也会付出一些代价
4. override 和 final (C++11)
class Car
{
public:virtual void Drive() final {}
};class Benz :public Car
{
public:virtual void Drive() { cout << "Benz-舒适" << endl; 报错,因为基类Drive虚函数被final修饰,不能再被继承重写}
};
class Car
{
public:virtual void Drive() {}
};class Benz :public Car
{
public:virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
如何设计一个不想被继承的类?
方法一:基类构造函数私有(C++98)
在C++98中,我们一般将构造函数私有,那么该类就不能被继承了,这是因为派生类的构造函数必须要先调用基类的构造函数
同理,也可以将析构函数私有,同样也不能被继承,因为无法调用析构,所以在类外不能创建对象,但是可以new出来一个,这样释放就归我们管了
class A
{
public:用static变成静态成员函数,避免用对象调用函数,因为A类不能在外部实例化对象static A CreateObj(){构造函数私有了,那么只能在这里实例化对象后返回return A();}
private:A(){}
};class B : public A
{};
方法二:加final(C++11)
class A final
{
public:private:};错误,不能用被final修饰的类作为基类
class B : public A
{};
5. 重载、重定义(隐藏)、重写(覆盖)的对比
三、抽象类
1. 概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承
但是可以定义指针,Car* p;
2. 接口继承和实现继承
class Car
{
public:inline virtual void Drive() = 0;//int Func();};class Benz :public Car
{
public:inline virtual void Drive(){cout << "Benz-舒适" << endl;}
};class BMW :public Car
{
public:inline virtual void Drive(){cout << "BMW-操控" << endl;}
};class BYD :public Car
{
public:inline virtual void Drive(){cout << "BYD-build your dream" << endl;}
};void Func(Car* p)
{p->Drive();
}
int main()
{Func(new Benz);Func(new BMW);Func(new BYD);return 0;
}
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
四、多态原理
1. 再次分析多态条件
多态的条件为什么不能是子类指针或引用?
因为只有父类可以即可指向子类又可指向父类(切片)而子类不行,子类只能指向子类,只有上转型没有下转型
那么多态条件能不能是父类对象呢?
不能!因为对象的切片和指针/引用的切片是不同的!
子类的虚函数表是先拷贝父类的虚函数表,如果有虚函数重写,那么就将新的地址覆盖原先拷贝的父类的虚函数地址
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }virtual void Func1() {cout << "Person::Func1()" << endl;}virtual void Func2() {cout << "Person::Func2()" << endl;}//protected:int _a = 0;
};class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }private:virtual void Func3(){//_b++;cout << "Student::Func3()" << endl;}
protected:int _b = 1;
};
指针和引用是不存在拷贝问题的,父类指针指向父类就查看父类的虚函数表,指向子类就查看子类当中属于父类的那一部分数据,再查看子类的虚函数表。
而如果使用对象就不行了,首先子类的虚函数表不会被拷贝进父类对象中,因为如果连虚函数表都拷贝了,那么当我们使用父类指针指向该父类对象时,调用虚函数,将会调用子类的虚函数!现在变成父类指针不管指向父类还是子类都是调用子类虚函数,这就乱套了!
结论:子类对象赋值给父类对象切片,不会拷贝虚函数表。如果拷贝虚表,那么父类虚表中时父类函数还是子类对象就不确定了。
为什么子类要重写父类的虚函数?
因为只有子类重写了父类的虚函数,子类拷贝父类的虚函数表中,子类才能修改虚函数的地址,从而达到父类指针/引用指向父类调父类虚函数,指向子类调子类虚函数
普通的继承都是实现继承,虚函数继承是接口继承
2. 虚函数表
这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};
通过观察测试我们发现b对象是8bytes,除了_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;
}
小结:
- 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
- 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
- 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函数,所以不会放进虚表。
- 虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
同类型的对象共用一个虚函数表
总结一下派生类的虚表生成:
a. 先将基类中的虚表内容拷贝一份到派生类虚表中
b. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
巧妙验证:
class Person {
public:virtual void BuyTicket() { cout << "买票-全价" << endl; }virtual void Func1() {cout << "Person::Func1()" << endl;}virtual void Func2() {cout << "Person::Func2()" << endl;}//protected:int _a = 0;
};class Student : public Person {
public:virtual void BuyTicket() { cout << "买票-半价" << endl; }private:virtual void Func3(){//_b++;cout << "Student::Func3()" << endl;}
protected:int _b = 1;
};typedef void(*FUNC_PTR) ();// 打印函数指针数组
// void PrintVFT(FUNC_PTR table[])
void PrintVFT(FUNC_PTR* table)
{for (size_t i = 0; table[i] != nullptr; i++){printf("[%d]:%p->", i, table[i]);FUNC_PTR f = table[i];f();}printf("\n");
}int main()
{Person ps;Student st;强转,取出类的前四个字节,就是虚表的地址,该地址是一个函数指针数组int vft1 = *((int*)&ps);PrintVFT((FUNC_PTR*)vft1);int vft2 = *((int*)&st);PrintVFT((FUNC_PTR*)vft2);return 0;
}
这里还有一个童鞋们很容易混淆的问题:虚函数存在哪?
答:虚函数存在虚表,虚表存在对象中。注意上面的回答的错的。但是很多童鞋都是这样深以为然的。注意虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的呢?实际我们去验证一下会发现vs下是存在代码段的,Linux g++下大家自己去验证
3. 动态绑定与静态绑定
//父类
class Person
{
public:virtual void BuyTicket(){cout << "买票-全价" << endl;}
};
//子类
class Student : public Person
{
public:virtual void BuyTicket(){cout << "买票-半价" << endl;}
};int main()
{//不构成多态Student Johnson;Person p = Johnson; p.BuyTicket();//构成多态Student Johnson;Person& p = Johnson; p.BuyTicket();return 0;
}
- 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载,也就是直接call函数地址
- 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。也就是先到指定对象的虚表中找到要调用的虚函数,然后才能进行函数的待用
- 买票的汇编代码很好的解释了什么是静态(编译器)绑定和动态(运行时)绑定
四、单继承与多继承的虚函数表
1. 单继承中的虚函数表
a. 先将基类中的虚表内容拷贝一份到派生类虚表中
b. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
在多态原理部分已经解释。如果在虚函数表中查找不到派生类自己的虚函数,可以试试强转获取虚函数表的地址,再进行输出打印(可能会出现权限问题)
// 打印函数指针数组
// void PrintVFT(FUNC_PTR table[])
void PrintVFT(FUNC_PTR* table)
{for (size_t i = 0; table[i] != nullptr; i++){printf("[%d]:%p->", i, table[i]);FUNC_PTR f = table[i];f();}printf("\n");
}int main()
{Person ps;Student st;强转,取出类的前四个字节,就是虚表的地址,该地址是一个函数指针数组int vft1 = *((int*)&ps);PrintVFT((FUNC_PTR*)vft1);int vft2 = *((int*)&st);PrintVFT((FUNC_PTR*)vft2);return 0;
}
2. 多继承中的虚函数表
class Base1 {
public:virtual void func1() { cout << "Base1::func1" << endl; }virtual void func2() { cout << "Base1::func2" << endl; }
private:int b1;
};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;
};int main()
{Derive d;Base1* ptr1 = &d;ptr1->func1();Base2* ptr2 = &d;ptr2->func1();Derive* ptr3 = &d;ptr3->func1();return 0;
}
继承两个基类,就会有两个虚函数表,即继承几个类,子类就有几个虚函数表
通过内存监视可以看到,derive类多继承base1和base2类之后,内存大小为20字节,这是因为base1类的sizeof包含了4字节的虚表指针(32位)和4字节的int型成员变量,同理base2类也是,再加上derive类本身的int型变量,总计20字节。这表明,在子类的虚函数表这方面, 它指挥继承父类的虚函数表,而不会自己单独创建一个而虚函数表!
但是derive子类自身的虚函数会放在哪里呢?
子类的虚函数被放在了base1的虚函数表内
为什么同样都是被子类derive重写了func1函数,在使用base1指针和base2指针分别指向derive类对象的时候,调用的也是同样的函数,为什么base1和base2的func1函数地址不一样呢?
答案是一个为真,一个为假!
多态调用子类重写的虚函数func1或子类本身调用自身的func1函数,它们调用的都是同一个函数,那就是子类的func1函数。从汇编指令可以看出,在使用多态调用时,编译器先传ecx(this指针),再call虚函数的地址。
为什么base2的func1地址要进行修改,而base1的func1函数地址不需要修改呢?
这是因为它们调用的都是子类derive重写的虚函数func1,要使用子类的函数就需要子类的this指针,如果不使用子类的this指针,那么如果在子类重写的虚函数中使用了子类的成员信息,这样就会出错。
而base1是先继承的类,它的地址恰好就是derive类的首地址,它们相同!所以base1直接就call对了,base2是后继承的类,它被夹在了中间,所以它如果像转为子类的this指针,就需要sub 8字节,修正this的位置,转为子类的this指针,从而调用子类重写的虚函数
五、常见面试题
下面的程序将会输出什么?
class A
{
public:virtual void func(int val = 1){ std::cout << "A->" << val << std::endl;}virtual void test(){ func();}
};class B : public A
{
public:void func(int val = 0){std::cout << "B->" << val << std::endl; }
};int main(int argc ,char* argv[])
{B*p = new B;p->test();return 0;
}
首先,p调用test函数,在编译视角看,test() 在B类内部找不到,所以向上找到父类A,A内部有test函数,完整链接,此时test函数内部调用func函数,那么问题来了,是谁调的func函数?
我们要明白一个前提,派生类继承时,只继继承拷贝了父类的成员变量,在内存对齐后再加上自己的成员变量,这样就构成了派生类的成员变量,而基类的成员函数派生类没有专门向自己类内部拷贝一份!因为成员函数都是放在代码段的!所以,test函数内部,this->func() 这里的this还是A*,B*切片为A*
其次,我们再来分析是否满足多态条件,此时已经满足了第一个条件:使用父类指针调用,那么此时的func函数构成重写吗?
我们可能存疑的地方就是形参的缺省值不同,但是重写要求的条件是参数相同,这里的相同表示的是类型、数量、顺序相同,所以这里的缺省值不影响,此时就构成了多态的条件
最后,因为p指向的是B类类型,所以多态调用的是B类的func函数,那么答案显而易见:B->0,但是很遗憾,答案错误
这里又有一个重点,重写的隐藏细节,为什么父类加了virtual后子类就可以省略呢?这是因为重写是重写的函数内部,它会复用基类的函数返回值、参数列表,所以此时多态调用的func函数使用的是A类的 virtual void func (int val = 1) 的壳子,所以正确答案是B->1
1. 什么是多态?
多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。多态又分为静态的多态和动态的多态。更方便、灵活的多种形态的调用。
2. 什么是重载、重写(覆盖)、重定义(隐藏)?
3. 多态的实现原理?
静态多态(重载):函数名修饰规则
动态多态:虚函数表
4. inline函数可以是虚函数吗?
答:不能,因为inline函数没有地址,无法把地址放到虚函数表中。但是实际执行时是可以的,因为编译器会直接忽略掉inline的内联属性,这个函数就不再是inline函数
注意:在类内部直接定义成员函数会被默认带有inline属性
5. 静态成员可以是虚函数吗?
答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式,无法访问虚函数表,所以静态成员函数无法放进虚函数表。
静态成员函数不依赖于对象实例,也不参与多态性,因此不能是虚函数
6. 构造函数可以是虚函数吗?
答:不能,因为虚表是在编译的时候生成的,对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。参考本节内容
8. 对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。
10. C++菱形继承的问题?虚继承的原理?
答:数据冗余和二义性,虚基表。详情参考继承文章。
<C++> 继承-CSDN博客
注意:这里不要把虚函数表和虚基表搞混了。
11. 什么是抽象类?抽象类的作用?
答:抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去抽象纯虚函数,因为子类若是不抽象从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。其次,抽象类可以很好的去表示现实世界中没有示例对象对应的抽象类型