多态就是多种形态,是不同的对象去完成同一个动作所产生的结果可能有多种。这种多种的形态我们称之为多态。
比如:我们在买票的时候的时候,可能有成人全价,儿童半价,军人免票等等。对于成人,儿童,军人这三个不同的对象,在买票同一动作当中,就产生了不同的结果。
多态出现在同一继承关系当中的不同类对象,比如上述说的 Person对象买票全价,Student对象买票半价。
在多态的组成方面,有两大必须的条件:
在知道多态是如何构成之前,我们先来认识一种特殊的成员函数---虚函数。
注意:
虚函数定义如:
class Person {
public:
virtual void buy() { cout << "全价" << endl;}
};
全局函数不能加 virtual 修饰变成虚函数:
虚函数和其他成员函数一样,但是虚函数有一个特征,虚函数支持重写(覆盖)。
如果在派生类当中,有一个和基类当中相同的虚函数(两者之间返回值,函数名,参数列表完全相同),我们认为,此时派生类重写了基类当中的虚函数。
class Person
{
public:
virtual void buy() {
cout << "Perosn:全价" << endl;
}
};
class Student : public Person
{
public:
virtual void buy() {
cout << "Student:半价" << endl;
}
};
上述子类(student)就重写了 父类(Perosn)当中的 buy() 这个虚函数。
对于上述 这种虚函数的使用场景(通过指针或者引用来调用虚函数):
void func(Person& people)
{
people.buy();
}
int main()
{
func(Person());
func(Student());
return 0;
}
输出:
Perosn:全价
Student:半价
这样的话,我们就可以做到类似于,自动识别对象,然后去购买不同的票了。
请注意,我们在调用虚函数的时候,一定是使用 引用或者指针的方式来调用虚函数,而且子类父类当中的函数都应该是 virtual 修饰的,子类重写过的虚函数,否则无法实现多态(如下,我们把func()函数当中的 Person& 参数类型 改为 Person):
void func(Person people)
{
people.buy();
}
输出:
Perosn:全价
Perosn:全价
我们发现结果都是 “全价”。没有多态现象出现。
同样,如果父类的函数没有加 virtual 修饰,输出结果和上述一样,但是如果父类虚函数加了 virtual 修饰,子类函数没有加 virtual 修饰,是可以实现多态的。----但是就算能够实现多态,建议还是把子类和父类的虚函数都加上 virtual 修饰。
编译器在这里支持,派生类不用加 virtual ,是因为,编译器对于派生类的检查只是检查,派生类符不符合 “三同”的 多态条件。不同,可能看该函数和父类当中的虚函数函数名相同,就是别成隐藏了;相同才会去认为该函数是虚函数的重写。
因为 派生类 继承了 父类的 virtual 修饰的虚函数,而子类当中的 重写只是对 父类当中虚函数的实现部分进行 重写。
class Person
{
public:
virtual void buy() {
cout << "Perosn:全价" << endl;
}
};
class Student : public Person
{
public:
void buy() {
cout << "Student:半价" << endl;
}
};
void func(Person& people)
{
people.buy();
}
int main()
{
Person perosn;
func(perosn);
Student student;
func(student);
return 0;
}
输出:
Perosn:全价
Student:半价
像上述的实现多态例子中的 Student 类型 对象传参到 Person& 类型参数接收,这里发生了 子类 到 父类的 切割。
有了切割,当传入参数就是父类的时候,不需要切割,这类直接就是调用父类对象的引用来调用buy()这个函数;如果传入的是子类的话,就会发生切割,指向子类,此时就是子类的引用,所以调用的是子类的buy()函数。
具体切割是如何切割法,可以看以下博客:C++ - 继承_chihiro1122的博客-CSDN博客
但是这里就有一个问题,我们知道,对象当中只存储成员变量,不存储成员函数;而且就算是子类的引用,只是访问的是父类当中子类的那一部分成员,编译器在此处究竟是如何做到区分两个虚函数的呢?
这种情况是 -- 基类和派生类虚函数的返回值类型不同。
但是,这里虚函数的返回值类型是有规定的,如果是只是普通类型的返回值类型不同,是会报错的:
如果不是协变引起的虚函数返回值类型不同,编译器是会报编译错误的。
只允许 基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用的情况,而我们把这种称为 协变。(而且,父类虚函数 和 子类虚函数 的返回值类型 必须同时是 指针 或者 引用,如果是像 指针 和 引用 岔着用是不行的,编译器会报错)
如下代码所示:
class Person
{
public:
virtual Person* buy() {
cout << "Perosn:全价" << endl;
return 0;
}
};
class Student : public Person
{
public:
Student* buy() {
cout << "Student:半价" << endl;
return 0;
}
};
虽然协变指定是父类虚函数返回值是父类的指针或引用,紫烈虚函数返回值是子类的之怎或引用;但是,只要是满足继承关系的类,按照上述的方式去使用协变,也是可以的(就是说上述返回值不一定是 Person 和 Student,也可以是其他父子关系)。
如下代码所示(在 Person 和 Student 的虚函数返回值类型使用 A 和 B 其他继承关系):
class A
{
public:
};
class B : public A
{
};
class Person
{
public:
virtual A* buy() {
cout << "Perosn:全价" << endl;
return 0;
}
};
class Student : public Person
{
public:
B* buy() {
cout << "Student:半价" << endl;
return 0;
}
};
但是协变是一个 坑,由上面说的种种细节可以看出来,细节很多,不好记。而且协变在日常当中的使用频率也很少。不如不支持这个语法。但是在学校考试 和 面试当中经常考。
class Person {
public:
virtual ~Person() {
cout << "~Person()" << endl;
}
};
class Student : public Person {
public:
virtual ~Student() {
cout << "~Student()" << endl;
}
};
虽然上述的 Person 和 Student 两个类的析构函数名字看上去不同,但是实际上,继承当中的 父类 和子类的 析构函数是可以 构成虚函数的。
如上述例子, ~Perosn()和 ~Student()两个函数,子类可以重写。
之所以支持,是因为,类的虚构函数都被处理为了destructor 这个统一的名字。这样处理的目的也是为了让 子类和父类的析构函数构成重写。
如果不这样处理,会出现一些问题,子类重写的话,会出现一些问题:
class Person {
public:
~Person() {
cout << "~Person()" << endl;
}
};
class Student : public Person {
public:
~Student() {
cout << "~Student()" << endl;
}
};
int main()
{
Person* p = new Person();
delete p;
p = new Student();
delete p;
return 0;
}
如上所示,我们希望输出的结果是 :
~Person()
~Student()
~Person()
但,实际输出却是:
~Person()
~Person()
出现这个问题的原因是也 p 指针的类型。我们知道,普通对象 看当前调用的类型来决定调用 哪一个对象的析构函数,当前调用者 (p) 的类型是 Person*,所以自然只会调用 Person 对象的析构函数,(对于 delete p ,释放顺序是 p->destructor + operator delete(p) ),这里调用的是 Person的析构函数,但是这里我们不希望调用 Perosn的析构函数。
这里我们希望 p 指向那个对象就调用哪一个对象的析构函数,而不是看 p 指针的类型来决定调用哪一个对象的 析构函数。如果看类型的话,一直调用的就是 p 的类型的析构函数。但是 p 这个指针有可能指向父类,也有可能指向子类。
我们希望 p->destructor()调用的析构函数,是一个多态调用,而不是一个普通调用。
所以这里我们要使用多态来实现,在 detele 底层实现当中,就是使用 指针来调用 析构函数的,指针已经实现了,现在还差重写,所以才有了上述的 析构函数重写。
上述我们也介绍了,如果实现函数重写,我们也发现,C++当中对于重写函数的规定还不少,缺一样都会导致重写失败。有些错误甚至在编译器时期是不会报错的,只有在程序运行之后才能发现问题,此时在发现问题就只能去debug,在代码量很多的场景当中,特别麻烦。
所以在C++11 当中新增了 两个关键词 final 和 override ,来帮助我们检查是否重写。
final:
final 关键字是用来阻止某一虚函数被子类重写:
final 关键词修饰位置 和 之前 const 修饰 this 指针一样,是在 参数列表括号的右边。(而且只能放在父类的虚函数上)
当父类的 虚函数被 final 修饰之后,子类就不能再重写父类的这个虚函数了。
override:
override用于帮助派生类检查是否完成重写,如果没有,会报错:
这样就方式我们因为,派生类没有重写完成,而导致后序debug的麻烦了。
下面这个例子,应该输出什么?
class Bass
{
public:
virtual void func()
{
}
private:
char _b;
};
int main()
{
cout << sizeof(Bass) << endl;
Bass b;
return 0;
}
上述输出不是1,而是8。我们知道,类的大小只计算成员大小,不计算函数。
我们打开调试发现,在 b 这个对象当中多了一个 _vfptr 指针(virtual function)。
这指针是 虚函数表 指针,
这就是为什么,没有实现多态,不要把虚函数搞到类当中去;因为虚函数会被放进虚函数表当中;其实严格来说,虚函数还是存储在代码段当中的,而虚函数表当中存储的是各个虚函数的地址。
这个虚函数表,在重写之后,会发生变化,我们来看下面这个例子:
class Person {
public:
virtual void BuyTicket() {
cout << "买票-全价" << endl;
}
int _a = 1;
};
class Student : public Person {
public:
virtual void BuyTicket() {
cout << "买票-半价" << endl;
}
int _b = 2;
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
Student jason;
Func(Mike);
Func(jason);
return 0;
}
在上述这个代码当中,Mike 对象(父类对象)当中有下面这两个部分:
_vfptr 是虚函数表的指针,此时的虚函数表当中存储的是 Person(父类)当中虚函数的地址,此时只有一个地址,因为只写了一个虚函数,如果有多个虚函数的话,有几个虚函数,虚函数表当中就有几个地址。
我们发现,在子类对象 jason 当中有一个父类对象,父类对象当中也有一个虚函数表指针,此时虚函数表当中也只有一个地址,这个地址已经发生了改变,指向了子类重写的虚函数。
总结:重写也可以叫覆盖,重写是我们写代码层面所看到的,覆盖是底层逻辑当中,子类重写的虚函数地址覆盖了父类虚函数的地址。
此时我们就明白下面这个函数是如果实现,传入父类就调用父类的函数,传入子类就调用子类的函数了:
- 传入父类,看到就是父类,直接调用父类的函数;传入子类,切片之后看到的还是父类;
- 如果是普通调用,在编译的时候就确定了地址,编译器判断是不是普通的调用很简单,看符不符合多态,不符合就是普通调用。
- 如果是普通调用,就直接看p的类型,p的类型是Person,那么就直接在Person当中找到这个函数的地址,所以就不能实现多态。
- 符合多态,就和上述说的一样,运行时到指向的对象的虚函数表当中,找调用。
很多人,看到满足多态的条件,以为输出的是 B->0 ;但是实际输出却是 B->1。
我们发现,上述的func()函数,满足 虚函数重写,子类父类的虚函数函数名,返回值,参数类型和个数都是相同的(注意,不要看val 的缺省参数不同就认为这里不满足多态,参数列表相同只要求 参数个数 和 参数类型相同即可);
而且,在 test()函数当中调用的 func ()函数,使用指针调用的 ,因为 func()函数是本类当中的成员函数,本类当中的成员是需要用 this->func() 这样的形式来访问的;而这里的this指针是父类还是子类的 指针呢?
答案是父类的。因为,子类继承父类当中的成员,不是直接进行拷贝赋值,而是调用父类的构造函数,在子类当中构造出一个父类的子对象,这个子对象我们可以理解为子类当中父类对象成员。然而,test()函数是存在于代码段的,他也不是在子类和父类当中都有存在,也就是说,test()函数只在代码段当中存储了一份,而不是在子类和父类当中都存储了一份。
因为,父类对象是直接在子类当中存储的,子类不会单独的看test()函数,而是把父类对象看做是一个整体,test()就在这个整体当中,所以,test()对象当中的 调用 func()函数使用的this指针是 A*(父类指针)。
而在主函数当中的指针p,指向的是 B (子类对象),又满足多态,所以此时肯定是调用子类当中的 func()函数,所以输出 B-> 是正确的。
但是,要注意的是,重写只是重写函数当中 实现部分,对于函数名,返回值,参数列表还是使用的是父类的。所以,此处的 val 的缺省参数才是 父类当中的1,而不是子类当中的0。
可以理解为,重写是,父类 的 函数名,返回值,参数列表 + 子类函数实现。
现在我们把上述例题修改一下,把 test()函数挪到 B 函数当中,其他不变:
此时输出结果就是 B->0 了 。因为此时的 test()函数不满足多态的条件,此时的test()函数当中调用的 func()函数的 this 指针不是A*(父类指针)了,而是 B* (子类指针)。
所以,此时 test()当中的 调用 func()函数,就只是一个简单的 在本类当中调用本类的其他函数的情况。