深入篇【C++】基于面向对象特性之<多态>总结->分析底层实现原理附代码案例
- Ⅰ.多态概念理解
- Ⅱ.多态实现条件
- Ⅲ.多态实现原理
- ①.虚表概念
- ②.虚表继承
- ③.虚表位置
- Ⅳ.单继承和多继承关系的虚表
Ⅰ.多态概念理解
1.多态就是多种状态,当完成某种行为时,当不同对象完成时,会产生不同的状态。即不同的对象传递过去,会调用不同的函数。
比如说当我们在12306上购买高铁票时,不同的人群购买的价格是不同的,成人是全票,学生是半票,军人可以优先购票等。这就是多态行为。
2.多态调用与普通函数调用不同。多态调用看的是指向的对象,而普通调用看的是当前的类型。
Ⅱ.多态实现条件
1.在函数前面加上关键字virtual,就变成虚函数了,而且只有成员函数可以变成虚函数。
①在虚函数后面加上=0,就会变成纯虚函数,而包含是纯虚函数的类叫做抽象类。抽象,顾名思义,没有对应的实体,也就是抽象类无法实例化出对象。
②就算这个抽象类被继承下来,派生类也无法实例化出对象。
③只有当这个纯虚函数被重写,这个派生类才可以实例化对象。无法定义出一个对象,但可以定义指针或者别名。
我们可以理解抽象类的出现就是为了强制派生类对虚函数重写,实现多态。
2.重写:基类和派生类中有相同虚函数时,对派生类的的同名虚函数编写就叫做重写。
这里我们将重写,隐藏,重载比较一下:
重写:基类与派生类中具有相同的虚函数,派生类中的同名虚函数构成重写。不过要求三同(返回类型,函数名字,参数类型都相同)。
隐藏:基类和派生类中具有相同的同名函数,派生类中即可构成隐藏。
重载:要求在同一作用域内,才可以构成重载,同名函数参数也要相同,就构成重载。
class person
{
public:
virtual void Buy()const//虚函数
{
cout << "全票" << endl;
}
};
class student :public person
{
public:
virtual void Buy()const//派生类中存在与基类同名的虚函数,并且三同,构成了重写
{
cout << "半票" << endl;
}
};
2.多态实现有两个条件:
①调用的函数必须是重写的虚函数。
②调用函数的参数必须是基类的指针或引用。
void func(const person* p)//必须用基类的指针或引用
{
p->Buy();
}
int main()
{
person p;
func(&p);//指向的对象是p
student s;
func(&s);//指向的对象是s
}
3.虚函数重写中一些细节:
①派生类中的重写虚函数可以不用加virtual。
②重写中存在着一个特例(协变)重写的虚函数返回值可以不同,但要求必须是父子类的指针或别名。
4.析构函数可以是虚函数吗?为什么呢?
我们要理解虚函数的出现就是为了完成多态,那么析构函数可以变成虚函数吗?
答案是可以的。在析构函数的前面加上virtual就可以变成虚函数了,并且析构函数也可以构成重写!
可能你很奇怪,每个类的析构函数名字都不一样,怎么能构成重写呢?这里是因为编译器做了统一改变,将析构函数名字都改成destructor,所以可以构成重写。
那么问题又来了,那析构函数可以实现多态吗?能构成重写那么应该可以实现多态,实现多态有两个条件,还有一个条件就是:调用函数的参数必须是基类的指针或引用。
这里明确的可以告诉你,析构函数必须要求可以实现多态,因为某些场景下,如果不实现多态的话就会出bug。
class person
{
public:
virtual void Buy()const//虚函数
{
cout << "全票" << endl;
}
virtual ~person()//析构函数要求重写
{
cout << "~person()" << endl;
}
};
class student :public person
{
public:
virtual void Buy()const
{
cout << "半票" << endl;
}
virtual ~student()//派生类的前面也可以不用加virtual
{
cout << "~student" << endl;
delete[] ptr;
}
protected:
int* ptr = new int[10];
};
//析构函数可以成为虚函数
//析构函数前面加上virtual就构成函数重写,因为所有析构函数都改成相同
//名字destructor了,为什么要重写呢?
//有一种场景:
int main()
{
person* p = new person;
p->Buy();
delete p;
//这里是没有问题的
p = new student;
p->Buy();
delete p;//这时如果不重写析构函就存在问题了,p指向的是派生类对象,而析构的是基类对象。
//这里析构的是p,p是person类型的,所以还是调用的是person类的析构函数
//这里就调用两次了,我们想要析构的是student ,是p指向的对象,
//我们想的是它调用指向对象的函数。
//这里我们期望p->析构 ,是一个多态调用,而不是一个普通调用。
return 0;
}
所以你应该明白为什么要将析构函数的名字统一改成destructor了,就是为了能让析构函数可以实现多态来修补上面的bug。所以还有一个建议那就是所有需要继承的基类的析构函数前面都加上virtual,而派生类的析构函数就可以不用加了。
5.final关键字的作用:
①修饰类不能被继承。
②修饰类虚函数不能被重写。
class car
{
public:
virtual void Drive() final //final修饰的虚函数不能重写
{
}
};`在这里插入代码片`
class benz : public car
{
public:
void Drive()//被final修饰后无法重写,这里会报错的
{
cout << "舒适" << endl;
}
};
根据final关键字我们可以设计出一个不能被继承的类:直接加上final关键字。而在final
关键字没有出现之前,大佬们都是这样设计的:将不想被继承的类的构造函数或者析构函数变成私有,那么任何函数都访问不到它的构造或析构,那么就无法继承下去。
6.override关键字的作用:
①检查要重写的虚函数是否重写成功,如果没有则会报错。
class car
{
public:
virtual void Drive()
{
}
};
class benz : public car
{
public:
void Drive() override//帮助派生类检查是否完成重写,如果没有重写则会报错
{
cout << "舒适" << endl;
}
};
Ⅲ.多态实现原理
①.虚表概念
.1. 当成员函数变成虚函数时,对象内存中会生成一个虚函数表,里面存的虚函数地址,指向对应的虚函数。这个虚函数表,本质上就是一个指针,会计入对象的大小中。
class B
{
virtual void fun1()
{
cout << "fun1()" << endl;
}
virtual void fun2()
{
cout << "fun2()" << endl;
}
void fun3()
{
cout << "fun3()" << endl;
}
protected:
char _b=1;
};
//类里有虚函数就会存在虚表,虚表里存的是虚函数的地址,而虚表本质上就是一个指针
int main()
{
B bb;
//B对象里,有着一个指针和一个char类型的字符,根据内存对齐算出来大小是8
cout << sizeof(B) << endl;
}
②.虚表继承
1.子类的虚表是如何形成的呢?
①首先继承父类的虚表。
②将重写的虚函数地址覆盖到虚表上。
③将子类的其他虚函数地址放入虚表最后中。
2.多态是如何实现指向父类调用父类函数,指向子类调用子类函数的呢?
当指向父类对象时,就会去父类对象的虚表里找虚函数,当指向子类对象时,就会去子类对象的虚表里找虚函数。
中间发生了切割,本质上都是指向了父类数据,看到的还是父类对象。因为派生类继承不仅继承父类的所有数据,也将父类的虚表继承下来了。派生类会将重写的虚函数地址覆盖原来的基类的虚函数。这样就可以实现指向父类调用父类函数,指向子类调用子类函数。
3.我们要理解子类是不会产生虚表的,子类的虚表都是从父类那里继承下来的,这个虚表都是存着父类的虚函数地址,然后子类会将重写的虚函数地址覆盖到对应的虚表位置,最后会将子类的其他虚函数地址放在这个虚表的最后面。
4.要理解将子类赋值给父类对象时,切片过程中,子类的虚表并没有拷贝切过去。这个过程是不会拷贝子类的虚表的。因为如果拷贝子类的虚表赋值给父类了,那么当指向的对象是这个父类时,到这个父类的虚表里找,找的那就是子类的虚函数了,而不是父类的虚函数。
class person
{
public:
virtual void Buy()const//虚函数
{
cout << "全票" << endl;
}
};
class student :public person
{
public:
virtual void Buy()const
{
cout << "半票" << endl;
}
};
void func(const person* p)
{
p->Buy(); //必须用基类的指针或引用
}
int main()
{
person p;
student s;
p = s;
//将子类s对象赋给父类对象p,不会拷贝虚表的
//当指向是父类还是会去父类的虚表里去找虚函数
func(&p);//指向的对象是p
}
5.理解虚函数为什么要重写?
只有虚函数重写,派生类的虚表里才可以存真正派生类虚函数,因为这个虚表是从父类继承下来的,里面都是父类的虚函数地址。而只有派生类虚函数重写后,才可以将重写的虚函数地址覆盖上去。这样就可以做到指向父类调用父类虚表中对应的虚函数,指向子类,调用子类虚表中对应的虚函数。
6.理解接口继承和实现继承
普通函数的继承是实现继承,派生类继承基类的函数,可以使用基类的函数,继承的是这个函数的实现。
而虚函数的继承是一种接口继承,派生类继承的是虚函数的接口,目的就是为了重写函数,为了实现多态。
当虚函数重写时,重写的是函数内部的实现,而外部的框架还是是继承父类的外壳。
所以不实现多态就不要将函数弄成虚函数。
7.静态多态与动态多态:
①静态多态,程序在编译时就确定了程序行为比如函数重载。
②动态多态,程序在运行时根据拿到的类型而确定程序的行为比如函数重写与多态。
当符合多态时,运行时到指向对象的虚表中找调用函数的地址。
而当不符合多态时,直接在编译时就确定了调用函数的地址。
③.虚表位置
1.虚表通常是存在常量区的,虚表是不能修改的,同一类型的对象共用一个虚表。
Ⅳ.单继承和多继承关系的虚表
1.单继承中的虚表
class Base
{
public:
virtual void fun1()
{
cout << "base:fun1()" << endl;
}
virtual void fun2()
{
cout << "base:fun2()" << endl;
}
protected:
int _c;
};
class Derive :public Base
{
public:
virtual void fun1()//重写fun1
{
cout << "Derive:fun1()" << endl;
}
virtual void fun3()//虚函数fun3
{
cout << "Derive:fun3()" << endl;
}
protected:
int _d;
};
int main()
{
Base b;
Derive d;
}
这个其实是编译器的问题,它把fun3隐藏起来了,正常来说fun3是派生类的虚函数,会放在派生类的虚函数表中最后面,但发现虚表中并没有。
我们要知道fun3是放在虚表的后面就可以了,也有办法将这个虚表打印出来,但有点麻烦。所以这里就不打印了。
2.多继承中的虚表
class Base1
{
public:
virtual void fun1()
{
cout << "base1:fun1()" << endl;
}
virtual void fun2()
{
cout << "base1:fun2()" << endl;
}
protected:
int _b;
};
class Base2
{
public:
virtual void fun1()
{
cout << "base2:fun1()" << endl;
}
virtual void fun2()
{
cout << "base2:fun2()" << endl;
}
protected:
int _c;
};
class Derive :public Base1 ,public Base2
{
public:
virtual void fun1()//重写fun1
{
cout << "Derive:fun1()" << endl;
}
virtual void fun3()//虚函数fun3
{
cout << "Derive:fun3()" << endl;
}
protected:
int _d;
};
int main()
{
Base1 b1;
Base2 b2;
Derive d;
}
其实派生类的虚函数fun3存的虚表是第一个虚表,也就是Base1类中的虚表中。被编译器隐藏看不到。
还有一个问题,派生类继承了两个基类,所以派生类有两个虚表,派生类中对虚函数fun1进行了重写,那么重写后的虚函数地址会覆盖在原来的虚表上。这些都是没有问题的。有问题的是继承下来的Base1的虚表中重写的fun1地址和Base2的虚表中重写的fun1的地址竟然不一样。派生类中只有一个fun1,理论上这两个重写虚函数地址应该相同啊。但是这里面并不相同,而且当我们去调用这两地址时,就会发现竟然都调用了派生类中的虚函数fun1。这是什么原理呢?
想要调用派生类的函数,即需要传this指针去调用Derive d d.fun1();
本质上是用this指针去调用fun1的,而this指针是Derive类型的,一开始是指向Derive对象的起始位置。
而Base1对象正好是存放在Derive对象的起始位置上,但Base2对象却不是存放在Derive对象的起始位置上。
所以Base2中的fun1地址需要调整一下,需要减去一个Base1类型的距离,这样Base2*指针就可以到Derive的起始位置了,就可以正确调用fun1了。所以原理很简单,就是对Base2的虚函数fun1地址进行封装。而真正的地址是原来的地址再减去Base1对象大小。