目录
一、多态的概念及构成
1.1概念
1.2多态的构成条件(继承+虚函数)
二、虚函数和虚函数重写
2.1虚函数和虚函数重写的概念
2.2虚函数的"异变"(协变+析构重写)
2.3虚函数的扩展(override+final)
2.4重载、重写(覆盖)、隐藏(重定义)的对比
三、抽象类
3.1概念
3.2接口继承和实现继承
四、多态的原理
4.1虚函数表及指针(原理层上的覆盖+虚表存储位置)
4.2多态调用的原理(动态绑定+静态绑定)
五、单继承和多继承关系的虚函数表
5.1单继承中的虚函数表
5.2多继承中的虚函数表
一、多态的概念及构成
1.1概念
从表面意思上来说,就是多种形态,具体来说就是去完成某个行为,当不同的对象去完成时会产生不同的状态。那么运用到类中,多态是在不同的继承关系的类对象去调用同一个函数,产生了不同的行为。
例如:坐飞机,高铁等,买同一趟的班次,有的人花的价钱却不一样。
1.2多态的构成条件(继承+虚函数)
在程序中,构成多态是在继承中发生,同时还有两个条件:
1.必须通过基类的指针或者引用调用虚函数
2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
光看概念还是懵懂,那么还得来了解虚函数是啥。
二、虚函数和虚函数重写
2.1虚函数和虚函数重写的概念
虚函数:在类中,被virtual修饰的函数就叫做虚函数
虚函数的重写(覆盖):
①派生类有一个跟基类完全相同的虚函数
②完全相同指的是他们的返回值类型、函数名字、参数列表完全相同
满足这两点,就可以说子类虚函数构成重写。
直接上一个多态例子:
#include <iostream>
using namespace std;
class Person
{
public:
virtual void rice()//虚函数
{
cout << "吃两碗饭" << endl;
}
};
class Student : public Person
{
public:
virtual void rice()//虚函数,对基类虚函数重写
{
cout << "吃一碗饭" << endl;
}
};
int main()
{
Person p;
Person& ps = p;//基类引用基类
ps.rice();//基类引用调用基类虚函数
Student s;
ps = s;//ps 最开始引用了基类对象 p。无论后来如何操作,ps 指向的始终是基类对象 p。即使将派生类对象 s赋值给 ps;,
//也只是将派生类对象的部分成员切片到了基类对象中,但 ps 本身依然是一个基类引用,
ps.rice();//因此调用 ps.rice() 时会调用基类 Person 中的 rice() 函数,而不是派生类 Student 中的版本。
Person& pp = s;
pp.rice();
pp = p;//同理pp 最开始引用了派生类对象s。无论后来如何操作,pp 指向的始终是派生类类对象 s
pp.rice();//因此调用 pp.rice() 时会调用派生类 Student 中的rice版本。
//指针就不一样了,指针是指向谁就调用谁的,因为指针指向的是对象的地址。
Person* sp = &p;//基类指针指向基类
sp->rice();//基类指针调用基类虚函数
sp = &s;//基类指针指向子类中父类的成员
sp->rice();//基类指针调用子类中父类的虚函数
return 0;
}
输出结果:
由上述多态可以总结:
1.基类引用调用虚函数时,调用其初始化引用中的虚函数,无论后来如何操作,始终不变。
2.基类指针调用虚函数时,指向谁就调用谁的。
同时要注意的是多态调用和普通调用的区别,普通调用规则:
1.在多态中,子类调用子类自己的方法为普通调用
2.非多态中,就是普通调用
普通调用就只跟对象类型有关了,对象类型是属于谁的就调用谁的。
了解了多态,虚函数的概念使用,对此,虚函数还有一些扩展,来继续学习吧
2.2虚函数的"异变"(协变+析构重写)
①子类虚函数重写时,不加关键字virtual也构成虚函数重写,其依然是虚函数,但是父类的关键字virtual不能省略。注意:只要父类的virtual未省略,其所有直接继承、非直接继承中子类虚函数重写都可不加virtual,其依然是虚函数。例如:
class Person
{
public:
virtual void rice()//虚函数
{
cout << "吃两碗饭" << endl;
}
};
class Student : public Person
{
public:
void rice()//子类虚函数的重写不加virtual依然构成重写
{
cout << "吃一碗饭" << endl;
}
};
② 协变(基类与派生类虚函数返回值类型不同)
当派生类重写基类虚函数时,其返回值类型与基类的虚函数返回值类型可以不同,构成这样的条件是,有额外的继承关系,且当前基类虚函数的返回值类型为额外的基类对象的指针或者引用,派生类虚函数的返回值类型为额外的派生类对象的指针或者引用。
#include <iostream>
using namespace std;
//额外的继承关系
class A
{};
class B : public A
{};
class Person
{
public:
//virtual A* rice()//基类虚函数返回额外的派生类对象的引用指针
//{
// cout << "吃两碗饭" << endl;
// return new A;
//}
virtual const A& rice()//基类虚函数返回额外的派生类对象的引用
{
cout << "吃两碗饭" << endl;
return A();//构造匿名对象并返回,因为该匿名对象在函数结束时会销毁,
//所以返回时会生成临时对象,匿名对象给给临时对象,而临时对象具有常属性,所以返回类型需要加const
}
};
class Student : public Person
{
public:
//B* rice()//派生类虚函数返回额外的派生类对象的指针
//{
// cout << "吃一碗饭" << endl;
// return new B;
//}
const B& rice()//派生类虚函数返回额外的派生类对象的引用
{
cout << "吃一碗饭" << endl;
return B();
}
};
int main()
{
Person p;
Person& ps = p;
ps.rice();
Person* sp = &p;
sp->rice();
Student stu;
sp = &stu;
sp->rice();
return 0;
}
输出结果:
按照协变的规则,其明显违反了多态的规则,但就是能支持,没办法,C++的语法就是这么复杂,在这里协变又可以看做一个特例。
③析构函数的重写(基类与派生类析构函数的名字不同)
如果基类析构函数是虚函数,那么子类的析构函数就构成对父类析构函数的重写。这是为什么呢,明明他们的函数名称不同为何构成重写?
实则,编译器会在编译后,对析构函数的名称统一处理成destructor,这样一来,他们的名称就相同了,从而构成了重写
#include <iostream>
using namespace std;
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* p = new Student;
delete p;
return 0;
}
输出结果:
由结果也可分析,子类析构函数构成了重写,多态调用了子类的析构函数,在继承中我们讲过,调用完子类的析构函数,会自动调用父类的析构函数,所以也会输出父类的结果。
在这里,有一个问题没解决的是,正因为编译器会在编译后,对析构函数的名称统一处理成destructor,那么对于父子类中的析构函数构成什么关系,普通调用析构函数又会发生什么?
首先,父类的析构函数可以认为和子类析构函数是同名的,那么父类的析构函数在子类中就构成了隐藏。普通调用只跟对象类型有关了,对象属于谁的,就调用谁的。
例如(非多态):
#include <iostream>
using namespace std;
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* ps = new Person;
delete ps;//普通调用只跟对象类型有关,其类型为父类类型指针,调用的就是父类的析构
Person* p = new Student;
delete p;//普通调用只跟对象类型有关,其类型为父类类型指针,调用的就是父类的析构
Student* s = new Student;
delete s;//普通调用只跟对象类型有关,其类型为子类类型指针,调用的就是子类的析构
//但调用完又会自动调用父类的析构
return 0;
}
输出结果:
除了上述虚函数的各种“异变”,那么虚函数还存在一些额外的修饰,具体是干嘛的,见下
2.3虚函数的扩展(override+final)
C++11引入了两个新的关键字,override和final。其作用是为了检测函数重写是否出现漏洞,当函数重写出现了bug,其只有在运行时才能发现错误,为了提高检测错误效率,这两个关键字就可以派上用处。
①override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。其加在函数重写后面
例如(错误例子):
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun()
{}
};
class Student : public Person
{
public:
void FUN() override//未重写父类虚函数,编译报错
{}
};
int main()
{
return 0;
}
编译报错:
②final:修饰虚函数,表示该虚函数不能再被重写。这就隔绝了函数重写可能带来的错误。
例如(错误例子):
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun() final//其子类虚函数不能构成重写,否则编译报错
{}
};
class Student : public Person
{
public:
void fun()
{}
};
int main()
{
return 0;
}
编译报错:
学习完了虚函数,接下来对虚函数的概念可能产生的冲突做一个总结。
2.4重载、重写(覆盖)、隐藏(重定义)的对比
这里有一点注意的是,重写虽然也叫覆盖,但虚函数重写是语法层的概念,覆盖是原理层(虚基表)的概念,这里先打个预防针,在后面会讲解。
OK,你以为虚函数就全部了解完了吗,你错了,纯虚函数才是法外狂徒。
三、抽象类
3.1概念
在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象,确保了抽象类中的纯虚函数不会被直接访问的安全性,其派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
例子:
class Person
{
public:
virtual void fun() = 0
{
cout << "谁都不能改变我" << endl;
}
};
class Student : public Person
{
public:
void fun()//重写纯虚函数
{
cout << "我要回家" << endl;
}
};
class Assistant : public Person
{
public:
void fun()//重写纯虚函数
{
cout << "我要回家找妈妈" << endl;
}
};
int main()
{
//Person sp;抽象类不能实例化出对象
Person* p = new Student;
p->fun();
Person* ps = new Assistant;
ps->fun();
return 0;
}
输出结果:
那么什么又是接口继承呢?
3.2接口继承和实现继承
普通函数的继承是一种实现继承,派生类继承基类,那么派生类就可以使用基类已经实现好的函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,构成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。
现在可以说是虚函数的完结了,但是对于多态的调用究竟是如何实现只了解了片面,对于其底层的挖掘还有一段距离,那么现在就来揭秘。
四、多态的原理
4.1虚函数表及指针(原理层上的覆盖+虚表存储位置)
在虚拟继承中,我们学习了虚基表指针和虚基表。对于多态呢就有这样一个概念,虚函数指针和虚函数表。他们是干啥的呢?先看 一段代码
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
virtual void fun2()
{
cout << "Person::fun2()" << endl;
}
private:
int _num = 0;
};
int main()
{
Person p;
cout << sizeof(p) << endl;
return 0;
}
输出结果:
根据往常的理解,其大小应该是4,计算的是_num成员的大小呀,那为什么是16呢, 实则不然,其还有其他成员那就是虚函数表:
调试监口:
通过调试监口发现对象p不只有_num成员还有_vfptr。那么其中_vfptr就是虚函数表,虚函数表又简称虚表,虚函数表存放的是虚函数表指针,指针的值是虚函数地址,虚函数表指针指向的是虚函数的地址,其次,每个含有虚函数的类都有一个虚函数表。所以我们在计算对象p大小的时候还得计算虚表的大小。虚表的对齐数为8,_num的对齐数为4,大小为12,再根据对齐的最后一条规则取最大对齐数的倍数,总大小为16。
原理层上的覆盖:
OK,了解了虚函数表和虚函数表指针的概念之后,接着就来延续上面提到的预防针,即原理层上的覆盖。当子类重写虚函数,则在子类虚表中,就会对子类中原先的父类虚函数进行覆盖。
例如:
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
virtual void fun2()
{
cout << "Person::fun2()" << endl;
}
private:
int _num = 0;
};
class Student : public Person
{
public:
virtual void fun1()//重写纯虚函数
{
cout << "Student::fun1()" << endl;
}
private:
int _id = 1;
};
int main()
{
Person p;
Student s;
return 0;
}
调试监口:
内存角度观察虚函数表成员:
对此可以对虚函数表做一个总结拓展:
1.虚函数表本质是一个存虚函数指针的指针数组,不是虚函数的其地址就不会存放到虚函数表中。一般情况这个数组最后面放了一个nullptr,在调试中我们看不到这个空指针,但看虚函数表的成员个数也可明确,同时也可通过内存观察出。
2. 派生类的虚表生成是先将基类中的虚表内容拷贝一份到派生类虚表中,如果派生类重写基类虚函数,则用派生类自己的虚函数覆盖派生类虚表中基类虚函数。
3.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
virtual void fun2()
{
cout << "Person::fun2()" << endl;
}
private:
int _num = 0;
};
class Student : public Person
{
public:
virtual void fun1()//重写纯虚函数
{
cout << "Student::fun1()" << endl;
}
virtual void fun3()
{
cout << "Student::fun3()" << endl;
}
virtual void fun4()
{
cout << "Student::fun4()" << endl;
}
private:
int _id = 1;
};
int main()
{
Student s;
return 0;
}
4.同一个类实例化出的对象共用一张虚表
对于虚函数表大家可能还会存在一个误区:
我们知道,成员函数是存放在代码区,那么虚函数也是如此,因为虚函数也是成员函数,有疑问的是虚表是存放在哪的?在调试结果中可以看到虚表似乎是在一个对象当中的,当真如此吗?实则不然,在VS下,通过验证虚表其实也是存放在代码区的。通过打印虚表地址进行验证。
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
virtual void fun2()
{
cout << "Person::fun2()" << endl;
}
private:
int _num = 0;
};
class Student : public Person
{
public:
virtual void fun1()//重写纯虚函数
{
cout << "Student::fun1()" << endl;
}
virtual void fun3()
{
cout << "Student::fun3()" << endl;
}
virtual void fun4()
{
cout << "Student::fun4()" << endl;
}
private:
int _id = 1;
};
int main()
{
int a;
printf("栈区:%p\n", &a);
int* p = (int*)malloc(sizeof(int)*1);
printf("堆区:%p\n", p);
static int b;
printf("静态区:%p\n", &b);
const char* q = "bit";
printf("常量区:%p\n", q);
Person pp;
void** ptr = ((void**)*(int*)(&pp));//取pp对象的地址,强转为int*,说明只能访问pp对象大小为4个字节,
//取到pp对象的头四个字节,即取到虚函数表
//虚函数表存的又是虚函数指针,虚函数的类型为void,所以虚函数指针的类型也为void*
//那么虚函数表的类型就相当于void**类型了,所以还要将取到的头四个字节强转成void**类型
printf("虚表地址: %p\n", ptr);
return 0;
}
pp对象空间抽象图:
输出结果:
调试结果:
通过打印,调试对比,可以看出虚表地址与常量区的地址最接近,而内存的地址划分大小变化是常量区/代码区往上增长,栈区往下增长的,由此可见虚表是存储在常量区/代码区的。
有了虚函数的理解,现在可以开展多态调用的原理了。
4.2多态调用的原理(动态绑定+静态绑定)
多态调用正是通过虚函数指针去调用对应的虚函数,父类指针或引用指向父类则通过父类中的虚函数指针调用父类的虚函数,父类指针或引用指向子类则通过子类中的虚函数指针调用子类的虚函数。
且多态调用是在运行时确定函数的调用,当父类指针或引用指向子类对象并调用方法时,会根据实际对象的类型来决定调用哪个方法的实现,这也体现了动态绑定的特性。而在编译时,编译器只知道引用变量的类型是父类类型,它无法确定该引用变量实际指向的是哪个子类对象,以及子类中是否有覆盖(重写)了父类方法的实现,因此,编译时只能根据引用变量的类型来决定调用哪些方法
而普通调用则是在编译时就确定了函数的地址并调用该函数,体现了静态绑定的特性。
动态绑定:又称为后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
静态绑定:又称为前期绑定(早绑定),在程序变压器间确定了程序的行为,也称为静态多态 。
还是这个例子:
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
private:
int _num = 0;
};
class Student : public Person
{
public:
virtual void fun1()//重写纯虚函数
{
cout << "Student::fun1()" << endl;
}
private:
int _id = 1;
};
int main()
{
//多态调用
Person pp;
Person* p = &pp;
p->fun1();
//静态调用
Person ps;
Student s;
ps = s;
ps.fun1();
return 0;
}
下面通过汇编来进行浅浅的观察:
多态调用:
静态调用:
了解完多态原理,就要来进行更深层次的研究,那就是对于多继承关系而言中的虚函数表。
五、单继承和多继承关系的虚函数表
5.1单继承中的虚函数表
在前面就已经知道在监视窗口中,并不能查看虚函数表的所有成员,这影响了对代码的理解性,这可以认为是一个小bug,但是可以通过内存的角度来观察其成员,那么这里还有另一种方式来进行演示,就是打印虚函函数表成员的地址,进行对比
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
virtual void fun2()
{
cout << "Person::fun2()" << endl;
}
private:
int _num = 0;
};
class Student : public Person
{
public:
virtual void fun1()//重写纯虚函数
{
cout << "Student::fun1()" << endl;
}
virtual void fun3()
{
cout << "Student::fun3()" << endl;
}
virtual void fun4()
{
cout << "Student::fun4()" << endl;
}
private:
int _id = 1;
};
int main()
{
Person p;
Student s;
return 0;
}
调试监口只能观察两个成员:
通过打印虚表成员地址:
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
virtual void fun2()
{
cout << "Person::fun2()" << endl;
}
private:
int _num = 0;
};
class Student : public Person
{
public:
virtual void fun1()//重写纯虚函数
{
cout << "Student::fun1()" << endl;
}
virtual void fun3()
{
cout << "Student::fun3()" << endl;
}
virtual void fun4()
{
cout << "Student::fun4()" << endl;
}
private:
int _id = 1;
};
typedef void(*VFPTR)();//定义函数指针,并重命名
void PrintVTable(VFPTR arr[])//void(*)() arr[] == void**
{
cout << " 虚表地址>" << arr << endl;
for (int i = 0; arr[i] != nullptr; i++)
{
printf("第%d个虚函数地址:0x%x,->", i, arr[i]);//打印虚函数表中的内容
VFPTR f = arr[i];//取到虚函数地址
f();//回调虚函数,例如:(&fun1)();调用虚函数
}
}
int main()
{
Person p;
PrintVTable((VFPTR*)(*(int*)(&p)));//(*(int*)(&P))//取p对象的地址,强转为int*,说明只能访问p对象大小为4个字节,
//取到P对象的头四个字节,即取到虚函数表
//虚函数表存的又是虚函数指针,虚函数的类型为void,所以虚函数指针的类型也为void*
//那么虚函数表的类型就相当于void**类型了,所以还要将取到的头四个字节强转成void**类型
cout << endl;
Student s;
PrintVTable((VFPTR*)(*(int*)(&s)));
return 0;
}
输出结果:
通过打印成员地址,就可以观察出虚表中的所有成员。 也观察出了,子类fun1对父类中的fun1进行了覆盖。有一个问题需注意的是编译器有时对虚表的处理并不干净,导致虚表最后面没有放nullptr,打印时崩溃,此时重新生成解决方案即可。
5.2多继承中的虚函数表
#include <iostream>
using namespace std;
class Person
{
public:
virtual void fun1()
{
cout << "Person::fun1()" << endl;
}
virtual void fun2()
{
cout << "Person::fun2()" << endl;
}
private:
int _num = 0;
};
class Student
{
public:
virtual void fun1()
{
cout << "Student::fun1()" << endl;
}
virtual void fun2()
{
cout << "Student::fun2()" << endl;
}
private:
int _id = 1;
};
class Assistant : public Person, public Student
{
public:
virtual void fun1()//重写纯虚函数
{
cout << "Assistant::fun1()" << endl;
}
virtual void fun3()
{
cout << "Assistant::fun3()" << endl;
}
private:
int _a = 0;
};
typedef void(*VFPTR)();//定义函数指针,并重命名
void PrintVTable(VFPTR arr[])//void(*)() arr[] == void**
{
cout << " 虚表地址>" << arr << endl;
for (int i = 0; arr[i] != nullptr; i++)
{
printf("第%d个虚函数地址:0x%x,->", i, arr[i]);//打印虚函数表中的内容
VFPTR f = arr[i];//取到虚函数地址
f();//回调虚函数,例如:(&fun1)();调用虚函数
}
}
int main()
{
Assistant a;
PrintVTable((VFPTR*)(*(int*)(&a)));
cout << endl;
PrintVTable((VFPTR*)(*(int*)((char*)&a + sizeof(Person))));
//(char*)&a的意思是可以访问a对象的大小为一个字节,而a对象中的头四个字节是父类虚表,
//则该表达式的意思是指访问的大小为a对象中父类虚表中的第一个元素
//sizeof(Person)计算Person类的大小,包括虚表大小和成员大小,即(char*)&a + sizeof(Person)表示跳过了一个Person大小
//即跳到了Student的虚表,所以该整个表达式在打印Student中虚表成员
//结合下面的图解可以更好理解
return 0;
}
调试结果:
输出结果:
抽象图:
结合以上,可以观察出,在多继承中,派生类重写虚函数fun1、fun2,则派生类中父类中的虚函数表对应被覆盖。未重写的虚函数fun3放在了第一个继承父类部分的虚函数表中。
对于多继承中的菱形继承和菱形虚拟继承在实际中并不建议去使用,一般也不用过多的去研究。
end~