前言:
上一篇文章已经介绍了c++的继承,那么这篇文章将会介绍多态。
多态的概念
在C++中,多态(polymorphism)是面向对象编程的重要特性之一,它允许在运行时根据对象的实际类型来调用相应的函数,而不是在编译时确定调用哪个函数。这种特性使得代码更加灵活和可扩展。
多态的定义
看完多态的概念,你一定会感觉脑子雾蒙蒙的,那么我们先以举一个例子,来给这朦胧大致勾勒出一个画面,
在此之前,先介绍一个名词虚函数,(要注意与虚拟继承区分)
虚函数
被virtual修饰的类成员函数被称为虚函数。
class Person
{
public:
//被virtual修饰的类成员函数
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
需要注意的是:
1:只有类的非静态成员函数前可以加virtual,普通的全局函数或静态成员函数前不能加virtual。
2:虚函数这里的virtual和虚继承中的virtual是同一个关键字,但是它们之间没有任何关系。虚函数这里的virtual是为了实现多态,而虚继承的virtual是为了解决菱形继承的数据冗余和二义性。
多态构成条件
多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。
在我们的日常的生活中,在购买高铁车票时,不同的人购买会有不同的优惠,比如像学生买就会是75折,军人买高铁票就会是5折,普通人群就会是原价,但我们假如要写一个代码,去实现这个功能,使其不同类人群买票其对应的价钱不一样,如果没有学多态,那么写出来的代码会是用if,else if,else来判断,然后对应不同的价钱。但是这样的代码会先得过于杂乱无章,自此就产生了多态。
就比如下面得代码。
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;
}
通过上面的代码我们知道了以下几点
派生类是通过public方式继承,那么理论上他会通过public方式访问,继承基类的BuyTicket函数,但是因为BuyTicket是虚函数,就会导致此函数虽被继承,但是可以在派生类中进行重写;
复习以下继承的知识,(切割规则)在Func函数内使用Person类型的p作为参数接收实参,这时候就有人问了用Student不更好么?这就会导致一个问题,比如用Student接收,这就不符合切割规则,他就会导致放大,这就导致权限的放大,会出现问题。
虚函数的重写
上面提到了虚函数重写,这一定义其实光从名字是来看也知道个大概意思,就是派生类中对虚函数进行重定义嘛。还是很好理解的。但其中还是由点细节的。
虚函数的重写也叫做虚函数的覆盖,若派生类中有一个和基类完全相同的虚函数(返回值类型相同、函数名相同以及参数列表完全相同),此时我们称该派生类的虚函数重写了基类的虚函数。
还拿上面的代码举例
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
我们定义了一个基类,其中BuyTicket()是作为虚函数存在,因为在派生类中也存在BuyTicket()函数,那么就会导致其该函数在派生类中进行重定义。
在原理上,派生类先回拷贝基类中的public成员,在派生类中,如果存在与基类中虚函数名字相同的函数,那么就会进行重定义,所以虚函数的重写也叫做虚函数的覆盖。先拷贝,然后再判断是否进行覆盖。
注意: 在重写基类虚函数时,派生类的虚函数不加virtual关键字也可以构成重写,主要原因是因为继承后基类的虚函数被继承下来了,在派生类中依旧保持虚函数属性。但是这种写法不是很规范,因此建议在派生类的虚函数前也加上virtual关键字。
虚函数重写的两个例外
根据上面的介绍,要知道的一点就是福字的两个虚函数要做到三同(函数名/参数/返回值相同)
但是凡是都会存在例外,比如下面的两个例外:
协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用,称为协变。
还是那上面的代码进行修改
基类Person当中的虚函数fun的返回值类型是基类A对象的指针,派生类Student当中的虚函数fun的返回值类型是派生类B对象的指针,此时也认为派生类Student的虚函数重写了基类Person的虚函数。
//基类
class A
{};
//子类
class B : public A
{};
//基类
class Person
{
public:
//返回基类A的指针
virtual A fun()
{
cout << "A Person::f()" << endl;
return new A;
}
};
//子类
class Student : public Person
{
public:
//返回子类B的指针
virtual B fun()
{
cout << "B Student::f()" << endl;
return new B;
}
};
析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。
下面代码中父类Person和子类Student的析构函数构成重写。
//父类
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;
}
如果二者不构成虚函数的重写,那么在调用的全都是Person的析构,这就导致了一个问题内存泄漏,因为此时delete p1和delete p2都是调用的父类的析构函数,而我们所期望的是p1调用父类的析构函数,p2调用子类的析构函数,即我们期望的是一种多态行为。
想到达到的理想结果就是自己调用对应的析构函数,那么此时只有父类和子类的析构函数构成了重写,才能使得delete按照我们的预期进行析构函数的调用,才能实现多态。因此,为了避免出现这种情况,比较建议将父类的析构函数定义为虚函数。
知识扩展:
在继承当中,子类和的析构函数和父类的析构函数构成隐藏的原因就在这里,这里表面上看子类的析构函数和父类的析构函数的函数名不同,但是为了构成重写,编译后析构函数的名字会被统一处理成destructor();
C++11 override和final
就比如下面的代码,分别设计一个完成重写的虚函数类,一个没有完成。
// 父类
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
//子类
class Student : public Person
{
public:
//子类完成了父类虚函数的重写,编译通过
virtual void BuyTicket() override
{
cout << "买票-半价" << endl;
}
};
//子类
class Soldier : public Person
{
public:
//子类没有完成了父类虚函数的重写,编译报错
virtual void BuyTicket(int i) override
{
cout << "买票-优先" << endl;
}
};
// 父类
class Person
{
public:
//被final修饰,该虚函数不能再被重写。
virtual void BuyTicket() final
{
cout << "买票-全价" << endl;
}
};
//子类
class Student : public Person
{
public:
//子类完成了父类虚函数的重写,但基类的虚函数被final修饰,不允许重写,会报错
virtual void BuyTicket() override
{
cout << "买票-半价" << endl;
}
};
重载、覆盖(重写)、隐藏(重定义)的对比
抽象类
概念
在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。
//抽象类(接口类)
class Person
{
public:
//纯虚函数
virtual void BuyTicket() = 0
{
cout << "买票-全价" << endl;
}
};
int mian()
{
Person p;//无法实例化抽象类
return 0;
}
{
cout << "买票-全价" << endl;
}
//抽象类(接口类)
class Person
{
public:
//纯虚函数
virtual void BuyTicket() = 0;
};
void Person::BuyTicket()
{
cout << "买票-全价" << endl;
}
int mian()
{
Person p;//无法实例化抽象类
return 0;
}
派生类继承抽象类后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
//抽象类(接口类)
class Person
{
public:
//纯虚函数
virtual void BuyTicket() = 0;
};
class Student : public Person
{
//没有重写纯虚函数
};
class Soldier : public Person
{
public:
//重写了纯虚函数
virtual void BuyTicket()
{
cout << "买票-优先" << endl;
}
};
int mian()
{
Person p;//无法实例化抽象类
Student s;//无法实例化抽象类
Soldier s1;//可以实例化抽象类
return 0;
}
抽象类可以更好的去表示现实世界中,没有实例对象对应的抽象类型,比如:植物、人、动物等。
抽象类很好的体现了虚函数的继承是一种接口继承,强制子类去重写纯虚函数,因为子类若是不重写从父类继承下来的纯虚函数,那么子类也是抽象类也不能实例化出对象。
接口继承和实现继承
实现继承: 普通函数的继承是一种实现继承,派生类继承了基类函数的实现,可以使用该函数。
接口继承: 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态。
建议: 所以如果不实现多态,就不要把函数定义成虚函数。
多态的原理
在上面说了那么多,我们已经知道多态是什么了,那么对于多态的原理还是不是很清楚的,那么接下来就看看底层吧。
虚函数表
我们定义一个Base类,里面有虚函数,还有一个变量int,按照我们之前学习的结构体计算大小方法,这里Base类的大小应该是4个字节,图中确是8个字节。(也存在着内存对齐)
为什么会发生这种现象呢?
用监视窗口看一下
可以发现b中不仅仅存了int类型的_b,还另存了一个 __vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数 的地址要被放到虚函数表中,虚函数表也简称虚表。其实应该叫__vftptr(多个t代表table)。
那如果我们多添加几个虚函数呢?
可以观察得知,虚函数全会放在虚函数表内,那么普通函数会不会呢?
可见,只有虚函数会被放到虚函数表内,而普通函数不会放到虚函数表内,同样运行起来大小仍然不变
然而说到底,Base产生的对象中其实就存了两个数据,一个是int类型的_b,一个是指针,这个指针又是函数指针数组,其函数指针指向的内容又是一群函数指针,每个指针对应存储着对应的函数地址。
实际上虚表当中存储的就是虚函数的地址,因为父类当中的Func1和Func2......都是虚函数,所以父类对象b的虚表当中存储的就是虚函数Func1和Func2.......的地址。
对于子类我们设计只对Func1进行重写,看看其效果
而子类虽然继承了父类的虚函数,但是子类对父类的虚函数Func2进行了重写,因此,子类对象d的虚表当中存储的是父类的虚函的地址和重写Func2的的地址。这就是为什么虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数地址的覆盖,重写是语法的叫法,覆盖是原理层的叫法。
总结一下,派生类的虚表生成步骤如下:
先将基类中的虚表内容拷贝一份到派生类的虚表。
如果派生类重写了基类中的某个虚函数,则用派生类自己的虚函数地址覆盖虚表中基类的虚函数地址。
派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
虚函数表的存放位置
虚表是什么阶段初始化的?虚函数存在哪里?虚表存在哪里?
虚表实际上是在构造函数初始化列表阶段进行初始化的,注意虚表当中存的是虚函数的地址不是虚函数,虚函数和普通函数一样,都是存在代码段的,只是他的地址又存到了虚表当中。另外,对象中存的不是虚表而是指向虚表的指针。
至于虚表是存在哪里的,我们可以通过以下这段代码进行判断。
int static_i = 0;
int main()
{
Base b;
Base p = &b;
printf("vfptr:%p\n", ((int*)p)); //000FDCAC
int i = 0;
printf("栈上地址:%p\n", &i); //005CFE24
printf("数据段地址:%p\n", &static_i); //0010038C
int* k = new int;
printf("堆上地址:%p\n", k); //00A6CA00
const char* cp = "hello world";
printf("代码段地址:%p\n", cp); //000FDCB4
return 0;
}
代码当中打印了对象b当中的虚表指针,也就是虚表的地址,可以发现虚表地址与代码段的地址非常接近,由此我们可以得出虚表实际上是存在代码段的。
判断是否构成多态
观察下面的代码,看看那个调用是构成多态:
class Base
{
public:
virtual void Func()
{
cout << "Func()--1" << endl;
}
void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func()
{
cout << "Func()--2" << endl;
}
private:
int _d = 1;
};
int main()
{
Base b;
Derive d;
Base* p1 = &b;
p1->Func();//1
p1->Func1();//2
p1->Func2();//3
Base* p2 = &d;
p2->Func();//4
p2->Func1();//5
p2->Func2();//6
Base p3 = b;
p3.Func();//7
p3.Func1();//8
p3.Func2();//9
Base p4 = d;
p4.Func();//10
p4.Func1();//11
p4.Func2();//12
d.Func2();//13
d.Func1();//14
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;
};
int main()
{
Base b;
Derive d;
return 0;
}
在我们理想的情况下:
对于d的虚函数表_vfptr内里应当回存储着4个值,分别是Func1(),Func2(),Func3(),Func4(),
其中Func2()会与b中存的是一样的值,因为没有进行重写嘛,
但通过监视窗口发现,实际与我们的猜想大大相反!!!
但监视窗口一定是对的么?
typedef void(VF)();//函数指针,打印虚表地址及其内容
void printfVF(VF a)
{
for (size_t i = 0; a[i] != 0; i++)
{
printf("[%d]:%p->", i, a[i]);//打印虚表当中的虚函数地址
VF f = a[i];
f();//使用虚函数地址调用虚函数
}
printf("\n");
}
int main()
{
Base b;
printfVF((VF)(((int)&b)));//打印基类对象d的虚表地址及其内容
Derive d;
printfVF((VF)(((int)&d))); //打印派生类对象d的虚表地址及其内容
return 0;
}
运行结果:
多继承中的虚函数表
以下列多继承关系为例,我们来看看基类和派生类的虚表模型。
//基类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;
};
在我们的理想情况下,其各自对应的存储虚表应该如下:
但是实际上不是的,Derive是只有两个虚表,不会再另外开辟一个虚表,实际上的存储如下:
其外添加的虚函数是默认放到第一个虚表内的
总结一下:
分别继承各个基类的虚表内容到派生类的各个虚表当中。
对派生类重写了的虚函数地址进行覆盖(派生类中的各个虚表中存有该被重写虚函数地址的都需要进行覆盖),比如func1。
在派生类第一个继承基类部分的虚表当中新增派生类当中新的虚函数地址,比如func3。
typedef void(VF)();//函数指针,打印虚表地址及其内容
void printfVF(VF a)
{
for (size_t i = 0; a[i] != 0; i++)
{
printf("[%d]:%p->", i, a[i]);//打印虚表当中的虚函数地址
VF f = a[i];
f();//使用虚函数地址调用虚函数
}
printf("\n");
}
int main()
{
Base1 b1;
printfVF((VF)(((int)&b1)));
Base2 b2;
printfVF((VF)(((int)&b2)));//打印基类对象d的虚表地址及其内容
Derive d;
printfVF((VF)(((int)&d)));//打印基类对象d的虚表地址及其内容
printfVF((VF)((int)((char*) &d + sizeof(Base1))));
return 0;
}
运行结果:
菱形继承和菱形虚拟继承
我的建议是别碰!别碰!别碰!饶了我吧,我会的也不多,学完脑子晕晕的。
最后那我们做一道题吧。
答案就是B,很难相信。
首先,B类型的对象p去调用test(); test()是B类继承下来的,但是里面默认存放的this指针依然是A*,(这是因为再B中的test没有进行重写的原因)所以这就导致因为是多态调用,(导致多态调用的原因:将一个B类型的指针传给A类型的指针,会发生多态)B类里面的func()是重写了A类的func() (A类func()为虚函数,B类重写了可以不写virtual,但一般我们写代码的时候还是要加上的)。
理解了上面的内容那么我们说说为什么对应打印的是1;主要还是重写,在虚函数重写了父类的实现,继承父类的函数声明,所以说前面的那些声明,依然是调用的A类的声明,因此给到的val值是1, 所以输出B->1